From ade24530834b8d46628b63c6c0793b81f87bb70e Mon Sep 17 00:00:00 2001 From: zhengchiliu <857493511@qq.com> Date: Tue, 15 Sep 2026 16:02:45 +0800 Subject: [PATCH] bugfix --- README.md | 8 +- docs/code-review-2026-09-15.md | 1 + internal/dto/outfit.go | 26 + internal/mysqldao/clothes.go | 42 ++ internal/mysqldao/collect.go | 42 +- internal/mysqldao/like.go | 28 + internal/mysqldao/outfit.go | 22 + internal/mysqldao/outfit_share.go | 12 +- internal/redisdao/notification.go | 23 - internal/redisdao/virtualpay.go | 67 ++ internal/redismodel/virtualpay.go | 9 + internal/routers/api/v1/outfit.go | 21 + internal/routers/router.go | 2 + internal/service/airecommend.go | 3 +- internal/service/closet.go | 1 + internal/service/clothes.go | 31 +- internal/service/collect.go | 34 +- internal/service/like.go | 29 +- internal/service/notification.go | 56 +- internal/service/order.go | 11 +- internal/service/outfit.go | 117 ++++ internal/service/outfit_share.go | 28 +- internal/service/service.go | 34 +- internal/service/user.go | 34 +- internal/service/virtualpay_compensate.go | 100 ++- main.go | 9 + pkg/migration/000001_create_users.down.sql | 1 - pkg/migration/000001_create_users.up.sql | 28 - pkg/migration/000001_init.down.sql | 53 ++ pkg/migration/000001_init.up.sql | 622 ++++++++++++++++++ pkg/migration/000002_create_clothes.down.sql | 1 - pkg/migration/000002_create_clothes.up.sql | 36 - pkg/migration/000003_create_outfits.down.sql | 2 - pkg/migration/000003_create_outfits.up.sql | 37 -- .../000004_create_closet_shares.down.sql | 2 - .../000004_create_closet_shares.up.sql | 31 - .../000005_create_user_quotas.down.sql | 2 - .../000005_create_user_quotas.up.sql | 32 - .../000006_create_quota_flow_logs.down.sql | 1 - .../000006_create_quota_flow_logs.up.sql | 24 - pkg/migration/000007_create_goods.down.sql | 1 - pkg/migration/000007_create_goods.up.sql | 28 - pkg/migration/000008_create_orders.down.sql | 1 - pkg/migration/000008_create_orders.up.sql | 37 -- .../000009_create_refund_flow_logs.down.sql | 1 - .../000009_create_refund_flow_logs.up.sql | 25 - .../000010_create_vip_flow_logs.down.sql | 1 - .../000010_create_vip_flow_logs.up.sql | 21 - ...000011_create_benefit_grant_tasks.down.sql | 1 - .../000011_create_benefit_grant_tasks.up.sql | 21 - .../000012_create_ai_flow_logs.down.sql | 2 - .../000012_create_ai_flow_logs.up.sql | 49 -- .../000013_create_interactions.down.sql | 2 - .../000013_create_interactions.up.sql | 33 - .../000014_create_user_notifications.down.sql | 1 - .../000014_create_user_notifications.up.sql | 20 - .../000015_create_horoscopes.down.sql | 1 - pkg/migration/000015_create_horoscopes.up.sql | 20 - .../000016_create_audit_logs.down.sql | 2 - pkg/migration/000016_create_audit_logs.up.sql | 31 - .../000017_create_quota_grants.down.sql | 1 - .../000017_create_quota_grants.up.sql | 20 - ...018_create_closet_share_receivers.down.sql | 1 - ...00018_create_closet_share_receivers.up.sql | 15 - .../000019_create_outfit_shares.down.sql | 2 - .../000019_create_outfit_shares.up.sql | 31 - .../000020_harden_benefit_workflows.down.sql | 3 - .../000020_harden_benefit_workflows.up.sql | 14 - pkg/tokenhub/outfit_recommend.go | 18 +- 接口文档.md | 83 ++- 70 files changed, 1441 insertions(+), 707 deletions(-) create mode 100644 internal/redisdao/virtualpay.go create mode 100644 internal/redismodel/virtualpay.go delete mode 100644 pkg/migration/000001_create_users.down.sql delete mode 100644 pkg/migration/000001_create_users.up.sql create mode 100644 pkg/migration/000001_init.down.sql create mode 100644 pkg/migration/000001_init.up.sql delete mode 100644 pkg/migration/000002_create_clothes.down.sql delete mode 100644 pkg/migration/000002_create_clothes.up.sql delete mode 100644 pkg/migration/000003_create_outfits.down.sql delete mode 100644 pkg/migration/000003_create_outfits.up.sql delete mode 100644 pkg/migration/000004_create_closet_shares.down.sql delete mode 100644 pkg/migration/000004_create_closet_shares.up.sql delete mode 100644 pkg/migration/000005_create_user_quotas.down.sql delete mode 100644 pkg/migration/000005_create_user_quotas.up.sql delete mode 100644 pkg/migration/000006_create_quota_flow_logs.down.sql delete mode 100644 pkg/migration/000006_create_quota_flow_logs.up.sql delete mode 100644 pkg/migration/000007_create_goods.down.sql delete mode 100644 pkg/migration/000007_create_goods.up.sql delete mode 100644 pkg/migration/000008_create_orders.down.sql delete mode 100644 pkg/migration/000008_create_orders.up.sql delete mode 100644 pkg/migration/000009_create_refund_flow_logs.down.sql delete mode 100644 pkg/migration/000009_create_refund_flow_logs.up.sql delete mode 100644 pkg/migration/000010_create_vip_flow_logs.down.sql delete mode 100644 pkg/migration/000010_create_vip_flow_logs.up.sql delete mode 100644 pkg/migration/000011_create_benefit_grant_tasks.down.sql delete mode 100644 pkg/migration/000011_create_benefit_grant_tasks.up.sql delete mode 100644 pkg/migration/000012_create_ai_flow_logs.down.sql delete mode 100644 pkg/migration/000012_create_ai_flow_logs.up.sql delete mode 100644 pkg/migration/000013_create_interactions.down.sql delete mode 100644 pkg/migration/000013_create_interactions.up.sql delete mode 100644 pkg/migration/000014_create_user_notifications.down.sql delete mode 100644 pkg/migration/000014_create_user_notifications.up.sql delete mode 100644 pkg/migration/000015_create_horoscopes.down.sql delete mode 100644 pkg/migration/000015_create_horoscopes.up.sql delete mode 100644 pkg/migration/000016_create_audit_logs.down.sql delete mode 100644 pkg/migration/000016_create_audit_logs.up.sql delete mode 100644 pkg/migration/000017_create_quota_grants.down.sql delete mode 100644 pkg/migration/000017_create_quota_grants.up.sql delete mode 100644 pkg/migration/000018_create_closet_share_receivers.down.sql delete mode 100644 pkg/migration/000018_create_closet_share_receivers.up.sql delete mode 100644 pkg/migration/000019_create_outfit_shares.down.sql delete mode 100644 pkg/migration/000019_create_outfit_shares.up.sql delete mode 100644 pkg/migration/000020_harden_benefit_workflows.down.sql delete mode 100644 pkg/migration/000020_harden_benefit_workflows.up.sql diff --git a/README.md b/README.md index f2cdbf3..4b390df 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ zt-closet-backend/ │ ├── lzcwyjwt/ # JWT 生成/解析 │ ├── matting/ # 抠图服务客户端 │ ├── migration/ # 数据库迁移:embed 迁移脚本 + Up/Down/Steps/Version/Force -│ │ # (目录下直接存放 000001~000016 版本化 SQL,up 建表/down 删表) +│ │ # (目录下只存放单一 000001_init,up 建全量表/down 删全量表) │ ├── setting/ # viper 配置读取与配置结构体 │ ├── tokenhub/ # 腾讯 Tokenhub 大模型网关客户端(运势/AI 推荐生成 + 调度) │ ├── tracing/ # OpenTelemetry 初始化与日志桥接 @@ -130,7 +130,11 @@ go run ./cmd/migrate down # 全量回滚(按依赖倒序删表,慎用 go run ./cmd/migrate force 3 # 迁移中断导致 dirty 时,强制修正版本号 ``` -迁移脚本位于 `pkg/migration`,共 17 个版本、22 张表,以 `go:embed` 打包进二进制;迁移记录保存在 `schema_migrations` 表。字段与索引以 `internal/mysqlmodel/` 的 GORM 模型为准,ER 图见 `design/zt-closet-er.drawio`。 +迁移脚本位于 `pkg/migration`,**单一版本**(`000001_init`)包含全部 **27 张业务表**,以 `go:embed` 打包进二进制;迁移记录保存在 `schema_migrations` 表。 + +> 维护约定:项目当前**无线上数据**,因此不使用增量迁移(ALTER/UPDATE)。结构变更直接修改 `000001_init.up.sql` 里对应表的 `CREATE TABLE` 语句,并同步 `000001_init.down.sql`。 + +字段与索引以 `internal/mysqlmodel/` 的 GORM 模型为准,ER 图见 `design/zt-closet-er.drawio`。 ### 4. 启动 AI 推理服务(可选,推荐) diff --git a/docs/code-review-2026-09-15.md b/docs/code-review-2026-09-15.md index 935670a..0ec4f01 100644 --- a/docs/code-review-2026-09-15.md +++ b/docs/code-review-2026-09-15.md @@ -42,6 +42,7 @@ 9. **收到的穿搭分享在取消或转私后仍泄露内容** - 接收列表联查分享和穿搭,仅返回分享正常、穿搭公开、审核通过且状态正常的记录。 + - ⚠️ 后续策略变更(见 `接口文档.md` §5.8):「分享给好友」与「公开到社区」已解耦,仅自己可见的穿搭同样可分享;接收列表与分享详情改为**只排除已删除 / 已封禁的穿搭**,不再按 `is_public` / `audit_status` 过滤。此处"转私后不再下发"的旧结论已作废,请勿据此回退代码。 10. **穿搭、AI 穿搭和衣柜分享的主记录与明细非事务写入** - 创建、更新重建明细和删除统一使用数据库事务,明细失败时主记录一并回滚。 diff --git a/internal/dto/outfit.go b/internal/dto/outfit.go index c235a02..9ec9822 100644 --- a/internal/dto/outfit.go +++ b/internal/dto/outfit.go @@ -67,6 +67,32 @@ type OutfitListResponse struct { List []OutfitCreateResponse `json:"list"` } +// OutfitCommunityListRequest 社区穿搭列表请求 +type OutfitCommunityListRequest struct { + PageRequest +} + +// OutfitCommunityListResponse 社区穿搭列表响应 +type OutfitCommunityListResponse struct { + PageResponse + List []OutfitCommunityItem `json:"list"` +} + +// OutfitCommunityItem 社区穿搭列表项(含作者信息与互动计数) +type OutfitCommunityItem struct { + OutfitUUID string `json:"outfit_uuid"` + OutfitName string `json:"outfit_name"` + OutfitDesc string `json:"outfit_desc"` + Scene string `json:"scene"` + Source int8 `json:"source"` // 1=用户创建 2=AI推荐 + CreatedAt int64 `json:"created_at"` // 秒级时间戳 + Items []OutfitItemResponse `json:"items"` + OwnerName string `json:"owner_name"` + OwnerAvatar string `json:"owner_avatar"` + LikeCount int64 `json:"like_count"` + CollectCount int64 `json:"collect_count"` +} + // OutfitDeleteRequest 删除穿搭 type OutfitDeleteRequest struct { OutfitUUID string `json:"outfit_uuid" binding:"required"` diff --git a/internal/mysqldao/clothes.go b/internal/mysqldao/clothes.go index 9bb2193..a6bc4c5 100644 --- a/internal/mysqldao/clothes.go +++ b/internal/mysqldao/clothes.go @@ -55,6 +55,48 @@ func (d *ClothesDao) ClothesUpdateByUUID(ctx context.Context, clothesUUID string return nil } +// ClothesUpdateByUUIDIfStatusIn 条件更新衣物:仅当当前图片处理状态在 statuses 内时才更新,返回是否更新成功。 +// 用于「处理成功回填」这类必须与状态守卫绑定的写操作:恢复任务可能已把该记录置为失败并回退额度, +// 无条件写回会把状态改回成功,造成「额度已退但衣物成功」的不一致。 +func (d *ClothesDao) ClothesUpdateByUUIDIfStatusIn(ctx context.Context, clothesUUID string, statuses []int8, updates map[string]any) (bool, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.ClothesUpdateByUUIDIfStatusIn") + defer span.End() + + res := d.db.WithContext(ctx).Model(&mysqlmodel.Clothes{}). + Where("clothes_uuid = ? AND image_process_status IN ?", clothesUUID, statuses). + Updates(updates) + if res.Error != nil { + span.RecordError(res.Error) + return false, res.Error + } + return res.RowsAffected > 0, nil +} + +// ClothesReferencedCount 统计衣物被「穿搭明细 / 衣柜分享明细」引用的数量(删除前校验,>0 表示不可删除)。 +// 删除被引用的衣物会留下悬挂引用,公开穿搭与分享会变成空壳(明细里查不到衣物,前端静默跳过)。 +func (d *ClothesDao) ClothesReferencedCount(ctx context.Context, clothesUUID string) (int64, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.ClothesReferencedCount") + defer span.End() + + subQuery := "clothes_id IN (SELECT id FROM clothes WHERE clothes_uuid = ? AND deleted_at = 0)" + + var total int64 + if err := d.db.WithContext(ctx).Model(&mysqlmodel.OutfitItem{}). + Where(subQuery, clothesUUID).Count(&total).Error; err != nil { + span.RecordError(err) + return 0, err + } + if total > 0 { + return total, nil + } + if err := d.db.WithContext(ctx).Model(&mysqlmodel.ClosetShareItem{}). + Where(subQuery, clothesUUID).Count(&total).Error; err != nil { + span.RecordError(err) + return 0, err + } + return total, nil +} + // ClothesGetByUUID 按 UUID 查询衣物 func (d *ClothesDao) ClothesGetByUUID(ctx context.Context, clothesUUID string) (*mysqlmodel.Clothes, error) { ctx, span := global.Tracer.Start(ctx, "mysqldao.ClothesGetByUUID") diff --git a/internal/mysqldao/collect.go b/internal/mysqldao/collect.go index 7509e89..f8a7a83 100644 --- a/internal/mysqldao/collect.go +++ b/internal/mysqldao/collect.go @@ -86,13 +86,48 @@ func (d *CollectDao) CollectCountByTarget(ctx context.Context, collectType int8, return count, nil } -// CollectListTargetIDsByUserPage 分页查询用户收藏的目标ID列表(仅 status=1),返回 ids 与总数。 +// CollectCountByTargets 批量统计多个目标的收藏数(仅 status=1),返回 targetID -> count;空入参短路。 +func (d *CollectDao) CollectCountByTargets(ctx context.Context, collectType int8, targetIDs []int64) (map[int64]int64, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.CollectCountByTargets") + defer span.End() + + counts := make(map[int64]int64, len(targetIDs)) + if len(targetIDs) == 0 { + return counts, nil + } + + var rows []struct { + TargetID int64 `gorm:"column:target_id"` + Cnt int64 `gorm:"column:cnt"` + } + if err := d.db.WithContext(ctx).Model(&mysqlmodel.Collect{}). + Select("target_id, COUNT(*) AS cnt"). + Where("collect_type = ? AND target_id IN ? AND status = ?", collectType, targetIDs, int8(1)). + Group("target_id"). + Scan(&rows).Error; err != nil { + span.RecordError(err) + return nil, err + } + for _, r := range rows { + counts[r.TargetID] = r.Cnt + } + return counts, nil +} + +// CollectListTargetIDsByUserPage 分页查询用户收藏的目标ID列表(仅 status=1,且目标穿搭对本人可见),返回 ids 与总数。 +// +// 可见性口径与 service.isOutfitPublicVisible 保持一致:本人收藏自己的穿搭始终可见; +// 收藏他人穿搭要求「公开(2) + 审核通过(2) + 状态正常(1)」。 +// 联表过滤而非服务层过滤,是为了让 total 与实际返回条数口径一致 +//(此前只按 collects 计数,穿搭转私/删除后会返回 total 虚高、页内条数偏少的空洞页)。 func (d *CollectDao) CollectListTargetIDsByUserPage(ctx context.Context, collectType int8, userID int64, page, pageSize int) ([]int64, int64, error) { ctx, span := global.Tracer.Start(ctx, "mysqldao.CollectListTargetIDsByUserPage") defer span.End() q := d.db.WithContext(ctx).Model(&mysqlmodel.Collect{}). - Where("collect_type = ? AND user_id = ? AND status = ?", collectType, userID, int8(1)) + Joins("JOIN outfits o ON o.id = collects.target_id AND o.deleted_at = 0"). + Where("collects.collect_type = ? AND collects.user_id = ? AND collects.status = ?", collectType, userID, int8(1)). + Where("o.user_id = ? OR (o.is_public = ? AND o.audit_status = ? AND o.status = ?)", userID, 2, 2, 1) var total int64 if err := q.Count(&total).Error; err != nil { @@ -101,7 +136,8 @@ func (d *CollectDao) CollectListTargetIDsByUserPage(ctx context.Context, collect } var ids []int64 - if err := q.Order("id DESC").Offset((page-1)*pageSize).Limit(pageSize).Pluck("target_id", &ids).Error; err != nil { + if err := q.Order("collects.id DESC").Offset((page-1)*pageSize).Limit(pageSize). + Pluck("collects.target_id", &ids).Error; err != nil { span.RecordError(err) return nil, 0, err } diff --git a/internal/mysqldao/like.go b/internal/mysqldao/like.go index 0d9d3f6..5758213 100644 --- a/internal/mysqldao/like.go +++ b/internal/mysqldao/like.go @@ -86,6 +86,34 @@ func (d *LikeDao) LikeCountByTarget(ctx context.Context, likeType int8, targetID return count, nil } +// LikeCountByTargets 批量统计多个目标的点赞数(仅 status=1),返回 targetID -> count;空入参短路。 +func (d *LikeDao) LikeCountByTargets(ctx context.Context, likeType int8, targetIDs []int64) (map[int64]int64, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.LikeCountByTargets") + defer span.End() + + counts := make(map[int64]int64, len(targetIDs)) + if len(targetIDs) == 0 { + return counts, nil + } + + var rows []struct { + TargetID int64 `gorm:"column:target_id"` + Cnt int64 `gorm:"column:cnt"` + } + if err := d.db.WithContext(ctx).Model(&mysqlmodel.Like{}). + Select("target_id, COUNT(*) AS cnt"). + Where("like_type = ? AND target_id IN ? AND status = ?", likeType, targetIDs, int8(1)). + Group("target_id"). + Scan(&rows).Error; err != nil { + span.RecordError(err) + return nil, err + } + for _, r := range rows { + counts[r.TargetID] = r.Cnt + } + return counts, nil +} + // LikeListUserIDsByTarget 查询点赞用户ID列表(仅 status=1) func (d *LikeDao) LikeListUserIDsByTarget(ctx context.Context, likeType int8, targetID int64) ([]int64, error) { ctx, span := global.Tracer.Start(ctx, "mysqldao.LikeListUserIDsByTarget") diff --git a/internal/mysqldao/outfit.go b/internal/mysqldao/outfit.go index dc7c34e..67f0fc3 100644 --- a/internal/mysqldao/outfit.go +++ b/internal/mysqldao/outfit.go @@ -165,6 +165,28 @@ func (d *OutfitDao) OutfitListByUserID(ctx context.Context, userID int64, isPubl return list, total, nil } +// OutfitListCommunity 分页查询社区穿搭(其他用户「公开 + 审核通过 + 状态正常」的穿搭)。 +// 状态字面量与 service.isOutfitPublicVisible 保持一致:is_public 2=公开;audit_status 2=审核通过;status 1=正常。 +func (d *OutfitDao) OutfitListCommunity(ctx context.Context, excludeUserID int64, page, pageSize int) ([]mysqlmodel.Outfit, int64, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitListCommunity") + defer span.End() + + var list []mysqlmodel.Outfit + var total int64 + q := d.db.WithContext(ctx).Model(&mysqlmodel.Outfit{}). + Where("is_public = ? AND audit_status = ? AND status = ?", int8(2), int8(2), int8(1)). + Where("user_id != ?", excludeUserID) + if err := q.Count(&total).Error; err != nil { + span.RecordError(err) + return nil, 0, err + } + if err := q.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&list).Error; err != nil { + span.RecordError(err) + return nil, 0, err + } + return list, total, nil +} + // OutfitItemDao 穿搭明细数据访问 type OutfitItemDao struct { db *gorm.DB diff --git a/internal/mysqldao/outfit_share.go b/internal/mysqldao/outfit_share.go index 846c053..f3cc4f1 100644 --- a/internal/mysqldao/outfit_share.go +++ b/internal/mysqldao/outfit_share.go @@ -119,10 +119,18 @@ func (d *OutfitShareReceiverDao) OutfitShareReceivedList(ctx context.Context, re Joins("JOIN outfit_share_receivers r ON r.share_uuid = outfit_shares.share_uuid"). Joins("JOIN outfits o ON o.id = outfit_shares.outfit_id"). Where("r.receiver_user_id = ?", receiverUserID). - Where("outfit_shares.status = ?", 1). - Where("o.is_public = ? AND o.audit_status = ? AND o.status = ? AND o.deleted_at = 0", 2, 2, 1) + // 可见性:仅排除「已删除」与「已封禁」的穿搭。 + // 分享与公开状态解耦(仅自己可见的穿搭同样可分享),故不再按 is_public / audit_status 过滤; + // 穿搭删除或封禁后不再下发内容。 + Where("o.status <> ? AND o.deleted_at = 0", 2) if status != 0 { + // 显式筛选分享状态:1=正常 2=已取消。 + // 修复:此前这里无条件叠加 `outfit_shares.status = 1`,与下面的参数条件形成 + // `status = 1 AND status = 2`,导致传 status=2 恒返回空列表。 q = q.Where("outfit_shares.status = ?", status) + } else { + // 默认只返回正常分享:已取消的分享不应再出现在接收列表(保留「取消分享后不再下发」语义)。 + q = q.Where("outfit_shares.status = ?", 1) } if err := q.Count(&total).Error; err != nil { span.RecordError(err) diff --git a/internal/redisdao/notification.go b/internal/redisdao/notification.go index 92d8d0c..d5b0596 100644 --- a/internal/redisdao/notification.go +++ b/internal/redisdao/notification.go @@ -22,15 +22,6 @@ end return v `) -// notificationUnreadDecrScript 未读数 -1,保证结果不低于 0 -var notificationUnreadDecrScript = redis.NewScript(` -local v = redis.call('GET', KEYS[1]) -if v and tonumber(v) > 0 then - return redis.call('DECRBY', KEYS[1], 1) -end -return 0 -`) - // NotificationUnreadIncr 用户未读消息数 +1,返回最新值 func NotificationUnreadIncr(ctx context.Context, userID int64) (int64, error) { ctx, span := global.Tracer.Start(ctx, "redisdao.NotificationUnreadIncr") @@ -45,20 +36,6 @@ func NotificationUnreadIncr(ctx context.Context, userID int64) (int64, error) { return val, nil } -// NotificationUnreadDecr 用户未读消息数 -1,返回扣减后的值(不低于 0) -func NotificationUnreadDecr(ctx context.Context, userID int64) (int64, error) { - ctx, span := global.Tracer.Start(ctx, "redisdao.NotificationUnreadDecr") - defer span.End() - - key := fmt.Sprintf(redismodel.KeyNotificationUnread, userID) - val, err := notificationUnreadDecrScript.Run(ctx, global.RedisEngine, []string{key}).Int64() - if err != nil { - span.RecordError(err) - return 0, err - } - return val, nil -} - // NotificationUnreadGet 获取用户未读消息数,不存在时返回 0 func NotificationUnreadGet(ctx context.Context, userID int64) (int64, error) { ctx, span := global.Tracer.Start(ctx, "redisdao.NotificationUnreadGet") diff --git a/internal/redisdao/virtualpay.go b/internal/redisdao/virtualpay.go new file mode 100644 index 0000000..13a3fe0 --- /dev/null +++ b/internal/redisdao/virtualpay.go @@ -0,0 +1,67 @@ +package redisdao + +import ( + "context" + "fmt" + "time" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel" +) + +// VirtualPayUnpaidSet 标记某虚拟支付订单「最近一次查单确认未支付」,TTL 内不再重复查询。 +func VirtualPayUnpaidSet(ctx context.Context, orderNo string, ttl time.Duration) error { + ctx, span := global.Tracer.Start(ctx, "redisdao.VirtualPayUnpaidSet") + defer span.End() + + key := fmt.Sprintf(redismodel.KeyVirtualPayUnpaid, orderNo) + if err := global.RedisEngine.Set(ctx, key, 1, ttl).Err(); err != nil { + span.RecordError(err) + return err + } + return nil +} + +// VirtualPayUnpaidDel 清除负缓存(订单已支付/已处理后调用,让后续流程不再跳过它)。 +func VirtualPayUnpaidDel(ctx context.Context, orderNo string) error { + ctx, span := global.Tracer.Start(ctx, "redisdao.VirtualPayUnpaidDel") + defer span.End() + + key := fmt.Sprintf(redismodel.KeyVirtualPayUnpaid, orderNo) + if err := global.RedisEngine.Del(ctx, key).Err(); err != nil { + span.RecordError(err) + return err + } + return nil +} + +// VirtualPayUnpaidBatchGet 批量判断哪些订单命中负缓存。 +// 用一次 MGET 代替 N 次 GET,避免逐单往返 Redis 放大单轮耗时。 +func VirtualPayUnpaidBatchGet(ctx context.Context, orderNos []string) (map[string]bool, error) { + ctx, span := global.Tracer.Start(ctx, "redisdao.VirtualPayUnpaidBatchGet") + defer span.End() + + result := make(map[string]bool, len(orderNos)) + if len(orderNos) == 0 { + return result, nil + } + + keys := make([]string, 0, len(orderNos)) + for _, no := range orderNos { + keys = append(keys, fmt.Sprintf(redismodel.KeyVirtualPayUnpaid, no)) + } + vals, err := global.RedisEngine.MGet(ctx, keys...).Result() + if err != nil { + span.RecordError(err) + return nil, err + } + for i, v := range vals { + if i >= len(orderNos) { + break + } + if v != nil { + result[orderNos[i]] = true + } + } + return result, nil +} diff --git a/internal/redismodel/virtualpay.go b/internal/redismodel/virtualpay.go new file mode 100644 index 0000000..6e14007 --- /dev/null +++ b/internal/redismodel/virtualpay.go @@ -0,0 +1,9 @@ +package redismodel + +// 虚拟支付相关 Redis key。 +const ( + // KeyVirtualPayUnpaid 查单补偿的「已确认未支付」负缓存:order_no。 + // 命中表示最近一次 query_order 反查结果为未支付,本轮跳过该订单, + // 避免大量「下单后不付款」的积压订单长期占满扫描名额,把真正已支付的订单挤出去。 + KeyVirtualPayUnpaid = RedisPrefix + "xpay:unpaid:%s" +) diff --git a/internal/routers/api/v1/outfit.go b/internal/routers/api/v1/outfit.go index efc95fe..12da9c2 100644 --- a/internal/routers/api/v1/outfit.go +++ b/internal/routers/api/v1/outfit.go @@ -61,6 +61,27 @@ func (h *OutfitHandler) List(c *gin.Context) { response.ToResponse(out) } +// Community 分页查询社区穿搭(其他用户公开的穿搭)。 +func (h *OutfitHandler) Community(c *gin.Context) { + response := app.NewResponse(c) + userID, ok := middleware.GetUserID(c.Request.Context()) + if !ok { + response.ToErrorResponse(errcode.Unauthorized) + return + } + var request dto.OutfitCommunityListRequest + if err := c.ShouldBindQuery(&request); err != nil { + response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error())) + return + } + out, e := h.svc.CommunityList(c.Request.Context(), userID, &request) + if e != nil { + response.ToErrorResponse(e) + return + } + response.ToResponse(out) +} + // Get 获取穿搭详情(uuid 来自路径参数)。 func (h *OutfitHandler) Get(c *gin.Context) { response := app.NewResponse(c) diff --git a/internal/routers/router.go b/internal/routers/router.go index 2ba6a5b..169ac91 100644 --- a/internal/routers/router.go +++ b/internal/routers/router.go @@ -88,6 +88,8 @@ func NewRouter() *gin.Engine { // 穿搭 auth.POST("/outfit", outfitHandler.Create) auth.GET("/outfit", outfitHandler.List) + // 注意:静态路径 /outfit/community 必须先于 /outfit/:uuid 注册,避免被通配路由抢占 + auth.GET("/outfit/community", outfitHandler.Community) auth.GET("/outfit/:uuid", outfitHandler.Get) auth.PUT("/outfit/:uuid", outfitHandler.Update) auth.DELETE("/outfit/:uuid", outfitHandler.Delete) diff --git a/internal/service/airecommend.go b/internal/service/airecommend.go index 9cc55f3..6e5b6f7 100644 --- a/internal/service/airecommend.go +++ b/internal/service/airecommend.go @@ -88,7 +88,8 @@ func (s *AIRecommendService) Create(ctx context.Context, userID int64, request * // 5. 调用大模型生成推荐。 requestTime := nowUnix() client := tokenhub.NewClient() - result, gerr := client.GenerateOutfitRecommendation(ctx, zodiac, fortune, clothes) + // 用户自定义场景(scene_type=2)透传给模型;为空时由模型自行判断。 + result, gerr := client.GenerateOutfitRecommendation(ctx, zodiac, fortune, clothes, request.Scene) if gerr != nil { return nil, fail(errcode.AIRecommendFailed, "大模型调用失败") } diff --git a/internal/service/closet.go b/internal/service/closet.go index d244639..537ffe3 100644 --- a/internal/service/closet.go +++ b/internal/service/closet.go @@ -215,6 +215,7 @@ func (s *ClosetShareService) List(ctx context.Context, userID int64, request *dt ClothesCount: countByShare[sh.ShareUUID], ExpireTime: sh.ExpireTime, Status: sh.Status, + CreatedAt: sh.CreatedAt, }) } diff --git a/internal/service/clothes.go b/internal/service/clothes.go index 75ded7d..459224b 100644 --- a/internal/service/clothes.go +++ b/internal/service/clothes.go @@ -222,10 +222,24 @@ func (s *ClothesService) processImageAsync(parentCtx context.Context, userID int "fail_reason": "", "original_image_path": "", } - if uerr := dao.ClothesUpdateByUUID(ctx, clothesUUID, updates); uerr != nil { + // 带状态守卫回填:恢复任务可能已把该记录置为失败并回退了额度, + // 此时若无条件写回成功,会出现「额度已退但衣物成功」(白送额度)并残留 COS 对象。 + ok, uerr := dao.ClothesUpdateByUUIDIfStatusIn(ctx, clothesUUID, + []int8{ImageProcessMatting, ImageProcessClassifying, ImageProcessQueuing}, updates) + if uerr != nil { slog.ErrorContext(ctx, "update clothes after classify failed", "clothes_uuid", clothesUUID, "error", uerr) return } + if !ok { + // 记录已被置失败或已删除:本次结果作废,清理刚上传的 COS 对象避免孤儿。 + slog.WarnContext(ctx, "clothes no longer processing, discard result", + "clothes_uuid", clothesUUID, "cos_key", cosKey) + if derr := upload.FileDeleteCos(ctx, cosKey); derr != nil { + slog.ErrorContext(ctx, "delete discarded cos image failed", + "clothes_uuid", clothesUUID, "cos_key", cosKey, "error", derr) + } + return + } _ = os.Remove(originalPath) } @@ -370,8 +384,8 @@ func (s *ClothesService) assertClothesVisibleViaOutfitShare(ctx context.Context, } return errcode.DatabaseError } - // 访客查看要求穿搭仍公开可见(属主访问自己的分享始终可查看)。 - if share.UserID != userID && !isOutfitPublicVisible(outfit) { + // 访客只读查看:分享正常 + 穿搭未被封禁即可(分享与公开状态解耦,属主访问自己的分享始终可查看)。 + if share.UserID != userID && outfit.Status == OutfitStatusBanned { return errcode.OutfitShareNotFound } // 该衣物必须属于该分享的穿搭明细。 @@ -433,6 +447,17 @@ func (s *ClothesService) DeleteByUUID(ctx context.Context, userID int64, request return nil, cerr } + // 引用校验:已被穿搭明细 / 衣柜分享明细引用的衣物不允许删除,否则会留下悬挂引用, + // 公开穿搭与分享会变成「空壳」(明细里查不到衣物,前端静默跳过), + // 也与接口文档约定的 50001「衣服已被搭配使用,无法删除」保持一致。 + refCount, rerr := s.clothesDao.ClothesReferencedCount(ctx, request.ClothesUUID) + if rerr != nil { + return nil, errcode.DatabaseError + } + if refCount > 0 { + return nil, errcode.ClothesInUse + } + // 事务内锁定衣物行 -> 读最新状态 -> 软删除 -> 条件回退额度 -> 写回退流水, // 数值变更与流水同事务强一致,并避免与异步处理失败回退并发导致漏退/双退。 vipExpire := getUserVIPExpire(ctx, userID) diff --git a/internal/service/collect.go b/internal/service/collect.go index 04d9442..0849fca 100644 --- a/internal/service/collect.go +++ b/internal/service/collect.go @@ -2,6 +2,7 @@ package service import ( "context" + "log/slog" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto" @@ -100,6 +101,11 @@ func (s *CollectService) List(ctx context.Context, userID int64, request *dto.Co // 穿搭可能已删除,跳过。 continue } + // 越权防护(纵深防御):DAO 已按可见性联表过滤,此处再挡一次查询与读取之间 + // 发生的「转私密 / 被封禁 / 审核驳回」变更,避免下发他人非公开穿搭内容。 + if outfit.UserID != userID && !isOutfitPublicVisible(outfit) { + continue + } outfitItems, uerr := s.itemResponses(ctx, outfit.ID) if uerr != nil { continue @@ -133,19 +139,27 @@ func (s *CollectService) GetByTarget(ctx context.Context, userID int64, request } targetID := outfit.ID - // 是否已收藏:Redis 优先,回落 MySQL。 - isCollected, err := redisdao.CollectIsMember(ctx, CollectTypeOutfit, targetID, userID) - if err != nil { - if rec, derr := s.collectDao.CollectGetByTargetAndUser(ctx, CollectTypeOutfit, targetID, userID); derr == nil { - isCollected = rec.Status == InteractStatusOn + // 是否已收藏:以 MySQL 为权威。 + // Redis 集合带 TTL,过期后会被后续写入重建为「仅含新成员」,导致 SIsMember/SCard 长期低估, + // 因此只在 DB 异常时用 Redis 兜底(降级可用性优先于精确性)。 + isCollected := false + if rec, derr := s.collectDao.CollectGetByTargetAndUser(ctx, CollectTypeOutfit, targetID, userID); derr == nil { + isCollected = rec.Status == InteractStatusOn + } else if !isNotFound(derr) { + slog.WarnContext(ctx, "query collect record failed, fallback to redis", + "target_id", targetID, "user_id", userID, "error", derr) + if v, rerr := redisdao.CollectIsMember(ctx, CollectTypeOutfit, targetID, userID); rerr == nil { + isCollected = v } } - // 收藏总数:Redis 优先,回落 MySQL。 - count, err := redisdao.CollectCount(ctx, CollectTypeOutfit, targetID) - if err != nil || count == 0 { - if dbCnt, derr := s.collectDao.CollectCountByTarget(ctx, CollectTypeOutfit, targetID); derr == nil { - count = dbCnt + // 收藏总数:以 MySQL 为权威(同上,Redis 仅在 DB 异常时兜底)。 + count, derr := s.collectDao.CollectCountByTarget(ctx, CollectTypeOutfit, targetID) + if derr != nil { + slog.WarnContext(ctx, "count collect failed, fallback to redis", + "target_id", targetID, "error", derr) + if rcnt, rerr := redisdao.CollectCount(ctx, CollectTypeOutfit, targetID); rerr == nil { + count = rcnt } } diff --git a/internal/service/like.go b/internal/service/like.go index e835178..1b5f1b7 100644 --- a/internal/service/like.go +++ b/internal/service/like.go @@ -2,6 +2,7 @@ package service import ( "context" + "log/slog" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto" @@ -87,19 +88,27 @@ func (s *LikeService) GetByTargetID(ctx context.Context, userID int64, request * } targetID := outfit.ID - // 是否已点赞:Redis 优先,回落 MySQL。 - isLiked, err := redisdao.LikeIsMember(ctx, LikeTypeOutfit, targetID, userID) - if err != nil { - if rec, derr := s.likeDao.LikeGetByTargetAndUser(ctx, LikeTypeOutfit, targetID, userID); derr == nil { - isLiked = rec.Status == InteractStatusOn + // 是否已点赞:以 MySQL 为权威。 + // Redis 集合带 TTL,过期后会被后续写入重建为「仅含新成员」,导致 SIsMember/SCard 长期低估, + // 因此只在 DB 异常时用 Redis 兜底(降级可用性优先于精确性)。 + isLiked := false + if rec, derr := s.likeDao.LikeGetByTargetAndUser(ctx, LikeTypeOutfit, targetID, userID); derr == nil { + isLiked = rec.Status == InteractStatusOn + } else if !isNotFound(derr) { + slog.WarnContext(ctx, "query like record failed, fallback to redis", + "target_id", targetID, "user_id", userID, "error", derr) + if v, rerr := redisdao.LikeIsMember(ctx, LikeTypeOutfit, targetID, userID); rerr == nil { + isLiked = v } } - // 点赞总数:Redis 优先,回落 MySQL。 - count, err := redisdao.LikeCount(ctx, LikeTypeOutfit, targetID) - if err != nil || count == 0 { - if dbCnt, derr := s.likeDao.LikeCountByTarget(ctx, LikeTypeOutfit, targetID); derr == nil { - count = dbCnt + // 点赞总数:以 MySQL 为权威(同上,Redis 仅在 DB 异常时兜底)。 + count, derr := s.likeDao.LikeCountByTarget(ctx, LikeTypeOutfit, targetID) + if derr != nil { + slog.WarnContext(ctx, "count like failed, fallback to redis", + "target_id", targetID, "error", derr) + if rcnt, rerr := redisdao.LikeCount(ctx, LikeTypeOutfit, targetID); rerr == nil { + count = rcnt } } diff --git a/internal/service/notification.go b/internal/service/notification.go index 93e5c8a..f856586 100644 --- a/internal/service/notification.go +++ b/internal/service/notification.go @@ -84,43 +84,57 @@ func (s *NotificationService) Read(ctx context.Context, userID int64, request *d // 已读则直接返回当前未读数。 if n.IsRead == NotificationRead { - cnt, _ := redisdao.NotificationUnreadGet(ctx, userID) + cnt, cerr := s.unreadCount(ctx, userID) + if cerr != nil { + return nil, cerr + } return &dto.NotificationUnreadResponse{UnreadCount: cnt}, nil } - // 条件标记已读(仅未读 -> 已读),防并发重复扣减未读数。 - ok, err := s.notificationDao.NotificationUpdateReadByUUIDIfUnread(ctx, request.NotificationUUID) - if err != nil { + // 条件标记已读(仅未读 -> 已读),防并发重复标记。 + if _, err := s.notificationDao.NotificationUpdateReadByUUIDIfUnread(ctx, request.NotificationUUID); err != nil { return nil, errcode.DatabaseError } - if !ok { - // 已被并发标记已读,直接返回当前未读数。 - cnt, _ := redisdao.NotificationUnreadGet(ctx, userID) - return &dto.NotificationUnreadResponse{UnreadCount: cnt}, nil + // 无论本次是否真的标记成功,都按 DB 重新统计并回填缓存: + // Redis 计数在 key 过期后会被 INCR 从 1 重建,直接返回 Redis 的 decr 结果会长期少报。 + cnt, cerr := s.unreadCount(ctx, userID) + if cerr != nil { + return nil, cerr } - cnt, _ := redisdao.NotificationUnreadDecr(ctx, userID) - return &dto.NotificationUnreadResponse{UnreadCount: cnt}, nil } -// Unread 获取当前用户未读数,Redis 优先、回落 MySQL。 +// Unread 获取当前用户未读数(以 MySQL 为权威)。 func (s *NotificationService) Unread(ctx context.Context, userID int64) (*dto.NotificationUnreadResponse, *errcode.Error) { ctx, span := global.Tracer.Start(ctx, "svc.NotificationUnread") defer span.End() - cnt, err := redisdao.NotificationUnreadGet(ctx, userID) - if err == nil && cnt > 0 { - return &dto.NotificationUnreadResponse{UnreadCount: cnt}, nil + cnt, cerr := s.unreadCount(ctx, userID) + if cerr != nil { + return nil, cerr } + return &dto.NotificationUnreadResponse{UnreadCount: cnt}, nil +} - // Redis 为 0 或异常,回落 MySQL 统计。 - dbCnt, derr := s.notificationDao.NotificationCountUnread(ctx, userID) - if derr != nil { - return nil, errcode.DatabaseError +// unreadCount 以 MySQL 为权威统计未读数并回填 Redis 缓存。 +// 此前「Redis 优先、只有 0 或异常才回源」的策略在 key 过期后会长期少报 +//(key 过期 → 新通知 INCR 从 1 开始 → Unread 读到正数就不再回源),改为 DB 权威。 +// DB 异常时才用 Redis 兜底,保证降级可用性。 +func (s *NotificationService) unreadCount(ctx context.Context, userID int64) (int64, *errcode.Error) { + cnt, err := s.notificationDao.NotificationCountUnread(ctx, userID) + if err != nil { + if rcnt, rerr := redisdao.NotificationUnreadGet(ctx, userID); rerr == nil { + slog.WarnContext(ctx, "count unread from db failed, fallback to redis", + "user_id", userID, "error", err) + return rcnt, nil + } + return 0, errcode.DatabaseError } - // 回填 Redis 未读数缓存,避免 key 过期导致后续标记已读时扣减被吞。 - _ = redisdao.NotificationUnreadSet(ctx, userID, dbCnt) - return &dto.NotificationUnreadResponse{UnreadCount: dbCnt}, nil + if serr := redisdao.NotificationUnreadSet(ctx, userID, cnt); serr != nil { + slog.WarnContext(ctx, "backfill notification unread cache failed", + "user_id", userID, "error", serr) + } + return cnt, nil } // buildNotificationResponse 将通知模型映射为响应。 diff --git a/internal/service/order.go b/internal/service/order.go index aabd51a..525dac5 100644 --- a/internal/service/order.go +++ b/internal/service/order.go @@ -146,12 +146,21 @@ func (s *OrderService) PayNotify(ctx context.Context, request *http.Request) *er return errcode.DatabaseError } if data.Amount.Total != order.PayAmount { + // 金额不一致:不流转、不发放权益,人工介入。 + // 这里回的是成功应答(微信不再重试),所以必须把原始报文落库留痕, + // 否则这笔钱在系统里将无任何可追溯记录(callback_raw 是唯一能事后对账的字段)。 slog.ErrorContext(ctx, "pay notify: amount mismatch", "out_trade_no", data.OutTradeNo, "notify_amount", data.Amount.Total, "order_amount", order.PayAmount, ) - return nil // 金额不一致,不流转、不发放权益,人工介入。 + if uerr := s.orderDao.OrderUpdateByOrderNo(ctx, order.OrderNo, map[string]any{ + "callback_raw": data.Raw, + }); uerr != nil { + slog.ErrorContext(ctx, "pay notify: record mismatch callback raw failed", + "out_trade_no", data.OutTradeNo, "error", uerr) + } + return nil } // 条件流转:待支付 -> 已支付,防重复回调。 diff --git a/internal/service/outfit.go b/internal/service/outfit.go index efff0aa..cae2a98 100644 --- a/internal/service/outfit.go +++ b/internal/service/outfit.go @@ -22,6 +22,9 @@ type OutfitService struct { outfitItemDao *mysqldao.OutfitItemDao clothesDao *mysqldao.ClothesDao auditLogDao *mysqldao.OutfitAuditLogDao + userDao *mysqldao.UserDao + likeDao *mysqldao.LikeDao + collectDao *mysqldao.CollectDao } // NewOutfitService 创建穿搭服务。 @@ -31,6 +34,9 @@ func NewOutfitService() *OutfitService { outfitItemDao: mysqldao.NewOutfitItemDao(), clothesDao: mysqldao.NewClothesDao(), auditLogDao: mysqldao.NewOutfitAuditLogDao(), + userDao: mysqldao.NewUserDao(), + likeDao: mysqldao.NewLikeDao(), + collectDao: mysqldao.NewCollectDao(), } } @@ -175,6 +181,30 @@ func (s *OutfitService) List(ctx context.Context, userID int64, request *dto.Out }, nil } +// CommunityList 分页查询社区穿搭:其他用户「公开 + 审核通过 + 状态正常」的穿搭, +// 聚合作者信息与点赞/收藏计数后一次性下发(批量查询,避免 N+1)。 +func (s *OutfitService) CommunityList(ctx context.Context, userID int64, request *dto.OutfitCommunityListRequest) (*dto.OutfitCommunityListResponse, *errcode.Error) { + ctx, span := global.Tracer.Start(ctx, "svc.OutfitCommunityList") + defer span.End() + + page, pageSize := normalizePage(request.Page, request.PageSize) + + list, total, err := s.outfitDao.OutfitListCommunity(ctx, userID, page, pageSize) + if err != nil { + return nil, errcode.DatabaseError + } + + items, berr := s.buildCommunityItems(ctx, list) + if berr != nil { + return nil, errcode.DatabaseError + } + + return &dto.OutfitCommunityListResponse{ + PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize}, + List: items, + }, nil +} + // Delete 删除穿搭(软删除穿搭 + 清理明细)。 func (s *OutfitService) Delete(ctx context.Context, userID int64, request *dto.OutfitDeleteRequest) (*dto.OutfitDeleteResponse, *errcode.Error) { ctx, span := global.Tracer.Start(ctx, "svc.OutfitDelete") @@ -414,6 +444,93 @@ func (s *OutfitService) buildOutfitListResponses(ctx context.Context, list []mys return resps, nil } +// buildCommunityItems 批量组装社区穿搭列表项(明细/衣物/作者/互动计数均批量查询,避免 N+1)。 +func (s *OutfitService) buildCommunityItems(ctx context.Context, list []mysqlmodel.Outfit) ([]dto.OutfitCommunityItem, error) { + items := make([]dto.OutfitCommunityItem, 0, len(list)) + if len(list) == 0 { + return items, nil + } + + outfitIDs := make([]int64, 0, len(list)) + ownerIDs := make([]int64, 0, len(list)) + for _, o := range list { + outfitIDs = append(outfitIDs, o.ID) + ownerIDs = append(ownerIDs, o.UserID) + } + + // 1. 穿搭明细 + 衣物(批量)。 + allItems, err := s.outfitItemDao.OutfitItemListByOutfitIDs(ctx, outfitIDs) + if err != nil { + return nil, err + } + itemsByOutfit := make(map[int64][]int64, len(list)) + clothesIDSet := make(map[int64]struct{}, len(allItems)) + for _, it := range allItems { + itemsByOutfit[it.OutfitID] = append(itemsByOutfit[it.OutfitID], it.ClothesID) + clothesIDSet[it.ClothesID] = struct{}{} + } + clothesIDs := make([]int64, 0, len(clothesIDSet)) + for id := range clothesIDSet { + clothesIDs = append(clothesIDs, id) + } + clothesByID := make(map[int64]mysqlmodel.Clothes, len(clothesIDs)) + if len(clothesIDs) > 0 { + clothesList, cerr := s.clothesDao.ClothesGetByIDs(ctx, clothesIDs) + if cerr != nil { + return nil, cerr + } + for i := range clothesList { + clothesByID[clothesList[i].ID] = clothesList[i] + } + } + + // 2. 作者信息(批量;头像 COS key 统一转可访问 URL)。 + ownerByID := make(map[int64]mysqlmodel.User, len(ownerIDs)) + if owners, uerr := s.userDao.UserGetByIDs(ctx, ownerIDs); uerr == nil { + for i := range owners { + ownerByID[owners[i].ID] = owners[i] + } + } + + // 3. 点赞 / 收藏计数(批量)。 + likeCounts, lerr := s.likeDao.LikeCountByTargets(ctx, LikeTypeOutfit, outfitIDs) + if lerr != nil { + return nil, lerr + } + collectCounts, cerr := s.collectDao.CollectCountByTargets(ctx, CollectTypeOutfit, outfitIDs) + if cerr != nil { + return nil, cerr + } + + for i := range list { + o := &list[i] + oItems := make([]dto.OutfitItemResponse, 0, len(itemsByOutfit[o.ID])) + for _, cid := range itemsByOutfit[o.ID] { + if c, ok := clothesByID[cid]; ok { + oItems = append(oItems, buildOutfitItemResponse(&c)) + } + } + + item := dto.OutfitCommunityItem{ + OutfitUUID: o.OutfitUUID, + OutfitName: o.OutfitName, + OutfitDesc: o.OutfitDesc, + Scene: o.Scene, + Source: o.Source, + CreatedAt: o.CreatedAt, + Items: oItems, + LikeCount: likeCounts[o.ID], + CollectCount: collectCounts[o.ID], + } + if owner, ok := ownerByID[o.UserID]; ok { + item.OwnerName = owner.Username + item.OwnerAvatar = upload.AvatarURL(owner.Avatar) + } + items = append(items, item) + } + return items, nil +} + // buildOutfitItemResponse 组装穿搭明细(processed_image 转为可访问 URL)。 func buildOutfitItemResponse(c *mysqlmodel.Clothes) dto.OutfitItemResponse { return dto.OutfitItemResponse{ClothesUUID: c.ClothesUUID, ProcessedImage: clothesImageURL(c.ProcessedImage)} diff --git a/internal/service/outfit_share.go b/internal/service/outfit_share.go index 26de9fc..844dfda 100644 --- a/internal/service/outfit_share.go +++ b/internal/service/outfit_share.go @@ -34,7 +34,9 @@ func NewOutfitShareService() *OutfitShareService { } } -// Create 创建穿搭分享。仅能分享本人且已公开的穿搭;不消耗额度。 +// Create 创建穿搭分享。仅能分享本人的穿搭;分享与「公开到社区」解耦, +// 仅自己可见的穿搭同样可生成分享链接(好友凭 share_uuid 只读查看)。 +// 额度:私密穿搭分享扣 1 次衣柜分享额度(与衣柜分享共用额度池),已公开穿搭分享不扣。 func (s *OutfitShareService) Create(ctx context.Context, userID int64, request *dto.OutfitShareCreateRequest) (*dto.OutfitShareCreateResponse, *errcode.Error) { ctx, span := global.Tracer.Start(ctx, "svc.OutfitShareCreate") defer span.End() @@ -49,9 +51,18 @@ func (s *OutfitShareService) Create(ctx context.Context, userID int64, request * if outfit.UserID != userID { return nil, errcode.OutfitNoPermission } - // 分享前提:穿搭已公开可见(否则对方打开后无法查看)。 - if !isOutfitPublicVisible(outfit) { - return nil, errcode.InvalidParams.WithDetails("请先公开该穿搭再分享") + // 封禁穿搭不可分享;公开状态 / 审核状态不再作为前置条件(分享与公开到社区解耦)。 + if outfit.Status == OutfitStatusBanned { + return nil, errcode.OutfitNoPermission + } + + // 额度:仅「仅自己可见」的穿搭分享扣 1 次衣柜分享额度(与衣柜分享共用额度池); + // 已公开穿搭在「公开到社区」时已扣过社区分享额度,分享给好友不再重复扣。 + charged := outfit.IsPublic == OutfitPrivate + if charged { + if qerr := consumeDailyQuota(ctx, userID, QuotaTypeClosetShare, 1, "私密穿搭分享消耗"); qerr != nil { + return nil, qerr + } } share := &mysqlmodel.OutfitShare{ @@ -61,6 +72,10 @@ func (s *OutfitShareService) Create(ctx context.Context, userID int64, request * Status: ShareStatusNormal, } if err := s.shareDao.OutfitShareCreate(ctx, share); err != nil { + // 分享落库失败:回退刚扣的额度,避免用户额度被白扣。 + if charged { + refundDailyQuota(ctx, userID, QuotaTypeClosetShare, 1) + } return nil, errcode.DatabaseError } return &dto.OutfitShareCreateResponse{ShareUUID: share.ShareUUID}, nil @@ -92,8 +107,9 @@ func (s *OutfitShareService) Detail(ctx context.Context, userID int64, shareUUID } isOwner := share.UserID == userID - // 访客视角:穿搭必须仍处于公开可见状态,否则分享失效 - if !isOwner && !isOutfitPublicVisible(outfit) { + // 访客视角:分享正常即可只读查看(分享独立于公开状态,取消公开不影响已发出的分享); + // 仅当穿搭已删除(上方判为 90103)或已封禁时视为分享失效。 + if !isOwner && outfit.Status == OutfitStatusBanned { return nil, errcode.OutfitShareNotFound } diff --git a/internal/service/service.go b/internal/service/service.go index 77d9569..6395345 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -300,10 +300,16 @@ func consumeCosQuota(ctx context.Context, userID int64, n int64, relatedID int64 if delta > remain { delta = remain } - if err := tx.Model(&mysqlmodel.QuotaGrant{}). + res := tx.Model(&mysqlmodel.QuotaGrant{}). Where("id = ? AND used + ? <= amount", grants[i].ID, delta). - UpdateColumn("used", gorm.Expr("used + ?", delta)).Error; err != nil { - return err + UpdateColumn("used", gorm.Expr("used + ?", delta)) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + // 凭证在行锁下被并发改写(理论上不应发生)。此时若继续累加会少扣额度, + // 直接回滚整个事务并报额度不足,宁可让用户重试也不超额放行。 + return errcode.QuotaInsufficient } remain -= delta } @@ -415,25 +421,31 @@ func refundCosQuotaTx(tx *gorm.DB, userID int64, vipExpire int64, n int64, relat } remain -= delta } - if remain > 0 { - // 无足够已用额度可回退(数据异常),跳过写流水。 - return nil - } } - // 3. 写回退流水。 + // 3. 写回退流水:变更量取「实际回退量」。 + // 此前 `remain > 0` 时直接 return,导致额度已被部分改回却没有流水(账实不一致); + // 同时 ChangeNum 写的是请求量 n,退不满时数字虚高。现统一按实际回退量记账, + // 退不满/退不动时在原因里标注并告警(已用额度不足通常意味着重复回退或数据异常,必须留痕)。 + refunded := n - remain + flowReason := reason + if refunded < n { + flowReason = fmt.Sprintf("%s(实际回退 %d/%d,可用已退额度不足)", reason, refunded, n) + slog.WarnContext(context.Background(), "refund cos quota partially applied", + "user_id", userID, "want", n, "refunded", refunded, "related_id", relatedID) + } after := cosRemainTx(tx, userID, vipExpire, now) flow := &mysqlmodel.QuotaFlowLog{ QuotaFlowUUID: genUUID(), UserID: userID, ChangeTarget: QuotaChangeTargetUse, ChangeType: QuotaChangeTypeRefund, - ChangeNum: n, - OldValue: after - n, + ChangeNum: refunded, + OldValue: after - refunded, NewValue: after, QuotaType: QuotaTypeCos, RelatedID: relatedID, - Reason: reason, + Reason: flowReason, } // 回退类流水无业务订单号,用流水 UUID 填充 order_no 避免唯一索引空串冲突。 flow.OrderNo = flow.QuotaFlowUUID diff --git a/internal/service/user.go b/internal/service/user.go index 6e6853d..0e2fafb 100644 --- a/internal/service/user.go +++ b/internal/service/user.go @@ -7,6 +7,7 @@ import ( "io" "log/slog" "mime/multipart" + "os" "strings" "time" @@ -143,6 +144,7 @@ func (s *UserService) InfoGetBySelf(ctx context.Context, userID int64) (*dto.Use if user, err := redisdao.UserInfoGet(ctx, userID); err == nil && user != nil { info := buildUserInfoResponse(user) + s.consumeNewUserFlag(userID, &info) return &info, nil } else if err != nil && !isCacheMiss(err) { // 缓存异常不阻断,继续回落 DB。 @@ -158,9 +160,22 @@ func (s *UserService) InfoGetBySelf(ctx context.Context, userID int64) (*dto.Use _ = redisdao.UserInfoSet(ctx, user, 0) info := buildUserInfoResponse(user) + s.consumeNewUserFlag(userID, &info) return &info, nil } +// consumeNewUserFlag 消费「新用户」标记。 +// is_new_user 只在注册时写 true,此前没有任何复位路径,会让客户端「新用户引导」反复触发。 +// 首次读取用户信息即视为引导已消费:本次响应返回 false,并异步复位 DB(best-effort,失败只告警)。 +// 只在「获取用户信息」路径调用——WxLogin 的 is_new_user 表示「本次是否新注册」,语义保持不变。 +func (s *UserService) consumeNewUserFlag(userID int64, info *dto.UserInfoGetBySelfResponse) { + if !info.IsNewUser { + return + } + info.IsNewUser = false + go s.resetNewUserFlag(userID) +} + // ProfileUpdate 修改当前用户资料。 func (s *UserService) ProfileUpdate(ctx context.Context, userID int64, request *dto.UserProfileUpdateBySelfRequest) (*dto.UserProfileUpdateBySelfResponse, *errcode.Error) { ctx, span := global.Tracer.Start(ctx, "svc.UserProfileUpdate") @@ -257,9 +272,13 @@ func (s *UserService) AvatarUpload(ctx context.Context, userID int64, fileHeader return nil, errcode.UploadFileSaveFailed } subDir := fmt.Sprintf("%s/%d/avatar", user.UserUUID, userID) - if _, serr := upload.FileSaveTmpBytes(ctx, imageData, fileName, subDir); serr != nil { + tmpPath, serr := upload.FileSaveTmpBytes(ctx, imageData, fileName, subDir) + if serr != nil { return nil, errcode.UploadFileSaveFailed } + // 头像不消耗额度、上传接口也无限流,临时文件必须清理, + // 否则单个认证用户循环上传即可把磁盘写满(此前返回值被丢弃,文件从不删除)。 + defer func() { _ = os.Remove(tmpPath) }() cosKey := fmt.Sprintf("%s/%s", subDir, fileName) if cerr := upload.FileUploadCos(ctx, cosKey); cerr != nil { return nil, errcode.UploadFileSaveFailed @@ -415,3 +434,16 @@ func buildUserInfoResponse(user *mysqlmodel.User) dto.UserInfoGetBySelfResponse VIPExpire: user.VIPExpire, } } + +// resetNewUserFlag 异步复位 is_new_user(best-effort,失败仅告警)。 +// 使用独立 context,避免请求结束后 ctx 被取消导致复位失败。 +func (s *UserService) resetNewUserFlag(userID int64) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := s.userDao.UserUpdateByID(ctx, userID, map[string]any{"is_new_user": false}); err != nil { + slog.WarnContext(ctx, "reset is_new_user failed", "user_id", userID, "error", err) + return + } + _ = redisdao.UserInfoDel(ctx, userID) +} diff --git a/internal/service/virtualpay_compensate.go b/internal/service/virtualpay_compensate.go index f7496ae..965c925 100644 --- a/internal/service/virtualpay_compensate.go +++ b/internal/service/virtualpay_compensate.go @@ -3,6 +3,7 @@ package service import ( "context" "log/slog" + "sync" "time" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" @@ -17,8 +18,20 @@ const ( // virtualConfirmLookbackSeconds 查单补偿的回看窗口:只处理近 2 小时内的待支付/已过期虚拟订单。 // 该窗口覆盖推送重试耗尽的时间(官方最多重试 15 次、间隔 2/4/8/16… 秒),又避免全表扫描。 virtualConfirmLookbackSeconds int64 = 2 * 60 * 60 - // virtualConfirmBatchSize 单轮最多处理的订单数,控制单轮耗时与微信接口调用量。 - virtualConfirmBatchSize = 50 + // virtualConfirmBatchSize 单轮最多取回的候选订单数。 + virtualConfirmBatchSize = 200 + // virtualConfirmWorkers 单轮并发查询的 worker 数。 + // 必须并发:串行查询在积压时会撞上 ctx 超时,导致尾部订单被静默跳过(看似处理了全部,实则只查了前一部分)。 + virtualConfirmWorkers = 5 + // virtualConfirmUnpaidTTL 「已确认未支付」负缓存的存活时间。 + // 命中即跳过本轮查询,使积压订单让出扫描名额——这是修复「积压订单饿死已支付订单」的关键; + // 同时把单个订单的查询频率从每分钟一次降到每 TTL 一次,控制微信接口调用量。 + // 代价:用户支付后最坏要等一个 TTL 才被补偿发现,需要更快收敛就把该值调小。 + virtualConfirmUnpaidTTL = 10 * time.Minute + // virtualConfirmLockTTL 单轮任务的分布式锁 TTL,必须大于单轮最大耗时,否则锁提前过期会让多实例并发执行。 + virtualConfirmLockTTL = 5 * time.Minute + // virtualConfirmTimeout 单轮任务的上下文超时,需给「批量查询 + 并发反查」留足余量。 + virtualConfirmTimeout = 10 * time.Minute ) // StartVirtualPayConfirmScheduler 启动虚拟支付查单补偿定时任务(官方「道具直购流程」的发货轮询分支)。 @@ -45,14 +58,20 @@ func StartVirtualPayConfirmScheduler() { }() } -// runVirtualPayConfirm 扫描待确认的虚拟支付订单并逐个向微信反查,单实例通过 Redis 分布式锁防重。 +// runVirtualPayConfirm 扫描待确认的虚拟支付订单并并发向微信反查,单实例通过 Redis 分布式锁防重。 +// +// 扫描策略(避免积压饿死): +// 1. 按 id 升序取窗口内候选订单,用「已确认未支付」负缓存批量过滤掉最近查过的订单, +// 被过滤的订单不再占名额,后续新订单自然会被覆盖到; +// 2. 剩余候选用固定 worker 并发反查,避免串行耗时撞上 ctx 超时; +// 3. 候选数打满上限时打 warning,提示可能存在积压。 func runVirtualPayConfirm() { // 虚拟支付未开通时直接返回,避免无谓的数据库扫描。 if !wechat.IsVirtualPayEnabled() { return } - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + ctx, cancel := context.WithTimeout(context.Background(), virtualConfirmTimeout) defer cancel() ctx, span := global.Tracer.Start(ctx, "scheduler.VirtualPayConfirm") @@ -60,7 +79,7 @@ func runVirtualPayConfirm() { lockKey := redismodel.KeyLockVirtualConfirm lockVal := genUUID() - ok, lerr := redisdao.TryLock(ctx, lockKey, lockVal, time.Minute) + ok, lerr := redisdao.TryLock(ctx, lockKey, lockVal, virtualConfirmLockTTL) if lerr != nil || !ok { return } @@ -76,17 +95,65 @@ func runVirtualPayConfirm() { if len(orders) == 0 { return } + if len(orders) >= virtualConfirmBatchSize { + slog.WarnContext(ctx, "virtual confirm backlog detected, candidates reached limit", + "candidates", len(orders), "limit", virtualConfirmBatchSize, + "lookback_seconds", virtualConfirmLookbackSeconds) + } + + // 批量过滤负缓存:一次 MGET,避免逐单往返 Redis。 + orderNos := make([]string, 0, len(orders)) + for i := range orders { + orderNos = append(orderNos, orders[i].OrderNo) + } + unpaid, merr := redisdao.VirtualPayUnpaidBatchGet(ctx, orderNos) + if merr != nil { + // 负缓存不可用不阻断补偿:本轮退化为「逐单都查」(最坏情况等价于旧行为)。 + slog.WarnContext(ctx, "get virtual pay unpaid cache failed, fallback to query all", "error", merr) + unpaid = map[string]bool{} + } + + targets := make([]*mysqlmodel.Order, 0, len(orders)) + for i := range orders { + if unpaid[orders[i].OrderNo] { + continue // 最近已确认未支付,让出名额给其它订单。 + } + targets = append(targets, &orders[i]) + } + if len(targets) == 0 { + return + } s := NewOrderService() - confirmed := 0 - for i := range orders { - if s.confirmVirtualOrder(ctx, &orders[i]) { - confirmed++ - } + var ( + mu sync.Mutex + wg sync.WaitGroup + confirmed int + ) + sem := make(chan struct{}, virtualConfirmWorkers) + for _, order := range targets { + wg.Add(1) + sem <- struct{}{} + go func(o *mysqlmodel.Order) { + defer wg.Done() + defer func() { <-sem }() + defer func() { + if r := recover(); r != nil { + slog.Error("virtual confirm worker panic", "order_no", o.OrderNo, "recover", r) + } + }() + if s.confirmVirtualOrder(ctx, o) { + mu.Lock() + confirmed++ + mu.Unlock() + } + }(order) } + wg.Wait() + if confirmed > 0 { slog.InfoContext(ctx, "virtual pay confirm compensated orders", - "scanned", len(orders), "confirmed", confirmed) + "scanned", len(orders), "queried", len(targets), "confirmed", confirmed) } } @@ -104,12 +171,18 @@ func (s *OrderService) confirmVirtualOrder(ctx context.Context, order *mysqlmode info, qerr := wechat.QueryOrder(ctx, user.WxOpenID, order.OrderNo, "") if qerr != nil { + // 查询失败不等于「未支付」,不写负缓存,下一轮继续重试。 slog.ErrorContext(ctx, "virtual confirm: query order failed", "order_no", order.OrderNo, "error", qerr) return false } if !wechat.IsOrderPaidStatus(info.Order.Status) { // 未支付:用户放弃或仍在支付中,保持原状态等待过期。 + // 写入负缓存让该订单在 TTL 内让出扫描名额,避免大量未支付订单把已支付订单挤出扫描窗口。 + if serr := redisdao.VirtualPayUnpaidSet(ctx, order.OrderNo, virtualConfirmUnpaidTTL); serr != nil { + slog.WarnContext(ctx, "virtual confirm: set unpaid cache failed", + "order_no", order.OrderNo, "error", serr) + } return false } // 金额必须与下单快照一致,防止订单号错配导致错发。 @@ -138,6 +211,11 @@ func (s *OrderService) confirmVirtualOrder(ctx context.Context, order *mysqlmode slog.ErrorContext(ctx, "virtual confirm: notify provide goods failed", "order_no", order.OrderNo, "error", nerr) } + // 订单已补发,清掉可能残留的负缓存。 + if derr := redisdao.VirtualPayUnpaidDel(ctx, order.OrderNo); derr != nil { + slog.WarnContext(ctx, "virtual confirm: clear unpaid cache failed", + "order_no", order.OrderNo, "error", derr) + } return true } } diff --git a/main.go b/main.go index 91f746e..1c2839c 100644 --- a/main.go +++ b/main.go @@ -155,6 +155,15 @@ func setupSetting() error { if global.JWTSetting == nil || global.JWTSetting.SecretKey == "" { return fmt.Errorf("JWT secret key must not be empty") } + // 过期时间必须为正数,这里 fail-fast 不静默兜底: + // 配 0 会让签出的 token 立即过期(登录即失败),且 TTL=0 传给 Redis 表示「不设过期」, + // 会话 key 会永久驻留、无法自然回收。 + if global.JWTSetting.AccessTokenExpire <= 0 { + return fmt.Errorf("JWT AccessTokenExpire must be > 0, got %d", global.JWTSetting.AccessTokenExpire) + } + if global.JWTSetting.RefreshTokenExpire <= 0 { + return fmt.Errorf("JWT RefreshTokenExpire must be > 0, got %d", global.JWTSetting.RefreshTokenExpire) + } err = setting.ReadSection("Cos", &global.CosSetting) if err != nil { diff --git a/pkg/migration/000001_create_users.down.sql b/pkg/migration/000001_create_users.down.sql deleted file mode 100644 index 5f0853a..0000000 --- a/pkg/migration/000001_create_users.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS `users`; diff --git a/pkg/migration/000001_create_users.up.sql b/pkg/migration/000001_create_users.up.sql deleted file mode 100644 index b4303a7..0000000 --- a/pkg/migration/000001_create_users.up.sql +++ /dev/null @@ -1,28 +0,0 @@ -CREATE TABLE `users` ( - `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', - `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', - `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', - `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', - `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', - `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', - `user_uuid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '用户UUID', - `wx_open_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '微信openid', - `username` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '用户名', - `avatar` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '头像存储位置', - `sex` TINYINT NOT NULL DEFAULT 0 COMMENT '性别 1=男 2=女', - `birth_day` BIGINT NOT NULL DEFAULT 0 COMMENT '生日', - `phone` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '手机号', - `is_new_user` TINYINT NOT NULL DEFAULT 0 COMMENT '是否新用户 0=否 1=是', - `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态 1=正常 2=临时禁用 3=永久禁用 4=已注销', - `ban_reason` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '封禁原因', - `ban_start` BIGINT NOT NULL DEFAULT 0 COMMENT '封禁开始时间', - `ban_expire` BIGINT NOT NULL DEFAULT 0 COMMENT '封禁到期时间', - `vip_type` TINYINT NOT NULL DEFAULT 0 COMMENT 'VIP类型 1=非会员 2=月度会员 3=年度会员 4=体验会员', - `vip_start` BIGINT NOT NULL DEFAULT 0 COMMENT 'VIP开始时间', - `vip_expire` BIGINT NOT NULL DEFAULT 0 COMMENT 'VIP到期时间', - `last_login_time` BIGINT NOT NULL DEFAULT 0 COMMENT '最近登录时间', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_user_wx_open_id` (`wx_open_id`), - KEY `idx_users_user_uuid` (`user_uuid`) -) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '用户表'; diff --git a/pkg/migration/000001_init.down.sql b/pkg/migration/000001_init.down.sql new file mode 100644 index 0000000..e58ae67 --- /dev/null +++ b/pkg/migration/000001_init.down.sql @@ -0,0 +1,53 @@ +-- zt-closet 数据库全量回滚脚本:删除全部业务表。 +-- +-- 注意:迁移中不存在任何外键约束,因此删除顺序无依赖,此处按与 up 相反的书写顺序排列仅为可读性。 +-- 该脚本会一次性删除全部 25 张业务表(不含 golang-migrate 自建的 schema_migrations),生产环境慎用。 + +-- 十一、运势与审核 +DROP TABLE IF EXISTS `outfit_audit_logs`; +DROP TABLE IF EXISTS `closet_audit_logs`; +DROP TABLE IF EXISTS `horoscopes`; + +-- 十、互动与通知 +DROP TABLE IF EXISTS `user_notifications`; +DROP TABLE IF EXISTS `likes`; +DROP TABLE IF EXISTS `collects`; + +-- 九、资金与调用流水 +DROP TABLE IF EXISTS `ai_horoscope_flow_logs`; +DROP TABLE IF EXISTS `ai_recommend_flow_logs`; +DROP TABLE IF EXISTS `vip_flow_logs`; +DROP TABLE IF EXISTS `refund_flow_logs`; + +-- 八、权益发放与回收(补偿任务) +DROP TABLE IF EXISTS `benefit_revoke_tasks`; +DROP TABLE IF EXISTS `benefit_grant_tasks`; + +-- 七、额度 +DROP TABLE IF EXISTS `quota_flow_logs`; +DROP TABLE IF EXISTS `quota_grants`; +DROP TABLE IF EXISTS `user_daily_usages`; +DROP TABLE IF EXISTS `user_quotas`; + +-- 六、商品与订单 +DROP TABLE IF EXISTS `orders`; +DROP TABLE IF EXISTS `goods`; + +-- 五、穿搭分享 +DROP TABLE IF EXISTS `outfit_share_receivers`; +DROP TABLE IF EXISTS `outfit_shares`; + +-- 四、衣柜分享 +DROP TABLE IF EXISTS `closet_share_receivers`; +DROP TABLE IF EXISTS `closet_share_items`; +DROP TABLE IF EXISTS `closet_shares`; + +-- 三、穿搭 +DROP TABLE IF EXISTS `outfit_items`; +DROP TABLE IF EXISTS `outfits`; + +-- 二、衣物 +DROP TABLE IF EXISTS `clothes`; + +-- 一、用户 +DROP TABLE IF EXISTS `users`; diff --git a/pkg/migration/000001_init.up.sql b/pkg/migration/000001_init.up.sql new file mode 100644 index 0000000..3317a8d --- /dev/null +++ b/pkg/migration/000001_init.up.sql @@ -0,0 +1,622 @@ +-- zt-closet 数据库全量初始化脚本(单一迁移版本)。 +-- +-- 维护约定: +-- 1. 项目当前无线上数据,所有结构变更统一合并进本文件,不再新增增量迁移(ALTER / UPDATE)。 +-- 2. 新增字段或索引时,直接修改下方对应表的 CREATE TABLE 语句,并同步 000001_init.down.sql。 +-- 3. 执行前需保证目标库为空(脚本只建表,不建库);迁移由 cmd/migrate 手动触发,服务启动不会自动迁移: +-- go run ./cmd/migrate up +-- 4. 迁移 DSN 已开启 multiStatements,本文件的多条语句可一次性执行。 +-- 5. 字段与索引以 internal/mysqlmodel/ 的 GORM 模型为准,ER 图见 design/zt-closet-er.drawio。 + +-- ============================================================ +-- 一、用户 +-- ============================================================ +CREATE TABLE `users` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `user_uuid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '用户UUID', + `wx_open_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '微信openid', + `username` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '用户名', + `avatar` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '头像存储位置', + `sex` TINYINT NOT NULL DEFAULT 0 COMMENT '性别 1=男 2=女', + `birth_day` BIGINT NOT NULL DEFAULT 0 COMMENT '生日', + `phone` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '手机号', + `is_new_user` TINYINT NOT NULL DEFAULT 0 COMMENT '是否新用户 0=否 1=是', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态 1=正常 2=临时禁用 3=永久禁用 4=已注销', + `ban_reason` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '封禁原因', + `ban_start` BIGINT NOT NULL DEFAULT 0 COMMENT '封禁开始时间', + `ban_expire` BIGINT NOT NULL DEFAULT 0 COMMENT '封禁到期时间', + `vip_type` TINYINT NOT NULL DEFAULT 0 COMMENT 'VIP类型 1=非会员 2=月度会员 3=年度会员 4=体验会员', + `vip_start` BIGINT NOT NULL DEFAULT 0 COMMENT 'VIP开始时间', + `vip_expire` BIGINT NOT NULL DEFAULT 0 COMMENT 'VIP到期时间', + `last_login_time` BIGINT NOT NULL DEFAULT 0 COMMENT '最近登录时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_user_wx_open_id` (`wx_open_id`), + KEY `idx_users_user_uuid` (`user_uuid`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '用户表'; + +-- ============================================================ +-- 二、衣物 +-- ============================================================ +CREATE TABLE `clothes` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `clothes_uuid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '衣物UUID', + `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '所属用户ID', + `category_main` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '衣物大类 上装/下装/连衣裙/外套/鞋/配饰', + `category_sub` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '衣物小类 T恤/牛仔裤/运动鞋等', + `style` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '风格', + `color` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '主颜色', + `color_sub` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '副颜色', + `thickness` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '厚度', + `version` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '版型 宽松/修身/阔腿/紧身/常规合身', + `material` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '材质 牛仔布/针织/皮质/纯棉/羊毛等', + `pattern` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '花纹', + `sleeve_type` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '袖型', + `clothes_type` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '衣长类型 短款/常规/中长/长款', + `neckline_type` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '领型', + `formality` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '正式程度 1-5', + `occasions` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '适用场合', + `visual_impact` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '视觉冲击力 1-5', + `name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '名称', + `remark` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '备注', + `processed_image` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '处理后图片存储位置 相对路径', + `original_image_path` VARCHAR(1000) NOT NULL DEFAULT '' COMMENT '待处理原图本地路径(任务恢复用)', + `image_size` BIGINT NOT NULL DEFAULT 0 COMMENT '图片大小 单位:kb', + `image_process_status` TINYINT NOT NULL DEFAULT 0 COMMENT '处理状态 1=抠图中 2=分类中 3=排队中 4=成功 5=失败', + `fail_reason` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '失败原因', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_clothes_uuid` (`clothes_uuid`), + KEY `idx_clothes_user_category` (`user_id`, `category_main`), + KEY `idx_clothes_user_status` (`user_id`, `image_process_status`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '衣物表'; + +-- ============================================================ +-- 三、穿搭 +-- ============================================================ +CREATE TABLE `outfits` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `outfit_uuid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '穿搭UUID', + `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '所属用户ID', + `outfit_name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '穿搭名称', + `outfit_desc` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '穿搭描述', + `scene` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '适合场景', + `source` TINYINT NOT NULL DEFAULT 0 COMMENT '来源 1=用户创建 2=AI推荐', + `is_public` TINYINT NOT NULL DEFAULT 0 COMMENT '是否公开 1=不公开 2=公开', + `audit_status` TINYINT NOT NULL DEFAULT 0 COMMENT '审核状态 1=待审核 2=审核通过 3=审核不通过', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态 1=正常 2=封禁 3=待用户保存', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_outfits_uuid` (`outfit_uuid`), + KEY `idx_outfits_user_status` (`user_id`, `status`), + KEY `idx_outfits_public` (`is_public`, `audit_status`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '穿搭表'; + +CREATE TABLE `outfit_items` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `outfit_id` BIGINT NOT NULL DEFAULT 0 COMMENT '穿搭ID', + `clothes_id` BIGINT NOT NULL DEFAULT 0 COMMENT '衣物ID', + PRIMARY KEY (`id`), + KEY `idx_outfit_items_outfit` (`outfit_id`), + KEY `idx_outfit_items_clothes` (`clothes_id`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '穿搭明细表'; + +-- ============================================================ +-- 四、衣柜分享 +-- ============================================================ +CREATE TABLE `closet_shares` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `share_uuid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '分享UUID', + `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '所属用户ID', + `share_type` TINYINT NOT NULL DEFAULT 0 COMMENT '分享类型 1=部分衣物分享 2=全量快照分享', + `expire_time` BIGINT NOT NULL DEFAULT 0 COMMENT '过期时间', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '分享状态 1=正常 2=用户取消 3=已过期 4=管理员封禁', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_closet_shares_uuid` (`share_uuid`), + KEY `idx_closet_shares_user_status` (`user_id`, `status`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '衣柜分享表'; + +CREATE TABLE `closet_share_items` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `share_uuid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '分享UUID', + `clothes_id` BIGINT NOT NULL DEFAULT 0 COMMENT '衣物ID', + PRIMARY KEY (`id`), + KEY `idx_closet_share_items_share` (`share_uuid`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '衣柜分享明细表'; + +-- 衣柜分享接收人关联表:记录"谁收到了谁的分享"(访问即视为收到,一对多、幂等)。 +CREATE TABLE `closet_share_receivers` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `share_uuid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '分享UUID', + `receiver_user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '接收人用户ID', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_closet_share_receiver` (`share_uuid`, `receiver_user_id`), + KEY `idx_closet_share_receivers_user` (`receiver_user_id`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '衣柜分享接收人关联表'; + +-- ============================================================ +-- 五、穿搭分享 +-- ============================================================ +CREATE TABLE `outfit_shares` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `share_uuid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '分享UUID', + `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '所属用户ID', + `outfit_id` BIGINT NOT NULL DEFAULT 0 COMMENT '穿搭ID', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '分享状态 1=正常 2=用户取消', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_outfit_shares_uuid` (`share_uuid`), + KEY `idx_outfit_shares_user_status` (`user_id`, `status`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '穿搭分享表'; + +CREATE TABLE `outfit_share_receivers` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `share_uuid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '分享UUID', + `receiver_user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '接收人用户ID', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_outfit_share_receiver` (`share_uuid`, `receiver_user_id`), + KEY `idx_outfit_share_receivers_user` (`receiver_user_id`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '穿搭分享接收人关联表'; + +-- ============================================================ +-- 六、商品与订单 +-- ============================================================ +CREATE TABLE `goods` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `goods_code` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '商品编码', + `goods_name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '商品名称', + `goods_type` TINYINT NOT NULL DEFAULT 0 COMMENT '商品类型 1=会员包 2=上传包 3=AI推荐包 4=衣柜分享包 5=搭配分享包', + `price` BIGINT NOT NULL DEFAULT 0 COMMENT '商品原价(分)', + `discount_price` BIGINT NOT NULL DEFAULT 0 COMMENT '商品售价(分)', + `member_days` BIGINT NOT NULL DEFAULT 0 COMMENT '赠送会员天数', + `cos_quota` BIGINT NOT NULL DEFAULT 0 COMMENT '赠送总上传额度', + `ai_recommend_quota` BIGINT NOT NULL DEFAULT 0 COMMENT '赠送AI推荐额度', + `closet_share_quota` BIGINT NOT NULL DEFAULT 0 COMMENT '赠送衣柜分享额度', + `outfit_share_quota` BIGINT NOT NULL DEFAULT 0 COMMENT '赠送搭配分享额度', + `valid_days` BIGINT NOT NULL DEFAULT 0 COMMENT '赠送额度有效期天数(加量包用;会员包为 0 表示跟随 VIP)', + `description` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '商品描述', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态 1=上架 2=下架', + `product_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '虚拟支付道具ID(空表示未发布)', + `product_status` TINYINT NOT NULL DEFAULT 0 COMMENT '虚拟支付道具发布状态 0=未发布 1=发布中 2=已发布 3=发布失败', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_goods_code` (`goods_code`), + KEY `idx_goods_type_status` (`goods_type`, `status`), + KEY `idx_goods_product_status` (`product_status`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '商品表'; + +CREATE TABLE `orders` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `order_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '商户订单号', + `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '下单用户ID', + `goods_id` BIGINT NOT NULL DEFAULT 0 COMMENT '关联商品ID', + `goods_name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '商品名称快照', + `unit_price` BIGINT NOT NULL DEFAULT 0 COMMENT '商品单价(分)', + `total_amount` BIGINT NOT NULL DEFAULT 0 COMMENT '订单总金额(分)', + `pay_amount` BIGINT NOT NULL DEFAULT 0 COMMENT '实付金额(分)', + `pay_channel` TINYINT NOT NULL DEFAULT 0 COMMENT '支付渠道 1=微信支付 2=微信虚拟支付', + `order_status` TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款', + `transaction_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '微信支付流水号', + `member_days` BIGINT NOT NULL DEFAULT 0 COMMENT '购买的会员天数(快照)', + `goods_type` TINYINT NOT NULL DEFAULT 0 COMMENT '商品类型快照', + `quota_valid_days` BIGINT NOT NULL DEFAULT 0 COMMENT '额度有效期天数快照', + `cos_quota` BIGINT NOT NULL DEFAULT 0 COMMENT '赠送上传额度(快照)', + `ai_recommend_quota` BIGINT NOT NULL DEFAULT 0 COMMENT '赠送AI推荐额度(快照)', + `closet_share_quota` BIGINT NOT NULL DEFAULT 0 COMMENT '赠送衣柜分享额度(快照)', + `outfit_share_quota` BIGINT NOT NULL DEFAULT 0 COMMENT '赠送搭配分享额度(快照)', + `expire_time` BIGINT NOT NULL DEFAULT 0 COMMENT '支付过期时间', + `pay_time` BIGINT NOT NULL DEFAULT 0 COMMENT '支付完成时间', + `refund_time` BIGINT NOT NULL DEFAULT 0 COMMENT '退款时间', + `refund_amount` BIGINT NOT NULL DEFAULT 0 COMMENT '退款金额(分)', + `remark` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '订单备注', + `callback_raw` TEXT COMMENT '支付回调原始报文', + `xpay_product_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '虚拟支付道具ID(快照,pay_channel=2 时使用)', + `xpay_platform` VARCHAR(16) NOT NULL DEFAULT '' COMMENT '虚拟支付平台 android/ios/harmony/windows', + `xpay_env` TINYINT NOT NULL DEFAULT 0 COMMENT '虚拟支付环境 0=现网 1=沙箱', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_order_no` (`order_no`), + KEY `idx_orders_user_status` (`user_id`, `order_status`), + KEY `idx_orders_expire` (`order_status`, `expire_time`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '订单表'; + +-- ============================================================ +-- 七、额度 +-- ============================================================ +CREATE TABLE `user_quotas` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '用户ID', + `quota_type` TINYINT NOT NULL DEFAULT 0 COMMENT '额度类型 1=cos上传 2=AI推荐 3=社区分享 4=衣柜分享', + `total` BIGINT NOT NULL DEFAULT 0 COMMENT '总配额', + `used` BIGINT NOT NULL DEFAULT 0 COMMENT '已用量', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_user_quota` (`user_id`, `quota_type`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '用户额度表'; + +CREATE TABLE `user_daily_usages` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '用户ID', + `stat_date` BIGINT NOT NULL DEFAULT 0 COMMENT '统计日期(YYYYMMDD)', + `ai_recommend_count` BIGINT NOT NULL DEFAULT 0 COMMENT '当日AI推荐次数', + `outfit_share_count` BIGINT NOT NULL DEFAULT 0 COMMENT '当日社区分享次数', + `closet_share_count` BIGINT NOT NULL DEFAULT 0 COMMENT '当日衣柜分享次数', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_daily_usage_user_date` (`user_id`, `stat_date`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '用户每日用量统计表'; + +-- 额度凭证表:承载有有效期的赠送额度(会员包/加量包/广告激励)。 +CREATE TABLE `quota_grants` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '用户ID', + `quota_type` TINYINT NOT NULL DEFAULT 0 COMMENT '额度类型 1=cos上传 2=AI推荐 3=社区分享 4=衣柜分享', + `amount` BIGINT NOT NULL DEFAULT 0 COMMENT '赠送总量', + `used` BIGINT NOT NULL DEFAULT 0 COMMENT '已用量(Cos 永久额度扣减;AI/社区/衣柜 每日额度恒为 0)', + `source` TINYINT NOT NULL DEFAULT 0 COMMENT '来源 1=会员包 2=加量包 3=广告激励', + `expire_time` BIGINT NOT NULL DEFAULT 0 COMMENT '到期时间(秒,0 表示跟随 VIP 有效期)', + `ref_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '关联ID(订单号/广告记录ID)', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_grant_ref_type` (`ref_id`, `quota_type`), + KEY `idx_grant_user_type_expire` (`user_id`, `quota_type`, `expire_time`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '额度凭证表(有有效期赠送额度)'; + +CREATE TABLE `quota_flow_logs` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `quota_flow_uuid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '配额流水UUID', + `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '用户ID', + `change_target` TINYINT NOT NULL DEFAULT 0 COMMENT '调整额度类型 1=配额调整 2=使用', + `change_type` TINYINT NOT NULL DEFAULT 0 COMMENT '变更类型 1=购买获赠 2=活动赠送 3=上传消耗 4=删除回退 5=人工调整 6=退款回收', + `change_num` BIGINT NOT NULL DEFAULT 0 COMMENT '变更数量(正数增加,负数减少)', + `old_value` BIGINT NOT NULL DEFAULT 0 COMMENT '变更前数值', + `new_value` BIGINT NOT NULL DEFAULT 0 COMMENT '变更后数值', + `order_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '关联订单号(购买/退款类);其余场景写流水UUID保证幂等', + `quota_type` TINYINT NOT NULL DEFAULT 0 COMMENT '关联业务类型 1=cos 2=AI 3=社区分享 4=衣柜分享', + `related_id` BIGINT NOT NULL DEFAULT 0 COMMENT '关联业务ID', + `reason` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '变更原因/备注', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_quota_flow_idem` (`order_no`, `quota_type`, `change_type`), + KEY `idx_quota_flow_user` (`user_id`), + KEY `idx_quota_flow_uuid` (`quota_flow_uuid`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '配额流水日志表'; + +-- ============================================================ +-- 八、权益发放与回收(补偿任务) +-- ============================================================ +CREATE TABLE `benefit_grant_tasks` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '用户ID', + `order_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '商户订单号', + `goods_id` BIGINT NOT NULL DEFAULT 0 COMMENT '关联商品ID', + `benefit_type` TINYINT NOT NULL DEFAULT 0 COMMENT '权益类型 1=vip 2=cos 3=ai 4=closet_share 5=outfit_share', + `amount` BIGINT NOT NULL DEFAULT 0 COMMENT '发放数量', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态 0=待发放 1=已发放 2=发放失败', + `retry_count` INT NOT NULL DEFAULT 0 COMMENT '已重试次数', + `max_retry` INT NOT NULL DEFAULT 0 COMMENT '最大重试次数', + `last_error` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '最近一次失败原因', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_benefit_task_idem` (`order_no`, `benefit_type`), + KEY `idx_benefit_task_status` (`status`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '权益发放补偿任务表'; + +CREATE TABLE `benefit_revoke_tasks` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `order_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '商户订单号', + `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '用户ID', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态 0=待回收 1=已回收 2=回收失败', + `retry_count` INT NOT NULL DEFAULT 0 COMMENT '已重试次数', + `max_retry` INT NOT NULL DEFAULT 10 COMMENT '最大重试次数', + `last_error` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '最近一次失败原因', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_benefit_revoke_order` (`order_no`), + KEY `idx_benefit_revoke_status` (`status`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '退款权益回收任务'; + +-- ============================================================ +-- 九、资金与调用流水 +-- ============================================================ +CREATE TABLE `refund_flow_logs` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `order_id` BIGINT NOT NULL DEFAULT 0 COMMENT '关联订单ID', + `order_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '商户订单号(冗余,方便查询)', + `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '用户ID(冗余,方便按用户查)', + `refund_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '商户退款单号', + `wechat_refund_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '微信退款单号', + `refund_amount` BIGINT NOT NULL DEFAULT 0 COMMENT '本次退款金额(分)', + `refund_reason` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '退款原因', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '退款状态 0=待处理 1=退款中 2=退款成功 3=退款失败', + `apply_time` BIGINT NOT NULL DEFAULT 0 COMMENT '申请时间', + `success_time` BIGINT NOT NULL DEFAULT 0 COMMENT '退款成功时间', + `fail_reason` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '失败原因', + `callback_raw` TEXT COMMENT '微信退款回调原始报文', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_refund_flow_no` (`refund_no`), + KEY `idx_refund_flow_order` (`order_id`), + KEY `idx_refund_flow_user` (`user_id`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '退款流水日志表'; + +CREATE TABLE `vip_flow_logs` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '用户ID', + `change_type` TINYINT NOT NULL DEFAULT 0 COMMENT '变更类型 1=开通 2=续费 3=活动赠送 4=到期失效 5=人工调整 6=退款回收', + `old_vip_type` TINYINT NOT NULL DEFAULT 0 COMMENT '变更前VIP类型 1=非会员 2=月度会员 3=年度会员 4=体验会员', + `new_vip_type` TINYINT NOT NULL DEFAULT 0 COMMENT '变更后VIP类型 1=非会员 2=月度会员 3=年度会员 4=体验会员', + `old_vip_expire` BIGINT NOT NULL DEFAULT 0 COMMENT '变更前到期时间(秒)', + `new_vip_expire` BIGINT NOT NULL DEFAULT 0 COMMENT '变更后到期时间(秒)', + `order_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '关联订单号(购买类变更)', + `source` TINYINT NOT NULL DEFAULT 0 COMMENT '变更来源 1=个人支付 2=活动赠送 3=系统 4=人工调整', + `reason` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '变更原因/备注', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_vip_flow_idem` (`order_no`, `change_type`), + KEY `idx_vip_flow_user` (`user_id`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '会员变更流水表'; + +CREATE TABLE `ai_recommend_flow_logs` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `request_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '请求唯一ID', + `server_request_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '服务请求唯一ID', + `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '用户ID', + `model_version` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '大模型版本', + `prompt_tokens` BIGINT NOT NULL DEFAULT 0 COMMENT '输入token数', + `completion_tokens` BIGINT NOT NULL DEFAULT 0 COMMENT '输出token数', + `result_content` TEXT COMMENT '推荐结果结构化数据', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态 1=成功 2=失败', + `fail_reason` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '失败原因', + `request_time` BIGINT NOT NULL DEFAULT 0 COMMENT '请求发起时间', + `response_time` BIGINT NOT NULL DEFAULT 0 COMMENT '响应返回时间', + PRIMARY KEY (`id`), + KEY `idx_ai_recommend_request` (`request_id`), + KEY `idx_ai_recommend_user` (`user_id`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'AI穿搭推荐流水表'; + +CREATE TABLE `ai_horoscope_flow_logs` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `request_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '请求唯一ID(幂等依据)', + `zodiac` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '星座', + `server_request_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '服务请求唯一ID', + `stat_date` BIGINT NOT NULL DEFAULT 0 COMMENT '运势日期(YYYYMMDD)', + `model_version` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '大模型版本', + `prompt_tokens` BIGINT NOT NULL DEFAULT 0 COMMENT '输入token数', + `completion_tokens` BIGINT NOT NULL DEFAULT 0 COMMENT '输出token数', + `result_content` TEXT COMMENT '生成结果结构化数据', + `horoscope_id` BIGINT NOT NULL DEFAULT 0 COMMENT '生成的运势记录ID', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态 1=成功 2=失败', + `fail_reason` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '失败原因', + `request_time` BIGINT NOT NULL DEFAULT 0 COMMENT '请求发起时间', + `response_time` BIGINT NOT NULL DEFAULT 0 COMMENT '响应返回时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ai_horoscope_request` (`request_id`), + KEY `idx_ai_horoscope_date_zodiac` (`stat_date`, `zodiac`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'AI运势生成流水表'; + +-- ============================================================ +-- 十、互动与通知 +-- ============================================================ +CREATE TABLE `collects` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `collect_type` TINYINT NOT NULL DEFAULT 0 COMMENT '业务类型 1=社区穿搭', + `target_id` BIGINT NOT NULL DEFAULT 0 COMMENT '业务目标ID', + `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '收藏用户ID', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态 1=已收藏 0=已取消', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_collect_user_target` (`collect_type`, `target_id`, `user_id`), + KEY `idx_collects_user` (`user_id`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '收藏表'; + +CREATE TABLE `likes` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `like_type` TINYINT NOT NULL DEFAULT 0 COMMENT '业务类型 1=社区穿搭', + `target_id` BIGINT NOT NULL DEFAULT 0 COMMENT '业务目标ID', + `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '点赞用户ID', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态 1=已点赞 0=已取消', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_like_user_target` (`like_type`, `target_id`, `user_id`), + KEY `idx_likes_user` (`user_id`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '点赞表'; + +CREATE TABLE `user_notifications` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `notification_uuid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '消息UUID', + `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '接收用户ID', + `msg_type` TINYINT NOT NULL DEFAULT 0 COMMENT '消息类型 1=点赞通知 2=订单通知 3=系统通知', + `sender_id` BIGINT NOT NULL DEFAULT 0 COMMENT '发送者ID 系统消息为0', + `title` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '消息标题', + `content` TEXT COMMENT '消息内容', + `is_read` TINYINT NOT NULL DEFAULT 0 COMMENT '是否已读 1=未读 2=已读', + `read_time` BIGINT NOT NULL DEFAULT 0 COMMENT '阅读时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_notification_uuid` (`notification_uuid`), + KEY `idx_notifications_user_read` (`user_id`, `is_read`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '用户通知表'; + +-- ============================================================ +-- 十一、运势与审核 +-- ============================================================ +CREATE TABLE `horoscopes` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `stat_date` BIGINT NOT NULL DEFAULT 0 COMMENT '运势日期(YYYYMMDD)', + `zodiac` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '星座', + `overall_score` TINYINT NOT NULL DEFAULT 0 COMMENT '综合运势 1-5分', + `love_score` TINYINT NOT NULL DEFAULT 0 COMMENT '爱情运势 1-5分', + `career_score` TINYINT NOT NULL DEFAULT 0 COMMENT '工作运势 1-5分', + `wealth_score` TINYINT NOT NULL DEFAULT 0 COMMENT '财富运势 1-5分', + `daily_text` TEXT COMMENT '今日运势详情', + `lucky_color` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '幸运色', + `lucky_number` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '幸运数字', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_horoscope_date_zodiac` (`stat_date`, `zodiac`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '星座运势表'; + +CREATE TABLE `closet_audit_logs` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `target_id` BIGINT NOT NULL DEFAULT 0 COMMENT '衣柜分享ID', + `audit_result` TINYINT NOT NULL DEFAULT 0 COMMENT '审核结果 1=通过 2=驳回', + `reject_reason` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '审核原因', + `audit_time` BIGINT NOT NULL DEFAULT 0 COMMENT '审核时间', + PRIMARY KEY (`id`), + KEY `idx_closet_audit_target` (`target_id`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '衣柜分享审核日志表'; + +CREATE TABLE `outfit_audit_logs` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `target_id` BIGINT NOT NULL DEFAULT 0 COMMENT '穿搭ID', + `audit_result` TINYINT NOT NULL DEFAULT 0 COMMENT '审核结果 1=通过 2=驳回', + `reject_reason` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '审核原因', + `audit_time` BIGINT NOT NULL DEFAULT 0 COMMENT '审核时间', + PRIMARY KEY (`id`), + KEY `idx_outfit_audit_target` (`target_id`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '穿搭审核日志表'; diff --git a/pkg/migration/000002_create_clothes.down.sql b/pkg/migration/000002_create_clothes.down.sql deleted file mode 100644 index 2fe1179..0000000 --- a/pkg/migration/000002_create_clothes.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS `clothes`; diff --git a/pkg/migration/000002_create_clothes.up.sql b/pkg/migration/000002_create_clothes.up.sql deleted file mode 100644 index b5a72f5..0000000 --- a/pkg/migration/000002_create_clothes.up.sql +++ /dev/null @@ -1,36 +0,0 @@ -CREATE TABLE `clothes` ( - `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', - `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', - `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', - `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', - `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', - `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', - `clothes_uuid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '衣物UUID', - `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '所属用户ID', - `category_main` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '衣物大类 上装/下装/连衣裙/外套/鞋/配饰', - `category_sub` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '衣物小类 T恤/牛仔裤/运动鞋等', - `style` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '风格', - `color` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '主颜色', - `color_sub` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '副颜色', - `thickness` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '厚度', - `version` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '版型 宽松/修身/阔腿/紧身/常规合身', - `material` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '材质 牛仔布/针织/皮质/纯棉/羊毛等', - `pattern` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '花纹', - `sleeve_type` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '袖型', - `clothes_type` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '衣长类型 短款/常规/中长/长款', - `neckline_type` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '领型', - `formality` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '正式程度 1-5', - `occasions` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '适用场合', - `visual_impact` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '视觉冲击力 1-5', - `name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '名称', - `remark` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '备注', - `processed_image` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '处理后图片存储位置 相对路径', - `image_size` BIGINT NOT NULL DEFAULT 0 COMMENT '图片大小 单位:kb', - `image_process_status` TINYINT NOT NULL DEFAULT 0 COMMENT '处理状态 1=抠图中 2=分类中 3=排队中 4=成功 5=失败', - `fail_reason` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '失败原因', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_clothes_uuid` (`clothes_uuid`), - KEY `idx_clothes_user_category` (`user_id`, `category_main`), - KEY `idx_clothes_user_status` (`user_id`, `image_process_status`) -) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '衣物表'; diff --git a/pkg/migration/000003_create_outfits.down.sql b/pkg/migration/000003_create_outfits.down.sql deleted file mode 100644 index c73e9e0..0000000 --- a/pkg/migration/000003_create_outfits.down.sql +++ /dev/null @@ -1,2 +0,0 @@ -DROP TABLE IF EXISTS `outfit_items`; -DROP TABLE IF EXISTS `outfits`; diff --git a/pkg/migration/000003_create_outfits.up.sql b/pkg/migration/000003_create_outfits.up.sql deleted file mode 100644 index eeda050..0000000 --- a/pkg/migration/000003_create_outfits.up.sql +++ /dev/null @@ -1,37 +0,0 @@ -CREATE TABLE `outfits` ( - `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', - `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', - `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', - `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', - `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', - `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', - `outfit_uuid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '穿搭UUID', - `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '所属用户ID', - `outfit_name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '穿搭名称', - `outfit_desc` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '穿搭描述', - `scene` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '适合场景', - `source` TINYINT NOT NULL DEFAULT 0 COMMENT '来源 1=用户创建 2=AI推荐', - `is_public` TINYINT NOT NULL DEFAULT 0 COMMENT '是否公开 1=不公开 2=公开', - `audit_status` TINYINT NOT NULL DEFAULT 0 COMMENT '审核状态 1=待审核 2=审核通过 3=审核不通过', - `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态 1=正常 2=封禁 3=待用户保存', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_outfits_uuid` (`outfit_uuid`), - KEY `idx_outfits_user_status` (`user_id`, `status`), - KEY `idx_outfits_public` (`is_public`, `audit_status`) -) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '穿搭表'; - -CREATE TABLE `outfit_items` ( - `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', - `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', - `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', - `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', - `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', - `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', - `outfit_id` BIGINT NOT NULL DEFAULT 0 COMMENT '穿搭ID', - `clothes_id` BIGINT NOT NULL DEFAULT 0 COMMENT '衣物ID', - PRIMARY KEY (`id`), - KEY `idx_outfit_items_outfit` (`outfit_id`), - KEY `idx_outfit_items_clothes` (`clothes_id`) -) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '穿搭明细表'; diff --git a/pkg/migration/000004_create_closet_shares.down.sql b/pkg/migration/000004_create_closet_shares.down.sql deleted file mode 100644 index 1d4d4c7..0000000 --- a/pkg/migration/000004_create_closet_shares.down.sql +++ /dev/null @@ -1,2 +0,0 @@ -DROP TABLE IF EXISTS `closet_share_items`; -DROP TABLE IF EXISTS `closet_shares`; diff --git a/pkg/migration/000004_create_closet_shares.up.sql b/pkg/migration/000004_create_closet_shares.up.sql deleted file mode 100644 index 74d3f49..0000000 --- a/pkg/migration/000004_create_closet_shares.up.sql +++ /dev/null @@ -1,31 +0,0 @@ -CREATE TABLE `closet_shares` ( - `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', - `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', - `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', - `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', - `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', - `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', - `share_uuid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '分享UUID', - `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '所属用户ID', - `share_type` TINYINT NOT NULL DEFAULT 0 COMMENT '分享类型 1=部分衣物分享 2=全量快照分享', - `expire_time` BIGINT NOT NULL DEFAULT 0 COMMENT '过期时间', - `status` TINYINT NOT NULL DEFAULT 0 COMMENT '分享状态 1=正常 2=用户取消 3=已过期 4=管理员封禁', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_closet_shares_uuid` (`share_uuid`), - KEY `idx_closet_shares_user_status` (`user_id`, `status`) -) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '衣柜分享表'; - -CREATE TABLE `closet_share_items` ( - `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', - `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', - `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', - `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', - `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', - `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', - `share_uuid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '分享UUID', - `clothes_id` BIGINT NOT NULL DEFAULT 0 COMMENT '衣物ID', - PRIMARY KEY (`id`), - KEY `idx_closet_share_items_share` (`share_uuid`) -) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '衣柜分享明细表'; diff --git a/pkg/migration/000005_create_user_quotas.down.sql b/pkg/migration/000005_create_user_quotas.down.sql deleted file mode 100644 index 61d1166..0000000 --- a/pkg/migration/000005_create_user_quotas.down.sql +++ /dev/null @@ -1,2 +0,0 @@ -DROP TABLE IF EXISTS `user_daily_usages`; -DROP TABLE IF EXISTS `user_quotas`; diff --git a/pkg/migration/000005_create_user_quotas.up.sql b/pkg/migration/000005_create_user_quotas.up.sql deleted file mode 100644 index 5186d1d..0000000 --- a/pkg/migration/000005_create_user_quotas.up.sql +++ /dev/null @@ -1,32 +0,0 @@ -CREATE TABLE `user_quotas` ( - `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', - `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', - `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', - `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', - `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', - `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', - `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '用户ID', - `quota_type` TINYINT NOT NULL DEFAULT 0 COMMENT '额度类型 1=cos上传 2=AI推荐 3=社区分享 4=衣柜分享', - `total` BIGINT NOT NULL DEFAULT 0 COMMENT '总配额', - `used` BIGINT NOT NULL DEFAULT 0 COMMENT '已用量', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_user_quota` (`user_id`, `quota_type`) -) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '用户额度表'; - -CREATE TABLE `user_daily_usages` ( - `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', - `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', - `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', - `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', - `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', - `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', - `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '用户ID', - `stat_date` BIGINT NOT NULL DEFAULT 0 COMMENT '统计日期(YYYYMMDD)', - `ai_recommend_count` BIGINT NOT NULL DEFAULT 0 COMMENT '当日AI推荐次数', - `outfit_share_count` BIGINT NOT NULL DEFAULT 0 COMMENT '当日社区分享次数', - `closet_share_count` BIGINT NOT NULL DEFAULT 0 COMMENT '当日衣柜分享次数', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_daily_usage_user_date` (`user_id`, `stat_date`) -) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '用户每日用量统计表'; diff --git a/pkg/migration/000006_create_quota_flow_logs.down.sql b/pkg/migration/000006_create_quota_flow_logs.down.sql deleted file mode 100644 index 8e81ed3..0000000 --- a/pkg/migration/000006_create_quota_flow_logs.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS `quota_flow_logs`; diff --git a/pkg/migration/000006_create_quota_flow_logs.up.sql b/pkg/migration/000006_create_quota_flow_logs.up.sql deleted file mode 100644 index 8cce07c..0000000 --- a/pkg/migration/000006_create_quota_flow_logs.up.sql +++ /dev/null @@ -1,24 +0,0 @@ -CREATE TABLE `quota_flow_logs` ( - `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', - `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', - `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', - `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', - `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', - `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', - `quota_flow_uuid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '配额流水UUID', - `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '用户ID', - `change_target` TINYINT NOT NULL DEFAULT 0 COMMENT '调整额度类型 1=配额调整 2=使用', - `change_type` TINYINT NOT NULL DEFAULT 0 COMMENT '变更类型 1=购买获赠 2=活动赠送 3=上传消耗 4=删除回退 5=人工调整 6=退款回收', - `change_num` BIGINT NOT NULL DEFAULT 0 COMMENT '变更数量(正数增加,负数减少)', - `old_value` BIGINT NOT NULL DEFAULT 0 COMMENT '变更前数值', - `new_value` BIGINT NOT NULL DEFAULT 0 COMMENT '变更后数值', - `order_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '关联订单号(购买/退款类);其余场景写流水UUID保证幂等', - `quota_type` TINYINT NOT NULL DEFAULT 0 COMMENT '关联业务类型 1=cos 2=AI 3=社区分享 4=衣柜分享', - `related_id` BIGINT NOT NULL DEFAULT 0 COMMENT '关联业务ID', - `reason` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '变更原因/备注', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_quota_flow_idem` (`order_no`, `quota_type`, `change_type`), - KEY `idx_quota_flow_user` (`user_id`), - KEY `idx_quota_flow_uuid` (`quota_flow_uuid`) -) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '配额流水日志表'; diff --git a/pkg/migration/000007_create_goods.down.sql b/pkg/migration/000007_create_goods.down.sql deleted file mode 100644 index 63949b7..0000000 --- a/pkg/migration/000007_create_goods.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS `goods`; diff --git a/pkg/migration/000007_create_goods.up.sql b/pkg/migration/000007_create_goods.up.sql deleted file mode 100644 index cd5a070..0000000 --- a/pkg/migration/000007_create_goods.up.sql +++ /dev/null @@ -1,28 +0,0 @@ -CREATE TABLE `goods` ( - `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', - `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', - `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', - `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', - `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', - `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', - `goods_code` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '商品编码', - `goods_name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '商品名称', - `goods_type` TINYINT NOT NULL DEFAULT 0 COMMENT '商品类型 1=会员包 2=上传包 3=AI推荐包 4=衣柜分享包 5=搭配分享包', - `price` BIGINT NOT NULL DEFAULT 0 COMMENT '商品原价(分)', - `discount_price` BIGINT NOT NULL DEFAULT 0 COMMENT '商品售价(分)', - `member_days` BIGINT NOT NULL DEFAULT 0 COMMENT '赠送会员天数', - `cos_quota` BIGINT NOT NULL DEFAULT 0 COMMENT '赠送总上传额度', - `ai_recommend_quota` BIGINT NOT NULL DEFAULT 0 COMMENT '赠送AI推荐额度', - `closet_share_quota` BIGINT NOT NULL DEFAULT 0 COMMENT '赠送衣柜分享额度', - `outfit_share_quota` BIGINT NOT NULL DEFAULT 0 COMMENT '赠送搭配分享额度', - `valid_days` BIGINT NOT NULL DEFAULT 0 COMMENT '赠送额度有效期天数(加量包用;会员包为 0 表示跟随 VIP)', - `description` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '商品描述', - `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态 1=上架 2=下架', - `product_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '虚拟支付道具ID(空表示未发布)', - `product_status` TINYINT NOT NULL DEFAULT 0 COMMENT '虚拟支付道具发布状态 0=未发布 1=发布中 2=已发布 3=发布失败', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_goods_code` (`goods_code`), - KEY `idx_goods_type_status` (`goods_type`, `status`), - KEY `idx_goods_product_status` (`product_status`) -) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '商品表'; diff --git a/pkg/migration/000008_create_orders.down.sql b/pkg/migration/000008_create_orders.down.sql deleted file mode 100644 index ac33c78..0000000 --- a/pkg/migration/000008_create_orders.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS `orders`; diff --git a/pkg/migration/000008_create_orders.up.sql b/pkg/migration/000008_create_orders.up.sql deleted file mode 100644 index e667b69..0000000 --- a/pkg/migration/000008_create_orders.up.sql +++ /dev/null @@ -1,37 +0,0 @@ -CREATE TABLE `orders` ( - `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', - `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', - `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', - `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', - `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', - `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', - `order_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '商户订单号', - `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '下单用户ID', - `goods_id` BIGINT NOT NULL DEFAULT 0 COMMENT '关联商品ID', - `goods_name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '商品名称快照', - `unit_price` BIGINT NOT NULL DEFAULT 0 COMMENT '商品单价(分)', - `total_amount` BIGINT NOT NULL DEFAULT 0 COMMENT '订单总金额(分)', - `pay_amount` BIGINT NOT NULL DEFAULT 0 COMMENT '实付金额(分)', - `pay_channel` TINYINT NOT NULL DEFAULT 0 COMMENT '支付渠道 1=微信支付', - `order_status` TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款', - `transaction_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '微信支付流水号', - `member_days` BIGINT NOT NULL DEFAULT 0 COMMENT '购买的会员天数(快照)', - `cos_quota` BIGINT NOT NULL DEFAULT 0 COMMENT '赠送上传额度(快照)', - `ai_recommend_quota` BIGINT NOT NULL DEFAULT 0 COMMENT '赠送AI推荐额度(快照)', - `closet_share_quota` BIGINT NOT NULL DEFAULT 0 COMMENT '赠送衣柜分享额度(快照)', - `outfit_share_quota` BIGINT NOT NULL DEFAULT 0 COMMENT '赠送搭配分享额度(快照)', - `expire_time` BIGINT NOT NULL DEFAULT 0 COMMENT '支付过期时间', - `pay_time` BIGINT NOT NULL DEFAULT 0 COMMENT '支付完成时间', - `refund_time` BIGINT NOT NULL DEFAULT 0 COMMENT '退款时间', - `refund_amount` BIGINT NOT NULL DEFAULT 0 COMMENT '退款金额(分)', - `remark` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '订单备注', - `callback_raw` TEXT COMMENT '支付回调原始报文', - `xpay_product_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '虚拟支付道具ID(快照,pay_channel=2 时使用)', - `xpay_platform` VARCHAR(16) NOT NULL DEFAULT '' COMMENT '虚拟支付平台 android/ios/harmony/windows', - `xpay_env` TINYINT NOT NULL DEFAULT 0 COMMENT '虚拟支付环境 0=现网 1=沙箱', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_order_no` (`order_no`), - KEY `idx_orders_user_status` (`user_id`, `order_status`), - KEY `idx_orders_expire` (`order_status`, `expire_time`) -) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '订单表'; diff --git a/pkg/migration/000009_create_refund_flow_logs.down.sql b/pkg/migration/000009_create_refund_flow_logs.down.sql deleted file mode 100644 index a7a071d..0000000 --- a/pkg/migration/000009_create_refund_flow_logs.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS `refund_flow_logs`; diff --git a/pkg/migration/000009_create_refund_flow_logs.up.sql b/pkg/migration/000009_create_refund_flow_logs.up.sql deleted file mode 100644 index 4f25674..0000000 --- a/pkg/migration/000009_create_refund_flow_logs.up.sql +++ /dev/null @@ -1,25 +0,0 @@ -CREATE TABLE `refund_flow_logs` ( - `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', - `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', - `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', - `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', - `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', - `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', - `order_id` BIGINT NOT NULL DEFAULT 0 COMMENT '关联订单ID', - `order_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '商户订单号(冗余,方便查询)', - `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '用户ID(冗余,方便按用户查)', - `refund_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '商户退款单号', - `wechat_refund_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '微信退款单号', - `refund_amount` BIGINT NOT NULL DEFAULT 0 COMMENT '本次退款金额(分)', - `refund_reason` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '退款原因', - `status` TINYINT NOT NULL DEFAULT 0 COMMENT '退款状态 0=待处理 1=退款中 2=退款成功 3=退款失败', - `apply_time` BIGINT NOT NULL DEFAULT 0 COMMENT '申请时间', - `success_time` BIGINT NOT NULL DEFAULT 0 COMMENT '退款成功时间', - `fail_reason` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '失败原因', - `callback_raw` TEXT COMMENT '微信退款回调原始报文', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_refund_flow_no` (`refund_no`), - KEY `idx_refund_flow_order` (`order_id`), - KEY `idx_refund_flow_user` (`user_id`) -) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '退款流水日志表'; diff --git a/pkg/migration/000010_create_vip_flow_logs.down.sql b/pkg/migration/000010_create_vip_flow_logs.down.sql deleted file mode 100644 index 3ab4ccb..0000000 --- a/pkg/migration/000010_create_vip_flow_logs.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS `vip_flow_logs`; diff --git a/pkg/migration/000010_create_vip_flow_logs.up.sql b/pkg/migration/000010_create_vip_flow_logs.up.sql deleted file mode 100644 index 7a974e4..0000000 --- a/pkg/migration/000010_create_vip_flow_logs.up.sql +++ /dev/null @@ -1,21 +0,0 @@ -CREATE TABLE `vip_flow_logs` ( - `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', - `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', - `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', - `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', - `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', - `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', - `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '用户ID', - `change_type` TINYINT NOT NULL DEFAULT 0 COMMENT '变更类型 1=开通 2=续费 3=活动赠送 4=到期失效 5=人工调整 6=退款回收', - `old_vip_type` TINYINT NOT NULL DEFAULT 0 COMMENT '变更前VIP类型 1=非会员 2=月度会员 3=年度会员 4=体验会员', - `new_vip_type` TINYINT NOT NULL DEFAULT 0 COMMENT '变更后VIP类型 1=非会员 2=月度会员 3=年度会员 4=体验会员', - `old_vip_expire` BIGINT NOT NULL DEFAULT 0 COMMENT '变更前到期时间(秒)', - `new_vip_expire` BIGINT NOT NULL DEFAULT 0 COMMENT '变更后到期时间(秒)', - `order_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '关联订单号(购买类变更)', - `source` TINYINT NOT NULL DEFAULT 0 COMMENT '变更来源 1=个人支付 2=活动赠送 3=系统 4=人工调整', - `reason` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '变更原因/备注', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_vip_flow_idem` (`order_no`, `change_type`), - KEY `idx_vip_flow_user` (`user_id`) -) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '会员变更流水表'; diff --git a/pkg/migration/000011_create_benefit_grant_tasks.down.sql b/pkg/migration/000011_create_benefit_grant_tasks.down.sql deleted file mode 100644 index f1d084b..0000000 --- a/pkg/migration/000011_create_benefit_grant_tasks.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS `benefit_grant_tasks`; diff --git a/pkg/migration/000011_create_benefit_grant_tasks.up.sql b/pkg/migration/000011_create_benefit_grant_tasks.up.sql deleted file mode 100644 index 4f04c02..0000000 --- a/pkg/migration/000011_create_benefit_grant_tasks.up.sql +++ /dev/null @@ -1,21 +0,0 @@ -CREATE TABLE `benefit_grant_tasks` ( - `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', - `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', - `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', - `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', - `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', - `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', - `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '用户ID', - `order_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '商户订单号', - `goods_id` BIGINT NOT NULL DEFAULT 0 COMMENT '关联商品ID', - `benefit_type` TINYINT NOT NULL DEFAULT 0 COMMENT '权益类型 1=vip 2=cos 3=ai 4=closet_share 5=outfit_share', - `amount` BIGINT NOT NULL DEFAULT 0 COMMENT '发放数量', - `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态 0=待发放 1=已发放 2=发放失败', - `retry_count` INT NOT NULL DEFAULT 0 COMMENT '已重试次数', - `max_retry` INT NOT NULL DEFAULT 0 COMMENT '最大重试次数', - `last_error` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '最近一次失败原因', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_benefit_task_idem` (`order_no`, `benefit_type`), - KEY `idx_benefit_task_status` (`status`) -) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '权益发放补偿任务表'; diff --git a/pkg/migration/000012_create_ai_flow_logs.down.sql b/pkg/migration/000012_create_ai_flow_logs.down.sql deleted file mode 100644 index aae5145..0000000 --- a/pkg/migration/000012_create_ai_flow_logs.down.sql +++ /dev/null @@ -1,2 +0,0 @@ -DROP TABLE IF EXISTS `ai_horoscope_flow_logs`; -DROP TABLE IF EXISTS `ai_recommend_flow_logs`; diff --git a/pkg/migration/000012_create_ai_flow_logs.up.sql b/pkg/migration/000012_create_ai_flow_logs.up.sql deleted file mode 100644 index 88f2fb6..0000000 --- a/pkg/migration/000012_create_ai_flow_logs.up.sql +++ /dev/null @@ -1,49 +0,0 @@ -CREATE TABLE `ai_recommend_flow_logs` ( - `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', - `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', - `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', - `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', - `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', - `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', - `request_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '请求唯一ID', - `server_request_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '服务请求唯一ID', - `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '用户ID', - `model_version` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '大模型版本', - `prompt_tokens` BIGINT NOT NULL DEFAULT 0 COMMENT '输入token数', - `completion_tokens` BIGINT NOT NULL DEFAULT 0 COMMENT '输出token数', - `result_content` TEXT COMMENT '推荐结果结构化数据', - `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态 1=成功 2=失败', - `fail_reason` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '失败原因', - `request_time` BIGINT NOT NULL DEFAULT 0 COMMENT '请求发起时间', - `response_time` BIGINT NOT NULL DEFAULT 0 COMMENT '响应返回时间', - PRIMARY KEY (`id`), - KEY `idx_ai_recommend_request` (`request_id`), - KEY `idx_ai_recommend_user` (`user_id`) -) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'AI穿搭推荐流水表'; - -CREATE TABLE `ai_horoscope_flow_logs` ( - `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', - `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', - `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', - `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', - `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', - `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', - `request_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '请求唯一ID(幂等依据)', - `zodiac` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '星座', - `server_request_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '服务请求唯一ID', - `stat_date` BIGINT NOT NULL DEFAULT 0 COMMENT '运势日期(YYYYMMDD)', - `model_version` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '大模型版本', - `prompt_tokens` BIGINT NOT NULL DEFAULT 0 COMMENT '输入token数', - `completion_tokens` BIGINT NOT NULL DEFAULT 0 COMMENT '输出token数', - `result_content` TEXT COMMENT '生成结果结构化数据', - `horoscope_id` BIGINT NOT NULL DEFAULT 0 COMMENT '生成的运势记录ID', - `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态 1=成功 2=失败', - `fail_reason` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '失败原因', - `request_time` BIGINT NOT NULL DEFAULT 0 COMMENT '请求发起时间', - `response_time` BIGINT NOT NULL DEFAULT 0 COMMENT '响应返回时间', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_ai_horoscope_request` (`request_id`), - KEY `idx_ai_horoscope_date_zodiac` (`stat_date`, `zodiac`) -) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'AI运势生成流水表'; diff --git a/pkg/migration/000013_create_interactions.down.sql b/pkg/migration/000013_create_interactions.down.sql deleted file mode 100644 index 7f0a773..0000000 --- a/pkg/migration/000013_create_interactions.down.sql +++ /dev/null @@ -1,2 +0,0 @@ -DROP TABLE IF EXISTS `likes`; -DROP TABLE IF EXISTS `collects`; diff --git a/pkg/migration/000013_create_interactions.up.sql b/pkg/migration/000013_create_interactions.up.sql deleted file mode 100644 index 75296fb..0000000 --- a/pkg/migration/000013_create_interactions.up.sql +++ /dev/null @@ -1,33 +0,0 @@ -CREATE TABLE `collects` ( - `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', - `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', - `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', - `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', - `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', - `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', - `collect_type` TINYINT NOT NULL DEFAULT 0 COMMENT '业务类型 1=社区穿搭', - `target_id` BIGINT NOT NULL DEFAULT 0 COMMENT '业务目标ID', - `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '收藏用户ID', - `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态 1=已收藏 0=已取消', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_collect_user_target` (`collect_type`, `target_id`, `user_id`), - KEY `idx_collects_user` (`user_id`) -) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '收藏表'; - -CREATE TABLE `likes` ( - `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', - `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', - `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', - `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', - `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', - `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', - `like_type` TINYINT NOT NULL DEFAULT 0 COMMENT '业务类型 1=社区穿搭', - `target_id` BIGINT NOT NULL DEFAULT 0 COMMENT '业务目标ID', - `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '点赞用户ID', - `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态 1=已点赞 0=已取消', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_like_user_target` (`like_type`, `target_id`, `user_id`), - KEY `idx_likes_user` (`user_id`) -) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '点赞表'; diff --git a/pkg/migration/000014_create_user_notifications.down.sql b/pkg/migration/000014_create_user_notifications.down.sql deleted file mode 100644 index 4e6c0ae..0000000 --- a/pkg/migration/000014_create_user_notifications.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS `user_notifications`; diff --git a/pkg/migration/000014_create_user_notifications.up.sql b/pkg/migration/000014_create_user_notifications.up.sql deleted file mode 100644 index 3f7ac31..0000000 --- a/pkg/migration/000014_create_user_notifications.up.sql +++ /dev/null @@ -1,20 +0,0 @@ -CREATE TABLE `user_notifications` ( - `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', - `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', - `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', - `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', - `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', - `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', - `notification_uuid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '消息UUID', - `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '接收用户ID', - `msg_type` TINYINT NOT NULL DEFAULT 0 COMMENT '消息类型 1=点赞通知 2=订单通知 3=系统通知', - `sender_id` BIGINT NOT NULL DEFAULT 0 COMMENT '发送者ID 系统消息为0', - `title` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '消息标题', - `content` TEXT COMMENT '消息内容', - `is_read` TINYINT NOT NULL DEFAULT 0 COMMENT '是否已读 1=未读 2=已读', - `read_time` BIGINT NOT NULL DEFAULT 0 COMMENT '阅读时间', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_notification_uuid` (`notification_uuid`), - KEY `idx_notifications_user_read` (`user_id`, `is_read`) -) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '用户通知表'; diff --git a/pkg/migration/000015_create_horoscopes.down.sql b/pkg/migration/000015_create_horoscopes.down.sql deleted file mode 100644 index 2fccc8d..0000000 --- a/pkg/migration/000015_create_horoscopes.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS `horoscopes`; diff --git a/pkg/migration/000015_create_horoscopes.up.sql b/pkg/migration/000015_create_horoscopes.up.sql deleted file mode 100644 index 68e0849..0000000 --- a/pkg/migration/000015_create_horoscopes.up.sql +++ /dev/null @@ -1,20 +0,0 @@ -CREATE TABLE `horoscopes` ( - `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', - `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', - `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', - `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', - `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', - `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', - `stat_date` BIGINT NOT NULL DEFAULT 0 COMMENT '运势日期(YYYYMMDD)', - `zodiac` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '星座', - `overall_score` TINYINT NOT NULL DEFAULT 0 COMMENT '综合运势 1-5分', - `love_score` TINYINT NOT NULL DEFAULT 0 COMMENT '爱情运势 1-5分', - `career_score` TINYINT NOT NULL DEFAULT 0 COMMENT '工作运势 1-5分', - `wealth_score` TINYINT NOT NULL DEFAULT 0 COMMENT '财富运势 1-5分', - `daily_text` TEXT COMMENT '今日运势详情', - `lucky_color` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '幸运色', - `lucky_number` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '幸运数字', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_horoscope_date_zodiac` (`stat_date`, `zodiac`) -) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '星座运势表'; diff --git a/pkg/migration/000016_create_audit_logs.down.sql b/pkg/migration/000016_create_audit_logs.down.sql deleted file mode 100644 index 3e9068a..0000000 --- a/pkg/migration/000016_create_audit_logs.down.sql +++ /dev/null @@ -1,2 +0,0 @@ -DROP TABLE IF EXISTS `outfit_audit_logs`; -DROP TABLE IF EXISTS `closet_audit_logs`; diff --git a/pkg/migration/000016_create_audit_logs.up.sql b/pkg/migration/000016_create_audit_logs.up.sql deleted file mode 100644 index 4e7eddc..0000000 --- a/pkg/migration/000016_create_audit_logs.up.sql +++ /dev/null @@ -1,31 +0,0 @@ -CREATE TABLE `closet_audit_logs` ( - `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', - `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', - `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', - `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', - `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', - `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', - `target_id` BIGINT NOT NULL DEFAULT 0 COMMENT '衣柜分享ID', - `audit_result` TINYINT NOT NULL DEFAULT 0 COMMENT '审核结果 1=通过 2=驳回', - `reject_reason` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '审核原因', - `audit_time` BIGINT NOT NULL DEFAULT 0 COMMENT '审核时间', - PRIMARY KEY (`id`), - KEY `idx_closet_audit_target` (`target_id`) -) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '衣柜分享审核日志表'; - -CREATE TABLE `outfit_audit_logs` ( - `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', - `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', - `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', - `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', - `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', - `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', - `target_id` BIGINT NOT NULL DEFAULT 0 COMMENT '穿搭ID', - `audit_result` TINYINT NOT NULL DEFAULT 0 COMMENT '审核结果 1=通过 2=驳回', - `reject_reason` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '审核原因', - `audit_time` BIGINT NOT NULL DEFAULT 0 COMMENT '审核时间', - PRIMARY KEY (`id`), - KEY `idx_outfit_audit_target` (`target_id`) -) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '穿搭审核日志表'; diff --git a/pkg/migration/000017_create_quota_grants.down.sql b/pkg/migration/000017_create_quota_grants.down.sql deleted file mode 100644 index 36842fb..0000000 --- a/pkg/migration/000017_create_quota_grants.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS `quota_grants`; diff --git a/pkg/migration/000017_create_quota_grants.up.sql b/pkg/migration/000017_create_quota_grants.up.sql deleted file mode 100644 index 07d544d..0000000 --- a/pkg/migration/000017_create_quota_grants.up.sql +++ /dev/null @@ -1,20 +0,0 @@ --- 额度凭证表:承载有有效期的赠送额度(会员包/加量包/广告激励)。 -CREATE TABLE `quota_grants` ( - `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', - `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', - `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', - `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', - `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', - `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', - `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '用户ID', - `quota_type` TINYINT NOT NULL DEFAULT 0 COMMENT '额度类型 1=cos上传 2=AI推荐 3=社区分享 4=衣柜分享', - `amount` BIGINT NOT NULL DEFAULT 0 COMMENT '赠送总量', - `used` BIGINT NOT NULL DEFAULT 0 COMMENT '已用量(Cos 永久额度扣减;AI/社区/衣柜 每日额度恒为 0)', - `source` TINYINT NOT NULL DEFAULT 0 COMMENT '来源 1=会员包 2=加量包 3=广告激励', - `expire_time` BIGINT NOT NULL DEFAULT 0 COMMENT '到期时间(秒,0 表示跟随 VIP 有效期)', - `ref_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '关联ID(订单号/广告记录ID)', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_grant_ref_type` (`ref_id`, `quota_type`), - KEY `idx_grant_user_type_expire` (`user_id`, `quota_type`, `expire_time`) -) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '额度凭证表(有有效期赠送额度)'; diff --git a/pkg/migration/000018_create_closet_share_receivers.down.sql b/pkg/migration/000018_create_closet_share_receivers.down.sql deleted file mode 100644 index 81339ec..0000000 --- a/pkg/migration/000018_create_closet_share_receivers.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS `closet_share_receivers`; diff --git a/pkg/migration/000018_create_closet_share_receivers.up.sql b/pkg/migration/000018_create_closet_share_receivers.up.sql deleted file mode 100644 index 24aecad..0000000 --- a/pkg/migration/000018_create_closet_share_receivers.up.sql +++ /dev/null @@ -1,15 +0,0 @@ --- 衣柜分享接收人关联表:记录"谁收到了谁的分享"(访问即视为收到,一对多、幂等)。 -CREATE TABLE `closet_share_receivers` ( - `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', - `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', - `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', - `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', - `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', - `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', - `share_uuid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '分享UUID', - `receiver_user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '接收人用户ID', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_closet_share_receiver` (`share_uuid`, `receiver_user_id`), - KEY `idx_closet_share_receivers_user` (`receiver_user_id`) -) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '衣柜分享接收人关联表'; diff --git a/pkg/migration/000019_create_outfit_shares.down.sql b/pkg/migration/000019_create_outfit_shares.down.sql deleted file mode 100644 index 498568e..0000000 --- a/pkg/migration/000019_create_outfit_shares.down.sql +++ /dev/null @@ -1,2 +0,0 @@ -DROP TABLE IF EXISTS `outfit_share_receivers`; -DROP TABLE IF EXISTS `outfit_shares`; diff --git a/pkg/migration/000019_create_outfit_shares.up.sql b/pkg/migration/000019_create_outfit_shares.up.sql deleted file mode 100644 index e5d97f3..0000000 --- a/pkg/migration/000019_create_outfit_shares.up.sql +++ /dev/null @@ -1,31 +0,0 @@ -CREATE TABLE `outfit_shares` ( - `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', - `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', - `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', - `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', - `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', - `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', - `share_uuid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '分享UUID', - `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '所属用户ID', - `outfit_id` BIGINT NOT NULL DEFAULT 0 COMMENT '穿搭ID', - `status` TINYINT NOT NULL DEFAULT 0 COMMENT '分享状态 1=正常 2=用户取消', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_outfit_shares_uuid` (`share_uuid`), - KEY `idx_outfit_shares_user_status` (`user_id`, `status`) -) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '穿搭分享表'; - -CREATE TABLE `outfit_share_receivers` ( - `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', - `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', - `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', - `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', - `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', - `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', - `share_uuid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '分享UUID', - `receiver_user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '接收人用户ID', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_outfit_share_receiver` (`share_uuid`, `receiver_user_id`), - KEY `idx_outfit_share_receivers_user` (`receiver_user_id`) -) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '穿搭分享接收人关联表'; diff --git a/pkg/migration/000020_harden_benefit_workflows.down.sql b/pkg/migration/000020_harden_benefit_workflows.down.sql deleted file mode 100644 index 100f59f..0000000 --- a/pkg/migration/000020_harden_benefit_workflows.down.sql +++ /dev/null @@ -1,3 +0,0 @@ -DROP TABLE IF EXISTS `benefit_revoke_tasks`; -ALTER TABLE `clothes` DROP COLUMN `original_image_path`; -ALTER TABLE `orders` DROP COLUMN `quota_valid_days`, DROP COLUMN `goods_type`; \ No newline at end of file diff --git a/pkg/migration/000020_harden_benefit_workflows.up.sql b/pkg/migration/000020_harden_benefit_workflows.up.sql deleted file mode 100644 index 4471ada..0000000 --- a/pkg/migration/000020_harden_benefit_workflows.up.sql +++ /dev/null @@ -1,14 +0,0 @@ -ALTER TABLE `orders` - ADD COLUMN `goods_type` TINYINT NOT NULL DEFAULT 0 COMMENT '商品类型快照' AFTER `member_days`, - ADD COLUMN `quota_valid_days` BIGINT NOT NULL DEFAULT 0 COMMENT '额度有效期天数快照' AFTER `goods_type`; -UPDATE `orders` o JOIN `goods` g ON g.id = o.goods_id -SET o.goods_type = g.goods_type, o.quota_valid_days = g.valid_days WHERE o.goods_type = 0; -ALTER TABLE `clothes` ADD COLUMN `original_image_path` VARCHAR(1000) NOT NULL DEFAULT '' COMMENT '待处理原图本地路径' AFTER `processed_image`; -CREATE TABLE `benefit_revoke_tasks` ( - `id` BIGINT NOT NULL AUTO_INCREMENT, `created_at` BIGINT NOT NULL DEFAULT 0, `updated_at` BIGINT NOT NULL DEFAULT 0, - `deleted_at` BIGINT NOT NULL DEFAULT 0, `created_by` BIGINT NOT NULL DEFAULT 0, `updated_by` BIGINT NOT NULL DEFAULT 0, - `deleted_by` BIGINT NOT NULL DEFAULT 0, `order_no` VARCHAR(64) NOT NULL DEFAULT '', `user_id` BIGINT NOT NULL DEFAULT 0, - `status` TINYINT NOT NULL DEFAULT 0, `retry_count` INT NOT NULL DEFAULT 0, `max_retry` INT NOT NULL DEFAULT 10, - `last_error` VARCHAR(255) NOT NULL DEFAULT '', PRIMARY KEY (`id`), UNIQUE KEY `uk_benefit_revoke_order` (`order_no`), - KEY `idx_benefit_revoke_status` (`status`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='退款权益回收任务'; \ No newline at end of file diff --git a/pkg/tokenhub/outfit_recommend.go b/pkg/tokenhub/outfit_recommend.go index a36d19d..68665b5 100644 --- a/pkg/tokenhub/outfit_recommend.go +++ b/pkg/tokenhub/outfit_recommend.go @@ -30,9 +30,10 @@ type RecommendLLMResponse struct { } // GenerateOutfitRecommendation 调用混元生成今日穿搭推荐。 -func (c *Client) GenerateOutfitRecommendation(ctx context.Context, zodiac string, fortune *FortuneItem, clothes []mysqlmodel.Clothes) (*RecommendLLMResponse, error) { +// scene 为用户自定义场景(前端 scene_type=2 时传入),为空表示由模型自行判断。 +func (c *Client) GenerateOutfitRecommendation(ctx context.Context, zodiac string, fortune *FortuneItem, clothes []mysqlmodel.Clothes, scene string) (*RecommendLLMResponse, error) { messages := []Message{ - {Role: "user", Content: buildRecommendPrompt(zodiac, fortune, clothes)}, + {Role: "user", Content: buildRecommendPrompt(zodiac, fortune, clothes, scene)}, } responseFormat := &ResponseFormat{ @@ -67,9 +68,16 @@ func (c *Client) GenerateOutfitRecommendation(ctx context.Context, zodiac string } // buildRecommendPrompt 构造穿搭推荐 prompt。 -func buildRecommendPrompt(zodiac string, fortune *FortuneItem, clothes []mysqlmodel.Clothes) string { +// scene 为用户自定义场景(已做注入清洗):非空时作为硬约束写入 prompt,并指示模型在 scene 字段回填; +// 为空时整段不出现,保持「由模型自行判断场景」的原行为。 +func buildRecommendPrompt(zodiac string, fortune *FortuneItem, clothes []mysqlmodel.Clothes, scene string) string { clothesText := FormatClothesInfo(clothes) + sceneSection := "" + if s := sanitizePromptField(scene); s != "" { + sceneSection = fmt.Sprintf("\n## 用户指定场景\n用户希望这套搭配用于「%s」,请优先围绕该场景挑选单品,并在 scene 字段回填该场景。\n", s) + } + return fmt.Sprintf(`你是一位专业的时尚搭配师。请根据用户今天的星座运势和衣橱中的衣物,为用户推荐一套今日穿搭。 ## 用户信息 @@ -82,7 +90,7 @@ func buildRecommendPrompt(zodiac string, fortune *FortuneItem, clothes []mysqlmo ## 用户衣橱 %s - +%s ## 搭配规则 1. 所有推荐单品必须严格选自用户衣橱,clothes_id 与 clothes_uuid 必须与衣橱中存在的衣物一一对应,不得虚构 2. 先识别衣橱中包含的有效品类,仅统计以下 6 类:上装、下装、连衣裙、外套、鞋、配饰: @@ -113,7 +121,7 @@ func buildRecommendPrompt(zodiac string, fortune *FortuneItem, clothes []mysqlmo - items 数组长度由衣橱中存在的品类数量决定,不存在的品类不写入数组 - clothes_id 必须为整数,且必须是衣橱中真实存在的 ID - clothes_uuid 必须从「用户衣橱」信息中精确复制,且与 clothes_id 一一对应,严禁编造或改动 UUID`, - zodiac, fortune.Overall, fortune.Love, fortune.Work, fortune.Money, fortune.Description, clothesText) + zodiac, fortune.Overall, fortune.Love, fortune.Work, fortune.Money, fortune.Description, clothesText, sceneSection) } // buildRecommendSchema 构造 JSON Schema 要求大模型结构化输出。 diff --git a/接口文档.md b/接口文档.md index 1b81cd7..d3685c9 100644 --- a/接口文档.md +++ b/接口文档.md @@ -283,12 +283,13 @@ Authorization: Bearer | PUT | `/clothes/:uuid` | 更新衣物属性 | | DELETE | `/clothes/:uuid` | 删除衣物(满足条件时回退上传额度) | -**穿搭(6)** +**穿搭(7)** | 方法 | 路径 | 说明 | | --- | --- | --- | | POST | `/outfit` | 创建穿搭 | | GET | `/outfit` | 我的穿搭列表 | +| GET | `/outfit/community` | 社区穿搭列表(其他用户公开的穿搭) | | GET | `/outfit/:uuid` | 穿搭详情 | | PUT | `/outfit/:uuid` | 更新穿搭 | | DELETE | `/outfit/:uuid` | 删除穿搭 | @@ -315,7 +316,7 @@ Authorization: Bearer | 方法 | 路径 | 说明 | | --- | --- | --- | -| POST | `/outfit-share` | 创建穿搭分享(不扣额度,需穿搭已公开) | +| POST | `/outfit-share` | 创建穿搭分享(无需公开到社区;私密穿搭扣 1 次衣柜分享额度) | | GET | `/outfit-share` | 我的穿搭分享列表 | | GET | `/outfit-share/received` | 分享给我的穿搭分享 | | GET | `/outfit-share/:uuid` | 穿搭分享详情(属主 / 访客视角) | @@ -350,7 +351,7 @@ Authorization: Bearer | POST | `/order/:orderNo/cancel` | 取消订单 | | POST | `/order/refund` | 申请退款 | -> **注意**:当前**没有"社区广场 / 发现页"列表接口**。公开穿搭只能通过 UUID 直接访问详情(`GET /outfit/:uuid`),或出现在"我的收藏"列表中。 +> **说明**:社区广场列表接口为 `GET /outfit/community`(分页返回其他用户已公开且审核通过的穿搭,含作者信息与点赞/收藏计数)。 --- @@ -794,9 +795,9 @@ Authorization: Bearer **业务逻辑(可见性)** - 属主:始终可见。 -- 非属主:**仅当携带有效 `share_uuid`**(该分享正常、穿搭仍公开可见、且该衣物属于该分享的穿搭明细)时返回**只读**衣物信息;否则 `50005`。 +- 非属主:**仅当携带有效 `share_uuid`**(该分享正常、穿搭未被删除 / 封禁、且该衣物属于该分享的穿搭明细)时返回**只读**衣物信息;否则 `50005`。 -**错误码**:`10000` 参数错误(uuid 为空)· `50000` 衣物不存在 · `50005` 无权操作该衣物 · `90103` 穿搭分享不存在(含穿搭已删除 / 取消公开)· `90104` 穿搭分享已取消 +**错误码**:`10000` 参数错误(uuid 为空)· `50000` 衣物不存在 · `50005` 无权操作该衣物 · `90103` 穿搭分享不存在(含穿搭已删除 / 已封禁)· `90104` 穿搭分享已取消 --- @@ -932,6 +933,49 @@ Authorization: Bearer --- +#### GET `/outfit/community` — 社区穿搭列表(鉴权) + +**业务逻辑** + +- 返回**其他用户**已公开的穿搭:`is_public=2` + `audit_status=2` + `status=1`(与详情页他人可见性口径一致),并**排除本人**的穿搭(自己的公开穿搭在"我的穿搭"中查看)。 +- 列表按发布时间倒序(`id DESC`)。 +- 每项聚合作者信息与互动计数:`owner_name` / `owner_avatar`(无头像为空串)、`like_count` / `collect_count`(仅统计有效点赞/收藏)。 + +**请求**(query) + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| page / page_size | int | 否 | 分页,page_size 上限 50 | + +**响应** `data` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| total | int64 | 总条数 | +| page | int | 当前页 | +| page_size | int | 每页条数 | +| list | array | 社区穿搭数组,见下表 | + +`list[]`: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| outfit_uuid | string | 穿搭 UUID | +| outfit_name | string | 穿搭名称 | +| outfit_desc | string | 穿搭描述 | +| scene | string | 适合场景 | +| source | int | `1`=用户创建 `2`=AI 推荐 | +| created_at | int64 | 发布时间(秒级时间戳) | +| items | array | 衣物明细 `[{clothes_uuid, processed_image}]`,`processed_image` 为可访问 URL | +| owner_name | string | 作者昵称 | +| owner_avatar | string | 作者头像 URL(无则空串) | +| like_count | int64 | 点赞数 | +| collect_count | int64 | 收藏数 | + +**错误码**:`10000` 参数错误 · `19001` 数据库错误 + +--- + #### GET `/outfit/:uuid` — 穿搭详情(鉴权) **请求**(path) @@ -944,6 +988,7 @@ Authorization: Bearer - 本人穿搭:始终可见。 - 他人穿搭:必须同时满足 `is_public=2` + `audit_status=2` + `status=1`,否则返回 `40004`。 +- 通过好友分享链接查看穿搭请使用 `GET /outfit-share/:uuid`(不要求穿搭已公开)。 **响应** `data` = [穿搭对象](#43-穿搭对象-outfititem) @@ -1236,15 +1281,20 @@ Authorization: Bearer **状态枚举** `status`:`1`=正常 `2`=用户取消 -> 穿搭分享用于把「自己的公开穿搭」以链接形式分享给好友。与衣柜分享不同,**创建分享不消耗任何额度**,但**要求穿搭已公开可见**(`is_public=2` 且 `audit_status=2` 且 `status=1`);一旦穿搭取消公开或被删除,相关分享即视为失效。 +> 穿搭分享用于把「自己的穿搭」以链接形式分享给好友,**与「公开到社区」完全解耦**:仅自己可见的穿搭同样可以分享,好友凭 `share_uuid` 只读查看整套穿搭与单品图。 +> +> **额度**:分享**仅自己可见**的穿搭扣 1 次**衣柜分享额度**(`quota_type=4`,与衣柜分享共用同一个额度池);分享**已公开**的穿搭**不扣**(公开到社区时已扣过社区分享额度)。额度不足 → `80000` 且不创建分享。 +> +> 分享一旦发出即独立于穿搭的公开状态——取消公开不影响已发出的分享,仅「取消分享」「删除穿搭」「封禁穿搭」才使分享失效。 #### POST `/outfit-share` — 创建穿搭分享(鉴权) **业务逻辑** 1. 仅能分享**本人**的穿搭(他人 → `40004`)。 -2. 穿搭必须已公开可见,否则 `10000` + `details:["请先公开该穿搭再分享"]`。 -3. **不扣额度**;创建成功后返回 `share_uuid`,分享链接携带该值。 +2. **无需先公开到社区**:仅自己可见的穿搭同样可分享(分享与公开解耦);穿搭已被删除 → `40000`,已被封禁 → `40004`。 +3. **额度**:`is_public=1`(仅自己可见)→ 扣 1 次衣柜分享额度(`quota_type=4`,与衣柜分享共用池),额度不足 → `80000` 且**不创建分享**;`is_public=2`(已公开)→ **不扣**。扣额成功后分享落库失败会**自动回退** 1 次额度。 +4. 创建成功后返回 `share_uuid`,分享链接携带该值。 **请求**(json) @@ -1258,7 +1308,7 @@ Authorization: Bearer | --- | --- | --- | | share_uuid | string | 分享 UUID,供他人访问分享详情 | -**错误码**:`10000` 参数错误 · `40000` 穿搭不存在 · `40004` 无权操作该穿搭 · `19001` 数据库错误 +**错误码**:`10000` 参数错误 · `40000` 穿搭不存在 · `40004` 无权操作该穿搭 · `80000` 额度不足 · `19001` 数据库错误 --- @@ -1295,7 +1345,7 @@ Authorization: Bearer **业务逻辑** - "访问即视为收到":登录用户(非分享属主)成功访问他人穿搭分享详情时,自动记录接收关系;重复访问不重复记录,属主访问不记录。 -- 列表按接收记录倒序分页返回,支持按 `status` 筛选。 +- 列表按接收记录倒序分页返回,支持按 `status` 筛选;**仅排除已删除 / 已封禁的穿搭**(穿搭是否公开不影响,分享与公开解耦)。 - 字段同「我的穿搭分享列表」,并额外返回 `owner_name` / `owner_avatar`。 **错误码**:`10000` 参数错误 · `19001` 数据库错误 @@ -1308,7 +1358,7 @@ Authorization: Bearer - 属主与访客都可访问(需已登录,`is_owner` 区分视角)。 - **访客访问成功即记录接收关系**;属主访问不记录。 -- 分享已取消 → `90104`;分享不存在或对应穿搭已删除 / 取消公开 → `90103`。 +- 分享已取消 → `90104`;分享不存在或对应穿搭已删除 / 已封禁 → `90103`(穿搭取消公开**不影响**已发出的分享)。 **请求**(path) @@ -1327,7 +1377,7 @@ Authorization: Bearer | is_owner | bool | 是否本人分享 | | outfit | object | [穿搭对象](#43-穿搭对象-outfititem),`items[]` 含 `processed_image`(可直接展示) | -**错误码**:`10000` 参数错误 · `90103` 穿搭分享不存在(含穿搭已删除 / 取消公开)· `90104` 穿搭分享已取消 +**错误码**:`10000` 参数错误 · `90103` 穿搭分享不存在(含穿搭已删除 / 已封禁)· `90104` 穿搭分享已取消 --- @@ -1869,6 +1919,9 @@ stateDiagram-v2 | **取消公开后再次公开** | **再扣 1 个额度** | ⚠️ 重点提示用户 | | 穿搭取消公开 | **不返还** | — | | 穿搭删除 | **不返还** | — | +| 分享「仅自己可见」的穿搭给好友 | **扣 1 个衣柜分享额度**(与衣柜分享共用池) | 额度不足时引导购买 | +| 分享「已公开」的穿搭给好友 | **不扣**(公开到社区时已扣社区分享额度) | — | +| 私密穿搭分享但分享记录落库失败 | **自动回退** | 重试即可 | | AI 推荐生成(成功) | **扣 1 个 AI 额度** | 生成前展示剩余次数 | | AI 推荐失败(衣橱为空/模型失败/无有效单品/落库失败) | **自动回退** | 可安全重试 | | **AI 推荐成功但未保存** | **不返还** | ⚠️ 重点提示用户及时保存 | @@ -1878,7 +1931,7 @@ stateDiagram-v2 | 购买商品支付成功 | **发放**(按订单快照) | 展示"已到账" | | 退款成功 | **回收**(`total >= used`,已用部分不退) | 提示额度已回收 | -**额度不足时**:前端跳商品列表引导购买。注意 AI 额度不足返回的是 `90401`(不是 `80000`)。 +**额度不足时**:前端跳商品列表引导购买。注意 AI 额度不足返回的是 `90401`(不是 `80000`)。私密穿搭分享额度不足返回 `80000`,此时**不要**回退为普通穿搭详情链接(私密穿搭对他人不可见,对方打开会是无权限的空页),应提示用户补充衣柜分享额度。 --- @@ -2220,8 +2273,8 @@ A:过期是**惰性**的。前端需同时判断 `status` 与 `expire_time`。 **Q:`GET /outfit` 里混进了 AI 推荐的穿搭?** A:列表**不过滤 `status=3`(待用户保存)**。前端用 `source` 区分来源,AI 未保存的单独入口展示。 -**Q:没有"社区广场"列表接口怎么办?** -A:当前确实没有。公开穿搭只能通过 UUID 直接访问详情,或在"我的收藏"中出现。如需广场列表,需后端新增接口。 +**Q:如何浏览"社区广场"的公开穿搭?** +A:调用 `GET /outfit/community` 分页获取其他用户已公开(公开 + 审核通过 + 正常)的穿搭列表,含作者信息与点赞/收藏计数;点击卡片进入 `GET /outfit/:uuid` 详情(他人视角)互动。 ### 9.2 联调检查清单