From 958c86730979db264cec5b41457f12299eb9ff84 Mon Sep 17 00:00:00 2001 From: zerosaturation Date: Mon, 6 Jul 2026 16:22:26 +0800 Subject: [PATCH] =?UTF-8?q?fix:=E4=BF=AE=E6=94=B9=E4=B8=BE=E6=8A=A5/?= =?UTF-8?q?=E5=8F=8D=E9=A6=88=E7=9A=84bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/gateway/config/config.go | 2 +- .../gateway/controller/asset_controller.go | 32 ++++++++++------- .../controller/notification_controller.go | 29 ++++++++++++--- .../config/moderation_config.go | 36 ++++++++++++++++++- backend/services/moderationService/main.go | 2 +- .../service/feedback_service.go | 2 ++ .../moderationService/service/helpers.go | 10 ++++++ .../service/report_service.go | 2 ++ frontend/pages/castlove/success.vue | 11 +++--- frontend/pages/components/FeedbackModal.vue | 11 ++---- frontend/pages/components/ReportModal.vue | 10 ++---- frontend/utils/api.js | 14 ++++++-- 12 files changed, 118 insertions(+), 43 deletions(-) create mode 100644 backend/services/moderationService/service/helpers.go diff --git a/backend/gateway/config/config.go b/backend/gateway/config/config.go index a09240f..d88583f 100644 --- a/backend/gateway/config/config.go +++ b/backend/gateway/config/config.go @@ -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{ diff --git a/backend/gateway/controller/asset_controller.go b/backend/gateway/controller/asset_controller.go index 1b9d5b4..2b1ed6e 100644 --- a/backend/gateway/controller/asset_controller.go +++ b/backend/gateway/controller/asset_controller.go @@ -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///(铸造素材) + // - 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 走 // 子目录; + // report / feedback 用平铺的 report/ / feedback/ 目录,避免与 asset/// 命名空间交叉。 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 } diff --git a/backend/gateway/controller/notification_controller.go b/backend/gateway/controller/notification_controller.go index 8f37c15..061daa3 100644 --- a/backend/gateway/controller/notification_controller.go +++ b/backend/gateway/controller/notification_controller.go @@ -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) } diff --git a/backend/services/moderationService/config/moderation_config.go b/backend/services/moderationService/config/moderation_config.go index d72f458..745d340 100644 --- a/backend/services/moderationService/config/moderation_config.go +++ b/backend/services/moderationService/config/moderation_config.go @@ -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/ 拼接。返回空串表示入参为空。 +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 } diff --git a/backend/services/moderationService/main.go b/backend/services/moderationService/main.go index 97353ec..ce8ebd1 100644 --- a/backend/services/moderationService/main.go +++ b/backend/services/moderationService/main.go @@ -92,7 +92,7 @@ func main() { } // 初始化 Config - cfg := config.Default + cfg := config.LoadEnvOverrides() // 初始化 Service categorySvc := service.NewCategoryService(repo) diff --git a/backend/services/moderationService/service/feedback_service.go b/backend/services/moderationService/service/feedback_service.go index 1e805e2..630baa3 100644 --- a/backend/services/moderationService/service/feedback_service.go +++ b/backend/services/moderationService/service/feedback_service.go @@ -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, } diff --git a/backend/services/moderationService/service/helpers.go b/backend/services/moderationService/service/helpers.go new file mode 100644 index 0000000..dcb79d3 --- /dev/null +++ b/backend/services/moderationService/service/helpers.go @@ -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 +} \ No newline at end of file diff --git a/backend/services/moderationService/service/report_service.go b/backend/services/moderationService/service/report_service.go index 5efc87c..32028a0 100644 --- a/backend/services/moderationService/service/report_service.go +++ b/backend/services/moderationService/service/report_service.go @@ -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, } diff --git a/frontend/pages/castlove/success.vue b/frontend/pages/castlove/success.vue index ef51758..646b3e0 100644 --- a/frontend/pages/castlove/success.vue +++ b/frontend/pages/castlove/success.vue @@ -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 diff --git a/frontend/pages/components/FeedbackModal.vue b/frontend/pages/components/FeedbackModal.vue index 8212ae7..03f6976 100644 --- a/frontend/pages/components/FeedbackModal.vue +++ b/frontend/pages/components/FeedbackModal.vue @@ -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 > @@ -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" /> @@ -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" /> @@ -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; diff --git a/frontend/pages/components/ReportModal.vue b/frontend/pages/components/ReportModal.vue index fc3c549..bdebdcc 100644 --- a/frontend/pages/components/ReportModal.vue +++ b/frontend/pages/components/ReportModal.vue @@ -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 > @@ -290,10 +290,11 @@ const handleConfirm = async () => { submitting.value = true; try { // 如果选择了本地图片但还没上传到 OSS,先直传 + // spec §9.1:type 传 'report',OSS 端把证据锁到 report/ 前缀,与 asset/// 命名空间完全隔离 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; diff --git a/frontend/utils/api.js b/frontend/utils/api.js index 5a27768..82e67e9 100644 --- a/frontend/utils/api.js +++ b/frontend/utils/api.js @@ -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///(铸造素材) + * - 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 = {}) {