迁移 Flutter 端与训练脚本,模型/训练产物移出 git(遵循纯代码约定)
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
# CLAUDE.md
|
||||
|
||||
## 目录结构与职责(硬性约束)
|
||||
|
||||
> **`biz/` 是泛化占位名,不是固定目录命名**。表格中 `biz/` 代表「业务模块目录」,各项目必须按自身业务命名替换(本项目即 `biz/`),禁止新项目照抄 `biz/`;`ui-src/`、`data/`、`workspace/` 亦为本项目目录名,各项目按自身命名。
|
||||
|
||||
| 目录 | 职责 | 强约束 |
|
||||
|---|---|---|
|
||||
| common/ | 通用层:HTTP 服务与鉴权中间件、文件解析(parser + pdf/docx/html/text)、中文分词、向量 JSON、DAO 基类、查询缓存、协程池封装 | 不得依赖业务模块包;新增跨模块通用能力放这里 |
|
||||
| biz/consts/ | 常量集中地:表名(table_name.go)、状态(status.go)、内容类型、默认参数与各协程池默认大小(consts.go) | 业务常量一律在此集中,禁止散落 magic number;新增池默认大小在此定义 |
|
||||
| biz/model/ | entity(表结构,与 DAO 一一对应)、dto(请求/响应结构,`g.Meta` 内嵌定义路由)、domain(领域模型:跨表聚合与服务层组装值,可被 dto/entity 引用) | entity 只做表映射,不带业务逻辑;dto 是 controller 与 HTTP 的唯一出入口;domain 收纳不属于 dto 也不属于 entity 的类型(见下) |
|
||||
| biz/dao/ | 单表数据访问,每表一个文件 | 无业务逻辑;查询经 base_dao 缓存 |
|
||||
| biz/service/ | 业务逻辑:规则校验、文件读写、事务、跨表组装、调用 dao、LLM 编排 | 不直接写 HTTP 响应(例外见下);并行任务走 common 协程池 |
|
||||
| biz/controller/ | 接口层:接收参数、调用 service、组装返回值 | 见「分层职责规范」;禁止调用 dao |
|
||||
| 前端目录 | 前台 `ui-src/` 为 uni-app (Vue 3) 多端工程(Android/iOS/平板/微信小程序/H5);后台管理端规划中(未建工程) | 前台开发用 vite 代理,生产构建产物 `dist` 由后端托管;多端工程差异见技术设计.md |
|
||||
| 运行时数据目录 | SQLite 库、上传/解析文件(本项目 `data/` `workspace/`) | 不提交 git;删除即丢失数据,改动前先确认 |
|
||||
|
||||
## 分层职责规范(硬性要求)
|
||||
|
||||
严格分层 `controller → service → dao`,禁止跨层调用(controller 禁止直接调 dao)。
|
||||
|
||||
| 层 | 目录 | 职责 | 禁止 |
|
||||
|---|---|---|---|
|
||||
| controller | biz/controller | 接收 dto 请求参数(依赖 DTO `v` tag 自动校验)调用 service,原样返回 service 结果(返回类型与 service 一致,即 dto);**传参方式:整个 `*dto.XxxReq` 直接传给 service,禁止从 dto 拆出多个属性逐个传参** | 直接调用 dao;任何组装/映射/字段搬运;手写业务规则校验(库表依赖/跨字段,应下沉 service);文件 IO;状态流转;跨表数据组装 |
|
||||
| service | biz/service | 业务逻辑:规则校验、文件读写、事务、跨表组装、调用 dao;只允许返回 dto 类型(返回与 controller 输出一致的 `*dto.XxxRes`),派生值(如 scene_name/node_count)在 service 用 dto 组装 | 直接写 HTTP 响应(例外见下);返回裸 gdb.Record 或任何非 dto 类型 |
|
||||
| dao | biz/dao | 构建 SQL 并执行;行→结构体转换在 dao 内部用 GoFrame 自带方法(`Record.Struct` / `Result.Structs`,按 `orm` tag)完成,对外只允许返回 entity(或单值如 int/map) | 业务逻辑;返回裸 gdb.Record——**裸 gdb.Record 不允许作为任何分层方法的返回值**(含 service 内事务读),转换只发生在 dao 内部,不外泄 |
|
||||
|
||||
**例外**:SSE 流式响应、HTML/文件导出等"直接写响应体"的场景由 controller 完成——这是"值返回"的流式形式,事件序列化、心跳属 HTTP 协议职责,保留在 controller。
|
||||
|
||||
**分层锚定原则(controller 反向锚定,防跑偏)**:controller 只透传 ⇒ 接口返回类型以 dto 为准 ⇒ service 返回类型被 dto 锁死 ⇒ dao 输出被 entity 锁死。任何一层若出现"为下一层做数据搬运"(controller 映射 service 结果、service 逐键取裸 Record 字段),即违反本原则,应向上收敛:转换在 dao 内部(Record→entity)、组装在 service(entity→dto)、透传在 controller。
|
||||
|
||||
**教训(此前偏离原因,开发时引以为戒)**:
|
||||
1. 自定义中转层不得替代 dto:domain 只收"不进 HTTP 出入参"的纯领域值(如结算输入 SettleState/结算结果 FinalSettle);凡出现在接口出入参中的类型一律用 dto,由 service 直接产出——否则 controller 被迫承担映射,违反"controller 只透传"
|
||||
2. dao 必须完成行→结构体转换:裸 gdb.Record 的 string 键取值是魔法值,拼错列名编译期不报错,且会把键取值扩散到 service/controller;GoFrame 自带 `Record.Struct`/`Result.Structs`(按 `orm` tag)即转换手段
|
||||
3. 新分层先对齐框架惯例:GoFrame 原生分层即 dao 转 entity / service 返回 dto / controller 薄透传,自定义设计前先核对框架默认范式
|
||||
|
||||
## 分层文件对齐与代码模式(硬性要求)
|
||||
|
||||
- 每张业务表对应一组 `entity / dao / service / controller / dto` 文件,数量严格对齐(核验方式:每层目录文件数 = 分层表数,分层表数 = 总表数 − 豁免表数);虚拟表(向量 vec0 / FTS5)与**流水/记录类表(如 point_log)豁免分层对齐**:不建任何独立分层文件(含 entity/dao),建表由主表 dao 统一管理(同虚拟表模式),由使用方 service 事务内直写,禁止为只写不读的审计表造分层门面;无任何读写引用的死表连表带分层整套删除,启动时 DROP 库内残留表与代码保持一致
|
||||
- **非表文件一律不进业务分层目录**:路由注册与中间件装配、表初始化列表(建表 + 死表 DROP)直接写在 `main.go`;鉴权等跨模块通用能力放 `common/`;跨表业务流程归入所属表文件(如闯关 Choose 属 level 表)——分层目录出现非表文件即违反对齐,禁止以非表名开独立分层文件
|
||||
- **不建 parser/rag 等技术目录**:纯技术能力(文档解析、中文分词、向量序列化)平铺在 `common/`;业务编排(分块、检索、工作流)归入对应 service 文件
|
||||
- entity:每文件一张表,`orm` 标签与列名一致,时间字段用 `*gtime.Time`,只做表映射
|
||||
- dto:请求/响应结构,`g.Meta` 内嵌定义路由;只描述 HTTP 出入参,不承载领域逻辑
|
||||
- **domain(目录 `biz/model/domain/`,package domain)**:仅收纳**不进 HTTP 出入参**的纯领域值(如结算输入 SettleState/结算结果 FinalSettle,service 内部流转 + 单测使用);判断标准:类型是否出现在接口出入参中——出现即用 dto。entity 对表、dto 对 HTTP、domain 对纯领域;service 返回 dto(允许 import dto),dao 返回 entity(禁止外泄 gdb.Record)
|
||||
- dao:单例 `var Xxx = &xxxDao{}`,`init()` 内 `CREATE TABLE IF NOT EXISTS` + 索引 + 迁移;通用 CRUD 复用 `common/base_dao.go`(InsertAndReturnId / GetOneByPk / UpdateByPk)
|
||||
- controller:路由**反射注册**(`common.BindController`),path/method 唯一来源为 dto 的 `g.Meta`(携带 path/method/summary);新增接口只需写 dto + controller 方法,禁止在 main.go 手动逐条注册;跨组方法以 `H5` 前缀命名(h5 组只注册 H5 开头方法,admin 组跳过)
|
||||
- **接口只允许 GET / POST**:写操作传 JSON body(或 multipart),读操作走 query params;无 PUT/DELETE
|
||||
- dao 查询缓存:查询用 `gdb.CacheOption`(TTL 来自配置),**写操作后必须清对应缓存**,否则出现"库里已改、查询还是旧值"
|
||||
|
||||
## 错误处理规范(硬性要求)
|
||||
|
||||
- 所有可能失败的调用必须显式处理返回的 error:向上返回(保留上下文用 `gerror.Wrap`/`Newf`)或记录日志,禁止 `_, _ =` 静默丢弃——吞错会掩盖故障根因,修复问题必须先定位错误路径,不得以忽略 error 换取编译通过
|
||||
- defer 关闭等无法向上返回的资源清理错误,用 `defer func() { _ = x.Close() }()` 显式声明忽略意图,禁止裸 `defer x.Close()` 隐式吞错
|
||||
|
||||
## 并发规范
|
||||
|
||||
- **可并行的场景**:纯 IO 任务——读查询、LLM/Embedding 调用、文件读取。SQLite 写一律回主 goroutine 串行(无 WAL 时并发写会 `database is locked`,锁定风险归零,并发只赢在 IO 等待上)
|
||||
- **新增并行点的固定三处**:common 加池封装(grpool)→ `biz/consts` 加默认大小 → `config.yml` 加 `key: 并发度`(缺失或非法时回退默认值)
|
||||
- 禁止直接用裸 `go` 启动并行工作负载,一律走 `common` 的池
|
||||
- **防死锁**:等待链单向「主 → A池 → B池」,被等待池的任务内不得再等待任何池(会饿死 worker);池无 Wait 方法,等待用调用方 `sync.WaitGroup`,任务结果经 buffered channel 回主 goroutine
|
||||
- **共享状态安全(内存)**:池内任务并发执行,共享实例(service 单例、model 句柄等)只允许**只读**访问;可变字段必须在**提交池之前**由主 goroutine 一次性预置,任务内禁止写共享字段——Go map 并发写直接 `fatal error: concurrent map writes`,无锁、无降级、不可恢复,只能崩溃重启。需要可变共享状态时按优先级:① 无共享(任务内新建、buffered channel 传递结果)② 锁(`sync.Mutex`/`RWMutex`,锁内只做内存操作,LLM/DB 等 IO 放锁外)③ `sync/atomic`(仅限 int 类标量计数/标志,如 `atomic.AddInt64`,并发计数禁用普通 `++`;复合结构不要用 atomic,指针 CAS 属例外)
|
||||
- **锁的使用**:互斥场景唯一入口是 `common.WithLock[T any](ctx, key, expire, retries, retryInterval, fn func() (T, error)) (T, error)`——泛型回调,业务返回值经 T 原样透出给下游;内部按 config.yml 自动选择锁实现(配置了 `redis` 节点 → redis 锁,跨实例互斥,SET NX EX + token 对比删除防误删他人锁;未配置 → gcache 内存锁,单实例互斥),禁止直接用 gcache/gredis 自己实现加锁。拿不到锁(被占用,`ErrLockHeld`)最多重试 `retries` 次、每次间隔 `retryInterval`(`retries=0` 立即失败;ctx 取消/超时同样终止等待);中间件故障不重试直接返回。锁自动释放:无论 fn 成功、失败还是 panic,defer 释放。`expire` 必须 > 0(进程崩溃兜底不死锁),fn 耗时必须在 expire 前完成,fn 内禁止长耗时 IO(LLM/DB 调用);锁粒度按业务唯一键尽量小
|
||||
- **裸 `go` 允许的例外**:`go func(){ wg.Wait(); close(ch) }()` 收尾惯用法、SSE 心跳、流式管道(Stream 读写)等长生命周期/非工作负载协程
|
||||
|
||||
## 文档职责(三文档体系)
|
||||
|
||||
| 文档 | 职责 | 何时补充/更新 |
|
||||
|---|---|---|
|
||||
| CLAUDE.md(本文件) | 公司通用开发规范:分层职责、代码模式、并发/事务/缓存约束、流程 | 规范变化时 |
|
||||
| README.md | 项目功能介绍:架构、数据流、表清单、功能模块、API 清单、使用说明 | 功能增减时 |
|
||||
| 技术设计.md | 实现细节与技术决策:DDL、检索参数、风险与备选方案 | 关键技术决策/参数变化时 |
|
||||
|
||||
## 开发流程(文档驱动,硬性要求)
|
||||
|
||||
永远以文档驱动开发:用户提出开发需求 → 先给出实现方案(技术选型、影响面、改动清单) → **用户确认后先补充文档再动手写代码**。补充哪个文档取决于内容性质:规范 → 本文件,功能 → README,实现细节/技术决策 → 技术设计.md。禁止未经确认直接开发,禁止先写代码后补文档。
|
||||
|
||||
## 数据访问规范(硬性要求)
|
||||
|
||||
- **事务**:涉及多张表的增删改操作必须包数据库事务,禁止逐表裸调用。**事务必须在 service 层**(`g.DB().Transaction` 包裹与事务内写方法如 `XxxInTx`,service 持有 tx 句柄编排多表),dao 层只做单表无状态 CRUD,不持事务;`tx.Begin` 后必须用 `defer` 防护已提交后的二次 Rollback
|
||||
- **SQL 单表约束**:每个 SQL 只允许访问一张表,禁止 JOIN 与跨表子查询(`IN (SELECT ...)` / `EXISTS`);跨表数据一律拆为多条单表 SQL + 应用层内存组装——先取外键 id 列表,再对目标表 `IN` 查询;`IN` 参数须按 ≤100 分批(SQLite 变量数上限 999)
|
||||
- **禁止 N+1 查询**:禁止在循环中逐条查库。循环场景一律改为批处理——一次 `ListByXxx` 取回后按外键在内存分组
|
||||
- **缓存一致性**:DAO 查询走缓存(TTL 来自 `database.cache.ttl`),写操作后必须清对应缓存
|
||||
- **批处理 SQL**:批量写入用 `InsertAll` 类方法,批量删除用 `IN` 子句,禁止循环单条 INSERT/DELETE
|
||||
- **配置即使用**:config.yml 中出现 redis / mq 等中间件配置时,代码必须实际接入使用,禁止"配置了但代码不用"或"代码写死但配置缺失"
|
||||
- **消息/回调幂等**:接入 MQ / Webhook 时,消费与回调处理必须幂等——MQ 至少一次语义、webhook 失败重试都可能重复投递同一事件,禁止依赖"只投一次"假设。幂等手段:以业务唯一键(如 `任务ID + 事件类型`)先查重或建唯一约束再落库,重复事件直接忽略;重试与补偿逻辑同样要防重复执行
|
||||
|
||||
## 运维部署规范(硬性要求)
|
||||
|
||||
- **部署形态**:Docker Compose 单机部署,前后端一体单端口;运行时数据目录挂载持久化,容器重建不丢数据。部署文件在项目根目录(`Dockerfile` / `docker-compose.yml`),启动与使用见 README
|
||||
- **数据即文件**:文件型存储,备份 = 打包「数据库目录(小而关键)+ 上传文件目录(大而可重建)」两个目录;迁移 = 拷贝到新机器即可
|
||||
- **运行时数据与代码分离**:数据目录不提交 git;**删除即丢数据,改动前先确认**
|
||||
- **配置即文件**:项目配置文件为唯一配置入口(监听端口、并发度等),环境变量覆盖无效
|
||||
|
||||
## 金额单位规范(硬性要求)
|
||||
|
||||
- **金额一律以「分」为单位存储与传输**:所有金额字段(零售价、进价、单价、小计、总金额)在数据库与接口中使用整数分(entity/dto 字段类型 `int64`),禁止浮点元
|
||||
- **展示转换在前端**:前端展示 ÷100 转元保留两位小数,提交 ×100 转分;禁止后端做元↔分转换,后端只处理整数分
|
||||
- **金额计算**:`数量(浮点克) × 单价(分)` 后必须 `common.RoundInt` 四舍五入到整数分,禁止裸浮点累加
|
||||
- **量纲区分**:数量/库存/用量为浮点克(REAL),金额为整数分;混算时必须显式转换
|
||||
- 存量库迁移以 `PRAGMA user_version` 版本化标记,禁止重复执行
|
||||
|
||||
## 约定
|
||||
|
||||
- controller 方法签名固定为 `(ctx, *dto.XxxReq) (*dto.XxxRes, error)`,实例注册模式 `var Xxx = &xxx{}`
|
||||
- **参数校验优先用 GoFrame DTO 校验**:请求结构体用 `v` tag(required / regex / in 等)声明,框架自动校验并返回错误,controller 不手写校验;仅 DTO 表达不了的业务规则(跨字段依赖、查库校验如重名、取值范围依赖配置)放 service。JSON 格式解析可留在 controller 或下沉 service,但须保持与调用点一致
|
||||
- 响应组装(实体 → DTO 字段映射)在 controller 进行
|
||||
- service 方法签名 ctx 开头,错误统一用 `gerror`
|
||||
- 编译验证:`go build ./...`
|
||||
- **列表页单元格禁止换行(布局优先级)**:单元格一律单行。排布顺序:① 列宽按数据量设足(自由文本列按实际长度加宽) → ② 列多表宽时用固定列(fixed,如操作列 fixed right) → ③ 最后才对超长文本用 `show-overflow-tooltip` 省略兜底;禁止无 tooltip 的 min-width 列静默截断。全局 `admin-src/src/style.css` 的 `.el-table .cell { white-space: nowrap }` 是最终兜底,新列表页不得依赖换行展示
|
||||
- **H5 客户侧文案合规(硬性)**:H5 页面与微信模板消息面向终端消费者,软件方无医疗资质,禁止出现 诊所/开方/处方/药方/医嘱/药品/服药/诊疗 等医疗行为用语——暗示诊疗即违规;一律用中性话术:「健康打卡」「调理」「饮食禁忌提醒」「联系服务机构」等。后台管理端(admin-src)是持牌机构内部经营工具,业务词汇(药品/处方/开方)不受此限,但登录页与浏览器标题等可被搜索引擎收录的对外表面保持中性品牌名
|
||||
@@ -0,0 +1,23 @@
|
||||
# 视野后端构建:多阶段编译 → 精简运行镜像(纯 Go + SQLite,无 CGO 依赖)
|
||||
FROM golang:1.26 AS builder
|
||||
|
||||
WORKDIR /build
|
||||
# 依赖层缓存:go.mod/go.sum 变更才重拉依赖
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 go build -o /out/observer-server .
|
||||
|
||||
FROM alpine:3.20
|
||||
# 时区对齐宿主(自然日授权语义依赖本地时区)
|
||||
RUN apk add --no-cache tzdata && \
|
||||
cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
|
||||
echo "Asia/Shanghai" > /etc/timezone
|
||||
WORKDIR /app
|
||||
COPY --from=builder /out/observer-server /app/observer-server
|
||||
COPY config.yml /app/config.yml
|
||||
# 运行时数据目录(SQLite 库),由 docker-compose 挂载持久化
|
||||
RUN mkdir -p /app/data
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/app/observer-server"]
|
||||
@@ -0,0 +1,158 @@
|
||||
# 视野 后端服务
|
||||
|
||||
野生动物实时识别 App「视野」(Flutter 客户端见 `../flutter_app/`)的支付与授权后端。
|
||||
|
||||
## 功能模块
|
||||
|
||||
| 模块 | 说明 |
|
||||
|---|---|
|
||||
| 账号 | 手机号+密码注册/登录(`POST /api/v1/auth/register` / `login`),密码 bcrypt 哈希存储,登录签发自签名 token(`auth.secret`),授权绑定手机号账号、同账号多设备共享 |
|
||||
| 订单 | 客户端 `GET /api/v1/plans` 拉取套餐价格 → `POST /api/v1/orders` 创建订单并返回支付参数(微信 prepay / 支付宝 orderStr);未支付订单 2 小时惰性关闭,同账号 2 小时内复用 created 订单防重复扣款 |
|
||||
| 支付回调 | 微信支付 / 支付宝异步回调验签落授权(`/api/v1/payment` 分组,按渠道应答格式直返,不做统一包装) |
|
||||
| 订单确认 | 客户端支付成功后 `POST /api/v1/orders/{orderId}/confirm` 幂等通知,加速授权刷新 |
|
||||
| 授权查询 | `GET /api/v1/license`(Bearer token)返回授权状态,App 识别入口强制服务端校验 |
|
||||
| 套餐 | `config.yml` `plans` 节点配置三档套餐(改价 = 改配置重启),价格**整数分**(1000 / 5600 / 18000) |
|
||||
| 后台管理端 | `server_admin/`(Vue3 + Element Plus)管理页面:订单查询、账号/授权管理(手动授权/撤销);构建产物由后端 `/admin/` 托管,登录页输入 token 后以 `X-Admin-Token` 头鉴权(`config.yml admin.token`) |
|
||||
|
||||
## 架构与数据流
|
||||
|
||||
```
|
||||
Flutter App ── POST /auth/register|login ─► 账号注册/登录,签发 token
|
||||
│
|
||||
├─► POST /orders (Bearer token) ──► 后端下单(校验 planId/channel)
|
||||
│ │
|
||||
│◄───────┘ orderId + payParams
|
||||
│
|
||||
├─► 拉起微信/支付宝 SDK 支付
|
||||
│
|
||||
├─► 微信/支付宝服务端异步回调 ─► 验签 → 落授权(幂等,绑账号)
|
||||
│
|
||||
└─► POST /orders/{id}/confirm (Bearer token) ─► 幂等确认(回调已落授权则直接返回)
|
||||
│
|
||||
└─► GET /license (Bearer token) ─► 返回 {active, expiresAt}
|
||||
```
|
||||
|
||||
- 授权以**服务端为准**:客户端本地缓存 `expiresAt` 仅用于主页展示,识别入口强制查询服务端
|
||||
- 有效期语义:**自然日**(day 当天 24:00 失效;week/month 自生效日起自然日累计),服务端计算 `expiresAt`,客户端只读不判权
|
||||
|
||||
## 数据表
|
||||
|
||||
| 表 | 说明 | 关键字段 |
|
||||
|---|---|---|
|
||||
| `payment_order` | 支付订单 | `order_id`(PK)、`phone_num`、`plan_id`、`channel`(wechat/alipay)、`amount_cents`、`status`(created/paid/closed)、`wx_trade_no`(UNIQUE)、`alipay_trade_no`(UNIQUE)、`created_at`、`paid_at` |
|
||||
| `license` | 手机号账号与授权 | `phone_num`(PK)、`password`(bcrypt)、`expires_at`(未充值 NULL)、`created_at`、`updated_at` |
|
||||
|
||||
建表与迁移见 `技术设计.md`(新库直接建表;存量库以 `PRAGMA user_version` 版本化迁移)。
|
||||
|
||||
## API 清单
|
||||
|
||||
响应统一格式 `{"code": 0, "message": "ok", "data": {...}}`,`code != 0` 视为失败。接口只允许 GET / POST。
|
||||
|
||||
### POST /api/v1/auth/register
|
||||
|
||||
手机号注册(与登录无关的公开接口,无需 token)。请求:`{"phone": "13800000000", "password": "..."}`。已注册手机号返回错误。注册成功即创建账号(未充值,无额度)。
|
||||
|
||||
### POST /api/v1/auth/login
|
||||
|
||||
手机号登录。请求:`{"phone": "13800000000", "password": "..."}`。响应 `data: {"token": "..."}`(HMAC 自签名,有效期 `auth.token_ttl`,默认 30 天)。后续登录态接口携带 `Authorization: Bearer <token>`。
|
||||
|
||||
### GET /api/v1/plans
|
||||
|
||||
套餐列表(需 Bearer token)。响应 `data`:
|
||||
|
||||
```json
|
||||
{
|
||||
"list": [
|
||||
{"planId": "day", "days": 1, "priceCents": 1000},
|
||||
{"planId": "week", "days": 7, "priceCents": 5600},
|
||||
{"planId": "month", "days": 30, "priceCents": 18000}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
套餐来自 `config.yml` `plans` 节点(静态定价,改价改配置后重启服务生效);客户端不硬编码价格。展示名由客户端按 `days` 拼「N天」派生,无独立 label 字段。
|
||||
|
||||
### POST /api/v1/orders
|
||||
|
||||
创建订单,返回渠道支付参数(需 Bearer token,手机号由 token 解出)。
|
||||
|
||||
请求(JSON body):
|
||||
|
||||
```json
|
||||
{"planId": "week", "channel": "wechat"}
|
||||
```
|
||||
|
||||
- `channel`:`wechat` | `alipay`
|
||||
- `planId`:`day` | `week` | `month`(需在 `config.yml` `plans` 节点存在,金额由服务端按配置锁定,客户端不可改价)
|
||||
|
||||
响应 `data`:
|
||||
|
||||
```json
|
||||
{
|
||||
"orderId": "O20260822001",
|
||||
"payParams": {
|
||||
"partnerId": "1900xxxxx",
|
||||
"prepayId": "wx...",
|
||||
"nonceStr": "...",
|
||||
"timeStamp": "1728000000",
|
||||
"sign": "...",
|
||||
"packageValue": "Sign=WXPay"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `channel == wechat`:`payParams` 为微信 APP 支付统一下单返回参数(后端签名)
|
||||
- `channel == alipay`:`payParams` 为 `{"orderStr": "alipay_sdk=..."}`(后端调 `alipay.trade.app.pay` 生成)
|
||||
|
||||
### POST /api/v1/orders/{orderId}/confirm
|
||||
|
||||
客户端支付成功后通知(幂等,需 Bearer token)。服务端以异步回调为准落授权,confirm 仅用于加速刷新;回调已到账时直接返回成功。
|
||||
|
||||
请求:`{}`(无参数)。响应:`data: {"status": "paid"|"created"}`。
|
||||
|
||||
返回 `created`(回调未到账)时客户端按 3s → 10s → 30s 递增重试 confirm(最多 3 次),期间禁止重新下单;仍未成功回退 `GET /license` 查询(回调到账后自然变为 active)。回调为唯一授权来源,confirm 仅加速刷新。
|
||||
|
||||
### GET /api/v1/license
|
||||
|
||||
查询账号授权状态(需 Bearer token,手机号由 token 解出)。
|
||||
|
||||
响应 `data`:
|
||||
|
||||
```json
|
||||
{"active": true, "expiresAt": "2026-08-29T23:59:59+08:00"}
|
||||
```
|
||||
|
||||
`active: false` 或 `expiresAt` 已过期(含未充值账号)→ 客户端展示付费墙/充值引导。
|
||||
|
||||
### 支付回调(微信/支付宝,勿直连)
|
||||
|
||||
| 渠道 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| 微信支付(APP 支付 V3) | `POST /api/v1/payment/wechat/notify` | 解密+验签回调,`out_trade_no` → 落授权 |
|
||||
| 支付宝(APP 支付) | `POST /api/v1/payment/alipay/notify` | RSA2 验签回调,`out_trade_no` → 落授权 |
|
||||
|
||||
### 管理端接口(`/api/v1/admin`,需请求头 `X-Admin-Token` = `config.yml admin.token`)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/admin/orders` | 订单列表,筛选参数 `phoneNum/status/startAt/endAt` + `page/size`,返回 `{total, list}` |
|
||||
| GET | `/admin/licenses` | 账号/授权列表,筛选 `phoneNum` + `page/size`(含未充值账号) |
|
||||
| POST | `/admin/licenses/grant` | 手动授权 `{"phoneNum":"13800000000","planId":"day"}`,自然日叠加 |
|
||||
| POST | `/admin/licenses/revoke` | 撤销授权 `{"phoneNum":"13800000000"}`(清授权保留账号) |
|
||||
|
||||
管理页面(订单/授权)由 `server_admin/` 构建产物提供,访问 `http://<host>/admin/`。金额均为整数分,前端展示 ÷100 转元。
|
||||
|
||||
## 使用说明
|
||||
|
||||
1. 配置 `config.yml`:监听端口、数据库路径、登录 token 签名密钥 `auth.secret`(必填,换值即全员下线)、套餐 `plans` 节点、微信支付(appid/mchid/商户私钥/证书序列号/APIv3 密钥)、支付宝(appid/应用私钥/支付宝公钥)、管理端 `admin.token`;SQLite 库由服务启动时自动建表并迁移,无需手工初始化
|
||||
2. `go build ./...` 编译验证
|
||||
3. 本地运行 `go run main.go`;服务层白盒测试:`GF_GCFG_FILE=biz/service/testdata/config.yml go test ./biz/service/`(独立测试库,见 `biz/service/testdata/`)
|
||||
4. 后台管理端:`cd server_admin && npm run build`(构建产物输出到 `server/admin_dist/`,由后端 `/admin/` 托管);开发联调 `npm run dev`(Vite 代理 `/api` → `:8080`)。首次访问 `/admin/` 进入登录页,输入 `config.yml admin.token` 对应的管理 token(存浏览器 localStorage,随请求携带;token 不内嵌构建产物)
|
||||
5. Docker Compose 部署见 `Dockerfile` / `docker-compose.yml`(`./data` 运行时数据目录挂载持久化,容器重建不丢数据)
|
||||
6. 客户端对接:`flutter_app/lib/config/app_config.dart` 的 `apiBaseUrl`、微信/支付宝 AppID 与 URL Scheme 替换为真实值
|
||||
|
||||
## 金额与安全约定
|
||||
|
||||
- 金额一律**整数分**(`price_cents` / `amount_cents` 为 INT64),禁止浮点元;前端展示 ÷100
|
||||
- 支付回调必须**验签**(微信 APIv3 验签/解密、支付宝 RSA2),回调处理**幂等**(`wx_trade_no`/`alipay_trade_no` 唯一约束,重复回调直接忽略)
|
||||
- 授权落库以**较晚到期为准**:已有授权未过期时新套餐顺延叠加,禁止覆盖缩短
|
||||
@@ -0,0 +1,22 @@
|
||||
package consts
|
||||
|
||||
// 业务常量集中地:表名、状态、默认参数与协程池默认大小。
|
||||
|
||||
const (
|
||||
TablePaymentOrder = "payment_order"
|
||||
TableLicense = "license"
|
||||
|
||||
// 订单状态机 created → paid(closed 仅超时/失败关闭)
|
||||
OrderStatusCreated = "created"
|
||||
OrderStatusPaid = "paid"
|
||||
OrderStatusClosed = "closed"
|
||||
|
||||
ChannelWechat = "wechat"
|
||||
ChannelAlipay = "alipay"
|
||||
|
||||
// 未支付订单惰性关闭阈值与同设备复用窗口(秒)
|
||||
PendingOrderWindowSeconds = 2 * 3600
|
||||
|
||||
// 回调 IO 池默认并发度(被 config.yml payment.poolSize 覆盖)
|
||||
PaymentPoolDefaultSize = 16
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"observer-server/biz/model/dto"
|
||||
"observer-server/biz/service"
|
||||
)
|
||||
|
||||
// cAdmin 后台管理端接口:薄透传;service 按表归入各表文件
|
||||
// (订单→service.Order、授权→service.License)。
|
||||
// 独立 controller 聚合,避免同一 controller 挂客户端组 + 管理组时路由重复注册。
|
||||
type cAdmin struct{}
|
||||
|
||||
var Admin = &cAdmin{}
|
||||
|
||||
// ListOrders 订单列表
|
||||
func (c *cAdmin) ListOrders(ctx context.Context, req *dto.AdminOrderListReq) (*dto.AdminOrderListRes, error) {
|
||||
return service.Order.AdminListOrders(ctx, req)
|
||||
}
|
||||
|
||||
// ListLicenses 授权列表
|
||||
func (c *cAdmin) ListLicenses(ctx context.Context, req *dto.AdminLicenseListReq) (*dto.AdminLicenseListRes, error) {
|
||||
return service.License.AdminListLicenses(ctx, req)
|
||||
}
|
||||
|
||||
// Grant 手动授权
|
||||
func (c *cAdmin) Grant(ctx context.Context, req *dto.AdminGrantReq) (*dto.AdminGrantRes, error) {
|
||||
return service.License.AdminGrant(ctx, req)
|
||||
}
|
||||
|
||||
// Revoke 撤销授权
|
||||
func (c *cAdmin) Revoke(ctx context.Context, req *dto.AdminRevokeReq) (*dto.AdminRevokeRes, error) {
|
||||
return service.License.AdminRevoke(ctx, req)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"observer-server/biz/model/dto"
|
||||
"observer-server/biz/service"
|
||||
)
|
||||
|
||||
// cAuth 账号接口:注册/登录(公开,无需登录态)
|
||||
type cAuth struct{}
|
||||
|
||||
var Auth = &cAuth{}
|
||||
|
||||
// Register 手机号注册
|
||||
func (c *cAuth) Register(ctx context.Context, req *dto.RegisterReq) (*dto.RegisterRes, error) {
|
||||
return service.Auth.Register(ctx, req)
|
||||
}
|
||||
|
||||
// Login 手机号登录,返回 token
|
||||
func (c *cAuth) Login(ctx context.Context, req *dto.LoginReq) (*dto.LoginRes, error) {
|
||||
return service.Auth.Login(ctx, req)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"observer-server/biz/model/dto"
|
||||
"observer-server/biz/service"
|
||||
)
|
||||
|
||||
// cLicense 授权接口
|
||||
type cLicense struct{}
|
||||
|
||||
var License = &cLicense{}
|
||||
|
||||
// Get 查询授权状态
|
||||
func (c *cLicense) Get(ctx context.Context, req *dto.LicenseReq) (*dto.LicenseRes, error) {
|
||||
return service.License.Get(ctx, req)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"observer-server/biz/model/dto"
|
||||
"observer-server/biz/service"
|
||||
)
|
||||
|
||||
// cOrder 订单接口:薄透传,组装在 service
|
||||
type cOrder struct{}
|
||||
|
||||
var Order = &cOrder{}
|
||||
|
||||
// ListPlans 套餐列表(config.yml plans)
|
||||
func (c *cOrder) ListPlans(ctx context.Context, req *dto.ListPlansReq) (*dto.ListPlansRes, error) {
|
||||
return service.Order.ListPlans(ctx, req)
|
||||
}
|
||||
|
||||
// Create 创建支付订单
|
||||
func (c *cOrder) Create(ctx context.Context, req *dto.CreateOrderReq) (*dto.CreateOrderRes, error) {
|
||||
return service.Order.Create(ctx, req)
|
||||
}
|
||||
|
||||
// Confirm 确认支付结果
|
||||
func (c *cOrder) Confirm(ctx context.Context, req *dto.ConfirmOrderReq) (*dto.ConfirmOrderRes, error) {
|
||||
return service.Order.Confirm(ctx, req)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
"observer-server/biz/model/dto"
|
||||
"observer-server/biz/service"
|
||||
"observer-server/common"
|
||||
)
|
||||
|
||||
// cPayment 支付回调接口:按渠道应答格式直接返回,不做统一响应包装
|
||||
type cPayment struct{}
|
||||
|
||||
var Payment = &cPayment{}
|
||||
|
||||
// WechatNotify 微信支付回调:验签+落授权在回调池执行,结果决定 SUCCESS/FAIL 应答;
|
||||
// 应答由本 controller 直接写 JSON(微信要求的格式),返回 nil res
|
||||
func (c *cPayment) WechatNotify(ctx context.Context, req *dto.WechatNotifyReq) (*dto.WechatNotifyRes, error) {
|
||||
r := g.RequestFromCtx(ctx)
|
||||
res := &dto.WechatNotifyRes{Code: "SUCCESS"}
|
||||
err := common.CallbackPoolInstance().Submit(ctx, func(ctx context.Context) error {
|
||||
return service.Payment.WechatNotify(ctx, r.Request.Header, r.GetBody())
|
||||
})
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "微信回调处理失败: %+v", err)
|
||||
res.Code = "FAIL"
|
||||
res.Message = err.Error()
|
||||
}
|
||||
r.Response.WriteJson(res)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// AlipayNotify 支付宝回调:直接写 "success"/"failure" 文本(支付宝约定的应答格式),
|
||||
// 返回 nil res(框架对 nil 不再写响应体)
|
||||
func (c *cPayment) AlipayNotify(ctx context.Context, req *dto.AlipayNotifyReq) (*dto.AlipayNotifyRes, error) {
|
||||
r := g.RequestFromCtx(ctx)
|
||||
if err := r.Request.ParseForm(); err != nil {
|
||||
g.Log().Warningf(ctx, "支付宝回调表单解析失败: %+v", err)
|
||||
r.Response.Write("failure")
|
||||
return nil, nil
|
||||
}
|
||||
values := r.Request.PostForm
|
||||
err := common.CallbackPoolInstance().Submit(ctx, func(ctx context.Context) error {
|
||||
return service.Payment.AlipayNotify(ctx, values)
|
||||
})
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "支付宝回调处理失败: %+v", err)
|
||||
r.Response.Write("failure")
|
||||
return nil, nil
|
||||
}
|
||||
r.Response.Write("success")
|
||||
return nil, nil
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"observer-server/biz/consts"
|
||||
"observer-server/biz/model/entity"
|
||||
"observer-server/common"
|
||||
)
|
||||
|
||||
// License 账号授权表 DAO:一手机号一记录(账号+授权合一);查询走缓存,写后必须清缓存。
|
||||
type licenseDao struct{}
|
||||
|
||||
var License = &licenseDao{}
|
||||
|
||||
func init() {
|
||||
ctx := context.Background()
|
||||
common.DropLegacyTableIfHasColumn(ctx, consts.TableLicense, "device_id")
|
||||
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS license (
|
||||
phone_num TEXT PRIMARY KEY,
|
||||
password TEXT NOT NULL,
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// GetByPhone 按手机号查询(走缓存,键含手机号)
|
||||
func (d *licenseDao) GetByPhone(ctx context.Context, phone string) (*entity.License, error) {
|
||||
return d.getByPhone(ctx, nil, phone, true)
|
||||
}
|
||||
|
||||
// GetByPhoneNoCache 按手机号查询(绕过缓存:登录校验密码必须即时读库)
|
||||
func (d *licenseDao) GetByPhoneNoCache(ctx context.Context, phone string) (*entity.License, error) {
|
||||
return d.getByPhone(ctx, nil, phone, false)
|
||||
}
|
||||
|
||||
// GetByPhoneInTx 事务内按手机号查询:读改写链路内必须用此方法(绕过缓存,
|
||||
// 防止并发写后缓存回填旧值导致续费叠加错乱)
|
||||
func (d *licenseDao) GetByPhoneInTx(ctx context.Context, tx gdb.TX, phone string) (*entity.License, error) {
|
||||
return d.getByPhone(ctx, tx, phone, false)
|
||||
}
|
||||
|
||||
func (d *licenseDao) getByPhone(ctx context.Context, tx gdb.TX, phone string, cache bool) (*entity.License, error) {
|
||||
m := g.DB().Model(consts.TableLicense).Ctx(ctx)
|
||||
if tx != nil {
|
||||
m = m.TX(tx)
|
||||
} else if cache {
|
||||
m = m.Cache(common.CacheOption(ctx, "license:"+phone))
|
||||
}
|
||||
var e entity.License
|
||||
if err := m.Where("phone_num", phone).Scan(&e); err != nil {
|
||||
if common.IsNoRows(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
// Register 注册账号:插入 license 行(无授权);冲突由 service 先查后插保证。
|
||||
func (d *licenseDao) Register(ctx context.Context, tx gdb.TX, phone, passwordHash string) error {
|
||||
now := gtime.Now()
|
||||
_, err := g.DB().Model(consts.TableLicense).Ctx(ctx).TX(tx).Insert(g.Map{
|
||||
"phone_num": phone,
|
||||
"password": passwordHash,
|
||||
"expires_at": nil,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// SetPasswordInTx 补写密码(注册时已有运营发卡占位行:只补密码,不覆盖授权)
|
||||
func (d *licenseDao) SetPasswordInTx(ctx context.Context, tx gdb.TX, phone, passwordHash string) error {
|
||||
_, err := g.DB().Model(consts.TableLicense).Ctx(ctx).TX(tx).
|
||||
Where("phone_num", phone).
|
||||
Data(gdb.Map{"password": passwordHash, "updated_at": gtime.Now()}).
|
||||
Update()
|
||||
return err
|
||||
}
|
||||
|
||||
// UpsertAuthInTx 落授权(支付回调/手动授权):INSERT 占位(password 空串,运营发卡场景)
|
||||
// 或 ON CONFLICT 只更新授权字段——password/created_at 一律不动,防止覆盖已注册账号密码。
|
||||
func (d *licenseDao) UpsertAuthInTx(ctx context.Context, tx gdb.TX, phone string, expiresAt any, updatedAt any) error {
|
||||
_, err := tx.Exec(`INSERT INTO license (phone_num, password, expires_at, created_at, updated_at)
|
||||
VALUES (?, '', ?, ?, ?)
|
||||
ON CONFLICT(phone_num) DO UPDATE SET
|
||||
expires_at = excluded.expires_at,
|
||||
updated_at = excluded.updated_at`,
|
||||
phone, expiresAt, updatedAt, updatedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
// ClearAuthInTx 事务内撤销授权(管理端 revoke):清空授权字段、保留账号行(密码不动)
|
||||
func (d *licenseDao) ClearAuthInTx(ctx context.Context, tx gdb.TX, phone string) error {
|
||||
_, err := g.DB().Model(consts.TableLicense).Ctx(ctx).TX(tx).
|
||||
Where("phone_num", phone).
|
||||
Data(gdb.Map{"expires_at": nil, "updated_at": gtime.Now()}).
|
||||
Update()
|
||||
return err
|
||||
}
|
||||
|
||||
// PageByPhone 管理端账号分页查询:phone 模糊筛选,按更新时间倒序;
|
||||
// 列表不缓存(单点 GetByPhone 仍走缓存,写后清缓存不变)。
|
||||
func (d *licenseDao) PageByPhone(ctx context.Context, phone string, page, size int) ([]*entity.License, int64, error) {
|
||||
base := func() *gdb.Model {
|
||||
m := g.DB().Model(consts.TableLicense).Ctx(ctx)
|
||||
if phone != "" {
|
||||
m = m.WhereLike("phone_num", "%"+phone+"%")
|
||||
}
|
||||
return m
|
||||
}
|
||||
total, err := base().Count()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var list []*entity.License
|
||||
err = base().OrderDesc("updated_at").Limit((page-1)*size, size).Scan(&list)
|
||||
if err != nil {
|
||||
if common.IsNoRows(err) {
|
||||
return []*entity.License{}, int64(total), nil
|
||||
}
|
||||
return nil, 0, err
|
||||
}
|
||||
return list, int64(total), nil
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"observer-server/biz/consts"
|
||||
"observer-server/biz/model/entity"
|
||||
"observer-server/common"
|
||||
)
|
||||
|
||||
// PaymentOrder 支付订单表 DAO:写频繁表不走查询缓存;
|
||||
// 所有写链路(含读改写事务)必须经 common.Serial() 串行执行。
|
||||
type paymentOrderDao struct{}
|
||||
|
||||
var PaymentOrder = &paymentOrderDao{}
|
||||
|
||||
func init() {
|
||||
ctx := context.Background()
|
||||
common.DropLegacyTableIfHasColumn(ctx, consts.TablePaymentOrder, "device_id")
|
||||
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS payment_order (
|
||||
order_id TEXT PRIMARY KEY,
|
||||
phone_num TEXT NOT NULL,
|
||||
plan_id TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
amount_cents INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'created',
|
||||
wx_trade_no TEXT UNIQUE,
|
||||
alipay_trade_no TEXT UNIQUE,
|
||||
created_at TEXT NOT NULL,
|
||||
paid_at TEXT
|
||||
)`)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
_, err = g.DB().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_payment_order_phone ON payment_order(phone_num)")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// InsertOneInTx 事务内插入订单
|
||||
func (d *paymentOrderDao) InsertOneInTx(ctx context.Context, tx gdb.TX, m *entity.PaymentOrder) error {
|
||||
_, err := g.DB().Model(consts.TablePaymentOrder).Ctx(ctx).TX(tx).Insert(m)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetByPk 按订单号查询(普通读,不走缓存)
|
||||
func (d *paymentOrderDao) GetByPk(ctx context.Context, orderId string) (*entity.PaymentOrder, error) {
|
||||
return d.getByPk(ctx, nil, orderId)
|
||||
}
|
||||
|
||||
// GetByPkInTx 事务内按订单号查询
|
||||
func (d *paymentOrderDao) GetByPkInTx(ctx context.Context, tx gdb.TX, orderId string) (*entity.PaymentOrder, error) {
|
||||
return d.getByPk(ctx, tx, orderId)
|
||||
}
|
||||
|
||||
func (d *paymentOrderDao) getByPk(ctx context.Context, tx gdb.TX, orderId string) (*entity.PaymentOrder, error) {
|
||||
m := g.DB().Model(consts.TablePaymentOrder).Ctx(ctx)
|
||||
if tx != nil {
|
||||
m = m.TX(tx)
|
||||
}
|
||||
var e entity.PaymentOrder
|
||||
if err := m.Where("order_id", orderId).Scan(&e); err != nil {
|
||||
if common.IsNoRows(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
// UpdatePaidWechatInTx 事务内标记微信订单已支付(状态机校验在 service 完成)
|
||||
func (d *paymentOrderDao) UpdatePaidWechatInTx(ctx context.Context, tx gdb.TX, orderId, wxTradeNo string) error {
|
||||
_, err := g.DB().Model(consts.TablePaymentOrder).Ctx(ctx).TX(tx).
|
||||
Where("order_id", orderId).
|
||||
Data(gdb.Map{"status": consts.OrderStatusPaid, "wx_trade_no": wxTradeNo, "paid_at": gtime.Now()}).
|
||||
Update()
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdatePaidAlipayInTx 事务内标记支付宝订单已支付
|
||||
func (d *paymentOrderDao) UpdatePaidAlipayInTx(ctx context.Context, tx gdb.TX, orderId, alipayTradeNo string) error {
|
||||
_, err := g.DB().Model(consts.TablePaymentOrder).Ctx(ctx).TX(tx).
|
||||
Where("order_id", orderId).
|
||||
Data(gdb.Map{"status": consts.OrderStatusPaid, "alipay_trade_no": alipayTradeNo, "paid_at": gtime.Now()}).
|
||||
Update()
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateClosedInTx 事务内关闭订单(渠道下单失败回滚 / 惰性回收)
|
||||
func (d *paymentOrderDao) UpdateClosedInTx(ctx context.Context, tx gdb.TX, orderId string) error {
|
||||
_, err := g.DB().Model(consts.TablePaymentOrder).Ctx(ctx).TX(tx).
|
||||
Where("order_id", orderId).
|
||||
Data(gdb.Map{"status": consts.OrderStatusClosed}).
|
||||
Update()
|
||||
return err
|
||||
}
|
||||
|
||||
// CloseStaleCreatedInTx 惰性关闭账号下超时未支付的 created 订单
|
||||
func (d *paymentOrderDao) CloseStaleCreatedInTx(ctx context.Context, tx gdb.TX, phoneNum string, before *gtime.Time) error {
|
||||
_, err := g.DB().Model(consts.TablePaymentOrder).Ctx(ctx).TX(tx).
|
||||
Where("phone_num", phoneNum).
|
||||
Where("status", consts.OrderStatusCreated).
|
||||
WhereLT("created_at", before).
|
||||
Data(gdb.Map{"status": consts.OrderStatusClosed}).
|
||||
Update()
|
||||
return err
|
||||
}
|
||||
|
||||
// GetRecentCreatedInTx 事务内查账号在 since 之后创建的未支付订单(防重复扣款兜底,返回最早一条)
|
||||
func (d *paymentOrderDao) GetRecentCreatedInTx(ctx context.Context, tx gdb.TX, phoneNum string, since *gtime.Time) (*entity.PaymentOrder, error) {
|
||||
var e entity.PaymentOrder
|
||||
err := g.DB().Model(consts.TablePaymentOrder).Ctx(ctx).TX(tx).
|
||||
Where("phone_num", phoneNum).
|
||||
Where("status", consts.OrderStatusCreated).
|
||||
WhereGTE("created_at", since).
|
||||
OrderAsc("created_at").
|
||||
Limit(1).
|
||||
Scan(&e)
|
||||
if err != nil {
|
||||
if common.IsNoRows(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
// PageByFilter 管理端订单分页查询:按条件精确筛选(phoneNum/status 精确、时间区间含界),
|
||||
// 写频繁表不走缓存;返回同条件总数与当前页列表(按下单时间倒序)。
|
||||
func (d *paymentOrderDao) PageByFilter(ctx context.Context, phoneNum, status string,
|
||||
startAt, endAt *gtime.Time, page, size int) ([]*entity.PaymentOrder, int64, error) {
|
||||
base := func() *gdb.Model {
|
||||
m := g.DB().Model(consts.TablePaymentOrder).Ctx(ctx)
|
||||
if phoneNum != "" {
|
||||
m = m.Where("phone_num", phoneNum)
|
||||
}
|
||||
if status != "" {
|
||||
m = m.Where("status", status)
|
||||
}
|
||||
if startAt != nil {
|
||||
m = m.WhereGTE("created_at", startAt)
|
||||
}
|
||||
if endAt != nil {
|
||||
m = m.WhereLTE("created_at", endAt)
|
||||
}
|
||||
return m
|
||||
}
|
||||
total, err := base().Count()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var list []*entity.PaymentOrder
|
||||
err = base().OrderDesc("created_at").Limit((page-1)*size, size).Scan(&list)
|
||||
if err != nil {
|
||||
if common.IsNoRows(err) {
|
||||
return []*entity.PaymentOrder{}, int64(total), nil
|
||||
}
|
||||
return nil, 0, err
|
||||
}
|
||||
return list, int64(total), nil
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
// 后台管理端接口聚合(组前缀 /api/v1/admin,path 相对组)。
|
||||
// service 按表归入各表文件(订单→order、授权→license),controller 聚合 admin。
|
||||
|
||||
// ---------- 订单管理 ----------
|
||||
|
||||
// AdminOrderListReq 订单列表查询
|
||||
type AdminOrderListReq struct {
|
||||
g.Meta `path:"/orders" method:"get" summary:"订单列表" tags:"管理端"`
|
||||
PhoneNum string `json:"phoneNum" v:"length:0,20" dc:"手机号精确匹配"`
|
||||
Status string `json:"status" v:"in:created,paid,closed" dc:"订单状态"`
|
||||
StartAt *gtime.Time `json:"startAt" dc:"下单时间起(含)"`
|
||||
EndAt *gtime.Time `json:"endAt" dc:"下单时间止(含)"`
|
||||
Page int `json:"page" v:"integer|min:1" dc:"页码,默认 1"`
|
||||
Size int `json:"size" v:"integer|min:1|max:100" dc:"每页条数,默认 20"`
|
||||
}
|
||||
|
||||
// AdminOrderItem 订单条目(金额整数分,前端展示 ÷100)
|
||||
type AdminOrderItem struct {
|
||||
OrderId string `json:"orderId"`
|
||||
PhoneNum string `json:"phoneNum"`
|
||||
PlanId string `json:"planId"`
|
||||
Channel string `json:"channel"`
|
||||
AmountCents int64 `json:"amountCents"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt *gtime.Time `json:"createdAt"`
|
||||
PaidAt *gtime.Time `json:"paidAt"`
|
||||
}
|
||||
|
||||
type AdminOrderListRes struct {
|
||||
Total int64 `json:"total"`
|
||||
List []*AdminOrderItem `json:"list"`
|
||||
}
|
||||
|
||||
// ---------- 授权管理 ----------
|
||||
|
||||
// AdminLicenseListReq 账号/授权列表查询
|
||||
type AdminLicenseListReq struct {
|
||||
g.Meta `path:"/licenses" method:"get" summary:"账号列表" tags:"管理端"`
|
||||
PhoneNum string `json:"phoneNum" v:"length:0,20" dc:"手机号模糊匹配"`
|
||||
Page int `json:"page" v:"integer|min:1" dc:"页码,默认 1"`
|
||||
Size int `json:"size" v:"integer|min:1|max:100" dc:"每页条数,默认 20"`
|
||||
}
|
||||
|
||||
// AdminLicenseItem 账号条目(未充值时 expiresAt 为 null)
|
||||
type AdminLicenseItem struct {
|
||||
PhoneNum string `json:"phoneNum"`
|
||||
ExpiresAt *gtime.Time `json:"expiresAt"`
|
||||
CreatedAt *gtime.Time `json:"createdAt"`
|
||||
UpdatedAt *gtime.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type AdminLicenseListRes struct {
|
||||
Total int64 `json:"total"`
|
||||
List []*AdminLicenseItem `json:"list"`
|
||||
}
|
||||
|
||||
// AdminGrantReq 手动授权(免费发卡,与支付回调同自然日叠加语义;未注册账号自动建占位行)
|
||||
type AdminGrantReq struct {
|
||||
g.Meta `path:"/licenses/grant" method:"post" summary:"手动授权" tags:"管理端"`
|
||||
PhoneNum string `json:"phoneNum" v:"required|length:6,20" dc:"手机号"`
|
||||
PlanId string `json:"planId" v:"required" dc:"套餐标识(需在 config.yml plans 存在)"`
|
||||
}
|
||||
|
||||
type AdminGrantRes struct {
|
||||
ExpiresAt *gtime.Time `json:"expiresAt"`
|
||||
}
|
||||
|
||||
// AdminRevokeReq 撤销授权(清授权保留账号)
|
||||
type AdminRevokeReq struct {
|
||||
g.Meta `path:"/licenses/revoke" method:"post" summary:"撤销授权" tags:"管理端"`
|
||||
PhoneNum string `json:"phoneNum" v:"required|length:6,20" dc:"手机号"`
|
||||
}
|
||||
|
||||
type AdminRevokeRes struct{}
|
||||
@@ -0,0 +1,24 @@
|
||||
package dto
|
||||
|
||||
import "github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
// RegisterReq 手机号注册(公开接口,无需登录)
|
||||
type RegisterReq struct {
|
||||
g.Meta `path:"/auth/register" method:"post" summary:"注册" tags:"账号"`
|
||||
Phone string `json:"phone" v:"required|regex:^1[3-9]\\d{9}$" dc:"手机号"`
|
||||
Password string `json:"password" v:"required|length:6,64" dc:"密码"`
|
||||
}
|
||||
|
||||
type RegisterRes struct{}
|
||||
|
||||
// LoginReq 手机号登录(公开接口,无需登录)
|
||||
type LoginReq struct {
|
||||
g.Meta `path:"/auth/login" method:"post" summary:"登录" tags:"账号"`
|
||||
Phone string `json:"phone" v:"required|regex:^1[3-9]\\d{9}$" dc:"手机号"`
|
||||
Password string `json:"password" v:"required|length:6,64" dc:"密码"`
|
||||
}
|
||||
|
||||
// LoginRes 登录成功签发 token,后续登录态接口携带 Authorization: Bearer <token>
|
||||
type LoginRes struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
// LicenseReq 查询授权(登录态:手机号由 AuthRequired 从 token 解出)
|
||||
type LicenseReq struct {
|
||||
g.Meta `path:"/license" method:"get" summary:"查询授权" tags:"授权"`
|
||||
}
|
||||
|
||||
// LicenseRes 授权状态:active=false 时 expiresAt 为 null
|
||||
type LicenseRes struct {
|
||||
Active bool `json:"active"`
|
||||
ExpiresAt *gtime.Time `json:"expiresAt"`
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package dto
|
||||
|
||||
import "github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
// ListPlansReq 套餐列表(登录态;价格以服务端 config.yml plans 为准)
|
||||
type ListPlansReq struct {
|
||||
g.Meta `path:"/plans" method:"get" summary:"套餐列表" tags:"订单"`
|
||||
}
|
||||
|
||||
// PlanItem 套餐项:展示名由客户端按 days 派生"N天"
|
||||
type PlanItem struct {
|
||||
PlanId string `json:"planId"`
|
||||
Days int `json:"days"`
|
||||
PriceCents int64 `json:"priceCents"`
|
||||
}
|
||||
|
||||
type ListPlansRes struct {
|
||||
List []*PlanItem `json:"list"`
|
||||
}
|
||||
|
||||
// CreateOrderReq 创建支付订单(登录态:手机号由 AuthRequired 从 token 解出)
|
||||
type CreateOrderReq struct {
|
||||
g.Meta `path:"/orders" method:"post" summary:"创建支付订单" tags:"订单"`
|
||||
PlanId string `json:"planId" v:"required" dc:"套餐标识(需在 config.yml plans 存在)"`
|
||||
Channel string `json:"channel" v:"required|in:wechat,alipay" dc:"支付渠道"`
|
||||
}
|
||||
|
||||
// CreateOrderRes 创建订单结果:payParams 按渠道二选一填充
|
||||
type CreateOrderRes struct {
|
||||
OrderId string `json:"orderId"`
|
||||
PayParams *PayParams `json:"payParams"`
|
||||
}
|
||||
|
||||
// PayParams 支付拉起参数:微信走 App 支付参数,支付宝走 orderStr
|
||||
type PayParams struct {
|
||||
PartnerId string `json:"partnerId,omitempty"`
|
||||
PrepayId string `json:"prepayId,omitempty"`
|
||||
NonceStr string `json:"nonceStr,omitempty"`
|
||||
TimeStamp string `json:"timeStamp,omitempty"`
|
||||
Sign string `json:"sign,omitempty"`
|
||||
PackageValue string `json:"packageValue,omitempty"`
|
||||
OrderStr string `json:"orderStr,omitempty"`
|
||||
}
|
||||
|
||||
// ConfirmOrderReq 确认支付结果(幂等,仅加速刷新,授权以回调为准)
|
||||
type ConfirmOrderReq struct {
|
||||
g.Meta `path:"/orders/{orderId}/confirm" method:"post" summary:"确认支付结果" tags:"订单"`
|
||||
OrderId string `json:"orderId" v:"required" in:"path" dc:"订单号"`
|
||||
}
|
||||
|
||||
// ConfirmOrderRes 确认结果:paid | created | closed
|
||||
type ConfirmOrderRes struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package dto
|
||||
|
||||
import "github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
// WechatNotifyReq 微信支付回调:raw 请求头/体在 controller 经 g.RequestFromCtx 读取,
|
||||
// dto 仅声明路由。
|
||||
type WechatNotifyReq struct {
|
||||
g.Meta `path:"/wechat/notify" method:"post" summary:"微信支付回调" tags:"回调"`
|
||||
}
|
||||
|
||||
// WechatNotifyRes 微信回调应答:{"code":"SUCCESS"} 或 {"code":"FAIL","message":...}
|
||||
type WechatNotifyRes struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// AlipayNotifyReq 支付宝回调:raw 表单在 controller 经 g.RequestFromCtx 读取,
|
||||
// 应答按支付宝约定直接写 "success"/"failure" 文本。
|
||||
type AlipayNotifyReq struct {
|
||||
g.Meta `path:"/alipay/notify" method:"post" summary:"支付宝回调" tags:"回调"`
|
||||
}
|
||||
|
||||
// AlipayNotifyRes 支付宝回调应答占位(响应体由 controller 直接写入,此处仅满足框架签名要求)
|
||||
type AlipayNotifyRes struct{}
|
||||
@@ -0,0 +1,13 @@
|
||||
package entity
|
||||
|
||||
import "github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
// License 手机号账号与授权:一账号一记录;password 不对外(json 排除);
|
||||
// expires_at 未充值(含被撤销)时为 NULL;购买的套餐记录在 payment_order,本表不存。
|
||||
type License struct {
|
||||
PhoneNum string `json:"phoneNum" orm:"phone_num" description:"手机号账号(主键)"`
|
||||
Password string `json:"-" orm:"password" description:"bcrypt 加盐哈希"`
|
||||
ExpiresAt *gtime.Time `json:"expiresAt" orm:"expires_at" description:"到期时间(自然日,服务端时区),未充值 NULL"`
|
||||
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"注册时间"`
|
||||
UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:"更新时间"`
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package entity
|
||||
|
||||
import "github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
// PaymentOrder 支付订单
|
||||
type PaymentOrder struct {
|
||||
OrderId string `json:"orderId" orm:"order_id" description:"O+时间戳+随机后缀"`
|
||||
PhoneNum string `json:"phoneNum" orm:"phone_num" description:"下单账号(手机号)"`
|
||||
PlanId string `json:"planId" orm:"plan_id" description:"套餐标识"`
|
||||
Channel string `json:"channel" orm:"channel" description:"wechat | alipay"`
|
||||
AmountCents int64 `json:"amountCents" orm:"amount_cents" description:"下单时锁定的金额(整数分)"`
|
||||
Status string `json:"status" orm:"status" description:"created | paid | closed"`
|
||||
WxTradeNo *string `json:"wxTradeNo" orm:"wx_trade_no" description:"微信交易号(幂等唯一),未支付为 NULL"`
|
||||
AlipayTradeNo *string `json:"alipayTradeNo" orm:"alipay_trade_no" description:"支付宝交易号(幂等唯一),未支付为 NULL"`
|
||||
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"创建时间"`
|
||||
PaidAt *gtime.Time `json:"paidAt" orm:"paid_at" description:"支付成功时间"`
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package service
|
||||
|
||||
// 管理端白盒测试:手动授权/撤销、订单分页、套餐更新、账号注册/登录。
|
||||
// 运行方式同 payment_test.go:cd server && GF_GCFG_FILE=biz/service/testdata/config.yml go test ./biz/service/
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"observer-server/biz/consts"
|
||||
"observer-server/biz/dao"
|
||||
"observer-server/biz/model/dto"
|
||||
"observer-server/biz/model/entity"
|
||||
"observer-server/common"
|
||||
)
|
||||
|
||||
func startOfToday() time.Time {
|
||||
now := time.Now()
|
||||
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local)
|
||||
}
|
||||
|
||||
// TestAuthRegisterLogin 注册/登录:注册后可登录、密码错误拒绝、重复注册报错
|
||||
func TestAuthRegisterLogin(t *testing.T) {
|
||||
phone := uniquePhone()
|
||||
if _, err := Auth.Register(ctx(), &dto.RegisterReq{Phone: phone, Password: "pass123456"}); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
// 登录成功,token 可解析回手机号
|
||||
res, err := Auth.Login(ctx(), &dto.LoginReq{Phone: phone, Password: "pass123456"})
|
||||
if err != nil {
|
||||
t.Fatalf("login: %v", err)
|
||||
}
|
||||
got, err := common.ParseToken(ctx(), res.Token)
|
||||
if err != nil || got != phone {
|
||||
t.Fatalf("parse token = %q, %v; want %q", got, err, phone)
|
||||
}
|
||||
// 密码错误
|
||||
if _, err := Auth.Login(ctx(), &dto.LoginReq{Phone: phone, Password: "wrong"}); err == nil {
|
||||
t.Fatal("wrong password should fail")
|
||||
}
|
||||
// 重复注册
|
||||
if _, err := Auth.Register(ctx(), &dto.RegisterReq{Phone: phone, Password: "other123"}); err == nil {
|
||||
t.Fatal("duplicate register should fail")
|
||||
}
|
||||
// 未注册手机号登录失败
|
||||
if _, err := Auth.Login(ctx(), &dto.LoginReq{Phone: uniquePhone(), Password: "pass123456"}); err == nil {
|
||||
t.Fatal("login unregistered phone should fail")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthRegisterAfterGrant 运营先手动授权(占位行无密码)→ 用户注册补密码 → 登录成功
|
||||
func TestAuthRegisterAfterGrant(t *testing.T) {
|
||||
phone := uniquePhone()
|
||||
if _, err := License.AdminGrant(ctx(), &dto.AdminGrantReq{PhoneNum: phone, PlanId: "day"}); err != nil {
|
||||
t.Fatalf("grant: %v", err)
|
||||
}
|
||||
if _, err := Auth.Register(ctx(), &dto.RegisterReq{Phone: phone, Password: "pass123456"}); err != nil {
|
||||
t.Fatalf("register after grant: %v", err)
|
||||
}
|
||||
if _, err := Auth.Login(ctx(), &dto.LoginReq{Phone: phone, Password: "pass123456"}); err != nil {
|
||||
t.Fatalf("login after register: %v", err)
|
||||
}
|
||||
// 注册补密码不能清掉已授权
|
||||
lic, err := dao.License.GetByPhone(ctx(), phone)
|
||||
if err != nil || lic == nil || lic.ExpiresAt == nil {
|
||||
t.Fatalf("grant lost after register: %+v, %v", lic, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminGrant 手动授权:新账号从今天 0 点起按套餐天数累计
|
||||
func TestAdminGrant(t *testing.T) {
|
||||
phone := uniquePhone()
|
||||
res, err := License.AdminGrant(ctx(), &dto.AdminGrantReq{PhoneNum: phone, PlanId: "week"})
|
||||
if err != nil {
|
||||
t.Fatalf("grant: %v", err)
|
||||
}
|
||||
want := startOfToday().AddDate(0, 0, 7)
|
||||
if !res.ExpiresAt.Time.Equal(want) {
|
||||
t.Fatalf("grant expiresAt = %s, want %s", res.ExpiresAt, want)
|
||||
}
|
||||
lic, err := License.Get(common.WithPhone(ctx(), phone), &dto.LicenseReq{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !lic.Active {
|
||||
t.Fatalf("license res = %+v", lic)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminGrantRenewal 手动授权续期叠加:未过期顺延;无效套餐报错
|
||||
func TestAdminGrantRenewal(t *testing.T) {
|
||||
phone := uniquePhone()
|
||||
if _, err := License.AdminGrant(ctx(), &dto.AdminGrantReq{PhoneNum: phone, PlanId: "week"}); err != nil {
|
||||
t.Fatalf("grant 1: %v", err)
|
||||
}
|
||||
lic, _ := dao.License.GetByPhone(ctx(), phone)
|
||||
|
||||
res, err := License.AdminGrant(ctx(), &dto.AdminGrantReq{PhoneNum: phone, PlanId: "month"})
|
||||
if err != nil {
|
||||
t.Fatalf("grant 2: %v", err)
|
||||
}
|
||||
want := lic.ExpiresAt.Time.AddDate(0, 0, 30)
|
||||
if !res.ExpiresAt.Time.Equal(want) {
|
||||
t.Fatalf("renewal expiresAt = %s, want %s (base %s)", res.ExpiresAt, want, lic.ExpiresAt)
|
||||
}
|
||||
|
||||
if _, err := License.AdminGrant(ctx(), &dto.AdminGrantReq{PhoneNum: phone, PlanId: "nope"}); err == nil {
|
||||
t.Fatal("invalid plan should fail")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminRevoke 撤销授权:清授权字段保留账号(可继续登录),授权查询立即 inactive
|
||||
func TestAdminRevoke(t *testing.T) {
|
||||
phone := uniquePhone()
|
||||
if _, err := Auth.Register(ctx(), &dto.RegisterReq{Phone: phone, Password: "pass123456"}); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
if _, err := License.AdminGrant(ctx(), &dto.AdminGrantReq{PhoneNum: phone, PlanId: "day"}); err != nil {
|
||||
t.Fatalf("grant: %v", err)
|
||||
}
|
||||
if _, err := License.AdminRevoke(ctx(), &dto.AdminRevokeReq{PhoneNum: phone}); err != nil {
|
||||
t.Fatalf("revoke: %v", err)
|
||||
}
|
||||
lic, err := License.Get(common.WithPhone(ctx(), phone), &dto.LicenseReq{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lic.Active {
|
||||
t.Fatal("license should be inactive after revoke")
|
||||
}
|
||||
row, err := dao.License.GetByPhone(ctx(), phone)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if row == nil || row.ExpiresAt != nil {
|
||||
t.Fatalf("account row should be kept with auth cleared: %+v", row)
|
||||
}
|
||||
// 账号仍可登录(密码未删)
|
||||
if _, err := Auth.Login(ctx(), &dto.LoginReq{Phone: phone, Password: "pass123456"}); err != nil {
|
||||
t.Fatalf("login after revoke: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminListOrders 订单分页与筛选:总数、渠道筛选、分页截断
|
||||
func TestAdminListOrders(t *testing.T) {
|
||||
phone := uniquePhone()
|
||||
orderId := uniqueOrderId("O-list-2-")
|
||||
for i, channel := range []string{consts.ChannelWechat, consts.ChannelWechat, consts.ChannelAlipay} {
|
||||
insertOrder(t, &entity.PaymentOrder{
|
||||
OrderId: uniqueOrderId("O-list-" + strconv.Itoa(i) + "-"), PhoneNum: phone, PlanId: "day", Channel: channel,
|
||||
AmountCents: 1000, Status: consts.OrderStatusCreated, CreatedAt: gtime.Now(),
|
||||
})
|
||||
}
|
||||
insertOrder(t, &entity.PaymentOrder{
|
||||
OrderId: orderId, PhoneNum: phone, PlanId: "day", Channel: consts.ChannelWechat,
|
||||
AmountCents: 1000, Status: consts.OrderStatusCreated, CreatedAt: gtime.Now(),
|
||||
})
|
||||
|
||||
res, err := Order.AdminListOrders(ctx(), &dto.AdminOrderListReq{PhoneNum: phone, Size: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("list orders: %v", err)
|
||||
}
|
||||
if res.Total != 4 || len(res.List) != 2 {
|
||||
t.Fatalf("total=%d len=%d, want 4/2", res.Total, len(res.List))
|
||||
}
|
||||
|
||||
wx, err := Order.AdminListOrders(ctx(), &dto.AdminOrderListReq{PhoneNum: phone, Channel: consts.ChannelWechat, Size: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if wx.Total != 3 {
|
||||
t.Fatalf("wechat total = %d, want 3", wx.Total)
|
||||
}
|
||||
|
||||
one, err := Order.AdminListOrders(ctx(), &dto.AdminOrderListReq{OrderId: orderId})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if one.Total != 1 || len(one.List) != 1 || one.List[0].OrderId != orderId {
|
||||
t.Fatalf("filter by orderId = %+v", one)
|
||||
}
|
||||
if one.List[0].AmountCents != 1000 {
|
||||
t.Fatalf("amountCents = %d, want 1000", one.List[0].AmountCents)
|
||||
}
|
||||
if one.List[0].PhoneNum != phone {
|
||||
t.Fatalf("phoneNum = %s, want %s", one.List[0].PhoneNum, phone)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListPlans 套餐列表:来自 testdata/config.yml plans 节点(配置驱动,无数据库表)
|
||||
func TestListPlans(t *testing.T) {
|
||||
res, err := Order.ListPlans(ctx(), &dto.ListPlansReq{})
|
||||
if err != nil {
|
||||
t.Fatalf("list plans: %v", err)
|
||||
}
|
||||
if len(res.List) != 3 {
|
||||
t.Fatalf("plans = %d, want 3", len(res.List))
|
||||
}
|
||||
var week *dto.PlanItem
|
||||
for _, p := range res.List {
|
||||
if p.PlanId == "week" {
|
||||
week = p
|
||||
}
|
||||
}
|
||||
if week == nil || week.Days != 7 || week.PriceCents != 5600 {
|
||||
t.Fatalf("week plan = %+v, want days=7 priceCents=5600", week)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"observer-server/biz/dao"
|
||||
"observer-server/biz/model/dto"
|
||||
"observer-server/common"
|
||||
)
|
||||
|
||||
// authService 账号业务:注册(license 表即账号表,注册即插行)、登录(bcrypt 校验 + 签发 token)
|
||||
type authService struct{}
|
||||
|
||||
var Auth = &authService{}
|
||||
|
||||
// Register 注册:已注册(有密码)报错;存在运营手动授权先建的占位行(无密码)时补写密码完成注册。
|
||||
func (s *authService) Register(ctx context.Context, req *dto.RegisterReq) (*dto.RegisterRes, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "密码加密失败")
|
||||
}
|
||||
err = common.Serial().Submit(ctx, func() error {
|
||||
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
lic, err := dao.License.GetByPhoneInTx(ctx, tx, req.Phone)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if lic == nil {
|
||||
return dao.License.Register(ctx, tx, req.Phone, string(hash))
|
||||
}
|
||||
if lic.Password == "" {
|
||||
return dao.License.SetPasswordInTx(ctx, tx, req.Phone, string(hash))
|
||||
}
|
||||
return gerror.New("该手机号已注册")
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
common.ClearCache(ctx, "license:"+req.Phone)
|
||||
return &dto.RegisterRes{}, nil
|
||||
}
|
||||
|
||||
// Login 登录:绕缓存查账号(密码变更即时生效)→ bcrypt 比对 → 签发 token
|
||||
func (s *authService) Login(ctx context.Context, req *dto.LoginReq) (*dto.LoginRes, error) {
|
||||
lic, err := dao.License.GetByPhoneNoCache(ctx, req.Phone)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if lic == nil || lic.Password == "" {
|
||||
return nil, gerror.New("账号或密码错误")
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(lic.Password), []byte(req.Password)); err != nil {
|
||||
return nil, gerror.New("账号或密码错误")
|
||||
}
|
||||
token, err := common.SignToken(ctx, req.Phone)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.LoginRes{Token: token}, nil
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"observer-server/biz/dao"
|
||||
"observer-server/biz/model/dto"
|
||||
"observer-server/common"
|
||||
)
|
||||
|
||||
// licenseService 授权业务:查询(缓存)、管理端手动授权/撤销(单写者串行事务)
|
||||
type licenseService struct{}
|
||||
|
||||
var License = &licenseService{}
|
||||
|
||||
// Get 查询授权:expires_at > now 视为 active(未充值 NULL 即未授权);
|
||||
// 手机号由登录中间件从 token 解出;查询走缓存(落授权后已清缓存)。
|
||||
func (s *licenseService) Get(ctx context.Context, req *dto.LicenseReq) (*dto.LicenseRes, error) {
|
||||
lic, err := dao.License.GetByPhone(ctx, common.PhoneFromCtx(ctx))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if lic == nil || lic.ExpiresAt == nil || !lic.ExpiresAt.Time.After(time.Now()) {
|
||||
return &dto.LicenseRes{Active: false}, nil
|
||||
}
|
||||
return &dto.LicenseRes{Active: true, ExpiresAt: lic.ExpiresAt}, nil
|
||||
}
|
||||
|
||||
// CalcExpiresAt 计算自然日到期时间:base 所在日 0 点 + days 天(day 套餐即当天 24:00 失效)。
|
||||
// 续费叠加由调用方传入 base = max(现有到期, now),本方法不感知现有授权。
|
||||
func (s *licenseService) CalcExpiresAt(base time.Time, days int) *gtime.Time {
|
||||
start := time.Date(base.Year(), base.Month(), base.Day(), 0, 0, 0, 0, base.Location())
|
||||
return gtime.New(start.AddDate(0, 0, days))
|
||||
}
|
||||
|
||||
// AdminListLicenses 管理端账号/授权分页列表(含未充值账号)
|
||||
func (s *licenseService) AdminListLicenses(ctx context.Context, req *dto.AdminLicenseListReq) (*dto.AdminLicenseListRes, error) {
|
||||
page, size := common.NormalizePage(req.Page, req.Size)
|
||||
list, total, err := dao.License.PageByPhone(ctx, req.PhoneNum, page, size)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]*dto.AdminLicenseItem, 0, len(list))
|
||||
for _, lic := range list {
|
||||
items = append(items, &dto.AdminLicenseItem{
|
||||
PhoneNum: lic.PhoneNum,
|
||||
ExpiresAt: lic.ExpiresAt, CreatedAt: lic.CreatedAt, UpdatedAt: lic.UpdatedAt,
|
||||
})
|
||||
}
|
||||
return &dto.AdminLicenseListRes{Total: total, List: items}, nil
|
||||
}
|
||||
|
||||
// AdminGrant 手动授权(免费发卡):与支付回调 persistPaid 同语义——单写者串行事务内
|
||||
// base = max(现有到期, now) 后按自然日叠加,已过期账号从当前时间起算;
|
||||
// 未注册账号自动建占位行(无密码,用户注册时补密码);提交后清授权缓存。
|
||||
func (s *licenseService) AdminGrant(ctx context.Context, req *dto.AdminGrantReq) (*dto.AdminGrantRes, error) {
|
||||
plan, err := common.GetPlan(ctx, req.PlanId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if plan == nil {
|
||||
return nil, gerror.NewCode(common.CodePlanNotConfigured, "套餐不存在")
|
||||
}
|
||||
var expiresAt *gtime.Time
|
||||
err = common.Serial().Submit(ctx, func() error {
|
||||
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
lic, err := dao.License.GetByPhoneInTx(ctx, tx, req.PhoneNum)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
base := time.Now()
|
||||
if lic != nil && lic.ExpiresAt != nil && lic.ExpiresAt.Time.After(base) {
|
||||
base = lic.ExpiresAt.Time
|
||||
}
|
||||
expiresAt = License.CalcExpiresAt(base, plan.Days)
|
||||
return dao.License.UpsertAuthInTx(ctx, tx, req.PhoneNum, expiresAt, gtime.Now())
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
common.ClearCache(ctx, "license:"+req.PhoneNum)
|
||||
return &dto.AdminGrantRes{ExpiresAt: expiresAt}, nil
|
||||
}
|
||||
|
||||
// AdminRevoke 撤销授权:清空授权字段、保留账号行(密码不动),提交后清缓存。
|
||||
func (s *licenseService) AdminRevoke(ctx context.Context, req *dto.AdminRevokeReq) (*dto.AdminRevokeRes, error) {
|
||||
err := common.Serial().Submit(ctx, func() error {
|
||||
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
return dao.License.ClearAuthInTx(ctx, tx, req.PhoneNum)
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
common.ClearCache(ctx, "license:"+req.PhoneNum)
|
||||
return &dto.AdminRevokeRes{}, nil
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"observer-server/biz/consts"
|
||||
"observer-server/biz/dao"
|
||||
"observer-server/biz/model/dto"
|
||||
"observer-server/biz/model/entity"
|
||||
"observer-server/common"
|
||||
)
|
||||
|
||||
// orderService 订单业务:创建(惰性回收+复用+渠道下单)、确认(幂等,回调为唯一授权来源)
|
||||
type orderService struct{}
|
||||
|
||||
var Order = &orderService{}
|
||||
|
||||
// ListPlans 套餐列表(config.yml plans 静态定价,客户端不硬编码价格)
|
||||
func (s *orderService) ListPlans(ctx context.Context, req *dto.ListPlansReq) (*dto.ListPlansRes, error) {
|
||||
plans, err := common.ListPlans(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]*dto.PlanItem, 0, len(plans))
|
||||
for _, p := range plans {
|
||||
items = append(items, &dto.PlanItem{
|
||||
PlanId: p.Id, Days: p.Days, PriceCents: p.PriceCents,
|
||||
})
|
||||
}
|
||||
return &dto.ListPlansRes{List: items}, nil
|
||||
}
|
||||
|
||||
// Create 创建支付订单:
|
||||
// 1. 校验套餐(服务端锁价,金额不由客户端传入)与渠道配置
|
||||
// 2. 单写者串行事务:惰性关闭超时未支付订单 → 2 小时内复用 created 订单(防重复扣款)→ 新建订单
|
||||
// 3. 渠道统一下单(外部 IO,串行外执行),失败回滚关闭订单
|
||||
func (s *orderService) Create(ctx context.Context, req *dto.CreateOrderReq) (*dto.CreateOrderRes, error) {
|
||||
plan, err := common.GetPlan(ctx, req.PlanId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if plan == nil {
|
||||
return nil, gerror.NewCode(common.CodePlanNotConfigured, "套餐不存在")
|
||||
}
|
||||
if err := Payment.ensureChannelConfigured(ctx, req.Channel); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
phoneNum := common.PhoneFromCtx(ctx)
|
||||
var order *entity.PaymentOrder
|
||||
err = common.Serial().Submit(ctx, func() error {
|
||||
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
// 惰性关闭超时未支付订单(对齐微信支付 2 小时有效期,保证支付成功后回调必然可落授权)
|
||||
if err := dao.PaymentOrder.CloseStaleCreatedInTx(ctx, tx, phoneNum,
|
||||
gtime.Now().Add(-consts.PendingOrderWindowSeconds*time.Second)); err != nil {
|
||||
return err
|
||||
}
|
||||
// 同账号窗口内 created 订单直接复用,禁止重复创建
|
||||
recent, err := dao.PaymentOrder.GetRecentCreatedInTx(ctx, tx, phoneNum,
|
||||
gtime.Now().Add(-consts.PendingOrderWindowSeconds*time.Second))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if recent != nil {
|
||||
order = recent
|
||||
return nil
|
||||
}
|
||||
order = &entity.PaymentOrder{
|
||||
OrderId: genOrderId(),
|
||||
PhoneNum: phoneNum,
|
||||
PlanId: req.PlanId,
|
||||
Channel: req.Channel,
|
||||
AmountCents: plan.PriceCents,
|
||||
Status: consts.OrderStatusCreated,
|
||||
CreatedAt: gtime.Now(),
|
||||
}
|
||||
return dao.PaymentOrder.InsertOneInTx(ctx, tx, order)
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 渠道统一下单(外部 IO 不占串行写者)
|
||||
payParams, err := Payment.CreatePayParams(ctx, order, fmt.Sprintf("%d天", plan.Days), req.Channel)
|
||||
if err != nil {
|
||||
if closeErr := common.Serial().Submit(ctx, func() error {
|
||||
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
return dao.PaymentOrder.UpdateClosedInTx(ctx, tx, order.OrderId)
|
||||
})
|
||||
}); closeErr != nil {
|
||||
g.Log().Errorf(ctx, "渠道下单失败回滚关闭订单 %s 失败: %+v", order.OrderId, closeErr)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &dto.CreateOrderRes{OrderId: order.OrderId, PayParams: payParams}, nil
|
||||
}
|
||||
|
||||
// Confirm 确认支付结果(幂等):只读订单状态,回调未到账返回 created;
|
||||
// 与回调写链路同经单写者串行,避免读到写中脏状态。
|
||||
func (s *orderService) Confirm(ctx context.Context, req *dto.ConfirmOrderReq) (*dto.ConfirmOrderRes, error) {
|
||||
var status string
|
||||
err := common.Serial().Submit(ctx, func() error {
|
||||
o, err := dao.PaymentOrder.GetByPk(ctx, req.OrderId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if o == nil {
|
||||
return gerror.NewCode(common.CodeOrderNotFound, "订单不存在")
|
||||
}
|
||||
switch o.Status {
|
||||
case consts.OrderStatusPaid:
|
||||
status = "paid"
|
||||
case consts.OrderStatusCreated:
|
||||
status = "created"
|
||||
default:
|
||||
status = "closed"
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.ConfirmOrderRes{Status: status}, nil
|
||||
}
|
||||
|
||||
// AdminListOrders 管理端订单分页查询:金额原样整数分返回(展示转换在前端)
|
||||
func (s *orderService) AdminListOrders(ctx context.Context, req *dto.AdminOrderListReq) (*dto.AdminOrderListRes, error) {
|
||||
page, size := common.NormalizePage(req.Page, req.Size)
|
||||
list, total, err := dao.PaymentOrder.PageByFilter(ctx, req.PhoneNum,
|
||||
req.Status, req.StartAt, req.EndAt, page, size)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]*dto.AdminOrderItem, 0, len(list))
|
||||
for _, o := range list {
|
||||
items = append(items, &dto.AdminOrderItem{
|
||||
OrderId: o.OrderId, PhoneNum: o.PhoneNum, PlanId: o.PlanId, Channel: o.Channel,
|
||||
AmountCents: o.AmountCents, Status: o.Status, CreatedAt: o.CreatedAt, PaidAt: o.PaidAt,
|
||||
})
|
||||
}
|
||||
return &dto.AdminOrderListRes{Total: total, List: items}, nil
|
||||
}
|
||||
|
||||
// genOrderId 订单号:O + 时间戳 + 随机后缀,长度 ≥ 20 不可枚举
|
||||
func genOrderId() string {
|
||||
buf := make([]byte, 8)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return fmt.Sprintf("O%d", time.Now().UnixNano())
|
||||
}
|
||||
return fmt.Sprintf("O%s%s", time.Now().Format("20060102150405"), hex.EncodeToString(buf))
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/smartwalle/alipay/v3"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/core"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/core/downloader"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/core/notify"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/core/option"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/services/payments"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/app"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/utils"
|
||||
|
||||
"observer-server/biz/consts"
|
||||
"observer-server/biz/dao"
|
||||
"observer-server/biz/model/dto"
|
||||
"observer-server/biz/model/entity"
|
||||
"observer-server/common"
|
||||
)
|
||||
|
||||
// paymentService 支付渠道接入:统一下单、回调验签落授权(回调是唯一授权来源,confirm 仅加速刷新)
|
||||
type paymentService struct{}
|
||||
|
||||
var Payment = &paymentService{}
|
||||
|
||||
// ensureChannelConfigured 渠道未配置时拒绝下单(避免产生无法支付的订单)
|
||||
func (s *paymentService) ensureChannelConfigured(ctx context.Context, channel string) error {
|
||||
switch channel {
|
||||
case consts.ChannelWechat:
|
||||
if !wechatConf(ctx).configured() {
|
||||
return gerror.NewCode(common.CodePaymentNotConfigured, "微信支付未配置")
|
||||
}
|
||||
case consts.ChannelAlipay:
|
||||
if !alipayConf(ctx).configured() {
|
||||
return gerror.NewCode(common.CodePaymentNotConfigured, "支付宝未配置")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreatePayParams 渠道统一下单,返回客户端拉起支付参数
|
||||
func (s *paymentService) CreatePayParams(ctx context.Context, order *entity.PaymentOrder, planLabel, channel string) (*dto.PayParams, error) {
|
||||
switch channel {
|
||||
case consts.ChannelWechat:
|
||||
return s.wechatPrepay(ctx, order, planLabel)
|
||||
case consts.ChannelAlipay:
|
||||
return s.alipayPrepay(ctx, order, planLabel)
|
||||
}
|
||||
return nil, gerror.Newf("未知支付渠道: %s", channel)
|
||||
}
|
||||
|
||||
// ---------- 微信支付(APIv3 APP 支付) ----------
|
||||
|
||||
type wechatConfig struct {
|
||||
appid string
|
||||
mchid string
|
||||
apiV3Key string
|
||||
serialNo string
|
||||
privateKey string
|
||||
notifyUrl string
|
||||
}
|
||||
|
||||
func wechatConf(ctx context.Context) wechatConfig {
|
||||
return wechatConfig{
|
||||
appid: g.Cfg().MustGet(ctx, "payment.wechat.appid", "").String(),
|
||||
mchid: g.Cfg().MustGet(ctx, "payment.wechat.mchid", "").String(),
|
||||
apiV3Key: g.Cfg().MustGet(ctx, "payment.wechat.apiV3Key", "").String(),
|
||||
serialNo: g.Cfg().MustGet(ctx, "payment.wechat.serialNo", "").String(),
|
||||
privateKey: g.Cfg().MustGet(ctx, "payment.wechat.privateKey", "").String(),
|
||||
notifyUrl: g.Cfg().MustGet(ctx, "payment.wechat.notifyUrl", "").String(),
|
||||
}
|
||||
}
|
||||
|
||||
func (c wechatConfig) configured() bool {
|
||||
return c.appid != "" && c.mchid != "" && c.apiV3Key != "" && c.serialNo != "" &&
|
||||
c.privateKey != "" && c.notifyUrl != ""
|
||||
}
|
||||
|
||||
var (
|
||||
wechatOnce sync.Once
|
||||
wechatClient *core.Client
|
||||
wechatNotifyHdl *notify.Handler
|
||||
wechatErr error
|
||||
)
|
||||
|
||||
// wechatClientInstance 懒加载微信客户端与回调处理器(配置静态,进程内只初始化一次)
|
||||
func wechatClientInstance() (*core.Client, *notify.Handler, error) {
|
||||
wechatOnce.Do(func() {
|
||||
ctx := context.Background()
|
||||
cfg := wechatConf(ctx)
|
||||
if !cfg.configured() {
|
||||
wechatErr = gerror.NewCode(common.CodePaymentNotConfigured, "微信支付未配置")
|
||||
return
|
||||
}
|
||||
key, err := utils.LoadPrivateKeyWithPath(cfg.privateKey)
|
||||
if err != nil {
|
||||
wechatErr = gerror.Wrap(err, "加载微信商户私钥失败")
|
||||
return
|
||||
}
|
||||
client, err := core.NewClient(ctx, option.WithWechatPayAutoAuthCipher(cfg.mchid, cfg.serialNo, key, cfg.apiV3Key))
|
||||
if err != nil {
|
||||
wechatErr = gerror.Wrap(err, "初始化微信支付客户端失败")
|
||||
return
|
||||
}
|
||||
visitor := downloader.MgrInstance().GetCertificateVisitor(cfg.mchid)
|
||||
handler, err := notify.NewRSANotifyHandler(cfg.apiV3Key, verifiers.NewSHA256WithRSAVerifier(visitor))
|
||||
if err != nil {
|
||||
wechatErr = gerror.Wrap(err, "初始化微信回调处理器失败")
|
||||
return
|
||||
}
|
||||
wechatClient, wechatNotifyHdl = client, handler
|
||||
})
|
||||
return wechatClient, wechatNotifyHdl, wechatErr
|
||||
}
|
||||
|
||||
func (s *paymentService) wechatPrepay(ctx context.Context, order *entity.PaymentOrder, planLabel string) (*dto.PayParams, error) {
|
||||
cfg := wechatConf(ctx)
|
||||
client, _, err := wechatClientInstance()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
svc := app.AppApiService{Client: client}
|
||||
resp, _, err := svc.PrepayWithRequestPayment(ctx, app.PrepayRequest{
|
||||
Appid: core.String(cfg.appid),
|
||||
Mchid: core.String(cfg.mchid),
|
||||
Description: core.String("视野会员-" + planLabel),
|
||||
OutTradeNo: core.String(order.OrderId),
|
||||
NotifyUrl: core.String(cfg.notifyUrl),
|
||||
Amount: &app.Amount{Total: core.Int64(order.AmountCents)},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "微信统一下单失败")
|
||||
}
|
||||
return &dto.PayParams{
|
||||
PartnerId: strDeref(resp.PartnerId),
|
||||
PrepayId: strDeref(resp.PrepayId),
|
||||
NonceStr: strDeref(resp.NonceStr),
|
||||
TimeStamp: strDeref(resp.TimeStamp),
|
||||
Sign: strDeref(resp.Sign),
|
||||
PackageValue: strDeref(resp.Package),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// WechatNotify 微信支付回调:APIv3 验签+解密 → 商户校验 → 落授权
|
||||
func (s *paymentService) WechatNotify(ctx context.Context, header http.Header, body []byte) error {
|
||||
cfg := wechatConf(ctx)
|
||||
if !cfg.configured() {
|
||||
return gerror.NewCode(common.CodePaymentNotConfigured, "微信支付未配置")
|
||||
}
|
||||
_, handler, err := wechatClientInstance()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "构造微信回调请求失败")
|
||||
}
|
||||
req.Header = header.Clone()
|
||||
txn := new(payments.Transaction)
|
||||
if _, err := handler.ParseNotifyRequest(ctx, req, txn); err != nil {
|
||||
return gerror.NewCode(common.CodeCallbackVerifyFailed, "微信回调验签/解密失败: "+err.Error())
|
||||
}
|
||||
// 商户校验:解密后校验 appid/mchid 与配置一致(防跨商户重放)
|
||||
if txn.Appid == nil || *txn.Appid != cfg.appid || txn.Mchid == nil || *txn.Mchid != cfg.mchid {
|
||||
return gerror.NewCode(common.CodeCallbackMismatch, "微信回调商户不匹配")
|
||||
}
|
||||
if txn.OutTradeNo == nil || txn.TransactionId == nil {
|
||||
return gerror.Newf("微信回调缺少订单号")
|
||||
}
|
||||
return s.persistPaid(ctx, *txn.OutTradeNo, *txn.TransactionId, consts.ChannelWechat, 0)
|
||||
}
|
||||
|
||||
// ---------- 支付宝(APP 支付) ----------
|
||||
|
||||
type alipayConfig struct {
|
||||
appid string
|
||||
privateKey string
|
||||
alipayPublicKey string
|
||||
sellerId string
|
||||
notifyUrl string
|
||||
}
|
||||
|
||||
func alipayConf(ctx context.Context) alipayConfig {
|
||||
return alipayConfig{
|
||||
appid: g.Cfg().MustGet(ctx, "payment.alipay.appid", "").String(),
|
||||
privateKey: g.Cfg().MustGet(ctx, "payment.alipay.privateKey", "").String(),
|
||||
alipayPublicKey: g.Cfg().MustGet(ctx, "payment.alipay.alipayPublicKey", "").String(),
|
||||
sellerId: g.Cfg().MustGet(ctx, "payment.alipay.sellerId", "").String(),
|
||||
notifyUrl: g.Cfg().MustGet(ctx, "payment.alipay.notifyUrl", "").String(),
|
||||
}
|
||||
}
|
||||
|
||||
func (c alipayConfig) configured() bool {
|
||||
return c.appid != "" && c.privateKey != "" && c.alipayPublicKey != "" &&
|
||||
c.sellerId != "" && c.notifyUrl != ""
|
||||
}
|
||||
|
||||
var (
|
||||
alipayOnce sync.Once
|
||||
alipayClient *alipay.Client
|
||||
alipayErr error
|
||||
)
|
||||
|
||||
// alipayClientInstance 懒加载支付宝客户端(密钥文件路径 → PEM 内容)
|
||||
func alipayClientInstance() (*alipay.Client, error) {
|
||||
alipayOnce.Do(func() {
|
||||
ctx := context.Background()
|
||||
cfg := alipayConf(ctx)
|
||||
if !cfg.configured() {
|
||||
alipayErr = gerror.NewCode(common.CodePaymentNotConfigured, "支付宝未配置")
|
||||
return
|
||||
}
|
||||
pri, err := os.ReadFile(cfg.privateKey)
|
||||
if err != nil {
|
||||
alipayErr = gerror.Wrap(err, "读取支付宝应用私钥失败")
|
||||
return
|
||||
}
|
||||
client, err := alipay.New(cfg.appid, string(pri), true)
|
||||
if err != nil {
|
||||
alipayErr = gerror.Wrap(err, "初始化支付宝客户端失败")
|
||||
return
|
||||
}
|
||||
pub, err := os.ReadFile(cfg.alipayPublicKey)
|
||||
if err != nil {
|
||||
alipayErr = gerror.Wrap(err, "读取支付宝公钥失败")
|
||||
return
|
||||
}
|
||||
if err := client.LoadAliPayPublicKey(string(pub)); err != nil {
|
||||
alipayErr = gerror.Wrap(err, "加载支付宝公钥失败")
|
||||
return
|
||||
}
|
||||
alipayClient = client
|
||||
})
|
||||
return alipayClient, alipayErr
|
||||
}
|
||||
|
||||
func (s *paymentService) alipayPrepay(ctx context.Context, order *entity.PaymentOrder, planLabel string) (*dto.PayParams, error) {
|
||||
cfg := alipayConf(ctx)
|
||||
client, err := alipayClientInstance()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 支付宝接口金额以「元」字符串传输(外部网关契约;内部存储与传输一律整数分)
|
||||
pay := alipay.TradeAppPay{}
|
||||
pay.Subject = "视野会员-" + planLabel
|
||||
pay.OutTradeNo = order.OrderId
|
||||
pay.TotalAmount = strconv.FormatFloat(float64(order.AmountCents)/100, 'f', 2, 64)
|
||||
pay.ProductCode = "QUICK_MSECURITY_PAY"
|
||||
pay.NotifyURL = cfg.notifyUrl
|
||||
orderStr, err := client.TradeAppPay(pay)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "支付宝统一下单失败")
|
||||
}
|
||||
return &dto.PayParams{OrderStr: orderStr}, nil
|
||||
}
|
||||
|
||||
// AlipayNotify 支付宝回调:RSA2 验签 → 商户/金额校验 → 落授权
|
||||
func (s *paymentService) AlipayNotify(ctx context.Context, values url.Values) error {
|
||||
cfg := alipayConf(ctx)
|
||||
if !cfg.configured() {
|
||||
return gerror.NewCode(common.CodePaymentNotConfigured, "支付宝未配置")
|
||||
}
|
||||
client, err := alipayClientInstance()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.VerifySign(ctx, values); err != nil {
|
||||
return gerror.NewCode(common.CodeCallbackVerifyFailed, "支付宝回调验签失败: "+err.Error())
|
||||
}
|
||||
// 商户校验:app_id/seller_id 与配置一致(防跨商户重放)
|
||||
if values.Get("app_id") != cfg.appid || values.Get("seller_id") != cfg.sellerId {
|
||||
return gerror.NewCode(common.CodeCallbackMismatch, "支付宝回调商户不匹配")
|
||||
}
|
||||
status := values.Get("trade_status")
|
||||
if status != "TRADE_SUCCESS" && status != "TRADE_FINISHED" {
|
||||
g.Log().Infof(ctx, "支付宝回调非终态 %s,忽略", status)
|
||||
return nil
|
||||
}
|
||||
outTradeNo := values.Get("out_trade_no")
|
||||
tradeNo := values.Get("trade_no")
|
||||
if outTradeNo == "" || tradeNo == "" {
|
||||
return gerror.Newf("支付宝回调缺少订单号")
|
||||
}
|
||||
amount, err := strconv.ParseFloat(values.Get("total_amount"), 64)
|
||||
if err != nil {
|
||||
return gerror.Newf("支付宝回调金额格式错误: %s", values.Get("total_amount"))
|
||||
}
|
||||
// 回调金额(元)转分后与订单快照比对
|
||||
cents := int64(math.Round(amount * 100))
|
||||
return s.persistPaid(ctx, outTradeNo, tradeNo, consts.ChannelAlipay, cents)
|
||||
}
|
||||
|
||||
// ---------- 回调落授权(唯一授权来源) ----------
|
||||
|
||||
// persistPaid 回调落授权:「查订单 → 算 expiresAt → 更新订单 → 写 license → 清缓存」,
|
||||
// 整条链路在事务 + 单写者下串行执行(SQLite 无 WAL,串行同时保证 expiresAt 只按一次现授权
|
||||
// 状态计算,叠加语义不被并发重复累加)。
|
||||
// 幂等:订单已 paid 直接成功(重复回调忽略);closed 仅告警不落授权。
|
||||
func (s *paymentService) persistPaid(ctx context.Context, outTradeNo, tradeNo, channel string, amountCents int64) error {
|
||||
var phoneNum string
|
||||
err := common.Serial().Submit(ctx, func() error {
|
||||
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
o, err := dao.PaymentOrder.GetByPkInTx(ctx, tx, outTradeNo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if o == nil {
|
||||
return gerror.Newf("回调订单 %s 不存在", outTradeNo)
|
||||
}
|
||||
if o.Status == consts.OrderStatusPaid {
|
||||
return nil // 幂等:重复回调直接成功
|
||||
}
|
||||
if o.Status != consts.OrderStatusCreated {
|
||||
g.Log().Errorf(ctx, "回调订单 %s 状态 %s,跳过落授权", outTradeNo, o.Status)
|
||||
return nil
|
||||
}
|
||||
// 金额一致性校验(支付宝回调带金额;微信回调无金额字段、以微信侧为准)
|
||||
if channel == consts.ChannelAlipay && amountCents != o.AmountCents {
|
||||
return gerror.NewCode(common.CodeCallbackMismatch,
|
||||
fmt.Sprintf("回调金额 %d 与订单金额 %d 不一致", amountCents, o.AmountCents))
|
||||
}
|
||||
plan, err := common.GetPlan(ctx, o.PlanId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if plan == nil {
|
||||
return gerror.Newf("订单 %s 套餐 %s 不存在", outTradeNo, o.PlanId)
|
||||
}
|
||||
// 续费叠加:base = max(现有到期, 当前时间),已过期则从当前时间起算
|
||||
lic, err := dao.License.GetByPhoneInTx(ctx, tx, o.PhoneNum)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
base := time.Now()
|
||||
if lic != nil && lic.ExpiresAt != nil && lic.ExpiresAt.Time.After(base) {
|
||||
base = lic.ExpiresAt.Time
|
||||
}
|
||||
expiresAt := License.CalcExpiresAt(base, plan.Days)
|
||||
if channel == consts.ChannelWechat {
|
||||
err = dao.PaymentOrder.UpdatePaidWechatInTx(ctx, tx, o.OrderId, tradeNo)
|
||||
} else {
|
||||
err = dao.PaymentOrder.UpdatePaidAlipayInTx(ctx, tx, o.OrderId, tradeNo)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
phoneNum = o.PhoneNum
|
||||
return dao.License.UpsertAuthInTx(ctx, tx, o.PhoneNum, expiresAt, gtime.Now())
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 提交成功后清缓存(提交前清可能被并发授权查询以旧值回填)
|
||||
common.ClearCache(ctx, "license:"+phoneNum)
|
||||
return nil
|
||||
}
|
||||
|
||||
// strDeref 解指针(SDK 返回的 *string 字段可能为 nil)
|
||||
func strDeref(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package service
|
||||
|
||||
// 服务层白盒测试:验证支付回调落授权核心链路(无真实商户,绕过 SDK 调用)。
|
||||
// 运行方式: cd server && GF_GCFG_FILE=biz/service/testdata/config.yml go test ./biz/service/
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"observer-server/biz/consts"
|
||||
"observer-server/biz/dao"
|
||||
"observer-server/biz/model/dto"
|
||||
"observer-server/biz/model/entity"
|
||||
"observer-server/common"
|
||||
)
|
||||
|
||||
func ctx() context.Context {
|
||||
return context.Background()
|
||||
}
|
||||
|
||||
// uniquePhone / uniqueOrderId 每次运行生成唯一值,避免测试库残留数据冲突
|
||||
func uniquePhone() string {
|
||||
return "138" + strconv.FormatInt(time.Now().UnixNano()%100000000, 10)
|
||||
}
|
||||
|
||||
func uniqueOrderId(prefix string) string {
|
||||
return prefix + strconv.FormatInt(time.Now().UnixNano()%100000000, 10)
|
||||
}
|
||||
|
||||
// insertOrder 直插一条 created 订单(模拟下单链路产出)
|
||||
func insertOrder(t *testing.T, o *entity.PaymentOrder) {
|
||||
t.Helper()
|
||||
err := g.DB().Transaction(ctx(), func(ctx context.Context, tx gdb.TX) error {
|
||||
return dao.PaymentOrder.InsertOneInTx(ctx, tx, o)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("insert order: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPersistPaid 回调落授权:created → paid + 授权生效,重复回调幂等
|
||||
func TestPersistPaid(t *testing.T) {
|
||||
phone := uniquePhone()
|
||||
orderId := uniqueOrderId("O-test-persist-")
|
||||
wxTrade := uniqueOrderId("wx-trade-")
|
||||
insertOrder(t, &entity.PaymentOrder{
|
||||
OrderId: orderId,
|
||||
PhoneNum: phone,
|
||||
PlanId: "week",
|
||||
Channel: consts.ChannelWechat,
|
||||
AmountCents: 5600,
|
||||
Status: consts.OrderStatusCreated,
|
||||
CreatedAt: gtime.Now(),
|
||||
})
|
||||
|
||||
if err := Payment.persistPaid(ctx(), orderId, wxTrade, consts.ChannelWechat, 0); err != nil {
|
||||
t.Fatalf("persistPaid: %v", err)
|
||||
}
|
||||
|
||||
// 订单已支付
|
||||
o, err := dao.PaymentOrder.GetByPk(ctx(), orderId)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if o.Status != consts.OrderStatusPaid || o.WxTradeNo == nil || *o.WxTradeNo != wxTrade {
|
||||
t.Fatalf("order not paid: status=%s wx=%v", o.Status, o.WxTradeNo)
|
||||
}
|
||||
|
||||
// 授权生效:week = 今天 0 点 + 7 天
|
||||
lic, err := dao.License.GetByPhone(ctx(), phone)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lic == nil || lic.ExpiresAt == nil {
|
||||
t.Fatal("license not granted")
|
||||
}
|
||||
want := time.Date(time.Now().Year(), time.Now().Month(), time.Now().Day(), 0, 0, 0, 0, time.Local).AddDate(0, 0, 7)
|
||||
if !lic.ExpiresAt.Time.Equal(want) {
|
||||
t.Fatalf("expiresAt = %s, want %s", lic.ExpiresAt.Time, want)
|
||||
}
|
||||
|
||||
// 授权查询接口返回 active
|
||||
licRes, err := License.Get(common.WithPhone(ctx(), phone), &dto.LicenseReq{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !licRes.Active {
|
||||
t.Fatalf("license res = %+v", licRes)
|
||||
}
|
||||
|
||||
// confirm 返回 paid
|
||||
conf, err := Order.Confirm(ctx(), &dto.ConfirmOrderReq{OrderId: orderId})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if conf.Status != "paid" {
|
||||
t.Fatalf("confirm status = %s, want paid", conf.Status)
|
||||
}
|
||||
|
||||
// 重复回调幂等:不报错、不重复累加授权
|
||||
before := lic.ExpiresAt
|
||||
if err := Payment.persistPaid(ctx(), orderId, wxTrade, consts.ChannelWechat, 0); err != nil {
|
||||
t.Fatalf("repeat persistPaid: %v", err)
|
||||
}
|
||||
lic2, _ := dao.License.GetByPhone(ctx(), phone)
|
||||
if !lic2.ExpiresAt.Time.Equal(before.Time) {
|
||||
t.Fatalf("idempotency broken: %s -> %s", before, lic2.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPersistPaidRenewal 续费叠加:未过期顺延(base=现有到期),已过期从当前时间起算
|
||||
func TestPersistPaidRenewal(t *testing.T) {
|
||||
phone := uniquePhone()
|
||||
order1 := uniqueOrderId("O-test-renew-1-")
|
||||
insertOrder(t, &entity.PaymentOrder{
|
||||
OrderId: order1,
|
||||
PhoneNum: phone,
|
||||
PlanId: "week",
|
||||
Channel: consts.ChannelAlipay,
|
||||
AmountCents: 5600,
|
||||
Status: consts.OrderStatusCreated,
|
||||
CreatedAt: gtime.Now(),
|
||||
})
|
||||
if err := Payment.persistPaid(ctx(), order1, uniqueOrderId("ali-trade-"), consts.ChannelAlipay, 5600); err != nil {
|
||||
t.Fatalf("persistPaid 1: %v", err)
|
||||
}
|
||||
lic, _ := dao.License.GetByPhone(ctx(), phone)
|
||||
|
||||
// 第二次购买 month:base = 现有到期(未过期),30 天叠加
|
||||
order2 := uniqueOrderId("O-test-renew-2-")
|
||||
insertOrder(t, &entity.PaymentOrder{
|
||||
OrderId: order2,
|
||||
PhoneNum: phone,
|
||||
PlanId: "month",
|
||||
Channel: consts.ChannelWechat,
|
||||
AmountCents: 18000,
|
||||
Status: consts.OrderStatusCreated,
|
||||
CreatedAt: gtime.Now(),
|
||||
})
|
||||
if err := Payment.persistPaid(ctx(), order2, uniqueOrderId("wx-trade-"), consts.ChannelWechat, 0); err != nil {
|
||||
t.Fatalf("persistPaid 2: %v", err)
|
||||
}
|
||||
lic2, _ := dao.License.GetByPhone(ctx(), phone)
|
||||
want := lic.ExpiresAt.Time.AddDate(0, 0, 30)
|
||||
if !lic2.ExpiresAt.Time.Equal(want) {
|
||||
t.Fatalf("renewal expiresAt = %s, want %s (base %s)", lic2.ExpiresAt, want, lic.ExpiresAt)
|
||||
}
|
||||
// 购买的套餐记录在 payment_order,license 只留到期时间(此处验证第二次下单的订单快照)
|
||||
o2, _ := dao.PaymentOrder.GetByPk(ctx(), order2)
|
||||
if o2 == nil || o2.PlanId != "month" {
|
||||
t.Fatalf("order plan snapshot = %v, want month", o2)
|
||||
}
|
||||
|
||||
// 支付宝回调金额不匹配 → 拒绝
|
||||
order3 := uniqueOrderId("O-test-renew-3-")
|
||||
insertOrder(t, &entity.PaymentOrder{
|
||||
OrderId: order3,
|
||||
PhoneNum: uniquePhone(),
|
||||
PlanId: "day",
|
||||
Channel: consts.ChannelAlipay,
|
||||
AmountCents: 1000,
|
||||
Status: consts.OrderStatusCreated,
|
||||
CreatedAt: gtime.Now(),
|
||||
})
|
||||
if err := Payment.persistPaid(ctx(), order3, uniqueOrderId("ali-trade-"), consts.ChannelAlipay, 999); err == nil {
|
||||
t.Fatal("amount mismatch should fail")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateSequence 订单创建串行序列:惰性关闭超时订单 + 2 小时内复用 created 订单
|
||||
func TestCreateSequence(t *testing.T) {
|
||||
phone := uniquePhone()
|
||||
orderStale := uniqueOrderId("O-test-stale-")
|
||||
orderRecent := uniqueOrderId("O-test-recent-")
|
||||
// 3 小时前的 stale 订单 + 1 分钟前的有效订单
|
||||
now := gtime.Now()
|
||||
insertOrder(t, &entity.PaymentOrder{
|
||||
OrderId: orderStale, PhoneNum: phone, PlanId: "day", Channel: consts.ChannelWechat,
|
||||
AmountCents: 1000, Status: consts.OrderStatusCreated,
|
||||
CreatedAt: gtime.New(now.Time.Add(-3 * time.Hour)),
|
||||
})
|
||||
insertOrder(t, &entity.PaymentOrder{
|
||||
OrderId: orderRecent, PhoneNum: phone, PlanId: "day", Channel: consts.ChannelWechat,
|
||||
AmountCents: 1000, Status: consts.OrderStatusCreated,
|
||||
CreatedAt: gtime.New(now.Time.Add(-1 * time.Minute)),
|
||||
})
|
||||
|
||||
// 复现 Create 的串行事务序列
|
||||
var recent *entity.PaymentOrder
|
||||
err := common.Serial().Submit(ctx(), func() error {
|
||||
return g.DB().Transaction(ctx(), func(ctx context.Context, tx gdb.TX) error {
|
||||
window := gtime.New(now.Time.Add(-consts.PendingOrderWindowSeconds * time.Second))
|
||||
if err := dao.PaymentOrder.CloseStaleCreatedInTx(ctx, tx, phone, window); err != nil {
|
||||
return err
|
||||
}
|
||||
var err error
|
||||
recent, err = dao.PaymentOrder.GetRecentCreatedInTx(ctx, tx, phone, window)
|
||||
return err
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stale, _ := dao.PaymentOrder.GetByPk(ctx(), orderStale)
|
||||
if stale.Status != consts.OrderStatusClosed {
|
||||
t.Fatalf("stale order status = %s, want closed", stale.Status)
|
||||
}
|
||||
if recent == nil || recent.OrderId != orderRecent {
|
||||
t.Fatalf("recent = %+v, want %s", recent, orderRecent)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTradeNoNullable 未支付订单 trade_no 必须落 NULL:UNIQUE 约束下多个 created 订单(同渠道不同账号)可共存
|
||||
func TestTradeNoNullable(t *testing.T) {
|
||||
for _, channel := range []string{consts.ChannelAlipay, consts.ChannelAlipay, consts.ChannelWechat, consts.ChannelWechat} {
|
||||
insertOrder(t, &entity.PaymentOrder{
|
||||
OrderId: uniqueOrderId("O-test-trade-"), PhoneNum: uniquePhone(), PlanId: "day", Channel: channel,
|
||||
AmountCents: 1000, Status: consts.OrderStatusCreated, CreatedAt: gtime.Now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalcExpiresAt 自然日语义:day 当天 24 点失效、week/month 自生效日起累计
|
||||
func TestCalcExpiresAt(t *testing.T) {
|
||||
base := time.Date(2026, 8, 22, 15, 30, 0, 0, time.Local)
|
||||
start := time.Date(2026, 8, 22, 0, 0, 0, 0, time.Local)
|
||||
cases := []struct {
|
||||
days int
|
||||
want time.Time
|
||||
}{
|
||||
{1, start.AddDate(0, 0, 1)}, // 当天 24:00
|
||||
{7, start.AddDate(0, 0, 7)}, // 第 7 天 24:00
|
||||
{30, start.AddDate(0, 0, 30)},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := License.CalcExpiresAt(base, c.days)
|
||||
if !got.Time.Equal(c.want) {
|
||||
t.Fatalf("CalcExpiresAt(%d) = %s, want %s", c.days, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
# 服务层白盒测试专用配置(独立临时库,不提交运行数据)
|
||||
# 运行方式: cd server && GF_GCFG_FILE=biz/service/testdata/config.yml go test ./biz/service/
|
||||
server:
|
||||
address: ":0"
|
||||
|
||||
database:
|
||||
default:
|
||||
type: sqlite
|
||||
link: "sqlite::@file(testdata/observer_test.db)"
|
||||
cache:
|
||||
ttl: 30
|
||||
|
||||
auth:
|
||||
secret: "test-auth-secret"
|
||||
tokenTtl: 2592000
|
||||
|
||||
plans:
|
||||
- id: day
|
||||
days: 1
|
||||
price_cents: 1000
|
||||
- id: week
|
||||
days: 7
|
||||
price_cents: 5600
|
||||
- id: month
|
||||
days: 30
|
||||
price_cents: 18000
|
||||
@@ -0,0 +1,23 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gcode"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
)
|
||||
|
||||
// AdminAuth 管理端静态 token 鉴权中间件:请求头 X-Admin-Token 须与 config.yml
|
||||
// admin.token 一致;token 未配置(空)时管理接口全部拒绝。
|
||||
func AdminAuth(r *ghttp.Request) {
|
||||
want := g.Cfg().MustGet(r.GetCtx(), "admin.token", "").String()
|
||||
if want == "" || r.Header.Get("X-Admin-Token") != want {
|
||||
r.Response.WriteStatusExit(http.StatusUnauthorized, g.Map{
|
||||
"code": gcode.CodeNotAuthorized.Code(),
|
||||
"message": "管理端未授权",
|
||||
"data": nil,
|
||||
})
|
||||
}
|
||||
r.Middleware.Next()
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gcode"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
)
|
||||
|
||||
// 登录 token(HMAC-SHA256 自签名,无状态):
|
||||
// token = base64url(payload) + "." + hex(HMAC-SHA256(payload, auth.secret))
|
||||
// payload = {"phone": "...", "exp": <unix 秒>};secret 轮换即全员下线。
|
||||
|
||||
var ctxKeyPhone = struct{}{}
|
||||
|
||||
// AuthTokenTtl 登录 token 有效期(秒),来自 config.yml auth.tokenTtl,缺失或非法回退 30 天。
|
||||
func AuthTokenTtl(ctx context.Context) time.Duration {
|
||||
ttl := g.Cfg().MustGet(ctx, "auth.tokenTtl", 2592000).Int64()
|
||||
if ttl <= 0 {
|
||||
ttl = 2592000
|
||||
}
|
||||
return time.Duration(ttl) * time.Second
|
||||
}
|
||||
|
||||
// SignToken 签发登录 token(secret 未配置时报错,注册/登录不可用)
|
||||
func SignToken(ctx context.Context, phone string) (string, error) {
|
||||
secret := g.Cfg().MustGet(ctx, "auth.secret", "").String()
|
||||
if secret == "" {
|
||||
return "", gerror.New("登录未配置(检查 config.yml auth.secret)")
|
||||
}
|
||||
payload, err := json.Marshal(map[string]any{
|
||||
"phone": phone,
|
||||
"exp": time.Now().Add(AuthTokenTtl(ctx)).Unix(),
|
||||
})
|
||||
if err != nil {
|
||||
return "", gerror.Wrap(err, "序列化 token 载荷失败")
|
||||
}
|
||||
return tokenOf(payload, secret), nil
|
||||
}
|
||||
|
||||
// ParseToken 校验并解析 token,返回 phone
|
||||
func ParseToken(ctx context.Context, token string) (string, error) {
|
||||
secret := g.Cfg().MustGet(ctx, "auth.secret", "").String()
|
||||
if secret == "" {
|
||||
return "", gerror.New("登录未配置")
|
||||
}
|
||||
i := strings.LastIndex(token, ".")
|
||||
if i <= 0 {
|
||||
return "", gerror.NewCode(gcode.CodeNotAuthorized, "token 格式错误")
|
||||
}
|
||||
payload, sig := token[:i], token[i+1:]
|
||||
raw, err := base64.RawURLEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
return "", gerror.NewCode(gcode.CodeNotAuthorized, "token 载荷无效")
|
||||
}
|
||||
// 签名覆盖原始载荷字节(签发时对 raw 签名,非 base64 串)
|
||||
if !hmac.Equal([]byte(sig), []byte(signPayload(string(raw), secret))) {
|
||||
return "", gerror.NewCode(gcode.CodeNotAuthorized, "token 签名无效")
|
||||
}
|
||||
var body struct {
|
||||
Phone string `json:"phone"`
|
||||
Exp int64 `json:"exp"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &body); err != nil {
|
||||
return "", gerror.NewCode(gcode.CodeNotAuthorized, "token 载荷无效")
|
||||
}
|
||||
if body.Phone == "" || body.Exp < time.Now().Unix() {
|
||||
return "", gerror.NewCode(gcode.CodeNotAuthorized, "token 已过期")
|
||||
}
|
||||
return body.Phone, nil
|
||||
}
|
||||
|
||||
func tokenOf(payload []byte, secret string) string {
|
||||
return base64.RawURLEncoding.EncodeToString(payload) + "." + signPayload(string(payload), secret)
|
||||
}
|
||||
|
||||
func signPayload(payload, secret string) string {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(payload))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// AuthRequired 登录态鉴权中间件:校验 Authorization: Bearer <token>,
|
||||
// 解出手机号注入请求上下文(service 经 PhoneFromCtx 读取)。
|
||||
func AuthRequired(r *ghttp.Request) {
|
||||
phone, err := ParseToken(r.GetCtx(), bearerToken(r.Header.Get("Authorization")))
|
||||
if err != nil {
|
||||
r.Response.WriteStatusExit(http.StatusUnauthorized, g.Map{
|
||||
"code": gcode.CodeNotAuthorized.Code(),
|
||||
"message": "登录已失效,请重新登录",
|
||||
"data": nil,
|
||||
})
|
||||
}
|
||||
r.SetCtx(WithPhone(r.GetCtx(), phone))
|
||||
r.Middleware.Next()
|
||||
}
|
||||
|
||||
func bearerToken(header string) string {
|
||||
if strings.HasPrefix(header, "Bearer ") {
|
||||
return strings.TrimPrefix(header, "Bearer ")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// PhoneFromCtx 取 AuthRequired 注入的登录手机号
|
||||
func PhoneFromCtx(ctx context.Context) string {
|
||||
if v, ok := ctx.Value(ctxKeyPhone).(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// WithPhone 注入登录手机号到 ctx(AuthRequired 中间件与白盒测试共用)
|
||||
func WithPhone(ctx context.Context, phone string) context.Context {
|
||||
return context.WithValue(ctx, ctxKeyPhone, phone)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
// IsNoRows 判断 Scan 空结果(结构体 Scan 无行时返回 sql.ErrNoRows),
|
||||
// dao 查询方法据此返回 nil 实体而非错误。
|
||||
func IsNoRows(err error) bool {
|
||||
return err != nil && errors.Is(err, sql.ErrNoRows)
|
||||
}
|
||||
|
||||
// CacheTTL 查询缓存 TTL(秒),来自 config.yml database.cache.ttl,缺失或非法回退默认值。
|
||||
func CacheTTL(ctx context.Context) time.Duration {
|
||||
ttl := g.Cfg().MustGet(ctx, "database.cache.ttl", 30).Int()
|
||||
if ttl <= 0 {
|
||||
ttl = 30
|
||||
}
|
||||
return time.Duration(ttl) * time.Second
|
||||
}
|
||||
|
||||
// CacheOption 构造带显式缓存键的查询缓存选项:键须含业务参数(如 license:设备号),
|
||||
// 写操作后必须按同键调用 ClearCache,否则「库里已改、查询还是旧值」。
|
||||
func CacheOption(ctx context.Context, key string) gdb.CacheOption {
|
||||
return gdb.CacheOption{Duration: CacheTTL(ctx), Force: false, Name: key}
|
||||
}
|
||||
|
||||
// gdb 的查询缓存键 = 固定前缀 + 自定义 Name(见 gdb.genSelectCacheKey,前缀常量未导出),
|
||||
// 清除时必须拼上同一前缀,否则键对不上、缓存永远清不掉。
|
||||
const selectCachePrefix = "SelectCache:"
|
||||
|
||||
// ClearCache 清除查询缓存(写操作后必须调用)。清除失败仅记录日志:缓存 TTL 自愈兜底,
|
||||
// 不阻断业务主链路。
|
||||
func ClearCache(ctx context.Context, keys ...string) {
|
||||
if len(keys) == 0 {
|
||||
return
|
||||
}
|
||||
for _, key := range keys {
|
||||
if _, err := g.DB().GetCache().Remove(ctx, selectCachePrefix+key); err != nil {
|
||||
g.Log().Warningf(ctx, "清除查询缓存 %q 失败: %+v", key, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DropLegacyTableIfHasColumn 存量库迁移:表存在旧版本废弃列(账号体系上线前的 device_id)
|
||||
// 时 DROP 整表(存量数据作废,用户决策),由 dao init 以新结构重建。
|
||||
func DropLegacyTableIfHasColumn(ctx context.Context, table, column string) {
|
||||
res, err := g.DB().GetAll(ctx, "PRAGMA table_info("+table+")")
|
||||
if err != nil {
|
||||
panic("检查表结构失败 " + table + ": " + err.Error())
|
||||
}
|
||||
for _, r := range res {
|
||||
if gconv.String(r["name"]) == column {
|
||||
if _, err := g.DB().Exec(ctx, "DROP TABLE "+table); err != nil {
|
||||
panic("DROP 旧表失败 " + table + ": " + err.Error())
|
||||
}
|
||||
g.Log().Warningf(ctx, "存量表 %s 为旧结构(列 %s),已 DROP 由新结构重建(存量数据作废)", table, column)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var fileLinkRegexp = regexp.MustCompile(`@file\(([^)]+)\)`)
|
||||
|
||||
// init 确保 SQLite 数据文件所在目录存在(驱动打开文件前必须已创建,否则报 unable to open)。
|
||||
// 必须在任何 g.DB() 调用(含 dao 包 init 建表)之前执行。
|
||||
func init() {
|
||||
ctx := context.Background()
|
||||
link := g.Cfg().MustGet(ctx, "database.default.link", "").String()
|
||||
file := link
|
||||
if m := fileLinkRegexp.FindStringSubmatch(link); len(m) == 2 {
|
||||
file = m[1]
|
||||
}
|
||||
if dir := filepath.Dir(file); dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
panic("创建数据目录失败: " + err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package common
|
||||
|
||||
import "github.com/gogf/gf/v2/net/ghttp"
|
||||
|
||||
// BindController 反射注册一组 controller 到路由组:接口 path/method 唯一来源为 dto 内嵌的
|
||||
// g.Meta(携带 path/method/summary),禁止在 main.go 手动逐条注册。
|
||||
func BindController(group *ghttp.RouterGroup, controllers ...interface{}) {
|
||||
group.Bind(controllers...)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package common
|
||||
|
||||
import "github.com/gogf/gf/v2/errors/gcode"
|
||||
|
||||
// 业务错误码(1000+,框架保留 <1000):统一 HTTP 200 + body code!=0 表示失败,
|
||||
// 客户端按 code 分支。错误统一用 gerror.NewCode(common.CodeXxx, "...") 构造。
|
||||
var (
|
||||
CodePlanNotConfigured = gcode.New(1001, "套餐不存在或未配置", nil)
|
||||
CodePaymentNotConfigured = gcode.New(1002, "支付渠道未配置", nil)
|
||||
CodeOrderNotFound = gcode.New(1003, "订单不存在", nil)
|
||||
CodeOrderClosed = gcode.New(1004, "订单已关闭,需重新下单", nil)
|
||||
CodeCallbackVerifyFailed = gcode.New(1005, "回调验签失败", nil)
|
||||
CodeCallbackMismatch = gcode.New(1006, "回调商户/金额不匹配", nil)
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
package common
|
||||
|
||||
// NormalizePage 分页钳制:page ≥1(默认 1)、size 1..100(默认 20)
|
||||
func NormalizePage(page, size int) (int, int) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 {
|
||||
size = 20
|
||||
}
|
||||
if size > 100 {
|
||||
size = 100
|
||||
}
|
||||
return page, size
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// Plan 套餐定价(config.yml plans 节点,静态配置;展示名由 days 派生"N天")
|
||||
type Plan struct {
|
||||
Id string `mapstructure:"id"`
|
||||
Days int `mapstructure:"days"`
|
||||
PriceCents int64 `mapstructure:"price_cents"`
|
||||
}
|
||||
|
||||
// ListPlans 全部套餐(按配置声明顺序)
|
||||
func ListPlans(ctx context.Context) ([]*Plan, error) {
|
||||
var plans []*Plan
|
||||
if err := g.Cfg().MustGet(ctx, "plans").Scan(&plans); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return plans, nil
|
||||
}
|
||||
|
||||
// GetPlan 按套餐 ID 查找,不存在返回 (nil, nil),调用方用 CodePlanNotConfigured 包装
|
||||
func GetPlan(ctx context.Context, id string) (*Plan, error) {
|
||||
plans, err := ListPlans(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range plans {
|
||||
if p.Id == id {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/grpool"
|
||||
|
||||
"observer-server/biz/consts"
|
||||
)
|
||||
|
||||
// CallbackPool 支付回调处理并发池:验签/落授权等 IO 任务在池内执行并返回结果,
|
||||
// 并发度来自 config.yml payment.poolSize,缺失或非法时回退 consts 默认值。
|
||||
// 池内任务禁止再提交本池(防 worker 饿死死锁)。
|
||||
type CallbackPool struct {
|
||||
pool *grpool.Pool
|
||||
}
|
||||
|
||||
var (
|
||||
callbackPoolOnce sync.Once
|
||||
callbackPool *CallbackPool
|
||||
)
|
||||
|
||||
// CallbackPoolInstance 进程级回调池单例(懒初始化,读取配置)。
|
||||
func CallbackPoolInstance() *CallbackPool {
|
||||
callbackPoolOnce.Do(func() {
|
||||
ctx := context.Background()
|
||||
size := g.Cfg().MustGet(ctx, "payment.poolSize", consts.PaymentPoolDefaultSize).Int()
|
||||
if size <= 0 {
|
||||
size = consts.PaymentPoolDefaultSize
|
||||
}
|
||||
callbackPool = &CallbackPool{pool: grpool.New(size, size)}
|
||||
})
|
||||
return callbackPool
|
||||
}
|
||||
|
||||
// Submit 提交回调任务并等待执行完成,返回任务的 error。
|
||||
func (p *CallbackPool) Submit(ctx context.Context, fn func(ctx context.Context) error) error {
|
||||
res := make(chan error, 1)
|
||||
if err := p.pool.Add(ctx, func(ctx context.Context) {
|
||||
res <- fn(ctx)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case err := <-res:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/errors/gcode"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
)
|
||||
|
||||
// UnifiedResponse 统一响应中间件:挂在客户端路由组上,输出 {"code":0,"message":"ok","data":...}。
|
||||
// handler 返回 error 时输出 {code, message, data:null}(code 取 gerror 附带的业务码);
|
||||
// 已直接写响应体的例外场景(SSE、文件导出等)跳过包装。
|
||||
func UnifiedResponse(r *ghttp.Request) {
|
||||
r.Middleware.Next()
|
||||
|
||||
// handler 已直接写响应体(例外场景),不再包装
|
||||
if r.Response.BufferLength() > 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.GetError(); err != nil {
|
||||
code := gerror.Code(err)
|
||||
if code == gcode.CodeNil {
|
||||
code = gcode.CodeInternalError
|
||||
}
|
||||
r.Response.WriteJson(g.Map{"code": code.Code(), "message": err.Error(), "data": nil})
|
||||
return
|
||||
}
|
||||
r.Response.WriteJson(g.Map{"code": 0, "message": "ok", "data": r.GetHandlerResponse()})
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package common
|
||||
|
||||
import "context"
|
||||
|
||||
// serialWriter 单写者执行器:所有 SQLite 写链路(含「查订单→算 expiresAt→更新订单→写 license→
|
||||
// 清缓存」等读改写事务)排入同一 goroutine 串行执行,规避无 WAL 时并发写 "database is locked";
|
||||
// 任务结果经 buffered channel 回调用方,不额外开池(写路径天然无并行点)。
|
||||
type serialWriter struct {
|
||||
jobs chan func()
|
||||
}
|
||||
|
||||
var serial = &serialWriter{jobs: make(chan func(), 64)}
|
||||
|
||||
func init() {
|
||||
go func() {
|
||||
for f := range serial.jobs {
|
||||
f()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Serial 返回进程级单写者:SQLite 写操作必须经此串行执行。
|
||||
func Serial() *serialWriter {
|
||||
return serial
|
||||
}
|
||||
|
||||
// Submit 排队执行写任务并等待结果,返回任务的 error;ctx 取消/超时不再等待。
|
||||
// 已排队未执行的任务在 ctx 取消后仍会执行(结果入 buffered channel 无泄漏),
|
||||
// 调用方以返回的 ctx.Err() 为准不再消费其结果。
|
||||
func (w *serialWriter) Submit(ctx context.Context, fn func() error) error {
|
||||
res := make(chan error, 1)
|
||||
select {
|
||||
case w.jobs <- func() {
|
||||
res <- fn()
|
||||
}:
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
select {
|
||||
case err := <-res:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
)
|
||||
|
||||
// SpaFallback 单页应用 history 路由回退中间件:prefix 路径下请求对应的静态文件不存在
|
||||
// (即前端路由,如 /admin/orders)时回退 index.html;真实静态文件交给后续静态服务处理。
|
||||
// root 为前端构建产物目录,prefix 为托管路径前缀(如 "/admin")。
|
||||
func SpaFallback(root, prefix string) ghttp.HandlerFunc {
|
||||
root = filepath.Clean(root)
|
||||
index := filepath.Join(root, "index.html")
|
||||
return func(r *ghttp.Request) {
|
||||
path := r.URL.Path
|
||||
if r.Method == http.MethodGet && (path == prefix || strings.HasPrefix(path, prefix+"/")) {
|
||||
rel := strings.TrimPrefix(path, prefix)
|
||||
candidate := filepath.Clean(filepath.Join(root, rel))
|
||||
if !strings.HasPrefix(candidate, root) {
|
||||
r.Middleware.Next()
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(candidate); err != nil {
|
||||
r.Response.ServeFile(index)
|
||||
return
|
||||
}
|
||||
}
|
||||
r.Middleware.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package common
|
||||
|
||||
// 全局统一注册 SQLite 驱动(main 与服务层白盒测试共用同一注册点,
|
||||
// 测试二进制不编译 main.go,驱动须在 common 层保证加载)
|
||||
import _ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||
@@ -0,0 +1,54 @@
|
||||
# 视野后端配置
|
||||
server:
|
||||
address: ":8080"
|
||||
openapiPath: "/api.json"
|
||||
|
||||
database:
|
||||
default:
|
||||
type: sqlite
|
||||
# SQLite 数据文件(运行时数据,不提交 git)
|
||||
link: "sqlite::@file(./data/observer.db)"
|
||||
cache:
|
||||
# 查询缓存 TTL(秒)
|
||||
ttl: 30
|
||||
|
||||
auth:
|
||||
# 登录 token 签名密钥(HMAC-SHA256,必填):换值即全员下线
|
||||
secret: "changeme-auth-secret"
|
||||
# 登录 token 有效期(秒),默认 30 天
|
||||
tokenTtl: 2592000
|
||||
|
||||
admin:
|
||||
# 后台管理端静态 token:请求头 X-Admin-Token 必须匹配;为空时管理接口全部拒绝
|
||||
token: "Tongli686^*^"
|
||||
|
||||
# 套餐定价(静态配置,改价 = 改本节点后重启服务;金额单位为分,展示名由 days 派生"N天")
|
||||
plans:
|
||||
- id: day
|
||||
days: 1
|
||||
price_cents: 1000
|
||||
- id: week
|
||||
days: 7
|
||||
price_cents: 5600
|
||||
- id: month
|
||||
days: 30
|
||||
price_cents: 18000
|
||||
|
||||
payment:
|
||||
# 回调验签/IO 处理并发度(缺失或非法时回退默认值)
|
||||
poolSize: 16
|
||||
wechat:
|
||||
# 微信支付 APP 支付(APIv3),商户号未配置时接口返回「支付未配置」
|
||||
appid: "" # 开放平台 AppID
|
||||
mchid: "" # 商户号
|
||||
apiV3Key: "" # APIv3 密钥
|
||||
serialNo: "" # 商户证书序列号
|
||||
privateKey: "" # 商户 API 私钥文件路径(PEM)
|
||||
notifyUrl: "" # 回调地址,如 https://api.example.com/api/v1/payment/wechat/notify
|
||||
alipay:
|
||||
# 支付宝 APP 支付,未配置时接口返回「支付未配置」
|
||||
appid: "" # 开放平台 AppID
|
||||
privateKey: "" # 应用私钥文件路径(PEM)
|
||||
alipayPublicKey: "" # 支付宝公钥文件路径(PEM)
|
||||
sellerId: "" # 收款方 PID(回调校验 app_id/seller_id 用)
|
||||
notifyUrl: "" # 回调地址,如 https://api.example.com/api/v1/payment/alipay/notify
|
||||
@@ -0,0 +1,12 @@
|
||||
# 视野后端单机部署:前后端一体单端口,./data 挂载持久化(容器重建不丢授权/订单数据)
|
||||
services:
|
||||
observer-server:
|
||||
build: .
|
||||
container_name: observer-server
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
@@ -0,0 +1,51 @@
|
||||
module observer-server
|
||||
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.2
|
||||
github.com/gogf/gf/v2 v2.10.2
|
||||
github.com/smartwalle/alipay/v3 v3.2.31
|
||||
github.com/wechatpay-apiv3/wechatpay-go v0.2.21
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.5.0 // indirect
|
||||
github.com/clbanning/mxj/v2 v2.7.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/emirpasic/gods/v2 v2.0.0-alpha // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/grokify/html-strip-tags-go v0.1.0 // indirect
|
||||
github.com/magiconair/properties v1.8.10 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/olekukonko/errors v1.1.0 // indirect
|
||||
github.com/olekukonko/ll v0.0.9 // indirect
|
||||
github.com/olekukonko/tablewriter v1.1.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.2.0 // indirect
|
||||
github.com/smartwalle/ncrypto v1.0.4 // indirect
|
||||
github.com/smartwalle/ngx v1.1.2 // indirect
|
||||
github.com/smartwalle/nsign v1.0.9 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/otel v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
||||
golang.org/x/crypto v0.55.0 // indirect
|
||||
golang.org/x/net v0.57.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/sqlite v1.23.1 // indirect
|
||||
)
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/agiledragon/gomonkey v2.0.2+incompatible h1:eXKi9/piiC3cjJD1658mEE2o3NjkJ5vDLgYjCQu0Xlw=
|
||||
github.com/agiledragon/gomonkey v2.0.2+incompatible/go.mod h1:2NGfXu1a80LLr2cmWXGBDaHEjb1idR6+FVlX5T3D9hw=
|
||||
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
|
||||
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/emirpasic/gods/v2 v2.0.0-alpha h1:dwFlh8pBg1VMOXWGipNMRt8v96dKAIvBehtCt6OtunU=
|
||||
github.com/emirpasic/gods/v2 v2.0.0-alpha/go.mod h1:W0y4M2dtBB9U5z3YlghmpuUhiaZT2h6yoeE+C1sCp6A=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.2 h1:KLS68SWS2W749x7e+eCCOO3UD2Sbw+bIbLEPR8o1FXw=
|
||||
github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.2/go.mod h1:uLcsu73PfpyhRc0Jq0gGAWQjN1tyGU9iBRrYgt/lu7g=
|
||||
github.com/gogf/gf/v2 v2.10.2 h1:46IO0Uc8e85/FqdftJFskfDejJLBL0JBnGS5qOftUu8=
|
||||
github.com/gogf/gf/v2 v2.10.2/go.mod h1:Svl1N+E8G/QshU2DUbh/3J/AJauqCgUnxHurXWR4Qx0=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grokify/html-strip-tags-go v0.1.0 h1:03UrQLjAny8xci+R+qjCce/MYnpNXCtgzltlQbOBae4=
|
||||
github.com/grokify/html-strip-tags-go v0.1.0/go.mod h1:ZdzgfHEzAfz9X6Xe5eBLVblWIxXfYSQ40S/VKrAOGpc=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM=
|
||||
github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
|
||||
github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI=
|
||||
github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g=
|
||||
github.com/olekukonko/tablewriter v1.1.0 h1:N0LHrshF4T39KvI96fn6GT8HEjXRXYNDrDjKFDB7RIY=
|
||||
github.com/olekukonko/tablewriter v1.1.0/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
github.com/smartwalle/alipay/v3 v3.2.31 h1:KVwWJZ5JvNxPridHrcpTwgJeMzi1IqZopid0sDJQewU=
|
||||
github.com/smartwalle/alipay/v3 v3.2.31/go.mod h1:0G9wqvo1719hxo6ZWntujuPNyVQFWikS52LF62rRYDg=
|
||||
github.com/smartwalle/ncrypto v1.0.4 h1:P2rqQxDepJwgeO5ShoC+wGcK2wNJDmcdBOWAksuIgx8=
|
||||
github.com/smartwalle/ncrypto v1.0.4/go.mod h1:Dwlp6sfeNaPMnOxMNayMTacvC5JGEVln3CVdiVDgbBk=
|
||||
github.com/smartwalle/ngx v1.1.2 h1:W+K262lHUvdfJ/2e762dIFwN543kxC/4qLkuCQLRS8o=
|
||||
github.com/smartwalle/ngx v1.1.2/go.mod h1:mx/nz2Pk5j+RBs7t6u6k22MPiBG/8CtOMpCnALIG8Y0=
|
||||
github.com/smartwalle/nsign v1.0.9 h1:8poAgG7zBd8HkZy9RQDwasC6XZvJpDGQWSjzL2FZL6E=
|
||||
github.com/smartwalle/nsign v1.0.9/go.mod h1:eY6I4CJlyNdVMP+t6z1H6Jpd4m5/V+8xi44ufSTxXgc=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/wechatpay-apiv3/wechatpay-go v0.2.21 h1:uIyMpzvcaHA33W/QPtHstccw+X52HO1gFdvVL9O6Lfs=
|
||||
github.com/wechatpay-apiv3/wechatpay-go v0.2.21/go.mod h1:A254AUBVB6R+EqQFo3yTgeh7HtyqRRtN2w9hQSOrd4Q=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
||||
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
|
||||
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
||||
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||
@@ -0,0 +1,95 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
|
||||
"observer-server/biz/controller"
|
||||
"observer-server/common"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
initDatabase(ctx)
|
||||
|
||||
s := g.Server()
|
||||
// 客户端接口组:统一响应包装 {"code":0,"message":"ok","data":...}
|
||||
// 账号组公开(注册/登录),业务组(订单/授权)需登录态(Authorization: Bearer token)
|
||||
s.Group("/api/v1", func(group *ghttp.RouterGroup) {
|
||||
group.Middleware(common.UnifiedResponse)
|
||||
common.BindController(group, controller.Auth)
|
||||
})
|
||||
s.Group("/api/v1", func(group *ghttp.RouterGroup) {
|
||||
group.Middleware(common.UnifiedResponse, common.AuthRequired)
|
||||
common.BindController(group, controller.Order, controller.License)
|
||||
})
|
||||
// 支付回调组:不做统一包装,按渠道应答格式直接返回(微信 SUCCESS/FAIL JSON、支付宝 success/failure 文本)
|
||||
s.Group("/api/v1/payment", func(group *ghttp.RouterGroup) {
|
||||
common.BindController(group, controller.Payment)
|
||||
})
|
||||
// 管理端接口组:统一响应包装 + 静态 token 鉴权
|
||||
s.Group("/api/v1/admin", func(group *ghttp.RouterGroup) {
|
||||
group.Middleware(common.UnifiedResponse, common.AdminAuth)
|
||||
common.BindController(group, controller.Admin)
|
||||
})
|
||||
// 管理端静态页面托管:构建产物输出到 admin_dist(server_admin/ 构建),SPA history 路由回退 index.html;
|
||||
// 目录不存在(尚未构建前端)时跳过,不影响 API 启动
|
||||
if stat, err := os.Stat("./admin_dist"); err == nil && stat.IsDir() {
|
||||
s.AddStaticPath("/admin", "./admin_dist")
|
||||
s.BindMiddlewareDefault(common.SpaFallback("./admin_dist", "/admin"))
|
||||
} else {
|
||||
g.Log().Warningf(ctx, "admin_dist 不存在,跳过管理端静态托管(cd server_admin && npm run build)")
|
||||
}
|
||||
s.Run()
|
||||
}
|
||||
|
||||
// initDatabase 数据初始化:存量库以 PRAGMA user_version 版本化迁移;
|
||||
// v2 = 删 license.plan_id 列(无业务语义);v3 = 删 plan 表(套餐已配置化,清残留表)。
|
||||
func initDatabase(ctx context.Context) {
|
||||
res, err := g.DB().GetAll(ctx, "PRAGMA user_version")
|
||||
if err != nil {
|
||||
g.Log().Fatalf(ctx, "读取库版本失败: %+v", err)
|
||||
}
|
||||
version := 0
|
||||
if len(res) > 0 {
|
||||
version = gconv.Int(res[0]["user_version"])
|
||||
}
|
||||
if version < 2 {
|
||||
// v2:删除 license.plan_id(只留到期时间;PRAGMA table_info 检测列存在才 DROP,
|
||||
// 全新库建表已无此列、直接跳过,不丢账号数据)
|
||||
cols, err := g.DB().GetAll(ctx, "PRAGMA table_info(license)")
|
||||
if err != nil {
|
||||
g.Log().Fatalf(ctx, "读取 license 表结构失败: %+v", err)
|
||||
}
|
||||
hasPlanId := false
|
||||
for _, col := range cols {
|
||||
if gconv.String(col["name"]) == "plan_id" {
|
||||
hasPlanId = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasPlanId {
|
||||
if _, err := g.DB().Exec(ctx, "ALTER TABLE license DROP COLUMN plan_id"); err != nil {
|
||||
g.Log().Fatalf(ctx, "删除 license.plan_id 列失败: %+v", err)
|
||||
}
|
||||
}
|
||||
if _, err := g.DB().Exec(ctx, "PRAGMA user_version = 2"); err != nil {
|
||||
g.Log().Fatalf(ctx, "库版本写入失败: %+v", err)
|
||||
}
|
||||
g.Log().Infof(ctx, "数据库初始化完成(version=2)")
|
||||
}
|
||||
if version < 3 {
|
||||
// v3:套餐改 config.yml 配置后清理残留 plan 表(订单快照 payment_order.plan_id 不受影响)
|
||||
if _, err := g.DB().Exec(ctx, "DROP TABLE IF EXISTS plan"); err != nil {
|
||||
g.Log().Fatalf(ctx, "删除残留 plan 表失败: %+v", err)
|
||||
}
|
||||
if _, err := g.DB().Exec(ctx, "PRAGMA user_version = 3"); err != nil {
|
||||
g.Log().Fatalf(ctx, "库版本写入失败: %+v", err)
|
||||
}
|
||||
g.Log().Infof(ctx, "数据库初始化完成(version=3)")
|
||||
}
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
# 视野后端 技术设计
|
||||
|
||||
支付与授权后端的实现细节与技术决策。文档驱动:涉及本文档的决策变更须先改文档再写代码。
|
||||
|
||||
## 技术栈与分层
|
||||
|
||||
Go + GoFrame(分层规范见 `CLAUDE.md`):`controller → service → dao`,SQLite 单机存储,Docker Compose 单机部署(前后端一体单端口)。
|
||||
|
||||
- controller:`biz/controller`,DTO `g.Meta` 反射注册路由;支付回调组用 `/api/v1/payment` 前缀(不做统一响应包装,按渠道应答格式直接返回),客户端组用 `/api/v1` 前缀(统一响应包装)
|
||||
- service:`biz/service`,订单创建、回调落授权、授权查询
|
||||
- dao:`biz/dao`,单表 CRUD,无业务逻辑
|
||||
- 并发:回调处理为 IO 任务,可走 `common` 池;SQLite 写一律回主 goroutine 串行(无 WAL)
|
||||
|
||||
## 套餐配置与数据表 DDL
|
||||
|
||||
**套餐(静态定价)存 `config.yml` `plans` 节点,不建表**(决策:套餐常年不变,改价 = 改配置重启,避免数据库表 + 管理端改价接口的维护成本;存量 `plan` 表由 v3 迁移 DROP)。展示名按 `days` 派生「N天」,无独立 label 字段。
|
||||
|
||||
```yaml
|
||||
plans:
|
||||
- id: day
|
||||
days: 1
|
||||
price_cents: 1000
|
||||
- id: week
|
||||
days: 7
|
||||
price_cents: 5600
|
||||
- id: month
|
||||
days: 30
|
||||
price_cents: 18000
|
||||
```
|
||||
|
||||
SQLite,金额整数分(INT64),时间存 ISO8601 文本(`gtime.Time` 序列化)。
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS payment_order (
|
||||
order_id TEXT PRIMARY KEY, -- O + 时间戳 + 随机后缀,业务唯一键
|
||||
phone_num TEXT NOT NULL, -- 下单账号(手机号),绑定授权归属
|
||||
plan_id TEXT NOT NULL,
|
||||
channel TEXT NOT NULL, -- wechat | alipay
|
||||
amount_cents INTEGER NOT NULL, -- 下单时锁定套餐价格(快照,防套餐改价)
|
||||
status TEXT NOT NULL DEFAULT 'created', -- created | paid | closed
|
||||
wx_trade_no TEXT UNIQUE, -- 微信交易号,幂等唯一约束
|
||||
alipay_trade_no TEXT UNIQUE, -- 支付宝交易号,幂等唯一约束
|
||||
created_at TEXT NOT NULL,
|
||||
paid_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS license (
|
||||
phone_num TEXT PRIMARY KEY, -- 手机号账号,一账号一授权记录,续费更新
|
||||
password TEXT NOT NULL, -- bcrypt 加盐哈希,不存明文
|
||||
expires_at TEXT, -- 未充值 NULL(NULL/过期即未授权)
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_payment_order_phone ON payment_order(phone_num);
|
||||
```
|
||||
|
||||
存量库迁移以 `PRAGMA user_version` 版本化标记,禁止重复执行。迁移记录:
|
||||
- v1 = 建表(各 dao init)+ 种子套餐(v1 已移除)
|
||||
- v2 = 删除 `license.plan_id` 列(该字段无业务语义,只留到期时间;`PRAGMA table_info` 检测列存在才 `ALTER TABLE ... DROP COLUMN`,新库与已迁移库跳过)
|
||||
- v3 = `DROP TABLE IF EXISTS plan`(套餐改配置后清残留表,订单快照 `payment_order.plan_id` 不受影响)
|
||||
|
||||
## 账号体系(注册/登录)
|
||||
|
||||
授权与账号合一:**`license` 表即账号表**(决策:不建独立 user 表)——注册插一行(`password`=bcrypt 哈希,`expires_at` 为 NULL),充值/手动授权更新同一行。未充值账号存在但无额度,授权判断 `expires_at > now`(NULL 视为未授权)。下单购买的套餐记录在 `payment_order.plan_id`(订单快照),`license` 表不存套餐只存到期时间。
|
||||
|
||||
- `POST /api/v1/auth/register`:`{phone, password}` → 插入 license 行;phone 已存在报错;password 服务端 bcrypt 加盐哈希存储
|
||||
- `POST /api/v1/auth/login`:`{phone, password}` → bcrypt 校验 → 签发 token;**登录查库必须绕过缓存**(license 查询有 TTL 缓存,密码变更需即时生效)
|
||||
- token:HMAC-SHA256 自签名(payload = base64({phone, exp}) + 签名),secret 来自 `config.yml` `auth.secret`,有效期 `auth.token_ttl`(默认 30 天);无状态、服务端不存储,secret 轮换即全员下线
|
||||
- 鉴权中间件 `common.AuthRequired`:校验 `Authorization: Bearer <token>` → 解出 phone 注入请求上下文;失败返回统一 401(code 61)
|
||||
- 需登录态接口:`GET /plans`、`POST /orders`、`POST /orders/{orderId}/confirm`、`GET /license`(phone 一律从 token 解出,客户端不传)
|
||||
|
||||
## 有效期语义(自然日)
|
||||
|
||||
| 套餐 | 有效期 |
|
||||
|---|---|
|
||||
| day | 当天 24:00(服务端时区)失效 |
|
||||
| week | 自生效日起第 7 天 24:00 失效 |
|
||||
| month | 自生效日起第 30 天 24:00 失效 |
|
||||
|
||||
- `expiresAt` 一律由**服务端**按本机时区计算并下发,客户端只做「是否过期」判断,不参与计算
|
||||
- 续费叠加:新授权 `expiresAt = max(现有 expiresAt, now) 起算套餐天数`,即未过期时顺延,已过期时从当前时间起算;禁止覆盖缩短
|
||||
- 单位与展示:服务端只下发 `expiresAt`(ISO8601,含时区),前端自行展示剩余时长
|
||||
|
||||
## 订单创建流程
|
||||
|
||||
1. `POST /api/v1/orders` 校验 DTO(`planId` 必填、`channel` in wechat/alipay)+ AuthRequired 解出的 `phoneNum`
|
||||
2. service:查 `config.yml` plans 锁定 `price_cents`(`common.GetPlan`,不存在报"套餐不存在或未配置")→ 生成 `orderId` → 插 `payment_order(status=created, phone_num)` → 调支付渠道统一下单
|
||||
3. 微信(APP 支付):V3 统一下单 `appid` 传客户端 AppID → 响应参数(prepay_id、partnerId、nonceStr、timeStamp、sign)组装返回
|
||||
4. 支付宝:`alipay.trade.app.pay` → 返回 `orderStr`
|
||||
5. 下单失败:事务回滚订单(status=closed 或删除),返回错误
|
||||
6. 未支付订单回收:创建新订单前,惰性关闭该 `phone_num` 下超时未支付的 created 订单(超时阈值默认 2 小时、可配置,对齐微信支付有效期,保证用户支付成功后回调必然可落授权);若该账号存在 2 小时内的 created 订单,直接复用返回,禁止重复创建(防重复扣款兜底)
|
||||
|
||||
## 支付回调与幂等
|
||||
|
||||
微信/支付宝回调是**唯一授权来源**,`confirm` 只是加速刷新;回调未到账时 confirm 不落授权。
|
||||
|
||||
**并发与事务**:回调落授权是「查订单 → 算 `expiresAt` → 更新订单 → 写 license → 清缓存」的读改写链路,微信/支付宝回调与 confirm 可能并发到达同一订单。整条链路必须由 service 在**事务 + 单写者**下串行执行:SQLite 无 WAL,并发写会 `database is locked`;串行化同时保证 `expiresAt` 只按一次现授权状态计算(叠加语义不被并发重复累加)。事务内写方法以 `XxxInTx` 形式实现,由 service 持有 tx 编排(见 `CLAUDE.md` 事务规范)。
|
||||
|
||||
**微信(APIv3)**:`POST /api/v1/payment/wechat/notify`
|
||||
- 验签:`Wechatpay-Timestamp/Nonce/Signature` + 平台证书 → 验签通过后 AES-GCM 解密
|
||||
- 商户校验:解密后校验 `appid`/`mchid` 与配置一致(防跨商户重放,对称支付宝的 `app_id`/`seller_id` 校验)
|
||||
- 落授权:`out_trade_no` → 查 `payment_order` → 更新 `status=paid` + `wx_trade_no` → 计算 `expiresAt` 写 `license`
|
||||
- 幂等:`wx_trade_no` UNIQUE 约束兜底,重复回调忽略(返回 200 空响应);订单状态已是 paid 时直接成功返回
|
||||
- 应答:成功返回 `{"code": "SUCCESS"}`,失败返回 `{"code": "FAIL", "message": ...}`(微信会重试)
|
||||
|
||||
**支付宝**:`POST /api/v1/payment/alipay/notify`
|
||||
- 验签:RSA2 验签 + 校验 `app_id`/`seller_id` 与配置一致、`total_amount` 与订单 `amount_cents`(元)一致
|
||||
- 落授权:同上;`alipay_trade_no` UNIQUE 兜底
|
||||
- 应答:验签失败返回 `failure`(支付宝会重试),成功返回 `success`
|
||||
|
||||
**客户端 confirm**:`POST /api/v1/orders/{orderId}/confirm`(Bearer token)幂等 —— 订单已 paid 返回 `{"status": "paid"}`;created 且无回调返回 `{"status": "created"}`。禁止在 confirm 里直接落授权。
|
||||
|
||||
**confirm 轮询(客户端对接约定)**:回调通常 1~5 秒到账。confirm 返回 created 时,客户端按 3s → 10s → 30s 递增重试(最多 3 次,总计 ~43s),期间展示「支付确认中」并禁用重新下单;重试仍 created 则回到授权查询(`GET /license`),服务端回调到账后自然变为 active。**防重复支付**:客户端在订单闭环(confirm 返回 paid)前不得为同一套餐创建新订单;服务端兜底见「订单创建流程」第 6 条(2 小时内复用 created 订单)。
|
||||
|
||||
## 授权查询
|
||||
|
||||
`GET /api/v1/license`(Bearer token)
|
||||
- AuthRequired 解出 `phoneNum` → 查 `license`:`expires_at > now` → `{active: true, expiresAt}`;否则 `{active: false, expiresAt: null}`(含未充值 NULL)
|
||||
- 查询走 DAO 缓存(`gdb.CacheOption`,TTL 来自配置),落授权后必须清对应缓存(键 `license:{phoneNum}`),否则「库里已改、查询还是旧值」
|
||||
|
||||
## 安全与风险
|
||||
|
||||
| 项 | 方案 |
|
||||
|---|---|
|
||||
| 回调伪造 | 微信 APIv3 证书验签+解密;支付宝 RSA2 验签,且校验金额/商户一致,禁止只验签不比对 |
|
||||
| 账号体系 | 手机号+密码登录,密码 bcrypt 哈希存储;token 自签名(`auth.secret` 校验),手机号从 token 解出,客户端不可伪造;授权绑账号,同账号多设备共享 |
|
||||
| 下单防刷 | 金额不由客户端传入(服务端按 planId 查 `config.yml` plans 锁定),客户端只能选套餐不能改价 |
|
||||
| 订单号防猜测 | `orderId` 加随机后缀,长度 ≥ 20,不可枚举 |
|
||||
| 回调重放 | 交易号唯一约束 + 订单状态机(created→paid 单向),paid 后忽略一切变更 |
|
||||
| 网络异常 | 回调失败返回 FAIL/failure 触发渠道重试;客户端 confirm 失败可重试(幂等) |
|
||||
|
||||
## 客户端对接要点
|
||||
|
||||
- API 契约:`flutter_app/docs/PaymentApi.md`(字段与本文档一致,以本文档为准)
|
||||
- 客户端配置替换项:`flutter_app/lib/config/app_config.dart`(`apiBaseUrl`、`wechatAppId`、`alipayAppId`、URL Scheme / Universal Link)
|
||||
- 微信开放平台需注册 App 包名 + 签名(Android)与 Universal Link(iOS);支付宝开放平台注册 AppID + 密钥对
|
||||
- 账号流程:首次进入 App → 注册/登录页(手机号+密码)→ token 存 `flutter_secure_storage` → 主页展示到期时间;点击「识别」按钮**强制调 `GET /license` 服务端校验**(不走本地缓存),未到期才进摄像头,过期/未授权跳付费墙
|
||||
- App 内授权缓存:客户端 `flutter_secure_storage` 缓存 `expiresAt` 仅用于主页展示,识别入口一律服务端为准
|
||||
|
||||
## 后台管理端
|
||||
|
||||
代码在仓库根 `server_admin/`(Vue3 + Element Plus + Vite),构建产物输出到 `server/admin_dist/` 由后端 `/admin/` 路径托管(前后端一体单端口,SPA 未命中静态文件的 GET 回退 index.html)。开发期 Vite `server.proxy` 将 `/api` 代理到 `:8080`。
|
||||
|
||||
**鉴权(静态 token)**:管理接口挂在 `/api/v1/admin` 组,`common.AdminAuth` 中间件校验请求头 `X-Admin-Token` 与 `config.yml` `admin.token` 一致,未配置或失配返回 401 统一错误格式(`{"code":401,"message":"管理端未授权","data":null}`)。前端登录页输入 token 后存浏览器 localStorage,请求拦截器自动携带;token 不内嵌构建产物,失配/失效时前端清 token 回登录页。token 属内网管理凭据,`server_admin/.env*` 已 gitignore(前端不再有 env token)。
|
||||
|
||||
**接口清单**(统一响应包装,`/api/v1/admin` 前缀):
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | /orders | 订单列表:`phoneNum/status/startAt/endAt` 筛选 + `page/size` 分页(size 上限 100),金额返回整数分 |
|
||||
| GET | /licenses | 账号列表:`phoneNum` 筛选 + 分页(含未充值账号) |
|
||||
| POST | /licenses/grant | 手动授权 `{phoneNum, planId}`:与支付回调同语义(自然日叠加、单写者串行事务),提交后清授权缓存 |
|
||||
| POST | /licenses/revoke | 撤销授权 `{phoneNum}`:清空 `expires_at` **保留账号行**(不删密码),清缓存,客户端下次查询即 inactive |
|
||||
|
||||
**结构决策**:管理端是跨表业务面,controller 聚合在 `biz/controller/admin.go`(避免同一 controller 挂客户端组 + 管理组时 `group.Bind` 重复注册路由),service 按「跨表业务流程归入所属表文件」归入各表文件(订单列表 → `service/order.go`,授权 grant/revoke/列表 → `service/license.go`),dto 聚合在 `biz/model/dto/admin.go`。套餐已配置化(`common/plans.go` 读取 config.yml,仅客户端登录组 `GET /plans` 使用),无独立分层文件,管理端不管理套餐。
|
||||
|
||||
**手动授权语义**:`grant` 等同免费发卡,与支付回调 `persistPaid` 共用自然日叠加规则——`base = max(现有到期, now)`,`expiresAt = base 所在日 0 点 + plan.days`;已过期账号从当前时间起算。整条链路在 `common.Serial()` 单写者 + 事务内执行(SQLite 无 WAL),提交后清 `license:{phoneNum}` 缓存。`revoke` 清空授权字段、保留账号行(密码不删),同一事务内执行并清缓存。
|
||||
|
||||
**分页约定**:`page` ≥1(默认 1),`size` 1..100(默认 20),service 内钳制;返回 `{total, list}`,`total` 为同条件总数(COUNT)。
|
||||
|
||||
## 待办/风险
|
||||
|
||||
- 微信支付需商户号(APP 支付权限)、APIv3 密钥与平台证书;支付宝需商户应用与密钥 —— 当前均未配置,接口按真实 SDK 契约实现,配置走 `config.yml` 占位
|
||||
- iOS App Store 对数字内容强制 IAP,微信/支付宝直连有被拒风险(已决策,记录在案)
|
||||
- 管理端静态 token 由登录页输入(浏览器 localStorage),属内网管理凭据:`config.yml admin.token` 失配/未配置时管理接口全部拒绝;token 不进入前端构建产物与 git
|
||||
- 手动授权等同免费发卡,仅限运营/客诉排查场景,前端页面带确认弹窗,后端不限制频率(按需再加审计)
|
||||
Reference in New Issue
Block a user