fix:修改举报/反馈的bug
This commit is contained in:
parent
440a7ad7d2
commit
958c867309
@ -165,7 +165,7 @@ func Load() *Config {
|
||||
StarbookServiceURL: getEnv("DUBBO_STARBOOK_SERVICE_URL", "tri://127.0.0.1:20007"),
|
||||
AIChatServiceURL: getEnv("DUBBO_AI_CHAT_SERVICE_URL", "tri://127.0.0.1:20008"),
|
||||
StatisticServiceURL: getEnv("DUBBO_STATISTIC_SERVICE_URL", "tri://127.0.0.1:20009"),
|
||||
NotificationServiceURL: getEnv("DUBBO_NOTIFICATION_SERVICE_URL", "tri://127.0.0.1:20008"),
|
||||
NotificationServiceURL: getEnv("DUBBO_NOTIFICATION_SERVICE_URL", "tri://127.0.0.1:20010"),
|
||||
ModerationServiceURL: getEnv("DUBBO_MODERATION_SERVICE_URL", "tri://127.0.0.1:20011"),
|
||||
},
|
||||
JWT: JWTConfig{
|
||||
|
||||
@ -846,7 +846,7 @@ func (ctrl *AssetController) GetAssetStatus(c *gin.Context) {
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param type query string false "上传类型: avatar/asset,默认asset"
|
||||
// @Param type query string false "上传类型: avatar/asset/report/feedback,默认asset"
|
||||
// @Param order_id query string false "订单ID(可选)。不传则后端生成,并在响应中返回,用于后续铸造全流程唯一标识"
|
||||
// @Success 200 {object} response.Response{data=object{order_id=string,policy=string,security_token=string,x_oss_signature_version=string,x_oss_credential=string,x_oss_date=string,signature=string,host=string,dir=string,key=string,expire_time=int}} "成功返回上传签名信息"
|
||||
// @Failure 400 {object} response.Response "参数错误"
|
||||
@ -869,8 +869,13 @@ func (ctrl *AssetController) GetOSSUploadSignature(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 3. 验证上传类型
|
||||
if uploadType != "avatar" && uploadType != "asset" {
|
||||
response.Error(c, http.StatusBadRequest, "参数错误: type 必须是 avatar 或 asset")
|
||||
// spec §9.1:type 仅允许 avatar / asset / report / feedback,OSS 端按 type 锁定目录前缀
|
||||
// - avatar → avatar/(后端生成唯一 key)
|
||||
// - asset → asset/<uid>/<sid>/(铸造素材)
|
||||
// - report → report/(举报证据命名空间独立)
|
||||
// - feedback → feedback/(反馈截图命名空间独立)
|
||||
if uploadType != "avatar" && uploadType != "asset" && uploadType != "report" && uploadType != "feedback" {
|
||||
response.Error(c, http.StatusBadRequest, "参数错误: type 必须是 avatar / asset / report / feedback 之一")
|
||||
return
|
||||
}
|
||||
|
||||
@ -1008,8 +1013,10 @@ func (ctrl *AssetController) generateOSSPolicyToken(
|
||||
// 根据上传类型获取基础目录
|
||||
baseDir := ossConfig.GetUploadDir(uploadType)
|
||||
// 动态生成上传目录(基于用户ID和上传类型)
|
||||
// spec §9.1:仅 avatar / asset 走 <uid>/<sid>/ 子目录;
|
||||
// report / feedback 用平铺的 report/ / feedback/ 目录,避免与 asset/<uid>/<sid>/ 命名空间交叉。
|
||||
uploadDir := baseDir
|
||||
if userID != nil && starID != nil {
|
||||
if (uploadType == "avatar" || uploadType == "asset") && userID != nil && starID != nil {
|
||||
uploadDir = fmt.Sprintf("%s%d/%d/", baseDir, userID, starID)
|
||||
}
|
||||
|
||||
@ -1023,6 +1030,7 @@ func (ctrl *AssetController) generateOSSPolicyToken(
|
||||
return ctrl.generateOSSPolicyTokenWithKey(ossConfig, uploadDir+uniqueFilename)
|
||||
}
|
||||
|
||||
// asset / report / feedback:仅锁定 dir 前缀,前端可在该目录内自取文件名
|
||||
return ctrl.generateOSSPolicyTokenWithDir(ossConfig, uploadDir)
|
||||
}
|
||||
|
||||
@ -1255,7 +1263,7 @@ func calculateSignature(signingKey []byte, stringToSign string) string {
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param type query string true "类型: avatar/asset"
|
||||
// @Param type query string true "类型: avatar/asset/report/feedback"
|
||||
// @Param file_name query string true "文件名或完整OSS key/URL"
|
||||
// @Param expires query int false "过期时间(秒),默认3600,最大86400"
|
||||
// @Success 200 {object} response.Response
|
||||
@ -1267,7 +1275,7 @@ func (ctrl *AssetController) GetOSSPresignedURL(c *gin.Context) {
|
||||
expiresStr := c.DefaultQuery("expires", "3600") // 默认1小时
|
||||
|
||||
if uploadType == "" {
|
||||
response.BadRequest(c, "type参数不能为空(avatar 或 asset)")
|
||||
response.BadRequest(c, "type参数不能为空(avatar / asset / report / feedback)")
|
||||
return
|
||||
}
|
||||
|
||||
@ -1277,8 +1285,8 @@ func (ctrl *AssetController) GetOSSPresignedURL(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 验证上传类型
|
||||
if uploadType != "avatar" && uploadType != "asset" {
|
||||
response.BadRequest(c, "type参数无效,必须是 avatar 或 asset")
|
||||
if uploadType != "avatar" && uploadType != "asset" && uploadType != "report" && uploadType != "feedback" {
|
||||
response.BadRequest(c, "type参数无效,必须是 avatar / asset / report / feedback 之一")
|
||||
return
|
||||
}
|
||||
|
||||
@ -1525,7 +1533,7 @@ func (ctrl *AssetController) generatePresignedURL(
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param type query string true "类型: avatar/asset"
|
||||
// @Param type query string true "类型: avatar/asset/report/feedback"
|
||||
// @Param expires query int false "过期时间(秒),默认3600"
|
||||
// @Param max_keys query int false "最大返回数量,默认100"
|
||||
// @Success 200 {object} response.Response
|
||||
@ -1537,13 +1545,13 @@ func (ctrl *AssetController) GetOSSBatchPresignedURLs(c *gin.Context) {
|
||||
maxKeysStr := c.DefaultQuery("max_keys", "100") // 最大返回数量
|
||||
|
||||
if uploadType == "" {
|
||||
response.BadRequest(c, "type参数不能为空(avatar 或 asset)")
|
||||
response.BadRequest(c, "type参数不能为空(avatar / asset / report / feedback)")
|
||||
return
|
||||
}
|
||||
|
||||
// 验证上传类型
|
||||
if uploadType != "avatar" && uploadType != "asset" {
|
||||
response.BadRequest(c, "type参数无效,必须是 avatar 或 asset")
|
||||
if uploadType != "avatar" && uploadType != "asset" && uploadType != "report" && uploadType != "feedback" {
|
||||
response.BadRequest(c, "type参数无效,必须是 avatar / asset / report / feedback 之一")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -598,9 +598,17 @@ type adminCreateNotificationRequest struct {
|
||||
}
|
||||
|
||||
// adminCreateNotificationResponse 响应 DTO,字段对齐 admin 的 CreateSystemNotificationResp。
|
||||
// Errors 字段收集失败 user_id + 首条 error 字符串(至多前 5 条),让 admin 端能感知
|
||||
// 单 user 失败(以前只 logger.Warn + continue,Python 端拿不到任何信号)。
|
||||
type adminCreateNotificationResponse struct {
|
||||
Affected int64 `json:"affected"`
|
||||
TargetCount int `json:"target_count"`
|
||||
Affected int64 `json:"affected"`
|
||||
TargetCount int `json:"target_count"`
|
||||
Errors []adminNotifError `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
type adminNotifError struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Err string `json:"err"`
|
||||
}
|
||||
|
||||
// AdminCreateNotification 批量创建通知 + 触发手机推送。
|
||||
@ -635,6 +643,7 @@ func (ctrl *NotificationController) AdminCreateNotification(g *gin.Context) {
|
||||
defer cancel()
|
||||
|
||||
var success int64
|
||||
var errs []adminNotifError
|
||||
for _, uid := range req.UserIDs {
|
||||
_, err := ctrl.notifService.CreateNotification(ctx, &pbNotif.CreateNotificationRequest{
|
||||
UserId: uid,
|
||||
@ -649,6 +658,10 @@ func (ctrl *NotificationController) AdminCreateNotification(g *gin.Context) {
|
||||
zap.Int64("user_id", uid),
|
||||
zap.String("type", req.Type),
|
||||
zap.Error(err))
|
||||
// 把前 5 条失败 user 塞到响应里,让 admin 端能感知(以前只 log,Python 拿不到)
|
||||
if len(errs) < 5 {
|
||||
errs = append(errs, adminNotifError{UserID: uid, Err: err.Error()})
|
||||
}
|
||||
continue
|
||||
}
|
||||
success++
|
||||
@ -657,10 +670,16 @@ func (ctrl *NotificationController) AdminCreateNotification(g *gin.Context) {
|
||||
logger.Logger.Info("admin notification batch done",
|
||||
zap.Int("requested", len(req.UserIDs)),
|
||||
zap.Int64("affected", success),
|
||||
zap.String("type", req.Type))
|
||||
zap.String("type", req.Type),
|
||||
zap.Int("errors", len(errs)))
|
||||
|
||||
response.Success(g, adminCreateNotificationResponse{
|
||||
resp := adminCreateNotificationResponse{
|
||||
Affected: success,
|
||||
TargetCount: len(req.UserIDs),
|
||||
})
|
||||
Errors: errs,
|
||||
}
|
||||
if len(errs) == 0 {
|
||||
resp.Errors = nil // 全部成功时不要带空数组,保持响应轻量
|
||||
}
|
||||
response.Success(g, resp)
|
||||
}
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
package config
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ModerationConfig 举报反馈服务配置 (spec §11.1)
|
||||
type ModerationConfig struct {
|
||||
@ -23,6 +27,9 @@ type ModerationConfig struct {
|
||||
AutoArchiveAfter time.Duration
|
||||
RedisKeyPrefix string
|
||||
PathDCounterPolicy string
|
||||
// OSSBaseURL OSS bucket 公开访问的 host(不含末尾斜杠),用于把证据 oss_key 拼成完整 URL 写入 oss_url 列。
|
||||
// 由环境变量 OSS_BASE_URL 覆盖(main.go)。
|
||||
OSSBaseURL string
|
||||
}
|
||||
|
||||
var Default = ModerationConfig{
|
||||
@ -45,4 +52,31 @@ var Default = ModerationConfig{
|
||||
AutoArchiveAfter: 90 * 24 * time.Hour,
|
||||
RedisKeyPrefix: "mod:report",
|
||||
PathDCounterPolicy: "preserve",
|
||||
OSSBaseURL: "https://top-fans-test.oss-cn-shanghai.aliyuncs.com",
|
||||
}
|
||||
|
||||
// BuildOSSURL 把 oss_key 还原成可访问的完整 URL。
|
||||
// 入参已是 http(s):// 开头则原样返回(兼容历史上传时就传完整 URL 的客户端);
|
||||
// 否则按 OSSBaseURL/<key> 拼接。返回空串表示入参为空。
|
||||
func BuildOSSURL(ossBaseURL, key string) string {
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(key, "http://") || strings.HasPrefix(key, "https://") {
|
||||
return key
|
||||
}
|
||||
base := strings.TrimRight(ossBaseURL, "/")
|
||||
if base == "" {
|
||||
return key
|
||||
}
|
||||
return base + "/" + strings.TrimLeft(key, "/")
|
||||
}
|
||||
|
||||
// LoadEnvOverrides 用环境变量覆盖 Default 里的可调字段。
|
||||
func LoadEnvOverrides() ModerationConfig {
|
||||
c := Default
|
||||
if v := os.Getenv("OSS_BASE_URL"); v != "" {
|
||||
c.OSSBaseURL = v
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
@ -92,7 +92,7 @@ func main() {
|
||||
}
|
||||
|
||||
// 初始化 Config
|
||||
cfg := config.Default
|
||||
cfg := config.LoadEnvOverrides()
|
||||
|
||||
// 初始化 Service
|
||||
categorySvc := service.NewCategoryService(repo)
|
||||
|
||||
@ -75,9 +75,11 @@ func (s *FeedbackService) SubmitFeedback(ctx context.Context, req *pb.SubmitFeed
|
||||
}
|
||||
|
||||
for i, key := range req.EvidenceKeys {
|
||||
fullURL := config.BuildOSSURL(s.cfg.OSSBaseURL, key)
|
||||
ev := &models.FeedbackEvidence{
|
||||
FeedbackID: feedback.ID,
|
||||
OSSKey: key,
|
||||
OSSURL: strPtrOrNil(fullURL),
|
||||
SortOrder: int32(i),
|
||||
CreatedAt: now,
|
||||
}
|
||||
|
||||
10
backend/services/moderationService/service/helpers.go
Normal file
10
backend/services/moderationService/service/helpers.go
Normal file
@ -0,0 +1,10 @@
|
||||
package service
|
||||
|
||||
// strPtrOrNil 把字符串转成 *string;空串返回 nil,以便 GORM 把 NULL 写入可空列。
|
||||
// 用于证据 oss_url 等"可空字符串"字段,避免出现空字符串而非 NULL 的脏数据。
|
||||
func strPtrOrNil(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
@ -149,9 +149,11 @@ func (s *ReportService) SubmitReport(ctx context.Context, req *pb.SubmitReportRe
|
||||
return err
|
||||
}
|
||||
for i, key := range req.EvidenceKeys {
|
||||
fullURL := config.BuildOSSURL(s.cfg.OSSBaseURL, key)
|
||||
ev := &models.ReportEvidence{
|
||||
ReportID: report.ID,
|
||||
OSSKey: key,
|
||||
OSSURL: strPtrOrNil(fullURL),
|
||||
SortOrder: int32(i),
|
||||
CreatedAt: now,
|
||||
}
|
||||
|
||||
@ -161,7 +161,8 @@ onMounted(async () => {
|
||||
const assetTags = Array.isArray(asset.tags) ? asset.tags : []
|
||||
const dataTags = Array.isArray(data.tags) ? data.tags : []
|
||||
// 工艺标记(展示走 showCoverCard;is_laser 供详情/扩展用)
|
||||
const isLaser =
|
||||
// 用局部变量 isLaserFlag 避免与顶层 ref isLaser 同名遮蔽
|
||||
const isLaserFlag =
|
||||
data.is_laser ||
|
||||
assetTags.includes('craft:laser') ||
|
||||
dataTags.includes('craft:laser')
|
||||
@ -173,18 +174,18 @@ onMounted(async () => {
|
||||
remark: asset.remark || data.remark || '',
|
||||
materialType: asset.material_type || data.materialType || '',
|
||||
is_lenticular: asset.is_lenticular || data.is_lenticular || false,
|
||||
is_laser: isLaser,
|
||||
is_laser: isLaserFlag,
|
||||
bg_image: bgImageUrl,
|
||||
};
|
||||
console.log('[success] nftData 设置后:', nftData.value);
|
||||
// 镭射卡:读取预设索引和抠图
|
||||
if (isLaser && typeof data.laser_preset_index === 'number') {
|
||||
if (isLaserFlag && typeof data.laser_preset_index === 'number') {
|
||||
laserPresetIndex.value = data.laser_preset_index
|
||||
}
|
||||
if (isLaser && data.laser_cutout_path) {
|
||||
if (isLaserFlag && data.laser_cutout_path) {
|
||||
laserCutoutPath.value = data.laser_cutout_path
|
||||
}
|
||||
isLaser.value = isLaser
|
||||
isLaser.value = isLaserFlag
|
||||
// 如果是光栅卡,构建图层
|
||||
if ((asset.is_lenticular || data.is_lenticular) && bgImageUrl && imageUrl) {
|
||||
isLenticular.value = true
|
||||
|
||||
@ -82,7 +82,7 @@
|
||||
class="input-field"
|
||||
v-model="content"
|
||||
placeholder="请输入具体内容"
|
||||
placeholder-class="input-placeholder"
|
||||
placeholder-style="color: rgba(255, 249, 231, 0.6); font-size: 26rpx;"
|
||||
:maxlength="200"
|
||||
auto-height
|
||||
></textarea>
|
||||
@ -94,7 +94,7 @@
|
||||
class="input-field input-field--single"
|
||||
v-model="title"
|
||||
placeholder="请输入反馈标题(必填,最多 100 字)"
|
||||
placeholder-class="input-placeholder"
|
||||
placeholder-style="color: rgba(255, 249, 231, 0.6); font-size: 26rpx;"
|
||||
:maxlength="100"
|
||||
/>
|
||||
</view>
|
||||
@ -105,7 +105,7 @@
|
||||
class="input-field input-field--single"
|
||||
v-model="contact"
|
||||
placeholder="联系方式(选填,邮箱/手机/微信)"
|
||||
placeholder-class="input-placeholder"
|
||||
placeholder-style="color: rgba(255, 249, 231, 0.6); font-size: 26rpx;"
|
||||
:maxlength="320"
|
||||
/>
|
||||
</view>
|
||||
@ -626,11 +626,6 @@ const handleConfirm = async () => {
|
||||
text-shadow: -1rpx 1rpx 4rpx rgba(0, 0, 0, 0.84);
|
||||
}
|
||||
|
||||
.input-placeholder {
|
||||
color: rgba(255, 249, 231, 0.6) !important;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
/* 按钮 */
|
||||
.button-row {
|
||||
display: flex;
|
||||
|
||||
@ -88,7 +88,7 @@
|
||||
class="input-field"
|
||||
v-model="content"
|
||||
placeholder="请输入具体内容"
|
||||
placeholder-class="input-placeholder"
|
||||
placeholder-style="color: rgba(255, 249, 231, 0.6); font-size: 26rpx;"
|
||||
:maxlength="200"
|
||||
auto-height
|
||||
></textarea>
|
||||
@ -290,10 +290,11 @@ const handleConfirm = async () => {
|
||||
submitting.value = true;
|
||||
try {
|
||||
// 如果选择了本地图片但还没上传到 OSS,先直传
|
||||
// spec §9.1:type 传 'report',OSS 端把证据锁到 report/ 前缀,与 asset/<uid>/<sid>/ 命名空间完全隔离
|
||||
if (imagePath.value && !imageUrl.value) {
|
||||
try {
|
||||
const upRes = await uploadLocalFileToOss(imagePath.value, {
|
||||
type: "asset",
|
||||
type: "report",
|
||||
});
|
||||
imageUrl.value = upRes?.imageUrl || "";
|
||||
} catch (e) {
|
||||
@ -643,11 +644,6 @@ const handleConfirm = async () => {
|
||||
text-shadow: -1rpx 1rpx 4rpx rgba(0, 0, 0, 0.84);
|
||||
}
|
||||
|
||||
.input-placeholder {
|
||||
color: rgba(255, 249, 231, 0.6) !important;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
/* 按钮 */
|
||||
.button-row {
|
||||
display: flex;
|
||||
|
||||
@ -487,8 +487,12 @@ export function removeAssetFromGalleryApi(slotId) {
|
||||
/**
|
||||
* 获取 OSS 浏览器直传凭证(PostObject 表单字段)
|
||||
* 对应网关:GET /api/v1/assets/oss/upload-signature(与 /oss/signature 同一处理逻辑,此处走阶段一推荐路径)
|
||||
* @param {'avatar'|'asset'} [type='asset'] 上传目录类型
|
||||
* @param {string} [orderId] 可选;传入则与后续 POST /api/v1/assets/mints 使用同一 order_id(后端 InitMintOrder)
|
||||
* @param {'avatar'|'asset'|'report'|'feedback'} [type='asset'] 上传目录类型
|
||||
* - avatar → avatar/(头像注册/编辑场景)
|
||||
* - asset → asset/<uid>/<sid>/(铸造素材)
|
||||
* - report → report/(举报证据命名空间,与 asset 隔离)
|
||||
* - feedback → feedback/(反馈截图命名空间,与 asset 隔离)
|
||||
* @param {string} [orderId] 可选;仅 asset 类型会用于铸造,后续 POST /api/v1/assets/mints 使用同一 order_id(后端 InitMintOrder)
|
||||
*/
|
||||
export function getOssSignatureApi(type = 'asset', orderId = '') {
|
||||
let url = `/api/v1/assets/oss/upload-signature?type=${encodeURIComponent(type)}`
|
||||
@ -520,6 +524,9 @@ export function getPublicOssSignatureApi(scene, key) {
|
||||
/**
|
||||
* 获取 OSS 预签名 GET URL(私有桶读图)
|
||||
* 对应网关:GET /api/v1/assets/oss/presigned-url
|
||||
* @param {string} fileName 文件名 / OSS key / 完整 OSS URL
|
||||
* @param {number} [expires=3600] 过期时间(秒),最大 86400
|
||||
* @param {'avatar'|'asset'|'report'|'feedback'} [type='avatar'] 命名空间类型
|
||||
*/
|
||||
export function getOssPresignedUrlApi(fileName, expires = 3600, type = 'avatar') {
|
||||
return request({
|
||||
@ -542,7 +549,8 @@ export function getBatchOssPresignedUrlsApi(files, expires = 3600, type = 'asset
|
||||
/**
|
||||
* 使用网关下发的 Post 策略将本地临时文件直传 OSS(App / 小程序等)
|
||||
* @param {string} filePath uni.chooseImage 等返回的 tempFilePaths 项
|
||||
* @param {{ type?: 'avatar'|'asset', orderId?: string, fileName?: string }} [options]
|
||||
* @param {{ type?: 'avatar'|'asset'|'report'|'feedback', orderId?: string, fileName?: string }} [options]
|
||||
* type 与 getOssSignatureApi 一致;report/feedback 用于举报证据 / 反馈截图
|
||||
* @returns {Promise<{ imageUrl: string, orderId?: string, data: object }>}
|
||||
*/
|
||||
export function uploadLocalFileToOss(filePath, options = {}) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user