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`。 +