浏览代码

Merge branch 'dev'

skyline 1 天之前
父节点
当前提交
6a8009e4a8

+ 77 - 0
CLAUDE.md

@@ -0,0 +1,77 @@
+# 洗车系统 (car-wash-java)
+
+## 项目结构
+
+```
+car-wash-java/
+├── car-wash-entity/      # 实体、VO、SQL 迁移脚本
+├── car-wash-mapper/      # MyBatis-Plus Mapper
+├── car-wash-service/     # 核心业务逻辑
+├── car-wash-common/      # 共享工具(Sa-Token、Redis、AOP)
+├── car-wash-admin/       # 管理后台 API(Spring Boot)
+├── car-wash-miniapp/     # 微信小程序 API(Spring Boot)
+├── car-wash-mp/          # 微信公众号 API(Spring Boot)
+├── admin-web/            # 运营管理后台前端(Vue 3 + Element Plus + Vite)
+├── admin-h5/             # 运营商 H5(uni-app)
+└── admin-web-new/        # 管理后台前端重构版(Vite + Vue 3)
+```
+
+## 技术栈
+
+- **后端**: Java 21, Spring Boot 3.3.3, MyBatis-Plus 3.x, Sa-Token, Redis + Redisson, RabbitMQ, 微信支付 APIv3
+- **前端**: Vue 3 + TypeScript + Vite + Element Plus + Pinia + uni-app
+- **构建**: Maven 多模块
+
+## 核心业务表
+
+| 表 | 用途 |
+|---|---|
+| `t_user` | 微信小程序用户(`station_id` 标识归属站点) |
+| `t_wash_order` | 洗车订单(`is_cross` 标记是否跨店消费) |
+| `t_split_record` | 分账流水,按类型追踪站点间资金流向 |
+| `t_settlement_record` | 月度结算单(幂等键 `uk_station_period`) |
+| `t_station_account` | 站点账户余额(`available_balance` 可提现额) |
+| `t_wallet_detail` | 用户钱包流水(充值/消费/退款) |
+| `t_pay_log` | 微信支付日志(`out_trade_no` 商户订单号,`transaction_id` 微信交易号) |
+| `t_recharge_config` | 充值配置(归属 `group_id` 分组) |
+| `t_recharge_config_group` | 充值配置分组(`is_default=1` 为默认分组) |
+
+## 结算 V2 模型
+
+月度结算由 `SettlementServiceImpl.executeMonthlySettlement()` 驱动,每月对上月数据进行结算。
+
+**SplitRecord 资金类型**:
+| 类型 | 值 | 方向 |
+|---|---|---|
+| `TYPE_RECHARGE` | 1 | 同一站点收支,`from=to` |
+| `TYPE_CROSS_EXPEND` | 4 | 跨店:归属站 → 消费站 |
+| `TYPE_CROSS_INCOME` | 6 | 跨店:归属站 → 消费站(与 EXPEND 配对) |
+| `TYPE_REFUND` | 5 | 退款支出 |
+
+**月结公式**:
+```
+feeBase = totalRecharge - totalRefund - totalCrossExpend + totalCrossIncome
+platformFee = feeBase × 10%
+available = openingBalance + feeBase - platformFee
+```
+
+`available > 0` 时正常结算,转入站点可提现余额,同时记录平台服务费。负数则结转下期。
+
+## 关键业务链路
+
+### 用户注册与站点归属
+- 扫码设备注册:直接通过 `shortId` 确定 `stationId`(`UserServiceImpl.java:135-137`)
+- 自主注册:`stationId = null`,首次消费时在 `WashOrderServiceImpl.createOrder()` 中自动归属
+
+### 充值 → 分账
+- 有归属站:`WxPayServiceImpl.wxNotify()` 中创建 `SplitRecord(TYPE_RECHARGE, from=stationId, to=stationId)`
+- 无归属站:不创建 SplitRecord,由 `OrderSettlementServiceImpl.backfillUnattributedRecharges()` 在首次消费结算时追溯补建
+
+### 消费 → 结算
+- `createOrder()` → 扣预付款,设置 `isCross` 标记
+- `settleOrder()` → 扣款,回填无归属充值,本店跳过/跨店创建分账
+
+## 注意事项
+- `SaToken` 注解 `@SaIgnore` 需要确保被 Spring 代理拦截才能生效
+- 分账记录 `tradeNo` 与微信支付 `transactionId` 对应,通过 `t_pay_log` 桥接 `WalletDetail`
+- 月结有幂等保护(`uk_station_period`),重复执行不会产生重复结算单

+ 11 - 3
admin-web/vite.config.ts

@@ -46,9 +46,17 @@ const viteConfig = defineConfig((mode: ConfigEnv) => {
                     chunkFileNames: `assets/[name].[hash].js`,
                     assetFileNames: `assets/[name].[hash].[ext]`,
                     compact: true,
-                    manualChunks: {
-                        vue: ['vue', 'vue-router', 'pinia'],
-                        echarts: ['echarts'],
+                    manualChunks(id) {
+                        if (!id.includes('node_modules')) return;
+                        if (id.includes('echarts-gl')) return 'echarts-gl';
+                        if (id.includes('echarts')) return 'echarts';
+                        if (id.includes('element-plus')) return 'element-plus';
+                        if (id.includes('vxe-table') || id.includes('xe-utils')) return 'vxe-table';
+                        if (id.includes('xlsx')) return 'xlsx';
+                        if (id.includes('@wangeditor')) return 'wangeditor';
+                        if (id.includes('jspdf') || id.includes('html2canvas')) return 'pdf-utils';
+                        if (id.includes('lodash')) return 'lodash';
+                        if (id.includes('vue') || id.includes('pinia') || id.includes('vue-router')) return 'vue';
                     },
                 },
             },

+ 11 - 0
car-wash-admin/src/main/resources/application-dev.yml

@@ -72,3 +72,14 @@ spring:
 kym:
   notify-email: skyline@kuaiyuman.cn
   domain: https://dev-wash.kuaiyuman.cn
+
+aliyun:
+  lot:
+    productKey: k1olfsszoYB
+    amqp:
+      accessKey: LTAI5t8urs6Xiw2YNbR7u158
+      accessSecret: UiLp1cB7ZeJ00pzf6Phy8t3VFz52ut
+      consumerGroupId: DEFAULT_GROUP
+      iotInstanceId: iot-06z00hb4ys0z7ri
+      host: iot-06z00hb4ys0z7ri.amqp.iothub.aliyuncs.com
+      clientId: wash-dev

+ 11 - 0
car-wash-admin/src/main/resources/application-prod.yml

@@ -73,3 +73,14 @@ spring:
 kym:
   notify-email: zaizai@kuaiyuman.cn,skyline@kuaiyuman.cn
   domain: https://api.yeswash.cn
+
+aliyun:
+  lot:
+    productKey: k2af0H4KLSU
+    amqp:
+      accessKey: LTAI5tPSodsNfiWEQxqDe4XT
+      accessSecret: xVswlKsGS6zHRixVwRjUmgGDuOxI2r
+      consumerGroupId: DEFAULT_GROUP
+      iotInstanceId: iot-06z00bnaqryq2gj
+      host: iot-06z00bnaqryq2gj.amqp.iothub.aliyuncs.com
+      clientId: wash-prod

+ 0 - 11
car-wash-admin/src/main/resources/application.yml

@@ -104,14 +104,3 @@ oss:
 password:
  privateKey: MIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBAM1zhmu+TcTSZSQ7w0541g45o8ji4ugEC4O31GnS6tfvLTCru+9DPK2/DNdd4z1enz2PuDhktHuoEsEKPdA08fRJSMTXLL0pUEp2OQ+t7tZP6mVLvizasnP+HKAqIndXFr5nXm/okQfL/f/6L2Bben9sItoxC28Z6Y28NfAJPAg1AgMBAAECgYAKOdvQ9RHt4AMEwKzB9SXCY4AReamNntXr4nSCJ+tkgBUhvQqHqDMW+tFqztOGtHT8nXCv7eNF3GHClf3ppRj91utk8zAwZPVAVlRoNcWs60nyKRUO2uhwAV8AE+9UKDoVui7L7UaMcIkssKqQbFGIRXUjjSoPJu0yoHCdp5/3QQJBAOZTbfgmFXVgRSH4IMXJ3aZOqz+Wy3EmvNatYz8NYLBFgLJTWWXtR7URw82R7jL6F9ettfCityhAmXEZnZsEsokCQQDkWkYwFZlUYJ2ctdNEmipXw8tjpCrzQRaZnydXbjviwbSpOvOo5nrxSG5BtL9QDwiy9DL7YLBVJPykAkJm3m1NAkBn2SQTJ7CzLIXfLA4yv7LFYmEKGcZ+rRWlwaWm7zQyJhRB0xzSvSqAtJLRJEP/Dg4j+7m11te4OXA1s3QBShvpAkEAq50gpKCG5D/cE9seVK9b5SuTnmXRlZE0D+3pXi7NOOSFBq30UtosSUs6+YyCPwOdcQhPjFYlD0hFymicSL0e/QJBAOMQaABh/6BcVimWP284x/WxBQ83zzVhcl7fUyqcFvfAw1JeMmRxvm2CWYKQ9MIhQ/9ptFotRCSwMAdJTZceWys=
  publicKey: MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDNc4Zrvk3E0mUkO8NOeNYOOaPI4uLoBAuDt9Rp0urX7y0wq7vvQzytvwzXXeM9Xp89j7g4ZLR7qBLBCj3QNPH0SUjE1yy9KVBKdjkPre7WT+plS74s2rJz/hygKiJ3Vxa+Z15v6JEHy/3/+i9gW3p/bCLaMQtvGemNvDXwCTwINQIDAQAB
-
-aliyun:
-  lot:
-    productKey: k2af0H4KLSU
-    amqp:
-      accessKey: LTAI5tPSodsNfiWEQxqDe4XT
-      accessSecret: xVswlKsGS6zHRixVwRjUmgGDuOxI2r
-      consumerGroupId: DEFAULT_GROUP
-      iotInstanceId: iot-06z00bnaqryq2gj
-      host: iot-06z00bnaqryq2gj.amqp.iothub.aliyuncs.com
-      clientId: wash-dev

+ 259 - 0
car-wash-entity/src/main/resources/sql/v2_settlement_doc.md

@@ -0,0 +1,259 @@
+# 结算 V2 业务逻辑文档
+
+> 2026-05-14 上线,2026-06-05 修复幻影站点 000 问题。
+
+## 一、核心概念
+
+### 1.1 用户归属站点
+
+每个洗车用户隶属于一个站点,由 `t_user.station_id` 确定:
+
+| 注册方式 | stationId | 说明 |
+|----------|-----------|------|
+| 扫码设备注册 | 即时确定 | 通过设备 shortId → 站点映射 |
+| 自主注册(微信登录) | null | 首次消费时自动归属到消费站点 |
+
+归属站点在用户首次消费时写入,详见 `WashOrderServiceImpl.createOrder():119-130`。
+
+### 1.2 本店消费 vs 跨店消费
+
+- **本店消费**:用户在自己的归属站点洗车 → `isCross = false`
+- **跨店消费**:用户在其他站点洗车 → `isCross = true`
+
+跨店消费时,归属站需将 70% 的消费金额转给消费站。
+
+### 1.3 分账记录表 (t_split_record)
+
+分账记录是结算的基础数据,记录每一笔资金的来源和去向:
+
+| 类型 | 值 | fromStationId | toStationId | 含义 |
+|------|---|---------------|-------------|------|
+| RECHARGE | 1 | 归属站 | 归属站 | 用户充值,资金流入归属站 |
+| CROSS_EXPEND | 4 | 归属站 | 消费站 | 跨店:归属站支出 |
+| CROSS_INCOME | 6 | 归属站 | 消费站 | 跨店:消费站收入(与 EXPEND 配对) |
+| REFUND | 5 | 归属站 | 归属站 | 退款,资金流出归属站 |
+
+> `tradeNo` 字段:对于 TYPE_RECHARGE,存储微信支付 `transactionId`;对于跨店,存储订单号 `orderId`。
+
+## 二、充值流程
+
+### 2.1 时序
+
+```
+用户              前端              后端                   微信
+ |                |                |                      |
+ |-- 选择金额 ---→|-- wxPay -----→|-- prepay -----------→ |
+ |                |                |  (attach=stationId)   |
+ |                |              ←|-- 支付参数 ------------|
+ |                |                |                      |
+ |-- 调起支付 ----|----|----------|--→ 支付回调 ---------→|
+ |                |    |          |←- notify(stationId) ---|
+ |                |    |          |                        |
+ |                |    |          |  创建 WalletDetail      |
+ |                |    |          |  更新 Account 余额      |
+ |                |    |          |  创建 PayLog            |
+ |                |    |          | if stationId有效       |
+ |                |    |          |   创建 SplitRecord     |
+ |                |    |          |   (RECHARGE)           |
+```
+
+### 2.2 关键逻辑
+
+```java
+// WxPayServiceImpl.java wxNotify() — 充值回调
+// 步骤1: 更新账户余额
+accountService.lambdaUpdate()
+    .setSql("balance = balance + {0}, recharge_balance = recharge_balance + {0}", amount)
+    .eq(Account::getUserId, userId).update();
+
+// 步骤2: 创建分账记录(仅有效站点)
+if (CommUtil.isNotEmptyAndNull(stationId) && !"000".equals(stationId)) {
+    splitRecordService.save(new SplitRecord()
+        .setFromStationId(stationId)
+        .setToStationId(stationId)
+        .setTradeNo(transactionId)
+        .setAmount(amount)
+        .setType(SplitRecord.TYPE_RECHARGE));
+}
+```
+
+### 2.3 无归属用户处理
+
+自主注册用户充值时分两种情况:
+
+- **充值发生在首次消费前**:用户 stationId = null,不创建 SplitRecord。充值金额留在钱包,分账在首次消费时追溯。
+- **充值发生在首次消费后**:用户已有 stationId,正常创建 SplitRecord。
+
+## 三、消费结算流程
+
+### 3.1 订单创建 (createOrder)
+
+```
+用户扫码 → createOrder()
+  ├── 检查是否有未完结订单
+  ├── 检查设备僵死订单
+  ├── 请求阿里云 IoT 创建设备订单
+  ├── 首次消费? → 自动归属站点 + 写入 Redis 缓存
+  ├── 设置 isCross = (消费站 != 归属站)
+  └── 保存 WashOrder
+```
+
+### 3.2 订单结算 (settleOrder)
+
+```
+设备结束洗车 → settleOrder()
+  ├── 幂等检查(已结算则跳过)
+  ├── 扣款:
+  │   ├── recharge_balance 足够 → 全从充值款扣
+  │   └── recharge_balance 不够 → 充值款扣完,差额从赠款扣
+  │
+  ├── 更新订单状态 (已支付、成功)
+  │
+  ├── 追溯补建分账 (backfillUnattributedRecharges)
+  │   查找用户所有已确认的充值 WalletDetail
+  │      → 通过 PayLog 桥接找到对应 transactionId
+  │      → 检查是否已有 SplitRecord(TYPE_RECHARGE)
+  │      → 没有则用当前归属站补建
+  │
+  ├── 分账逻辑:
+  │   ├── amount == 0 → 跳过
+  │   ├── isCross == false → doLocalSplit() —— 空操作
+  │   └── isCross == true  → doCrossSplit() —— 创建 CROSS_EXPEND + CROSS_INCOME
+  │
+  ├── 创建 WalletDetail(消费)
+  ├── 计算折扣
+  └── 发送模板消息(停车券 or 结算通知)
+```
+
+### 3.3 追溯补建详解 (backfillUnattributedRecharges)
+
+这是修复幻影站点 000 的关键逻辑:
+
+```
+输入: userId
+
+1. 查 Redis: userStationId = KymCache.getUserStationId(userId)
+   如果为 null → 跳过(用户仍未归属)
+
+2. 查 WalletDetail: 该用户所有 TYPE_充值 + STATUS_已确认 的记录
+   如果为空 → 跳过
+
+3. 查 PayLog: 通过 WalletDetail.orderNo → PayLog.outTradeNo 桥接
+   提取所有 transactionId (微信支付交易号)
+
+4. 查 SplitRecord: 哪些 transactionId 已有 TYPE_RECHARGE 记录
+   已有 → 跳过(幂等)
+   没有 → 补建 SplitRecord(TYPE_RECHARGE, from=归属站, to=归属站)
+```
+
+关联关系:
+```
+WalletDetail.orderNo  ←→  PayLog.outTradeNo  (商户订单号)
+PayLog.transactionId  ←→  SplitRecord.tradeNo  (微信交易号)
+```
+
+## 四、跨店分账
+
+当用户在非归属站消费时:
+
+```
+归属站 A          消费站 B
+   |                |
+   |  EXPEND -----→ |  (A 支出 70% 消费额)
+   |  ←----- INCOME |  (B 收入 70% 消费额)
+   |                |
+   |  净效果:         |
+   |  A 失去 70%     |
+   |  B 获得 70%     |
+```
+
+```java
+// doCrossSplit() 逻辑
+int crossAmount = totalPayment × 70%;
+
+// 两条记录成对出现
+SplitRecord(TYPE_CROSS_EXPEND, from=归属站A, to=消费站B, amount=crossAmount)
+SplitRecord(TYPE_CROSS_INCOME, from=归属站A, to=消费站B, amount=crossAmount)
+```
+
+**为什么本店消费不需要分账?** 因为充值时已经通过 TYPE_RECHARGE 将全部充值金额记入归属站,用户在本站消费只是钱包扣款,站点收入已经体现在充值分账里了。
+
+## 五、月度结算
+
+### 5.1 触发与幂等
+
+每月由定时任务调用 `SettlementServiceImpl.executeMonthlySettlement()`:
+- 结算周期:上月(`YearMonth.now().minusMonths(1)`)
+- 幂等保护:`uk_station_period` (station_id + settlement_period) 唯一约束
+
+### 5.2 结算公式
+
+```
+每个站点独立计算,以分为单位
+
+1. 汇总当月 SplitRecord:
+   totalRecharge   = SUM(TYPE_RECHARGE   WHERE toStationId   = 本站)
+   totalRefund     = SUM(TYPE_REFUND     WHERE fromStationId = 本站)
+   totalCrossExpend = SUM(TYPE_CROSS_EXPEND WHERE fromStationId = 本站)
+   totalCrossIncome = SUM(TYPE_CROSS_INCOME WHERE toStationId   = 本站)
+
+2. 计算费用基数:
+   feeBase = totalRecharge - totalRefund - totalCrossExpend + totalCrossIncome
+
+3. 平台服务费:
+   platformFee = feeBase × 10%(向下取整)
+
+4. 期初结转:
+   openingBalance = 上期 settlement_record.closing_pending_balance
+
+5. 可结算额:
+   available = openingBalance + feeBase - platformFee
+
+6. 结果:
+   available > 0 → 正常结算:
+     - settlementAmount = available
+     - closingPendingBalance = 0
+     - 加入站点可提现余额 (t_station_account.available_balance)
+     - 平台费入平台账 (t_platform_account)
+   available ≤ 0 → 异常结算:
+     - settlementAmount = 0
+     - closingPendingBalance = available (负数结转下期)
+```
+
+### 5.3 数据流
+
+```
+t_split_record (当月)
+       │
+       ▼
+  executeMonthlySettlement()
+       │
+       ├──→ t_settlement_record (结算单, uk_station_period 幂等)
+       ├──→ t_station_account.available_balance (+ 可结算额)
+       ├──→ t_platform_account (+ 平台费)
+       └──→ t_platform_revenue_record (平台费明细)
+```
+
+## 六、涉及的核心文件
+
+| 文件 | 职责 |
+|------|------|
+| `WxPayServiceImpl.java` | 微信支付充值 + SplitRecord(充值) 创建 |
+| `WashOrderServiceImpl.java` | 订单创建 + 自动归属站点 |
+| `OrderSettlementServiceImpl.java` | 订单结算 + 追溯补建 + 跨店分账 |
+| `SettlementServiceImpl.java` | 月度结算 |
+| `SplitRecord.java` | 分账记录实体 |
+| `PaymentController.java` | 充值接口 |
+| `recharge.vue` / `promotion.vue` | 前端充值页面 |
+
+## 七、边界情况与特殊处理
+
+| 场景 | 处理方式 |
+|------|----------|
+| 自主注册用户充值(无归属站) | 不创建 SplitRecord,首次消费时追溯补建 |
+| 历史残留 stationId="000" 分账 | 月结跳过 "000";SQL 迁移脚本清理历史数据 |
+| 充值后从未消费的用户 | 无 SplitRecord,余额留在钱包,无财务影响 |
+| 消费金额为 0 | settleOrder 提前返回,不执行分账 |
+| 订单重复结算 | settleOrder 幂等检查,已支付订单直接返回 |
+| 充值+赠款混合消费 | 先消耗充值款,不足部分用赠款 |
+| 用户跨多个站点消费 | isCross 每次按 (消费站 != 归属站) 判断 |

+ 53 - 0
car-wash-entity/src/main/resources/sql/v8_clean_phantom_station.sql

@@ -0,0 +1,53 @@
+-- ====================================================
+-- V8 清理幻影站点 "000" 历史数据
+-- 背景:自主注册用户充值时代码硬编码 stationId="000",
+-- 导致 t_split_record / t_settlement_record 积攒了无效站点数据
+-- 发布日期:2026-06-05
+-- ====================================================
+
+-- ----------------------------
+-- 1. 迁移可追溯的分账记录:将已归属用户的 "000" SplitRecord 更新为实际站点
+--    通过 t_pay_log 和 t_user 关联找到用户的真实 station_id
+-- ----------------------------
+UPDATE t_split_record sr
+INNER JOIN t_pay_log pl ON sr.trade_no = pl.transaction_id
+INNER JOIN t_user u ON pl.user_id = u.id
+SET sr.from_station_id = u.station_id,
+    sr.to_station_id   = u.station_id
+WHERE sr.type = 1
+  AND sr.from_station_id = '000'
+  AND sr.to_station_id   = '000'
+  AND u.station_id IS NOT NULL
+  AND u.station_id != ''
+  AND u.station_id != '000';
+
+-- ----------------------------
+-- 2. 删除无法追溯的残余 "000" 分账记录
+--    即便残留全部删除也无业务影响:
+--    - 用户再次消费时会触发 OrderSettlementServiceImpl.backfillUnattributedRecharges 补建
+--    - 不消费的用户充值余额始终在钱包中,无需分账
+-- ----------------------------
+DELETE FROM t_split_record
+WHERE from_station_id = '000'
+   OR to_station_id   = '000';
+
+-- ----------------------------
+-- 3. 清理 "000" 站点的结算记录
+-- ----------------------------
+DELETE FROM t_settlement_record
+WHERE station_id = '000';
+
+-- ----------------------------
+-- 4. 清理 "000" 站点的账户记录
+-- ----------------------------
+DELETE FROM t_station_account
+WHERE station_id = '000';
+
+-- ----------------------------
+-- 5. 清理 "000" 站点的充值配置分组关联(如有)
+-- ----------------------------
+DELETE FROM t_recharge_config_station_group
+WHERE station_id = '000';
+
+DELETE FROM t_recharge_config_group
+WHERE name = '000';

+ 2 - 2
car-wash-miniapp/src/main/java/com/kym/miniapp/controller/PaymentController.java

@@ -35,7 +35,7 @@ public class PaymentController {
     @ApiLog("用户充值微信支付")
     @GetMapping("/wxPay")
     @ResponseBody
-    R<?> prepay(@RequestParam Long rechargeConfigId, @RequestParam(value = "stationId", defaultValue = "000") String stationId) { //todo 前端更新后修改
+    R<?> prepay(@RequestParam Long rechargeConfigId, @RequestParam(value = "stationId", required = false) String stationId) {
         return R.success(wxPayService.wxPay(rechargeConfigId, stationId));
     }
 
@@ -55,7 +55,7 @@ public class PaymentController {
     @GetMapping("/promotionPay")
     @ResponseBody
     R<?> promotionPay(@RequestParam String promotionToken,
-                      @RequestParam(value = "stationId", defaultValue = "000") String stationId) {
+                      @RequestParam(value = "stationId", required = false) String stationId) {
         return R.success(wxPayService.promotionPay(promotionToken, stationId));
     }
 

+ 11 - 0
car-wash-miniapp/src/main/resources/application-dev.yml

@@ -58,3 +58,14 @@ spring:
 kym:
   notify-email: skyline@kuaiyuman.cn
   domain: https://dev-wash.kuaiyuman.cn
+
+aliyun:
+  lot:
+    productKey: k1olfsszoYB
+    amqp:
+      accessKey: LTAI5t8urs6Xiw2YNbR7u158
+      accessSecret: UiLp1cB7ZeJ00pzf6Phy8t3VFz52ut
+      consumerGroupId: DEFAULT_GROUP
+      iotInstanceId: iot-06z00hb4ys0z7ri
+      host: iot-06z00hb4ys0z7ri.amqp.iothub.aliyuncs.com
+      clientId: wash-dev

+ 11 - 0
car-wash-miniapp/src/main/resources/application-prod.yml

@@ -58,3 +58,14 @@ spring:
 kym:
   notify-email: skyline@kuaiyuman.cn
   domain: https://api.yeswash.cn
+
+aliyun:
+  lot:
+    productKey: k2af0H4KLSU
+    amqp:
+      accessKey: LTAI5tPSodsNfiWEQxqDe4XT
+      accessSecret: xVswlKsGS6zHRixVwRjUmgGDuOxI2r
+      consumerGroupId: DEFAULT_GROUP
+      iotInstanceId: iot-06z00bnaqryq2gj
+      host: iot-06z00bnaqryq2gj.amqp.iothub.aliyuncs.com
+      clientId: wash-prod

+ 0 - 10
car-wash-miniapp/src/main/resources/application.yml

@@ -104,16 +104,6 @@ password:
   privateKey: MIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBAM1zhmu+TcTSZSQ7w0541g45o8ji4ugEC4O31GnS6tfvLTCru+9DPK2/DNdd4z1enz2PuDhktHuoEsEKPdA08fRJSMTXLL0pUEp2OQ+t7tZP6mVLvizasnP+HKAqIndXFr5nXm/okQfL/f/6L2Bben9sItoxC28Z6Y28NfAJPAg1AgMBAAECgYAKOdvQ9RHt4AMEwKzB9SXCY4AReamNntXr4nSCJ+tkgBUhvQqHqDMW+tFqztOGtHT8nXCv7eNF3GHClf3ppRj91utk8zAwZPVAVlRoNcWs60nyKRUO2uhwAV8AE+9UKDoVui7L7UaMcIkssKqQbFGIRXUjjSoPJu0yoHCdp5/3QQJBAOZTbfgmFXVgRSH4IMXJ3aZOqz+Wy3EmvNatYz8NYLBFgLJTWWXtR7URw82R7jL6F9ettfCityhAmXEZnZsEsokCQQDkWkYwFZlUYJ2ctdNEmipXw8tjpCrzQRaZnydXbjviwbSpOvOo5nrxSG5BtL9QDwiy9DL7YLBVJPykAkJm3m1NAkBn2SQTJ7CzLIXfLA4yv7LFYmEKGcZ+rRWlwaWm7zQyJhRB0xzSvSqAtJLRJEP/Dg4j+7m11te4OXA1s3QBShvpAkEAq50gpKCG5D/cE9seVK9b5SuTnmXRlZE0D+3pXi7NOOSFBq30UtosSUs6+YyCPwOdcQhPjFYlD0hFymicSL0e/QJBAOMQaABh/6BcVimWP284x/WxBQ83zzVhcl7fUyqcFvfAw1JeMmRxvm2CWYKQ9MIhQ/9ptFotRCSwMAdJTZceWys=
   publicKey: MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDNc4Zrvk3E0mUkO8NOeNYOOaPI4uLoBAuDt9Rp0urX7y0wq7vvQzytvwzXXeM9Xp89j7g4ZLR7qBLBCj3QNPH0SUjE1yy9KVBKdjkPre7WT+plS74s2rJz/hygKiJ3Vxa+Z15v6JEHy/3/+i9gW3p/bCLaMQtvGemNvDXwCTwINQIDAQAB
 
-aliyun:
-  lot:
-    productKey: k2af0H4KLSU
-    amqp:
-      accessKey: LTAI5tPSodsNfiWEQxqDe4XT
-      accessSecret: xVswlKsGS6zHRixVwRjUmgGDuOxI2r
-      consumerGroupId: DEFAULT_GROUP
-      iotInstanceId: iot-06z00bnaqryq2gj
-      host: iot-06z00bnaqryq2gj.amqp.iothub.aliyuncs.com
-      clientId: wash-dev
 
 
 

+ 6 - 5
car-wash-mp/src/pages-user/wallet/promotion.vue

@@ -109,11 +109,12 @@ const confirm = () => {
   paying.value = true;
   uni.showLoading({ title: "支付中..." });
 
-  const homeStationId = getApp<any>().globalData.user?.stationId || "000";
-  get("/payment/promotionPay", {
-    promotionToken: state.promotionToken,
-    stationId: homeStationId,
-  })
+  const homeStationId = getApp<any>().globalData.user?.stationId;
+  const params: any = { promotionToken: state.promotionToken };
+  if (homeStationId) {
+    params.stationId = homeStationId;
+  }
+  get("/payment/promotionPay", params)
     .then((res: any) => {
       // #ifdef MP-WEIXIN
       wx.requestPayment({

+ 6 - 5
car-wash-mp/src/pages-user/wallet/recharge.vue

@@ -56,7 +56,7 @@ import { get } from "@/utils/https";
 const initState = () => ({
   configList: [] as any[],
   chosenIdx: -1,
-  stationId: "000",
+  stationId: "",
   rechargeItem: { grantsAmount: 0 } as any,
 });
 
@@ -114,10 +114,11 @@ const confirm = () => {
   uni.showLoading({ title: "支付中..." });
 
   const recharge = state.configList[state.chosenIdx];
-  get("/payment/wxPay", {
-    rechargeConfigId: recharge.id,
-    stationId: state.stationId,
-  })
+  const params: any = { rechargeConfigId: recharge.id };
+  if (state.stationId) {
+    params.stationId = state.stationId;
+  }
+  get("/payment/wxPay", params)
     .then((res: any) => {
       // #ifdef MP-WEIXIN
       wx.requestPayment({

+ 1 - 1
car-wash-mp/src/pages/user/index.vue

@@ -167,7 +167,7 @@ const toPage = (item: any) => {
 };
 
 const handleUserClick = () => {
-  if (!isLogin.value) {
+  if (!isLogin.value || !user.value.mobilePhone) {
     uni.navigateTo({
       url: '/pages-user/login/index'
     })

+ 77 - 0
car-wash-service/src/main/java/com/kym/service/impl/OrderSettlementServiceImpl.java

@@ -15,7 +15,9 @@ import java.math.RoundingMode;
 import java.time.Duration;
 import java.time.LocalDateTime;
 import java.util.List;
+import java.util.Map;
 import java.util.UUID;
+import java.util.stream.Collectors;
 
 /**
  * 订单结算服务实现
@@ -32,15 +34,20 @@ public class OrderSettlementServiceImpl implements OrderSettlementService {
     private final WalletDetailService walletDetailService;
     private final AccountService accountService;
     private final SplitRecordService splitRecordService;
+    private final PayLogService payLogService;
+    private final UserService userService;
     private final MpMsgTemplateService mpMsgTemplateService;
 
     public OrderSettlementServiceImpl(WashOrderService washOrderService, WalletDetailService walletDetailService,
                                       AccountService accountService, SplitRecordService splitRecordService,
+                                      PayLogService payLogService, UserService userService,
                                       MpMsgTemplateService mpMsgTemplateService) {
         this.washOrderService = washOrderService;
         this.walletDetailService = walletDetailService;
         this.accountService = accountService;
         this.splitRecordService = splitRecordService;
+        this.payLogService = payLogService;
+        this.userService = userService;
         this.mpMsgTemplateService = mpMsgTemplateService;
     }
 
@@ -93,6 +100,9 @@ public class OrderSettlementServiceImpl implements OrderSettlementService {
 
         washOrderService.updateById(washOrder);
 
+        ensureUserStationBelonging(washOrder.getUserId(), washOrder.getStationId());
+        backfillUnattributedRecharges(washOrder.getUserId());
+
         if (orderInfo.getAmount() == 0) {
             return;
         }
@@ -169,4 +179,71 @@ public class OrderSettlementServiceImpl implements OrderSettlementService {
         splitRecordService.saveBatch(List.of(crossExpend, crossIncome));
         log.info("订单:{},跨店分账完成", washOrder.getOrderId());
     }
+
+    /**
+     * 兜底保障:如果 createOrder 的自动归属没有生效,在结算时补齐。
+     * IoT 回调路径没有用户 Session,同步写 Redis 缓存供后续使用。
+     */
+    private void ensureUserStationBelonging(Long userId, String orderStationId) {
+        var user = userService.getById(userId);
+        if (user == null || user.getStationId() != null) {
+            return;
+        }
+        userService.lambdaUpdate()
+                .set(User::getStationId, orderStationId)
+                .eq(User::getId, userId)
+                .update();
+        KymCache.INSTANCE.putUserId2StationId(Map.of(userId, orderStationId));
+        log.info("兜底归属:用户={} 归属站点={}", userId, orderStationId);
+    }
+
+    private void backfillUnattributedRecharges(Long userId) {
+        var userStationId = KymCache.INSTANCE.getUserStationId(userId);
+        if (userStationId == null) {
+            var user = userService.getById(userId);
+            userStationId = user != null ? user.getStationId() : null;
+        }
+        if (userStationId == null) {
+            return;
+        }
+
+        var walletDetails = walletDetailService.lambdaQuery()
+                .eq(WalletDetail::getUserId, userId)
+                .eq(WalletDetail::getType, WalletDetail.TYPE_充值)
+                .eq(WalletDetail::getStatus, WalletDetail.STATUS_已确认)
+                .list();
+
+        if (walletDetails.isEmpty()) {
+            return;
+        }
+
+        var outTradeNos = walletDetails.stream().map(WalletDetail::getOrderNo).toList();
+        var payLogs = payLogService.lambdaQuery().in(PayLog::getOutTradeNo, outTradeNos).list();
+        if (payLogs.isEmpty()) {
+            return;
+        }
+
+        var allTxIds = payLogs.stream().map(PayLog::getTransactionId).collect(Collectors.toSet());
+        var existingTxIds = splitRecordService.lambdaQuery()
+                .in(SplitRecord::getTradeNo, allTxIds)
+                .eq(SplitRecord::getType, SplitRecord.TYPE_RECHARGE)
+                .list()
+                .stream()
+                .map(SplitRecord::getTradeNo)
+                .collect(Collectors.toSet());
+
+        for (var payLog : payLogs) {
+            if (!existingTxIds.contains(payLog.getTransactionId())) {
+                var splitRecord = new SplitRecord()
+                        .setFromStationId(userStationId)
+                        .setToStationId(userStationId)
+                        .setTradeNo(payLog.getTransactionId())
+                        .setAmount(payLog.getTotal())
+                        .setType(SplitRecord.TYPE_RECHARGE);
+                splitRecordService.save(splitRecord);
+                log.info("追溯补建充值分账:用户={}, 站点={}, 交易号={}, 金额={}",
+                        userId, userStationId, payLog.getTransactionId(), payLog.getTotal());
+            }
+        }
+    }
 }

+ 3 - 0
car-wash-service/src/main/java/com/kym/service/impl/SettlementServiceImpl.java

@@ -87,6 +87,9 @@ public class SettlementServiceImpl extends MyBaseServiceImpl<SettlementRecordMap
                 .collect(Collectors.toSet());
 
         for (String stationId : stationIds) {
+            if ("000".equals(stationId)) {
+                continue;
+            }
             doStationSettlement(stationId, period, allRecords);
         }
 

+ 1 - 1
car-wash-service/src/main/java/com/kym/service/impl/WashOrderServiceImpl.java

@@ -139,7 +139,7 @@ public class WashOrderServiceImpl extends MyBaseServiceImpl<WashOrderMapper, Was
                 .setOrderId(orderId)
                 .setOrderIdLocal(createOrder.getOrder_id_local())
                 .setMemberDiscount(Account.NO_DISCOUNT)
-                .setPrepayMoney(account.getBalance() - 50)
+                .setPrepayMoney(account.getBalance())
                 .setStartTime(LocalDateTime.now())
                 .setOrderStatus(WashOrder.ORDER_STATUS_开机)
                 .setPayStatus(WashOrder.PAY_STATUS_未支付)

+ 10 - 9
car-wash-service/src/main/java/com/kym/service/wechat/impl/WxPayServiceImpl.java

@@ -419,15 +419,16 @@ public class WxPayServiceImpl implements WxPayService {
                 payLogService.save(payLog);
 
                 // V2 结算方案:充值资金记录分账流水,结算日统一处理,不再即时转入站点账户
-
-                // 分账记录(结算时按此汇总)
-                var splitRecord = new SplitRecord()
-                        .setFromStationId(stationId)
-                        .setToStationId(stationId)
-                        .setTradeNo(transaction.getTransactionId())
-                        .setAmount(totalAmount)
-                        .setType(SplitRecord.TYPE_RECHARGE);
-                splitRecordService.save(splitRecord);
+                // 无归属站点的用户充值暂不生成分账记录,首次消费时追溯补建
+                if (CommUtil.isNotEmptyAndNull(stationId) && !"000".equals(stationId)) {
+                    var splitRecord = new SplitRecord()
+                            .setFromStationId(stationId)
+                            .setToStationId(stationId)
+                            .setTradeNo(transaction.getTransactionId())
+                            .setAmount(totalAmount)
+                            .setType(SplitRecord.TYPE_RECHARGE);
+                    splitRecordService.save(splitRecord);
+                }
 
                 // 发送公众号消息
                 mpMsgTemplateService.sendPaymentSuccessMsg(payLog, walletDetail.getAfterBalance());