commit 9f22e6ccae7230cc84c65e9bb866169caa239e18
Author: zhengchiliu <857493511@qq.com>
Date: Fri Sep 11 01:41:52 2026 +0800
first commit
diff --git a/.codebuddy/plans/Go_后端全量代码审查_a0608b8a.md b/.codebuddy/plans/Go_后端全量代码审查_a0608b8a.md
new file mode 100644
index 0000000..63e8830
--- /dev/null
+++ b/.codebuddy/plans/Go_后端全量代码审查_a0608b8a.md
@@ -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**
+- 用途:追踪额度扣减/回退、订单状态流转、权益发放等关键符号的定义与引用链。
+- 预期结果:明确关键调用链与全部调用点,验证并发与幂等路径。
\ No newline at end of file
diff --git a/.codebuddy/plans/OpenTelemetry三信号完整接入_fe5b200f.md b/.codebuddy/plans/OpenTelemetry三信号完整接入_fe5b200f.md
new file mode 100644
index 0000000..0a451b5
--- /dev/null
+++ b/.codebuddy/plans/OpenTelemetry三信号完整接入_fe5b200f.md
@@ -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
global.Tracer.Start 子 span]
+ C --> D[slog 扇出 Handler]
+ D --> E[本地 JSON 文件+stdout
OtelContextHandler 注入 trace_id]
+ D --> F[otelslog bridge]
+ C --> G[TracerProvider
BatchSpanProcessor]
+ C --> H[MeterProvider
PeriodicReader + runtime 指标]
+ F --> I[LoggerProvider
BatchProcessor]
+ G --> J[(OTLP gRPC Collector
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)
+```
\ No newline at end of file
diff --git a/.codebuddy/plans/README_补充业务流程章节_75c77671.md b/.codebuddy/plans/README_补充业务流程章节_75c77671.md
new file mode 100644
index 0000000..7808f15
--- /dev/null
+++ b/.codebuddy/plans/README_补充业务流程章节_75c77671.md
@@ -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,提取业务流程撰写所需的业务规则事实(额度初始化与扣减回退时机、穿搭审核状态机、分享过期判定、订单与权益补偿链路、通知触发点、运势调度等)。
+- 预期结果:输出一份按业务域组织的规则事实清单(含具体常量数值、周期、触发条件、状态流转),作为撰写"业务流程"章节的唯一事实依据,确保不臆测。
\ No newline at end of file
diff --git a/.codebuddy/plans/api-v1层完整实现_e89004bd.md b/.codebuddy/plans/api-v1层完整实现_e89004bd.md
new file mode 100644
index 0000000..0795e8e
--- /dev/null
+++ b/.codebuddy/plans/api-v1层完整实现_e89004bd.md
@@ -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 绑定一次性对齐,减少编译期返工。
\ No newline at end of file
diff --git a/.codebuddy/plans/classification-server-completion_ed2c7660.md b/.codebuddy/plans/classification-server-completion_ed2c7660.md
new file mode 100644
index 0000000..32b099a
--- /dev/null
+++ b/.codebuddy/plans/classification-server-completion_ed2c7660.md
@@ -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"`
+}
+```
\ No newline at end of file
diff --git a/.codebuddy/plans/classification_server_python_run_77f6663b.md b/.codebuddy/plans/classification_server_python_run_77f6663b.md
new file mode 100644
index 0000000..a2bff4e
--- /dev/null
+++ b/.codebuddy/plans/classification_server_python_run_77f6663b.md
@@ -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 安装说明注释)
+```
\ No newline at end of file
diff --git a/.codebuddy/plans/classify-service-align-clothes-model_85d8b823.md b/.codebuddy/plans/classify-service-align-clothes-model_85d8b823.md
new file mode 100644
index 0000000..f598c34
--- /dev/null
+++ b/.codebuddy/plans/classify-service-align-clothes-model_85d8b823.md
@@ -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 后才合成白底
+```
\ No newline at end of file
diff --git a/.codebuddy/plans/classify-service-align-clothes-model_e429d660(未完成).md b/.codebuddy/plans/classify-service-align-clothes-model_e429d660(未完成).md
new file mode 100644
index 0000000..c955f17
--- /dev/null
+++ b/.codebuddy/plans/classify-service-align-clothes-model_e429d660(未完成).md
@@ -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 通道后才合成白底
+```
\ No newline at end of file
diff --git a/.codebuddy/plans/dao-tracing-refactor_d4fb1e5c.md b/.codebuddy/plans/dao-tracing-refactor_d4fb1e5c.md
new file mode 100644
index 0000000..6166460
--- /dev/null
+++ b/.codebuddy/plans/dao-tracing-refactor_d4fb1e5c.md
@@ -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
+```
\ No newline at end of file
diff --git a/.codebuddy/plans/fix-14-issues-except-8-and-15_dc316bf3.md b/.codebuddy/plans/fix-14-issues-except-8-and-15_dc316bf3.md
new file mode 100644
index 0000000..3dabf5e
--- /dev/null
+++ b/.codebuddy/plans/fix-14-issues-except-8-and-15_dc316bf3.md
@@ -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:`,TTL 5 分钟),避免 pkg→internal 反向依赖。
+
+### 验证方式
+
+- `go build ./...` 与 `go vet ./...` 通过;重点回归启动加载、支付回调、点赞/收藏可见性、收藏分页。
\ No newline at end of file
diff --git a/.codebuddy/plans/fix-code-review-bugs_a7c936c2.md b/.codebuddy/plans/fix-code-review-bugs_a7c936c2.md
new file mode 100644
index 0000000..ceeabd1
--- /dev/null
+++ b/.codebuddy/plans/fix-code-review-bugs_a7c936c2.md
@@ -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
+```
\ No newline at end of file
diff --git a/.codebuddy/plans/fix-remaining-issues_32b47301.md b/.codebuddy/plans/fix-remaining-issues_32b47301.md
new file mode 100644
index 0000000..fe1a6af
--- /dev/null
+++ b/.codebuddy/plans/fix-remaining-issues_32b47301.md
@@ -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` 需在部署时手动执行。
\ No newline at end of file
diff --git a/.codebuddy/plans/fix-second-review-bugs_4bdf0ac1.md b/.codebuddy/plans/fix-second-review-bugs_4bdf0ac1.md
new file mode 100644
index 0000000..a82b79c
--- /dev/null
+++ b/.codebuddy/plans/fix-second-review-bugs_4bdf0ac1.md
@@ -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);
+```
\ No newline at end of file
diff --git a/.codebuddy/plans/matting-client-and-test_a3ad5696.md b/.codebuddy/plans/matting-client-and-test_a3ad5696.md
new file mode 100644
index 0000000..5de2447
--- /dev/null
+++ b/.codebuddy/plans/matting-client-and-test_a3ad5696.md
@@ -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` 包风格一致。
\ No newline at end of file
diff --git a/.codebuddy/plans/matting_server_python_run_5105572d.md b/.codebuddy/plans/matting_server_python_run_5105572d.md
new file mode 100644
index 0000000..3c55195
--- /dev/null
+++ b/.codebuddy/plans/matting_server_python_run_5105572d.md
@@ -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 与模型下载说明注释)
+```
\ No newline at end of file
diff --git a/.codebuddy/plans/model-call-retry_5868b00a.md b/.codebuddy/plans/model-call-retry_5868b00a.md
new file mode 100644
index 0000000..d0c0f14
--- /dev/null
+++ b/.codebuddy/plans/model-call-retry_5868b00a.md
@@ -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 泛型辅助函数,抠图/分类调用接入重试
+```
\ No newline at end of file
diff --git a/.codebuddy/plans/models_auto_download_fallback_2b158447.md b/.codebuddy/plans/models_auto_download_fallback_2b158447.md
new file mode 100644
index 0000000..ac55ee6
--- /dev/null
+++ b/.codebuddy/plans/models_auto_download_fallback_2b158447.md
@@ -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 镜像),有缓存后完全离线;日志区分「命中本地缓存」与「自动下载」
\ No newline at end of file
diff --git a/.codebuddy/plans/mysqlmodel_类型统一与_tag_精简_19fbaf95.md b/.codebuddy/plans/mysqlmodel_类型统一与_tag_精简_19fbaf95.md
new file mode 100644
index 0000000..c952d74
--- /dev/null
+++ b/.codebuddy/plans/mysqlmodel_类型统一与_tag_精简_19fbaf95.md
@@ -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 格式合法
\ No newline at end of file
diff --git a/.codebuddy/plans/redismodel_key_设计对齐_mysqlmodel_da53c011(未完成).md b/.codebuddy/plans/redismodel_key_设计对齐_mysqlmodel_da53c011(未完成).md
new file mode 100644
index 0000000..499b7f4
--- /dev/null
+++ b/.codebuddy/plans/redismodel_key_设计对齐_mysqlmodel_da53c011(未完成).md
@@ -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 字符串变更属于破坏性变更,依赖可清库/未上线前提(用户已确认)。
\ No newline at end of file
diff --git a/.codebuddy/plans/redismodel_从0全量设计_31a88700.md b/.codebuddy/plans/redismodel_从0全量设计_31a88700.md
new file mode 100644
index 0000000..db9231c
--- /dev/null
+++ b/.codebuddy/plans/redismodel_从0全量设计_31a88700.md
@@ -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 实现提供契约。
\ No newline at end of file
diff --git a/.codebuddy/plans/service-span-and-async-trace_d93c7ebf.md b/.codebuddy/plans/service-span-and-async-trace_d93c7ebf.md
new file mode 100644
index 0000000..13cc41c
--- /dev/null
+++ b/.codebuddy/plans/service-span-and-async-trace_d93c7ebf.md
@@ -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 已导入)
+```
\ No newline at end of file
diff --git a/.codebuddy/plans/service-span-naming-dedupe_b2900987.md b/.codebuddy/plans/service-span-naming-dedupe_b2900987.md
new file mode 100644
index 0000000..1c598a8
--- /dev/null
+++ b/.codebuddy/plans/service-span-naming-dedupe_b2900987.md
@@ -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。
\ No newline at end of file
diff --git a/.codebuddy/plans/service层完整实现_3a88b729.md b/.codebuddy/plans/service层完整实现_3a88b729.md
new file mode 100644
index 0000000..4765a8a
--- /dev/null
+++ b/.codebuddy/plans/service层完整实现_3a88b729.md
@@ -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:跨文件签名/字段映射一次性确认到位,减少编译期返工。
\ No newline at end of file
diff --git a/.codebuddy/plans/switch-matting-to-birefnet-lite_dfe3ba51.md b/.codebuddy/plans/switch-matting-to-birefnet-lite_dfe3ba51.md
new file mode 100644
index 0000000..cac06a1
--- /dev/null
+++ b/.codebuddy/plans/switch-matting-to-birefnet-lite_dfe3ba51.md
@@ -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/`(当前网络可访问)。
+
+## 实现方案
+
+### 关键决策
+
+- **仅下载 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(, 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 报错。
\ No newline at end of file
diff --git a/.codebuddy/plans/tokenhub_与_model_对齐优化_b2391cd4.md b/.codebuddy/plans/tokenhub_与_model_对齐优化_b2391cd4.md
new file mode 100644
index 0000000..28d9932
--- /dev/null
+++ b/.codebuddy/plans/tokenhub_与_model_对齐优化_b2391cd4.md
@@ -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
+```
\ No newline at end of file
diff --git a/.codebuddy/plans/tokenhub_穿搭推荐_prompt_与输出字段对齐_020be2fa.md b/.codebuddy/plans/tokenhub_穿搭推荐_prompt_与输出字段对齐_020be2fa.md
new file mode 100644
index 0000000..a42d406
--- /dev/null
+++ b/.codebuddy/plans/tokenhub_穿搭推荐_prompt_与输出字段对齐_020be2fa.md
@@ -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"` // 选择理由
+}
+```
\ No newline at end of file
diff --git a/.codebuddy/plans/修复XPay发货推送伪造漏洞_9bf015a6.md b/.codebuddy/plans/修复XPay发货推送伪造漏洞_9bf015a6.md
new file mode 100644
index 0000000..89d333b
--- /dev/null
+++ b/.codebuddy/plans/修复XPay发货推送伪造漏洞_9bf015a6.md
@@ -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` 返回状态为准,实现时集中定义常量并注释「联调校正」。
\ No newline at end of file
diff --git a/.codebuddy/plans/修复XPay发货推送伪造漏洞_a31999c1(未完成).md b/.codebuddy/plans/修复XPay发货推送伪造漏洞_a31999c1(未完成).md
new file mode 100644
index 0000000..ded832f
--- /dev/null
+++ b/.codebuddy/plans/修复XPay发货推送伪造漏洞_a31999c1(未完成).md
@@ -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 标注「已确认由管理后台同步,仅文档标注」
+```
\ No newline at end of file
diff --git a/.codebuddy/plans/修复_redismodel_redisdao_语义与健壮性问题_P1_P2__832261d9.md b/.codebuddy/plans/修复_redismodel_redisdao_语义与健壮性问题_P1_P2__832261d9.md
new file mode 100644
index 0000000..6948b53
--- /dev/null
+++ b/.codebuddy/plans/修复_redismodel_redisdao_语义与健壮性问题_P1_P2__832261d9.md
@@ -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 < d then return -2 end
+return redis.call('DECRBY', KEYS[1], d)
+```
+
+返回值契约:
+
+- `-1`:配额未初始化(key 不存在)
+- `-2`:剩余配额不足
+- `>= 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) > 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` 返回值上抛,由上层统一处理。
\ No newline at end of file
diff --git a/.codebuddy/plans/修复中等与轻微问题6项_599caa55.md b/.codebuddy/plans/修复中等与轻微问题6项_599caa55.md
new file mode 100644
index 0000000..a8598ce
--- /dev/null
+++ b/.codebuddy/plans/修复中等与轻微问题6项_599caa55.md
@@ -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` 检查修改文件无新增告警
\ No newline at end of file
diff --git a/.codebuddy/plans/修复中等与轻微问题8项_e909def3(未完成).md b/.codebuddy/plans/修复中等与轻微问题8项_e909def3(未完成).md
new file mode 100644
index 0000000..fb87169
--- /dev/null
+++ b/.codebuddy/plans/修复中等与轻微问题8项_e909def3(未完成).md
@@ -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 提交
+```
\ No newline at end of file
diff --git a/.codebuddy/plans/修复中高等级代码问题_7e510a15.md b/.codebuddy/plans/修复中高等级代码问题_7e510a15.md
new file mode 100644
index 0000000..567b79e
--- /dev/null
+++ b/.codebuddy/plans/修复中高等级代码问题_7e510a15.md
@@ -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` 字段等实现细节。
+- 预期结果:产出修复所需的精确文件与函数清单,支撑分组实施。
\ No newline at end of file
diff --git a/.codebuddy/plans/修复全部_39_项代码缺陷_f3b9019e.md b/.codebuddy/plans/修复全部_39_项代码缺陷_f3b9019e.md
new file mode 100644
index 0000000..6575e45
--- /dev/null
+++ b/.codebuddy/plans/修复全部_39_项代码缺陷_f3b9019e.md
@@ -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`)做定义/引用/调用层次追踪,验证幂等与回退链路没有遗漏调用点或破坏现有引用。
+- 预期产出:关键函数完整调用链,确认修复后无断链、无重复发放/回退缺口。
\ No newline at end of file
diff --git a/.codebuddy/plans/修复分类模型手表识别错误_0588c8ee.md b/.codebuddy/plans/修复分类模型手表识别错误_0588c8ee.md
new file mode 100644
index 0000000..13dac02
--- /dev/null
+++ b/.codebuddy/plans/修复分类模型手表识别错误_0588c8ee.md
@@ -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] 归档的手表测试图
+```
\ No newline at end of file
diff --git a/.codebuddy/plans/修复支付业务逻辑问题_cb2e4aae.md b/.codebuddy/plans/修复支付业务逻辑问题_cb2e4aae.md
new file mode 100644
index 0000000..65c5335
--- /dev/null
+++ b/.codebuddy/plans/修复支付业务逻辑问题_cb2e4aae.md
@@ -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)。
\ No newline at end of file
diff --git a/.codebuddy/plans/允许免费额度配置为_0_dc363311.md b/.codebuddy/plans/允许免费额度配置为_0_dc363311.md
new file mode 100644
index 0000000..251b373
--- /dev/null
+++ b/.codebuddy/plans/允许免费额度配置为_0_dc363311.md
@@ -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」
+```
\ No newline at end of file
diff --git a/.codebuddy/plans/全量代码二次审查报告_ceea1a15.md b/.codebuddy/plans/全量代码二次审查报告_ceea1a15.md
new file mode 100644
index 0000000..d59d5da
--- /dev/null
+++ b/.codebuddy/plans/全量代码二次审查报告_ceea1a15.md
@@ -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**
+- 用途:对订单状态流转、额度变更、回调处理等关键符号做语义级定义/引用导航,确认所有状态写入点与并发路径
+- 预期产出:关键函数调用层次与状态变更写入点全集,避免漏查竞态路径
\ No newline at end of file
diff --git a/.codebuddy/plans/全量代码审查_定位业务与技术逻辑_bug_6b069bbb.md b/.codebuddy/plans/全量代码审查_定位业务与技术逻辑_bug_6b069bbb.md
new file mode 100644
index 0000000..fed652c
--- /dev/null
+++ b/.codebuddy/plans/全量代码审查_定位业务与技术逻辑_bug_6b069bbb.md
@@ -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`)做语义级定义/引用/调用层次追踪,验证并发、事务与幂等逻辑是否安全。
+- 预期产出:关键函数完整调用链与引用点,支撑并发/幂等结论的准确判断。
\ No newline at end of file
diff --git a/.codebuddy/plans/删除衣物时清理COS图片_39034235.md b/.codebuddy/plans/删除衣物时清理COS图片_39034235.md
new file mode 100644
index 0000000..bde396f
--- /dev/null
+++ b/.codebuddy/plans/删除衣物时清理COS图片_39034235.md
@@ -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
+```
\ No newline at end of file
diff --git a/.codebuddy/plans/同步更新三个项目文档_5b365d72.md b/.codebuddy/plans/同步更新三个项目文档_5b365d72.md
new file mode 100644
index 0000000..f7c2f70
--- /dev/null
+++ b/.codebuddy/plans/同步更新三个项目文档_5b365d72.md
@@ -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 风格。
\ No newline at end of file
diff --git a/.codebuddy/plans/基于_golang-migrate_的数据表迁移_50fc3756.md b/.codebuddy/plans/基于_golang-migrate_的数据表迁移_50fc3756.md
new file mode 100644
index 0000000..87a8f95
--- /dev/null
+++ b/.codebuddy/plans/基于_golang-migrate_的数据表迁移_50fc3756.md
@@ -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 `,仅读取 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
解析子命令"] --> B["pkg/setting
读取 Mysql 段"]
+ B --> C["pkg/migration.Run
拼 DSN(multiStatements=true)
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 ;
+ # 仅 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 模型零偏差
\ No newline at end of file
diff --git a/.codebuddy/plans/完善UserDefaultQuota配置化_ca78d85f.md b/.codebuddy/plans/完善UserDefaultQuota配置化_ca78d85f.md
new file mode 100644
index 0000000..f7d457f
--- /dev/null
+++ b/.codebuddy/plans/完善UserDefaultQuota配置化_ca78d85f.md
@@ -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 且回落常量。
\ No newline at end of file
diff --git a/.codebuddy/plans/完善项目_README_文档_413d78c9.md b/.codebuddy/plans/完善项目_README_文档_413d78c9.md
new file mode 100644
index 0000000..d0c1734
--- /dev/null
+++ b/.codebuddy/plans/完善项目_README_文档_413d78c9.md
@@ -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 各章节的事实清单,确保文档内容与代码一一对应、无虚构。
\ No newline at end of file
diff --git a/.codebuddy/plans/局部变量resp-req重命名为全称_419b3866.md b/.codebuddy/plans/局部变量resp-req重命名为全称_419b3866.md
new file mode 100644
index 0000000..fba618f
--- /dev/null
+++ b/.codebuddy/plans/局部变量resp-req重命名为全称_419b3866.md
@@ -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`,所有引用一致更新。
\ No newline at end of file
diff --git a/.codebuddy/plans/微信支付切换到官方wechatpay-go_910cf320.md b/.codebuddy/plans/微信支付切换到官方wechatpay-go_910cf320.md
new file mode 100644
index 0000000..ca63bca
--- /dev/null
+++ b/.codebuddy/plans/微信支付切换到官方wechatpay-go_910cf320.md
@@ -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:确认无遗漏引用,调用链签名对齐,避免编译期返工。
\ No newline at end of file
diff --git a/.codebuddy/plans/扁平化迁移脚本目录结构_11e4276d.md b/.codebuddy/plans/扁平化迁移脚本目录结构_11e4276d.md
new file mode 100644
index 0000000..5086306
--- /dev/null
+++ b/.codebuddy/plans/扁平化迁移脚本目录结构_11e4276d.md
@@ -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
up/down/steps/version/force"] --> B["pkg/setting
读取 Mysql 段"]
+ B --> C["pkg/migration.Run
DSN multiStatements=true"]
+ C --> D["iofs.New(embed FS, .)"]
+ D --> E["pkg/migration/*.sql
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:输出路径引用与文件清单核查结果,确保无残留旧路径
\ No newline at end of file
diff --git a/.codebuddy/plans/拆分_Stitch_提示词为分段可粘贴文档_fdfe8c49.md b/.codebuddy/plans/拆分_Stitch_提示词为分段可粘贴文档_fdfe8c49.md
new file mode 100644
index 0000000..645fb95
--- /dev/null
+++ b/.codebuddy/plans/拆分_Stitch_提示词为分段可粘贴文档_fdfe8c49.md
@@ -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:输出字段与枚举事实清单,作为分段迁移与最终校对的依据,杜绝虚构字段
\ No newline at end of file
diff --git a/.codebuddy/plans/按层区分_DAO_span_命名_72d9748c.md b/.codebuddy/plans/按层区分_DAO_span_命名_72d9748c.md
new file mode 100644
index 0000000..f3f4de6
--- /dev/null
+++ b/.codebuddy/plans/按层区分_DAO_span_命名_72d9748c.md
@@ -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
Gin 中间件 server span"] --> B["internal/service
span: service.Xxx"]
+ B --> C["internal/mysqldao
span: mysqldao.Xxx(本次由 dao. 改名)"]
+ B --> D["internal/redisdao
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 处全部正确改名、零误伤
\ No newline at end of file
diff --git a/.codebuddy/plans/接入微信虚拟支付_全终端__8cbaa9ab.md b/.codebuddy/plans/接入微信虚拟支付_全终端__8cbaa9ab.md
new file mode 100644
index 0000000..7053d2b
--- /dev/null
+++ b/.codebuddy/plans/接入微信虚拟支付_全终端__8cbaa9ab.md
@@ -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
本地落单 pay_channel=2]
+ C --> D[返回 product_id / order_no]
+ A -->|wx.requestVirtualPayment| E[微信虚拟支付
iOS走Apple 其余走微信]
+ E -->|xpay_goods_deliver_notify| F[POST /xpay/notify]
+ F --> G[验签 幂等锁 金额校验]
+ G --> H[复用 finalizePaidOrder
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 分支与错误码引用。
+- 预期结果:输出逐表差异清单,确认新增字段与索引与模型定义完全一致、无旧路径引用遗漏。
\ No newline at end of file
diff --git a/.codebuddy/plans/收藏单条状态查询接口_352ab083.md b/.codebuddy/plans/收藏单条状态查询接口_352ab083.md
new file mode 100644
index 0000000..adfb3d1
--- /dev/null
+++ b/.codebuddy/plans/收藏单条状态查询接口_352ab083.md
@@ -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 路由树中可共存。
\ No newline at end of file
diff --git a/.codebuddy/plans/数据库唯一索引全面补齐_0a7c6d59.md b/.codebuddy/plans/数据库唯一索引全面补齐_0a7c6d59.md
new file mode 100644
index 0000000..c42a402
--- /dev/null
+++ b/.codebuddy/plans/数据库唯一索引全面补齐_0a7c6d59.md
@@ -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` 一致。
\ No newline at end of file
diff --git a/.codebuddy/plans/新用户注册赠送体验会员_9ba5542c.md b/.codebuddy/plans/新用户注册赠送体验会员_9ba5542c.md
new file mode 100644
index 0000000..0ab940f
--- /dev/null
+++ b/.codebuddy/plans/新用户注册赠送体验会员_9ba5542c.md
@@ -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_` 作为幂等键
+- **欢迎通知**:赠送成功后写入一条系统通知(「欢迎礼包已到账」),并递增未读徽标计数
+- **不影响登录**:注册赠送为 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_`**:注册场景没有订单号,而唯一索引是 `(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
首次自动注册"] --> B["ensureDefaultQuota
4 类免费额度"]
+ B --> C["ensureTrialVIP
读 UserDefaultVipDays 配置"]
+ C -->|"days <= 0"| Z["不赠送,直接返回"]
+ C -->|"days > 0"| D["mysqldao.VIPGrantIdempotent
vipType=4, order_no=register_gift_uid"]
+ D --> E[("users.vip_type=4
vip_expire += days*86400")]
+ D --> F[("vip_flow_logs
uk_vip_flow_idem 幂等")]
+ D -->|成功| G["回读用户回填 VIPType/VIPExpire
→ UserInfoSet 缓存正确"]
+ G --> H["sendNotification
系统通知 + 未读 +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_ 依赖 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:输出差异清单(遗漏位置 / 错误逻辑 / 缓存一致性风险),据其修正后保证注册赠送幂等、可配置、可关闭且与既有会员链路零冲突
\ No newline at end of file
diff --git a/.codebuddy/plans/生成_Stitch_设计描述文档_fac81b47.md b/.codebuddy/plans/生成_Stitch_设计描述文档_fac81b47.md
new file mode 100644
index 0000000..94e5649
--- /dev/null
+++ b/.codebuddy/plans/生成_Stitch_设计描述文档_fac81b47.md
@@ -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 的唯一数据依据,杜绝臆造字段与功能
\ No newline at end of file
diff --git a/.codebuddy/plans/生成前端接口文档_05e564d5.md b/.codebuddy/plans/生成前端接口文档_05e564d5.md
new file mode 100644
index 0000000..b9aabd9
--- /dev/null
+++ b/.codebuddy/plans/生成前端接口文档_05e564d5.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 与校验规则、路径参数名、业务状态机与规则、全量错误码
+- 预期产出:一份可直接用于撰写的事实清单(路由 × 参数 × 响应 × 枚举 × 错误码 × 业务规则),保证文档零臆造
\ No newline at end of file
diff --git a/.codebuddy/plans/生成数据库_ER_图_drawio_d0c6a093.md b/.codebuddy/plans/生成数据库_ER_图_drawio_d0c6a093.md
new file mode 100644
index 0000000..1d59ea2
--- /dev/null
+++ b/.codebuddy/plans/生成数据库_ER_图_drawio_d0c6a093.md
@@ -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运营域紫)。
+```
\ No newline at end of file
diff --git a/.codebuddy/plans/用户头像上传接口_eb17021f.md b/.codebuddy/plans/用户头像上传接口_eb17021f.md
new file mode 100644
index 0000000..51c0cfc
--- /dev/null
+++ b/.codebuddy/plans/用户头像上传接口_eb17021f.md
@@ -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 转换。
\ No newline at end of file
diff --git a/.codebuddy/plans/虚拟支付沙箱环境自动选择_bb981350.md b/.codebuddy/plans/虚拟支付沙箱环境自动选择_bb981350.md
new file mode 100644
index 0000000..e8c7d8d
--- /dev/null
+++ b/.codebuddy/plans/虚拟支付沙箱环境自动选择_bb981350.md
@@ -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
EnvAppKey / SandboxAppKey] --> B[InitVirtualPayClient
resolveEnvKey]
+ B --> C[client.envAppKey = 激活key
client.env = 0/1]
+ C --> D[道具同步 API
body 带 env + 沙箱key签名]
+ C --> E[BuildPaymentSign
signData.env = client.env
paySig 用激活key]
+ C --> F[QueryOrder / RefundOrder
内部用 client.env]
+ E --> G[VirtualOrderCreate
订单 xpay_env = GetVirtualPayEnv]
+ G --> H[发货推送校验
推送 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 无新增错误
\ No newline at end of file
diff --git a/.codebuddy/plans/衣柜分享-分享给我的_70d0547d.md b/.codebuddy/plans/衣柜分享-分享给我的_70d0547d.md
new file mode 100644
index 0000000..7f0a403
--- /dev/null
+++ b/.codebuddy/plans/衣柜分享-分享给我的_70d0547d.md
@@ -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
唯一键幂等]
+ 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)
+```
\ No newline at end of file
diff --git a/.codebuddy/plans/补充_README_接口调用逻辑文档_747f2d39.md b/.codebuddy/plans/补充_README_接口调用逻辑文档_747f2d39.md
new file mode 100644
index 0000000..fb83c9d
--- /dev/null
+++ b/.codebuddy/plans/补充_README_接口调用逻辑文档_747f2d39.md
@@ -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 的唯一事实依据,确保无虚构字段
\ No newline at end of file
diff --git a/.codebuddy/plans/补齐_mysqldao_数据访问层_a1ecd74f.md b/.codebuddy/plans/补齐_mysqldao_数据访问层_a1ecd74f.md
new file mode 100644
index 0000000..b6a564d
--- /dev/null
+++ b/.codebuddy/plans/补齐_mysqldao_数据访问层_a1ecd74f.md
@@ -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` 判断;本次不引入新依赖。
\ No newline at end of file
diff --git a/.codebuddy/plans/调整_COS_图片存储路径结构_5eea0fe3.md b/.codebuddy/plans/调整_COS_图片存储路径结构_5eea0fe3.md
new file mode 100644
index 0000000..64094db
--- /dev/null
+++ b/.codebuddy/plans/调整_COS_图片存储路径结构_5eea0fe3.md
@@ -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 ./...` 确保编译通过。
\ No newline at end of file
diff --git a/.codebuddy/plans/通知列表补充_msg_type_筛选_9998d1d5.md b/.codebuddy/plans/通知列表补充_msg_type_筛选_9998d1d5.md
new file mode 100644
index 0000000..1dea9d6
--- /dev/null
+++ b/.codebuddy/plans/通知列表补充_msg_type_筛选_9998d1d5.md
@@ -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` 参数比较)。
\ No newline at end of file
diff --git a/.codebuddy/plans/配置全面去环境变量化_3cc28783.md b/.codebuddy/plans/配置全面去环境变量化_3cc28783.md
new file mode 100644
index 0000000..4e4c89f
--- /dev/null
+++ b/.codebuddy/plans/配置全面去环境变量化_3cc28783.md
@@ -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` 会随代码入库,必须保持占位符示例值。
\ No newline at end of file
diff --git a/.codebuddy/plans/配额流水事务一致性修复_9574ec66.md b/.codebuddy/plans/配额流水事务一致性修复_9574ec66.md
new file mode 100644
index 0000000..db57bb6
--- /dev/null
+++ b/.codebuddy/plans/配额流水事务一致性修复_9574ec66.md
@@ -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)` 联合唯一索引(幂等兜底前提)。
\ No newline at end of file
diff --git a/.codebuddy/plans/额度体系改造_每日额度与VIP有效期_9b667d2b(未完成).md b/.codebuddy/plans/额度体系改造_每日额度与VIP有效期_9b667d2b(未完成).md
new file mode 100644
index 0000000..f0a9e67
--- /dev/null
+++ b/.codebuddy/plans/额度体系改造_每日额度与VIP有效期_9b667d2b(未完成).md
@@ -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 原子限流
当日计数 +1 且 <= 总额度]
+ L --> M[user_daily_usages 统计]
+
+ P --> TX[MySQL 事务
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)
+```
\ No newline at end of file
diff --git a/.codebuddy/plans/额度体系重构_每日额度_凭证有效期_广告激励_defdda63.md b/.codebuddy/plans/额度体系重构_每日额度_凭证有效期_广告激励_defdda63.md
new file mode 100644
index 0000000..103d1db
--- /dev/null
+++ b/.codebuddy/plans/额度体系重构_每日额度_凭证有效期_广告激励_defdda63.md
@@ -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,幂等键
+}
+```
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..7c0c44a
--- /dev/null
+++ b/.gitignore
@@ -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/
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..f2cdbf3
--- /dev/null
+++ b/README.md
@@ -0,0 +1,1567 @@
+# zt-closet-backend
+
+智能衣橱微信小程序后端服务。基于 Go + Gin 构建,提供衣物管理、AI 衣物识别(分类/抠图)、穿搭与 AI 推荐、每日星座运势、社区互动(分享/收藏/点赞/通知)、额度体系、商品与微信支付订单等完整能力,并内置 OpenTelemetry 全链路可观测性与结构化日志。
+
+## 核心功能
+
+- **用户体系**:微信小程序登录(code2session,首次自动注册并初始化免费额度)、JWT 双 token(access/refresh)、个人资料维护。
+- **衣物管理**:图片上传(扩展名 + magic bytes 双重校验)→ 异步 AI 流水线(抠图 → 15 维分类)→ 衣物 CRUD 与处理状态查询。
+- **穿搭与 AI 推荐**:自建穿搭(含社区公开与审核)、基于星座运势 + 衣橱衣物的 AI 穿搭推荐(大模型生成,落库为"待用户保存")。
+- **运势**:每日 0 点定时生成十二星座运势,Redis 分布式锁保证多实例下仅执行一次,查询时支持按需兜底生成。
+- **社区互动**:衣柜分享(部分衣物 / 全量快照)、穿搭点赞与收藏(Redis 集合 + MySQL 双写)、站内通知(未读计数)。
+- **额度体系**:四类额度(上传 / AI 推荐 / 穿搭分享 / 衣柜分享)的扣减、回退、发放、回收;上传为**永久额度**,AI 推荐 / 穿搭分享 / 衣柜分享为**每日额度**(每天 0 点重置);购买赠送额度以**凭证**(`quota_grants`)发放——会员包随 VIP 有效期、加量包独立有效期(`goods.valid_days`)、广告激励当天有效;Cos 消费先扣即将过期凭证再扣免费额度,全部走事务 + 幂等流水。
+- **商品与订单**:商品(会员包 / 上传包 / AI 推荐包 / 分享包)、微信支付 V3 下单、支付与退款回调(验签 + 解密)、订单 15 分钟超时自动过期、权益发放失败自动补偿。
+- **可观测性**:OpenTelemetry Trace / Metric / Log 三信号 OTLP gRPC 上报;结构化 JSON 日志注入 `trace_id` / `span_id`。
+
+## 技术栈
+
+| 分类 | 技术 |
+| --- | --- |
+| 语言 | Go 1.27 |
+| Web 框架 | Gin v1.12 |
+| ORM / 数据库 | GORM + MySQL(含 soft_delete 软删除插件) |
+| 缓存 / 锁 | go-redis v9(会话存储、缓存、分布式锁、计数) |
+| 配置 | spf13/viper(仅读 YAML 文件) |
+| 鉴权 | golang-jwt/v5(HS256 双 token) |
+| 对象存储 | 腾讯云 COS SDK v5(含 CDN 签名 URL) |
+| 微信生态 | 小程序 code2session 登录、微信支付 APIv3(wechatpay-go 官方 SDK)、小程序虚拟支付(XPay) |
+| 可观测性 | OpenTelemetry v1.46(otelgin、otelslog、OTLP gRPC 导出) |
+| 日志 | log/slog + lumberjack(JSON、按大小轮转压缩) |
+| AI 推理服务 | Python FastAPI:Fashion-CLIP 衣物分类、BiRefNet 抠图 |
+
+## 目录结构
+
+```
+zt-closet-backend/
+├── main.go # 入口:初始化配置/日志/DB/Redis/COS/支付,启动后台任务与 HTTP 服务
+├── cmd/
+│ └── migrate/ # 独立数据库迁移命令入口(up/down/steps/version/force)
+├── configs/
+│ ├── config.example.yaml # 配置模板(含注释说明)
+│ └── config.yaml # 实际配置(已被 .gitignore 忽略,需自行复制创建)
+├── global/ # 全局单例:配置指针、DB/Redis/COS 引擎、OTel Tracer/Meter
+├── internal/
+│ ├── dto/ # 请求/响应 DTO 与分页结构
+│ ├── middleware/ # RequestID / OTel / 请求日志(脱敏) / CORS / JWT 鉴权
+│ ├── mysqldao/ # MySQL 数据访问层(tracing span 命名 mysqldao.Xxx)
+│ ├── mysqlmodel/ # GORM 数据表模型(18 张表)
+│ ├── redisdao/ # Redis 数据访问层(tracing span 命名 redisdao.Xxx)
+│ ├── redismodel/ # Redis 引擎初始化与 key 常量
+│ ├── routers/ # 路由注册(router.go)与各 handler(api/v1)
+│ └── service/ # 业务逻辑层(16 个 service)
+├── pkg/
+│ ├── app/ # HTTP 统一响应封装 {code, msg, data}
+│ ├── classification/ # 衣物分类服务客户端
+│ ├── cos/ # 腾讯云 COS 客户端
+│ ├── errcode/ # 统一业务错误码(按业务域分段)
+│ ├── lzcwyjwt/ # JWT 生成/解析
+│ ├── matting/ # 抠图服务客户端
+│ ├── migration/ # 数据库迁移:embed 迁移脚本 + Up/Down/Steps/Version/Force
+│ │ # (目录下直接存放 000001~000016 版本化 SQL,up 建表/down 删表)
+│ ├── setting/ # viper 配置读取与配置结构体
+│ ├── tokenhub/ # 腾讯 Tokenhub 大模型网关客户端(运势/AI 推荐生成 + 调度)
+│ ├── tracing/ # OpenTelemetry 初始化与日志桥接
+│ ├── upload/ # 文件上传校验与存储
+│ └── wechat/ # 微信登录与微信支付
+├── thirdparty/ # Python AI 推理服务
+│ ├── classification_server/ # Fashion-CLIP 衣物分类(端口 8000)
+│ └── matting_server/ # BiRefNet 抠图(端口 8001)
+├── design/ # 数据库 ER 图(drawio)
+├── sql/ # 占位目录(建表 SQL 已统一收归 pkg/migration)
+├── scripts/ # 占位目录(暂无部署脚本)
+└── storage/ # 本地存储(日志、上传临时文件,已被 gitignore)
+```
+
+> 说明:`sql/`、`scripts/`、`docs/` 目前为空占位目录;建表与索引由 `pkg/migration` 中的版本化迁移脚本维护,表模型见 `internal/mysqlmodel/`,ER 图见 `design/zt-closet-er.drawio`。
+
+## 快速开始
+
+### 1. 环境依赖
+
+- Go 1.27+
+- MySQL 5.7+ / 8.0
+- Redis 5+
+- Python 3.9+(运行 AI 推理服务时需要;若仅做基础调试可先跳过)
+
+### 2. 配置准备
+
+```bash
+cp configs/config.example.yaml configs/config.yaml
+```
+
+编辑 `configs/config.yaml`,至少修改以下内容:
+
+- `Mysql`:数据库连接信息
+- `Redis`:Redis 连接信息
+- `JWT.SecretKey`:**必填**,须为强随机字符串,留空会导致启动失败
+- `Server.HttpPort`:服务监听端口(模板默认为 80)
+- `Cos` / `WechatMini` / `WechatPay` / `WechatVirtualPay`:按需填写;未配置的第三方能力(COS、微信支付、虚拟支付)启动时仅告警、不阻断服务
+
+> **重要**:`config.example.yaml` 中不包含 `Tokenhub` 段,但服务启动时会强制读取该段(缺失将导致启动 panic)。请手动在 `config.yaml` 中补充:
+
+```yaml
+Tokenhub:
+ APIKey: your_tokenhub_api_key # 腾讯 Tokenhub 大模型网关 APIKey,用于生成运势与 AI 穿搭推荐
+ Model: hy4 # 模型代号,留空时默认 hy4
+```
+
+`APIKey` 留空不会阻断启动,但运势与 AI 推荐功能将不可用(后台任务仅记录错误日志)。
+
+### 3. 准备数据库
+
+先创建 `config.yaml` 中 `Mysql.DBName` 对应的**空数据库**(迁移脚本只建表,不建库):
+
+```sql
+CREATE DATABASE IF NOT EXISTS `zt-closet` DEFAULT CHARSET utf8mb4;
+```
+
+再用独立命令执行迁移(服务启动时**不会**自动迁移):
+
+```bash
+go run ./cmd/migrate up # 建表到最新版本(幂等,可重复执行)
+go run ./cmd/migrate version # 查看当前版本与是否为脏库
+```
+
+其他命令:
+
+```bash
+go run ./cmd/migrate steps 1 # 向上 1 步(负数向下)
+go run ./cmd/migrate down # 全量回滚(按依赖倒序删表,慎用)
+go run ./cmd/migrate force 3 # 迁移中断导致 dirty 时,强制修正版本号
+```
+
+迁移脚本位于 `pkg/migration`,共 17 个版本、22 张表,以 `go:embed` 打包进二进制;迁移记录保存在 `schema_migrations` 表。字段与索引以 `internal/mysqlmodel/` 的 GORM 模型为准,ER 图见 `design/zt-closet-er.drawio`。
+
+### 4. 启动 AI 推理服务(可选,推荐)
+
+衣物上传后的抠图与分类依赖两个 Python 服务,地址与端口在配置的 `MattingModel` / `ClassificationModel` 段中约定(默认分别为 `8001` / `8000`)。
+
+**抠图服务(BiRefNet):**
+
+```bash
+cd thirdparty/matting_server
+pip install -r requirements.txt
+python main.py # 监听 0.0.0.0:8001
+```
+
+- 模型使用 `ZhengPeng7/BiRefNet_lite`(small 变体),本地缓存优先、缺失时自动下载;可用环境变量 `BIREFNET_REPO_ID` 覆盖模型来源。
+- 默认从 `https://hf-mirror.com`(国内镜像)下载权重。
+
+**分类服务(Fashion-CLIP):**
+
+```bash
+cd thirdparty/classification_server
+pip install -r requirements.txt
+python main.py # 监听 0.0.0.0:8000
+```
+
+- 模型使用 `patrickjohncyh/fashion-clip`,一次前向识别衣物 15 个维度(大类/子类别/风格/主副颜色/厚度/版型/材质/花纹/袖型/衣长/领型/正式程度/场合/视觉冲击力)。
+- 首次运行需下载模型权重,同样走 hf-mirror 镜像。
+
+> 建议在带 GPU 的环境中部署两个推理服务;CPU 环境亦可运行(抠图服务自动退回 fp32)。
+
+### 5. 编译运行
+
+```bash
+# 必须在项目根目录执行(配置路径固定为相对路径 configs/config.yaml)
+go run .
+```
+
+服务启动后验证健康检查:
+
+```bash
+curl http://localhost:/ping
+# {"message":"pong"}
+```
+
+## 配置说明
+
+配置文件为 `configs/config.yaml`(YAML),**仅从文件读取,不支持环境变量覆盖**。各配置段说明如下:
+
+| 配置段 | 说明 | 关键字段 |
+| --- | --- | --- |
+| `Server` | HTTP 服务 | `Domain`(CORS 允许域名,`*` 或不填允许所有来源)、`RunMode`(debug/release)、`HttpPort`、`ReadTimeout`/`WriteTimeout`(秒) |
+| `Log` | 日志 | `LogSavePath`、`LogFileName`、`MaxSize`/`MaxBackups`/`MaxAge`(MB/份数/天数)、`LogLevel`(Debug/Info/Warn/Error) |
+| `Mysql` | MySQL 连接池 | 用户名/密码/Host/Port/DBName 及连接池参数 |
+| `Redis` | Redis 连接 | Host/Port/Password/DB 及连接池、重试、超时参数 |
+| `Otel` | OpenTelemetry | `Enabled`、`ServiceName`、`ExporterEndpoint`(OTLP gRPC 端点)、`Insecure`、`SamplerRatio`(0~1) |
+| `JWT` | 鉴权 | `SecretKey`(**必填**,空则启动失败)、`Issuer`、`AccessTokenExpire`(默认 7200s)、`RefreshTokenExpire`(默认 86400s) |
+| `Cos` | 腾讯云 COS | SecretID/SecretKey、上传/下载域名、CDN 下载鉴权密钥 |
+| `Upload` | 上传限制 | `TmpSavePath`、`MaxSizeMB`(默认 20)、`AllowedExtensions` 扩展名白名单 |
+| `MattingModel` | 抠图服务地址 | `Host`/`Port`(默认 8001)、`MaxRetry`(默认 3) |
+| `ClassificationModel` | 分类服务地址 | `Host`/`Port`(默认 8000)、`MaxRetry`(默认 3) |
+| `WechatMini` | 微信小程序 | `AppID`/`AppSecret`(用于 code2session 登录) |
+| `WechatPay` | 微信支付 V3 | `Enable`(是否启用,未开通置 false 不加载)/`AppID`/`MchID`/`APIv3Key`(**必须 32 字节**)/`SerialNo`/`PrivateKeyPath`/`NotifyURL`/`RefundNotifyURL` |
+| `WechatVirtualPay` | 小程序虚拟支付(XPay) | `Enable`(是否启用)/`OfferID`/`EnvAppKey`(现网 AppKey)/`SandboxAppKey`(沙箱 AppKey)/`Token`(消息推送验签);与普通微信支付独立商户号、独立结算,虚拟商品(会员/额度包)须走此通道。**环境自动选择**:只填 `SandboxAppKey` 走沙箱测试(不真实扣款),只填 `EnvAppKey` 或两把都填走现网(现网优先) |
+
+> **虚拟支付道具同步由管理后台负责,服务端不会自动同步**:商品发布为虚拟支付道具(上传/发布、改价)由管理后台完成,并将微信侧返回的 `product_id` 与 `product_status` 回填到 `goods` 表后,虚拟商品才可下单。**运营改价后必须通过管理后台同步道具价格**,否则微信侧道具价格与本地不一致:微信扣款多于订单金额时发货金额校验会拒绝(用户付款拿不到权益),少于订单金额时产生差价资损。
+| `UserDefaultQuota` | 新用户免费额度与赠送会员 | `Cos`(**永久**额度,未配置回落 20)、`AI`/`OutfitShare`/`ClosetShare`(**每日**额度,未配置回落 3/10/5,每天 0 点重置;可显式配 **0** 表示不赠送)、`GiftVIPDay`(注册赠送体验会员天数,未配置回落 3 天,可配 **0** 关闭;负数回落默认) |
+| `AdReward` | 广告激励额度 | `Enable`(是否开启,关闭时接口返回「广告激励未开启」)、`Cos`/`AI`/`OutfitShare`/`ClosetShare`(各类型**固定金额**,服务端定价,前端不传金额;可删或置 0 表示不发放该类型) |
+| `Tokenhub` | 大模型网关 | `APIKey`、`Model`(默认 hy4);**模板中缺失,需手动补充(见快速开始)** |
+
+> 未配置(整段缺失或留空)的第三方能力:COS、微信支付与虚拟支付在启动时降级为告警并禁用对应功能;微信登录(`WechatMini`)未配置时服务可正常启动,但登录接口会返回"未配置"错误。`Tokenhub`、`MattingModel`、`ClassificationModel` 三段为启动强依赖(缺失会导致 panic),请务必配置。
+
+## 业务流程
+
+> 本章从**业务视角**介绍各业务域的闭环流转、状态机与关键业务规则(额度扣减/回退、审核、幂等、补偿等);接口层面的调用顺序与时序图见下文 [核心业务调用流程](#核心业务调用流程),接口入参/出参细节见 [接口调用详解](#接口调用详解)。
+
+### 1. 业务流程总览
+
+```mermaid
+flowchart TD
+ A["微信登录
新用户自动注册"] --> B["初始化免费额度
默认 上传 20 / AI 3 / 搭配分享 10 / 衣柜分享 5
(可配置,允许为 0)"]
+ B --> C["上传衣物
校验 → 扣上传额度 → 异步 AI 抠图 + 分类"]
+ C --> D["衣橱管理
查看 / 修正属性 / 删除回退额度"]
+ D --> E["创建穿搭"]
+ D --> F["AI 穿搭推荐
扣 AI 额度 → 大模型生成 → 待用户保存"]
+ D --> G["衣柜分享
部分衣物 / 全量快照"]
+ E --> H["公开到社区
自动审核通过 → 扣搭配分享额度"]
+ H --> I["社区互动
点赞 / 收藏 / 站内通知"]
+ F --> E
+ B --> J{"额度不足?"}
+ J -- "是" --> K["选购商品 → 微信支付 → 权益到账"]
+ K --> B
+```
+
+全系统共 7 条业务主线:
+
+1. **注册与额度**:微信登录首次自动注册,初始化 Cos **永久**免费额度(内置默认 20),AI 推荐 / 搭配分享 / 衣柜分享为**每日**额度(内置默认 3/10/5,可由 `UserDefaultQuota` 覆盖,**允许显式配 0** 表示不赠送;未配置或负数回落默认),每日额度不落库、按当天计数每天 0 点重置;并赠送体验会员(`vip_type=4`,内置默认 3 天,可由 `UserDefaultQuota.GiftVIPDay` 覆盖,**允许配 0 关闭**),同时下发一条系统通知;会员已过期时接口返回的 `vip_type` 归一为 1。
+2. **衣物生命周期**:上传(校验 + 扣额)→ 异步 AI 处理(抠图 → 分类)→ 成功入库可被选用;失败自动回退额度;删除软删并回退额度。
+3. **穿搭与社区**:创建穿搭 → 公开(审核自动通过 + 扣分享额度)→ 其他用户点赞/收藏 → 触发通知。
+4. **衣柜分享**:部分衣物或全量快照分享 → 扣额度 → 属主/访客查看(访客访问即记录接收关系)→ 取消或到期懒过期;"分享给我的"列表联表查询接收关系。
+5. **交易与权益**:选购商品 → 下单(15 分钟有效)→ 微信支付回调发放权益 → 超时过期 / 退款回收,发放失败自动补偿。
+6. **AI 推荐**:扣 AI 额度 → 结合星座运势与衣橱生成 → 落库"待用户保存" → 保存转正式穿搭(公开仍需扣分享额度)。
+7. **通知**:点赞、支付成功、退款成功等业务动作触发站内通知,未读计数实时维护。
+
+### 2. 衣物生命周期
+
+衣物图片处理状态机(`image_process_status`):
+
+```mermaid
+stateDiagram-v2
+ [*] --> queued : 上传成功(同步扣上传额度 1 点)
+ queued --> matting : 异步任务开始
+ matting --> classifying : 抠图完成
+ classifying --> success : 分类完成
+ classifying --> failed : 分类失败
+ matting --> failed : 抠图失败
+ queued --> failed : 保存/上传异常
+ success --> deleted : 用户删除(回退额度)
+ failed --> deleted : 用户删除(不回退,失败时已回退)
+
+ state "3 排队中" as queued
+ state "1 抠图中" as matting
+ state "2 分类中" as classifying
+ state "4 成功" as success
+ state "5 失败" as failed
+```
+
+处理流程:
+
+1. **上传校验**:扩展名白名单 `jpg/jpeg/png` + magic bytes(文件头)双重校验 + 大小 ≤ 20MB,任一项不通过即拒绝上传。
+2. **同步扣额**:先扣减上传额度 1 点,扣减成功后才创建衣物记录(初始状态 `3 排队中`)并立即返回 `clothes_uuid`;建记录失败自动回退额度。
+3. **异步 AI 流水线**:抠图 → 15 维分类在后台 goroutine 中执行;并发信号量上限 10、单任务超时 3 分钟;抠图/分类调用各带重试(默认 3 次、间隔 1s)。
+4. **失败处理**:任一环节异常即置 `5 失败` 并写入 `fail_reason`(如"图片抠图失败/图片分类失败");**失败自动回退上传额度**(仅当衣物仍未被删除,防止与删除回退双重回退);分类失败但抠图成功时保留抠好的图,供用户手动补全属性。
+5. **修正与删除**:`update` 支持 18 个属性字段(分类/风格/颜色/材质/袖型等,无枚举限制,支持自定义分类);删除为软删除,**处理状态非失败**(失败态已在失败路径回退过)且已扣额度时,在事务内(行锁 → 软删 → 条件扣减 → 写回退流水)回退额度。
+
+### 3. 穿搭与社区
+
+穿搭公开与审核状态机:
+
+```mermaid
+stateDiagram-v2
+ [*] --> created : 创建穿搭(1~10 件本人衣物)
+ created --> private : 默认私有 is_public=1
+ private --> public : 公开 is_public=2(审核自动通过 + 扣搭配分享额度 1 点)
+ public --> private : 取消公开(不返还已扣额度)
+ public --> interacted : 其他用户点赞 / 收藏(触发点赞通知)
+ private --> [*] : 修改(明细先删后建)/ 删除(软删 + 明细物理删)
+ public --> [*] : 修改(明细先删后建)/ 删除(软删 + 明细物理删)
+
+ state "创建(私有/待审核)" as created
+ state "1 私有" as private
+ state "2 公开(审核通过)" as public
+ state "社区互动" as interacted
+```
+
+处理流程:
+
+1. **创建**:1~10 件衣物(必须属于当前用户)、名称必填;默认 `is_public=1` 私有、`audit_status=1` 待审核、`status=1` 正常。
+2. **公开**:置 `is_public=2` 时审核**自动通过**(`audit_status 1→2`)并扣搭配分享额度 1 点;扣额失败则回退为私有。当前无人工驳回流程(`audit_status=3` 不通过为预留值)。
+3. **可见性**:`is_public=2` 且 `audit_status=2` 且 `status=1` 三者同时满足才对他人可见;他人仅可点赞/收藏公开可见的穿搭。
+4. **点赞 / 收藏**:先写 MySQL(联合唯一索引 upsert 幂等)再写 Redis 集合;取消时 MySQL 记录状态置 0(不删记录),可再次点赞/收藏;仅"从取消/无记录变为点赞"且非本人时才触发通知。
+5. **实现备注**:当前无独立"社区广场"浏览列表接口(仅"我的穿搭"列表 + 按 UUID 查看公开穿搭)。
+
+### 4. 衣柜分享
+
+分享状态机(`status`):
+
+```mermaid
+stateDiagram-v2
+ [*] --> normal : 创建分享(扣衣柜分享额度 1 点 + 自动审核通过)
+ normal --> cancelled : 用户取消(校验归属,不返还额度)
+ normal --> expired : 到期懒过期(访问详情时判定 now > ExpireTime)
+ normal --> banned : 封禁(预留状态,代码无写入点)
+
+ state "1 正常" as normal
+ state "2 已取消" as cancelled
+ state "3 已过期" as expired
+ state "4 已封禁" as banned
+```
+
+处理流程:
+
+1. **创建**:`share_type=1` 部分衣物(指定衣物 UUID 列表,逐件校验归属 + 去重)或 `share_type=2` 全量快照(当前全部衣物 ID 的引用列表,非属性数据副本)。
+2. **扣额**:扣衣柜分享额度 1 点,明细落库失败自动回退(避免产生空分享);创建即自动审核通过。
+3. **过期机制**:`ExpireTime` 由创建者指定(0 表示永不);采用**懒过期**——访问详情时判定过期并更新状态,无定时扫描任务。
+4. **查看**:详情支持属主与访客视角(`is_owner`),访客计入访问统计;封禁状态对访问者隐藏(返回"不存在");已取消/已过期返回对应错误。
+5. **删除衣物影响**:不联动清理分享明细,被软删除的衣物在分享详情中自动过滤。
+6. **接收关系**:采用"访问即视为收到"——登录用户(非属主)成功访问他人分享详情时,幂等写入 `closet_share_receivers`(`share_uuid + receiver_user_id` 联合唯一);一个分享可被多个用户接收,重复访问不重复记录。
+7. **分享给我的列表**:`GET /closet-share/received` 联表分页返回当前用户收到的分享,额外返回分享者昵称/头像(`owner_name` / `owner_avatar`)。
+8. **实现备注**:service 层已支持访客(`userID=0`)视角,但当前路由注册在鉴权组内,未登录访客的免登录访问需结合 HTTP 层路由确认。
+
+### 5. 交易与权益
+
+订单状态机(`order_status`):
+
+```mermaid
+stateDiagram-v2
+ [*] --> pending : 创建订单(快照权益 + 微信 JSAPI 下单)
+ pending --> paid : 支付回调 SUCCESS(验签 + 金额校验 → 发放权益 → 通知)
+ pending --> expired : 超时 15 分钟(后台每分钟扫描)
+ pending --> cancelled : 用户主动取消(同步关闭微信订单)
+ expired --> paid : 过期后付款到账(补发货)
+ cancelled --> refunded : 已付款的取消订单自动发起退款
+ paid --> refunded : 退款成功回调(回收权益 → 通知)
+
+ state "1 待支付" as pending
+ state "2 已支付" as paid
+ state "3 已取消" as cancelled
+ state "4 已过期" as expired
+ state "5 已退款" as refunded
+```
+
+处理流程:
+
+1. **商品**:5 类商品(`1` 会员包 / `2` 上传包 / `3` AI 推荐包 / `4` 衣柜分享包 / `5` 搭配分享包),价格单位为**分**;仅 `1 上架` 状态可购买。
+2. **下单**:实际支付金额取 `discount_price`;下单时把商品权益快照(会员天数/各类额度)冗余进订单,防止商品后续变更导致错发;先落本地待支付订单再调微信 JSAPI,微信下单失败时订单保留并靠超时自动过期。同一用户可并存多个待支付订单(无防重限制)。
+3. **支付回调**:验签 + 解密 → 仅处理 `SUCCESS` → 幂等锁 30s → **金额校验**(回调金额与订单不一致则拒绝流转、人工介入)→ 待支付 → 已支付 → 发放权益 + 写支付信息 + 发订单通知。竞态兜底:已过期但已付款的订单补发货;已取消但已付款的订单生成**待审核退款单**,由管理员手动退款。
+4. **权益发放**:会员天数实际发放(叠加规则 `vip_expire = max(vip_expire, now) + 天数×86400`,续费顺延,会员类型按**续费后最终到期时长**推导——剩余 ≥365 天为年度会员、否则月度会员,避免年度会员续月包被降级);Cos / AI / 分享额度改为写入**额度凭证** `quota_grants`——会员包 `expire_time=0`(跟随 VIP)、加量包按 `goods.valid_days` 独立到期,幂等由 `(ref_id, quota_type)` 唯一索引保证;补偿任务 `order_no + benefit_type` 唯一保证不重发。
+5. **超时过期**:后台任务**每分钟**扫描 `待支付且 expire_time < now` 的订单批量置为已过期(条件更新天然幂等);过期后无权益可回收。
+6. **退款**:**所有退款均需管理员操作**——用户申请退款仅登记待审核退款单(`0 待处理`),运营审核后在微信后台手动退款,退款回调更新状态(`0 待处理 → 1 退款中 → 2 成功 / 3 失败`);「已取消但已付款」的订单同样生成待审核退款单而非自动退款。退款成功回调将订单置已退款、**回收权益**(会员天数不回退到过去;赠送额度通过失效凭证回收未用部分,已用不追回;已取消订单权益从未发放,跳过回收)并发通知。
+7. **发放失败补偿**:权益发放失败自动落 pending 任务,后台任务**每分钟**扫描重试(Redis 锁保证单实例),每轮最多 100 个,重试 3 次仍失败则标记失败 + 告警人工介入;重试前先查发放流水幂等。
+8. **额度变更统一收口**:Cos 永久额度扣减/回退封装在事务内「凭证扣减(先到期先用)+ 免费额度扣减 + 写流水」强一致;每日额度(AI/分享/衣柜分享)由 Redis 原子限流计数(当天 0 点自然过期),MySQL `user_daily_usages` 兜底统计。
+
+### 6. AI 推荐
+
+```mermaid
+flowchart TD
+ A["发起推荐请求"] --> B{"Redis 锁 180s
(防并发重复生成)"}
+ B -- 抢锁失败 --> X["返回「请求过于频繁」"]
+ B -- 成功 --> C["扣 AI 额度 1 点
(生成前权威扣减)"]
+ C --> D["组装输入:
处理成功的全部衣物 + 星座 + 今日运势"]
+ D --> E["调用大模型生成
(tokenhub)"]
+ E --> F{"校验结果
(过滤模型幻觉衣物 ID)"}
+ F -- 有效 --> G["落库:source=2 AI、status=3 待用户保存"]
+ F -- 无效 / 任一步失败 --> Y["回退 AI 额度 + 写失败流水"]
+ G --> H["用户确认 Save:
status 3→1 转正式穿搭(不扣额)"]
+ H --> I["公开到社区仍需 Public
(扣搭配分享额度)"]
+```
+
+处理流程:
+
+1. **输入**:当前用户**处理成功(4)** 的全部衣物(分页遍历)+ 由生日推算的星座 + 今日运势(无生日/运势时降级为仅星座或空)。
+2. **扣额与回退**:**生成前**扣减 AI 额度 1 点;衣橱为空、模型调用失败、无有效单品、落库失败等任一情况均**统一回退额度**并写失败流水。
+3. **结果校验**:校正模型返回的衣物 ID(过滤幻觉),无有效单品则失败回退。
+4. **落库**:生成穿搭以 `source=2 AI`、`status=3 待用户保存` 入库(私有、待审核);明细落库失败则删除穿搭 + 回退额度。
+5. **保存转正**:仅 `status=3` 可保存(重复保存幂等),保存仅将状态 3→1,**不扣分享额度**;后续公开到社区仍走 Public 流程扣搭配分享额度。
+6. **实现备注**:不保存的"待保存"穿搭无自动清理任务;同用户并发请求由 Redis 锁(180s)拦截。
+
+### 7. 通知触发规则
+
+| 触发动作 | 通知类型 | 发送者 | 接收者 | 触发条件与防重 |
+| --- | --- | --- | --- | --- |
+| 点赞 | `1` 点赞 | 点赞者 | 穿搭作者 | 仅当"从取消/无记录变为点赞"且非本人点赞时触发 |
+| 支付成功 | `2` 订单 | 系统(sender=0) | 买家 | 支付回调发放权益成功后触发;取消/过期不发 |
+| 退款成功 | `2` 订单 | 系统(sender=0) | 买家 | 退款回调回收权益后触发 |
+| 系统通知 | `3` 系统 | 预留 | — | 常量已定义,当前代码无发送点(需运营/后台触发) |
+
+- **发送实现**:写入 MySQL 通知表(`is_read=1` 未读)+ Redis 未读计数自增,best-effort(失败仅告警,不影响主业务)。
+- **未读/已读**:`is_read` 取值 `1 未读 / 2 已读`;标记已读为**单条接口**(条件更新"仅未读→已读"防并发重复扣减,随后 Redis 计数自减),无"全部已读"接口。
+- **未读计数**:Redis 优先,Redis 为 0/异常时回落 MySQL 统计并回填缓存。
+
+> 以上 7 条业务主线的**接口调用顺序**(时序图)见下文 [核心业务调用流程](#核心业务调用流程)。
+
+## API 概览
+
+- 统一前缀:`/api/v1`
+- 统一响应结构:成功 `{"code":0,"msg":"成功","data":{...}}`;失败 `{"code":<业务错误码>,"msg":<文案>,"details":[...]}`。HTTP 状态码大多为 200(按业务 code 处理),鉴权类错误返回 401/403,限流 429,系统错误 500。
+- 鉴权方式:`Authorization: Bearer `(登录/刷新接口获取)。
+
+### 通用调用约定
+
+#### 鉴权
+
+- 需登录接口统一携带请求头 `Authorization: Bearer `;access_token 由登录接口返回,默认有效期 7200 秒(`JWT.AccessTokenExpire`)。
+- access_token 过期(业务码 `11002`,HTTP 401)时,前端调用 `POST /user/refresh-token` 换新,成功后携带新 access_token 重试原请求。
+- 鉴权中间件除校验 JWT 外,还会校验 Redis 会话状态与用户状态(必须为 `status=1` 正常,否则拒绝);Redis 故障时降级为仅校验 JWT。
+
+#### 统一响应结构
+
+成功(HTTP 200):
+
+```json
+{
+ "code": 0,
+ "msg": "成功",
+ "data": {}
+}
+```
+
+- 无业务数据的接口(删除、取消、标记已读等)`data` 为空对象 `{}`。
+
+失败:
+
+```json
+{
+ "code": 10000,
+ "msg": "请求参数错误",
+ "details": ["field: 错误说明"]
+}
+```
+
+- `details` 仅在参数校验类错误时返回错误明细数组,非校验类错误无此字段。
+- **判断业务结果一律以 `code` 为准**,不能仅凭 HTTP 状态码。
+
+#### HTTP 状态码与业务码映射
+
+| HTTP 状态码 | 业务 code | 场景 |
+| --- | --- | --- |
+| 200 | 0 及其余全部业务码 | 成功与绝大多数业务错误(按 code 区分) |
+| 401 | 11000 / 11001 / 11002 / 11004 | 未授权、Token 无效 / 已过期 / 缺失 |
+| 403 | 12000 | 越权访问他人资源 |
+| 429 | 10003 | 请求过于频繁(限流) |
+| 500 | 19000–19003 / 19999 / 39001 / 39002 | 系统 / 数据库 / 缓存 / 下游 AI 服务错误 |
+
+#### 微信支付回调专用响应
+
+`/order/pay/notify`、`/order/refund/notify` 是微信服务端回调,不走统一响应包装,按微信 V3 规范返回:
+
+- 受理成功:HTTP 200,`{"code":"SUCCESS","message":"成功"}`
+- 受理失败:HTTP 500,`{"code":"FAIL","message":"<失败原因>"}`
+
+#### 分页约定
+
+- 所有列表接口使用 GET query 参数:`page`(可选,≥1)、`page_size`(可选,1~50)。
+- 列表响应统一包含:`total`(总条数,int64)、`page`(当前页)、`page_size`(每页条数)、`list`(数据数组)。
+
+#### 金额单位
+
+- 所有金额字段(`price`、`discount_price`、`unit_price`、`pay_amount`、`refund_amount`)单位均为**分**(int64),前端展示需除以 100。
+
+#### 幂等与动作切换
+
+- 点赞 / 收藏 / 取消分享等写操作幂等,重复提交不会产生重复数据。
+- 点赞与收藏通过 `action` 字段切换:`1=执行(点赞/收藏)`、`2=取消`。
+- 删除衣物回退上传额度、权益发放等均由服务端事务 + 幂等流水保证一致,前端无需补偿处理。
+
+### 健康检查
+
+| 方法 | 路径 | 说明 |
+| --- | --- | --- |
+| GET | `/ping` | 存活探针,返回 `{"message":"pong"}` |
+
+### 公开路由(无需登录)
+
+| 方法 | 路径 | 说明 |
+| --- | --- | --- |
+| POST | `/user/wxlogin` | 微信登录(code 换 openid,首次自动注册) |
+| POST | `/user/refresh-token` | 刷新 access token |
+| GET | `/goods` | 商品列表 |
+| GET | `/goods/:code` | 按编码查询商品 |
+| POST | `/order/pay/notify` | 微信支付结果回调 |
+| POST | `/order/refund/notify` | 微信退款结果回调 |
+
+### 鉴权路由(需登录)
+
+**用户 / 额度 / 运势**
+
+| 方法 | 路径 | 说明 |
+| --- | --- | --- |
+| GET | `/user/info` | 查询个人信息 |
+| PUT | `/user/profile` | 更新个人资料 |
+| GET | `/quota/info` | 额度概览(四类额度余量) |
+| GET | `/quota/flow` | 额度流水 |
+| GET | `/horoscope/today` | 今日星座运势(按生日推算星座) |
+
+**衣物 / 穿搭**
+
+| 方法 | 路径 | 说明 |
+| --- | --- | --- |
+| POST | `/clothes/upload` | 上传衣物图片(触发 AI 抠图/分类流水线) |
+| GET | `/clothes` | 衣物列表 |
+| GET | `/clothes/:uuid` | 衣物详情 |
+| GET | `/clothes/:uuid/status` | 衣物 AI 处理状态 |
+| 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/received` | 分享给我的列表 |
+| GET | `/closet-share/:uuid` | 分享详情 |
+| POST | `/closet-share/:uuid/cancel` | 取消分享 |
+| POST | `/collect` | 收藏/取消收藏(由请求 Action 决定) |
+| GET | `/collect` | 收藏列表 |
+| GET | `/collect/:targetUUID` | 查询目标收藏状态与数量 |
+| POST | `/like` | 点赞/取消点赞(由请求 Action 决定) |
+| GET | `/like/:targetUUID` | 查询目标点赞情况 |
+| GET | `/notification` | 通知列表 |
+| GET | `/notification/unread` | 未读通知数 |
+| GET | `/notification/:uuid` | 通知详情 |
+| POST | `/notification/:uuid/read` | 标记已读 |
+
+**订单 / AI 推荐**
+
+| 方法 | 路径 | 说明 |
+| --- | --- | --- |
+| POST | `/order` | 创建订单(微信 JSAPI 支付) |
+| GET | `/order` | 订单列表 |
+| POST | `/order/:orderNo/cancel` | 取消订单 |
+| POST | `/order/refund` | 申请退款 |
+| POST | `/ad-reward/grant` | 广告激励额度发放(当天有效,幂等;由 `AdReward` 配置开关控制,金额服务端定价) |
+| POST | `/ai-recommend` | 生成 AI 穿搭推荐(扣减 AI 额度) |
+| POST | `/ai-recommend/save` | 保存 AI 推荐结果为用户穿搭 |
+
+### 接口调用详解
+
+> 本节为路由总表的字段级补充。请求参数位置标注:`json` = JSON 请求体、`query` = URL 查询参数、`path` = 路径参数、`form` = multipart 文件。路径中的 `:xxx` 为占位参数名,调用时替换为真实值。字段类型按 JSON 语义标注(string / int / int64 / bool / object / array)。
+
+#### 公共数据结构
+
+以下对象在多处接口中出现,统一在此定义,后文不再重复。
+
+**用户对象(user_info)**
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| username | string | 昵称 |
+| avatar | string | 头像地址 |
+| sex | int | 1=男 2=女 |
+| birth_day | int64 | 生日(秒级时间戳) |
+| phone | string | 手机号 |
+| is_new_user | bool | 是否本次新注册用户 |
+| status | int | 1=正常 2=临时禁用 3=永久禁用 4=已注销 |
+| ban_reason | string | 封禁原因 |
+| ban_start | int64 | 封禁开始时间 |
+| ban_expire | int64 | 封禁到期时间 |
+| vip_type | int | 1=非会员 2=月度会员 3=年度会员 4=体验会员(注册赠送) |
+| vip_expire | int64 | VIP 到期时间 |
+
+**衣物对象(ClothesItem)**
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| clothes_uuid | string | 衣物 UUID |
+| category_main | string | 大类(上装/下装/连衣裙/外套/鞋/配饰) |
+| category_sub | string | 小类(T恤/牛仔裤/运动鞋/发带/戒指等) |
+| style | string | 风格 |
+| color_main | string | 主颜色 |
+| color_sub | string | 副颜色 |
+| thickness | string | 厚度 |
+| version | string | 版型(宽松/修身/阔腿/紧身/常规合身) |
+| material | string | 材质(牛仔布/针织/皮质/纯棉/羊毛等) |
+| pattern | string | 花纹 |
+| sleeve_type | string | 袖型 |
+| clothes_type | string | 衣长类型(短款/常规/中长/长款) |
+| neckline_type | string | 领型 |
+| formality | string | 正式程度(1-5) |
+| occasions | string | 适用场合 |
+| visual_impact | string | 视觉冲击力(1-5) |
+| name | string | 名称 |
+| remark | string | 备注 |
+| processed_image | string | 处理后图片地址 |
+
+**穿搭对象(OutfitItem)**
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| outfit_uuid | string | 穿搭 UUID |
+| outfit_name | string | 穿搭名称 |
+| outfit_desc | string | 穿搭描述 |
+| scene | string | 适合场景 |
+| source | int | 1=用户创建 2=AI 推荐 |
+| is_public | int | 1=不公开 2=公开 |
+| audit_status | int | 1=待审核 2=审核通过 3=审核不通过 |
+| items | array | 衣物列表,元素:`{"clothes_uuid": string}` |
+
+**商品对象(GoodsItem)**
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| id | int64 | 商品 ID |
+| goods_code | string | 商品编码(下单时使用) |
+| goods_name | string | 商品名称 |
+| goods_type | int | 1=会员包 2=上传包 3=AI 推荐包 4=衣柜分享包 5=搭配分享包 |
+| price | int64 | 原价(分) |
+| discount_price | int64 | 售价(分) |
+| member_days | int64 | 会员天数 |
+| upload_quota | int64 | 上传额度 |
+| closet_share_quota | int64 | 衣柜分享额度 |
+| outfit_share_quota | int64 | 搭配分享额度 |
+| description | string | 商品描述 |
+
+**订单对象(OrderItem)**
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| order_no | string | 订单号 |
+| goods_name | string | 商品名称 |
+| unit_price | int64 | 单价(分) |
+| pay_amount | int64 | 实付金额(分) |
+| order_status | int | 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款 |
+| member_days | int64 | 会员天数 |
+| expire_time | int64 | 支付截止时间戳(秒) |
+| pay_time | int64 | 支付时间戳(秒) |
+| refund_time | int64 | 退款时间戳(秒) |
+| refund_amount | int64 | 退款金额(分) |
+| created_at | int64 | 创建时间戳(秒) |
+
+**通知对象(NotificationItem)**
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| notification_uuid | string | 消息 UUID |
+| msg_type | int | 1=点赞通知 2=订单通知 3=系统通知 |
+| title | string | 标题 |
+| content | string | 内容 |
+| is_read | int | 1=未读 2=已读 |
+| created_at | int64 | 创建时间戳(秒) |
+
+**额度流水对象(QuotaFlowItem)**
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| quota_flow_uuid | string | 流水 UUID |
+| quota_type | int | 1=上传 2=AI 3=社区分享 4=衣柜分享 |
+| change_target | int | 1=配额调整 2=使用 |
+| change_type | int | 1=购买获赠 2=活动赠送 3=上传消耗 4=删除回退 5=人工调整 6=退款回收 |
+| change_num | int64 | 变更数量(正增负减) |
+| new_value | int64 | 变更后数值 |
+| reason | string | 变更原因 / 备注 |
+| order_no | string | 关联订单号(购买类流水) |
+| created_at | int64 | 创建时间戳(秒) |
+
+#### 1. 用户模块
+
+**POST `/user/wxlogin` —— 微信登录(公开)**
+
+请求(json):
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| code | string | 是 | 微信登录 code(`wx.login` 获取),首次登录自动注册并初始化免费额度 |
+
+响应 `data`:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| access_token | string | 访问令牌,后续请求放入 `Authorization: Bearer` 头 |
+| refresh_token | string | 刷新令牌,用于 access 过期后续期 |
+| expire_time | int64 | access_token 过期时间(秒) |
+| user_info | object | 用户对象(见公共结构) |
+
+相关错误码:21002 微信登录失败、21003 Code 无效。
+
+**POST `/user/refresh-token` —— 刷新令牌(公开)**
+
+请求(json):
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| refresh_token | string | 是 | 有效的 refresh_token |
+| access_token | string | 是 | 旧 access_token,必须与 refresh_token 一起传入;服务端校验通过后立即吊销旧 access + 旧 refresh,缺少返回 10000 |
+
+响应 `data`:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| access_token | string | 新访问令牌 |
+| refresh_token | string | 新刷新令牌(旋转更新) |
+| expire_time | int64 | 新 access_token 过期时间(秒) |
+
+**GET `/user/info` —— 查询当前用户信息(需鉴权)**
+
+无请求参数。响应 `data` = 用户对象(见公共结构)。
+
+**PUT `/user/profile` —— 更新个人资料(需鉴权)**
+
+请求(json):
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| username | string | 否 | 昵称,最长 32 |
+| avatar | string | 否 | 头像地址,最长 500 |
+| sex | int | 否 | 0=未知 1=男 2=女 |
+| birth_day | int64 | 否 | 生日(秒级时间戳) |
+
+响应:`data` 为空对象 `{}`。
+
+**POST `/user/avatar/upload` —— 上传头像(需鉴权)**
+
+multipart 上传,字段名 `file`;仅支持图片(jpeg/png/gif/webp),复用全站图片扩展名白名单与大小限制。上传成功后写入 COS(key 规则 `{用户UUID}/{用户ID}/avatar/{随机名}.{扩展名}`)并将头像 key 存入 `users.avatar`,**不消耗** COS 上传额度。响应 `data`:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| avatar | string | 头像可访问 URL |
+
+#### 2. 额度模块
+
+**GET `/quota/info` —— 额度概览(需鉴权)**
+
+无请求参数。响应 `data`:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| quotas | array | 额度数组,元素见下表 |
+
+数组元素:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| quota_type | int | 1=上传 2=AI 3=社区分享 4=衣柜分享 |
+| total | int64 | 总配额 |
+| used | int64 | 已使用 |
+| remain | int64 | 剩余配额 |
+
+**GET `/quota/flow` —— 额度流水(需鉴权)**
+
+请求(query):
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| page / page_size | int | 否 | 分页(见通用约定) |
+| quota_type | int | 否 | 1=上传 2=AI 3=社区分享 4=衣柜分享;不传 = 全部 |
+
+响应 `data.list` 元素 = 额度流水对象(见公共结构)。
+
+#### 3. 运势模块
+
+**GET `/horoscope/today` —— 今日星座运势(需鉴权)**
+
+无请求参数(星座按用户生日自动推算)。响应 `data`:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| stat_date | int64 | 日期(格式 YYYYMMDD) |
+| zodiac | string | 星座 |
+| overall_score | int | 综合运势 1-5 分 |
+| love_score | int | 爱情 1-5 分 |
+| career_score | int | 事业 1-5 分 |
+| wealth_score | int | 财运 1-5 分 |
+| daily_text | string | 今日运势文案 |
+| lucky_color | string | 今日幸运色 |
+| lucky_number | string | 今日幸运数字 |
+
+#### 4. 衣物模块
+
+图片处理状态枚举(`image_process_status`,全模块通用):
+
+| 值 | 含义 |
+| --- | --- |
+| 1 | 抠图中 |
+| 2 | 分类中 |
+| 3 | 排队中 |
+| 4 | 成功 |
+| 5 | 失败(配合 `fail_reason` 查看原因) |
+
+**POST `/clothes/upload` —— 上传衣物图片(需鉴权)**
+
+请求(form,multipart/form-data):
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| file | file | 是 | 衣物图片文件(**字段名固定为 `file`**,无其他表单字段) |
+
+服务端校验:扩展名白名单(配置 `Upload.AllowedExtensions`)+ 大小上限(`Upload.MaxSizeMB`,默认 20MB)+ magic bytes 真图校验;上传即扣减一次上传额度。
+
+响应 `data`:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| clothes_uuid | string | 衣物 UUID,后续查询/更新/删除均用此值 |
+| image_process_status | int | 处理状态(见枚举表),初始为 3=排队中 |
+
+相关错误码:30000 文件为空、30001 类型不支持、30002 超限、39000 保存失败、80000 额度不足。
+
+**GET `/clothes` —— 衣物列表(需鉴权)**
+
+请求(query):
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| page / page_size | int | 否 | 分页 |
+| category_main | string | 否 | 按大类筛选 |
+| category_sub | string | 否 | 按小类筛选 |
+
+响应 `data`:`total` / `page` / `page_size` + `list`(衣物对象数组,见公共结构)。
+
+**GET `/clothes/:uuid` —— 衣物详情(需鉴权)**
+
+| 参数 | 位置 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- | --- |
+| uuid | path | string | 是 | 衣物 UUID |
+
+响应 `data` = 衣物对象(见公共结构)。
+
+相关错误码:50000 衣物不存在、50005 无权操作该衣物。
+
+**GET `/clothes/:uuid/status` —— 查询图片处理状态(需鉴权)**
+
+| 参数 | 位置 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- | --- |
+| uuid | path | string | 是 | 衣物 UUID |
+
+响应 `data`:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| image_process_status | int | 处理状态(见枚举表) |
+| fail_reason | string | 失败原因(状态为 5 时有效) |
+
+**PUT `/clothes/:uuid` —— 更新衣物信息(需鉴权)**
+
+| 参数 | 位置 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- | --- |
+| uuid | path | string | 是 | 衣物 UUID |
+
+请求(json,全部可选,传什么改什么):
+
+| 参数 | 类型 | 说明 |
+| --- | --- | --- |
+| category_main | string | 大类,最长 64 |
+| category_sub | string | 小类,最长 64 |
+| style | string | 风格,最长 64 |
+| color | string | 主颜色,最长 64(**注意:请求字段名为 `color`,非 `color_main`**) |
+| color_sub | string | 副颜色,最长 64 |
+| thickness | string | 厚度,最长 64 |
+| version | string | 版型,最长 64 |
+| material | string | 材质,最长 64 |
+| pattern | string | 花纹,最长 64 |
+| sleeve_type | string | 袖型,最长 64 |
+| clothes_type | string | 衣长类型,最长 64 |
+| neckline_type | string | 领型,最长 64 |
+| formality | string | 正式程度(1-5),最长 16 |
+| occasions | string | 适用场合,最长 255 |
+| visual_impact | string | 视觉冲击力(1-5),最长 16 |
+| name | string | 名称,最长 64 |
+| remark | string | 备注,最长 500 |
+
+响应:`data` 为空对象 `{}`。
+
+**DELETE `/clothes/:uuid` —— 删除衣物(需鉴权)**
+
+| 参数 | 位置 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- | --- |
+| uuid | path | string | 是 | 衣物 UUID |
+
+响应:`data` 为空对象 `{}`。删除成功自动回退上传额度。
+
+相关错误码:50000 衣物不存在、50001 已被搭配使用无法删除。
+
+#### 5. 穿搭模块
+
+穿搭枚举汇总:来源 `source` 1=用户创建 2=AI 推荐;公开 `is_public` 1=不公开 2=公开;审核 `audit_status` 1=待审核 2=通过 3=不通过。
+
+**POST `/outfit` —— 创建穿搭(需鉴权)**
+
+请求(json):
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| outfit_name | string | 是 | 穿搭名称,最长 32 |
+| outfit_desc | string | 否 | 穿搭描述,最长 500 |
+| scene | string | 否 | 适合场景,最长 100 |
+| source | int | 否 | 1=用户创建 2=AI 推荐 |
+| items | array | 是 | 衣物列表,1~10 项,元素:`{"clothes_uuid": string}` |
+
+响应 `data` = 穿搭对象(见公共结构)。
+
+**GET `/outfit` —— 我的穿搭列表(需鉴权)**
+
+请求(query):
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| page / page_size | int | 否 | 分页 |
+| is_public | int | 否 | 1=不公开 2=公开 |
+| source | int | 否 | 1=用户创建 2=AI 推荐 |
+
+响应 `data.list` 元素 = 穿搭对象(见公共结构)。
+
+**GET `/outfit/:uuid` —— 穿搭详情(需鉴权)**
+
+| 参数 | 位置 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- | --- |
+| uuid | path | string | 是 | 穿搭 UUID |
+
+响应 `data` = 穿搭对象(见公共结构)。
+
+相关错误码:40000 穿搭不存在、40004 无权操作该穿搭。
+
+**PUT `/outfit/:uuid` —— 更新穿搭(需鉴权)**
+
+| 参数 | 位置 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- | --- |
+| uuid | path | string | 是 | 穿搭 UUID |
+
+请求(json):
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| outfit_name | string | 是 | 穿搭名称,最长 32 |
+| outfit_desc | string | 否 | 穿搭描述,最长 500 |
+| scene | string | 否 | 适合场景,最长 100 |
+| items | array | 是 | 衣物列表,1~10 项,元素:`{"clothes_uuid": string}` |
+
+响应:`data` 为空对象 `{}`。
+
+**DELETE `/outfit/:uuid` —— 删除穿搭(需鉴权)**
+
+| 参数 | 位置 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- | --- |
+| uuid | path | string | 是 | 穿搭 UUID |
+
+响应:`data` 为空对象 `{}`。
+
+**PUT `/outfit/:uuid/public` —— 设置公开状态(需鉴权)**
+
+| 参数 | 位置 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- | --- |
+| uuid | path | string | 是 | 穿搭 UUID |
+
+请求(json):
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| is_public | int | 是 | 1=不公开 2=公开(公开进入社区,需审核) |
+
+响应:`data` 为空对象 `{}`。
+
+#### 6. 衣柜分享模块
+
+分享状态枚举:`status` 1=正常 2=已取消 3=已过期 4=已封禁。
+
+**POST `/closet-share` —— 创建衣柜分享(需鉴权)**
+
+请求(json):
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| share_type | int | 是 | 1=部分衣物分享 2=全量快照分享 |
+| clothes_uuids | array | 部分分享必填 | 衣物 UUID 数组(share_type=1 时必填) |
+| expire_time | int64 | 否 | 过期时间戳(秒) |
+
+响应 `data`:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| share_uuid | string | 分享 UUID,供他人访问分享详情 |
+
+**GET `/closet-share` —— 我的分享列表(需鉴权)**
+
+请求(query):
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| page / page_size | int | 否 | 分页 |
+| status | int | 否 | 1=正常 2=已取消 3=已过期 4=已封禁;不传 = 全部 |
+
+响应 `data.list` 元素:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| share_uuid | string | 分享 UUID |
+| share_type | int | 1=部分衣物分享 2=全量快照分享 |
+| clothes_count | int | 衣物数量 |
+| expire_time | int64 | 过期时间戳(秒) |
+| status | int | 1=正常 2=已取消 3=已过期 4=已封禁 |
+
+**GET `/closet-share/:uuid` —— 分享详情(需鉴权,访客视角)**
+
+| 参数 | 位置 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- | --- |
+| uuid | path | string | 是 | 分享 UUID |
+
+响应 `data`:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| share_uuid | string | 分享 UUID |
+| owner_name | string | 分享者昵称 |
+| owner_avatar | string | 分享者头像 |
+| clothes | array | 衣物对象数组(见公共结构) |
+| expire_time | int64 | 过期时间戳(秒) |
+| status | int | 1=正常 2=已取消 3=已过期 4=已封禁 |
+| is_owner | bool | 是否本人分享 |
+
+相关错误码:90100 分享不存在、90101 分享已过期、90102 分享已取消。
+
+**POST `/closet-share/:uuid/cancel` —— 取消分享(需鉴权)**
+
+| 参数 | 位置 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- | --- |
+| uuid | path | string | 是 | 分享 UUID |
+
+无请求体,响应为操作结果(空对象)。
+
+#### 7. 点赞 / 收藏模块
+
+动作枚举:`action` 1=执行(点赞/收藏)2=取消,重复调用幂等。
+
+**POST `/collect` —— 收藏 / 取消收藏(需鉴权)**
+
+请求(json):
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| target_uuid | string | 是 | 目标(社区穿搭)UUID |
+| action | int | 是 | 1=收藏 2=取消收藏 |
+
+响应:`data` 为空对象 `{}`。
+
+相关错误码:90200 目标内容不存在、90202 收藏操作失败。
+
+**GET `/collect` —— 我的收藏列表(需鉴权)**
+
+请求(query):仅 `page` / `page_size` 分页参数。
+
+响应 `data.list` 元素 = 穿搭对象(见公共结构)。
+
+**POST `/like` —— 点赞 / 取消点赞(需鉴权)**
+
+请求(json):
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| target_uuid | string | 是 | 目标穿搭 UUID |
+| action | int | 是 | 1=点赞 2=取消点赞 |
+
+响应:`data` 为空对象 `{}`。
+
+相关错误码:90201 点赞操作失败。
+
+**GET `/like/:targetUUID` —— 查询点赞状态与数量(需鉴权)**
+
+| 参数 | 位置 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- | --- |
+| targetUUID | path | string | 是 | 目标穿搭 UUID(**参数名为 `targetUUID`,非 `uuid`**) |
+
+响应 `data`:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| is_liked | bool | 当前用户是否已点赞 |
+| like_count | int64 | 点赞总数 |
+
+#### 8. 通知模块
+
+**GET `/notification` —— 通知列表(需鉴权)**
+
+请求(query):
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| page / page_size | int | 否 | 分页 |
+| is_read | int | 否 | 1=未读 2=已读;不传 = 全部(**注意:不是布尔值**) |
+
+响应 `data.list` 元素 = 通知对象(见公共结构)。
+
+**GET `/notification/unread` —— 未读通知数(需鉴权)**
+
+无请求参数。响应 `data`:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| unread_count | int64 | 未读通知数 |
+
+**GET `/notification/:uuid` —— 通知详情(需鉴权)**
+
+| 参数 | 位置 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- | --- |
+| uuid | path | string | 是 | 通知 UUID |
+
+响应 `data` = 通知对象(见公共结构)。
+
+相关错误码:90000 通知不存在。
+
+**POST `/notification/:uuid/read` —— 标记已读(需鉴权)**
+
+| 参数 | 位置 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- | --- |
+| uuid | path | string | 是 | 通知 UUID |
+
+无请求体,响应为操作结果(空对象)。
+
+#### 9. 商品模块(公开)
+
+商品类型枚举:`goods_type` 1=会员包 2=上传包 3=AI 推荐包 4=衣柜分享包 5=搭配分享包。
+
+**GET `/goods` —— 商品列表(公开)**
+
+请求(query):
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| page / page_size | int | 否 | 分页 |
+| goods_type | int | 否 | 商品类型(见枚举),不传 = 全部 |
+
+响应 `data.list` 元素 = 商品对象(见公共结构,仅返回上架商品)。
+
+**GET `/goods/:code` —— 按编码查询商品(公开)**
+
+| 参数 | 位置 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- | --- |
+| code | path | string | 是 | 商品编码(goods_code) |
+
+响应 `data` = 商品对象(见公共结构)。
+
+相关错误码:60000 商品不存在、60001 商品已下架。
+
+#### 10. 订单模块
+
+订单状态枚举:`order_status` 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款;退款状态 `status` 0=待处理 1=退款中 2=退款成功 3=退款失败。
+
+**POST `/order` —— 创建订单并发起微信支付(需鉴权)**
+
+请求(json):
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| goods_code | string | 是 | 商品编码(**唯一请求字段**,金额由服务端按商品售价计算,客户端不传金额) |
+
+响应 `data`:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| order_no | string | 订单号 |
+| pay_amount | int64 | 应付金额(分) |
+| expire_time | int64 | 支付截止时间戳(秒),前端倒计时用(15 分钟) |
+| pay_params | object | 小程序拉起支付参数(微信原生命名,注意大小写):`timeStamp`、`nonceStr`、`package`、`signType`、`paySign` |
+
+相关错误码:60000 商品不存在、60001 已下架、79000 创建订单失败。
+
+**GET `/order` —— 我的订单列表(需鉴权)**
+
+请求(query):
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| page / page_size | int | 否 | 分页 |
+| status | int | 否 | 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款;不传 = 全部 |
+
+响应 `data.list` 元素 = 订单对象(见公共结构)。
+
+**POST `/order/:orderNo/cancel` —— 取消订单(需鉴权)**
+
+| 参数 | 位置 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- | --- |
+| orderNo | path | string | 是 | 订单号(**参数名为 `orderNo`,非 `uuid`**) |
+
+无请求体,响应为操作结果(空对象)。仅待支付订单可取消。
+
+**POST `/order/refund` —— 申请退款(需鉴权)**
+
+请求(json):
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| order_no | string | 是 | 订单号 |
+| reason | string | 是 | 退款原因,最长 200 |
+
+响应 `data`:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| refund_no | string | 退款单号 |
+| refund_amount | int64 | 退款金额(分) |
+| status | int | 0=待处理 1=退款中 2=退款成功 3=退款失败 |
+| apply_time | int64 | 申请时间戳(秒) |
+
+相关错误码:71000 退款记录不存在、71001 当前状态不允许退款、71002 退款金额不合法、71003 退款申请失败。
+
+**POST `/order/pay/notify` —— 微信支付回调(公开,微信服务端调用)**
+
+请求体为微信支付回调原始报文(服务端验签 + 解密)。响应遵循微信规范(见通用调用约定),前端**不要**手动调用。
+
+**POST `/order/refund/notify` —— 微信退款回调(公开,微信服务端调用)**
+
+请求体为微信退款回调原始报文。响应遵循微信规范(见通用调用约定),前端**不要**手动调用。
+
+#### 11. AI 推荐模块
+
+**POST `/ai-recommend` —— 发起 AI 穿搭推荐(需鉴权)**
+
+请求(json):
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| scene_type | int | 是 | 1=每日推荐 2=个性化场景 |
+| scene | string | 否 | 用户自定义场景描述(scene_type=2 时使用) |
+
+响应 `data`:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| outfit_uuid | string | 生成结果 UUID |
+| outfit_name | string | 穿搭名称 |
+| outfit_desc | string | 穿搭描述 |
+| scene | string | 场景 |
+| source | int | 1=用户创建 2=AI 推荐 |
+| status | int | 1=正常 2=封禁 3=待用户保存 |
+| outfit_item | array | 衣物列表,元素:`{"clothes_uuid": string}` |
+
+相关错误码:90400 AI 推荐失败、90401 AI 推荐次数已达上限、80000 额度不足。
+
+**POST `/ai-recommend/save` —— 保存 AI 推荐为我的穿搭(需鉴权)**
+
+请求(json):
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| outfit_uuid | string | 是 | AI 推荐结果 UUID(由 `/ai-recommend` 返回) |
+
+响应:`data` 为空对象 `{}`。
+
+### 错误码
+
+业务错误码按业务域分段(`pkg/errcode`),常见分段如下:
+
+| 段位 | 业务域 |
+| --- | --- |
+| 0 | 成功 |
+| 10000–10999 | 通用/参数(10000 参数错误、10001 不存在、10003 限流) |
+| 11000–11999 | 鉴权(11000 未授权、11001 Token 鉴权失败、11002 Token 已过期、11004 Token 缺失) |
+| 12000–12999 | 权限(12000 无权限) |
+| 19000–19999 | 系统错误(19000 服务器错误、19001 数据库、19002 缓存) |
+| 20000–29999 | 用户(21000 用户名或密码错误、21002 微信登录失败、22000 用户不存在) |
+| 30000–39999 | 上传(30000 空文件、30001 扩展名不允许、30002 超限、39001 抠图失败、39002 分类失败) |
+| 40000–49999 | 穿搭 |
+| 50000–59999 | 衣物 |
+| 60000–69999 | 商品 |
+| 70000–79999 | 订单/退款(70000 订单不存在、70004 订单已过期) |
+| 80000–89999 | 额度/VIP(80000 额度不足、80004 广告激励未开启) |
+| 90000–99999 | 通知/分享/社区/星座/AI 推荐(90000 通知不存在、90400 AI 推荐失败、90401 AI 推荐次数已达上限) |
+
+### 核心业务调用流程
+
+> 以下为前后端联调时的关键调用时序(业务视角的状态机与业务规则见上文 [业务流程](#业务流程))。参与方:小程序前端、后端服务、微信服务端、AI 推理服务与大模型网关。
+
+#### 流程 1:登录与 Token 刷新
+
+```mermaid
+sequenceDiagram
+ participant F as 小程序前端
+ participant B as 后端服务
+ participant W as 微信服务端
+
+ F->>B: POST /user/wxlogin {code}
+ B->>W: code2session(code)
+ W-->>B: openid / session_key
+ B->>B: 首次登录:注册用户 + 初始化免费额度
+ B-->>F: {access_token, refresh_token, expire_time, user_info}
+
+ Note over F,B: access_token 过期(业务码 11002 / HTTP 401)
+
+ F->>B: POST /user/refresh-token {refresh_token, access_token?}
+ B-->>F: 新 {access_token, refresh_token, expire_time}
+ F->>B: 携带新 token 重试原请求
+```
+
+1. 前端调用 `wx.login` 获取 code,POST 到 `/user/wxlogin`;后端经微信 code2session 换取 openid,首次登录自动注册并初始化免费额度。
+2. 前端保存双 token;此后所有需鉴权接口携带 `Authorization: Bearer `。
+3. 收到业务码 11002(HTTP 401)时调用 `/user/refresh-token` 换新(可带上旧 access_token 使其立即吊销),成功后用新 token 重试。
+
+#### 流程 2:衣物上传与 AI 处理轮询
+
+```mermaid
+sequenceDiagram
+ participant F as 小程序前端
+ participant B as 后端服务
+ participant M as 抠图服务(8001)
+ participant C as 分类服务(8000)
+
+ F->>B: POST /clothes/upload (multipart: file)
+ B->>B: 校验扩展名/大小/magic bytes + 扣上传额度
+ B-->>F: {clothes_uuid, image_process_status: 3 排队中}
+
+ Note over B,M: 异步:排队(3) → 抠图(1) → 分类(2) → 成功(4)/失败(5)
+
+ B->>M: 抠图请求(失败重试 ≤ MaxRetry)
+ B->>C: 15 维分类请求(失败重试 ≤ MaxRetry)
+
+ loop 前端轮询(建议 1~2 秒间隔)
+ F->>B: GET /clothes/:uuid/status
+ B-->>F: {image_process_status, fail_reason}
+ end
+
+ alt 状态为 4 成功
+ F->>B: GET /clothes/:uuid 读取识别结果
+ else 状态为 5 失败
+ Note over F: 查看 fail_reason,可重新上传
+ end
+```
+
+1. 上传接口**同步返回**(不等待 AI 完成):`clothes_uuid` + 初始状态 `3`(排队中)。
+2. 后端异步流水线:3 排队 → 1 抠图中(BiRefNet,端口 8001)→ 2 分类中(Fashion-CLIP,端口 8000)→ 4 成功 / 5 失败。
+3. 前端轮询 `GET /clothes/:uuid/status`(建议间隔 1~2 秒),直到状态为 4 或 5。
+4. 成功后调用 `GET /clothes/:uuid` 读取识别结果,可用 `PUT /clothes/:uuid` 修正识别值(注意该接口主颜色字段名为 `color`)。
+5. 失败时根据 `fail_reason` 判断,可重新上传。
+
+#### 流程 3:AI 穿搭推荐与保存
+
+```mermaid
+sequenceDiagram
+ participant F as 小程序前端
+ participant B as 后端服务
+ participant L as 大模型网关(Tokenhub)
+
+ F->>B: POST /ai-recommend {scene_type, scene?}
+ B->>B: 校验并扣减 AI 额度
+ B->>L: 结合星座运势 + 衣橱衣物生成推荐
+ L-->>B: 推荐结果
+ B->>B: 落库(status=3 待用户保存)
+ B-->>F: {outfit_uuid, outfit_name, outfit_item...}
+
+ F->>F: 用户预览、确认
+ F->>B: POST /ai-recommend/save {outfit_uuid}
+ B-->>F: 保存成功,转入我的穿搭
+```
+
+1. 请求必带 `scene_type`(1=每日推荐 2=个性化场景),每次调用扣减一次 AI 额度,不足返回 80000 / 90401。
+2. 推荐结果先落库为"待用户保存"状态(status=3),响应中返回完整推荐内容供预览。
+3. 用户确认后调用 `/ai-recommend/save`,结果转为用户正式穿搭;未保存的结果不进入穿搭列表。
+
+#### 流程 4:创建订单到支付完成
+
+```mermaid
+sequenceDiagram
+ participant F as 小程序前端
+ participant B as 后端服务
+ participant W as 微信支付
+
+ F->>B: POST /order {goods_code}
+ B->>B: 按商品售价计算金额,创建订单(15 分钟有效)
+ B->>W: 微信 JSAPI 下单
+ B-->>F: {order_no, pay_amount, expire_time, pay_params}
+
+ F->>W: wx.requestPayment(pay_params)
+ W-->>B: POST /order/pay/notify(验签 + 解密)
+ B->>B: 订单置为已支付,发放权益(会员/额度)
+
+ F->>B: GET /order 确认支付结果
+ B-->>F: {order_status: 2 已支付}
+
+ opt 超时未支付
+ B->>B: 后台任务每分钟扫描,超 15 分钟置为已过期
+ end
+```
+
+1. 创建订单只传 `goods_code`,金额由服务端按商品售价计算(单位:分),前端不传金额。
+2. 前端用响应中的 `pay_params` 调 `wx.requestPayment`;`expire_time` 用于支付倒计时(15 分钟)。
+3. 支付结果以微信回调为准(公开路由,服务端验签 + 解密);前端支付完成后应通过 `GET /order` 确认 `order_status=2`,不要自行判定支付成功。
+4. 超时未支付订单由后台任务每分钟扫描并自动置为已过期(4)。
+5. 退款:`POST /order/refund` 申请 → 微信异步处理 → 退款回调更新退款状态;可通过订单列表查看 `refund_time` / `refund_amount`。
+
+#### 流程 5:点赞 / 收藏切换
+
+```mermaid
+sequenceDiagram
+ participant F as 小程序前端
+ participant B as 后端服务
+
+ F->>B: GET /like/:targetUUID 获取初始状态
+ B-->>F: {is_liked, like_count}
+
+ F->>B: POST /like {target_uuid, action: 1 点赞}
+ B-->>F: 成功(幂等)
+
+ F->>B: POST /like {target_uuid, action: 2 取消}
+ B-->>F: 成功(幂等)
+
+ Note over F,B: 收藏同理:POST /collect {target_uuid, action}
+```
+
+1. 进入详情页先调 `GET /like/:targetUUID` 获取 `is_liked` / `like_count` 渲染状态。
+2. 点击切换调用 `POST /like`(`action` 1=点赞 2=取消),服务端幂等,重复提交安全。
+3. 收藏同理:`GET /collect` 查看收藏列表,`POST /collect` 切换收藏状态。
+
+### 接口调用示例
+
+> 以下示例中的 token、UUID、时间戳等业务数据均为示意值;实际以接口返回为准。
+
+**微信登录:**
+
+```bash
+curl -X POST http://localhost:80/api/v1/user/wxlogin \
+ -H "Content-Type: application/json" \
+ -d '{"code": "0b3abcxxxxxxxx"}'
+```
+
+```json
+{
+ "code": 0,
+ "msg": "成功",
+ "data": {
+ "access_token": "eyJhbGciOiJIUzI1NiIs...",
+ "refresh_token": "eyJhbGciOiJIUzI1NiIs...",
+ "expire_time": 1756742400,
+ "user_info": {
+ "username": "微信用户",
+ "avatar": "",
+ "sex": 0,
+ "birth_day": 0,
+ "phone": "",
+ "is_new_user": true,
+ "status": 1,
+ "ban_reason": "",
+ "ban_start": 0,
+ "ban_expire": 0,
+ "vip_type": 4,
+ "vip_expire": 1744761600
+ }
+ }
+}
+```
+
+**上传衣物图片(multipart):**
+
+```bash
+curl -X POST http://localhost:80/api/v1/clothes/upload \
+ -H "Authorization: Bearer " \
+ -F "file=@/path/to/t-shirt.png"
+```
+
+```json
+{
+ "code": 0,
+ "msg": "成功",
+ "data": {
+ "clothes_uuid": "c8f2e1a0-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
+ "image_process_status": 3
+ }
+}
+```
+
+**轮询处理状态:**
+
+```bash
+curl http://localhost:80/api/v1/clothes/c8f2e1a0-xxxx-xxxx-xxxx-xxxxxxxxxxxx/status \
+ -H "Authorization: Bearer "
+```
+
+```json
+{
+ "code": 0,
+ "msg": "成功",
+ "data": {
+ "image_process_status": 4,
+ "fail_reason": ""
+ }
+}
+```
+
+**创建订单(微信支付):**
+
+```bash
+curl -X POST http://localhost:80/api/v1/order \
+ -H "Authorization: Bearer " \
+ -H "Content-Type: application/json" \
+ -d '{"goods_code": "<商品编码,如 AI_3>"}'
+```
+
+```json
+{
+ "code": 0,
+ "msg": "成功",
+ "data": {
+ "order_no": "20260907000123",
+ "pay_amount": 990,
+ "expire_time": 1756743300,
+ "pay_params": {
+ "timeStamp": "1756742400",
+ "nonceStr": "1a2b3c4d5e6f",
+ "package": "prepay_id=wx09153000123456",
+ "signType": "RSA",
+ "paySign": "xxxxxxxxxxxxxxxx"
+ }
+ }
+}
+```
+
+**AI 穿搭推荐:**
+
+```bash
+curl -X POST http://localhost:80/api/v1/ai-recommend \
+ -H "Authorization: Bearer " \
+ -H "Content-Type: application/json" \
+ -d '{"scene_type": 2, "scene": "参加朋友的婚礼"}'
+```
+
+```json
+{
+ "code": 0,
+ "msg": "成功",
+ "data": {
+ "outfit_uuid": "of-xxxxxxxxxxxx",
+ "outfit_name": "婚礼宾客穿搭",
+ "outfit_desc": "简约大方,正式又不失亲和力",
+ "scene": "参加朋友的婚礼",
+ "source": 2,
+ "status": 3,
+ "outfit_item": [
+ { "clothes_uuid": "c8f2e1a0-xxxx-xxxx-xxxx-xxxxxxxxxxxx" }
+ ]
+ }
+}
+```
+
+## 可观测性与后台任务
+
+### 可观测性
+
+- **Trace/Metric/Log** 三信号通过 OTLP gRPC 上报(`Otel.Enabled=true` 时);初始化失败自动降级为 no-op,不阻断启动。
+- 请求链路:每请求生成 `X-Request-ID`,Gin 中间件自动开启 server span,日志中注入 `trace_id`/`span_id`。
+- 结构化日志(JSON)同时输出到文件(lumberjack 滚动压缩)与 stdout;请求日志对 query 中的 `token`/`code`/`secret`/`password` 等参数自动脱敏。
+
+### 后台任务
+
+| 任务 | 周期 | 说明 |
+| --- | --- | --- |
+| 每日运势生成 | 每日 0 点 + 启动兜底 | 大模型生成十二星座运势;Redis 分布式锁保证多实例仅执行一次;当日不足 12 条时按需兜底生成 |
+| 订单超时过期 | 每 60 秒 | 将超过 15 分钟未支付的订单批量置为"已过期" |
+| 权益发放补偿 | 每 60 秒 | 重试发放失败的权益任务(恢复僵死任务、幂等防重复发放、超限告警) |
+
+## 部署提示
+
+- **工作目录**:服务启动时必须位于项目根目录(配置路径固定为 `configs/config.yaml`),容器化部署时注意挂载配置文件与日志目录。
+- **多实例**:支持水平扩展;写操作幂等保护与关键后台任务(运势生成、权益补偿)均通过 Redis 分布式锁协调。
+- **推理服务**:`thirdparty/` 下的两个 Python 服务建议独立部署(GPU 优先),并通过 `MattingModel` / `ClassificationModel` 配置段指向其地址。
+- **可观测性后端**:按需配置 `Otel.ExporterEndpoint` 指向 OTLP Collector;无需可观测性时设置 `Enabled: false` 即可零开销关闭。
+
+## 安全注意事项
+
+- `JWT.SecretKey` 必须设置为强随机值,代码会强制校验,为空直接启动失败;请勿使用默认占位值。
+- `WechatPay.APIv3Key` 必须为 32 字节随机密钥;微信支付/退款回调依赖官方 SDK 验签与解密,勿绕过。
+- 所有密钥仅存放于 `configs/config.yaml`(已被 `.gitignore` 忽略),严禁提交到版本库或写入数据库。
+- 后端对 AI 大模型输入(衣物信息、用户数据)做了清洗与截断,防 prompt 注入;上传文件除扩展名外还校验 magic bytes,防伪造类型。
+- 生产环境请将 `Server.RunMode` 设为 `release`,并通过反向代理对外提供 HTTPS。
diff --git a/Stitch.md b/Stitch.md
new file mode 100644
index 0000000..21d038d
--- /dev/null
+++ b/Stitch.md
@@ -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 件衣物」;名称为空 →「请填写穿搭名称」。保存成功后 轻提示「已保存」并返回穿搭详情。
diff --git a/cmd/migrate/main.go b/cmd/migrate/main.go
new file mode 100644
index 0000000..750fd41
--- /dev/null
+++ b/cmd/migrate/main.go
@@ -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
+}
diff --git a/configs/config.example.yaml b/configs/config.example.yaml
new file mode 100644
index 0000000..2f201b3
--- /dev/null
+++ b/configs/config.example.yaml
@@ -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 # 广告激励赠送的衣柜分享额度(每日)
diff --git a/design/zt-closet-er.drawio b/design/zt-closet-er.drawio
new file mode 100644
index 0000000..cd3aa96
--- /dev/null
+++ b/design/zt-closet-er.drawio
@@ -0,0 +1,179 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/代码审查报告-三次check.md b/docs/代码审查报告-三次check.md
new file mode 100644
index 0000000..88bb6e0
--- /dev/null
+++ b/docs/代码审查报告-三次check.md
@@ -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` 中多处已标注「以官方枚举为准,联调时校正」)。
diff --git a/docs/代码审查报告-二次check.md b/docs/代码审查报告-二次check.md
new file mode 100644
index 0000000..e03658c
--- /dev/null
+++ b/docs/代码审查报告-二次check.md
@@ -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` 等接口在官方文档中的字段与状态枚举。
diff --git a/global/cos.go b/global/cos.go
new file mode 100644
index 0000000..1293f6a
--- /dev/null
+++ b/global/cos.go
@@ -0,0 +1,7 @@
+package global
+
+import "github.com/tencentyun/cos-go-sdk-v5"
+
+var (
+ CosClient *cos.Client
+)
diff --git a/global/mysql.go b/global/mysql.go
new file mode 100644
index 0000000..34f5ee4
--- /dev/null
+++ b/global/mysql.go
@@ -0,0 +1,5 @@
+package global
+
+import "gorm.io/gorm"
+
+var MysqlEngine *gorm.DB
diff --git a/global/otel.go b/global/otel.go
new file mode 100644
index 0000000..1995ccd
--- /dev/null
+++ b/global/otel.go
@@ -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
+)
diff --git a/global/redis.go b/global/redis.go
new file mode 100644
index 0000000..b52f530
--- /dev/null
+++ b/global/redis.go
@@ -0,0 +1,5 @@
+package global
+
+import "github.com/redis/go-redis/v9"
+
+var RedisEngine *redis.Client
diff --git a/global/setting.go b/global/setting.go
new file mode 100644
index 0000000..10b7c85
--- /dev/null
+++ b/global/setting.go
@@ -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
+)
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..cff22f4
--- /dev/null
+++ b/go.mod
@@ -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
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..0c4b6c5
--- /dev/null
+++ b/go.sum
@@ -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=
diff --git a/internal/dto/adreward.go b/internal/dto/adreward.go
new file mode 100644
index 0000000..836f8d0
--- /dev/null
+++ b/internal/dto/adreward.go
@@ -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"` // 发放数量
+}
diff --git a/internal/dto/airecommend.go b/internal/dto/airecommend.go
new file mode 100644
index 0000000..764a449
--- /dev/null
+++ b/internal/dto/airecommend.go
@@ -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
+}
diff --git a/internal/dto/closet.go b/internal/dto/closet.go
new file mode 100644
index 0000000..46c41ab
--- /dev/null
+++ b/internal/dto/closet.go
@@ -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"`
+}
diff --git a/internal/dto/clothes.go b/internal/dto/clothes.go
new file mode 100644
index 0000000..1da82a4
--- /dev/null
+++ b/internal/dto/clothes.go
@@ -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"`
+}
diff --git a/internal/dto/collect.go b/internal/dto/collect.go
new file mode 100644
index 0000000..f774497
--- /dev/null
+++ b/internal/dto/collect.go
@@ -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"` // 收藏数
+}
diff --git a/internal/dto/common.go b/internal/dto/common.go
new file mode 100644
index 0000000..b108471
--- /dev/null
+++ b/internal/dto/common.go
@@ -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"`
+}
diff --git a/internal/dto/goods.go b/internal/dto/goods.go
new file mode 100644
index 0000000..83617fd
--- /dev/null
+++ b/internal/dto/goods.go
@@ -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"`
+}
diff --git a/internal/dto/horoscope.go b/internal/dto/horoscope.go
new file mode 100644
index 0000000..398a028
--- /dev/null
+++ b/internal/dto/horoscope.go
@@ -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"` // 今日幸运数字
+}
diff --git a/internal/dto/like.go b/internal/dto/like.go
new file mode 100644
index 0000000..fa77db1
--- /dev/null
+++ b/internal/dto/like.go
@@ -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"` // 点赞数
+}
diff --git a/internal/dto/notification.go b/internal/dto/notification.go
new file mode 100644
index 0000000..868debf
--- /dev/null
+++ b/internal/dto/notification.go
@@ -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"`
+}
diff --git a/internal/dto/order.go b/internal/dto/order.go
new file mode 100644
index 0000000..78f560f
--- /dev/null
+++ b/internal/dto/order.go
@@ -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"`
+}
diff --git a/internal/dto/outfit.go b/internal/dto/outfit.go
new file mode 100644
index 0000000..c235a02
--- /dev/null
+++ b/internal/dto/outfit.go
@@ -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{}
diff --git a/internal/dto/quota.go b/internal/dto/quota.go
new file mode 100644
index 0000000..57a3262
--- /dev/null
+++ b/internal/dto/quota.go
@@ -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"`
+}
diff --git a/internal/dto/user.go b/internal/dto/user.go
new file mode 100644
index 0000000..b5ad4b2
--- /dev/null
+++ b/internal/dto/user.go
@@ -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过期时间(秒)
+}
diff --git a/internal/middleware/cors.go b/internal/middleware/cors.go
new file mode 100644
index 0000000..9ac1ded
--- /dev/null
+++ b/internal/middleware/cors.go
@@ -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
+}
diff --git a/internal/middleware/logger.go b/internal/middleware/logger.go
new file mode 100644
index 0000000..273ecaf
--- /dev/null
+++ b/internal/middleware/logger.go
@@ -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(),
+ )
+ }
+}
diff --git a/internal/middleware/lzcwyjwt.go b/internal/middleware/lzcwyjwt.go
new file mode 100644
index 0000000..b0b83f9
--- /dev/null
+++ b/internal/middleware/lzcwyjwt.go
@@ -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
+}
diff --git a/internal/middleware/otel.go b/internal/middleware/otel.go
new file mode 100644
index 0000000..7b92aea
--- /dev/null
+++ b/internal/middleware/otel.go
@@ -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)
+}
diff --git a/internal/middleware/requestid.go b/internal/middleware/requestid.go
new file mode 100644
index 0000000..6c1b963
--- /dev/null
+++ b/internal/middleware/requestid.go
@@ -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
+}
diff --git a/internal/mysqldao/auditlog.go b/internal/mysqldao/auditlog.go
new file mode 100644
index 0000000..a9f79c5
--- /dev/null
+++ b/internal/mysqldao/auditlog.go
@@ -0,0 +1,79 @@
+package mysqldao
+
+import (
+ "context"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
+ "gorm.io/gorm"
+)
+
+// ClosetAuditLogDao 衣柜分享审核日志数据访问
+type ClosetAuditLogDao struct {
+ db *gorm.DB
+}
+
+// NewClosetAuditLogDao 创建衣柜分享审核日志 DAO
+func NewClosetAuditLogDao() *ClosetAuditLogDao {
+ return &ClosetAuditLogDao{db: global.MysqlEngine}
+}
+
+// ClosetAuditLogCreate 创建衣柜分享审核日志
+func (d *ClosetAuditLogDao) ClosetAuditLogCreate(ctx context.Context, log *mysqlmodel.ClosetAuditLog) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.ClosetAuditLogCreate")
+ defer span.End()
+
+ if err := d.db.WithContext(ctx).Create(log).Error; err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// ClosetAuditLogListByTargetID 查询目标衣柜分享的审核日志
+func (d *ClosetAuditLogDao) ClosetAuditLogListByTargetID(ctx context.Context, targetID int64) ([]mysqlmodel.ClosetAuditLog, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.ClosetAuditLogListByTargetID")
+ defer span.End()
+
+ var list []mysqlmodel.ClosetAuditLog
+ if err := d.db.WithContext(ctx).Where("target_id = ?", targetID).Find(&list).Error; err != nil {
+ span.RecordError(err)
+ return nil, err
+ }
+ return list, nil
+}
+
+// OutfitAuditLogDao 穿搭审核日志数据访问
+type OutfitAuditLogDao struct {
+ db *gorm.DB
+}
+
+// NewOutfitAuditLogDao 创建穿搭审核日志 DAO
+func NewOutfitAuditLogDao() *OutfitAuditLogDao {
+ return &OutfitAuditLogDao{db: global.MysqlEngine}
+}
+
+// OutfitAuditLogCreate 创建穿搭审核日志
+func (d *OutfitAuditLogDao) OutfitAuditLogCreate(ctx context.Context, log *mysqlmodel.OutfitAuditLog) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitAuditLogCreate")
+ defer span.End()
+
+ if err := d.db.WithContext(ctx).Create(log).Error; err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// OutfitAuditLogListByTargetID 查询目标穿搭的审核日志
+func (d *OutfitAuditLogDao) OutfitAuditLogListByTargetID(ctx context.Context, targetID int64) ([]mysqlmodel.OutfitAuditLog, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitAuditLogListByTargetID")
+ defer span.End()
+
+ var list []mysqlmodel.OutfitAuditLog
+ if err := d.db.WithContext(ctx).Where("target_id = ?", targetID).Find(&list).Error; err != nil {
+ span.RecordError(err)
+ return nil, err
+ }
+ return list, nil
+}
diff --git a/internal/mysqldao/benefit_grant_task.go b/internal/mysqldao/benefit_grant_task.go
new file mode 100644
index 0000000..d7bbe0d
--- /dev/null
+++ b/internal/mysqldao/benefit_grant_task.go
@@ -0,0 +1,121 @@
+package mysqldao
+
+import (
+ "context"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
+ "gorm.io/gorm"
+)
+
+// BenefitGrantTaskDao 权益发放补偿任务数据访问
+type BenefitGrantTaskDao struct {
+ db *gorm.DB
+}
+
+// NewBenefitGrantTaskDao 创建补偿任务 DAO
+func NewBenefitGrantTaskDao() *BenefitGrantTaskDao {
+ return &BenefitGrantTaskDao{db: global.MysqlEngine}
+}
+
+// BenefitGrantTaskCreate 创建补偿任务,依赖唯一索引 (order_no, benefit_type) 防重复发放。
+func (d *BenefitGrantTaskDao) BenefitGrantTaskCreate(ctx context.Context, task *mysqlmodel.BenefitGrantTask) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.BenefitGrantTaskCreate")
+ defer span.End()
+
+ if err := d.db.WithContext(ctx).Create(task).Error; err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// BenefitGrantTaskGetByOrderNoAndType 按订单号+权益类型查询补偿任务。
+func (d *BenefitGrantTaskDao) BenefitGrantTaskGetByOrderNoAndType(ctx context.Context, orderNo string, benefitType int8) (*mysqlmodel.BenefitGrantTask, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.BenefitGrantTaskGetByOrderNoAndType")
+ defer span.End()
+
+ var task mysqlmodel.BenefitGrantTask
+ if err := d.db.WithContext(ctx).
+ Where("order_no = ? AND benefit_type = ?", orderNo, benefitType).
+ First(&task).Error; err != nil {
+ span.RecordError(err)
+ return nil, err
+ }
+ return &task, nil
+}
+
+// BenefitGrantTaskUpdateByID 按主键更新补偿任务字段。
+func (d *BenefitGrantTaskDao) BenefitGrantTaskUpdateByID(ctx context.Context, id int64, updates map[string]any) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.BenefitGrantTaskUpdateByID")
+ defer span.End()
+
+ if err := d.db.WithContext(ctx).Model(&mysqlmodel.BenefitGrantTask{}).Where("id = ?", id).Updates(updates).Error; err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// BenefitGrantTaskListPending 查询待发放的补偿任务(status=0),按主键升序。
+func (d *BenefitGrantTaskDao) BenefitGrantTaskListPending(ctx context.Context, limit int) ([]mysqlmodel.BenefitGrantTask, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.BenefitGrantTaskListPending")
+ defer span.End()
+
+ var list []mysqlmodel.BenefitGrantTask
+ if err := d.db.WithContext(ctx).
+ Where("status = ?", 0).
+ Order("id ASC").
+ Limit(limit).
+ Find(&list).Error; err != nil {
+ span.RecordError(err)
+ return nil, err
+ }
+ return list, nil
+}
+
+// BenefitGrantTaskReset 将失败任务重置为待发放(首次发放遇到 failed 记录时复用)。
+// 保留 retry_count:不归零,保证重试上限与人工介入机制不被重置绕过;
+// 调用方应配合冷却期使用,避免高频回调无限重置。
+func (d *BenefitGrantTaskDao) BenefitGrantTaskReset(ctx context.Context, id int64) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.BenefitGrantTaskReset")
+ defer span.End()
+
+ if err := d.db.WithContext(ctx).Model(&mysqlmodel.BenefitGrantTask{}).
+ Where("id = ?", id).
+ Updates(map[string]any{"status": 0, "last_error": ""}).Error; err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// BenefitGrantTaskClaim 条件认领任务(待发放/失败 -> 发放中),返回是否认领成功,防并发重复发放。
+func (d *BenefitGrantTaskDao) BenefitGrantTaskClaim(ctx context.Context, id int64) (bool, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.BenefitGrantTaskClaim")
+ defer span.End()
+
+ res := d.db.WithContext(ctx).Model(&mysqlmodel.BenefitGrantTask{}).
+ Where("id = ? AND status IN (?, ?)", id, 0, 2).
+ Update("status", 3)
+ if res.Error != nil {
+ span.RecordError(res.Error)
+ return false, res.Error
+ }
+ return res.RowsAffected > 0, nil
+}
+
+// BenefitGrantTaskRecoverStale 将「发放中」且超过一定时间未完成的任务重置为待发放(认领后崩溃的恢复)。
+func (d *BenefitGrantTaskDao) BenefitGrantTaskRecoverStale(ctx context.Context, beforeUpdatedAt int64) (int64, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.BenefitGrantTaskRecoverStale")
+ defer span.End()
+
+ res := d.db.WithContext(ctx).Model(&mysqlmodel.BenefitGrantTask{}).
+ Where("status = ? AND updated_at < ?", 3, beforeUpdatedAt).
+ Update("status", 0)
+ if res.Error != nil {
+ span.RecordError(res.Error)
+ return 0, res.Error
+ }
+ return res.RowsAffected, nil
+}
diff --git a/internal/mysqldao/closet.go b/internal/mysqldao/closet.go
new file mode 100644
index 0000000..d43c9d5
--- /dev/null
+++ b/internal/mysqldao/closet.go
@@ -0,0 +1,201 @@
+package mysqldao
+
+import (
+ "context"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+)
+
+// ClosetShareDao 衣柜分享数据访问
+type ClosetShareDao struct {
+ db *gorm.DB
+}
+
+// NewClosetShareDao 创建衣柜分享 DAO
+func NewClosetShareDao() *ClosetShareDao {
+ return &ClosetShareDao{db: global.MysqlEngine}
+}
+
+// ClosetShareCreate 创建分享
+func (d *ClosetShareDao) ClosetShareCreate(ctx context.Context, share *mysqlmodel.ClosetShare) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.ClosetShareCreate")
+ defer span.End()
+
+ if err := d.db.WithContext(ctx).Create(share).Error; err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// ClosetShareUpdateStatus 更新分享状态(取消/封禁)
+func (d *ClosetShareDao) ClosetShareUpdateStatus(ctx context.Context, shareUUID string, status int8) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.ClosetShareUpdateStatus")
+ defer span.End()
+
+ if err := d.db.WithContext(ctx).Model(&mysqlmodel.ClosetShare{}).Where("share_uuid = ?", shareUUID).UpdateColumn("status", status).Error; err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// ClosetShareGetByUUID 按 UUID 查询分享
+func (d *ClosetShareDao) ClosetShareGetByUUID(ctx context.Context, shareUUID string) (*mysqlmodel.ClosetShare, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.ClosetShareGetByUUID")
+ defer span.End()
+
+ var share mysqlmodel.ClosetShare
+ if err := d.db.WithContext(ctx).Where("share_uuid = ?", shareUUID).First(&share).Error; err != nil {
+ span.RecordError(err)
+ return nil, err
+ }
+ return &share, nil
+}
+
+// ClosetShareListByUserID 分页查询我的分享(status 为 0 不筛选)
+func (d *ClosetShareDao) ClosetShareListByUserID(ctx context.Context, userID int64, status int8, page, pageSize int) ([]mysqlmodel.ClosetShare, int64, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.ClosetShareListByUserID")
+ defer span.End()
+
+ var list []mysqlmodel.ClosetShare
+ var total int64
+ q := d.db.WithContext(ctx).Model(&mysqlmodel.ClosetShare{}).Where("user_id = ?", userID)
+ if status != 0 {
+ q = q.Where("status = ?", status)
+ }
+ if err := q.Count(&total).Error; err != nil {
+ span.RecordError(err)
+ return nil, 0, err
+ }
+ if err := q.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&list).Error; err != nil {
+ span.RecordError(err)
+ return nil, 0, err
+ }
+ return list, total, nil
+}
+
+// ClosetShareItemDao 分享明细数据访问
+type ClosetShareItemDao struct {
+ db *gorm.DB
+}
+
+// NewClosetShareItemDao 创建分享明细 DAO
+func NewClosetShareItemDao() *ClosetShareItemDao {
+ return &ClosetShareItemDao{db: global.MysqlEngine}
+}
+
+// ClosetShareItemCreateBatch 批量创建分享明细
+func (d *ClosetShareItemDao) ClosetShareItemCreateBatch(ctx context.Context, items []mysqlmodel.ClosetShareItem) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.ClosetShareItemCreateBatch")
+ defer span.End()
+
+ if len(items) == 0 {
+ return nil
+ }
+ if err := d.db.WithContext(ctx).Create(&items).Error; err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// ClosetShareItemDeleteByShareUUID 删除分享明细
+func (d *ClosetShareItemDao) ClosetShareItemDeleteByShareUUID(ctx context.Context, shareUUID string) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.ClosetShareItemDeleteByShareUUID")
+ defer span.End()
+
+ if err := d.db.WithContext(ctx).Where("share_uuid = ?", shareUUID).Delete(&mysqlmodel.ClosetShareItem{}).Error; err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// ClosetShareItemListByShareUUID 查询分享明细
+func (d *ClosetShareItemDao) ClosetShareItemListByShareUUID(ctx context.Context, shareUUID string) ([]mysqlmodel.ClosetShareItem, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.ClosetShareItemListByShareUUID")
+ defer span.End()
+
+ var list []mysqlmodel.ClosetShareItem
+ if err := d.db.WithContext(ctx).Where("share_uuid = ?", shareUUID).Find(&list).Error; err != nil {
+ span.RecordError(err)
+ return nil, err
+ }
+ return list, nil
+}
+
+// ClosetShareItemListByShareUUIDs 批量查询分享明细
+func (d *ClosetShareItemDao) ClosetShareItemListByShareUUIDs(ctx context.Context, shareUUIDs []string) ([]mysqlmodel.ClosetShareItem, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.ClosetShareItemListByShareUUIDs")
+ defer span.End()
+
+ var list []mysqlmodel.ClosetShareItem
+ if len(shareUUIDs) == 0 {
+ return list, nil
+ }
+ if err := d.db.WithContext(ctx).Where("share_uuid IN ?", shareUUIDs).Find(&list).Error; err != nil {
+ span.RecordError(err)
+ return nil, err
+ }
+ return list, nil
+}
+
+// ClosetShareReceiverDao 衣柜分享接收人数据访问
+type ClosetShareReceiverDao struct {
+ db *gorm.DB
+}
+
+// NewClosetShareReceiverDao 创建衣柜分享接收人 DAO
+func NewClosetShareReceiverDao() *ClosetShareReceiverDao {
+ return &ClosetShareReceiverDao{db: global.MysqlEngine}
+}
+
+// ClosetShareReceiverUpsert 幂等记录接收关系:唯一索引冲突时不做任何事,避免重复记录。
+func (d *ClosetShareReceiverDao) ClosetShareReceiverUpsert(ctx context.Context, shareUUID string, receiverUserID int64) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.ClosetShareReceiverUpsert")
+ defer span.End()
+
+ receiver := &mysqlmodel.ClosetShareReceiver{
+ ShareUUID: shareUUID,
+ ReceiverUserID: receiverUserID,
+ }
+ if err := d.db.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(receiver).Error; err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// ClosetShareReceivedList 联表分页查询某接收人收到的分享(status 为 0 不筛选)。
+func (d *ClosetShareReceiverDao) ClosetShareReceivedList(ctx context.Context, receiverUserID int64, status int8, page, pageSize int) ([]mysqlmodel.ClosetShare, int64, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.ClosetShareReceivedList")
+ defer span.End()
+
+ var list []mysqlmodel.ClosetShare
+ var total int64
+
+ q := d.db.WithContext(ctx).
+ Model(&mysqlmodel.ClosetShare{}).
+ Joins("JOIN closet_share_receivers r ON r.share_uuid = closet_shares.share_uuid").
+ Where("r.receiver_user_id = ?", receiverUserID)
+ if status != 0 {
+ q = q.Where("closet_shares.status = ?", status)
+ }
+ if err := q.Count(&total).Error; err != nil {
+ span.RecordError(err)
+ return nil, 0, err
+ }
+ if err := q.Select("closet_shares.*").
+ Order("r.id DESC").
+ Offset((page - 1) * pageSize).
+ Limit(pageSize).
+ Find(&list).Error; err != nil {
+ span.RecordError(err)
+ return nil, 0, err
+ }
+ return list, total, nil
+}
diff --git a/internal/mysqldao/clothes.go b/internal/mysqldao/clothes.go
new file mode 100644
index 0000000..fa3e7a6
--- /dev/null
+++ b/internal/mysqldao/clothes.go
@@ -0,0 +1,138 @@
+package mysqldao
+
+import (
+ "context"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
+ "gorm.io/gorm"
+)
+
+// ClothesDao 衣物数据访问
+type ClothesDao struct {
+ db *gorm.DB
+}
+
+// NewClothesDao 创建衣物 DAO
+func NewClothesDao() *ClothesDao {
+ return &ClothesDao{db: global.MysqlEngine}
+}
+
+// ClothesCreate 创建衣物
+func (d *ClothesDao) ClothesCreate(ctx context.Context, clothes *mysqlmodel.Clothes) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.ClothesCreate")
+ defer span.End()
+
+ if err := d.db.WithContext(ctx).Create(clothes).Error; err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// ClothesDeleteByUUID 按 UUID 软删除衣物
+func (d *ClothesDao) ClothesDeleteByUUID(ctx context.Context, clothesUUID string) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.ClothesDeleteByUUID")
+ defer span.End()
+
+ if err := d.db.WithContext(ctx).Where("clothes_uuid = ?", clothesUUID).Delete(&mysqlmodel.Clothes{}).Error; err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// ClothesUpdateByUUID 按 UUID 更新衣物字段
+func (d *ClothesDao) ClothesUpdateByUUID(ctx context.Context, clothesUUID string, updates map[string]any) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.ClothesUpdateByUUID")
+ defer span.End()
+
+ if err := d.db.WithContext(ctx).Model(&mysqlmodel.Clothes{}).Where("clothes_uuid = ?", clothesUUID).Updates(updates).Error; err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// ClothesGetByUUID 按 UUID 查询衣物
+func (d *ClothesDao) ClothesGetByUUID(ctx context.Context, clothesUUID string) (*mysqlmodel.Clothes, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.ClothesGetByUUID")
+ defer span.End()
+
+ var clothes mysqlmodel.Clothes
+ if err := d.db.WithContext(ctx).Where("clothes_uuid = ?", clothesUUID).First(&clothes).Error; err != nil {
+ span.RecordError(err)
+ return nil, err
+ }
+ return &clothes, nil
+}
+
+// ClothesGetByID 按主键 ID 查询衣物
+func (d *ClothesDao) ClothesGetByID(ctx context.Context, id int64) (*mysqlmodel.Clothes, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.ClothesGetByID")
+ defer span.End()
+
+ var clothes mysqlmodel.Clothes
+ if err := d.db.WithContext(ctx).First(&clothes, id).Error; err != nil {
+ span.RecordError(err)
+ return nil, err
+ }
+ return &clothes, nil
+}
+
+// ClothesGetByUUIDs 按 UUID 批量查询衣物
+func (d *ClothesDao) ClothesGetByUUIDs(ctx context.Context, uuids []string) ([]mysqlmodel.Clothes, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.ClothesGetByUUIDs")
+ defer span.End()
+
+ var list []mysqlmodel.Clothes
+ if len(uuids) == 0 {
+ return list, nil
+ }
+ if err := d.db.WithContext(ctx).Where("clothes_uuid IN ?", uuids).Find(&list).Error; err != nil {
+ span.RecordError(err)
+ return nil, err
+ }
+ return list, nil
+}
+
+// ClothesGetByIDs 按主键 ID 批量查询衣物
+func (d *ClothesDao) ClothesGetByIDs(ctx context.Context, ids []int64) ([]mysqlmodel.Clothes, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.ClothesGetByIDs")
+ defer span.End()
+
+ var list []mysqlmodel.Clothes
+ if len(ids) == 0 {
+ return list, nil
+ }
+ if err := d.db.WithContext(ctx).Where("id IN ?", ids).Find(&list).Error; err != nil {
+ span.RecordError(err)
+ return nil, err
+ }
+ return list, nil
+}
+
+// ClothesListByUserID 分页查询用户衣物(categoryMain/categorySub 为空不筛选)
+func (d *ClothesDao) ClothesListByUserID(ctx context.Context, userID int64, categoryMain, categorySub string, page, pageSize int) ([]mysqlmodel.Clothes, int64, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.ClothesListByUserID")
+ defer span.End()
+
+ var list []mysqlmodel.Clothes
+ var total int64
+ q := d.db.WithContext(ctx).Model(&mysqlmodel.Clothes{}).Where("user_id = ?", userID)
+ if categoryMain != "" {
+ q = q.Where("category_main = ?", categoryMain)
+ }
+ if categorySub != "" {
+ q = q.Where("category_sub = ?", categorySub)
+ }
+ if err := q.Count(&total).Error; err != nil {
+ span.RecordError(err)
+ return nil, 0, err
+ }
+ if err := q.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&list).Error; err != nil {
+ span.RecordError(err)
+ return nil, 0, err
+ }
+ return list, total, nil
+}
diff --git a/internal/mysqldao/collect.go b/internal/mysqldao/collect.go
new file mode 100644
index 0000000..24083f3
--- /dev/null
+++ b/internal/mysqldao/collect.go
@@ -0,0 +1,109 @@
+package mysqldao
+
+import (
+ "context"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+)
+
+// CollectDao 收藏数据访问
+type CollectDao struct {
+ db *gorm.DB
+}
+
+// NewCollectDao 创建收藏 DAO
+func NewCollectDao() *CollectDao {
+ return &CollectDao{db: global.MysqlEngine}
+}
+
+// CollectCreate 创建收藏记录
+func (d *CollectDao) CollectCreate(ctx context.Context, collect *mysqlmodel.Collect) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.CollectCreate")
+ defer span.End()
+
+ if err := d.db.WithContext(ctx).Create(collect).Error; err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// CollectUpsertOn 收藏幂等写入:唯一索引冲突时仅置 status=1,防并发重复插入。
+func (d *CollectDao) CollectUpsertOn(ctx context.Context, collect *mysqlmodel.Collect) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.CollectUpsertOn")
+ defer span.End()
+
+ if err := d.db.WithContext(ctx).Clauses(clause.OnConflict{
+ Columns: []clause.Column{{Name: "collect_type"}, {Name: "target_id"}, {Name: "user_id"}},
+ DoUpdates: clause.AssignmentColumns([]string{"status"}),
+ }).Create(collect).Error; err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// CollectUpdateStatus 更新收藏状态(1=已收藏 0=已取消)
+func (d *CollectDao) CollectUpdateStatus(ctx context.Context, id int64, status int8) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.CollectUpdateStatus")
+ defer span.End()
+
+ if err := d.db.WithContext(ctx).Model(&mysqlmodel.Collect{}).Where("id = ?", id).UpdateColumn("status", status).Error; err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// CollectGetByTargetAndUser 查询用户在目标上的收藏记录
+func (d *CollectDao) CollectGetByTargetAndUser(ctx context.Context, collectType int8, targetID, userID int64) (*mysqlmodel.Collect, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.CollectGetByTargetAndUser")
+ defer span.End()
+
+ var collect mysqlmodel.Collect
+ if err := d.db.WithContext(ctx).Where("collect_type = ? AND target_id = ? AND user_id = ?", collectType, targetID, userID).First(&collect).Error; err != nil {
+ span.RecordError(err)
+ return nil, err
+ }
+ return &collect, nil
+}
+
+// CollectCountByTarget 统计目标收藏数(仅 status=1)
+func (d *CollectDao) CollectCountByTarget(ctx context.Context, collectType int8, targetID int64) (int64, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.CollectCountByTarget")
+ defer span.End()
+
+ var count int64
+ if err := d.db.WithContext(ctx).Model(&mysqlmodel.Collect{}).
+ Where("collect_type = ? AND target_id = ? AND status = ?", collectType, targetID, int8(1)).
+ Count(&count).Error; err != nil {
+ span.RecordError(err)
+ return 0, err
+ }
+ return count, nil
+}
+
+// CollectListTargetIDsByUserPage 分页查询用户收藏的目标ID列表(仅 status=1),返回 ids 与总数。
+func (d *CollectDao) CollectListTargetIDsByUserPage(ctx context.Context, collectType int8, userID int64, page, pageSize int) ([]int64, int64, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.CollectListTargetIDsByUserPage")
+ defer span.End()
+
+ q := d.db.WithContext(ctx).Model(&mysqlmodel.Collect{}).
+ Where("collect_type = ? AND user_id = ? AND status = ?", collectType, userID, int8(1))
+
+ var total int64
+ if err := q.Count(&total).Error; err != nil {
+ span.RecordError(err)
+ return nil, 0, err
+ }
+
+ var ids []int64
+ if err := q.Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Pluck("target_id", &ids).Error; err != nil {
+ span.RecordError(err)
+ return nil, 0, err
+ }
+ return ids, total, nil
+}
diff --git a/internal/mysqldao/goods.go b/internal/mysqldao/goods.go
new file mode 100644
index 0000000..4330eaa
--- /dev/null
+++ b/internal/mysqldao/goods.go
@@ -0,0 +1,67 @@
+package mysqldao
+
+import (
+ "context"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
+ "gorm.io/gorm"
+)
+
+// GoodsDao 商品数据访问
+type GoodsDao struct {
+ db *gorm.DB
+}
+
+// NewGoodsDao 创建商品 DAO
+func NewGoodsDao() *GoodsDao {
+ return &GoodsDao{db: global.MysqlEngine}
+}
+
+// GoodsGetByCode 按商品编码查询商品
+func (d *GoodsDao) GoodsGetByCode(ctx context.Context, code string) (*mysqlmodel.Goods, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.GoodsGetByCode")
+ defer span.End()
+
+ var goods mysqlmodel.Goods
+ if err := d.db.WithContext(ctx).Where("goods_code = ?", code).First(&goods).Error; err != nil {
+ span.RecordError(err)
+ return nil, err
+ }
+ return &goods, nil
+}
+
+// GoodsGetByID 按主键 ID 查询商品
+func (d *GoodsDao) GoodsGetByID(ctx context.Context, id int64) (*mysqlmodel.Goods, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.GoodsGetByID")
+ defer span.End()
+
+ var goods mysqlmodel.Goods
+ if err := d.db.WithContext(ctx).First(&goods, id).Error; err != nil {
+ span.RecordError(err)
+ return nil, err
+ }
+ return &goods, nil
+}
+
+// GoodsList 分页查询上架商品(goodsType 为 0 不筛选)
+func (d *GoodsDao) GoodsList(ctx context.Context, goodsType int8, page, pageSize int) ([]mysqlmodel.Goods, int64, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.GoodsList")
+ defer span.End()
+
+ var list []mysqlmodel.Goods
+ var total int64
+ q := d.db.WithContext(ctx).Model(&mysqlmodel.Goods{}).Where("status = ?", int8(1))
+ if goodsType != 0 {
+ q = q.Where("goods_type = ?", goodsType)
+ }
+ if err := q.Count(&total).Error; err != nil {
+ span.RecordError(err)
+ return nil, 0, err
+ }
+ if err := q.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&list).Error; err != nil {
+ span.RecordError(err)
+ return nil, 0, err
+ }
+ return list, total, nil
+}
diff --git a/internal/mysqldao/horoscope.go b/internal/mysqldao/horoscope.go
new file mode 100644
index 0000000..81df436
--- /dev/null
+++ b/internal/mysqldao/horoscope.go
@@ -0,0 +1,127 @@
+package mysqldao
+
+import (
+ "context"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
+ "gorm.io/gorm"
+)
+
+// HoroscopeDao 运势数据访问
+type HoroscopeDao struct {
+ db *gorm.DB
+}
+
+// NewHoroscopeDao 创建运势 DAO
+func NewHoroscopeDao() *HoroscopeDao {
+ return &HoroscopeDao{db: global.MysqlEngine}
+}
+
+// HoroscopeCreate 创建运势记录
+func (d *HoroscopeDao) HoroscopeCreate(ctx context.Context, horoscope *mysqlmodel.Horoscope) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.HoroscopeCreate")
+ defer span.End()
+
+ if err := d.db.WithContext(ctx).Create(horoscope).Error; err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// HoroscopeGetByZodiacAndDate 按星座和日期查询运势(幂等)
+func (d *HoroscopeDao) HoroscopeGetByZodiacAndDate(ctx context.Context, zodiac string, statDate int64) (*mysqlmodel.Horoscope, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.HoroscopeGetByZodiacAndDate")
+ defer span.End()
+
+ var horoscope mysqlmodel.Horoscope
+ if err := d.db.WithContext(ctx).Where("zodiac = ? AND stat_date = ?", zodiac, statDate).First(&horoscope).Error; err != nil {
+ span.RecordError(err)
+ return nil, err
+ }
+ return &horoscope, nil
+}
+
+// HoroscopeGetByDate 按日期查询当日全部星座运势。
+func (d *HoroscopeDao) HoroscopeGetByDate(ctx context.Context, statDate int64) ([]mysqlmodel.Horoscope, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.HoroscopeGetByDate")
+ defer span.End()
+
+ var list []mysqlmodel.Horoscope
+ if err := d.db.WithContext(ctx).Where("stat_date = ?", statDate).Find(&list).Error; err != nil {
+ span.RecordError(err)
+ return nil, err
+ }
+ return list, nil
+}
+
+// AIHoroscopeFlowLogDao 星座运势 AI 流水数据访问
+type AIHoroscopeFlowLogDao struct {
+ db *gorm.DB
+}
+
+// NewAIHoroscopeFlowLogDao 创建星座运势 AI 流水 DAO
+func NewAIHoroscopeFlowLogDao() *AIHoroscopeFlowLogDao {
+ return &AIHoroscopeFlowLogDao{db: global.MysqlEngine}
+}
+
+// AIHoroscopeFlowLogCreate 创建星座运势 AI 流水
+func (d *AIHoroscopeFlowLogDao) AIHoroscopeFlowLogCreate(ctx context.Context, log *mysqlmodel.AIHoroscopeFlowLog) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.AIHoroscopeFlowLogCreate")
+ defer span.End()
+
+ if err := d.db.WithContext(ctx).Create(log).Error; err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// AIHoroscopeFlowLogGetByRequestID 按请求ID查询流水(幂等)
+func (d *AIHoroscopeFlowLogDao) AIHoroscopeFlowLogGetByRequestID(ctx context.Context, requestID string) (*mysqlmodel.AIHoroscopeFlowLog, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.AIHoroscopeFlowLogGetByRequestID")
+ defer span.End()
+
+ var log mysqlmodel.AIHoroscopeFlowLog
+ if err := d.db.WithContext(ctx).Where("request_id = ?", requestID).First(&log).Error; err != nil {
+ span.RecordError(err)
+ return nil, err
+ }
+ return &log, nil
+}
+
+// AIRecommendFlowLogDao AI 推荐流水数据访问
+type AIRecommendFlowLogDao struct {
+ db *gorm.DB
+}
+
+// NewAIRecommendFlowLogDao 创建 AI 推荐流水 DAO
+func NewAIRecommendFlowLogDao() *AIRecommendFlowLogDao {
+ return &AIRecommendFlowLogDao{db: global.MysqlEngine}
+}
+
+// AIRecommendFlowLogCreate 创建 AI 推荐流水
+func (d *AIRecommendFlowLogDao) AIRecommendFlowLogCreate(ctx context.Context, log *mysqlmodel.AIRecommendFlowLog) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.AIRecommendFlowLogCreate")
+ defer span.End()
+
+ if err := d.db.WithContext(ctx).Create(log).Error; err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// AIRecommendFlowLogGetByRequestID 按请求ID查询流水(幂等)
+func (d *AIRecommendFlowLogDao) AIRecommendFlowLogGetByRequestID(ctx context.Context, requestID string) (*mysqlmodel.AIRecommendFlowLog, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.AIRecommendFlowLogGetByRequestID")
+ defer span.End()
+
+ var log mysqlmodel.AIRecommendFlowLog
+ if err := d.db.WithContext(ctx).Where("request_id = ?", requestID).First(&log).Error; err != nil {
+ span.RecordError(err)
+ return nil, err
+ }
+ return &log, nil
+}
diff --git a/internal/mysqldao/like.go b/internal/mysqldao/like.go
new file mode 100644
index 0000000..0d9d3f6
--- /dev/null
+++ b/internal/mysqldao/like.go
@@ -0,0 +1,102 @@
+package mysqldao
+
+import (
+ "context"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+)
+
+// LikeDao 点赞数据访问
+type LikeDao struct {
+ db *gorm.DB
+}
+
+// NewLikeDao 创建点赞 DAO
+func NewLikeDao() *LikeDao {
+ return &LikeDao{db: global.MysqlEngine}
+}
+
+// LikeCreate 创建点赞记录
+func (d *LikeDao) LikeCreate(ctx context.Context, like *mysqlmodel.Like) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.LikeCreate")
+ defer span.End()
+
+ if err := d.db.WithContext(ctx).Create(like).Error; err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// LikeUpsertOn 点赞幂等写入:唯一索引冲突时仅置 status=1,防并发重复插入。
+func (d *LikeDao) LikeUpsertOn(ctx context.Context, like *mysqlmodel.Like) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.LikeUpsertOn")
+ defer span.End()
+
+ if err := d.db.WithContext(ctx).Clauses(clause.OnConflict{
+ Columns: []clause.Column{{Name: "like_type"}, {Name: "target_id"}, {Name: "user_id"}},
+ DoUpdates: clause.AssignmentColumns([]string{"status"}),
+ }).Create(like).Error; err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// LikeUpdateStatus 更新点赞状态(1=已点赞 0=已取消)
+func (d *LikeDao) LikeUpdateStatus(ctx context.Context, id int64, status int8) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.LikeUpdateStatus")
+ defer span.End()
+
+ if err := d.db.WithContext(ctx).Model(&mysqlmodel.Like{}).Where("id = ?", id).UpdateColumn("status", status).Error; err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// LikeGetByTargetAndUser 查询用户在目标上的点赞记录
+func (d *LikeDao) LikeGetByTargetAndUser(ctx context.Context, likeType int8, targetID, userID int64) (*mysqlmodel.Like, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.LikeGetByTargetAndUser")
+ defer span.End()
+
+ var like mysqlmodel.Like
+ if err := d.db.WithContext(ctx).Where("like_type = ? AND target_id = ? AND user_id = ?", likeType, targetID, userID).First(&like).Error; err != nil {
+ span.RecordError(err)
+ return nil, err
+ }
+ return &like, nil
+}
+
+// LikeCountByTarget 统计目标点赞数(仅 status=1)
+func (d *LikeDao) LikeCountByTarget(ctx context.Context, likeType int8, targetID int64) (int64, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.LikeCountByTarget")
+ defer span.End()
+
+ var count int64
+ if err := d.db.WithContext(ctx).Model(&mysqlmodel.Like{}).
+ Where("like_type = ? AND target_id = ? AND status = ?", likeType, targetID, int8(1)).
+ Count(&count).Error; err != nil {
+ span.RecordError(err)
+ return 0, err
+ }
+ return count, nil
+}
+
+// LikeListUserIDsByTarget 查询点赞用户ID列表(仅 status=1)
+func (d *LikeDao) LikeListUserIDsByTarget(ctx context.Context, likeType int8, targetID int64) ([]int64, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.LikeListUserIDsByTarget")
+ defer span.End()
+
+ var ids []int64
+ if err := d.db.WithContext(ctx).Model(&mysqlmodel.Like{}).
+ Where("like_type = ? AND target_id = ? AND status = ?", likeType, targetID, int8(1)).
+ Pluck("user_id", &ids).Error; err != nil {
+ span.RecordError(err)
+ return nil, err
+ }
+ return ids, nil
+}
diff --git a/internal/mysqldao/notification.go b/internal/mysqldao/notification.go
new file mode 100644
index 0000000..150d8be
--- /dev/null
+++ b/internal/mysqldao/notification.go
@@ -0,0 +1,114 @@
+package mysqldao
+
+import (
+ "context"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
+ "gorm.io/gorm"
+)
+
+// NotificationDao 用户通知数据访问
+type NotificationDao struct {
+ db *gorm.DB
+}
+
+// NewNotificationDao 创建通知 DAO
+func NewNotificationDao() *NotificationDao {
+ return &NotificationDao{db: global.MysqlEngine}
+}
+
+// NotificationCreate 创建通知
+func (d *NotificationDao) NotificationCreate(ctx context.Context, notification *mysqlmodel.UserNotification) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.NotificationCreate")
+ defer span.End()
+
+ if err := d.db.WithContext(ctx).Create(notification).Error; err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// NotificationUpdateReadByUUID 标记通知已读
+func (d *NotificationDao) NotificationUpdateReadByUUID(ctx context.Context, notificationUUID string) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.NotificationUpdateReadByUUID")
+ defer span.End()
+
+ if err := d.db.WithContext(ctx).Model(&mysqlmodel.UserNotification{}).
+ Where("notification_uuid = ?", notificationUUID).
+ Update("is_read", int8(2)).Error; err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// NotificationUpdateReadByUUIDIfUnread 条件标记已读(仅未读 -> 已读),返回是否更新成功,
+// 用于防并发重复扣减未读数。
+func (d *NotificationDao) NotificationUpdateReadByUUIDIfUnread(ctx context.Context, notificationUUID string) (bool, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.NotificationUpdateReadByUUIDIfUnread")
+ defer span.End()
+
+ res := d.db.WithContext(ctx).Model(&mysqlmodel.UserNotification{}).
+ Where("notification_uuid = ? AND is_read = ?", notificationUUID, int8(1)).
+ Update("is_read", int8(2))
+ if res.Error != nil {
+ span.RecordError(res.Error)
+ return false, res.Error
+ }
+ return res.RowsAffected > 0, nil
+}
+
+// NotificationGetByUUID 按 UUID 查询通知
+func (d *NotificationDao) NotificationGetByUUID(ctx context.Context, notificationUUID string) (*mysqlmodel.UserNotification, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.NotificationGetByUUID")
+ defer span.End()
+
+ var notification mysqlmodel.UserNotification
+ if err := d.db.WithContext(ctx).Where("notification_uuid = ?", notificationUUID).First(¬ification).Error; err != nil {
+ span.RecordError(err)
+ return nil, err
+ }
+ return ¬ification, nil
+}
+
+// NotificationCountUnread 统计用户未读数
+func (d *NotificationDao) NotificationCountUnread(ctx context.Context, userID int64) (int64, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.NotificationCountUnread")
+ defer span.End()
+
+ var count int64
+ if err := d.db.WithContext(ctx).Model(&mysqlmodel.UserNotification{}).
+ Where("user_id = ? AND is_read = ?", userID, int8(1)).
+ Count(&count).Error; err != nil {
+ span.RecordError(err)
+ return 0, err
+ }
+ return count, nil
+}
+
+// NotificationListByUserID 分页查询用户通知(isRead/msgType 为 0 不筛选)
+func (d *NotificationDao) NotificationListByUserID(ctx context.Context, userID int64, isRead, msgType int8, page, pageSize int) ([]mysqlmodel.UserNotification, int64, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.NotificationListByUserID")
+ defer span.End()
+
+ var list []mysqlmodel.UserNotification
+ var total int64
+ q := d.db.WithContext(ctx).Model(&mysqlmodel.UserNotification{}).Where("user_id = ?", userID)
+ if isRead != 0 {
+ q = q.Where("is_read = ?", isRead)
+ }
+ if msgType != 0 {
+ q = q.Where("msg_type = ?", msgType)
+ }
+ if err := q.Count(&total).Error; err != nil {
+ span.RecordError(err)
+ return nil, 0, err
+ }
+ if err := q.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&list).Error; err != nil {
+ span.RecordError(err)
+ return nil, 0, err
+ }
+ return list, total, nil
+}
diff --git a/internal/mysqldao/order.go b/internal/mysqldao/order.go
new file mode 100644
index 0000000..95d4fad
--- /dev/null
+++ b/internal/mysqldao/order.go
@@ -0,0 +1,197 @@
+package mysqldao
+
+import (
+ "context"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
+ "gorm.io/gorm"
+)
+
+// OrderDao 订单数据访问
+type OrderDao struct {
+ db *gorm.DB
+}
+
+// NewOrderDao 创建订单 DAO
+func NewOrderDao() *OrderDao {
+ return &OrderDao{db: global.MysqlEngine}
+}
+
+// OrderCreate 创建订单
+func (d *OrderDao) OrderCreate(ctx context.Context, order *mysqlmodel.Order) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.OrderCreate")
+ defer span.End()
+
+ if err := d.db.WithContext(ctx).Create(order).Error; err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// OrderUpdateByOrderNo 按订单号更新订单字段
+func (d *OrderDao) OrderUpdateByOrderNo(ctx context.Context, orderNo string, updates map[string]any) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.OrderUpdateByOrderNo")
+ defer span.End()
+
+ if err := d.db.WithContext(ctx).Model(&mysqlmodel.Order{}).Where("order_no = ?", orderNo).Updates(updates).Error; err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// OrderUpdateStatusByOrderNo 条件流转订单状态(防重复回调),返回是否更新成功
+func (d *OrderDao) OrderUpdateStatusByOrderNo(ctx context.Context, orderNo string, fromStatus, toStatus int8) (bool, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.OrderUpdateStatusByOrderNo")
+ defer span.End()
+
+ res := d.db.WithContext(ctx).Model(&mysqlmodel.Order{}).
+ Where("order_no = ? AND order_status = ?", orderNo, fromStatus).
+ Update("order_status", toStatus)
+ if res.Error != nil {
+ span.RecordError(res.Error)
+ return false, res.Error
+ }
+ return res.RowsAffected > 0, nil
+}
+
+// OrderExpireByTime 将超过支付有效期的待支付订单批量置为已过期,返回更新条数。
+// 状态字面量:1=待支付 4=已过期(与 service 层 OrderStatusPending/OrderStatusExpired 一致)。
+func (d *OrderDao) OrderExpireByTime(ctx context.Context, now int64) (int64, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.OrderExpireByTime")
+ defer span.End()
+
+ res := d.db.WithContext(ctx).Model(&mysqlmodel.Order{}).
+ Where("order_status = ? AND expire_time < ?", 1, now).
+ Update("order_status", 4)
+ if res.Error != nil {
+ span.RecordError(res.Error)
+ return 0, res.Error
+ }
+ return res.RowsAffected, nil
+}
+
+// OrderGetByID 按主键 ID 查询订单
+func (d *OrderDao) OrderGetByID(ctx context.Context, id int64) (*mysqlmodel.Order, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.OrderGetByID")
+ defer span.End()
+
+ var order mysqlmodel.Order
+ if err := d.db.WithContext(ctx).First(&order, id).Error; err != nil {
+ span.RecordError(err)
+ return nil, err
+ }
+ return &order, nil
+}
+
+// OrderGetByOrderNo 按商户订单号查询订单
+func (d *OrderDao) OrderGetByOrderNo(ctx context.Context, orderNo string) (*mysqlmodel.Order, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.OrderGetByOrderNo")
+ defer span.End()
+
+ var order mysqlmodel.Order
+ if err := d.db.WithContext(ctx).Where("order_no = ?", orderNo).First(&order).Error; err != nil {
+ span.RecordError(err)
+ return nil, err
+ }
+ return &order, nil
+}
+
+// OrderListByUserID 分页查询用户订单(status 为 0 不筛选)
+func (d *OrderDao) OrderListByUserID(ctx context.Context, userID int64, status int8, page, pageSize int) ([]mysqlmodel.Order, int64, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.OrderListByUserID")
+ defer span.End()
+
+ var list []mysqlmodel.Order
+ var total int64
+ q := d.db.WithContext(ctx).Model(&mysqlmodel.Order{}).Where("user_id = ?", userID)
+ if status != 0 {
+ q = q.Where("order_status = ?", status)
+ }
+ if err := q.Count(&total).Error; err != nil {
+ span.RecordError(err)
+ return nil, 0, err
+ }
+ if err := q.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&list).Error; err != nil {
+ span.RecordError(err)
+ return nil, 0, err
+ }
+ return list, total, nil
+}
+
+// RefundFlowLogDao 退款流水数据访问
+type RefundFlowLogDao struct {
+ db *gorm.DB
+}
+
+// NewRefundFlowLogDao 创建退款流水 DAO
+func NewRefundFlowLogDao() *RefundFlowLogDao {
+ return &RefundFlowLogDao{db: global.MysqlEngine}
+}
+
+// RefundFlowLogCreate 创建退款流水
+func (d *RefundFlowLogDao) RefundFlowLogCreate(ctx context.Context, log *mysqlmodel.RefundFlowLog) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.RefundFlowLogCreate")
+ defer span.End()
+
+ if err := d.db.WithContext(ctx).Create(log).Error; err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// RefundFlowLogUpdateStatus 按退款单号更新退款流水字段
+func (d *RefundFlowLogDao) RefundFlowLogUpdateStatus(ctx context.Context, refundNo string, updates map[string]any) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.RefundFlowLogUpdateStatus")
+ defer span.End()
+
+ if err := d.db.WithContext(ctx).Model(&mysqlmodel.RefundFlowLog{}).Where("refund_no = ?", refundNo).Updates(updates).Error; err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// RefundFlowLogUpdateStatusByFrom 条件流转退款流水状态(防并发/乱序回调覆盖),返回是否更新成功。
+func (d *RefundFlowLogDao) RefundFlowLogUpdateStatusByFrom(ctx context.Context, refundNo string, fromStatus int8, updates map[string]any) (bool, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.RefundFlowLogUpdateStatusByFrom")
+ defer span.End()
+
+ res := d.db.WithContext(ctx).Model(&mysqlmodel.RefundFlowLog{}).
+ Where("refund_no = ? AND status = ?", refundNo, fromStatus).
+ Updates(updates)
+ if res.Error != nil {
+ span.RecordError(res.Error)
+ return false, res.Error
+ }
+ return res.RowsAffected > 0, nil
+}
+
+// RefundFlowLogGetByRefundNo 按退款单号查询退款流水
+func (d *RefundFlowLogDao) RefundFlowLogGetByRefundNo(ctx context.Context, refundNo string) (*mysqlmodel.RefundFlowLog, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.RefundFlowLogGetByRefundNo")
+ defer span.End()
+
+ var log mysqlmodel.RefundFlowLog
+ if err := d.db.WithContext(ctx).Where("refund_no = ?", refundNo).First(&log).Error; err != nil {
+ span.RecordError(err)
+ return nil, err
+ }
+ return &log, nil
+}
+
+// RefundFlowLogGetByOrderNo 按商户订单号查询退款流水
+func (d *RefundFlowLogDao) RefundFlowLogGetByOrderNo(ctx context.Context, orderNo string) (*mysqlmodel.RefundFlowLog, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.RefundFlowLogGetByOrderNo")
+ defer span.End()
+
+ var log mysqlmodel.RefundFlowLog
+ if err := d.db.WithContext(ctx).Where("order_no = ?", orderNo).First(&log).Error; err != nil {
+ span.RecordError(err)
+ return nil, err
+ }
+ return &log, nil
+}
diff --git a/internal/mysqldao/outfit.go b/internal/mysqldao/outfit.go
new file mode 100644
index 0000000..ea968cd
--- /dev/null
+++ b/internal/mysqldao/outfit.go
@@ -0,0 +1,216 @@
+package mysqldao
+
+import (
+ "context"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
+ "gorm.io/gorm"
+)
+
+// OutfitDao 穿搭数据访问
+type OutfitDao struct {
+ db *gorm.DB
+}
+
+// NewOutfitDao 创建穿搭 DAO
+func NewOutfitDao() *OutfitDao {
+ return &OutfitDao{db: global.MysqlEngine}
+}
+
+// OutfitCreate 创建穿搭
+func (d *OutfitDao) OutfitCreate(ctx context.Context, outfit *mysqlmodel.Outfit) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitCreate")
+ defer span.End()
+
+ if err := d.db.WithContext(ctx).Create(outfit).Error; err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// OutfitDeleteByUUID 按 UUID 软删除穿搭
+func (d *OutfitDao) OutfitDeleteByUUID(ctx context.Context, outfitUUID string) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitDeleteByUUID")
+ defer span.End()
+
+ if err := d.db.WithContext(ctx).Where("outfit_uuid = ?", outfitUUID).Delete(&mysqlmodel.Outfit{}).Error; err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// OutfitUpdateByUUID 按 UUID 更新穿搭字段
+func (d *OutfitDao) OutfitUpdateByUUID(ctx context.Context, outfitUUID string, updates map[string]any) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitUpdateByUUID")
+ defer span.End()
+
+ if err := d.db.WithContext(ctx).Model(&mysqlmodel.Outfit{}).Where("outfit_uuid = ?", outfitUUID).Updates(updates).Error; err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// OutfitUpdateIsPublic 设置穿搭公开状态
+func (d *OutfitDao) OutfitUpdateIsPublic(ctx context.Context, outfitUUID string, isPublic int8) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitUpdateIsPublic")
+ defer span.End()
+
+ if err := d.db.WithContext(ctx).Model(&mysqlmodel.Outfit{}).Where("outfit_uuid = ?", outfitUUID).UpdateColumn("is_public", isPublic).Error; err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// OutfitPublish 条件公开穿搭(仅私密 -> 公开 + 审核通过),返回是否更新成功,防并发重复消耗额度。
+// 状态字面量:is_public 1=私密 2=公开;audit_status 2=审核通过。
+func (d *OutfitDao) OutfitPublish(ctx context.Context, outfitUUID string) (bool, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitPublish")
+ defer span.End()
+
+ res := d.db.WithContext(ctx).Model(&mysqlmodel.Outfit{}).
+ Where("outfit_uuid = ? AND is_public = ?", outfitUUID, int8(1)).
+ Updates(map[string]any{"is_public": int8(2), "audit_status": int8(2)})
+ if res.Error != nil {
+ span.RecordError(res.Error)
+ return false, res.Error
+ }
+ return res.RowsAffected > 0, nil
+}
+
+// OutfitGetByUUID 按 UUID 查询穿搭
+func (d *OutfitDao) OutfitGetByUUID(ctx context.Context, outfitUUID string) (*mysqlmodel.Outfit, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitGetByUUID")
+ defer span.End()
+
+ var outfit mysqlmodel.Outfit
+ if err := d.db.WithContext(ctx).Where("outfit_uuid = ?", outfitUUID).First(&outfit).Error; err != nil {
+ span.RecordError(err)
+ return nil, err
+ }
+ return &outfit, nil
+}
+
+// OutfitGetByID 按主键 ID 查询穿搭
+func (d *OutfitDao) OutfitGetByID(ctx context.Context, id int64) (*mysqlmodel.Outfit, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitGetByID")
+ defer span.End()
+
+ var outfit mysqlmodel.Outfit
+ if err := d.db.WithContext(ctx).First(&outfit, id).Error; err != nil {
+ span.RecordError(err)
+ return nil, err
+ }
+ return &outfit, nil
+}
+
+// OutfitGetByUUIDs 按 UUID 批量查询穿搭
+func (d *OutfitDao) OutfitGetByUUIDs(ctx context.Context, uuids []string) ([]mysqlmodel.Outfit, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitGetByUUIDs")
+ defer span.End()
+
+ var list []mysqlmodel.Outfit
+ if len(uuids) == 0 {
+ return list, nil
+ }
+ if err := d.db.WithContext(ctx).Where("outfit_uuid IN ?", uuids).Find(&list).Error; err != nil {
+ span.RecordError(err)
+ return nil, err
+ }
+ return list, nil
+}
+
+// OutfitListByUserID 分页查询用户穿搭(isPublic/source 为 0 不筛选)
+func (d *OutfitDao) OutfitListByUserID(ctx context.Context, userID int64, isPublic, source int8, page, pageSize int) ([]mysqlmodel.Outfit, int64, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitListByUserID")
+ defer span.End()
+
+ var list []mysqlmodel.Outfit
+ var total int64
+ q := d.db.WithContext(ctx).Model(&mysqlmodel.Outfit{}).Where("user_id = ?", userID)
+ if isPublic != 0 {
+ q = q.Where("is_public = ?", isPublic)
+ }
+ if source != 0 {
+ q = q.Where("source = ?", source)
+ }
+ if err := q.Count(&total).Error; err != nil {
+ span.RecordError(err)
+ return nil, 0, err
+ }
+ if err := q.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&list).Error; err != nil {
+ span.RecordError(err)
+ return nil, 0, err
+ }
+ return list, total, nil
+}
+
+// OutfitItemDao 穿搭明细数据访问
+type OutfitItemDao struct {
+ db *gorm.DB
+}
+
+// NewOutfitItemDao 创建穿搭明细 DAO
+func NewOutfitItemDao() *OutfitItemDao {
+ return &OutfitItemDao{db: global.MysqlEngine}
+}
+
+// OutfitItemCreateBatch 批量创建穿搭明细
+func (d *OutfitItemDao) OutfitItemCreateBatch(ctx context.Context, items []mysqlmodel.OutfitItem) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitItemCreateBatch")
+ defer span.End()
+
+ if len(items) == 0 {
+ return nil
+ }
+ if err := d.db.WithContext(ctx).Create(&items).Error; err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// OutfitItemDeleteByOutfitID 删除穿搭明细
+func (d *OutfitItemDao) OutfitItemDeleteByOutfitID(ctx context.Context, outfitID int64) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitItemDeleteByOutfitID")
+ defer span.End()
+
+ if err := d.db.WithContext(ctx).Where("outfit_id = ?", outfitID).Delete(&mysqlmodel.OutfitItem{}).Error; err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// OutfitItemListByOutfitID 查询穿搭明细
+func (d *OutfitItemDao) OutfitItemListByOutfitID(ctx context.Context, outfitID int64) ([]mysqlmodel.OutfitItem, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitItemListByOutfitID")
+ defer span.End()
+
+ var list []mysqlmodel.OutfitItem
+ if err := d.db.WithContext(ctx).Where("outfit_id = ?", outfitID).Find(&list).Error; err != nil {
+ span.RecordError(err)
+ return nil, err
+ }
+ return list, nil
+}
+
+// OutfitItemListByOutfitIDs 批量查询穿搭明细
+func (d *OutfitItemDao) OutfitItemListByOutfitIDs(ctx context.Context, outfitIDs []int64) ([]mysqlmodel.OutfitItem, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitItemListByOutfitIDs")
+ defer span.End()
+
+ var list []mysqlmodel.OutfitItem
+ if len(outfitIDs) == 0 {
+ return list, nil
+ }
+ if err := d.db.WithContext(ctx).Where("outfit_id IN ?", outfitIDs).Find(&list).Error; err != nil {
+ span.RecordError(err)
+ return nil, err
+ }
+ return list, nil
+}
diff --git a/internal/mysqldao/quota.go b/internal/mysqldao/quota.go
new file mode 100644
index 0000000..e6905a2
--- /dev/null
+++ b/internal/mysqldao/quota.go
@@ -0,0 +1,502 @@
+package mysqldao
+
+import (
+ "context"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+)
+
+// QuotaDao 用户配额数据访问
+type QuotaDao struct {
+ db *gorm.DB
+}
+
+// NewQuotaDao 创建配额 DAO
+func NewQuotaDao() *QuotaDao {
+ return &QuotaDao{db: global.MysqlEngine}
+}
+
+// QuotaCreate 创建配额记录
+func (d *QuotaDao) QuotaCreate(ctx context.Context, quota *mysqlmodel.Quota) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaCreate")
+ defer span.End()
+
+ if err := d.db.WithContext(ctx).Create(quota).Error; err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// QuotaIncreaseUsed 原子增加已用量(扣减配额),剩余不足时返回 false。
+// 已废弃:仅改数值不写流水,存在账实不符风险;新代码请使用 QuotaConsumeWithFlow。
+func (d *QuotaDao) QuotaIncreaseUsed(ctx context.Context, userID int64, quotaType int8, n int64) (bool, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaIncreaseUsed")
+ defer span.End()
+
+ 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 {
+ span.RecordError(res.Error)
+ return false, res.Error
+ }
+ return res.RowsAffected > 0, nil
+}
+
+// QuotaDecreaseUsed 原子减少已用量(回退配额),已用量不足时返回 false。
+// 已废弃:仅改数值不写流水,存在账实不符风险;新代码请使用 QuotaRefundWithFlow。
+func (d *QuotaDao) QuotaDecreaseUsed(ctx context.Context, userID int64, quotaType int8, n int64) (bool, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaDecreaseUsed")
+ defer span.End()
+
+ res := d.db.WithContext(ctx).Model(&mysqlmodel.Quota{}).
+ Where("user_id = ? AND quota_type = ? AND used >= ?", userID, quotaType, n).
+ UpdateColumn("used", gorm.Expr("used - ?", n))
+ if res.Error != nil {
+ span.RecordError(res.Error)
+ return false, res.Error
+ }
+ return res.RowsAffected > 0, nil
+}
+
+// QuotaConsumeWithFlow 事务内扣减配额并写流水,保证数值变更与流水强一致。
+// 条件更新不满足(额度不足)返回 (false, nil);其余错误(含流水写入失败整体回滚)返回 error。
+func (d *QuotaDao) QuotaConsumeWithFlow(ctx context.Context, userID int64, quotaType int8, n int64, flow *mysqlmodel.QuotaFlowLog) (bool, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaConsumeWithFlow")
+ defer span.End()
+
+ var ok bool
+ err := d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ res := tx.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 res.Error
+ }
+ if res.RowsAffected == 0 {
+ return nil // 额度不足,无变更。
+ }
+ ok = true
+
+ // 回读真实值填充流水 old/new。
+ var cur mysqlmodel.Quota
+ if err := tx.Where("user_id = ? AND quota_type = ?", userID, quotaType).First(&cur).Error; err != nil {
+ return err
+ }
+ remain := cur.Total - cur.Used
+ flow.OldValue = remain + n
+ flow.NewValue = remain
+
+ // 写流水(失败则整个事务回滚,扣减一并撤销)。
+ ensureFlowOrderNo(flow)
+ return tx.Create(flow).Error
+ })
+ if err != nil {
+ span.RecordError(err)
+ return false, err
+ }
+ return ok, nil
+}
+
+// QuotaRefundWithFlow 事务内回退配额并写流水,保证数值变更与流水强一致。
+// 条件更新不满足(已用量不足/重复回退)返回 (false, nil);其余错误(含流水失败回滚)返回 error。
+func (d *QuotaDao) QuotaRefundWithFlow(ctx context.Context, userID int64, quotaType int8, n int64, flow *mysqlmodel.QuotaFlowLog) (bool, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaRefundWithFlow")
+ defer span.End()
+
+ var ok bool
+ err := d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ res := tx.Model(&mysqlmodel.Quota{}).
+ Where("user_id = ? AND quota_type = ? AND used >= ?", userID, quotaType, n).
+ UpdateColumn("used", gorm.Expr("used - ?", n))
+ if res.Error != nil {
+ return res.Error
+ }
+ if res.RowsAffected == 0 {
+ return nil // 已用量不足,无变更。
+ }
+ ok = true
+
+ // 回读真实值填充流水 old/new。
+ var cur mysqlmodel.Quota
+ if err := tx.Where("user_id = ? AND quota_type = ?", userID, quotaType).First(&cur).Error; err != nil {
+ return err
+ }
+ remain := cur.Total - cur.Used
+ flow.OldValue = remain - n
+ flow.NewValue = remain
+
+ // 写流水(失败则整个事务回滚,回退一并撤销)。
+ ensureFlowOrderNo(flow)
+ return tx.Create(flow).Error
+ })
+ if err != nil {
+ span.RecordError(err)
+ return false, err
+ }
+ return ok, nil
+}
+
+// QuotaInitWithFlow 事务内初始化配额并写流水。
+// 依赖 uk_user_quota 唯一索引(OnConflict DoNothing)实现并发安全幂等:已存在则跳过且不写流水。
+func (d *QuotaDao) QuotaInitWithFlow(ctx context.Context, userID int64, quotaType int8, total int64, flow *mysqlmodel.QuotaFlowLog) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaInitWithFlow")
+ defer span.End()
+
+ return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ q := &mysqlmodel.Quota{
+ UserID: userID,
+ QuotaType: quotaType,
+ Total: total,
+ Used: 0,
+ }
+ res := tx.Clauses(clause.OnConflict{
+ Columns: []clause.Column{{Name: "user_id"}, {Name: "quota_type"}},
+ DoNothing: true,
+ }).Create(q)
+ if res.Error != nil {
+ return res.Error
+ }
+ if res.RowsAffected == 0 {
+ return nil // 已存在,幂等跳过。
+ }
+
+ // 回读真实值填充流水 old/new。
+ var cur mysqlmodel.Quota
+ if err := tx.Where("user_id = ? AND quota_type = ?", userID, quotaType).First(&cur).Error; err != nil {
+ return err
+ }
+ remain := cur.Total - cur.Used
+ flow.OldValue = remain - total
+ flow.NewValue = remain
+
+ // 写流水(失败则整个事务回滚,初始化一并撤销)。
+ ensureFlowOrderNo(flow)
+ return tx.Create(flow).Error
+ })
+}
+
+// ensureFlowOrderNo 流水联合唯一索引 (order_no, quota_type, change_type) 的兜底:
+// 消费/回退/初始化流水无业务订单号时(order_no 为空串),MySQL 唯一索引将空串视为相同值,
+// 第二条同类流水会 1062 冲突。此处用流水自身 UUID 填充 order_no 保证唯一,不影响
+// 购买/退款类幂等流水(其 order_no 非空,直接沿用业务订单号)。
+func ensureFlowOrderNo(flow *mysqlmodel.QuotaFlowLog) {
+ if flow != nil && flow.OrderNo == "" {
+ flow.OrderNo = flow.QuotaFlowUUID
+ }
+}
+
+// QuotaGrantIdempotent 幂等发放额度:事务内先累加 total(不存在则创建,存在则累加),
+// 回读真实值后写流水;流水唯一索引(order_no+quota_type+change_type)冲突时事务回滚,
+// 调用方据此识别为「已发放」从而幂等。
+func (d *QuotaDao) QuotaGrantIdempotent(ctx context.Context, userID int64, quotaType int8, n int64, flow *mysqlmodel.QuotaFlowLog) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantIdempotent")
+ defer span.End()
+
+ return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ // 1. 累加 total(记录不存在则创建,存在则原子累加)。
+ q := &mysqlmodel.Quota{
+ UserID: userID,
+ QuotaType: quotaType,
+ Total: n,
+ Used: 0,
+ }
+ if err := tx.Clauses(clause.OnConflict{
+ Columns: []clause.Column{{Name: "user_id"}, {Name: "quota_type"}},
+ DoUpdates: clause.Assignments(map[string]interface{}{"total": gorm.Expr("total + ?", n)}),
+ }).Create(q).Error; err != nil {
+ return err
+ }
+
+ // 2. 回读真实值填充流水 old/new。
+ var cur mysqlmodel.Quota
+ if err := tx.Where("user_id = ? AND quota_type = ?", userID, quotaType).First(&cur).Error; err != nil {
+ return err
+ }
+ remain := cur.Total - cur.Used
+ flow.OldValue = remain - n
+ flow.NewValue = remain
+
+ // 3. 写流水(唯一索引冲突会回滚整个事务,保证不重复发放)。
+ return tx.Create(flow).Error
+ })
+}
+
+// QuotaRevokeIdempotent 幂等回退额度(退款回收):事务内先扣减 total(GREATEST 保证 total 不低于 used,
+// 即已用部分不回退),回读真实值后写回退流水;流水唯一索引(order_no+quota_type+change_type)冲突时
+// 事务回滚,调用方据此识别为「已回退」从而幂等。
+func (d *QuotaDao) QuotaRevokeIdempotent(ctx context.Context, userID int64, quotaType int8, n int64, flow *mysqlmodel.QuotaFlowLog) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaRevokeIdempotent")
+ defer span.End()
+
+ return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ // 1. 扣减 total(GREATEST(used, total - n) 保证 total >= used)。
+ res := tx.Model(&mysqlmodel.Quota{}).
+ Where("user_id = ? AND quota_type = ?", userID, quotaType).
+ UpdateColumn("total", gorm.Expr("GREATEST(used, total - ?)", n))
+ if res.Error != nil {
+ return res.Error
+ }
+
+ // 2. 回读真实值填充流水 old/new。
+ var cur mysqlmodel.Quota
+ if err := tx.Where("user_id = ? AND quota_type = ?", userID, quotaType).First(&cur).Error; err != nil {
+ return err
+ }
+ remain := cur.Total - cur.Used
+ flow.OldValue = remain + n
+ flow.NewValue = remain
+
+ // 3. 写回退流水(唯一索引冲突回滚整个事务,保证不重复回退)。
+ return tx.Create(flow).Error
+ })
+}
+
+// QuotaGetByUserIDAndType 查询用户某类配额
+func (d *QuotaDao) QuotaGetByUserIDAndType(ctx context.Context, userID int64, quotaType int8) (*mysqlmodel.Quota, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGetByUserIDAndType")
+ defer span.End()
+
+ var quota mysqlmodel.Quota
+ if err := d.db.WithContext(ctx).Where("user_id = ? AND quota_type = ?", userID, quotaType).First("a).Error; err != nil {
+ span.RecordError(err)
+ return nil, err
+ }
+ return "a, nil
+}
+
+// QuotaListByUserID 查询用户的所有配额
+func (d *QuotaDao) QuotaListByUserID(ctx context.Context, userID int64) ([]mysqlmodel.Quota, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaListByUserID")
+ defer span.End()
+
+ var quotas []mysqlmodel.Quota
+ if err := d.db.WithContext(ctx).Where("user_id = ?", userID).Find("as).Error; err != nil {
+ span.RecordError(err)
+ return nil, err
+ }
+ return quotas, nil
+}
+
+// UserDailyUsageDao 每日使用计数数据访问
+type UserDailyUsageDao struct {
+ db *gorm.DB
+}
+
+// NewUserDailyUsageDao 创建每日使用计数 DAO
+func NewUserDailyUsageDao() *UserDailyUsageDao {
+ return &UserDailyUsageDao{db: global.MysqlEngine}
+}
+
+// UserDailyUsageCreate 创建每日使用计数
+func (d *UserDailyUsageDao) UserDailyUsageCreate(ctx context.Context, usage *mysqlmodel.UserDailyUsage) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.UserDailyUsageCreate")
+ defer span.End()
+
+ if err := d.db.WithContext(ctx).Create(usage).Error; err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// UserDailyUsageIncrementAI 当日AI推荐次数 +1
+func (d *UserDailyUsageDao) UserDailyUsageIncrementAI(ctx context.Context, userID int64, statDate int64) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.UserDailyUsageIncrementAI")
+ defer span.End()
+
+ if err := d.db.WithContext(ctx).Model(&mysqlmodel.UserDailyUsage{}).
+ Where("user_id = ? AND stat_date = ?", userID, statDate).
+ UpdateColumn("ai_recommend_count", gorm.Expr("ai_recommend_count + ?", 1)).Error; err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// UserDailyUsageIncrementOutfitShare 当日社区分享次数 +1
+func (d *UserDailyUsageDao) UserDailyUsageIncrementOutfitShare(ctx context.Context, userID int64, statDate int64) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.UserDailyUsageIncrementOutfitShare")
+ defer span.End()
+
+ if err := d.db.WithContext(ctx).Model(&mysqlmodel.UserDailyUsage{}).
+ Where("user_id = ? AND stat_date = ?", userID, statDate).
+ UpdateColumn("outfit_share_count", gorm.Expr("outfit_share_count + ?", 1)).Error; err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// UserDailyUsageIncrementClosetShare 当日衣柜分享次数 +1
+func (d *UserDailyUsageDao) UserDailyUsageIncrementClosetShare(ctx context.Context, userID int64, statDate int64) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.UserDailyUsageIncrementClosetShare")
+ defer span.End()
+
+ if err := d.db.WithContext(ctx).Model(&mysqlmodel.UserDailyUsage{}).
+ Where("user_id = ? AND stat_date = ?", userID, statDate).
+ UpdateColumn("closet_share_count", gorm.Expr("closet_share_count + ?", 1)).Error; err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// UserDailyUsageGetByUserIDAndDate 查询用户某日使用计数
+func (d *UserDailyUsageDao) UserDailyUsageGetByUserIDAndDate(ctx context.Context, userID int64, statDate int64) (*mysqlmodel.UserDailyUsage, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.UserDailyUsageGetByUserIDAndDate")
+ defer span.End()
+
+ var usage mysqlmodel.UserDailyUsage
+ if err := d.db.WithContext(ctx).Where("user_id = ? AND stat_date = ?", userID, statDate).First(&usage).Error; err != nil {
+ span.RecordError(err)
+ return nil, err
+ }
+ return &usage, nil
+}
+
+// QuotaFlowLogDao 配额流水数据访问
+type QuotaFlowLogDao struct {
+ db *gorm.DB
+}
+
+// NewQuotaFlowLogDao 创建配额流水 DAO
+func NewQuotaFlowLogDao() *QuotaFlowLogDao {
+ return &QuotaFlowLogDao{db: global.MysqlEngine}
+}
+
+// QuotaFlowLogCreate 创建配额流水
+func (d *QuotaFlowLogDao) QuotaFlowLogCreate(ctx context.Context, log *mysqlmodel.QuotaFlowLog) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaFlowLogCreate")
+ defer span.End()
+
+ if err := d.db.WithContext(ctx).Create(log).Error; err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// QuotaFlowLogExistsByOrderNoAndType 判断指定订单+额度类型是否已有发放流水(补偿重试幂等判断)。
+func (d *QuotaFlowLogDao) QuotaFlowLogExistsByOrderNoAndType(ctx context.Context, orderNo string, quotaType int8) (bool, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaFlowLogExistsByOrderNoAndType")
+ defer span.End()
+
+ var count int64
+ if err := d.db.WithContext(ctx).Model(&mysqlmodel.QuotaFlowLog{}).
+ Where("order_no = ? AND quota_type = ?", orderNo, quotaType).
+ Count(&count).Error; err != nil {
+ span.RecordError(err)
+ return false, err
+ }
+ return count > 0, nil
+}
+
+// QuotaFlowLogListByUserID 分页查询用户配额流水(quotaType 为 0 不筛选)
+func (d *QuotaFlowLogDao) QuotaFlowLogListByUserID(ctx context.Context, userID int64, quotaType int8, page, pageSize int) ([]mysqlmodel.QuotaFlowLog, int64, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaFlowLogListByUserID")
+ defer span.End()
+
+ var list []mysqlmodel.QuotaFlowLog
+ var total int64
+ q := d.db.WithContext(ctx).Model(&mysqlmodel.QuotaFlowLog{}).Where("user_id = ?", userID)
+ if quotaType != 0 {
+ q = q.Where("quota_type = ?", quotaType)
+ }
+ if err := q.Count(&total).Error; err != nil {
+ span.RecordError(err)
+ return nil, 0, err
+ }
+ if err := q.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&list).Error; err != nil {
+ span.RecordError(err)
+ return nil, 0, err
+ }
+ return list, total, nil
+}
+
+// QuotaGrantDao 额度凭证数据访问(有有效期赠送额度)。
+type QuotaGrantDao struct {
+ db *gorm.DB
+}
+
+// NewQuotaGrantDao 创建额度凭证 DAO。
+func NewQuotaGrantDao() *QuotaGrantDao {
+ return &QuotaGrantDao{db: global.MysqlEngine}
+}
+
+// QuotaGrantCreate 创建额度凭证(ref_id+quota_type 唯一索引兜底幂等,冲突返回 1062)。
+func (d *QuotaGrantDao) QuotaGrantCreate(ctx context.Context, grant *mysqlmodel.QuotaGrant) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantCreate")
+ defer span.End()
+
+ if err := d.db.WithContext(ctx).Create(grant).Error; err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// QuotaGrantListByUserIDAndType 查询用户某类型全部凭证(未删除)。
+// 按到期时间升序(expire_time=0 表示跟随 VIP,排最后),配合「先到期先用」扣减策略。
+func (d *QuotaGrantDao) QuotaGrantListByUserIDAndType(ctx context.Context, userID int64, quotaType int8) ([]mysqlmodel.QuotaGrant, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantListByUserIDAndType")
+ defer span.End()
+
+ var list []mysqlmodel.QuotaGrant
+ if err := d.db.WithContext(ctx).
+ Where("user_id = ? AND quota_type = ?", userID, quotaType).
+ Order("CASE WHEN expire_time = 0 THEN 1 ELSE 0 END ASC").
+ Order("expire_time ASC").
+ Find(&list).Error; err != nil {
+ span.RecordError(err)
+ return nil, err
+ }
+ return list, nil
+}
+
+// QuotaGrantConsume 条件扣减单条凭证已用量(used + n <= amount),返回是否扣减成功。
+// 用于 Cos 永久额度凭证维度的扣减,避免并发超扣。
+func (d *QuotaGrantDao) QuotaGrantConsume(ctx context.Context, grantID int64, n int64) (bool, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantConsume")
+ defer span.End()
+
+ res := d.db.WithContext(ctx).Model(&mysqlmodel.QuotaGrant{}).
+ Where("id = ? AND used + ? <= amount", grantID, n).
+ UpdateColumn("used", gorm.Expr("used + ?", n))
+ if res.Error != nil {
+ span.RecordError(res.Error)
+ return false, res.Error
+ }
+ return res.RowsAffected > 0, nil
+}
+
+// QuotaGrantInvalidateByRefID 按关联ID软删除凭证(退款回收时使赠送额度失效)。
+func (d *QuotaGrantDao) QuotaGrantInvalidateByRefID(ctx context.Context, refID string) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantInvalidateByRefID")
+ defer span.End()
+
+ if err := d.db.WithContext(ctx).Where("ref_id = ?", refID).Delete(&mysqlmodel.QuotaGrant{}).Error; err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// QuotaGrantExistsByRefIDAndType 判断凭证是否已存在(广告激励防重复领取)。
+func (d *QuotaGrantDao) QuotaGrantExistsByRefIDAndType(ctx context.Context, refID string, quotaType int8) (bool, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantExistsByRefIDAndType")
+ defer span.End()
+
+ var count int64
+ if err := d.db.WithContext(ctx).Model(&mysqlmodel.QuotaGrant{}).
+ Where("ref_id = ? AND quota_type = ?", refID, quotaType).
+ Count(&count).Error; err != nil {
+ span.RecordError(err)
+ return false, err
+ }
+ return count > 0, nil
+}
diff --git a/internal/mysqldao/user.go b/internal/mysqldao/user.go
new file mode 100644
index 0000000..dbfa1d1
--- /dev/null
+++ b/internal/mysqldao/user.go
@@ -0,0 +1,176 @@
+package mysqldao
+
+import (
+ "context"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
+ "gorm.io/gorm"
+)
+
+// UserDao 用户数据访问
+type UserDao struct {
+ db *gorm.DB
+}
+
+// NewUserDao 创建用户 DAO
+func NewUserDao() *UserDao {
+ return &UserDao{db: global.MysqlEngine}
+}
+
+// UserCreate 创建用户
+func (d *UserDao) UserCreate(ctx context.Context, user *mysqlmodel.User) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.UserCreate")
+ defer span.End()
+
+ if err := d.db.WithContext(ctx).Create(user).Error; err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// UserUpdateByID 按主键 ID 更新用户字段
+func (d *UserDao) UserUpdateByID(ctx context.Context, userID int64, updates map[string]any) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.UserUpdateByID")
+ defer span.End()
+
+ if err := d.db.WithContext(ctx).Model(&mysqlmodel.User{}).Where("id = ?", userID).Updates(updates).Error; err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// VIPGrantIdempotent 幂等续费会员:事务内先原子累加 vip_expire(CASE 表达式避免并发丢失天数),
+// 回读真实到期时间后推导会员类型并写 VIP 流水;流水唯一索引(order_no+change_type)冲突时事务回滚,
+// 调用方据此识别为「已发放」从而幂等。
+//
+// 注意:续费 SQL 使用原生语句显式控制 SET 顺序(vip_start 先于 vip_expire 求值),
+// 使 vip_start 读到更新前的旧 vip_expire,保证「已过期续费时重置 vip_start」语义正确。
+//
+// 会员类型按「续费后最终到期时长」推导:>=365 天为年度会员,否则月度会员,
+// 避免「年度会员续月包被错误降级为月度」;体验会员等其它类型保持传入值不参与推导。
+func (d *UserDao) VIPGrantIdempotent(ctx context.Context, userID int64, now int64, memberDays int64, vipType int8, flow *mysqlmodel.VIPFlowLog) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.VIPGrantIdempotent")
+ defer span.End()
+
+ return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ sql := `UPDATE users SET
+ vip_start = CASE WHEN vip_type = 1 OR vip_expire <= ? THEN ? ELSE vip_start END,
+ vip_expire = (CASE WHEN vip_expire > ? THEN vip_expire ELSE ? END) + ?
+ WHERE id = ? AND deleted_at = 0`
+ if res := tx.Exec(sql, now, now, now, now, memberDays*86400, userID); res.Error != nil {
+ return res.Error
+ }
+
+ // 回读续费后的真实到期时间,用于推导会员类型。
+ var renewed mysqlmodel.User
+ if err := tx.Where("id = ?", userID).First(&renewed).Error; err != nil {
+ return err
+ }
+
+ // 字面量 2=月度会员 3=年度会员(与 service 层 VIPTypeMonthly/VIPTypeYearly 一致)。
+ // 月度/年度续费按最终到期时长推导;体验会员(4)等其它类型保持传入值。
+ finalType := vipType
+ if vipType == 2 || vipType == 3 {
+ if renewed.VIPExpire-now >= 365*86400 {
+ finalType = 3
+ } else {
+ finalType = 2
+ }
+ }
+ if err := tx.Model(&mysqlmodel.User{}).Where("id = ?", userID).
+ UpdateColumn("vip_type", finalType).Error; err != nil {
+ return err
+ }
+
+ flow.NewVIPExpire = renewed.VIPExpire
+ flow.NewVIPType = finalType
+
+ return tx.Create(flow).Error
+ })
+}
+
+// VIPRevokeIdempotent 幂等回退会员(退款回收):事务内先扣减 vip_expire(GREATEST 保证不早于 now),
+// 回读真实值后写回退流水;流水唯一索引(order_no+change_type)冲突时事务回滚,调用方据此识别为「已回退」。
+func (d *UserDao) VIPRevokeIdempotent(ctx context.Context, userID int64, now int64, memberDays int64, flow *mysqlmodel.VIPFlowLog) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.VIPRevokeIdempotent")
+ defer span.End()
+
+ return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ // 1. 扣减 vip_expire(GREATEST(now, vip_expire - days) 保证不倒退到过去)。
+ res := tx.Model(&mysqlmodel.User{}).
+ Where("id = ? AND vip_expire > ?", userID, now).
+ UpdateColumn("vip_expire", gorm.Expr("GREATEST(?, vip_expire - ?)", now, memberDays*86400))
+ if res.Error != nil {
+ return res.Error
+ }
+
+ // 2. 回读真实值填充流水。
+ var renewed mysqlmodel.User
+ if err := tx.Where("id = ?", userID).First(&renewed).Error; err != nil {
+ return err
+ }
+ flow.NewVIPExpire = renewed.VIPExpire
+ flow.NewVIPType = renewed.VIPType
+
+ // 3. 写回退流水(唯一索引冲突回滚整个事务,保证不重复回退)。
+ return tx.Create(flow).Error
+ })
+}
+
+// UserGetByID 按主键 ID 查询用户
+func (d *UserDao) UserGetByID(ctx context.Context, userID int64) (*mysqlmodel.User, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.UserGetByID")
+ defer span.End()
+
+ var user mysqlmodel.User
+ if err := d.db.WithContext(ctx).First(&user, userID).Error; err != nil {
+ span.RecordError(err)
+ return nil, err
+ }
+ return &user, nil
+}
+
+// UserGetByIDs 按主键 ID 批量查询用户(空 ids 直接返回空切片)
+func (d *UserDao) UserGetByIDs(ctx context.Context, ids []int64) ([]mysqlmodel.User, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.UserGetByIDs")
+ defer span.End()
+
+ var users []mysqlmodel.User
+ if len(ids) == 0 {
+ return users, nil
+ }
+ if err := d.db.WithContext(ctx).Where("id IN ?", ids).Find(&users).Error; err != nil {
+ span.RecordError(err)
+ return nil, err
+ }
+ return users, nil
+}
+
+// UserGetByUUID 按 UUID 查询用户
+func (d *UserDao) UserGetByUUID(ctx context.Context, userUUID string) (*mysqlmodel.User, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.UserGetByUUID")
+ defer span.End()
+
+ var user mysqlmodel.User
+ if err := d.db.WithContext(ctx).Where("user_uuid = ?", userUUID).First(&user).Error; err != nil {
+ span.RecordError(err)
+ return nil, err
+ }
+ return &user, nil
+}
+
+// UserGetByOpenID 按微信 openid 查询用户
+func (d *UserDao) UserGetByOpenID(ctx context.Context, openID string) (*mysqlmodel.User, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.UserGetByOpenID")
+ defer span.End()
+
+ var user mysqlmodel.User
+ if err := d.db.WithContext(ctx).Where("wx_open_id = ?", openID).First(&user).Error; err != nil {
+ span.RecordError(err)
+ return nil, err
+ }
+ return &user, nil
+}
diff --git a/internal/mysqldao/vipflowlog.go b/internal/mysqldao/vipflowlog.go
new file mode 100644
index 0000000..2491e4b
--- /dev/null
+++ b/internal/mysqldao/vipflowlog.go
@@ -0,0 +1,65 @@
+package mysqldao
+
+import (
+ "context"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
+ "gorm.io/gorm"
+)
+
+// VIPFlowLogDao VIP 流水数据访问
+type VIPFlowLogDao struct {
+ db *gorm.DB
+}
+
+// NewVIPFlowLogDao 创建 VIP 流水 DAO
+func NewVIPFlowLogDao() *VIPFlowLogDao {
+ return &VIPFlowLogDao{db: global.MysqlEngine}
+}
+
+// VIPFlowLogCreate 创建 VIP 流水
+func (d *VIPFlowLogDao) VIPFlowLogCreate(ctx context.Context, log *mysqlmodel.VIPFlowLog) error {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.VIPFlowLogCreate")
+ defer span.End()
+
+ if err := d.db.WithContext(ctx).Create(log).Error; err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// VIPFlowLogExistsByOrderNo 判断指定订单是否已有 VIP 发放流水(补偿重试幂等判断)。
+func (d *VIPFlowLogDao) VIPFlowLogExistsByOrderNo(ctx context.Context, orderNo string) (bool, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.VIPFlowLogExistsByOrderNo")
+ defer span.End()
+
+ var count int64
+ if err := d.db.WithContext(ctx).Model(&mysqlmodel.VIPFlowLog{}).
+ Where("order_no = ?", orderNo).
+ Count(&count).Error; err != nil {
+ span.RecordError(err)
+ return false, err
+ }
+ return count > 0, nil
+}
+
+// VIPFlowLogListByUserID 分页查询用户 VIP 流水
+func (d *VIPFlowLogDao) VIPFlowLogListByUserID(ctx context.Context, userID int64, page, pageSize int) ([]mysqlmodel.VIPFlowLog, int64, error) {
+ ctx, span := global.Tracer.Start(ctx, "mysqldao.VIPFlowLogListByUserID")
+ defer span.End()
+
+ var list []mysqlmodel.VIPFlowLog
+ var total int64
+ q := d.db.WithContext(ctx).Model(&mysqlmodel.VIPFlowLog{}).Where("user_id = ?", userID)
+ if err := q.Count(&total).Error; err != nil {
+ span.RecordError(err)
+ return nil, 0, err
+ }
+ if err := q.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&list).Error; err != nil {
+ span.RecordError(err)
+ return nil, 0, err
+ }
+ return list, total, nil
+}
diff --git a/internal/mysqlmodel/aiflowlog.go b/internal/mysqlmodel/aiflowlog.go
new file mode 100644
index 0000000..14b2e91
--- /dev/null
+++ b/internal/mysqlmodel/aiflowlog.go
@@ -0,0 +1,41 @@
+package mysqlmodel
+
+type AIRecommendFlowLog struct {
+ Model
+ RequestID string `gorm:"column:request_id"` // 请求唯一ID
+ ServerRequestID string `gorm:"column:server_request_id"` // 服务请求唯一ID
+ UserID int64 `gorm:"column:user_id"` // 用户ID
+ ModelVersion string `gorm:"column:model_version"` // 大模型版本
+ PromptTokens int64 `gorm:"column:prompt_tokens"` // 输入token数
+ CompletionTokens int64 `gorm:"column:completion_tokens"` // 输出token数
+ ResultContent string `gorm:"column:result_content"` // 推荐结果结构化数据
+ Status int8 `gorm:"column:status"` // 状态 1=成功 2=失败
+ FailReason string `gorm:"column:fail_reason"` // 失败原因
+ RequestTime int64 `gorm:"column:request_time"` // 请求发起时间
+ ResponseTime int64 `gorm:"column:response_time"` // 响应返回时间
+}
+
+func (AIRecommendFlowLog) TableName() string {
+ return "ai_recommend_flow_logs"
+}
+
+type AIHoroscopeFlowLog struct {
+ Model
+ RequestID string `gorm:"column:request_id"` // 请求唯一ID(幂等依据)
+ Zodiac string `gorm:"column:zodiac"` // 星座
+ ServerRequestID string `gorm:"column:server_request_id"` // 服务请求唯一ID
+ StatDate int64 `gorm:"column:stat_date"` // 运势日期(YYYYMMDD)
+ ModelVersion string `gorm:"column:model_version"` // 大模型版本
+ PromptTokens int64 `gorm:"column:prompt_tokens"` // 输入token数
+ CompletionTokens int64 `gorm:"column:completion_tokens"` // 输出token数
+ ResultContent string `gorm:"column:result_content"` // 生成结果结构化数据
+ HoroscopeID int64 `gorm:"column:horoscope_id"` // 生成的运势记录ID
+ Status int8 `gorm:"column:status"` // 状态 1=成功 2=失败
+ FailReason string `gorm:"column:fail_reason"` // 失败原因
+ RequestTime int64 `gorm:"column:request_time"` // 请求发起时间
+ ResponseTime int64 `gorm:"column:response_time"` // 响应返回时间
+}
+
+func (AIHoroscopeFlowLog) TableName() string {
+ return "ai_horoscope_flow_logs"
+}
diff --git a/internal/mysqlmodel/auditlog.go b/internal/mysqlmodel/auditlog.go
new file mode 100644
index 0000000..2201cf5
--- /dev/null
+++ b/internal/mysqlmodel/auditlog.go
@@ -0,0 +1,25 @@
+package mysqlmodel
+
+type ClosetAuditLog struct {
+ Model
+ TargetID int64 `gorm:"column:target_id"` // 衣柜分享ID
+ AuditResult int8 `gorm:"column:audit_result"` // 审核结果 1=通过 2=驳回
+ RejectReason string `gorm:"column:reject_reason"` // 审核原因
+ AuditTime int64 `gorm:"column:audit_time"` // 审核时间
+}
+
+func (ClosetAuditLog) TableName() string {
+ return "closet_audit_logs"
+}
+
+type OutfitAuditLog struct {
+ Model
+ TargetID int64 `gorm:"column:target_id"` // 穿搭ID
+ AuditResult int8 `gorm:"column:audit_result"` // 审核结果 1=通过 2=驳回
+ RejectReason string `gorm:"column:reject_reason"` // 审核原因
+ AuditTime int64 `gorm:"column:audit_time"` // 审核时间
+}
+
+func (OutfitAuditLog) TableName() string {
+ return "outfit_audit_logs"
+}
diff --git a/internal/mysqlmodel/benefit_grant_task.go b/internal/mysqlmodel/benefit_grant_task.go
new file mode 100644
index 0000000..f3ada4e
--- /dev/null
+++ b/internal/mysqlmodel/benefit_grant_task.go
@@ -0,0 +1,22 @@
+package mysqlmodel
+
+// BenefitGrantTask 权益发放补偿任务,用于支付成功后权益发放失败时的幂等补偿。
+// 唯一索引 (order_no, benefit_type) 作为幂等凭证,防止重复发放。
+type BenefitGrantTask struct {
+ Model
+ UserID int64 `gorm:"column:user_id"` // 用户ID
+ // OrderNo 商户订单号;与 BenefitType 组成联合唯一索引作为幂等凭证。
+ OrderNo string `gorm:"column:order_no;uniqueIndex:uk_benefit_task_idem,priority:1"`
+ GoodsID int64 `gorm:"column:goods_id"` // 关联商品ID
+ // BenefitType 权益类型 1=vip 2=cos 3=ai 4=closet_share 5=outfit_share
+ BenefitType int8 `gorm:"column:benefit_type;uniqueIndex:uk_benefit_task_idem,priority:2"`
+ Amount int64 `gorm:"column:amount"` // 发放数量
+ Status int8 `gorm:"column:status"` // 状态 0=待发放 1=已发放 2=发放失败
+ RetryCount int `gorm:"column:retry_count"` // 已重试次数
+ MaxRetry int `gorm:"column:max_retry"` // 最大重试次数
+ LastError string `gorm:"column:last_error"` // 最近一次失败原因
+}
+
+func (BenefitGrantTask) TableName() string {
+ return "benefit_grant_tasks"
+}
diff --git a/internal/mysqlmodel/closet.go b/internal/mysqlmodel/closet.go
new file mode 100644
index 0000000..32221f9
--- /dev/null
+++ b/internal/mysqlmodel/closet.go
@@ -0,0 +1,35 @@
+package mysqlmodel
+
+type ClosetShare struct {
+ Model
+ ShareUUID string `gorm:"column:share_uuid"` // 分享UUID
+ UserID int64 `gorm:"column:user_id"` // 所属用户ID
+ ShareType int8 `gorm:"column:share_type"` // 分享类型 1=部分衣物分享 2=全量快照分享
+ ExpireTime int64 `gorm:"column:expire_time"` // 过期时间
+ Status int8 `gorm:"column:status"` // 分享状态 1=正常 2=用户取消 3=已过期 4=管理员封禁
+}
+
+func (ClosetShare) TableName() string {
+ return "closet_shares"
+}
+
+type ClosetShareItem struct {
+ Model
+ ShareUUID string `gorm:"column:share_uuid"` // 分享UUID
+ ClothesID int64 `gorm:"column:clothes_id"` // 衣物ID
+}
+
+func (ClosetShareItem) TableName() string {
+ return "closet_share_items"
+}
+
+// ClosetShareReceiver 衣柜分享接收人关联。
+type ClosetShareReceiver struct {
+ Model
+ ShareUUID string `gorm:"column:share_uuid;uniqueIndex:uk_closet_share_receiver,priority:1"` // 分享UUID
+ ReceiverUserID int64 `gorm:"column:receiver_user_id;uniqueIndex:uk_closet_share_receiver,priority:2"` // 接收人用户ID
+}
+
+func (ClosetShareReceiver) TableName() string {
+ return "closet_share_receivers"
+}
diff --git a/internal/mysqlmodel/clothes.go b/internal/mysqlmodel/clothes.go
new file mode 100644
index 0000000..f313df3
--- /dev/null
+++ b/internal/mysqlmodel/clothes.go
@@ -0,0 +1,32 @@
+package mysqlmodel
+
+type Clothes struct {
+ Model
+ ClothesUUID string `gorm:"column:clothes_uuid"` // 衣物UUID
+ UserID int64 `gorm:"column:user_id"` // 所属用户ID
+ CategoryMain string `gorm:"column:category_main"` // 衣物大类 上装/下装/连衣裙/外套/鞋/配饰
+ CategorySub string `gorm:"column:category_sub"` // 衣物小类 T恤/牛仔裤/运动鞋/发带/戒指等
+ Style string `gorm:"column:style"` // 风格
+ ColorMain string `gorm:"column:color"` // 主颜色
+ ColorSub string `gorm:"column:color_sub"` // 副颜色
+ Thickness string `gorm:"column:thickness"` // 厚度
+ Version string `gorm:"column:version"` // 版型 宽松/修身/阔腿/紧身/常规合身
+ Material string `gorm:"column:material"` // 材质 牛仔布/针织/皮质/纯棉/羊毛等
+ Pattern string `gorm:"column:pattern"` // 花纹
+ SleeveType string `gorm:"column:sleeve_type"` // 袖型
+ ClothesType string `gorm:"column:clothes_type"` // 衣长类型 短款/常规/中长/长款
+ NecklineType string `gorm:"column:neckline_type"` // 领型
+ Formality string `gorm:"column:formality"` // 正式程度 1-5
+ Occasions string `gorm:"column:occasions"` // 适用场合
+ VisualImpact string `gorm:"column:visual_impact"` // 视觉冲击力 1-5
+ Name string `gorm:"column:name"` // 名称
+ Remark string `gorm:"column:remark"` // 备注
+ ProcessedImage string `gorm:"column:processed_image"` // 处理后图片存储位置 相对路径
+ ImageSize int64 `gorm:"column:image_size"` // 图片大小 单位:kb
+ ImageProcessStatus int8 `gorm:"column:image_process_status"` // 处理状态 1=抠图中 2=分类中 3=排队中 4=成功 5=失败
+ FailReason string `gorm:"column:fail_reason"` // 失败原因
+}
+
+func (Clothes) TableName() string {
+ return "clothes"
+}
diff --git a/internal/mysqlmodel/collect.go b/internal/mysqlmodel/collect.go
new file mode 100644
index 0000000..a140243
--- /dev/null
+++ b/internal/mysqlmodel/collect.go
@@ -0,0 +1,14 @@
+package mysqlmodel
+
+type Collect struct {
+ Model
+ // CollectType 业务类型 1=社区穿搭;与 TargetID、UserID 组成联合唯一索引,支撑收藏/取消收藏 upsert。
+ CollectType int8 `gorm:"column:collect_type;uniqueIndex:uk_collect_user_target,priority:1"`
+ TargetID int64 `gorm:"column:target_id;uniqueIndex:uk_collect_user_target,priority:2"` // 业务目标ID
+ UserID int64 `gorm:"column:user_id;uniqueIndex:uk_collect_user_target,priority:3"` // 收藏用户ID
+ Status int8 `gorm:"column:status"` // 状态 1=已收藏 0=已取消
+}
+
+func (Collect) TableName() string {
+ return "collects"
+}
diff --git a/internal/mysqlmodel/goods.go b/internal/mysqlmodel/goods.go
new file mode 100644
index 0000000..8bead2f
--- /dev/null
+++ b/internal/mysqlmodel/goods.go
@@ -0,0 +1,24 @@
+package mysqlmodel
+
+type Goods struct {
+ Model
+ GoodsCode string `gorm:"column:goods_code"` // 商品编码
+ GoodsName string `gorm:"column:goods_name"` // 商品名称
+ GoodsType int8 `gorm:"column:goods_type"` // 商品类型 1=会员包 2=上传包 3=AI推荐包 4=衣柜分享包 5=搭配分享包
+ Price int64 `gorm:"column:price"` // 商品原价(分)
+ DiscountPrice int64 `gorm:"column:discount_price"` // 商品售价(分)
+ MemberDays int64 `gorm:"column:member_days"` // 赠送会员天数
+ CosQuota int64 `gorm:"column:cos_quota"` // 赠送总上传额度
+ AIRecommendQuota int64 `gorm:"column:ai_recommend_quota"` // 赠送AI推荐额度
+ ClosetShareQuota int64 `gorm:"column:closet_share_quota"` // 赠送衣柜分享额度
+ OutfitShareQuota int64 `gorm:"column:outfit_share_quota"` // 赠送搭配分享额度
+ ValidDays int64 `gorm:"column:valid_days"` // 赠送额度有效期天数(加量包用;会员包为 0 表示跟随 VIP)
+ Description string `gorm:"column:description"` // 商品描述
+ Status int8 `gorm:"column:status"` // 状态 1=上架 2=下架
+ ProductID string `gorm:"column:product_id"` // 虚拟支付道具ID(空表示未发布)
+ ProductStatus int8 `gorm:"column:product_status"` // 虚拟支付道具发布状态 0=未发布 1=发布中 2=已发布 3=发布失败
+}
+
+func (Goods) TableName() string {
+ return "goods"
+}
diff --git a/internal/mysqlmodel/horoscope.go b/internal/mysqlmodel/horoscope.go
new file mode 100644
index 0000000..1ee2d87
--- /dev/null
+++ b/internal/mysqlmodel/horoscope.go
@@ -0,0 +1,19 @@
+package mysqlmodel
+
+type Horoscope struct {
+ Model
+ // StatDate 日期(YYYYMMDD);与 Zodiac 组成联合唯一索引,保证每日每星座仅一条运势。
+ StatDate int64 `gorm:"column:stat_date;uniqueIndex:uk_horoscope_date_zodiac,priority:1"`
+ Zodiac string `gorm:"column:zodiac;uniqueIndex:uk_horoscope_date_zodiac,priority:2"` // 星座
+ OverallScore int8 `gorm:"column:overall_score"` // 综合运势 1-5分
+ LoveScore int8 `gorm:"column:love_score"` // 爱情运势
+ CareerScore int8 `gorm:"column:career_score"` // 工作运势
+ WealthScore int8 `gorm:"column:wealth_score"` // 财富运势
+ DailyText string `gorm:"column:daily_text"` // 今日运势详情
+ LuckyColor string `gorm:"column:lucky_color"` // 幸运色
+ LuckyNumber string `gorm:"column:lucky_number"` // 幸运数字
+}
+
+func (Horoscope) TableName() string {
+ return "horoscopes"
+}
diff --git a/internal/mysqlmodel/like.go b/internal/mysqlmodel/like.go
new file mode 100644
index 0000000..8625657
--- /dev/null
+++ b/internal/mysqlmodel/like.go
@@ -0,0 +1,14 @@
+package mysqlmodel
+
+type Like struct {
+ Model
+ // LikeType 业务类型 1=社区穿搭;与 TargetID、UserID 组成联合唯一索引,支撑点赞/取消点赞 upsert。
+ LikeType int8 `gorm:"column:like_type;uniqueIndex:uk_like_user_target,priority:1"`
+ TargetID int64 `gorm:"column:target_id;uniqueIndex:uk_like_user_target,priority:2"` // 穿搭ID
+ UserID int64 `gorm:"column:user_id;uniqueIndex:uk_like_user_target,priority:3"` // 点赞用户ID
+ Status int8 `gorm:"column:status"` // 状态 1=已点赞 0=已取消
+}
+
+func (Like) TableName() string {
+ return "likes"
+}
diff --git a/internal/mysqlmodel/model.go b/internal/mysqlmodel/model.go
new file mode 100644
index 0000000..efd0a6d
--- /dev/null
+++ b/internal/mysqlmodel/model.go
@@ -0,0 +1,54 @@
+package mysqlmodel
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/setting"
+ "gorm.io/driver/mysql"
+ "gorm.io/gorm"
+ "gorm.io/plugin/soft_delete"
+)
+
+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"` // 删除人
+}
+
+// NewMysqlEngine 根据配置创建 GORM MySQL 连接并完成连接池初始化。
+func NewMysqlEngine(mysqlSetting *setting.MysqlSettingS) (*gorm.DB, error) {
+ s := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=10s&readTimeout=10s&writeTimeout=10s",
+ mysqlSetting.UserName,
+ mysqlSetting.Password,
+ mysqlSetting.Host,
+ mysqlSetting.Port,
+ mysqlSetting.DBName,
+ )
+ db, err := gorm.Open(mysql.Open(s), &gorm.Config{})
+ if err != nil {
+ return nil, err
+ }
+
+ sqlDB, err := db.DB()
+ if err != nil {
+ return nil, err
+ }
+
+ sqlDB.SetMaxIdleConns(mysqlSetting.MaxIdleConns)
+ sqlDB.SetMaxOpenConns(mysqlSetting.MaxOpenConns)
+ sqlDB.SetConnMaxLifetime(time.Duration(mysqlSetting.ConnMaxLifeTime) * time.Second)
+ sqlDB.SetConnMaxIdleTime(time.Duration(mysqlSetting.ConnMaxIdleTime) * time.Second)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ if err := sqlDB.PingContext(ctx); err != nil {
+ return nil, err
+ }
+ return db, nil
+}
diff --git a/internal/mysqlmodel/notification.go b/internal/mysqlmodel/notification.go
new file mode 100644
index 0000000..8981a2c
--- /dev/null
+++ b/internal/mysqlmodel/notification.go
@@ -0,0 +1,17 @@
+package mysqlmodel
+
+type UserNotification struct {
+ Model
+ NotificationUUID string `gorm:"column:notification_uuid"` // 消息UUID
+ UserID int64 `gorm:"column:user_id"` // 接收用户ID
+ MsgType int8 `gorm:"column:msg_type"` // 消息类型 1=点赞通知 2=订单通知 3=系统通知
+ SenderID int64 `gorm:"column:sender_id"` // 发送者ID 系统消息为0
+ Title string `gorm:"column:title"` // 消息标题
+ Content string `gorm:"column:content"` // 消息内容
+ IsRead int8 `gorm:"column:is_read"` // 是否已读 1=未读 2=已读
+ ReadTime int64 `gorm:"column:read_time"` // 阅读时间
+}
+
+func (UserNotification) TableName() string {
+ return "user_notifications"
+}
diff --git a/internal/mysqlmodel/order.go b/internal/mysqlmodel/order.go
new file mode 100644
index 0000000..0122b27
--- /dev/null
+++ b/internal/mysqlmodel/order.go
@@ -0,0 +1,34 @@
+package mysqlmodel
+
+type Order struct {
+ Model
+ // OrderNo 商户订单号;唯一索引保证订单号全局唯一。
+ OrderNo string `gorm:"column:order_no;uniqueIndex:uk_order_no"`
+ UserID int64 `gorm:"column:user_id"` // 下单用户ID
+ GoodsID int64 `gorm:"column:goods_id"` // 关联商品ID
+ GoodsName string `gorm:"column:goods_name"` // 商品名称快照
+ UnitPrice int64 `gorm:"column:unit_price"` // 商品单价(分)
+ TotalAmount int64 `gorm:"column:total_amount"` // 订单总金额(分)
+ PayAmount int64 `gorm:"column:pay_amount"` // 实付金额(分)
+ PayChannel int8 `gorm:"column:pay_channel"` // 支付渠道 1=微信支付
+ OrderStatus int8 `gorm:"column:order_status"` // 订单状态 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款
+ TransactionID string `gorm:"column:transaction_id"` // 微信支付流水号
+ MemberDays int64 `gorm:"column:member_days"` // 购买的会员天数(快照)
+ CosQuota int64 `gorm:"column:cos_quota"` // 赠送上传额度(快照)
+ AIRecommendQuota int64 `gorm:"column:ai_recommend_quota"` // 赠送AI推荐额度(快照)
+ ClosetShareQuota int64 `gorm:"column:closet_share_quota"` // 赠送衣柜分享额度(快照)
+ OutfitShareQuota int64 `gorm:"column:outfit_share_quota"` // 赠送搭配分享额度(快照)
+ ExpireTime int64 `gorm:"column:expire_time"` // 支付过期时间
+ PayTime int64 `gorm:"column:pay_time"` // 支付完成时间
+ RefundTime int64 `gorm:"column:refund_time"` // 退款时间
+ RefundAmount int64 `gorm:"column:refund_amount"` // 退款金额(分)
+ Remark string `gorm:"column:remark"` // 订单备注
+ CallbackRaw string `gorm:"column:callback_raw"` // 支付回调原始报文
+ XPayProductID string `gorm:"column:xpay_product_id"` // 虚拟支付道具ID(快照,pay_channel=2 时使用)
+ XPayPlatform string `gorm:"column:xpay_platform"` // 虚拟支付平台 android/ios/harmony/windows
+ XPayEnv int8 `gorm:"column:xpay_env"` // 虚拟支付环境 0=现网 1=沙箱
+}
+
+func (Order) TableName() string {
+ return "orders"
+}
diff --git a/internal/mysqlmodel/outfit.go b/internal/mysqlmodel/outfit.go
new file mode 100644
index 0000000..86bd3ed
--- /dev/null
+++ b/internal/mysqlmodel/outfit.go
@@ -0,0 +1,28 @@
+package mysqlmodel
+
+type Outfit struct {
+ Model
+ OutfitUUID string `gorm:"column:outfit_uuid"` // 穿搭UUID
+ UserID int64 `gorm:"column:user_id"` // 所属用户ID
+ OutfitName string `gorm:"column:outfit_name"` // 穿搭名称
+ OutfitDesc string `gorm:"column:outfit_desc"` // 穿搭描述
+ Scene string `gorm:"column:scene"` // 适合场景
+ Source int8 `gorm:"column:source"` // 来源 1=用户创建 2=AI推荐
+ IsPublic int8 `gorm:"column:is_public"` // 是否公开 1=不公开 2=公开
+ AuditStatus int8 `gorm:"column:audit_status"` // 审核状态 1=待审核 2=审核通过 3=审核不通过
+ Status int8 `gorm:"column:status"` // 状态 1=正常 2=封禁 3=待用户保存
+}
+
+func (Outfit) TableName() string {
+ return "outfits"
+}
+
+type OutfitItem struct {
+ Model
+ OutfitID int64 `gorm:"column:outfit_id"` // 穿搭ID
+ ClothesID int64 `gorm:"column:clothes_id"` // 衣物ID
+}
+
+func (OutfitItem) TableName() string {
+ return "outfit_items"
+}
diff --git a/internal/mysqlmodel/quota.go b/internal/mysqlmodel/quota.go
new file mode 100644
index 0000000..5200b3e
--- /dev/null
+++ b/internal/mysqlmodel/quota.go
@@ -0,0 +1,45 @@
+package mysqlmodel
+
+type Quota struct {
+ Model
+ UserID int64 `gorm:"column:user_id;uniqueIndex:uk_user_quota,priority:1"` // 用户ID
+ QuotaType int8 `gorm:"column:quota_type;uniqueIndex:uk_user_quota,priority:2"` // 1=cos 2=AI 3=社区分享 4=衣柜分享
+ Total int64 `gorm:"column:total"` // 免费额度底值(仅 Cos 永久额度使用;AI/社区/衣柜 每日额度不再落库)
+ Used int64 `gorm:"column:used"` // 免费额度已用(仅 Cos 使用,永久累计)
+}
+
+func (Quota) TableName() string {
+ return "user_quotas"
+}
+
+// QuotaGrant 有有效期额度凭证(会员包/加量包/广告激励)。
+// AI/社区/衣柜 每日额度场景 Amount 表示「有效期内每天额外赠送次数」,Used 恒为 0;
+// Cos 永久额度场景 Amount/Used 表示「一次性赠送库存及其已用」。
+type QuotaGrant struct {
+ Model
+ UserID int64 `gorm:"column:user_id;index:idx_grant_user_type_expire,priority:1"` // 用户ID
+ QuotaType int8 `gorm:"column:quota_type;index:idx_grant_user_type_expire,priority:2;uniqueIndex:uk_grant_ref_type,priority:2"` // 额度类型 1=cos 2=AI 3=社区分享 4=衣柜分享
+ 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_type,priority:1"` // 关联ID(订单号/广告记录ID)
+}
+
+func (QuotaGrant) TableName() string {
+ return "quota_grants"
+}
+
+type UserDailyUsage struct {
+ Model
+ // UserID 用户ID;与 StatDate 组成联合唯一索引,保证每日每用户仅一行。
+ UserID int64 `gorm:"column:user_id;uniqueIndex:uk_daily_usage_user_date,priority:1"`
+ StatDate int64 `gorm:"column:stat_date;uniqueIndex:uk_daily_usage_user_date,priority:2"` // 统计日期(YYYYMMDD)
+ AIRecommendCount int64 `gorm:"column:ai_recommend_count"` // 当日AI推荐次数
+ OutfitShareCount int64 `gorm:"column:outfit_share_count"` // 当日社区分享次数
+ ClosetShareCount int64 `gorm:"column:closet_share_count"` // 当日衣柜分享次数
+}
+
+func (UserDailyUsage) TableName() string {
+ return "user_daily_usages"
+}
diff --git a/internal/mysqlmodel/quotaflowlog.go b/internal/mysqlmodel/quotaflowlog.go
new file mode 100644
index 0000000..d32dc6f
--- /dev/null
+++ b/internal/mysqlmodel/quotaflowlog.go
@@ -0,0 +1,23 @@
+package mysqlmodel
+
+// QuotaFlowLog 配额流水日志,统计用户配额变更情况
+type QuotaFlowLog struct {
+ Model
+ QuotaFlowUUID string `gorm:"column:quota_flow_uuid"` // 配额流水UUID
+ UserID int64 `gorm:"column:user_id"` // 用户ID
+ ChangeTarget int8 `gorm:"column:change_target"` // 调整额度类型 1=配额调整 2=使用
+ // ChangeType 变更类型 1=购买获赠 2=活动赠送 3=上传消耗 4=删除回退 5=人工调整 6=退款回收
+ ChangeType int8 `gorm:"column:change_type;uniqueIndex:uk_quota_flow_idem,priority:3"`
+ ChangeNum int64 `gorm:"column:change_num"` // 变更数量(正数增加,负数减少)
+ OldValue int64 `gorm:"column:old_value"` // 变更前数值
+ NewValue int64 `gorm:"column:new_value"` // 变更后数值
+ // OrderNo 关联订单号(购买/退款类);消费/回退/初始化类由 DAO 写入流水 UUID 保证联合唯一索引不冲突。
+ OrderNo string `gorm:"column:order_no;uniqueIndex:uk_quota_flow_idem,priority:1"`
+ QuotaType int8 `gorm:"column:quota_type;uniqueIndex:uk_quota_flow_idem,priority:2"` // 关联业务类型 1=cos 2=AI 3=社区分享 4=衣柜分享
+ RelatedID int64 `gorm:"column:related_id"` // 关联业务ID
+ Reason string `gorm:"column:reason"` // 变更原因/备注
+}
+
+func (QuotaFlowLog) TableName() string {
+ return "quota_flow_logs"
+}
diff --git a/internal/mysqlmodel/refundflowlog.go b/internal/mysqlmodel/refundflowlog.go
new file mode 100644
index 0000000..7ded1ee
--- /dev/null
+++ b/internal/mysqlmodel/refundflowlog.go
@@ -0,0 +1,22 @@
+package mysqlmodel
+
+type RefundFlowLog struct {
+ Model
+ OrderID int64 `gorm:"column:order_id"` // 关联订单ID
+ OrderNo string `gorm:"column:order_no"` // 商户订单号(冗余,方便查询)
+ UserID int64 `gorm:"column:user_id"` // 用户ID(冗余,方便按用户查)
+ // RefundNo 商户退款单号;唯一索引为退款申请防重/重试幂等依据。
+ RefundNo string `gorm:"column:refund_no;uniqueIndex:uk_refund_flow_no"`
+ WechatRefundNo string `gorm:"column:wechat_refund_no"` // 微信退款单号
+ RefundAmount int64 `gorm:"column:refund_amount"` // 本次退款金额(分)
+ RefundReason string `gorm:"column:refund_reason"` // 退款原因
+ Status int8 `gorm:"column:status"` // 退款状态 0=待处理 1=退款中 2=退款成功 3=退款失败
+ ApplyTime int64 `gorm:"column:apply_time"` // 申请时间
+ SuccessTime int64 `gorm:"column:success_time"` // 退款成功时间
+ FailReason string `gorm:"column:fail_reason"` // 失败原因
+ CallbackRaw string `gorm:"column:callback_raw"` // 微信退款回调原始报文
+}
+
+func (RefundFlowLog) TableName() string {
+ return "refund_flow_logs"
+}
diff --git a/internal/mysqlmodel/user.go b/internal/mysqlmodel/user.go
new file mode 100644
index 0000000..8711ac2
--- /dev/null
+++ b/internal/mysqlmodel/user.go
@@ -0,0 +1,25 @@
+package mysqlmodel
+
+type User struct {
+ Model
+ UserUUID string `gorm:"column:user_uuid"` // 用户UUID
+ WxOpenID string `gorm:"column:wx_open_id;uniqueIndex:uk_user_wx_open_id"` // 微信openid
+ Username string `gorm:"column:username"` // 用户名
+ Avatar string `gorm:"column:avatar"` // 头像存储位置
+ Sex int8 `gorm:"column:sex"` // 性别 1=男 2=女
+ BirthDay int64 `gorm:"column:birth_day"` // 生日
+ Phone string `gorm:"column:phone"` // 手机号
+ IsNewUser bool `gorm:"column:is_new_user"` // 是否新用户
+ Status int8 `gorm:"column:status"` // 状态 1=正常 2=临时禁用 3=永久禁用 4=已注销
+ BanReason string `gorm:"column:ban_reason"` // 封禁原因
+ BanStart int64 `gorm:"column:ban_start"` // 封禁开始时间
+ BanExpire int64 `gorm:"column:ban_expire"` // 封禁到期时间
+ VIPType int8 `gorm:"column:vip_type"` // VIP类型 1=非会员 2=月度会员 3=年度会员 4=体验会员
+ VIPStart int64 `gorm:"column:vip_start"` // VIP开始时间
+ VIPExpire int64 `gorm:"column:vip_expire"` // VIP到期时间
+ LastLoginTime int64 `gorm:"column:last_login_time"` // 最近登录时间
+}
+
+func (User) TableName() string {
+ return "users"
+}
diff --git a/internal/mysqlmodel/vipflowlog.go b/internal/mysqlmodel/vipflowlog.go
new file mode 100644
index 0000000..2cb7a7b
--- /dev/null
+++ b/internal/mysqlmodel/vipflowlog.go
@@ -0,0 +1,20 @@
+package mysqlmodel
+
+type VIPFlowLog struct {
+ Model
+ UserID int64 `gorm:"column:user_id"` // 用户ID
+ // ChangeType 变更类型 1=开通 2=续费 3=活动赠送 4=到期失效 5=人工调整 6=退款回收
+ ChangeType int8 `gorm:"column:change_type;uniqueIndex:uk_vip_flow_idem,priority:2"`
+ OldVIPType int8 `gorm:"column:old_vip_type"` // 变更前VIP类型 1=非会员 2=月度会员 3=年度会员 4=体验会员
+ NewVIPType int8 `gorm:"column:new_vip_type"` // 变更后VIP类型 1=非会员 2=月度会员 3=年度会员 4=体验会员
+ OldVIPExpire int64 `gorm:"column:old_vip_expire"` // 变更前到期时间(秒)
+ NewVIPExpire int64 `gorm:"column:new_vip_expire"` // 变更后到期时间(秒)
+ // OrderNo 关联订单号(购买类变更);唯一索引 (order_no, change_type) 为 VIP 发放/回收幂等兜底。
+ OrderNo string `gorm:"column:order_no;uniqueIndex:uk_vip_flow_idem,priority:1"`
+ Source int8 `gorm:"column:source"` // 变更来源 1=个人支付 2=活动赠送 3=系统 4=人工调整
+ Reason string `gorm:"column:reason"` // 变更原因/备注
+}
+
+func (VIPFlowLog) TableName() string {
+ return "vip_flow_logs"
+}
diff --git a/internal/redisdao/auth.go b/internal/redisdao/auth.go
new file mode 100644
index 0000000..a55f9ea
--- /dev/null
+++ b/internal/redisdao/auth.go
@@ -0,0 +1,122 @@
+package redisdao
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel"
+ "github.com/redis/go-redis/v9"
+)
+
+// authTokenSAddScript 向用户 token 集合加入 refresh_token,仅在集合由空变为非空时设置 TTL,
+// 避免每次登录都刷新集合 TTL 导致旧 token 条目长期驻留。
+const authTokenSAddScript = `
+local added = redis.call('SADD', KEYS[1], ARGV[1])
+if added == 1 and redis.call('SCARD', KEYS[1]) == 1 then
+ redis.call('EXPIRE', KEYS[1], ARGV[2])
+end
+return added
+`
+
+// AuthTokenSet 登录成功后写入会话:
+// - access_token -> user_id
+// - refresh_token -> user_id
+// - user_id 的 token 集合加入 refresh_token(用于多端管理 / 登出)
+func AuthTokenSet(ctx context.Context, userID int64, accessToken, refreshToken string) error {
+ ctx, span := global.Tracer.Start(ctx, "redisdao.AuthTokenSet")
+ defer span.End()
+
+ accessKey := fmt.Sprintf(redismodel.KeyAuthAccess, accessToken)
+ refreshKey := fmt.Sprintf(redismodel.KeyAuthRefresh, refreshToken)
+ userTokensKey := fmt.Sprintf(redismodel.KeyAuthUserTokens, userID)
+
+ accessTTL := time.Duration(global.JWTSetting.AccessTokenExpire) * time.Second
+ refreshTTL := time.Duration(global.JWTSetting.RefreshTokenExpire) * time.Second
+
+ pipe := global.RedisEngine.TxPipeline()
+ pipe.Set(ctx, accessKey, userID, accessTTL)
+ pipe.Set(ctx, refreshKey, userID, refreshTTL)
+ pipe.Eval(ctx, authTokenSAddScript, []string{userTokensKey}, refreshToken, int(refreshTTL.Seconds()))
+ if _, err := pipe.Exec(ctx); err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// AuthTokenDel 登出/失效:删除 access、refresh 映射,并从用户 token 集合移除 refresh_token
+func AuthTokenDel(ctx context.Context, userID int64, accessToken, refreshToken string) error {
+ ctx, span := global.Tracer.Start(ctx, "redisdao.AuthTokenDel")
+ defer span.End()
+
+ accessKey := fmt.Sprintf(redismodel.KeyAuthAccess, accessToken)
+ refreshKey := fmt.Sprintf(redismodel.KeyAuthRefresh, refreshToken)
+ userTokensKey := fmt.Sprintf(redismodel.KeyAuthUserTokens, userID)
+
+ pipe := global.RedisEngine.TxPipeline()
+ pipe.Del(ctx, accessKey)
+ pipe.Del(ctx, refreshKey)
+ pipe.SRem(ctx, userTokensKey, refreshToken)
+ if _, err := pipe.Exec(ctx); err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// AuthUserIDByAccess 通过 access token 查询 user_id,token 不存在或已过期返回 redis.Nil
+func AuthUserIDByAccess(ctx context.Context, accessToken string) (int64, error) {
+ ctx, span := global.Tracer.Start(ctx, "redisdao.AuthUserIDByAccess")
+ defer span.End()
+
+ key := fmt.Sprintf(redismodel.KeyAuthAccess, accessToken)
+ userID, err := global.RedisEngine.Get(ctx, key).Int64()
+ if err != nil && err != redis.Nil {
+ span.RecordError(err)
+ }
+ return userID, err
+}
+
+// AuthUserIDByRefresh 通过 refresh token 查询 user_id,token 不存在或已过期返回 redis.Nil
+func AuthUserIDByRefresh(ctx context.Context, refreshToken string) (int64, error) {
+ ctx, span := global.Tracer.Start(ctx, "redisdao.AuthUserIDByRefresh")
+ defer span.End()
+
+ key := fmt.Sprintf(redismodel.KeyAuthRefresh, refreshToken)
+ userID, err := global.RedisEngine.Get(ctx, key).Int64()
+ if err != nil && err != redis.Nil {
+ span.RecordError(err)
+ }
+ return userID, err
+}
+
+// sessionKeyTTL session_key 缓存时长(30 天,与微信 session_key 有效期对齐)。
+const sessionKeyTTL = 30 * 24 * time.Hour
+
+// AuthSessionKeySet 写入用户的 session_key(登录时 code2session 返回,用于虚拟支付 signature 计算)。
+func AuthSessionKeySet(ctx context.Context, userID int64, sessionKey string) error {
+ ctx, span := global.Tracer.Start(ctx, "redisdao.AuthSessionKeySet")
+ defer span.End()
+
+ key := fmt.Sprintf(redismodel.KeyAuthSessionKey, userID)
+ if err := global.RedisEngine.Set(ctx, key, sessionKey, sessionKeyTTL).Err(); err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// AuthSessionKeyGet 读取用户的 session_key,不存在返回 redis.Nil。
+func AuthSessionKeyGet(ctx context.Context, userID int64) (string, error) {
+ ctx, span := global.Tracer.Start(ctx, "redisdao.AuthSessionKeyGet")
+ defer span.End()
+
+ key := fmt.Sprintf(redismodel.KeyAuthSessionKey, userID)
+ sessionKey, err := global.RedisEngine.Get(ctx, key).Result()
+ if err != nil && err != redis.Nil {
+ span.RecordError(err)
+ }
+ return sessionKey, err
+}
diff --git a/internal/redisdao/closet.go b/internal/redisdao/closet.go
new file mode 100644
index 0000000..e0a6b9a
--- /dev/null
+++ b/internal/redisdao/closet.go
@@ -0,0 +1,27 @@
+package redisdao
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel"
+)
+
+// viewCountTTL 访问计数 key 的过期时间,避免长期驻留导致内存无界增长。
+const viewCountTTL = 7 * 24 * time.Hour
+
+// ClosetShareViewIncr 衣柜分享访问计数 +1,返回最新值
+func ClosetShareViewIncr(ctx context.Context, shareUUID string) (int64, error) {
+ ctx, span := global.Tracer.Start(ctx, "redisdao.ClosetShareViewIncr")
+ defer span.End()
+
+ key := fmt.Sprintf(redismodel.KeyClosetShareView, shareUUID)
+ val, err := quotaDailyIncrScript.Run(ctx, global.RedisEngine, []string{key}, int(viewCountTTL.Seconds())).Int64()
+ if err != nil {
+ span.RecordError(err)
+ return 0, err
+ }
+ return val, nil
+}
diff --git a/internal/redisdao/collect.go b/internal/redisdao/collect.go
new file mode 100644
index 0000000..1f28c01
--- /dev/null
+++ b/internal/redisdao/collect.go
@@ -0,0 +1,79 @@
+package redisdao
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel"
+ "github.com/redis/go-redis/v9"
+)
+
+// collectSetTTL 收藏集合 key 的过期时间,避免长期驻留导致与 MySQL 弱一致漂移。
+const collectSetTTL = 7 * 24 * time.Hour
+
+// CollectAdd 用户收藏(collect_type + target_id 分桶,成员为 user_id)
+func CollectAdd(ctx context.Context, collectType int8, targetID, userID int64) error {
+ ctx, span := global.Tracer.Start(ctx, "redisdao.CollectAdd")
+ defer span.End()
+
+ key := fmt.Sprintf(redismodel.KeyCollectSet, collectType, targetID)
+ if err := global.RedisEngine.SAdd(ctx, key, userID).Err(); err != nil {
+ span.RecordError(err)
+ return err
+ }
+ // 设置 TTL(best-effort)。
+ _ = global.RedisEngine.Expire(ctx, key, collectSetTTL).Err()
+ return nil
+}
+
+// CollectRemove 用户取消收藏
+func CollectRemove(ctx context.Context, collectType int8, targetID, userID int64) error {
+ ctx, span := global.Tracer.Start(ctx, "redisdao.CollectRemove")
+ defer span.End()
+
+ key := fmt.Sprintf(redismodel.KeyCollectSet, collectType, targetID)
+ if err := global.RedisEngine.SRem(ctx, key, userID).Err(); err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// CollectIsMember 判断用户是否已收藏;集合 key 不存在(缓存过期/冷启动)时返回 redis.Nil,
+// 由调用方回落 MySQL 以区分「未命中」与「确实未收藏」。
+func CollectIsMember(ctx context.Context, collectType int8, targetID, userID int64) (bool, error) {
+ ctx, span := global.Tracer.Start(ctx, "redisdao.CollectIsMember")
+ defer span.End()
+
+ key := fmt.Sprintf(redismodel.KeyCollectSet, collectType, targetID)
+ exists, err := global.RedisEngine.Exists(ctx, key).Result()
+ if err != nil {
+ span.RecordError(err)
+ return false, err
+ }
+ if exists == 0 {
+ return false, redis.Nil
+ }
+ ok, err := global.RedisEngine.SIsMember(ctx, key, userID).Result()
+ if err != nil {
+ span.RecordError(err)
+ return false, err
+ }
+ return ok, nil
+}
+
+// CollectCount 获取收藏总数
+func CollectCount(ctx context.Context, collectType int8, targetID int64) (int64, error) {
+ ctx, span := global.Tracer.Start(ctx, "redisdao.CollectCount")
+ defer span.End()
+
+ key := fmt.Sprintf(redismodel.KeyCollectSet, collectType, targetID)
+ cnt, err := global.RedisEngine.SCard(ctx, key).Result()
+ if err != nil {
+ span.RecordError(err)
+ return 0, err
+ }
+ return cnt, nil
+}
diff --git a/internal/redisdao/horoscope.go b/internal/redisdao/horoscope.go
new file mode 100644
index 0000000..0f2595f
--- /dev/null
+++ b/internal/redisdao/horoscope.go
@@ -0,0 +1,37 @@
+package redisdao
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel"
+ "github.com/redis/go-redis/v9"
+)
+
+// HoroscopeSet 缓存星座运势内容(zodiac + 日期维度,data 由上层负责序列化)
+func HoroscopeSet(ctx context.Context, zodiac string, statDate int64, data []byte, ttl time.Duration) error {
+ ctx, span := global.Tracer.Start(ctx, "redisdao.HoroscopeSet")
+ defer span.End()
+
+ key := fmt.Sprintf(redismodel.KeyHoroscope, zodiac, statDate)
+ if err := global.RedisEngine.Set(ctx, key, data, ttl).Err(); err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// HoroscopeGet 读取星座运势缓存,未命中返回 redis.Nil
+func HoroscopeGet(ctx context.Context, zodiac string, statDate int64) ([]byte, error) {
+ ctx, span := global.Tracer.Start(ctx, "redisdao.HoroscopeGet")
+ defer span.End()
+
+ key := fmt.Sprintf(redismodel.KeyHoroscope, zodiac, statDate)
+ data, err := global.RedisEngine.Get(ctx, key).Bytes()
+ if err != nil && err != redis.Nil {
+ span.RecordError(err)
+ }
+ return data, err
+}
diff --git a/internal/redisdao/like.go b/internal/redisdao/like.go
new file mode 100644
index 0000000..c1dcbc7
--- /dev/null
+++ b/internal/redisdao/like.go
@@ -0,0 +1,79 @@
+package redisdao
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel"
+ "github.com/redis/go-redis/v9"
+)
+
+// likeSetTTL 点赞集合 key 的过期时间,避免长期驻留导致与 MySQL 弱一致漂移。
+const likeSetTTL = 7 * 24 * time.Hour
+
+// LikeAdd 用户点赞(like_type + target_id 分桶,成员为 user_id)
+func LikeAdd(ctx context.Context, likeType int8, targetID, userID int64) error {
+ ctx, span := global.Tracer.Start(ctx, "redisdao.LikeAdd")
+ defer span.End()
+
+ key := fmt.Sprintf(redismodel.KeyLikeSet, likeType, targetID)
+ if err := global.RedisEngine.SAdd(ctx, key, userID).Err(); err != nil {
+ span.RecordError(err)
+ return err
+ }
+ // 设置 TTL(best-effort)。
+ _ = global.RedisEngine.Expire(ctx, key, likeSetTTL).Err()
+ return nil
+}
+
+// LikeRemove 用户取消点赞
+func LikeRemove(ctx context.Context, likeType int8, targetID, userID int64) error {
+ ctx, span := global.Tracer.Start(ctx, "redisdao.LikeRemove")
+ defer span.End()
+
+ key := fmt.Sprintf(redismodel.KeyLikeSet, likeType, targetID)
+ if err := global.RedisEngine.SRem(ctx, key, userID).Err(); err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// LikeIsMember 判断用户是否已点赞;集合 key 不存在(缓存过期/冷启动)时返回 redis.Nil,
+// 由调用方回落 MySQL 以区分「未命中」与「确实未点赞」。
+func LikeIsMember(ctx context.Context, likeType int8, targetID, userID int64) (bool, error) {
+ ctx, span := global.Tracer.Start(ctx, "redisdao.LikeIsMember")
+ defer span.End()
+
+ key := fmt.Sprintf(redismodel.KeyLikeSet, likeType, targetID)
+ exists, err := global.RedisEngine.Exists(ctx, key).Result()
+ if err != nil {
+ span.RecordError(err)
+ return false, err
+ }
+ if exists == 0 {
+ return false, redis.Nil
+ }
+ ok, err := global.RedisEngine.SIsMember(ctx, key, userID).Result()
+ if err != nil {
+ span.RecordError(err)
+ return false, err
+ }
+ return ok, nil
+}
+
+// LikeCount 获取点赞总数
+func LikeCount(ctx context.Context, likeType int8, targetID int64) (int64, error) {
+ ctx, span := global.Tracer.Start(ctx, "redisdao.LikeCount")
+ defer span.End()
+
+ key := fmt.Sprintf(redismodel.KeyLikeSet, likeType, targetID)
+ cnt, err := global.RedisEngine.SCard(ctx, key).Result()
+ if err != nil {
+ span.RecordError(err)
+ return 0, err
+ }
+ return cnt, nil
+}
diff --git a/internal/redisdao/lock.go b/internal/redisdao/lock.go
new file mode 100644
index 0000000..a621dd2
--- /dev/null
+++ b/internal/redisdao/lock.go
@@ -0,0 +1,44 @@
+package redisdao
+
+import (
+ "context"
+ "time"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "github.com/redis/go-redis/v9"
+)
+
+// unlockScript 仅当锁值匹配时才删除,防止误删其他持有者的锁
+var unlockScript = redis.NewScript(`
+if redis.call('GET', KEYS[1]) == ARGV[1] then
+ return redis.call('DEL', KEYS[1])
+else
+ return 0
+end
+`)
+
+// TryLock 尝试获取分布式锁(SET NX EX),获取成功返回 true。
+// value 建议传唯一标识(如 request_id / uuid),用于释放时校验持有者。
+func TryLock(ctx context.Context, key, value string, ttl time.Duration) (bool, error) {
+ ctx, span := global.Tracer.Start(ctx, "redisdao.TryLock")
+ defer span.End()
+
+ ok, err := global.RedisEngine.SetNX(ctx, key, value, ttl).Result()
+ if err != nil {
+ span.RecordError(err)
+ return false, err
+ }
+ return ok, nil
+}
+
+// Unlock 释放分布式锁,仅当 value 匹配时才删除;锁不存在或不属于自己时静默返回。
+func Unlock(ctx context.Context, key, value string) error {
+ ctx, span := global.Tracer.Start(ctx, "redisdao.Unlock")
+ defer span.End()
+
+ if err := unlockScript.Run(ctx, global.RedisEngine, []string{key}, value).Err(); err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
diff --git a/internal/redisdao/notification.go b/internal/redisdao/notification.go
new file mode 100644
index 0000000..92d8d0c
--- /dev/null
+++ b/internal/redisdao/notification.go
@@ -0,0 +1,89 @@
+package redisdao
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel"
+ "github.com/redis/go-redis/v9"
+)
+
+// notificationUnreadTTL 未读数 key 的过期时间,避免长期驻留与 MySQL 弱一致漂移。
+const notificationUnreadTTL = 7 * 24 * time.Hour
+
+// notificationUnreadIncrScript 首次自增时设置过期时间,保证 INCR 与 EXPIRE 原子执行。
+var notificationUnreadIncrScript = redis.NewScript(`
+local v = redis.call('INCR', KEYS[1])
+if v == 1 then
+ redis.call('EXPIRE', KEYS[1], ARGV[1])
+end
+return v
+`)
+
+// notificationUnreadDecrScript 未读数 -1,保证结果不低于 0
+var notificationUnreadDecrScript = redis.NewScript(`
+local v = redis.call('GET', KEYS[1])
+if v and tonumber(v) > 0 then
+ return redis.call('DECRBY', KEYS[1], 1)
+end
+return 0
+`)
+
+// NotificationUnreadIncr 用户未读消息数 +1,返回最新值
+func NotificationUnreadIncr(ctx context.Context, userID int64) (int64, error) {
+ ctx, span := global.Tracer.Start(ctx, "redisdao.NotificationUnreadIncr")
+ defer span.End()
+
+ key := fmt.Sprintf(redismodel.KeyNotificationUnread, userID)
+ val, err := notificationUnreadIncrScript.Run(ctx, global.RedisEngine, []string{key}, int(notificationUnreadTTL.Seconds())).Int64()
+ if err != nil {
+ span.RecordError(err)
+ return 0, err
+ }
+ return val, nil
+}
+
+// NotificationUnreadDecr 用户未读消息数 -1,返回扣减后的值(不低于 0)
+func NotificationUnreadDecr(ctx context.Context, userID int64) (int64, error) {
+ ctx, span := global.Tracer.Start(ctx, "redisdao.NotificationUnreadDecr")
+ defer span.End()
+
+ key := fmt.Sprintf(redismodel.KeyNotificationUnread, userID)
+ val, err := notificationUnreadDecrScript.Run(ctx, global.RedisEngine, []string{key}).Int64()
+ if err != nil {
+ span.RecordError(err)
+ return 0, err
+ }
+ return val, nil
+}
+
+// NotificationUnreadGet 获取用户未读消息数,不存在时返回 0
+func NotificationUnreadGet(ctx context.Context, userID int64) (int64, error) {
+ ctx, span := global.Tracer.Start(ctx, "redisdao.NotificationUnreadGet")
+ defer span.End()
+
+ key := fmt.Sprintf(redismodel.KeyNotificationUnread, userID)
+ v, err := global.RedisEngine.Get(ctx, key).Int64()
+ if err == redis.Nil {
+ return 0, nil
+ }
+ if err != nil {
+ span.RecordError(err)
+ }
+ return v, err
+}
+
+// NotificationUnreadSet 回填未读数缓存(DB 对账回源后使用),避免 key 过期导致后续扣减被吞。
+func NotificationUnreadSet(ctx context.Context, userID int64, cnt int64) error {
+ ctx, span := global.Tracer.Start(ctx, "redisdao.NotificationUnreadSet")
+ defer span.End()
+
+ key := fmt.Sprintf(redismodel.KeyNotificationUnread, userID)
+ if err := global.RedisEngine.Set(ctx, key, cnt, notificationUnreadTTL).Err(); err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
diff --git a/internal/redisdao/outfit.go b/internal/redisdao/outfit.go
new file mode 100644
index 0000000..39b79af
--- /dev/null
+++ b/internal/redisdao/outfit.go
@@ -0,0 +1,23 @@
+package redisdao
+
+import (
+ "context"
+ "fmt"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel"
+)
+
+// OutfitViewIncr 公开穿搭浏览计数 +1,返回最新值
+func OutfitViewIncr(ctx context.Context, outfitID int64) (int64, error) {
+ ctx, span := global.Tracer.Start(ctx, "redisdao.OutfitViewIncr")
+ defer span.End()
+
+ key := fmt.Sprintf(redismodel.KeyOutfitView, outfitID)
+ val, err := quotaDailyIncrScript.Run(ctx, global.RedisEngine, []string{key}, int(viewCountTTL.Seconds())).Int64()
+ if err != nil {
+ span.RecordError(err)
+ return 0, err
+ }
+ return val, nil
+}
diff --git a/internal/redisdao/quota.go b/internal/redisdao/quota.go
new file mode 100644
index 0000000..49cfe7a
--- /dev/null
+++ b/internal/redisdao/quota.go
@@ -0,0 +1,186 @@
+package redisdao
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel"
+ "github.com/redis/go-redis/v9"
+)
+
+// quotaDailyTTL 每日计数 key 的过期时间(跨天,保证到期自然过期)
+const quotaDailyTTL = 48 * time.Hour
+
+// quotaDailyIncrScript 首次自增时设置过期时间,保证 INCR 与 EXPIRE 原子执行
+var quotaDailyIncrScript = redis.NewScript(`
+local v = redis.call('INCR', KEYS[1])
+if v == 1 then
+ redis.call('EXPIRE', KEYS[1], ARGV[1])
+end
+return v
+`)
+
+// quotaDailyIncr 每日计数 +1
+func quotaDailyIncr(ctx context.Context, key string) (int64, error) {
+ return quotaDailyIncrScript.Run(ctx, global.RedisEngine, []string{key}, int(quotaDailyTTL.Seconds())).Int64()
+}
+
+// quotaDailyValue 读取每日计数,key 不存在时返回 0
+func quotaDailyValue(ctx context.Context, key string) (int64, error) {
+ v, err := global.RedisEngine.Get(ctx, key).Int64()
+ if err == redis.Nil {
+ return 0, nil
+ }
+ return v, err
+}
+
+// QuotaDailyAIIncr 当日 AI 推荐次数 +1
+func QuotaDailyAIIncr(ctx context.Context, userID int64, date int64) (int64, error) {
+ ctx, span := global.Tracer.Start(ctx, "redisdao.QuotaDailyAIIncr")
+ defer span.End()
+
+ val, err := quotaDailyIncr(ctx, fmt.Sprintf(redismodel.KeyQuotaDailyAI, userID, date))
+ if err != nil {
+ span.RecordError(err)
+ return 0, err
+ }
+ return val, nil
+}
+
+// QuotaDailyOutfitShareIncr 当日社区分享(搭配分享)次数 +1
+func QuotaDailyOutfitShareIncr(ctx context.Context, userID int64, date int64) (int64, error) {
+ ctx, span := global.Tracer.Start(ctx, "redisdao.QuotaDailyOutfitShareIncr")
+ defer span.End()
+
+ val, err := quotaDailyIncr(ctx, fmt.Sprintf(redismodel.KeyQuotaDailyOutfitShare, userID, date))
+ if err != nil {
+ span.RecordError(err)
+ return 0, err
+ }
+ return val, nil
+}
+
+// QuotaDailyClosetShareIncr 当日衣柜分享次数 +1
+func QuotaDailyClosetShareIncr(ctx context.Context, userID int64, date int64) (int64, error) {
+ ctx, span := global.Tracer.Start(ctx, "redisdao.QuotaDailyClosetShareIncr")
+ defer span.End()
+
+ val, err := quotaDailyIncr(ctx, fmt.Sprintf(redismodel.KeyQuotaDailyClosetShare, userID, date))
+ if err != nil {
+ span.RecordError(err)
+ return 0, err
+ }
+ return val, nil
+}
+
+// QuotaDailyAIValue 当日 AI 推荐次数
+func QuotaDailyAIValue(ctx context.Context, userID int64, date int64) (int64, error) {
+ ctx, span := global.Tracer.Start(ctx, "redisdao.QuotaDailyAIValue")
+ defer span.End()
+
+ val, err := quotaDailyValue(ctx, fmt.Sprintf(redismodel.KeyQuotaDailyAI, userID, date))
+ if err != nil {
+ span.RecordError(err)
+ return 0, err
+ }
+ return val, nil
+}
+
+// QuotaDailyOutfitShareValue 当日社区分享(搭配分享)次数
+func QuotaDailyOutfitShareValue(ctx context.Context, userID int64, date int64) (int64, error) {
+ ctx, span := global.Tracer.Start(ctx, "redisdao.QuotaDailyOutfitShareValue")
+ defer span.End()
+
+ val, err := quotaDailyValue(ctx, fmt.Sprintf(redismodel.KeyQuotaDailyOutfitShare, userID, date))
+ if err != nil {
+ span.RecordError(err)
+ return 0, err
+ }
+ return val, nil
+}
+
+// QuotaDailyClosetShareValue 当日衣柜分享次数
+func QuotaDailyClosetShareValue(ctx context.Context, userID int64, date int64) (int64, error) {
+ ctx, span := global.Tracer.Start(ctx, "redisdao.QuotaDailyClosetShareValue")
+ defer span.End()
+
+ val, err := quotaDailyValue(ctx, fmt.Sprintf(redismodel.KeyQuotaDailyClosetShare, userID, date))
+ if err != nil {
+ span.RecordError(err)
+ return 0, err
+ }
+ return val, nil
+}
+
+// ---- 通用每日额度原子限流(带限额) ----
+
+// dailyQuotaConsumeScript 带限额的原子自增:
+// KEYS[1]=当日计数 key;ARGV[1]=每日上限;ARGV[2]=消费数量;ARGV[3]=TTL 秒。
+// 当前计数 + n 超过上限返回 -1;否则 INCRBY 并在首次写入时设置 TTL,返回消费后的当日计数。
+var dailyQuotaConsumeScript = redis.NewScript(`
+local v = tonumber(redis.call('GET', KEYS[1]) or '0')
+local limit = tonumber(ARGV[1])
+local n = tonumber(ARGV[2])
+if v + n > limit then
+ return -1
+end
+local r = redis.call('INCRBY', KEYS[1], n)
+if v == 0 then
+ redis.call('EXPIRE', KEYS[1], ARGV[3])
+end
+return r
+`)
+
+// dailyQuotaRefundScript 回退当日计数(DECRBY 且不小于 0)。
+// ARGV[1]=回退数量。
+var dailyQuotaRefundScript = redis.NewScript(`
+local v = tonumber(redis.call('GET', KEYS[1]) or '0')
+local n = tonumber(ARGV[1])
+if v <= 0 then
+ return 0
+end
+if n > v then
+ n = v
+end
+return redis.call('DECRBY', KEYS[1], n)
+`)
+
+// DailyQuotaConsume 原子消费当日额度。
+// 返回 (消费后计数, nil);额度不足返回 (-1, nil);Redis 异常返回 (0, err)。
+func DailyQuotaConsume(ctx context.Context, key string, limit int64, n int64) (int64, error) {
+ ctx, span := global.Tracer.Start(ctx, "redisdao.DailyQuotaConsume")
+ defer span.End()
+
+ if limit <= 0 || n <= 0 {
+ return -1, nil
+ }
+ val, err := dailyQuotaConsumeScript.Run(ctx, global.RedisEngine, []string{key}, limit, n, int(quotaDailyTTL.Seconds())).Int64()
+ if err != nil {
+ span.RecordError(err)
+ return 0, err
+ }
+ return val, nil
+}
+
+// DailyQuotaRefund 回退当日计数(保证不小于 0)。失败仅记录,调用方按 best-effort 处理。
+func DailyQuotaRefund(ctx context.Context, key string, n int64) error {
+ ctx, span := global.Tracer.Start(ctx, "redisdao.DailyQuotaRefund")
+ defer span.End()
+
+ if n <= 0 {
+ return nil
+ }
+ if _, err := dailyQuotaRefundScript.Run(ctx, global.RedisEngine, []string{key}, n).Int64(); err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// DailyQuotaValue 读取当日计数,key 不存在时返回 0。
+func DailyQuotaValue(ctx context.Context, key string) (int64, error) {
+ return quotaDailyValue(ctx, key)
+}
+
diff --git a/internal/redisdao/user.go b/internal/redisdao/user.go
new file mode 100644
index 0000000..84fb86e
--- /dev/null
+++ b/internal/redisdao/user.go
@@ -0,0 +1,71 @@
+package redisdao
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "time"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel"
+ "github.com/redis/go-redis/v9"
+)
+
+// userInfoTTL 用户鉴权信息缓存默认过期时间
+const userInfoTTL = 24 * time.Hour
+
+// UserInfoSet 缓存用户鉴权核心信息(含 Status,供中间件鉴权时快速读取)
+func UserInfoSet(ctx context.Context, user *mysqlmodel.User, ttl time.Duration) error {
+ ctx, span := global.Tracer.Start(ctx, "redisdao.UserInfoSet")
+ defer span.End()
+
+ if ttl <= 0 {
+ ttl = userInfoTTL
+ }
+ data, err := json.Marshal(user)
+ if err != nil {
+ span.RecordError(err)
+ return err
+ }
+ key := fmt.Sprintf(redismodel.KeyUserInfo, user.ID)
+ if err := global.RedisEngine.Set(ctx, key, data, ttl).Err(); err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// UserInfoDel 删除用户缓存(用户资料变更/注销时失效缓存)
+func UserInfoDel(ctx context.Context, userID int64) error {
+ ctx, span := global.Tracer.Start(ctx, "redisdao.UserInfoDel")
+ defer span.End()
+
+ key := fmt.Sprintf(redismodel.KeyUserInfo, userID)
+ if err := global.RedisEngine.Del(ctx, key).Err(); err != nil {
+ span.RecordError(err)
+ return err
+ }
+ return nil
+}
+
+// UserInfoGet 读取缓存中的用户鉴权核心信息,未命中返回 redis.Nil
+func UserInfoGet(ctx context.Context, userID int64) (*mysqlmodel.User, error) {
+ ctx, span := global.Tracer.Start(ctx, "redisdao.UserInfoGet")
+ defer span.End()
+
+ key := fmt.Sprintf(redismodel.KeyUserInfo, userID)
+ data, err := global.RedisEngine.Get(ctx, key).Bytes()
+ if err != nil {
+ if err != redis.Nil {
+ span.RecordError(err)
+ }
+ return nil, err
+ }
+ var user mysqlmodel.User
+ if err := json.Unmarshal(data, &user); err != nil {
+ span.RecordError(err)
+ return nil, err
+ }
+ return &user, nil
+}
diff --git a/internal/redismodel/auth.go b/internal/redismodel/auth.go
new file mode 100644
index 0000000..6eaa1f2
--- /dev/null
+++ b/internal/redismodel/auth.go
@@ -0,0 +1,9 @@
+package redismodel
+
+// 认证模块:token 与用户会话的 Redis key。
+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,支持多端登录)
+ KeyAuthSessionKey = RedisPrefix + "auth:session_key:%d" // user_id -> session_key(String,虚拟支付 signature 用)
+)
diff --git a/internal/redismodel/closet.go b/internal/redismodel/closet.go
new file mode 100644
index 0000000..f3e9c8c
--- /dev/null
+++ b/internal/redismodel/closet.go
@@ -0,0 +1,4 @@
+package redismodel
+
+// 衣柜分享模块:分享访问计数。
+const KeyClosetShareView = RedisPrefix + "closet:share:view:%s" // share_uuid,衣柜分享访问计数(INCR)
diff --git a/internal/redismodel/collect.go b/internal/redismodel/collect.go
new file mode 100644
index 0000000..3da1732
--- /dev/null
+++ b/internal/redismodel/collect.go
@@ -0,0 +1,4 @@
+package redismodel
+
+// 收藏模块:按业务类型 + 目标 ID 分桶的收藏集合。
+const KeyCollectSet = RedisPrefix + "collect:set:%d:%d" // collect_type:target_id,成员为 user_id(Set,1=社区穿搭)
diff --git a/internal/redismodel/horoscope.go b/internal/redismodel/horoscope.go
new file mode 100644
index 0000000..024feeb
--- /dev/null
+++ b/internal/redismodel/horoscope.go
@@ -0,0 +1,4 @@
+package redismodel
+
+// 星座模块:按星座 + 日期缓存的运势内容。
+const KeyHoroscope = RedisPrefix + "horoscope:%s:%d" // zodiac:stat_date(YYYYMMDD),星座运势缓存(String/JSON)
diff --git a/internal/redismodel/keys.go b/internal/redismodel/keys.go
new file mode 100644
index 0000000..0324670
--- /dev/null
+++ b/internal/redismodel/keys.go
@@ -0,0 +1,41 @@
+package redismodel
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "time"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/setting"
+ "github.com/redis/go-redis/v9"
+)
+
+// RedisPrefix 是所有 Redis key 的全局命名空间前缀,避免多服务共享 Redis 实例时发生 key 冲突。
+const RedisPrefix = "ztcloset:"
+
+// NewRedisClient 根据配置创建 Redis 客户端并验证连接。
+func NewRedisEngine(s *setting.RedisSettingS) (*redis.Client, error) {
+ client := redis.NewClient(&redis.Options{
+ Addr: fmt.Sprintf("%s:%d", s.Host, s.Port),
+ Password: s.Password,
+ DB: s.DB,
+ PoolSize: s.PoolSize,
+ MinIdleConns: s.MinIdleConns,
+ MaxIdleConns: s.MaxIdleConns,
+ MinRetryBackoff: time.Duration(s.MinRetryBackoff) * time.Millisecond,
+ MaxRetryBackoff: time.Duration(s.MaxRetryBackoff) * time.Millisecond,
+ MaxRetries: s.MaxRetries,
+ // 连接/读写/池超时配置单位为秒(与 config.yaml 注释一致)。
+ DialTimeout: time.Duration(s.DialTimeout) * time.Second,
+ ReadTimeout: time.Duration(s.ReadTimeout) * time.Second,
+ WriteTimeout: time.Duration(s.WriteTimeout) * time.Second,
+ PoolTimeout: time.Duration(s.PoolTimeout) * time.Second,
+ })
+
+ if err := client.Ping(context.Background()).Err(); err != nil {
+ return nil, fmt.Errorf("redis ping failed: %w", err)
+ }
+
+ slog.Info("redis connected", "addr", fmt.Sprintf("%s:%d", s.Host, s.Port))
+ return client, nil
+}
diff --git a/internal/redismodel/like.go b/internal/redismodel/like.go
new file mode 100644
index 0000000..12f0e1d
--- /dev/null
+++ b/internal/redismodel/like.go
@@ -0,0 +1,4 @@
+package redismodel
+
+// 点赞模块:按业务类型 + 目标 ID 分桶的点赞集合。
+const KeyLikeSet = RedisPrefix + "like:set:%d:%d" // like_type:target_id,成员为 user_id(Set,1=社区穿搭)
diff --git a/internal/redismodel/lock.go b/internal/redismodel/lock.go
new file mode 100644
index 0000000..292434b
--- /dev/null
+++ b/internal/redismodel/lock.go
@@ -0,0 +1,13 @@
+package redismodel
+
+// 分布式锁与幂等模块:对齐 mysqlmodel 的 Order、AIRecommend、AIHoroscope、审核等业务。
+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,穿搭审核防重
+ KeyLockOutfitPublic = RedisPrefix + "lock:outfit:public:%s" // outfit_uuid,穿搭公开/取消公开串行化
+)
diff --git a/internal/redismodel/notification.go b/internal/redismodel/notification.go
new file mode 100644
index 0000000..1e22cce
--- /dev/null
+++ b/internal/redismodel/notification.go
@@ -0,0 +1,4 @@
+package redismodel
+
+// 通知模块:用户未读消息计数。
+const KeyNotificationUnread = RedisPrefix + "notification:unread:%d" // user_id,未读消息数(INCR/DECR)
diff --git a/internal/redismodel/outfit.go b/internal/redismodel/outfit.go
new file mode 100644
index 0000000..427b26c
--- /dev/null
+++ b/internal/redismodel/outfit.go
@@ -0,0 +1,4 @@
+package redismodel
+
+// 穿搭模块:公开穿搭浏览计数。
+const KeyOutfitView = RedisPrefix + "outfit:view:%d" // outfit_id,公开穿搭浏览计数(INCR)
diff --git a/internal/redismodel/quota.go b/internal/redismodel/quota.go
new file mode 100644
index 0000000..490d499
--- /dev/null
+++ b/internal/redismodel/quota.go
@@ -0,0 +1,9 @@
+package redismodel
+
+// 配额模块:对齐 mysqlmodel 的 Quota(总配额)与 UserDailyUsage(每日使用计数)。
+// 说明:每日使用计数使用 Redis INCR 做当日限流统计,权威值仍以 MySQL 为准。
+const (
+ KeyQuotaDailyAI = RedisPrefix + "quota:daily:ai:%d:%d" // user_id:date(YYYYMMDD),当日AI推荐次数(INCR)
+ KeyQuotaDailyOutfitShare = RedisPrefix + "quota:daily:outfit_share:%d:%d" // user_id:date(YYYYMMDD),当日社区分享次数(搭配分享,INCR)
+ KeyQuotaDailyClosetShare = RedisPrefix + "quota:daily:closet_share:%d:%d" // user_id:date(YYYYMMDD),当日衣柜分享次数(INCR)
+)
diff --git a/internal/redismodel/user.go b/internal/redismodel/user.go
new file mode 100644
index 0000000..512f777
--- /dev/null
+++ b/internal/redismodel/user.go
@@ -0,0 +1,4 @@
+package redismodel
+
+// 用户模块:用户鉴权核心信息缓存。
+const KeyUserInfo = RedisPrefix + "user:info:%d" // user_id -> 用户鉴权核心信息(String/JSON,含 Status)
diff --git a/internal/routers/api/v1/adreward.go b/internal/routers/api/v1/adreward.go
new file mode 100644
index 0000000..b3e5f79
--- /dev/null
+++ b/internal/routers/api/v1/adreward.go
@@ -0,0 +1,41 @@
+package v1
+
+import (
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/middleware"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/service"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/app"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
+ "github.com/gin-gonic/gin"
+)
+
+// AdRewardHandler 广告激励额度接口。
+type AdRewardHandler struct {
+ svc *service.AdRewardService
+}
+
+// NewAdRewardHandler 创建广告激励 handler。
+func NewAdRewardHandler() *AdRewardHandler {
+ return &AdRewardHandler{svc: service.NewAdRewardService()}
+}
+
+// Grant 发放广告激励额度(当天有效,幂等防重复领取)。
+func (h *AdRewardHandler) Grant(c *gin.Context) {
+ response := app.NewResponse(c)
+ userID, ok := middleware.GetUserID(c.Request.Context())
+ if !ok {
+ response.ToErrorResponse(errcode.Unauthorized)
+ return
+ }
+ var request dto.AdRewardGrantRequest
+ if err := c.ShouldBindJSON(&request); err != nil {
+ response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
+ return
+ }
+ out, e := h.svc.Grant(c.Request.Context(), userID, &request)
+ if e != nil {
+ response.ToErrorResponse(e)
+ return
+ }
+ response.ToResponse(out)
+}
diff --git a/internal/routers/api/v1/airecommend.go b/internal/routers/api/v1/airecommend.go
new file mode 100644
index 0000000..9806464
--- /dev/null
+++ b/internal/routers/api/v1/airecommend.go
@@ -0,0 +1,61 @@
+package v1
+
+import (
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/middleware"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/service"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/app"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
+ "github.com/gin-gonic/gin"
+)
+
+// AIRecommendHandler AI 穿搭推荐相关接口。
+type AIRecommendHandler struct {
+ svc *service.AIRecommendService
+}
+
+// NewAIRecommendHandler 创建 AI 推荐 handler。
+func NewAIRecommendHandler() *AIRecommendHandler {
+ return &AIRecommendHandler{svc: service.NewAIRecommendService()}
+}
+
+// Create 发起 AI 穿搭推荐。
+func (h *AIRecommendHandler) Create(c *gin.Context) {
+ response := app.NewResponse(c)
+ userID, ok := middleware.GetUserID(c.Request.Context())
+ if !ok {
+ response.ToErrorResponse(errcode.Unauthorized)
+ return
+ }
+ var request dto.AIRecommendCreateRequest
+ if err := c.ShouldBindJSON(&request); err != nil {
+ response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
+ return
+ }
+ out, e := h.svc.Create(c.Request.Context(), userID, &request)
+ if e != nil {
+ response.ToErrorResponse(e)
+ return
+ }
+ response.ToResponse(out)
+}
+
+// Save 保存 AI 推荐穿搭到我的搭配。
+func (h *AIRecommendHandler) Save(c *gin.Context) {
+ response := app.NewResponse(c)
+ userID, ok := middleware.GetUserID(c.Request.Context())
+ if !ok {
+ response.ToErrorResponse(errcode.Unauthorized)
+ return
+ }
+ var request dto.AIRecommendSaveRequest
+ if err := c.ShouldBindJSON(&request); err != nil {
+ response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
+ return
+ }
+ if e := h.svc.Save(c.Request.Context(), userID, &request); e != nil {
+ response.ToErrorResponse(e)
+ return
+ }
+ response.ToResponse(nil)
+}
diff --git a/internal/routers/api/v1/closet.go b/internal/routers/api/v1/closet.go
new file mode 100644
index 0000000..c1cfec2
--- /dev/null
+++ b/internal/routers/api/v1/closet.go
@@ -0,0 +1,125 @@
+package v1
+
+import (
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/middleware"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/service"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/app"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
+ "github.com/gin-gonic/gin"
+)
+
+// ClosetShareHandler 衣柜分享相关接口。
+type ClosetShareHandler struct {
+ svc *service.ClosetShareService
+}
+
+// NewClosetShareHandler 创建衣柜分享 handler。
+func NewClosetShareHandler() *ClosetShareHandler {
+ return &ClosetShareHandler{svc: service.NewClosetShareService()}
+}
+
+// Create 创建衣柜分享。
+func (h *ClosetShareHandler) Create(c *gin.Context) {
+ response := app.NewResponse(c)
+ userID, ok := middleware.GetUserID(c.Request.Context())
+ if !ok {
+ response.ToErrorResponse(errcode.Unauthorized)
+ return
+ }
+ var request dto.ClosetShareCreateRequest
+ if err := c.ShouldBindJSON(&request); err != nil {
+ response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
+ return
+ }
+ out, e := h.svc.Create(c.Request.Context(), userID, &request)
+ if e != nil {
+ response.ToErrorResponse(e)
+ return
+ }
+ response.ToResponse(out)
+}
+
+// List 分页查询我的分享。
+func (h *ClosetShareHandler) List(c *gin.Context) {
+ response := app.NewResponse(c)
+ userID, ok := middleware.GetUserID(c.Request.Context())
+ if !ok {
+ response.ToErrorResponse(errcode.Unauthorized)
+ return
+ }
+ var request dto.ClosetShareListRequest
+ if err := c.ShouldBindQuery(&request); err != nil {
+ response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
+ return
+ }
+ out, e := h.svc.List(c.Request.Context(), userID, &request)
+ if e != nil {
+ response.ToErrorResponse(e)
+ return
+ }
+ response.ToResponse(out)
+}
+
+// ReceivedList 分页查询分享给我的分享。
+func (h *ClosetShareHandler) ReceivedList(c *gin.Context) {
+ response := app.NewResponse(c)
+ userID, ok := middleware.GetUserID(c.Request.Context())
+ if !ok {
+ response.ToErrorResponse(errcode.Unauthorized)
+ return
+ }
+ var request dto.ClosetShareListRequest
+ if err := c.ShouldBindQuery(&request); err != nil {
+ response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
+ return
+ }
+ out, e := h.svc.ListReceived(c.Request.Context(), userID, &request)
+ if e != nil {
+ response.ToErrorResponse(e)
+ return
+ }
+ response.ToResponse(out)
+}
+
+// Detail 查看分享详情(uuid 来自路径参数)。
+func (h *ClosetShareHandler) Detail(c *gin.Context) {
+ response := app.NewResponse(c)
+ userID, ok := middleware.GetUserID(c.Request.Context())
+ if !ok {
+ response.ToErrorResponse(errcode.Unauthorized)
+ return
+ }
+ shareUUID := c.Param("uuid")
+ if shareUUID == "" {
+ response.ToErrorResponse(errcode.InvalidParams)
+ return
+ }
+ out, e := h.svc.Detail(c.Request.Context(), userID, shareUUID)
+ if e != nil {
+ response.ToErrorResponse(e)
+ return
+ }
+ response.ToResponse(out)
+}
+
+// Cancel 取消分享(uuid 来自路径参数)。
+func (h *ClosetShareHandler) Cancel(c *gin.Context) {
+ response := app.NewResponse(c)
+ userID, ok := middleware.GetUserID(c.Request.Context())
+ if !ok {
+ response.ToErrorResponse(errcode.Unauthorized)
+ return
+ }
+ request := dto.ClosetShareCancelRequest{ShareUUID: c.Param("uuid")}
+ if request.ShareUUID == "" {
+ response.ToErrorResponse(errcode.InvalidParams)
+ return
+ }
+ out, e := h.svc.Cancel(c.Request.Context(), userID, &request)
+ if e != nil {
+ response.ToErrorResponse(e)
+ return
+ }
+ response.ToResponse(out)
+}
diff --git a/internal/routers/api/v1/clothes.go b/internal/routers/api/v1/clothes.go
new file mode 100644
index 0000000..3f690b8
--- /dev/null
+++ b/internal/routers/api/v1/clothes.go
@@ -0,0 +1,158 @@
+package v1
+
+import (
+ "net/http"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/middleware"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/service"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/app"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
+ "github.com/gin-gonic/gin"
+)
+
+// ClothesHandler 衣物相关接口。
+type ClothesHandler struct {
+ svc *service.ClothesService
+}
+
+// NewClothesHandler 创建衣物 handler。
+func NewClothesHandler() *ClothesHandler {
+ return &ClothesHandler{svc: service.NewClothesService()}
+}
+
+// Upload 上传衣物图片(multipart,字段名 file)。
+func (h *ClothesHandler) Upload(c *gin.Context) {
+ response := app.NewResponse(c)
+ userID, ok := middleware.GetUserID(c.Request.Context())
+ if !ok {
+ response.ToErrorResponse(errcode.Unauthorized)
+ return
+ }
+ // 解析 multipart 前限制请求体大小(MaxSizeMB + 1MB 的 multipart 边界开销),
+ // 防止超大 body 在 FormFile 解析时耗尽内存/磁盘。
+ maxBytes := int64(global.UploadSetting.MaxSizeMB)*1024*1024 + 1024*1024
+ c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes)
+ fileHeader, err := c.FormFile("file")
+ if err != nil {
+ response.ToErrorResponse(errcode.UploadFileEmpty.WithDetails(err.Error()))
+ return
+ }
+ out, e := h.svc.Upload(c.Request.Context(), userID, fileHeader)
+ if e != nil {
+ response.ToErrorResponse(e)
+ return
+ }
+ response.ToResponse(out)
+}
+
+// List 分页查询当前用户衣物。
+func (h *ClothesHandler) List(c *gin.Context) {
+ response := app.NewResponse(c)
+ userID, ok := middleware.GetUserID(c.Request.Context())
+ if !ok {
+ response.ToErrorResponse(errcode.Unauthorized)
+ return
+ }
+ var request dto.ClothesListRequest
+ if err := c.ShouldBindQuery(&request); err != nil {
+ response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
+ return
+ }
+ out, e := h.svc.List(c.Request.Context(), userID, &request)
+ if e != nil {
+ response.ToErrorResponse(e)
+ return
+ }
+ response.ToResponse(out)
+}
+
+// Get 获取衣物详情(uuid 来自路径参数)。
+func (h *ClothesHandler) Get(c *gin.Context) {
+ response := app.NewResponse(c)
+ userID, ok := middleware.GetUserID(c.Request.Context())
+ if !ok {
+ response.ToErrorResponse(errcode.Unauthorized)
+ return
+ }
+ request := dto.ClothesGetByUUIDRequest{ClothesUUID: c.Param("uuid")}
+ if request.ClothesUUID == "" {
+ response.ToErrorResponse(errcode.InvalidParams)
+ return
+ }
+ out, e := h.svc.GetByUUID(c.Request.Context(), userID, &request)
+ if e != nil {
+ response.ToErrorResponse(e)
+ return
+ }
+ response.ToResponse(out)
+}
+
+// Status 查询衣物图片处理状态(uuid 来自路径参数)。
+func (h *ClothesHandler) Status(c *gin.Context) {
+ response := app.NewResponse(c)
+ userID, ok := middleware.GetUserID(c.Request.Context())
+ if !ok {
+ response.ToErrorResponse(errcode.Unauthorized)
+ return
+ }
+ request := dto.ClothesProcessStatusGetByUUIDRequest{ClothesUUID: c.Param("uuid")}
+ if request.ClothesUUID == "" {
+ response.ToErrorResponse(errcode.InvalidParams)
+ return
+ }
+ out, e := h.svc.ProcessStatusGetByUUID(c.Request.Context(), userID, &request)
+ if e != nil {
+ response.ToErrorResponse(e)
+ return
+ }
+ response.ToResponse(out)
+}
+
+// Update 修改衣物属性(uuid 来自路径参数)。
+func (h *ClothesHandler) Update(c *gin.Context) {
+ response := app.NewResponse(c)
+ userID, ok := middleware.GetUserID(c.Request.Context())
+ if !ok {
+ response.ToErrorResponse(errcode.Unauthorized)
+ return
+ }
+ var request dto.ClothesUpdateByUUIDRequest
+ if err := c.ShouldBindJSON(&request); err != nil {
+ response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
+ return
+ }
+ request.ClothesUUID = c.Param("uuid")
+ if request.ClothesUUID == "" {
+ response.ToErrorResponse(errcode.InvalidParams)
+ return
+ }
+ out, e := h.svc.UpdateByUUID(c.Request.Context(), userID, &request)
+ if e != nil {
+ response.ToErrorResponse(e)
+ return
+ }
+ response.ToResponse(out)
+}
+
+// Delete 删除衣物(uuid 来自路径参数)。
+func (h *ClothesHandler) Delete(c *gin.Context) {
+ response := app.NewResponse(c)
+ userID, ok := middleware.GetUserID(c.Request.Context())
+ if !ok {
+ response.ToErrorResponse(errcode.Unauthorized)
+ return
+ }
+ request := dto.ClothesDeleteByUUIDRequest{ClothesUUID: c.Param("uuid")}
+ if request.ClothesUUID == "" {
+ response.ToErrorResponse(errcode.InvalidParams)
+ return
+ }
+ out, e := h.svc.DeleteByUUID(c.Request.Context(), userID, &request)
+ if e != nil {
+ response.ToErrorResponse(e)
+ return
+ }
+ response.ToResponse(out)
+}
diff --git a/internal/routers/api/v1/collect.go b/internal/routers/api/v1/collect.go
new file mode 100644
index 0000000..287f6ea
--- /dev/null
+++ b/internal/routers/api/v1/collect.go
@@ -0,0 +1,83 @@
+package v1
+
+import (
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/middleware"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/service"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/app"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
+ "github.com/gin-gonic/gin"
+)
+
+// CollectHandler 收藏相关接口。
+type CollectHandler struct {
+ svc *service.CollectService
+}
+
+// NewCollectHandler 创建收藏 handler。
+func NewCollectHandler() *CollectHandler {
+ return &CollectHandler{svc: service.NewCollectService()}
+}
+
+// Collect 收藏/取消收藏社区穿搭。
+func (h *CollectHandler) Collect(c *gin.Context) {
+ response := app.NewResponse(c)
+ userID, ok := middleware.GetUserID(c.Request.Context())
+ if !ok {
+ response.ToErrorResponse(errcode.Unauthorized)
+ return
+ }
+ var request dto.CollectRequest
+ if err := c.ShouldBindJSON(&request); err != nil {
+ response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
+ return
+ }
+ out, e := h.svc.Collect(c.Request.Context(), userID, &request)
+ if e != nil {
+ response.ToErrorResponse(e)
+ return
+ }
+ response.ToResponse(out)
+}
+
+// List 分页查询我收藏的穿搭。
+func (h *CollectHandler) List(c *gin.Context) {
+ response := app.NewResponse(c)
+ userID, ok := middleware.GetUserID(c.Request.Context())
+ if !ok {
+ response.ToErrorResponse(errcode.Unauthorized)
+ return
+ }
+ var request dto.CollectListRequest
+ if err := c.ShouldBindQuery(&request); err != nil {
+ response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
+ return
+ }
+ out, e := h.svc.List(c.Request.Context(), userID, &request)
+ if e != nil {
+ response.ToErrorResponse(e)
+ return
+ }
+ response.ToResponse(out)
+}
+
+// GetByTarget 查询穿搭收藏状态与数量(targetUUID 来自路径参数)。
+func (h *CollectHandler) GetByTarget(c *gin.Context) {
+ response := app.NewResponse(c)
+ userID, ok := middleware.GetUserID(c.Request.Context())
+ if !ok {
+ response.ToErrorResponse(errcode.Unauthorized)
+ return
+ }
+ request := dto.CollectGetByTargetRequest{TargetUUID: c.Param("targetUUID")}
+ if request.TargetUUID == "" {
+ response.ToErrorResponse(errcode.InvalidParams)
+ return
+ }
+ out, e := h.svc.GetByTarget(c.Request.Context(), userID, &request)
+ if e != nil {
+ response.ToErrorResponse(e)
+ return
+ }
+ response.ToResponse(out)
+}
diff --git a/internal/routers/api/v1/goods.go b/internal/routers/api/v1/goods.go
new file mode 100644
index 0000000..e2deb8e
--- /dev/null
+++ b/internal/routers/api/v1/goods.go
@@ -0,0 +1,51 @@
+package v1
+
+import (
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/service"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/app"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
+ "github.com/gin-gonic/gin"
+)
+
+// GoodsHandler 商品相关接口(公开)。
+type GoodsHandler struct {
+ svc *service.GoodsService
+}
+
+// NewGoodsHandler 创建商品 handler。
+func NewGoodsHandler() *GoodsHandler {
+ return &GoodsHandler{svc: service.NewGoodsService()}
+}
+
+// GetByCode 按商品编码查询商品(编码来自路径参数)。
+func (h *GoodsHandler) GetByCode(c *gin.Context) {
+ response := app.NewResponse(c)
+ request := dto.GoodsGetByCodeRequest{GoodsCode: c.Param("code")}
+ if request.GoodsCode == "" {
+ response.ToErrorResponse(errcode.InvalidParams)
+ return
+ }
+ out, e := h.svc.GetByCode(c.Request.Context(), &request)
+ if e != nil {
+ response.ToErrorResponse(e)
+ return
+ }
+ response.ToResponse(out)
+}
+
+// List 分页查询上架商品。
+func (h *GoodsHandler) List(c *gin.Context) {
+ response := app.NewResponse(c)
+ var request dto.GoodsListRequest
+ if err := c.ShouldBindQuery(&request); err != nil {
+ response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
+ return
+ }
+ out, e := h.svc.List(c.Request.Context(), &request)
+ if e != nil {
+ response.ToErrorResponse(e)
+ return
+ }
+ response.ToResponse(out)
+}
diff --git a/internal/routers/api/v1/horoscope.go b/internal/routers/api/v1/horoscope.go
new file mode 100644
index 0000000..dd04b88
--- /dev/null
+++ b/internal/routers/api/v1/horoscope.go
@@ -0,0 +1,35 @@
+package v1
+
+import (
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/middleware"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/service"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/app"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
+ "github.com/gin-gonic/gin"
+)
+
+// HoroscopeHandler 今日运势接口。
+type HoroscopeHandler struct {
+ svc *service.HoroscopeService
+}
+
+// NewHoroscopeHandler 创建运势 handler。
+func NewHoroscopeHandler() *HoroscopeHandler {
+ return &HoroscopeHandler{svc: service.NewHoroscopeService()}
+}
+
+// Today 获取当前用户今日运势。
+func (h *HoroscopeHandler) Today(c *gin.Context) {
+ response := app.NewResponse(c)
+ userID, ok := middleware.GetUserID(c.Request.Context())
+ if !ok {
+ response.ToErrorResponse(errcode.Unauthorized)
+ return
+ }
+ out, e := h.svc.GetToday(c.Request.Context(), userID)
+ if e != nil {
+ response.ToErrorResponse(e)
+ return
+ }
+ response.ToResponse(out)
+}
diff --git a/internal/routers/api/v1/like.go b/internal/routers/api/v1/like.go
new file mode 100644
index 0000000..9e8e6a0
--- /dev/null
+++ b/internal/routers/api/v1/like.go
@@ -0,0 +1,62 @@
+package v1
+
+import (
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/middleware"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/service"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/app"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
+ "github.com/gin-gonic/gin"
+)
+
+// LikeHandler 点赞相关接口。
+type LikeHandler struct {
+ svc *service.LikeService
+}
+
+// NewLikeHandler 创建点赞 handler。
+func NewLikeHandler() *LikeHandler {
+ return &LikeHandler{svc: service.NewLikeService()}
+}
+
+// Like 点赞/取消点赞社区穿搭。
+func (h *LikeHandler) Like(c *gin.Context) {
+ response := app.NewResponse(c)
+ userID, ok := middleware.GetUserID(c.Request.Context())
+ if !ok {
+ response.ToErrorResponse(errcode.Unauthorized)
+ return
+ }
+ var request dto.LikeCreateRequest
+ if err := c.ShouldBindJSON(&request); err != nil {
+ response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
+ return
+ }
+ out, e := h.svc.LikeCreate(c.Request.Context(), userID, &request)
+ if e != nil {
+ response.ToErrorResponse(e)
+ return
+ }
+ response.ToResponse(out)
+}
+
+// GetByTarget 查询穿搭点赞状态与数量(targetUUID 来自路径参数)。
+func (h *LikeHandler) GetByTarget(c *gin.Context) {
+ response := app.NewResponse(c)
+ userID, ok := middleware.GetUserID(c.Request.Context())
+ if !ok {
+ response.ToErrorResponse(errcode.Unauthorized)
+ return
+ }
+ request := dto.LikeGetByTargetIDRequest{TargetUUID: c.Param("targetUUID")}
+ if request.TargetUUID == "" {
+ response.ToErrorResponse(errcode.InvalidParams)
+ return
+ }
+ out, e := h.svc.GetByTargetID(c.Request.Context(), userID, &request)
+ if e != nil {
+ response.ToErrorResponse(e)
+ return
+ }
+ response.ToResponse(out)
+}
diff --git a/internal/routers/api/v1/notification.go b/internal/routers/api/v1/notification.go
new file mode 100644
index 0000000..982bad9
--- /dev/null
+++ b/internal/routers/api/v1/notification.go
@@ -0,0 +1,99 @@
+package v1
+
+import (
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/middleware"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/service"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/app"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
+ "github.com/gin-gonic/gin"
+)
+
+// NotificationHandler 通知相关接口。
+type NotificationHandler struct {
+ svc *service.NotificationService
+}
+
+// NewNotificationHandler 创建通知 handler。
+func NewNotificationHandler() *NotificationHandler {
+ return &NotificationHandler{svc: service.NewNotificationService()}
+}
+
+// List 分页查询当前用户通知。
+func (h *NotificationHandler) List(c *gin.Context) {
+ response := app.NewResponse(c)
+ userID, ok := middleware.GetUserID(c.Request.Context())
+ if !ok {
+ response.ToErrorResponse(errcode.Unauthorized)
+ return
+ }
+ var request dto.NotificationListRequest
+ if err := c.ShouldBindQuery(&request); err != nil {
+ response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
+ return
+ }
+ out, e := h.svc.List(c.Request.Context(), userID, &request)
+ if e != nil {
+ response.ToErrorResponse(e)
+ return
+ }
+ response.ToResponse(out)
+}
+
+// Unread 获取当前用户未读数。
+func (h *NotificationHandler) Unread(c *gin.Context) {
+ response := app.NewResponse(c)
+ userID, ok := middleware.GetUserID(c.Request.Context())
+ if !ok {
+ response.ToErrorResponse(errcode.Unauthorized)
+ return
+ }
+ out, e := h.svc.Unread(c.Request.Context(), userID)
+ if e != nil {
+ response.ToErrorResponse(e)
+ return
+ }
+ response.ToResponse(out)
+}
+
+// Get 获取通知详情(uuid 来自路径参数)。
+func (h *NotificationHandler) Get(c *gin.Context) {
+ response := app.NewResponse(c)
+ userID, ok := middleware.GetUserID(c.Request.Context())
+ if !ok {
+ response.ToErrorResponse(errcode.Unauthorized)
+ return
+ }
+ request := dto.NotificationGetByUUIDRequest{NotificationUUID: c.Param("uuid")}
+ if request.NotificationUUID == "" {
+ response.ToErrorResponse(errcode.InvalidParams)
+ return
+ }
+ out, e := h.svc.GetByUUID(c.Request.Context(), userID, &request)
+ if e != nil {
+ response.ToErrorResponse(e)
+ return
+ }
+ response.ToResponse(out)
+}
+
+// Read 标记通知已读(uuid 来自路径参数)。
+func (h *NotificationHandler) Read(c *gin.Context) {
+ response := app.NewResponse(c)
+ userID, ok := middleware.GetUserID(c.Request.Context())
+ if !ok {
+ response.ToErrorResponse(errcode.Unauthorized)
+ return
+ }
+ request := dto.NotificationReadRequest{NotificationUUID: c.Param("uuid")}
+ if request.NotificationUUID == "" {
+ response.ToErrorResponse(errcode.InvalidParams)
+ return
+ }
+ out, e := h.svc.Read(c.Request.Context(), userID, &request)
+ if e != nil {
+ response.ToErrorResponse(e)
+ return
+ }
+ response.ToResponse(out)
+}
diff --git a/internal/routers/api/v1/order.go b/internal/routers/api/v1/order.go
new file mode 100644
index 0000000..d411e7d
--- /dev/null
+++ b/internal/routers/api/v1/order.go
@@ -0,0 +1,181 @@
+package v1
+
+import (
+ "io"
+ "net/http"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/middleware"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/service"
+ "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/wechat"
+ "github.com/gin-gonic/gin"
+)
+
+// OrderHandler 订单与退款相关接口。
+type OrderHandler struct {
+ svc *service.OrderService
+}
+
+// NewOrderHandler 创建订单 handler。
+func NewOrderHandler() *OrderHandler {
+ return &OrderHandler{svc: service.NewOrderService()}
+}
+
+// Create 创建订单并发起微信下单。
+func (h *OrderHandler) Create(c *gin.Context) {
+ response := app.NewResponse(c)
+ userID, ok := middleware.GetUserID(c.Request.Context())
+ if !ok {
+ response.ToErrorResponse(errcode.Unauthorized)
+ return
+ }
+ var request dto.OrderCreateRequest
+ if err := c.ShouldBindJSON(&request); err != nil {
+ response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
+ return
+ }
+ out, e := h.svc.Create(c.Request.Context(), userID, &request)
+ if e != nil {
+ response.ToErrorResponse(e)
+ return
+ }
+ response.ToResponse(out)
+}
+
+// VirtualCreate 创建虚拟支付订单(小程序虚拟支付,覆盖 iOS/Android 全终端)。
+func (h *OrderHandler) VirtualCreate(c *gin.Context) {
+ response := app.NewResponse(c)
+ userID, ok := middleware.GetUserID(c.Request.Context())
+ if !ok {
+ response.ToErrorResponse(errcode.Unauthorized)
+ return
+ }
+ var request dto.VirtualOrderCreateRequest
+ if err := c.ShouldBindJSON(&request); err != nil {
+ response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
+ return
+ }
+ out, e := h.svc.VirtualOrderCreate(c.Request.Context(), userID, &request)
+ if e != nil {
+ response.ToErrorResponse(e)
+ return
+ }
+ response.ToResponse(out)
+}
+
+// List 分页查询我的订单。
+func (h *OrderHandler) List(c *gin.Context) {
+ response := app.NewResponse(c)
+ userID, ok := middleware.GetUserID(c.Request.Context())
+ if !ok {
+ response.ToErrorResponse(errcode.Unauthorized)
+ return
+ }
+ var request dto.OrderListRequest
+ if err := c.ShouldBindQuery(&request); err != nil {
+ response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
+ return
+ }
+ out, e := h.svc.List(c.Request.Context(), userID, &request)
+ if e != nil {
+ response.ToErrorResponse(e)
+ return
+ }
+ response.ToResponse(out)
+}
+
+// Cancel 取消订单(orderNo 来自路径参数)。
+func (h *OrderHandler) Cancel(c *gin.Context) {
+ response := app.NewResponse(c)
+ userID, ok := middleware.GetUserID(c.Request.Context())
+ if !ok {
+ response.ToErrorResponse(errcode.Unauthorized)
+ return
+ }
+ request := dto.OrderCancelRequest{OrderNo: c.Param("orderNo")}
+ if request.OrderNo == "" {
+ response.ToErrorResponse(errcode.InvalidParams)
+ return
+ }
+ out, e := h.svc.Cancel(c.Request.Context(), userID, &request)
+ if e != nil {
+ response.ToErrorResponse(e)
+ return
+ }
+ response.ToResponse(out)
+}
+
+// RefundApply 申请退款。
+func (h *OrderHandler) RefundApply(c *gin.Context) {
+ response := app.NewResponse(c)
+ userID, ok := middleware.GetUserID(c.Request.Context())
+ if !ok {
+ response.ToErrorResponse(errcode.Unauthorized)
+ return
+ }
+ var request dto.RefundApplyRequest
+ if err := c.ShouldBindJSON(&request); err != nil {
+ response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
+ return
+ }
+ out, e := h.svc.RefundApply(c.Request.Context(), userID, &request)
+ if e != nil {
+ response.ToErrorResponse(e)
+ return
+ }
+ response.ToResponse(out)
+}
+
+// PayNotify 微信支付结果回调(公开,按微信规范返回受理结果)。
+func (h *OrderHandler) PayNotify(c *gin.Context) {
+ if e := h.svc.PayNotify(c.Request.Context(), c.Request); e != nil {
+ wechatCallbackFail(c, e.Msg())
+ return
+ }
+ wechatCallbackSuccess(c)
+}
+
+// RefundNotify 微信退款结果回调(公开,按微信规范返回受理结果)。
+func (h *OrderHandler) RefundNotify(c *gin.Context) {
+ if e := h.svc.RefundNotify(c.Request.Context(), c.Request); e != nil {
+ wechatCallbackFail(c, e.Msg())
+ return
+ }
+ wechatCallbackSuccess(c)
+}
+
+// XPayVerify 虚拟支付消息推送 URL 配置验证(GET):校验签名并原样回显 echostr。
+func (h *OrderHandler) XPayVerify(c *gin.Context) {
+ timestamp := c.Query("timestamp")
+ nonce := c.Query("nonce")
+ signature := c.Query("signature")
+ echostr := c.Query("echostr")
+ if wechat.VerifyNotifyURL(timestamp, nonce, signature) {
+ c.String(http.StatusOK, echostr)
+ return
+ }
+ c.String(http.StatusUnauthorized, "invalid signature")
+}
+
+// XPayNotify 虚拟支付消息推送回调(POST,公开,按虚拟支付规范返回 ErrCode 应答)。
+func (h *OrderHandler) XPayNotify(c *gin.Context) {
+ raw, err := io.ReadAll(c.Request.Body)
+ if err != nil {
+ c.Data(http.StatusOK, "application/json; charset=utf-8", wechat.BuildNotifyResponse(false))
+ return
+ }
+ resp := h.svc.XPayNotify(c.Request.Context(), raw)
+ c.Data(http.StatusOK, "application/json; charset=utf-8", resp)
+}
+
+// wechatCallbackSuccess 按微信规范返回受理成功,避免微信重复回调。
+func wechatCallbackSuccess(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"code": "SUCCESS", "message": "成功"})
+}
+
+// wechatCallbackFail 按微信规范返回受理失败,微信将按策略重试。
+func wechatCallbackFail(c *gin.Context, message string) {
+ c.JSON(http.StatusInternalServerError, gin.H{"code": "FAIL", "message": message})
+}
diff --git a/internal/routers/api/v1/outfit.go b/internal/routers/api/v1/outfit.go
new file mode 100644
index 0000000..efc95fe
--- /dev/null
+++ b/internal/routers/api/v1/outfit.go
@@ -0,0 +1,156 @@
+package v1
+
+import (
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/middleware"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/service"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/app"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
+ "github.com/gin-gonic/gin"
+)
+
+// OutfitHandler 穿搭相关接口。
+type OutfitHandler struct {
+ svc *service.OutfitService
+}
+
+// NewOutfitHandler 创建穿搭 handler。
+func NewOutfitHandler() *OutfitHandler {
+ return &OutfitHandler{svc: service.NewOutfitService()}
+}
+
+// Create 创建穿搭。
+func (h *OutfitHandler) Create(c *gin.Context) {
+ response := app.NewResponse(c)
+ userID, ok := middleware.GetUserID(c.Request.Context())
+ if !ok {
+ response.ToErrorResponse(errcode.Unauthorized)
+ return
+ }
+ var request dto.OutfitCreateRequest
+ if err := c.ShouldBindJSON(&request); err != nil {
+ response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
+ return
+ }
+ out, e := h.svc.Create(c.Request.Context(), userID, &request)
+ if e != nil {
+ response.ToErrorResponse(e)
+ return
+ }
+ response.ToResponse(out)
+}
+
+// List 分页查询我的穿搭。
+func (h *OutfitHandler) List(c *gin.Context) {
+ response := app.NewResponse(c)
+ userID, ok := middleware.GetUserID(c.Request.Context())
+ if !ok {
+ response.ToErrorResponse(errcode.Unauthorized)
+ return
+ }
+ var request dto.OutfitListRequest
+ if err := c.ShouldBindQuery(&request); err != nil {
+ response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
+ return
+ }
+ out, e := h.svc.List(c.Request.Context(), userID, &request)
+ if e != nil {
+ response.ToErrorResponse(e)
+ return
+ }
+ response.ToResponse(out)
+}
+
+// Get 获取穿搭详情(uuid 来自路径参数)。
+func (h *OutfitHandler) Get(c *gin.Context) {
+ response := app.NewResponse(c)
+ userID, ok := middleware.GetUserID(c.Request.Context())
+ if !ok {
+ response.ToErrorResponse(errcode.Unauthorized)
+ return
+ }
+ request := dto.OutfitGetByUUIDRequest{OutfitUUID: c.Param("uuid")}
+ if request.OutfitUUID == "" {
+ response.ToErrorResponse(errcode.InvalidParams)
+ return
+ }
+ out, e := h.svc.GetByUUID(c.Request.Context(), userID, &request)
+ if e != nil {
+ response.ToErrorResponse(e)
+ return
+ }
+ response.ToResponse(out)
+}
+
+// Update 修改穿搭(uuid 来自路径参数)。
+func (h *OutfitHandler) Update(c *gin.Context) {
+ response := app.NewResponse(c)
+ userID, ok := middleware.GetUserID(c.Request.Context())
+ if !ok {
+ response.ToErrorResponse(errcode.Unauthorized)
+ return
+ }
+ var request dto.OutfitUpdateByUUIDRequest
+ if err := c.ShouldBindJSON(&request); err != nil {
+ response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
+ return
+ }
+ request.OutfitUUID = c.Param("uuid")
+ if request.OutfitUUID == "" {
+ response.ToErrorResponse(errcode.InvalidParams)
+ return
+ }
+ out, e := h.svc.UpdateByUUID(c.Request.Context(), userID, &request)
+ if e != nil {
+ response.ToErrorResponse(e)
+ return
+ }
+ response.ToResponse(out)
+}
+
+// Delete 删除穿搭(uuid 来自路径参数)。
+func (h *OutfitHandler) Delete(c *gin.Context) {
+ response := app.NewResponse(c)
+ userID, ok := middleware.GetUserID(c.Request.Context())
+ if !ok {
+ response.ToErrorResponse(errcode.Unauthorized)
+ return
+ }
+ request := dto.OutfitDeleteRequest{OutfitUUID: c.Param("uuid")}
+ if request.OutfitUUID == "" {
+ response.ToErrorResponse(errcode.InvalidParams)
+ return
+ }
+ out, e := h.svc.Delete(c.Request.Context(), userID, &request)
+ if e != nil {
+ response.ToErrorResponse(e)
+ return
+ }
+ response.ToResponse(out)
+}
+
+// Public 设置穿搭公开状态(uuid 来自路径参数)。
+func (h *OutfitHandler) Public(c *gin.Context) {
+ response := app.NewResponse(c)
+ userID, ok := middleware.GetUserID(c.Request.Context())
+ if !ok {
+ response.ToErrorResponse(errcode.Unauthorized)
+ return
+ }
+ var request dto.OutfitPublicRequest
+ if err := c.ShouldBindJSON(&request); err != nil {
+ response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
+ return
+ }
+ request.OutfitUUID = c.Param("uuid")
+ if request.OutfitUUID == "" {
+ response.ToErrorResponse(errcode.InvalidParams)
+ return
+ }
+ out, e := h.svc.Public(c.Request.Context(), userID, &request)
+ if e != nil {
+ response.ToErrorResponse(e)
+ return
+ }
+ response.ToResponse(out)
+}
diff --git a/internal/routers/api/v1/quota.go b/internal/routers/api/v1/quota.go
new file mode 100644
index 0000000..e4969d1
--- /dev/null
+++ b/internal/routers/api/v1/quota.go
@@ -0,0 +1,57 @@
+package v1
+
+import (
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/middleware"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/service"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/app"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
+ "github.com/gin-gonic/gin"
+)
+
+// QuotaHandler 额度相关接口。
+type QuotaHandler struct {
+ svc *service.QuotaService
+}
+
+// NewQuotaHandler 创建额度 handler。
+func NewQuotaHandler() *QuotaHandler {
+ return &QuotaHandler{svc: service.NewQuotaService()}
+}
+
+// Info 获取当前用户额度概览。
+func (h *QuotaHandler) Info(c *gin.Context) {
+ response := app.NewResponse(c)
+ userID, ok := middleware.GetUserID(c.Request.Context())
+ if !ok {
+ response.ToErrorResponse(errcode.Unauthorized)
+ return
+ }
+ out, e := h.svc.InfoGetBySelf(c.Request.Context(), userID)
+ if e != nil {
+ response.ToErrorResponse(e)
+ return
+ }
+ response.ToResponse(out)
+}
+
+// FlowList 分页查询当前用户额度流水。
+func (h *QuotaHandler) FlowList(c *gin.Context) {
+ response := app.NewResponse(c)
+ userID, ok := middleware.GetUserID(c.Request.Context())
+ if !ok {
+ response.ToErrorResponse(errcode.Unauthorized)
+ return
+ }
+ var request dto.QuotaFlowListRequest
+ if err := c.ShouldBindQuery(&request); err != nil {
+ response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
+ return
+ }
+ out, e := h.svc.FlowList(c.Request.Context(), userID, &request)
+ if e != nil {
+ response.ToErrorResponse(e)
+ return
+ }
+ response.ToResponse(out)
+}
diff --git a/internal/routers/api/v1/user.go b/internal/routers/api/v1/user.go
new file mode 100644
index 0000000..a262386
--- /dev/null
+++ b/internal/routers/api/v1/user.go
@@ -0,0 +1,119 @@
+// Package v1 提供 /api/v1 版本的 HTTP handler,负责请求绑定、鉴权透传与统一响应,
+// 业务逻辑与归属校验均在 internal/service 层完成。
+package v1
+
+import (
+ "net/http"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/middleware"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/service"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/app"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
+ "github.com/gin-gonic/gin"
+)
+
+// UserHandler 用户相关接口。
+type UserHandler struct {
+ svc *service.UserService
+}
+
+// NewUserHandler 创建用户 handler。
+func NewUserHandler() *UserHandler {
+ return &UserHandler{svc: service.NewUserService()}
+}
+
+// WxLogin 微信登录(公开)。
+func (h *UserHandler) WxLogin(c *gin.Context) {
+ response := app.NewResponse(c)
+ var request dto.UserWxLoginRequest
+ if err := c.ShouldBindJSON(&request); err != nil {
+ response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
+ return
+ }
+ out, e := h.svc.WxLogin(c.Request.Context(), &request)
+ if e != nil {
+ response.ToErrorResponse(e)
+ return
+ }
+ response.ToResponse(out)
+}
+
+// RefreshToken 刷新访问令牌(公开)。
+func (h *UserHandler) RefreshToken(c *gin.Context) {
+ response := app.NewResponse(c)
+ var request dto.UserRefreshTokenRequest
+ if err := c.ShouldBindJSON(&request); err != nil {
+ response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
+ return
+ }
+ out, e := h.svc.RefreshToken(c.Request.Context(), &request)
+ if e != nil {
+ response.ToErrorResponse(e)
+ return
+ }
+ response.ToResponse(out)
+}
+
+// InfoGetBySelf 获取当前用户信息。
+func (h *UserHandler) InfoGetBySelf(c *gin.Context) {
+ response := app.NewResponse(c)
+ userID, ok := middleware.GetUserID(c.Request.Context())
+ if !ok {
+ response.ToErrorResponse(errcode.Unauthorized)
+ return
+ }
+ out, e := h.svc.InfoGetBySelf(c.Request.Context(), userID)
+ if e != nil {
+ response.ToErrorResponse(e)
+ return
+ }
+ response.ToResponse(out)
+}
+
+// ProfileUpdate 修改当前用户资料。
+func (h *UserHandler) ProfileUpdate(c *gin.Context) {
+ response := app.NewResponse(c)
+ userID, ok := middleware.GetUserID(c.Request.Context())
+ if !ok {
+ response.ToErrorResponse(errcode.Unauthorized)
+ return
+ }
+ var request dto.UserProfileUpdateBySelfRequest
+ if err := c.ShouldBindJSON(&request); err != nil {
+ response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
+ return
+ }
+ out, e := h.svc.ProfileUpdate(c.Request.Context(), userID, &request)
+ if e != nil {
+ response.ToErrorResponse(e)
+ return
+ }
+ response.ToResponse(out)
+}
+
+// AvatarUpload 上传用户头像(multipart,字段名 file)。
+func (h *UserHandler) AvatarUpload(c *gin.Context) {
+ response := app.NewResponse(c)
+ userID, ok := middleware.GetUserID(c.Request.Context())
+ if !ok {
+ response.ToErrorResponse(errcode.Unauthorized)
+ return
+ }
+ // 解析 multipart 前限制请求体大小(MaxSizeMB + 1MB 的 multipart 边界开销),
+ // 防止超大 body 在 FormFile 解析时耗尽内存/磁盘。
+ maxBytes := int64(global.UploadSetting.MaxSizeMB)*1024*1024 + 1024*1024
+ c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes)
+ fileHeader, err := c.FormFile("file")
+ if err != nil {
+ response.ToErrorResponse(errcode.UploadFileEmpty.WithDetails(err.Error()))
+ return
+ }
+ out, e := h.svc.AvatarUpload(c.Request.Context(), userID, fileHeader)
+ if e != nil {
+ response.ToErrorResponse(e)
+ return
+ }
+ response.ToResponse(out)
+}
diff --git a/internal/routers/router.go b/internal/routers/router.go
new file mode 100644
index 0000000..e53fa5c
--- /dev/null
+++ b/internal/routers/router.go
@@ -0,0 +1,133 @@
+package routers
+
+import (
+ "net/http"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/middleware"
+ v1 "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/routers/api/v1"
+ "github.com/gin-gonic/gin"
+)
+
+// NewRouter 构建 gin 引擎:注册中间件链与 /api/v1 路由。
+func NewRouter() *gin.Engine {
+ r := gin.New()
+
+ // 中间件链:Recovery -> RequestID -> OTel -> Slog -> CORS
+ r.Use(
+ gin.Recovery(),
+ middleware.RequestID(),
+ middleware.GinOTelMiddleware(),
+ middleware.GinSlogMiddleware(),
+ middleware.CORS(),
+ )
+
+ // 健康检查
+ r.GET("/ping", func(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"message": "pong"})
+ })
+
+ // handler 实例
+ userHandler := v1.NewUserHandler()
+ quotaHandler := v1.NewQuotaHandler()
+ horoscopeHandler := v1.NewHoroscopeHandler()
+ clothesHandler := v1.NewClothesHandler()
+ outfitHandler := v1.NewOutfitHandler()
+ closetHandler := v1.NewClosetShareHandler()
+ collectHandler := v1.NewCollectHandler()
+ likeHandler := v1.NewLikeHandler()
+ notificationHandler := v1.NewNotificationHandler()
+ goodsHandler := v1.NewGoodsHandler()
+ orderHandler := v1.NewOrderHandler()
+ aiHandler := v1.NewAIRecommendHandler()
+ adRewardHandler := v1.NewAdRewardHandler()
+
+ apiV1 := r.Group("/api/v1")
+
+ // ===== 公开路由(无需登录)=====
+ {
+ apiV1.POST("/user/wxlogin", userHandler.WxLogin)
+ apiV1.POST("/user/refresh-token", userHandler.RefreshToken)
+
+ apiV1.GET("/goods", goodsHandler.List)
+ apiV1.GET("/goods/:code", goodsHandler.GetByCode)
+
+ // 微信支付/退款回调
+ apiV1.POST("/order/pay/notify", orderHandler.PayNotify)
+ apiV1.POST("/order/refund/notify", orderHandler.RefundNotify)
+
+ // 虚拟支付消息推送(GET 为 URL 配置验证,POST 为业务回调)
+ apiV1.GET("/xpay/notify", orderHandler.XPayVerify)
+ apiV1.POST("/xpay/notify", orderHandler.XPayNotify)
+ }
+
+ // ===== 鉴权路由(需登录)=====
+ auth := apiV1.Group("")
+ auth.Use(middleware.JWTAuth())
+ {
+ // 用户
+ auth.GET("/user/info", userHandler.InfoGetBySelf)
+ auth.PUT("/user/profile", userHandler.ProfileUpdate)
+ auth.POST("/user/avatar/upload", userHandler.AvatarUpload)
+
+ // 额度
+ auth.GET("/quota/info", quotaHandler.Info)
+ auth.GET("/quota/flow", quotaHandler.FlowList)
+
+ // 运势
+ auth.GET("/horoscope/today", horoscopeHandler.Today)
+
+ // 衣物
+ auth.POST("/clothes/upload", clothesHandler.Upload)
+ auth.GET("/clothes", clothesHandler.List)
+ auth.GET("/clothes/:uuid", clothesHandler.Get)
+ auth.GET("/clothes/:uuid/status", clothesHandler.Status)
+ auth.PUT("/clothes/:uuid", clothesHandler.Update)
+ auth.DELETE("/clothes/:uuid", clothesHandler.Delete)
+
+ // 穿搭
+ auth.POST("/outfit", outfitHandler.Create)
+ auth.GET("/outfit", outfitHandler.List)
+ auth.GET("/outfit/:uuid", outfitHandler.Get)
+ auth.PUT("/outfit/:uuid", outfitHandler.Update)
+ auth.DELETE("/outfit/:uuid", outfitHandler.Delete)
+ auth.PUT("/outfit/:uuid/public", outfitHandler.Public)
+
+ // 衣柜分享
+ auth.POST("/closet-share", closetHandler.Create)
+ auth.GET("/closet-share", closetHandler.List)
+ auth.GET("/closet-share/received", closetHandler.ReceivedList)
+ auth.GET("/closet-share/:uuid", closetHandler.Detail)
+ auth.POST("/closet-share/:uuid/cancel", closetHandler.Cancel)
+
+ // 收藏
+ auth.POST("/collect", collectHandler.Collect)
+ auth.GET("/collect", collectHandler.List)
+ auth.GET("/collect/:targetUUID", collectHandler.GetByTarget)
+
+ // 点赞
+ auth.POST("/like", likeHandler.Like)
+ auth.GET("/like/:targetUUID", likeHandler.GetByTarget)
+
+ // 通知
+ auth.GET("/notification", notificationHandler.List)
+ auth.GET("/notification/unread", notificationHandler.Unread)
+ auth.GET("/notification/:uuid", notificationHandler.Get)
+ auth.POST("/notification/:uuid/read", notificationHandler.Read)
+
+ // 订单
+ auth.POST("/order", orderHandler.Create)
+ auth.POST("/order/virtual", orderHandler.VirtualCreate)
+ auth.GET("/order", orderHandler.List)
+ auth.POST("/order/:orderNo/cancel", orderHandler.Cancel)
+ auth.POST("/order/refund", orderHandler.RefundApply)
+
+ // 广告激励额度
+ auth.POST("/ad-reward/grant", adRewardHandler.Grant)
+
+ // AI 推荐
+ auth.POST("/ai-recommend", aiHandler.Create)
+ auth.POST("/ai-recommend/save", aiHandler.Save)
+ }
+
+ return r
+}
diff --git a/internal/service/adreward.go b/internal/service/adreward.go
new file mode 100644
index 0000000..91aa090
--- /dev/null
+++ b/internal/service/adreward.go
@@ -0,0 +1,113 @@
+package service
+
+import (
+ "context"
+ "time"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
+)
+
+// AdRewardService 广告激励额度服务。
+type AdRewardService struct{}
+
+// NewAdRewardService 创建广告激励额度服务。
+func NewAdRewardService() *AdRewardService {
+ return &AdRewardService{}
+}
+
+// Grant 发放广告激励额度:校验开关后按服务端配置的固定金额创建当天有效凭证。
+// ref_id+quota_type 唯一索引兜底幂等,重复领取返回 AdRewardClaimed。
+func (s *AdRewardService) Grant(ctx context.Context, userID int64, request *dto.AdRewardGrantRequest) (*dto.AdRewardGrantResponse, *errcode.Error) {
+ ctx, span := global.Tracer.Start(ctx, "svc.AdRewardGrant")
+ defer span.End()
+
+ quotaType, ok := parseAdRewardType(request.RewardType)
+ if !ok {
+ return nil, errcode.AdRewardInvalid
+ }
+
+ // 开关校验:广告激励未开启时拒绝发放。
+ if global.AdRewardSetting == nil || !global.AdRewardSetting.Enable {
+ return nil, errcode.AdRewardDisabled
+ }
+
+ // 服务端定价:发放金额由配置固定,前端仅传类型与广告记录 ID,杜绝刷额度。
+ amount := adRewardAmount(quotaType)
+ if amount <= 0 {
+ return nil, errcode.AdRewardInvalid.WithDetails("该激励类型未配置发放数量")
+ }
+
+ grant := &mysqlmodel.QuotaGrant{
+ UserID: userID,
+ QuotaType: quotaType,
+ Amount: amount,
+ Used: 0,
+ Source: QuotaGrantSourceAdReward,
+ ExpireTime: todayEndUnix(), // 当天 23:59:59 到期
+ RefID: request.RefID,
+ }
+ if err := mysqldao.NewQuotaGrantDao().QuotaGrantCreate(ctx, grant); err != nil {
+ if isDuplicateKey(err) {
+ return nil, errcode.AdRewardClaimed
+ }
+ return nil, errcode.DatabaseError
+ }
+
+ return &dto.AdRewardGrantResponse{
+ RefID: request.RefID,
+ QuotaType: quotaType,
+ Amount: amount,
+ }, nil
+}
+
+// adRewardAmount 返回指定额度类型的广告激励固定金额(服务端配置)。
+// 未配置(指针为 nil)或配置为非正数时返回 0,表示不发放该类型激励。
+func adRewardAmount(quotaType int8) int64 {
+ cfg := global.AdRewardSetting
+ if cfg == nil {
+ return 0
+ }
+ var p *int64
+ switch quotaType {
+ case QuotaTypeCos:
+ p = cfg.Cos
+ case QuotaTypeAI:
+ p = cfg.AI
+ case QuotaTypeOutfitShare:
+ p = cfg.OutfitShare
+ case QuotaTypeClosetShare:
+ p = cfg.ClosetShare
+ default:
+ return 0
+ }
+ if p == nil || *p <= 0 {
+ return 0
+ }
+ return *p
+}
+
+// parseAdRewardType 将 reward_type 字符串映射到额度类型。
+func parseAdRewardType(rewardType string) (int8, bool) {
+ switch rewardType {
+ case "cos":
+ return QuotaTypeCos, true
+ case "ai":
+ return QuotaTypeAI, true
+ case "outfit_share":
+ return QuotaTypeOutfitShare, true
+ case "closet_share":
+ return QuotaTypeClosetShare, true
+ default:
+ return 0, false
+ }
+}
+
+// todayEndUnix 返回当天 23:59:59 的秒级时间戳(广告激励当天有效)。
+func todayEndUnix() int64 {
+ now := time.Now()
+ return time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()).Unix()
+}
diff --git a/internal/service/airecommend.go b/internal/service/airecommend.go
new file mode 100644
index 0000000..8e2a068
--- /dev/null
+++ b/internal/service/airecommend.go
@@ -0,0 +1,256 @@
+package service
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "time"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/tokenhub"
+)
+
+// AIRecommendService AI 穿搭推荐业务服务。
+type AIRecommendService struct {
+ outfitDao *mysqldao.OutfitDao
+ outfitItemDao *mysqldao.OutfitItemDao
+ clothesDao *mysqldao.ClothesDao
+ userDao *mysqldao.UserDao
+ horoscopeDao *mysqldao.HoroscopeDao
+ flowLogDao *mysqldao.AIRecommendFlowLogDao
+}
+
+// NewAIRecommendService 创建 AI 推荐服务。
+func NewAIRecommendService() *AIRecommendService {
+ return &AIRecommendService{
+ outfitDao: mysqldao.NewOutfitDao(),
+ outfitItemDao: mysqldao.NewOutfitItemDao(),
+ clothesDao: mysqldao.NewClothesDao(),
+ userDao: mysqldao.NewUserDao(),
+ horoscopeDao: mysqldao.NewHoroscopeDao(),
+ flowLogDao: mysqldao.NewAIRecommendFlowLogDao(),
+ }
+}
+
+// Create 发起 AI 穿搭推荐:防并发、扣 AI 额度、基于衣橱与运势生成推荐,落库为待保存穿搭。
+func (s *AIRecommendService) Create(ctx context.Context, userID int64, request *dto.AIRecommendCreateRequest) (*dto.AIRecommendCreateResponse, *errcode.Error) {
+ ctx, span := global.Tracer.Start(ctx, "svc.AIRecommendCreate")
+ defer span.End()
+
+ // 1. 防并发锁。
+ lockKey := fmt.Sprintf(redismodel.KeyLockAIRecommend, userID)
+ lockVal := genUUID()
+ ok, err := redisdao.TryLock(ctx, lockKey, lockVal, 180*time.Second)
+ if err != nil {
+ // Redis 异常:返回服务错误而非误报「请求过于频繁」。
+ return nil, errcode.CacheError
+ }
+ if !ok {
+ return nil, errcode.TooManyRequests
+ }
+ defer func() { _ = redisdao.Unlock(ctx, lockKey, lockVal) }()
+
+ // 2. 扣减 AI 每日额度(权威扣减)。
+ if qerr := consumeDailyQuota(ctx, userID, QuotaTypeAI, 1, "AI 推荐消耗"); qerr != nil {
+ if qerr == errcode.QuotaInsufficient {
+ return nil, errcode.AIRecommendQuotaLimit
+ }
+ return nil, qerr
+ }
+
+ // 失败时统一回退额度的兜底闭包。
+ fail := func(bizErr *errcode.Error, reason string) *errcode.Error {
+ refundDailyQuota(ctx, userID, QuotaTypeAI, 1)
+ s.writeFlowLog(ctx, userID, "", AIFlowStatusFailed, reason)
+ return bizErr
+ }
+
+ // 3. 加载用户可用衣物(已成功处理)。
+ clothes, cerr := s.processedClothes(ctx, userID)
+ if cerr != nil {
+ return nil, fail(errcode.DatabaseError, "查询衣橱失败")
+ }
+ if len(clothes) == 0 {
+ return nil, fail(errcode.AIRecommendFailed.WithDetails("衣橱暂无可用衣物"), "衣橱为空")
+ }
+
+ // 4. 计算星座与今日运势(best-effort)。
+ zodiac, fortune := s.userFortune(ctx, userID)
+
+ // 5. 调用大模型生成推荐。
+ requestTime := nowUnix()
+ client := tokenhub.NewClient()
+ result, gerr := client.GenerateOutfitRecommendation(ctx, zodiac, fortune, clothes)
+ if gerr != nil {
+ return nil, fail(errcode.AIRecommendFailed, "大模型调用失败")
+ }
+ result.ValidateClothesIDs(clothes)
+ if len(result.Items) == 0 {
+ return nil, fail(errcode.AIRecommendFailed.WithDetails("未生成有效搭配"), "无有效单品")
+ }
+
+ // 6. 落库为待用户保存的 AI 穿搭。
+ scene := result.Scene
+ if scene == "" {
+ scene = request.Scene
+ }
+ outfit := &mysqlmodel.Outfit{
+ OutfitUUID: genUUID(),
+ UserID: userID,
+ OutfitName: result.OutfitName,
+ OutfitDesc: result.Description,
+ Scene: scene,
+ Source: OutfitSourceAI,
+ IsPublic: OutfitPrivate,
+ AuditStatus: OutfitAuditPending,
+ Status: OutfitStatusUnsaved,
+ }
+ if err := s.outfitDao.OutfitCreate(ctx, outfit); err != nil {
+ return nil, fail(errcode.OutfitCreateFailed, "穿搭落库失败")
+ }
+
+ clothesIDs := result.GetClothesIDs()
+ items := make([]mysqlmodel.OutfitItem, 0, len(clothesIDs))
+ for _, cid := range clothesIDs {
+ items = append(items, mysqlmodel.OutfitItem{OutfitID: outfit.ID, ClothesID: cid})
+ }
+ if err := s.outfitItemDao.OutfitItemCreateBatch(ctx, items); err != nil {
+ // 明细落库失败:删除已建穿搭并回退额度,避免产生无明细的空推荐。
+ _ = s.outfitDao.OutfitDeleteByUUID(ctx, outfit.OutfitUUID)
+ return nil, fail(errcode.OutfitCreateFailed, "穿搭明细落库失败")
+ }
+
+ // 7. 写成功流水 + 当日计数。
+ resultJSON, _ := json.Marshal(result)
+ if err := s.flowLogDao.AIRecommendFlowLogCreate(ctx, &mysqlmodel.AIRecommendFlowLog{
+ RequestID: genUUID(),
+ UserID: userID,
+ ModelVersion: global.TokenhubSetting.Model,
+ ResultContent: string(resultJSON),
+ Status: AIFlowStatusSuccess,
+ RequestTime: requestTime,
+ ResponseTime: nowUnix(),
+ }); err != nil {
+ slog.WarnContext(ctx, "write ai recommend success flow log failed", "user_id", userID, "error", err)
+ }
+ recordDailyUsage(ctx, userID, QuotaTypeAI)
+
+ // 8. 组装响应(衣物 UUID 来自校正后的 items)。
+ outfitItems := make([]dto.OutfitItem, 0, len(result.Items))
+ for _, it := range result.Items {
+ outfitItems = append(outfitItems, dto.OutfitItem{ClothesUUID: it.ClothesUUID})
+ }
+
+ return &dto.AIRecommendCreateResponse{
+ OutfitUUID: outfit.OutfitUUID,
+ OutfitName: outfit.OutfitName,
+ OutfitDesc: outfit.OutfitDesc,
+ Scene: outfit.Scene,
+ Source: outfit.Source,
+ Status: outfit.Status,
+ OutfitItem: outfitItems,
+ }, nil
+}
+
+// Save 将 AI 推荐生成的待保存穿搭保存到「我的搭配」(置为正常状态)。
+func (s *AIRecommendService) Save(ctx context.Context, userID int64, request *dto.AIRecommendSaveRequest) *errcode.Error {
+ ctx, span := global.Tracer.Start(ctx, "svc.AIRecommendSave")
+ defer span.End()
+
+ outfit, err := s.outfitDao.OutfitGetByUUID(ctx, request.OutfitUUID)
+ if err != nil {
+ if isNotFound(err) {
+ return errcode.OutfitNotFound
+ }
+ return errcode.DatabaseError
+ }
+ if outfit.UserID != userID {
+ return errcode.OutfitNoPermission
+ }
+ // 仅待保存状态可保存;已正常则幂等返回。
+ if outfit.Status == OutfitStatusNormal {
+ return nil
+ }
+ if outfit.Status != OutfitStatusUnsaved {
+ return errcode.OutfitUpdateFailed
+ }
+
+ if uerr := s.outfitDao.OutfitUpdateByUUID(ctx, request.OutfitUUID, map[string]any{
+ "status": OutfitStatusNormal,
+ }); uerr != nil {
+ return errcode.OutfitUpdateFailed
+ }
+ return nil
+}
+
+// ---- 内部辅助 ----
+
+// processedClothes 获取用户已成功处理、可用于推荐的衣物。
+func (s *AIRecommendService) processedClothes(ctx context.Context, userID int64) ([]mysqlmodel.Clothes, error) {
+ var result []mysqlmodel.Clothes
+ page, pageSize := 1, 200
+ for {
+ list, total, err := s.clothesDao.ClothesListByUserID(ctx, userID, "", "", page, pageSize)
+ if err != nil {
+ return nil, err
+ }
+ for _, c := range list {
+ if c.ImageProcessStatus == ImageProcessSuccess {
+ result = append(result, c)
+ }
+ }
+ if len(list) == 0 || int64(page*pageSize) >= total {
+ break
+ }
+ page++
+ }
+ return result, nil
+}
+
+// userFortune 计算用户星座并读取今日运势(缺失时返回仅含星座的空运势)。
+func (s *AIRecommendService) userFortune(ctx context.Context, userID int64) (string, *tokenhub.FortuneItem) {
+ zodiac := ""
+ if user, err := s.userDao.UserGetByID(ctx, userID); err == nil && user.BirthDay != 0 {
+ zodiac = zodiacByUnix(user.BirthDay)
+ }
+
+ fortune := &tokenhub.FortuneItem{Zodiac: zodiac}
+ if zodiac != "" {
+ if h, err := s.horoscopeDao.HoroscopeGetByZodiacAndDate(ctx, zodiac, todayStatDate()); err == nil && h != nil {
+ fortune = &tokenhub.FortuneItem{
+ Zodiac: h.Zodiac,
+ Overall: h.OverallScore,
+ Love: h.LoveScore,
+ Work: h.CareerScore,
+ Money: h.WealthScore,
+ LuckyColor: h.LuckyColor,
+ LuckyNumber: h.LuckyNumber,
+ Description: h.DailyText,
+ }
+ }
+ }
+ return zodiac, fortune
+}
+
+// writeFlowLog 写 AI 推荐流水(best-effort,失败仅告警不阻断主流程)。
+func (s *AIRecommendService) writeFlowLog(ctx context.Context, userID int64, resultContent string, status int8, failReason string) {
+ if err := s.flowLogDao.AIRecommendFlowLogCreate(ctx, &mysqlmodel.AIRecommendFlowLog{
+ RequestID: genUUID(),
+ UserID: userID,
+ ModelVersion: global.TokenhubSetting.Model,
+ ResultContent: resultContent,
+ Status: status,
+ FailReason: failReason,
+ RequestTime: nowUnix(),
+ ResponseTime: nowUnix(),
+ }); err != nil {
+ slog.WarnContext(ctx, "write ai recommend flow log failed", "user_id", userID, "status", status, "error", err)
+ }
+}
diff --git a/internal/service/benefit_compensation.go b/internal/service/benefit_compensation.go
new file mode 100644
index 0000000..cb1d9dd
--- /dev/null
+++ b/internal/service/benefit_compensation.go
@@ -0,0 +1,112 @@
+package service
+
+import (
+ "context"
+ "log/slog"
+ "time"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao"
+)
+
+// StartBenefitCompensationScheduler 启动权益发放补偿定时任务:周期性重试发放失败的权益。
+func StartBenefitCompensationScheduler() {
+ go func() {
+ slog.Info("benefit compensation scheduler started")
+ for {
+ func() {
+ defer func() {
+ if r := recover(); r != nil {
+ slog.Error("benefit compensation scheduler panic", "recover", r)
+ }
+ }()
+ runBenefitCompensation()
+ }()
+ time.Sleep(time.Minute)
+ }
+ }()
+}
+
+// runBenefitCompensation 扫描待发放补偿任务并重试,单实例通过 Redis 分布式锁防重。
+func runBenefitCompensation() {
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
+ defer cancel()
+
+ ctx, span := global.Tracer.Start(ctx, "scheduler.BenefitCompensation")
+ defer span.End()
+
+ lockKey := "ztcloset:lock:benefit:compensation"
+ lockVal := genUUID()
+ ok, lerr := redisdao.TryLock(ctx, lockKey, lockVal, time.Minute)
+ if lerr != nil || !ok {
+ return
+ }
+ defer redisdao.Unlock(context.Background(), lockKey, lockVal)
+
+ taskDao := mysqldao.NewBenefitGrantTaskDao()
+
+ // 恢复超时的「发放中」任务(认领后崩溃的场景),使其回到待发放继续重试。
+ _, _ = taskDao.BenefitGrantTaskRecoverStale(ctx, nowUnix()-int64((10*time.Minute).Seconds()))
+
+ tasks, err := taskDao.BenefitGrantTaskListPending(ctx, 100)
+ if err != nil {
+ slog.ErrorContext(ctx, "list pending benefit tasks failed", "error", err)
+ return
+ }
+ if len(tasks) == 0 {
+ return
+ }
+
+ s := NewOrderService()
+ for i := range tasks {
+ s.retryBenefitTask(ctx, &tasks[i])
+ }
+}
+
+// retryBenefitTask 重试单个补偿任务(幂等:先查发放流水避免重复发放)。
+func (s *OrderService) retryBenefitTask(ctx context.Context, task *mysqlmodel.BenefitGrantTask) {
+ taskDao := mysqldao.NewBenefitGrantTaskDao()
+
+ // 重试次数超限,标记失败并告警,等待人工介入。
+ if task.RetryCount >= task.MaxRetry {
+ _ = taskDao.BenefitGrantTaskUpdateByID(ctx, task.ID, map[string]any{"status": BenefitTaskFailed})
+ slog.ErrorContext(ctx, "benefit grant task exhausted retries",
+ "order_no", task.OrderNo, "benefit_type", task.BenefitType, "retry_count", task.RetryCount)
+ return
+ }
+
+ // 幂等判断:若发放流水已存在(上次发放成功但未置 success 的崩溃窗口),直接置成功。
+ if granted, err := s.benefitAlreadyGranted(ctx, task); err == nil && granted {
+ _ = taskDao.BenefitGrantTaskUpdateByID(ctx, task.ID, map[string]any{"status": BenefitTaskSuccess})
+ return
+ } else if err != nil {
+ slog.ErrorContext(ctx, "check benefit granted failed",
+ "order_no", task.OrderNo, "benefit_type", task.BenefitType, "error", err)
+ return
+ }
+
+ // 执行发放(claim 防并发,内部处理 retry_count 原子自增与状态流转)。
+ s.executeGrant(ctx, task)
+}
+
+// benefitAlreadyGranted 判断该权益是否已发放(用于崩溃恢复幂等)。
+// VIP 查 VIP 流水;额度类查额度凭证(quota_grants)。
+func (s *OrderService) benefitAlreadyGranted(ctx context.Context, task *mysqlmodel.BenefitGrantTask) (bool, error) {
+ grantDao := mysqldao.NewQuotaGrantDao()
+ switch task.BenefitType {
+ case BenefitTypeVIP:
+ return s.vipDao.VIPFlowLogExistsByOrderNo(ctx, task.OrderNo)
+ case BenefitTypeCos:
+ return grantDao.QuotaGrantExistsByRefIDAndType(ctx, task.OrderNo, QuotaTypeCos)
+ case BenefitTypeAI:
+ return grantDao.QuotaGrantExistsByRefIDAndType(ctx, task.OrderNo, QuotaTypeAI)
+ case BenefitTypeClosetShare:
+ return grantDao.QuotaGrantExistsByRefIDAndType(ctx, task.OrderNo, QuotaTypeClosetShare)
+ case BenefitTypeOutfitShare:
+ return grantDao.QuotaGrantExistsByRefIDAndType(ctx, task.OrderNo, QuotaTypeOutfitShare)
+ default:
+ return false, nil
+ }
+}
diff --git a/internal/service/closet.go b/internal/service/closet.go
new file mode 100644
index 0000000..1c80af1
--- /dev/null
+++ b/internal/service/closet.go
@@ -0,0 +1,366 @@
+package service
+
+import (
+ "context"
+ "log/slog"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/upload"
+)
+
+// ClosetShareService 衣柜分享业务服务。
+type ClosetShareService struct {
+ shareDao *mysqldao.ClosetShareDao
+ shareItemDao *mysqldao.ClosetShareItemDao
+ receiverDao *mysqldao.ClosetShareReceiverDao
+ clothesDao *mysqldao.ClothesDao
+ userDao *mysqldao.UserDao
+ auditLogDao *mysqldao.ClosetAuditLogDao
+}
+
+// NewClosetShareService 创建衣柜分享服务。
+func NewClosetShareService() *ClosetShareService {
+ return &ClosetShareService{
+ shareDao: mysqldao.NewClosetShareDao(),
+ shareItemDao: mysqldao.NewClosetShareItemDao(),
+ receiverDao: mysqldao.NewClosetShareReceiverDao(),
+ clothesDao: mysqldao.NewClothesDao(),
+ userDao: mysqldao.NewUserDao(),
+ auditLogDao: mysqldao.NewClosetAuditLogDao(),
+ }
+}
+
+// Create 创建衣柜分享。部分分享需指定衣物;全量快照分享快照当前全部衣物。创建即自动审核通过。
+func (s *ClosetShareService) Create(ctx context.Context, userID int64, request *dto.ClosetShareCreateRequest) (*dto.ClosetShareCreateResponse, *errcode.Error) {
+ ctx, span := global.Tracer.Start(ctx, "svc.ClosetShareCreate")
+ defer span.End()
+
+ // 校验过期时间:必须晚于当前时间(0 表示不设置过期时间)。
+ if request.ExpireTime > 0 && request.ExpireTime <= nowUnix() {
+ return nil, errcode.InvalidParams.WithDetails("过期时间必须晚于当前时间")
+ }
+
+ // 1. 确定分享的衣物 ID 集合。
+ var clothesIDs []int64
+ switch request.ShareType {
+ case ShareTypePartial:
+ if len(request.ClothesUUIDs) == 0 {
+ return nil, errcode.InvalidParams.WithDetails("部分分享需选择衣物")
+ }
+ ids, cerr := s.resolveOwnedClothesIDs(ctx, userID, request.ClothesUUIDs)
+ if cerr != nil {
+ return nil, cerr
+ }
+ clothesIDs = ids
+ case ShareTypeSnapshot:
+ ids, err := s.allUserClothesIDs(ctx, userID)
+ if err != nil {
+ return nil, errcode.DatabaseError
+ }
+ clothesIDs = ids
+ default:
+ return nil, errcode.InvalidParams
+ }
+
+ // 2. 消耗衣柜分享每日额度。
+ if qerr := consumeDailyQuota(ctx, userID, QuotaTypeClosetShare, 1, "衣柜分享消耗"); qerr != nil {
+ return nil, qerr
+ }
+
+ // 3. 创建分享。
+ shareUUID := genUUID()
+ share := &mysqlmodel.ClosetShare{
+ ShareUUID: shareUUID,
+ UserID: userID,
+ ShareType: request.ShareType,
+ ExpireTime: request.ExpireTime,
+ Status: ShareStatusNormal,
+ }
+ if err := s.shareDao.ClosetShareCreate(ctx, share); err != nil {
+ refundDailyQuota(ctx, userID, QuotaTypeClosetShare, 1)
+ return nil, errcode.DatabaseError
+ }
+
+ // 4. 快照衣物明细(明细是分享核心数据,失败则回退额度并报错,避免产生空分享)。
+ items := make([]mysqlmodel.ClosetShareItem, 0, len(clothesIDs))
+ for _, cid := range clothesIDs {
+ items = append(items, mysqlmodel.ClosetShareItem{ShareUUID: shareUUID, ClothesID: cid})
+ }
+ if err := s.shareItemDao.ClosetShareItemCreateBatch(ctx, items); err != nil {
+ refundDailyQuota(ctx, userID, QuotaTypeClosetShare, 1)
+ return nil, errcode.DatabaseError
+ }
+
+ // 5. 自动审核通过,写审核日志(best-effort:失败仅告警不阻断创建)。
+ if err := s.auditLogDao.ClosetAuditLogCreate(ctx, &mysqlmodel.ClosetAuditLog{
+ TargetID: share.ID,
+ AuditResult: AuditResultPass,
+ AuditTime: nowUnix(),
+ }); err != nil {
+ slog.WarnContext(ctx, "write closet audit log failed", "share_id", share.ID, "error", err)
+ }
+
+ recordDailyUsage(ctx, userID, QuotaTypeClosetShare)
+
+ return &dto.ClosetShareCreateResponse{ShareUUID: shareUUID}, nil
+}
+
+// Detail 查看分享详情(访客/属主视角)。
+func (s *ClosetShareService) Detail(ctx context.Context, userID int64, shareUUID string) (*dto.ClosetShareDetailResponse, *errcode.Error) {
+ ctx, span := global.Tracer.Start(ctx, "svc.ClosetShareDetail")
+ defer span.End()
+
+ share, err := s.shareDao.ClosetShareGetByUUID(ctx, shareUUID)
+ if err != nil {
+ if isNotFound(err) {
+ return nil, errcode.ShareNotFound
+ }
+ return nil, errcode.DatabaseError
+ }
+
+ // 状态校验。
+ switch share.Status {
+ case ShareStatusCancelled:
+ return nil, errcode.ShareCancelled
+ case ShareStatusBanned:
+ return nil, errcode.ShareNotFound
+ case ShareStatusExpired:
+ return nil, errcode.ShareExpired
+ }
+ // 时间过期校验。
+ if share.ExpireTime > 0 && nowUnix() > share.ExpireTime {
+ _ = s.shareDao.ClosetShareUpdateStatus(ctx, shareUUID, ShareStatusExpired)
+ return nil, errcode.ShareExpired
+ }
+
+ isOwner := share.UserID == userID
+
+ // 访问计数(访客计入)。
+ if !isOwner {
+ _, _ = redisdao.ClosetShareViewIncr(ctx, shareUUID)
+ }
+
+ // 加载分享的衣物。
+ shareItems, ierr := s.shareItemDao.ClosetShareItemListByShareUUID(ctx, shareUUID)
+ if ierr != nil {
+ return nil, errcode.DatabaseError
+ }
+ clothesIDs := make([]int64, 0, len(shareItems))
+ for _, it := range shareItems {
+ clothesIDs = append(clothesIDs, it.ClothesID)
+ }
+ clothesList, cerr := s.clothesDao.ClothesGetByIDs(ctx, clothesIDs)
+ if cerr != nil {
+ return nil, errcode.DatabaseError
+ }
+ clothesResp := make([]dto.ClothesGetByUUIDResponse, 0, len(clothesList))
+ for i := range clothesList {
+ clothesResp = append(clothesResp, buildClothesResponse(&clothesList[i]))
+ }
+
+ // 记录接收关系:登录且非属主用户成功访问即视为收到,幂等,失败不阻断详情返回。
+ if userID > 0 && !isOwner {
+ if rerr := s.receiverDao.ClosetShareReceiverUpsert(ctx, shareUUID, userID); rerr != nil {
+ slog.WarnContext(ctx, "record closet share receiver failed", "share_uuid", shareUUID, "user_id", userID, "error", rerr)
+ }
+ }
+
+ // 分享者信息。
+ ownerName, ownerAvatar := "", ""
+ if owner, oerr := s.userDao.UserGetByID(ctx, share.UserID); oerr == nil {
+ ownerName = owner.Username
+ ownerAvatar = upload.AvatarURL(owner.Avatar)
+ }
+
+ return &dto.ClosetShareDetailResponse{
+ ShareUUID: shareUUID,
+ OwnerName: ownerName,
+ OwnerAvatar: ownerAvatar,
+ Clothes: clothesResp,
+ ExpireTime: share.ExpireTime,
+ Status: share.Status,
+ IsOwner: isOwner,
+ }, nil
+}
+
+// List 分页查询我的分享。
+func (s *ClosetShareService) List(ctx context.Context, userID int64, request *dto.ClosetShareListRequest) (*dto.ShareListResponse, *errcode.Error) {
+ ctx, span := global.Tracer.Start(ctx, "svc.ClosetShareList")
+ defer span.End()
+
+ page, pageSize := normalizePage(request.Page, request.PageSize)
+
+ list, total, err := s.shareDao.ClosetShareListByUserID(ctx, userID, request.Status, page, pageSize)
+ if err != nil {
+ return nil, errcode.DatabaseError
+ }
+
+ // 批量统计各分享的衣物数量。
+ shareUUIDs := make([]string, 0, len(list))
+ for _, sh := range list {
+ shareUUIDs = append(shareUUIDs, sh.ShareUUID)
+ }
+ countByShare := make(map[string]int, len(list))
+ if allItems, ierr := s.shareItemDao.ClosetShareItemListByShareUUIDs(ctx, shareUUIDs); ierr == nil {
+ for _, it := range allItems {
+ countByShare[it.ShareUUID]++
+ }
+ }
+
+ items := make([]dto.ClosetShareItemResponse, 0, len(list))
+ for _, sh := range list {
+ items = append(items, dto.ClosetShareItemResponse{
+ ShareUUID: sh.ShareUUID,
+ ShareType: sh.ShareType,
+ ClothesCount: countByShare[sh.ShareUUID],
+ ExpireTime: sh.ExpireTime,
+ Status: sh.Status,
+ })
+ }
+
+ return &dto.ShareListResponse{
+ PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize},
+ List: items,
+ }, nil
+}
+
+// ListReceived 分页查询"分享给我的"分享。
+func (s *ClosetShareService) ListReceived(ctx context.Context, userID int64, request *dto.ClosetShareListRequest) (*dto.ShareListResponse, *errcode.Error) {
+ ctx, span := global.Tracer.Start(ctx, "svc.ClosetShareReceivedList")
+ defer span.End()
+
+ page, pageSize := normalizePage(request.Page, request.PageSize)
+
+ list, total, err := s.receiverDao.ClosetShareReceivedList(ctx, userID, request.Status, page, pageSize)
+ if err != nil {
+ return nil, errcode.DatabaseError
+ }
+
+ // 批量统计各分享的衣物数量。
+ shareUUIDs := make([]string, 0, len(list))
+ ownerIDs := make([]int64, 0, len(list))
+ for _, sh := range list {
+ shareUUIDs = append(shareUUIDs, sh.ShareUUID)
+ ownerIDs = append(ownerIDs, sh.UserID)
+ }
+ countByShare := make(map[string]int, len(list))
+ if allItems, ierr := s.shareItemDao.ClosetShareItemListByShareUUIDs(ctx, shareUUIDs); ierr == nil {
+ for _, it := range allItems {
+ countByShare[it.ShareUUID]++
+ }
+ }
+
+ // 批量查询分享者信息。
+ ownerByID := make(map[int64]mysqlmodel.User, len(ownerIDs))
+ if owners, oerr := s.userDao.UserGetByIDs(ctx, ownerIDs); oerr == nil {
+ for _, u := range owners {
+ ownerByID[u.ID] = u
+ }
+ }
+
+ items := make([]dto.ClosetShareItemResponse, 0, len(list))
+ for _, sh := range list {
+ ownerName, ownerAvatar := "", ""
+ if owner, ok := ownerByID[sh.UserID]; ok {
+ ownerName = owner.Username
+ ownerAvatar = upload.AvatarURL(owner.Avatar)
+ }
+ items = append(items, dto.ClosetShareItemResponse{
+ ShareUUID: sh.ShareUUID,
+ ShareType: sh.ShareType,
+ ClothesCount: countByShare[sh.ShareUUID],
+ ExpireTime: sh.ExpireTime,
+ Status: sh.Status,
+ OwnerName: ownerName,
+ OwnerAvatar: ownerAvatar,
+ })
+ }
+
+ return &dto.ShareListResponse{
+ PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize},
+ List: items,
+ }, nil
+}
+
+// Cancel 取消分享(校验归属)。
+func (s *ClosetShareService) Cancel(ctx context.Context, userID int64, request *dto.ClosetShareCancelRequest) (*dto.CollectResponse, *errcode.Error) {
+ ctx, span := global.Tracer.Start(ctx, "svc.ClosetShareCancel")
+ defer span.End()
+
+ share, err := s.shareDao.ClosetShareGetByUUID(ctx, request.ShareUUID)
+ if err != nil {
+ if isNotFound(err) {
+ return nil, errcode.ShareNotFound
+ }
+ return nil, errcode.DatabaseError
+ }
+ if share.UserID != userID {
+ return nil, errcode.Forbidden
+ }
+ if err := s.shareDao.ClosetShareUpdateStatus(ctx, request.ShareUUID, ShareStatusCancelled); err != nil {
+ return nil, errcode.DatabaseError
+ }
+ return &dto.CollectResponse{}, nil
+}
+
+// ---- 内部辅助 ----
+
+// resolveOwnedClothesIDs 校验衣物均属于用户,返回有序去重的 clothesID 列表。
+func (s *ClosetShareService) resolveOwnedClothesIDs(ctx context.Context, userID int64, uuids []string) ([]int64, *errcode.Error) {
+ dedup := make([]string, 0, len(uuids))
+ seen := make(map[string]struct{}, len(uuids))
+ for _, u := range uuids {
+ if _, ok := seen[u]; ok {
+ continue
+ }
+ seen[u] = struct{}{}
+ dedup = append(dedup, u)
+ }
+
+ clothesList, err := s.clothesDao.ClothesGetByUUIDs(ctx, dedup)
+ if err != nil {
+ return nil, errcode.DatabaseError
+ }
+ byUUID := make(map[string]mysqlmodel.Clothes, len(clothesList))
+ for _, c := range clothesList {
+ byUUID[c.ClothesUUID] = c
+ }
+
+ ids := make([]int64, 0, len(dedup))
+ for _, u := range dedup {
+ c, ok := byUUID[u]
+ if !ok {
+ return nil, errcode.ClothesNotFound
+ }
+ if c.UserID != userID {
+ return nil, errcode.ClothesNoPermission
+ }
+ ids = append(ids, c.ID)
+ }
+ return ids, nil
+}
+
+// allUserClothesIDs 获取用户当前全部衣物 ID(分页遍历)。
+func (s *ClosetShareService) allUserClothesIDs(ctx context.Context, userID int64) ([]int64, error) {
+ var ids []int64
+ page, pageSize := 1, 200
+ for {
+ list, total, err := s.clothesDao.ClothesListByUserID(ctx, userID, "", "", page, pageSize)
+ if err != nil {
+ return nil, err
+ }
+ for _, c := range list {
+ ids = append(ids, c.ID)
+ }
+ if len(list) == 0 || int64(page*pageSize) >= total {
+ break
+ }
+ page++
+ }
+ return ids, nil
+}
diff --git a/internal/service/clothes.go b/internal/service/clothes.go
new file mode 100644
index 0000000..3152944
--- /dev/null
+++ b/internal/service/clothes.go
@@ -0,0 +1,472 @@
+package service
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "log/slog"
+ "mime/multipart"
+ "time"
+
+ "go.opentelemetry.io/otel/trace"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/classification"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/matting"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/upload"
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+)
+
+// imageProcessSem 限制异步抠图处理的并发数,避免瞬时大量上传打满下游抠图/分类服务。
+var imageProcessSem = make(chan struct{}, 10)
+
+// ClothesService 衣物业务服务。
+type ClothesService struct {
+ clothesDao *mysqldao.ClothesDao
+ userDao *mysqldao.UserDao
+}
+
+// NewClothesService 创建衣物服务。
+func NewClothesService() *ClothesService {
+ return &ClothesService{
+ clothesDao: mysqldao.NewClothesDao(),
+ userDao: mysqldao.NewUserDao(),
+ }
+}
+
+// Upload 上传衣物图片:同步完成校验、扣额度、建记录后立即返回,
+// 抠图与分类在后台 goroutine 异步执行并回填状态。
+func (s *ClothesService) Upload(ctx context.Context, userID int64, fileHeader *multipart.FileHeader) (*dto.ClothesUploadResponse, *errcode.Error) {
+ ctx, span := global.Tracer.Start(ctx, "svc.ClothesUpload")
+ defer span.End()
+
+ if fileHeader == nil || fileHeader.Size == 0 {
+ return nil, errcode.UploadFileEmpty
+ }
+
+ // 1. 校验扩展名与大小。
+ ext := upload.FileGetExt(fileHeader.Filename)
+ if !upload.FileCheckAllowExt(ext, global.UploadSetting.AllowedExtensions) {
+ return nil, errcode.UploadFileExtNotAllow
+ }
+ if !upload.FileCheckMaxSize(fileHeader.Size, int64(global.UploadSetting.MaxSizeMB)) {
+ return nil, errcode.UploadFileTooLarge
+ }
+
+ // 2. 读取原图字节(必须同步读取,请求返回后 multipart 临时文件会被清理)。
+ src, oerr := fileHeader.Open()
+ if oerr != nil {
+ return nil, errcode.UploadFileSaveFailed
+ }
+ imageData, rerr := io.ReadAll(src)
+ _ = src.Close()
+ if rerr != nil {
+ return nil, errcode.UploadFileSaveFailed
+ }
+
+ // 3. 校验文件内容为真实图片(magic bytes),防止伪造扩展名上传恶意文件。
+ if !upload.FileCheckImageMagic(imageData) {
+ return nil, errcode.UploadFileExtNotAllow
+ }
+
+ // 4. 扣减 COS 上传额度(权威扣减,先到期凭证先用,再扣免费额度)。
+ if qerr := consumeCosQuota(ctx, userID, 1, 0, "上传衣物消耗"); qerr != nil {
+ return nil, qerr
+ }
+
+ // 5. 创建衣物记录,初始状态为排队中。
+ clothesUUID := genUUID()
+ clothes := &mysqlmodel.Clothes{
+ ClothesUUID: clothesUUID,
+ UserID: userID,
+ ImageSize: fileHeader.Size / 1024,
+ ImageProcessStatus: ImageProcessQueuing,
+ }
+ if err := s.clothesDao.ClothesCreate(ctx, clothes); err != nil {
+ // 建记录失败,回退已扣额度。
+ refundCosQuota(ctx, userID, 1, 0, "上传失败回退")
+ return nil, errcode.ClothesCreateFailed
+ }
+
+ // 6. 异步抠图 + 分类。
+ go s.processImageAsync(ctx, userID, clothesUUID, imageData)
+
+ return &dto.ClothesUploadResponse{
+ ClothesUUID: clothesUUID,
+ ImageProcessStatus: clothes.ImageProcessStatus,
+ }, nil
+}
+
+// processImageAsync 后台执行抠图、上传 COS、分类,并回填衣物记录。
+func (s *ClothesService) processImageAsync(parentCtx context.Context, userID int64, clothesUUID string, imageData []byte) {
+ // 限流:获取信号量,超出并发上限时阻塞等待。
+ imageProcessSem <- struct{}{}
+ defer func() { <-imageProcessSem }()
+
+ // 派生独立 ctx:继承父 trace 信息,但切断取消信号,避免复用已结束的请求 ctx。
+ ctx, cancel := context.WithTimeout(trace.ContextWithSpanContext(context.Background(), trace.SpanContextFromContext(parentCtx)), 3*time.Minute)
+ defer cancel()
+
+ ctx, span := global.Tracer.Start(ctx, "svc.ClothesProcessImageAsync")
+ defer span.End()
+
+ defer func() {
+ if r := recover(); r != nil {
+ slog.ErrorContext(ctx, "clothes image process panic", "clothes_uuid", clothesUUID, "recover", r)
+ s.markProcessFailed(ctx, userID, clothesUUID, "内部错误")
+ }
+ }()
+
+ dao := s.clothesDao
+
+ // 1. 抠图。
+ _ = dao.ClothesUpdateByUUID(ctx, clothesUUID, map[string]any{"image_process_status": ImageProcessMatting})
+ mattingClient := matting.NewClient()
+ processedData, err := retryCall(ctx, global.MattingModelSetting.MaxRetry, "matting", func() ([]byte, error) {
+ return mattingClient.Matting(ctx, imageData)
+ })
+ if err != nil {
+ slog.ErrorContext(ctx, "matting failed", "clothes_uuid", clothesUUID, "error", err)
+ s.markProcessFailed(ctx, userID, clothesUUID, "图片抠图失败")
+ return
+ }
+
+ // 2. 处理结果落临时目录并上传 COS。对象键结构:用户UUID/用户ID/图片文件名。
+ processedName := genUUID() + ".png"
+ // 获取用户 UUID,作为 COS 对象键第一层(uuid/userid/图片)。
+ user, gerr := s.userDao.UserGetByID(ctx, userID)
+ if gerr != nil {
+ slog.ErrorContext(ctx, "get user for cos key failed", "clothes_uuid", clothesUUID, "error", gerr)
+ s.markProcessFailed(ctx, userID, clothesUUID, "获取用户信息失败")
+ return
+ }
+ subDir := fmt.Sprintf("%s/%d", user.UserUUID, userID)
+ if _, serr := upload.FileSaveTmpBytes(ctx, processedData, processedName, subDir); serr != nil {
+ slog.ErrorContext(ctx, "save processed image failed", "clothes_uuid", clothesUUID, "error", serr)
+ s.markProcessFailed(ctx, userID, clothesUUID, "图片保存失败")
+ return
+ }
+ cosKey := fmt.Sprintf("%s/%s", subDir, processedName)
+ if uerr := upload.FileUploadCos(ctx, cosKey); uerr != nil {
+ slog.ErrorContext(ctx, "upload processed image to cos failed", "clothes_uuid", clothesUUID, "error", uerr)
+ s.markProcessFailed(ctx, userID, clothesUUID, "图片上传失败")
+ return
+ }
+
+ // 上传成功后回填前检查衣物是否仍存在:用户可能在处理期间删除衣物,
+ // 若已删除则清理刚上传的对象,避免 COS 残留(额度已由删除事务回退,无需再处理)。
+ if _, gerr := dao.ClothesGetByUUID(ctx, clothesUUID); isNotFound(gerr) {
+ if derr := upload.FileDeleteCos(ctx, cosKey); derr != nil {
+ slog.ErrorContext(ctx, "delete orphan cos image failed", "clothes_uuid", clothesUUID, "cos_key", cosKey, "error", derr)
+ }
+ slog.InfoContext(ctx, "clothes deleted during processing, cleaned uploaded image", "clothes_uuid", clothesUUID)
+ return
+ }
+
+ // 3. 分类。
+ _ = dao.ClothesUpdateByUUID(ctx, clothesUUID, map[string]any{"image_process_status": ImageProcessClassifying})
+ classifyClient := classification.NewClient()
+ result, cerr := retryCall(ctx, global.ClassificationModelSetting.MaxRetry, "classify", func() (*classification.ClassifyResult, error) {
+ return classifyClient.Classify(ctx, processedData)
+ })
+ if cerr != nil {
+ slog.ErrorContext(ctx, "classify failed", "clothes_uuid", clothesUUID, "error", cerr)
+ // 分类失败仍保留已抠图结果,但条件标记失败并回退以便用户手动补全。
+ _ = dao.ClothesUpdateByUUID(ctx, clothesUUID, map[string]any{"processed_image": cosKey})
+ s.markProcessFailed(ctx, userID, clothesUUID, "图片分类失败")
+ return
+ }
+
+ // 4. 回填分类结果与处理后图片,标记成功。注意主色列名为 color。
+ updates := map[string]any{
+ "category_main": result.CategoryMain,
+ "category_sub": result.CategorySub,
+ "style": result.Style,
+ "name": buildDefaultClothesName(result),
+ "color": result.ColorMain,
+ "color_sub": result.ColorSub,
+ "thickness": result.Thickness,
+ "version": result.Version,
+ "material": result.Material,
+ "pattern": result.Pattern,
+ "sleeve_type": result.SleeveType,
+ "clothes_type": result.ClothesType,
+ "neckline_type": result.NecklineType,
+ "formality": result.Formality,
+ "occasions": result.Occasions,
+ "visual_impact": result.VisualImpact,
+ "processed_image": cosKey,
+ "image_process_status": ImageProcessSuccess,
+ "fail_reason": "",
+ }
+ if uerr := dao.ClothesUpdateByUUID(ctx, clothesUUID, updates); uerr != nil {
+ slog.ErrorContext(ctx, "update clothes after classify failed", "clothes_uuid", clothesUUID, "error", uerr)
+ }
+}
+
+// buildDefaultClothesName 根据分类结果生成衣服默认名称(风格+小类+大类,空字段跳过)。
+func buildDefaultClothesName(result *classification.ClassifyResult) string {
+ name := ""
+ if result.Style != "" {
+ name += result.Style
+ }
+ if result.CategorySub != "" {
+ name += result.CategorySub
+ }
+ if result.CategoryMain != "" {
+ name += result.CategoryMain
+ }
+ return name
+}
+
+// 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) {
+ var zero T
+ var err error
+ for attempt := 0; attempt <= maxRetry; attempt++ {
+ result, e := fn()
+ if e == nil {
+ return result, nil
+ }
+ err = e
+ if attempt >= maxRetry {
+ break
+ }
+ slog.WarnContext(ctx, "model call failed, retrying", "name", name, "attempt", attempt+1, "error", e)
+ select {
+ case <-ctx.Done():
+ return zero, ctx.Err()
+ case <-time.After(time.Second):
+ }
+ }
+ return zero, err
+}
+
+// markProcessFailed 标记处理失败并回退上传额度。
+// 单事务(行锁 -> 置失败 -> 回退额度 + 写流水),与删除事务(FOR UPDATE)通过行锁互斥:
+// 两路径串行化保证「处理失败回退」与「删除回退」恰好执行一次,消除漏退/双退窗口。
+func (s *ClothesService) markProcessFailed(ctx context.Context, userID int64, clothesUUID, reason string) {
+ vipExpire := getUserVIPExpire(ctx, userID)
+ err := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ var c mysqlmodel.Clothes
+ if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
+ Where("clothes_uuid = ?", clothesUUID).
+ First(&c).Error; err != nil {
+ if isNotFound(err) {
+ // 衣物已删除:删除事务已回退额度,跳过。
+ return nil
+ }
+ return err
+ }
+ // 已是失败态:说明失败回退已处理过,跳过。
+ if c.ImageProcessStatus == ImageProcessFailed {
+ return nil
+ }
+
+ // 置失败。
+ if err := tx.Model(&mysqlmodel.Clothes{}).
+ Where("clothes_uuid = ?", clothesUUID).
+ Updates(map[string]any{"image_process_status": ImageProcessFailed, "fail_reason": reason}).Error; err != nil {
+ return err
+ }
+
+ // 同事务回退额度(优先免费额度,不足回退凭证)。
+ return refundCosQuotaTx(tx, userID, vipExpire, 1, 0, "衣物处理失败回退")
+ })
+ if err != nil {
+ slog.ErrorContext(ctx, "mark process failed transaction error", "clothes_uuid", clothesUUID, "error", err)
+ }
+}
+
+// ProcessStatusGetByUUID 查询衣物图片处理状态。
+func (s *ClothesService) ProcessStatusGetByUUID(ctx context.Context, userID int64, request *dto.ClothesProcessStatusGetByUUIDRequest) (*dto.ClothesProcessStatusGetByUUIDResponse, *errcode.Error) {
+ ctx, span := global.Tracer.Start(ctx, "svc.ClothesProcessStatusGetByUUID")
+ defer span.End()
+
+ clothes, cerr := s.getOwnedClothes(ctx, request.ClothesUUID, userID)
+ if cerr != nil {
+ return nil, cerr
+ }
+ return &dto.ClothesProcessStatusGetByUUIDResponse{
+ ImageProcessStatus: clothes.ImageProcessStatus,
+ FailReason: clothes.FailReason,
+ }, nil
+}
+
+// GetByUUID 获取衣物详情。
+func (s *ClothesService) GetByUUID(ctx context.Context, userID int64, request *dto.ClothesGetByUUIDRequest) (*dto.ClothesGetByUUIDResponse, *errcode.Error) {
+ ctx, span := global.Tracer.Start(ctx, "svc.ClothesGetByUUID")
+ defer span.End()
+
+ clothes, cerr := s.getOwnedClothes(ctx, request.ClothesUUID, userID)
+ if cerr != nil {
+ return nil, cerr
+ }
+ response := buildClothesResponse(clothes)
+ return &response, nil
+}
+
+// UpdateByUUID 修改衣物属性(仅更新非空字段)。
+func (s *ClothesService) UpdateByUUID(ctx context.Context, userID int64, request *dto.ClothesUpdateByUUIDRequest) (*dto.ClothesUpdateByUUIDResponse, *errcode.Error) {
+ ctx, span := global.Tracer.Start(ctx, "svc.ClothesUpdateByUUID")
+ defer span.End()
+
+ if _, cerr := s.getOwnedClothes(ctx, request.ClothesUUID, userID); cerr != nil {
+ return nil, cerr
+ }
+
+ updates := map[string]any{}
+ setIfNotEmpty(updates, "category_main", request.CategoryMain)
+ setIfNotEmpty(updates, "category_sub", request.CategorySub)
+ setIfNotEmpty(updates, "style", request.Style)
+ setIfNotEmpty(updates, "color", request.ColorMain) // 主色列名为 color
+ setIfNotEmpty(updates, "color_sub", request.ColorSub)
+ setIfNotEmpty(updates, "thickness", request.Thickness)
+ setIfNotEmpty(updates, "version", request.Version)
+ setIfNotEmpty(updates, "material", request.Material)
+ setIfNotEmpty(updates, "pattern", request.Pattern)
+ setIfNotEmpty(updates, "sleeve_type", request.SleeveType)
+ setIfNotEmpty(updates, "clothes_type", request.ClothesType)
+ setIfNotEmpty(updates, "neckline_type", request.NecklineType)
+ setIfNotEmpty(updates, "formality", request.Formality)
+ setIfNotEmpty(updates, "occasions", request.Occasions)
+ setIfNotEmpty(updates, "visual_impact", request.VisualImpact)
+ setIfNotEmpty(updates, "name", request.Name)
+ setIfNotEmpty(updates, "remark", request.Remark)
+
+ if len(updates) == 0 {
+ return &dto.ClothesUpdateByUUIDResponse{}, nil
+ }
+ if err := s.clothesDao.ClothesUpdateByUUID(ctx, request.ClothesUUID, updates); err != nil {
+ return nil, errcode.ClothesUpdateFailed
+ }
+ return &dto.ClothesUpdateByUUIDResponse{}, nil
+}
+
+// DeleteByUUID 删除衣物(软删除)。
+func (s *ClothesService) DeleteByUUID(ctx context.Context, userID int64, request *dto.ClothesDeleteByUUIDRequest) (*dto.ClothesDeleteByUUIDResponse, *errcode.Error) {
+ ctx, span := global.Tracer.Start(ctx, "svc.ClothesDeleteByUUID")
+ defer span.End()
+
+ if _, cerr := s.getOwnedClothes(ctx, request.ClothesUUID, userID); cerr != nil {
+ return nil, cerr
+ }
+
+ // 事务内锁定衣物行 -> 读最新状态 -> 软删除 -> 条件回退额度 -> 写回退流水,
+ // 数值变更与流水同事务强一致,并避免与异步处理失败回退并发导致漏退/双退。
+ vipExpire := getUserVIPExpire(ctx, userID)
+ var processedImage string
+ err := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ var c mysqlmodel.Clothes
+ if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
+ Where("clothes_uuid = ?", request.ClothesUUID).
+ First(&c).Error; err != nil {
+ return err
+ }
+ processedImage = c.ProcessedImage
+ if err := tx.Where("clothes_uuid = ?", request.ClothesUUID).Delete(&mysqlmodel.Clothes{}).Error; err != nil {
+ return err
+ }
+ // 失败状态已在处理失败路径回退,不再回退,避免双重回退。
+ if c.ImageProcessStatus == ImageProcessFailed {
+ return nil
+ }
+ // 同事务回退额度(优先免费额度,不足回退凭证)。
+ return refundCosQuotaTx(tx, userID, vipExpire, 1, 0, "删除衣物回退")
+ })
+ if err != nil {
+ if isNotFound(err) {
+ return nil, errcode.ClothesNotFound
+ }
+ return nil, errcode.ClothesDeleteFailed
+ }
+
+ // 事务提交成功后清理 COS 图片(尽力而为,失败仅记录日志,不影响删除结果)。
+ if processedImage != "" {
+ if derr := upload.FileDeleteCos(ctx, processedImage); derr != nil {
+ slog.WarnContext(ctx, "delete clothes cos image failed", "clothes_uuid", request.ClothesUUID, "cos_key", processedImage, "error", derr)
+ }
+ }
+
+ return &dto.ClothesDeleteByUUIDResponse{}, nil
+}
+
+// List 分页查询用户衣物。
+func (s *ClothesService) List(ctx context.Context, userID int64, request *dto.ClothesListRequest) (*dto.ClothesListResponse, *errcode.Error) {
+ ctx, span := global.Tracer.Start(ctx, "svc.ClothesList")
+ defer span.End()
+
+ page, pageSize := normalizePage(request.Page, request.PageSize)
+
+ list, total, err := s.clothesDao.ClothesListByUserID(ctx, userID, request.CategoryMain, request.CategorySub, page, pageSize)
+ if err != nil {
+ return nil, errcode.DatabaseError
+ }
+
+ items := make([]dto.ClothesGetByUUIDResponse, 0, len(list))
+ for i := range list {
+ items = append(items, buildClothesResponse(&list[i]))
+ }
+
+ return &dto.ClothesListResponse{
+ PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize},
+ List: items,
+ }, nil
+}
+
+// getOwnedClothes 查询衣物并校验归属。
+func (s *ClothesService) getOwnedClothes(ctx context.Context, clothesUUID string, userID int64) (*mysqlmodel.Clothes, *errcode.Error) {
+ clothes, err := s.clothesDao.ClothesGetByUUID(ctx, clothesUUID)
+ if err != nil {
+ if isNotFound(err) {
+ return nil, errcode.ClothesNotFound
+ }
+ return nil, errcode.DatabaseError
+ }
+ if clothes.UserID != userID {
+ return nil, errcode.ClothesNoPermission
+ }
+ return clothes, nil
+}
+
+// buildClothesResponse 将衣物模型映射为响应,processed_image 转为可访问 URL。
+func buildClothesResponse(c *mysqlmodel.Clothes) dto.ClothesGetByUUIDResponse {
+ processed := c.ProcessedImage
+ if processed != "" {
+ processed = upload.GetCosUrl(processed)
+ }
+ return dto.ClothesGetByUUIDResponse{
+ ClothesUUID: c.ClothesUUID,
+ CategoryMain: c.CategoryMain,
+ CategorySub: c.CategorySub,
+ Style: c.Style,
+ ColorMain: c.ColorMain,
+ ColorSub: c.ColorSub,
+ Thickness: c.Thickness,
+ Version: c.Version,
+ Material: c.Material,
+ Pattern: c.Pattern,
+ SleeveType: c.SleeveType,
+ ClothesType: c.ClothesType,
+ NecklineType: c.NecklineType,
+ Formality: c.Formality,
+ Occasions: c.Occasions,
+ VisualImpact: c.VisualImpact,
+ Name: c.Name,
+ Remark: c.Remark,
+ ProcessedImage: processed,
+ }
+}
+
+// setIfNotEmpty 当值非空时写入 updates map。
+func setIfNotEmpty(m map[string]any, key, val string) {
+ if val != "" {
+ m[key] = val
+ }
+}
diff --git a/internal/service/collect.go b/internal/service/collect.go
new file mode 100644
index 0000000..04d9442
--- /dev/null
+++ b/internal/service/collect.go
@@ -0,0 +1,183 @@
+package service
+
+import (
+ "context"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
+)
+
+// CollectService 收藏业务服务。
+type CollectService struct {
+ collectDao *mysqldao.CollectDao
+ outfitDao *mysqldao.OutfitDao
+ outfitItemDao *mysqldao.OutfitItemDao
+ clothesDao *mysqldao.ClothesDao
+}
+
+// NewCollectService 创建收藏服务。
+func NewCollectService() *CollectService {
+ return &CollectService{
+ collectDao: mysqldao.NewCollectDao(),
+ outfitDao: mysqldao.NewOutfitDao(),
+ outfitItemDao: mysqldao.NewOutfitItemDao(),
+ clothesDao: mysqldao.NewClothesDao(),
+ }
+}
+
+// Collect 收藏或取消收藏社区穿搭。
+func (s *CollectService) Collect(ctx context.Context, userID int64, request *dto.CollectRequest) (*dto.CollectResponse, *errcode.Error) {
+ ctx, span := global.Tracer.Start(ctx, "svc.CollectCollect")
+ defer span.End()
+
+ outfit, err := s.outfitDao.OutfitGetByUUID(ctx, request.TargetUUID)
+ if err != nil {
+ if isNotFound(err) {
+ return nil, errcode.TargetNotFound
+ }
+ return nil, errcode.DatabaseError
+ }
+ // 非本人仅可收藏公开可见的穿搭。
+ if outfit.UserID != userID && !isOutfitPublicVisible(outfit) {
+ return nil, errcode.OutfitNoPermission
+ }
+ targetID := outfit.ID
+
+ existing, err := s.collectDao.CollectGetByTargetAndUser(ctx, CollectTypeOutfit, targetID, userID)
+ notExist := false
+ if err != nil {
+ if isNotFound(err) {
+ notExist = true
+ } else {
+ return nil, errcode.DatabaseError
+ }
+ }
+
+ switch request.Action {
+ case ActionDo: // 收藏
+ if cerr := s.collectDao.CollectUpsertOn(ctx, &mysqlmodel.Collect{
+ CollectType: CollectTypeOutfit,
+ TargetID: targetID,
+ UserID: userID,
+ Status: InteractStatusOn,
+ }); cerr != nil {
+ return nil, errcode.CollectFailed
+ }
+ _ = redisdao.CollectAdd(ctx, CollectTypeOutfit, targetID, userID)
+
+ case ActionCancel: // 取消收藏
+ if !notExist && existing.Status == InteractStatusOn {
+ if uerr := s.collectDao.CollectUpdateStatus(ctx, existing.ID, InteractStatusOff); uerr != nil {
+ return nil, errcode.CollectFailed
+ }
+ _ = redisdao.CollectRemove(ctx, CollectTypeOutfit, targetID, userID)
+ }
+ }
+
+ return &dto.CollectResponse{}, nil
+}
+
+// List 分页查询我收藏的穿搭。
+func (s *CollectService) List(ctx context.Context, userID int64, request *dto.CollectListRequest) (*dto.CollectListResponse, *errcode.Error) {
+ ctx, span := global.Tracer.Start(ctx, "svc.CollectList")
+ defer span.End()
+
+ page, pageSize := normalizePage(request.Page, request.PageSize)
+
+ pageIDs, total, err := s.collectDao.CollectListTargetIDsByUserPage(ctx, CollectTypeOutfit, userID, page, pageSize)
+ if err != nil {
+ return nil, errcode.DatabaseError
+ }
+
+ items := make([]dto.OutfitGetByUUIDResponse, 0, len(pageIDs))
+ for _, oid := range pageIDs {
+ outfit, gerr := s.outfitDao.OutfitGetByID(ctx, oid)
+ if gerr != nil {
+ // 穿搭可能已删除,跳过。
+ continue
+ }
+ outfitItems, uerr := s.itemResponses(ctx, outfit.ID)
+ if uerr != nil {
+ continue
+ }
+ items = append(items, dto.OutfitGetByUUIDResponse{
+ OutfitCreateResponse: buildOutfitCreateResponse(outfit, outfitItems),
+ })
+ }
+
+ return &dto.CollectListResponse{
+ PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize},
+ List: items,
+ }, nil
+}
+
+// GetByTarget 查询穿搭收藏状态与收藏数(Redis 优先,回落 MySQL)。
+func (s *CollectService) GetByTarget(ctx context.Context, userID int64, request *dto.CollectGetByTargetRequest) (*dto.CollectGetByTargetResponse, *errcode.Error) {
+ ctx, span := global.Tracer.Start(ctx, "svc.CollectGetByTarget")
+ defer span.End()
+
+ outfit, err := s.outfitDao.OutfitGetByUUID(ctx, request.TargetUUID)
+ if err != nil {
+ if isNotFound(err) {
+ return nil, errcode.TargetNotFound
+ }
+ return nil, errcode.DatabaseError
+ }
+ // 非本人仅可查询公开可见穿搭的收藏状态。
+ if outfit.UserID != userID && !isOutfitPublicVisible(outfit) {
+ return nil, errcode.OutfitNoPermission
+ }
+ targetID := outfit.ID
+
+ // 是否已收藏:Redis 优先,回落 MySQL。
+ isCollected, err := redisdao.CollectIsMember(ctx, CollectTypeOutfit, targetID, userID)
+ if err != nil {
+ if rec, derr := s.collectDao.CollectGetByTargetAndUser(ctx, CollectTypeOutfit, targetID, userID); derr == nil {
+ isCollected = rec.Status == InteractStatusOn
+ }
+ }
+
+ // 收藏总数:Redis 优先,回落 MySQL。
+ count, err := redisdao.CollectCount(ctx, CollectTypeOutfit, targetID)
+ if err != nil || count == 0 {
+ if dbCnt, derr := s.collectDao.CollectCountByTarget(ctx, CollectTypeOutfit, targetID); derr == nil {
+ count = dbCnt
+ }
+ }
+
+ return &dto.CollectGetByTargetResponse{
+ UserIsCollected: isCollected,
+ CollectCount: count,
+ }, nil
+}
+
+// itemResponses 获取某穿搭的衣物明细(含图片 URL)。
+func (s *CollectService) itemResponses(ctx context.Context, outfitID int64) ([]dto.OutfitItemResponse, error) {
+ its, err := s.outfitItemDao.OutfitItemListByOutfitID(ctx, outfitID)
+ if err != nil {
+ return nil, err
+ }
+ ids := make([]int64, 0, len(its))
+ for _, it := range its {
+ ids = append(ids, it.ClothesID)
+ }
+ clothesList, err := s.clothesDao.ClothesGetByIDs(ctx, ids)
+ if err != nil {
+ return nil, err
+ }
+ clothesByID := make(map[int64]mysqlmodel.Clothes, len(clothesList))
+ for _, c := range clothesList {
+ clothesByID[c.ID] = c
+ }
+ resps := make([]dto.OutfitItemResponse, 0, len(its))
+ for _, it := range its {
+ if c, ok := clothesByID[it.ClothesID]; ok {
+ resps = append(resps, buildOutfitItemResponse(&c))
+ }
+ }
+ return resps, nil
+}
diff --git a/internal/service/constants.go b/internal/service/constants.go
new file mode 100644
index 0000000..4bc9f87
--- /dev/null
+++ b/internal/service/constants.go
@@ -0,0 +1,213 @@
+package service
+
+// 本文件集中定义各业务域的状态/类型枚举常量,消除魔法数字。
+// 取值与 internal/mysqlmodel 各表字段注释保持一致。
+
+// ===== 用户 User =====
+const (
+ UserStatusNormal int8 = 1 // 正常
+ UserStatusTempBan int8 = 2 // 临时禁用
+ UserStatusPermBan int8 = 3 // 永久禁用
+ UserStatusDeactivated int8 = 4 // 已注销
+
+ SexMale int8 = 1 // 男
+ SexFemale int8 = 2 // 女
+
+ VIPTypeNone int8 = 1 // 非会员
+ VIPTypeMonthly int8 = 2 // 月度会员
+ VIPTypeYearly int8 = 3 // 年度会员
+ VIPTypeTrial int8 = 4 // 体验会员(新用户注册赠送,天数由 UserDefaultQuota.GiftVIPDay 配置)
+)
+
+// ===== 配额 Quota =====
+const (
+ QuotaTypeCos int8 = 1 // COS 上传
+ QuotaTypeAI int8 = 2 // AI 推荐
+ QuotaTypeOutfitShare int8 = 3 // 社区分享
+ QuotaTypeClosetShare int8 = 4 // 衣柜分享
+
+ // 额度凭证来源(quota_grants.source)
+ QuotaGrantSourceMember int8 = 1 // 会员包(跟随 VIP 有效期)
+ QuotaGrantSourceAddon int8 = 2 // 加量包(独立有效期)
+ QuotaGrantSourceAdReward int8 = 3 // 广告激励(当天有效)
+
+ // ChangeTarget 调整额度类型
+ QuotaChangeTargetAdjust int8 = 1 // 配额调整
+ QuotaChangeTargetUse int8 = 2 // 使用
+
+ // ChangeType 变更类型
+ QuotaChangeTypePurchase int8 = 1 // 购买获赠
+ QuotaChangeTypeActivity int8 = 2 // 活动赠送
+ QuotaChangeTypeConsume int8 = 3 // 上传消耗
+ QuotaChangeTypeRefund int8 = 4 // 删除回退
+ QuotaChangeTypeManual int8 = 5 // 人工调整
+ QuotaChangeTypeRefundRevoke int8 = 6 // 退款回收
+)
+
+// 新用户默认免费额度(内置兜底值)。
+// 优先使用 global.UserDefaultQuotaSetting 的配置值(配置为 0 时生效,表示不赠送该类免费额度);
+// 仅在该项未配置(指针为 nil)或配置为负数时回落这些常量。
+const (
+ DefaultQuotaCos int64 = 20 // 免费上传额度
+ DefaultQuotaAI int64 = 3 // 免费 AI 推荐额度
+ DefaultQuotaOutfitShare int64 = 10 // 免费社区分享额度
+ DefaultQuotaClosetShare int64 = 5 // 免费衣柜分享额度
+)
+
+// 新用户注册赠送体验会员的内置兜底天数。
+// 优先使用 UserDefaultQuota.GiftVIPDay 的配置值(配置为 0 时生效,表示不赠送);
+// 仅在该项未配置(指针为 nil)或配置为负数时回落此常量。
+const DefaultVipDays int64 = 3
+
+// ===== 衣物 Clothes 图片处理状态 =====
+const (
+ ImageProcessMatting int8 = 1 // 抠图中
+ ImageProcessClassifying int8 = 2 // 分类中
+ ImageProcessQueuing int8 = 3 // 排队中
+ ImageProcessSuccess int8 = 4 // 成功
+ ImageProcessFailed int8 = 5 // 失败
+)
+
+// ===== 穿搭 Outfit =====
+const (
+ OutfitSourceUser int8 = 1 // 用户创建
+ OutfitSourceAI int8 = 2 // AI 推荐
+
+ OutfitPrivate int8 = 1 // 不公开
+ OutfitPublic int8 = 2 // 公开
+
+ OutfitAuditPending int8 = 1 // 待审核
+ OutfitAuditPass int8 = 2 // 审核通过
+ OutfitAuditReject int8 = 3 // 审核不通过
+
+ OutfitStatusNormal int8 = 1 // 正常
+ OutfitStatusBanned int8 = 2 // 封禁
+ OutfitStatusUnsaved int8 = 3 // 待用户保存(AI 推荐生成后未保存)
+)
+
+// ===== 衣柜分享 ClosetShare =====
+const (
+ ShareTypePartial int8 = 1 // 部分衣物分享
+ ShareTypeSnapshot int8 = 2 // 全量快照分享
+
+ ShareStatusNormal int8 = 1 // 正常
+ ShareStatusCancelled int8 = 2 // 用户取消
+ ShareStatusExpired int8 = 3 // 已过期
+ ShareStatusBanned int8 = 4 // 管理员封禁
+)
+
+// ===== 审核日志 AuditLog =====
+const (
+ AuditResultPass int8 = 1 // 通过
+ AuditResultReject int8 = 2 // 驳回
+)
+
+// ===== 收藏 / 点赞 =====
+const (
+ CollectTypeOutfit int8 = 1 // 社区穿搭
+ LikeTypeOutfit int8 = 1 // 社区穿搭
+
+ InteractStatusOn int8 = 1 // 已收藏 / 已点赞
+ InteractStatusOff int8 = 0 // 已取消
+
+ ActionDo int8 = 1 // 收藏 / 点赞
+ ActionCancel int8 = 2 // 取消
+)
+
+// ===== 通知 Notification =====
+const (
+ NotificationTypeLike int8 = 1 // 点赞通知
+ NotificationTypeOrder int8 = 2 // 订单通知
+ NotificationTypeSystem int8 = 3 // 系统通知
+
+ NotificationUnread int8 = 1 // 未读
+ NotificationRead int8 = 2 // 已读
+)
+
+// ===== 商品 Goods =====
+const (
+ GoodsTypeMember int8 = 1 // 会员包
+ GoodsTypeUpload int8 = 2 // 上传包
+ GoodsTypeAIRecommend int8 = 3 // AI 推荐包
+ GoodsTypeClosetShare int8 = 4 // 衣柜分享包
+ GoodsTypeOutfitShare int8 = 5 // 搭配分享包
+
+ GoodsStatusOnShelf int8 = 1 // 上架
+ GoodsStatusOffShelf int8 = 2 // 下架
+
+ // 虚拟支付道具发布状态
+ GoodsProductUnpublished int8 = 0 // 未发布
+ GoodsProductPublishing int8 = 1 // 发布中(上传/发布任务处理中)
+ GoodsProductPublished int8 = 2 // 已发布
+ GoodsProductPublishFail int8 = 3 // 发布失败
+)
+
+// ===== 订单 Order =====
+const (
+ OrderStatusPending int8 = 1 // 待支付
+ OrderStatusPaid int8 = 2 // 已支付
+ OrderStatusCancelled int8 = 3 // 已取消
+ OrderStatusExpired int8 = 4 // 已过期
+ OrderStatusRefunded int8 = 5 // 已退款
+
+ PayChannelWechat int8 = 1 // 微信支付(普通 JSAPI)
+ PayChannelVirtual int8 = 2 // 微信虚拟支付(XPay)
+
+ // 虚拟支付平台(由前端按 systemInfo.platform 上报)
+ XPayPlatformAndroid = "android"
+ XPayPlatformIOS = "ios"
+ XPayPlatformHarmony = "harmony"
+ XPayPlatformWindows = "windows"
+)
+
+// ===== 退款 Refund =====
+const (
+ RefundStatusPending int8 = 0 // 待处理
+ RefundStatusProcessing int8 = 1 // 退款中
+ RefundStatusSuccess int8 = 2 // 退款成功
+ RefundStatusFailed int8 = 3 // 退款失败
+)
+
+// ===== VIP 流水 =====
+const (
+ VIPChangeTypeOpen int8 = 1 // 开通
+ VIPChangeTypeRenew int8 = 2 // 续费
+ VIPChangeTypeActivity int8 = 3 // 活动赠送
+ VIPChangeTypeExpire int8 = 4 // 到期失效
+ VIPChangeTypeManual int8 = 5 // 人工调整
+ VIPChangeTypeRefundRevoke int8 = 6 // 退款回收
+
+ VIPSourcePersonalPay int8 = 1 // 个人支付
+ VIPSourceActivity int8 = 2 // 活动赠送
+ VIPSourceSystem int8 = 3 // 系统
+ VIPSourceManual int8 = 4 // 人工调整
+)
+
+// ===== AI 流水状态 =====
+const (
+ AIFlowStatusSuccess int8 = 1 // 成功
+ AIFlowStatusFailed int8 = 2 // 失败
+)
+
+// 订单支付有效期(秒)。
+const orderPayTTLSeconds int64 = 15 * 60
+
+// ===== 权益发放补偿 BenefitGrantTask =====
+const (
+ BenefitTypeVIP int8 = 1 // 会员
+ BenefitTypeCos int8 = 2 // 上传额度
+ BenefitTypeAI int8 = 3 // AI 推荐额度
+ BenefitTypeClosetShare int8 = 4 // 衣柜分享额度
+ BenefitTypeOutfitShare int8 = 5 // 搭配分享额度
+
+ BenefitTaskPending int8 = 0 // 待发放
+ BenefitTaskSuccess int8 = 1 // 已发放
+ BenefitTaskFailed int8 = 2 // 发放失败(重试超限)
+ BenefitTaskProcessing int8 = 3 // 发放中(认领后,防并发重复发放)
+
+ benefitMaxRetry int = 3 // 默认最大重试次数
+
+ // benefitRetryCooldownSeconds 失败任务被回调重新触发的冷却期(秒):
+ // 冷却期内不重置,防止微信回调重试风暴无限重置任务绕过重试上限。
+ benefitRetryCooldownSeconds int64 = 300
+)
diff --git a/internal/service/goods.go b/internal/service/goods.go
new file mode 100644
index 0000000..939ce51
--- /dev/null
+++ b/internal/service/goods.go
@@ -0,0 +1,84 @@
+package service
+
+import (
+ "context"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
+)
+
+// GoodsService 商品业务服务。
+type GoodsService struct {
+ goodsDao *mysqldao.GoodsDao
+}
+
+// NewGoodsService 创建商品服务。
+func NewGoodsService() *GoodsService {
+ return &GoodsService{
+ goodsDao: mysqldao.NewGoodsDao(),
+ }
+}
+
+// GetByCode 按商品编码查询商品(下架返回错误)。
+func (s *GoodsService) GetByCode(ctx context.Context, request *dto.GoodsGetByCodeRequest) (*dto.GoodsGetByCodeResponse, *errcode.Error) {
+ ctx, span := global.Tracer.Start(ctx, "svc.GoodsGetByCode")
+ defer span.End()
+
+ goods, err := s.goodsDao.GoodsGetByCode(ctx, request.GoodsCode)
+ if err != nil {
+ if isNotFound(err) {
+ return nil, errcode.GoodsNotFound
+ }
+ return nil, errcode.DatabaseError
+ }
+ if goods.Status != GoodsStatusOnShelf {
+ return nil, errcode.GoodsOffShelf
+ }
+ response := buildGoodsResponse(goods)
+ return &response, nil
+}
+
+// List 分页查询上架商品(按类型过滤)。
+func (s *GoodsService) List(ctx context.Context, request *dto.GoodsListRequest) (*dto.GoodsListResponse, *errcode.Error) {
+ ctx, span := global.Tracer.Start(ctx, "svc.GoodsList")
+ defer span.End()
+
+ page, pageSize := normalizePage(request.Page, request.PageSize)
+
+ list, total, err := s.goodsDao.GoodsList(ctx, request.GoodsType, page, pageSize)
+ if err != nil {
+ return nil, errcode.DatabaseError
+ }
+
+ items := make([]dto.GoodsGetByCodeResponse, 0, len(list))
+ for i := range list {
+ items = append(items, buildGoodsResponse(&list[i]))
+ }
+
+ return &dto.GoodsListResponse{
+ PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize},
+ List: items,
+ }, nil
+}
+
+// buildGoodsResponse 将商品模型映射为响应。注意 dto.UploadQuota 对应 model.CosQuota。
+func buildGoodsResponse(g *mysqlmodel.Goods) dto.GoodsGetByCodeResponse {
+ return dto.GoodsGetByCodeResponse{
+ ID: g.ID,
+ GoodsCode: g.GoodsCode,
+ GoodsName: g.GoodsName,
+ GoodsType: g.GoodsType,
+ Price: g.Price,
+ DiscountPrice: g.DiscountPrice,
+ MemberDays: g.MemberDays,
+ UploadQuota: g.CosQuota,
+ AIRecommendQuota: g.AIRecommendQuota,
+ ClosetShareQuota: g.ClosetShareQuota,
+ OutfitShareQuota: g.OutfitShareQuota,
+ ValidDays: g.ValidDays,
+ Description: g.Description,
+ }
+}
diff --git a/internal/service/horoscope.go b/internal/service/horoscope.go
new file mode 100644
index 0000000..86298e4
--- /dev/null
+++ b/internal/service/horoscope.go
@@ -0,0 +1,172 @@
+package service
+
+import (
+ "context"
+ "encoding/json"
+ "time"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/tokenhub"
+)
+
+// HoroscopeService 今日运势业务服务。
+type HoroscopeService struct {
+ horoscopeDao *mysqldao.HoroscopeDao
+ userDao *mysqldao.UserDao
+}
+
+// NewHoroscopeService 创建运势服务。
+func NewHoroscopeService() *HoroscopeService {
+ return &HoroscopeService{
+ horoscopeDao: mysqldao.NewHoroscopeDao(),
+ userDao: mysqldao.NewUserDao(),
+ }
+}
+
+// GetToday 获取当前用户今日运势:由生日推算星座,Redis 优先、未命中回落 MySQL 并回填缓存。
+func (s *HoroscopeService) GetToday(ctx context.Context, userID int64) (*dto.HoroscopeResponse, *errcode.Error) {
+ ctx, span := global.Tracer.Start(ctx, "svc.HoroscopeGetToday")
+ defer span.End()
+
+ user, err := s.userDao.UserGetByID(ctx, userID)
+ if err != nil {
+ if isNotFound(err) {
+ return nil, errcode.UserNotFound
+ }
+ return nil, errcode.DatabaseError
+ }
+ if user.BirthDay == 0 {
+ return nil, errcode.InvalidParams.WithDetails("请先完善生日信息")
+ }
+
+ zodiac := zodiacByUnix(user.BirthDay)
+ statDate := todayStatDate()
+
+ // 1. Redis 优先。
+ if data, cerr := redisdao.HoroscopeGet(ctx, zodiac, statDate); cerr == nil && len(data) > 0 {
+ var response dto.HoroscopeResponse
+ if json.Unmarshal(data, &response) == nil {
+ return &response, nil
+ }
+ }
+
+ // 2. 回落 MySQL。
+ h, derr := s.horoscopeDao.HoroscopeGetByZodiacAndDate(ctx, zodiac, statDate)
+ if derr != nil {
+ if isNotFound(derr) {
+ // 今日运势尚未生成,异步触发兜底生成,本次返回未生成。
+ tokenhub.EnsureTodayFortunes()
+ return nil, errcode.HoroscopeNotFound
+ }
+ return nil, errcode.DatabaseError
+ }
+
+ response := buildHoroscopeResponse(h)
+
+ // 回填缓存,TTL 至次日凌晨。
+ if data, merr := json.Marshal(response); merr == nil {
+ _ = redisdao.HoroscopeSet(ctx, zodiac, statDate, data, ttlUntilNextMidnight())
+ }
+
+ return &response, nil
+}
+
+// buildHoroscopeResponse 将运势模型映射为响应。
+func buildHoroscopeResponse(h *mysqlmodel.Horoscope) dto.HoroscopeResponse {
+ return dto.HoroscopeResponse{
+ StatDate: h.StatDate,
+ Zodiac: h.Zodiac,
+ OverallScore: h.OverallScore,
+ LoveScore: h.LoveScore,
+ CareerScore: h.CareerScore,
+ WealthScore: h.WealthScore,
+ DailyText: h.DailyText,
+ LuckyColor: h.LuckyColor,
+ LuckyNumber: h.LuckyNumber,
+ }
+}
+
+// ttlUntilNextMidnight 计算到次日 0 点的时长,用于运势缓存过期。
+func ttlUntilNextMidnight() time.Duration {
+ now := time.Now()
+ next := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location())
+ return next.Sub(now)
+}
+
+// zodiacByUnix 根据生日(秒级 unix 时间戳)计算星座(中文名,与运势数据对齐)。
+func zodiacByUnix(birthUnix int64) string {
+ t := time.Unix(birthUnix, 0)
+ return zodiacByMonthDay(int(t.Month()), t.Day())
+}
+
+// zodiacByMonthDay 根据月日返回星座中文名。
+func zodiacByMonthDay(month, day int) string {
+ switch month {
+ case 1:
+ if day <= 19 {
+ return "摩羯座"
+ }
+ return "水瓶座"
+ case 2:
+ if day <= 18 {
+ return "水瓶座"
+ }
+ return "双鱼座"
+ case 3:
+ if day <= 20 {
+ return "双鱼座"
+ }
+ return "白羊座"
+ case 4:
+ if day <= 19 {
+ return "白羊座"
+ }
+ return "金牛座"
+ case 5:
+ if day <= 20 {
+ return "金牛座"
+ }
+ return "双子座"
+ case 6:
+ if day <= 21 {
+ return "双子座"
+ }
+ return "巨蟹座"
+ case 7:
+ if day <= 22 {
+ return "巨蟹座"
+ }
+ return "狮子座"
+ case 8:
+ if day <= 22 {
+ return "狮子座"
+ }
+ return "处女座"
+ case 9:
+ if day <= 22 {
+ return "处女座"
+ }
+ return "天秤座"
+ case 10:
+ if day <= 23 {
+ return "天秤座"
+ }
+ return "天蝎座"
+ case 11:
+ if day <= 22 {
+ return "天蝎座"
+ }
+ return "射手座"
+ case 12:
+ if day <= 21 {
+ return "射手座"
+ }
+ return "摩羯座"
+ }
+ return "摩羯座"
+}
diff --git a/internal/service/like.go b/internal/service/like.go
new file mode 100644
index 0000000..e835178
--- /dev/null
+++ b/internal/service/like.go
@@ -0,0 +1,125 @@
+package service
+
+import (
+ "context"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
+)
+
+// LikeService 点赞业务服务。
+type LikeService struct {
+ likeDao *mysqldao.LikeDao
+ outfitDao *mysqldao.OutfitDao
+}
+
+// NewLikeService 创建点赞服务。
+func NewLikeService() *LikeService {
+ return &LikeService{
+ likeDao: mysqldao.NewLikeDao(),
+ outfitDao: mysqldao.NewOutfitDao(),
+ }
+}
+
+// LikeCreate 点赞或取消点赞社区穿搭。
+func (s *LikeService) LikeCreate(ctx context.Context, userID int64, request *dto.LikeCreateRequest) (*dto.LikeResponse, *errcode.Error) {
+ ctx, span := global.Tracer.Start(ctx, "svc.LikeLikeCreate")
+ defer span.End()
+
+ outfit, cerr := s.getPublicOutfit(ctx, userID, request.TargetUUID)
+ if cerr != nil {
+ return nil, cerr
+ }
+ targetID := outfit.ID
+
+ existing, err := s.likeDao.LikeGetByTargetAndUser(ctx, LikeTypeOutfit, targetID, userID)
+ notExist := false
+ if err != nil {
+ if isNotFound(err) {
+ notExist = true
+ } else {
+ return nil, errcode.DatabaseError
+ }
+ }
+
+ switch request.Action {
+ case ActionDo: // 点赞
+ if cerr := s.likeDao.LikeUpsertOn(ctx, &mysqlmodel.Like{
+ LikeType: LikeTypeOutfit,
+ TargetID: targetID,
+ UserID: userID,
+ Status: InteractStatusOn,
+ }); cerr != nil {
+ return nil, errcode.LikeFailed
+ }
+ _ = redisdao.LikeAdd(ctx, LikeTypeOutfit, targetID, userID)
+
+ // 仅在「从取消/无记录变为点赞」时发通知,避免并发/重复点赞重复通知。
+ wasLiked := !notExist && existing.Status == InteractStatusOn
+ if !wasLiked && outfit.UserID != userID {
+ sendNotification(ctx, outfit.UserID, userID, NotificationTypeLike, "收到新的点赞", "你的穿搭「"+outfit.OutfitName+"」收到了一个赞")
+ }
+
+ case ActionCancel: // 取消点赞
+ if !notExist && existing.Status == InteractStatusOn {
+ if uerr := s.likeDao.LikeUpdateStatus(ctx, existing.ID, InteractStatusOff); uerr != nil {
+ return nil, errcode.LikeFailed
+ }
+ _ = redisdao.LikeRemove(ctx, LikeTypeOutfit, targetID, userID)
+ }
+ }
+
+ return &dto.LikeResponse{}, nil
+}
+
+// GetByTargetID 查询穿搭点赞状态与点赞数(Redis 优先)。
+func (s *LikeService) GetByTargetID(ctx context.Context, userID int64, request *dto.LikeGetByTargetIDRequest) (*dto.LikeGetByTargetIDResponse, *errcode.Error) {
+ ctx, span := global.Tracer.Start(ctx, "svc.LikeGetByTargetID")
+ defer span.End()
+
+ outfit, cerr := s.getPublicOutfit(ctx, userID, request.TargetUUID)
+ if cerr != nil {
+ return nil, cerr
+ }
+ targetID := outfit.ID
+
+ // 是否已点赞:Redis 优先,回落 MySQL。
+ isLiked, err := redisdao.LikeIsMember(ctx, LikeTypeOutfit, targetID, userID)
+ if err != nil {
+ if rec, derr := s.likeDao.LikeGetByTargetAndUser(ctx, LikeTypeOutfit, targetID, userID); derr == nil {
+ isLiked = rec.Status == InteractStatusOn
+ }
+ }
+
+ // 点赞总数:Redis 优先,回落 MySQL。
+ count, err := redisdao.LikeCount(ctx, LikeTypeOutfit, targetID)
+ if err != nil || count == 0 {
+ if dbCnt, derr := s.likeDao.LikeCountByTarget(ctx, LikeTypeOutfit, targetID); derr == nil {
+ count = dbCnt
+ }
+ }
+
+ return &dto.LikeGetByTargetIDResponse{
+ UserIsLiked: isLiked,
+ LikeCount: count,
+ }, nil
+}
+
+// getPublicOutfit 按 UUID 查询社区穿搭(须存在),非本人仅可操作公开可见的穿搭。
+func (s *LikeService) getPublicOutfit(ctx context.Context, userID int64, outfitUUID string) (*mysqlmodel.Outfit, *errcode.Error) {
+ outfit, err := s.outfitDao.OutfitGetByUUID(ctx, outfitUUID)
+ if err != nil {
+ if isNotFound(err) {
+ return nil, errcode.TargetNotFound
+ }
+ return nil, errcode.DatabaseError
+ }
+ if outfit.UserID != userID && !isOutfitPublicVisible(outfit) {
+ return nil, errcode.OutfitNoPermission
+ }
+ return outfit, nil
+}
diff --git a/internal/service/notification.go b/internal/service/notification.go
new file mode 100644
index 0000000..93e5c8a
--- /dev/null
+++ b/internal/service/notification.go
@@ -0,0 +1,158 @@
+package service
+
+import (
+ "context"
+ "log/slog"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
+)
+
+// NotificationService 通知业务服务。
+type NotificationService struct {
+ notificationDao *mysqldao.NotificationDao
+}
+
+// NewNotificationService 创建通知服务。
+func NewNotificationService() *NotificationService {
+ return &NotificationService{
+ notificationDao: mysqldao.NewNotificationDao(),
+ }
+}
+
+// List 分页查询当前用户通知。
+func (s *NotificationService) List(ctx context.Context, userID int64, request *dto.NotificationListRequest) (*dto.NotificationListResponse, *errcode.Error) {
+ ctx, span := global.Tracer.Start(ctx, "svc.NotificationList")
+ defer span.End()
+
+ page, pageSize := normalizePage(request.Page, request.PageSize)
+
+ list, total, err := s.notificationDao.NotificationListByUserID(ctx, userID, request.IsRead, request.MsgType, page, pageSize)
+ if err != nil {
+ return nil, errcode.DatabaseError
+ }
+
+ items := make([]dto.NotificationGetByUUIDResponse, 0, len(list))
+ for i := range list {
+ items = append(items, buildNotificationResponse(&list[i]))
+ }
+
+ return &dto.NotificationListResponse{
+ PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize},
+ List: items,
+ }, nil
+}
+
+// GetByUUID 获取通知详情(校验归属)。
+func (s *NotificationService) GetByUUID(ctx context.Context, userID int64, request *dto.NotificationGetByUUIDRequest) (*dto.NotificationGetByUUIDResponse, *errcode.Error) {
+ ctx, span := global.Tracer.Start(ctx, "svc.NotificationGetByUUID")
+ defer span.End()
+
+ n, err := s.notificationDao.NotificationGetByUUID(ctx, request.NotificationUUID)
+ if err != nil {
+ if isNotFound(err) {
+ return nil, errcode.NotificationNotFound
+ }
+ return nil, errcode.DatabaseError
+ }
+ if n.UserID != userID {
+ return nil, errcode.Forbidden
+ }
+ response := buildNotificationResponse(n)
+ return &response, nil
+}
+
+// Read 标记通知已读(校验归属),并同步扣减未读数。
+func (s *NotificationService) Read(ctx context.Context, userID int64, request *dto.NotificationReadRequest) (*dto.NotificationUnreadResponse, *errcode.Error) {
+ ctx, span := global.Tracer.Start(ctx, "svc.NotificationRead")
+ defer span.End()
+
+ n, err := s.notificationDao.NotificationGetByUUID(ctx, request.NotificationUUID)
+ if err != nil {
+ if isNotFound(err) {
+ return nil, errcode.NotificationNotFound
+ }
+ return nil, errcode.DatabaseError
+ }
+ if n.UserID != userID {
+ return nil, errcode.Forbidden
+ }
+
+ // 已读则直接返回当前未读数。
+ if n.IsRead == NotificationRead {
+ cnt, _ := redisdao.NotificationUnreadGet(ctx, userID)
+ return &dto.NotificationUnreadResponse{UnreadCount: cnt}, nil
+ }
+
+ // 条件标记已读(仅未读 -> 已读),防并发重复扣减未读数。
+ ok, err := s.notificationDao.NotificationUpdateReadByUUIDIfUnread(ctx, request.NotificationUUID)
+ if err != nil {
+ return nil, errcode.DatabaseError
+ }
+ if !ok {
+ // 已被并发标记已读,直接返回当前未读数。
+ cnt, _ := redisdao.NotificationUnreadGet(ctx, userID)
+ return &dto.NotificationUnreadResponse{UnreadCount: cnt}, nil
+ }
+ cnt, _ := redisdao.NotificationUnreadDecr(ctx, userID)
+
+ return &dto.NotificationUnreadResponse{UnreadCount: cnt}, nil
+}
+
+// Unread 获取当前用户未读数,Redis 优先、回落 MySQL。
+func (s *NotificationService) Unread(ctx context.Context, userID int64) (*dto.NotificationUnreadResponse, *errcode.Error) {
+ ctx, span := global.Tracer.Start(ctx, "svc.NotificationUnread")
+ defer span.End()
+
+ cnt, err := redisdao.NotificationUnreadGet(ctx, userID)
+ if err == nil && cnt > 0 {
+ return &dto.NotificationUnreadResponse{UnreadCount: cnt}, nil
+ }
+
+ // Redis 为 0 或异常,回落 MySQL 统计。
+ dbCnt, derr := s.notificationDao.NotificationCountUnread(ctx, userID)
+ if derr != nil {
+ return nil, errcode.DatabaseError
+ }
+ // 回填 Redis 未读数缓存,避免 key 过期导致后续标记已读时扣减被吞。
+ _ = redisdao.NotificationUnreadSet(ctx, userID, dbCnt)
+ return &dto.NotificationUnreadResponse{UnreadCount: dbCnt}, nil
+}
+
+// buildNotificationResponse 将通知模型映射为响应。
+func buildNotificationResponse(n *mysqlmodel.UserNotification) dto.NotificationGetByUUIDResponse {
+ return dto.NotificationGetByUUIDResponse{
+ NotificationUUID: n.NotificationUUID,
+ MsgType: n.MsgType,
+ Title: n.Title,
+ Content: n.Content,
+ IsRead: n.IsRead,
+ CreatedAt: n.CreatedAt,
+ }
+}
+
+// sendNotification 发送一条通知并同步递增接收方未读数(供 like/order 等复用,best-effort)。
+func sendNotification(ctx context.Context, recipientUserID, senderID int64, msgType int8, title, content string) {
+ dao := mysqldao.NewNotificationDao()
+ n := &mysqlmodel.UserNotification{
+ NotificationUUID: genUUID(),
+ UserID: recipientUserID,
+ MsgType: msgType,
+ SenderID: senderID,
+ Title: title,
+ Content: content,
+ IsRead: NotificationUnread,
+ }
+ if err := dao.NotificationCreate(ctx, n); err != nil {
+ slog.WarnContext(ctx, "create notification failed",
+ "user_id", recipientUserID, "msg_type", msgType, "error", err)
+ return
+ }
+ if _, err := redisdao.NotificationUnreadIncr(ctx, recipientUserID); err != nil {
+ slog.ErrorContext(ctx, "notification unread incr failed", "user_id", recipientUserID, "error", err)
+ }
+}
diff --git a/internal/service/order.go b/internal/service/order.go
new file mode 100644
index 0000000..85e8f04
--- /dev/null
+++ b/internal/service/order.go
@@ -0,0 +1,747 @@
+package service
+
+import (
+ "context"
+ "log/slog"
+ "net/http"
+ "strings"
+ "time"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/wechat"
+ "gorm.io/gorm"
+)
+
+// OrderService 订单与退款业务服务。
+type OrderService struct {
+ orderDao *mysqldao.OrderDao
+ goodsDao *mysqldao.GoodsDao
+ userDao *mysqldao.UserDao
+ refundDao *mysqldao.RefundFlowLogDao
+ vipDao *mysqldao.VIPFlowLogDao
+}
+
+// NewOrderService 创建订单服务。
+func NewOrderService() *OrderService {
+ return &OrderService{
+ orderDao: mysqldao.NewOrderDao(),
+ goodsDao: mysqldao.NewGoodsDao(),
+ userDao: mysqldao.NewUserDao(),
+ refundDao: mysqldao.NewRefundFlowLogDao(),
+ vipDao: mysqldao.NewVIPFlowLogDao(),
+ }
+}
+
+// Create 创建订单并发起微信 JSAPI 下单。
+func (s *OrderService) Create(ctx context.Context, userID int64, request *dto.OrderCreateRequest) (*dto.OrderCreateResponse, *errcode.Error) {
+ ctx, span := global.Tracer.Start(ctx, "svc.OrderCreate")
+ defer span.End()
+
+ goods, err := s.goodsDao.GoodsGetByCode(ctx, request.GoodsCode)
+ if err != nil {
+ if isNotFound(err) {
+ return nil, errcode.GoodsNotFound
+ }
+ return nil, errcode.DatabaseError
+ }
+ if goods.Status != GoodsStatusOnShelf {
+ return nil, errcode.GoodsOffShelf
+ }
+
+ // 支付用户 openid。
+ user, uerr := s.userDao.UserGetByID(ctx, userID)
+ if uerr != nil {
+ if isNotFound(uerr) {
+ return nil, errcode.UserNotFound
+ }
+ return nil, errcode.DatabaseError
+ }
+ if user.WxOpenID == "" {
+ return nil, errcode.OrderCreateFailed.WithDetails("缺少微信 openid")
+ }
+
+ orderNo := genOrderNo()
+ expireTime := nowUnix() + orderPayTTLSeconds
+ payAmount := goods.DiscountPrice
+
+ order := &mysqlmodel.Order{
+ OrderNo: orderNo,
+ UserID: userID,
+ GoodsID: goods.ID,
+ GoodsName: goods.GoodsName,
+ UnitPrice: goods.DiscountPrice,
+ TotalAmount: payAmount,
+ PayAmount: payAmount,
+ PayChannel: PayChannelWechat,
+ OrderStatus: OrderStatusPending,
+ MemberDays: goods.MemberDays,
+ CosQuota: goods.CosQuota,
+ AIRecommendQuota: goods.AIRecommendQuota,
+ ClosetShareQuota: goods.ClosetShareQuota,
+ OutfitShareQuota: goods.OutfitShareQuota,
+ ExpireTime: expireTime,
+ }
+ // 先落库再微信下单:微信下单失败时本地订单保留为待支付并超时自动过期,
+ // 避免「先微信下单、落库失败」产生无本地记录的孤儿预支付单。
+ if cerr := s.orderDao.OrderCreate(ctx, order); cerr != nil {
+ return nil, errcode.OrderCreateFailed
+ }
+
+ payParams, perr := wechat.JsapiPrepay(ctx, user.WxOpenID, orderNo, goods.GoodsName, payAmount)
+ if perr != nil {
+ return nil, errcode.OrderCreateFailed
+ }
+
+ return &dto.OrderCreateResponse{
+ OrderNo: orderNo,
+ PayAmount: payAmount,
+ ExpireTime: expireTime,
+ PayParams: &dto.WechatPayParams{
+ TimeStamp: payParams.TimeStamp,
+ NonceStr: payParams.NonceStr,
+ Package: payParams.Package,
+ SignType: payParams.SignType,
+ PaySign: payParams.PaySign,
+ },
+ }, nil
+}
+
+// PayNotify 处理微信支付结果回调(供回调 handler 调用):验签解密 + 幂等 + 发放权益。
+func (s *OrderService) PayNotify(ctx context.Context, request *http.Request) *errcode.Error {
+ ctx, span := global.Tracer.Start(ctx, "svc.OrderPayNotify")
+ defer span.End()
+
+ data, err := wechat.VerifyAndDecryptPayNotify(ctx, request)
+ if err != nil {
+ return errcode.RPCError
+ }
+ if data.TradeState != "SUCCESS" {
+ return nil // 非支付成功,忽略。
+ }
+
+ // 幂等锁。
+ lockKey := fmtLockKey(redismodel.KeyLockPayCallback, data.OutTradeNo)
+ lockVal := genUUID()
+ if ok, _ := redisdao.TryLock(ctx, lockKey, lockVal, 30*time.Second); !ok {
+ return nil // 并发回调,交由持锁者处理。
+ }
+ defer func() { _ = redisdao.Unlock(ctx, lockKey, lockVal) }()
+
+ // 先查订单并校验回调金额,防止金额被篡改导致错误发放。
+ order, gerr := s.orderDao.OrderGetByOrderNo(ctx, data.OutTradeNo)
+ if gerr != nil {
+ if isNotFound(gerr) {
+ slog.ErrorContext(ctx, "pay notify: order not found", "out_trade_no", data.OutTradeNo)
+ return nil
+ }
+ return errcode.DatabaseError
+ }
+ if data.Amount.Total != order.PayAmount {
+ slog.ErrorContext(ctx, "pay notify: amount mismatch",
+ "out_trade_no", data.OutTradeNo,
+ "notify_amount", data.Amount.Total,
+ "order_amount", order.PayAmount,
+ )
+ return nil // 金额不一致,不流转、不发放权益,人工介入。
+ }
+
+ // 条件流转:待支付 -> 已支付,防重复回调。
+ updated, uerr := s.orderDao.OrderUpdateStatusByOrderNo(ctx, data.OutTradeNo, OrderStatusPending, OrderStatusPaid)
+ if uerr != nil {
+ return errcode.DatabaseError
+ }
+ if updated {
+ s.finalizePaidOrder(ctx, order, data)
+ return nil
+ }
+
+ // 流转失败:重新读取订单,处理「本地已过期/已取消但用户实际已付款」的竞态。
+ current, gerr := s.orderDao.OrderGetByOrderNo(ctx, data.OutTradeNo)
+ if gerr != nil {
+ if isNotFound(gerr) {
+ return nil
+ }
+ return errcode.DatabaseError
+ }
+
+ switch current.OrderStatus {
+ case OrderStatusPaid:
+ // 已被并发回调处理。兜底确保支付信息与权益发放已完成(均幂等),
+ // 覆盖「订单已流转为 Paid 但权益发放前崩溃导致权益丢失」的场景。
+ _ = s.orderDao.OrderUpdateByOrderNo(ctx, current.OrderNo, map[string]any{
+ "pay_time": nowUnix(),
+ "transaction_id": data.TransactionID,
+ "callback_raw": data.Raw,
+ })
+ s.grantOrderBenefits(ctx, current)
+ return nil
+ case OrderStatusExpired:
+ // 订单被系统过期但用户已付款:过期 -> 已支付并补发货,避免用户付款却不到账。
+ ok, uerr := s.orderDao.OrderUpdateStatusByOrderNo(ctx, data.OutTradeNo, OrderStatusExpired, OrderStatusPaid)
+ if uerr != nil {
+ return errcode.DatabaseError
+ }
+ if !ok {
+ return nil // 已被其它回调处理。
+ }
+ s.finalizePaidOrder(ctx, current, data)
+ return nil
+ case OrderStatusCancelled:
+ // 用户主动取消却仍支付(关闭订单与支付存在竞态窗口):自动发起退款,避免用户付款却拿不到权益。
+ s.autoRefundCancelledOrder(ctx, current, data)
+ return nil
+ default:
+ return nil
+ }
+}
+
+// Cancel 取消待支付订单(校验归属)。
+func (s *OrderService) Cancel(ctx context.Context, userID int64, request *dto.OrderCancelRequest) (*dto.OrderResponse, *errcode.Error) {
+ ctx, span := global.Tracer.Start(ctx, "svc.OrderCancel")
+ defer span.End()
+
+ order, cerr := s.getOwnedOrder(ctx, request.OrderNo, userID)
+ if cerr != nil {
+ return nil, cerr
+ }
+ // 虚拟支付订单不可取消:虚拟支付无服务端关单 API,本地取消后微信侧单仍有效,
+ // 用户继续支付会造成「已付款但权益不发放」。禁止取消从源头杜绝该资损场景。
+ if order.PayChannel == PayChannelVirtual {
+ return nil, errcode.VirtualOrderCancelNotAllowed
+ }
+ if order.OrderStatus == OrderStatusPaid {
+ return nil, errcode.OrderAlreadyPaid
+ }
+ if order.OrderStatus != OrderStatusPending {
+ return nil, errcode.OrderStatusInvalid
+ }
+
+ ok, err := s.orderDao.OrderUpdateStatusByOrderNo(ctx, request.OrderNo, OrderStatusPending, OrderStatusCancelled)
+ if err != nil {
+ return nil, errcode.OrderCancelFailed
+ }
+ if !ok {
+ return nil, errcode.OrderStatusInvalid
+ }
+
+ // 本地取消成功后,尽力关闭微信侧订单,防止用户继续支付(失败仅告警,不阻断取消)。
+ if cerr := wechat.CloseOrder(ctx, request.OrderNo); cerr != nil {
+ slog.WarnContext(ctx, "close wechat order failed", "order_no", request.OrderNo, "error", cerr)
+ }
+
+ order.OrderStatus = OrderStatusCancelled
+ response := buildOrderResponse(order)
+ return &response, nil
+}
+
+// List 分页查询我的订单。
+func (s *OrderService) List(ctx context.Context, userID int64, request *dto.OrderListRequest) (*dto.OrderListResponse, *errcode.Error) {
+ ctx, span := global.Tracer.Start(ctx, "svc.OrderList")
+ defer span.End()
+
+ page, pageSize := normalizePage(request.Page, request.PageSize)
+
+ list, total, err := s.orderDao.OrderListByUserID(ctx, userID, request.Status, page, pageSize)
+ if err != nil {
+ return nil, errcode.DatabaseError
+ }
+
+ items := make([]dto.OrderResponse, 0, len(list))
+ for i := range list {
+ items = append(items, buildOrderResponse(&list[i]))
+ }
+
+ return &dto.OrderListResponse{
+ PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize},
+ List: items,
+ }, nil
+}
+
+// RefundApply 申请退款(仅已支付订单)。
+func (s *OrderService) RefundApply(ctx context.Context, userID int64, request *dto.RefundApplyRequest) (*dto.RefundResponse, *errcode.Error) {
+ ctx, span := global.Tracer.Start(ctx, "svc.OrderRefundApply")
+ defer span.End()
+
+ order, cerr := s.getOwnedOrder(ctx, request.OrderNo, userID)
+ if cerr != nil {
+ return nil, cerr
+ }
+ // 虚拟支付 iOS 订单不可主动退款:用户走 App Store 申请,Apple 通过退款问询推送回收。
+ if order.PayChannel == PayChannelVirtual && order.XPayPlatform == XPayPlatformIOS {
+ return nil, errcode.IOSRefundViaAppStore
+ }
+ if order.OrderStatus != OrderStatusPaid {
+ return nil, errcode.RefundNotAllowed
+ }
+
+ // 已有退款流水则按状态处理:非失败不允许重复申请;失败则复用旧记录重新申请。
+ existing, gerr := s.refundDao.RefundFlowLogGetByOrderNo(ctx, request.OrderNo)
+ if gerr != nil && !isNotFound(gerr) {
+ return nil, errcode.DatabaseError
+ }
+ if existing != nil && existing.Status != RefundStatusFailed {
+ return nil, errcode.RefundNotAllowed
+ }
+
+ refundNo := genRefundNo()
+ applyTime := nowUnix()
+
+ if existing != nil {
+ // 失败重试:复用旧记录,更新退款单号与状态。
+ if err := s.refundDao.RefundFlowLogUpdateStatus(ctx, existing.RefundNo, map[string]any{
+ "refund_no": refundNo,
+ "refund_amount": order.PayAmount,
+ "refund_reason": request.Reason,
+ "status": RefundStatusPending,
+ "apply_time": applyTime,
+ "fail_reason": "",
+ }); err != nil {
+ return nil, errcode.RefundApplyFailed
+ }
+ } else {
+ // 新建退款流水(order_no 唯一索引兜底并发,冲突即已有申请)。
+ flow := &mysqlmodel.RefundFlowLog{
+ OrderID: order.ID,
+ OrderNo: order.OrderNo,
+ UserID: userID,
+ RefundNo: refundNo,
+ RefundAmount: order.PayAmount,
+ RefundReason: request.Reason,
+ Status: RefundStatusPending,
+ ApplyTime: applyTime,
+ }
+ if err := s.refundDao.RefundFlowLogCreate(ctx, flow); err != nil {
+ if isDuplicateKey(err) {
+ return nil, errcode.RefundNotAllowed
+ }
+ return nil, errcode.RefundApplyFailed
+ }
+ }
+
+ // 人工审核:不再自动调用微信退款,仅创建待审核退款申请,避免自动退款造成权益未回退的资损。
+ // 运营审核通过后在微信后台手动发起退款,微信退款回调 RefundNotify 会更新订单与流水状态。
+ return &dto.RefundResponse{
+ RefundNo: refundNo,
+ RefundAmount: order.PayAmount,
+ Status: RefundStatusPending,
+ ApplyTime: applyTime,
+ }, nil
+}
+
+// RefundNotify 处理微信退款结果回调(供回调 handler 调用):验签解密 + 幂等 + 更新状态。
+func (s *OrderService) RefundNotify(ctx context.Context, request *http.Request) *errcode.Error {
+ ctx, span := global.Tracer.Start(ctx, "svc.OrderRefundNotify")
+ defer span.End()
+
+ data, err := wechat.VerifyAndDecryptRefundNotify(ctx, request)
+ if err != nil {
+ return errcode.RPCError
+ }
+
+ lockKey := fmtLockKey(redismodel.KeyLockRefundCallback, data.OutRefundNo)
+ lockVal := genUUID()
+ if ok, _ := redisdao.TryLock(ctx, lockKey, lockVal, 30*time.Second); !ok {
+ return nil
+ }
+ defer func() { _ = redisdao.Unlock(ctx, lockKey, lockVal) }()
+
+ flow, ferr := s.refundDao.RefundFlowLogGetByRefundNo(ctx, data.OutRefundNo)
+ if ferr != nil {
+ if isNotFound(ferr) {
+ return nil
+ }
+ return errcode.DatabaseError
+ }
+ // 已终态则幂等返回。
+ if flow.Status == RefundStatusSuccess || flow.Status == RefundStatusFailed {
+ return nil
+ }
+
+ // 校验回调退款金额与流水一致,防止金额被篡改。
+ if data.Amount.Refund != flow.RefundAmount {
+ slog.ErrorContext(ctx, "refund notify: amount mismatch",
+ "out_refund_no", data.OutRefundNo,
+ "notify_amount", data.Amount.Refund,
+ "flow_amount", flow.RefundAmount,
+ )
+ return nil
+ }
+
+ if data.RefundStatus == "SUCCESS" {
+ // 条件流转,防止并发/乱序回调覆盖状态。
+ ok, _ := s.refundDao.RefundFlowLogUpdateStatusByFrom(ctx, data.OutRefundNo, flow.Status, map[string]any{
+ "status": RefundStatusSuccess,
+ "success_time": nowUnix(),
+ "callback_raw": data.Raw,
+ })
+ if !ok {
+ return nil // 已被并发回调处理。
+ }
+ order, gerr := s.orderDao.OrderGetByOrderNo(ctx, data.OutTradeNo)
+ if gerr == nil {
+ s.settleRefundSuccess(ctx, order, flow.RefundAmount)
+ }
+ } else {
+ _, _ = s.refundDao.RefundFlowLogUpdateStatusByFrom(ctx, data.OutRefundNo, flow.Status, map[string]any{
+ "status": RefundStatusFailed,
+ "fail_reason": "微信退款未成功:" + data.RefundStatus,
+ "callback_raw": data.Raw,
+ })
+ }
+
+ return nil
+}
+
+// autoRefundCancelledOrder 订单已取消但用户已付款:生成待审核退款单,交由管理员在后台手动退款。
+// 不再自动调用微信退款(所有退款均由管理员操作),避免「自动退款失败后流水卡死无重试」的资损风险。
+func (s *OrderService) autoRefundCancelledOrder(ctx context.Context, order *mysqlmodel.Order, data *wechat.PayNotifyData) {
+ // 幂等:已有退款流水则跳过,避免重复生成待审核退款单。
+ if existing, err := s.refundDao.RefundFlowLogGetByOrderNo(ctx, order.OrderNo); err == nil && existing != nil {
+ return
+ }
+
+ // 补记支付信息(订单已取消,此前未记录),便于管理员审核退款时核对。
+ _ = s.orderDao.OrderUpdateByOrderNo(ctx, order.OrderNo, map[string]any{
+ "pay_time": nowUnix(),
+ "transaction_id": data.TransactionID,
+ "callback_raw": data.Raw,
+ })
+
+ refundNo := genRefundNo()
+ flow := &mysqlmodel.RefundFlowLog{
+ OrderID: order.ID,
+ OrderNo: order.OrderNo,
+ UserID: order.UserID,
+ RefundNo: refundNo,
+ RefundAmount: order.PayAmount,
+ RefundReason: "订单已取消但已付款,待管理员退款",
+ Status: RefundStatusPending,
+ ApplyTime: nowUnix(),
+ }
+ if err := s.refundDao.RefundFlowLogCreate(ctx, flow); err != nil {
+ if isDuplicateKey(err) {
+ return
+ }
+ slog.ErrorContext(ctx, "auto refund: create refund flow failed", "order_no", order.OrderNo, "error", err)
+ return
+ }
+ slog.InfoContext(ctx, "cancelled order refund flow created, waiting manual review",
+ "order_no", order.OrderNo, "refund_no", refundNo)
+}
+
+// ---- 内部辅助 ----
+
+// getOwnedOrder 查询订单并校验归属。
+func (s *OrderService) getOwnedOrder(ctx context.Context, orderNo string, userID int64) (*mysqlmodel.Order, *errcode.Error) {
+ order, err := s.orderDao.OrderGetByOrderNo(ctx, orderNo)
+ if err != nil {
+ if isNotFound(err) {
+ return nil, errcode.OrderNotFound
+ }
+ return nil, errcode.DatabaseError
+ }
+ if order.UserID != userID {
+ return nil, errcode.Forbidden
+ }
+ return order, nil
+}
+
+// finalizePaidOrder 记录支付信息、发放权益并发送通知(支付成功或过期补发货后调用)。
+func (s *OrderService) finalizePaidOrder(ctx context.Context, order *mysqlmodel.Order, data *wechat.PayNotifyData) {
+ _ = s.orderDao.OrderUpdateByOrderNo(ctx, order.OrderNo, map[string]any{
+ "pay_time": nowUnix(),
+ "transaction_id": data.TransactionID,
+ "callback_raw": data.Raw,
+ })
+
+ s.grantOrderBenefits(ctx, order)
+
+ sendNotification(ctx, order.UserID, 0, NotificationTypeOrder, "订单支付成功", "你购买的「"+order.GoodsName+"」已到账")
+}
+
+// grantOrderBenefits 支付成功后幂等发放会员天数与各类额度权益(使用下单时的订单快照,避免商品变更导致错发)。
+func (s *OrderService) grantOrderBenefits(ctx context.Context, order *mysqlmodel.Order) {
+ benefits := []struct {
+ benefitType int8
+ amount int64
+ }{
+ {BenefitTypeVIP, order.MemberDays},
+ {BenefitTypeCos, order.CosQuota},
+ {BenefitTypeAI, order.AIRecommendQuota},
+ {BenefitTypeClosetShare, order.ClosetShareQuota},
+ {BenefitTypeOutfitShare, order.OutfitShareQuota},
+ }
+ for _, b := range benefits {
+ if b.amount > 0 {
+ s.grantBenefitIdempotent(ctx, order, b.benefitType, b.amount)
+ }
+ }
+}
+
+// grantBenefitIdempotent 幂等发放单个权益:先落补偿任务凭证(唯一索引 order_no+benefit_type),
+// 凭证创建成功才执行发放;凭证冲突说明已有记录,按状态跳过或交定时任务处理。
+func (s *OrderService) grantBenefitIdempotent(ctx context.Context, order *mysqlmodel.Order, benefitType int8, amount int64) {
+ taskDao := mysqldao.NewBenefitGrantTaskDao()
+ task := &mysqlmodel.BenefitGrantTask{
+ UserID: order.UserID,
+ OrderNo: order.OrderNo,
+ GoodsID: order.GoodsID,
+ BenefitType: benefitType,
+ Amount: amount,
+ Status: BenefitTaskPending,
+ MaxRetry: benefitMaxRetry,
+ }
+ if err := taskDao.BenefitGrantTaskCreate(ctx, task); err != nil {
+ // 非唯一冲突(DB 临时故障等):记录错误返回,交由补偿/人工,不能静默丢权益。
+ if !isDuplicateKey(err) {
+ slog.ErrorContext(ctx, "create benefit grant task failed",
+ "order_no", order.OrderNo, "benefit_type", benefitType, "error", err)
+ return
+ }
+ // 唯一冲突:已有记录。
+ existing, gerr := taskDao.BenefitGrantTaskGetByOrderNoAndType(ctx, order.OrderNo, benefitType)
+ if gerr != nil {
+ slog.ErrorContext(ctx, "get benefit grant task failed",
+ "order_no", order.OrderNo, "benefit_type", benefitType, "error", gerr)
+ return
+ }
+ if existing.Status == BenefitTaskSuccess {
+ return // 已发放成功。
+ }
+ if existing.Status == BenefitTaskFailed {
+ // 曾失败过:冷却期内不重置(防微信回调重试风暴无限重置任务绕过重试上限),
+ // 冷却期外重置为待发放并尝试发放(发放本身有幂等兜底,重置失败仅告警)。
+ if nowUnix()-existing.UpdatedAt < benefitRetryCooldownSeconds {
+ slog.WarnContext(ctx, "benefit grant task in cooldown, skip retry",
+ "order_no", order.OrderNo, "benefit_type", benefitType, "task_id", existing.ID)
+ return
+ }
+ if rerr := taskDao.BenefitGrantTaskReset(ctx, existing.ID); rerr != nil {
+ slog.WarnContext(ctx, "reset benefit grant task failed",
+ "order_no", order.OrderNo, "benefit_type", benefitType, "error", rerr)
+ return
+ }
+ s.executeGrant(ctx, existing)
+ }
+ return // pending 交由定时任务处理。
+ }
+
+ // 凭证创建成功,执行发放(claim 防并发)。
+ s.executeGrant(ctx, task)
+}
+
+// executeGrant 认领并发放单个权益任务(claim 保证同一任务仅发放一次,防并发重复发放)。
+func (s *OrderService) executeGrant(ctx context.Context, task *mysqlmodel.BenefitGrantTask) {
+ taskDao := mysqldao.NewBenefitGrantTaskDao()
+ claimed, err := taskDao.BenefitGrantTaskClaim(ctx, task.ID)
+ if err != nil || !claimed {
+ return // 已被其他实例处理。
+ }
+ if e := s.doGrantBenefit(ctx, task); e != nil {
+ // 发放失败:回退为待发放并记录错误,交由补偿任务重试。
+ _ = taskDao.BenefitGrantTaskUpdateByID(ctx, task.ID, map[string]any{
+ "status": BenefitTaskPending,
+ "retry_count": gorm.Expr("retry_count + 1"),
+ "last_error": e.Error(),
+ })
+ slog.ErrorContext(ctx, "grant benefit failed",
+ "order_no", task.OrderNo, "benefit_type", task.BenefitType, "error", e)
+ return
+ }
+ _ = taskDao.BenefitGrantTaskUpdateByID(ctx, task.ID, map[string]any{"status": BenefitTaskSuccess})
+}
+
+// doGrantBenefit 根据补偿任务执行实际权益发放:VIP 实际发放,额度类写入有有效期凭证。
+func (s *OrderService) doGrantBenefit(ctx context.Context, task *mysqlmodel.BenefitGrantTask) *errcode.Error {
+ switch task.BenefitType {
+ case BenefitTypeVIP:
+ if err := s.grantVIP(ctx, task.UserID, task.Amount, task.OrderNo); err != nil {
+ return errcode.DatabaseError
+ }
+ return nil
+ case BenefitTypeCos:
+ return s.grantQuotaGrant(ctx, task, QuotaTypeCos)
+ case BenefitTypeAI:
+ return s.grantQuotaGrant(ctx, task, QuotaTypeAI)
+ case BenefitTypeClosetShare:
+ return s.grantQuotaGrant(ctx, task, QuotaTypeClosetShare)
+ case BenefitTypeOutfitShare:
+ return s.grantQuotaGrant(ctx, task, QuotaTypeOutfitShare)
+ default:
+ return errcode.DatabaseError
+ }
+}
+
+// grantQuotaGrant 发放额度凭证(替代原 grantQuota 累加 user_quotas.total)。
+// 会员包(goods_type=1)凭证跟随 VIP(expire_time=0);加量包按商品 valid_days 设置独立到期时间。
+// ref_id 复用订单号,配合 (ref_id, quota_type) 唯一索引实现幂等。
+func (s *OrderService) grantQuotaGrant(ctx context.Context, task *mysqlmodel.BenefitGrantTask, quotaType int8) *errcode.Error {
+ source := QuotaGrantSourceMember
+ expireTime := int64(0) // 0 表示跟随 VIP
+
+ if goods, err := s.goodsDao.GoodsGetByID(ctx, task.GoodsID); err == nil && goods.GoodsType != GoodsTypeMember {
+ // 加量包:独立有效期,从发放时刻起算 valid_days 天;未配置则兜底 30 天。
+ source = QuotaGrantSourceAddon
+ days := goods.ValidDays
+ if days <= 0 {
+ days = 30
+ }
+ expireTime = nowUnix() + days*86400
+ }
+
+ grant := &mysqlmodel.QuotaGrant{
+ UserID: task.UserID,
+ QuotaType: quotaType,
+ Amount: task.Amount,
+ Used: 0,
+ Source: source,
+ ExpireTime: expireTime,
+ RefID: task.OrderNo,
+ }
+ if err := mysqldao.NewQuotaGrantDao().QuotaGrantCreate(ctx, grant); err != nil {
+ if isDuplicateKey(err) {
+ return nil // 已发放,幂等。
+ }
+ return errcode.DatabaseError
+ }
+ return nil
+}
+
+// grantVIP 开通/续费会员,并写 VIP 流水(事务内幂等:累加与流水同事务,流水唯一索引兜底)。
+func (s *OrderService) grantVIP(ctx context.Context, userID, memberDays int64, orderNo string) error {
+ user, err := s.userDao.UserGetByID(ctx, userID)
+ if err != nil {
+ return err
+ }
+
+ now := nowUnix()
+
+ newVIPType := VIPTypeMonthly
+ if memberDays >= 365 {
+ newVIPType = VIPTypeYearly
+ }
+
+ changeType := VIPChangeTypeOpen
+ if user.VIPType != VIPTypeNone && user.VIPExpire > now {
+ changeType = VIPChangeTypeRenew
+ }
+
+ flow := &mysqlmodel.VIPFlowLog{
+ UserID: userID,
+ ChangeType: changeType,
+ OldVIPType: user.VIPType,
+ NewVIPType: newVIPType,
+ OldVIPExpire: user.VIPExpire,
+ OrderNo: orderNo,
+ Source: VIPSourcePersonalPay,
+ Reason: "购买会员",
+ }
+ if err := s.userDao.VIPGrantIdempotent(ctx, userID, now, memberDays, newVIPType, flow); err != nil {
+ if isDuplicateKey(err) {
+ return nil // 已发放,幂等。
+ }
+ return err
+ }
+ // 用户信息已变更,失效缓存。
+ _ = redisdao.UserInfoDel(ctx, userID)
+ return nil
+}
+
+// settleRefundSuccess 退款成功后将订单置为已退款、回退已发放权益并记录退款信息,发送通知。
+// 兼容两种退款来源:已支付订单(Paid->Refunded,需回收已发放权益)与已取消但已付款订单
+// (Cancelled->Refunded,其权益从未发放,跳过回收避免误扣其它来源的会员天数/额度凭证)。
+func (s *OrderService) settleRefundSuccess(ctx context.Context, order *mysqlmodel.Order, refundAmount int64) {
+ from := order.OrderStatus
+ if from != OrderStatusPaid && from != OrderStatusCancelled {
+ return
+ }
+ ok, _ := s.orderDao.OrderUpdateStatusByOrderNo(ctx, order.OrderNo, from, OrderStatusRefunded)
+ if !ok {
+ return
+ }
+ _ = s.orderDao.OrderUpdateByOrderNo(ctx, order.OrderNo, map[string]any{
+ "refund_time": nowUnix(),
+ "refund_amount": refundAmount,
+ })
+ // 仅已支付订单回退已发放权益;已取消订单权益从未发放,无需回收。
+ if from == OrderStatusPaid {
+ s.revokeOrderBenefits(ctx, order)
+ }
+ sendNotification(ctx, order.UserID, 0, NotificationTypeOrder, "退款成功", "订单「"+order.GoodsName+"」已退款")
+}
+
+// revokeOrderBenefits 退款成功后回退该订单发放的会员天数并失效赠送额度凭证。
+// 会员天数走 revokeVIP;额度类通过失效凭证回收(未用部分回收,已用部分不追回,凭证软删除后不计入有效额度)。
+func (s *OrderService) revokeOrderBenefits(ctx context.Context, order *mysqlmodel.Order) {
+ if order.MemberDays > 0 {
+ s.revokeVIP(ctx, order.UserID, order.MemberDays, order.OrderNo)
+ }
+ if err := mysqldao.NewQuotaGrantDao().QuotaGrantInvalidateByRefID(ctx, order.OrderNo); err != nil {
+ slog.WarnContext(ctx, "invalidate quota grants failed", "order_no", order.OrderNo, "error", err)
+ }
+}
+
+// revokeVIP 幂等回退会员天数(退款回收)。
+func (s *OrderService) revokeVIP(ctx context.Context, userID, memberDays int64, orderNo string) {
+ user, err := s.userDao.UserGetByID(ctx, userID)
+ if err != nil {
+ slog.ErrorContext(ctx, "revoke vip: get user failed", "user_id", userID, "error", err)
+ return
+ }
+ flow := &mysqlmodel.VIPFlowLog{
+ UserID: userID,
+ ChangeType: VIPChangeTypeRefundRevoke,
+ OldVIPType: user.VIPType,
+ NewVIPType: user.VIPType,
+ OldVIPExpire: user.VIPExpire,
+ OrderNo: orderNo,
+ Source: VIPSourceSystem,
+ Reason: "退款回收会员",
+ }
+ if err := s.userDao.VIPRevokeIdempotent(ctx, userID, nowUnix(), memberDays, flow); err != nil {
+ if !isDuplicateKey(err) {
+ slog.ErrorContext(ctx, "revoke vip failed", "order_no", orderNo, "error", err)
+ }
+ }
+ // 会员信息已变更,失效缓存。
+ _ = redisdao.UserInfoDel(ctx, userID)
+}
+
+// buildOrderResponse 将订单模型映射为响应。
+func buildOrderResponse(o *mysqlmodel.Order) dto.OrderResponse {
+ return dto.OrderResponse{
+ OrderNo: o.OrderNo,
+ GoodsName: o.GoodsName,
+ UnitPrice: o.UnitPrice,
+ PayAmount: o.PayAmount,
+ PayChannel: o.PayChannel,
+ OrderStatus: o.OrderStatus,
+ MemberDays: o.MemberDays,
+ ExpireTime: o.ExpireTime,
+ PayTime: o.PayTime,
+ RefundTime: o.RefundTime,
+ RefundAmount: o.RefundAmount,
+ CreatedAt: o.CreatedAt,
+ }
+}
+
+// genOrderNo 生成唯一商户订单号。
+func genOrderNo() string {
+ return "OD" + time.Now().Format("20060102150405") + strings.ReplaceAll(genUUID(), "-", "")[:16]
+}
+
+// genRefundNo 生成唯一商户退款单号。
+func genRefundNo() string {
+ return "RF" + time.Now().Format("20060102150405") + strings.ReplaceAll(genUUID(), "-", "")[:16]
+}
+
+// fmtLockKey 拼接分布式锁 key(key 模板含单个 %s 占位符)。
+func fmtLockKey(tmpl, arg string) string {
+ return strings.Replace(tmpl, "%s", arg, 1)
+}
diff --git a/internal/service/order_expire.go b/internal/service/order_expire.go
new file mode 100644
index 0000000..71cefd7
--- /dev/null
+++ b/internal/service/order_expire.go
@@ -0,0 +1,46 @@
+package service
+
+import (
+ "context"
+ "log/slog"
+ "time"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao"
+)
+
+// StartOrderExpireScheduler 启动订单超时过期定时任务:每分钟将超时未支付订单批量置为已过期。
+func StartOrderExpireScheduler() {
+ go func() {
+ slog.Info("order expire scheduler started")
+ for {
+ func() {
+ defer func() {
+ if r := recover(); r != nil {
+ slog.Error("order expire scheduler panic", "recover", r)
+ }
+ }()
+ runOrderExpire()
+ }()
+ time.Sleep(time.Minute)
+ }
+ }()
+}
+
+// runOrderExpire 扫描并过期超时待支付订单。条件更新天然幂等,多实例并发无害,无需分布式锁。
+func runOrderExpire() {
+ ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
+ defer cancel()
+
+ ctx, span := global.Tracer.Start(ctx, "scheduler.OrderExpire")
+ defer span.End()
+
+ affected, err := mysqldao.NewOrderDao().OrderExpireByTime(ctx, nowUnix())
+ if err != nil {
+ slog.ErrorContext(ctx, "expire orders failed", "error", err)
+ return
+ }
+ if affected > 0 {
+ slog.InfoContext(ctx, "expired pending orders", "count", affected)
+ }
+}
diff --git a/internal/service/outfit.go b/internal/service/outfit.go
new file mode 100644
index 0000000..945f15f
--- /dev/null
+++ b/internal/service/outfit.go
@@ -0,0 +1,424 @@
+package service
+
+import (
+ "context"
+ "log/slog"
+ "time"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/upload"
+ "gorm.io/gorm"
+)
+
+// OutfitService 穿搭业务服务。
+type OutfitService struct {
+ outfitDao *mysqldao.OutfitDao
+ outfitItemDao *mysqldao.OutfitItemDao
+ clothesDao *mysqldao.ClothesDao
+ auditLogDao *mysqldao.OutfitAuditLogDao
+}
+
+// NewOutfitService 创建穿搭服务。
+func NewOutfitService() *OutfitService {
+ return &OutfitService{
+ outfitDao: mysqldao.NewOutfitDao(),
+ outfitItemDao: mysqldao.NewOutfitItemDao(),
+ clothesDao: mysqldao.NewClothesDao(),
+ auditLogDao: mysqldao.NewOutfitAuditLogDao(),
+ }
+}
+
+// Create 创建穿搭。
+func (s *OutfitService) Create(ctx context.Context, userID int64, request *dto.OutfitCreateRequest) (*dto.OutfitCreateResponse, *errcode.Error) {
+ ctx, span := global.Tracer.Start(ctx, "svc.OutfitCreate")
+ defer span.End()
+
+ clothesIDs, items, cerr := s.resolveOwnedClothes(ctx, userID, request.Items)
+ if cerr != nil {
+ return nil, cerr
+ }
+
+ source := request.Source
+ if source != OutfitSourceUser && source != OutfitSourceAI {
+ source = OutfitSourceUser
+ }
+
+ outfit := &mysqlmodel.Outfit{
+ OutfitUUID: genUUID(),
+ UserID: userID,
+ OutfitName: request.OutfitName,
+ OutfitDesc: request.OutfitDesc,
+ Scene: request.Scene,
+ Source: source,
+ IsPublic: OutfitPrivate,
+ AuditStatus: OutfitAuditPending,
+ Status: OutfitStatusNormal,
+ }
+ if err := s.outfitDao.OutfitCreate(ctx, outfit); err != nil {
+ return nil, errcode.OutfitCreateFailed
+ }
+
+ if err := s.createItems(ctx, outfit.ID, clothesIDs); err != nil {
+ return nil, errcode.OutfitCreateFailed
+ }
+
+ response := buildOutfitCreateResponse(outfit, items)
+ return &response, nil
+}
+
+// GetByUUID 获取穿搭详情。私有穿搭仅本人可见。
+func (s *OutfitService) GetByUUID(ctx context.Context, userID int64, request *dto.OutfitGetByUUIDRequest) (*dto.OutfitGetByUUIDResponse, *errcode.Error) {
+ ctx, span := global.Tracer.Start(ctx, "svc.OutfitGetByUUID")
+ defer span.End()
+
+ outfit, err := s.outfitDao.OutfitGetByUUID(ctx, request.OutfitUUID)
+ if err != nil {
+ if isNotFound(err) {
+ return nil, errcode.OutfitNotFound
+ }
+ return nil, errcode.DatabaseError
+ }
+
+ // 可见性:本人,或已公开且审核通过且正常。
+ if outfit.UserID != userID && !isOutfitPublicVisible(outfit) {
+ return nil, errcode.OutfitNoPermission
+ }
+
+ items, lerr := s.itemResponses(ctx, outfit.ID)
+ if lerr != nil {
+ return nil, errcode.DatabaseError
+ }
+
+ return &dto.OutfitGetByUUIDResponse{
+ OutfitCreateResponse: buildOutfitCreateResponse(outfit, items),
+ }, nil
+}
+
+// UpdateByUUID 修改穿搭基础信息与明细。
+func (s *OutfitService) UpdateByUUID(ctx context.Context, userID int64, request *dto.OutfitUpdateByUUIDRequest) (*dto.OutfitUpdateByUUIDResponse, *errcode.Error) {
+ ctx, span := global.Tracer.Start(ctx, "svc.OutfitUpdateByUUID")
+ defer span.End()
+
+ outfit, cerr := s.getOwnedOutfit(ctx, request.OutfitUUID, userID)
+ if cerr != nil {
+ return nil, cerr
+ }
+
+ clothesIDs, _, verr := s.resolveOwnedClothes(ctx, userID, request.Items)
+ if verr != nil {
+ return nil, verr
+ }
+
+ updates := map[string]any{}
+ setIfNotEmpty(updates, "outfit_name", request.OutfitName)
+ setIfNotEmpty(updates, "outfit_desc", request.OutfitDesc)
+ setIfNotEmpty(updates, "scene", request.Scene)
+ if len(updates) > 0 {
+ if err := s.outfitDao.OutfitUpdateByUUID(ctx, request.OutfitUUID, updates); err != nil {
+ return nil, errcode.OutfitUpdateFailed
+ }
+ }
+
+ // 重建明细(事务内先删后建,避免中途失败留下空明细)。
+ rebuildErr := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ if err := tx.Where("outfit_id = ?", outfit.ID).Delete(&mysqlmodel.OutfitItem{}).Error; err != nil {
+ return err
+ }
+ items := make([]mysqlmodel.OutfitItem, 0, len(clothesIDs))
+ for _, cid := range clothesIDs {
+ items = append(items, mysqlmodel.OutfitItem{OutfitID: outfit.ID, ClothesID: cid})
+ }
+ if len(items) == 0 {
+ return nil
+ }
+ return tx.Create(&items).Error
+ })
+ if rebuildErr != nil {
+ return nil, errcode.OutfitUpdateFailed
+ }
+
+ return &dto.OutfitUpdateByUUIDResponse{}, nil
+}
+
+// List 分页查询我的穿搭。
+func (s *OutfitService) List(ctx context.Context, userID int64, request *dto.OutfitListRequest) (*dto.OutfitListResponse, *errcode.Error) {
+ ctx, span := global.Tracer.Start(ctx, "svc.OutfitList")
+ defer span.End()
+
+ page, pageSize := normalizePage(request.Page, request.PageSize)
+
+ list, total, err := s.outfitDao.OutfitListByUserID(ctx, userID, request.IsPublic, request.Source, page, pageSize)
+ if err != nil {
+ return nil, errcode.DatabaseError
+ }
+
+ items, berr := s.buildOutfitListResponses(ctx, list)
+ if berr != nil {
+ return nil, errcode.DatabaseError
+ }
+
+ return &dto.OutfitListResponse{
+ PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize},
+ List: items,
+ }, nil
+}
+
+// Delete 删除穿搭(软删除穿搭 + 清理明细)。
+func (s *OutfitService) Delete(ctx context.Context, userID int64, request *dto.OutfitDeleteRequest) (*dto.OutfitDeleteResponse, *errcode.Error) {
+ ctx, span := global.Tracer.Start(ctx, "svc.OutfitDelete")
+ defer span.End()
+
+ outfit, cerr := s.getOwnedOutfit(ctx, request.OutfitUUID, userID)
+ if cerr != nil {
+ return nil, cerr
+ }
+ if err := s.outfitDao.OutfitDeleteByUUID(ctx, request.OutfitUUID); err != nil {
+ return nil, errcode.OutfitDeleteFailed
+ }
+ _ = s.outfitItemDao.OutfitItemDeleteByOutfitID(ctx, outfit.ID)
+ return &dto.OutfitDeleteResponse{}, nil
+}
+
+// Public 设置穿搭公开状态。公开时自动审核通过并写审核日志、消耗社区分享额度。
+func (s *OutfitService) Public(ctx context.Context, userID int64, request *dto.OutfitPublicRequest) (*dto.OutfitPublicResponse, *errcode.Error) {
+ ctx, span := global.Tracer.Start(ctx, "svc.OutfitPublic")
+ defer span.End()
+
+ outfit, cerr := s.getOwnedOutfit(ctx, request.OutfitUUID, userID)
+ if cerr != nil {
+ return nil, cerr
+ }
+
+ // 公开/取消公开流程加锁串行化:消除并发下「A 扣额失败回退公开态、B 幂等返回成功但最终未公开」
+ // 的语义冲突;锁失败返回请求过于频繁,Redis 异常时降级放行(与项目 fail-open 策略一致)。
+ lockKey := fmtLockKey(redismodel.KeyLockOutfitPublic, request.OutfitUUID)
+ lockVal := genUUID()
+ if ok, lerr := redisdao.TryLock(ctx, lockKey, lockVal, 10*time.Second); lerr != nil {
+ slog.WarnContext(ctx, "acquire outfit public lock failed, proceed without lock",
+ "outfit_uuid", request.OutfitUUID, "error", lerr)
+ } else if !ok {
+ return nil, errcode.TooManyRequests
+ } else {
+ defer func() { _ = redisdao.Unlock(ctx, lockKey, lockVal) }()
+ }
+
+ // 取消公开:直接置为不公开。
+ if request.IsPublic == OutfitPrivate {
+ if outfit.IsPublic == OutfitPrivate {
+ return &dto.OutfitPublicResponse{}, nil
+ }
+ if err := s.outfitDao.OutfitUpdateIsPublic(ctx, request.OutfitUUID, OutfitPrivate); err != nil {
+ return nil, errcode.OutfitUpdateFailed
+ }
+ return &dto.OutfitPublicResponse{}, nil
+ }
+
+ // 已公开则无需重复处理。
+ if outfit.IsPublic == OutfitPublic {
+ return &dto.OutfitPublicResponse{}, nil
+ }
+
+ // 首次公开:先条件更新(私密 -> 公开+审核通过),成功才消耗额度,防并发重复扣减。
+ ok, perr := s.outfitDao.OutfitPublish(ctx, request.OutfitUUID)
+ if perr != nil {
+ return nil, errcode.OutfitUpdateFailed
+ }
+ if !ok {
+ // 已被并发公开,幂等返回。
+ return &dto.OutfitPublicResponse{}, nil
+ }
+
+ if qerr := consumeDailyQuota(ctx, userID, QuotaTypeOutfitShare, 1, "穿搭社区分享消耗"); qerr != nil {
+ // 扣额度失败,回退公开状态。
+ _ = s.outfitDao.OutfitUpdateIsPublic(ctx, request.OutfitUUID, OutfitPrivate)
+ return nil, qerr
+ }
+
+ // 写审核日志(自动通过,best-effort:失败仅告警不阻断公开)。
+ if err := s.auditLogDao.OutfitAuditLogCreate(ctx, &mysqlmodel.OutfitAuditLog{
+ TargetID: outfit.ID,
+ AuditResult: AuditResultPass,
+ AuditTime: nowUnix(),
+ }); err != nil {
+ slog.WarnContext(ctx, "write outfit audit log failed", "outfit_id", outfit.ID, "error", err)
+ }
+
+ // 记录当日社区分享计数(统计)。
+ recordDailyUsage(ctx, userID, QuotaTypeOutfitShare)
+
+ return &dto.OutfitPublicResponse{}, nil
+}
+
+// ---- 内部辅助 ----
+
+// getOwnedOutfit 查询穿搭并校验归属。
+func (s *OutfitService) getOwnedOutfit(ctx context.Context, outfitUUID string, userID int64) (*mysqlmodel.Outfit, *errcode.Error) {
+ outfit, err := s.outfitDao.OutfitGetByUUID(ctx, outfitUUID)
+ if err != nil {
+ if isNotFound(err) {
+ return nil, errcode.OutfitNotFound
+ }
+ return nil, errcode.DatabaseError
+ }
+ if outfit.UserID != userID {
+ return nil, errcode.OutfitNoPermission
+ }
+ return outfit, nil
+}
+
+// isOutfitPublicVisible 判断穿搭是否对他人公开可见(公开 + 审核通过 + 正常)。
+func isOutfitPublicVisible(o *mysqlmodel.Outfit) bool {
+ return o.IsPublic == OutfitPublic && o.AuditStatus == OutfitAuditPass && o.Status == OutfitStatusNormal
+}
+
+// resolveOwnedClothes 校验请求中的衣物均属于该用户,返回有序去重的 clothesID 与 uuid 列表。
+func (s *OutfitService) resolveOwnedClothes(ctx context.Context, userID int64, items []dto.OutfitItemRequest) ([]int64, []dto.OutfitItemResponse, *errcode.Error) {
+ uuids := make([]string, 0, len(items))
+ seen := make(map[string]struct{}, len(items))
+ for _, it := range items {
+ if _, ok := seen[it.ClothesUUID]; ok {
+ continue
+ }
+ seen[it.ClothesUUID] = struct{}{}
+ uuids = append(uuids, it.ClothesUUID)
+ }
+
+ clothesList, err := s.clothesDao.ClothesGetByUUIDs(ctx, uuids)
+ if err != nil {
+ return nil, nil, errcode.DatabaseError
+ }
+ byUUID := make(map[string]mysqlmodel.Clothes, len(clothesList))
+ for _, c := range clothesList {
+ byUUID[c.ClothesUUID] = c
+ }
+
+ ids := make([]int64, 0, len(uuids))
+ respItems := make([]dto.OutfitItemResponse, 0, len(uuids))
+ for _, u := range uuids {
+ c, ok := byUUID[u]
+ if !ok {
+ return nil, nil, errcode.ClothesNotFound
+ }
+ if c.UserID != userID {
+ return nil, nil, errcode.ClothesNoPermission
+ }
+ ids = append(ids, c.ID)
+ respItems = append(respItems, buildOutfitItemResponse(&c))
+ }
+ return ids, respItems, nil
+}
+
+// createItems 批量创建穿搭明细。
+func (s *OutfitService) createItems(ctx context.Context, outfitID int64, clothesIDs []int64) error {
+ items := make([]mysqlmodel.OutfitItem, 0, len(clothesIDs))
+ for _, cid := range clothesIDs {
+ items = append(items, mysqlmodel.OutfitItem{OutfitID: outfitID, ClothesID: cid})
+ }
+ return s.outfitItemDao.OutfitItemCreateBatch(ctx, items)
+}
+
+// itemResponses 获取某穿搭的衣物明细(含图片 URL)。
+func (s *OutfitService) itemResponses(ctx context.Context, outfitID int64) ([]dto.OutfitItemResponse, error) {
+ items, err := s.outfitItemDao.OutfitItemListByOutfitID(ctx, outfitID)
+ if err != nil {
+ return nil, err
+ }
+ ids := make([]int64, 0, len(items))
+ for _, it := range items {
+ ids = append(ids, it.ClothesID)
+ }
+ clothesList, err := s.clothesDao.ClothesGetByIDs(ctx, ids)
+ if err != nil {
+ return nil, err
+ }
+ clothesByID := make(map[int64]mysqlmodel.Clothes, len(clothesList))
+ for _, c := range clothesList {
+ clothesByID[c.ID] = c
+ }
+ resps := make([]dto.OutfitItemResponse, 0, len(items))
+ for _, it := range items {
+ if c, ok := clothesByID[it.ClothesID]; ok {
+ resps = append(resps, buildOutfitItemResponse(&c))
+ }
+ }
+ return resps, nil
+}
+
+// buildOutfitListResponses 批量组装穿搭列表(一次性加载全部明细与衣物,避免 N+1)。
+func (s *OutfitService) buildOutfitListResponses(ctx context.Context, list []mysqlmodel.Outfit) ([]dto.OutfitCreateResponse, error) {
+ if len(list) == 0 {
+ return []dto.OutfitCreateResponse{}, nil
+ }
+
+ outfitIDs := make([]int64, 0, len(list))
+ for _, o := range list {
+ outfitIDs = append(outfitIDs, o.ID)
+ }
+
+ allItems, err := s.outfitItemDao.OutfitItemListByOutfitIDs(ctx, outfitIDs)
+ if err != nil {
+ return nil, err
+ }
+
+ clothesIDSet := make(map[int64]struct{}, len(allItems))
+ itemsByOutfit := make(map[int64][]int64, len(list))
+ for _, it := range allItems {
+ itemsByOutfit[it.OutfitID] = append(itemsByOutfit[it.OutfitID], it.ClothesID)
+ clothesIDSet[it.ClothesID] = struct{}{}
+ }
+
+ clothesIDs := make([]int64, 0, len(clothesIDSet))
+ for id := range clothesIDSet {
+ clothesIDs = append(clothesIDs, id)
+ }
+ clothesList, err := s.clothesDao.ClothesGetByIDs(ctx, clothesIDs)
+ if err != nil {
+ return nil, err
+ }
+ clothesByID := make(map[int64]mysqlmodel.Clothes, len(clothesList))
+ for _, c := range clothesList {
+ clothesByID[c.ID] = c
+ }
+
+ resps := make([]dto.OutfitCreateResponse, 0, len(list))
+ for i := range list {
+ o := &list[i]
+ items := make([]dto.OutfitItemResponse, 0, len(itemsByOutfit[o.ID]))
+ for _, cid := range itemsByOutfit[o.ID] {
+ if c, ok := clothesByID[cid]; ok {
+ items = append(items, buildOutfitItemResponse(&c))
+ }
+ }
+ resps = append(resps, buildOutfitCreateResponse(o, items))
+ }
+ return resps, nil
+}
+
+// buildOutfitItemResponse 组装穿搭明细(processed_image 转为可访问 URL)。
+func buildOutfitItemResponse(c *mysqlmodel.Clothes) dto.OutfitItemResponse {
+ img := c.ProcessedImage
+ if img != "" {
+ img = upload.GetCosUrl(img)
+ }
+ return dto.OutfitItemResponse{ClothesUUID: c.ClothesUUID, ProcessedImage: img}
+}
+
+// buildOutfitCreateResponse 组装穿搭响应。
+func buildOutfitCreateResponse(o *mysqlmodel.Outfit, items []dto.OutfitItemResponse) dto.OutfitCreateResponse {
+ return dto.OutfitCreateResponse{
+ OutfitUUID: o.OutfitUUID,
+ OutfitName: o.OutfitName,
+ OutfitDesc: o.OutfitDesc,
+ Scene: o.Scene,
+ Source: o.Source,
+ IsPublic: o.IsPublic,
+ AuditStatus: o.AuditStatus,
+ Items: items,
+ }
+}
diff --git a/internal/service/quota.go b/internal/service/quota.go
new file mode 100644
index 0000000..29a6f63
--- /dev/null
+++ b/internal/service/quota.go
@@ -0,0 +1,137 @@
+package service
+
+import (
+ "context"
+ "log/slog"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
+)
+
+// QuotaService 额度业务服务。
+type QuotaService struct {
+ quotaDao *mysqldao.QuotaDao
+ flowLogDao *mysqldao.QuotaFlowLogDao
+}
+
+// NewQuotaService 创建额度服务。
+func NewQuotaService() *QuotaService {
+ return &QuotaService{
+ quotaDao: mysqldao.NewQuotaDao(),
+ flowLogDao: mysqldao.NewQuotaFlowLogDao(),
+ }
+}
+
+// InfoGetBySelf 获取当前用户各类额度概览。
+// Cos 返回永久总额度/累计已用/剩余;AI/Outfit/Closet 返回每日总额度/当日已用/当日剩余。
+func (s *QuotaService) InfoGetBySelf(ctx context.Context, userID int64) (*dto.QuotaInfoGetBySelfResponse, *errcode.Error) {
+ ctx, span := global.Tracer.Start(ctx, "svc.QuotaInfoGetBySelf")
+ defer span.End()
+
+ types := []int8{QuotaTypeCos, QuotaTypeAI, QuotaTypeOutfitShare, QuotaTypeClosetShare}
+ items := make([]dto.QuotaItemResponse, 0, len(types))
+ for _, quotaType := range types {
+ eff, err := getEffectiveQuota(ctx, userID, quotaType)
+ if err != nil {
+ return nil, err
+ }
+ items = append(items, dto.QuotaItemResponse{
+ QuotaType: quotaType,
+ Total: eff.Total,
+ Used: eff.Used,
+ Remain: eff.Remain,
+ })
+ }
+
+ return &dto.QuotaInfoGetBySelfResponse{Quotas: items}, nil
+}
+
+// FlowList 分页查询当前用户的额度流水。
+func (s *QuotaService) FlowList(ctx context.Context, userID int64, request *dto.QuotaFlowListRequest) (*dto.QuotaFlowListResponse, *errcode.Error) {
+ ctx, span := global.Tracer.Start(ctx, "svc.QuotaFlowList")
+ defer span.End()
+
+ page, pageSize := normalizePage(request.Page, request.PageSize)
+
+ list, total, err := s.flowLogDao.QuotaFlowLogListByUserID(ctx, userID, request.QuotaType, page, pageSize)
+ if err != nil {
+ return nil, errcode.DatabaseError
+ }
+
+ items := make([]dto.QuotaFlowItemResponse, 0, len(list))
+ for _, f := range list {
+ items = append(items, dto.QuotaFlowItemResponse{
+ QuotaFlowUUID: f.QuotaFlowUUID,
+ QuotaType: f.QuotaType,
+ ChangeTarget: f.ChangeTarget,
+ ChangeType: f.ChangeType,
+ ChangeNum: f.ChangeNum,
+ NewValue: f.NewValue,
+ Reason: f.Reason,
+ OrderNo: f.OrderNo,
+ CreatedAt: f.CreatedAt,
+ })
+ }
+
+ return &dto.QuotaFlowListResponse{
+ PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize},
+ List: items,
+ }, nil
+}
+
+// ensureDefaultQuota 为新用户初始化 Cos 永久免费额度底值。
+// AI/Outfit/Closet 为每日额度,不落库,由 getEffectiveQuota 动态计算,无需初始化。
+// 事务内「创建 + 写流水」强一致,依赖 uk_user_quota 唯一索引实现并发安全幂等;
+// best-effort:失败仅告警,不阻断登录主流程。
+func ensureDefaultQuota(ctx context.Context, userID int64) {
+ quotaDao := mysqldao.NewQuotaDao()
+ total := defaultQuotaValue(QuotaTypeCos)
+
+ flow := &mysqlmodel.QuotaFlowLog{
+ QuotaFlowUUID: genUUID(),
+ UserID: userID,
+ ChangeTarget: QuotaChangeTargetAdjust,
+ ChangeType: QuotaChangeTypeActivity,
+ ChangeNum: total,
+ QuotaType: QuotaTypeCos,
+ Reason: "新用户默认上传额度",
+ }
+ if err := quotaDao.QuotaInitWithFlow(ctx, userID, QuotaTypeCos, total, flow); err != nil {
+ slog.WarnContext(ctx, "init default cos quota failed", "user_id", userID, "error", err)
+ }
+}
+
+// defaultQuotaValue 返回某类额度的新用户默认值:
+// 优先取 global.UserDefaultQuotaSetting 的配置值,配置为 0 时同样生效(表示不赠送该类免费额度);
+// 未配置(整段缺失或该项缺失,指针为 nil)或配置为负数时,回落内置常量。
+func defaultQuotaValue(quotaType int8) int64 {
+ cfg := global.UserDefaultQuotaSetting
+
+ switch quotaType {
+ case QuotaTypeCos:
+ if cfg != nil && cfg.Cos != nil && *cfg.Cos >= 0 {
+ return *cfg.Cos
+ }
+ return DefaultQuotaCos
+ case QuotaTypeAI:
+ if cfg != nil && cfg.AI != nil && *cfg.AI >= 0 {
+ return *cfg.AI
+ }
+ return DefaultQuotaAI
+ case QuotaTypeOutfitShare:
+ if cfg != nil && cfg.OutfitShare != nil && *cfg.OutfitShare >= 0 {
+ return *cfg.OutfitShare
+ }
+ return DefaultQuotaOutfitShare
+ case QuotaTypeClosetShare:
+ if cfg != nil && cfg.ClosetShare != nil && *cfg.ClosetShare >= 0 {
+ return *cfg.ClosetShare
+ }
+ return DefaultQuotaClosetShare
+ default:
+ return 0
+ }
+}
diff --git a/internal/service/service.go b/internal/service/service.go
new file mode 100644
index 0000000..3dd4ae3
--- /dev/null
+++ b/internal/service/service.go
@@ -0,0 +1,465 @@
+// Package service 承载业务逻辑层,编排 dto、mysqldao、redisdao 与 pkg 能力。
+//
+// 约定:
+// - 每个业务域一个 Service 结构体,NewXxxService() 构造,内部持有所需 DAO。
+// - 方法统一返回 (*dto.XxxResponse, *errcode.Error),将底层 error 映射为语义化错误。
+// - redis 未命中返回 redis.Nil,按缓存未命中处理,不视为错误。
+// - 所有"我的"资源操作在本层做归属校验(AuthZ)。
+package service
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "time"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
+ "github.com/go-sql-driver/mysql"
+ "github.com/google/uuid"
+ "github.com/redis/go-redis/v9"
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+)
+
+// isNotFound 判断 gorm 是否为「记录不存在」。
+func isNotFound(err error) bool {
+ return errors.Is(err, gorm.ErrRecordNotFound)
+}
+
+// isCacheMiss 判断 redis 是否为「未命中」。
+func isCacheMiss(err error) bool {
+ return errors.Is(err, redis.Nil)
+}
+
+// isDuplicateKey 判断是否为 MySQL 唯一键冲突(Error 1062),用于幂等发放/回退。
+func isDuplicateKey(err error) bool {
+ var mysqlErr *mysql.MySQLError
+ return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062
+}
+
+// 分页默认值。
+const (
+ defaultPage = 1
+ defaultPageSize = 10
+ maxPageSize = 50
+)
+
+// normalizePage 规整分页参数,返回可直接用于 DAO 的 page/pageSize。
+func normalizePage(page, pageSize int) (int, int) {
+ if page <= 0 {
+ page = defaultPage
+ }
+ if pageSize <= 0 {
+ pageSize = defaultPageSize
+ }
+ if pageSize > maxPageSize {
+ pageSize = maxPageSize
+ }
+ return page, pageSize
+}
+
+// nowUnix 当前秒级时间戳。
+func nowUnix() int64 {
+ return time.Now().Unix()
+}
+
+// todayStatDate 返回本地时区今日的 YYYYMMDD 整数。
+func todayStatDate() int64 {
+ now := time.Now()
+ return int64(now.Year()*10000 + int(now.Month())*100 + now.Day())
+}
+
+// genUUID 生成新的 UUID 字符串。
+func genUUID() string {
+ return uuid.NewString()
+}
+
+// recordDailyUsage 记录用户当日使用计数(best-effort,用于统计,不作为硬限制;失败仅告警)。
+// kind 复用 QuotaType 语义:QuotaTypeAI / QuotaTypeOutfitShare / QuotaTypeClosetShare。
+func recordDailyUsage(ctx context.Context, userID int64, kind int8) {
+ usageDao := mysqldao.NewUserDailyUsageDao()
+ statDate := todayStatDate()
+
+ // 确保当日计数行存在。
+ if _, err := usageDao.UserDailyUsageGetByUserIDAndDate(ctx, userID, statDate); err != nil {
+ if isNotFound(err) {
+ if cerr := usageDao.UserDailyUsageCreate(ctx, &mysqlmodel.UserDailyUsage{
+ UserID: userID,
+ StatDate: statDate,
+ }); cerr != nil {
+ slog.WarnContext(ctx, "create daily usage failed", "user_id", userID, "error", cerr)
+ return
+ }
+ } else {
+ slog.WarnContext(ctx, "get daily usage failed", "user_id", userID, "error", err)
+ return
+ }
+ }
+
+ var err error
+ switch kind {
+ case QuotaTypeAI:
+ err = usageDao.UserDailyUsageIncrementAI(ctx, userID, statDate)
+ case QuotaTypeOutfitShare:
+ err = usageDao.UserDailyUsageIncrementOutfitShare(ctx, userID, statDate)
+ case QuotaTypeClosetShare:
+ err = usageDao.UserDailyUsageIncrementClosetShare(ctx, userID, statDate)
+ }
+ if err != nil {
+ slog.WarnContext(ctx, "increment daily usage failed", "user_id", userID, "kind", kind, "error", err)
+ }
+}
+
+// ---- 新额度体系:每日额度 + 凭证有效期 ----
+
+// EffectiveQuota 某类额度的有效额度概览。
+// Cos:Total=永久总额度、Used=永久累计已用、Remain=永久剩余;
+// AI/Outfit/Closet:Total=每日总额度、Used=当日已用、Remain=当日剩余。
+type EffectiveQuota struct {
+ Total int64
+ Used int64
+ Remain int64
+}
+
+// getUserVIPExpire 读取用户 VIP 到期时间(秒);用户不存在或查询失败返回 0(视为非会员)。
+func getUserVIPExpire(ctx context.Context, userID int64) int64 {
+ user, err := mysqldao.NewUserDao().UserGetByID(ctx, userID)
+ if err != nil {
+ return 0
+ }
+ return user.VIPExpire
+}
+
+// grantExpired 判断凭证是否已失效:expire_time=0 表示跟随 VIP,其余按到期时间判断。
+func grantExpired(expireTime, vipExpire, now int64) bool {
+ if expireTime == 0 {
+ return vipExpire <= now
+ }
+ return expireTime <= now
+}
+
+// listValidGrants 查询用户某类型当前有效的额度凭证。
+func listValidGrants(ctx context.Context, userID int64, quotaType int8, vipExpire, now int64) ([]mysqlmodel.QuotaGrant, error) {
+ grants, err := mysqldao.NewQuotaGrantDao().QuotaGrantListByUserIDAndType(ctx, userID, quotaType)
+ if err != nil {
+ return nil, err
+ }
+ valid := make([]mysqlmodel.QuotaGrant, 0, len(grants))
+ for _, g := range grants {
+ if !grantExpired(g.ExpireTime, vipExpire, now) {
+ valid = append(valid, g)
+ }
+ }
+ return valid, nil
+}
+
+// getEffectiveQuota 返回某类额度的有效额度概览(Cos 永久 / 每日额度)。
+// 有效总额度 = 免费配置值 + SUM(有效凭证 amount);已用按类型分别取永久累计或当日计数。
+func getEffectiveQuota(ctx context.Context, userID int64, quotaType int8) (*EffectiveQuota, *errcode.Error) {
+ now := nowUnix()
+ freeTotal := defaultQuotaValue(quotaType)
+
+ vipExpire := getUserVIPExpire(ctx, userID)
+ grants, err := listValidGrants(ctx, userID, quotaType, vipExpire, now)
+ if err != nil {
+ return nil, errcode.DatabaseError
+ }
+
+ var grantTotal, grantUsed int64
+ for i := range grants {
+ grantTotal += grants[i].Amount
+ grantUsed += grants[i].Used
+ }
+
+ if quotaType == QuotaTypeCos {
+ // 永久额度:免费已用来自 user_quotas.used(永久累计),赠送已用来自凭证 used。
+ freeUsed := int64(0)
+ if q, qerr := mysqldao.NewQuotaDao().QuotaGetByUserIDAndType(ctx, userID, quotaType); qerr == nil {
+ freeUsed = q.Used
+ }
+ total := freeTotal + grantTotal
+ used := freeUsed + grantUsed
+ remain := total - used
+ if remain < 0 {
+ remain = 0
+ }
+ return &EffectiveQuota{Total: total, Used: used, Remain: remain}, nil
+ }
+
+ // 每日额度:免费与赠送合并为每日总额度,已用为当日计数。
+ total := freeTotal + grantTotal
+ used := getDailyUsed(ctx, userID, quotaType)
+ remain := total - used
+ if remain < 0 {
+ remain = 0
+ }
+ return &EffectiveQuota{Total: total, Used: used, Remain: remain}, nil
+}
+
+// dailyQuotaKey 按额度类型构造当日计数 Redis key。
+func dailyQuotaKey(quotaType int8, userID int64, date int64) string {
+ switch quotaType {
+ case QuotaTypeAI:
+ return fmt.Sprintf(redismodel.KeyQuotaDailyAI, userID, date)
+ case QuotaTypeOutfitShare:
+ return fmt.Sprintf(redismodel.KeyQuotaDailyOutfitShare, userID, date)
+ case QuotaTypeClosetShare:
+ return fmt.Sprintf(redismodel.KeyQuotaDailyClosetShare, userID, date)
+ default:
+ return ""
+ }
+}
+
+// getDailyUsed 读取当日已用计数(Redis 优先,异常回落 MySQL 统计)。
+func getDailyUsed(ctx context.Context, userID int64, quotaType int8) int64 {
+ key := dailyQuotaKey(quotaType, userID, todayStatDate())
+ if key == "" {
+ return 0
+ }
+ if val, err := redisdao.DailyQuotaValue(ctx, key); err == nil {
+ return val
+ }
+ return getDailyUsedFromMySQL(ctx, userID, quotaType)
+}
+
+// getDailyUsedFromMySQL 从 user_daily_usages 读取当日计数(Redis 故障兜底)。
+func getDailyUsedFromMySQL(ctx context.Context, userID int64, quotaType int8) int64 {
+ usage, err := mysqldao.NewUserDailyUsageDao().UserDailyUsageGetByUserIDAndDate(ctx, userID, todayStatDate())
+ if err != nil {
+ return 0
+ }
+ switch quotaType {
+ case QuotaTypeAI:
+ return usage.AIRecommendCount
+ case QuotaTypeOutfitShare:
+ return usage.OutfitShareCount
+ case QuotaTypeClosetShare:
+ return usage.ClosetShareCount
+ default:
+ return 0
+ }
+}
+
+// consumeDailyQuota 原子消费 AI/Outfit/Closet 当日额度。
+// 额度不足返回 errcode.QuotaInsufficient;Redis 异常返回 errcode.CacheError。
+func consumeDailyQuota(ctx context.Context, userID int64, quotaType int8, n int64, reason string) *errcode.Error {
+ quota, qerr := getEffectiveQuota(ctx, userID, quotaType)
+ if qerr != nil {
+ return qerr
+ }
+ key := dailyQuotaKey(quotaType, userID, todayStatDate())
+ if key == "" {
+ return errcode.QuotaInsufficient
+ }
+ val, err := redisdao.DailyQuotaConsume(ctx, key, quota.Total, n)
+ if err != nil {
+ return errcode.CacheError
+ }
+ if val < 0 {
+ return errcode.QuotaInsufficient
+ }
+ // 同步 MySQL 当日计数(统计/兜底)。
+ recordDailyUsage(ctx, userID, quotaType)
+ return nil
+}
+
+// refundDailyQuota 业务失败时回退当日已用计数(best-effort,保证不小于 0)。
+func refundDailyQuota(ctx context.Context, userID int64, quotaType int8, n int64) {
+ key := dailyQuotaKey(quotaType, userID, todayStatDate())
+ if key == "" {
+ return
+ }
+ if err := redisdao.DailyQuotaRefund(ctx, key, n); err != nil {
+ slog.WarnContext(ctx, "refund daily quota failed", "user_id", userID, "quota_type", quotaType, "error", err)
+ }
+}
+
+// consumeCosQuota 消费 Cos 永久额度(先到期凭证先用,再扣免费额度),事务内保证数值与流水一致。
+func consumeCosQuota(ctx context.Context, userID int64, n int64, relatedID int64, reason string) *errcode.Error {
+ now := nowUnix()
+ vipExpire := getUserVIPExpire(ctx, userID)
+
+ err := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ // 1. 有效凭证(expire_time>now,或跟随 VIP 且 VIP 有效),按先到期先用锁定。
+ var grants []mysqlmodel.QuotaGrant
+ q := tx.Where("user_id = ? AND quota_type = ?", userID, QuotaTypeCos)
+ if vipExpire > now {
+ q = q.Where("(expire_time > ? OR expire_time = 0)", now)
+ } else {
+ q = q.Where("expire_time > ?", now)
+ }
+ if err := q.Order("CASE WHEN expire_time = 0 THEN 1 ELSE 0 END ASC").
+ Order("expire_time ASC").
+ Clauses(clause.Locking{Strength: "UPDATE"}).
+ Find(&grants).Error; err != nil {
+ return err
+ }
+
+ remain := n
+ for i := range grants {
+ if remain <= 0 {
+ break
+ }
+ avail := grants[i].Amount - grants[i].Used
+ if avail <= 0 {
+ continue
+ }
+ delta := avail
+ if delta > remain {
+ delta = remain
+ }
+ if err := tx.Model(&mysqlmodel.QuotaGrant{}).
+ Where("id = ? AND used + ? <= amount", grants[i].ID, delta).
+ UpdateColumn("used", gorm.Expr("used + ?", delta)).Error; err != nil {
+ return err
+ }
+ remain -= delta
+ }
+
+ // 2. 凭证不够再扣免费额度(免费上限以配置值为准,避免历史 user_quotas.total 污染)。
+ if remain > 0 {
+ freeLimit := defaultQuotaValue(QuotaTypeCos)
+ res := tx.Model(&mysqlmodel.Quota{}).
+ Where("user_id = ? AND quota_type = ? AND used + ? <= ?", userID, QuotaTypeCos, remain, freeLimit).
+ UpdateColumn("used", gorm.Expr("used + ?", remain))
+ if res.Error != nil {
+ return res.Error
+ }
+ if res.RowsAffected == 0 {
+ return errcode.QuotaInsufficient
+ }
+ }
+
+ // 3. 写消费流水(old/new 记录 Cos 有效剩余)。
+ before := cosRemainTx(tx, userID, vipExpire, now)
+ flow := &mysqlmodel.QuotaFlowLog{
+ QuotaFlowUUID: genUUID(),
+ UserID: userID,
+ ChangeTarget: QuotaChangeTargetUse,
+ ChangeType: QuotaChangeTypeConsume,
+ ChangeNum: -n,
+ OldValue: before + n,
+ NewValue: before,
+ QuotaType: QuotaTypeCos,
+ RelatedID: relatedID,
+ Reason: reason,
+ }
+ // 消费类流水无业务订单号,用流水 UUID 填充 order_no 避免唯一索引空串冲突。
+ flow.OrderNo = flow.QuotaFlowUUID
+ return tx.Create(flow).Error
+ })
+
+ if err != nil {
+ if errors.Is(err, errcode.QuotaInsufficient) {
+ return errcode.QuotaInsufficient
+ }
+ return errcode.DatabaseError
+ }
+ return nil
+}
+
+// cosRemainTx 在事务内计算 Cos 有效剩余(免费剩余 + 有效凭证剩余)。
+func cosRemainTx(tx *gorm.DB, userID int64, vipExpire, now int64) int64 {
+ freeRemain := int64(0)
+ var freeQ mysqlmodel.Quota
+ if err := tx.Where("user_id = ? AND quota_type = ?", userID, QuotaTypeCos).First(&freeQ).Error; err == nil {
+ freeRemain = defaultQuotaValue(QuotaTypeCos) - freeQ.Used
+ }
+
+ var grantRemain int64
+ q := tx.Model(&mysqlmodel.QuotaGrant{}).Where("user_id = ? AND quota_type = ?", userID, QuotaTypeCos)
+ if vipExpire > now {
+ q = q.Where("(expire_time > ? OR expire_time = 0)", now)
+ } else {
+ q = q.Where("expire_time > ?", now)
+ }
+ if err := q.Select("COALESCE(SUM(amount - used), 0)").Scan(&grantRemain).Error; err != nil {
+ slog.WarnContext(context.Background(), "calc cos grant remain failed", "user_id", userID, "error", err)
+ }
+ if freeRemain < 0 {
+ freeRemain = 0
+ }
+ return freeRemain + grantRemain
+}
+
+// refundCosQuotaTx 在事务内回退 Cos 额度并写流水。
+// 回退策略(后进先出):优先回退免费额度(最后扣减的),不足再回退凭证(最后到期优先)。
+func refundCosQuotaTx(tx *gorm.DB, userID int64, vipExpire int64, n int64, relatedID int64, reason string) error {
+ now := nowUnix()
+
+ remain := n
+ // 1. 优先回退免费额度(used >= n 时整体回退,否则不满足则跳过)。
+ res := tx.Model(&mysqlmodel.Quota{}).
+ Where("user_id = ? AND quota_type = ? AND used >= ?", userID, QuotaTypeCos, remain).
+ UpdateColumn("used", gorm.Expr("used - ?", remain))
+ if res.Error != nil {
+ return res.Error
+ }
+ if res.RowsAffected > 0 {
+ remain = 0
+ }
+
+ // 2. 免费额度不足,回退凭证(按到期时间倒序,最后到期的优先回退)。
+ if remain > 0 {
+ var grants []mysqlmodel.QuotaGrant
+ if err := tx.Where("user_id = ? AND quota_type = ? AND used > 0", userID, QuotaTypeCos).
+ Order("expire_time DESC").
+ Clauses(clause.Locking{Strength: "UPDATE"}).
+ Find(&grants).Error; err != nil {
+ return err
+ }
+ for i := range grants {
+ if remain <= 0 {
+ break
+ }
+ delta := grants[i].Used
+ if delta > remain {
+ delta = remain
+ }
+ if err := tx.Model(&mysqlmodel.QuotaGrant{}).
+ Where("id = ? AND used >= ?", grants[i].ID, delta).
+ UpdateColumn("used", gorm.Expr("used - ?", delta)).Error; err != nil {
+ return err
+ }
+ remain -= delta
+ }
+ if remain > 0 {
+ // 无足够已用额度可回退(数据异常),跳过写流水。
+ return nil
+ }
+ }
+
+ // 3. 写回退流水。
+ after := cosRemainTx(tx, userID, vipExpire, now)
+ flow := &mysqlmodel.QuotaFlowLog{
+ QuotaFlowUUID: genUUID(),
+ UserID: userID,
+ ChangeTarget: QuotaChangeTargetUse,
+ ChangeType: QuotaChangeTypeRefund,
+ ChangeNum: n,
+ OldValue: after - n,
+ NewValue: after,
+ QuotaType: QuotaTypeCos,
+ RelatedID: relatedID,
+ Reason: reason,
+ }
+ // 回退类流水无业务订单号,用流水 UUID 填充 order_no 避免唯一索引空串冲突。
+ flow.OrderNo = flow.QuotaFlowUUID
+ return tx.Create(flow).Error
+}
+
+// refundCosQuota 独立事务回退 Cos 额度(上传失败等非事务场景)。
+func refundCosQuota(ctx context.Context, userID int64, n int64, relatedID int64, reason string) {
+ vipExpire := getUserVIPExpire(ctx, userID)
+ if err := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ return refundCosQuotaTx(tx, userID, vipExpire, n, relatedID, reason)
+ }); err != nil {
+ slog.WarnContext(ctx, "refund cos quota failed", "user_id", userID, "error", err)
+ }
+}
diff --git a/internal/service/user.go b/internal/service/user.go
new file mode 100644
index 0000000..6e6853d
--- /dev/null
+++ b/internal/service/user.go
@@ -0,0 +1,417 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "mime/multipart"
+ "strings"
+ "time"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/lzcwyjwt"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/upload"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/wechat"
+)
+
+// UserService 用户业务服务。
+type UserService struct {
+ userDao *mysqldao.UserDao
+}
+
+// NewUserService 创建用户服务。
+func NewUserService() *UserService {
+ return &UserService{
+ userDao: mysqldao.NewUserDao(),
+ }
+}
+
+// WxLogin 微信小程序登录:code2session 换取 openid,首次登录自动注册并初始化默认额度。
+func (s *UserService) WxLogin(ctx context.Context, request *dto.UserWxLoginRequest) (*dto.UserWxLoginResponse, *errcode.Error) {
+ ctx, span := global.Tracer.Start(ctx, "svc.UserWxLogin")
+ defer span.End()
+
+ sess, err := wechat.Code2Session(ctx, request.Code)
+ if err != nil {
+ var codeErr *wechat.Code2SessionError
+ if errors.As(err, &codeErr) && codeErr.IsInvalidCode() {
+ return nil, errcode.UserWxCodeInvalid
+ }
+ return nil, errcode.UserWxLoginFailed
+ }
+
+ isNew := false
+ // userCacheStale 标记内存中的 user 对象已落后于数据库(如赠送会员后回读用户失败)。
+ // 为 true 时跳过用户信息缓存回写,避免用过期的非会员对象覆盖缓存(下次读取会走 DB 回填)。
+ userCacheStale := false
+ user, err := s.userDao.UserGetByOpenID(ctx, sess.OpenID)
+ if err != nil {
+ if !isNotFound(err) {
+ return nil, errcode.DatabaseError
+ }
+ // 首次登录:注册新用户。
+ user = &mysqlmodel.User{
+ UserUUID: genUUID(),
+ WxOpenID: sess.OpenID,
+ Username: "微信用户",
+ IsNewUser: true,
+ Status: UserStatusNormal,
+ VIPType: VIPTypeNone,
+ LastLoginTime: nowUnix(),
+ }
+ if cerr := s.userDao.UserCreate(ctx, user); cerr != nil {
+ if isDuplicateKey(cerr) {
+ // 并发注册冲突:重查已存在的用户继续登录。
+ user, err = s.userDao.UserGetByOpenID(ctx, sess.OpenID)
+ if err != nil {
+ return nil, errcode.DatabaseError
+ }
+ } else {
+ return nil, errcode.UserCreateFailed
+ }
+ } else {
+ isNew = true
+ // 初始化默认免费额度(best-effort,内部失败已告警,不阻断登录)。
+ ensureDefaultQuota(ctx, user.ID)
+ // 赠送体验会员(best-effort);赠送成功后回读用户,
+ // 保证后续写入缓存与登录响应的是会员态,避免被内存中的非会员对象覆盖。
+ if s.ensureTrialVIP(ctx, user.ID) > 0 {
+ if fresh, ferr := s.userDao.UserGetByID(ctx, user.ID); ferr == nil {
+ user = fresh
+ } else {
+ // 库中已是会员态但回读失败:不回写缓存,交由下次读取走 DB 回填。
+ slog.WarnContext(ctx, "reload user after trial vip failed", "user_id", user.ID, "error", ferr)
+ userCacheStale = true
+ }
+ }
+ }
+ }
+
+ // 非正常状态账号(临时/永久封禁、已注销)禁止登录。
+ if user.Status != UserStatusNormal {
+ return nil, errcode.UserDisabled
+ }
+
+ if !isNew {
+ _ = s.userDao.UserUpdateByID(ctx, user.ID, map[string]any{"last_login_time": nowUnix()})
+ }
+
+ accessToken, refreshToken, terr := lzcwyjwt.GenerateTokens(user.ID)
+ if terr != nil {
+ return nil, errcode.UnauthorizedTokenGenerate
+ }
+ if aerr := redisdao.AuthTokenSet(ctx, user.ID, accessToken, refreshToken); aerr != nil {
+ // Redis 故障降级放行(与鉴权中间件 fail-open 策略一致),避免 Redis 抖动导致全员无法登录。
+ slog.WarnContext(ctx, "set auth token failed, allow by fallback", "error", aerr)
+ }
+
+ // 缓存 session_key(虚拟支付 signature 计算依赖;best-effort,失败不阻断登录,
+ // 虚拟支付下单时若缺失会提示重新登录)。
+ if sess.SessionKey != "" {
+ if serr := redisdao.AuthSessionKeySet(ctx, user.ID, sess.SessionKey); serr != nil {
+ slog.WarnContext(ctx, "set session key failed", "user_id", user.ID, "error", serr)
+ }
+ }
+
+ // 刷新缓存中的用户信息(best-effort);对象已落后于数据库时不回写。
+ if !userCacheStale {
+ _ = redisdao.UserInfoSet(ctx, user, 0)
+ }
+
+ info := buildUserInfoResponse(user)
+ info.IsNewUser = isNew
+
+ return &dto.UserWxLoginResponse{
+ AccessToken: accessToken,
+ RefreshToken: refreshToken,
+ ExpireTime: nowUnix() + int64(global.JWTSetting.AccessTokenExpire),
+ UserInfo: info,
+ }, nil
+}
+
+// InfoGetBySelf 获取当前用户信息,优先读缓存。
+func (s *UserService) InfoGetBySelf(ctx context.Context, userID int64) (*dto.UserInfoGetBySelfResponse, *errcode.Error) {
+ ctx, span := global.Tracer.Start(ctx, "svc.UserInfoGetBySelf")
+ defer span.End()
+
+ if user, err := redisdao.UserInfoGet(ctx, userID); err == nil && user != nil {
+ info := buildUserInfoResponse(user)
+ return &info, nil
+ } else if err != nil && !isCacheMiss(err) {
+ // 缓存异常不阻断,继续回落 DB。
+ }
+
+ user, err := s.userDao.UserGetByID(ctx, userID)
+ if err != nil {
+ if isNotFound(err) {
+ return nil, errcode.UserNotFound
+ }
+ return nil, errcode.DatabaseError
+ }
+ _ = redisdao.UserInfoSet(ctx, user, 0)
+
+ info := buildUserInfoResponse(user)
+ return &info, nil
+}
+
+// ProfileUpdate 修改当前用户资料。
+func (s *UserService) ProfileUpdate(ctx context.Context, userID int64, request *dto.UserProfileUpdateBySelfRequest) (*dto.UserProfileUpdateBySelfResponse, *errcode.Error) {
+ ctx, span := global.Tracer.Start(ctx, "svc.UserProfileUpdate")
+ defer span.End()
+
+ // 字段校验:昵称长度、头像长度、性别枚举、生日范围。
+ if u := strings.TrimSpace(request.Username); u != "" {
+ if len([]rune(u)) > 32 {
+ return nil, errcode.InvalidParams.WithDetails("昵称长度不能超过 32 个字符")
+ }
+ request.Username = u
+ }
+ if request.Avatar != "" && len([]rune(request.Avatar)) > 500 {
+ return nil, errcode.InvalidParams.WithDetails("头像地址过长")
+ }
+ if request.Sex != 0 && request.Sex != SexMale && request.Sex != SexFemale {
+ return nil, errcode.InvalidParams.WithDetails("性别参数不合法")
+ }
+ if request.BirthDay != 0 && request.BirthDay > nowUnix() {
+ return nil, errcode.InvalidParams.WithDetails("生日不能晚于当前时间")
+ }
+
+ updates := map[string]any{}
+ if request.Username != "" {
+ updates["username"] = request.Username
+ }
+ if request.Avatar != "" {
+ updates["avatar"] = request.Avatar
+ }
+ if request.Sex != 0 {
+ updates["sex"] = request.Sex
+ }
+ if request.BirthDay != 0 {
+ updates["birth_day"] = request.BirthDay
+ }
+ if len(updates) == 0 {
+ return &dto.UserProfileUpdateBySelfResponse{}, nil
+ }
+
+ if err := s.userDao.UserUpdateByID(ctx, userID, updates); err != nil {
+ return nil, errcode.UserUpdateFailed
+ }
+ // 失效缓存,下次读取回填最新数据。
+ _ = redisdao.UserInfoDel(ctx, userID)
+
+ return &dto.UserProfileUpdateBySelfResponse{}, nil
+}
+
+// AvatarUpload 上传用户头像:校验图片 → 落临时目录 → 上传 COS → 更新 users.avatar(存 COS key)→ 失效缓存,
+// 返回可直接访问的头像 URL。头像属基础资料,不消耗衣物 COS 上传额度。
+func (s *UserService) AvatarUpload(ctx context.Context, userID int64, fileHeader *multipart.FileHeader) (*dto.UserAvatarUploadResponse, *errcode.Error) {
+ ctx, span := global.Tracer.Start(ctx, "svc.UserAvatarUpload")
+ defer span.End()
+
+ if fileHeader == nil || fileHeader.Size == 0 {
+ return nil, errcode.UploadFileEmpty
+ }
+
+ // 1. 校验扩展名与大小。
+ ext := upload.FileGetExt(fileHeader.Filename)
+ if !upload.FileCheckAllowExt(ext, global.UploadSetting.AllowedExtensions) {
+ return nil, errcode.UploadFileExtNotAllow
+ }
+ if !upload.FileCheckMaxSize(fileHeader.Size, int64(global.UploadSetting.MaxSizeMB)) {
+ return nil, errcode.UploadFileTooLarge
+ }
+
+ // 2. 读取原图字节(必须同步读取,请求返回后 multipart 临时文件会被清理)。
+ src, oerr := fileHeader.Open()
+ if oerr != nil {
+ return nil, errcode.UploadFileSaveFailed
+ }
+ imageData, rerr := io.ReadAll(src)
+ _ = src.Close()
+ if rerr != nil {
+ return nil, errcode.UploadFileSaveFailed
+ }
+
+ // 3. 校验文件内容为真实图片(magic bytes),防止伪造扩展名上传恶意文件。
+ if !upload.FileCheckImageMagic(imageData) {
+ return nil, errcode.UploadFileExtNotAllow
+ }
+
+ // 4. 获取用户 UUID,构造 COS key:{userUUID}/{userID}/avatar/{随机名+扩展名}。
+ user, uerr := s.userDao.UserGetByID(ctx, userID)
+ if uerr != nil {
+ if isNotFound(uerr) {
+ return nil, errcode.UserNotFound
+ }
+ return nil, errcode.DatabaseError
+ }
+ fileName, nerr := upload.FileGenName(fileHeader.Filename)
+ if nerr != nil {
+ return nil, errcode.UploadFileSaveFailed
+ }
+ subDir := fmt.Sprintf("%s/%d/avatar", user.UserUUID, userID)
+ if _, serr := upload.FileSaveTmpBytes(ctx, imageData, fileName, subDir); serr != nil {
+ return nil, errcode.UploadFileSaveFailed
+ }
+ cosKey := fmt.Sprintf("%s/%s", subDir, fileName)
+ if cerr := upload.FileUploadCos(ctx, cosKey); cerr != nil {
+ return nil, errcode.UploadFileSaveFailed
+ }
+
+ // 5. 更新 avatar(存 COS key,读取时统一转 URL)。
+ if uerr := s.userDao.UserUpdateByID(ctx, userID, map[string]any{"avatar": cosKey}); uerr != nil {
+ return nil, errcode.UserUpdateFailed
+ }
+
+ // 6. 失效缓存,下次读取回填最新头像。
+ _ = redisdao.UserInfoDel(ctx, userID)
+
+ return &dto.UserAvatarUploadResponse{Avatar: upload.GetCosUrl(cosKey)}, nil
+}
+
+// RefreshToken 使用 refresh token 刷新访问令牌。
+func (s *UserService) RefreshToken(ctx context.Context, request *dto.UserRefreshTokenRequest) (*dto.UserRefreshTokenResponse, *errcode.Error) {
+ ctx, span := global.Tracer.Start(ctx, "svc.UserRefreshToken")
+ defer span.End()
+
+ claims, err := lzcwyjwt.ParseRefreshToken(request.RefreshToken)
+ if err != nil {
+ if errors.Is(err, lzcwyjwt.ErrTokenExpired) {
+ return nil, errcode.UnauthorizedTokenTimeout
+ }
+ return nil, errcode.UnauthorizedTokenError
+ }
+
+ // 校验 refresh token 仍在有效会话集合中。
+ uid, aerr := redisdao.AuthUserIDByRefresh(ctx, request.RefreshToken)
+ if aerr != nil {
+ if isCacheMiss(aerr) {
+ return nil, errcode.UnauthorizedTokenError
+ }
+ return nil, errcode.CacheError
+ }
+ if uid != claims.UserID {
+ return nil, errcode.UnauthorizedTokenError
+ }
+
+ accessToken, refreshToken, terr := lzcwyjwt.GenerateTokens(claims.UserID)
+ if terr != nil {
+ return nil, errcode.UnauthorizedTokenGenerate
+ }
+
+ // 失效旧会话:强制携带旧 access token,精确吊销旧 access + 旧 refresh,防止旧 token 泄露后被继续使用。
+ if request.AccessToken == "" {
+ return nil, errcode.InvalidParams.WithDetails("缺少旧 access token")
+ }
+ oldUID, oerr := redisdao.AuthUserIDByAccess(ctx, request.AccessToken)
+ if oerr != nil && !isCacheMiss(oerr) {
+ return nil, errcode.CacheError
+ }
+ if oerr == nil && oldUID != claims.UserID {
+ return nil, errcode.UnauthorizedTokenError
+ }
+ _ = redisdao.AuthTokenDel(ctx, claims.UserID, request.AccessToken, request.RefreshToken)
+ if serr := redisdao.AuthTokenSet(ctx, claims.UserID, accessToken, refreshToken); serr != nil {
+ return nil, errcode.CacheError
+ }
+
+ return &dto.UserRefreshTokenResponse{
+ AccessToken: accessToken,
+ RefreshToken: refreshToken,
+ ExpireTime: nowUnix() + int64(global.JWTSetting.AccessTokenExpire),
+ }, nil
+}
+
+// defaultVipDays 返回新用户注册赠送的体验会员天数:
+// 优先取 UserDefaultQuota.GiftVIPDay 的配置值,配置为 0 时生效(表示不赠送);
+// 未配置(整段缺失或该项缺失,指针为 nil)或配置为负数时,回落内置常量 DefaultVipDays。
+func defaultVipDays() int64 {
+ cfg := global.UserDefaultQuotaSetting
+ if cfg == nil || cfg.GiftVIPDay == nil || *cfg.GiftVIPDay < 0 {
+ return DefaultVipDays
+ }
+ return *cfg.GiftVIPDay
+}
+
+// ensureTrialVIP 新用户注册赠送体验会员(best-effort:任何失败仅告警,不阻断注册)。
+//
+// 幂等:依赖 vip_flow_logs 的 uk_vip_flow_idem(order_no, change_type) 唯一索引;
+// 注册场景没有订单号,使用 register_gift_ 作为幂等键 —— 必须携带 userID,
+// 若写成固定常量则全站只能发放一次(第二个用户注册即冲突)。
+//
+// 返回赠送后的会员到期时间(秒);未赠送或已发放过返回 0。
+func (s *UserService) ensureTrialVIP(ctx context.Context, userID int64) int64 {
+ days := defaultVipDays()
+ if days <= 0 {
+ return 0
+ }
+
+ ctx, span := global.Tracer.Start(ctx, "svc.EnsureTrialVIP")
+ defer span.End()
+
+ flow := &mysqlmodel.VIPFlowLog{
+ UserID: userID,
+ ChangeType: VIPChangeTypeActivity,
+ OldVIPType: VIPTypeNone,
+ NewVIPType: VIPTypeTrial,
+ OldVIPExpire: 0,
+ OrderNo: fmt.Sprintf("register_gift_%d", userID),
+ Source: VIPSourceSystem,
+ Reason: "新用户注册赠送体验会员",
+ }
+
+ if err := s.userDao.VIPGrantIdempotent(ctx, userID, nowUnix(), days, VIPTypeTrial, flow); err != nil {
+ if isDuplicateKey(err) {
+ // 唯一索引冲突:该用户已赠送过,按已发放处理。
+ return 0
+ }
+ span.RecordError(err)
+ slog.WarnContext(ctx, "grant trial vip failed", "user_id", userID, "days", days, "error", err)
+ return 0
+ }
+
+ // 会员信息已变更,失效缓存(注册流程随后会用回读后的用户重新写入)。
+ _ = redisdao.UserInfoDel(ctx, userID)
+
+ sendNotification(ctx, userID, 0, NotificationTypeSystem, "欢迎礼包已到账",
+ fmt.Sprintf("已为你开通 %d 天体验会员,有效期至 %s",
+ days, time.Unix(flow.NewVIPExpire, 0).Format("2006-01-02")))
+
+ return flow.NewVIPExpire
+}
+
+// buildUserInfoResponse 将 User 模型映射为用户信息响应。
+// 会员已过期(vip_expire <= now)时把 vip_type 归一为 1:项目没有会员到期归位任务,
+// 过期会员的 vip_type 仍保留原值,归一可避免调用方仅凭 vip_type 误判为有效会员(只影响响应,不写库)。
+func buildUserInfoResponse(user *mysqlmodel.User) dto.UserInfoGetBySelfResponse {
+ vipType := user.VIPType
+ if vipType != VIPTypeNone && user.VIPExpire <= nowUnix() {
+ vipType = VIPTypeNone
+ }
+
+ // 头像兼容转换:历史通过修改资料接口传入的 http(s) URL 直接透传;
+ // 头像上传接口写入的 COS key 统一转为可访问 URL。
+ avatar := upload.AvatarURL(user.Avatar)
+
+ return dto.UserInfoGetBySelfResponse{
+ Username: user.Username,
+ Avatar: avatar,
+ Sex: user.Sex,
+ BirthDay: user.BirthDay,
+ Phone: user.Phone,
+ IsNewUser: user.IsNewUser,
+ Status: user.Status,
+ BanReason: user.BanReason,
+ BanStart: user.BanStart,
+ BanExpire: user.BanExpire,
+ VIPType: vipType,
+ VIPExpire: user.VIPExpire,
+ }
+}
diff --git a/internal/service/virtualpay.go b/internal/service/virtualpay.go
new file mode 100644
index 0000000..db53220
--- /dev/null
+++ b/internal/service/virtualpay.go
@@ -0,0 +1,433 @@
+package service
+
+import (
+ "context"
+ "encoding/json"
+ "log/slog"
+ "time"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/wechat"
+ "github.com/redis/go-redis/v9"
+)
+
+// VirtualOrderCreate 创建虚拟支付订单:本地落单(pay_channel=2)并返回前端调起
+// wx.requestVirtualPayment 所需的 signData / paySig / signature。
+func (s *OrderService) VirtualOrderCreate(ctx context.Context, userID int64, request *dto.VirtualOrderCreateRequest) (*dto.VirtualOrderCreateResponse, *errcode.Error) {
+ ctx, span := global.Tracer.Start(ctx, "svc.VirtualOrderCreate")
+ defer span.End()
+
+ if !wechat.IsVirtualPayEnabled() {
+ return nil, errcode.VirtualPayNotEnabled
+ }
+ // 订单记录当前激活的虚拟支付环境(现网 0 / 沙箱 1),发货推送按此校验。
+ xpayEnv, verr := wechat.GetVirtualPayEnv()
+ if verr != nil {
+ return nil, errcode.VirtualPayNotEnabled
+ }
+
+ goods, err := s.goodsDao.GoodsGetByCode(ctx, request.GoodsCode)
+ if err != nil {
+ if isNotFound(err) {
+ return nil, errcode.GoodsNotFound
+ }
+ return nil, errcode.DatabaseError
+ }
+ if goods.Status != GoodsStatusOnShelf {
+ return nil, errcode.GoodsOffShelf
+ }
+ if goods.ProductID == "" || goods.ProductStatus != GoodsProductPublished {
+ return nil, errcode.VirtualPayGoodsNotPublished
+ }
+ // iOS 端微信限制最低支付 1 元(100 分),低于此金额调起支付必失败,提前拒绝以降低垃圾订单。
+ // 注意:platform 由前端上报(不可作为安全边界,可伪造为 android 绕过此限制);
+ // 此校验仅作体验优化,不构成资损防线——真正的资损防线是发货推送 verifyXPayOrderPaid
+ // 对微信侧订单真实性、金额、道具的反查。低金额商品在 iOS 上的垃圾订单靠过期清理兜底。
+ if request.Platform == XPayPlatformIOS && goods.DiscountPrice < 100 {
+ return nil, errcode.VirtualPayAmountTooLow
+ }
+
+ // 虚拟支付 signature 依赖用户 session_key(登录时缓存),缺失说明需重新登录。
+ sessionKey, serr := redisdao.AuthSessionKeyGet(ctx, userID)
+ if serr != nil {
+ if serr == redis.Nil {
+ return nil, errcode.UnauthorizedTokenError.WithDetails("缺少 session_key,请重新登录")
+ }
+ return nil, errcode.CacheError
+ }
+
+ orderNo := genOrderNo()
+ expireTime := nowUnix() + orderPayTTLSeconds
+ payAmount := goods.DiscountPrice
+ platform := request.Platform
+
+ order := &mysqlmodel.Order{
+ OrderNo: orderNo,
+ UserID: userID,
+ GoodsID: goods.ID,
+ GoodsName: goods.GoodsName,
+ UnitPrice: goods.DiscountPrice,
+ TotalAmount: payAmount,
+ PayAmount: payAmount,
+ PayChannel: PayChannelVirtual,
+ OrderStatus: OrderStatusPending,
+ MemberDays: goods.MemberDays,
+ CosQuota: goods.CosQuota,
+ AIRecommendQuota: goods.AIRecommendQuota,
+ ClosetShareQuota: goods.ClosetShareQuota,
+ OutfitShareQuota: goods.OutfitShareQuota,
+ ExpireTime: expireTime,
+ XPayProductID: goods.ProductID,
+ XPayPlatform: platform,
+ XPayEnv: int8(xpayEnv),
+ }
+ if cerr := s.orderDao.OrderCreate(ctx, order); cerr != nil {
+ return nil, errcode.VirtualOrderCreateFailed
+ }
+
+ sign, perr := wechat.BuildPaymentSign(
+ global.VirtualPaySetting.OfferID,
+ goods.ProductID,
+ orderNo,
+ "",
+ sessionKey,
+ payAmount,
+ )
+ if perr != nil {
+ return nil, errcode.VirtualOrderCreateFailed
+ }
+
+ return &dto.VirtualOrderCreateResponse{
+ OrderNo: orderNo,
+ PayAmount: payAmount,
+ ExpireTime: expireTime,
+ SignData: sign.SignData,
+ PaySig: sign.PaySig,
+ Signature: sign.Signature,
+ }, nil
+}
+
+// ---- 消息推送回调 ----
+
+// XPayNotify 处理虚拟支付消息推送(发货/退款/iOS 退款问询/其它),返回应答报文。
+func (s *OrderService) XPayNotify(ctx context.Context, raw []byte) []byte {
+ ctx, span := global.Tracer.Start(ctx, "svc.XPayNotify")
+ defer span.End()
+
+ base, err := wechat.ParseNotify(raw)
+ if err != nil {
+ slog.ErrorContext(ctx, "xpay notify parse failed", "error", err)
+ return wechat.BuildNotifyResponse(false)
+ }
+
+ switch base.Event {
+ case wechat.EventGoodsDeliverNotify:
+ var deliver wechat.XPayGoodsDeliverNotify
+ if err := json.Unmarshal(raw, &deliver); err != nil {
+ slog.ErrorContext(ctx, "xpay deliver notify decode failed", "error", err)
+ return wechat.BuildNotifyResponse(false)
+ }
+ if e := s.handleGoodsDeliver(ctx, &deliver, string(raw)); e != nil {
+ slog.ErrorContext(ctx, "xpay deliver notify handle failed", "error", e)
+ return wechat.BuildNotifyResponse(false)
+ }
+ return wechat.BuildNotifyResponse(true)
+
+ case wechat.EventRefundNotify:
+ var refund wechat.XPayRefundNotify
+ if err := json.Unmarshal(raw, &refund); err != nil {
+ slog.ErrorContext(ctx, "xpay refund notify decode failed", "error", err)
+ return wechat.BuildNotifyResponse(false)
+ }
+ if e := s.handleRefundNotify(ctx, &refund); e != nil {
+ slog.ErrorContext(ctx, "xpay refund notify handle failed", "error", e)
+ return wechat.BuildNotifyResponse(false)
+ }
+ return wechat.BuildNotifyResponse(true)
+
+ case wechat.EventIOSRefundQueryNotify:
+ var query wechat.XPayIOSRefundQueryNotify
+ if err := json.Unmarshal(raw, &query); err != nil {
+ slog.ErrorContext(ctx, "xpay ios refund query decode failed", "error", err)
+ return wechat.BuildNotifyResponse(false)
+ }
+ return s.handleIOSRefundQuery(ctx, &query)
+
+ default:
+ // 投诉/代币/风控等推送:结构化记录即可,暂不实现业务。
+ slog.WarnContext(ctx, "xpay notify ignored", "event", base.Event, "out_trade_no", base.OutTradeNo)
+ return wechat.BuildNotifyResponse(true)
+ }
+}
+
+// verifyXPayOrderPaid 反查微信侧订单真实性:消息推送无签名,发货前必须通过
+// query_order(带 HMAC 签名的服务端 API)确认订单已真实支付、金额与道具一致,
+// 防止伪造发货推送零成本获取权益。任一校验不通过返回错误(fail-closed,触发微信重试)。
+func (s *OrderService) verifyXPayOrderPaid(ctx context.Context, order *mysqlmodel.Order, deliver *wechat.XPayGoodsDeliverNotify) *errcode.Error {
+ if deliver.OpenID == "" {
+ slog.ErrorContext(ctx, "xpay deliver: empty openid", "order_no", order.OrderNo)
+ return errcode.OrderRealityCheckFailed
+ }
+
+ info, err := wechat.QueryOrder(ctx, deliver.OpenID, deliver.OutTradeNo, "")
+ if err != nil {
+ slog.ErrorContext(ctx, "xpay deliver: query order failed",
+ "order_no", order.OrderNo, "error", err)
+ return errcode.OrderRealityCheckFailed
+ }
+
+ // 微信侧无支付记录/未支付:伪造推送或数据异常,拒绝发放。
+ if !wechat.IsOrderPaidStatus(info.Status) {
+ slog.ErrorContext(ctx, "xpay deliver: order not paid in wechat side",
+ "order_no", order.OrderNo, "query_status", info.Status)
+ return errcode.OrderRealityCheckFailed
+ }
+ // 反查金额与道具必须与下单快照一致(资损防线)。
+ if info.PayFee != order.PayAmount {
+ slog.ErrorContext(ctx, "xpay deliver: query amount mismatch",
+ "order_no", order.OrderNo, "query_amount", info.PayFee, "order_amount", order.PayAmount)
+ return errcode.OrderRealityCheckFailed
+ }
+ if info.ProductID != order.XPayProductID {
+ slog.ErrorContext(ctx, "xpay deliver: query product mismatch",
+ "order_no", order.OrderNo, "query_product", info.ProductID, "order_product", order.XPayProductID)
+ return errcode.OrderRealityCheckFailed
+ }
+ return nil
+}
+
+// verifyXPayRefund 反查微信侧退款真实性:退款推送无签名,回退权益前必须通过
+// query_order(带 HMAC 签名的服务端 API)确认订单在微信侧已真实退款、金额一致,
+// 防止伪造退款推送回收权益。任一校验不通过返回错误(fail-closed,触发微信重试)。
+func (s *OrderService) verifyXPayRefund(ctx context.Context, order *mysqlmodel.Order, refund *wechat.XPayRefundNotify) *errcode.Error {
+ if refund.OpenID == "" {
+ slog.ErrorContext(ctx, "xpay refund: empty openid", "order_no", order.OrderNo)
+ return errcode.OrderRealityCheckFailed
+ }
+
+ info, err := wechat.QueryOrder(ctx, refund.OpenID, refund.OutTradeNo, "")
+ if err != nil {
+ slog.ErrorContext(ctx, "xpay refund: query order failed",
+ "order_no", order.OrderNo, "error", err)
+ return errcode.OrderRealityCheckFailed
+ }
+
+ // 微信侧无退款记录/未退款:伪造推送或数据异常,拒绝回退权益。
+ if !wechat.IsOrderRefundedStatus(info.RefundStatus) {
+ slog.ErrorContext(ctx, "xpay refund: order not refunded in wechat side",
+ "order_no", order.OrderNo, "refund_status", info.RefundStatus)
+ return errcode.OrderRealityCheckFailed
+ }
+ // 反查退款金额必须与推送一致(资损防线)。
+ if info.RefundFee != refund.RefundFee {
+ slog.ErrorContext(ctx, "xpay refund: refund amount mismatch",
+ "order_no", order.OrderNo, "query_refund_fee", info.RefundFee, "notify_refund_fee", refund.RefundFee)
+ return errcode.OrderRealityCheckFailed
+ }
+ return nil
+}
+
+// handleGoodsDeliver 处理道具发货推送:幂等锁 + 金额/道具校验 + 条件流转 + 发放权益。
+func (s *OrderService) handleGoodsDeliver(ctx context.Context, deliver *wechat.XPayGoodsDeliverNotify, raw string) *errcode.Error {
+ lockKey := fmtLockKey(redismodel.KeyLockPayCallback, deliver.OutTradeNo)
+ lockVal := genUUID()
+ if ok, _ := redisdao.TryLock(ctx, lockKey, lockVal, 30*time.Second); !ok {
+ return nil // 并发回调,交由持锁者处理。
+ }
+ defer func() { _ = redisdao.Unlock(ctx, lockKey, lockVal) }()
+
+ order, gerr := s.orderDao.OrderGetByOrderNo(ctx, deliver.OutTradeNo)
+ if gerr != nil {
+ if isNotFound(gerr) {
+ slog.ErrorContext(ctx, "xpay deliver: order not found", "out_trade_no", deliver.OutTradeNo)
+ return errcode.OrderRealityCheckFailed
+ }
+ return errcode.DatabaseError
+ }
+
+ // 仅虚拟支付订单走此链路,防止与普通支付回调串单。
+ if order.PayChannel != PayChannelVirtual {
+ slog.ErrorContext(ctx, "xpay deliver: order not virtual pay", "order_no", order.OrderNo, "pay_channel", order.PayChannel)
+ return errcode.OrderRealityCheckFailed
+ }
+ // 防篡改:道具 ID 与实付金额必须与下单快照一致。
+ if deliver.GoodsInfo.ProductID != order.XPayProductID {
+ slog.ErrorContext(ctx, "xpay deliver: product mismatch",
+ "order_no", order.OrderNo, "notify_product", deliver.GoodsInfo.ProductID, "order_product", order.XPayProductID)
+ return errcode.OrderRealityCheckFailed
+ }
+ if deliver.GoodsInfo.ActualPrice != order.PayAmount {
+ slog.ErrorContext(ctx, "xpay deliver: amount mismatch",
+ "order_no", order.OrderNo, "notify_amount", deliver.GoodsInfo.ActualPrice, "order_amount", order.PayAmount)
+ return errcode.OrderRealityCheckFailed
+ }
+ // 防环境串单:沙箱推送不得发放现网订单。
+ if int8(deliver.Env) != order.XPayEnv {
+ slog.ErrorContext(ctx, "xpay deliver: env mismatch",
+ "order_no", order.OrderNo, "notify_env", deliver.Env, "order_env", order.XPayEnv)
+ return errcode.OrderRealityCheckFailed
+ }
+
+ // 订单真实性反查:消息推送无签名,必须反查微信侧订单确认真实支付,
+ // 防止伪造发货推送零成本获取权益;未通过返回错误 -> 应答 ErrCode:1,微信重试。
+ if verr := s.verifyXPayOrderPaid(ctx, order, deliver); verr != nil {
+ return verr
+ }
+
+ // 条件流转:待支付 -> 已支付,防重复回调。
+ updated, uerr := s.orderDao.OrderUpdateStatusByOrderNo(ctx, deliver.OutTradeNo, OrderStatusPending, OrderStatusPaid)
+ if uerr != nil {
+ return errcode.DatabaseError
+ }
+ if updated {
+ s.finalizeVirtualPaidOrder(ctx, order, deliver, raw)
+ return nil
+ }
+
+ // 流转失败:处理「订单已过期但用户实际已付款」的竞态(与普通支付 PayNotify 一致)。
+ current, gerr := s.orderDao.OrderGetByOrderNo(ctx, deliver.OutTradeNo)
+ if gerr != nil {
+ if isNotFound(gerr) {
+ return nil
+ }
+ return errcode.DatabaseError
+ }
+ switch current.OrderStatus {
+ case OrderStatusPaid:
+ // 已被并发回调处理,兜底补发(幂等)。
+ _ = s.orderDao.OrderUpdateByOrderNo(ctx, current.OrderNo, map[string]any{
+ "pay_time": resolvePaidTime(deliver.WeChatPayInfo.PaidTime),
+ "transaction_id": deliver.WeChatPayInfo.TransactionID,
+ "callback_raw": raw,
+ })
+ s.grantOrderBenefits(ctx, current)
+ case OrderStatusExpired:
+ // 订单被系统过期但用户已付款:过期 -> 已支付并补发货。
+ if ok, uerr := s.orderDao.OrderUpdateStatusByOrderNo(ctx, deliver.OutTradeNo, OrderStatusExpired, OrderStatusPaid); uerr != nil {
+ return errcode.DatabaseError
+ } else if ok {
+ s.finalizeVirtualPaidOrder(ctx, current, deliver, raw)
+ }
+ case OrderStatusCancelled:
+ // 防御分支:历史上已取消的虚拟支付订单用户仍完成了支付,补发货避免「钱已扣、权益不到账」。
+ // 当前虚拟支付订单已禁止取消,此分支兜底存量数据与并发竞态。
+ if ok, uerr := s.orderDao.OrderUpdateStatusByOrderNo(ctx, deliver.OutTradeNo, OrderStatusCancelled, OrderStatusPaid); uerr != nil {
+ return errcode.DatabaseError
+ } else if ok {
+ s.finalizeVirtualPaidOrder(ctx, current, deliver, raw)
+ }
+ }
+ return nil
+}
+
+// handleRefundNotify 处理退款推送:复用退款成功回退权益链路(settleRefundSuccess)。
+func (s *OrderService) handleRefundNotify(ctx context.Context, refund *wechat.XPayRefundNotify) *errcode.Error {
+ lockKey := fmtLockKey(redismodel.KeyLockRefundCallback, refund.RefundOrderID)
+ lockVal := genUUID()
+ if ok, _ := redisdao.TryLock(ctx, lockKey, lockVal, 30*time.Second); !ok {
+ return nil
+ }
+ defer func() { _ = redisdao.Unlock(ctx, lockKey, lockVal) }()
+
+ // 白名单判断:仅「退款成功」才回退权益;空串/未知/中间状态仅记录,防止误回退
+ // (退款推送按官方文档在退款完成时触发,状态枚举联调时以官方为准)。
+ if refund.RefundStatus != wechat.XPayRefundStatusSuccess {
+ slog.WarnContext(ctx, "xpay refund notify not success, skip revoke",
+ "order_no", refund.OutTradeNo, "refund_status", refund.RefundStatus)
+ return nil
+ }
+
+ order, gerr := s.orderDao.OrderGetByOrderNo(ctx, refund.OutTradeNo)
+ if gerr != nil {
+ if isNotFound(gerr) {
+ slog.ErrorContext(ctx, "xpay refund: order not found", "out_trade_no", refund.OutTradeNo)
+ return nil
+ }
+ return errcode.DatabaseError
+ }
+ // 已终态则幂等返回。
+ if order.OrderStatus == OrderStatusRefunded {
+ return nil
+ }
+
+ // 订单真实性反查:退款推送无签名,回退权益前必须反查微信侧确认真实退款,
+ // 防止伪造退款推送回收权益;未通过返回错误 -> 应答 ErrCode:1,微信重试。
+ if verr := s.verifyXPayRefund(ctx, order, refund); verr != nil {
+ return verr
+ }
+
+ // settleRefundSuccess 内部含条件流转(Paid -> Refunded),重复推送不会重复回退。
+ s.settleRefundSuccess(ctx, order, refund.RefundFee)
+ return nil
+}
+
+// handleIOSRefundQuery 处理 iOS 退款问询(3 秒内应答,仅本地查询)。
+// 按权益使用情况细化:权益已消耗则拒绝退款(result_code=1 + evidence),未消耗则建议退款;
+// 建议退款后权益由后续退款推送 settleRefundSuccess 回收(回收链路保证「已消耗部分不回退」)。
+func (s *OrderService) handleIOSRefundQuery(ctx context.Context, query *wechat.XPayIOSRefundQueryNotify) []byte {
+ order, err := s.orderDao.OrderGetByOrderNo(ctx, query.OutTradeNo)
+ if err != nil {
+ slog.ErrorContext(ctx, "xpay ios refund query: order not found", "out_trade_no", query.OutTradeNo, "error", err)
+ return wechat.BuildIOSRefundQueryResponse(1, "order not found")
+ }
+ if order.OrderStatus == OrderStatusRefunded {
+ return wechat.BuildIOSRefundQueryResponse(0, "already refunded")
+ }
+ // 权益已消耗则拒绝退款,防止「已使用权益后白嫖退款」。
+ if s.iosRefundBenefitsConsumed(ctx, order) {
+ return wechat.BuildIOSRefundQueryResponse(1, "权益已使用,不支持退款")
+ }
+ return wechat.BuildIOSRefundQueryResponse(0, order.OrderNo)
+}
+
+// iosRefundBenefitsConsumed 判断订单发放的权益是否已被消耗(用于 iOS 退款问询拒绝策略)。
+// 额度类:Cos 凭证已用 >0 视为已消耗;会员类:会员已生效(在有效期内)视为已使用。
+func (s *OrderService) iosRefundBenefitsConsumed(ctx context.Context, order *mysqlmodel.Order) bool {
+ // 额度类:该订单发放的 Cos 凭证已消耗则视为已使用。
+ if order.CosQuota > 0 {
+ grants, err := mysqldao.NewQuotaGrantDao().QuotaGrantListByUserIDAndType(ctx, order.UserID, QuotaTypeCos)
+ if err == nil {
+ for _, g := range grants {
+ if g.RefID == order.OrderNo && g.Used > 0 {
+ return true
+ }
+ }
+ }
+ }
+ // 会员类:会员已生效(vip_expire 在未来且非非会员),说明用户已开始享受会员权益。
+ if order.MemberDays > 0 {
+ if user, err := s.userDao.UserGetByID(ctx, order.UserID); err == nil {
+ if user.VIPExpire > nowUnix() && user.VIPType != VIPTypeNone {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// finalizeVirtualPaidOrder 虚拟支付发货:记录支付信息、发放权益并发送通知。
+func (s *OrderService) finalizeVirtualPaidOrder(ctx context.Context, order *mysqlmodel.Order, deliver *wechat.XPayGoodsDeliverNotify, raw string) {
+ _ = s.orderDao.OrderUpdateByOrderNo(ctx, order.OrderNo, map[string]any{
+ "pay_time": resolvePaidTime(deliver.WeChatPayInfo.PaidTime),
+ "transaction_id": deliver.WeChatPayInfo.TransactionID,
+ "callback_raw": raw,
+ })
+
+ s.grantOrderBenefits(ctx, order)
+
+ sendNotification(ctx, order.UserID, 0, NotificationTypeOrder, "订单支付成功", "你购买的「"+order.GoodsName+"」已到账")
+}
+
+// resolvePaidTime 兜底支付时间:推送字段缺失/非法(<=0)时用当前时间,避免 pay_time 写入 0。
+func resolvePaidTime(paidTime int64) int64 {
+ if paidTime <= 0 {
+ return nowUnix()
+ }
+ return paidTime
+}
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..2c0980b
--- /dev/null
+++ b/main.go
@@ -0,0 +1,349 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/routers"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/service"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/cos"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/setting"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/tokenhub"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/tracing"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/wechat"
+ "github.com/gin-gonic/gin"
+ "go.opentelemetry.io/contrib/bridges/otelslog"
+ "go.opentelemetry.io/otel"
+ "gopkg.in/natefinch/lumberjack.v2"
+)
+
+func init() {
+ if err := setupSetting(); err != nil {
+ panic(err)
+ }
+
+ // OTel 需在 logger 之前初始化,使 LoggerProvider 就绪后 slog 可桥接到 OTel Logs。
+ setupOTel()
+
+ if err := setupSlogLogger(); err != nil {
+ panic(err)
+ }
+
+ if err := setupMysqlEngine(); err != nil {
+ panic(err)
+ }
+
+ if err := setupRedisEngine(); err != nil {
+ panic(err)
+ }
+
+ setupCosClient()
+ setupWechatPayClient()
+ setupVirtualPayClient()
+}
+
+func main() {
+ gin.SetMode(global.ServerSetting.RunMode)
+
+ // 启动运势后台任务:每日 0 点生成 + 启动兜底。
+ tokenhub.EnsureTodayFortunes()
+ tokenhub.StartFortuneScheduler()
+
+ // 启动权益发放补偿与订单超时过期后台任务。
+ service.StartBenefitCompensationScheduler()
+ service.StartOrderExpireScheduler()
+
+ router := routers.NewRouter()
+
+ s := &http.Server{
+ Addr: ":" + global.ServerSetting.HttpPort,
+ Handler: router,
+ ReadTimeout: time.Duration(global.ServerSetting.ReadTimeout) * time.Second,
+ WriteTimeout: time.Duration(global.ServerSetting.WriteTimeout) * time.Second,
+
+ // 限制http请求头的大小为1MB
+ MaxHeaderBytes: 1 << 20,
+ }
+
+ // 后台启动 HTTP 服务;启动失败(如端口占用)通过 channel 反馈主流程,避免进程「假存活」。
+ serverErr := make(chan error, 1)
+ go func() {
+ if err := s.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ serverErr <- err
+ }
+ }()
+ slog.Info("server started", "port", global.ServerSetting.HttpPort)
+
+ // 等待退出信号或服务启动错误。
+ quit := make(chan os.Signal, 1)
+ signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
+
+ select {
+ case <-quit:
+ slog.Info("shutting down server...")
+ case err := <-serverErr:
+ slog.Error("http server start failed", "error", err)
+ }
+
+ // 优雅关闭:HTTP 与 OTel 使用独立 context,避免 HTTP 等待耗尽 OTel flush 的超时。
+ shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer shutdownCancel()
+ if err := s.Shutdown(shutdownCtx); err != nil {
+ slog.Error("http server shutdown error", "error", err)
+ }
+
+ if global.OTelShutdown != nil {
+ otelCtx, otelCancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer otelCancel()
+ if err := global.OTelShutdown(otelCtx); err != nil {
+ slog.Error("otel shutdown error", "error", err)
+ }
+ }
+ slog.Info("server exited")
+}
+
+func setupSetting() error {
+ setting, err := setting.NewSetting()
+ if err != nil {
+ return err
+ }
+
+ err = setting.ReadSection("Server", &global.ServerSetting)
+ if err != nil {
+ return err
+ }
+
+ err = setting.ReadSection("Log", &global.LogSetting)
+ if err != nil {
+ return err
+ }
+
+ err = setting.ReadSection("Mysql", &global.MysqlSetting)
+ if err != nil {
+ return err
+ }
+
+ err = setting.ReadSection("Redis", &global.RedisSetting)
+ if err != nil {
+ return err
+ }
+
+ err = setting.ReadSection("Otel", &global.OtelSetting)
+ if err != nil {
+ return err
+ }
+
+ err = setting.ReadSection("JWT", &global.JWTSetting)
+ if err != nil {
+ return err
+ }
+ // 校验 JWT 关键配置,防止密钥缺失导致可用空密钥伪造 token。
+ if global.JWTSetting == nil || global.JWTSetting.SecretKey == "" {
+ return fmt.Errorf("JWT secret key must not be empty")
+ }
+
+ err = setting.ReadSection("Cos", &global.CosSetting)
+ if err != nil {
+ return err
+ }
+
+ err = setting.ReadSection("Upload", &global.UploadSetting)
+ if err != nil {
+ return err
+ }
+ if global.UploadSetting.MaxSizeMB <= 0 {
+ global.UploadSetting.MaxSizeMB = 20
+ }
+
+ err = setting.ReadSection("Tokenhub", &global.TokenhubSetting)
+ if err != nil {
+ return err
+ }
+ if global.TokenhubSetting.Model == "" {
+ global.TokenhubSetting.Model = "hy4"
+ }
+
+ err = setting.ReadSection("MattingModel", &global.MattingModelSetting)
+ if err != nil {
+ return err
+ }
+ if global.MattingModelSetting.MaxRetry <= 0 {
+ global.MattingModelSetting.MaxRetry = 3
+ }
+
+ err = setting.ReadSection("ClassificationModel", &global.ClassificationModelSetting)
+ if err != nil {
+ return err
+ }
+ if global.ClassificationModelSetting.MaxRetry <= 0 {
+ global.ClassificationModelSetting.MaxRetry = 3
+ }
+
+ err = setting.ReadSection("WechatMini", &global.WechatMiniSetting)
+ if err != nil {
+ return err
+ }
+
+ err = setting.ReadSection("WechatPay", &global.WechatPaySetting)
+ if err != nil {
+ return err
+ }
+
+ err = setting.ReadSection("UserDefaultQuota", &global.UserDefaultQuotaSetting)
+ if err != nil {
+ return err
+ }
+
+ err = setting.ReadSection("AdReward", &global.AdRewardSetting)
+ if err != nil {
+ return err
+ }
+
+ err = setting.ReadSection("WechatVirtualPay", &global.VirtualPaySetting)
+ if err != nil {
+ return err
+ }
+
+
+ // 校验 Server 配置(时间字段已按秒解析,此处仅做存在性校验)。
+ if global.ServerSetting == nil {
+ return fmt.Errorf("Server config section is missing")
+ }
+
+ return nil
+}
+
+func setupSlogLogger() error {
+ // 确保日志目录存在(lumberjack 不会自动创建父目录,目录缺失时文件日志会静默失效)。
+ if err := os.MkdirAll(global.LogSetting.LogSavePath, 0o755); err != nil {
+ return fmt.Errorf("create log dir: %w", err)
+ }
+
+ writer := &lumberjack.Logger{
+ Filename: global.LogSetting.LogSavePath + "/" + global.LogSetting.LogFileName + global.LogSetting.LogFileExt,
+ MaxSize: global.LogSetting.MaxSize,
+ MaxBackups: global.LogSetting.MaxBackups,
+ MaxAge: global.LogSetting.MaxAge,
+ Compress: true,
+ }
+
+ multiWriter := io.MultiWriter(writer, os.Stdout)
+
+ level := slog.LevelInfo
+ switch global.LogSetting.LogLevel {
+ case "Debug":
+ level = slog.LevelDebug
+ case "Warn":
+ level = slog.LevelWarn
+ case "Error":
+ level = slog.LevelError
+ default:
+ level = slog.LevelInfo
+ }
+
+ // 本地 JSON 日志(文件 + stdout),并注入 trace_id/span_id。
+ localHandler := tracing.OtelContextHandler{
+ Handler: slog.NewJSONHandler(multiWriter, &slog.HandlerOptions{
+ AddSource: true,
+ Level: level,
+ }),
+ }
+
+ // OTel 日志桥接:将 slog 记录导出到 OTel Logs(LoggerProvider 已在 setupOTel 就绪;
+ // 未启用 OTel 时为 no-op,不产生开销)。
+ otelHandler := otelslog.NewHandler("zt-closet-backend")
+
+ // 扇出:本地日志 + OTel 日志同时输出。
+ slog.SetDefault(slog.New(tracing.NewFanoutHandler(localHandler, otelHandler)))
+
+ return nil
+}
+
+// setupMysqlEngine 初始化 MySQL 数据库连接。
+func setupMysqlEngine() error {
+ var err error
+ global.MysqlEngine, err = mysqlmodel.NewMysqlEngine(global.MysqlSetting)
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+// setupRedisEngine 初始化 Redis 连接。
+func setupRedisEngine() error {
+ var err error
+ global.RedisEngine, err = redismodel.NewRedisEngine(global.RedisSetting)
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func setupOTel() {
+ tracer, shutdown, err := tracing.NewOTelProvider(global.OtelSetting)
+ // NewOTelProvider 在任何错误路径都会返回可用的 no-op tracer 与安全 shutdown。
+ global.Tracer = tracer
+ global.OTelShutdown = shutdown
+ if err != nil {
+ // 此时 logger 尚未就绪,用标准输出告警;已降级为 no-op。
+ fmt.Printf("[WARN] init otel failed, downgraded to no-op: %v\n", err)
+ }
+
+ name := "zt-closet-backend"
+ if global.OtelSetting != nil && global.OtelSetting.ServiceName != "" {
+ name = global.OtelSetting.ServiceName
+ }
+ global.Meter = otel.Meter(name)
+}
+
+// setupCosClient 初始化腾讯云 COS 客户端(衣物图片上传依赖)。
+// 初始化失败仅告警,不阻断服务启动。
+func setupCosClient() {
+ client, err := cos.NewCosClient()
+ if err != nil {
+ slog.Warn("init cos client failed, upload features may fail", "error", err)
+ return
+ }
+ global.CosClient = client
+}
+
+// setupWechatPayClient 初始化微信支付客户端。
+// 未启用(Enable=false)或未配置时仅告警,不阻断服务启动(便于本地/未开通支付的开发环境)。
+func setupWechatPayClient() {
+ if global.WechatPaySetting == nil || !global.WechatPaySetting.Enable {
+ slog.Warn("wechat pay not enabled, pay features disabled")
+ return
+ }
+ if err := wechat.InitPayClient(global.WechatPaySetting); err != nil {
+ slog.Warn("init wechat pay client failed, pay features disabled", "error", err)
+ return
+ }
+ slog.Info("wechat pay client initialized")
+}
+
+// setupVirtualPayClient 初始化微信小程序虚拟支付(XPay)客户端。
+// 未开通(Enable=false)或配置不完整时仅告警,不阻断服务启动;虚拟商品下单时会返回「虚拟支付未开通」。
+func setupVirtualPayClient() {
+ if global.VirtualPaySetting == nil || !global.VirtualPaySetting.Enable {
+ slog.Warn("virtual pay not enabled, virtual goods purchase disabled")
+ return
+ }
+ if err := wechat.InitVirtualPayClient(global.VirtualPaySetting); err != nil {
+ slog.Warn("init virtual pay client failed, virtual goods purchase disabled", "error", err)
+ return
+ }
+ env, _ := wechat.GetVirtualPayEnv()
+ slog.Info("virtual pay client initialized", "env", env, "env_name", map[int]string{0: "prod", 1: "sandbox"}[env])
+}
diff --git a/pkg/app/response.go b/pkg/app/response.go
new file mode 100644
index 0000000..e42aa93
--- /dev/null
+++ b/pkg/app/response.go
@@ -0,0 +1,41 @@
+package app
+
+import (
+ "net/http"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
+ "github.com/gin-gonic/gin"
+)
+
+// Response 统一响应包装
+type Response struct {
+ Ctx *gin.Context
+}
+
+func NewResponse(c *gin.Context) *Response {
+ return &Response{Ctx: c}
+}
+
+// ToResponse 成功响应
+func (r *Response) ToResponse(data interface{}) {
+ if data == nil {
+ data = gin.H{}
+ }
+ r.Ctx.JSON(http.StatusOK, gin.H{
+ "code": errcode.Success.Code(),
+ "msg": errcode.Success.Msg(),
+ "data": data,
+ })
+}
+
+// ToErrorResponse 错误响应
+func (r *Response) ToErrorResponse(err *errcode.Error) {
+ response := gin.H{
+ "code": err.Code(),
+ "msg": err.Msg(),
+ }
+ if details := err.Details(); len(details) > 0 {
+ response["details"] = details
+ }
+ r.Ctx.JSON(err.StatusCode(), response)
+}
diff --git a/pkg/classification/classification.go b/pkg/classification/classification.go
new file mode 100644
index 0000000..cd4f8e1
--- /dev/null
+++ b/pkg/classification/classification.go
@@ -0,0 +1,174 @@
+// Package classification 封装衣物分类服务调用,将衣物图片上传到 Fashion-CLIP 分类服务,
+// 返回结构化的衣物属性分类结果。
+package classification
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "time"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+)
+
+const (
+ classifyPath = "/classify"
+ healthzPath = "/healthz"
+)
+
+// DetailItem 分类明细项。
+type DetailItem struct {
+ Label string `json:"label"`
+ Confidence float64 `json:"confidence"`
+}
+
+// Details 各维度 Top-5 明细。
+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"`
+}
+
+// ClassifyResult 衣物分类结果。
+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"`
+}
+
+// Client 分类服务客户端。
+type Client struct {
+ baseURL string
+ httpClient *http.Client
+}
+
+// NewClient 从全局配置创建客户端。
+func NewClient() *Client {
+ return NewClientWithAddr(global.ClassificationModelSetting.Host, global.ClassificationModelSetting.Port)
+}
+
+// NewClientWithAddr 使用指定 Host/Port 创建客户端,便于测试与解耦。
+func NewClientWithAddr(host string, port int) *Client {
+ return &Client{
+ baseURL: fmt.Sprintf("http://%s:%d", host, port),
+ httpClient: &http.Client{
+ Timeout: 120 * time.Second,
+ },
+ }
+}
+
+// Classify 调用分类服务,上传图片并返回结构化的衣物分类结果。
+func (c *Client) Classify(ctx context.Context, imageData []byte) (*ClassifyResult, error) {
+ var body bytes.Buffer
+ writer := multipart.NewWriter(&body)
+
+ part, err := writer.CreateFormFile("file", "image.png")
+ if err != nil {
+ return nil, fmt.Errorf("classify create form file: %w", err)
+ }
+ if _, err := part.Write(imageData); err != nil {
+ return nil, fmt.Errorf("classify write image: %w", err)
+ }
+ if err := writer.Close(); err != nil {
+ return nil, fmt.Errorf("classify close multipart: %w", err)
+ }
+
+ request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+classifyPath, &body)
+ if err != nil {
+ return nil, fmt.Errorf("classify create request: %w", err)
+ }
+ request.Header.Set("Content-Type", writer.FormDataContentType())
+
+ response, err := c.httpClient.Do(request)
+ if err != nil {
+ return nil, fmt.Errorf("classify do request: %w", err)
+ }
+ defer response.Body.Close()
+
+ data, err := io.ReadAll(response.Body)
+ if err != nil {
+ return nil, fmt.Errorf("classify read response: %w", err)
+ }
+
+ if response.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("classify api error: status=%d, body=%s", response.StatusCode, summarize(data))
+ }
+
+ var result ClassifyResult
+ if err := json.Unmarshal(data, &result); err != nil {
+ return nil, fmt.Errorf("classify decode response: %w", err)
+ }
+ return &result, nil
+}
+
+// HealthCheck 检查分类服务是否就绪。
+func (c *Client) HealthCheck(ctx context.Context) error {
+ request, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+healthzPath, nil)
+ if err != nil {
+ return fmt.Errorf("classify create healthz request: %w", err)
+ }
+
+ response, err := c.httpClient.Do(request)
+ if err != nil {
+ return fmt.Errorf("classify healthz request: %w", err)
+ }
+ defer response.Body.Close()
+
+ if response.StatusCode != http.StatusOK {
+ return fmt.Errorf("classify healthz status: %d", response.StatusCode)
+ }
+ return nil
+}
+
+// summarize 截断响应体摘要,避免错误信息打印大体积 JSON。
+func summarize(data []byte) string {
+ const maxLen = 512
+ if len(data) > maxLen {
+ return string(data[:maxLen]) + "..."
+ }
+ return string(data)
+}
diff --git a/pkg/classification/classification_test.go b/pkg/classification/classification_test.go
new file mode 100644
index 0000000..c23dc22
--- /dev/null
+++ b/pkg/classification/classification_test.go
@@ -0,0 +1,65 @@
+package classification
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+// TestClassify 使用仓库根目录的 image.png 调用真实分类服务进行端到端验证。
+func TestClassify(t *testing.T) {
+ client := NewClientWithAddr("127.0.0.1", 8000)
+ ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
+ defer cancel()
+
+ // 前置:确认分类服务已就绪(模型加载完成后 /healthz 才会响应)
+ if err := client.HealthCheck(ctx); err != nil {
+ t.Skipf("分类服务未就绪,跳过真实分类测试: %v", err)
+ }
+
+ imgPath := filepath.Join("..", "..", "image.png")
+ raw, err := os.ReadFile(imgPath)
+ if err != nil {
+ t.Fatalf("读取 image.png 失败: %v", err)
+ }
+
+ result, err := client.Classify(ctx, raw)
+ if err != nil {
+ t.Fatalf("Classify 失败: %v", err)
+ }
+
+ if result == nil {
+ t.Fatal("分类返回空结果")
+ }
+ if result.CategoryMain == "" {
+ t.Fatal("分类大类 category_main 为空")
+ }
+ if !validLevel(result.Formality) {
+ t.Fatalf("正式程度 formality 应为 1~5,实际 %q", result.Formality)
+ }
+ if !validLevel(result.VisualImpact) {
+ t.Fatalf("视觉冲击力 visual_impact 应为 1~5,实际 %q", result.VisualImpact)
+ }
+
+ t.Logf("分类成功: category_main=%s(%.3f), category_sub=%s(%.3f), style=%s(%.3f), color_main=%s(%.3f)/%s(%.3f), formality=%s(%s), visual_impact=%s(%s), occasions=%s",
+ result.CategoryMain, result.CategoryMainConfidence,
+ result.CategorySub, result.CategorySubConfidence,
+ result.Style, result.StyleConfidence,
+ result.ColorMain, result.ColorMainConfidence,
+ result.ColorSub, result.ColorSubConfidence,
+ result.Formality, result.FormalityLabel,
+ result.VisualImpact, result.VisualImpactLabel,
+ result.Occasions)
+}
+
+// validLevel 校验 1-5 等级字符串是否合法。
+func validLevel(s string) bool {
+ switch s {
+ case "1", "2", "3", "4", "5":
+ return true
+ default:
+ return false
+ }
+}
diff --git a/pkg/cos/cosclient.go b/pkg/cos/cosclient.go
new file mode 100644
index 0000000..e90f195
--- /dev/null
+++ b/pkg/cos/cosclient.go
@@ -0,0 +1,26 @@
+package cos
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "github.com/tencentyun/cos-go-sdk-v5"
+)
+
+func NewCosClient() (*cos.Client, error) {
+ u, err := url.Parse("https://" + global.CosSetting.CosUploadDomain)
+ if err != nil {
+ return nil, fmt.Errorf("parse cos upload domain: %w", err)
+ }
+ b := &cos.BaseURL{BucketURL: u}
+ client := cos.NewClient(b, &http.Client{
+ Transport: &cos.AuthorizationTransport{
+ SecretID: global.CosSetting.CosSecretID,
+ SecretKey: global.CosSetting.CosSecretKey,
+ },
+ })
+
+ return client, nil
+}
diff --git a/pkg/errcode/clothes_code.go b/pkg/errcode/clothes_code.go
new file mode 100644
index 0000000..23a9184
--- /dev/null
+++ b/pkg/errcode/clothes_code.go
@@ -0,0 +1,11 @@
+package errcode
+
+// 衣物相关错误码段位:50000 - 59999
+var (
+ ClothesNotFound = NewError(50000, "衣物不存在")
+ ClothesInUse = NewError(50001, "衣服已被搭配使用,无法删除")
+ ClothesCreateFailed = NewError(50002, "创建衣物失败")
+ ClothesDeleteFailed = NewError(50003, "删除衣物失败")
+ ClothesUpdateFailed = NewError(50004, "更新衣物失败")
+ ClothesNoPermission = NewError(50005, "无权操作该衣物")
+)
diff --git a/pkg/errcode/common_code.go b/pkg/errcode/common_code.go
new file mode 100644
index 0000000..981b178
--- /dev/null
+++ b/pkg/errcode/common_code.go
@@ -0,0 +1,43 @@
+package errcode
+
+// 公共错误码段位:10000 - 19999
+//
+// 细分:
+// 10000 - 10999 通用 / 参数错误
+// 11000 - 11999 鉴权 / Token
+// 12000 - 12999 权限 / 资源
+// 19000 - 19999 系统级错误(DB、缓存、RPC)
+
+// 成功
+var Success = NewError(0, "成功")
+
+// ===== 通用错误(10000 - 10999) =====
+var (
+ InvalidParams = NewError(10000, "请求参数错误")
+ NotFound = NewError(10001, "资源不存在")
+ MethodNotAllow = NewError(10002, "请求方法不允许")
+ TooManyRequests = NewError(10003, "请求过于频繁")
+)
+
+// ===== 鉴权 / Token(11000 - 11999) =====
+var (
+ Unauthorized = NewError(11000, "未授权")
+ UnauthorizedTokenError = NewError(11001, "Token 鉴权失败")
+ UnauthorizedTokenTimeout = NewError(11002, "Token 已过期")
+ UnauthorizedTokenGenerate = NewError(11003, "Token 生成失败")
+ UnauthorizedTokenMissing = NewError(11004, "Token 缺失")
+)
+
+// ===== 权限(12000 - 12999) =====
+var (
+ Forbidden = NewError(12000, "无权限访问")
+)
+
+// ===== 系统错误(19000 - 19999) =====
+var (
+ ServerError = NewError(19000, "服务内部错误")
+ DatabaseError = NewError(19001, "数据库错误")
+ CacheError = NewError(19002, "缓存错误")
+ RPCError = NewError(19003, "下游服务调用失败")
+ UnknownError = NewError(19999, "未知错误")
+)
diff --git a/pkg/errcode/errcode.go b/pkg/errcode/errcode.go
new file mode 100644
index 0000000..9aa094e
--- /dev/null
+++ b/pkg/errcode/errcode.go
@@ -0,0 +1,79 @@
+package errcode
+
+import (
+ "fmt"
+ "net/http"
+)
+
+// Error 业务错误
+type Error struct {
+ code int
+ msg string
+ details []string
+}
+
+// codes 用于注册时去重检查
+var codes = map[int]string{}
+
+// NewError 注册并创建一个错误码
+func NewError(code int, msg string) *Error {
+ if _, exists := codes[code]; exists {
+ panic(fmt.Sprintf("error code %d already exists, please change", code))
+ }
+ codes[code] = msg
+ return &Error{code: code, msg: msg}
+}
+
+func (e *Error) Error() string {
+ return fmt.Sprintf("error code: %d, msg: %s", e.code, e.msg)
+}
+
+func (e *Error) Code() int { return e.code }
+func (e *Error) Msg() string { return e.msg }
+func (e *Error) Details() []string { return e.details }
+
+func (e *Error) Msgf(args ...interface{}) string {
+ return fmt.Sprintf(e.msg, args...)
+}
+
+// WithDetails 附加详细信息(返回副本)
+func (e *Error) WithDetails(details ...string) *Error {
+ newError := *e
+ newError.details = append(newError.details, details...)
+ return &newError
+}
+
+// StatusCode 业务码 → HTTP 状态码
+// 仅对少数特殊错误返回非 200,其余统一 200,由前端按业务 code 处理
+func (e *Error) StatusCode() int {
+ switch e.code {
+ case Success.Code():
+ return http.StatusOK
+
+ // 鉴权类返回 401,便于前端拦截器统一处理跳转登录
+ case Unauthorized.Code(),
+ UnauthorizedTokenError.Code(),
+ UnauthorizedTokenTimeout.Code(),
+ UnauthorizedTokenMissing.Code():
+ return http.StatusUnauthorized
+
+ // 越权/无权限返回 403,便于网关与审计识别越权事件
+ case Forbidden.Code():
+ return http.StatusForbidden
+
+ // 限流返回 429,便于网关识别限流事件
+ case TooManyRequests.Code():
+ return http.StatusTooManyRequests
+
+ // 系统错误返回 500,便于运维监控告警
+ case ServerError.Code(),
+ DatabaseError.Code(),
+ CacheError.Code(),
+ RPCError.Code(),
+ UnknownError.Code(),
+ MattingFailed.Code(),
+ ClassifyFailed.Code():
+ return http.StatusInternalServerError
+ }
+ return http.StatusOK
+}
diff --git a/pkg/errcode/goods_code.go b/pkg/errcode/goods_code.go
new file mode 100644
index 0000000..ec0033a
--- /dev/null
+++ b/pkg/errcode/goods_code.go
@@ -0,0 +1,13 @@
+package errcode
+
+// 商品模块错误码段位:60000 - 69999
+//
+// 60000 - 60999 业务错误
+// 69000 - 69999 服务端错误
+var (
+ GoodsNotFound = NewError(60000, "商品不存在")
+ GoodsOffShelf = NewError(60001, "商品已下架")
+ GoodsCreateFailed = NewError(69000, "创建商品失败")
+ GoodsUpdateFailed = NewError(69001, "更新商品失败")
+ GoodsDeleteFailed = NewError(69002, "删除商品失败")
+)
diff --git a/pkg/errcode/misc_code.go b/pkg/errcode/misc_code.go
new file mode 100644
index 0000000..cf00bc4
--- /dev/null
+++ b/pkg/errcode/misc_code.go
@@ -0,0 +1,24 @@
+package errcode
+
+// 通知/分享/社区/星座/AI 模块错误码段位:90000 - 99999
+var (
+ // 通知(90000 - 90099)
+ NotificationNotFound = NewError(90000, "通知不存在")
+
+ // 分享(90100 - 90199)
+ ShareNotFound = NewError(90100, "分享不存在")
+ ShareExpired = NewError(90101, "分享已过期")
+ ShareCancelled = NewError(90102, "分享已取消")
+
+ // 点赞/收藏/社区(90200 - 90299)
+ TargetNotFound = NewError(90200, "目标内容不存在")
+ LikeFailed = NewError(90201, "点赞操作失败")
+ CollectFailed = NewError(90202, "收藏操作失败")
+
+ // 星座(90300 - 90399)
+ HoroscopeNotFound = NewError(90300, "星座运势不存在")
+
+ // AI 推荐(90400 - 90499)
+ AIRecommendFailed = NewError(90400, "AI 推荐失败")
+ AIRecommendQuotaLimit = NewError(90401, "AI 推荐次数已达上限")
+)
diff --git a/pkg/errcode/order_code.go b/pkg/errcode/order_code.go
new file mode 100644
index 0000000..aebe956
--- /dev/null
+++ b/pkg/errcode/order_code.go
@@ -0,0 +1,31 @@
+package errcode
+
+// 订单模块错误码段位:70000 - 70999
+// 退款子模块:71000 - 71999
+// 服务端错误:79000 - 79999
+var (
+ // 订单
+ OrderNotFound = NewError(70000, "订单不存在")
+ OrderStatusInvalid = NewError(70001, "订单状态不正确")
+ OrderAlreadyPaid = NewError(70002, "订单已支付")
+ OrderAlreadyCancelled = NewError(70003, "订单已取消")
+ OrderExpired = NewError(70004, "订单已过期")
+ OrderCreateFailed = NewError(79000, "创建订单失败")
+ OrderCancelFailed = NewError(79001, "取消订单失败")
+ OrderUpdateFailed = NewError(79002, "更新订单失败")
+
+ // 虚拟支付
+ VirtualPayNotEnabled = NewError(70005, "虚拟支付未开通")
+ VirtualPayGoodsNotPublished = NewError(70006, "商品尚未发布为虚拟支付道具")
+ VirtualOrderCancelNotAllowed = NewError(70007, "虚拟支付订单不支持取消")
+ VirtualPayAmountTooLow = NewError(70008, "iOS 虚拟支付最低 1 元")
+ OrderRealityCheckFailed = NewError(70009, "订单真实性校验失败")
+ VirtualOrderCreateFailed = NewError(79003, "创建虚拟支付订单失败")
+
+ // 退款
+ RefundNotFound = NewError(71000, "退款记录不存在")
+ RefundNotAllowed = NewError(71001, "当前状态不允许退款")
+ RefundAmountInvalid = NewError(71002, "退款金额不合法")
+ RefundApplyFailed = NewError(71003, "退款申请失败")
+ IOSRefundViaAppStore = NewError(71004, "iOS 订单请通过 App Store 申请退款")
+)
diff --git a/pkg/errcode/outfit_code.go b/pkg/errcode/outfit_code.go
new file mode 100644
index 0000000..e0e3469
--- /dev/null
+++ b/pkg/errcode/outfit_code.go
@@ -0,0 +1,10 @@
+package errcode
+
+// 穿搭相关错误码段位:40000 - 49999
+var (
+ OutfitNotFound = NewError(40000, "穿搭不存在")
+ OutfitCreateFailed = NewError(40001, "创建穿搭失败")
+ OutfitDeleteFailed = NewError(40002, "删除穿搭失败")
+ OutfitUpdateFailed = NewError(40003, "更新穿搭失败")
+ OutfitNoPermission = NewError(40004, "无权操作该穿搭")
+)
diff --git a/pkg/errcode/quota_code.go b/pkg/errcode/quota_code.go
new file mode 100644
index 0000000..8072067
--- /dev/null
+++ b/pkg/errcode/quota_code.go
@@ -0,0 +1,19 @@
+package errcode
+
+// 额度模块错误码段位:80000 - 80999
+// VIP 子模块:81000 - 81999
+// 服务端错误:89000 - 89999
+var (
+ // 额度
+ QuotaInsufficient = NewError(80000, "额度不足")
+ QuotaNotFound = NewError(80001, "额度记录不存在")
+ AdRewardClaimed = NewError(80002, "广告激励已领取")
+ AdRewardInvalid = NewError(80003, "无效的激励类型")
+ AdRewardDisabled = NewError(80004, "广告激励未开启")
+ QuotaUpdateFailed = NewError(89000, "额度更新失败")
+
+ // VIP
+ VIPNotFound = NewError(81000, "会员记录不存在")
+ VIPExpired = NewError(81001, "会员已过期")
+ VIPCreateFailed = NewError(81002, "会员开通失败")
+)
diff --git a/pkg/errcode/upload_code.go b/pkg/errcode/upload_code.go
new file mode 100644
index 0000000..dcf4b25
--- /dev/null
+++ b/pkg/errcode/upload_code.go
@@ -0,0 +1,18 @@
+package errcode
+
+// 上传模块错误码段位:30000-39999
+// 30000-30999 参数/校验
+// 39000-39999 服务端错误
+
+var (
+ UploadFileEmpty = NewError(30000, "上传文件为空")
+ UploadFileExtNotAllow = NewError(30001, "不支持的文件类型")
+ UploadFileTooLarge = NewError(30002, "文件大小超过限制")
+ UploadFileNotFound = NewError(30004, "文件不存在")
+ UploadFileSaveFailed = NewError(39000, "文件保存失败")
+ FileNotPublic = NewError(30005, "文件不是公开的")
+ FileNoPermission = NewError(30006, "权限不足")
+ FileProcessing = NewError(30007, "文件处理中")
+ MattingFailed = NewError(39001, "图片抠图处理失败")
+ ClassifyFailed = NewError(39002, "图片分类处理失败")
+)
diff --git a/pkg/errcode/user_code.go b/pkg/errcode/user_code.go
new file mode 100644
index 0000000..430e321
--- /dev/null
+++ b/pkg/errcode/user_code.go
@@ -0,0 +1,43 @@
+package errcode
+
+// 用户模块错误码段位:20000 - 29999
+//
+// 细分:
+// 20000 - 20999 参数错误
+// 21000 - 21999 鉴权 / 登录
+// 22000 - 22999 资源不存在
+// 23000 - 23999 冲突 / 重复
+// 29000 - 29999 服务端错误
+
+// ===== 参数错误(20000 - 20999) =====
+var (
+ UserParamsInvalid = NewError(20000, "用户参数错误")
+)
+
+// ===== 鉴权 / 登录(21000 - 21999) =====
+var (
+ UserLoginFailed = NewError(21000, "用户名或密码错误")
+ UserDisabled = NewError(21001, "账号已被禁用")
+ UserWxLoginFailed = NewError(21002, "微信登录失败")
+ UserWxCodeInvalid = NewError(21003, "微信 Code 无效")
+ UserRegistrationDisabled = NewError(21004, "注册暂未开放")
+)
+
+// ===== 资源不存在(22000 - 22999) =====
+var (
+ UserNotFound = NewError(22000, "用户不存在")
+)
+
+// ===== 冲突(23000 - 23999) =====
+var (
+ UserAlreadyExists = NewError(23000, "用户已存在")
+ UserEmailExists = NewError(23001, "邮箱已被注册")
+)
+
+// ===== 服务端错误(29000 - 29999) =====
+var (
+ UserCreateFailed = NewError(29000, "用户创建失败")
+ UserUpdateFailed = NewError(29001, "用户更新失败")
+ UserDeleteFailed = NewError(29002, "用户删除失败")
+ UserQueryFailed = NewError(29003, "用户查询失败")
+)
diff --git a/pkg/lzcwyjwt/lzcwyjwt.go b/pkg/lzcwyjwt/lzcwyjwt.go
new file mode 100644
index 0000000..009416e
--- /dev/null
+++ b/pkg/lzcwyjwt/lzcwyjwt.go
@@ -0,0 +1,137 @@
+package lzcwyjwt
+
+import (
+ "errors"
+ "time"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "github.com/golang-jwt/jwt/v5"
+)
+
+type TokenType string
+
+const (
+ TokenTypeAccess TokenType = "access"
+ TokenTypeRefresh TokenType = "refresh"
+)
+
+type Claims struct {
+ UserID int64 `json:"user_id"`
+ TokenType TokenType `json:"token_type"`
+ jwt.RegisteredClaims
+}
+
+var (
+ ErrTokenInvalid = errors.New("token invalid")
+ ErrTokenExpired = errors.New("token expired")
+ ErrTokenWrongType = errors.New("token type mismatch")
+)
+
+func GenerateTokens(userID int64) (accessToken, refreshToken string, err error) {
+ now := time.Now()
+ accessClaims := Claims{
+ UserID: userID,
+ TokenType: TokenTypeAccess,
+ RegisteredClaims: jwt.RegisteredClaims{
+ Issuer: global.JWTSetting.Issuer,
+ IssuedAt: jwt.NewNumericDate(now),
+ ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(global.JWTSetting.AccessTokenExpire) * time.Second)),
+ NotBefore: jwt.NewNumericDate(now),
+ },
+ }
+
+ refreshClaims := Claims{
+ UserID: userID,
+ TokenType: TokenTypeRefresh,
+ RegisteredClaims: jwt.RegisteredClaims{
+ Issuer: global.JWTSetting.Issuer,
+ IssuedAt: jwt.NewNumericDate(now),
+ ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(global.JWTSetting.RefreshTokenExpire) * time.Second)),
+ NotBefore: jwt.NewNumericDate(now),
+ },
+ }
+
+ accessToken, err = jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims).SignedString([]byte(global.JWTSetting.SecretKey))
+ if err != nil {
+ return
+ }
+
+ refreshToken, err = jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).SignedString([]byte(global.JWTSetting.SecretKey))
+ if err != nil {
+ return
+ }
+
+ return accessToken, refreshToken, nil
+}
+
+func ParseAccessToken(tokenString string) (*Claims, error) {
+ token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(t *jwt.Token) (interface{}, error) {
+ // 验证签名算法
+ if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
+ return nil, ErrTokenInvalid
+ }
+ return []byte(global.JWTSetting.SecretKey), nil
+ })
+
+ if err != nil {
+ // 区分过期和其他错误
+ if errors.Is(err, jwt.ErrTokenExpired) {
+ return nil, ErrTokenExpired
+ }
+ return nil, err
+ }
+
+ claims, ok := token.Claims.(*Claims)
+ if !ok || !token.Valid {
+ return nil, ErrTokenInvalid
+ }
+
+ // 校验签发方,防止跨服务 token 混用。
+ if global.JWTSetting.Issuer != "" && claims.Issuer != global.JWTSetting.Issuer {
+ return nil, ErrTokenInvalid
+ }
+
+ // 校验 token 类型(防止 refresh token 被当 access token 用)
+ if claims.TokenType != TokenTypeAccess {
+ return nil, ErrTokenWrongType
+ }
+
+ return claims, nil
+
+}
+
+func ParseRefreshToken(tokenString string) (*Claims, error) {
+ token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(t *jwt.Token) (interface{}, error) {
+ // 验证签名算法
+ if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
+ return nil, ErrTokenInvalid
+ }
+ return []byte(global.JWTSetting.SecretKey), nil
+ })
+
+ if err != nil {
+ // 区分过期和其他错误
+ if errors.Is(err, jwt.ErrTokenExpired) {
+ return nil, ErrTokenExpired
+ }
+ return nil, err
+ }
+
+ claims, ok := token.Claims.(*Claims)
+ if !ok || !token.Valid {
+ return nil, ErrTokenInvalid
+ }
+
+ // 校验签发方,防止跨服务 token 混用。
+ if global.JWTSetting.Issuer != "" && claims.Issuer != global.JWTSetting.Issuer {
+ return nil, ErrTokenInvalid
+ }
+
+ // 校验 token 类型(防止 access token 被当 refresh token 用)
+ if claims.TokenType != TokenTypeRefresh {
+ return nil, ErrTokenWrongType
+ }
+
+ return claims, nil
+
+}
diff --git a/pkg/matting/matting.go b/pkg/matting/matting.go
new file mode 100644
index 0000000..6848504
--- /dev/null
+++ b/pkg/matting/matting.go
@@ -0,0 +1,109 @@
+// Package matting 封装图片抠图服务调用,将衣物图片上传到 BiRefNet 抠图服务,
+// 返回透明底 PNG 图片字节。
+package matting
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "time"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+)
+
+const (
+ mattingPath = "/matting"
+ healthzPath = "/healthz"
+)
+
+// Client 抠图服务客户端。
+type Client struct {
+ baseURL string
+ httpClient *http.Client
+}
+
+// NewClient 从全局配置创建客户端。
+func NewClient() *Client {
+ return NewClientWithAddr(global.MattingModelSetting.Host, global.MattingModelSetting.Port)
+}
+
+// NewClientWithAddr 使用指定 Host/Port 创建客户端,便于测试与解耦。
+func NewClientWithAddr(host string, port int) *Client {
+ return &Client{
+ baseURL: fmt.Sprintf("http://%s:%d", host, port),
+ httpClient: &http.Client{
+ Timeout: 120 * time.Second,
+ },
+ }
+}
+
+// Matting 调用抠图服务,将图片上传并返回透明底 PNG 字节。
+func (c *Client) Matting(ctx context.Context, imageData []byte) ([]byte, error) {
+ var body bytes.Buffer
+ writer := multipart.NewWriter(&body)
+
+ part, err := writer.CreateFormFile("file", "image.png")
+ if err != nil {
+ return nil, fmt.Errorf("matting create form file: %w", err)
+ }
+ if _, err := part.Write(imageData); err != nil {
+ return nil, fmt.Errorf("matting write image: %w", err)
+ }
+ if err := writer.Close(); err != nil {
+ return nil, fmt.Errorf("matting close multipart: %w", err)
+ }
+
+ request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+mattingPath, &body)
+ if err != nil {
+ return nil, fmt.Errorf("matting create request: %w", err)
+ }
+ request.Header.Set("Content-Type", writer.FormDataContentType())
+
+ response, err := c.httpClient.Do(request)
+ if err != nil {
+ return nil, fmt.Errorf("matting do request: %w", err)
+ }
+ defer response.Body.Close()
+
+ data, err := io.ReadAll(response.Body)
+ if err != nil {
+ return nil, fmt.Errorf("matting read response: %w", err)
+ }
+
+ if response.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("matting api error: status=%d, body=%s", response.StatusCode, summarize(data))
+ }
+
+ return data, nil
+}
+
+// HealthCheck 检查抠图服务是否就绪。
+func (c *Client) HealthCheck(ctx context.Context) error {
+ request, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+healthzPath, nil)
+ if err != nil {
+ return fmt.Errorf("matting create healthz request: %w", err)
+ }
+
+ response, err := c.httpClient.Do(request)
+ if err != nil {
+ return fmt.Errorf("matting healthz request: %w", err)
+ }
+ defer response.Body.Close()
+
+ if response.StatusCode != http.StatusOK {
+ return fmt.Errorf("matting healthz status: %d", response.StatusCode)
+ }
+ return nil
+}
+
+// summarize 截断响应体摘要,避免错误信息打印大体积二进制。
+func summarize(data []byte) string {
+ const maxLen = 512
+ if len(data) > maxLen {
+ return string(data[:maxLen]) + "..."
+ }
+ return string(data)
+}
diff --git a/pkg/matting/matting_test.go b/pkg/matting/matting_test.go
new file mode 100644
index 0000000..ed87c42
--- /dev/null
+++ b/pkg/matting/matting_test.go
@@ -0,0 +1,53 @@
+package matting
+
+import (
+ "bytes"
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+// TestMatting 使用仓库根目录的 image.png 调用真实抠图服务进行端到端验证。
+func TestMatting(t *testing.T) {
+ client := NewClientWithAddr("127.0.0.1", 8001)
+ ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
+ defer cancel()
+
+ // 前置:确认抠图服务已就绪(模型加载完成后 /healthz 才会响应)
+ if err := client.HealthCheck(ctx); err != nil {
+ t.Skipf("抠图服务未就绪,跳过真实抠图测试: %v", err)
+ }
+
+ imgPath := filepath.Join("..", "..", "image.png")
+ raw, err := os.ReadFile(imgPath)
+ if err != nil {
+ t.Fatalf("读取 image.png 失败: %v", err)
+ }
+
+ out, err := client.Matting(ctx, raw)
+ if err != nil {
+ t.Fatalf("Matting 失败: %v", err)
+ }
+
+ if len(out) == 0 {
+ t.Fatal("抠图返回空结果")
+ }
+
+ pngMagic := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}
+ if len(out) < len(pngMagic) || !bytes.Equal(out[:len(pngMagic)], pngMagic) {
+ preview := out
+ if len(preview) > len(pngMagic) {
+ preview = preview[:len(pngMagic)]
+ }
+ t.Fatalf("返回结果不是 PNG,前 %d 字节: % x", len(preview), preview)
+ }
+
+ // 保存结果便于人工核验
+ resultPath := filepath.Join(t.TempDir(), "matting_result.png")
+ if err := os.WriteFile(resultPath, out, 0o644); err != nil {
+ t.Fatalf("保存抠图结果失败: %v", err)
+ }
+ t.Logf("抠图成功,输入 %d 字节 -> 输出 %d 字节,结果已保存到 %s", len(raw), len(out), resultPath)
+}
diff --git a/pkg/migration/000001_create_users.down.sql b/pkg/migration/000001_create_users.down.sql
new file mode 100644
index 0000000..5f0853a
--- /dev/null
+++ b/pkg/migration/000001_create_users.down.sql
@@ -0,0 +1 @@
+DROP TABLE IF EXISTS `users`;
diff --git a/pkg/migration/000001_create_users.up.sql b/pkg/migration/000001_create_users.up.sql
new file mode 100644
index 0000000..b4303a7
--- /dev/null
+++ b/pkg/migration/000001_create_users.up.sql
@@ -0,0 +1,28 @@
+CREATE TABLE `users` (
+ `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+ `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间',
+ `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间',
+ `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)',
+ `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人',
+ `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人',
+ `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人',
+ `user_uuid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '用户UUID',
+ `wx_open_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '微信openid',
+ `username` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '用户名',
+ `avatar` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '头像存储位置',
+ `sex` TINYINT NOT NULL DEFAULT 0 COMMENT '性别 1=男 2=女',
+ `birth_day` BIGINT NOT NULL DEFAULT 0 COMMENT '生日',
+ `phone` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '手机号',
+ `is_new_user` TINYINT NOT NULL DEFAULT 0 COMMENT '是否新用户 0=否 1=是',
+ `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态 1=正常 2=临时禁用 3=永久禁用 4=已注销',
+ `ban_reason` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '封禁原因',
+ `ban_start` BIGINT NOT NULL DEFAULT 0 COMMENT '封禁开始时间',
+ `ban_expire` BIGINT NOT NULL DEFAULT 0 COMMENT '封禁到期时间',
+ `vip_type` TINYINT NOT NULL DEFAULT 0 COMMENT 'VIP类型 1=非会员 2=月度会员 3=年度会员 4=体验会员',
+ `vip_start` BIGINT NOT NULL DEFAULT 0 COMMENT 'VIP开始时间',
+ `vip_expire` BIGINT NOT NULL DEFAULT 0 COMMENT 'VIP到期时间',
+ `last_login_time` BIGINT NOT NULL DEFAULT 0 COMMENT '最近登录时间',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_user_wx_open_id` (`wx_open_id`),
+ KEY `idx_users_user_uuid` (`user_uuid`)
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '用户表';
diff --git a/pkg/migration/000002_create_clothes.down.sql b/pkg/migration/000002_create_clothes.down.sql
new file mode 100644
index 0000000..2fe1179
--- /dev/null
+++ b/pkg/migration/000002_create_clothes.down.sql
@@ -0,0 +1 @@
+DROP TABLE IF EXISTS `clothes`;
diff --git a/pkg/migration/000002_create_clothes.up.sql b/pkg/migration/000002_create_clothes.up.sql
new file mode 100644
index 0000000..b5a72f5
--- /dev/null
+++ b/pkg/migration/000002_create_clothes.up.sql
@@ -0,0 +1,36 @@
+CREATE TABLE `clothes` (
+ `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+ `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间',
+ `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间',
+ `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)',
+ `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人',
+ `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人',
+ `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人',
+ `clothes_uuid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '衣物UUID',
+ `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '所属用户ID',
+ `category_main` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '衣物大类 上装/下装/连衣裙/外套/鞋/配饰',
+ `category_sub` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '衣物小类 T恤/牛仔裤/运动鞋等',
+ `style` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '风格',
+ `color` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '主颜色',
+ `color_sub` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '副颜色',
+ `thickness` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '厚度',
+ `version` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '版型 宽松/修身/阔腿/紧身/常规合身',
+ `material` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '材质 牛仔布/针织/皮质/纯棉/羊毛等',
+ `pattern` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '花纹',
+ `sleeve_type` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '袖型',
+ `clothes_type` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '衣长类型 短款/常规/中长/长款',
+ `neckline_type` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '领型',
+ `formality` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '正式程度 1-5',
+ `occasions` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '适用场合',
+ `visual_impact` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '视觉冲击力 1-5',
+ `name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '名称',
+ `remark` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '备注',
+ `processed_image` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '处理后图片存储位置 相对路径',
+ `image_size` BIGINT NOT NULL DEFAULT 0 COMMENT '图片大小 单位:kb',
+ `image_process_status` TINYINT NOT NULL DEFAULT 0 COMMENT '处理状态 1=抠图中 2=分类中 3=排队中 4=成功 5=失败',
+ `fail_reason` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '失败原因',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_clothes_uuid` (`clothes_uuid`),
+ KEY `idx_clothes_user_category` (`user_id`, `category_main`),
+ KEY `idx_clothes_user_status` (`user_id`, `image_process_status`)
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '衣物表';
diff --git a/pkg/migration/000003_create_outfits.down.sql b/pkg/migration/000003_create_outfits.down.sql
new file mode 100644
index 0000000..c73e9e0
--- /dev/null
+++ b/pkg/migration/000003_create_outfits.down.sql
@@ -0,0 +1,2 @@
+DROP TABLE IF EXISTS `outfit_items`;
+DROP TABLE IF EXISTS `outfits`;
diff --git a/pkg/migration/000003_create_outfits.up.sql b/pkg/migration/000003_create_outfits.up.sql
new file mode 100644
index 0000000..eeda050
--- /dev/null
+++ b/pkg/migration/000003_create_outfits.up.sql
@@ -0,0 +1,37 @@
+CREATE TABLE `outfits` (
+ `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+ `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间',
+ `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间',
+ `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)',
+ `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人',
+ `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人',
+ `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人',
+ `outfit_uuid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '穿搭UUID',
+ `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '所属用户ID',
+ `outfit_name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '穿搭名称',
+ `outfit_desc` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '穿搭描述',
+ `scene` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '适合场景',
+ `source` TINYINT NOT NULL DEFAULT 0 COMMENT '来源 1=用户创建 2=AI推荐',
+ `is_public` TINYINT NOT NULL DEFAULT 0 COMMENT '是否公开 1=不公开 2=公开',
+ `audit_status` TINYINT NOT NULL DEFAULT 0 COMMENT '审核状态 1=待审核 2=审核通过 3=审核不通过',
+ `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态 1=正常 2=封禁 3=待用户保存',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_outfits_uuid` (`outfit_uuid`),
+ KEY `idx_outfits_user_status` (`user_id`, `status`),
+ KEY `idx_outfits_public` (`is_public`, `audit_status`)
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '穿搭表';
+
+CREATE TABLE `outfit_items` (
+ `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+ `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间',
+ `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间',
+ `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)',
+ `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人',
+ `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人',
+ `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人',
+ `outfit_id` BIGINT NOT NULL DEFAULT 0 COMMENT '穿搭ID',
+ `clothes_id` BIGINT NOT NULL DEFAULT 0 COMMENT '衣物ID',
+ PRIMARY KEY (`id`),
+ KEY `idx_outfit_items_outfit` (`outfit_id`),
+ KEY `idx_outfit_items_clothes` (`clothes_id`)
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '穿搭明细表';
diff --git a/pkg/migration/000004_create_closet_shares.down.sql b/pkg/migration/000004_create_closet_shares.down.sql
new file mode 100644
index 0000000..1d4d4c7
--- /dev/null
+++ b/pkg/migration/000004_create_closet_shares.down.sql
@@ -0,0 +1,2 @@
+DROP TABLE IF EXISTS `closet_share_items`;
+DROP TABLE IF EXISTS `closet_shares`;
diff --git a/pkg/migration/000004_create_closet_shares.up.sql b/pkg/migration/000004_create_closet_shares.up.sql
new file mode 100644
index 0000000..74d3f49
--- /dev/null
+++ b/pkg/migration/000004_create_closet_shares.up.sql
@@ -0,0 +1,31 @@
+CREATE TABLE `closet_shares` (
+ `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+ `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间',
+ `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间',
+ `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)',
+ `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人',
+ `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人',
+ `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人',
+ `share_uuid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '分享UUID',
+ `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '所属用户ID',
+ `share_type` TINYINT NOT NULL DEFAULT 0 COMMENT '分享类型 1=部分衣物分享 2=全量快照分享',
+ `expire_time` BIGINT NOT NULL DEFAULT 0 COMMENT '过期时间',
+ `status` TINYINT NOT NULL DEFAULT 0 COMMENT '分享状态 1=正常 2=用户取消 3=已过期 4=管理员封禁',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_closet_shares_uuid` (`share_uuid`),
+ KEY `idx_closet_shares_user_status` (`user_id`, `status`)
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '衣柜分享表';
+
+CREATE TABLE `closet_share_items` (
+ `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+ `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间',
+ `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间',
+ `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)',
+ `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人',
+ `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人',
+ `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人',
+ `share_uuid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '分享UUID',
+ `clothes_id` BIGINT NOT NULL DEFAULT 0 COMMENT '衣物ID',
+ PRIMARY KEY (`id`),
+ KEY `idx_closet_share_items_share` (`share_uuid`)
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '衣柜分享明细表';
diff --git a/pkg/migration/000005_create_user_quotas.down.sql b/pkg/migration/000005_create_user_quotas.down.sql
new file mode 100644
index 0000000..61d1166
--- /dev/null
+++ b/pkg/migration/000005_create_user_quotas.down.sql
@@ -0,0 +1,2 @@
+DROP TABLE IF EXISTS `user_daily_usages`;
+DROP TABLE IF EXISTS `user_quotas`;
diff --git a/pkg/migration/000005_create_user_quotas.up.sql b/pkg/migration/000005_create_user_quotas.up.sql
new file mode 100644
index 0000000..5186d1d
--- /dev/null
+++ b/pkg/migration/000005_create_user_quotas.up.sql
@@ -0,0 +1,32 @@
+CREATE TABLE `user_quotas` (
+ `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+ `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间',
+ `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间',
+ `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)',
+ `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人',
+ `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人',
+ `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人',
+ `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '用户ID',
+ `quota_type` TINYINT NOT NULL DEFAULT 0 COMMENT '额度类型 1=cos上传 2=AI推荐 3=社区分享 4=衣柜分享',
+ `total` BIGINT NOT NULL DEFAULT 0 COMMENT '总配额',
+ `used` BIGINT NOT NULL DEFAULT 0 COMMENT '已用量',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_user_quota` (`user_id`, `quota_type`)
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '用户额度表';
+
+CREATE TABLE `user_daily_usages` (
+ `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+ `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间',
+ `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间',
+ `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)',
+ `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人',
+ `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人',
+ `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人',
+ `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '用户ID',
+ `stat_date` BIGINT NOT NULL DEFAULT 0 COMMENT '统计日期(YYYYMMDD)',
+ `ai_recommend_count` BIGINT NOT NULL DEFAULT 0 COMMENT '当日AI推荐次数',
+ `outfit_share_count` BIGINT NOT NULL DEFAULT 0 COMMENT '当日社区分享次数',
+ `closet_share_count` BIGINT NOT NULL DEFAULT 0 COMMENT '当日衣柜分享次数',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_daily_usage_user_date` (`user_id`, `stat_date`)
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '用户每日用量统计表';
diff --git a/pkg/migration/000006_create_quota_flow_logs.down.sql b/pkg/migration/000006_create_quota_flow_logs.down.sql
new file mode 100644
index 0000000..8e81ed3
--- /dev/null
+++ b/pkg/migration/000006_create_quota_flow_logs.down.sql
@@ -0,0 +1 @@
+DROP TABLE IF EXISTS `quota_flow_logs`;
diff --git a/pkg/migration/000006_create_quota_flow_logs.up.sql b/pkg/migration/000006_create_quota_flow_logs.up.sql
new file mode 100644
index 0000000..8cce07c
--- /dev/null
+++ b/pkg/migration/000006_create_quota_flow_logs.up.sql
@@ -0,0 +1,24 @@
+CREATE TABLE `quota_flow_logs` (
+ `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+ `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间',
+ `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间',
+ `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)',
+ `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人',
+ `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人',
+ `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人',
+ `quota_flow_uuid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '配额流水UUID',
+ `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '用户ID',
+ `change_target` TINYINT NOT NULL DEFAULT 0 COMMENT '调整额度类型 1=配额调整 2=使用',
+ `change_type` TINYINT NOT NULL DEFAULT 0 COMMENT '变更类型 1=购买获赠 2=活动赠送 3=上传消耗 4=删除回退 5=人工调整 6=退款回收',
+ `change_num` BIGINT NOT NULL DEFAULT 0 COMMENT '变更数量(正数增加,负数减少)',
+ `old_value` BIGINT NOT NULL DEFAULT 0 COMMENT '变更前数值',
+ `new_value` BIGINT NOT NULL DEFAULT 0 COMMENT '变更后数值',
+ `order_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '关联订单号(购买/退款类);其余场景写流水UUID保证幂等',
+ `quota_type` TINYINT NOT NULL DEFAULT 0 COMMENT '关联业务类型 1=cos 2=AI 3=社区分享 4=衣柜分享',
+ `related_id` BIGINT NOT NULL DEFAULT 0 COMMENT '关联业务ID',
+ `reason` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '变更原因/备注',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_quota_flow_idem` (`order_no`, `quota_type`, `change_type`),
+ KEY `idx_quota_flow_user` (`user_id`),
+ KEY `idx_quota_flow_uuid` (`quota_flow_uuid`)
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '配额流水日志表';
diff --git a/pkg/migration/000007_create_goods.down.sql b/pkg/migration/000007_create_goods.down.sql
new file mode 100644
index 0000000..63949b7
--- /dev/null
+++ b/pkg/migration/000007_create_goods.down.sql
@@ -0,0 +1 @@
+DROP TABLE IF EXISTS `goods`;
diff --git a/pkg/migration/000007_create_goods.up.sql b/pkg/migration/000007_create_goods.up.sql
new file mode 100644
index 0000000..cd5a070
--- /dev/null
+++ b/pkg/migration/000007_create_goods.up.sql
@@ -0,0 +1,28 @@
+CREATE TABLE `goods` (
+ `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+ `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间',
+ `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间',
+ `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)',
+ `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人',
+ `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人',
+ `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人',
+ `goods_code` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '商品编码',
+ `goods_name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '商品名称',
+ `goods_type` TINYINT NOT NULL DEFAULT 0 COMMENT '商品类型 1=会员包 2=上传包 3=AI推荐包 4=衣柜分享包 5=搭配分享包',
+ `price` BIGINT NOT NULL DEFAULT 0 COMMENT '商品原价(分)',
+ `discount_price` BIGINT NOT NULL DEFAULT 0 COMMENT '商品售价(分)',
+ `member_days` BIGINT NOT NULL DEFAULT 0 COMMENT '赠送会员天数',
+ `cos_quota` BIGINT NOT NULL DEFAULT 0 COMMENT '赠送总上传额度',
+ `ai_recommend_quota` BIGINT NOT NULL DEFAULT 0 COMMENT '赠送AI推荐额度',
+ `closet_share_quota` BIGINT NOT NULL DEFAULT 0 COMMENT '赠送衣柜分享额度',
+ `outfit_share_quota` BIGINT NOT NULL DEFAULT 0 COMMENT '赠送搭配分享额度',
+ `valid_days` BIGINT NOT NULL DEFAULT 0 COMMENT '赠送额度有效期天数(加量包用;会员包为 0 表示跟随 VIP)',
+ `description` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '商品描述',
+ `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态 1=上架 2=下架',
+ `product_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '虚拟支付道具ID(空表示未发布)',
+ `product_status` TINYINT NOT NULL DEFAULT 0 COMMENT '虚拟支付道具发布状态 0=未发布 1=发布中 2=已发布 3=发布失败',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_goods_code` (`goods_code`),
+ KEY `idx_goods_type_status` (`goods_type`, `status`),
+ KEY `idx_goods_product_status` (`product_status`)
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '商品表';
diff --git a/pkg/migration/000008_create_orders.down.sql b/pkg/migration/000008_create_orders.down.sql
new file mode 100644
index 0000000..ac33c78
--- /dev/null
+++ b/pkg/migration/000008_create_orders.down.sql
@@ -0,0 +1 @@
+DROP TABLE IF EXISTS `orders`;
diff --git a/pkg/migration/000008_create_orders.up.sql b/pkg/migration/000008_create_orders.up.sql
new file mode 100644
index 0000000..e667b69
--- /dev/null
+++ b/pkg/migration/000008_create_orders.up.sql
@@ -0,0 +1,37 @@
+CREATE TABLE `orders` (
+ `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+ `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间',
+ `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间',
+ `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)',
+ `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人',
+ `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人',
+ `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人',
+ `order_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '商户订单号',
+ `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '下单用户ID',
+ `goods_id` BIGINT NOT NULL DEFAULT 0 COMMENT '关联商品ID',
+ `goods_name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '商品名称快照',
+ `unit_price` BIGINT NOT NULL DEFAULT 0 COMMENT '商品单价(分)',
+ `total_amount` BIGINT NOT NULL DEFAULT 0 COMMENT '订单总金额(分)',
+ `pay_amount` BIGINT NOT NULL DEFAULT 0 COMMENT '实付金额(分)',
+ `pay_channel` TINYINT NOT NULL DEFAULT 0 COMMENT '支付渠道 1=微信支付',
+ `order_status` TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款',
+ `transaction_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '微信支付流水号',
+ `member_days` BIGINT NOT NULL DEFAULT 0 COMMENT '购买的会员天数(快照)',
+ `cos_quota` BIGINT NOT NULL DEFAULT 0 COMMENT '赠送上传额度(快照)',
+ `ai_recommend_quota` BIGINT NOT NULL DEFAULT 0 COMMENT '赠送AI推荐额度(快照)',
+ `closet_share_quota` BIGINT NOT NULL DEFAULT 0 COMMENT '赠送衣柜分享额度(快照)',
+ `outfit_share_quota` BIGINT NOT NULL DEFAULT 0 COMMENT '赠送搭配分享额度(快照)',
+ `expire_time` BIGINT NOT NULL DEFAULT 0 COMMENT '支付过期时间',
+ `pay_time` BIGINT NOT NULL DEFAULT 0 COMMENT '支付完成时间',
+ `refund_time` BIGINT NOT NULL DEFAULT 0 COMMENT '退款时间',
+ `refund_amount` BIGINT NOT NULL DEFAULT 0 COMMENT '退款金额(分)',
+ `remark` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '订单备注',
+ `callback_raw` TEXT COMMENT '支付回调原始报文',
+ `xpay_product_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '虚拟支付道具ID(快照,pay_channel=2 时使用)',
+ `xpay_platform` VARCHAR(16) NOT NULL DEFAULT '' COMMENT '虚拟支付平台 android/ios/harmony/windows',
+ `xpay_env` TINYINT NOT NULL DEFAULT 0 COMMENT '虚拟支付环境 0=现网 1=沙箱',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_order_no` (`order_no`),
+ KEY `idx_orders_user_status` (`user_id`, `order_status`),
+ KEY `idx_orders_expire` (`order_status`, `expire_time`)
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '订单表';
diff --git a/pkg/migration/000009_create_refund_flow_logs.down.sql b/pkg/migration/000009_create_refund_flow_logs.down.sql
new file mode 100644
index 0000000..a7a071d
--- /dev/null
+++ b/pkg/migration/000009_create_refund_flow_logs.down.sql
@@ -0,0 +1 @@
+DROP TABLE IF EXISTS `refund_flow_logs`;
diff --git a/pkg/migration/000009_create_refund_flow_logs.up.sql b/pkg/migration/000009_create_refund_flow_logs.up.sql
new file mode 100644
index 0000000..4f25674
--- /dev/null
+++ b/pkg/migration/000009_create_refund_flow_logs.up.sql
@@ -0,0 +1,25 @@
+CREATE TABLE `refund_flow_logs` (
+ `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+ `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间',
+ `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间',
+ `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)',
+ `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人',
+ `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人',
+ `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人',
+ `order_id` BIGINT NOT NULL DEFAULT 0 COMMENT '关联订单ID',
+ `order_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '商户订单号(冗余,方便查询)',
+ `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '用户ID(冗余,方便按用户查)',
+ `refund_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '商户退款单号',
+ `wechat_refund_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '微信退款单号',
+ `refund_amount` BIGINT NOT NULL DEFAULT 0 COMMENT '本次退款金额(分)',
+ `refund_reason` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '退款原因',
+ `status` TINYINT NOT NULL DEFAULT 0 COMMENT '退款状态 0=待处理 1=退款中 2=退款成功 3=退款失败',
+ `apply_time` BIGINT NOT NULL DEFAULT 0 COMMENT '申请时间',
+ `success_time` BIGINT NOT NULL DEFAULT 0 COMMENT '退款成功时间',
+ `fail_reason` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '失败原因',
+ `callback_raw` TEXT COMMENT '微信退款回调原始报文',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_refund_flow_no` (`refund_no`),
+ KEY `idx_refund_flow_order` (`order_id`),
+ KEY `idx_refund_flow_user` (`user_id`)
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '退款流水日志表';
diff --git a/pkg/migration/000010_create_vip_flow_logs.down.sql b/pkg/migration/000010_create_vip_flow_logs.down.sql
new file mode 100644
index 0000000..3ab4ccb
--- /dev/null
+++ b/pkg/migration/000010_create_vip_flow_logs.down.sql
@@ -0,0 +1 @@
+DROP TABLE IF EXISTS `vip_flow_logs`;
diff --git a/pkg/migration/000010_create_vip_flow_logs.up.sql b/pkg/migration/000010_create_vip_flow_logs.up.sql
new file mode 100644
index 0000000..7a974e4
--- /dev/null
+++ b/pkg/migration/000010_create_vip_flow_logs.up.sql
@@ -0,0 +1,21 @@
+CREATE TABLE `vip_flow_logs` (
+ `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+ `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间',
+ `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间',
+ `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)',
+ `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人',
+ `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人',
+ `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人',
+ `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '用户ID',
+ `change_type` TINYINT NOT NULL DEFAULT 0 COMMENT '变更类型 1=开通 2=续费 3=活动赠送 4=到期失效 5=人工调整 6=退款回收',
+ `old_vip_type` TINYINT NOT NULL DEFAULT 0 COMMENT '变更前VIP类型 1=非会员 2=月度会员 3=年度会员 4=体验会员',
+ `new_vip_type` TINYINT NOT NULL DEFAULT 0 COMMENT '变更后VIP类型 1=非会员 2=月度会员 3=年度会员 4=体验会员',
+ `old_vip_expire` BIGINT NOT NULL DEFAULT 0 COMMENT '变更前到期时间(秒)',
+ `new_vip_expire` BIGINT NOT NULL DEFAULT 0 COMMENT '变更后到期时间(秒)',
+ `order_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '关联订单号(购买类变更)',
+ `source` TINYINT NOT NULL DEFAULT 0 COMMENT '变更来源 1=个人支付 2=活动赠送 3=系统 4=人工调整',
+ `reason` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '变更原因/备注',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_vip_flow_idem` (`order_no`, `change_type`),
+ KEY `idx_vip_flow_user` (`user_id`)
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '会员变更流水表';
diff --git a/pkg/migration/000011_create_benefit_grant_tasks.down.sql b/pkg/migration/000011_create_benefit_grant_tasks.down.sql
new file mode 100644
index 0000000..f1d084b
--- /dev/null
+++ b/pkg/migration/000011_create_benefit_grant_tasks.down.sql
@@ -0,0 +1 @@
+DROP TABLE IF EXISTS `benefit_grant_tasks`;
diff --git a/pkg/migration/000011_create_benefit_grant_tasks.up.sql b/pkg/migration/000011_create_benefit_grant_tasks.up.sql
new file mode 100644
index 0000000..4f04c02
--- /dev/null
+++ b/pkg/migration/000011_create_benefit_grant_tasks.up.sql
@@ -0,0 +1,21 @@
+CREATE TABLE `benefit_grant_tasks` (
+ `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+ `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间',
+ `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间',
+ `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)',
+ `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人',
+ `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人',
+ `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人',
+ `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '用户ID',
+ `order_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '商户订单号',
+ `goods_id` BIGINT NOT NULL DEFAULT 0 COMMENT '关联商品ID',
+ `benefit_type` TINYINT NOT NULL DEFAULT 0 COMMENT '权益类型 1=vip 2=cos 3=ai 4=closet_share 5=outfit_share',
+ `amount` BIGINT NOT NULL DEFAULT 0 COMMENT '发放数量',
+ `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态 0=待发放 1=已发放 2=发放失败',
+ `retry_count` INT NOT NULL DEFAULT 0 COMMENT '已重试次数',
+ `max_retry` INT NOT NULL DEFAULT 0 COMMENT '最大重试次数',
+ `last_error` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '最近一次失败原因',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_benefit_task_idem` (`order_no`, `benefit_type`),
+ KEY `idx_benefit_task_status` (`status`)
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '权益发放补偿任务表';
diff --git a/pkg/migration/000012_create_ai_flow_logs.down.sql b/pkg/migration/000012_create_ai_flow_logs.down.sql
new file mode 100644
index 0000000..aae5145
--- /dev/null
+++ b/pkg/migration/000012_create_ai_flow_logs.down.sql
@@ -0,0 +1,2 @@
+DROP TABLE IF EXISTS `ai_horoscope_flow_logs`;
+DROP TABLE IF EXISTS `ai_recommend_flow_logs`;
diff --git a/pkg/migration/000012_create_ai_flow_logs.up.sql b/pkg/migration/000012_create_ai_flow_logs.up.sql
new file mode 100644
index 0000000..88f2fb6
--- /dev/null
+++ b/pkg/migration/000012_create_ai_flow_logs.up.sql
@@ -0,0 +1,49 @@
+CREATE TABLE `ai_recommend_flow_logs` (
+ `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+ `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间',
+ `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间',
+ `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)',
+ `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人',
+ `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人',
+ `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人',
+ `request_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '请求唯一ID',
+ `server_request_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '服务请求唯一ID',
+ `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '用户ID',
+ `model_version` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '大模型版本',
+ `prompt_tokens` BIGINT NOT NULL DEFAULT 0 COMMENT '输入token数',
+ `completion_tokens` BIGINT NOT NULL DEFAULT 0 COMMENT '输出token数',
+ `result_content` TEXT COMMENT '推荐结果结构化数据',
+ `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态 1=成功 2=失败',
+ `fail_reason` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '失败原因',
+ `request_time` BIGINT NOT NULL DEFAULT 0 COMMENT '请求发起时间',
+ `response_time` BIGINT NOT NULL DEFAULT 0 COMMENT '响应返回时间',
+ PRIMARY KEY (`id`),
+ KEY `idx_ai_recommend_request` (`request_id`),
+ KEY `idx_ai_recommend_user` (`user_id`)
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'AI穿搭推荐流水表';
+
+CREATE TABLE `ai_horoscope_flow_logs` (
+ `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+ `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间',
+ `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间',
+ `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)',
+ `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人',
+ `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人',
+ `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人',
+ `request_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '请求唯一ID(幂等依据)',
+ `zodiac` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '星座',
+ `server_request_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '服务请求唯一ID',
+ `stat_date` BIGINT NOT NULL DEFAULT 0 COMMENT '运势日期(YYYYMMDD)',
+ `model_version` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '大模型版本',
+ `prompt_tokens` BIGINT NOT NULL DEFAULT 0 COMMENT '输入token数',
+ `completion_tokens` BIGINT NOT NULL DEFAULT 0 COMMENT '输出token数',
+ `result_content` TEXT COMMENT '生成结果结构化数据',
+ `horoscope_id` BIGINT NOT NULL DEFAULT 0 COMMENT '生成的运势记录ID',
+ `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态 1=成功 2=失败',
+ `fail_reason` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '失败原因',
+ `request_time` BIGINT NOT NULL DEFAULT 0 COMMENT '请求发起时间',
+ `response_time` BIGINT NOT NULL DEFAULT 0 COMMENT '响应返回时间',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_ai_horoscope_request` (`request_id`),
+ KEY `idx_ai_horoscope_date_zodiac` (`stat_date`, `zodiac`)
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'AI运势生成流水表';
diff --git a/pkg/migration/000013_create_interactions.down.sql b/pkg/migration/000013_create_interactions.down.sql
new file mode 100644
index 0000000..7f0a773
--- /dev/null
+++ b/pkg/migration/000013_create_interactions.down.sql
@@ -0,0 +1,2 @@
+DROP TABLE IF EXISTS `likes`;
+DROP TABLE IF EXISTS `collects`;
diff --git a/pkg/migration/000013_create_interactions.up.sql b/pkg/migration/000013_create_interactions.up.sql
new file mode 100644
index 0000000..75296fb
--- /dev/null
+++ b/pkg/migration/000013_create_interactions.up.sql
@@ -0,0 +1,33 @@
+CREATE TABLE `collects` (
+ `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+ `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间',
+ `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间',
+ `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)',
+ `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人',
+ `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人',
+ `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人',
+ `collect_type` TINYINT NOT NULL DEFAULT 0 COMMENT '业务类型 1=社区穿搭',
+ `target_id` BIGINT NOT NULL DEFAULT 0 COMMENT '业务目标ID',
+ `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '收藏用户ID',
+ `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态 1=已收藏 0=已取消',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_collect_user_target` (`collect_type`, `target_id`, `user_id`),
+ KEY `idx_collects_user` (`user_id`)
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '收藏表';
+
+CREATE TABLE `likes` (
+ `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+ `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间',
+ `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间',
+ `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)',
+ `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人',
+ `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人',
+ `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人',
+ `like_type` TINYINT NOT NULL DEFAULT 0 COMMENT '业务类型 1=社区穿搭',
+ `target_id` BIGINT NOT NULL DEFAULT 0 COMMENT '业务目标ID',
+ `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '点赞用户ID',
+ `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态 1=已点赞 0=已取消',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_like_user_target` (`like_type`, `target_id`, `user_id`),
+ KEY `idx_likes_user` (`user_id`)
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '点赞表';
diff --git a/pkg/migration/000014_create_user_notifications.down.sql b/pkg/migration/000014_create_user_notifications.down.sql
new file mode 100644
index 0000000..4e6c0ae
--- /dev/null
+++ b/pkg/migration/000014_create_user_notifications.down.sql
@@ -0,0 +1 @@
+DROP TABLE IF EXISTS `user_notifications`;
diff --git a/pkg/migration/000014_create_user_notifications.up.sql b/pkg/migration/000014_create_user_notifications.up.sql
new file mode 100644
index 0000000..3f7ac31
--- /dev/null
+++ b/pkg/migration/000014_create_user_notifications.up.sql
@@ -0,0 +1,20 @@
+CREATE TABLE `user_notifications` (
+ `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+ `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间',
+ `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间',
+ `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)',
+ `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人',
+ `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人',
+ `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人',
+ `notification_uuid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '消息UUID',
+ `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '接收用户ID',
+ `msg_type` TINYINT NOT NULL DEFAULT 0 COMMENT '消息类型 1=点赞通知 2=订单通知 3=系统通知',
+ `sender_id` BIGINT NOT NULL DEFAULT 0 COMMENT '发送者ID 系统消息为0',
+ `title` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '消息标题',
+ `content` TEXT COMMENT '消息内容',
+ `is_read` TINYINT NOT NULL DEFAULT 0 COMMENT '是否已读 1=未读 2=已读',
+ `read_time` BIGINT NOT NULL DEFAULT 0 COMMENT '阅读时间',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_notification_uuid` (`notification_uuid`),
+ KEY `idx_notifications_user_read` (`user_id`, `is_read`)
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '用户通知表';
diff --git a/pkg/migration/000015_create_horoscopes.down.sql b/pkg/migration/000015_create_horoscopes.down.sql
new file mode 100644
index 0000000..2fccc8d
--- /dev/null
+++ b/pkg/migration/000015_create_horoscopes.down.sql
@@ -0,0 +1 @@
+DROP TABLE IF EXISTS `horoscopes`;
diff --git a/pkg/migration/000015_create_horoscopes.up.sql b/pkg/migration/000015_create_horoscopes.up.sql
new file mode 100644
index 0000000..68e0849
--- /dev/null
+++ b/pkg/migration/000015_create_horoscopes.up.sql
@@ -0,0 +1,20 @@
+CREATE TABLE `horoscopes` (
+ `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+ `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间',
+ `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间',
+ `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)',
+ `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人',
+ `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人',
+ `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人',
+ `stat_date` BIGINT NOT NULL DEFAULT 0 COMMENT '运势日期(YYYYMMDD)',
+ `zodiac` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '星座',
+ `overall_score` TINYINT NOT NULL DEFAULT 0 COMMENT '综合运势 1-5分',
+ `love_score` TINYINT NOT NULL DEFAULT 0 COMMENT '爱情运势 1-5分',
+ `career_score` TINYINT NOT NULL DEFAULT 0 COMMENT '工作运势 1-5分',
+ `wealth_score` TINYINT NOT NULL DEFAULT 0 COMMENT '财富运势 1-5分',
+ `daily_text` TEXT COMMENT '今日运势详情',
+ `lucky_color` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '幸运色',
+ `lucky_number` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '幸运数字',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_horoscope_date_zodiac` (`stat_date`, `zodiac`)
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '星座运势表';
diff --git a/pkg/migration/000016_create_audit_logs.down.sql b/pkg/migration/000016_create_audit_logs.down.sql
new file mode 100644
index 0000000..3e9068a
--- /dev/null
+++ b/pkg/migration/000016_create_audit_logs.down.sql
@@ -0,0 +1,2 @@
+DROP TABLE IF EXISTS `outfit_audit_logs`;
+DROP TABLE IF EXISTS `closet_audit_logs`;
diff --git a/pkg/migration/000016_create_audit_logs.up.sql b/pkg/migration/000016_create_audit_logs.up.sql
new file mode 100644
index 0000000..4e7eddc
--- /dev/null
+++ b/pkg/migration/000016_create_audit_logs.up.sql
@@ -0,0 +1,31 @@
+CREATE TABLE `closet_audit_logs` (
+ `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+ `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间',
+ `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间',
+ `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)',
+ `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人',
+ `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人',
+ `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人',
+ `target_id` BIGINT NOT NULL DEFAULT 0 COMMENT '衣柜分享ID',
+ `audit_result` TINYINT NOT NULL DEFAULT 0 COMMENT '审核结果 1=通过 2=驳回',
+ `reject_reason` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '审核原因',
+ `audit_time` BIGINT NOT NULL DEFAULT 0 COMMENT '审核时间',
+ PRIMARY KEY (`id`),
+ KEY `idx_closet_audit_target` (`target_id`)
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '衣柜分享审核日志表';
+
+CREATE TABLE `outfit_audit_logs` (
+ `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+ `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间',
+ `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间',
+ `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)',
+ `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人',
+ `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人',
+ `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人',
+ `target_id` BIGINT NOT NULL DEFAULT 0 COMMENT '穿搭ID',
+ `audit_result` TINYINT NOT NULL DEFAULT 0 COMMENT '审核结果 1=通过 2=驳回',
+ `reject_reason` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '审核原因',
+ `audit_time` BIGINT NOT NULL DEFAULT 0 COMMENT '审核时间',
+ PRIMARY KEY (`id`),
+ KEY `idx_outfit_audit_target` (`target_id`)
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '穿搭审核日志表';
diff --git a/pkg/migration/000017_create_quota_grants.down.sql b/pkg/migration/000017_create_quota_grants.down.sql
new file mode 100644
index 0000000..36842fb
--- /dev/null
+++ b/pkg/migration/000017_create_quota_grants.down.sql
@@ -0,0 +1 @@
+DROP TABLE IF EXISTS `quota_grants`;
diff --git a/pkg/migration/000017_create_quota_grants.up.sql b/pkg/migration/000017_create_quota_grants.up.sql
new file mode 100644
index 0000000..07d544d
--- /dev/null
+++ b/pkg/migration/000017_create_quota_grants.up.sql
@@ -0,0 +1,20 @@
+-- 额度凭证表:承载有有效期的赠送额度(会员包/加量包/广告激励)。
+CREATE TABLE `quota_grants` (
+ `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+ `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间',
+ `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间',
+ `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)',
+ `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人',
+ `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人',
+ `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人',
+ `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '用户ID',
+ `quota_type` TINYINT NOT NULL DEFAULT 0 COMMENT '额度类型 1=cos上传 2=AI推荐 3=社区分享 4=衣柜分享',
+ `amount` BIGINT NOT NULL DEFAULT 0 COMMENT '赠送总量',
+ `used` BIGINT NOT NULL DEFAULT 0 COMMENT '已用量(Cos 永久额度扣减;AI/社区/衣柜 每日额度恒为 0)',
+ `source` TINYINT NOT NULL DEFAULT 0 COMMENT '来源 1=会员包 2=加量包 3=广告激励',
+ `expire_time` BIGINT NOT NULL DEFAULT 0 COMMENT '到期时间(秒,0 表示跟随 VIP 有效期)',
+ `ref_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '关联ID(订单号/广告记录ID)',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_grant_ref_type` (`ref_id`, `quota_type`),
+ KEY `idx_grant_user_type_expire` (`user_id`, `quota_type`, `expire_time`)
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '额度凭证表(有有效期赠送额度)';
diff --git a/pkg/migration/000018_create_closet_share_receivers.down.sql b/pkg/migration/000018_create_closet_share_receivers.down.sql
new file mode 100644
index 0000000..81339ec
--- /dev/null
+++ b/pkg/migration/000018_create_closet_share_receivers.down.sql
@@ -0,0 +1 @@
+DROP TABLE IF EXISTS `closet_share_receivers`;
diff --git a/pkg/migration/000018_create_closet_share_receivers.up.sql b/pkg/migration/000018_create_closet_share_receivers.up.sql
new file mode 100644
index 0000000..24aecad
--- /dev/null
+++ b/pkg/migration/000018_create_closet_share_receivers.up.sql
@@ -0,0 +1,15 @@
+-- 衣柜分享接收人关联表:记录"谁收到了谁的分享"(访问即视为收到,一对多、幂等)。
+CREATE TABLE `closet_share_receivers` (
+ `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+ `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间',
+ `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间',
+ `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)',
+ `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人',
+ `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人',
+ `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人',
+ `share_uuid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '分享UUID',
+ `receiver_user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '接收人用户ID',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_closet_share_receiver` (`share_uuid`, `receiver_user_id`),
+ KEY `idx_closet_share_receivers_user` (`receiver_user_id`)
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '衣柜分享接收人关联表';
diff --git a/pkg/migration/migration.go b/pkg/migration/migration.go
new file mode 100644
index 0000000..d8eb857
--- /dev/null
+++ b/pkg/migration/migration.go
@@ -0,0 +1,163 @@
+// Package migration 基于 golang-migrate 提供数据库结构迁移能力。
+// 迁移脚本以 go:embed 打包进二进制,通过独立命令 cmd/migrate 手动执行;服务启动流程不触发迁移。
+package migration
+
+import (
+ "database/sql"
+ "embed"
+ "errors"
+ "fmt"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/setting"
+ _ "github.com/go-sql-driver/mysql"
+ "github.com/golang-migrate/migrate/v4"
+ migratemysql "github.com/golang-migrate/migrate/v4/database/mysql"
+ "github.com/golang-migrate/migrate/v4/source/iofs"
+)
+
+// migrationsDir 迁移脚本所在目录:脚本与 migration.go 同级,故为当前目录。
+const migrationsDir = "."
+
+//go:embed *.sql
+var migrationFS embed.FS
+
+// dsn 拼装迁移专用 DSN。
+// 必须开启 multiStatements:单个迁移文件内含多条 CREATE TABLE / CREATE INDEX 语句,
+// 未开启时 MySQL 会拒绝执行。该参数与 gorm 使用的 DSN 不同,故此处单独拼装。
+func dsn(s *setting.MysqlSettingS) string {
+ return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&multiStatements=true",
+ s.UserName,
+ s.Password,
+ s.Host,
+ s.Port,
+ s.DBName,
+ )
+}
+
+// newMigrate 创建 migrate 实例;返回的 close 用于释放底层数据库连接。
+func newMigrate(s *setting.MysqlSettingS) (*migrate.Migrate, func() error, error) {
+ if s == nil {
+ return nil, nil, errors.New("mysql setting is nil")
+ }
+ if s.DBName == "" {
+ return nil, nil, errors.New("mysql DBName is empty")
+ }
+
+ source, err := iofs.New(migrationFS, migrationsDir)
+ if err != nil {
+ return nil, nil, fmt.Errorf("load embedded migrations: %w", err)
+ }
+
+ db, err := sql.Open("mysql", dsn(s))
+ if err != nil {
+ return nil, nil, fmt.Errorf("open mysql: %w", err)
+ }
+ closeFn := func() error { return db.Close() }
+
+ driver, err := migratemysql.WithInstance(db, &migratemysql.Config{})
+ if err != nil {
+ _ = closeFn()
+ return nil, nil, fmt.Errorf("create mysql migrate driver: %w", err)
+ }
+
+ m, err := migrate.NewWithInstance("iofs", source, "mysql", driver)
+ if err != nil {
+ _ = closeFn()
+ return nil, nil, fmt.Errorf("create migrate instance: %w", err)
+ }
+ return m, closeFn, nil
+}
+
+// Up 执行到最新版本;无待执行迁移(ErrNoChange)视为成功。
+func Up(s *setting.MysqlSettingS) error {
+ m, closeFn, err := newMigrate(s)
+ if err != nil {
+ return err
+ }
+ defer closeFn()
+
+ if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
+ return fmt.Errorf("migrate up: %w", err)
+ }
+ return nil
+}
+
+// Down 全量回滚所有已应用的迁移。
+func Down(s *setting.MysqlSettingS) error {
+ m, closeFn, err := newMigrate(s)
+ if err != nil {
+ return err
+ }
+ defer closeFn()
+
+ if err := m.Down(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
+ return fmt.Errorf("migrate down: %w", err)
+ }
+ return nil
+}
+
+// Steps 按步数迁移:n > 0 向上 n 步,n < 0 向下 n 步。
+func Steps(s *setting.MysqlSettingS, n int) error {
+ m, closeFn, err := newMigrate(s)
+ if err != nil {
+ return err
+ }
+ defer closeFn()
+
+ if err := m.Steps(n); err != nil && !errors.Is(err, migrate.ErrNoChange) {
+ return fmt.Errorf("migrate steps %d: %w", n, err)
+ }
+ return nil
+}
+
+// Version 返回当前迁移版本号、是否为脏库状态。未执行过任何迁移时返回 0。
+func Version(s *setting.MysqlSettingS) (uint, bool, error) {
+ m, closeFn, err := newMigrate(s)
+ if err != nil {
+ return 0, false, err
+ }
+ defer closeFn()
+
+ version, dirty, err := m.Version()
+ if err != nil {
+ if errors.Is(err, migrate.ErrNilVersion) {
+ return 0, false, nil
+ }
+ return 0, false, fmt.Errorf("migrate version: %w", err)
+ }
+ return version, dirty, nil
+}
+
+// Force 强制设置版本号并清除脏标记,用于修复迁移中途失败导致的 dirty 状态。
+func Force(s *setting.MysqlSettingS, version int) error {
+ if version < 0 {
+ return fmt.Errorf("force version must not be negative: %d", version)
+ }
+ m, closeFn, err := newMigrate(s)
+ if err != nil {
+ return err
+ }
+ defer closeFn()
+
+ if err := m.Force(version); err != nil {
+ return fmt.Errorf("migrate force %d: %w", version, err)
+ }
+ return nil
+}
+
+// Run 按 action 执行迁移动作;arg 仅 steps / force 使用。
+// action 取值:up、down、steps、force。
+func Run(s *setting.MysqlSettingS, action string, arg int) error {
+ switch action {
+ case "up":
+ return Up(s)
+ case "down":
+ return Down(s)
+ case "steps":
+ return Steps(s, arg)
+ case "force":
+ return Force(s, arg)
+ default:
+ return fmt.Errorf("unknown migration action: %s", action)
+ }
+}
diff --git a/pkg/setting/section.go b/pkg/setting/section.go
new file mode 100644
index 0000000..b647f7a
--- /dev/null
+++ b/pkg/setting/section.go
@@ -0,0 +1,157 @@
+package setting
+
+// ServerSettingS 服务配置。时间字段单位为秒(int),避免 time.Duration 对整数配置按纳秒解析的歧义。
+type ServerSettingS struct {
+ Domain string // 服务域名(用于 CORS 允许来源,如 test.com)
+ RunMode string
+ HttpPort string
+ ReadTimeout int // 秒
+ WriteTimeout int // 秒
+}
+
+type LogSettingS struct {
+ LogSavePath string
+ LogFileName string
+ LogFileExt string
+ MaxSize int
+ MaxBackups int
+ MaxAge int
+ LogLevel string
+}
+
+type MysqlSettingS struct {
+ UserName string
+ Password string
+ Host string
+ Port int
+ DBName string
+ MaxIdleConns int
+ MaxOpenConns int
+ ConnMaxLifeTime int
+ ConnMaxIdleTime int
+}
+
+type RedisSettingS struct {
+ Host string
+ Port int
+ Password string
+ DB int
+ PoolSize int
+ MaxIdleConns int
+ MinIdleConns int
+ MaxRetryBackoff int
+ MinRetryBackoff int
+ MaxRetries int
+ DialTimeout int
+ ReadTimeout int
+ WriteTimeout int
+ PoolTimeout int
+}
+
+type OtelSettingS struct {
+ Enabled bool
+ ServiceName string
+ ServiceVersion string
+ Environment string
+ ExporterEndpoint string
+ Insecure bool
+ SamplerRatio float64
+}
+
+type JWTSettingS struct {
+ SecretKey string
+ Issuer string
+ AccessTokenExpire int // 秒
+ RefreshTokenExpire int // 秒
+}
+
+type UploadSettingS struct {
+ TmpSavePath string
+ MaxSizeMB int
+ AllowedExtensions []string
+}
+
+type CosSettingS struct {
+ CosSecretID string
+ CosSecretKey string
+ CosUploadDomain string
+ CosDownloadDomain string
+ CdnDownloadSecretKey string
+}
+
+type TokenhubSettingS struct {
+ APIKey string
+ Model string
+}
+
+type MattingModelSettingS struct {
+ Host string
+ Port int
+ MaxRetry int
+}
+
+type ClassificationModelSettingS struct {
+ Host string
+ Port int
+ MaxRetry int
+}
+
+// WechatMiniSettingS 微信小程序配置(用于 code2session 登录)。
+type WechatMiniSettingS struct {
+ AppID string
+ AppSecret string
+}
+
+// WechatPaySettingS 微信支付 V3 商户配置。
+type WechatPaySettingS struct {
+ Enable bool // 是否启用微信支付;未开通时置 false,支付下单返回「未配置」
+ AppID string // 小程序 AppID(下单/调起支付使用)
+ MchID string // 商户号
+ APIv3Key string // APIv3 密钥(32 字节,用于回调解密)
+ SerialNo string // 商户证书序列号
+ PrivateKeyPath string // 商户 API 私钥 PEM 文件路径
+ NotifyURL string // 支付结果回调地址
+ RefundNotifyURL string // 退款结果回调地址
+}
+
+// VirtualPaySettingS 微信小程序虚拟支付(XPay)配置。
+// 虚拟支付与普通微信支付为独立商户号、独立结算,配置项在 MP 后台「虚拟支付 → 基本配置」获取。
+type VirtualPaySettingS struct {
+ OfferID string // 虚拟支付 OfferID(AppID)
+ EnvAppKey string // 现网 AppKey(env=0)
+ SandboxAppKey string // 沙箱 AppKey(env=1,iOS 不支持沙箱,默认不启用)
+ Token string // 消息推送验签 Token(配置回调地址时自定义)
+ Enable bool // 是否启用虚拟支付;未开通时置 false,虚拟商品下单返回「虚拟支付未开通」
+}
+
+// UserDefaultQuotaSettingS 新用户注册时初始化的免费额度与赠送会员天数配置。
+// 使用指针类型以区分「未配置」与「显式配置为 0」:
+// - nil:该项未配置(或整段缺失),由 service 层回落内置默认常量;
+// - 非 nil:以配置值为准,允许为 0(额度类表示不赠送,GiftVIPDay 表示不赠送体验会员);
+// - 负数:视为非法配置,回落内置默认常量。
+type UserDefaultQuotaSettingS struct {
+ Cos *int64 // 免费上传额度(永久,不每天重置)
+ AI *int64 // 免费 AI 推荐额度(每日,每天 0 点重置)
+ OutfitShare *int64 // 免费社区分享额度(每日,每天 0 点重置)
+ ClosetShare *int64 // 免费衣柜分享额度(每日,每天 0 点重置;0 表示不赠送)
+ GiftVIPDay *int64 // 注册赠送的体验会员天数(vip_type=4,0 表示不赠送)
+}
+
+// AdRewardSettingS 广告激励额度配置。
+// 金额为服务端固定值,前端不传金额,从根源杜绝刷额度;各类型金额使用指针以区分
+// 「未配置/显式 0」,nil 或非正数表示不发放该类型激励。
+type AdRewardSettingS struct {
+ Enable bool // 是否开启广告激励;关闭时接口返回「广告激励未开启」
+ Cos *int64 // 广告激励赠送的 Cos 上传额度(固定值)
+ AI *int64 // 广告激励赠送的 AI 推荐额度(固定值)
+ OutfitShare *int64 // 广告激励赠送的社区分享额度(固定值)
+ ClosetShare *int64 // 广告激励赠送的衣柜分享额度(固定值)
+}
+
+func (s *Setting) ReadSection(k string, v any) error {
+ err := s.vp.UnmarshalKey(k, v)
+ if err != nil {
+ return err
+ }
+ return nil
+}
diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go
new file mode 100644
index 0000000..42fdc86
--- /dev/null
+++ b/pkg/setting/setting.go
@@ -0,0 +1,24 @@
+package setting
+
+import (
+ "github.com/spf13/viper"
+)
+
+// Setting 封装 viper 配置读取能力。
+type Setting struct {
+ vp *viper.Viper
+}
+
+// NewSetting 初始化配置读取器,仅从 configs/config.yaml 读取配置。
+// 所有配置项一律在配置文件中直接填写,不支持环境变量覆盖。
+func NewSetting() (*Setting, error) {
+ vp := viper.New()
+ vp.AddConfigPath("configs/")
+ vp.SetConfigName("config")
+ vp.SetConfigType("yaml")
+ if err := vp.ReadInConfig(); err != nil {
+ return nil, err
+ }
+
+ return &Setting{vp: vp}, nil
+}
diff --git a/pkg/tokenhub/client.go b/pkg/tokenhub/client.go
new file mode 100644
index 0000000..09eb23d
--- /dev/null
+++ b/pkg/tokenhub/client.go
@@ -0,0 +1,101 @@
+// Package tokenhub 封装 Tokenhub 大模型 API 调用,用于生成每日星座运势与穿搭推荐。
+package tokenhub
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+)
+
+const baseURL = "https://tokenhub.tencentmaas.com/v1/chat/completions"
+
+// Client Tokenhub API 客户端。
+type Client struct {
+ apiKey string
+ model string
+ httpClient *http.Client
+}
+
+// NewClient 从全局配置创建客户端。
+func NewClient() *Client {
+ return &Client{
+ apiKey: global.TokenhubSetting.APIKey,
+ model: global.TokenhubSetting.Model,
+ httpClient: &http.Client{
+ Timeout: 120 * time.Second,
+ },
+ }
+}
+
+// Message 聊天消息。
+type Message struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+}
+
+// chatResponse Tokenhub chat/completions 外层响应结构(只取需要的字段)。
+type chatResponse struct {
+ Choices []struct {
+ Message struct {
+ Content string `json:"content"`
+ } `json:"message"`
+ } `json:"choices"`
+}
+
+// ResponseFormat JSON Schema 响应格式。
+type ResponseFormat struct {
+ Type string `json:"type"`
+ JSONSchema *JSONSchema `json:"json_schema"`
+}
+
+// JSONSchema 结构化输出定义。
+type JSONSchema struct {
+ Name string `json:"name"`
+ Schema interface{} `json:"schema"`
+}
+
+// ChatCompletion 调用混元 chat/completions,返回原始响应体。
+func (c *Client) ChatCompletion(ctx context.Context, messages []Message, responseFormat *ResponseFormat) ([]byte, error) {
+ body := map[string]interface{}{
+ "model": c.model,
+ "messages": messages,
+ }
+ if responseFormat != nil {
+ body["response_format"] = responseFormat
+ }
+
+ jsonBody, err := json.Marshal(body)
+ if err != nil {
+ return nil, fmt.Errorf("marshal request: %w", err)
+ }
+
+ request, err := http.NewRequestWithContext(ctx, "POST", baseURL, bytes.NewBuffer(jsonBody))
+ if err != nil {
+ return nil, fmt.Errorf("create request: %w", err)
+ }
+ request.Header.Set("Authorization", "Bearer "+c.apiKey)
+ request.Header.Set("Content-Type", "application/json")
+
+ response, err := c.httpClient.Do(request)
+ if err != nil {
+ return nil, fmt.Errorf("do request: %w", err)
+ }
+ defer response.Body.Close()
+
+ data, err := io.ReadAll(response.Body)
+ if err != nil {
+ return nil, fmt.Errorf("read response: %w", err)
+ }
+
+ if response.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("tokenhub api error: status=%d, body=%s", response.StatusCode, string(data))
+ }
+
+ return data, nil
+}
diff --git a/pkg/tokenhub/fortune.go b/pkg/tokenhub/fortune.go
new file mode 100644
index 0000000..95b2761
--- /dev/null
+++ b/pkg/tokenhub/fortune.go
@@ -0,0 +1,206 @@
+package tokenhub
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strings"
+ "time"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
+)
+
+// FortuneItem 单个星座运势。
+type FortuneItem struct {
+ Zodiac string `json:"zodiac"`
+ Overall int8 `json:"overall"`
+ Love int8 `json:"love"`
+ Work int8 `json:"work"`
+ Money int8 `json:"money"`
+ LuckyColor string `json:"lucky_color"`
+ LuckyNumber string `json:"lucky_number"`
+ Description string `json:"description"`
+}
+
+// ToHoroscope 将 LLM 返回的运势项转换为 Horoscope 模型。
+func (f FortuneItem) ToHoroscope(statDate int64) *mysqlmodel.Horoscope {
+ return &mysqlmodel.Horoscope{
+ StatDate: statDate,
+ Zodiac: f.Zodiac,
+ OverallScore: f.Overall,
+ LoveScore: f.Love,
+ CareerScore: f.Work,
+ WealthScore: f.Money,
+ LuckyColor: f.LuckyColor,
+ LuckyNumber: f.LuckyNumber,
+ DailyText: f.Description,
+ }
+}
+
+// FortuneResponse Tokenhub 返回的运势 JSON。
+type FortuneResponse struct {
+ Date string `json:"date"`
+ Fortunes []FortuneItem `json:"fortunes"`
+}
+
+// GenerateDailyFortunes 调用 Tokenhub 生成今日十二星座运势。
+func (c *Client) GenerateDailyFortunes(ctx context.Context) (*FortuneResponse, error) {
+ today := time.Now().Format("2006-01-02")
+
+ messages := []Message{
+ {Role: "user", Content: buildFortunePrompt(today)},
+ }
+
+ responseFormat := &ResponseFormat{
+ Type: "json_schema",
+ JSONSchema: &JSONSchema{
+ Name: "daily_fortunes",
+ Schema: buildFortuneSchema(),
+ },
+ }
+
+ data, err := c.ChatCompletion(ctx, messages, responseFormat)
+ if err != nil {
+ return nil, err
+ }
+
+ // 解析混元外层响应
+ var cr chatResponse
+ if err := json.Unmarshal(data, &cr); err != nil {
+ return nil, fmt.Errorf("unmarshal chat response: %w", err)
+ }
+
+ if len(cr.Choices) == 0 {
+ return nil, fmt.Errorf("no choices in response")
+ }
+
+ // 解析内层内容。注意:部分模型(如 hy3-preview)不严格遵守 json_schema,
+ // 可能返回中文键名的 map 结构,这里做容错解析。
+ content := sanitizeJSONContent(cr.Choices[0].Message.Content)
+ result, err := parseFortuneContent(content)
+ if err != nil {
+ return nil, err
+ }
+
+ // 兜底:模型未返回日期时,使用当天日期
+ if result.Date == "" {
+ result.Date = time.Now().Format("2006-01-02")
+ }
+
+ return result, nil
+}
+
+// cnFortuneEntry 兼容模型返回的中文键名结构:{"白羊座":{"综合运势":4,...}}。
+type cnFortuneEntry struct {
+ Overall int8 `json:"综合运势"`
+ Love int8 `json:"爱情运势"`
+ Work int8 `json:"工作运势"`
+ Money int8 `json:"财富运势"`
+ LuckyColor string `json:"幸运色"`
+ LuckyNumber string `json:"幸运数字"`
+ Description string `json:"今日运势描述"`
+}
+
+// sanitizeJSONContent 去除可能的 markdown 代码围栏与首尾空白。
+func sanitizeJSONContent(s string) string {
+ s = strings.TrimSpace(s)
+ if strings.HasPrefix(s, "```") {
+ if idx := strings.IndexByte(s, '\n'); idx != -1 {
+ s = s[idx+1:]
+ }
+ s = strings.TrimSuffix(strings.TrimSpace(s), "```")
+ }
+ return strings.TrimSpace(s)
+}
+
+// parseFortuneContent 容错解析运势内容,兼容两种结构:
+// 1. 标准结构 {"date":..,"fortunes":[{"zodiac":..,"overall":..}]}
+// 2. 中文键名结构 {"白羊座":{"综合运势":..}}
+func parseFortuneContent(content string) (*FortuneResponse, error) {
+ // 1. 标准结构
+ var std FortuneResponse
+ if err := json.Unmarshal([]byte(content), &std); err == nil && len(std.Fortunes) > 0 {
+ return &std, nil
+ }
+
+ // 2. 中文键名 map 结构
+ var cn map[string]cnFortuneEntry
+ if err := json.Unmarshal([]byte(content), &cn); err == nil && len(cn) > 0 {
+ items := make([]FortuneItem, 0, len(cn))
+ for zodiac, v := range cn {
+ // 过滤非星座键(无有效运势内容)
+ if v.Description == "" && v.Overall == 0 {
+ continue
+ }
+ items = append(items, FortuneItem{
+ Zodiac: zodiac,
+ Overall: v.Overall,
+ Love: v.Love,
+ Work: v.Work,
+ Money: v.Money,
+ LuckyColor: v.LuckyColor,
+ LuckyNumber: v.LuckyNumber,
+ Description: v.Description,
+ })
+ }
+ if len(items) > 0 {
+ return &FortuneResponse{Fortunes: items}, nil
+ }
+ }
+
+ return nil, fmt.Errorf("no fortunes parsed from content, raw=%s", content)
+}
+
+// buildFortunePrompt 构造运势生成 prompt。
+func buildFortunePrompt(today string) string {
+ return fmt.Sprintf(`请为十二星座生成%s的每日运势。
+
+要求:
+1. 十二星座运势要有差异,星数分布要合理,不能全部一样
+2. 描述要个性化,不要套模板,50 字以内,生动有趣,可结合当前季节和月份特点
+3. 整体运势要乐观积极
+4. 描述中不要出现星号(*)或 markdown 标记
+5. 每个星座给出幸运色(lucky_color)和幸运数字(lucky_number)
+
+必须严格按以下 JSON 结构输出(英文键名,overall/love/work/money 为 1-5 的整数),不要输出任何多余文字或代码围栏:
+{
+ "date": "%s",
+ "fortunes": [
+ {"zodiac": "白羊座", "overall": 4, "love": 3, "work": 5, "money": 4, "lucky_color": "红色", "lucky_number": "6", "description": "……"}
+ ]
+}
+其中 fortunes 数组必须恰好包含 12 个星座(白羊座、金牛座、双子座、巨蟹座、狮子座、处女座、天秤座、天蝎座、射手座、摩羯座、水瓶座、双鱼座)。`,
+ today, today)
+}
+
+// buildFortuneSchema 构造 JSON Schema 让混元按结构返回。
+func buildFortuneSchema() map[string]interface{} {
+ return map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{
+ "date": map[string]interface{}{
+ "type": "string",
+ "description": "日期,格式 YYYY-MM-DD",
+ },
+ "fortunes": map[string]interface{}{
+ "type": "array",
+ "description": "十二星座运势列表,必须恰好 12 条",
+ "items": map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{
+ "zodiac": map[string]interface{}{"type": "string", "description": "星座名称,如:白羊座"},
+ "overall": map[string]interface{}{"type": "integer", "description": "综合运势,1-5 星"},
+ "love": map[string]interface{}{"type": "integer", "description": "爱情运势,1-5 星"},
+ "work": map[string]interface{}{"type": "integer", "description": "工作运势,1-5 星"},
+ "money": map[string]interface{}{"type": "integer", "description": "财富运势,1-5 星"},
+ "lucky_color": map[string]interface{}{"type": "string", "description": "幸运色"},
+ "lucky_number": map[string]interface{}{"type": "string", "description": "幸运数字"},
+ "description": map[string]interface{}{"type": "string", "description": "今日运势描述,50 字以内"},
+ },
+ "required": []string{"zodiac", "overall", "love", "work", "money", "lucky_color", "lucky_number", "description"},
+ },
+ },
+ },
+ "required": []string{"date", "fortunes"},
+ }
+}
diff --git a/pkg/tokenhub/outfit_recommend.go b/pkg/tokenhub/outfit_recommend.go
new file mode 100644
index 0000000..a36d19d
--- /dev/null
+++ b/pkg/tokenhub/outfit_recommend.go
@@ -0,0 +1,262 @@
+package tokenhub
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
+)
+
+// outfitCategories 穿搭推荐支持的衣物大类,与 mysqlmodel.Clothes.CategoryMain 取值对齐。
+var outfitCategories = []string{"上装", "下装", "连衣裙", "外套", "鞋", "配饰"}
+
+// RecommendLLMItem 大模型返回的单品推荐。
+type RecommendLLMItem struct {
+ Type string `json:"type"` // 品类:上装/下装/连衣裙/外套/鞋/配饰
+ ClothesID int64 `json:"clothes_id"` // 衣橱衣物 ID
+ ClothesUUID string `json:"clothes_uuid"` // 衣橱衣物 UUID
+ Reason string `json:"reason"` // 选择理由
+}
+
+// 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"`
+}
+
+// GenerateOutfitRecommendation 调用混元生成今日穿搭推荐。
+func (c *Client) GenerateOutfitRecommendation(ctx context.Context, zodiac string, fortune *FortuneItem, clothes []mysqlmodel.Clothes) (*RecommendLLMResponse, error) {
+ messages := []Message{
+ {Role: "user", Content: buildRecommendPrompt(zodiac, fortune, clothes)},
+ }
+
+ responseFormat := &ResponseFormat{
+ Type: "json_schema",
+ JSONSchema: &JSONSchema{
+ Name: "outfit_recommendation",
+ Schema: buildRecommendSchema(),
+ },
+ }
+
+ data, err := c.ChatCompletion(ctx, messages, responseFormat)
+ if err != nil {
+ return nil, fmt.Errorf("call hunyuan for outfit recommend: %w", err)
+ }
+
+ var cr chatResponse
+ if err := json.Unmarshal(data, &cr); err != nil {
+ return nil, fmt.Errorf("unmarshal chat response: %w", err)
+ }
+
+ if len(cr.Choices) == 0 {
+ return nil, fmt.Errorf("no choices in response")
+ }
+
+ var result RecommendLLMResponse
+ content := sanitizeJSONContent(cr.Choices[0].Message.Content)
+ if err := json.Unmarshal([]byte(content), &result); err != nil {
+ return nil, fmt.Errorf("unmarshal outfit recommend json: %w, raw=%s", err, content)
+ }
+
+ return &result, nil
+}
+
+// buildRecommendPrompt 构造穿搭推荐 prompt。
+func buildRecommendPrompt(zodiac string, fortune *FortuneItem, clothes []mysqlmodel.Clothes) string {
+ clothesText := FormatClothesInfo(clothes)
+
+ return fmt.Sprintf(`你是一位专业的时尚搭配师。请根据用户今天的星座运势和衣橱中的衣物,为用户推荐一套今日穿搭。
+
+## 用户信息
+- 星座:%s
+- 今日综合运势:%d 星(满分 5 星)
+- 今日爱情运势:%d 星
+- 今日工作运势:%d 星
+- 今日财富运势:%d 星
+- 今日运势描述:%s
+
+## 用户衣橱
+%s
+
+## 搭配规则
+1. 所有推荐单品必须严格选自用户衣橱,clothes_id 与 clothes_uuid 必须与衣橱中存在的衣物一一对应,不得虚构
+2. 先识别衣橱中包含的有效品类,仅统计以下 6 类:上装、下装、连衣裙、外套、鞋、配饰:
+ - 每个存在的品类最多推荐 1 件最合适的单品
+ - 衣橱中没有的品类,不在结果中输出,无需强制凑满 6 件
+ - 连衣裙为「一件式」单品,若选择连衣裙则通常不再搭配下装,可搭配外套、鞋、配饰
+ - 鞋为必备品类(若衣橱中存在),配饰为可选点缀
+3. 结合今日运势调整风格:运势评分高可选择出彩、有设计感的搭配;运势一般则选择稳重、舒适的基础款搭配
+4. 参考衣服的版型(version)、面料材质(material)、图案纹理(pattern)、颜色(主色/副色)、袖型、衣长等进行搭配,确保搭配和谐
+5. 保证所选单品之间颜色、风格协调统一
+6. 为每件推荐单品撰写一句简洁的推荐理由
+7. 为整套穿搭拟定一个贴合风格与运势的好听标题
+
+## 输出要求
+必须严格按以下 JSON 结构输出,不要输出任何多余文字、解释或代码围栏:
+{
+ "outfit_name": "穿搭标题",
+ "description": "结合今日运势的整体搭配说明",
+ "style": "穿搭风格,如:简约/复古/街头/甜美等",
+ "scene": "适合场景,如:通勤/约会/休闲/运动等",
+ "items": [
+ {"type": "上装", "clothes_id": 1, "clothes_uuid": "衣橱中该衣物对应的UUID", "reason": "单品推荐理由"}
+ ]
+}
+补充约束:
+- type 字段取值仅限「上装」「下装」「连衣裙」「外套」「鞋」「配饰」,与衣橱实际品类一一对应
+- style 表示穿搭风格(如简约/复古/街头/甜美),scene 表示适合场景(如通勤/约会/休闲/运动),两者含义不同,必须分别输出
+- items 数组长度由衣橱中存在的品类数量决定,不存在的品类不写入数组
+- clothes_id 必须为整数,且必须是衣橱中真实存在的 ID
+- clothes_uuid 必须从「用户衣橱」信息中精确复制,且与 clothes_id 一一对应,严禁编造或改动 UUID`,
+ zodiac, fortune.Overall, fortune.Love, fortune.Work, fortune.Money, fortune.Description, clothesText)
+}
+
+// buildRecommendSchema 构造 JSON Schema 要求大模型结构化输出。
+func buildRecommendSchema() map[string]interface{} {
+ return map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{
+ "outfit_name": map[string]interface{}{
+ "type": "string",
+ "description": "穿搭标题",
+ },
+ "description": map[string]interface{}{
+ "type": "string",
+ "description": "穿搭整体描述,结合运势说明搭配理由",
+ },
+ "style": map[string]interface{}{
+ "type": "string",
+ "description": "穿搭风格,如:简约、复古、街头、甜美等",
+ },
+ "scene": map[string]interface{}{
+ "type": "string",
+ "description": "适合场景,如:通勤、约会、休闲、运动等",
+ },
+ "items": map[string]interface{}{
+ "type": "array",
+ "description": "推荐的单品列表,每个存在的品类最多一件,不存在的品类不输出",
+ "items": map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{
+ "type": map[string]interface{}{
+ "type": "string",
+ "description": "品类,必须是以下之一:上装、下装、连衣裙、外套、鞋、配饰",
+ "enum": outfitCategories,
+ },
+ "clothes_id": map[string]interface{}{
+ "type": "integer",
+ "description": "衣橱中衣物的 ID",
+ },
+ "clothes_uuid": map[string]interface{}{
+ "type": "string",
+ "description": "衣橱中衣物的 UUID,必须与 clothes_id 对应",
+ },
+ "reason": map[string]interface{}{
+ "type": "string",
+ "description": "推荐理由,一句话说明为什么选这件",
+ },
+ },
+ "required": []string{"type", "clothes_id", "clothes_uuid", "reason"},
+ },
+ },
+ },
+ "required": []string{"outfit_name", "description", "style", "scene", "items"},
+ }
+}
+
+// ValidateClothesIDs 校验并纠偏大模型返回的推荐结果:
+// 1. 过滤 clothes_id 不在用户衣橱中的项;
+// 2. 将 type 规范化为衣物实际的大类(CategoryMain);
+// 3. 以衣橱实际数据回填正确的 ClothesUUID(防止模型抄错 UUID);
+// 4. 对重复的 clothes_id 去重(保留首次出现)。
+func (r *RecommendLLMResponse) ValidateClothesIDs(clothes []mysqlmodel.Clothes) {
+ clothesByID := make(map[int64]mysqlmodel.Clothes, len(clothes))
+ for _, c := range clothes {
+ clothesByID[c.ID] = c
+ }
+
+ filtered := make([]RecommendLLMItem, 0, len(r.Items))
+ seen := make(map[int64]struct{}, len(r.Items))
+ for _, item := range r.Items {
+ c, ok := clothesByID[item.ClothesID]
+ if !ok {
+ continue
+ }
+ if _, dup := seen[item.ClothesID]; dup {
+ continue
+ }
+ // 以衣橱中实际数据为准,纠偏模型可能返回的 type 与 UUID。
+ item.Type = c.CategoryMain
+ item.ClothesUUID = c.ClothesUUID
+ seen[item.ClothesID] = struct{}{}
+ filtered = append(filtered, item)
+ }
+ r.Items = filtered
+}
+
+// GetClothesIDs 获取推荐中所有不重复的 clothes ID。
+func (r *RecommendLLMResponse) GetClothesIDs() []int64 {
+ ids := make([]int64, 0, len(r.Items))
+ seen := make(map[int64]struct{}, len(r.Items))
+ for _, item := range r.Items {
+ if _, ok := seen[item.ClothesID]; ok {
+ continue
+ }
+ seen[item.ClothesID] = struct{}{}
+ ids = append(ids, item.ClothesID)
+ }
+ return ids
+}
+
+// FormatClothesInfo 将衣橱衣物列表转换为传给 LLM 的摘要格式。
+// 使用「字段名:值」标注,补全穿搭相关字段,便于模型准确理解每个属性。
+// 所有字符串字段经 sanitizePromptField 清洗,降低 prompt 注入风险。
+func FormatClothesInfo(clothesList []mysqlmodel.Clothes) string {
+ var sb strings.Builder
+ for _, c := range clothesList {
+ sb.WriteString(fmt.Sprintf(
+ "- ID:%d | UUID:%s | 大类:%s | 小类:%s | 名称:%s | 主色:%s | 副色:%s | 风格:%s | 版型:%s | 材质:%s | 花纹:%s | 厚度:%s | 袖型:%s | 衣长:%s | 领型:%s | 正式度:%s | 场合:%s | 视觉冲击力:%s\n",
+ c.ID,
+ sanitizePromptField(c.ClothesUUID), sanitizePromptField(c.CategoryMain), sanitizePromptField(c.CategorySub),
+ sanitizePromptField(c.Name), sanitizePromptField(c.ColorMain), sanitizePromptField(c.ColorSub),
+ sanitizePromptField(c.Style), sanitizePromptField(c.Version), sanitizePromptField(c.Material),
+ sanitizePromptField(c.Pattern), sanitizePromptField(c.Thickness), sanitizePromptField(c.SleeveType),
+ sanitizePromptField(c.ClothesType), sanitizePromptField(c.NecklineType), sanitizePromptField(c.Formality),
+ sanitizePromptField(c.Occasions), sanitizePromptField(c.VisualImpact)))
+ }
+ return sb.String()
+}
+
+// sanitizePromptField 清洗拼入 prompt 的用户可控字段:
+// 截断长度、移除换行与控制字符、压缩连续空白,降低 prompt 注入风险。
+func sanitizePromptField(s string) string {
+ const maxLen = 200
+ if s == "" {
+ return ""
+ }
+
+ var b strings.Builder
+ for _, r := range s {
+ switch {
+ case r == '\n' || r == '\r' || r == '\t':
+ b.WriteRune(' ') // 换行/制表符压缩为空格,防止注入换行指令。
+ case r < 0x20 || r == 0x7F:
+ continue // 丢弃其它控制字符。
+ default:
+ b.WriteRune(r)
+ }
+ }
+
+ // 压缩连续空白。
+ out := strings.Join(strings.Fields(b.String()), " ")
+ runes := []rune(out)
+ if len(runes) > maxLen {
+ out = string(runes[:maxLen])
+ }
+ return out
+}
diff --git a/pkg/tokenhub/scheduler.go b/pkg/tokenhub/scheduler.go
new file mode 100644
index 0000000..ab75042
--- /dev/null
+++ b/pkg/tokenhub/scheduler.go
@@ -0,0 +1,170 @@
+package tokenhub
+
+import (
+ "context"
+ "log/slog"
+ "strconv"
+ "sync"
+ "time"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao"
+ "github.com/google/uuid"
+)
+
+// fortuneEnsureMu 用于同一实例内串行化当日运势兜底生成,避免并发重复触发 LLM。
+var fortuneEnsureMu sync.Mutex
+
+// StartFortuneScheduler 启动后台定时任务:每天 0:00 生成当日运势并入库。
+func StartFortuneScheduler() {
+ go func() {
+ defer func() {
+ if r := recover(); r != nil {
+ slog.Error("fortune scheduler panic", "recover", r)
+ }
+ }()
+ slog.Info("fortune scheduler started")
+
+ client := NewClient()
+ horoscopeDao := mysqldao.NewHoroscopeDao()
+
+ for {
+ // 每次循环重新计算到下一个午夜的等待时间,避免漂移累积。
+ now := time.Now()
+ next := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location())
+ wait := next.Sub(now)
+
+ slog.Info("fortune scheduler: next run",
+ "at", next.Format("2006-01-02 15:04:05"),
+ "in", wait.Round(time.Second).String(),
+ )
+
+ time.Sleep(wait)
+
+ // 生成 & 入库(带分布式锁,多实例仅一个执行)。
+ func() {
+ defer func() {
+ if r := recover(); r != nil {
+ slog.Error("fortune generation panic", "recover", r)
+ }
+ }()
+ runFortuneGenerationWithLock(client, horoscopeDao)
+ }()
+ }
+ }()
+}
+
+// runFortuneGeneration 调用混元生成运势,写入数据库。
+func runFortuneGeneration(client *Client, horoscopeDao *mysqldao.HoroscopeDao) {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ defer cancel()
+
+ ctx, span := global.Tracer.Start(ctx, "scheduler.FortuneGeneration")
+ defer span.End()
+
+ slog.InfoContext(ctx, "generating daily fortunes...")
+
+ result, err := client.GenerateDailyFortunes(ctx)
+ if err != nil {
+ span.RecordError(err)
+ slog.ErrorContext(ctx, "generate fortunes failed", "error", err)
+ return
+ }
+
+ // result.Date 形如 "2006-01-02",转换为 Horoscope.StatDate 的 YYYYMMDD 整数。
+ date, err := time.Parse("2006-01-02", result.Date)
+ if err != nil {
+ slog.ErrorContext(ctx, "parse fortune date failed", "date", result.Date, "error", err)
+ return
+ }
+ statDate, err := strconv.ParseInt(date.Format("20060102"), 10, 64)
+ if err != nil {
+ slog.ErrorContext(ctx, "parse stat date failed", "date", result.Date, "error", err)
+ return
+ }
+
+ successCount := 0
+ for _, f := range result.Fortunes {
+ horoscope := f.ToHoroscope(statDate)
+ if err := horoscopeDao.HoroscopeCreate(ctx, horoscope); err != nil {
+ slog.ErrorContext(ctx, "save horoscope failed", "zodiac", f.Zodiac, "error", err)
+ } else {
+ successCount++
+ }
+ }
+
+ slog.InfoContext(ctx, "daily fortune generation completed",
+ "total", len(result.Fortunes),
+ "saved", successCount,
+ )
+}
+
+// runFortuneGenerationWithLock 带 Redis 分布式锁执行当日运势生成:
+// 多实例部署时仅一个实例执行;锁释放校验持有者 value,防止超时误删其它实例的锁。
+func runFortuneGenerationWithLock(client *Client, horoscopeDao *mysqldao.HoroscopeDao) {
+ today := time.Now().Format("2006-01-02")
+ lockKey := "ztcloset:lock:fortune:daily:" + today
+ lockVal := uuid.NewString()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ defer cancel()
+
+ ok, lerr := redisdao.TryLock(ctx, lockKey, lockVal, 5*time.Minute)
+ if lerr != nil || !ok {
+ slog.Info("fortune generation already in progress, skip", "date", today)
+ return
+ }
+ defer redisdao.Unlock(context.Background(), lockKey, lockVal)
+
+ runFortuneGeneration(client, horoscopeDao)
+}
+
+// EnsureTodayFortunes 检查今日运势数据是否完整,不完整则自动生成。
+// 该函数异步执行,适用于服务器启动时兜底。
+func EnsureTodayFortunes() {
+ go func() {
+ defer func() {
+ if r := recover(); r != nil {
+ slog.Error("EnsureTodayFortunes panic", "recover", r)
+ }
+ }()
+ // 进程内防重:已有兜底生成在进行中则直接跳过。
+ if !fortuneEnsureMu.TryLock() {
+ return
+ }
+ defer fortuneEnsureMu.Unlock()
+
+ client := NewClient()
+ horoscopeDao := mysqldao.NewHoroscopeDao()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ defer cancel()
+
+ now := time.Now()
+ today := now.Format("2006-01-02")
+ statDate, err := strconv.ParseInt(now.Format("20060102"), 10, 64)
+ if err != nil {
+ slog.Error("EnsureTodayFortunes: parse stat date failed", "error", err)
+ return
+ }
+
+ horoscopes, err := horoscopeDao.HoroscopeGetByDate(ctx, statDate)
+ if err != nil {
+ slog.Warn("EnsureTodayFortunes: query today fortunes failed, will try to generate", "error", err, "date", today)
+ }
+
+ if len(horoscopes) >= 12 {
+ slog.Info("EnsureTodayFortunes: today fortunes already complete, skip", "date", today, "count", len(horoscopes))
+ return
+ }
+
+ slog.Info("EnsureTodayFortunes: today fortunes incomplete, generating...",
+ "date", today,
+ "existing", len(horoscopes),
+ "needed", 12,
+ )
+
+ runFortuneGenerationWithLock(client, horoscopeDao)
+ }()
+}
diff --git a/pkg/tracing/fanout.go b/pkg/tracing/fanout.go
new file mode 100644
index 0000000..dd51e11
--- /dev/null
+++ b/pkg/tracing/fanout.go
@@ -0,0 +1,68 @@
+package tracing
+
+import (
+ "context"
+ "errors"
+ "log/slog"
+)
+
+// FanoutHandler 是一个将日志记录扇出到多个 slog.Handler 的组合处理器,
+// 用于同时输出本地 JSON 日志与 OTel 日志桥接。
+type FanoutHandler struct {
+ handlers []slog.Handler
+}
+
+// NewFanoutHandler 创建扇出处理器(忽略 nil handler)。
+func NewFanoutHandler(handlers ...slog.Handler) *FanoutHandler {
+ filtered := make([]slog.Handler, 0, len(handlers))
+ for _, h := range handlers {
+ if h != nil {
+ filtered = append(filtered, h)
+ }
+ }
+ return &FanoutHandler{handlers: filtered}
+}
+
+// Enabled 任一子处理器启用即返回 true。
+func (f *FanoutHandler) Enabled(ctx context.Context, level slog.Level) bool {
+ for _, h := range f.handlers {
+ if h.Enabled(ctx, level) {
+ return true
+ }
+ }
+ return false
+}
+
+// Handle 将记录分发给每个启用的子处理器(各自使用副本,避免相互污染)。
+func (f *FanoutHandler) Handle(ctx context.Context, r slog.Record) error {
+ var errs []error
+ for _, h := range f.handlers {
+ if h.Enabled(ctx, r.Level) {
+ if err := h.Handle(ctx, r.Clone()); err != nil {
+ errs = append(errs, err)
+ }
+ }
+ }
+ return errors.Join(errs...)
+}
+
+// WithAttrs 为每个子处理器附加属性。
+func (f *FanoutHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
+ next := make([]slog.Handler, len(f.handlers))
+ for i, h := range f.handlers {
+ next[i] = h.WithAttrs(attrs)
+ }
+ return &FanoutHandler{handlers: next}
+}
+
+// WithGroup 为每个子处理器开启分组。
+func (f *FanoutHandler) WithGroup(name string) slog.Handler {
+ if name == "" {
+ return f
+ }
+ next := make([]slog.Handler, len(f.handlers))
+ for i, h := range f.handlers {
+ next[i] = h.WithGroup(name)
+ }
+ return &FanoutHandler{handlers: next}
+}
diff --git a/pkg/tracing/otel.go b/pkg/tracing/otel.go
new file mode 100644
index 0000000..c1031c8
--- /dev/null
+++ b/pkg/tracing/otel.go
@@ -0,0 +1,35 @@
+package tracing
+
+import (
+ "context"
+ "log/slog"
+
+ "go.opentelemetry.io/otel/trace"
+)
+
+type OtelContextHandler struct {
+ Handler slog.Handler
+}
+
+func (h OtelContextHandler) Handle(ctx context.Context, r slog.Record) error {
+ if span := trace.SpanFromContext(ctx); span.SpanContext().IsValid() {
+ sc := span.SpanContext()
+ r.AddAttrs(
+ slog.String("trace_id", sc.TraceID().String()),
+ slog.String("span_id", sc.SpanID().String()),
+ )
+ }
+ return h.Handler.Handle(ctx, r)
+}
+
+func (h OtelContextHandler) Enabled(ctx context.Context, level slog.Level) bool {
+ return h.Handler.Enabled(ctx, level)
+}
+
+func (h OtelContextHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
+ return OtelContextHandler{Handler: h.Handler.WithAttrs(attrs)}
+}
+
+func (h OtelContextHandler) WithGroup(name string) slog.Handler {
+ return OtelContextHandler{Handler: h.Handler.WithGroup(name)}
+}
diff --git a/pkg/tracing/provider.go b/pkg/tracing/provider.go
new file mode 100644
index 0000000..7e67164
--- /dev/null
+++ b/pkg/tracing/provider.go
@@ -0,0 +1,154 @@
+package tracing
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/setting"
+
+ "go.opentelemetry.io/contrib/instrumentation/runtime"
+ "go.opentelemetry.io/otel"
+ "go.opentelemetry.io/otel/attribute"
+ "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc"
+ "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
+ "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
+ logglobal "go.opentelemetry.io/otel/log/global"
+ metricnoop "go.opentelemetry.io/otel/metric/noop"
+ "go.opentelemetry.io/otel/propagation"
+ sdklog "go.opentelemetry.io/otel/sdk/log"
+ sdkmetric "go.opentelemetry.io/otel/sdk/metric"
+ "go.opentelemetry.io/otel/sdk/resource"
+ sdktrace "go.opentelemetry.io/otel/sdk/trace"
+ "go.opentelemetry.io/otel/trace"
+ "go.opentelemetry.io/otel/trace/noop"
+)
+
+// defaultServiceName 缺省服务名。
+const defaultServiceName = "zt-closet-backend"
+
+// noopShutdown 安全的空关闭函数。
+func noopShutdown(context.Context) error { return nil }
+
+// NewOTelProvider 初始化 OpenTelemetry 三大信号(Traces/Metrics/Logs)并通过 OTLP gRPC 导出。
+//
+// 返回:
+// - trace.Tracer:全局 Tracer(供 global.Tracer 使用);
+// - func(ctx) error:聚合的优雅关闭函数,进程退出时调用以 flush 三个 Provider;
+// - error:初始化错误。
+//
+// 行为约定:
+// - cfg.Enabled=false:返回 no-op Tracer 与空 Shutdown,不建立任何导出器(业务层 Tracer.Start 仍安全)。
+// - cfg.Insecure=true:OTLP gRPC 使用非 TLS(内网采集器)。
+// - 导出器采用惰性连接,采集器不可用时不阻断启动,仅后台重试。
+func NewOTelProvider(cfg *setting.OtelSettingS) (trace.Tracer, func(context.Context) error, error) {
+ name := defaultServiceName
+ if cfg != nil && cfg.ServiceName != "" {
+ name = cfg.ServiceName
+ }
+
+ // 未启用:降级为 no-op。
+ if cfg == nil || !cfg.Enabled {
+ return noop.NewTracerProvider().Tracer(name), noopShutdown, nil
+ }
+
+ ctx := context.Background()
+
+ res, err := resource.New(ctx, resource.WithAttributes(
+ attribute.String("service.name", name),
+ attribute.String("service.version", valueOr(cfg.ServiceVersion, "unknown")),
+ attribute.String("deployment.environment", valueOr(cfg.Environment, "unknown")),
+ ))
+ if err != nil {
+ return noop.NewTracerProvider().Tracer(name), noopShutdown, fmt.Errorf("otel build resource: %w", err)
+ }
+
+ endpoint := cfg.ExporterEndpoint
+
+ // ===== Traces =====
+ traceOpts := []otlptracegrpc.Option{otlptracegrpc.WithEndpoint(endpoint)}
+ if cfg.Insecure {
+ traceOpts = append(traceOpts, otlptracegrpc.WithInsecure())
+ }
+ traceExp, err := otlptracegrpc.New(ctx, traceOpts...)
+ if err != nil {
+ return noop.NewTracerProvider().Tracer(name), noopShutdown, fmt.Errorf("otel trace exporter: %w", err)
+ }
+
+ ratio := cfg.SamplerRatio
+ if ratio <= 0 {
+ ratio = 1.0
+ }
+ tp := sdktrace.NewTracerProvider(
+ sdktrace.WithBatcher(traceExp),
+ sdktrace.WithResource(res),
+ sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(ratio))),
+ )
+ otel.SetTracerProvider(tp)
+ otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
+ propagation.TraceContext{},
+ propagation.Baggage{},
+ ))
+
+ // ===== Metrics =====
+ metricOpts := []otlpmetricgrpc.Option{otlpmetricgrpc.WithEndpoint(endpoint)}
+ if cfg.Insecure {
+ metricOpts = append(metricOpts, otlpmetricgrpc.WithInsecure())
+ }
+ metricExp, err := otlpmetricgrpc.New(ctx, metricOpts...)
+ if err != nil {
+ // 已建 trace,尽力关闭并恢复全局默认 provider,避免留下已关闭的 provider。
+ _ = tp.Shutdown(ctx)
+ otel.SetTracerProvider(noop.NewTracerProvider())
+ return noop.NewTracerProvider().Tracer(name), noopShutdown, fmt.Errorf("otel metric exporter: %w", err)
+ }
+ mp := sdkmetric.NewMeterProvider(
+ sdkmetric.WithResource(res),
+ sdkmetric.WithReader(sdkmetric.NewPeriodicReader(metricExp)),
+ )
+ otel.SetMeterProvider(mp)
+
+ // Go runtime 指标采集(GC、goroutine、内存等)。
+ if rerr := runtime.Start(runtime.WithMeterProvider(mp)); rerr != nil {
+ // 非致命,仅记录。
+ fmt.Printf("otel runtime metrics start failed: %v\n", rerr)
+ }
+
+ // ===== Logs =====
+ logOpts := []otlploggrpc.Option{otlploggrpc.WithEndpoint(endpoint)}
+ if cfg.Insecure {
+ logOpts = append(logOpts, otlploggrpc.WithInsecure())
+ }
+ logExp, err := otlploggrpc.New(ctx, logOpts...)
+ if err != nil {
+ _ = tp.Shutdown(ctx)
+ _ = mp.Shutdown(ctx)
+ otel.SetTracerProvider(noop.NewTracerProvider())
+ otel.SetMeterProvider(metricnoop.NewMeterProvider())
+ return noop.NewTracerProvider().Tracer(name), noopShutdown, fmt.Errorf("otel log exporter: %w", err)
+ }
+ lp := sdklog.NewLoggerProvider(
+ sdklog.WithResource(res),
+ sdklog.WithProcessor(sdklog.NewBatchProcessor(logExp)),
+ )
+ logglobal.SetLoggerProvider(lp)
+
+ // 聚合优雅关闭:依次 flush/关闭三个 Provider。
+ shutdown := func(ctx context.Context) error {
+ return errors.Join(
+ tp.Shutdown(ctx),
+ mp.Shutdown(ctx),
+ lp.Shutdown(ctx),
+ )
+ }
+
+ return tp.Tracer(name), shutdown, nil
+}
+
+// valueOr 返回 v,v 为空时返回 def。
+func valueOr(v, def string) string {
+ if v == "" {
+ return def
+ }
+ return v
+}
diff --git a/pkg/upload/upload.go b/pkg/upload/upload.go
new file mode 100644
index 0000000..b3341a0
--- /dev/null
+++ b/pkg/upload/upload.go
@@ -0,0 +1,217 @@
+// Package upload 文件上传、校验、落盘、公开 token 生成的工具函数。
+package upload
+
+import (
+ "context"
+ "crypto/md5"
+ "crypto/rand"
+ "encoding/hex"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "os"
+ "path"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+)
+
+func FileGetExt(name string) string {
+ return strings.ToLower(path.Ext(name))
+}
+
+// 校验扩展名是否在白名单内。
+func FileCheckAllowExt(ext string, allowed []string) bool {
+ ext = strings.TrimPrefix(strings.ToLower(ext), ".")
+ for _, e := range allowed {
+ if strings.ToLower(e) == ext {
+ return true
+ }
+ }
+ return false
+}
+
+// 校验文件大小。maxMB <= 0 表示不限制。
+func FileCheckMaxSize(size, maxMB int64) bool {
+ if maxMB <= 0 {
+ return true
+ }
+ return size <= maxMB*1024*1024
+}
+
+// FileCheckImageMagic 校验文件头 magic bytes,判断内容是否为真实图片(jpeg/png/gif/webp)。
+// 用于防止伪造扩展名上传非图片文件(如脚本、可执行文件、HTML)。
+func FileCheckImageMagic(data []byte) bool {
+ if len(data) < 4 {
+ return false
+ }
+
+ // JPEG: FF D8 FF
+ if data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
+ return true
+ }
+
+ // PNG: 89 50 4E 47 0D 0A 1A 0A
+ if len(data) >= 8 &&
+ data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 &&
+ data[4] == 0x0D && data[5] == 0x0A && data[6] == 0x1A && data[7] == 0x0A {
+ return true
+ }
+
+ // GIF: "GIF8"
+ if data[0] == 'G' && data[1] == 'I' && data[2] == 'F' && data[3] == '8' {
+ return true
+ }
+
+ // WebP: "RIFF" .... "WEBP"
+ if len(data) >= 12 &&
+ data[0] == 'R' && data[1] == 'I' && data[2] == 'F' && data[3] == 'F' &&
+ data[8] == 'W' && data[9] == 'E' && data[10] == 'B' && data[11] == 'P' {
+ return true
+ }
+
+ return false
+}
+
+// GenFileName 生成防冲突随机相对文件名。
+func FileGenName(originName string) (string, error) {
+ ext := FileGetExt(originName)
+ b := make([]byte, 16)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(b) + ext, nil
+}
+
+// 临时保存从前端获取的图片
+func FileSaveTmp(ctx context.Context, file *multipart.FileHeader, fileName string, userID int64) (string, error) {
+ ctx, span := global.Tracer.Start(ctx, "upload.FileSaveTmp")
+ defer span.End()
+
+ dir := filepath.Join(global.UploadSetting.TmpSavePath, fmt.Sprint(userID))
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ span.RecordError(err)
+ return "", err
+ }
+
+ tmpFile := filepath.Join(dir, fileName)
+
+ dst, err := os.Create(tmpFile)
+ if err != nil {
+ return "", err
+ }
+ defer dst.Close()
+
+ src, err := file.Open()
+ if err != nil {
+ return "", err
+ }
+ defer src.Close()
+
+ if _, err := io.Copy(dst, src); err != nil {
+ return "", err
+ }
+
+ return tmpFile, nil
+
+}
+
+// 从后端抠图返回的图片保存到临时文件(subDir 为相对子目录,如 "用户UUID/用户ID")。
+func FileSaveTmpBytes(ctx context.Context, data []byte, fileName string, subDir string) (string, error) {
+ _, span := global.Tracer.Start(ctx, "upload.FileSaveTmpBytes")
+ defer span.End()
+
+ dir := filepath.Join(global.UploadSetting.TmpSavePath, subDir)
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ span.RecordError(err)
+ return "", err
+ }
+
+ tmpFile := filepath.Join(dir, fileName)
+ if err := os.WriteFile(tmpFile, data, 0o644); err != nil {
+ span.RecordError(err)
+ return "", err
+ }
+ return tmpFile, nil
+}
+
+// 将抠图后的文件上传到cos
+func FileUploadCos(ctx context.Context, fileName string) error {
+ ctx, span := global.Tracer.Start(ctx, "upload.FileUploadCos")
+ defer span.End()
+
+ // COS 客户端初始化失败时降级为 nil,此处做保护避免 nil 指针 panic。
+ if global.CosClient == nil {
+ err := fmt.Errorf("cos client not initialized")
+ span.RecordError(err)
+ return err
+ }
+
+ _, _, err := global.CosClient.Object.Upload(ctx, fileName, fmt.Sprintf("%s/%s", global.UploadSetting.TmpSavePath, fileName), nil)
+ if err != nil {
+ span.RecordError(err)
+ return err
+ }
+
+ return nil
+
+}
+
+// 删除 COS 对象。key 为对象键(即 processed_image 存储值)。
+// key 为空直接返回 nil;client 未初始化返回 error。
+// 删除为尽力而为的清理操作,内部使用短超时避免网络异常长时间阻塞调用方。
+func FileDeleteCos(ctx context.Context, key string) error {
+ ctx, span := global.Tracer.Start(ctx, "upload.FileDeleteCos")
+ defer span.End()
+
+ if key == "" {
+ return nil
+ }
+
+ // COS 客户端初始化失败时降级为 nil,此处做保护避免 nil 指针 panic。
+ if global.CosClient == nil {
+ err := fmt.Errorf("cos client not initialized")
+ span.RecordError(err)
+ return err
+ }
+
+ delCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
+ defer cancel()
+
+ _, err := global.CosClient.Object.Delete(delCtx, key)
+ if err != nil {
+ span.RecordError(err)
+ return err
+ }
+
+ return nil
+}
+
+// 获取cos访问url
+func GetCosUrl(fileName string) string {
+ ts := time.Now().Unix()
+ raw := global.CosSetting.CdnDownloadSecretKey + "/" + fileName + strconv.FormatInt(ts, 10)
+ sign := fmt.Sprintf("%x", md5.Sum([]byte(raw)))
+
+ return fmt.Sprintf("https://%s/%s?sign=%s&t=%d",
+ global.CosSetting.CosDownloadDomain,
+ fileName,
+ sign,
+ ts,
+ )
+}
+
+// AvatarURL 将头像存储值转为可访问 URL:
+// 历史通过修改资料接口传入的 http(s) URL 直接透传;COS key 统一转为 CDN 签名 URL。
+func AvatarURL(raw string) string {
+ if raw == "" {
+ return ""
+ }
+ if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") {
+ return raw
+ }
+ return GetCosUrl(raw)
+}
diff --git a/pkg/wechat/mini.go b/pkg/wechat/mini.go
new file mode 100644
index 0000000..7481516
--- /dev/null
+++ b/pkg/wechat/mini.go
@@ -0,0 +1,114 @@
+// Package wechat 封装微信小程序登录与微信支付 V3 的真实对接能力,仅依赖 Go 标准库。
+//
+// 安全说明:
+// - AppSecret / APIv3Key / 商户私钥均来自配置(禁止硬编码)。
+// - 仅访问微信固定域名(api.weixin.qq.com / api.mch.weixin.qq.com),无 SSRF 风险。
+package wechat
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "time"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+)
+
+// code2SessionURL 微信小程序登录凭证校验接口。
+const code2SessionURL = "https://api.weixin.qq.com/sns/jscode2session"
+
+// code2SessionResp 微信 code2session 响应。
+type code2SessionResp struct {
+ OpenID string `json:"openid"`
+ SessionKey string `json:"session_key"`
+ UnionID string `json:"unionid"`
+ ErrCode int `json:"errcode"`
+ ErrMsg string `json:"errmsg"`
+}
+
+// Code2SessionResult code2session 结果。
+type Code2SessionResult struct {
+ OpenID string
+ SessionKey string
+ UnionID string
+}
+
+// miniHTTPClient 小程序接口 HTTP 客户端(较短超时,微信接口通常很快)。
+var miniHTTPClient = &http.Client{Timeout: 10 * time.Second}
+
+// Code2Session 使用小程序登录 code 换取 openid / session_key。
+//
+// 依赖配置 global.WechatMiniSetting.AppID / AppSecret。
+// 常见错误:40029 code 无效、45011 频率限制、40163 code 已使用。
+func Code2Session(ctx context.Context, code string) (*Code2SessionResult, error) {
+ if global.WechatMiniSetting == nil || global.WechatMiniSetting.AppID == "" || global.WechatMiniSetting.AppSecret == "" {
+ return nil, fmt.Errorf("wechat mini not configured")
+ }
+
+ q := url.Values{}
+ q.Set("appid", global.WechatMiniSetting.AppID)
+ q.Set("secret", global.WechatMiniSetting.AppSecret)
+ q.Set("js_code", code)
+ q.Set("grant_type", "authorization_code")
+
+ reqURL := code2SessionURL + "?" + q.Encode()
+ request, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("code2session build request: %w", err)
+ }
+
+ response, err := miniHTTPClient.Do(request)
+ if err != nil {
+ return nil, fmt.Errorf("code2session do request: %w", err)
+ }
+ defer response.Body.Close()
+
+ data, err := io.ReadAll(response.Body)
+ if err != nil {
+ return nil, fmt.Errorf("code2session read response: %w", err)
+ }
+ if response.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("code2session http status %d: %s", response.StatusCode, string(data))
+ }
+
+ var r code2SessionResp
+ if err := json.Unmarshal(data, &r); err != nil {
+ return nil, fmt.Errorf("code2session decode: %w", err)
+ }
+ if r.ErrCode != 0 {
+ return nil, &Code2SessionError{Code: r.ErrCode, Msg: r.ErrMsg}
+ }
+ if r.OpenID == "" {
+ return nil, fmt.Errorf("code2session empty openid")
+ }
+
+ return &Code2SessionResult{
+ OpenID: r.OpenID,
+ SessionKey: r.SessionKey,
+ UnionID: r.UnionID,
+ }, nil
+}
+
+// Code2SessionError 微信 code2session 业务错误,携带 errcode 便于上层区分 code 无效等场景。
+type Code2SessionError struct {
+ Code int
+ Msg string
+}
+
+func (e *Code2SessionError) Error() string {
+ return fmt.Sprintf("code2session errcode=%d, errmsg=%s", e.Code, e.Msg)
+}
+
+// IsInvalidCode 判断是否为「code 无效 / 已使用 / 过期」类错误。
+func (e *Code2SessionError) IsInvalidCode() bool {
+ switch e.Code {
+ case 40029, // js_code 无效
+ 40163, // code been used
+ 41008: // missing code
+ return true
+ }
+ return false
+}
diff --git a/pkg/wechat/pay.go b/pkg/wechat/pay.go
new file mode 100644
index 0000000..c849bc3
--- /dev/null
+++ b/pkg/wechat/pay.go
@@ -0,0 +1,351 @@
+package wechat
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "sync"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/setting"
+
+ "github.com/wechatpay-apiv3/wechatpay-go/core"
+ "github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers"
+ "github.com/wechatpay-apiv3/wechatpay-go/core/downloader"
+ "github.com/wechatpay-apiv3/wechatpay-go/core/notify"
+ "github.com/wechatpay-apiv3/wechatpay-go/core/option"
+ "github.com/wechatpay-apiv3/wechatpay-go/services/payments"
+ "github.com/wechatpay-apiv3/wechatpay-go/services/payments/jsapi"
+ "github.com/wechatpay-apiv3/wechatpay-go/services/refunddomestic"
+ "github.com/wechatpay-apiv3/wechatpay-go/utils"
+)
+
+// payClient 微信支付客户端,基于官方 SDK(自动平台证书轮换 + 请求签名 + 应答/回调验签)。
+type payClient struct {
+ appID string
+ mchID string
+ notifyURL string
+ refundNotify string
+ client *core.Client
+ notifyHandler *notify.Handler
+}
+
+var (
+ defaultPayClient *payClient
+ payClientMu sync.RWMutex
+)
+
+// InitPayClient 初始化全局微信支付客户端。
+//
+// 使用官方 SDK 的 WithWechatPayAutoAuthCipher:加载商户私钥后自动注册平台证书下载器
+// (定期轮换),并具备请求签名、应答自动验签能力;同时构建回调处理器用于回调验签 + 解密。
+func InitPayClient(s *setting.WechatPaySettingS) error {
+ if s == nil {
+ return errors.New("wechat pay setting is nil")
+ }
+ if s.AppID == "" || s.MchID == "" || s.SerialNo == "" || s.APIv3Key == "" || s.PrivateKeyPath == "" {
+ return errors.New("wechat pay setting incomplete")
+ }
+ if len(s.APIv3Key) != 32 {
+ return fmt.Errorf("wechat pay APIv3Key must be 32 bytes, got %d", len(s.APIv3Key))
+ }
+
+ privateKey, err := utils.LoadPrivateKeyWithPath(s.PrivateKeyPath)
+ if err != nil {
+ return fmt.Errorf("load merchant private key: %w", err)
+ }
+
+ ctx := context.Background()
+ // 创建客户端:内部注册平台证书自动下载器(轮换)、请求签名器与应答验签器。
+ client, err := core.NewClient(
+ ctx,
+ option.WithWechatPayAutoAuthCipher(s.MchID, s.SerialNo, privateKey, s.APIv3Key),
+ )
+ if err != nil {
+ return fmt.Errorf("new wechat pay client: %w", err)
+ }
+
+ // 回调处理器:使用平台证书访问器进行回调验签,APIv3Key 进行解密。
+ certVisitor := downloader.MgrInstance().GetCertificateVisitor(s.MchID)
+ notifyHandler, err := notify.NewRSANotifyHandler(
+ s.APIv3Key,
+ verifiers.NewSHA256WithRSAVerifier(certVisitor),
+ )
+ if err != nil {
+ return fmt.Errorf("new wechat notify handler: %w", err)
+ }
+
+ c := &payClient{
+ appID: s.AppID,
+ mchID: s.MchID,
+ notifyURL: s.NotifyURL,
+ refundNotify: s.RefundNotifyURL,
+ client: client,
+ notifyHandler: notifyHandler,
+ }
+
+ payClientMu.Lock()
+ defaultPayClient = c
+ payClientMu.Unlock()
+ return nil
+}
+
+// getPayClient 获取已初始化的支付客户端。
+func getPayClient() (*payClient, error) {
+ payClientMu.RLock()
+ defer payClientMu.RUnlock()
+ if defaultPayClient == nil {
+ return nil, errors.New("wechat pay client not initialized")
+ }
+ return defaultPayClient, nil
+}
+
+// MiniPayParams 小程序调起支付所需参数(对应 dto.WechatPayParams)。
+type MiniPayParams struct {
+ TimeStamp string
+ NonceStr string
+ Package string
+ SignType string
+ PaySign string
+}
+
+// JsapiPrepay 调用微信 JSAPI 下单,一步返回小程序调起支付所需的签名参数。
+// - openid:支付用户在小程序 AppID 下的 openid
+// - orderNo:商户订单号(out_trade_no)
+// - description:商品描述
+// - amount:应付金额(分)
+func JsapiPrepay(ctx context.Context, openid, orderNo, description string, amount int64) (*MiniPayParams, error) {
+ c, err := getPayClient()
+ if err != nil {
+ return nil, err
+ }
+ if openid == "" {
+ return nil, errors.New("empty openid")
+ }
+
+ svc := jsapi.JsapiApiService{Client: c.client}
+ resp, _, err := svc.PrepayWithRequestPayment(ctx, jsapi.PrepayRequest{
+ Appid: core.String(c.appID),
+ Mchid: core.String(c.mchID),
+ Description: core.String(description),
+ OutTradeNo: core.String(orderNo),
+ NotifyUrl: core.String(c.notifyURL),
+ Amount: &jsapi.Amount{
+ Total: core.Int64(amount),
+ Currency: core.String("CNY"),
+ },
+ Payer: &jsapi.Payer{
+ Openid: core.String(openid),
+ },
+ })
+ if err != nil {
+ return nil, fmt.Errorf("jsapi prepay: %w", err)
+ }
+
+ return &MiniPayParams{
+ TimeStamp: strDeref(resp.TimeStamp),
+ NonceStr: strDeref(resp.NonceStr),
+ Package: strDeref(resp.Package),
+ SignType: strDeref(resp.SignType),
+ PaySign: strDeref(resp.PaySign),
+ }, nil
+}
+
+// CloseOrder 关闭微信侧待支付订单,防止本地取消后用户仍完成支付。
+// - orderNo:商户订单号(out_trade_no)
+func CloseOrder(ctx context.Context, orderNo string) error {
+ c, err := getPayClient()
+ if err != nil {
+ return err
+ }
+ if orderNo == "" {
+ return errors.New("empty order no")
+ }
+
+ svc := jsapi.JsapiApiService{Client: c.client}
+ if _, err := svc.CloseOrder(ctx, jsapi.CloseOrderRequest{
+ OutTradeNo: core.String(orderNo),
+ Mchid: core.String(c.mchID),
+ }); err != nil {
+ return fmt.Errorf("jsapi close order: %w", err)
+ }
+ return nil
+}
+
+// RefundResult 退款受理结果。
+type RefundResult struct {
+ RefundID string // 微信退款单号
+ Status string // SUCCESS/CLOSED/PROCESSING/ABNORMAL
+}
+
+// Refund 申请退款。
+// - orderNo:原商户订单号
+// - refundNo:商户退款单号
+// - total:原订单总金额(分)
+// - refund:本次退款金额(分)
+func Refund(ctx context.Context, orderNo, refundNo string, total, refund int64, reason string) (*RefundResult, error) {
+ c, err := getPayClient()
+ if err != nil {
+ return nil, err
+ }
+
+ req := refunddomestic.CreateRequest{
+ OutTradeNo: core.String(orderNo),
+ OutRefundNo: core.String(refundNo),
+ Amount: &refunddomestic.AmountReq{
+ Refund: core.Int64(refund),
+ Total: core.Int64(total),
+ Currency: core.String("CNY"),
+ },
+ }
+ if c.refundNotify != "" {
+ req.NotifyUrl = core.String(c.refundNotify)
+ }
+ if reason != "" {
+ req.Reason = core.String(reason)
+ }
+
+ svc := refunddomestic.RefundsApiService{Client: c.client}
+ resp, _, err := svc.Create(ctx, req)
+ if err != nil {
+ return nil, fmt.Errorf("refund create: %w", err)
+ }
+
+ res := &RefundResult{RefundID: strDeref(resp.RefundId)}
+ if resp.Status != nil {
+ res.Status = string(*resp.Status)
+ }
+ return res, nil
+}
+
+// ---- 回调验签 + 解密 ----
+
+// PayNotifyData 支付成功回调解密后的关键字段。
+type PayNotifyData struct {
+ Raw string // 回调原始报文(验签前捕获,用于审计持久化)
+ OutTradeNo string
+ TransactionID string
+ TradeState string // SUCCESS 表示支付成功
+ Amount struct {
+ Total int64
+ PayerTotal int64
+ Currency string
+ }
+ SuccessTime string
+}
+
+// RefundNotifyData 退款回调解密后的关键字段。
+type RefundNotifyData struct {
+ Raw string // 回调原始报文(验签前捕获,用于审计持久化)
+ OutTradeNo string
+ OutRefundNo string
+ RefundID string
+ RefundStatus string // SUCCESS/CLOSED/ABNORMAL
+ SuccessTime string
+ Amount struct {
+ Total int64
+ Refund int64
+ }
+}
+
+// refundNotifyContent 退款回调解密后的原始结构(退款通知使用 refund_status 字段)。
+type refundNotifyContent struct {
+ OutTradeNo string `json:"out_trade_no"`
+ OutRefundNo string `json:"out_refund_no"`
+ RefundID string `json:"refund_id"`
+ TransactionID string `json:"transaction_id"`
+ RefundStatus string `json:"refund_status"`
+ SuccessTime string `json:"success_time"`
+ Amount struct {
+ Total int64 `json:"total"`
+ Refund int64 `json:"refund"`
+ PayerTotal int64 `json:"payer_total"`
+ PayerRefund int64 `json:"payer_refund"`
+ } `json:"amount"`
+}
+
+// VerifyAndDecryptPayNotify 校验并解密支付回调请求,返回订单数据。
+//
+// 由官方 SDK 完成:校验签名头 → 平台证书验签 → APIv3Key 解密 → 反序列化,杜绝伪造/篡改回调。
+func VerifyAndDecryptPayNotify(ctx context.Context, request *http.Request) (*PayNotifyData, error) {
+ c, err := getPayClient()
+ if err != nil {
+ return nil, err
+ }
+
+ // 验签前捕获原始报文用于审计持久化;ParseNotifyRequest 会消费 request.Body。
+ raw, err := io.ReadAll(request.Body)
+ if err != nil {
+ return nil, fmt.Errorf("read pay notify body: %w", err)
+ }
+ request.Body = io.NopCloser(bytes.NewReader(raw))
+
+ tx := new(payments.Transaction)
+ if _, err := c.notifyHandler.ParseNotifyRequest(ctx, request, tx); err != nil {
+ return nil, fmt.Errorf("parse pay notify: %w", err)
+ }
+
+ d := &PayNotifyData{
+ Raw: string(raw),
+ OutTradeNo: strDeref(tx.OutTradeNo),
+ TransactionID: strDeref(tx.TransactionId),
+ TradeState: strDeref(tx.TradeState),
+ SuccessTime: strDeref(tx.SuccessTime),
+ }
+ if tx.Amount != nil {
+ d.Amount.Total = int64Deref(tx.Amount.Total)
+ d.Amount.PayerTotal = int64Deref(tx.Amount.PayerTotal)
+ d.Amount.Currency = strDeref(tx.Amount.Currency)
+ }
+ return d, nil
+}
+
+// VerifyAndDecryptRefundNotify 校验并解密退款回调请求。
+func VerifyAndDecryptRefundNotify(ctx context.Context, request *http.Request) (*RefundNotifyData, error) {
+ c, err := getPayClient()
+ if err != nil {
+ return nil, err
+ }
+
+ // 验签前捕获原始报文用于审计持久化;ParseNotifyRequest 会消费 request.Body。
+ raw, err := io.ReadAll(request.Body)
+ if err != nil {
+ return nil, fmt.Errorf("read refund notify body: %w", err)
+ }
+ request.Body = io.NopCloser(bytes.NewReader(raw))
+
+ content := new(refundNotifyContent)
+ if _, err := c.notifyHandler.ParseNotifyRequest(ctx, request, content); err != nil {
+ return nil, fmt.Errorf("parse refund notify: %w", err)
+ }
+
+ d := &RefundNotifyData{
+ Raw: string(raw),
+ OutTradeNo: content.OutTradeNo,
+ OutRefundNo: content.OutRefundNo,
+ RefundID: content.RefundID,
+ RefundStatus: content.RefundStatus,
+ SuccessTime: content.SuccessTime,
+ }
+ d.Amount.Total = content.Amount.Total
+ d.Amount.Refund = content.Amount.Refund
+ return d, nil
+}
+
+// ---- 内部辅助 ----
+
+func strDeref(s *string) string {
+ if s == nil {
+ return ""
+ }
+ return *s
+}
+
+func int64Deref(i *int64) int64 {
+ if i == nil {
+ return 0
+ }
+ return *i
+}
diff --git a/pkg/wechat/virtualpay.go b/pkg/wechat/virtualpay.go
new file mode 100644
index 0000000..a1c3e7d
--- /dev/null
+++ b/pkg/wechat/virtualpay.go
@@ -0,0 +1,553 @@
+// Package wechat 微信小程序虚拟支付(XPay)客户端。
+//
+// 与普通微信支付(V3)完全独立:
+// - 服务器 API 前缀 /xpay/,网关 https://api.weixin.qq.com;
+// - 签名用 HMAC-SHA256(appKey):pay_sig = hex(hmac_sha256(appKey, uri + "&" + postBody)),
+// uri 为接口路径(不含 query),postBody 与真实请求体逐字节一致;
+// - 前端调起 wx.requestVirtualPayment 由服务端生成 signData / paySig / signature,
+// 其中 paySig 的 uri 固定为 "requestVirtualPayment",signature 用用户 session_key;
+// - 结果通知走小程序消息推送(Event 通道),非 V3 的 RSA 验签 + AES 解密回调,
+// 应答格式为 {"ErrCode":0,"ErrMsg":"success"}。
+//
+// 安全说明:AppKey / AppSecret 均来自配置(禁止硬编码),仅访问微信固定域名。
+//
+// 注意:/xpay/ 各接口的字段以微信官方「虚拟支付」服务端 API 文档为准,
+// 本文件已按官方文档与社区 SDK 对齐;微信后台未开通前,接口调用需联调时再核对字段。
+package wechat
+
+import (
+ "bytes"
+ "context"
+ "crypto/hmac"
+ "crypto/sha1"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "sort"
+ "sync"
+ "time"
+
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
+ "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/setting"
+)
+
+// xpay 网关与接口路径。
+const (
+ xpayBaseURL = "https://api.weixin.qq.com"
+ stableTokenURL = "https://api.weixin.qq.com/cgi-bin/stable_token"
+
+ xpayQueryOrderPath = "/xpay/query_order"
+ xpayRefundOrderPath = "/xpay/refund_order"
+
+ // requestVirtualPaymentURI 前端调起支付时计算 paySig 使用的固定 uri。
+ requestVirtualPaymentURI = "requestVirtualPayment"
+)
+
+// 虚拟支付环境。
+const (
+ XPayEnvProd = 0 // 现网(正式环境)
+ XPayEnvSandbox = 1 // 沙箱(iOS 不支持沙箱,默认不启用)
+)
+
+// virtualPayClient 虚拟支付客户端(全局单例)。
+type virtualPayClient struct {
+ offerID string // 虚拟支付 OfferID(即小程序 AppID)
+ appKey string // 激活环境的 AppKey(按配置自动选择:现网优先,仅填沙箱时用沙箱 key)
+ env int // 激活环境 0=现网 1=沙箱
+ token string // 消息推送验签 Token(配置回调地址时自定义)
+
+ appID string // 获取 access_token 用的小程序 AppID
+ appSecret string // 小程序 AppSecret
+
+ httpClient *http.Client
+
+ accessMu sync.Mutex
+ accessToken string
+ accessExp time.Time
+}
+
+var (
+ defaultVirtualPayClient *virtualPayClient
+ virtualPayMu sync.RWMutex
+)
+
+// resolveEnvKey 按配置决定激活环境与 AppKey:
+// - EnvAppKey 非空 -> 现网(env=0),两把都填时现网优先;
+// - EnvAppKey 空且 SandboxAppKey 非空 -> 沙箱(env=1);
+// - 两把都空 -> 返回错误。
+func resolveEnvKey(envAppKey, sandboxAppKey string) (appKey string, env int, err error) {
+ if envAppKey != "" {
+ return envAppKey, XPayEnvProd, nil
+ }
+ if sandboxAppKey != "" {
+ return sandboxAppKey, XPayEnvSandbox, nil
+ }
+ return "", 0, errors.New("virtual pay app key not configured: both env_app_key and sandbox_app_key are empty")
+}
+
+// InitVirtualPayClient 初始化全局虚拟支付客户端。
+// 复用 global.WechatMiniSetting 的 AppID/AppSecret 获取 access_token(同一小程序)。
+// 环境选择:只填沙箱 AppKey 走沙箱(env=1,测试不真实扣款);只填现网或两把都填走现网(env=0)。
+func InitVirtualPayClient(s *setting.VirtualPaySettingS) error {
+ if s == nil || !s.Enable {
+ return errors.New("virtual pay setting is nil or disabled")
+ }
+ if s.OfferID == "" {
+ return errors.New("virtual pay setting incomplete: offer_id is required")
+ }
+ appKey, env, err := resolveEnvKey(s.EnvAppKey, s.SandboxAppKey)
+ if err != nil {
+ return err
+ }
+ if global.WechatMiniSetting == nil || global.WechatMiniSetting.AppID == "" || global.WechatMiniSetting.AppSecret == "" {
+ return errors.New("wechat mini setting incomplete, cannot fetch virtual pay access_token")
+ }
+
+ c := &virtualPayClient{
+ offerID: s.OfferID,
+ appKey: appKey,
+ env: env,
+ token: s.Token,
+ appID: global.WechatMiniSetting.AppID,
+ appSecret: global.WechatMiniSetting.AppSecret,
+ httpClient: &http.Client{Timeout: 10 * time.Second},
+ }
+
+ virtualPayMu.Lock()
+ defaultVirtualPayClient = c
+ virtualPayMu.Unlock()
+ return nil
+}
+
+// GetVirtualPayEnv 返回当前激活的虚拟支付环境(0=现网 1=沙箱)。
+func GetVirtualPayEnv() (int, error) {
+ c, err := getVirtualPayClient()
+ if err != nil {
+ return 0, err
+ }
+ return c.env, nil
+}
+
+// getVirtualPayClient 获取已初始化的虚拟支付客户端。
+func getVirtualPayClient() (*virtualPayClient, error) {
+ virtualPayMu.RLock()
+ defer virtualPayMu.RUnlock()
+ if defaultVirtualPayClient == nil {
+ return nil, errors.New("virtual pay client not initialized")
+ }
+ return defaultVirtualPayClient, nil
+}
+
+// IsVirtualPayEnabled 虚拟支付是否已初始化(供上层判断是否可用)。
+func IsVirtualPayEnabled() bool {
+ virtualPayMu.RLock()
+ defer virtualPayMu.RUnlock()
+ return defaultVirtualPayClient != nil
+}
+
+// ---- 签名与工具 ----
+
+// hmacSHA256Hex 计算 HMAC-SHA256 并返回小写 hex。
+func hmacSHA256Hex(key, data string) string {
+ h := hmac.New(sha256.New, []byte(key))
+ _, _ = h.Write([]byte(data))
+ return hex.EncodeToString(h.Sum(nil))
+}
+
+// xpayAPIError xpay 接口业务错误。
+type xpayAPIError struct {
+ Code int
+ Msg string
+}
+
+func (e *xpayAPIError) Error() string {
+ return fmt.Sprintf("xpay errcode=%d, errmsg=%s", e.Code, e.Msg)
+}
+
+// stableTokenResp stable_token 响应。
+type stableTokenResp struct {
+ AccessToken string `json:"access_token"`
+ ExpiresIn int `json:"expires_in"`
+ ErrCode int `json:"errcode"`
+ ErrMsg string `json:"errmsg"`
+}
+
+// getAccessToken 获取并缓存 access_token(提前 300 秒刷新)。
+func (c *virtualPayClient) getAccessToken(ctx context.Context) (string, error) {
+ c.accessMu.Lock()
+ defer c.accessMu.Unlock()
+
+ if c.accessToken != "" && time.Now().Before(c.accessExp.Add(-300*time.Second)) {
+ return c.accessToken, nil
+ }
+
+ body, _ := json.Marshal(map[string]string{
+ "grant_type": "client_credential",
+ "appid": c.appID,
+ "secret": c.appSecret,
+ })
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, stableTokenURL, bytes.NewReader(body))
+ if err != nil {
+ return "", fmt.Errorf("stable_token build request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("stable_token do request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ data, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", fmt.Errorf("stable_token read response: %w", err)
+ }
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("stable_token http status %d: %s", resp.StatusCode, string(data))
+ }
+
+ var r stableTokenResp
+ if err := json.Unmarshal(data, &r); err != nil {
+ return "", fmt.Errorf("stable_token decode: %w", err)
+ }
+ if r.ErrCode != 0 || r.AccessToken == "" {
+ return "", &xpayAPIError{Code: r.ErrCode, Msg: r.ErrMsg}
+ }
+
+ c.accessToken = r.AccessToken
+ expire := r.ExpiresIn
+ if expire <= 0 {
+ expire = 7200
+ }
+ c.accessExp = time.Now().Add(time.Duration(expire) * time.Second)
+ return c.accessToken, nil
+}
+
+// callXPay 向 /xpay/ 接口发起 POST 请求并解码响应。
+// 签名使用激活环境的 AppKey(现网或沙箱,按配置自动选择);body 为请求体结构,
+// out 为响应结构(可为 nil 表示忽略响应体)。
+func (c *virtualPayClient) callXPay(ctx context.Context, uri string, body any, out any) error {
+ bodyBytes, err := json.Marshal(body)
+ if err != nil {
+ return fmt.Errorf("xpay marshal body: %w", err)
+ }
+
+ token, err := c.getAccessToken(ctx)
+ if err != nil {
+ return err
+ }
+
+ // pay_sig = hex(hmac_sha256(appKey, uri + "&" + body)),body 与真实请求体逐字节一致。
+ paySig := hmacSHA256Hex(c.appKey, uri+"&"+string(bodyBytes))
+
+ q := url.Values{}
+ q.Set("access_token", token)
+ q.Set("pay_sig", paySig)
+
+ reqURL := xpayBaseURL + uri + "?" + q.Encode()
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(bodyBytes))
+ if err != nil {
+ return fmt.Errorf("xpay build request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("xpay do request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ data, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return fmt.Errorf("xpay read response: %w", err)
+ }
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("xpay http status %d: %s", resp.StatusCode, string(data))
+ }
+
+ // 先解一层通用错误码(若存在),避免把业务错误误判为成功。
+ var errChk struct {
+ ErrCode int `json:"errcode"`
+ ErrMsg string `json:"errmsg"`
+ }
+ if err := json.Unmarshal(data, &errChk); err == nil && errChk.ErrCode != 0 {
+ return &xpayAPIError{Code: errChk.ErrCode, Msg: errChk.ErrMsg}
+ }
+
+ if out != nil {
+ if err := json.Unmarshal(data, out); err != nil {
+ return fmt.Errorf("xpay decode response: %w", err)
+ }
+ }
+ return nil
+}
+
+// ---- 订单查询 / 退款 ----
+
+// XPayOrderInfo 虚拟支付订单查询结果。
+type XPayOrderInfo struct {
+ OrderID string `json:"order_id"` // 商户订单号
+ WxOrderID string `json:"wx_order_id"` // 微信订单号
+ OpenID string `json:"openid"` // 用户 openid
+ ProductID string `json:"product_id"` // 道具 ID
+ Quantity int64 `json:"quantity"` // 购买数量
+ PayFee int64 `json:"pay_fee"` // 实付金额(分)
+ Status string `json:"status"` // 订单状态(以官方枚举为准)
+ RefundStatus string `json:"refund_status"` // 退款状态(以官方枚举为准,待确认字段名)
+ RefundFee int64 `json:"refund_fee"` // 退款金额(分,待确认字段名)
+ ErrCode int `json:"errcode"`
+ ErrMsg string `json:"errmsg"`
+}
+
+// 虚拟支付订单支付状态(query_order 返回的 status 字段,以微信官方枚举为准,联调时校正)。
+const (
+ XPayOrderStatusPaid = "PAID" // 已支付
+)
+
+// IsOrderPaidStatus 判断 query_order 返回的订单状态是否为「已支付」。
+// 白名单判断:仅命中已知成功枚举返回 true;未知状态/空串一律视为未支付,
+// 供发货链路 fail-closed 使用(判定为未支付的推送会被拒绝并交由微信重试)。
+func IsOrderPaidStatus(status string) bool {
+ return status == XPayOrderStatusPaid
+}
+
+// 虚拟支付订单退款状态(query_order 返回的 refund_status 字段,以微信官方枚举为准,联调时校正)。
+const (
+ XPayOrderRefundedStatus = "REFUNDED" // 已退款
+)
+
+// IsOrderRefundedStatus 判断 query_order 返回的订单退款状态是否为「已退款」。
+// 白名单判断:仅命中已知成功枚举返回 true;未知状态/空串一律视为未退款,
+// 供退款链路 fail-closed 使用(判定为未退款的推送会被拒绝并交由微信重试)。
+func IsOrderRefundedStatus(status string) bool {
+ return status == XPayOrderRefundedStatus
+}
+
+// 虚拟支付退款推送的退款状态(RefundStatus 字段,以微信官方枚举为准,联调时校正)。
+const (
+ XPayRefundStatusSuccess = "SUCCESS" // 退款成功
+)
+
+// QueryOrder 查询虚拟支付订单(orderID 与 wxOrderID 二选一,非空者优先;作用于激活环境)。
+func QueryOrder(ctx context.Context, openid, orderID, wxOrderID string) (*XPayOrderInfo, error) {
+ c, err := getVirtualPayClient()
+ if err != nil {
+ return nil, err
+ }
+ body := map[string]any{"openid": openid, "env": c.env}
+ if orderID != "" {
+ body["order_id"] = orderID
+ } else {
+ body["wx_order_id"] = wxOrderID
+ }
+ out := &XPayOrderInfo{}
+ if err := c.callXPay(ctx, xpayQueryOrderPath, body, out); err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+// RefundOrder 启动虚拟支付退款任务(仅启动任务,结果需轮询 QueryOrder;作用于激活环境)。
+// - leftFee:剩余金额(分),即退款前的订单实付金额;
+// - refundFee:本次退款金额(分)。
+func RefundOrder(ctx context.Context, openid, orderID, refundOrderID string, leftFee, refundFee int64, reason string) error {
+ c, err := getVirtualPayClient()
+ if err != nil {
+ return err
+ }
+ body := map[string]any{
+ "openid": openid,
+ "env": c.env,
+ "order_id": orderID,
+ "refund_order_id": refundOrderID,
+ "left_fee": leftFee,
+ "refund_fee": refundFee,
+ "refund_reason": reason,
+ }
+ return c.callXPay(ctx, xpayRefundOrderPath, body, nil)
+}
+
+// ---- 前端调起签名 ----
+
+// VirtualPaySign 返回给前端调起 wx.requestVirtualPayment 的签名数据。
+type VirtualPaySign struct {
+ SignData string `json:"sign_data"` // 参与签名的 JSON 字符串(原样传给前端,勿重新序列化)
+ PaySig string `json:"pay_sig"`
+ Signature string `json:"signature"`
+}
+
+// BuildPaymentSign 生成前端调起虚拟支付的 signData / paySig / signature。
+//
+// signData 字段按字典序排列(微信侧按字典序重组验签,顺序敏感),offerId 必须为字符串;
+// env 与签名 key 使用客户端激活环境(现网或沙箱)。
+// - paySig = hex(hmac_sha256(appKey, "requestVirtualPayment" + "&" + signData))
+// - signature = hex(hmac_sha256(sessionKey, signData))
+func BuildPaymentSign(offerID, productID, outTradeNo, attach, sessionKey string, goodsPrice int64) (*VirtualPaySign, error) {
+ if offerID == "" || productID == "" || outTradeNo == "" {
+ return nil, errors.New("virtual pay sign: offer_id, product_id, out_trade_no are required")
+ }
+ c, err := getVirtualPayClient()
+ if err != nil {
+ return nil, err
+ }
+
+ fields := map[string]any{
+ "offerId": offerID,
+ "buyQuantity": 1,
+ "currencyType": "CNY",
+ "env": c.env,
+ "goodsPrice": goodsPrice,
+ "productId": productID,
+ "outTradeNo": outTradeNo,
+ }
+ if attach != "" {
+ fields["attach"] = attach
+ }
+
+ // 按字典序排序字段名后序列化,保证与微信侧重组结果一致。
+ keys := make([]string, 0, len(fields))
+ for k := range fields {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+
+ var b bytes.Buffer
+ b.WriteByte('{')
+ for i, k := range keys {
+ if i > 0 {
+ b.WriteByte(',')
+ }
+ kb, _ := json.Marshal(k)
+ vb, _ := json.Marshal(fields[k])
+ b.Write(kb)
+ b.WriteByte(':')
+ b.Write(vb)
+ }
+ b.WriteByte('}')
+ signData := b.String()
+
+ paySig := hmacSHA256Hex(c.appKey, requestVirtualPaymentURI+"&"+signData)
+ signature := hmacSHA256Hex(sessionKey, signData)
+
+ return &VirtualPaySign{SignData: signData, PaySig: paySig, Signature: signature}, nil
+}
+
+// ---- 消息推送 ----
+
+// 虚拟支付消息推送 Event 类型。
+const (
+ EventGoodsDeliverNotify = "xpay_goods_deliver_notify" // 道具发货推送
+ EventCoinPayNotify = "xpay_coin_pay_notify" // 代币支付推送
+ EventRefundNotify = "xpay_refund_notify" // 退款推送
+ EventComplaintNotify = "xpay_complaint_notify" // 用户投诉推送
+ EventWxpayCallbackNotify = "xpay_wxpay_callback_notify" // 微信支付风控事件
+ EventIOSRefundQueryNotify = "xpay_subscribe_ios_refund_query_notify" // iOS 退款问询推送
+)
+
+// XPayNotifyBase 消息推送公共字段。
+type XPayNotifyBase struct {
+ ToUserName string `json:"ToUserName"`
+ FromUserName string `json:"FromUserName"`
+ CreateTime int64 `json:"CreateTime"`
+ MsgType string `json:"MsgType"`
+ Event string `json:"Event"`
+ OpenID string `json:"OpenId"`
+ OutTradeNo string `json:"OutTradeNo"`
+ Env int `json:"Env"`
+}
+
+// XPayGoodsDeliverNotify 道具发货推送。
+type XPayGoodsDeliverNotify struct {
+ XPayNotifyBase
+ WeChatPayInfo struct {
+ MchOrderNo string `json:"MchOrderNo"` // 微信支付商户单号
+ TransactionID string `json:"TransactionId"` // 微信支付交易单号
+ PaidTime int64 `json:"PaidTime"` // 支付时间(Linux 秒级时间戳)
+ } `json:"WeChatPayInfo"`
+ GoodsInfo struct {
+ ProductID string `json:"ProductId"` // 道具 ID
+ Quantity int64 `json:"Quantity"` // 购买数量
+ OrigPrice int64 `json:"OrigPrice"` // 原始价格(分)
+ ActualPrice int64 `json:"ActualPrice"` // 实际支付价格(分)
+ Attach string `json:"Attach"` // 透传信息
+ } `json:"GoodsInfo"`
+}
+
+// XPayRefundNotify 退款推送。
+type XPayRefundNotify struct {
+ XPayNotifyBase
+ RefundStatus string `json:"RefundStatus"` // 退款状态(以官方枚举为准)
+ RefundFee int64 `json:"RefundFee"` // 退款金额(分)
+ RefundOrderID string `json:"RefundOrderId"` // 商户退款单号
+}
+
+// XPayIOSRefundQueryNotify iOS 退款问询推送。
+type XPayIOSRefundQueryNotify struct {
+ XPayNotifyBase
+ // 字段以官方文档为准;此处解析退款问询所需的最小信息。
+ RefundOrderID string `json:"RefundOrderId"`
+ RefundFee int64 `json:"RefundFee"`
+}
+
+// ParseNotify 解析消息推送 JSON 并返回事件类型与公共字段。
+// 返回值为 base(公共字段)+ 原始 body,调用方再按 Event 分发到具体结构。
+func ParseNotify(raw []byte) (*XPayNotifyBase, error) {
+ if len(raw) == 0 {
+ return nil, errors.New("empty xpay notify body")
+ }
+ base := &XPayNotifyBase{}
+ if err := json.Unmarshal(raw, base); err != nil {
+ return nil, fmt.Errorf("parse xpay notify: %w", err)
+ }
+ if base.Event == "" {
+ return nil, errors.New("xpay notify missing event")
+ }
+ return base, nil
+}
+
+// BuildNotifyResponse 构造消息推送应答(成功/失败)。
+func BuildNotifyResponse(success bool) []byte {
+ if success {
+ return []byte(`{"ErrCode":0,"ErrMsg":"success"}`)
+ }
+ return []byte(`{"ErrCode":1,"ErrMsg":"处理失败"}`)
+}
+
+// BuildIOSRefundQueryResponse 构造 iOS 退款问询应答(3 秒内必须返回)。
+// resultCode: 0 建议退款、1 拒绝退款;evidence 为决策凭据。
+// 字段名以官方文档为准(result_code / evidence)。
+// 使用 json.Marshal 保证字符串转义符合 JSON 规范(避免 fmt %q 的 Go 风格转义)。
+func BuildIOSRefundQueryResponse(resultCode int, evidence string) []byte {
+ b, _ := json.Marshal(map[string]any{
+ "result_code": resultCode,
+ "evidence": evidence,
+ })
+ return b
+}
+
+// CheckURLSignature 校验消息推送 URL 配置时的 GET 验签(SHA1)。
+// token 为配置回调地址时自定义的 Token;通过则返回 true 并原样回显 echostr。
+func CheckURLSignature(token, timestamp, nonce, signature string) bool {
+ if token == "" || signature == "" {
+ return false
+ }
+ arr := []string{token, timestamp, nonce}
+ sort.Strings(arr)
+ joined := arr[0] + arr[1] + arr[2]
+ h := sha1.Sum([]byte(joined))
+ return hex.EncodeToString(h[:]) == signature
+}
+
+// VerifyNotifyURL 校验消息推送 URL 配置时的 GET 验签(使用已初始化的 Token)。
+func VerifyNotifyURL(timestamp, nonce, signature string) bool {
+ c, err := getVirtualPayClient()
+ if err != nil {
+ return false
+ }
+ return CheckURLSignature(c.token, timestamp, nonce, signature)
+}
diff --git a/pkg/wechat/virtualpay_test.go b/pkg/wechat/virtualpay_test.go
new file mode 100644
index 0000000..f34d8f0
--- /dev/null
+++ b/pkg/wechat/virtualpay_test.go
@@ -0,0 +1,191 @@
+package wechat
+
+import (
+ "crypto/sha1"
+ "encoding/hex"
+ "encoding/json"
+ "sort"
+ "strings"
+ "testing"
+)
+
+// TestHMACSHA256Hex 用 RFC 4231 测试向量校验 HMAC-SHA256 实现。
+func TestHMACSHA256Hex(t *testing.T) {
+ key := strings.Repeat("\x0b", 20) // 0x0b * 20
+ data := "Hi There"
+ want := "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"
+ if got := hmacSHA256Hex(key, data); got != want {
+ t.Fatalf("hmacSHA256Hex mismatch: got %s, want %s", got, want)
+ }
+}
+
+// TestBuildPaymentSign 校验 signData 字典序、paySig 与 signature 的内部一致性。
+func TestBuildPaymentSign(t *testing.T) {
+ const (
+ appKey = "test-app-key"
+ sessionKey = "test-session-key"
+ offerID = "wx1234567890"
+ productID = "monthly_vip"
+ outTradeNo = "OD20260908120000abcdef"
+ attach = "uid=42"
+ )
+
+ virtualPayMu.Lock()
+ defaultVirtualPayClient = &virtualPayClient{appKey: appKey, env: XPayEnvProd}
+ virtualPayMu.Unlock()
+ defer func() {
+ virtualPayMu.Lock()
+ defaultVirtualPayClient = nil
+ virtualPayMu.Unlock()
+ }()
+
+ sign, err := BuildPaymentSign(offerID, productID, outTradeNo, attach, sessionKey, 1900)
+ if err != nil {
+ t.Fatalf("BuildPaymentSign error: %v", err)
+ }
+
+ // signData 字段必须按字典序排列(微信侧按字典序重组验签)。
+ wantOrder := []string{"attach", "buyQuantity", "currencyType", "env", "goodsPrice", "offerId", "outTradeNo", "productId"}
+ last := -1
+ for _, key := range wantOrder {
+ idx := strings.Index(sign.SignData, `"`+key+`"`)
+ if idx < 0 {
+ t.Fatalf("signData missing key %q: %s", key, sign.SignData)
+ }
+ if idx < last {
+ t.Fatalf("signData key order wrong at %q: %s", key, sign.SignData)
+ }
+ last = idx
+ }
+
+ // signData 必须是合法 JSON,且 offerId 为字符串。
+ var m map[string]any
+ if err := json.Unmarshal([]byte(sign.SignData), &m); err != nil {
+ t.Fatalf("signData is not valid JSON: %v", err)
+ }
+ if _, ok := m["offerId"].(string); !ok {
+ t.Fatalf("offerId must be string, got %T", m["offerId"])
+ }
+ if v, ok := m["goodsPrice"].(float64); !ok || v != 1900 {
+ t.Fatalf("goodsPrice mismatch: %v", m["goodsPrice"])
+ }
+
+ // paySig = hmac(appKey, "requestVirtualPayment&"+signData);signature = hmac(sessionKey, signData)。
+ wantPaySig := hmacSHA256Hex(appKey, requestVirtualPaymentURI+"&"+sign.SignData)
+ wantSignature := hmacSHA256Hex(sessionKey, sign.SignData)
+ if sign.PaySig != wantPaySig {
+ t.Fatalf("paySig mismatch: got %s, want %s", sign.PaySig, wantPaySig)
+ }
+ if sign.Signature != wantSignature {
+ t.Fatalf("signature mismatch: got %s, want %s", sign.Signature, wantSignature)
+ }
+}
+
+// TestCheckURLSignature 校验消息推送 URL 验签(SHA1 字典序拼接)。
+func TestCheckURLSignature(t *testing.T) {
+ token := "my-token"
+ timestamp := "1756000000"
+ nonce := "abc123"
+
+ // 独立计算期望值:["1756000000","abc123","my-token"] 字典序拼接后做 SHA1。
+ arr := []string{token, timestamp, nonce}
+ sort.Strings(arr)
+ sum := sha1.Sum([]byte(arr[0] + arr[1] + arr[2]))
+ wantSig := hex.EncodeToString(sum[:])
+
+ if !CheckURLSignature(token, timestamp, nonce, wantSig) {
+ t.Fatal("CheckURLSignature should pass with correct signature")
+ }
+ if CheckURLSignature(token, timestamp, nonce, "bad") {
+ t.Fatal("CheckURLSignature should fail with wrong signature")
+ }
+ if CheckURLSignature("", timestamp, nonce, wantSig) {
+ t.Fatal("CheckURLSignature should fail with empty token")
+ }
+}
+
+// TestParseNotifyAndResponse 校验发货推送解析与应答构造。
+func TestParseNotifyAndResponse(t *testing.T) {
+ raw := []byte(`{
+ "ToUserName":"gh_test",
+ "FromUserName":"official_openid",
+ "CreateTime":1756000000,
+ "MsgType":"event",
+ "Event":"xpay_goods_deliver_notify",
+ "OpenId":"o_user",
+ "OutTradeNo":"OD20260908120000abcdef",
+ "Env":0,
+ "WeChatPayInfo":{"MchOrderNo":"mch_123","TransactionId":"wx_123","PaidTime":1756000000},
+ "GoodsInfo":{"ProductId":"monthly_vip","Quantity":1,"OrigPrice":1900,"ActualPrice":1900,"Attach":"uid=42"}
+ }`)
+
+ base, err := ParseNotify(raw)
+ if err != nil {
+ t.Fatalf("ParseNotify error: %v", err)
+ }
+ if base.Event != EventGoodsDeliverNotify {
+ t.Fatalf("unexpected event: %s", base.Event)
+ }
+ if base.OutTradeNo != "OD20260908120000abcdef" {
+ t.Fatalf("unexpected out_trade_no: %s", base.OutTradeNo)
+ }
+
+ var deliver XPayGoodsDeliverNotify
+ if err := json.Unmarshal(raw, &deliver); err != nil {
+ t.Fatalf("unmarshal deliver notify: %v", err)
+ }
+ if deliver.GoodsInfo.ProductID != "monthly_vip" || deliver.GoodsInfo.ActualPrice != 1900 {
+ t.Fatalf("deliver goods info mismatch: %+v", deliver.GoodsInfo)
+ }
+
+ okBody := BuildNotifyResponse(true)
+ if !strings.Contains(string(okBody), `"ErrCode":0`) {
+ t.Fatalf("success response missing ErrCode 0: %s", okBody)
+ }
+ failBody := BuildNotifyResponse(false)
+ if !strings.Contains(string(failBody), `"ErrCode":1`) {
+ t.Fatalf("fail response missing ErrCode 1: %s", failBody)
+ }
+}
+
+// TestIsOrderPaidStatus 校验订单支付状态白名单判断:仅已知成功枚举为已支付,未知/空串按未支付。
+func TestIsOrderPaidStatus(t *testing.T) {
+ cases := []struct {
+ status string
+ want bool
+ }{
+ {XPayOrderStatusPaid, true},
+ {"NOTPAY", false},
+ {"CLOSED", false},
+ {"UNKNOWN", false},
+ {"", false},
+ }
+ for _, c := range cases {
+ if got := IsOrderPaidStatus(c.status); got != c.want {
+ t.Fatalf("IsOrderPaidStatus(%q) = %v, want %v", c.status, got, c.want)
+ }
+ }
+}
+
+// TestResolveEnvKey 校验环境自动选择规则:只填现网/只填沙箱/两把都填/两把都空。
+func TestResolveEnvKey(t *testing.T) {
+ // 只填现网 -> 现网
+ key, env, err := resolveEnvKey("prod-key", "")
+ if err != nil || key != "prod-key" || env != XPayEnvProd {
+ t.Fatalf("only env key: key=%q env=%d err=%v", key, env, err)
+ }
+ // 只填沙箱 -> 沙箱
+ key, env, err = resolveEnvKey("", "sandbox-key")
+ if err != nil || key != "sandbox-key" || env != XPayEnvSandbox {
+ t.Fatalf("only sandbox key: key=%q env=%d err=%v", key, env, err)
+ }
+ // 两把都填 -> 现网优先
+ key, env, err = resolveEnvKey("prod-key", "sandbox-key")
+ if err != nil || key != "prod-key" || env != XPayEnvProd {
+ t.Fatalf("both keys: key=%q env=%d err=%v", key, env, err)
+ }
+ // 两把都空 -> 报错
+ if _, _, err := resolveEnvKey("", ""); err == nil {
+ t.Fatal("empty keys should return error")
+ }
+}
diff --git a/resources/watch_gshock_test.jpg b/resources/watch_gshock_test.jpg
new file mode 100644
index 0000000..fc1b04a
Binary files /dev/null and b/resources/watch_gshock_test.jpg differ
diff --git a/resources/微信图片_20260907235100_780_53.jpg b/resources/微信图片_20260907235100_780_53.jpg
new file mode 100644
index 0000000..5f550c5
Binary files /dev/null and b/resources/微信图片_20260907235100_780_53.jpg differ
diff --git a/resources/纸条AI衣橱小程序头像生成.jpg b/resources/纸条AI衣橱小程序头像生成.jpg
new file mode 100644
index 0000000..07ea357
Binary files /dev/null and b/resources/纸条AI衣橱小程序头像生成.jpg differ
diff --git a/scripts/nginx/nginx.conf b/scripts/nginx/nginx.conf
new file mode 100644
index 0000000..508c897
--- /dev/null
+++ b/scripts/nginx/nginx.conf
@@ -0,0 +1,57 @@
+# ============================================================
+# zt-closet-backend 反向代理配置
+# 后端监听 127.0.0.1:8080,nginx 对外 80(跳转 443)+ 443(HTTPS)
+# ============================================================
+
+# 上传大小限制(衣物图片上传最大 20MB,multipart 边界 + 后端 MaxBytesReader 余量)
+client_max_body_size 25m;
+
+# ---------- HTTP 80:强制跳转 HTTPS ----------
+server {
+ listen 80;
+ server_name your.domain.com;
+
+ # 微信回调、xpay 推送等也一并走 HTTPS
+ location / {
+ return 301 https://$host$request_uri;
+ }
+}
+
+# ---------- HTTPS 443:SSL + 反向代理 ----------
+server {
+ listen 443 ssl;
+ http2 on;
+ server_name your.domain.com;
+
+ # SSL 证书(占位,替换为你的真实路径)
+ ssl_certificate /etc/nginx/ssl/your.domain.com.pem;
+ ssl_certificate_key /etc/nginx/ssl/your.domain.com.key;
+ ssl_protocols TLSv1.2 TLSv1.3;
+ ssl_ciphers HIGH:!aNULL:!MD5;
+ ssl_session_cache shared:SSL:10m;
+ ssl_session_timeout 10m;
+
+ # 上传大小限制(覆盖全局,明确写在这里更直观)
+ client_max_body_size 25m;
+
+ # 反向代理到后端
+ location / {
+ proxy_pass http://127.0.0.1:8080;
+
+ # 透传真实客户端信息与链路请求 ID
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Request-ID $request_id; # 后端 RequestID 中间件会透传
+
+ # 超时:AI 推荐等接口需调大模型,给足余量(后端 WriteTimeout 默认 60s)
+ proxy_connect_timeout 10s;
+ proxy_send_timeout 120s;
+ proxy_read_timeout 120s;
+
+ # 上传大文件建议关闭请求体缓冲,降低延迟与磁盘占用
+ proxy_request_buffering off;
+ }
+}
diff --git a/thirdparty/classification_server/main.py b/thirdparty/classification_server/main.py
new file mode 100644
index 0000000..6713053
--- /dev/null
+++ b/thirdparty/classification_server/main.py
@@ -0,0 +1,611 @@
+"""Fashion-CLIP 衣物分类服务
+
+使用 patrickjohncyh/fashion-clip 模型进行零样本分类,
+一次性识别衣物的 15 个维度:
+大类 / 子类别 / 风格 / 主颜色 / 副颜色 / 厚度 / 版型 / 材质 / 花纹 /
+袖型 / 衣长类型 / 领型 / 正式程度(1-5) / 适用场合 / 视觉冲击力(1-5)。
+
+所有维度在一次模型前向中完成(约 210 个提示词拼接为一个 batch,
+按组 softmax 归一化),响应字段与后端 clothes 表模型命名对齐。
+
+模型首次运行从 HuggingFace Hub 下载权重并缓存到本地。
+默认使用 hf-mirror.com 镜像(国内加速)。
+"""
+
+import io
+import logging
+import os
+
+# !!!必须在 import transformers 之前设置,否则 huggingface_hub 读不到这个环境变量!!!
+if "HF_ENDPOINT" not in os.environ:
+ os.environ["HF_ENDPOINT"] = "https://hf-mirror.com"
+
+import torch
+from fastapi import FastAPI, HTTPException, UploadFile
+from PIL import Image
+from transformers import CLIPModel, CLIPProcessor
+
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger(__name__)
+
+app = FastAPI(title="Fashion-CLIP Classification Service")
+
+# ============================================================================
+# 分类提示词
+# 说明:
+# - Fashion-CLIP 是英文训练的,用英文+视觉特征描述效果远好于简单中文标签。
+# - 键为中文标签(返回给客户端,与 clothes 表字段取值一致),值为英文提示词。
+# - 描述尽量包含剪裁、长度、材质视觉特征与穿着场景,提升零样本区分度。
+# ============================================================================
+
+CATEGORY_PROMPTS = {
+ "上装": "a short top, t-shirt, shirt, blouse or sweater worn on the upper body, garment covering the torso from shoulders to waist or hips",
+ "下装": "a pair of pants, trousers, jeans, shorts, leggings or a skirt worn on the lower body below the waist",
+ "连衣裙": "a one-piece dress worn from shoulders down, covering both upper and lower body in a single connected garment",
+ "外套": "a jacket, coat, blazer or outerwear worn as an outer layer over other clothes, with sleeves and front opening",
+ "鞋": "a pair of shoes, sneakers, boots, sandals or slippers worn on the feet, a full shoe shape with sole, heel and toe, footwear covering the foot",
+ "配饰": "a small personal accessory item like a hat, scarf, belt, bag, sunglasses, necklace, earrings, bracelet, wristwatch or other jewelry, not a main clothing garment",
+}
+
+# 子类别按大类分组:大类确定后在其对应组内取 top1,避免牛仔裤出现在上装里
+SUBCATEGORY_GROUPS = {
+ "上装": {
+ "T恤": "a short-sleeve cotton t-shirt with round crew neck, casual basic top, no buttons no collar",
+ "衬衫": "a button-up shirt with collar and front buttons, long or short sleeves, woven fabric with cuffs",
+ "卫衣": "a hoodie or sweatshirt, thick fleece cotton pullover with or without hood and kangaroo pocket",
+ "毛衣": "a thick knitted wool sweater or pullover, chunky warm textured knit top",
+ "针织衫": "a thin fine-knit top, ribbed lightweight knitwear, fitted casual knit shirt, finer yarn than a chunky sweater",
+ "背心": "a sleeveless tank top with wide shoulder coverage, sporty or basic vest top",
+ "Polo衫": "a polo shirt with flat collar, short sleeves and a 2-3 button placket, sporty casual",
+ "开衫": "a cardigan, open-front knitted sweater-like top with buttons or zipper down the front",
+ "吊带衫": "a camisole top with thin spaghetti straps, sleeveless lightweight top with narrow shoulder straps",
+ "打底衫": "a basic fitted long-sleeve layering top, thin turtleneck or crew neck undershirt, plain solid color",
+ },
+ "下装": {
+ "牛仔裤": "a pair of blue denim jeans, casual pants with visible seams, rivets and five pockets",
+ "休闲裤": "a pair of casual cotton or chino trousers, straight leg comfortable pants, no denim texture",
+ "运动裤": "a pair of sporty sweatpants or joggers with elastic waistband, relaxed athletic fit",
+ "短裤": "a pair of shorts ending above the knee, short pants for warm weather",
+ "半身裙": "a skirt worn from waist down, flared A-line or straight or pleated silhouette",
+ "阔腿裤": "a pair of wide-leg loose flowing trousers with very wide leg openings",
+ "西装裤": "a pair of formal tailored dress trousers with center crease, suit pants",
+ "鲨鱼裤": "a pair of tight shiny yoga leggings, skin-tight stretch pants, sporty smooth surface",
+ "工装裤": "a pair of cargo pants with large side pockets on the legs, utility workwear style",
+ "喇叭裤": "a pair of flared pants fitted at thigh and widening from knee to hem, bell-bottom style",
+ },
+ "连衣裙": {
+ "连衣短裙": "a short mini dress above the knee, one-piece garment",
+ "连衣长裙": "a long maxi dress reaching the ankles, elegant flowing one-piece",
+ "吊带裙": "a slip dress with thin spaghetti straps, sleeveless one-piece dress",
+ "衬衫连衣裙": "a shirt dress with button-up front and collar, shirt style one-piece dress",
+ "针织连衣裙": "a knitted one-piece dress with ribbed sweater texture, body-hugging or flowing knit",
+ "雪纺连衣裙": "a chiffon dress with sheer lightweight flowing layered fabric, romantic airy look",
+ "西装连衣裙": "a tailored blazer-style dress with lapels, structured office dress",
+ },
+ "外套": {
+ "夹克": "a short lightweight jacket ending at waist or hip, sporty casual outerwear with zipper",
+ "西装外套": "a formal blazer or suit jacket with lapels, structured shoulders, button closure",
+ "风衣": "a long trench coat with belt, double-breasted buttons, knee-length waterproof style",
+ "羽绒服": "a puffy down jacket with quilted sections, thick warm insulation, winter puffer coat",
+ "大衣": "a long wool overcoat reaching knee or below, elegant warm winter coat",
+ "马甲": "a sleeveless vest worn over other clothes, no sleeves, front zipper or buttons",
+ "牛仔外套": "a denim jacket with metal buttons and chest flap pockets, blue jean fabric, trucker style",
+ "皮衣": "a leather jacket with glossy or matte leather surface, biker or fashion style",
+ "棉服": "a quilted cotton padded jacket, lightweight puffer, insulated casual winter jacket",
+ },
+ "鞋": {
+ "运动鞋": "a pair of athletic sneakers with rubber sole, sport running or training shoes",
+ "皮鞋": "a pair of formal leather dress shoes with polished shiny surface, lace-up or slip-on",
+ "凉鞋": "a pair of open-toe sandals with straps, exposed toes, summer footwear",
+ "靴子": "a pair of boots covering ankle or higher, sturdy footwear with high-top shaft",
+ "帆布鞋": "a pair of canvas shoes with flat rubber sole, casual lace-up sneakers",
+ "高跟鞋": "a pair of high heel shoes or pumps with elevated heel, formal feminine footwear",
+ "乐福鞋": "a pair of loafers, slip-on leather shoes without laces, flat casual dress shoes",
+ "外穿拖鞋": "a pair of outdoor slides or chunky slippers, open-toe flat sandals for casual wear",
+ "玛丽珍鞋": "a pair of Mary Jane shoes with rounded toe and one or more straps across the instep, feminine flat or slight heel",
+ "洞洞鞋": "a pair of foam clogs with perforated holes on the upper, casual slip-on crocs style",
+ "马丁靴": "a pair of combat boots with chunky sole, lace-up leather ankle boots",
+ },
+ "配饰": {
+ "帽子": "a hat or cap worn on the head, baseball cap, beanie, bucket hat or sun hat",
+ "围巾": "a scarf worn around the neck, long fabric accessory",
+ "腰带": "a belt worn around the waist, leather strap with metal buckle",
+ "墨镜": "a pair of sunglasses with dark tinted lenses",
+ "项链": "a necklace, jewelry chain or pendant worn around the neck",
+ "手链": "a bracelet or bangle, purely decorative jewelry worn on the wrist, no watch face, no dial, no clock",
+ "包包": "a handbag, purse, shoulder bag, tote bag or backpack accessory",
+ "耳饰": "a pair of earrings, jewelry worn on the earlobes",
+ "丝巾": "a silk scarf or bandana, lightweight decorative fabric neck accessory",
+ "发带": "a headband or hair tie accessory worn on the head or in the hair",
+ "戒指": "a ring, small jewelry worn on the finger",
+ "胸针": "a brooch, decorative pin jewelry attached to clothing",
+ "手表": "a wristwatch timepiece with a large prominent round or square watch face at the center, a clock dial showing numbers and watch hands or digital display, buttons or crown on the sides, thick strap band, worn on the wrist as a timekeeping device",
+ },
+}
+
+COLOR_PROMPTS = {
+ "黑色": "a black colored garment, very dark black clothing",
+ "白色": "a white colored garment, pure white clothing",
+ "红色": "a red colored garment, bright red clothing",
+ "蓝色": "a standard blue colored garment, medium blue clothing",
+ "绿色": "a green colored garment, green clothing",
+ "黄色": "a yellow colored garment, bright yellow clothing",
+ "粉色": "a pink colored garment, soft pink clothing",
+ "紫色": "a purple colored garment, violet purple clothing",
+ "灰色": "a gray colored garment, grey clothing",
+ "棕色": "a brown colored garment, brown clothing",
+ "橙色": "an orange colored garment, orange clothing",
+ "卡其色": "a khaki colored garment, beige-tan clothing",
+ "米色": "a cream or beige colored garment, off-white soft beige clothing",
+ "杏色": "an apricot colored garment, soft peachy beige clothing",
+ "焦糖色": "a caramel brown colored garment, warm rich toffee brown clothing",
+ "藏青色": "a navy blue colored garment, very dark deep blue clothing",
+ "天蓝色": "a sky blue colored garment, light bright clear blue clothing",
+ "军绿色": "an olive green or army green colored garment, military green clothing",
+ "酒红色": "a burgundy or wine red colored garment, deep maroon red clothing",
+ "金色": "a gold colored garment, metallic golden shiny clothing",
+ "银色": "a silver colored garment, metallic silver shiny clothing",
+ "玫红色": "a rose red or hot pink magenta colored garment, vivid pinkish red clothing",
+ "荧光色": "a neon fluorescent colored garment, extremely bright glowing highlighter colors like neon green or neon orange",
+ "墨绿色": "a dark forest green colored garment, deep dark green clothing",
+ "宝蓝色": "a royal blue colored garment, saturated vivid bright blue clothing",
+ "浅蓝色": "a light blue colored garment, pale soft pastel blue clothing",
+ "粉紫色": "a lavender or lilac colored garment, soft purple-pink pastel clothing",
+ "驼色": "a camel colored garment, warm tan brown beige clothing",
+ "砖红色": "a brick red colored garment, muted earthy red-brown clothing",
+ "浅灰色": "a light gray colored garment, pale soft grey clothing",
+}
+
+STYLE_PROMPTS = {
+ "休闲风": "casual everyday style clothing, relaxed comfortable fashion",
+ "通勤风": "business formal office style clothing, professional workplace attire",
+ "运动风": "athletic sporty style clothing, gym and workout fashion",
+ "街头风": "streetwear urban style clothing, trendy hip-hop influenced fashion",
+ "优雅风": "elegant sophisticated style clothing, feminine chic fashion",
+ "甜美风": "cute sweet girly style clothing, lovely feminine fashion with ruffles and pastel colors",
+ "复古风": "vintage retro style clothing, old-school classic fashion from past decades",
+ "简约风": "minimalist simple style clothing, clean basic design without decoration",
+ "商务正装风": "formal business suit style clothing, corporate professional tailored attire",
+ "轻熟风": "smart casual mature style clothing, polished sophisticated young professional fashion",
+ "工装风": "utility workwear style clothing, cargo pockets and durable functional fashion",
+ "学院风": "preppy academic style clothing, college campus style with plaid, knit sweaters, collared shirts",
+ "甜酷风": "sweet and edgy style clothing, feminine meets punk, cute yet rebellious fashion",
+ "辣妹风": "sexy hot girl style clothing, bold bodycon and crop top fashion, confident glam look",
+ "度假风": "resort vacation style clothing, tropical holiday beachwear, relaxed bohemian fashion",
+ "新中式风": "modern Chinese style clothing, contemporary oriental fashion with traditional Chinese elements",
+ "极简风": "extreme minimalist style clothing, monochrome neutral colors, no logos no patterns, pure basic design",
+ "波西米亚风": "bohemian style clothing, flowy fabric, fringe, ethnic embroidery, relaxed free-spirited silhouette",
+ "朋克风": "punk style clothing, black leather, studs, ripped details, rebellious rock look",
+ "嘻哈风": "hip-hop style clothing, oversized baggy silhouette, urban street rap culture fashion",
+ "森系风": "mori girl forest style clothing, natural earthy colors, layered loose linen and cotton, rustic gentle look",
+ "法式风": "French chic style clothing, effortless elegant parisian look, simple refined feminine fashion",
+ "美式复古风": "American vintage retro style clothing, classic 90s american casual, denim, varsity, old-school usa look",
+ "日系风": "Japanese style clothing, clean simple japanese fashion, relaxed natural fabric look",
+ "韩系风": "Korean style clothing, trendy korean fashion, soft colors, neat youthful casual look",
+}
+
+FIT_PROMPTS = {
+ "宽松": "a loose oversized fit garment, baggy relaxed silhouette clothing",
+ "修身": "a slim fitted garment, tailored silhouette that follows the body shape",
+ "阔腿": "a wide leg loose flowing silhouette, palazzo or flare pants style",
+ "紧身": "a tight skinny fit garment, bodycon stretch clothing, form-fitting second skin",
+ "常规合身": "a regular standard fit garment, normal classic cut, neither tight nor loose",
+ "直筒": "a straight leg or straight cut garment, parallel vertical lines from top to bottom, no flare no taper",
+}
+
+MATERIAL_PROMPTS = {
+ "牛仔布": "denim fabric material, rugged blue cotton twill with visible diagonal weave and stitching",
+ "针织": "knitted fabric material, textured knitwear with visible yarn loops, sweater material",
+ "皮质": "genuine leather material, smooth shiny or matte animal hide surface with natural grain",
+ "纯棉": "pure cotton material, soft matte natural cotton fabric, breathable woven cotton textile",
+ "羊毛": "wool material, fuzzy warm woolen fabric, thick natural animal fiber textile",
+ "丝绸": "silk material, smooth lustrous shiny satin-like fabric, elegant silky sheen surface",
+ "雪纺": "chiffon material, sheer lightweight translucent fabric, soft delicate flowy textile",
+ "麻料": "linen material, textured natural flax fabric with visible weave, breathable rustic look",
+ "蕾丝": "lace material, delicate openwork patterned fabric, intricate floral mesh textile",
+ "尼龙": "nylon synthetic material, smooth shiny technical fabric, waterproof windbreaker material",
+ "灯芯绒": "corduroy material, ribbed velvety fabric with parallel raised cords, textured wales",
+ "天鹅绒": "velvet material, soft plush fabric with rich deep sheen and smooth nap",
+ "缎面": "satin material, smooth glossy surface with strong silky shine, fluid drape",
+ "网纱": "tulle or mesh material, sheer fine net fabric, transparent gauzy texture",
+ "羊羔绒": "sherpa fleece material, fluffy curly wool-like lining or surface, cozy plush texture",
+ "PU皮": "faux leather PU material, artificial leather with uniform smooth plastic-like glossy surface",
+ "磨毛棉": "brushed cotton material, soft slightly fuzzy matte surface, cozy flannel-like texture",
+}
+
+PATTERN_PROMPTS = {
+ "纯色": "a solid color garment with no pattern, plain single-color fabric without any design",
+ "条纹": "a striped pattern garment with horizontal or vertical parallel lines",
+ "格纹": "a plaid or checkered pattern garment, crossed horizontal and vertical lines forming squares",
+ "印花": "a printed pattern garment with graphic floral or artistic designs on fabric",
+ "刺绣": "an embroidered garment with raised stitched thread decorative patterns",
+ "波点": "a polka dot pattern garment with small round dots evenly distributed",
+ "迷彩": "a camouflage pattern garment with irregular green brown patches, military style",
+ "扎染": "a tie-dye pattern garment with irregular colorful swirled dyed patterns",
+ "碎花": "a small floral print pattern garment with tiny scattered flower designs",
+ "字母": "a lettering or slogan print garment with text words or logos",
+ "千鸟格": "a houndstooth check pattern garment, small broken zigzag checks in two-tone",
+ "菱格纹": "an argyle pattern garment with diamond shapes, diagonal intersecting lines",
+ "人字纹": "a herringbone pattern garment with V-shaped zigzag weave, broken twill",
+ "拼色块": "a color block garment with contrasting solid color panels stitched together in large sections",
+ "渐变": "an ombre gradient pattern garment with colors fading gradually from one shade to another",
+ "豹纹": "a leopard print pattern garment with animal spots, brown and black rosette spots",
+ "蕾丝花纹": "a lace pattern garment with delicate openwork floral mesh design",
+ "几何图案": "a geometric pattern garment with shapes like triangles, circles and abstract lines arranged in order",
+ "logo图案": "a logo or brand emblem print garment with large recognizable brand marks or icons",
+}
+
+# 厚度(适用:上装/下装/连衣裙/外套/鞋)
+THICKNESS_PROMPTS = {
+ "薄": "a thin lightweight fabric garment, sheer or semi-sheer summer clothing, cool breathable material",
+ "适中": "a medium weight fabric garment, regular thickness clothing, standard everyday material",
+ "厚": "a thick heavy fabric garment, warm dense material, substantial winter clothing",
+ "加厚": "an extra thick heavily padded garment, quilted puffy down insulation, maximum warmth winter wear",
+}
+
+# 袖型(适用:上装/连衣裙/外套)
+SLEEVE_TYPE_PROMPTS = {
+ "无袖": "a sleeveless garment with no sleeves, bare shoulders and arms exposed",
+ "吊带": "a garment with thin spaghetti straps, narrow shoulder straps instead of sleeves",
+ "短袖": "a short sleeve garment, sleeves ending above the elbow",
+ "中袖": "a half sleeve or elbow length sleeve garment, sleeves ending at or below the elbow",
+ "长袖": "a long sleeve garment, sleeves reaching the wrist",
+ "喇叭袖": "a flared bell sleeve garment, sleeve widening dramatically from elbow to a wide cuff",
+ "泡泡袖": "a puff sleeve garment, voluminous balloon sleeve gathered at shoulder and cuff",
+ "落肩袖": "a dropped shoulder sleeve garment, shoulder seam falling below the natural shoulder, relaxed slouchy look",
+}
+
+# 衣长类型(适用:上装/下装/连衣裙/外套)
+CLOTHES_TYPE_PROMPTS = {
+ "短款": "a cropped short length garment, ends above or at the waist, showing midriff or waistline",
+ "常规": "a regular standard length garment, normal classic length, ends around hip or upper thigh",
+ "中长": "a mid length garment, ends around mid-thigh or knee",
+ "长款": "a long length garment, ends below the knee or at the calf",
+ "超长款": "an extra long garment, ankle-length or floor-length, maxi style full length",
+}
+
+# 领型(适用:上装/连衣裙/外套)
+NECKLINE_PROMPTS = {
+ "圆领": "a round crew neckline, circular collar hugging the neck",
+ "V领": "a V-shaped neckline, pointed dip at the front center chest",
+ "方领": "a square neckline with straight horizontal edge across the chest",
+ "高领": "a turtleneck high collar, folded fabric covering the whole neck",
+ "翻领": "a lapel or shirt collar, folded flat collar with pointed tips",
+ "立领": "a standing band collar, mandarin collar rising vertically around the neck",
+ "一字领": "an off-shoulder neckline, straight horizontal top edge exposing the shoulders",
+ "挂脖": "a halter neckline with straps tying behind the neck, open back and shoulders",
+ "连帽": "a hooded garment with an attached hood around the neckline",
+ "娃娃领": "a peter pan collar, small flat rounded collar lying on the garment",
+}
+
+# 正式程度 1-5(label 分类后映射为数字等级)
+FORMALITY_PROMPTS = {
+ "非常休闲": "very casual clothing, sporty gym wear, beach wear, loungewear, relaxed informal style",
+ "休闲": "casual clothing, everyday relaxed wear, jeans and t-shirt style",
+ "轻正式": "smart casual clothing, neat polished everyday style, business casual attire",
+ "商务正式": "business formal clothing, office suit and tailored professional attire",
+ "非常正式": "very formal clothing, evening gown, tuxedo, ceremonial dress code attire",
+}
+
+# 适用场合(取置信度达标的 top-N 逗号连接)
+OCCASION_PROMPTS = {
+ "日常通勤": "everyday commuting and office wear, practical comfortable work outfit",
+ "运动健身": "sports and gym workout wear, athletic exercise clothing",
+ "约会聚会": "date night and social gathering outfit, romantic or fashionable party look",
+ "正式商务": "formal business meeting attire, professional corporate dress code",
+ "居家休闲": "home loungewear, cozy at-home relaxing clothing",
+ "度假旅游": "vacation and travel wear, resort holiday clothing, beach and sightseeing outfit",
+ "户外活动": "outdoor activity wear, hiking camping adventure functional clothing",
+ "派对夜店": "party and nightclub outfit, trendy bold nightlife fashion",
+ "校园上课": "school and campus wear, youthful student daily outfit",
+ "婚礼宴会": "wedding and banquet attire, elegant formal celebration dress",
+}
+
+# 视觉冲击力 1-5(label 分类后映射为数字等级)
+VISUAL_IMPACT_PROMPTS = {
+ "低调": "very low visual impact, plain minimal clothing, muted neutral colors, no decoration",
+ "平淡": "low visual impact, simple understated clothing, basic design with little attention-grabbing",
+ "适中": "moderate visual impact, average clothing with normal color and simple design details",
+ "亮眼": "high visual impact, eye-catching clothing, bright colors or striking design, stands out",
+ "高调": "very high visual impact, bold dazzling clothing, neon glitter dramatic statement design, impossible to ignore",
+}
+
+# ============================================================================
+# 批量推理分组:所有维度 + 子类别按大类展开,一次前向全部完成
+# ============================================================================
+INFERENCE_GROUPS = {
+ "category": CATEGORY_PROMPTS,
+ "color": COLOR_PROMPTS,
+ "style": STYLE_PROMPTS,
+ "version": FIT_PROMPTS,
+ "material": MATERIAL_PROMPTS,
+ "pattern": PATTERN_PROMPTS,
+ "thickness": THICKNESS_PROMPTS,
+ "sleeve_type": SLEEVE_TYPE_PROMPTS,
+ "clothes_type": CLOTHES_TYPE_PROMPTS,
+ "neckline_type": NECKLINE_PROMPTS,
+ "formality": FORMALITY_PROMPTS,
+ "occasion": OCCASION_PROMPTS,
+ "visual_impact": VISUAL_IMPACT_PROMPTS,
+}
+for _main, _subs in SUBCATEGORY_GROUPS.items():
+ INFERENCE_GROUPS[f"subcategory:{_main}"] = _subs
+
+# 副颜色提取阈值:第二颜色置信度达到 min(top1 * 40%, 0.15) 才输出
+COLOR_SUB_RATIO_THRESHOLD = 0.40
+COLOR_SUB_ABS_THRESHOLD = 0.15
+
+# 适用场合:置信度不低于 top1 的 60%,最多取 3 个
+OCCASION_MIN_RATIO = 0.60
+OCCASION_MAX_COUNT = 3
+
+# 维度适用性:各维度仅在指定大类下输出,其余品类置空
+DIMENSION_APPLICABILITY = {
+ "thickness": {"上装", "下装", "连衣裙", "外套", "鞋"},
+ "version": {"上装", "下装", "连衣裙", "外套"},
+ "sleeve_type": {"上装", "连衣裙", "外套"},
+ "clothes_type": {"上装", "下装", "连衣裙", "外套"},
+ "neckline_type": {"上装", "连衣裙", "外套"},
+}
+
+# 1-5 等级映射(中文标签 -> 数字等级字符串)
+FORMALITY_LEVELS = {
+ "非常休闲": "1",
+ "休闲": "2",
+ "轻正式": "3",
+ "商务正式": "4",
+ "非常正式": "5",
+}
+
+VISUAL_IMPACT_LEVELS = {
+ "低调": "1",
+ "平淡": "2",
+ "适中": "3",
+ "亮眼": "4",
+ "高调": "5",
+}
+
+
+def _classify_all(
+ model: CLIPModel,
+ processor: CLIPProcessor,
+ image: Image.Image,
+ groups: dict,
+ device: str,
+) -> dict:
+ """一次前向完成多组分类。
+
+ groups: 组名 -> {中文标签: 英文提示词}
+ 返回: 组名 -> [{"label": 标签, "confidence": 置信度}, ...] 按置信度降序
+ """
+ texts = []
+ boundaries = []
+ for prompts in groups.values():
+ start = len(texts)
+ texts.extend(prompts.values())
+ boundaries.append((start, len(texts)))
+
+ inputs = processor(
+ text=texts,
+ images=image,
+ return_tensors="pt",
+ padding=True,
+ ).to(device)
+
+ with torch.inference_mode():
+ outputs = model(**inputs)
+ logits = outputs.logits_per_image # [1, total_texts]
+ logits = logits.squeeze(0).cpu()
+
+ results = {}
+ for name, (start, end) in zip(groups.keys(), boundaries):
+ labels = list(groups[name].keys())
+ probs = torch.softmax(logits[start:end], dim=-1)
+ order = torch.argsort(probs, descending=True)
+ results[name] = [
+ {"label": labels[int(i)], "confidence": round(float(probs[int(i)]), 4)}
+ for i in order
+ ]
+ return results
+
+
+def _top1(results: list) -> tuple:
+ """取组内 top1 的 (标签, 置信度)。"""
+ return results[0]["label"], results[0]["confidence"]
+
+
+def _extract_color_sub(color_results: list) -> tuple:
+ """从颜色 top-2 提取副颜色,置信度过低则返回空。"""
+ if len(color_results) < 2:
+ return "", 0.0
+ top_conf = color_results[0]["confidence"]
+ sub = color_results[1]
+ threshold = min(top_conf * COLOR_SUB_RATIO_THRESHOLD, COLOR_SUB_ABS_THRESHOLD)
+ if sub["confidence"] >= threshold:
+ return sub["label"], sub["confidence"]
+ return "", 0.0
+
+
+def _pick_occasions(occasion_results: list) -> str:
+ """取置信度不低于 top1 一定比例的场合,逗号连接。"""
+ top_conf = occasion_results[0]["confidence"]
+ picked = [
+ r["label"]
+ for r in occasion_results
+ if r["confidence"] >= top_conf * OCCASION_MIN_RATIO
+ ][:OCCASION_MAX_COUNT]
+ if not picked:
+ picked = [occasion_results[0]["label"]]
+ return ",".join(picked)
+
+
+# ---------- 模型加载 ----------
+MODEL_NAME = "patrickjohncyh/fashion-clip"
+device = "cuda" if torch.cuda.is_available() else "cpu"
+
+# 模型缓存走 HuggingFace 默认目录(~/.cache/huggingface)
+logger.info(f"Loading {MODEL_NAME} on {device} ...")
+try:
+ model = CLIPModel.from_pretrained(MODEL_NAME, local_files_only=True)
+ processor = CLIPProcessor.from_pretrained(MODEL_NAME, local_files_only=True)
+ logger.info(f"{MODEL_NAME} 命中本地缓存,离线加载完成")
+except (OSError, ValueError) as e:
+ logger.info(f"本地未找到 {MODEL_NAME} 缓存,自动从 HuggingFace 下载 ... ({e})")
+ model = CLIPModel.from_pretrained(MODEL_NAME)
+ processor = CLIPProcessor.from_pretrained(MODEL_NAME)
+ logger.info(f"{MODEL_NAME} 下载完成")
+
+model = model.to(device)
+model.eval()
+
+if device == "cuda":
+ model.half()
+
+logger.info(f"Fashion-CLIP loaded on {device}")
+
+
+@app.post("/classify")
+async def classify(file: UploadFile):
+ """对上传的衣物图片进行分类,返回与 clothes 表模型对齐的 15 个维度。
+
+ Response:
+ category_main: 衣物大类(上装/下装/连衣裙/外套/鞋/配饰)
+ category_sub: 子类别(大类对应组内 top1)
+ style: 风格
+ color_main: 主颜色
+ color_sub: 副颜色(top-2 置信度达标才输出,否则空字符串)
+ thickness: 厚度(薄/适中/厚/加厚)
+ version: 版型(宽松/修身/阔腿/紧身/常规合身/直筒)
+ material: 材质
+ pattern: 花纹
+ sleeve_type: 袖型(仅上装/连衣裙/外套,其余置空)
+ clothes_type: 衣长类型(短款/常规/中长/长款/超长款)
+ neckline_type: 领型(仅上装/连衣裙/外套,其余置空)
+ formality: 正式程度 "1"~"5"(附 formality_label 中文标签)
+ occasions: 适用场合(top-N 逗号连接)
+ visual_impact: 视觉冲击力 "1"~"5"(附 visual_impact_label 中文标签)
+ details: 各维度 Top-5 明细
+ """
+ raw = await file.read()
+ try:
+ image = Image.open(io.BytesIO(raw))
+ # 仅含透明通道的图片(PNG 等)合成白底,避免透明通道干扰 CLIP;普通 JPEG 直接转 RGB
+ if image.mode in ("RGBA", "LA") or (image.mode == "P" and "transparency" in image.info):
+ image = image.convert("RGBA")
+ bg = Image.new("RGBA", image.size, (255, 255, 255, 255))
+ image = Image.alpha_composite(bg, image)
+ image = image.convert("RGB")
+ except Exception:
+ raise HTTPException(status_code=400, detail="invalid image")
+
+ # 1. 一次前向完成全部分类
+ results = _classify_all(model, processor, image, INFERENCE_GROUPS, device)
+
+ # 2. 大类
+ best_main, best_main_conf = _top1(results["category"])
+
+ # 3. 子类别:大类对应组内 top1
+ sub_results = results.get(f"subcategory:{best_main}", [])
+ if sub_results:
+ best_sub, best_sub_conf = _top1(sub_results)
+ else:
+ best_sub, best_sub_conf = "", 0.0
+
+ # 4. 颜色:主色 + 副色(top-2 阈值提取)
+ color_results = results["color"]
+ best_color, best_color_conf = _top1(color_results)
+ best_color_sub, best_color_sub_conf = _extract_color_sub(color_results)
+
+ # 5. 风格 / 材质 / 花纹
+ best_style, best_style_conf = _top1(results["style"])
+ best_material, best_material_conf = _top1(results["material"])
+ best_pattern, best_pattern_conf = _top1(results["pattern"])
+
+ # 6. 适用性控制:不适用维度置空
+ def pick_or_empty(dim: str) -> tuple:
+ if best_main in DIMENSION_APPLICABILITY.get(dim, set()):
+ return _top1(results[dim])
+ return "", 0.0
+
+ best_thickness, best_thickness_conf = pick_or_empty("thickness")
+ best_version, best_version_conf = pick_or_empty("version")
+ best_sleeve, best_sleeve_conf = pick_or_empty("sleeve_type")
+ best_clothes_type, best_clothes_type_conf = pick_or_empty("clothes_type")
+ best_neckline, best_neckline_conf = pick_or_empty("neckline_type")
+
+ # 7. 正式程度 1-5
+ formality_label, formality_conf = _top1(results["formality"])
+ formality = FORMALITY_LEVELS.get(formality_label, "")
+
+ # 8. 适用场合 top-N
+ occasions = _pick_occasions(results["occasion"])
+
+ # 9. 视觉冲击力 1-5
+ impact_label, impact_conf = _top1(results["visual_impact"])
+ impact = VISUAL_IMPACT_LEVELS.get(impact_label, "")
+
+ def detail(name: str) -> list:
+ return results.get(name, [])[:5]
+
+ return {
+ "category_main": best_main,
+ "category_main_confidence": best_main_conf,
+ "category_sub": best_sub,
+ "category_sub_confidence": best_sub_conf,
+ "style": best_style,
+ "style_confidence": best_style_conf,
+ "color_main": best_color,
+ "color_main_confidence": best_color_conf,
+ "color_sub": best_color_sub,
+ "color_sub_confidence": best_color_sub_conf,
+ "thickness": best_thickness,
+ "thickness_confidence": best_thickness_conf,
+ "version": best_version,
+ "version_confidence": best_version_conf,
+ "material": best_material,
+ "material_confidence": best_material_conf,
+ "pattern": best_pattern,
+ "pattern_confidence": best_pattern_conf,
+ "sleeve_type": best_sleeve,
+ "sleeve_type_confidence": best_sleeve_conf,
+ "clothes_type": best_clothes_type,
+ "clothes_type_confidence": best_clothes_type_conf,
+ "neckline_type": best_neckline,
+ "neckline_type_confidence": best_neckline_conf,
+ "formality": formality,
+ "formality_label": formality_label,
+ "formality_confidence": formality_conf,
+ "occasions": occasions,
+ "visual_impact": impact,
+ "visual_impact_label": impact_label,
+ "visual_impact_confidence": impact_conf,
+ "details": {
+ "category_main": detail("category"),
+ "category_sub": sub_results[:5],
+ "style": detail("style"),
+ "color_main": color_results[:5],
+ "thickness": detail("thickness"),
+ "version": detail("version"),
+ "material": detail("material"),
+ "pattern": detail("pattern"),
+ "sleeve_type": detail("sleeve_type"),
+ "clothes_type": detail("clothes_type"),
+ "neckline_type": detail("neckline_type"),
+ "formality": detail("formality"),
+ "occasions": detail("occasion"),
+ "visual_impact": detail("visual_impact"),
+ },
+ }
+
+
+@app.get("/healthz")
+def healthz():
+ return {"status": "ok"}
+
+
+if __name__ == "__main__":
+ # 模型已在模块 import 时于模块级加载,这里直接传 app 对象启动,
+ # 不要用 "main:app" 字符串 + reload 方式,否则会重复加载模型。
+ import uvicorn
+
+ uvicorn.run(app, host="0.0.0.0", port=8000)
diff --git a/thirdparty/classification_server/requirements.txt b/thirdparty/classification_server/requirements.txt
new file mode 100644
index 0000000..f7349d6
--- /dev/null
+++ b/thirdparty/classification_server/requirements.txt
@@ -0,0 +1,16 @@
+# ============================================================================
+# Fashion-CLIP 衣物分类服务依赖清单
+# 安装:pip install -r requirements.txt
+#
+# 注意:PyPI 默认安装的是 CPU 版 torch。
+# 如果你的机器有 NVIDIA GPU,请先按 PyTorch 官网命令单独安装对应 CUDA 版 torch,
+# 再执行 pip install -r requirements.txt(已安装的 torch 会被跳过):
+# pip install torch --index-url https://download.pytorch.org/whl/cu121
+# ============================================================================
+
+torch>=2.0.0
+transformers>=4.30.0
+fastapi>=0.100.0
+uvicorn>=0.23.0
+pillow>=9.5.0
+python-multipart>=0.0.9 # FastAPI 处理 multipart 表单(/classify 上传图片)必需
diff --git a/thirdparty/matting_server/main.py b/thirdparty/matting_server/main.py
new file mode 100644
index 0000000..8bba5ee
--- /dev/null
+++ b/thirdparty/matting_server/main.py
@@ -0,0 +1,96 @@
+import io
+import logging
+import os
+
+# !!!必须在 import transformers 之前设置,否则 huggingface_hub 读不到这个环境变量!!!
+if "HF_ENDPOINT" not in os.environ:
+ os.environ["HF_ENDPOINT"] = "https://hf-mirror.com"
+
+import torch
+from fastapi import FastAPI, File, HTTPException, UploadFile
+from fastapi.responses import Response
+from PIL import Image
+from torchvision import transforms
+from transformers import AutoModelForImageSegmentation
+
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger(__name__)
+
+app = FastAPI(title="BiRefNet Matting Service")
+
+# 模块加载时就把模型读进内存/显存,常驻复用(BiRefNet_lite = small 变体)
+# 模型缓存走 HuggingFace 默认目录(~/.cache/huggingface):本地存在则离线加载,不存在则自动下载
+# 可通过环境变量 BIREFNET_REPO_ID 覆盖模型来源(本地目录或 HuggingFace repo id),
+# 默认使用 BiRefNet_lite(small 变体)。
+_REPO_ID = os.environ.get("BIREFNET_REPO_ID", "ZhengPeng7/BiRefNet_lite")
+
+try:
+ model = AutoModelForImageSegmentation.from_pretrained(
+ _REPO_ID,
+ trust_remote_code=True,
+ local_files_only=True,
+ )
+ logger.info("BiRefNet 命中本地缓存,离线加载完成")
+except (OSError, ValueError) as e:
+ logger.info(f"本地未找到 BiRefNet 模型缓存,自动从 HuggingFace 下载 ... ({e})")
+ model = AutoModelForImageSegmentation.from_pretrained(
+ _REPO_ID,
+ trust_remote_code=True,
+ )
+ logger.info("BiRefNet 模型下载完成")
+model.eval()
+device = "cuda" if torch.cuda.is_available() else "cpu"
+if device == "cuda":
+ model.to(device)
+ model.half() # GPU 半精度省显存
+else:
+ # 模型权重可能为 fp16(如 ModelScope 镜像),CPU 推理统一转 fp32 避免 dtype 不匹配
+ model.to(device).float()
+
+transform = transforms.Compose(
+ [
+ transforms.Resize((1024, 1024)),
+ transforms.ToTensor(),
+ transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),
+ ]
+)
+
+
+@app.post("/matting")
+async def matting(file: UploadFile = File(...)):
+ raw = await file.read()
+ try:
+ image = Image.open(io.BytesIO(raw)).convert("RGB")
+ except Exception:
+ raise HTTPException(status_code=400, detail="invalid image")
+
+ w, h = image.size
+ inp = transform(image).unsqueeze(0).to(device)
+ if device == "cuda":
+ inp = inp.half()
+
+ with torch.no_grad():
+ preds = model(inp)[-1].sigmoid().cpu()
+
+ # 单通道概率图 → resize 回原尺寸 → 作为 alpha 合成透明底 PNG
+ mask = transforms.ToPILImage()(preds[0].squeeze()).resize((w, h))
+ result = image.convert("RGBA")
+ result.putalpha(mask)
+
+ buf = io.BytesIO()
+ result.save(buf, format="PNG")
+ return Response(content=buf.getvalue(), media_type="image/png")
+
+
+@app.get("/healthz")
+def healthz():
+ # 能响应即代表模型已加载完成(模块级加载在 import 时完成)
+ return {"status": "ok"}
+
+
+if __name__ == "__main__":
+ # 模型已在模块 import 时于模块级加载,这里直接传 app 对象启动,
+ # 不要用 "main:app" 字符串 + reload 方式,否则会重复加载模型。
+ import uvicorn
+
+ uvicorn.run(app, host="0.0.0.0", port=8001)
diff --git a/thirdparty/matting_server/requirements.txt b/thirdparty/matting_server/requirements.txt
new file mode 100644
index 0000000..a007504
--- /dev/null
+++ b/thirdparty/matting_server/requirements.txt
@@ -0,0 +1,23 @@
+# ============================================================================
+# BiRefNet 抠图服务依赖清单
+# 安装:pip install -r requirements.txt
+#
+# 注意 1:PyPI 默认安装的是 CPU 版 torch。
+# 如果你的机器有 NVIDIA GPU,请先按 PyTorch 官网命令单独安装对应 CUDA 版 torch,
+# 再执行 pip install -r requirements.txt(已安装的 torch/torchvision 会被跳过):
+# pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121
+#
+# 注意 2:模型会自动下载到 HuggingFace 默认缓存目录 ~/.cache/huggingface(走 HF_ENDPOINT 镜像)。
+# 首次启动若本地无缓存会联网下载 BiRefNet_lite;之后启动命中本地缓存则完全离线。
+# ============================================================================
+
+torch>=2.0.0
+torchvision>=0.15.0
+transformers>=4.44.0
+fastapi>=0.100.0
+uvicorn>=0.23.0
+pillow>=9.5.0
+python-multipart>=0.0.9 # FastAPI 处理 multipart 表单(UploadFile)必需
+einops>=0.7.0 # BiRefNet 自定义建模代码(birefnet.py)依赖
+kornia>=0.7.0 # BiRefNet 自定义建模代码(birefnet.py)依赖
+timm>=0.9.0 # BiRefNet 自定义建模代码(birefnet.py)依赖
diff --git a/接口文档.md b/接口文档.md
new file mode 100644
index 0000000..038dd02
--- /dev/null
+++ b/接口文档.md
@@ -0,0 +1,2326 @@
+# 智能衣橱(zt-closet)后端接口文档 · 前端版
+
+> 面向**前端/小程序工程师**的联调文档。全文基于后端源码(`internal/routers`、`internal/dto`、`internal/service`、`pkg/errcode`)逐字段核对生成,包含接口入参出参、业务逻辑与状态机、调用时序、错误码全表。
+>
+> - 服务:Go + Gin,微信小程序后端
+> - 统一前缀:`/api/v1`(下文中路径如 `/user/wxlogin` 均表示 `/api/v1/user/wxlogin`)
+> - 健康检查:`GET /ping`(无 `/api/v1` 前缀)
+> - 文档版本:v1.1 · 生成日期:2026-09-09
+
+---
+
+## 目录
+
+- [1. 五分钟上手](#1-五分钟上手)
+- [2. 通用调用约定](#2-通用调用约定)
+ - [2.1 鉴权与 Token](#21-鉴权与-token)
+ - [2.2 统一响应结构](#22-统一响应结构)
+ - [2.3 HTTP 状态码与业务码](#23-http-状态码与业务码)
+ - [2.4 分页约定](#24-分页约定)
+ - [2.5 单位约定](#25-单位约定)
+ - [2.6 幂等与重试](#26-幂等与重试)
+ - [2.7 需要轮询的场景](#27-需要轮询的场景)
+ - [2.8 CORS 与链路追踪](#28-cors-与链路追踪)
+- [3. 接口总表(43 个)](#3-接口总表43-个)
+- [4. 公共数据结构](#4-公共数据结构)
+- [5. 接口详解](#5-接口详解)
+ - [5.1 用户模块](#51-用户模块)
+ - [5.2 额度模块](#52-额度模块)
+ - [5.3 运势模块](#53-运势模块)
+ - [5.4 衣物模块](#54-衣物模块)
+ - [5.5 穿搭模块](#55-穿搭模块)
+ - [5.6 AI 推荐模块](#56-ai-推荐模块)
+ - [5.7 衣柜分享模块](#57-衣柜分享模块)
+ - [5.8 收藏与点赞模块](#58-收藏与点赞模块)
+ - [5.9 通知模块](#59-通知模块)
+ - [5.10 商品模块](#510-商品模块)
+ - [5.11 订单与退款模块](#511-订单与退款模块)
+- [6. 业务逻辑与状态机](#6-业务逻辑与状态机)
+- [7. 核心调用时序图](#7-核心调用时序图)
+- [8. 错误码全表](#8-错误码全表)
+- [9. 前端联调 FAQ 与检查清单](#9-前端联调-faq-与检查清单)
+- [10. 接口调用示例(curl)](#10-接口调用示例curl)
+
+---
+
+## 1. 五分钟上手
+
+标准联调路径:
+
+```text
+1. wx.login() 拿到 code
+ └─> POST /user/wxlogin -> access_token / refresh_token / user_info
+2. 所有需登录接口带 Header:
+ Authorization: Bearer
+3. 上传衣物
+ POST /clothes/upload (multipart, 字段名 file)
+ └─> { clothes_uuid, image_process_status: 3 }
+ └─> 轮询 GET /clothes/:uuid/status 直到 4(成功)或 5(失败)
+4. 做穿搭
+ POST /outfit 创建 -> PUT /outfit/:uuid/public 公开(扣额度,进入社区)
+ 或 POST /ai-recommend AI 推荐 -> POST /ai-recommend/save 保存
+5. 额度不够
+ GET /goods 商品列表 -> POST /order 下单 -> wx.requestPayment(pay_params)
+ -> 轮询 GET /order 直到 order_status = 2(已支付)
+```
+
+**最容易踩的 6 个坑(详见 [第 9 节](#9-前端联调-faq-与检查清单)):**
+
+1. **判断成败只看 body 里的 `code`**,绝大多数业务错误返回 HTTP 200(包括 `21001 账号已被禁用`)。
+2. **刷新 Token 必须同时传 `refresh_token` 和 `access_token`**,否则报 `10000`;成功后旧的两个 token 立即失效,必须整体替换。
+3. **衣物上传是异步的**,返回只代表"已排队",必须轮询状态接口。
+4. **更新衣物的主颜色请求字段是 `color`**,响应里才是 `color_main`。
+5. **金额单位都是分、时间都是秒**,展示前要换算。
+6. **AI 推荐生成后不保存不退额度**;退款是人工审核,不是实时到账。
+
+---
+
+## 2. 通用调用约定
+
+### 2.1 鉴权与 Token
+
+**请求头格式**
+
+```http
+Authorization: Bearer
+```
+
+- 必须恰好是 `Bearer` + 空格 + token(大小写敏感),其它写法一律 `11001`。
+- access_token 默认有效期 **7200 秒**(配置 `JWT.AccessTokenExpire`),refresh_token 默认 **86400 秒**。
+- 登录响应里的 `expire_time` 是 access_token 的过期时间戳(秒),前端可据此提前刷新。
+
+**服务端校验顺序(失败即中断)**
+
+| 顺序 | 校验 | 返回 | HTTP |
+| --- | --- | --- | --- |
+| 1 | 无 `Authorization` 头 | `11004` Token 缺失 | 401 |
+| 2 | 格式不是 `Bearer xxx` | `11001` + `details:["invalid Authorization format"]` | 401 |
+| 3 | access_token 已过期 | `11002` Token 已过期 | 401 |
+| 4 | 拿 refresh_token 当 access 用 | `11001` + `details:["token type mismatch"]` | 401 |
+| 5 | Redis 会话不存在(已登出/被顶替) | `11001` + `details:["token session invalid"]` | 401 |
+| 6 | 会话 userID 与 token 不一致 | `11001` + `details:["token user mismatch"]` | 401 |
+| 7 | 用户不存在 | `11001` + `details:["用户不存在"]` | 401 |
+| 8 | 用户 `status != 1`(禁用/注销) | `21001` 账号已被禁用 | **200** |
+
+> ⚠️ 第 8 步是 **HTTP 200 + code 21001**,不是 401。拦截器里必须单独判断并跳转封禁提示页。
+
+**刷新流程(前端必须实现)**
+
+```text
+任意接口返回 code = 11002 / 11001(HTTP 401)
+ └─> POST /user/refresh-token { refresh_token, access_token } // 两个都要传
+ ├─ 成功 -> 用返回的两个新 token **整体替换**本地存储,再重试原请求
+ └─ 失败(11001/11002)-> 清空本地登录态,重新走 wx.login
+```
+
+- 刷新成功后,服务端会**立即吊销旧的 access_token 与 refresh_token**,前端不能沿用任何旧值。
+- refresh_token 也是**一次性旋转**的:每次刷新都会返回新的 refresh_token。
+
+**公开接口(无需 Token)**:`/ping`、`/user/wxlogin`、`/user/refresh-token`、`/goods`、`/goods/:code`、`/order/pay/notify`、`/order/refund/notify`。
+
+---
+
+### 2.2 统一响应结构
+
+**成功**(HTTP 200)
+
+```json
+{
+ "code": 0,
+ "msg": "成功",
+ "data": { }
+}
+```
+
+- `code` 恒为 `0`,`msg` 恒为 `"成功"`。
+- **`data` 永不为 `null`**:无业务数据(删除、取消、标记已读等)时是空对象 `{}`。
+- 成功响应**没有 `details` 字段**。
+
+**失败**
+
+```json
+{
+ "code": 80000,
+ "msg": "额度不足",
+ "details": ["额度不足,请先购买"]
+}
+```
+
+- 失败响应**没有 `data` 字段**,前端不要读取 `data`。
+- `details` 是**可选**的字符串数组,仅在有补充信息时出现(参数校验错误、业务提示等)。典型内容:
+ `"请先完善生日信息"`、`"过期时间必须晚于当前时间"`、`"部分分享需选择衣物"`、`"衣橱暂无可用衣物"`、`"未生成有效搭配"`、`"缺少旧 access token"`、`"缺少微信 openid"` 等。
+- **`details` 可直接用于 toast 提示用户**,但建议仅对 `10000 参数错误` 与业务提示类展示,系统错误统一展示兜底文案。
+
+---
+
+### 2.3 HTTP 状态码与业务码
+
+**绝大多数业务错误返回 HTTP 200**,只有下表这些会返回非 200:
+
+| HTTP | 业务 code | 场景 |
+| --- | --- | --- |
+| 200 | `0` 成功,**以及除下表外的所有业务码** | 成功与绝大多数业务错误 |
+| **401** | `11000` 未授权、`11001` Token 鉴权失败、`11002` Token 已过期、`11004` Token 缺失 | 鉴权失败 → 走刷新或重新登录 |
+| **403** | `12000` 无权限访问 | 越权操作他人资源 |
+| **429** | `10003` 请求过于频繁 | 限流(如 AI 推荐并发锁),稍后重试 |
+| **500** | `19000` 服务内部错误、`19001` 数据库错误、`19002` 缓存错误、`19003` 下游服务调用失败、`19999` 未知错误、`39001` 抠图失败、`39002` 分类失败 | 服务端异常 → 提示"服务繁忙" |
+
+> **前端拦截器建议**:先取 body 的 `code` 判断业务结果;`code` 缺失(网络层失败)时才回落到 HTTP 状态码。
+
+---
+
+### 2.4 分页约定
+
+所有列表接口统一使用 **GET query 参数**:
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| page | int | 否 | 页码,≥1,默认 **1** |
+| page_size | int | 否 | 每页条数,1~50,默认 **10**;超过 50 服务端自动收敛为 50 |
+
+列表响应统一结构:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| total | int64 | 总条数 |
+| page | int | 当前页码 |
+| page_size | int | 每页条数(服务端实际生效值) |
+| list | array | 数据数组;无数据时为空数组 `[]` |
+
+---
+
+### 2.5 单位约定
+
+| 类别 | 单位 | 涉及字段 |
+| --- | --- | --- |
+| **金额** | **分**(int64) | `price`、`discount_price`、`unit_price`、`pay_amount`、`refund_amount` |
+| **时间** | **秒级 Unix 时间戳**(int64) | `expire_time`、`pay_time`、`refund_time`、`created_at`、`birth_day`、`vip_expire`、`ban_start`、`ban_expire`、`apply_time` |
+| 日期 | `YYYYMMDD` 整数 | 运势 `stat_date` |
+
+> 展示金额需 `÷ 100`;展示时间需 `× 1000` 再交给 JS `Date`(因为 JS 用毫秒)。
+
+---
+
+### 2.6 幂等与重试
+
+以下接口**天然幂等,可安全重试**(重复提交不会产生重复数据或重复扣费):
+
+- 点赞 / 取消点赞 `POST /like`
+- 收藏 / 取消收藏 `POST /collect`
+- 穿搭公开 `PUT /outfit/:uuid/public`(重复公开**不会**重复扣额度;但**取消公开后再次公开会再扣 1 次**,见 [6.6](#66-额度规则全景))
+- 标记已读 `POST /notification/:uuid/read`
+- 保存 AI 推荐 `POST /ai-recommend/save`
+- 取消订单 `POST /order/:orderNo/cancel`
+- 取消分享 `POST /closet-share/:uuid/cancel`
+
+**不建议重试**:`POST /clothes/upload`(每次都会扣 1 个上传额度)、`POST /ai-recommend`(每次扣 1 个 AI 额度,且并发会触发 `10003`)、`POST /order`(会创建新订单)。
+
+---
+
+### 2.7 需要轮询的场景
+
+后端**没有 WebSocket / 消息推送**,以下场景前端需主动轮询:
+
+| 场景 | 轮询接口 | 终态 | 建议间隔 |
+| --- | --- | --- | --- |
+| 衣物 AI 处理 | `GET /clothes/:uuid/status` | `4` 成功 / `5` 失败(`1/2/3` 继续轮询) | 1~2 秒,建议上限 60 次 |
+| 支付结果 | `GET /order`(按 `status` 或 `order_no` 过滤) | `2` 已支付 / `3` 已取消 / `4` 已过期 | 支付后 1 秒起,最多 15 分钟 |
+| 退款进度 | `GET /order` | `5` 已退款 | 30 秒(人工审核,可能很久) |
+| 今日运势未生成 | `GET /horoscope/today` | 返回数据(首次可能 `90300`) | 3~5 秒,最多 3 次 |
+| 未读通知数 | `GET /notification/unread` | 无终态,按需刷新 | 进入页面时 / 30~60 秒 |
+
+---
+
+### 2.8 CORS 与链路追踪
+
+- 允许的 Method:`GET, POST, PUT, PATCH, DELETE, OPTIONS`;允许的请求头:`Origin, Content-Type, Authorization`。
+- 响应头 `X-Request-ID` 可用于排障,但**未加入 CORS `Expose-Headers`,跨域场景下 JS 读不到**;需要排障时请在请求侧自带 `X-Request-ID` 头,服务端会透传。
+- 后端已开启 OpenTelemetry 全链路追踪,反馈问题时提供 `X-Request-ID` 可快速定位。
+
+---
+
+## 3. 接口总表(43 个)
+
+### 健康检查(1)
+
+| 方法 | 路径 | 说明 |
+| --- | --- | --- |
+| GET | `/ping` | 存活探针,返回 `{"message":"pong"}`,**不在 `/api/v1` 下** |
+
+### 公开路由(6,无需登录)
+
+| 方法 | 路径 | 说明 |
+| --- | --- | --- |
+| POST | `/user/wxlogin` | 微信登录(code 换 openid,首次自动注册) |
+| POST | `/user/refresh-token` | 刷新 access_token |
+| GET | `/goods` | 商品列表(仅上架) |
+| GET | `/goods/:code` | 按商品编码查询 |
+| POST | `/order/pay/notify` | 🚫 **微信支付回调,服务端专用,前端禁止调用** |
+| POST | `/order/refund/notify` | 🚫 **微信退款回调,服务端专用,前端禁止调用** |
+| GET/POST | `/xpay/notify` | 🚫 **虚拟支付消息推送(GET 为 URL 验证 / POST 为发货·退款·iOS 退款问询回调),服务端专用,前端禁止调用** |
+
+### 鉴权路由(36,需 `Authorization: Bearer`)
+
+**用户 / 额度 / 运势(5)**
+
+| 方法 | 路径 | 说明 |
+| --- | --- | --- |
+| GET | `/user/info` | 当前用户信息 |
+| PUT | `/user/profile` | 更新个人资料 |
+| GET | `/quota/info` | 额度概览(4 类额度余量) |
+| GET | `/quota/flow` | 额度流水 |
+| GET | `/horoscope/today` | 今日星座运势(按生日推算星座) |
+
+**衣物(6)**
+
+| 方法 | 路径 | 说明 |
+| --- | --- | --- |
+| POST | `/clothes/upload` | 上传衣物图片(扣上传额度,触发异步 AI 流水线) |
+| GET | `/clothes` | 我的衣物列表 |
+| GET | `/clothes/:uuid` | 衣物详情 |
+| GET | `/clothes/:uuid/status` | 衣物 AI 处理状态(轮询用) |
+| PUT | `/clothes/:uuid` | 更新衣物属性 |
+| DELETE | `/clothes/:uuid` | 删除衣物(满足条件时回退上传额度) |
+
+**穿搭(6)**
+
+| 方法 | 路径 | 说明 |
+| --- | --- | --- |
+| POST | `/outfit` | 创建穿搭 |
+| GET | `/outfit` | 我的穿搭列表 |
+| GET | `/outfit/:uuid` | 穿搭详情 |
+| PUT | `/outfit/:uuid` | 更新穿搭 |
+| DELETE | `/outfit/:uuid` | 删除穿搭 |
+| PUT | `/outfit/:uuid/public` | 公开 / 取消公开(公开扣搭配分享额度) |
+
+**AI 推荐(2)**
+
+| 方法 | 路径 | 说明 |
+| --- | --- | --- |
+| POST | `/ai-recommend` | 生成 AI 穿搭推荐(扣 AI 额度) |
+| POST | `/ai-recommend/save` | 保存 AI 推荐为我的穿搭 |
+
+**衣柜分享(5)**
+
+| 方法 | 路径 | 说明 |
+| --- | --- | --- |
+| POST | `/closet-share` | 创建衣柜分享(扣衣柜分享额度) |
+| GET | `/closet-share` | 我的分享列表 |
+| GET | `/closet-share/received` | 分享给我的列表 |
+| GET | `/closet-share/:uuid` | 分享详情(属主 / 访客视角) |
+| POST | `/closet-share/:uuid/cancel` | 取消分享 |
+
+**收藏 / 点赞(5)**
+
+| 方法 | 路径 | 说明 |
+| --- | --- | --- |
+| POST | `/collect` | 收藏 / 取消收藏 |
+| GET | `/collect` | 我的收藏列表 |
+| GET | `/collect/:targetUUID` | 查询目标收藏状态与数量 |
+| POST | `/like` | 点赞 / 取消点赞 |
+| GET | `/like/:targetUUID` | 查询目标点赞状态与数量 |
+
+**通知(4)**
+
+| 方法 | 路径 | 说明 |
+| --- | --- | --- |
+| GET | `/notification` | 通知列表 |
+| GET | `/notification/unread` | 未读通知数 |
+| GET | `/notification/:uuid` | 通知详情 |
+| POST | `/notification/:uuid/read` | 标记单条已读 |
+
+**订单(4)**
+
+| 方法 | 路径 | 说明 |
+| --- | --- | --- |
+| POST | `/order` | 创建订单(微信 JSAPI 支付) |
+| POST | `/order/virtual` | 创建虚拟支付订单(小程序虚拟支付,iOS/Android 全终端) |
+| GET | `/order` | 我的订单列表 |
+| POST | `/order/:orderNo/cancel` | 取消订单 |
+| POST | `/order/refund` | 申请退款 |
+
+> **注意**:当前**没有"社区广场 / 发现页"列表接口**。公开穿搭只能通过 UUID 直接访问详情(`GET /outfit/:uuid`),或出现在"我的收藏"列表中。
+
+---
+
+## 4. 公共数据结构
+
+以下对象在多个接口中复用,后文接口章节直接引用,不再重复列出字段。
+
+### 4.1 用户对象 `user_info`
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| username | string | 昵称(新用户默认"微信用户") |
+| avatar | string | 头像地址 |
+| sex | int | 性别:`1`=男 `2`=女(`0` 或未设置时不展示) |
+| birth_day | int64 | 生日(秒级时间戳);**为 0 表示未设置**,运势接口会要求先完善 |
+| phone | string | 手机号 |
+| is_new_user | bool | 是否本次新注册用户(用于引导页) |
+| status | int | `1`=正常 `2`=临时禁用 `3`=永久禁用 `4`=已注销 |
+| ban_reason | string | 封禁原因 |
+| ban_start | int64 | 封禁开始时间(秒) |
+| ban_expire | int64 | 封禁到期时间(秒) |
+| vip_type | int | `1`=非会员 `2`=月度会员 `3`=年度会员 `4`=体验会员(新用户注册赠送) |
+| vip_expire | int64 | VIP 到期时间(秒) |
+
+### 4.2 衣物对象 `ClothesItem`
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| clothes_uuid | string | 衣物 UUID(后续查询/更新/删除均用此值) |
+| category_main | string | 大类:上装 / 下装 / 连衣裙 / 外套 / 鞋 / 配饰 |
+| category_sub | string | 小类:T恤 / 牛仔裤 / 运动鞋 / 发带 / 戒指 等 |
+| style | string | 风格 |
+| color_main | string | 主颜色 |
+| color_sub | string | 副颜色 |
+| thickness | string | 厚度 |
+| version | string | 版型:宽松 / 修身 / 阔腿 / 紧身 / 常规合身 |
+| material | string | 材质:牛仔布 / 针织 / 皮质 / 纯棉 / 羊毛 等 |
+| pattern | string | 花纹 |
+| sleeve_type | string | 袖型 |
+| clothes_type | string | 衣长:短款 / 常规 / 中长 / 长款 |
+| neckline_type | string | 领型 |
+| formality | string | 正式程度 `1-5` |
+| occasions | string | 适用场合 |
+| visual_impact | string | 视觉冲击力 `1-5` |
+| name | string | 名称 |
+| remark | string | 备注 |
+| processed_image | string | 抠图后的图片完整 URL(可直接展示) |
+
+### 4.3 穿搭对象 `OutfitItem`
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| outfit_uuid | string | 穿搭 UUID |
+| outfit_name | string | 穿搭名称(≤32) |
+| outfit_desc | string | 穿搭描述(≤500) |
+| scene | string | 适合场景(≤100) |
+| source | int | 来源:`1`=用户创建 `2`=AI 推荐 |
+| is_public | int | 公开:`1`=不公开 `2`=公开 |
+| audit_status | int | 审核:`1`=待审核 `2`=审核通过 `3`=审核不通过 |
+| items | array | 衣物列表,元素 `{"clothes_uuid": string}` |
+
+> ⚠️ 列表接口**不返回** `status` 字段,但数据库存在 `status`(`1`=正常 `2`=封禁 `3`=待用户保存)。AI 生成的"待保存"穿搭(`status=3`)也会出现在 `GET /outfit` 列表中,**前端需自行区分展示**(建议:AI 推荐未保存的单独归到"AI 推荐"入口,不要混入"我的搭配")。
+
+### 4.4 商品对象 `GoodsItem`
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| id | int64 | 商品 ID |
+| goods_code | string | 商品编码(**下单时用这个**) |
+| goods_name | string | 商品名称 |
+| goods_type | int | `1`=会员包 `2`=上传包 `3`=AI 推荐包 `4`=衣柜分享包 `5`=搭配分享包 |
+| price | int64 | 原价(**分**) |
+| discount_price | int64 | 售价(**分**,实际支付金额) |
+| member_days | int64 | 赠送会员天数;`>=365` 发放年度会员,否则月度会员 |
+| upload_quota | int64 | 赠送上传额度(对应后端 `cos_quota`) |
+| closet_share_quota | int64 | 赠送衣柜分享额度 |
+| outfit_share_quota | int64 | 赠送搭配分享额度 |
+| description | string | 商品描述 |
+
+> 商品对象**不含** AI 推荐额度展示字段,但 `goods_type=3`(AI 推荐包)会发放 AI 额度。
+
+### 4.5 订单对象 `OrderItem`
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| order_no | string | 订单号 |
+| goods_name | string | 商品名称 |
+| unit_price | int64 | 单价(**分**) |
+| pay_amount | int64 | 实付金额(**分**) |
+| pay_channel | int | 支付渠道 `1`=微信支付(JSAPI) `2`=虚拟支付 |
+| order_status | int | `1`=待支付 `2`=已支付 `3`=已取消 `4`=已过期 `5`=已退款 |
+| member_days | int64 | 订单快照:会员天数 |
+| expire_time | int64 | 支付截止时间(秒),前端倒计时用(15 分钟) |
+| pay_time | int64 | 支付时间(秒),未支付为 0 |
+| refund_time | int64 | 退款时间(秒),未退款为 0 |
+| refund_amount | int64 | 退款金额(**分**) |
+| created_at | int64 | 创建时间(秒) |
+
+### 4.6 通知对象 `NotificationItem`
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| notification_uuid | string | 通知 UUID |
+| msg_type | int | `1`=点赞通知 `2`=订单通知 `3`=系统通知 |
+| title | string | 标题 |
+| content | string | 内容 |
+| is_read | int | ⚠️ `1`=**未读** `2`=**已读**(不是布尔值,也不是 0/1) |
+| created_at | int64 | 创建时间(秒) |
+
+### 4.7 额度流水对象 `QuotaFlowItem`
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| quota_flow_uuid | string | 流水 UUID |
+| quota_type | int | `1`=上传 `2`=AI `3`=社区分享 `4`=衣柜分享 |
+| change_target | int | `1`=配额调整 `2`=使用 |
+| change_type | int | `1`=购买获赠 `2`=活动赠送 `3`=上传消耗 `4`=删除回退 `5`=人工调整 `6`=退款回收 |
+| change_num | int64 | 变更数量(**正数为增加,负数为减少**) |
+| new_value | int64 | 变更后的数值 |
+| reason | string | 变更原因 / 备注 |
+| order_no | string | 关联订单号(购买类流水才有) |
+| created_at | int64 | 创建时间(秒) |
+
+### 4.8 微信支付参数 `pay_params`
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| timeStamp | string | ⚠️ 注意大小写(微信原生命名),直接透传给 `wx.requestPayment` |
+| nonceStr | string | 随机字符串 |
+| package | string | 形如 `prepay_id=wx...` |
+| signType | string | 签名方式,如 `RSA` |
+| paySign | string | 签名 |
+
+> `pay_params` 是微信原样返回的字段,**整体透传给 `wx.requestPayment` 即可,不要改名或转换大小写**。
+
+---
+## 5. 接口详解
+
+> 参数位置标注:`json` = JSON 请求体、`query` = URL 查询参数、`path` = 路径参数、`form` = multipart 表单。
+> 路径中的 `:xxx` 是占位参数名,调用时替换为真实值(注意 `targetUUID`、`orderNo` 的驼峰写法)。
+
+---
+
+### 5.1 用户模块
+
+#### POST `/user/wxlogin` — 微信登录(公开)
+
+**业务逻辑**
+
+1. 前端 `wx.login()` 获取 `code`,传给后端;后端用 `code2session` 换 `openid`。
+2. **首次登录自动注册**:昵称默认"微信用户",`status=1` 正常,返回 `is_new_user=true`;同时**自动初始化 4 类免费额度**(内置默认:上传 20 / AI 推荐 3 / 搭配分享 10 / 衣柜分享 5;可由服务端配置 `UserDefaultQuota` 覆盖,允许配置为 0),并**赠送体验会员**(`vip_type=4`,内置默认 3 天;可由 `UserDefaultQuota.GiftVIPDay` 配置,配 0 表示不赠送),赠送成功后下发一条系统通知「欢迎礼包已到账」。因此新用户返回的 `vip_type` 通常为 `4`(配置为 0 时为 `1`)。
+3. 并发首次登录不会注册重复账号(依赖 `wx_open_id` 唯一索引)。
+4. 账号被禁用/注销(`status != 1`)时返回 `21001`,HTTP 仍是 200。
+5. 额度初始化失败**不影响登录**(仅后端告警)。
+
+**请求**(json)
+
+| 参数 | 类型 | 必填 | 约束 | 说明 |
+| --- | --- | --- | --- | --- |
+| code | string | ✅ | required | `wx.login` 获取的临时登录凭证,一次性 |
+
+**响应** `data`
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| access_token | string | 访问令牌,放入 `Authorization: Bearer` |
+| refresh_token | string | 刷新令牌,用于续期 |
+| expire_time | int64 | access_token 过期时间戳(秒) |
+| user_info | object | [用户对象](#41-用户对象-user_info) |
+
+**错误码**:`10000` 参数错误 · `21002` 微信登录失败 · `21003` 微信 Code 无效 · `21001` 账号已被禁用(HTTP 200)· `29000` 用户创建失败 · `19002` 缓存错误
+
+---
+
+#### POST `/user/refresh-token` — 刷新 Token(公开)
+
+**业务逻辑**
+
+- ⚠️ **`refresh_token` 与 `access_token` 两个字段都必须传**,缺少 access_token 会返回 `10000` + `details:["缺少旧 access token"]`。
+- 刷新成功后,服务端**立即吊销**旧的 access_token 与 refresh_token,并返回一对全新的 token —— 前端必须**整体替换**本地存储。
+- refresh_token 旋转更新,不可重复使用同一返回值多次刷新。
+
+**请求**(json)
+
+| 参数 | 类型 | 必填 | 约束 | 说明 |
+| --- | --- | --- | --- | --- |
+| refresh_token | string | ✅ | required | 当前有效的 refresh_token |
+| access_token | string | ✅ | 业务必填 | 当前(可能已过期)的 access_token,用于刷新后立即吊销 |
+
+**响应** `data`
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| access_token | string | 新访问令牌 |
+| refresh_token | string | 新刷新令牌 |
+| expire_time | int64 | 新 access_token 过期时间戳(秒) |
+
+**错误码**:`10000` + `缺少旧 access token` · `11001` Token 鉴权失败(无效/已被吊销)· `11002` Token 已过期 · `19002` 缓存错误
+
+---
+
+#### GET `/user/info` — 当前用户信息(鉴权)
+
+无请求参数。
+
+**响应** `data` = [用户对象](#41-用户对象-user_info)。
+
+> 新注册用户 `birth_day = 0`,此时调用运势接口会提示"请先完善生日信息",建议在首页做一次引导。
+
+**错误码**:`11001`/`11002`/`11004` 鉴权 · `22000` 用户不存在 · `29003` 用户查询失败
+
+---
+
+#### PUT `/user/profile` — 更新个人资料(鉴权)
+
+**业务逻辑**
+
+- **只更新非空字段**(传什么改什么),全空则直接返回成功且不写库。
+- 昵称按字符数(汉字算 1)限制 ≤ 32,超长返回 `10000` + `details:["昵称长度不能超过 32 个字符"]`。
+- `birth_day` 不能晚于当前时间。
+- 更新后自动失效缓存,下次 `GET /user/info` 即为最新值。
+
+**请求**(json,全部可选)
+
+| 参数 | 类型 | 必填 | 约束 | 说明 |
+| --- | --- | --- | --- | --- |
+| username | string | 否 | `max=32` | 昵称 |
+| avatar | string | 否 | `max=500` | 头像地址(建议先自行上传到 COS 再传 URL) |
+| sex | int | 否 | `oneof=0 1 2` | `0`=未知(忽略) `1`=男 `2`=女 |
+| birth_day | int64 | 否 | — | 生日(秒级时间戳),**决定星座与运势** |
+
+**响应** `data` = `{}`
+
+**错误码**:`10000` 参数错误(含 `昵称长度不能超过 32 个字符`、`头像地址过长`、`性别参数不合法`、`生日不能晚于当前时间`)· `29001` 用户更新失败
+
+---
+
+#### POST `/user/avatar/upload` — 上传头像(鉴权)
+
+**业务逻辑**
+
+- multipart 上传,字段名 `file`;仅支持图片(jpeg/png/gif/webp),复用全站图片扩展名白名单与大小限制(`MaxSizeMB`)。
+- 上传成功后写入 COS(key 规则 `{用户UUID}/{用户ID}/avatar/{随机名}.{扩展名}`),并将 COS key 存入 `users.avatar`。
+- 头像上传**不消耗** COS 上传额度。
+- 更新后自动失效缓存,`GET /user/info` 返回的头像字段为可直接访问的 URL。
+
+**请求**:`multipart/form-data`,字段名 `file`。
+
+**响应** `data`
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| avatar | string | 头像可访问 URL |
+
+**错误码**:`11001`/`11002`/`11004` 鉴权 · `30000` 上传文件为空 · `30001` 不支持的文件类型 · `30002` 文件大小超过限制 · `39000` 文件保存失败 · `22000` 用户不存在 · `29001` 用户更新失败
+
+---
+
+### 5.2 额度模块
+
+全站共 **4 类额度**:**上传额度为永久额度(不每天重置)**;**AI 推荐 / 社区分享 / 衣柜分享为每日额度(每天 0 点重置)**。免费额度由服务端配置 `UserDefaultQuota` 覆盖(下表「新用户默认」为内置兜底值,**允许配置为 0**,表示不赠送该类免费额度):
+
+| quota_type | 名称 | 类型 | 新用户默认 | 消耗场景 |
+| --- | --- | --- | --- | --- |
+| 1 | 上传额度 | 永久 | 20 | 上传一件衣物扣 1 |
+| 2 | AI 推荐额度 | 每日 | 3 | 生成一次 AI 穿搭推荐扣 1 |
+| 3 | 社区分享额度(搭配分享) | 每日 | 10 | 穿搭首次公开扣 1 |
+| 4 | 衣柜分享额度 | 每日 | 5 | 创建一次衣柜分享扣 1 |
+
+> 购买赠送额度以**额度凭证**(`quota_grants`)发放,分三类来源:**会员包**(随 VIP 有效期,续期自动延长)、**加量包**(独立有效期 `goods.valid_days`,到期失效)、**广告激励**(当天 23:59:59 到期,金额由服务端 `AdReward` 配置固定,开关控制)。Cos 消费先扣「即将过期凭证」再扣免费额度;每日额度总额 = 免费配置 + 有效凭证赠送额。
+
+#### GET `/quota/info` — 额度概览(鉴权)
+
+无请求参数。
+
+**响应** `data`
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| quotas | array | 额度数组,元素见下表 |
+
+`quotas[]`:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| quota_type | int | `1`=上传 `2`=AI `3`=社区分享 `4`=衣柜分享 |
+| total | int64 | 总额度(上传=永久总额度;AI/社区/衣柜=每日总额度) |
+| used | int64 | 已使用(上传=永久累计已用;AI/社区/衣柜=当日已用) |
+| remain | int64 | 剩余(`max(total-used, 0)`,不会为负;每日额度次日 0 点重置) |
+
+**错误码**:`19001` 数据库错误 · `89000` 额度更新失败
+
+---
+
+#### GET `/quota/flow` — 额度流水(鉴权)
+
+**请求**(query)
+
+| 参数 | 类型 | 必填 | 约束 | 说明 |
+| --- | --- | --- | --- | --- |
+| page / page_size | int | 否 | 见[分页约定](#24-分页约定) | 分页 |
+| quota_type | int | 否 | `oneof=1 2 3 4` | 额度类型筛选;不传 = 全部 |
+
+**响应** `data.list[]` = [额度流水对象](#47-额度流水对象-quotaflowitem)
+
+**错误码**:`10000` 参数错误(quota_type 非 1~4)· `19001` 数据库错误
+
+---
+
+#### POST `/ad-reward/grant` — 广告激励额度发放(鉴权)
+
+看广告后发放单次激励额度,**当天有效**(当天 23:59:59 到期),通过 `ref_id` 幂等防重复领取。
+
+**业务逻辑**:广告激励由服务端 `AdReward` 配置开关控制——**未开启时返回 `80004 广告激励未开启`**;发放金额由**服务端配置固定值**(`cos`/`ai`/`outfit_share`/`closet_share` 各类型分别配置),前端**不传金额**,只传类型与广告记录 ID。
+
+**请求**(JSON body)
+
+| 参数 | 类型 | 必填 | 约束 | 说明 |
+| --- | --- | --- | --- | --- |
+| reward_type | string | ✅ | `oneof=cos ai outfit_share closet_share` | 激励类型,映射到额度类型 |
+| ref_id | string | ✅ | — | 广告记录ID(幂等键,同 ref_id+类型 重复发放返回 `80002`) |
+
+**响应** `data`
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| ref_id | string | 广告记录ID |
+| quota_type | int | `1`=上传 `2`=AI `3`=社区分享 `4`=衣柜分享 |
+| amount | int64 | 发放数量(由服务端配置决定) |
+
+**错误码**:`80002` 广告激励已领取 · `80003` 无效的激励类型 · `80004` 广告激励未开启 · `10000` 参数错误 · `19001` 数据库错误
+
+---
+
+### 5.3 运势模块
+
+#### GET `/horoscope/today` — 今日星座运势(鉴权)
+
+**业务逻辑**
+
+- 星座由用户 `birth_day` **自动推算**,请求不需要传星座。
+- **未设置生日** → `10000` + `details:["请先完善生日信息"]`,前端应引导跳转资料页。
+- 读取顺序:Redis 缓存(有效期至次日 0 点)→ MySQL → **都没有时异步触发生成,并当次返回 `90300`**。此时前端应提示"运势生成中,请稍后重试",3~5 秒后重试即可。
+- 十二星座运势由后端每日 0 点定时任务批量生成(Redis 分布式锁保证多实例只生成一次)。
+- 运势是 AI 推荐的输入之一,缺失不影响 AI 推荐(会降级为仅星座或空)。
+
+**请求**:无参数。
+
+**响应** `data`
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| stat_date | int64 | 日期,格式 `YYYYMMDD`(如 `20260907`) |
+| zodiac | string | 星座中文名(摩羯座 / 水瓶座 / … / 射手座) |
+| overall_score | int | 综合运势 `1-5` |
+| love_score | int | 爱情运势 `1-5` |
+| career_score | int | 事业运势 `1-5` |
+| wealth_score | int | 财运运势 `1-5` |
+| daily_text | string | 今日运势文案 |
+| lucky_color | string | 今日幸运色 |
+| lucky_number | string | 今日幸运数字 |
+
+**错误码**:`10000` + `请先完善生日信息` · `90300` 星座运势不存在(生成中,请重试)· `19001` 数据库错误
+
+---
+
+### 5.4 衣物模块
+
+#### 图片处理状态枚举 `image_process_status`(全模块通用)
+
+| 值 | 含义 | 前端处理 |
+| --- | --- | --- |
+| 1 | 抠图中 | 继续轮询 |
+| 2 | 分类中 | 继续轮询 |
+| 3 | 排队中 | 继续轮询 |
+| 4 | **成功** | 终态:可读取识别结果、可用于 AI 推荐 |
+| 5 | **失败** | 终态:查看 `fail_reason`,可重新上传 |
+
+---
+
+#### POST `/clothes/upload` — 上传衣物图片(鉴权)
+
+**业务逻辑**
+
+1. **同步校验**:文件非空 → 扩展名白名单(默认 `jpg/jpeg/png`)→ 大小 ≤ 20MB(配置 `Upload.MaxSizeMB`)→ **magic bytes 文件头真图校验**(防伪造扩展名)。
+2. **同步扣 1 个上传额度**(`quota_type=1`),额度不足直接 `80000`,不会创建记录。
+3. 创建衣物记录,`image_process_status = 3`(排队中),**立即返回**。
+4. **后端异步流水线**(与请求无关,最多并发 10,单任务超时 3 分钟):
+ `3 排队中` → `1 抠图中`(BiRefNet 抠图,失败重试 3 次,间隔 1s)→ 上传 COS → `2 分类中`(Fashion-CLIP 识别 15 个属性,失败重试 3 次)→ `4 成功`(回填属性 + 抠图 URL)。
+5. **任一环节失败** → 置 `5 失败` 并写 `fail_reason`(`"图片抠图失败"` / `"图片保存失败"` / `"图片上传失败"` / `"图片分类失败"` / `"内部错误"`),**并自动回退 1 个上传额度**。
+6. **分类失败但抠图成功时**仍会保留抠好的图(`processed_image`),用户可用 `PUT /clothes/:uuid` 手动补全属性;但状态仍是 `5`。
+
+> **前端必做**:拿到 `clothes_uuid` 后**轮询** `GET /clothes/:uuid/status` 直到 `4` 或 `5`。**状态 1/2/3 时不要展示"完成"**。
+
+**请求**(form,`multipart/form-data`)
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| file | file | ✅ | 衣物图片,**字段名固定为 `file`**,无其他表单字段 |
+
+**响应** `data`
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| clothes_uuid | string | 衣物 UUID,后续查询/更新/删除/轮询都用它 |
+| image_process_status | int | 初始恒为 `3`(排队中) |
+
+**错误码**:`30000` 上传文件为空 · `30001` 不支持的文件类型(含 magic bytes 校验失败)· `30002` 文件大小超过限制 · `39000` 文件保存失败 · `50002` 创建衣物失败(额度已自动回退)· `80000` 额度不足
+
+---
+
+#### GET `/clothes` — 我的衣物列表(鉴权)
+
+**请求**(query)
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| page / page_size | int | 否 | 分页 |
+| category_main | string | 否 | 按大类筛选(上装 / 下装 / 连衣裙 / 外套 / 鞋 / 配饰) |
+| category_sub | string | 否 | 按小类筛选 |
+
+**响应** `data`:`total` / `page` / `page_size` + `list`([衣物对象](#42-衣物对象-clothesitem) 数组)
+
+> 列表**包含**处理中的衣物(状态 1/2/3),其属性字段为空字符串。前端应识别并展示"处理中"占位,或按 `image_process_status` 过滤(注意:列表响应不含该字段,如需判断请结合上传后的本地状态或详情接口)。
+
+**错误码**:`10000` 参数错误 · `19001` 数据库错误
+
+---
+
+#### GET `/clothes/:uuid` — 衣物详情(鉴权)
+
+**请求**(path)
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| uuid | string | ✅ | 衣物 UUID |
+
+**响应** `data` = [衣物对象](#42-衣物对象-clothesitem)
+
+**错误码**:`10000` 参数错误(uuid 为空)· `50000` 衣物不存在 · `50005` 无权操作该衣物
+
+---
+
+#### GET `/clothes/:uuid/status` — 查询 AI 处理状态(鉴权,轮询用)
+
+**请求**(path)
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| uuid | string | ✅ | 衣物 UUID |
+
+**响应** `data`
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| image_process_status | int | 处理状态,见 [5.4 节枚举表](#54-衣物模块) |
+| fail_reason | string | 失败原因(状态为 `5` 时有效,其余为空) |
+
+**错误码**:`10000` 参数错误 · `50000` 衣物不存在 · `50005` 无权操作该衣物
+
+---
+
+#### PUT `/clothes/:uuid` — 更新衣物属性(鉴权)
+
+**业务逻辑**
+
+- 用于**修正 AI 识别结果**或用户手动补全(尤其分类失败但抠图成功时)。
+- **只更新非空字段**,全空返回成功但不写库。
+- 字段**无枚举限制**,允许自定义值(如自定义分类)。
+- ⚠️ **主颜色的请求字段是 `color`,不是 `color_main`**(响应里才是 `color_main`),这是最容易写错的地方。
+- 更新不会改变 `image_process_status`(识别失败仍是 `5`,但属性已可用)。
+
+**请求**(path)
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| uuid | string | ✅ | 衣物 UUID(路径参数优先,body 中的 `clothes_uuid` 会被覆盖) |
+
+**请求体**(json,全部可选,传什么改什么)
+
+| 参数 | 类型 | 约束 | 说明 |
+| --- | --- | --- | --- |
+| category_main | string | ≤64 | 大类 |
+| category_sub | string | ≤64 | 小类 |
+| style | string | ≤64 | 风格 |
+| **color** | string | ≤64 | ⚠️ **主颜色(请求字段名是 `color`)** |
+| color_sub | string | ≤64 | 副颜色 |
+| thickness | string | ≤64 | 厚度 |
+| version | string | ≤64 | 版型 |
+| material | string | ≤64 | 材质 |
+| pattern | string | ≤64 | 花纹 |
+| sleeve_type | string | ≤64 | 袖型 |
+| clothes_type | string | ≤64 | 衣长类型 |
+| neckline_type | string | ≤64 | 领型 |
+| formality | string | ≤16 | 正式程度 1-5 |
+| occasions | string | ≤255 | 适用场合 |
+| visual_impact | string | ≤16 | 视觉冲击力 1-5 |
+| name | string | ≤64 | 名称 |
+| remark | string | ≤500 | 备注 |
+
+**响应** `data` = `{}`
+
+**错误码**:`10000` 参数错误 · `50000` 衣物不存在 · `50004` 更新衣物失败 · `50005` 无权操作该衣物
+
+---
+
+#### DELETE `/clothes/:uuid` — 删除衣物(鉴权)
+
+**业务逻辑**
+
+- **软删除**,**无恢复接口**,视为不可逆操作,前端需二次确认。
+- **额度回退规则**(重要):
+ - 衣物状态为 `1/2/3/4`(处理中或成功)且已扣额度 → 事务内**回退 1 个上传额度**;
+ - 衣物状态为 `5`(处理失败)→ **不回退**(失败时已回退过,防止双退)。
+- **已被穿搭引用的衣物无法删除** → `50001` 衣服已被搭配使用,无法删除(需先从穿搭中移除)。
+- 删除后,已创建的衣柜分享里该衣物会自动过滤掉(不会崩)。
+
+**请求**(path)
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| uuid | string | ✅ | 衣物 UUID |
+
+**响应** `data` = `{}`
+
+**错误码**:`10000` 参数错误 · `50000` 衣物不存在 · `50001` 衣服已被搭配使用,无法删除 · `50003` 删除衣物失败 · `50005` 无权操作该衣物
+
+---
+### 5.5 穿搭模块
+
+**枚举速查**:`source` `1`=用户创建 `2`=AI 推荐 · `is_public` `1`=不公开 `2`=公开 · `audit_status` `1`=待审核 `2`=审核通过 `3`=审核不通过 · `status` `1`=正常 `2`=封禁 `3`=待用户保存(AI 推荐生成后未保存)
+
+#### POST `/outfit` — 创建穿搭(鉴权)
+
+**业务逻辑**
+
+- 1~10 件衣物,**必须全部属于当前用户**(非本人 → `50005`,不存在 → `50000`),同一穿搭内重复衣物会自动去重。
+- 创建后默认 `is_public=1` 私有、`audit_status=1` 待审核、`status=1` 正常。
+- `source` 传非 1/2 的值时服务端兜底为 `1`(用户创建)。
+- **创建不扣任何额度**,公开时才扣。
+
+**请求**(json)
+
+| 参数 | 类型 | 必填 | 约束 | 说明 |
+| --- | --- | --- | --- | --- |
+| outfit_name | string | ✅ | required, ≤32 | 穿搭名称 |
+| outfit_desc | string | 否 | ≤500 | 穿搭描述 |
+| scene | string | 否 | ≤100 | 适合场景 |
+| source | int | 否 | `oneof=1 2` | `1`=用户创建 `2`=AI 推荐 |
+| items | array | ✅ | `min=1, max=10` | 衣物列表,元素 `{"clothes_uuid": string}` |
+
+**响应** `data` = [穿搭对象](#43-穿搭对象-outfititem)
+
+**错误码**:`10000` 参数错误(名称超长 / items 为空或超过 10 件)· `40001` 创建穿搭失败 · `50000` 衣物不存在 · `50005` 无权操作该衣物
+
+---
+
+#### GET `/outfit` — 我的穿搭列表(鉴权)
+
+**请求**(query)
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| page / page_size | int | 否 | 分页 |
+| is_public | int | 否 | `1`=不公开 `2`=公开;不传 = 全部 |
+| source | int | 否 | `1`=用户创建 `2`=AI 推荐;不传 = 全部 |
+
+**响应** `data.list[]` = [穿搭对象](#43-穿搭对象-outfititem)
+
+> ⚠️ 该接口**不返回 `status` 字段,也不会过滤 `status=3`(AI 待保存)的穿搭**。前端拿到列表后,建议用 `source` 区分来源;AI 推荐未保存的穿搭应引导用户去"AI 推荐"入口处理,不要混入"我的搭配"。
+
+**错误码**:`10000` 参数错误 · `19001` 数据库错误
+
+---
+
+#### GET `/outfit/:uuid` — 穿搭详情(鉴权)
+
+**请求**(path)
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| uuid | string | ✅ | 穿搭 UUID |
+
+**业务逻辑(可见性)**
+
+- 本人穿搭:始终可见。
+- 他人穿搭:必须同时满足 `is_public=2` + `audit_status=2` + `status=1`,否则返回 `40004`。
+
+**响应** `data` = [穿搭对象](#43-穿搭对象-outfititem)
+
+**错误码**:`10000` 参数错误 · `40000` 穿搭不存在 · `40004` 无权操作该穿搭(他人未公开/未过审)
+
+---
+
+#### PUT `/outfit/:uuid` — 更新穿搭(鉴权)
+
+**业务逻辑**
+
+- 只更新 `outfit_name` / `outfit_desc` / `scene` 与明细。
+- **明细采用"先删后建"**(事务内),中途失败不会留下空明细。
+- `outfit_uuid` 以路径参数为准(body 中传了也会被覆盖)。
+- 更新**不影响**公开状态与审核状态(已公开的仍是公开,不重复扣额度)。
+
+**请求**(path)
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| uuid | string | ✅ | 穿搭 UUID |
+
+**请求体**(json)
+
+| 参数 | 类型 | 必填 | 约束 | 说明 |
+| --- | --- | --- | --- | --- |
+| outfit_name | string | ✅ | required, ≤32 | 穿搭名称 |
+| outfit_desc | string | 否 | ≤500 | 穿搭描述 |
+| scene | string | 否 | ≤100 | 适合场景 |
+| items | array | ✅ | `min=1, max=10` | 衣物列表,元素 `{"clothes_uuid": string}` |
+
+**响应** `data` = `{}`
+
+**错误码**:`10000` 参数错误 · `40000` 穿搭不存在 · `40003` 更新穿搭失败 · `40004` 无权操作该穿搭 · `50000` 衣物不存在
+
+---
+
+#### DELETE `/outfit/:uuid` — 删除穿搭(鉴权)
+
+**业务逻辑**
+
+- 软删穿搭 + **物理删除**明细,**无恢复接口**,需二次确认。
+- **不返还任何额度**(已扣的搭配分享额度不退)。
+- 删除后,他人收藏列表与点赞记录不会自动清理。
+
+**请求**(path)
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| uuid | string | ✅ | 穿搭 UUID |
+
+**响应** `data` = `{}`
+
+**错误码**:`10000` 参数错误 · `40000` 穿搭不存在 · `40002` 删除穿搭失败 · `40004` 无权操作该穿搭
+
+---
+
+#### PUT `/outfit/:uuid/public` — 设置公开状态(鉴权)
+
+**业务逻辑(额度相关,重点)**
+
+- **公开(`is_public=2`)**:条件更新 `is_public 1→2` 且 `audit_status → 2`(**自动审核通过,无需等待人工**)→ 成功后**扣 1 个社区分享额度**(`quota_type=3`)。
+ - **额度不足 → 回滚为私有**并返回 `80000`,穿搭不会公开。
+ - 重复公开(已是公开态)→ **幂等成功,不重复扣额度**。
+ - ⚠️ **取消公开后再次公开,会再扣 1 个额度**(因为 `is_public` 已回到 1,首次公开条件重新成立)。
+- **取消公开(`is_public=1`)**:直接置为私有,**不返还已扣额度**。
+- 公开成功后,其他用户可通过 `POST /like`、`POST /collect` 按 UUID 互动。
+
+**请求**(path)
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| uuid | string | ✅ | 穿搭 UUID |
+
+**请求体**(json)
+
+| 参数 | 类型 | 必填 | 约束 | 说明 |
+| --- | --- | --- | --- | --- |
+| is_public | int | ✅ | required, `oneof=1 2` | `1`=不公开 `2`=公开 |
+
+**响应** `data` = `{}`
+
+**错误码**:`10000` 参数错误 · `40000` 穿搭不存在 · `40003` 更新穿搭失败 · `40004` 无权操作该穿搭 · `80000` 额度不足(已自动回滚为私有)
+
+---
+
+### 5.6 AI 推荐模块
+
+#### POST `/ai-recommend` — 生成 AI 穿搭推荐(鉴权)
+
+**业务逻辑**
+
+1. **并发锁**:同一用户 180 秒内只能有一个生成任务,抢锁失败返回 `10003` 请求过于频繁(**HTTP 429**),前端应提示"正在生成中,请稍候"。
+2. **生成前先扣 1 个 AI 额度**(`quota_type=2`);额度不足返回 **`90401` AI 推荐次数已达上限**(注意不是 `80000`)。
+3. 输入:当前用户**处理成功(状态 4)**的全部衣物 + 生日推算的星座 + 今日运势(星座/运势缺失会降级,不影响生成)。
+4. **衣橱中没有可用衣物** → `90400` + `details:["衣橱暂无可用衣物"]`,**额度已自动回退**。
+5. 调用大模型生成;失败或无有效单品(`details:["未生成有效搭配"]`)→ `90400`,**额度自动回退**。
+6. 成功:落库为穿搭,`source=2`、`is_public=1`、`audit_status=1`、`status=3`(**待用户保存**),并返回完整内容供预览。
+7. ⚠️ **生成成功但未调用 save,不退还 AI 额度**(额度在生成时已消耗)。
+
+**请求**(json)
+
+| 参数 | 类型 | 必填 | 约束 | 说明 |
+| --- | --- | --- | --- | --- |
+| scene_type | int | ✅ | required, `oneof=1 2` | `1`=每日推荐 `2`=个性化场景 |
+| scene | string | 否 | — | 用户自定义场景描述(`scene_type=2` 时使用,如"参加朋友的婚礼") |
+
+**响应** `data`
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| outfit_uuid | string | 生成结果 UUID(**保存时要用**) |
+| outfit_name | string | 穿搭名称 |
+| outfit_desc | string | 穿搭描述 |
+| scene | string | 场景 |
+| source | int | `2`=AI 推荐 |
+| status | int | `3`=待用户保存 |
+| outfit_item | array | ⚠️ 注意字段名是 `outfit_item`(单数 item),元素 `{"clothes_uuid": string}` |
+
+**错误码**:`10000` 参数错误 · `10003` 请求过于频繁(429,180 秒内重复请求)· `90400` AI 推荐失败(含 `衣橱暂无可用衣物`、`未生成有效搭配`)· `90401` AI 推荐次数已达上限(AI 额度不足)· `19002` 缓存错误 · `19003` 下游服务调用失败
+
+---
+
+#### POST `/ai-recommend/save` — 保存 AI 推荐为我的穿搭(鉴权)
+
+**业务逻辑**
+
+- 仅 `status=3`(待用户保存)的穿搭可保存 → 置为 `status=1`。
+- 已是 `status=1` → **幂等成功**(重复保存安全)。
+- 其它状态(如已封禁)→ `40003`。
+- **保存不扣任何额度**;后续若要公开到社区,仍需调 `PUT /outfit/:uuid/public`,届时扣 1 个搭配分享额度。
+
+**请求**(json)
+
+| 参数 | 类型 | 必填 | 约束 | 说明 |
+| --- | --- | --- | --- | --- |
+| outfit_uuid | string | ✅ | required | 由 `/ai-recommend` 返回的 `outfit_uuid` |
+
+**响应** `data` = `{}`
+
+**错误码**:`10000` 参数错误 · `40000` 穿搭不存在 · `40003` 更新穿搭失败(状态不允许保存)· `40004` 无权操作该穿搭
+
+---
+
+### 5.7 衣柜分享模块
+
+**状态枚举** `status`:`1`=正常 `2`=已取消 `3`=已过期 `4`=已封禁
+**分享类型** `share_type`:`1`=部分衣物分享 `2`=全量快照分享
+
+#### POST `/closet-share` — 创建衣柜分享(鉴权)
+
+**业务逻辑**
+
+1. `expire_time`:`0` 表示**永不过期**;非 0 时必须晚于当前时间(否则 `10000` + `details:["过期时间必须晚于当前时间"]`)。
+2. `share_type=1`:必须传非空 `clothes_uuids`(否则 `10000` + `details:["部分分享需选择衣物"]`),且衣物必须全部属于本人。
+ `share_type=2`:自动快照当前全部衣物(后端分页遍历,无需传列表)。
+3. **先扣 1 个衣柜分享额度**(`quota_type=4`),不足 → `80000`。
+4. 创建分享(`status=1` 正常)→ 写入明细;**明细写入失败会回退额度并返回 `19001`**(避免产生空分享)。
+5. 创建即自动审核通过。
+
+**请求**(json)
+
+| 参数 | 类型 | 必填 | 约束 | 说明 |
+| --- | --- | --- | --- | --- |
+| share_type | int | ✅ | required, `oneof=1 2` | `1`=部分衣物 `2`=全量快照 |
+| clothes_uuids | array | 条件必填 | — | 衣物 UUID 数组;`share_type=1` 时必填且非空 |
+| expire_time | int64 | 否 | — | 过期时间戳(秒);`0` 或省略 = 永不过期 |
+
+**响应** `data`
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| share_uuid | string | 分享 UUID,供他人访问分享详情 |
+
+**错误码**:`10000` 参数错误(含 `部分分享需选择衣物`、`过期时间必须晚于当前时间`)· `80000` 额度不足 · `19001` 数据库错误(额度已回退)· `50000` 衣物不存在 · `50005` 无权操作该衣物
+
+---
+
+#### GET `/closet-share` — 我的分享列表(鉴权)
+
+**请求**(query)
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| page / page_size | int | 否 | 分页 |
+| status | int | 否 | `1`=正常 `2`=已取消 `3`=已过期 `4`=已封禁;不传 = 全部 |
+
+**响应** `data.list[]`
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| share_uuid | string | 分享 UUID |
+| share_type | int | `1`=部分衣物 `2`=全量快照 |
+| clothes_count | int | 衣物数量 |
+| expire_time | int64 | 过期时间戳(秒),`0`=永不过期 |
+| status | int | `1`=正常 `2`=已取消 `3`=已过期 `4`=已封禁 |
+| owner_name | string | 分享者昵称(本接口暂不返回,恒为空) |
+| owner_avatar | string | 分享者头像(本接口暂不返回,恒为空) |
+
+> ⚠️ **过期是"惰性"的**:`expire_time` 到了但没人访问时,列表里 `status` 可能仍是 `1`,直到有人访问详情才变成 `3`。**前端应同时判断 `status` 与 `expire_time`** 来展示"已过期"。
+
+**错误码**:`10000` 参数错误 · `19001` 数据库错误
+
+---
+
+#### GET `/closet-share/received` — 分享给我的列表(鉴权)
+
+**业务逻辑**
+
+- "访问即视为收到":登录用户(非分享属主)成功访问他人分享详情时,自动记录接收关系;重复访问不重复记录,属主访问不记录。
+- 列表按接收记录倒序分页返回,支持按 `status` 筛选。
+
+**请求**(query)
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| page / page_size | int | 否 | 分页 |
+| status | int | 否 | `1`=正常 `2`=已取消 `3`=已过期 `4`=已封禁;不传 = 全部 |
+
+**响应** `data.list[]`
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| share_uuid | string | 分享 UUID |
+| share_type | int | `1`=部分衣物 `2`=全量快照 |
+| clothes_count | int | 衣物数量 |
+| expire_time | int64 | 过期时间戳(秒),`0`=永不过期 |
+| status | int | `1`=正常 `2`=已取消 `3`=已过期 `4`=已封禁 |
+| owner_name | string | 分享者昵称 |
+| owner_avatar | string | 分享者头像 |
+
+> ⚠️ 过期与状态判定同"我的分享列表",前端应同时判断 `status` 与 `expire_time`。
+
+**错误码**:`10000` 参数错误 · `19001` 数据库错误
+
+---
+
+#### GET `/closet-share/:uuid` — 分享详情(鉴权,属主 / 访客视角)
+
+**业务逻辑**
+
+- 属主与访客都可访问(需已登录,`is_owner` 区分视角)。
+- **访客访问会 +1 访问计数**,属主自己访问不计数。
+- 状态处理:`2` 已取消 → `90102`;`4` 已封禁 → `90100`(**故意伪装成"不存在"**);`3` 已过期 → `90101`;`expire_time` 已过 → 惰性置为 `3` 并返回 `90101`。
+- 已被软删除的衣物会自动从分享中过滤。
+
+**请求**(path)
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| uuid | string | ✅ | 分享 UUID |
+
+**响应** `data`
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| share_uuid | string | 分享 UUID |
+| owner_name | string | 分享者昵称 |
+| owner_avatar | string | 分享者头像 |
+| clothes | array | [衣物对象](#42-衣物对象-clothesitem) 数组 |
+| expire_time | int64 | 过期时间戳(秒) |
+| status | int | `1`=正常 `2`=已取消 `3`=已过期 `4`=已封禁 |
+| is_owner | bool | 是否本人分享(`true` 时隐藏"收藏/点赞"入口) |
+
+**错误码**:`10000` 参数错误 · `90100` 分享不存在(含已封禁)· `90101` 分享已过期 · `90102` 分享已取消
+
+---
+
+#### POST `/closet-share/:uuid/cancel` — 取消分享(鉴权)
+
+**业务逻辑**
+
+- **仅属主可操作**,他人 → `12000`(HTTP 403)。
+- 置 `status=2`,**不可逆,且不返还衣柜分享额度**,前端需二次确认。
+
+**请求**(path)
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| uuid | string | ✅ | 分享 UUID |
+
+**响应** `data` = `{}`
+
+**错误码**:`10000` 参数错误 · `12000` 无权限访问(403)· `90100` 分享不存在 · `19001` 数据库错误
+
+---
+
+### 5.8 收藏与点赞模块
+
+**动作枚举** `action`:`1`=执行(点赞/收藏)`2`=取消。两个接口都**幂等**,重复提交无副作用。
+
+#### POST `/collect` — 收藏 / 取消收藏(鉴权)
+
+**业务逻辑**
+
+- 目标为社区穿搭 UUID。目标不存在 → `90200`;**非本人且未公开可见** → `40004`。
+- 取消时仅当当前为"已收藏"才更新为已取消;**记录不会物理删除**,可再次收藏。
+- **收藏不触发任何通知**。
+
+**请求**(json)
+
+| 参数 | 类型 | 必填 | 约束 | 说明 |
+| --- | --- | --- | --- | --- |
+| target_uuid | string | ✅ | required | 目标穿搭 UUID |
+| action | int | ✅ | required, `oneof=1 2` | `1`=收藏 `2`=取消收藏 |
+
+**响应** `data` = `{}`
+
+**错误码**:`10000` 参数错误 · `90200` 目标内容不存在 · `90202` 收藏操作失败 · `40004` 无权操作该穿搭 · `19001` 数据库错误
+
+---
+
+#### GET `/collect` — 我的收藏列表(鉴权)
+
+**请求**(query):仅 `page` / `page_size`。
+
+**响应** `data.list[]` = [穿搭对象](#43-穿搭对象-outfititem)
+
+**错误码**:`10000` 参数错误 · `19001` 数据库错误
+
+---
+
+#### GET `/collect/:targetUUID` — 查询收藏状态与数量(鉴权)
+
+进入穿搭详情时用于初始化收藏按钮状态。
+
+**请求**(path)
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| **targetUUID** | string | ✅ | ⚠️ 路径参数名是 `targetUUID`(驼峰),不是 `uuid` |
+
+**响应** `data`
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| is_collected | bool | 当前用户是否已收藏 |
+| collect_count | int64 | 该穿搭收藏总数 |
+
+**错误码**:`10000` 参数错误 · `90200` 目标内容不存在 · `40004` 无权操作该穿搭 · `19001` 数据库错误
+
+---
+
+#### POST `/like` — 点赞 / 取消点赞(鉴权)
+
+**业务逻辑**
+
+- 目标为社区穿搭 UUID。目标不存在 → `90200`;**非本人且未公开可见** → `40004`。
+- **通知触发条件**:仅当状态从"无记录或已取消"变为"已点赞"、且**不是给自己点赞**时,才向穿搭作者发一条 `msg_type=1` 通知(标题"收到新的点赞",内容"你的穿搭「{名称}」收到了一个赞")。**重复点赞不会重复通知**。
+
+**请求**(json)
+
+| 参数 | 类型 | 必填 | 约束 | 说明 |
+| --- | --- | --- | --- | --- |
+| target_uuid | string | ✅ | required | 目标穿搭 UUID |
+| action | int | ✅ | required, `oneof=1 2` | `1`=点赞 `2`=取消点赞 |
+
+**响应** `data` = `{}`
+
+**错误码**:`10000` 参数错误 · `90200` 目标内容不存在 · `90201` 点赞操作失败 · `40004` 无权操作该穿搭 · `19001` 数据库错误
+
+---
+
+#### GET `/like/:targetUUID` — 查询点赞状态与数量(鉴权)
+
+**请求**(path)
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| **targetUUID** | string | ✅ | ⚠️ 路径参数名是 `targetUUID`(驼峰),不是 `uuid` |
+
+**响应** `data`
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| is_liked | bool | 当前用户是否已点赞 |
+| like_count | int64 | 该穿搭点赞总数 |
+
+**错误码**:`10000` 参数错误 · `19001` 数据库错误
+
+---
+
+### 5.9 通知模块
+
+**`is_read` 取值**:⚠️ `1`=**未读** `2`=**已读**(非常规的 0/1,也不是布尔值)
+**`msg_type` 取值**:`1`=点赞通知 `2`=订单通知 `3`=系统通知(当前代码无发送点,预留)
+
+**通知触发点(仅 3 处)**
+
+| 触发动作 | msg_type | 标题 | 内容 | 接收者 |
+| --- | --- | --- | --- | --- |
+| 他人点赞我的公开穿搭 | `1` | 收到新的点赞 | 你的穿搭「{OutfitName}」收到了一个赞 | 穿搭作者(非本人点赞才触发) |
+| 支付成功 | `2` | 订单支付成功 | 你购买的「{GoodsName}」已到账 | 买家(sender=0 系统) |
+| 退款成功 | `2` | 退款成功 | 订单「{GoodsName}」已退款 | 买家(sender=0 系统) |
+
+> 通知**无推送能力**(无 WebSocket/订阅消息),前端需**轮询** `GET /notification/unread` 刷新红点。
+
+#### GET `/notification` — 通知列表(鉴权)
+
+**请求**(query)
+
+| 参数 | 类型 | 必填 | 约束 | 说明 |
+| --- | --- | --- | --- | --- |
+| page / page_size | int | 否 | — | 分页 |
+| is_read | int | 否 | `oneof=1 2` | ⚠️ `1`=未读 `2`=已读;不传 = 全部(**不是布尔值**) |
+| msg_type | int | 否 | `oneof=1 2 3` | `1`=点赞 `2`=订单 `3`=系统;不传 = 全部(可与 `is_read` 组合筛选) |
+
+**响应** `data.list[]` = [通知对象](#46-通知对象-notificationitem)
+
+**错误码**:`10000` 参数错误 · `19001` 数据库错误
+
+---
+
+#### GET `/notification/unread` — 未读通知数(鉴权)
+
+**业务逻辑**:优先读 Redis 计数;Redis 为 0 或异常时回落 MySQL 统计并回填缓存。计数为 best-effort,可能短暂偏小,**展示红点时以列表接口为准更稳妥**。
+
+**响应** `data`
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| unread_count | int64 | 未读通知数 |
+
+**错误码**:`19001` 数据库错误 · `19002` 缓存错误
+
+---
+
+#### GET `/notification/:uuid` — 通知详情(鉴权)
+
+**请求**(path)
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| uuid | string | ✅ | 通知 UUID |
+
+**响应** `data` = [通知对象](#46-通知对象-notificationitem)
+
+**错误码**:`10000` 参数错误 · `90000` 通知不存在 · `12000` 无权限访问
+
+---
+
+#### POST `/notification/:uuid/read` — 标记已读(鉴权)
+
+**业务逻辑**
+
+- **条件更新**(仅"未读→已读"),防并发重复扣减未读数;已读时直接返回当前未读数,幂等。
+- ⚠️ **没有"全部已读"接口**,需要全标已读时前端需逐条调用。
+
+**请求**(path)
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| uuid | string | ✅ | 通知 UUID |
+
+**响应** `data` = `{}`
+
+**错误码**:`10000` 参数错误 · `90000` 通知不存在 · `19001` 数据库错误
+
+---
+
+### 5.10 商品模块(公开,无需登录)
+
+**`goods_type` 枚举**:`1`=会员包 `2`=上传包 `3`=AI 推荐包 `4`=衣柜分享包 `5`=搭配分享包
+**`status`**:`1`=上架 `2`=下架(列表只返回上架商品)
+
+#### GET `/goods` — 商品列表(公开)
+
+**请求**(query)
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| page / page_size | int | 否 | 分页 |
+| goods_type | int | 否 | 商品类型筛选;不传或 `0` = 全部 |
+
+**响应** `data.list[]` = [商品对象](#44-商品对象-goodsitem)(**仅上架商品**)
+
+**错误码**:`10000` 参数错误 · `19001` 数据库错误
+
+---
+
+#### GET `/goods/:code` — 按编码查询商品(公开)
+
+**请求**(path)
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| code | string | ✅ | 商品编码 `goods_code`(**不是数字 ID**) |
+
+**响应** `data` = [商品对象](#44-商品对象-goodsitem)
+
+**错误码**:`10000` 参数错误 · `60000` 商品不存在 · `60001` 商品已下架
+
+---
+
+### 5.11 订单与退款模块
+
+**`order_status` 枚举**:`1`=待支付 `2`=已支付 `3`=已取消 `4`=已过期 `5`=已退款
+**退款 `status` 枚举**:`0`=待处理 `1`=退款中 `2`=退款成功 `3`=退款失败
+
+#### POST `/order` — 创建订单并发起微信支付(鉴权)
+
+**业务逻辑**
+
+1. 只用 `goods_code` 下单,**金额由服务端按商品售价 `discount_price` 计算**,前端**不要传金额**。
+2. 商品必须存在且上架(否则 `60000` / `60001`)。
+3. 用户必须有 `wx_open_id`(即必须通过微信登录),否则 `79000` + `details:["缺少微信 openid"]`。
+4. **支付有效期 15 分钟**,`expire_time` 用于倒计时。
+5. 下单时把商品权益(会员天数 / 各类额度)**快照进订单**,后续商品改价不影响已下单权益。
+6. **先落本地待支付订单,再调微信 JSAPI**。微信下单失败返回 `79000`,但**本地订单保留为待支付**,15 分钟后自动过期。
+7. 同一用户可并存多个待支付订单(**无防重限制**),前端应避免重复点击。
+
+**请求**(json)
+
+| 参数 | 类型 | 必填 | 约束 | 说明 |
+| --- | --- | --- | --- | --- |
+| goods_code | string | ✅ | required | 商品编码(**唯一请求字段**) |
+
+**响应** `data`
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| order_no | string | 订单号(后续取消/退款/查询用) |
+| pay_amount | int64 | 应付金额(**分**) |
+| expire_time | int64 | 支付截止时间戳(秒),倒计时用(15 分钟) |
+| pay_params | object | 微信支付参数,[见此](#48-微信支付参数-pay_params),**整体透传给 `wx.requestPayment`** |
+
+**错误码**:`10000` 参数错误 · `60000` 商品不存在 · `60001` 商品已下架 · `79000` 创建订单失败(含 `缺少微信 openid`)· `19001` 数据库错误
+
+---
+
+#### POST `/order/virtual` — 创建虚拟支付订单(鉴权)
+
+小程序虚拟商品(会员 / 额度包)**必须走虚拟支付**(微信 2026-04-01 起全终端强制)。本接口本地落单(`pay_channel=2`)后返回前端调起 `wx.requestVirtualPayment` 所需的签名参数,**不调用微信 JSAPI 下单**。
+
+**业务逻辑**
+
+1. 服务端校验虚拟支付已开通、商品上架且已发布为虚拟支付道具(`product_id` 非空、`product_status=2`)。
+2. 下单时把商品权益快照进订单;`outTradeNo` 复用本地订单号。
+3. 服务端用 `offerId` + 现网 `appKey` 生成 `signData` / `paySig`,用登录缓存的 `session_key` 生成 `signature`,原样返回前端。
+4. 支付结果**不由前端回调决定**:前端 `success` 可能丢失,最终以服务端收到的「道具发货推送」为准。
+
+**请求**(json)
+
+| 参数 | 类型 | 必填 | 约束 | 说明 |
+| --- | --- | --- | --- | --- |
+| goods_code | string | ✅ | required | 商品编码 |
+| platform | string | ✅ | `oneof=android ios harmony windows` | 支付平台,前端按 `wx.getSystemInfoSync().platform` 上报 |
+
+**响应** `data`
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| order_no | string | 订单号(即 `outTradeNo`) |
+| pay_amount | int64 | 实付金额(**分**) |
+| expire_time | int64 | 支付截止时间戳(秒),15 分钟 |
+| sign_data | string | 参与签名的 JSON 字符串,**原样传给 `wx.requestVirtualPayment`,勿 JSON.parse/stringify** |
+| pay_sig | string | 支付签名(服务端生成) |
+| signature | string | 用户态签名(服务端生成) |
+
+**前端调用契约**(示例)
+
+```js
+const res = await post('/order/virtual', { goods_code, platform: wx.getSystemInfoSync().platform })
+wx.requestVirtualPayment({
+ signData: res.data.sign_data, // 原样透传,勿重新序列化
+ paySig: res.data.pay_sig,
+ signature: res.data.signature,
+ success: () => { /* 仅表示拉起成功,仍需轮询 GET /order 确认 order_status=2 */ },
+ fail: (err) => { /* requestVirtualPayment:fail cancel = 用户取消 */ },
+})
+```
+
+**错误码**:`10000` 参数错误 · `60000` 商品不存在 · `60001` 商品已下架 · `70005` 虚拟支付未开通 · `70006` 商品尚未发布为虚拟支付道具 · `70007` 虚拟支付订单不支持取消 · `70008` iOS 虚拟支付最低 1 元 · `79003` 创建虚拟支付订单失败 · `19001` 数据库错误
+
+> iOS 虚拟支付限制:最低 1 元(低于 100 分返回 `70008`)、iOS 15+、微信 8.0.68+、不支持沙箱;结算由 Apple(45~60 天)。iOS 订单退款用户走 App Store 申请,服务端会收到退款问询推送并应答。
+>
+> **运营约定**:虚拟支付道具的发布与改价由**管理后台**负责(服务端**不会自动同步**道具)。商品发布为道具后回填 `product_id` / `product_status=2` 才可下单;**运营改价后必须通过管理后台同步微信侧道具价格**,否则微信侧道具价格与本地不一致:微信扣款多于订单金额时发货金额校验会拒绝(用户付款拿不到权益),少于订单金额时产生差价资损。
+
+---
+
+#### GET `/order` — 我的订单列表(鉴权)
+
+**请求**(query)
+
+| 参数 | 类型 | 必填 | 约束 | 说明 |
+| --- | --- | --- | --- | --- |
+| page / page_size | int | 否 | — | 分页 |
+| status | int | 否 | `oneof=1 2 3 4 5` | 订单状态筛选;不传 = 全部 |
+
+**响应** `data.list[]` = [订单对象](#45-订单对象-orderitem)
+
+**错误码**:`10000` 参数错误 · `19001` 数据库错误
+
+---
+
+#### POST `/order/:orderNo/cancel` — 取消订单(鉴权)
+
+**业务逻辑**
+
+- **虚拟支付订单(`pay_channel=2`)不可取消** → `70007`(虚拟支付无服务端关单 API,取消后微信侧单仍可支付,会造成付款不到账)。
+- **仅 `1` 待支付**订单可取消;已支付 → `70002`;其它状态 → `70001`。
+- 条件更新 `1→3`,失败 → `70001`。
+- 本地取消后后端尽力调用微信关单(失败仅告警,不影响结果)。
+- **不可撤销**,但可重新下单。
+
+**请求**(path)
+
+| 参数 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| **orderNo** | string | ✅ | ⚠️ 路径参数名是 `orderNo`(驼峰),不是 `uuid` |
+
+**响应** `data` = `{}`
+
+**错误码**:`10000` 参数错误 · `70000` 订单不存在 · `70001` 订单状态不正确 · `70002` 订单已支付 · `79001` 取消订单失败 · `12000` 无权限访问
+
+---
+
+#### POST `/order/refund` — 申请退款(鉴权)
+
+**业务逻辑(重要:人工审核模式)**
+
+- **iOS 虚拟支付订单不可主动退款** → `71004`,请引导用户走 App Store 申请退款(Apple 通过退款问询推送回收权益)。
+- **仅 `2` 已支付**订单可申请,其它状态 → `71001`。
+- ⚠️ **系统不会自动调微信退款**:接口只是登记退款申请(`status=0` 待处理),**需运营人工审核并手动发起退款**——普通支付订单在微信支付商户后台退款,**虚拟支付订单(`pay_channel=2`)请在虚拟支付后台(Midas)发起退款**,退款结果由推送回调同步。
+- 前端应提示"**退款审核中**",并允许轮询订单状态直到 `5` 已退款;**不要承诺实时到账**。
+- 同一订单已有退款记录且状态不是 `3`(失败)→ `71001`;上次失败可重新申请(复用记录、换新退款单号)。
+- 退款成功后,订单置 `5`,**会员天数与已发放额度会被回收**(已消耗部分不回收),并发退款通知。
+
+**请求**(json)
+
+| 参数 | 类型 | 必填 | 约束 | 说明 |
+| --- | --- | --- | --- | --- |
+| order_no | string | ✅ | required | 订单号 |
+| reason | string | ✅ | required, ≤200 | 退款原因 |
+
+**响应** `data`
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| refund_no | string | 退款单号 |
+| refund_amount | int64 | 退款金额(**分**) |
+| status | int | `0`=待处理 `1`=退款中 `2`=退款成功 `3`=退款失败 |
+| apply_time | int64 | 申请时间(秒) |
+
+**错误码**:`10000` 参数错误 · `70000` 订单不存在 · `71000` 退款记录不存在 · `71001` 当前状态不允许退款 · `71002` 退款金额不合法 · `71003` 退款申请失败 · `71004` iOS 订单请通过 App Store 申请退款
+
+---
+
+#### POST `/order/pay/notify` · POST `/order/refund/notify` — 微信回调(公开)
+
+🚫 **这是微信服务端回调地址,前端绝对不要调用。**
+
+- 不走统一 `{code,msg,data}` 响应包装,按微信 V3 规范返回:成功 HTTP 200 `{"code":"SUCCESS","message":"成功"}`;失败 HTTP 500 `{"code":"FAIL","message":"<原因>"}`。
+- 服务端处理:验签 + 解密 → 幂等锁(30 秒)→ **金额校验**(与订单金额不一致则拒绝流转、人工介入)→ 流转订单状态 → 发放/回收权益 → 发站内通知。
+- 竞态兜底:已过期但已付款 → 补发货;已取消但已付款 → **自动发起微信退款**。
+
+---
+## 6. 业务逻辑与状态机
+
+### 6.1 七条业务主线(总览)
+
+```mermaid
+flowchart TD
+ A["微信登录
新用户自动注册"] --> B["初始化免费额度
默认 上传 20 / AI 3 / 搭配分享 10 / 衣柜分享 5
(可配置,允许为 0)"]
+ B --> C["上传衣物
校验 → 扣上传额度 → 异步 AI 抠图 + 分类"]
+ C --> D["衣橱管理
查看 / 修正属性 / 删除回退额度"]
+ D --> E["创建穿搭"]
+ D --> F["AI 穿搭推荐
扣 AI 额度 → 大模型生成 → 待用户保存"]
+ D --> G["衣柜分享
部分衣物 / 全量快照"]
+ E --> H["公开到社区
自动审核通过 → 扣搭配分享额度"]
+ H --> I["社区互动
点赞 → 触发站内通知"]
+ F --> E
+ B --> J{"额度不足?"}
+ J -- "是" --> K["选购商品 → 微信支付 → 权益到账"]
+ K --> B
+```
+
+1. **注册与额度**:微信登录首次自动注册,同步初始化 4 类免费额度(内置默认 上传 20 / AI 3 / 搭配分享 10 / 衣柜分享 5,可由 `UserDefaultQuota` 配置,允许为 0)。
+2. **衣物生命周期**:上传(校验 + 扣额)→ 异步 AI 处理(抠图 → 分类)→ 成功入库可被选用 → 删除软删并回退额度。
+3. **穿搭与社区**:创建穿搭 → 公开(自动审核通过 + 扣额度)→ 他人点赞触发通知。
+4. **衣柜分享**:部分衣物或全量快照 → 扣额度 → 属主/访客查看 → 取消或惰性过期。
+5. **交易与权益**:选购商品 → 下单(15 分钟有效)→ 微信支付回调发放权益 → 超时过期 / 退款回收。
+6. **AI 推荐**:扣 AI 额度 → 结合星座运势与衣橱生成 → 落库"待用户保存" → 保存转正式穿搭。
+7. **通知**:点赞、支付成功、退款成功触发站内通知,未读计数实时维护(需前端轮询)。
+
+---
+
+### 6.2 衣物 AI 处理状态机
+
+```mermaid
+stateDiagram-v2
+ [*] --> queued : 上传成功(同步扣上传额度 1 点)
+ queued --> matting : 异步任务开始
+ matting --> classifying : 抠图完成(上传 COS)
+ classifying --> success : 分类完成,回填 15 维属性
+ classifying --> failed : 分类失败(保留抠图结果)
+ matting --> failed : 抠图失败
+ queued --> failed : 保存/上传 COS 异常
+ success --> [*] : 可被穿搭/AI 推荐选用
+ failed --> [*] : 用户可修正属性或删除(额度已自动回退)
+
+ state "1 抠图中" as matting
+ state "2 分类中" as classifying
+ state "3 排队中" as queued
+ state "4 成功" as success
+ state "5 失败(写 fail_reason)" as failed
+```
+
+**关键规则**
+
+- 上传接口**同步返回**(不等待 AI),返回即 `3 排队中`。
+- 异步流水线并发上限 **10**,单任务超时 **3 分钟**;抠图/分类各带重试(默认 3 次,间隔 1 秒)。
+- 失败时 `fail_reason` 取值:`图片抠图失败` / `图片保存失败` / `图片上传失败` / `图片分类失败` / `内部错误`。
+- **失败自动回退上传额度**,三重防重防护:状态已是 5 不再退、衣物已删除不再退、删除接口用行锁 + 状态判断。
+- 只有 `4 成功` 的衣物才会被 AI 推荐采用。
+
+---
+
+### 6.3 穿搭状态机(公开 / 审核 / 保存)
+
+```mermaid
+stateDiagram-v2
+ [*] --> created : 创建穿搭(1~10 件本人衣物)
+ created --> private : 默认 is_public=1, audit_status=1, status=1
+ private --> public : 公开(审核自动通过 + 扣搭配分享额度 1 点)
+ public --> private : 取消公开(不返还额度,再次公开会再扣)
+ public --> interacted : 他人点赞(触发通知)/ 收藏(不通知)
+ private --> [*] : 修改(明细先删后建)/ 删除(软删 + 明细物理删)
+ public --> [*] : 修改 / 删除(均不返还额度)
+
+ state "1 私有(待审核)" as private
+ state "2 公开(审核通过)" as public
+ state "社区互动" as interacted
+```
+
+**AI 推荐穿搭的额外状态**:`status=3` 待用户保存 → 调 `/ai-recommend/save` → `status=1` 正常。
+
+**可见性判定**:他人可见需同时满足 `is_public=2` 且 `audit_status=2` 且 `status=1`,否则 `40004`。
+
+---
+
+### 6.4 衣柜分享状态机
+
+```mermaid
+stateDiagram-v2
+ [*] --> normal : 创建分享(扣衣柜分享额度 1 点 + 自动审核通过)
+ normal --> cancelled : 用户取消(仅属主,不返还额度,不可逆)
+ normal --> expired : 惰性过期(访问详情时判定 now > ExpireTime)
+ normal --> banned : 封禁(预留状态,对访问者隐藏为"不存在")
+
+ state "1 正常" as normal
+ state "2 已取消" as cancelled
+ state "3 已过期" as expired
+ state "4 已封禁" as banned
+```
+
+**关键规则**
+
+- `expire_time = 0` 表示永不过期。
+- **惰性过期**:到时间但无人访问时列表 `status` 仍为 `1`,前端必须同时判断 `status` 与 `expire_time`。
+- 访客访问 +1 访问计数,属主访问不计数。
+- 删除衣物**不会**清理分享明细,被软删的衣物在分享详情中自动过滤。
+
+---
+
+### 6.5 订单状态机
+
+```mermaid
+stateDiagram-v2
+ [*] --> pending : 创建订单(快照权益 + 微信 JSAPI 下单,15 分钟有效)
+ pending --> paid : 支付回调 SUCCESS(验签 + 金额校验 → 发放权益 → 通知)
+ pending --> expired : 超时 15 分钟(后台每分钟扫描)
+ pending --> cancelled : 用户主动取消(同步关闭微信订单)
+ expired --> paid : 过期后付款到账(补发货)
+ cancelled --> refunded : 已取消但已付款 → 生成待审核退款单,管理员手动退款
+ paid --> refunded : 退款成功回调(回收权益 → 通知)
+
+ state "1 待支付" as pending
+ state "2 已支付" as paid
+ state "3 已取消" as cancelled
+ state "4 已过期" as expired
+ state "5 已退款" as refunded
+```
+
+**关键规则**
+
+- **金额校验**:回调金额与订单 `pay_amount` 不一致 → 拒绝流转、不发货、转人工。
+- **权益发放**:会员天数按快照实际发放,会员类型按**续费后最终到期时长**推导(剩余 ≥365 天为年度,否则月度,避免年度会员续月包被降级);上传 / AI / 衣柜分享 / 搭配分享额度改为写入**额度凭证** `quota_grants`(会员包 `expire_time=0` 跟随 VIP、加量包按 `goods.valid_days` 独立到期)。
+- **幂等 + 补偿**:`benefit_grant_tasks` 唯一索引 + "发放中"状态防并发;失败自动落任务,**每分钟重试**,重试 3 次仍失败则标记失败并告警人工介入。
+- **退款回收**:会员天数按快照回退(不回退到过去);赠送额度通过**失效凭证**回收未用部分,**已消耗部分不追回**。
+
+---
+
+### 6.6 额度规则全景
+
+**扣减 / 回退 / 发放 / 回收全部走事务 + 幂等流水(唯一索引 `(order_no, quota_type, change_type)` 兜底),前端无需做补偿。**
+
+| 场景 | 额度处理 | 前端提示 |
+| --- | --- | --- |
+| 上传衣物(建记录成功) | **扣 1 个上传额度** | — |
+| 上传衣物但建记录失败 | **自动回退** | 重试即可,不会丢额度 |
+| 衣物抠图/分类/保存失败 | **自动回退**(状态非 5 且未删除) | 提示识别失败,可重传 |
+| 删除"处理中/成功"的衣物 | **回退 1 个上传额度** | 可提示"已返还 1 次上传额度" |
+| 删除"处理失败"的衣物 | **不回退**(失败时已退过) | 不要提示返还 |
+| 穿搭**首次**公开 | **扣 1 个搭配分享额度** | 公开前展示额度消耗确认 |
+| 穿搭重复公开 | **不重复扣**(幂等) | — |
+| **取消公开后再次公开** | **再扣 1 个额度** | ⚠️ 重点提示用户 |
+| 穿搭取消公开 | **不返还** | — |
+| 穿搭删除 | **不返还** | — |
+| AI 推荐生成(成功) | **扣 1 个 AI 额度** | 生成前展示剩余次数 |
+| AI 推荐失败(衣橱为空/模型失败/无有效单品/落库失败) | **自动回退** | 可安全重试 |
+| **AI 推荐成功但未保存** | **不返还** | ⚠️ 重点提示用户及时保存 |
+| 创建衣柜分享 | **扣 1 个衣柜分享额度** | — |
+| 创建衣柜分享但明细写入失败 | **自动回退** | 重试即可 |
+| 取消衣柜分享 | **不返还** | 二次确认 |
+| 购买商品支付成功 | **发放**(按订单快照) | 展示"已到账" |
+| 退款成功 | **回收**(`total >= used`,已用部分不退) | 提示额度已回收 |
+
+**额度不足时**:前端跳商品列表引导购买。注意 AI 额度不足返回的是 `90401`(不是 `80000`)。
+
+---
+
+### 6.7 通知触发规则
+
+| 触发动作 | msg_type | 发送者 | 接收者 | 触发条件与防重 |
+| --- | --- | --- | --- | --- |
+| 点赞 | `1` | 点赞者 | 穿搭作者 | 仅"从取消/无记录 变为 已点赞"且**非本人**时触发;重复点赞不重复通知 |
+| 支付成功 | `2` | 系统(sender=0) | 买家 | 权益发放成功后触发;取消/过期不发 |
+| 退款成功 | `2` | 系统(sender=0) | 买家 | 权益回收后触发 |
+| 系统通知 | `3` | 预留 | — | 常量已定义,当前代码无发送点 |
+
+- 通知写入 MySQL + Redis 未读计数自增,best-effort(失败仅告警,不影响主业务)。
+- **无推送能力,前端需轮询** `GET /notification/unread`。
+
+---
+
+## 7. 核心调用时序图
+
+### 7.1 登录与 Token 刷新
+
+```mermaid
+sequenceDiagram
+ participant F as 小程序前端
+ participant B as 后端服务
+ participant W as 微信服务端
+
+ F->>B: POST /user/wxlogin {code}
+ B->>W: code2session(code)
+ W-->>B: openid / session_key
+ B->>B: 首次登录:注册用户 + 初始化 4 类免费额度
+ B-->>F: {access_token, refresh_token, expire_time, user_info}
+
+ Note over F,B: 后续请求携带 Authorization: Bearer
+
+ F->>B: 任意鉴权接口
+ B-->>F: code 11002 / HTTP 401(Token 已过期)
+
+ F->>B: POST /user/refresh-token {refresh_token, access_token}
+ B-->>F: 全新 {access_token, refresh_token, expire_time}
+ Note over F: 必须整体替换本地 token(旧的已立即失效)
+ F->>B: 携带新 token 重试原请求
+```
+
+### 7.2 衣物上传与 AI 处理轮询
+
+```mermaid
+sequenceDiagram
+ participant F as 小程序前端
+ participant B as 后端服务
+ participant M as 抠图服务(8001)
+ participant C as 分类服务(8000)
+
+ F->>B: POST /clothes/upload (multipart, 字段名 file)
+ B->>B: 校验扩展名/大小/magic bytes + 扣 1 个上传额度
+ B-->>F: {clothes_uuid, image_process_status: 3}
+
+ Note over B,C: 异步:3 排队 → 1 抠图 → 2 分类 → 4 成功 / 5 失败
+ B->>M: 抠图请求(失败重试 ≤3 次,间隔 1s)
+ B->>C: 15 维分类请求(失败重试 ≤3 次)
+
+ loop 前端轮询(建议 1~2 秒)
+ F->>B: GET /clothes/:uuid/status
+ B-->>F: {image_process_status, fail_reason}
+ end
+
+ alt 状态 = 4 成功
+ F->>B: GET /clothes/:uuid 读取识别结果
+ opt 识别不准
+ F->>B: PUT /clothes/:uuid {color: "...", ...}
+ end
+ else 状态 = 5 失败
+ Note over F: 展示 fail_reason,可重新上传(额度已自动回退)
+ end
+```
+
+### 7.3 AI 穿搭推荐与保存
+
+```mermaid
+sequenceDiagram
+ participant F as 小程序前端
+ participant B as 后端服务
+ participant L as 大模型网关(Tokenhub)
+
+ F->>B: POST /ai-recommend {scene_type, scene?}
+ B->>B: Redis 锁 180s(防并发,失败返回 10003)
+ B->>B: 扣 1 个 AI 额度(不足 → 90401)
+ B->>L: 结合星座运势 + 衣橱(状态 4)衣物生成
+ L-->>B: 推荐结果
+ B->>B: 校验结果(过滤模型幻觉衣物 ID)
+ B->>B: 落库 source=2, status=3 待用户保存
+ B-->>F: {outfit_uuid, outfit_name, outfit_item, ...}
+
+ alt 生成失败
+ B->>B: 回退 AI 额度 + 写失败流水
+ B-->>F: code 90400
+ end
+
+ F->>F: 用户预览、确认
+ F->>B: POST /ai-recommend/save {outfit_uuid}
+ B-->>F: 保存成功(status 3 → 1,不扣额度)
+
+ opt 公开到社区
+ F->>B: PUT /outfit/:uuid/public {is_public: 2}
+ B->>B: 自动审核通过 + 扣 1 个搭配分享额度
+ end
+```
+
+### 7.4 下单到支付完成
+
+```mermaid
+sequenceDiagram
+ participant F as 小程序前端
+ participant B as 后端服务
+ participant W as 微信支付
+
+ F->>B: GET /goods 选品
+ F->>B: POST /order {goods_code}
+ B->>B: 按售价计算金额 + 快照权益,创建订单(15 分钟有效)
+ B->>W: 微信 JSAPI 下单
+ B-->>F: {order_no, pay_amount, expire_time, pay_params}
+
+ F->>W: wx.requestPayment(pay_params)
+ W-->>B: POST /order/pay/notify(验签 + 解密)
+ B->>B: 金额校验 → 订单 1→2 → 发放权益 → 发站内通知
+
+ loop 前端轮询(1 秒起)
+ F->>B: GET /order
+ B-->>F: order_status
+ end
+ Note over F: 以服务端 order_status=2 为准,
不要只信支付组件回调
+
+ opt 超时未支付
+ B->>B: 后台任务每分钟扫描,超 15 分钟置为 4 已过期
+ end
+```
+
+### 7.5 点赞 / 收藏切换
+
+```mermaid
+sequenceDiagram
+ participant F as 小程序前端
+ participant B as 后端服务
+
+ F->>B: GET /like/:targetUUID 获取初始状态
+ B-->>F: {is_liked, like_count}
+
+ F->>B: POST /like {target_uuid, action: 1}
+ B->>B: 状态 无/已取消 → 已点赞,且非本人 → 向作者发通知
+ B-->>F: 成功(幂等)
+
+ F->>B: POST /like {target_uuid, action: 2}
+ B-->>F: 成功(幂等,记录保留可再次点赞)
+
+ Note over F,B: 收藏同理:POST /collect {target_uuid, action},但收藏不触发通知
+```
+
+---
+
+## 8. 错误码全表
+
+> **前端统一以响应体 `code` 为准**(HTTP 状态码仅供参考)。全量 83 个错误码如下。
+
+### 8.1 通用 / 鉴权 / 权限 / 系统(16)
+
+| code | 含义 | HTTP | 前端建议 |
+| --- | --- | --- | --- |
+| 0 | 成功 | 200 | — |
+| 10000 | 请求参数错误 | 200 | 展示 `details[0]` |
+| 10001 | 资源不存在 | 200 | 通用兜底 |
+| 10002 | 请求方法不允许 | 200 | 检查 Method |
+| 10003 | 请求过于频繁 | **429** | 稍后重试(AI 推荐 180s 并发锁等) |
+| 11000 | 未授权 | **401** | 跳登录 |
+| 11001 | Token 鉴权失败 | **401** | 尝试刷新,失败则重新登录 |
+| 11002 | Token 已过期 | **401** | 刷新 Token 后重试 |
+| 11003 | Token 生成失败 | 200 | 重新登录 |
+| 11004 | Token 缺失 | **401** | 跳登录 |
+| 12000 | 无权限访问 | **403** | 提示无权操作 |
+| 19000 | 服务内部错误 | **500** | "服务繁忙,请稍后重试" |
+| 19001 | 数据库错误 | **500** | 同上 |
+| 19002 | 缓存错误 | **500** | 同上 |
+| 19003 | 下游服务调用失败 | **500** | 同上(AI 服务不可用) |
+| 19999 | 未知错误 | **500** | 同上 |
+
+### 8.2 用户(13)
+
+| code | 含义 | HTTP |
+| --- | --- | --- |
+| 20000 | 用户参数错误 | 200 |
+| 21000 | 用户名或密码错误 | 200 |
+| **21001** | **账号已被禁用** | **200** ⚠️ 非 401,需单独处理 |
+| 21002 | 微信登录失败 | 200 |
+| 21003 | 微信 Code 无效 | 200 |
+| 21004 | 注册暂未开放 | 200 |
+| 22000 | 用户不存在 | 200 |
+| 23000 | 用户已存在 | 200 |
+| 23001 | 邮箱已被注册 | 200 |
+| 29000 | 用户创建失败 | 200 |
+| 29001 | 用户更新失败 | 200 |
+| 29002 | 用户删除失败 | 200 |
+| 29003 | 用户查询失败 | 200 |
+
+### 8.3 上传(10)
+
+| code | 含义 | HTTP |
+| --- | --- | --- |
+| 30000 | 上传文件为空 | 200 |
+| 30001 | 不支持的文件类型 | 200 |
+| 30002 | 文件大小超过限制 | 200 |
+| 30004 | 文件不存在 | 200 |
+| 30005 | 文件不是公开的 | 200 |
+| 30006 | 权限不足 | 200 |
+| 30007 | 文件处理中 | 200 |
+| 39000 | 文件保存失败 | 200 |
+| 39001 | 图片抠图处理失败 | **500** |
+| 39002 | 图片分类处理失败 | **500** |
+
+### 8.4 穿搭(5)
+
+| code | 含义 | HTTP |
+| --- | --- | --- |
+| 40000 | 穿搭不存在 | 200 |
+| 40001 | 创建穿搭失败 | 200 |
+| 40002 | 删除穿搭失败 | 200 |
+| 40003 | 更新穿搭失败 | 200 |
+| 40004 | 无权操作该穿搭 | 200 |
+
+### 8.5 衣物(6)
+
+| code | 含义 | HTTP |
+| --- | --- | --- |
+| 50000 | 衣物不存在 | 200 |
+| 50001 | 衣服已被搭配使用,无法删除 | 200 |
+| 50002 | 创建衣物失败 | 200 |
+| 50003 | 删除衣物失败 | 200 |
+| 50004 | 更新衣物失败 | 200 |
+| 50005 | 无权操作该衣物 | 200 |
+
+### 8.6 商品(5)
+
+| code | 含义 | HTTP |
+| --- | --- | --- |
+| 60000 | 商品不存在 | 200 |
+| 60001 | 商品已下架 | 200 |
+| 69000 | 创建商品失败 | 200 |
+| 69001 | 更新商品失败 | 200 |
+| 69002 | 删除商品失败 | 200 |
+
+### 8.7 订单 / 退款(12)
+
+| code | 含义 | HTTP |
+| --- | --- | --- |
+| 70000 | 订单不存在 | 200 |
+| 70001 | 订单状态不正确 | 200 |
+| 70002 | 订单已支付 | 200 |
+| 70003 | 订单已取消 | 200 |
+| 70004 | 订单已过期 | 200 |
+| 70005 | 虚拟支付未开通 | 200 |
+| 70006 | 商品尚未发布为虚拟支付道具 | 200 |
+| 70007 | 虚拟支付订单不支持取消 | 200 |
+| 70008 | iOS 虚拟支付最低 1 元 | 200 |
+| 79000 | 创建订单失败 | 200 |
+| 79001 | 取消订单失败 | 200 |
+| 79002 | 更新订单失败 | 200 |
+| 79003 | 创建虚拟支付订单失败 | 200 |
+| 71000 | 退款记录不存在 | 200 |
+| 71001 | 当前状态不允许退款 | 200 |
+| 71002 | 退款金额不合法 | 200 |
+| 71003 | 退款申请失败 | 200 |
+| 71004 | iOS 订单请通过 App Store 申请退款 | 200 |
+
+### 8.8 额度 / VIP(9)
+
+| code | 含义 | HTTP |
+| --- | --- | --- |
+| **80000** | **额度不足** | 200 |
+| 80001 | 额度记录不存在 | 200 |
+| 80002 | 广告激励已领取 | 200 |
+| 80003 | 无效的激励类型 | 200 |
+| 80004 | 广告激励未开启 | 200 |
+| 89000 | 额度更新失败 | 200 |
+| 81000 | 会员记录不存在 | 200 |
+| 81001 | 会员已过期 | 200 |
+| 81002 | 会员开通失败 | 200 |
+
+### 8.9 通知 / 分享 / 社区 / 星座 / AI(10)
+
+| code | 含义 | HTTP |
+| --- | --- | --- |
+| 90000 | 通知不存在 | 200 |
+| 90100 | 分享不存在 | 200 |
+| 90101 | 分享已过期 | 200 |
+| 90102 | 分享已取消 | 200 |
+| 90200 | 目标内容不存在 | 200 |
+| 90201 | 点赞操作失败 | 200 |
+| 90202 | 收藏操作失败 | 200 |
+| 90300 | 星座运势不存在(生成中,稍后重试) | 200 |
+| 90400 | AI 推荐失败 | 200 |
+| **90401** | **AI 推荐次数已达上限**(AI 额度不足) | 200 |
+
+---
+
+## 9. 前端联调 FAQ 与检查清单
+
+### 9.1 高频问题
+
+**Q:接口返回 HTTP 200,但业务是失败的?**
+A:正常。除 401/403/429/500 外,所有业务错误都返回 HTTP 200。**必须解析 `code`**。
+
+**Q:`21001 账号已被禁用` 为什么不是 401?**
+A:它在鉴权中间件最后一步(查用户状态)返回,HTTP 是 200。拦截器里需单独判断 `code === 21001` 并跳封禁页。
+
+**Q:刷新 Token 一直报 `10000 缺少旧 access token`?**
+A:`/user/refresh-token` **必须同时传 `refresh_token` 和 `access_token`**,只传前者会失败。
+
+**Q:上传成功但衣物没有属性?**
+A:上传是异步的。用返回的 `clothes_uuid` 轮询 `GET /clothes/:uuid/status`,直到 `4`(成功)再拉详情。
+
+**Q:更新衣物颜色没生效?**
+A:主颜色的**请求字段名是 `color`**(响应才是 `color_main`)。写成 `color_main` 会被忽略(因为只更新非空字段,且不报错)。
+
+**Q:AI 推荐报 `10003 请求过于频繁`?**
+A:同一用户 180 秒内只能有一个生成任务(Redis 锁)。前端应禁用按钮并提示"生成中",生成完成后再放开。
+
+**Q:AI 推荐扣了额度但用户没保存,能退吗?**
+A:不能。额度在**生成时**已消耗,只有生成失败才回退。前端应在生成前明确提示消耗。
+
+**Q:支付组件回调 success,但订单还是待支付?**
+A:以服务端为准。微信回调是异步的,**轮询 `GET /order` 直到 `order_status=2`**。
+
+**Q:退款申请后一直没到账?**
+A:退款是**人工审核模式**,接口只登记申请(`status=0`)。前端提示"退款审核中",并允许轮询订单状态到 `5`。
+
+**Q:衣柜分享列表里显示"正常",但对方打不开?**
+A:过期是**惰性**的。前端需同时判断 `status` 与 `expire_time`。
+
+**Q:`GET /outfit` 里混进了 AI 推荐的穿搭?**
+A:列表**不过滤 `status=3`(待用户保存)**。前端用 `source` 区分来源,AI 未保存的单独入口展示。
+
+**Q:没有"社区广场"列表接口怎么办?**
+A:当前确实没有。公开穿搭只能通过 UUID 直接访问详情,或在"我的收藏"中出现。如需广场列表,需后端新增接口。
+
+### 9.2 联调检查清单
+
+- [ ] 统一封装请求:自动带 `Authorization: Bearer`,统一解析 `code`
+- [ ] 实现 401 自动刷新 Token(同时传两个 token),失败则清登录态
+- [ ] 单独处理 `code === 21001`(账号禁用,HTTP 200)
+- [ ] 金额展示 `÷100`,时间展示 `×1000`
+- [ ] 上传后轮询状态(1~2 秒,最多 60 次),展示处理中/失败态
+- [ ] 支付后轮询订单状态,不依赖支付组件回调
+- [ ] 退款提示"人工审核中"
+- [ ] 公开穿搭前提示将消耗 1 次搭配分享额度
+- [ ] AI 推荐前提示将消耗 1 次 AI 额度,且**不保存不退**
+- [ ] 点赞/收藏按钮做乐观更新 + 失败回滚(接口幂等,可重试)
+- [ ] 通知红点通过轮询 `GET /notification/unread` 刷新(无推送)
+- [ ] 不可逆操作(删除衣物/穿搭、取消订单/分享)加二次确认
+- [ ] 列表分页 `page_size` 不超过 50
+- [ ] 排障时请求头自带 `X-Request-ID`(跨域读不到响应头)
+
+---
+
+## 10. 接口调用示例(curl)
+
+> 示例中的 token、UUID、订单号均为示意值;`BASE` = `http:///api/v1`。
+
+### 微信登录
+
+```bash
+curl -X POST $BASE/user/wxlogin \
+ -H "Content-Type: application/json" \
+ -d '{"code": "0b3abcxxxxxxxx"}'
+```
+
+```json
+{
+ "code": 0,
+ "msg": "成功",
+ "data": {
+ "access_token": "eyJhbGciOiJIUzI1NiIs...",
+ "refresh_token": "eyJhbGciOiJIUzI1NiIs...",
+ "expire_time": 1756742400,
+ "user_info": {
+ "username": "微信用户",
+ "avatar": "",
+ "sex": 0,
+ "birth_day": 0,
+ "phone": "",
+ "is_new_user": true,
+ "status": 1,
+ "ban_reason": "",
+ "ban_start": 0,
+ "ban_expire": 0,
+ "vip_type": 4,
+ "vip_expire": 1744761600
+ }
+ }
+}
+```
+
+### 刷新 Token
+
+```bash
+curl -X POST $BASE/user/refresh-token \
+ -H "Content-Type: application/json" \
+ -d '{"refresh_token":"","access_token":"<旧 access_token>"}'
+```
+
+### 上传衣物(multipart)
+
+```bash
+curl -X POST $BASE/clothes/upload \
+ -H "Authorization: Bearer " \
+ -F "file=@/path/to/t-shirt.png"
+```
+
+```json
+{
+ "code": 0,
+ "msg": "成功",
+ "data": {
+ "clothes_uuid": "c8f2e1a0-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
+ "image_process_status": 3
+ }
+}
+```
+
+### 轮询处理状态
+
+```bash
+curl "$BASE/clothes/c8f2e1a0-xxxx-xxxx-xxxx-xxxxxxxxxxxx/status" \
+ -H "Authorization: Bearer "
+```
+
+```json
+{ "code": 0, "msg": "成功", "data": { "image_process_status": 4, "fail_reason": "" } }
+```
+
+### 修正衣物属性(注意 `color`)
+
+```bash
+curl -X PUT $BASE/clothes/c8f2e1a0-xxxx-xxxx-xxxx-xxxxxxxxxxxx \
+ -H "Authorization: Bearer " \
+ -H "Content-Type: application/json" \
+ -d '{"color":"白色","category_main":"上装","name":"我的白T"}'
+```
+
+### 额度概览
+
+```bash
+curl $BASE/quota/info -H "Authorization: Bearer "
+```
+
+```json
+{
+ "code": 0,
+ "msg": "成功",
+ "data": {
+ "quotas": [
+ { "quota_type": 1, "total": 20, "used": 1, "remain": 19 },
+ { "quota_type": 2, "total": 3, "used": 0, "remain": 3 },
+ { "quota_type": 3, "total": 10, "used": 0, "remain": 10 },
+ { "quota_type": 4, "total": 5, "used": 0, "remain": 5 }
+ ]
+ }
+}
+```
+
+### AI 穿搭推荐与保存
+
+```bash
+curl -X POST $BASE/ai-recommend \
+ -H "Authorization: Bearer " \
+ -H "Content-Type: application/json" \
+ -d '{"scene_type": 2, "scene": "参加朋友的婚礼"}'
+```
+
+```json
+{
+ "code": 0,
+ "msg": "成功",
+ "data": {
+ "outfit_uuid": "of-xxxxxxxxxxxx",
+ "outfit_name": "婚礼宾客穿搭",
+ "outfit_desc": "简约大方,正式又不失亲和力",
+ "scene": "参加朋友的婚礼",
+ "source": 2,
+ "status": 3,
+ "outfit_item": [{ "clothes_uuid": "c8f2e1a0-xxxx-xxxx-xxxx-xxxxxxxxxxxx" }]
+ }
+}
+```
+
+```bash
+curl -X POST $BASE/ai-recommend/save \
+ -H "Authorization: Bearer " \
+ -H "Content-Type: application/json" \
+ -d '{"outfit_uuid":"of-xxxxxxxxxxxx"}'
+```
+
+### 创建订单(微信支付)
+
+```bash
+curl -X POST $BASE/order \
+ -H "Authorization: Bearer " \
+ -H "Content-Type: application/json" \
+ -d '{"goods_code": "AI_3"}'
+```
+
+```json
+{
+ "code": 0,
+ "msg": "成功",
+ "data": {
+ "order_no": "OD20260907000123",
+ "pay_amount": 990,
+ "expire_time": 1756743300,
+ "pay_params": {
+ "timeStamp": "1756742400",
+ "nonceStr": "1a2b3c4d5e6f",
+ "package": "prepay_id=wx09153000123456",
+ "signType": "RSA",
+ "paySign": "xxxxxxxxxxxxxxxx"
+ }
+ }
+}
+```
+
+小程序侧:
+
+```javascript
+wx.requestPayment({
+ ...pay_params, // timeStamp / nonceStr / package / signType / paySign 原样透传
+ success: () => { /* 仅表示拉起成功,仍需轮询 GET /order 确认 order_status=2 */ },
+ fail: () => { /* 用户取消或失败 */ }
+})
+```
+
+### 点赞 / 取消点赞
+
+```bash
+curl -X POST $BASE/like \
+ -H "Authorization: Bearer " \
+ -H "Content-Type: application/json" \
+ -d '{"target_uuid":"of-xxxxxxxxxxxx","action":1}'
+
+curl "$BASE/like/of-xxxxxxxxxxxx" -H "Authorization: Bearer "
+# {"code":0,"msg":"成功","data":{"is_liked":true,"like_count":12}}
+```
+
+### 创建衣柜分享
+
+```bash
+curl -X POST $BASE/closet-share \
+ -H "Authorization: Bearer " \
+ -H "Content-Type: application/json" \
+ -d '{"share_type":1,"clothes_uuids":["c8f2e1a0-xxxx"],"expire_time":1756828800}'
+```
+
+### 错误响应示例(额度不足)
+
+```json
+{ "code": 80000, "msg": "额度不足" }
+```
+
+### 错误响应示例(参数校验)
+
+```json
+{
+ "code": 10000,
+ "msg": "请求参数错误",
+ "details": ["Key: 'OutfitCreateRequest.OutfitName' Error:Field validation for 'OutfitName' failed on the 'required' tag"]
+}
+```
+
+---
+
+> **文档维护说明**:本文档由后端源码(`internal/routers/router.go`、`internal/dto/`、`internal/service/`、`pkg/errcode/`)逐字段核对生成。接口变更时请同步更新本文件;工程视角的部署/配置说明见 `README.md`。
+