Jelajahi Sumber

开发通用点餐服务

lutong 2 minggu lalu
induk
melakukan
888d933915

+ 159 - 0
alien-dining/docs/通用价目点餐接口说明.md

@@ -0,0 +1,159 @@
+# 通用价目点餐(与美食点餐并存)说明文档
+
+本文描述 **通用价目表(`store_price`)** 点餐能力:与原有 **美食菜品(`store_cuisine`)** 共用订单/明细/购物车等表结构,通过 **`menu_type`、`line_type`、`store_table.type`** 等字段区分;**未删除或替换** 原 `/store/dining`、`/store/order` 接口。
+
+---
+
+## 1. 概念与约束
+
+| 项目 | 美食点餐 | 通用价目点餐 |
+|------|----------|----------------|
+| 商品主数据 | `store_cuisine` | `store_price` |
+| 桌台 `store_table.type` | `1` 或 `NULL`(按美食处理) | 必须为 **`2`** |
+| 购物车隔离 | Redis/DB:`menu_type = 1` | Redis 前缀 + DB:`menu_type = 2` |
+| 订单 `store_order.menu_type` | `1` | `2` |
+| 明细 `store_order_detail.line_type` | `1`,`cuisine_id` = 菜品 id | `2`,`cuisine_id` 存 **`store_price.id`** |
+| 优惠规则 `rule_product_type` | `1` 或 `NULL`(指向菜品) | `2`(`product_id` 指向 **`store_price.id`**) |
+
+**前端约定:** 通用流程里凡名为 `cuisineId` 的入参(如加购 DTO),语义为 **`store_price.id`**,不是 `store_cuisine.id`。
+
+---
+
+## 2. 数据库变更
+
+执行脚本(需在目标库执行一次):
+
+`alien-dining/src/main/resources/db/generic_ordering_alter.sql`
+
+主要包括:`store_order.menu_type`、`store_order_detail.line_type`、`store_cart.menu_type`、`store_product_discount_rule.rule_product_type` 及索引。
+
+**注意:** 未执行脚本而代码已包含对应实体字段时,读写可能报列不存在。**历史 `store_cart` 行** 建议将美食购物车统一为 `menu_type = 1`(或与脚本默认值一致),否则可能影响美食购物车查询。
+
+---
+
+## 3. 新接口一览(通用价目)
+
+网关若有统一前缀(如 `/api`),请在下列路径前自行拼接。
+
+### 3.1 点餐页 / 券 / 锁单 / 结算预览
+
+**前缀:** `/store/generic-dining`
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| GET | `/page-info` | 开桌/人数,同美食逻辑;**仅 `type=2` 桌** |
+| GET | `/search` | 按名称搜 `store_price` |
+| GET | `/cuisines` | 分页列表;可选 `categoryId`(`category_ids` 为 JSON 时用 `JSON_CONTAINS`) |
+| GET | `/cuisine/{priceItemId}` | 详情,`priceItemId` = `store_price.id` |
+| GET | `/order/confirm` | 确认页;购物车为通用价目 |
+| GET | `/coupons/available` | 可领券(与美食共用门店券逻辑) |
+| POST | `/coupon/receive` | 领券 |
+| POST | `/order/lock` | 锁单(Redis 键与美食相同:`order:lock:table:{tableId}`) |
+| POST | `/order/unlock` | 解锁 |
+| GET | `/order/check-lock` | 查询持锁用户 |
+| GET | `/order/settlement` | 结算页信息(按 `orderId`) |
+| POST | `/order/settlement/lock` | 锁结算 |
+| POST | `/order/settlement/unlock` | 解结算锁 |
+
+以下接口 **仅在美食控制器** 提供,通用桌如需相同能力可继续调旧路径(与桌台业务相关,未复制到 generic):
+
+- `/store/dining/walk-in/reservation`
+- `/store/dining/reservation/detail-by-store-table-record`
+- `/store/dining/table-dining-status`
+- `/store/dining/service-fee/estimate`
+
+### 3.2 购物车 / 下单 / SSE
+
+**前缀:** `/store/generic-order`
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| GET | `/cart/{tableId}` | 获取通用购物车 |
+| POST | `/cart/add` | 加购,`AddCartItemDTO.cuisineId` = `store_price.id` |
+| PUT | `/cart/update` | `tableId`、`cuisineId`(价目 id)、`quantity` |
+| DELETE | `/cart/remove` | 删除一行 |
+| DELETE | `/cart/clear` | 清空(保留逻辑与实现一致,参见代码) |
+| POST | `/cart/set-diner-count` | 用餐人数 |
+| PUT | `/cart/update-tableware` | 餐具数量 |
+| POST | `/create` | 创建订单,对应服务方法 **`createGenericOrder`**;须先锁单成功 |
+| GET | `/sse/{tableId}` | SSE;与美食共用「按桌台」通道,推送内容随业务为购物车或订单事件 |
+
+**换桌:** 当前美食侧 `change-table` 未一并迁移通用 Redis 购物车;通用换桌如需与美食一致行为,需在后续迭代扩展(`GenericCartService#migrateCart` 现状为不支持)。
+
+---
+
+## 4. 与旧接口对照(美食 → 通用)
+
+同一语义的新旧路径对比如下(**旧** 仍保留,并非废弃)。
+
+### 4.1 点餐域
+
+| 旧(美食) | 新(通用价目) |
+|------------|----------------|
+| `GET /store/dining/page-info` | `GET /store/generic-dining/page-info` |
+| `GET /store/dining/search` | `GET /store/generic-dining/search` |
+| `GET /store/dining/cuisines` | `GET /store/generic-dining/cuisines` |
+| `GET /store/dining/cuisine/{cuisineId}` | `GET /store/generic-dining/cuisine/{priceItemId}` |
+| `GET /store/dining/order/confirm` | `GET /store/generic-dining/order/confirm` |
+| `GET /store/dining/coupons/available` | `GET /store/generic-dining/coupons/available` |
+| `POST /store/dining/coupon/receive` | `POST /store/generic-dining/coupon/receive` |
+| `POST /store/dining/order/lock` | `POST /store/generic-dining/order/lock` |
+| `POST /store/dining/order/unlock` | `POST /store/generic-dining/order/unlock` |
+| `GET /store/dining/order/check-lock` | `GET /store/generic-dining/order/check-lock` |
+| `GET /store/dining/order/settlement` | `GET /store/generic-dining/order/settlement` |
+| `POST /store/dining/order/settlement/lock` | `POST /store/generic-dining/order/settlement/lock` |
+| `POST /store/dining/order/settlement/unlock` | `POST /store/generic-dining/order/settlement/unlock` |
+
+### 4.2 订单域(购物车与创建)
+
+| 旧(美食) | 新(通用价目) |
+|------------|----------------|
+| `GET /store/order/cart/{tableId}` | `GET /store/generic-order/cart/{tableId}` |
+| `POST /store/order/cart/add` | `POST /store/generic-order/cart/add` |
+| `PUT /store/order/cart/update` | `PUT /store/generic-order/cart/update` |
+| `DELETE /store/order/cart/remove` | `DELETE /store/generic-order/cart/remove` |
+| `DELETE /store/order/cart/clear` | `DELETE /store/generic-order/cart/clear` |
+| `POST /store/order/cart/set-diner-count` | `POST /store/generic-order/cart/set-diner-count` |
+| `PUT /store/order/cart/update-tableware` | `PUT /store/generic-order/cart/update-tableware` |
+| `POST /store/order/create` | `POST /store/generic-order/create` |
+| `GET /store/order/sse/{tableId}` | `GET /store/generic-order/sse/{tableId}` |
+
+---
+
+## 5. 仍仅使用旧路径的订单能力
+
+以下接口 **未** 提供 `generic-order` 副本,通用价目订单创建后仍通过 **`/store/order/...`** 使用(订单记录上带有 `menu_type=2`):
+
+- 支付:`POST /store/order/pay/{orderId}`
+- 取消:`POST /store/order/cancel/{orderId}`
+- 详情 / 明细 / 变更记录:`/store/order/detail/...`、`detail/list`、`info`、`change-log`
+- 分页与我的订单:`/store/order/page`、`/store/order/my-orders`
+- 换桌:`POST /store/order/change-table`(美食购物车迁移逻辑为主;通用购物车参见上文说明)
+- 完成订单、商家完成、改券、管理员重置桌等
+
+具体以 `StoreOrderController` 为准。
+
+---
+
+## 6. 兼容性说明(旧代码是否仍可用)
+
+- **旧 URL 未被移除**:继续使用 `/store/dining`、`/store/order` 的美食流程在设计上仍支持。
+- **前提:** 已执行 DDL;美食桌 **`store_table.type` 不为 `2`**(`NULL` 一般仍走美食)。
+- **桌台为 `type=2` 时**:美食下单接口会按业务拒绝,须改用 **`/store/generic-order/create`** 等通用入口。
+
+---
+
+## 7. 关键代码位置(便于维护)
+
+| 内容 | 路径 |
+|------|------|
+| 常量 | `alien-dining/.../constants/OrderMenuConstants.java` |
+| DDL | `alien-dining/src/main/resources/db/generic_ordering_alter.sql` |
+| 通用浏览 | `GenericDiningController`、`GenericDiningServiceImpl` |
+| 通用购物车 | `GenericCartService`、`GenericCartServiceImpl` |
+| 通用下单 | `StoreOrderService#createGenericOrder`、`StoreOrderServiceImpl` |
+| 美食购物车过滤等 | `CartServiceImpl`、`StoreCartMenuFilters` |
+
+---
+
+*文档版本:与仓库中通用价目点餐实现同步整理,如有接口增减请以 Swagger / 源码为准。*

+ 28 - 0
alien-dining/src/main/java/shop/alien/dining/constants/OrderMenuConstants.java

@@ -0,0 +1,28 @@
+package shop.alien.dining.constants;
+
+/**
+ * 点餐菜单维度:美食价目(store_cuisine)与通用价目(store_price)。
+ */
+public final class OrderMenuConstants {
+
+    private OrderMenuConstants() {
+    }
+
+    /** 美食 / store_cuisine */
+    public static final int MENU_TYPE_CUISINE = 1;
+    /** 通用价目 / store_price */
+    public static final int MENU_TYPE_GENERIC_PRICE = 2;
+
+    /** 订单明细:行来自 store_cuisine */
+    public static final int LINE_TYPE_CUISINE = 1;
+    /** 订单明细:行来自 store_price(cuisine_id 列存 store_price.id) */
+    public static final int LINE_TYPE_GENERIC_PRICE = 2;
+
+    /** 优惠规则:product_id 指向 store_cuisine.id */
+    public static final int RULE_PRODUCT_CUISINE = 1;
+    /** 优惠规则:product_id 指向 store_price.id */
+    public static final int RULE_PRODUCT_GENERIC_PRICE = 2;
+
+    public static final String GENERIC_CART_REDIS_PREFIX = "generic-cart:table:";
+    public static final String GENERIC_COUPON_REDIS_PREFIX = "generic-coupon:used:table:";
+}

+ 249 - 0
alien-dining/src/main/java/shop/alien/dining/controller/GenericDiningController.java

@@ -0,0 +1,249 @@
+package shop.alien.dining.controller;
+
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import io.swagger.annotations.ApiParam;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.util.StringUtils;
+import org.springframework.web.bind.annotation.*;
+import shop.alien.dining.service.DiningService;
+import shop.alien.dining.service.GenericDiningService;
+import shop.alien.dining.util.TokenUtil;
+import shop.alien.entity.result.R;
+import shop.alien.entity.store.vo.*;
+
+import java.util.List;
+
+/**
+ * 小程序-通用价目(store_price)点餐页,路径与 {@link DiningController} 区分,仅允许 type=2 桌台。
+ */
+@Slf4j
+@Api(tags = {"小程序-通用价目点餐"})
+@CrossOrigin
+@RestController
+@RequestMapping("/store/generic-dining")
+@RequiredArgsConstructor
+public class GenericDiningController {
+
+    private final GenericDiningService genericDiningService;
+    private final DiningService diningService;
+
+    @ApiOperation(value = "获取点餐页面信息", notes = "逻辑同美食点餐 page-info,仅通用价目桌(type=2)可用")
+    @GetMapping("/page-info")
+    public R<DiningPageInfoVO> getDiningPageInfo(
+            @ApiParam(value = "桌号ID", required = true) @RequestParam Integer tableId,
+            @ApiParam(value = "就餐人数(首客必传;餐桌已就餐中时可省略)") @RequestParam(required = false) Integer dinerCount) {
+        log.info("GenericDiningController.getDiningPageInfo?tableId={}, dinerCount={}", tableId, dinerCount);
+        try {
+            Integer userId = TokenUtil.getCurrentUserId();
+            if (userId == null) {
+                return R.fail("用户未登录");
+            }
+            return R.data(genericDiningService.getDiningPageInfo(tableId, dinerCount));
+        } catch (Exception e) {
+            log.error("通用价目-获取点餐页面信息失败: {}", e.getMessage(), e);
+            return R.fail("获取点餐页面信息失败: " + e.getMessage());
+        }
+    }
+
+    @ApiOperation(value = "搜索价目", notes = "按名称模糊搜索 store_price,关键词限10字")
+    @GetMapping("/search")
+    public R<List<CuisineListVO>> search(
+            @ApiParam(value = "门店ID", required = true) @RequestParam Integer storeId,
+            @ApiParam(value = "关键词") @RequestParam(required = false) String keyword,
+            @ApiParam(value = "桌号ID", required = true) @RequestParam Integer tableId) {
+        log.info("GenericDiningController.search?storeId={}, keyword={}, tableId={}", storeId, keyword, tableId);
+        try {
+            if (!TokenUtil.hasValidToken()) {
+                return R.fail("用户未登录");
+            }
+            if (StringUtils.hasText(keyword) && keyword.length() > 10) {
+                keyword = keyword.substring(0, 10);
+            }
+            return R.data(genericDiningService.searchCuisines(storeId, keyword, tableId));
+        } catch (Exception e) {
+            log.error("通用价目-搜索失败: {}", e.getMessage(), e);
+            return R.fail("搜索失败: " + e.getMessage());
+        }
+    }
+
+    @ApiOperation(value = "价目列表", notes = "分页;categoryId 可选,对应 store_price.category_ids JSON 数组")
+    @GetMapping("/cuisines")
+    public R<List<CuisineListVO>> cuisines(
+            @ApiParam(value = "门店ID", required = true) @RequestParam Integer storeId,
+            @ApiParam(value = "分类ID") @RequestParam(required = false) Integer categoryId,
+            @ApiParam(value = "桌号ID", required = true) @RequestParam Integer tableId,
+            @ApiParam(value = "页码") @RequestParam(defaultValue = "1") Integer page,
+            @ApiParam(value = "每页数量") @RequestParam(defaultValue = "12") Integer size) {
+        log.info("GenericDiningController.cuisines?storeId={}, categoryId={}, tableId={}, page={}, size={}", storeId, categoryId, tableId, page, size);
+        try {
+            if (!TokenUtil.hasValidToken()) {
+                return R.fail("用户未登录");
+            }
+            return R.data(genericDiningService.getCuisinesByCategory(storeId, categoryId, tableId, page, size));
+        } catch (Exception e) {
+            log.error("通用价目-列表失败: {}", e.getMessage(), e);
+            return R.fail("获取列表失败: " + e.getMessage());
+        }
+    }
+
+    @ApiOperation(value = "价目详情", notes = "cuisineId 实为 store_price.id;加购时 AddCartItemDTO.cuisineId 传此 id")
+    @GetMapping("/cuisine/{priceItemId}")
+    public R<CuisineDetailVO> detail(
+            @ApiParam(value = "价目ID(store_price.id)", required = true) @PathVariable Integer priceItemId,
+            @ApiParam(value = "桌号ID", required = true) @RequestParam Integer tableId) {
+        log.info("GenericDiningController.detail?priceItemId={}, tableId={}", priceItemId, tableId);
+        try {
+            return R.data(genericDiningService.getCuisineDetail(priceItemId, tableId));
+        } catch (Exception e) {
+            log.error("通用价目-详情失败: {}", e.getMessage(), e);
+            return R.fail("获取详情失败: " + e.getMessage());
+        }
+    }
+
+    @ApiOperation(value = "订单确认页信息", notes = "购物车为通用价目 Redis/DB;优惠券与美食共用门店券接口")
+    @GetMapping("/order/confirm")
+    public R<OrderConfirmVO> orderConfirm(
+            @ApiParam(value = "桌号ID", required = true) @RequestParam Integer tableId,
+            @ApiParam(value = "就餐人数", required = true) @RequestParam Integer dinerCount) {
+        log.info("GenericDiningController.orderConfirm?tableId={}, dinerCount={}", tableId, dinerCount);
+        try {
+            Integer userId = TokenUtil.getCurrentUserId();
+            if (userId == null) {
+                return R.fail("用户未登录");
+            }
+            return R.data(genericDiningService.getOrderConfirmInfo(tableId, dinerCount, userId));
+        } catch (Exception e) {
+            log.error("通用价目-确认页失败: {}", e.getMessage(), e);
+            return R.fail("获取订单确认信息失败: " + e.getMessage());
+        }
+    }
+
+    @ApiOperation(value = "可领取优惠券")
+    @GetMapping("/coupons/available")
+    public R<List<AvailableCouponVO>> availableCoupons(@ApiParam(value = "门店ID", required = true) @RequestParam Integer storeId) {
+        try {
+            Integer userId = TokenUtil.getCurrentUserId();
+            if (userId == null) {
+                return R.fail("用户未登录");
+            }
+            return R.data(diningService.getAvailableCoupons(storeId, userId));
+        } catch (Exception e) {
+            log.error("通用价目-可领券列表失败: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation(value = "领取优惠券")
+    @PostMapping("/coupon/receive")
+    public R<Boolean> receiveCoupon(@ApiParam(value = "优惠券ID", required = true) @RequestParam Integer couponId) {
+        try {
+            Integer userId = TokenUtil.getCurrentUserId();
+            if (userId == null) {
+                return R.fail("用户未登录");
+            }
+            return R.data(diningService.receiveCoupon(couponId, userId));
+        } catch (Exception e) {
+            log.error("通用价目-领券失败: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation(value = "锁定订单", notes = "与美食共用 Redis 锁键 order:lock:table:{tableId}")
+    @PostMapping("/order/lock")
+    public R<Boolean> lockOrder(@ApiParam(value = "桌号ID", required = true) @RequestParam Integer tableId) {
+        try {
+            Integer userId = TokenUtil.getCurrentUserId();
+            if (userId == null) {
+                return R.fail("用户未登录");
+            }
+            if (!diningService.lockOrder(tableId, userId)) {
+                return R.fail("订单已被其他用户锁定,无法下单");
+            }
+            return R.data(true);
+        } catch (Exception e) {
+            log.error("通用价目-锁单失败: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation(value = "解锁订单")
+    @PostMapping("/order/unlock")
+    public R<Boolean> unlockOrder(@ApiParam(value = "桌号ID", required = true) @RequestParam Integer tableId) {
+        try {
+            Integer userId = TokenUtil.getCurrentUserId();
+            if (userId == null) {
+                return R.fail("用户未登录");
+            }
+            diningService.unlockOrder(tableId, userId);
+            return R.data(true);
+        } catch (Exception e) {
+            log.error("通用价目-解锁失败: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation(value = "查询订单锁")
+    @GetMapping("/order/check-lock")
+    public R<Integer> checkLock(@ApiParam(value = "桌号ID", required = true) @RequestParam Integer tableId) {
+        try {
+            if (!TokenUtil.hasValidToken()) {
+                return R.fail("用户未登录");
+            }
+            return R.data(diningService.checkOrderLock(tableId));
+        } catch (Exception e) {
+            log.error("通用价目-查锁失败: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation(value = "结算页信息")
+    @GetMapping("/order/settlement")
+    public R<OrderSettlementVO> settlement(@ApiParam(value = "订单ID", required = true) @RequestParam Integer orderId) {
+        try {
+            Integer userId = TokenUtil.getCurrentUserId();
+            if (userId == null) {
+                return R.fail("用户未登录");
+            }
+            return R.data(diningService.getOrderSettlementInfo(orderId, userId));
+        } catch (Exception e) {
+            log.error("通用价目-结算页失败: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation(value = "锁定结算")
+    @PostMapping("/order/settlement/lock")
+    public R<Boolean> lockSettlement(@ApiParam(value = "订单ID", required = true) @RequestParam Integer orderId) {
+        try {
+            Integer userId = TokenUtil.getCurrentUserId();
+            if (userId == null) {
+                return R.fail("用户未登录");
+            }
+            if (!diningService.lockSettlement(orderId, userId)) {
+                return R.fail("订单已被其他用户锁定,无法结算");
+            }
+            return R.data(true);
+        } catch (Exception e) {
+            log.error("通用价目-锁结算失败: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation(value = "解锁结算")
+    @PostMapping("/order/settlement/unlock")
+    public R<Boolean> unlockSettlement(@ApiParam(value = "订单ID", required = true) @RequestParam Integer orderId) {
+        try {
+            Integer userId = TokenUtil.getCurrentUserId();
+            if (userId == null) {
+                return R.fail("用户未登录");
+            }
+            diningService.unlockSettlement(orderId, userId);
+            return R.data(true);
+        } catch (Exception e) {
+            log.error("通用价目-解锁结算失败: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+}

+ 206 - 0
alien-dining/src/main/java/shop/alien/dining/controller/GenericStoreOrderController.java

@@ -0,0 +1,206 @@
+package shop.alien.dining.controller;
+
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
+import shop.alien.dining.service.GenericCartService;
+import shop.alien.dining.service.SseService;
+import shop.alien.dining.service.StoreOrderService;
+import shop.alien.dining.util.TokenUtil;
+import shop.alien.entity.result.R;
+import shop.alien.entity.store.StoreInfo;
+import shop.alien.entity.store.StoreOrder;
+import shop.alien.entity.store.dto.AddCartItemDTO;
+import shop.alien.entity.store.dto.CartDTO;
+import shop.alien.entity.store.dto.CreateOrderDTO;
+import shop.alien.entity.store.vo.OrderSuccessVO;
+import shop.alien.mapper.StoreInfoMapper;
+
+import javax.validation.Valid;
+
+/**
+ * 小程序-通用价目购物车与下单({@link shop.alien.dining.controller.StoreOrderController} 平行入口)。
+ */
+@Slf4j
+@Api(tags = {"小程序-通用价目订单"})
+@CrossOrigin
+@RestController
+@RequestMapping("/store/generic-order")
+@RequiredArgsConstructor
+public class GenericStoreOrderController {
+
+    private final StoreOrderService orderService;
+    private final GenericCartService genericCartService;
+    private final SseService sseService;
+    private final StoreInfoMapper storeInfoMapper;
+
+    @ApiOperation(value = "获取通用价目购物车")
+    @GetMapping("/cart/{tableId}")
+    public R<CartDTO> getCart(@PathVariable Integer tableId) {
+        try {
+            if (!TokenUtil.hasValidToken()) {
+                return R.fail("用户未登录");
+            }
+            return R.data(genericCartService.getCart(tableId));
+        } catch (Exception e) {
+            log.error("通用价目-获取购物车失败: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation(value = "加购", notes = "AddCartItemDTO.cuisineId 传 store_price.id")
+    @PostMapping("/cart/add")
+    public R<CartDTO> addCart(@Valid @RequestBody AddCartItemDTO dto) {
+        try {
+            if (!TokenUtil.hasValidToken()) {
+                return R.fail("用户未登录");
+            }
+            CartDTO cart = genericCartService.addItem(dto);
+            sseService.pushCartUpdate(dto.getTableId(), cart);
+            return R.data(cart);
+        } catch (Exception e) {
+            log.error("通用价目-加购失败: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation(value = "更新数量", notes = "cuisineId 为 store_price.id")
+    @PutMapping("/cart/update")
+    public R<CartDTO> updateCart(
+            @RequestParam Integer tableId,
+            @RequestParam Integer cuisineId,
+            @RequestParam Integer quantity) {
+        try {
+            if (!TokenUtil.hasValidToken()) {
+                return R.fail("用户未登录");
+            }
+            CartDTO cart = genericCartService.updateItemQuantity(tableId, cuisineId, quantity);
+            sseService.pushCartUpdate(tableId, cart);
+            return R.data(cart);
+        } catch (Exception e) {
+            log.error("通用价目-更新购物车失败: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation(value = "删除行")
+    @DeleteMapping("/cart/remove")
+    public R<CartDTO> remove(
+            @RequestParam Integer tableId,
+            @RequestParam Integer cuisineId) {
+        try {
+            if (!TokenUtil.hasValidToken()) {
+                return R.fail("用户未登录");
+            }
+            CartDTO cart = genericCartService.removeItem(tableId, cuisineId);
+            sseService.pushCartUpdate(tableId, cart);
+            return R.data(cart);
+        } catch (Exception e) {
+            log.error("通用价目-删除购物车行失败: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation(value = "清空购物车")
+    @DeleteMapping("/cart/clear")
+    public R<CartDTO> clear(@RequestParam Integer tableId) {
+        try {
+            if (!TokenUtil.hasValidToken()) {
+                return R.fail("用户未登录");
+            }
+            genericCartService.clearCart(tableId);
+            CartDTO cart = genericCartService.getCart(tableId);
+            sseService.pushCartUpdate(tableId, cart);
+            return R.data(cart);
+        } catch (Exception e) {
+            log.error("通用价目-清空购物车失败: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation(value = "设置用餐人数")
+    @PostMapping("/cart/set-diner-count")
+    public R<CartDTO> setDinerCount(@RequestParam Integer tableId, @RequestParam Integer dinerCount) {
+        try {
+            if (!TokenUtil.hasValidToken()) {
+                return R.fail("用户未登录");
+            }
+            if (dinerCount == null || dinerCount <= 0) {
+                return R.fail("用餐人数必须大于0");
+            }
+            CartDTO cart = genericCartService.setDinerCount(tableId, dinerCount);
+            sseService.pushCartUpdate(tableId, cart);
+            return R.data(cart);
+        } catch (Exception e) {
+            log.error("通用价目-设置人数失败: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation(value = "更新餐具数量")
+    @PutMapping("/cart/update-tableware")
+    public R<CartDTO> updateTableware(@RequestParam Integer tableId, @RequestParam(required = false) Integer quantity) {
+        try {
+            if (!TokenUtil.hasValidToken()) {
+                return R.fail("用户未登录");
+            }
+            if (quantity != null && quantity < 0) {
+                return R.fail("餐具数量不能小于0");
+            }
+            if (quantity == null) {
+                CartDTO cart = genericCartService.getCart(tableId);
+                sseService.pushCartUpdate(tableId, cart);
+                return R.data(cart);
+            }
+            CartDTO cart = genericCartService.updateTablewareQuantity(tableId, quantity);
+            sseService.pushCartUpdate(tableId, cart);
+            return R.data(cart);
+        } catch (Exception e) {
+            log.error("通用价目-餐具数量失败: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation(value = "创建订单", notes = "须先锁定订单;桌台须为通用价目桌。逻辑见 StoreOrderService.createGenericOrder")
+    @PostMapping("/create")
+    public R<OrderSuccessVO> create(@Valid @RequestBody CreateOrderDTO dto) {
+        try {
+            if (!TokenUtil.hasValidToken()) {
+                return R.fail("用户未登录");
+            }
+            if (dto.getRemark() != null && dto.getRemark().length() > 30) {
+                dto.setRemark(dto.getRemark().substring(0, 30));
+            }
+            dto.setImmediatePay(0);
+            StoreOrder order = orderService.createGenericOrder(dto);
+
+            OrderSuccessVO vo = new OrderSuccessVO();
+            vo.setOrderId(order.getId());
+            vo.setOrderNo(order.getOrderNo());
+            vo.setTableNumber(order.getTableNumber());
+            vo.setDinerCount(order.getDinerCount());
+            vo.setOrderStatus(order.getOrderStatus());
+            StoreInfo storeInfo = storeInfoMapper.selectById(order.getStoreId());
+            vo.setStoreName(storeInfo != null ? storeInfo.getStoreName() : null);
+
+            sseService.pushCartUpdate(dto.getTableId(), order);
+            return R.data(vo);
+        } catch (Exception e) {
+            log.error("通用价目-下单失败: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation(value = "SSE", notes = "与美食同通道,按 tableId;推送内容为通用购物车或订单事件")
+    @GetMapping(value = "/sse/{tableId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
+    public SseEmitter sse(@PathVariable Integer tableId) {
+        if (!TokenUtil.hasValidToken()) {
+            return null;
+        }
+        return sseService.createConnection(tableId);
+    }
+}

+ 38 - 0
alien-dining/src/main/java/shop/alien/dining/service/GenericCartService.java

@@ -0,0 +1,38 @@
+package shop.alien.dining.service;
+
+import shop.alien.entity.store.dto.AddCartItemDTO;
+import shop.alien.entity.store.dto.CartDTO;
+
+/**
+ * 通用价目表(store_price)购物车,与美食购物车 Redis/DB 隔离。
+ */
+public interface GenericCartService {
+
+    CartDTO getCart(Integer tableId);
+
+    CartDTO addItem(AddCartItemDTO dto);
+
+    CartDTO updateItemQuantity(Integer tableId, Integer priceItemId, Integer quantity);
+
+    CartDTO removeItem(Integer tableId, Integer priceItemId);
+
+    void clearCart(Integer tableId);
+
+    default CartDTO migrateCart(Integer fromTableId, Integer toTableId) {
+        throw new UnsupportedOperationException("通用价目购物车暂不支持换桌迁移,请后续扩展");
+    }
+
+    boolean hasUsedCoupon(Integer tableId);
+
+    void markCouponUsed(Integer tableId, Integer couponId);
+
+    void clearCouponUsed(Integer tableId);
+
+    CartDTO setDinerCount(Integer tableId, Integer dinerCount);
+
+    CartDTO updateTablewareQuantity(Integer tableId, Integer quantity);
+
+    CartDTO lockCartItems(Integer tableId);
+
+    CartDTO unlockCartItems(Integer tableId);
+}

+ 21 - 0
alien-dining/src/main/java/shop/alien/dining/service/GenericDiningService.java

@@ -0,0 +1,21 @@
+package shop.alien.dining.service;
+
+import shop.alien.entity.store.vo.*;
+
+import java.util.List;
+
+/**
+ * 通用价目(store_price)点餐页:列表/详情/确认页,仅允许 type=2 桌台。
+ */
+public interface GenericDiningService {
+
+    DiningPageInfoVO getDiningPageInfo(Integer tableId, Integer dinerCount);
+
+    List<CuisineListVO> searchCuisines(Integer storeId, String keyword, Integer tableId);
+
+    List<CuisineListVO> getCuisinesByCategory(Integer storeId, Integer categoryId, Integer tableId, Integer page, Integer size);
+
+    CuisineDetailVO getCuisineDetail(Integer priceItemId, Integer tableId);
+
+    OrderConfirmVO getOrderConfirmInfo(Integer tableId, Integer dinerCount, Integer userId);
+}

+ 7 - 1
alien-dining/src/main/java/shop/alien/dining/service/StoreOrderService.java

@@ -29,6 +29,11 @@ public interface StoreOrderService extends IService<StoreOrder> {
     StoreOrder createOrder(CreateOrderDTO dto);
 
     /**
+     * 通用价目表(store_price)下单,与 {@link #createOrder} 共用订单表,menu_type=2。
+     */
+    StoreOrder createGenericOrder(CreateOrderDTO dto);
+
+    /**
      * 支付订单
      *
      * @param orderId 订单ID
@@ -147,8 +152,9 @@ public interface StoreOrderService extends IService<StoreOrder> {
      * 支付完成后重置餐桌(保留订单数据,只重置餐桌绑定关系)
      *
      * @param tableId 餐桌ID
+     * @param orderMenuType 订单 {@code menu_type},用于仅清理对应类型购物车;可为 null 表示清理美食+未区分的历史行
      */
-    void resetTableAfterPayment(Integer tableId);
+    void resetTableAfterPayment(Integer tableId, Integer orderMenuType);
 
     /**
      * 查询我的订单(通过标识查询未支付订单或历史订单)

+ 6 - 0
alien-dining/src/main/java/shop/alien/dining/service/impl/CartServiceImpl.java

@@ -24,6 +24,8 @@ import shop.alien.mapper.StoreInfoMapper;
 import shop.alien.mapper.StoreProductDiscountRuleMapper;
 import shop.alien.mapper.StoreTableMapper;
 import shop.alien.dining.support.DiningMenuPricing;
+import shop.alien.dining.support.StoreCartMenuFilters;
+import shop.alien.dining.constants.OrderMenuConstants;
 import shop.alien.dining.util.TokenUtil;
 
 import java.math.BigDecimal;
@@ -207,6 +209,7 @@ public class CartServiceImpl implements CartService {
         LambdaQueryWrapper<StoreCart> wrapper = new LambdaQueryWrapper<>();
         wrapper.eq(StoreCart::getTableId, tableId);
         wrapper.eq(StoreCart::getDeleteFlag, 0);
+        StoreCartMenuFilters.applyCuisineCart(wrapper);
         List<StoreCart> cartList = storeCartMapper.selectList(wrapper);
 
         if (cartList != null && !cartList.isEmpty()) {
@@ -510,6 +513,7 @@ public class CartServiceImpl implements CartService {
                 LambdaQueryWrapper<StoreCart> wrapper = new LambdaQueryWrapper<>();
                 wrapper.eq(StoreCart::getTableId, tableId);
                 wrapper.eq(StoreCart::getDeleteFlag, 0);
+                StoreCartMenuFilters.applyCuisineCart(wrapper);
                 // 排除餐具(cuisineId = -1)
                 wrapper.ne(StoreCart::getCuisineId, TABLEWARE_CUISINE_ID);
                 if (!orderedCuisineIds.isEmpty()) {
@@ -1009,6 +1013,7 @@ public class CartServiceImpl implements CartService {
             LambdaQueryWrapper<StoreCart> queryWrapper = new LambdaQueryWrapper<>();
             queryWrapper.eq(StoreCart::getTableId, cart.getTableId())
                     .eq(StoreCart::getDeleteFlag, 0);
+            StoreCartMenuFilters.applyCuisineCart(queryWrapper);
             List<StoreCart> existingCartList = storeCartMapper.selectList(queryWrapper);
             if (existingCartList != null && !existingCartList.isEmpty()) {
                 List<Integer> cartIds = existingCartList.stream()
@@ -1036,6 +1041,7 @@ public class CartServiceImpl implements CartService {
                     storeCart.setAddUserPhone(item.getAddUserPhone());
                     storeCart.setRemark(item.getRemark());
                     storeCart.setDeleteFlag(0);
+                    storeCart.setMenuType(OrderMenuConstants.MENU_TYPE_CUISINE);
                     storeCart.setCreatedTime(now);
                     storeCart.setCreatedUserId(userId);
                     storeCart.setUpdatedTime(now);

+ 1025 - 0
alien-dining/src/main/java/shop/alien/dining/service/impl/GenericCartServiceImpl.java

@@ -0,0 +1,1025 @@
+package shop.alien.dining.service.impl;
+
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONObject;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+import shop.alien.dining.config.BaseRedisService;
+import shop.alien.dining.constants.OrderMenuConstants;
+import shop.alien.dining.service.GenericCartService;
+import shop.alien.entity.store.StoreCart;
+import shop.alien.entity.store.StoreCouponUsage;
+import shop.alien.entity.store.StoreInfo;
+import shop.alien.entity.store.StorePrice;
+import shop.alien.entity.store.StoreTable;
+import shop.alien.entity.store.dto.AddCartItemDTO;
+import shop.alien.entity.store.dto.CartDTO;
+import shop.alien.entity.store.dto.CartItemDTO;
+import shop.alien.mapper.StoreCartMapper;
+import shop.alien.mapper.StoreCouponUsageMapper;
+import shop.alien.mapper.StorePriceMapper;
+import shop.alien.mapper.StoreInfoMapper;
+import shop.alien.mapper.StoreProductDiscountRuleMapper;
+import shop.alien.mapper.StoreTableMapper;
+import shop.alien.dining.support.DiningMenuPricing;
+import shop.alien.dining.support.StoreCartMenuFilters;
+import shop.alien.dining.util.TokenUtil;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.stream.Collectors;
+
+/**
+ * 通用价目表(store_price)购物车:Redis/DB 与美食购物车隔离(menu_type=2)。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class GenericCartServiceImpl implements GenericCartService {
+
+    private static final String CART_KEY_PREFIX = OrderMenuConstants.GENERIC_CART_REDIS_PREFIX;
+    private static final String COUPON_USED_KEY_PREFIX = OrderMenuConstants.GENERIC_COUPON_REDIS_PREFIX;
+    private static final int CART_EXPIRE_SECONDS = 24 * 60 * 60; // 24小时过期
+
+    // 异步写入数据库的线程池(专门用于购物车数据库写入)
+    private static final ExecutorService CART_DB_WRITE_EXECUTOR = Executors.newFixedThreadPool(5, r -> {
+        Thread t = new Thread(r, "generic-cart-db-write-" + System.currentTimeMillis());
+        t.setDaemon(true);
+        return t;
+    });
+
+    private final BaseRedisService baseRedisService;
+    private final StoreTableMapper storeTableMapper;
+    private final StorePriceMapper storePriceMapper;
+    private final StoreCartMapper storeCartMapper;
+    private final StoreCouponUsageMapper storeCouponUsageMapper;
+    private final StoreInfoMapper storeInfoMapper;
+    private final StoreProductDiscountRuleMapper storeProductDiscountRuleMapper;
+
+    @Override
+    public CartDTO getCart(Integer tableId) {
+        log.info("获取通用价目购物车, tableId={}", tableId);
+        String cartKey = CART_KEY_PREFIX + tableId;
+        String cartJson = baseRedisService.getString(cartKey);
+
+        CartDTO cart = new CartDTO();
+        cart.setTableId(tableId);
+
+        // 查询桌号信息
+        StoreTable table = storeTableMapper.selectById(tableId);
+        if (table != null) {
+            cart.setTableNumber(table.getTableNumber());
+            cart.setStoreId(table.getStoreId());
+        }
+
+        if (StringUtils.hasText(cartJson)) {
+            try {
+                JSONObject cartObj = JSON.parseObject(cartJson);
+                List<CartItemDTO> items = cartObj.getList("items", CartItemDTO.class);
+                if (items != null) {
+                    cart.setItems(items);
+                    // 计算总金额和总数量
+                    BigDecimal totalAmount = items.stream()
+                            .map(CartItemDTO::getSubtotalAmount)
+                            .reduce(BigDecimal.ZERO, BigDecimal::add);
+                    Integer totalQuantity = items.stream()
+                            .mapToInt(CartItemDTO::getQuantity)
+                            .sum();
+                    cart.setTotalAmount(totalAmount);
+                    cart.setTotalQuantity(totalQuantity);
+                } else {
+                    cart.setItems(new ArrayList<>());
+                    cart.setTotalAmount(BigDecimal.ZERO);
+                    cart.setTotalQuantity(0);
+                }
+            } catch (Exception e) {
+                log.error("解析购物车数据失败: {}", e.getMessage(), e);
+                cart.setItems(new ArrayList<>());
+                cart.setTotalAmount(BigDecimal.ZERO);
+                cart.setTotalQuantity(0);
+            }
+        } else {
+            // Redis中没有,尝试从数据库加载
+            cart = loadCartFromDatabase(tableId);
+        }
+
+        applyRealtimeMenuPricing(cart);
+        return cart;
+    }
+
+    /**
+     * 按门店标价 + {@code store_product_discount_rule} 刷新行单价/小计/总价,并写回 Redis+DB(与下单明细口径一致)。
+     */
+    private void applyRealtimeMenuPricing(CartDTO cart) {
+        if (cart == null || cart.getStoreId() == null || cart.getItems() == null || cart.getItems().isEmpty()) {
+            return;
+        }
+        List<CartItemDTO> items = cart.getItems();
+        Set<Integer> cuisineIds = items.stream()
+                .map(CartItemDTO::getCuisineId)
+                .filter(Objects::nonNull)
+                .filter(id -> id > 0)
+                .collect(Collectors.toSet());
+        Map<Integer, BigDecimal> listByIdReal = cuisineIds.isEmpty()
+                ? java.util.Collections.emptyMap()
+                : DiningMenuPricing.resolveListUnitPriceByGenericPriceId(cuisineIds, storePriceMapper);
+        Map<Integer, BigDecimal> saleById = cuisineIds.isEmpty()
+                ? java.util.Collections.emptyMap()
+                : DiningMenuPricing.resolveSaleUnitPriceForGenericPrice(cart.getStoreId(), listByIdReal, storeProductDiscountRuleMapper);
+
+        boolean changed = false;
+        for (CartItemDTO it : items) {
+            Integer cid = it.getCuisineId();
+            int qty = it.getQuantity() != null ? it.getQuantity() : 0;
+            if (cid == null || cid <= 0) {
+                BigDecimal p = it.getUnitPrice() != null ? it.getUnitPrice() : BigDecimal.ZERO;
+                it.setOriginalUnitPrice(p);
+                it.setCurrentUnitPrice(p);
+                it.setHasActiveDiscount(Boolean.FALSE);
+                BigDecimal origSub = p.multiply(BigDecimal.valueOf(qty)).setScale(2, RoundingMode.HALF_UP);
+                BigDecimal newSub = origSub;
+                it.setOriginalSubtotalAmount(origSub);
+                if (!Objects.equals(it.getUnitPrice(), p) || !Objects.equals(it.getSubtotalAmount(), newSub)) {
+                    changed = true;
+                }
+                it.setUnitPrice(p);
+                it.setSubtotalAmount(newSub);
+                continue;
+            }
+            BigDecimal list = listByIdReal.getOrDefault(cid, BigDecimal.ZERO);
+            BigDecimal sale = saleById.getOrDefault(cid, list);
+            it.setOriginalUnitPrice(list);
+            it.setCurrentUnitPrice(sale);
+            it.setHasActiveDiscount(list.compareTo(sale) != 0);
+            BigDecimal origSub = list.multiply(BigDecimal.valueOf(qty)).setScale(2, RoundingMode.HALF_UP);
+            BigDecimal newSub = sale.multiply(BigDecimal.valueOf(qty)).setScale(2, RoundingMode.HALF_UP);
+            it.setOriginalSubtotalAmount(origSub);
+            if (!Objects.equals(it.getUnitPrice(), sale) || !Objects.equals(it.getSubtotalAmount(), newSub)
+                    || !Objects.equals(it.getOriginalSubtotalAmount(), origSub)) {
+                changed = true;
+            }
+            it.setUnitPrice(sale);
+            it.setSubtotalAmount(newSub);
+        }
+        BigDecimal total = items.stream()
+                .map(CartItemDTO::getSubtotalAmount)
+                .filter(Objects::nonNull)
+                .reduce(BigDecimal.ZERO, BigDecimal::add);
+        cart.setTotalAmount(total);
+        cart.setTotalQuantity(items.stream().mapToInt(i -> i.getQuantity() != null ? i.getQuantity() : 0).sum());
+        if (changed) {
+            saveCart(cart);
+        }
+    }
+
+    /**
+     * 从数据库加载购物车
+     */
+    private CartDTO loadCartFromDatabase(Integer tableId) {
+        log.info("从数据库加载购物车, tableId={}", tableId);
+        CartDTO cart = new CartDTO();
+        cart.setTableId(tableId);
+        cart.setItems(new ArrayList<>());
+        cart.setTotalAmount(BigDecimal.ZERO);
+        cart.setTotalQuantity(0);
+
+        // 查询桌号信息
+        StoreTable table = storeTableMapper.selectById(tableId);
+        if (table != null) {
+            cart.setTableNumber(table.getTableNumber());
+            cart.setStoreId(table.getStoreId());
+        }
+
+        // 从数据库查询购物车数据
+        LambdaQueryWrapper<StoreCart> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(StoreCart::getTableId, tableId);
+        wrapper.eq(StoreCart::getDeleteFlag, 0);
+        StoreCartMenuFilters.applyGenericCart(wrapper);
+        List<StoreCart> cartList = storeCartMapper.selectList(wrapper);
+
+        if (cartList != null && !cartList.isEmpty()) {
+            List<CartItemDTO> items = cartList.stream().map(cartItem -> {
+                CartItemDTO item = new CartItemDTO();
+                item.setCuisineId(cartItem.getCuisineId());
+                item.setCuisineName(cartItem.getCuisineName());
+                item.setCuisineImage(cartItem.getCuisineImage());
+                item.setUnitPrice(cartItem.getUnitPrice());
+                item.setQuantity(cartItem.getQuantity());
+                item.setLockedQuantity(cartItem.getLockedQuantity());
+                item.setSubtotalAmount(cartItem.getSubtotalAmount());
+                item.setAddUserId(cartItem.getAddUserId());
+                item.setAddUserPhone(cartItem.getAddUserPhone());
+                item.setRemark(cartItem.getRemark());
+                return item;
+            }).collect(Collectors.toList());
+
+            cart.setItems(items);
+            BigDecimal totalAmount = items.stream()
+                    .map(CartItemDTO::getSubtotalAmount)
+                    .reduce(BigDecimal.ZERO, BigDecimal::add);
+            Integer totalQuantity = items.stream()
+                    .mapToInt(CartItemDTO::getQuantity)
+                    .sum();
+            cart.setTotalAmount(totalAmount);
+            cart.setTotalQuantity(totalQuantity);
+
+            // 同步到Redis
+            saveCartToRedis(cart);
+        }
+
+        return cart;
+    }
+
+    @Override
+    public CartDTO addItem(AddCartItemDTO dto) {
+        log.info("添加商品到购物车, dto={}", dto);
+        // 验证桌号(须为通用价目桌 type=2)
+        StoreTable table = storeTableMapper.selectById(dto.getTableId());
+        if (table == null) {
+            throw new RuntimeException("桌号不存在");
+        }
+        if (table.getType() == null || !Integer.valueOf(2).equals(table.getType())) {
+            throw new RuntimeException("当前桌台不是通用价目桌,无法使用本购物车");
+        }
+
+        // dto.cuisineId 此处表示 store_price.id
+        StorePrice price = storePriceMapper.selectById(dto.getCuisineId());
+        if (price == null) {
+            throw new RuntimeException("价目项不存在");
+        }
+        if (price.getShelfStatus() == null || price.getShelfStatus() != 1) {
+            throw new RuntimeException("价目项已下架");
+        }
+        if (price.getStatus() == null || price.getStatus() != 1) {
+            throw new RuntimeException("价目项未审核通过");
+        }
+
+        // 获取当前用户信息
+        Integer userId = TokenUtil.getCurrentUserId();
+        String userPhone = TokenUtil.getCurrentUserPhone();
+
+        // 获取购物车
+        CartDTO cart = getCart(dto.getTableId());
+
+        // 查找是否已存在该商品
+        List<CartItemDTO> items = cart.getItems();
+        CartItemDTO existingItem = items.stream()
+                .filter(item -> item.getCuisineId().equals(dto.getCuisineId()))
+                .findFirst()
+                .orElse(null);
+
+        if (existingItem != null) {
+            // 商品已存在
+            Integer lockedQuantity = existingItem.getLockedQuantity();
+            if (lockedQuantity != null && lockedQuantity > 0) {
+                // 如果商品有已下单数量,将新数量叠加到当前数量和已下单数量上
+                Integer newQuantity = existingItem.getQuantity() + dto.getQuantity();
+                Integer newLockedQuantity = lockedQuantity + dto.getQuantity();
+                existingItem.setQuantity(newQuantity);
+                existingItem.setLockedQuantity(newLockedQuantity);
+                existingItem.setSubtotalAmount(existingItem.getUnitPrice()
+                        .multiply(BigDecimal.valueOf(newQuantity)));
+                log.info("商品已存在且有已下单数量,叠加数量, priceId={}, oldQuantity={}, newQuantity={}, oldOrderedQuantity={}, newOrderedQuantity={}",
+                        dto.getCuisineId(), existingItem.getQuantity() - dto.getQuantity(), newQuantity, lockedQuantity, newLockedQuantity);
+            } else {
+                // 商品已存在但没有已下单数量,不允许重复添加
+                throw new RuntimeException("已添加过本商品");
+            }
+        } else {
+            // 添加新商品
+            CartItemDTO newItem = new CartItemDTO();
+            newItem.setCuisineId(price.getId());
+            newItem.setCuisineName(price.getName());
+            newItem.setCuisineType(1);
+            newItem.setCuisineImage(price.getImages());
+            newItem.setUnitPrice(price.getTotalPrice());
+            newItem.setQuantity(dto.getQuantity());
+            newItem.setSubtotalAmount(price.getTotalPrice()
+                    .multiply(BigDecimal.valueOf(dto.getQuantity())));
+            newItem.setAddUserId(userId);
+            newItem.setAddUserPhone(userPhone);
+            newItem.setRemark(dto.getRemark());
+            items.add(newItem);
+        }
+
+        applyRealtimeMenuPricing(cart);
+        saveCart(cart);
+
+        return cart;
+    }
+
+    @Override
+    public CartDTO updateItemQuantity(Integer tableId, Integer cuisineId, Integer quantity) {
+        log.info("更新购物车商品数量, tableId={}, cuisineId={}, quantity={}", tableId, cuisineId, quantity);
+        
+        // 如果数量为0或小于0,删除该商品
+        if (quantity == null || quantity <= 0) {
+            log.info("商品数量为0或小于0,删除商品, tableId={}, cuisineId={}", tableId, cuisineId);
+            return removeItem(tableId, cuisineId);
+        }
+
+        CartDTO cart = getCart(tableId);
+        List<CartItemDTO> items = cart.getItems();
+        CartItemDTO item = items.stream()
+                .filter(i -> i.getCuisineId().equals(cuisineId))
+                .findFirst()
+                .orElse(null);
+
+        if (item != null) {
+            // 商品已存在,更新数量
+            // 检查已下单数量:不允许将数量减少到小于已下单数量
+            Integer lockedQuantity = item.getLockedQuantity();
+            if (lockedQuantity != null && lockedQuantity > 0) {
+                if (quantity < lockedQuantity) {
+                    throw new RuntimeException("商品数量不能少于已下单数量(" + lockedQuantity + "),该数量已下单");
+                }
+            }
+            
+            item.setQuantity(quantity);
+            item.setSubtotalAmount(item.getUnitPrice()
+                    .multiply(BigDecimal.valueOf(quantity)));
+
+            applyRealtimeMenuPricing(cart);
+            saveCart(cart);
+        } else {
+            log.info("商品不在购物车中,自动添加, tableId={}, priceId={}, quantity={}", tableId, cuisineId, quantity);
+            StoreTable t = storeTableMapper.selectById(tableId);
+            if (t == null) {
+                throw new RuntimeException("桌号不存在");
+            }
+            if (t.getType() == null || !Integer.valueOf(2).equals(t.getType())) {
+                throw new RuntimeException("当前桌台不是通用价目桌");
+            }
+            StorePrice price = storePriceMapper.selectById(cuisineId);
+            if (price == null) {
+                throw new RuntimeException("价目项不存在");
+            }
+            if (price.getShelfStatus() == null || price.getShelfStatus() != 1) {
+                throw new RuntimeException("价目项已下架");
+            }
+            Integer userId = TokenUtil.getCurrentUserId();
+            String userPhone = TokenUtil.getCurrentUserPhone();
+            CartItemDTO newItem = new CartItemDTO();
+            newItem.setCuisineId(price.getId());
+            newItem.setCuisineName(price.getName());
+            newItem.setCuisineType(1);
+            newItem.setCuisineImage(price.getImages());
+            newItem.setUnitPrice(price.getTotalPrice());
+            newItem.setQuantity(quantity);
+            newItem.setSubtotalAmount(price.getTotalPrice()
+                    .multiply(BigDecimal.valueOf(quantity)));
+            newItem.setAddUserId(userId);
+            newItem.setAddUserPhone(userPhone);
+            items.add(newItem);
+
+            applyRealtimeMenuPricing(cart);
+            saveCart(cart);
+            log.info("商品已自动添加到购物车, tableId={}, priceId={}, quantity={}", tableId, cuisineId, quantity);
+        }
+
+        return cart;
+    }
+
+    @Override
+    public CartDTO removeItem(Integer tableId, Integer cuisineId) {
+        log.info("删除购物车商品, tableId={}, cuisineId={}", tableId, cuisineId);
+        CartDTO cart = getCart(tableId);
+        List<CartItemDTO> items = cart.getItems();
+        
+        // 检查是否有已下单数量,如果有则不允许删除
+        CartItemDTO item = items.stream()
+                .filter(i -> i.getCuisineId().equals(cuisineId))
+                .findFirst()
+                .orElse(null);
+        
+        if (item != null) {
+            Integer lockedQuantity = item.getLockedQuantity();
+            if (lockedQuantity != null && lockedQuantity > 0) {
+                throw new RuntimeException("商品已下单,已下单数量为 " + lockedQuantity + ",不允许删除");
+            }
+        }
+        
+        items.removeIf(i -> i.getCuisineId().equals(cuisineId));
+
+        if (items.isEmpty()) {
+            cart.setTotalAmount(BigDecimal.ZERO);
+            cart.setTotalQuantity(0);
+            saveCart(cart);
+        } else {
+            applyRealtimeMenuPricing(cart);
+            saveCart(cart);
+        }
+        return cart;
+    }
+
+    @Override
+    public void clearCart(Integer tableId) {
+        log.info("清空购物车(保留已下单商品), tableId={}", tableId);
+        
+        // 获取购物车
+        CartDTO cart = getCart(tableId);
+        List<CartItemDTO> items = cart.getItems();
+        
+        if (items == null || items.isEmpty()) {
+            log.info("购物车为空,无需清空, tableId={}", tableId);
+            return;
+        }
+        
+        // 分离已下单的商品、未下单的商品和餐具
+        List<CartItemDTO> orderedItems = new ArrayList<>(); // 已下单的商品(保留,数量恢复为已下单数量)
+        List<Integer> orderedCuisineIds = new ArrayList<>(); // 已下单的商品ID列表
+        List<CartItemDTO> unorderedItems = new ArrayList<>(); // 未下单的商品(删除)
+        CartItemDTO tablewareItem = null; // 餐具项(始终保留)
+        boolean hasChanges = false; // 是否有变化(需要更新)
+        
+        for (CartItemDTO item : items) {
+            // 餐具始终保留,不清空
+            if (TABLEWARE_CUISINE_ID.equals(item.getCuisineId())) {
+                tablewareItem = item;
+                continue;
+            }
+            
+            Integer lockedQuantity = item.getLockedQuantity();
+            if (lockedQuantity != null && lockedQuantity > 0) {
+                // 有已下单数量,保留该商品,但将当前数量恢复为已下单数量
+                Integer currentQuantity = item.getQuantity();
+                if (currentQuantity != null && !currentQuantity.equals(lockedQuantity)) {
+                    // 当前数量不等于已下单数量,需要恢复
+                    item.setQuantity(lockedQuantity);
+                    item.setSubtotalAmount(item.getUnitPrice().multiply(BigDecimal.valueOf(lockedQuantity)));
+                    hasChanges = true;
+                    log.info("恢复已下单商品数量, cuisineId={}, oldQuantity={}, orderedQuantity={}", 
+                            item.getCuisineId(), currentQuantity, lockedQuantity);
+                }
+                orderedItems.add(item);
+                orderedCuisineIds.add(item.getCuisineId());
+            } else {
+                // 没有已下单数量,标记为删除
+                unorderedItems.add(item);
+                hasChanges = true;
+            }
+        }
+        
+        // 将餐具项添加到保留列表中,若有已下单数量则恢复为已下单数量(与菜品逻辑一致)
+        if (tablewareItem != null) {
+            Integer tablewareLocked = tablewareItem.getLockedQuantity();
+            if (tablewareLocked != null && tablewareLocked > 0) {
+                Integer currentQty = tablewareItem.getQuantity();
+                if (currentQty == null || !currentQty.equals(tablewareLocked)) {
+                    tablewareItem.setQuantity(tablewareLocked);
+                    tablewareItem.setSubtotalAmount(tablewareItem.getUnitPrice().multiply(BigDecimal.valueOf(tablewareLocked)));
+                    hasChanges = true;
+                    log.info("恢复餐具数量为已下单数量, cuisineId={}, oldQuantity={}, orderedQuantity={}",
+                            tablewareItem.getCuisineId(), currentQty, tablewareLocked);
+                }
+            }
+            orderedItems.add(tablewareItem);
+            orderedCuisineIds.add(tablewareItem.getCuisineId());
+            log.info("保留餐具项, cuisineId={}, quantity={}", tablewareItem.getCuisineId(), tablewareItem.getQuantity());
+        }
+        
+        // 如果有变化(有未下单的商品需要删除,或者已下单商品数量需要恢复),进行更新
+        if (hasChanges) {
+            // 1. 更新购物车(删除未下单商品,已下单商品数量已恢复)
+            cart.setItems(orderedItems);
+            if (!orderedItems.isEmpty()) {
+                applyRealtimeMenuPricing(cart);
+            } else {
+                cart.setTotalAmount(BigDecimal.ZERO);
+                cart.setTotalQuantity(0);
+            }
+            
+            // 更新Redis(保留已下单的商品,数量已恢复)
+            if (orderedItems.isEmpty()) {
+                // 如果所有商品都未下单,清空Redis
+                String cartKey = CART_KEY_PREFIX + tableId;
+                baseRedisService.delete(cartKey);
+            } else {
+                // 保存更新后的购物车到Redis(已下单商品数量已恢复为已下单数量)
+                saveCartToRedis(cart);
+            }
+            
+            // 2. 从数据库中逻辑删除未下单的商品(排除餐具)
+            if (!unorderedItems.isEmpty()) {
+                LambdaQueryWrapper<StoreCart> wrapper = new LambdaQueryWrapper<>();
+                wrapper.eq(StoreCart::getTableId, tableId);
+                wrapper.eq(StoreCart::getDeleteFlag, 0);
+                StoreCartMenuFilters.applyGenericCart(wrapper);
+                // 排除餐具(cuisineId = -1)
+                wrapper.ne(StoreCart::getCuisineId, TABLEWARE_CUISINE_ID);
+                if (!orderedCuisineIds.isEmpty()) {
+                    // 排除已下单的商品ID(包括餐具)
+                    wrapper.notIn(StoreCart::getCuisineId, orderedCuisineIds);
+                }
+                List<StoreCart> cartListToDelete = storeCartMapper.selectList(wrapper);
+                if (cartListToDelete != null && !cartListToDelete.isEmpty()) {
+                    List<Integer> cartIds = cartListToDelete.stream()
+                            .map(StoreCart::getId)
+                            .collect(Collectors.toList());
+                    // 使用 deleteBatchIds 进行逻辑删除(MyBatis-Plus 会自动处理 @TableLogic)
+                    storeCartMapper.deleteBatchIds(cartIds);
+                    log.info("删除未下单商品(已排除餐具), tableId={}, count={}", tableId, cartIds.size());
+                }
+            }
+            
+            // 3. 更新数据库中已下单商品的数量(恢复为已下单数量)
+            if (!orderedItems.isEmpty()) {
+                // 保存更新后的购物车到数据库(会更新已下单商品的数量)
+                saveCartToDatabase(cart);
+            }
+            
+            // 4. 更新桌号表的购物车统计
+            StoreTable table = storeTableMapper.selectById(tableId);
+            if (table != null) {
+                table.setCartItemCount(cart.getTotalQuantity());
+                table.setCartTotalAmount(cart.getTotalAmount());
+                storeTableMapper.updateById(table);
+            }
+            
+            log.info("清空购物车完成(保留已下单商品和餐具,数量恢复为已下单数量), tableId={}, 删除商品数={}, 保留商品数={}", 
+                    tableId, unorderedItems.size(), orderedItems.size());
+        } else {
+            log.info("购物车无需更新, tableId={}", tableId);
+        }
+    }
+
+    @Override
+    public boolean hasUsedCoupon(Integer tableId) {
+        // 先查Redis
+        String couponUsedKey = COUPON_USED_KEY_PREFIX + tableId;
+        String couponId = baseRedisService.getString(couponUsedKey);
+        if (StringUtils.hasText(couponId)) {
+            return true;
+        }
+
+        // Redis中没有,查数据库
+        LambdaQueryWrapper<StoreCouponUsage> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(StoreCouponUsage::getTableId, tableId);
+        wrapper.eq(StoreCouponUsage::getDeleteFlag, 0);
+        wrapper.in(StoreCouponUsage::getUsageStatus, 0, 1, 2); // 已标记使用、已下单、已支付
+        wrapper.orderByDesc(StoreCouponUsage::getCreatedTime);
+        wrapper.last("LIMIT 1");
+        StoreCouponUsage usage = storeCouponUsageMapper.selectOne(wrapper);
+        return usage != null;
+    }
+
+    @Override
+    public void markCouponUsed(Integer tableId, Integer couponId) {
+        // 保存到Redis
+        String couponUsedKey = COUPON_USED_KEY_PREFIX + tableId;
+        baseRedisService.setString(couponUsedKey, String.valueOf(couponId), (long) CART_EXPIRE_SECONDS);
+
+        // 保存到数据库
+        StoreTable table = storeTableMapper.selectById(tableId);
+        if (table == null) {
+            log.warn("桌号不存在, tableId={}", tableId);
+            return;
+        }
+
+        // 检查是否已存在
+        LambdaQueryWrapper<StoreCouponUsage> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(StoreCouponUsage::getTableId, tableId);
+        wrapper.eq(StoreCouponUsage::getCouponId, couponId);
+        wrapper.eq(StoreCouponUsage::getDeleteFlag, 0);
+        StoreCouponUsage existing = storeCouponUsageMapper.selectOne(wrapper);
+
+        if (existing == null) {
+            Date now = new Date();
+            StoreCouponUsage usage = new StoreCouponUsage();
+            usage.setTableId(tableId);
+            usage.setStoreId(table.getStoreId());
+            usage.setCouponId(couponId);
+            usage.setUsageStatus(0); // 已标记使用
+            usage.setCreatedTime(now);
+            usage.setUpdatedTime(now); // 设置更新时间,避免数据库约束错误
+            Integer userId = TokenUtil.getCurrentUserId();
+            if (userId != null) {
+                usage.setCreatedUserId(userId);
+            }
+            storeCouponUsageMapper.insert(usage);
+        }
+
+        // 更新桌号表的优惠券ID
+        table.setCurrentCouponId(couponId);
+        storeTableMapper.updateById(table);
+    }
+
+    @Override
+    public void clearCouponUsed(Integer tableId) {
+        // 清空Redis
+        String couponUsedKey = COUPON_USED_KEY_PREFIX + tableId;
+        baseRedisService.delete(couponUsedKey);
+
+        // 更新数据库(逻辑删除未下单的记录,使用 MyBatis-Plus 的 deleteBatchIds)
+        LambdaQueryWrapper<StoreCouponUsage> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(StoreCouponUsage::getTableId, tableId);
+        wrapper.eq(StoreCouponUsage::getDeleteFlag, 0);
+        wrapper.eq(StoreCouponUsage::getUsageStatus, 0); // 只删除已标记使用但未下单的
+        List<StoreCouponUsage> usageList = storeCouponUsageMapper.selectList(wrapper);
+        if (usageList != null && !usageList.isEmpty()) {
+            List<Integer> usageIds = usageList.stream()
+                    .map(StoreCouponUsage::getId)
+                    .collect(Collectors.toList());
+            // 使用 deleteBatchIds 进行逻辑删除(MyBatis-Plus 会自动处理 @TableLogic)
+            storeCouponUsageMapper.deleteBatchIds(usageIds);
+        }
+
+        // 更新桌号表的优惠券ID
+        StoreTable table = storeTableMapper.selectById(tableId);
+        if (table != null) {
+            table.setCurrentCouponId(null);
+            storeTableMapper.updateById(table);
+        }
+    }
+
+    /**
+     * 餐具的特殊ID(用于标识餐具项)
+     */
+    private static final Integer TABLEWARE_CUISINE_ID = -1;
+    private static final String TABLEWARE_NAME = "餐具费";
+
+    /**
+     * 获取餐具单价(从 store_info 表获取)
+     *
+     * @param storeId 门店ID
+     * @return 餐具单价(BigDecimal),如果门店不存在或未设置餐具费,返回 0.00
+     */
+    private BigDecimal getTablewareUnitPrice(Integer storeId) {
+        if (storeId == null) {
+            log.warn("门店ID为空,返回默认餐具单价 0.00");
+            return BigDecimal.ZERO;
+        }
+        StoreInfo storeInfo = storeInfoMapper.selectById(storeId);
+        if (storeInfo == null) {
+            log.warn("门店不存在, storeId={},返回默认餐具单价 0.00", storeId);
+            return BigDecimal.ZERO;
+        }
+        Integer tablewareFee = storeInfo.getTablewareFee();
+        if (tablewareFee == null || tablewareFee < 0) {
+            log.warn("门店餐具费未设置或无效, storeId={}, tablewareFee={},返回默认餐具单价 0.00", storeId, tablewareFee);
+            return BigDecimal.ZERO;
+        }
+        return BigDecimal.valueOf(tablewareFee);
+    }
+
+    @Override
+    public CartDTO setDinerCount(Integer tableId, Integer dinerCount) {
+        log.info("设置用餐人数, tableId={}, dinerCount={}", tableId, dinerCount);
+        
+        if (dinerCount == null || dinerCount <= 0) {
+            throw new RuntimeException("用餐人数必须大于0");
+        }
+
+        // 获取购物车
+        CartDTO cart = getCart(tableId);
+        List<CartItemDTO> items = cart.getItems();
+
+        // 获取门店ID和餐具单价
+        Integer storeId = cart.getStoreId();
+        if (storeId == null) {
+            // 如果购物车中没有门店ID,从桌号获取
+            StoreTable table = storeTableMapper.selectById(tableId);
+            if (table != null) {
+                storeId = table.getStoreId();
+            }
+        }
+        BigDecimal tablewareUnitPrice = getTablewareUnitPrice(storeId);
+
+        // 商铺未设置餐具费时,不往购物车加餐具;若已有餐具项则移除
+        if (tablewareUnitPrice == null || tablewareUnitPrice.compareTo(BigDecimal.ZERO) <= 0) {
+            log.info("门店未设置餐具费, storeId={},设置就餐人数时不添加餐具", storeId);
+            CartItemDTO existing = items.stream()
+                    .filter(item -> TABLEWARE_CUISINE_ID.equals(item.getCuisineId()))
+                    .findFirst()
+                    .orElse(null);
+            if (existing != null) {
+                items.remove(existing);
+            }
+        } else {
+            // 查找是否已存在餐具项
+            CartItemDTO tablewareItem = items.stream()
+                    .filter(item -> TABLEWARE_CUISINE_ID.equals(item.getCuisineId()))
+                    .findFirst()
+                    .orElse(null);
+
+            Integer userId = TokenUtil.getCurrentUserId();
+            String userPhone = TokenUtil.getCurrentUserPhone();
+
+            if (tablewareItem != null) {
+                Integer lockedQuantity = tablewareItem.getLockedQuantity();
+                if (lockedQuantity != null && lockedQuantity > 0 && dinerCount < lockedQuantity) {
+                    throw new RuntimeException("餐具数量不能少于已下单数量(" + lockedQuantity + ")");
+                }
+                tablewareItem.setQuantity(dinerCount);
+                tablewareItem.setUnitPrice(tablewareUnitPrice);
+                tablewareItem.setSubtotalAmount(tablewareUnitPrice.multiply(BigDecimal.valueOf(dinerCount)));
+            } else {
+                CartItemDTO newTablewareItem = new CartItemDTO();
+                newTablewareItem.setCuisineId(TABLEWARE_CUISINE_ID);
+                newTablewareItem.setCuisineName(TABLEWARE_NAME);
+                newTablewareItem.setCuisineType(0);
+                newTablewareItem.setCuisineImage("");
+                newTablewareItem.setUnitPrice(tablewareUnitPrice);
+                newTablewareItem.setQuantity(dinerCount);
+                newTablewareItem.setSubtotalAmount(tablewareUnitPrice.multiply(BigDecimal.valueOf(dinerCount)));
+                newTablewareItem.setAddUserId(userId);
+                newTablewareItem.setAddUserPhone(userPhone);
+                items.add(newTablewareItem);
+            }
+        }
+
+        applyRealtimeMenuPricing(cart);
+        saveCart(cart);
+
+        return cart;
+    }
+
+    @Override
+    public CartDTO updateTablewareQuantity(Integer tableId, Integer quantity) {
+        log.info("更新餐具数量, tableId={}, quantity={}", tableId, quantity);
+        
+        if (quantity == null || quantity < 0) {
+            throw new RuntimeException("餐具数量不能小于0");
+        }
+
+        if (quantity == 0) {
+            // 数量为0时,删除餐具项
+            return removeItem(tableId, TABLEWARE_CUISINE_ID);
+        }
+
+        // 获取购物车
+        CartDTO cart = getCart(tableId);
+        List<CartItemDTO> items = cart.getItems();
+
+        // 获取门店ID和餐具单价
+        Integer storeId = cart.getStoreId();
+        if (storeId == null) {
+            StoreTable table = storeTableMapper.selectById(tableId);
+            if (table != null) {
+                storeId = table.getStoreId();
+            }
+        }
+        BigDecimal tablewareUnitPrice = getTablewareUnitPrice(storeId);
+
+        // 商铺未设置餐具费(单价为0或未配置)时,不往购物车加餐具;若已有餐具项则移除
+        if (tablewareUnitPrice == null || tablewareUnitPrice.compareTo(BigDecimal.ZERO) <= 0) {
+            log.info("门店未设置餐具费, storeId={},不添加餐具到购物车", storeId);
+            CartItemDTO existing = items.stream()
+                    .filter(item -> TABLEWARE_CUISINE_ID.equals(item.getCuisineId()))
+                    .findFirst()
+                    .orElse(null);
+            if (existing != null) {
+                items.remove(existing);
+                if (items.isEmpty()) {
+                    cart.setTotalAmount(BigDecimal.ZERO);
+                    cart.setTotalQuantity(0);
+                    saveCart(cart);
+                } else {
+                    applyRealtimeMenuPricing(cart);
+                    saveCart(cart);
+                }
+            }
+            return cart;
+        }
+
+        // 查找餐具项
+        CartItemDTO tablewareItem = items.stream()
+                .filter(item -> TABLEWARE_CUISINE_ID.equals(item.getCuisineId()))
+                .findFirst()
+                .orElse(null);
+
+        if (tablewareItem == null) {
+            // 如果不存在餐具项,创建一个
+            Integer userId = TokenUtil.getCurrentUserId();
+            String userPhone = TokenUtil.getCurrentUserPhone();
+            
+            tablewareItem = new CartItemDTO();
+            tablewareItem.setCuisineId(TABLEWARE_CUISINE_ID);
+            tablewareItem.setCuisineName(TABLEWARE_NAME);
+            tablewareItem.setCuisineType(0); // 0表示餐具
+            tablewareItem.setCuisineImage("");
+            tablewareItem.setUnitPrice(tablewareUnitPrice);
+            tablewareItem.setAddUserId(userId);
+            tablewareItem.setAddUserPhone(userPhone);
+            items.add(tablewareItem);
+        } else {
+            // 下单后只能增不能减:已有已下单数量时,数量不能少于已下单数量
+            Integer lockedQuantity = tablewareItem.getLockedQuantity();
+            if (lockedQuantity != null && lockedQuantity > 0 && quantity < lockedQuantity) {
+                throw new RuntimeException("餐具数量不能少于已下单数量(" + lockedQuantity + ")");
+            }
+            // 如果已存在,更新单价(可能门店修改了餐具费)
+            tablewareItem.setUnitPrice(tablewareUnitPrice);
+        }
+
+        // 更新数量
+        tablewareItem.setQuantity(quantity);
+        tablewareItem.setSubtotalAmount(tablewareUnitPrice.multiply(BigDecimal.valueOf(quantity)));
+
+        applyRealtimeMenuPricing(cart);
+        saveCart(cart);
+
+        return cart;
+    }
+
+    @Override
+    public CartDTO lockCartItems(Integer tableId) {
+        log.info("锁定购物车商品数量(设置已下单数量), tableId={}", tableId);
+        
+        // 获取购物车
+        CartDTO cart = getCart(tableId);
+        List<CartItemDTO> items = cart.getItems();
+        
+        if (items == null || items.isEmpty()) {
+            log.warn("购物车为空,无需锁定, tableId={}", tableId);
+            return cart;
+        }
+        
+        // 遍历所有商品,将当前数量设置为已下单数量
+        boolean hasChanges = false;
+        for (CartItemDTO item : items) {
+            Integer currentQuantity = item.getQuantity();
+            Integer lockedQuantity = item.getLockedQuantity();
+            
+            if (currentQuantity != null && currentQuantity > 0) {
+                if (lockedQuantity == null || lockedQuantity == 0) {
+                    // 如果还没有已下单数量,将当前数量设置为已下单数量
+                    item.setLockedQuantity(currentQuantity);
+                    hasChanges = true;
+                    log.info("设置商品已下单数量, cuisineId={}, orderedQuantity={}", item.getCuisineId(), currentQuantity);
+                } else if (currentQuantity > lockedQuantity) {
+                    // 如果已有已下单数量,且当前数量大于已下单数量(再次下单的情况),将新增数量累加到已下单数量
+                    Integer newLockedQuantity = lockedQuantity + (currentQuantity - lockedQuantity);
+                    item.setLockedQuantity(newLockedQuantity);
+                    hasChanges = true;
+                    log.info("更新商品已下单数量, cuisineId={}, oldOrderedQuantity={}, newOrderedQuantity={}", 
+                            item.getCuisineId(), lockedQuantity, newLockedQuantity);
+                }
+            }
+        }
+        
+        // 如果有变化,保存购物车
+        if (hasChanges) {
+            saveCart(cart);
+        }
+        
+        return cart;
+    }
+
+    @Override
+    public CartDTO unlockCartItems(Integer tableId) {
+        log.info("解锁购物车商品数量(清除已下单数量), tableId={}", tableId);
+        
+        // 获取购物车
+        CartDTO cart = getCart(tableId);
+        List<CartItemDTO> items = cart.getItems();
+        
+        if (items == null || items.isEmpty()) {
+            log.info("购物车为空,无需解锁, tableId={}", tableId);
+            return cart;
+        }
+        
+        // 遍历所有商品,清除已下单数量(lockedQuantity)
+        boolean hasChanges = false;
+        for (CartItemDTO item : items) {
+            if (item.getLockedQuantity() != null && item.getLockedQuantity() > 0) {
+                // 清除已下单数量,允许重新下单
+                item.setLockedQuantity(null);
+                hasChanges = true;
+                log.info("清除商品已下单数量, cuisineId={}", item.getCuisineId());
+            }
+        }
+        
+        // 如果有变化,保存购物车
+        if (hasChanges) {
+            saveCart(cart);
+            log.info("解锁购物车商品数量完成, tableId={}", tableId);
+        } else {
+            log.info("购物车无需解锁, tableId={}", tableId);
+        }
+        
+        return cart;
+    }
+
+    /**
+     * 保存购物车到Redis和数据库(优化后的双写策略)
+     * Redis同步写入(保证实时性),数据库异步批量写入(提高性能)
+     */
+    private void saveCart(CartDTO cart) {
+        // 1. 同步保存到Redis(保证实时性)
+        saveCartToRedis(cart);
+
+        // 2. 异步保存到数据库(不阻塞主流程,提高性能)
+        CompletableFuture.runAsync(() -> {
+            try {
+                saveCartToDatabase(cart);
+            } catch (Exception e) {
+                log.error("异步保存购物车到数据库失败, tableId={}, error={}", cart.getTableId(), e.getMessage(), e);
+            }
+        }, CART_DB_WRITE_EXECUTOR);
+    }
+
+    /**
+     * 保存购物车到Redis
+     */
+    private void saveCartToRedis(CartDTO cart) {
+        String cartKey = CART_KEY_PREFIX + cart.getTableId();
+        String cartJson = JSON.toJSONString(cart);
+        baseRedisService.setString(cartKey, cartJson, (long) CART_EXPIRE_SECONDS);
+    }
+
+    /**
+     * 保存购物车到数据库(优化后的批量操作版本)
+     * 使用批量逻辑删除和批量插入,提高性能
+     */
+    private void saveCartToDatabase(CartDTO cart) {
+        try {
+            Date now = new Date();
+            Integer userId = TokenUtil.getCurrentUserId();
+
+            // 1. 批量逻辑删除该桌号的所有购物车记录(使用 MyBatis-Plus 的 deleteBatchIds)
+            LambdaQueryWrapper<StoreCart> queryWrapper = new LambdaQueryWrapper<>();
+            queryWrapper.eq(StoreCart::getTableId, cart.getTableId())
+                    .eq(StoreCart::getDeleteFlag, 0);
+            StoreCartMenuFilters.applyGenericCart(queryWrapper);
+            List<StoreCart> existingCartList = storeCartMapper.selectList(queryWrapper);
+            if (existingCartList != null && !existingCartList.isEmpty()) {
+                List<Integer> cartIds = existingCartList.stream()
+                        .map(StoreCart::getId)
+                        .collect(Collectors.toList());
+                // 使用 deleteBatchIds 进行逻辑删除(MyBatis-Plus 会自动处理 @TableLogic)
+                storeCartMapper.deleteBatchIds(cartIds);
+            }
+
+            // 2. 批量插入新的购物车记录
+            if (cart.getItems() != null && !cart.getItems().isEmpty()) {
+                List<StoreCart> cartList = new ArrayList<>(cart.getItems().size());
+                for (CartItemDTO item : cart.getItems()) {
+                    StoreCart storeCart = new StoreCart();
+                    storeCart.setTableId(cart.getTableId());
+                    storeCart.setStoreId(cart.getStoreId());
+                    storeCart.setCuisineId(item.getCuisineId());
+                    storeCart.setCuisineName(item.getCuisineName());
+                    storeCart.setCuisineImage(item.getCuisineImage());
+                    storeCart.setUnitPrice(item.getUnitPrice());
+                    storeCart.setQuantity(item.getQuantity());
+                    storeCart.setLockedQuantity(item.getLockedQuantity());
+                    storeCart.setSubtotalAmount(item.getSubtotalAmount());
+                    storeCart.setAddUserId(item.getAddUserId());
+                    storeCart.setAddUserPhone(item.getAddUserPhone());
+                    storeCart.setRemark(item.getRemark());
+                    storeCart.setDeleteFlag(0);
+                    storeCart.setMenuType(OrderMenuConstants.MENU_TYPE_GENERIC_PRICE);
+                    storeCart.setCreatedTime(now);
+                    storeCart.setCreatedUserId(userId);
+                    storeCart.setUpdatedTime(now);
+                    cartList.add(storeCart);
+                }
+
+                // 批量插入(如果数量较少,直接循环插入;如果数量较多,可以考虑分批插入)
+                if (cartList.size() <= 50) {
+                    // 小批量直接插入
+                    for (StoreCart storeCart : cartList) {
+                        storeCartMapper.insert(storeCart);
+                    }
+                } else {
+                    // 大批量分批插入(每批50条)
+                    int batchSize = 50;
+                    for (int i = 0; i < cartList.size(); i += batchSize) {
+                        int end = Math.min(i + batchSize, cartList.size());
+                        List<StoreCart> batch = cartList.subList(i, end);
+                        for (StoreCart storeCart : batch) {
+                            storeCartMapper.insert(storeCart);
+                        }
+                    }
+                }
+            }
+
+            // 3. 更新桌号表的购物车统计
+            StoreTable table = storeTableMapper.selectById(cart.getTableId());
+            if (table != null) {
+                table.setCartItemCount(cart.getTotalQuantity());
+                table.setCartTotalAmount(cart.getTotalAmount());
+                storeTableMapper.updateById(table);
+            }
+
+            log.debug("购物车数据已异步保存到数据库, tableId={}, itemCount={}", 
+                    cart.getTableId(), cart.getItems() != null ? cart.getItems().size() : 0);
+        } catch (Exception e) {
+            log.error("保存购物车到数据库失败, tableId={}, error={}", cart.getTableId(), e.getMessage(), e);
+            // 数据库保存失败不影响Redis,继续执行
+        }
+    }
+}

+ 416 - 0
alien-dining/src/main/java/shop/alien/dining/service/impl/GenericDiningServiceImpl.java

@@ -0,0 +1,416 @@
+package shop.alien.dining.service.impl;
+
+import com.alibaba.fastjson2.JSON;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+import shop.alien.dining.constants.OrderMenuConstants;
+import shop.alien.dining.service.DiningService;
+import shop.alien.dining.service.GenericCartService;
+import shop.alien.dining.service.GenericDiningService;
+import shop.alien.dining.service.OrderLockService;
+import shop.alien.entity.store.LifeDiscountCoupon;
+import shop.alien.entity.store.StoreInfo;
+import shop.alien.entity.store.StoreOrderDetail;
+import shop.alien.entity.store.StorePrice;
+import shop.alien.entity.store.StoreTable;
+import shop.alien.entity.store.dto.CartDTO;
+import shop.alien.entity.store.dto.CartItemDTO;
+import shop.alien.entity.store.vo.AvailableCouponVO;
+import shop.alien.entity.store.vo.CuisineDetailVO;
+import shop.alien.entity.store.vo.CuisineListVO;
+import shop.alien.entity.store.vo.DiningPageInfoVO;
+import shop.alien.entity.store.vo.OrderConfirmVO;
+import shop.alien.mapper.LifeDiscountCouponMapper;
+import shop.alien.mapper.StoreInfoMapper;
+import shop.alien.mapper.StoreOrderDetailMapper;
+import shop.alien.mapper.StorePriceMapper;
+import shop.alien.mapper.StoreTableMapper;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class GenericDiningServiceImpl implements GenericDiningService {
+
+    private final StoreTableMapper storeTableMapper;
+    private final StoreInfoMapper storeInfoMapper;
+    private final StorePriceMapper storePriceMapper;
+    private final GenericCartService genericCartService;
+    private final OrderLockService orderLockService;
+    private final LifeDiscountCouponMapper lifeDiscountCouponMapper;
+    private final StoreOrderDetailMapper storeOrderDetailMapper;
+    private final DiningService diningService;
+
+    private void assertGenericTable(StoreTable table) {
+        if (table == null) {
+            throw new RuntimeException("桌号不存在");
+        }
+        if (table.getType() == null || !Integer.valueOf(2).equals(table.getType())) {
+            throw new RuntimeException("当前桌台不是通用价目桌,请使用美食点餐入口");
+        }
+    }
+
+    @Override
+    public DiningPageInfoVO getDiningPageInfo(Integer tableId, Integer dinerCount) {
+        log.info("通用价目-获取点餐页面信息, tableId={}, dinerCount={}", tableId, dinerCount);
+        StoreTable table = storeTableMapper.selectById(tableId);
+        assertGenericTable(table);
+
+        StoreInfo storeInfo = storeInfoMapper.selectById(table.getStoreId());
+        if (storeInfo == null) {
+            throw new RuntimeException("门店不存在");
+        }
+
+        if (dinerCount != null && dinerCount > 0) {
+            table.setStatus(1);
+            table.setDinerCount(dinerCount);
+            storeTableMapper.updateById(table);
+            log.info("通用价目-首客选桌并填写用餐人数, tableId={}, dinerCount={}", tableId, dinerCount);
+        } else {
+            if (Integer.valueOf(1).equals(table.getStatus()) && table.getDinerCount() != null && table.getDinerCount() > 0) {
+                dinerCount = table.getDinerCount();
+                log.info("通用价目-餐桌已就餐中,使用已保存人数, tableId={}, dinerCount={}", tableId, dinerCount);
+            } else {
+                throw new RuntimeException("请选择用餐人数");
+            }
+        }
+
+        DiningPageInfoVO vo = new DiningPageInfoVO();
+        vo.setStoreName(storeInfo.getStoreName());
+        vo.setTableNumber(table.getTableNumber());
+        vo.setDinerCount(dinerCount);
+        vo.setStoreId(table.getStoreId());
+        vo.setTableId(tableId);
+        return vo;
+    }
+
+    private LambdaQueryWrapper<StorePrice> baseOnShelfApproved(Integer storeId) {
+        LambdaQueryWrapper<StorePrice> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(StorePrice::getStoreId, storeId);
+        wrapper.eq(StorePrice::getShelfStatus, 1);
+        wrapper.eq(StorePrice::getStatus, 1);
+        wrapper.orderByDesc(StorePrice::getCreatedTime);
+        return wrapper;
+    }
+
+    @Override
+    public List<CuisineListVO> searchCuisines(Integer storeId, String keyword, Integer tableId) {
+        log.info("通用价目-搜索, storeId={}, keyword={}, tableId={}", storeId, keyword, tableId);
+        StoreTable table = storeTableMapper.selectById(tableId);
+        assertGenericTable(table);
+        if (!Objects.equals(table.getStoreId(), storeId)) {
+            throw new RuntimeException("门店与桌台不匹配");
+        }
+
+        if (StringUtils.hasText(keyword) && keyword.length() > 10) {
+            keyword = keyword.substring(0, 10);
+        }
+
+        LambdaQueryWrapper<StorePrice> wrapper = baseOnShelfApproved(storeId);
+        if (StringUtils.hasText(keyword)) {
+            wrapper.like(StorePrice::getName, keyword);
+        }
+        List<StorePrice> list = storePriceMapper.selectList(wrapper);
+        return toCuisineListVO(list, tableId);
+    }
+
+    @Override
+    public List<CuisineListVO> getCuisinesByCategory(Integer storeId, Integer categoryId, Integer tableId, Integer page, Integer size) {
+        log.info("通用价目-分类列表, storeId={}, categoryId={}, tableId={}, page={}, size={}", storeId, categoryId, tableId, page, size);
+        StoreTable table = storeTableMapper.selectById(tableId);
+        assertGenericTable(table);
+        if (!Objects.equals(table.getStoreId(), storeId)) {
+            throw new RuntimeException("门店与桌台不匹配");
+        }
+
+        if (page == null || page < 1) {
+            page = 1;
+        }
+        if (size == null || size < 1) {
+            size = 12;
+        }
+        int offset = (page - 1) * size;
+
+        LambdaQueryWrapper<StorePrice> wrapper = baseOnShelfApproved(storeId);
+        if (categoryId != null) {
+            wrapper.apply("JSON_CONTAINS(CAST(category_ids AS JSON), CAST({0} AS JSON))", categoryId);
+        }
+        wrapper.last("LIMIT " + offset + ", " + size);
+        List<StorePrice> list = storePriceMapper.selectList(wrapper);
+        return toCuisineListVO(list, tableId);
+    }
+
+    @Override
+    public CuisineDetailVO getCuisineDetail(Integer priceItemId, Integer tableId) {
+        log.info("通用价目-详情, priceItemId={}, tableId={}", priceItemId, tableId);
+        StoreTable table = storeTableMapper.selectById(tableId);
+        assertGenericTable(table);
+
+        StorePrice price = storePriceMapper.selectById(priceItemId);
+        if (price == null || !Objects.equals(price.getStoreId(), table.getStoreId())) {
+            throw new RuntimeException("价目不存在");
+        }
+
+        CuisineDetailVO vo = new CuisineDetailVO();
+        vo.setId(price.getId());
+        vo.setStoreId(price.getStoreId());
+        vo.setName(price.getName());
+        vo.setTotalPrice(price.getTotalPrice());
+        vo.setCuisineType(1);
+        vo.setCategoryIds(price.getCategoryIds());
+        vo.setTags(formatTagsForDetail(price.getTags()));
+        vo.setDishReview(price.getDishReview());
+        vo.setDescription(price.getDescription());
+        vo.setDetailContent(price.getDetailContent());
+        vo.setImageContent(price.getImageContent());
+        vo.setExtraNote(price.getExtraNote());
+        vo.setNeedReserve(price.getNeedReserve());
+        vo.setReserveRule(price.getReserveRule());
+        vo.setPeopleLimit(price.getPeopleLimit());
+        vo.setUsageRule(price.getUsageRule());
+        vo.setComboItems(null);
+
+        if (StringUtils.hasText(price.getImages())) {
+            vo.setImages(java.util.Arrays.asList(price.getImages().split(",")));
+        } else {
+            vo.setImages(new ArrayList<>());
+        }
+
+        vo.setMonthlySales(monthlySalesForGenericPrice(priceItemId));
+
+        CartDTO cart = genericCartService.getCart(tableId);
+        if (cart.getItems() != null) {
+            Integer qty = cart.getItems().stream()
+                    .filter(item -> priceItemId.equals(item.getCuisineId()))
+                    .map(CartItemDTO::getQuantity)
+                    .filter(Objects::nonNull)
+                    .findFirst()
+                    .orElse(0);
+            vo.setCartQuantity(qty);
+        } else {
+            vo.setCartQuantity(0);
+        }
+        return vo;
+    }
+
+    @Override
+    public OrderConfirmVO getOrderConfirmInfo(Integer tableId, Integer dinerCount, Integer userId) {
+        log.info("通用价目-确认页, tableId={}, dinerCount={}, userId={}", tableId, dinerCount, userId);
+
+        DiningPageInfoVO pageInfo = getDiningPageInfo(tableId, dinerCount);
+        int effectiveDinerCount = pageInfo.getDinerCount() != null && pageInfo.getDinerCount() > 0
+                ? pageInfo.getDinerCount() : (dinerCount != null ? dinerCount : 0);
+
+        CartDTO cart = genericCartService.getCart(tableId);
+        List<CartItemDTO> items = cart.getItems();
+        if (items != null && !items.isEmpty()) {
+            Set<Integer> priceIds = items.stream()
+                    .map(CartItemDTO::getCuisineId)
+                    .filter(Objects::nonNull)
+                    .collect(Collectors.toSet());
+            if (!priceIds.isEmpty()) {
+                List<StorePrice> prices = storePriceMapper.selectBatchIds(new ArrayList<>(priceIds));
+                Map<Integer, String> tagsMap = new HashMap<>();
+                if (prices != null) {
+                    for (StorePrice p : prices) {
+                        if (p.getTags() != null) {
+                            tagsMap.put(p.getId(), formatTagsForDetail(p.getTags()));
+                        }
+                    }
+                }
+                for (CartItemDTO item : items) {
+                    if (item.getCuisineId() != null && item.getTags() == null) {
+                        item.setTags(tagsMap.get(item.getCuisineId()));
+                    }
+                }
+            }
+        }
+
+        Integer lockUserId = orderLockService.checkOrderLock(tableId);
+        boolean isLocked = lockUserId != null && !lockUserId.equals(userId);
+
+        OrderConfirmVO vo = new OrderConfirmVO();
+        vo.setStoreName(pageInfo.getStoreName());
+        vo.setTableNumber(pageInfo.getTableNumber());
+        vo.setDinerCount(pageInfo.getDinerCount() != null ? pageInfo.getDinerCount() : dinerCount);
+        vo.setItems(items != null ? items : cart.getItems());
+        vo.setTotalAmount(cart.getTotalAmount());
+        vo.setIsLocked(isLocked);
+        vo.setLockUserId(lockUserId);
+
+        BigDecimal tablewareUnitPrice = new BigDecimal("1.00");
+        BigDecimal tablewareFee = BigDecimal.ZERO;
+        if (effectiveDinerCount > 0) {
+            tablewareFee = tablewareUnitPrice.multiply(BigDecimal.valueOf(effectiveDinerCount));
+        }
+        vo.setTablewareFee(tablewareFee);
+        vo.setTablewareUnitPrice(tablewareUnitPrice);
+        vo.setServiceFee(BigDecimal.ZERO);
+
+        BigDecimal totalWithTableware = cart.getTotalAmount().add(tablewareFee);
+
+        List<AvailableCouponVO> availableCoupons = new ArrayList<>();
+        if (userId != null && cart.getTotalAmount().compareTo(BigDecimal.ZERO) > 0) {
+            availableCoupons = diningService.getAvailableCoupons(pageInfo.getStoreId(), userId);
+            List<Integer> couponIds = availableCoupons.stream()
+                    .filter(c -> Boolean.TRUE.equals(c.getIsReceived()) && Boolean.TRUE.equals(c.getIsAvailable()))
+                    .filter(c -> totalWithTableware.compareTo(c.getMinimumSpendingAmount() != null ? c.getMinimumSpendingAmount() : BigDecimal.ZERO) >= 0)
+                    .map(AvailableCouponVO::getId)
+                    .collect(Collectors.toList());
+
+            Map<Integer, LifeDiscountCoupon> couponMap = new HashMap<>();
+            if (!couponIds.isEmpty()) {
+                List<LifeDiscountCoupon> couponDetails = lifeDiscountCouponMapper.selectBatchIds(couponIds);
+                couponMap = couponDetails.stream().collect(Collectors.toMap(LifeDiscountCoupon::getId, c -> c));
+            }
+
+            final BigDecimal finalTotal = totalWithTableware;
+            final Map<Integer, LifeDiscountCoupon> finalCouponMap = couponMap;
+            List<AvailableCouponVO> usableCoupons = availableCoupons.stream()
+                    .filter(c -> Boolean.TRUE.equals(c.getIsReceived()) && Boolean.TRUE.equals(c.getIsAvailable()))
+                    .filter(c -> totalWithTableware.compareTo(c.getMinimumSpendingAmount() != null ? c.getMinimumSpendingAmount() : BigDecimal.ZERO) >= 0)
+                    .sorted((a, b) -> {
+                        BigDecimal discountA = calculateDiscountAmountForVO(a, finalCouponMap, finalTotal);
+                        BigDecimal discountB = calculateDiscountAmountForVO(b, finalCouponMap, finalTotal);
+                        return discountB.compareTo(discountA);
+                    })
+                    .collect(Collectors.toList());
+
+            if (!usableCoupons.isEmpty()) {
+                AvailableCouponVO bestCoupon = usableCoupons.get(0);
+                vo.setCouponId(bestCoupon.getId());
+                vo.setCouponName(bestCoupon.getName());
+                LifeDiscountCoupon couponDetail = lifeDiscountCouponMapper.selectById(bestCoupon.getId());
+                BigDecimal discountAmount = calculateDiscountAmount(couponDetail, totalWithTableware);
+                vo.setDiscountAmount(discountAmount);
+                vo.setPayAmount(totalWithTableware.subtract(discountAmount));
+            } else {
+                vo.setPayAmount(totalWithTableware);
+            }
+        } else {
+            vo.setPayAmount(totalWithTableware);
+        }
+        vo.setAvailableCoupons(availableCoupons);
+        return vo;
+    }
+
+    private List<CuisineListVO> toCuisineListVO(List<StorePrice> prices, Integer tableId) {
+        CartDTO cart = genericCartService.getCart(tableId);
+        Map<Integer, Integer> cartQty = new HashMap<>();
+        if (cart.getItems() != null) {
+            cartQty = cart.getItems().stream()
+                    .collect(Collectors.toMap(CartItemDTO::getCuisineId, CartItemDTO::getQuantity, (a, b) -> b));
+        }
+        final Map<Integer, Integer> finalCartQty = cartQty;
+        return prices.stream().map(p -> {
+            CuisineListVO vo = new CuisineListVO();
+            vo.setId(p.getId());
+            vo.setName(p.getName());
+            vo.setPrice(p.getTotalPrice());
+            vo.setShortComment(p.getDishReview());
+            vo.setTags(formatTagsForDetail(p.getTags()));
+            vo.setMonthlySales(monthlySalesForGenericPrice(p.getId()));
+            vo.setCartQuantity(finalCartQty.getOrDefault(p.getId(), 0));
+            vo.setCuisineType(1);
+            vo.setShelfStatus(p.getShelfStatus());
+            if (StringUtils.hasText(p.getImages())) {
+                String[] images = p.getImages().split(",");
+                vo.setFirstImage(images.length > 0 ? images[0] : null);
+            }
+            return vo;
+        }).collect(Collectors.toList());
+    }
+
+    private int monthlySalesForGenericPrice(Integer priceId) {
+        try {
+            Calendar calendar = Calendar.getInstance();
+            calendar.set(Calendar.DAY_OF_MONTH, 1);
+            calendar.set(Calendar.HOUR_OF_DAY, 0);
+            calendar.set(Calendar.MINUTE, 0);
+            calendar.set(Calendar.SECOND, 0);
+            calendar.set(Calendar.MILLISECOND, 0);
+            Date monthStart = calendar.getTime();
+
+            LambdaQueryWrapper<StoreOrderDetail> wrapper = new LambdaQueryWrapper<>();
+            wrapper.eq(StoreOrderDetail::getCuisineId, priceId);
+            wrapper.eq(StoreOrderDetail::getLineType, OrderMenuConstants.LINE_TYPE_GENERIC_PRICE);
+            wrapper.eq(StoreOrderDetail::getDeleteFlag, 0);
+            wrapper.ge(StoreOrderDetail::getCreatedTime, monthStart);
+            wrapper.inSql(StoreOrderDetail::getOrderId,
+                    "SELECT id FROM store_order WHERE pay_status = 1 AND delete_flag = 0");
+
+            List<StoreOrderDetail> details = storeOrderDetailMapper.selectList(wrapper);
+            return details.stream().mapToInt(StoreOrderDetail::getQuantity).sum();
+        } catch (Exception e) {
+            log.warn("通用价目-月售统计失败 priceId={}", priceId, e);
+            return 0;
+        }
+    }
+
+    private static String formatTagsForDetail(String raw) {
+        if (!StringUtils.hasText(raw)) {
+            return null;
+        }
+        String t = raw.trim();
+        if (t.startsWith("[")) {
+            try {
+                List<String> arr = JSON.parseArray(t, String.class);
+                if (arr == null || arr.isEmpty()) {
+                    return null;
+                }
+                return String.join(",", arr);
+            } catch (Exception ignored) {
+                return raw;
+            }
+        }
+        return raw;
+    }
+
+    private BigDecimal calculateDiscountAmount(LifeDiscountCoupon coupon, BigDecimal totalBeforeDiscount) {
+        if (coupon == null || totalBeforeDiscount == null || totalBeforeDiscount.compareTo(BigDecimal.ZERO) <= 0) {
+            return BigDecimal.ZERO;
+        }
+        Integer couponType = coupon.getCouponType();
+        BigDecimal discountAmount = BigDecimal.ZERO;
+        if (couponType != null && couponType == 2) {
+            BigDecimal discountRate = coupon.getDiscountRate();
+            if (discountRate != null && discountRate.compareTo(BigDecimal.ZERO) > 0 && discountRate.compareTo(new BigDecimal(100)) <= 0) {
+                BigDecimal discountedAmount = totalBeforeDiscount.multiply(discountRate).divide(new BigDecimal(100), 2, BigDecimal.ROUND_HALF_UP);
+                discountAmount = totalBeforeDiscount.subtract(discountedAmount);
+            }
+        } else {
+            discountAmount = coupon.getNominalValue();
+            if (discountAmount == null) {
+                discountAmount = BigDecimal.ZERO;
+            }
+            if (discountAmount.compareTo(totalBeforeDiscount) > 0) {
+                discountAmount = totalBeforeDiscount;
+            }
+        }
+        return discountAmount;
+    }
+
+    private BigDecimal calculateDiscountAmountForVO(AvailableCouponVO vo, Map<Integer, LifeDiscountCoupon> couponMap, BigDecimal totalAmount) {
+        if (vo == null || totalAmount == null || totalAmount.compareTo(BigDecimal.ZERO) <= 0) {
+            return BigDecimal.ZERO;
+        }
+        LifeDiscountCoupon coupon = couponMap.get(vo.getId());
+        if (coupon == null) {
+            return vo.getNominalValue() != null ? vo.getNominalValue() : BigDecimal.ZERO;
+        }
+        return calculateDiscountAmount(coupon, totalAmount);
+    }
+}

+ 107 - 31
alien-dining/src/main/java/shop/alien/dining/service/impl/StoreOrderServiceImpl.java

@@ -15,6 +15,9 @@ import shop.alien.dining.service.CartService;
 import shop.alien.dining.service.StoreOrderService;
 import shop.alien.dining.support.DiningMenuPricing;
 import shop.alien.dining.util.TokenUtil;
+import shop.alien.dining.constants.OrderMenuConstants;
+import shop.alien.dining.service.GenericCartService;
+import shop.alien.dining.support.StoreCartMenuFilters;
 import shop.alien.util.common.constant.DiscountCouponEnum;
 import shop.alien.entity.store.*;
 import shop.alien.entity.store.dto.CartDTO;
@@ -73,10 +76,21 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
     private final UserReservationTableMapper userReservationTableMapper;
     private final StoreProductDiscountRuleMapper storeProductDiscountRuleMapper;
     private final LifeDiscountCouponUserMapper lifeDiscountCouponUserMapper;
+    private final GenericCartService genericCartService;
+    private final StorePriceMapper storePriceMapper;
 
     @Override
     public StoreOrder createOrder(CreateOrderDTO dto) {
-        log.info("创建订单, dto={}", dto);
+        return createOrderWithMenuType(dto, OrderMenuConstants.MENU_TYPE_CUISINE);
+    }
+
+    @Override
+    public StoreOrder createGenericOrder(CreateOrderDTO dto) {
+        return createOrderWithMenuType(dto, OrderMenuConstants.MENU_TYPE_GENERIC_PRICE);
+    }
+
+    private StoreOrder createOrderWithMenuType(CreateOrderDTO dto, int menuType) {
+        log.info("创建订单, dto={}, menuType={}", dto, menuType);
 
         // 获取当前用户信息
         Object[] userInfo = getCurrentUserInfo();
@@ -101,8 +115,20 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
             throw new RuntimeException("门店不存在");
         }
 
+        if (menuType == OrderMenuConstants.MENU_TYPE_GENERIC_PRICE) {
+            if (table.getType() == null || !Integer.valueOf(2).equals(table.getType())) {
+                throw new RuntimeException("当前桌台不是通用价目桌,请使用美食点餐入口");
+            }
+        } else {
+            if (table.getType() != null && Integer.valueOf(2).equals(table.getType())) {
+                throw new RuntimeException("当前桌台为通用价目桌,请使用通用点餐接口下单");
+            }
+        }
+
         // 获取购物车
-        CartDTO cart = cartService.getCart(dto.getTableId());
+        CartDTO cart = menuType == OrderMenuConstants.MENU_TYPE_GENERIC_PRICE
+                ? genericCartService.getCart(dto.getTableId())
+                : cartService.getCart(dto.getTableId());
         if (cart.getItems() == null || cart.getItems().isEmpty()) {
             throw new RuntimeException("购物车为空");
         }
@@ -143,7 +169,10 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
         
         if (dto.getCouponId() != null) {
             // 检查桌号是否已使用优惠券,如果已使用则替换为新优惠券
-            if (cartService.hasUsedCoupon(dto.getTableId())) {
+            boolean couponUsed = menuType == OrderMenuConstants.MENU_TYPE_GENERIC_PRICE
+                    ? genericCartService.hasUsedCoupon(dto.getTableId())
+                    : cartService.hasUsedCoupon(dto.getTableId());
+            if (couponUsed) {
                 // 获取旧的优惠券使用记录
                 LambdaQueryWrapper<StoreCouponUsage> oldUsageWrapper = new LambdaQueryWrapper<>();
                 oldUsageWrapper.eq(StoreCouponUsage::getTableId, dto.getTableId());
@@ -170,7 +199,11 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
                 }
                 
                 // 清除旧的优惠券使用标记
-                cartService.clearCouponUsed(dto.getTableId());
+                if (menuType == OrderMenuConstants.MENU_TYPE_GENERIC_PRICE) {
+                    genericCartService.clearCouponUsed(dto.getTableId());
+                } else {
+                    cartService.clearCouponUsed(dto.getTableId());
+                }
                 log.info("替换优惠券:清除旧优惠券标记, tableId={}, newCouponId={}", dto.getTableId(), dto.getCouponId());
             }
 
@@ -191,7 +224,11 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
             assertCouponOwnedAndUsable(userId, coupon);
 
             // 标记桌号已使用新优惠券
-            cartService.markCouponUsed(dto.getTableId(), dto.getCouponId());
+            if (menuType == OrderMenuConstants.MENU_TYPE_GENERIC_PRICE) {
+                genericCartService.markCouponUsed(dto.getTableId(), dto.getCouponId());
+            } else {
+                cartService.markCouponUsed(dto.getTableId(), dto.getCouponId());
+            }
         } else {
             // 直接使用前端传入的值,如果为null则设为0
             if (discountAmount == null) {
@@ -208,12 +245,23 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
                 .filter(Objects::nonNull)
                 .filter(id -> id > 0)
                 .collect(Collectors.toSet());
-        Map<Integer, BigDecimal> listUnitByCuisine = cartCuisineIds.isEmpty()
-                ? Collections.emptyMap()
-                : DiningMenuPricing.resolveListUnitPriceByCuisineId(cartCuisineIds, storeCuisineMapper);
-        Map<Integer, BigDecimal> saleUnitByCuisine = cartCuisineIds.isEmpty()
-                ? Collections.emptyMap()
-                : DiningMenuPricing.resolveSaleUnitPrice(table.getStoreId(), listUnitByCuisine, storeProductDiscountRuleMapper);
+        final Map<Integer, BigDecimal> listUnitByProduct;
+        final Map<Integer, BigDecimal> saleUnitByProduct;
+        if (menuType == OrderMenuConstants.MENU_TYPE_GENERIC_PRICE) {
+            listUnitByProduct = cartCuisineIds.isEmpty()
+                    ? Collections.emptyMap()
+                    : DiningMenuPricing.resolveListUnitPriceByGenericPriceId(cartCuisineIds, storePriceMapper);
+            saleUnitByProduct = cartCuisineIds.isEmpty()
+                    ? Collections.emptyMap()
+                    : DiningMenuPricing.resolveSaleUnitPriceForGenericPrice(table.getStoreId(), listUnitByProduct, storeProductDiscountRuleMapper);
+        } else {
+            listUnitByProduct = cartCuisineIds.isEmpty()
+                    ? Collections.emptyMap()
+                    : DiningMenuPricing.resolveListUnitPriceByCuisineId(cartCuisineIds, storeCuisineMapper);
+            saleUnitByProduct = cartCuisineIds.isEmpty()
+                    ? Collections.emptyMap()
+                    : DiningMenuPricing.resolveSaleUnitPrice(table.getStoreId(), listUnitByProduct, storeProductDiscountRuleMapper);
+        }
 
         StoreOrder order = null;
         String orderNo = null;
@@ -227,6 +275,9 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
             StoreOrder existingOrder = this.getById(table.getCurrentOrderId());
             if (existingOrder != null && existingOrder.getOrderStatus() == 0 && existingOrder.getPayStatus() == 0) {
                 // 订单存在且是待支付状态,更新订单
+                if (existingOrder.getMenuType() != null && !existingOrder.getMenuType().equals(menuType)) {
+                    throw new RuntimeException("订单菜单类型与当前下单入口不一致");
+                }
                 isUpdate = true;
                 order = existingOrder;
                 previousCouponIdBeforeUpdate = order.getCouponId();
@@ -315,6 +366,7 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
             order.setPayStatus(0); // 未支付
             order.setRemark(dto.getRemark());
             order.setUserReservationId(userReservationId);
+            order.setMenuType(menuType);
             order.setCreatedUserId(userId);
             order.setUpdatedUserId(userId);
             // 手动设置创建时间和更新时间(临时方案,避免自动填充未生效导致 created_time 为 null)
@@ -379,6 +431,9 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
                     StoreOrderDetail detail = new StoreOrderDetail();
                     detail.setOrderId(finalOrder.getId());
                     detail.setOrderNo(finalOrderNo);
+                    detail.setLineType(menuType == OrderMenuConstants.MENU_TYPE_GENERIC_PRICE
+                            ? OrderMenuConstants.LINE_TYPE_GENERIC_PRICE
+                            : OrderMenuConstants.LINE_TYPE_CUISINE);
                     detail.setCuisineId(item.getCuisineId());
                     detail.setCuisineName(item.getCuisineName());
                     
@@ -388,6 +443,8 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
                         // 如果是餐具,设置为0;否则设置为1(默认单品)
                         if (TABLEWARE_CUISINE_ID.equals(item.getCuisineId())) {
                             cuisineType = 0; // 0表示餐具
+                        } else if (menuType == OrderMenuConstants.MENU_TYPE_GENERIC_PRICE) {
+                            cuisineType = 1;
                         } else {
                             // 尝试从菜品信息中获取,如果获取不到则默认为1(单品)
                             try {
@@ -414,10 +471,10 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
                         detail.setSubtotalAmount(p.multiply(BigDecimal.valueOf(lineQty)).setScale(2, RoundingMode.HALF_UP));
                     } else {
                         Integer cid = item.getCuisineId();
-                        java.math.BigDecimal listU = listUnitByCuisine.getOrDefault(cid,
+                        java.math.BigDecimal listU = listUnitByProduct.getOrDefault(cid,
                                 item.getOriginalUnitPrice() != null ? item.getOriginalUnitPrice()
                                         : (item.getUnitPrice() != null ? item.getUnitPrice() : BigDecimal.ZERO));
-                        java.math.BigDecimal saleU = saleUnitByCuisine.getOrDefault(cid,
+                        java.math.BigDecimal saleU = saleUnitByProduct.getOrDefault(cid,
                                 item.getCurrentUnitPrice() != null ? item.getCurrentUnitPrice()
                                         : (item.getUnitPrice() != null ? item.getUnitPrice() : listU));
                         detail.setListUnitPrice(listU);
@@ -482,7 +539,11 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
         }
 
         // 锁定购物车商品数量(禁止减少或删除已下单的商品)
-        cartService.lockCartItems(dto.getTableId());
+        if (menuType == OrderMenuConstants.MENU_TYPE_GENERIC_PRICE) {
+            genericCartService.lockCartItems(dto.getTableId());
+        } else {
+            cartService.lockCartItems(dto.getTableId());
+        }
 
         // 下单后不清空购物车,允许加餐(加餐时添加到同一订单)
         // 只有在支付完成后才清空购物车
@@ -557,7 +618,7 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
 
         // 支付完成后,清空购物车和重置餐桌状态(保留订单数据,不删除订单)
         // resetTableAfterPayment 方法会清空购物车和重置餐桌状态,但不会删除订单数据
-        resetTableAfterPayment(order.getTableId());
+        resetTableAfterPayment(order.getTableId(), order.getMenuType());
 
         // 支付订单成功后,自动解锁结算锁定
         try {
@@ -591,20 +652,25 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
         this.updateById(order);
 
         // 恢复购物车的已下单数量(允许重新下单)
-        cartService.unlockCartItems(order.getTableId());
-
-        // 清除优惠券使用标记
-        cartService.clearCouponUsed(order.getTableId());
+        Integer tableId = order.getTableId();
+        if (order.getMenuType() != null && order.getMenuType().equals(OrderMenuConstants.MENU_TYPE_GENERIC_PRICE)) {
+            genericCartService.unlockCartItems(tableId);
+            genericCartService.clearCouponUsed(tableId);
+        } else {
+            cartService.unlockCartItems(tableId);
+            cartService.clearCouponUsed(tableId);
+        }
 
         // 更新桌号状态:显式清空 currentOrderId(updateById 会忽略 null,必须用 LambdaUpdateWrapper)
-        Integer tableId = order.getTableId();
         StoreTable table = storeTableMapper.selectById(tableId);
         if (table != null) {
             LambdaUpdateWrapper<StoreTable> tableWrapper = new LambdaUpdateWrapper<>();
             tableWrapper.eq(StoreTable::getId, tableId)
                     .set(StoreTable::getCurrentOrderId, null)
                     .set(StoreTable::getUpdatedTime, new Date());
-            CartDTO cart = cartService.getCart(tableId);
+            CartDTO cart = order.getMenuType() != null && order.getMenuType().equals(OrderMenuConstants.MENU_TYPE_GENERIC_PRICE)
+                    ? genericCartService.getCart(tableId)
+                    : cartService.getCart(tableId);
             if (cart.getItems() == null || cart.getItems().isEmpty()) {
                 tableWrapper.set(StoreTable::getStatus, 0).set(StoreTable::getDinerCount, null);
             } else {
@@ -1213,13 +1279,14 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
             }
         }
         
-        // 5. 清空Redis中的购物车缓存
-        String cartKey = "cart:table:" + tableId;
-        baseRedisService.delete(cartKey);
+        // 5. 清空Redis中的购物车缓存(美食 + 通用)
+        baseRedisService.delete("cart:table:" + tableId);
+        baseRedisService.delete(OrderMenuConstants.GENERIC_CART_REDIS_PREFIX + tableId);
         log.info("清空Redis购物车缓存, tableId={}", tableId);
         
         // 6. 清除优惠券使用标记(Redis中的标记)
         cartService.clearCouponUsed(tableId);
+        genericCartService.clearCouponUsed(tableId);
         log.info("清除优惠券使用标记, tableId={}", tableId);
         
         // 7. 重置餐桌表(使用 LambdaUpdateWrapper 来显式设置 null 值)
@@ -1246,10 +1313,11 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
      * 支付完成后重置餐桌(保留订单数据,只重置餐桌绑定关系)
      *
      * @param tableId 餐桌ID
+     * @param orderMenuType 订单菜单类型,用于只清理对应 store_cart 与 Redis
      */
     @Override
-    public void resetTableAfterPayment(Integer tableId) {
-        log.info("支付完成后重置餐桌, tableId={}", tableId);
+    public void resetTableAfterPayment(Integer tableId, Integer orderMenuType) {
+        log.info("支付完成后重置餐桌, tableId={}, orderMenuType={}", tableId, orderMenuType);
 
         // 验证餐桌是否存在
         StoreTable table = storeTableMapper.selectById(tableId);
@@ -1259,10 +1327,14 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
         }
 
         // 1. 完全清空购物车(包括已下单的商品,因为订单已支付完成)
-        // 逻辑删除所有购物车数据
         LambdaQueryWrapper<StoreCart> cartWrapper = new LambdaQueryWrapper<>();
         cartWrapper.eq(StoreCart::getTableId, tableId);
         cartWrapper.eq(StoreCart::getDeleteFlag, 0);
+        if (orderMenuType != null && orderMenuType.equals(OrderMenuConstants.MENU_TYPE_GENERIC_PRICE)) {
+            StoreCartMenuFilters.applyGenericCart(cartWrapper);
+        } else {
+            StoreCartMenuFilters.applyCuisineCart(cartWrapper);
+        }
         List<StoreCart> cartList = storeCartMapper.selectList(cartWrapper);
         if (cartList != null && !cartList.isEmpty()) {
             List<Integer> cartIds = cartList.stream()
@@ -1272,13 +1344,17 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
             log.info("支付完成后删除购物车数据, tableId={}, count={}", tableId, cartList.size());
         }
 
-        // 2. 清空Redis中的购物车缓存
-        String cartKey = "cart:table:" + tableId;
-        baseRedisService.delete(cartKey);
+        // 2. 清空Redis中的购物车缓存(两套前缀都清,避免残留)
+        baseRedisService.delete("cart:table:" + tableId);
+        baseRedisService.delete(OrderMenuConstants.GENERIC_CART_REDIS_PREFIX + tableId);
         log.info("清空Redis购物车缓存, tableId={}", tableId);
 
         // 3. 清除优惠券使用标记
-        cartService.clearCouponUsed(tableId);
+        if (orderMenuType != null && orderMenuType.equals(OrderMenuConstants.MENU_TYPE_GENERIC_PRICE)) {
+            genericCartService.clearCouponUsed(tableId);
+        } else {
+            cartService.clearCouponUsed(tableId);
+        }
         log.info("清除优惠券使用标记, tableId={}", tableId);
 
         // 4. 重置餐桌表(使用 LambdaUpdateWrapper 来显式设置 null 值)

+ 1 - 1
alien-dining/src/main/java/shop/alien/dining/strategy/payment/impl/WeChatPaymentMininProgramStrategyImpl.java

@@ -376,7 +376,7 @@ public class WeChatPaymentMininProgramStrategyImpl implements PaymentStrategy {
                         }
                         // 支付完成后,清空购物车和重置餐桌状态(保留订单数据,不删除订单)
                         try {
-                            storeOrderService.resetTableAfterPayment(storeOrder.getTableId());
+                            storeOrderService.resetTableAfterPayment(storeOrder.getTableId(), storeOrder.getMenuType());
                             log.info("支付完成后重置餐桌成功, tableId={}", storeOrder.getTableId());
                         } catch (Exception e) {
                             log.error("支付完成后重置餐桌失败, tableId={}, error={}", storeOrder.getTableId(), e.getMessage(), e);

+ 57 - 1
alien-dining/src/main/java/shop/alien/dining/support/DiningMenuPricing.java

@@ -2,9 +2,12 @@ package shop.alien.dining.support;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import shop.alien.entity.store.StoreCuisine;
+import shop.alien.entity.store.StorePrice;
 import shop.alien.entity.store.StoreProductDiscountRule;
 import shop.alien.mapper.StoreCuisineMapper;
+import shop.alien.mapper.StorePriceMapper;
 import shop.alien.mapper.StoreProductDiscountRuleMapper;
+import shop.alien.dining.constants.OrderMenuConstants;
 
 import java.math.BigDecimal;
 import java.time.LocalDateTime;
@@ -50,6 +53,27 @@ public final class DiningMenuPricing {
     }
 
     /**
+     * 通用价目标价(store_price.total_price)。
+     */
+    public static Map<Integer, BigDecimal> resolveListUnitPriceByGenericPriceId(Set<Integer> priceIds, StorePriceMapper priceMapper) {
+        if (priceIds == null || priceIds.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        List<StorePrice> list = priceMapper.selectList(
+                new LambdaQueryWrapper<StorePrice>().in(StorePrice::getId, priceIds));
+        Map<Integer, BigDecimal> m = new HashMap<>();
+        if (list != null) {
+            for (StorePrice p : list) {
+                m.put(p.getId(), p.getTotalPrice() != null ? p.getTotalPrice() : BigDecimal.ZERO);
+            }
+        }
+        for (Integer id : priceIds) {
+            m.putIfAbsent(id, BigDecimal.ZERO);
+        }
+        return m;
+    }
+
+    /**
      * 成交单价:有规则命中则用规则价,否则等于标价。
      */
     public static Map<Integer, BigDecimal> resolveSaleUnitPrice(
@@ -64,7 +88,9 @@ public final class DiningMenuPricing {
                 new LambdaQueryWrapper<StoreProductDiscountRule>()
                         .eq(StoreProductDiscountRule::getStoreId, storeId)
                         .eq(StoreProductDiscountRule::getStatus, 1)
-                        .in(StoreProductDiscountRule::getProductId, ids));
+                        .in(StoreProductDiscountRule::getProductId, ids)
+                        .and(w -> w.eq(StoreProductDiscountRule::getRuleProductType, OrderMenuConstants.RULE_PRODUCT_CUISINE)
+                                .or().isNull(StoreProductDiscountRule::getRuleProductType)));
         Map<Integer, BigDecimal> disc = ProductDiscountPricingSupport.resolveDiscountedPrices(
                 ids,
                 listUnitByCuisineId,
@@ -77,4 +103,34 @@ public final class DiningMenuPricing {
         }
         return sale;
     }
+
+    /**
+     * 通用价目成交单价(rule_product_type=2 的规则)。
+     */
+    public static Map<Integer, BigDecimal> resolveSaleUnitPriceForGenericPrice(
+            Integer storeId,
+            Map<Integer, BigDecimal> listUnitByPriceId,
+            StoreProductDiscountRuleMapper ruleMapper) {
+        if (storeId == null || listUnitByPriceId == null || listUnitByPriceId.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        List<Integer> ids = new ArrayList<>(listUnitByPriceId.keySet());
+        List<StoreProductDiscountRule> rules = ruleMapper.selectList(
+                new LambdaQueryWrapper<StoreProductDiscountRule>()
+                        .eq(StoreProductDiscountRule::getStoreId, storeId)
+                        .eq(StoreProductDiscountRule::getStatus, 1)
+                        .eq(StoreProductDiscountRule::getRuleProductType, OrderMenuConstants.RULE_PRODUCT_GENERIC_PRICE)
+                        .in(StoreProductDiscountRule::getProductId, ids));
+        Map<Integer, BigDecimal> disc = ProductDiscountPricingSupport.resolveDiscountedPrices(
+                ids,
+                listUnitByPriceId,
+                LocalDateTime.now(SHANGHAI),
+                rules != null ? rules : Collections.emptyList());
+        Map<Integer, BigDecimal> sale = new HashMap<>();
+        for (Integer id : ids) {
+            BigDecimal list = listUnitByPriceId.getOrDefault(id, BigDecimal.ZERO);
+            sale.put(id, disc.containsKey(id) ? disc.get(id) : list);
+        }
+        return sale;
+    }
 }

+ 23 - 0
alien-dining/src/main/java/shop/alien/dining/support/StoreCartMenuFilters.java

@@ -0,0 +1,23 @@
+package shop.alien.dining.support;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import shop.alien.dining.constants.OrderMenuConstants;
+import shop.alien.entity.store.StoreCart;
+
+/**
+ * store_cart 按 menu_type 区分美食/通用价目购物车。
+ */
+public final class StoreCartMenuFilters {
+
+    private StoreCartMenuFilters() {
+    }
+
+    public static void applyCuisineCart(LambdaQueryWrapper<StoreCart> w) {
+        w.and(x -> x.eq(StoreCart::getMenuType, OrderMenuConstants.MENU_TYPE_CUISINE)
+                .or().isNull(StoreCart::getMenuType));
+    }
+
+    public static void applyGenericCart(LambdaQueryWrapper<StoreCart> w) {
+        w.eq(StoreCart::getMenuType, OrderMenuConstants.MENU_TYPE_GENERIC_PRICE);
+    }
+}