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