fix:修改举报/反馈的bug

This commit is contained in:
zerosaturation 2026-07-06 16:22:26 +08:00
parent 440a7ad7d2
commit 958c867309
12 changed files with 118 additions and 43 deletions

View File

@ -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{

View File

@ -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.1type 仅允许 avatar / asset / report / feedbackOSS 端按 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
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -92,7 +92,7 @@ func main() {
}
// 初始化 Config
cfg := config.Default
cfg := config.LoadEnvOverrides()
// 初始化 Service
categorySvc := service.NewCategoryService(repo)

View File

@ -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,
}

View 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
}

View File

@ -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,
}

View File

@ -161,7 +161,8 @@ onMounted(async () => {
const assetTags = Array.isArray(asset.tags) ? asset.tags : []
const dataTags = Array.isArray(data.tags) ? data.tags : []
// showCoverCardis_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

View File

@ -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;

View File

@ -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.1type '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;

View File

@ -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 策略将本地临时文件直传 OSSApp / 小程序等
* @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 = {}) {