bugfix
This commit is contained in:
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
- **用户体系**:微信小程序登录(code2session,首次自动注册并初始化免费额度)、JWT 双 token(access/refresh)、个人资料维护。
|
- **用户体系**:微信小程序登录(code2session,首次自动注册并初始化免费额度)、JWT 双 token(access/refresh)、个人资料维护。
|
||||||
- **衣物管理**:图片上传(扩展名 + magic bytes 双重校验)→ 异步 AI 流水线(抠图 → 15 维分类)→ 衣物 CRUD 与处理状态查询。
|
- **衣物管理**:图片上传(扩展名 + magic bytes 双重校验)→ 异步 AI 流水线(抠图 → 15 维分类)→ 衣物 CRUD 与处理状态查询。
|
||||||
- **穿搭与 AI 推荐**:自建穿搭(含社区公开与审核)、基于星座运势 + 衣橱衣物的 AI 穿搭推荐(大模型生成,落库为"待用户保存")。
|
- **穿搭与 AI 推荐**:自建穿搭(含社区公开与审核)、基于星座运势 + 衣橱衣物的 AI 穿搭推荐(**异步提交 + 轮询结果**,前端断开不中断生成;落库为"待用户保存",超期未保存自动清理)。
|
||||||
- **运势**:每日 0 点定时生成十二星座运势,Redis 分布式锁保证多实例下仅执行一次,查询时支持按需兜底生成。
|
- **运势**:每日 0 点定时生成十二星座运势,Redis 分布式锁保证多实例下仅执行一次,查询时支持按需兜底生成。
|
||||||
- **社区互动**:衣柜分享(部分衣物 / 全量快照)、穿搭点赞与收藏(Redis 集合 + MySQL 双写)、站内通知(未读计数)。
|
- **社区互动**:衣柜分享(部分衣物 / 全量快照)、穿搭点赞与收藏(Redis 集合 + MySQL 双写)、站内通知(未读计数)。
|
||||||
- **额度体系**:四类额度(上传 / AI 推荐 / 穿搭分享 / 衣柜分享)的扣减、回退、发放、回收;上传为**永久额度**,AI 推荐 / 穿搭分享 / 衣柜分享为**每日额度**(每天 0 点重置);购买赠送额度以**凭证**(`quota_grants`)发放——会员包随 VIP 有效期、加量包独立有效期(`goods.valid_days`)、广告激励当天有效;Cos 消费先扣即将过期凭证再扣免费额度,全部走事务 + 幂等流水。
|
- **额度体系**:四类额度(上传 / AI 推荐 / 穿搭分享 / 衣柜分享)的扣减、回退、发放、回收;上传为**永久额度**,AI 推荐 / 穿搭分享 / 衣柜分享为**每日额度**(每天 0 点重置);购买赠送额度以**凭证**(`quota_grants`)发放——会员包随 VIP 有效期、加量包独立有效期(`goods.valid_days`)、广告激励当天有效;Cos 消费先扣即将过期凭证再扣免费额度,全部走事务 + 幂等流水。
|
||||||
@@ -234,7 +234,7 @@ flowchart TD
|
|||||||
3. **穿搭与社区**:创建穿搭 → 公开(审核自动通过 + 扣分享额度)→ 其他用户点赞/收藏 → 触发通知。
|
3. **穿搭与社区**:创建穿搭 → 公开(审核自动通过 + 扣分享额度)→ 其他用户点赞/收藏 → 触发通知。
|
||||||
4. **衣柜分享**:部分衣物或全量快照分享 → 扣额度 → 属主/访客查看(访客访问即记录接收关系)→ 取消或到期懒过期;"分享给我的"列表联表查询接收关系。
|
4. **衣柜分享**:部分衣物或全量快照分享 → 扣额度 → 属主/访客查看(访客访问即记录接收关系)→ 取消或到期懒过期;"分享给我的"列表联表查询接收关系。
|
||||||
5. **交易与权益**:选购商品 → 下单(15 分钟有效)→ 微信支付回调发放权益 → 超时过期 / 退款回收,发放失败自动补偿。
|
5. **交易与权益**:选购商品 → 下单(15 分钟有效)→ 微信支付回调发放权益 → 超时过期 / 退款回收,发放失败自动补偿。
|
||||||
6. **AI 推荐**:扣 AI 额度 → 结合星座运势与衣橱生成 → 落库"待用户保存" → 保存转正式穿搭(公开仍需扣分享额度)。
|
6. **AI 推荐**:异步提交(抢占活跃位 + 扣 AI 额度 + 秒回 `task_id`)→ 后台生成 → 前端轮询结果 → 落库"待用户保存" → 保存转正式穿搭(公开仍需扣分享额度);超期未保存的由定时任务自动清理。
|
||||||
7. **通知**:点赞、支付成功、退款成功等业务动作触发站内通知,未读计数实时维护。
|
7. **通知**:点赞、支付成功、退款成功等业务动作触发站内通知,未读计数实时维护。
|
||||||
|
|
||||||
### 2. 衣物生命周期
|
### 2. 衣物生命周期
|
||||||
@@ -360,26 +360,33 @@ stateDiagram-v2
|
|||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
A["发起推荐请求"] --> B{"Redis 锁 180s<br/>(防并发重复生成)"}
|
A["POST /ai-recommend<br/>(异步提交)"] --> B{"抢占用户活跃位<br/>(已有任务则返回原 task_id)"}
|
||||||
B -- 抢锁失败 --> X["返回「请求过于频繁」"]
|
B -- 已有任务 --> X["幂等返回该 task_id<br/>(不重复扣额度)"]
|
||||||
B -- 成功 --> C["扣 AI 额度 1 点<br/>(生成前权威扣减)"]
|
B -- 抢占成功 --> C["扣 AI 额度 1 点"]
|
||||||
C --> D["组装输入:<br/>处理成功的全部衣物 + 星座 + 今日运势"]
|
C --> D["写任务快照 status=1<br/>立即返回 {task_id, status:1}"]
|
||||||
D --> E["调用大模型生成<br/>(tokenhub)"]
|
D --> E["后台 goroutine 生成<br/>(独立 ctx,前端断开不中断)"]
|
||||||
E --> F{"校验结果<br/>(过滤模型幻觉衣物 ID)"}
|
E --> F["组装输入:处理成功的全部衣物 + 星座 + 今日运势"]
|
||||||
F -- 有效 --> G["落库:source=2 AI、status=3 待用户保存"]
|
F --> G["调用大模型生成<br/>(tokenhub)"]
|
||||||
F -- 无效 / 任一步失败 --> Y["回退 AI 额度 + 写失败流水"]
|
G --> H{"校验结果<br/>(过滤模型幻觉衣物 ID)"}
|
||||||
G --> H["用户确认 Save:<br/>status 3→1 转正式穿搭(不扣额)"]
|
H -- 有效 --> I["落库:source=2 AI、status=3 待用户保存<br/>任务快照置 status=2 + result"]
|
||||||
H --> I["公开到社区仍需 Public<br/>(扣搭配分享额度)"]
|
H -- 无效 / 任一步失败 --> Y["回退 AI 额度 + 写失败流水<br/>任务快照置 status=3 + error"]
|
||||||
|
I --> J["前端轮询 GET /ai-recommend/status<br/>拿到 result"]
|
||||||
|
J --> K["用户确认 Save:<br/>status 3→1 转正式穿搭(不扣额)"]
|
||||||
|
K --> L["公开到社区仍需 Public<br/>(扣搭配分享额度)"]
|
||||||
|
I -. "超过 7 天未保存" .-> M["定时任务自动清理<br/>(软删除穿搭 + 明细)"]
|
||||||
```
|
```
|
||||||
|
|
||||||
处理流程:
|
处理流程:
|
||||||
|
|
||||||
1. **输入**:当前用户**处理成功(4)** 的全部衣物(分页遍历)+ 由生日推算的星座 + 今日运势(无生日/运势时降级为仅星座或空)。
|
1. **异步提交**:`POST /ai-recommend` 只做「抢占活跃位 → 扣额度 → 写任务快照」三步,**秒级返回 `task_id`**;生成在后台 goroutine 完成,使用独立 context,前端退出、切页面或断网都不会中断。
|
||||||
2. **扣额与回退**:**生成前**扣减 AI 额度 1 点;衣橱为空、模型调用失败、无有效单品、落库失败等任一情况均**统一回退额度**并写失败流水。
|
2. **提交幂等**:同一用户已有任务在执行时直接返回该任务的 `task_id`,不会重复扣额度;活跃位 TTL 4 分钟,略长于任务超时 3 分钟,避免任务未收尾就被放行重复提交。
|
||||||
3. **结果校验**:校正模型返回的衣物 ID(过滤幻觉),无有效单品则失败回退。
|
3. **输入**:当前用户**处理成功(4)** 的全部衣物(分页遍历)+ 由生日推算的星座 + 今日运势(无生日/运势时降级为仅星座或空)。
|
||||||
4. **落库**:生成穿搭以 `source=2 AI`、`status=3 待用户保存` 入库(私有、待审核);明细落库失败则删除穿搭 + 回退额度。
|
4. **扣额与回退**:**提交时**扣减 AI 额度 1 点;衣橱为空、模型调用失败、无有效单品、落库失败、任务超时等任一情况均**统一回退额度**并写失败流水(回退与写流水使用不受任务取消影响的 context,避免断连时收尾失败)。
|
||||||
5. **保存转正**:仅 `status=3` 可保存(重复保存幂等),保存仅将状态 3→1,**不扣分享额度**;后续公开到社区仍走 Public 流程扣搭配分享额度。
|
5. **结果校验**:校正模型返回的衣物 ID(过滤幻觉),无有效单品则失败回退。
|
||||||
6. **实现备注**:不保存的"待保存"穿搭无自动清理任务;同用户并发请求由 Redis 锁(180s)拦截。
|
6. **落库**:生成穿搭以 `source=2 AI`、`status=3 待用户保存` 入库(私有、待审核);明细落库失败则删除穿搭 + 回退额度。
|
||||||
|
7. **结果获取**:前端轮询 `GET /ai-recommend/status?task_id=...`(建议 2 秒一次、上限 90 次);`status=2` 取 `result` 渲染,`status=3` 读 `error_msg` 提示用户。任务快照在 Redis 保留 15 分钟,过期返回 `90402`。
|
||||||
|
8. **保存转正**:仅 `status=3` 可保存(重复保存幂等),保存仅将状态 3→1,**不扣分享额度**;后续公开到社区仍走 Public 流程扣搭配分享额度。
|
||||||
|
9. **自动清理**:未被保存的 `status=3` 穿搭超过 **7 天**,由后台定时任务(启动即执行一次,之后每小时一次,单批 500 条)连同明细一起软删除,避免长期堆积;清理后列表与详情立即不可见。
|
||||||
|
|
||||||
### 7. 通知触发规则
|
### 7. 通知触发规则
|
||||||
|
|
||||||
@@ -1213,7 +1220,9 @@ multipart 上传,字段名 `file`;仅支持图片(jpeg/png/gif/webp),
|
|||||||
|
|
||||||
#### 11. AI 推荐模块
|
#### 11. AI 推荐模块
|
||||||
|
|
||||||
**POST `/ai-recommend` —— 发起 AI 穿搭推荐(需鉴权)**
|
**POST `/ai-recommend` —— 提交 AI 穿搭推荐任务(需鉴权)**
|
||||||
|
|
||||||
|
> AI 推荐为**异步接口**:提交后立即返回 `task_id`,需轮询 `GET /ai-recommend/status` 获取结果。生成在后台执行,前端退出、切页面、断网都不会中断。
|
||||||
|
|
||||||
请求(json):
|
请求(json):
|
||||||
|
|
||||||
@@ -1224,6 +1233,29 @@ multipart 上传,字段名 `file`;仅支持图片(jpeg/png/gif/webp),
|
|||||||
|
|
||||||
响应 `data`:
|
响应 `data`:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| task_id | string | 任务 ID,用于轮询状态接口 |
|
||||||
|
| status | int | 1=生成中 |
|
||||||
|
|
||||||
|
相关错误码:90401 AI 推荐次数已达上限、19002 缓存错误。
|
||||||
|
|
||||||
|
**GET `/ai-recommend/status` —— 查询 AI 推荐任务状态(需鉴权)**
|
||||||
|
|
||||||
|
请求(query):`task_id`(必填,由上一个接口返回)
|
||||||
|
|
||||||
|
响应 `data`:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| task_id | string | 任务 ID |
|
||||||
|
| status | int | 1=生成中 2=成功 3=失败 |
|
||||||
|
| result | object | 仅 status=2 时存在,结构见下表 |
|
||||||
|
| error_code | int | 仅 status=3 时存在(通常 90400) |
|
||||||
|
| error_msg | string | 仅 status=3 时存在 |
|
||||||
|
|
||||||
|
推荐结果 `result`:
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| outfit_uuid | string | 生成结果 UUID |
|
| outfit_uuid | string | 生成结果 UUID |
|
||||||
@@ -1234,7 +1266,7 @@ multipart 上传,字段名 `file`;仅支持图片(jpeg/png/gif/webp),
|
|||||||
| status | int | 1=正常 2=封禁 3=待用户保存 |
|
| status | int | 1=正常 2=封禁 3=待用户保存 |
|
||||||
| outfit_item | array | 衣物列表,元素:`{"clothes_uuid": string}` |
|
| outfit_item | array | 衣物列表,元素:`{"clothes_uuid": string}` |
|
||||||
|
|
||||||
相关错误码:90400 AI 推荐失败、90401 AI 推荐次数已达上限、80000 额度不足。
|
相关错误码:90400 AI 推荐失败、90402 AI 推荐任务不存在或已过期。
|
||||||
|
|
||||||
**POST `/ai-recommend/save` —— 保存 AI 推荐为我的穿搭(需鉴权)**
|
**POST `/ai-recommend/save` —— 保存 AI 推荐为我的穿搭(需鉴权)**
|
||||||
|
|
||||||
@@ -1242,7 +1274,7 @@ multipart 上传,字段名 `file`;仅支持图片(jpeg/png/gif/webp),
|
|||||||
|
|
||||||
| 参数 | 类型 | 必填 | 说明 |
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| outfit_uuid | string | 是 | AI 推荐结果 UUID(由 `/ai-recommend` 返回) |
|
| outfit_uuid | string | 是 | AI 推荐结果 UUID(由 `GET /ai-recommend/status` 的 `result.outfit_uuid` 返回) |
|
||||||
|
|
||||||
响应:`data` 为空对象 `{}`。
|
响应:`data` 为空对象 `{}`。
|
||||||
|
|
||||||
@@ -1337,23 +1369,35 @@ sequenceDiagram
|
|||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
participant F as 小程序前端
|
participant F as 小程序前端
|
||||||
participant B as 后端服务
|
participant B as 后端服务
|
||||||
|
participant R as Redis
|
||||||
participant L as 大模型网关(Tokenhub)
|
participant L as 大模型网关(Tokenhub)
|
||||||
|
|
||||||
F->>B: POST /ai-recommend {scene_type, scene?}
|
F->>B: POST /ai-recommend {scene_type, scene?}
|
||||||
|
B->>R: 抢占用户活跃位 + 写入任务快照(status=1)
|
||||||
B->>B: 校验并扣减 AI 额度
|
B->>B: 校验并扣减 AI 额度
|
||||||
B->>L: 结合星座运势 + 衣橱衣物生成推荐
|
B-->>F: {task_id, status: 1} · 秒级返回
|
||||||
L-->>B: 推荐结果
|
|
||||||
B->>B: 落库(status=3 待用户保存)
|
par 后台生成(独立 ctx,前端退出不中断)
|
||||||
B-->>F: {outfit_uuid, outfit_name, outfit_item...}
|
B->>L: 结合星座运势 + 衣橱衣物生成推荐
|
||||||
|
L-->>B: 推荐结果
|
||||||
|
B->>B: 落库(status=3 待用户保存)
|
||||||
|
B->>R: 任务快照 status=2 + result
|
||||||
|
and 前端轮询
|
||||||
|
loop 每 2 秒,上限 90 次
|
||||||
|
F->>B: GET /ai-recommend/status?task_id
|
||||||
|
B-->>F: {status, result?}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
F->>F: 用户预览、确认
|
F->>F: 用户预览、确认
|
||||||
F->>B: POST /ai-recommend/save {outfit_uuid}
|
F->>B: POST /ai-recommend/save {outfit_uuid}
|
||||||
B-->>F: 保存成功,转入我的穿搭
|
B-->>F: 保存成功,转入我的穿搭
|
||||||
```
|
```
|
||||||
|
|
||||||
1. 请求必带 `scene_type`(1=每日推荐 2=个性化场景),每次调用扣减一次 AI 额度,不足返回 80000 / 90401。
|
1. 请求必带 `scene_type`(1=每日推荐 2=个性化场景),提交时扣减一次 AI 额度,不足返回 90401。
|
||||||
2. 推荐结果先落库为"待用户保存"状态(status=3),响应中返回完整推荐内容供预览。
|
2. 接口**秒级返回** `task_id`,不等大模型;生成在后台执行,前端退出、切页面或断网都不会中断。
|
||||||
3. 用户确认后调用 `/ai-recommend/save`,结果转为用户正式穿搭;未保存的结果不进入穿搭列表。
|
3. 前端轮询 `GET /ai-recommend/status`(建议 2 秒一次),直到 `status` 为 2(成功)或 3(失败);失败时额度已自动回退。
|
||||||
|
4. 成功后结果先落库为"待用户保存"状态(status=3),用户确认后调用 `/ai-recommend/save` 转为正式穿搭;未保存的结果不进入穿搭列表。
|
||||||
|
|
||||||
#### 流程 4:创建订单到支付完成
|
#### 流程 4:创建订单到支付完成
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,27 @@ type AIRecommendCreateRequest struct {
|
|||||||
Scene string `json:"scene"` // 用户自定义场景描述
|
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推荐结果
|
// AI推荐结果
|
||||||
type AIRecommendCreateResponse struct {
|
type AIRecommendCreateResponse struct {
|
||||||
OutfitUUID string `json:"outfit_uuid"` // 穿搭UUID
|
OutfitUUID string `json:"outfit_uuid"` // 穿搭UUID
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type OutfitCreateResponse struct {
|
|||||||
Source int8 `json:"source"` // 1=用户创建 2=AI推荐
|
Source int8 `json:"source"` // 1=用户创建 2=AI推荐
|
||||||
IsPublic int8 `json:"is_public"` // 1=不公开 2=公开
|
IsPublic int8 `json:"is_public"` // 1=不公开 2=公开
|
||||||
AuditStatus int8 `json:"audit_status"` // 1=待审核 2=通过 3=不通过
|
AuditStatus int8 `json:"audit_status"` // 1=待审核 2=通过 3=不通过
|
||||||
|
Status int8 `json:"status"` // 1=正常 2=封禁 3=待用户保存(AI 推荐生成后未保存)
|
||||||
Items []OutfitItemResponse `json:"items"`
|
Items []OutfitItemResponse `json:"items"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -187,6 +187,36 @@ func (d *OutfitDao) OutfitListCommunity(ctx context.Context, excludeUserID int64
|
|||||||
return list, total, nil
|
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 穿搭明细数据访问
|
// OutfitItemDao 穿搭明细数据访问
|
||||||
type OutfitItemDao struct {
|
type OutfitItemDao struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -5,7 +5,7 @@ const (
|
|||||||
KeyLockOrderCreate = RedisPrefix + "lock:order:create:%d" // order_id,下单防重
|
KeyLockOrderCreate = RedisPrefix + "lock:order:create:%d" // order_id,下单防重
|
||||||
KeyLockPayCallback = RedisPrefix + "lock:pay:callback:%s" // order_no,支付回调幂等
|
KeyLockPayCallback = RedisPrefix + "lock:pay:callback:%s" // order_no,支付回调幂等
|
||||||
KeyLockRefundCallback = RedisPrefix + "lock:refund:callback:%s" // refund_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星座防并发
|
KeyLockAIHoroscope = RedisPrefix + "lock:ai:horoscope:%d:%s" // user_id:zodiac,AI星座防并发
|
||||||
KeyLockClosetAudit = RedisPrefix + "lock:audit:closet:%d" // target_id,衣柜分享审核防重
|
KeyLockClosetAudit = RedisPrefix + "lock:audit:closet:%d" // target_id,衣柜分享审核防重
|
||||||
KeyLockOutfitAudit = RedisPrefix + "lock:audit:outfit:%d" // target_id,穿搭审核防重
|
KeyLockOutfitAudit = RedisPrefix + "lock:audit:outfit:%d" // target_id,穿搭审核防重
|
||||||
|
|||||||
@@ -19,8 +19,9 @@ func NewAIRecommendHandler() *AIRecommendHandler {
|
|||||||
return &AIRecommendHandler{svc: service.NewAIRecommendService()}
|
return &AIRecommendHandler{svc: service.NewAIRecommendService()}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create 发起 AI 穿搭推荐。
|
// Submit 提交 AI 穿搭推荐任务(异步)。
|
||||||
func (h *AIRecommendHandler) Create(c *gin.Context) {
|
// 立即返回 task_id,不等待大模型;生成在前端断开后仍会继续,结果通过 Status 轮询获取。
|
||||||
|
func (h *AIRecommendHandler) Submit(c *gin.Context) {
|
||||||
response := app.NewResponse(c)
|
response := app.NewResponse(c)
|
||||||
userID, ok := middleware.GetUserID(c.Request.Context())
|
userID, ok := middleware.GetUserID(c.Request.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -32,7 +33,28 @@ func (h *AIRecommendHandler) Create(c *gin.Context) {
|
|||||||
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||||
return
|
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 {
|
if e != nil {
|
||||||
response.ToErrorResponse(e)
|
response.ToErrorResponse(e)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -134,8 +134,9 @@ func NewRouter() *gin.Engine {
|
|||||||
// 广告激励额度
|
// 广告激励额度
|
||||||
auth.POST("/ad-reward/grant", adRewardHandler.Grant)
|
auth.POST("/ad-reward/grant", adRewardHandler.Grant)
|
||||||
|
|
||||||
// AI 推荐
|
// AI 推荐(异步:提交任务后轮询结果,避免长耗时请求被超时中断)
|
||||||
auth.POST("/ai-recommend", aiHandler.Create)
|
auth.POST("/ai-recommend", aiHandler.Submit)
|
||||||
|
auth.GET("/ai-recommend/status", aiHandler.Status)
|
||||||
auth.POST("/ai-recommend/save", aiHandler.Save)
|
auth.POST("/ai-recommend/save", aiHandler.Save)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -3,8 +3,8 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
"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/mysqldao"
|
||||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
"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/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/errcode"
|
||||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/tokenhub"
|
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/tokenhub"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// aiRecommendRunTimeout 后台生成任务的整体超时。
|
||||||
|
// 大模型单次最长 120s,其余留给查库与落库,给足余量。
|
||||||
|
aiRecommendRunTimeout = 3 * time.Minute
|
||||||
|
|
||||||
|
// aiRecommendSlotTTL 用户活跃位保留时长,需略长于任务超时,
|
||||||
|
// 否则任务尚未收尾活跃位就先过期,会造成同一用户重复提交、重复扣额度。
|
||||||
|
aiRecommendSlotTTL = aiRecommendRunTimeout + time.Minute
|
||||||
|
|
||||||
|
// aiRecommendCleanupTimeout 任务收尾(退额度 / 写流水 / 释放活跃位)的超时。
|
||||||
|
aiRecommendCleanupTimeout = 15 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
// AIRecommendService AI 穿搭推荐业务服务。
|
// AIRecommendService AI 穿搭推荐业务服务。
|
||||||
type AIRecommendService struct {
|
type AIRecommendService struct {
|
||||||
outfitDao *mysqldao.OutfitDao
|
outfitDao *mysqldao.OutfitDao
|
||||||
@@ -40,125 +52,99 @@ func NewAIRecommendService() *AIRecommendService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create 发起 AI 穿搭推荐:防并发、扣 AI 额度、基于衣橱与运势生成推荐,落库为待保存穿搭。
|
// Submit 提交 AI 穿搭推荐任务,立即返回 task_id,真正的生成在后台完成。
|
||||||
func (s *AIRecommendService) Create(ctx context.Context, userID int64, request *dto.AIRecommendCreateRequest) (*dto.AIRecommendCreateResponse, *errcode.Error) {
|
//
|
||||||
ctx, span := global.Tracer.Start(ctx, "svc.AIRecommendCreate")
|
// 与同步实现的区别:
|
||||||
|
// - 接口不等待大模型,秒级返回,天然不受 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()
|
defer span.End()
|
||||||
|
|
||||||
// 1. 防并发锁。
|
taskID := genUUID()
|
||||||
lockKey := fmt.Sprintf(redismodel.KeyLockAIRecommend, userID)
|
|
||||||
lockVal := genUUID()
|
// 1. 抢占活跃位:抢不到说明该用户已有任务在跑,幂等返回它。
|
||||||
ok, err := redisdao.TryLock(ctx, lockKey, lockVal, 180*time.Second)
|
acquired, runningTaskID, err := redisdao.AIRecommendTaskAcquire(ctx, userID, taskID, aiRecommendSlotTTL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Redis 异常:返回服务错误而非误报「请求过于频繁」。
|
|
||||||
return nil, errcode.CacheError
|
return nil, errcode.CacheError
|
||||||
}
|
}
|
||||||
if !ok {
|
if !acquired {
|
||||||
return nil, errcode.TooManyRequests
|
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 {
|
if qerr := consumeDailyQuota(ctx, userID, QuotaTypeAI, 1, "AI 推荐消耗"); qerr != nil {
|
||||||
|
s.releaseSlot(context.WithoutCancel(ctx), userID, taskID)
|
||||||
if qerr == errcode.QuotaInsufficient {
|
if qerr == errcode.QuotaInsufficient {
|
||||||
return nil, errcode.AIRecommendQuotaLimit
|
return nil, errcode.AIRecommendQuotaLimit
|
||||||
}
|
}
|
||||||
return nil, qerr
|
return nil, qerr
|
||||||
}
|
}
|
||||||
|
|
||||||
// 失败时统一回退额度的兜底闭包。
|
// 3. 先落一个「生成中」快照,前端提交后立刻就能轮询到。
|
||||||
fail := func(bizErr *errcode.Error, reason string) *errcode.Error {
|
if serr := redisdao.AIRecommendTaskSave(ctx, &redisdao.AIRecommendTask{
|
||||||
refundDailyQuota(ctx, userID, QuotaTypeAI, 1)
|
TaskID: taskID,
|
||||||
s.writeFlowLog(ctx, userID, "", AIFlowStatusFailed, reason)
|
UserID: userID,
|
||||||
return bizErr
|
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. 加载用户可用衣物(已成功处理)。
|
// 4. 后台生成。request 是栈上参数,复制一份交给 goroutine,避免外部复用导致数据竞争。
|
||||||
clothes, cerr := s.processedClothes(ctx, userID)
|
reqCopy := *request
|
||||||
if cerr != nil {
|
go s.runTask(taskID, userID, &reqCopy)
|
||||||
return nil, fail(errcode.DatabaseError, "查询衣橱失败")
|
|
||||||
}
|
|
||||||
if len(clothes) == 0 {
|
|
||||||
return nil, fail(errcode.AIRecommendFailed.WithDetails("衣橱暂无可用衣物"), "衣橱为空")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 计算星座与今日运势(best-effort)。
|
return &dto.AIRecommendSubmitResponse{
|
||||||
zodiac, fortune := s.userFortune(ctx, userID)
|
TaskID: taskID,
|
||||||
|
Status: AIRecommendTaskPending,
|
||||||
// 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,
|
|
||||||
}, nil
|
}, 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 推荐生成的待保存穿搭保存到「我的搭配」(置为正常状态)。
|
// Save 将 AI 推荐生成的待保存穿搭保存到「我的搭配」(置为正常状态)。
|
||||||
func (s *AIRecommendService) Save(ctx context.Context, userID int64, request *dto.AIRecommendSaveRequest) *errcode.Error {
|
func (s *AIRecommendService) Save(ctx context.Context, userID int64, request *dto.AIRecommendSaveRequest) *errcode.Error {
|
||||||
ctx, span := global.Tracer.Start(ctx, "svc.AIRecommendSave")
|
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
|
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 获取用户已成功处理、可用于推荐的衣物。
|
// 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)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -189,6 +189,13 @@ const (
|
|||||||
AIFlowStatusFailed int8 = 2 // 失败
|
AIFlowStatusFailed int8 = 2 // 失败
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ===== AI 推荐异步任务状态(Redis 快照) =====
|
||||||
|
const (
|
||||||
|
AIRecommendTaskPending int8 = 1 // 生成中
|
||||||
|
AIRecommendTaskSuccess int8 = 2 // 生成成功
|
||||||
|
AIRecommendTaskFailed int8 = 3 // 生成失败
|
||||||
|
)
|
||||||
|
|
||||||
// 订单支付有效期(秒)。
|
// 订单支付有效期(秒)。
|
||||||
const orderPayTTLSeconds int64 = 15 * 60
|
const orderPayTTLSeconds int64 = 15 * 60
|
||||||
|
|
||||||
|
|||||||
@@ -545,6 +545,7 @@ func clothesImageURL(processed string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildOutfitCreateResponse 组装穿搭响应。
|
// buildOutfitCreateResponse 组装穿搭响应。
|
||||||
|
// 带出 status,便于前端在列表中区分「待用户保存」的 AI 穿搭(status=3)与正式穿搭。
|
||||||
func buildOutfitCreateResponse(o *mysqlmodel.Outfit, items []dto.OutfitItemResponse) dto.OutfitCreateResponse {
|
func buildOutfitCreateResponse(o *mysqlmodel.Outfit, items []dto.OutfitItemResponse) dto.OutfitCreateResponse {
|
||||||
return dto.OutfitCreateResponse{
|
return dto.OutfitCreateResponse{
|
||||||
OutfitUUID: o.OutfitUUID,
|
OutfitUUID: o.OutfitUUID,
|
||||||
@@ -554,6 +555,7 @@ func buildOutfitCreateResponse(o *mysqlmodel.Outfit, items []dto.OutfitItemRespo
|
|||||||
Source: o.Source,
|
Source: o.Source,
|
||||||
IsPublic: o.IsPublic,
|
IsPublic: o.IsPublic,
|
||||||
AuditStatus: o.AuditStatus,
|
AuditStatus: o.AuditStatus,
|
||||||
|
Status: o.Status,
|
||||||
Items: items,
|
Items: items,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ func main() {
|
|||||||
service.StartOrderExpireScheduler()
|
service.StartOrderExpireScheduler()
|
||||||
service.StartVirtualPayConfirmScheduler()
|
service.StartVirtualPayConfirmScheduler()
|
||||||
|
|
||||||
|
// 清理超期未保存的 AI 推荐穿搭(status=3),避免长期堆积。
|
||||||
|
service.StartAIOutfitCleanupScheduler()
|
||||||
|
|
||||||
router := routers.NewRouter()
|
router := routers.NewRouter()
|
||||||
|
|
||||||
s := &http.Server{
|
s := &http.Server{
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ var (
|
|||||||
HoroscopeNotFound = NewError(90300, "星座运势不存在")
|
HoroscopeNotFound = NewError(90300, "星座运势不存在")
|
||||||
|
|
||||||
// AI 推荐(90400 - 90499)
|
// AI 推荐(90400 - 90499)
|
||||||
AIRecommendFailed = NewError(90400, "AI 推荐失败")
|
AIRecommendFailed = NewError(90400, "AI 推荐失败")
|
||||||
AIRecommendQuotaLimit = NewError(90401, "AI 推荐次数已达上限")
|
AIRecommendQuotaLimit = NewError(90401, "AI 推荐次数已达上限")
|
||||||
|
AIRecommendTaskNotFound = NewError(90402, "AI 推荐任务不存在或已过期")
|
||||||
|
AIRecommendTaskInProgress = NewError(90403, "AI 推荐正在生成中,请稍后查询结果")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -104,7 +104,10 @@ CREATE TABLE `outfits` (
|
|||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
UNIQUE KEY `uk_outfits_uuid` (`outfit_uuid`),
|
UNIQUE KEY `uk_outfits_uuid` (`outfit_uuid`),
|
||||||
KEY `idx_outfits_user_status` (`user_id`, `status`),
|
KEY `idx_outfits_user_status` (`user_id`, `status`),
|
||||||
KEY `idx_outfits_public` (`is_public`, `audit_status`)
|
KEY `idx_outfits_public` (`is_public`, `audit_status`),
|
||||||
|
-- 供「未保存 AI 穿搭」清理任务使用:
|
||||||
|
-- WHERE deleted_at = 0 AND source = 2 AND status = 3 AND created_at < ?
|
||||||
|
KEY `idx_outfits_source_status_created` (`source`, `status`, `created_at`)
|
||||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '穿搭表';
|
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '穿搭表';
|
||||||
|
|
||||||
CREATE TABLE `outfit_items` (
|
CREATE TABLE `outfit_items` (
|
||||||
|
|||||||
@@ -72,7 +72,7 @@
|
|||||||
3. **衣物上传是异步的**,返回只代表"已排队",必须轮询状态接口。
|
3. **衣物上传是异步的**,返回只代表"已排队",必须轮询状态接口。
|
||||||
4. **更新衣物的主颜色请求字段是 `color`**,响应里才是 `color_main`。
|
4. **更新衣物的主颜色请求字段是 `color`**,响应里才是 `color_main`。
|
||||||
5. **金额单位都是分、时间都是秒**,展示前要换算。
|
5. **金额单位都是分、时间都是秒**,展示前要换算。
|
||||||
6. **AI 推荐生成后不保存不退额度**;退款是人工审核,不是实时到账。
|
6. **AI 推荐是异步的**:`POST /ai-recommend` 只返回 `task_id`,必须轮询 `GET /ai-recommend/status` 才能拿到结果;生成后不保存不退额度。退款是人工审核,不是实时到账。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -211,10 +211,11 @@ Authorization: Bearer <access_token>
|
|||||||
- 穿搭公开 `PUT /outfit/:uuid/public`(重复公开**不会**重复扣额度;但**取消公开后再次公开会再扣 1 次**,见 [6.6](#66-额度规则全景))
|
- 穿搭公开 `PUT /outfit/:uuid/public`(重复公开**不会**重复扣额度;但**取消公开后再次公开会再扣 1 次**,见 [6.6](#66-额度规则全景))
|
||||||
- 标记已读 `POST /notification/:uuid/read`
|
- 标记已读 `POST /notification/:uuid/read`
|
||||||
- 保存 AI 推荐 `POST /ai-recommend/save`
|
- 保存 AI 推荐 `POST /ai-recommend/save`
|
||||||
|
- 提交 AI 推荐 `POST /ai-recommend`(任务生成中重复提交会返回**同一个 `task_id`**,不会重复扣额度)
|
||||||
- 取消订单 `POST /order/:orderNo/cancel`
|
- 取消订单 `POST /order/:orderNo/cancel`
|
||||||
- 取消分享 `POST /closet-share/:uuid/cancel`
|
- 取消分享 `POST /closet-share/:uuid/cancel`
|
||||||
|
|
||||||
**不建议重试**:`POST /clothes/upload`(每次都会扣 1 个上传额度)、`POST /ai-recommend`(每次扣 1 个 AI 额度,且并发会触发 `10003`)、`POST /order`(会创建新订单)。
|
**不建议重试**:`POST /clothes/upload`(每次都会扣 1 个上传额度)、`POST /order`(会创建新订单)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -225,6 +226,7 @@ Authorization: Bearer <access_token>
|
|||||||
| 场景 | 轮询接口 | 终态 | 建议间隔 |
|
| 场景 | 轮询接口 | 终态 | 建议间隔 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| 衣物 AI 处理 | `GET /clothes/:uuid/status` | `4` 成功 / `5` 失败(`1/2/3` 继续轮询) | 1~2 秒,建议上限 60 次 |
|
| 衣物 AI 处理 | `GET /clothes/:uuid/status` | `4` 成功 / `5` 失败(`1/2/3` 继续轮询) | 1~2 秒,建议上限 60 次 |
|
||||||
|
| AI 推荐生成 | `GET /ai-recommend/status` | `2` 成功 / `3` 失败(`1` 继续轮询) | 2 秒,建议上限 90 次 |
|
||||||
| 支付结果 | `GET /order`(按 `status` 或 `order_no` 过滤) | `2` 已支付 / `3` 已取消 / `4` 已过期 | 支付后 1 秒起,最多 15 分钟 |
|
| 支付结果 | `GET /order`(按 `status` 或 `order_no` 过滤) | `2` 已支付 / `3` 已取消 / `4` 已过期 | 支付后 1 秒起,最多 15 分钟 |
|
||||||
| 退款进度 | `GET /order` | `5` 已退款 | 30 秒(人工审核,可能很久) |
|
| 退款进度 | `GET /order` | `5` 已退款 | 30 秒(人工审核,可能很久) |
|
||||||
| 今日运势未生成 | `GET /horoscope/today` | 返回数据(首次可能 `90300`) | 3~5 秒,最多 3 次 |
|
| 今日运势未生成 | `GET /horoscope/today` | 返回数据(首次可能 `90300`) | 3~5 秒,最多 3 次 |
|
||||||
@@ -240,7 +242,7 @@ Authorization: Bearer <access_token>
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. 接口总表(48 个)
|
## 3. 接口总表(49 个)
|
||||||
|
|
||||||
### 健康检查(1)
|
### 健康检查(1)
|
||||||
|
|
||||||
@@ -411,9 +413,12 @@ Authorization: Bearer <access_token>
|
|||||||
| source | int | 来源:`1`=用户创建 `2`=AI 推荐 |
|
| source | int | 来源:`1`=用户创建 `2`=AI 推荐 |
|
||||||
| is_public | int | 公开:`1`=不公开 `2`=公开 |
|
| is_public | int | 公开:`1`=不公开 `2`=公开 |
|
||||||
| audit_status | int | 审核:`1`=待审核 `2`=审核通过 `3`=审核不通过 |
|
| audit_status | int | 审核:`1`=待审核 `2`=审核通过 `3`=审核不通过 |
|
||||||
|
| status | int | 状态:`1`=正常 `2`=封禁 `3`=**待用户保存**(AI 推荐生成后未保存) |
|
||||||
| items | array | 衣物列表,元素 `{"clothes_uuid": string}` |
|
| items | array | 衣物列表,元素 `{"clothes_uuid": string}` |
|
||||||
|
|
||||||
> ⚠️ 列表接口**不返回** `status` 字段,但数据库存在 `status`(`1`=正常 `2`=封禁 `3`=待用户保存)。AI 生成的"待保存"穿搭(`status=3`)也会出现在 `GET /outfit` 列表中,**前端需自行区分展示**(建议:AI 推荐未保存的单独归到"AI 推荐"入口,不要混入"我的搭配")。
|
> **关于 `status=3`(AI 待保存)**:AI 推荐生成的穿搭会以 `status=3` 出现在 `GET /outfit` 列表中,前端应据此把它归到「AI 推荐」入口,不要混入「我的搭配」。
|
||||||
|
>
|
||||||
|
> 这类穿搭**超过 7 天未被用户保存,会由后端定时任务自动清理**(软删除,列表与详情立即不可见)。用户超期后再调 `POST /ai-recommend/save` 会返回 `40000` 穿搭不存在,属预期行为。
|
||||||
|
|
||||||
### 4.4 商品对象 `GoodsItem`
|
### 4.4 商品对象 `GoodsItem`
|
||||||
|
|
||||||
@@ -927,7 +932,9 @@ Authorization: Bearer <access_token>
|
|||||||
|
|
||||||
**响应** `data.list[]` = [穿搭对象](#43-穿搭对象-outfititem)
|
**响应** `data.list[]` = [穿搭对象](#43-穿搭对象-outfititem)
|
||||||
|
|
||||||
> ⚠️ 该接口**不返回 `status` 字段,也不会过滤 `status=3`(AI 待保存)的穿搭**。前端拿到列表后,建议用 `source` 区分来源;AI 推荐未保存的穿搭应引导用户去"AI 推荐"入口处理,不要混入"我的搭配"。
|
> ⚠️ 该接口**不会过滤 `status=3`(AI 待保存)的穿搭**,但响应中已带上 `status` 字段。前端应据此把 AI 未保存的穿搭归到「AI 推荐」入口,不要混入「我的搭配」。
|
||||||
|
>
|
||||||
|
> 这类穿搭超过 7 天未保存会被后端自动清理(软删除),详见 [穿搭对象](#43-穿搭对象-outfititem)。
|
||||||
|
|
||||||
**错误码**:`10000` 参数错误 · `19001` 数据库错误
|
**错误码**:`10000` 参数错误 · `19001` 数据库错误
|
||||||
|
|
||||||
@@ -1077,17 +1084,24 @@ Authorization: Bearer <access_token>
|
|||||||
|
|
||||||
### 5.6 AI 推荐模块
|
### 5.6 AI 推荐模块
|
||||||
|
|
||||||
#### POST `/ai-recommend` — 生成 AI 穿搭推荐(鉴权)
|
> AI 推荐是**异步接口**。`POST /ai-recommend` 只提交任务并立即返回 `task_id`,真正的生成在后台执行;前端用 `GET /ai-recommend/status` 轮询结果。
|
||||||
|
>
|
||||||
|
> 这样设计的好处:接口不再长时间挂起,**前端退出、切页面、断网都不会中断生成** —— 结果仍会落库为"待保存"穿搭,用户重新进入也能看到。
|
||||||
|
|
||||||
|
#### POST `/ai-recommend` — 提交 AI 穿搭推荐任务(鉴权)
|
||||||
|
|
||||||
**业务逻辑**
|
**业务逻辑**
|
||||||
|
|
||||||
1. **并发锁**:同一用户 180 秒内只能有一个生成任务,抢锁失败返回 `10003` 请求过于频繁(**HTTP 429**),前端应提示"正在生成中,请稍候"。
|
1. **提交幂等**:同一用户已有任务在生成中时,直接返回该任务的 `task_id`(**不会重复扣额度**),前端继续轮询原任务即可。
|
||||||
2. **生成前先扣 1 个 AI 额度**(`quota_type=2`);额度不足返回 **`90401` AI 推荐次数已达上限**(注意不是 `80000`)。
|
2. **提交时先扣 1 个 AI 额度**(`quota_type=2`);额度不足返回 **`90401` AI 推荐次数已达上限**(注意不是 `80000`)。
|
||||||
3. 输入:当前用户**处理成功(状态 4)**的全部衣物 + 生日推算的星座 + 今日运势(星座/运势缺失会降级,不影响生成)。
|
3. 接口**秒级返回**(实测 ~30ms),不等大模型。
|
||||||
4. **衣橱中没有可用衣物** → `90400` + `details:["衣橱暂无可用衣物"]`,**额度已自动回退**。
|
4. 生成在后台执行,输入为:当前用户**处理成功(状态 4)**的全部衣物 + 生日推算的星座 + 今日运势(星座/运势缺失会降级,不影响生成)。
|
||||||
5. 调用大模型生成;失败或无有效单品(`details:["未生成有效搭配"]`)→ `90400`,**额度自动回退**。
|
5. 失败时**额度自动回退**,失败原因写入任务状态(`error_code` / `error_msg`),详见下方状态接口:
|
||||||
6. 成功:落库为穿搭,`source=2`、`is_public=1`、`audit_status=1`、`status=3`(**待用户保存**),并返回完整内容供预览。
|
- `90400` + `衣橱暂无可用衣物`
|
||||||
7. ⚠️ **生成成功但未调用 save,不退还 AI 额度**(额度在生成时已消耗)。
|
- `90400` + `大模型调用失败`
|
||||||
|
- `90400` + `未生成有效搭配`
|
||||||
|
- `90400` + `生成任务异常终止`(超过 3 分钟未完成)
|
||||||
|
6. ⚠️ **生成成功但未调用 save,不退还 AI 额度**(额度在提交时已消耗)。
|
||||||
|
|
||||||
**请求**(json)
|
**请求**(json)
|
||||||
|
|
||||||
@@ -1098,6 +1112,46 @@ Authorization: Bearer <access_token>
|
|||||||
|
|
||||||
**响应** `data`
|
**响应** `data`
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| task_id | string | 任务 ID,用于轮询 `GET /ai-recommend/status` |
|
||||||
|
| status | int | 任务状态,提交后恒为 `1`(生成中) |
|
||||||
|
|
||||||
|
**错误码**:`10000` 参数错误 · `90401` AI 推荐次数已达上限(AI 额度不足)· `10003` 请求过于频繁(429,活跃位刚过期的极端情况,重试即可)· `19002` 缓存错误
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### GET `/ai-recommend/status` — 查询 AI 推荐任务状态(鉴权)
|
||||||
|
|
||||||
|
**业务逻辑**
|
||||||
|
|
||||||
|
- 任务快照保存在 Redis,**有效期 15 分钟**;任务不存在、已过期或不属于当前用户,统一返回 `90402`(不暴露他人任务是否存在)。
|
||||||
|
- 任务状态 `status`:
|
||||||
|
|
||||||
|
| 值 | 含义 | 前端处理 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | 生成中 | 继续轮询,建议 2 秒一次、上限 90 次(3 分钟) |
|
||||||
|
| 2 | **成功** | 终态:读取 `result` 渲染推荐结果 |
|
||||||
|
| 3 | **失败** | 终态:读 `error_code` / `error_msg` 提示用户(**额度已自动回退**) |
|
||||||
|
|
||||||
|
**请求**(query)
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| task_id | string | ✅ | `POST /ai-recommend` 返回的 `task_id` |
|
||||||
|
|
||||||
|
**响应** `data`
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| task_id | string | 任务 ID |
|
||||||
|
| status | int | 任务状态:`1` 生成中 / `2` 成功 / `3` 失败 |
|
||||||
|
| result | object | **仅 `status=2` 时存在**,结构见下表 |
|
||||||
|
| error_code | int | **仅 `status=3` 时存在**,业务错误码(通常 `90400`) |
|
||||||
|
| error_msg | string | **仅 `status=3` 时存在**,失败原因文案 |
|
||||||
|
|
||||||
|
`result` 对象(`status=2`):
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| outfit_uuid | string | 生成结果 UUID(**保存时要用**) |
|
| outfit_uuid | string | 生成结果 UUID(**保存时要用**) |
|
||||||
@@ -1105,10 +1159,10 @@ Authorization: Bearer <access_token>
|
|||||||
| outfit_desc | string | 穿搭描述 |
|
| outfit_desc | string | 穿搭描述 |
|
||||||
| scene | string | 场景 |
|
| scene | string | 场景 |
|
||||||
| source | int | `2`=AI 推荐 |
|
| source | int | `2`=AI 推荐 |
|
||||||
| status | int | `3`=待用户保存 |
|
| status | int | ⚠️ 穿搭状态 `3`=待用户保存(**与顶层的任务状态 `status` 同名但含义不同**) |
|
||||||
| outfit_item | array | ⚠️ 注意字段名是 `outfit_item`(单数 item),元素 `{"clothes_uuid": string}` |
|
| outfit_item | array | ⚠️ 注意字段名是 `outfit_item`(单数 item),元素 `{"clothes_uuid": string}` |
|
||||||
|
|
||||||
**错误码**:`10000` 参数错误 · `10003` 请求过于频繁(429,180 秒内重复请求)· `90400` AI 推荐失败(含 `衣橱暂无可用衣物`、`未生成有效搭配`)· `90401` AI 推荐次数已达上限(AI 额度不足)· `19002` 缓存错误 · `19003` 下游服务调用失败
|
**错误码**:`10000` 参数错误 · `90402` 任务不存在或已过期 · `19002` 缓存错误
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1125,7 +1179,7 @@ Authorization: Bearer <access_token>
|
|||||||
|
|
||||||
| 参数 | 类型 | 必填 | 约束 | 说明 |
|
| 参数 | 类型 | 必填 | 约束 | 说明 |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| outfit_uuid | string | ✅ | required | 由 `/ai-recommend` 返回的 `outfit_uuid` |
|
| outfit_uuid | string | ✅ | required | 由 `GET /ai-recommend/status` 返回的 `result.outfit_uuid` |
|
||||||
|
|
||||||
**响应** `data` = `{}`
|
**响应** `data` = `{}`
|
||||||
|
|
||||||
@@ -2014,20 +2068,32 @@ sequenceDiagram
|
|||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
participant F as 小程序前端
|
participant F as 小程序前端
|
||||||
participant B as 后端服务
|
participant B as 后端服务
|
||||||
|
participant R as Redis
|
||||||
participant L as 大模型网关(Tokenhub)
|
participant L as 大模型网关(Tokenhub)
|
||||||
|
|
||||||
F->>B: POST /ai-recommend {scene_type, scene?}
|
F->>B: POST /ai-recommend {scene_type, scene?}
|
||||||
B->>B: Redis 锁 180s(防并发,失败返回 10003)
|
B->>R: 抢占用户活跃位(已有任务则返回原 task_id,幂等)
|
||||||
B->>B: 扣 1 个 AI 额度(不足 → 90401)
|
B->>B: 扣 1 个 AI 额度(不足 → 90401)
|
||||||
B->>L: 结合星座运势 + 衣橱(状态 4)衣物生成
|
B->>R: 写入任务快照(status=1 生成中)
|
||||||
L-->>B: 推荐结果
|
B-->>F: {task_id, status: 1} · 秒级返回,不等大模型
|
||||||
B->>B: 校验结果(过滤模型幻觉衣物 ID)
|
|
||||||
B->>B: 落库 source=2, status=3 待用户保存
|
par 后台生成(独立 ctx,前端退出也不中断)
|
||||||
B-->>F: {outfit_uuid, outfit_name, outfit_item, ...}
|
B->>L: 结合星座运势 + 衣橱(状态 4)衣物生成
|
||||||
|
L-->>B: 推荐结果
|
||||||
|
B->>B: 校验结果(过滤模型幻觉衣物 ID)
|
||||||
|
B->>B: 落库 source=2, status=3 待用户保存
|
||||||
|
B->>R: 任务快照 status=2 + result
|
||||||
|
and 前端轮询
|
||||||
|
loop 每 2 秒,上限 90 次
|
||||||
|
F->>B: GET /ai-recommend/status?task_id=...
|
||||||
|
B->>R: 读任务快照
|
||||||
|
B-->>F: {task_id, status, result?}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
alt 生成失败
|
alt 生成失败
|
||||||
B->>B: 回退 AI 额度 + 写失败流水
|
B->>B: 回退 AI 额度 + 写失败流水
|
||||||
B-->>F: code 90400
|
B->>R: 任务快照 status=3 + error_code/error_msg
|
||||||
end
|
end
|
||||||
|
|
||||||
F->>F: 用户预览、确认
|
F->>F: 用户预览、确认
|
||||||
@@ -2233,6 +2299,7 @@ sequenceDiagram
|
|||||||
| 90300 | 星座运势不存在(生成中,稍后重试) | 200 |
|
| 90300 | 星座运势不存在(生成中,稍后重试) | 200 |
|
||||||
| 90400 | AI 推荐失败 | 200 |
|
| 90400 | AI 推荐失败 | 200 |
|
||||||
| **90401** | **AI 推荐次数已达上限**(AI 额度不足) | 200 |
|
| **90401** | **AI 推荐次数已达上限**(AI 额度不足) | 200 |
|
||||||
|
| 90402 | AI 推荐任务不存在或已过期(轮询用) | 200 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user