fix(wallet): enforce consistent withdrawal accounting

This commit is contained in:
david
2026-07-31 09:54:17 +08:00
parent 36a5ced1c0
commit 42a2322c8e
23 changed files with 1405 additions and 250 deletions

640
audit/CODE_AUDIT_REPORT.md Normal file
View File

@@ -0,0 +1,640 @@
# 全项目代码审计报告
审计日期2026-07-31
审计对象:`apps/*``backend/*``frontend/*`
审计方式:只读代码审计、静态检查、单元测试和本地构建
审计基线:仅以实际代码、路由、数据模型、客户端调用和测试为依据,未将 `docs/*` 作为需求或验收依据
## 1. 结论
当前代码**不具备生产发布条件**。
本次确认:
- P02 项
- P17 项
- P28 项
- P33 项
最先需要阻断发布的事项是:
1. 仓库历史中存在明文的外部数据库、Redis 等敏感配置;固定验证码又可直接进入注册、登录和重置密码流程。
2. 钱包的 `balance``withdrawal_balance` 记账口径不闭合,可产生重复支出、拒绝提现后余额凭空增加、提现完成后总余额不减少等资金错误。
3. Worker、IoT 和上传存储仍明确是 Mock实际异步与设备链路不存在。
4. 工作人员“作业前置检查”仅用于展示,业务接口没有服务端强制执行。
因此,测试和构建全部通过并不能推导出业务可上线;现有测试主要验证路由、模型和少量纯函数,没有覆盖上述关键资金、鉴权、幂等和跨端流程。
### 等级定义
| 等级 | 含义 |
|---|---|
| P0 | 可直接造成账户接管、资金损失、核心数据破坏或必须立即处置的密钥泄露 |
| P1 | 核心流程不可用、可绕过关键业务控制或上线即产生严重错误 |
| P2 | 在异常、并发、重试或特定数据下产生错误,或形成明显维护/兼容风险 |
| P3 | 低风险冗余、局部质量或性能问题 |
置信度“高”表示从当前代码可以完整证明;“中”表示代码证据明确,但删除字段或迁移前仍需核对线上数据及外部消费者。
## 2. P0发布阻断
### P0-01 仓库内存在敏感连接配置,固定验证码可形成账户接管链路
置信度:高
**证据**
- `backend/api/etc/platform_dev.yaml:7` 包含公网 PostgreSQL 连接地址、账号和明文密码。
- `backend/api/etc/platform_dev.yaml:10` 包含公网 Redis 地址和明文凭据。
- `backend/api/etc/platform_dev.yaml:12,18,24` 分别包含固定 JWT 密钥/固定验证码/字段加密密钥性质的配置。
- 该文件由 Git 跟踪,且至少出现在多个历史提交中,单纯修改当前文件不能消除历史泄露。
- `backend/api/internal/logic/common/client_auth.go:60-67` 将全局固定验证码写入 Redis。
- `backend/api/internal/logic/common/client_auth.go:73-85` 使用该固定值完成验证和消费。
- 用户与工作人员的注册、验证码登录、重置密码、支付密码设置均复用该验证能力。
**影响**
- 外部数据库和 Redis 可能被直接访问、篡改或拖库。
- 已知固定验证码的人可为目标手机号申请新的 `request_identity`,随后尝试注册、验证码登录或重置密码。
- 字段加密密钥一旦与密文数据同时泄露,敏感字段的静态加密失去保护。
**根因**
- 开发配置被当作可提交配置管理。
- Mock 验证码能力直接复用了真实身份流程,没有环境级硬隔离。
**最小整改**
1. 立即轮换数据库、Redis、JWT、字段加密等所有已提交凭据先吊销旧值再更新部署。
2. 核查相关服务的访问日志、异常登录、密码重置和数据导出记录。
3. 将敏感值迁移到环境变量或密钥管理系统,仓库只保留无效示例。
4. 清理 Git 历史中的敏感内容,并要求所有已有克隆重新同步。
5. Mock 验证码只允许在不可访问生产数据的本地环境启动;生产启动时发现 Mock 开关或固定码应直接失败。
**迁移风险**
- 字段加密密钥轮换需要双密钥读取或批量重加密方案,不能直接替换后让历史数据失效。
**复测**
- 对仓库当前树和完整 Git 历史执行密钥扫描。
- 在非本地环境验证 Mock 验证码无法启动。
- 使用旧密钥、旧数据库凭据和旧 Redis 凭据验证均已失效。
- 覆盖目标手机号的验证码登录和重置密码攻击用例。
### P0-02 钱包双余额账本不闭合,可重复支出并制造余额
置信度:高
**证据**
1. 平台可提现充值同时增加两列:
- `backend/api/internal/logic/platform/wallet/wallet.go:232-239`
2. 商城支付只扣 `balance`,不扣 `withdrawal_balance`
- `backend/api/internal/logic/client/user/shop.go:167-189`
3. 用户提现申请只扣 `withdrawal_balance`,不扣 `balance`
- `backend/api/internal/logic/common/client_wallet.go:372-398`
4. 提现完成只更新申请状态和外部交易号,不扣 `balance`
- `backend/api/internal/logic/platform/wallet/wallet.go:336-366`
5. 配送后台创建提现申请时不预扣 `withdrawal_balance`,仅在查询时减去待处理申请:
- `backend/api/internal/logic/delivery/finance.go:145-188`
6. 平台驳回任何提现申请都会把申请金额加回 `withdrawal_balance`
- `backend/api/internal/logic/platform/wallet/wallet.go:313-323`
**可复现场景**
- 钱包充值 100 元且标记可提现后:`balance=100``withdrawal_balance=100`
- 商城消费 80 元后:`balance=20``withdrawal_balance=100`
- 再申请提现 100 元会通过,形成 80 元消费加 100 元提现。
- 配送后台申请提现时未预扣;若平台驳回,现有代码仍加回金额,可将 100 元可提现余额变成 200 元。
- 提现最终完成也不会减少总余额,余额和实际资金继续背离。
**影响**
- 直接资金损失。
- 钱包余额、可提现余额、提现申请和流水无法对账。
- 客户可在正常 API 流程内触发,不需要数据库权限。
**根因**
- `withdrawal_balance``balance` 的可提现子集,但各交易没有在同一事务内维护该不变量。
- 用户提现和配送后台提现采用了两套互不兼容的预扣策略。
- 提现没有完整的冻结额、解冻、出账和不可变流水模型。
**最小整改**
1. 立即关闭充值、余额支付和提现写入口,先冻结风险窗口。
2. 明确统一不变量,例如 `0 <= available_withdrawable <= available_balance`,并增加“冻结余额/冻结可提现余额”。
3. 支付、提现申请、驳回、完成必须在事务和行锁内同时更新余额、冻结额、流水。
4. 合并用户与配送后台提现逻辑,禁止各自实现不同扣减策略。
5. 对历史钱包、流水、提现、商城订单做全量对账和差异修复。
**迁移风险**
- 不能只改代码;历史余额已经可能不可信。
- 修复前需以外部支付记录、订单、提现回执和不可变流水重建余额,避免把现有错误余额作为初始事实。
**复测**
- 覆盖充值→支付→提现、提现→驳回、提现→完成、重复请求、并发支付/提现。
- 对每一步断言余额、可提现余额、冻结额和流水守恒。
- 增加基于随机交易序列的账本不变量测试。
## 3. P1核心流程和安全控制
### P1-01 Worker 与 IoT 仅为阻塞式 Mock 进程
置信度:高
**证据**
- `backend/worker/cmd/main/main.go:14-20` 只初始化后等待退出信号,并明确输出 Redis Streams consumer 未启用。
- `backend/iot/cmd/main/main.go:14-20` 只初始化后等待退出信号,并明确输出 MQTT Broker 未连接。
- 两模块合计约 150 行 Go 代码没有消费者、重试、Outbox 投递、MQTT 会话、命令回执或业务测试。
**影响**
- 异步通知、对账、超时处理等依赖 Worker 的流程不会执行。
- 设备遥测、远程命令和回执链路不存在。
- 进程可以成功构建和启动,但只会制造“服务在线”的假象。
**最小整改**
- 发布清单中明确排除这两个能力,或在发布前实现真实适配、健康检查、失败重试、幂等和可观测性。
- 健康检查必须区分“进程存活”和“已连接 Redis Streams/MQTT”。
### P1-02 关闭 Mock 后验证码功能全部失效,代码中没有真实发送适配器
置信度:高
**证据**
- `backend/api/internal/logic/common/client_auth.go:60-63` 无条件保存 `MockVerificationCode`,没有生成随机码或调用短信渠道。
- `backend/api/internal/logic/common/client_auth.go:73-76``MockVerificationEnabled=false` 时直接拒绝所有验证码。
- 未发现短信发送接口、供应商适配器或发送结果处理。
**影响**
- 为安全而关闭 Mock 后,验证码登录、注册、密码重置和支付密码相关流程全部不可用。
**最小整改**
- 建立真实验证码生成、散列保存、发送、频率限制、失败处理和审计链路。
- Mock 与真实实现应通过依赖注入隔离,禁止在业务函数内用全局开关混用。
### P1-03 工作人员作业预检可以绕过
置信度:高
**证据**
- `backend/api/internal/logic/client/staff/auth.go:67-108` 计算机构归属、资质有效性、在岗状态和 `can_work`
- `backend/api/internal/logic/client/staff/auth.go:20-52` 的登录实现只校验账户状态、角色和密码,注释所称“资质有效”未被执行。
- `backend/api/internal/logic/client/staff/work.go:72-85``backend/api/internal/logic/client/staff/delivery.go:55-230` 的开始、轨迹、到达、异常、恢复、签收等接口没有校验同一套预检条件。
- `apps/service_app/lib/app/router.dart:12-98` 只按是否有 Token 路由;可直接进入 `/work``/tasks/:identity`,没有强制 `can_work`
**影响**
- 离岗、资质过期或缺少机构归属的人员仍可绕过页面直接调用作业 API。
**最小整改**
- 将作业资格校验提取为服务端中间件/领域守卫,挂载在所有会改变工单、配送、轨迹、证据的接口上。
- 客户端预检仅负责展示,不作为可信控制。
### P1-04 三套支付事实互相割裂,真实支付不会进入看板支付统计
置信度:高
**证据**
- 重叠模型:
- `backend/api/internal/models/fin_payment.go:8-16`
- `backend/api/internal/models/wallet_payment.go:5-20`
- `backend/api/internal/models/gasorder_payment.go:5-12`
- 除 Mock seed 外,未发现业务代码创建 `FinPayment``WalletPayment``GasorderPayment`
- 商城实际支付只更新 `EcOrder` 并创建 `WalletRecord`
- `backend/api/internal/logic/client/user/shop.go:180-191`
- 平台支付金额和渠道统计读取 `WalletPayment`
- `backend/api/internal/logic/platform/dashboard/statistics.go:106-132`
- 气体订单的金额调整又以 `GasorderPayment` 是否存在作为“已支付”判断:
- `backend/api/internal/logic/delivery/order.go:369-375`
**影响**
- 真实商城支付成功后,看板支付金额仍可能为零。
- 支付页面、财务支付、钱包支付、气体订单支付显示不同事实。
- 气体订单代码具备“已支付后禁止改价”判断,但当前流程没有形成对应支付记录。
**最小整改**
- 选定唯一支付主事实和订单支付关联模型。
- 所有支付渠道在同一事务/事件链路写入统一支付事实和钱包流水。
- 看板、财务、气站、配送后台统一读取同一事实或受控聚合。
**迁移风险**
- 三张表不能直接删;需先核对线上数据和外部消费者,建立字段映射与去重规则。
### P1-05 用户 App 的商城流程在“请前往订单页支付”后中断
置信度:高
**证据**
- 下单成功明确提示前往订单页支付:
- `apps/user_app/lib/ui/features/shop/shop_page.dart:59-70`
- `ClientRepository` 只有创建和列表方法,没有调用后端已有的支付、取消、确认收货接口:
- `apps/user_app/lib/data/repositories/client_repository.dart:43-117`
- 订单页只是三个只读列表:
- `apps/user_app/lib/ui/features/orders/orders_page.dart:7-47`
- 后端实际提供 `/shop/orders/:identity/pay``cancel``confirm-receipt`
- `backend/api/internal/routers/client.go:47-51`
**影响**
- 用户可以下单并占用库存,但不能在客户端完成支付、取消或确认收货。
**最小整改**
- 增加订单详情及基于服务端状态的支付/取消/确认动作。
- 支付请求必须持久化并复用幂等号,不能每次点击生成新值。
### P1-06 冻结或停用的钱包仍可在客户端执行资金操作
置信度:高
**证据**
- 平台允许把钱包改为启用、停用或冻结:
- `backend/api/internal/logic/platform/wallet/wallet.go:191-200`
- 客户端 `ensureWallet` 对已存在钱包直接返回,不检查状态:
- `backend/api/internal/logic/common/client_wallet.go:41-57`
- 商城支付和用户提现查询钱包时也未限制 `status`
- `backend/api/internal/logic/client/user/shop.go:167-177`
- `backend/api/internal/logic/common/client_wallet.go:366-398`
**影响**
- 风控冻结不能阻止支付和提现。
**最小整改**
- 所有资金写操作在事务内按 `status=enable` 锁定钱包;冻结后禁止新交易,仅允许受控冲正/退款。
### P1-07 上传能力仍是本地 Mock且只按扩展名判定文件类型
置信度:高
**证据**
- `backend/api/internal/logic/upload/upload.go:36-37,90-95` 明确为本地 Mock 存储。
- `backend/api/internal/logic/upload/upload.go:45-49` 只检查文件名扩展名和大小。
- `backend/api/internal/logic/upload/upload.go:79-83` 将客户端声明的 Content-Type 原样返回,没有校验文件签名或实际 MIME。
**影响**
- 伪装成图片/PDF/视频的任意内容可进入存储。
- 单机本地目录无法支持多实例、一致备份、受控下载或恶意文件隔离。
**最小整改**
- 校验魔数和解码结果,重编码图片,对视频/PDF进行独立扫描。
- 使用私有对象存储、短期授权访问、病毒扫描、审计和生命周期策略。
## 4. P2一致性、重试与维护风险
### P2-01 多处“幂等”只处理唯一键冲突,没有验证请求归属和载荷
置信度:高
**证据**
- 签收回执按全局 `request_no` 返回任意既有确认记录,没有校验订单和当前工作人员:
- `backend/api/internal/logic/client/staff/delivery.go:226-231`
- 商城支付成功后使用相同 `request_no` 重试,会先因订单状态不再是待支付而失败,无法返回原结果:
- `backend/api/internal/logic/client/user/shop.go:161-195`
- 创建工单遇到重复 `request_no` 直接返回数据库错误:
- `backend/api/internal/logic/client/user/address_ticket.go:98-109`
- 打卡重复请求返回时,响应中的 `work_status` 根据本次请求重新计算,而不是根据既有记录:
- `backend/api/internal/logic/client/staff/work.go:46-69`
**影响**
- 网络超时后的安全重试可能变成失败、误报成功,甚至返回另一订单的结果。
**最小整改**
- 幂等记录至少绑定:主体、资源、动作、请求载荷摘要和最终响应。
- 相同幂等键但载荷不同必须返回稳定冲突错误;相同载荷返回已保存结果。
### P2-02 服务 App 保存了签收幂等号,但提交时重新生成
置信度:高
**证据**
- 草稿保存 `request_no`
- `apps/service_app/lib/ui/features/work/work_detail_page.dart:114-123`
- 页面没有把该值传给仓库:
- `apps/service_app/lib/ui/features/work/work_detail_page.dart:124-136`
- 仓库在每次提交时重新生成 UUID
- `apps/service_app/lib/data/repositories/service_repository.dart:92-108`
**影响**
- 上传或请求响应丢失后,用户重试会使用新幂等键,可能重复形成签收动作。
**最小整改**
- `submitDeliveryReceipt` 必须接收并复用草稿中的 `requestNo`;删除草稿前保留服务端最终结果。
### P2-03 商城订单的组织字段在真实创建流程中永远为零
置信度:高
**证据**
- `EcOrder` 声明 `gas_station_id``delivery_point_id`
- `backend/api/internal/models/ec_order.go:15-16`
- 真实下单创建 `EcOrder` 时未赋值:
- `backend/api/internal/logic/client/user/shop.go:54-59`
- 仅 Mock seed 为这两个字段赋值。
**影响**
- 按气站/配送点统计、权限范围、履约分派或结算会得到空组织。
**最小整改**
- 若商城订单必须归属机构,应在服务端从服务关系或商品归属中确定并写入快照。
- 若业务确实为平台统一商城,应迁移后移除这两个误导字段及相关索引/展示。
### P2-04 三个 Web 管理端以复制方式维护,且配送端包含整段不可达配置
置信度:高
**证据**
- 三个 `src + scripts` 各约 1.9 万行。
- 平台端与气站端有 80 个逐字节相同文件,约 18,280 行;平台端与配送端有 79 个相同文件,约 18,199 行。
- 三端的 `CrudListPage.vue` 均为 1,175 行且内容相同。
- `frontend/delivery_admin/src/api/resources.ts:287-384` 复制了完整平台资源定义。
- `frontend/delivery_admin/src/api/resources.ts:386-426` 定义 `gasOverrides`,但导出只使用 `deliveryOverrides`
- `frontend/delivery_admin/src/api/resources.ts:428-470`
**影响**
- 一个通用缺陷需要在三处同步修复,极易发生漂移。
- 配送包携带与本端无关的大量资源、字段和动作配置。
**最小整改**
- 将 API 客户端、会话、通用 CRUD、字段渲染、权限和契约检查提取到工作区共享包。
- 各管理端只维护入口、主题和本端资源覆盖。
- 直接删除前先用引用检查确认不可达;当前 `gasOverrides` 已可由导出链证明不可达。
### P2-05 数据库自增 `id` 被作为公共 API 字段并在三端展示
置信度:高
**证据**
- 公共实体把内部主键序列化为 `id`
- `backend/api/internal/models/entity.go:10-16`
- 公共资源响应显式保留记录自身 `id`
- `backend/api/internal/logic/common/resource.go:450-486`
- 三端通用列表固定显示 `ID` 列:
- `frontend/platform_admin/src/views/shared/CrudListPage.vue:23-26`
**影响**
- `id``identity` 成为两个公开身份字段,增加前端误用和外部耦合。
- 连续主键还会泄露记录规模和创建顺序。
**最小整改**
- 数据库 `id` 本身不是冗余字段,不应删除;应从 HTTP 响应和前端移除。
- 迁移前检查外部调用方是否仍使用 `id`,提供兼容窗口。
### P2-06 两个 Flutter API 客户端缺少超时和统一的 401 会话失效处理
置信度:高
**证据**
- `apps/user_app/lib/data/services/api_client.dart:47-76`
- `apps/service_app/lib/data/services/api_client.dart:41-80`
- 请求直接等待 `_client.send`/上传结果,没有连接、读取或总超时。
- 401 只抛异常,没有清理安全存储中的 Token。
- 两个路由都仅以“Token 字符串非空”判断已登录:
- `apps/user_app/lib/app/router.dart:14-21`
- `apps/service_app/lib/app/router.dart:12-19`
**影响**
- 弱网下页面可能长期挂起。
- Token 过期或被撤销后,用户会停留在已登录路由并持续收到请求错误。
**最小整改**
- 建立共享客户端层统一超时、有限重试、取消、401 清会话和重新登录。
- 资金写操作只能依赖幂等键重试,不能盲目重放。
### P2-07 前后端契约检查只比较资源名、模式和路由字符串
置信度:高
**证据**
- `frontend/platform_admin/scripts/check-backend-contract.mjs:12-37` 通过正则读取 `define(name, mode)`,再检查资源路径和路由源码是否包含字符串。
- 未校验 HTTP 方法、动作路径、请求字段、必填项、枚举、响应字段和错误码。
- 气站、配送端脚本采用同类实现。
**影响**
- `contract:check` 通过不能发现支付动作缺失、字段漂移或错误 HTTP 方法。
**最小整改**
- 使用结构化 OpenAPI/Schema 生成客户端与字段类型。
- 至少对每个动作校验方法、路径、请求/响应结构,并增加真实路由契约测试。
### P2-08 钱包创建存在并发竞态,银行卡敏感字段加密错误被忽略
置信度:高
**证据**
- `ensureWallet` 采用先查后创建,唯一键冲突时不回查已有钱包:
- `backend/api/internal/logic/common/client_wallet.go:41-57`
- 首次并发请求时,一个请求可能收到数据库唯一约束错误。
- 绑卡时只处理卡号加密错误,身份证和手机号加密错误被丢弃:
- `backend/api/internal/logic/common/client_wallet.go:287-300`
**影响**
- 新用户并发访问钱包时出现偶发失败。
- 加密异常可能生成空密文但仍写入银行卡记录,造成不可恢复的数据缺失。
**最小整改**
- 使用 `ON CONFLICT DO NOTHING` 后回查,或在事务中锁定所有者。
- 每个敏感字段的加密错误都必须中止事务。
## 5. P3已证实或疑似冗余
### P3-01 钱包第三方账号字段只有 Mock 数据写入
置信度:中
**证据**
- `backend/api/internal/models/wallet_basic.go:11-14` 定义支付宝、微信账号及姓名四个字段。
- 生产业务中未发现写入入口;仅 seed 使用。
- Web 资源仍展示这些字段。
**判断**
- 当前属于“疑似冗余/未完成字段”,不能仅凭静态引用直接删除。
**建议**
- 核对线上非空率、导出消费者和未来渠道设计;确认不用后再做带回滚方案的迁移。
### P3-02 Web 与状态 Store 保留多组本端不可达能力和模板字段
置信度:中
**证据**
- 气站/配送端的通用平台 API 保留角色创建、角色菜单、平台账号等调用,但本端路由和资源未暴露这些页面。
- 三端用户 Store 保留 `job``organization``location``email``introduction``personalWebsite` 等模板字段,实际登录资料只填充账号、名称、头像、角色和菜单。
**建议**
- 先用 TypeScript 引用分析和运行时埋点确认不可达,再删除本端无关方法和状态字段。
- 共享 Store 只保留跨端最小会话模型,本端扩展单独声明。
### P3-03 离线草稿读取重复解析同一加密文件
置信度:高
**证据**
- `apps/service_app/lib/data/offline/encrypted_draft_store.dart:69-77` 先读取并 `jsonDecode` 外层载荷,随后再次读取文件并在 `_decrypt` 内再次解析。
**影响**
- 每次读草稿多一次文件读取和 JSON 解析,且前后两次读取理论上可能看到不同内容。
**最小整改**
- 读取一次字符串,完成结构校验后把同一值传给解密函数。
## 6. 字段与表冗余结论
| 对象 | 结论 | 依据 | 处理方式 |
|---|---|---|---|
| 数据库 `id` | 数据库内部必需,公共 API/UI 冗余 | API 同时提供 `identity`UI仍显示 `id` | 保留数据库列;移除公开序列化和 UI 展示 |
| `FinPayment` / `WalletPayment` / `GasorderPayment` | 已证实事实重叠,但不能直接删表 | 实际支付不写三表,看板却读其中一表 | 先确定唯一支付事实并迁移、对账 |
| `EcOrder.GasStationID/DeliveryPointID` | 当前生产流程未填充 | 仅 seed 写入 | 明确归属规则后补写,或迁移删除 |
| `WalletBasic` 支付宝/微信四字段 | 疑似冗余 | 仅 seed 写入 | 查线上非空率及外部消费者后决定 |
| 配送端 `gasOverrides` | 已证实代码冗余 | 定义后未进入导出链 | 可删除,并用静态检查防回归 |
| Web 会话模板字段 | 疑似冗余 | 未进入实际资料映射 | 引用和运行时确认后删除 |
没有将“只在响应、展示或 seed 中出现”的字段直接判定为可删。数据库删字段、删表前必须补充:
1. 线上非空率和取值分布;
2. API 网关/访问日志中的字段消费者;
3. 报表、导出、脚本和第三方集成引用;
4. 双写/回填/回滚方案。
## 7. 测试、静态检查与构建结果
已实际执行:
```text
backend/api:
go test ./... 通过
go vet ./... 通过
go build ./cmd/main/main.go 通过
backend/worker:
go test ./... 通过(无测试文件)
go build ./cmd/main/main.go 通过
backend/iot:
go test ./... 通过(无测试文件)
go build ./cmd/main/main.go 通过
apps/user_app:
flutter analyze 通过
flutter test 通过2 个测试)
apps/service_app:
flutter analyze 通过
flutter test 通过3 个测试)
frontend/platform_admin:
pnpm type:check 通过
pnpm lint 退出码 0112 warnings / 12 infos
pnpm contract:check 通过47 个资源)
pnpm build 通过
frontend/gas_admin:
pnpm type:check 通过
pnpm lint 退出码 0105 warnings / 12 infos
pnpm contract:check 通过20 个资源)
pnpm build 通过
frontend/delivery_admin:
pnpm type:check 通过
pnpm lint 退出码 0106 warnings / 12 infos
pnpm contract:check 通过18 个资源)
pnpm build 通过
```
说明:
- Web lint 的相当一部分告警来自工具无法识别 Vue 模板对 `script setup` 变量的使用,不能全部视为真实死代码;本报告仅列出能够从导出/调用链证明的冗余。
- 后端虽有若干测试文件,但没有覆盖用户钱包支付、用户提现、配送提现、验证码完整流程、工作人员预检强制执行等高风险路径。
- 两个 Flutter App 的测试主要覆盖模型和登录页面不覆盖商城支付、离线签收重试、401 会话失效或完整作业流程。
## 8. 建议整改顺序
### 立即处置
1. 关闭相关公网凭据并轮换全部已泄露密钥。
2. 关闭生产资金写入口,审计历史余额和提现。
3. 禁止生产环境启用固定验证码和 Mock 支付。
### 第一阶段:资金与身份
1. 重建钱包不变量、冻结额、统一流水和提现状态机。
2. 合并支付事实,完成历史数据对账。
3. 接入真实验证码并增加限流、审计和攻击测试。
4. 强制服务端工作人员作业资格守卫。
### 第二阶段:闭环与可靠性
1. 完成用户 App 支付/取消/收货闭环。
2. 修复所有幂等键的归属和载荷校验。
3. 修复服务 App 草稿幂等号传递、401 会话和网络超时。
4. 上线真实 Worker、IoT、对象存储及健康检查。
### 第三阶段:去重与契约
1. 抽取三个 Web 端共享包,删除不可达配置。
2. 用结构化契约替换正则和源码字符串检查。
3. 在确认线上数据和消费者后迁移疑似冗余字段/表。
## 9. 审计限制
- 未连接公网数据库、Redis、短信、支付、MQTT 或对象存储,未验证仓库中凭据是否仍有效。
- 未启动依赖外部数据库的完整 E2E 流程,结论来自当前代码可证明的控制流、数据写入和客户端调用链。
- 未使用 `docs/*` 推导功能缺口或业务要求。
- 未修改任何业务源代码;只新增本报告。

View File

@@ -8,8 +8,7 @@ import (
"git.apinb.com/bsm-sdk/core/errcode"
"git.apinb.com/bsm-sdk/core/infra"
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
clientcommon "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/client/common"
base "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
common "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
@@ -26,25 +25,25 @@ func Login(ctx *gin.Context) {
Code string `json:"code"`
RequestIdentity string `json:"request_identity"`
}
if ctx.ShouldBindJSON(&request) != nil || !clientcommon.ValidPhone(request.Phone) {
if ctx.ShouldBindJSON(&request) != nil || !common.ValidPhone(request.Phone) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
var account models.StaffAccount
if impl.DBService.Where("phone = ? AND status = ?", strings.TrimSpace(request.Phone), base.StatusEnable).First(&account).Error != nil ||
if impl.DBService.Where("phone = ? AND status = ?", strings.TrimSpace(request.Phone), common.StatusEnable).First(&account).Error != nil ||
!supportedRoles[account.RoleCode] {
infra.Response.Error(ctx, errcode.ErrPassword)
return
}
valid := request.Mode == "password" && bcrypt.CompareHashAndPassword([]byte(account.PasswordHash), []byte(request.Password)) == nil
if request.Mode == "verification_code" {
valid = clientcommon.VerifyCode("service_app", account.Phone, "login", request.RequestIdentity, request.Code)
valid = common.VerifyCode("service_app", account.Phone, "login", request.RequestIdentity, request.Code)
}
if !valid {
infra.Response.Error(ctx, errcode.ErrPassword)
return
}
accessToken, err := clientcommon.IssueToken(account.Identity, "service_app", account.RoleCode, map[string]string{"phone": account.Phone})
accessToken, err := common.IssueToken(account.Identity, "service_app", account.RoleCode, map[string]string{"phone": account.Phone})
if err != nil {
infra.Response.Error(ctx, err)
return
@@ -54,7 +53,7 @@ func Login(ctx *gin.Context) {
// Profile 返回工作人员岗位和归属。
func Profile(ctx *gin.Context) {
account, ok := clientcommon.StaffAccount(ctx)
account, ok := common.StaffAccount(ctx)
if !ok {
return
}
@@ -66,14 +65,14 @@ func Profile(ctx *gin.Context) {
// Preflight 返回当前单角色账号可由服务端确认的作业前置条件。
func Preflight(ctx *gin.Context) {
account, ok := clientcommon.StaffAccount(ctx)
account, ok := common.StaffAccount(ctx)
if !ok {
return
}
var credential models.StaffCredential
credentialFound := impl.DBService.
Where("staff_account_id = ? AND status = ?", account.ID, base.StatusEnable).
Where("staff_account_id = ? AND status = ?", account.ID, common.StatusEnable).
Order("expired_at desc").
First(&credential).Error == nil
credentialValid := credentialFound && (credential.ExpiredAt == nil || credential.ExpiredAt.After(time.Now()))
@@ -117,7 +116,7 @@ func checkStatus(passed bool) string {
// ChangePassword 修改当前工作人员登录密码。
func ChangePassword(ctx *gin.Context) {
account, ok := clientcommon.StaffAccount(ctx)
account, ok := common.StaffAccount(ctx)
if !ok {
return
}
@@ -125,12 +124,12 @@ func ChangePassword(ctx *gin.Context) {
CurrentPassword string `json:"current_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required"`
}
if ctx.ShouldBindJSON(&request) != nil || !base.IsValidAccountPassword(request.NewPassword) ||
if ctx.ShouldBindJSON(&request) != nil || !common.IsValidAccountPassword(request.NewPassword) ||
bcrypt.CompareHashAndPassword([]byte(account.PasswordHash), []byte(request.CurrentPassword)) != nil {
infra.Response.Error(ctx, errcode.ErrPassword)
return
}
hash, err := base.PasswordHash(request.NewPassword)
hash, err := common.PasswordHash(request.NewPassword)
if err != nil {
infra.Response.Error(ctx, err)
return
@@ -150,18 +149,18 @@ func ResetPassword(ctx *gin.Context) {
Code string `json:"code" binding:"required"`
RequestIdentity string `json:"request_identity" binding:"required"`
}
if ctx.ShouldBindJSON(&request) != nil || !base.IsValidAccountPassword(request.NewPassword) ||
!clientcommon.VerifyCode("service_app", request.Phone, "reset_login_password", request.RequestIdentity, request.Code) {
if ctx.ShouldBindJSON(&request) != nil || !common.IsValidAccountPassword(request.NewPassword) ||
!common.VerifyCode("service_app", request.Phone, "reset_login_password", request.RequestIdentity, request.Code) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
hash, err := base.PasswordHash(request.NewPassword)
hash, err := common.PasswordHash(request.NewPassword)
if err != nil {
infra.Response.Error(ctx, err)
return
}
result := impl.DBService.Model(&models.StaffAccount{}).
Where("phone = ? AND status = ?", strings.TrimSpace(request.Phone), base.StatusEnable).
Where("phone = ? AND status = ?", strings.TrimSpace(request.Phone), common.StatusEnable).
Update("password_hash", hash)
if result.Error != nil {
infra.Response.Error(ctx, result.Error)

View File

@@ -10,8 +10,7 @@ import (
"git.apinb.com/bsm-sdk/core/infra"
"git.apinb.com/heqiapp/platforms/backend/api/internal/config"
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
clientcommon "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/client/common"
base "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
common "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -25,7 +24,7 @@ func ListDeliveryOrders(ctx *gin.Context) {
return
}
var orders []models.GasorderBasic
if err := impl.DBService.Where("staff_account_id = ? AND status <> ?", account.ID, base.StatusArchived).
if err := impl.DBService.Where("staff_account_id = ? AND status <> ?", account.ID, common.StatusArchived).
Order("created_at desc").Find(&orders).Error; err != nil {
infra.Response.Error(ctx, err)
return
@@ -48,12 +47,12 @@ func GetDeliveryOrder(ctx *gin.Context) {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, gin.H{"order": deliveryOrderResponse(order), "items": base.ResourceResponse(items)})
infra.Response.Success(ctx, gin.H{"order": deliveryOrderResponse(order), "items": common.ResourceResponse(items)})
}
// StartDeliveryOrder 将已就绪订单置为配送中并创建本次轨迹。
func StartDeliveryOrder(ctx *gin.Context) {
transitionDeliveryOrder(ctx, base.StatusReady, base.StatusDelivering, true)
transitionDeliveryOrder(ctx, common.StatusReady, common.StatusDelivering, true)
}
// AppendDeliveryTracks 批量补传配送中轨迹点request_no 保证重复补传不重复落库。
@@ -70,7 +69,7 @@ func AppendDeliveryTracks(ctx *gin.Context) {
return
}
order, ok := requireDeliveryOrder(ctx, account, true)
if !ok || order.OrderStatus != base.StatusDelivering {
if !ok || order.OrderStatus != common.StatusDelivering {
if ok {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
}
@@ -90,7 +89,7 @@ func AppendDeliveryTracks(ctx *gin.Context) {
return
}
points = append(points, models.GasorderTrackPoint{
Entity: base.NewEntity(base.StatusEnable), GasorderTrackID: track.ID, RequestNo: item.RequestNo,
Entity: common.NewEntity(common.StatusEnable), GasorderTrackID: track.ID, RequestNo: item.RequestNo,
Longitude: item.Longitude, Latitude: item.Latitude, OccurredAt: item.OccurredAt,
ReceivedAt: receivedAt, Source: item.Source, Accuracy: item.Accuracy,
Speed: item.Speed, Direction: item.Direction,
@@ -119,7 +118,7 @@ func ArriveDeliveryOrder(ctx *gin.Context) {
return
}
distance, valid := coordinateDistanceMeters(order.Longitude, order.Latitude, request.Longitude, request.Latitude)
if order.OrderStatus != base.StatusDelivering || !valid || distance > config.Spec.Global.DeliveryArrivalRadiusMeters {
if order.OrderStatus != common.StatusDelivering || !valid || distance > config.Spec.Global.DeliveryArrivalRadiusMeters {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
@@ -132,7 +131,7 @@ func ArriveDeliveryOrder(ctx *gin.Context) {
}
now := time.Now()
point := models.GasorderTrackPoint{
Entity: base.NewEntity(base.StatusEnable), GasorderTrackID: track.ID, RequestNo: request.RequestNo,
Entity: common.NewEntity(common.StatusEnable), GasorderTrackID: track.ID, RequestNo: request.RequestNo,
Longitude: request.Longitude, Latitude: request.Latitude, OccurredAt: request.OccurredAt,
ReceivedAt: now, Source: request.Source, Accuracy: request.Accuracy, Speed: request.Speed, Direction: request.Direction,
}
@@ -143,18 +142,18 @@ func ArriveDeliveryOrder(ctx *gin.Context) {
return err
}
result := tx.Model(&models.GasorderBasic{}).
Where("id = ? AND staff_account_id = ? AND order_status = ?", order.ID, account.ID, base.StatusDelivering).
Update("order_status", base.StatusAwaitingConfirmation)
Where("id = ? AND staff_account_id = ? AND order_status = ?", order.ID, account.ID, common.StatusDelivering).
Update("order_status", common.StatusAwaitingConfirmation)
if result.Error != nil || result.RowsAffected != 1 {
return gorm.ErrInvalidData
}
return tx.Create(deliveryStatusRecord(order, account, base.StatusDelivering, base.StatusAwaitingConfirmation, "配送到达")).Error
return tx.Create(deliveryStatusRecord(order, account, common.StatusDelivering, common.StatusAwaitingConfirmation, "配送到达")).Error
})
if err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
infra.Response.Success(ctx, gin.H{"order_status": base.StatusAwaitingConfirmation, "distance_meters": math.Round(distance)})
infra.Response.Success(ctx, gin.H{"order_status": common.StatusAwaitingConfirmation, "distance_meters": math.Round(distance)})
}
// ExceptionDeliveryOrder 将配送中或待签收订单置为异常。
@@ -171,13 +170,13 @@ func ExceptionDeliveryOrder(ctx *gin.Context) {
return
}
order, ok := requireDeliveryOrder(ctx, account, true)
if !ok || (order.OrderStatus != base.StatusDelivering && order.OrderStatus != base.StatusAwaitingConfirmation) {
if !ok || (order.OrderStatus != common.StatusDelivering && order.OrderStatus != common.StatusAwaitingConfirmation) {
if ok {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
}
return
}
updateDeliveryStatus(ctx, order, account, base.StatusException, request.Reason, gin.H{"previous_order_status": order.OrderStatus})
updateDeliveryStatus(ctx, order, account, common.StatusException, request.Reason, gin.H{"previous_order_status": order.OrderStatus})
}
// RecoverDeliveryOrder 将本人异常订单恢复到异常前状态。
@@ -194,15 +193,15 @@ func RecoverDeliveryOrder(ctx *gin.Context) {
return
}
order, ok := requireDeliveryOrder(ctx, account, true)
if !ok || order.OrderStatus != base.StatusException ||
(order.PreviousOrderStatus != base.StatusDelivering && order.PreviousOrderStatus != base.StatusAwaitingConfirmation) {
if !ok || order.OrderStatus != common.StatusException ||
(order.PreviousOrderStatus != common.StatusDelivering && order.PreviousOrderStatus != common.StatusAwaitingConfirmation) {
if ok {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
}
return
}
target := order.PreviousOrderStatus
updateDeliveryStatus(ctx, order, account, target, request.Reason, gin.H{"previous_order_status": base.StatusDraft})
updateDeliveryStatus(ctx, order, account, target, request.Reason, gin.H{"previous_order_status": common.StatusDraft})
}
// SubmitDeliveryReceipt 保存签收凭证并完成订单,重复 request_no 返回既有结果。
@@ -225,18 +224,18 @@ func SubmitDeliveryReceipt(ctx *gin.Context) {
}
var existing models.GasorderConfirm
if impl.DBService.Where("request_no = ?", request.RequestNo).First(&existing).Error == nil {
infra.Response.Success(ctx, gin.H{"confirmed": true, "identity": existing.Identity, "order_status": base.StatusCompleted})
infra.Response.Success(ctx, gin.H{"confirmed": true, "identity": existing.Identity, "order_status": common.StatusCompleted})
return
}
order, ok := requireDeliveryOrder(ctx, account, true)
if !ok || order.OrderStatus != base.StatusAwaitingConfirmation {
if !ok || order.OrderStatus != common.StatusAwaitingConfirmation {
if ok {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
}
return
}
confirm := models.GasorderConfirm{
Entity: base.NewEntity(base.StatusEnable), GasorderBasicID: order.ID, RequestNo: request.RequestNo,
Entity: common.NewEntity(common.StatusEnable), GasorderBasicID: order.ID, RequestNo: request.RequestNo,
ConfirmType: request.ConfirmType, RecipientName: request.RecipientName, RecipientPhone: request.RecipientPhone,
ProofURI: request.ProofURI, ConfirmedAt: time.Now(), Remark: request.Remark,
}
@@ -245,21 +244,21 @@ func SubmitDeliveryReceipt(ctx *gin.Context) {
return err
}
result := tx.Model(&models.GasorderBasic{}).
Where("id = ? AND staff_account_id = ? AND order_status = ?", order.ID, account.ID, base.StatusAwaitingConfirmation).
Update("order_status", base.StatusCompleted)
Where("id = ? AND staff_account_id = ? AND order_status = ?", order.ID, account.ID, common.StatusAwaitingConfirmation).
Update("order_status", common.StatusCompleted)
if result.Error != nil || result.RowsAffected != 1 {
return gorm.ErrInvalidData
}
if err := tx.Model(&models.GasorderItem{}).Where("gasorder_basic_id = ?", order.ID).Update("active", false).Error; err != nil {
return err
}
return tx.Create(deliveryStatusRecord(order, account, base.StatusAwaitingConfirmation, base.StatusCompleted, "用户签收")).Error
return tx.Create(deliveryStatusRecord(order, account, common.StatusAwaitingConfirmation, common.StatusCompleted, "用户签收")).Error
})
if err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
infra.Response.Success(ctx, gin.H{"confirmed": true, "identity": confirm.Identity, "order_status": base.StatusCompleted})
infra.Response.Success(ctx, gin.H{"confirmed": true, "identity": confirm.Identity, "order_status": common.StatusCompleted})
}
type deliveryTrackPointRequest struct {
@@ -274,7 +273,7 @@ type deliveryTrackPointRequest struct {
}
func requireDeliveryAccount(ctx *gin.Context) (models.StaffAccount, bool) {
account, ok := clientcommon.StaffAccount(ctx)
account, ok := common.StaffAccount(ctx)
if !ok {
return account, false
}
@@ -291,7 +290,7 @@ func requireDeliveryOrder(ctx *gin.Context, account models.StaffAccount, lock bo
if lock {
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
}
if query.Where("identity = ? AND staff_account_id = ? AND status <> ?", ctx.Param("identity"), account.ID, base.StatusArchived).
if query.Where("identity = ? AND staff_account_id = ? AND status <> ?", ctx.Param("identity"), account.ID, common.StatusArchived).
First(&order).Error != nil {
infra.Response.Error(ctx, errcode.ErrRecordNotFound)
return order, false
@@ -332,7 +331,7 @@ func transitionDeliveryOrder(ctx *gin.Context, from, to int, createTrack bool) {
return err
}
if err := tx.Create(&models.GasorderTrack{
Entity: base.NewEntity(base.StatusEnable), GasorderBasicID: order.ID,
Entity: common.NewEntity(common.StatusEnable), GasorderBasicID: order.ID,
StaffAccountID: account.ID, AttemptNo: attempt + 1, StartedAt: time.Now(),
}).Error; err != nil {
return err
@@ -370,7 +369,7 @@ func updateDeliveryStatus(ctx *gin.Context, order models.GasorderBasic, account
func deliveryStatusRecord(order models.GasorderBasic, account models.StaffAccount, from, to int, reason string) models.GasorderStatus {
return models.GasorderStatus{
Entity: base.NewEntity(base.StatusEnable), GasorderBasicID: order.ID,
Entity: common.NewEntity(common.StatusEnable), GasorderBasicID: order.ID,
FromStatus: from, ToStatus: to, OperatorIdentity: account.Identity,
OperatorName: account.Name, OccurredAt: time.Now(), Reason: strings.TrimSpace(reason),
}
@@ -397,13 +396,13 @@ func deliveryOrderResponse(order models.GasorderBasic) gin.H {
func deliveryAllowedActions(status int) []string {
switch status {
case base.StatusReady:
case common.StatusReady:
return []string{"start"}
case base.StatusDelivering:
case common.StatusDelivering:
return []string{"append_tracks", "arrive", "exception"}
case base.StatusAwaitingConfirmation:
case common.StatusAwaitingConfirmation:
return []string{"submit_receipt", "exception"}
case base.StatusException:
case common.StatusException:
return []string{"recover"}
default:
return []string{}

View File

@@ -7,8 +7,7 @@ import (
"git.apinb.com/bsm-sdk/core/errcode"
"git.apinb.com/bsm-sdk/core/infra"
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
clientcommon "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/client/common"
base "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
common "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -16,7 +15,7 @@ import (
// Attendance 上下班打卡;存在进行中任务时禁止下班。
func Attendance(ctx *gin.Context) {
account, ok := clientcommon.StaffAccount(ctx)
account, ok := common.StaffAccount(ctx)
if !ok {
return
}
@@ -44,7 +43,7 @@ func Attendance(ctx *gin.Context) {
}
}
record := models.StaffAttendance{
Entity: base.NewEntity(base.StatusEnable), StaffAccountID: account.ID, RoleCode: account.RoleCode,
Entity: common.NewEntity(common.StatusEnable), StaffAccountID: account.ID, RoleCode: account.RoleCode,
Action: request.Action, OccurredAt: request.OccurredAt, Longitude: request.Longitude,
Latitude: request.Latitude, DeviceIdentity: request.DeviceIdentity, RequestNo: request.RequestNo,
}
@@ -71,7 +70,7 @@ func Attendance(ctx *gin.Context) {
// ListTickets 仅返回分派给当前人员且与岗位匹配的工单。
func ListTickets(ctx *gin.Context) {
account, ok := clientcommon.StaffAccount(ctx)
account, ok := common.StaffAccount(ctx)
if !ok {
return
}
@@ -84,27 +83,27 @@ func ListTickets(ctx *gin.Context) {
return
}
var list []models.CsTicket
if err := impl.DBService.Where("staff_account_id = ? AND category IN ? AND status <> ?", account.ID, categories, base.StatusArchived).
if err := impl.DBService.Where("staff_account_id = ? AND category IN ? AND status <> ?", account.ID, categories, common.StatusArchived).
Order("created_at desc").Find(&list).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, base.ResourceResponse(list))
infra.Response.Success(ctx, common.ResourceResponse(list))
}
// GetTicket 按公开 identity 返回当前工作人员被分派的单一工单。
func GetTicket(ctx *gin.Context) {
account, ok := clientcommon.StaffAccount(ctx)
account, ok := common.StaffAccount(ctx)
if !ok {
return
}
var ticket models.CsTicket
if impl.DBService.Where("identity = ? AND staff_account_id = ? AND status <> ?", ctx.Param("identity"), account.ID, base.StatusArchived).
if impl.DBService.Where("identity = ? AND staff_account_id = ? AND status <> ?", ctx.Param("identity"), account.ID, common.StatusArchived).
First(&ticket).Error != nil {
infra.Response.Error(ctx, errcode.ErrRecordNotFound)
return
}
infra.Response.Success(ctx, base.ResourceResponse(ticket))
infra.Response.Success(ctx, common.ResourceResponse(ticket))
}
// StartTicket 将本人已分派工单置为处理中。
@@ -124,7 +123,7 @@ func RecoverTicket(ctx *gin.Context) {
// SubmitTicketResult 追加现场证据并提交用户确认;不合格或高风险结果必须进入异常。
func SubmitTicketResult(ctx *gin.Context) {
account, ok := clientcommon.StaffAccount(ctx)
account, ok := common.StaffAccount(ctx)
if !ok {
return
}
@@ -185,7 +184,7 @@ func SubmitTicketResult(ctx *gin.Context) {
now := time.Now()
for _, item := range request.Evidences {
record := models.CsTicketEvidence{
Entity: base.NewEntity(base.StatusEnable), CsTicketID: ticket.ID, EvidenceType: item.EvidenceType,
Entity: common.NewEntity(common.StatusEnable), CsTicketID: ticket.ID, EvidenceType: item.EvidenceType,
MediaType: item.MediaType, FileURI: item.FileURI, CapturedAt: item.CapturedAt, ReceivedAt: now,
Longitude: item.Longitude, Latitude: item.Latitude, Source: "app",
IntegrityStatus: "unverified", OperatorIdentity: account.Identity, RequestNo: item.RequestNo,
@@ -212,7 +211,7 @@ func SubmitTicketResult(ctx *gin.Context) {
}
func updateTicketStatus(ctx *gin.Context, from, to int, extra map[string]any) {
account, ok := clientcommon.StaffAccount(ctx)
account, ok := common.StaffAccount(ctx)
if !ok {
return
}

View File

@@ -7,8 +7,7 @@ import (
"git.apinb.com/bsm-sdk/core/errcode"
"git.apinb.com/bsm-sdk/core/infra"
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
clientcommon "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/client/common"
base "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
common "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -16,21 +15,21 @@ import (
// ListAddresses 返回当前用户未归档地址。
func ListAddresses(ctx *gin.Context) {
account, ok := clientcommon.UserAccount(ctx)
account, ok := common.UserAccount(ctx)
if !ok {
return
}
var list []models.UserAddress
if err := impl.DBService.Where("user_account_id = ? AND status <> ?", account.ID, base.StatusArchived).Order("is_default desc, created_at desc").Find(&list).Error; err != nil {
if err := impl.DBService.Where("user_account_id = ? AND status <> ?", account.ID, common.StatusArchived).Order("is_default desc, created_at desc").Find(&list).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, base.ResourceResponse(list))
infra.Response.Success(ctx, common.ResourceResponse(list))
}
// SaveAddress 新增地址,设为默认时原默认地址会在同一事务取消默认。
func SaveAddress(ctx *gin.Context) {
account, ok := clientcommon.UserAccount(ctx)
account, ok := common.UserAccount(ctx)
if !ok {
return
}
@@ -45,7 +44,7 @@ func SaveAddress(ctx *gin.Context) {
return
}
address := models.UserAddress{
Entity: base.NewEntity(base.StatusEnable), UserAccountID: account.ID, Address: request.Address,
Entity: common.NewEntity(common.StatusEnable), UserAccountID: account.ID, Address: request.Address,
Longitude: request.Longitude, Latitude: request.Latitude, IsDefault: request.IsDefault,
}
err := impl.DBService.Transaction(func(tx *gorm.DB) error {
@@ -69,7 +68,7 @@ var userTicketCategories = map[string]bool{
// CreateTicket 创建工单,服务人员只能由后台分派。
func CreateTicket(ctx *gin.Context) {
account, ok := clientcommon.UserAccount(ctx)
account, ok := common.UserAccount(ctx)
if !ok {
return
}
@@ -85,18 +84,18 @@ func CreateTicket(ctx *gin.Context) {
return
}
var relation models.UserServiceRelation
_ = impl.DBService.Where("user_account_id = ? AND status = ?", account.ID, base.StatusEnable).First(&relation).Error
_ = impl.DBService.Where("user_account_id = ? AND status = ?", account.ID, common.StatusEnable).First(&relation).Error
addressText := ""
if request.AddressIdentity != "" {
var address models.UserAddress
if impl.DBService.Where("identity = ? AND user_account_id = ? AND status <> ?", request.AddressIdentity, account.ID, base.StatusArchived).First(&address).Error != nil {
if impl.DBService.Where("identity = ? AND user_account_id = ? AND status <> ?", request.AddressIdentity, account.ID, common.StatusArchived).First(&address).Error != nil {
infra.Response.Error(ctx, errcode.ErrRecordNotFound)
return
}
addressText = address.Address
}
ticket := models.CsTicket{
Entity: base.NewEntity(base.StatusEnable), TicketStatus: 32, TicketNo: clientcommon.RecordNo("TK"),
Entity: common.NewEntity(common.StatusEnable), TicketStatus: 32, TicketNo: common.RecordNo("TK"),
RequestNo: request.RequestNo,
UserAccountID: account.ID, GasBasicID: relation.GasBasicID, DeliveryBasicID: relation.DeliveryBasicID,
Category: request.Category, Priority: "normal", Description: strings.TrimSpace(request.Description),
@@ -111,21 +110,21 @@ func CreateTicket(ctx *gin.Context) {
// ListTickets 仅返回当前用户自己的工单。
func ListTickets(ctx *gin.Context) {
account, ok := clientcommon.UserAccount(ctx)
account, ok := common.UserAccount(ctx)
if !ok {
return
}
var list []models.CsTicket
if err := impl.DBService.Where("user_account_id = ? AND status <> ?", account.ID, base.StatusArchived).Order("created_at desc").Find(&list).Error; err != nil {
if err := impl.DBService.Where("user_account_id = ? AND status <> ?", account.ID, common.StatusArchived).Order("created_at desc").Find(&list).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, base.ResourceResponse(list))
infra.Response.Success(ctx, common.ResourceResponse(list))
}
// ConfirmTicket 用户确认工作人员提交的处理结果。
func ConfirmTicket(ctx *gin.Context) {
account, ok := clientcommon.UserAccount(ctx)
account, ok := common.UserAccount(ctx)
if !ok {
return
}
@@ -146,7 +145,7 @@ func ConfirmTicket(ctx *gin.Context) {
// CancelTicket 取消尚未完成的本人工单。
func CancelTicket(ctx *gin.Context) {
account, ok := clientcommon.UserAccount(ctx)
account, ok := common.UserAccount(ctx)
if !ok {
return
}

View File

@@ -7,8 +7,7 @@ import (
"git.apinb.com/bsm-sdk/core/errcode"
"git.apinb.com/bsm-sdk/core/infra"
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
clientcommon "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/client/common"
base "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
common "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
@@ -26,24 +25,24 @@ type loginRequest struct {
// Login 支持密码和一次性验证码两种登录模式。
func Login(ctx *gin.Context) {
var request loginRequest
if ctx.ShouldBindJSON(&request) != nil || !clientcommon.ValidPhone(request.Phone) {
if ctx.ShouldBindJSON(&request) != nil || !common.ValidPhone(request.Phone) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
var account models.UserAccount
if impl.DBService.Where("phone = ? AND status = ?", strings.TrimSpace(request.Phone), base.StatusEnable).First(&account).Error != nil {
if impl.DBService.Where("phone = ? AND status = ?", strings.TrimSpace(request.Phone), common.StatusEnable).First(&account).Error != nil {
infra.Response.Error(ctx, errcode.ErrPassword)
return
}
valid := request.Mode == "password" && bcrypt.CompareHashAndPassword([]byte(account.PasswordHash), []byte(request.Password)) == nil
if request.Mode == "verification_code" {
valid = clientcommon.VerifyCode("user_app", account.Phone, "login", request.RequestIdentity, request.Code)
valid = common.VerifyCode("user_app", account.Phone, "login", request.RequestIdentity, request.Code)
}
if !valid {
infra.Response.Error(ctx, errcode.ErrPassword)
return
}
accessToken, err := clientcommon.IssueToken(account.Identity, "user_app", "user", map[string]string{"phone": account.Phone})
accessToken, err := common.IssueToken(account.Identity, "user_app", "user", map[string]string{"phone": account.Phone})
if err != nil {
infra.Response.Error(ctx, err)
return
@@ -65,19 +64,19 @@ func Register(ctx *gin.Context) {
Code string `json:"code" binding:"required"`
RequestIdentity string `json:"request_identity" binding:"required"`
}
if ctx.ShouldBindJSON(&request) != nil || !clientcommon.ValidPhone(request.Phone) ||
!base.IsValidAccountPassword(request.Password) ||
!clientcommon.VerifyCode("user_app", request.Phone, "register", request.RequestIdentity, request.Code) {
if ctx.ShouldBindJSON(&request) != nil || !common.ValidPhone(request.Phone) ||
!common.IsValidAccountPassword(request.Password) ||
!common.VerifyCode("user_app", request.Phone, "register", request.RequestIdentity, request.Code) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
hash, err := base.PasswordHash(request.Password)
hash, err := common.PasswordHash(request.Password)
if err != nil {
infra.Response.Error(ctx, err)
return
}
account := models.UserAccount{
Entity: base.NewEntity(base.StatusEnable), Username: strings.TrimSpace(request.Phone),
Entity: common.NewEntity(common.StatusEnable), Username: strings.TrimSpace(request.Phone),
Phone: strings.TrimSpace(request.Phone), PasswordHash: hash, Name: strings.TrimSpace(request.Name),
}
err = impl.DBService.Transaction(func(tx *gorm.DB) error {
@@ -85,7 +84,7 @@ func Register(ctx *gin.Context) {
return err
}
address := models.UserAddress{
Entity: base.NewEntity(base.StatusEnable), UserAccountID: account.ID, Address: request.Address,
Entity: common.NewEntity(common.StatusEnable), UserAccountID: account.ID, Address: request.Address,
Longitude: request.Longitude, Latitude: request.Latitude, IsDefault: true,
}
if err := tx.Create(&address).Error; err != nil {
@@ -98,19 +97,19 @@ func Register(ctx *gin.Context) {
return nil
}
var gas models.GasBasic
if err := tx.Where("identity = ? AND status = ?", request.GasIdentity, base.StatusEnable).First(&gas).Error; err != nil {
if err := tx.Where("identity = ? AND status = ?", request.GasIdentity, common.StatusEnable).First(&gas).Error; err != nil {
return err
}
var deliveryID uint64
if request.DeliveryIdentity != "" {
var delivery models.DeliveryBasic
if err := tx.Where("identity = ? AND gas_basic_id = ? AND status = ?", request.DeliveryIdentity, gas.ID, base.StatusEnable).First(&delivery).Error; err != nil {
if err := tx.Where("identity = ? AND gas_basic_id = ? AND status = ?", request.DeliveryIdentity, gas.ID, common.StatusEnable).First(&delivery).Error; err != nil {
return err
}
deliveryID = delivery.ID
}
return tx.Create(&models.UserServiceRelation{
Entity: base.NewEntity(base.StatusEnable), UserAccountID: account.ID,
Entity: common.NewEntity(common.StatusEnable), UserAccountID: account.ID,
GasBasicID: gas.ID, DeliveryBasicID: deliveryID,
}).Error
})
@@ -123,7 +122,7 @@ func Register(ctx *gin.Context) {
// Profile 返回当前用户的脱敏资料。
func Profile(ctx *gin.Context) {
account, ok := clientcommon.UserAccount(ctx)
account, ok := common.UserAccount(ctx)
if !ok {
return
}
@@ -132,7 +131,7 @@ func Profile(ctx *gin.Context) {
// UpdateProfile 只允许修改非认证资料。
func UpdateProfile(ctx *gin.Context) {
account, ok := clientcommon.UserAccount(ctx)
account, ok := common.UserAccount(ctx)
if !ok {
return
}
@@ -153,7 +152,7 @@ func UpdateProfile(ctx *gin.Context) {
// ChangePassword 使用当前密码修改登录密码。
func ChangePassword(ctx *gin.Context) {
account, ok := clientcommon.UserAccount(ctx)
account, ok := common.UserAccount(ctx)
if !ok {
return
}
@@ -161,12 +160,12 @@ func ChangePassword(ctx *gin.Context) {
CurrentPassword string `json:"current_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required"`
}
if ctx.ShouldBindJSON(&request) != nil || !base.IsValidAccountPassword(request.NewPassword) ||
if ctx.ShouldBindJSON(&request) != nil || !common.IsValidAccountPassword(request.NewPassword) ||
bcrypt.CompareHashAndPassword([]byte(account.PasswordHash), []byte(request.CurrentPassword)) != nil {
infra.Response.Error(ctx, errcode.ErrPassword)
return
}
hash, err := base.PasswordHash(request.NewPassword)
hash, err := common.PasswordHash(request.NewPassword)
if err != nil {
infra.Response.Error(ctx, err)
return
@@ -186,18 +185,18 @@ func ResetPassword(ctx *gin.Context) {
Code string `json:"code" binding:"required"`
RequestIdentity string `json:"request_identity" binding:"required"`
}
if ctx.ShouldBindJSON(&request) != nil || !base.IsValidAccountPassword(request.NewPassword) ||
!clientcommon.VerifyCode("user_app", request.Phone, "reset_login_password", request.RequestIdentity, request.Code) {
if ctx.ShouldBindJSON(&request) != nil || !common.IsValidAccountPassword(request.NewPassword) ||
!common.VerifyCode("user_app", request.Phone, "reset_login_password", request.RequestIdentity, request.Code) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
hash, err := base.PasswordHash(request.NewPassword)
hash, err := common.PasswordHash(request.NewPassword)
if err != nil {
infra.Response.Error(ctx, err)
return
}
result := impl.DBService.Model(&models.UserAccount{}).
Where("phone = ? AND status = ?", strings.TrimSpace(request.Phone), base.StatusEnable).
Where("phone = ? AND status = ?", strings.TrimSpace(request.Phone), common.StatusEnable).
Update("password_hash", hash)
if result.Error != nil {
infra.Response.Error(ctx, result.Error)

View File

@@ -7,8 +7,7 @@ import (
"git.apinb.com/bsm-sdk/core/errcode"
"git.apinb.com/bsm-sdk/core/infra"
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
clientcommon "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/client/common"
base "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
common "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
"github.com/gin-gonic/gin"
)
@@ -16,31 +15,31 @@ import (
// PublicGasStations 提供注册页所需的最小启用气站数据。
func PublicGasStations(ctx *gin.Context) {
var list []models.GasBasic
if err := impl.DBService.Select("identity", "name", "address").Where("status = ?", base.StatusEnable).Order("name").Find(&list).Error; err != nil {
if err := impl.DBService.Select("identity", "name", "address").Where("status = ?", common.StatusEnable).Order("name").Find(&list).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, base.ResourceResponse(list))
infra.Response.Success(ctx, common.ResourceResponse(list))
}
// PublicDeliveryPoints 提供指定气站下的启用配送点。
func PublicDeliveryPoints(ctx *gin.Context) {
var gas models.GasBasic
if impl.DBService.Where("identity = ? AND status = ?", ctx.Query("gas_identity"), base.StatusEnable).First(&gas).Error != nil {
if impl.DBService.Where("identity = ? AND status = ?", ctx.Query("gas_identity"), common.StatusEnable).First(&gas).Error != nil {
infra.Response.Error(ctx, errcode.ErrRecordNotFound)
return
}
var list []models.DeliveryBasic
if err := impl.DBService.Select("identity", "name", "address").Where("gas_basic_id = ? AND status = ?", gas.ID, base.StatusEnable).Order("name").Find(&list).Error; err != nil {
if err := impl.DBService.Select("identity", "name", "address").Where("gas_basic_id = ? AND status = ?", gas.ID, common.StatusEnable).Order("name").Find(&list).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, base.ResourceResponse(list))
infra.Response.Success(ctx, common.ResourceResponse(list))
}
// PublicContents 返回已发布内容,支持内容类型筛选。
func PublicContents(ctx *gin.Context) {
query := impl.DBService.Where("status = ? AND publish_status = ?", base.StatusEnable, "published")
query := impl.DBService.Where("status = ? AND publish_status = ?", common.StatusEnable, "published")
if contentType := strings.TrimSpace(ctx.Query("content_type")); contentType != "" {
query = query.Where("content_type = ?", contentType)
}
@@ -49,12 +48,12 @@ func PublicContents(ctx *gin.Context) {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, base.ResourceResponse(list))
infra.Response.Success(ctx, common.ResourceResponse(list))
}
// ConfirmContentRead 记录用户对特定内容版本的确认,幂等号全局唯一。
func ConfirmContentRead(ctx *gin.Context) {
account, ok := clientcommon.UserAccount(ctx)
account, ok := common.UserAccount(ctx)
if !ok {
return
}
@@ -74,7 +73,7 @@ func ConfirmContentRead(ctx *gin.Context) {
return
}
record := models.CmsContentRead{
Entity: base.NewEntity(base.StatusEnable), UserAccountID: account.ID, CmsContentID: content.ID,
Entity: common.NewEntity(common.StatusEnable), UserAccountID: account.ID, CmsContentID: content.ID,
VersionNo: content.VersionNo, ShownAt: time.Now(), ConfirmedAt: timePointer(time.Now()),
ClientVersion: request.ClientVersion, DeviceIdentity: request.DeviceIdentity, RequestNo: request.RequestNo,
}

View File

@@ -4,20 +4,19 @@ import (
"git.apinb.com/bsm-sdk/core/errcode"
"git.apinb.com/bsm-sdk/core/infra"
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
clientcommon "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/client/common"
base "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
common "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
"github.com/gin-gonic/gin"
)
// ServiceRelation 返回当前唯一有效服务归属的公开 identity。
func ServiceRelation(ctx *gin.Context) {
account, ok := clientcommon.UserAccount(ctx)
account, ok := common.UserAccount(ctx)
if !ok {
return
}
var relation models.UserServiceRelation
if impl.DBService.Where("user_account_id = ? AND status = ?", account.ID, base.StatusEnable).First(&relation).Error != nil {
if impl.DBService.Where("user_account_id = ? AND status = ?", account.ID, common.StatusEnable).First(&relation).Error != nil {
infra.Response.Success(ctx, nil)
return
}
@@ -39,41 +38,41 @@ func ServiceRelation(ctx *gin.Context) {
// ListGasContracts 返回用户自己的供气合同。
func ListGasContracts(ctx *gin.Context) {
account, ok := clientcommon.UserAccount(ctx)
account, ok := common.UserAccount(ctx)
if !ok {
return
}
var list []models.GasorderContract
if err := impl.DBService.Where("user_account_id = ? AND status <> ?", account.ID, base.StatusArchived).Order("created_at desc").Find(&list).Error; err != nil {
if err := impl.DBService.Where("user_account_id = ? AND status <> ?", account.ID, common.StatusArchived).Order("created_at desc").Find(&list).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, base.ResourceResponse(list))
infra.Response.Success(ctx, common.ResourceResponse(list))
}
// ListGasOrders 返回用户自己的供气订单。
func ListGasOrders(ctx *gin.Context) {
account, ok := clientcommon.UserAccount(ctx)
account, ok := common.UserAccount(ctx)
if !ok {
return
}
var list []models.GasorderBasic
if err := impl.DBService.Where("user_account_id = ? AND status <> ?", account.ID, base.StatusArchived).Order("created_at desc").Find(&list).Error; err != nil {
if err := impl.DBService.Where("user_account_id = ? AND status <> ?", account.ID, common.StatusArchived).Order("created_at desc").Find(&list).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, base.ResourceResponse(list))
infra.Response.Success(ctx, common.ResourceResponse(list))
}
// CancelGasOrder 仅允许取消已创建或已分派的本人订单。
func CancelGasOrder(ctx *gin.Context) {
account, ok := clientcommon.UserAccount(ctx)
account, ok := common.UserAccount(ctx)
if !ok {
return
}
result := impl.DBService.Model(&models.GasorderBasic{}).
Where("identity = ? AND user_account_id = ? AND order_status IN ?", ctx.Param("identity"), account.ID, []int{base.StatusCreated, base.StatusAssigned}).
Updates(map[string]any{"order_status": base.StatusCancelled, "operator_identity": account.Identity})
Where("identity = ? AND user_account_id = ? AND order_status IN ?", ctx.Param("identity"), account.ID, []int{common.StatusCreated, common.StatusAssigned}).
Updates(map[string]any{"order_status": common.StatusCancelled, "operator_identity": account.Identity})
if result.Error != nil {
infra.Response.Error(ctx, result.Error)
return

View File

@@ -7,8 +7,7 @@ import (
"git.apinb.com/bsm-sdk/core/errcode"
"git.apinb.com/bsm-sdk/core/infra"
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
clientcommon "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/client/common"
base "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
common "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -18,16 +17,16 @@ import (
// PublicProducts 返回上架且有库存的商品。
func PublicProducts(ctx *gin.Context) {
var list []models.EcProduct
if err := impl.DBService.Where("status = ? AND stock_quantity > 0", base.StatusEnable).Order("created_at desc").Find(&list).Error; err != nil {
if err := impl.DBService.Where("status = ? AND stock_quantity > 0", common.StatusEnable).Order("created_at desc").Find(&list).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, base.ResourceResponse(list))
infra.Response.Success(ctx, common.ResourceResponse(list))
}
// CreateShopOrder 按服务端价格创建订单并原子扣减库存。
func CreateShopOrder(ctx *gin.Context) {
account, ok := clientcommon.UserAccount(ctx)
account, ok := common.UserAccount(ctx)
if !ok {
return
}
@@ -42,17 +41,17 @@ func CreateShopOrder(ctx *gin.Context) {
Quantity int `json:"quantity" binding:"required,gt=0"`
} `json:"items" binding:"required,min=1"`
}
if ctx.ShouldBindJSON(&request) != nil || !clientcommon.ValidPhone(request.ContactPhone) {
if ctx.ShouldBindJSON(&request) != nil || !common.ValidPhone(request.ContactPhone) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
var address models.UserAddress
if impl.DBService.Where("identity = ? AND user_account_id = ? AND status <> ?", request.AddressIdentity, account.ID, base.StatusArchived).First(&address).Error != nil {
if impl.DBService.Where("identity = ? AND user_account_id = ? AND status <> ?", request.AddressIdentity, account.ID, common.StatusArchived).First(&address).Error != nil {
infra.Response.Error(ctx, errcode.ErrRecordNotFound)
return
}
order := models.EcOrder{
Entity: base.NewEntity(base.StatusEnable), OrderStatus: 16, OrderNo: clientcommon.RecordNo("EC"),
Entity: common.NewEntity(common.StatusEnable), OrderStatus: 16, OrderNo: common.RecordNo("EC"),
RequestNo: request.RequestNo, UserAccountID: account.ID, UserAddressID: address.ID,
Address: address.Address, Longitude: address.Longitude, Latitude: address.Latitude,
ContactName: request.ContactName, ContactPhone: request.ContactPhone, Remark: request.Remark, LogisticsStatus: 10,
@@ -63,7 +62,7 @@ func CreateShopOrder(ctx *gin.Context) {
for _, requested := range request.Items {
var product models.EcProduct
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("identity = ? AND status = ? AND stock_quantity >= ?", requested.ProductIdentity, base.StatusEnable, requested.Quantity).
Where("identity = ? AND status = ? AND stock_quantity >= ?", requested.ProductIdentity, common.StatusEnable, requested.Quantity).
First(&product).Error; err != nil {
return err
}
@@ -72,7 +71,7 @@ func CreateShopOrder(ctx *gin.Context) {
}
snapshot, _ := json.Marshal(gin.H{"identity": product.Identity, "name": product.Name, "product_code": product.ProductCode})
items = append(items, models.EcOrderItem{
Entity: base.NewEntity(base.StatusEnable), EcProductID: product.ID, ProductSnapshot: string(snapshot),
Entity: common.NewEntity(common.StatusEnable), EcProductID: product.ID, ProductSnapshot: string(snapshot),
Quantity: requested.Quantity, SaleAmount: product.PriceAmount,
})
amount += product.PriceAmount * int64(requested.Quantity)
@@ -97,26 +96,26 @@ func CreateShopOrder(ctx *gin.Context) {
}
order = existing
}
infra.Response.Success(ctx, base.ResourceResponse(order))
infra.Response.Success(ctx, common.ResourceResponse(order))
}
// ListShopOrders 返回本人的商城订单。
func ListShopOrders(ctx *gin.Context) {
account, ok := clientcommon.UserAccount(ctx)
account, ok := common.UserAccount(ctx)
if !ok {
return
}
var list []models.EcOrder
if err := impl.DBService.Where("user_account_id = ? AND status <> ?", account.ID, base.StatusArchived).Order("created_at desc").Find(&list).Error; err != nil {
if err := impl.DBService.Where("user_account_id = ? AND status <> ?", account.ID, common.StatusArchived).Order("created_at desc").Find(&list).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, base.ResourceResponse(list))
infra.Response.Success(ctx, common.ResourceResponse(list))
}
// CancelShopOrder 取消未支付订单并恢复库存。
func CancelShopOrder(ctx *gin.Context) {
account, ok := clientcommon.UserAccount(ctx)
account, ok := common.UserAccount(ctx)
if !ok {
return
}
@@ -146,7 +145,7 @@ func CancelShopOrder(ctx *gin.Context) {
// PayShopOrder 使用用户钱包余额支付,金额和订单状态由服务端锁定校验。
func PayShopOrder(ctx *gin.Context) {
account, ok := clientcommon.UserAccount(ctx)
account, ok := common.UserAccount(ctx)
if !ok {
return
}
@@ -169,12 +168,13 @@ func PayShopOrder(ctx *gin.Context) {
Where("owner_type = ? AND owner_identity = ?", "user", account.Identity).First(&wallet).Error; err != nil {
return err
}
if !clientcommon.VerifyPaymentPassword(account.Identity, wallet, request.PaymentPassword) ||
wallet.Balance < order.PayableAmount {
if !common.VerifyPaymentPassword(account.Identity, wallet, request.PaymentPassword) {
return gorm.ErrInvalidData
}
wallet.Balance -= order.PayableAmount
if err := tx.Model(&wallet).Update("balance", wallet.Balance).Error; err != nil {
if err := common.SpendWalletBalance(&wallet, order.PayableAmount); err != nil {
return err
}
if err := common.SaveWalletBalances(tx, wallet); err != nil {
return err
}
now := time.Now()
@@ -183,7 +183,7 @@ func PayShopOrder(ctx *gin.Context) {
}
date := now.In(time.Local)
return tx.Create(&models.WalletRecord{
Entity: base.NewEntity(base.StatusEnable), WalletBasicID: wallet.ID, RecordNo: clientcommon.RecordNo("WR"),
Entity: common.NewEntity(common.StatusEnable), WalletBasicID: wallet.ID, RecordNo: common.RecordNo("WR"),
RequestNo: request.RequestNo, Direction: "expense", TradeType: "ec_order",
Amount: order.PayableAmount, BalanceAfter: wallet.Balance, WithdrawalBalanceAfter: wallet.WithdrawalBalance,
OutTradeNo: order.OrderNo, PayChannel: "wallet", OperatorIdentity: account.Identity,
@@ -199,7 +199,7 @@ func PayShopOrder(ctx *gin.Context) {
// ConfirmShopReceipt 只推进独立物流状态,不伪造支付状态。
func ConfirmShopReceipt(ctx *gin.Context) {
account, ok := clientcommon.UserAccount(ctx)
account, ok := common.UserAccount(ctx)
if !ok {
return
}

View File

@@ -1,4 +1,4 @@
// Package common 提供两个客户端共用的鉴权、验证码和账户范围能力。
// Package common 提供各业务端共用的鉴权、资源、钱包和账户范围能力。
package common
import (

View File

@@ -6,7 +6,7 @@ import (
"git.apinb.com/heqiapp/platforms/backend/api/internal/config"
)
func TestValidPhone(t *testing.T) {
func TestClientValidPhone(t *testing.T) {
tests := map[string]bool{
"13800138000": true,
"12800138000": false,

View File

@@ -16,7 +16,6 @@ import (
"git.apinb.com/bsm-sdk/core/infra"
"git.apinb.com/heqiapp/platforms/backend/api/internal/config"
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
base "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
@@ -49,7 +48,7 @@ func ensureWallet(tx *gorm.DB, owner walletOwner) (models.WalletBasic, error) {
return wallet, err
}
wallet = models.WalletBasic{
Entity: base.NewEntity(base.StatusEnable), OwnerType: owner.Type, OwnerID: owner.ID, OwnerIdentity: owner.Identity,
Entity: NewEntity(StatusEnable), OwnerType: owner.Type, OwnerID: owner.ID, OwnerIdentity: owner.Identity,
}
if err := tx.Create(&wallet).Error; err != nil {
return wallet, err
@@ -139,7 +138,7 @@ func CreateRecharge(client string) gin.HandlerFunc {
return
}
order := models.WalletRechargeOrder{
Entity: base.NewEntity(base.StatusEnable), RechargeStatus: 10, WalletBasicID: wallet.ID,
Entity: NewEntity(StatusEnable), RechargeStatus: 10, WalletBasicID: wallet.ID,
RechargeNo: RecordNo("RC"), RequestNo: request.RequestNo, Amount: request.Amount,
Channel: request.Channel, OwnerType: owner.Type, OwnerIdentity: owner.Identity,
}
@@ -151,7 +150,7 @@ func CreateRecharge(client string) gin.HandlerFunc {
}
order = existing
}
infra.Response.Success(ctx, base.ResourceResponse(order))
infra.Response.Success(ctx, ResourceResponse(order))
}
}
@@ -192,7 +191,7 @@ func ConfirmMockRecharge(client string) gin.HandlerFunc {
}
date := now.In(time.Local)
return tx.Create(&models.WalletRecord{
Entity: base.NewEntity(base.StatusEnable), WalletBasicID: wallet.ID, RecordNo: RecordNo("WR"),
Entity: NewEntity(StatusEnable), WalletBasicID: wallet.ID, RecordNo: RecordNo("WR"),
RequestNo: "recharge:" + response.Identity, Direction: "income", TradeType: "recharge",
Amount: response.Amount, BalanceAfter: wallet.Balance, WithdrawalBalanceAfter: wallet.WithdrawalBalance,
InTradeNo: response.RechargeNo, PayChannel: "mock", OperatorIdentity: owner.Identity,
@@ -224,7 +223,7 @@ func ListWalletRecords(client string) gin.HandlerFunc {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, base.ResourceResponse(list))
infra.Response.Success(ctx, ResourceResponse(list))
}
}
@@ -241,7 +240,7 @@ func ListBanks(client string) gin.HandlerFunc {
return
}
var banks []models.WalletBank
if err := impl.DBService.Where("wallet_basic_id = ? AND status <> ?", wallet.ID, base.StatusArchived).Find(&banks).Error; err != nil {
if err := impl.DBService.Where("wallet_basic_id = ? AND status <> ?", wallet.ID, StatusArchived).Find(&banks).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
@@ -293,7 +292,7 @@ func BindBank(client string) gin.HandlerFunc {
idCipher, _, _ := protectField(request.IDCard)
phoneCipher, _, _ := protectField(request.Phone)
bank := models.WalletBank{
Entity: base.NewEntity(base.StatusEnable), WalletBasicID: wallet.ID, CardNoCiphertext: cardCipher,
Entity: NewEntity(StatusEnable), WalletBasicID: wallet.ID, CardNoCiphertext: cardCipher,
CardFingerprint: fingerprint, CardNoLast4: request.CardNo[len(request.CardNo)-4:],
BankName: request.BankName, CardOwner: request.CardOwner, IDCardCiphertext: idCipher,
PhoneCiphertext: phoneCipher, BankType: request.BankType, Bank: request.Bank,
@@ -328,7 +327,7 @@ func UnbindBank(client string) gin.HandlerFunc {
return
}
var bank models.WalletBank
if impl.DBService.Where("identity = ? AND wallet_basic_id = ? AND status <> ?", ctx.Param("identity"), wallet.ID, base.StatusArchived).First(&bank).Error != nil {
if impl.DBService.Where("identity = ? AND wallet_basic_id = ? AND status <> ?", ctx.Param("identity"), wallet.ID, StatusArchived).First(&bank).Error != nil {
infra.Response.Error(ctx, errcode.ErrRecordNotFound)
return
}
@@ -338,7 +337,7 @@ func UnbindBank(client string) gin.HandlerFunc {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
if err := impl.DBService.Model(&bank).Update("status", base.StatusArchived).Error; err != nil {
if err := impl.DBService.Model(&bank).Update("status", StatusArchived).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
@@ -371,25 +370,22 @@ func CreateWithdrawal(client string) gin.HandlerFunc {
}
var apply models.WalletApplyCash
err = impl.DBService.Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&wallet, wallet.ID).Error; err != nil {
return err
}
if wallet.WithdrawalBalance < request.Amount {
return gorm.ErrInvalidData
}
var bank models.WalletBank
if err := tx.Where("identity = ? AND wallet_basic_id = ? AND status = ?", request.BankIdentity, wallet.ID, base.StatusEnable).First(&bank).Error; err != nil {
if err := tx.Where("identity = ? AND wallet_basic_id = ? AND status = ?", request.BankIdentity, wallet.ID, StatusEnable).First(&bank).Error; err != nil {
return err
}
apply = models.WalletApplyCash{
Entity: base.NewEntity(base.StatusEnable), ApplyStatus: 10, WalletBasicID: wallet.ID,
WalletBankID: bank.ID, CashNo: RecordNo("WD"), RequestNo: request.RequestNo,
Amount: request.Amount, Channel: "bank", Remark: request.Remark,
}
if err := tx.Create(&apply).Error; err != nil {
return err
}
return tx.Model(&wallet).Update("withdrawal_balance", gorm.Expr("withdrawal_balance - ?", request.Amount)).Error
var createErr error
apply, _, createErr = CreateReservedWithdrawal(tx, WalletWithdrawalInput{
WalletBasicID: wallet.ID,
WalletBankID: bank.ID,
RequestNo: request.RequestNo,
CashNo: RecordNo("WD"),
Amount: request.Amount,
Channel: "bank",
Remark: request.Remark,
OperatorIdentity: owner.Identity,
})
return createErr
})
if err != nil {
var existing models.WalletApplyCash
@@ -399,7 +395,7 @@ func CreateWithdrawal(client string) gin.HandlerFunc {
}
apply = existing
}
infra.Response.Success(ctx, base.ResourceResponse(apply))
infra.Response.Success(ctx, ResourceResponse(apply))
}
}
@@ -420,7 +416,7 @@ func ListWithdrawals(client string) gin.HandlerFunc {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, base.ResourceResponse(list))
infra.Response.Success(ctx, ResourceResponse(list))
}
}

View File

@@ -0,0 +1,253 @@
package common
import (
"errors"
"math"
"time"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var (
ErrInvalidWalletAmount = errors.New("invalid wallet amount")
ErrWalletUnavailable = errors.New("wallet is unavailable")
ErrWalletBalance = errors.New("insufficient wallet balance")
ErrWalletOverflow = errors.New("wallet balance overflow")
ErrIdempotencyConflict = errors.New("idempotency request conflicts with existing withdrawal")
)
// WalletWithdrawalInput 是统一提现预扣所需的最小业务输入。
type WalletWithdrawalInput struct {
WalletBasicID uint64
WalletBankID uint64
RequestNo string
CashNo string
Amount int64
Channel string
Remark string
OperatorIdentity string
OperatorName string
}
// LockWalletForUpdate 锁定钱包事实行,所有资金扣减必须在同一事务内调用。
func LockWalletForUpdate(tx *gorm.DB, walletID uint64, requireEnabled bool) (models.WalletBasic, error) {
var wallet models.WalletBasic
query := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", walletID)
if requireEnabled {
query = query.Where("status = ?", StatusEnable)
}
if err := query.First(&wallet).Error; err != nil {
return wallet, err
}
return wallet, nil
}
// SpendWalletBalance 扣减普通消费,并保证可提现余额始终不超过总余额。
func SpendWalletBalance(wallet *models.WalletBasic, amount int64) error {
if amount <= 0 {
return ErrInvalidWalletAmount
}
if wallet.Status != StatusEnable {
return ErrWalletUnavailable
}
if wallet.Balance < 0 || wallet.WithdrawalBalance < 0 {
return ErrWalletBalance
}
if wallet.Balance < amount {
return ErrWalletBalance
}
wallet.Balance -= amount
if wallet.WithdrawalBalance > wallet.Balance {
wallet.WithdrawalBalance = wallet.Balance
}
return nil
}
// ReserveWalletWithdrawal 在申请时同时预扣总余额和可提现余额。
func ReserveWalletWithdrawal(wallet *models.WalletBasic, amount int64) error {
if amount <= 0 {
return ErrInvalidWalletAmount
}
if wallet.Status != StatusEnable {
return ErrWalletUnavailable
}
if wallet.Balance < 0 || wallet.WithdrawalBalance < 0 || wallet.WithdrawalBalance > wallet.Balance {
return ErrWalletBalance
}
if wallet.Balance < amount || wallet.WithdrawalBalance < amount {
return ErrWalletBalance
}
wallet.Balance -= amount
wallet.WithdrawalBalance -= amount
return nil
}
// ReleaseWalletWithdrawal 在提现驳回时原样返还此前预扣的两类余额。
func ReleaseWalletWithdrawal(wallet *models.WalletBasic, amount int64) error {
if amount <= 0 {
return ErrInvalidWalletAmount
}
if wallet.Balance < 0 || wallet.WithdrawalBalance < 0 || wallet.WithdrawalBalance > wallet.Balance {
return ErrWalletBalance
}
if wallet.Balance > math.MaxInt64-amount || wallet.WithdrawalBalance > math.MaxInt64-amount {
return ErrWalletOverflow
}
wallet.Balance += amount
wallet.WithdrawalBalance += amount
return nil
}
// ReleaseLegacyWithdrawalBalance 兼容旧客户端申请只预扣可提现余额的历史记录。
func ReleaseLegacyWithdrawalBalance(wallet *models.WalletBasic, amount int64) error {
if amount <= 0 {
return ErrInvalidWalletAmount
}
if wallet.WithdrawalBalance > math.MaxInt64-amount {
return ErrWalletOverflow
}
wallet.WithdrawalBalance += amount
if wallet.WithdrawalBalance > wallet.Balance {
wallet.WithdrawalBalance = wallet.Balance
}
return nil
}
// ReleaseWithdrawalApplication 按新旧申请的实际预扣方式返还余额。
func ReleaseWithdrawalApplication(wallet *models.WalletBasic, application models.WalletApplyCash) (bool, error) {
if application.BalanceReserved {
return true, ReleaseWalletWithdrawal(wallet, application.Amount)
}
if wallet.OwnerType == "user" || wallet.OwnerType == "staff" {
return true, ReleaseLegacyWithdrawalBalance(wallet, application.Amount)
}
return false, nil
}
// SettleLegacyWithdrawal 为升级前未完整预扣的申请补扣余额。
func SettleLegacyWithdrawal(wallet *models.WalletBasic, application models.WalletApplyCash) (bool, error) {
if application.BalanceReserved {
return false, nil
}
if wallet.OwnerType == "user" || wallet.OwnerType == "staff" {
return true, SpendWalletBalance(wallet, application.Amount)
}
return true, ReserveWalletWithdrawal(wallet, application.Amount)
}
// SaveWalletBalances 将内存中已校验的余额快照写回当前事务。
func SaveWalletBalances(tx *gorm.DB, wallet models.WalletBasic) error {
result := tx.Model(&models.WalletBasic{}).Where("id = ?", wallet.ID).Updates(map[string]any{
"balance": wallet.Balance,
"withdrawal_balance": wallet.WithdrawalBalance,
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return ErrWalletUnavailable
}
return nil
}
// CreateReservedWithdrawal 幂等创建提现申请,并在同一事务中预扣两类余额和写入流水。
func CreateReservedWithdrawal(tx *gorm.DB, input WalletWithdrawalInput) (models.WalletApplyCash, bool, error) {
wallet, err := LockWalletForUpdate(tx, input.WalletBasicID, false)
if err != nil {
return models.WalletApplyCash{}, false, err
}
var existing models.WalletApplyCash
err = tx.Where("request_no = ?", input.RequestNo).First(&existing).Error
if err == nil {
if existing.WalletBasicID != input.WalletBasicID ||
existing.WalletBankID != input.WalletBankID ||
existing.Amount != input.Amount ||
existing.Channel != input.Channel {
return existing, false, ErrIdempotencyConflict
}
return existing, false, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return models.WalletApplyCash{}, false, err
}
if err := ReserveWalletWithdrawal(&wallet, input.Amount); err != nil {
return models.WalletApplyCash{}, false, err
}
if input.CashNo == "" {
input.CashNo = models.NewIdentity()
}
application := models.WalletApplyCash{
Entity: NewEntity(StatusEnable),
ApplyStatus: StatusPending,
WalletBasicID: input.WalletBasicID,
WalletBankID: input.WalletBankID,
CashNo: input.CashNo,
RequestNo: input.RequestNo,
Amount: input.Amount,
Channel: input.Channel,
Remark: input.Remark,
BalanceReserved: true,
}
if err := tx.Create(&application).Error; err != nil {
return models.WalletApplyCash{}, false, err
}
if err := SaveWalletBalances(tx, wallet); err != nil {
return models.WalletApplyCash{}, false, err
}
record := NewWalletBalanceRecord(
wallet,
"withdrawal-reserve:"+application.Identity,
"expense",
"withdrawal_reserve",
application.Amount,
"",
application.CashNo,
application.Channel,
input.OperatorIdentity,
input.OperatorName,
input.Remark,
)
if err := tx.Create(&record).Error; err != nil {
return models.WalletApplyCash{}, false, err
}
return application, true, nil
}
// NewWalletBalanceRecord 创建带完整余额快照的不可变资金流水。
func NewWalletBalanceRecord(
wallet models.WalletBasic,
requestNo string,
direction string,
tradeType string,
amount int64,
inTradeNo string,
outTradeNo string,
channel string,
operatorIdentity string,
operatorName string,
remark string,
) models.WalletRecord {
now := time.Now()
return models.WalletRecord{
Entity: NewEntity(StatusEnable),
WalletBasicID: wallet.ID,
RecordNo: models.NewIdentity(),
RequestNo: requestNo,
Direction: direction,
TradeType: tradeType,
Amount: amount,
BalanceAfter: wallet.Balance,
WithdrawalBalanceAfter: wallet.WithdrawalBalance,
InTradeNo: inTradeNo,
OutTradeNo: outTradeNo,
PayChannel: channel,
OperatorIdentity: operatorIdentity,
OperatorName: operatorName,
Ymd: int32(now.Year()*10000 + int(now.Month())*100 + now.Day()),
Ym: int32(now.Year()*100 + int(now.Month())),
Remark: remark,
}
}

View File

@@ -0,0 +1,212 @@
package common
import (
"math"
"testing"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
)
func TestSpendWalletBalanceClosesWithdrawableGap(t *testing.T) {
wallet := models.WalletBasic{
Entity: NewEntity(StatusEnable),
Balance: 10_000,
WithdrawalBalance: 10_000,
}
if err := SpendWalletBalance(&wallet, 8_000); err != nil {
t.Fatal(err)
}
if wallet.Balance != 2_000 || wallet.WithdrawalBalance != 2_000 {
t.Fatalf("balances after expense = (%d, %d), want (2000, 2000)", wallet.Balance, wallet.WithdrawalBalance)
}
if err := ReserveWalletWithdrawal(&wallet, 10_000); err != ErrWalletBalance {
t.Fatalf("withdrawal after expense error = %v, want %v", err, ErrWalletBalance)
}
}
func TestSpendWalletBalanceConsumesNonWithdrawableBalanceFirst(t *testing.T) {
wallet := models.WalletBasic{
Entity: NewEntity(StatusEnable),
Balance: 15_000,
WithdrawalBalance: 10_000,
}
if err := SpendWalletBalance(&wallet, 4_000); err != nil {
t.Fatal(err)
}
if wallet.Balance != 11_000 || wallet.WithdrawalBalance != 10_000 {
t.Fatalf("balances after first expense = (%d, %d), want (11000, 10000)", wallet.Balance, wallet.WithdrawalBalance)
}
if err := SpendWalletBalance(&wallet, 2_000); err != nil {
t.Fatal(err)
}
if wallet.Balance != 9_000 || wallet.WithdrawalBalance != 9_000 {
t.Fatalf("balances after second expense = (%d, %d), want (9000, 9000)", wallet.Balance, wallet.WithdrawalBalance)
}
}
func TestWithdrawalReserveAndRejectAreExactInverse(t *testing.T) {
wallet := models.WalletBasic{
Entity: NewEntity(StatusEnable),
Balance: 10_000,
WithdrawalBalance: 10_000,
}
if err := ReserveWalletWithdrawal(&wallet, 10_000); err != nil {
t.Fatal(err)
}
if wallet.Balance != 0 || wallet.WithdrawalBalance != 0 {
t.Fatalf("reserved balances = (%d, %d), want (0, 0)", wallet.Balance, wallet.WithdrawalBalance)
}
if err := ReleaseWalletWithdrawal(&wallet, 10_000); err != nil {
t.Fatal(err)
}
if wallet.Balance != 10_000 || wallet.WithdrawalBalance != 10_000 {
t.Fatalf("released balances = (%d, %d), want (10000, 10000)", wallet.Balance, wallet.WithdrawalBalance)
}
}
func TestLegacyWithdrawalReleaseCannotExceedRemainingBalance(t *testing.T) {
wallet := models.WalletBasic{
Entity: NewEntity(StatusEnable),
Balance: 2_000,
WithdrawalBalance: 2_000,
}
if err := ReleaseLegacyWithdrawalBalance(&wallet, 8_000); err != nil {
t.Fatal(err)
}
if wallet.Balance != 2_000 || wallet.WithdrawalBalance != 2_000 {
t.Fatalf("legacy released balances = (%d, %d), want (2000, 2000)", wallet.Balance, wallet.WithdrawalBalance)
}
}
func TestFrozenWalletRejectsNewDebits(t *testing.T) {
wallet := models.WalletBasic{
Entity: NewEntity(StatusFrozen),
Balance: 10_000,
WithdrawalBalance: 10_000,
}
if err := SpendWalletBalance(&wallet, 1); err != ErrWalletUnavailable {
t.Fatalf("expense error = %v, want %v", err, ErrWalletUnavailable)
}
if err := ReserveWalletWithdrawal(&wallet, 1); err != ErrWalletUnavailable {
t.Fatalf("withdrawal error = %v, want %v", err, ErrWalletUnavailable)
}
}
func TestWithdrawalReleaseRejectsOverflow(t *testing.T) {
wallet := models.WalletBasic{
Entity: NewEntity(StatusEnable),
Balance: math.MaxInt64,
WithdrawalBalance: math.MaxInt64,
}
if err := ReleaseWalletWithdrawal(&wallet, 1); err != ErrWalletOverflow {
t.Fatalf("release error = %v, want %v", err, ErrWalletOverflow)
}
}
func TestNewWithdrawalRejectionRestoresBothBalances(t *testing.T) {
wallet := models.WalletBasic{
Entity: NewEntity(StatusEnable),
OwnerType: "delivery",
Balance: 2_000,
WithdrawalBalance: 2_000,
}
application := models.WalletApplyCash{Amount: 8_000, BalanceReserved: true}
released, err := ReleaseWithdrawalApplication(&wallet, application)
if err != nil || !released {
t.Fatalf("release = (%v, %v), want (true, nil)", released, err)
}
if wallet.Balance != 10_000 || wallet.WithdrawalBalance != 10_000 {
t.Fatalf("released balances = (%d, %d), want (10000, 10000)", wallet.Balance, wallet.WithdrawalBalance)
}
}
func TestLegacyOrganizationWithdrawalRejectionDoesNotMintBalance(t *testing.T) {
wallet := models.WalletBasic{
Entity: NewEntity(StatusEnable),
OwnerType: "delivery",
Balance: 10_000,
WithdrawalBalance: 10_000,
}
application := models.WalletApplyCash{Amount: 8_000}
released, err := ReleaseWithdrawalApplication(&wallet, application)
if err != nil || released {
t.Fatalf("release = (%v, %v), want (false, nil)", released, err)
}
if wallet.Balance != 10_000 || wallet.WithdrawalBalance != 10_000 {
t.Fatalf("legacy organization balances changed to (%d, %d)", wallet.Balance, wallet.WithdrawalBalance)
}
}
func TestLegacyWithdrawalCompletionDebitsMissingBalances(t *testing.T) {
tests := []struct {
name string
ownerType string
withdrawalBalance int64
wantBalance int64
wantWithdrawal int64
}{
{
name: "client already reserved withdrawable balance",
ownerType: "user",
withdrawalBalance: 2_000,
wantBalance: 2_000,
wantWithdrawal: 2_000,
},
{
name: "organization reserved neither balance",
ownerType: "delivery",
withdrawalBalance: 10_000,
wantBalance: 2_000,
wantWithdrawal: 2_000,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
wallet := models.WalletBasic{
Entity: NewEntity(StatusEnable),
OwnerType: test.ownerType,
Balance: 10_000,
WithdrawalBalance: test.withdrawalBalance,
}
changed, err := SettleLegacyWithdrawal(&wallet, models.WalletApplyCash{Amount: 8_000})
if err != nil || !changed {
t.Fatalf("settle = (%v, %v), want (true, nil)", changed, err)
}
if wallet.Balance != test.wantBalance || wallet.WithdrawalBalance != test.wantWithdrawal {
t.Fatalf(
"settled balances = (%d, %d), want (%d, %d)",
wallet.Balance,
wallet.WithdrawalBalance,
test.wantBalance,
test.wantWithdrawal,
)
}
})
}
}
func TestReservedWithdrawalCompletionDoesNotDebitAgain(t *testing.T) {
wallet := models.WalletBasic{
Entity: NewEntity(StatusEnable),
OwnerType: "user",
Balance: 2_000,
WithdrawalBalance: 2_000,
}
application := models.WalletApplyCash{Amount: 8_000, BalanceReserved: true}
changed, err := SettleLegacyWithdrawal(&wallet, application)
if err != nil || changed {
t.Fatalf("settle = (%v, %v), want (false, nil)", changed, err)
}
if wallet.Balance != 2_000 || wallet.WithdrawalBalance != 2_000 {
t.Fatalf("reserved completion changed balances to (%d, %d)", wallet.Balance, wallet.WithdrawalBalance)
}
}

View File

@@ -143,7 +143,7 @@ func Recharge(ctx *gin.Context) {
}
func CreateApplyCash(ctx *gin.Context) {
point, _, ok := currentScope(ctx)
account, point, _, ok := CurrentDeliveryAccount(ctx)
if !ok {
return
}
@@ -162,29 +162,31 @@ func CreateApplyCash(ctx *gin.Context) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
var pending int64
if err := db().Model(&models.WalletApplyCash{}).Where("wallet_basic_id = ? AND apply_status = ? AND status <> ?",
wallet.ID, common.StatusPending, common.StatusArchived).Select("COALESCE(SUM(amount), 0)").Scan(&pending).Error; err != nil ||
request.Amount > wallet.WithdrawalBalance-pending {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
var bankID uint64
if request.WalletBankIdentity != "" {
var bank models.WalletBank
if err := common.ActiveRecords(db()).Where("identity = ? AND wallet_basic_id = ?",
request.WalletBankIdentity, wallet.ID).First(&bank).Error; err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
var apply models.WalletApplyCash
err := db().Transaction(func(tx *gorm.DB) error {
var bankID uint64
if request.WalletBankIdentity != "" {
var bank models.WalletBank
if err := common.ActiveRecords(tx).Where("identity = ? AND wallet_basic_id = ?",
request.WalletBankIdentity, wallet.ID).First(&bank).Error; err != nil {
return err
}
bankID = bank.ID
}
bankID = bank.ID
}
apply := models.WalletApplyCash{
Entity: common.NewEntity(common.StatusEnable), ApplyStatus: common.StatusPending,
WalletBasicID: wallet.ID, WalletBankID: bankID, CashNo: models.NewIdentity(), RequestNo: request.RequestNo,
Amount: request.Amount, Channel: request.Channel, Remark: request.Remark,
}
if err := db().Create(&apply).Error; err != nil {
var createErr error
apply, _, createErr = common.CreateReservedWithdrawal(tx, common.WalletWithdrawalInput{
WalletBasicID: wallet.ID,
WalletBankID: bankID,
RequestNo: request.RequestNo,
Amount: request.Amount,
Channel: request.Channel,
Remark: request.Remark,
OperatorIdentity: account.Identity,
OperatorName: account.DisplayName,
})
return createErr
})
if err != nil {
infra.Response.Error(ctx, err)
return
}

View File

@@ -9,6 +9,7 @@ import (
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func currentWallet(ctx *gin.Context, gas models.GasBasic) (models.WalletBasic, bool) {
@@ -58,7 +59,7 @@ func ListWalletApplyCash(ctx *gin.Context) {
}
func CreateWalletApplyCash(ctx *gin.Context) {
station, ok := currentGas(ctx)
account, station, ok := CurrentGasAccount(ctx)
if !ok {
return
}
@@ -78,30 +79,31 @@ func CreateWalletApplyCash(ctx *gin.Context) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
var pendingAmount int64
if err := impl.DBService.Model(&models.WalletApplyCash{}).
Where("wallet_basic_id = ? AND status <> ? AND apply_status = ?", wallet.ID, common.StatusArchived, common.StatusPending).
Select("COALESCE(SUM(amount), 0)").Scan(&pendingAmount).Error; err != nil ||
request.Amount > wallet.WithdrawalBalance-pendingAmount {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
var bankID uint64
if request.WalletBankIdentity != "" {
var bank models.WalletBank
if err := common.ActiveRecords(impl.DBService).
Where("identity = ? AND wallet_basic_id = ?", request.WalletBankIdentity, wallet.ID).First(&bank).Error; err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
var apply models.WalletApplyCash
err := impl.DBService.Transaction(func(tx *gorm.DB) error {
var bankID uint64
if request.WalletBankIdentity != "" {
var bank models.WalletBank
if err := common.ActiveRecords(tx).
Where("identity = ? AND wallet_basic_id = ?", request.WalletBankIdentity, wallet.ID).First(&bank).Error; err != nil {
return err
}
bankID = bank.ID
}
bankID = bank.ID
}
apply := models.WalletApplyCash{
Entity: common.NewEntity(common.StatusEnable), ApplyStatus: common.StatusPending,
WalletBasicID: wallet.ID, WalletBankID: bankID, CashNo: models.NewIdentity(),
RequestNo: request.RequestNo, Amount: request.Amount, Channel: request.Channel, Remark: request.Remark,
}
if err := impl.DBService.Create(&apply).Error; err != nil {
var createErr error
apply, _, createErr = common.CreateReservedWithdrawal(tx, common.WalletWithdrawalInput{
WalletBasicID: wallet.ID,
WalletBankID: bankID,
RequestNo: request.RequestNo,
Amount: request.Amount,
Channel: request.Channel,
Remark: request.Remark,
OperatorIdentity: account.Identity,
OperatorName: account.DisplayName,
})
return createErr
})
if err != nil {
infra.Response.Error(ctx, err)
return
}

View File

@@ -311,14 +311,34 @@ func reviewWalletApplyCash(ctx *gin.Context, targetStatus int) {
}
now := time.Now()
if targetStatus == common.StatusRejected {
result := tx.Model(&models.WalletBasic{}).
Where("id = ? AND withdrawal_balance <= ?", application.WalletBasicID, math.MaxInt64-application.Amount).
Update("withdrawal_balance", gorm.Expr("withdrawal_balance + ?", application.Amount))
if result.Error != nil {
return result.Error
wallet, err := common.LockWalletForUpdate(tx, application.WalletBasicID, false)
if err != nil {
return err
}
if result.RowsAffected != 1 {
return errors.New("withdrawal balance overflow")
released, err := common.ReleaseWithdrawalApplication(&wallet, application)
if err != nil {
return err
}
if released {
if err := common.SaveWalletBalances(tx, wallet); err != nil {
return err
}
record := common.NewWalletBalanceRecord(
wallet,
"withdrawal-reject:"+application.Identity,
"income",
"withdrawal_release",
application.Amount,
application.CashNo,
"",
application.Channel,
operatorIdentity,
operatorName,
request.Reason,
)
if err := tx.Create(&record).Error; err != nil {
return err
}
}
}
return tx.Model(&application).Updates(map[string]any{
@@ -359,10 +379,43 @@ func CompleteWalletApplyCash(ctx *gin.Context) {
if application.ApplyStatus != common.StatusApproved {
return errors.New("cash application is not approved")
}
if !application.BalanceReserved {
wallet, err := common.LockWalletForUpdate(tx, application.WalletBasicID, true)
if err != nil {
return err
}
changed, err := common.SettleLegacyWithdrawal(&wallet, application)
if err != nil {
return err
}
if !changed {
return errors.New("legacy withdrawal settlement did not change wallet")
}
if err := common.SaveWalletBalances(tx, wallet); err != nil {
return err
}
operatorIdentity, operatorName := common.PlatformOperator(ctx)
record := common.NewWalletBalanceRecord(
wallet,
"withdrawal-complete:"+application.Identity,
"expense",
"withdrawal_complete",
application.Amount,
"",
request.TradeNo,
application.Channel,
operatorIdentity,
operatorName,
"历史提现申请完成时补记总余额",
)
if err := tx.Create(&record).Error; err != nil {
return err
}
}
now := time.Now()
return tx.Model(&application).Updates(map[string]any{
"apply_status": common.StatusCompleted, "trade_no": strings.TrimSpace(request.TradeNo),
"callback_msg": request.CallbackMsg, "completed_at": &now,
"callback_msg": request.CallbackMsg, "completed_at": &now, "balance_reserved": true,
}).Error
})
if err != nil {

View File

@@ -25,6 +25,7 @@ type WalletApplyCash struct {
ReviewedAt *time.Time `gorm:"column:reviewed_at;type:timestamptz" json:"reviewed_at"` // 审核时间
ReviewReason string `gorm:"column:review_reason;type:text;not null;default:''" json:"review_reason"` // 审核原因
CompletedAt *time.Time `gorm:"column:completed_at;type:timestamptz" json:"completed_at"` // 完成时间
BalanceReserved bool `gorm:"column:balance_reserved;not null;default:false;index" json:"balance_reserved"` // 是否已在申请时同时预扣总余额和可提现余额
}
func init() { database.AppendMigrate(&WalletApplyCash{}) }

View File

@@ -4,9 +4,9 @@ import (
"fmt"
sdkmiddleware "git.apinb.com/bsm-sdk/core/middleware"
clientcommon "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/client/common"
stafflogic "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/client/staff"
userlogic "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/client/user"
common "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
"github.com/gin-gonic/gin"
)
@@ -19,7 +19,7 @@ func RegisterClient(serviceKey string, engine *gin.Engine) {
func registerUserClient(serviceKey string, engine *gin.Engine) {
basePath := fmt.Sprintf("/%s/client/v1/user", serviceKey)
anonymous := engine.Group(basePath)
anonymous.POST("/auth/verification-code", clientcommon.SendVerificationCode("user_app"))
anonymous.POST("/auth/verification-code", common.SendVerificationCode("user_app"))
anonymous.POST("/auth/register", userlogic.Register)
anonymous.POST("/auth/login", userlogic.Login)
anonymous.POST("/auth/reset-password", userlogic.ResetPassword)
@@ -29,7 +29,7 @@ func registerUserClient(serviceKey string, engine *gin.Engine) {
anonymous.GET("/public/products", userlogic.PublicProducts)
protected := engine.Group(basePath)
protected.Use(sdkmiddleware.JwtAuth(true), clientcommon.RequireClient("user_app"))
protected.Use(sdkmiddleware.JwtAuth(true), common.RequireClient("user_app"))
protected.GET("/auth/profile", userlogic.Profile)
protected.PUT("/auth/profile", userlogic.UpdateProfile)
protected.PUT("/auth/password", userlogic.ChangePassword)
@@ -55,12 +55,12 @@ func registerUserClient(serviceKey string, engine *gin.Engine) {
func registerStaffClient(serviceKey string, engine *gin.Engine) {
basePath := fmt.Sprintf("/%s/client/v1/staff", serviceKey)
anonymous := engine.Group(basePath)
anonymous.POST("/auth/verification-code", clientcommon.SendVerificationCode("service_app"))
anonymous.POST("/auth/verification-code", common.SendVerificationCode("service_app"))
anonymous.POST("/auth/login", stafflogic.Login)
anonymous.POST("/auth/reset-password", stafflogic.ResetPassword)
protected := engine.Group(basePath)
protected.Use(sdkmiddleware.JwtAuth(true), clientcommon.RequireClient("service_app"))
protected.Use(sdkmiddleware.JwtAuth(true), common.RequireClient("service_app"))
protected.GET("/auth/profile", stafflogic.Profile)
protected.GET("/preflight", stafflogic.Preflight)
protected.PUT("/auth/password", stafflogic.ChangePassword)
@@ -83,14 +83,14 @@ func registerStaffClient(serviceKey string, engine *gin.Engine) {
}
func registerClientWalletRoutes(group *gin.RouterGroup, client string) {
group.GET("/wallet", clientcommon.GetWallet(client))
group.PUT("/wallet/payment-password", clientcommon.SetPaymentPassword(client))
group.GET("/wallet/records", clientcommon.ListWalletRecords(client))
group.POST("/wallet/recharges", clientcommon.CreateRecharge(client))
group.POST("/wallet/recharges/:identity/mock-confirm", clientcommon.ConfirmMockRecharge(client))
group.GET("/wallet/banks", clientcommon.ListBanks(client))
group.POST("/wallet/banks", clientcommon.BindBank(client))
group.DELETE("/wallet/banks/:identity", clientcommon.UnbindBank(client))
group.GET("/wallet/withdrawals", clientcommon.ListWithdrawals(client))
group.POST("/wallet/withdrawals", clientcommon.CreateWithdrawal(client))
group.GET("/wallet", common.GetWallet(client))
group.PUT("/wallet/payment-password", common.SetPaymentPassword(client))
group.GET("/wallet/records", common.ListWalletRecords(client))
group.POST("/wallet/recharges", common.CreateRecharge(client))
group.POST("/wallet/recharges/:identity/mock-confirm", common.ConfirmMockRecharge(client))
group.GET("/wallet/banks", common.ListBanks(client))
group.POST("/wallet/banks", common.BindBank(client))
group.DELETE("/wallet/banks/:identity", common.UnbindBank(client))
group.GET("/wallet/withdrawals", common.ListWithdrawals(client))
group.POST("/wallet/withdrawals", common.CreateWithdrawal(client))
}

View File

@@ -382,7 +382,7 @@ func MockData(database *gorm.DB) error {
CashNo: "MOCK-CASH-001", RequestNo: "MOCK-REQ-CASH-001", Amount: 5000,
Channel: "bank", TradeNo: "MOCK-CASH-TRADE-001", Remark: "模拟提现",
ReviewerIdentity: gasAccount.Identity, ReviewerName: gasAccount.DisplayName,
ReviewedAt: &now, ReviewReason: "模拟审核通过", CompletedAt: &now,
ReviewedAt: &now, ReviewReason: "模拟审核通过", CompletedAt: &now, BalanceReserved: true,
}
if err := put(tx, &applyCash); err != nil {
return err

View File

@@ -116,6 +116,7 @@ platforms/
## 6. 后端领域划分
- `backend/api/internal/logic/common`:承载跨平台后台、气站、配送点以及两个 Client 复用的鉴权、账户解析、资源响应和钱包记账能力;不再保留 `logic/client/common` 同义公共包。
- `identity`登录、验证码、账号、RBAC、组织、数据范围、协议同意。
- `organization`:气站、配送点、服务区域、人员归属、用户服务关系和邀请注册二维码。
- `device`:设备注册/绑定、设备模型、遥测、命令、在线状态、固件。

View File

@@ -108,3 +108,5 @@
- 支付密码独立于登录密码,仅允许六位数字,使用 bcrypt 保存;连续失败达到阈值后在 Redis 短时锁定。绑卡、解绑、余额支付和提现均要求支付密码或限定用途的一次性验证码。
- 公共上传接口 `/upload/file` 必须携带平台、气站、配送点、用户或工作人员任一合法 JWT图片/PDF 最大 10MB视频上限从配置读取。上传只返回资源 URI业务接口负责建立关联并记录操作者、采集与接收时间。
- 充值、支付、提现、工单证据、轨迹点、内容确认等写入均携带幂等号;资金入账在数据库事务内锁定钱包并同时写不可变流水。
- 钱包可提现余额是当前总余额的子集,始终满足 `0 <= 可提现余额 <= 总余额`。普通消费扣减总余额后,必须同步把可提现余额限制在剩余总余额以内。
- 提现申请在同一数据库事务内锁定钱包、同时预扣总余额和可提现余额并写入不可变流水;驳回只返还该申请实际预扣的两类余额,完成打款只确认外部结果,不得再次扣款。

File diff suppressed because one or more lines are too long