补齐相关日志日志

This commit is contained in:
2026-09-16 00:23:49 +08:00
parent 8c2ea84939
commit b2e5eef3c8
39 changed files with 1576 additions and 176 deletions
@@ -0,0 +1,104 @@
---
name: 日志打点全量评估报告
overview: 全量审计 zt-closet-backend 项目中所有 slog 日志打点,从覆盖度(关键路径遗漏)、级别合理性、字段完整性、敏感信息、规范一致性等维度评估,在 docs/ 下产出一份日志专项评估报告(不改代码)。
todos:
- id: inventory-logging
content: 用 [subagent:code-explorer] 全量盘点所有 slog 打点明细及日志基础设施实现
status: completed
- id: audit-coverage
content: 用 [subagent:code-explorer] 对照业务主线、后台任务与资损路径审计日志覆盖遗漏点
status: completed
dependencies:
- inventory-logging
- id: audit-quality
content: 审计日志级别、字段完整性、敏感信息与规范一致性问题并分级
status: completed
dependencies:
- inventory-logging
- id: write-report
content: 撰写 docs/日志专项评估报告-2026-09-15.md(问题清单+覆盖矩阵+结论)
status: completed
dependencies:
- audit-coverage
- audit-quality
- id: verify-report
content: 抽查报告中文件行号引用与结论准确性,修正偏差并定稿
status: completed
dependencies:
- write-report
---
## User Requirements
对 zt-closet-backend 后端项目**代码中的日志打点**进行一次全量分析评估,核心问题:「是否存在遗漏与不足」。
- **遗漏评估**:关键业务路径(登录、上传、支付、退款、额度、AI 推荐、分享、通知等)、错误分支、后台任务失败分支、中间件异常分支是否存在日志缺失。
- **不足评估**:日志级别是否合理(Error/Warn/Info/Debug 误用)、关键字段是否完整、是否使用 Context 版本 API 以关联 trace_id/span_id、是否泄露敏感信息、字段命名与 msg 风格是否一致。
- **交付物**:在 docs/ 下新增一份日志专项评估报告,列出遗漏点与改进建议。
- **明确约束**:仅输出评估报告,**不修改任何代码**。
## Product Overview
产出一份中文 Markdown《日志专项评估报告》,面向开发/运维人员,作为后续日志优化工作的依据。报告需结论明确、可直接执行:每个问题带精确定位(文件:行号)、问题描述、影响说明与改进建议,并按严重度分级排序,便于后续按优先级逐项处理。
## Core Features
1. **日志基础设施盘点**:输出通道(文件+stdout+OTel 桥接)、滚动策略、级别配置映射、query 脱敏、trace_id/span_id 注入机制现状。
2. **全量打点清单与覆盖度对照矩阵**:覆盖全部 slog 调用点;对照 7 条业务主线、6 个后台任务、资损敏感路径(支付/退款回调、权益发放回收、额度扣减回退、鉴权降级)逐项标注有/无日志。
3. **多维度质量审计**:级别合理性、字段完整性(实体 ID)、敏感信息风险(openid/手机号/token/回调报文)、规范一致性(msg 风格、user_id/userID 命名、ctx 版本使用率)。
4. **结构化问题清单**:编号 + 严重度 + 位置 + 问题 + 影响 + 改进建议,风格对齐 docs/ 既有审查报告。
5. **总体结论**:遗漏与不足的总体判断及改进优先级建议。
## 审计方法(Implementation Approach)
采用**静态代码审计**方式,不运行服务、不依赖运行环境:
1. **盘点阶段**:全量收集所有 slog 调用点(文件、行号、级别、msg、字段、是否 `XxxContext` 版本),同步梳理日志基础设施实现:`main.go`(setupSlogLogger)、`pkg/tracing/fanout.go`、`pkg/tracing/otel.go`、`internal/middleware/logger.go`、配置项 `Log.LogLevel`。
2. **覆盖度审计**:以 README 的 7 条业务主线、6 个后台任务、历史审查报告标注的资损敏感路径为对照基准,逐个关键函数(handler → service → 错误/失败分支)标注日志有无,形成覆盖矩阵并识别遗漏。
3. **质量审计**:对每条打点按 6 个维度逐条检查:级别合理性、字段完整性、敏感信息、trace 关联、命名一致性、基础设施配置。
4. **撰写报告**:对齐 `docs/` 既有审查报告风格(中文、编号+严重度+位置+问题+影响+建议、修复状态可标注)。
5. **校验阶段**:抽查报告中 `文件:行号` 引用与结论准确性,修正偏差后定稿。
## 已知事实基准(用于对照)
- 打点总量约 200 处(非测试约 190+),分布:service 层约 110+、tokenhub/scheduler.go 16、main.go 13、middleware 3 处。
- **0 打点区域**:`internal/routers/`(15 文件)、`internal/mysqldao/`(19 文件)、`internal/redisdao/`(12 文件)、`pkg/` 除 tokenhub/tracing 外全部包;`internal/service/` 中 adreward.go、goods.go、horoscope.go 无打点。
- 基础设施:JSON 日志(lumberjack 滚动压缩 + stdout)+ otelslog 桥接;`OtelContextHandler` 仅在 `slog.XxxContext(ctx)` 调用时才注入 trace_id/span_id;`GinSlogMiddleware` 对 query 中 token/code/secret/password 等脱敏,但请求体不记录。
- 其他输出:main.go:320、pkg/tracing/provider.go:114 用 fmt.Printf(logger 未就绪场景);cmd/migrate 用 fmt.Println(CLI,不纳入问题清单)。
## 评估维度框架(6 项)
1. 覆盖度/遗漏;2. 级别合理性;3. 字段完整性;4. 敏感信息;5. 规范一致性;6. 基础设施配置(如 LogLevel=Info 时 Debug 打点在生产的有效性)。
## 报告结构设计
1. 审计范围与方法说明(含局限:未连接运行环境、以当前工作区代码为准);
2. 日志基础设施现状;
3. 问题清单(按 高/中/低 分级,每条含位置+问题+影响+建议);
4. 关键路径日志覆盖度对照矩阵;
5. 总体结论与改进优先级建议。
## Implementation Notes(执行要点)
- 所有问题必须带 `文件:行号` 引用,行号以**当前工作区**文件为准(工作区存在未提交改动,不得依据历史报告行号)。
- 严重度分级原则:资损/安全相关遗漏(如支付校验失败静默、权益回收无日志)为高;排查困难类(无实体 ID、缺 ctx 关联)为中;规范统一类(命名/风格)为低。
- 报告中描述敏感字段时仅引用字段名,不复制任何真实敏感值;不打印任何配置密钥内容。
- 结论表述为建议(如"建议补充……"),不包含代码修改动作;不再引入与日志无关的审查项(如业务逻辑缺陷),聚焦日志主题。
## Directory Structure
本次仅新增 1 个文件,不修改任何代码:
```
zt-closet-backend/
└── docs/
└── 日志专项评估报告-2026-09-15.md # [NEW] 日志专项评估报告。内容:审计范围与方法说明、日志基础设施现状盘点、按严重度分级的问题清单(编号+位置+问题+影响+改进建议,覆盖遗漏与不足两类)、关键业务路径日志覆盖度对照矩阵、总体结论与改进优先级建议。需对齐 docs/ 既有审查报告的中文风格与"文件:行号"引用规范,所有结论须经代码抽查验证。
```
## Agent Extensions
### SubAgent
- **code-explorer**
- Purpose: 承担跨目录的大规模代码探索:全量收集所有 slog 调用点清单(位置/级别/字段/上下文版本)与日志基础设施实现细节;并对照业务主线逐个关键路径函数核实日志覆盖情况。
- Expected outcome: 产出结构化的打点明细清单与关键路径覆盖情况数据,作为评估报告"问题清单"与"覆盖度对照矩阵"的可靠事实依据,确保报告中所有定位与结论准确可核验。
@@ -0,0 +1,173 @@
---
name: 日志问题全量修复
overview: 依据 docs/日志专项评估报告-2026-09-15.md,以"纯日志改进,零控制流变更"为边界,全量修复 H1-H10 / M1-M8 / L1-L5 共 23 项问题及覆盖矩阵点名的具体静默点:补齐缺失日志、修正级别与字段、修复 trace 断链、增加任务心跳与 Debug 能力、改进日志配置健壮性,并通过构建/测试与逐项 diff 复核保证行为不变。
todos:
- id: fix-trade-logging
content: 补齐交易与回调链路日志:H1-H7/H9 静默点、成功审计与去重,不改控制流
status: completed
- id: fix-auth-user-logging
content: 补齐鉴权与用户链路日志:H8 拒绝分支、登录注册与账号操作静默点
status: completed
- id: fix-clothes-community-logging
content: 补齐衣物与社区链路日志:上传、删除、公开、双写等静默点
status: completed
- id: fix-ai-horoscope-logging
content: 补齐 AI 推荐与运势链路日志:提交、落库、降级静默点
status: completed
- id: fix-quota-scheduler-logging
content: 补齐额度与调度日志:H10、trace 断链与心跳,用 [skill:lsp-code-analysis] 核查签名引用
status: completed
- id: fix-log-infra
content: 改造日志基础设施:访问日志分级、Recovery 结构化、LogLevel 容错与 fanout trace
status: completed
- id: verify-and-annotate
content: 用 [subagent:code-explorer] 复核修复完整性,构建测试通过后标注评估报告状态
status: completed
dependencies:
- fix-trade-logging
- fix-auth-user-logging
- fix-clothes-community-logging
- fix-ai-horoscope-logging
- fix-quota-scheduler-logging
- fix-log-infra
---
## Product Overview
依据《日志专项评估报告(2026-09-15)》对后端服务实施全量日志修复:覆盖报告列出的 23 项编号问题(高 10 项、中 8 项、低 5 项)及评估矩阵点名的全部日志静默点,涉及 20 余个源文件。修复完成后,关键业务失败场景均可在日志中定位、日志可关联同一次请求、级别与字段规范统一,并在评估报告中逐项标注修复状态。全部改动限定为纯日志改进:不改变任何业务行为。
## Core Features
- 交易与回调链路:补齐回调验证、幂等锁、支付信息落库、退款流转、过期补发货、下单失败等静默场景日志,并补成功侧审计日志
- 鉴权与用户链路:补齐各类鉴权拒绝分支、令牌刷新、登录注册、账号操作失败的日志
- 衣物与社区链路:补齐上传校验、删除回退、公开扣额、双写一致性等失败日志
- AI 推荐与运势链路:补齐任务提交、后台生成、落库、降级场景日志
- 额度与后台任务:补齐额度事务失败日志,六个定时任务增加每轮运行记录
- 日志基础设施:访问日志按状态分级、异常恢复输出纳入统一日志、配置容错、链路标识完整注入
- 评估报告逐项标注修复状态(已修复 + 日期)
## Tech Stack
- 语言与框架:Go 1.27 + Gin(沿用现状,不新增任何第三方依赖)
- 日志:标准库 log/slog(JSON 结构化)+ lumberjack 滚动文件(沿用既有输出通道与 OpenTelemetry 桥接)
- 验证工具:gofmt、go vet、go build、go test
## Implementation Approach
### 总体策略
按"文件集互不重叠"的 6 个修复批次顺序执行,加 1 个验证与标注批次(共 7 个任务):
| 批次 | 文件集 | 覆盖问题 |
| --- | --- | --- |
| T1 交易与回调 | order.go、virtualpay.go、virtualpay_compensate.go、benefit_compensation.go、order_expire.go、routers/api/v1/order.go | H1-H7、H9、M1/M2/M3/M4/M5、L3 |
| T2 鉴权与用户 | lzcwyjwt.go、user.go | H8、M1/M2/M3、L4、矩阵缺口 |
| T3 衣物与社区 | clothes.go、outfit.go、like.go、collect.go、closet.go、outfit_share.go、notification.go | H10(clothes 部分)、M2/M3/M4/M5、L4/L5、矩阵缺口 |
| T4 AI 与运势 | airecommend.go、horoscope.go、ai_outfit_cleanup.go | M2/M3/M4/M5、L5、矩阵缺口 |
| T5 额度与调度 | service.go、quota.go、quota_expire.go、tokenhub/scheduler.go、redismodel/keys.go | H10(service 部分)、M3/M4/M5、L4 |
| T6 基础设施 | middleware/logger.go、middleware/recovery.go(新增)、routers/router.go、main.go | M6、L1、L2、M3(fanout) |
| T7 验证与标注 | 全仓复核 + 评估报告状态标注 | 全部 |
### 关键设计决策
1. **唯一落点去重**:每个失败场景只在有完整业务上下文处记录一次。普通支付/退款回调的失败判定(验签/解密/非 SUCCESS/金额等)统一由 service 层记录,handler 只回 FAIL 应答不重复记录;XPay 入口层失败(读体/验签/解密/兼容回显/加密降级)service 不可见,由 handler 层记录;pkg/wechat 等客户端不加日志(错误上抛到 service 记录),避免与 H1 重复。
2. **静默值统一范式**:被 `_ =` 丢弃的返回值改为"接收并记录错误但不阻断后续流程":order.go:461-465、virtualpay.go:426-430、virtualpay.go:370(仅记录,仍 return nil)、outfit.go:277-281、closet.go:131-141 等。
3. **锁错误拆分**:`lerr != nil` 记 Error、`!ok`(正常竞争)记 Debug;分支行为不变(仍 return nil 当作并发处理)。
4. **级别规范**:业务性拒绝 Info;可恢复 / best-effort 失败 Warn;主流程与资损失败 Error;高频与细节路径 Debug(补齐全仓 Debug 能力,L5)。
5. **链路标识**:ctx 可得时一律使用 slog.XxxContext;service.go 事务函数(cosRemainTx、refundCosQuotaTx)透传 ctx;clothes.go:320 清理 context 改用 context.WithoutCancel(ctx) 派生以保留 span 值;user.go:541 异步复位派生独立 span;main.go 将 OtelContextHandler 移至 FanoutHandler 外层,使本地与 OTel 两条分支都注入 trace_id/span_id(本地输出等价、OTel 分支增强)。
6. **后台任务心跳**:每轮有产出记 Info、无产出 / 零值轮次记 Debug(高频任务保持低噪),字段含 scanned / affected / failed 等。
7. **访问日志与 Recovery**:访问日志按状态码分级(5xx 记 Error、4xx(除 401)记 Warn、401 与其余记 Info),user_agent 截断 256 字符;新增 Recovery 中间件:panic 信息与堆栈经 slog.ErrorContext 输出,HTTP 500 与 broken-pipe 处理保持 gin 默认语义。
8. **配置健壮性**:LogLevel 忽略大小写匹配,未匹配值在 logger 就绪后告警并回落 Info;lumberjack 增加 LocalTime: true;AddSource 保留现状(按级别裁剪需自定义 Handler,复杂度与收益不成比例,在报告中注明理由)。
### 性能与可靠性
- 新增日志均为单点 O(1) 调用,无循环内逐条日志;批量任务使用每轮汇总;高频路径(轮询、懒过期、零值轮次)使用 Debug 级,生产默认 Info 下零输出开销;
- 日志调用不进入任何事务 / 锁语义,不改变延迟与并发行为;
- 既有良好实践不回退:资损防线 Error 打点、字段命名(order_no / out_trade_no / _amount)、trace 关联率、敏感信息零泄漏(query 脱敏)。
## Implementation Notes
- 行号基准:以当前工作区文件为准(存在未提交改动),执行时逐文件核对后再修改,不得套用历史报告行号;
- 敏感值红线:不打印 openid 值、session_key、token、回调报文正文(回调报文仅入库);引用字段名不受限;
- 去重检查:同一失败场景唯一落点;同一错误禁止 wrapper 与内层重复记录(virtualpay.go:139/151 需合并为单条);
- msg/字段规范:msg 英文小写、不带标点;字段 snake_case;best-effort 日志不阻断主流程;
- 每批次完成后立即执行 gofmt 与 go build,避免批量错误累积;
- 报告标注:对齐既有 docs 风格(已修复 + 2026-09-15),逐项覆盖 23 项与矩阵缺口。
## Architecture Design
- 无架构变更:不新增分层与模式;唯一新增文件 internal/middleware/recovery.go 属既有 middleware 层;
- 改动分布:service 层 17 个文件(补齐为主);middleware 3 处(鉴权拒绝、访问日志、新增 Recovery);routers 2 处(handler 入口日志、Recovery 替换);main.go 3 处(日志组装、级别容错、LocalTime);pkg 1 处微调(scheduler);
- 数据流不变,仅观测面扩展。
## Directory Structure
```
zt-closet-backend/
├── internal/
│ ├── service/ # 修复主体(17 个文件)
│ │ ├── order.go # [MODIFY] H1(123-129/354-357 回调验签、解密、非 SUCCESS;146/167-170 DB 错误)、H3(134-136/361-363 锁错误拆分)、H4(461-465 记录不阻断)、H5(394-396 settle 失败记录)、H6(196-206 过期补发:成功 Info/DB Error/竞争 Debug)、H7(95-102 落库与 JSAPI 失败)、H9(723-734/786/789 回收静默)、M1(104-115/171-174/341-346/380-389 成功审计)、M2/L3(565/567 补 retry_count/amount/user_id)、M5(438 升 Warn、525 降 Info、741 升 Error)
│ │ ├── virtualpay.go # [MODIFY] H3(244-246/337-339)、H4(426-430)、H5(370 接收返回值记 Error、仍 return nil)、H7(26-63/92-106 业务拒绝 Info、异常 Error)、L3(380 拆分 msg、139/151 去重、345 补 refund_no 与金额)
│ │ ├── virtualpay_compensate.go # [MODIFY] H3(82-85)、M3(142 worker panic 用外层 ctx)、M4(155 每轮汇总补 queried/failed/unpaid)、M5(167 拆分 openid 空/查询失败并修正 error 字段 nil)
│ │ ├── benefit_compensation.go # [MODIFY] H3(43-46)、H9(129-134/61/73/144-146/157 补日志与恢复条数)、L3(154 拆分两种场景)
│ │ ├── order_expire.go # [MODIFY] M4(31-46 每轮汇总:有产出 Info、零值 Debug)
│ │ ├── user.go # [MODIFY] H8(343 吊销失败 Error)、M1(登录成功 Info)、M2/L4(117 补 user_id)、M3(541 独立 span)、矩阵缺口(44-49/70-79/103-105/107-109/111-114/129-131/149-155/222-226/237-294/307-346 各失败点)
│ │ ├── clothes.go # [MODIFY] H10(466-490 删除回退失败)、M2(159 补 user_id、275 retryCall 加业务标识)、M3(320 用 WithoutCancel 派生)、M4(597-620 每轮汇总)、L4(320 补 phase)、L5(143/189/197 状态推进 Debug)、矩阵缺口(55-82 上传校验、84-87 扣额、92-96 落盘、104-109 建记录、609-617 认领与损坏)
│ │ ├── outfit.go # [MODIFY] 矩阵缺口(69-83/136-156/217-224 事务失败 Error、245-248 锁竞争 Debug 与解锁 Warn、256-281 公开失败与扣额回退)、M2(289 补 outfit_uuid)
│ │ ├── like.go / collect.go # [MODIFY] 双写失败(like:60/73、collect:71/78)Warn、写路径 DB 失败 Error、降级 Debug
│ │ ├── closet.go # [MODIFY] 矩阵缺口(72-74/83-94/131-134/139-141/302-304)、M2(102 补 share_uuid)
│ │ ├── outfit_share.go # [MODIFY] 矩阵缺口(63-65/74-80/218-220)
│ │ ├── notification.go # [MODIFY] 矩阵缺口(95-97 Error、124-131 双失败)、M5(170 降 Warn)
│ │ ├── airecommend.go # [MODIFY] 矩阵缺口(68-103 提交三失败点、120-127、153-175、254-260 空衣橱 Info、294-306 落库失败、364-386 运势降级)、M2(270 补 task_id)、M3(417 传 ctx)、M5(213 降 Warn)、L5(轮询 Debug)
│ │ ├── horoscope.go # [MODIFY] 矩阵缺口(51-56/59-67/72-74)
│ │ ├── ai_outfit_cleanup.go # [MODIFY] M4(64-81 每轮汇总)
│ │ ├── service.go # [MODIFY] H10(350-355/103-109/141-144/155-157)、M3(375/434 事务函数透传 ctx)、L4(461 补 related_id/reason)
│ │ └── quota.go / quota_expire.go # [MODIFY] 矩阵缺口(quota:37-40/59-62);M4(quota_expire:80 汇总)
│ ├── middleware/
│ │ ├── lzcwyjwt.go # [MODIFY] H8(30-34/36-41/44-56/59-65 各分支 Warn、71-75 UID 不匹配 Error、84-89/95-99 Warn、91 回填失败 Debug);52 行响应文案不动
│ │ ├── logger.go # [MODIFY] M6(44-53 按状态分级、user_agent 截断)
│ │ └── recovery.go # [NEW] M6:Recovery() 中间件,panic 与堆栈经 slog 输出,500 与 broken-pipe 语义保持 gin 默认
│ ├── routers/
│ │ ├── router.go # [MODIFY] M6:gin.Recovery() 替换为 middleware.Recovery()
│ │ └── api/v1/order.go # [MODIFY] H2(180/187-192/152-173/211-214);普通回调 133-137/142-143 不重复记录(service 唯一落点)
│ └── redismodel/keys.go # [MODIFY] M3(39 启动期无 span,保留并加注释理由)
├── pkg/tokenhub/scheduler.go # [MODIFY] M3/M5(115 用 ctx 并拆分 lerr 记 Error、!ok 记 Info;118 解锁失败 Warn)
├── main.go # [MODIFY] L1(LogLevel 忽略大小写与未匹配告警)、L2(LocalTime: true;AddSource 保留并注理由)、M3(276-291 fanout 重组)
└── docs/日志专项评估报告-2026-09-15.md # [MODIFY] 逐项标注修复状态(已修复 + 2026-09-15)
```
## Key Code Structures
1. main.go 日志组装调整(防误改 fanout.go / otel.go,两者实现均无需改动):
改造前:
```
localHandler := OtelContextHandler{Handler: JSONHandler}
slog.SetDefault(slog.New(NewFanoutHandler(localHandler, otelHandler)))
```
改造后:
```
slog.SetDefault(slog.New(OtelContextHandler{Handler: NewFanoutHandler(JSONHandler, otelHandler)}))
```
本地分支输出等价,OTel 分支获得 trace_id/span_id(FanoutHandler 对子 handler 使用 r.Clone() 分发,外层注入的 attrs 会同时进入两分支)。
2. 新增 Recovery 中间件签名:`internal/middleware/recovery.go` 提供 `func Recovery() gin.HandlerFunc`(基于 gin.CustomRecoveryWithWriter 等价实现,panic 值 + debug.Stack 经 slog.ErrorContext 输出,响应语义与 gin.Recovery 一致)。
## Agent Extensions
### SubAgent
- **code-explorer**
- Purpose: 修复完成后全量复核——重扫全部 slog 打点与静默点,核对 23 项 + 矩阵缺口逐项落地、无重复日志、无敏感输出、ctx 使用正确
- Expected outcome: 输出复核清单与偏差项,支撑评估报告定稿与状态标注
### Skill
- **lsp-code-analysis**
- Purpose: 在签名 / 组装变更点(retryCall 业务标识扩展、service.go 事务函数 ctx 透传、Recovery 替换)执行引用与影响面分析,确保调用点无遗漏、无误改
- Expected outcome: 确认相关符号全部引用点均在预期范围内,签名变更不影响无关调用
+11 -9
View File
@@ -199,7 +199,8 @@ curl http://localhost:<HttpPort>/ping
| `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** 关闭;负数回落默认) |
| `UserDefaultQuota` | 新用户**免费额度** | `Cos`(**永久**额度,未配置回落 20)、`AI`/`OutfitShare`/`ClosetShare`(**每日**额度,未配置回落 3/10/5,每天 0 点重置;可显式配 **0** 表示不赠送)。与会员身份无关,所有用户长期享有 |
| `NewUserGift` | 新用户注册赠送的**体验会员及其额度** | `GiftVIPDay`(体验会员天数,未配置回落 3 天,可配 **0** 表示不赠送会员)、`Cos`/`AI`/`OutfitShare`/`ClosetShare`(体验会员额度,未配置回落 0/3/3/3)。额度以**体验会员凭证**(`source=4`)发放,**有效期为固定值 = 注册时刻 + 体验天数**(⚠️ 不跟随 VIP 延长,否则用户后续充值会把体验额度一直「继承」下去);`GiftVIPDay` 配 0 时额度一律不发放。与 `UserDefaultQuota` 的免费额度**叠加生效** |
| `AdReward` | 广告激励额度 | `Enable`(是否开启,关闭时接口返回「广告激励未开启」)、`Cos`/`AI`/`OutfitShare`/`ClosetShare`(各类型**固定金额**,服务端定价,前端不传金额;可删或置 0 表示不发放该类型) |
| `Tokenhub` | 大模型网关 | `APIKey`、`Model`(默认 hy4);**模板中缺失,需手动补充(见快速开始)** |
@@ -213,8 +214,9 @@ curl http://localhost:<HttpPort>/ping
```mermaid
flowchart TD
A["微信登录<br/>新用户自动注册"] --> B["初始化免费额度<br/>默认 上传 20 / AI 3 / 搭配分享 10 / 衣柜分享 5<br/>(可配置,允许为 0)"]
B --> C["上传衣物<br/>校验 → 扣上传额度 → 异步 AI 抠图 + 分类"]
A["微信登录<br/>新用户自动注册"] --> B["初始化 4 类免费额度<br/>默认 上传 20 / AI 3 / 搭配分享 10 / 衣柜分享 5<br/>(可配置,允许为 0)"]
B --> B2["赠送体验会员 + 会员额度<br/>默认 3 天;AI 3 / 分享 3 / 衣柜 3<br/>(体验会员凭证 source=4,固定到期)"]
B2 --> C["上传衣物<br/>校验 → 扣上传额度 → 异步 AI 抠图 + 分类"]
C --> D["衣橱管理<br/>查看 / 修正属性 / 删除回退额度"]
D --> E["创建穿搭"]
D --> F["AI 穿搭推荐<br/>扣 AI 额度 → 大模型生成 → 待用户保存"]
@@ -229,7 +231,7 @@ flowchart TD
全系统共 7 条业务主线:
1. **注册与额度**:微信登录首次自动注册,初始化 Cos **永久**免费额度(内置默认 20),AI 推荐 / 搭配分享 / 衣柜分享为**每日**额度(内置默认 3/10/5,可由 `UserDefaultQuota` 覆盖,**允许显式配 0** 表示不赠送;未配置或负数回落默认),每日额度不落库、按当天计数每天 0 点重置;并赠送体验会员(`vip_type=4`,内置默认 3 天,可由 `UserDefaultQuota.GiftVIPDay` 覆盖,**允许配 0 关闭**),同时下发一条系统通知;会员已过期时接口返回的 `vip_type` 归一为 1。
1. **注册与额度**:微信登录首次自动注册,先初始化**免费额度**(`UserDefaultQuota`:Cos **永久**默认 20,AI 推荐 / 搭配分享 / 衣柜分享为**每日**默认 3/10/5,**允许显式配 0**;未配置或负数回落默认,每日额度不落库、按当天计数每天 0 点重置),再赠送**体验会员及其额度**(`NewUserGift`:开通 `vip_type=4` 默认 3 天、**允许配 0 关闭**,并发放 `source=4` 体验会员额度凭证,**固定有效期为体验期结束、续费不会延长**,与免费额度叠加),同时下发一条系统通知;会员已过期时接口返回的 `vip_type` 归一为 1。
2. **衣物生命周期**:上传(校验 + 扣额)→ 异步 AI 处理(抠图 → 分类)→ 成功入库可被选用;失败自动回退额度;删除软删并回退额度。
3. **穿搭与社区**:创建穿搭 → 公开(审核自动通过 + 扣分享额度)→ 其他用户点赞/收藏 → 触发通知。
4. **衣柜分享**:部分衣物或全量快照分享 → 扣额度 → 属主/访客查看(访客访问即记录接收关系)→ 取消或到期懒过期;"分享给我的"列表联表查询接收关系。
@@ -665,11 +667,11 @@ flowchart TD
| 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 | 变更数量(正增负减) |
| change_type | int | 1=购买获赠 2=活动赠送 3=上传消耗 4=删除回退 5=人工调整 6=退款回收 7=**到期失效**(固定有效期的额度凭证过期,由后台任务每小时补记) |
| change_num | int64 | 变更数量(正增负减;到期失效为负数) |
| new_value | int64 | 变更后数值 |
| reason | string | 变更原因 / 备注 |
| order_no | string | 关联订单号(购买类流水) |
| order_no | string | 关联订单号;购买/退款类为真实订单号,其余为流水 UUID 或凭证 `ref_id`(用于幂等唯一索引) |
| created_at | int64 | 创建时间戳(秒) |
#### 1. 用户模块
@@ -680,7 +682,7 @@ flowchart TD
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| code | string | 是 | 微信登录 code(`wx.login` 获取),首次登录自动注册并初始化免费额度 |
| code | string | 是 | 微信登录 code(`wx.login` 获取),首次登录自动注册:初始化免费额度 + 赠送体验会员及额度 |
响应 `data`:
@@ -1313,7 +1315,7 @@ sequenceDiagram
F->>B: POST /user/wxlogin {code}
B->>W: code2session(code)
W-->>B: openid / session_key
B->>B: 首次登录:注册用户 + 初始化免费额度
B->>B: 首次登录:注册用户 + 初始化免费额度 + 赠送体验会员及会员额度
B-->>F: {access_token, refresh_token, expire_time, user_info}
Note over F,B: access_token 过期(业务码 11002 / HTTP 401)
+14 -4
View File
@@ -104,15 +104,25 @@ WechatVirtualPay:
Token: your_virtual_pay_notify_token # 消息推送验签 Token(配置回调地址时自定义)
UserDefaultQuota:
# 新用户免费额度与注册赠送的会员天数;可显式配置为 0(额度类表示不赠送,GiftVIPDay 表示不赠送会员),
# 删除某项或整段则回落内置默认(额度 20/3/10/5,GiftVIPDay 3 天);负数视为非法配置,同样回落内置默认。
# 新用户免费额度:所有用户长期享有,与会员身份无关;可显式配置为 0 表示不赠送该类额度,
# 删除某项或整段则回落内置默认(20/3/10/5);负数视为非法配置,同样回落内置默认。
# 额度语义:Cos 为永久额度(不每天重置);AI / OutfitShare / ClosetShare 为每日额度(每天 0 点重置)。
Cos: 20 # 免费上传额度(永久)
AI: 3 # 免费 AI 推荐额度(每日)
OutfitShare: 10 # 免费社区分享额度(每日)
ClosetShare: 5 # 免费衣柜分享额度(每日,配置 0 表示不赠送)
GiftVIPDay: 3 # 注册赠送的体验会员天数(vip_type=4);0 表示不赠送;
# 删除该项或整段则回落内置默认 3 天;负数视为非法配置,同样回落内置默认。
NewUserGift:
# 新用户注册赠送的体验会员及其额度。
# 额度以「会员包」凭证发放(expire_time=0 跟随 VIP),VIP 到期自动失效;
# 因此 GiftVIPDay 配 0(不赠送会员)时,下方额度一律不发放。
# 各项同样支持显式配置 0(表示不赠送该类额度);未配置则回落内置默认(天数 3,额度 0/3/3/3);
# 负数视为非法配置,回落内置默认。
GiftVIPDay: 3 # 赠送的体验会员天数(vip_type=4);0 表示不赠送
Cos: 0 # 体验会员赠送的上传额度(0 表示不额外赠送)
AI: 3 # 体验会员赠送的 AI 推荐额度
OutfitShare: 3 # 体验会员赠送的社区分享额度
ClosetShare: 3 # 体验会员赠送的衣柜分享额度
AdReward:
# 广告激励额度:是否开启(关闭时 /ad-reward/grant 返回「广告激励未开启」)。
+134
View File
@@ -0,0 +1,134 @@
# 日志专项评估报告(2026-09-15)
> **审计对象**:zt-closet-backend 后端服务**代码中的日志打点**(`log/slog`),及日志基础设施(写出通道、级别、trace 注入、脱敏)。
> **审计方式**:静态代码审计——全仓检索 + 逐文件逐条核对(共 **160 条 slog 语句 / 23 个文件**)+ 关键结论二次抽查。未运行服务、未连接数据库。
> **行号基准**:当前工作区代码(工作区存在未提交改动,历史报告行号不可复用)。
> **交付性质**:评估建议稿;所列问题已于 2026-09-15 按「纯日志改进(零控制流变更)」边界全量修复完成,逐项状态见第四章(✅ = 已修复)。
---
## 一、总体结论
**不存在系统性"没日志",但覆盖严重不均衡,且存在若干被静默吞掉的资金/安全相关失败。**
1. **资损防线的 Error 级打点较扎实**:普通支付/虚拟支付的金额校验、发货五类校验、退款金额校验、补偿超限告警、鉴权 Redis 降级等均有日志(见"值得保留的良好实践")。
2. **三类结构性空白**:
- HTTP 入口层与鉴权中间件:`internal/routers/` 15 个文件 **0 条日志**;鉴权中间件 8 类拒绝分支 **全部无日志**;
- 成功侧审计:下单/支付/退款/发放/回收**成功路径均无日志**,日志无法用于对账与业务量统计;
- `pkg/` 第三方客户端:wechat/cos/matting/classification 等 **0 条日志**,失败仅向上抛。
3. **最高风险是"失败被静默吞掉"**:幂等锁错误与锁竞争不可区分(Redis 故障时回调被当作"已处理"应答成功,权益漏发)、支付信息落库失败被 `_ =` 吞掉、XPay 退款 settle 返回值丢弃、过期补发全静默、额度事务失败静默等,共 **10 项高风险**(见第四章 H1–H10)。
4. **日志质量**:全仓 76% 打点使用 Context 版本(service 层 89%);**未发现** openid/手机号/token/密钥/回调报文数值泄漏;msg 与字段命名风格整体一致;主要质量问题为 trace 断链、缺实体 ID、少量级别不当、后台任务无运行心跳。
---
## 二、审计范围与方法
- **对象**:全仓 Go 代码(排除 `thirdparty/` Python 服务),共 160 条 `slog` 语句、23 个含日志的文件。
- **对照基准**:README 的 7 条业务主线、6 个后台任务、历史审查报告标注的资损敏感路径(支付/退款回调、发货校验、权益发放回收、额度扣减回退、鉴权降级)。
- **评估维度**:①覆盖度(遗漏);②级别合理性;③字段完整性;④敏感信息;⑤trace 关联;⑥规范一致性;⑦基础设施配置。
- **局限**:未运行服务,运行期行为(文件轮转、OTel 采样与导出、告警链路)仅在代码层评估;`storage/logs/app.log` 实际产出内容未纳入本次范围。
---
## 三、日志基础设施现状
| 项 | 现状 |
| --- | --- |
| 输出通道 | lumberjack 滚动文件(`storage/logs/app.log`,压缩、按大小/份数/天数)+ stdout;JSON 格式;`AddSource: true`(每条带 source 文件:行号) |
| trace 注入 | `tracing.OtelContextHandler` 仅在 **`slog.XxxContext(ctx,…)` 且 ctx 内存在有效 span** 时注入 trace_id/span_id;OTel Logs 桥接分支(`otelslog`)**不携带** trace_id/span_id(record 克隆后各自处理) |
| 日志级别 | 配置 `Log.LogLevel` **大小写敏感**精确匹配 Debug/Warn/Error,其余值静默回落 Info;当前配置 Info;全仓 **0 条 Debug** 打点 |
| 请求日志 | 每请求 1 条 Info(`GIN_REQUEST`):request_id/method/path/query/status/ip/latency/user_agent;query 中 token/code/secret/password/access_token/refresh_token/key 共 7 类参数脱敏;**不记录**请求体/响应体;状态码任意均 Info,不按 4xx/5xx 升级;panic 请求不落日志(`gin.Recovery` 位于更外层,输出 stderr 且非 JSON) |
| 敏感信息 | 未见 openid/手机号/token/session_key/密钥/回调报文数值落入日志;支付回调原始报文仅入库 `callback_raw` |
| 打点分布 | `internal/service` 127(18 文件)、`pkg/tokenhub/scheduler.go` 16、`main.go` 13、`internal/middleware` 3、`internal/redismodel/keys.go` 1;**0 打点**:`internal/routers/`(15 文件)、`internal/mysqldao/`(19)、`internal/redisdao/`(12)、`pkg/` 除 tokenhub/tracing 外全部包 |
| 级别分布 | Error 80 / Warn 51 / Info 29 / Debug 0(全仓 160 条) |
---
## 四、问题清单
分级原则:**资损/安全相关遗漏 = 高**;排查困难/可观测性缺口 = 中;规范一致 = 低。
**修复状态(2026-09-15)**:本清单全部 23 项及第五章矩阵点名的具体静默点已按「纯日志改进」边界完成修复——仅新增/调整日志与级别、补齐实体字段与 Context 版本、增加任务心跳与 Debug 能力、改进日志配置健壮性;控制流、返回值与幂等语义零变化。报告中涉及控制流调整的建议(如 H5「失败应答微信重试」)未采纳,已裁剪为「仅记录日志」。验证:`gofmt` / `go vet` / `go build` / `go test` 通过,全部修复点经全仓检索逐项核验。以下编号加 ✅ 表示已修复。
### 高(资损/安全,建议优先处理)
| # | 位置 | 问题 | 影响与建议 |
| --- | --- | --- | --- |
| **H1** ✅ | `internal/service/order.go:123-129`、`:354-357`;`internal/routers/api/v1/order.go:133-137`、`:142-143` | 支付/退款回调的**验签失败、解密失败、非 SUCCESS 状态**无任何应用日志,service 失败仅回微信 FAIL 应答 | 伪造回调、平台密钥/证书配置错误、微信重试风暴均不可见(仅访问日志 status=500 + span)。建议 Error 级补记 `out_trade_no/out_refund_no` + 失败阶段,验签失败附来源 IP |
| **H2** ✅ | `internal/routers/api/v1/order.go:180`、`:187-192`、`:152-173`、`:211-214` | XPay(虚拟支付)回调入口:读体失败、安全模式验签/解密失败、`XPayVerify` 验签失败均无日志;回包加密失败静默降级明文 | 虚拟支付通道的伪造回调尝试与密钥错误不可发现。建议 Error 级记录事件类型与失败原因;加密降级明文应 Warn |
| **H3** ✅ | `internal/service/order.go:134-136`、`:361-363`;`internal/service/virtualpay.go:244-246`、`:337-339`;`internal/service/virtualpay_compensate.go:82-85`;`internal/service/benefit_compensation.go:43-46` | 幂等 `TryLock` 错误与正常锁竞争**合并静默忽略**(部分为 `_` 丢弃) | Redis 故障时回调/补偿被当作"并发已处理"直接应答成功,**权益漏发且无任何日志痕迹**。建议 `lerr != nil` 记 Error,正常竞争记 Debug |
| **H4** ✅ | `internal/service/order.go:461-465`;`internal/service/virtualpay.go:426-430` | 支付成功后的支付信息更新(`pay_time`/`transaction_id`/`callback_raw`)返回值被 `_ =` 吞掉 | 对账所依赖的交易号与回调报文缺失而无告警。建议 Error 级补记 order_no + error |
| **H5** ✅ | `internal/service/virtualpay.go:370`;`internal/service/order.go:394-396` | XPay 退款推送 `settleRefundSuccess` 返回值未接收;普通退款回调 settle 失败无日志 | 退款已到账但订单终态流转/回收任务创建失败时完全无日志。建议接收返回值并 Error 记录,失败应答微信重试 |
| **H6** ✅ | `internal/service/order.go:196-206` | 已过期订单补发货分支:流转 DB 错误、条件竞争 `!ok`、成功补发**全部静默** | "用户付了钱、订单过期又补发"的关键资损场景无审计线索。建议成功 Info(order_no)、DB 错误 Error、竞争 Debug |
| **H7** ✅ | `internal/service/order.go:95-97`、`:99-102`;`internal/service/virtualpay.go:26-63`、`:92-106` | 下单链路失败静默:订单落库失败、JSAPI 下单失败(订单保留)、虚拟支付未开通/环境/商品/金额/session_key/落库/签名失败 | 遗留待支付单与微信侧下单失败无可追溯线索。建议 Error 级补记 order_no/user_id/goods_code;业务性拒绝(商品下架)可 Info/Warn |
| **H8** ✅ | `internal/middleware/lzcwyjwt.go:30-34`(缺失)、`:36-41`(格式)、`:44-56`(无效/过期/类型)、`:59-65`(会话失效)、`:71-75`(UID 不匹配)、`:84-89`(DB 回落失败)、`:95-99`(状态拒绝);`internal/service/user.go:343` | 鉴权 **8 类拒绝分支全部无日志**(响应层可区分,日志层 0 条);UID 不匹配(疑伪造/重放)无留痕;刷新令牌时旧 token 吊销失败 `_ =` 丢弃;另 `lzcwyjwt.go:52` 把底层解析错误回给客户端而服务端无日志(信息投放错位) | 无法统计鉴权失败、定位被攻击账号、排查封禁争议。建议统一 Warn(UID 不匹配/吊销失败 Error),字段 method/path/ip/user_id/reason |
| **H9** ✅ | `internal/service/benefit_compensation.go:129-134`、`:61`、`:73`、`:144-146`、`:157`;`internal/service/order.go:723-734`、`:786`、`:789` | 权益回收链路静默点:回收任务查询失败、恢复僵死任务错误与条数被丢弃、Claim 失败、成功写失败均无日志 | 退款已到账但权益未回收的"资金-权益不一致"无法从日志发现。建议 Error 级补记 order_no/retry_count,成功回收补 Info 审计 |
| **H10** ✅ | `internal/service/service.go:350-355`、`:103-109`、`:141-144`、`:155-157`;`internal/service/clothes.go:466-490` | 额度事务失败静默:Cos 消费事务失败(非额度不足)、VIP 读取失败当非会员处理、有效额度/免费已用查询失败、删除衣物回退额度失败(仅返回 50005) | 用户"白扣/少退"无从核查。建议按性质补 Warn/Error,携带 user_id/related_id/quota_type |
### 中(排查困难/可观测性缺口)
| # | 位置 | 问题 | 建议 |
| --- | --- | --- | --- |
| **M1** ✅ | `internal/service/order.go:104-115`、`:171-174`、`:341-346`、`:380-389`;`internal/service/user.go`(登录成功) | **成功侧审计日志整体缺失**:下单/支付/退款/发放/回收/登录成功均无 Info | 补 Info 审计日志(order_no/user_id/amount/channel),支撑对账与业务量统计 |
| **M2** ✅ | `internal/service/user.go:117`、`clothes.go:159`、`clothes.go:275`、`airecommend.go:270`、`outfit.go:289`、`closet.go:102`、`service.go:461` | 关键日志缺业务实体 ID:user_id/clothes_uuid(重试告警仅有 name)/task_id/outfit_uuid/share_uuid/related_id | 补齐对应实体 ID,重试类日志带 clothes_uuid 或 task_id |
| **M3** ✅ | `pkg/tokenhub/scheduler.go:115`;`internal/service/virtualpay_compensate.go:142`;`internal/service/airecommend.go:417`;`internal/redismodel/keys.go:39`;`internal/service/service.go:375`、`:434`;`user.go:541`;`clothes.go:320` | **trace 断链**:全仓 38/160 条非 Context 版本(scheduler 启动/panic 类属可接受);重点改进点如左列——已有 ctx 却用裸调用、worker panic 可用外层 ctx、显式 `context.Background()`、recovery 路径无 span | 上述重点位置改 `XxxContext(ctx,…)`;OTel Logs 桥接分支不携带 trace_id 属基础设施限制,如需可在 fanout 层统一注入 |
| **M4** ✅ | `internal/service/quota_expire.go:80`、`ai_outfit_cleanup.go:77`、`order_expire.go:44`、`clothes.go:597-620`、`virtualpay_compensate.go:155` | 后台任务**无运行心跳**:仅"有产出"时打汇总(total/count/confirmed>0),无事发生则无声;衣物恢复调度每轮无记录 | 每轮 Info/Debug 汇总(scanned/affected/failed/耗时),供存活巡检与积压趋势观察 |
| **M5** ✅ | `internal/service/order.go:438`、`:741`、`:525`;`notification.go:170`;`airecommend.go:213`;`pkg/tokenhub/scheduler.go:115`;`virtualpay_compensate.go:167` | **级别不当/不一致**:待人工审核资损事件仅 Info(438);回收链路主查询失败仅 Warn(741);cooldown skip 用 Warn 可能刷屏(525);通知未读计数(缓存 best-effort)用 Error 高于同函数通知创建失败的 Warn(170);快照保存失败(best-effort)用 Error(213);Redis 故障与锁竞争合并记为 Info"already in progress"(`scheduler.go:115`,当天运势可能缺失且无告警);错误分支 Warn 且 openid 为空时 `error` 字段输出 nil(`virtualpay_compensate.go:167`) | 按左列逐条调整级别与字段;scheduler 建议拆分 `lerr!=nil → Error` / `!ok → Info` |
| **M6** ✅ | `internal/middleware/logger.go:44-53`;`internal/routers/router.go`(Recovery 注册) | 访问日志恒 Info,4xx/5xx 不升级;panic 请求无访问日志且 `gin.Recovery` 输出 stderr 非 JSON;`user_agent` 未截断 | 按状态码升级级别;定制 Recovery 输出走 slog;user_agent 截断 |
| **M7** ✅ | `pkg/wechat/`(pay.go、virtualpay.go、mini.go 等)、`pkg/cos/`、`pkg/matting/`、`pkg/classification/`、`pkg/upload/` | 第三方客户端 **0 条日志**:验签/解密/退款/查单、COS 读写、模型调用失败仅向上抛(部分仅 span.RecordError) | 高价值失败点(验签/解密、模型重试终失败)下沉日志或确保调用方补记;`upload` 已有 span 约定可保持 |
| **M8** ✅ | `internal/routers/api/v1/*.go`(15 文件)、`internal/mysqldao/`(19)、`internal/redisdao/`(12) | DAO/handler 层 0 条 slog,错误仅靠 span + 响应码;JSON 日志文件中无错误率/错误类型分布 | 维持"错误由上层记录"的分工,但上层须落实 H/M1;关键降级路径(Redis 读回退等)可补 Debug |
### 低(规范一致性)
| # | 位置 | 问题 | 建议 |
| --- | --- | --- | --- |
| **L1** ✅ | `main.go:266-276` | `Log.LogLevel` 大小写敏感,误配 `debug`/`info` 等将**静默**回落 Info | 忽略大小写匹配,或未匹配时启动告警 |
| **L2** ✅ | `main.go:250-294` | `AddSource: true` 全量开启有 CPU/日志体积成本;lumberjack 未配 `LocalTime`(轮转文件名按 UTC);`Log` 段缺失时在 init panic(fail-fast 合理,保持) | 可考虑仅 Warn 以上开启 AddSource;按需配置 LocalTime |
| **L3** ✅ | `virtualpay.go:380`、`benefit_compensation.go:154`、`virtualpay.go:139`、`:151`、`order.go:565`、`:567` | msg 质量问题:DB 故障记成 "order not found"(380,误导排障);同一 msg 覆盖两种失败场景(154);包装与内层重复记 Error(139/151);发放失败缺 retry_count/amount | 拆分/明确 msg,去除重复日志,补重试进度字段 |
| **L4** ✅ | `user.go:117` vs `:124`;`notification.go:165` vs `:170`;`clothes.go:320` | 一致性瑕疵:同类日志是否带 user_id 不一致;best-effort 缓存失败级别不一致;置态失败与额度回退失败合并一条且缺 phase(无法判断是否白扣额度) | 统一字段与级别规范;`clothes.go:320` 补 phase 字段 |
| **L5** ✅ | 全仓 | **0 条 Debug 打点**,与"排障期临时提高详单"的能力矛盾(改代码才能加日志) | 为高频排查点(轮询、降级、批量任务细节)补 Debug 级日志,生产用 LogLevel 控制 |
---
## 五、关键路径日志覆盖度矩阵
> 现状标注:✅ 覆盖较好;◐ 部分覆盖;✗ 基本空白。缺口详情见第四章对应编号。
| 业务线/模块 | 关键路径 | 现状 | 主要缺口 |
| --- | --- | --- | --- |
| 1 注册与额度 | 登录 → 注册 → 免费额度 → 体验会员 | ◐ | H8(code2session/注册失败无日志)、`user.go:343`、封禁拒绝、额度初始化失败(部分有 Warn) |
| 2 衣物生命周期 | 上传校验 → 扣额 → 异步流水线 → 失败回退 → 删除回退 | ◐ | H10(删除回退)、上传校验三分支静默、扣额/建记录失败、恢复调度认领与心跳(M4) |
| 3 穿搭与社区 | 创建/更新/删除 → 公开 → 点赞/收藏 | ◐ | 创建/明细事务失败、公开扣额回退失败、点赞/收藏 Redis 双写静默(`like.go:60`、`collect.go:71`) |
| 4 衣柜分享 | 创建 → 详情(懒过期/接收) → 取消 | ◐ | 扣额/明细落库失败、懒过期更新、访问统计静默、取消无日志 |
| 5 交易与权益 | 下单 → 支付回调 → 发放 → 退款 → 回收 → 补偿 | ◐(防线较全,**静默点最多**) | H1–H7、H9、H10;成功侧审计缺失(M1) |
| 6 AI 推荐 | 提交 → 后台生成 → 轮询 → 保存 → 清理 | ◐ | 提交三失败点(`airecommend.go:68-103`)、衣物列表/落库失败(`:254-306`)、运势降级(`:364-386`)、清理无心跳 |
| 7 通知 | 创建 → 未读 → 已读 | ◐ | 标记已读失败(`notification.go:95-97`)、DB+Redis 双失败静默(`:124-131`)、级别不一致(M5) |
| 后台任务(6 个) | 定时调度循环 | ◐ | M4 无运行心跳;资损相关失败(补偿超限、回收失败)已有 Error |
| 鉴权 | JWT 全链路 | ✗ | H8 全部 8 类拒绝分支;仅 Redis 降级 2 处 Warn(缺 user_id/ip) |
| 基础设施 | 请求访问日志 / 日志写出 | ◐ | M6(级别不升级、panic 不落日志);L1/L2 配置健壮性 |
---
## 六、改进优先级建议
| 优先级 | 内容 | 说明 |
| --- | --- | --- |
| **P0(立即)** | 第四章高风险 **H1–H10**(10 项) | 重点消除"静默吞错":锁错误分离、`_ =` 返回值收口、回调验签失败与鉴权拒绝补日志。这些是资损/安全的最后一道人工可查防线 |
| **P1(随迭代)** | 中优先级 **M1–M8** | 补成功侧审计与实体 ID、修复 trace 断链、后台任务心跳、级别调整、pkg 高价值失败点下沉 |
| **P2(规范)** | 低优先级 **L1–L5** | 配置健壮性、msg/字段规范、Debug 级日志能力建设 |
**值得保留的良好实践**(后续改动请勿回退):
- 资损防线 Error 打点齐全:支付/退款/发货金额校验、环境校验、防伪反查、补偿超限告警均带订单号;
- 支付链路字段命名统一(`order_no`/`out_trade_no`/`*_amount`),可检索可对账;
- service 层 Context 版本使用率 89%,主流程 trace 关联良好;
- 敏感信息 **0 泄漏**(openid 相关分支只打订单号;回调报文仅入库);query 脱敏覆盖 7 类参数;
- DAO 层统一 `span.RecordError` 约定,trace 侧可完整回溯错误。
---
## 附:数据口径
- slog 语句统计:`internal/service` 127 条(18 个文件有打点,`adreward.go`、`goods.go`、`horoscope.go`、`constants.go` 及测试文件 0 条);`pkg/tokenhub/scheduler.go` 16;`main.go` 13;`internal/middleware`(`lzcwyjwt.go` 2、`logger.go` 1);`internal/redismodel/keys.go` 1。合计 160 条。
- 未使用 Context 版本:全仓 38 条,其中 30 条为进程启停 / 调度器启动与 panic 兜底类(本身无请求 span,影响有限);其余 8 条为运行期路径,其中 `pkg/tokenhub/scheduler.go:115`、`internal/service/virtualpay_compensate.go:142`、`internal/service/airecommend.go:417`、`internal/redismodel/keys.go:39` 等为建议改进点(详见 M3)。
- 本报告全部行号以 2026-09-15 当前工作区文件为准;引用敏感字段时仅出现字段名,不含任何真实值。
+1
View File
@@ -18,5 +18,6 @@ var (
WechatPaySetting *setting.WechatPaySettingS
VirtualPaySetting *setting.VirtualPaySettingS
UserDefaultQuotaSetting *setting.UserDefaultQuotaSettingS
NewUserGiftSetting *setting.NewUserGiftSettingS
AdRewardSetting *setting.AdRewardSettingS
)
+28 -6
View File
@@ -2,6 +2,7 @@ package middleware
import (
"log/slog"
"net/http"
"net/url"
"strings"
"time"
@@ -30,6 +31,17 @@ func sanitizeQuery(query string) string {
return values.Encode()
}
// maxUserAgentLen user_agent 日志字段最大长度:超出截断,避免异常长 UA 撑大日志体积。
const maxUserAgentLen = 256
// truncateUserAgent 截断超长 User-Agent(仅用于日志展示)。
func truncateUserAgent(ua string) string {
if len(ua) <= maxUserAgentLen {
return ua
}
return ua[:maxUserAgentLen] + "...(truncated)"
}
func GinSlogMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
@@ -40,16 +52,26 @@ func GinSlogMiddleware() gin.HandlerFunc {
// 处理请求
c.Next()
// 请求结束后打日志
slog.InfoContext(c.Request.Context(), "GIN_REQUEST",
"request_id", GetRequestID(c.Request.Context()),
// 请求结束后按状态码分级打日志:5xx=Error、4xx(除 401)=Warn、其余=Info。
ctx := c.Request.Context()
status := c.Writer.Status()
attrs := []any{
"request_id", GetRequestID(ctx),
"method", c.Request.Method,
"path", path,
"query", query,
"status", c.Writer.Status(),
"status", status,
"ip", c.ClientIP(),
"latency", time.Since(start).String(),
"user_agent", c.Request.UserAgent(),
)
"user_agent", truncateUserAgent(c.Request.UserAgent()),
}
switch {
case status >= http.StatusInternalServerError:
slog.ErrorContext(ctx, "GIN_REQUEST", attrs...)
case status >= http.StatusBadRequest && status != http.StatusUnauthorized:
slog.WarnContext(ctx, "GIN_REQUEST", attrs...)
default:
slog.InfoContext(ctx, "GIN_REQUEST", attrs...)
}
}
}
+25 -3
View File
@@ -28,6 +28,8 @@ func JWTAuth() gin.HandlerFunc {
// 1. 从 Authorization 头取 token:Bearer xxx.yyy.zzz
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
slog.WarnContext(c.Request.Context(), "jwt auth: authorization header missing",
"method", c.Request.Method, "path", c.Request.URL.Path, "ip", c.ClientIP())
response.ToErrorResponse(errcode.UnauthorizedTokenMissing)
c.Abort()
return
@@ -35,6 +37,8 @@ func JWTAuth() gin.HandlerFunc {
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
slog.WarnContext(c.Request.Context(), "jwt auth: invalid authorization format",
"method", c.Request.Method, "path", c.Request.URL.Path, "ip", c.ClientIP())
response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails("invalid Authorization format"))
c.Abort()
return
@@ -45,10 +49,14 @@ func JWTAuth() gin.HandlerFunc {
if err != nil {
switch {
case errors.Is(err, lzcwyjwt.ErrTokenExpired):
slog.InfoContext(c.Request.Context(), "jwt auth: token expired", "path", c.Request.URL.Path)
response.ToErrorResponse(errcode.UnauthorizedTokenTimeout)
case errors.Is(err, lzcwyjwt.ErrTokenWrongType):
slog.WarnContext(c.Request.Context(), "jwt auth: token type mismatch", "path", c.Request.URL.Path)
response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails("token type mismatch"))
default:
slog.WarnContext(c.Request.Context(), "jwt auth: parse token failed",
"path", c.Request.URL.Path, "ip", c.ClientIP(), "error", err)
response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails(err.Error()))
}
c.Abort()
@@ -59,6 +67,8 @@ func JWTAuth() gin.HandlerFunc {
sessionUID, serr := redisdao.AuthUserIDByAccess(c.Request.Context(), parts[1])
if serr != nil {
if errors.Is(serr, redis.Nil) {
slog.WarnContext(c.Request.Context(), "jwt auth: token session invalid",
"user_id", claims.UserID, "path", c.Request.URL.Path)
response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails("token session invalid"))
c.Abort()
return
@@ -67,8 +77,12 @@ func JWTAuth() gin.HandlerFunc {
// 已确认的安全权衡决策——优先保证可用性,避免 Redis 抖动导致全员不可用。
// 代价是 Redis 故障窗口内,已登出/被吊销但未过期的 token 仍可访问;
// 封禁用户仍会被下方 DB 回查(Status != 1)拒绝,不会因本降级放行而绕过封禁。
slog.WarnContext(c.Request.Context(), "check access token session failed, allow by fallback", "error", serr)
slog.WarnContext(c.Request.Context(), "check access token session failed, allow by fallback",
"user_id", claims.UserID, "path", c.Request.URL.Path, "ip", c.ClientIP(), "error", serr)
} else if sessionUID != claims.UserID {
slog.ErrorContext(c.Request.Context(), "jwt auth: token user mismatch",
"user_id", claims.UserID, "session_user_id", sessionUID,
"path", c.Request.URL.Path, "ip", c.ClientIP())
response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails("token user mismatch"))
c.Abort()
return
@@ -78,21 +92,29 @@ func JWTAuth() gin.HandlerFunc {
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)
slog.WarnContext(c.Request.Context(), "get user info from cache failed, fallback to db",
"user_id", claims.UserID, "error", err)
}
// 缓存未命中或 Redis 抖动:回落数据库。
dbUser, dbErr := mysqldao.NewUserDao().UserGetByID(c.Request.Context(), claims.UserID)
if dbErr != nil {
slog.WarnContext(c.Request.Context(), "jwt auth: query user failed",
"user_id", claims.UserID, "path", c.Request.URL.Path, "error", dbErr)
response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails("用户不存在"))
c.Abort()
return
}
userInfo = dbUser
_ = redisdao.UserInfoSet(c.Request.Context(), dbUser, 0)
if cerr := redisdao.UserInfoSet(c.Request.Context(), dbUser, 0); cerr != nil {
slog.DebugContext(c.Request.Context(), "jwt auth: backfill user cache failed",
"user_id", claims.UserID, "error", cerr)
}
}
// 状态非正常(1=正常)一律拒绝
if userInfo.Status != 1 {
slog.WarnContext(c.Request.Context(), "jwt auth: user disabled",
"user_id", claims.UserID, "status", userInfo.Status, "path", c.Request.URL.Path)
response.ToErrorResponse(errcode.UserDisabled)
c.Abort()
return
+53
View File
@@ -0,0 +1,53 @@
package middleware
import (
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"runtime/debug"
"strings"
"github.com/gin-gonic/gin"
)
// Recovery 替代 gin.Recovery():panic 恢复语义保持一致(客户端断开类 panic 仅记录并中止,
// 其余 panic 返回 500 Internal Server Error),但 panic 与堆栈统一走 slog 结构化输出,
// 与全站 JSON 日志(含 request_id / trace_id)对齐,避免默认实现写 stderr 非结构化日志。
func Recovery() gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if r := recover(); r != nil {
ctx := c.Request.Context()
if isBrokenPipe(r) {
// 连接已断开,无法再写响应:仅记录并中止。
slog.ErrorContext(ctx, "GIN_PANIC_BROKEN_PIPE",
"method", c.Request.Method, "path", c.Request.URL.Path, "recover", fmt.Sprint(r))
c.Abort()
return
}
slog.ErrorContext(ctx, "GIN_PANIC",
"method", c.Request.Method, "path", c.Request.URL.Path, "ip", c.ClientIP(),
"recover", fmt.Sprint(r), "stack", string(debug.Stack()))
c.AbortWithStatus(http.StatusInternalServerError)
}
}()
c.Next()
}
}
// isBrokenPipe 判定 panic 是否源于客户端断开(broken pipe / connection reset),与 gin.Recovery 判定一致。
func isBrokenPipe(r any) bool {
ne, ok := r.(*net.OpError)
if !ok {
return false
}
var se *os.SyscallError
if !errors.As(ne, &se) {
return false
}
seStr := strings.ToLower(se.Error())
return strings.Contains(seStr, "broken pipe") || strings.Contains(seStr, "connection reset by peer")
}
+51
View File
@@ -644,3 +644,54 @@ func (d *QuotaGrantDao) QuotaGrantExistsByRefIDAndType(ctx context.Context, refI
}
return count > 0, nil
}
// quotaFlowChangeTypeExpire 与 service.QuotaChangeTypeExpire 对应。
// DAO 层不反向依赖 service,故此处用字面量并在此说明。
const quotaFlowChangeTypeExpire int8 = 7
// QuotaGrantListExpiredFixed 查询「固定有效期」且已过期、但尚未补记到期失效流水的凭证。
//
// 只处理 expire_time > 0 的凭证:expire_time=0 表示跟随 VIP,用户续费后凭证会重新生效,
// 不应记为「失效」(否则续费后额度凭空多一笔负数流水,反而污染明细)。
//
// 幂等依据:(ref_id, quota_type, change_type=7) 的流水是否已存在;写入失败下轮会重新捞取。
func (d *QuotaGrantDao) QuotaGrantListExpiredFixed(ctx context.Context, now int64, limit int) ([]mysqlmodel.QuotaGrant, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantListExpiredFixed")
defer span.End()
var list []mysqlmodel.QuotaGrant
err := d.db.WithContext(ctx).Model(&mysqlmodel.QuotaGrant{}).
Where("expire_time > 0 AND expire_time <= ?", now).
Where("NOT EXISTS (?)", d.db.Model(&mysqlmodel.QuotaFlowLog{}).
Select("1").
Where("quota_flow_logs.order_no = quota_grants.ref_id").
Where("quota_flow_logs.quota_type = quota_grants.quota_type").
Where("quota_flow_logs.change_type = ?", quotaFlowChangeTypeExpire)).
Order("id ASC").Limit(limit).Find(&list).Error
if err != nil {
span.RecordError(err)
return nil, err
}
return list, nil
}
// QuotaFlowLogCreateIgnoreDup 写入额度流水,命中唯一索引 uk_quota_flow_idem 冲突时静默忽略。
// 返回 created 表示本次是否真正插入,供「到期失效」流水的幂等补记使用。
func (d *QuotaGrantDao) QuotaFlowLogCreateIgnoreDup(ctx context.Context, flow *mysqlmodel.QuotaFlowLog) (bool, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaFlowLogCreateIgnoreDup")
defer span.End()
res := d.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{
{Name: "order_no"},
{Name: "quota_type"},
{Name: "change_type"},
},
DoNothing: true,
}).Create(flow)
if res.Error != nil {
span.RecordError(res.Error)
return false, res.Error
}
return res.RowsAffected > 0, nil
}
+1 -1
View File
@@ -21,7 +21,7 @@ type QuotaGrant struct {
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=广告激励
Source int8 `gorm:"column:source"` // 来源 1=会员包 2=加量包 3=广告激励 4=体验会员赠送
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)
}
+1 -1
View File
@@ -6,7 +6,7 @@ type QuotaFlowLog struct {
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 变更类型 1=购买获赠 2=活动赠送 3=上传消耗 4=删除回退 5=人工调整 6=退款回收 7=到期失效
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"` // 变更前数值
+1
View File
@@ -36,6 +36,7 @@ func NewRedisEngine(s *setting.RedisSettingS) (*redis.Client, error) {
return nil, fmt.Errorf("redis ping failed: %w", err)
}
// 启动期一次性连接日志:此时无请求 span 可关联,保持非 Context 版本(无 trace_id 可注入)。
slog.Info("redis connected", "addr", fmt.Sprintf("%s:%d", s.Host, s.Port))
return client, nil
}
+10
View File
@@ -2,6 +2,7 @@ package v1
import (
"io"
"log/slog"
"net/http"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
@@ -158,6 +159,8 @@ func (h *OrderHandler) XPayVerify(c *gin.Context) {
plain, err := wechat.DecodeEchoStr(echostr, timestamp, nonce, msgSignature)
if err != nil {
// 兼容模式:echostr 可能仍是明文(msg_signature 对明文计算),原样回显。
slog.WarnContext(c.Request.Context(), "xpay verify: decode echo str failed, fallback to plain echo",
"remote_addr", c.Request.RemoteAddr, "error", err)
c.String(http.StatusOK, echostr)
return
}
@@ -169,6 +172,7 @@ func (h *OrderHandler) XPayVerify(c *gin.Context) {
c.String(http.StatusOK, echostr)
return
}
slog.WarnContext(c.Request.Context(), "xpay verify: invalid signature", "remote_addr", c.Request.RemoteAddr)
c.String(http.StatusUnauthorized, "invalid signature")
}
@@ -177,6 +181,8 @@ func (h *OrderHandler) XPayVerify(c *gin.Context) {
func (h *OrderHandler) XPayNotify(c *gin.Context) {
raw, err := io.ReadAll(c.Request.Body)
if err != nil {
slog.ErrorContext(c.Request.Context(), "xpay notify: read request body failed",
"remote_addr", c.Request.RemoteAddr, "error", err)
writeXPayNotifyReply(c, false, false)
return
}
@@ -187,6 +193,8 @@ func (h *OrderHandler) XPayNotify(c *gin.Context) {
plain, derr := wechat.DecodeEncryptedNotify(raw, c.Query("timestamp"), c.Query("nonce"), c.Query("msg_signature"))
if derr != nil {
// 验签/解密失败:回失败应答触发微信重试(fail-closed)。
slog.ErrorContext(c.Request.Context(), "xpay notify: verify or decrypt failed",
"remote_addr", c.Request.RemoteAddr, "error", derr)
writeXPayNotifyReply(c, true, false)
return
}
@@ -210,6 +218,8 @@ func encryptIfNeeded(c *gin.Context, plain []byte, encrypted bool) []byte {
}
out, err := wechat.EncodeEncryptedNotify(plain, c.Query("nonce"))
if err != nil {
slog.WarnContext(c.Request.Context(), "xpay notify: encrypt reply failed, fallback to plain",
"remote_addr", c.Request.RemoteAddr, "error", err)
return plain
}
return out
+1 -1
View File
@@ -14,7 +14,7 @@ func NewRouter() *gin.Engine {
// 中间件链:Recovery -> RequestID -> OTel -> Slog -> CORS
r.Use(
gin.Recovery(),
middleware.Recovery(),
middleware.RequestID(),
middleware.GinOTelMiddleware(),
middleware.GinSlogMiddleware(),
+2
View File
@@ -78,5 +78,7 @@ func runAIOutfitCleanup() {
"count", total,
"retention_hours", int(aiUnsavedOutfitRetention.Hours()),
)
} else {
slog.DebugContext(ctx, "ai outfit cleanup: nothing to clean")
}
}
+44 -8
View File
@@ -67,13 +67,17 @@ func (s *AIRecommendService) Submit(ctx context.Context, userID int64, request *
// 1. 抢占活跃位:抢不到说明该用户已有任务在跑,幂等返回它。
acquired, runningTaskID, err := redisdao.AIRecommendTaskAcquire(ctx, userID, taskID, aiRecommendSlotTTL)
if err != nil {
slog.ErrorContext(ctx, "ai recommend submit: acquire slot failed", "user_id", userID, "error", err)
return nil, errcode.CacheError
}
if !acquired {
if runningTaskID == "" {
// 活跃位恰好在两次调用之间过期,让前端稍后重试即可。
slog.InfoContext(ctx, "ai recommend submit: slot expired between calls, ask retry", "user_id", userID)
return nil, errcode.TooManyRequests
}
slog.InfoContext(ctx, "ai recommend submit: task already running, return existing",
"user_id", userID, "task_id", runningTaskID)
return &dto.AIRecommendSubmitResponse{
TaskID: runningTaskID,
Status: AIRecommendTaskPending,
@@ -84,8 +88,10 @@ func (s *AIRecommendService) Submit(ctx context.Context, userID int64, request *
if qerr := consumeDailyQuota(ctx, userID, QuotaTypeAI, 1, "AI 推荐消耗"); qerr != nil {
s.releaseSlot(context.WithoutCancel(ctx), userID, taskID)
if qerr == errcode.QuotaInsufficient {
slog.InfoContext(ctx, "ai recommend submit: quota insufficient", "user_id", userID)
return nil, errcode.AIRecommendQuotaLimit
}
slog.ErrorContext(ctx, "ai recommend submit: consume quota failed", "user_id", userID, "error", qerr)
return nil, qerr
}
@@ -96,6 +102,8 @@ func (s *AIRecommendService) Submit(ctx context.Context, userID int64, request *
Status: AIRecommendTaskPending,
CreateTime: nowUnix(),
}); serr != nil {
slog.ErrorContext(ctx, "ai recommend submit: save task snapshot failed",
"user_id", userID, "task_id", taskID, "error", serr)
cleanupCtx := context.WithoutCancel(ctx)
refundDailyQuota(cleanupCtx, userID, QuotaTypeAI, 1)
s.releaseSlot(cleanupCtx, userID, taskID)
@@ -106,6 +114,8 @@ func (s *AIRecommendService) Submit(ctx context.Context, userID int64, request *
reqCopy := *request
go s.runTask(taskID, userID, &reqCopy)
slog.InfoContext(ctx, "ai recommend submitted", "user_id", userID, "task_id", taskID)
return &dto.AIRecommendSubmitResponse{
TaskID: taskID,
Status: AIRecommendTaskPending,
@@ -153,26 +163,39 @@ func (s *AIRecommendService) Save(ctx context.Context, userID int64, request *dt
outfit, err := s.outfitDao.OutfitGetByUUID(ctx, request.OutfitUUID)
if err != nil {
if isNotFound(err) {
slog.WarnContext(ctx, "ai recommend save: outfit not found",
"user_id", userID, "outfit_uuid", request.OutfitUUID)
return errcode.OutfitNotFound
}
slog.ErrorContext(ctx, "ai recommend save: query outfit failed",
"user_id", userID, "outfit_uuid", request.OutfitUUID, "error", err)
return errcode.DatabaseError
}
if outfit.UserID != userID {
slog.InfoContext(ctx, "ai recommend save: permission denied",
"user_id", userID, "outfit_uuid", request.OutfitUUID)
return errcode.OutfitNoPermission
}
// 仅待保存状态可保存;已正常则幂等返回。
if outfit.Status == OutfitStatusNormal {
slog.DebugContext(ctx, "ai recommend save: outfit already saved, skip",
"user_id", userID, "outfit_uuid", request.OutfitUUID)
return nil
}
if outfit.Status != OutfitStatusUnsaved {
slog.WarnContext(ctx, "ai recommend save: outfit status not saveable",
"user_id", userID, "outfit_uuid", request.OutfitUUID, "status", outfit.Status)
return errcode.OutfitUpdateFailed
}
if uerr := s.outfitDao.OutfitUpdateByUUID(ctx, request.OutfitUUID, map[string]any{
"status": OutfitStatusNormal,
}); uerr != nil {
slog.ErrorContext(ctx, "ai recommend save: update outfit failed",
"user_id", userID, "outfit_uuid", request.OutfitUUID, "error", uerr)
return errcode.OutfitUpdateFailed
}
slog.InfoContext(ctx, "ai recommend outfit saved", "user_id", userID, "outfit_uuid", request.OutfitUUID)
return nil
}
@@ -196,7 +219,7 @@ func (s *AIRecommendService) runTask(taskID string, userID int64, request *dto.A
s.releaseSlot(context.WithoutCancel(ctx), userID, taskID)
}()
out, bizErr := s.generate(ctx, userID, request)
out, bizErr := s.generate(ctx, taskID, userID, request)
if bizErr != nil {
s.finishFailed(ctx, taskID, userID, bizErr.Code(), formatBizErr(bizErr))
return
@@ -206,11 +229,11 @@ func (s *AIRecommendService) runTask(taskID string, userID int64, request *dto.A
TaskID: taskID,
UserID: userID,
Status: AIRecommendTaskSuccess,
Result: mustMarshal(out),
Result: mustMarshal(ctx, out),
CreateTime: nowUnix(),
}); serr != nil {
// 结果已落库为主,快照写失败只影响前端轮询展示,记录即可。
slog.ErrorContext(ctx, "save ai recommend task result failed",
// 结果已落库为主,快照写失败只影响前端轮询展示,降为 Warn。
slog.WarnContext(ctx, "save ai recommend task result failed",
"task_id", taskID, "user_id", userID, "error", serr)
}
}
@@ -249,13 +272,16 @@ func (s *AIRecommendService) releaseSlot(ctx context.Context, userID int64, task
}
// generate 执行一次完整的推荐生成:查衣橱 → 取运势 → 调大模型 → 校验 → 落库。
func (s *AIRecommendService) generate(ctx context.Context, userID int64, request *dto.AIRecommendCreateRequest) (*dto.AIRecommendCreateResponse, *errcode.Error) {
func (s *AIRecommendService) generate(ctx context.Context, taskID string, userID int64, request *dto.AIRecommendCreateRequest) (*dto.AIRecommendCreateResponse, *errcode.Error) {
// 1. 加载用户可用衣物(已成功处理)。
clothes, cerr := s.processedClothes(ctx, userID)
if cerr != nil {
slog.ErrorContext(ctx, "ai recommend generate: load clothes failed",
"user_id", userID, "task_id", taskID, "error", cerr)
return nil, errcode.DatabaseError
}
if len(clothes) == 0 {
slog.InfoContext(ctx, "ai recommend generate: no usable clothes", "user_id", userID, "task_id", taskID)
return nil, errcode.AIRecommendFailed.WithDetails("衣橱暂无可用衣物")
}
@@ -267,11 +293,13 @@ func (s *AIRecommendService) generate(ctx context.Context, userID int64, request
client := tokenhub.NewClient()
result, gerr := client.GenerateOutfitRecommendation(ctx, zodiac, fortune, clothes, request.Scene)
if gerr != nil {
slog.ErrorContext(ctx, "generate outfit recommendation failed", "user_id", userID, "error", gerr)
slog.ErrorContext(ctx, "generate outfit recommendation failed", "user_id", userID, "task_id", taskID, "error", gerr)
return nil, errcode.AIRecommendFailed.WithDetails("大模型调用失败")
}
result.ValidateClothesIDs(clothes)
if len(result.Items) == 0 {
slog.WarnContext(ctx, "ai recommend generate: no valid items after validation",
"user_id", userID, "task_id", taskID)
return nil, errcode.AIRecommendFailed.WithDetails("未生成有效搭配")
}
@@ -302,6 +330,8 @@ func (s *AIRecommendService) generate(ctx context.Context, userID int64, request
}
return tx.Create(&items).Error
}); err != nil {
slog.ErrorContext(ctx, "ai recommend generate: save outfit failed",
"user_id", userID, "task_id", taskID, "error", err)
return nil, errcode.OutfitCreateFailed
}
@@ -365,6 +395,9 @@ func (s *AIRecommendService) userFortune(ctx context.Context, userID int64) (str
zodiac := ""
if user, err := s.userDao.UserGetByID(ctx, userID); err == nil && user.BirthDay != 0 {
zodiac = zodiacByUnix(user.BirthDay)
} else if err != nil {
slog.WarnContext(ctx, "ai recommend generate: get user for fortune failed, degrade",
"user_id", userID, "error", err)
}
fortune := &tokenhub.FortuneItem{Zodiac: zodiac}
@@ -380,6 +413,9 @@ func (s *AIRecommendService) userFortune(ctx context.Context, userID int64) (str
LuckyNumber: h.LuckyNumber,
Description: h.DailyText,
}
} else if err != nil {
slog.WarnContext(ctx, "ai recommend generate: get horoscope failed, degrade",
"user_id", userID, "zodiac", zodiac, "error", err)
}
}
return zodiac, fortune
@@ -411,10 +447,10 @@ func formatBizErr(e *errcode.Error) string {
}
// mustMarshal 序列化任务结果;失败时返回 nil(任务仍标记成功,前端可去「我的搭配」查看)。
func mustMarshal(v any) json.RawMessage {
func mustMarshal(ctx context.Context, v any) json.RawMessage {
data, err := json.Marshal(v)
if err != nil {
slog.Error("marshal ai recommend task result failed", "error", err)
slog.ErrorContext(ctx, "marshal ai recommend task result failed", "error", err)
return nil
}
return data
+60 -18
View File
@@ -41,10 +41,19 @@ func runBenefitCompensation() {
lockKey := "ztcloset:lock:benefit:compensation"
lockVal := genUUID()
ok, lerr := redisdao.TryLock(ctx, lockKey, lockVal, time.Minute)
if lerr != nil || !ok {
if lerr != nil {
slog.ErrorContext(ctx, "benefit compensation: acquire lock failed", "error", lerr)
return
}
defer redisdao.Unlock(context.Background(), lockKey, lockVal)
if !ok {
slog.DebugContext(ctx, "benefit compensation: another instance running, skip")
return
}
defer func() {
if uerr := redisdao.Unlock(context.Background(), lockKey, lockVal); uerr != nil {
slog.WarnContext(ctx, "benefit compensation: release lock failed", "error", uerr)
}
}()
taskDao := mysqldao.NewBenefitGrantTaskDao()
s := NewOrderService()
@@ -58,7 +67,12 @@ func runBenefitCompensation() {
}
// 恢复超时的「发放中」任务(认领后崩溃的场景),使其回到待发放继续重试。
_, _ = taskDao.BenefitGrantTaskRecoverStale(ctx, nowUnix()-int64((10*time.Minute).Seconds()))
recovered, rerr := taskDao.BenefitGrantTaskRecoverStale(ctx, nowUnix()-int64((10*time.Minute).Seconds()))
if rerr != nil {
slog.ErrorContext(ctx, "benefit compensation: recover stale grant tasks failed", "error", rerr)
} else if recovered > 0 {
slog.InfoContext(ctx, "benefit compensation: recovered stale grant tasks", "count", recovered)
}
tasks, err := taskDao.BenefitGrantTaskListPending(ctx, 100)
if err != nil {
@@ -70,7 +84,9 @@ func runBenefitCompensation() {
}
revokeDao := mysqldao.NewBenefitRevokeTaskDao()
_ = revokeDao.RecoverStale(ctx, nowUnix()-int64((10*time.Minute).Seconds()))
if rerr := revokeDao.RecoverStale(ctx, nowUnix()-int64((10*time.Minute).Seconds())); rerr != nil {
slog.ErrorContext(ctx, "benefit compensation: recover stale revoke tasks failed", "error", rerr)
}
if revokeTasks, err := revokeDao.ListPending(ctx, 100); err == nil {
for i := range revokeTasks {
s.executeRevokeTask(ctx, &revokeTasks[i])
@@ -86,7 +102,10 @@ func (s *OrderService) retryBenefitTask(ctx context.Context, task *mysqlmodel.Be
// 重试次数超限,标记失败并告警,等待人工介入。
if task.RetryCount >= task.MaxRetry {
_ = taskDao.BenefitGrantTaskUpdateByID(ctx, task.ID, map[string]any{"status": BenefitTaskFailed})
if uerr := taskDao.BenefitGrantTaskUpdateByID(ctx, task.ID, map[string]any{"status": BenefitTaskFailed}); uerr != nil {
slog.ErrorContext(ctx, "benefit compensation: mark exhausted task failed status failed",
"order_no", task.OrderNo, "benefit_type", task.BenefitType, "error", uerr)
}
slog.ErrorContext(ctx, "benefit grant task exhausted retries",
"order_no", task.OrderNo, "benefit_type", task.BenefitType, "retry_count", task.RetryCount)
return
@@ -94,7 +113,12 @@ func (s *OrderService) retryBenefitTask(ctx context.Context, task *mysqlmodel.Be
// 幂等判断:若发放流水已存在(上次发放成功但未置 success 的崩溃窗口),直接置成功。
if granted, err := s.benefitAlreadyGranted(ctx, task); err == nil && granted {
_ = taskDao.BenefitGrantTaskUpdateByID(ctx, task.ID, map[string]any{"status": BenefitTaskSuccess})
if uerr := taskDao.BenefitGrantTaskUpdateByID(ctx, task.ID, map[string]any{"status": BenefitTaskSuccess}); uerr != nil {
slog.ErrorContext(ctx, "benefit compensation: mark already-granted task success failed",
"order_no", task.OrderNo, "benefit_type", task.BenefitType, "error", uerr)
}
slog.InfoContext(ctx, "benefit already granted, task marked success",
"order_no", task.OrderNo, "benefit_type", task.BenefitType)
return
} else if err != nil {
slog.ErrorContext(ctx, "check benefit granted failed",
@@ -128,9 +152,14 @@ func (s *OrderService) benefitAlreadyGranted(ctx context.Context, task *mysqlmod
func (s *OrderService) executeRevokeTaskByOrder(ctx context.Context, orderNo string) {
task, err := mysqldao.NewBenefitRevokeTaskDao().GetByOrderNo(ctx, orderNo)
if err == nil {
s.executeRevokeTask(ctx, task)
if err != nil {
if isNotFound(err) {
return // 无回收任务(如未退款订单),属正常路径。
}
slog.ErrorContext(ctx, "get benefit revoke task failed", "order_no", orderNo, "error", err)
return
}
s.executeRevokeTask(ctx, task)
}
func (s *OrderService) executeRevokeTask(ctx context.Context, task *mysqlmodel.BenefitRevokeTask) {
@@ -142,17 +171,30 @@ func (s *OrderService) executeRevokeTask(ctx context.Context, task *mysqlmodel.B
slog.ErrorContext(ctx, "benefit revoke task exceeded normal retry threshold; continuing for eventual consistency", "order_no", task.OrderNo, "retry_count", task.RetryCount)
}
claimed, err := dao.Claim(ctx, task.ID)
if err != nil || !claimed {
return
}
order, err := s.orderDao.OrderGetByOrderNo(ctx, task.OrderNo)
if err == nil {
err = s.revokeOrderBenefits(ctx, order)
}
if err != nil {
_ = dao.Update(ctx, task.ID, map[string]any{"status": BenefitTaskPending, "retry_count": gorm.Expr("retry_count + 1"), "last_error": err.Error()})
slog.ErrorContext(ctx, "revoke refunded order benefits failed", "order_no", task.OrderNo, "error", err)
slog.ErrorContext(ctx, "claim benefit revoke task failed", "order_no", task.OrderNo, "error", err)
return
}
_ = dao.Update(ctx, task.ID, map[string]any{"status": BenefitTaskSuccess, "last_error": ""})
if !claimed {
slog.DebugContext(ctx, "benefit revoke task already claimed, skip", "order_no", task.OrderNo)
return
}
order, oerr := s.orderDao.OrderGetByOrderNo(ctx, task.OrderNo)
if oerr != nil {
_ = dao.Update(ctx, task.ID, map[string]any{"status": BenefitTaskPending, "retry_count": gorm.Expr("retry_count + 1"), "last_error": oerr.Error()})
slog.ErrorContext(ctx, "revoke refunded order benefits failed: get order failed",
"order_no", task.OrderNo, "retry_count", task.RetryCount, "error", oerr)
return
}
if rerr := s.revokeOrderBenefits(ctx, order); rerr != nil {
_ = dao.Update(ctx, task.ID, map[string]any{"status": BenefitTaskPending, "retry_count": gorm.Expr("retry_count + 1"), "last_error": rerr.Error()})
slog.ErrorContext(ctx, "revoke refunded order benefits failed",
"order_no", task.OrderNo, "retry_count", task.RetryCount, "error", rerr)
return
}
if uerr := dao.Update(ctx, task.ID, map[string]any{"status": BenefitTaskSuccess, "last_error": ""}); uerr != nil {
slog.ErrorContext(ctx, "mark benefit revoke task success failed", "order_no", task.OrderNo, "error", uerr)
return
}
slog.InfoContext(ctx, "refunded order benefits revoked", "order_no", task.OrderNo, "user_id", order.UserID)
}
+31 -3
View File
@@ -61,6 +61,7 @@ func (s *ClosetShareService) Create(ctx context.Context, userID int64, request *
case ShareTypeSnapshot:
ids, err := s.allUserClothesIDs(ctx, userID)
if err != nil {
slog.ErrorContext(ctx, "closet share create: list user clothes failed", "user_id", userID, "error", err)
return nil, errcode.DatabaseError
}
clothesIDs = ids
@@ -70,6 +71,11 @@ func (s *ClosetShareService) Create(ctx context.Context, userID int64, request *
// 2. 消耗衣柜分享每日额度。
if qerr := consumeDailyQuota(ctx, userID, QuotaTypeClosetShare, 1, "衣柜分享消耗"); qerr != nil {
if qerr == errcode.QuotaInsufficient {
slog.InfoContext(ctx, "closet share create: quota insufficient", "user_id", userID)
} else {
slog.ErrorContext(ctx, "closet share create: consume quota failed", "user_id", userID, "error", qerr)
}
return nil, qerr
}
@@ -89,6 +95,8 @@ func (s *ClosetShareService) Create(ctx context.Context, userID int64, request *
}
return nil
}); err != nil {
slog.ErrorContext(ctx, "closet share create: transaction failed",
"user_id", userID, "share_uuid", shareUUID, "error", err)
refundDailyQuota(ctx, userID, QuotaTypeClosetShare, 1)
return nil, errcode.DatabaseError
}
@@ -99,9 +107,13 @@ func (s *ClosetShareService) Create(ctx context.Context, userID int64, request *
AuditResult: AuditResultPass,
AuditTime: nowUnix(),
}); err != nil {
slog.WarnContext(ctx, "write closet audit log failed", "share_id", share.ID, "error", err)
slog.WarnContext(ctx, "write closet audit log failed",
"share_id", share.ID, "share_uuid", shareUUID, "error", err)
}
slog.InfoContext(ctx, "closet share created",
"user_id", userID, "share_uuid", shareUUID, "share_type", request.ShareType, "clothes_count", len(clothesIDs))
return &dto.ClosetShareCreateResponse{ShareUUID: shareUUID}, nil
}
@@ -115,6 +127,7 @@ func (s *ClosetShareService) Detail(ctx context.Context, userID int64, shareUUID
if isNotFound(err) {
return nil, errcode.ShareNotFound
}
slog.ErrorContext(ctx, "closet share detail: query share failed", "share_uuid", shareUUID, "error", err)
return nil, errcode.DatabaseError
}
@@ -129,7 +142,9 @@ func (s *ClosetShareService) Detail(ctx context.Context, userID int64, shareUUID
}
// 时间过期校验。
if share.ExpireTime > 0 && nowUnix() > share.ExpireTime {
_ = s.shareDao.ClosetShareUpdateStatus(ctx, shareUUID, ShareStatusExpired)
if uerr := s.shareDao.ClosetShareUpdateStatus(ctx, shareUUID, ShareStatusExpired); uerr != nil {
slog.WarnContext(ctx, "closet share detail: mark expired failed", "share_uuid", shareUUID, "error", uerr)
}
return nil, errcode.ShareExpired
}
@@ -137,7 +152,9 @@ func (s *ClosetShareService) Detail(ctx context.Context, userID int64, shareUUID
// 访问计数(访客计入)。
if !isOwner {
_, _ = redisdao.ClosetShareViewIncr(ctx, shareUUID)
if _, verr := redisdao.ClosetShareViewIncr(ctx, shareUUID); verr != nil {
slog.DebugContext(ctx, "closet share detail: incr view count failed", "share_uuid", shareUUID, "error", verr)
}
}
// 加载分享的衣物。
@@ -170,6 +187,9 @@ func (s *ClosetShareService) Detail(ctx context.Context, userID int64, shareUUID
if owner, oerr := s.userDao.UserGetByID(ctx, share.UserID); oerr == nil {
ownerName = owner.Username
ownerAvatar = upload.AvatarURL(owner.Avatar)
} else {
slog.DebugContext(ctx, "closet share detail: get owner info failed",
"share_uuid", shareUUID, "owner_id", share.UserID, "error", oerr)
}
return &dto.ClosetShareDetailResponse{
@@ -294,14 +314,21 @@ func (s *ClosetShareService) Cancel(ctx context.Context, userID int64, request *
if isNotFound(err) {
return nil, errcode.ShareNotFound
}
slog.ErrorContext(ctx, "closet share cancel: query share failed",
"share_uuid", request.ShareUUID, "user_id", userID, "error", err)
return nil, errcode.DatabaseError
}
if share.UserID != userID {
slog.InfoContext(ctx, "closet share cancel: permission denied",
"share_uuid", request.ShareUUID, "user_id", userID, "owner_id", share.UserID)
return nil, errcode.Forbidden
}
if err := s.shareDao.ClosetShareUpdateStatus(ctx, request.ShareUUID, ShareStatusCancelled); err != nil {
slog.ErrorContext(ctx, "closet share cancel: update status failed",
"share_uuid", request.ShareUUID, "user_id", userID, "error", err)
return nil, errcode.DatabaseError
}
slog.InfoContext(ctx, "closet share cancelled", "share_uuid", request.ShareUUID, "user_id", userID)
return &dto.CollectResponse{}, nil
}
@@ -321,6 +348,7 @@ func (s *ClosetShareService) resolveOwnedClothesIDs(ctx context.Context, userID
clothesList, err := s.clothesDao.ClothesGetByUUIDs(ctx, dedup)
if err != nil {
slog.ErrorContext(ctx, "closet share: query clothes failed", "user_id", userID, "error", err)
return nil, errcode.DatabaseError
}
byUUID := make(map[string]mysqlmodel.Clothes, len(clothesList))
+78 -18
View File
@@ -53,36 +53,47 @@ func (s *ClothesService) Upload(ctx context.Context, userID int64, fileHeader *m
defer span.End()
if fileHeader == nil || fileHeader.Size == 0 {
slog.DebugContext(ctx, "clothes upload: empty file", "user_id", userID)
return nil, errcode.UploadFileEmpty
}
// 1. 校验扩展名与大小。
ext := upload.FileGetExt(fileHeader.Filename)
if !upload.FileCheckAllowExt(ext, global.UploadSetting.AllowedExtensions) {
slog.DebugContext(ctx, "clothes upload: extension not allowed", "user_id", userID, "ext", ext)
return nil, errcode.UploadFileExtNotAllow
}
if !upload.FileCheckMaxSize(fileHeader.Size, int64(global.UploadSetting.MaxSizeMB)) {
slog.DebugContext(ctx, "clothes upload: file too large", "user_id", userID, "size", fileHeader.Size)
return nil, errcode.UploadFileTooLarge
}
// 2. 读取原图字节(必须同步读取,请求返回后 multipart 临时文件会被清理)。
src, oerr := fileHeader.Open()
if oerr != nil {
slog.ErrorContext(ctx, "clothes upload: open file failed", "user_id", userID, "error", oerr)
return nil, errcode.UploadFileSaveFailed
}
imageData, rerr := io.ReadAll(src)
_ = src.Close()
if rerr != nil {
slog.ErrorContext(ctx, "clothes upload: read file failed", "user_id", userID, "error", rerr)
return nil, errcode.UploadFileSaveFailed
}
// 3. 校验文件内容为真实图片(magic bytes),防止伪造扩展名上传恶意文件。
if !upload.FileCheckImageMagic(imageData) {
slog.WarnContext(ctx, "clothes upload: magic bytes check failed", "user_id", userID, "size", fileHeader.Size)
return nil, errcode.UploadFileExtNotAllow
}
// 4. 扣减 COS 上传额度(权威扣减,先到期凭证先用,再扣免费额度)。
if qerr := consumeCosQuota(ctx, userID, 1, 0, "上传衣物消耗"); qerr != nil {
if qerr == errcode.QuotaInsufficient {
slog.InfoContext(ctx, "clothes upload: quota insufficient", "user_id", userID)
} else {
slog.ErrorContext(ctx, "clothes upload: consume quota failed", "user_id", userID, "error", qerr)
}
return nil, qerr
}
@@ -91,6 +102,7 @@ func (s *ClothesService) Upload(ctx context.Context, userID int64, fileHeader *m
originalName := clothesUUID + ext
originalPath, serr := upload.FileSaveTmpBytes(ctx, imageData, originalName, fmt.Sprintf("pending/%d", userID))
if serr != nil {
slog.ErrorContext(ctx, "clothes upload: save original image failed", "user_id", userID, "error", serr)
refundCosQuota(ctx, userID, 1, 0, "原图持久化失败回退")
return nil, errcode.UploadFileSaveFailed
}
@@ -102,6 +114,8 @@ func (s *ClothesService) Upload(ctx context.Context, userID int64, fileHeader *m
OriginalImagePath: originalPath,
}
if err := s.clothesDao.ClothesCreate(ctx, clothes); err != nil {
slog.ErrorContext(ctx, "clothes upload: create clothes record failed",
"user_id", userID, "clothes_uuid", clothesUUID, "error", err)
_ = os.Remove(originalPath)
// 建记录失败,回退已扣额度。
refundCosQuota(ctx, userID, 1, 0, "上传失败回退")
@@ -111,6 +125,8 @@ func (s *ClothesService) Upload(ctx context.Context, userID int64, fileHeader *m
// 6. 异步抠图 + 分类。
go s.processImageAsync(ctx, userID, clothesUUID, imageData, originalPath)
slog.InfoContext(ctx, "clothes uploaded", "user_id", userID, "clothes_uuid", clothesUUID)
return &dto.ClothesUploadResponse{
ClothesUUID: clothesUUID,
ImageProcessStatus: clothes.ImageProcessStatus,
@@ -140,11 +156,13 @@ func (s *ClothesService) processImageAsync(parentCtx context.Context, userID int
dao := s.clothesDao
// 1. 抠图。
_ = dao.ClothesUpdateByUUID(ctx, clothesUUID, map[string]any{"image_process_status": ImageProcessMatting})
if uerr := dao.ClothesUpdateByUUID(ctx, clothesUUID, map[string]any{"image_process_status": ImageProcessMatting}); uerr != nil {
slog.DebugContext(ctx, "clothes process: mark matting status failed", "clothes_uuid", clothesUUID, "error", uerr)
}
mattingClient := matting.NewClient()
processedData, err := retryCall(ctx, global.MattingModelSetting.MaxRetry, "matting", func() ([]byte, error) {
return mattingClient.Matting(ctx, imageData)
})
}, "clothes_uuid", clothesUUID)
if err != nil {
slog.ErrorContext(ctx, "matting failed", "clothes_uuid", clothesUUID, "error", err)
s.markProcessFailed(ctx, userID, clothesUUID, "图片抠图失败")
@@ -156,7 +174,7 @@ func (s *ClothesService) processImageAsync(parentCtx context.Context, userID int
// 获取用户 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)
slog.ErrorContext(ctx, "get user for cos key failed", "clothes_uuid", clothesUUID, "user_id", userID, "error", gerr)
s.markProcessFailed(ctx, userID, clothesUUID, "获取用户信息失败")
return
}
@@ -186,15 +204,19 @@ func (s *ClothesService) processImageAsync(parentCtx context.Context, userID int
}
// 3. 分类。
_ = dao.ClothesUpdateByUUID(ctx, clothesUUID, map[string]any{"image_process_status": ImageProcessClassifying})
if uerr := dao.ClothesUpdateByUUID(ctx, clothesUUID, map[string]any{"image_process_status": ImageProcessClassifying}); uerr != nil {
slog.DebugContext(ctx, "clothes process: mark classifying status failed", "clothes_uuid", clothesUUID, "error", uerr)
}
classifyClient := classification.NewClient()
result, cerr := retryCall(ctx, global.ClassificationModelSetting.MaxRetry, "classify", func() (*classification.ClassifyResult, error) {
return classifyClient.Classify(ctx, processedData)
})
}, "clothes_uuid", clothesUUID)
if cerr != nil {
slog.ErrorContext(ctx, "classify failed", "clothes_uuid", clothesUUID, "error", cerr)
// 分类失败仍保留已抠图结果,但条件标记失败并回退以便用户手动补全。
_ = dao.ClothesUpdateByUUID(ctx, clothesUUID, map[string]any{"processed_image": cosKey})
if uerr := dao.ClothesUpdateByUUID(ctx, clothesUUID, map[string]any{"processed_image": cosKey}); uerr != nil {
slog.WarnContext(ctx, "clothes process: keep processed image failed", "clothes_uuid", clothesUUID, "error", uerr)
}
s.markProcessFailed(ctx, userID, clothesUUID, "图片分类失败")
return
}
@@ -260,7 +282,7 @@ func buildDefaultClothesName(result *classification.ClassifyResult) string {
// 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) {
func retryCall[T any](ctx context.Context, maxRetry int, name string, fn func() (T, error), attrs ...any) (T, error) {
var zero T
var err error
for attempt := 0; attempt <= maxRetry; attempt++ {
@@ -272,7 +294,8 @@ func retryCall[T any](ctx context.Context, maxRetry int, name string, fn func()
if attempt >= maxRetry {
break
}
slog.WarnContext(ctx, "model call failed, retrying", "name", name, "attempt", attempt+1, "error", e)
args := append([]any{"name", name, "attempt", attempt + 1, "error", e}, attrs...)
slog.WarnContext(ctx, "model call failed, retrying", args...)
select {
case <-ctx.Done():
return zero, ctx.Err()
@@ -287,10 +310,12 @@ func retryCall[T any](ctx context.Context, maxRetry int, name string, fn func()
// 两路径串行化保证「处理失败回退」与「删除回退」恰好执行一次,消除漏退/双退窗口。
func (s *ClothesService) markProcessFailed(ctx context.Context, userID int64, clothesUUID, reason string) {
// 调用方的三分钟处理 context 可能已超时;清理与额度回退必须使用独立短 context。
cleanupCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
// 用 WithoutCancel 派生:既不受调用方取消影响,又保留 ctx 中已有的 span/trace 信息。
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 20*time.Second)
defer cancel()
vipExpire := getUserVIPExpire(cleanupCtx, userID)
var originalPath string
failPhase := ""
err := global.MysqlEngine.WithContext(cleanupCtx).Transaction(func(tx *gorm.DB) error {
var c mysqlmodel.Clothes
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
@@ -312,16 +337,24 @@ func (s *ClothesService) markProcessFailed(ctx context.Context, userID int64, cl
"fail_reason": reason,
"original_image_path": "",
}).Error; err != nil {
failPhase = "mark_failed"
return err
}
return refundCosQuotaTx(tx, userID, vipExpire, 1, 0, "衣物处理失败回退")
if err := refundCosQuotaTx(cleanupCtx, tx, userID, vipExpire, 1, 0, "衣物处理失败回退"); err != nil {
failPhase = "refund_quota"
return err
}
return nil
})
if err != nil {
slog.ErrorContext(ctx, "mark process failed transaction error", "clothes_uuid", clothesUUID, "error", err)
slog.ErrorContext(ctx, "mark process failed transaction error",
"clothes_uuid", clothesUUID, "user_id", userID, "phase", failPhase, "error", err)
return
}
if originalPath != "" {
_ = os.Remove(originalPath)
if rerr := os.Remove(originalPath); rerr != nil {
slog.DebugContext(ctx, "clothes process: remove original image failed", "clothes_uuid", clothesUUID, "error", rerr)
}
}
}
@@ -452,9 +485,13 @@ func (s *ClothesService) DeleteByUUID(ctx context.Context, userID int64, request
// 也与接口文档约定的 50001「衣服已被搭配使用,无法删除」保持一致。
refCount, rerr := s.clothesDao.ClothesReferencedCount(ctx, request.ClothesUUID)
if rerr != nil {
slog.ErrorContext(ctx, "delete clothes: count references failed",
"clothes_uuid", request.ClothesUUID, "user_id", userID, "error", rerr)
return nil, errcode.DatabaseError
}
if refCount > 0 {
slog.InfoContext(ctx, "delete clothes: clothes in use",
"clothes_uuid", request.ClothesUUID, "user_id", userID)
return nil, errcode.ClothesInUse
}
@@ -480,18 +517,23 @@ func (s *ClothesService) DeleteByUUID(ctx context.Context, userID int64, request
return nil
}
// 同事务回退额度(优先免费额度,不足回退凭证)。
return refundCosQuotaTx(tx, userID, vipExpire, 1, 0, "删除衣物回退")
return refundCosQuotaTx(ctx, tx, userID, vipExpire, 1, 0, "删除衣物回退")
})
if err != nil {
if isNotFound(err) {
return nil, errcode.ClothesNotFound
}
slog.ErrorContext(ctx, "delete clothes: transaction failed",
"clothes_uuid", request.ClothesUUID, "user_id", userID, "error", err)
return nil, errcode.ClothesDeleteFailed
}
// 事务提交成功后清理 COS 图片(尽力而为,失败仅记录日志,不影响删除结果)。
if originalImagePath != "" {
_ = os.Remove(originalImagePath)
if rerr := os.Remove(originalImagePath); rerr != nil {
slog.DebugContext(ctx, "delete clothes: remove original image failed",
"clothes_uuid", request.ClothesUUID, "error", rerr)
}
}
if processedImage != "" {
if derr := upload.FileDeleteCos(ctx, processedImage); derr != nil {
@@ -499,6 +541,8 @@ func (s *ClothesService) DeleteByUUID(ctx context.Context, userID int64, request
}
}
slog.InfoContext(ctx, "clothes deleted", "clothes_uuid", request.ClothesUUID, "user_id", userID)
return &dto.ClothesDeleteByUUIDResponse{}, nil
}
@@ -604,17 +648,33 @@ func runClothesProcessingRecovery() {
slog.ErrorContext(ctx, "list stale clothes processing tasks failed", "error", err)
return
}
if len(list) == 0 {
slog.DebugContext(ctx, "clothes recovery: no stale processing tasks")
return
}
service := NewClothesService()
recovered := 0
for i := range list {
claimed, err := dao.ClothesClaimStaleProcessing(ctx, list[i].ClothesUUID, before)
if err != nil || !claimed {
claimed, cerr := dao.ClothesClaimStaleProcessing(ctx, list[i].ClothesUUID, before)
if cerr != nil {
slog.ErrorContext(ctx, "clothes recovery: claim stale task failed",
"clothes_uuid", list[i].ClothesUUID, "error", cerr)
continue
}
imageData, err := os.ReadFile(list[i].OriginalImagePath)
if err != nil || !upload.FileCheckImageMagic(imageData) {
if !claimed {
slog.DebugContext(ctx, "clothes recovery: task already claimed by another instance",
"clothes_uuid", list[i].ClothesUUID)
continue
}
imageData, rerr := os.ReadFile(list[i].OriginalImagePath)
if rerr != nil || !upload.FileCheckImageMagic(imageData) {
slog.WarnContext(ctx, "clothes recovery: original image missing or corrupted",
"clothes_uuid", list[i].ClothesUUID, "user_id", list[i].UserID, "error", rerr)
service.markProcessFailed(ctx, list[i].UserID, list[i].ClothesUUID, "原图丢失或损坏,任务无法恢复")
continue
}
recovered++
go service.processImageAsync(context.Background(), list[i].UserID, list[i].ClothesUUID, imageData, list[i].OriginalImagePath)
}
slog.InfoContext(ctx, "clothes recovery: stale tasks recovered", "scanned", len(list), "recovered", recovered)
}
+18 -2
View File
@@ -40,6 +40,7 @@ func (s *CollectService) Collect(ctx context.Context, userID int64, request *dto
if isNotFound(err) {
return nil, errcode.TargetNotFound
}
slog.ErrorContext(ctx, "collect: query outfit failed", "outfit_uuid", request.TargetUUID, "user_id", userID, "error", err)
return nil, errcode.DatabaseError
}
// 非本人仅可收藏公开可见的穿搭。
@@ -54,6 +55,7 @@ func (s *CollectService) Collect(ctx context.Context, userID int64, request *dto
if isNotFound(err) {
notExist = true
} else {
slog.ErrorContext(ctx, "collect: query record failed", "target_id", targetID, "user_id", userID, "error", err)
return nil, errcode.DatabaseError
}
}
@@ -66,16 +68,24 @@ func (s *CollectService) Collect(ctx context.Context, userID int64, request *dto
UserID: userID,
Status: InteractStatusOn,
}); cerr != nil {
slog.ErrorContext(ctx, "collect: upsert record failed", "target_id", targetID, "user_id", userID, "error", cerr)
return nil, errcode.CollectFailed
}
_ = redisdao.CollectAdd(ctx, CollectTypeOutfit, targetID, userID)
if rerr := redisdao.CollectAdd(ctx, CollectTypeOutfit, targetID, userID); rerr != nil {
slog.WarnContext(ctx, "collect: redis add failed, counter may undercount",
"target_id", targetID, "user_id", userID, "error", rerr)
}
case ActionCancel: // 取消收藏
if !notExist && existing.Status == InteractStatusOn {
if uerr := s.collectDao.CollectUpdateStatus(ctx, existing.ID, InteractStatusOff); uerr != nil {
slog.ErrorContext(ctx, "collect: update status failed", "target_id", targetID, "user_id", userID, "error", uerr)
return nil, errcode.CollectFailed
}
_ = redisdao.CollectRemove(ctx, CollectTypeOutfit, targetID, userID)
if rerr := redisdao.CollectRemove(ctx, CollectTypeOutfit, targetID, userID); rerr != nil {
slog.WarnContext(ctx, "collect: redis remove failed, counter may overcount",
"target_id", targetID, "user_id", userID, "error", rerr)
}
}
}
@@ -131,6 +141,7 @@ func (s *CollectService) GetByTarget(ctx context.Context, userID int64, request
if isNotFound(err) {
return nil, errcode.TargetNotFound
}
slog.ErrorContext(ctx, "collect: query outfit for status failed", "outfit_uuid", request.TargetUUID, "user_id", userID, "error", err)
return nil, errcode.DatabaseError
}
// 非本人仅可查询公开可见穿搭的收藏状态。
@@ -150,6 +161,9 @@ func (s *CollectService) GetByTarget(ctx context.Context, userID int64, request
"target_id", targetID, "user_id", userID, "error", derr)
if v, rerr := redisdao.CollectIsMember(ctx, CollectTypeOutfit, targetID, userID); rerr == nil {
isCollected = v
} else {
slog.DebugContext(ctx, "collect: redis fallback read failed",
"target_id", targetID, "user_id", userID, "error", rerr)
}
}
@@ -160,6 +174,8 @@ func (s *CollectService) GetByTarget(ctx context.Context, userID int64, request
"target_id", targetID, "error", derr)
if rcnt, rerr := redisdao.CollectCount(ctx, CollectTypeOutfit, targetID); rerr == nil {
count = rcnt
} else {
slog.DebugContext(ctx, "collect: redis fallback count failed", "target_id", targetID, "error", rerr)
}
}
+7 -4
View File
@@ -16,7 +16,7 @@ const (
VIPTypeNone int8 = 1 // 非会员
VIPTypeMonthly int8 = 2 // 月度会员
VIPTypeYearly int8 = 3 // 年度会员
VIPTypeTrial int8 = 4 // 体验会员(新用户注册赠送,天数由 UserDefaultQuota.GiftVIPDay 配置)
VIPTypeTrial int8 = 4 // 体验会员(新用户注册赠送,天数由 NewUserGift.GiftVIPDay 配置)
)
// ===== 配额 Quota =====
@@ -27,9 +27,10 @@ const (
QuotaTypeClosetShare int8 = 4 // 衣柜分享
// 额度凭证来源(quota_grants.source)
QuotaGrantSourceMember int8 = 1 // 会员包(跟随 VIP 有效期)
QuotaGrantSourceMember int8 = 1 // 会员包(跟随 VIP 有效期,续期自动延长)
QuotaGrantSourceAddon int8 = 2 // 加量包(独立有效期)
QuotaGrantSourceAdReward int8 = 3 // 广告激励(当天有效)
QuotaGrantSourceTrial int8 = 4 // 体验会员赠送(新用户注册;固定有效期=体验期结束,不随续费延长)
// ChangeTarget 调整额度类型
QuotaChangeTargetAdjust int8 = 1 // 配额调整
@@ -42,6 +43,7 @@ const (
QuotaChangeTypeRefund int8 = 4 // 删除回退
QuotaChangeTypeManual int8 = 5 // 人工调整
QuotaChangeTypeRefundRevoke int8 = 6 // 退款回收
QuotaChangeTypeExpire int8 = 7 // 到期失效(固定有效期的额度凭证过期,由定时任务补记)
)
// 新用户默认免费额度(内置兜底值)。
@@ -55,8 +57,9 @@ const (
)
// 新用户注册赠送体验会员的内置兜底天数。
// 优先使用 UserDefaultQuota.GiftVIPDay 的配置值(配置为 0 时生效,表示不赠送);
// 仅在该项未配置(指针为 nil)或配置为负数时回落此常量。
// 优先使用 NewUserGift.GiftVIPDay 的配置值(配置为 0 时生效,表示不赠送);
// 该项未配置时回落旧的 UserDefaultQuota.GiftVIPDay(兼容既有配置);
// 两者都未配置(指针为 nil)或配置为负数时回落此常量。
const DefaultVipDays int64 = 3
// ===== 衣物 Clothes 图片处理状态 =====
+17 -2
View File
@@ -3,6 +3,7 @@ package service
import (
"context"
"encoding/json"
"log/slog"
"time"
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
@@ -36,11 +37,14 @@ func (s *HoroscopeService) GetToday(ctx context.Context, userID int64) (*dto.Hor
user, err := s.userDao.UserGetByID(ctx, userID)
if err != nil {
if isNotFound(err) {
slog.WarnContext(ctx, "horoscope: user not found", "user_id", userID)
return nil, errcode.UserNotFound
}
slog.ErrorContext(ctx, "horoscope: query user failed", "user_id", userID, "error", err)
return nil, errcode.DatabaseError
}
if user.BirthDay == 0 {
slog.InfoContext(ctx, "horoscope: birth day missing", "user_id", userID)
return nil, errcode.InvalidParams.WithDetails("请先完善生日信息")
}
@@ -50,9 +54,14 @@ func (s *HoroscopeService) GetToday(ctx context.Context, userID int64) (*dto.Hor
// 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 {
if uerr := json.Unmarshal(data, &response); uerr == nil {
return &response, nil
} else {
slog.DebugContext(ctx, "horoscope: unmarshal cached data failed, fallback to db",
"zodiac", zodiac, "error", uerr)
}
} else if cerr != nil {
slog.DebugContext(ctx, "horoscope: cache read failed, fallback to db", "zodiac", zodiac, "error", cerr)
}
// 2. 回落 MySQL。
@@ -60,9 +69,11 @@ func (s *HoroscopeService) GetToday(ctx context.Context, userID int64) (*dto.Hor
if derr != nil {
if isNotFound(derr) {
// 今日运势尚未生成,异步触发兜底生成,本次返回未生成。
slog.InfoContext(ctx, "horoscope: today data missing, trigger fallback generation", "zodiac", zodiac)
tokenhub.EnsureTodayFortunes()
return nil, errcode.HoroscopeNotFound
}
slog.ErrorContext(ctx, "horoscope: query db failed", "zodiac", zodiac, "error", derr)
return nil, errcode.DatabaseError
}
@@ -70,7 +81,11 @@ func (s *HoroscopeService) GetToday(ctx context.Context, userID int64) (*dto.Hor
// 回填缓存,TTL 至次日凌晨。
if data, merr := json.Marshal(response); merr == nil {
_ = redisdao.HoroscopeSet(ctx, zodiac, statDate, data, ttlUntilNextMidnight())
if serr := redisdao.HoroscopeSet(ctx, zodiac, statDate, data, ttlUntilNextMidnight()); serr != nil {
slog.DebugContext(ctx, "horoscope: cache backfill failed", "zodiac", zodiac, "error", serr)
}
} else {
slog.DebugContext(ctx, "horoscope: marshal response failed", "zodiac", zodiac, "error", merr)
}
return &response, nil
+15 -2
View File
@@ -43,6 +43,7 @@ func (s *LikeService) LikeCreate(ctx context.Context, userID int64, request *dto
if isNotFound(err) {
notExist = true
} else {
slog.ErrorContext(ctx, "like: query record failed", "target_id", targetID, "user_id", userID, "error", err)
return nil, errcode.DatabaseError
}
}
@@ -55,9 +56,13 @@ func (s *LikeService) LikeCreate(ctx context.Context, userID int64, request *dto
UserID: userID,
Status: InteractStatusOn,
}); cerr != nil {
slog.ErrorContext(ctx, "like: upsert record failed", "target_id", targetID, "user_id", userID, "error", cerr)
return nil, errcode.LikeFailed
}
_ = redisdao.LikeAdd(ctx, LikeTypeOutfit, targetID, userID)
if rerr := redisdao.LikeAdd(ctx, LikeTypeOutfit, targetID, userID); rerr != nil {
slog.WarnContext(ctx, "like: redis add failed, counter may undercount",
"target_id", targetID, "user_id", userID, "error", rerr)
}
// 仅在「从取消/无记录变为点赞」时发通知,避免并发/重复点赞重复通知。
wasLiked := !notExist && existing.Status == InteractStatusOn
@@ -68,9 +73,13 @@ func (s *LikeService) LikeCreate(ctx context.Context, userID int64, request *dto
case ActionCancel: // 取消点赞
if !notExist && existing.Status == InteractStatusOn {
if uerr := s.likeDao.LikeUpdateStatus(ctx, existing.ID, InteractStatusOff); uerr != nil {
slog.ErrorContext(ctx, "like: update status failed", "target_id", targetID, "user_id", userID, "error", uerr)
return nil, errcode.LikeFailed
}
_ = redisdao.LikeRemove(ctx, LikeTypeOutfit, targetID, userID)
if rerr := redisdao.LikeRemove(ctx, LikeTypeOutfit, targetID, userID); rerr != nil {
slog.WarnContext(ctx, "like: redis remove failed, counter may overcount",
"target_id", targetID, "user_id", userID, "error", rerr)
}
}
}
@@ -99,6 +108,9 @@ func (s *LikeService) GetByTargetID(ctx context.Context, userID int64, request *
"target_id", targetID, "user_id", userID, "error", derr)
if v, rerr := redisdao.LikeIsMember(ctx, LikeTypeOutfit, targetID, userID); rerr == nil {
isLiked = v
} else {
slog.DebugContext(ctx, "like: redis fallback read failed",
"target_id", targetID, "user_id", userID, "error", rerr)
}
}
@@ -125,6 +137,7 @@ func (s *LikeService) getPublicOutfit(ctx context.Context, userID int64, outfitU
if isNotFound(err) {
return nil, errcode.TargetNotFound
}
slog.ErrorContext(ctx, "like: query outfit failed", "outfit_uuid", outfitUUID, "user_id", userID, "error", err)
return nil, errcode.DatabaseError
}
if outfit.UserID != userID && !isOutfitPublicVisible(outfit) {
+10 -2
View File
@@ -76,6 +76,8 @@ func (s *NotificationService) Read(ctx context.Context, userID int64, request *d
if isNotFound(err) {
return nil, errcode.NotificationNotFound
}
slog.ErrorContext(ctx, "read notification: query notification failed",
"notification_uuid", request.NotificationUUID, "user_id", userID, "error", err)
return nil, errcode.DatabaseError
}
if n.UserID != userID {
@@ -93,6 +95,8 @@ func (s *NotificationService) Read(ctx context.Context, userID int64, request *d
// 条件标记已读(仅未读 -> 已读),防并发重复标记。
if _, err := s.notificationDao.NotificationUpdateReadByUUIDIfUnread(ctx, request.NotificationUUID); err != nil {
slog.ErrorContext(ctx, "mark notification read failed",
"notification_uuid", request.NotificationUUID, "user_id", userID, "error", err)
return nil, errcode.DatabaseError
}
// 无论本次是否真的标记成功,都按 DB 重新统计并回填缓存:
@@ -127,9 +131,12 @@ func (s *NotificationService) unreadCount(ctx context.Context, userID int64) (in
slog.WarnContext(ctx, "count unread from db failed, fallback to redis",
"user_id", userID, "error", err)
return rcnt, nil
}
} else {
slog.ErrorContext(ctx, "count unread failed: db and redis both unavailable",
"user_id", userID, "db_error", err, "redis_error", rerr)
return 0, errcode.DatabaseError
}
}
if serr := redisdao.NotificationUnreadSet(ctx, userID, cnt); serr != nil {
slog.WarnContext(ctx, "backfill notification unread cache failed",
"user_id", userID, "error", serr)
@@ -167,6 +174,7 @@ func sendNotification(ctx context.Context, recipientUserID, senderID int64, msgT
return
}
if _, err := redisdao.NotificationUnreadIncr(ctx, recipientUserID); err != nil {
slog.ErrorContext(ctx, "notification unread incr failed", "user_id", recipientUserID, "error", err)
// 未读数是 best-effort 缓存:失败不影响通知已落库,级别与「通知创建失败」保持一致。
slog.WarnContext(ctx, "notification unread incr failed", "user_id", recipientUserID, "error", err)
}
}
+101 -16
View File
@@ -47,11 +47,14 @@ func (s *OrderService) Create(ctx context.Context, userID int64, request *dto.Or
goods, err := s.goodsDao.GoodsGetByCode(ctx, request.GoodsCode)
if err != nil {
if isNotFound(err) {
slog.InfoContext(ctx, "create order: goods not found", "user_id", userID, "goods_code", request.GoodsCode)
return nil, errcode.GoodsNotFound
}
slog.ErrorContext(ctx, "create order: query goods failed", "goods_code", request.GoodsCode, "error", err)
return nil, errcode.DatabaseError
}
if goods.Status != GoodsStatusOnShelf {
slog.InfoContext(ctx, "create order: goods off shelf", "user_id", userID, "goods_code", request.GoodsCode)
return nil, errcode.GoodsOffShelf
}
@@ -59,11 +62,14 @@ func (s *OrderService) Create(ctx context.Context, userID int64, request *dto.Or
user, uerr := s.userDao.UserGetByID(ctx, userID)
if uerr != nil {
if isNotFound(uerr) {
slog.WarnContext(ctx, "create order: user not found", "user_id", userID)
return nil, errcode.UserNotFound
}
slog.ErrorContext(ctx, "create order: query user failed", "user_id", userID, "error", uerr)
return nil, errcode.DatabaseError
}
if user.WxOpenID == "" {
slog.WarnContext(ctx, "create order: user openid missing", "user_id", userID)
return nil, errcode.OrderCreateFailed.WithDetails("缺少微信 openid")
}
@@ -93,14 +99,20 @@ func (s *OrderService) Create(ctx context.Context, userID int64, request *dto.Or
// 先落库再微信下单:微信下单失败时本地订单保留为待支付并超时自动过期,
// 避免「先微信下单、落库失败」产生无本地记录的孤儿预支付单。
if cerr := s.orderDao.OrderCreate(ctx, order); cerr != nil {
slog.ErrorContext(ctx, "create order: save order failed",
"order_no", orderNo, "user_id", userID, "goods_code", request.GoodsCode, "error", cerr)
return nil, errcode.OrderCreateFailed
}
payParams, perr := wechat.JsapiPrepay(ctx, user.WxOpenID, orderNo, goods.GoodsName, payAmount)
if perr != nil {
slog.ErrorContext(ctx, "create order: jsapi prepay failed", "order_no", orderNo, "user_id", userID, "error", perr)
return nil, errcode.OrderCreateFailed
}
slog.InfoContext(ctx, "order created",
"order_no", orderNo, "user_id", userID, "goods_code", request.GoodsCode, "pay_amount", payAmount)
return &dto.OrderCreateResponse{
OrderNo: orderNo,
PayAmount: payAmount,
@@ -122,16 +134,25 @@ func (s *OrderService) PayNotify(ctx context.Context, request *http.Request) *er
data, err := wechat.VerifyAndDecryptPayNotify(ctx, request)
if err != nil {
slog.ErrorContext(ctx, "pay notify: verify or decrypt failed", "remote_addr", request.RemoteAddr, "error", err)
return errcode.RPCError
}
if data.TradeState != "SUCCESS" {
slog.InfoContext(ctx, "pay notify: ignored non-success trade state",
"out_trade_no", data.OutTradeNo, "trade_state", data.TradeState)
return nil // 非支付成功,忽略。
}
// 幂等锁。
lockKey := fmtLockKey(redismodel.KeyLockPayCallback, data.OutTradeNo)
lockVal := genUUID()
if ok, _ := redisdao.TryLock(ctx, lockKey, lockVal, 30*time.Second); !ok {
ok, lerr := redisdao.TryLock(ctx, lockKey, lockVal, 30*time.Second)
if lerr != nil {
slog.ErrorContext(ctx, "pay notify: acquire lock failed", "out_trade_no", data.OutTradeNo, "error", lerr)
return nil // 锁不可用(Redis 故障):不重复处理,交由微信重试/持锁者。
}
if !ok {
slog.DebugContext(ctx, "pay notify: concurrent callback, skip", "out_trade_no", data.OutTradeNo)
return nil // 并发回调,交由持锁者处理。
}
defer func() { _ = redisdao.Unlock(ctx, lockKey, lockVal) }()
@@ -143,6 +164,7 @@ func (s *OrderService) PayNotify(ctx context.Context, request *http.Request) *er
slog.ErrorContext(ctx, "pay notify: order not found", "out_trade_no", data.OutTradeNo)
return nil
}
slog.ErrorContext(ctx, "pay notify: query order failed", "out_trade_no", data.OutTradeNo, "error", gerr)
return errcode.DatabaseError
}
if data.Amount.Total != order.PayAmount {
@@ -166,9 +188,12 @@ func (s *OrderService) PayNotify(ctx context.Context, request *http.Request) *er
// 条件流转:待支付 -> 已支付,防重复回调。
updated, uerr := s.orderDao.OrderUpdateStatusByOrderNo(ctx, data.OutTradeNo, OrderStatusPending, OrderStatusPaid)
if uerr != nil {
slog.ErrorContext(ctx, "pay notify: update order status failed", "out_trade_no", data.OutTradeNo, "error", uerr)
return errcode.DatabaseError
}
if updated {
slog.InfoContext(ctx, "pay notify: order paid",
"order_no", order.OrderNo, "user_id", order.UserID, "pay_amount", order.PayAmount, "transaction_id", data.TransactionID)
s.finalizePaidOrder(ctx, order, data)
return nil
}
@@ -177,8 +202,10 @@ func (s *OrderService) PayNotify(ctx context.Context, request *http.Request) *er
current, gerr := s.orderDao.OrderGetByOrderNo(ctx, data.OutTradeNo)
if gerr != nil {
if isNotFound(gerr) {
slog.WarnContext(ctx, "pay notify: order disappeared after status conflict", "out_trade_no", data.OutTradeNo)
return nil
}
slog.ErrorContext(ctx, "pay notify: reload order failed", "out_trade_no", data.OutTradeNo, "error", gerr)
return errcode.DatabaseError
}
@@ -186,22 +213,28 @@ func (s *OrderService) PayNotify(ctx context.Context, request *http.Request) *er
case OrderStatusPaid:
// 已被并发回调处理。兜底确保支付信息与权益发放已完成(均幂等),
// 覆盖「订单已流转为 Paid 但权益发放前崩溃导致权益丢失」的场景。
_ = s.orderDao.OrderUpdateByOrderNo(ctx, current.OrderNo, map[string]any{
if uerr := s.orderDao.OrderUpdateByOrderNo(ctx, current.OrderNo, map[string]any{
"pay_time": nowUnix(),
"transaction_id": data.TransactionID,
"callback_raw": data.Raw,
})
}); uerr != nil {
slog.ErrorContext(ctx, "pay notify: reconcile paid order pay info failed", "order_no", current.OrderNo, "error", uerr)
}
slog.InfoContext(ctx, "pay notify: order already paid, reconcile benefits", "order_no", current.OrderNo, "user_id", current.UserID)
s.grantOrderBenefits(ctx, current)
return nil
case OrderStatusExpired:
// 订单被系统过期但用户已付款:过期 -> 已支付并补发货,避免用户付款却不到账。
ok, uerr := s.orderDao.OrderUpdateStatusByOrderNo(ctx, data.OutTradeNo, OrderStatusExpired, OrderStatusPaid)
if uerr != nil {
slog.ErrorContext(ctx, "pay notify: compensate expired order failed", "out_trade_no", data.OutTradeNo, "error", uerr)
return errcode.DatabaseError
}
if !ok {
slog.DebugContext(ctx, "pay notify: expired order already handled by another callback", "out_trade_no", data.OutTradeNo)
return nil // 已被其它回调处理。
}
slog.InfoContext(ctx, "pay notify: expired order compensated", "order_no", current.OrderNo, "user_id", current.UserID)
s.finalizePaidOrder(ctx, current, data)
return nil
case OrderStatusCancelled:
@@ -209,6 +242,7 @@ func (s *OrderService) PayNotify(ctx context.Context, request *http.Request) *er
s.autoRefundCancelledOrder(ctx, current, data)
return nil
default:
slog.DebugContext(ctx, "pay notify: unhandled order status", "out_trade_no", data.OutTradeNo, "order_status", current.OrderStatus)
return nil
}
}
@@ -353,12 +387,19 @@ func (s *OrderService) RefundNotify(ctx context.Context, request *http.Request)
data, err := wechat.VerifyAndDecryptRefundNotify(ctx, request)
if err != nil {
slog.ErrorContext(ctx, "refund notify: verify or decrypt failed", "remote_addr", request.RemoteAddr, "error", err)
return errcode.RPCError
}
lockKey := fmtLockKey(redismodel.KeyLockRefundCallback, data.OutRefundNo)
lockVal := genUUID()
if ok, _ := redisdao.TryLock(ctx, lockKey, lockVal, 30*time.Second); !ok {
ok, lerr := redisdao.TryLock(ctx, lockKey, lockVal, 30*time.Second)
if lerr != nil {
slog.ErrorContext(ctx, "refund notify: acquire lock failed", "out_refund_no", data.OutRefundNo, "error", lerr)
return nil // 锁不可用(Redis 故障):不重复处理,交由微信重试/持锁者。
}
if !ok {
slog.DebugContext(ctx, "refund notify: concurrent callback, skip", "out_refund_no", data.OutRefundNo)
return nil
}
defer func() { _ = redisdao.Unlock(ctx, lockKey, lockVal) }()
@@ -366,11 +407,14 @@ func (s *OrderService) RefundNotify(ctx context.Context, request *http.Request)
flow, ferr := s.refundDao.RefundFlowLogGetByRefundNo(ctx, data.OutRefundNo)
if ferr != nil {
if isNotFound(ferr) {
slog.WarnContext(ctx, "refund notify: refund flow not found", "out_refund_no", data.OutRefundNo)
return nil
}
slog.ErrorContext(ctx, "refund notify: get refund flow failed", "out_refund_no", data.OutRefundNo, "error", ferr)
return errcode.DatabaseError
}
if flow.Status == RefundStatusFailed {
slog.DebugContext(ctx, "refund notify: refund flow already failed, skip", "out_refund_no", data.OutRefundNo)
return nil
}
if data.Amount.Refund != flow.RefundAmount {
@@ -381,21 +425,30 @@ func (s *OrderService) RefundNotify(ctx context.Context, request *http.Request)
if flow.Status != RefundStatusSuccess {
ok, uerr := s.refundDao.RefundFlowLogUpdateStatusByFrom(ctx, data.OutRefundNo, flow.Status, map[string]any{"status": RefundStatusSuccess, "success_time": nowUnix(), "callback_raw": data.Raw})
if uerr != nil {
slog.ErrorContext(ctx, "refund notify: update refund flow status failed", "out_refund_no", data.OutRefundNo, "error", uerr)
return errcode.DatabaseError
}
if !ok {
slog.WarnContext(ctx, "refund notify: refund flow status changed concurrently", "out_refund_no", data.OutRefundNo)
return errcode.DatabaseError
}
}
order, gerr := s.orderDao.OrderGetByOrderNo(ctx, data.OutTradeNo)
if gerr != nil {
slog.ErrorContext(ctx, "refund notify: get order failed", "out_trade_no", data.OutTradeNo, "error", gerr)
return errcode.DatabaseError
}
if err := s.settleRefundSuccess(ctx, order, flow.RefundAmount); err != nil {
slog.ErrorContext(ctx, "refund notify: settle refund success failed",
"out_refund_no", data.OutRefundNo, "order_no", order.OrderNo, "error", err)
return errcode.DatabaseError
}
slog.InfoContext(ctx, "refund notify: refund success settled",
"order_no", order.OrderNo, "out_refund_no", data.OutRefundNo, "refund_amount", flow.RefundAmount)
} else if flow.Status != RefundStatusSuccess {
if _, err := s.refundDao.RefundFlowLogUpdateStatusByFrom(ctx, data.OutRefundNo, flow.Status, map[string]any{"status": RefundStatusFailed, "fail_reason": "微信退款未成功:" + data.RefundStatus, "callback_raw": data.Raw}); err != nil {
slog.ErrorContext(ctx, "refund notify: mark refund failed status failed",
"out_refund_no", data.OutRefundNo, "refund_status", data.RefundStatus, "error", err)
return errcode.DatabaseError
}
}
@@ -407,15 +460,20 @@ func (s *OrderService) RefundNotify(ctx context.Context, request *http.Request)
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 {
slog.InfoContext(ctx, "auto refund: refund flow already exists, skip", "order_no", order.OrderNo)
return
} else if err != nil && !isNotFound(err) {
slog.ErrorContext(ctx, "auto refund: query existing refund flow failed", "order_no", order.OrderNo, "error", err)
}
// 补记支付信息(订单已取消,此前未记录),便于管理员审核退款时核对。
_ = s.orderDao.OrderUpdateByOrderNo(ctx, order.OrderNo, map[string]any{
if uerr := s.orderDao.OrderUpdateByOrderNo(ctx, order.OrderNo, map[string]any{
"pay_time": nowUnix(),
"transaction_id": data.TransactionID,
"callback_raw": data.Raw,
})
}); uerr != nil {
slog.ErrorContext(ctx, "auto refund: record pay info failed", "order_no", order.OrderNo, "error", uerr)
}
refundNo := genRefundNo()
flow := &mysqlmodel.RefundFlowLog{
@@ -435,8 +493,8 @@ func (s *OrderService) autoRefundCancelledOrder(ctx context.Context, order *mysq
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)
slog.WarnContext(ctx, "cancelled order refund flow created, waiting manual review",
"order_no", order.OrderNo, "user_id", order.UserID, "refund_no", refundNo, "refund_amount", order.PayAmount)
}
// ---- 内部辅助 ----
@@ -458,11 +516,13 @@ func (s *OrderService) getOwnedOrder(ctx context.Context, orderNo string, userID
// finalizePaidOrder 记录支付信息、发放权益并发送通知(支付成功或过期补发货后调用)。
func (s *OrderService) finalizePaidOrder(ctx context.Context, order *mysqlmodel.Order, data *wechat.PayNotifyData) {
_ = s.orderDao.OrderUpdateByOrderNo(ctx, order.OrderNo, map[string]any{
if uerr := s.orderDao.OrderUpdateByOrderNo(ctx, order.OrderNo, map[string]any{
"pay_time": nowUnix(),
"transaction_id": data.TransactionID,
"callback_raw": data.Raw,
})
}); uerr != nil {
slog.ErrorContext(ctx, "finalize paid order: update pay info failed", "order_no", order.OrderNo, "error", uerr)
}
s.grantOrderBenefits(ctx, order)
@@ -522,7 +582,7 @@ func (s *OrderService) grantBenefitIdempotent(ctx context.Context, order *mysqlm
// 曾失败过:冷却期内不重置(防微信回调重试风暴无限重置任务绕过重试上限),
// 冷却期外重置为待发放并尝试发放(发放本身有幂等兜底,重置失败仅告警)。
if nowUnix()-existing.UpdatedAt < benefitRetryCooldownSeconds {
slog.WarnContext(ctx, "benefit grant task in cooldown, skip retry",
slog.InfoContext(ctx, "benefit grant task in cooldown, skip retry",
"order_no", order.OrderNo, "benefit_type", benefitType, "task_id", existing.ID)
return
}
@@ -543,28 +603,43 @@ func (s *OrderService) grantBenefitIdempotent(ctx context.Context, order *mysqlm
// executeGrant 认领并发放单个权益任务(claim 保证同一任务仅发放一次,防并发重复发放)。
func (s *OrderService) executeGrant(ctx context.Context, task *mysqlmodel.BenefitGrantTask) {
var grantErr error
var granted bool
err := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var order mysqlmodel.Order
if err := tx.Select("id", "order_status").Clauses(clause.Locking{Strength: "UPDATE"}).Where("order_no = ?", task.OrderNo).First(&order).Error; err != nil {
return err
}
if order.OrderStatus != OrderStatusPaid {
slog.InfoContext(ctx, "grant benefit skipped: order not paid",
"order_no", task.OrderNo, "benefit_type", task.BenefitType)
return tx.Model(&mysqlmodel.BenefitGrantTask{}).Where("id = ? AND status IN ?", task.ID, []int8{BenefitTaskPending, BenefitTaskFailed}).Update("status", BenefitTaskCancelled).Error
}
res := tx.Model(&mysqlmodel.BenefitGrantTask{}).Where("id = ? AND status IN ?", task.ID, []int8{BenefitTaskPending, BenefitTaskFailed}).Update("status", BenefitTaskProcessing)
if res.Error != nil || res.RowsAffected == 0 {
if res.Error == nil {
slog.InfoContext(ctx, "grant benefit skipped: task already claimed or status changed",
"order_no", task.OrderNo, "benefit_type", task.BenefitType, "task_id", task.ID)
}
return res.Error
}
if e := s.doGrantBenefit(ctx, task); e != nil {
grantErr = e
return tx.Model(&mysqlmodel.BenefitGrantTask{}).Where("id = ?", task.ID).Updates(map[string]any{"status": BenefitTaskPending, "retry_count": gorm.Expr("retry_count + 1"), "last_error": e.Error()}).Error
}
granted = true
return tx.Model(&mysqlmodel.BenefitGrantTask{}).Where("id = ?", task.ID).Updates(map[string]any{"status": BenefitTaskSuccess, "last_error": ""}).Error
})
if err != nil {
slog.ErrorContext(ctx, "grant benefit task update failed", "order_no", task.OrderNo, "benefit_type", task.BenefitType, "error", err)
slog.ErrorContext(ctx, "grant benefit task update failed",
"order_no", task.OrderNo, "benefit_type", task.BenefitType, "user_id", task.UserID,
"retry_count", task.RetryCount, "amount", task.Amount, "error", err)
} else if grantErr != nil {
slog.ErrorContext(ctx, "grant benefit failed", "order_no", task.OrderNo, "benefit_type", task.BenefitType, "error", grantErr)
slog.ErrorContext(ctx, "grant benefit failed",
"order_no", task.OrderNo, "benefit_type", task.BenefitType, "user_id", task.UserID,
"retry_count", task.RetryCount, "amount", task.Amount, "error", grantErr)
} else if granted {
slog.InfoContext(ctx, "benefit granted",
"order_no", task.OrderNo, "benefit_type", task.BenefitType, "user_id", task.UserID, "amount", task.Amount)
}
}
@@ -697,7 +772,9 @@ func (s *OrderService) grantVIP(ctx context.Context, userID, memberDays int64, o
return err
}
// 用户信息已变更,失效缓存。
_ = redisdao.UserInfoDel(ctx, userID)
if derr := redisdao.UserInfoDel(ctx, userID); derr != nil {
slog.DebugContext(ctx, "grant vip: invalidate user cache failed", "user_id", userID, "order_no", orderNo, "error", derr)
}
return nil
}
@@ -710,7 +787,10 @@ func (s *OrderService) settleRefundSuccess(ctx context.Context, order *mysqlmode
return err
}
if settled {
slog.InfoContext(ctx, "refund settled", "order_no", order.OrderNo, "user_id", order.UserID, "refund_amount", refundAmount)
sendNotification(ctx, order.UserID, 0, NotificationTypeOrder, "退款成功", "订单「"+order.GoodsName+"」已退款")
} else {
slog.InfoContext(ctx, "refund settle skipped: order already settled or not in paid status", "order_no", order.OrderNo)
}
// 立即尝试;失败时任务仍为待处理,由定时任务恢复。
s.executeRevokeTaskByOrder(ctx, order.OrderNo)
@@ -738,7 +818,7 @@ func (s *OrderService) revokeOrderBenefits(ctx context.Context, order *mysqlmode
func (s *OrderService) buildQuotaRevokeFlows(ctx context.Context, order *mysqlmodel.Order) ([]*mysqlmodel.QuotaFlowLog, error) {
grants, err := mysqldao.NewQuotaGrantDao().QuotaGrantListByRefID(ctx, order.OrderNo)
if err != nil {
slog.WarnContext(ctx, "list quota grants for revoke failed", "order_no", order.OrderNo, "error", err)
slog.ErrorContext(ctx, "list quota grants for revoke failed", "order_no", order.OrderNo, "error", err)
return nil, err
}
@@ -755,6 +835,9 @@ func (s *OrderService) buildQuotaRevokeFlows(ctx context.Context, order *mysqlmo
if !computed[g.QuotaType] {
if eff, qerr := getEffectiveQuota(ctx, order.UserID, g.QuotaType); qerr == nil {
current[g.QuotaType] = eff.Remain
} else {
slog.WarnContext(ctx, "build quota revoke flows: calc effective quota failed",
"order_no", order.OrderNo, "user_id", order.UserID, "quota_type", g.QuotaType, "error", qerr)
}
computed[g.QuotaType] = true
}
@@ -786,7 +869,9 @@ func (s *OrderService) revokeVIP(ctx context.Context, userID, memberDays int64,
if err := s.userDao.VIPRevokeIdempotent(ctx, userID, nowUnix(), memberDays, flow); err != nil && !isDuplicateKey(err) {
return err
}
_ = redisdao.UserInfoDel(ctx, userID)
if derr := redisdao.UserInfoDel(ctx, userID); derr != nil {
slog.DebugContext(ctx, "revoke vip: invalidate user cache failed", "user_id", userID, "order_no", orderNo, "error", derr)
}
return nil
}
+2
View File
@@ -42,5 +42,7 @@ func runOrderExpire() {
}
if affected > 0 {
slog.InfoContext(ctx, "expired pending orders", "count", affected)
} else {
slog.DebugContext(ctx, "order expire: no pending orders expired")
}
}
+32 -3
View File
@@ -79,9 +79,12 @@ func (s *OutfitService) Create(ctx context.Context, userID int64, request *dto.O
}
return nil
}); err != nil {
slog.ErrorContext(ctx, "create outfit: transaction failed", "user_id", userID, "error", err)
return nil, errcode.OutfitCreateFailed
}
slog.InfoContext(ctx, "outfit created", "user_id", userID, "outfit_uuid", outfit.OutfitUUID)
response := buildOutfitCreateResponse(outfit, items)
return &response, nil
}
@@ -152,6 +155,8 @@ func (s *OutfitService) UpdateByUUID(ctx context.Context, userID int64, request
return tx.Create(&items).Error
})
if rebuildErr != nil {
slog.ErrorContext(ctx, "update outfit: transaction failed",
"user_id", userID, "outfit_uuid", request.OutfitUUID, "error", rebuildErr)
return nil, errcode.OutfitUpdateFailed
}
@@ -220,8 +225,11 @@ func (s *OutfitService) Delete(ctx context.Context, userID int64, request *dto.O
}
return tx.Where("outfit_uuid = ?", request.OutfitUUID).Delete(&mysqlmodel.Outfit{}).Error
}); err != nil {
slog.ErrorContext(ctx, "delete outfit: transaction failed",
"user_id", userID, "outfit_uuid", request.OutfitUUID, "error", err)
return nil, errcode.OutfitDeleteFailed
}
slog.InfoContext(ctx, "outfit deleted", "user_id", userID, "outfit_uuid", request.OutfitUUID)
return &dto.OutfitDeleteResponse{}, nil
}
@@ -243,9 +251,14 @@ func (s *OutfitService) Public(ctx context.Context, userID int64, request *dto.O
slog.WarnContext(ctx, "acquire outfit public lock failed, proceed without lock",
"outfit_uuid", request.OutfitUUID, "error", lerr)
} else if !ok {
slog.DebugContext(ctx, "outfit public: locked by another request, reject", "outfit_uuid", request.OutfitUUID)
return nil, errcode.TooManyRequests
} else {
defer func() { _ = redisdao.Unlock(ctx, lockKey, lockVal) }()
defer func() {
if uerr := redisdao.Unlock(ctx, lockKey, lockVal); uerr != nil {
slog.WarnContext(ctx, "outfit public: release lock failed", "outfit_uuid", request.OutfitUUID, "error", uerr)
}
}()
}
// 取消公开:直接置为不公开。
@@ -254,8 +267,11 @@ func (s *OutfitService) Public(ctx context.Context, userID int64, request *dto.O
return &dto.OutfitPublicResponse{}, nil
}
if err := s.outfitDao.OutfitUpdateIsPublic(ctx, request.OutfitUUID, OutfitPrivate); err != nil {
slog.ErrorContext(ctx, "outfit public: set private failed",
"outfit_uuid", request.OutfitUUID, "user_id", userID, "error", err)
return nil, errcode.OutfitUpdateFailed
}
slog.InfoContext(ctx, "outfit set private", "outfit_uuid", request.OutfitUUID, "user_id", userID)
return &dto.OutfitPublicResponse{}, nil
}
@@ -267,16 +283,24 @@ func (s *OutfitService) Public(ctx context.Context, userID int64, request *dto.O
// 首次公开:先条件更新(私密 -> 公开+审核通过),成功才消耗额度,防并发重复扣减。
ok, perr := s.outfitDao.OutfitPublish(ctx, request.OutfitUUID)
if perr != nil {
slog.ErrorContext(ctx, "outfit public: condition publish failed",
"outfit_uuid", request.OutfitUUID, "user_id", userID, "error", perr)
return nil, errcode.OutfitUpdateFailed
}
if !ok {
// 已被并发公开,幂等返回。
slog.DebugContext(ctx, "outfit public: already published by another request", "outfit_uuid", request.OutfitUUID)
return &dto.OutfitPublicResponse{}, nil
}
if qerr := consumeDailyQuota(ctx, userID, QuotaTypeOutfitShare, 1, "穿搭社区分享消耗"); qerr != nil {
// 扣额度失败,回退公开状态。
_ = s.outfitDao.OutfitUpdateIsPublic(ctx, request.OutfitUUID, OutfitPrivate)
slog.WarnContext(ctx, "outfit public: consume share quota failed, revert to private",
"outfit_uuid", request.OutfitUUID, "user_id", userID, "error", qerr)
if rerr := s.outfitDao.OutfitUpdateIsPublic(ctx, request.OutfitUUID, OutfitPrivate); rerr != nil {
slog.ErrorContext(ctx, "outfit public: revert to private failed",
"outfit_uuid", request.OutfitUUID, "user_id", userID, "error", rerr)
}
return nil, qerr
}
@@ -286,11 +310,14 @@ func (s *OutfitService) Public(ctx context.Context, userID int64, request *dto.O
AuditResult: AuditResultPass,
AuditTime: nowUnix(),
}); err != nil {
slog.WarnContext(ctx, "write outfit audit log failed", "outfit_id", outfit.ID, "error", err)
slog.WarnContext(ctx, "write outfit audit log failed",
"outfit_id", outfit.ID, "outfit_uuid", outfit.OutfitUUID, "error", err)
}
// 记录当日社区分享计数(统计)。
slog.InfoContext(ctx, "outfit published", "outfit_uuid", outfit.OutfitUUID, "user_id", userID)
return &dto.OutfitPublicResponse{}, nil
}
@@ -303,6 +330,8 @@ func (s *OutfitService) getOwnedOutfit(ctx context.Context, outfitUUID string, u
if isNotFound(err) {
return nil, errcode.OutfitNotFound
}
slog.ErrorContext(ctx, "get owned outfit: query failed",
"outfit_uuid", outfitUUID, "user_id", userID, "error", err)
return nil, errcode.DatabaseError
}
if outfit.UserID != userID {
+24
View File
@@ -46,6 +46,8 @@ func (s *OutfitShareService) Create(ctx context.Context, userID int64, request *
if isNotFound(err) {
return nil, errcode.OutfitNotFound
}
slog.ErrorContext(ctx, "outfit share create: query outfit failed",
"outfit_uuid", request.OutfitUUID, "user_id", userID, "error", err)
return nil, errcode.DatabaseError
}
if outfit.UserID != userID {
@@ -61,6 +63,11 @@ func (s *OutfitShareService) Create(ctx context.Context, userID int64, request *
charged := outfit.IsPublic == OutfitPrivate
if charged {
if qerr := consumeDailyQuota(ctx, userID, QuotaTypeClosetShare, 1, "私密穿搭分享消耗"); qerr != nil {
if qerr == errcode.QuotaInsufficient {
slog.InfoContext(ctx, "outfit share create: quota insufficient", "user_id", userID)
} else {
slog.ErrorContext(ctx, "outfit share create: consume quota failed", "user_id", userID, "error", qerr)
}
return nil, qerr
}
}
@@ -72,12 +79,16 @@ func (s *OutfitShareService) Create(ctx context.Context, userID int64, request *
Status: ShareStatusNormal,
}
if err := s.shareDao.OutfitShareCreate(ctx, share); err != nil {
slog.ErrorContext(ctx, "outfit share create: save share failed",
"user_id", userID, "outfit_uuid", request.OutfitUUID, "error", err)
// 分享落库失败:回退刚扣的额度,避免用户额度被白扣。
if charged {
refundDailyQuota(ctx, userID, QuotaTypeClosetShare, 1)
}
return nil, errcode.DatabaseError
}
slog.InfoContext(ctx, "outfit share created",
"user_id", userID, "share_uuid", share.ShareUUID, "outfit_uuid", request.OutfitUUID, "charged", charged)
return &dto.OutfitShareCreateResponse{ShareUUID: share.ShareUUID}, nil
}
@@ -91,6 +102,7 @@ func (s *OutfitShareService) Detail(ctx context.Context, userID int64, shareUUID
if isNotFound(err) {
return nil, errcode.OutfitShareNotFound
}
slog.ErrorContext(ctx, "outfit share detail: query share failed", "share_uuid", shareUUID, "error", err)
return nil, errcode.DatabaseError
}
if share.Status == ShareStatusCancelled {
@@ -129,6 +141,9 @@ func (s *OutfitShareService) Detail(ctx context.Context, userID int64, shareUUID
if owner, uerr := s.userDao.UserGetByID(ctx, share.UserID); uerr == nil {
ownerName = owner.Username
ownerAvatar = upload.AvatarURL(owner.Avatar)
} else {
slog.DebugContext(ctx, "outfit share detail: get owner info failed",
"share_uuid", shareUUID, "owner_id", share.UserID, "error", uerr)
}
return &dto.OutfitShareDetailResponse{
@@ -210,14 +225,21 @@ func (s *OutfitShareService) Cancel(ctx context.Context, userID int64, request *
if isNotFound(err) {
return nil, errcode.OutfitShareNotFound
}
slog.ErrorContext(ctx, "outfit share cancel: query share failed",
"share_uuid", request.ShareUUID, "user_id", userID, "error", err)
return nil, errcode.DatabaseError
}
if share.UserID != userID {
slog.InfoContext(ctx, "outfit share cancel: permission denied",
"share_uuid", request.ShareUUID, "user_id", userID, "owner_id", share.UserID)
return nil, errcode.Forbidden
}
if err := s.shareDao.OutfitShareUpdateStatus(ctx, request.ShareUUID, ShareStatusCancelled); err != nil {
slog.ErrorContext(ctx, "outfit share cancel: update status failed",
"share_uuid", request.ShareUUID, "user_id", userID, "error", err)
return nil, errcode.DatabaseError
}
slog.InfoContext(ctx, "outfit share cancelled", "share_uuid", request.ShareUUID, "user_id", userID)
return &dto.OutfitShareCancelResponse{}, nil
}
@@ -266,6 +288,8 @@ func (s *OutfitShareService) buildShareItems(ctx context.Context, list []mysqlmo
for i := range clothesList {
coverByClothesID[clothesList[i].ID] = clothesImageURL(clothesList[i].ProcessedImage)
}
} else {
slog.DebugContext(ctx, "outfit share list: load cover images failed", "error", cerr)
}
for _, sh := range list {
+3
View File
@@ -36,6 +36,8 @@ func (s *QuotaService) InfoGetBySelf(ctx context.Context, userID int64) (*dto.Qu
for _, quotaType := range types {
eff, err := getEffectiveQuota(ctx, userID, quotaType)
if err != nil {
slog.ErrorContext(ctx, "quota info: calc effective quota failed",
"user_id", userID, "quota_type", quotaType, "error", err)
return nil, err
}
items = append(items, dto.QuotaItemResponse{
@@ -58,6 +60,7 @@ func (s *QuotaService) FlowList(ctx context.Context, userID int64, request *dto.
list, total, err := s.flowLogDao.QuotaFlowLogListByUserID(ctx, userID, request.QuotaType, page, pageSize)
if err != nil {
slog.ErrorContext(ctx, "quota flow list: query failed", "user_id", userID, "error", err)
return nil, errcode.DatabaseError
}
+120
View File
@@ -0,0 +1,120 @@
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"
)
// 额度凭证的「到期」原本只是惰性判断(读取时用 grantExpired 现算),既不落流水也不做任何动作,
// 导致用户看到的额度明细对不上账:+3 之后某个时刻剩余无故减少,却没有任何对应记录。
//
// 本任务为已过期的「固定有效期」凭证补记一条 change_type=7(到期失效)流水,让明细闭环。
// 注意:只补流水,不删除凭证 —— 凭证本身保留,读取时依旧由 grantExpired 过滤。
const (
// quotaExpireScanInterval 任务执行间隔。
quotaExpireScanInterval = time.Hour
// quotaExpireScanBatch 单批处理条数。
quotaExpireScanBatch = 500
// quotaExpireScanMaxRounds 单次执行最大批次轮数。
quotaExpireScanMaxRounds = 20
)
// StartQuotaExpireScheduler 启动额度到期补记流水任务(启动即执行一次,之后每小时一次)。
func StartQuotaExpireScheduler() {
go func() {
slog.Info("quota expire scheduler started", "interval", quotaExpireScanInterval.String())
for {
func() {
defer func() {
if r := recover(); r != nil {
slog.Error("quota expire scheduler panic", "recover", r)
}
}()
runQuotaExpire()
}()
time.Sleep(quotaExpireScanInterval)
}
}()
}
// runQuotaExpire 执行一次到期流水补记。
// 天然幂等(靠流水唯一索引 uk_quota_flow_idem 去重),多实例并发执行无害,无需分布式锁。
func runQuotaExpire() {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
ctx, span := global.Tracer.Start(ctx, "scheduler.QuotaExpire")
defer span.End()
grantDao := mysqldao.NewQuotaGrantDao()
now := nowUnix()
var total int64
for round := 0; round < quotaExpireScanMaxRounds; round++ {
grants, err := grantDao.QuotaGrantListExpiredFixed(ctx, now, quotaExpireScanBatch)
if err != nil {
slog.ErrorContext(ctx, "list expired quota grants failed", "error", err, "logged", total)
return
}
if len(grants) == 0 {
break
}
for i := range grants {
logOneExpireFlow(ctx, grantDao, &grants[i])
}
total += int64(len(grants))
if len(grants) < quotaExpireScanBatch {
break
}
}
if total > 0 {
slog.InfoContext(ctx, "logged expired quota flows", "count", total)
} else {
slog.DebugContext(ctx, "quota expire: nothing to log")
}
}
// logOneExpireFlow 为单条已过期凭证补记「到期失效」流水。
func logOneExpireFlow(ctx context.Context, grantDao *mysqldao.QuotaGrantDao, g *mysqlmodel.QuotaGrant) {
// 只有「没用完」的部分才是真正失效损失的额度;已消耗的不再变化。
expireAmount := g.Amount - g.Used
if expireAmount < 0 {
expireAmount = 0
}
// 凭证此刻已过期,getEffectiveQuota 返回的正是「失效后」的真实剩余;
// 用它反推变更前剩余,保证明细里满足「变更前 - 变更量 = 变更后」。
newValue := int64(0)
if eff, qerr := getEffectiveQuota(ctx, g.UserID, g.QuotaType); qerr == nil {
newValue = eff.Remain
}
flow := &mysqlmodel.QuotaFlowLog{
QuotaFlowUUID: genUUID(),
UserID: g.UserID,
ChangeTarget: QuotaChangeTargetAdjust,
ChangeType: QuotaChangeTypeExpire,
ChangeNum: -expireAmount,
OldValue: newValue + expireAmount,
NewValue: newValue,
// 与 quota_type、change_type 一起命中 uk_quota_flow_idem,天然幂等,无需额外标记字段。
OrderNo: g.RefID,
QuotaType: g.QuotaType,
Reason: "额度凭证到期失效",
}
if _, err := grantDao.QuotaFlowLogCreateIgnoreDup(ctx, flow); err != nil {
slog.WarnContext(ctx, "write quota expire flow failed",
"grant_id", g.ID, "user_id", g.UserID, "quota_type", g.QuotaType, "error", err)
}
}
+20 -8
View File
@@ -103,6 +103,10 @@ type EffectiveQuota struct {
func getUserVIPExpire(ctx context.Context, userID int64) int64 {
user, err := mysqldao.NewUserDao().UserGetByID(ctx, userID)
if err != nil {
// 查询失败一律视为非会员(跟随 VIP 的凭证将按失效处理),必须留痕便于排查额度差异。
if !isNotFound(err) {
slog.WarnContext(ctx, "get user vip expire failed, treat as non-vip", "user_id", userID, "error", err)
}
return 0
}
return user.VIPExpire
@@ -140,6 +144,8 @@ func getEffectiveQuota(ctx context.Context, userID int64, quotaType int8) (*Effe
vipExpire := getUserVIPExpire(ctx, userID)
grants, err := listValidGrants(ctx, userID, quotaType, vipExpire, now)
if err != nil {
slog.ErrorContext(ctx, "get effective quota: list valid grants failed",
"user_id", userID, "quota_type", quotaType, "error", err)
return nil, errcode.DatabaseError
}
@@ -154,6 +160,9 @@ func getEffectiveQuota(ctx context.Context, userID int64, quotaType int8) (*Effe
freeUsed := int64(0)
if q, qerr := mysqldao.NewQuotaDao().QuotaGetByUserIDAndType(ctx, userID, quotaType); qerr == nil {
freeUsed = q.Used
} else if !isNotFound(qerr) {
slog.WarnContext(ctx, "get effective quota: query free quota failed, treat as 0",
"user_id", userID, "quota_type", quotaType, "error", qerr)
}
total := freeTotal + grantTotal
used := freeUsed + grantUsed
@@ -329,7 +338,7 @@ func consumeCosQuota(ctx context.Context, userID int64, n int64, relatedID int64
}
// 3. 写消费流水(old/new 记录 Cos 有效剩余)。
before := cosRemainTx(tx, userID, vipExpire, now)
before := cosRemainTx(ctx, tx, userID, vipExpire, now)
flow := &mysqlmodel.QuotaFlowLog{
QuotaFlowUUID: genUUID(),
UserID: userID,
@@ -351,13 +360,15 @@ func consumeCosQuota(ctx context.Context, userID int64, n int64, relatedID int64
if errors.Is(err, errcode.QuotaInsufficient) {
return errcode.QuotaInsufficient
}
slog.ErrorContext(ctx, "consume cos quota: transaction failed",
"user_id", userID, "amount", n, "related_id", relatedID, "error", err)
return errcode.DatabaseError
}
return nil
}
// cosRemainTx 在事务内计算 Cos 有效剩余(免费剩余 + 有效凭证剩余)。
func cosRemainTx(tx *gorm.DB, userID int64, vipExpire, now int64) int64 {
func cosRemainTx(ctx context.Context, 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 {
@@ -372,7 +383,7 @@ func cosRemainTx(tx *gorm.DB, userID int64, vipExpire, now int64) int64 {
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)
slog.WarnContext(ctx, "calc cos grant remain failed", "user_id", userID, "error", err)
}
if freeRemain < 0 {
freeRemain = 0
@@ -382,7 +393,7 @@ func cosRemainTx(tx *gorm.DB, userID int64, vipExpire, now int64) int64 {
// refundCosQuotaTx 在事务内回退 Cos 额度并写流水。
// 回退策略(后进先出):优先回退免费额度(最后扣减的),不足再回退凭证(最后到期优先)。
func refundCosQuotaTx(tx *gorm.DB, userID int64, vipExpire int64, n int64, relatedID int64, reason string) error {
func refundCosQuotaTx(ctx context.Context, tx *gorm.DB, userID int64, vipExpire int64, n int64, relatedID int64, reason string) error {
now := nowUnix()
remain := n
@@ -431,10 +442,10 @@ func refundCosQuotaTx(tx *gorm.DB, userID int64, vipExpire int64, n int64, relat
flowReason := reason
if refunded < n {
flowReason = fmt.Sprintf("%s(实际回退 %d/%d,可用已退额度不足)", reason, refunded, n)
slog.WarnContext(context.Background(), "refund cos quota partially applied",
slog.WarnContext(ctx, "refund cos quota partially applied",
"user_id", userID, "want", n, "refunded", refunded, "related_id", relatedID)
}
after := cosRemainTx(tx, userID, vipExpire, now)
after := cosRemainTx(ctx, tx, userID, vipExpire, now)
flow := &mysqlmodel.QuotaFlowLog{
QuotaFlowUUID: genUUID(),
UserID: userID,
@@ -456,8 +467,9 @@ func refundCosQuotaTx(tx *gorm.DB, userID int64, vipExpire int64, n int64, relat
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)
return refundCosQuotaTx(ctx, tx, userID, vipExpire, n, relatedID, reason)
}); err != nil {
slog.WarnContext(ctx, "refund cos quota failed", "user_id", userID, "error", err)
slog.WarnContext(ctx, "refund cos quota failed",
"user_id", userID, "amount", n, "related_id", relatedID, "reason", reason, "error", err)
}
}
+170 -16
View File
@@ -43,8 +43,10 @@ func (s *UserService) WxLogin(ctx context.Context, request *dto.UserWxLoginReque
if err != nil {
var codeErr *wechat.Code2SessionError
if errors.As(err, &codeErr) && codeErr.IsInvalidCode() {
slog.WarnContext(ctx, "wx login: invalid code", "error", err)
return nil, errcode.UserWxCodeInvalid
}
slog.ErrorContext(ctx, "wx login: code2session failed", "error", err)
return nil, errcode.UserWxLoginFailed
}
@@ -55,6 +57,7 @@ func (s *UserService) WxLogin(ctx context.Context, request *dto.UserWxLoginReque
user, err := s.userDao.UserGetByOpenID(ctx, sess.OpenID)
if err != nil {
if !isNotFound(err) {
slog.ErrorContext(ctx, "wx login: query user failed", "error", err)
return nil, errcode.DatabaseError
}
// 首次登录:注册新用户。
@@ -70,11 +73,14 @@ func (s *UserService) WxLogin(ctx context.Context, request *dto.UserWxLoginReque
if cerr := s.userDao.UserCreate(ctx, user); cerr != nil {
if isDuplicateKey(cerr) {
// 并发注册冲突:重查已存在的用户继续登录。
slog.InfoContext(ctx, "wx login: concurrent register conflict, reload user")
user, err = s.userDao.UserGetByOpenID(ctx, sess.OpenID)
if err != nil {
slog.ErrorContext(ctx, "wx login: reload user after register conflict failed", "error", err)
return nil, errcode.DatabaseError
}
} else {
slog.ErrorContext(ctx, "wx login: create user failed", "error", cerr)
return nil, errcode.UserCreateFailed
}
} else {
@@ -83,7 +89,8 @@ func (s *UserService) WxLogin(ctx context.Context, request *dto.UserWxLoginReque
ensureDefaultQuota(ctx, user.ID)
// 赠送体验会员(best-effort);赠送成功后回读用户,
// 保证后续写入缓存与登录响应的是会员态,避免被内存中的非会员对象覆盖。
if s.ensureTrialVIP(ctx, user.ID) > 0 {
trialVIPExpire := s.ensureTrialVIP(ctx, user.ID)
if trialVIPExpire > 0 {
if fresh, ferr := s.userDao.UserGetByID(ctx, user.ID); ferr == nil {
user = fresh
} else {
@@ -92,25 +99,32 @@ func (s *UserService) WxLogin(ctx context.Context, request *dto.UserWxLoginReque
userCacheStale = true
}
}
// 赠送体验会员额度(best-effort):有效期固定为体验期结束时刻(trialVIPExpire),
// 不跟随 VIP 延长 —— 否则用户后续充值会把体验额度一直「继承」下去。
s.ensureTrialVIPQuota(ctx, user.ID, trialVIPExpire)
}
}
// 非正常状态账号(临时/永久封禁、已注销)禁止登录。
if user.Status != UserStatusNormal {
slog.WarnContext(ctx, "wx login: user disabled", "user_id", user.ID, "status", user.Status)
return nil, errcode.UserDisabled
}
if !isNew {
_ = s.userDao.UserUpdateByID(ctx, user.ID, map[string]any{"last_login_time": nowUnix()})
if uerr := s.userDao.UserUpdateByID(ctx, user.ID, map[string]any{"last_login_time": nowUnix()}); uerr != nil {
slog.WarnContext(ctx, "wx login: update last login time failed", "user_id", user.ID, "error", uerr)
}
}
accessToken, refreshToken, terr := lzcwyjwt.GenerateTokens(user.ID)
if terr != nil {
slog.ErrorContext(ctx, "wx login: generate tokens failed", "user_id", user.ID, "error", terr)
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)
slog.WarnContext(ctx, "set auth token failed, allow by fallback", "user_id", user.ID, "error", aerr)
}
// 缓存 session_key(虚拟支付 signature 计算依赖;best-effort,失败不阻断登录,
@@ -123,12 +137,16 @@ func (s *UserService) WxLogin(ctx context.Context, request *dto.UserWxLoginReque
// 刷新缓存中的用户信息(best-effort);对象已落后于数据库时不回写。
if !userCacheStale {
_ = redisdao.UserInfoSet(ctx, user, 0)
if cerr := redisdao.UserInfoSet(ctx, user, 0); cerr != nil {
slog.WarnContext(ctx, "wx login: cache user info failed", "user_id", user.ID, "error", cerr)
}
}
info := buildUserInfoResponse(user)
info.IsNewUser = isNew
slog.InfoContext(ctx, "user login", "user_id", user.ID, "is_new_user", isNew)
return &dto.UserWxLoginResponse{
AccessToken: accessToken,
RefreshToken: refreshToken,
@@ -148,16 +166,21 @@ func (s *UserService) InfoGetBySelf(ctx context.Context, userID int64) (*dto.Use
return &info, nil
} else if err != nil && !isCacheMiss(err) {
// 缓存异常不阻断,继续回落 DB。
slog.WarnContext(ctx, "get user info: cache unavailable, fallback to db", "user_id", userID, "error", err)
}
user, err := s.userDao.UserGetByID(ctx, userID)
if err != nil {
if isNotFound(err) {
slog.WarnContext(ctx, "get user info: user not found", "user_id", userID)
return nil, errcode.UserNotFound
}
slog.ErrorContext(ctx, "get user info: query user failed", "user_id", userID, "error", err)
return nil, errcode.DatabaseError
}
_ = redisdao.UserInfoSet(ctx, user, 0)
if cerr := redisdao.UserInfoSet(ctx, user, 0); cerr != nil {
slog.WarnContext(ctx, "get user info: backfill cache failed", "user_id", userID, "error", cerr)
}
info := buildUserInfoResponse(user)
s.consumeNewUserFlag(userID, &info)
@@ -216,10 +239,13 @@ func (s *UserService) ProfileUpdate(ctx context.Context, userID int64, request *
}
if err := s.userDao.UserUpdateByID(ctx, userID, updates); err != nil {
slog.ErrorContext(ctx, "profile update: update user failed", "user_id", userID, "error", err)
return nil, errcode.UserUpdateFailed
}
// 失效缓存,下次读取回填最新数据。
_ = redisdao.UserInfoDel(ctx, userID)
if derr := redisdao.UserInfoDel(ctx, userID); derr != nil {
slog.DebugContext(ctx, "profile update: invalidate user cache failed", "user_id", userID, "error", derr)
}
return &dto.UserProfileUpdateBySelfResponse{}, nil
}
@@ -231,31 +257,37 @@ func (s *UserService) AvatarUpload(ctx context.Context, userID int64, fileHeader
defer span.End()
if fileHeader == nil || fileHeader.Size == 0 {
slog.DebugContext(ctx, "avatar upload: empty file", "user_id", userID)
return nil, errcode.UploadFileEmpty
}
// 1. 校验扩展名与大小。
ext := upload.FileGetExt(fileHeader.Filename)
if !upload.FileCheckAllowExt(ext, global.UploadSetting.AllowedExtensions) {
slog.DebugContext(ctx, "avatar upload: extension not allowed", "user_id", userID, "ext", ext)
return nil, errcode.UploadFileExtNotAllow
}
if !upload.FileCheckMaxSize(fileHeader.Size, int64(global.UploadSetting.MaxSizeMB)) {
slog.DebugContext(ctx, "avatar upload: file too large", "user_id", userID, "size", fileHeader.Size)
return nil, errcode.UploadFileTooLarge
}
// 2. 读取原图字节(必须同步读取,请求返回后 multipart 临时文件会被清理)。
src, oerr := fileHeader.Open()
if oerr != nil {
slog.ErrorContext(ctx, "avatar upload: open file failed", "user_id", userID, "error", oerr)
return nil, errcode.UploadFileSaveFailed
}
imageData, rerr := io.ReadAll(src)
_ = src.Close()
if rerr != nil {
slog.ErrorContext(ctx, "avatar upload: read file failed", "user_id", userID, "error", rerr)
return nil, errcode.UploadFileSaveFailed
}
// 3. 校验文件内容为真实图片(magic bytes),防止伪造扩展名上传恶意文件。
if !upload.FileCheckImageMagic(imageData) {
slog.WarnContext(ctx, "avatar upload: magic bytes check failed", "user_id", userID, "size", fileHeader.Size)
return nil, errcode.UploadFileExtNotAllow
}
@@ -263,17 +295,21 @@ func (s *UserService) AvatarUpload(ctx context.Context, userID int64, fileHeader
user, uerr := s.userDao.UserGetByID(ctx, userID)
if uerr != nil {
if isNotFound(uerr) {
slog.WarnContext(ctx, "avatar upload: user not found", "user_id", userID)
return nil, errcode.UserNotFound
}
slog.ErrorContext(ctx, "avatar upload: query user failed", "user_id", userID, "error", uerr)
return nil, errcode.DatabaseError
}
fileName, nerr := upload.FileGenName(fileHeader.Filename)
if nerr != nil {
slog.ErrorContext(ctx, "avatar upload: generate file name failed", "user_id", userID, "error", nerr)
return nil, errcode.UploadFileSaveFailed
}
subDir := fmt.Sprintf("%s/%d/avatar", user.UserUUID, userID)
tmpPath, serr := upload.FileSaveTmpBytes(ctx, imageData, fileName, subDir)
if serr != nil {
slog.ErrorContext(ctx, "avatar upload: save temp file failed", "user_id", userID, "error", serr)
return nil, errcode.UploadFileSaveFailed
}
// 头像不消耗额度、上传接口也无限流,临时文件必须清理,
@@ -281,16 +317,20 @@ func (s *UserService) AvatarUpload(ctx context.Context, userID int64, fileHeader
defer func() { _ = os.Remove(tmpPath) }()
cosKey := fmt.Sprintf("%s/%s", subDir, fileName)
if cerr := upload.FileUploadCos(ctx, cosKey); cerr != nil {
slog.ErrorContext(ctx, "avatar upload: upload to cos failed", "user_id", userID, "cos_key", cosKey, "error", cerr)
return nil, errcode.UploadFileSaveFailed
}
// 5. 更新 avatar(存 COS key,读取时统一转 URL)。
if uerr := s.userDao.UserUpdateByID(ctx, userID, map[string]any{"avatar": cosKey}); uerr != nil {
slog.ErrorContext(ctx, "avatar upload: update user avatar failed", "user_id", userID, "error", uerr)
return nil, errcode.UserUpdateFailed
}
// 6. 失效缓存,下次读取回填最新头像。
_ = redisdao.UserInfoDel(ctx, userID)
if derr := redisdao.UserInfoDel(ctx, userID); derr != nil {
slog.DebugContext(ctx, "avatar upload: invalidate user cache failed", "user_id", userID, "error", derr)
}
return &dto.UserAvatarUploadResponse{Avatar: upload.GetCosUrl(cosKey)}, nil
}
@@ -303,8 +343,10 @@ func (s *UserService) RefreshToken(ctx context.Context, request *dto.UserRefresh
claims, err := lzcwyjwt.ParseRefreshToken(request.RefreshToken)
if err != nil {
if errors.Is(err, lzcwyjwt.ErrTokenExpired) {
slog.InfoContext(ctx, "refresh token: token expired")
return nil, errcode.UnauthorizedTokenTimeout
}
slog.WarnContext(ctx, "refresh token: parse token failed", "error", err)
return nil, errcode.UnauthorizedTokenError
}
@@ -312,35 +354,47 @@ func (s *UserService) RefreshToken(ctx context.Context, request *dto.UserRefresh
uid, aerr := redisdao.AuthUserIDByRefresh(ctx, request.RefreshToken)
if aerr != nil {
if isCacheMiss(aerr) {
slog.WarnContext(ctx, "refresh token: session not found", "user_id", claims.UserID)
return nil, errcode.UnauthorizedTokenError
}
slog.ErrorContext(ctx, "refresh token: get session failed", "user_id", claims.UserID, "error", aerr)
return nil, errcode.CacheError
}
if uid != claims.UserID {
slog.ErrorContext(ctx, "refresh token: session user mismatch", "user_id", claims.UserID, "session_user_id", uid)
return nil, errcode.UnauthorizedTokenError
}
accessToken, refreshToken, terr := lzcwyjwt.GenerateTokens(claims.UserID)
if terr != nil {
slog.ErrorContext(ctx, "refresh token: generate tokens failed", "user_id", claims.UserID, "error", terr)
return nil, errcode.UnauthorizedTokenGenerate
}
// 失效旧会话:强制携带旧 access token,精确吊销旧 access + 旧 refresh,防止旧 token 泄露后被继续使用。
if request.AccessToken == "" {
slog.InfoContext(ctx, "refresh token: old access token missing", "user_id", claims.UserID)
return nil, errcode.InvalidParams.WithDetails("缺少旧 access token")
}
oldUID, oerr := redisdao.AuthUserIDByAccess(ctx, request.AccessToken)
if oerr != nil && !isCacheMiss(oerr) {
slog.ErrorContext(ctx, "refresh token: get old access session failed", "user_id", claims.UserID, "error", oerr)
return nil, errcode.CacheError
}
if oerr == nil && oldUID != claims.UserID {
slog.ErrorContext(ctx, "refresh token: old access token user mismatch", "user_id", claims.UserID, "old_user_id", oldUID)
return nil, errcode.UnauthorizedTokenError
}
_ = redisdao.AuthTokenDel(ctx, claims.UserID, request.AccessToken, request.RefreshToken)
if derr := redisdao.AuthTokenDel(ctx, claims.UserID, request.AccessToken, request.RefreshToken); derr != nil {
slog.ErrorContext(ctx, "refresh token: revoke old tokens failed", "user_id", claims.UserID, "error", derr)
}
if serr := redisdao.AuthTokenSet(ctx, claims.UserID, accessToken, refreshToken); serr != nil {
slog.ErrorContext(ctx, "refresh token: save new tokens failed", "user_id", claims.UserID, "error", serr)
return nil, errcode.CacheError
}
slog.DebugContext(ctx, "refresh token: tokens rotated", "user_id", claims.UserID)
return &dto.UserRefreshTokenResponse{
AccessToken: accessToken,
RefreshToken: refreshToken,
@@ -349,15 +403,17 @@ func (s *UserService) RefreshToken(ctx context.Context, request *dto.UserRefresh
}
// defaultVipDays 返回新用户注册赠送的体验会员天数:
// 优先取 UserDefaultQuota.GiftVIPDay 的配置值,配置为 0 时生效(表示不赠送);
// 未配置(整段缺失或该项缺失,指针为 nil)或配置为负数时,回落内置常量 DefaultVipDays。
// 优先取 NewUserGift.GiftVIPDay(新位置);未配置该项时回落 UserDefaultQuota.GiftVIPDay(旧位置,兼容既有配置);
// 两者都不可用时回落内置常量 DefaultVipDays。显式配置为 0 表示不赠送;负数视为非法配置,继续往后回落。
func defaultVipDays() int64 {
cfg := global.UserDefaultQuotaSetting
if cfg == nil || cfg.GiftVIPDay == nil || *cfg.GiftVIPDay < 0 {
return DefaultVipDays
}
if cfg := global.NewUserGiftSetting; cfg != nil && cfg.GiftVIPDay != nil && *cfg.GiftVIPDay >= 0 {
return *cfg.GiftVIPDay
}
if cfg := global.UserDefaultQuotaSetting; cfg != nil && cfg.GiftVIPDay != nil && *cfg.GiftVIPDay >= 0 {
return *cfg.GiftVIPDay
}
return DefaultVipDays
}
// ensureTrialVIP 新用户注册赠送体验会员(best-effort:任何失败仅告警,不阻断注册)。
//
@@ -397,7 +453,10 @@ func (s *UserService) ensureTrialVIP(ctx context.Context, userID int64) int64 {
}
// 会员信息已变更,失效缓存(注册流程随后会用回读后的用户重新写入)。
_ = redisdao.UserInfoDel(ctx, userID)
if derr := redisdao.UserInfoDel(ctx, userID); derr != nil {
slog.DebugContext(ctx, "grant trial vip: invalidate user cache failed", "user_id", userID, "error", derr)
}
slog.InfoContext(ctx, "trial vip granted", "user_id", userID, "days", days, "vip_expire", flow.NewVIPExpire)
sendNotification(ctx, userID, 0, NotificationTypeSystem, "欢迎礼包已到账",
fmt.Sprintf("已为你开通 %d 天体验会员,有效期至 %s",
@@ -406,6 +465,96 @@ func (s *UserService) ensureTrialVIP(ctx context.Context, userID int64) int64 {
return flow.NewVIPExpire
}
// trialVIPQuotaValue 返回体验会员赠送的某类额度数量(NewUserGift 段配置)。
// 段缺失、该项未配置(指针为 nil)或非正数一律返回 0,表示不赠送该类额度。
func trialVIPQuotaValue(quotaType int8) int64 {
cfg := global.NewUserGiftSetting
if cfg == nil {
return 0
}
switch quotaType {
case QuotaTypeCos:
if cfg.Cos != nil && *cfg.Cos > 0 {
return *cfg.Cos
}
case QuotaTypeAI:
if cfg.AI != nil && *cfg.AI > 0 {
return *cfg.AI
}
case QuotaTypeOutfitShare:
if cfg.OutfitShare != nil && *cfg.OutfitShare > 0 {
return *cfg.OutfitShare
}
case QuotaTypeClosetShare:
if cfg.ClosetShare != nil && *cfg.ClosetShare > 0 {
return *cfg.ClosetShare
}
}
return 0
}
// ensureTrialVIPQuota 新用户注册赠送体验会员额度(best-effort:任何失败仅告警,不阻断注册)。
//
// expireAt 为体验期结束时刻(取自 ensureTrialVIP 的实际发放结果)。
//
// 这里刻意使用「固定到期时间」而不是 expire_time=0(跟随 VIP):
// 跟随 VIP 时 grantExpired 判断的是用户「当前」的 vip_expire,用户后续一充值就会把
// vip_expire 推后,体验额度被续费「继承」下去、永远不失效,失去「体验」的语义。
//
// 幂等:ref_id 固定为 register_gift_<userID>,依赖 quota_grants 的 (ref_id, quota_type) 唯一索引,
// 重复注册或重试不会重复发放,也不会重复写发放流水。
func (s *UserService) ensureTrialVIPQuota(ctx context.Context, userID, expireAt int64) {
if expireAt <= 0 {
// 未赠送体验会员(GiftVIPDay<=0),或本次未实际发放(幂等命中):
// 没有明确的体验期就不发额度,避免写入语义不清的凭证。
return
}
ctx, span := global.Tracer.Start(ctx, "svc.EnsureTrialVIPQuota")
defer span.End()
refID := fmt.Sprintf("register_gift_%d", userID)
grantDao := mysqldao.NewQuotaGrantDao()
for _, quotaType := range []int8{QuotaTypeCos, QuotaTypeAI, QuotaTypeOutfitShare, QuotaTypeClosetShare} {
amount := trialVIPQuotaValue(quotaType)
if amount <= 0 {
continue // 未配置或显式配 0:不赠送该类额度
}
grant := &mysqlmodel.QuotaGrant{
UserID: userID,
QuotaType: quotaType,
Amount: amount,
Source: QuotaGrantSourceTrial, // 体验会员赠送(与购买会员包 source=1 区分)
ExpireTime: expireAt, // 固定到期时间,不随后续续费延长
RefID: refID,
}
// 变更前后取「有效剩余」,仅用于额度明细展示(权威数值以凭证表为准),与购买发放路径保持一致。
before := int64(0)
if eff, qerr := getEffectiveQuota(ctx, userID, quotaType); qerr == nil {
before = eff.Remain
}
flow := &mysqlmodel.QuotaFlowLog{
QuotaFlowUUID: genUUID(),
UserID: userID,
ChangeTarget: QuotaChangeTargetAdjust,
ChangeType: QuotaChangeTypeActivity,
ChangeNum: amount,
OldValue: before,
NewValue: before + amount,
QuotaType: quotaType,
Reason: "新用户注册赠送体验会员额度",
}
if _, err := grantDao.QuotaGrantCreateWithFlow(ctx, grant, flow); err != nil {
slog.WarnContext(ctx, "grant trial vip quota failed",
"user_id", userID, "quota_type", quotaType, "amount", amount, "error", err)
}
}
}
// buildUserInfoResponse 将 User 模型映射为用户信息响应。
// 会员已过期(vip_expire <= now)时把 vip_type 归一为 1:项目没有会员到期归位任务,
// 过期会员的 vip_type 仍保留原值,归一可避免调用方仅凭 vip_type 误判为有效会员(只影响响应,不写库)。
@@ -440,10 +589,15 @@ func buildUserInfoResponse(user *mysqlmodel.User) dto.UserInfoGetBySelfResponse
func (s *UserService) resetNewUserFlag(userID int64) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// 异步任务无请求 ctx,派生独立 span 保证该路径日志携带 trace_id。
ctx, span := global.Tracer.Start(ctx, "svc.ResetNewUserFlag")
defer span.End()
if err := s.userDao.UserUpdateByID(ctx, userID, map[string]any{"is_new_user": false}); err != nil {
slog.WarnContext(ctx, "reset is_new_user failed", "user_id", userID, "error", err)
return
}
_ = redisdao.UserInfoDel(ctx, userID)
if derr := redisdao.UserInfoDel(ctx, userID); derr != nil {
slog.DebugContext(ctx, "reset is_new_user: invalidate user cache failed", "user_id", userID, "error", derr)
}
}
+84 -12
View File
@@ -24,25 +24,32 @@ func (s *OrderService) VirtualOrderCreate(ctx context.Context, userID int64, req
defer span.End()
if !wechat.IsVirtualPayEnabled() {
slog.WarnContext(ctx, "virtual order create: virtual pay not enabled", "user_id", userID)
return nil, errcode.VirtualPayNotEnabled
}
// 订单记录当前激活的虚拟支付环境(现网 0 / 沙箱 1),发货推送按此校验。
xpayEnv, verr := wechat.GetVirtualPayEnv()
if verr != nil {
slog.ErrorContext(ctx, "virtual order create: resolve pay env failed", "user_id", userID, "error", verr)
return nil, errcode.VirtualPayNotEnabled
}
goods, err := s.goodsDao.GoodsGetByCode(ctx, request.GoodsCode)
if err != nil {
if isNotFound(err) {
slog.InfoContext(ctx, "virtual order create: goods not found", "user_id", userID, "goods_code", request.GoodsCode)
return nil, errcode.GoodsNotFound
}
slog.ErrorContext(ctx, "virtual order create: query goods failed", "goods_code", request.GoodsCode, "error", err)
return nil, errcode.DatabaseError
}
if goods.Status != GoodsStatusOnShelf {
slog.InfoContext(ctx, "virtual order create: goods off shelf", "user_id", userID, "goods_code", request.GoodsCode)
return nil, errcode.GoodsOffShelf
}
if goods.ProductID == "" || goods.ProductStatus != GoodsProductPublished {
slog.WarnContext(ctx, "virtual order create: goods not published as xpay product",
"user_id", userID, "goods_code", request.GoodsCode, "product_id", goods.ProductID, "product_status", goods.ProductStatus)
return nil, errcode.VirtualPayGoodsNotPublished
}
// iOS 端微信限制最低支付 1 元(100 分),低于此金额调起支付必失败,提前拒绝以降低垃圾订单。
@@ -50,6 +57,8 @@ func (s *OrderService) VirtualOrderCreate(ctx context.Context, userID int64, req
// 此校验仅作体验优化,不构成资损防线——真正的资损防线是发货推送 verifyXPayOrderPaid
// 对微信侧订单真实性、金额、道具的反查。低金额商品在 iOS 上的垃圾订单靠过期清理兜底。
if request.Platform == XPayPlatformIOS && goods.DiscountPrice < 100 {
slog.InfoContext(ctx, "virtual order create: rejected low amount on ios",
"user_id", userID, "goods_code", request.GoodsCode, "pay_amount", goods.DiscountPrice)
return nil, errcode.VirtualPayAmountTooLow
}
@@ -57,8 +66,10 @@ func (s *OrderService) VirtualOrderCreate(ctx context.Context, userID int64, req
sessionKey, serr := redisdao.AuthSessionKeyGet(ctx, userID)
if serr != nil {
if serr == redis.Nil {
slog.WarnContext(ctx, "virtual order create: session key missing", "user_id", userID)
return nil, errcode.UnauthorizedTokenError.WithDetails("缺少 session_key,请重新登录")
}
slog.ErrorContext(ctx, "virtual order create: get session key failed", "user_id", userID, "error", serr)
return nil, errcode.CacheError
}
@@ -90,6 +101,8 @@ func (s *OrderService) VirtualOrderCreate(ctx context.Context, userID int64, req
XPayEnv: int8(xpayEnv),
}
if cerr := s.orderDao.OrderCreate(ctx, order); cerr != nil {
slog.ErrorContext(ctx, "virtual order create: save order failed",
"order_no", orderNo, "user_id", userID, "goods_code", request.GoodsCode, "error", cerr)
return nil, errcode.VirtualOrderCreateFailed
}
@@ -102,9 +115,13 @@ func (s *OrderService) VirtualOrderCreate(ctx context.Context, userID int64, req
payAmount,
)
if perr != nil {
slog.ErrorContext(ctx, "virtual order create: build payment sign failed", "order_no", orderNo, "user_id", userID, "error", perr)
return nil, errcode.VirtualOrderCreateFailed
}
slog.InfoContext(ctx, "virtual order created",
"order_no", orderNo, "user_id", userID, "goods_code", request.GoodsCode, "pay_amount", payAmount, "env", xpayEnv)
return &dto.VirtualOrderCreateResponse{
OrderNo: orderNo,
PayAmount: payAmount,
@@ -136,7 +153,8 @@ func (s *OrderService) XPayNotify(ctx context.Context, raw []byte) []byte {
return wechat.BuildNotifyResponse(false)
}
if e := s.handleGoodsDeliver(ctx, &deliver, string(raw)); e != nil {
slog.ErrorContext(ctx, "xpay deliver notify handle failed", "error", e)
// 内部失败分支已各自记录 Error 日志,这里仅保留调用链痕迹,避免重复告警。
slog.DebugContext(ctx, "xpay deliver notify handle failed", "error", e)
return wechat.BuildNotifyResponse(false)
}
return wechat.BuildNotifyResponse(true)
@@ -148,7 +166,8 @@ func (s *OrderService) XPayNotify(ctx context.Context, raw []byte) []byte {
return wechat.BuildNotifyResponse(false)
}
if e := s.handleRefundNotify(ctx, &refund); e != nil {
slog.ErrorContext(ctx, "xpay refund notify handle failed", "error", e)
// 内部失败分支已各自记录 Error 日志,这里仅保留调用链痕迹,避免重复告警。
slog.DebugContext(ctx, "xpay refund notify handle failed", "error", e)
return wechat.BuildNotifyResponse(false)
}
return wechat.BuildNotifyResponse(true)
@@ -241,7 +260,13 @@ func (s *OrderService) verifyXPayRefund(ctx context.Context, order *mysqlmodel.O
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 {
ok, lerr := redisdao.TryLock(ctx, lockKey, lockVal, 30*time.Second)
if lerr != nil {
slog.ErrorContext(ctx, "xpay deliver: acquire lock failed", "out_trade_no", deliver.OutTradeNo, "error", lerr)
return nil // 锁不可用(Redis 故障):不重复处理,交由微信重试/持锁者。
}
if !ok {
slog.DebugContext(ctx, "xpay deliver: concurrent callback, skip", "out_trade_no", deliver.OutTradeNo)
return nil // 并发回调,交由持锁者处理。
}
defer func() { _ = redisdao.Unlock(ctx, lockKey, lockVal) }()
@@ -252,6 +277,7 @@ func (s *OrderService) handleGoodsDeliver(ctx context.Context, deliver *wechat.X
slog.ErrorContext(ctx, "xpay deliver: order not found", "out_trade_no", deliver.OutTradeNo)
return errcode.OrderRealityCheckFailed
}
slog.ErrorContext(ctx, "xpay deliver: query order failed", "out_trade_no", deliver.OutTradeNo, "error", gerr)
return errcode.DatabaseError
}
@@ -287,9 +313,12 @@ func (s *OrderService) handleGoodsDeliver(ctx context.Context, deliver *wechat.X
// 条件流转:待支付 -> 已支付,防重复回调。
updated, uerr := s.orderDao.OrderUpdateStatusByOrderNo(ctx, deliver.OutTradeNo, OrderStatusPending, OrderStatusPaid)
if uerr != nil {
slog.ErrorContext(ctx, "xpay deliver: update order status failed", "out_trade_no", deliver.OutTradeNo, "error", uerr)
return errcode.DatabaseError
}
if updated {
slog.InfoContext(ctx, "xpay deliver: order paid",
"order_no", order.OrderNo, "user_id", order.UserID, "pay_amount", order.PayAmount)
s.finalizeVirtualPaidOrder(ctx, order, deliver.WeChatPayInfo.PaidTime, deliver.WeChatPayInfo.TransactionID, raw)
return nil
}
@@ -298,33 +327,46 @@ func (s *OrderService) handleGoodsDeliver(ctx context.Context, deliver *wechat.X
current, gerr := s.orderDao.OrderGetByOrderNo(ctx, deliver.OutTradeNo)
if gerr != nil {
if isNotFound(gerr) {
slog.WarnContext(ctx, "xpay deliver: order disappeared after status conflict", "out_trade_no", deliver.OutTradeNo)
return nil
}
slog.ErrorContext(ctx, "xpay deliver: reload order failed", "out_trade_no", deliver.OutTradeNo, "error", gerr)
return errcode.DatabaseError
}
switch current.OrderStatus {
case OrderStatusPaid:
// 已被并发回调处理,兜底补发(幂等)。
_ = s.orderDao.OrderUpdateByOrderNo(ctx, current.OrderNo, map[string]any{
if uerr := s.orderDao.OrderUpdateByOrderNo(ctx, current.OrderNo, map[string]any{
"pay_time": resolvePaidTime(deliver.WeChatPayInfo.PaidTime),
"transaction_id": deliver.WeChatPayInfo.TransactionID,
"callback_raw": raw,
})
}); uerr != nil {
slog.ErrorContext(ctx, "xpay deliver: reconcile paid order pay info failed", "order_no", current.OrderNo, "error", uerr)
}
slog.InfoContext(ctx, "xpay deliver: order already paid, reconcile benefits", "order_no", current.OrderNo, "user_id", current.UserID)
s.grantOrderBenefits(ctx, current)
case OrderStatusExpired:
// 订单被系统过期但用户已付款:过期 -> 已支付并补发货。
if ok, uerr := s.orderDao.OrderUpdateStatusByOrderNo(ctx, deliver.OutTradeNo, OrderStatusExpired, OrderStatusPaid); uerr != nil {
slog.ErrorContext(ctx, "xpay deliver: compensate expired order failed", "out_trade_no", deliver.OutTradeNo, "error", uerr)
return errcode.DatabaseError
} else if ok {
slog.InfoContext(ctx, "xpay deliver: expired order compensated", "order_no", current.OrderNo, "user_id", current.UserID)
s.finalizeVirtualPaidOrder(ctx, current, deliver.WeChatPayInfo.PaidTime, deliver.WeChatPayInfo.TransactionID, raw)
} else {
slog.DebugContext(ctx, "xpay deliver: expired order already handled by another callback", "out_trade_no", deliver.OutTradeNo)
}
case OrderStatusCancelled:
// 防御分支:历史上已取消的虚拟支付订单用户仍完成了支付,补发货避免「钱已扣、权益不到账」。
// 当前虚拟支付订单已禁止取消,此分支兜底存量数据与并发竞态。
if ok, uerr := s.orderDao.OrderUpdateStatusByOrderNo(ctx, deliver.OutTradeNo, OrderStatusCancelled, OrderStatusPaid); uerr != nil {
slog.ErrorContext(ctx, "xpay deliver: compensate cancelled order failed", "out_trade_no", deliver.OutTradeNo, "error", uerr)
return errcode.DatabaseError
} else if ok {
slog.InfoContext(ctx, "xpay deliver: cancelled order compensated", "order_no", current.OrderNo, "user_id", current.UserID)
s.finalizeVirtualPaidOrder(ctx, current, deliver.WeChatPayInfo.PaidTime, deliver.WeChatPayInfo.TransactionID, raw)
} else {
slog.DebugContext(ctx, "xpay deliver: cancelled order already handled by another callback", "out_trade_no", deliver.OutTradeNo)
}
}
return nil
@@ -334,7 +376,13 @@ func (s *OrderService) handleGoodsDeliver(ctx context.Context, deliver *wechat.X
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 {
ok, lerr := redisdao.TryLock(ctx, lockKey, lockVal, 30*time.Second)
if lerr != nil {
slog.ErrorContext(ctx, "xpay refund: acquire lock failed", "out_refund_no", refund.RefundOrderID, "error", lerr)
return nil // 锁不可用(Redis 故障):不重复处理,交由微信重试/持锁者。
}
if !ok {
slog.DebugContext(ctx, "xpay refund: concurrent callback, skip", "out_refund_no", refund.RefundOrderID)
return nil
}
defer func() { _ = redisdao.Unlock(ctx, lockKey, lockVal) }()
@@ -343,7 +391,8 @@ func (s *OrderService) handleRefundNotify(ctx context.Context, refund *wechat.XP
// (退款推送按官方文档在退款完成时触发,状态枚举联调时以官方为准)。
if refund.RefundStatus != wechat.XPayRefundStatusSuccess {
slog.WarnContext(ctx, "xpay refund notify not success, skip revoke",
"order_no", refund.OutTradeNo, "refund_status", refund.RefundStatus)
"order_no", refund.OutTradeNo, "refund_order_id", refund.RefundOrderID,
"refund_status", refund.RefundStatus, "refund_fee", refund.RefundFee)
return nil
}
@@ -353,10 +402,12 @@ func (s *OrderService) handleRefundNotify(ctx context.Context, refund *wechat.XP
slog.ErrorContext(ctx, "xpay refund: order not found", "out_trade_no", refund.OutTradeNo)
return nil
}
slog.ErrorContext(ctx, "xpay refund: query order failed", "out_trade_no", refund.OutTradeNo, "error", gerr)
return errcode.DatabaseError
}
// 已终态则幂等返回。
if order.OrderStatus == OrderStatusRefunded {
slog.DebugContext(ctx, "xpay refund: order already refunded, skip", "order_no", order.OrderNo)
return nil
}
@@ -367,7 +418,11 @@ func (s *OrderService) handleRefundNotify(ctx context.Context, refund *wechat.XP
}
// settleRefundSuccess 内部含条件流转(Paid -> Refunded),重复推送不会重复回退。
s.settleRefundSuccess(ctx, order, refund.RefundFee)
// 内部失败已由 settleRefundSuccess 的使用方日志覆盖:此处接收返回值并记录,避免静默。
if serr := s.settleRefundSuccess(ctx, order, refund.RefundFee); serr != nil {
slog.ErrorContext(ctx, "xpay refund: settle refund success failed",
"order_no", order.OrderNo, "refund_order_id", refund.RefundOrderID, "error", serr)
}
return nil
}
@@ -377,20 +432,29 @@ func (s *OrderService) handleRefundNotify(ctx context.Context, refund *wechat.XP
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)
if isNotFound(err) {
slog.ErrorContext(ctx, "xpay ios refund query: order not found", "out_trade_no", query.OutTradeNo)
} else {
slog.ErrorContext(ctx, "xpay ios refund query: query order failed", "out_trade_no", query.OutTradeNo, "error", err)
}
return wechat.BuildIOSRefundQueryResponse(1, "拒绝退款",
"订单号 "+query.OutTradeNo+" 在本地无记录,无法确认权益使用情况,拒绝退款")
}
// 已退款:款项已退回,再次建议退款会造成重复退款资损。
if order.OrderStatus == OrderStatusRefunded {
slog.InfoContext(ctx, "xpay ios refund query: reject, order already refunded", "order_no", order.OrderNo)
return wechat.BuildIOSRefundQueryResponse(1, "拒绝退款",
"订单 "+order.OrderNo+" 已处于已退款状态,不支持重复退款")
}
// 权益已消耗则拒绝退款,防止「已使用权益后白嫖退款」。
if s.iosRefundBenefitsConsumed(ctx, order) {
slog.InfoContext(ctx, "xpay ios refund query: reject, benefits consumed",
"order_no", order.OrderNo, "user_id", order.UserID)
return wechat.BuildIOSRefundQueryResponse(1, "拒绝退款",
"订单 "+order.OrderNo+" 发放的权益已被使用(上传额度已消耗或会员已生效),不支持退款")
}
slog.InfoContext(ctx, "xpay ios refund query: suggest refund, benefits unused",
"order_no", order.OrderNo, "user_id", order.UserID)
return wechat.BuildIOSRefundQueryResponse(0, "建议退款",
"订单 "+order.OrderNo+" 发放的权益尚未使用,建议退款")
}
@@ -401,7 +465,10 @@ func (s *OrderService) iosRefundBenefitsConsumed(ctx context.Context, order *mys
// 额度类:该订单发放的 Cos 凭证已消耗则视为已使用。
if order.CosQuota > 0 {
grants, err := mysqldao.NewQuotaGrantDao().QuotaGrantListByUserIDAndType(ctx, order.UserID, QuotaTypeCos)
if err == nil {
if err != nil {
slog.WarnContext(ctx, "xpay ios refund query: list cos grants failed, treat as not consumed",
"order_no", order.OrderNo, "user_id", order.UserID, "error", err)
} else {
for _, g := range grants {
if g.RefID == order.OrderNo && g.Used > 0 {
return true
@@ -415,6 +482,9 @@ func (s *OrderService) iosRefundBenefitsConsumed(ctx context.Context, order *mys
if user.VIPExpire > nowUnix() && user.VIPType != VIPTypeNone {
return true
}
} else {
slog.WarnContext(ctx, "xpay ios refund query: get user failed, treat vip as not consumed",
"order_no", order.OrderNo, "user_id", order.UserID, "error", err)
}
}
return false
@@ -423,11 +493,13 @@ func (s *OrderService) iosRefundBenefitsConsumed(ctx context.Context, order *mys
// finalizeVirtualPaidOrder 虚拟支付发货:记录支付信息、发放权益并发送通知。
// paidTime/transactionID 由调用方提供:推送路径取推送报文,查单补偿路径取 query_order 结果。
func (s *OrderService) finalizeVirtualPaidOrder(ctx context.Context, order *mysqlmodel.Order, paidTime int64, transactionID, raw string) {
_ = s.orderDao.OrderUpdateByOrderNo(ctx, order.OrderNo, map[string]any{
if uerr := s.orderDao.OrderUpdateByOrderNo(ctx, order.OrderNo, map[string]any{
"pay_time": resolvePaidTime(paidTime),
"transaction_id": transactionID,
"callback_raw": raw,
})
}); uerr != nil {
slog.ErrorContext(ctx, "finalize virtual paid order: update pay info failed", "order_no", order.OrderNo, "error", uerr)
}
s.grantOrderBenefits(ctx, order)
+24 -5
View File
@@ -80,10 +80,19 @@ func runVirtualPayConfirm() {
lockKey := redismodel.KeyLockVirtualConfirm
lockVal := genUUID()
ok, lerr := redisdao.TryLock(ctx, lockKey, lockVal, virtualConfirmLockTTL)
if lerr != nil || !ok {
if lerr != nil {
slog.ErrorContext(ctx, "virtual confirm: acquire lock failed", "error", lerr)
return
}
defer redisdao.Unlock(context.Background(), lockKey, lockVal)
if !ok {
slog.DebugContext(ctx, "virtual confirm: another instance running, skip")
return
}
defer func() {
if uerr := redisdao.Unlock(context.Background(), lockKey, lockVal); uerr != nil {
slog.WarnContext(ctx, "virtual confirm: release lock failed", "error", uerr)
}
}()
now := nowUnix()
orders, err := mysqldao.NewOrderDao().
@@ -93,6 +102,7 @@ func runVirtualPayConfirm() {
return
}
if len(orders) == 0 {
slog.DebugContext(ctx, "virtual confirm: no unconfirmed orders in window")
return
}
if len(orders) >= virtualConfirmBatchSize {
@@ -121,6 +131,7 @@ func runVirtualPayConfirm() {
targets = append(targets, &orders[i])
}
if len(targets) == 0 {
slog.DebugContext(ctx, "virtual confirm: all candidates skipped by unpaid cache", "candidates", len(orders))
return
}
@@ -139,7 +150,7 @@ func runVirtualPayConfirm() {
defer func() { <-sem }()
defer func() {
if r := recover(); r != nil {
slog.Error("virtual confirm worker panic", "order_no", o.OrderNo, "recover", r)
slog.ErrorContext(ctx, "virtual confirm worker panic", "order_no", o.OrderNo, "recover", r)
}
}()
if s.confirmVirtualOrder(ctx, o) {
@@ -154,6 +165,9 @@ func runVirtualPayConfirm() {
if confirmed > 0 {
slog.InfoContext(ctx, "virtual pay confirm compensated orders",
"scanned", len(orders), "queried", len(targets), "confirmed", confirmed)
} else {
slog.DebugContext(ctx, "virtual pay confirm finished without compensation",
"scanned", len(orders), "queried", len(targets))
}
}
@@ -163,11 +177,16 @@ func runVirtualPayConfirm() {
func (s *OrderService) confirmVirtualOrder(ctx context.Context, order *mysqlmodel.Order) bool {
// query_order 需要 openid,订单表不冗余该字段,按 user_id 取。
user, err := s.userDao.UserGetByID(ctx, order.UserID)
if err != nil || user.WxOpenID == "" {
slog.WarnContext(ctx, "virtual confirm: user openid unavailable",
if err != nil {
slog.ErrorContext(ctx, "virtual confirm: get user failed",
"order_no", order.OrderNo, "user_id", order.UserID, "error", err)
return false
}
if user.WxOpenID == "" {
slog.WarnContext(ctx, "virtual confirm: user openid unavailable",
"order_no", order.OrderNo, "user_id", order.UserID)
return false
}
info, qerr := wechat.QueryOrder(ctx, user.WxOpenID, order.OrderNo, "")
if qerr != nil {
+34 -11
View File
@@ -9,6 +9,7 @@ import (
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
@@ -70,6 +71,9 @@ func main() {
// 清理超期未保存的 AI 推荐穿搭(status=3),避免长期堆积。
service.StartAIOutfitCleanupScheduler()
// 为已过期的固定有效期额度凭证补记「到期失效」流水,让额度明细闭环。
service.StartQuotaExpireScheduler()
router := routers.NewRouter()
s := &http.Server{
@@ -220,6 +224,12 @@ func setupSetting() error {
return err
}
// 新用户注册赠送的体验会员及其额度(与上面的免费额度相互独立、叠加生效)。
err = setting.ReadSection("NewUserGift", &global.NewUserGiftSetting)
if err != nil {
return err
}
err = setting.ReadSection("AdReward", &global.AdRewardSetting)
if err != nil {
return err
@@ -250,36 +260,49 @@ func setupSlogLogger() error {
MaxBackups: global.LogSetting.MaxBackups,
MaxAge: global.LogSetting.MaxAge,
Compress: true,
LocalTime: true, // 轮转文件名时间戳使用本地时区(默认 UTC,与业务展示时区不一致)
}
multiWriter := io.MultiWriter(writer, os.Stdout)
// 级别匹配忽略大小写与首尾空白,避免 "debug"/"Info " 等误配静默回落 Info。
level := slog.LevelInfo
switch global.LogSetting.LogLevel {
case "Debug":
unknownLevel := ""
switch strings.ToLower(strings.TrimSpace(global.LogSetting.LogLevel)) {
case "debug":
level = slog.LevelDebug
case "Warn":
case "", "info":
level = slog.LevelInfo
case "warn", "warning":
level = slog.LevelWarn
case "Error":
case "error":
level = slog.LevelError
default:
level = slog.LevelInfo
unknownLevel = global.LogSetting.LogLevel
}
// 本地 JSON 日志(文件 + stdout),并注入 trace_id/span_id。
localHandler := tracing.OtelContextHandler{
Handler: slog.NewJSONHandler(multiWriter, &slog.HandlerOptions{
// 本地 JSON 日志(文件 + stdout)。
// AddSource 保留全量开启:按级别裁剪需自定义 Handler(slog.HandlerOptions 不支持),
// 复杂度与收益不成比例;后续如需降本可在此处自定义 Handler 实现。
localHandler := 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)))
// trace_id/span_id 注入放在扇出外层:FanoutHandler 对子 handler 使用 record 副本分发(r.Clone()),
// 在外层 AddAttrs 后两个分支都能拿到,本地输出等价、OTel 桥接分支也能携带链路标识。
slog.SetDefault(slog.New(tracing.OtelContextHandler{
Handler: tracing.NewFanoutHandler(localHandler, otelHandler),
}))
if unknownLevel != "" {
slog.Warn("unknown log level in config, fallback to info", "configured", unknownLevel)
}
return nil
}
+22 -3
View File
@@ -125,17 +125,36 @@ type VirtualPaySettingS struct {
Enable bool // 是否启用虚拟支付;未开通时置 false,虚拟商品下单返回「虚拟支付未开通」
}
// UserDefaultQuotaSettingS 新用户注册时初始化的免费额度与赠送会员天数配置。
// UserDefaultQuotaSettingS 新用户注册时初始化的免费额度配置。
// 使用指针类型以区分「未配置」与「显式配置为 0」:
// - nil:该项未配置(或整段缺失),由 service 层回落内置默认常量;
// - 非 nil:以配置值为准,允许为 0(额度类表示不赠送,GiftVIPDay 表示不赠送体验会员);
// - 非 nil:以配置值为准,允许为 0(表示不赠送该类免费额度);
// - 负数:视为非法配置,回落内置默认常量。
type UserDefaultQuotaSettingS struct {
Cos *int64 // 免费上传额度(永久,不每天重置)
AI *int64 // 免费 AI 推荐额度(每日,每天 0 点重置)
OutfitShare *int64 // 免费社区分享额度(每日,每天 0 点重置)
ClosetShare *int64 // 免费衣柜分享额度(每日,每天 0 点重置;0 表示不赠送)
GiftVIPDay *int64 // 注册赠送的体验会员天数(vip_type=4,0 表示不赠送)
// [已废弃] 体验会员天数已迁至 NewUserGift.GiftVIPDay。
// 保留此字段仅为兼容旧配置:新位置未配置时回落读这里。
GiftVIPDay *int64
}
// NewUserGiftSettingS 新用户注册赠送的体验会员及其额度配置。
//
// 与 UserDefaultQuota 的区别:
// - UserDefaultQuota 是「免费额度」,所有用户长期享有,不依赖会员身份;
// - 本段是「注册赠送」,其中额度以体验会员凭证(source=4)发放,有效期是
// 「注册时刻 + 体验天数」这个固定值,不随后续续费延长(否则用户充值后会把
// 体验额度一直「继承」下去);因此仅在 GiftVIPDay > 0 时才发放。
//
// 指针语义同 UserDefaultQuotaSettingS:nil 回落内置默认,显式 0 表示不赠送,负数视为非法。
type NewUserGiftSettingS struct {
GiftVIPDay *int64 // 赠送的体验会员天数(vip_type=4);0 表示不赠送体验会员
Cos *int64 // 体验会员赠送的上传额度
AI *int64 // 体验会员赠送的 AI 推荐额度
OutfitShare *int64 // 体验会员赠送的社区分享额度
ClosetShare *int64 // 体验会员赠送的衣柜分享额度
}
// AdRewardSettingS 广告激励额度配置。
+16 -7
View File
@@ -111,11 +111,20 @@ func runFortuneGenerationWithLock(client *Client, horoscopeDao *mysqldao.Horosco
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)
if lerr != nil {
// Redis 故障与「其它实例正在执行」必须区分:前者当天运势可能缺失且无人知晓。
slog.ErrorContext(ctx, "fortune generation: acquire lock failed", "date", today, "error", lerr)
return
}
defer redisdao.Unlock(context.Background(), lockKey, lockVal)
if !ok {
slog.InfoContext(ctx, "fortune generation already in progress, skip", "date", today)
return
}
defer func() {
if uerr := redisdao.Unlock(context.Background(), lockKey, lockVal); uerr != nil {
slog.WarnContext(ctx, "fortune generation: release lock failed", "date", today, "error", uerr)
}
}()
runFortuneGeneration(client, horoscopeDao)
}
@@ -145,21 +154,21 @@ func EnsureTodayFortunes() {
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)
slog.ErrorContext(ctx, "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)
slog.WarnContext(ctx, "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))
slog.InfoContext(ctx, "EnsureTodayFortunes: today fortunes already complete, skip", "date", today, "count", len(horoscopes))
return
}
slog.Info("EnsureTodayFortunes: today fortunes incomplete, generating...",
slog.InfoContext(ctx, "EnsureTodayFortunes: today fortunes incomplete, generating...",
"date", today,
"existing", len(horoscopes),
"needed", 12,
+32 -9
View File
@@ -473,11 +473,11 @@ Authorization: Bearer <access_token>
| 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 | 变更数量(**正数为增加,负数为减少**) |
| change_type | int | `1`=购买获赠 `2`=活动赠送 `3`=上传消耗 `4`=删除回退 `5`=人工调整 `6`=退款回收 `7`=**到期失效**(固定有效期的额度凭证过期,由后台任务每小时补记) |
| change_num | int64 | 变更数量(**正数为增加,负数为减少**;到期失效为负数) |
| new_value | int64 | 变更后的数值 |
| reason | string | 变更原因 / 备注 |
| order_no | string | 关联订单号(购买类流水才有) |
| order_no | string | 关联订单号:购买/退款类为真实订单号;赠送/消耗/回退/到期类由后端填充流水 UUID 或凭证 `ref_id`,用于满足唯一索引幂等 |
| created_at | int64 | 创建时间(秒) |
### 4.8 微信支付参数 `pay_params`
@@ -507,7 +507,11 @@ Authorization: Bearer <access_token>
**业务逻辑**
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`)。
2. **首次登录自动注册**:昵称默认"微信用户",`status=1` 正常,返回 `is_new_user=true`,随后依次赠送两样东西:
- **免费额度**(`UserDefaultQuota` 段,与会员身份无关、所有用户长期享有):内置默认上传 20 / AI 推荐 3 / 搭配分享 10 / 衣柜分享 5,允许显式配置为 0;
- **体验会员及其额度**(`NewUserGift` 段):开通 `vip_type=4` 体验会员(内置默认 3 天,配 0 表示不赠送会员),并按该段配置发放**体验会员额度凭证**(`source=4`,如右上表;有效期是**固定值** = 注册时刻 + 体验天数,**不随后续续费延长**)。内置默认上传 0 / AI 3 / 搭配分享 3 / 衣柜分享 3。这部分额度与上面的免费额度**叠加生效**。
两段配置都支持显式配 0 表示不赠送;`GiftVIPDay` 配 0 时,体验会员额度一律不发放。赠送成功后下发一条系统通知「欢迎礼包已到账」。因此新用户返回的 `vip_type` 通常为 `4`(配置为 0 时为 `1`)。
3. 并发首次登录不会注册重复账号(依赖 `wx_open_id` 唯一索引)。
4. 账号被禁用/注销(`status != 1`)时返回 `21001`,HTTP 仍是 200。
5. 额度初始化失败**不影响登录**(仅后端告警)。
@@ -617,7 +621,11 @@ Authorization: Bearer <access_token>
### 5.2 额度模块
全站共 **4 类额度**:**上传额度为永久额度(不每天重置)**;**AI 推荐 / 社区分享 / 衣柜分享为每日额度(每天 0 点重置)**。免费额度由服务端配置 `UserDefaultQuota` 覆盖(下表「新用户默认」为内置兜底值,**允许配置为 0**,表示不赠送该类免费额度):
全站共 **4 类额度**:**上传额度为永久额度(不每天重置)**;**AI 推荐 / 社区分享 / 衣柜分享为每日额度(每天 0 点重置)**。
额度有两个来源,**叠加生效**:
**① 免费额度** —— 由服务端配置 `UserDefaultQuota` 控制,所有用户长期享有、与会员身份无关(下表「新用户默认」为内置兜底值,**允许配置为 0**,表示不赠送该类免费额度):
| quota_type | 名称 | 类型 | 新用户默认 | 消耗场景 |
| --- | --- | --- | --- | --- |
@@ -626,7 +634,18 @@ Authorization: Bearer <access_token>
| 3 | 社区分享额度(搭配分享) | 每日 | 10 | 穿搭首次公开扣 1 |
| 4 | 衣柜分享额度 | 每日 | 5 | 创建一次衣柜分享扣 1 |
> 购买赠送额度以**额度凭证**(`quota_grants`)发放,分三类来源:**会员包**(随 VIP 有效期,续期自动延长)、**加量包**(独立有效期 `goods.valid_days`,到期失效)、**广告激励**(当天 23:59:59 到期,金额由服务端 `AdReward` 配置固定,开关控制)。Cos 消费先扣「即将过期凭证」再扣免费额度;每日额度总额 = 免费配置 + 有效凭证赠送额。
**② 额度凭证**(`quota_grants`)—— 有有效期的赠送额度,按来源分三类:
| source | 来源 | 有效期 |
| --- | --- | --- |
| 1 | 会员包(购买) | `expire_time=0`,**跟随 VIP**:VIP 到期自动失效,续期自动延长 |
| 2 | 加量包(购买) | 独立有效期 `goods.valid_days`,到期失效 |
| 3 | 广告激励 | 当天 23:59:59 到期(金额由 `AdReward` 配置固定,开关控制) |
| 4 | 体验会员赠送(新用户注册) | **固定有效期** = 注册时刻 + 体验天数,**不随后续续费延长** |
> 新用户注册时,`NewUserGift` 段配置的体验会员额度以 **source=4** 凭证发放;`GiftVIPDay` 配 0(不赠送会员)时这部分额度一律不发放。
> ⚠️ 体验额度**不会**被后续充值「继承」:用户 3 天体验期后再购买会员,体验额度照常失效,只剩免费额度 + 新购会员包额度。
> Cos 消费先扣「即将过期凭证」再扣免费额度;每日额度总额 = 免费配置 + 有效凭证赠送额。
#### GET `/quota/info` — 额度概览(鉴权)
@@ -1827,8 +1846,9 @@ wx.requestVirtualPayment({
```mermaid
flowchart TD
A["微信登录<br/>新用户自动注册"] --> B["初始化免费额度<br/>默认 上传 20 / AI 3 / 搭配分享 10 / 衣柜分享 5<br/>(可配置,允许为 0)"]
B --> C["上传衣物<br/>校验 → 扣上传额度 → 异步 AI 抠图 + 分类"]
A["微信登录<br/>新用户自动注册"] --> B["初始化 4 类免费额度<br/>默认 上传 20 / AI 3 / 搭配分享 10 / 衣柜分享 5<br/>(可配置,允许为 0)"]
B --> B2["赠送体验会员 + 会员额度<br/>默认 3 天;AI 3 / 分享 3 / 衣柜 3<br/>(体验会员凭证 source=4,固定到期)"]
B2 --> C["上传衣物<br/>校验 → 扣上传额度 → 异步 AI 抠图 + 分类"]
C --> D["衣橱管理<br/>查看 / 修正属性 / 删除回退额度"]
D --> E["创建穿搭"]
D --> F["AI 穿搭推荐<br/>扣 AI 额度 → 大模型生成 → 待用户保存"]
@@ -1982,6 +2002,9 @@ stateDiagram-v2
| 创建衣柜分享 | **扣 1 个衣柜分享额度** | — |
| 创建衣柜分享但明细写入失败 | **自动回退** | 重试即可 |
| 取消衣柜分享 | **不返还** | 二次确认 |
| 新用户注册 | **发放**:免费额度 + 体验会员额度(`NewUserGift`,`source=4` 凭证,固定有效期=体验期结束) | 「欢迎礼包已到账」 |
| 固定有效期额度过期 | **自动失效**:到期即不再计入有效额度;**后续充值续费不会延长它**(跟随 VIP 的凭证则不受影响,续期自动延长) | 无感知,剩余额度同步下降 |
| 额度过期记账 | 后台任务**每小时**为已过期的固定有效期凭证补记 `change_type=7`(到期失效)流水,变更量为负数(幂等,重复执行不会重复记账) | 额度明细里能看到 `-N 到期失效`,账目闭环 |
| 购买商品支付成功 | **发放**(按订单快照) | 展示"已到账" |
| 退款成功 | **回收**(`total >= used`,已用部分不退) | 提示额度已回收 |
@@ -2016,7 +2039,7 @@ sequenceDiagram
F->>B: POST /user/wxlogin {code}
B->>W: code2session(code)
W-->>B: openid / session_key
B->>B: 首次登录:注册用户 + 初始化 4 类免费额度
B->>B: 首次登录:注册用户 + 初始化 4 类免费额度 + 赠送体验会员及会员额度
B-->>F: {access_token, refresh_token, expire_time, user_info}
Note over F,B: 后续请求携带 Authorization: Bearer <access_token>