zyp 1 月之前
父节点
当前提交
da4b76efc0
共有 54 个文件被更改,包括 3350 次插入0 次删除
  1. 151 0
      docs/SaaS计费模块产品文档-业务简版.md
  2. 488 0
      docs/SaaS计费模块产品文档.md
  3. 119 0
      docs/租户费用模块.postman_collection.json
  4. 200 0
      docs/租户费用模块SQL-master.sql
  5. 12 0
      docs/租户费用模块SQL-tenant.sql
  6. 203 0
      docs/租户费用模块SQL.sql
  7. 78 0
      docs/计费模块菜单SQL-总账号-计费配置入口.sql
  8. 66 0
      docs/计费模块菜单SQL-总账号.sql
  9. 70 0
      docs/计费模块菜单SQL-租户账号.sql
  10. 51 0
      fs-admin/src/main/java/com/fs/billing/controller/BillingStatementController.java
  11. 55 0
      fs-admin/src/main/java/com/fs/billing/controller/FeePlanController.java
  12. 46 0
      fs-admin/src/main/java/com/fs/billing/controller/TenantBillingController.java
  13. 28 0
      fs-admin/src/main/java/com/fs/billing/controller/UsageEventController.java
  14. 36 0
      fs-admin/src/main/java/com/fs/billing/controller/WalletController.java
  15. 46 0
      fs-service/src/main/java/com/fs/billing/domain/BillingDetail.java
  16. 40 0
      fs-service/src/main/java/com/fs/billing/domain/BillingStatement.java
  17. 30 0
      fs-service/src/main/java/com/fs/billing/domain/BillingStatementItem.java
  18. 39 0
      fs-service/src/main/java/com/fs/billing/domain/FeePlan.java
  19. 32 0
      fs-service/src/main/java/com/fs/billing/domain/FeePlanFlowTier.java
  20. 38 0
      fs-service/src/main/java/com/fs/billing/domain/FeePlanItem.java
  21. 34 0
      fs-service/src/main/java/com/fs/billing/domain/TenantWallet.java
  22. 38 0
      fs-service/src/main/java/com/fs/billing/domain/TenantWalletTxn.java
  23. 38 0
      fs-service/src/main/java/com/fs/billing/domain/UsageEvent.java
  24. 75 0
      fs-service/src/main/java/com/fs/billing/dto/FeePlanRequests.java
  25. 52 0
      fs-service/src/main/java/com/fs/billing/dto/TenantBillingRequests.java
  26. 30 0
      fs-service/src/main/java/com/fs/billing/dto/UsageEventReportReq.java
  27. 38 0
      fs-service/src/main/java/com/fs/billing/mapper/BillingDetailMapper.java
  28. 8 0
      fs-service/src/main/java/com/fs/billing/mapper/BillingStatementItemMapper.java
  29. 13 0
      fs-service/src/main/java/com/fs/billing/mapper/BillingStatementMapper.java
  30. 32 0
      fs-service/src/main/java/com/fs/billing/mapper/BillingTenantMapper.java
  31. 20 0
      fs-service/src/main/java/com/fs/billing/mapper/FeePlanFlowTierMapper.java
  32. 20 0
      fs-service/src/main/java/com/fs/billing/mapper/FeePlanItemMapper.java
  33. 23 0
      fs-service/src/main/java/com/fs/billing/mapper/FeePlanMapper.java
  34. 28 0
      fs-service/src/main/java/com/fs/billing/mapper/TenantWalletMapper.java
  35. 8 0
      fs-service/src/main/java/com/fs/billing/mapper/TenantWalletTxnMapper.java
  36. 13 0
      fs-service/src/main/java/com/fs/billing/mapper/UsageEventMapper.java
  37. 40 0
      fs-service/src/main/java/com/fs/billing/service/BillingServices.java
  38. 138 0
      fs-service/src/main/java/com/fs/billing/service/impl/BillingDbSupport.java
  39. 129 0
      fs-service/src/main/java/com/fs/billing/service/impl/BillingQueryServiceImpl.java
  40. 92 0
      fs-service/src/main/java/com/fs/billing/service/impl/FeePlanServiceImpl.java
  41. 43 0
      fs-service/src/main/java/com/fs/billing/service/impl/TenantBillingServiceImpl.java
  42. 200 0
      fs-service/src/main/java/com/fs/billing/service/impl/UsageEventServiceImpl.java
  43. 76 0
      fs-service/src/main/java/com/fs/billing/service/impl/WalletServiceImpl.java
  44. 40 0
      fs-service/src/main/java/com/fs/billing/vo/BillingVos.java
  45. 69 0
      fs-service/src/main/resources/mapper/billing/BillingDetailMapper.xml
  46. 7 0
      fs-service/src/main/resources/mapper/billing/BillingStatementItemMapper.xml
  47. 13 0
      fs-service/src/main/resources/mapper/billing/BillingStatementMapper.xml
  48. 30 0
      fs-service/src/main/resources/mapper/billing/BillingTenantMapper.xml
  49. 31 0
      fs-service/src/main/resources/mapper/billing/FeePlanFlowTierMapper.xml
  50. 39 0
      fs-service/src/main/resources/mapper/billing/FeePlanItemMapper.xml
  51. 46 0
      fs-service/src/main/resources/mapper/billing/FeePlanMapper.xml
  52. 42 0
      fs-service/src/main/resources/mapper/billing/TenantWalletMapper.xml
  53. 7 0
      fs-service/src/main/resources/mapper/billing/TenantWalletTxnMapper.xml
  54. 10 0
      fs-service/src/main/resources/mapper/billing/UsageEventMapper.xml

+ 151 - 0
docs/SaaS计费模块产品文档-业务简版.md

@@ -0,0 +1,151 @@
+# SaaS计费模块产品文档(业务简版)
+
+## 1. 这套模块解决什么问题
+
+一句话:  
+平台可以统一配置收费规则,系统按租户实际使用自动计费,租户可随时查看自己的费用明细。
+
+---
+
+## 2. 角色分工
+
+## 2.1 平台总账号
+
+可以做:
+
+1. 配置计费方案(单价、阶梯、收费项);
+2. 绑定租户使用哪个方案;
+3. 查看所有租户费用明细;
+4. 生成账单。
+
+## 2.2 租户账号
+
+可以做:
+
+1. 查看自己的费用明细;
+2. 查看钱包余额;
+3. 发起充值(按授权开放)。
+
+---
+
+## 3. 计费项说明(业务口径)
+
+当前支持:
+
+1. 流量计费(FLOW)
+2. 通话计费(CALL_OUT / CALL_IN)
+3. AI 外呼附加计费(AI_CALL)
+4. SOP Token 计费(TOKEN_SOP)
+5. AI 回复 Token 计费(TOKEN_AI_REPLY)
+6. 加微计费(ADD_WECHAT)
+7. 开户费(OPEN_ACCOUNT_AI / OPEN_ACCOUNT_NON_AI)
+
+---
+
+## 4. 核心计费规则
+
+## 4.1 预付费 vs 后付费
+
+- 预付费:计费后立即从钱包扣款;
+- 后付费:先记费用,后续按账单结算。
+
+## 4.2 流量阶梯
+
+- 预付费租户按累计预存金额匹配阶梯单价;
+- 后付费租户按后付费单价计费。
+
+## 4.3 通话规则
+
+- 外呼/呼入按分钟计费;
+- 不足 1 分钟按 1 分钟计算;
+- 若为 AI 外呼,则在通话费基础上增加 AI 附加费用。
+
+## 4.4 Token 规则
+
+- 例如配置“10万 Token = 1元”;
+- 计费时按 token 用量折算金额。
+
+## 4.5 幂等规则
+
+- 同一个事件ID只计费一次;
+- 重复上报不会重复扣费。
+
+---
+
+## 5. 业务流程(简版)
+
+## 5.1 平台配置流程
+
+1. 创建方案;
+2. 录入收费项;
+3. 录入流量阶梯;
+4. 发布方案;
+5. 绑定到租户。
+
+## 5.2 日常计费流程
+
+1. 业务系统上报“用量事件”;
+2. 系统按租户方案计算金额;
+3. 写入费用明细;
+4. 预付费则自动扣钱包。
+
+## 5.3 出账流程
+
+1. 按时间区间汇总未出账明细;
+2. 生成账单;
+3. 明细标记已入账。
+
+---
+
+## 6. 页面说明
+
+## 6.1 总账号页面
+
+- 页面:费用明细(总账号)
+- 功能:查看全租户明细,可按租户筛选。
+
+## 6.2 租户页面
+
+- 页面:费用明细(我的)
+- 功能:仅查看本租户费用明细。
+
+---
+
+## 7. 数据安全口径
+
+1. 租户页面不允许手工指定租户ID;
+2. 系统按登录身份自动识别所属租户;
+3. 总账号和租户账号权限分离,避免越权查看。
+
+---
+
+## 8. 上线验收清单(业务侧)
+
+1. 平台可完成方案配置与发布;
+2. 租户绑定后可正常计费;
+3. 同一事件重复上报不重复计费;
+4. 预付费租户会正确扣减余额;
+5. 总账号可查看全部租户明细;
+6. 租户仅能查看自己明细;
+7. 账单可按区间正常生成。
+
+---
+
+## 9. 常见问题(FAQ)
+
+## Q1:为什么租户看不到别的租户数据?
+
+因为系统按登录身份自动绑定租户,接口层不接受手工传入租户ID。
+
+## Q2:重复上报会重复扣费吗?
+
+不会。事件ID幂等,重复事件会被识别并忽略。
+
+## Q3:后付费租户会立即扣钱包吗?
+
+不会。后付费只记明细,后续通过账单结算。
+
+## Q4:价格调整会影响历史数据吗?
+
+不会。历史明细按发生时绑定的方案版本计算并固化。
+

+ 488 - 0
docs/SaaS计费模块产品文档.md

@@ -0,0 +1,488 @@
+# SaaS计费模块产品文档
+
+## 1. 文档信息
+
+- 模块名称:SaaS 多租户计费模块
+- 适用系统:`ylrz_saas_his_scrm`
+- 当前状态:已完成核心计费闭环改造(方案、绑定、钱包、事件计费、明细、账单)
+- 文档用途:产品、研发、测试、运营统一对齐
+
+---
+
+## 2. 产品目标
+
+### 2.1 目标
+
+构建一套可在 SaaS 场景下稳定运行的统一计费能力,支持:
+
+1. 平台统一维护计费方案;
+2. 租户按绑定方案自动计费;
+3. 支持预付费/后付费;
+4. 全链路可追溯(事件 -> 明细 -> 钱包流水 -> 账单)。
+
+### 2.2 业务价值
+
+- 支撑商业化收费与多租户精细化运营;
+- 平台可审计全部租户费用情况;
+- 租户可自助查看自身费用明细,降低客服压力。
+
+---
+
+## 3. 角色与权限模型
+
+### 3.1 平台总账号(Admin)
+
+- 配置并发布计费方案;
+- 绑定租户计费方案;
+- 查看全部租户明细(可按租户筛选);
+- 生成账单。
+
+权限点:
+
+- `fee:billing:admin:list`
+
+### 3.2 租户账号(Tenant)
+
+- 查看“我的费用明细”;
+- 查询钱包、发起充值(按产品授权控制)。
+
+权限点:
+
+- `fee:billing:tenant:list`
+
+---
+
+## 4. 收费项设计
+
+当前实现的收费项如下:
+
+1. `FLOW`:流量计费  
+   - 预付费:按阶梯价格(基于累计预存金额);
+   - 后付费:按 `FLOW_POSTPAID` 单价。
+2. `CALL`:通话计费  
+   - 子类型:`CALL_OUT`、`CALL_IN`;
+   - 不满一分钟按一分钟;
+   - AI 外呼在通话费基础上叠加 `AI_CALL`。
+3. `TOKEN_SOP`:SOP Token 计费。
+4. `TOKEN_AI_REPLY`:AI 回复 Token 计费。
+5. `ADD_WECHAT`:按加微次数计费。
+6. `OPEN_ACCOUNT`:开户费  
+   - AI 租户:`OPEN_ACCOUNT_AI`;
+   - 非 AI 租户:`OPEN_ACCOUNT_NON_AI`。
+
+---
+
+## 5. 核心业务规则
+
+## 5.1 方案规则
+
+1. 方案唯一键:`planCode + version`;
+2. 仅 `PUBLISHED` 状态方案可用于计费;
+3. 支持多版本并行,租户绑定到具体版本。
+
+## 5.2 租户绑定规则
+
+租户绑定项:
+
+- `tenantType`(AI / NON_AI)
+- `billingMode`(PREPAID / POSTPAID)
+- `planCode`
+- `planVersion`
+
+未绑定方案时禁止执行计费。
+
+## 5.3 幂等规则
+
+`usage_event.event_id` 作为幂等键:  
+重复事件上报不重复计费。
+
+## 5.4 钱包规则
+
+- 预付费:实时扣减余额并写流水;
+- 后付费:仅记录明细,后续账单结算。
+
+## 5.5 账单规则
+
+1. 仅聚合未入账明细(`statement_id is null`);
+2. 账单项按 `event_type` 聚合;
+3. 生成后回填明细 `statement_id`,避免重复出账。
+
+---
+
+## 6. 端到端流程
+
+## 6.1 方案配置流程(平台)
+
+1. 创建方案草稿;
+2. 配置收费项;
+3. 配置流量阶梯;
+4. 发布方案;
+5. 绑定租户。
+
+## 6.2 事件计费流程(系统)
+
+1. 业务侧上报 `usage_event`;
+2. 幂等校验(`eventId`);
+3. 加载租户绑定与方案配置;
+4. 计算金额并写 `billing_detail`;
+5. 若预付费则扣钱包并写 `tenant_wallet_txn`;
+6. 返回计费结果。
+
+## 6.3 对账出账流程(平台)
+
+1. 查询明细;
+2. 指定时间区间生成账单;
+3. 账单聚合 + 明细挂账;
+4. 进入后续财务流程(支付/核销可扩展)。
+
+---
+
+## 7. 页面设计(前端)
+
+已按“两页面模型”实现:
+
+1. 总账号页:`saas/billingAdmin/index`  
+   - 查看所有租户费用明细;
+   - 支持按租户筛选。
+2. 租户页:`saas/billingTenant/index`  
+   - 仅查看当前登录租户的费用明细;
+   - 不允许手工传 `tenantId`。
+
+---
+
+## 8. 接口清单与示例
+
+以下示例统一返回结构基于项目常见 `R`:
+
+```json
+{
+  "code": 200,
+  "msg": "操作成功",
+  "data": {},
+  "rows": []
+}
+```
+
+## 8.1 方案管理
+
+### 8.1.1 创建方案
+
+- `POST /api/fee/plan/create`
+
+请求示例:
+
+```json
+{
+  "planCode": "STANDARD",
+  "planName": "标准方案",
+  "version": 1,
+  "remark": "默认标准方案"
+}
+```
+
+响应示例:
+
+```json
+{
+  "code": 200,
+  "msg": "创建成功"
+}
+```
+
+### 8.1.2 保存收费项
+
+- `POST /api/fee/plan/item/save`
+
+请求示例:
+
+```json
+{
+  "planCode": "STANDARD",
+  "version": 1,
+  "items": [
+    {
+      "itemCode": "FLOW_POSTPAID",
+      "unit": "KB",
+      "unitPrice": 0.2,
+      "tokenUnit": 100000,
+      "minChargeUnit": 1,
+      "enabled": 1
+    },
+    {
+      "itemCode": "CALL_OUT",
+      "unit": "MIN",
+      "unitPrice": 0.3,
+      "enabled": 1
+    }
+  ]
+}
+```
+
+### 8.1.3 保存流量阶梯
+
+- `POST /api/fee/plan/flow-tier/save`
+
+请求示例:
+
+```json
+{
+  "planCode": "STANDARD",
+  "version": 1,
+  "tiers": [
+    {
+      "minPrepayAmount": 0,
+      "maxPrepayAmount": 100000,
+      "unitPrice": 0.1,
+      "sortNo": 1
+    },
+    {
+      "minPrepayAmount": 100000,
+      "maxPrepayAmount": 200000,
+      "unitPrice": 0.08,
+      "sortNo": 2
+    }
+  ]
+}
+```
+
+### 8.1.4 发布方案
+
+- `POST /api/fee/plan/publish?planCode=STANDARD&version=1`
+
+---
+
+## 8.2 租户绑定
+
+### 8.2.1 绑定方案
+
+- `POST /api/fee/tenant/bind-plan`
+
+请求示例:
+
+```json
+{
+  "tenantId": 1001,
+  "tenantType": "NON_AI",
+  "billingMode": "PREPAID",
+  "planCode": "STANDARD",
+  "planVersion": 1
+}
+```
+
+### 8.2.2 切换计费模式
+
+- `POST /api/fee/tenant/change-billing-mode`
+
+请求示例:
+
+```json
+{
+  "tenantId": 1001,
+  "billingMode": "POSTPAID"
+}
+```
+
+### 8.2.3 切换租户类型
+
+- `POST /api/fee/tenant/change-type`
+
+请求示例:
+
+```json
+{
+  "tenantId": 1001,
+  "tenantType": "AI"
+}
+```
+
+---
+
+## 8.3 钱包
+
+### 8.3.1 查询钱包
+
+- `GET /api/fee/wallet/{tenantId}`
+
+响应示例:
+
+```json
+{
+  "code": 200,
+  "data": {
+    "tenantId": 1001,
+    "balanceAmount": 12345.67,
+    "totalRecharge": 50000,
+    "totalCost": 37654.33
+  }
+}
+```
+
+### 8.3.2 充值
+
+- `POST /api/fee/wallet/recharge`
+
+请求示例:
+
+```json
+{
+  "tenantId": 1001,
+  "amount": 10000,
+  "bizNo": "RC202604210001",
+  "remark": "线下转账充值"
+}
+```
+
+---
+
+## 8.4 用量上报
+
+### 8.4.1 上报并计费
+
+- `POST /api/fee/usage/report`
+
+请求示例:
+
+```json
+{
+  "eventId": "EVT_20260421_0001",
+  "tenantId": 1001,
+  "eventType": "CALL",
+  "subType": "CALL_OUT",
+  "bizId": "ORDER_001",
+  "usageValue": 125,
+  "usageUnit": "SECOND",
+  "occurredAt": "2026-04-21 15:30:00",
+  "extJson": {
+    "isAiCall": true
+  }
+}
+```
+
+响应示例:
+
+```json
+{
+  "code": 200,
+  "data": {
+    "eventId": "EVT_20260421_0001",
+    "charged": true,
+    "amount": 2.4,
+    "message": "计费成功"
+  }
+}
+```
+
+---
+
+## 8.5 明细与账单
+
+### 8.5.1 平台查询明细(可看全租户)
+
+- `GET /api/fee/billing/detail/list`
+- 参数:`tenantId`(可选)
+
+示例:
+
+- `GET /api/fee/billing/detail/list`(全部)
+- `GET /api/fee/billing/detail/list?tenantId=1001`(指定租户)
+
+### 8.5.2 租户查询我的明细
+
+- `GET /api/fee/billing/detail/my`
+- 不接收 tenantId,由后端从登录态获取
+
+### 8.5.3 生成账单
+
+- `POST /api/fee/statement/generate`
+- 参数:`tenantId`、`periodType`、`periodStart`、`periodEnd`
+
+示例:
+
+`POST /api/fee/statement/generate?tenantId=1001&periodType=MONTH&periodStart=2026-04-01%2000:00:00&periodEnd=2026-04-30%2023:59:59`
+
+响应示例:
+
+```json
+{
+  "code": 200,
+  "data": {
+    "statementId": 88,
+    "statementNo": "ST1713693258123",
+    "periodType": "MONTH",
+    "periodStart": "2026-04-01 00:00:00",
+    "periodEnd": "2026-04-30 23:59:59",
+    "totalAmount": 1299.35
+  }
+}
+```
+
+---
+
+## 9. 数据模型
+
+## 9.1 方案
+
+- `fee_plan`
+- `fee_plan_item`
+- `fee_plan_flow_tier`
+
+## 9.2 租户绑定
+
+- `tenant_info`(新增计费相关字段)
+  - `tenant_type`
+  - `billing_mode`
+  - `fee_plan_code`
+  - `fee_plan_version`
+
+## 9.3 钱包
+
+- `tenant_wallet`
+- `tenant_wallet_txn`
+
+## 9.4 计费主链路
+
+- `usage_event`
+- `billing_detail`
+- `billing_statement`
+- `billing_statement_item`
+
+---
+
+## 10. 安全与数据隔离
+
+1. 明细查询按权限点分流;
+2. 租户端明细接口不接受 `tenantId`;
+3. 统一通过登录态解析租户 ID;
+4. 防止前端参数篡改导致跨租户越权查询。
+
+---
+
+## 11. 监控与审计建议
+
+1. 监控 `usage_event` 入库量与重复率;
+2. 监控钱包扣费失败告警;
+3. 监控账单生成耗时和失败率;
+4. 保留事件、明细、流水的关联查询能力,支持财务审计。
+
+---
+
+## 12. 验收标准(UAT)
+
+1. 方案创建/配置/发布可用;
+2. 租户绑定后可正常计费;
+3. 重复事件不会重复扣费;
+4. 预付费会扣钱包并生成流水;
+5. 总账号可看全租户明细;
+6. 租户仅能看本人租户明细;
+7. 账单生成后明细正确挂账。
+
+---
+
+## 13. 版本演进建议
+
+1. 增加账单支付、核销、坏账流程;
+2. 增加账单列表与账单详情 API;
+3. 增加按天/月统计报表与导出;
+4. 用量上报支持 MQ 异步削峰;
+5. 引入欠费阈值与停服策略。
+

+ 119 - 0
docs/租户费用模块.postman_collection.json

@@ -0,0 +1,119 @@
+{
+  "info": {
+    "_postman_id": "a0f8f4c6-3e22-4bd4-a3c8-billing-module",
+    "name": "租户费用模块联调集合",
+    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
+  },
+  "item": [
+    {
+      "name": "1. 创建方案",
+      "request": {
+        "method": "POST",
+        "header": [{ "key": "Content-Type", "value": "application/json" }],
+        "url": { "raw": "{{baseUrl}}/api/fee/plan/create", "host": ["{{baseUrl}}"], "path": ["api","fee","plan","create"] },
+        "body": {
+          "mode": "raw",
+          "raw": "{\n  \"planCode\": \"STANDARD\",\n  \"planName\": \"标准方案\",\n  \"version\": 1,\n  \"remark\": \"postman初始化\"\n}"
+        }
+      }
+    },
+    {
+      "name": "2. 保存计费项",
+      "request": {
+        "method": "POST",
+        "header": [{ "key": "Content-Type", "value": "application/json" }],
+        "url": { "raw": "{{baseUrl}}/api/fee/plan/item/save", "host": ["{{baseUrl}}"], "path": ["api","fee","plan","item","save"] },
+        "body": {
+          "mode": "raw",
+          "raw": "{\n  \"planCode\": \"STANDARD\",\n  \"version\": 1,\n  \"items\": [\n    {\"itemCode\":\"FLOW_POSTPAID\",\"unit\":\"GB\",\"unitPrice\":0.2,\"enabled\":1},\n    {\"itemCode\":\"CALL_OUT\",\"unit\":\"MIN\",\"unitPrice\":0.3,\"minChargeUnit\":1,\"enabled\":1},\n    {\"itemCode\":\"CALL_IN\",\"unit\":\"MIN\",\"unitPrice\":0.2,\"minChargeUnit\":1,\"enabled\":1},\n    {\"itemCode\":\"AI_CALL\",\"unit\":\"MIN\",\"unitPrice\":0.15,\"minChargeUnit\":1,\"enabled\":1},\n    {\"itemCode\":\"SOP_TOKEN\",\"unit\":\"TOKEN\",\"unitPrice\":1,\"tokenUnit\":100000,\"enabled\":1},\n    {\"itemCode\":\"AI_REPLY_TOKEN\",\"unit\":\"TOKEN\",\"unitPrice\":1,\"tokenUnit\":100000,\"enabled\":1},\n    {\"itemCode\":\"ADD_WECHAT\",\"unit\":\"COUNT\",\"unitPrice\":0.5,\"enabled\":1},\n    {\"itemCode\":\"OPEN_ACCOUNT_NON_AI\",\"unit\":\"TIME\",\"unitPrice\":1000,\"enabled\":1},\n    {\"itemCode\":\"OPEN_ACCOUNT_AI\",\"unit\":\"TIME\",\"unitPrice\":3000,\"enabled\":1}\n  ]\n}"
+        }
+      }
+    },
+    {
+      "name": "3. 保存流量阶梯",
+      "request": {
+        "method": "POST",
+        "header": [{ "key": "Content-Type", "value": "application/json" }],
+        "url": { "raw": "{{baseUrl}}/api/fee/plan/flow-tier/save", "host": ["{{baseUrl}}"], "path": ["api","fee","plan","flow-tier","save"] },
+        "body": {
+          "mode": "raw",
+          "raw": "{\n  \"planCode\":\"STANDARD\",\n  \"version\":1,\n  \"tiers\":[\n    {\"minPrepayAmount\":100000,\"maxPrepayAmount\":200000,\"unitPrice\":0.1,\"sortNo\":1},\n    {\"minPrepayAmount\":200000,\"maxPrepayAmount\":null,\"unitPrice\":0.08,\"sortNo\":2}\n  ]\n}"
+        }
+      }
+    },
+    {
+      "name": "4. 发布方案",
+      "request": {
+        "method": "POST",
+        "url": { "raw": "{{baseUrl}}/api/fee/plan/publish?planCode=STANDARD&version=1", "host": ["{{baseUrl}}"], "path": ["api","fee","plan","publish"], "query": [{"key":"planCode","value":"STANDARD"},{"key":"version","value":"1"}] }
+      }
+    },
+    {
+      "name": "5. 绑定租户方案",
+      "request": {
+        "method": "POST",
+        "header": [{ "key": "Content-Type", "value": "application/json" }],
+        "url": { "raw": "{{baseUrl}}/api/fee/tenant/bind-plan", "host": ["{{baseUrl}}"], "path": ["api","fee","tenant","bind-plan"] },
+        "body": {
+          "mode": "raw",
+          "raw": "{\n  \"tenantId\": 1,\n  \"tenantType\": \"AI\",\n  \"billingMode\": \"PREPAID\",\n  \"planCode\": \"STANDARD\",\n  \"planVersion\": 1\n}"
+        }
+      }
+    },
+    {
+      "name": "6. 钱包充值",
+      "request": {
+        "method": "POST",
+        "header": [{ "key": "Content-Type", "value": "application/json" }],
+        "url": { "raw": "{{baseUrl}}/api/fee/wallet/recharge", "host": ["{{baseUrl}}"], "path": ["api","fee","wallet","recharge"] },
+        "body": {
+          "mode": "raw",
+          "raw": "{\n  \"tenantId\": 1,\n  \"amount\": 100000,\n  \"bizNo\": \"RC001\",\n  \"remark\": \"预存充值\"\n}"
+        }
+      }
+    },
+    {
+      "name": "7. 上报CALL事件(AI外呼)",
+      "request": {
+        "method": "POST",
+        "header": [{ "key": "Content-Type", "value": "application/json" }],
+        "url": { "raw": "{{baseUrl}}/api/fee/usage/report", "host": ["{{baseUrl}}"], "path": ["api","fee","usage","report"] },
+        "body": {
+          "mode": "raw",
+          "raw": "{\n  \"eventId\": \"evt_call_001\",\n  \"tenantId\": 1,\n  \"eventType\": \"CALL\",\n  \"subType\": \"CALL_OUT\",\n  \"bizId\": \"callBiz001\",\n  \"usageValue\": 47,\n  \"usageUnit\": \"SECOND\",\n  \"extJson\": {\"isAiCall\": true}\n}"
+        }
+      }
+    },
+    {
+      "name": "8. 查询计费明细",
+      "request": {
+        "method": "GET",
+        "url": { "raw": "{{baseUrl}}/api/fee/billing/detail/list?tenantId=1", "host": ["{{baseUrl}}"], "path": ["api","fee","billing","detail","list"], "query": [{"key":"tenantId","value":"1"}] }
+      }
+    },
+    {
+      "name": "9. 生成账单",
+      "request": {
+        "method": "POST",
+        "url": {
+          "raw": "{{baseUrl}}/api/fee/statement/generate?tenantId=1&periodType=MONTHLY&periodStart=2026-04-01%2000:00:00&periodEnd=2026-04-30%2023:59:59",
+          "host": ["{{baseUrl}}"],
+          "path": ["api","fee","statement","generate"],
+          "query": [
+            {"key":"tenantId","value":"1"},
+            {"key":"periodType","value":"MONTHLY"},
+            {"key":"periodStart","value":"2026-04-01 00:00:00"},
+            {"key":"periodEnd","value":"2026-04-30 23:59:59"}
+          ]
+        }
+      }
+    }
+  ],
+  "variable": [
+    {
+      "key": "baseUrl",
+      "value": "http://127.0.0.1:8004"
+    }
+  ]
+}
+

+ 200 - 0
docs/租户费用模块SQL-master.sql

@@ -0,0 +1,200 @@
+-- 租户费用模块 SQL(总库)
+-- 适用数据库:MySQL 8.x
+-- 说明:
+-- 1) 当前计费模块采用“总库集中存储(按 tenant_id 隔离)”;
+-- 2) 本文件应在总库执行;
+-- 3) 所有金额统一使用 DECIMAL,避免浮点误差。
+
+/* =========================================================
+   0. 租户表扩展(tenant_info)
+   ========================================================= */
+ALTER TABLE tenant_info
+ADD COLUMN tenant_type VARCHAR(16) NOT NULL DEFAULT 'NON_AI' COMMENT '租户类型: NON_AI/AI',
+ADD COLUMN billing_mode VARCHAR(16) NOT NULL DEFAULT 'PREPAID' COMMENT '计费模式: PREPAID/POSTPAID',
+ADD COLUMN fee_plan_code VARCHAR(64) DEFAULT NULL COMMENT '绑定计费方案编码',
+ADD COLUMN fee_plan_version INT DEFAULT NULL COMMENT '绑定计费方案版本';
+
+/* =========================================================
+   1. 计费方案
+   ========================================================= */
+CREATE TABLE IF NOT EXISTS fee_plan (
+  id BIGINT NOT NULL AUTO_INCREMENT,
+  plan_code VARCHAR(64) NOT NULL COMMENT '方案编码',
+  plan_name VARCHAR(128) NOT NULL COMMENT '方案名称',
+  version INT NOT NULL COMMENT '版本号',
+  status VARCHAR(16) NOT NULL DEFAULT 'DRAFT' COMMENT 'DRAFT/PUBLISHED/ARCHIVED',
+  effective_time DATETIME DEFAULT NULL COMMENT '生效时间',
+  expire_time DATETIME DEFAULT NULL COMMENT '失效时间',
+  remark VARCHAR(500) DEFAULT NULL,
+  create_by VARCHAR(64) DEFAULT NULL,
+  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  update_by VARCHAR(64) DEFAULT NULL,
+  update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_plan_ver (plan_code, version),
+  KEY idx_status (status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='计费方案主表';
+
+CREATE TABLE IF NOT EXISTS fee_plan_item (
+  id BIGINT NOT NULL AUTO_INCREMENT,
+  plan_code VARCHAR(64) NOT NULL,
+  version INT NOT NULL,
+  item_code VARCHAR(64) NOT NULL COMMENT 'FLOW_POSTPAID/CALL_OUT/CALL_IN/AI_CALL/SOP_TOKEN/AI_REPLY_TOKEN/ADD_WECHAT/OPEN_ACCOUNT_NON_AI/OPEN_ACCOUNT_AI',
+  unit VARCHAR(32) NOT NULL COMMENT 'GB/MIN/TOKEN/COUNT/TIME',
+  unit_price DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '单价',
+  token_unit BIGINT DEFAULT NULL COMMENT 'token计费单位(如100000)',
+  min_charge_unit INT DEFAULT NULL COMMENT '最小计费单位(如通话最小1分钟)',
+  enabled TINYINT NOT NULL DEFAULT 1,
+  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_plan_item (plan_code, version, item_code)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='计费项配置';
+
+CREATE TABLE IF NOT EXISTS fee_plan_flow_tier (
+  id BIGINT NOT NULL AUTO_INCREMENT,
+  plan_code VARCHAR(64) NOT NULL,
+  version INT NOT NULL,
+  min_prepay_amount DECIMAL(18,2) NOT NULL COMMENT '最低预存金额(含)',
+  max_prepay_amount DECIMAL(18,2) DEFAULT NULL COMMENT '最高预存金额(不含,空为无上限)',
+  unit_price DECIMAL(18,6) NOT NULL COMMENT '流量单价(元/GB)',
+  sort_no INT NOT NULL DEFAULT 0,
+  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_tier (plan_code, version, min_prepay_amount)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='流量阶梯配置';
+
+/* =========================================================
+   2. 钱包与流水
+   ========================================================= */
+CREATE TABLE IF NOT EXISTS tenant_wallet (
+  id BIGINT NOT NULL AUTO_INCREMENT,
+  tenant_id BIGINT NOT NULL,
+  balance_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '可用余额',
+  frozen_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '冻结金额',
+  credit_limit DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '授信额度(后付费可用)',
+  total_recharge DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '累计充值',
+  total_cost DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '累计消费',
+  update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_tenant_wallet (tenant_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户钱包';
+
+CREATE TABLE IF NOT EXISTS tenant_wallet_txn (
+  id BIGINT NOT NULL AUTO_INCREMENT,
+  tenant_id BIGINT NOT NULL,
+  txn_no VARCHAR(64) NOT NULL,
+  txn_type VARCHAR(32) NOT NULL COMMENT 'RECHARGE/CONSUME/ADJUST/REFUND',
+  amount DECIMAL(18,2) NOT NULL COMMENT '消费建议记录为负数',
+  balance_after DECIMAL(18,2) NOT NULL,
+  biz_type VARCHAR(64) DEFAULT NULL COMMENT 'FLOW/CALL/TOKEN/ADD_WECHAT/OPEN_ACCOUNT',
+  biz_id VARCHAR(64) DEFAULT NULL COMMENT '关联业务ID',
+  remark VARCHAR(500) DEFAULT NULL,
+  create_by VARCHAR(64) DEFAULT NULL,
+  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_txn_no (txn_no),
+  KEY idx_tenant_time (tenant_id, create_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='钱包流水';
+
+/* =========================================================
+   3. 用量事件与计费明细
+   ========================================================= */
+CREATE TABLE IF NOT EXISTS usage_event (
+  id BIGINT NOT NULL AUTO_INCREMENT,
+  event_id VARCHAR(64) NOT NULL COMMENT '幂等ID',
+  tenant_id BIGINT NOT NULL,
+  event_type VARCHAR(32) NOT NULL COMMENT 'FLOW/CALL/TOKEN_SOP/TOKEN_AI_REPLY/ADD_WECHAT/OPEN_ACCOUNT',
+  sub_type VARCHAR(32) DEFAULT NULL COMMENT 'CALL_IN/CALL_OUT/AI_CALL 等',
+  biz_id VARCHAR(64) DEFAULT NULL,
+  usage_value DECIMAL(20,6) NOT NULL,
+  usage_unit VARCHAR(32) NOT NULL COMMENT 'GB/MB/SECOND/TOKEN/COUNT',
+  occurred_at DATETIME NOT NULL,
+  ext_json JSON DEFAULT NULL,
+  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_event_id (event_id),
+  KEY idx_tenant_type_time (tenant_id, event_type, occurred_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用量事件';
+
+CREATE TABLE IF NOT EXISTS billing_detail (
+  id BIGINT NOT NULL AUTO_INCREMENT,
+  tenant_id BIGINT NOT NULL,
+  statement_id BIGINT DEFAULT NULL,
+  event_id VARCHAR(64) NOT NULL,
+  event_type VARCHAR(32) NOT NULL,
+  sub_type VARCHAR(32) DEFAULT NULL,
+  plan_code VARCHAR(64) NOT NULL,
+  plan_version INT NOT NULL,
+  unit_price DECIMAL(18,6) NOT NULL,
+  usage_value DECIMAL(20,6) NOT NULL,
+  charge_value DECIMAL(20,6) NOT NULL COMMENT '计费量,如向上取整后的分钟',
+  amount DECIMAL(18,2) NOT NULL,
+  billing_mode VARCHAR(16) NOT NULL COMMENT 'PREPAID/POSTPAID',
+  occurred_at DATETIME NOT NULL,
+  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_event_type_sub (event_id, event_type, sub_type),
+  KEY idx_tenant_time (tenant_id, occurred_at),
+  KEY idx_statement (statement_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='计费明细';
+
+/* =========================================================
+   4. 账单
+   ========================================================= */
+CREATE TABLE IF NOT EXISTS billing_statement (
+  id BIGINT NOT NULL AUTO_INCREMENT,
+  statement_no VARCHAR(64) NOT NULL,
+  tenant_id BIGINT NOT NULL,
+  period_type VARCHAR(16) NOT NULL COMMENT 'DAILY/MONTHLY',
+  period_start DATETIME NOT NULL,
+  period_end DATETIME NOT NULL,
+  total_amount DECIMAL(18,2) NOT NULL DEFAULT 0,
+  paid_amount DECIMAL(18,2) NOT NULL DEFAULT 0,
+  unpaid_amount DECIMAL(18,2) NOT NULL DEFAULT 0,
+  status VARCHAR(16) NOT NULL DEFAULT 'INIT' COMMENT 'INIT/CONFIRMED/PAID/PARTIAL',
+  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_statement_no (statement_no),
+  KEY idx_tenant_period (tenant_id, period_start, period_end)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='账单主表';
+
+CREATE TABLE IF NOT EXISTS billing_statement_item (
+  id BIGINT NOT NULL AUTO_INCREMENT,
+  statement_id BIGINT NOT NULL,
+  item_code VARCHAR(64) NOT NULL,
+  amount DECIMAL(18,2) NOT NULL DEFAULT 0,
+  usage_value DECIMAL(20,6) NOT NULL DEFAULT 0,
+  unit VARCHAR(32) DEFAULT NULL,
+  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_stmt_item (statement_id, item_code),
+  KEY idx_statement_id (statement_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='账单项汇总';
+
+/* =========================================================
+   5. 初始化示例数据(可选)
+   ========================================================= */
+INSERT INTO fee_plan(plan_code, plan_name, version, status, effective_time, remark)
+VALUES ('STANDARD', '标准计费方案', 1, 'PUBLISHED', NOW(), '默认方案')
+ON DUPLICATE KEY UPDATE plan_name=VALUES(plan_name), status=VALUES(status), effective_time=VALUES(effective_time), remark=VALUES(remark);
+
+INSERT INTO fee_plan_item(plan_code, version, item_code, unit, unit_price, min_charge_unit, token_unit, enabled) VALUES
+('STANDARD',1,'FLOW_POSTPAID','GB',0.20,NULL,NULL,1),
+('STANDARD',1,'CALL_OUT','MIN',0.30,1,NULL,1),
+('STANDARD',1,'CALL_IN','MIN',0.20,1,NULL,1),
+('STANDARD',1,'AI_CALL','MIN',0.15,1,NULL,1),
+('STANDARD',1,'SOP_TOKEN','TOKEN',1.00,NULL,100000,1),
+('STANDARD',1,'AI_REPLY_TOKEN','TOKEN',1.00,NULL,100000,1),
+('STANDARD',1,'ADD_WECHAT','COUNT',0.50,NULL,NULL,1),
+('STANDARD',1,'OPEN_ACCOUNT_NON_AI','TIME',1000.00,NULL,NULL,1),
+('STANDARD',1,'OPEN_ACCOUNT_AI','TIME',3000.00,NULL,NULL,1)
+ON DUPLICATE KEY UPDATE unit=VALUES(unit), unit_price=VALUES(unit_price), min_charge_unit=VALUES(min_charge_unit), token_unit=VALUES(token_unit), enabled=VALUES(enabled);
+
+INSERT INTO fee_plan_flow_tier(plan_code, version, min_prepay_amount, max_prepay_amount, unit_price, sort_no) VALUES
+('STANDARD',1,100000,200000,0.10,1),
+('STANDARD',1,200000,NULL,0.08,2)
+ON DUPLICATE KEY UPDATE max_prepay_amount=VALUES(max_prepay_amount), unit_price=VALUES(unit_price), sort_no=VALUES(sort_no);
+

+ 12 - 0
docs/租户费用模块SQL-tenant.sql

@@ -0,0 +1,12 @@
+-- 租户费用模块 SQL(租户库)
+-- 适用数据库:MySQL 8.x
+-- 说明:
+-- 1) 按当前版本实现,计费数据集中存储在总库,按 tenant_id 逻辑隔离;
+-- 2) 因此本版本“租户库”无新增计费表结构;
+-- 3) 保留此文件用于后续版本(若改为租户库分片计费可在此追加)。
+
+/* =========================================================
+   当前版本无租户库 DDL 变更
+   ========================================================= */
+-- NO-OP
+

+ 203 - 0
docs/租户费用模块SQL.sql

@@ -0,0 +1,203 @@
+-- 租户费用模块 SQL(MVP)
+-- 适用数据库:MySQL 8.x
+-- 说明:
+-- 1) 先执行租户扩展字段,再执行计费基础表;
+-- 2) 初始化示例数据可按需执行;
+-- 3) 所有金额统一使用 DECIMAL,避免浮点误差。
+-- 4) 已按库类型拆分为:
+--    - docs/租户费用模块SQL-master.sql(总库执行)
+--    - docs/租户费用模块SQL-tenant.sql(租户库执行)
+
+/* =========================================================
+   0. 租户表扩展(tenant_info)
+   ========================================================= */
+ALTER TABLE tenant_info
+ADD COLUMN tenant_type VARCHAR(16) NOT NULL DEFAULT 'NON_AI' COMMENT '租户类型: NON_AI/AI',
+ADD COLUMN billing_mode VARCHAR(16) NOT NULL DEFAULT 'PREPAID' COMMENT '计费模式: PREPAID/POSTPAID',
+ADD COLUMN fee_plan_code VARCHAR(64) DEFAULT NULL COMMENT '绑定计费方案编码',
+ADD COLUMN fee_plan_version INT DEFAULT NULL COMMENT '绑定计费方案版本';
+
+/* =========================================================
+   1. 计费方案
+   ========================================================= */
+CREATE TABLE IF NOT EXISTS fee_plan (
+  id BIGINT NOT NULL AUTO_INCREMENT,
+  plan_code VARCHAR(64) NOT NULL COMMENT '方案编码',
+  plan_name VARCHAR(128) NOT NULL COMMENT '方案名称',
+  version INT NOT NULL COMMENT '版本号',
+  status VARCHAR(16) NOT NULL DEFAULT 'DRAFT' COMMENT 'DRAFT/PUBLISHED/ARCHIVED',
+  effective_time DATETIME DEFAULT NULL COMMENT '生效时间',
+  expire_time DATETIME DEFAULT NULL COMMENT '失效时间',
+  remark VARCHAR(500) DEFAULT NULL,
+  create_by VARCHAR(64) DEFAULT NULL,
+  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  update_by VARCHAR(64) DEFAULT NULL,
+  update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_plan_ver (plan_code, version),
+  KEY idx_status (status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='计费方案主表';
+
+CREATE TABLE IF NOT EXISTS fee_plan_item (
+  id BIGINT NOT NULL AUTO_INCREMENT,
+  plan_code VARCHAR(64) NOT NULL,
+  version INT NOT NULL,
+  item_code VARCHAR(64) NOT NULL COMMENT 'FLOW_POSTPAID/CALL_OUT/CALL_IN/AI_CALL/SOP_TOKEN/AI_REPLY_TOKEN/ADD_WECHAT/OPEN_ACCOUNT_NON_AI/OPEN_ACCOUNT_AI',
+  unit VARCHAR(32) NOT NULL COMMENT 'GB/MIN/TOKEN/COUNT/TIME',
+  unit_price DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '单价',
+  token_unit BIGINT DEFAULT NULL COMMENT 'token计费单位(如100000)',
+  min_charge_unit INT DEFAULT NULL COMMENT '最小计费单位(如通话最小1分钟)',
+  enabled TINYINT NOT NULL DEFAULT 1,
+  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_plan_item (plan_code, version, item_code)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='计费项配置';
+
+CREATE TABLE IF NOT EXISTS fee_plan_flow_tier (
+  id BIGINT NOT NULL AUTO_INCREMENT,
+  plan_code VARCHAR(64) NOT NULL,
+  version INT NOT NULL,
+  min_prepay_amount DECIMAL(18,2) NOT NULL COMMENT '最低预存金额(含)',
+  max_prepay_amount DECIMAL(18,2) DEFAULT NULL COMMENT '最高预存金额(不含,空为无上限)',
+  unit_price DECIMAL(18,6) NOT NULL COMMENT '流量单价(元/GB)',
+  sort_no INT NOT NULL DEFAULT 0,
+  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_tier (plan_code, version, min_prepay_amount)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='流量阶梯配置';
+
+/* =========================================================
+   2. 钱包与流水
+   ========================================================= */
+CREATE TABLE IF NOT EXISTS tenant_wallet (
+  id BIGINT NOT NULL AUTO_INCREMENT,
+  tenant_id BIGINT NOT NULL,
+  balance_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '可用余额',
+  frozen_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '冻结金额',
+  credit_limit DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '授信额度(后付费可用)',
+  total_recharge DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '累计充值',
+  total_cost DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '累计消费',
+  update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_tenant_wallet (tenant_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户钱包';
+
+CREATE TABLE IF NOT EXISTS tenant_wallet_txn (
+  id BIGINT NOT NULL AUTO_INCREMENT,
+  tenant_id BIGINT NOT NULL,
+  txn_no VARCHAR(64) NOT NULL,
+  txn_type VARCHAR(32) NOT NULL COMMENT 'RECHARGE/CONSUME/ADJUST/REFUND',
+  amount DECIMAL(18,2) NOT NULL COMMENT '消费建议记录为负数',
+  balance_after DECIMAL(18,2) NOT NULL,
+  biz_type VARCHAR(64) DEFAULT NULL COMMENT 'FLOW/CALL/TOKEN/ADD_WECHAT/OPEN_ACCOUNT',
+  biz_id VARCHAR(64) DEFAULT NULL COMMENT '关联业务ID',
+  remark VARCHAR(500) DEFAULT NULL,
+  create_by VARCHAR(64) DEFAULT NULL,
+  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_txn_no (txn_no),
+  KEY idx_tenant_time (tenant_id, create_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='钱包流水';
+
+/* =========================================================
+   3. 用量事件与计费明细
+   ========================================================= */
+CREATE TABLE IF NOT EXISTS usage_event (
+  id BIGINT NOT NULL AUTO_INCREMENT,
+  event_id VARCHAR(64) NOT NULL COMMENT '幂等ID',
+  tenant_id BIGINT NOT NULL,
+  event_type VARCHAR(32) NOT NULL COMMENT 'FLOW/CALL/TOKEN_SOP/TOKEN_AI_REPLY/ADD_WECHAT/OPEN_ACCOUNT',
+  sub_type VARCHAR(32) DEFAULT NULL COMMENT 'CALL_IN/CALL_OUT/AI_CALL 等',
+  biz_id VARCHAR(64) DEFAULT NULL,
+  usage_value DECIMAL(20,6) NOT NULL,
+  usage_unit VARCHAR(32) NOT NULL COMMENT 'GB/MB/SECOND/TOKEN/COUNT',
+  occurred_at DATETIME NOT NULL,
+  ext_json JSON DEFAULT NULL,
+  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_event_id (event_id),
+  KEY idx_tenant_type_time (tenant_id, event_type, occurred_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用量事件';
+
+CREATE TABLE IF NOT EXISTS billing_detail (
+  id BIGINT NOT NULL AUTO_INCREMENT,
+  tenant_id BIGINT NOT NULL,
+  statement_id BIGINT DEFAULT NULL,
+  event_id VARCHAR(64) NOT NULL,
+  event_type VARCHAR(32) NOT NULL,
+  sub_type VARCHAR(32) DEFAULT NULL,
+  plan_code VARCHAR(64) NOT NULL,
+  plan_version INT NOT NULL,
+  unit_price DECIMAL(18,6) NOT NULL,
+  usage_value DECIMAL(20,6) NOT NULL,
+  charge_value DECIMAL(20,6) NOT NULL COMMENT '计费量,如向上取整后的分钟',
+  amount DECIMAL(18,2) NOT NULL,
+  billing_mode VARCHAR(16) NOT NULL COMMENT 'PREPAID/POSTPAID',
+  occurred_at DATETIME NOT NULL,
+  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_event_type_sub (event_id, event_type, sub_type),
+  KEY idx_tenant_time (tenant_id, occurred_at),
+  KEY idx_statement (statement_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='计费明细';
+
+/* =========================================================
+   4. 账单
+   ========================================================= */
+CREATE TABLE IF NOT EXISTS billing_statement (
+  id BIGINT NOT NULL AUTO_INCREMENT,
+  statement_no VARCHAR(64) NOT NULL,
+  tenant_id BIGINT NOT NULL,
+  period_type VARCHAR(16) NOT NULL COMMENT 'DAILY/MONTHLY',
+  period_start DATETIME NOT NULL,
+  period_end DATETIME NOT NULL,
+  total_amount DECIMAL(18,2) NOT NULL DEFAULT 0,
+  paid_amount DECIMAL(18,2) NOT NULL DEFAULT 0,
+  unpaid_amount DECIMAL(18,2) NOT NULL DEFAULT 0,
+  status VARCHAR(16) NOT NULL DEFAULT 'INIT' COMMENT 'INIT/CONFIRMED/PAID/PARTIAL',
+  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_statement_no (statement_no),
+  KEY idx_tenant_period (tenant_id, period_start, period_end)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='账单主表';
+
+CREATE TABLE IF NOT EXISTS billing_statement_item (
+  id BIGINT NOT NULL AUTO_INCREMENT,
+  statement_id BIGINT NOT NULL,
+  item_code VARCHAR(64) NOT NULL,
+  amount DECIMAL(18,2) NOT NULL DEFAULT 0,
+  usage_value DECIMAL(20,6) NOT NULL DEFAULT 0,
+  unit VARCHAR(32) DEFAULT NULL,
+  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_stmt_item (statement_id, item_code),
+  KEY idx_statement_id (statement_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='账单项汇总';
+
+/* =========================================================
+   5. 初始化示例数据(可选)
+   ========================================================= */
+INSERT INTO fee_plan(plan_code, plan_name, version, status, effective_time, remark)
+VALUES ('STANDARD', '标准计费方案', 1, 'PUBLISHED', NOW(), '默认方案')
+ON DUPLICATE KEY UPDATE plan_name=VALUES(plan_name), status=VALUES(status), effective_time=VALUES(effective_time), remark=VALUES(remark);
+
+INSERT INTO fee_plan_item(plan_code, version, item_code, unit, unit_price, min_charge_unit, token_unit, enabled) VALUES
+('STANDARD',1,'FLOW_POSTPAID','GB',0.20,NULL,NULL,1),
+('STANDARD',1,'CALL_OUT','MIN',0.30,1,NULL,1),
+('STANDARD',1,'CALL_IN','MIN',0.20,1,NULL,1),
+('STANDARD',1,'AI_CALL','MIN',0.15,1,NULL,1),
+('STANDARD',1,'SOP_TOKEN','TOKEN',1.00,NULL,100000,1),
+('STANDARD',1,'AI_REPLY_TOKEN','TOKEN',1.00,NULL,100000,1),
+('STANDARD',1,'ADD_WECHAT','COUNT',0.50,NULL,NULL,1),
+('STANDARD',1,'OPEN_ACCOUNT_NON_AI','TIME',1000.00,NULL,NULL,1),
+('STANDARD',1,'OPEN_ACCOUNT_AI','TIME',3000.00,NULL,NULL,1)
+ON DUPLICATE KEY UPDATE unit=VALUES(unit), unit_price=VALUES(unit_price), min_charge_unit=VALUES(min_charge_unit), token_unit=VALUES(token_unit), enabled=VALUES(enabled);
+
+INSERT INTO fee_plan_flow_tier(plan_code, version, min_prepay_amount, max_prepay_amount, unit_price, sort_no) VALUES
+('STANDARD',1,100000,200000,0.10,1),
+('STANDARD',1,200000,NULL,0.08,2)
+ON DUPLICATE KEY UPDATE max_prepay_amount=VALUES(max_prepay_amount), unit_price=VALUES(unit_price), sort_no=VALUES(sort_no);
+

+ 78 - 0
docs/计费模块菜单SQL-总账号-计费配置入口.sql

@@ -0,0 +1,78 @@
+-- 计费模块菜单 SQL(总账号 - 计费配置入口)
+-- 说明:
+-- 1) 为总账号新增“计费配置”页面菜单;
+-- 2) 幂等执行,不重复插入;
+-- 3) 默认挂在 path='tenant' 父菜单下,找不到则挂根节点(0)。
+
+SET @now = NOW();
+SET @createBy = 'admin';
+
+-- 找父菜单:优先租户管理目录
+SELECT @parentId := menu_id
+FROM sys_menu
+WHERE path = 'tenant'
+ORDER BY menu_id DESC
+LIMIT 1;
+
+SET @parentId = IFNULL(@parentId, 0);
+
+-- 1) 页面菜单(C)
+INSERT INTO sys_menu
+(menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
+SELECT
+  '计费配置', @parentId, 30, 'billingConfig', 'saas/billing/index', 1, 0, 'C', '0', '0', 'fee:billing:config', '#',
+  @createBy, @now, '', NULL, 'SaaS计费配置入口(方案、绑定、钱包、事件)'
+FROM dual
+WHERE NOT EXISTS (
+  SELECT 1 FROM sys_menu WHERE component = 'saas/billing/index'
+);
+
+-- 菜单ID
+SELECT @configMenuId := menu_id
+FROM sys_menu
+WHERE component = 'saas/billing/index'
+LIMIT 1;
+
+-- 2) 按钮权限(F):查询
+INSERT INTO sys_menu
+(menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
+SELECT
+  '计费配置查询', @configMenuId, 1, '#', '', 1, 0, 'F', '0', '0', 'fee:billing:config:query', '#',
+  @createBy, @now, '', NULL, '计费配置查询权限'
+FROM dual
+WHERE @configMenuId IS NOT NULL
+  AND NOT EXISTS (
+    SELECT 1 FROM sys_menu WHERE parent_id = @configMenuId AND perms = 'fee:billing:config:query' AND menu_type = 'F'
+  );
+
+-- 3) 按钮权限(F):编辑
+INSERT INTO sys_menu
+(menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
+SELECT
+  '计费配置编辑', @configMenuId, 2, '#', '', 1, 0, 'F', '0', '0', 'fee:billing:config:edit', '#',
+  @createBy, @now, '', NULL, '计费配置编辑权限'
+FROM dual
+WHERE @configMenuId IS NOT NULL
+  AND NOT EXISTS (
+    SELECT 1 FROM sys_menu WHERE parent_id = @configMenuId AND perms = 'fee:billing:config:edit' AND menu_type = 'F'
+  );
+
+-- 4) 授权给 admin 角色
+SELECT @adminRoleId := role_id
+FROM sys_role
+WHERE role_key = 'admin'
+LIMIT 1;
+
+INSERT INTO sys_role_menu (role_id, menu_id)
+SELECT @adminRoleId, m.menu_id
+FROM sys_menu m
+WHERE @adminRoleId IS NOT NULL
+  AND m.menu_id IN (
+    @configMenuId,
+    (SELECT menu_id FROM sys_menu WHERE parent_id = @configMenuId AND perms = 'fee:billing:config:query' AND menu_type = 'F' LIMIT 1),
+    (SELECT menu_id FROM sys_menu WHERE parent_id = @configMenuId AND perms = 'fee:billing:config:edit' AND menu_type = 'F' LIMIT 1)
+  )
+  AND NOT EXISTS (
+    SELECT 1 FROM sys_role_menu rm WHERE rm.role_id = @adminRoleId AND rm.menu_id = m.menu_id
+  );
+

+ 66 - 0
docs/计费模块菜单SQL-总账号.sql

@@ -0,0 +1,66 @@
+-- 计费模块菜单 SQL(总账号)
+-- 说明:
+-- 1) 本脚本用于平台总账号菜单与权限初始化;
+-- 2) 幂等执行,不会重复插入;
+-- 3) 默认挂在 path='tenant' 的父菜单下,找不到则挂根节点(0)。
+
+SET @now = NOW();
+SET @createBy = 'admin';
+
+-- 找父菜单:优先租户管理目录
+SELECT @parentId := menu_id
+FROM sys_menu
+WHERE path = 'tenant'
+ORDER BY menu_id DESC
+LIMIT 1;
+
+SET @parentId = IFNULL(@parentId, 0);
+
+-- 1) 总账号页面菜单(C)
+INSERT INTO sys_menu
+(menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
+SELECT
+  '费用明细(总账号)', @parentId, 31, 'billingAdmin', 'saas/billingAdmin/index', 1, 0, 'C', '0', '0', 'fee:billing:admin:list', '#',
+  @createBy, @now, '', NULL, 'SaaS总账号查看全租户费用明细'
+FROM dual
+WHERE NOT EXISTS (
+  SELECT 1 FROM sys_menu WHERE component = 'saas/billingAdmin/index'
+);
+
+-- 页面菜单ID
+SELECT @adminPageMenuId := menu_id
+FROM sys_menu
+WHERE component = 'saas/billingAdmin/index'
+LIMIT 1;
+
+-- 2) 总账号查询按钮(F)
+INSERT INTO sys_menu
+(menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
+SELECT
+  '总账号费用明细查询', @adminPageMenuId, 1, '#', '', 1, 0, 'F', '0', '0', 'fee:billing:admin:list', '#',
+  @createBy, @now, '', NULL, '总账号费用明细查询权限'
+FROM dual
+WHERE @adminPageMenuId IS NOT NULL
+  AND NOT EXISTS (
+    SELECT 1
+    FROM sys_menu
+    WHERE parent_id = @adminPageMenuId
+      AND perms = 'fee:billing:admin:list'
+      AND menu_type = 'F'
+  );
+
+-- 3) 绑定 admin 角色
+SELECT @adminRoleId := role_id
+FROM sys_role
+WHERE role_key = 'admin'
+LIMIT 1;
+
+INSERT INTO sys_role_menu (role_id, menu_id)
+SELECT @adminRoleId, m.menu_id
+FROM sys_menu m
+WHERE @adminRoleId IS NOT NULL
+  AND m.menu_id = @adminPageMenuId
+  AND NOT EXISTS (
+    SELECT 1 FROM sys_role_menu rm WHERE rm.role_id = @adminRoleId AND rm.menu_id = m.menu_id
+  );
+

+ 70 - 0
docs/计费模块菜单SQL-租户账号.sql

@@ -0,0 +1,70 @@
+-- 计费模块菜单 SQL(租户账号)
+-- 说明:
+-- 1) 本脚本用于租户账号菜单与权限初始化;
+-- 2) 幂等执行,不会重复插入;
+-- 3) 默认挂在 path='tenant' 的父菜单下,找不到则挂根节点(0);
+-- 4) 需将 @tenantRoleKey 修改为你系统真实租户角色 key。
+
+SET @now = NOW();
+SET @createBy = 'admin';
+
+-- TODO: 改为你系统真实租户角色 key
+SET @tenantRoleKey = 'tenant_admin';
+
+-- 找父菜单:优先租户管理目录
+SELECT @parentId := menu_id
+FROM sys_menu
+WHERE path = 'tenant'
+ORDER BY menu_id DESC
+LIMIT 1;
+
+SET @parentId = IFNULL(@parentId, 0);
+
+-- 1) 租户账号页面菜单(C)
+INSERT INTO sys_menu
+(menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
+SELECT
+  '费用明细(我的)', @parentId, 32, 'billingTenant', 'saas/billingTenant/index', 1, 0, 'C', '0', '0', 'fee:billing:tenant:list', '#',
+  @createBy, @now, '', NULL, 'SaaS租户查看自己费用明细'
+FROM dual
+WHERE NOT EXISTS (
+  SELECT 1 FROM sys_menu WHERE component = 'saas/billingTenant/index'
+);
+
+-- 页面菜单ID
+SELECT @tenantPageMenuId := menu_id
+FROM sys_menu
+WHERE component = 'saas/billingTenant/index'
+LIMIT 1;
+
+-- 2) 租户账号查询按钮(F)
+INSERT INTO sys_menu
+(menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
+SELECT
+  '租户费用明细查询', @tenantPageMenuId, 1, '#', '', 1, 0, 'F', '0', '0', 'fee:billing:tenant:list', '#',
+  @createBy, @now, '', NULL, '租户费用明细查询权限'
+FROM dual
+WHERE @tenantPageMenuId IS NOT NULL
+  AND NOT EXISTS (
+    SELECT 1
+    FROM sys_menu
+    WHERE parent_id = @tenantPageMenuId
+      AND perms = 'fee:billing:tenant:list'
+      AND menu_type = 'F'
+  );
+
+-- 3) 绑定租户角色
+SELECT @tenantRoleId := role_id
+FROM sys_role
+WHERE role_key = @tenantRoleKey
+LIMIT 1;
+
+INSERT INTO sys_role_menu (role_id, menu_id)
+SELECT @tenantRoleId, m.menu_id
+FROM sys_menu m
+WHERE @tenantRoleId IS NOT NULL
+  AND m.menu_id = @tenantPageMenuId
+  AND NOT EXISTS (
+    SELECT 1 FROM sys_role_menu rm WHERE rm.role_id = @tenantRoleId AND rm.menu_id = m.menu_id
+  );
+

+ 51 - 0
fs-admin/src/main/java/com/fs/billing/controller/BillingStatementController.java

@@ -0,0 +1,51 @@
+package com.fs.billing.controller;
+
+import com.fs.billing.service.BillingServices;
+import com.fs.common.exception.CustomException;
+import com.fs.common.core.domain.R;
+import com.fs.common.utils.SecurityUtils;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+
+@RestController
+@RequestMapping("/api/fee")
+public class BillingStatementController {
+    @Resource
+    private BillingServices.BillingQueryService billingQueryService;
+
+    /**
+     * 平台端查询计费明细(tenantId 不传时查询全部租户)。
+     */
+    @GetMapping("/billing/detail/list")
+    @PreAuthorize("@ss.hasPermi('fee:billing:admin:list')")
+    public R details(@RequestParam(required = false) Long tenantId) {
+        return R.ok().put("rows", billingQueryService.listDetailsAdmin(tenantId));
+    }
+
+    /**
+     * 租户端查询本人租户计费明细(从登录态获取 tenantId)。
+     */
+    @GetMapping("/billing/detail/my")
+    @PreAuthorize("@ss.hasPermi('fee:billing:tenant:list')")
+    public R myDetails() {
+        Long tenantId = SecurityUtils.getTenantId();
+        if (tenantId == null) {
+            throw new CustomException("当前账号未绑定租户,无法查询租户费用明细");
+        }
+        return R.ok().put("rows", billingQueryService.listDetails(tenantId));
+    }
+
+    /**
+     * 生成账单(按时间区间汇总未入账明细)。
+     */
+    @PostMapping("/statement/generate")
+    public R generate(@RequestParam Long tenantId,
+                      @RequestParam String periodType,
+                      @RequestParam String periodStart,
+                      @RequestParam String periodEnd) {
+        return R.ok().put("data", billingQueryService.generateStatement(tenantId, periodType, periodStart, periodEnd));
+    }
+}
+

+ 55 - 0
fs-admin/src/main/java/com/fs/billing/controller/FeePlanController.java

@@ -0,0 +1,55 @@
+package com.fs.billing.controller;
+
+import com.fs.billing.dto.FeePlanRequests;
+import com.fs.billing.service.BillingServices;
+import com.fs.common.core.domain.R;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import javax.validation.Valid;
+
+@RestController
+@Validated
+@RequestMapping("/api/fee/plan")
+public class FeePlanController {
+    @Resource
+    private BillingServices.FeePlanService feePlanService;
+
+    /**
+     * 创建计费方案版本(草稿)。
+     */
+    @PostMapping("/create")
+    public R create(@Valid @RequestBody FeePlanRequests.CreateReq req) {
+        feePlanService.createPlan(req);
+        return R.ok("创建成功");
+    }
+
+    /**
+     * 保存方案计费项(单价、单位、token单位等)。
+     */
+    @PostMapping("/item/save")
+    public R saveItem(@Valid @RequestBody FeePlanRequests.ItemSaveReq req) {
+        feePlanService.saveItems(req);
+        return R.ok("保存成功");
+    }
+
+    /**
+     * 保存流量阶梯价格配置。
+     */
+    @PostMapping("/flow-tier/save")
+    public R saveTier(@Valid @RequestBody FeePlanRequests.FlowTierSaveReq req) {
+        feePlanService.saveFlowTiers(req);
+        return R.ok("保存成功");
+    }
+
+    /**
+     * 发布方案版本,发布后租户可绑定并生效。
+     */
+    @PostMapping("/publish")
+    public R publish(@RequestParam String planCode, @RequestParam Integer version) {
+        feePlanService.publish(planCode, version);
+        return R.ok("发布成功");
+    }
+}
+

+ 46 - 0
fs-admin/src/main/java/com/fs/billing/controller/TenantBillingController.java

@@ -0,0 +1,46 @@
+package com.fs.billing.controller;
+
+import com.fs.billing.dto.TenantBillingRequests;
+import com.fs.billing.service.BillingServices;
+import com.fs.common.core.domain.R;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import javax.validation.Valid;
+
+@RestController
+@Validated
+@RequestMapping("/api/fee/tenant")
+public class TenantBillingController {
+    @Resource
+    private BillingServices.TenantBillingService tenantBillingService;
+
+    /**
+     * 绑定租户计费方案(含租户类型、计费模式)。
+     */
+    @PostMapping("/bind-plan")
+    public R bindPlan(@Valid @RequestBody TenantBillingRequests.BindPlanReq req) {
+        tenantBillingService.bindPlan(req);
+        return R.ok("绑定成功");
+    }
+
+    /**
+     * 切换租户计费模式(PREPAID/POSTPAID)。
+     */
+    @PostMapping("/change-billing-mode")
+    public R changeBillingMode(@Valid @RequestBody TenantBillingRequests.ChangeBillingModeReq req) {
+        tenantBillingService.changeBillingMode(req);
+        return R.ok("切换成功");
+    }
+
+    /**
+     * 切换租户类型(AI/NON_AI)。
+     */
+    @PostMapping("/change-type")
+    public R changeType(@Valid @RequestBody TenantBillingRequests.ChangeTenantTypeReq req) {
+        tenantBillingService.changeTenantType(req);
+        return R.ok("切换成功");
+    }
+}
+

+ 28 - 0
fs-admin/src/main/java/com/fs/billing/controller/UsageEventController.java

@@ -0,0 +1,28 @@
+package com.fs.billing.controller;
+
+import com.fs.billing.dto.UsageEventReportReq;
+import com.fs.billing.service.BillingServices;
+import com.fs.common.core.domain.R;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import javax.validation.Valid;
+
+@RestController
+@Validated
+@RequestMapping("/api/fee/usage")
+public class UsageEventController {
+    @Resource
+    private BillingServices.UsageEventService usageEventService;
+
+    /**
+     * 统一用量事件上报入口。
+     * 说明:当前实现为上报后立即计费,后续可改为MQ异步消费。
+     */
+    @PostMapping("/report")
+    public R report(@Valid @RequestBody UsageEventReportReq req) {
+        return R.ok().put("data", usageEventService.reportAndCharge(req));
+    }
+}
+

+ 36 - 0
fs-admin/src/main/java/com/fs/billing/controller/WalletController.java

@@ -0,0 +1,36 @@
+package com.fs.billing.controller;
+
+import com.fs.billing.dto.TenantBillingRequests;
+import com.fs.billing.service.BillingServices;
+import com.fs.common.core.domain.R;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import javax.validation.Valid;
+
+@RestController
+@Validated
+@RequestMapping("/api/fee/wallet")
+public class WalletController {
+    @Resource
+    private BillingServices.WalletService walletService;
+
+    /**
+     * 查询租户钱包余额与累计金额。
+     */
+    @GetMapping("/{tenantId}")
+    public R wallet(@PathVariable Long tenantId) {
+        return R.ok().put("data", walletService.getWallet(tenantId));
+    }
+
+    /**
+     * 租户钱包充值(预存模式基础能力)。
+     */
+    @PostMapping("/recharge")
+    public R recharge(@Valid @RequestBody TenantBillingRequests.WalletRechargeReq req) {
+        walletService.recharge(req);
+        return R.ok("充值成功");
+    }
+}
+

+ 46 - 0
fs-service/src/main/java/com/fs/billing/domain/BillingDetail.java

@@ -0,0 +1,46 @@
+package com.fs.billing.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@TableName("billing_detail")
+public class BillingDetail {
+    /** 主键ID。 */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    /** 租户ID。 */
+    private Long tenantId;
+    /** 关联账单ID(未出账时为空)。 */
+    private Long statementId;
+    /** 关联事件ID。 */
+    private String eventId;
+    /** 事件类型。 */
+    private String eventType;
+    /** 事件子类型。 */
+    private String subType;
+    /** 计费方案编码。 */
+    private String planCode;
+    /** 计费方案版本。 */
+    private Integer planVersion;
+    /** 单价。 */
+    private BigDecimal unitPrice;
+    /** 原始用量。 */
+    private BigDecimal usageValue;
+    /** 计费用量。 */
+    private BigDecimal chargeValue;
+    /** 金额。 */
+    private BigDecimal amount;
+    /** 计费模式。 */
+    private String billingMode;
+    /** 发生时间。 */
+    private Date occurredAt;
+    /** 创建时间。 */
+    private Date createTime;
+}
+

+ 40 - 0
fs-service/src/main/java/com/fs/billing/domain/BillingStatement.java

@@ -0,0 +1,40 @@
+package com.fs.billing.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@TableName("billing_statement")
+public class BillingStatement {
+    /** 主键ID。 */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    /** 账单号。 */
+    private String statementNo;
+    /** 租户ID。 */
+    private Long tenantId;
+    /** 账期类型(月/周/自定义)。 */
+    private String periodType;
+    /** 账期开始时间。 */
+    private Date periodStart;
+    /** 账期结束时间。 */
+    private Date periodEnd;
+    /** 账单总额。 */
+    private BigDecimal totalAmount;
+    /** 已支付金额。 */
+    private BigDecimal paidAmount;
+    /** 未支付金额。 */
+    private BigDecimal unpaidAmount;
+    /** 账单状态。 */
+    private String status;
+    /** 创建时间。 */
+    private Date createTime;
+    /** 更新时间。 */
+    private Date updateTime;
+}
+

+ 30 - 0
fs-service/src/main/java/com/fs/billing/domain/BillingStatementItem.java

@@ -0,0 +1,30 @@
+package com.fs.billing.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@TableName("billing_statement_item")
+public class BillingStatementItem {
+    /** 主键ID。 */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    /** 关联账单ID。 */
+    private Long statementId;
+    /** 账单项编码(通常为事件类型)。 */
+    private String itemCode;
+    /** 该项金额。 */
+    private BigDecimal amount;
+    /** 该项用量。 */
+    private BigDecimal usageValue;
+    /** 用量单位。 */
+    private String unit;
+    /** 创建时间。 */
+    private Date createTime;
+}
+

+ 39 - 0
fs-service/src/main/java/com/fs/billing/domain/FeePlan.java

@@ -0,0 +1,39 @@
+package com.fs.billing.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.util.Date;
+
+@Data
+@TableName("fee_plan")
+public class FeePlan {
+    /** 主键ID。 */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    /** 方案编码。 */
+    private String planCode;
+    /** 方案名称。 */
+    private String planName;
+    /** 方案版本号。 */
+    private Integer version;
+    /** 状态:DRAFT/PUBLISHED。 */
+    private String status;
+    /** 生效时间。 */
+    private Date effectiveTime;
+    /** 失效时间。 */
+    private Date expireTime;
+    /** 备注。 */
+    private String remark;
+    /** 创建人。 */
+    private String createBy;
+    /** 创建时间。 */
+    private Date createTime;
+    /** 更新人。 */
+    private String updateBy;
+    /** 更新时间。 */
+    private Date updateTime;
+}
+

+ 32 - 0
fs-service/src/main/java/com/fs/billing/domain/FeePlanFlowTier.java

@@ -0,0 +1,32 @@
+package com.fs.billing.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@TableName("fee_plan_flow_tier")
+public class FeePlanFlowTier {
+    /** 主键ID。 */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    /** 方案编码。 */
+    private String planCode;
+    /** 方案版本。 */
+    private Integer version;
+    /** 预存金额下限(含)。 */
+    private BigDecimal minPrepayAmount;
+    /** 预存金额上限(不含,null表示无上限)。 */
+    private BigDecimal maxPrepayAmount;
+    /** 对应流量单价。 */
+    private BigDecimal unitPrice;
+    /** 排序号。 */
+    private Integer sortNo;
+    /** 创建时间。 */
+    private Date createTime;
+}
+

+ 38 - 0
fs-service/src/main/java/com/fs/billing/domain/FeePlanItem.java

@@ -0,0 +1,38 @@
+package com.fs.billing.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@TableName("fee_plan_item")
+public class FeePlanItem {
+    /** 主键ID。 */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    /** 方案编码。 */
+    private String planCode;
+    /** 方案版本。 */
+    private Integer version;
+    /** 收费项编码。 */
+    private String itemCode;
+    /** 计费单位。 */
+    private String unit;
+    /** 单价。 */
+    private BigDecimal unitPrice;
+    /** Token计费单位(如10w token)。 */
+    private Long tokenUnit;
+    /** 最小计费单位。 */
+    private Integer minChargeUnit;
+    /** 是否启用:1启用,0禁用。 */
+    private Integer enabled;
+    /** 创建时间。 */
+    private Date createTime;
+    /** 更新时间。 */
+    private Date updateTime;
+}
+

+ 34 - 0
fs-service/src/main/java/com/fs/billing/domain/TenantWallet.java

@@ -0,0 +1,34 @@
+package com.fs.billing.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@TableName("tenant_wallet")
+public class TenantWallet {
+    /** 主键ID。 */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    /** 租户ID。 */
+    private Long tenantId;
+    /** 当前可用余额。 */
+    private BigDecimal balanceAmount;
+    /** 冻结金额。 */
+    private BigDecimal frozenAmount;
+    /** 信用额度。 */
+    private BigDecimal creditLimit;
+    /** 累计充值金额。 */
+    private BigDecimal totalRecharge;
+    /** 累计消费金额。 */
+    private BigDecimal totalCost;
+    /** 更新时间。 */
+    private Date updateTime;
+    /** 创建时间。 */
+    private Date createTime;
+}
+

+ 38 - 0
fs-service/src/main/java/com/fs/billing/domain/TenantWalletTxn.java

@@ -0,0 +1,38 @@
+package com.fs.billing.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@TableName("tenant_wallet_txn")
+public class TenantWalletTxn {
+    /** 主键ID。 */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    /** 租户ID。 */
+    private Long tenantId;
+    /** 交易流水号。 */
+    private String txnNo;
+    /** 交易类型:RECHARGE/CONSUME。 */
+    private String txnType;
+    /** 交易金额(消费通常为负值)。 */
+    private BigDecimal amount;
+    /** 交易后余额。 */
+    private BigDecimal balanceAfter;
+    /** 业务类型。 */
+    private String bizType;
+    /** 业务ID。 */
+    private String bizId;
+    /** 备注。 */
+    private String remark;
+    /** 创建人。 */
+    private String createBy;
+    /** 创建时间。 */
+    private Date createTime;
+}
+

+ 38 - 0
fs-service/src/main/java/com/fs/billing/domain/UsageEvent.java

@@ -0,0 +1,38 @@
+package com.fs.billing.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@TableName("usage_event")
+public class UsageEvent {
+    /** 主键ID。 */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    /** 幂等事件ID。 */
+    private String eventId;
+    /** 租户ID。 */
+    private Long tenantId;
+    /** 事件类型。 */
+    private String eventType;
+    /** 子类型。 */
+    private String subType;
+    /** 业务ID。 */
+    private String bizId;
+    /** 用量值。 */
+    private BigDecimal usageValue;
+    /** 用量单位。 */
+    private String usageUnit;
+    /** 事件发生时间。 */
+    private Date occurredAt;
+    /** 扩展JSON信息。 */
+    private String extJson;
+    /** 创建时间。 */
+    private Date createTime;
+}
+

+ 75 - 0
fs-service/src/main/java/com/fs/billing/dto/FeePlanRequests.java

@@ -0,0 +1,75 @@
+package com.fs.billing.dto;
+
+import lombok.Data;
+
+import javax.validation.Valid;
+import javax.validation.constraints.DecimalMin;
+import javax.validation.constraints.Min;
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotEmpty;
+import javax.validation.constraints.NotNull;
+import java.math.BigDecimal;
+import java.util.List;
+
+public class FeePlanRequests {
+    @Data
+    public static class CreateReq {
+        @NotBlank(message = "planCode不能为空")
+        private String planCode;
+        @NotBlank(message = "planName不能为空")
+        private String planName;
+        @NotNull(message = "version不能为空")
+        @Min(value = 1, message = "version必须>=1")
+        private Integer version;
+        private String remark;
+    }
+
+    @Data
+    public static class ItemSaveReq {
+        @NotBlank(message = "planCode不能为空")
+        private String planCode;
+        @NotNull(message = "version不能为空")
+        private Integer version;
+        @Valid
+        @NotEmpty(message = "items不能为空")
+        private List<ItemReq> items;
+    }
+
+    @Data
+    public static class ItemReq {
+        @NotBlank(message = "itemCode不能为空")
+        private String itemCode;
+        @NotBlank(message = "unit不能为空")
+        private String unit;
+        @NotNull(message = "unitPrice不能为空")
+        @DecimalMin(value = "0", inclusive = true, message = "unitPrice不能小于0")
+        private BigDecimal unitPrice;
+        private Long tokenUnit;
+        private Integer minChargeUnit;
+        private Integer enabled;
+    }
+
+    @Data
+    public static class FlowTierSaveReq {
+        @NotBlank(message = "planCode不能为空")
+        private String planCode;
+        @NotNull(message = "version不能为空")
+        private Integer version;
+        @Valid
+        @NotEmpty(message = "tiers不能为空")
+        private List<FlowTierReq> tiers;
+    }
+
+    @Data
+    public static class FlowTierReq {
+        @NotNull(message = "minPrepayAmount不能为空")
+        @DecimalMin(value = "0", inclusive = true, message = "minPrepayAmount不能小于0")
+        private BigDecimal minPrepayAmount;
+        private BigDecimal maxPrepayAmount;
+        @NotNull(message = "unitPrice不能为空")
+        @DecimalMin(value = "0", inclusive = true, message = "unitPrice不能小于0")
+        private BigDecimal unitPrice;
+        private Integer sortNo;
+    }
+}
+

+ 52 - 0
fs-service/src/main/java/com/fs/billing/dto/TenantBillingRequests.java

@@ -0,0 +1,52 @@
+package com.fs.billing.dto;
+
+import lombok.Data;
+
+import javax.validation.constraints.DecimalMin;
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+import java.math.BigDecimal;
+
+public class TenantBillingRequests {
+    @Data
+    public static class BindPlanReq {
+        @NotNull(message = "tenantId不能为空")
+        private Long tenantId;
+        @NotBlank(message = "tenantType不能为空")
+        private String tenantType;
+        @NotBlank(message = "billingMode不能为空")
+        private String billingMode;
+        @NotBlank(message = "planCode不能为空")
+        private String planCode;
+        @NotNull(message = "planVersion不能为空")
+        private Integer planVersion;
+    }
+
+    @Data
+    public static class ChangeBillingModeReq {
+        @NotNull(message = "tenantId不能为空")
+        private Long tenantId;
+        @NotBlank(message = "billingMode不能为空")
+        private String billingMode;
+    }
+
+    @Data
+    public static class ChangeTenantTypeReq {
+        @NotNull(message = "tenantId不能为空")
+        private Long tenantId;
+        @NotBlank(message = "tenantType不能为空")
+        private String tenantType;
+    }
+
+    @Data
+    public static class WalletRechargeReq {
+        @NotNull(message = "tenantId不能为空")
+        private Long tenantId;
+        @NotNull(message = "amount不能为空")
+        @DecimalMin(value = "0", inclusive = false, message = "amount必须大于0")
+        private BigDecimal amount;
+        private String bizNo;
+        private String remark;
+    }
+}
+

+ 30 - 0
fs-service/src/main/java/com/fs/billing/dto/UsageEventReportReq.java

@@ -0,0 +1,30 @@
+package com.fs.billing.dto;
+
+import lombok.Data;
+
+import javax.validation.constraints.DecimalMin;
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+import java.math.BigDecimal;
+import java.util.Date;
+import java.util.Map;
+
+@Data
+public class UsageEventReportReq {
+    @NotBlank(message = "eventId不能为空")
+    private String eventId;
+    @NotNull(message = "tenantId不能为空")
+    private Long tenantId;
+    @NotBlank(message = "eventType不能为空")
+    private String eventType;
+    private String subType;
+    private String bizId;
+    @NotNull(message = "usageValue不能为空")
+    @DecimalMin(value = "0", inclusive = false, message = "usageValue必须大于0")
+    private BigDecimal usageValue;
+    @NotBlank(message = "usageUnit不能为空")
+    private String usageUnit;
+    private Date occurredAt;
+    private Map<String, Object> extJson;
+}
+

+ 38 - 0
fs-service/src/main/java/com/fs/billing/mapper/BillingDetailMapper.java

@@ -0,0 +1,38 @@
+package com.fs.billing.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.billing.domain.BillingDetail;
+import org.apache.ibatis.annotations.Param;
+
+import java.math.BigDecimal;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+public interface BillingDetailMapper extends BaseMapper<BillingDetail> {
+    /**
+     * 平台端查询计费明细(tenantId 为空时查全部租户)。
+     */
+    List<BillingDetail> selectAdminDetails(@Param("tenantId") Long tenantId);
+
+    /**
+     * 查询租户的计费明细列表。
+     */
+    List<BillingDetail> selectByTenant(@Param("tenantId") Long tenantId);
+
+    /**
+     * 汇总待出账明细总金额。
+     */
+    BigDecimal sumUnstatementAmount(@Param("tenantId") Long tenantId, @Param("start") Date start, @Param("end") Date end);
+
+    /**
+     * 按事件类型汇总待出账金额与计费量。
+     */
+    List<Map<String, Object>> groupUnstatementByEventType(@Param("tenantId") Long tenantId, @Param("start") Date start, @Param("end") Date end);
+
+    /**
+     * 将区间内未出账明细挂到指定账单。
+     */
+    int updateStatementId(@Param("statementId") Long statementId, @Param("tenantId") Long tenantId, @Param("start") Date start, @Param("end") Date end);
+}
+

+ 8 - 0
fs-service/src/main/java/com/fs/billing/mapper/BillingStatementItemMapper.java

@@ -0,0 +1,8 @@
+package com.fs.billing.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.billing.domain.BillingStatementItem;
+
+public interface BillingStatementItemMapper extends BaseMapper<BillingStatementItem> {
+}
+

+ 13 - 0
fs-service/src/main/java/com/fs/billing/mapper/BillingStatementMapper.java

@@ -0,0 +1,13 @@
+package com.fs.billing.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.billing.domain.BillingStatement;
+import org.apache.ibatis.annotations.Param;
+
+public interface BillingStatementMapper extends BaseMapper<BillingStatement> {
+    /**
+     * 通过账单号查询主键ID。
+     */
+    Long selectIdByStatementNo(@Param("statementNo") String statementNo);
+}
+

+ 32 - 0
fs-service/src/main/java/com/fs/billing/mapper/BillingTenantMapper.java

@@ -0,0 +1,32 @@
+package com.fs.billing.mapper;
+
+import org.apache.ibatis.annotations.Param;
+
+import java.util.Map;
+
+public interface BillingTenantMapper {
+    /**
+     * 查询租户计费配置(方案、模式、类型)。
+     */
+    Map<String, Object> selectTenantBillingCfg(@Param("tenantId") Long tenantId);
+
+    /**
+     * 绑定租户计费方案及计费属性。
+     */
+    int bindPlan(@Param("tenantId") Long tenantId,
+                 @Param("tenantType") String tenantType,
+                 @Param("billingMode") String billingMode,
+                 @Param("planCode") String planCode,
+                 @Param("planVersion") Integer planVersion);
+
+    /**
+     * 修改租户计费模式(预付/后付)。
+     */
+    int changeBillingMode(@Param("tenantId") Long tenantId, @Param("billingMode") String billingMode);
+
+    /**
+     * 修改租户类型(AI/非AI)。
+     */
+    int changeTenantType(@Param("tenantId") Long tenantId, @Param("tenantType") String tenantType);
+}
+

+ 20 - 0
fs-service/src/main/java/com/fs/billing/mapper/FeePlanFlowTierMapper.java

@@ -0,0 +1,20 @@
+package com.fs.billing.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.billing.domain.FeePlanFlowTier;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+public interface FeePlanFlowTierMapper extends BaseMapper<FeePlanFlowTier> {
+    /**
+     * 查询方案的流量阶梯配置。
+     */
+    List<FeePlanFlowTier> selectByPlan(@Param("planCode") String planCode, @Param("version") Integer version);
+
+    /**
+     * 删除方案历史阶梯配置(用于整批重建)。
+     */
+    int deleteByPlan(@Param("planCode") String planCode, @Param("version") Integer version);
+}
+

+ 20 - 0
fs-service/src/main/java/com/fs/billing/mapper/FeePlanItemMapper.java

@@ -0,0 +1,20 @@
+package com.fs.billing.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.billing.domain.FeePlanItem;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+public interface FeePlanItemMapper extends BaseMapper<FeePlanItem> {
+    /**
+     * 查询方案下所有启用的收费项。
+     */
+    List<FeePlanItem> selectEnabledItems(@Param("planCode") String planCode, @Param("version") Integer version);
+
+    /**
+     * 新增或更新收费项定义。
+     */
+    int upsertItem(FeePlanItem item);
+}
+

+ 23 - 0
fs-service/src/main/java/com/fs/billing/mapper/FeePlanMapper.java

@@ -0,0 +1,23 @@
+package com.fs.billing.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.billing.domain.FeePlan;
+import org.apache.ibatis.annotations.Param;
+
+public interface FeePlanMapper extends BaseMapper<FeePlan> {
+    /**
+     * 查询已发布的计费方案版本。
+     */
+    FeePlan selectPublishedByCodeAndVersion(@Param("planCode") String planCode, @Param("version") Integer version);
+
+    /**
+     * 新增或更新方案基础信息。
+     */
+    int upsertPlan(FeePlan plan);
+
+    /**
+     * 发布指定方案版本。
+     */
+    int publish(@Param("planCode") String planCode, @Param("version") Integer version);
+}
+

+ 28 - 0
fs-service/src/main/java/com/fs/billing/mapper/TenantWalletMapper.java

@@ -0,0 +1,28 @@
+package com.fs.billing.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.billing.domain.TenantWallet;
+import org.apache.ibatis.annotations.Param;
+
+public interface TenantWalletMapper extends BaseMapper<TenantWallet> {
+    /**
+     * 保证钱包记录存在,不存在则初始化。
+     */
+    int ensureWalletExists(@Param("tenantId") Long tenantId);
+
+    /**
+     * 按租户查询钱包。
+     */
+    TenantWallet selectByTenantId(@Param("tenantId") Long tenantId);
+
+    /**
+     * 充值:增加余额和累计充值。
+     */
+    int recharge(@Param("tenantId") Long tenantId, @Param("amount") java.math.BigDecimal amount);
+
+    /**
+     * 扣费:减少余额并增加累计消费。
+     */
+    int deduct(@Param("tenantId") Long tenantId, @Param("amount") java.math.BigDecimal amount);
+}
+

+ 8 - 0
fs-service/src/main/java/com/fs/billing/mapper/TenantWalletTxnMapper.java

@@ -0,0 +1,8 @@
+package com.fs.billing.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.billing.domain.TenantWalletTxn;
+
+public interface TenantWalletTxnMapper extends BaseMapper<TenantWalletTxn> {
+}
+

+ 13 - 0
fs-service/src/main/java/com/fs/billing/mapper/UsageEventMapper.java

@@ -0,0 +1,13 @@
+package com.fs.billing.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.billing.domain.UsageEvent;
+import org.apache.ibatis.annotations.Param;
+
+public interface UsageEventMapper extends BaseMapper<UsageEvent> {
+    /**
+     * 按事件ID判断是否已存在(幂等校验)。
+     */
+    int existsByEventId(@Param("eventId") String eventId);
+}
+

+ 40 - 0
fs-service/src/main/java/com/fs/billing/service/BillingServices.java

@@ -0,0 +1,40 @@
+package com.fs.billing.service;
+
+import com.fs.billing.dto.FeePlanRequests;
+import com.fs.billing.dto.TenantBillingRequests;
+import com.fs.billing.dto.UsageEventReportReq;
+import com.fs.billing.vo.BillingVos;
+
+import java.util.List;
+import java.util.Map;
+
+public class BillingServices {
+    public interface FeePlanService {
+        void createPlan(FeePlanRequests.CreateReq req);
+        void saveItems(FeePlanRequests.ItemSaveReq req);
+        void saveFlowTiers(FeePlanRequests.FlowTierSaveReq req);
+        void publish(String planCode, Integer version);
+    }
+
+    public interface TenantBillingService {
+        void bindPlan(TenantBillingRequests.BindPlanReq req);
+        void changeBillingMode(TenantBillingRequests.ChangeBillingModeReq req);
+        void changeTenantType(TenantBillingRequests.ChangeTenantTypeReq req);
+    }
+
+    public interface WalletService {
+        BillingVos.WalletVo getWallet(Long tenantId);
+        void recharge(TenantBillingRequests.WalletRechargeReq req);
+    }
+
+    public interface UsageEventService {
+        BillingVos.ChargeResultVo reportAndCharge(UsageEventReportReq req);
+    }
+
+    public interface BillingQueryService {
+        List<BillingVos.BillingDetailVo> listDetailsAdmin(Long tenantId);
+        List<BillingVos.BillingDetailVo> listDetails(Long tenantId);
+        Map<String, Object> generateStatement(Long tenantId, String periodType, String periodStart, String periodEnd);
+    }
+}
+

+ 138 - 0
fs-service/src/main/java/com/fs/billing/service/impl/BillingDbSupport.java

@@ -0,0 +1,138 @@
+package com.fs.billing.service.impl;
+
+import com.fs.common.exception.CustomException;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.sql.Timestamp;
+import java.text.SimpleDateFormat;
+import java.util.*;
+
+/**
+ * 计费模块数据库通用工具与配置加载。
+ */
+@Component
+public class BillingDbSupport {
+    /** 租户计费配置查询。 */
+    @Resource
+    private com.fs.billing.mapper.BillingTenantMapper billingTenantMapper;
+    /** 计费方案主表访问。 */
+    @Resource
+    private com.fs.billing.mapper.FeePlanMapper feePlanMapper;
+    /** 计费方案收费项访问。 */
+    @Resource
+    private com.fs.billing.mapper.FeePlanItemMapper feePlanItemMapper;
+    /** 计费方案流量阶梯访问。 */
+    @Resource
+    private com.fs.billing.mapper.FeePlanFlowTierMapper feePlanFlowTierMapper;
+    /** 租户钱包访问。 */
+    @Resource
+    private com.fs.billing.mapper.TenantWalletMapper tenantWalletMapper;
+
+    /**
+     * 加载租户计费绑定配置。
+     */
+    public TenantCfg loadTenantCfg(Long tenantId) {
+        Map<String, Object> m = billingTenantMapper.selectTenantBillingCfg(tenantId);
+        if (m == null || m.isEmpty()) throw new CustomException("租户不存在: " + tenantId);
+        TenantCfg c = new TenantCfg();
+        c.tenantId = longVal(m.get("id"));
+        c.tenantType = str(m.get("tenant_type"), "NON_AI");
+        c.billingMode = str(m.get("billing_mode"), "PREPAID");
+        c.planCode = str(m.get("fee_plan_code"), null);
+        c.planVersion = intVal(m.get("fee_plan_version"), null);
+        if (c.planCode == null || c.planVersion == null) throw new CustomException("租户未绑定计费方案");
+        return c;
+    }
+
+    /**
+     * 加载已发布方案及收费配置。
+     */
+    public PlanCfg loadPlanCfg(String planCode, Integer version) {
+        com.fs.billing.domain.FeePlan published = feePlanMapper.selectPublishedByCodeAndVersion(planCode, version);
+        if (published == null) throw new CustomException("计费方案未发布或不存在");
+        PlanCfg p = new PlanCfg();
+        p.planCode = planCode;
+        p.version = version;
+        java.util.List<com.fs.billing.domain.FeePlanItem> items = feePlanItemMapper.selectEnabledItems(planCode, version);
+        for (com.fs.billing.domain.FeePlanItem i : items) {
+            String code = str(i.getItemCode(), "");
+            p.itemPrice.put(code, dec(i.getUnitPrice()));
+            p.tokenUnit.put(code, i.getTokenUnit() == null ? 100000L : i.getTokenUnit());
+        }
+        java.util.List<com.fs.billing.domain.FeePlanFlowTier> tiers = feePlanFlowTierMapper.selectByPlan(planCode, version);
+        for (com.fs.billing.domain.FeePlanFlowTier t : tiers) {
+            java.util.Map<String, Object> m = new java.util.HashMap<>();
+            m.put("min_prepay_amount", t.getMinPrepayAmount());
+            m.put("max_prepay_amount", t.getMaxPrepayAmount());
+            m.put("unit_price", t.getUnitPrice());
+            p.flowTiers.add(m);
+        }
+        return p;
+    }
+
+    /**
+     * 确保租户钱包存在。
+     */
+    public void ensureWalletExists(Long tenantId) {
+        tenantWalletMapper.ensureWalletExists(tenantId);
+    }
+
+    /**
+     * 根据累计充值匹配流量阶梯单价。
+     */
+    public BigDecimal flowTierPrice(Long tenantId, PlanCfg plan) {
+        ensureWalletExists(tenantId);
+        com.fs.billing.domain.TenantWallet wallet = tenantWalletMapper.selectByTenantId(tenantId);
+        BigDecimal recharge = wallet == null ? BigDecimal.ZERO : nvl(wallet.getTotalRecharge());
+        for (Map<String, Object> t : plan.flowTiers) {
+            BigDecimal min = dec(t.get("min_prepay_amount"));
+            BigDecimal max = dec(t.get("max_prepay_amount"));
+            boolean ge = recharge.compareTo(min) >= 0;
+            boolean lt = (max == null) || recharge.compareTo(max) < 0;
+            if (ge && lt) return dec(t.get("unit_price"));
+        }
+        return plan.itemPrice.getOrDefault("FLOW_POSTPAID", BigDecimal.ZERO);
+    }
+
+    public static BigDecimal nvl(BigDecimal v) { return v == null ? BigDecimal.ZERO : v; }
+    public static BigDecimal dec(Object v) { if (v == null) return null; if (v instanceof BigDecimal) return (BigDecimal) v; return new BigDecimal(String.valueOf(v)); }
+    public static Long longVal(Object v) { if (v == null) return null; if (v instanceof Number) return ((Number) v).longValue(); return Long.parseLong(String.valueOf(v)); }
+    public static Integer intVal(Object v, Integer def) { if (v == null) return def; if (v instanceof Number) return ((Number) v).intValue(); return Integer.parseInt(String.valueOf(v)); }
+    public static String str(Object v, String def) { return v == null ? def : String.valueOf(v); }
+    public static String upper(String s) { return s == null ? null : s.trim().toUpperCase(Locale.ROOT); }
+    public static String defaultTxnNo(String prefix) { return prefix + new SimpleDateFormat("yyyyMMddHHmmssSSS").format(new Date()) + String.format("%03d", new Random().nextInt(1000)); }
+    public static Date parseDate(String s) {
+        try { return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse(s); }
+        catch (Exception e) { throw new CustomException("日期格式错误,应为 yyyy-MM-dd HH:mm:ss"); }
+    }
+    public static Timestamp ts(Date d) { return new Timestamp(d.getTime()); }
+
+    public static class TenantCfg {
+        /** 租户ID。 */
+        public Long tenantId;
+        /** 租户类型:AI/NON_AI。 */
+        public String tenantType;
+        /** 计费模式:PREPAID/POSTPAID。 */
+        public String billingMode;
+        /** 计费方案编码。 */
+        public String planCode;
+        /** 计费方案版本。 */
+        public Integer planVersion;
+    }
+
+    public static class PlanCfg {
+        /** 方案编码。 */
+        public String planCode;
+        /** 方案版本。 */
+        public Integer version;
+        /** 收费项单价表(itemCode -> unitPrice)。 */
+        public Map<String, BigDecimal> itemPrice = new HashMap<>();
+        /** Token计费单位(itemCode -> tokenUnit)。 */
+        public Map<String, Long> tokenUnit = new HashMap<>();
+        /** 流量阶梯配置集合。 */
+        public List<Map<String, Object>> flowTiers = new ArrayList<>();
+    }
+}
+

+ 129 - 0
fs-service/src/main/java/com/fs/billing/service/impl/BillingQueryServiceImpl.java

@@ -0,0 +1,129 @@
+package com.fs.billing.service.impl;
+
+import com.fs.billing.service.BillingServices;
+import com.fs.billing.vo.BillingVos;
+import com.fs.billing.domain.BillingStatement;
+import com.fs.billing.domain.BillingStatementItem;
+import com.fs.billing.mapper.BillingDetailMapper;
+import com.fs.billing.mapper.BillingStatementItemMapper;
+import com.fs.billing.mapper.BillingStatementMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.*;
+
+/**
+ * 计费查询与账单生成服务。
+ */
+@Service
+public class BillingQueryServiceImpl implements BillingServices.BillingQueryService {
+    /** 通用时间解析与工具能力。 */
+    private final BillingDbSupport db;
+    /** 计费明细Mapper。 */
+    private final BillingDetailMapper billingDetailMapper;
+    /** 账单主表Mapper。 */
+    private final BillingStatementMapper billingStatementMapper;
+    /** 账单项Mapper。 */
+    private final BillingStatementItemMapper billingStatementItemMapper;
+    public BillingQueryServiceImpl(BillingDbSupport db,
+                                   BillingDetailMapper billingDetailMapper,
+                                   BillingStatementMapper billingStatementMapper,
+                                   BillingStatementItemMapper billingStatementItemMapper) {
+        this.db = db;
+        this.billingDetailMapper = billingDetailMapper;
+        this.billingStatementMapper = billingStatementMapper;
+        this.billingStatementItemMapper = billingStatementItemMapper;
+    }
+
+    @Override
+    /**
+     * 平台端查询计费明细,支持按租户筛选。
+     */
+    public List<BillingVos.BillingDetailVo> listDetailsAdmin(Long tenantId) {
+        List<com.fs.billing.domain.BillingDetail> rows = billingDetailMapper.selectAdminDetails(tenantId);
+        List<BillingVos.BillingDetailVo> list = new ArrayList<>();
+        for (com.fs.billing.domain.BillingDetail r : rows) {
+            BillingVos.BillingDetailVo vo = new BillingVos.BillingDetailVo();
+            vo.setEventId(r.getEventId());
+            vo.setTenantId(r.getTenantId());
+            vo.setEventType(r.getEventType());
+            vo.setSubType(r.getSubType());
+            vo.setUsageValue(r.getUsageValue());
+            vo.setAmount(r.getAmount());
+            vo.setOccurredAt(r.getOccurredAt());
+            list.add(vo);
+        }
+        return list;
+    }
+
+    @Override
+    /**
+     * 查询租户计费明细列表。
+     */
+    public List<BillingVos.BillingDetailVo> listDetails(Long tenantId) {
+        List<com.fs.billing.domain.BillingDetail> rows = billingDetailMapper.selectByTenant(tenantId);
+        List<BillingVos.BillingDetailVo> list = new ArrayList<>();
+        for (com.fs.billing.domain.BillingDetail r : rows) {
+            BillingVos.BillingDetailVo vo = new BillingVos.BillingDetailVo();
+            vo.setEventId(r.getEventId());
+            vo.setTenantId(r.getTenantId());
+            vo.setEventType(r.getEventType());
+            vo.setSubType(r.getSubType());
+            vo.setUsageValue(r.getUsageValue());
+            vo.setAmount(r.getAmount());
+            vo.setOccurredAt(r.getOccurredAt());
+            list.add(vo);
+        }
+        return list;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    /**
+     * 生成账单并回填明细对应的账单ID。
+     */
+    public Map<String, Object> generateStatement(Long tenantId, String periodType, String periodStart, String periodEnd) {
+        Date start = BillingDbSupport.parseDate(periodStart);
+        Date end = BillingDbSupport.parseDate(periodEnd);
+        String statementNo = "ST" + System.currentTimeMillis();
+
+        BigDecimal total = BillingDbSupport.nvl(billingDetailMapper.sumUnstatementAmount(tenantId, start, end));
+
+        BillingStatement statement = new BillingStatement();
+        statement.setStatementNo(statementNo);
+        statement.setTenantId(tenantId);
+        statement.setPeriodType(periodType);
+        statement.setPeriodStart(start);
+        statement.setPeriodEnd(end);
+        statement.setTotalAmount(total);
+        statement.setPaidAmount(BigDecimal.ZERO);
+        statement.setUnpaidAmount(total);
+        statement.setStatus("INIT");
+        billingStatementMapper.insert(statement);
+        Long statementId = statement.getId() == null ? billingStatementMapper.selectIdByStatementNo(statementNo) : statement.getId();
+
+        List<Map<String, Object>> groups = billingDetailMapper.groupUnstatementByEventType(tenantId, start, end);
+        for (Map<String, Object> g : groups) {
+            BillingStatementItem item = new BillingStatementItem();
+            item.setStatementId(statementId);
+            item.setItemCode(BillingDbSupport.str(g.get("event_type"), "UNKNOWN"));
+            item.setAmount(BillingDbSupport.dec(g.get("amount")));
+            item.setUsageValue(BillingDbSupport.dec(g.get("usage")));
+            item.setUnit(null);
+            billingStatementItemMapper.insert(item);
+        }
+        billingDetailMapper.updateStatementId(statementId, tenantId, start, end);
+
+        Map<String, Object> m = new HashMap<>();
+        m.put("statementId", statementId);
+        m.put("statementNo", statementNo);
+        m.put("periodType", periodType);
+        m.put("periodStart", periodStart);
+        m.put("periodEnd", periodEnd);
+        m.put("totalAmount", total.setScale(2, RoundingMode.HALF_UP));
+        return m;
+    }
+}
+

+ 92 - 0
fs-service/src/main/java/com/fs/billing/service/impl/FeePlanServiceImpl.java

@@ -0,0 +1,92 @@
+package com.fs.billing.service.impl;
+
+import com.fs.billing.dto.FeePlanRequests;
+import com.fs.billing.domain.FeePlan;
+import com.fs.billing.domain.FeePlanFlowTier;
+import com.fs.billing.domain.FeePlanItem;
+import com.fs.billing.mapper.FeePlanFlowTierMapper;
+import com.fs.billing.mapper.FeePlanItemMapper;
+import com.fs.billing.mapper.FeePlanMapper;
+import com.fs.billing.service.BillingServices;
+import com.fs.common.exception.CustomException;
+import org.springframework.stereotype.Service;
+
+/**
+ * 计费方案配置服务(数据库实现)。
+ */
+@Service
+public class FeePlanServiceImpl implements BillingServices.FeePlanService {
+    /** 方案主表Mapper。 */
+    private final FeePlanMapper feePlanMapper;
+    /** 方案收费项Mapper。 */
+    private final FeePlanItemMapper feePlanItemMapper;
+    /** 方案流量阶梯Mapper。 */
+    private final FeePlanFlowTierMapper feePlanFlowTierMapper;
+    public FeePlanServiceImpl(FeePlanMapper feePlanMapper, FeePlanItemMapper feePlanItemMapper, FeePlanFlowTierMapper feePlanFlowTierMapper) {
+        this.feePlanMapper = feePlanMapper;
+        this.feePlanItemMapper = feePlanItemMapper;
+        this.feePlanFlowTierMapper = feePlanFlowTierMapper;
+    }
+
+    @Override
+    /**
+     * 创建或更新方案基础信息(草稿态)。
+     */
+    public void createPlan(FeePlanRequests.CreateReq req) {
+        FeePlan p = new FeePlan();
+        p.setPlanCode(req.getPlanCode());
+        p.setPlanName(req.getPlanName());
+        p.setVersion(req.getVersion());
+        p.setStatus("DRAFT");
+        p.setRemark(req.getRemark());
+        feePlanMapper.upsertPlan(p);
+    }
+
+    @Override
+    /**
+     * 保存方案收费项(逐项upsert)。
+     */
+    public void saveItems(FeePlanRequests.ItemSaveReq req) {
+        for (FeePlanRequests.ItemReq item : req.getItems()) {
+            FeePlanItem i = new FeePlanItem();
+            i.setPlanCode(req.getPlanCode());
+            i.setVersion(req.getVersion());
+            i.setItemCode(item.getItemCode());
+            i.setUnit(item.getUnit());
+            i.setUnitPrice(BillingDbSupport.nvl(item.getUnitPrice()));
+            i.setTokenUnit(item.getTokenUnit());
+            i.setMinChargeUnit(item.getMinChargeUnit());
+            i.setEnabled(item.getEnabled() == null ? 1 : item.getEnabled());
+            feePlanItemMapper.upsertItem(i);
+        }
+    }
+
+    @Override
+    /**
+     * 覆盖保存流量阶梯配置。
+     */
+    public void saveFlowTiers(FeePlanRequests.FlowTierSaveReq req) {
+        feePlanFlowTierMapper.deleteByPlan(req.getPlanCode(), req.getVersion());
+        req.getTiers().sort(java.util.Comparator.comparing(FeePlanRequests.FlowTierReq::getMinPrepayAmount));
+        for (FeePlanRequests.FlowTierReq tier : req.getTiers()) {
+            FeePlanFlowTier t = new FeePlanFlowTier();
+            t.setPlanCode(req.getPlanCode());
+            t.setVersion(req.getVersion());
+            t.setMinPrepayAmount(tier.getMinPrepayAmount());
+            t.setMaxPrepayAmount(tier.getMaxPrepayAmount());
+            t.setUnitPrice(BillingDbSupport.nvl(tier.getUnitPrice()));
+            t.setSortNo(tier.getSortNo() == null ? 0 : tier.getSortNo());
+            feePlanFlowTierMapper.insert(t);
+        }
+    }
+
+    @Override
+    /**
+     * 发布指定方案版本。
+     */
+    public void publish(String planCode, Integer version) {
+        int c = feePlanMapper.publish(planCode, version);
+        if (c <= 0) throw new CustomException("方案不存在,无法发布");
+    }
+}
+

+ 43 - 0
fs-service/src/main/java/com/fs/billing/service/impl/TenantBillingServiceImpl.java

@@ -0,0 +1,43 @@
+package com.fs.billing.service.impl;
+
+import com.fs.billing.dto.TenantBillingRequests;
+import com.fs.billing.mapper.BillingTenantMapper;
+import com.fs.billing.service.BillingServices;
+import com.fs.common.exception.CustomException;
+import org.springframework.stereotype.Service;
+
+/**
+ * 租户计费绑定服务。
+ */
+@Service
+public class TenantBillingServiceImpl implements BillingServices.TenantBillingService {
+    /** 租户计费配置Mapper。 */
+    private final BillingTenantMapper billingTenantMapper;
+    public TenantBillingServiceImpl(BillingTenantMapper billingTenantMapper) { this.billingTenantMapper = billingTenantMapper; }
+
+    @Override
+    /**
+     * 绑定租户计费方案及基础计费属性。
+     */
+    public void bindPlan(TenantBillingRequests.BindPlanReq req) {
+        int c = billingTenantMapper.bindPlan(req.getTenantId(), req.getTenantType(), req.getBillingMode(), req.getPlanCode(), req.getPlanVersion());
+        if (c <= 0) throw new CustomException("租户不存在或绑定失败");
+    }
+
+    @Override
+    /**
+     * 修改租户计费模式。
+     */
+    public void changeBillingMode(TenantBillingRequests.ChangeBillingModeReq req) {
+        billingTenantMapper.changeBillingMode(req.getTenantId(), req.getBillingMode());
+    }
+
+    @Override
+    /**
+     * 修改租户类型。
+     */
+    public void changeTenantType(TenantBillingRequests.ChangeTenantTypeReq req) {
+        billingTenantMapper.changeTenantType(req.getTenantId(), req.getTenantType());
+    }
+}
+

+ 200 - 0
fs-service/src/main/java/com/fs/billing/service/impl/UsageEventServiceImpl.java

@@ -0,0 +1,200 @@
+package com.fs.billing.service.impl;
+
+import com.alibaba.fastjson.JSON;
+import com.fs.billing.domain.BillingDetail;
+import com.fs.billing.domain.TenantWallet;
+import com.fs.billing.domain.TenantWalletTxn;
+import com.fs.billing.domain.UsageEvent;
+import com.fs.billing.dto.UsageEventReportReq;
+import com.fs.billing.mapper.BillingDetailMapper;
+import com.fs.billing.mapper.TenantWalletMapper;
+import com.fs.billing.mapper.TenantWalletTxnMapper;
+import com.fs.billing.mapper.UsageEventMapper;
+import com.fs.billing.service.BillingServices;
+import com.fs.billing.vo.BillingVos;
+import com.fs.common.exception.CustomException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.Date;
+
+/**
+ * 用量事件上报与计费执行服务。
+ */
+@Service
+public class UsageEventServiceImpl implements BillingServices.UsageEventService {
+    /** 计费配置与通用计算支撑。 */
+    private final BillingDbSupport db;
+    /** 用量事件Mapper(幂等校验+入库)。 */
+    private final UsageEventMapper usageEventMapper;
+    /** 计费明细Mapper。 */
+    private final BillingDetailMapper billingDetailMapper;
+    /** 钱包Mapper(预付费扣费)。 */
+    private final TenantWalletMapper tenantWalletMapper;
+    /** 钱包流水Mapper(扣费流水)。 */
+    private final TenantWalletTxnMapper tenantWalletTxnMapper;
+    public UsageEventServiceImpl(BillingDbSupport db,
+                                 UsageEventMapper usageEventMapper,
+                                 BillingDetailMapper billingDetailMapper,
+                                 TenantWalletMapper tenantWalletMapper,
+                                 TenantWalletTxnMapper tenantWalletTxnMapper) {
+        this.db = db;
+        this.usageEventMapper = usageEventMapper;
+        this.billingDetailMapper = billingDetailMapper;
+        this.tenantWalletMapper = tenantWalletMapper;
+        this.tenantWalletTxnMapper = tenantWalletTxnMapper;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    /**
+     * 上报用量事件并执行计费(含预付费自动扣费)。
+     */
+    public BillingVos.ChargeResultVo reportAndCharge(UsageEventReportReq req) {
+        Integer exists = usageEventMapper.existsByEventId(req.getEventId());
+        if (exists != null && exists > 0) {
+            return new BillingVos.ChargeResultVo(req.getEventId(), false, BigDecimal.ZERO, "重复事件已忽略");
+        }
+        if (req.getOccurredAt() == null) req.setOccurredAt(new Date());
+
+        UsageEvent event = new UsageEvent();
+        event.setEventId(req.getEventId());
+        event.setTenantId(req.getTenantId());
+        event.setEventType(req.getEventType());
+        event.setSubType(req.getSubType());
+        event.setBizId(req.getBizId());
+        event.setUsageValue(BillingDbSupport.nvl(req.getUsageValue()));
+        event.setUsageUnit(req.getUsageUnit());
+        event.setOccurredAt(req.getOccurredAt());
+        event.setExtJson(req.getExtJson() == null ? null : JSON.toJSONString(req.getExtJson()));
+        usageEventMapper.insert(event);
+
+        BillingDbSupport.TenantCfg tenant = db.loadTenantCfg(req.getTenantId());
+        BillingDbSupport.PlanCfg plan = db.loadPlanCfg(tenant.planCode, tenant.planVersion);
+
+        ChargeMeta meta = calc(req, tenant, plan);
+        BillingDetail detail = new BillingDetail();
+        detail.setTenantId(req.getTenantId());
+        detail.setEventId(req.getEventId());
+        detail.setEventType(req.getEventType());
+        detail.setSubType(meta.subTypeForDetail);
+        detail.setPlanCode(tenant.planCode);
+        detail.setPlanVersion(tenant.planVersion);
+        detail.setUnitPrice(meta.unitPrice);
+        detail.setUsageValue(BillingDbSupport.nvl(req.getUsageValue()));
+        detail.setChargeValue(meta.chargeValue);
+        detail.setAmount(meta.amount);
+        detail.setBillingMode(tenant.billingMode);
+        detail.setOccurredAt(req.getOccurredAt());
+        billingDetailMapper.insert(detail);
+
+        if (meta.aiExtraAmount.compareTo(BigDecimal.ZERO) > 0) {
+            BillingDetail ai = new BillingDetail();
+            ai.setTenantId(req.getTenantId());
+            ai.setEventId(req.getEventId());
+            ai.setEventType(req.getEventType());
+            ai.setSubType("AI_CALL");
+            ai.setPlanCode(tenant.planCode);
+            ai.setPlanVersion(tenant.planVersion);
+            ai.setUnitPrice(meta.aiUnitPrice);
+            ai.setUsageValue(BillingDbSupport.nvl(req.getUsageValue()));
+            ai.setChargeValue(meta.chargeValue);
+            ai.setAmount(meta.aiExtraAmount);
+            ai.setBillingMode(tenant.billingMode);
+            ai.setOccurredAt(req.getOccurredAt());
+            billingDetailMapper.insert(ai);
+        }
+
+        if ("PREPAID".equalsIgnoreCase(tenant.billingMode)) {
+            db.ensureWalletExists(req.getTenantId());
+            BigDecimal total = meta.amount.add(meta.aiExtraAmount).setScale(2, RoundingMode.HALF_UP);
+            tenantWalletMapper.deduct(req.getTenantId(), total);
+            TenantWallet wallet = tenantWalletMapper.selectByTenantId(req.getTenantId());
+            TenantWalletTxn txn = new TenantWalletTxn();
+            txn.setTenantId(req.getTenantId());
+            txn.setTxnNo(BillingDbSupport.defaultTxnNo("CS"));
+            txn.setTxnType("CONSUME");
+            txn.setAmount(total.negate());
+            txn.setBalanceAfter(wallet.getBalanceAmount());
+            txn.setBizType(req.getEventType());
+            txn.setBizId(req.getEventId());
+            txn.setRemark("自动计费扣费");
+            txn.setCreateBy("system");
+            tenantWalletTxnMapper.insert(txn);
+        }
+        BigDecimal totalAmount = meta.amount.add(meta.aiExtraAmount).setScale(2, RoundingMode.HALF_UP);
+        return new BillingVos.ChargeResultVo(req.getEventId(), true, totalAmount, "计费成功");
+    }
+
+    /**
+     * 按事件类型计算计费单价、计费量和金额。
+     */
+    private ChargeMeta calc(UsageEventReportReq req, BillingDbSupport.TenantCfg tenant, BillingDbSupport.PlanCfg plan) {
+        String type = BillingDbSupport.upper(req.getEventType());
+        ChargeMeta m = new ChargeMeta();
+        m.subTypeForDetail = req.getSubType();
+        m.chargeValue = BillingDbSupport.nvl(req.getUsageValue());
+        m.amount = BigDecimal.ZERO;
+        m.aiExtraAmount = BigDecimal.ZERO;
+        m.aiUnitPrice = BigDecimal.ZERO;
+        if ("FLOW".equals(type)) {
+            BigDecimal p = "POSTPAID".equalsIgnoreCase(tenant.billingMode) ? plan.itemPrice.getOrDefault("FLOW_POSTPAID", BigDecimal.ZERO) : db.flowTierPrice(tenant.tenantId, plan);
+            m.unitPrice = p;
+            m.amount = BillingDbSupport.nvl(req.getUsageValue()).multiply(p).setScale(2, RoundingMode.HALF_UP);
+            return m;
+        }
+        if ("CALL".equals(type)) {
+            BigDecimal mins = BillingDbSupport.nvl(req.getUsageValue()).divide(new BigDecimal("60"), 0, RoundingMode.CEILING);
+            BigDecimal callUnit = plan.itemPrice.getOrDefault(BillingDbSupport.upper(req.getSubType()), BigDecimal.ZERO);
+            m.unitPrice = callUnit;
+            m.chargeValue = mins;
+            m.amount = mins.multiply(callUnit).setScale(2, RoundingMode.HALF_UP);
+            boolean ai = req.getExtJson() != null && Boolean.TRUE.equals(req.getExtJson().get("isAiCall"));
+            if (ai) {
+                m.aiUnitPrice = plan.itemPrice.getOrDefault("AI_CALL", BigDecimal.ZERO);
+                m.aiExtraAmount = mins.multiply(m.aiUnitPrice).setScale(2, RoundingMode.HALF_UP);
+            }
+            return m;
+        }
+        if ("TOKEN_SOP".equals(type) || "TOKEN_AI_REPLY".equals(type)) {
+            String code = "TOKEN_SOP".equals(type) ? "SOP_TOKEN" : "AI_REPLY_TOKEN";
+            BigDecimal unitPrice = plan.itemPrice.getOrDefault(code, BigDecimal.ZERO);
+            Long tokenUnit = plan.tokenUnit.getOrDefault(code, 100000L);
+            m.unitPrice = unitPrice;
+            m.amount = BillingDbSupport.nvl(req.getUsageValue()).divide(new BigDecimal(String.valueOf(tokenUnit)), 6, RoundingMode.HALF_UP)
+                    .multiply(unitPrice).setScale(2, RoundingMode.HALF_UP);
+            return m;
+        }
+        if ("ADD_WECHAT".equals(type)) {
+            m.unitPrice = plan.itemPrice.getOrDefault("ADD_WECHAT", BigDecimal.ZERO);
+            m.amount = BillingDbSupport.nvl(req.getUsageValue()).multiply(m.unitPrice).setScale(2, RoundingMode.HALF_UP);
+            return m;
+        }
+        if ("OPEN_ACCOUNT".equals(type)) {
+            String code = "AI".equalsIgnoreCase(tenant.tenantType) ? "OPEN_ACCOUNT_AI" : "OPEN_ACCOUNT_NON_AI";
+            m.unitPrice = plan.itemPrice.getOrDefault(code, BigDecimal.ZERO);
+            m.chargeValue = BigDecimal.ONE;
+            m.amount = m.unitPrice.setScale(2, RoundingMode.HALF_UP);
+            return m;
+        }
+        throw new CustomException("不支持的计费类型: " + req.getEventType());
+    }
+
+    private static class ChargeMeta {
+        /** 明细子类型。 */
+        String subTypeForDetail;
+        /** 单价。 */
+        BigDecimal unitPrice;
+        /** 计费量。 */
+        BigDecimal chargeValue;
+        /** 主计费金额。 */
+        BigDecimal amount;
+        /** AI附加单价。 */
+        BigDecimal aiUnitPrice;
+        /** AI附加金额。 */
+        BigDecimal aiExtraAmount;
+    }
+}
+

+ 76 - 0
fs-service/src/main/java/com/fs/billing/service/impl/WalletServiceImpl.java

@@ -0,0 +1,76 @@
+package com.fs.billing.service.impl;
+
+import com.fs.billing.dto.TenantBillingRequests;
+import com.fs.billing.domain.TenantWallet;
+import com.fs.billing.domain.TenantWalletTxn;
+import com.fs.billing.mapper.TenantWalletMapper;
+import com.fs.billing.mapper.TenantWalletTxnMapper;
+import com.fs.billing.service.BillingServices;
+import com.fs.billing.vo.BillingVos;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+/**
+ * 钱包服务(充值/查询)。
+ */
+@Service
+public class WalletServiceImpl implements BillingServices.WalletService {
+    /** 计费公共能力(租户上下文/钱包兜底)。 */
+    private final BillingDbSupport db;
+    /** 钱包Mapper。 */
+    private final TenantWalletMapper tenantWalletMapper;
+    /** 钱包流水Mapper。 */
+    private final TenantWalletTxnMapper tenantWalletTxnMapper;
+    public WalletServiceImpl(BillingDbSupport db, TenantWalletMapper tenantWalletMapper, TenantWalletTxnMapper tenantWalletTxnMapper) {
+        this.db = db;
+        this.tenantWalletMapper = tenantWalletMapper;
+        this.tenantWalletTxnMapper = tenantWalletTxnMapper;
+    }
+
+    @Override
+    /**
+     * 查询租户钱包余额与累计统计。
+     */
+    public BillingVos.WalletVo getWallet(Long tenantId) {
+        db.ensureWalletExists(tenantId);
+        TenantWallet wallet = tenantWalletMapper.selectByTenantId(tenantId);
+        if (wallet == null) {
+            BillingVos.WalletVo vo = new BillingVos.WalletVo();
+            vo.setTenantId(tenantId);
+            vo.setBalanceAmount(BigDecimal.ZERO);
+            vo.setTotalRecharge(BigDecimal.ZERO);
+            vo.setTotalCost(BigDecimal.ZERO);
+            return vo;
+        }
+        BillingVos.WalletVo vo = new BillingVos.WalletVo();
+        vo.setTenantId(wallet.getTenantId());
+        vo.setBalanceAmount(wallet.getBalanceAmount());
+        vo.setTotalRecharge(wallet.getTotalRecharge());
+        vo.setTotalCost(wallet.getTotalCost());
+        return vo;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    /**
+     * 钱包充值并记录流水。
+     */
+    public void recharge(TenantBillingRequests.WalletRechargeReq req) {
+        db.ensureWalletExists(req.getTenantId());
+        tenantWalletMapper.recharge(req.getTenantId(), req.getAmount());
+        TenantWallet wallet = tenantWalletMapper.selectByTenantId(req.getTenantId());
+        TenantWalletTxn txn = new TenantWalletTxn();
+        txn.setTenantId(req.getTenantId());
+        txn.setTxnNo(BillingDbSupport.defaultTxnNo("RC"));
+        txn.setTxnType("RECHARGE");
+        txn.setAmount(req.getAmount());
+        txn.setBalanceAfter(wallet.getBalanceAmount());
+        txn.setBizType("RECHARGE");
+        txn.setBizId(req.getBizNo());
+        txn.setRemark(req.getRemark());
+        txn.setCreateBy("system");
+        tenantWalletTxnMapper.insert(txn);
+    }
+}
+

+ 40 - 0
fs-service/src/main/java/com/fs/billing/vo/BillingVos.java

@@ -0,0 +1,40 @@
+package com.fs.billing.vo;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+public class BillingVos {
+    @Data
+    @NoArgsConstructor
+    @AllArgsConstructor
+    public static class ChargeResultVo {
+        private String eventId;
+        private Boolean accepted;
+        private BigDecimal amount;
+        private String message;
+    }
+
+    @Data
+    public static class WalletVo {
+        private Long tenantId;
+        private BigDecimal balanceAmount;
+        private BigDecimal totalRecharge;
+        private BigDecimal totalCost;
+    }
+
+    @Data
+    public static class BillingDetailVo {
+        private String eventId;
+        private Long tenantId;
+        private String eventType;
+        private String subType;
+        private BigDecimal usageValue;
+        private BigDecimal amount;
+        private Date occurredAt;
+    }
+}
+

+ 69 - 0
fs-service/src/main/resources/mapper/billing/BillingDetailMapper.xml

@@ -0,0 +1,69 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper
+        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.fs.billing.mapper.BillingDetailMapper">
+    <resultMap id="BillingDetailResult" type="com.fs.billing.domain.BillingDetail">
+        <id property="id" column="id"/>
+        <result property="tenantId" column="tenant_id"/>
+        <result property="statementId" column="statement_id"/>
+        <result property="eventId" column="event_id"/>
+        <result property="eventType" column="event_type"/>
+        <result property="subType" column="sub_type"/>
+        <result property="planCode" column="plan_code"/>
+        <result property="planVersion" column="plan_version"/>
+        <result property="unitPrice" column="unit_price"/>
+        <result property="usageValue" column="usage_value"/>
+        <result property="chargeValue" column="charge_value"/>
+        <result property="amount" column="amount"/>
+        <result property="billingMode" column="billing_mode"/>
+        <result property="occurredAt" column="occurred_at"/>
+        <result property="createTime" column="create_time"/>
+    </resultMap>
+
+    <select id="selectByTenant" resultMap="BillingDetailResult">
+        select * from billing_detail
+        where tenant_id = #{tenantId}
+        order by occurred_at desc, id desc
+    </select>
+
+    <select id="selectAdminDetails" resultMap="BillingDetailResult">
+        select *
+        from billing_detail
+        <where>
+            <if test="tenantId != null">
+                tenant_id = #{tenantId}
+            </if>
+        </where>
+        order by occurred_at desc, id desc
+    </select>
+
+    <select id="sumUnstatementAmount" resultType="java.math.BigDecimal">
+        select IFNULL(sum(amount),0)
+        from billing_detail
+        where tenant_id = #{tenantId}
+          and statement_id is null
+          and occurred_at >= #{start}
+          and occurred_at &lt;= #{end}
+    </select>
+
+    <select id="groupUnstatementByEventType" resultType="java.util.Map">
+        select event_type, sum(amount) amount, sum(charge_value) usage
+        from billing_detail
+        where tenant_id = #{tenantId}
+          and statement_id is null
+          and occurred_at >= #{start}
+          and occurred_at &lt;= #{end}
+        group by event_type
+    </select>
+
+    <update id="updateStatementId">
+        update billing_detail
+        set statement_id = #{statementId}
+        where tenant_id = #{tenantId}
+          and statement_id is null
+          and occurred_at >= #{start}
+          and occurred_at &lt;= #{end}
+    </update>
+</mapper>
+

+ 7 - 0
fs-service/src/main/resources/mapper/billing/BillingStatementItemMapper.xml

@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper
+        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.fs.billing.mapper.BillingStatementItemMapper">
+</mapper>
+

+ 13 - 0
fs-service/src/main/resources/mapper/billing/BillingStatementMapper.xml

@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper
+        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.fs.billing.mapper.BillingStatementMapper">
+    <select id="selectIdByStatementNo" resultType="java.lang.Long">
+        select id
+        from billing_statement
+        where statement_no = #{statementNo}
+        limit 1
+    </select>
+</mapper>
+

+ 30 - 0
fs-service/src/main/resources/mapper/billing/BillingTenantMapper.xml

@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper
+        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.fs.billing.mapper.BillingTenantMapper">
+    <select id="selectTenantBillingCfg" resultType="java.util.Map">
+        select id, tenant_type, billing_mode, fee_plan_code, fee_plan_version
+        from tenant_info
+        where id = #{tenantId}
+        limit 1
+    </select>
+
+    <update id="bindPlan">
+        update tenant_info
+        set tenant_type = #{tenantType},
+            billing_mode = #{billingMode},
+            fee_plan_code = #{planCode},
+            fee_plan_version = #{planVersion}
+        where id = #{tenantId}
+    </update>
+
+    <update id="changeBillingMode">
+        update tenant_info set billing_mode = #{billingMode} where id = #{tenantId}
+    </update>
+
+    <update id="changeTenantType">
+        update tenant_info set tenant_type = #{tenantType} where id = #{tenantId}
+    </update>
+</mapper>
+

+ 31 - 0
fs-service/src/main/resources/mapper/billing/FeePlanFlowTierMapper.xml

@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper
+        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.fs.billing.mapper.FeePlanFlowTierMapper">
+    <resultMap id="TierResult" type="com.fs.billing.domain.FeePlanFlowTier">
+        <id property="id" column="id"/>
+        <result property="planCode" column="plan_code"/>
+        <result property="version" column="version"/>
+        <result property="minPrepayAmount" column="min_prepay_amount"/>
+        <result property="maxPrepayAmount" column="max_prepay_amount"/>
+        <result property="unitPrice" column="unit_price"/>
+        <result property="sortNo" column="sort_no"/>
+        <result property="createTime" column="create_time"/>
+    </resultMap>
+
+    <select id="selectByPlan" resultMap="TierResult">
+        select *
+        from fee_plan_flow_tier
+        where plan_code = #{planCode}
+          and version = #{version}
+        order by sort_no asc, min_prepay_amount asc
+    </select>
+
+    <delete id="deleteByPlan">
+        delete from fee_plan_flow_tier
+        where plan_code = #{planCode}
+          and version = #{version}
+    </delete>
+</mapper>
+

+ 39 - 0
fs-service/src/main/resources/mapper/billing/FeePlanItemMapper.xml

@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper
+        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.fs.billing.mapper.FeePlanItemMapper">
+    <resultMap id="FeePlanItemResult" type="com.fs.billing.domain.FeePlanItem">
+        <id property="id" column="id"/>
+        <result property="planCode" column="plan_code"/>
+        <result property="version" column="version"/>
+        <result property="itemCode" column="item_code"/>
+        <result property="unit" column="unit"/>
+        <result property="unitPrice" column="unit_price"/>
+        <result property="tokenUnit" column="token_unit"/>
+        <result property="minChargeUnit" column="min_charge_unit"/>
+        <result property="enabled" column="enabled"/>
+        <result property="createTime" column="create_time"/>
+        <result property="updateTime" column="update_time"/>
+    </resultMap>
+
+    <select id="selectEnabledItems" resultMap="FeePlanItemResult">
+        select *
+        from fee_plan_item
+        where plan_code = #{planCode}
+          and version = #{version}
+          and enabled = 1
+    </select>
+
+    <insert id="upsertItem" parameterType="com.fs.billing.domain.FeePlanItem">
+        insert into fee_plan_item(plan_code, version, item_code, unit, unit_price, token_unit, min_charge_unit, enabled)
+        values (#{planCode}, #{version}, #{itemCode}, #{unit}, #{unitPrice}, #{tokenUnit}, #{minChargeUnit}, #{enabled})
+        ON DUPLICATE KEY UPDATE
+            unit = values(unit),
+            unit_price = values(unit_price),
+            token_unit = values(token_unit),
+            min_charge_unit = values(min_charge_unit),
+            enabled = values(enabled)
+    </insert>
+</mapper>
+

+ 46 - 0
fs-service/src/main/resources/mapper/billing/FeePlanMapper.xml

@@ -0,0 +1,46 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper
+        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.fs.billing.mapper.FeePlanMapper">
+    <resultMap id="FeePlanResult" type="com.fs.billing.domain.FeePlan">
+        <id property="id" column="id"/>
+        <result property="planCode" column="plan_code"/>
+        <result property="planName" column="plan_name"/>
+        <result property="version" column="version"/>
+        <result property="status" column="status"/>
+        <result property="effectiveTime" column="effective_time"/>
+        <result property="expireTime" column="expire_time"/>
+        <result property="remark" column="remark"/>
+        <result property="createBy" column="create_by"/>
+        <result property="createTime" column="create_time"/>
+        <result property="updateBy" column="update_by"/>
+        <result property="updateTime" column="update_time"/>
+    </resultMap>
+
+    <select id="selectPublishedByCodeAndVersion" resultMap="FeePlanResult">
+        select *
+        from fee_plan
+        where plan_code = #{planCode}
+          and version = #{version}
+          and status = 'PUBLISHED'
+        limit 1
+    </select>
+
+    <insert id="upsertPlan" parameterType="com.fs.billing.domain.FeePlan">
+        insert into fee_plan(plan_code, plan_name, version, status, remark)
+        values (#{planCode}, #{planName}, #{version}, #{status}, #{remark})
+        ON DUPLICATE KEY UPDATE
+            plan_name = values(plan_name),
+            remark = values(remark)
+    </insert>
+
+    <update id="publish">
+        update fee_plan
+        set status = 'PUBLISHED',
+            effective_time = now()
+        where plan_code = #{planCode}
+          and version = #{version}
+    </update>
+</mapper>
+

+ 42 - 0
fs-service/src/main/resources/mapper/billing/TenantWalletMapper.xml

@@ -0,0 +1,42 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper
+        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.fs.billing.mapper.TenantWalletMapper">
+    <resultMap id="WalletResult" type="com.fs.billing.domain.TenantWallet">
+        <id property="id" column="id"/>
+        <result property="tenantId" column="tenant_id"/>
+        <result property="balanceAmount" column="balance_amount"/>
+        <result property="frozenAmount" column="frozen_amount"/>
+        <result property="creditLimit" column="credit_limit"/>
+        <result property="totalRecharge" column="total_recharge"/>
+        <result property="totalCost" column="total_cost"/>
+        <result property="updateTime" column="update_time"/>
+        <result property="createTime" column="create_time"/>
+    </resultMap>
+
+    <update id="ensureWalletExists">
+        INSERT INTO tenant_wallet(tenant_id,balance_amount,frozen_amount,credit_limit,total_recharge,total_cost)
+        VALUES(#{tenantId},0,0,0,0,0)
+        ON DUPLICATE KEY UPDATE tenant_id=tenant_id
+    </update>
+
+    <select id="selectByTenantId" resultMap="WalletResult">
+        select * from tenant_wallet where tenant_id = #{tenantId} limit 1
+    </select>
+
+    <update id="recharge">
+        update tenant_wallet
+        set balance_amount = balance_amount + #{amount},
+            total_recharge = total_recharge + #{amount}
+        where tenant_id = #{tenantId}
+    </update>
+
+    <update id="deduct">
+        update tenant_wallet
+        set balance_amount = balance_amount - #{amount},
+            total_cost = total_cost + #{amount}
+        where tenant_id = #{tenantId}
+    </update>
+</mapper>
+

+ 7 - 0
fs-service/src/main/resources/mapper/billing/TenantWalletTxnMapper.xml

@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper
+        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.fs.billing.mapper.TenantWalletTxnMapper">
+</mapper>
+

+ 10 - 0
fs-service/src/main/resources/mapper/billing/UsageEventMapper.xml

@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper
+        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.fs.billing.mapper.UsageEventMapper">
+    <select id="existsByEventId" resultType="java.lang.Integer">
+        select count(1) from usage_event where event_id = #{eventId}
+    </select>
+</mapper>
+