7 次代码提交 505ef87683 ... 233305233f

作者 SHA1 备注 提交日期
  qmj 233305233f 摊位的经纬度通过夜市拿到并返回 1 天之前
  qmj cea86f244b chore: 标记夜市摊位抽成实现计划为已完成 1 天之前
  qmj 65ae97d1bd fix: addStallBalance 使用 MessageUtils 替代硬编码中文 1 天之前
  qmj 2560baf1f9 feat: setSanghuBilling 支持摊位订单抽成逻辑 1 天之前
  qmj 4ee4361afa feat: WalletService 新增 addStallBalance 摊位钱包操作方法 1 天之前
  qmj 438396b589 feat: UserBilling 新增 storeId 字段关联摊位钱包 1 天之前
  qmj 7c7761a57e 夜市摊位抽成设计文档 1 天之前

+ 234 - 0
docs/superpowers/plans/2026-05-09-night-market-commission.md

@@ -0,0 +1,234 @@
+# 夜市摊位抽成实现计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
+
+**Goal:** 摊位订单完成后,使用所属夜市的抽成比例计算平台佣金,收入入摊位门店钱包。
+
+**Architecture:** 在现有 `setSanghuBilling` 中增加摊位判断分支,通过 `PosStore.isStall` 判断是否为摊位订单,是则用夜市的 `commission` 计算抽成,并调用新增的 `addStallBalance` 方法更新摊位门店钱包。
+
+**Tech Stack:** Java, Spring Boot, MyBatis-Plus, Redisson
+
+---
+
+### Task 1: UserBilling 新增 storeId 字段
+
+**Files:**
+- Modify: `ruoyi-system/src/main/java/com/ruoyi/system/domain/UserBilling.java`
+
+- [x] **Step 1: 在 UserBilling 实体中添加 storeId 字段**
+
+在 `private Long mdId;` 之后添加:
+
+```java
+/** 关联摊位ID,非摊位账单为NULL */
+private Long storeId;
+```
+
+- [x] **Step 2: 在 UserBillingMapper.xml 的 resultMap 中添加映射**
+
+文件:`ruoyi-system/src/main/resources/mapper/system/UserBillingMapper.xml`
+
+找到 `mdId` 的 result 映射,在其后添加:
+
+```xml
+<result property="storeId"    column="store_id"    />
+```
+
+- [x] **Step 3: 在数据库中执行 ALTER TABLE**
+
+```sql
+ALTER TABLE user_billing ADD COLUMN store_id BIGINT DEFAULT NULL COMMENT '关联摊位ID,非摊位账单为NULL';
+```
+
+- [x] **Step 4: Commit**
+
+```bash
+git add ruoyi-system/src/main/java/com/ruoyi/system/domain/UserBilling.java ruoyi-system/src/main/resources/mapper/system/UserBillingMapper.xml
+git commit -m "feat: UserBilling 新增 storeId 字段关联摊位钱包"
+```
+
+---
+
+### Task 2: WalletService 新增 addStallBalance 方法
+
+**Files:**
+- Modify: `ruoyi-admin/src/main/java/com/ruoyi/app/service/WalletService.java`
+
+- [x] **Step 1: 添加 addStallBalance 方法**
+
+在 `addBalance(Long userId, BigDecimal amount, UserBilling billing)` 方法之后添加新方法。逻辑与 `addBalance` 类似,但通过 `storeId` 查找摊位钱包并更新:
+
+```java
+/**
+ * 增加摊位钱包余额
+ *
+ * @param storeId 摊位门店ID
+ * @param amount  增加金额
+ * @param billing 账单信息
+ */
+@Transactional(rollbackFor = Exception.class)
+public void addStallBalance(Long storeId, BigDecimal amount, UserBilling billing) {
+    if (storeId == null || amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
+        throw new ServiceException(MessageUtils.message("no.wallet.common.cs.error"));
+    }
+    UserWallet stallWallet = userWalletService.selectByStoreId(storeId);
+    if (stallWallet == null) {
+        throw new ServiceException("摊位钱包不存在");
+    }
+    String lockKey = "wallet:stall:lock:" + storeId;
+    RLock lock = redissonClient.getLock(lockKey);
+    try {
+        if (lock.tryLock(LOCK_WAIT_TIME, LOCK_TIMEOUT, TimeUnit.SECONDS)) {
+            boolean releaseInFinally = true;
+            try {
+                BigDecimal newBalance = stallWallet.getBalanceWallet().add(amount);
+                LambdaUpdateWrapper<UserWallet> updateQuery = new LambdaUpdateWrapper<>();
+                updateQuery.eq(UserWallet::getStoreId, storeId)
+                        .eq(UserWallet::getVersion, stallWallet.getVersion());
+                stallWallet.setBalanceWallet(newBalance);
+                stallWallet.setVersion(stallWallet.getVersion() + 1);
+                boolean updateSuccess = userWalletService.update(stallWallet, updateQuery);
+                createUserBill(billing, stallWallet.getBalanceWallet());
+                if (!updateSuccess) {
+                    throw new ServiceException(MessageUtils.message("no.wallet.update.faile"));
+                }
+                releaseInFinally = false;
+                TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+                    @Override
+                    public void afterCommit() {
+                        if (lock.isHeldByCurrentThread()) {
+                            lock.unlock();
+                        }
+                    }
+                });
+            } finally {
+                if (releaseInFinally && lock.isHeldByCurrentThread()) {
+                    lock.unlock();
+                }
+            }
+        } else {
+            throw new ServiceException(MessageUtils.message("no.system.busy.try.again"));
+        }
+    } catch (InterruptedException e) {
+        Thread.currentThread().interrupt();
+        throw new ServiceException(MessageUtils.message("no.operation.interrupted.try.again"));
+    }
+}
+```
+
+- [x] **Step 2: Commit**
+
+```bash
+git add ruoyi-admin/src/main/java/com/ruoyi/app/service/WalletService.java
+git commit -m "feat: WalletService 新增 addStallBalance 摊位钱包操作方法"
+```
+
+---
+
+### Task 3: 修改 setSanghuBilling 抽成逻辑
+
+**Files:**
+- Modify: `ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderController.java`
+
+- [x] **Step 1: 注入 IPosStoreService 依赖**
+
+在 `PosOrderController` 中已有的 `@Autowired` 字段区域添加:
+
+```java
+@Autowired
+private IPosStoreService posStoreService;
+```
+
+确认 import 存在:
+```java
+import com.ruoyi.system.service.IPosStoreService;
+```
+
+- [x] **Step 2: 修改 setSanghuBilling 方法**
+
+将现有方法(约 576-641 行)替换为:
+
+```java
+public void setSanghuBilling(@NotNull PosOrder posOrder) {
+    long count = userBillingService.count(new LambdaQueryWrapper<UserBilling>().eq(UserBilling::getUserId, posOrder.getShId()).eq(UserBilling::getType, "0").eq(UserBilling::getDdId, posOrder.getDdId()));
+    if (count <= 0) {
+        UserBilling billing = new UserBilling();
+        DecimalFormat df = new DecimalFormat("#.00");
+
+        // 判断是否为摊位订单
+        PosStore store = posStoreService.getById(posOrder.getMdId());
+        boolean isStall = store != null && store.getIsStall() != null && store.getIsStall() == 1
+                && store.getNightMarketId() != null;
+
+        Double fcbili;
+        if (isStall) {
+            InfoUser nightMarket = infoUserService.getById(store.getNightMarketId());
+            fcbili = nightMarket.getCommission() == null ? 0.00 : nightMarket.getCommission();
+        } else {
+            InfoUser user = infoUserService.getById(posOrder.getShId());
+            fcbili = user.getCommission() == null ? 0.00 : user.getCommission();
+        }
+        fcbili = getShCommissionRate(fcbili, posOrder.getShId());
+
+        JSONArray list = JSONArray.parseArray(posOrder.getFood());
+        int fenc = 0;
+        for (int i = 0; i < list.size(); i++) {
+            JSONObject foods = list.getJSONObject(i);
+            int price = foods.getInteger("price");
+            int otherPrice = foods.getInteger("otherPrice");
+            int num = foods.getInteger("number");
+            int age = (price + otherPrice) * num;
+            fenc += age;
+        }
+        String remark = "";
+        if (posOrder.getMdYhId() != null && posOrder.getMdDiscountAmount() != null) {
+            VipUserQuanyi userQuanyi = userQuanyiService.getById(posOrder.getMdYhId());
+            if (userQuanyi != null && "1".equals(userQuanyi.getType())) {
+                fenc = fenc - posOrder.getMdDiscountAmount();
+                String yhmcMessage = MessageUtils.message("no.posorder.md.yh.mc.messag");
+                yhmcMessage = StrUtil.format(yhmcMessage, posOrder.getMdYhName());
+                remark += yhmcMessage;
+                String yhAmount = MessageUtils.message("no.posorder.md.yh.jiner.messag");
+                yhAmount = StrUtil.format(yhAmount, posOrder.getMdDiscountAmount());
+                remark += yhAmount;
+            }
+
+        }
+        if (posOrder.getMdSalesName() != null && posOrder.getMdSalesReduction() != null) {
+            fenc = fenc - posOrder.getMdSalesReduction();
+            String cxNameMessage = MessageUtils.message("no.posorder.md.cx.mc.messag");
+            cxNameMessage = StrUtil.format(cxNameMessage, posOrder.getMdSalesName());
+            remark += cxNameMessage;
+            String cxAmount = MessageUtils.message("no.posorder.md.cx.jiner.messag");
+            cxAmount = StrUtil.format(cxAmount, posOrder.getMdSalesReduction());
+            remark += cxAmount;
+        }
+        billing.setIllustrate(remark);
+        Double chouc = fenc * fcbili;
+        Double shfc = fenc - chouc;
+        billing.setUserId(posOrder.getShId());
+        billing.setDdId(String.valueOf(posOrder.getDdId()));
+        billing.setType("0");
+        billing.setAmount(Double.valueOf(df.format(shfc)));
+        billing.setDivvy(Double.valueOf(df.format(chouc)));
+        billing.setState("0");
+        billing.setMdId(posOrder.getMdId());
+        billing.setUserType("1");
+        billing.setDivvyRate(fcbili);
+
+        if (isStall) {
+            billing.setStoreId(store.getId());
+            walletService.addStallBalance(store.getId(), BigDecimal.valueOf(billing.getAmount()), billing);
+        } else {
+            walletService.addBalance(billing.getUserId(), BigDecimal.valueOf(billing.getAmount()), billing);
+        }
+    }
+}
+```
+
+- [x] **Step 3: Commit**
+
+```bash
+git add ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderController.java
+git commit -m "feat: setSanghuBilling 支持摊位订单抽成逻辑"
+```

+ 101 - 0
docs/superpowers/specs/2026-05-09-night-market-commission-design.md

@@ -0,0 +1,101 @@
+# 夜市摊位抽成设计
+
+## 背景
+
+现有抽成模式:平台从商家(userType=1)每笔交易中按 `commission` 比例收取佣金,收入入商家个人钱包。
+
+新增夜市层级后,交易主体变为摊位(PosStore, isStall=1),摊位归属夜市(InfoUser, userType=3)管理。摊位可能有多个摊位主,钱包是门店维度的,不是个人维度的。
+
+## 业务规则
+
+- 平台从摊位每笔交易中收取佣金,比例由所属夜市统一设定
+- 夜市不参与交易抽成,只线下收取摊位费
+- 抽成比例存在夜市用户的 `commission` 字段上,下属所有摊位统一使用
+- 资金流向:交易额 - 平台抽成 = 摊位到手,平台直接扣除
+- 摊位收入进入摊位门店钱包(UserWallet.storeId 关联),不进摊位主个人钱包
+
+## 技术设计
+
+### 改动范围
+
+| 改动 | 文件 |
+|------|------|
+| 抽成判断逻辑 | `PosOrderController.setSanghuBilling()` |
+| 摊位钱包更新 | `WalletService` 新增摊位钱包操作方法 |
+| 账单关联摊位 | `UserBilling` 新增 `storeId` 字段 |
+| 数据库 | `user_billing` 表新增 `store_id` 列 |
+
+### 1. 抽成判断逻辑
+
+修改 `PosOrderController.setSanghuBilling()`:
+
+```
+PosStore store = posStoreService.getById(posOrder.getMdId())
+if (store.getIsStall() == 1 && store.getNightMarketId() != null) {
+    // 摊位订单:用夜市的抽成比例
+    InfoUser nightMarket = infoUserService.getById(store.getNightMarketId())
+    fcbili = nightMarket.getCommission() == null ? 0.00 : nightMarket.getCommission()
+} else {
+    // 普通商家:保持原逻辑
+    InfoUser user = infoUserService.getById(posOrder.getShId())
+    fcbili = user.getCommission() == null ? 0.00 : user.getCommission()
+}
+```
+
+后续计算不变:`chouc = fenc * fcbili`,`shfc = fenc - chouc`。
+
+### 2. 账单记录
+
+`user_billing` 表新增 `store_id` 列(BIGINT, NULL, 默认NULL)。
+
+摊位订单账单记录:
+
+| 字段 | 值 | 说明 |
+|------|-----|------|
+| userId | shId(夜市ID) | 标识交易归属哪个夜市 |
+| mdId | 摊位门店ID | 标识哪个摊位的交易 |
+| storeId | 摊位门店ID | 关联摊位钱包(新增字段) |
+| amount | shfc | 摊位到手金额 |
+| divvy | chouc | 平台抽成金额 |
+| divvyRate | fcbili | 抽成比例 |
+
+普通商家订单不受影响,`storeId` 为 NULL。
+
+### 3. 钱包更新
+
+`WalletService` 新增摊位钱包操作方法 `addStallBalance(Long storeId, BigDecimal amount, UserBilling billing)`:
+
+```
+1. 通过 storeId 查找 UserWallet(WHERE store_id = ?)
+2. 加分布式锁
+3. 乐观锁更新钱包余额(与现有 addBalance 逻辑相同)
+4. 创建账单记录(billing.storeId 已设置)
+```
+
+`setSanghuBilling()` 中的调用变更:
+
+```
+if (isStall) {
+    billing.setStoreId(store.getId())
+    walletService.addStallBalance(store.getId(), amount, billing)
+} else {
+    walletService.addBalance(userId, amount, billing)  // 原逻辑不变
+}
+```
+
+### 4. 数据模型
+
+仅一个数据库变更:
+
+```sql
+ALTER TABLE user_billing ADD COLUMN store_id BIGINT DEFAULT NULL COMMENT '关联摊位ID,非摊位账单为NULL';
+```
+
+复用现有字段:
+
+| 字段 | 所在实体 | 用途 |
+|------|---------|------|
+| commission | InfoUser (夜市) | 夜市设定的平台抽成比例 |
+| isStall | PosStore | 判断是否为摊位(1=摊位) |
+| nightMarketId | PosStore | 摊位关联的夜市用户ID |
+| storeId | UserWallet | 摊位钱包关联的门店ID(已有) |

+ 20 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosFoodController.java

@@ -171,7 +171,9 @@ public class PosFoodController extends BaseController {
             JSONObject org = new JSONObject();
             org.put("id", list.get(i).getId());
             org.put("fenlei", posFenleiService.getById(list.get(i).getFlId()));
-            org.put("store", posStoreService.getById(list.get(i).getMdid()));
+            PosStore store = posStoreService.getById(list.get(i).getMdid());
+            fillStallLocation(store);
+            org.put("store", store);
             org.put("name", list.get(i).getName());
             org.put("image", list.get(i).getImage());
             org.put("price", list.get(i).getPrice());
@@ -195,7 +197,9 @@ public class PosFoodController extends BaseController {
         PosFood posFood = posFoodService.getById(id);
         org.put("id", posFood.getId());
         org.put("fenlei", posFenleiService.getById(posFood.getFlId()));
-        org.put("store", posStoreService.getById(posFood.getMdid()));
+        PosStore store = posStoreService.getById(posFood.getMdid());
+        fillStallLocation(store);
+        org.put("store", store);
         org.put("name", posFood.getName());
         org.put("image", posFood.getImage());
         org.put("price", posFood.getPrice());
@@ -603,4 +607,18 @@ public class PosFoodController extends BaseController {
         });
         return success("成功压缩的图片数量:"+count.get());
     }
+
+    /**
+     * 当门店是摊位时,用夜市的经纬度覆盖摊位的经纬度
+     */
+    private void fillStallLocation(PosStore store) {
+        if (store != null && store.getIsStall() != null && store.getIsStall() == 1
+                && store.getNightMarketId() != null) {
+            InfoUser nightMarket = infoUserService.getById(store.getNightMarketId());
+            if (nightMarket != null) {
+                store.setLongitude(nightMarket.getLongitude());
+                store.setLatitude(nightMarket.getLatitude());
+            }
+        }
+    }
 }

+ 21 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderController.java

@@ -578,7 +578,19 @@ public class PosOrderController extends BaseController {
         if (count <= 0) {
             UserBilling billing = new UserBilling();
             DecimalFormat df = new DecimalFormat("#.00");
-            InfoUser user = infoUserService.getById(posOrder.getShId());
+
+            // 判断是否为摊位订单
+            PosStore store = posStoreService.getById(posOrder.getMdId());
+            boolean isStall = store != null && store.getIsStall() != null && store.getIsStall() == 1 && store.getNightMarketId() != null;
+
+            InfoUser user;
+            if (isStall) {
+                // 摊位订单:使用夜市用户的分成比例
+                user = infoUserService.getById(store.getNightMarketId());
+            } else {
+                // 普通订单:使用商家用户的分成比例
+                user = infoUserService.getById(posOrder.getShId());
+            }
             Double fcbili = user.getCommission() == null ? 0.00 : user.getCommission();
             fcbili = getShCommissionRate(fcbili, user.getUserId());
             JSONArray list = JSONArray.parseArray(posOrder.getFood());
@@ -628,7 +640,14 @@ public class PosOrderController extends BaseController {
             billing.setMdId(posOrder.getMdId());
             billing.setUserType("1");
             billing.setDivvyRate(fcbili);
-            walletService.addBalance(billing.getUserId(), BigDecimal.valueOf(billing.getAmount()), billing);
+            if (isStall) {
+                // 摊位订单:更新摊位钱包
+                billing.setStoreId(store.getId());
+                walletService.addStallBalance(store.getId(), BigDecimal.valueOf(billing.getAmount()), billing);
+            } else {
+                // 普通订单:更新用户钱包
+                walletService.addBalance(billing.getUserId(), BigDecimal.valueOf(billing.getAmount()), billing);
+            }
 //            int reg = userBillingService.insertUserBilling(billing);
 //            if (reg == 1) {
 //

+ 66 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/service/WalletService.java

@@ -300,6 +300,72 @@ public class WalletService {
         }
     }
 
+    /**
+     * 增加摊位余额
+     *
+     * @param storeId 摊位ID
+     * @param amount  增加金额
+     * @param billing 账单信息
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public void addStallBalance(Long storeId, BigDecimal amount, UserBilling billing) {
+        // 1. 参数校验
+        if (storeId == null || amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
+            throw new ServiceException(MessageUtils.message("no.wallet.common.cs.error"));
+        }
+        String lockKey = "wallet:stall:lock:" + storeId;
+        RLock lock = redissonClient.getLock(lockKey);
+
+        try {
+            // 尝试获取锁
+            if (lock.tryLock(LOCK_WAIT_TIME, LOCK_TIMEOUT, TimeUnit.SECONDS)) {
+                boolean releaseInFinally = true;
+                try {
+                    // 2. 获取摊位钱包
+                    UserWallet stallWallet = userWalletService.selectByStoreId(storeId);
+                    if (stallWallet == null) {
+                        throw new ServiceException(MessageUtils.message("no.user.wallet.not.exist"));
+                    }
+
+                    // 3. 更新余额
+                    BigDecimal newBalance = stallWallet.getBalanceWallet().add(amount);
+
+                    // 4. 乐观锁更新钱包
+                    LambdaUpdateWrapper<UserWallet> updateQuery = new LambdaUpdateWrapper<>();
+                    updateQuery.eq(UserWallet::getStoreId, storeId)
+                            .eq(UserWallet::getVersion, stallWallet.getVersion());
+
+                    stallWallet.setBalanceWallet(newBalance);
+                    stallWallet.setVersion(stallWallet.getVersion() + 1);
+
+                    boolean updateSuccess = userWalletService.update(stallWallet, updateQuery);
+                    createUserBill(billing, stallWallet.getBalanceWallet());
+                    if (!updateSuccess) {
+                        throw new ServiceException(MessageUtils.message("no.wallet.update.faile"));
+                    }
+                    releaseInFinally = false;
+                    TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+                        @Override
+                        public void afterCommit() {
+                            if (lock.isHeldByCurrentThread()) {
+                                lock.unlock();
+                            }
+                        }
+                    });
+                } finally {
+                    if (releaseInFinally && lock.isHeldByCurrentThread()) {
+                        lock.unlock();
+                    }
+                }
+            } else {
+                throw new ServiceException(MessageUtils.message("no.system.busy.try.again"));
+            }
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new ServiceException(MessageUtils.message("no.operation.interrupted.try.again"));
+        }
+    }
+
     /**
      * 增加余额
      *

+ 3 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/UserBilling.java

@@ -63,6 +63,9 @@ public class UserBilling
     @Excel(name = "门店id")
     private Long mdId;
 
+    /** 关联摊位ID,非摊位账单为NULL */
+    private Long storeId;
+
     /** 门店名称 */
     @TableField(exist = false)
     private String mdName;

+ 2 - 1
ruoyi-system/src/main/resources/mapper/Billing/UserBillingMapper.xml

@@ -15,6 +15,7 @@
         <result property="paymentMethod"    column="payment_method"    />
         <result property="accountNumber"    column="account_number"    />
         <result property="mdId"    column="md_id"    />
+        <result property="storeId"    column="store_id"    />
         <result property="divvy"    column="divvy"    />
         <result property="balancePay"    column="balance_pay"    />
         <result property="userType"    column="user_type"    />
@@ -27,7 +28,7 @@
     </resultMap>
 
     <sql id="selectUserBillingVo">
-        select b.id, b.user_id, b.type, b.dd_id, b.md_id,b.amount, b.divvy,b.state, b.illustrate, b.payment_method, b.account_number,b.balance_pay, b.user_type,b.cretim, b.divvy_rate, b.behalf_amount, b.wallet_balance, b.tax, b.tax_rate from user_billing b
+        select b.id, b.user_id, b.type, b.dd_id, b.md_id, b.store_id, b.amount, b.divvy,b.state, b.illustrate, b.payment_method, b.account_number,b.balance_pay, b.user_type,b.cretim, b.divvy_rate, b.behalf_amount, b.wallet_balance, b.tax, b.tax_rate from user_billing b
     </sql>
 
     <select id="selectUserBillingList" parameterType="UserBilling" resultMap="UserBillingResult">