This commit is contained in:
2026-09-15 16:02:45 +08:00
parent 112c5f8e46
commit ade2453083
70 changed files with 1441 additions and 707 deletions
+6 -2
View File
@@ -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 推理服务(可选,推荐)
+1
View File
@@ -42,6 +42,7 @@
9. **收到的穿搭分享在取消或转私后仍泄露内容**
- 接收列表联查分享和穿搭,仅返回分享正常、穿搭公开、审核通过且状态正常的记录。
- ⚠️ 后续策略变更(见 `接口文档.md` §5.8):「分享给好友」与「公开到社区」已解耦,仅自己可见的穿搭同样可分享;接收列表与分享详情改为**只排除已删除 / 已封禁的穿搭**,不再按 `is_public` / `audit_status` 过滤。此处"转私后不再下发"的旧结论已作废,请勿据此回退代码。
10. **穿搭、AI 穿搭和衣柜分享的主记录与明细非事务写入**
- 创建、更新重建明细和删除统一使用数据库事务,明细失败时主记录一并回滚。
+26
View File
@@ -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"`
+42
View File
@@ -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")
+39 -3
View File
@@ -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
}
+28
View File
@@ -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")
+22
View File
@@ -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
+10 -2
View File
@@ -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)
-23
View File
@@ -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")
+67
View File
@@ -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
}
+9
View File
@@ -0,0 +1,9 @@
package redismodel
// 虚拟支付相关 Redis key。
const (
// KeyVirtualPayUnpaid 查单补偿的「已确认未支付」负缓存:order_no。
// 命中表示最近一次 query_order 反查结果为未支付,本轮跳过该订单,
// 避免大量「下单后不付款」的积压订单长期占满扫描名额,把真正已支付的订单挤出去。
KeyVirtualPayUnpaid = RedisPrefix + "xpay:unpaid:%s"
)
+21
View File
@@ -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)
+2
View File
@@ -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)
+2 -1
View File
@@ -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, "大模型调用失败")
}
+1
View File
@@ -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,
})
}
+28 -3
View File
@@ -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)
+24 -10
View File
@@ -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
}
}
+19 -10
View File
@@ -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
}
}
+35 -21
View File
@@ -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 将通知模型映射为响应。
+10 -1
View File
@@ -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
}
// 条件流转:待支付 -> 已支付,防重复回调。
+117
View File
@@ -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)}
+22 -6
View File
@@ -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
}
+23 -11
View File
@@ -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
+33 -1
View File
@@ -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)
}
+89 -11
View File
@@ -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
}
}
+9
View File
@@ -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 {
@@ -1 +0,0 @@
DROP TABLE IF EXISTS `users`;
-28
View File
@@ -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 = '用户表';
+53
View File
@@ -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`;
+622
View File
@@ -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 = '穿搭审核日志表';
@@ -1 +0,0 @@
DROP TABLE IF EXISTS `clothes`;
@@ -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 = '衣物表';
@@ -1,2 +0,0 @@
DROP TABLE IF EXISTS `outfit_items`;
DROP TABLE IF EXISTS `outfits`;
@@ -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 = '穿搭明细表';
@@ -1,2 +0,0 @@
DROP TABLE IF EXISTS `closet_share_items`;
DROP TABLE IF EXISTS `closet_shares`;
@@ -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 = '衣柜分享明细表';
@@ -1,2 +0,0 @@
DROP TABLE IF EXISTS `user_daily_usages`;
DROP TABLE IF EXISTS `user_quotas`;
@@ -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 = '用户每日用量统计表';
@@ -1 +0,0 @@
DROP TABLE IF EXISTS `quota_flow_logs`;
@@ -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 = '配额流水日志表';
@@ -1 +0,0 @@
DROP TABLE IF EXISTS `goods`;
-28
View File
@@ -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 = '商品表';
@@ -1 +0,0 @@
DROP TABLE IF EXISTS `orders`;
-37
View File
@@ -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 = '订单表';
@@ -1 +0,0 @@
DROP TABLE IF EXISTS `refund_flow_logs`;
@@ -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 = '退款流水日志表';
@@ -1 +0,0 @@
DROP TABLE IF EXISTS `vip_flow_logs`;
@@ -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 = '会员变更流水表';
@@ -1 +0,0 @@
DROP TABLE IF EXISTS `benefit_grant_tasks`;
@@ -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 = '权益发放补偿任务表';
@@ -1,2 +0,0 @@
DROP TABLE IF EXISTS `ai_horoscope_flow_logs`;
DROP TABLE IF EXISTS `ai_recommend_flow_logs`;
@@ -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运势生成流水表';
@@ -1,2 +0,0 @@
DROP TABLE IF EXISTS `likes`;
DROP TABLE IF EXISTS `collects`;
@@ -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 = '点赞表';
@@ -1 +0,0 @@
DROP TABLE IF EXISTS `user_notifications`;
@@ -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 = '用户通知表';
@@ -1 +0,0 @@
DROP TABLE IF EXISTS `horoscopes`;
@@ -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 = '星座运势表';
@@ -1,2 +0,0 @@
DROP TABLE IF EXISTS `outfit_audit_logs`;
DROP TABLE IF EXISTS `closet_audit_logs`;
@@ -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 = '穿搭审核日志表';
@@ -1 +0,0 @@
DROP TABLE IF EXISTS `quota_grants`;
@@ -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 = '额度凭证表(有有效期赠送额度)';
@@ -1 +0,0 @@
DROP TABLE IF EXISTS `closet_share_receivers`;
@@ -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 = '衣柜分享接收人关联表';
@@ -1,2 +0,0 @@
DROP TABLE IF EXISTS `outfit_share_receivers`;
DROP TABLE IF EXISTS `outfit_shares`;
@@ -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 = '穿搭分享接收人关联表';
@@ -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`;
@@ -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='退款权益回收任务';
+13 -5
View File
@@ -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 要求大模型结构化输出。
+68 -15
View File
@@ -283,12 +283,13 @@ Authorization: Bearer <access_token>
| 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 <access_token>
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| POST | `/outfit-share` | 创建穿搭分享(不扣额度,需穿搭已公开) |
| POST | `/outfit-share` | 创建穿搭分享(无需公开到社区;私密穿搭扣 1 次衣柜分享额度) |
| GET | `/outfit-share` | 我的穿搭分享列表 |
| GET | `/outfit-share/received` | 分享给我的穿搭分享 |
| GET | `/outfit-share/:uuid` | 穿搭分享详情(属主 / 访客视角) |
@@ -350,7 +351,7 @@ Authorization: Bearer <access_token>
| POST | `/order/:orderNo/cancel` | 取消订单 |
| POST | `/order/refund` | 申请退款 |
> **注意**:当前**没有"社区广场 / 发现页"列表接口**。公开穿搭只能通过 UUID 直接访问详情(`GET /outfit/:uuid`),或出现在"我的收藏"列表中。
> **说明**:社区广场列表接口为 `GET /outfit/community`(分页返回其他用户已公开且审核通过的穿搭,含作者信息与点赞/收藏计数)。
---
@@ -794,9 +795,9 @@ Authorization: Bearer <access_token>
**业务逻辑(可见性)**
- 属主:始终可见。
- 非属主:**仅当携带有效 `share_uuid`**(该分享正常、穿搭仍公开可见、且该衣物属于该分享的穿搭明细)时返回**只读**衣物信息;否则 `50005`。
- 非属主:**仅当携带有效 `share_uuid`**(该分享正常、穿搭未被删除 / 封禁、且该衣物属于该分享的穿搭明细)时返回**只读**衣物信息;否则 `50005`。
**错误码**:`10000` 参数错误(uuid 为空)· `50000` 衣物不存在 · `50005` 无权操作该衣物 · `90103` 穿搭分享不存在(含穿搭已删除 / 取消公开)· `90104` 穿搭分享已取消
**错误码**:`10000` 参数错误(uuid 为空)· `50000` 衣物不存在 · `50005` 无权操作该衣物 · `90103` 穿搭分享不存在(含穿搭已删除 / 已封禁)· `90104` 穿搭分享已取消
---
@@ -932,6 +933,49 @@ Authorization: Bearer <access_token>
---
#### 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 <access_token>
- 本人穿搭:始终可见。
- 他人穿搭:必须同时满足 `is_public=2` + `audit_status=2` + `status=1`,否则返回 `40004`。
- 通过好友分享链接查看穿搭请使用 `GET /outfit-share/:uuid`(不要求穿搭已公开)。
**响应** `data` = [穿搭对象](#43-穿搭对象-outfititem)
@@ -1236,15 +1281,20 @@ Authorization: Bearer <access_token>
**状态枚举** `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 <access_token>
| --- | --- | --- |
| share_uuid | string | 分享 UUID,供他人访问分享详情 |
**错误码**:`10000` 参数错误 · `40000` 穿搭不存在 · `40004` 无权操作该穿搭 · `19001` 数据库错误
**错误码**:`10000` 参数错误 · `40000` 穿搭不存在 · `40004` 无权操作该穿搭 · `80000` 额度不足 · `19001` 数据库错误
---
@@ -1295,7 +1345,7 @@ Authorization: Bearer <access_token>
**业务逻辑**
- "访问即视为收到":登录用户(非分享属主)成功访问他人穿搭分享详情时,自动记录接收关系;重复访问不重复记录,属主访问不记录。
- 列表按接收记录倒序分页返回,支持按 `status` 筛选。
- 列表按接收记录倒序分页返回,支持按 `status` 筛选;**仅排除已删除 / 已封禁的穿搭**(穿搭是否公开不影响,分享与公开解耦)。
- 字段同「我的穿搭分享列表」,并额外返回 `owner_name` / `owner_avatar`。
**错误码**:`10000` 参数错误 · `19001` 数据库错误
@@ -1308,7 +1358,7 @@ Authorization: Bearer <access_token>
- 属主与访客都可访问(需已登录,`is_owner` 区分视角)。
- **访客访问成功即记录接收关系**;属主访问不记录。
- 分享已取消 → `90104`;分享不存在或对应穿搭已删除 / 取消公开 → `90103`。
- 分享已取消 → `90104`;分享不存在或对应穿搭已删除 / 已封禁 → `90103`(穿搭取消公开**不影响**已发出的分享)。
**请求**(path)
@@ -1327,7 +1377,7 @@ Authorization: Bearer <access_token>
| 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 联调检查清单