diff --git a/.codebuddy/plans/日志打点全量评估报告_41e5bf30.md b/.codebuddy/plans/日志打点全量评估报告_41e5bf30.md new file mode 100644 index 0000000..7df4f21 --- /dev/null +++ b/.codebuddy/plans/日志打点全量评估报告_41e5bf30.md @@ -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: 产出结构化的打点明细清单与关键路径覆盖情况数据,作为评估报告"问题清单"与"覆盖度对照矩阵"的可靠事实依据,确保报告中所有定位与结论准确可核验。 \ No newline at end of file diff --git a/.codebuddy/plans/日志问题全量修复_bfe8dc83.md b/.codebuddy/plans/日志问题全量修复_bfe8dc83.md new file mode 100644 index 0000000..7d0352c --- /dev/null +++ b/.codebuddy/plans/日志问题全量修复_bfe8dc83.md @@ -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: 确认相关符号全部引用点均在预期范围内,签名变更不影响无关调用 \ No newline at end of file diff --git a/README.md b/README.md index 16f697c..83292e7 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,8 @@ curl http://localhost:/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:/ping ```mermaid flowchart TD - A["微信登录
新用户自动注册"] --> B["初始化免费额度
默认 上传 20 / AI 3 / 搭配分享 10 / 衣柜分享 5
(可配置,允许为 0)"] - B --> C["上传衣物
校验 → 扣上传额度 → 异步 AI 抠图 + 分类"] + A["微信登录
新用户自动注册"] --> B["初始化 4 类免费额度
默认 上传 20 / AI 3 / 搭配分享 10 / 衣柜分享 5
(可配置,允许为 0)"] + B --> B2["赠送体验会员 + 会员额度
默认 3 天;AI 3 / 分享 3 / 衣柜 3
(体验会员凭证 source=4,固定到期)"] + B2 --> C["上传衣物
校验 → 扣上传额度 → 异步 AI 抠图 + 分类"] C --> D["衣橱管理
查看 / 修正属性 / 删除回退额度"] D --> E["创建穿搭"] D --> F["AI 穿搭推荐
扣 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) diff --git a/configs/config.example.yaml b/configs/config.example.yaml index f475ef2..fbdf4de 100644 --- a/configs/config.example.yaml +++ b/configs/config.example.yaml @@ -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 返回「广告激励未开启」)。 diff --git a/docs/日志专项评估报告-2026-09-15.md b/docs/日志专项评估报告-2026-09-15.md new file mode 100644 index 0000000..a4b4046 --- /dev/null +++ b/docs/日志专项评估报告-2026-09-15.md @@ -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 当前工作区文件为准;引用敏感字段时仅出现字段名,不含任何真实值。 diff --git a/global/setting.go b/global/setting.go index 10b7c85..d2c6c04 100644 --- a/global/setting.go +++ b/global/setting.go @@ -18,5 +18,6 @@ var ( WechatPaySetting *setting.WechatPaySettingS VirtualPaySetting *setting.VirtualPaySettingS UserDefaultQuotaSetting *setting.UserDefaultQuotaSettingS + NewUserGiftSetting *setting.NewUserGiftSettingS AdRewardSetting *setting.AdRewardSettingS ) diff --git a/internal/middleware/logger.go b/internal/middleware/logger.go index 273ecaf..67a07ab 100644 --- a/internal/middleware/logger.go +++ b/internal/middleware/logger.go @@ -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...) + } } } diff --git a/internal/middleware/lzcwyjwt.go b/internal/middleware/lzcwyjwt.go index b0b83f9..1eb6dca 100644 --- a/internal/middleware/lzcwyjwt.go +++ b/internal/middleware/lzcwyjwt.go @@ -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 diff --git a/internal/middleware/recovery.go b/internal/middleware/recovery.go new file mode 100644 index 0000000..9b36bf1 --- /dev/null +++ b/internal/middleware/recovery.go @@ -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") +} diff --git a/internal/mysqldao/quota.go b/internal/mysqldao/quota.go index 6c9a837..d0c5a99 100644 --- a/internal/mysqldao/quota.go +++ b/internal/mysqldao/quota.go @@ -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 +} diff --git a/internal/mysqlmodel/quota.go b/internal/mysqlmodel/quota.go index 23d8a6a..56a8c6b 100644 --- a/internal/mysqlmodel/quota.go +++ b/internal/mysqlmodel/quota.go @@ -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) } diff --git a/internal/mysqlmodel/quotaflowlog.go b/internal/mysqlmodel/quotaflowlog.go index b9480b0..db66b64 100644 --- a/internal/mysqlmodel/quotaflowlog.go +++ b/internal/mysqlmodel/quotaflowlog.go @@ -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"` // 变更前数值 diff --git a/internal/redismodel/keys.go b/internal/redismodel/keys.go index 0324670..6434a66 100644 --- a/internal/redismodel/keys.go +++ b/internal/redismodel/keys.go @@ -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 } diff --git a/internal/routers/api/v1/order.go b/internal/routers/api/v1/order.go index 07274fb..f8ca81e 100644 --- a/internal/routers/api/v1/order.go +++ b/internal/routers/api/v1/order.go @@ -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 diff --git a/internal/routers/router.go b/internal/routers/router.go index b59948e..c0e69dc 100644 --- a/internal/routers/router.go +++ b/internal/routers/router.go @@ -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(), diff --git a/internal/service/ai_outfit_cleanup.go b/internal/service/ai_outfit_cleanup.go index c7d0cf0..98dbb5a 100644 --- a/internal/service/ai_outfit_cleanup.go +++ b/internal/service/ai_outfit_cleanup.go @@ -78,5 +78,7 @@ func runAIOutfitCleanup() { "count", total, "retention_hours", int(aiUnsavedOutfitRetention.Hours()), ) + } else { + slog.DebugContext(ctx, "ai outfit cleanup: nothing to clean") } } diff --git a/internal/service/airecommend.go b/internal/service/airecommend.go index 887182c..20dfe03 100644 --- a/internal/service/airecommend.go +++ b/internal/service/airecommend.go @@ -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 diff --git a/internal/service/benefit_compensation.go b/internal/service/benefit_compensation.go index d5b36ad..35147ca 100644 --- a/internal/service/benefit_compensation.go +++ b/internal/service/benefit_compensation.go @@ -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) } diff --git a/internal/service/closet.go b/internal/service/closet.go index 537ffe3..03ed545 100644 --- a/internal/service/closet.go +++ b/internal/service/closet.go @@ -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)) diff --git a/internal/service/clothes.go b/internal/service/clothes.go index 459224b..70b830f 100644 --- a/internal/service/clothes.go +++ b/internal/service/clothes.go @@ -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) } diff --git a/internal/service/collect.go b/internal/service/collect.go index 0849fca..6d2e9dc 100644 --- a/internal/service/collect.go +++ b/internal/service/collect.go @@ -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) } } diff --git a/internal/service/constants.go b/internal/service/constants.go index 527cad3..846e1b0 100644 --- a/internal/service/constants.go +++ b/internal/service/constants.go @@ -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 图片处理状态 ===== diff --git a/internal/service/horoscope.go b/internal/service/horoscope.go index 86298e4..19787b4 100644 --- a/internal/service/horoscope.go +++ b/internal/service/horoscope.go @@ -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 diff --git a/internal/service/like.go b/internal/service/like.go index 1b5f1b7..15c7755 100644 --- a/internal/service/like.go +++ b/internal/service/like.go @@ -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) { diff --git a/internal/service/notification.go b/internal/service/notification.go index f856586..7c07023 100644 --- a/internal/service/notification.go +++ b/internal/service/notification.go @@ -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,8 +131,11 @@ 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 } - return 0, errcode.DatabaseError } if serr := redisdao.NotificationUnreadSet(ctx, userID, cnt); serr != nil { slog.WarnContext(ctx, "backfill notification unread cache failed", @@ -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) } } diff --git a/internal/service/order.go b/internal/service/order.go index 525dac5..9d1c2f4 100644 --- a/internal/service/order.go +++ b/internal/service/order.go @@ -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 } diff --git a/internal/service/order_expire.go b/internal/service/order_expire.go index 71cefd7..3ac3fc7 100644 --- a/internal/service/order_expire.go +++ b/internal/service/order_expire.go @@ -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") } } diff --git a/internal/service/outfit.go b/internal/service/outfit.go index 0e329f3..13e87a1 100644 --- a/internal/service/outfit.go +++ b/internal/service/outfit.go @@ -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 { diff --git a/internal/service/outfit_share.go b/internal/service/outfit_share.go index 844dfda..93d5f1c 100644 --- a/internal/service/outfit_share.go +++ b/internal/service/outfit_share.go @@ -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 { diff --git a/internal/service/quota.go b/internal/service/quota.go index 29a6f63..0a753a8 100644 --- a/internal/service/quota.go +++ b/internal/service/quota.go @@ -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 } diff --git a/internal/service/quota_expire.go b/internal/service/quota_expire.go new file mode 100644 index 0000000..6564cb2 --- /dev/null +++ b/internal/service/quota_expire.go @@ -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) + } +} diff --git a/internal/service/service.go b/internal/service/service.go index 6395345..b341b97 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -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) } } diff --git a/internal/service/user.go b/internal/service/user.go index 0e2fafb..d2f31de 100644 --- a/internal/service/user.go +++ b/internal/service/user.go @@ -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,14 +403,16 @@ 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 } - 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_,依赖 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) + } } diff --git a/internal/service/virtualpay.go b/internal/service/virtualpay.go index a7ede20..5db4919 100644 --- a/internal/service/virtualpay.go +++ b/internal/service/virtualpay.go @@ -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) diff --git a/internal/service/virtualpay_compensate.go b/internal/service/virtualpay_compensate.go index 965c925..a1beb21 100644 --- a/internal/service/virtualpay_compensate.go +++ b/internal/service/virtualpay_compensate.go @@ -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 { diff --git a/main.go b/main.go index 73dcf74..e526aa6 100644 --- a/main.go +++ b/main.go @@ -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{ - AddSource: true, - Level: level, - }), - } + // 本地 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 } diff --git a/pkg/setting/section.go b/pkg/setting/section.go index 9a1999a..a42d159 100644 --- a/pkg/setting/section.go +++ b/pkg/setting/section.go @@ -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 广告激励额度配置。 diff --git a/pkg/tokenhub/scheduler.go b/pkg/tokenhub/scheduler.go index ab75042..c1dcc9b 100644 --- a/pkg/tokenhub/scheduler.go +++ b/pkg/tokenhub/scheduler.go @@ -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, diff --git a/接口文档.md b/接口文档.md index d499309..11a5e54 100644 --- a/接口文档.md +++ b/接口文档.md @@ -473,11 +473,11 @@ Authorization: Bearer | 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 **业务逻辑** 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 ### 5.2 额度模块 -全站共 **4 类额度**:**上传额度为永久额度(不每天重置)**;**AI 推荐 / 社区分享 / 衣柜分享为每日额度(每天 0 点重置)**。免费额度由服务端配置 `UserDefaultQuota` 覆盖(下表「新用户默认」为内置兜底值,**允许配置为 0**,表示不赠送该类免费额度): +全站共 **4 类额度**:**上传额度为永久额度(不每天重置)**;**AI 推荐 / 社区分享 / 衣柜分享为每日额度(每天 0 点重置)**。 + +额度有两个来源,**叠加生效**: + +**① 免费额度** —— 由服务端配置 `UserDefaultQuota` 控制,所有用户长期享有、与会员身份无关(下表「新用户默认」为内置兜底值,**允许配置为 0**,表示不赠送该类免费额度): | quota_type | 名称 | 类型 | 新用户默认 | 消耗场景 | | --- | --- | --- | --- | --- | @@ -626,7 +634,18 @@ Authorization: Bearer | 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["微信登录
新用户自动注册"] --> B["初始化免费额度
默认 上传 20 / AI 3 / 搭配分享 10 / 衣柜分享 5
(可配置,允许为 0)"] - B --> C["上传衣物
校验 → 扣上传额度 → 异步 AI 抠图 + 分类"] + A["微信登录
新用户自动注册"] --> B["初始化 4 类免费额度
默认 上传 20 / AI 3 / 搭配分享 10 / 衣柜分享 5
(可配置,允许为 0)"] + B --> B2["赠送体验会员 + 会员额度
默认 3 天;AI 3 / 分享 3 / 衣柜 3
(体验会员凭证 source=4,固定到期)"] + B2 --> C["上传衣物
校验 → 扣上传额度 → 异步 AI 抠图 + 分类"] C --> D["衣橱管理
查看 / 修正属性 / 删除回退额度"] D --> E["创建穿搭"] D --> F["AI 穿搭推荐
扣 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