458 lines
17 KiB
Go
458 lines
17 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"log/slog"
|
||
"strings"
|
||
"time"
|
||
|
||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
|
||
"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/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
|
||
outfitItemDao *mysqldao.OutfitItemDao
|
||
clothesDao *mysqldao.ClothesDao
|
||
userDao *mysqldao.UserDao
|
||
horoscopeDao *mysqldao.HoroscopeDao
|
||
flowLogDao *mysqldao.AIRecommendFlowLogDao
|
||
}
|
||
|
||
// NewAIRecommendService 创建 AI 推荐服务。
|
||
func NewAIRecommendService() *AIRecommendService {
|
||
return &AIRecommendService{
|
||
outfitDao: mysqldao.NewOutfitDao(),
|
||
outfitItemDao: mysqldao.NewOutfitItemDao(),
|
||
clothesDao: mysqldao.NewClothesDao(),
|
||
userDao: mysqldao.NewUserDao(),
|
||
horoscopeDao: mysqldao.NewHoroscopeDao(),
|
||
flowLogDao: mysqldao.NewAIRecommendFlowLogDao(),
|
||
}
|
||
}
|
||
|
||
// 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()
|
||
|
||
taskID := genUUID()
|
||
|
||
// 1. 抢占活跃位:抢不到说明该用户已有任务在跑,幂等返回它。
|
||
acquired, runningTaskID, err := redisdao.AIRecommendTaskAcquire(ctx, userID, taskID, aiRecommendSlotTTL)
|
||
if err != nil {
|
||
slog.ErrorContext(ctx, "ai recommend submit: acquire slot failed", "user_id", userID, "error", err)
|
||
return nil, errcode.CacheError
|
||
}
|
||
if !acquired {
|
||
if runningTaskID == "" {
|
||
// 活跃位恰好在两次调用之间过期,让前端稍后重试即可。
|
||
slog.InfoContext(ctx, "ai recommend submit: slot expired between calls, ask retry", "user_id", userID)
|
||
return nil, errcode.TooManyRequests
|
||
}
|
||
slog.InfoContext(ctx, "ai recommend submit: task already running, return existing",
|
||
"user_id", userID, "task_id", runningTaskID)
|
||
return &dto.AIRecommendSubmitResponse{
|
||
TaskID: runningTaskID,
|
||
Status: AIRecommendTaskPending,
|
||
}, nil
|
||
}
|
||
|
||
// 2. 扣减 AI 每日额度(权威扣减)。失败时释放活跃位,避免把用户卡死。
|
||
if qerr := consumeDailyQuota(ctx, userID, QuotaTypeAI, 1, "AI 推荐消耗"); qerr != nil {
|
||
s.releaseSlot(context.WithoutCancel(ctx), userID, taskID)
|
||
if qerr == errcode.QuotaInsufficient {
|
||
slog.InfoContext(ctx, "ai recommend submit: quota insufficient", "user_id", userID)
|
||
return nil, errcode.AIRecommendQuotaLimit
|
||
}
|
||
slog.ErrorContext(ctx, "ai recommend submit: consume quota failed", "user_id", userID, "error", qerr)
|
||
return nil, qerr
|
||
}
|
||
|
||
// 3. 先落一个「生成中」快照,前端提交后立刻就能轮询到。
|
||
if serr := redisdao.AIRecommendTaskSave(ctx, &redisdao.AIRecommendTask{
|
||
TaskID: taskID,
|
||
UserID: userID,
|
||
Status: AIRecommendTaskPending,
|
||
CreateTime: nowUnix(),
|
||
}); serr != nil {
|
||
slog.ErrorContext(ctx, "ai recommend submit: save task snapshot failed",
|
||
"user_id", userID, "task_id", taskID, "error", serr)
|
||
cleanupCtx := context.WithoutCancel(ctx)
|
||
refundDailyQuota(cleanupCtx, userID, QuotaTypeAI, 1)
|
||
s.releaseSlot(cleanupCtx, userID, taskID)
|
||
return nil, errcode.CacheError
|
||
}
|
||
|
||
// 4. 后台生成。request 是栈上参数,复制一份交给 goroutine,避免外部复用导致数据竞争。
|
||
reqCopy := *request
|
||
go s.runTask(taskID, userID, &reqCopy)
|
||
|
||
slog.InfoContext(ctx, "ai recommend submitted", "user_id", userID, "task_id", taskID)
|
||
|
||
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")
|
||
defer span.End()
|
||
|
||
outfit, err := s.outfitDao.OutfitGetByUUID(ctx, request.OutfitUUID)
|
||
if err != nil {
|
||
if isNotFound(err) {
|
||
slog.WarnContext(ctx, "ai recommend save: outfit not found",
|
||
"user_id", userID, "outfit_uuid", request.OutfitUUID)
|
||
return errcode.OutfitNotFound
|
||
}
|
||
slog.ErrorContext(ctx, "ai recommend save: query outfit failed",
|
||
"user_id", userID, "outfit_uuid", request.OutfitUUID, "error", err)
|
||
return errcode.DatabaseError
|
||
}
|
||
if outfit.UserID != userID {
|
||
slog.InfoContext(ctx, "ai recommend save: permission denied",
|
||
"user_id", userID, "outfit_uuid", request.OutfitUUID)
|
||
return errcode.OutfitNoPermission
|
||
}
|
||
// 仅待保存状态可保存;已正常则幂等返回。
|
||
if outfit.Status == OutfitStatusNormal {
|
||
slog.DebugContext(ctx, "ai recommend save: outfit already saved, skip",
|
||
"user_id", userID, "outfit_uuid", request.OutfitUUID)
|
||
return nil
|
||
}
|
||
if outfit.Status != OutfitStatusUnsaved {
|
||
slog.WarnContext(ctx, "ai recommend save: outfit status not saveable",
|
||
"user_id", userID, "outfit_uuid", request.OutfitUUID, "status", outfit.Status)
|
||
return errcode.OutfitUpdateFailed
|
||
}
|
||
|
||
if uerr := s.outfitDao.OutfitUpdateByUUID(ctx, request.OutfitUUID, map[string]any{
|
||
"status": OutfitStatusNormal,
|
||
}); uerr != nil {
|
||
slog.ErrorContext(ctx, "ai recommend save: update outfit failed",
|
||
"user_id", userID, "outfit_uuid", request.OutfitUUID, "error", uerr)
|
||
return errcode.OutfitUpdateFailed
|
||
}
|
||
slog.InfoContext(ctx, "ai recommend outfit saved", "user_id", userID, "outfit_uuid", request.OutfitUUID)
|
||
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, taskID, 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(ctx, out),
|
||
CreateTime: nowUnix(),
|
||
}); serr != nil {
|
||
// 结果已落库为主,快照写失败只影响前端轮询展示,降为 Warn。
|
||
slog.WarnContext(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, taskID string, userID int64, request *dto.AIRecommendCreateRequest) (*dto.AIRecommendCreateResponse, *errcode.Error) {
|
||
// 1. 加载用户可用衣物(已成功处理)。
|
||
clothes, cerr := s.processedClothes(ctx, userID)
|
||
if cerr != nil {
|
||
slog.ErrorContext(ctx, "ai recommend generate: load clothes failed",
|
||
"user_id", userID, "task_id", taskID, "error", cerr)
|
||
return nil, errcode.DatabaseError
|
||
}
|
||
if len(clothes) == 0 {
|
||
slog.InfoContext(ctx, "ai recommend generate: no usable clothes", "user_id", userID, "task_id", taskID)
|
||
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, "task_id", taskID, "error", gerr)
|
||
return nil, errcode.AIRecommendFailed.WithDetails("大模型调用失败")
|
||
}
|
||
result.ValidateClothesIDs(clothes)
|
||
if len(result.Items) == 0 {
|
||
slog.WarnContext(ctx, "ai recommend generate: no valid items after validation",
|
||
"user_id", userID, "task_id", taskID)
|
||
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 {
|
||
slog.ErrorContext(ctx, "ai recommend generate: save outfit failed",
|
||
"user_id", userID, "task_id", taskID, "error", err)
|
||
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 获取用户已成功处理、可用于推荐的衣物。
|
||
func (s *AIRecommendService) processedClothes(ctx context.Context, userID int64) ([]mysqlmodel.Clothes, error) {
|
||
var result []mysqlmodel.Clothes
|
||
page, pageSize := 1, 200
|
||
for {
|
||
list, total, err := s.clothesDao.ClothesListByUserID(ctx, userID, "", "", page, pageSize)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for _, c := range list {
|
||
if c.ImageProcessStatus == ImageProcessSuccess {
|
||
result = append(result, c)
|
||
}
|
||
}
|
||
if len(list) == 0 || int64(page*pageSize) >= total {
|
||
break
|
||
}
|
||
page++
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
// userFortune 计算用户星座并读取今日运势(缺失时返回仅含星座的空运势)。
|
||
func (s *AIRecommendService) userFortune(ctx context.Context, userID int64) (string, *tokenhub.FortuneItem) {
|
||
zodiac := ""
|
||
if user, err := s.userDao.UserGetByID(ctx, userID); err == nil && user.BirthDay != 0 {
|
||
zodiac = zodiacByUnix(user.BirthDay)
|
||
} else if err != nil {
|
||
slog.WarnContext(ctx, "ai recommend generate: get user for fortune failed, degrade",
|
||
"user_id", userID, "error", err)
|
||
}
|
||
|
||
fortune := &tokenhub.FortuneItem{Zodiac: zodiac}
|
||
if zodiac != "" {
|
||
if h, err := s.horoscopeDao.HoroscopeGetByZodiacAndDate(ctx, zodiac, todayStatDate()); err == nil && h != nil {
|
||
fortune = &tokenhub.FortuneItem{
|
||
Zodiac: h.Zodiac,
|
||
Overall: h.OverallScore,
|
||
Love: h.LoveScore,
|
||
Work: h.CareerScore,
|
||
Money: h.WealthScore,
|
||
LuckyColor: h.LuckyColor,
|
||
LuckyNumber: h.LuckyNumber,
|
||
Description: h.DailyText,
|
||
}
|
||
} else if err != nil {
|
||
slog.WarnContext(ctx, "ai recommend generate: get horoscope failed, degrade",
|
||
"user_id", userID, "zodiac", zodiac, "error", err)
|
||
}
|
||
}
|
||
return zodiac, fortune
|
||
}
|
||
|
||
// writeFlowLog 写 AI 推荐流水(best-effort,失败仅告警不阻断主流程)。
|
||
func (s *AIRecommendService) writeFlowLog(ctx context.Context, userID int64, resultContent string, status int8, failReason string) {
|
||
if err := s.flowLogDao.AIRecommendFlowLogCreate(ctx, &mysqlmodel.AIRecommendFlowLog{
|
||
RequestID: genUUID(),
|
||
UserID: userID,
|
||
ModelVersion: global.TokenhubSetting.Model,
|
||
ResultContent: resultContent,
|
||
Status: status,
|
||
FailReason: failReason,
|
||
RequestTime: nowUnix(),
|
||
ResponseTime: nowUnix(),
|
||
}); err != nil {
|
||
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(ctx context.Context, v any) json.RawMessage {
|
||
data, err := json.Marshal(v)
|
||
if err != nil {
|
||
slog.ErrorContext(ctx, "marshal ai recommend task result failed", "error", err)
|
||
return nil
|
||
}
|
||
return data
|
||
}
|