This commit is contained in:
2026-09-15 23:04:13 +08:00
parent ade2453083
commit 8c2ea84939
17 changed files with 749 additions and 161 deletions
+21
View File
@@ -6,6 +6,27 @@ type AIRecommendCreateRequest struct {
Scene string `json:"scene"` // 用户自定义场景描述
}
// AIRecommendSubmitResponse 提交 AI 推荐任务响应。
// 接口不等待大模型,立即返回 task_id;前端拿到后轮询 AIRecommendStatusRequest 对应的接口获取结果。
type AIRecommendSubmitResponse struct {
TaskID string `json:"task_id"` // 任务ID,用于轮询结果
Status int8 `json:"status"` // 任务状态 1=生成中 2=成功 3=失败
}
// AIRecommendStatusRequest 查询 AI 推荐任务状态
type AIRecommendStatusRequest struct {
TaskID string `form:"task_id" binding:"required"` // 任务ID
}
// AIRecommendStatusResponse 任务状态响应
type AIRecommendStatusResponse struct {
TaskID string `json:"task_id"`
Status int8 `json:"status"` // 1=生成中 2=成功 3=失败
Result *AIRecommendCreateResponse `json:"result,omitempty"` // status=2 时返回推荐结果
ErrorCode int `json:"error_code,omitempty"` // status=3 时返回业务错误码
ErrorMsg string `json:"error_msg,omitempty"` // status=3 时返回失败原因
}
// AI推荐结果
type AIRecommendCreateResponse struct {
OutfitUUID string `json:"outfit_uuid"` // 穿搭UUID
+1
View File
@@ -23,6 +23,7 @@ type OutfitCreateResponse struct {
Source int8 `json:"source"` // 1=用户创建 2=AI推荐
IsPublic int8 `json:"is_public"` // 1=不公开 2=公开
AuditStatus int8 `json:"audit_status"` // 1=待审核 2=通过 3=不通过
Status int8 `json:"status"` // 1=正常 2=封禁 3=待用户保存(AI 推荐生成后未保存)
Items []OutfitItemResponse `json:"items"`
}
+30
View File
@@ -187,6 +187,36 @@ func (d *OutfitDao) OutfitListCommunity(ctx context.Context, excludeUserID int64
return list, total, nil
}
// OutfitCleanupExpiredUnsavedAI 清理「超过保留期仍未保存」的 AI 穿搭(连带明细),返回清理条数。
// 状态字面量:source 2=AI 推荐;status 3=待用户保存。软删除后列表与详情立即不可见。
// 分批 + 限量:单次最多处理 limit 条,避免长事务持锁。
func (d *OutfitDao) OutfitCleanupExpiredUnsavedAI(ctx context.Context, createdBefore int64, limit int) (int64, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitCleanupExpiredUnsavedAI")
defer span.End()
var ids []int64
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Outfit{}).
Where("source = ? AND status = ? AND created_at < ?", int8(2), int8(3), createdBefore).
Order("id ASC").Limit(limit).Pluck("id", &ids).Error; err != nil {
span.RecordError(err)
return 0, err
}
if len(ids) == 0 {
return 0, nil
}
if err := d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("outfit_id IN ?", ids).Delete(&mysqlmodel.OutfitItem{}).Error; err != nil {
return err
}
return tx.Where("id IN ?", ids).Delete(&mysqlmodel.Outfit{}).Error
}); err != nil {
span.RecordError(err)
return 0, err
}
return int64(len(ids)), nil
}
// OutfitItemDao 穿搭明细数据访问
type OutfitItemDao struct {
db *gorm.DB
+126
View File
@@ -0,0 +1,126 @@
package redisdao
import (
"context"
"encoding/json"
"fmt"
"time"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel"
"github.com/redis/go-redis/v9"
)
// aiRecommendTaskTTL 任务快照保留时长,需覆盖前端轮询窗口。
const aiRecommendTaskTTL = 15 * time.Minute
// aiRecommendReleaseScript 仅当活跃位仍属于本次任务时才删除,
// 避免任务超时释放后又被后续任务占用,却被旧任务误删。
var aiRecommendReleaseScript = redis.NewScript(`
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
`)
// AIRecommendTask AI 推荐异步任务快照。
type AIRecommendTask struct {
TaskID string `json:"task_id"`
UserID int64 `json:"user_id"`
Status int8 `json:"status"` // 1=进行中 2=成功 3=失败
Result json.RawMessage `json:"result,omitempty"`
ErrorCode int `json:"error_code,omitempty"`
ErrorMsg string `json:"error_msg,omitempty"`
CreateTime int64 `json:"create_time"`
}
// AIRecommendTaskAcquire 抢占用户的 AI 推荐活跃位。
// - 抢占成功:(true, "", nil);
// - 已有任务在执行:(false, 进行中的 task_id, nil),调用方应把它当作幂等结果返回;
// - Redis 异常:(false, "", err)。
func AIRecommendTaskAcquire(ctx context.Context, userID int64, taskID string, ttl time.Duration) (bool, string, error) {
ctx, span := global.Tracer.Start(ctx, "redisdao.AIRecommendTaskAcquire")
defer span.End()
key := fmt.Sprintf(redismodel.KeyLockAIRecommend, userID)
ok, err := global.RedisEngine.SetNX(ctx, key, taskID, ttl).Result()
if err != nil {
span.RecordError(err)
return false, "", err
}
if ok {
return true, "", nil
}
// 已有任务在跑:读回它的 task_id,让前端继续轮询同一个任务。
running, gerr := global.RedisEngine.Get(ctx, key).Result()
if gerr == redis.Nil {
// 活跃位正好在两次调用之间过期,交由上层按「稍后重试」处理。
return false, "", nil
}
if gerr != nil {
span.RecordError(gerr)
return false, "", gerr
}
return false, running, nil
}
// AIRecommendTaskRelease 释放活跃位,仅当值仍等于本次 task_id 时才删除。
// 失败仅由调用方记录,不阻断主流程。
func AIRecommendTaskRelease(ctx context.Context, userID int64, taskID string) error {
if global.RedisEngine == nil {
return nil
}
ctx, span := global.Tracer.Start(ctx, "redisdao.AIRecommendTaskRelease")
defer span.End()
key := fmt.Sprintf(redismodel.KeyLockAIRecommend, userID)
if err := aiRecommendReleaseScript.Run(ctx, global.RedisEngine, []string{key}, taskID).Err(); err != nil {
span.RecordError(err)
return err
}
return nil
}
// AIRecommendTaskSave 写入任务快照(覆盖式),TTL 由 aiRecommendTaskTTL 决定。
func AIRecommendTaskSave(ctx context.Context, task *AIRecommendTask) error {
ctx, span := global.Tracer.Start(ctx, "redisdao.AIRecommendTaskSave")
defer span.End()
data, err := json.Marshal(task)
if err != nil {
span.RecordError(err)
return fmt.Errorf("marshal ai recommend task: %w", err)
}
key := fmt.Sprintf(redismodel.KeyAIRecommendTask, task.TaskID)
if err := global.RedisEngine.Set(ctx, key, data, aiRecommendTaskTTL).Err(); err != nil {
span.RecordError(err)
return err
}
return nil
}
// AIRecommendTaskGet 读取任务快照,任务不存在或已过期返回 (nil, nil)。
func AIRecommendTaskGet(ctx context.Context, taskID string) (*AIRecommendTask, error) {
ctx, span := global.Tracer.Start(ctx, "redisdao.AIRecommendTaskGet")
defer span.End()
key := fmt.Sprintf(redismodel.KeyAIRecommendTask, taskID)
data, err := global.RedisEngine.Get(ctx, key).Bytes()
if err == redis.Nil {
return nil, nil
}
if err != nil {
span.RecordError(err)
return nil, err
}
var task AIRecommendTask
if err := json.Unmarshal(data, &task); err != nil {
span.RecordError(err)
return nil, fmt.Errorf("unmarshal ai recommend task: %w", err)
}
return &task, nil
}
+12
View File
@@ -0,0 +1,12 @@
package redismodel
// AI 穿搭推荐异步任务。
//
// 推荐生成被拆成「提交任务 + 后台执行 + 前端轮询」:
// - 活跃位复用 KeyLockAIRecommend(lock:ai:recommend:%d),值为进行中的 task_id,
// 既做同一用户互斥,也让重复提交能拿回同一个任务而不是报「请求过于频繁」;
// - 任务快照独立存储,供前端按 task_id 轮询,生命周期不绑定 HTTP 连接。
const (
// KeyAIRecommendTask 任务快照(JSON),task_id 维度。
KeyAIRecommendTask = RedisPrefix + "ai:recommend:task:%s"
)
+1 -1
View File
@@ -5,7 +5,7 @@ const (
KeyLockOrderCreate = RedisPrefix + "lock:order:create:%d" // order_id,下单防重
KeyLockPayCallback = RedisPrefix + "lock:pay:callback:%s" // order_no,支付回调幂等
KeyLockRefundCallback = RedisPrefix + "lock:refund:callback:%s" // refund_no,退款回调幂等
KeyLockAIRecommend = RedisPrefix + "lock:ai:recommend:%d" // user_id,AI推荐防并发
KeyLockAIRecommend = RedisPrefix + "lock:ai:recommend:%d" // user_id,AI推荐活跃位:值为进行中的 task_id,兼作防并发
KeyLockAIHoroscope = RedisPrefix + "lock:ai:horoscope:%d:%s" // user_id:zodiac,AI星座防并发
KeyLockClosetAudit = RedisPrefix + "lock:audit:closet:%d" // target_id,衣柜分享审核防重
KeyLockOutfitAudit = RedisPrefix + "lock:audit:outfit:%d" // target_id,穿搭审核防重
+25 -3
View File
@@ -19,8 +19,9 @@ func NewAIRecommendHandler() *AIRecommendHandler {
return &AIRecommendHandler{svc: service.NewAIRecommendService()}
}
// Create 发起 AI 穿搭推荐。
func (h *AIRecommendHandler) Create(c *gin.Context) {
// Submit 提交 AI 穿搭推荐任务(异步)。
// 立即返回 task_id,不等待大模型;生成在前端断开后仍会继续,结果通过 Status 轮询获取。
func (h *AIRecommendHandler) Submit(c *gin.Context) {
response := app.NewResponse(c)
userID, ok := middleware.GetUserID(c.Request.Context())
if !ok {
@@ -32,7 +33,28 @@ func (h *AIRecommendHandler) Create(c *gin.Context) {
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
return
}
out, e := h.svc.Create(c.Request.Context(), userID, &request)
out, e := h.svc.Submit(c.Request.Context(), userID, &request)
if e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(out)
}
// Status 查询 AI 穿搭推荐任务的状态与结果。
func (h *AIRecommendHandler) Status(c *gin.Context) {
response := app.NewResponse(c)
userID, ok := middleware.GetUserID(c.Request.Context())
if !ok {
response.ToErrorResponse(errcode.Unauthorized)
return
}
var request dto.AIRecommendStatusRequest
if err := c.ShouldBindQuery(&request); err != nil {
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
return
}
out, e := h.svc.Status(c.Request.Context(), userID, &request)
if e != nil {
response.ToErrorResponse(e)
return
+3 -2
View File
@@ -134,8 +134,9 @@ func NewRouter() *gin.Engine {
// 广告激励额度
auth.POST("/ad-reward/grant", adRewardHandler.Grant)
// AI 推荐
auth.POST("/ai-recommend", aiHandler.Create)
// AI 推荐(异步:提交任务后轮询结果,避免长耗时请求被超时中断)
auth.POST("/ai-recommend", aiHandler.Submit)
auth.GET("/ai-recommend/status", aiHandler.Status)
auth.POST("/ai-recommend/save", aiHandler.Save)
}
+82
View File
@@ -0,0 +1,82 @@
package service
import (
"context"
"log/slog"
"time"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao"
)
// AI 推荐每次生成都会落一条 status=3(待用户保存)的穿搭:
// 用户不调用 /ai-recommend/save 就会长期堆积在 outfits / outfit_items 中,
// 并且会出现在 GET /outfit 列表里。这里按保留期兜底清理,连同明细一起软删除。
const (
// aiUnsavedOutfitRetention 未保存 AI 穿搭的保留时长。
aiUnsavedOutfitRetention = 7 * 24 * time.Hour
// aiUnsavedOutfitCleanupInterval 清理任务执行间隔。
aiUnsavedOutfitCleanupInterval = time.Hour
// aiUnsavedOutfitCleanupBatch 单批清理条数,避免长事务持锁。
aiUnsavedOutfitCleanupBatch = 500
// aiUnsavedOutfitCleanupMaxRounds 单次执行的最大批次轮数,防止一次跑太久。
aiUnsavedOutfitCleanupMaxRounds = 20
)
// StartAIOutfitCleanupScheduler 启动「未保存 AI 穿搭」清理定时任务:
// 启动后立即执行一次,之后每小时执行一次。
func StartAIOutfitCleanupScheduler() {
go func() {
slog.Info("ai unsaved outfit cleanup scheduler started",
"interval", aiUnsavedOutfitCleanupInterval.String(),
"retention", aiUnsavedOutfitRetention.String(),
)
for {
func() {
defer func() {
if r := recover(); r != nil {
slog.Error("ai unsaved outfit cleanup panic", "recover", r)
}
}()
runAIOutfitCleanup()
}()
time.Sleep(aiUnsavedOutfitCleanupInterval)
}
}()
}
// runAIOutfitCleanup 清理超过保留期仍未保存的 AI 穿搭。
// 条件更新天然幂等,多实例并发执行无害,无需分布式锁。
func runAIOutfitCleanup() {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
ctx, span := global.Tracer.Start(ctx, "scheduler.AIOutfitCleanup")
defer span.End()
deadline := time.Now().Add(-aiUnsavedOutfitRetention).Unix()
dao := mysqldao.NewOutfitDao()
var total int64
for round := 0; round < aiUnsavedOutfitCleanupMaxRounds; round++ {
affected, err := dao.OutfitCleanupExpiredUnsavedAI(ctx, deadline, aiUnsavedOutfitCleanupBatch)
if err != nil {
slog.ErrorContext(ctx, "cleanup unsaved ai outfits failed", "error", err, "cleaned", total)
return
}
total += affected
if affected < aiUnsavedOutfitCleanupBatch {
break
}
}
if total > 0 {
slog.InfoContext(ctx, "cleaned unsaved ai outfits",
"count", total,
"retention_hours", int(aiUnsavedOutfitRetention.Hours()),
)
}
}
+266 -101
View File
@@ -3,8 +3,8 @@ package service
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
"time"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
@@ -12,12 +12,24 @@ import (
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/tokenhub"
"gorm.io/gorm"
)
const (
// aiRecommendRunTimeout 后台生成任务的整体超时。
// 大模型单次最长 120s,其余留给查库与落库,给足余量。
aiRecommendRunTimeout = 3 * time.Minute
// aiRecommendSlotTTL 用户活跃位保留时长,需略长于任务超时,
// 否则任务尚未收尾活跃位就先过期,会造成同一用户重复提交、重复扣额度。
aiRecommendSlotTTL = aiRecommendRunTimeout + time.Minute
// aiRecommendCleanupTimeout 任务收尾(退额度 / 写流水 / 释放活跃位)的超时。
aiRecommendCleanupTimeout = 15 * time.Second
)
// AIRecommendService AI 穿搭推荐业务服务。
type AIRecommendService struct {
outfitDao *mysqldao.OutfitDao
@@ -40,125 +52,99 @@ func NewAIRecommendService() *AIRecommendService {
}
}
// Create 发起 AI 穿搭推荐:防并发、扣 AI 额度、基于衣橱与运势生成推荐,落库为待保存穿搭。
func (s *AIRecommendService) Create(ctx context.Context, userID int64, request *dto.AIRecommendCreateRequest) (*dto.AIRecommendCreateResponse, *errcode.Error) {
ctx, span := global.Tracer.Start(ctx, "svc.AIRecommendCreate")
// Submit 提交 AI 穿搭推荐任务,立即返回 task_id,真正的生成在后台完成。
//
// 与同步实现的区别:
// - 接口不等待大模型,秒级返回,天然不受 Server.WriteTimeout 约束;
// - 同一用户已有任务在执行时,直接返回该任务的 task_id(幂等),不重复扣额度;
// - 生成过程使用独立 context,前端退出不会中断,结果仍会落库为「待保存」穿搭。
func (s *AIRecommendService) Submit(ctx context.Context, userID int64, request *dto.AIRecommendCreateRequest) (*dto.AIRecommendSubmitResponse, *errcode.Error) {
ctx, span := global.Tracer.Start(ctx, "svc.AIRecommendSubmit")
defer span.End()
// 1. 防并发锁。
lockKey := fmt.Sprintf(redismodel.KeyLockAIRecommend, userID)
lockVal := genUUID()
ok, err := redisdao.TryLock(ctx, lockKey, lockVal, 180*time.Second)
taskID := genUUID()
// 1. 抢占活跃位:抢不到说明该用户已有任务在跑,幂等返回它。
acquired, runningTaskID, err := redisdao.AIRecommendTaskAcquire(ctx, userID, taskID, aiRecommendSlotTTL)
if err != nil {
// Redis 异常:返回服务错误而非误报「请求过于频繁」。
return nil, errcode.CacheError
}
if !ok {
return nil, errcode.TooManyRequests
if !acquired {
if runningTaskID == "" {
// 活跃位恰好在两次调用之间过期,让前端稍后重试即可。
return nil, errcode.TooManyRequests
}
return &dto.AIRecommendSubmitResponse{
TaskID: runningTaskID,
Status: AIRecommendTaskPending,
}, nil
}
defer func() { _ = redisdao.Unlock(ctx, lockKey, lockVal) }()
// 2. 扣减 AI 每日额度(权威扣减)。
// 2. 扣减 AI 每日额度(权威扣减)。失败时释放活跃位,避免把用户卡死。
if qerr := consumeDailyQuota(ctx, userID, QuotaTypeAI, 1, "AI 推荐消耗"); qerr != nil {
s.releaseSlot(context.WithoutCancel(ctx), userID, taskID)
if qerr == errcode.QuotaInsufficient {
return nil, errcode.AIRecommendQuotaLimit
}
return nil, qerr
}
// 失败时统一回退额度的兜底闭包。
fail := func(bizErr *errcode.Error, reason string) *errcode.Error {
refundDailyQuota(ctx, userID, QuotaTypeAI, 1)
s.writeFlowLog(ctx, userID, "", AIFlowStatusFailed, reason)
return bizErr
// 3. 先落一个「生成中」快照,前端提交后立刻就能轮询到。
if serr := redisdao.AIRecommendTaskSave(ctx, &redisdao.AIRecommendTask{
TaskID: taskID,
UserID: userID,
Status: AIRecommendTaskPending,
CreateTime: nowUnix(),
}); serr != nil {
cleanupCtx := context.WithoutCancel(ctx)
refundDailyQuota(cleanupCtx, userID, QuotaTypeAI, 1)
s.releaseSlot(cleanupCtx, userID, taskID)
return nil, errcode.CacheError
}
// 3. 加载用户可用衣物(已成功处理)。
clothes, cerr := s.processedClothes(ctx, userID)
if cerr != nil {
return nil, fail(errcode.DatabaseError, "查询衣橱失败")
}
if len(clothes) == 0 {
return nil, fail(errcode.AIRecommendFailed.WithDetails("衣橱暂无可用衣物"), "衣橱为空")
}
// 4. 后台生成。request 是栈上参数,复制一份交给 goroutine,避免外部复用导致数据竞争。
reqCopy := *request
go s.runTask(taskID, userID, &reqCopy)
// 4. 计算星座与今日运势(best-effort)。
zodiac, fortune := s.userFortune(ctx, userID)
// 5. 调用大模型生成推荐。
requestTime := nowUnix()
client := tokenhub.NewClient()
// 用户自定义场景(scene_type=2)透传给模型;为空时由模型自行判断。
result, gerr := client.GenerateOutfitRecommendation(ctx, zodiac, fortune, clothes, request.Scene)
if gerr != nil {
return nil, fail(errcode.AIRecommendFailed, "大模型调用失败")
}
result.ValidateClothesIDs(clothes)
if len(result.Items) == 0 {
return nil, fail(errcode.AIRecommendFailed.WithDetails("未生成有效搭配"), "无有效单品")
}
// 6. 落库为待用户保存的 AI 穿搭。
scene := result.Scene
if scene == "" {
scene = request.Scene
}
outfit := &mysqlmodel.Outfit{
OutfitUUID: genUUID(),
UserID: userID,
OutfitName: result.OutfitName,
OutfitDesc: result.Description,
Scene: scene,
Source: OutfitSourceAI,
IsPublic: OutfitPrivate,
AuditStatus: OutfitAuditPending,
Status: OutfitStatusUnsaved,
}
if err := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(outfit).Error; err != nil {
return err
}
clothesIDs := result.GetClothesIDs()
items := make([]mysqlmodel.OutfitItem, 0, len(clothesIDs))
for _, cid := range clothesIDs {
items = append(items, mysqlmodel.OutfitItem{OutfitID: outfit.ID, ClothesID: cid})
}
return tx.Create(&items).Error
}); err != nil {
return nil, fail(errcode.OutfitCreateFailed, "穿搭及明细落库失败")
}
// 7. 写成功流水 + 当日计数。
resultJSON, _ := json.Marshal(result)
if err := s.flowLogDao.AIRecommendFlowLogCreate(ctx, &mysqlmodel.AIRecommendFlowLog{
RequestID: genUUID(),
UserID: userID,
ModelVersion: global.TokenhubSetting.Model,
ResultContent: string(resultJSON),
Status: AIFlowStatusSuccess,
RequestTime: requestTime,
ResponseTime: nowUnix(),
}); err != nil {
slog.WarnContext(ctx, "write ai recommend success flow log failed", "user_id", userID, "error", err)
}
// 8. 组装响应(衣物 UUID 来自校正后的 items)。
outfitItems := make([]dto.OutfitItem, 0, len(result.Items))
for _, it := range result.Items {
outfitItems = append(outfitItems, dto.OutfitItem{ClothesUUID: it.ClothesUUID})
}
return &dto.AIRecommendCreateResponse{
OutfitUUID: outfit.OutfitUUID,
OutfitName: outfit.OutfitName,
OutfitDesc: outfit.OutfitDesc,
Scene: outfit.Scene,
Source: outfit.Source,
Status: outfit.Status,
OutfitItem: outfitItems,
return &dto.AIRecommendSubmitResponse{
TaskID: taskID,
Status: AIRecommendTaskPending,
}, nil
}
// Status 查询 AI 推荐任务的状态与结果。
func (s *AIRecommendService) Status(ctx context.Context, userID int64, request *dto.AIRecommendStatusRequest) (*dto.AIRecommendStatusResponse, *errcode.Error) {
ctx, span := global.Tracer.Start(ctx, "svc.AIRecommendStatus")
defer span.End()
task, err := redisdao.AIRecommendTaskGet(ctx, request.TaskID)
if err != nil {
return nil, errcode.CacheError
}
// 任务不存在、已过期,或不属于当前用户时统一按「不存在」处理,避免探测他人任务。
if task == nil || task.UserID != userID {
return nil, errcode.AIRecommendTaskNotFound
}
resp := &dto.AIRecommendStatusResponse{
TaskID: task.TaskID,
Status: task.Status,
ErrorCode: task.ErrorCode,
ErrorMsg: task.ErrorMsg,
}
if task.Status == AIRecommendTaskSuccess && len(task.Result) > 0 {
var out dto.AIRecommendCreateResponse
if uerr := json.Unmarshal(task.Result, &out); uerr != nil {
slog.ErrorContext(ctx, "unmarshal ai recommend task result failed",
"task_id", task.TaskID, "user_id", userID, "error", uerr)
return nil, errcode.AIRecommendFailed
}
resp.Result = &out
}
return resp, nil
}
// Save 将 AI 推荐生成的待保存穿搭保存到「我的搭配」(置为正常状态)。
func (s *AIRecommendService) Save(ctx context.Context, userID int64, request *dto.AIRecommendSaveRequest) *errcode.Error {
ctx, span := global.Tracer.Start(ctx, "svc.AIRecommendSave")
@@ -190,6 +176,166 @@ func (s *AIRecommendService) Save(ctx context.Context, userID int64, request *dt
return nil
}
// ---- 后台任务 ----
// runTask 后台执行推荐生成并回写任务状态。
//
// 关键点:ctx 由 context.Background() 派生,与 HTTP 请求生命周期彻底解耦 ——
// 前端退出后生成仍会跑完并落库,额度退还、流水写入与活跃位释放也能正常收尾。
func (s *AIRecommendService) runTask(taskID string, userID int64, request *dto.AIRecommendCreateRequest) {
ctx, cancel := context.WithTimeout(context.Background(), aiRecommendRunTimeout)
defer cancel()
defer func() {
if r := recover(); r != nil {
slog.ErrorContext(ctx, "ai recommend task panic",
"task_id", taskID, "user_id", userID, "recover", r)
s.finishFailed(ctx, taskID, userID, errcode.AIRecommendFailed.Code(), "生成任务异常终止")
}
// 无论成功、失败还是 panic,都要释放活跃位。
s.releaseSlot(context.WithoutCancel(ctx), userID, taskID)
}()
out, bizErr := s.generate(ctx, userID, request)
if bizErr != nil {
s.finishFailed(ctx, taskID, userID, bizErr.Code(), formatBizErr(bizErr))
return
}
if serr := redisdao.AIRecommendTaskSave(ctx, &redisdao.AIRecommendTask{
TaskID: taskID,
UserID: userID,
Status: AIRecommendTaskSuccess,
Result: mustMarshal(out),
CreateTime: nowUnix(),
}); serr != nil {
// 结果已落库为主,快照写失败只影响前端轮询展示,记录即可。
slog.ErrorContext(ctx, "save ai recommend task result failed",
"task_id", taskID, "user_id", userID, "error", serr)
}
}
// finishFailed 任务失败的统一收尾:退额度、写失败流水、回写任务状态。
// 收尾用的 ctx 不受任务取消影响(任务超时时 ctx 已 Done),否则会出现「额度不退、流水不写」。
func (s *AIRecommendService) finishFailed(ctx context.Context, taskID string, userID int64, code int, msg string) {
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), aiRecommendCleanupTimeout)
defer cancel()
refundDailyQuota(cleanupCtx, userID, QuotaTypeAI, 1)
s.writeFlowLog(cleanupCtx, userID, "", AIFlowStatusFailed, msg)
if err := redisdao.AIRecommendTaskSave(cleanupCtx, &redisdao.AIRecommendTask{
TaskID: taskID,
UserID: userID,
Status: AIRecommendTaskFailed,
ErrorCode: code,
ErrorMsg: msg,
CreateTime: nowUnix(),
}); err != nil {
slog.ErrorContext(cleanupCtx, "save failed ai recommend task failed",
"task_id", taskID, "user_id", userID, "error", err)
}
}
// releaseSlot 释放用户活跃位(best-effort,失败仅告警)。
func (s *AIRecommendService) releaseSlot(ctx context.Context, userID int64, taskID string) {
relCtx, cancel := context.WithTimeout(ctx, aiRecommendCleanupTimeout)
defer cancel()
if err := redisdao.AIRecommendTaskRelease(relCtx, userID, taskID); err != nil {
slog.WarnContext(relCtx, "release ai recommend slot failed",
"user_id", userID, "task_id", taskID, "error", err)
}
}
// generate 执行一次完整的推荐生成:查衣橱 → 取运势 → 调大模型 → 校验 → 落库。
func (s *AIRecommendService) generate(ctx context.Context, userID int64, request *dto.AIRecommendCreateRequest) (*dto.AIRecommendCreateResponse, *errcode.Error) {
// 1. 加载用户可用衣物(已成功处理)。
clothes, cerr := s.processedClothes(ctx, userID)
if cerr != nil {
return nil, errcode.DatabaseError
}
if len(clothes) == 0 {
return nil, errcode.AIRecommendFailed.WithDetails("衣橱暂无可用衣物")
}
// 2. 计算星座与今日运势(best-effort)。
zodiac, fortune := s.userFortune(ctx, userID)
// 3. 调用大模型生成推荐。
requestTime := nowUnix()
client := tokenhub.NewClient()
result, gerr := client.GenerateOutfitRecommendation(ctx, zodiac, fortune, clothes, request.Scene)
if gerr != nil {
slog.ErrorContext(ctx, "generate outfit recommendation failed", "user_id", userID, "error", gerr)
return nil, errcode.AIRecommendFailed.WithDetails("大模型调用失败")
}
result.ValidateClothesIDs(clothes)
if len(result.Items) == 0 {
return nil, errcode.AIRecommendFailed.WithDetails("未生成有效搭配")
}
// 4. 落库为待用户保存的 AI 穿搭。
scene := result.Scene
if scene == "" {
scene = request.Scene
}
outfit := &mysqlmodel.Outfit{
OutfitUUID: genUUID(),
UserID: userID,
OutfitName: result.OutfitName,
OutfitDesc: result.Description,
Scene: scene,
Source: OutfitSourceAI,
IsPublic: OutfitPrivate,
AuditStatus: OutfitAuditPending,
Status: OutfitStatusUnsaved,
}
if err := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(outfit).Error; err != nil {
return err
}
clothesIDs := result.GetClothesIDs()
items := make([]mysqlmodel.OutfitItem, 0, len(clothesIDs))
for _, cid := range clothesIDs {
items = append(items, mysqlmodel.OutfitItem{OutfitID: outfit.ID, ClothesID: cid})
}
return tx.Create(&items).Error
}); err != nil {
return nil, errcode.OutfitCreateFailed
}
// 5. 写成功流水(best-effort)。
resultJSON, _ := json.Marshal(result)
if err := s.flowLogDao.AIRecommendFlowLogCreate(ctx, &mysqlmodel.AIRecommendFlowLog{
RequestID: genUUID(),
UserID: userID,
ModelVersion: global.TokenhubSetting.Model,
ResultContent: string(resultJSON),
Status: AIFlowStatusSuccess,
RequestTime: requestTime,
ResponseTime: nowUnix(),
}); err != nil {
slog.WarnContext(ctx, "write ai recommend success flow log failed", "user_id", userID, "error", err)
}
// 6. 组装响应(衣物 UUID 来自校正后的 items)。
outfitItems := make([]dto.OutfitItem, 0, len(result.Items))
for _, it := range result.Items {
outfitItems = append(outfitItems, dto.OutfitItem{ClothesUUID: it.ClothesUUID})
}
return &dto.AIRecommendCreateResponse{
OutfitUUID: outfit.OutfitUUID,
OutfitName: outfit.OutfitName,
OutfitDesc: outfit.OutfitDesc,
Scene: outfit.Scene,
Source: outfit.Source,
Status: outfit.Status,
OutfitItem: outfitItems,
}, nil
}
// ---- 内部辅助 ----
// processedClothes 获取用户已成功处理、可用于推荐的衣物。
@@ -254,3 +400,22 @@ func (s *AIRecommendService) writeFlowLog(ctx context.Context, userID int64, res
slog.WarnContext(ctx, "write ai recommend flow log failed", "user_id", userID, "status", status, "error", err)
}
}
// formatBizErr 拼接业务错误主文案与明细,便于前端展示失败原因。
func formatBizErr(e *errcode.Error) string {
msg := e.Msg()
if details := e.Details(); len(details) > 0 {
msg = msg + ":" + strings.Join(details, ";")
}
return msg
}
// mustMarshal 序列化任务结果;失败时返回 nil(任务仍标记成功,前端可去「我的搭配」查看)。
func mustMarshal(v any) json.RawMessage {
data, err := json.Marshal(v)
if err != nil {
slog.Error("marshal ai recommend task result failed", "error", err)
return nil
}
return data
}
+7
View File
@@ -189,6 +189,13 @@ const (
AIFlowStatusFailed int8 = 2 // 失败
)
// ===== AI 推荐异步任务状态(Redis 快照) =====
const (
AIRecommendTaskPending int8 = 1 // 生成中
AIRecommendTaskSuccess int8 = 2 // 生成成功
AIRecommendTaskFailed int8 = 3 // 生成失败
)
// 订单支付有效期(秒)。
const orderPayTTLSeconds int64 = 15 * 60
+2
View File
@@ -545,6 +545,7 @@ func clothesImageURL(processed string) string {
}
// buildOutfitCreateResponse 组装穿搭响应。
// 带出 status,便于前端在列表中区分「待用户保存」的 AI 穿搭(status=3)与正式穿搭。
func buildOutfitCreateResponse(o *mysqlmodel.Outfit, items []dto.OutfitItemResponse) dto.OutfitCreateResponse {
return dto.OutfitCreateResponse{
OutfitUUID: o.OutfitUUID,
@@ -554,6 +555,7 @@ func buildOutfitCreateResponse(o *mysqlmodel.Outfit, items []dto.OutfitItemRespo
Source: o.Source,
IsPublic: o.IsPublic,
AuditStatus: o.AuditStatus,
Status: o.Status,
Items: items,
}
}