From 42a2322c8e87c83f89b27cb532301a1353847768 Mon Sep 17 00:00:00 2001 From: david Date: Fri, 31 Jul 2026 09:54:17 +0800 Subject: [PATCH] fix(wallet): enforce consistent withdrawal accounting --- audit/CODE_AUDIT_REPORT.md | 640 ++++++++++++++++++ .../api/internal/logic/client/staff/auth.go | 31 +- .../internal/logic/client/staff/delivery.go | 65 +- .../api/internal/logic/client/staff/work.go | 25 +- .../logic/client/user/address_ticket.go | 31 +- .../api/internal/logic/client/user/auth.go | 47 +- .../api/internal/logic/client/user/basic.go | 21 +- .../internal/logic/client/user/gasorder.go | 25 +- .../api/internal/logic/client/user/shop.go | 44 +- .../common/auth.go => common/client_auth.go} | 2 +- .../client_security_test.go} | 2 +- .../wallet.go => common/client_wallet.go} | 52 +- .../internal/logic/common/wallet_balance.go | 253 +++++++ .../logic/common/wallet_balance_test.go | 212 ++++++ .../api/internal/logic/delivery/finance.go | 48 +- backend/api/internal/logic/gas/finance.go | 50 +- .../internal/logic/platform/wallet/wallet.go | 69 +- .../api/internal/models/wallet_apply_cash.go | 1 + backend/api/internal/routers/client.go | 30 +- backend/api/internal/seed/mock.go | 2 +- docs/10-技术实现规划.md | 1 + docs/11-数据接口与安全.md | 2 + .../src/contracts/platform-resources.json | 2 +- 23 files changed, 1405 insertions(+), 250 deletions(-) create mode 100644 audit/CODE_AUDIT_REPORT.md rename backend/api/internal/logic/{client/common/auth.go => common/client_auth.go} (98%) rename backend/api/internal/logic/{client/common/security_test.go => common/client_security_test.go} (96%) rename backend/api/internal/logic/{client/common/wallet.go => common/client_wallet.go} (89%) create mode 100644 backend/api/internal/logic/common/wallet_balance.go create mode 100644 backend/api/internal/logic/common/wallet_balance_test.go diff --git a/audit/CODE_AUDIT_REPORT.md b/audit/CODE_AUDIT_REPORT.md new file mode 100644 index 0000000..2a2f6f5 --- /dev/null +++ b/audit/CODE_AUDIT_REPORT.md @@ -0,0 +1,640 @@ +# 全项目代码审计报告 + +审计日期:2026-07-31 + +审计对象:`apps/*`、`backend/*`、`frontend/*` + +审计方式:只读代码审计、静态检查、单元测试和本地构建 + +审计基线:仅以实际代码、路由、数据模型、客户端调用和测试为依据,未将 `docs/*` 作为需求或验收依据 + +## 1. 结论 + +当前代码**不具备生产发布条件**。 + +本次确认: + +- P0:2 项 +- P1:7 项 +- P2:8 项 +- P3:3 项 + +最先需要阻断发布的事项是: + +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 退出码 0;112 warnings / 12 infos + pnpm contract:check 通过(47 个资源) + pnpm build 通过 + +frontend/gas_admin: + pnpm type:check 通过 + pnpm lint 退出码 0;105 warnings / 12 infos + pnpm contract:check 通过(20 个资源) + pnpm build 通过 + +frontend/delivery_admin: + pnpm type:check 通过 + pnpm lint 退出码 0;106 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/*` 推导功能缺口或业务要求。 +- 未修改任何业务源代码;只新增本报告。 diff --git a/backend/api/internal/logic/client/staff/auth.go b/backend/api/internal/logic/client/staff/auth.go index cfbea0a..ac44ded 100644 --- a/backend/api/internal/logic/client/staff/auth.go +++ b/backend/api/internal/logic/client/staff/auth.go @@ -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) diff --git a/backend/api/internal/logic/client/staff/delivery.go b/backend/api/internal/logic/client/staff/delivery.go index 1ae47ca..75c9ff2 100644 --- a/backend/api/internal/logic/client/staff/delivery.go +++ b/backend/api/internal/logic/client/staff/delivery.go @@ -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{} diff --git a/backend/api/internal/logic/client/staff/work.go b/backend/api/internal/logic/client/staff/work.go index e6a7ed6..e82d64e 100644 --- a/backend/api/internal/logic/client/staff/work.go +++ b/backend/api/internal/logic/client/staff/work.go @@ -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 } diff --git a/backend/api/internal/logic/client/user/address_ticket.go b/backend/api/internal/logic/client/user/address_ticket.go index d8e09b8..338a874 100644 --- a/backend/api/internal/logic/client/user/address_ticket.go +++ b/backend/api/internal/logic/client/user/address_ticket.go @@ -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 } diff --git a/backend/api/internal/logic/client/user/auth.go b/backend/api/internal/logic/client/user/auth.go index d11527f..7e85e31 100644 --- a/backend/api/internal/logic/client/user/auth.go +++ b/backend/api/internal/logic/client/user/auth.go @@ -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) diff --git a/backend/api/internal/logic/client/user/basic.go b/backend/api/internal/logic/client/user/basic.go index b0d6506..90aecfe 100644 --- a/backend/api/internal/logic/client/user/basic.go +++ b/backend/api/internal/logic/client/user/basic.go @@ -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, } diff --git a/backend/api/internal/logic/client/user/gasorder.go b/backend/api/internal/logic/client/user/gasorder.go index 413ca16..e60cbc0 100644 --- a/backend/api/internal/logic/client/user/gasorder.go +++ b/backend/api/internal/logic/client/user/gasorder.go @@ -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 diff --git a/backend/api/internal/logic/client/user/shop.go b/backend/api/internal/logic/client/user/shop.go index 0c0264d..72057d1 100644 --- a/backend/api/internal/logic/client/user/shop.go +++ b/backend/api/internal/logic/client/user/shop.go @@ -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 } diff --git a/backend/api/internal/logic/client/common/auth.go b/backend/api/internal/logic/common/client_auth.go similarity index 98% rename from backend/api/internal/logic/client/common/auth.go rename to backend/api/internal/logic/common/client_auth.go index e2be14f..3f6fda5 100644 --- a/backend/api/internal/logic/client/common/auth.go +++ b/backend/api/internal/logic/common/client_auth.go @@ -1,4 +1,4 @@ -// Package common 提供两个客户端共用的鉴权、验证码和账户范围能力。 +// Package common 提供各业务端共用的鉴权、资源、钱包和账户范围能力。 package common import ( diff --git a/backend/api/internal/logic/client/common/security_test.go b/backend/api/internal/logic/common/client_security_test.go similarity index 96% rename from backend/api/internal/logic/client/common/security_test.go rename to backend/api/internal/logic/common/client_security_test.go index b927f4f..8105d1e 100644 --- a/backend/api/internal/logic/client/common/security_test.go +++ b/backend/api/internal/logic/common/client_security_test.go @@ -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, diff --git a/backend/api/internal/logic/client/common/wallet.go b/backend/api/internal/logic/common/client_wallet.go similarity index 89% rename from backend/api/internal/logic/client/common/wallet.go rename to backend/api/internal/logic/common/client_wallet.go index eaf4dfa..94d7593 100644 --- a/backend/api/internal/logic/client/common/wallet.go +++ b/backend/api/internal/logic/common/client_wallet.go @@ -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)) } } diff --git a/backend/api/internal/logic/common/wallet_balance.go b/backend/api/internal/logic/common/wallet_balance.go new file mode 100644 index 0000000..9b7463c --- /dev/null +++ b/backend/api/internal/logic/common/wallet_balance.go @@ -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, + } +} diff --git a/backend/api/internal/logic/common/wallet_balance_test.go b/backend/api/internal/logic/common/wallet_balance_test.go new file mode 100644 index 0000000..f30c5d8 --- /dev/null +++ b/backend/api/internal/logic/common/wallet_balance_test.go @@ -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) + } +} diff --git a/backend/api/internal/logic/delivery/finance.go b/backend/api/internal/logic/delivery/finance.go index c6dc6ed..df56ae6 100644 --- a/backend/api/internal/logic/delivery/finance.go +++ b/backend/api/internal/logic/delivery/finance.go @@ -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 } diff --git a/backend/api/internal/logic/gas/finance.go b/backend/api/internal/logic/gas/finance.go index f5cbaa1..16c7b11 100644 --- a/backend/api/internal/logic/gas/finance.go +++ b/backend/api/internal/logic/gas/finance.go @@ -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 } diff --git a/backend/api/internal/logic/platform/wallet/wallet.go b/backend/api/internal/logic/platform/wallet/wallet.go index cd237d2..ff581b5 100644 --- a/backend/api/internal/logic/platform/wallet/wallet.go +++ b/backend/api/internal/logic/platform/wallet/wallet.go @@ -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 { diff --git a/backend/api/internal/models/wallet_apply_cash.go b/backend/api/internal/models/wallet_apply_cash.go index 936f277..17b6a54 100644 --- a/backend/api/internal/models/wallet_apply_cash.go +++ b/backend/api/internal/models/wallet_apply_cash.go @@ -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{}) } diff --git a/backend/api/internal/routers/client.go b/backend/api/internal/routers/client.go index 2948257..7ed9703 100644 --- a/backend/api/internal/routers/client.go +++ b/backend/api/internal/routers/client.go @@ -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)) } diff --git a/backend/api/internal/seed/mock.go b/backend/api/internal/seed/mock.go index 9c0a050..9798732 100644 --- a/backend/api/internal/seed/mock.go +++ b/backend/api/internal/seed/mock.go @@ -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 diff --git a/docs/10-技术实现规划.md b/docs/10-技术实现规划.md index 0d5aa1c..689542f 100644 --- a/docs/10-技术实现规划.md +++ b/docs/10-技术实现规划.md @@ -116,6 +116,7 @@ platforms/ ## 6. 后端领域划分 +- `backend/api/internal/logic/common`:承载跨平台后台、气站、配送点以及两个 Client 复用的鉴权、账户解析、资源响应和钱包记账能力;不再保留 `logic/client/common` 同义公共包。 - `identity`:登录、验证码、账号、RBAC、组织、数据范围、协议同意。 - `organization`:气站、配送点、服务区域、人员归属、用户服务关系和邀请注册二维码。 - `device`:设备注册/绑定、设备模型、遥测、命令、在线状态、固件。 diff --git a/docs/11-数据接口与安全.md b/docs/11-数据接口与安全.md index 5b3e4e3..a0657ff 100644 --- a/docs/11-数据接口与安全.md +++ b/docs/11-数据接口与安全.md @@ -108,3 +108,5 @@ - 支付密码独立于登录密码,仅允许六位数字,使用 bcrypt 保存;连续失败达到阈值后在 Redis 短时锁定。绑卡、解绑、余额支付和提现均要求支付密码或限定用途的一次性验证码。 - 公共上传接口 `/upload/file` 必须携带平台、气站、配送点、用户或工作人员任一合法 JWT;图片/PDF 最大 10MB,视频上限从配置读取。上传只返回资源 URI,业务接口负责建立关联并记录操作者、采集与接收时间。 - 充值、支付、提现、工单证据、轨迹点、内容确认等写入均携带幂等号;资金入账在数据库事务内锁定钱包并同时写不可变流水。 +- 钱包可提现余额是当前总余额的子集,始终满足 `0 <= 可提现余额 <= 总余额`。普通消费扣减总余额后,必须同步把可提现余额限制在剩余总余额以内。 +- 提现申请在同一数据库事务内锁定钱包、同时预扣总余额和可提现余额并写入不可变流水;驳回只返还该申请实际预扣的两类余额,完成打款只确认外部结果,不得再次扣款。 diff --git a/frontend/platform_admin/src/contracts/platform-resources.json b/frontend/platform_admin/src/contracts/platform-resources.json index 8570f0c..ff0d5aa 100644 --- a/frontend/platform_admin/src/contracts/platform-resources.json +++ b/frontend/platform_admin/src/contracts/platform-resources.json @@ -1 +1 @@ -{"resources":[{"domain":"gas","name":"gas_basic","path":"/gas_basic","pageKind":"list","mode":"writable"},{"domain":"gas","name":"gas_account","path":"/gas_account","pageKind":"list","mode":"writable"},{"domain":"delivery","name":"delivery_basic","path":"/delivery_basic","pageKind":"list","mode":"writable"},{"domain":"delivery","name":"delivery_account","path":"/delivery_account","pageKind":"list","mode":"writable"},{"domain":"staff","name":"staff_account","path":"/staff_account","pageKind":"list","mode":"writable"},{"domain":"staff","name":"staff_credential","path":"/staff_credential","pageKind":"list","mode":"writable"},{"domain":"user","name":"user_account","path":"/user_account","pageKind":"list","mode":"writable"},{"domain":"user","name":"user_address","path":"/user_address","pageKind":"list","mode":"writable"},{"domain":"user","name":"user_service_relation","path":"/user_service_relation","pageKind":"list","mode":"writable"},{"domain":"product","name":"product_type","path":"/product_type","pageKind":"list","mode":"editable"},{"domain":"product","name":"product_warehouse","path":"/product_warehouse","pageKind":"list","mode":"editable"},{"domain":"product","name":"product_info","path":"/product_info","pageKind":"list","mode":"editable"},{"domain":"product","name":"product_repair","path":"/product_repair","pageKind":"list","mode":"editable"},{"domain":"product","name":"product_owner","path":"/product_owner","pageKind":"list","mode":"readonly"},{"domain":"ec","name":"ec_category","path":"/ec_category","pageKind":"list","mode":"writable"},{"domain":"ec","name":"ec_product","path":"/ec_product","pageKind":"list","mode":"writable"},{"domain":"ec","name":"ec_product_attribute","path":"/ec_product_attribute","pageKind":"list","mode":"writable"},{"domain":"ec","name":"ec_product_image","path":"/ec_product_image","pageKind":"list","mode":"writable"},{"domain":"ec","name":"ec_cart","path":"/ec_cart","pageKind":"list","mode":"readonly"},{"domain":"ec","name":"ec_order","path":"/ec_order","pageKind":"list","mode":"readonly"},{"domain":"ec","name":"ec_order_item","path":"/ec_order_item","pageKind":"list","mode":"readonly"},{"domain":"ec","name":"ec_review","path":"/ec_review","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_contract","path":"/gasorder_contract","pageKind":"list","mode":"managed"},{"domain":"gasorder","name":"gasorder_contract_product","path":"/gasorder_contract_product","pageKind":"list","mode":"append_only"},{"domain":"gasorder","name":"gasorder_contract_revision","path":"/gasorder_contract_revision","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_basic","path":"/gasorder_basic","pageKind":"list","mode":"append_only"},{"domain":"gasorder","name":"gasorder_item","path":"/gasorder_item","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_assign","path":"/gasorder_assign","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_status","path":"/gasorder_status","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_track","path":"/gasorder_track","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_track_point","path":"/gasorder_track_point","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_confirm","path":"/gasorder_confirm","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_payment","path":"/gasorder_payment","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"fin_payment","path":"/fin_payment","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"fin_settlement","path":"/fin_settlement","pageKind":"list","mode":"writable"},{"domain":"finance","name":"fin_reconciliation","path":"/fin_reconciliation","pageKind":"list","mode":"readonly"},{"domain":"content","name":"cms_content","path":"/cms_content","pageKind":"list","mode":"writable"},{"domain":"customer_service","name":"cs_ticket","path":"/cs_ticket","pageKind":"list","mode":"writable"},{"domain":"platform","name":"platform_account","path":"/platform_account","pageKind":"list","mode":"writable"},{"domain":"platform","name":"platform_role","path":"/platform_role","pageKind":"list","mode":"writable"},{"domain":"platform","name":"platform_menu","path":"/platform_menu","pageKind":"tree","mode":"readonly"},{"domain":"wallet","name":"wallet_basic","path":"/wallet_basic","pageKind":"list","mode":"readonly"},{"domain":"wallet","name":"wallet_bank","path":"/wallet_bank","pageKind":"list","mode":"readonly"},{"domain":"wallet","name":"wallet_payment","path":"/wallet_payment","pageKind":"list","mode":"readonly"},{"domain":"wallet","name":"wallet_record","path":"/wallet_record","pageKind":"list","mode":"readonly"},{"domain":"wallet","name":"wallet_refund","path":"/wallet_refund","pageKind":"list","mode":"readonly"},{"domain":"wallet","name":"wallet_apply_cash","path":"/wallet_apply_cash","pageKind":"list","mode":"readonly"}],"routes":[{"method":"GET","path":"/gasorder_contract"},{"method":"GET","path":"/gasorder_contract_product"},{"method":"GET","path":"/gasorder_contract_product/:identity"},{"method":"GET","path":"/gasorder_contract_revision"},{"method":"GET","path":"/gasorder_contract_revision/:identity"},{"method":"GET","path":"/gasorder_contract/:identity"},{"method":"GET","path":"/gasorder_confirm"},{"method":"GET","path":"/gasorder_confirm/:identity"},{"method":"GET","path":"/gasorder_track"},{"method":"GET","path":"/gasorder_track_point"},{"method":"GET","path":"/gasorder_track_point/:identity"},{"method":"GET","path":"/gasorder_track/:identity"},{"method":"GET","path":"/gasorder_basic"},{"method":"GET","path":"/gasorder_basic/:identity"},{"method":"GET","path":"/gasorder_item"},{"method":"GET","path":"/gasorder_item/:identity"},{"method":"GET","path":"/gasorder_assign"},{"method":"GET","path":"/gasorder_assign/:identity"},{"method":"GET","path":"/gasorder_status"},{"method":"GET","path":"/gasorder_status/:identity"},{"method":"GET","path":"/gasorder_payment"},{"method":"GET","path":"/gasorder_payment/:identity"},{"method":"GET","path":"/gas_basic"},{"method":"GET","path":"/gas_basic/:identity"},{"method":"GET","path":"/gas_account"},{"method":"GET","path":"/gas_account/:identity"},{"method":"GET","path":"/product_type"},{"method":"GET","path":"/product_type/:identity"},{"method":"GET","path":"/product_warehouse"},{"method":"GET","path":"/product_warehouse/:identity"},{"method":"GET","path":"/product_info"},{"method":"GET","path":"/product_info/:identity"},{"method":"GET","path":"/product_repair"},{"method":"GET","path":"/product_repair/:identity"},{"method":"GET","path":"/product_owner"},{"method":"GET","path":"/product_owner/:identity"},{"method":"GET","path":"/platform_role"},{"method":"GET","path":"/platform_role/:identity"},{"method":"GET","path":"/platform_role/:identity/menu"},{"method":"GET","path":"/platform_account"},{"method":"GET","path":"/platform_account/:identity"},{"method":"GET","path":"/platform_menu"},{"method":"GET","path":"/platform_menu/:identity"},{"method":"GET","path":"/ping/hello"},{"method":"GET","path":"/ec_product"},{"method":"GET","path":"/ec_product_attribute"},{"method":"GET","path":"/ec_product_attribute/:identity"},{"method":"GET","path":"/ec_product_image"},{"method":"GET","path":"/ec_product_image/:identity"},{"method":"GET","path":"/ec_product/:identity"},{"method":"GET","path":"/ec_category"},{"method":"GET","path":"/ec_category/:identity"},{"method":"GET","path":"/ec_cart"},{"method":"GET","path":"/ec_cart/:identity"},{"method":"GET","path":"/ec_order"},{"method":"GET","path":"/ec_order_item"},{"method":"GET","path":"/ec_order_item/:identity"},{"method":"GET","path":"/ec_order/:identity"},{"method":"GET","path":"/ec_review"},{"method":"GET","path":"/ec_review/:identity"},{"method":"GET","path":"/wallet_basic"},{"method":"GET","path":"/wallet_basic/:identity"},{"method":"GET","path":"/wallet_bank"},{"method":"GET","path":"/wallet_bank/:identity"},{"method":"GET","path":"/wallet_record"},{"method":"GET","path":"/wallet_record/:identity"},{"method":"GET","path":"/wallet_refund"},{"method":"GET","path":"/wallet_refund/:identity"},{"method":"GET","path":"/wallet_payment"},{"method":"GET","path":"/wallet_payment/:identity"},{"method":"GET","path":"/wallet_apply_cash"},{"method":"GET","path":"/wallet_apply_cash/:identity"},{"method":"GET","path":"/user_account"},{"method":"GET","path":"/user_account/:identity"},{"method":"GET","path":"/user_address"},{"method":"GET","path":"/user_address/:identity"},{"method":"GET","path":"/user_service_relation"},{"method":"GET","path":"/user_service_relation/:identity"},{"method":"GET","path":"/fin_payment"},{"method":"GET","path":"/fin_payment/:identity"},{"method":"GET","path":"/fin_settlement"},{"method":"GET","path":"/fin_settlement/:identity"},{"method":"GET","path":"/fin_reconciliation"},{"method":"GET","path":"/fin_reconciliation/:identity"},{"method":"GET","path":"/delivery_basic"},{"method":"GET","path":"/delivery_basic/:identity"},{"method":"GET","path":"/delivery_account"},{"method":"GET","path":"/delivery_account/:identity"},{"method":"GET","path":"/dashboard/overview"},{"method":"GET","path":"/staff_account"},{"method":"GET","path":"/staff_account/:identity"},{"method":"GET","path":"/staff_credential"},{"method":"GET","path":"/staff_credential/:identity"},{"method":"GET","path":"/cms_content"},{"method":"GET","path":"/cms_content/:identity"},{"method":"GET","path":"/cs_ticket"},{"method":"GET","path":"/cs_ticket/:identity"},{"method":"GET","path":"/auth/profile"},{"method":"POST","path":"/gasorder_basic"},{"method":"POST","path":"/gasorder_basic/:identity/assign"},{"method":"POST","path":"/gasorder_basic/:identity/awaiting-confirmation"},{"method":"POST","path":"/gasorder_basic/:identity/ready"},{"method":"POST","path":"/gasorder_basic/:identity/recover"},{"method":"POST","path":"/gasorder_basic/:identity/complete"},{"method":"POST","path":"/gasorder_basic/:identity/cancel"},{"method":"POST","path":"/gasorder_basic/:identity/filling"},{"method":"POST","path":"/gasorder_basic/:identity/delivering"},{"method":"POST","path":"/gasorder_basic/:identity/exception"},{"method":"POST","path":"/gasorder_contract"},{"method":"POST","path":"/gasorder_contract/:identity/activate"},{"method":"POST","path":"/gasorder_contract/:identity/renew"},{"method":"POST","path":"/gasorder_contract/:identity/terminate"},{"method":"POST","path":"/gasorder_contract_product"},{"method":"POST","path":"/gasorder_contract_product/:identity/unbind"},{"method":"POST","path":"/gas_basic"},{"method":"POST","path":"/gas_account"},{"method":"POST","path":"/product_type"},{"method":"POST","path":"/product_warehouse"},{"method":"POST","path":"/product_info"},{"method":"POST","path":"/product_repair"},{"method":"POST","path":"/platform_account"},{"method":"POST","path":"/platform_role"},{"method":"POST","path":"/wallet_apply_cash/:identity/approve"},{"method":"POST","path":"/wallet_apply_cash/:identity/reject"},{"method":"POST","path":"/wallet_apply_cash/:identity/complete"},{"method":"POST","path":"/wallet_basic/owner/:owner_type/:owner_identity"},{"method":"POST","path":"/wallet_basic/:identity/recharge"},{"method":"POST","path":"/ec_product"},{"method":"POST","path":"/ec_product_attribute"},{"method":"POST","path":"/ec_product_image"},{"method":"POST","path":"/ec_category"},{"method":"POST","path":"/user_account"},{"method":"POST","path":"/user_address"},{"method":"POST","path":"/user_service_relation"},{"method":"POST","path":"/delivery_basic"},{"method":"POST","path":"/delivery_account"},{"method":"POST","path":"/staff_account"},{"method":"POST","path":"/staff_credential"},{"method":"POST","path":"/cms_content"},{"method":"POST","path":"/cs_ticket"},{"method":"POST","path":"/auth/login"},{"method":"POST","path":"/fin_settlement"},{"method":"PUT","path":"/product_type/:identity"},{"method":"PUT","path":"/product_warehouse/:identity"},{"method":"PUT","path":"/product_info/:identity"},{"method":"PUT","path":"/product_repair/:identity"},{"method":"PUT","path":"/platform_role/:identity"},{"method":"PUT","path":"/platform_role/:identity/menu"},{"method":"PUT","path":"/platform_account/:identity"},{"method":"PUT","path":"/ec_product_attribute/:identity"},{"method":"PUT","path":"/ec_product_image/:identity"},{"method":"PUT","path":"/ec_product/:identity"},{"method":"PUT","path":"/ec_category/:identity"},{"method":"PUT","path":"/gas_basic/:identity"},{"method":"PUT","path":"/gas_account/:identity"},{"method":"PUT","path":"/gasorder_contract/:identity"},{"method":"PUT","path":"/user_account/:identity"},{"method":"PUT","path":"/user_address/:identity"},{"method":"PUT","path":"/user_service_relation/:identity"},{"method":"PUT","path":"/delivery_basic/:identity"},{"method":"PUT","path":"/delivery_account/:identity"},{"method":"PUT","path":"/staff_account/:identity"},{"method":"PUT","path":"/staff_credential/:identity"},{"method":"PUT","path":"/cms_content/:identity"},{"method":"PUT","path":"/cs_ticket/:identity"},{"method":"PUT","path":"/auth/password"},{"method":"PUT","path":"/fin_settlement/:identity"},{"method":"PATCH","path":"/product_info/:identity/status"},{"method":"PATCH","path":"/product_info/:identity/lifecycle"},{"method":"PATCH","path":"/product_type/:identity/status"},{"method":"PATCH","path":"/product_warehouse/:identity/status"},{"method":"PATCH","path":"/product_repair/:identity/status"},{"method":"PATCH","path":"/platform_account/:identity/status"},{"method":"PATCH","path":"/platform_role/:identity/status"},{"method":"PATCH","path":"/ec_product_attribute/:identity/status"},{"method":"PATCH","path":"/ec_product_image/:identity/status"},{"method":"PATCH","path":"/ec_product/:identity/status"},{"method":"PATCH","path":"/ec_category/:identity/status"},{"method":"PATCH","path":"/user_account/:identity/status"},{"method":"PATCH","path":"/user_address/:identity/status"},{"method":"PATCH","path":"/user_service_relation/:identity/status"},{"method":"PATCH","path":"/gas_basic/:identity/status"},{"method":"PATCH","path":"/gas_account/:identity/status"},{"method":"PATCH","path":"/delivery_basic/:identity/status"},{"method":"PATCH","path":"/delivery_account/:identity/status"},{"method":"PATCH","path":"/staff_account/:identity/status"},{"method":"PATCH","path":"/staff_credential/:identity/status"},{"method":"PATCH","path":"/cms_content/:identity/status"},{"method":"PATCH","path":"/cs_ticket/:identity/status"},{"method":"PATCH","path":"/wallet_basic/:identity/status"},{"method":"PATCH","path":"/fin_settlement/:identity/status"},{"method":"DELETE","path":"/ec_product_attribute/:identity"},{"method":"DELETE","path":"/ec_product_image/:identity"},{"method":"DELETE","path":"/ec_product/:identity"},{"method":"DELETE","path":"/ec_category/:identity"},{"method":"DELETE","path":"/user_account/:identity"},{"method":"DELETE","path":"/user_address/:identity"},{"method":"DELETE","path":"/user_service_relation/:identity"},{"method":"DELETE","path":"/gas_basic/:identity"},{"method":"DELETE","path":"/gas_account/:identity"},{"method":"DELETE","path":"/delivery_basic/:identity"},{"method":"DELETE","path":"/delivery_account/:identity"},{"method":"DELETE","path":"/staff_account/:identity"},{"method":"DELETE","path":"/staff_credential/:identity"},{"method":"DELETE","path":"/cms_content/:identity"},{"method":"DELETE","path":"/cs_ticket/:identity"},{"method":"DELETE","path":"/platform_account/:identity"},{"method":"DELETE","path":"/platform_role/:identity"},{"method":"DELETE","path":"/fin_settlement/:identity"}]} +{"resources":[{"domain":"gas","name":"gas_basic","path":"/gas_basic","pageKind":"list","mode":"writable"},{"domain":"gas","name":"gas_account","path":"/gas_account","pageKind":"list","mode":"writable"},{"domain":"delivery","name":"delivery_basic","path":"/delivery_basic","pageKind":"list","mode":"writable"},{"domain":"delivery","name":"delivery_account","path":"/delivery_account","pageKind":"list","mode":"writable"},{"domain":"staff","name":"staff_account","path":"/staff_account","pageKind":"list","mode":"writable"},{"domain":"staff","name":"staff_credential","path":"/staff_credential","pageKind":"list","mode":"writable"},{"domain":"user","name":"user_account","path":"/user_account","pageKind":"list","mode":"writable"},{"domain":"user","name":"user_address","path":"/user_address","pageKind":"list","mode":"writable"},{"domain":"user","name":"user_service_relation","path":"/user_service_relation","pageKind":"list","mode":"writable"},{"domain":"product","name":"product_type","path":"/product_type","pageKind":"list","mode":"editable"},{"domain":"product","name":"product_warehouse","path":"/product_warehouse","pageKind":"list","mode":"editable"},{"domain":"product","name":"product_info","path":"/product_info","pageKind":"list","mode":"editable"},{"domain":"product","name":"product_repair","path":"/product_repair","pageKind":"list","mode":"editable"},{"domain":"product","name":"product_owner","path":"/product_owner","pageKind":"list","mode":"readonly"},{"domain":"ec","name":"ec_category","path":"/ec_category","pageKind":"list","mode":"writable"},{"domain":"ec","name":"ec_product","path":"/ec_product","pageKind":"list","mode":"writable"},{"domain":"ec","name":"ec_product_attribute","path":"/ec_product_attribute","pageKind":"list","mode":"writable"},{"domain":"ec","name":"ec_product_image","path":"/ec_product_image","pageKind":"list","mode":"writable"},{"domain":"ec","name":"ec_cart","path":"/ec_cart","pageKind":"list","mode":"readonly"},{"domain":"ec","name":"ec_order","path":"/ec_order","pageKind":"list","mode":"readonly"},{"domain":"ec","name":"ec_order_item","path":"/ec_order_item","pageKind":"list","mode":"readonly"},{"domain":"ec","name":"ec_review","path":"/ec_review","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_contract","path":"/gasorder_contract","pageKind":"list","mode":"managed"},{"domain":"gasorder","name":"gasorder_contract_product","path":"/gasorder_contract_product","pageKind":"list","mode":"append_only"},{"domain":"gasorder","name":"gasorder_contract_revision","path":"/gasorder_contract_revision","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_basic","path":"/gasorder_basic","pageKind":"list","mode":"append_only"},{"domain":"gasorder","name":"gasorder_item","path":"/gasorder_item","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_assign","path":"/gasorder_assign","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_status","path":"/gasorder_status","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_track","path":"/gasorder_track","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_track_point","path":"/gasorder_track_point","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_confirm","path":"/gasorder_confirm","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_payment","path":"/gasorder_payment","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"fin_payment","path":"/fin_payment","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"fin_settlement","path":"/fin_settlement","pageKind":"list","mode":"writable"},{"domain":"finance","name":"fin_reconciliation","path":"/fin_reconciliation","pageKind":"list","mode":"readonly"},{"domain":"content","name":"cms_content","path":"/cms_content","pageKind":"list","mode":"writable"},{"domain":"customer_service","name":"cs_ticket","path":"/cs_ticket","pageKind":"list","mode":"writable"},{"domain":"platform","name":"platform_account","path":"/platform_account","pageKind":"list","mode":"writable"},{"domain":"platform","name":"platform_role","path":"/platform_role","pageKind":"list","mode":"writable"},{"domain":"platform","name":"platform_menu","path":"/platform_menu","pageKind":"tree","mode":"readonly"},{"domain":"wallet","name":"wallet_basic","path":"/wallet_basic","pageKind":"list","mode":"readonly"},{"domain":"wallet","name":"wallet_bank","path":"/wallet_bank","pageKind":"list","mode":"readonly"},{"domain":"wallet","name":"wallet_payment","path":"/wallet_payment","pageKind":"list","mode":"readonly"},{"domain":"wallet","name":"wallet_record","path":"/wallet_record","pageKind":"list","mode":"readonly"},{"domain":"wallet","name":"wallet_refund","path":"/wallet_refund","pageKind":"list","mode":"readonly"},{"domain":"wallet","name":"wallet_apply_cash","path":"/wallet_apply_cash","pageKind":"list","mode":"readonly"}],"routes":[{"method":"GET","path":"/gasorder_contract"},{"method":"GET","path":"/gasorder_contract_product"},{"method":"GET","path":"/gasorder_contract_product/:identity"},{"method":"GET","path":"/gasorder_contract_revision"},{"method":"GET","path":"/gasorder_contract_revision/:identity"},{"method":"GET","path":"/gasorder_contract/:identity"},{"method":"GET","path":"/gasorder_confirm"},{"method":"GET","path":"/gasorder_confirm/:identity"},{"method":"GET","path":"/gasorder_track"},{"method":"GET","path":"/gasorder_track_point"},{"method":"GET","path":"/gasorder_track_point/:identity"},{"method":"GET","path":"/gasorder_track/:identity"},{"method":"GET","path":"/gasorder_basic"},{"method":"GET","path":"/gasorder_basic/:identity"},{"method":"GET","path":"/gasorder_item"},{"method":"GET","path":"/gasorder_item/:identity"},{"method":"GET","path":"/gasorder_assign"},{"method":"GET","path":"/gasorder_assign/:identity"},{"method":"GET","path":"/gasorder_status"},{"method":"GET","path":"/gasorder_status/:identity"},{"method":"GET","path":"/gasorder_payment"},{"method":"GET","path":"/gasorder_payment/:identity"},{"method":"GET","path":"/gas_basic"},{"method":"GET","path":"/gas_basic/:identity"},{"method":"GET","path":"/gas_account"},{"method":"GET","path":"/gas_account/:identity"},{"method":"GET","path":"/product_type"},{"method":"GET","path":"/product_type/:identity"},{"method":"GET","path":"/product_warehouse"},{"method":"GET","path":"/product_warehouse/:identity"},{"method":"GET","path":"/product_info"},{"method":"GET","path":"/product_info/:identity"},{"method":"GET","path":"/product_repair"},{"method":"GET","path":"/product_repair/:identity"},{"method":"GET","path":"/product_owner"},{"method":"GET","path":"/product_owner/:identity"},{"method":"GET","path":"/platform_role"},{"method":"GET","path":"/platform_role/:identity"},{"method":"GET","path":"/platform_role/:identity/menu"},{"method":"GET","path":"/platform_account"},{"method":"GET","path":"/platform_account/:identity"},{"method":"GET","path":"/platform_menu"},{"method":"GET","path":"/platform_menu/:identity"},{"method":"GET","path":"/ping/hello"},{"method":"GET","path":"/ec_product"},{"method":"GET","path":"/ec_product_attribute"},{"method":"GET","path":"/ec_product_attribute/:identity"},{"method":"GET","path":"/ec_product_image"},{"method":"GET","path":"/ec_product_image/:identity"},{"method":"GET","path":"/ec_product/:identity"},{"method":"GET","path":"/ec_category"},{"method":"GET","path":"/ec_category/:identity"},{"method":"GET","path":"/ec_cart"},{"method":"GET","path":"/ec_cart/:identity"},{"method":"GET","path":"/ec_order"},{"method":"GET","path":"/ec_order_item"},{"method":"GET","path":"/ec_order_item/:identity"},{"method":"GET","path":"/ec_order/:identity"},{"method":"GET","path":"/ec_review"},{"method":"GET","path":"/ec_review/:identity"},{"method":"GET","path":"/wallet_basic"},{"method":"GET","path":"/wallet_basic/:identity"},{"method":"GET","path":"/wallet_bank"},{"method":"GET","path":"/wallet_bank/:identity"},{"method":"GET","path":"/wallet_record"},{"method":"GET","path":"/wallet_record/:identity"},{"method":"GET","path":"/wallet_refund"},{"method":"GET","path":"/wallet_refund/:identity"},{"method":"GET","path":"/wallet_payment"},{"method":"GET","path":"/wallet_payment/:identity"},{"method":"GET","path":"/wallet_apply_cash"},{"method":"GET","path":"/wallet_apply_cash/:identity"},{"method":"GET","path":"/user_account"},{"method":"GET","path":"/user_account/:identity"},{"method":"GET","path":"/user_address"},{"method":"GET","path":"/user_address/:identity"},{"method":"GET","path":"/user_service_relation"},{"method":"GET","path":"/user_service_relation/:identity"},{"method":"GET","path":"/fin_payment"},{"method":"GET","path":"/fin_payment/:identity"},{"method":"GET","path":"/fin_settlement"},{"method":"GET","path":"/fin_settlement/:identity"},{"method":"GET","path":"/fin_reconciliation"},{"method":"GET","path":"/fin_reconciliation/:identity"},{"method":"GET","path":"/delivery_basic"},{"method":"GET","path":"/delivery_basic/:identity"},{"method":"GET","path":"/delivery_account"},{"method":"GET","path":"/delivery_account/:identity"},{"method":"GET","path":"/dashboard/overview"},{"method":"GET","path":"/staff_account"},{"method":"GET","path":"/staff_account/:identity"},{"method":"GET","path":"/staff_credential"},{"method":"GET","path":"/staff_credential/:identity"},{"method":"GET","path":"/cms_content"},{"method":"GET","path":"/cms_content/:identity"},{"method":"GET","path":"/cs_ticket"},{"method":"GET","path":"/cs_ticket/:identity"},{"method":"GET","path":"/auth/profile"},{"method":"POST","path":"/gasorder_basic"},{"method":"POST","path":"/gasorder_basic/:identity/assign"},{"method":"POST","path":"/gasorder_basic/:identity/awaiting-confirmation"},{"method":"POST","path":"/gasorder_basic/:identity/ready"},{"method":"POST","path":"/gasorder_basic/:identity/recover"},{"method":"POST","path":"/gasorder_basic/:identity/complete"},{"method":"POST","path":"/gasorder_basic/:identity/cancel"},{"method":"POST","path":"/gasorder_basic/:identity/filling"},{"method":"POST","path":"/gasorder_basic/:identity/delivering"},{"method":"POST","path":"/gasorder_basic/:identity/exception"},{"method":"POST","path":"/gasorder_contract"},{"method":"POST","path":"/gasorder_contract/:identity/activate"},{"method":"POST","path":"/gasorder_contract/:identity/renew"},{"method":"POST","path":"/gasorder_contract/:identity/terminate"},{"method":"POST","path":"/gasorder_contract_product"},{"method":"POST","path":"/gasorder_contract_product/:identity/unbind"},{"method":"POST","path":"/gas_basic"},{"method":"POST","path":"/gas_basic/:identity/review"},{"method":"POST","path":"/gas_account"},{"method":"POST","path":"/product_type"},{"method":"POST","path":"/product_warehouse"},{"method":"POST","path":"/product_info"},{"method":"POST","path":"/product_repair"},{"method":"POST","path":"/platform_account"},{"method":"POST","path":"/platform_role"},{"method":"POST","path":"/wallet_apply_cash/:identity/approve"},{"method":"POST","path":"/wallet_apply_cash/:identity/reject"},{"method":"POST","path":"/wallet_apply_cash/:identity/complete"},{"method":"POST","path":"/wallet_basic/owner/:owner_type/:owner_identity"},{"method":"POST","path":"/wallet_basic/:identity/recharge"},{"method":"POST","path":"/ec_product"},{"method":"POST","path":"/ec_product_attribute"},{"method":"POST","path":"/ec_product_image"},{"method":"POST","path":"/ec_category"},{"method":"POST","path":"/user_account"},{"method":"POST","path":"/user_address"},{"method":"POST","path":"/user_service_relation"},{"method":"POST","path":"/delivery_basic"},{"method":"POST","path":"/delivery_account"},{"method":"POST","path":"/staff_account"},{"method":"POST","path":"/staff_credential"},{"method":"POST","path":"/cms_content"},{"method":"POST","path":"/cs_ticket"},{"method":"POST","path":"/auth/login"},{"method":"POST","path":"/fin_settlement"},{"method":"PUT","path":"/product_type/:identity"},{"method":"PUT","path":"/product_warehouse/:identity"},{"method":"PUT","path":"/product_info/:identity"},{"method":"PUT","path":"/product_repair/:identity"},{"method":"PUT","path":"/platform_role/:identity"},{"method":"PUT","path":"/platform_role/:identity/menu"},{"method":"PUT","path":"/platform_account/:identity"},{"method":"PUT","path":"/ec_product_attribute/:identity"},{"method":"PUT","path":"/ec_product_image/:identity"},{"method":"PUT","path":"/ec_product/:identity"},{"method":"PUT","path":"/ec_category/:identity"},{"method":"PUT","path":"/gas_basic/:identity"},{"method":"PUT","path":"/gas_account/:identity"},{"method":"PUT","path":"/gasorder_contract/:identity"},{"method":"PUT","path":"/user_account/:identity"},{"method":"PUT","path":"/user_address/:identity"},{"method":"PUT","path":"/user_service_relation/:identity"},{"method":"PUT","path":"/delivery_basic/:identity"},{"method":"PUT","path":"/delivery_account/:identity"},{"method":"PUT","path":"/staff_account/:identity"},{"method":"PUT","path":"/staff_credential/:identity"},{"method":"PUT","path":"/cms_content/:identity"},{"method":"PUT","path":"/cs_ticket/:identity"},{"method":"PUT","path":"/auth/password"},{"method":"PUT","path":"/fin_settlement/:identity"},{"method":"PATCH","path":"/product_info/:identity/status"},{"method":"PATCH","path":"/product_info/:identity/lifecycle"},{"method":"PATCH","path":"/product_type/:identity/status"},{"method":"PATCH","path":"/product_warehouse/:identity/status"},{"method":"PATCH","path":"/product_repair/:identity/status"},{"method":"PATCH","path":"/platform_account/:identity/status"},{"method":"PATCH","path":"/platform_role/:identity/status"},{"method":"PATCH","path":"/ec_product_attribute/:identity/status"},{"method":"PATCH","path":"/ec_product_image/:identity/status"},{"method":"PATCH","path":"/ec_product/:identity/status"},{"method":"PATCH","path":"/ec_category/:identity/status"},{"method":"PATCH","path":"/user_account/:identity/status"},{"method":"PATCH","path":"/user_address/:identity/status"},{"method":"PATCH","path":"/user_service_relation/:identity/status"},{"method":"PATCH","path":"/gas_basic/:identity/status"},{"method":"PATCH","path":"/gas_account/:identity/status"},{"method":"PATCH","path":"/delivery_basic/:identity/status"},{"method":"PATCH","path":"/delivery_account/:identity/status"},{"method":"PATCH","path":"/staff_account/:identity/status"},{"method":"PATCH","path":"/staff_credential/:identity/status"},{"method":"PATCH","path":"/cms_content/:identity/status"},{"method":"PATCH","path":"/cs_ticket/:identity/status"},{"method":"PATCH","path":"/wallet_basic/:identity/status"},{"method":"PATCH","path":"/fin_settlement/:identity/status"},{"method":"DELETE","path":"/ec_product_attribute/:identity"},{"method":"DELETE","path":"/ec_product_image/:identity"},{"method":"DELETE","path":"/ec_product/:identity"},{"method":"DELETE","path":"/ec_category/:identity"},{"method":"DELETE","path":"/user_account/:identity"},{"method":"DELETE","path":"/user_address/:identity"},{"method":"DELETE","path":"/user_service_relation/:identity"},{"method":"DELETE","path":"/gas_basic/:identity"},{"method":"DELETE","path":"/gas_account/:identity"},{"method":"DELETE","path":"/delivery_basic/:identity"},{"method":"DELETE","path":"/delivery_account/:identity"},{"method":"DELETE","path":"/staff_account/:identity"},{"method":"DELETE","path":"/staff_credential/:identity"},{"method":"DELETE","path":"/cms_content/:identity"},{"method":"DELETE","path":"/cs_ticket/:identity"},{"method":"DELETE","path":"/platform_account/:identity"},{"method":"DELETE","path":"/platform_role/:identity"},{"method":"DELETE","path":"/fin_settlement/:identity"}]}