first commit

This commit is contained in:
2026-09-11 01:41:52 +08:00
commit 9f22e6ccae
263 changed files with 28935 additions and 0 deletions
@@ -0,0 +1,65 @@
---
name: Go 后端全量代码审查
overview: 对 zt-closet-backend 的 Go 后端代码(internal / pkg / global / main.go / cmd)进行全量业务逻辑与技术逻辑审查,产出一份 Markdown 审查报告文件,不改动任何业务代码。
todos:
- id: baseline-review
content: 用 [subagent:code-explorer] 建立模块基线并核对上轮修复现状
status: completed
- id: review-trade
content: 用 [skill:lsp-code-analysis] 审查交易与额度域(订单/虚拟支付/额度/补偿/商品)
status: completed
dependencies:
- baseline-review
- id: review-content
content: 审查内容与社区域(衣物/穿搭/分享/互动/通知/AI推荐/用户)
status: completed
dependencies:
- baseline-review
- id: review-crosscutting
content: 审查横切技术面(安全/并发/事务/错误处理/资源/基础设施)
status: completed
dependencies:
- baseline-review
- id: generate-report
content: 复核上轮报告并生成审查报告 docs/代码审查报告-三次check.md
status: completed
dependencies:
- review-trade
- review-content
- review-crosscutting
---
## 用户需求
对 zt-closet-backend 全量 Go 后端进行只读代码审查,识别业务逻辑与技术逻辑问题,并生成 Markdown 审查报告文件。
## 审查范围
- 覆盖 `internal/`、`pkg/`、`global/`、`main.go`、`cmd/`(不含 `thirdparty/` Python 服务)。
- 只出报告,不修改任何业务代码,仅新增报告文件。
## 审查维度
- 业务逻辑:状态机流转、额度扣减/回退一致性、幂等性、并发竞态、金额与单位、边界条件、补偿机制闭环。
- 技术逻辑:安全(SQL 注入、越权 AuthZ、SSRF、XSS、密钥泄露、反序列化)、事务一致性、并发安全、错误处理、资源泄漏、超时控制、分页边界、日志脱敏。
## 交付物
- 新报告文件 `docs/代码审查报告-三次check.md`。
- 问题按「严重/中等/轻微」分级,每条包含文件+行号/函数定位、风险说明、修复建议。
- 复核上一轮报告 `docs/代码审查报告-二次check.md` 中各项的当前修复状态。
- 结论须基于真实代码,不确定处标注「待确认」。
## 推荐扩展
### SubAgent
- **code-explorer**
- 用途:跨目录建立模块与文件清单,定位上一轮报告涉及文件及当前实现状态。
- 预期结果:产出各业务域关键文件/函数清单,支撑后续分域审查。
### Skill
- **lsp-code-analysis**
- 用途:追踪额度扣减/回退、订单状态流转、权益发放等关键符号的定义与引用链。
- 预期结果:明确关键调用链与全部调用点,验证并发与幂等路径。
@@ -0,0 +1,120 @@
---
name: OpenTelemetry三信号完整接入
overview: 实现 OpenTelemetry Traces/Metrics/Logs 三大信号的完整初始化与 OTLP gRPC 导出,补齐当前 main.go 引用的未实现桩(NewOTelProvider/OTelShutdown),保持本地日志不变的同时新增 OTel 导出,并加优雅关闭以刷新数据。
todos:
- id: deps-and-globals
content: 补齐 otel sdk/exporter/otelslog/runtime 依赖(go mod tidy),在 global/otel.go 新增 OTelShutdown 与 Meter 变量,并对齐 config.yaml 与 config.example.yaml 的 SamplerRatio 键名
status: completed
- id: provider
content: 实现 pkg/tracing/provider.go 的 NewOTelProvider:构建 Resource 与 Tracer/Meter/Logger 三 Provider、OTLP gRPC 导出、runtime 指标、采样与 Enabled/Insecure 开关、no-op 降级、聚合 Shutdown
status: completed
dependencies:
- deps-and-globals
- id: log-fanout
content: 实现 pkg/tracing/fanout.go 扇出 slog.Handler,将日志同时输出到本地 JSON(保留 OtelContextHandler)与 otelslog bridge
status: completed
dependencies:
- provider
- id: wire-main
content: 修改 main.go:修正 setupOTel 符号与错误降级、调整 init 顺序使 logger 桥接 LoggerProvider、setupSlogLogger 接入扇出 handler、main() 增加 SIGINT/SIGTERM 监听与 server.Shutdown + OTelShutdown 优雅关闭
status: completed
dependencies:
- provider
- log-fanout
- id: verify
content: 执行 go mod tidy 与 go build/go vet 全量验证,smoke 验证 Enabled=false 及采集器不可用时不 panic 并正常启动,清理临时验证文件
status: completed
dependencies:
- wire-main
---
## 产品概述
为「衣柜/穿搭」后端补齐 OpenTelemetry 三大信号(Traces / Metrics / Logs)的完整初始化与 OTLP gRPC 导出,把当前"只埋点、未导出"的半接入状态升级为可真正观测的完整链路,并修复因待实现桩导致的编译失败问题。
## 核心功能
- **Traces(链路追踪)**:注册真实 TracerProvider(Resource + OTLP gRPC 批量导出 + 采样率),使全项目已有的 span 与 gin 请求 span 真正导出。
- **Metrics(指标)**:注册 MeterProvider(OTLP gRPC 周期导出),采集 Go runtime 指标,并预留自定义指标入口。
- **Logs(日志)**:新增 OTel 日志导出(otelslog 桥接),同时保留现有本地 JSON 日志(文件+stdout,含 trace_id/span_id 注入)。
- **优雅关闭**:进程收到退出信号时先关闭 HTTP 服务,再刷新并关闭三个 Provider,避免丢失缓冲数据。
- **开关与降级**:尊重配置 `Enabled` 开关;未启用或采集器不可用时降级为 no-op,服务照常启动、业务不受影响、不 panic。
## 技术栈
- 语言/框架:Go 1.27 + Gin(沿用现有项目),OpenTelemetry-Go SDK。
- 基准版本对齐现有 `go.opentelemetry.io/otel v1.46.0`;SDK trace/metric 用对应 v1.46.x,log 相关为配套 v0.x,由 `go mod tidy` 解析。
- 传输:OTLP over gRPC,导出到配置的 `ExporterEndpoint`(如 `127.0.0.1:4317`)。
## 实现思路
在 `pkg/tracing` 新增统一初始化入口 `NewOTelProvider`,一次性构建 Resource 与三个 SDK Provider(Tracer/Meter/Logger),设置到 otel 全局,并返回聚合的 shutdown 函数;`main.go` 修正现有待实现桩(`setupOTel` 引用的未定义符号)、修正 init 顺序与错误降级、补充信号监听与优雅关闭。业务层 100+ 处 `global.Tracer.Start(...)` 与 `otelgin.Middleware(...)` 无需改动,Provider 注册后立即生效。
### 关键技术决策
- **统一入口 + 聚合 Shutdown**:单一 `NewOTelProvider` 返回 `(trace.Tracer, func(ctx) error, error)`,内部聚合三个 Provider 的 Shutdown,`main` 只持有一个 `global.OTelShutdown`,降低接线复杂度与遗漏 flush 风险。
- **日志扇出(fanout)而非替换**:保留现有本地 JSON handler(含 `OtelContextHandler` 的 trace 注入),叠加 `otelslog` bridge handler,用一个极简自研 `slog.Handler` 扇出到两者,避免引入第三方 slog-multi 依赖,且不破坏现有日志落盘。
- **降级优先,保障可用性**:`Enabled=false` 或 exporter 建立失败时,`global.Tracer` 使用 no-op tracer(业务层 `Tracer.Start` 安全非 nil),`OTelShutdown` 为空实现;`Insecure=true` 用非 TLS gRPC(内网采集器语义,不额外放开外网)。
- **批处理与性能**:Trace 用 BatchSpanProcessor、Metric 用 PeriodicReader、Log 用 BatchProcessor,均为异步批量导出,热路径开销可控;采样由 `SamplerRatio` 控制(`ParentBased(TraceIDRatioBased)`),对 <=0 做默认兜底 1.0。
## 实现要点(防回归)
- **编译阻断修复**:`main.go` 现引用未定义符号 `tracing.NewOTelProvider`、`global.OTelShutdown`、`global.OTelSetting`(真实名为 `global.OtelSetting`)。需实现前两者、并把 setupOTel 内改回 `global.OtelSetting`,使项目恢复可编译。
- **配置键名对齐 bug**:`config.example.yaml` 使用 `SampleRatio`,而 `setting.OtelSettingS` 字段为 `SamplerRatio`(拼写不一致 → viper 读不到 → 采样率 0 → 不采样)。统一为 `SamplerRatio`(改 yaml 键),并在代码对 ratio<=0 兜底 1.0。同时 `configs/config.yaml`(实际运行配置)若存在同样键名需一并对齐。
- **init 顺序**:需保证 setupOTel 在 setupSlogLogger 之前完成 LoggerProvider 构建,slog 扇出才能拿到 bridge;或 setupSlogLogger 在 provider 就绪后重建默认 logger。
- **错误处理**:setupOTel 失败时告警不 panic(保持服务可启动),并降级 no-op。
- **优雅关闭**:`main()` 当前直接 `ListenAndServe`,需改为监听 SIGINT/SIGTERM,先 `http.Server.Shutdown(ctx)` 再 `global.OTelShutdown(ctx)`,设置合理超时(如 5s)避免卡死。
- 复用现有 `internal/middleware/otel.go`(otelgin)与 `pkg/tracing/otel.go`(OtelContextHandler),不重复造轮子。
## 架构与数据流
```mermaid
flowchart TD
A[HTTP 请求] --> B[otelgin 中间件 server span]
B --> C[handler/service/dao<br/>global.Tracer.Start 子 span]
C --> D[slog 扇出 Handler]
D --> E[本地 JSON 文件+stdout<br/>OtelContextHandler 注入 trace_id]
D --> F[otelslog bridge]
C --> G[TracerProvider<br/>BatchSpanProcessor]
C --> H[MeterProvider<br/>PeriodicReader + runtime 指标]
F --> I[LoggerProvider<br/>BatchProcessor]
G --> J[(OTLP gRPC Collector<br/>ExporterEndpoint)]
H --> J
I --> J
```
## 目录结构
```
zt-closet-backend/
├── go.mod / go.sum # [MODIFY] go mod tidy 补 otel sdk / sdk-metric / sdk-log / resource+semconv /
│ # exporters/otlp/{otlptracegrpc, otlpmetricgrpc, otlploggrpc} /
│ # contrib/instrumentation/runtime / contrib/bridges/otelslog / log/global
├── global/
│ └── otel.go # [MODIFY] 保留 Tracer;新增 OTelShutdown func(context.Context) error、
│ # Meter metric.Meter(可选自定义指标入口)、LoggerProvider(可选)
├── pkg/
│ ├── setting/section.go # [MODIFY] 若选择改字段则同步;默认优先改 yaml 对齐字段(本方案改 yaml,section 不动)
│ └── tracing/
│ ├── otel.go # [KEEP] OtelContextHandler 保留
│ ├── provider.go # [NEW] NewOTelProvider:构建 Resource + Tracer/Meter/Logger Provider +
│ │ # OTLP gRPC 导出 + runtime 指标 + 采样 + Enabled/Insecure 开关 +
│ │ # no-op 降级 + 聚合 shutdown
│ └── fanout.go # [NEW] fanoutHandler:极简 slog.Handler,将日志同时发给本地 handler 与 otelslog bridge
├── internal/middleware/
│ └── otel.go # [KEEP] otelgin 中间件,无需改
├── configs/
│ ├── config.example.yaml # [MODIFY] OTel 段键名对齐 SamplerRatio,校正注释
│ └── config.yaml # [MODIFY] 若存在同名键,一并对齐 SamplerRatio(运行时实际读取)
└── main.go # [MODIFY] 修正 setupOTel 符号与错误降级;调整 init 顺序;
# setupSlogLogger 使用扇出 handler;main() 加信号监听 + 优雅关闭
```
## 关键代码结构(接口约定)
```
// pkg/tracing/provider.go —— 统一初始化入口
// 返回:全局 Tracer、聚合 Shutdown(flush 三个 Provider)、错误
// Enabled=false 或初始化失败时返回 no-op Tracer + 安全空 Shutdown
func NewOTelProvider(cfg *setting.OtelSettingS) (trace.Tracer, func(context.Context) error, error)
```
@@ -0,0 +1,75 @@
---
name: README 补充业务流程章节
overview: 在 README 中新增"业务流程"章节,以产品级业务视角梳理端到端业务闭环(用户旅程、衣物生命周期、穿搭与社区、衣柜分享、交易与权益、AI 推荐、通知触发),用 mermaid 流程图 + 状态机 + 业务规则描述,与已有的"核心业务调用流程"(接口时序视角)互补不重复。
todos:
- id: explore-business-rules
content: 使用 [subagent:code-explorer] 读取 service 层与常量定义,产出 7 个业务域的规则事实清单
status: completed
- id: write-flow-overview-lifecycle
content: 在 README"配置说明"后插入"## 业务流程"章节:总览图、衣物生命周期、穿搭与社区、衣柜分享四节
status: completed
dependencies:
- explore-business-rules
- id: write-flow-trade-ai-notify
content: 继续撰写交易与权益、AI 推荐、通知触发规则三节,并与"核心业务调用流程"互链
status: completed
dependencies:
- write-flow-overview-lifecycle
- id: proofread-business-flows
content: 全文校对:枚举与周期和代码一致、mermaid 语法与层级正确、既有章节未被破坏
status: completed
dependencies:
- write-flow-trade-ai-notify
---
## 用户需求
在已完成接口调用逻辑文档的基础上,进一步把"业务流程"写入 README.md,让读者能从**业务视角**理解系统:各业务域如何闭环流转、有哪些状态机与业务规则、关键动作如何触发下游变化。
## 内容要求
- 新增独立二级章节"## 业务流程",放在"配置说明"之后、"API 概览"之前(先懂业务、再懂接口的阅读顺序)。
- 与已有"核心业务调用流程"(接口时序视角,5 条 sequenceDiagram)明确分工:新章节讲"业务上发生了什么"(状态机、业务规则、mermaid flowchart),已有章节讲"接口怎么调";两章互相引用、不重复内容。
- 覆盖 7 个业务域:用户旅程总览、衣物生命周期、穿搭与社区、衣柜分享、交易与权益、AI 推荐、通知触发规则。
- 所有枚举值、周期、数值必须以代码为准,不得臆测;中文撰写,风格与现有 README 一致;其他章节保持不变。
## 交付物
仅修改 /root/zt-closet-backend/README.md。
## 撰写方案
### 事实来源
- 业务规则事实以 internal/service/ 下 16 个 service 文件为准(用户注册额度初始化、衣物上传扣额/回退、穿搭公开审核、衣柜分享规则、订单支付/过期/退款链路、权益补偿任务、AI 推荐扣额与转正、通知触发点、运势生成调度)。
- 枚举与常量以 internal/service/constants.go、pkg/errcode、global/setting.go 为准;已确认的枚举清单(衣物处理状态 1-5、穿搭审核 1-3、分享状态 1-4、订单状态 1-5、退款状态 0-3、额度类型 1-4、流水变更类型 1-6、商品类型 1-5、通知类型 1-3)直接复用。
- 与既有章节一致性:插入位置锚定"## API 概览"标题前;新章节末尾与"核心业务调用流程"互链引用。
### 章节结构(## 业务流程 下的 7 个三级小节)
1. **业务流程总览**:一张 flowchart 描绘端到端用户旅程(登录注册 → 上传衣物 → AI 识别入库 → 衣橱管理 → 创建穿搭 / AI 推荐 → 社区公开与互动 → 额度不足购买商品 → 权益到账)。
2. **衣物生命周期**:上传校验(扩展名/magic bytes/大小)→ 扣上传额度 → 排队(3) → 抠图(1) → 分类(2) → 成功(4)/失败(5) 状态机 → 用户修正 15 维属性 → 删除回退额度;配状态机图 + 编号步骤。
3. **穿搭与社区**:创建穿搭(1~10 件衣物)→ 公开(is_public=2 进入社区)→ 审核状态机(1 待审核/2 通过/3 不通过)→ 通过后可见 → 其他用户点赞/收藏 → 触发点赞通知。
4. **衣柜分享**:部分衣物分享 / 全量快照分享 → 生成分享链接 → 访客查看分享详情 → 取消 / 到期自动过期状态机(1 正常/2 已取消/3 已过期/4 已封禁)。
5. **交易与权益**:选购商品(5 类)→ 创建订单(15 分钟有效期)→ 微信支付回调 → 发放权益(会员/额度)→ 订单超时过期(后台每分钟扫描)→ 退款与额度回收 → 发放失败自动补偿(benefit_compensation 任务)。
6. **AI 推荐**:扣减 AI 额度 → 大模型结合星座运势 + 衣橱衣物生成 → 落库"待用户保存"(status=3) → 用户确认保存转正式穿搭;额度不足(80000/90401)与生成失败的处理。
7. **通知触发规则**:点赞通知(点赞时发给被赞用户)、订单通知(支付成功/退款等状态变化时)、系统通知的触发源;未读(1) → 已读(2) 流转与未读数统计。
### 图表规范
- 每节至少一张 ```mermaid 代码块(flowchart 或 stateDiagram),节点文案使用枚举原值(如"4 成功"),避免与代码不一致的表述。
- 编号步骤紧随图表之后,每条流程 3~6 步;关键业务规则(额度扣减/回退、幂等、审核、补偿)以加粗或列表强调。
- 篇幅控制:总览图一张紧凑流程图;各节图表节点数控制在 10 个以内;复用既有枚举表,不在新章节重复大表格。
### 防回归
- 插入位置唯一锚点:`## API 概览`;不改动其他章节内容。
- 新增章节内标题层级为 `###`,不破坏既有目录结构;完成后用标题搜索与 mermaid 语法检查验证。
## 推荐扩展
### SubAgent
- **code-explorer**
- 用途:读取 internal/service/ 下 16 个 service 文件与 constants.go、errcode、setting,提取业务流程撰写所需的业务规则事实(额度初始化与扣减回退时机、穿搭审核状态机、分享过期判定、订单与权益补偿链路、通知触发点、运势调度等)。
- 预期结果:输出一份按业务域组织的规则事实清单(含具体常量数值、周期、触发条件、状态流转),作为撰写"业务流程"章节的唯一事实依据,确保不臆测。
@@ -0,0 +1,153 @@
---
name: api-v1层完整实现
overview: 实现 internal/routers/api/v1 全部 HTTP handler 并在 router.go 完成中间件与路由接线,调用已完成的 service 层;同时修复阻断运行的中间件/启动初始化问题,形成可跑通的 RESTful 接口。
todos:
- id: fix-infra
content: 修复 middleware(requestid uuid 导入、JWTAuth 封禁判断反转与缓存回落 DB)、main.go 初始化 global.Tracer/CosClient 与运势任务、dto 列表字段补 form tag
status: completed
- id: handlers-user-basic
content: 实现 user/quota/horoscope/goods 四个 handler(登录/刷新/信息/额度/运势/商品)
status: completed
dependencies:
- fix-infra
- id: handlers-clothes-outfit
content: 实现 clothes(含 multipart 上传)与 outfit 两个 handler,覆盖增删改查与公开
status: completed
dependencies:
- fix-infra
- id: handlers-community
content: 实现 closet/collect/like/notification 四个 handler(分享、收藏、点赞、通知)
status: completed
dependencies:
- fix-infra
- id: handlers-order-ai
content: 实现 order(含微信支付/退款回调按规范返回)与 airecommend 两个 handler
status: completed
dependencies:
- fix-infra
- id: wire-router
content: 在 router.go 接线中间件链与 /api/v1 公开/鉴权路由,添加 /ping,go build 与 go vet 全量验证
status: completed
dependencies:
- handlers-user-basic
- handlers-clothes-outfit
- handlers-community
- handlers-order-ai
---
## 产品概述
为"衣柜/穿搭"微信小程序后端补齐 **HTTP 接口层(api/v1)**:将已完成的 service 业务逻辑通过 Gin handler 暴露为 RESTful 接口,并完成路由与中间件接线,形成可跑通的完整后端服务。
## 核心功能
- **用户与登录**:微信登录、刷新 Token、获取/修改个人信息
- **衣物管理**:上传(含图片处理)、状态查询、详情、修改、删除、列表
- **穿搭管理**:创建、详情、修改、删除、列表、公开设置
- **衣柜分享**:创建、详情(访客/属主)、我的分享列表、取消
- **社区互动**:点赞/取消、点赞状态与计数、收藏/取消、收藏列表
- **消息通知**:列表、详情、标记已读、未读数
- **商品与订单**:商品查询/列表、下单、订单列表、取消、退款申请、微信支付/退款回调
- **额度与运势**:额度概览与流水、今日运势
- **AI 推荐**:发起 AI 穿搭推荐、保存到我的搭配
## 接口约定
- 统一前缀 `/api/v1`;成功返回 `{code:0,msg,data}`,失败按业务错误码返回。
- 登录、刷新 Token、商品查询、微信回调为公开接口;其余需登录鉴权。
- 微信支付/退款回调按微信规范返回受理结果。
## 技术栈
- 语言/框架:Go 1.27 + Gin(沿用现有项目),复用 `pkg/app`(统一响应)、`pkg/errcode`(业务错误码)、`internal/middleware`(JWT/CORS/日志/追踪/RequestID)。
- 依赖注入:handler 结构体持有对应 `internal/service`,构造函数注入,与 service/dao 现有风格一致。
## 实现思路
新增 `internal/routers/api/v1` 下按业务域拆分的 handler,并在 `router.go` 完成中间件与路由接线。handler 只负责:请求绑定与校验、path param 覆盖标识字段、从 context 取 userID、调用 service、统一响应转换;业务逻辑与归属校验(AuthZ)保持在 service 层,handler 不重复实现。
统一 handler 模式:
1. `resp := app.NewResponse(c)`
2. 绑定:JSON 用 `ShouldBindJSON`,GET query 用 `ShouldBindQuery`,失败返回 `errcode.InvalidParams.WithDetails(err.Error())`
3. REST 路径标识(uuid/code/orderNo 等)用 `c.Param(...)` 覆盖 req 对应字段
4. `middleware.GetUserID(c.Request.Context())` 取 userID
5. 调 service,错误 `resp.ToErrorResponse(e)`,成功 `resp.ToResponse(out)`
### 关键技术决策
- **同层静态路由与参数路由共存**:Gin v1.12 支持 `/notification/unread` 与 `/notification/:uuid`、`/order/refund` 与 `/order/:orderNo/cancel` 共存,无需拆分前缀,保持 URL 语义清晰。
- **微信回调独立处理**:回调不走 JWT,handler 读 `c.GetRawData()` 原始报文传 service(service 已做 V3 验签/GCM 解密),按微信规范返回 `{"code":"SUCCESS"}`(200)或 `{"code":"FAIL","message":...}`(非 200),避免微信重复回调。
- **文件上传**:`/clothes/upload` 用 `c.FormFile("file")` 取 `*multipart.FileHeader` 传 service。
## 实现要点(防回归)
本次需一并修复阻断 api 层编译/运行的既有问题,否则接口无法跑通:
- **middleware/requestid.go**:修复非法导入 `import "uuid"` → `github.com/google/uuid`,调用 `uuid.New().String()`(当前该文件导致 middleware 包无法编译)。
- **middleware/lzcwyjwt.go**:
- 修复封禁判断反转:`if userInfo.Status == 1` → 应为 `!= 1` 时拒绝(1=正常),避免正常用户被拦、禁用用户放行。
- 缓存未命中(`redis.Nil`)回落 DB(`mysqldao.UserDao.UserGetByID`),成功回填 `redisdao.UserInfoSet`,仍失败再拒绝,避免缓存过期导致合法用户被锁死。
- **main.go 运行期初始化**(当前缺失,直接运行会 nil panic):
- `global.Tracer = otel.Tracer(global.OtelSetting.ServiceName)`(otel 默认 no-op provider 返回合法非 nil tracer,DAO 的 `global.Tracer.Start` 依赖它)。
- `global.CosClient, _ = cos.NewCosClient()`(衣物上传 `upload.FileUploadCos` 依赖)。
- 启动运势兜底:`tokenhub.EnsureTodayFortunes()` 与 `tokenhub.StartFortuneScheduler()`。
- **dto 补 form tag**(GET query 筛选可用的最小调整):`OutfitListRequest.IsPublic/Source`、`GoodsListRequest.GoodsType`、`ClosetShareListRequest.Status` 当前仅 `json` tag,需补 `form` tag 才能 `ShouldBindQuery` 绑定。
- 不打印 token/密钥;绑定错误文本作为 details 返回可接受,不泄露内部实现。
## 架构与数据流
请求 → Gin 中间件链(Recovery → RequestID → OTel → Slog → CORS)→ [公开路由 / JWTAuth 鉴权组] → v1 handler(绑定+取 userID)→ service(业务+AuthZ)→ dao/redis/pkg → 统一响应。
## 目录结构
```
zt-closet-backend/
├── main.go # [MODIFY] 初始化 global.Tracer / global.CosClient;启动运势定时任务与启动兜底
├── internal/
│ ├── dto/
│ │ ├── outfit.go # [MODIFY] OutfitListRequest.IsPublic/Source 补 form tag
│ │ ├── goods.go # [MODIFY] GoodsListRequest.GoodsType 补 form tag
│ │ └── closet.go # [MODIFY] ClosetShareListRequest.Status 补 form tag
│ ├── middleware/
│ │ ├── requestid.go # [MODIFY] 修复 uuid 导入路径
│ │ └── lzcwyjwt.go # [MODIFY] 修复封禁状态判断反转 + 缓存未命中回落 DB 回填
│ └── routers/
│ ├── router.go # [MODIFY] 中间件链接线 + /api/v1 公开与鉴权路由注册 + /ping
│ └── api/v1/
│ ├── user.go # [NEW] UserHandler:WxLogin/RefreshToken/InfoGetBySelf/ProfileUpdate
│ ├── quota.go # [NEW] QuotaHandler:Info/FlowList
│ ├── horoscope.go # [NEW] HoroscopeHandler:Today
│ ├── clothes.go # [NEW] ClothesHandler:Upload/List/Get/Status/Update/Delete
│ ├── outfit.go # [NEW] OutfitHandler:Create/Get/Update/Delete/List/Public
│ ├── closet.go # [NEW] ClosetShareHandler:Create/Detail/List/Cancel
│ ├── collect.go # [NEW] CollectHandler:Collect/List
│ ├── like.go # [NEW] LikeHandler:Like/GetByTarget
│ ├── notification.go # [NEW] NotificationHandler:List/Unread/Get/Read
│ ├── goods.go # [NEW] GoodsHandler:GetByCode/List(公开)
│ ├── order.go # [NEW] OrderHandler:Create/List/Cancel/RefundApply/PayNotify/RefundNotify
│ └── airecommend.go # [NEW] AIRecommendHandler:Create/Save
```
## 路由清单(前缀 /api/v1)
公开:POST /user/wxlogin、POST /user/refresh-token、GET /goods、GET /goods/:code、POST /order/pay/notify、POST /order/refund/notify
鉴权(JWTAuth):
- GET /user/info、PUT /user/profile
- GET /quota/info、GET /quota/flow
- GET /horoscope/today
- POST /clothes/upload、GET /clothes、GET /clothes/:uuid、GET /clothes/:uuid/status、PUT /clothes/:uuid、DELETE /clothes/:uuid
- POST /outfit、GET /outfit、GET /outfit/:uuid、PUT /outfit/:uuid、DELETE /outfit/:uuid、PUT /outfit/:uuid/public
- POST /closet-share、GET /closet-share、GET /closet-share/:uuid、POST /closet-share/:uuid/cancel
- POST /collect、GET /collect
- POST /like、GET /like/:targetUUID
- GET /notification、GET /notification/unread、GET /notification/:uuid、POST /notification/:uuid/read
- POST /order、GET /order、POST /order/:orderNo/cancel、POST /order/refund
- POST /ai-recommend、POST /ai-recommend/save
## SubAgent
- **code-explorer**
- Purpose:实现阶段如需核对个别 service 方法签名或 dto 字段(如回调 body 读取、multipart 字段名、path param 与 dto 字段映射),快速跨文件确认,避免签名臆测。
- Expected outcome:handler 与 service 签名、dto 绑定一次性对齐,减少编译期返工。
@@ -0,0 +1,153 @@
---
name: classification-server-completion
overview: 完成衣物分类服务 Fashion-CLIP 的端到端落地:实现 pkg/classification Go 客户端与配置接线,补齐依赖,启动 8000 分类服务并用 image.png 跑通真实分类测试,完全对标 matting 的完成方式。
todos:
- id: implement-classify-client
content: 新建 pkg/classification/classification.go,实现 Client、Classify、HealthCheck 与 ClassifyResult 结构体
status: completed
- id: wire-classify-config-and-deps
content: 接线 ClassificationModel 配置并修复依赖(main.go、config.yaml、config.example.yaml、requirements.txt)
status: completed
- id: write-classify-test
content: 新建 classification_test.go,用 image.png 断言分类关键字段非空
status: completed
dependencies:
- implement-classify-client
- wire-classify-config-and-deps
- id: run-classify-service-verify
content: 复用 matting venv 启动 8000 分类服务并跑 go test 完成真实验证
status: completed
dependencies:
- implement-classify-client
- wire-classify-config-and-deps
- write-classify-test
---
## 用户需求
参照 matting_server 的完成方式,将衣物分类服务(Fashion-CLIP)也完整落地,使其端到端可运行、可测试。
## 核心内容
- 补齐分类服务的 Go 客户端封装,对接 Python 服务 `POST /classify` 与 `GET /healthz`。
- 完成配置接线:`main.go` 读取 `ClassificationModel` 配置;`config.yaml` 端口改为 8000 并移除结构体不支持的 `retry` 字段;`config.example.yaml` 补示例段。
- 修复 `classification_server/requirements.txt`,补上 FastAPI multipart 表单必需的 `python-multipart` 依赖。
- 编写分类服务端到端测试,用仓库根目录 `image.png` 调用真实服务并断言关键分类字段非空。
- 启动 8000 分类服务(模型已在本地缓存,离线加载),跑通 `go test` 完成真实验证。
## 技术栈
- Go 客户端:复用标准库 `net/http`、`mime/multipart`、`encoding/json`、`context`,对齐 `pkg/matting` 客户端模式。
- Python 服务端:复用已有 `thirdparty/classification_server/main.py`(已实现两级加载,模型 `patrickjohncyh/fashion-clip` 已缓存于 `~/.cache/huggingface`,581MB,`local_files_only=True` 可离线加载)。
- 运行环境:直接复用 `thirdparty/matting_server/.venv`(python3.12,已含 torch/transformers/fastapi/uvicorn/pillow/python-multipart),避免重复安装 torch。
## 实现方案
### Go 客户端设计(新建 `pkg/classification/classification.go`)
- 对齐 `pkg/matting/matting.go` 的轻量客户端模式,不引入第三方 HTTP 库。
- `Client` 持有 `baseURL` 与 `httpClient`(Timeout 建议 120s,CLIP 单次前向 210 个提示词在 CPU 上耗时较长)。
- `NewClient()` 从 `global.ClassificationModelSetting.Host/Port` 拼接地址;`NewClientWithAddr(host string, port int)` 便于测试解耦。
- `Classify(ctx, imageData []byte) (*ClassifyResult, error)`:构造 multipart/form-data(字段名 `file`,与 Python 端 `UploadFile` 对齐),POST 到 `/classify`,将 JSON 响应解析为 `ClassifyResult`;非 200 时返回状态码与响应体摘要(复用截断逻辑,避免打印大 JSON)。
- `HealthCheck(ctx) error`:GET `/healthz`,供测试前置就绪检查。
### 配置接线与依赖修复
- `main.go` 的 `setupSetting()` 仿照 `MattingModel` 增加 `setting.ReadSection("ClassificationModel", &global.ClassificationModelSetting)`,避免配置为 nil 导致 `NewClient` panic。
- `configs/config.yaml`:`ClassificationModel.Port` 由 80 改为 8000;移除 `retry: 3`(`ClassificationModelSettingS` 无此字段)。
- `configs/config.example.yaml`:补充 `ClassificationModel`(Host 127.0.0.1 / Port 8000)示例段。
- `thirdparty/classification_server/requirements.txt`:补 `python-multipart>=0.0.9`,并保留与 matting 一致的注释说明。
### 测试设计(新建 `pkg/classification/classification_test.go`)
- 读取 `../../image.png`(测试文件位于 `pkg/classification/` 下)。
- `NewClientWithAddr("127.0.0.1", 8000)`,先 `HealthCheck` 确认服务就绪(失败则 `t.Skipf` 明确提示),再调用 `Classify`。
- 断言:返回结果非空、`CategoryMain` 非空、`Formality` 与 `VisualImpact` 落在 `1`~`5`;可选记录 `CategorySub`、`ColorMain` 等关键字段便于人工核验。
- context 超时设 180s,覆盖 CPU 推理耗时。
### 服务启动与验证
- 复用 matting venv 启动服务:`thirdparty/matting_server/.venv/bin/python thirdparty/classification_server/main.py`(监听 8000),日志重定向到 `/tmp/classification_server.log`。
- 轮询 `GET /healthz` 直到返回 `{"status":"ok"}`(模型离线加载完成后才就绪)。
- 执行 `go test ./pkg/classification/ -run TestClassify -v -timeout 600s` 完成真实分类验证。
## 实现注意事项
- **性能**:CLIP 单次批量前向(约 210 文本 + 1 图)在 CPU 上约几十秒,客户端 Timeout 与测试 context 需相应放宽;模型已在本地缓存,启动零网络请求。
- **日志**:Python 端已有「命中本地缓存/自动下载」日志;Go 客户端错误信息需截断响应体(如 512 字节),避免打印大 JSON 造成日志噪声。
- **兼容性**:fashion-clip 为标准 `CLIPModel`,非 `trust_remote_code` 自定义代码,不会触发 BiRefNet 在 transformers 5.x 下的兼容问题;CPU 加载保持 fp32,无需额外 dtype 转换。
- **blast radius**:仅新增客户端/测试文件,配置改动最小化;不引入新第三方依赖,不触碰现有 matting 与业务逻辑。
## 目录结构
```
thirdparty/classification_server/
├── requirements.txt # [MODIFY] 补 python-multipart 依赖
pkg/classification/
├── classification.go # [NEW] 分类服务 Go 客户端(Client/NewClient/NewClientWithAddr/Classify/HealthCheck/ClassifyResult)
└── classification_test.go # [NEW] 端到端测试,用 image.png 验证真实分类
main.go # [MODIFY] setupSetting 增加 ClassificationModel 配置读取
configs/config.yaml # [MODIFY] ClassificationModel.Port 改 8000,移除 retry 字段
configs/config.example.yaml # [MODIFY] 补 ClassificationModel 示例段
```
## 关键代码结构
```
type DetailItem struct {
Label string `json:"label"`
Confidence float64 `json:"confidence"`
}
type Details struct {
CategoryMain []DetailItem `json:"category_main"`
CategorySub []DetailItem `json:"category_sub"`
Style []DetailItem `json:"style"`
ColorMain []DetailItem `json:"color_main"`
Thickness []DetailItem `json:"thickness"`
Version []DetailItem `json:"version"`
Material []DetailItem `json:"material"`
Pattern []DetailItem `json:"pattern"`
SleeveType []DetailItem `json:"sleeve_type"`
ClothesType []DetailItem `json:"clothes_type"`
NecklineType []DetailItem `json:"neckline_type"`
Formality []DetailItem `json:"formality"`
Occasions []DetailItem `json:"occasions"`
VisualImpact []DetailItem `json:"visual_impact"`
}
type ClassifyResult struct {
CategoryMain string `json:"category_main"`
CategoryMainConfidence float64 `json:"category_main_confidence"`
CategorySub string `json:"category_sub"`
CategorySubConfidence float64 `json:"category_sub_confidence"`
Style string `json:"style"`
StyleConfidence float64 `json:"style_confidence"`
ColorMain string `json:"color_main"`
ColorMainConfidence float64 `json:"color_main_confidence"`
ColorSub string `json:"color_sub"`
ColorSubConfidence float64 `json:"color_sub_confidence"`
Thickness string `json:"thickness"`
ThicknessConfidence float64 `json:"thickness_confidence"`
Version string `json:"version"`
VersionConfidence float64 `json:"version_confidence"`
Material string `json:"material"`
MaterialConfidence float64 `json:"material_confidence"`
Pattern string `json:"pattern"`
PatternConfidence float64 `json:"pattern_confidence"`
SleeveType string `json:"sleeve_type"`
SleeveTypeConfidence float64 `json:"sleeve_type_confidence"`
ClothesType string `json:"clothes_type"`
ClothesTypeConfidence float64 `json:"clothes_type_confidence"`
NecklineType string `json:"neckline_type"`
NecklineTypeConfidence float64 `json:"neckline_type_confidence"`
Formality string `json:"formality"`
FormalityLabel string `json:"formality_label"`
FormalityConfidence float64 `json:"formality_confidence"`
Occasions string `json:"occasions"`
VisualImpact string `json:"visual_impact"`
VisualImpactLabel string `json:"visual_impact_label"`
VisualImpactConfidence float64 `json:"visual_impact_confidence"`
Details Details `json:"details"`
}
```
@@ -0,0 +1,58 @@
---
name: classification_server_python_run
overview: 为纯 Python 分类服务补齐独立运行配套:新增 requirements.txt 依赖清单,并在 main.py 末尾添加 uvicorn 启动入口,使 `python main.py` 一条命令即可启动,完全不依赖 Go。
todos:
- id: create-requirements
content: 新建 thirdparty/classification_server/requirements.txt 依赖清单并添加 GPU 安装说明注释
status: completed
- id: add-start-entry
content: 在 main.py 末尾追加 uvicorn 启动入口并做语法验证
status: completed
---
## 用户需求
分类服务(Fashion-CLIP 衣物分类)要能完全不依赖 Go 项目、纯 Python 独立运行。用户已确认需要补齐以下运行配套文件:
- 新增 `requirements.txt` 依赖清单
- 在 `main.py` 末尾添加启动入口,使 `python main.py` 一条命令即可启动服务
不要求本地测试脚本和 README。
## 核心功能
- **依赖清单**:声明 torch、transformers、fastapi、uvicorn、pillow 五个依赖及版本下限,并注明 GPU 用户需单独安装 CUDA 版 torch
- **一键启动**:`python main.py` 直接以 0.0.0.0:8000 启动 FastAPI 服务,无需 `uvicorn main:app` 命令
## 技术方案
### 实现方式
1. 新建 `thirdparty/classification_server/requirements.txt`,列出运行所需的 5 个第三方依赖,采用宽松下限版本避免锁死小版本:
- torch>=2.0.0、transformers>=4.30.0、fastapi>=0.100.0、uvicorn>=0.23.0、pillow>=9.5.0
- 文件内添加注释:PyPI 默认安装 CPU 版 torch,GPU 用户需按 PyTorch 官网命令单独安装对应 CUDA 版本
2. 修改 `main.py` 末尾(第 594-596 行 `/healthz` 路由之后)追加启动入口:
```python
if **name** == "**main**":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
```
- **关键点**:Fashion-CLIP 模型在模块 import 时于模块级加载(`model`、`processor` 为模块级变量),因此必须直接传 `app` 对象调用 `uvicorn.run(app, ...)`,不能使用 `"main:app"` 字符串加 reload 的方式,否则会导致模型重复加载
- uvicorn 放在入口内局部 import,保持顶部 import 区原结构不变,改动最小化
### 验证方式
- `python3 -m py_compile main.py` 确认语法无误
- 交付后运行验证:`pip install -r requirements.txt && python main.py`,访问 `http://localhost:8000/healthz` 返回 `{"status": "ok"}`
### 目录结构
```
thirdparty/classification_server/
├── main.py # [MODIFY] 末尾追加 __main__ 启动入口,python main.py 直接启动
└── requirements.txt # [NEW] 依赖清单(torch/transformers/fastapi/uvicorn/pillow + GPU 安装说明注释)
```
@@ -0,0 +1,176 @@
---
name: classify-service-align-clothes-model
overview: 根据 mysqlmodel.Clothes 的 15 个分类维度字段,全面重构 thirdparty/classification_server/main.py 分类服务:所有维度(含已有)的 prompt 大幅扩充至全面覆盖各种情况,补齐 8 个缺失分类维度,响应字段命名与 model 对齐,并将多次模型前向推理合并为单次批量前向以提升性能。
todos:
- id: expand-existing-prompts
content: 扩充已有 6 组 prompt:颜色至 30、子类别至约 60、风格至 25、版型加直筒、材质至 17、图案至 19,全部配详细英文视觉描述
status: completed
- id: add-new-dimension-prompts
content: 新增 7 组维度 prompt(厚度/袖型/衣长/领型/正式度/场合/视觉冲击)、维度适用性映射表与副颜色阈值常量
status: completed
dependencies:
- expand-existing-prompts
- id: implement-batch-forward
content: 实现 _classify_all 单次批量前向:按组 softmax、子类别按大类过滤、副颜色 top-2 阈值提取
status: completed
dependencies:
- add-new-dimension-prompts
- id: rewrite-classify-api
content: 重写 /classify 接口:响应字段对齐 Clothes 模型命名、不适用维度置空、occasions top-N 逗号连接、formality 与 visual_impact 返回数字加标签
status: completed
dependencies:
- implement-batch-forward
- id: preprocess-verify
content: 优化图片预处理(仅透明图合成白底)并运行 Python 语法检查与启动自检验证
status: completed
dependencies:
- rewrite-classify-api
---
## 产品概述
根据 `mysqlmodel.Clothes` 的 15 个分类维度字段,全面优化 Fashion-CLIP 衣物分类服务(`thirdparty/classification_server/main.py`):不仅补齐缺失维度,所有已有维度的 prompt 也要大幅扩充细化,覆盖各种衣物边界情况(多色拼色、稀有颜色、特殊袖型领型、加厚冬装、各类场合等),同时将推理从逐组多次前向合并为单次批量前向,响应字段与模型命名完全对齐。
## 核心功能
- **已有 prompt 全面扩充**:颜色扩至 30+(新增玫红/荧光/墨绿/宝蓝/驼色/砖红等),子类别扩至约 60 个(补针织衫/工装裤/喇叭裤/玛丽珍鞋/马丁靴/发带/戒指/胸针/手表等),风格扩至 25 个(补极简/波西米亚/朋克/嘻哈/森系/法式/日系/韩系等),材质扩至 17 个(补天鹅绒/缎面/网纱/羊羔绒/PU皮/磨毛棉),图案扩至 19 个(补千鸟格/菱格纹/人字纹/拼色块/渐变/豹纹/几何/logo等)
- **补齐 8 个缺失维度**:副颜色(color_sub,颜色 top-2 阈值提取)、厚度(4 档)、袖型(8 档)、衣长类型(5 档)、领型(10 档)、正式程度(1-5)、适用场合(10 档 top-N)、视觉冲击力(1-5)
- **单次批量前向**:约 210 个文本合并为一次模型前向,按组 softmax,推理耗时从 15 次降为 1 次
- **品类适用性控制**:鞋/配饰等品类的不适用维度自动置空,避免不合理标签
- **响应对齐模型**:category_main/category_sub/color_main/color_sub/thickness/version/material/pattern/sleeve_type/clothes_type/neckline_type/formality/occasions/visual_impact,均附 *_confidence,details 保留 Top-5 明细
## 边界说明
- 改动仅限 `thirdparty/classification_server/main.py` 单文件,Go 侧无消费方,字段改名无破坏风险
- 多色衣物通过 color_sub 副颜色机制表达,颜色组内不放"多色"标签避免抢占主色
- 所有 prompt 沿用"中文标签键 + 详细英文视觉描述值"模式,英文描述包含剪裁、长度、材质视觉特征
## 技术栈
- 沿用现有:Python 3 + FastAPI + Transformers(patrickjohncyh/fashion-clip)+ PyTorch,不引入新依赖
## 实现方案
### 核心策略
1. **Prompt 维度全覆盖**:已有 6 组 prompt 扩充 + 新增 7 组维度 prompt,全部收敛为统一 dict 结构
2. **单次批量前向**:新增 `_classify_all`,全部组(约 210 个文本)拼一个 batch 一次推理,输出 logits 按组 start/end 边界切分后分别 softmax
3. **子类别等价合并**:所有大类子类别合并进同一批前向,大类确定后在其组内取 top1——同组内相对分数不变,argmax 与当前两阶段方案数学等价
4. **适用性映射**:`DIMENSION_APPLICABILITY` 表声明各维度适用的大类,不适用直接返回空字符串
5. **副颜色逻辑**:颜色组 top-2,第二置信度达到阈值(top1 的 40% 与绝对值 0.15 取较低者)才输出 color_sub
```mermaid
flowchart LR
A[图片上传] --> B[预处理: 仅透明图合成白底]
B --> C[一次前向: 全部维度约210文本拼batch]
C --> D[按组softmax归一化]
D --> E[大类 top1]
E --> F[子类别: 大类组内 top1]
E --> G[颜色 top1/top2 主色/副色]
E --> H[其余维度 top1 或 top-N]
H --> I[适用性映射: 不适用置空]
F --> J[响应字段对齐 model]
G --> J
I --> J
```
### Prompt 扩充清单(各组完整标签与描述规范)
**CATEGORY_PROMPTS(6 类,保持)**:上装/下装/连衣裙/外套/鞋/配饰。描述要点:明确穿着部位与廓形边界(如连衣裙强调"从肩到腿一件式",配饰强调"非服装的小件")。
**SUBCATEGORY_GROUPS(约 60 个)**
- 上装(10):T恤/衬衫/卫衣/毛衣/针织衫/背心/Polo衫/开衫/吊带衫/打底衫
- 下装(10):牛仔裤/休闲裤/运动裤/短裤/半身裙/阔腿裤/西装裤/鲨鱼裤/工装裤/喇叭裤
- 连衣裙(7):连衣短裙/连衣长裙/吊带裙/衬衫连衣裙/针织连衣裙/雪纺连衣裙/西装连衣裙
- 外套(9):夹克/西装外套/风衣/羽绒服/大衣/马甲/牛仔外套/皮衣/棉服
- 鞋(11):运动鞋/皮鞋/凉鞋/靴子/帆布鞋/高跟鞋/乐福鞋/外穿拖鞋/玛丽珍鞋/洞洞鞋/马丁靴
- 配饰(13):帽子/围巾/腰带/墨镜/项链/手链/包包/耳饰/丝巾/发带/戒指/胸针/手表
描述规范:每条必须包含款式特征词(领型/口袋/闭合方式/鞋面结构等)+ 材质质感词 + 穿着场景词,如针织衫需区分"ribbed knit texture, pullover without buttons"与卫衣的"fleece fabric, hood, kangaroo pocket"。
**COLOR_PROMPTS(30 个)**
原有 20:黑/白/红/绿/黄/粉/紫/灰/棕/橙/卡其/米/杏/焦糖/藏青/天蓝/军绿/酒红/金/银
新增 10:玫红/荧光色/墨绿/宝蓝/浅蓝/粉紫/驼色/砖红/藏蓝/浅灰
描述规范:新增颜色需给出色相 + 明度 + 饱和度描述(如宝蓝"bright vivid royal blue, saturated deep bright blue",浅蓝"light pale blue, soft pastel sky tone");金色/银色强调 metallic 金属光泽;荧光色强调 neon 高饱和度。
**STYLE_PROMPTS(25 个)**
原有 16:休闲/通勤/运动/街头/优雅/甜美/复古/简约/商务正装/轻熟/工装/学院/甜酷/辣妹/度假/新中式
新增 9:极简风/波西米亚风/朋克风/嘻哈风/森系风/法式风/美式复古风/日系风/韩系风
描述规范:每条包含整体气质 + 典型元素(如波西米亚"flowy fabric, fringe, ethnic embroidery, relaxed silhouette";朋克"black leather, studs, ripped details, rebellious look")。
**FIT_PROMPTS(6 个)**:宽松/修身/阔腿/紧身/常规合身/直筒。描述强调廓形与身体关系(hugs body / straight parallel legs / oversized)。
**MATERIAL_PROMPTS(17 个)**
原有 11:牛仔布/针织/皮质/纯棉/羊毛/丝绸/雪纺/麻料/蕾丝/尼龙/灯芯绒
新增 6:天鹅绒/缎面/网纱/羊羔绒/PU皮/磨毛棉
描述规范:强调表面质感特征(如天鹅绒"soft plush velvet surface, rich deep sheen";缎面"smooth glossy satin sheen, silky shine";网纱"sheer mesh tulle";羊羔绒"fluffy fleece";磨毛棉"brushed soft cotton, slightly fuzzy surface"),区分真皮与 PU 皮的光泽差异。
**PATTERN_PROMPTS(19 个)**
原有 10:纯色/条纹/格纹/印花/刺绣/波点/迷彩/扎染/碎花/字母
新增 9:千鸟格/菱格纹/人字纹/拼色块/渐变/豹纹/蕾丝花纹/几何图案/logo图案
描述规范:强调图案构成方式(如千鸟格"houndstooth check, small broken zigzag checks";菱格纹"argyle diamond pattern";人字纹"herringbone zigzag weave";拼色块"color blocking, contrasting solid color panels")。
**新增 THICKNESS_PROMPTS(4 档)**:薄(sheer lightweight summer)/适中(regular medium weight)/厚(thick warm winter)/加厚(heavily padded down, quilted thick insulation)。适用:上装/下装/连衣裙/外套/鞋。
**新增 SLEEVE_TYPE_PROMPTS(8 档)**:无袖/吊带/短袖/中袖/长袖/喇叭袖/泡泡袖/落肩袖。描述强调袖长比例与袖型结构(喇叭袖"flared bell sleeve widening at cuff",泡泡袖"puffed voluminous sleeve with gathered shoulder",落肩袖"dropped shoulder seam, relaxed slouchy sleeve")。适用:上装/连衣裙/外套。
**新增 CLOTHES_TYPE_PROMPTS(5 档)**:短款/常规/中长/长款/超长款。描述以身体参照点界定衣长(短款"ends above waist, cropped";中长"mid-thigh to knee";超长"ankle-length or floor-length, maxi")。适用:上装/下装/连衣裙/外套。
**新增 NECKLINE_PROMPTS(10 档)**:圆领/V领/方领/高领/翻领/立领/一字领/挂脖/连帽/娃娃领。描述强调领口形状与领部结构(方领"square shaped neckline with straight edges",立领"standing band collar, mandarin collar",挂脖"halter neck with straps behind neck",娃娃领"peter pan collar, flat rounded collar")。适用:上装/连衣裙/外套。
**新增 FORMALITY_PROMPTS(5 档)**:标签(非常休闲/休闲/轻正式/商务正式/非常正式)对应等级 1-5,返回 `formality="1"~"5"` 字符串 + `formality_label` 中文标签。描述用场景锚定(如 5"evening gown, tuxedo, ceremonial formal wear";1"sporty gym wear, beach casual")。
**新增 OCCASION_PROMPTS(10 档)**:日常通勤/运动健身/约会聚会/正式商务/居家休闲/度假旅游/户外活动/派对夜店/校园上课/婚礼宴会。取置信度达标的 top-N(如置信度不低于 top1 的 60%)用逗号连接返回 occasions 字符串。
**新增 VISUAL_IMPACT_PROMPTS(5 档)**:标签(低调/平淡/适中/亮眼/高调)对应等级 1-5,返回 `visual_impact="1"~"5"` + `visual_impact_label`。描述锚定视觉特征(如 5"bold neon, glitter, dramatic design";1"plain neutral minimal")。
### 响应结构(对齐 model)
```
{
"category_main": "上装",
"category_sub": "针织衫",
"style": "韩系风",
"color_main": "宝蓝",
"color_sub": "白色",
"thickness": "适中",
"version": "修身",
"material": "针织",
"pattern": "条纹",
"sleeve_type": "长袖",
"clothes_type": "常规",
"neckline_type": "圆领",
"formality": "3",
"formality_label": "轻正式",
"occasions": "日常通勤,约会聚会",
"visual_impact": "3",
"visual_impact_label": "适中",
"details": { "...各组 Top-5 明细,按新字段命名,含置信度..." }
}
```
### 实现要点
- **性能**:前向 7 次 → 1 次(约 210 文本,CLIP 单 batch 可承受,CPU 场景收益最大);避免每组重复 tokenize 开销
- **等价性**:按组 softmax 与逐组调用结果一致;子类别"组内 top1"与两阶段 argmax 完全一致
- **健壮性**:`_classify_all` 用 start/end 边界切片防顺序错乱;大类不在适用性表中时安全回退(维度置空、子类别为空)
- **可维护性**:新增维度只需加 dict 项 + 注册分组,无需改前向逻辑;/healthz 保留;docstring 同步更新
- **预处理**:仅当图片含 alpha 通道(PNG)才合成白底,JPEG 直接转 RGB
## 目录结构
```
thirdparty/
└── classification_server/
└── main.py # [MODIFY] 唯一改动文件
# 1. 扩充 6 组已有 prompt(颜色30/子类别60/风格25/版型6/材质17/图案19)
# 2. 新增 7 组维度 prompt + DIMENSION_APPLICABILITY + 副色阈值常量
# 3. 新增 _classify_all 单次批量前向(替代逐组 _classify_group 循环)
# 4. 重写 /classify 接口:批量前向、副色提取、适用性置空、响应对齐 model
# 5. 优化图片预处理:检测 alpha 后才合成白底
```
@@ -0,0 +1,127 @@
---
name: classify-service-align-clothes-model
overview: 根据 mysqlmodel.Clothes 的 15 个分类维度字段,优化 thirdparty/classification_server/main.py 分类服务:补齐缺失的分类维度(副颜色/厚度/袖型/衣长/领型/正式程度/适用场合/视觉冲击力)、响应字段命名与 model 对齐,并将多次模型前向推理合并为单次批量前向以提升性能。
todos:
- id: add-prompt-groups
content: 在 main.py 新增 7 组缺失维度 prompt 定义、副颜色阈值常量与维度适用性映射表
status: pending
- id: batch-forward
content: 实现 _classify_all 单次批量前向推理,按组 softmax 并完成子类别按大类过滤与副颜色 top-2 提取
status: pending
dependencies:
- add-prompt-groups
- id: rewrite-classify-api
content: 重写 /classify 接口响应,字段对齐 Clothes 模型命名并实现不适用维度置空逻辑
status: pending
dependencies:
- batch-forward
- id: preprocess-and-verify
content: 优化图片预处理(仅透明图合成白底)并运行 python 语法与启动自检验证
status: pending
dependencies:
- rewrite-classify-api
---
## 产品概述
根据 `mysqlmodel.Clothes` 的 15 个分类维度字段,优化 Fashion-CLIP 衣物分类服务(`thirdparty/classification_server/main.py`),使分类输出与模型字段完全对齐、维度补齐、推理性能提升。
## 核心功能
- **字段命名对齐**:响应字段由 type/subcategory/color/fit 改为 category_main/category_sub/color_main/version 等 snake_case 命名,与模型及 DTO 的 json tag 一致,便于 Go 侧直接映射落库
- **补齐 8 个缺失维度**:新增副颜色(color_sub,从颜色 top-2 提取)、厚度(thickness)、袖型(sleeve_type)、衣长类型(clothes_type)、领型(neckline_type)、正式程度(formality,1-5)、适用场合(occasions,多个场合逗号分隔)、视觉冲击力(visual_impact,1-5)
- **单次批量前向推理**:将当前 7 次逐组模型前向合并为 1 次批量前向,按组 softmax 归一化,推理耗时大幅下降
- **品类适用性控制**:按大类判断维度适用性(如鞋/配饰无袖型、领型,置空不输出),避免给出不合理标签
- **图片预处理优化**:仅对含透明通道的图片合成白底,普通 JPEG 直接转 RGB,减少冗余计算
## 边界说明
- 改动仅限 `thirdparty/classification_server/main.py` 单文件
- Go 侧目前无消费方,字段改名无兼容性风险
- Formality/VisualImpact 返回 "1"~"5" 字符串(模型字段为 string 类型)
- 子类别过滤逻辑保持"避免牛仔裤出现在上装里"的原有语义
## 技术栈
- 沿用现有:Python 3 + FastAPI + Transformers(Fashion-CLIP)+ PyTorch
- 不改动模型、不引入新依赖
## 实现方案
### 核心策略
1. **维度补齐**:为 8 个缺失维度新增英文 prompt 组(沿用现有中文标签键 + 英文视觉描述值模式)
2. **单次批量前向**:新增 `_classify_all`,将所有 prompt 组(约 150 个文本)拼成一个 batch 一次推理,输出 logits 后按组切分分别 softmax 归一化
3. **子类别等价合并**:所有大类的子类别合并进同一次前向,大类确定后在其子类别组内取 top1——同组内相对分数不变,argmax 结果与当前两阶段方案数学等价,省掉第二次前向
4. **适用性映射**:用 `DIMENSION_APPLICABILITY` 表声明各维度适用的大类,不适用维度直接返回空字符串
5. **副颜色逻辑**:从颜色组 top-2 中取副色,第二置信度需达到阈值(建议为 top1 的 40% 或绝对值 0.15,取较低者更宽松)才输出,否则置空
```mermaid
flowchart LR
A[图片上传] --> B[预处理: 仅透明图合成白底]
B --> C[一次前向: 全部维度prompt拼batch]
C --> D[按组softmax归一化]
D --> E[大类 top1]
E --> F[子类别: 大类组内 top1]
E --> G[颜色 top1/top2 → 主色/副色]
E --> H[其余维度 top1 或 top-N]
H --> I[适用性映射: 不适用的置空]
F --> J[响应字段对齐 model 命名]
G --> J
I --> J
```
### 新增 Prompt 组设计
- **THICKNESS_PROMPTS**:厚/适中/薄
- **SLEEVE_TYPE_PROMPTS**:无袖/短袖/中袖/长袖(仅上装、连衣裙、外套适用)
- **CLOTHES_TYPE_PROMPTS**:短款/常规/中长/长款(上装、下装、连衣裙、外套适用)
- **NECKLINE_PROMPTS**:圆领/V领/方领/高领/翻领/一字领/连帽(仅上装、连衣裙、外套适用)
- **FORMALITY_PROMPTS**:5 档英文描述,返回 "1"~"5" 字符串 + 中文标签(如 休闲/日常/轻正式/商务/正式)
- **OCCASION_PROMPTS**:日常通勤/运动健身/约会聚会/正式商务/居家休闲/度假旅游/户外活动,取置信度达标的 top-N 用逗号连接
- **VISUAL_IMPACT_PROMPTS**:5 档英文描述,返回 "1"~"5" 字符串 + 中文标签(低调/平淡/适中/亮眼/高调)
### 响应结构(对齐 model)
```
{
"category_main": "上装",
"category_sub": "T恤",
"style": "休闲风",
"color_main": "黑色",
"color_sub": "白色",
"thickness": "适中",
"version": "宽松",
"material": "纯棉",
"pattern": "纯色",
"sleeve_type": "短袖",
"clothes_type": "常规",
"neckline_type": "圆领",
"formality": "2",
"occasions": "日常通勤,居家休闲",
"visual_impact": "3",
"details": { "…各维度 Top-5 明细,按新字段命名…" }
}
```
各维度附带 `*_confidence` 字段;details 保留 Top-5 明细便于调试与后续微调标签。
## 实现要点
- **性能**:前向次数 7 次 → 1 次(文本数约 150,CLIP 单 batch 可承受;CPU 场景收益最大);避免每组一次 processor 调用的重复 tokenize 开销
- **等价性保证**:批量方案下每组的 softmax 与逐组调用结果一致(组内归一化),子类别"组内 top1"与大类过滤后两阶段 argmax 完全一致
- **健壮性**:`_classify_all` 内部用 start/end 边界记录各组切片位置,防止顺序错乱;大类不在映射表中时安全回退(维度置空、子类别为空)
- **兼容性**:保留 `/healthz` 与整体接口形态;docstring 同步更新字段说明
- **可维护性**:prompt 组全部收敛为统一 dict 结构,新增维度只需加 dict 项 + 注册到批量分组,无需新增前向逻辑
## 目录结构
```
thirdparty/
└── classification_server/
└── main.py # [MODIFY] 唯一改动文件
# 1. 新增 7 组 prompt 定义 + 副颜色阈值常量 + 维度适用性映射表
# 2. 新增 _classify_all 批量单次前向函数(替代逐组 _classify_group 循环)
# 3. 重写 /classify 接口:调用批量前向、副颜色提取、适用性置空、响应字段对齐 model
# 4. 优化图片预处理:检测 alpha 通道后才合成白底
```
@@ -0,0 +1,133 @@
---
name: dao-tracing-refactor
overview: 按 UserCreate 的链路追踪模式,为 mysqldao(13 文件) 与 redisdao(10 文件) 中所有 DAO 方法/函数统一补全 tracing span(Start/defer End/RecordError),并保持增删改查list 顺序。
todos:
- id: mysqldao-batch-a
content: 按样板改造 mysqldao 的 user.go 剩余方法、auditlog.go、closet.go、clothes.go、collect.go、goods.go,补充 Span 追踪与错误记录
status: completed
- id: mysqldao-batch-b
content: 改造 mysqldao 的 horoscope.go、like.go、notification.go、order.go、outfit.go、quota.go、vipflowlog.go,处理 RowsAffected 分支并加追踪
status: in_progress
dependencies:
- mysqldao-batch-a
- id: redisdao-batch-a
content: 改造 redisdao 的 auth.go、closet.go、collect.go、horoscope.go、like.go,处理管道 Exec 与 redis.Nil 豁免并加追踪
status: pending
- id: redisdao-batch-b
content: 改造 redisdao 的 lock.go、notification.go、outfit.go、quota.go、user.go,处理 Lua 脚本与 redis.Nil 豁免(user.go 补充 go-redis 导入)
status: pending
dependencies:
- redisdao-batch-a
- id: verify-build
content: 执行 go build ./... 与 go vet ./... 验证编译通过、无新增告警且语义不变
status: pending
dependencies:
- mysqldao-batch-b
- redisdao-batch-b
---
## 产品概述
为 `zt-closet-backend` 的数据访问层(DAO)统一接入 OpenTelemetry 链路追踪能力,使 MySQL 与 Redis 的每个数据操作方法都具备可观测的 Span 追踪和错误记录,便于分布式追踪与线上问题定位。
## 核心功能
- 以 `UserCreate` 方法为改造样板,为 `internal/mysqldao` 下全部 DAO 方法(13 个文件)补充统一的 Span 追踪与错误记录逻辑。
- 为 `internal/redisdao` 下全部包级函数/方法(10 个文件)补充相同风格的 Span 追踪与错误记录逻辑。
- 统一改造模式:方法开头启动 Span(命名 `dao.<方法名>`)→ `defer span.End()` → 执行原有 DB/缓存调用 → 出错时先 `span.RecordError(err)` 再返回。
- 保持各方法原有业务逻辑、入参与返回值语义完全不变,仅新增追踪与错误记录。
- 对 Redis 查询"未命中"(`redis.Nil`)等属于正常业务分支的场景不记录为错误,避免污染追踪链路。
- 维持既有 "增-删-改-查-list" 的方法排列顺序。
## 技术栈
- 语言:Go
- ORM:GORM(`gorm.io/gorm`),已通过 `d.db.WithContext(ctx)` 传递上下文
- 缓存:go-redis v9(`github.com/redis/go-redis/v9`)
- 追踪:OpenTelemetry,全局追踪器 `global.Tracer`(定义于 `global/otel.go` 的 `var Tracer trace.Tracer`)
## 实现方式
基于用户提供的 `UserCreate` 样板,对每个 DAO 方法套用统一的追踪装饰模式。这是纯增量式改造,不改变任何业务语义与函数签名,风险可控、易于批量执行。
### 改造模板(mysqldao,结构体方法)
```
func (d *XxxDao) XxxCreate(ctx context.Context, ...) (...) {
ctx, span := global.Tracer.Start(ctx, "dao.XxxCreate")
defer span.End()
if err := d.db.WithContext(ctx).Create(x).Error; err != nil {
span.RecordError(err)
return err
}
return nil
}
```
- 单行 `return ...Error` 需改写为 `if err := ...; err != nil { span.RecordError(err); return err }` 形式。
- 带 `RowsAffected` 返回值的方法(如 `QuotaIncreaseUsed`、`OrderUpdateStatusByOrderNo`):先判断 `res.Error`,记录后再返回,保留 `RowsAffected > 0` 判断逻辑。
- 查询类方法(`First`/`Find`)出错统一 `span.RecordError(err)` 后返回。
- `ctx` 必须重新赋值(`ctx, span := ...`),确保新 Span 的上下文继续向下游 `WithContext(ctx)` 传递。
### 改造模板(redisdao,多为包级函数)
```
func XxxSet(ctx context.Context, ...) error {
ctx, span := global.Tracer.Start(ctx, "dao.XxxSet")
defer span.End()
if err := global.RedisEngine.Set(ctx, key, val, ttl).Err(); err != nil {
span.RecordError(err)
return err
}
return nil
}
```
- redisdao 方法原多为 `return xxx.Err()` / `return xxx.Result()`,需先捕获 error/result 再记录并返回。
- 事务管道(`TxPipeline` + `Exec`,见 `auth.go`):对 `pipe.Exec(ctx)` 返回的 err 做记录后返回。
- Lua 脚本调用(`lock.go`、`quota.go`):对 `script.Run(...)` 返回结果的 error 做记录后返回。
- **`redis.Nil` 处理策略**:对 Get 类"未命中属正常"的方法(如 `UserInfoGet`、`AuthUserIDByAccess`、`QuotaRemainGet`),仅当 `err != nil && err != redis.Nil` 时才调用 `span.RecordError(err)`,其余错误正常记录;`redis.Nil` 仍原样返回给调用方,语义不变。`quotaDailyValue` 已内部吞掉 `redis.Nil`,无需特殊处理。
### 实现要点
- **保持顺序**:延续既有 "增删改查list" 排列,不调整方法位置。
- **import**:mysqldao 各文件已含 `global`;redisdao 各文件已含 `global`,对需判断 `redis.Nil` 的文件确认已导入 `github.com/redis/go-redis/v9`(`user.go` 当前未导入,需新增)。
- **零行为变更**:不改动 SQL/Redis 命令、条件、返回值与错误传播路径,仅叠加追踪。
- **性能**:Span 创建为轻量操作,DAO 均为单次 I/O,无额外热点开销;不引入 N+1 或重复遍历。
- **安全**:仅记录 error 对象,不写入用户敏感数据(避免 PII 进入追踪属性)。
## 目录结构
```
zt-closet-backend/
├── internal/
│ ├── mysqldao/
│ │ ├── user.go # [MODIFY] UserCreate 已完成;改造 UserUpdateByID/UserGetByID/UserGetByUUID/UserGetByOpenID
│ │ ├── auditlog.go # [MODIFY] ClosetAuditLog* 与 OutfitAuditLog* 全部方法加追踪
│ │ ├── closet.go # [MODIFY] ClosetShare* 与 ClosetShareItem* 全部方法加追踪
│ │ ├── clothes.go # [MODIFY] Clothes* 全部方法加追踪
│ │ ├── collect.go # [MODIFY] 收藏相关 DAO 方法加追踪
│ │ ├── goods.go # [MODIFY] 商品相关 DAO 方法加追踪
│ │ ├── horoscope.go # [MODIFY] 星座相关 DAO 方法加追踪
│ │ ├── like.go # [MODIFY] 点赞相关 DAO 方法加追踪
│ │ ├── notification.go # [MODIFY] 通知相关 DAO 方法加追踪
│ │ ├── order.go # [MODIFY] Order* 与 RefundFlowLog* 方法加追踪(含 RowsAffected 分支)
│ │ ├── outfit.go # [MODIFY] 穿搭相关 DAO 方法加追踪
│ │ ├── quota.go # [MODIFY] Quota*/UserDailyUsage*/QuotaFlowLog* 方法加追踪(含 RowsAffected 分支)
│ │ └── vipflowlog.go # [MODIFY] VIP 流水相关 DAO 方法加追踪
│ └── redisdao/
│ ├── auth.go # [MODIFY] AuthTokenSet/Del(管道 Exec)、AuthUserIDBy*(redis.Nil 豁免)
│ ├── closet.go # [MODIFY] 衣柜缓存相关函数加追踪
│ ├── collect.go # [MODIFY] 收藏缓存相关函数加追踪
│ ├── horoscope.go # [MODIFY] 星座缓存相关函数加追踪
│ ├── like.go # [MODIFY] LikeAdd/Remove/IsMember/Count 加追踪
│ ├── lock.go # [MODIFY] TryLock/Unlock(Lua 脚本)加追踪
│ ├── notification.go # [MODIFY] 通知缓存相关函数加追踪
│ ├── outfit.go # [MODIFY] 穿搭缓存相关函数加追踪
│ ├── quota.go # [MODIFY] Quota 缓存/Lua 脚本相关函数加追踪
│ └── user.go # [MODIFY] UserInfoSet/Del/Get(Get 需 redis.Nil 豁免,新增 go-redis 导入)
├── global/
│ └── otel.go # [无需修改] 提供 global.Tracer
```
@@ -0,0 +1,116 @@
---
name: fix-14-issues-except-8-and-15
overview: 修复代码审查发现的 14 个问题(跳过第 8 项「公开内容自动审核」与第 15 项「CORS 放开」),覆盖:配置加载导致启动崩溃、支付/退款金额校验缺失、点赞收藏越权、CDN 签名弱化、示例配置不一致、删衣不回退额度、穿搭更新清空字段、AI 锁 TTL 过短、收藏列表内存分页、异步 goroutine 无并发上限、单号随机段过短、运势生成无防重、OTel 中间件 nil 防御。
todos:
- id: config-upload-load
content: 修复配置加载:main.go 补 Upload 段读取、config.example.yaml 修正字段并补 Cos 段、otel.go 加 nil 兜底
status: completed
- id: order-pay-security
content: order.go 增加支付/退款回调金额校验,并加长订单号/退款单号随机段
status: completed
- id: interact-visibility
content: 新增穿搭可见性辅助函数,修复点赞/收藏越权校验
status: completed
- id: upload-clothes-fix
content: upload.go 升级 https+HMAC-SHA256 签名;clothes.go 删除回退额度并加异步并发限流
status: completed
- id: outfit-airecommend-fix
content: outfit.go 更新防清空字段;airecommend.go 锁 TTL 提高到 180s
status: completed
- id: collect-pagination
content: 收藏列表改 DB 分页:新增 DAO 分页方法并改造 collect.go List
status: completed
- id: fortune-dedupe
content: 运势兜底生成加防重(进程内标记 + Redis SetNX)
status: completed
- id: build-vet-verify
content: 运行 go build ./... 与 go vet ./... 验证全部修复
status: completed
dependencies:
- config-upload-load
- order-pay-security
- interact-visibility
- upload-clothes-fix
- outfit-airecommend-fix
- collect-pagination
- fortune-dedupe
---
## 修复范围
基于前序代码审查发现的 14 项问题,按用户指令修复除第 8 项(公开内容自动审核)与第 15 项(CORS 放开)之外的全部问题。
## 核心修复项
1. **P0 启动崩溃**:`main.go` 的 `setupSetting()` 漏加载 `Upload` 配置段,导致 `global.UploadSetting` 为 nil、启动即 panic。
2. **P1 安全**:
- 微信支付回调 `PayNotify` 未校验回调金额是否等于订单应付金额;
- 退款回调 `RefundNotify` 未校验回调退款金额是否等于流水退款金额;
- 点赞/收藏未校验目标穿搭可见性(非本人可操作私有穿搭);
- CDN 图片 URL 使用 md5 签名 + http 明文传输;
- `config.example.yaml` 字段名与代码不一致且缺 `Cos` 段。
3. **P2 业务一致性**:
- 删除衣物不回退上传额度;
- 穿搭更新会无条件用空串清空字段;
- AI 推荐防并发锁 TTL 短于下游 120s 超时;
- 收藏列表全量加载内存分页导致分页口径错乱;
- 异步抠图 goroutine 无并发上限。
4. **P3 健壮性**:
- 订单号/退款单号随机段过短;
- 运势兜底生成无防重;
- OTel 中间件直接解引用 `global.OtelSetting` 缺 nil 防御。
## 技术方案
### 总体策略
沿用现有分层架构(`main` → `routers` → `internal/service` → `internal/mysqldao` / `internal/redisdao` → `pkg/*`),复用已有的 `consumeQuota`/`refundQuota`、`redisdao.TryLock`/`Unlock`、`setIfNotEmpty` 等封装,不做架构变更,以最小侵入方式修复。
### 关键决策
**1. 配置修复(P0-1 / P1-6 / P3-16)**
- 在 `main.go` 的 `setupSetting()` 中,于 `ReadSection("Cos", ...)` 后补 `setting.ReadSection("Upload", &global.UploadSetting)`,保留 `MaxSizeMB<=0` 兜底为 20。
- `config.example.yaml` 的 `Upload.SavePath` 改为 `TmpSavePath`,并补 `Cos` 占位段(字段对齐 `CosSettingS`)。
- `middleware/otel.go` 的 `GinOTelMiddleware` 增加 nil/空判断,fallback 服务名为 `zt-closet-backend`。
**2. 支付安全(P1-2 / P1-3 / P3-13)**
- `PayNotify`:在幂等锁内改为「先 `OrderGetByOrderNo` 查单 → 校验 `data.Amount.Total == order.PayAmount`(单位均为分)→ 再 `OrderUpdateStatusByOrderNo` 条件流转 → 发放权益」。金额不符仅记 error 日志并 return nil,不流转、不发放。
- `RefundNotify`:拿到 `flow` 后校验 `data.Amount.Refund == flow.RefundAmount`,不符告警 return nil。
- `genOrderNo`/`genRefundNo` 随机段由 `[:8]` 加长为 `[:16]`(结果 32 字符,未超微信单号上限)。
**3. 越权修复(P1-4)**
- 在 `service` 包新增共享辅助函数 `isOutfitPublicVisible(o *mysqlmodel.Outfit) bool`(语义同 `outfit.go` `GetByUUID`:`IsPublic==OutfitPublic && AuditStatus==OutfitAuditPass && Status==OutfitStatusNormal`)。
- `like.go` 的 `getPublicOutfit` 增加 `userID` 入参,非本人且不可见时返回 `errcode.OutfitNoPermission`;同步更新 `LikeCreate`、`GetByTargetID` 两处调用点。
- `collect.go` 的 `Collect` 对非本人校验可见性。
**4. 图片链路与额度(P1-5 / P2-7 / P2-12)**
- `upload.GetCosUrl`:`http` → `https`;签名由 `md5(secret+"/"+file+ts)` 升级为 `HMAC-SHA256(secret, "/"+file+ts)`,保持 `?sign=xxx&t=ts`。风险:CDN 边缘鉴权算法需同步升级为 HMAC-SHA256,否则图片 403。
- `clothes.DeleteByUUID`:删除成功后,仅当 `clothes.ImageProcessStatus == ImageProcessSuccess` 时回退 1 个 COS 额度(`refundQuota`),避免与 `markProcessFailed` 重复回退。
- `clothes.go` 增加包级信号量 `imageProcessSem = make(chan struct{}, 10)`,在 `processImageAsync` 入口获取、`defer` 释放,限制异步并发。
**5. 更新与并发(P2-9 / P2-10)**
- `outfit.UpdateByUUID`:改用 `setIfNotEmpty` 仅写非空字段;`updates` 为空时跳过 update(明细重建逻辑保持不变)。
- `airecommend.Create` 锁 TTL 由 60s 提高到 180s(大于 tokenhub 客户端 120s 超时,留余量)。
**6. 收藏分页(P2-11)**
- `mysqldao/collect.go` 新增 `CollectListTargetIDsByUserPage(ctx, collectType, userID, page, pageSize) ([]int64, int64, error)`,用 `Order("id DESC")` + `Count` + `Offset/Limit`。
- `service/collect.go` 的 `List` 改用该分页方法,`total` 取 DAO 计数,消除内存分页与口径错乱。
**7. 运势防重(P3-14)**
- `tokenhub/scheduler.go` 的 `EnsureTodayFortunes` 与 `runFortuneGeneration` 共用「当日」防重:进程内用 `sync.Mutex`/atomic 标记 + 日期判断,跨实例用 `global.RedisEngine.SetNX`(key `ztcloset:lock:fortune:daily:<YYYYMMDD>`,TTL 5 分钟),避免 pkg→internal 反向依赖。
### 验证方式
- `go build ./...` 与 `go vet ./...` 通过;重点回归启动加载、支付回调、点赞/收藏可见性、收藏分页。
@@ -0,0 +1,107 @@
---
name: fix-code-review-bugs
overview: 修复全量代码审查确认的 5 项高/中优先级业务与技术 bug:支付回调与订单过期/取消竞态、grantVIP 并发续费丢失天数、补偿任务 failed 重置后死循环、补偿定时任务分布式锁误删、中间件 Redis 错误误拒服务。
todos:
- id: fix-pay-notify-race
content: 修复支付回调竞态:新增微信 CloseOrder 并在 Cancel 关单、PayNotify 支持过期订单补发货
status: completed
- id: atomic-vip-renewal
content: 新增 UserRenewVIPAtomic 原子续费并改造 grantVIP 防并发丢天数
status: completed
- id: fix-compensation-scheduler
content: 修复补偿任务 failed 重置死循环与定时任务分布式锁误删
status: completed
- id: middleware-redis-fallback
content: 改造 JWTAuth 中间件 UserInfoGet 错误回落到数据库避免误拒
status: completed
---
## 产品概述
对现有后端进行全量代码审查后的 5 项高/中优先级缺陷修复,核心目标是堵住两个资损漏洞(支付回调竞态、VIP 并发续费丢失),并修复补偿任务死循环、分布式锁误删、鉴权中间件可用性三个技术逻辑缺陷。
## 核心功能
1. **支付回调与订单过期/取消竞态修复**:取消订单时同步调用微信关单;支付回调在订单被系统过期后仍能流转为已支付并发放权益,避免用户付款但权益不到账。
2. **VIP 并发续费原子化**:会员续费从「读-改-写」改为单条原子 SQL(CASE 表达式),避免两笔订单并发支付时后写覆盖、丢失续费天数。
3. **补偿任务重试恢复**:失败补偿任务被重置为待发放时同步清零重试次数,避免因重试次数仍超限而陷入「立即置失败」的死循环。
4. **补偿定时任务锁安全**:分布式锁释放改为校验持有者 value 后删除,避免任务执行超时后误删其它实例的锁。
5. **鉴权中间件容错**:Redis 读取用户信息出错时回落到数据库查询,避免 Redis 抖动导致全部鉴权接口被拒绝。
## 非目标(本次不修改)
RefreshToken 轮换未失效旧 access token、微信登录并发可能重复建用户、创建订单落库失败产生孤儿预支付单、空衣橱快照分享仍扣额度、回调报文未截断长度,上述 5 项作为低优先级建议记录,不在本次修复范围内。
## 技术栈
- 语言与框架:Go 1.27、Gin、GORM(gorm.io/gorm v1.31.2)、go-redis、log/slog、OpenTelemetry
- 微信支付:官方 `github.com/wechatpay-apiv3/wechatpay-go v0.2.21`(已确认 `jsapi.JsapiApiService.CloseOrder` 与 `CloseOrderRequest` 结构)
- 复用现有约定:Service 返回 `(*dto.X, *errcode.Error)`、DAO 返回底层 error、Redis 锁用 `redisdao.TryLock/Unlock`、日志用 `slog` + `global.Tracer`
## 实现方案
### Bug1 支付回调竞态(资损)
- `pkg/wechat/pay.go` 新增 `CloseOrder(ctx, orderNo string) error`:调用 `jsapi.JsapiApiService{Client: c.client}.CloseOrder(ctx, jsapi.CloseOrderRequest{OutTradeNo: core.String(orderNo), Mchid: core.String(c.mchID)})`,直接返回 err。
- `internal/service/order.go` `Cancel`:本地条件流转 `pending->cancelled` 成功后,`best-effort` 调用 `wechat.CloseOrder`,失败用 `slog.WarnContext` 记录(不阻断用户取消响应)。
- `internal/service/order.go` `PayNotify`:将「写支付信息 + 发放权益 + 发订单通知」抽取为可复用私有方法 `finalizePaidOrder(ctx, order, data)`;在 `pending->paid` 流转返回 false 后重新 `OrderGetByOrderNo` 读取订单:若 `OrderStatusExpired` 则改为 `expired->paid` 流转并调用 `finalizePaidOrder`(用户已付款应发货);若 `OrderStatusCancelled` 则记录 `slog.ErrorContext` 告警(用户主动取消却仍支付,人工介入)。
### Bug2 grantVIP 原子续费(资损)
- `internal/mysqldao/user.go` 新增原子方法 `UserRenewVIPAtomic(ctx, userID int64, now int64, memberDays int64, vipType int8) (int64, error)`,用一条 `UPDATE` + `gorm.Expr` 原子更新,返回 RowsAffected:
- `vip_expire = (CASE WHEN vip_expire > ? THEN vip_expire ELSE ? END) + ?`(参数 now, now, memberDays*86400)
- `vip_type = ?`(vipType)
- `vip_start = CASE WHEN vip_type = 1 OR vip_expire <= ? THEN ? ELSE vip_start END`(参数 now, now)
- `internal/service/order.go` `grantVIP` 改造:先 `UserGetByID` 读旧值用于判定 `changeType/newVIPType/vipStart` 与流水 `OldVIPExpire`,再调用 `UserRenewVIPAtomic`,随后 `UserGetByID` 读回真实 `newExpire` 写 VIP 流水(`OldVIPExpire=旧值`、`NewVIPExpire=读回值`),最后失效用户缓存。核心收益:`vip_expire` 的累加在数据库层原子完成,并发不丢天数。
### Bug3 补偿任务死循环
- `internal/mysqldao/benefit_grant_task.go` `BenefitGrantTaskReset` 的 Updates 增加 `"retry_count": 0`,使 failed 任务重置后真正进入可重试状态。
### Bug4 补偿定时任务锁误删
- `internal/service/benefit_compensation.go` `runBenefitCompensation` 改用 `redisdao.TryLock(ctx, lockKey, lockVal, time.Minute)` + `defer redisdao.Unlock(context.Background(), lockKey, lockVal)`,`lockVal := genUUID()`,复用现有 lua 校验 value 后才 DEL 的 Unlock 语义。
### Bug5 中间件 Redis 错误回落 DB
- `internal/middleware/lzcwyjwt.go` `JWTAuth` 中 `UserInfoGet` 返回任何错误(`redis.Nil` 或其它)都回落到 `mysqldao.NewUserDao().UserGetByID`;仅 DB 查询也失败时才拒绝,并保留「DB 用户不存在 → UnauthorizedTokenError」「状态非正常 → UserDisabled」逻辑。
## 实现注意事项
- SQL 一律值绑定,`gorm.Expr` 仅用于表达式片段、参数通过占位符绑定,禁止字符串拼接 SQL。
- 所有改动保持向后兼容,不引入事务封装或新依赖;`Cancel` 关单与 `finalizePaidOrder` 为增量修改,不影响现有 `RefundNotify` 流程。
- 日志复用现有 `slog`,回调金额校验、订单归属校验保持不变;不打印 token、支付密钥等敏感信息。
- `vip_start`/`vip_type` 的 CASE 更新与 `vip_expire` 在同一 UPDATE 中原子完成,避免字段间短暂不一致;`vip_type` 的续费/开通判定沿用现有 `memberDays >= 365` 规则。
## 目录结构
```
zt-closet-backend/
├── pkg/
│ └── wechat/
│ └── pay.go # [MODIFY] 新增 CloseOrder 关单方法
├── internal/
│ ├── service/
│ │ ├── order.go # [MODIFY] Cancel 关单、PayNotify 过期/取消竞态处理、grantVIP 原子续费、finalizePaidOrder 抽取
│ │ └── benefit_compensation.go # [MODIFY] 定时任务锁改用 TryLock/Unlock
│ ├── mysqldao/
│ │ ├── user.go # [MODIFY] 新增 UserRenewVIPAtomic 原子续费方法
│ │ └── benefit_grant_task.go # [MODIFY] Reset 增加 retry_count 清零
│ └── middleware/
│ └── lzcwyjwt.go # [MODIFY] UserInfoGet 错误回落 DB
```
## 关键代码结构
```
// internal/mysqldao/user.go
// UserRenewVIPAtomic 原子续费会员:vip_expire 使用 CASE 表达式累加避免并发丢失,
// 同时原子维护 vip_type 与 vip_start,返回受影响行数。
func (d *UserDao) UserRenewVIPAtomic(ctx context.Context, userID int64, now int64, memberDays int64, vipType int8) (int64, error)
```
```
// pkg/wechat/pay.go
// CloseOrder 关闭微信侧待支付订单,防止本地取消后用户仍完成支付。
func CloseOrder(ctx context.Context, orderNo string) error
```
@@ -0,0 +1,179 @@
---
name: fix-remaining-issues
overview: 修复代码审查中除「问题2:唯一约束依赖数据库 Schema」外的全部技术/业务漏洞,共 9 项:退款改人工审核、access token 中间件校验、权益发放补偿流水+定时重试、订单超时过期、refundQuota 检查 RowsAffected、上传 magic bytes 校验、LLM prompt 注入防护、回调原始报文持久化、死代码清理。
todos:
- id: refund-manual-review
content: 在 order.go 与 wechat/pay.go 实现退款人工审核并持久化支付/退款回调报文
status: completed
- id: benefit-compensation
content: 新增 benefit_grant_task 表/模型/DAO 与 DDL,改造幂等发放并加补偿定时重试
status: completed
dependencies:
- refund-manual-review
- id: order-expire-scheduler
content: 在 OrderDao 新增批量过期方法并新增订单超时过期定时任务
status: completed
dependencies:
- benefit-compensation
- id: token-session-check
content: 在 lzcwyjwt.go 中间件增加 access token 会话存在性校验
status: completed
- id: quota-refund-rows
content: 修改 QuotaDecreaseUsed 返回影响行数并在 refundQuota 校验,同步检查调用点
status: completed
- id: security-hardening
content: 在 upload/clothes/tokenhub 增加图片 magic bytes 校验与 prompt 注入清洗
status: completed
- id: dead-code-cleanup
content: 删除 redisdao/quota.go 中未使用的 QuotaRemainDecr/QuotaRemainGet/脚本
status: completed
---
## 产品概述
对现有后端(Go + Gin + GORM + Redis)进行 9 项安全与资损漏洞修复,唯一约束依赖数据库 Schema 一项按用户要求保持不变。核心目标:堵住退款白嫖权益的资损漏洞、让权益发放可补偿、让 access token 可被会话撤销,并补齐订单过期、额度回退一致性、上传内容校验、prompt 注入防护、回调审计与死代码清理。
## 核心功能
1. 退款改为人工审核:用户申请退款仅创建待审核流水,不再自动调用微信退款;运营人工退款后由回调更新订单状态。
2. 权益发放补偿:支付成功发放权益失败时写入补偿任务表,后台定时任务幂等重试,超过上限后告警。
3. access token 会话校验:JWTAuth 中间件校验 token 是否仍存在于 Redis 会话,登出/失效后立即拒绝。
4. 订单超时自动过期:后台定时任务将超时未支付订单批量置为已过期。
5. 额度回退一致性:回退配额时校验实际影响行数,无效回退不再误写成功流水。
6. 上传内容校验:读取文件头 magic bytes 校验真实图片类型,防止伪造扩展名绕过。
7. LLM prompt 注入防护:清洗拼入 prompt 的衣物名称等用户可控字段。
8. 回调报文持久化:支付/退款回调原始报文写入订单与退款流水,便于审计对账。
9. 死代码清理:删除未使用的 Redis 配额函数与脚本。
## Tech Stack
- 语言与框架:Go 1.27、Gin、GORM、go-redis、log/slog、OpenTelemetry
- 复用现有架构:Service 层返回 `(*dto.XxxResponse, *errcode.Error)`;DAO 返回底层 error;定时任务沿用 `pkg/tokenhub/scheduler.go` 的 go func + ticker + Redis 分布式锁模式
## Implementation Approach
### 1. 退款人工审核(问题1)
`internal/service/order.go` 的 `RefundApply` 删除 `wechat.Refund` 调用与即时成功分支,仅创建 `RefundFlowLog`(`Status: RefundStatusPending`)并返回。保留 `RefundNotify`/`settleRefundSuccess`,运营在微信后台手动退款后由回调完成订单置为已退款、写 `refund_time/refund_amount`、发通知。`RefundResponse.Status` 已支持 0=待处理,无需改 DTO。
### 2. 回调报文持久化(问题10)
`pkg/wechat/pay.go` 的 `VerifyAndDecryptPayNotify`/`VerifyAndDecryptRefundNotify` 中,`notifyHandler.ParseNotifyRequest` 会消费 `request.Body`,需先 `io.ReadAll(request.Body)` 再用 `io.NopCloser(bytes.NewReader(raw))` 恢复,并把 raw 通过返回结构新增的 `Raw string` 字段带回。`order.go` 的 `PayNotify` 将 raw 写入 `orders.callback_raw`,`RefundNotify` 写入 `refund_flow_logs.callback_raw`(字段已存在,仅需落库)。
### 3. 权益补偿流水 + 定时重试(问题4)
新增 `benefit_grant_task` 表(无 AutoMigrate,需提供 DDL)。唯一索引 `(order_no, benefit_type)` 作为幂等凭证,防止重复发放。
发放改造为幂等流程:
1. 对每个权益,先尝试 INSERT 一条 `status=pending` 的补偿任务记录;
2. INSERT 冲突说明该权益已有记录:查询状态,`success` 跳过,`pending` 交由定时任务,`failed` 重置为 pending;
3. INSERT 成功则执行实际发放(VIP 延长/额度增加),成功后置 `success`,失败保留 `pending` 并写 `last_error`;
4. 定时任务(单实例 Redis 锁)扫描 `pending` 且 `retry_count < max_retry` 的任务,重试前先查 `quota_flow_logs`/`vip_flow_logs` 中是否已有同 `order_no` 的发放流水,已发放则直接置 `success`,未发放才执行发放,从而把「发放成功但未置 success」崩溃窗口的重复发放风险降至最低;
5. 重试失败 `retry_count+1`,超过 `max_retry` 置 `failed` 并记录 Error 告警日志。
### 4. 订单超时过期(问题5)
`internal/mysqldao/order.go` 新增批量条件更新方法(`WHERE order_status=待支付 AND expire_time < now` 置为已过期),条件更新天然幂等、多实例安全;新增定时任务每 1 分钟执行一次,`main.go` 启动。
### 5. access token 会话校验(问题3)
`internal/middleware/lzcwyjwt.go` 的 `JWTAuth` 在 `ParseAccessToken` 成功后,调用 `redisdao.AuthUserIDByAccess`:`redis.Nil` 或 uid 与 claims 不一致 → 拒绝;Redis 其他错误 → warn 日志并放行(与现有 UserInfoGet 降级风格一致)。
### 6. refundQuota 检查影响行数(问题7)
`internal/mysqldao/quota.go` 的 `QuotaDecreaseUsed` 改为返回 `(bool, error)`;`internal/service/service.go` 的 `refundQuota` 在 `RowsAffected==0` 时记录 warn 且不写成功流水;同步检查 `internal/service/clothes.go` 中的调用点。
### 7. 上传 magic bytes 校验(问题8)
`pkg/upload/upload.go` 新增基于文件头字节的真实类型识别(jpeg/png/webp/gif);`internal/service/clothes.go` 的 `Upload` 在已读取的 `imageData` 之后、扣额度之前校验,不匹配返回 `errcode.UploadFileExtNotAllow`。
### 8. LLM prompt 注入防护(问题9)
`pkg/tokenhub/outfit_recommend.go` 新增 `sanitizePromptField` 辅助(截断长度、移除换行与控制字符、过滤可能改变指令语义的字符),应用到 `FormatClothesInfo` 的每个字段输出。
### 9. 死代码清理(问题11)
删除 `internal/redisdao/quota.go` 中未使用的 `QuotaRemainDecr`、`QuotaRemainGet`、`quotaRemainDecrScript`;保留仍在使用的 `quotaDailyIncrScript`。
## Architecture Design
补偿发放幂等状态流转:
```mermaid
flowchart TD
A[支付成功] --> B[逐权益尝试 INSERT 补偿任务]
B -->|INSERT 成功| C[执行发放]
B -->|唯一冲突| D{查询现有状态}
D -->|success| E[跳过]
D -->|pending| F[等待定时任务]
D -->|failed| G[重置 pending 后走重试]
C -->|成功| H[置 success]
C -->|失败| I[保留 pending 写 last_error]
I --> J[定时任务扫描 pending]
J --> K{查发放流水是否已存在}
K -->|已存在| H
K -->|不存在| C
J -->|retry 超限| L[置 failed 并告警]
```
## Directory Structure
```
zt-closet-backend/
├── internal/
│ ├── service/
│ │ ├── order.go # [MODIFY] 退款改人工审核;PayNotify/RefundNotify 写回调 raw;grantOrderBenefits/grantVIP 幂等改造
│ │ ├── benefit_compensation.go # [NEW] 权益补偿定时任务(扫描 pending、幂等重试、超限告警)
│ │ ├── order_expire.go # [NEW] 订单超时过期定时任务(每 1 分钟条件更新)
│ │ └── service.go # [MODIFY] refundQuota 检查 RowsAffected
│ ├── mysqlmodel/
│ │ └── benefit_grant_task.go # [NEW] 补偿任务模型(唯一索引 order_no+benefit_type)
│ ├── mysqldao/
│ │ ├── benefit_grant_task.go # [NEW] 补偿任务 DAO(插入/查询/条件抢占/状态更新)
│ │ ├── order.go # [MODIFY] 新增批量过期方法 OrderExpireByTime
│ │ └── quota.go # [MODIFY] QuotaDecreaseUsed 返回 (bool, error)
│ ├── middleware/
│ │ └── lzcwyjwt.go # [MODIFY] JWTAuth 增加 access token 会话校验
│ └── redisdao/
│ └── quota.go # [MODIFY] 删除未使用的 QuotaRemainDecr/QuotaRemainGet/脚本
├── pkg/
│ ├── wechat/
│ │ └── pay.go # [MODIFY] 验签前捕获 raw 并在返回结构新增 Raw 字段
│ ├── upload/
│ │ └── upload.go # [MODIFY] 新增 magic bytes 真实图片类型校验
│ └── tokenhub/
│ └── outfit_recommend.go # [MODIFY] 新增 sanitizePromptField 并应用到 FormatClothesInfo
├── migrations/
│ └── benefit_grant_task.sql # [NEW] 补偿任务表 DDL(含唯一索引)
└── main.go # [MODIFY] 启动权益补偿与订单过期两个定时任务
```
## Key Code Structures
```
// internal/mysqlmodel/benefit_grant_task.go
type BenefitGrantTask struct {
Model
UserID int64 // 用户ID
OrderNo string // 商户订单号(与 benefit_type 组成唯一索引)
GoodsID int64 // 商品ID
BenefitType int8 // 1=vip 2=cos 3=ai 4=closet_share 5=outfit_share
Amount int64 // 发放数量
Status int8 // 0=pending 1=success 2=failed
RetryCount int // 已重试次数
MaxRetry int // 最大重试次数
LastError string // 最近一次失败原因
}
// internal/mysqldao/quota.go
func (d *QuotaDao) QuotaDecreaseUsed(ctx context.Context, userID int64, quotaType int8, n int64) (bool, error)
```
## Implementation Notes
- 幂等是补偿重试的关键:所有发放先落 `benefit_grant_task` 唯一记录,重试前查 `quota_flow_logs`/`vip_flow_logs`(含 `order_no`)避免重复发放。
- 定时任务必须带 Redis 分布式锁与 ctx 超时,避免多实例重复执行;沿用现有 `slog` + `global.Tracer` 记录日志与 trace。
- 回调 raw 落库注意长度限制(订单/退款流水字段类型),必要时截断;验签失败时不落库。
- SQL 一律值绑定,退款申请与发放不新增越权路径;prompt 清洗与 magic bytes 校验均为输入侧防护,不得引入 panic。
- 无 AutoMigrate,`migrations/benefit_grant_task.sql` 需在部署时手动执行。
@@ -0,0 +1,157 @@
---
name: fix-second-review-bugs
overview: 修复第二轮全量代码审查确认的 8 项问题:RefreshToken 未吊销旧 access token(安全)、fortune 定时任务无锁/锁误删、收藏点赞并发重复、衣物删除额度泄漏、资料无校验、AI 明细落库失败被吞、缓存 key 无 TTL。
todos:
- id: fix-refresh-token-revocation
content: 修复 RefreshToken 未吊销旧 access token:DTO 加字段并在服务层精确删除旧会话
status: completed
- id: fix-fortune-scheduler-lock
content: 统一运势生成分布式锁为 TryLock/Unlock,修复每日无锁与 Ensure 锁误删
status: completed
- id: fix-collect-like-idempotent
content: 新增 collect/like 唯一索引迁移并用 upsert 改造收藏点赞写入
status: completed
- id: fix-clothes-quota-refund
content: 修复衣物删除与处理失败的额度回退闭环,防止泄漏与双重回退
status: completed
- id: fix-profile-validation
content: 为 ProfileUpdate 增加昵称、性别、生日、头像字段校验
status: completed
- id: fix-ai-items-error
content: AI 推荐明细落库失败时回滚穿搭并回退额度
status: completed
- id: fix-cache-ttl
content: 为通知未读数与收藏点赞 Redis 集合增加 TTL
status: completed
---
## 产品概述
对全量代码审查确认的 8 项业务/技术漏洞进行修复,覆盖安全(token 吊销)、并发(定时任务锁、收藏点赞幂等)、业务一致性(额度回退、明细落库)、数据校验与缓存健壮性。所有修复基于当前代码真实状态,不触碰前几轮已完成的修复(支付回调竞态、VIP 原子续费、补偿任务锁、中间件 Redis 回落等)。
## 核心功能
1. **RefreshToken 轮换吊销旧 access token**:刷新令牌时同步删除旧 access token 会话,使旧 token 立即失效,不再等到自然过期。
2. **每日运势生成加分布式锁**:多实例部署时每天 0 点仅一个实例生成运势,且锁释放改为校验持有者,杜绝误删。
3. **收藏/点赞并发幂等**:通过数据库唯一索引 + upsert,消除并发双击导致的重复记录与计数虚高。
4. **衣物额度回退闭环**:统一「失败即回退」,处理中删除也回退额度,并防止异步失败路径重复回退。
5. **用户资料字段校验**:昵称长度、性别枚举、生日范围、头像长度校验,拦截非法输入。
6. **AI 推荐明细落库失败处理**:明细批量创建失败时删除已建穿搭并回退额度,避免空推荐。
7. **缓存 key 增加 TTL**:通知未读数与收藏/点赞集合设置过期时间,避免弱一致长期漂移。
## 非目标(本次不修改)
前几轮已完成修复的支付回调竞态、VIP 并发续费、benefit_compensation 锁、中间件 Redis 回落、权益补偿幂等、订单过期、退款人工审核、图片 magic bytes、prompt 注入清洗等,均保持不动。微信登录并发建用户、订单孤儿预支付单、空衣橱快照仍扣额度、回调报文未截断等历史已知项本次仍不处理。
## 技术栈
- 语言与框架:Go、Gin、GORM(gorm.io/gorm v1.31.2 + gorm.io/gorm/clause)、go-redis、log/slog
- 复用现有约定:Service 返回 `(*dto.X, *errcode.Error)`、DAO 返回底层 error、分布式锁统一 `redisdao.TryLock/Unlock`、SQL 一律 gorm 占位符值绑定、日志用 `slog`
## 实现方案
### H1 RefreshToken 吊销旧 access token
- `internal/dto/user.go` `UserRefreshTokenRequest` 新增可选字段 `AccessToken string json:"access_token"`(不加 `binding:"required"`,向前兼容)。
- `internal/service/user.go` `RefreshToken`:若 `request.AccessToken != ""`,先用 `redisdao.AuthUserIDByAccess` 校验其归属 `uid == claims.UserID`,不匹配返回 `UnauthorizedTokenError`;随后 `AuthTokenDel(ctx, claims.UserID, request.AccessToken, request.RefreshToken)` 精确删除旧 access + refresh 会话。为空时保持现状仅删 refresh(记录 warn)。
### M1/M2 运势生成分布式锁(合并于 scheduler.go)
- `pkg/tokenhub/scheduler.go` 新增辅助函数 `runFortuneGenerationWithLock`:内部生成 `lockKey = "ztcloset:lock:fortune:daily:" + today`、`lockVal = uuid.NewString()`,`redisdao.TryLock(ctx, lockKey, lockVal, 5*time.Minute)` 失败即返回,`defer redisdao.Unlock(context.Background(), lockKey, lockVal)`,再调 `runFortuneGeneration`。
- `StartFortuneScheduler` 每日循环与 `EnsureTodayFortunes` 均改调该带锁辅助函数;删除 `EnsureTodayFortunes` 中现有的 `SetNX + 无条件 Del`。
- 该文件已 import `internal/mysqldao`,按 Go internal 规则可 import `internal/redisdao`;新增 import `github.com/google/uuid`。
### M3 收藏/点赞并发幂等
- 新增 `migrations/collect_like_unique.sql`:`collects` 加 `UNIQUE KEY uk_collect (collect_type, target_id, user_id)`,`likes` 加 `UNIQUE KEY uk_like (like_type, target_id, user_id)`。DDL 前先清理历史重复数据(保留最小 id)。
- `internal/mysqldao/collect.go` 新增 `CollectUpsertOn`:用 `clause.OnConflict{Columns: [collect_type,target_id,user_id], DoUpdates: AssignmentColumns(["status"])}` 的 `Create`。
- `internal/mysqldao/like.go` 新增 `LikeUpsertOn`,语义同上。
- `internal/service/collect.go` `Collect` 的 `ActionDo` 分支与 `internal/service/like.go` `LikeCreate` 的 `ActionDo` 分支改为直接调用 upsert(去掉 read-then-write),`ActionCancel` 保持现有查询后更新(幂等无害)。
### L1 衣物额度回退闭环
- `internal/service/clothes.go`:
- 分类失败分支(原仅置 Failed 不回退)补 `refundQuota`,统一「失败即回退」。
- 新增辅助 `refundIfExists(ctx, userID, clothesUUID, reason)`:先 `ClothesGetByUUID`(软删除自动过滤),仅当衣物仍存在时才 `refundQuota`,已删除则跳过。
- `markProcessFailed` 改用 `refundIfExists` 替代无条件 `refundQuota`,防止「处理中删除已回退 + 异步失败再回退」双重回退。
- `DeleteByUUID` 回退条件由 `== ImageProcessSuccess` 改为 `!= ImageProcessFailed`(成功与排队/抠图/分类中等处理中状态均回退;失败状态因已回退而不回退)。
### L2 ProfileUpdate 字段校验
- `internal/service/user.go` `ProfileUpdate` 增加校验:
- `Username`:trim 后按 rune 长度 ≤ 32,超长返回 `InvalidParams`。
- `Avatar`:长度 ≤ 500。
- `Sex`:非 0 时必须为 `SexMale(1)` 或 `SexFemale(2)`。
- `BirthDay`:必须 `> 0` 且 `<= nowUnix()`。
- 校验失败返回 `errcode.InvalidParams.WithDetails(...)`;新增 import `strings`。
### L3 AI 推荐明细落库失败处理
- `internal/service/airecommend.go` 第 119 行附近:`OutfitItemCreateBatch` 返回 error 时,先 `OutfitDeleteByUUID` 软删除已建穿搭,再 `return nil, fail(errcode.OutfitCreateFailed, "穿搭明细落库失败")`(`fail` 闭包会回退 AI 额度并写失败流水)。
### L4 缓存 key 加 TTL
- `internal/redisdao/notification.go`:新增 `notificationUnreadIncrScript`(INCR 后首次 EXPIRE),`NotificationUnreadIncr` 改用脚本,TTL 常量 `7 * 24 * time.Hour`。
- `internal/redisdao/collect.go` `CollectAdd`:`SADD` 后 `Expire(key, 7*24h)`(best-effort)。
- `internal/redisdao/like.go` `LikeAdd`:同上加 `Expire`。`Remove` 系列不刷新 TTL。
## 实现注意事项
- 安全:M3 的 upsert 依赖唯一索引,DDL 需部署时手动执行;若 `collects/likes` 已存在重复数据,必须先清理再建索引(SQL 中提供 `DELETE` 保留最小 id 的清理语句)。
- 一致性:L1 的核心是「失败即回退 + 删除回退 + 已删除不回退」三段式闭环,`refundIfExists` 是防止双重回退的关键守卫,不可省略。
- 向后兼容:H1 的 `AccessToken` 为可选字段,老客户端不传不报错,仅保留原行为;新客户端传则立即吊销旧 token。
- 影响范围:所有改动为增量修改,不引入事务封装或新依赖;不改动既有的支付/订单/额度/补偿相关已修复逻辑。
- 日志:沿用 `slog`,H1 旧 token 未携带时用 `WarnContext` 提示,不打印 token 明文与密钥。
## 目录结构
```
zt-closet-backend/
├── internal/
│ ├── dto/
│ │ └── user.go # [MODIFY] UserRefreshTokenRequest 新增 AccessToken 字段
│ ├── service/
│ │ ├── user.go # [MODIFY] RefreshToken 吊销旧 access;ProfileUpdate 字段校验
│ │ ├── collect.go # [MODIFY] ActionDo 改 upsert 防并发重复
│ │ ├── like.go # [MODIFY] ActionDo 改 upsert 防并发重复
│ │ ├── clothes.go # [MODIFY] 删除/失败额度回退闭环
│ │ └── airecommend.go # [MODIFY] 明细落库失败回滚处理
│ ├── mysqldao/
│ │ ├── collect.go # [MODIFY] 新增 CollectUpsertOn
│ │ └── like.go # [MODIFY] 新增 LikeUpsertOn
│ └── redisdao/
│ ├── notification.go # [MODIFY] 未读数 key 加 TTL
│ ├── collect.go # [MODIFY] 收藏集合加 TTL
│ └── like.go # [MODIFY] 点赞集合加 TTL
├── pkg/
│ └── tokenhub/
│ └── scheduler.go # [MODIFY] 运势生成统一 TryLock/Unlock 锁
└── migrations/
└── collect_like_unique.sql # [NEW] collects/likes 唯一索引 + 历史重复清理
```
## 关键代码结构
```
// internal/mysqldao/collect.go
// CollectUpsertOn 收藏幂等写入:唯一索引冲突时仅置 status=1,防并发重复插入。
func (d *CollectDao) CollectUpsertOn(ctx context.Context, collect *mysqlmodel.Collect) error
// internal/mysqldao/like.go
// LikeUpsertOn 点赞幂等写入:唯一索引冲突时仅置 status=1,防并发重复插入。
func (d *LikeDao) LikeUpsertOn(ctx context.Context, like *mysqlmodel.Like) error
```
```sql
-- migrations/collect_like_unique.sql
-- 1) 清理历史重复(保留最小 id)
DELETE c1 FROM collects c1 JOIN collects c2
ON c1.collect_type=c2.collect_type AND c1.target_id=c2.target_id AND c1.user_id=c2.user_id
AND c1.id > c2.id;
ALTER TABLE collects ADD UNIQUE KEY uk_collect (collect_type, target_id, user_id);
DELETE l1 FROM likes l1 JOIN likes l2
ON l1.like_type=l2.like_type AND l1.target_id=l2.target_id AND l1.user_id=l2.user_id
AND l1.id > l2.id;
ALTER TABLE likes ADD UNIQUE KEY uk_like (like_type, target_id, user_id);
```
@@ -0,0 +1,99 @@
---
name: matting-client-and-test
overview: 实现 pkg/matting 抠图服务 Go 客户端,补齐配置读取并把端口对齐到 8001,然后在本地安装依赖、下载 BiRefNet_lite 模型并启动服务,用 image.png 跑通真实抠图测试。
todos:
- id: implement-matting-client
content: 实现 pkg/matting 客户端 Matting 与 HealthCheck 方法
status: completed
- id: wire-matting-config
content: 接线配置:main.go 读取 MattingModel,config.yaml 端口改 8001
status: completed
- id: write-matting-test
content: 编写 matting_test.go,读取 image.png 并断言 PNG 结果
status: completed
dependencies:
- implement-matting-client
- id: prepare-matting-service
content: 创建 venv 安装依赖并启动 8001 抠图服务
status: completed
- id: run-real-matting-test
content: 运行 go test 完成 image.png 真实抠图验证
status: completed
dependencies:
- write-matting-test
- prepare-matting-service
---
## 用户需求
完成 Go 后端 `pkg/matting` 抠图客户端封装,并用仓库根目录下的 `image.png` 进行真实抠图测试。
## 已确认的补充约束
- 测试方式:本地启动真实抠图服务跑通(安装 torch 等依赖、下载 BiRefNet_lite 模型、启动 8001 服务、用 image.png 真实抠图验证)。
- 服务端口:将 `config.yaml` 中 `MattingModel.Port` 改为 8001,与 Python 服务默认端口一致。
## 核心功能
- 实现 `pkg/matting` 客户端,封装对 Python 抠图服务 `POST /matting` 的 multipart 上传调用,返回透明底 PNG 字节。
- 提供客户端初始化(从全局配置读取 Host/Port)与可指定地址的构造方式,便于测试解耦。
- 补充主程序配置加载,使 `MattingModel` 配置真正生效。
- 编写测试用例,读取 `image.png` 作为输入,验证返回结果为合法 PNG 图片。
- 在本地搭建并启动 Python 抠图服务,完成端到端真实抠图验证。
## 技术栈
- 语言:Go(复用现有 `net/http`、`mime/multipart`、`context` 标准库)。
- 参考实现:`pkg/tokenhub/client.go`(Client 结构体 + NewClient 从 global 配置读取 + 带 Timeout 的 httpClient)。
- 服务端:已有 `thirdparty/matting_server/main.py`(FastAPI,`POST /matting` multipart 字段名 `file`,返回 `image/png`)。
## 实现方案
### 客户端设计(新建 `pkg/matting/matting.go`)
- 采用与 `tokenhub.Client` 一致的轻量客户端模式,不引入第三方 HTTP 库。
- `Client` 持有 `baseURL` 与 `httpClient`(Timeout 建议 120s,抠图为 CPU/GPU 推理耗时较长)。
- `NewClient()` 从 `global.MattingModelSetting.Host/Port` 拼接 `http://host:port`。
- `NewClientWithAddr(host string, port int)` 便于测试与解耦,避免依赖全局配置初始化。
- `Matting(ctx, imageData []byte) ([]byte, error)`:构造 multipart/form-data,字段名 `file`(与 Python 端 `UploadFile = File(...)` 对齐),发送 POST 到 `/matting`,读取并返回响应体;非 200 时返回带状态码与响应体摘要的错误。
- `HealthCheck(ctx) error`:GET `/healthz`,用于服务启动后确认就绪与测试前置检查。
### 配置接线
- `main.go` 的 `setupSetting()` 补 `setting.ReadSection("MattingModel", &global.MattingModelSetting)`,避免 `global.MattingModelSetting` 为 nil 导致 `NewClient` panic。
- `configs/config.yaml`:`MattingModel.Port` 由 80 改为 8001;移除结构体不支持的 `retry` 字段。
- `configs/config.example.yaml`:补 `MattingModel`(Host 127.0.0.1 / Port 8001)示例段。
### 测试设计(新建 `pkg/matting/matting_test.go`)
- 读取 `../../image.png`(测试文件位于 `pkg/matting/` 下)。
- 使用 `NewClientWithAddr("127.0.0.1", 8001)` 调用 `Matting`。
- 断言返回字节非空、以 PNG 魔数 `\x89PNG` 开头;将结果写入 `t.TempDir()` 下的文件便于人工核验。
- 可选:先调用 `HealthCheck` 确认服务就绪,失败时给出明确提示。
### 环境准备与真实跑通
- 使用独立 venv 隔离 Python 依赖,`pip install -r thirdparty/matting_server/requirements.txt`(CPU 版 torch)。
- 启动 `thirdparty/matting_server/main.py`(监听 8001),等待 BiRefNet_lite 模型下载与加载完成。
- 执行 `go test ./pkg/matting/ -run TestMatting -v` 完成真实抠图验证。
## 关键代码结构
```
type Client struct {
baseURL string
httpClient *http.Client
}
func NewClient() *Client
func NewClientWithAddr(host string, port int) *Client
func (c *Client) Matting(ctx context.Context, imageData []byte) ([]byte, error)
func (c *Client) HealthCheck(ctx context.Context) error
```
## 实现注意事项
- multipart 写入需正确 `Close()` 并设置 `Content-Type: multipart/form-data; boundary=...`。
- 请求与响应体需 `defer resp.Body.Close()`,避免连接泄漏。
- 错误信息应包含 HTTP 状态码与响应体摘要(截断),便于定位模型侧错误,避免打印大体积二进制。
- 不引入与现有项目冲突的新架构或第三方依赖,保持 `pkg` 包风格一致。
@@ -0,0 +1,61 @@
---
name: matting_server_python_run
overview: 与分类服务一致,为抠图服务(BiRefNet)补齐纯 Python 独立运行配套:新增 requirements.txt 依赖清单,并在 main.py 末尾添加 uvicorn 启动入口(端口 8001),使 `python main.py` 一条命令即可启动。
todos:
- id: create-matting-requirements
content: 新建 thirdparty/matting_server/requirements.txt 依赖清单并添加 GPU 安装与模型手动下载说明注释
status: completed
- id: add-matting-start-entry
content: 在 main.py 末尾追加 uvicorn 启动入口(端口 8001)并做语法验证
status: completed
---
## 用户需求
参照刚完成的分类服务改造,将抠图服务(BiRefNet 人像/衣物抠图)同样改造为纯 Python 独立运行,完全不依赖 Go 项目:
- 新增 `requirements.txt` 依赖清单
- 在 `main.py` 末尾添加启动入口,使 `python main.py` 一条命令即可启动服务
不要求本地测试脚本和 README。
## 核心功能
- **依赖清单**:声明 torch、torchvision、transformers、fastapi、uvicorn、pillow 六个依赖及版本下限,并注明 GPU 用户需单独安装 CUDA 版 torch、BiRefNet_lite 模型文件需手动下载到 ./model/ 目录
- **一键启动**:`python main.py` 直接以 0.0.0.0:8001 启动 FastAPI 服务,无需 `uvicorn main:app` 命令
## 技术方案
### 实现方式
1. 新建 `thirdparty/matting_server/requirements.txt`,列出运行所需的 6 个第三方依赖,采用宽松下限版本避免锁死小版本:
- torch>=2.0.0、torchvision>=0.15.0、transformers>=4.44.0、fastapi>=0.100.0、uvicorn>=0.23.0、pillow>=9.5.0
- 与分类服务的差异:多一个 `torchvision`(transforms/ToPILImage 用到);`transformers` 下限为 4.44.0(`AutoModelForImageSegmentation` 是该版本才引入的类,不能照抄分类服务的 4.30.0)
- 文件内注释说明两点:PyPI 默认安装 CPU 版 torch,GPU 用户需按 PyTorch 官网命令单独安装对应 CUDA 版本;模型代码 `local_files_only=True`,需先将 BiRefNet_lite 模型文件手动下载放到 `./model/` 目录
2. 修改 `main.py` 末尾(第 61-64 行 `/healthz` 路由之后)追加启动入口:
```python
if **name** == "**main**":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8001)
```
- **关键点**:BiRefNet 模型在模块 import 时于模块级加载(`model`、`transform` 为模块级变量),必须直接传 `app` 对象调用 `uvicorn.run(app, ...)`,不能使用 `"main:app"` 字符串加 reload 方式,否则会导致模型重复加载
- uvicorn 放在入口内局部 import,保持顶部 import 区原结构不变,改动最小化
- 端口选用 8001,与分类服务的 8000 错开避免冲突(Go 侧目前无抠图服务端口配置,端口可自由选定)
### 验证方式
- `python3 -m py_compile main.py` 确认语法无误,验证后清理 `__pycache__`
- 交付后运行验证:`pip install -r requirements.txt && python main.py`,访问 `http://localhost:8001/healthz` 返回 `{"status": "ok"}`
### 目录结构
```
thirdparty/matting_server/
├── main.py # [MODIFY] 末尾追加 __main__ 启动入口,python main.py 直接启动(端口 8001)
└── requirements.txt # [NEW] 依赖清单(torch/torchvision/transformers/fastapi/uvicorn/pillow + GPU 与模型下载说明注释)
```
@@ -0,0 +1,87 @@
---
name: model-call-retry
overview: 为抠图(Matting)与分类(Classification)两个模型调用增加可配置的自动重试机制,默认最大重试 3 次;配置项分别加到 MattingModel 与 ClassificationModel 配置段下,并在衣物异步处理流程中接入重试逻辑。
todos:
- id: add-retry-config
content: 在 section.go 结构体及 config.yaml、config.example.yaml 中为 MattingModel/ClassificationModel 增加 MaxRetry 配置
status: completed
- id: default-retry-value
content: 在 main.go 读取配置后对两个模型 MaxRetry
status: completed
dependencies:
- add-retry-config
---
## 产品概述
为衣物图片处理流水线中的抠图(Matting)与分类(Classification)两个模型调用增加自动重试能力,降低下游模型服务偶发失败导致的衣物处理失败率。重试次数通过配置文件分别控制,默认最大重试 3 次。
## 核心功能
- 抠图调用失败时自动重试,默认最大重试 3 次(总尝试 4 次)
- 分类调用失败时自动重试,默认最大重试 3 次(总尝试 4 次)
- 重试次数分别配置在 `MattingModel` 与 `ClassificationModel` 两个配置段下,配置缺失或小于等于 0 时兜底为 3
- 全部重试失败后维持现有失败处理:抠图失败回退上传额度并标记失败;分类失败保留已抠图结果(processed_image)并标记失败,便于用户手动补全
- 删除处理中衣物不回退额度的既有防滥用设计保持不变(不做修改)
## 技术栈
- Go 1.27(使用泛型、`log/slog`、`context` + `time.After`)
- 配置:viper(复用现有 `setting.ReadSection` 读取模式)
- 日志:`log/slog`(复用现有 `slog.WarnContext` / `slog.ErrorContext`)
## 实现方案
### 总体策略
复用现有配置读取与兜底模式,在配置结构体、配置文件中增加 `MaxRetry` 字段;在异步处理函数 `processImageAsync` 中新增一个泛型重试辅助函数 `retryCall`,包裹抠图与分类两处调用,实现失败自动重试,失败分支逻辑保持不变。
### 关键设计
1. **配置层(最小改动)**
- `pkg/setting/section.go` 的 `MattingModelSettingS`、`ClassificationModelSettingS` 各新增 `MaxRetry int` 字段。
- `configs/config.yaml`(实际配置)与 `configs/config.example.yaml`(示例配置)的 `MattingModel`、`ClassificationModel` 段各新增 `MaxRetry: 3`。
- `main.go` 在读取 `MattingModel`、`ClassificationModel` 后,对 `MaxRetry <= 0` 兜底为 3,与现有 `MaxSizeMB <= 0` 兜底 20、`Model == ""` 兜底 `hy4` 的模式保持一致。
2. **重试层(泛型辅助函数)**
- 在 `internal/service/clothes.go` 新增 `retryCall[T any]` 泛型函数:循环尝试 `maxRetry+1` 次;每次失败记录 `slog.WarnContext`;重试间隔使用 `select` + `time.After(1s)` + `ctx.Done()`,保证 ctx 到期时提前终止。
- 抠图与分类调用分别用 `retryCall` 包裹,`maxRetry` 分别取 `global.MattingModelSetting.MaxRetry` 与 `global.ClassificationModelSetting.MaxRetry`。
- `fn` 闭包捕获外部 `ctx`,每次调用内部会重建 multipart body,无需额外重置状态。
3. **失败分支保持不变**
- 抠图全部重试失败后仍调用 `markProcessFailed`(标记失败 + 回退上传额度)。
- 分类全部重试失败后仍保留 `processed_image` 并标记 `ImageProcessFailed`(不回退额度,允许用户手动补全)。
### 关键代码结构
```
// retryCall 对 fn 进行最多 maxRetry 次额外重试(总尝试 maxRetry+1 次)。
// 重试间隔 1s;ctx 到期则提前终止并返回 ctx.Err()。
func retryCall[T any](ctx context.Context, maxRetry int, name string, fn func() (T, error)) (T, error)
// 调用示例
processedData, err := retryCall(ctx, global.MattingModelSetting.MaxRetry, "matting", func() ([]byte, error) {
return mattingClient.Matting(ctx, imageData)
})
```
## 实现注意
- **MaxRetry 语义**:失败后的额外重试次数,总尝试次数 = MaxRetry + 1;默认 3 即总尝试 4 次。
- **超时预算**:异步 ctx 总超时为 3 分钟,单次 HTTP 客户端超时为 120s。`retryCall` 通过 `ctx.Done()` 检查避免超时后继续重试;瞬时失败(连接拒绝、偶发非 200)通常快速返回,3 分钟预算足够。
- **并发限流不受影响**:`imageProcessSem` 信号量在 `processImageAsync` 入口已获取,重试发生在同一 goroutine 内,不会放大并发。
- **blast radius 控制**:仅新增配置字段与重试辅助函数,不修改 `DeleteByUUID` 额度回退逻辑;`config.yaml` 中重复的 `UserDefaultQuota` 段为既有问题,本次不处理。
## 目录结构
```
zt-closet-backend/
├── pkg/setting/section.go # [MODIFY] MattingModelSettingS、ClassificationModelSettingS 各新增 MaxRetry int 字段
├── configs/config.yaml # [MODIFY] MattingModel、ClassificationModel 段各新增 MaxRetry: 3
├── configs/config.example.yaml # [MODIFY] MattingModel、ClassificationModel 段各新增 MaxRetry: 3
├── main.go # [MODIFY] 读取 MattingModel、ClassificationModel 后对 MaxRetry<=0 兜底为 3
└── internal/service/clothes.go # [MODIFY] 新增 retryCall 泛型辅助函数,抠图/分类调用接入重试
```
@@ -0,0 +1,84 @@
---
name: models_auto_download_fallback
overview: 为分类服务(Fashion-CLIP)与抠图服务(BiRefNet)统一改造模型加载逻辑:启动时先尝试完全离线加载本地缓存,本地不存在则自动联网下载;同时统一两个服务的 HF 镜像配置。
todos:
- id: matting-two-level-loading
content: 改造抠图服务 main.py(镜像、日志、绝对路径 cache_dir、两级加载)并同步 requirements.txt 注释
status: completed
- id: classification-two-level-loading
content: 改造分类服务 main.py(镜像统一 hf-mirror.com、模型与处理器两级加载)
status: completed
- id: syntax-verify
content: 对两个 main.py 执行 py_compile 语法验证并清理 __pycache__
status: completed
dependencies:
- matting-two-level-loading
- classification-two-level-loading
---
## 产品概述
对两个纯 Python AI 服务(衣物分类服务 Fashion-CLIP、抠图服务 BiRefNet)的模型加载逻辑统一改造:启动时先检查本地缓存,本地已存在则完全离线加载(零网络请求);本地不存在或不完整时自动联网下载模型到本地缓存,下载完成后后续启动均走离线分支。
## 核心功能
- **抠图服务改造**:新增国内镜像与日志模块,模型缓存目录改为绝对路径(消除启动工作目录依赖),模型加载改为「本地存在则离线、不存在则自动下载」两级模式,并同步更新 requirements.txt 注释说明
- **分类服务改造**:镜像地址统一为 hf-mirror.com(与代码注释保持一致),模型与处理器加载改为同一套「本地存在则离线、不存在则自动下载」两级模式
- **运行行为**:首次启动无缓存时自动下载(走国内镜像加速),有缓存后完全离线;日志明确区分「命中本地缓存」与「自动下载」两种路径
## 技术栈
- Python 3 + HuggingFace Transformers(CLIPModel / CLIPProcessor / AutoModelForImageSegmentation)
- 国内镜像 hf-mirror.com(通过 HF_ENDPOINT 环境变量,在 import transformers 之前设置)
- 依赖与 FastAPI 服务结构不变,仅改模型加载段
## 实现方案
### 统一两级加载模式
利用 `local_files_only=True` 的行为特性:本地缓存缺失或不完整时 huggingface_hub 抛出 `OSError`(Python 3 中 `LocalEntryNotFoundError` 是 `OSError` 子类)或 `ValueError`,捕获后回退联网下载。两级使用同一个 cache_dir,HF 缓存结构固定(`models--repo/snapshots/...`),无需手动判断目录结构:
```python
try:
model = XXX.from_pretrained(REPO, ..., local_files_only=True, cache_dir=CACHE_DIR)
logger.info("模型命中本地缓存,离线加载")
except (OSError, ValueError) as e:
logger.info(f"本地未找到模型缓存,自动下载 ... ({e})")
model = XXX.from_pretrained(REPO, ..., cache_dir=CACHE_DIR)
```
### 抠图服务 `thirdparty/matting_server/main.py`
1. 顶部(import transformers 之前)加镜像设置:`if "HF_ENDPOINT" not in os.environ: os.environ["HF_ENDPOINT"] = "https://hf-mirror.com"`,并补 `import os`
2. 新增 `logging.basicConfig(level=logging.INFO)` + `logger = logging.getLogger(__name__)`
3. 模型缓存目录改为绝对路径:`_model_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "model")`,替换原相对路径 `cache_dir="./model"`
4. 模型加载改为两级加载(保持 `trust_remote_code=True`;`model.eval()`、device 判定、CUDA `model.half()` 逻辑保持不变)
5. 同目录 `requirements.txt` 注释同步:模型首次启动自动下载到本目录 `./model/` 缓存,无需手动放置
### 分类服务 `thirdparty/classification_server/main.py`
1. 第 21 行镜像从 `https://hf-api.gitee.com` 改为 `https://hf-mirror.com`(与第 12 行 docstring 一致)
2. 第 450-451 行 model 与 processor 放入同一 try 块两级加载:`local_files_only=True` 先试,任一失败则整体回退联网下载;`model = model.to(device)`、`model.eval()`、CUDA `half()` 保持原位置
### 性能与可靠性
- 有缓存时零网络请求,启动时间不受网络影响;无缓存时仅首次下载(约 400MB,走镜像)
- 缓存不完整(下载中断)时第一级失败自动触发补齐,天然容错
- 日志两条路径可观测,便于运维确认模型来源
## 目录结构
```
thirdparty/
├── classification_server/
│ ├── main.py # [MODIFY] 镜像统一 hf-mirror.com;模型/处理器两级加载
│ └── requirements.txt # 不变
└── matting_server/
├── main.py # [MODIFY] 新增镜像/日志;cache_dir 绝对路径;模型两级加载
└── requirements.txt # [MODIFY] 注释更新为自动下载说明
```
## 验证方式
- 两个文件 `python3 -m py_compile` 语法验证,验证后清理 `__pycache__`
- 交付说明:首次启动自动下载(走 hf-mirror 镜像),有缓存后完全离线;日志区分「命中本地缓存」与「自动下载」
@@ -0,0 +1,108 @@
---
name: mysqlmodel 类型统一与 tag 精简
overview: 将 internal/mysqlmodel 下所有模型统一改造:外键/计数/金额/时间字段类型统一为 int64,删除所有 json tag 与 DDL 类 gorm tag,只保留 column 列名映射,并把 OutfitItem、UserOrder 的标准 gorm.Model 替换为自定义 Model。
todos:
- id: refactor-model-user
content: 改造 model.go 与 user.go,统一 int64 类型与极简 column tag
status: completed
- id: refactor-clothes-closet
content: 改造 clothes.go 与 closet.go,外键 UserID/ShareID/ClothesID 统一 int64
status: completed
- id: refactor-outfit-order
content: 改造 outfit.go 与 order.go,替换 gorm.Model 并统一金额/时间/外键类型
status: completed
- id: refactor-org-goods-ai-horoscope
content: 改造 org.go、goods.go、airecommend.go、horoscope.go 的类型与 tag
status: completed
- id: build-lint-verify
content: 运行 go build 与 read_lints 校验编译与 tag 语法
status: completed
dependencies:
- refactor-model-user
- refactor-clothes-closet
- refactor-outfit-order
- refactor-org-goods-ai-horoscope
---
## 产品概述
对 `internal/mysqlmodel/` 下全部数据库模型进行一次统一规范改造,解决字段类型不一致与 tag 冗余问题,使模型层与「手动建表 + DTO 对外」的架构约定保持一致。
## 核心功能
- 将 `Model.ID`(int64)的外键引用字段统一为 int64,消除当前 uint/string 混用问题
- 将所有 `float64` 金额字段改为 int64(单位:分),避免浮点精度误差
- 将所有 `time.Time` 时间字段改为 int64 Unix 秒时间戳
- 将所有 `uint`/`uint8`/`int` 计数、配额、token 数量字段统一为 int64
- 删除所有 `json` tag(model 不直接对外,由 DTO 层承担 JSON 契约)
- 删除所有 DDL 类 gorm tag(`type`、`comment`、`uniqueIndex`、`index`),仅保留 `column` 列名映射与 `primaryKey`
- 将 `OutfitItem`、`UserOrder` 中误用的标准 `gorm.Model` 替换为项目自定义 `Model`
## 技术栈
- 语言:Go
- ORM:GORM v2
- 软删除:`gorm.io/plugin/soft_delete`
- 改动范围:仅 `internal/mysqlmodel/` 下的 10 个文件(纯模型层,无调用方依赖)
## 实现方案
本次为纯数据模型类型与 tag 规范化改造,不引入新架构。已核实 `internal/dao`、`internal/service`、`internal/dto`、`internal/routers/api/v1` 均为空目录,`routers/router.go` 仅含空 gin 引擎,全项目无任何代码引用这些模型字段,因此改动无编译破坏风险。
### 类型映射规则
| 字段类别 | 原类型 | 目标类型 | 说明 |
| --- | --- | --- | --- |
| 外键/引用 ID | uint / string | int64 | UserID、TargetID、SenderID、RelatedID、ClothesID、GoodsID、ShareID、SavedOutfitID、OutfitID |
| 金额 | float64 | int64 | 单位统一为「分」,前端展示时除以 100 |
| 时间 | time.Time | int64 | Unix 秒时间戳,与 Model.CreatedAt/UpdatedAt 一致 |
| 日期统计 | time.Time | int64 | StatDate 存 `YYYYMMDD` 整数(如 20260829),避免时区偏移 |
| 计数/配额/token | uint / uint8 / int | int64 | UploadCount、MemberDays、PromptTokens、ViewCount、LikeCount 等 |
| 枚举/状态/评分 | int / int8 | 保持不变 | Sex、Status、MsgType、IsRead、CategoryMain、Scene、OrderStatus、评分等 |
### tag 精简规则
- 保留:`gorm:"column:xxx"`(列名映射 + 代码可读)、`gorm:"column:id;primaryKey"`(主键)
- 删除:所有 `json:"..."`、`type`、`comment`、`uniqueIndex`、`index`
- 字段注释保留在 Go 行尾注释中
- `CreatedAt/UpdatedAt` 字段名命中 GORM 约定,无需 `autoCreateTime/autoUpdateTime`,自动填充秒级时间戳
### 关键代码结构
`Model` 基类改造后的最终形态:
```
type Model struct {
ID int64 `gorm:"column:id;primaryKey"` // 主键ID
CreatedAt int64 `gorm:"column:created_at"` // 创建时间
UpdatedAt int64 `gorm:"column:updated_at"` // 更新时间
DeletedAt soft_delete.DeletedAt `gorm:"column:deleted_at"` // 删除时间
CreatedBy int64 `gorm:"column:created_by"` // 创建人
UpdatedBy int64 `gorm:"column:updated_by"` // 更新人
DeletedBy int64 `gorm:"column:deleted_by"` // 删除人
}
```
## 目录结构
```
internal/mysqlmodel/
├── model.go # [MODIFY] Model 基类:删 json tag,保留 column + primaryKey;NewMysqlEngine 函数不动
├── user.go # [MODIFY] User 删 json 补 column、配额 int→int64;UserDailyUsage 与 UserNotification 的 uint/time.Time→int64
├── clothes.go # [MODIFY] Clothes:UserID string→int64(关键:UUID 改内部主键 ID),删 json 补 column
├── closet.go # [MODIFY] ClosetShare:UserID uint→int64、ViewCount/LikeCount int→int64;ClosetShareItem:ShareID string→int64、ClothesID uint→int64
├── outfit.go # [MODIFY] Outfit:UserID uint→int64;OutfitItem:gorm.Model→自定义 Model、ClothesID uint→int64
├── order.go # [MODIFY] UserOrder:gorm.Model→自定义 Model;UserID/GoodsID/MemberDays→int64;5 个金额 float64→int64(分);3 个时间 time.Time→int64
├── org.go # [MODIFY] OrgLike/OrgCollect:TargetID、UserID uint→int64
├── goods.go # [MODIFY] Goods:Price/DiscountPrice float64→int64(分);MemberDays/UploadQuota uint→int64
├── airecommend.go # [MODIFY] AIRecommendLog:UserID/ClothesCount/PromptTokens/CompletionTokens/SavedOutfitID/ConsumeQuota→int64;RequestTime/ResponseTime→int64
└── horoscope.go # [MODIFY] Horoscope:StatDate/GeneratedAt time.Time→int64(StatDate 存 YYYYMMDD)
```
## 实现注意事项
- 金额改「分」后,后续 service/dao 层所有金额计算必须以分为单位,注释中标注清楚
- StatDate 存 `YYYYMMDD` 整数并在注释说明,避免跨时区日期偏移
- redismodel 的 key 常量本次不改,其 `%d` 占位符对 int64 的 user_id 依然适用
- 改造后运行 `go build ./...` 与 `read_lints` 确认无语法错误、tag 格式合法
@@ -0,0 +1,129 @@
---
name: redismodel key 设计对齐 mysqlmodel
overview: 以 internal/mysqlmodel 的实体与字段术语为基准,修复 internal/redismodel 的 Redis key 常量命名、文件组织与 key 字符串,使其语义对齐,并做最小引用同步以保持编译通过。
todos:
- id: refactor-key-basics
content: 新建 keys.go 抽取 RedisPrefix,删除 org.go 并拆分为 like.go 与 collect.go
status: pending
- id: align-quota-keys
content: 修改 quota.go 对齐 mysqlmodel 术语,并补充 lock.go 注释
status: pending
dependencies:
- refactor-key-basics
- id: align-auth-user-keys
content: 修改 auth.go 的 token key 为复数 Set 语义,新增 user.go 定义 KeyUserInfo
status: pending
dependencies:
- refactor-key-basics
- id: sync-redisdao-refs
content: 同步 redisdao 中 quota.go 与 user.go 的常量引用
status: pending
dependencies:
- align-quota-keys
- align-auth-user-keys
- id: build-lint-verify
content: 运行 go build 与 read_lints 校验编译与引用完整性
status: pending
dependencies:
- sync-redisdao-refs
---
## 产品概述
以 `internal/mysqlmodel` 的实体与字段术语为唯一基准,重构 `internal/redismodel` 下的 Redis key 常量设计,使 Redis 命名空间与数据库模型语义保持一致。本次仅修改 key 常量定义,并做 `internal/redisdao` 中常量引用的最小同步,不改动任何业务逻辑实现。
## 核心功能
- 将全局前缀 `RedisPrefix` 从 `auth.go` 抽取到独立文件 `keys.go`
- 删除命名不符的 `org.go`,按 mysqlmodel 的 `like.go`/`collect.go` 拆分为两个文件
- 对齐 quota key 术语:`community` 改为 `outfit_share`(社区分享/搭配分享)、`wardrobe_share` 改为 `closet_share`(衣柜分享)、`upload` 改为 `cos`(cos 上传额度)
- 将 `KeyAuthUserToken` 改为复数 Set 语义 `KeyAuthUserTokens`,支持多端登录一对多 token
- 新增 `KeyUserInfo` 常量,把 `redisdao/user.go` 中绕开统一管理的 `user:info:` 前缀纳入 redismodel 并补上 `ztcloset:` 命名空间
- 同步 `redisdao` 中对被重命名常量的引用,保证编译通过
## 技术栈
- 语言:Go
- 改动核心:`internal/redismodel` 下的 Redis key 常量定义
- 语义基准:`internal/mysqlmodel`(`user.go`、`quota.go`、`like.go`、`collect.go`、`order.go`、`goods.go`、`quotaflowlog.go`)
- 最小同步:`internal/redisdao`(仅更新常量引用,不改函数逻辑与函数名)
## 实现方案
### 总体策略
以 mysqlmodel 的实体命名和字段注释为准,重命名 Redis key 常量和 key 字符串,使两层模型术语一致。重命名导致 key 字符串变化,用户已确认适用于未上线/可清库场景。由于 `redisdao` 直接引用了这些常量,必须同步更新引用,否则无法编译。
### 关键决策
1. **`RedisPrefix` 独立成文件**:从 `auth.go` 移至新建的 `keys.go`,作为全包共享的命名空间前缀,消除其他文件对 `auth.go` 的隐式依赖。
2. **`org.go` 拆分为 `like.go` 与 `collect.go`**:对齐 mysqlmodel 的文件划分,每个文件只包含对应业务的 key 常量,并补充 `like_type:target_id`、`collect_type:target_id` 的参数注释。
3. **quota 术语统一**:以 `UserDailyUsage` 字段和 `Quota.QuotaType` 注释为准,AI 保持不变,`community` 统一为 `outfit_share`,`wardrobe_share` 统一为 `closet_share`,`upload` 统一为 `cos`。
4. **token 一对多语义修正**:`KeyAuthUserToken` 改为 `KeyAuthUserTokens`,key 使用复数 `auth:user:tokens:%d`,注释明确为 `user_id -> token 集合(Set)`,支持多端登录。该常量当前无引用,改名不产生编译影响。
5. **`user:info` 纳入统一管理**:新增 `redismodel/user.go` 定义 `KeyUserInfo`(带 `ztcloset:` 前缀),`redisdao/user.go` 移除本地 `userInfoKeyPrefix` 常量并改用该常量。
6. **锁定 key 保留并补注释**:`lock.go` 的三个常量与 mysqlmodel 的 `Order`、AIRecommend 业务对应,保留不动,仅补充参数与用途注释。
7. **不改 redisdao 函数名**:`QuotaDailyIncrCommunity`、`QuotaDailyIncrShare` 等函数名本次不调整,仅同步函数体内引用的常量名,避免扩大影响面。
## 目录结构
```
internal/redismodel/
├── keys.go # [NEW] 定义全局 RedisPrefix = "ztcloset:"
├── auth.go # [MODIFY] 移除 RedisPrefix;KeyAuthUserToken 改为 KeyAuthUserTokens(Set 语义)
├── quota.go # [MODIFY] community→outfit_share、wardrobe_share→closet_share、upload→cos
├── like.go # [NEW] KeyLikeSet(由 org.go 拆分)
├── collect.go # [NEW] KeyCollectSet(由 org.go 拆分)
├── org.go # [DELETE] 内容已拆分至 like.go/collect.go
├── lock.go # [MODIFY] 补充参数与用途注释(常量不变)
└── user.go # [NEW] KeyUserInfo 缓存 key 常量
internal/redisdao/
├── quota.go # [MODIFY] 同步引用 KeyQuotaDailyOutfitShare / KeyQuotaDailyClosetShare
└── user.go # [MODIFY] 移除本地 userInfoKeyPrefix,改用 redismodel.KeyUserInfo
```
## 关键代码结构
修复后的 `internal/redismodel` key 常量全集:
```
// keys.go
const RedisPrefix = "ztcloset:"
// auth.go
const (
KeyAuthAccess = RedisPrefix + "auth:access:%s" // access_token -> user_id
KeyAuthRefresh = RedisPrefix + "auth:refresh:%s" // refresh_token -> user_id
KeyAuthUserTokens = RedisPrefix + "auth:user:tokens:%d" // user_id -> token 集合(Set,支持多端登录)
)
// quota.go
const (
KeyQuotaDailyAI = RedisPrefix + "quota:daily:ai:%d:%s" // user_id:date,当日AI推荐次数
KeyQuotaDailyOutfitShare = RedisPrefix + "quota:daily:outfit_share:%d:%s" // user_id:date,当日社区分享次数(搭配分享)
KeyQuotaDailyClosetShare = RedisPrefix + "quota:daily:closet_share:%d:%s" // user_id:date,当日衣柜分享次数
KeyQuotaCosTotal = RedisPrefix + "quota:cos:total:%d" // user_id,cos 上传总配额
)
// like.go
const KeyLikeSet = RedisPrefix + "like:set:%d:%d" // like_type:target_id
// collect.go
const KeyCollectSet = RedisPrefix + "collect:set:%d:%d" // collect_type:target_id
// lock.go
const (
KeyLockOrderCreate = RedisPrefix + "lock:order:create:%d" // order_id,下单防重
KeyLockPayCallback = RedisPrefix + "lock:pay:callback:%s" // order_no,支付回调幂等
KeyLockAIRecommend = RedisPrefix + "lock:ai:recommend:%d" // user_id,AI推荐防并发
)
// user.go
const KeyUserInfo = RedisPrefix + "user:info:%d" // user_id,缓存用户鉴权核心信息
```
## 实现注意事项
- 修改后需运行 `go build ./...` 与 `read_lints` 校验,重点确认 `redisdao` 内无遗漏的旧常量引用。
- 严格限定在 key 常量层:不改 `redisdao` 的函数名与业务逻辑,不改 `middleware/lzcwyjwt.go` 的 status 判断,不改 quota 过期/时区实现。
- key 字符串变更属于破坏性变更,依赖可清库/未上线前提(用户已确认)。
@@ -0,0 +1,154 @@
---
name: redismodel 从0全量设计
overview: 以 internal/mysqlmodel 全部实体语义为基准,从 0 重新设计 internal/redismodel 的 Redis key 常量层,覆盖认证、用户、点赞、收藏、配额、锁、星座、通知、分享等全部业务,文件组织与术语对齐 mysqlmodel。
todos:
- id: create-keys-basics
content: 新建 keys.go 定义 RedisPrefix,删除旧 org.go
status: completed
- id: rewrite-auth-user
content: 重写 auth.go(token 复数 Set 语义)并新建 user.go 定义 KeyUserInfo
status: completed
dependencies:
- create-keys-basics
- id: rewrite-quota-like-collect
content: 重写 quota.go(术语对齐与总配额参数化)并新建 like.go、collect.go
status: completed
dependencies:
- create-keys-basics
- id: create-cache-count-keys
content: 新建 closet.go、outfit.go、horoscope.go、notification.go 的缓存与计数 key
status: completed
dependencies:
- create-keys-basics
- id: rewrite-lock-keys
content: 重写 lock.go 补齐全量锁与幂等 key 及参数注释
status: completed
dependencies:
- create-keys-basics
- id: build-lint-verify
content: 运行 go build 与 read_lints 校验 internal/redismodel 包
status: completed
dependencies:
- rewrite-auth-user
- rewrite-quota-like-collect
- create-cache-count-keys
- rewrite-lock-keys
---
## 产品概述
以 `internal/mysqlmodel` 的实体与字段术语为唯一语义基准,从 0 重新设计 `internal/redismodel` 的 Redis key 常量层。本次只交付 key 常量定义,不写任何 `redisdao` 操作函数(`redisdao` 已清空,由用户后续补充),也不改动 `middleware`。
## 核心功能
- 全局命名空间前缀 `RedisPrefix = "ztcloset:"` 独立成文件,不再挂靠在 auth.go
- 认证与会话:access/refresh token 映射,以及 `user_id -> token 集合` 的 Set 反向索引(支持多端登录)
- 用户缓存:`user_id -> 用户鉴权核心信息`(对齐 `User.Status` 语义)
- 点赞/收藏:按 `type:target_id` 分桶的 Set key,成员为 user_id
- 配额:每日计数 key(对齐 `UserDailyUsage` 的 AI/社区分享/衣柜分享三字段)与总配额 key(对齐 `Quota.QuotaType` 四类枚举)
- 缓存与计数:星座运势缓存、通知未读数、衣柜分享访问计数、公开穿搭浏览计数
- 分布式锁与幂等:下单防重、支付回调幂等、退款回调幂等、AI 推荐防并发、AI 星座防并发、衣柜/穿搭审核防重
- 文件组织对齐 mysqlmodel 的模块划分(like/collect 分开,删除旧的 org.go)
## 技术栈
- 语言:Go
- 改动范围:仅 `internal/redismodel` 下的 key 常量定义
- Redis 客户端(已存在,不修改):`global.RedisEngine`(`github.com/redis/go-redis/v9`)
- 语义基准:`internal/mysqlmodel` 全部实体
## 实现方案
### 总体策略
推翻旧的 4 个文件(`auth.go`、`quota.go`、`org.go`、`lock.go`),按 mysqlmodel 的实体划分重新组织为 11 个文件。每个文件只包含对应业务模块的 key 常量,常量名与 key 字符串均对齐 mysqlmodel 的字段/枚举术语。所有 key 统一携带 `ztcloset:` 前缀。
### 关键设计决策
1. **命名空间收敛**:`RedisPrefix` 抽到独立 `keys.go`,作为全包共享前缀,消除跨文件隐式依赖。
2. **文件对齐 mysqlmodel**:删除语义不符的 `org.go`,点赞、收藏分别用 `like.go`、`collect.go`;新增 `user.go`、`closet.go`、`outfit.go`、`horoscope.go`、`notification.go` 对应各实体。
3. **术语统一**:`community → outfit_share`(社区分享/搭配分享,对齐 `UserDailyUsage.OutfitShareCount`)、`wardrobe_share → closet_share`(对齐 `CLosetShareCount`)、`upload → cos`(对齐 `Quota.QuotaType=1=cos`)。
4. **token 一对多**:`KeyAuthUserToken` 改为复数 `KeyAuthUserTokens`,key 用 `auth:user:tokens:%d` 并明确为 Set 语义,解决单值无法表达多端登录的问题。
5. **配额双维度**:每日计数按 `UserDailyUsage` 的三个具名字段拆分;总配额用 `quota:total:%d:%d`(`user_id:quota_type`)参数化覆盖 `Quota.QuotaType` 全部 4 类,与数据库表结构(user_id + quota_type 唯一)完全对齐。
6. **锁与幂等维度**:下单用 order_id、支付回调用 order_no、退款回调用 refund_no、AI 推荐/星座用 user_id 维度防并发、审核用 target_id 防重,均对齐 mysqlmodel 对应字段。
7. **纯流水不设计**:`QuotaFlowLog`、`VIPFlowLog` 为纯流水日志;`Goods`、`Clothes`、`ClosetShareItem`、`OutfitItem` 为低频/关系数据,均无需 Redis key,避免过度设计。
## 目录结构
```
internal/redismodel/
├── keys.go # [NEW] 全局 RedisPrefix = "ztcloset:"
├── auth.go # [MODIFY] 移除 RedisPrefix;KeyAuthUserToken 改为复数 Set 语义
├── user.go # [NEW] KeyUserInfo 用户鉴权缓存 key
├── like.go # [NEW] KeyLikeSet(由旧 org.go 拆分)
├── collect.go # [NEW] KeyCollectSet(由旧 org.go 拆分)
├── quota.go # [MODIFY] 每日计数对齐 UserDailyUsage;总配额参数化对齐 QuotaType
├── closet.go # [NEW] KeyClosetShareView 衣柜分享访问计数
├── outfit.go # [NEW] KeyOutfitView 公开穿搭浏览计数
├── horoscope.go # [NEW] KeyHoroscope 星座运势缓存
├── notification.go # [NEW] KeyNotificationUnread 通知未读数
├── lock.go # [MODIFY] 补齐全量锁/幂等 key 与参数注释
└── org.go # [DELETE] 内容已拆分至 like.go / collect.go
```
## 关键代码结构
修复后的 key 常量全集(均为常量定义,无函数):
```
// keys.go
const RedisPrefix = "ztcloset:"
// auth.go
const (
KeyAuthAccess = RedisPrefix + "auth:access:%s" // access_token -> user_id(String)
KeyAuthRefresh = RedisPrefix + "auth:refresh:%s" // refresh_token -> user_id(String)
KeyAuthUserTokens = RedisPrefix + "auth:user:tokens:%d" // user_id -> token 集合(Set,支持多端登录)
)
// user.go
const KeyUserInfo = RedisPrefix + "user:info:%d" // user_id -> 用户鉴权核心信息(String/JSON,含 Status)
// like.go
const KeyLikeSet = RedisPrefix + "like:set:%d:%d" // like_type:target_id,成员为 user_id(Set)
// collect.go
const KeyCollectSet = RedisPrefix + "collect:set:%d:%d" // collect_type:target_id,成员为 user_id(Set)
// quota.go
const (
KeyQuotaDailyAI = RedisPrefix + "quota:daily:ai:%d:%s" // user_id:date,当日AI推荐次数
KeyQuotaDailyOutfitShare = RedisPrefix + "quota:daily:outfit_share:%d:%s" // user_id:date,当日社区分享次数(搭配分享)
KeyQuotaDailyClosetShare = RedisPrefix + "quota:daily:closet_share:%d:%s" // user_id:date,当日衣柜分享次数
KeyQuotaTotal = RedisPrefix + "quota:total:%d:%d" // user_id:quota_type,总配额(1=cos 2=AI 3=社区分享 4=衣柜分享)
)
// closet.go
const KeyClosetShareView = RedisPrefix + "closet:share:view:%s" // share_uuid,衣柜分享访问计数(INCR)
// outfit.go
const KeyOutfitView = RedisPrefix + "outfit:view:%d" // outfit_id,公开穿搭浏览计数(INCR)
// horoscope.go
const KeyHoroscope = RedisPrefix + "horoscope:%s:%d" // zodiac:stat_date(YYYYMMDD),星座运势缓存(String/JSON)
// notification.go
const KeyNotificationUnread = RedisPrefix + "notification:unread:%d" // user_id,未读消息数(INCR/DECR)
// lock.go
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推荐防并发
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,穿搭审核防重
)
```
## 实现注意事项
- 校验仅针对 `redismodel` 包:`go build ./internal/redismodel/` 与 `read_lints`(路径限定在 `internal/redismodel`)。不要运行 `go build ./...`,因为 `redisdao` 已清空而 `middleware/lzcwyjwt.go` 仍引用 `redisdao.UserInfoGet`,全量编译会失败(该问题由用户后续补 redisdao 解决,不在本次范围)。
- key 字符串变更属于破坏性变更,依赖可清库/未上线前提(用户已确认)。
- 所有注释需标明占位符含义与数据结构类型(String/Set/INCR/JSON),为后续 redisdao 实现提供契约。
@@ -0,0 +1,100 @@
---
name: service-span-and-async-trace
overview: 给 service 层所有对外入口方法补埋 span(svc.Xxx),并修复 clothes 上传异步 goroutine 的 trace 断链,使调用链可读、异步链路可关联。
todos:
- id: clothes-spans
content: 为 clothes.go 的 6 个入口方法(Upload、ProcessStatusGetByUUID、GetByUUID、UpdateByUUID、DeleteByUUID、List)补埋 svc span
status: completed
- id: clothes-async-trace
content: 修复 clothes.go 异步断链:改造 processImageAsync 签名与 ctx 派生、补 svc.ProcessImageAsync span、更新调用点、新增 otel/trace import
status: completed
dependencies:
- clothes-spans
- id: outfit-closet-collect-like-spans
content: 为 outfit.go、closet.go、collect.go、like.go 的入口方法补埋 svc span 并新增 global import
status: completed
- id: notification-quota-user-goods-horoscope-spans
content: 为 notification.go、quota.go、user.go、goods.go、horoscope.go 的入口方法补埋 svc span 并补充所需 import
status: completed
- id: order-airecommend-spans
content: 为 order.go、airecommend.go 的入口方法补埋 svc span 并新增 global import
status: completed
- id: build-vet-verify
content: 运行 go build ./... 与 go vet ./... 验证编译与静态检查通过
status: completed
dependencies:
- clothes-spans
- clothes-async-trace
- outfit-closet-collect-like-spans
- notification-quota-user-goods-horoscope-spans
- order-airecommend-spans
---
## 用户需求
1. 在 service 层对外入口方法补埋 OpenTelemetry span,使 trace 调用链可读。
2. 修复衣物上传异步 goroutine 的 trace 断链,使抠图、COS 上传、分类、回填等异步 DAO span 能关联回原请求。
## 功能内容
- 为 12 个 service 文件、共 41 个对外入口方法(首字母大写的导出方法)统一补埋 `svc.Xxx` span,命名与现有 `dao.Xxx`、`upload.Xxx` 前缀保持一致。
- 私有辅助方法不埋点,避免产生过深的 span 森林。
- 修复 `processImageAsync` 使用 `context.Background()` 导致异步链路丢失父 trace 的问题,通过 `trace.ContextWithSpanContext` 继承 trace 信息、同时切断取消信号。
- 异步分支补一个 `svc.ProcessImageAsync` span,将异步处理在 trace 中聚合成一个独立分组。
## 视觉/可观测效果
- 在 trace 平台中,每个 HTTP 请求下能看到 `svc.Xxx` 容器 span,其下嵌套对应业务调用的 `dao.Xxx`、`upload.Xxx` span,调用层次清晰。
- 衣物上传后,后台异步处理(抠图、COS、分类、回填)不再生成孤立 trace,而是挂在原请求(或 `svc.ProcessImageAsync`)之下,便于端到端排查。
## Tech Stack
- 语言:Go 1.27
- 可观测:OpenTelemetry Trace(已接入,`global.Tracer`)
- Web 框架:Gin(API 层已由 `otelgin.Middleware` 自动埋 server span,本次不涉及)
## Implementation Approach
采用「只补边界、不埋全程」的埋点策略:
1. service 入口方法方法体开头统一插入:
```
ctx, span := global.Tracer.Start(ctx, "svc.Xxx")
defer span.End()
```
span 名 = `svc.` + 方法名。通过覆盖入口方法,使 DAO/upload 子 span 在 trace 中正确归属到业务动作。
2. 修复异步断链:将 `processImageAsync` 签名改为接收 `parentCtx context.Context`,内部用:
```
ctx, cancel := context.WithTimeout(trace.ContextWithSpanContext(context.Background(), trace.SpanContextFromContext(parentCtx)), 3*time.Minute)
```
继承父 trace 的 span context,但用 `context.Background()` 切断请求 ctx 的取消信号(保证请求结束后 goroutine 仍能运行),再补 `svc.ProcessImageAsync` span 聚合异步分支。
## Implementation Notes
- service 层 span 只做 start/end,不调用 `span.RecordError`:service 方法返回 `*errcode.Error` 且在多个 return 点返回,若记录错误需改成具名返回值,侵入过大;本次目标为调用链可读,span 嵌套与耗时已满足需求。
- 需新增 `global` import 的文件:order.go、goods.go、outfit.go、horoscope.go、collect.go、closet.go、like.go、notification.go;airecommend.go、clothes.go、user.go、quota.go 已导入 `global`。
- clothes.go 需新增 `go.opentelemetry.io/otel/trace` import(go.mod 已包含该依赖)。
- 保持与现有 DAO 埋点风格一致:span 名用 `dao.Xxx`/`upload.Xxx` 相同的大写方法名风格,不引入新的埋点封装或 helper,避免与现有约定不一致。
## Directory Structure
```
internal/service/
├── clothes.go # [MODIFY] 6 个入口方法补 span;processImageAsync 签名改造、trace 传播修复、新增 svc.ProcessImageAsync span、更新调用点、新增 otel/trace import
├── outfit.go # [MODIFY] Create/GetByUUID/UpdateByUUID/List/Delete/Public 补 span;新增 global import
├── closet.go # [MODIFY] Create/Detail/List/Cancel 补 span;新增 global import
├── collect.go # [MODIFY] Collect/List 补 span;新增 global import
├── like.go # [MODIFY] LikeCreate/GetByTargetID 补 span;新增 global import
├── notification.go # [MODIFY] List/GetByUUID/Read/Unread 补 span;新增 global import
├── quota.go # [MODIFY] InfoGetBySelf/FlowList 补 span(global 已导入)
├── user.go # [MODIFY] WxLogin/InfoGetBySelf/ProfileUpdate/RefreshToken 补 span(global 已导入)
├── goods.go # [MODIFY] GetByCode/List 补 span;新增 global import
├── horoscope.go # [MODIFY] GetToday 补 span;新增 global import
├── order.go # [MODIFY] Create/PayNotify/Cancel/List/RefundApply/RefundNotify 补 span;新增 global import
└── airecommend.go # [MODIFY] Create/Save 补 span(global 已导入)
```
@@ -0,0 +1,93 @@
---
name: service-span-naming-dedupe
overview: 将 service 层 span 名从 svc.<方法名> 统一改为 svc.<领域><方法名>,消除跨 service 的同名 span,使 span 名全局唯一、可按名过滤,并与 DAO 层 dao.<领域><动作> 命名风格对齐。
todos:
- id: rename-clothes-outfit
content: 替换 clothes.go、outfit.go 共 13 处 span 命名(Clothes/Outfit 领域前缀)
status: completed
- id: rename-closet-collect-like
content: 替换 closet.go、collect.go、like.go 共 8 处 span 命名(ClosetShare/Collect/Like 领域前缀)
status: completed
- id: rename-notification-quota-user
content: 替换 notification.go、quota.go、user.go 共 10 处 span 命名(Notification/Quota/User 领域前缀)
status: completed
- id: rename-goods-horoscope-order-airecommend
content: 替换 goods.go、horoscope.go、order.go、airecommend.go 共 11 处 span 命名(Goods/Horoscope/Order/AIRecommend 领域前缀)
status: completed
- id: verify-build-vet
content: 运行 go build ./... 与 go vet ./...,并搜索确认无裸 svc 方法名 span
status: completed
dependencies:
- rename-clothes-outfit
- rename-closet-collect-like
- rename-notification-quota-user
- rename-goods-horoscope-order-airecommend
---
## 用户需求
将 service 层已埋设的 span 命名从 `svc.<方法名>` 调整为 `svc.<领域名><方法名>`,解决因多个 service 存在同名方法而导致的 span 名重复问题(如 `svc.Create`、`svc.List`、`svc.GetByUUID`),使每个 span 名全局唯一、可精确过滤与区分业务归属。
## 功能内容
- 仅修改 `global.Tracer.Start(ctx, "svc.Xxx")` 中的 span 名字符串,不改变业务逻辑、方法签名、import 或返回类型。
- 领域名取 Service 结构体名去掉 `Service` 后缀,与 DAO 层 `dao.ClothesCreate`、`dao.OutfitCreate` 的「领域名 + 动作」驼峰命名风格保持一致。
- 覆盖 12 个 service 文件、共 42 处 span。
## 技术栈
- 语言:Go
- 可观测:OpenTelemetry Trace(`global.Tracer.Start`)
- 改动性质:纯字符串替换,无新增依赖、无结构变更
## 实现方案
### 命名规则
- 领域名 = Service 结构体名去掉 `Service` 后缀:
- ClothesService → Clothes
- OutfitService → Outfit
- ClosetShareService → ClosetShare
- CollectService → Collect
- LikeService → Like
- NotificationService → Notification
- QuotaService → Quota
- UserService → User
- GoodsService → Goods
- HoroscopeService → Horoscope
- OrderService → Order
- AIRecommendService → AIRecommend
- span 名 = `svc.` + 领域名 + 方法名,驼峰无分隔,与方法名大小写一致。
### 特殊处理
- `CollectService.Collect` → `svc.CollectCollect`、`LikeService.LikeCreate` → `svc.LikeLikeCreate`,为「领域名 + 方法名」严格拼接导致的字面重复,但规则统一、机械可推导;本次不重命名方法本身。
## 实现细节
- 仅替换 `global.Tracer.Start(ctx, "svc.Xxx")` 的第二个字符串参数。
- 不改 import、不新增/删除 span、不调整方法体其他内容。
- 每个文件内按方法逐一替换,保证 42 处全部覆盖。
## 目录结构
```
internal/service/
├── clothes.go # [MODIFY] svc.Upload→svc.ClothesUpload、svc.ProcessImageAsync→svc.ClothesProcessImageAsync、svc.ProcessStatusGetByUUID→svc.ClothesProcessStatusGetByUUID、svc.GetByUUID→svc.ClothesGetByUUID、svc.UpdateByUUID→svc.ClothesUpdateByUUID、svc.DeleteByUUID→svc.ClothesDeleteByUUID、svc.List→svc.ClothesList(7 处)
├── outfit.go # [MODIFY] svc.Create→svc.OutfitCreate、svc.GetByUUID→svc.OutfitGetByUUID、svc.UpdateByUUID→svc.OutfitUpdateByUUID、svc.List→svc.OutfitList、svc.Delete→svc.OutfitDelete、svc.Public→svc.OutfitPublic(6 处)
├── closet.go # [MODIFY] svc.Create→svc.ClosetShareCreate、svc.Detail→svc.ClosetShareDetail、svc.List→svc.ClosetShareList、svc.Cancel→svc.ClosetShareCancel(4 处)
├── collect.go # [MODIFY] svc.Collect→svc.CollectCollect、svc.List→svc.CollectList(2 处)
├── like.go # [MODIFY] svc.LikeCreate→svc.LikeLikeCreate、svc.GetByTargetID→svc.LikeGetByTargetID(2 处)
├── notification.go # [MODIFY] svc.List→svc.NotificationList、svc.GetByUUID→svc.NotificationGetByUUID、svc.Read→svc.NotificationRead、svc.Unread→svc.NotificationUnread(4 处)
├── quota.go # [MODIFY] svc.InfoGetBySelf→svc.QuotaInfoGetBySelf、svc.FlowList→svc.QuotaFlowList(2 处)
├── user.go # [MODIFY] svc.WxLogin→svc.UserWxLogin、svc.InfoGetBySelf→svc.UserInfoGetBySelf、svc.ProfileUpdate→svc.UserProfileUpdate、svc.RefreshToken→svc.UserRefreshToken(4 处)
├── goods.go # [MODIFY] svc.GetByCode→svc.GoodsGetByCode、svc.List→svc.GoodsList(2 处)
├── horoscope.go # [MODIFY] svc.GetToday→svc.HoroscopeGetToday(1 处)
├── order.go # [MODIFY] svc.Create→svc.OrderCreate、svc.PayNotify→svc.OrderPayNotify、svc.Cancel→svc.OrderCancel、svc.List→svc.OrderList、svc.RefundApply→svc.OrderRefundApply、svc.RefundNotify→svc.OrderRefundNotify(6 处)
└── airecommend.go # [MODIFY] svc.Create→svc.AIRecommendCreate、svc.Save→svc.AIRecommendSave(2 处)
```
## 验证方式
- `go build ./...` 与 `go vet ./...` 通过。
- 全项目搜索确认不再存在 `"svc.Create"`、`"svc.List"`、`"svc.GetByUUID"` 等裸方法名 span。
@@ -0,0 +1,147 @@
---
name: service层完整实现
overview: 从零实现 internal/service 全部业务逻辑(用户/衣物/穿搭/衣柜分享/收藏/点赞/通知/商品/订单退款/额度/运势/AI推荐),复用已就绪的 dao/dto/redis/pkg,并接入真实微信登录与微信支付/退款(含配置与依赖),公开内容自动审核通过,保证 service 层可独立编译。
todos:
- id: setup-wechat-infra
content: 新增微信小程序/支付配置项与依赖,实现 pkg/wechat(code2session、JSAPI下单、回调验签解密、退款),并在 setting/global/main.go 接线
status: completed
- id: service-foundation
content: 创建 constants.go 常量与 service.go 公共工具(错误映射、redis.Nil 判定、分页默认、consumeQuota/refundQuota 额度封装)
status: completed
- id: user-quota-horoscope
content: 实现 user/quota/horoscope 三个 service:微信登录注册+初始化额度、用户信息缓存、Token刷新、额度概览与流水、按生日算星座读取今日运势
status: completed
dependencies:
- setup-wechat-infra
- service-foundation
- id: clothes-pipeline
content: 实现 ClothesService:上传扣额度+落COS+建记录,goroutine 异步抠图分类回填状态与失败回退,状态查询与增删改查列表
status: completed
dependencies:
- service-foundation
- id: community-and-closet
content: 实现 outfit/like/collect/notification/closet 五个 service:穿搭增删改查与公开(自动审核通过+扣额度)、点赞收藏与作者通知、通知读写未读数、衣柜分享创建/详情/列表/取消
status: completed
dependencies:
- clothes-pipeline
- id: order-refund
content: 实现 goods 与 OrderService:商品查询、微信JSAPI下单、支付回调发放会员/额度权益+通知、取消订单、订单列表、申请退款与退款回调(幂等+状态流转)
status: completed
dependencies:
- setup-wechat-infra
- user-quota-horoscope
- id: ai-recommend
content: 实现 AIRecommendService:防并发扣AI额度、基于衣橱与运势调用 tokenhub 生成推荐穿搭、幂等流水、保存到我的搭配,失败回退额度
status: completed
dependencies:
- community-and-closet
- user-quota-horoscope
---
## 产品概述
为"衣柜/穿搭"微信小程序后端补齐 **service 业务逻辑层**(`internal/service`),将已就绪的 DTO、MySQL DAO、Redis DAO 与 pkg 能力(大模型推荐、抠图、分类、COS、JWT、错误码)编排为完整可用的业务服务,覆盖 `internal/dto` 中全部请求/响应。本次仅实现 service 层(保证可独立编译),不实现 handler/router。
## 核心功能
- **用户与登录**:微信小程序 code2session 真实登录、注册即初始化默认额度、资料修改、Token 刷新、用户信息缓存。
- **衣物管理**:上传(扣额度、落 COS)+ 异步抠图/分类回填、状态查询、增删改查、列表分页。
- **穿搭管理**:创建/编辑/删除、明细组装、公开设置(公开即自动审核通过并写审核日志、消耗社区分享额度)。
- **衣柜分享**:部分/全量快照分享、访客详情(含过期与状态校验、访问计数)、我的分享列表、取消;创建即自动审核通过。
- **社区互动**:点赞/收藏及其取消、点赞给作者发通知、点赞数/收藏数统计(Redis 优先)。
- **通知**:列表、详情、标记已读、未读数(Redis 优先回落 MySQL)。
- **商品与订单**:商品查询/列表;下单(真实微信 JSAPI 支付)、支付回调发放会员与额度权益、取消、订单列表;申请退款与退款回调(真实微信支付退款)。
- **额度与运势**:额度概览与流水;按用户生日计算星座返回今日运势(Redis→MySQL 多级读取)。
- **AI 穿搭推荐**:防并发扣 AI 额度、基于衣橱与星座运势调用大模型生成推荐穿搭、幂等流水、保存到"我的搭配"。
## 关键约束
- 所有"我的"资源操作必须校验归属(用户/衣物/穿搭/分享/通知/订单)。
- 微信密钥仅来自配置,不硬编码;支付/退款回调必须验签解密并做幂等;额度扣减以数据库原子条件更新为准,Redis 作缓存加速。
## 技术栈
- 语言/框架:Go 1.27、Gin(沿用现有项目);ORM 为 GORM(含 `soft_delete` 软删除);缓存 go-redis/v9。
- 复用能力:`internal/mysqldao`(结构体 DAO + `global.MysqlEngine`)、`internal/redisdao`(**包级函数**,未命中返回 `redis.Nil`)、`pkg/tokenhub`(大模型运势/推荐)、`pkg/matting`(抠图)、`pkg/classification`(分类)、`pkg/upload` + `pkg/cos`(落盘/上传/CDN URL)、`pkg/lzcwyjwt`(JWT)、`pkg/errcode`(业务错误码)、`pkg/app`(响应封装,供后续 handler 用)、`global.Tracer`(OTel)。
- 新增依赖:`github.com/wechatpay-apiv3/wechatpay-go`(微信支付 v3 官方 SDK,用于 JSAPI 下单/回调验签解密/退款);微信登录 code2session 用标准库 `net/http` 直接请求固定域名 `api.weixin.qq.com`(无需额外依赖)。已具备 `github.com/google/uuid` 生成各类 UUID。
## 实现思路
采用与 DAO 一致的**结构体 Service + 构造函数**风格(`NewXxxService()` 内部持有所需 DAO 实例),按业务域分文件放置于 `internal/service`,统一方法签名约定:`func (s *XxxService) Method(ctx context.Context, userID int64, req *dto.XxxRequest) (*dto.XxxResponse, *errcode.Error)`,将底层 `error` 映射为语义化 `*errcode.Error`(DB→`DatabaseError`、缓存→`CacheError`、`redis.Nil` 视为未命中而非错误)。service 层不依赖 gin,保证 `go build ./internal/service/...` 可独立编译。
关键决策与权衡:
- **枚举常量集中化**:mysqlmodel 状态/类型仅有注释、无 Go 常量。新增 `internal/service/constants.go` 定义可读常量(状态、类型、额度类型、变更类型等),消除魔法数字,降低跨域协作出错概率。
- **额度扣减一致性**:以 MySQL `QuotaIncreaseUsed`(`used+n<=total` 条件原子更新)为**权威扣减**,保证不超发(乐观并发);Redis `QuotaRemain*`/每日计数作为读取加速与限流,二者不一致时以 MySQL 为准。封装 `consumeQuota/refundQuota` 复用于衣物上传、公开分享、AI 推荐。
- **异步图片处理**:`ClothesUpload` 同步完成校验/扣额度/落原图/建记录后立即返回,抠图+分类放入 goroutine(派生独立 `context`、`recover` 兜底),逐步更新 `image_process_status`;任一步失败置状态 5 并写 `fail_reason` 且回退额度,避免阻塞请求、控制外部 RPC 时延影响。
- **支付幂等与状态机**:下单/支付回调/退款回调分别用 `redisdao.TryLock`(`KeyLockOrderCreate/KeyLockPayCallback/KeyLockRefundCallback`)+ `OrderUpdateStatusByOrderNo(from→to)` 条件流转双保险,防重复回调重复发放权益。
- **多级读取**:运势、未读数、点赞/收藏计数走 Redis 优先、未命中回落 MySQL 并回填,降低热点查询压力。
## 实现要点(防回归)
- redisdao 是包级函数,未命中返回 `redis.Nil`,必须 `errors.Is(err, redis.Nil)` 判断,不可当错误抛。
- mysqlmodel 均内嵌 `Model`,`clothes` 表主色列名为 `color`(非 `color_main`),且 dto `ClothesUpdateByUUIDRequest.ColorMain` 的 tag 为 `json:"color"`、`ClothesGetByUUIDResponse.ColorMain` 为 `json:"color_main"`——映射一律以 model 字段为准,`updates` map 的 key 用真实列名 `color`。
- dto `GoodsGetByCodeResponse.UploadQuota` 对应 model `Goods.CosQuota`;`AIRecommendQuota` 无对应 dto 字段(发放额度时仍需处理)。注意 `UserDailyUsage.CLosetShareCount` 原始拼写。
- 复用现有 logger(`log/slog`,已接入 OTel context handler);错误日志不打印密钥/回调原文明文,避免 PII/密钥泄露。
- 归属校验统一在 service 层做(AuthZ);所有 SQL 走 DAO 参数绑定(防 SQLi 已满足);微信回调仅访问微信固定域名(无 SSRF 面)。
## 目录结构
```
zt-closet-backend/
├── configs/
│ └── config.example.yaml # [MODIFY] 新增 WechatMini(AppID/AppSecret) 与 WechatPay(AppID/MchID/APIv3Key/SerialNo/PrivateKeyPath/NotifyURL/RefundNotifyURL) 示例段
├── pkg/
│ ├── setting/
│ │ └── section.go # [MODIFY] 新增 WechatMiniSettingS、WechatPaySettingS 结构体
│ └── wechat/
│ ├── mini.go # [NEW] 微信小程序登录:Code2Session(ctx,code)->(openid,sessionKey,err),net/http 请求 jscode2session,errcode 映射
│ └── pay.go # [NEW] 微信支付客户端:初始化 wechatpay-go(加载商户私钥/证书),封装 JsapiPrepay/BuildPaySign、VerifyAndDecryptPayNotify、Refund、VerifyRefundNotify
├── global/
│ └── setting.go # [MODIFY] 新增 WechatMiniSetting、WechatPaySetting 全局变量
├── main.go # [MODIFY] setupSetting 增 ReadSection("WechatMini"/"WechatPay");新增 setupWechatPayClient 初始化支付客户端(可选,失败仅告警不 panic)
└── internal/service/
├── service.go # [NEW] 公共工具:error->errcode 映射、redis.Nil 判定、分页默认值(page/pageSize)、时间/日期工具、consumeQuota/refundQuota 额度封装
├── constants.go # [NEW] 各状态/类型 int8 常量(用户/衣物处理状态/穿搭/分享/审核/额度类型/变更类型/订单/退款/通知/点赞收藏类型等)
├── user.go # [NEW] UserService:WxLogin(真实code2session+注册+初始化额度+发Token)、InfoGetBySelf(缓存)、ProfileUpdate、RefreshToken
├── quota.go # [NEW] QuotaService:InfoGetBySelf(remain=total-used)、FlowList;ensureDefaultQuota 新用户默认额度初始化
├── horoscope.go # [NEW] HoroscopeService:由生日算星座+当日日期,Redis→MySQL 读取今日运势,回填缓存
├── clothes.go # [NEW] ClothesService:Upload(扣额度+落COS+建记录+异步抠图分类)、ProcessStatusGet、GetByUUID(CDN URL)、UpdateByUUID、DeleteByUUID、List
├── outfit.go # [NEW] OutfitService:Create/GetByUUID/Update/List/Delete/Public(公开自动审核通过+写审核日志+扣社区分享额度)
├── closet.go # [NEW] ClosetShareService:Create(自动审核通过+扣衣柜分享额度)、Detail(过期/状态校验+访问计数)、List、Cancel
├── collect.go # [NEW] CollectService:Collect(收藏/取消+Redis集合+target校验)、List(收藏穿搭列表)
├── like.go # [NEW] LikeService:LikeCreate(点赞/取消+Redis+给作者发通知)、GetByTargetID(Redis优先计数)
├── notification.go # [NEW] NotificationService:List/GetByUUID/Read/Unread;内部 sendNotification 供 like/order 复用
├── goods.go # [NEW] GoodsService:GetByCode(下架校验)、List(仅上架+类型过滤)
├── order.go # [NEW] OrderService:Create(微信JSAPI下单)、PayNotify(发放会员/额度权益+通知)、Cancel、List、RefundApply、RefundNotify
└── airecommend.go # [NEW] AIRecommendService:Create(防并发+扣AI额度+大模型推荐+幂等流水+生成待保存穿搭)、Save(置为正常)
```
## 关键代码结构
```
// internal/service/service.go —— service 层统一错误映射约定
// 将底层 error 归一为语义化业务错误;redis.Nil 由调用方按未命中处理,不进入此函数。
func toErr(err error, biz *errcode.Error) *errcode.Error
// 额度封装(供 clothes/outfit/closet/airecommend 复用):MySQL 原子扣减为准 + 写流水 + 同步 Redis
func (s *QuotaService) consumeQuota(ctx context.Context, userID int64, quotaType int8, n int64, changeType int8, reason, orderNo string) *errcode.Error
func (s *QuotaService) refundQuota(ctx context.Context, userID int64, quotaType int8, n int64, reason string) *errcode.Error
```
```
// pkg/wechat/pay.go —— 微信支付封装对外接口(签名以官方 SDK 实际类型为准,实现时对齐)
func InitPayClient(s *setting.WechatPaySettingS) error
func JsapiPrepay(ctx context.Context, openid, orderNo, description string, amount int64, notifyURL string) (prepayID string, err error)
func BuildMiniPayParams(prepayID string) *dto.WechatPayParams
func VerifyAndDecryptPayNotify(req *http.Request) (orderNo, transactionID string, paid bool, err error)
func Refund(ctx context.Context, orderNo, refundNo string, total, refund int64, notifyURL string) error
```
## Agent Extensions
### SubAgent
- **code-explorer**
- Purpose:实现阶段按需核对 DAO/Redis/pkg 的精确方法签名与字段(如 wechatpay-go 实际 API、`ClassifyResult` 字段到 `Clothes` 列的映射),避免签名臆测。
- Expected outcome:跨文件签名/字段映射一次性确认到位,减少编译期返工。
@@ -0,0 +1,71 @@
---
name: switch-matting-to-birefnet-lite
overview: 将本地抠图服务从 ModelScope 标准版 BiRefNet(444MB)切换为 lite 版(chenmingyu/BiRefNet_lite,177MB),删除标准版权重,重启 8001 服务并用 image.png 重跑测试。
todos:
- id: download-lite-model
content: 下载 lite 版 4 个文件到 models/BiRefNet_lite 目录
status: completed
- id: remove-std-model-and-stop-service
content: 删除标准版目录并停止当前 8001 标准版服务
status: completed
dependencies:
- download-lite-model
- id: restart-lite-service
content: 用 lite 目录重启 8001 服务并等待 /healthz 就绪
status: completed
dependencies:
- remove-std-model-and-stop-service
- id: verify-lite-matting
content: 运行 go test 验证 image.png 抠图返回合法 PNG
status: completed
dependencies:
- restart-lite-service
---
## 用户需求
将抠图服务改为使用 BiRefNet_lite 权重,不使用其它版本(即弃用此前临时采用的 ModelScope 标准版 BiRefNet),并继续用 `image.png` 完成真实抠图验证。
## 核心内容
- 从 ModelScope 获取 transformers 格式的 lite 版模型文件(`chenmingyu/BiRefNet_lite`,权重约 177MB),跳过其中无用的 4.18GB `env.bin`。
- 删除本地已下载的标准版 BiRefNet 模型目录,确保环境只保留 lite 版。
- 停止当前以标准版运行的 8001 服务,改用 lite 版权重重启服务。
- 重新运行 `go test ./pkg/matting/`,确认 `image.png` 抠图成功并返回合法 PNG。
## 技术栈
- Go 客户端:复用已实现的 `pkg/matting` 客户端(`Matting`/`HealthCheck`),无需改动。
- Python 服务端:复用已适配的 `thirdparty/matting_server/main.py`,其 `_REPO_ID` 默认值为 `ZhengPeng7/BiRefNet_lite`,并已支持 CPU 下将 fp16 权重转 fp32,无需再改。
- 模型获取:ModelScope 直链 `https://www.modelscope.cn/models/chenmingyu/BiRefNet_lite/resolve/master/<file>`(当前网络可访问)。
## 实现方案
### 关键决策
- **仅下载 lite 版 4 个必需文件**:`birefnet.py`、`BiRefNet_config.py`、`config.json`、`model.safetensors`。跳过 `env.bin`(4.18GB,非加载必需),避免无谓的带宽与磁盘占用。
- **删除标准版目录**:`thirdparty/matting_server/models/BiRefNet/`(444MB),满足用户"不要别的版本"的要求。
- **优雅停服方式**:本环境 `kill`/`pkill` 命令会触发不可见的审批超时,改用 `python3 -c "import os,signal; os.kill(<pid>, signal.SIGTERM)"` 停止当前 8001 服务。
- **服务启动**:通过 `BIREFNET_REPO_ID=/root/zt-closet-backend/thirdparty/matting_server/models/BiRefNet_lite` 环境变量指向 lite 目录启动,`/healthz` 就绪后再跑测试。
### 执行步骤
1. 创建 `thirdparty/matting_server/models/BiRefNet_lite/` 目录,用 curl 依次下载 4 个关键文件;校验 `model.safetensors` 大小约为 177634392 字节。
2. 删除标准版目录 `models/BiRefNet/`;用 `python os.kill` 停止当前 8001 服务进程。
3. 以 lite 目录重启服务,轮询 `/healthz` 直到返回 `{"status":"ok"}`。
4. 执行 `go test ./pkg/matting/ -run TestMatting -v -timeout 600s`,断言输出非空且以 PNG 魔数开头。
### 涉及文件与目录
```
thirdparty/matting_server/models/BiRefNet_lite/ # [NEW] lite 版模型文件(birefnet.py、BiRefNet_config.py、config.json、model.safetensors)
thirdparty/matting_server/models/BiRefNet/ # [DELETE] 标准版模型目录(444MB,用户要求移除)
pkg/matting/matting.go # 复用,不改动
thirdparty/matting_server/main.py # 复用,不改动
```
### 注意事项
- venv 及依赖已就绪(`thirdparty/matting_server/.venv/`),无需重装。
- `model.safetensors` 与 `models/` 目录均已被 `.gitignore` 忽略,不会误提交。
- 服务启动后确认日志出现 "BiRefNet 命中本地缓存,离线加载完成",且推理不再出现 fp16/fp32 dtype 报错。
@@ -0,0 +1,91 @@
---
name: tokenhub 与 model 对齐优化
overview: 优化 tokenhub 包:让穿搭推荐充分利用 Clothes 模型字段、将品类枚举统一为 6 大类、整理运势映射与公共结构,使代码与 mysqlmodel 数据模型对齐。
todos:
- id: refactor-client
content: 更新 client.go 包注释并将 chatResponse 结构下沉至 client.go
status: completed
- id: fortune-model-align
content: 为 FortuneItem 新增 ToHoroscope 方法并让 scheduler.go 改用
status: completed
- id: outfit-fields-enum
content: 重写 FormatClothesInfo 补全字段并统一六类品类枚举与 prompt/schema
status: completed
- id: outfit-validate
content: 增强 ValidateClothesIDs 类型对齐与去重,GetClothesIDs 去重
status: completed
dependencies:
- outfit-fields-enum
- id: verify-build
content: 执行 go build 与 go vet 验证 tokenhub 包编译通过
status: completed
dependencies:
- refactor-client
- fortune-model-align
- outfit-fields-enum
- outfit-validate
---
## 产品概述
对 `pkg/tokenhub` 包做一次与数据模型(`mysqlmodel`)对齐的代码优化,使穿搭推荐和星座运势生成更贴合现有 `Clothes`、`Horoscope` 等模型定义,提升 LLM 推荐质量与代码可维护性。仅改动 tokenhub 包内代码,不接入新接口、不修改 `main.go`。
## 核心功能
- **穿搭推荐字段补全**:将传给大模型的衣物摘要从 8 个字段扩充到 `Clothes` 模型的完整穿搭属性(副色、厚度、袖型、衣长、领型、正式度、场合、视觉冲击力、名称等),并采用「字段名:值」标注格式,让模型准确理解每项属性。
- **品类枚举统一为 6 大类**:将推荐品类从「上装/下装/鞋」扩展并统一为 `Clothes.CategoryMain` 的 6 大类(上装/下装/连衣裙/外套/鞋/配饰),同步更新结构体注释、prompt 搭配规则与 JSON Schema 枚举,消除「鞋/鞋子」措辞不一致。
- **推荐结果校验增强**:校验大模型返回的 `clothes_id` 是否存在,并按实际衣物 `CategoryMain` 对齐 `type` 字段,同时对重复衣物去重。
- **运势映射收敛**:为 `FortuneItem` 增加到 `Horoscope` 模型的转换方法,将散落在调度器中的字段映射逻辑收拢到模型转换处。
- **公共结构整理**:将共享的 `chatResponse` 响应结构下沉到 `client.go`,并更新过时的包注释。
## 视觉与交互
本次为纯逻辑层优化,不涉及界面或交互变化。
## 技术栈
- 语言:Go(沿用现有项目技术栈)
- 依赖:仅使用标准库(`encoding/json`、`strings`、`fmt` 等),不新增任何外部依赖
- 包结构:仅修改 `/root/zt-closet-backend/pkg/tokenhub/` 下 4 个文件,`mysqlmodel` 仅作只读引用
## 实现方法
### 总体策略
以 `mysqlmodel` 的字段定义为单一事实来源,消除 tokenhub 包中的硬编码字段列表与品类枚举漂移:衣物信息统一通过「字段名标注 + 全量字段」输出,品类统一通过包级常量切片管理,运势字段映射统一通过模型转换方法收敛。
### 关键决策
1. **品类枚举集中管理**:新增包级常量 `outfitCategories`,作为 prompt 文案、JSON Schema `enum`、类型校验的唯一来源,避免多处字符串硬编码不一致。
2. **衣物摘要字段名标注**:`FormatClothesInfo` 改为 `- ID:1 | 大类:上装 | 小类:T恤 | ...` 格式,补全 `ColorSub/Thickness/SleeveType/ClothesType/NecklineType/Formality/Occasions/VisualImpact/Name` 等字段;继续使用 `strings.Builder` 拼接以保证性能。
3. **推荐结果以 model 为准做纠偏**:`ValidateClothesIDs` 先按 `clothes_id` 是否在衣橱过滤,再将保留项的 `Type` 规范化为衣橱中该衣物实际的 `CategoryMain`(比简单丢弃更鲁棒),最后按 `clothes_id` 去重;`GetClothesIDs` 同步去重。均使用 `map` 保证 O(n) 复杂度。
4. **运势映射下沉到模型转换方法**:为 `FortuneItem` 增加 `ToHoroscope(statDate int64) *mysqlmodel.Horoscope`,`scheduler.go` 中的逐字段构造改为调用该方法,职责更清晰。
5. **保持导出 API 兼容**:`GenerateDailyFortunes`、`GenerateOutfitRecommendation`、`FormatClothesInfo` 的函数签名与返回类型不变,仅改内部实现,避免影响未来接入方。
### 性能与可靠性
- 所有列表处理均为单次遍历,`map` 去重 O(n),无额外性能负担。
- 不引入网络调用、不改变模型加载/调用流程,对启动与运行期无副作用。
- 新增的 `ToHoroscope` 与 `outfitCategories` 均无并发写入,可安全复用。
## 目录结构
仅改动 `pkg/tokenhub/` 下 4 个文件:
```
pkg/tokenhub/
├── client.go # [MODIFY] 更新 package 注释(补充穿搭推荐);新增 chatResponse 通用响应结构
├── fortune.go # [MODIFY] 移除 chatResponse;FortuneItem 新增 ToHoroscope 方法;统一「混元→Tokenhub」措辞
├── outfit_recommend.go # [MODIFY] 新增 outfitCategories 常量;重写 FormatClothesInfo;更新 prompt/schema/Type 注释为 6 大类;增强 ValidateClothesIDs 与 GetClothesIDs
└── scheduler.go # [MODIFY] runFortuneGeneration 改用 FortuneItem.ToHoroscope 构造 Horoscope
```
## 关键代码结构
```
// outfitCategories 穿搭推荐支持的衣物大类,与 mysqlmodel.Clothes.CategoryMain 取值对齐。
var outfitCategories = []string{"上装", "下装", "连衣裙", "外套", "鞋", "配饰"}
// ToHoroscope 将 LLM 返回的运势项转换为 Horoscope 模型。
func (f FortuneItem) ToHoroscope(statDate int64) *mysqlmodel.Horoscope
```
@@ -0,0 +1,102 @@
---
name: tokenhub 穿搭推荐 prompt 与输出字段对齐
overview: 优化 tokenhub 穿搭推荐的 prompt 与输出结构:补充「适合场景 scene」和「衣物 UUID clothes_uuid」字段,使推荐结果对齐 mysqlmodel.Outfit/OutfitItem 与 dto.AIRecommendCreateResponse。
todos:
- id: align-fields
content: 补充 RecommendLLMResponse.Scene 与 RecommendLLMItem.ClothesUUID 字段,并在 FormatClothesInfo 中输出 UUID
status: completed
- id: optimize-prompt
content: 优化 buildRecommendPrompt:明确 style/scene 语义、更新 JSON 示例并加入 clothes_uuid 精确复制约束
status: completed
dependencies:
- align-fields
- id: sync-schema
content: 同步 buildRecommendSchema:新增 scene 与 items.clothes_uuid 字段及 required 描述
status: completed
dependencies:
- align-fields
- id: enhance-validate
content: 增强 ValidateClothesIDs:以衣橱实际数据回填正确的 ClothesUUID,保留品类对齐与去重
status: completed
dependencies:
- align-fields
- id: verify-build
content: 执行 go build 与 go vet 验证 tokenhub 包编译与静态检查通过
status: completed
dependencies:
- align-fields
- optimize-prompt
- sync-schema
- enhance-validate
---
## 产品概述
完善 tokenhub 穿搭推荐的结果字段,使大模型输出可直接对齐 `mysqlmodel.Outfit/OutfitItem` 与 `dto.AIRecommendCreateResponse`,重点优化穿搭推荐的 prompt 与 JSON Schema,让推荐结果补齐「适合场景 Scene」与「衣物 ClothesUUID」两个关键字段。
## 核心功能
- **新增「适合场景 Scene」字段**:推荐结果在保留风格 `Style` 的基础上,新增 `Scene`(适合场景),与 `Outfit.Scene`、`dto.AIRecommendCreateResponse.Scene` 对齐。
- **新增「衣物 ClothesUUID」字段**:推荐单品在保留 `ClothesID` 的基础上,新增 `ClothesUUID`,与 `dto.OutfitItem.ClothesUUID` 对齐。
- **衣物摘要补充 UUID**:`FormatClothesInfo` 输出衣橱衣物时同时给出 `ID` 与 `UUID`,供大模型精确引用。
- **优化 prompt**:明确区分 `style`(风格:简约/复古/街头等)与 `scene`(适合场景:通勤/约会/休闲/运动等),要求分别输出;要求 `clothes_uuid` 从衣橱信息中精确复制并与 `clothes_id` 一一对应。
- **同步 JSON Schema**:补充 `scene` 与 `items.clothes_uuid` 字段定义及必填约束。
- **校验回填 UUID**:`ValidateClothesIDs` 以衣橱实际数据为准回填正确的 `ClothesUUID`,防止大模型抄错长 UUID,同时保留品类对齐与去重逻辑。
## 约束
- 仅修改 `/root/zt-closet-backend/pkg/tokenhub/outfit_recommend.go`。
- 不改 `main.go`、不接接口、不改配置、不新增依赖。
- 导出函数与方法签名保持不变。
## 技术栈
- 语言:Go(沿用现有项目技术栈)
- 依赖:仅标准库(`fmt`、`strings`、`encoding/json`),不新增任何外部依赖
- 改动文件:仅 `pkg/tokenhub/outfit_recommend.go`
## 实现方法
### 总体策略
以 `mysqlmodel.Outfit/OutfitItem` 与 `dto.AIRecommendCreateResponse/OutfitItem` 为字段对齐目标,对 tokenhub 包内的推荐结构体、衣物摘要格式、prompt、JSON Schema 与结果校验做一次一致性完善。核心是让 LLM 输出结果具备可直接映射到模型层的 `Scene` 与 `ClothesUUID` 字段,同时通过校验逻辑兜底纠偏。
### 关键决策
1. **`Style` 与 `Scene` 并存**:`Style` 表示穿搭风格(简约/复古/街头/甜美等),`Scene` 表示适合场景(通勤/约会/休闲/运动等)。两者语义不同,在 prompt 中明确区分并要求分别输出,避免模型把「风格」和「场合」混为一谈。
2. **`ClothesID` 与 `ClothesUUID` 并存**:保留 `ClothesID`(整数,便于模型快速引用与校验),新增 `ClothesUUID`(对齐 dto 的落库字段)。由于 UUID 较长且易抄错,`ValidateClothesIDs` 在过滤后**以衣橱实际数据回填正确的 `ClothesUUID`**,而非信任模型输出,保证最终结果可靠。
3. **`FormatClothesInfo` 并列输出 `ID` 与 `UUID`**:在现有「字段名:值」标注格式基础上,首列增加 `UUID`,形成 `- ID:1 | UUID:xxx | 大类:上装 | ...`,让模型能同时看到两个标识并建立对应关系。
4. **保持导出 API 兼容**:`GenerateOutfitRecommendation`、`FormatClothesInfo`、`ValidateClothesIDs`、`GetClothesIDs` 等签名不变,仅改内部实现与结构体字段,避免影响未来接入方。
### 性能与可靠性
- `ValidateClothesIDs` 继续使用 `map[int64]mysqlmodel.Clothes` 建立 ID→衣物映射,过滤、type 对齐、UUID 回填、去重均在单次遍历内完成,整体 O(n),无额外性能负担。
- 不改变网络调用、模型参数与调度流程,对运行期无副作用。
## 目录结构
```
pkg/tokenhub/
└── outfit_recommend.go # [MODIFY] 完善推荐结构体字段、衣物摘要、prompt、JSON Schema 与结果校验
```
## 关键代码结构
```
// RecommendLLMResponse 大模型返回的穿搭推荐。
type RecommendLLMResponse struct {
OutfitName string `json:"outfit_name"`
Description string `json:"description"`
Style string `json:"style"` // 穿搭风格
Scene string `json:"scene"` // 适合场景
Items []RecommendLLMItem `json:"items"`
}
// RecommendLLMItem 大模型返回的单品推荐。
type RecommendLLMItem struct {
Type string `json:"type"` // 品类:上装/下装/连衣裙/外套/鞋/配饰
ClothesID int64 `json:"clothes_id"` // 衣橱衣物 ID
ClothesUUID string `json:"clothes_uuid"` // 衣橱衣物 UUID
Reason string `json:"reason"` // 选择理由
}
```
@@ -0,0 +1,111 @@
---
name: 修复XPay发货推送伪造漏洞
overview: 修复 S1:XPay 发货推送在发放权益前增加微信侧订单真实性反查(QueryOrder),杜绝伪造推送资损;S2:彻底删除服务端自动道具同步逻辑(避免每次启动自动同步),道具同步交由用户管理后台负责,并更新 README 标注。
todos:
- id: add-paid-status-helper
content: 在 pkg/wechat/virtualpay.go 新增已支付状态常量与 IsOrderPaidStatus 白名单判断函数,并在 virtualpay_test.go 补单测(已支付/未支付/未知/空串)
status: completed
- id: add-reality-check
content: 在 pkg/errcode/order_code.go 新增 70009,并在 internal/service/virtualpay.go 的 handleGoodsDeliver 本地校验后插入 verifyXPayOrderPaid 反查(fail-closed,附审计日志)
status: completed
dependencies:
- add-paid-status-helper
- id: remove-sync-logic
content: 删除服务端自动道具同步逻辑:main.go 调用、service 同步函数与常量、wechat 道具管理函数/类型/常量、goods.go 的 GoodsListVirtualSync 与 GoodsUpdateByID
status: completed
- id: update-docs
content: 更新 README.md 标注道具同步运营约束并删除后台任务表同步行,同步更新 docs/代码审查报告-二次check.md 中 S1 与 S2 处置状态
status: completed
---
## 用户需求
1. **修复 S1(资损漏洞)**:XPay 发货推送无验签且缺少订单真实性反查,攻击者可伪造 `xpay_goods_deliver_notify` 推送使订单 Pending→Paid 零成本获取权益。修复方式:发货前调用带 HMAC-SHA256 签名的微信服务端 API `query_order` 反查订单真实支付状态,确认「已支付 + 金额一致 + 道具一致」后才流转发放,推送仅作触发信号。
2. **S2 删除服务端自动同步逻辑**:删除服务启动时自动执行的虚拟支付道具同步代码,避免每次运行服务端自动同步;道具同步改由用户自己的管理后台负责。同时在 README 标注运营约束:运营改价后必须通过管理后台同步道具(或重启服务),否则微信侧道具价格与本地不一致会导致发货金额校验拒绝(用户付款拿不到权益)或差价资损。
## 范围边界
- 仅修改虚拟支付链路:发货推送处理(`handleGoodsDeliver`)增加真实性反查;普通微信支付 V3 回调链路(官方 SDK 验签)不涉及。
- 删除服务端道具同步链路(main 触发、service 同步逻辑、wechat 道具管理函数、同步专用 DAO),保留商品表 `product_id`/`product_status` 字段与 `GoodsProduct*` 状态常量(下单校验依赖)。
- 退款推送伪造风险与 M1(RefundStatus 空串误判)不在本次范围。
## 技术方案
### S1:发货推送真实性反查
**策略**:在 `handleGoodsDeliver` 中,幂等锁内、全部本地校验(`pay_channel` / `product_id` / `amount` / `env`)通过之后、条件流转(Pending→Paid)之前,插入微信侧订单反查。反查失败或结果不符时返回非 nil 错误码,使 `XPayNotify` 回 `ErrCode:1`,微信按推送重试策略重试(fail-closed)。
**关键设计**:
1. 支付状态白名单判断(`pkg/wechat` 层集中管理):
- 新增「已支付」状态常量与 `IsOrderPaidStatus(status string) bool` 白名单判断函数;未知状态/空串按未支付处理并告警。
- `virtualpay_test.go` 补充单测:已支付 / 未支付 / 未知状态 / 空串。
2. 反查校验函数(service 层):
- 新增 `verifyXPayOrderPaid(ctx, order, deliver) *errcode.Error`,校验顺序:
- `deliver.OpenID` 为空 → 拒绝
- `wechat.QueryOrder(ctx, deliver.OpenID, deliver.OutTradeNo, "")` 接口错误 → 拒绝
- `!wechat.IsOrderPaidStatus(info.Status)` → 拒绝
- `info.PayFee != order.PayAmount` → 拒绝
- `info.ProductID != order.XPayProductID` → 拒绝
- 全部通过 → nil
- 插入位置:`handleGoodsDeliver` 中 env 校验之后、`OrderUpdateStatusByOrderNo(Pending→Paid)` 之前。
3. 错误码:
- `pkg/errcode/order_code.go` 新增 `OrderRealityCheckFailed = NewError(70009, "订单真实性校验失败")`。
4. 安全性与一致性:
- 反查走 `callXPay` 的 HMAC 签名(激活环境 AppKey),攻击者无法伪造;伪造推送在微信侧无支付记录,反查必然失败 → 拒绝发货。
- 真实支付后反查必能查到已支付,微信推送重试保证最终发货。
- 反查使用激活环境,与订单 `XPayEnv` 快照经本地校验一致。
- 成功路径沿用现有 `finalizeVirtualPaidOrder`(callback_raw 持久化原始推送),审计闭环不变。
- 审计日志携带 `out_trade_no` / query 返回状态 / 金额,失败不落库(最小改动)。
**性能**:反查仅在幂等锁内执行一次(每笔发货推送一次 HTTP 调用,10s 超时),无额外热点。沙箱环境 `query_order` 行为需联调确认(代码注释保持「以官方文档为准」风格)。
### S2:删除服务端自动道具同步逻辑
**删除内容**:
- `main.go`:`service.StartVirtualGoodsSync()` 调用及注释
- `internal/service/virtualpay.go`:`StartVirtualGoodsSync` / `SyncVirtualGoods` / `syncOneGoods` / `pollGoodsTask` 及 4 个同步常量
- `pkg/wechat/virtualpay.go`:`StartUploadGoods` / `QueryUploadGoods` / `StartPublishGoods` / `QueryPublishGoods`、`XPayGoodsItem` / `XPayTaskResult`、4 个道具 path 常量
- `internal/mysqldao/goods.go`:`GoodsListVirtualSync` / `GoodsUpdateByID`(删除后无引用)
**保留内容**:
- 商品表 `product_id` / `product_status` 字段(管理后台回填,下单校验依赖)
- `GoodsProductUnpublished/Publishing/Published/PublishFail` 常量(下单校验与状态语义一致)
- `setupVirtualPayClient` / `GetVirtualPayEnv` / `QueryOrder`(下单、反查、回调仍需客户端)
- `GoodsGetByCode` / `GoodsGetByID` / `GoodsList`(商品模块与下单使用)
**README 标注**:
- 道具同步由管理后台负责,服务端不再自动同步
- 运营改价后必须通过管理后台同步道具(或重启服务),否则微信侧价格与本地不一致会导致发货金额校验拒绝或差价资损
- 删除后台任务表中「虚拟支付道具同步」行,同步更新交易与权益流程相关描述
### 文件修改清单
```
zt-closet-backend/
├── pkg/wechat/virtualpay.go # [MODIFY] 新增已支付状态常量与 IsOrderPaidStatus;删除道具管理函数/类型/path 常量
├── pkg/wechat/virtualpay_test.go # [MODIFY] 补 IsOrderPaidStatus 单测
├── pkg/errcode/order_code.go # [MODIFY] 新增 70009 错误码
├── internal/service/virtualpay.go # [MODIFY] 插入 verifyXPayOrderPaid 反查;删除同步逻辑与常量
├── internal/mysqldao/goods.go # [MODIFY] 删除 GoodsListVirtualSync / GoodsUpdateByID
├── main.go # [MODIFY] 删除 StartVirtualGoodsSync 调用
├── README.md # [MODIFY] 标注道具同步运营约束,删除后台任务表同步行
└── docs/代码审查报告-二次check.md # [MODIFY] S1 标「已修复」,S2 标「已删除自动同步,由管理后台负责」
```
### 实现注意事项
- 删除同步逻辑后需确认 `internal/service/virtualpay.go` 的 `time` 包仍被锁 TTL 使用、`wechat` 包仍被 BuildPaymentSign 使用,避免编译错误。
- 删除 `XPayGoodsItem` 后需确认无其他文件引用(搜索已确认仅同步逻辑引用)。
- 反查状态白名单的枚举值以微信官方 `query_order` 返回状态为准,实现时集中定义常量并注释「联调校正」。
@@ -0,0 +1,81 @@
---
name: 修复XPay发货推送伪造漏洞
overview: 修复 S1:XPay 发货推送在条件流转发放权益前增加微信侧订单真实性反查(QueryOrder),杜绝伪造推送零成本获取权益;S2 不做代码修复,仅在 README 标注道具价格同步由管理后台负责的运维约束。
todos:
- id: add-paid-status-helper
content: 在 pkg/wechat/virtualpay.go 新增「已支付」状态常量与 IsOrderPaidStatus 白名单判断函数,并在 virtualpay_test.go 补充单测(含未知状态、空串)
status: pending
- id: add-reality-check
content: 在 pkg/errcode/order_code.go 新增 70009 错误码,并在 internal/service/virtualpay.go 的 handleGoodsDeliver 中本地校验后、条件流转前插入 QueryOrder 反查(fail-closed,openid 为空/接口错误/未支付/金额道具不符均返回错误让微信重试,附审计日志)
status: pending
dependencies:
- add-paid-status-helper
- id: update-docs
content: 更新 README.md 标注道具同步运营约束(管理后台负责、改价必须同步及其风险),并同步更新 docs/代码审查报告-二次check.md 中 S1「已修复」、S2「已确认由管理后台同步,仅文档标注」
status: pending
---
## 用户需求
1. **修复 S1(资损漏洞)**:XPay 发货推送无验签,攻击者可伪造 `xpay_goods_deliver_notify` 推送零成本获取权益。按已确认方案修复:发货前调用带 HMAC-SHA256 签名的微信服务端 API `query_order` 反查订单真实支付状态,确认「已支付 + 金额一致 + 道具一致」后才流转发放,推送仅作触发信号。
2. **S2 仅文档标注**:不做道具周期同步代码。用户有自己的管理后台负责道具同步,仅在 README 标注运营约束:运营改价后必须通过管理后台同步道具(或重启服务),否则微信侧道具价格与本地不一致,会导致发货金额校验拒绝(用户付款拿不到权益)或差价资损。
## 范围边界
- 仅修改虚拟支付链路:发货推送处理(`handleGoodsDeliver`)增加真实性反查;普通微信支付 V3 回调链路(官方 SDK 验签)不涉及
- 退款推送伪造风险与 M1(RefundStatus 空串误判)不在本次范围
- 交付物:修复后的代码 + 单测 + README 与审查报告文档更新
## 技术方案
### 实现策略
在 `handleGoodsDeliver` 中,幂等锁内、全部本地校验(pay_channel / product_id / amount / env)通过之后、条件流转(Pending→Paid)之前,插入微信侧订单真实性反查。反查失败或结果不符时返回非 nil 错误码,使 `XPayNotify` 回 `ErrCode:1`,微信按推送重试策略重试(fail-closed)。
### 关键设计
**1. 支付状态白名单判断(pkg/wechat 层,与微信枚举集中管理)**
- 在 `pkg/wechat/virtualpay.go` 新增导出函数 `IsOrderPaidStatus(status string) bool` 与状态常量(如 `xpayOrderStatusPaid`,枚举值以官方文档为准,联调校正)
- 未知状态按「未支付」处理并告警,保证 fail-closed
- `internal/service/virtualpay.go` 调用该函数,不散落魔法字符串
**2. 反查校验函数(service 层)**
- 新增 `verifyXPayOrderPaid(ctx, order, deliver) *errcode.Error`:
- `deliver.OpenID` 为空 → 直接拒绝(返回新错误码 70009),审计日志
- 调用 `wechat.QueryOrder(ctx, deliver.OpenID, deliver.OutTradeNo, "")`
- 接口错误 → 返回非 nil 错误(fail-closed),日志携带 out_trade_no 与 error
- `!wechat.IsOrderPaidStatus(info.Status)` → 拒绝 + 日志(含 query 返回状态)
- `info.PayFee != order.PayAmount` → 拒绝 + 日志(金额不符,资损线索)
- `info.ProductID != order.XPayProductID` → 拒绝 + 日志(道具不符)
- 全部通过 → 返回 nil,继续原有条件流转与 `finalizeVirtualPaidOrder` 发货
- 插入位置:`handleGoodsDeliver` 中 env 校验之后、`OrderUpdateStatusByOrderNo(Pending→Paid)` 之前
**3. 错误码**
- `pkg/errcode/order_code.go` 新增 `OrderRealityCheckFailed = NewError(70009, "订单真实性校验失败")`,语义用于「反查未通过/异常」,触发微信重试
**4. 安全性与一致性**
- 反查走 `callXPay` 的 HMAC 签名(激活环境 AppKey),攻击者无法伪造;伪造推送在微信侧无支付记录,反查必然失败 → 拒绝发货
- 真实支付后微信必然推送且反查必可查到已支付,重试机制保证最终发货
- 反查使用激活环境(现网/沙箱),与订单 `XPayEnv` 快照已通过本地校验一致
- 成功路径沿用现有 `finalizeVirtualPaidOrder`(callback_raw 持久化原始推送),审计闭环不变
### 性能与稳定性
- 反查仅在幂等锁内执行一次(每笔发货推送一次 HTTP 调用,10s 超时),不引入额外热点
- 沙箱环境 `query_order` 行为需联调确认(代码注释保持与现有「以官方文档为准」风格一致)
### 文件修改清单
```
zt-closet-backend/
├── pkg/wechat/virtualpay.go # [MODIFY] 新增 XPay 订单已支付状态常量与 IsOrderPaidStatus 白名单判断函数
├── pkg/wechat/virtualpay_test.go # [MODIFY] 补充 IsOrderPaidStatus 单测:已支付/未支付/未知状态/空串
├── pkg/errcode/order_code.go # [MODIFY] 新增 70009 订单真实性校验失败错误码
├── internal/service/virtualpay.go # [MODIFY] handleGoodsDeliver 插入 verifyXPayOrderPaid 反查(fail-closed)+审计日志
├── README.md # [MODIFY] S2 标注:道具同步由管理后台负责,改价必须同步(风险说明)
└── docs/代码审查报告-二次check.md # [MODIFY] S1 标注「已修复」,S2 标注「已确认由管理后台同步,仅文档标注」
```
@@ -0,0 +1,111 @@
---
name: 修复 redismodel/redisdao 语义与健壮性问题(P1+P2)
overview: 修复 redismodel/redisdao 内部的日期类型不一致、Quota 缓存语义不清,以及扣减/读取的健壮性边界问题,不修改 middleware。
todos:
- id: fix-quota-key-defs
content: 统一日期类型并重命名 redismodel/quota.go 的剩余配额 key
status: completed
- id: fix-quota-dao
content: 修改 redisdao/quota.go 函数签名、剩余配额语义与 Lua 扣减逻辑
status: completed
dependencies:
- fix-quota-key-defs
- id: fix-notification-decr
content: 用 Lua 脚本修复 notification.go 未读数扣减防负
status: completed
- id: verify-build
content: 运行 go build、go vet 与 lint 验证编译及诊断通过
status: completed
dependencies:
- fix-quota-key-defs
- fix-quota-dao
- fix-notification-decr
---
## 产品概述
针对 `redismodel` 与 `redisdao` 二次校验后发现的 P1(语义/一致性)与 P2(健壮性边界)问题进行修复,仅限这两层内部改动,不动 `middleware`。
## 核心修复内容
- **P1.1 日期类型统一**:`quota` 模块每日计数 key 的日期由 `%s`(string)统一为 `%d`(int64 YYYYMMDD),与 `horoscope` 及 `mysqlmodel` 的 `StatDate int64` 对齐。
- **P1.2 Quota 缓存语义澄清**:将 `KeyQuotaTotal`/`QuotaTotalSet/Get/Decr` 重命名为 `KeyQuotaRemain`/`QuotaRemainSet/Get/Decr`,明确 Redis 存的是「剩余可用配额」而非「总配额」,消除与 `mysqlmodel.Quota`(Total + Used)的语义错位。
- **P2.1 扣减原子化与防负**:`QuotaRemainDecr` 改用 Lua 脚本原子扣减,key 不存在时不产生负数脏 key,剩余不足时不扣减。
- **P2.2 每日计数未命中返回 0**:`quotaDailyValue` 对 `redis.Nil` 返回 `0, nil`,与 `NotificationUnreadGet` 行为一致。
- **P2.3 未读计数扣减防负**:`NotificationUnreadDecr` 改用 Lua 脚本保证结果不低于 0。
## 技术栈
- 语言:Go
- Redis 客户端:`github.com/redis/go-redis/v9`
- 仅修改 3 个文件,沿用现有 `redismodel`(key 常量)+ `redisdao`(操作函数)分层与 `redis.NewScript` Lua 脚本模式。
## 实现方案
### P1.1 日期类型统一为 int64
`internal/redismodel/quota.go` 三个每日 key 的 `%s` 改为 `%d`,注释标注 `date(YYYYMMDD)`;`internal/redisdao/quota.go` 中 6 个 `QuotaDaily*` 函数签名 `date string` 改为 `date int64`。`fmt.Sprintf` 的 `%d` 直接接收 int64,无其它改动。
### P1.2 配额语义重命名为「剩余配额」
- `redismodel/quota.go`:`KeyQuotaTotal` 重命名为 `KeyQuotaRemain`,key 由 `quota:total:%d:%d` 改为 `quota:remain:%d:%d`,注释改为「剩余可用配额」。
- `redisdao/quota.go`:`QuotaTotalSet/Get/Decr` 重命名为 `QuotaRemainSet/Get/Decr`,`QuotaRemainSet` 形参 `total` 改为 `remain`,函数注释同步说明存的是「剩余可用配额」。
### P2.1 配额扣减原子化(Lua)
新增 `quotaRemainDecrScript`,`QuotaRemainDecr` 通过脚本执行,单次 RTT、Redis 端原子,避免并发超扣与脏 key:
```
local v = redis.call('GET', KEYS[1])
if not v then return -1 end
v = tonumber(v)
local d = tonumber(ARGV[1])
if v &lt; d then return -2 end
return redis.call('DECRBY', KEYS[1], d)
```
返回值契约:
- `-1`:配额未初始化(key 不存在)
- `-2`:剩余配额不足
- `&gt;= 0`:扣减成功后的剩余值
### P2.2 每日计数未命中返回 0
`quotaDailyValue` 改为:
```
v, err := global.RedisEngine.Get(ctx, key).Int64()
if err == redis.Nil {
return 0, nil
}
return v, err
```
### P2.3 未读数扣减防负(Lua)
`NotificationUnreadDecr` 改用脚本,保证结果不低于 0:
```
local v = redis.call('GET', KEYS[1])
if v and tonumber(v) &gt; 0 then return redis.call('DECRBY', KEYS[1], 1) end
return 0
```
## 目录结构
```
internal/
├── redismodel/
│ └── quota.go # [MODIFY] 每日 key %s→%d;KeyQuotaTotal→KeyQuotaRemain(key quota:remain:%d:%d),注释澄清语义
└── redisdao/
├── quota.go # [MODIFY] 6 个 QuotaDaily* date→int64;QuotaTotal*→QuotaRemain* 重命名;QuotaRemainDecr 用 Lua;quotaDailyValue 未命中返回 0
└── notification.go # [MODIFY] NotificationUnreadDecr 用 Lua 防负
```
## 实施要点
- **影响范围可控**:已确认 `QuotaDaily*`/`QuotaTotal*` 及 `KeyQuotaDaily*`/`KeyQuotaTotal` 无业务层调用方,重命名与签名变更自包含、无连带改动。
- **兼容性**:这些 key 均为新增未上线代码,key 字符串变更(`quota:total`→`quota:remain`、日期 `%s`→`%d`)不涉及存量数据迁移。
- **性能**:Lua 脚本均为单次往返、Redis 端原子执行,与既有 `quotaDailyIncrScript`/`unlockScript` 风格一致,无额外开销。
- **日志**:本次改动不引入日志;错误沿现有 `error` 返回值上抛,由上层统一处理。
@@ -0,0 +1,98 @@
---
name: 修复中等与轻微问题6项
overview: 按二次审查报告修复 6 个问题:M1(退款推送 RefundStatus 空串误回收)、M2(发货校验失败返回成功应答吞资损线索)、M5(补偿任务 Failed 被回调重置)、M14(衣物失败回退与删除并发漏退)、M10(refresh-token access_token 文档矛盾)、M13(Outfit.Public 并发语义冲突)。限流(M11)与上传防护(M12)本次不修复。
todos:
- id: fix-xpay-notify
content: 修复虚拟支付推送链路:新增 XPayRefundStatusSuccess 常量,handleRefundNotify 改白名单判断,handleGoodsDeliver 五个校验失败分支返回 70009 触发微信重试
status: completed
- id: fix-benefit-reset
content: 修复补偿任务重置:BenefitGrantTaskReset 保留 retry_count,失败任务增加 5 分钟冷却期后才允许回调重新触发
status: completed
- id: fix-clothes-refund-tx
content: 修复衣物回退漏退:markProcessFailed 重写为行锁单事务,删除 refundIfExists 与 ClothesMarkProcessFailedIfNotFailed
status: completed
- id: fix-outfit-public-lock
content: 修复穿搭公开竞态:redismodel 新增 KeyLockOutfitPublic,OutfitService.Public 全流程加 Redis 锁串行化
status: completed
- id: sync-docs
content: 同步文档:README 修正 refresh-token access_token 为必填,审查报告 M1/M2/M5/M10/M13/M14 标注已修复
status: completed
dependencies:
- fix-xpay-notify
- fix-benefit-reset
- fix-clothes-refund-tx
- fix-outfit-public-lock
- id: verify
content: 验证 go build 与 go test 通过,read_lints 无新增告警
status: completed
dependencies:
- sync-docs
---
## 用户需求
根据二次代码审查报告,修复用户确认的 6 个问题(最新调整:限流缺失 M11、上传防护 M12 暂不修复):
1. **M1 退款推送误回收**:`RefundStatus` 字段为空串或未知值时被误判为「退款成功」并回收权益,修复为仅当明确等于 `SUCCESS` 才回退
2. **M2 发货校验失败静默**:发货推送本地校验失败(订单不存在/渠道不符/道具不符/金额不符/环境不符)返回成功应答,微信不重试且资损线索被吞掉,修复为返回失败应答触发重试
3. **M5 补偿任务重试上限失效**:失败任务被支付回调重置(retry_count 清零),绕过重试上限与人工介入机制
4. **M14 衣物额度漏退窗口**:异步处理失败回退与用户删除并发时,可能两个「应回退」事件叠加却零回退,用户损失额度
5. **M10 refresh-token 文档矛盾**:README 标注 access_token 为可选,但代码要求必填,统一为必填
6. **M13 穿搭公开并发语义冲突**:并发公开时,一个请求回退公开态后,另一请求已误报成功
## 约束
- 保持现有代码风格(slog 结构化日志、errcode 错误码、Redis fail-open 降级)
- 修复后 `go build ./...` 与 `go test ./pkg/wechat/` 必须通过,read_lints 无新增告警
- 不修改普通微信支付 V3 回调链路;M3/M4/M6-M9/M11/M12/M15 及未点名问题不在本次范围
## 技术方案
### 1. M1 + M2:虚拟支付推送链路修复
**M1 退款推送白名单**
- `pkg/wechat/virtualpay.go`:新增常量 `XPayRefundStatusSuccess = "SUCCESS"`
- `internal/service/virtualpay.go` 的 `handleRefundNotify`:改为 `if refund.RefundStatus != wechat.XPayRefundStatusSuccess { slog.WarnContext(...); return nil }`,仅成功状态走 `settleRefundSuccess`;订单不存在分支保持 `return nil`
**M2 发货校验失败应答**
- `handleGoodsDeliver` 中以下五个分支由 `return nil` 改为 `return errcode.OrderRealityCheckFailed`(70009 已存在),使 `XPayNotify` 回 `ErrCode:1` 让微信重试:
- 订单不存在
- `pay_channel != PayChannelVirtual`
- 道具 ID 不匹配
- 实付金额不匹配
- 环境不匹配
- 锁竞争失败分支保持 `return nil`(并发回调交由持锁者处理,防重试风暴)
### 2. M5:补偿任务重置保护
- `internal/mysqldao/benefit_grant_task.go` 的 `BenefitGrantTaskReset`:删除 `retry_count: 0`,保留原重试计数
- `internal/service/order.go` 的 `grantBenefitIdempotent` Failed 分支:新增冷却常量 `benefitRetryCooldownSeconds = 300`;当 `nowUnix()-existing.UpdatedAt < 300` 时仅打告警跳过,冷却期外才 Reset + executeGrant,恢复重试上限与人工介入机制
### 3. M14:衣物处理失败回退事务化
- `internal/service/clothes.go` 重写 `markProcessFailed` 为单事务(复用 `DeleteByUUID` 的 FOR UPDATE + 回退模式):
1. `tx.Clauses(clause.Locking{Strength: "UPDATE"})` 按 `clothes_uuid` 查衣物;不存在/已软删(RecordNotFound)或已是失败态则提交并跳过
2. 置 `image_process_status=5` + `fail_reason`
3. 同事务回退额度:条件更新 `used - 1`(used>=1),回读真实值,写回退流水(Reason="衣物处理失败回退",OrderNo=流水 UUID 兜底)
- 删除 `refundIfExists` 方法与 `internal/mysqldao/clothes.go` 的 `ClothesMarkProcessFailedIfNotFailed`(已确认仅此一处调用)
- `DeleteByUUID` 保持不变:行锁读到失败态即跳过回退,与失败事务通过行锁互斥,两路径恰好回退一次
### 4. M13:穿搭公开加 Redis 锁
- `internal/redismodel/lock.go`:新增 `KeyLockOutfitPublic = RedisPrefix + "lock:outfit:public:%s"`(outfit_uuid)
- `internal/service/outfit.go` 的 `Public`:进入后先 `redisdao.TryLock(fmtLockKey(KeyLockOutfitPublic, request.OutfitUUID), 10s)`,失败返回 `errcode.TooManyRequests`;成功 `defer` 解锁;锁内保留现有「条件公开 → 扣额 → 失败回退」流程,串行化后并发请求语义正确
### 5. M10 + 文档同步
- `README.md` 686 行:access_token 由「否(可选,向前兼容)」改为「是(必填)」,说明缺少返回 10000
- `docs/代码审查报告-二次check.md`:M1/M2/M5/M10/M13/M14 标题后标注「已修复」及一行修复说明;M11/M12 保持未修复状态
### 验证
- `go build ./...` 全量编译通过
- `go test ./pkg/wechat/` 通过
- `read_lints` 检查修改文件无新增告警
@@ -0,0 +1,117 @@
---
name: 修复中等与轻微问题8项
overview: 按二次审查报告修复 8 个问题:M1(退款推送 RefundStatus 空串误回收)、M2(发货校验失败返回成功应答吞资损线索)、M5(补偿任务 Failed 被回调重置)、M14(衣物失败回退与删除并发漏退)、M10(refresh-token access_token 文档矛盾)、M11(公开接口无限流)、M12(上传接口无 body 大小限制)、M13(Outfit.Public 并发语义冲突)。
todos:
- id: fix-xpay-notify
content: 修复虚拟支付推送链路:新增 XPayRefundStatusSuccess 常量,handleRefundNotify 改白名单判断,handleGoodsDeliver 五个校验失败分支返回 70009 触发微信重试
status: pending
- id: fix-benefit-reset
content: 修复补偿任务重置:BenefitGrantTaskReset 保留 retry_count,失败任务增加 5 分钟冷却期后才允许回调重新触发
status: pending
- id: fix-clothes-refund-tx
content: 修复衣物回退漏退:markProcessFailed 重写为行锁单事务(置失败+回退+流水),删除 refundIfExists 与 ClothesMarkProcessFailedIfNotFailed
status: pending
- id: fix-outfit-public-lock
content: 修复穿搭公开竞态:redismodel 新增 KeyLockOutfitPublic,OutfitService.Public 全流程加 Redis 锁串行化
status: pending
- id: add-ratelimit-upload-guard
content: 新增登录/刷新限流中间件(Redis 固定窗口 fail-open)并接入路由,上传接口加 MaxBytesReader 请求体限制
status: pending
- id: sync-docs
content: 同步文档:README 修正 refresh-token access_token 为必填,审查报告 M1/M2/M5/M10/M11/M12/M13/M14 标注已修复并验证编译测试
status: pending
dependencies:
- fix-xpay-notify
- fix-benefit-reset
- fix-clothes-refund-tx
- fix-outfit-public-lock
- add-ratelimit-upload-guard
---
## 产品概述
基于二次审查报告,修复用户确认的 8 个问题:虚拟支付退款误回收(M1)、发货校验失败静默(M2)、补偿任务重试上限失效(M5)、衣物额度漏退窗口(M14),以及限流缺失(M11)、上传接口无请求体限制(M12)、refresh-token 文档矛盾(M10)、穿搭公开并发语义冲突(M13)。全部为后端 Go 代码修复,不涉及 UI。
## 核心功能
- **M1 退款推送白名单**:仅当 `RefundStatus == "SUCCESS"` 才回收权益,空串/未知状态仅记录日志,杜绝误回收
- **M2 发货校验失败应答**:发货推送本地校验失败(订单不存在/渠道不符/道具不符/金额不符/环境不符)返回失败应答触发微信重试,不再静默吞掉资损线索;并发锁竞争分支保持成功应答防重试风暴
- **M5 补偿任务冷却保护**:任务重置不再清零重试计数,且失败任务 5 分钟冷却期外才允许被回调重新触发,重试上限与人工介入机制恢复有效
- **M14 衣物回退事务化**:处理失败标记与额度回退合并为单事务(行锁互斥),消除与删除并发时的漏退窗口,保证恰好回退一次
- **M11 登录/刷新限流**:新增基于 Redis 固定窗口的限流中间件(按 IP,Redis 故障 fail-open),仅应用于登录与刷新两个公开接口,回调接口不限流
- **M12 上传请求体限制**:上传接口在 multipart 解析前用 MaxBytesReader 按配置上限拦截超大请求体
- **M13 穿搭公开串行化**:公开/取消公开流程加 per-outfit Redis 锁,消除并发下「回退公开但另一请求误报成功」的语义冲突
- **M10 文档修正**:README 中 refresh-token 的 access_token 改为必填,与代码实现一致
## 技术方案
### 1. M1 + M2:虚拟支付推送链路修复
- `pkg/wechat/virtualpay.go`:在退款推送结构附近新增常量 `XPayRefundStatusSuccess = "SUCCESS"`
- `internal/service/virtualpay.go` `handleRefundNotify`:条件改为 `if refund.RefundStatus != wechat.XPayRefundStatusSuccess { 记录日志; return nil }`,仅成功状态走 `settleRefundSuccess`;订单不存在分支保持 return nil(退款重试无意义)
- `handleGoodsDeliver`:order not found / pay_channel 不符 / product mismatch / amount mismatch / env mismatch 五个分支由 `return nil` 改为 `return errcode.OrderRealityCheckFailed`(70009 已存在),使 `XPayNotify` 回 `ErrCode:1` 让微信重试;锁竞争失败分支保持 `return nil`(防并发回调重试风暴)
### 2. M5:补偿任务重置保护
- `internal/mysqldao/benefit_grant_task.go` `BenefitGrantTaskReset`:删除 `retry_count: 0`,保留原重试计数
- `internal/service/order.go` `grantBenefitIdempotent` 的 Failed 分支:新增冷却常量 `benefitRetryCooldownSeconds = 300`;当 `nowUnix() - existing.UpdatedAt < 300` 时仅打告警日志跳过,冷却期外才 Reset + executeGrant,防止微信回调重试风暴无限重置任务
### 3. M14:衣物处理失败回退事务化
- `internal/service/clothes.go` 重写 `markProcessFailed` 为单事务(与 `DeleteByUUID` 同模式,已 import gorm/clause):
1. `tx.Clauses(clause.Locking{Strength: "UPDATE"})` 按 clothes_uuid 查衣物;不存在/已软删(RecordNotFound)或已是失败态则 return nil(删除路径已回退或已处理)
2. 置 `image_process_status=5` + `fail_reason`
3. 同事务回退额度:条件更新 `used - 1`(used>=1),回读真实值,写回退流水(Reason="衣物处理失败回退",OrderNo=流水 UUID 兜底)
- 删除 `refundIfExists` 方法与 `internal/mysqldao/clothes.go` 的 `ClothesMarkProcessFailedIfNotFailed`(已确认仅此一处调用)
- 删除路径 `DeleteByUUID` 保持不变:行锁读到失败态即跳过回退,与新事务通过行锁互斥,两路径恰好回退一次
### 4. M13:穿搭公开加 Redis 锁
- `internal/redismodel/lock.go` 新增 `KeyLockOutfitPublic = RedisPrefix + "lock:outfit:public:%s"`(outfit_uuid)
- `internal/service/outfit.go` `Public`:进入后先 `redisdao.TryLock(fmtLockKey(KeyLockOutfitPublic, outfitUUID), 10s)`,失败返回 `errcode.TooManyRequests`;成功后 defer 解锁;锁内保留现有「条件公开 → 扣额 → 失败条件回退」流程,串行化后并发请求语义正确
### 5. M11:限流中间件
- 新增 `internal/middleware/ratelimit.go`:`RateLimit(limit int, window time.Duration) gin.HandlerFunc`
- Redis 固定窗口:key = `redismodel.KeyRateLimit(IP)`,首次 `INCR` 后设 `EXPIRE window`,超过 limit 返回 `errcode.TooManyRequests`(10003,已映射 HTTP 429)
- Redis 异常时 fail-open 放行(与项目降级策略一致),仅告警
- `internal/redismodel/` 新增 key 常量(如 `KeyRateLimit = RedisPrefix + "ratelimit:%s"`)
- `internal/routers/router.go`:对 `/user/wxlogin`、`/user/refresh-token` 两个路由应用,默认阈值常量 `每 IP 每 60 秒 30 次`;微信支付/xpay 回调路由不接限流(避免误伤真实回调)
### 6. M12:上传请求体限制
- `internal/routers/api/v1/clothes.go` `Upload`:在 `c.FormFile` 前设置 `c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, int64(global.UploadSetting.MaxSizeMB+2)*1024*1024)`(留 multipart 边界余量)
- FormFile 失败时:错误信息含 "request body too large" 返回 `errcode.UploadFileTooLarge`(30002),否则保持 `errcode.UploadFileEmpty`
### 7. M10 + 文档同步
- `README.md` 686 行:access_token 由「否(可选,向前兼容)」改为「是(必填)」,说明缺少返回 10000
- `docs/代码审查报告-二次check.md`:M1/M2/M5/M10/M11/M12/M13/M14 标题后标注「已修复」及一行修复说明
### 验证
- `go build ./...` 全量编译通过
- `go test ./pkg/wechat/` 通过
- read_lints 检查修改文件无新增告警
## 关键代码结构
限流中间件签名:
```
// RateLimit 基于 Redis 固定窗口的按 IP 限流中间件(Redis 故障 fail-open)。
func RateLimit(limit int, window time.Duration) gin.HandlerFunc
```
衣物失败回退事务核心流程:
```text
tx 开始
SELECT ... FOR UPDATE clothes(不存在/已删/已失败 → 提交并跳过)
UPDATE clothes SET image_process_status=5, fail_reason=?
UPDATE quota SET used=used-1 WHERE used>=1(条件失败 → 跳过流水)
回读 quota → 写回退流水(唯一索引兜底)
tx 提交
```
@@ -0,0 +1,148 @@
---
name: 修复中高等级代码问题
overview: 修复三次代码审查报告中「中等及以上」问题(排除限流 M11、虚拟订单垃圾堆积 M15):广告激励加配置开关+服务端定价、退款改为管理员操作、XPay 退款反查、vip_type 推导、iOS 退款问询、platform 校验、pay_time 兜底、上传请求体限制。
todos:
- id: adreward-config
content: 新增 AdReward 配置段与错误码,改造广告激励为开关加服务端定价
status: completed
- id: xpay-refund-verify
content: 新增微信侧退款查询并接入 XPay 退款推送反查
status: completed
- id: refund-manual-review
content: 重写自动退款为待审核退款单并兼容已取消订单退款流转
status: completed
- id: vip-type-derive
content: 改造 VIPGrantIdempotent 按最终到期时长推导会员类型
status: completed
- id: virtualpay-fixes
content: 修复 iOS 退款问询、平台真实性校验与支付时间兜底
status: completed
- id: upload-body-limit
content: 上传 handler 入口增加请求体大小限制
status: completed
- id: update-report-verify
content: 更新审查报告修复状态并执行编译与静态检查验证
status: completed
dependencies:
- adreward-config
- xpay-refund-verify
- refund-manual-review
- vip-type-derive
- virtualpay-fixes
- upload-body-limit
---
## 用户需求
针对三次代码审查报告,修复中等及以上问题(排除公开接口限流、虚拟支付订单禁取消带来的垃圾订单堆积两项),并落实两项产品调整:
1. **广告激励配置化与防刷**:广告激励额度在 config 中配置「是否开启」;每种 reward_type 的发放金额改为服务端配置固定值,前端不再传金额,从根源杜绝刷额度。
2. **退款管理员操作**:取消订单自动调微信退款的行为改为生成待审核退款单,由管理员在后台手动退款,所有退款均由管理员操作。
3. **修复范围**:严重问题 S1-new(XPay 退款推送无反查)、S2-new(广告激励刷额度);中等问题 M1-new(自动退款卡死)、M2-new(会员类型覆盖)、M3(iOS 退款问询无条件建议退款)、M4(platform 可伪造)、M9(并发支付时间无兜底)、M12(上传请求体无大小限制)。
## 核心功能
- 广告激励开关关闭时接口返回明确的「未开启」错误;开启后按服务端配置金额发放,前端仅传奖励类型与广告记录 ID。
- XPay 退款推送在处理前反查微信侧退款真实性,未通过拒绝发放并触发微信重试。
- 取消后已付款订单生成待审核退款单,退款回调兼容「已取消」订单的退款流转,且不误回收从未发放的权益。
- 会员续费时会员类型按最终到期时长自动推导,年度会员续月包不再被降级。
- iOS 退款问询按权益消耗情况细化同意/拒绝。
- 虚拟支付发货时校验微信侧真实平台,支付时间统一兜底。
- 上传接口在解析 multipart 前限制请求体大小。
## 视觉/交互
本任务为后端逻辑修复,不涉及界面视觉与交互变更。
## 技术栈
- 语言:Go 1.27
- 框架:Gin v1.12、GORM + MySQL(含 soft_delete)、go-redis v9、viper(仅读 YAML)
- 微信:微信支付 V3 官方 SDK、小程序虚拟支付(XPay)自研 HMAC-SHA256 签名客户端
- 复用项目既有模式:`errcode` 语义化错误、事务幂等、best-effort 告警、`*int64` 配置指针区分「未配置/显式 0」
## 实现方式
按 8 个修复点分 6 组独立实施,均沿既有分层(handler → service → dao / pkg 客户端)推进,不改动架构:
1. **广告激励(S2)**:新增 `AdReward` 配置段与错误码,DTO 移除金额字段,`Grant` 校验开关并从配置取金额。
2. **XPay 退款反查(S1)**:`wechat` 层补退款状态查询,`handleRefundNotify` 在权益回退前反查微信侧真实性。
3. **退款管理员操作(M1)**:`autoRefundCancelledOrder` 重写为待审核退款单,`settleRefundSuccess` 兼容「已取消」订单。
4. **会员类型推导(M2)**:`VIPGrantIdempotent` 原生 SQL 按最终到期时长推导会员类型。
5. **虚拟支付细节(M3/M4/M9)**:iOS 退款问询细化、平台真实性校验、支付时间兜底。
6. **上传限制(M12)**:handler 入口加 `http.MaxBytesReader`。
## 架构设计
```mermaid
flowchart LR
A[config AdReward] --> B[adreward.Grant]
B --> C[QuotaGrant]
D[XPay 退款推送] --> E[handleRefundNotify]
E --> F[QueryOrder 反查]
F --> G[settleRefundSuccess]
H[PayNotify Cancelled] --> I[autoRefundCancelledOrder]
I --> J[待审核 RefundFlowLog]
K[grantVIP] --> L[VIPGrantIdempotent SQL 推导类型]
M[Upload handler] --> N[MaxBytesReader] --> O[service.Upload]
```
## 目录结构
以下为本次修改/新增的文件:
```
zt-closet-backend/
├── configs/
│ └── config.example.yaml # [MODIFY] 新增 AdReward 配置段(Enable + 各类型金额,含注释)
├── global/
│ └── setting.go # [MODIFY] 注册 AdRewardSetting 全局指针
├── main.go # [MODIFY] setupSetting() 读取 AdReward 段并做默认值兜底
├── pkg/
│ ├── setting/
│ │ └── section.go # [MODIFY] 新增 AdRewardSettingS 结构体
│ ├── errcode/
│ │ └── quota_code.go # [MODIFY] 新增 AdRewardDisabled 错误码
│ └── wechat/
│ └── virtualpay.go # [MODIFY] XPayOrderInfo 补退款状态字段 + 退款状态判断
└── internal/
├── dto/
│ └── adreward.go # [MODIFY] 移除 Amount 字段
├── mysqldao/
│ └── user.go # [MODIFY] VIPGrantIdempotent 按最终到期时长推导 vip_type
├── routers/api/v1/
│ └── clothes.go # [MODIFY] Upload handler 入口加 MaxBytesReader
└── service/
├── adreward.go # [MODIFY] Grant 校验开关 + 服务端定价
├── virtualpay.go # [MODIFY] 退款反查、iOS 问询细化、platform 校验、pay_time 兜底
└── order.go # [MODIFY] autoRefundCancelledOrder 改待审核、settleRefundSuccess 兼容 Cancelled、grantVIP 调整
```
## 实现注意事项
- **S1 字段待确认**:XPay 官方退款查询/平台字段未联调确认,新增字段与状态枚举处必须保留「待确认」注释,不臆造字段名;未通过反查一律 `OrderRealityCheckFailed(70009)` + `ErrCode:1` 触发微信重试。
- **M1 防误回收**:「已取消但已付款」的订单权益从未发放,`settleRefundSuccess` 对该场景只做 `Cancelled->Refunded` 流转与退款信息记录,**跳过 `revokeOrderBenefits`**,避免误扣用户其他来源的会员天数/额度凭证。
- **M2 保持 Trial**:`ensureTrialVIP` 复用 `VIPGrantIdempotent` 传 `Trial(4)`,SQL 推导时须保留 Trial 不参与年度/月度推导。
- **M2 SQL 求值顺序**:依赖 MySQL 单表 UPDATE SET 从左到右求值,先计算 `vip_expire` 再推导 `vip_type`,与现有「显式控制 SET 顺序」注释保持一致。
- **M4 不信任前端**:platform 的资损判断改为以微信侧反查结果为准,保留 env 校验。
- **M12 余量**:`MaxBytesReader` 上限按 `MaxSizeMB*1024*1024 + 1MB`(multipart 边界开销),超限返回上传超限语义错误,避免误伤合法请求。
- **安全**:广告激励金额服务端定价后,不再接收客户端金额;退款反查沿 HMAC 签名服务端 API,无法被伪造。
- **验证**:全部改动完成后 `go build ./...` 与 `go vet ./...` 通过,并核对 linter。
## 推荐扩展
### Skill
- **lsp-code-analysis**
- 用途:追踪 `settleRefundSuccess`、`VIPGrantIdempotent`、`QuotaGrant`、`verifyXPayOrderPaid` 等关键符号的定义与全部调用点,确认改动影响面,避免遗漏调用链或误改 Trial 场景。
- 预期结果:明确各修复点的完整调用链,验证「Cancelled 订单跳过权益回退」「Trial 保持」等分支覆盖所有相关调用方。
### SubAgent
- **code-explorer**
- 用途:跨目录核对涉及文件与调用点,补充确认 `errcode` 分段、`QuotaGrantDao` 查询能力、`RefundFlowLog` 字段等实现细节。
- 预期结果:产出修复所需的精确文件与函数清单,支撑分组实施。
@@ -0,0 +1,228 @@
---
name: 修复全部 39 项代码缺陷
overview: 根据代码审查报告,修复全部 39 项业务逻辑与技术逻辑 bug。核心是资损链路(退款回退权益、取消后付款自动退款、权益发放幂等化)、安全修复(密钥环境变量化、JWT 校验、错误码映射)与正确性修复(VIP 续费 CASE 求值顺序、Redis 超时单位、配额唯一约束等)。鉴权降级按用户决策维持 fail-open 现状,不改代码。
todos:
- id: fix-dal
content: 修复数据访问层:VIP 续费 CASE 求值顺序、QuotaAddTotal 命中检查、新增 QuotaReduceTotal/UserReduceVIP、配额与用户唯一索引 tag
status: completed
- id: fix-redis
content: 修复 Redis 数据层:点赞/收藏未命中回源语义、计数 TTL、未读数对账、token 集合 TTL、清理 QuotaRemain 死缓存
status: completed
- id: fix-benefit
content: 修复权益发放幂等化:发放与流水同事务、订单快照、Paid 后凭证恢复、retry_count 原子自增、唯一冲突区分
status: completed
dependencies:
- fix-dal
- id: fix-refund
content: 修复退款链路:退款成功回退权益、取消后付款自动微信退款、退款申请与流水幂等、grantQuota 并发建行与流水失真
status: completed
dependencies:
- fix-dal
- id: fix-business
content: 修复其他业务逻辑:登录并发与封禁、点赞通知去重、穿搭公开锁、衣物回退竞态、衣柜分享明细、通知已读与 AI 锁错误
status: completed
dependencies:
- fix-redis
- id: fix-infra
content: 修复基础设施与安全:Redis 超时单位、密钥环境变量化、CORS、错误码映射、端口绑定、优雅关闭、panic 恢复、JWT 校验、COS 与日志等
status: completed
- id: fix-dto-migrate-verify
content: 补充 DTO 校验、新增唯一索引迁移 SQL,并执行 go build 与 go vet 验证全部修复
status: completed
dependencies:
- fix-dal
- fix-redis
- fix-benefit
- fix-refund
- fix-business
- fix-infra
---
## 用户需求
修复此前全量代码审查发现的 **39 项 bug**(业务逻辑 15 项 + 技术逻辑 24 项),不引入新的架构模式,保持现有 Gin + GORM + Redis 分层风格,修复后项目可正常编译。
## 关键决策(已确认)
1. **修复范围**:全部 39 项(含低危的边界校验、日志、代码质量问题)。
2. **退款权益**:订单全额退款成功后,**回退已发放的会员天数与各类额度**。
- 额度回退:仅回退未使用部分,已用部分不回退(`total = max(used, total - n)`)。
- VIP 回退:扣减会员天数,但到期时间不倒退到过去(`vip_expire = max(now, vip_expire - n*86400)`)。
3. **取消后付款**:订单本地已取消但用户实际已付款时,**自动发起微信退款**(调用已存在的 `wechat.Refund`)。
4. **鉴权降级**:Redis 故障时 access token 会话校验**维持 fail-open 现状,不改代码**(仅在注释中记录该决策)。
## 修复原则
- 优先修复资损与安全高危项(退款回退、权益重复发放、自动退款、密钥、JWT)。
- 幂等性修复统一采用「唯一索引兜底 + 流水先写 + 累加同事务」的成熟模式。
- 每个模块的修复保持小步、可编译,不跨模块做无关重构。
- 新增数据库迁移 SQL 放入 `migrations/`,不动已有迁移文件。
## 技术栈
- Go 1.27、Gin v1.12、GORM v1.31(MySQL + soft_delete 插件)、go-redis v9、golang-jwt/v5、OpenTelemetry、微信支付 apiv3(`wechatpay-go` 官方 SDK)、腾讯云 COS、viper。
## 实现方法
采用「分层分模块」修复策略,自底向上:数据访问层 → 资损链路 → 业务逻辑 → 基础设施 → DTO 校验与迁移。核心思路:
### 1. 退款回退权益(A1)
- 新增 `QuotaDao.QuotaReduceTotal`:`UPDATE user_quotas SET total = GREATEST(used, total - ?) WHERE user_id = ? AND quota_type = ?`,检查 `RowsAffected` 防静默丢失。
- 新增 `UserDao.UserReduceVIP`:`UPDATE users SET vip_expire = GREATEST(?, vip_expire - ?) WHERE id = ?`(now, days*86400)。
- 幂等:仅在退款回调 `SUCCESS` 且流水从非成功态流转到成功态时执行回退;回退写 `QuotaFlowLog`/`VIPFlowLog`,新增 `ChangeType` 常量(退款回收),并以 `order_no + 回收类型` 唯一索引兜底防重复回退。
### 2. 取消后付款自动退款(A2)
- `PayNotify` 的 `OrderStatusCancelled` 分支:生成 `refundNo` → 创建 `RefundFlowLog(status=Processing)` → 调用 `wechat.Refund(orderNo, refundNo, payAmount, payAmount, reason)` → 记录微信退款单号。
- 幂等:创建退款流水前按 `order_no` 查重(已有非失败退款则跳过),并为 `refund_flow_logs` 增加 `order_no` 唯一约束(部分退款场景暂不支持,本项目为全额退款)。
### 3. 权益发放幂等化(A3/A4/A6/B11/B12)
- 将「不可逆累加」与「幂等凭证写入」放入**同一 DB 事务**:
- VIP:事务内先写 `VIPFlowLog`(唯一索引 `order_no + change_type`),冲突即视为已发放;成功后再 `UserRenewVIPAtomic`。
- 额度:事务内先写 `QuotaFlowLog`(唯一索引 `order_no + quota_type + change_type`),冲突即视为已发放;成功后再 `QuotaAddTotal`。
- `grantOrderBenefits` 改用**订单快照字段**(`order.MemberDays`)而非实时读 `goods`(A5);订单需在快照中补充各类额度字段或从 `benefit_grant_tasks` 的 `Amount` 取值。
- 崩溃恢复(A6):`PayNotify` 的 `OrderStatusPaid` 幂等分支不再直接 return,而是检查该订单是否已存在 `benefit_grant_tasks` 凭证,若无则补建并发起发放(幂等)。
- `retry_count` 改为 `retry_count = retry_count + 1` 原子自增(B11)。
- `grantBenefitIdempotent` 用错误类型区分「唯一键冲突」与「其他 DB 错误」(B12)。
### 4. VIP 续费 CASE 修复(B2)
- `UserRenewVIPAtomic` 改为原生 SQL(`Exec`)显式控制 SET 顺序,使 `vip_start` 先于 `vip_expire` 求值,读到旧 `vip_expire`,恢复「过期续费重置 vip_start」语义。
### 5. 配额与 Redis 修复(A7/B5/B6/B7/B13/B29/B30/B31)
- `QuotaAddTotal` 补 `RowsAffected` 检查;`user_quotas` 模型加 `(user_id, quota_type)` 唯一索引 tag,并新增迁移 SQL。
- `grantQuota` 创建分支改为「先插入(唯一索引冲突则重查后走累加)」;流水 `oldValue/newValue` 改为在原子更新后回读真实值写入。
- 点赞/收藏 `SIsMember` 在 key 不存在时返回 `redis.Nil` 语义(或 DAO 返回 `(false, redis.Nil)`),使 service 层正确回落 MySQL。
- 访问计数 `INCR` 配套首次设置 TTL;未读数 key 改用 DB 对账回源;token 集合 TTL 只在首次 SAdd 时设置;删除或补全只写不读的 `QuotaRemain`。
### 6. 基础设施与安全(B3/B4/B8/B9/B14-B19/B22-B28/B36)
- Redis 超时统一单位:代码改 `* time.Second`,与配置注释「单位s」对齐(B3)。
- 密钥环境变量化:`setting` 增加 `AutomaticEnv` + `SetEnvKeyReplacer`;`config.yaml` 敏感值改为占位/移除,保留 `config.example.yaml` 模板(B4/B25)。
- CORS 收窄来源白名单(B8);错误码补充 `Forbidden→403`、`TooManyRequests→429`、`ClassifyFailed→500` 等映射(B9/B24)。
- `ListenAndServe` 立即错误触发退出(B14);优雅关闭拆分 HTTP 与 OTel context(B15);COS nil 保护(B16);4 个后台 goroutine 加 `defer recover()`(B17);配置段 nil 校验(B18);JWT 校验 issuer 且拒绝空密钥(B19);日志目录 `MkdirAll`(B23);`url.Parse` 错误处理(B22);OTel 失败回滚全局 provider(B26);清理重复 `UserDefaultQuota` 配置段(B27);时间单位避免 duration 双重乘算(B28);访问日志 query 脱敏(B36)。
### 7. 其余业务逻辑(A8-A13/B10/B20/B35/B37/B38/B39)
- 登录并发注册:`wx_open_id` 唯一索引 + duplicate 冲突后重查继续登录;登录拦截临时/永久封禁(A8/A13)。
- 点赞通知去重(A9);穿搭公开加分布式锁 + 条件更新(A10);衣物删除回退改原子判定(A11);衣柜分享明细失败返回错误(A12)。
- 刷新令牌强制携带旧 access token 并吊销(B10);通知已读条件更新防重复扣减(B20);登录与鉴权降级策略统一为 fail-open 并注释(B35);AI 锁错误区分处理(B37);穿搭明细更新包事务(B38);通知未读数递增错误处理(B39)。
## 架构设计
保持现有分层(routers → service → mysqldao/redisdao → mysqlmodel/redismodel),不新增架构层。核心修复后的资金/权益流转如下:
```mermaid
flowchart TD
A[微信支付回调 PayNotify] --> B{订单状态}
B -->|Pending| C[条件更新 Pending->Paid]
C --> D[创建权益发放凭证 benefit_grant_tasks]
D --> E[事务: 写流水唯一索引 + 累加权益]
E --> F[权益到账]
B -->|Cancelled| G[创建退款流水 + 调 wechat.Refund]
G --> H[退款回调 RefundNotify]
B -->|Paid 但无凭证| D
H -->|SUCCESS| I[事务: 回退额度 GREATEST + 回退VIP天数]
I --> J[写回退流水 幂等防重]
J --> K[订单置 Refunded]
```
## 目录结构
以下为本次修复涉及的文件(MODIFY 表示修改,NEW 表示新增):
```
/root/zt-closet-backend
├── main.go # [MODIFY] B14/B15/B16/B17/B18/B23/B28 端口绑定/优雅关闭/panic恢复/配置nil/日志目录/时间单位
├── configs/
│ ├── config.yaml # [MODIFY] B3/B4/B27 超时单位、密钥占位、清理重复段
│ └── config.example.yaml # [MODIFY] 同步模板更新
├── global/ # 视 B18 需要调整设置读取
├── migrations/
│ └── 20260907_bugfix_constraints.sql # [NEW] 唯一索引:user_quotas、quota_flow_logs、vip_flow_logs、refund_flow_logs、users.wx_open_id
├── internal/
│ ├── mysqlmodel/
│ │ ├── quota.go # [MODIFY] A7 唯一索引 tag
│ │ ├── user.go # [MODIFY] A8 wx_open_id 唯一索引 tag
│ │ ├── quotaflowlog.go # [MODIFY] A4 唯一索引 tag
│ │ ├── vipflowlog.go # [MODIFY] A3 唯一索引 tag
│ │ └── refundflowlog.go # [MODIFY] A14 唯一索引 tag
│ ├── mysqldao/
│ │ ├── user.go # [MODIFY] B2 VIP CASE、新增 UserReduceVIP
│ │ └── quota.go # [MODIFY] B5/A7、新增 QuotaReduceTotal
│ ├── redisdao/
│ │ ├── like.go / collect.go # [MODIFY] B6 未命中语义
│ │ ├── closet.go / outfit.go # [MODIFY] B7 计数 TTL
│ │ ├── quota.go # [MODIFY] B29 死缓存清理
│ │ ├── notification.go # [MODIFY] B30 未读数对账
│ │ └── auth.go # [MODIFY] B31 token 集合 TTL
│ ├── redismodel/
│ │ └── keys.go # [MODIFY] B3 超时单位
│ ├── service/
│ │ ├── order.go # [MODIFY] A1/A2/A5/A6/A14/A15/B12/B21 退款回退/自动退款/快照/退款幂等
│ │ ├── benefit_compensation.go # [MODIFY] A4/B11 幂等发放/retry_count
│ │ ├── service.go # [MODIFY] A7/B13 grantQuota 并发与流水
│ │ ├── constants.go # [MODIFY] 新增退款回收 ChangeType 常量
│ │ ├── user.go # [MODIFY] A8/A13/B10/B35 登录/封禁/刷新/降级
│ │ ├── like.go # [MODIFY] A9 点赞通知去重
│ │ ├── outfit.go # [MODIFY] A10/B38 公开锁/明细事务
│ │ ├── clothes.go # [MODIFY] A11 删除回退竞态
│ │ ├── closet.go # [MODIFY] A12 明细失败处理
│ │ ├── notification.go # [MODIFY] B20/B39 已读幂等/未读数
│ │ └── airecommend.go # [MODIFY] B37 锁错误处理
│ ├── dto/
│ │ ├── closet.go # [MODIFY] B32 过期时间校验
│ │ ├── user.go # [MODIFY] B33 binding tag
│ │ └── outfit.go / clothes.go # [MODIFY] B34 文本长度上限
│ └── middleware/
│ ├── cors.go # [MODIFY] B8 来源白名单
│ ├── logger.go # [MODIFY] B36 query 脱敏
│ └── lzcwyjwt.go # [MODIFY] B1 仅注释说明 fail-open 决策
├── pkg/
│ ├── setting/setting.go # [MODIFY] B25 环境变量支持
│ ├── lzcwyjwt/lzcwyjwt.go # [MODIFY] B19 issuer/密钥校验
│ ├── errcode/errcode.go # [MODIFY] B9/B24 状态码映射
│ ├── cos/cosclient.go # [MODIFY] B22 url.Parse 错误
│ ├── tracing/provider.go # [MODIFY] B26 失败回滚
│ └── upload/upload.go # [MODIFY] B16 COS nil 保护
└── pkg/tokenhub/scheduler.go / fortune.go # [MODIFY] B17 panic 恢复
```
## 关键代码结构
新增 DAO 方法(接口级定义,实现时遵循现有 DAO 风格):
```
// QuotaReduceTotal 原子扣减总配额(退款回收),保证 total 不低于 used,返回是否命中。
func (d *QuotaDao) QuotaReduceTotal(ctx context.Context, userID int64, quotaType int8, n int64) (bool, error)
// UserReduceVIP 原子回退会员到期时间,保证不早于 now,返回是否命中。
func (d *UserDao) UserReduceVIP(ctx context.Context, userID int64, now int64, memberDays int64) (bool, error)
```
## 实施注意事项
- **兼容性**:新增唯一索引前需评估存量数据是否已有重复(`user_quotas`、`quota_flow_logs`、`vip_flow_logs`、`refund_flow_logs`),迁移 SQL 需先清理/合并重复再建索引。
- **事务边界**:GORM 事务使用 `db.Transaction(func(tx *gorm.DB) error {...})`,避免跨 service 的裸事务。
- **回退幂等**:退款回退与权益发放的所有「写流水 + 累加/回退」必须在同一事务内,唯一索引冲突通过错误类型识别并视为已处理。
- **日志与安全**:环境变量化后,禁止在日志打印 openid/金额/密钥;`config.yaml` 敏感值改为 `${ENV_VAR}` 占位。
- **验证**:修复完成后执行 `go build ./...` 与 `go vet ./...`,确保可编译且无新增告警;不强制写测试,但不破坏现有逻辑。
## 使用的扩展
### SubAgent
- **code-explorer**
- 用途:在执行各模块修复前,快速确认相关文件的完整实现、字段定义与调用点(如 `grantQuota`/`doGrantBenefit`/`RefundNotify` 的全部调用方),确保修改不遗漏、不引入冲突。
- 预期产出:每个修复模块的精确修改点清单与调用关系,支撑小步、可编译的改动。
### Skill
- **lsp-code-analysis**
- 用途:修复后对关键函数(`consumeQuota`、`grantQuota`、`doGrantBenefit`、`UserRenewVIPAtomic`、`QuotaReduceTotal`、`UserReduceVIP`)做定义/引用/调用层次追踪,验证幂等与回退链路没有遗漏调用点或破坏现有引用。
- 预期产出:关键函数完整调用链,确认修复后无断链、无重复发放/回退缺口。
@@ -0,0 +1,83 @@
---
name: 修复分类模型手表识别错误
overview: 优化 Fashion-CLIP 分类服务的零样本提示词(大类「配饰」覆盖手表、强化「手表」子类别特征、纠正与「鞋/手链」的混淆),重启分类服务并用手表测试图验证识别结果。
todos:
- id: tune-prompts
content: 优化 main.py 大类「配饰/鞋」与配饰组「手表/手链」提示词
status: completed
- id: restart-service
content: 重启分类服务进程并轮询健康检查直至恢复
status: completed
dependencies:
- tune-prompts
- id: verify-regression
content: 用测试图验证手表识别并回归验证正常衣物分类不受影响
status: completed
dependencies:
- restart-service
- id: iterate-archive
content: 按验证结果迭代微调提示词并归档测试图到 resources
status: completed
dependencies:
- verify-regression
---
## 产品概述
修复衣柜小程序的衣物分类模型无法识别「手表」的问题。实测复现:上传手表图片后,分类服务将手表误识别为「鞋」大类(置信度 0.9471),正确大类「配饰」仅 0.0013,导致子类别轮不到「手表」。
## 核心功能
- 手表图片分类结果正确:大类输出「配饰」、子类别输出「手表」
- 正常衣物图片分类结果不受影响(回归基线:上装/衬衫)
- 分类服务重启后健康检查恢复,上传→抠图→分类链路保持正常
## 技术栈
- 分类服务:Python FastAPI + transformers + patrickjohncyh/fashion-clip(零样本 CLIP 分类,端口 8000)
- 修改范围:仅 `thirdparty/classification_server/main.py` 的提示词字典,不改推理逻辑、响应结构、后端 Go 代码
## 实现方案
采用零样本 CLIP 提示词工程修复,三处针对性调整:
1. **大类「配饰」提示词**(`CATEGORY_PROMPTS`,第 47 行):加入 watch/wristwatch 描述,强化「小而独立的腕部配饰、非衣物主体」语义,补齐手表完全未覆盖的问题。
2. **大类「鞋」提示词**(第 46 行):补充强视觉特征(成双出现 pair、鞋底 sole、包裹脚的立体鞋型),压低手表白底商品图与「鞋」的相似度;微调幅度保守,避免影响正常鞋类识别。
3. **「配饰」组内子类别提示词**(`SUBCATEGORY_GROUPS["配饰"]`,第 115、122 行):
- 「手表」强化表盘特征:round watch face、circular dial with numerals and hands、crown、metal or leather strap;
- 「手链」显式排除表盘:no watch face、no dial、purely decorative,消除两者互选错误。
关键决策:
- 只改提示词字符串,每组 label 数量不变,单次前向 batch 规模不变,推理性能无变化;
- 响应 JSON 字段结构不变,`pkg/classification/classification.go` 与 `internal/service/clothes.go` 无需改动,完全向后兼容;
- 大类修复与子类别修复同步完成,一次生效,避免「大类对了但子类错」的二次返工。
## 实施细节
### 服务重启流程(已实测进程信息)
- 当前分类服务 PID 3156827,cwd=/root/zt-closet-backend,启动命令 `thirdparty/matting_server/.venv/bin/python thirdparty/classification_server/main.py`(复用 matting_server 的 .venv,无 systemd 托管;模型在模块 import 时加载,改代码必须重启)。
- 重启方式:kill 3156827 后,在项目根目录按原解释器与原命令用 nohup 拉起,日志重定向到 `storage/logs/classification.log`;轮询 `curl http://127.0.0.1:8000/healthz` 直至返回 ok(模型重载约 1 分钟)。
- 严禁误动抠图服务 PID 3144600(cwd=thirdparty/matting_server)。
- 重启窗口内后端有 retryCall(MaxRetry=3)兜底,短暂不可用影响可控。
### 验证与回归
- 手表测试图 `/root/zt-closet-backend/0d98288268054879d481a7bb1d2dd7af.jpg`:期望 category_main=配饰、category_sub=手表;
- 回归图 `resources/微信图片_20260907235100_780_53.jpg`:期望维持 category_main=上装、category_sub=衬衫(当前基线 0.5028/0.5024);
- 若验证不达标,迭代微调提示词措辞(最多 2~3 轮),每轮重启后重测。
### 目录结构
```
/root/zt-closet-backend/
├── thirdparty/classification_server/
│ └── main.py # [MODIFY] 修改 CATEGORY_PROMPTS 的「配饰」「鞋」提示词,及 SUBCATEGORY_GROUPS["配饰"] 的「手表」「手链」提示词
├── storage/logs/classification.log # [NEW] 重启后的分类服务日志(nohup 重定向)
├── 0d98288268054879d481a7bb1d2dd7af.jpg # [MOVE] 验证通过后移入 resources/ 留作回归样本
└── resources/
├── 微信图片_20260907235100_780_53.jpg # [不变] 回归验证用正常衣物图
└── 0d98288268054879d481a7bb1d2dd7af.jpg # [NEW] 归档的手表测试图
```
@@ -0,0 +1,93 @@
---
name: 修复支付业务逻辑问题
overview: 修复二次审查发现的支付业务问题:2 个资损级缺陷(虚拟支付订单取消漏洞、道具价格不同步)+ 5 个逻辑缺陷(iOS 最低价校验、退款状态判断、PaidTime 兜底等)+ 2 处文档同步,全部在现有支付代码上做定点修复。
todos:
- id: fix-p0-1
content: 修复取消漏洞:新增 70007 错误码、Cancel 拒绝虚拟支付订单、handleGoodsDeliver 补 Cancelled 补发货分支
status: completed
- id: fix-p0-2
content: 修复道具价格不同步:GoodsListVirtualSync 改为全量查询上架商品并更新同步注释与运营约定
status: completed
- id: fix-p1
content: 修复 P1 缺陷:新增 70008 与 iOS 最低金额校验、退款推送状态判断、PaidTime 兜底、策略注释
status: completed
- id: sync-p2
content: 同步 P2:OrderResponse 加 pay_channel 字段与映射,接口文档补退款路径、错误码与道具刷新说明
status: completed
- id: verify
content: 执行 go build、go vet、go test 与 read_lints 全量验证
status: completed
dependencies:
- fix-p0-1
- fix-p0-2
- fix-p1
- sync-p2
---
## 产品概述
对支付业务逻辑二次审查发现的 2 个资损级问题、5 个逻辑缺陷与 2 个文档问题做定点修复,不改变支付链路整体架构与权益发放回退幂等机制。
## 核心修复内容
### P0 资损级
- P0-1 虚拟支付订单取消漏洞:虚拟支付订单(pay_channel=2)不可再取消(新增 70007 错误码);发货推送补「已取消订单补发货」防御分支,杜绝「用户取消后继续支付、钱已扣但权益永不发放」。
- P0-2 道具价格不同步:道具同步改为全量刷新全部上架商品(含已发布),每次启动重新上传发布道具信息与价格,保证微信侧道具价格与本地改价一致,避免金额校验失败导致付款不发货。
### P1 逻辑缺陷
- iOS 下单校验最低 1 元(100 分),低于此金额拒绝(70008)。
- 退款推送仅「成功」状态才回退权益,防中间状态误回退。
- 发货推送 PaidTime 缺失或异常时兜底为当前时间。
- iOS 退款问询保持默认建议退款,补充策略注释。
### P2 文档与提示
- 订单响应增加 pay_channel 字段(1=微信支付 2=虚拟支付)。
- 接口文档补充虚拟支付退款操作路径(虚拟支付后台 Midas)、道具价格刷新运营约定、新错误码说明。
## 技术方案
### 修复策略
定点修复,复用现有架构与幂等机制:不动 `benefit_grant_tasks` 发放凭证、唯一索引幂等、`settleRefundSuccess`/`revokeOrderBenefits` 回退链路;不新增表结构与迁移 SQL。
### 关键改动
**1. 错误码(pkg/errcode/order_code.go)**
- 新增 `VirtualOrderCancelNotAllowed = NewError(70007, "虚拟支付订单不支持取消")`
- 新增 `VirtualPayAmountTooLow = NewError(70008, "iOS 虚拟支付最低 1 元")`
- 70007/70008 空闲无冲突(70005/70006 已占用)。
**2. 取消漏洞(internal/service/order.go + virtualpay.go)**
- `Cancel`:`getOwnedOrder` 后加 `if order.PayChannel == PayChannelVirtual { return nil, errcode.VirtualOrderCancelNotAllowed }`(置于状态校验前,统一拒绝提示);同时避免对虚拟订单误调 JSAPI `CloseOrder`。
- `handleGoodsDeliver`:switch 增加 `case OrderStatusCancelled:`,条件流转 `Cancelled → Paid` 成功后 `finalizeVirtualPaidOrder` 补发货,与 `Expired` 分支并列(防御历史数据与竞态)。
**3. 道具全量同步(internal/mysqldao/goods.go + virtualpay.go)**
- `GoodsListVirtualSync` 查询条件改为 `Where("status = ?", 1)`,返回全部上架商品(含已发布)。
- `SyncVirtualGoods`/`syncOneGoods` 注释更新为全量同步语义:重复 upload+publish 同 goods_id 视为更新道具信息与价格;商品 SKU 少(5 类),启动一次开销可忽略;失败仍置 `GoodsProductPublishFail`。
**4. P1 系列(internal/service/virtualpay.go)**
- `VirtualOrderCreate`:goods 上架校验后、落单前加 `if request.Platform == XPayPlatformIOS && payAmount < 100` → 返回 70008。
- `handleRefundNotify`:仅 `RefundStatus == ""` 或 `== "SUCCESS"` 时调用 `settleRefundSuccess`,其他状态 `slog.WarnContext` 记录并返回(枚举联调确认)。
- `finalizeVirtualPaidOrder`:`paidTime <= 0` 时兜底 `nowUnix()`。
- `handleIOSRefundQuery`:补充策略注释(默认建议退款、已用权益不回退、后续可按权益使用情况细化拒绝)。
**5. 文档与 DTO(internal/dto/order.go + service/order.go + 接口文档.md)**
- `OrderResponse` 加 `PayChannel int8 json:"pay_channel"`;`buildOrderResponse` 加映射。
- 接口文档:订单对象(4.5 节)补 pay_channel 字段;退款章节补「虚拟支付订单请在虚拟支付后台(Midas)发起退款」;虚拟支付下单章节补 70007/70008 说明与「改价后重启服务自动刷新道具价格」运营约定;错误码表补 70007/70008。
## 验证
- `go build ./...`、`go vet ./...`、`go test ./pkg/wechat/` 通过。
- read_lints 检查改动文件无新增错误。
## 约束
- 不改支付架构、权益幂等机制、表结构;不修改 configs/config.yaml(本地文件,含非法 YAML)。
@@ -0,0 +1,92 @@
---
name: 允许免费额度配置为 0
overview: 修复新用户免费额度配置无法设为 0 的问题:配置字段改为 *int64 以区分「未配置」与「显式 0」,未配置回落内置默认,显式 0 生效为 0。
todos:
- id: change-setting-fields
content: 将 UserDefaultQuotaSettingS 四字段改为 *int64 并更新注释
status: completed
- id: fix-default-quota-value
content: 改 defaultQuotaValue 为指针判空判据,允许 0 生效
status: completed
dependencies:
- change-setting-fields
- id: sync-constants-comment
content: 同步 constants.go 内置默认常量注释语义
status: completed
dependencies:
- fix-default-quota-value
- id: sync-docs
content: 同步 README、config.example.yaml 与接口文档说明
status: completed
dependencies:
- fix-default-quota-value
- id: build-verify
content: 执行 go build ./... 与 go vet ./... 验证编译
status: completed
dependencies:
- sync-constants-comment
- sync-docs
- id: review-config-usage
content: 用 [subagent:code-explorer] 复核配置消费点与文档无旧语义残留
status: completed
dependencies:
- build-verify
---
## 产品概述
修复新用户免费额度初始化的配置语义缺陷:原有实现把「配置为 0」误判为「未配置」而回落内置默认,导致无法将某类免费额度配成 0(例如要求新用户 AI 推荐额度为 0、必须购买后才可用)。
## 核心功能
- **区分「未配置」与「显式 0」**:`UserDefaultQuota` 四个配置字段改为指针类型,未配置(nil)回落内置默认 20/3/10/5,显式配置 0 则生效为 0
- **保持兜底能力**:整段配置缺失、单个键缺失、配置为负数三种情况仍回落内置默认,不破坏既有部署
- **文档同步**:README 配置项说明、config.example.yaml 注释、接口文档额度说明统一为「允许配 0,未配置回落默认」
- **行为不变**:四类额度的初始化流程、流水记录、唯一索引幂等逻辑完全不动
## 技术栈
- Go 1.27,模块 `git.lzcwy.cn/zhengchiliu/zt-closet-backend`
- 配置:`pkg/setting`(viper 只读 `configs/config.yaml`,`ReadSection` 走 `UnmarshalKey`/mapstructure)
- 消费点唯一:`internal/service/quota.go` 的 `defaultQuotaValue`(grep 已确认无其它引用)
## 实现方案
采用**指针字段区分三态**:把 `UserDefaultQuotaSettingS` 的 `Cos/AI/OutfitShare/ClosetShare` 由 `int64` 改为 `*int64`。viper 解码时键存在则分配指针(含 0),键不存在则保持 nil,从而在类型层面区分「未配置」与「配置为 0」。
取值判据(`defaultQuotaValue`):
- `cfg == nil`(整段缺失)→ 内置默认
- `cfg.X == nil`(该键缺失)→ 内置默认
- `*cfg.X >= 0` → 用配置值(**0 生效,本次修复目标**)
- `*cfg.X < 0` → 内置默认(防御:负数会让 `total` 为负,破坏额度校验)
关键决策与取舍:
1. **用 `*int64` 而非把判据改成 `>= 0`**:后者能让 0 生效,但「键缺失」解码后同样是 0,会静默把兜底默认值变成 0——对现有未显式配置的环境是破坏性变更。指针方案零歧义,代价仅是多一层判空。
2. **用指针而非 `viper.IsSet` 逐键查询**:配置结构仍由结构体承载,`ReadSection` 用法与其它配置段一致,不引入第二套读取方式,符合现有架构。
3. **负数回落而非报错**:保持 best-effort 风格(与额度初始化失败仅告警不阻断一致),不引入启动期配置校验的新失败路径。
4. **保留内置常量值 20/3/10/5 不变**:只改判据与注释,不动 `ensureDefaultQuota` 的创建+流水事务逻辑。
性能与可靠性:该路径仅在首次登录注册时执行 4 次,指针判空为 O(1) 无额外开销;风险点仅为指针解引用,通过判空分支覆盖,并有 `go build`/`go vet` 兜底。
## 实现要点(防回归)
- 改字段类型后必须全量编译,`global.UserDefaultQuotaSetting` 的其它消费点若有解引用会直接编译报错(当前仅 quota.go 一处)。
- 判空顺序必须为 `cfg != nil && cfg.X != nil && *cfg.X >= 0`,避免 nil 解引用 panic。
- 注释需同步:结构体注释「值 <=0 视为未设置」、`constants.go:42`「配置缺省或 <=0 时回落」、`defaultQuotaValue` 函数注释,三处都要改成新语义,否则后续维护者会按旧语义改回去。
- 不要扩大范围:`Upload.MaxSizeMB`、`MattingModel.MaxRetry` 等其它 `<=0` 兜底语义不同(0 无业务意义),本次不动,仅在结论中提示。
- 不改动 `configs/config.yaml`(用户本地文件,已被 .gitignore 忽略)。
## 目录结构
```
pkg/setting/section.go # [MODIFY] UserDefaultQuotaSettingS 四字段 int64 → *int64;
# 注释改为「nil=未配置回落默认;非 nil 生效,允许 0」
internal/service/quota.go # [MODIFY] defaultQuotaValue 判据改为指针判空 + >=0 生效;
# 函数注释同步(未配置/负数回落,0 生效)
internal/service/constants.go # [MODIFY] 第 42 行注释「配置缺省或 <=0 时回落」→「未配置(nil)或负数时回落」
README.md # [MODIFY] 第 195 行配置表说明改为「未配置回落内置默认 20/3/10/5;可显式配 0」
configs/config.example.yaml # [MODIFY] UserDefaultQuota 段注释补充「可为 0 表示不赠送;留空则回落内置默认」
接口文档.md # [MODIFY] 第 488/1432/1446 行及 5.2 节额度表格补「可由 UserDefaultQuota 配置,允许为 0」
```
@@ -0,0 +1,94 @@
---
name: 全量代码二次审查报告
overview: 对 zt-closet-backend 全量 Go 代码进行二次审查(所有模块覆盖 + 支付/虚拟支付链路深挖),输出按严重程度分级的业务逻辑/技术逻辑问题清单报告,不修改任何代码。
todos:
- id: explore-payment-chain
content: Use [subagent:code-explorer] to explore full codebase and read all payment-chain files (virtualpay, pay, order, benefit, quota, migration)
status: completed
- id: review-xpay
content: "Review virtual pay XPay chain: signing, notify push, goods sync, delivery/refund/iOS query handlers, session_key dependency"
status: completed
dependencies:
- explore-payment-chain
- id: review-wxpay-order
content: "Use [skill:lsp-code-analysis] to review WeChat Pay V3 and order state machine: callbacks, cancel, expire scan, race fallback"
status: completed
dependencies:
- explore-payment-chain
- id: review-benefit-quota
content: "Review benefit grant/revoke and quota flows: idempotency ledgers, compensation scheduler, transaction boundaries"
status: completed
dependencies:
- explore-payment-chain
- id: review-other-modules
content: "Review remaining modules and infrastructure: auth, clothes AI pipeline, community, middleware, DAO, security"
status: completed
dependencies:
- explore-payment-chain
- id: write-report
content: Compile severity-graded issue list and write Chinese review report to docs/ directory
status: completed
dependencies:
- review-xpay
- review-wxpay-order
- review-benefit-quota
- review-other-modules
---
## 产品概述
对智能衣橱微信小程序后端(Go + Gin)进行第二轮全量代码审查("二次 check"),系统性分析业务逻辑问题与技术逻辑问题。本次审查**不修改任何代码**,仅产出问题清单报告。
## 核心需求
- **审查范围**:全量代码(约 9700+ 行核心业务代码),并对支付链路做最深审查——包括最近新增的虚拟支付 XPay(`pkg/wechat/virtualpay.go`、`internal/service/virtualpay.go`)、普通微信支付 V3、订单状态机、权益发放/退款/补偿链路;其余模块(用户/衣物/穿搭/社区/分享/AI/运势/通知/中间件/DAO/迁移)全部过一遍
- **审查维度**:
- 业务逻辑:状态机流转完整性、并发竞态(回调并发/幂等锁)、资损风险(金额校验、重复发放/回收、环境串单、防篡改校验)、权益一致性、越权、边界条件
- 技术逻辑:并发安全(goroutine/锁)、事务边界、Redis/MySQL 一致性与降级、错误处理、资源泄漏、时间与金额精度、签名与验签正确性、HTTP 超时与重试、安全问题(SQL 注入/XSS/SSRF/密钥硬编码/权限)
- **复核历史**:对照此前 `.codebuddy/plans/` 中"修复支付业务逻辑问题"等计划的修复项,验证修复是否完备、是否引入新问题
- **交付物**:一份中文审查报告(写入 `docs/` 目录 markdown 文件),按严重程度分级(严重/中等/轻微),每条问题包含:问题描述、位置(文件:行号)、触发场景与影响、修复建议;不修改任何业务代码
## 审查方法论
### 审查策略
1. **支付链路深挖**:逐行审查虚拟支付 XPay 新增代码与普通支付链路,重点核对:
- 签名与验签(`pay_sig` 的 `uri+"&"+body` 规则、`signData` 字典序序列化、推送 URL SHA1 验签、V3 回调验签解密)
- 发货推送处理:幂等锁 30s、道具 ID/实付金额/环境三重防篡改校验、条件状态流转(待支付/已过期/已取消 → 已支付)的竞态兜底、`finalizeVirtualPaidOrder` 的 pay_time 兜底
- 退款推送:`RefundStatus` 为空串时是否误回退权益、`settleRefundSuccess` 重复推送幂等
- 道具同步:轮询仅 5 次×500ms 是否覆盖微信任务耗时、多商品循环重复 publish 同一 group、上传失败后 `product_status` 状态回填
- `VirtualOrderCreate`:落单与签名的先后顺序、签名失败时遗留待支付订单、iOS 最低 1 元限制、session_key 依赖 Redis 的可用性
- 取消/退款限制:虚拟支付订单禁取消、iOS 订单禁主动退款的位置与状态校验顺序
2. **订单/权益/额度链路**:状态机流转完整性、金额校验、幂等凭证唯一性、过期扫描竞态、补偿任务与 Redis 锁、事务边界(行锁+条件更新)、退款回收"已用额度不回退"规则
3. **其余模块静态审查**:用户登录/鉴权、衣物上传与 AI 流水线、穿搭与社区、衣柜分享懒过期、AI 推荐扣额回退、运势生成锁、通知未读计数一致性
4. **基础设施与安全**:中间件(JWT/限流/日志脱敏)、DAO 层 SQL 参数绑定、Redis 故障降级、配置读取、迁移脚本、错误码分段
### 工具与资源
- 使用 code-explorer 子代理做跨文件、跨目录的批量探索与调用链追踪
- 使用 lsp-code-analysis 技能做语义级导航:定位订单/额度状态变更的所有写入点、回调处理函数的定义与引用、事务边界
- 输出报告:新建 `docs/代码审查报告-二次check.md`,含分级问题清单与修复建议
### 分级标准
- **严重**:资损、安全漏洞、并发导致数据错乱/重复发放、状态机死锁或不可达
- **中等**:边界条件缺失、一致性降级风险、性能隐患、错误处理不当
- **轻微**:代码风格、冗余、注释与文档偏差
### 约束
- 全程只读,不修改任何业务代码;报告为唯一交付物
## 子代理
- **code-explorer**
- 用途:全量代码库跨目录探索,批量读取各模块核心文件,追踪支付/订单/权益链路的跨层调用(router → service → dao → pkg/wechat)
- 预期产出:完整的关键文件清单、调用链关系、各模块代码事实,支撑逐项问题分析
## 技能
- **lsp-code-analysis**
- 用途:对订单状态流转、额度变更、回调处理等关键符号做语义级定义/引用导航,确认所有状态写入点与并发路径
- 预期产出:关键函数调用层次与状态变更写入点全集,避免漏查竞态路径
@@ -0,0 +1,112 @@
---
name: 全量代码审查:定位业务与技术逻辑 bug
overview: 对项目全部 Go 源码(internal/ + pkg/ + global/ + main.go)做系统性代码审查,分模块定位业务逻辑与技术逻辑 bug,最终输出一份分类 bug 报告(含位置、原因、影响、严重程度),不做任何代码修改。
todos:
- id: review-infra
content: 使用 [subagent:code-explorer] 审查 main.go、global/ 与 pkg/ 基础设施及第三方集成
status: completed
- id: review-dal
content: 使用 [subagent:code-explorer] 审查 mysqlmodel、mysqldao、redismodel、redisdao 数据访问层
status: completed
- id: review-money
content: 使用 [subagent:code-explorer] 审查订单/支付/权益/配额资损链路,并用 [skill:lsp-code-analysis] 追踪关键函数调用关系
status: completed
dependencies:
- review-dal
- id: review-content
content: 使用 [subagent:code-explorer] 审查衣物/穿搭/收藏/点赞/通知/运势/AI 等业务逻辑
status: completed
dependencies:
- review-dal
- id: review-auth
content: 使用 [subagent:code-explorer] 审查中间件、路由与鉴权(JWT、越权、参数绑定、安全)
status: completed
- id: write-report
content: 汇总各模块审查发现,输出分类中文 bug 报告(含位置、根因、影响、严重程度、建议)
status: completed
dependencies:
- review-infra
- review-dal
- review-money
- review-content
- review-auth
---
## 用户需求
对 `/root/zt-closet-backend` 智能衣橱后端服务进行**全量静态代码审查**,确认是否存在**业务逻辑 bug** 或**技术逻辑 bug**。
## 澄清结果
1. **交付方式**:仅输出一份分类的 **bug 报告**,**不做任何代码修改**。
2. **审查范围**:全部 Go 源码(`internal/` + `pkg/` + `global/` + `main.go`)。
## 交付内容
一份中文 Markdown bug 报告,按「业务逻辑 bug」与「技术逻辑 bug」两大类组织,每条 bug 包含:文件路径与行号、问题描述、根因分析、影响、严重程度(高/中/低)、修复建议(仅建议,不实施)。报告需覆盖但不限于:订单支付退款、权益幂等发放、配额原子扣减与回退、并发与事务一致性、缓存一致性、鉴权与越权、金额/时间单位边界、错误处理与资源释放、安全(注入/越权/敏感信息泄露)等。
## 审查技术方案
### 审查方法
采用「分层 + 分域」静态审查策略,自底向上覆盖完整调用链:
1. **基础设施层**(`main.go`、`global/`、`pkg/`):配置加载与初始化顺序、OTel/slog 初始化、MySQL/Redis/COS/微信支付客户端初始化、优雅关闭、goroutine 调度器、错误处理与资源释放。
2. **数据访问层**(`internal/mysqlmodel/`、`internal/mysqldao/`、`internal/redismodel/`、`internal/redisdao/`):GORM 模型字段映射与索引/唯一约束、软删除、原子条件更新(`RowsAffected` 语义)、Redis key 设计与 TTL、分布式锁正确性(锁粒度、TTL、释放时 value 校验)。
3. **业务服务层**(`internal/service/`):事务边界、并发一致性、幂等性、金额单位、时间戳单位、AuthZ 归属校验、缓存一致性、错误码映射、分页边界。
4. **接口与鉴权层**(`internal/middleware/`、`internal/routers/`、`internal/dto/`):JWT 校验、会话降级策略、CORS、参数绑定、路由鉴权覆盖完整性。
### 审查重点清单
- **资损敏感链路**:订单创建(先微信下单后落库的竞态)、支付回调幂等与金额校验、退款回调、权益幂等发放、补偿任务重试、配额原子扣减/回退/发放。
- **并发一致性**:分布式锁粒度与 TTL、锁释放误删风险、MySQL 条件更新的 `RowsAffected` 判断、多实例定时任务防重、`retry_count` 更新是否基于内存快照导致覆盖。
- **幂等性**:支付回调、退款回调、权益发放、补偿任务崩溃恢复(`benefitAlreadyGranted` 流水查重)。
- **边界与单位**:金额单位(分/元)、时间单位(秒/毫秒)、分页上限、切片越界/空指针、字符串截断(`genOrderNo`/`genRefundNo` 取 UUID 前 16 位)。
- **安全**:SQL 注入(GORM 参数绑定)、越权访问(`getOwnedOrder` 等归属校验是否完整覆盖)、日志敏感信息(openid/金额)、JWT 会话 Redis 故障降级放行的安全权衡。
- **资源与性能**:context 超时、goroutine 泄漏、Redis/DB 连接与 body 释放、N+1 查询、缓存穿透/击穿。
### 报告格式
最终输出一份中文 Markdown bug 报告,结构如下:
- 「业务逻辑 bug」章节:业务规则、状态流转、权益/资损、幂等、越权相关缺陷。
- 「技术逻辑 bug」章节:并发、事务、缓存、错误处理、资源管理、边界条件、安全相关缺陷。
- 每条含:`文件:行号`、问题描述、根因、影响、严重程度(高/中/低)、修复建议。
严重程度定义:
- **高**:资损、数据丢失、越权、严重并发不一致、安全漏洞。
- **中**:功能错误、边界异常、异常处理缺失、缓存不一致。
- **低**:代码质量、潜在风险、性能隐患、维护性问题。
### 审查覆盖范围
```
/root/zt-closet-backend
├── main.go # 初始化与调度器启动
├── global/ # 全局配置与连接(5 文件)
├── internal/
│ ├── service/ # 业务编排(16 文件)
│ ├── mysqldao/ # MySQL DAO(14 文件)
│ ├── redisdao/ # Redis DAO 含锁(10 文件)
│ ├── mysqlmodel/ # GORM 模型(18 文件)
│ ├── redismodel/ # Redis 模型与 key(11 文件)
│ ├── dto/ # 请求/响应 DTO(13 文件)
│ ├── middleware/ # JWT/CORS/日志/OTel(5 文件)
│ └── routers/ # 路由与 handler(13 文件)
└── pkg/ # 工具与第三方封装(29 文件)
```
## 使用的扩展
### SubAgent
- **code-explorer**
- 用途:对 `internal/`、`pkg/`、`global/`、`main.go` 等全部源码进行跨文件、跨目录的系统性读取与搜索,定位每个业务域的实现细节、调用关系与潜在缺陷。
- 预期产出:各模块的审查发现清单(文件路径 + 行号 + 疑似问题描述),供汇总阶段整理为最终报告。
### Skill
- **lsp-code-analysis**
- 用途:对资损敏感链路的几个关键函数(如 `consumeQuota`、`refundQuota`、`grantQuota`、`grantBenefitIdempotent`、`retryBenefitTask`、`OrderUpdateStatusByOrderNo`)做语义级定义/引用/调用层次追踪,验证并发、事务与幂等逻辑是否安全。
- 预期产出:关键函数完整调用链与引用点,支撑并发/幂等结论的准确判断。
@@ -0,0 +1,75 @@
---
name: 删除衣物时清理COS图片
overview: 在衣物删除流程中,同步删除 COS 上对应的处理后图片对象,并对上传处理中的并发竞态做防护,避免删除衣物后 COS 图片残留。
todos:
- id: add-cos-delete-util
content: 在 pkg/upload/upload.go 新增 FileDeleteCos 删除工具函数
status: completed
- id: delete-cos-on-clothes-delete
content: 修改 DeleteByUUID,事务成功后删除 processed_image 对应 COS 对象
status: completed
dependencies:
- add-cos-delete-util
- id: guard-async-upload-race
content: 修改 processImageAsync,上传后检测衣物已删除则清理 COS 对象
status: completed
dependencies:
- add-cos-delete-util
---
## 产品概述
修复用户删除衣物后,腾讯云 COS 中对应抠图图片对象未被删除的问题。当前删除衣物只软删除了数据库记录,衣物抠图后上传到 COS 的图片仍残留在云端,产生无效存储占用。
## 核心功能
- 删除衣物时,若该衣物已有处理后的图片(`processed_image` 非空),同步删除 COS 上对应的图片对象。
- COS 删除失败不影响衣物删除结果:用户删除请求始终成功返回,删除失败仅记录日志,便于后续排查与补偿。
- 处理「上传处理中删除衣物」的竞态:用户在图片异步抠图/上传期间删除衣物时,异步流程在检测到衣物已被删除后,主动清理刚上传的 COS 对象,避免残留。
## 技术栈
- 语言与框架:Go + Gin + GORM(沿用现有项目技术栈)
- 对象存储:腾讯云 COS SDK `github.com/tencentyun/cos-go-sdk-v5 v0.7.75`(已存在)
- 复用现有:`global.CosClient`、`pkg/upload` 工具层、`slog` 日志、OpenTelemetry trace span
## 实现方案
### 总体思路
在现有文件上传工具层新增一个 COS 删除工具函数,供删除流程与异步处理流程复用;在衣物删除事务提交成功后调用删除,删除失败仅记录日志不影响主流程;同时在异步图片处理成功回填前增加「衣物是否已被删除」的存在性检查,处理上传/删除并发竞态。
### 关键设计决策
- **删除失败不影响主流程**:COS 删除是尽力而为的清理操作。数据库软删除与额度回退仍以现有事务为准,COS 删除放在事务提交之后、并忽略错误返回,保证用户删除体验不因外部存储异常而失败。
- **复用对象键语义**:`clothes.ProcessedImage` 字段存储的值即 COS 对象键(如 `{用户UUID}/{用户ID}/{随机名}.png`),上传时 `FileUploadCos(ctx, cosKey)` 正是以该值作为对象名上传,因此删除时直接用 `c.ProcessedImage` 作为 key 调用 `Object.Delete`。
- **短超时防阻塞**:删除工具函数内部使用 10 秒超时 context,避免 COS 网络异常长时间阻塞删除接口。
- **竞态防护位置**:在 `processImageAsync` 上传 COS 成功后、回填 `processed_image` 之前,通过 `ClothesGetByUUID` 检查衣物是否仍存在(软删除后普通查询会过滤已删行,`isNotFound` 为 true),若已删除则删除刚上传的对象并结束,避免回填丢失造成残留。
### 性能与可靠性
- COS Delete 为单次网络调用,时间复杂度 O(1);删除接口新增一次短超时调用,影响可控。
- 上传/删除竞态窗口:检测存在性与删除 COS 之间仍可能存在极小窗口,但该窗口下后果仅为偶发残留单个对象,风险可控;核心场景(已处理完成再删除)被完整覆盖。
- 不改动现有额度回退逻辑、错误码与软删除行为,保持向后兼容。
### 实现要点
- `pkg/upload/upload.go` 新增 `FileDeleteCos`,风格与 `FileUploadCos` 一致:nil client 保护、空 key 直接返回、trace span、10s 超时。
- `internal/service/clothes.go` 的 `DeleteByUUID`:在事务闭包外声明变量承接 `c.ProcessedImage`,事务成功后非空则调用删除,失败用 `slog.WarnContext` 记录 `clothes_uuid` 与 `cos_key`,不记录密钥。
- `internal/service/clothes.go` 的 `processImageAsync`:上传 COS 成功后、分类与回填前检查衣物是否存在;若已删除,删除刚上传对象并 `return`(额度已由删除事务回退,无需再处理)。
## 目录结构
```
project-root/
├── pkg/upload/upload.go # [MODIFY] 新增 FileDeleteCos 删除 COS 对象工具函数
└── internal/service/clothes.go # [MODIFY] DeleteByUUID 删除后清理 COS;processImageAsync 增加删除竞态防护
```
## 关键代码结构
```
// FileDeleteCos 删除 COS 对象。key 为对象键(即 processed_image 存储值)。
// client 未初始化返回 error;key 为空返回 nil。
func FileDeleteCos(ctx context.Context, key string) error
```
@@ -0,0 +1,33 @@
---
name: 同步更新三个项目文档
overview: 同步更新 README.md、接口文档.md、Stitch.md,反映本轮代码变更(广告激励配置开关+服务端定价、退款管理员操作、会员类型推导、iOS 退款问询细化等),并在 Stitch.md 额度商城页补充「看广告得额度」入口 UI。
todos:
- id: update-readme
content: 更新 README.md 的 AdReward 配置段、错误码 80004、退款管理员操作与广告激励接口描述
status: completed
- id: update-api-doc
content: 更新接口文档.md 的广告激励参数与错误码、订单状态机、会员类型推导说明
status: completed
- id: update-stitch
content: 更新 Stitch.md 在额度商城页补充「看广告得额度」入口 UI 描述
status: completed
---
## 用户需求
将 README.md、接口文档.md、Stitch.md 三个文档同步更新至最近一轮后端代码修复后的最新状态。
## 核心变更点(需映射到文档)
1. 广告激励:新增 `AdReward` 配置段(`Enable` 开关 + `Cos`/`AI`/`OutfitShare`/`ClosetShare` 各类型固定金额);`/ad-reward/grant` 请求移除 `amount`,仅传 `reward_type` 与 `ref_id`;金额由服务端配置固定;新增错误码 `80004 广告激励未开启`。
2. 退款管理员操作:订单「已取消但已付款」由「自动调微信退款」改为「生成待审核退款单,管理员后台手动退款」;退款成功回调兼容「已取消」订单流转且跳过权益回收。
3. XPay 退款反查:退款推送回退权益前反查微信侧退款真实性。
4. 会员类型推导:`vip_type` 按续费后最终到期时长推导(剩余不低于 365 天为年度,否则月度),体验会员保持不变。
5. iOS 退款问询:按权益消耗情况细化拒绝。
6. 上传接口:解析 multipart 前限制请求体大小。
## 更新范围
- README.md:配置说明表新增 `AdReward` 段;业务流程第 5 条退款描述;错误码段位表补 `80004`;API 概览广告激励行;额度体系措辞。
- 接口文档.md:`/ad-reward/grant` 参数表删除 `amount` 并补业务逻辑与 `80004`;额度模块引言;微信回调竞态兜底描述;订单状态机 mermaid;会员类型推导规则;错误码全表补 `80004`;版本号与日期。
- Stitch.md:在「额度与会员商城」页的「我的额度」卡片后补充「看广告得额度」入口 UI 描述(含开关控制、服务端定价、加载/失败态),严格沿用既有 iOS HIG 与 SF Symbols 风格。
@@ -0,0 +1,178 @@
---
name: 基于 golang-migrate 的数据表迁移
overview: 使用 github.com/golang-migrate/migrate/v4 在 pkg/migration 下建立版本化迁移:22 张表的 up/down SQL(按业务域分组)+ embed 加载 + cmd/migrate 独立命令入口,并同步更新 go.mod 与 README。
todos:
- id: setup-deps
content: 提升 go.mod 依赖:golang-migrate/v4 与 go-sql-driver/mysql 改为直接依赖并 tidy
status: completed
- id: write-migration-pkg
content: 实现 pkg/migration/migration.go:embed 源、DSN 拼装、Up/Down/Steps/Version/Force
status: completed
dependencies:
- setup-deps
- id: write-sql
content: 编写 22 张表的版本化 up/down 迁移 SQL 与全部索引
status: completed
dependencies:
- setup-deps
- id: write-cmd
content: 实现 cmd/migrate 独立命令入口(up/down/steps/version/force)
status: completed
dependencies:
- write-migration-pkg
- id: verify-schema
content: 用 [subagent:code-explorer] 核对表字段与索引并修正差异
status: completed
dependencies:
- write-sql
- id: update-readme
content: 更新 README 目录树与数据库准备章节的迁移说明
status: completed
dependencies:
- write-cmd
- id: build-verify
content: 执行 go build ./... 与 go vet 验证编译通过
status: completed
dependencies:
- verify-schema
- update-readme
---
## 产品概述
为 zt-closet-backend 引入基于 `github.com/golang-migrate/migrate/v4` 的版本化数据库迁移能力:把 `internal/mysqlmodel` 中 22 张表的结构固化为可执行、可回滚、可版本管理的 SQL 迁移脚本,并提供独立命令入口手动执行,服务启动流程保持不变。
## 核心功能
- **建表迁移脚本**:按业务域分组的版本化 SQL(up 建表 + 索引,down 为 DROP TABLE),覆盖全部 22 张表,字段/类型/注释与 GORM 模型严格一致
- **索引完整性**:补齐 GORM tag 声明的全部唯一索引(幂等索引、联合唯一索引),并补充列表查询所需的普通索引
- **迁移加载**:`go:embed` 将 SQL 打包进二进制,通过 `iofs` 作为 golang-migrate 数据源,部署无需额外携带 SQL 目录
- **独立命令入口**:`cmd/migrate` 支持 `up` / `down` / `steps N` / `version` / `force <version>`,仅读取 Mysql 配置段,服务启动时完全不触发迁移
- **幂等与容错**:`migrate.ErrNoChange` 视为成功;提供 `force` 修复脏库(dirty)状态;失败以非 0 退出码返回
- **文档同步**:改写 README 中「未内置建表 SQL,也不会自动建表」及占位目录说明,给出迁移命令用法
## 技术栈
- 语言/框架:Go 1.27,模块 `git.lzcwy.cn/zhengchiliu/zt-closet-backend`
- 迁移库:`github.com/golang-migrate/migrate/v4 v4.19.1`(当前为 indirect,需提升为直接依赖),配套 `database/mysql` 驱动、`iofs` 源
- SQL 驱动:`github.com/go-sql-driver/mysql v1.10.0`(当前 indirect,需提升为直接依赖)
- 配置:`pkg/setting`(viper 只读 `configs/config.yaml`)
- 数据库:MySQL 5.7+/8.0,InnoDB + utf8mb4
## 实现方案
采用「嵌入 SQL + 独立命令执行」模式:迁移脚本作为版本化资产随代码评审与发布,运行时由 `cmd/migrate` 显式触发,服务主进程不接触 DDL,避免多实例并发迁移与启动期锁表风险。
关键决策与取舍:
1. **embed 而非 file://**:迁移二进制可单独拷贝执行,避免工作目录/漏拷导致的版本漂移;切换 file:// 仅需替换源构造的 2 行代码,保留改造空间。
2. **独立命令而非启动自动迁移**:主服务多实例部署时自动迁移会产生并发 DDL 与脏库风险;独立命令让 DDL 成为发布流程中的显式步骤。
3. **自建 DSN 而非复用 gorm 连接**:gorm DSN 无 `multiStatements=true`,而单个迁移文件含多条 `CREATE TABLE`/`CREATE INDEX`,必须开启该参数;故在迁移包内按 `setting.MysqlSettingS` 拼装专用 DSN 并 `sql.Open("mysql", dsn)`,不改动既有 `NewMysqlEngine`。
4. **按业务域分组版本(约 14 对文件)而非一表一版本**:22 张表一文件一对会导致文件过碎、回滚步骤过多;单文件全量则回滚粒度太粗。按域分组(用户/衣物/穿搭/衣柜分享/额度/商品/订单/退款/VIP/权益任务/AI 日志/审核日志/互动/通知/运势)在可读性与回滚粒度间平衡。
5. **down 为 DROP TABLE**:本项目为初始建库,down 语义即整域回滚;按依赖倒序删除(明细表先于主表)。
性能与可靠性:
- 迁移为一次性 DDL,非热路径;唯一风险点是 `multiStatements` 与脏库。通过 DSN 参数与 `force` 子命令覆盖。
- 索引设计直接映射 GORM 唯一索引(保障 upsert 幂等:collects/likes/quota_flow_logs/vip_flow_logs/benefit_grant_tasks/horoscopes/user_quotas/user_daily_usages)与列表查询条件(user_id + 状态/分类),避免全表扫描。
- `migrate.ErrNoChange` 与 `os.ErrNotExist`(无版本记录)均按成功处理,保证命令可重复执行。
## 架构设计
```mermaid
flowchart LR
A["cmd/migrate/main.go<br/>解析子命令"] --> B["pkg/setting<br/>读取 Mysql 段"]
B --> C["pkg/migration.Run<br/>拼 DSN(multiStatements=true)<br/>sql.Open"]
C --> D["iofs.New(embed FS, migrations)"]
C --> E["mysql.WithInstance(*sql.DB)"]
D --> F["migrate.NewWithSourceInstance"]
E --> F
F --> G["Up/Down/Steps/Version/Force"]
G --> H["schema_migrations 版本表"]
```
组件关系:
- `cmd/migrate`:命令层,只做参数解析、配置读取与结果输出,不含业务逻辑
- `pkg/migration`:能力层,封装 embed FS、DSN 构造、migrate 实例创建与 Up/Down/Steps/Version/Force
- `pkg/migration/migrations/*.sql`:数据资产层,版本化 DDL
- 依赖方向单向:`cmd/migrate → pkg/migration → pkg/setting`,主服务 `main.go` 不依赖迁移包
## 目录结构
```
pkg/migration/
├── migration.go # [NEW] 迁移能力封装://go:embed migrations/*.sql、iofs 源构造、DSN 拼装(含 multiStatements=true)、
│ # Run/Up/Down/Steps/Version/Force 导出函数;ErrNoChange 视为成功
└── migrations/ # [NEW] 版本化 SQL,命名 000001_xxx.up.sql / 000001_xxx.down.sql,约 14 对
├── 000001_create_users.{up,down}.sql
├── 000002_create_clothes.{up,down}.sql
├── 000003_create_outfits.{up,down}.sql # outfits + outfit_items
├── 000004_create_closet_shares.{up,down}.sql # closet_shares + closet_share_items
├── 000005_create_user_quotas.{up,down}.sql # user_quotas + user_daily_usages
├── 000006_create_quota_flow_logs.{up,down}.sql
├── 000007_create_goods.{up,down}.sql
├── 000008_create_orders.{up,down}.sql
├── 000009_create_refund_flow_logs.{up,down}.sql
├── 000010_create_vip_flow_logs.{up,down}.sql
├── 000011_create_benefit_grant_tasks.{up,down}.sql
├── 000012_create_ai_flow_logs.{up,down}.sql # ai_recommend_flow_logs + ai_horoscope_flow_logs
├── 000013_create_interactions.{up,down}.sql # collects + likes
├── 000014_create_user_notifications.{up,down}.sql
├── 000015_create_horoscopes.{up,down}.sql
└── 000016_create_audit_logs.{up,down}.sql # closet_audit_logs + outfit_audit_logs
cmd/
└── migrate/
└── main.go # [NEW] 独立迁移命令入口:子命令 up/down/steps N/version/force <version>;
# 仅 setting.NewSetting() + ReadSection("Mysql"),不调用 main.go 的 setupSetting()
go.mod # [MODIFY] golang-migrate/v4、go-sql-driver/mysql 由 indirect 提升为直接依赖
go.sum # [MODIFY] 随 go mod tidy 更新(不手工编造哈希)
README.md # [MODIFY] 目录树新增 pkg/migration、cmd/migrate;改写 sql/、migrations/ 占位说明;
# 「准备数据库」章节改为 go run ./cmd/migrate up 等命令用法
```
## 关键代码结构
```
// pkg/migration/migration.go(仅签名,不含实现)
package migration
//go:embed migrations/*.sql
var migrationFS embed.FS
// Run 执行迁移动作;action 取值 up/down/steps/version/force。
func Run(s *setting.MysqlSettingS, action string, arg int) error
// 语义化封装,供命令层直接调用
func Up(s *setting.MysqlSettingS) error // 执行到最新版本;ErrNoChange 视为成功
func Down(s *setting.MysqlSettingS) error // 全量回滚(按依赖倒序 DROP)
func Steps(s *setting.MysqlSettingS, n int) error
func Version(s *setting.MysqlSettingS) (uint, bool, error) // 返回版本号与 dirty 状态
func Force(s *setting.MysqlSettingS, version int) error // 修复脏库
```
## 实现要点(防回归)
- **DSN 必须带 `multiStatements=true`**,否则同文件多条语句会被 MySQL 拒绝;同时保留 `charset=utf8mb4&parseTime=True&loc=Local`。
- **字段类型映射**:字符串按用途取 `VARCHAR(64)`(UUID/编码)、`VARCHAR(128/255)`(名称/短描述)、`TEXT`(description、content、result_content、callback_raw、daily_text、remark);状态/类型 `TINYINT`;时间统一 `BIGINT`(秒级时间戳);金额与计数 `BIGINT`(分);`deleted_at BIGINT NOT NULL DEFAULT 0`(soft_delete 插件以 0 表示未删除)。
- **易错字段**:`clothes` 主色列名为 **`color`**(非 color_main),必须与模型一致,否则 GORM 查询报未知列。
- **必须建立的唯一索引**(与 GORM tag 一一对应,缺失会破坏 upsert 幂等):`uk_user_wx_open_id`、`uk_user_quota(user_id,quota_type)`、`uk_daily_usage_user_date(user_id,stat_date)`、`uk_quota_flow_idem(order_no,quota_type,change_type)`、`uk_order_no`、`uk_refund_flow_no`、`uk_vip_flow_idem(order_no,change_type)`、`uk_benefit_task_idem(order_no,benefit_type)`、`uk_collect_user_target(collect_type,target_id,user_id)`、`uk_like_user_target(like_type,target_id,user_id)`、`uk_horoscope_date_zodiac(stat_date,zodiac)`。
- **补充普通索引**(模型未声明但为查询条件):`clothes(user_id,category_main)`、`clothes(user_id,image_process_status)`、`outfits(user_id,status)`、`outfit_items(outfit_id)`、`closet_shares(user_id,status)`、`closet_share_items(share_uuid)`、各业务 UUID 列(clothes_uuid/outfit_uuid/share_uuid/notification_uuid/quota_flow_uuid)、`user_notifications(user_id,is_read)`、`orders(user_id,order_status)`。
- **命令层不要复用 `main.go` 的 `setupSetting()`**(会强制校验 JWT 等无关配置,导致迁移命令在只配了数据库的环境失败);也不要 import `global` 之外触发 Redis/COS 初始化。
- 日志/输出:命令层用 `fmt`/`slog` 输出版本号与执行结果,失败 `os.Exit(1)`;**不打印 DSN 明文密码**。
- 本次**不连接真实数据库**(配置中的 MySQL 为内网地址),验证以 `go build`/`go vet` 为准。
## 验证清单
- `go build ./...` 通过,`go vet ./pkg/migration/... ./cmd/migrate/...` 无告警
- `go run ./cmd/migrate version` 能正确加载配置并输出版本(连接失败时应给出可读错误而非 panic)
- 逐对检查 up/down 文件名配对、版本号连续无重复
- 22 张表名、列名与 `internal/mysqlmodel` 模型逐一比对一致
## Agent Extensions
### SubAgent
- **code-explorer**
- Purpose:在迁移 SQL 编写完成后,交叉核对 `internal/mysqlmodel/` 下 18 个模型文件与 `pkg/migration/migrations/*.sql`,逐表比对表名、列名、字段类型与索引是否一致,重点核查 22 张表无遗漏、唯一索引与 GORM tag 完全对应
- Expected outcome:输出差异清单(缺失表/错误列名/缺失或多余索引),并据此修正 SQL,确保迁移结果与 GORM 模型零偏差
@@ -0,0 +1,89 @@
---
name: 完善UserDefaultQuota配置化
overview: 补齐 UserDefaultQuota 配置链路:定义 setting.UserDefaultQuotaSettingS 结构体、main 读取配置、config 增加 UserDefaultQuota 段,并让 ensureDefaultQuota 改用配置值(配置缺省时回落现有常量兜底),修复当前因未定义结构体导致的编译错误。
todos:
- id: define-setting
content: 在 pkg/setting/section.go 新增 UserDefaultQuotaSettingS 结构体(Cos/AI/OutfitShare/ClosetShare int64),修复 global/setting.go 的 undefined 编译错误
status: completed
- id: wire-config
content: 在 main.go setupSetting 增加 ReadSection("UserDefaultQuota"),并在 config.yaml 与 config.example.yaml 新增 UserDefaultQuota 段(20/3/10/5)
status: completed
dependencies:
- define-setting
- id: service-use-config
content: 在 internal/service/quota.go 新增 defaultQuotaValue 辅助(判 nil、字段
status: completed
dependencies:
- define-setting
---
## 需求概述
完善「新用户默认额度(UserDefaultQuota)」的配置化链路。当前 `global/setting.go` 已声明 `UserDefaultQuotaSetting *setting.UserDefaultQuotaSettingS`,但结构体未定义、main 未读取、config 无配置段、service 仍用硬编码常量,导致项目编译报错(`undefined: setting.UserDefaultQuotaSettingS`)。
## 核心功能
- 定义 `UserDefaultQuotaSettingS` 配置结构体,修复编译错误。
- main 启动时读取 `UserDefaultQuota` 配置段。
- 两个 config 文件新增 `UserDefaultQuota` 段(Cos/AI/OutfitShare/ClosetShare)。
- `ensureDefaultQuota` 改为读取配置值,配置缺省(<=0 或 global 为 nil)时回落现有常量兜底。
- 保持现有行为不变:未配置时默认仍为 20/3/10/5;初始化仍为 best-effort 且幂等。
## 技术方案
### 技术栈
沿用现有:Go + viper(`setting.ReadSection` → `UnmarshalKey`)配置读取,`global` 单例持有配置,`internal/service` 消费配置。
### 实现要点
1. **定义配置结构体** — `pkg/setting/section.go` 新增(放在 `WechatPaySettingS` 之后、`ReadSection` 方法之前,与现有结构体风格一致):
```
type UserDefaultQuotaSettingS struct {
Cos int64 // 免费上传额度
AI int64 // 免费 AI 推荐额度
OutfitShare int64 // 免费社区分享额度
ClosetShare int64 // 免费衣柜分享额度
}
```
修复 `global/setting.go:19` 的 `undefined` 编译错误。
2. **main 读取配置** — `main.go` `setupSetting()` 增 `setting.ReadSection("UserDefaultQuota", &global.UserDefaultQuotaSetting)`,与其他段处理方式一致(错误则 return err)。
3. **config 增加配置段** — `configs/config.yaml` 与 `configs/config.example.yaml` 均新增:
```
UserDefaultQuota:
Cos: 20
AI: 3
OutfitShare: 10
ClosetShare: 5
```
4. **service 使用配置 + 兜底** — `internal/service/quota.go`:
- 新增取值辅助 `defaultQuotaValue(quotaType int8) int64`:判 `global.UserDefaultQuotaSetting` 是否为 nil;取对应字段;字段 `<= 0` 时回落 `constants.go` 的 `DefaultQuota*` 常量。
- `ensureDefaultQuota` 的 defaults 列表 `total` 改为调用 `defaultQuotaValue(quotaType)`。
- 语义约定:值 `<= 0` 视为「未设置」回落常量默认(本次不支持「显式 0 额度」,如需后续加开关,注释说明)。
- `constants.go` 的四个 `DefaultQuota*` 常量**保留**作为兜底默认,补注释说明用途。
### 关键决策与约束
- **兜底优先,不破坏现有默认**:配置缺省或 global 为 nil(如未走 main 的单测场景)时结果仍为 20/3/10/5,避免空指针 panic。
- **幂等/best-effort 不变**:`ensureDefaultQuota` 仍在 `WxLogin` 新用户分支中以 best-effort 调用,已存在则跳过,写流水(`QuotaChangeTypeActivity`)与同步 Redis 逻辑保持不变。
- **安全**:纯数值配置,无秘钥、无外部输入风险。
### 涉及文件
- `pkg/setting/section.go`:[MODIFY] 新增 `UserDefaultQuotaSettingS`。
- `main.go`:[MODIFY] `setupSetting` 增 `ReadSection("UserDefaultQuota", ...)`。
- `configs/config.yaml`、`configs/config.example.yaml`:[MODIFY] 新增 `UserDefaultQuota` 段。
- `internal/service/quota.go`:[MODIFY] 新增 `defaultQuotaValue` 辅助,`ensureDefaultQuota` 改用配置。
- `internal/service/constants.go`:[MODIFY] 保留 `DefaultQuota*` 常量并补注释。
### 验证
- `go build ./...`、`go vet ./...` 通过(修复当前 undefined 错误)。
- 逻辑核对:未配置该段时结果为 20/3/10/5;配置后按配置生效;`global.UserDefaultQuotaSetting` 为 nil 或字段 <=0 时不 panic 且回落常量。
@@ -0,0 +1,64 @@
---
name: 完善项目 README 文档
overview: 为空白的 README.md 撰写一份完整的中文项目文档,涵盖项目简介、功能特性、技术栈、目录结构、快速开始(依赖/配置/数据库/启动)、API 概览、第三方 AI 服务说明、可观测性与部署等。
todos:
- id: explore-details
content: 使用 [subagent:code-explorer] 补充确认 tokenhub/service/errcode/mysqlmodel 表结构/Redis 用途/AI 服务端点等 README 所需事实
status: completed
- id: write-readme-core
content: 撰写 README 主体:项目简介、核心功能、技术栈、目录结构、快速开始(依赖/配置/基础设施/AI 服务/运行)
status: completed
dependencies:
- explore-details
- id: write-readme-api-ops
content: 撰写配置说明、API 概览表、可观测性与后台任务、部署提示与安全注意事项,并全文校对与代码事实一致性
status: completed
dependencies:
- write-readme-core
---
## 用户需求
为 zt-closet-backend 项目(智能衣橱微信小程序后端)补全根目录下当前为空白的 README.md,产出一份与代码实际相符、可直接指导新开发者上手的完整中文项目文档。
## 文档内容要求
- **项目简介**:说明项目定位(智能衣橱/衣柜管理小程序后端服务),概括核心能力:衣物管理、AI 衣物分类与抠图、穿搭与 AI 推荐、运势生成、社区互动(分享/收藏/点赞/通知)、额度体系、商品与微信支付订单。
- **技术栈**:如实列出 Go 1.27、Gin、GORM + MySQL、Redis、Viper、JWT、腾讯云 COS、微信小程序登录与 APIv3 支付、OpenTelemetry(Trace/Metric/Log)、slog + lumberjack。
- **目录结构**:按已探明的真实结构逐层说明(configs/global/internal/pkg/thirdparty 等),空占位目录(docs、sql、migrations、scripts 子目录)不得虚构内容。
- **快速开始**:环境依赖、配置准备(复制 config.example.yaml 并填写)、MySQL/Redis 准备、两个 Python AI 模型服务的安装与启动、后端编译运行、健康检查验证。
- **配置说明**:逐一说明配置段落(Server/Log/Mysql/Redis/OTel/JWT/Cos/Upload/MattingModel/ClassificationModel/WechatMini/WechatPay/UserDefaultQuota/Tokenhub)与默认值。
- **API 概览**:以表格列出 /ping 与 /api/v1 全部路由(公开路由与 JWT 鉴权路由分组),说明统一响应结构 {code, msg, data} 与 Bearer Token 鉴权方式。
- **可观测性与后台任务**:OTel 上报、结构化日志(含 trace_id/span_id)、每日运势生成、权益补偿、订单超时过期等定时任务。
- **安全注意事项**:JWT 密钥必填且须为强随机值、密钥仅存于配置文件不入库、微信回调验签等。
## 文档架构设计
README 采用面向开发者的标准结构,中文撰写,章节顺序遵循"是什么 → 能做什么 → 怎么跑起来 → 怎么配 → 有哪些接口 → 怎么运维"的认知路径:
1. 项目简介
2. 核心功能
3. 技术栈
4. 目录结构
5. 快速开始(依赖 → 配置 → 基础设施 → AI 模型服务 → 运行)
6. 配置说明
7. API 概览
8. 可观测性与后台任务
9. 部署提示
10. 安全注意事项
## 写作规范(关键约束)
- **事实准确性优先**:所有技术栈版本、路由、配置段、默认值必须与 go.mod、router.go、main.go、config.example.yaml 已探明内容一致;不得凭想象补充不存在的文件或机制。
- **数据库初始化如实描述**:全仓未发现 AutoMigrate 调用与建表 SQL(sql/、migrations/ 目录为空),README 中须如实说明表结构需按 internal/mysqlmodel 的模型定义自行准备建表 SQL,不得虚构自动建表能力。
- **配置机制如实描述**:当前 pkg/setting 仅从 configs/config.yaml 读取、不支持环境变量覆盖(最近刚完成"配置全面去环境变量化"改造);Tokenhub 段在 config.example.yaml 中缺失但 main.go 会读取(Model 默认 hy4、APIKey 为空时运势/AI 功能不可用),撰写时需核实 ReadSection 对缺失段的行为后如实说明。
- **AI 服务说明**:如实描述 Fashion-CLIP 分类服务(15 维度、hf-mirror 镜像、端口 8000 约定)与 BiRefNet 抠图服务(BiRefNet_lite、本地缓存优先、BIREFNET_REPO_ID 可覆盖、端口 8001 约定)的依赖安装、GPU 建议与启动方式。
- **安全提示**:JWT SecretKey 为空会导致启动 panic(代码强制校验)、微信支付 APIv3Key 必须 32 字节、密钥不入库、配置不入版本库(.gitignore 检查 config.yaml 是否被忽略)。
## 推荐使用的扩展
### SubAgent
- **code-explorer**
- 用途:在撰写 README 前,系统性地补充确认剩余关键事实——pkg/tokenhub(fortune.go、outfit_recommend.go、scheduler.go 的职责与触发时机)、internal/service 各业务服务职责清单、pkg/errcode 错误码分组、internal/mysqlmodel 表模型清单、internal/redisdao 缓存用途、thirdparty 两个 Python 服务的路由端点、pkg/setting 的 ReadSection 对缺失配置段的行为。
- 预期结果:形成一份可支撑 README 各章节的事实清单,确保文档内容与代码一一对应、无虚构。
@@ -0,0 +1,127 @@
---
name: 局部变量resp-req重命名为全称
overview: 将项目中作为局部变量/函数参数的缩写 resp、req 统一重命名为 response、request,不改动 Dao 字段与 Go 惯用短名,不触碰 c.Request/http.Request 等标准库字段,保证编译与 vet 通过、对外行为不变。
todos:
- id: scan-occurrences
content: 使用 [subagent:code-explorer] 全仓普查 \bresp\b、\breq\b 命中点,输出文件与行号清单,并标注需排除的 RequestID/c.Request/ResponseFormat 等完整标识符
status: completed
- id: rename-handlers
content: 重命名 internal/routers/api/v1 全部 handler 中的局部 resp→response、req→request,借助 [skill:lsp-code-analysis] 做作用域内安全 rename 并同步引用
status: completed
dependencies:
- scan-occurrences
- id: rename-service
content: 重命名 internal/service 各文件的参数名 req→request 与局部 resp→response,保持函数体引用一致,借助 [skill:lsp-code-analysis] 校验引用
status: completed
dependencies:
- scan-occurrences
- id: rename-middleware-pkg
content: 重命名 internal/middleware/lzcwyjwt.go 及 pkg(wechat 等)中命中的局部 resp/req,排除类型名与导出符号
status: completed
dependencies:
- scan-occurrences
- id: verify-build
content: 执行 gofmt、go build ./...、go vet ./... 并 grep 复核无残留独立 resp/req 变量、排除项未被改动
status: completed
dependencies:
- rename-handlers
- rename-service
- rename-middleware-pkg
---
## 需求概述
将项目中作为**局部变量与函数参数**的缩写标识符 `resp`、`req` 统一重命名为全称 `response`、`request`,提升代码可读性与命名一致性。这是纯内部命名重构,不改变任何对外行为。
## 明确边界(已与用户确认)
- **要改**:`resp → response`、`req → request`(仅限局部变量/函数参数标识符)
- **保留不改**:
- service 中的 `xxxDao` 字段(`Dao` 为领域专有缩写)
- Go 惯用短名:`ctx`、`err`、`ok`、`db`、`id`、`tx`、循环变量 `i` 等
- 所有类型名/导出符号:`dto.XxxRequest`、`dto.XxxResponse`、`app.Response`、`app.NewResponse`、`ToResponse`、`ToErrorResponse`、`ResponseFormat`、`chatResponse`、`FortuneResponse` 等
- 标准库/框架字段:`c.Request`、`http.Request`、`c.Request.Context()`
- 含 `req`/`resp` 的完整标识符:`RequestID`、`GetRequestID`、`requestIDKey`、`ShouldBindQuery`、`responseFormat` 等
## 验收标准
- 项目内不再有名为 `resp`/`req` 的独立局部变量/参数标识符
- 上述排除项完全未被改动
- `go build ./...`、`go vet ./...` 通过,对外 HTTP 行为、DTO JSON tag、路由完全不变
## 技术方案
### 重构策略
采用**逐文件、按 Go 标识符词边界精确替换**的方式(`\bresp\b → response`、`\breq\b → request`),仅作用于变量声明、赋值与引用位置。优先使用 LSP 作用域内重命名(rename symbol)保证不跨作用域误伤;对形态高度统一的 handler 文件可按文件精确替换后逐一校验。
### 命中范围(已通过 search_content 统计确认)
**handler 层**(`internal/routers/api/v1/*.go`,形态统一):
- `resp := app.NewResponse(c)` → `response := app.NewResponse(c)`,后续 `resp.ToResponse(...)` / `resp.ToErrorResponse(...)` 同步更新
- `var req dto.XxxRequest`、`req.XxxUUID = c.Param(...)`、`&req` → 全部改为 `request`
**service 层**(`internal/service/*.go`):
- 方法参数名 `req *dto.XxxRequest` → `request *dto.XxxRequest`,函数体内引用同步更新
- 局部 `resp`(如 `horoscope.go`、`user.go` 组装响应处)→ `response`
**middleware / pkg**:
- `internal/middleware/lzcwyjwt.go` 的 `resp := app.NewResponse(c)`
- `pkg/wechat/*`、`pkg/errcode`、`pkg/app` 等如有名为 `req`/`resp` 的局部变量/参数,一并处理(以实际命中为准)
### 关键防回归点
- `\breq\b`(小写+词边界)天然不匹配 `c.Request`(大写 R)、`RequestID`、`requestIDKey`、`ShouldBindQuery`,但替换时须逐处确认无误伤
- `\bresp\b` 不匹配 `ResponseFormat`、`responseFormat`、`chatResponse`、`FortuneResponse`、`RecommendLLMResponse`,保持不动
- 类型名 `dto.*Request`/`dto.*Response`、`app.Response`/`app.NewResponse`/`ToResponse` 等导出符号绝不改动
- 替换后须确认无与既有 `response`/`request` 标识符的命名冲突(当前代码未发现同名局部变量)
### 涉及文件清单(预估,以实际命中为准)
```
internal/routers/api/v1/
├── user.go # [MODIFY] resp/req → response/request
├── quota.go # [MODIFY] 同上
├── horoscope.go # [MODIFY] 同上
├── clothes.go # [MODIFY] 同上(含 multipart 分支)
├── outfit.go # [MODIFY] 同上(命中较多)
├── closet.go # [MODIFY] 同上
├── collect.go # [MODIFY] 同上
├── like.go # [MODIFY] 同上
├── notification.go # [MODIFY] 同上
├── goods.go # [MODIFY] 同上
├── order.go # [MODIFY] 同上(含回调 handler)
└── airecommend.go # [MODIFY] 同上
internal/service/
├── user.go / quota.go / horoscope.go / clothes.go / outfit.go
├── closet.go / collect.go / like.go / notification.go
├── goods.go / order.go / airecommend.go # [MODIFY] 参数名 req→request、局部 resp→response
internal/middleware/
└── lzcwyjwt.go # [MODIFY] resp → response
pkg/
├── wechat/mini.go, wechat/pay.go # [MODIFY] 按实际命中的局部 req/resp
└── errcode/*, app/* # [CHECK] 按实际命中处理,类型/方法名不动
```
### 验证方式
- `gofmt -l` 无格式问题
- `go build ./...`、`go vet ./...` 通过
- grep 复核:无残留作为独立标识符的 `\bresp\b`/`\breq\b` 局部变量;`c.Request`、`RequestID`、`dto.*Request`、`dto.*Response`、`ResponseFormat` 等未被改动
## Agent Extensions
### SubAgent
- **code-explorer**
- Purpose:在重构前对全仓库 `\bresp\b`、`\breq\b` 做精确命中普查,产出「文件 → 行号 → 上下文」清单,并甄别需排除的完整标识符(RequestID、c.Request、ResponseFormat 等),确保不漏改、不误伤。
- Expected outcome:得到完整、去噪后的待改点清单,作为逐文件重命名的依据。
### Skill
- **lsp-code-analysis**
- Purpose:对 `req`/`resp` 局部变量与函数参数执行 LSP 作用域内 rename(含引用点同步更新)与改前引用预览,避免跨作用域误改。
- Expected outcome:每个 `resp`/`req` 标识符在其作用域内被安全重命名为 `response`/`request`,所有引用一致更新。
@@ -0,0 +1,151 @@
---
name: 微信支付切换到官方wechatpay-go
overview: 将 pkg/wechat/pay.go 的自实现微信支付(签名/下单/退款/回调解密)替换为官方 SDK github.com/wechatpay-apiv3/wechatpay-go,获得平台证书自动下载轮换与应答/回调自动验签能力;同步调整订单回调链路以传入 *http.Request,code2session 登录保持标准库不变。
todos:
- id: add-dep-init
content: 添加 wechatpay-go 依赖(go get + go mod tidy),基于 core.NewClient + WithWechatPayAutoAuthCipher 重写 InitPayClient,构建 notify.RSANotifyHandler,保留配置校验与单例
status: completed
- id: rewrite-pay-refund
content: 用 SDK 重写 JsapiPrepay(PrepayWithRequestPayment 一步返回 *MiniPayParams,移除 BuildMiniPayParams)与 Refund(RefundsApiService.Create),并删除全部自实现签名/HTTP 辅助函数
status: completed
dependencies:
- add-dep-init
- id: rewrite-notify
content: 用 handler.ParseNotifyRequest 重写两个回调函数,入参改为 *http.Request,分别用 payments.Transaction/refunddomestic.Refund 映射到现有 PayNotifyData/RefundNotifyData
status: completed
dependencies:
- add-dep-init
- id: adapt-callers
content: 适配 internal/service/order.go(Create 一步下单、PayNotify/RefundNotify 入参改 *http.Request、import net/http)与 internal/routers/api/v1/order.go(回调 handler 直接传 c.Request)
status: completed
dependencies:
- rewrite-pay-refund
- rewrite-notify
- id: verify
content: 执行 go build ./... 与 go vet ./...,使用 [subagent:code-explorer] 复核无残留 BuildMiniPayParams 及旧 []byte 回调调用,确认签名一致
status: completed
dependencies:
- adapt-callers
---
## 产品概述
将微信支付能力从「Go 标准库自实现」切换为官方 SDK `github.com/wechatpay-apiv3/wechatpay-go`,在保持对外业务契约基本不变的前提下,获得官方 SDK 内置的**平台证书自动下载与轮换**、**请求签名与应答自动验签**、**回调自动验签 + 解密**能力,弥补自实现方案缺失的验签安全环节。
## 核心功能
- 商户支付客户端初始化:加载商户私钥、自动注册平台证书下载器(定期轮换)、自动应答验签。
- 小程序 JSAPI 下单:一步返回小程序调起支付所需参数(TimeStamp/NonceStr/Package/SignType/PaySign)。
- 申请退款:调用官方退款服务。
- 支付/退款回调:官方处理器完成签名校验 + APIv3Key 解密 + 反序列化。
## 范围与约束
- 仅改造微信支付部分(`pkg/wechat/pay.go` 及其调用方)。
- 微信小程序登录 `code2session`(`pkg/wechat/mini.go`)保持 Go 标准库实现,不改动。
- 配置结构体 `setting.WechatPaySettingS` 字段不变;`main.go` 的初始化时机与「初始化失败仅告警、禁用支付、不 panic」降级逻辑保持不变。
- 对外返回结构体(`MiniPayParams`/`PayNotifyData`/`RefundNotifyData`/`RefundResult`)字段保持不变,最小化上层改动。
## 技术栈
- 语言/框架:Go 1.27 + Gin(沿用现有项目)。
- 新增依赖:`github.com/wechatpay-apiv3/wechatpay-go`(`go get` + `go mod tidy`,GOPROXY 已配置 goproxy.cn 可用)。
- 复用 SDK 子包:`core`、`core/option`、`core/auth/verifiers`、`core/downloader`、`utils`、`services/payments/jsapi`、`services/payments`、`services/refunddomestic`、`core/notify`。
## 实现方案
### 1. 初始化 `InitPayClient(s *setting.WechatPaySettingS) error`(签名不变)
- 保留原有配置完整性校验:字段非空、`APIv3Key` 必须 32 字节。
- `utils.LoadPrivateKeyWithPath(s.PrivateKeyPath)` 加载商户私钥。
- `core.NewClient(ctx, option.WithWechatPayAutoAuthCipher(mchID, serialNo, privateKey, apiV3Key))` 创建客户端:内部完成请求签名 + 平台证书自动下载/轮换 + 应答自动验签。
- 用 `downloader.MgrInstance().GetCertificateVisitor(mchID)` + `verifiers.NewSHA256WithRSAVerifier(...)` 构造 `notify.NewRSANotifyHandler(apiV3Key, verifier)` 作为回调处理器。
- 包级单例持有 `*core.Client`、`*notify.Handler`、`appID/mchID/notifyURL/refundNotifyURL`,沿用现有 `sync.RWMutex` + `getPayClient()` 模式。
- 初始化会联网下载平台证书;失败返回 err(由 `main.go` 已有降级路径处理)。
### 2. 下单(合并两步为一步)
- 用 `jsapi.JsapiApiService{Client}.PrepayWithRequestPayment(ctx, jsapi.PrepayRequest{Appid, Mchid, Description, OutTradeNo, NotifyUrl, Amount:{Total, Currency:"CNY"}, Payer:{Openid}})`,直接返回含 `TimeStamp/NonceStr/Package/SignType/PaySign` 的应答(SDK 内部已用商户私钥完成 paySign 签名)。
- 接口调整:`JsapiPrepay(ctx, openid, orderNo, description string, amount int64) (*MiniPayParams, error)`(原返回 string prepayID)。
- **移除 `BuildMiniPayParams`**(职责被 SDK 合并)。
- 金额单位为分,用 `core.Int64(amount)` 等 SDK 指针辅助函数构造请求。
### 3. 退款 `Refund(...) (*RefundResult, error)`(签名不变)
- 用 `refunddomestic.RefundsApiService{Client}.Create(ctx, refunddomestic.CreateRequest{OutTradeNo, OutRefundNo, NotifyUrl, Reason, Amount:{Refund, Total, Currency:"CNY"}})`。
- 映射应答到 `RefundResult{RefundID, Status}`(注意 SDK 返回为指针,需判空取值)。
### 4. 回调验签 + 解密(关键变更:入参 `[]byte` → `*http.Request`)
- `handler.ParseNotifyRequest(ctx, request, content)`:校验 `Wechatpay-Signature/Timestamp/Nonce/Serial` 签名头 → 平台证书验签 → APIv3Key 解密 → 反序列化。
- `VerifyAndDecryptPayNotify(ctx context.Context, request *http.Request) (*PayNotifyData, error)`:content 用 `payments.Transaction`,映射到现有 `PayNotifyData`(含 `Amount.Total/PayerTotal/Currency`,指针判空)。
- `VerifyAndDecryptRefundNotify(ctx context.Context, request *http.Request) (*RefundNotifyData, error)`:content 用 `refunddomestic.Refund`,`RefundStatus` 取 `refunddomestic.Status` 值映射。
### 5. 调用方适配
- `internal/service/order.go`:
- `Create`:改为一步 `payParams, err := wechat.JsapiPrepay(...)`,删除 `BuildMiniPayParams` 调用;`payParams → dto.WechatPayParams` 映射逻辑不变。
- `PayNotify(ctx, request *http.Request) *errcode.Error` / `RefundNotify(ctx, request *http.Request) *errcode.Error`:入参由 `body []byte` 改为 `*http.Request`,转调新的 Verify 函数;幂等锁、状态条件流转、发放权益、通知等业务逻辑完全不变;新增 `net/http` import。
- `internal/routers/api/v1/order.go`:回调 handler 不再 `c.GetRawData()`,直接 `svc.PayNotify(c.Request.Context(), c.Request)`;微信规范返回(`{"code":"SUCCESS"}`/`{"code":"FAIL"}`)不变。中间件链不消费 body,SDK 可正常读取。
- `main.go`:`InitPayClient` 签名一致,无需改动。
### 6. 移除的自实现代码
删除 `pay.go` 中:`parsePrivateKey`、`doRequest`、`buildAuthorization`、`sign`、`randomHex`、`aesGCMDecrypt`、`decryptNotify`、`notifyEnvelope`、`payAPIBase`/path 常量、`PrepayResult` 等(被 SDK 取代)。`mini.go` 当前未使用 `randomHex`,无需保留。
## 架构与数据流
```mermaid
flowchart TD
A[order.go Create] --> B[wechat.JsapiPrepay 一步]
B --> C[jsapi.PrepayWithRequestPayment]
C --> D[core.Client 签名+应答验签]
E[api/v1 PayNotify/RefundNotify handler] --> F[svc PayNotify/RefundNotify ctx,*http.Request]
F --> G[wechat.VerifyAndDecrypt* ]
G --> H[notify.Handler.ParseNotifyRequest\n验签+解密+反序列化]
D --> I[(微信支付 API\napi.mch.weixin.qq.com)]
H --> J[downloader 平台证书自动轮换]
```
## 安全与健壮性
- 新增应答验签 + 回调验签 + 平台证书自动轮换,杜绝伪造/篡改回调,补齐自实现缺口。
- 商户私钥/APIv3Key 仍仅来自配置文件路径,不硬编码、不写日志。
- 回调验签失败返回微信 FAIL 触发重试;业务幂等(redis 锁 + 状态条件流转)保持不变,重复回调安全。
- 初始化联网失败降级为「禁用支付」,不影响服务启动与其它业务。
## 目录结构
```
zt-closet-backend/
├── go.mod / go.sum # [MODIFY] 新增 wechatpay-go 依赖(go get + go mod tidy)
├── pkg/wechat/
│ ├── pay.go # [REWRITE] 基于官方 SDK 重写:InitPayClient/Refund 签名不变;
│ │ # JsapiPrepay 返回 *MiniPayParams 并移除 BuildMiniPayParams;
│ │ # VerifyAndDecryptPayNotify/RefundNotify 入参改为 *http.Request;
│ │ # 删除全部手写签名/解密/HTTP 辅助函数
│ └── mini.go # [KEEP] code2session 保持标准库实现,不改动
├── internal/service/order.go # [MODIFY] Create 一步下单;PayNotify/RefundNotify 入参改 *http.Request;import net/http
├── internal/routers/api/v1/order.go # [MODIFY] 回调 handler 传 c.Request(不再 GetRawData)
└── main.go # [KEEP] InitPayClient 签名一致,初始化与降级逻辑不变
```
## 关键代码结构(对外契约)
```
// pkg/wechat/pay.go 对外签名(结构体字段保持不变)
func InitPayClient(s *setting.WechatPaySettingS) error
func JsapiPrepay(ctx context.Context, openid, orderNo, description string, amount int64) (*MiniPayParams, error) // 返回类型调整
func Refund(ctx context.Context, orderNo, refundNo string, total, refund int64, reason string) (*RefundResult, error)
func VerifyAndDecryptPayNotify(ctx context.Context, request *http.Request) (*PayNotifyData, error) // 入参改 *http.Request
func VerifyAndDecryptRefundNotify(ctx context.Context, request *http.Request) (*RefundNotifyData, error) // 入参改 *http.Request
// 移除:BuildMiniPayParams、PrepayResult 及所有自实现辅助函数
```
## Agent Extensions
### SubAgent
- **code-explorer**
- Purpose:在改造完成后,全仓复核是否残留对已移除符号(`BuildMiniPayParams`、旧 `VerifyAndDecryptPayNotify([]byte)`/`VerifyAndDecryptRefundNotify([]byte)`)的调用,并确认 `order.go` 与 `api/v1/order.go` 与新签名一致。
- Expected outcome:确认无遗漏引用,调用链签名对齐,避免编译期返工。
@@ -0,0 +1,106 @@
---
name: 扁平化迁移脚本目录结构
overview: 把 pkg/migration/migrations/*.sql 上移一层到 pkg/migration/*.sql,embed 改为 *.sql + iofs.New(fs, "."),并删除根目录多余的空占位目录 migrations/,同步更新 README 与验证。
todos:
- id: flatten-sql
content: 将 32 个迁移 SQL 上移到 pkg/migration 并删除空的 migrations 子目录
status: completed
- id: fix-embed
content: 修改 migration.go 的 embed 模式与 iofs 源路径为当前目录
status: completed
dependencies:
- flatten-sql
- id: clean-root-dir
content: 删除仓库根空的 migrations 占位目录
status: completed
- id: sync-readme
content: 同步 README 中所有 pkg/migration/migrations 路径引用
status: completed
dependencies:
- fix-embed
- id: verify-build
content: 编译与 go vet 校验,并用临时测试确认 embed 源可解析(验证后删除)
status: completed
dependencies:
- fix-embed
- sync-readme
- id: review-paths
content: 用 [subagent:code-explorer] 复核目录结构与路径引用无残留
status: completed
dependencies:
- verify-build
---
## 产品概述
调整已实现的数据库迁移目录结构:去掉 `pkg/migration/migrations/` 这一层多余嵌套,将版本化 SQL 直接放在 `pkg/migration/` 下,并清理仓库根已失效的 `migrations/` 占位目录;迁移入口 `cmd/migrate` 保留不动。
## 核心功能
- **SQL 目录扁平化**:32 个迁移脚本(`000001~000016` 的 up/down 配对)上移一层至 `pkg/migration/`,文件名与内容完全不变,删除空的 `migrations/` 子目录
- **embed 路径适配**:改为 `//go:embed *.sql` + `iofs.New(migrationFS, ".")`,迁移脚本与 `migration.go` 同级
- **清理根目录**:删除仓库根已无引用的空占位目录 `migrations/`(`sql/`、`scripts/`、`docs/` 保持不变)
- **文档同步**:README 目录树与「准备数据库」章节中的 `pkg/migration/migrations` 路径全部改为 `pkg/migration`
- **迁移能力不变**:`Up/Down/Steps/Version/Force` API、`cmd/migrate` 子命令、22 张表 SQL 内容均保持不变
## 技术栈
- Go 1.27,模块 `git.lzcwy.cn/zhengchiliu/zt-closet-backend`
- `github.com/golang-migrate/migrate/v4 v4.19.1`(`database/mysql` 驱动 + `source/iofs` 源)
- `github.com/go-sql-driver/mysql v1.10.0`
## 实现方案
采用「脚本与代码同级」的扁平布局:`//go:embed *.sql` 把同目录 SQL 打进 embed FS,`iofs.New(migrationFS, ".")` 以当前目录作为 golang-migrate 源根目录,文件名 `00000N_xxx.up.sql` / `.down.sql` 保持 golang-migrate 规范不变。
关键决策:
1. **去掉源子目录而非保留**:`iofs` 源只需一个目录根,SQL 与 `migration.go` 同级时路径最短、引用最少,且不损失任何能力(golang-migrate 按文件名而非目录识别版本)。
2. **`//go:embed *.sql` 只嵌入 SQL**:embed FS 中不会出现 `.go` 文件,避免 `iofs` 源扫描到无关文件。
3. **保留 `cmd/migrate`**:用户已确认接受 Go 标准布局;独立二进制使迁移不依赖主服务的 Redis/COS/支付等初始化。
4. **不改文件名与内容**:避免已应用环境的版本号/校验和变化,保证迁移历史兼容。
## 实现要点(防回归)
- `migration.go` 中 `migrationsDir` 常量改为 `"."` 并更新注释(迁移脚本与本文件同级);`iofs.New(migrationFS, migrationsDir)` 调用不变。
- 文件名保持 `00000N_xxx.up.sql` / `00000N_xxx.down.sql`,**不要**重命名或改版本号。
- 验证用临时测试文件放在 `pkg/migration` 下(仅 `iofs.New` + `src.First()`,不连数据库),**验证后必须删除**。
- 不要改动 `cmd/migrate/main.go`、SQL 内容与迁移包对外 API。
- 不连接真实数据库:`configs/config.yaml` 的 MySQL 为内网地址,且该文件第 2 行 `Domain: *` 为非法 YAML(用户本地配置,本次不处理)。
## 架构设计
```mermaid
flowchart LR
A["cmd/migrate/main.go<br/>up/down/steps/version/force"] --> B["pkg/setting<br/>读取 Mysql 段"]
B --> C["pkg/migration.Run<br/>DSN multiStatements=true"]
C --> D["iofs.New(embed FS, .)"]
D --> E["pkg/migration/*.sql<br/>000001~000016 up/down"]
C --> F["mysql.WithInstance(*sql.DB)"]
E --> G["migrate.NewWithInstance"]
F --> G
G --> H["schema_migrations"]
```
## 目录结构
```
pkg/migration/
├── migration.go # [MODIFY] //go:embed 由 migrations/*.sql 改为 *.sql;migrationsDir 常量由 "migrations" 改为 ".";
│ # Up/Down/Steps/Version/Force/Run 与 DSN 拼装逻辑不变
├── 000001_create_users.up.sql # [MOVE] 由 migrations/ 上移,内容不变
├── 000001_create_users.down.sql # [MOVE]
├── ...(000002~000016 的 up/down,共 32 个文件)[MOVE]
└── migrations/ # [DELETE] 空目录,扁平化后删除
migrations/ # [DELETE] 仓库根空占位目录,已无引用
README.md # [MODIFY] 目录树:pkg/migration 去掉 migrations 子层;
# 「准备数据库」章节与 sql/ 占位说明中的 pkg/migration/migrations 改为 pkg/migration
```
## Agent Extensions
### SubAgent
- **code-explorer**
- Purpose:扁平化完成后复核 `pkg/migration/` 目录结构与 README 中的路径引用,确认 `migrations/` 子目录与根占位目录已消失、无遗留 `pkg/migration/migrations` 文本引用、32 个 SQL 文件齐全且 up/down 配对完整
- Expected outcome:输出路径引用与文件清单核查结果,确保无残留旧路径
@@ -0,0 +1,47 @@
---
name: 拆分 Stitch 提示词为分段可粘贴文档
overview: 将现有 Stitch.md 的整段一次性提示词,改写为「第 1 段全局规范 + 第 2~14 段每页一段」共 14 段的分段结构,每段可独立复制粘贴到 Stitch。
todos:
- id: rebuild-global-segment
content: 重写 Stitch.md 头部使用说明与第 1 段全局规范段
status: completed
- id: verify-enums
content: 用 [subagent:code-explorer] 核对各屏字段与状态枚举真实取值
status: completed
- id: migrate-core-pages
content: 迁移主流程 5 页为第 2~6 段(登录/衣橱/衣物详情/穿搭/AI 推荐)
status: completed
dependencies:
- rebuild-global-segment
- verify-enums
- id: migrate-minor-pages
content: 迁移次要流程 8 页为第 7~14 段(上传至我的)
status: completed
dependencies:
- migrate-core-pages
- id: proofread-segments
content: 全文校对:13 屏零丢失、段号连贯、锚点与负面约束一致
status: completed
dependencies:
- migrate-minor-pages
---
## 产品概述
将现有给 Google Stitch 使用的 UI 生成提示词文档 `Stitch.md`,由「一次性整段粘贴」改写为「分段粘贴」结构:共 14 段,第 1 段为全局规范,第 2~14 段每页一段,每段可独立复制粘贴到 Stitch 逐次发送,解决整段过长导致发送失败/生成不稳定的问题。
## 核心功能
- **单文件分段结构**:仍在 `Stitch.md` 内,用「第 N 段|页面名」醒目标记分隔 14 段,每段标注「复制本段(不含标题行)粘贴到 Stitch」
- **第 1 段 全局规范**:产品定位、微信小程序平台与画布约束、iOS 设计语言与色系字阶、导航结构与页面跳转关系、图标与组件规格、全局通用元素、负面约束、逐页生成约定
- **第 2~14 段 逐页提示词**:按「先主流程、后次要流程」排序——登录、衣橱、衣物详情、穿搭、AI 穿搭推荐、衣物上传、穿搭详情、运势、衣柜分享、通知、额度与会员商城、订单、我的
- **上下文锚点**:每段开头用 1~2 句压缩重申小程序 375×812、系统蓝 #007AFF、白卡 + #F2F2F7 底 + 发丝线等基准,保证分次发送时跨页风格一致
- **内容零丢失**:现有 13 屏的全部示例文案、状态徽标、空态/加载态/异常态、负面约束完整迁移;衣柜分享一屏内含 ①②③ 三个子页面
## Agent Extensions
### SubAgent
- **code-explorer**
- Purpose:核对 `internal/dto/` 与 `internal/service/constants.go` 中衣物 15 维属性、四类额度、订单/分享/通知/商品状态枚举,确保分段后的示例文案与枚举取值与后端真实数据一致
- Expected outcome:输出字段与枚举事实清单,作为分段迁移与最终校对的依据,杜绝虚构字段
@@ -0,0 +1,124 @@
---
name: 按层区分 DAO span 命名
overview: 将 mysqldao 的 span 名由 dao.X 改为 mysqldao.X、redisdao 的改为 redisdao.X,共 140 处,解决两层 span 名无法区分的问题。
todos:
- id: rename-mysqldao
content: 批量替换 internal/mysqldao 下 109 处 span 前缀为 mysqldao.
status: completed
- id: rename-redisdao
content: 批量替换 internal/redisdao 下 31 处 span 前缀为 redisdao.
status: completed
- id: verify-rename
content: 用 [subagent:code-explorer] 复核前缀计数与无残留无错改
status: completed
dependencies:
- rename-mysqldao
- rename-redisdao
- id: sync-readme
content: 在 README 目录说明中补充 span 命名约定
status: completed
dependencies:
- verify-rename
- id: build-verify
content: 执行 go build ./... 与 go vet ./... 验证
status: completed
dependencies:
- verify-rename
- sync-readme
---
## 产品概述
统一调整 OTel span 命名前缀:数据访问层的 span 名由通用的 `dao.XXX` 改为可区分来源的 `mysqldao.XXX` 与 `redisdao.XXX`,解决 MySQL 与 Redis 两层调用在链路追踪中无法区分的问题。
## 核心功能
- **MySQL 层改名**:`internal/mysqldao/` 下全部 109 处 span 名前缀 `dao.` 改为 `mysqldao.`
- **Redis 层改名**:`internal/redisdao/` 下全部 31 处 span 名前缀 `dao.` 改为 `redisdao.`
- **命名一致性保持**:仅改前缀,方法名(如 `UserInfoSet`)、业务逻辑、其他 span 属性均不变
- **不影响其他层**:`internal/service`(`service.`)、`pkg/upload`(`upload.`)、`pkg/tokenhub`(`scheduler.`)保持原样
- **可维护性**:README 目录说明补一句 span 命名约定,避免后续新增代码再用回 `dao.`
## 技术栈
- Go 1.27,模块 `git.lzcwy.cn/zhengchiliu/zt-closet-backend`
- OpenTelemetry Trace(`global.Tracer`,OTLP gRPC 上报)
- span 创建统一写法:`ctx, span := global.Tracer.Start(ctx, "<前缀>.<方法名>")`
## 实现方案
采用**目录限定的批量字符串替换**:把 `global.Tracer.Start(ctx, "dao.` 中的前缀分别替换为 `mysqldao.` / `redisdao.`,替换范围严格限定在 `internal/mysqldao/` 与 `internal/redisdao/` 两个目录。
关键决策与取舍:
1. **前缀命名用 `mysqldao.` / `redisdao.` 而非 `mysql.` / `redis.`**:与包名(`package mysqldao` / `package redisdao`)完全一致,Go 项目中 span 名即包名+方法名,排查时可从 span 名直接定位到文件与包,无需映射记忆。
2. **批量 sed 而非逐处手改**:140 处手工改必漏;用 `sed -i 's/Tracer\.Start(ctx, "dao\./Tracer.Start(ctx, "mysqldao./g'` 这类按目录执行的命令可保证一致性与完整性,且模式串锚定了 `Tracer.Start(ctx, "dao.` 全串,不会误伤字符串变量、注释或日志中的 `dao.`。
3. **只改前缀,不改方法名**:方法名与所在函数名一一对应,改名会造成与函数名不一致的二次困惑;同时避免破坏任何按名称检索的既有习惯。
4. **不引入常量**:项目其余前缀(`service.`、`upload.`、`scheduler.`)均为字面量,沿用字面量保持风格统一,不引入新的抽象层(YAGNI)。
5. **不改动 service / upload / tokenhub**:这些层前缀本身已能区分,不属于本次「分不清」的范围,缩小改动面。
性能与可靠性:
- span 名为启动期/调用期构造的字符串常量,改为更长前缀无运行时开销(无额外分配语义变化)。
- 风险点仅为「替换不完整」或「误伤其他目录」,通过目录限定 + 替换后计数校验(mysqldao 109 / redisdao 31 / 残留 `dao.` 为 0)来兜底。
## 实现要点(防回归)
- 替换模式必须锚定完整串 `global.Tracer.Start(ctx, "dao.`,避免误改 `mysqldao.`/`redisdao.` 之外的其它 `dao` 文本(如注释、错误信息)。
- 严格限定目录:`internal/mysqldao/*.go` 与 `internal/redisdao/*.go`,**不要**对仓库根或 `internal/` 整体执行替换(会误伤 `internal/service` 中可能存在的同名串)。
- 替换后必须核验:`internal/mysqldao` 与 `internal/redisdao` 中 `"dao.` 残留为 0;`"mysqldao.` 计数 109、`"redisdao.` 计数 31(与替换前一致)。
- 未发现任何测试或断言依赖 span 名字符串,改名不会破坏断言;仍以 `go build ./...` + `go vet ./...` 作为回归基线。
- 不改动 `configs/config.yaml`(第 2 行 `Domain: *` 为非法 YAML,属用户本地配置,本次不处理)。
## 架构设计
调用链与 span 命名分层(本次改动仅涉及最下两层的命名):
```mermaid
flowchart TD
A["routers/api/v1 handler<br/>Gin 中间件 server span"] --> B["internal/service<br/>span: service.Xxx"]
B --> C["internal/mysqldao<br/>span: mysqldao.Xxx(本次由 dao. 改名)"]
B --> D["internal/redisdao<br/>span: redisdao.Xxx(本次由 dao. 改名)"]
C --> E[("MySQL")]
D --> F[("Redis")]
```
## 目录结构
```
internal/mysqldao/
├── quota.go # [MODIFY] 18 处 span 前缀 dao. → mysqldao.
├── outfit.go # [MODIFY] 13 处
├── order.go # [MODIFY] 12 处
├── clothes.go # [MODIFY] 9 处
├── closet.go # [MODIFY] 8 处
├── user.go # [MODIFY] 7 处
├── horoscope.go # [MODIFY] 7 处
├── benefit_grant_task.go # [MODIFY] 7 处
├── collect.go # [MODIFY] 6 处
├── like.go # [MODIFY] 6 处
├── notification.go # [MODIFY] 6 处
├── auditlog.go # [MODIFY] 4 处
├── goods.go # [MODIFY] 3 处
└── vipflowlog.go # [MODIFY] 3 处
internal/redisdao/
├── quota.go # [MODIFY] 6 处 span 前缀 dao. → redisdao.
├── collect.go # [MODIFY] 4 处
├── like.go # [MODIFY] 4 处
├── auth.go # [MODIFY] 4 处
├── notification.go # [MODIFY] 4 处
├── user.go # [MODIFY] 3 处
├── horoscope.go # [MODIFY] 2 处
├── lock.go # [MODIFY] 2 处
├── outfit.go # [MODIFY] 1 处
└── closet.go # [MODIFY] 1 处
README.md # [MODIFY] 可选:目录树中 mysqldao/redisdao 说明补 span 命名约定(mysqldao.* / redisdao.*)
```
## Agent Extensions
### SubAgent
- **code-explorer**
- Purpose:替换完成后复核两个 DAO 目录的 span 命名:确认 `internal/mysqldao` 全部为 `mysqldao.` 前缀、`internal/redisdao` 全部为 `redisdao.` 前缀、无 `"dao.` 残留,且 `internal/service`、`pkg/upload`、`pkg/tokenhub` 的 span 名未被误改
- Expected outcome:输出各目录前缀计数与残留清单,确保 140 处全部正确改名、零误伤
@@ -0,0 +1,149 @@
---
name: 接入微信虚拟支付(全终端)
overview: 按微信官方政策把虚拟商品(会员/额度包)支付从普通 JSAPI 支付切换到小程序虚拟支付(wx.requestVirtualPayment,覆盖 Android/iOS 全终端):新增 pkg/wechat/virtualpay.go(xpay 服务器 API HMAC 签名客户端 + 事件推送验签/应答)、虚拟支付订单创建接口、道具服务器 API 同步上架、发货/退款/iOS 退款问询三类推送处理、goods/orders 扩展字段与新迁移、配置与文档同步;现有 JSAPI 支付代码保留但不再用于虚拟商品。
todos:
- id: config-wiring
content: 新增 VirtualPaySettingS 配置结构与全局变量,接线 main.go 初始化与 config.example.yaml 样例段
status: completed
- id: xpay-client
content: 实现 pkg/wechat/virtualpay.go 客户端(HMAC 签名、道具上传发布、查单退款、推送验签应答)并编写单元测试
status: completed
dependencies:
- config-wiring
- id: data-layer
content: 扩展 goods/orders 模型与常量错误码,编写 000017 迁移 SQL,并用 [subagent:code-explorer] 校对一致性
status: completed
- id: virtual-order
content: 实现道具同步 SyncVirtualGoods 与虚拟支付订单创建接口(service、dto、handler、路由)
status: completed
dependencies:
- xpay-client
- data-layer
- id: notify-callbacks
content: 实现发货/退款/iOS 退款问询推送回调处理并复用权益发放回退链路,调整退款申请分流
status: completed
dependencies:
- xpay-client
- data-layer
- id: docs-verify
content: 同步 README 与接口文档,执行 go build 与 go vet 全量验证
status: completed
dependencies:
- virtual-order
- notify-callbacks
---
## 产品概述
为微信小程序接入微信官方「虚拟支付(XPay)」能力:小程序内所有虚拟商品(会员、上传包、AI 推荐包、衣柜分享包、搭配分享包)的购买统一改走虚拟支付通道,覆盖 Android、iOS、鸿蒙、Windows 全终端(符合微信 2026-04-01 起虚拟商品全终端强制接入虚拟支付的政策要求)。现有普通微信支付(JSAPI)代码保留不删,供未来独立 App 或实物商品使用。
## 核心功能
- 配置与初始化:新增 VirtualPay 配置段(OfferID、现网/沙箱 AppKey、回调参数),服务启动时初始化虚拟支付客户端,未配置仅告警不阻断启动。
- 道具同步:实现 xpay 服务器 API 客户端(HMAC 签名),将上架商品自动上传并发布为虚拟支付道具(一次一个、轮询任务状态),回填 product_id;失败告警不阻塞服务。
- 虚拟支付下单:新增「创建虚拟支付订单」接口,本地落单(OutTradeNo 复用本地订单号、pay_channel=2、记录商品 product_id 与平台)后返回前端调起 wx.requestVirtualPayment 所需业务参数。
- 发货推送回调:处理 xpay_goods_deliver_notify(验签、幂等、校验 ProductId 与实际支付金额后流转订单并幂等发放会员/额度权益),必须按微信规范应答 ErrCode=0。
- 退款与 iOS 退款问询:处理 xpay_refund_notify 推送复用现有权益回退链路;处理 xpay_subscribe_ios_refund_query_notify(3 秒内应答建议退款/拒绝);iOS 订单退款申请接口提示走 App Store,Android 虚拟支付订单走 xpay 退款任务。
## 技术栈
- 语言与框架:Go 1.27 + Gin + GORM(复用现有项目栈)
- 新增模块:pkg/wechat/virtualpay.go(XPay 客户端,仅 Go 标准库 + 现有依赖,与现有 mini.go 风格一致)
- 数据迁移:沿用现有 golang-migrate 版本化迁移体系(pkg/migration/*.sql embed),新增 000017 版本
- 可复用基建:redisdao.TryLock、redismodel.KeyLockPayCallback、benefit_grant_tasks 幂等发放、sendNotification、slog
## 实现方案
微信虚拟支付是独立体系:无服务端下单 API(下单由前端 wx.requestVirtualPayment 内部完成),服务端职责为「道具上传发布 + 本地订单管理 + 推送回调发货/退款 + iOS 退款问询应答」。因此本次采用**双通道并存、虚拟支付旁路接入**的架构:
```mermaid
flowchart LR
A[小程序前端] -->|全终端统一| B[POST /order/virtual 创建虚拟订单]
B --> C[service.VirtualCreate<br/>本地落单 pay_channel=2]
C --> D[返回 product_id / order_no]
A -->|wx.requestVirtualPayment| E[微信虚拟支付<br/>iOS走Apple 其余走微信]
E -->|xpay_goods_deliver_notify| F[POST /xpay/notify]
F --> G[验签 幂等锁 金额校验]
G --> H[复用 finalizePaidOrder<br/>grantOrderBenefits 发放权益]
E -->|xpay_refund_notify| I[复用 settleRefundSuccess 回退权益]
E -->|xpay_subscribe_ios_refund_query_notify| J[3秒内应答 result_code]
K[道具同步 SyncVirtualGoods] -->|start_upload_goods 轮询 publish| E
```
关键决策:
1. **订单与道具标识**:goods 表加 product_id(道具 ID,空表示未发布);orders 表加 pay_channel=2(虚拟支付)、xpay_product_id、xpay_platform(android/ios,下单时前端上报,用于退款分流)、xpay_env(0 现网 1 沙箱)。发货推送以 OutTradeNo 匹配本地 order_no。
2. **金额校验**:发货推送 GoodsInfo.ActualPrice(分)必须等于订单 PayAmount,ProductId 必须等于订单 xpay_product_id,否则不流转不发货、记日志人工介入(沿用现有 PayNotify 的防篡改策略)。
3. **权益发放零改造**:虚拟支付发货完全复用 finalizePaidOrder → grantOrderBenefits → benefit_grant_tasks 幂等凭证链路;退款推送复用 settleRefundSuccess → revokeOrderBenefits 回退链路。不新增第二套发放逻辑。
4. **道具同步策略**:提供 SyncVirtualGoods(ctx) 服务方法(遍历上架且 product_id 为空或发布失败的 goods,逐个 upload → 轮询 → publish),启动时 goroutine 异步执行并告警,另暴露内部触发入口;单个商品失败不影响其他商品。
5. **推送应答**:XPay 消息推送与普通 V3 回调不同(ErrCode 格式、最多重推 15 次),必须用独立应答函数,不能复用现有 wechatCallbackSuccess。
6. **旧通道保留**:现有 JSAPI 下单/回调/退款代码不动;虚拟商品前端不再调用 POST /order 的 JSAPI 下单(保留给未来实物商品/独立 App)。
性能与可靠性:
- 回调处理热路径与现有 PayNotify 一致:Redis 幂等锁(30 秒)+ 条件状态流转 + 并发竞态兜底,无新增瓶颈。
- iOS 退款问询必须在 3 秒内应答:处理逻辑仅本地 DB 查询 + 构造应答,不做外部调用,保证时延。
- 道具同步轮询使用短超时 HTTP 客户端(复用 10s 风格),启动异步执行不阻塞服务。
## 目录结构
```
pkg/wechat/
├── virtualpay.go # [NEW] XPay 客户端:HMAC 签名、道具上传/发布/查询、query_order/refund_order、推送验签解析与 ErrCode 应答
└── virtualpay_test.go # [NEW] HMAC 签名样例、推送解析/应答单元测试(固定 appKey,不连真实微信)
pkg/setting/section.go # [MODIFY] 新增 VirtualPaySettingS 配置结构
global/setting.go # [MODIFY] 新增 VirtualPaySetting 全局变量
main.go # [MODIFY] ReadSection("VirtualPay") + setupVirtualPayClient()
configs/config.example.yaml # [MODIFY] 新增 VirtualPay 配置样例段(占位值 + 注释)
internal/service/
├── constants.go # [MODIFY] PayChannelVirtual=2、GoodsProductStatus 常量、Platform 常量
├── virtualpay.go # [NEW] SyncVirtualGoods 道具同步 + VirtualOrderCreate + 推送处理(发货/退款/iOS 退款问询)
internal/mysqlmodel/
├── goods.go # [MODIFY] 加 ProductID、ProductStatus 字段
└── order.go # [MODIFY] 加 XPayProductID、XPayPlatform、XPayEnv 字段
internal/dto/order.go # [MODIFY] 新增 VirtualOrderCreateRequest/Response
internal/routers/api/v1/order.go # [MODIFY] 新增 VirtualCreate、XPayNotify handler + XPay 应答函数
internal/routers/router.go # [MODIFY] 注册 POST /order/virtual(鉴权)与 POST /xpay/notify(公开)
pkg/errcode/order_code.go # [MODIFY] 新增虚拟支付相关错误码(道具未发布/虚拟支付未配置/iOS 退款走 App Store 等)
pkg/migration/
├── 000017_add_virtual_pay_fields.up.sql # [NEW] goods/orders 加字段与索引
└── 000017_add_virtual_pay_fields.down.sql # [NEW] 对应回滚
README.md # [MODIFY] 配置表、支付架构说明、迁移版本数
接口文档.md # [MODIFY] 虚拟支付下单接口、三类推送、前端 wx.requestVirtualPayment 调用契约
```
## 关键代码结构
```
// VirtualPaySettingS 微信虚拟支付(XPay)配置,与普通微信支付独立结算。
type VirtualPaySettingS struct {
OfferID string // 虚拟支付 OfferID(AppID)
EnvAppKey string // 现网 AppKey
SandboxAppKey string // 沙箱 AppKey
Enable bool // 是否启用虚拟支付(未开通时服务降级为不可下单)
}
// VirtualOrderCreateResponse 创建虚拟支付订单响应(前端据此调 wx.requestVirtualPayment)。
type VirtualOrderCreateResponse struct {
OrderNo string `json:"order_no"` // 业务订单号(作为 OutTradeNo)
PayAmount int64 `json:"pay_amount"` // 实付金额(分)
ProductID string `json:"product_id"` // 虚拟支付道具 ID
Quantity int64 `json:"quantity"` // 购买数量(固定 1)
Attach string `json:"attach"` // 透传信息
}
```
实现注意事项:
- XPay 签名规则 `paySig = hex(hmac_sha256(appKey, uri + "&" + signData))`,服务器 API 的 uri 为 API 路径(不含 query),post_body 必须与真实请求体完全一致;推送验签以官方「虚拟支付」消息推送文档为准,实现时不得虚构字段。
- xpay 请求域名与路径(/xpay/start_upload_goods 等)以官方文档为准;客户端初始化为全局单例 + RWMutex,风格对齐 pay.go。
- 推送应答:成功返回 `{"ErrCode":0,"ErrMsg":"success"}`(JSON)或对应 XML;非 goods_deliver/refund/ios_refund_query 的推送(complaint/coin/wxpay_callback)仅结构化记日志并应答成功。
- Env 字段解析保留(0 现网 / 1 沙箱),沙箱默认关闭(iOS 不支持沙箱)。
## Agent Extensions
### SubAgent
- **code-explorer**
- 用途:迁移 SQL 落地后,交叉核对 000017 迁移脚本与 internal/mysqlmodel 中 goods/orders 模型字段的一致性(列名、类型、默认值、索引),并复核全仓库无遗漏的 pay_channel=2 分支与错误码引用。
- 预期结果:输出逐表差异清单,确认新增字段与索引与模型定义完全一致、无旧路径引用遗漏。
@@ -0,0 +1,73 @@
---
name: 收藏单条状态查询接口
overview: 参照点赞的 GET /like/:targetUUID,为收藏新增 GET /collect/:targetUUID 单条收藏状态查询接口(返回 is_collected + collect_count),补齐前端进入穿搭详情时的收藏状态初始化能力。
todos:
- id: add-collect-status-api
content: 新增收藏单条状态查询 DTO、Service、Handler 与路由注册
status: completed
- id: sync-collect-docs
content: 同步 README 与接口文档,补充 GET /collect/:targetUUID 说明
status: completed
dependencies:
- add-collect-status-api
- id: verify-collect-build
content: 执行 go build ./... 验证编译通过
status: completed
dependencies:
- add-collect-status-api
---
## 用户需求
收藏状态无法正确初始化——后端缺少「单条收藏状态」查询接口。点赞已有 `GET /like/:targetUUID`(返回 `is_liked` + `like_count`),收藏仅有 `POST /collect`(收藏/取消)和 `GET /collect`(我的收藏列表),前端进入穿搭详情时无法获知当前用户是否已收藏该穿搭。
## 核心功能
参照点赞接口,为收藏补齐 `GET /collect/:targetUUID` 单条状态查询:
- 返回当前用户是否已收藏(`is_collected`)与该穿搭收藏总数(`collect_count`)
- Redis 优先、异常回落 MySQL,与点赞查询策略严格对称
- 校验穿搭存在性与可见性(非本人仅可查询公开可见穿搭)
## 技术栈
- Go 1.27 + Gin + GORM + go-redis,沿用现有分层(handler → service → dao / redisdao)
- 复用现有收藏底层能力,不新增 DAO/Redis 方法
## 实现方式
严格对齐点赞 `GET /like/:targetUUID` 的实现模式,新增收藏单条状态查询链路。
### 关键决策
- **字段命名对称**:点赞 `is_liked`/`like_count`,收藏用 `is_collected`/`collect_count`。
- **路径参数命名对齐**:`targetUUID`(驼峰,与点赞一致),避免前端两套传参方式不一致。
- **回落策略一致**:是否已收藏与收藏总数均 Redis 优先;Redis 出错或计数为 0 时回落 MySQL 兜底,保证缓存冷启动/漂移时数据正确。
- **仅新增不改旧**:收藏的收藏/取消与列表逻辑保持不动,降低回归风险。
### 数据流
`GET /collect/:targetUUID` → handler 取 `targetUUID` → service 校验穿搭(存在 + 可见性)→ Redis 查收藏状态/计数 → 异常回落 MySQL → 返回 `{is_collected, collect_count}`
## 目录结构
```
zt-closet-backend/
├── internal/
│ ├── dto/
│ │ └── collect.go # [MODIFY] 新增 CollectGetByTargetRequest/Response
│ ├── service/
│ │ └── collect.go # [MODIFY] 新增 GetByTarget 方法
│ └── routers/
│ ├── router.go # [MODIFY] 注册 GET /collect/:targetUUID
│ └── api/v1/
│ └── collect.go # [MODIFY] 新增 GetByTarget handler
├── README.md # [MODIFY] API 概览收藏小节新增接口行
└── 接口文档.md # [MODIFY] 接口总表与 5.8 节新增接口说明
```
## 实现细节
- DTO 定义与点赞完全对应:`CollectGetByTargetRequest{ TargetUUID string json:"target_uuid" binding:"required" }`、`CollectGetByTargetResponse{ UserIsCollected bool json:"is_collected"; CollectCount int64 json:"collect_count" }`。
- Service `GetByTarget` 内部逻辑:先 `OutfitGetByUUID`(不存在返回 `TargetNotFound`、非本人且未公开返回 `OutfitNoPermission`),再取 `targetID`,随后 `CollectIsMember`(Redis 优先,err 回落 `CollectGetByTargetAndUser` 判断 `Status == InteractStatusOn`),最后 `CollectCount`(Redis 优先,`err != nil || count == 0` 回落 `CollectCountByTarget`)。
- Handler 与路由均对齐点赞:path 参数 `targetUUID`,空值返回 `InvalidParams`;路由注册在鉴权组内,`/collect`(精确路径)与 `/collect/:targetUUID`(参数路径)在 Gin 路由树中可共存。
@@ -0,0 +1,82 @@
---
name: 数据库唯一索引全面补齐
overview: 全面盘点代码中依赖数据库唯一索引做幂等/防重、但模型未声明 tag 的表,补全 GORM 模型 tag,并生成一份幂等的建索引 SQL 脚本供用户自行在开发库执行。
todos:
- id: add-model-unique-index-tags
content: 为 vipflowlog、benefit_grant_task、refundflowlog、collect、like、order、quota(UserDailyUsage)、horoscope 八个模型补 uniqueIndex tag
status: completed
- id: create-sql-script
content: 新建 sql/unique_indexes.sql:9 条唯一索引 DDL、幂等检查语句、执行说明与重复数据清理提示
status: completed
dependencies:
- add-model-unique-index-tags
- id: build-verify
content: 执行 go build、go vet 与 lint 检查,核对脚本索引与模型 tag 一一对应
status: completed
dependencies:
- create-sql-script
---
## 产品概述
针对上一轮「配额流水事务一致性修复」遗留的数据库唯一索引落地问题,为开发环境提供一套完整的数据库唯一索引补齐方案。用户处于开发阶段、无历史业务数据,选择由本人后续自行在开发库执行 SQL,本次交付:补全所有依赖唯一索引做幂等/防重的模型声明,并生成可直接执行的幂等 SQL 脚本。
## 核心功能
- **补全模型唯一索引声明**:为 8 个依赖唯一索引但未声明 gorm tag 的模型补充 `uniqueIndex` 声明(自文档化,不影响当前运行)。
- **生成唯一索引 SQL 脚本**:新建 `sql/unique_indexes.sql`,覆盖 9 个表的唯一索引 DDL,每条附用途说明与依赖代码位置。
- **提供幂等执行与清理指引**:脚本头部提供索引存在性检查语句与重复执行说明,尾部提供开发环境重复测试数据的清理提示。
- **构建验证**:确保 tag 改动无编译、静态检查错误。
## 索引清单
| 表 | 唯一索引(建议名) | 依赖场景 |
| --- | --- | --- |
| quota_flow_logs | uk_quota_flow_idem (order_no, quota_type, change_type) | 配额发放/回收幂等(tag 已补,仅需 SQL) |
| vip_flow_logs | uk_vip_flow_idem (order_no, change_type) | VIP 发放/回收幂等 |
| benefit_grant_tasks | uk_benefit_task_idem (order_no, benefit_type) | 权益发放补偿任务幂等凭证 |
| refund_flow_logs | uk_refund_flow_no (refund_no) | 退款申请防重 |
| collects | uk_collect_user_target (collect_type, target_id, user_id) | 收藏/取消收藏 upsert |
| likes | uk_like_user_target (like_type, target_id, user_id) | 点赞/取消点赞 upsert |
| orders | uk_order_no (order_no) | 商户订单号唯一 |
| user_daily_usages | uk_daily_usage_user_date (user_id, stat_date) | 每日计数行唯一,防并发重复行 |
| horoscopes | uk_horoscope_date_zodiac (stat_date, zodiac) | 每日星座运势防重复生成 |
## 技术栈
- 语言:Go
- ORM:GORM(`uniqueIndex` tag 声明,`priority` 控制联合索引列顺序)
- 数据库:MySQL 8.0(`ALTER TABLE ... ADD UNIQUE INDEX`)
- 脚本形式:纯 SQL 文件,人工执行
## 实现方案
### 总体策略
项目无迁移机制(无 AutoMigrate、无 .sql 文件、表结构外部管理),用户已明确选择「自己执行 SQL」,因此本次改动仅两处:模型 tag 补全 + 新增 SQL 脚本。不引入 AutoMigrate、不改服务启动行为。tag 声明仅作代码自文档化,为未来可能启用的自动迁移预留基础。
### 模型 tag 补全(8 个文件)
1. `internal/mysqlmodel/vipflowlog.go`:`OrderNo` + `ChangeType` 联合索引 `uk_vip_flow_idem`(priority 1/2)。
2. `internal/mysqlmodel/benefit_grant_task.go`:`OrderNo` + `BenefitType` 联合索引 `uk_benefit_task_idem`(priority 1/2)。
3. `internal/mysqlmodel/refundflowlog.go`:`RefundNo` 单列唯一索引 `uk_refund_flow_no`。
4. `internal/mysqlmodel/collect.go`:`CollectType` + `TargetID` + `UserID` 联合索引 `uk_collect_user_target`(priority 1/2/3)。
5. `internal/mysqlmodel/like.go`:`LikeType` + `TargetID` + `UserID` 联合索引 `uk_like_user_target`(priority 1/2/3)。
6. `internal/mysqlmodel/order.go`:`OrderNo` 单列唯一索引 `uk_order_no`。
7. `internal/mysqlmodel/quota.go`(`UserDailyUsage`):`UserID` + `StatDate` 联合索引 `uk_daily_usage_user_date`(priority 1/2)。
8. `internal/mysqlmodel/horoscope.go`:`StatDate` + `Zodiac` 联合索引 `uk_horoscope_date_zodiac`(priority 1/2)。
tag 格式遵循现有项目约定(如 `quotaflowlog.go`、`quota.go` 已声明的 `uniqueIndex:名称,priority:N` 风格),保持注释说明索引用途。
### SQL 脚本设计(新建 sql/unique_indexes.sql)
- **头部**:执行说明(目标库、开发环境定位、执行方式 `mysql -u... -p... < sql/unique_indexes.sql`);MySQL 8.0 不支持 `CREATE INDEX IF NOT EXISTS`,提供基于 `information_schema.statistics` 的索引存在性检查模板查询。
- **主体**:9 条 `ALTER TABLE xxx ADD UNIQUE INDEX xxx (...)`,按上表顺序排列,每条以注释块说明用途、依赖代码位置、重复执行时预期行为(已存在会报 Duplicate key name,可忽略或先删后建)。
- **尾部**:开发阶段如有重复测试数据需先清理的提示,并给出关键表的查重 SQL 示例(如 `SELECT order_no, COUNT(*) FROM quota_flow_logs GROUP BY order_no, quota_type, change_type HAVING COUNT(*) > 1;` 模板)。
- **不包含**:任何 `DROP TABLE`、`TRUNCATE`、删除数据的自动执行语句;清理数据仅以注释形式提示,由用户手动确认执行。
### 验证
- `go build ./...`、`go vet ./...`、`read_lints` 检查 tag 改动相关文件,确保无新增错误(tag 为纯字符串注释性元数据,预期零编译影响)。
- 人工核对脚本中 9 个索引与模型中 tag 声明一一对应,列顺序与 `priority` 一致。
@@ -0,0 +1,149 @@
---
name: 新用户注册赠送体验会员
overview: 新增 vip_type=4 体验会员,并在微信登录首次注册时按可配置天数(默认 3 天,允许 0)幂等发放,同时下发系统通知;同步配置、常量、注释、迁移列注释与三份文档。
todos:
- id: add-config
content: 新增 UserDefaultVipDays 配置段(section.go/global/main.go/config.example.yaml)
status: completed
- id: add-vip-type
content: 新增 VIPTypeTrial=4 与 DefaultVipDays 常量并同步模型与 DTO 注释
status: completed
- id: impl-register-gift
content: 实现 ensureTrialVIP:幂等发放、回填缓存字段、系统通知
status: completed
dependencies:
- add-config
- add-vip-type
- id: add-migration
content: 新增 000017 迁移更新三处 vip_type 列注释
status: completed
dependencies:
- add-vip-type
- id: sync-docs
content: 同步接口文档、README 与 Stitch 的体验会员说明
status: completed
dependencies:
- impl-register-gift
- id: verify
content: 编译校验并用 [subagent:code-explorer] 复核枚举同步与幂等缓存
status: completed
dependencies:
- impl-register-gift
- add-migration
- sync-docs
---
## 产品概述
为「纸条AI衣橱」新增「新用户注册赠送体验会员」能力:微信首次登录自动注册后,同步赠送 3 天体验会员(新增 `vip_type=4` 体验会员类型),并给用户发一条系统通知;赠送天数可配置、可关闭,失败不影响登录。
## 核心功能
- **注册即送体验会员**:首次注册成功后自动开通 3 天体验会员(`vip_type=4`),到期时间按注册时间 + N×86400 秒累加
- **新增会员类型**:`vip_type` 枚举新增 `4=体验会员`,与既有的非会员(1)/月度(2)/年度(3) 并列,模型、DTO、迁移列注释、接口文档、README、Stitch 设计稿同步
- **天数可配置可关闭**:新增 `UserDefaultVipDays` 配置段,未配置回落内置默认 3 天;显式配 0 表示不赠送;负数视为非法回落默认(与 4 类免费额度的指针三态语义一致)
- **发放幂等**:复用 `vip_flow_logs` 的 `uk_vip_flow_idem(order_no, change_type)` 唯一索引,注册场景以 `register_gift_<userID>` 作为幂等键
- **欢迎通知**:赠送成功后写入一条系统通知(「欢迎礼包已到账」),并递增未读徽标计数
- **不影响登录**:注册赠送为 best-effort,任何失败仅告警;购买、退款回收、权益补偿等既有链路行为不变
## 技术栈
- 语言/框架:Go 1.27,模块 `git.lzcwy.cn/zhengchiliu/zt-closet-backend`
- 配置:`pkg/setting`(viper 只读 `configs/config.yaml`,`ReadSection` → `UnmarshalKey`/mapstructure)
- 存储:MySQL(GORM + 已落地的 golang-migrate 版本化迁移)、Redis(会话/用户信息缓存/未读计数)
- 可观测:OpenTelemetry span(命名已按层区分:`mysqldao.*` / `redisdao.*` / `svc.*`)
## 实现方案
采用「复用既有会员发放链路 + 配置三态指针 + 幂等键带 userID」的方案:注册分支内调用与购买路径同一个 `VIPGrantIdempotent`(事务内原子累加 `vip_expire` 并写流水),只额外传入新的 `vipType=4` 与注册专用的幂等键,从而不新增表、不改发放语义、天然获得并发幂等能力。
关键决策与取舍:
1. **复用 `VIPGrantIdempotent` 而非新写发放函数**:该函数第 51 行已把 `vipType` 作为入参,传 4 即可,SQL 无需改动;其原生 SQL `vip_expire = (CASE WHEN vip_expire > now THEN vip_expire ELSE now END) + days*86400` 保证并发安全与「已过期则重置」语义,重复实现反而易错。
2. **幂等键用 `register_gift_<userID>`**:注册场景没有订单号,而唯一索引是 `(order_no, change_type)` 而非按 user 维度——order_no **必须带 userID**,若写死常量会导致全站只能发放一次(第二个用户注册即冲突回滚)。此做法与 `quota_flow_logs` 在非订单场景把流水 UUID 写入 `order_no` 的既有约定一致,`order_no VARCHAR(64)` 长度充足。
3. **配置用对象段 + `*int64`**:`UserDefaultVipDays: { Days: 3 }`,与 `UserDefaultQuota` 同构、共用 `ReadSection`,零解码风险;指针用于区分「未配置(nil)→回落 3 天」与「显式 0→不赠送」,沿用刚落地的额度配置语义,避免二义。
4. **不改已应用的迁移文件**:`000001`/`000010` 的列注释更新走新增的 `000017`(`ALTER TABLE ... MODIFY COLUMN`),保持迁移历史可重放、不漂移。
5. **同步内存 user 对象再写缓存(易漏点)**:注册流程第 97 行 `redisdao.UserInfoSet(ctx, user, 0)` 会把内存对象写入缓存,若赠送后不更新 `user.VIPType/VIPExpire`,缓存会被写成「非会员」导致登录响应与缓存不一致。故赠送成功后必须回读用户并回填这两个字段。
6. **响应层归一(可选,默认纳入)**:`buildUserInfoResponse` 在 `vip_expire <= now` 时把返回 `vip_type` 归一为 1,避免过期会员(尤其是 3 天体验会员)被前端误判为有效会员;只影响响应,不写库。
性能与可靠性:该路径仅在首次注册执行一次;复用既有事务与索引,无额外查询放大。风险点为「缓存被旧对象覆盖」与「幂等键冲突范围」,分别由第 5、2 点覆盖,并以 `go build`/`go vet` + 子代理复核兜底。
## 架构设计
```mermaid
flowchart TD
A["微信登录 /user/wxlogin<br/>首次自动注册"] --> B["ensureDefaultQuota<br/>4 类免费额度"]
B --> C["ensureTrialVIP<br/>读 UserDefaultVipDays 配置"]
C -->|"days <= 0"| Z["不赠送,直接返回"]
C -->|"days > 0"| D["mysqldao.VIPGrantIdempotent<br/>vipType=4, order_no=register_gift_uid"]
D --> E[("users.vip_type=4<br/>vip_expire += days*86400")]
D --> F[("vip_flow_logs<br/>uk_vip_flow_idem 幂等")]
D -->|成功| G["回读用户回填 VIPType/VIPExpire<br/>→ UserInfoSet 缓存正确"]
G --> H["sendNotification<br/>系统通知 + 未读 +1"]
D -->|唯一键冲突| I["视为已发放,跳过"]
```
组件关系:注册服务(`internal/service/user.go`)为唯一编排点,向下依赖 `mysqldao.VIPGrantIdempotent`、`redisdao.UserInfoSet/NotificationUnreadIncr`、`pkg/setting` 配置;与购买链路(`internal/service/order.go` 的 `grantVIP`)并行复用同一 DAO,互不侵入。
## 目录结构
```
pkg/setting/section.go # [NEW 段] 新增 UserDefaultVipDaysSettingS{ Days *int64 },注释 nil/0/负数三态
global/setting.go # [MODIFY] 新增 UserDefaultVipDaysSetting *setting.UserDefaultVipDaysSettingS
main.go # [MODIFY] 追加 ReadSection("UserDefaultVipDays", &global.UserDefaultVipDaysSetting)
configs/config.example.yaml # [MODIFY] 新增 UserDefaultVipDays 段(Days: 3 + 0/负数/删除语义注释)
internal/service/constants.go # [MODIFY] 新增 VIPTypeTrial int8 = 4;新增内置兜底常量 DefaultVipDays int64 = 3
internal/service/user.go # [MODIFY] 新增 defaultVipDays() 与 ensureTrialVIP();注册分支调用并回读回填 user;
# buildUserInfoResponse 过期归一为 1(可选项,默认纳入)
internal/mysqlmodel/user.go # [MODIFY] vip_type 字段注释补 4=体验会员
internal/mysqlmodel/vipflowlog.go # [MODIFY] old/new_vip_type 注释补 4=体验会员
internal/dto/user.go # [MODIFY] vip_type 注释补 4=体验会员
pkg/migration/000017_alter_vip_type_comment.up.sql # [NEW] ALTER 更新 users.vip_type、vip_flow_logs.old/new_vip_type 三处列注释
pkg/migration/000017_alter_vip_type_comment.down.sql # [NEW] 恢复三处列注释为旧文案
接口文档.md # [MODIFY] 360 行 vip_type 枚举补 4;488 行注册流程补「赠送体验会员」
README.md # [MODIFY] 558 行字段枚举补 4;业务流程/配置表补 UserDefaultVipDays 说明
Stitch.md # [MODIFY] 第 12 段会员卡、第 14 段会员标签补「体验会员」展示文案
```
## 关键代码结构
```
// internal/service/constants.go(新增)
const (
VIPTypeNone int8 = 1 // 非会员
VIPTypeMonthly int8 = 2 // 月度会员
VIPTypeYearly int8 = 3 // 年度会员
VIPTypeTrial int8 = 4 // 体验会员(注册赠送,天数由 UserDefaultVipDays 配置)
)
// 内置兜底:仅在 UserDefaultVipDays 未配置或为负数时回落
const DefaultVipDays int64 = 3
// internal/service/user.go(新增,仅签名)
// defaultVipDays 返回注册赠送的体验会员天数:配置未设置(nil)或负数回落 DefaultVipDays,显式 0 表示不赠送。
func defaultVipDays() int64
// ensureTrialVIP 注册赠送体验会员(best-effort):赠送成功返回新的到期时间,未赠送或已发放返回 0。
// 幂等键 register_gift_<userID> 依赖 vip_flow_logs 的 uk_vip_flow_idem(order_no, change_type)。
func ensureTrialVIP(ctx context.Context, userID int64) int64
```
## 实现要点(防回归)
- **幂等键必须含 userID**:`fmt.Sprintf("register_gift_%d", userID)`;写死常量会导致全站只能发放一次。
- **缓存一致性**:赠送成功后必须回读用户并把 `VIPType/VIPExpire` 回填到内存 `user`,否则第 97 行 `redisdao.UserInfoSet(ctx, user, 0)` 会把「非会员」写进缓存。
- **流水字段**:`ChangeType = VIPChangeTypeActivity(3) activity赠送`、`Source = VIPSourceSystem(3) 系统`、`Reason = "新用户注册赠送体验会员"`;`OldVIPType` 取回读前的值(注册场景为 1)。
- **幂等判定**:`isDuplicateKey(err)` → 视为已发放直接返回,不视为失败;其它错误 `slog.WarnContext` 后继续,绝不阻断登录。
- **通知文案**:`sendNotification(ctx, userID, 0, NotificationTypeSystem, "欢迎礼包已到账", "已为你开通 N 天体验会员,有效期至 YYYY-MM-DD")`,best-effort。
- **不扩大范围**:`grantVIP`(购买)、`VIPRevokeIdempotent`(退款回收)、`benefit_grant_tasks` 补偿链路均不动;体验会员续费时 `user.VIPType != 1 && vip_expire > now` 会自然记为「续费」,符合预期。
- **迁移 000017** 用 `ALTER TABLE ... MODIFY COLUMN` 时需完整重复列定义(`TINYINT NOT NULL DEFAULT 0 COMMENT '...'`),down 恢复旧注释;**不要**改动已应用的 000001/000010。
- 不改动 `configs/config.yaml`(用户本地、已 gitignore;其第 2 行 `Domain: *` 为既有非法 YAML 问题,本次不处理)。
- 验证:`go build ./...`、`go vet ./...` 通过;用 code-explorer 复核 vip_type 枚举全量同步、幂等键含 userID、注册后缓存不被写成非会员。
## Agent Extensions
### SubAgent
- **code-explorer**
- Purpose:实现完成后复核三件事——① 全仓库 vip_type 枚举说明(模型 2 处、DTO 1 处、迁移 3 处、接口文档 2 处、README 1 处、Stitch 2 段)是否全部补上 4=体验会员且无遗漏;② `ensureTrialVIP` 的幂等键是否带 userID、幂等分支是否正确、失败是否严格 best-effort 不阻断登录;③ 注册流程中内存 `user` 对象是否在赠送后被正确回填,避免缓存被写成非会员
- Expected outcome:输出差异清单(遗漏位置 / 错误逻辑 / 缓存一致性风险),据其修正后保证注册赠送幂等、可配置、可关闭且与既有会员链路零冲突
@@ -0,0 +1,105 @@
---
name: 生成 Stitch 设计描述文档
overview: 根据 zt-closet-backend(智能衣橱微信小程序后端)的真实能力,在已存在的空文件 /root/zt-closet-backend/Stitch.md 中撰写一份面向 Google Stitch 的完整描述:单一连续、可直接一次性粘贴的提示词,覆盖约 12 个微信小程序页面,视觉风格为现代简约。
design:
architecture:
component: tdesign
styleKeywords:
- 现代简约
- 浅色卡片
- 充足留白
- 细分割线
- 单一强调色
- 微信小程序规范
fontSystem:
fontFamily: PingFang SC
heading:
size: 20px
weight: 600
subheading:
size: 15px
weight: 500
body:
size: 13px
weight: 400
colorSystem:
primary:
- "#5B5BD6"
- "#8B87FF"
- "#F1F0FF"
background:
- "#F5F6F8"
- "#FFFFFF"
text:
- "#1A1A1C"
- "#6B7280"
- "#9CA3AF"
functional:
- "#16A34A"
- "#F59E0B"
- "#EF4444"
- "#3B82F6"
todos:
- id: extract-ui-fields
content: 用 [subagent:code-explorer] 核实 13 屏展示字段与枚举取值示例
status: completed
- id: write-stitch-head
content: 撰写 Stitch.md 前半:产品定位、小程序平台约束、设计语言、导航结构
status: completed
dependencies:
- extract-ui-fields
- id: write-stitch-screens
content: 续写 13 屏逐屏连续描述与空态、加载态、异常态
status: completed
dependencies:
- write-stitch-head
- id: proofread-stitch
content: 校对 Stitch.md:枚举一致、小程序规范、无虚构功能、可整体粘贴
status: completed
dependencies:
- write-stitch-screens
---
## 产品概述
为智能衣橱后端项目生成一份可直接粘贴给 Google Stitch 的 UI 设计提示词,写入项目根目录已存在的空文件 `Stitch.md`。描述面向**微信小程序**前端(非 Web/H5),覆盖全部业务页面,让 Stitch 一次性生成整套现代简约风格的小程序界面原型。
## 核心功能
- **单一完整描述**:整份文档即一段连续提示词,整体复制粘贴即可,不做分屏表格、不做多段 prompt
- **全量 13 屏**:微信授权登录、衣橱、衣物上传、衣物详情/编辑、穿搭列表、穿搭详情、AI 穿搭推荐、每日运势、衣柜分享、通知、额度与会员商城、订单、我的
- **小程序平台约束**:顶部导航栏与右上胶囊按钮、底部 tabBar、375×812 逻辑分辨率、rpx 栅格、无浏览器地址栏
- **现代简约设计语言**:浅灰底 + 白色卡片 + 细分割线 + 充足留白 + 克制中性色 + 单一强调色 + 线性图标
- **每屏完整描述**:页面目标、顶部导航、内容区元素与文案、主要交互、空态/加载态/异常态
- **真实业务状态呈现**:衣物处理状态(排队/抠图/分类/成功/失败)、订单状态(待支付/已支付/已取消/已过期/已退款)、分享状态(正常/已取消/已过期)、穿搭公开与审核、额度与会员、通知未读
- **字段与枚举以代码为准**:衣物 15 维属性、运势四项评分与幸运色/数字、商品五类与价格为分、四类额度等,均取自项目真实数据结构,不虚构后端不存在的能力
## 设计方法
面向 Google Stitch 生成小程序原型:文档开头 1-3 行使用说明,其后为一段可整体复制的连续提示词正文。正文顺序为:产品一句话定位与目标用户 → 平台约束(微信小程序:状态栏、导航胶囊、tabBar、375×812 逻辑分辨率、rpx 栅格、无地址栏)→ 设计语言(浅灰页面底 + 白色卡片 + 细分割线 + 8pt 间距 + 统一圆角 + 单一强调色 + 线性图标 + 中文无衬线字阶)→ 导航结构 → 13 屏逐屏描述 → 全局通用元素(按钮、标签、弹窗、Toast、状态徽标、图片占位)。
页面导航结构:
```mermaid
flowchart LR
S["微信授权登录"] --> A
A["tabBar 衣橱"] --> A1["衣物上传"]
A --> A2["衣物详情/编辑"]
B["tabBar 穿搭"] --> B1["穿搭详情"]
B --> B2["AI 穿搭推荐"]
C["tabBar 运势"] --> C1["每日运势详情"]
D["tabBar 通知"] --> D1["通知列表"]
E["tabBar 我的"] --> E1["额度与会员商城"]
E --> E2["订单列表"]
E --> E3["衣柜分享"]
```
每屏描述统一包含:页面目标、顶部导航标题与返回、内容区元素与示例文案、主要交互、空态/加载态/异常态。真实业务状态需内联到界面元素中(如衣物卡片角标"排队中/抠图中/分类中/失败"、订单卡片状态徽标、分享卡片"已过期"遮罩),使 Stitch 能生成对应状态样式。篇幅控制在 1200-2500 字,便于一次性粘贴。
## Agent Extensions
### SubAgent
- **code-explorer**
- Purpose:读取 internal/dto/ 下 13 个文件与 internal/service/constants.go,提取 13 屏所需的展示字段语义与取值示例(衣物 15 维属性字段名与示例值、穿搭/分享/订单/通知/商品/额度/运势列表项与详情字段),确保 Stitch 描述中的信息结构与真实数据结构一致
- Expected outcome:输出按页面组织的字段事实清单(字段名 + 含义 + 示例取值 + 对应枚举),作为撰写 Stitch.md 的唯一数据依据,杜绝臆造字段与功能
@@ -0,0 +1,136 @@
---
name: 生成前端接口文档
overview: 全量分析 zt-closet-backend 代码,在根目录 `接口文档.md`(当前为空文件)生成一份面向前端工程师的自包含接口文档:覆盖全部 42 个路由的字段级入参/出参、业务逻辑与状态机、调用时序图与 curl 示例、完整错误码表与前端处理约定。README.md 保持不变。
todos:
- id: explore-facts
content: 用 [subagent:code-explorer] 全量抽取路由、DTO 字段与校验、路径参数名、业务规则与错误码事实清单
status: completed
- id: doc-conventions
content: 撰写文档头、目录、通用调用约定、42 路由总表与公共数据结构
status: completed
dependencies:
- explore-facts
- id: doc-user-module
content: 撰写用户、额度、运势模块的字段级接口详解
status: completed
dependencies:
- doc-conventions
- id: doc-clothes-outfit-ai
content: 撰写衣物、穿搭、AI 推荐模块接口详解及异步处理/额度规则
status: completed
dependencies:
- doc-conventions
- id: doc-social
content: 撰写衣柜分享、收藏、点赞、通知模块接口详解及交互规则
status: completed
dependencies:
- doc-conventions
- id: doc-trade
content: 撰写商品、订单模块接口详解,标注支付回调为服务端专用
status: completed
dependencies:
- doc-conventions
- id: doc-biz-errcode
content: 补写业务逻辑与状态机章节、mermaid 时序图、curl 示例、全量错误码表与前端 FAQ
status: completed
dependencies:
- doc-user-module
- doc-clothes-outfit-ai
- doc-social
- doc-trade
- id: doc-verify
content: 对照代码复核路径参数名、字段名差异、枚举取值与错误码,修正不一致
status: completed
dependencies:
- doc-biz-errcode
---
## 用户原始需求
分析 `zt-closet-backend` 全量代码,生成一份**面向前端工程师**的接口文档,并且在文档中把**业务逻辑**也描述清楚。
## 澄清后的补充约定
1. **交付形式**:填充根目录已有的空文件 `接口文档.md`,产出一份自包含 Markdown 文档(含全部 42 个路由、字段级入参/出参、业务逻辑、时序图、错误码、curl 示例),可直接交付前端。不产出 OpenAPI/Swagger 文件。
2. **内容范围(四项全选)**:
- 字段级入参/出参表:必填性、类型、长度限制、枚举含义,必须以 `internal/dto` 的 `json`/`form`/`binding` tag 逐字段核对,不得臆造
- 业务逻辑与状态机:额度扣减/回退、衣物 AI 处理状态机、订单状态机、穿搭公开/审核状态机、衣柜分享状态机、通知触发规则
- 调用时序图 + 示例:mermaid 时序图(登录刷新、上传轮询、AI 推荐保存、下单支付、点赞收藏切换)+ curl/JSON 示例
- 错误码全表与前端处理建议:按业务域列出全部错误码,说明 401 刷新 token、以 `code` 为准、金额单位为分等前端约定
3. **README.md 保持不动**:允许与接口文档有少量重复;发现 README 与代码不一致时,只以代码为准写入接口文档,不修改 README。
## 核心产出内容
- 文档头与服务信息:BasePath `/api/v1`、环境说明、鉴权方式
- 通用调用约定:Bearer Token、统一响应结构、HTTP 状态码与业务码映射、分页约定、金额单位、时间戳单位、幂等约定、轮询策略
- 42 个路由总表(公开 6 + 鉴权 35 + 健康检查 1)
- 11 个业务模块的接口详解(用户、额度、运势、衣物、穿搭、衣柜分享、收藏、点赞、通知、商品、订单、AI 推荐)
- 公共数据结构定义(user_info / ClothesItem / OutfitItem / GoodsItem / OrderItem / NotificationItem / QuotaFlowItem)
- 业务逻辑章节:7 条业务主线 + 状态机图 + 额度规则 + 通知触发规则
- 5 张 mermaid 时序图 + curl/JSON 示例
- 全量错误码表(按业务域分段)+ 前端处理建议
- 前端联调 FAQ / 检查清单
## 约束
- 唯一写入文件:`/root/zt-closet-backend/接口文档.md`
- 所有路径、字段名、枚举值、错误码必须与代码一致;微信支付/退款回调需标注"服务端专用,前端禁止调用"
## 技术栈
- 项目:Go 1.27 + Gin + GORM/MySQL + go-redis + JWT + 腾讯云 COS + 微信支付 V3 + OpenTelemetry
- 文档:Markdown(GFM 表格)+ Mermaid(`sequenceDiagram` / `stateDiagram-v2` / `flowchart`),零新增依赖、零代码改动
## 实施策略
"事实抽取 → 分模块撰写 → 交叉复核"三段式:
1. **事实抽取**:以代码为唯一事实源,按顺序读取
- `internal/routers/router.go`:42 个路由的方法、路径、是否鉴权、路径参数名
- `internal/dto/*.go`(13 个):请求/响应字段、`binding` 校验规则(必填/最大长度/枚举 `oneof`/数组 `min,max`)、`json` 与 `form` 差异
- `internal/routers/api/v1/*.go`(12 个):参数来源(json/query/path/form)、路径参数真实名称(如 `:targetUUID`、`:orderNo`)
- `internal/service/*.go`(16 个):业务规则、状态机、额度扣减/回退、幂等、异步流水线、通知触发
- `internal/mysqlmodel/*.go`:枚举值语义与字段含义
- `pkg/errcode/*.go`(10 个):全量错误码与文案
- `pkg/app/`:统一响应结构 `{code, msg, data}` 与 `details` 行为
- `internal/middleware/`:JWT 鉴权、401/403 行为
2. **分模块撰写**:按业务域顺序写入同一文件,先写通用约定与公共数据结构,再写各模块接口,最后写业务规则、时序图、错误码与 FAQ,保证前端从上到下即可读懂。
3. **交叉复核**:逐项比对路由总数、路径参数名、请求/响应字段名差异、枚举取值,确保无遗漏无臆造。
## 关键设计决策
- **单一文件自包含**:前端只需一个文件,不拆分为多文档;用锚点目录做导航。
- **公共数据结构集中定义**:`user_info`、ClothesItem、OutfitItem、GoodsItem、OrderItem、NotificationItem、QuotaFlowItem 在"公共数据结构"章节统一定义,接口章节引用,避免重复与不一致(DRY)。
- **坑位显式高亮**:对已确认的不一致点加粗标注——更新衣物的主颜色请求字段为 `color`(响应为 `color_main`);`GET /like/:targetUUID`、`POST /order/:orderNo/cancel` 的路径参数名非 `uuid`;通知 `is_read` 为 `1 未读 / 2 已读` 而非布尔;支付/退款回调不走统一响应且前端禁止调用。
- **业务与接口分离但互链**:业务逻辑章节独立成章(状态机 + 规则),接口章节每个接口挂"业务逻辑"小节与相关错误码,双向锚点引用。
## 实施注意事项
- **准确性优先**:每个字段表必须来自 dto tag 而非 README;README 仅作素材参考,遇冲突以代码为准且**不改动 README**。
- **性能/规模**:文档较长(预计 1500+ 行),分批追加写入,避免单次超长写入出错;表格列保持统一(字段 / 类型 / 必填 / 说明)。
- **兼容性**:不改动任何 Go 代码、配置与路由,零运行时影响。
## 目录结构
```
/root/zt-closet-backend/
└── 接口文档.md # [MODIFY] 当前为空文件。写入完整前端接口文档:服务信息与 BasePath、通用调用约定
# (鉴权/统一响应/HTTP 与业务码映射/分页/金额与时间单位/幂等/轮询)、42 个路由总表、
# 公共数据结构、11 个业务模块字段级接口详解(含业务逻辑说明)、7 条业务主线与状态机
# (额度/衣物 AI/穿搭/衣柜分享/订单/AI 推荐/通知)、5 张 mermaid 时序图 + curl/JSON
# 示例、全量错误码表、前端联调 FAQ 与检查清单。
```
只读参考(不修改):`internal/routers/router.go`、`internal/dto/*.go`、`internal/routers/api/v1/*.go`、`internal/service/*.go`、`internal/mysqlmodel/*.go`、`pkg/errcode/*.go`、`pkg/app/`、`internal/middleware/`、`README.md`
## Agent Extensions
### SubAgent
- **code-explorer**
- 用途:全量扫描 `internal/routers`、`internal/dto`、`internal/routers/api/v1`、`internal/service`、`internal/mysqlmodel`、`pkg/errcode`、`pkg/app`、`internal/middleware`,抽取路由清单、字段级 DTO 与校验规则、路径参数名、业务状态机与规则、全量错误码
- 预期产出:一份可直接用于撰写的事实清单(路由 × 参数 × 响应 × 枚举 × 错误码 × 业务规则),保证文档零臆造
@@ -0,0 +1,69 @@
---
name: 生成数据库 ER 图 drawio
overview: 基于 internal/mysqlmodel 下 22 张表模型,生成可直接用 draw.io 打开编辑的整体数据库 ER 图文件(docs/zt-closet-er.drawio),按业务域分组布局、全字段展示、标注主键/唯一索引/外键关系。
todos:
- id: create-drawio-skeleton
content: 创建 docs/zt-closet-er.drawio 骨架:四个业务域分区容器(背景色、标题、图例)并规划网格坐标
status: completed
- id: draw-user-domain-entities
content: 绘制用户域 5 张表实体(users、user_quotas、quota_flow_logs、user_daily_usages、user_notifications),含全字段、PK/UK 标注
status: completed
dependencies:
- create-drawio-skeleton
- id: draw-wardrobe-domain-entities
content: 绘制衣橱域 9 张表实体(clothes、outfits、outfit_items、closet_shares、closet_share_items、collects、likes、两个 audit_logs)
status: completed
dependencies:
- create-drawio-skeleton
- id: draw-trade-ai-entities
content: 绘制交易域 5 张表(goods、orders、refund_flow_logs、vip_flow_logs、benefit_grant_tasks)与 AI/运营域 3 张表(horoscopes、ai_recommend_flow_logs、ai_horoscope_flow_logs)
status: completed
dependencies:
- create-drawio-skeleton
- id: draw-relationships
content: 按 20 条关系清单绘制 1:N 连线(乌鸦脚样式、1/N 标签、就近路由),重点优化 users 13 条关联线
status: completed
dependencies:
- draw-user-domain-entities
- draw-wardrobe-domain-entities
- draw-trade-ai-entities
- id: validate-xml-layout
content: 用 Python xml.etree 或 xmllint 校验 XML 合法性,抽查坐标无重叠、连线端点 id 引用完整,修正布局问题
status: completed
dependencies:
- draw-relationships
---
## 产品概述
基于项目现有 22 张数据库表(internal/mysqlmodel 全部模型),生成一份完整的数据库 ER 图,输出为 draw.io 可打开、可编辑的 .drawio 文件,纳入项目 git 管理。
## 核心功能
- **22 张表实体完整呈现**:每张表以「表头 + 字段行」表格样式展示全部字段(含类型、注释),主键加粗标注,唯一索引字段标注 UK,外键字段标注 →目标表。
- **四个业务域分区布局**:用户域、衣橱域、交易域、AI/运营域,每域使用不同底色容器与标题区分,域内表就近排布。
- **关系连线**:按盘点出的 20 条 1:N 关系绘制连线(users 关联 13 张子表、outfits/clothes/goods/orders/closet_shares/horoscopes 各自关联),采用「就近直连 + 简洁路由」保证可读性,连线端使用乌鸦脚样式并标注 1:N。
- **可编辑可扩展**:文件为标准 mxGraph XML 格式,可用 draw.io 桌面版/网页版直接打开编辑,后续模型变更可手工维护。
## 技术方案
- 输出格式:draw.io 原生 mxGraph XML(手写生成,无需第三方库),文件名 `docs/zt-closet-er.drawio`(新建 docs 目录)。
- 布局方案:纵向四层分区(用户域 → 衣橱域 → 交易域 → AI/运营域),每域一个带背景色的父容器(mxCell vertex container),表实体以固定尺寸(约 320x220 起,随字段数增高)网格排布,坐标手工规划避免重叠;users 表置于左上角作为中心实体,减少其 13 条关联线的交叉。
- 实体结构:表头行显示表名(含色带与白字),字段行格式为「字段名: 类型」+ 注释,PK 加粗、UK 标注唯一索引名、FK 后缀 →目标表;Model 公共字段(created_at/updated_at/deleted_at/created_by/updated_by/deleted_by)在大表以「+ 公共字段」折叠行表达,避免图面过长。
- 连线:使用 mxCell edge,source/target 指向实体 id,末端样式(endArrow/endSize/startFill)表达 1:N,edgeLabel 标注「1」与「N」;users 的关联线按目标域就近出口,避免长线穿越。
- 校验:生成后用 Python `xml.etree.ElementTree` 或 `xmllint` 解析验证 XML 合法性,并抽查坐标无重叠、连线端点引用的 id 均存在。
## 实现细节
- 实体清单与字段来源:internal/mysqlmodel 下 22 个模型(users、user_quotas、quota_flow_logs、user_daily_usages、user_notifications、clothes、outfits、outfit_items、closet_shares、closet_share_items、collects、likes、outfit_audit_logs、closet_audit_logs、goods、orders、refund_flow_logs、vip_flow_logs、benefit_grant_tasks、horoscopes、ai_recommend_flow_logs、ai_horoscope_flow_logs)。
- 唯一索引标注:uk_user_wx_open_id、uk_user_quota、uk_quota_flow_idem、uk_daily_usage_user_date、uk_collect_user_target、uk_like_user_target、uk_order_no、uk_refund_flow_no、uk_vip_flow_idem、uk_benefit_task_idem、uk_horoscope_date_zodiac。
- 关系清单(20 条 1:N):users→clothes/outfits/closet_shares/user_quotas/user_daily_usages/user_notifications/orders/refund_flow_logs/vip_flow_logs/benefit_grant_tasks/collects/likes/ai_recommend_flow_logs;outfits→outfit_items/outfit_audit_logs/collects/likes;clothes→outfit_items/closet_share_items;closet_shares→closet_share_items/closet_audit_logs;goods→orders/benefit_grant_tasks;orders→refund_flow_logs/benefit_grant_tasks;horoscopes→ai_horoscope_flow_logs。
- 图面性能:仅静态 XML,无运行时开销;文件预计 40-80KB,生成一次即可。
## 目录结构
```
project-root/
└── docs/
└── zt-closet-er.drawio # [NEW] 数据库 ER 图(draw.io 格式)。包含 4 个业务域分区容器、22 张表实体、20 条 1:N 关系连线、图例说明。mxGraph XML 根节点 mxfile/diagram/mxGraphModel,实体为 vertex,连线为 edge,坐标与样式按分区配色(用户域蓝、衣橱域绿、交易域橙、AI运营域紫)。
```
@@ -0,0 +1,133 @@
---
name: 用户头像上传接口
overview: 新增服务端中转的头像上传接口 POST /api/v1/user/avatar/upload,复用现有衣物上传的图片校验与 COS 上传能力,校验通过后上传 COS 并将头像 key 写入 users.avatar,最后失效用户缓存。
todos:
- id: avatar-service
content: 实现 UserService.AvatarUpload(校验、COS 上传、更新 avatar、失效缓存)与 buildUserInfoResponse 头像 URL 转换
status: completed
- id: avatar-api
content: 新增头像上传 DTO 与 UserHandler.AvatarUpload,注册 POST /user/avatar/upload 路由
status: completed
dependencies:
- avatar-service
- id: avatar-docs
content: 同步更新接口文档.md 与 README.md 的头像上传接口说明
status: completed
dependencies:
- avatar-api
---
## 产品概述
为当前用户新增"头像文件上传"能力。此前后端只允许通过 `PUT /user/profile` 传入头像 URL 字符串,现需提供方案 A(服务端中转):客户端以 multipart 上传头像图片,后端完成校验、存储到 COS,并更新用户头像字段,返回可直接访问的头像 URL。
## 核心功能
- 新增鉴权接口 `POST /api/v1/user/avatar/upload`,multipart 字段名 `file`
- 后端校验上传文件:非空、扩展名白名单、大小上限、图片 magic bytes
- 上传成功后写入 COS,并将 COS 对象键保存到 `users.avatar`
- 更新成功后失效用户信息缓存,返回可访问的头像 URL 供前端立即展示
- 用户信息查询接口对头像做兼容转换:历史 URL 直接透传,新存 COS key 自动转可访问 URL
- 头像上传不消耗衣物 COS 上传额度
## 技术栈
- 语言/框架:Go + Gin(沿用现有项目栈)
- 存储:腾讯云 COS(复用 `global.CosClient`)
- 缓存:Redis(复用 `redisdao.UserInfoDel` 失效用户缓存)
- 持久层:GORM(复用 `mysqldao.UserDao`)
- 工具函数:复用 `pkg/upload` 的文件校验、落盘、COS 上传、URL 生成能力
- 无新增第三方依赖
## 实施方案
### 整体策略
仿照现有衣物上传 `ClothesService.Upload` 的同步链路,在用户模块新增头像上传能力:Handler 解析 multipart → Service 完成校验、落盘、上传 COS、更新数据库、失效缓存 → 返回头像 URL。
### 关键决策
1. **复用现有校验链路与错误码**:扩展名白名单(`global.UploadSetting.AllowedExtensions`)、大小上限(`global.UploadSetting.MaxSizeMB`)、magic bytes 校验,以及 `UploadFileEmpty` / `UploadFileExtNotAllow` / `UploadFileTooLarge` / `UploadFileSaveFailed` 错误码,保持与衣物上传一致。
2. **avatar 存储 COS 对象键而非完整 URL**:与衣物 `processed_image` 存 key 的语义一致,读取时统一经 `upload.GetCosUrl` 转换;对历史通过 `PUT /user/profile` 传入的 http(s) URL 直接透传,保证向后兼容。
3. **COS key 规则**:`{user.UserUUID}/{userID}/avatar/{随机名+原扩展名}`,复用 `FileGenName` 防冲突,且按用户维度隔离,避免跨用户覆盖。
4. **不扣 COS 上传额度**:头像属基础资料,与衣物图片处理(抠图/分类)成本不同,不调用 `consumeCosQuota`。
5. **上传后失效缓存**:调用 `redisdao.UserInfoDel(ctx, userID)`,使 `InfoGetBySelf` / `WxLogin` 下次回读最新头像。
### 数据流
```mermaid
sequenceDiagram
participant C as 客户端
participant H as UserHandler.AvatarUpload
participant S as UserService.AvatarUpload
participant U as pkg/upload
participant COS as 腾讯云 COS
participant D as UserDao
participant R as Redis
C->>H: POST /user/avatar/upload (multipart file)
H->>H: JWT 鉴权 + MaxBytesReader 限流
H->>S: AvatarUpload(ctx, userID, fileHeader)
S->>S: 扩展名/大小/magic bytes 校验 + 读取字节
S->>D: UserGetByID 获取 UserUUID
S->>U: FileGenName + FileSaveTmpBytes
S->>U: FileUploadCos(cosKey)
U->>COS: 上传对象
S->>D: UserUpdateByID(avatar=cosKey)
S->>R: UserInfoDel 失效缓存
S-->>H: 返回头像可访问 URL
H-->>C: 统一响应
```
## 实现细节
### 目录结构
```
project-root/
├── internal/
│ ├── dto/
│ │ └── user.go # [MODIFY] 新增 UserAvatarUploadRequest(空)/ UserAvatarUploadResponse
│ ├── service/
│ │ └── user.go # [MODIFY] 新增 AvatarUpload 方法;buildUserInfoResponse 头像 URL 转换;补充 imports
│ ├── routers/
│ │ ├── router.go # [MODIFY] 注册 POST /user/avatar/upload
│ │ └── api/v1/
│ │ └── user.go # [MODIFY] 新增 UserHandler.AvatarUpload;补充 imports
├── 接口文档.md # [MODIFY] 新增头像上传接口说明
└── README.md # [MODIFY] 同步用户接口说明
```
### 关键代码结构
```
// internal/dto/user.go
type UserAvatarUploadRequest struct{}
type UserAvatarUploadResponse struct {
Avatar string `json:"avatar"` // 头像可访问 URL
}
```
```
// internal/service/user.go
func (s *UserService) AvatarUpload(ctx context.Context, userID int64, fileHeader *multipart.FileHeader) (*dto.UserAvatarUploadResponse, *errcode.Error)
```
`buildUserInfoResponse` 中头像转换逻辑:
```
avatar := user.Avatar
if avatar != "" && !strings.HasPrefix(avatar, "http://") && !strings.HasPrefix(avatar, "https://") {
avatar = upload.GetCosUrl(avatar)
}
```
### 执行要点
- **imports 补充**:`internal/service/user.go` 需新增 `io`、`mime/multipart`、`pkg/upload`;`internal/routers/api/v1/user.go` 需新增 `net/http`、`global`、`pkg/errcode`(若缺)。
- **错误映射**:`FileSaveTmpBytes`、`FileUploadCos` 返回原生 error 时统一映射为 `errcode.UploadFileSaveFailed`;`UserGetByID` 未命中映射 `UserNotFound`,其他 DB 错误映射 `DatabaseError`;`UserUpdateByID` 失败映射 `UserUpdateFailed`。
- **同步读取字节**:multipart 临时文件在请求返回后会被清理,必须在 Service 内同步 `io.ReadAll` 后再异步/后续处理(本方案全同步,无异步环节)。
- **`buildUserInfoResponse` 影响面**:该函数同时被 `InfoGetBySelf` 与 `WxLogin` 使用,头像 URL 转换对两处一并生效,属预期行为,无需额外改动。
- **缓存失效**:复用 `ProfileUpdate` 既有模式 `_ = redisdao.UserInfoDel(ctx, userID)`,失败仅忽略(best-effort),下次读取会回落 DB 回填。
- **兼容性保障**:不修改 `PUT /user/profile` 的现有 avatar 传 URL 语义,避免破坏已有客户端;仅扩展用户信息读取时的 URL 转换。
@@ -0,0 +1,124 @@
---
name: 虚拟支付沙箱环境自动选择
overview: 为虚拟支付增加环境自动选择:保持 EnvAppKey/SandboxAppKey 双槽位配置,按「只填沙箱 key → env=1 沙箱;只填现网 key 或两把都填 → env=0 现网」的规则在客户端初始化时决定激活环境,并把 env 贯穿到道具同步、下单签名、订单记录与回调校验全链路。
todos:
- id: wechat-env-select
content: 改造 pkg/wechat/virtualpay.go:新增 resolveEnvKey 与 client.env,调整初始化校验,道具 API 带 env,QueryOrder/RefundOrder/BuildPaymentSign 去 env 入参并新增 GetVirtualPayEnv
status: completed
- id: service-env-wire
content: 接入 internal/service/virtualpay.go:VirtualOrderCreate 改用 GetVirtualPayEnv 记录订单环境并适配签名调用
status: completed
dependencies:
- wechat-env-select
- id: test-adapt
content: 适配 virtualpay_test.go 现有测试并新增 TestResolveEnvKey 四分支单测
status: completed
dependencies:
- wechat-env-select
- id: sync-config-docs
content: 更新 config.yaml、config.example.yaml 与 README 中 WechatVirtualPay 的环境选择规则说明
status: completed
dependencies:
- wechat-env-select
- id: verify-build
content: 执行 go build、go vet、go test 与 read_lints 全量验证
status: completed
dependencies:
- service-env-wire
- test-adapt
- sync-config-docs
---
## 产品概述
为微信小程序虚拟支付(XPay)增加环境自动选择能力:配置保留双槽位(EnvAppKey 现网 / SandboxAppKey 沙箱),服务启动初始化时按规则自动判定激活环境,并把 env 贯穿到道具同步、下单签名、查单退款全链路。测试与上线切换只需修改配置文件,无需改代码。
## 核心功能
- 环境自动选择:只填沙箱 AppKey 时激活沙箱环境(env=1,测试不真实扣款);只填现网 AppKey 或两把都填时激活现网环境(env=0);两把都空时初始化报错、虚拟支付禁用
- 全链路 env 贯穿:道具上传/发布、订单查询/退款、前端调起签名(signData 的 env 字段与 paySig)均使用激活环境的 key 与 env 值
- 订单记录环境:虚拟支付订单的 xpay_env 字段记录下单时的激活环境,发货推送按此校验(沙箱推送不会误发现网订单)
- 环境切换零代码:切换测试/现网只需改配置后重启,启动时的全量道具同步会自动把商品发布到新激活的环境
## 技术栈
- 语言与框架:Go 1.27(复用现有项目栈,不改架构)
- 涉及模块:pkg/wechat/virtualpay.go(XPay 客户端)、internal/service/virtualpay.go(虚拟支付业务)、pkg/wechat/virtualpay_test.go(单元测试)
- 配置:configs/config.yaml、configs/config.example.yaml(仅更新注释)
## 实现方案
### 环境选择规则(初始化时一次决定)
抽成纯函数便于单测:
```
// resolveEnvKey 按配置决定激活环境:
// EnvAppKey 非空 -> 现网(env=0,两把都填时现网优先);
// EnvAppKey 空且 SandboxAppKey 非空 -> 沙箱(env=1);
// 两把都空 -> 返回错误。
func resolveEnvKey(envAppKey, sandboxAppKey string) (appKey string, env int, err error)
```
`InitVirtualPayClient` 调用该函数得到激活 key 与 env:校验 OfferID 非空 + resolveEnvKey 成功;激活 key 存入 client 的 envAppKey 字段(保持 callXPay 签名逻辑不动),新增 `env int` 字段记录激活环境。日志输出当前激活环境,便于运维确认。
### env 贯穿链路
```mermaid
flowchart LR
A[config.yaml<br/>EnvAppKey / SandboxAppKey] --> B[InitVirtualPayClient<br/>resolveEnvKey]
B --> C[client.envAppKey = 激活key<br/>client.env = 0/1]
C --> D[道具同步 API<br/>body 带 env + 沙箱key签名]
C --> E[BuildPaymentSign<br/>signData.env = client.env<br/>paySig 用激活key]
C --> F[QueryOrder / RefundOrder<br/>内部用 client.env]
E --> G[VirtualOrderCreate<br/>订单 xpay_env = GetVirtualPayEnv]
G --> H[发货推送校验<br/>推送 Env == 订单 xpay_env]
```
### 关键改动点
**pkg/wechat/virtualpay.go**
- 新增 `resolveEnvKey` 纯函数(规则见上)
- `virtualPayClient` 增加 `env int` 字段;`InitVirtualPayClient` 改用 resolveEnvKey(原校验改为 OfferID 非空 + 至少一把 key)
- 道具 API(StartUploadGoods/QueryUploadGoods/StartPublishGoods/QueryPublishGoods)body 增加 `"env": c.env`(微信按 env+key 双校验,注释标注字段联调以官方文档为准)
- `QueryOrder`/`RefundOrder` 删除 env 入参(当前无 service 调用点,改动安全),内部使用 `c.env`
- `BuildPaymentSign` 删除 env 入参,signData 的 env 字段与 paySig 使用 `c.env` 与 `c.envAppKey`(即激活环境)
- 新增导出函数 `GetVirtualPayEnv() (int, error)` 供 service 层记录订单环境
**internal/service/virtualpay.go**
- `VirtualOrderCreate`:`XPayEnv` 由写死的 `wechat.XPayEnvProd` 改为 `wechat.GetVirtualPayEnv()` 返回值;`BuildPaymentSign` 调用去掉 env 实参
- `SyncVirtualGoods` 道具同步调用不变(env 已在 wechat 层内部带入)
- `handleGoodsDeliver` 的 Env 校验逻辑保持不动(订单 xpay_env 与推送 Env 匹配)
**pkg/wechat/virtualpay_test.go**
- 适配 `TestBuildPaymentSign`:client 构造改为含 env 字段、调用去掉 env 实参
- 新增 `TestResolveEnvKey`:覆盖四个分支(只填现网、只填沙箱、两把都填、两把都空)
**配置与文档**
- `configs/config.yaml` 与 `configs/config.example.yaml`:WechatVirtualPay 段注释更新为环境选择规则(只填沙箱 key 走沙箱测试;只填现网 key 或两把都填走现网)
- `README.md` 配置表 WechatVirtualPay 行同步该规则
## 目录结构
```
pkg/wechat/
├── virtualpay.go # [MODIFY] resolveEnvKey 纯函数、client.env 字段、初始化调整、道具 API 带 env、QueryOrder/RefundOrder 去 env 入参、BuildPaymentSign 去 env 入参、GetVirtualPayEnv
└── virtualpay_test.go # [MODIFY] 适配现有签名测试 + 新增 TestResolveEnvKey 四分支单测
internal/service/
└── virtualpay.go # [MODIFY] VirtualOrderCreate 改用 GetVirtualPayEnv 记录订单环境
configs/
├── config.yaml # [MODIFY] WechatVirtualPay 段注释更新(不改已填值)
└── config.example.yaml # [MODIFY] WechatVirtualPay 段注释更新环境选择规则
README.md # [MODIFY] 配置表 WechatVirtualPay 行说明同步
```
## 实现注意事项
- 只改虚拟支付环境选择与 env 贯穿,不动支付架构、权益发放/回退、订单状态机、普通 JSAPI 支付
- 环境切换后重启即可:P0-2 修复后的全量道具同步会自动把商品发布到新激活环境,无需额外迁移
- 沙箱与现网的道具/订单/推送隔离由微信侧保证(按签名 key 与 env 双校验)
- 验证:go build ./...、go vet ./...、go test ./pkg/wechat/ 通过;read_lints 无新增错误
@@ -0,0 +1,147 @@
---
name: 衣柜分享-分享给我的
overview: 新增"分享给我的"能力:通过 closet_share_receivers 关联表记录登录用户访问分享详情的接收关系(一对多、幂等),并新增 GET /closet-share/received 列表接口,返回含分享者昵称/头像的分享列表。
todos:
- id: add-receiver-migration-model
content: 新增 closet_share_receivers 迁移、ClosetShareReceiver 模型与 DAO(幂等 upsert、联表分页查询)
status: completed
- id: add-received-service
content: 扩展 DTO 与 UserGetByIDs,详情记录接收人并实现 ListReceived 服务
status: completed
dependencies:
- add-receiver-migration-model
- id: add-received-handler-route
content: 新增 ReceivedList handler 并注册 /closet-share/received 路由
status: completed
dependencies:
- add-received-service
- id: update-docs
content: 更新接口文档与 README 的衣柜分享模块说明
status: completed
dependencies:
- add-received-handler-route
---
## 产品概述
为后端"衣柜分享"补齐"分享给我的"数据能力。当前 `closet_shares` 只记录分享发起者(`user_id`),没有接收人概念,`GET /closet-share` 仅返回"我发起的分享"。本次改造采用"访问即视为收到"的语义:登录用户(非分享属主)成功访问某个分享详情时,自动建立该用户与分享的接收关系,并提供"我收到的分享"分页列表接口。
## 核心功能
- **接收关系自动记录**:保持现有链接分享方式不变;登录用户首次访问他人分享详情且分享状态正常时,自动幂等记录为接收人,重复访问不产生重复记录,分享属主访问不记录。
- **一对多接收人**:同一分享可被多个登录用户访问,每个接收人各生成一条关联记录,通过 `share_uuid + receiver_user_id` 联合唯一索引去重。
- **"分享给我的"列表接口**:新增 `GET /closet-share/received`,按当前登录用户分页查询其收到的分享,返回分享基本信息、衣物数量、分享状态,并额外返回分享者昵称与头像(`owner_name` / `owner_avatar`),支持按状态筛选。
- **接口兼容**:现有"我的分享"列表响应结构仅新增字段,不破坏既有字段;既有创建、详情、取消分享接口行为保持不变。
## 技术选型
- 语言/框架:Go + Gin(沿用现有项目)
- ORM:GORM(含 `soft_delete.DeletedAt` 软删除)
- 数据库:MySQL,迁移使用 `golang-migrate`(`go:embed *.sql` 自动打包,服务不自动执行迁移)
- 缓存:Redis(访问计数沿用现状,接收关系落 MySQL,保证可查询与持久化)
## 实现方案
### 数据模型
新增关联表 `closet_share_receivers`,与现有表结构规范保持一致(含 `id`、`created_at`、`updated_at`、`deleted_at`、`created_by`、`updated_by`、`deleted_by`):
- `share_uuid VARCHAR(64)`:分享 UUID
- `receiver_user_id BIGINT`:接收人用户 ID
- `UNIQUE KEY uk_closet_share_receiver (share_uuid, receiver_user_id)`:保证一个用户对同一分享只有一条记录
- `KEY idx_closet_share_receivers_user (receiver_user_id)`:支撑"按接收人分页查询"
接收关系写入采用幂等 upsert(唯一索引冲突时 `DO NOTHING`),避免并发访问重复插入。
### 接收关系记录时机
在 `ClosetShareService.Detail` 中,仅当以下条件全部满足时记录接收关系:
1. 分享状态校验通过(未被取消/封禁/过期);
2. 衣物明细加载成功,详情能够正常返回;
3. 当前 `userID > 0` 且非分享属主。
记录为 best-effort:失败仅告警,不阻断详情返回,避免因关联表故障影响详情可用性。
### "分享给我的"列表查询
新增 `ClosetShareService.ListReceived`,通过 DAO 联表 `closet_share_receivers` 与 `closet_shares` 分页查询,避免 N+1:
1. 联表分页取出分享记录(按接收记录 `id DESC` 排序,支持 `status` 过滤);
2. 复用 `ClosetShareItemListByShareUUIDs` 批量统计各分享衣物数量;
3. 新增 `UserDao.UserGetByIDs` 批量查询分享者昵称/头像,头像按现有 `upload.GetCosUrl` 规则转换;
4. 组装列表项返回。
### 路由兼容
在 `GET /closet-share/:uuid` 之前注册静态路由 `GET /closet-share/received`。gin 路由树对静态段优先于参数段,此顺序可确保 `/received` 不被 `/:uuid` 误匹配。
## 实施要点
- **性能**:列表查询一次联表取分享、两次批量查询(衣物明细 + 用户信息),时间复杂度与页大小线性相关;联合唯一索引 + `receiver_user_id` 普通索引保证写入去重与列表检索效率。
- **幂等与并发**:接收关系写入用 `clause.OnConflict{DoNothing: true}`,并发首次访问不会报错或产生重复数据。
- **日志**:复用 `slog.WarnContext` 记录接收关系写入失败,仅含 `share_uuid`、`user_id`、`error`,不记录衣物或用户敏感大字段;避免高频详情访问造成日志刷屏,仅记录异常。
- **安全**:接收关系写入、列表查询均使用 GORM 参数绑定,防 SQL 注入;列表仅返回当前登录用户自身的接收记录,无越权读取面。
- **爆炸半径控制**:不改动既有 `closet_shares` 表结构,不修改创建/详情/取消接口既有返回契约,仅新增字段与新接口;迁移通过独立命令执行,服务启动不受影响。
- **兼容性**:`ClosetShareItemResponse` 新增 `OwnerName`、`OwnerAvatar` 字段,"我的分享"列表暂不填充(空字符串),"分享给我的"列表填充分享者信息。
## 架构设计
```mermaid
flowchart LR
A[登录用户访问 GET /closet-share/:uuid] --> B{状态校验通过且非属主}
B -->|是| C[UPSERT closet_share_receivers<br/>唯一键幂等]
B -->|否| D[返回详情]
C --> D
E[GET /closet-share/received] --> F[JOIN receivers + shares 分页]
F --> G[批量统计衣物数量]
F --> H[批量查询分享者信息]
G --> I[组装列表返回]
H --> I
```
## 目录结构
```
/root/zt-closet-backend/
├── pkg/migration/
│ ├── 000018_create_closet_share_receivers.up.sql # [NEW] 创建 closet_share_receivers 表:标准字段 + share_uuid + receiver_user_id,联合唯一索引与 receiver_user_id 普通索引
│ └── 000018_create_closet_share_receivers.down.sql # [NEW] DROP TABLE IF EXISTS closet_share_receivers
├── internal/mysqlmodel/
│ └── closet.go # [MODIFY] 新增 ClosetShareReceiver 模型及 TableName,唯一索引标签与迁移一致
├── internal/mysqldao/
│ ├── closet.go # [MODIFY] 新增 ClosetShareReceiverDao:NewClosetShareReceiverDao、ClosetShareReceiverUpsert(DO NOTHING 幂等)、ClosetShareReceivedList(联表分页查询 shares)
│ └── user.go # [MODIFY] 新增 UserGetByIDs 批量查询,返回 []mysqlmodel.User,空 ids 直接返回空
├── internal/dto/
│ └── closet.go # [MODIFY] ClosetShareItemResponse 新增 OwnerName/OwnerAvatar;接收列表复用 ClosetShareListRequest 与 ShareListResponse
├── internal/service/
│ └── closet.go # [MODIFY] ClosetShareService 新增 shareReceiverDao;构造器初始化;Detail 状态校验通过后 best-effort 记录接收人;新增 ListReceived 分页查询并批量填充分享者信息
├── internal/routers/api/v1/
│ └── closet.go # [MODIFY] ClosetShareHandler 新增 ReceivedList 方法,绑定 ClosetShareListRequest 并调用 svc.ListReceived
├── internal/routers/
│ └── router.go # [MODIFY] 在 GET /closet-share/:uuid 前注册 auth.GET("/closet-share/received", closetHandler.ReceivedList)
├── 接口文档.md # [MODIFY] 衣柜分享模块新增 GET /closet-share/received 接口说明(请求参数、响应字段、错误码)
└── README.md # [MODIFY] 更新衣柜分享业务流程与接口列表,补充"访问即视为收到"与"分享给我的"说明
```
## 关键代码结构
```
// internal/mysqlmodel/closet.go
type ClosetShareReceiver struct {
Model
ShareUUID string `gorm:"column:share_uuid;uniqueIndex:uk_closet_share_receiver,priority:1"`
ReceiverUserID int64 `gorm:"column:receiver_user_id;uniqueIndex:uk_closet_share_receiver,priority:2"`
}
// internal/mysqldao/closet.go
func (d *ClosetShareReceiverDao) ClosetShareReceiverUpsert(ctx context.Context, shareUUID string, receiverUserID int64) error
func (d *ClosetShareReceiverDao) ClosetShareReceivedList(ctx context.Context, receiverUserID int64, status int8, page, pageSize int) ([]mysqlmodel.ClosetShare, int64, error)
// internal/mysqldao/user.go
func (d *UserDao) UserGetByIDs(ctx context.Context, ids []int64) ([]mysqlmodel.User, error)
// internal/service/closet.go
func (s *ClosetShareService) ListReceived(ctx context.Context, userID int64, request *dto.ClosetShareListRequest) (*dto.ShareListResponse, *errcode.Error)
```
@@ -0,0 +1,70 @@
---
name: 补充 README 接口调用逻辑文档
overview: 在现有 README 的 API 概览基础上,补充通用调用约定(鉴权/响应/分页/幂等/状态码映射)、全量接口入参出参字段表(基于 internal/dto)、核心业务调用流程时序(登录、衣物上传与 AI 处理轮询、AI 推荐、订单支付、点赞收藏),并附关键接口示例,使其他开发者可通过 README 确认调用逻辑。
todos:
- id: extract-contracts
content: 使用 [subagent:code-explorer] 读取全部 dto 与 handler 文件,产出逐接口契约事实清单(绑定方式/请求响应字段/枚举)
status: completed
- id: write-conventions-details
content: 在 README 的 API 概览章节增补通用调用约定与 11 个模块的接口请求/响应字段详解表
status: completed
dependencies:
- extract-contracts
- id: write-flows-examples
content: 增补 5 条核心业务调用流程(mermaid 时序图与步骤说明)及关键接口 curl 调用示例
status: completed
dependencies:
- write-conventions-details
- id: proofread-readme
content: 全文校对:字段、枚举、错误码、路由与代码一致,检查 Markdown 与 mermaid 渲染正确
status: completed
dependencies:
- write-flows-examples
---
## 用户需求
在上一轮已完成 README.md(含项目简介、核心功能、技术栈、目录结构、快速开始、配置说明、API 路由概览表、错误码分段表等章节)的基础上,进一步补充完善 README,使其他人(前端 / 联调开发者)仅凭 README 即可确认接口的调用逻辑:
- 每个接口如何调用:方法、路径、请求参数(字段/类型/必填/说明)、响应字段
- 关键业务的调用顺序与依赖关系
- 统一调用约定:鉴权方式、响应结构、HTTP 状态码与业务错误码映射、分页、幂等
- 关键接口的调用示例(curl + 响应 JSON)
交付物:仅修改 `/root/zt-closet-backend/README.md`,在现有内容上增补,不重写已有章节,中文撰写;所有字段名、枚举值、默认值以代码为准,不虚构。
## 核心内容
1. **通用调用约定**:Bearer 鉴权(11000/11001/11002/11004 错误)、统一响应结构(成功/失败 JSON 示例)、HTTP 状态码与业务 code 映射(401/403/429/500)、分页约定(page/page_size,max 50)、幂等约定(点赞/收藏 Action 切换、AI 推荐保存防重)。
2. **接口调用详解**:按 11 个业务模块(用户/额度/运势/衣物/穿搭/衣柜分享/点赞收藏/通知/商品/订单/AI 推荐)为全部 40+ 接口给出请求参数表与响应字段表,字段严格以 `internal/dto` 的 json tag、binding 与注释为准。
3. **核心业务调用流程**:5 条流程(登录与 token 刷新、衣物上传到 AI 处理状态轮询、AI 推荐到保存为穿搭、订单创建到微信支付回调到查询、点赞/收藏切换),配 mermaid 时序图与编号步骤。
4. **调用示例**:登录、衣物上传、状态查询、AI 推荐等关键接口的 curl 命令与响应 JSON。
## 撰写策略
### 事实来源与准确性保障
- 全部接口契约以 `internal/dto/`(13 个文件的 Request/Response 结构体:json tag、binding 约束、中文注释)与 `internal/routers/router.go`(路由注册、path 参数)为准。
- 参数绑定方式(JSON body / GET form / path / multipart file)以 `internal/routers/api/v1/` 下 12 个 handler 文件为准;若 dto 与 handler 不一致,以 handler 与 router.go 为准。
- 枚举与常量(衣物处理状态 1-5、点赞/收藏 Action 1/2、订单状态 1-5 等)以 `internal/service/constants.go` 为准。
### 文档结构(在现有 README 的 API 概览章节内增补)
1. **通用调用约定**:鉴权头格式与失效表现、成功/失败响应 JSON 示例、HTTP 状态码映射表、分页约定、幂等约定。
2. **接口调用详解**:每个模块小节先列路由清单,再逐接口给请求参数表与响应字段表,采用紧凑三列表格(字段/类型/说明),字段类型按 JSON 语义标注(string/int/int64/bool/object/array),必填性依据 binding 标签。
3. **核心业务调用流程**:5 条流程各配一张 mermaid sequenceDiagram(参与方:小程序前端、后端、微信/推理服务/大模型),流程图下附编号步骤说明与关键注意点(如衣物上传后立即返回排队状态、前端需轮询 status 接口;订单支付回调为公开路由、注意验签)。
4. **调用示例**:登录、衣物上传(multipart)、状态查询、AI 推荐等关键接口的 curl 示例与响应 JSON。
### 篇幅控制
- 复用已有路由总表与错误码表,不重复整表;详解部分只补充字段级信息。
- 字段过多的响应(如衣物 15 维分类字段)用紧凑表格呈现;公共分页结构说明一次,列表接口仅标注内嵌 PageResponse。
- 保持既有章节编号与顺序不变,仅在 API 概览内插入小节。
## 推荐使用的扩展
### SubAgent
- **code-explorer**
- 用途:读取 `internal/dto/` 全部 13 个文件与 `internal/routers/api/v1/` 全部 12 个 handler 文件,提取每个接口的绑定方式(JSON body/form/path/multipart)、请求与响应字段(json tag、类型、binding、注释)、以及 `internal/service/constants.go` 中的相关枚举常量
- 预期结果:输出一份逐接口的契约事实清单(模块/接口/方法/路径/参数绑定方式/请求字段表/响应字段表/枚举值),作为撰写 README 的唯一事实依据,确保无虚构字段
@@ -0,0 +1,245 @@
---
name: 补齐 mysqldao 数据访问层
overview: 根据 mysqlmodel 全部实体与 dto 暴露的业务接口,补齐 internal/mysqldao 下所有 DAO(单表 CRUD + 常用查询,含分页、软删除、原子扣减、幂等),沿用现有 user.go 风格。
todos:
- id: fix-user-dao
content: 修正 user.go 统一 d.db 并移除 UserQuotaListByUserID
status: completed
- id: add-clothes-outfit-closet-dao
content: 新增 clothes.go、outfit.go、closet.go 三个 DAO 文件
status: completed
- id: add-like-collect-goods-dao
content: 新增 like.go、collect.go、goods.go 三个 DAO 文件
status: completed
- id: add-order-quota-dao
content: 新增 order.go、quota.go 两个 DAO 文件
status: completed
dependencies:
- fix-user-dao
- id: add-horoscope-notification-audit-vip-dao
content: 新增 horoscope.go、notification.go、auditlog.go、vipflowlog.go 四个 DAO 文件
status: completed
- id: verify-build
content: 运行 go build、go vet 与 lint 验证编译通过
status: completed
dependencies:
- fix-user-dao
- add-clothes-outfit-closet-dao
- add-like-collect-goods-dao
- add-order-quota-dao
- add-horoscope-notification-audit-vip-dao
---
## 产品概述
根据现有业务实体(`internal/mysqlmodel`)与接口 DTO(`internal/dto`)推断出的业务逻辑,补齐 `internal/mysqldao` 数据访问层,为后续 service 层提供统一、可测试、可替换的 GORM DAO。
## 核心功能
- **覆盖全部业务实体**:为 User、Clothes、Outfit/OutfitItem、ClosetShare/ClosetShareItem、Like、Collect、Goods、Order/RefundFlowLog、Quota/UserDailyUsage/QuotaFlowLog、Horoscope/AI 流水日志、UserNotification、审核日志、VIPFlowLog 提供 DAO。
- **统一 CRUD 与列表**:主键/UUID/业务编码查询、创建、条件更新、软删除,以及带总数返回的分页列表。
- **业务一致性能力**:Like/Collect 幂等(查记录→更新或创建)、订单/退款回调条件更新防重、配额原子扣减与回退、每日使用计数原子自增、Horoscope 与 AI 流水的幂等查询。
- **风格对齐**:沿用现有 `struct{db *gorm.DB} + NewXxxDao()` 模式,并将方法体统一改为 `d.db.WithContext(ctx)`(顺带修正 user.go 中直接使用 `global.MysqlEngine` 的写法)。
## 约束与边界
- 仅修改/新增 `internal/mysqldao/` 内文件,不实现 service、handler、routers。
- DAO 不引入 `app`/`errcode`,错误统一返回 `error`(含 `gorm.ErrRecordNotFound`),由上层转换。
- 全部使用 GORM 参数绑定防 SQL 注入,不拼接 SQL 字符串。
## 技术栈
- 语言:Go
- ORM:GORM v2(复用 `global.MysqlEngine *gorm.DB`)
- 数据模型:`internal/mysqlmodel`(`Model` 基类含 `ID int64` 主键、`CreatedAt/UpdatedAt int64`、`DeletedAt soft_delete.DeletedAt` 软删除)
## 实现方案
### DAO 组织方式
沿用现有 `user.go` 模式:每个 DAO 为 `struct{ db *gorm.DB }`,提供 `NewXxxDao()` 构造函数注入 `global.MysqlEngine`,方法统一使用 `d.db.WithContext(ctx)`。一个文件承载一个或多个强相关 DAO(如 Outfit 与 OutfitItem 同文件)。
### 关键技术决策
1. **统一 `d.db`**:修正 user.go 直接调用 `global.MysqlEngine` 的写法,使 DAO 可替换、可测试。
2. **软删除**:依赖 `Model.DeletedAt soft_delete.DeletedAt`,gorm 查询自动过滤已删除记录;删除统一走 `db.Delete(&model)` 软删,不硬删。
3. **分页 List 约定**:DAO List 方法签名统一为 `(ctx, 筛选条件..., page, pageSize int) (list, total int64, err error)`,内部先 `Count(&total)` 再 `Offset((page-1)*pageSize).Limit(pageSize).Order("id DESC")`。
4. **Like/Collect 幂等**:目标表 `TargetID` 为 int64 主键(UUID→ID 转换在 service 层);DAO 提供 `GetByTargetAndUser` 供上层判断存在则 `UpdateStatus`,不存在则 `Create`。
5. **订单/退款回调幂等**:提供条件更新 `UpdateStatusByOrderNo(orderNo, fromStatus, toStatus)`,用 `WHERE order_no = ? AND order_status = ?`,以 `RowsAffected == 0` 判定重复回调或状态非法。
6. **配额原子扣减**:`IncreaseUsed` 用 `Where("user_id = ? AND quota_type = ? AND used + ? <= total", ...).UpdateColumn("used", gorm.Expr("used + ?", n))`,以 RowsAffected 判断是否超额;`DecreaseUsed` 用 `Where("used >= ?", n)` 递减回退。
7. **每日使用计数原子自增**:`UserDailyUsage` 按 `user_id + stat_date` 唯一,提供按日期查询 + 对应字段 `UpdateColumn(..., gorm.Expr("... + 1"))` 自增。
8. **错误处理**:查询未命中返回 `gorm.ErrRecordNotFound`(`First` 直接返回),不吞错;Create/Update 直接透传 `.Error`。
## 目录结构
本次共涉及 13 个文件(1 个修改 + 12 个新增),全部位于 `internal/mysqldao/`:
```
internal/mysqldao/
├── user.go # [MODIFY] UserDao 方法体统一 d.db;删除 UserQuotaListByUserID(迁移至 QuotaDao)
├── clothes.go # [NEW] ClothesDao
├── outfit.go # [NEW] OutfitDao + OutfitItemDao
├── closet.go # [NEW] ClosetShareDao + ClosetShareItemDao
├── like.go # [NEW] LikeDao
├── collect.go # [NEW] CollectDao
├── goods.go # [NEW] GoodsDao
├── order.go # [NEW] OrderDao + RefundFlowLogDao
├── quota.go # [NEW] QuotaDao + UserDailyUsageDao + QuotaFlowLogDao
├── horoscope.go # [NEW] HoroscopeDao + AIHoroscopeFlowLogDao + AIRecommendFlowLogDao
├── notification.go # [NEW] NotificationDao
├── auditlog.go # [NEW] ClosetAuditLogDao + OutfitAuditLogDao
└── vipflowlog.go # [NEW] VIPFlowLogDao
```
## 各 DAO 方法清单
### ClothesDao(clothes.go)
- `ClothesCreate(ctx, *Clothes) error`
- `ClothesGetByUUID(ctx, uuid string) (*Clothes, error)`
- `ClothesGetByID(ctx, id int64) (*Clothes, error)`
- `ClothesGetByUUIDs(ctx, uuids []string) ([]Clothes, error)`
- `ClothesGetByIDs(ctx, ids []int64) ([]Clothes, error)`
- `ClothesListByUserID(ctx, userID int64, categoryMain, categorySub string, page, pageSize int) ([]Clothes, int64, error)`(大类/小类为空则不筛选)
- `ClothesUpdateByUUID(ctx, uuid string, updates map[string]any) error`
- `ClothesDeleteByUUID(ctx, uuid string) error`(软删)
### OutfitDao + OutfitItemDao(outfit.go)
- `OutfitCreate/GetByUUID/GetByID/GetByUUIDs`
- `OutfitListByUserID(ctx, userID int64, isPublic, source int8, page, pageSize int) ([]Outfit, int64, error)`(isPublic/source 为 0 时不筛选)
- `OutfitUpdateByUUID(ctx, uuid string, updates map[string]any) error`
- `OutfitUpdateIsPublic(ctx, uuid string, isPublic int8) error`
- `OutfitDeleteByUUID(ctx, uuid string) error`
- `OutfitItemCreateBatch(ctx, items []OutfitItem) error`
- `OutfitItemListByOutfitID(ctx, outfitID int64) ([]OutfitItem, error)`
- `OutfitItemListByOutfitIDs(ctx, outfitIDs []int64) ([]OutfitItem, error)`
- `OutfitItemDeleteByOutfitID(ctx, outfitID int64) error`
### ClosetShareDao + ClosetShareItemDao(closet.go)
- `ClosetShareCreate/GetByUUID`
- `ClosetShareListByUserID(ctx, userID int64, status int8, page, pageSize int) ([]ClosetShare, int64, error)`
- `ClosetShareUpdateStatus(ctx, shareUUID string, status int8) error`(取消/封禁)
- `ClosetShareItemCreateBatch/ListByShareUUID/ListByShareUUIDs/DeleteByShareUUID`
### LikeDao(like.go)
- `LikeCreate(ctx, *Like) error`
- `LikeGetByTargetAndUser(ctx, likeType int8, targetID, userID int64) (*Like, error)`
- `LikeUpdateStatus(ctx, id int64, status int8) error`
- `LikeCountByTarget(ctx, likeType int8, targetID int64) (int64, error)`
- `LikeListUserIDsByTarget(ctx, likeType int8, targetID int64) ([]int64, error)`
### CollectDao(collect.go)
- `CollectCreate/GetByTargetAndUser/UpdateStatus`
- `CollectCountByTarget(ctx, collectType int8, targetID int64) (int64, error)`
- `CollectListTargetIDsByUser(ctx, collectType int8, userID int64) ([]int64, error)`
### GoodsDao(goods.go)
- `GoodsGetByCode(ctx, code string) (*Goods, error)`
- `GoodsGetByID(ctx, id int64) (*Goods, error)`
- `GoodsList(ctx, goodsType int8, page, pageSize int) ([]Goods, int64, error)`(仅 `status = 1` 上架;goodsType 为 0 时不筛选)
### OrderDao + RefundFlowLogDao(order.go)
- `OrderCreate/GetByID/GetByOrderNo`
- `OrderListByUserID(ctx, userID int64, status int8, page, pageSize int) ([]Order, int64, error)`
- `OrderUpdateByOrderNo(ctx, orderNo string, updates map[string]any) error`
- `OrderUpdateStatusByOrderNo(ctx, orderNo string, fromStatus, toStatus int8) (bool, error)`(条件更新,RowsAffected 判重)
- `RefundFlowLogCreate/GetByRefundNo/GetByOrderNo`
- `RefundFlowLogUpdateStatus(ctx, refundNo string, updates map[string]any) error`
### QuotaDao + UserDailyUsageDao + QuotaFlowLogDao(quota.go)
- `QuotaListByUserID(ctx, userID int64) ([]Quota, error)`(自 user.go 迁移)
- `QuotaGetByUserIDAndType(ctx, userID int64, quotaType int8) (*Quota, error)`
- `QuotaCreate(ctx, *Quota) error`
- `QuotaIncreaseUsed(ctx, userID int64, quotaType int8, n int64) (bool, error)`(原子扣减,超额返回 false)
- `QuotaDecreaseUsed(ctx, userID int64, quotaType int8, n int64) error`(回退)
- `UserDailyUsageGetByUserIDAndDate/Create`
- `UserDailyUsageIncrementAI/IncrementOutfitShare/IncrementClosetShare`
- `QuotaFlowLogCreate/ListByUserID(userID, quotaType, page, pageSize)`
### HoroscopeDao + AI 流水(horoscope.go)
- `HoroscopeGetByZodiacAndDate(ctx, zodiac string, statDate int64) (*Horoscope, error)`
- `HoroscopeCreate(ctx, *Horoscope) error`
- `AIHoroscopeFlowLogGetByRequestID/Create`
- `AIRecommendFlowLogGetByRequestID/Create`
### NotificationDao(notification.go)
- `NotificationCreate/GetByUUID`
- `NotificationListByUserID(ctx, userID int64, isRead int8, page, pageSize int) ([]UserNotification, int64, error)`
- `NotificationUpdateReadByUUID(ctx, uuid string) error`
- `NotificationCountUnread(ctx, userID int64) (int64, error)`
### 审核日志(auditlog.go)
- `ClosetAuditLogCreate/ListByTargetID`
- `OutfitAuditLogCreate/ListByTargetID`
### VIPFlowLogDao(vipflowlog.go)
- `VIPFlowLogCreate`
- `VIPFlowLogListByUserID(ctx, userID int64, page, pageSize int) ([]VIPFlowLog, int64, error)`
## 关键代码结构
### DAO 构造与查询统一模式
```
type XxxDao struct{ db *gorm.DB }
func NewXxxDao() *XxxDao { return &XxxDao{db: global.MysqlEngine} }
func (d *XxxDao) XxxGetByID(ctx context.Context, id int64) (*mysqlmodel.Xxx, error) {
var m mysqlmodel.Xxx
if err := d.db.WithContext(ctx).First(&m, id).Error; err != nil {
return nil, err
}
return &m, nil
}
```
### 分页 List 模式
```
func (d *XxxDao) XxxList(ctx context.Context, page, pageSize int) ([]mysqlmodel.Xxx, int64, error) {
var list []mysqlmodel.Xxx
var total int64
q := d.db.WithContext(ctx).Model(&mysqlmodel.Xxx{})
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
err := q.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&list).Error
return list, total, err
}
```
### 原子扣减 / 条件更新模式
```
// 配额原子扣减:超额时 RowsAffected 为 0
res := d.db.WithContext(ctx).Model(&mysqlmodel.Quota{}).
Where("user_id = ? AND quota_type = ? AND used + ? <= total", userID, quotaType, n).
UpdateColumn("used", gorm.Expr("used + ?", n))
if res.Error != nil { return false, res.Error }
return res.RowsAffected > 0, nil
// 订单状态条件流转:防重复回调
res := d.db.WithContext(ctx).Model(&mysqlmodel.Order{}).
Where("order_no = ? AND order_status = ?", orderNo, fromStatus).
Update("order_status", toStatus)
return res.RowsAffected > 0, res.Error
```
## 实施要点
- **影响范围可控**:仅新增/修改 `internal/mysqldao/`,不触碰 `mysqlmodel`、`dto`、`redisdao` 及其他模块;对外仅移除 user.go 的 `UserQuotaListByUserID`(当前无调用方)。
- **性能**:列表查询采用先 Count 再分页的单次复用 Query 链;批量查询 items 使用 `WHERE id IN (?)` 一次取回,避免 N+1;无额外开销。
- **安全**:所有条件使用 `?` 占位符绑定,不拼接 SQL;软删除交由 gorm 处理。
- **兼容性**:`gorm.ErrRecordNotFound` 原样返回,供 service 层用 `errors.Is` 判断;本次不引入新依赖。
@@ -0,0 +1,61 @@
---
name: 调整 COS 图片存储路径结构
overview: 将 COS 图片对象键从 {userID}/{uuid.png} 改为 {衣物UUID}/{userID}/{uuid.png}(uuid/userid/图片 三层结构),同步调整临时文件保存目录保持一致。
todos:
- id: adjust-cos-path
content: 修改 upload.go 的 FileSaveTmpBytes 签名(userID 改为 subDir)与 clothes.go 的 processImageAsync(subDir/cosKey 三层结构)
status: completed
- id: verify-build
content: 执行 go build ./... 验证编译通过
status: completed
dependencies:
- adjust-cos-path
---
## 用户需求
修改 COS 图片的存储位置,将当前的对象键与本地临时目录结构从 `{userID}/{uuid.png}` 两层改为 `{衣物UUID}/{userID}/{uuid.png}` 三层(即用户表述的「uuid/userid/图片」)。
## 核心变更
- 抠图结果图片在 COS 上的对象键、以及后端本地临时文件目录,统一调整为 `{衣物UUID}/{userID}/{图片文件名}`。
- 衣物详情返回的 `processed_image` 字段仍存该 COS 对象键,前端获取图片 URL 的链路不变。
## 影响范围
- 仅涉及图片上传落盘与 COS 对象键拼接,不影响上传/抠图/分类/额度等其它业务逻辑。
## 技术方案
### 现状
- `internal/service/clothes.go` 的 `processImageAsync`(约 137-145 行)当前生成:
- `processedName := genUUID() + ".png"`
- `cosKey := fmt.Sprintf("%d/%s", userID, processedName)`,即 `{userID}/{uuid.png}`。
- `pkg/upload/upload.go` 的 `FileSaveTmpBytes(ctx, data, fileName, userID)` 当前把临时文件保存到 `TmpSavePath/{userID}/{fileName}`。
- `FileUploadCos(ctx, cosKey)` 以同一个 `cosKey` 作为本地相对路径与 COS 对象键,因此只要 `cosKey` 结构变了,本地读取与 COS 上传会同步适配。
### 修改点
#### 1. `pkg/upload/upload.go` — `FileSaveTmpBytes`
- 将第 4 个参数 `userID int64` 改为 `subDir string`。
- 目录拼接由 `filepath.Join(TmpSavePath, fmt.Sprint(userID))` 改为 `filepath.Join(TmpSavePath, subDir)`。
- 函数职责从「按 userID 建目录」泛化为「按调用方传入的子目录建目录」,保持通用。
#### 2. `internal/service/clothes.go` — `processImageAsync`
- 新增 `subDir := fmt.Sprintf("%s/%d", clothesUUID, userID)`。
- 保存调用改为 `upload.FileSaveTmpBytes(ctx, processedData, processedName, subDir)`。
- `cosKey := fmt.Sprintf("%s/%s", subDir, processedName)`,最终结构为 `{clothesUUID}/{userID}/{uuid.png}`。
- 同步更新该处「对象键含 userID 前缀」的注释,改为三层结构说明。
### 关键设计
- `FileUploadCos` 与 `GetCosUrl` 的签名、逻辑均不变,它们以 `cosKey` 为输入自动适配新结构,改动面最小。
- `FileSaveTmpBytes` 仅 `clothes.go` 一处调用点,修改签名无连锁影响。
### 注意事项
- 旧数据兼容:历史 `processed_image` 中存的是旧两层 key,新代码下 `GetCosUrl` 会拼出 COS 上不存在的对象路径。联调阶段建议清库重传;如需兼容旧数据,需另做数据迁移,本次不处理。
- 验证:改动后执行 `go build ./...` 确保编译通过。
@@ -0,0 +1,86 @@
---
name: 通知列表补充 msg_type 筛选
overview: 为通知列表接口补充 msg_type 筛选字段(1=点赞 2=订单 3=系统),使 Stitch.md 通知页的「全部/点赞/订单/系统」分类筛选可用,并同步接口文档。
todos:
- id: add-msgtype-filter
content: 修改通知 DTO、DAO、Service 三层,增加 msg_type 筛选参数并透传
status: completed
- id: update-api-doc-msgtype
content: 更新接口文档 GET /notification 的 msg_type 参数说明与错误码描述
status: completed
dependencies:
- add-msgtype-filter
- id: verify-build
content: 执行 go build ./... 验证编译通过
status: completed
dependencies:
- add-msgtype-filter
---
## 用户需求
后端通知列表接口 `GET /notification` 目前仅支持 `is_read`(1=未读 2=已读)筛选,缺少 `msg_type`(1=点赞 2=订单 3=系统)筛选字段。而 Stitch.md 通知页已设计「全部、点赞、订单、系统」筛选标签,前后端能力不一致。需为后端补充 `msg_type` 筛选能力,使前端可按通知类型过滤。
## 核心功能
- `NotificationListRequest` 新增 `msg_type` 查询参数(可选,`oneof=1 2 3`,不传表示全部),与现有 `is_read` 可组合筛选。
- `GET /notification` 支持按 `msg_type` 过滤点赞/订单/系统通知,同时保持与 `is_read` 组合使用。
- 同步更新接口文档,补充 `msg_type` 参数说明。
## 约束
- 沿用现有 `is_read` 的筛选风格(`omitempty` + `oneof`,0 表示不筛选)。
- 仅修改通知相关的 DTO、DAO、Service 三个 Go 文件与接口文档,不改动其它业务逻辑。
- 改动后需通过 `go build ./...` 编译验证。
## 技术栈
- 语言:Go 1.27
- 框架:Gin + GORM(复用现有通知模块实现)
- 分层:handler → service → dao,沿现有 `is_read` 筛选链路扩展
## 实现方式
在现有通知列表筛选链路上增加 `msg_type` 维度,三层逐层透传:
1. **DTO 层**(`internal/dto/notification.go`):`NotificationListRequest` 增加字段
```
MsgType int8 `form:"msg_type" binding:"omitempty,oneof=1 2 3"` // 1=点赞 2=订单 3=系统,不传=全部
```
2. **DAO 层**(`internal/mysqldao/notification.go`):`NotificationListByUserID` 增加 `msgType int8` 参数,在现有 `is_read` 过滤后追加:
```
if msgType != 0 {
q = q.Where("msg_type = ?", msgType)
}
```
3. **Service 层**(`internal/service/notification.go`):`List` 调用改为
```
s.notificationDao.NotificationListByUserID(ctx, userID, request.IsRead, request.MsgType, page, pageSize)
```
4. **文档层**(`接口文档.md`):`GET /notification` 请求参数表新增 `msg_type` 行,并说明 `oneof=1 2 3`、不传表示全部;错误码 `10000` 参数错误说明同步。
## 目录结构
```
zt-closet-backend/
├── internal/
│ ├── dto/
│ │ └── notification.go # [MODIFY] NotificationListRequest 新增 MsgType 字段
│ ├── mysqldao/
│ │ └── notification.go # [MODIFY] NotificationListByUserID 增加 msgType 参数与过滤
│ └── service/
│ └── notification.go # [MODIFY] List 透传 request.MsgType
└── 接口文档.md # [MODIFY] GET /notification 补充 msg_type 参数说明
```
## 实现注意
- 参数校验用 `binding:"omitempty,oneof=1 2 3"`,与 `is_read` 的 `omitempty,oneof=1 2` 风格一致;传 0 或不传均表示不筛选。
- `msg_type` 与 `is_read` 为并列条件,可组合使用(如「未读的点赞通知」)。
- 通知类型常量沿用 `NotificationTypeLike=1`、`NotificationTypeOrder=2`、`NotificationTypeSystem=3`,不在 DAO 层引入魔法数字依赖 service 常量(DAO 直接用 `int8` 参数比较)。
@@ -0,0 +1,83 @@
---
name: 配置全面去环境变量化
overview: 移除应用配置的环境变量注入机制(viper AutomaticEnv),将所有配置统一收敛到 configs/config.yaml 直接配置,并更新所有相关注释与校验逻辑。
todos:
- id: remove-viper-env
content: 删除 pkg/setting/setting.go 中 SetEnvPrefix、SetEnvKeyReplacer、AutomaticEnv 三行及 strings import,更新函数注释为仅从 config 读取
status: completed
- id: update-config-comments
content: 将 configs/config.yaml 中 13 处环境变量注入注释统一改为直接填写说明,JWT 密钥处标注必填
status: completed
- id: global-check-verify
content: 执行 go build、go vet、lint 检查,全局搜索确认无 os.Getenv、AutomaticEnv、ZT_CLOSET_ 残留
status: completed
dependencies:
- remove-viper-env
- update-config-comments
---
## 产品概述
彻底移除后端配置的环境变量注入机制,所有配置一律从 `configs/config.yaml` 配置文件读取,环境变量不再参与任何配置项的赋值或覆盖。
## 核心功能
- **禁用 viper 环境变量覆盖**:删除 `pkg/setting/setting.go` 中 `SetEnvPrefix`、`SetEnvKeyReplacer`、`AutomaticEnv` 三处环境变量绑定,配置仅来自 `configs/config.yaml`。
- **更新配置文件说明**:将 `configs/config.yaml` 中 13 处「敏感信息通过环境变量 ZT_CLOSET_XXX 注入」注释统一改为「直接在此填写」说明,并在 JWT 密钥处标注必填要求。
- **全局清理与复查**:确保 Go 代码与配置注释中无 `os.Getenv`、`AutomaticEnv`、`ZT_CLOSET_` 残留引用(第三方 Python 推理服务的库级环境变量除外)。
- **保持现有校验**:`main.go` 中 JWT SecretKey 为空的启动失败校验继续生效,提醒用户必须在配置文件中填写密钥才能启动。
## 技术栈
- 语言:Go
- 配置库:spf13/viper(保留 YAML 文件读取,移除环境变量绑定)
- 配置文件:`configs/config.yaml`(已被 .gitignore 忽略,不入库)
## 实现方案
### 总体策略
环境变量注入在 Go 侧仅存在于 `pkg/setting/setting.go` 的 `NewSetting` 中,且全仓库已无 `os.Getenv` 调用(CORS 中间件此前已改为读取 `global.ServerSetting.Domain`)。因此本次改动为「单点删除 + 注释更新 + 全局复查」,不引入新机制、不影响其余 viper 读取逻辑。
### 核心数据流(改造后)
```
启动 main.go init -> setting.NewSetting()
-> viper 读取 configs/config.yaml(唯一来源)
-> ReadSection 逐节解析到 global.*Setting
```
环境变量不再参与任何环节。
### 模块改动
1. **pkg/setting/setting.go**
- 删除 `vp.SetEnvPrefix("ZT_CLOSET")`、`vp.SetEnvKeyReplacer(...)`、`vp.AutomaticEnv()` 三行。
- 更新 `NewSetting` 顶部注释为「仅从 configs/config.yaml 读取配置,不支持环境变量覆盖」。
- 删除仅被 `strings.NewReplacer` 使用的 `strings` import(不删会导致 unused import 编译错误)。
- 保留 AddConfigPath / SetConfigName / SetConfigType / ReadInConfig 逻辑。
2. **configs/config.yaml**(13 处注释统一改写,值保持空串待用户填写)
- Mysql.Password、Redis.Password、Cos.CosSecretID、Cos.CosSecretKey、Cos.CdnDownloadSecretKey、Tokenhub.APIKey、WechatMini.AppSecret:注释改为「# 直接在此填写(config.yaml 已被 .gitignore 忽略,不会提交到仓库)」。
- JWT.SecretKey:同上,并追加「必填,为空则启动失败」。
- WechatPay.AppID / MchID / APIv3Key / SerialNo / NotifyURL / RefundNotifyURL:注释改为「# 直接在此填写(config.yaml 已被 .gitignore 忽略,不会提交到仓库)」。
3. **configs/config.example.yaml**:已确认为纯占位符示例、无环境变量表述,保持不动(该文件会入库,绝不可填真实密钥)。
4. **main.go**:启动校验逻辑(JWT.SecretKey 非空)无需改动,移除环境变量后仍有效。
### 范围边界
- `thirdparty/matting_server/main.py`、`thirdparty/classification_server/main.py` 中的环境变量是 huggingface/transformers 库运行要求(必须在 import 前设置),不属于本应用配置体系,本次不改动。
- `README.md` 为空文件,不涉及。
### 验证要点
- `go build ./...`、`go vet ./...`、`read_lints` 无新增错误。
- 全局搜索确认 Go 代码与配置文件中无残留 `os.Getenv`、`AutomaticEnv`、`ZT_CLOSET_` 引用(thirdparty Python 除外)。
### 安全说明
- `configs/config.yaml` 已被 `.gitignore` 忽略,敏感信息写入不会入库;提醒用户注意本地文件权限与备份安全。
- `configs/config.example.yaml` 会随代码入库,必须保持占位符示例值。
@@ -0,0 +1,126 @@
---
name: 配额流水事务一致性修复
overview: 按方案 A 将「配额数值变更」与「写配额流水」合并到同一数据库事务,消除 best-effort 静默丢流水导致的账实不符(配额泄漏)风险,并全面排查同类问题、为其余 best-effort 写操作补齐日志。
todos:
- id: dao-transactional-methods
content: 在 internal/mysqldao/quota.go 新增 QuotaConsumeWithFlow、QuotaRefundWithFlow、QuotaInitWithFlow 三个事务方法,并为 QuotaFlowLog 模型补唯一索引 tag
status: completed
- id: service-consume-refund
content: 改造 internal/service/service.go 的 consumeQuota 与 refundQuota 调用新事务方法,删除分离的 writeQuotaFlow 调用及函数
status: completed
dependencies:
- dao-transactional-methods
- id: clothes-delete-transaction
content: 将 internal/service/clothes.go DeleteByUUID 的流水写入移入既有事务闭包,消除事务外写流水窗口
status: completed
dependencies:
- dao-transactional-methods
- id: ensure-default-quota-transaction
content: 将 internal/service/quota.go ensureDefaultQuota 改用 QuotaInitWithFlow 实现并发安全幂等初始化
status: completed
dependencies:
- dao-transactional-methods
- id: best-effort-logging
content: 为 airecommend.go、outfit.go、closet.go、notification.go、service.go recordDailyUsage、user.go 等保留的 best-effort 写操作补失败日志
status: completed
- id: build-verify
content: 执行 go build、go vet 与 lint 检查,回归核对所有 consumeQuota/refundQuota 调用点返回语义
status: completed
dependencies:
- service-consume-refund
- clothes-delete-transaction
- ensure-default-quota-transaction
- best-effort-logging
---
## 产品概述
修复配额流水与配额数值不一致导致的「配额泄漏」问题。核心思路(方案 A):将「扣减/回退配额」与「写配额流水」合并进同一数据库事务,保证数值变更与流水记录强一致;同时全面排查所有同类位置并补齐失败日志。
## 核心功能
- **事务化扣减(consume)**:扣减 used 与写流水在同一事务,流水写失败则整体回滚,返回数据库错误,杜绝「额度已扣但查不到流水」。
- **事务化回退(refund)**:回退 used 与写流水同事务,保持「已用量不足则不回退」的现有语义,并保证流水一致性。
- **事务化初始化(ensureDefaultQuota)**:新用户默认额度创建与流水写入同事务,利用 `uk_user_quota` 唯一索引实现并发安全、幂等初始化。
- **衣物删除回退入事务**:`ClothesDeleteByUUID` 中条件回退 used 与流水写入移入同一事务闭包,消除事务外写流水的丢失窗口。
- **全面排查补日志**:对所有保留的 best-effort 写操作(AI 推荐流水、审核日志、每日计数、通知、缓存等)失败时输出 `slog` 日志,保证可观测性。
- **幂等索引自文档化**:为 `QuotaFlowLog` 模型补 `(order_no, quota_type, change_type)` 联合唯一索引 tag(不改动数据库,仅声明)。
## 行为变化说明
流水表故障时,配额消耗/回退将整体失败并返回 `DatabaseError`(用户需重试),这是方案 A 的预期代价,换来账实一致。
## 技术栈
- 语言:Go
- Web 框架:Gin
- ORM:GORM(事务、`clause.OnConflict`、`clause.Locking`)
- 数据库:MySQL(唯一索引兜底幂等)
- 日志:`log/slog`(复用现有 `slog.ErrorContext/WarnContext` 模式)
## 实现方案
### 总体策略
参照项目内已正确的 `QuotaGrantIdempotent` / `QuotaRevokeIdempotent` 事务模式(`internal/mysqldao/quota.go`),在 DAO 层新增三个「事务内条件更新 + 回读 + 写流水」方法,Service 层改为调用事务方法,删除分离的 best-effort 写流水路径。
### 核心数据流
```mermaid
flowchart LR
A[业务调用 consumeQuota/refundQuota] --> B[构造 QuotaFlowLog]
B --> C[DAO 事务开始]
C --> D[条件更新 used 原子增减]
D --> E{RowsAffected > 0?}
E -- 否 --> F[返回 false/nil 额度不足或已用量不足]
E -- 是 --> G[事务内回读 total/used 填 Old/NewValue]
G --> H[事务内写入流水]
H --> I{写流水失败?}
I -- 是 --> J[整个事务回滚 数值变更撤销 返回错误]
I -- 否 --> K[事务提交 数值与流水强一致]
```
### 关键代码结构
DAO 层新增方法(置于 `internal/mysqldao/quota.go`):
```
// QuotaConsumeWithFlow 事务内扣减配额并写流水。
// 条件更新失败(额度不足)返回 (false, nil);流水写入失败整体回滚并返回 error。
func (d *QuotaDao) QuotaConsumeWithFlow(ctx context.Context, userID int64, quotaType int8, n int64, flow *mysqlmodel.QuotaFlowLog) (bool, error)
// QuotaRefundWithFlow 事务内回退配额并写流水。
// 条件更新失败(已用量不足/重复回退)返回 (false, nil);流水失败整体回滚并返回 error。
func (d *QuotaDao) QuotaRefundWithFlow(ctx context.Context, userID int64, quotaType int8, n int64, flow *mysqlmodel.QuotaFlowLog) (bool, error)
// QuotaInitWithFlow 事务内初始化配额并写流水。
// 依赖 uk_user_quota 唯一索引(OnConflict DoNothing),已存在则跳过返回 nil。
func (d *QuotaDao) QuotaInitWithFlow(ctx context.Context, userID int64, quotaType int8, total int64, flow *mysqlmodel.QuotaFlowLog) error
```
事务内部模式(仿 `QuotaGrantIdempotent`):
1. `tx.Model(&Quota{}).Where("user_id=? AND quota_type=? AND used+?<=total").UpdateColumn("used", gorm.Expr("used + ?", n))`(消费)或 `WHERE used >= ?`(回退);`RowsAffected==0` 直接返回 `(false, nil)`。
2. `tx.Where(...).First(&cur)` 回读真实值,`remain := cur.Total - cur.Used`,填 `flow.OldValue/NewValue`。
3. `tx.Create(flow)`,任何错误返回导致整体回滚。
### 模块改动
- **`internal/mysqldao/quota.go`(核心)**:新增上述三个事务方法;保留 `QuotaIncreaseUsed`/`QuotaDecreaseUsed`(供 `clothes.go` 事务内或未来复用)。
- **`internal/service/service.go`**:`consumeQuota`/`refundQuota` 改为构造 `flow`(含 `QuotaFlowUUID`、`ChangeType`、`OrderNo`、`Reason` 等)后调用事务方法;`false` 分支保持原语义(`QuotaInsufficient` / 告警后返回 nil);删除对 `writeQuotaFlow` 的分离调用。`writeQuotaFlow` 在所有调用点迁移完毕后删除。
- **`internal/service/clothes.go`**:`DeleteByUUID` 将流水写入移入事务闭包——条件更新成功后,在同一 `tx` 内回读 `Quota` 并 `tx.Create(flow)`,删除事务外的 `refunded` 后置写流水逻辑(`refunded` 变量可保留供逻辑判断)。
- **`internal/service/quota.go`**:`ensureDefaultQuota` 改为逐类型调用 `QuotaInitWithFlow`(`OnConflict DoNothing`),移除先查后插的竞态窗口和分离写流水。
- **`internal/mysqlmodel/quotaflowlog.go`**:为 `OrderNo`、`QuotaType`、`ChangeType` 字段补 `uniqueIndex` tag 组合(仅自文档化;需同步提示用户在数据库确认该索引真实存在)。
- **P1 补日志**:`internal/service/airecommend.go`(`writeFlowLog` 与成功流水写入)、`internal/service/outfit.go`(`OutfitAuditLogCreate`)、`internal/service/closet.go`(`ClosetAuditLogCreate`)、`internal/service/service.go`(`recordDailyUsage`)、`internal/service/notification.go`(`sendNotification` 的 `NotificationCreate` 失败分支)、`internal/service/user.go`(`ensureDefaultQuota` 调用处)失败时补 `slog.WarnContext/ErrorContext`(带 user_id、quota_type、error 等关键字段)。
### 性能与可靠性
- 每次消耗/回退新增一次回读 + 一次流水插入,均在同一短事务内完成;与现有 `grantQuota`/`revokeQuota` 事务模式开销一致,无长事务风险。
- 保留原子条件更新语义,避免「先查后改」的并发超扣。
- 日志不打印敏感数据(无 PII/密钥),仅记录业务 ID 与错误信息,避免日志轰炸(这些路径非高频热点)。
### 验证要点
- 编译与静态检查:`go build ./...`、`go vet ./...`、`read_lints` 无新增错误。
- 语义回归核对:`consumeQuota` 调用方(`outfit.go`、`closet.go`、`airecommend.go`、`clothes.go`)仍按 `QuotaInsufficient`/`DatabaseError` 分支处理;`refundQuota` 调用方的 `_ =` 忽略行为不受影响。
- 数据库检查项:确认 `quota_flow_logs` 存在 `(order_no, quota_type, change_type)` 联合唯一索引(幂等兜底前提)。
@@ -0,0 +1,184 @@
---
name: 额度体系改造:每日额度与VIP有效期
overview: 重构额度体系:AI/Outfit/Closet 三类改为每日重置额度(每日总额度 = 免费配置 + 购买赠送,购买赠送随 VIP 有效期失效);Cos 保持永久累计,但购买赠送的 Cos 额度也随 VIP 过期失效。
todos:
- id: dao-layer
content: 改造数据访问层:新增订单赠送额度汇总查询与 Cos 动态总额度消费方法
status: pending
- id: redis-daily
content: 实现 Redis 每日额度原子限流:带限额 Lua 脚本及 consume/refund/value 封装
status: pending
- id: service-core
content: 实现 Service 层额度核心:动态总额度计算、每日额度消费/回退、Cos 消费改造
status: pending
dependencies:
- dao-layer
- redis-daily
- id: biz-switch
content: 切换 AI/Outfit/Closet 业务使用点为每日额度消费,Cos 适配动态总额度
status: pending
dependencies:
- service-core
- id: order-benefit
content: 改造订单权益发放/退款/补偿链路:额度类不再落库发放,仅保留 VIP 发放
status: pending
dependencies:
- service-core
- id: info-config-doc
content: 更新额度信息接口、配置语义注释与 README/接口文档
status: pending
dependencies:
- biz-switch
- order-benefit
---
## 产品概述
将当前「AI 推荐、社区分享(OutfitShare)、衣柜分享(ClosetShare)、上传(Cos)」四类统一永久累计的额度体系,改造为混合额度模型:
- **Cos(上传)**:永久额度,不每日重置;免费部分永久,购买赠送部分随 VIP 有效期生效
- **AI / Outfit / Closet**:每日额度,每天 0 点重置
## 核心功能
1. **Cos 永久额度**:总额度 = 免费永久额度(配置 20)+ 购买赠送额度(仅 VIP 有效期内计入);已用量永久累计,删除衣物/处理失败时回退。
2. **AI / Outfit / Closet 每日额度**:每日总额度 = 免费每日额度(配置 AI:3 / OutfitShare:3 / ClosetShare:0)+ 购买赠送额度(仅 VIP 有效期内计入);当日已用每天 0 点自动清零;ClosetShare:0 表示新用户无免费衣柜分享次数,只能靠购买赠送获得。
3. **购买赠送额度随 VIP 过期失效**:用户 VIP 过期后,购买赠送的 Cos/AI/Outfit/Closet 额度全部失效;重新成为会员后恢复。
4. **消耗顺序**:免费额度与购买赠送额度合并为单一额度池,无需区分消耗顺序(用户已确认「都可以」)。
5. **额度信息接口**:`GET /api/v1/quota/info` 对 Cos 返回永久总额度/累计已用/剩余;对 AI/Outfit/Closet 返回每日总额度/当日已用/当日剩余。
## 技术栈
- 后端语言/框架:Go + Gin(复用现有)
- 数据层:GORM + MySQL(复用现有)
- 缓存/原子计数:Redis + go-redis(复用现有,新增 Lua 原子限流脚本)
- 迁移:golang-migrate(复用现有迁移机制)
## 实现方案
### 核心策略:赠送额度动态计算、不落库
不再通过 `grantQuota` 把购买赠送额度累加进 `user_quotas.total`,而是每次查询/消费时**动态计算**有效总额度:
- `每日总额度(AI/Outfit/Closet) = 免费配置值 + (VIP有效 ? SUM(有效订单对应赠送额度) : 0)`
- `永久总额度(Cos) = 免费配置值 + (VIP有效 ? SUM(有效订单 cos_quota) : 0)`
「有效订单」= `order_status = paid` 且未退款(退款后状态变为 `refunded` 自动排除);VIP 有效性用 `user.vip_expire > now` 判断。免费底值直接读配置,不再依赖被历史数据污染过的 `user_quotas.total`。
### 分渠道消费机制
- **Cos(永久额度)**:继续走 MySQL 事务条件更新 `used`,但将 `total` 上限从表字段改为**动态计算后作为参数传入**,避免 total 被历史购买赠送污染。
- **AI / Outfit / Closet(每日额度)**:用 Redis Lua 脚本原子完成「当日已用 + 1 且不超过每日总额度」的校验与计数,key 按 `user_id:YYYYMMDD` 区分并带 48h TTL 跨天自然过期;MySQL `user_daily_usages` 保留作为统计与 Redis 故障兜底。
### 为什么这样设计
- **不落库赠送额度**:赠送额度与 VIP 有效期强耦合,落库后需要额外的过期回收任务且易产生账实不符;动态计算天然跟随 VIP 状态与订单退款状态,消除过期/退款回收的复杂度和资损窗口。
- **Cos 用 MySQL 事务、每日额度用 Redis 原子计数**:Cos 的 `used` 需要永久累计并写流水,适合事务;每日额度需要高频原子增减与每日清零,Redis INCR + TTL 天然适配,避免每日重置的定时任务。
- **复用现有半成品**:`user_daily_usages` 表、`recordDailyUsage`、`QuotaDaily*` Redis 函数均已存在,只需补上「带限额的原子校验」与业务接入。
### 关键并发与一致性
- 每日额度消费:Redis Lua 脚本内完成「读当日计数 → 判断 < 每日总额度 → INCR → 首次设置 TTL」,保证不超发、并发安全。
- 业务失败回退:AI 推荐失败、分享创建失败等沿用现有 `refundQuota` 思路,新增 `refundDailyQuota`(Redis DECR 并保证不小于 0)。
- Cos 消费:在事务内先读取动态总额度,再条件更新 `used + n <= limit`,避免并发超扣;沿用现有「流水与数值同事务」的强一致模式。
- 幂等:购买赠送额度不再创建额度发放流水,`BenefitGrantTask` 中 Cos/AI/Outfit/Closet 四类改为「记录支付完成但不实际发放」,只保留 VIP 实际发放。
### 性能考量
- 动态总额度需要 `SUM(orders.xxx_quota)`,已存在索引 `idx_orders_user_status(user_id, order_status)` 可支撑;商品 SKU 少、单用户订单量小,直接聚合开销可控。
- 为避免高频消费时反复 SUM + 读用户,可在 Redis 增加「当日总额度缓存」(key 带 YYYYMMDD,TTL 到当天结束),订单支付/退款、VIP 变更时失效;实现时作为可选项,先以直接计算为主、缓存优化为增量。
## 架构设计
```mermaid
flowchart TD
subgraph 额度查询/消费入口
Q[GET /quota/info] --> E[getEffectiveQuota]
C1[AI/Outfit/Closet 业务] --> D[consumeDailyQuota]
C2[Cos 上传] --> P[consumeCosQuota]
end
E --> V{vip_expire > now?}
V -->|是| S[SUM 有效订单赠送额度]
V -->|否| F[免费配置值]
S --> T[每日/永久总额度]
D --> L[Redis Lua 原子限流<br/>当日计数 +1 且 <= 总额度]
L --> M[user_daily_usages 统计]
P --> TX[MySQL 事务<br/>used + 1 且 <= 动态总额度]
TX --> FLOW[写 quota_flow_logs]
T --> Q
```
## 目录结构
```
internal/
├── service/
│ ├── service.go # [MODIFY] 新增 getEffectiveQuota、consumeDailyQuota、refundDailyQuota;改造 Cos consumeQuota 支持动态总额度
│ ├── constants.go # [MODIFY] 更新额度语义注释,补充每日额度/永久额度常量说明
│ ├── quota.go # [MODIFY] ensureDefaultQuota 仅初始化 Cos 底值;InfoGetBySelf 返回每日额度信息
│ ├── airecommend.go # [MODIFY] AI 消费改 consumeDailyQuota,失败回退 refundDailyQuota
│ ├── outfit.go # [MODIFY] OutfitShare 消费改 consumeDailyQuota
│ ├── closet.go # [MODIFY] ClosetShare 消费改 consumeDailyQuota
│ ├── clothes.go # [MODIFY] Cos 消费适配动态总额度(调用点基本不变,内部语义调整)
│ ├── order.go # [MODIFY] grantOrderBenefits/revokeOrderBenefits 不再发放/回退四类额度,仅保留 VIP;doGrantBenefit 对额度类直接置成功
│ └── benefit_compensation.go # [MODIFY] 额度类补偿任务兼容(不发放直接成功)
├── mysqldao/
│ ├── order.go # [MODIFY] 新增 OrderGrantedQuotaSumByUserID:SUM 有效订单四类赠送额度
│ └── quota.go # [MODIFY] 新增 Cos 动态总额度消费方法(传入 limit 条件更新)
├── mysqlmodel/
│ └── quota.go # [MODIFY] 模型注释更新(total 对 Cos 语义调整,AI/Outfit/Closet 不再使用)
├── redisdao/
│ └── quota.go # [MODIFY] 新增带限额 Lua 脚本及 DailyQuotaConsume/Refund/Value 封装
├── redismodel/
│ └── quota.go # [MODIFY] 复用/补充每日额度 key 定义
└── dto/
└── quota.go # [MODIFY] QuotaItemResponse 字段语义注释(Cos 永久 vs 每日),按 quota_type 区分含义
pkg/
└── setting/
└── section.go # [MODIFY] UserDefaultQuotaSettingS 注释更新(AI/Outfit/Closet 为每日免费额度)
configs/
└── config.example.yaml # [MODIFY] UserDefaultQuota 段注释更新(Cos 永久、AI/Outfit/Closet 每日)
README.md # [MODIFY] 额度语义说明同步
接口文档.md # [MODIFY] /quota/info 返回字段语义同步
```
## 关键代码结构
```
// internal/service/service.go
// getEffectiveQuota 返回某类额度的当日/永久有效总额度:
// Cos = 免费配置 + (VIP有效 ? 订单赠送cos : 0);AI/Outfit/Closet = 免费配置 + (VIP有效 ? 订单赠送对应值 : 0)
func getEffectiveQuota(ctx context.Context, userID int64, quotaType int8) (int64, *errcode.Error)
// consumeDailyQuota 原子扣减 AI/Outfit/Closet 当日额度:Redis Lua 校验限额 + INCR
func consumeDailyQuota(ctx context.Context, userID int64, quotaType int8, n int64, reason string) *errcode.Error
// refundDailyQuota 业务失败时回退当日已用计数(DECR 且不小于 0)
func refundDailyQuota(ctx context.Context, userID int64, quotaType int8, n int64)
```
```
-- internal/redisdao/quota.go 中的限流 Lua(伪代码示意)
-- KEYS[1]=daily count key, ARGV[1]=limit, ARGV[2]=ttl
local v = tonumber(redis.call('GET', KEYS[1]) or '0')
if v >= tonumber(ARGV[1]) then return -1 end
local n = redis.call('INCR', KEYS[1])
if n == 1 then redis.call('EXPIRE', KEYS[1], ARGV[2]) end
return n
```
```
// internal/mysqldao/order.go
// OrderGrantedQuotaSum 用户有效订单(order_status=paid)四类赠送额度汇总
type OrderGrantedQuotaSum struct {
CosQuota int64
AIRecommendQuota int64
OutfitShareQuota int64
ClosetShareQuota int64
}
func (d *OrderDao) OrderGrantedQuotaSumByUserID(ctx context.Context, userID int64) (*OrderGrantedQuotaSum, error)
```
@@ -0,0 +1,214 @@
---
name: 额度体系重构:每日额度+凭证有效期+广告激励
overview: 重构额度体系:引入统一额度凭证表(quota_grants)承载会员包/加量包/广告激励三类有有效期赠送;AI/Outfit/Closet 改为每日重置额度,Cos 保持永久库存;新增广告激励发放接口与加量包有效期配置。
todos:
- id: dao-migration
content: 新增 quota_grants 凭证表与 goods.valid_days 迁移,扩展模型与 DAO(凭证 CRUD/汇总/扣减)
status: completed
- id: redis-daily
content: 实现 Redis 每日额度原子限流:带限额 Lua 脚本及 consume/refund/value 封装
status: completed
- id: service-core
content: 实现 Service 层额度核心:getEffectiveQuota、consumeDailyQuota、refundDailyQuota、Cos 凭证消费
status: completed
dependencies:
- dao-migration
- redis-daily
- id: biz-switch
content: 切换 AI/Outfit/Closet/Cos 业务使用点为每日额度与凭证消费
status: completed
dependencies:
- service-core
- id: order-benefit
content: 改造订单权益发放/退款/补偿链路为凭证模型,仅保留 VIP 实际发放
status: completed
dependencies:
- dao-migration
- service-core
- id: ad-reward
content: 新增广告激励发放接口(服务/路由/DTO/幂等),创建当天有效凭证
status: completed
dependencies:
- dao-migration
- service-core
- id: info-config-doc
content: 更新额度信息接口响应、配置语义注释与 README/接口文档
status: completed
dependencies:
- biz-switch
- order-benefit
- ad-reward
---
## 产品概述
将当前 Cos/AI/Outfit/Closet 四类统一永久累计的额度体系,重构为「永久额度 + 每日额度 + 有有效期赠送凭证」的混合模型,并新增广告激励额度来源。
## 核心功能
**免费基础额度**
- Cos 免费上传额度(配置 20):永久,不每天重置。
- AI/Outfit/Closet 免费额度(配置 3/3/0):每日额度,每天 0 点重置;ClosetShare 为 0 表示新用户无免费衣柜分享次数。
**有有效期赠送额度(统一凭证模型)**
- 会员包赠送:随 VIP 有效期,VIP 续期时自动延长(跟随 vip_expire 动态变化)。
- 加量包赠送:独立有效期,每个商品可配置 valid_days 天数,到期失效。
- 广告激励:可配置多类额度(AI/Cos 等),当天有效(当天 23:59:59 到期),后端新增发放接口并幂等防重复领取。
**额度计算与消耗**
- Cos:有效总额度 = 免费配置 + 有效凭证剩余(amount-used);used 永久累计,凭证按先到期先用。
- AI/Outfit/Closet:每日总额度 = 免费配置 + 有效凭证总额;当日已用每天 0 点清零。
- 消耗顺序:免费与赠送合并为单一额度池,无需区分顺序。
**额度信息接口**
- Cos 返回永久总额度/累计已用/剩余;AI/Outfit/Closet 返回每日总额度/当日已用/当日剩余。
## 技术栈
- 后端:Go + Gin(复用现有)
- 数据层:GORM + MySQL(复用现有)
- 缓存与原子计数:Redis + go-redis(复用现有,新增带限额 Lua 脚本)
- 迁移:golang-migrate(复用现有 pkg/migration 机制)
## 实现方案
### 核心策略:统一额度凭证表 quota_grants
新增凭证表承载所有「有有效期赠送」来源(会员包/加量包/广告激励),不再把赠送额度累加进 user_quotas.total,而是每次查询/消费时动态计算有效总额度:
- Cos 有效总额 = 免费配置 + SUM(有效凭证 amount - used)
- AI/Outfit/Closet 每日总额 = 免费配置 + SUM(有效凭证 amount)
「有效凭证」= expire_time > now,或 expire_time=0 且 user.vip_expire > now(会员包跟随 VIP)。
### 分渠道消费
- Cos(永久库存):MySQL 事务 + 凭证维度扣减(先到期先用,减少过期浪费),写 quota_flow_logs。
- AI/Outfit/Closet(每日额度):Redis Lua 原子限流(读当日计数 → 判断小于每日总额 → INCR → 首次设置 48h TTL),MySQL user_daily_usages 保留为统计与兜底。
### 为什么这样设计
- 赠送额度不落库为 total,动态计算天然跟随 VIP 状态、加量包有效期与退款状态,消除过期回收任务与账实不符风险。
- Cos 需永久累计 used 并写流水,走 MySQL 事务;每日额度需高频原子增减与每日清零,走 Redis INCR + TTL。
- 复用现有 user_daily_usages 表与 QuotaDaily* Redis 半成品,只补齐「带限额原子校验」与业务接入。
### 并发与一致性
- 每日额度:Redis Lua 原子完成校验+计数,保证不超发;业务失败 refundDailyQuota(DECR 且不小于 0)。
- Cos:事务内凭证行锁/条件更新,动态限额作为参数传入,避免并发超扣。
- 广告激励:ref_id 唯一索引防重复领取。
- 退款:将对应订单凭证失效/删除(未用部分回收),VIP 走现有 revokeVIP。
### 性能
- SUM 有效凭证已有 user_id+quota_type+expire_time 索引可支撑;商品与单用户凭证量小,直接聚合开销可控。
- 每日总额度可加 Redis 当日缓存(key 带 YYYYMMDD,TTL 到当天结束),订单支付/退款、VIP 变更时失效;作为增量优化。
## 架构设计
```mermaid
flowchart TD
subgraph 额度查询/消费
Q[GET /quota/info] --> E[getEffectiveQuota]
C1[AI/Outfit/Closet 业务] --> D[consumeDailyQuota]
C2[Cos 上传] --> P[consumeCosQuota]
A[广告激励接口] --> G[写 quota_grants 当天凭证]
end
E --> V{vip_expire 或凭证 expire_time}
V --> S[SUM 有效 quota_grants]
S --> T[每日/永久总额度]
D --> L[Redis Lua 原子限流]
L --> M[user_daily_usages 统计]
P --> TX[MySQL 事务凭证扣减]
TX --> FLOW[写 quota_flow_logs]
G --> QGRANT[quota_grants]
```
## 目录结构
```
internal/
├── mysqlmodel/
│ ├── quota.go # [MODIFY] 新增 QuotaGrant 凭证模型;Quota 注释语义更新
│ └── goods.go # [MODIFY] 新增 ValidDays 字段
├── mysqldao/
│ ├── quota.go # [MODIFY] 凭证 CRUD/汇总/扣减;Cos 动态限额消费
│ └── goods.go # [MODIFY] ValidDays 相关查询/更新
├── service/
│ ├── service.go # [MODIFY] getEffectiveQuota、consumeDailyQuota、refundDailyQuota、Cos 消费改造
│ ├── constants.go # [MODIFY] 额度来源/有效期常量补充
│ ├── quota.go # [MODIFY] ensureDefaultQuota 调整;InfoGetBySelf 返回每日额度
│ ├── airecommend.go # [MODIFY] AI 消费改 consumeDailyQuota
│ ├── outfit.go # [MODIFY] OutfitShare 消费改 consumeDailyQuota
│ ├── closet.go # [MODIFY] ClosetShare 消费改 consumeDailyQuota
│ ├── clothes.go # [MODIFY] Cos 消费适配凭证扣减
│ ├── order.go # [MODIFY] 发放/退款改写凭证,不再 grantQuota 累加 total
│ ├── benefit_compensation.go # [MODIFY] 额度类补偿任务兼容
│ └── adreward.go # [NEW] 广告激励发放服务(按类型、当天有效、幂等)
├── routers/
│ ├── api/v1/adreward.go # [NEW] 广告激励发放 handler
│ └── router.go # [MODIFY] 注册广告激励路由
├── redisdao/
│ └── quota.go # [MODIFY] 带限额 Lua 脚本 + DailyQuotaConsume/Refund/Value
├── redismodel/
│ └── quota.go # [MODIFY] 每日额度 key 定义复用/补充
└── dto/
├── quota.go # [MODIFY] 响应结构区分永久/每日额度语义
├── goods.go # [MODIFY] 响应增加 valid_days
└── adreward.go # [NEW] 广告激励发放请求/响应
pkg/
├── migration/ # [NEW] quota_grants 表、goods.valid_days、广告激励相关索引
└── setting/section.go # [MODIFY] 配置语义注释/新增广告激励配置
global/setting.go # [MODIFY] 配置解析补充
configs/config.example.yaml # [MODIFY] 配置语义注释
README.md # [MODIFY] 额度语义说明
接口文档.md # [MODIFY] /quota/info 与广告激励接口文档
```
## 关键代码结构
```
// internal/mysqlmodel/quota.go
// QuotaGrant 有有效期额度凭证(会员包/加量包/广告激励)
type QuotaGrant struct {
Model
UserID int64 `gorm:"column:user_id;index:idx_grant_user_type_expire,priority:1"`
QuotaType int8 `gorm:"column:quota_type;index:idx_grant_user_type_expire,priority:2"`
Amount int64 `gorm:"column:amount"` // 赠送总量
Used int64 `gorm:"column:used"` // 已用(Cos 凭证扣减用)
Source int8 `gorm:"column:source"` // 1=会员包 2=加量包 3=广告激励
ExpireTime int64 `gorm:"column:expire_time;index:idx_grant_user_type_expire,priority:3"` // 0=跟随VIP
RefID string `gorm:"column:ref_id;uniqueIndex:uk_grant_ref"`
}
```
```
// internal/service/service.go
// getEffectiveQuota 返回某类额度有效总额度(Cos 永久库存 / 每日额度上限)
func getEffectiveQuota(ctx context.Context, userID int64, quotaType int8) (int64, *errcode.Error)
// consumeDailyQuota 原子扣减 AI/Outfit/Closet 当日额度
func consumeDailyQuota(ctx context.Context, userID int64, quotaType int8, n int64, reason string) *errcode.Error
// refundDailyQuota 业务失败时回退当日计数
func refundDailyQuota(ctx context.Context, userID int64, quotaType int8, n int64)
```
```
// internal/dto/adreward.go
type AdRewardGrantRequest struct {
RewardType string `json:"reward_type" binding:"required"` // ai/cos/... 可配置映射到 quota_type
Amount int64 `json:"amount" binding:"required,min=1"`
RefID string `json:"ref_id" binding:"required"` // 广告记录ID,幂等键
}
```
+22
View File
@@ -0,0 +1,22 @@
storage/logs/*
storage/tmp/*
configs/config.yaml
# ===== Python =====
__pycache__/
*.py[cod]
*$py.class
.venv/
venv/
env/
*.egg-info/
.pytest_cache/
.mypy_cache/
.ruff_cache/
# ===== 模型权重(模型缓存走 ~/.cache/huggingface,不入库;以下兜底防误提交)=====
*.pth
*.pt
*.ckpt
*.safetensors
thirdparty/matting_server/models/
+1567
View File
File diff suppressed because it is too large Load Diff
+152
View File
@@ -0,0 +1,152 @@
> **使用说明(本段不要粘贴)**:本文件是给 Google Stitch 的**分段粘贴**提示词,共 15 段。请按段号从小到大、**每段单独复制一次**依次粘贴发送给 Stitch:复制范围=该段标题行(`## 第 N 段|…`)**以下**、下一段标题行**以上**的正文,不要包含段标题行。
> **第 1 段必须先发**,它是全局设计基准;第 2~15 段每页一段,每段开头自带「上下文锚点」,保证分次发送时跨页风格一致。请在**同一轮对话**中按顺序发送,不要一次性粘贴多段,也不要跳段。
---
## 第 1 段|全局规范与设计语言(必须先发)
你是一位资深的 Apple 生态产品设计师,精通 iOS 与微信小程序的界面规范。接下来我要让你为一款名为「纸条AI衣橱」的**微信小程序**设计全套高保真 UI 界面原型(共 14 个页面:13 个主页面 + 「创建/编辑穿搭」)。
我会在本条消息给出**全局规范**,随后**逐页单独发送**每一屏的详细描述,请你逐屏生成,并保证全部 14 页风格、组件、配色完全一致。请先阅读并记住以下全局规范,本轮**不要生成任何页面**,只回复「已理解全局规范,请发送第 1 屏」。
【产品定位】这是一款面向年轻女性用户的 AI 衣橱管理工具:用户拍照上传自己的衣物,系统自动抠图并识别衣物的 15 个属性维度(大类、小类、风格、主色、副色、厚度、版型、材质、花纹、袖型、衣长、领型、正式程度、适用场合、视觉冲击力);随后用户可以自由组合衣物创建穿搭、一键生成 AI 穿搭推荐(结合当日星座运势)、把整套衣橱或精选衣物分享给好友、在社区公开自己的穿搭并收获点赞与收藏,还可以购买会员或额度包解锁更多上传、AI 推荐与分享次数。产品气质应当是清爽、克制、值得信赖的私人衣橱助手,而不是电商导购。
【平台与画布约束】所有界面均为微信小程序(WeChat Mini Program),不是 Web 页面,也不是原生 App。画布按 iPhone 逻辑分辨率 375×812pt 设计(即 750rpx 宽),尺寸请用 rpx 思维表达。每个页面顶部必须有微信原生导航栏:居中显示页面标题,右上角固定保留微信胶囊按钮(圆角边框内的「···」与「⊙」)的位置,任何自定义图标都不得覆盖胶囊区域;二级页面左上角为原生返回箭头。底部 tabBar 为微信小程序标准样式:高度约 50pt,半透明毛玻璃(frosted material)底 + 顶部 0.5px 发丝分隔线,每个 tab 为「SF Symbols 风格线性图标 + 中文文字」,选中态为系统蓝 #007AFF、未选中为系统灰 #8E8E93,共 5 个:衣橱、穿搭、运势、通知、我的。全局不得出现浏览器地址栏、汉堡菜单、网页式页脚。
【设计语言】整体遵循 Apple 人机界面指南(HIG)的 iOS 视觉语言:干净、克制、以内容为中心。页面背景统一为 iOS 系统分组灰 #F2F2F7,内容一律承载在纯白 #FFFFFF 卡片中,卡片圆角 16pt 并采用 inset 分组列表(分组之间留 16pt 间距,分组内列表行高 44pt,用 0.5px 发丝级分隔线 #E5E5EA 且分隔线左缩进 16pt 与文字左对齐),不使用彩色阴影与重投影;层次靠背景色、毛玻璃与发丝分隔线区分,避免渐变与强装饰。强调色使用 iOS 系统蓝 #007AFF,辅助色使用系统靛蓝 #5856D6,情感化元素(点赞、收藏、喜欢)使用系统粉 #FF2D55;文字使用系统标签色:主要文字 #000000(label)、次要文字 #8E8E93(secondaryLabel)、占位与弱提示 #C7C7CC(tertiaryLabel);功能色严格使用 Apple 系统色:绿 #34C759、橙 #FF9500、红 #FF3B30、蓝 #007AFF、黄 #FFCC00。字体使用 SF Pro Text / Display 与中文苹方(PingFang SC):iOS 大标题 34px/700 左对齐(上滑时收进导航栏变为 17px 居中)、区块标题 17px/600、正文 15px/400、辅助说明 13px/400、最小注释 11px;金额与时间使用等宽数字(tabular figures)。图标统一采用 SF Symbols 风格:线性、2px 圆角描边、字重与文字协调、单一颜色,不使用多色拟物图标。控件遵循 iOS 规范:主操作为系统蓝填充按钮(圆角 12pt、白字、按下时降低不透明度),次操作为浅灰 #F2F2F7 无边框按钮或系统蓝文字按钮,危险操作为系统红文字,禁用态文字与底色同时降透明度;开关为 iOS 绿色开关,分段控件为灰底滑块,滑块为细轨圆点。导航栏与标签栏使用半透明毛玻璃材质(frosted material)并带 0.5px 发丝分隔线;留白遵循 8pt 栅格,页面左右安全边距 16pt。所有衣物图片为白底或透明底的单件商品图,居中展示在 #F2F2F7 圆角方块内,图片圆角与卡片一致。
【导航结构】5 个 tabBar 页面:衣橱(衣物网格 + 上传入口)、穿搭(我的穿搭 + AI 推荐入口)、运势(今日星座运势)、通知(消息列表)、我的(个人资料、额度、会员、订单、分享管理)。二级页面包括:衣物上传、衣物详情与编辑、穿搭详情、AI 穿搭推荐、衣柜分享(列表 / 创建 / 详情)、额度与会员商城、订单列表、编辑资料。
【页面跳转关系】登录成功进入「衣橱」;衣橱右下悬浮按钮 → 衣物上传 → 处理完成自动进入衣物详情;点击衣物卡片 → 衣物详情;穿搭页点击卡片 → 穿搭详情,点「AI 推荐」→ AI 穿搭推荐,保存后回到穿搭详情;创建穿搭从衣橱多选衣物后进入;运势页「生成今日推荐穿搭」→ AI 穿搭推荐;我的页 → 额度与会员商城 / 订单 / 我的分享 / 我的收藏 / 额度明细 / 编辑资料;我的分享 → 创建分享 → 分享详情;通知项点击后标记为已读;商品购买 → 订单确认 → 订单列表。所有二级页面左上角为返回箭头并支持右滑返回。
【图标与组件规格】图标采用 SF Symbols 语义:衣橱 = hanger、运势 = sparkles、通知 = bell、我的 = person.crop.circle、上传 = camera、点赞 = heart、收藏 = star、分享 = square.and.arrow.up、删除 = trash、编辑 = pencil、会员 = crown、订单 = doc.text、额度 = gauge、设置 = gear、返回 = chevron.left。尺寸:tabBar 图标 24pt,行内图标 20pt,卡片大图标 28pt,头像 40~64pt 圆形。按钮:主按钮高 48pt、圆角 12pt、整宽时左右留 16pt;次按钮高 44pt;输入框高 44pt、圆角 10pt、内边距 12pt、占位文字用 #C7C7CC。布局:卡片内边距 16pt、卡片间距 12pt、分组间距 16pt、页面左右安全边距 16pt、底部安全区 34pt;衣物网格为两列,列间距与行间距均 12pt,卡片圆角 16pt,图片区 1:1,列表行高 44pt,分隔线 0.5px #E5E5EA。
【全局通用元素】状态徽标统一为小圆角胶囊标签(10~11px,系统色 10% 浅底 + 同色深字),颜色取系统灰 / 系统蓝 / 系统橙 / 系统绿 / 系统红。空态统一为「居中 SF Symbols 风格线稿插画(系统蓝线条 + 淡蓝 #E8F2FF 块面)+ 17px 加粗主文案 + 13px 系统灰说明 + 一个系统蓝填充按钮或系统蓝文字引导」。加载态:首屏为骨架屏(#F2F2F7 与 #E5E5EA 圆角块轻微呼吸动画),按钮内为 iOS 风格活动指示器,下拉刷新为微信小程序标准三点动画。轻提示为毛玻璃圆角浮层(白色 80% 底 + 深色文字,圆角 12pt,1.5 秒后自动消失);确认提示为 iOS 风格居中 Alert(毛玻璃白底、圆角 14pt、标题 17px 加粗 + 说明 13px、按钮横向平分,主按钮为系统蓝加粗文字,取消为常规字重,危险操作为系统红 #FF3B30);底部面板为 iOS sheet(顶部居中 36×5pt 灰色 grabber 小横条,顶部圆角 10pt,毛玻璃白底,取消项单独成组并与其他项间隔 8pt)。衣物图未就绪时显示 #F2F2F7 圆角方块 + 居中线性衣架图标。金额统一展示为「¥ + 两位小数」并使用等宽数字(后端单位为分,前端需除以 100),原价用系统灰删除线。时间统一展示为「刚刚 / x 分钟前 / 今天 / 2025-04-15」(后端为 10 位秒级时间戳)。所有列表支持下拉刷新与上拉加载,每页最多 50 条。页面跳转使用微信小程序标准的右滑返回手势与原生导航返回,转场为 iOS 风格的右推与右滑。
【负面约束】不要出现 Material Design 风格(带阴影的圆形浮动按钮、汉堡菜单、黑色 Snackbar)、不要用 emoji 充当功能图标、不要出现英文界面文案(金额符号与 SF Symbols 名称除外)、不要使用真实照片或任何品牌 Logo、不要使用浓烈渐变、霓虹色、拟物质感与彩色投影、不要出现网页式页脚与浏览器地址栏、不要在右上角胶囊区域放置任何自定义按钮、不要虚构后端不存在的字段与能力(穿搭封面图由多件衣物图拼接、不展示收藏数与穿搭创建时间、不展示穿搭作者信息、通知不展示发送者头像、不提供「全部已读」、商品不展示图片与标签、不提供衣物搜索与排序、分享不展示标题与访问量、运势只有综合/爱情/工作/财富四项评分)。
【后续生成约定】
1. 我接下来会按「登录 → 衣橱 → 衣物详情 → 穿搭 → AI 穿搭推荐 → 衣物上传 → 穿搭详情 → 运势 → 衣柜分享 → 通知 → 额度与会员商城 → 订单 → 我的 → 创建/编辑穿搭」的顺序,每屏单独发一条消息,共 14 屏(13 个主页面 + 「创建/编辑穿搭」补充页)。
2. 每屏输出为一个独立画板(竖版 375×812pt),画板顶部标注中文页面名。
3. 收到某屏描述后**只输出该屏**,不要重复输出其他页面,不要复述我的描述。
4. 同一组件(按钮、标签、卡片、列表行、弹窗、空态)在各页保持完全一致。
5. 各屏描述中已内联该屏的空态、加载态与异常态,请在同一画板旁一并生成。
6. 所有文案使用简体中文;图片位置使用 #F2F2F7 占位或简约线稿,不要出现真实照片。
请回复「已理解全局规范,请发送第 1 屏」,我将逐屏发送页面描述。
---
## 第 2 段|第 1 屏 微信授权登录
【上下文锚点】沿用第 1 段全局规范:微信小程序「纸条AI衣橱」,竖版 375×812pt,顶部微信原生导航栏 + 右上胶囊按钮占位,iOS 系统蓝 #007AFF 为强调色,#F2F2F7 分组灰底 + 纯白卡片 + 0.5px #E5E5EA 发丝分隔线,SF Symbols 风格线性图标,SF Pro / 苹方字阶,左右安全边距 16pt。本条为第 1 屏,请只生成本页一个画板(画板顶部标注「微信授权登录」),并一并生成其中的空态/异常态。
全屏页面,无顶部导航标题。顶部 1/3 为淡蓝 #E8F2FF 块面托起一张轻盈的线稿插画:衣架、连衣裙、T 恤、鞋子错落摆放,线条用系统蓝 #007AFF、块面用 #E8F2FF 与 #5856D6 点缀。中部为线性衣架图标组成的产品 Logo 与产品名「纸条AI衣橱」,下方一句 slogan「把你的衣橱,交给 AI 打理」。底部为安全区上方的整宽圆角 14pt 填充按钮「微信一键登录」(按钮内左侧带微信图标),按钮正下方一行 11px 灰色小字「登录即代表同意《用户协议》和《隐私政策》」,其中协议名为可点击的系统蓝文字。页面不要出现手机号输入框、验证码、账号密码等任何登录方式。
---
## 第 3 段|第 2 屏 衣橱(tabBar)
【上下文锚点】沿用第 1 段全局规范:微信小程序「纸条AI衣橱」,竖版 375×812pt,iOS 系统蓝 #007AFF 强调色、#F2F2F7 页面底 + 纯白卡片 + 0.5px #E5E5EA 发丝线,SF Symbols 线性图标,两列衣物网格(列间距与行间距 12pt、卡片圆角 16pt、图片区 1:1)。本条为第 2 屏「衣橱」,属于 5 个 tabBar 页之一(衣橱 / 穿搭 / 运势 / 通知 / 我的,本页为选中态),请只生成本页一个画板。
顶部导航标题「我的衣橱」。标题下方第一行是可横向滑动的分类筛选标签:全部、上装、下装、连衣裙、外套、鞋、配饰(选中为系统蓝实心底白字,未选中为浅灰底深灰字);第二行是一行 11px 灰色统计小字「共 128 件 · 上传额度剩余 13 次」。主体为两列等宽衣物卡片网格:每张卡片为白底圆角,上部为 1:1 浅灰圆角图区,透明底衣物图居中;图区右上角为处理状态徽标——排队中(系统灰)、AI 抠图中(系统蓝)、智能分类中(系统蓝)、识别失败(系统红,点击可重试);下部左侧为衣物名「白色纯棉T恤」(单行截断),下方灰色小字「上装 · T恤 · 白色」。右下角为悬浮圆形系统蓝按钮(内为加号),点击上传衣物。空态:中央衣架线稿插画 + 主文案「衣橱还空着」+ 说明「拍照上传第一件衣物,AI 自动抠图并识别属性」+ 系统蓝按钮「上传衣物」。加载态:两列骨架卡片(灰块呼吸动画)。列表支持下拉刷新与上拉加载更多。
---
## 第 4 段|第 4 屏 衣物详情与编辑
【上下文锚点】沿用第 1 段全局规范:微信小程序「纸条AI衣橱」,竖版 375×812pt,二级页面(左上角原生返回箭头、支持右滑返回),顶部微信原生导航栏 + 右上胶囊占位,iOS 系统蓝 #007AFF,#F2F2F7 底 + 纯白卡片 + 0.5px #E5E5EA 发丝线,inset 分组列表(行高 44pt、分隔线左缩进 16pt)。本条为第 4 屏,请只生成本页一个画板,并同时给出「浏览态」与「编辑态」两个版本。
二级页面,标题「衣物详情」,标题下方右侧放一个「编辑」文字按钮。顶部为占屏约 45% 的浅灰圆角大图区,透明底衣物图居中展示。下方白色卡片:第一行为衣物名「白色纯棉T恤」大字(编辑态为输入框,右侧「保存」按钮);下面是属性列表,按分组以「左侧字段名 + 右侧值」的行式呈现,整行可点击并在底部弹起选择器或输入框,正式程度与视觉冲击力两项用 5 段小进度条加数字「3/5」「4/5」展示,主色一项在文字右侧跟一个同色小色块:大类(上装)、小类(T恤)、风格(简约)、主色(白色)、副色(—)、厚度(薄)、版型(宽松)、材质(纯棉)、花纹(纯色)、袖型(短袖)、衣长(常规)、领型(圆领)、正式程度(3/5)、适用场合(休闲、通勤,标签形式)、视觉冲击力(4/5);再下方是备注区「备注:去年夏天买的,领口略松」。页面底部为红色文字按钮「删除这件衣物」,点击弹确认框「删除后将退回 1 次上传额度,确认删除?」+「取消」「确认删除」(红色)。注意额度回退规则:仅「处理中 / 已成功」的衣物删除时回退 1 次上传额度;「识别失败」的衣物在失败时已自动回退,删除时不再返还,弹窗文案应改为「确认删除?」(不提返还)。
---
## 第 5 段|第 5 屏 穿搭(tabBar)
【上下文锚点】沿用第 1 段全局规范:微信小程序「纸条AI衣橱」,竖版 375×812pt,iOS 系统蓝 #007AFF 强调色、辅助靛蓝 #5856D6、情感粉 #FF2D55,#F2F2F7 底 + 纯白卡片(圆角 16pt)+ 0.5px #E5E5EA 发丝线,SF Symbols 线性图标(点赞 = heart、收藏 = star)。本条为第 5 屏「穿搭」,属于 5 个 tabBar 页之一(本页为选中态),请只生成本页一个画板。
顶部导航标题「穿搭」,下方为分段控件:我的穿搭 / 已收藏(选中段为白底系统蓝字滑块)。其下右侧一行两个入口:描边小按钮「+ 创建穿搭」与系统蓝小按钮「AI 推荐」。主体为纵向卡片流:每张穿搭卡片为白底圆角,顶部为「拼图式」封面——把该穿搭包含的衣物抠图并排错落摆放在浅灰圆角横图上(示例 4 件,最多 10 件;若只有一件则居中放大);下方左侧为穿搭名「初秋通勤三件套」,右侧为状态标签:仅自己可见(系统灰)、已公开(系统蓝)、AI 推荐(系统靛蓝 #5856D6);第二行为穿搭描述「白T配直筒牛仔裤,清爽干净,适合初秋通勤」两行截断;第三行为场景标签「通勤」+ 灰色小字「4 件衣物」;卡片底部右侧为点赞图标与数量「128」、收藏图标(未收藏为线框、已收藏为系统蓝实心)。点击卡片进入穿搭详情。空态:插画 + 「还没有穿搭」+「从衣橱挑几件,搭一套吧」+ 系统蓝按钮「创建穿搭」;收藏 tab 空态为「还没有收藏的穿搭」。
---
## 第 6 段|第 7 屏 AI 穿搭推荐
【上下文锚点】沿用第 1 段全局规范:微信小程序「纸条AI衣橱」,竖版 375×812pt,二级页面(左上角返回箭头、支持右滑返回),iOS 系统蓝 #007AFF 强调色、辅助靛蓝 #5856D6,#F2F2F7 底 + 纯白卡片 + 0.5px #E5E5EA 发丝线,SF Symbols 线性图标,底部固定操作栏位于安全区上方(白底 + 上分割线)。本条为第 7 屏,请生成本页画板,并一并生成「场景选择弹窗」「生成中状态页」「结果页」与三种异常态。
从穿搭页「AI 推荐」按钮进入。进入后首先是场景选择半屏弹窗(可跳过,默认「每日推荐」):两个大卡片单选——每日推荐(按今日运势与衣橱自动搭配)与自定义场景(下方出现输入框,占位文案「例如:海边度假、周一例会、初次约会」),底部系统蓝按钮「开始生成(消耗 1 次推荐额度)」。生成中为全屏状态页:中央是衣物线稿轮播的加载动画,下方文案「AI 正在为你搭配…」+ 灰字「结合今日白羊座运势与你的 128 件衣物」。生成完成后展示结果页:顶部为推荐组合的大幅拼图(4~6 件衣物),下方白卡为 AI 生成的标题「晴日轻复古通勤组合」+ 推荐文案「今日综合运势 4 星,适合以米白针织开衫搭配直筒牛仔裤,清爽又有层次」+ 场景标签「通勤」;再下方是「推荐单品」横向列表(每件为小方图 + 名称 + 小类,如「米白针织开衫 · 上装」)。底部固定栏:系统蓝按钮「保存到我的穿搭」+ 描边按钮「换一套」(下方一行 11px 灰字「换一套将再消耗 1 次推荐额度」;受 180 秒并发限制,上一套生成后 180 秒内点击不发起请求,仅轻提示「正在生成中,请稍候」并禁用按钮)。保存成功后 轻提示「已保存到我的穿搭」并跳转穿搭详情;保存前该穿搭标注标签「待保存」。异常态有三种:衣橱为空或无可用衣物 → 插画 +「还没有可用衣物」+「先上传几件处理成功的衣物吧」+ 按钮「去上传」;额度不足 → 弹窗「AI 推荐额度不足」+ 按钮「去购买」;请求过于频繁(180 秒内重复生成,返回 429)→ 轻提示「正在生成中,请稍候」并禁用「换一套」与「开始生成」按钮。
---
## 第 7 段|第 3 屏 衣物上传
【上下文锚点】沿用第 1 段全局规范:微信小程序「纸条AI衣橱」,竖版 375×812pt,二级页面(左上角返回箭头、支持右滑返回),iOS 系统蓝 #007AFF 强调色、系统红 #FF3B30 危险色,#F2F2F7 底 + 纯白卡片 + 0.5px #E5E5EA 发丝线,SF Symbols 线性图标(上传 = camera),底部面板为 iOS sheet(36×5pt grabber、圆角 10pt、取消项单独成组间隔 8pt)。本条为第 3 屏,请生成本页画板,并一并生成「初始态」「处理中」「失败态」「额度不足弹窗」与「底部动作面板」。
二级页面,标题「上传衣物」。初始态为一块占屏约 40% 的白色虚线圆角上传区,中央为线性相机图标 + 文案「拍照或从相册选择」+ 灰色小字「支持 JPG / PNG,单张不超过 20MB,上传消耗 1 次上传额度」,区域下方为一行灰色说明「AI 将自动抠图并识别 15 项属性」。点击后从底部弹起半屏动作面板:拍照、从相册选择、取消(白色圆角上滑面板,取消项与其他项之间用 8pt 灰间隔)。选择图片后进入处理中状态:顶部为所选图片缩略(圆角),中部为三步横向步骤条「排队中 → AI 抠图 → 智能分类」,当前步骤圆点为系统蓝实心、已完成为对勾、未开始为浅灰;下方文案「AI 正在识别你的衣物…」与小字「通常需要 10 秒左右,请稍候」。处理成功后自动跳转衣物详情;失败态为红色感叹号图标 + 「识别失败:图片抠图失败」+ 灰字「本次上传额度已自动退回」+ 两个按钮「重新上传」(系统蓝)与「手动填写信息」(描边)。若上传前额度为 0:居中弹窗「上传额度不足」+ 说明「当前剩余 0 次,购买额度包后可继续上传」+ 按钮「去购买」(系统蓝)与「取消」。
---
## 第 8 段|第 6 屏 穿搭详情
【上下文锚点】沿用第 1 段全局规范:微信小程序「纸条AI衣橱」,竖版 375×812pt,二级页面(左上角返回箭头、右上「更多」入口),iOS 系统蓝 #007AFF、情感粉 #FF2D55、辅助靛蓝 #5856D6,#F2F2F7 底 + 纯白卡片 + 0.5px #E5E5EA 发丝线,SF Symbols 线性图标(点赞 = heart、收藏 = star、分享 = square.and.arrow.up),底部固定操作栏位于安全区上方。本条为第 6 屏,请生成本页画板,并一并给出「属主视角」「他人浏览视角」两个版本与「公开确认弹窗」。
二级页面,标题为穿搭名。顶部为大幅拼图封面(白底圆角,多件衣物横排错落)。下方白色卡片:穿搭名 + 场景标签「通勤」+ 来源标签(自建 / AI 推荐)+ 公开状态(仅自己可见 / 已公开);描述文案;「包含衣物」横向滚动小卡列表(每件为小方图 + 名称,点击可进入该衣物详情)。底部为固定操作栏(安全区上方白底 + 上分割线):左侧爱心点赞按钮(含数量)与星形收藏按钮,右侧为「公开到社区」系统蓝按钮(已公开时变为描边按钮「取消公开」)与分享图标按钮。点击「公开到社区」弹确认框「公开后将展示在社区供他人点赞收藏,并消耗 1 次社区分享额度」+「取消」「确认公开」。他人浏览公开穿搭时,底部操作栏只保留点赞与收藏。删除与编辑入口放在页面右上「更多」弹出的动作面板中(编辑穿搭、删除穿搭)。
---
## 第 9 段|第 8 屏 运势(tabBar)
【上下文锚点】沿用第 1 段全局规范:微信小程序「纸条AI衣橱」,竖版 375×812pt,iOS 系统蓝 #007AFF、淡蓝块面 #E8F2FF,#F2F2F7 底 + 纯白卡片(圆角 16pt)+ 0.5px #E5E5EA 发丝线,SF Symbols 线性图标(运势 = sparkles),等宽数字。本条为第 8 屏「运势」,属于 5 个 tabBar 页之一(本页为选中态),请只生成本页一个画板,并一并生成加载态与异常态。注意:运势只有综合 / 爱情 / 工作 / 财富四项评分与幸运色、幸运数字,不要出现其他评分维度;星座由用户生日推算,**不提供「切换星座」能力**,右上角只放「去设置生日」文字入口(跳转编辑资料)。
顶部导航标题「每日运势」。页面主体是一张淡蓝 #E8F2FF 圆角大卡(不要浓烈渐变):顶部左侧为星座线性图标与星座名「白羊座」,右侧为日期「2025.04.15」与文字按钮「去设置生日」;中部为「综合运势」标签 + 超大数字「4.0」+ 一排 5 星(4 星为实心系统蓝、1 星为浅灰线框);下方为三项评分行,每行为「名称 + 5 段小进度条 + 分数」:爱情运势 3/5、工作运势 5/5、财富运势 4/5(进度条填充系统蓝、未填充为浅灰);再下方为白色小卡「今日运势」+ 文案「今天适合主动出击,贵人运旺,适合尝试新的穿搭风格」;底部并排两张小卡:幸运色(左侧一个红色小圆点 + 「红色」)与幸运数字(「6」)。页面底部为描边按钮「生成今日推荐穿搭」(跳转 AI 推荐页)。异常态:用户未设置生日 → 插画 + 「还没有设置生日」+「设置生日后即可查看你的星座运势」+ 系统蓝按钮「去完善资料」。加载态为整卡骨架屏;若返回「运势生成中」(首次无缓存异步生成),保持骨架屏并轻提示「运势生成中,请稍后重试」,3~5 秒后自动重试一次。
---
## 第 10 段|第 9 屏 衣柜分享(3 个子页面)
【上下文锚点】沿用第 1 段全局规范:微信小程序「纸条AI衣橱」,竖版 375×812pt,二级页面(左上角返回箭头、支持右滑返回),iOS 系统蓝 #007AFF,#F2F2F7 底 + 纯白卡片 + 0.5px #E5E5EA 发丝线,SF Symbols 线性图标,两列衣物网格(间距 12pt、圆角 16pt、图片区 1:1),底部固定栏位于安全区上方。本条为第 9 屏,包含 ①②③ 三个子页面,请生成三个画板,分别标注「衣柜分享 - 我的分享」「衣柜分享 - 创建分享」「衣柜分享 - 分享详情」,并一并生成其中的有效期面板、分享预览卡片与失效态。
①「我的分享」列表页(从我的页进入,标题「衣柜分享」):右上角下方为「+ 新建分享」描边按钮;列表项为白卡,左侧为衣物小图组成的 2×2 预览网格,右侧第一行「衣柜分享 · 12 件」,第二行类型标签(部分衣物 / 全量衣橱),第三行为有效期「3 天后过期」或「长期有效」;已取消或已过期的卡片整体降透明度并在右上角加盖灰色角标「已取消」「已过期」。
②创建分享页:第一步为两个大卡片单选——部分衣物(说明「挑选几件衣物分享给好友」)与全量快照(说明「分享当前衣橱的全部衣物」);若选部分,进入衣物多选网格(勾选态为卡片系统蓝描边 + 右上系统蓝对勾),底部固定栏显示「已选 6 件」+ 系统蓝按钮「下一步」;随后底部弹起有效期面板:1 天 / 7 天 / 30 天 / 长期有效(单选,选中右侧对勾),底部按钮「确认分享(消耗 1 次衣柜分享额度)」。分享成功后展示分享卡片预览:小程序卡片样式,顶部为分享者头像与昵称「小雨 向你分享了 12 件衣物」,中部为衣物预览横图,底部为「查看衣橱」按钮。
③分享详情页(访客或属主视角):顶部为分享者头像 + 昵称 + 「分享了 12 件衣物」与有效期提示,下方为只读衣物网格;属主视角底部固定栏为「取消分享」红色文字按钮,访客视角无操作栏。分享已失效时整页为失效态:灰色插画 +「该分享已过期或已被取消」+ 描边按钮「返回首页」。
---
## 第 11 段|第 10 屏 通知(tabBar)
【上下文锚点】沿用第 1 段全局规范:微信小程序「纸条AI衣橱」,竖版 375×812pt,iOS 系统蓝 #007AFF、系统红 #FF3B30,#F2F2F7 底 + 纯白卡片 + 0.5px #E5E5EA 发丝线,SF Symbols 线性图标(通知 = bell),未读标记为 6pt 系统蓝小圆点。本条为第 10 屏「通知」,属于 5 个 tabBar 页之一(本页为选中态,tabBar 通知图标右上角带红色未读数徽标「7」),请只生成本页一个画板,并一并生成空态。注意:通知不展示发送者头像,不提供「全部已读」入口。
顶部导航标题「通知」,下方为筛选标签:全部、点赞、订单、系统。列表项为白卡:左侧 40pt 圆形线性图标——点赞为心形(浅粉底 #FFE8EC 红字 #FF3B30)、订单为票据(淡蓝底 #E8F2FF 系统蓝)、系统为铃铛(浅灰底 #F2F2F7 灰字);右侧第一行为标题「收到新的点赞」加粗,右上角为时间「2 小时前」,第二行为内容「你的穿搭「周末休闲通勤套」收到了一个赞」两行截断。未读项在标题左侧带一个 6pt 系统蓝小圆点且卡片底部有一条极浅系统蓝左边线,已读项文字整体偏灰。tabBar 的通知图标右上角带红色未读数徽标「7」。点击通知项进入「通知详情」二级页面(标题「通知详情」,左上角返回箭头):白卡内自上而下为类型圆形图标 + 加粗标题「收到新的点赞」+ 灰色时间「2025-04-15 14:32」+ 完整内容正文「你的穿搭「周末休闲通勤套」收到了一个赞」,进入的同时将该条标记为已读(列表红点消失、文字转灰);不展示发送者头像,不提供「全部已读」入口。空态:铃铛线稿插画 +「还没有消息」+「穿搭被点赞、订单动态都会出现在这里」。
---
## 第 12 段|第 11 屏 额度与会员商城
【上下文锚点】沿用第 1 段全局规范:微信小程序「纸条AI衣橱」,竖版 375×812pt,二级页面(左上角返回箭头、支持右滑返回),iOS 系统蓝 #007AFF、警示橙 #FF9500,#F2F2F7 底 + 纯白卡片(圆角 16pt)+ 0.5px #E5E5EA 发丝线,SF Symbols 线性图标(会员 = crown、上传 = camera、AI = sparkles、衣柜分享 = 衣柜、搭配分享 = hanger),金额展示为「¥ + 两位小数」等宽数字、原价为系统灰删除线。本条为第 11 屏,请生成本页画板,并一并生成「订单确认」子页面。注意:商品不展示图片与标签,只展示名称、描述、售价与原价。
从我的页进入,标题「额度与会员」。顶部为会员卡(淡蓝 #E8F2FF 底、系统蓝文字的圆角大卡):左侧为皇冠线性图标 + 「月度会员」+ 灰字「有效期至 2025-05-15」,右侧为「立即续费」描边按钮;体验会员(新用户注册赠送,通常 3 天)时显示「体验会员」+ 灰字「3 天后到期」;非会员时显示「开通会员,解锁更多 AI 与上传权益」+ 系统蓝按钮「立即开通」。下方为「我的额度」四个小卡组成的 2×2 网格,每卡为白底圆角:名称为「上传额度 / AI 推荐额度 / 社区分享额度 / 衣柜分享额度」,中部为剩余数大字「13」+ 灰字「/ 20」,底部为细进度条(已用比例,系统蓝填充;剩余低于 20% 时进度条转为警示橙 #FF9500)。额度网格下方为「看广告得额度」入口条(白底圆角横条,左侧为 SF Symbols 风格「播放」线性图标 + 文案「看广告得额度」,右侧为「看广告 +N」小系统蓝描边按钮,N 由服务端配置固定):点击弹出微信激励视频广告(小程序原生广告组件),完整观看后自动调后端 `/ad-reward/grant`(前端只传奖励类型与广告记录 ID,不传金额),成功后对应额度余量实时 +N 并轻提示「已到账 +N 额度」;广告未看完或中途关闭不发放;服务端未开启广告激励时整个入口条不展示。加载态:广告加载中按钮内为 iOS 活动指示器;失败态:轻提示「广告加载失败,请稍后重试」。其下为「额度明细」入口行(右侧箭头,进入流水列表:每条为「变更原因 + 数量(-1 / +30)+ 变更后剩余 + 时间」)。再下方为「额度与会员充值」商品区,顶部为类型标签:全部、会员包、上传包、AI 推荐包、衣柜分享包、搭配分享包。商品卡为白底圆角:左侧为类型图标(会员=皇冠、上传=相机、AI=星星、衣柜分享=衣柜、搭配分享=衣架),中部第一行为商品名「月度会员」,第二行为描述「开通后 30 天内无限使用 AI 推荐」灰色小字,右侧为售价「¥19.00」(系统蓝 15px 加粗)与原价「¥29.00」(灰色划线),下方右侧为小系统蓝按钮「购买」。点击购买进入订单确认:商品摘要卡(名称 + 权益说明)+ 金额行「实付 ¥19.00」+ 底部系统蓝按钮「微信支付」。
---
## 第 13 段|第 12 屏 订单
【上下文锚点】沿用第 1 段全局规范:微信小程序「纸条AI衣橱」,竖版 375×812pt,二级页面(左上角返回箭头、支持右滑返回),iOS 系统色:警示橙 #FF9500、成功绿 #34C759、系统灰 #8E8E93、系统蓝 #007AFF,#F2F2F7 底 + 纯白卡片 + 0.5px #E5E5EA 发丝线,SF Symbols 线性图标(订单 = doc.text),金额「¥ + 两位小数」等宽数字。本条为第 12 屏,请生成本页画板,并一并生成「微信支付面板」「申请退款面板」与空态。
顶部导航标题「我的订单」,下方为状态筛选标签:全部、待支付、已支付、已取消、已过期、已退款。列表项为白卡:第一行左侧为订单号尾部「订单 …8b」灰色小字,右侧为状态徽标——待支付(警示橙)、已支付(成功绿)、已取消(浅灰)、已过期(系统灰)、已退款(系统蓝);第二行为商品名「月度会员」+ 权益灰字「30 天会员」;第三行左侧为实付金额「实付 ¥19.00」,右侧为操作按钮:待支付显示倒计时灰字「剩余 14:32」+ 描边按钮「取消订单」+ 系统蓝按钮「去支付」;已支付显示描边按钮「申请退款」;其余状态无按钮。点击「去支付」从底部弹起微信支付面板(金额「¥19.00」+ 商品名 + 绿色「确认支付」按钮)。申请退款弹出面板:退款原因输入框(占位「请填写退款原因」,上限 200 字)+ 提交按钮,提交后 轻提示「退款申请已提交,等待审核」,该订单出现「退款中」提示。空态:票据线稿插画 +「还没有订单」+ 描边按钮「去看看额度包」。
---
## 第 14 段|第 13 屏 我的(tabBar)
【上下文锚点】沿用第 1 段全局规范:微信小程序「纸条AI衣橱」,竖版 375×812pt,iOS 系统蓝 #007AFF、警示橙 #FF9500,#F2F2F7 底 + 纯白卡片 + 0.5px #E5E5EA 发丝线(分隔线左缩进 16pt 与文字对齐),inset 分组列表行高 44pt,SF Symbols 线性图标,头像 40~64pt 圆形。本条为第 13 屏「我的」,属于 5 个 tabBar 页之一(本页为选中态),是最后一屏。请生成本页画板,并一并生成「编辑资料」子页面与顶部额度提示条。
顶部为个人信息白卡:左侧 64pt 圆形头像(未设置时为灰色人像占位),右侧第一行为昵称「小雨」+ 性别图标,第二行为两个小标签:会员标签(系统蓝浅底)——非会员时不展示该标签,体验会员展示「体验会员 · 3 天后到期」(新用户注册赠送,通常 3 天),月度/年度会员展示「月度会员 · 05-15 到期」;与星座「白羊座」(浅灰底,未设置生日时显示可点击的「设置生日」);卡片右侧为「编辑资料 >」文字按钮。下方第一组功能列表(白卡,每行线性图标 + 文字 + 右侧说明 + 箭头):我的额度(右侧灰字「上传额度剩 13」)、我的订单、我的分享、我的收藏、额度明细。第二组:编辑资料、设置生日、客服与反馈、关于纸条AI衣橱。底部为灰色小字版本号「v1.0.0」。编辑资料页为表单:头像(点击更换)、昵称(≤32 字)、性别(男 / 女)、生日(日期选择器,不可晚于今天)、手机号(只读展示),底部系统蓝按钮「保存」。额度不足或会员到期前,在「我的」页顶部显示一条浅橙提示条「你的上传额度仅剩 2 次,点击购买额度包 >」。
---
## 第 15 段|补充页 创建 / 编辑穿搭
【上下文锚点】沿用第 1 段全局规范:微信小程序「纸条AI衣橱」,竖版 375×812pt,二级页面(左上角返回箭头、支持右滑返回),iOS 系统蓝 #007AFF 强调色、系统红 #FF3B30 危险色,#F2F2F7 底 + 纯白卡片(圆角 16pt)+ 0.5px #E5E5EA 发丝线,输入框高 44pt 圆角 10pt、占位文字 #C7C7CC,两列衣物网格(间距 12pt、卡片圆角 16pt、图片区 1:1),底部固定栏位于安全区上方(白底 + 上分割线),轻提示为毛玻璃圆角浮层。本条为最后一个补充页,请生成「创建穿搭」「编辑穿搭」两个版本(表单与多选网格一致,编辑态预填已有内容),并一并给出空态与轻提示异常态。
从穿搭页「+ 创建穿搭」按钮进入(或从穿搭详情右上「更多」→「编辑穿搭」进入),标题为「创建穿搭」(编辑态为「编辑穿搭」)。页面顶部为拼图预览区:白底圆角横图内把已选衣物抠图并排错落摆放,未选择任何衣物时显示 #F2F2F7 占位 + 居中线性衣架图标 + 灰字「还没有选择衣物」。其下为白色表单卡片:第一行为「穿搭名称」输入框(必填,上限 32 字,占位「给这套穿搭起个名字」,示例「初秋通勤三件套」);第二行为「穿搭描述」多行输入框(上限 500 字,占位「简单描述这套穿搭」,右上角显示字数「12/500」);第三行为「适合场景」输入框(上限 100 字,占位「例如:通勤、约会、休闲」,可展示为已选场景标签 + 「+ 添加」)。表单下方为「选择衣物」分组(白卡,行式入口,右侧显示「已选 4 件」与箭头):点击进入衣橱多选网格(复用第 2 屏的两列衣物卡片,勾选态为卡片系统蓝描边 + 右上系统蓝对勾,最多 10 件、至少 1 件),底部固定栏显示「已选 4 件」+ 系统蓝按钮「确定」。底部固定操作栏:系统蓝按钮「保存」(未填名称或未选衣物时为禁用态,文字与底色同时降透明度),编辑态在按钮上方另加一行红色文字按钮「删除穿搭」(点击弹确认框「确认删除这套穿搭?」+「取消」「确认删除」(红色))。异常态用轻提示:未选任何衣物点保存 →「至少选择 1 件衣物」;选择超过 10 件 →「最多选择 10 件衣物」;名称为空 →「请填写穿搭名称」。保存成功后 轻提示「已保存」并返回穿搭详情。
+118
View File
@@ -0,0 +1,118 @@
// Command migrate 是独立的数据库结构迁移入口:手动执行,服务启动不会触发迁移。
//
// 用法:
//
// go run ./cmd/migrate up # 执行到最新版本
// go run ./cmd/migrate down # 全量回滚
// go run ./cmd/migrate steps 2 # 向上 2 步(负数向下)
// go run ./cmd/migrate version # 查看当前版本与脏库状态
// go run ./cmd/migrate force 3 # 修复脏库:强制设置版本号
package main
import (
"fmt"
"os"
"strconv"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/migration"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/setting"
)
const usage = `用法:
go run ./cmd/migrate up 执行到最新版本
go run ./cmd/migrate down 全量回滚所有迁移
go run ./cmd/migrate steps N 向上 N 步(N 为负数时向下)
go run ./cmd/migrate version 查看当前版本与脏库状态
go run ./cmd/migrate force V 强制设置版本号并清除脏标记`
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintf(os.Stderr, "migrate failed: %v\n", err)
os.Exit(1)
}
}
func run(args []string) error {
if len(args) == 0 {
fmt.Println(usage)
return nil
}
// 仅读取 Mysql 配置段,不复用 main.go 的 setupSetting(后者会强制校验 JWT 等无关配置)。
mysqlSetting, err := loadMysqlSetting()
if err != nil {
return err
}
switch args[0] {
case "up":
if err := migration.Up(mysqlSetting); err != nil {
return err
}
fmt.Println("migrate up: done")
return nil
case "down":
if err := migration.Down(mysqlSetting); err != nil {
return err
}
fmt.Println("migrate down: done")
return nil
case "steps":
if len(args) < 2 {
return fmt.Errorf("steps 缺少步数参数\n%s", usage)
}
n, err := strconv.Atoi(args[1])
if err != nil {
return fmt.Errorf("steps 参数必须为整数: %w", err)
}
if err := migration.Steps(mysqlSetting, n); err != nil {
return err
}
fmt.Printf("migrate steps %d: done\n", n)
return nil
case "version":
version, dirty, err := migration.Version(mysqlSetting)
if err != nil {
return err
}
fmt.Printf("version: %d, dirty: %t\n", version, dirty)
return nil
case "force":
if len(args) < 2 {
return fmt.Errorf("force 缺少版本号参数\n%s", usage)
}
v, err := strconv.Atoi(args[1])
if err != nil {
return fmt.Errorf("force 参数必须为整数: %w", err)
}
if err := migration.Force(mysqlSetting, v); err != nil {
return err
}
fmt.Printf("migrate force %d: done\n", v)
return nil
default:
return fmt.Errorf("unknown command: %s\n%s", args[0], usage)
}
}
// loadMysqlSetting 读取 configs/config.yaml 的 Mysql 配置段。
func loadMysqlSetting() (*setting.MysqlSettingS, error) {
s, err := setting.NewSetting()
if err != nil {
return nil, fmt.Errorf("read config: %w", err)
}
var mysqlSetting setting.MysqlSettingS
if err := s.ReadSection("Mysql", &mysqlSetting); err != nil {
return nil, fmt.Errorf("read Mysql section: %w", err)
}
if mysqlSetting.DBName == "" {
return nil, fmt.Errorf("Mysql.DBName is empty in configs/config.yaml")
}
return &mysqlSetting, nil
}
+125
View File
@@ -0,0 +1,125 @@
Server:
Domain: test.com #服务域名,用于 CORS 允许来源(http/https 均放行);置 * 或不填则允许所有来源
RunMode: release #gin运行模式,debug 和 release
HttpPort: 80
ReadTimeout: 60
WriteTimeout: 60
Log:
LogSavePath: storage/logs
LogFileName: app
LogFileExt: .log
MaxSize: 100
MaxBackups: 10
MaxAge: 30
LogLevel: Info #日志级别,Debug, Info, Warn, Error
Mysql:
Username: your_mysql_user
Password: your_mysql_password
Host: 127.0.0.1
Port: 3306
DBName: your_db_name
MaxIdleConns: 30
MaxOpenConns: 100
ConnMaxLifeTime: 3600
ConnMaxIdleTime: 600
Redis:
Host: 127.0.0.1
Port: 6379
Password: ""
DB: 0
PoolSize: 20
MinIdleConns: 5
OTel:
Enabled: true
ServiceName: your_svc_name
ServiceVersion: 1.0.0
Environment: dev
ExporterEndpoint: 127.0.0.1:4317 # OTLP gRPC 端点
Insecure: true # 本地/内网不开 TLS
SamplerRatio: 1.0 # 采样率 0~1
JWT:
SecretKey: replace_with_a_strong_random_secret
Issuer: yourIssuer
AccessTokenExpire: 7200
RefreshTokenExpire: 86400
Cos:
CosSecretID: your_cos_secret_id # 腾讯云 COS SecretId
CosSecretKey: your_cos_secret_key # 腾讯云 COS SecretKey
CosUploadDomain: your_cos_upload_domain # 上传域名
CosDownloadDomain: your_cos_download_domain # 下载域名
CdnDownloadSecretKey: your_cdn_download_secret_key # CDN 下载鉴权密钥
Upload:
TmpSavePath: storage/uploads
MaxSizeMB: 20
AllowedExtensions:
- jpg
- jpeg
- png
- gif
- webp
- pdf
- txt
- doc
- docx
- xls
- xlsx
- mp4
Tokenhub:
APIKey: your_tokenhub_api_key # 大模型网关 APIKey
Model: deepseek-v4-flash
MattingModel:
Host: 127.0.0.1
Port: 8001
MaxRetry: 3
ClassificationModel:
Host: 127.0.0.1
Port: 8000
MaxRetry: 3
WechatMini:
AppID: your_mini_appid
AppSecret: your_mini_appsecret
WechatPay:
Enable: false # 是否启用微信支付;未开通时置 false,服务启动不加载支付客户端
AppID: your_mini_appid # 小程序 AppID
MchID: your_mch_id # 商户号
APIv3Key: your_apiv3_key_32bytes_len # APIv3 密钥(必须 32 字节)
SerialNo: your_merchant_cert_serial_no # 商户证书序列号
PrivateKeyPath: configs/apiclient_key.pem # 商户 API 私钥 PEM 路径
NotifyURL: https://your.domain/api/v1/order/pay/notify
RefundNotifyURL: https://your.domain/api/v1/order/refund/notify
WechatVirtualPay:
# 微信小程序虚拟支付(XPay),与普通微信支付为独立商户号/独立结算。
# 配置项在 MP 后台「虚拟支付 → 基本配置」获取;未开通时 Enable 置 false。
# 环境自动选择:只填 SandboxAppKey 走沙箱测试(不真实扣款);
# 只填 EnvAppKey 或两把都填走现网(现网优先);两把都空则虚拟支付不可用。
Enable: false
OfferID: your_virtual_pay_offer_id # 虚拟支付 OfferID(AppID)
EnvAppKey: your_virtual_pay_env_app_key # 现网 AppKey(env=0)
SandboxAppKey: your_virtual_pay_sandbox_app_key # 沙箱 AppKey(只填这一把即走沙箱;iOS 不支持沙箱)
Token: your_virtual_pay_notify_token # 消息推送验签 Token(配置回调地址时自定义)
UserDefaultQuota:
# 新用户免费额度与注册赠送的会员天数;可显式配置为 0(额度类表示不赠送,GiftVIPDay 表示不赠送会员),
# 删除某项或整段则回落内置默认(额度 20/3/10/5,GiftVIPDay 3 天);负数视为非法配置,同样回落内置默认。
# 额度语义:Cos 为永久额度(不每天重置);AI / OutfitShare / ClosetShare 为每日额度(每天 0 点重置)。
Cos: 20 # 免费上传额度(永久)
AI: 3 # 免费 AI 推荐额度(每日)
OutfitShare: 10 # 免费社区分享额度(每日)
ClosetShare: 5 # 免费衣柜分享额度(每日,配置 0 表示不赠送)
GiftVIPDay: 3 # 注册赠送的体验会员天数(vip_type=4);0 表示不赠送;
# 删除该项或整段则回落内置默认 3 天;负数视为非法配置,同样回落内置默认。
AdReward:
# 广告激励额度:是否开启(关闭时 /ad-reward/grant 返回「广告激励未开启」)。
# 发放金额由服务端固定配置,前端仅传 reward_type 与 ref_id(广告记录ID),不再传金额,杜绝刷额度;
# 各类型金额可删除或置 0(表示不发放该类型激励)。
Enable: false
Cos: 1 # 广告激励赠送的 Cos 上传额度(永久)
AI: 1 # 广告激励赠送的 AI 推荐额度(每日)
OutfitShare: 1 # 广告激励赠送的社区分享额度(每日)
ClosetShare: 1 # 广告激励赠送的衣柜分享额度(每日)
+179
View File
@@ -0,0 +1,179 @@
<mxfile host="app.diagrams.net" modified="2026-09-07T00:00:00.000Z" agent="CodeBuddy" version="24.0.0">
<diagram id="zt-closet-er" name="zt-closet 数据库 ER 图">
<mxGraphModel dx="900" dy="500" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="0" fold="1" page="1" pageScale="1" pageWidth="2600" pageHeight="1200" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<!-- ============ 图例 ============ -->
<mxCell id="legend" value="&lt;b&gt;图例&lt;/b&gt;&lt;br&gt;&lt;b&gt;PK&lt;/b&gt; 主键 &lt;b&gt;UK&lt;/b&gt; 唯一索引&lt;br&gt;→ 外键关联目标表&lt;br&gt;⋯ 每表均含 GORM 公共字段:&lt;br&gt;  created_at / updated_at /&lt;br&gt;  deleted_at(软删除) / created_by /&lt;br&gt;  updated_by / deleted_by&lt;br&gt;连线:| 端为一,三叉端为多(1:N)" style="text;html=1;align=left;verticalAlign=top;spacing=8;fontSize=11;rounded=0;fillColor=#FFFFFF;strokeColor=#999999;" vertex="1" parent="1">
<mxGeometry x="1960" y="790" width="280" height="170" as="geometry" />
</mxCell>
<!-- ============ 用户域容器 ============ -->
<mxCell id="zone-user" value="用户域 Users" style="rounded=1;html=1;verticalAlign=top;align=center;fontSize=15;fontStyle=1;fillColor=#EAF2FB;strokeColor=#1E6091;strokeWidth=2;container=1;collapsible=0;whiteSpace=wrap;" vertex="1" parent="1">
<mxGeometry x="40" y="40" width="640" height="620" as="geometry" />
</mxCell>
<!-- ============ AI/运营域容器 ============ -->
<mxCell id="zone-ai" value="AI / 运营域" style="rounded=1;html=1;verticalAlign=top;align=center;fontSize=15;fontStyle=1;fillColor=#F3EFFB;strokeColor=#6C3FA0;strokeWidth=2;container=1;collapsible=0;whiteSpace=wrap;" vertex="1" parent="1">
<mxGeometry x="40" y="700" width="640" height="400" as="geometry" />
</mxCell>
<!-- ============ 衣橱域容器 ============ -->
<mxCell id="zone-wardrobe" value="衣橱域 Wardrobe" style="rounded=1;html=1;verticalAlign=top;align=center;fontSize=15;fontStyle=1;fillColor=#EAF6EE;strokeColor=#2E7D32;strokeWidth=2;container=1;collapsible=0;whiteSpace=wrap;" vertex="1" parent="1">
<mxGeometry x="720" y="40" width="1180" height="1060" as="geometry" />
</mxCell>
<!-- ============ 交易域容器 ============ -->
<mxCell id="zone-trade" value="交易域 Trade" style="rounded=1;html=1;verticalAlign=top;align=center;fontSize=15;fontStyle=1;fillColor=#FDF2E7;strokeColor=#C77700;strokeWidth=2;container=1;collapsible=0;whiteSpace=wrap;" vertex="1" parent="1">
<mxGeometry x="1940" y="40" width="620" height="700" as="geometry" />
</mxCell>
<!-- ============ 用户域实体 ============ -->
<!-- users -->
<mxCell id="tbl-users" value="&lt;table border=&quot;0&quot; cellspacing=&quot;0&quot; cellpadding=&quot;2&quot; style=&quot;width:100%;font-size:11px;&quot;&gt;&lt;tr&gt;&lt;td colspan=&quot;2&quot; style=&quot;background:#1E6091;color:#FFFFFF;font-weight:bold;text-align:center;&quot;&gt;users&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;b&gt;id&lt;/b&gt; BIGINT &lt;b&gt;PK&lt;/b&gt;&lt;/td&gt;&lt;td&gt;user_uuid VARCHAR&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;wx_open_id VARCHAR &lt;b&gt;UK&lt;/b&gt;&lt;/td&gt;&lt;td&gt;username VARCHAR&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;avatar VARCHAR&lt;/td&gt;&lt;td&gt;sex TINYINT&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;birth_day BIGINT&lt;/td&gt;&lt;td&gt;phone VARCHAR&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;is_new_user TINYINT&lt;/td&gt;&lt;td&gt;status TINYINT&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;ban_reason VARCHAR&lt;/td&gt;&lt;td&gt;ban_start BIGINT&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;ban_expire BIGINT&lt;/td&gt;&lt;td&gt;vip_type TINYINT&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;vip_start BIGINT&lt;/td&gt;&lt;td&gt;vip_expire BIGINT&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;last_login_time BIGINT&lt;/td&gt;&lt;td&gt;⋯ 公共字段&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;" style="shape=table;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#FFFFFF;strokeColor=#1E6091;rounded=0;" vertex="1" parent="1">
<mxGeometry x="60" y="90" width="300" height="206" as="geometry" />
</mxCell>
<!-- user_quotas -->
<mxCell id="tbl-user-quotas" value="&lt;table border=&quot;0&quot; cellspacing=&quot;0&quot; cellpadding=&quot;2&quot; style=&quot;width:100%;font-size:11px;&quot;&gt;&lt;tr&gt;&lt;td colspan=&quot;2&quot; style=&quot;background:#1E6091;color:#FFFFFF;font-weight:bold;text-align:center;&quot;&gt;user_quotas&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;b&gt;id&lt;/b&gt; BIGINT &lt;b&gt;PK&lt;/b&gt;&lt;/td&gt;&lt;td&gt;user_id → users &lt;b&gt;UK&lt;/b&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;quota_type TINYINT &lt;b&gt;UK&lt;/b&gt;&lt;/td&gt;&lt;td&gt;total BIGINT&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;used BIGINT&lt;/td&gt;&lt;td&gt;⋯ 公共字段&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;" style="shape=table;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#FFFFFF;strokeColor=#1E6091;rounded=0;" vertex="1" parent="1">
<mxGeometry x="60" y="320" width="300" height="98" as="geometry" />
</mxCell>
<!-- user_daily_usages -->
<mxCell id="tbl-user-daily-usages" value="&lt;table border=&quot;0&quot; cellspacing=&quot;0&quot; cellpadding=&quot;2&quot; style=&quot;width:100%;font-size:11px;&quot;&gt;&lt;tr&gt;&lt;td colspan=&quot;2&quot; style=&quot;background:#1E6091;color:#FFFFFF;font-weight:bold;text-align:center;&quot;&gt;user_daily_usages&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;b&gt;id&lt;/b&gt; BIGINT &lt;b&gt;PK&lt;/b&gt;&lt;/td&gt;&lt;td&gt;user_id → users &lt;b&gt;UK&lt;/b&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;stat_date BIGINT &lt;b&gt;UK&lt;/b&gt;&lt;/td&gt;&lt;td&gt;ai_recommend_count BIGINT&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;outfit_share_count BIGINT&lt;/td&gt;&lt;td&gt;closet_share_count BIGINT&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;⋯ 公共字段&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;" style="shape=table;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#FFFFFF;strokeColor=#1E6091;rounded=0;" vertex="1" parent="1">
<mxGeometry x="60" y="440" width="300" height="116" as="geometry" />
</mxCell>
<!-- user_notifications -->
<mxCell id="tbl-user-notifications" value="&lt;table border=&quot;0&quot; cellspacing=&quot;0&quot; cellpadding=&quot;2&quot; style=&quot;width:100%;font-size:11px;&quot;&gt;&lt;tr&gt;&lt;td colspan=&quot;2&quot; style=&quot;background:#1E6091;color:#FFFFFF;font-weight:bold;text-align:center;&quot;&gt;user_notifications&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;b&gt;id&lt;/b&gt; BIGINT &lt;b&gt;PK&lt;/b&gt;&lt;/td&gt;&lt;td&gt;notification_uuid VARCHAR&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;user_id → users&lt;/td&gt;&lt;td&gt;msg_type TINYINT&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;sender_id BIGINT&lt;/td&gt;&lt;td&gt;title VARCHAR&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;content VARCHAR&lt;/td&gt;&lt;td&gt;is_read TINYINT&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;read_time BIGINT&lt;/td&gt;&lt;td&gt;⋯ 公共字段&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;" style="shape=table;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#FFFFFF;strokeColor=#1E6091;rounded=0;" vertex="1" parent="1">
<mxGeometry x="60" y="578" width="300" height="134" as="geometry" />
</mxCell>
<!-- quota_flow_logs -->
<mxCell id="tbl-quota-flow-logs" value="&lt;table border=&quot;0&quot; cellspacing=&quot;0&quot; cellpadding=&quot;2&quot; style=&quot;width:100%;font-size:11px;&quot;&gt;&lt;tr&gt;&lt;td colspan=&quot;2&quot; style=&quot;background:#1E6091;color:#FFFFFF;font-weight:bold;text-align:center;&quot;&gt;quota_flow_logs&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;b&gt;id&lt;/b&gt; BIGINT &lt;b&gt;PK&lt;/b&gt;&lt;/td&gt;&lt;td&gt;quota_flow_uuid VARCHAR&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;user_id → users&lt;/td&gt;&lt;td&gt;change_target TINYINT&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;change_type TINYINT &lt;b&gt;UK&lt;/b&gt;&lt;/td&gt;&lt;td&gt;change_num BIGINT&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;old_value BIGINT&lt;/td&gt;&lt;td&gt;new_value BIGINT&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;order_no VARCHAR &lt;b&gt;UK&lt;/b&gt;&lt;/td&gt;&lt;td&gt;quota_type TINYINT &lt;b&gt;UK&lt;/b&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;related_id BIGINT&lt;/td&gt;&lt;td&gt;reason VARCHAR&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;⋯ 公共字段&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;" style="shape=table;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#FFFFFF;strokeColor=#1E6091;rounded=0;" vertex="1" parent="1">
<mxGeometry x="380" y="90" width="280" height="152" as="geometry" />
</mxCell>
<!-- ============ 衣橱域实体 ============ -->
<!-- clothes -->
<mxCell id="tbl-clothes" value="&lt;table border=&quot;0&quot; cellspacing=&quot;0&quot; cellpadding=&quot;2&quot; style=&quot;width:100%;font-size:11px;&quot;&gt;&lt;tr&gt;&lt;td colspan=&quot;2&quot; style=&quot;background:#2E7D32;color:#FFFFFF;font-weight:bold;text-align:center;&quot;&gt;clothes&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;b&gt;id&lt;/b&gt; BIGINT &lt;b&gt;PK&lt;/b&gt;&lt;/td&gt;&lt;td&gt;clothes_uuid VARCHAR&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;user_id → users&lt;/td&gt;&lt;td&gt;category_main VARCHAR&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;category_sub VARCHAR&lt;/td&gt;&lt;td&gt;style VARCHAR&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;color VARCHAR&lt;/td&gt;&lt;td&gt;color_sub VARCHAR&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;thickness VARCHAR&lt;/td&gt;&lt;td&gt;version VARCHAR&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;material VARCHAR&lt;/td&gt;&lt;td&gt;pattern VARCHAR&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;sleeve_type VARCHAR&lt;/td&gt;&lt;td&gt;clothes_type VARCHAR&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;neckline_type VARCHAR&lt;/td&gt;&lt;td&gt;formality VARCHAR&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;occasions VARCHAR&lt;/td&gt;&lt;td&gt;visual_impact VARCHAR&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;name VARCHAR&lt;/td&gt;&lt;td&gt;remark VARCHAR&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;processed_image VARCHAR&lt;/td&gt;&lt;td&gt;image_size BIGINT&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;image_process_status TINYINT&lt;/td&gt;&lt;td&gt;fail_reason VARCHAR&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;⋯ 公共字段&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;" style="shape=table;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#FFFFFF;strokeColor=#2E7D32;rounded=0;" vertex="1" parent="1">
<mxGeometry x="740" y="90" width="300" height="290" as="geometry" />
</mxCell>
<!-- closet_shares -->
<mxCell id="tbl-closet-shares" value="&lt;table border=&quot;0&quot; cellspacing=&quot;0&quot; cellpadding=&quot;2&quot; style=&quot;width:100%;font-size:11px;&quot;&gt;&lt;tr&gt;&lt;td colspan=&quot;2&quot; style=&quot;background:#2E7D32;color:#FFFFFF;font-weight:bold;text-align:center;&quot;&gt;closet_shares&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;b&gt;id&lt;/b&gt; BIGINT &lt;b&gt;PK&lt;/b&gt;&lt;/td&gt;&lt;td&gt;share_uuid VARCHAR&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;user_id → users&lt;/td&gt;&lt;td&gt;share_type TINYINT&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;expire_time BIGINT&lt;/td&gt;&lt;td&gt;status TINYINT&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;⋯ 公共字段&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;" style="shape=table;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#FFFFFF;strokeColor=#2E7D32;rounded=0;" vertex="1" parent="1">
<mxGeometry x="740" y="410" width="300" height="116" as="geometry" />
</mxCell>
<!-- closet_share_items -->
<mxCell id="tbl-closet-share-items" value="&lt;table border=&quot;0&quot; cellspacing=&quot;0&quot; cellpadding=&quot;2&quot; style=&quot;width:100%;font-size:11px;&quot;&gt;&lt;tr&gt;&lt;td colspan=&quot;2&quot; style=&quot;background:#2E7D32;color:#FFFFFF;font-weight:bold;text-align:center;&quot;&gt;closet_share_items&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;b&gt;id&lt;/b&gt; BIGINT &lt;b&gt;PK&lt;/b&gt;&lt;/td&gt;&lt;td&gt;share_uuid → closet_shares&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;clothes_id → clothes&lt;/td&gt;&lt;td&gt;⋯ 公共字段&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;" style="shape=table;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#FFFFFF;strokeColor=#2E7D32;rounded=0;" vertex="1" parent="1">
<mxGeometry x="740" y="546" width="300" height="72" as="geometry" />
</mxCell>
<!-- closet_audit_logs -->
<mxCell id="tbl-closet-audit-logs" value="&lt;table border=&quot;0&quot; cellspacing=&quot;0&quot; cellpadding=&quot;2&quot; style=&quot;width:100%;font-size:11px;&quot;&gt;&lt;tr&gt;&lt;td colspan=&quot;2&quot; style=&quot;background:#2E7D32;color:#FFFFFF;font-weight:bold;text-align:center;&quot;&gt;closet_audit_logs&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;b&gt;id&lt;/b&gt; BIGINT &lt;b&gt;PK&lt;/b&gt;&lt;/td&gt;&lt;td&gt;target_id → closet_shares&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;audit_result TINYINT&lt;/td&gt;&lt;td&gt;reject_reason VARCHAR&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;audit_time BIGINT&lt;/td&gt;&lt;td&gt;⋯ 公共字段&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;" style="shape=table;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#FFFFFF;strokeColor=#2E7D32;rounded=0;" vertex="1" parent="1">
<mxGeometry x="740" y="638" width="300" height="94" as="geometry" />
</mxCell>
<!-- outfits -->
<mxCell id="tbl-outfits" value="&lt;table border=&quot;0&quot; cellspacing=&quot;0&quot; cellpadding=&quot;2&quot; style=&quot;width:100%;font-size:11px;&quot;&gt;&lt;tr&gt;&lt;td colspan=&quot;2&quot; style=&quot;background:#2E7D32;color:#FFFFFF;font-weight:bold;text-align:center;&quot;&gt;outfits&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;b&gt;id&lt;/b&gt; BIGINT &lt;b&gt;PK&lt;/b&gt;&lt;/td&gt;&lt;td&gt;outfit_uuid VARCHAR&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;user_id → users&lt;/td&gt;&lt;td&gt;outfit_name VARCHAR&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;outfit_desc VARCHAR&lt;/td&gt;&lt;td&gt;scene VARCHAR&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;source TINYINT&lt;/td&gt;&lt;td&gt;is_public TINYINT&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;audit_status TINYINT&lt;/td&gt;&lt;td&gt;status TINYINT&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;⋯ 公共字段&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;" style="shape=table;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#FFFFFF;strokeColor=#2E7D32;rounded=0;" vertex="1" parent="1">
<mxGeometry x="1080" y="90" width="300" height="140" as="geometry" />
</mxCell>
<!-- outfit_items -->
<mxCell id="tbl-outfit-items" value="&lt;table border=&quot;0&quot; cellspacing=&quot;0&quot; cellpadding=&quot;2&quot; style=&quot;width:100%;font-size:11px;&quot;&gt;&lt;tr&gt;&lt;td colspan=&quot;2&quot; style=&quot;background:#2E7D32;color:#FFFFFF;font-weight:bold;text-align:center;&quot;&gt;outfit_items&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;b&gt;id&lt;/b&gt; BIGINT &lt;b&gt;PK&lt;/b&gt;&lt;/td&gt;&lt;td&gt;outfit_id → outfits&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;clothes_id → clothes&lt;/td&gt;&lt;td&gt;⋯ 公共字段&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;" style="shape=table;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#FFFFFF;strokeColor=#2E7D32;rounded=0;" vertex="1" parent="1">
<mxGeometry x="1080" y="250" width="300" height="72" as="geometry" />
</mxCell>
<!-- collects -->
<mxCell id="tbl-collects" value="&lt;table border=&quot;0&quot; cellspacing=&quot;0&quot; cellpadding=&quot;2&quot; style=&quot;width:100%;font-size:11px;&quot;&gt;&lt;tr&gt;&lt;td colspan=&quot;2&quot; style=&quot;background:#2E7D32;color:#FFFFFF;font-weight:bold;text-align:center;&quot;&gt;collects&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;b&gt;id&lt;/b&gt; BIGINT &lt;b&gt;PK&lt;/b&gt;&lt;/td&gt;&lt;td&gt;collect_type TINYINT &lt;b&gt;UK&lt;/b&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;target_id → outfits &lt;b&gt;UK&lt;/b&gt;&lt;/td&gt;&lt;td&gt;user_id → users &lt;b&gt;UK&lt;/b&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;status TINYINT&lt;/td&gt;&lt;td&gt;⋯ 公共字段&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;" style="shape=table;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#FFFFFF;strokeColor=#2E7D32;rounded=0;" vertex="1" parent="1">
<mxGeometry x="1080" y="342" width="300" height="94" as="geometry" />
</mxCell>
<!-- likes -->
<mxCell id="tbl-likes" value="&lt;table border=&quot;0&quot; cellspacing=&quot;0&quot; cellpadding=&quot;2&quot; style=&quot;width:100%;font-size:11px;&quot;&gt;&lt;tr&gt;&lt;td colspan=&quot;2&quot; style=&quot;background:#2E7D32;color:#FFFFFF;font-weight:bold;text-align:center;&quot;&gt;likes&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;b&gt;id&lt;/b&gt; BIGINT &lt;b&gt;PK&lt;/b&gt;&lt;/td&gt;&lt;td&gt;like_type TINYINT &lt;b&gt;UK&lt;/b&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;target_id → outfits &lt;b&gt;UK&lt;/b&gt;&lt;/td&gt;&lt;td&gt;user_id → users &lt;b&gt;UK&lt;/b&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;status TINYINT&lt;/td&gt;&lt;td&gt;⋯ 公共字段&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;" style="shape=table;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#FFFFFF;strokeColor=#2E7D32;rounded=0;" vertex="1" parent="1">
<mxGeometry x="1080" y="456" width="300" height="94" as="geometry" />
</mxCell>
<!-- outfit_audit_logs -->
<mxCell id="tbl-outfit-audit-logs" value="&lt;table border=&quot;0&quot; cellspacing=&quot;0&quot; cellpadding=&quot;2&quot; style=&quot;width:100%;font-size:11px;&quot;&gt;&lt;tr&gt;&lt;td colspan=&quot;2&quot; style=&quot;background:#2E7D32;color:#FFFFFF;font-weight:bold;text-align:center;&quot;&gt;outfit_audit_logs&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;b&gt;id&lt;/b&gt; BIGINT &lt;b&gt;PK&lt;/b&gt;&lt;/td&gt;&lt;td&gt;target_id → outfits&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;audit_result TINYINT&lt;/td&gt;&lt;td&gt;reject_reason VARCHAR&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;audit_time BIGINT&lt;/td&gt;&lt;td&gt;⋯ 公共字段&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;" style="shape=table;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#FFFFFF;strokeColor=#2E7D32;rounded=0;" vertex="1" parent="1">
<mxGeometry x="1080" y="570" width="300" height="94" as="geometry" />
</mxCell>
<!-- ============ 交易域实体 ============ -->
<!-- goods -->
<mxCell id="tbl-goods" value="&lt;table border=&quot;0&quot; cellspacing=&quot;0&quot; cellpadding=&quot;2&quot; style=&quot;width:100%;font-size:11px;&quot;&gt;&lt;tr&gt;&lt;td colspan=&quot;2&quot; style=&quot;background:#C77700;color:#FFFFFF;font-weight:bold;text-align:center;&quot;&gt;goods&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;b&gt;id&lt;/b&gt; BIGINT &lt;b&gt;PK&lt;/b&gt;&lt;/td&gt;&lt;td&gt;goods_code VARCHAR&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;goods_name VARCHAR&lt;/td&gt;&lt;td&gt;goods_type TINYINT&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;price BIGINT&lt;/td&gt;&lt;td&gt;discount_price BIGINT&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;member_days BIGINT&lt;/td&gt;&lt;td&gt;cos_quota BIGINT&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;ai_recommend_quota BIGINT&lt;/td&gt;&lt;td&gt;closet_share_quota BIGINT&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;outfit_share_quota BIGINT&lt;/td&gt;&lt;td&gt;description VARCHAR&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;status TINYINT&lt;/td&gt;&lt;td&gt;⋯ 公共字段&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;" style="shape=table;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#FFFFFF;strokeColor=#C77700;rounded=0;" vertex="1" parent="1">
<mxGeometry x="1960" y="90" width="280" height="160" as="geometry" />
</mxCell>
<!-- orders -->
<mxCell id="tbl-orders" value="&lt;table border=&quot;0&quot; cellspacing=&quot;0&quot; cellpadding=&quot;2&quot; style=&quot;width:100%;font-size:11px;&quot;&gt;&lt;tr&gt;&lt;td colspan=&quot;2&quot; style=&quot;background:#C77700;color:#FFFFFF;font-weight:bold;text-align:center;&quot;&gt;orders&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;b&gt;id&lt;/b&gt; BIGINT &lt;b&gt;PK&lt;/b&gt;&lt;/td&gt;&lt;td&gt;order_no VARCHAR &lt;b&gt;UK&lt;/b&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;user_id → users&lt;/td&gt;&lt;td&gt;goods_id → goods&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;goods_name VARCHAR&lt;/td&gt;&lt;td&gt;unit_price BIGINT&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;total_amount BIGINT&lt;/td&gt;&lt;td&gt;pay_amount BIGINT&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;pay_channel TINYINT&lt;/td&gt;&lt;td&gt;order_status TINYINT&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;transaction_id VARCHAR&lt;/td&gt;&lt;td&gt;member_days BIGINT&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;cos_quota BIGINT&lt;/td&gt;&lt;td&gt;ai_recommend_quota BIGINT&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;closet_share_quota BIGINT&lt;/td&gt;&lt;td&gt;outfit_share_quota BIGINT&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;expire_time BIGINT&lt;/td&gt;&lt;td&gt;pay_time BIGINT&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;refund_time BIGINT&lt;/td&gt;&lt;td&gt;refund_amount BIGINT&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;remark VARCHAR&lt;/td&gt;&lt;td&gt;callback_raw VARCHAR&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;⋯ 公共字段&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;" style="shape=table;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#FFFFFF;strokeColor=#C77700;rounded=0;" vertex="1" parent="1">
<mxGeometry x="1960" y="270" width="280" height="240" as="geometry" />
</mxCell>
<!-- refund_flow_logs -->
<mxCell id="tbl-refund-flow-logs" value="&lt;table border=&quot;0&quot; cellspacing=&quot;0&quot; cellpadding=&quot;2&quot; style=&quot;width:100%;font-size:11px;&quot;&gt;&lt;tr&gt;&lt;td colspan=&quot;2&quot; style=&quot;background:#C77700;color:#FFFFFF;font-weight:bold;text-align:center;&quot;&gt;refund_flow_logs&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;b&gt;id&lt;/b&gt; BIGINT &lt;b&gt;PK&lt;/b&gt;&lt;/td&gt;&lt;td&gt;order_id → orders&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;order_no VARCHAR&lt;/td&gt;&lt;td&gt;user_id → users&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;refund_no VARCHAR &lt;b&gt;UK&lt;/b&gt;&lt;/td&gt;&lt;td&gt;wechat_refund_no VARCHAR&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;refund_amount BIGINT&lt;/td&gt;&lt;td&gt;refund_reason VARCHAR&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;status TINYINT&lt;/td&gt;&lt;td&gt;apply_time BIGINT&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;success_time BIGINT&lt;/td&gt;&lt;td&gt;fail_reason VARCHAR&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;callback_raw VARCHAR&lt;/td&gt;&lt;td&gt;⋯ 公共字段&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;" style="shape=table;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#FFFFFF;strokeColor=#C77700;rounded=0;" vertex="1" parent="1">
<mxGeometry x="1960" y="530" width="280" height="160" as="geometry" />
</mxCell>
<!-- vip_flow_logs -->
<mxCell id="tbl-vip-flow-logs" value="&lt;table border=&quot;0&quot; cellspacing=&quot;0&quot; cellpadding=&quot;2&quot; style=&quot;width:100%;font-size:11px;&quot;&gt;&lt;tr&gt;&lt;td colspan=&quot;2&quot; style=&quot;background:#C77700;color:#FFFFFF;font-weight:bold;text-align:center;&quot;&gt;vip_flow_logs&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;b&gt;id&lt;/b&gt; BIGINT &lt;b&gt;PK&lt;/b&gt;&lt;/td&gt;&lt;td&gt;user_id → users&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;change_type TINYINT &lt;b&gt;UK&lt;/b&gt;&lt;/td&gt;&lt;td&gt;old_vip_type TINYINT&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;new_vip_type TINYINT&lt;/td&gt;&lt;td&gt;old_vip_expire BIGINT&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;new_vip_expire BIGINT&lt;/td&gt;&lt;td&gt;order_no VARCHAR &lt;b&gt;UK&lt;/b&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;source TINYINT&lt;/td&gt;&lt;td&gt;reason VARCHAR&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;⋯ 公共字段&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;" style="shape=table;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#FFFFFF;strokeColor=#C77700;rounded=0;" vertex="1" parent="1">
<mxGeometry x="2260" y="90" width="280" height="140" as="geometry" />
</mxCell>
<!-- benefit_grant_tasks -->
<mxCell id="tbl-benefit-grant-tasks" value="&lt;table border=&quot;0&quot; cellspacing=&quot;0&quot; cellpadding=&quot;2&quot; style=&quot;width:100%;font-size:11px;&quot;&gt;&lt;tr&gt;&lt;td colspan=&quot;2&quot; style=&quot;background:#C77700;color:#FFFFFF;font-weight:bold;text-align:center;&quot;&gt;benefit_grant_tasks&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;b&gt;id&lt;/b&gt; BIGINT &lt;b&gt;PK&lt;/b&gt;&lt;/td&gt;&lt;td&gt;user_id → users&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;order_no VARCHAR &lt;b&gt;UK&lt;/b&gt; → orders&lt;/td&gt;&lt;td&gt;goods_id → goods&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;benefit_type TINYINT &lt;b&gt;UK&lt;/b&gt;&lt;/td&gt;&lt;td&gt;amount BIGINT&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;status TINYINT&lt;/td&gt;&lt;td&gt;retry_count INT&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;max_retry INT&lt;/td&gt;&lt;td&gt;last_error VARCHAR&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;⋯ 公共字段&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;" style="shape=table;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#FFFFFF;strokeColor=#C77700;rounded=0;" vertex="1" parent="1">
<mxGeometry x="2260" y="250" width="280" height="120" as="geometry" />
</mxCell>
<!-- ============ AI/运营域实体 ============ -->
<!-- horoscopes -->
<mxCell id="tbl-horoscopes" value="&lt;table border=&quot;0&quot; cellspacing=&quot;0&quot; cellpadding=&quot;2&quot; style=&quot;width:100%;font-size:11px;&quot;&gt;&lt;tr&gt;&lt;td colspan=&quot;2&quot; style=&quot;background:#6C3FA0;color:#FFFFFF;font-weight:bold;text-align:center;&quot;&gt;horoscopes&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;b&gt;id&lt;/b&gt; BIGINT &lt;b&gt;PK&lt;/b&gt;&lt;/td&gt;&lt;td&gt;stat_date BIGINT &lt;b&gt;UK&lt;/b&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;zodiac VARCHAR &lt;b&gt;UK&lt;/b&gt;&lt;/td&gt;&lt;td&gt;overall_score TINYINT&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;love_score TINYINT&lt;/td&gt;&lt;td&gt;career_score TINYINT&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;wealth_score TINYINT&lt;/td&gt;&lt;td&gt;daily_text VARCHAR&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;lucky_color VARCHAR&lt;/td&gt;&lt;td&gt;lucky_number VARCHAR&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;⋯ 公共字段&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;" style="shape=table;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#FFFFFF;strokeColor=#6C3FA0;rounded=0;" vertex="1" parent="1">
<mxGeometry x="60" y="750" width="300" height="120" as="geometry" />
</mxCell>
<!-- ai_recommend_flow_logs -->
<mxCell id="tbl-ai-recommend-flow-logs" value="&lt;table border=&quot;0&quot; cellspacing=&quot;0&quot; cellpadding=&quot;2&quot; style=&quot;width:100%;font-size:11px;&quot;&gt;&lt;tr&gt;&lt;td colspan=&quot;2&quot; style=&quot;background:#6C3FA0;color:#FFFFFF;font-weight:bold;text-align:center;&quot;&gt;ai_recommend_flow_logs&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;b&gt;id&lt;/b&gt; BIGINT &lt;b&gt;PK&lt;/b&gt;&lt;/td&gt;&lt;td&gt;request_id VARCHAR&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;server_request_id VARCHAR&lt;/td&gt;&lt;td&gt;user_id → users&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;model_version VARCHAR&lt;/td&gt;&lt;td&gt;prompt_tokens BIGINT&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;completion_tokens BIGINT&lt;/td&gt;&lt;td&gt;result_content VARCHAR&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;status TINYINT&lt;/td&gt;&lt;td&gt;fail_reason VARCHAR&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;request_time BIGINT&lt;/td&gt;&lt;td&gt;response_time BIGINT&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;⋯ 公共字段&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;" style="shape=table;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#FFFFFF;strokeColor=#6C3FA0;rounded=0;" vertex="1" parent="1">
<mxGeometry x="60" y="890" width="300" height="140" as="geometry" />
</mxCell>
<!-- ai_horoscope_flow_logs -->
<mxCell id="tbl-ai-horoscope-flow-logs" value="&lt;table border=&quot;0&quot; cellspacing=&quot;0&quot; cellpadding=&quot;2&quot; style=&quot;width:100%;font-size:11px;&quot;&gt;&lt;tr&gt;&lt;td colspan=&quot;2&quot; style=&quot;background:#6C3FA0;color:#FFFFFF;font-weight:bold;text-align:center;&quot;&gt;ai_horoscope_flow_logs&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;b&gt;id&lt;/b&gt; BIGINT &lt;b&gt;PK&lt;/b&gt;&lt;/td&gt;&lt;td&gt;request_id VARCHAR&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;zodiac VARCHAR&lt;/td&gt;&lt;td&gt;server_request_id VARCHAR&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;stat_date BIGINT&lt;/td&gt;&lt;td&gt;model_version VARCHAR&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;prompt_tokens BIGINT&lt;/td&gt;&lt;td&gt;completion_tokens BIGINT&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;result_content VARCHAR&lt;/td&gt;&lt;td&gt;horoscope_id → horoscopes&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;status TINYINT&lt;/td&gt;&lt;td&gt;fail_reason VARCHAR&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;request_time BIGINT&lt;/td&gt;&lt;td&gt;response_time BIGINT&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;⋯ 公共字段&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;" style="shape=table;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#FFFFFF;strokeColor=#6C3FA0;rounded=0;" vertex="1" parent="1">
<mxGeometry x="380" y="750" width="280" height="160" as="geometry" />
</mxCell>
<!-- ============ 关系连线(ER:| 端为一,三叉端为多) ============ -->
<mxCell id="e-u-quotas" style="edgeStyle=orthogonalEdgeStyle;html=1;startArrow=ERone;startFill=0;endArrow=ERmany;endFill=0;strokeColor=#555555;rounded=0;exitX=0.2;exitY=1;entryX=0.2;entryY=0;" edge="1" parent="1" source="tbl-users" target="tbl-user-quotas"><mxGeometry relative="1" as="geometry" /></mxCell>
<mxCell id="e-u-daily" style="edgeStyle=orthogonalEdgeStyle;html=1;startArrow=ERone;startFill=0;endArrow=ERmany;endFill=0;strokeColor=#555555;rounded=0;exitX=1;exitY=0.88;entryX=1;entryY=0.5;" edge="1" parent="1" source="tbl-users" target="tbl-user-daily-usages"><mxGeometry relative="1" as="geometry" /></mxCell>
<mxCell id="e-u-notifications" style="edgeStyle=orthogonalEdgeStyle;html=1;startArrow=ERone;startFill=0;endArrow=ERmany;endFill=0;strokeColor=#555555;rounded=0;exitX=1;exitY=0.96;entryX=1;entryY=0.5;" edge="1" parent="1" source="tbl-users" target="tbl-user-notifications"><mxGeometry relative="1" as="geometry" /></mxCell>
<mxCell id="e-u-flow-logs" style="edgeStyle=orthogonalEdgeStyle;html=1;startArrow=ERone;startFill=0;endArrow=ERmany;endFill=0;strokeColor=#555555;rounded=0;exitX=1;exitY=0.08;entryX=0;entryY=0.5;" edge="1" parent="1" source="tbl-users" target="tbl-quota-flow-logs"><mxGeometry relative="1" as="geometry" /></mxCell>
<mxCell id="e-u-clothes" style="edgeStyle=orthogonalEdgeStyle;html=1;startArrow=ERone;startFill=0;endArrow=ERmany;endFill=0;strokeColor=#555555;rounded=0;exitX=1;exitY=0.2;entryX=0;entryY=0.2;" edge="1" parent="1" source="tbl-users" target="tbl-clothes"><mxGeometry relative="1" as="geometry" /></mxCell>
<mxCell id="e-u-outfits" style="edgeStyle=orthogonalEdgeStyle;html=1;startArrow=ERone;startFill=0;endArrow=ERmany;endFill=0;strokeColor=#555555;rounded=0;exitX=1;exitY=0.35;entryX=0;entryY=0.4;" edge="1" parent="1" source="tbl-users" target="tbl-outfits"><mxGeometry relative="1" as="geometry" /></mxCell>
<mxCell id="e-u-closet-shares" style="edgeStyle=orthogonalEdgeStyle;html=1;startArrow=ERone;startFill=0;endArrow=ERmany;endFill=0;strokeColor=#555555;rounded=0;exitX=1;exitY=0.5;entryX=0;entryY=0.5;" edge="1" parent="1" source="tbl-users" target="tbl-closet-shares"><mxGeometry relative="1" as="geometry" /></mxCell>
<mxCell id="e-u-collects" style="edgeStyle=orthogonalEdgeStyle;html=1;startArrow=ERone;startFill=0;endArrow=ERmany;endFill=0;strokeColor=#555555;rounded=0;exitX=1;exitY=0.65;entryX=0;entryY=0.5;" edge="1" parent="1" source="tbl-users" target="tbl-collects"><mxGeometry relative="1" as="geometry" /></mxCell>
<mxCell id="e-u-likes" style="edgeStyle=orthogonalEdgeStyle;html=1;startArrow=ERone;startFill=0;endArrow=ERmany;endFill=0;strokeColor=#555555;rounded=0;exitX=1;exitY=0.8;entryX=0;entryY=0.5;" edge="1" parent="1" source="tbl-users" target="tbl-likes"><mxGeometry relative="1" as="geometry" /></mxCell>
<mxCell id="e-u-orders" style="edgeStyle=orthogonalEdgeStyle;html=1;startArrow=ERone;startFill=0;endArrow=ERmany;endFill=0;strokeColor=#555555;rounded=0;exitX=0.3;exitY=0;entryX=0.3;entryY=0;" edge="1" parent="1" source="tbl-users" target="tbl-orders"><mxGeometry relative="1" as="geometry" /></mxCell>
<mxCell id="e-u-refund" style="edgeStyle=orthogonalEdgeStyle;html=1;startArrow=ERone;startFill=0;endArrow=ERmany;endFill=0;strokeColor=#555555;rounded=0;exitX=0.4;exitY=0;entryX=0.3;entryY=0;" edge="1" parent="1" source="tbl-users" target="tbl-refund-flow-logs"><mxGeometry relative="1" as="geometry" /></mxCell>
<mxCell id="e-u-vip" style="edgeStyle=orthogonalEdgeStyle;html=1;startArrow=ERone;startFill=0;endArrow=ERmany;endFill=0;strokeColor=#555555;rounded=0;exitX=0.15;exitY=0;entryX=0.5;entryY=0;" edge="1" parent="1" source="tbl-users" target="tbl-vip-flow-logs"><mxGeometry relative="1" as="geometry" /></mxCell>
<mxCell id="e-u-benefit" style="edgeStyle=orthogonalEdgeStyle;html=1;startArrow=ERone;startFill=0;endArrow=ERmany;endFill=0;strokeColor=#555555;rounded=0;exitX=0.2;exitY=0;entryX=0.5;entryY=0;" edge="1" parent="1" source="tbl-users" target="tbl-benefit-grant-tasks"><mxGeometry relative="1" as="geometry" /></mxCell>
<mxCell id="e-u-ai-rec" style="edgeStyle=orthogonalEdgeStyle;html=1;startArrow=ERone;startFill=0;endArrow=ERmany;endFill=0;strokeColor=#555555;rounded=0;exitX=0;exitY=0.9;entryX=0;entryY=0.5;" edge="1" parent="1" source="tbl-users" target="tbl-ai-recommend-flow-logs"><mxGeometry relative="1" as="geometry" /></mxCell>
<mxCell id="e-o-items" style="edgeStyle=orthogonalEdgeStyle;html=1;startArrow=ERone;startFill=0;endArrow=ERmany;endFill=0;strokeColor=#555555;rounded=0;exitX=0.4;exitY=1;entryX=0.4;entryY=0;" edge="1" parent="1" source="tbl-outfits" target="tbl-outfit-items"><mxGeometry relative="1" as="geometry" /></mxCell>
<mxCell id="e-o-audit" style="edgeStyle=orthogonalEdgeStyle;html=1;startArrow=ERone;startFill=0;endArrow=ERmany;endFill=0;strokeColor=#555555;rounded=0;exitX=1;exitY=0.8;entryX=1;entryY=0.5;" edge="1" parent="1" source="tbl-outfits" target="tbl-outfit-audit-logs"><mxGeometry relative="1" as="geometry" /></mxCell>
<mxCell id="e-o-collects" style="edgeStyle=orthogonalEdgeStyle;html=1;startArrow=ERone;startFill=0;endArrow=ERmany;endFill=0;strokeColor=#555555;rounded=0;exitX=1;exitY=0.3;entryX=1;entryY=0.5;" edge="1" parent="1" source="tbl-outfits" target="tbl-collects"><mxGeometry relative="1" as="geometry" /></mxCell>
<mxCell id="e-o-likes" style="edgeStyle=orthogonalEdgeStyle;html=1;startArrow=ERone;startFill=0;endArrow=ERmany;endFill=0;strokeColor=#555555;rounded=0;exitX=1;exitY=0.6;entryX=1;entryY=0.5;" edge="1" parent="1" source="tbl-outfits" target="tbl-likes"><mxGeometry relative="1" as="geometry" /></mxCell>
<mxCell id="e-c-outfit-items" style="edgeStyle=orthogonalEdgeStyle;html=1;startArrow=ERone;startFill=0;endArrow=ERmany;endFill=0;strokeColor=#555555;rounded=0;exitX=1;exitY=0.35;entryX=0;entryY=0.5;" edge="1" parent="1" source="tbl-clothes" target="tbl-outfit-items"><mxGeometry relative="1" as="geometry" /></mxCell>
<mxCell id="e-c-share-items" style="edgeStyle=orthogonalEdgeStyle;html=1;startArrow=ERone;startFill=0;endArrow=ERmany;endFill=0;strokeColor=#555555;rounded=0;exitX=0.55;exitY=1;entryX=0.45;entryY=0;" edge="1" parent="1" source="tbl-clothes" target="tbl-closet-share-items"><mxGeometry relative="1" as="geometry" /></mxCell>
<mxCell id="e-cs-items" style="edgeStyle=orthogonalEdgeStyle;html=1;startArrow=ERone;startFill=0;endArrow=ERmany;endFill=0;strokeColor=#555555;rounded=0;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="tbl-closet-shares" target="tbl-closet-share-items"><mxGeometry relative="1" as="geometry" /></mxCell>
<mxCell id="e-cs-audit" style="edgeStyle=orthogonalEdgeStyle;html=1;startArrow=ERone;startFill=0;endArrow=ERmany;endFill=0;strokeColor=#555555;rounded=0;exitX=0.85;exitY=1;entryX=0.85;entryY=0;" edge="1" parent="1" source="tbl-closet-shares" target="tbl-closet-audit-logs"><mxGeometry relative="1" as="geometry" /></mxCell>
<mxCell id="e-g-orders" style="edgeStyle=orthogonalEdgeStyle;html=1;startArrow=ERone;startFill=0;endArrow=ERmany;endFill=0;strokeColor=#555555;rounded=0;exitX=0.4;exitY=1;entryX=0.4;entryY=0;" edge="1" parent="1" source="tbl-goods" target="tbl-orders"><mxGeometry relative="1" as="geometry" /></mxCell>
<mxCell id="e-g-benefit" style="edgeStyle=orthogonalEdgeStyle;html=1;startArrow=ERone;startFill=0;endArrow=ERmany;endFill=0;strokeColor=#555555;rounded=0;exitX=1;exitY=0.5;entryX=0;entryY=0.5;" edge="1" parent="1" source="tbl-goods" target="tbl-benefit-grant-tasks"><mxGeometry relative="1" as="geometry" /></mxCell>
<mxCell id="e-o-refund" style="edgeStyle=orthogonalEdgeStyle;html=1;startArrow=ERone;startFill=0;endArrow=ERmany;endFill=0;strokeColor=#555555;rounded=0;exitX=0.3;exitY=1;entryX=0.3;entryY=0;" edge="1" parent="1" source="tbl-orders" target="tbl-refund-flow-logs"><mxGeometry relative="1" as="geometry" /></mxCell>
<mxCell id="e-o-benefit" style="edgeStyle=orthogonalEdgeStyle;html=1;startArrow=ERone;startFill=0;endArrow=ERmany;endFill=0;strokeColor=#555555;rounded=0;exitX=1;exitY=0.3;entryX=0;entryY=0.5;" edge="1" parent="1" source="tbl-orders" target="tbl-benefit-grant-tasks"><mxGeometry relative="1" as="geometry" /></mxCell>
<mxCell id="e-h-aih" style="edgeStyle=orthogonalEdgeStyle;html=1;startArrow=ERone;startFill=0;endArrow=ERmany;endFill=0;strokeColor=#555555;rounded=0;exitX=1;exitY=0.4;entryX=0;entryY=0.4;" edge="1" parent="1" source="tbl-horoscopes" target="tbl-ai-horoscope-flow-logs"><mxGeometry relative="1" as="geometry" /></mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
+178
View File
@@ -0,0 +1,178 @@
# zt-closet-backend 全量代码三次审查报告
- **审查日期**:2026-09-09
- **审查范围**:全量 Go 代码(`internal/`、`pkg/`、`global/`、`main.go`、`cmd/`,不含 `thirdparty/` Python 服务)
- **审查方式**:只读审查,未修改任何业务代码
- **分级标准**:
- 严重:资损、安全漏洞、并发导致数据错乱/重复发放、可被直接利用的越权
- 中等:边界条件缺失、一致性降级风险、错误处理不当导致的业务卡死
- 轻微:代码风格、冗余、文档偏差、低概率窗口、死代码
> 说明:本报告为「三次 check」,在复核上一轮(二次 check)各项修复状态的基础上,继续深挖新增问题。上一轮已修复项统一在「第四部分」复核确认;上轮遗留未修复项在「第五部分」列明现状。
---
## 修复状态更新(2026-09-09)
> 本报告原为只读审查。经用户确认后,已对以下「中等及以上」问题实施修复(按约定排除:M11 公开接口限流、M15 虚拟订单垃圾堆积):
| 编号 | 问题 | 修复状态 |
| --- | --- | --- |
| S2-new | 广告激励刷额度 | ✅ 已修复:新增 `AdReward` 配置段(`Enable` 开关 + 各类型固定金额),DTO 移除 `Amount`,前端不再传金额,服务端定价 |
| S1-new | XPay 退款推送无反查 | ✅ 已修复:新增 `verifyXPayRefund` 反查微信侧退款真实性(状态+金额),fail-closed 触发重试 |
| M1-new | 自动退款卡死无重试 | ✅ 已修复:`autoRefundCancelledOrder` 改为生成待审核退款单(不再自动调微信退款,退款全部由管理员操作) |
| M2-new | 会员类型覆盖降级 | ✅ 已修复:`VIPGrantIdempotent` 按最终到期时长推导 `vip_type`(体验会员除外) |
| M3 | iOS 退款问询无条件建议退款 | ✅ 已修复:按权益消耗情况细化,已消耗则返回 `result_code=1` 拒绝 |
| M4 | platform 可伪造 | ✅ 已处理:明确 platform 仅作体验优化、真正资损防线为发货反查;dto 已含 `oneof` 白名单校验 |
| M9 | 并发 Paid 分支 pay_time 无兜底 | ✅ 已修复:抽 `resolvePaidTime` 统一兜底,`finalizeVirtualPaidOrder` 与并发 Paid 分支复用 |
| M12 | 上传无请求体大小限制 | ✅ 已修复:handler 入口加 `http.MaxBytesReader` 限制请求体 |
> 备注:S1-new 反查依赖的微信 XPay `query_order` 退款字段(`refund_status`/`refund_fee`)与退款状态枚举为「待确认」,联调时需以官方文档校正。验证:`go build ./...` 与 `go vet ./...` 均通过。
---
## 一、结论摘要
- **上轮 2 个严重问题(S1 发货伪造、S2 道具价格同步)均已妥善处置**,发货链路的 `verifyXPayOrderPaid` 反查已落地,但**其对称缺口——XPay 退款推送无反查——本次新发现,构成新的资损/DoS 面(S1-new)**。
- **本次新发现 1 个严重资损漏洞**:广告激励额度接口信任前端上报的金额与凭证 ID,可被任意用户无限刷额度(S2-new)。
- 支付/退款/权益发放的核心事务、幂等、金额校验整体设计正确,上一轮确认良好的项继续成立。
- 上轮遗留的中等问题(M3/M4/M9/M11/M12/M15)与大部分轻微问题(L 系列)**仍未处理**。
---
## 二、严重问题(新增)
### S1-new. XPay 退款推送无微信侧真实性反查,可伪造退款回收权益(资损/DoS)
- **位置**:`internal/service/virtualpay.go:295-326`(`handleRefundNotify`)
- **问题**:上一轮 S1 修复时,发货推送 `handleGoodsDeliver` 已新增 `verifyXPayOrderPaid`(`virtualpay.go:168-199`)——在本地校验后、条件流转前调用 `wechat.QueryOrder` 反查微信侧订单「已支付 + 金额一致 + 道具一致」,fail-closed。但**退款推送 `handleRefundNotify` 只做了 `RefundStatus == "SUCCESS"` 的白名单判断,没有对应的微信侧退款真实性反查**。
- **攻击路径**:XPay 消息推送 POST 报文不携带签名(`ParseNotify` 只解析 JSON),攻击者可向公开路由 `POST /api/v1/xpay/notify` 构造 `Event=xpay_refund_notify`、`RefundStatus="SUCCESS"`、`OutTradeNo=<目标订单号>` 的报文:
1. `handleRefundNotify` 白名单判断通过;
2. 查订单(`OutTradeNo`)命中且非 Refunded;
3. `settleRefundSuccess` 条件流转 `Paid -> Refunded` 成功 → **回收该订单已发放的会员天数与额度凭证** + 发「退款成功」通知。
- **影响**:
- 对他人订单:攻击者若获取到他人订单号(`OD` + 时间戳 + 16 位 hex,存在展示/日志泄露渠道),可恶意回收他人权益、把他人订单标记为已退款,造成「用户付了钱、权益被回收、钱并未退回」的资损与客诉。
- 对自己订单:结合 M3(iOS 退款问询无条件建议退款)存在权益白嫖放大。
- **对比**:普通微信支付退款回调 `RefundNotify` 走官方 SDK 验签 + AES 解密(`pkg/wechat/pay.go:306-335`),不存在此问题;XPay 退款链路是发货反查落地后唯一仍「裸奔」的入口。
- **修复建议**:`handleRefundNotify` 增加微信侧退款状态反查(`wechat` 层补一个退款状态查询接口,或复用 `QueryOrder` 的退款字段),确认「微信侧确实已退款 + 退款金额一致」后才 `settleRefundSuccess`;未通过返回失败应答(`ErrCode:1`)触发微信重试,并落库/告警。至少在反查落地前,对该路由加网关级来源防护。
### S2-new. 广告激励额度接口信任前端金额与凭证 ID,可无限刷额度(资损)
- **位置**:`internal/service/adreward.go:24-54`(`Grant`)
- **问题**:`/ad-reward/grant`(需鉴权)接收前端上报的 `RewardType`、`Amount`、`RefID`,其中 **`Amount`(奖励额度数量)与 `RefID`(幂等凭证 ID)完全由客户端指定**,服务端:
1. 不校验 `Amount` 上限(可传任意大值,如 999999);
2. 不校验 `RefID` 的真实性(未与广告平台服务端回调或观看记录对账);
3. 直接以 `Amount` 作为凭证数量 `QuotaGrantCreate` 落库(`ExpireTime=当天23:59:59`)。
- **影响**:任意登录用户可反复调用,给自己发放任意数量的 Cos/AI/分享额度凭证(当天有效),彻底击穿额度体系,构成直接资损。
- **修复建议**:
1. 奖励额度改为**服务端按 RewardType 配置固定值**,不从客户端接收 `Amount`;
2. `RefID` 必须由广告平台**服务端回调**下发并校验(或引入一次性凭证/签名),杜绝前端自造;
3. 增加每日领取次数上限。
---
## 三、中等问题(新增)
### M1-new. 自动退款失败后状态卡死,无重试机制(用户付了钱收不到退款)
- **位置**:`internal/service/order.go:402-437`(`autoRefundCancelledOrder`)
- **问题**:订单「已取消但用户已付款」时自动发起退款:
1. 先建退款流水 `Status=RefundStatusProcessing`(`order.go:411-420`);
2. 调 `wechat.Refund`(`order.go:429`),**若失败仅记录日志后直接 return,未把流水置为 `RefundStatusFailed`**(`order.go:430-433`)。
3. 下次再次进入该函数时,幂等检查 `existing.Status != RefundStatusFailed` 命中(`order.go:404-408`),直接 return,跳过退款。
- **影响**:`PayNotify` 对 Cancelled 分支返回 `nil`(成功应答,`order.go:197-198`),微信不会重推;后台无退款重试任务;用户也无法通过 `RefundApply` 再次申请(订单已 Cancelled,`RefundApply` 仅接受 `Paid`,`order.go:279-281`)。最终该订单退款**永久卡在 Processing**,用户付了钱拿不到退款。
- **修复建议**:`wechat.Refund` 失败时把流水置为 `RefundStatusFailed`(复用已有失败重试语义);或增加独立的退款重试后台任务。
### M2-new. 会员续费时 `vip_type` 直接覆盖,年度会员续月包被降级为月度
- **位置**:`internal/service/order.go:618-655`(`grantVIP`)+ `internal/mysqldao/user.go:51-73`(`VIPGrantIdempotent`)
- **问题**:`grantVIP` 的 `newVIPType` 仅由本次购买 `memberDays >= 365` 决定(`order.go:626-629`),`VIPGrantIdempotent` 用 `vip_type = ?`(本次类型)直接 `SET`(`user.go:58`),**未根据最终 `vip_expire` 剩余时长推导**。场景:
- 年度会员(剩余 350 天)续费 30 天月包 → `vip_expire` 变为 380 天(> 365,应为年度),但 `vip_type` 被覆盖为「月度(2)」,字段自相矛盾,前端展示「月度会员」造成「被降级」客诉。
- 反向:月会员续大额包也有类型跳变。
- **修复建议**:`vip_type` 依据「续费后的 `vip_expire - now` 是否 ≥ 365 天」推导写入(或改为查询时统一按到期时长归一,DB 中 `vip_type` 仅作冗余),与 `buildUserInfoResponse` 的归一逻辑对齐。
---
## 四、上一轮修复项复核结论(已确认落地,无需再改)
| 编号 | 上一轮问题 | 复核结论 |
| --- | --- | --- |
| S1 | XPay 发货推送伪造资损 | ✅ 已修复:`verifyXPayOrderPaid` 反查微信侧订单(金额/道具/支付态),fail-closed |
| S2 | 道具价格同步仅启动一次 | ✅ 已处置:服务端道具自动同步逻辑已删除,改由管理后台负责 |
| M1 | 退款推送 `RefundStatus` 空串误判 | ✅ 已修复:白名单 `XPayRefundStatusSuccess` 判断 |
| M2 | 发货校验失败静默吞掉 | ✅ 已修复:失败分支返回 `70009`,应答 `ErrCode:1` 触发重试 |
| M5 | 补偿重试上限被回调重置绕过 | ✅ 已修复:`BenefitGrantTaskReset` 保留 `retry_count` + 5 分钟冷却期 |
| M10 | `RefreshToken` 与文档「可选」矛盾 | ✅ 已修复:代码与文档统一为必填 |
| M13 | `Outfit.Public` 扣额失败回退竞态 | ✅ 已修复:`KeyLockOutfitPublic` per-outfit 锁串行化 |
| M14 | 衣物失败/删除并发漏退额度 | ✅ 已修复:`markProcessFailed` 重写为行锁单事务,与删除事务行锁互斥 |
此外,上轮确认良好的核心链路本次继续成立:支付/退款回调金额校验、条件状态流转 + Redis 幂等锁双层防护、过期/取消竞态兜底、权益发放幂等(`(order_no,benefit_type)` 唯一索引 + claim 状态机 + 补偿先查流水)、退款回收「已用不回退」(`GREATEST` 保护)、SQL 全部参数绑定(无 SQLi)、响应 JSON 无 XSS、出站请求仅微信固定域名与配置的 AI 地址(无 SSRF)、密钥仅存配置文件(无硬编码)。
---
## 五、上一轮遗留未修复项(现状复核)
### 中等(仍未处理)
| 编号 | 问题 | 位置(现状) |
| --- | --- | --- |
| M3 | iOS 退款问询无条件建议退款,白嫖权益窗口 | `internal/service/virtualpay.go:332-342`(仍恒返回 `result_code=0`,注释声明为权衡) |
| M4 | 前端上报 `platform` 可伪造,绕过 iOS 最低 1 元限制 | `internal/service/virtualpay.go:47-50` + `internal/dto/order.go`(`Platform` 由前端上报) |
| M9 | 并发 Paid 分支 `pay_time` 无兜底 | `internal/service/virtualpay.go:267-274`(`finalizeVirtualPaidOrder` 已兜底,但并发 Paid 分支仍直接写 `deliver.WeChatPayInfo.PaidTime`) |
| M11 | 公开接口(wxlogin/refresh/pay/notify/xpay/notify)无限流 | `internal/routers/router.go:47-61`(无 rate limit 中间件) |
| M12 | 上传接口大小校验发生在 multipart 解析之后 | `internal/service/clothes.go:51-57`(`c.FormFile` 解析后才 `FileCheckMaxSize`) |
| M15 | 虚拟支付订单禁取消带来的垃圾订单堆积 | `internal/service/order.go:213-217`(设计权衡,建议加监控) |
> M6/M7/M8(道具上传/发布轮询、分组重复发布、道具 ID 字符集)已随 S2 处置(道具同步逻辑删除)自然消除。
### 轻微(L 系列,仍未处理)
| 编号 | 问题 | 位置 |
| --- | --- | --- |
| L1 | 通知 `read_time` 永不写入 | `internal/service/notification.go:69-104` |
| L2 | 并发点赞基于前置读 `wasLiked` 判断,可能重复发通知 | `internal/service/like.go:61-64` |
| L3 | 收藏列表已删穿搭被跳过导致 `total` 与 `list` 不一致 + 每页 N+1 查询 | `internal/service/collect.go:91-110` |
| L4 | CORS 未配置 Domain 时回退 `Allow-Origin: *` | `internal/middleware/cors.go` |
| L5 | `http.Server` 未设 `IdleTimeout`/`ReadHeaderTimeout`(slowloris 风险) | `main.go:70-78` |
| L6 | 临时封禁无自动解封逻辑 | `internal/service/user.go:95`(`Status != Normal` 直接拒绝,不按 `ban_expire` 判定) |
| L7 | 封禁状态受用户缓存延迟影响 | `internal/middleware/lzcwyjwt.go:78-99`(缓存命中直接用缓存 status) |
| L8 | `RefreshToken` 先删旧会话再写新会话,Del 成功 Set 失败时新旧 token 全失效 | `internal/service/user.go:250-253` |
| L9 | `OrderExpireByTime` 硬编码状态字面量 1/4 | `internal/mysqldao/order.go` |
| L10 | 迁移 `down` 全量回滚无二次确认 | `pkg/migration/migration.go` |
| L12 | XPay `getAccessToken` 无重试 | `pkg/wechat/virtualpay.go:180-229` |
| L13 | `Outfit.Create` 主表成功后明细失败留下孤儿穿搭 | `internal/service/outfit.go:62-68`(与 AI 推荐不一致,AI 推荐会删主表) |
| L14 | `Outfit.Update` 基础字段与明细重建分两个事务 | `internal/service/outfit.go:121-140` |
| L15 | `ClosetShare.Create` 明细失败仅回退额度,主记录残留空分享 | `internal/service/closet.go:92-95` |
| L16 | 信号量全局共享、阻塞式获取不可取消 | `internal/service/clothes.go:26,106` |
| L17 | 扣额建记录成功、goroutine 启动前崩溃,衣物停留「排队中」无恢复 | `internal/service/clothes.go:88-95` |
| L18 | COS 签名 URL 无过期参数 | `pkg/upload/upload.go` |
| L19 | AI 推荐 Redis 锁 180s 无续期 | `internal/service/airecommend.go:50` |
| L20 | 运势生成单次失败无重试 | `pkg/tokenhub/scheduler.go` |
| L21 | `time.Sleep` 在系统时间回拨时可能忙循环 | `pkg/tokenhub/scheduler.go` |
| L22 | 锁 key 手写字符串与 redismodel 常量两处维护 | `internal/service/benefit_compensation.go:40` |
| L23 | 已废弃 DAO(`QuotaIncreaseUsed`/`QuotaDecreaseUsed`/`QuotaConsumeWithFlow`/`QuotaRefundWithFlow`/`QuotaGrantIdempotent`/`QuotaRevokeIdempotent`)无调用点,死代码 | `internal/mysqldao/quota.go`(本次已确认全工程无调用) |
| L24 | 普通支付 `finalizePaidOrder` 的 `pay_time` 用本地时间而非微信 `SuccessTime` | `internal/service/order.go:457-462` |
| L25 | 道具同步全量上架商品无通道开关 | 已随 S2 消除,可忽略 |
### 新增轻微
| 编号 | 问题 | 位置 |
| --- | --- | --- |
| L26 | `recordDailyUsage` 首次并发创建当日计数行无 OnConflict 幂等,第二个请求 1062 冲突(仅日志噪音,不阻断) | `internal/service/service.go:85-117` |
| L27 | 登录 `AuthTokenSet` fail-open 与刷新 `AuthTokenSet` fail-closed 策略不一致 | `internal/service/user.go:107-110` vs `user.go:250-253` |
| L28 | 退款回收 `VIPRevokeIdempotent` 仅扣 `vip_expire`,不重算 `vip_type`,DB 字段可能残留过期类型 | `internal/mysqldao/user.go:77-101` |
---
## 六、修复优先级建议
| 优先级 | 编号 | 一句话结论 |
| --- | --- | --- |
| P0(上线前) | S1-new | XPay 退款推送无微信侧反查,可伪造退款回收权益,必须补齐反查 |
| P0(上线前) | S2-new | 广告激励额度信任前端金额/凭证 ID,可无限刷额度,必须服务端定价 + 校验 |
| P1 | M1-new | 自动退款失败卡死无重试,用户付钱收不到退款 |
| P1 | M2-new | 会员续费 `vip_type` 覆盖导致年度会员降级 |
| P2 | M3/M4/M9/M11/M12 | iOS 白嫖、platform 伪造、pay_time 兜底、公开接口限流、上传防护 |
| P3 | L1-L28 | 按影响面择机处理;其中 L13/L14/L15 与一致性相关,建议优先 |
> 备注:本报告仅列出问题与建议,未修改任何代码。S1-new 的修复需先联调确认 XPay `query_order` / 退款查询接口在官方文档中的字段与状态枚举(`wechat/virtualpay.go` 中多处已标注「以官方枚举为准,联调时校正」)。
+207
View File
@@ -0,0 +1,207 @@
# zt-closet-backend 全量代码二次审查报告
- **审查日期**:2026-09-08
- **审查范围**:全量 Go 代码(约 9700 行核心业务代码),重点深挖支付链路(虚拟支付 XPay、微信支付 V3、订单状态机、权益发放/退款/补偿)
- **审查方式**:只读审查,未修改任何业务代码
- **分级标准**:
- 严重:资损、安全漏洞、并发导致数据错乱/重复发放、状态机死锁
- 中等:边界条件缺失、一致性降级风险、错误处理不当
- 轻微:代码风格、冗余、文档偏差、低概率窗口
---
## 一、严重问题(必须修复)
### S1. XPay 发货推送无验签与订单真实性反查,可伪造发货通知零成本获取权益(资损) —— ✅ 已修复(2026-09-08)
> 修复内容:发货推送处理链路新增微信侧订单真实性反查 `verifyXPayOrderPaid`(`internal/service/virtualpay.go`),在本地校验通过后、条件流转前调用 `wechat.QueryOrder` 反查订单已支付、金额与道具一致才发放权益;fail-closed,未通过返回 `70009 订单真实性校验失败` 触发微信重试。配套 `IsOrderPaidStatus` 白名单判断与单测(`pkg/wechat/virtualpay.go`)。
- **位置**:`internal/routers/router.go:59`(公开路由)、`internal/routers/api/v1/order.go:163-171`、`internal/service/virtualpay.go:290-374`
- **问题**:微信虚拟支付的消息推送 POST 报文**不携带签名**(URL 配置时的 GET SHA1 验签仅用于验证 URL 归属,`XPayVerify` 只处理 GET)。`XPayNotify` 收到 POST 后直接信任报文内容,仅做本地校验(订单存在、pay_channel=2、product_id/amount/env 与订单快照一致)。这些校验信息对攻击者完全可控:
1. 攻击者用合法账号创建虚拟支付订单(自己下单即可拿到 `order_no`、`product_id`、金额;`env` 只需在 0/1 间试一次);
2. 向公开路由 `POST /api/v1/xpay/notify` 发送伪造的 `xpay_goods_deliver_notify` 报文;
3. 所有本地校验通过 → 订单 Pending→Paid → 权益发放 → **未付款即到账**。
- **对比**:普通微信支付回调 `PayNotify` 走官方 SDK 验签 + AES 解密,不存在此问题;XPay 链路是唯一裸奔的资损入口。
- **修复建议**:发货处理前调用 `wechat.QueryOrder`(带 HMAC-SHA256 签名的服务端 API,签名无法伪造)反查微信侧真实订单状态,确认「已支付 + 金额一致 + 道具一致」后才流转发放;推送仅作为触发信号。退款推送同样需要反查 `QueryOrder` 的退款状态。至少在反查落地前,建议给该路由加网关级来源防护并告警。
### S2. 虚拟支付道具价格同步只在服务启动执行一次,运营改价后微信侧与本地价格不一致(资损/拒付风险) —— ✅ 已确认由管理后台负责,已删除服务端自动同步(2026-09-08)
> 处置:删除服务端自动道具同步逻辑(`main.go` 调用、`StartVirtualGoodsSync`/`SyncVirtualGoods`/`syncOneGoods`/`pollGoodsTask`、wechat 层道具上传/发布函数、DAO `GoodsListVirtualSync`/`GoodsUpdateByID`)。道具上传/发布/改价由管理后台负责并回填 `goods.product_id`/`product_status`。README 已标注运营约束:改价后必须通过管理后台同步,否则微信侧价格与本地不一致会导致发货金额校验拒绝或差价资损。
- **位置**:`internal/service/virtualpay.go:32-41`(`StartVirtualGoodsSync` 仅启动时触发一次)、`main.go:68`、`syncOneGoods` 用 `goods.DiscountPrice` 作为道具价格
- **问题**:运营在 DB 修改商品售价后,若服务未重启,微信侧道具价格仍是旧价。用户下单时按本地新价生成 `signData`(goodsPrice),但微信侧按道具旧价扣款 → 出现两种情况:
- 微信扣款 > 本地订单金额:发货推送 `ActualPrice != order.PayAmount` 校验失败,**拒绝发货**,用户付了钱拿不到权益(客诉/监管风险);
- 微信扣款 < 本地订单金额:少扣钱,资损。
- **修复建议**:道具同步改为周期任务(如每 10 分钟增量同步),或提供管理端改价入口同步触发;对金额不匹配的发货推送增加人工兜底策略(按实际扣款金额改单发货 + 告警),而不是直接拒绝。
---
## 二、中等问题
### M1. 退款推送 `RefundStatus` 为空串时被误判为「退款成功」并回收权益 —— ✅ 已修复(2026-09-08)
> 修复:新增 `XPayRefundStatusSuccess` 常量,`handleRefundNotify` 改为白名单判断,仅 `RefundStatus == "SUCCESS"` 才回退权益,空串/未知状态仅告警。
- **位置**:`internal/service/virtualpay.go:386-391`
- **问题**:判断条件 `if refund.RefundStatus != "" && refund.RefundStatus != "SUCCESS"`,当推送报文字段缺失或字段名与官方文档不符(解析结果为空串)时,跳过 warn 分支,直接走 `settleRefundSuccess` 回收权益并置订单 Refunded。若实际退款并未成功,会造成误回收。
- **修复建议**:改为白名单判断——仅当 `RefundStatus == "SUCCESS"` 才回退;空串/未知状态仅记录日志并告警,等待后续推送。
### M2. 发货推送校验失败时返回成功应答,微信不重试且无告警,资损线索被静默吞掉 —— ✅ 已修复(2026-09-08)
> 修复:`handleGoodsDeliver` 五个校验失败分支(订单不存在/渠道不符/道具不符/金额不符/环境不符)改返回 `70009`,应答 `ErrCode:1` 触发微信重试;锁竞争分支保持成功应答防重试风暴。
- **位置**:`internal/service/virtualpay.go:298-328`(order not found / product mismatch / amount mismatch / env mismatch 均 `return nil`),`XPayNotify` 对 `handleGoodsDeliver` 返回 nil 时回 `ErrCode:0`
- **问题**:金额不符、道具不符是资损/攻击线索,当前仅打 error 日志,微信收到成功应答后不再推送,无任何人工介入或监控渠道。回调原始报文(`raw`)也未持久化(未落库)。
- **修复建议**:校验失败返回失败应答(`ErrCode:1`)让微信重试(可限制重试次数防风暴),并将校验失败的推送原始报文落库/告警到监控。
### M3. iOS 退款问询无条件「建议退款」,存在白嫖权益窗口
- **位置**:`internal/service/virtualpay.go:410-424`(`handleIOSRefundQuery` 恒返回 `result_code=0`)
- **问题**:用户在 iOS 购买会员/额度包后立即走 App Store 申请退款,后端无条件建议退款;退款成功后 `revokeOrderBenefits` 回收权益,但「已消耗部分不回退」(GREATEST 保护)且会员天数不回退到过去。对会员类权益,用户可在会员有效期内退款白嫖。代码注释已声明该权衡,但从业务角度属于明确的风险敞口。
- **修复建议**:按权益使用情况细化拒绝策略(如已消耗额度超过阈值时 `result_code=1 + evidence`),或对会员类订单设置退款冷却期。
### M4. 前端上报的 `platform` 可伪造,绕过 iOS 最低 1 元限制
- **位置**:`internal/service/virtualpay.go:172`、`internal/dto/order.go`(`Platform` 由前端上报)
- **问题**:iOS 用户上报 `platform=android` 即可绕过 100 分最低金额校验。微信侧 iOS 调起低金额虚拟支付会失败,产生无法取消(虚拟订单禁取消)的垃圾订单,堆积至过期。
- **修复建议**:该限制本身只是前端体验保护,不构成资损;可在发货时校验平台一致性,或对超低价商品在商品层面限制,订单滞留靠过期清理即可。
### M5. 补偿任务 Failed 状态被支付回调重置(retry_count 清零),重试上限被绕过 —— ✅ 已修复(2026-09-08)
> 修复:`BenefitGrantTaskReset` 不再清零 `retry_count`;Failed 任务增加 5 分钟冷却期(`benefitRetryCooldownSeconds`),冷却期内回调仅告警不重置,恢复重试上限与人工介入机制。
- **位置**:`internal/service/order.go:518-525`(Failed 分支 `BenefitGrantTaskReset`)、`internal/mysqldao/benefit_grant_task.go:78-89`(Reset 将 `retry_count` 归零)
- **问题**:任务重试 3 次失败进入 Failed(等待人工介入),但微信回调重试会再次触发 `grantOrderBenefits` → 唯一索引冲突 → `existing.Status == Failed` → Reset(`retry_count=0`)→ 立即重新发放。若故障持续,任务在 Failed 与 Pending 之间无限循环,「重试超限告警人工介入」机制失效,且每次回调都会触发一次发放尝试。
- **修复建议**:Reset 时保留 `retry_count`(不归零),或对 Failed 任务在回调路径仅记录告警、不自动重置,由人工确认后重置。
### M6. 道具上传/发布任务轮询仅 2.5 秒(5 次 × 500ms),大概率误判失败
- **位置**:`internal/service/virtualpay.go:25-27`、`119-141`(`pollGoodsTask`)
- **问题**:微信道具上传/发布任务耗时未在代码中确认(注释自述「联调时核对」),若实际耗时超过 2.5 秒,`syncOneGoods` 会把商品置为 `product_status=3 发布失败`,导致虚拟商品下单入口长期不可用,直到下次重启重新同步。
- **修复建议**:增加轮询总时长(如 30 秒、退避间隔),或超时后保持「发布中」状态待下次同步周期重查。
### M7. 多商品循环中每个商品都重复发布整个分组
- **位置**:`internal/service/virtualpay.go:76-116`(`syncOneGoods` 对每个商品都执行 `StartUploadGoods` + `StartPublishGoods`,且发布参数只有 `group_id`)
- **问题**:若微信 API 语义为「发布分组内全部待发布道具」,循环 N 个商品会发布 N 次分组,冗余且可能触发频率限制;若语义为单道具,则参数不匹配。另外「对已发布道具重复 upload+publish 视为改价」这一假设需要官方文档确认(已发布道具是否允许改价)。
- **修复建议**:联调确认 group 语义后重构:全部 upload 完成后统一 publish 一次;改价策略按官方支持方式实现。
### M8. 道具 ID 直接复用 `GoodsCode`,可能不符合微信 goods_id 字符集规范
- **位置**:`internal/service/virtualpay.go:77`(`productID := goods.GoodsCode`)
- **问题**:微信虚拟支付道具 ID 一般有字符集限制(字母数字、长度上限)。若商品编码含下划线等特殊字符,道具上传会失败,虚拟支付功能整体不可用。
- **修复建议**:联调核对规范,必要时做编码映射(如取 MD5 前缀或规范化 ID)并保持 `goods.product_id` 回填一致。
### M9. 发货推送并发分支中 `pay_time` 无兜底(与 finalize 路径不一致)
- **位置**:`internal/service/virtualpay.go:349-356`(OrderStatusPaid 分支直接写 `deliver.WeChatPayInfo.PaidTime`)
- **问题**:`finalizeVirtualPaidOrder` 对 `PaidTime <= 0` 有兜底(取 `nowUnix()`),但并发回调的 Paid 分支没有;推送字段缺失时 `pay_time` 会被写成 0,订单列表展示异常。
- **修复建议**:抽公共函数统一兜底。
### M10. `RefreshToken` 要求旧 access_token 必填,与接口文档「可选」矛盾 —— ✅ 已修复(2026-09-08)
> 修复:代码与接口文档均为必填,README 接口表中 access_token 由「否」改为「是」并说明缺失返回 10000,与实现一致。
- **位置**:`internal/service/user.go:240-242`、`接口文档.md`(access_token 标注「否」)
- **问题**:文档声明不传旧 access_token 也能刷新(向前兼容),但代码对空值直接返回参数错误,前端若按文档实现会刷新失败。
- **修复建议**:二选一并保持一致——文档改为必填,或代码放宽为可选(不传则不吊销旧 access)。
### M11. 公开接口无任何限流防护
- **位置**:`internal/middleware/`(无 rate limit 中间件)、`internal/routers/router.go:45-60`
- **问题**:`/user/wxlogin`、`/user/refresh-token`、`/order/pay/notify`、`/xpay/notify` 均无频率限制。暴力调用 code2session 会消耗微信侧配额(触发 45011 后全员登录失败);回调接口可被刷量放大 S1 的伪造风险。
- **修复建议**:接入限流中间件(按 IP + 按用户),回调类接口单独设置更严格的防护策略。
### M12. 上传接口无请求体大小限制(校验发生在 multipart 解析之后)
- **位置**:`internal/service/clothes.go:51-57`、`main.go:73-81`(仅 `MaxHeaderBytes`)
- **问题**:`c.FormFile` 先完整解析 multipart(超出 `MaxMultipartMemory` 32MB 的部分落临时文件),之后才做 `FileCheckMaxSize` 校验。攻击者可发送超大 body 消耗内存与磁盘。
- **修复建议**:在 handler 入口用 `http.MaxBytesReader` 按 `Upload.MaxSizeMB` 提前限制请求体。
### M13. `Outfit.Public` 扣额失败回退与并发幂等返回存在语义冲突 —— ✅ 已修复(2026-09-08)
> 修复:新增 `KeyLockOutfitPublic`,公开/取消公开全流程加 per-outfit Redis 锁(10s)串行化,锁失败返回 10003;Redis 异常 fail-open。串行化后「A 回退 → B 条件更新」语义正确。
- **位置**:`internal/service/outfit.go:210-224`
- **问题**:请求 A 条件更新(私→公)成功、扣额度失败、回退公开态;期间并发请求 B 的条件更新返回 0 行(已被 A 置为公开),B 幂等返回「成功」。最终穿搭实际为未公开,但 B 用户收到成功响应,且双方均未写审核日志。
- **修复建议**:扣额失败回退后返回明确错误码(而非让并发请求误报成功);或回退前重查状态,仅在仍为公开时回退。
### M14. 衣物「异步处理失败」与「用户删除」并发存在额度漏退窗口 —— ✅ 已修复(2026-09-08)
> 修复:`markProcessFailed` 重写为行锁单事务(FOR UPDATE → 置失败 → 同事务回退额度 + 写流水),与删除事务通过行锁互斥,两路径恰好回退一次;删除 `refundIfExists` 与 DAO `ClothesMarkProcessFailedIfNotFailed`。
- **位置**:`internal/service/clothes.go:216-238`(`markProcessFailed` → `refundIfExists`)与 `306-368`(删除事务)
- **问题**:时序:异步路径先提交 `image_process_status=5` → 删除事务 `FOR UPDATE` 读到失败态跳过回退并软删 → 异步路径随后 `ClothesGetByUUID` 读不到(已软删)跳过回退。两个「应回退」的事件叠加,结果零回退,用户损失 1 点上传额度(概率低,但真实存在)。
- **说明**:上一轮担心的「双退」窗口经复核已被防御(GORM soft_delete 自动过滤 + 条件更新 + 删除事务行锁串行化),不会重复回退;但漏退窗口未覆盖。
- **修复建议**:删除事务中对「状态=失败但删除前未回退过」的场景仍执行回退(可查回退流水是否存在判断),或 `refundIfExists` 改为按流水幂等凭证条件回退。
### M15. 虚拟支付订单禁取消带来的垃圾订单堆积(设计权衡)
- **位置**:`internal/service/order.go:213-217`
- **问题**:虚拟支付订单禁取消后,用户下单不支付只能等 15 分钟过期,过期订单仅靠每分钟扫描批量更新,量大时扫描压力与脏数据堆积。属可接受的设计权衡(避免「已付款但权益不发放」),建议加监控观察堆积量。
---
## 三、轻微问题
| # | 问题 | 位置 | 建议 |
|---|---|---|---|
| L1 | 通知 `read_time` 永不写入,恒为 0 | `internal/mysqldao/notification.go:53-60` | 标记已读时写入 `read_time` |
| L2 | 并发点赞时基于前置读的 `wasLiked` 判断,可能重复发通知 | `internal/service/like.go:61-65` | 通知本身加幂等凭证或改为「状态变更成功才通知」(upsert 返回是否新增) |
| L3 | 收藏列表:已删除穿搭被跳过导致 `total` 与 `list` 长度不一致,且每页 N+1 查询 | `internal/service/collect.go:91-110` | 批量查询 + 过滤后重算 total,或 join 查询 |
| L4 | CORS 未配置 Domain 时回退 `Allow-Origin: *` | `internal/middleware/cors.go:21` | 生产环境强制配置 Domain |
| L5 | `http.Server` 未设 `IdleTimeout`/`ReadHeaderTimeout`,存在 slowloris 风险 | `main.go:73-81` | 补充超时配置 |
| L6 | 临时封禁无自动解封逻辑,`ban_expire` 到期后账号永久禁用 | `internal/service/user.go:95` | 增加解封后台任务或查询时按 `ban_expire` 动态判定 |
| L7 | 封禁状态受 24h 用户缓存延迟影响,改库封禁最长 24h 生效 | `internal/middleware/lzcwyjwt.go:78-92` | 运营封禁入口需同步删除缓存 |
| L8 | `RefreshToken` 先删旧会话再写新会话,Del 成功 Set 失败时新旧 token 全部失效,用户被强制登出 | `internal/service/user.go:250-253` | Set 失败时保留旧会话或允许旧 refresh 重试 |
| L9 | `OrderExpireByTime` 硬编码状态字面量 1/4,与 service 层常量重复维护 | `internal/mysqldao/order.go:62-74` | 收敛为共享常量 |
| L10 | 迁移 `down` 为全量回滚且无二次确认,生产误执行即删全库 | `pkg/migration/migration.go` | 增加环境检查/交互确认 |
| L11 | XPay 商品同步启动失败无重试与告警渠道 | `internal/service/virtualpay.go:32-41` | 失败时上报监控 |
| L12 | XPay 客户端 HTTP 无重试,`getAccessToken` 网络抖动直接失败 | `pkg/wechat/virtualpay.go:184-233` | 关键调用加 1~2 次重试 |
| L13 | `Outfit.Create` 主表创建成功后明细失败,留下孤儿穿搭 | `internal/service/outfit.go:59-65` | 与 AI 推荐一致:失败时删除主记录 |
| L14 | `Outfit.Update` 基础字段与明细重建分两个事务,中途失败状态不一致 | `internal/service/outfit.go:118-140` | 合并事务或先明细后主表 |
| L15 | `ClosetShare.Create` 明细失败仅回退额度,主记录残留空分享 | `internal/service/closet.go:92-95` | 失败时删除主记录 |
| L16 | 衣物异步流水线信号量全局共享、阻塞式获取不可取消,单用户可占满 10 槽位 | `internal/service/clothes.go:26,106` | 获取信号量支持 ctx 取消,或 per-user 限额 |
| L17 | 扣额度 + 建记录成功后、goroutine 启动前崩溃,衣物永远停留「排队中」,无恢复任务 | `internal/service/clothes.go:88-95` | 增加启动/周期扫描重置超时排队记录 |
| L18 | COS 签名 URL 无过期参数,泄露后长期有效 | `pkg/upload/upload.go:165-178` | 签名加有效期 |
| L19 | AI 推荐 Redis 锁 180s 无续期,大模型响应逼近超时时可并发重复扣额 + 重复调用 | `internal/service/airecommend.go:50` | 锁续期或加大 TTL 并依赖额度流水幂等 |
| L20 | 运势生成任务单次执行失败无重试,当天数据可能不完整 | `pkg/tokenhub/scheduler.go:52-101` | 失败后延迟重试 |
| L21 | `time.Sleep(wait)` 在系统时间回拨时可能忙循环 | `pkg/tokenhub/scheduler.go:35-43` | wait<=0 时设最小间隔 |
| L22 | 锁 key 手写字符串(`ztcloset:lock:benefit:compensation` 等)与 redismodel 常量两处维护,易漂移 | `internal/service/benefit_compensation.go:40` | 收敛到 redismodel |
| L23 | 已废弃 DAO(`QuotaIncreaseUsed`/`QuotaDecreaseUsed` 等)与死代码未清理 | `internal/mysqldao/quota.go:36-64` | 删除防误用 |
| L24 | 普通支付回调 `finalizePaidOrder` 的 `pay_time` 用本地时间而非微信 `SuccessTime` | `internal/service/order.go:457-462` | 优先使用回调时间字段 |
| L25 | 道具同步任务将「全部上架商品」同步为虚拟道具,无商品级开关区分普通/虚拟通道 | `internal/mysqldao/goods.go:62-75` | 视业务需要增加商品级通道开关 |
---
## 四、上一轮修复项复核结论(已确认良好)
以下为上一轮「修复支付业务逻辑问题」计划涉及的修复点,本轮复核确认实现正确,无需再改:
1. **支付回调金额校验**:`PayNotify`/`RefundNotify`/XPay 发货推送均校验回调金额与订单/流水一致后才流转。✓
2. **条件状态流转防重复回调**:`OrderUpdateStatusByOrderNo`/`RefundFlowLogUpdateStatusByFrom` 条件更新 + Redis 幂等锁(30s)双层防护。✓
3. **过期/取消竞态兜底**:普通支付「已过期→补发货」「已取消→自动退款」,XPay「已过期/已取消→补发货」分支均已实现且使用重新查询的最新订单对象。✓
4. **权益发放幂等**:`benefit_grant_tasks` 唯一索引 (order_no, benefit_type) + claim 状态机 + 补偿任务先查流水幂等,链路完整。(M5 的重置绕过是唯一遗留缺口)
5. **退款回收「已用不回退」**:`GREATEST(used, total-n)` 与 `GREATEST(now, vip_expire-days)` 实现正确。✓
6. **虚拟支付订单禁取消、iOS 订单禁主动退款**:位置与校验顺序正确(归属校验 → 渠道校验 → 状态校验)。✓
7. **事务边界**:额度扣减/回退/发放/回收、VIP 发放/回收均为「数值变更 + 回读 + 写流水」同事务,唯一索引兜底幂等。✓
8. **SQL 注入面**:全部 DAO 使用参数绑定,未发现 SQLi。✓
9. **XSS**:响应均为 JSON 结构化输出,Gin 默认无模板渲染场景,未发现 XSS。✓
10. **SSRF**:出站请求仅微信固定域名与配置的 AI 服务地址,无用户可控 URL。✓
11. **密钥管理**:JWT Secret 启动强制校验、APIv3Key 32 字节校验、所有密钥仅存配置文件(已 gitignore),未发现硬编码。✓
---
## 五、修复优先级建议
| 优先级 | 编号 | 一句话结论 |
|---|---|---|
| P0(上线前) | S1 | XPay 推送伪造发货资损漏洞,必须加订单反查 |
| P0(上线前) | S2 | 道具价格同步仅启动一次,改价即资损 |
| P1 | M1、M2 | 退款误回收、校验失败静默(资损线索) |
| P1 | M5 | 补偿重试上限被绕过 |
| P2 | M3、M4、M6-M9 | iOS 白嫖、道具同步可靠性 |
| P2 | M10-M14 | 文档矛盾、限流、上传防护、公开竞态、衣物漏退 |
| P3 | L1-L25 | 按影响面择机处理 |
> 备注:本报告仅列出问题与建议,未修改任何代码。S1/S2 的修复方案(订单反查、同步周期化)涉及与微信 XPay 官方文档的联调核对,建议修复前先确认 `query_order` 等接口在官方文档中的字段与状态枚举。
+7
View File
@@ -0,0 +1,7 @@
package global
import "github.com/tencentyun/cos-go-sdk-v5"
var (
CosClient *cos.Client
)
+5
View File
@@ -0,0 +1,5 @@
package global
import "gorm.io/gorm"
var MysqlEngine *gorm.DB
+17
View File
@@ -0,0 +1,17 @@
package global
import (
"context"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/trace"
)
var (
// Tracer 全局 Tracer,业务层通过 Tracer.Start 创建 span。
Tracer trace.Tracer
// Meter 全局 Meter,供自定义指标使用。
Meter metric.Meter
// OTelShutdown 聚合的 OTel 优雅关闭函数,进程退出时调用以 flush 缓冲数据。
OTelShutdown func(context.Context) error
)
+5
View File
@@ -0,0 +1,5 @@
package global
import "github.com/redis/go-redis/v9"
var RedisEngine *redis.Client
+22
View File
@@ -0,0 +1,22 @@
package global
import "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/setting"
var (
ServerSetting *setting.ServerSettingS
LogSetting *setting.LogSettingS
MysqlSetting *setting.MysqlSettingS
RedisSetting *setting.RedisSettingS
OtelSetting *setting.OtelSettingS
JWTSetting *setting.JWTSettingS
UploadSetting *setting.UploadSettingS
CosSetting *setting.CosSettingS
TokenhubSetting *setting.TokenhubSettingS
MattingModelSetting *setting.MattingModelSettingS
ClassificationModelSetting *setting.ClassificationModelSettingS
WechatMiniSetting *setting.WechatMiniSettingS
WechatPaySetting *setting.WechatPaySettingS
VirtualPaySetting *setting.VirtualPaySettingS
UserDefaultQuotaSetting *setting.UserDefaultQuotaSettingS
AdRewardSetting *setting.AdRewardSettingS
)
+91
View File
@@ -0,0 +1,91 @@
module git.lzcwy.cn/zhengchiliu/zt-closet-backend
go 1.27.0
require (
github.com/gin-gonic/gin v1.12.0
github.com/go-sql-driver/mysql v1.10.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/google/uuid v1.6.0
github.com/redis/go-redis/v9 v9.22.0
github.com/spf13/viper v1.21.0
github.com/tencentyun/cos-go-sdk-v5 v0.7.75
github.com/wechatpay-apiv3/wechatpay-go v0.2.21
go.opentelemetry.io/contrib/bridges/otelslog v0.20.1
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.71.0
go.opentelemetry.io/contrib/instrumentation/runtime v0.71.0
go.opentelemetry.io/otel v1.46.0
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.22.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.46.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0
go.opentelemetry.io/otel/log v0.22.0
go.opentelemetry.io/otel/metric v1.46.0
go.opentelemetry.io/otel/sdk v1.46.0
go.opentelemetry.io/otel/sdk/log v0.22.0
go.opentelemetry.io/otel/sdk/metric v1.46.0
go.opentelemetry.io/otel/trace v1.46.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gorm.io/driver/mysql v1.6.0
gorm.io/gorm v1.31.2
gorm.io/plugin/soft_delete v1.2.1
)
require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.15.3 // indirect
github.com/bytedance/sonic/loader v0.5.2 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clbanning/mxj v1.8.4 // indirect
github.com/cloudwego/base64x v0.1.7 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
github.com/gin-contrib/sse v1.1.1 // indirect
github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.3 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/go-querystring v1.2.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
github.com/leodido/go-urn v1.5.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mozillazg/go-httpheader v0.4.0 // indirect
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.61.0 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.2 // indirect
go.mongodb.org/mongo-driver/v2 v2.8.2 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/arch v0.30.0 // indirect
golang.org/x/crypto v0.55.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect
google.golang.org/grpc v1.83.1 // indirect
google.golang.org/protobuf v1.36.12 // indirect
)
+284
View File
@@ -0,0 +1,284 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/agiledragon/gomonkey v2.0.2+incompatible h1:eXKi9/piiC3cjJD1658mEE2o3NjkJ5vDLgYjCQu0Xlw=
github.com/agiledragon/gomonkey v2.0.2+incompatible/go.mod h1:2NGfXu1a80LLr2cmWXGBDaHEjb1idR6+FVlX5T3D9hw=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
github.com/bytedance/sonic v1.15.3 h1:P3akjLPBtV/i6bHC6LbcLjY3KuoOvfiqF8wFHeP5IhY=
github.com/bytedance/sonic v1.15.3/go.mod h1:8e51yTPdY8M6t+vvGL1c2Y1xL9i+frEeIAQAEl75NUc=
github.com/bytedance/sonic/loader v0.5.2 h1:0QtP1gevc1OZ6/H8Lb9BRZiCXd1Ftjd3OKuj1T1lBIo=
github.com/bytedance/sonic/loader v0.5.2/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/clbanning/mxj v1.8.4 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I=
github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng=
github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI=
github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0=
github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-sqlite3 v1.14.3/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/mozillazg/go-httpheader v0.2.1/go.mod h1:jJ8xECTlalr6ValeXYdOF8fFUISeBAdw6E61aqQma60=
github.com/mozillazg/go-httpheader v0.4.0 h1:aBn6aRXtFzyDLZ4VIRLsZbbJloagQfMnCiYgOq6hK4w=
github.com/mozillazg/go-httpheader v0.4.0/go.mod h1:PuT8h0pw6efvp8ZeUec1Rs7dwjK08bt6gKSReGMqtdA=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.61.0 h1:ui88A53s8MSVYLC56en0KQ17HARk+9986Dn0SBfKNvA=
github.com/quic-go/quic-go v0.61.0/go.mod h1:9So2anK4Tp22URSQq00k+Vo2PNkle96ycDPDHL4s9vs=
github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0=
github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529/go.mod h1:qe5TWALJ8/a1Lqznoc5BDHpYX/8HU60Hm2AwRmqzxqA=
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.563/go.mod h1:7sCQWVkxcsR38nffDW057DRGk8mUjK1Ing/EFOK8s8Y=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/kms v1.0.563/go.mod h1:uom4Nvi9W+Qkom0exYiJ9VWJjXwyxtPYTkKkaLMlfE0=
github.com/tencentyun/cos-go-sdk-v5 v0.7.75 h1:eRCGP5chujSYGFnsCPxlhQcN9wtlSO/4eXrxKtLVAIw=
github.com/tencentyun/cos-go-sdk-v5 v0.7.75/go.mod h1:STbTNaNKq03u+gscPEGOahKzLcGSYOj6Dzc5zNay7Pg=
github.com/tencentyun/qcloud-cos-sts-sdk v0.0.0-20250515025012-e0eec8a5d123/go.mod h1:b18KQa4IxHbxeseW1GcZox53d7J0z39VNONTxvvlkXw=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.2 h1:zkEASHHyEClGeURfgNT9PJZVfAbs9oEX9QXggwWNJbc=
github.com/ugorji/go/codec v1.3.2/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/wechatpay-apiv3/wechatpay-go v0.2.21 h1:uIyMpzvcaHA33W/QPtHstccw+X52HO1gFdvVL9O6Lfs=
github.com/wechatpay-apiv3/wechatpay-go v0.2.21/go.mod h1:A254AUBVB6R+EqQFo3yTgeh7HtyqRRtN2w9hQSOrd4Q=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.mongodb.org/mongo-driver/v2 v2.8.2 h1:b6o2m7zL8g2URuO8urBedAylxojybKXNZTxgkOcl+2w=
go.mongodb.org/mongo-driver/v2 v2.8.2/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/bridges/otelslog v0.20.1 h1:5sHc4ToTFjfSZCtGAAM6jPunICAmJX73htv372T4ipc=
go.opentelemetry.io/contrib/bridges/otelslog v0.20.1/go.mod h1:oa6kgvyz/3GYW04dohd0++xJIH4xdQY8PAbpeCMaM8M=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.71.0 h1:TMTU0sQyqsF1QU+/Q4LAZlLOx1L3FJDbk5N2RVB1nx4=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.71.0/go.mod h1:QzTELfxkj/tFEZSD22OPPwLet5nIPmcdmZPeISk4C8M=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04=
go.opentelemetry.io/contrib/instrumentation/runtime v0.71.0 h1:v4KkRLVvE1cWqTJDfZZkTCG+Z4aolsa6RVos0FX7vqE=
go.opentelemetry.io/contrib/instrumentation/runtime v0.71.0/go.mod h1:g/xbuPC0XbgwMdKuyF5sKOUUEsorSkN6APydyFP/H9E=
go.opentelemetry.io/contrib/propagators/b3 v1.46.0 h1:OFVqWObn7xLIbOjE/koO0LS9fZJNgAyBD0msA+UQAoc=
go.opentelemetry.io/contrib/propagators/b3 v1.46.0/go.mod h1:t/d64xy7xuuEDJN/4ThqohLgRhIuQxL9y7P1v02bYuM=
go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc=
go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.22.0 h1:Bu39F5tzJct+f2IZbB8989fwyTps3c8e7EsUQsz+vs8=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.22.0/go.mod h1:dJUwod88EsFgYCqrDHaSPzhiY9pBUpt0d85/qSfua7k=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.46.0 h1:qkDYCAFiZXLcs1L4aY+tP2wguQ4kURANqHOQMA2et2s=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.46.0/go.mod h1:tkipS4DRzmpAmvg+Gw4++O1IdDq6TVDnvnYU6cmbQVs=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 h1:OFnwLJr+pF3iHrlGSzbxyuo6/6HyBlnlN1CWEJmBVcw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0/go.mod h1:716wFneO0ov19A2beH5hjfh9AK5z/VWNAtDijp1Y0/g=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0 h1:w53CDeOA/Kurp7yRsegSr6pbbr759dOvJ+yNmWM6Hxs=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0/go.mod h1:BOmGMCbAtvcJiSJ+hLuhgPLdDbimnraSl8irz3iY8sY=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0 h1:KdRxPiAoMptR3vfWzvjjvutTsSiwbC2uG0496rzZNfo=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0/go.mod h1:K/qSA+3G7Eovxi4K09wzrAgkWRnosS0DAOZeEpve7sM=
go.opentelemetry.io/otel/log v0.22.0 h1:5DBNnfvaJ6CVdkJ+Jle8Tzs50aSSv49TXGj9XRsEYw0=
go.opentelemetry.io/otel/log v0.22.0/go.mod h1:gzOt/R67vF2GniAqWu8Qv0SXy89f71muHcrkz76PCdc=
go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8=
go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o=
go.opentelemetry.io/otel/metric/x v0.68.0 h1:TA/cBT23D3MnxYPwHL7YFOdYGdx0A0v+s7Mzotpd1dU=
go.opentelemetry.io/otel/metric/x v0.68.0/go.mod h1:agudOmvWhwUTjgibWDzxD2PoWYnpw5Ht5jISYOD2Hd4=
go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI=
go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM=
go.opentelemetry.io/otel/sdk/log v0.22.0 h1:PRL+s6P63XT4E/bheEflopPUpVxuvANqZwtt89yhoGk=
go.opentelemetry.io/otel/sdk/log v0.22.0/go.mod h1:JNp0sBELrjCTcu5W3GzABVypeU6vDJjBS+X0JISuz+g=
go.opentelemetry.io/otel/sdk/log/logtest v0.22.0 h1:infPnfNrhCNgOUZRs3gWUg8vhoBUHihq02gwK05gzlg=
go.opentelemetry.io/otel/sdk/log/logtest v0.22.0/go.mod h1:gkQZA3z15Bv3KU9vigBTi8dFechSozRP7v94X4VZv+s=
go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE=
go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4=
go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c=
go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI=
go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk=
go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/arch v0.30.0 h1:sB9h+1gRGa2+LauFSV0tm8bK1J2yo1bx6/Uyi/P6DTU=
golang.org/x/arch v0.30.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 h1:ax2KzoSRIZU/M0cIxri3pKxy99vniH1PVxWC6si/eZI=
google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688/go.mod h1:1RJ9BQGyNdZwkGc1eTqkErfRZ6RJyYPHZo73BZ1vQqI=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 h1:cYNAzI2sUwhmCcoj9TxvihSrqsxt6uIkj3rDRhSDmW4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA=
google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y=
google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/driver/sqlite v1.1.3/go.mod h1:AKDgRWk8lcSQSw+9kxCJnX/yySj8G3rdwYlU57cB45c=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.20.1/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
gorm.io/gorm v1.23.0/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gorm.io/plugin/soft_delete v1.2.1 h1:qx9D/c4Xu6w5KT8LviX8DgLcB9hkKl6JC9f44Tj7cGU=
gorm.io/plugin/soft_delete v1.2.1/go.mod h1:Zv7vQctOJTGOsJ/bWgrN1n3od0GBAZgnLjEx+cApLGk=
+16
View File
@@ -0,0 +1,16 @@
package dto
// AdRewardGrantRequest 广告激励额度发放请求。
// RewardType 取值:cos / ai / outfit_share / closet_share,映射到额度类型。
// 发放金额由服务端配置固定,前端不传金额,杜绝刷额度。
type AdRewardGrantRequest struct {
RewardType string `json:"reward_type" binding:"required"` // 激励类型
RefID string `json:"ref_id" binding:"required"` // 广告记录ID(幂等键)
}
// AdRewardGrantResponse 广告激励额度发放响应。
type AdRewardGrantResponse struct {
RefID string `json:"ref_id"` // 广告记录ID
QuotaType int8 `json:"quota_type"` // 额度类型 1=cos 2=AI 3=社区分享 4=衣柜分享
Amount int64 `json:"amount"` // 发放数量
}
+28
View File
@@ -0,0 +1,28 @@
package dto
// 发起AI推荐
type AIRecommendCreateRequest struct {
SceneType int8 `json:"scene_type" binding:"required,oneof=1 2"` // 场景类型 1=每日推荐 2=个性化场景
Scene string `json:"scene"` // 用户自定义场景描述
}
// AI推荐结果
type AIRecommendCreateResponse struct {
OutfitUUID string `json:"outfit_uuid"` // 穿搭UUID
OutfitName string `json:"outfit_name"` // 穿搭名称
OutfitDesc string `json:"outfit_desc"` // 穿搭描述
Scene string `json:"scene"` // 适合场景
Source int8 `json:"source"` // 来源 1=用户创建 2=AI推荐
Status int8 `json:"status"` // 状态 1=正常 2=封禁 3=待用户保存
OutfitItem []OutfitItem `json:"outfit_item"`
}
// AI推荐穿搭的衣物UUID
type OutfitItem struct {
ClothesUUID string `json:"clothes_uuid"` // 衣物UUID
}
// 保存AI推荐穿搭到我的搭配
type AIRecommendSaveRequest struct {
OutfitUUID string `json:"outfit_uuid" binding:"required"` // 穿搭UUID
}
+52
View File
@@ -0,0 +1,52 @@
package dto
// ClosetShareCreateRequest 创建衣柜分享
type ClosetShareCreateRequest struct {
ShareType int8 `json:"share_type" binding:"required,oneof=1 2"` // 1=部分衣物分享 2=全量快照分享
ClothesUUIDs []string `json:"clothes_uuids"` // 部分分享时必填(service校验)
ExpireTime int64 `json:"expire_time"` // 过期时间
}
// ClosetShareCreateResponse 创建分享响应
type ClosetShareCreateResponse struct {
ShareUUID string `json:"share_uuid"`
}
// ClosetShareDetailResponse 分享详情(访客视角)
type ClosetShareDetailResponse struct {
ShareUUID string `json:"share_uuid"`
OwnerName string `json:"owner_name"`
OwnerAvatar string `json:"owner_avatar"`
Clothes []ClothesGetByUUIDResponse `json:"clothes"`
ExpireTime int64 `json:"expire_time"`
Status int8 `json:"status"` // 1=正常 2=已取消 3=已过期 4=已封禁
IsOwner bool `json:"is_owner"`
}
// ClosetShareItemResponse 分享列表项
type ClosetShareItemResponse struct {
ShareUUID string `json:"share_uuid"` // 分享UUID
ShareType int8 `json:"share_type"` // 分享类型 1=部分衣物分享 2=全量快照分享
ClothesCount int `json:"clothes_count"` // 衣物数量
ExpireTime int64 `json:"expire_time"` // 过期时间
Status int8 `json:"status"` // 1=正常 2=已取消 3=已过期 4=已封禁
OwnerName string `json:"owner_name"` // 分享者昵称("分享给我的"列表返回)
OwnerAvatar string `json:"owner_avatar"` // 分享者头像("分享给我的"列表返回)
}
// ShareListRequest 我的分享列表
type ClosetShareListRequest struct {
PageRequest
Status int8 `form:"status" json:"status"` // 状态 1=正常 2=已取消 3=已过期 4=已封禁
}
// ShareListResponse 我的分享列表
type ShareListResponse struct {
PageResponse
List []ClosetShareItemResponse `json:"list"`
}
// ClosetShareCancelRequest 取消分享
type ClosetShareCancelRequest struct {
ShareUUID string `json:"share_uuid" binding:"required"`
}
+91
View File
@@ -0,0 +1,91 @@
package dto
// ClothesUploadRequest 上传衣物图片
type ClothesUploadRequest struct{}
// ClothesUploadResponse 上传衣物图片响应
type ClothesUploadResponse struct {
ClothesUUID string `json:"clothes_uuid"` // 衣物UUID
ImageProcessStatus int8 `json:"image_process_status"` // 1=抠图中 2=分类中 3=排队中 4=成功 5=失败
}
type ClothesProcessStatusGetByUUIDRequest struct {
ClothesUUID string `json:"clothes_uuid" binding:"required"`
}
type ClothesProcessStatusGetByUUIDResponse struct {
ImageProcessStatus int8 `json:"image_process_status"` // 1=抠图中 2=分类中 3=排队中 4=成功 5=失败
FailReason string `json:"fail_reason"`
}
// ClothesDeleteByUUIDRequest 删除衣物
type ClothesDeleteByUUIDRequest struct {
ClothesUUID string `json:"clothes_uuid" binding:"required"`
}
// ClothesDeleteResponse 删除衣物响应
type ClothesDeleteByUUIDResponse struct{}
// ClothesUpdateByUUIDRequest 修改衣物
type ClothesUpdateByUUIDRequest struct {
ClothesUUID string `json:"clothes_uuid" binding:"required"`
CategoryMain string `json:"category_main" binding:"max=64"` // 衣物大类 上装/下装/连衣裙/外套/鞋/配饰
CategorySub string `json:"category_sub" binding:"max=64"` // 衣物小类 T恤/牛仔裤/运动鞋/发带/戒指等
Style string `json:"style" binding:"max=64"` // 风格
ColorMain string `json:"color" binding:"max=64"` // 主颜色
ColorSub string `json:"color_sub" binding:"max=64"` // 副颜色
Thickness string `json:"thickness" binding:"max=64"` // 厚度
Version string `json:"version" binding:"max=64"` // 版型 宽松/修身/阔腿/紧身/常规合身
Material string `json:"material" binding:"max=64"` // 材质 牛仔布/针织/皮质/纯棉/羊毛等
Pattern string `json:"pattern" binding:"max=64"` // 花纹
SleeveType string `json:"sleeve_type" binding:"max=64"` // 袖型
ClothesType string `json:"clothes_type" binding:"max=64"` // 衣长类型 短款/常规/中长/长款
NecklineType string `json:"neckline_type" binding:"max=64"` // 领型
Formality string `json:"formality" binding:"max=16"` // 正式程度 1-5
Occasions string `json:"occasions" binding:"max=255"` // 适用场合
VisualImpact string `json:"visual_impact" binding:"max=16"` // 视觉冲击力 1-5
Name string `json:"name" binding:"max=64"` // 名称
Remark string `json:"remark" binding:"max=500"` // 备注
}
type ClothesUpdateByUUIDResponse struct{}
type ClothesGetByUUIDRequest struct {
ClothesUUID string `json:"clothes_uuid" binding:"required"` // 衣物UUID
}
// ClothesGetByUUIDResponse 衣物信息
type ClothesGetByUUIDResponse struct {
ClothesUUID string `json:"clothes_uuid"`
CategoryMain string `json:"category_main"`
CategorySub string `json:"category_sub"`
Style string `json:"style"`
ColorMain string `json:"color_main"`
ColorSub string `json:"color_sub"`
Thickness string `json:"thickness"`
Version string `json:"version"`
Material string `json:"material"`
Pattern string `json:"pattern"`
SleeveType string `json:"sleeve_type"`
ClothesType string `json:"clothes_type"`
NecklineType string `json:"neckline_type"`
Formality string `json:"formality"`
Occasions string `json:"occasions"`
VisualImpact string `json:"visual_impact"`
Name string `json:"name"`
Remark string `json:"remark"`
ProcessedImage string `json:"processed_image"`
}
// ClothesListRequest 衣物列表
type ClothesListRequest struct {
PageRequest
CategoryMain string `form:"category_main"` // 按大类筛选
CategorySub string `form:"category_sub"`
}
// ClothesListResponse 衣物列表
type ClothesListResponse struct {
PageResponse
List []ClothesGetByUUIDResponse `json:"list"`
}
+29
View File
@@ -0,0 +1,29 @@
package dto
// 收藏/取消收藏
type CollectRequest struct {
TargetUUID string `json:"target_uuid" binding:"required"`
Action int8 `json:"action" binding:"required,oneof=1 2"` // 1=收藏 2=取消
}
type CollectResponse struct{}
type CollectListRequest struct {
PageRequest
}
type CollectListResponse struct {
PageResponse
List []OutfitGetByUUIDResponse `json:"list"`
}
// CollectGetByTargetRequest 查询穿搭收藏状态
type CollectGetByTargetRequest struct {
TargetUUID string `json:"target_uuid" binding:"required"` // 穿搭UUID
}
// CollectGetByTargetResponse 查询穿搭收藏状态
type CollectGetByTargetResponse struct {
UserIsCollected bool `json:"is_collected"` // 是否已收藏
CollectCount int64 `json:"collect_count"` // 收藏数
}
+14
View File
@@ -0,0 +1,14 @@
package dto
// PageRequest 分页请求参数(GET query,嵌入各列表请求)
type PageRequest struct {
Page int `form:"page" binding:"omitempty,min=1"`
PageSize int `form:"page_size" binding:"omitempty,min=1,max=50"`
}
// PageResponse 分页响应(嵌入各列表响应)
type PageResponse struct {
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
+34
View File
@@ -0,0 +1,34 @@
package dto
type GoodsGetByCodeRequest struct {
GoodsCode string `json:"goods_code" binding:"required"`
}
// GoodsGetByCodeResponse 商品信息
type GoodsGetByCodeResponse struct {
ID int64 `json:"id"`
GoodsCode string `json:"goods_code"`
GoodsName string `json:"goods_name"`
GoodsType int8 `json:"goods_type"` // 1=会员包 2=上传包
Price int64 `json:"price"` // 原价(分)
DiscountPrice int64 `json:"discount_price"` // 售价(分)
MemberDays int64 `json:"member_days"`
UploadQuota int64 `json:"upload_quota"`
AIRecommendQuota int64 `json:"ai_recommend_quota"`
ClosetShareQuota int64 `json:"closet_share_quota"`
OutfitShareQuota int64 `json:"outfit_share_quota"`
ValidDays int64 `json:"valid_days"` // 赠送额度有效期天数(加量包;会员包为 0 表示跟随 VIP)
Description string `json:"description"`
}
// GoodsListRequest 商品信息
type GoodsListRequest struct {
PageRequest
GoodsType int8 `form:"goods_type" json:"goods_type"` // 1=会员包 2=上传包 3=AI推荐包 4=衣柜分享包 5=搭配分享包
}
// GoodsListResponse 商品列表
type GoodsListResponse struct {
PageResponse
List []GoodsGetByCodeResponse `json:"list"`
}
+16
View File
@@ -0,0 +1,16 @@
package dto
type HoroscopeRequest struct{}
// HoroscopeResponse 今日运势
type HoroscopeResponse struct {
StatDate int64 `json:"stat_date"` // YYYYMMDD
Zodiac string `json:"zodiac"` // 星座
OverallScore int8 `json:"overall_score"` // 1-5分
LoveScore int8 `json:"love_score"` // 1-5分
CareerScore int8 `json:"career_score"` // 1-5分
WealthScore int8 `json:"wealth_score"` // 1-5分
DailyText string `json:"daily_text"` // 今日运势
LuckyColor string `json:"lucky_color"` // 今日幸运色
LuckyNumber string `json:"lucky_number"` // 今日幸运数字
}
+21
View File
@@ -0,0 +1,21 @@
package dto
// LikeCreateRequest 点赞/取消点赞
type LikeCreateRequest struct {
TargetUUID string `json:"target_uuid" binding:"required"` // 穿搭UUID
Action int8 `json:"action" binding:"required,oneof=1 2"` // 1=点赞 2=取消
}
// LikeResponse 点赞/取消点赞响应
type LikeResponse struct{}
// LikeGetByTargetIDRequest 获取穿搭的点赞数
type LikeGetByTargetIDRequest struct {
TargetUUID string `json:"target_uuid" binding:"required"` // 穿搭UUID
}
// LikeGetByTargetIDResponse 获取穿搭的点赞数
type LikeGetByTargetIDResponse struct {
UserIsLiked bool `json:"is_liked"` // 是否已点赞
LikeCount int64 `json:"like_count"` // 点赞数
}
+39
View File
@@ -0,0 +1,39 @@
package dto
// NotificationListRequest 通知列表
type NotificationListRequest struct {
PageRequest
IsRead int8 `form:"is_read" binding:"omitempty,oneof=1 2"` // 1=未读 2=已读,不传=全部
MsgType int8 `form:"msg_type" binding:"omitempty,oneof=1 2 3"` // 1=点赞 2=订单 3=系统,不传=全部
}
// NotificationGetByUUIDRequest 获取通知详情
type NotificationGetByUUIDRequest struct {
NotificationUUID string `json:"notification_uuid" binding:"required"`
}
// NotificationGetByUUIDResponse 通知项
type NotificationGetByUUIDResponse struct {
NotificationUUID string `json:"notification_uuid"` // 消息UUID
MsgType int8 `json:"msg_type"` // 1=点赞通知 2=订单通知 3=系统通知
Title string `json:"title"`
Content string `json:"content"`
IsRead int8 `json:"is_read"` // 1=未读 2=已读
CreatedAt int64 `json:"created_at"`
}
// NotificationListResponse 通知列表
type NotificationListResponse struct {
PageResponse
List []NotificationGetByUUIDResponse `json:"list"`
}
// NotificationReadRequest 标记已读
type NotificationReadRequest struct {
NotificationUUID string `json:"notification_uuid" binding:"required"`
}
// NotificationUnreadResponse 未读数
type NotificationUnreadResponse struct {
UnreadCount int64 `json:"unread_count"`
}
+86
View File
@@ -0,0 +1,86 @@
package dto
// OrderCreateRequest 创建订单
type OrderCreateRequest struct {
GoodsCode string `json:"goods_code" binding:"required"`
}
// OrderCreateResponse 创建订单响应
type OrderCreateResponse struct {
OrderNo string `json:"order_no"`
PayAmount int64 `json:"pay_amount"` // 应付(分)
ExpireTime int64 `json:"expire_time"` // 支付截止(秒),前端倒计时
PayParams *WechatPayParams `json:"pay_params"` // 小程序拉起支付参数
}
// WechatPayParams 微信小程序支付参数
type WechatPayParams struct {
TimeStamp string `json:"timeStamp"`
NonceStr string `json:"nonceStr"`
Package string `json:"package"`
SignType string `json:"signType"`
PaySign string `json:"paySign"`
}
// VirtualOrderCreateRequest 创建虚拟支付订单(小程序虚拟支付,覆盖 iOS/Android 全终端)
type VirtualOrderCreateRequest struct {
GoodsCode string `json:"goods_code" binding:"required"` // 商品编码
Platform string `json:"platform" binding:"required,oneof=android ios harmony windows"` // 支付平台(前端按 systemInfo.platform 上报)
}
// VirtualOrderCreateResponse 虚拟支付订单响应(前端据此调 wx.requestVirtualPayment)
type VirtualOrderCreateResponse struct {
OrderNo string `json:"order_no"` // 业务订单号(作为 outTradeNo)
PayAmount int64 `json:"pay_amount"` // 实付金额(分)
ExpireTime int64 `json:"expire_time"` // 支付截止(秒),前端倒计时
SignData string `json:"sign_data"` // 参与签名的 JSON 字符串(原样传给前端)
PaySig string `json:"pay_sig"` // 支付签名
Signature string `json:"signature"` // 用户态签名
}
// OrderResponse 订单信息
type OrderResponse struct {
OrderNo string `json:"order_no"`
GoodsName string `json:"goods_name"`
UnitPrice int64 `json:"unit_price"` // 单价(分)
PayAmount int64 `json:"pay_amount"` // 实付(分)
PayChannel int8 `json:"pay_channel"` // 支付渠道 1=微信支付 2=虚拟支付
OrderStatus int8 `json:"order_status"` // 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款
MemberDays int64 `json:"member_days"`
ExpireTime int64 `json:"expire_time"`
PayTime int64 `json:"pay_time"`
RefundTime int64 `json:"refund_time"`
RefundAmount int64 `json:"refund_amount"`
CreatedAt int64 `json:"created_at"`
}
// OrderListRequest 订单列表
type OrderListRequest struct {
PageRequest
Status int8 `form:"status" binding:"omitempty,oneof=1 2 3 4 5"`
}
// OrderListResponse 订单列表
type OrderListResponse struct {
PageResponse
List []OrderResponse `json:"list"`
}
// OrderCancelRequest 取消订单
type OrderCancelRequest struct {
OrderNo string `json:"order_no" binding:"required"`
}
// RefundApplyRequest 申请退款
type RefundApplyRequest struct {
OrderNo string `json:"order_no" binding:"required"`
Reason string `json:"reason" binding:"required,max=200"`
}
// RefundResponse 退款信息
type RefundResponse struct {
RefundNo string `json:"refund_no"`
RefundAmount int64 `json:"refund_amount"`
Status int8 `json:"status"` // 0=待处理 1=退款中 2=成功 3=失败
ApplyTime int64 `json:"apply_time"`
}
+83
View File
@@ -0,0 +1,83 @@
package dto
// OutfitCreateRequest 创建穿搭
type OutfitCreateRequest struct {
OutfitName string `json:"outfit_name" binding:"required,max=32"` // 穿搭名称
OutfitDesc string `json:"outfit_desc" binding:"max=500"` // 穿搭描述
Scene string `json:"scene" binding:"max=100"` // 适合场景
Source int8 `json:"source" binding:"oneof=1 2"` // 1=用户创建 2=AI推荐
Items []OutfitItemRequest `json:"items" binding:"required,min=1,max=10"`
}
// OutfitItemRequest 穿搭中的衣物
type OutfitItemRequest struct {
ClothesUUID string `json:"clothes_uuid" binding:"required"`
}
// OutfitCreateResponse 创建穿搭响应
type OutfitCreateResponse struct {
OutfitUUID string `json:"outfit_uuid"`
OutfitName string `json:"outfit_name"`
OutfitDesc string `json:"outfit_desc"`
Scene string `json:"scene"`
Source int8 `json:"source"` // 1=用户创建 2=AI推荐
IsPublic int8 `json:"is_public"` // 1=不公开 2=公开
AuditStatus int8 `json:"audit_status"` // 1=待审核 2=通过 3=不通过
Items []OutfitItemResponse `json:"items"`
}
// OutfitItemResponse 穿搭明细
type OutfitItemResponse struct {
ClothesUUID string `json:"clothes_uuid"`
ProcessedImage string `json:"processed_image"` // 处理后图片可访问 URL(无则空)
}
// OutfitGetByUUIDRequest 获取穿搭详情
type OutfitGetByUUIDRequest struct {
OutfitUUID string `json:"outfit_uuid" binding:"required"` // 穿搭UUID
}
// OutfitGetByUUIDResponse 获取穿搭详情响应
type OutfitGetByUUIDResponse struct {
OutfitCreateResponse
}
// OutfitUpdateByUUIDRequest 修改穿搭
type OutfitUpdateByUUIDRequest struct {
OutfitUUID string `json:"outfit_uuid" binding:"required"` // 穿搭UUID
OutfitName string `json:"outfit_name" binding:"required,max=32"` // 穿搭名称
OutfitDesc string `json:"outfit_desc" binding:"max=500"` // 穿搭描述
Scene string `json:"scene" binding:"max=100"` // 适合场景
Items []OutfitItemRequest `json:"items" binding:"required,min=1,max=10"` // 穿搭中的衣物
}
// OutfitUpdateByUUIDResponse 修改穿搭响应
type OutfitUpdateByUUIDResponse struct{}
// OutfitListRequest 获取我的穿搭列表
type OutfitListRequest struct {
PageRequest
IsPublic int8 `form:"is_public" json:"is_public"` // 1=不公开 2=公开
Source int8 `form:"source" json:"source"` // 1=用户创建 2=AI推荐
}
// OutfitListResponse 我的穿搭列表响应
type OutfitListResponse struct {
PageResponse
List []OutfitCreateResponse `json:"list"`
}
// OutfitDeleteRequest 删除穿搭
type OutfitDeleteRequest struct {
OutfitUUID string `json:"outfit_uuid" binding:"required"`
}
// OutfitDeleteResponse 删除穿搭响应
type OutfitDeleteResponse struct{}
type OutfitPublicRequest struct {
OutfitUUID string `json:"outfit_uuid" binding:"required"` // 穿搭UUID
IsPublic int8 `json:"is_public" binding:"required,oneof=1 2"` // 1=不公开 2=公开
}
type OutfitPublicResponse struct{}
+44
View File
@@ -0,0 +1,44 @@
package dto
// QuotaInfoGetBySelfRequest 我的额度
type QuotaInfoGetBySelfRequest struct{}
// QuotaInfoGetBySelfResponse 我的额度
type QuotaInfoGetBySelfResponse struct {
Quotas []QuotaItemResponse `json:"quotas"`
}
// QuotaItemResponse 单项额度。
// Cos(quota_type=1):Total=永久总额度、Used=永久累计已用、Remain=永久剩余;
// AI/社区/衣柜(2/3/4):Total=每日总额度、Used=当日已用、Remain=当日剩余(次日 0 点重置)。
type QuotaItemResponse struct {
QuotaType int8 `json:"quota_type"` // 1=cos上传 2=AI 3=社区分享 4=衣柜分享
Total int64 `json:"total"` // 总额度(Cos 永久 / 每日额度)
Used int64 `json:"used"` // 已用(Cos 永久累计 / 当日已用)
Remain int64 `json:"remain"` // 剩余
}
// QuotaFlowListRequest 额度流水查询
type QuotaFlowListRequest struct {
PageRequest
QuotaType int8 `form:"quota_type" binding:"omitempty,oneof=1 2 3 4"` // 不传=全部
}
// QuotaFlowItemResponse 额度流水明细
type QuotaFlowItemResponse struct {
QuotaFlowUUID string `json:"quota_flow_uuid"` // 配额流水UUID
QuotaType int8 `json:"quota_type"` // 1=cos上传 2=AI 3=社区分享 4=衣柜分享
ChangeTarget int8 `json:"change_target"` // 1=配额调整 2=使用
ChangeType int8 `json:"change_type"` // 1=购买获赠 2=活动赠送 3=上传消耗 4=删除回退 5=人工调整
ChangeNum int64 `json:"change_num"` // 变更数量(正增负减)
NewValue int64 `json:"new_value"` // 变更后数值
Reason string `json:"reason"` // 变更原因/备注
OrderNo string `json:"order_no"` // 关联订单号(购买类)
CreatedAt int64 `json:"created_at"`
}
// QuotaFlowListResponse 额度流水列表
type QuotaFlowListResponse struct {
PageResponse
List []QuotaFlowItemResponse `json:"list"`
}
+68
View File
@@ -0,0 +1,68 @@
package dto
// UserWxLoginRequest 微信登录请求
type UserWxLoginRequest struct {
Code string `json:"code" binding:"required"` // 微信登录code
}
// UserWxLoginResponse 登录响应
type UserWxLoginResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpireTime int64 `json:"expire_time"` // access_token过期时间(秒)
UserInfo UserInfoGetBySelfResponse `json:"user_info"`
}
type UserInfoGetBySelfRequest struct{}
// UserInfoGetBySelfResponse 用户信息
type UserInfoGetBySelfResponse struct {
Username string `json:"username"`
Avatar string `json:"avatar"`
Sex int8 `json:"sex"` // 1=男 2=女
BirthDay int64 `json:"birth_day"` // 生日
Phone string `json:"phone"` // 手机号
IsNewUser bool `json:"is_new_user"` // 是否本次新注册用户
Status int8 `json:"status"` // 状态 1=正常 2=临时禁用 3=永久禁用 4=已注销
BanReason string `json:"ban_reason"` // 封禁原因
BanStart int64 `json:"ban_start"` // 封禁开始时间
BanExpire int64 `json:"ban_expire"` // 封禁到期时间
VIPType int8 `json:"vip_type"` // VIP类型 1=非会员 2=月度会员 3=年度会员 4=体验会员
VIPExpire int64 `json:"vip_expire"` // VIP到期时间
}
// UserProfileUpdateBySelfRequest 修改用户资料
type UserProfileUpdateBySelfRequest struct {
Username string `json:"username" binding:"max=32"` // 昵称
Avatar string `json:"avatar" binding:"max=500"` // 头像地址
Sex int8 `json:"sex" binding:"omitempty,oneof=0 1 2"` // 0=未知 1=男 2=女
BirthDay int64 `json:"birth_day"` // 生日
}
// UserProfileUpdateBySelfResponse 修改用户资料响应
type UserProfileUpdateBySelfResponse struct{}
// UserAvatarUploadRequest 上传头像(multipart,字段名 file)
type UserAvatarUploadRequest struct{}
// UserAvatarUploadResponse 上传头像响应
type UserAvatarUploadResponse struct {
Avatar string `json:"avatar"` // 头像可访问 URL
}
// UserRefreshTokenRequest 刷新用户token请求
type UserRefreshTokenRequest struct {
RefreshToken string `json:"refresh_token" binding:"required"`
// AccessToken 必填:旧 access token,用于刷新时立即吊销旧 access + 旧 refresh。
// 会话以 token 字符串为 key 存在 Redis 中,缺少旧 access token 无法精确删除其会话 key,
// 旧 token 会在 TTL 内继续可用;为空时返回 10000 + details:["缺少旧 access token"]。
// 注意:允许传入已过期的旧 access token(此时会话已随 TTL 消失,仅吊销旧 refresh 与集合条目)。
AccessToken string `json:"access_token"`
}
// UserRefreshTokenResponse 刷新用户token响应
type UserRefreshTokenResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpireTime int64 `json:"expire_time"` // access_token过期时间(秒)
}
+50
View File
@@ -0,0 +1,50 @@
package middleware
import (
"strings"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
"github.com/gin-gonic/gin"
)
// CORS 跨域中间件。
// 允许来源通过 Server.Domain 配置(如 test.com),自动放行其 http/https 两种协议的 Origin。
// 未配置时回退为 *(保持兼容)。
func CORS() gin.HandlerFunc {
domain := ""
if global.ServerSetting != nil {
domain = strings.TrimSpace(global.ServerSetting.Domain)
}
return func(c *gin.Context) {
origin := c.GetHeader("Origin")
allowOrigin := "*"
if domain != "" && domain != "*" {
allowOrigin = ""
if originAllowed(origin, domain) {
allowOrigin = origin
}
}
if allowOrigin != "" {
c.Header("Access-Control-Allow-Origin", allowOrigin)
}
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization")
c.Header("Access-Control-Expose-Headers", "Content-Length")
c.Header("Access-Control-Max-Age", "86400")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}
// originAllowed 判断 Origin 是否命中配置域名(http/https,忽略尾部斜杠)。
func originAllowed(origin, domain string) bool {
o := strings.TrimSuffix(strings.TrimSpace(origin), "/")
return o == "https://"+domain || o == "http://"+domain
}
+55
View File
@@ -0,0 +1,55 @@
package middleware
import (
"log/slog"
"net/url"
"strings"
"time"
"github.com/gin-gonic/gin"
)
// sanitizeQuery 对 query 中的敏感参数值脱敏,避免日志泄露 token/code/密钥等。
func sanitizeQuery(query string) string {
if query == "" {
return ""
}
values, err := url.ParseQuery(query)
if err != nil {
return "***"
}
sensitive := map[string]bool{
"token": true, "code": true, "secret": true, "password": true,
"access_token": true, "refresh_token": true, "key": true,
}
for k := range values {
if sensitive[strings.ToLower(k)] {
values.Set(k, "***")
}
}
return values.Encode()
}
func GinSlogMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
query := sanitizeQuery(c.Request.URL.RawQuery)
// 处理请求
c.Next()
// 请求结束后打日志
slog.InfoContext(c.Request.Context(), "GIN_REQUEST",
"request_id", GetRequestID(c.Request.Context()),
"method", c.Request.Method,
"path", path,
"query", query,
"status", c.Writer.Status(),
"ip", c.ClientIP(),
"latency", time.Since(start).String(),
"user_agent", c.Request.UserAgent(),
)
}
}
+113
View File
@@ -0,0 +1,113 @@
package middleware
import (
"context"
"errors"
"log/slog"
"strings"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/app"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/lzcwyjwt"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
)
type ctxKey string
const (
userIDKey ctxKey = "userID"
)
func JWTAuth() gin.HandlerFunc {
return func(c *gin.Context) {
response := app.NewResponse(c)
// 1. 从 Authorization 头取 token:Bearer xxx.yyy.zzz
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
response.ToErrorResponse(errcode.UnauthorizedTokenMissing)
c.Abort()
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails("invalid Authorization format"))
c.Abort()
return
}
// 2. 解析 token
claims, err := lzcwyjwt.ParseAccessToken(parts[1])
if err != nil {
switch {
case errors.Is(err, lzcwyjwt.ErrTokenExpired):
response.ToErrorResponse(errcode.UnauthorizedTokenTimeout)
case errors.Is(err, lzcwyjwt.ErrTokenWrongType):
response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails("token type mismatch"))
default:
response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails(err.Error()))
}
c.Abort()
return
}
// 2.5 校验 access token 是否仍在会话中(登出/失效后立即拒绝)。
sessionUID, serr := redisdao.AuthUserIDByAccess(c.Request.Context(), parts[1])
if serr != nil {
if errors.Is(serr, redis.Nil) {
response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails("token session invalid"))
c.Abort()
return
}
// Redis 其它错误降级放行(fail-open):
// 已确认的安全权衡决策——优先保证可用性,避免 Redis 抖动导致全员不可用。
// 代价是 Redis 故障窗口内,已登出/被吊销但未过期的 token 仍可访问;
// 封禁用户仍会被下方 DB 回查(Status != 1)拒绝,不会因本降级放行而绕过封禁。
slog.WarnContext(c.Request.Context(), "check access token session failed, allow by fallback", "error", serr)
} else if sessionUID != claims.UserID {
response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails("token user mismatch"))
c.Abort()
return
}
// 3. 从缓存获取用户信息,未命中或 Redis 出错都回落 DB 并回填缓存,检查封禁状态
userInfo, err := redisdao.UserInfoGet(c.Request.Context(), claims.UserID)
if err != nil {
if !errors.Is(err, redis.Nil) {
slog.WarnContext(c.Request.Context(), "get user info from cache failed, fallback to db", "error", err)
}
// 缓存未命中或 Redis 抖动:回落数据库。
dbUser, dbErr := mysqldao.NewUserDao().UserGetByID(c.Request.Context(), claims.UserID)
if dbErr != nil {
response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails("用户不存在"))
c.Abort()
return
}
userInfo = dbUser
_ = redisdao.UserInfoSet(c.Request.Context(), dbUser, 0)
}
// 状态非正常(1=正常)一律拒绝
if userInfo.Status != 1 {
response.ToErrorResponse(errcode.UserDisabled)
c.Abort()
return
}
// 4. 把 userID注入 context
ctx := context.WithValue(c.Request.Context(), userIDKey, claims.UserID)
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}
// GetUserID 从 context 取登录用户 ID(在 handler 里用)
func GetUserID(ctx context.Context) (int64, bool) {
uid, ok := ctx.Value(userIDKey).(int64)
return uid, ok
}
+16
View File
@@ -0,0 +1,16 @@
package middleware
import (
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
"github.com/gin-gonic/gin"
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
)
// GinOTelMiddleware 为每个 HTTP 请求自动开启 server span。
func GinOTelMiddleware() gin.HandlerFunc {
name := "zt-closet-backend"
if global.OtelSetting != nil && global.OtelSetting.ServiceName != "" {
name = global.OtelSetting.ServiceName
}
return otelgin.Middleware(name)
}
+37
View File
@@ -0,0 +1,37 @@
package middleware
import (
"context"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
const requestIDKey ctxKey = "requestID"
// RequestID 从请求头取 X-Request-ID,没有则生成 UUID。
// 注入 context 并写入响应头,便于全链路追踪。
func RequestID() gin.HandlerFunc {
return func(c *gin.Context) {
rid := c.GetHeader("X-Request-ID")
if rid == "" {
rid = uuid.New().String()
}
// 写入 gin context(供 handler 取用)
c.Set("requestID", rid)
// 写入 request context(供 service/dao 的 slog 取用)
ctx := context.WithValue(c.Request.Context(), requestIDKey, rid)
c.Request = c.Request.WithContext(ctx)
// 响应头
c.Header("X-Request-ID", rid)
c.Next()
}
}
// GetRequestID 从 context 取出 request-id。
func GetRequestID(ctx context.Context) string {
rid, _ := ctx.Value(requestIDKey).(string)
return rid
}

Some files were not shown because too many files have changed in this diff Show More