فهرست منبع

夜市功能接口

qmj 1 روز پیش
والد
کامیت
7ff3a2afb8

+ 2 - 3
CLAUDE.md

@@ -9,9 +9,8 @@ Auto-generated from all feature plans. Last updated: 2026-04-29
 ## Project Structure
 
 ```text
-backend/
-frontend/
-tests/
+平台管理前端代码路径:E:\QtwCode\foodie\foodie-admin-vue
+商家端管理前端代码路径:E:\QtwCode\foodie\foodie-store
 ```
 
 ## Commands

+ 468 - 0
docs/superpowers/plans/2026-05-08-nightmarket-customer-api.md

@@ -0,0 +1,468 @@
+# 夜市用户端 API 实现计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 为用户端夜市功能提供后端接口:附近商家列表(含搜索)、常购/关注列表、店铺详情。
+
+**Architecture:** 新建 `NightMarketController` 作为用户端夜市入口,复用已有的 `PosStoreEnrichService`(评分/月售/营业状态)、`PosStoreMapper`(距离计算)、`PosCollectService`(关注)和 `PosFoodService`(商品)。夜市特有的查询(按 nightMarketId 过滤的摊位列表、搜索)通过在 PosStoreMapper 中新增 SQL 实现。
+
+**Tech Stack:** Java 21, Spring Boot 3.3.5, MyBatis-Plus, MySQL ST_Distance 距离计算
+
+---
+
+## File Structure
+
+| File | Action | Responsibility |
+|------|--------|----------------|
+| `ruoyi-admin/.../app/nightmarket/NightMarketController.java` | Create | 用户端夜市 API 入口(列表/搜索/关注/详情) |
+| `ruoyi-system/.../mapper/PosStoreMapper.java` | Modify | 新增夜市摊位列表查询(含搜索、距离排序) |
+| `ruoyi-system/.../mapper/PosStoreMapper.xml` | Modify | 新增夜市摊位列表 SQL |
+| `ruoyi-system/.../mapper/PosFoodMapper.java` | Modify | 新增按店铺查商品(含分类) |
+| `ruoyi-system/.../mapper/PosFoodMapper.xml` | Modify | 新增按店铺查商品 SQL |
+
+---
+
+### Task 1: 夜市附近商家列表(含搜索)
+
+**Files:**
+- Modify: `ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosStoreMapper.java`
+- Modify: `ruoyi-system/src/main/resources/mapper/PosStoreMapper.xml`
+- Create: `ruoyi-admin/src/main/java/com/ruoyi/app/nightmarket/NightMarketController.java`
+
+- [ ] **Step 1: 在 PosStoreMapper.java 新增夜市摊位列表查询方法**
+
+在 `PosStoreMapper.java` 中新增两个方法:
+
+```java
+/**
+ * 夜市附近摊位列表(含搜索)
+ * @param longitude 用户经度
+ * @param latitude 用户纬度
+ * @param nightMarketId 夜市ID
+ * @param keyword 搜索关键词(可为null)
+ * @param offset 分页偏移
+ * @return 摊位列表(含距离juli)
+ */
+List<PosStore> getNightMarketStalls(@Param("longitude") BigDecimal longitude,
+                                     @Param("latitude") BigDecimal latitude,
+                                     @Param("nightMarketId") Long nightMarketId,
+                                     @Param("keyword") String keyword,
+                                     @Param("offset") Integer offset);
+
+/**
+ * 夜市摊位总数(含搜索)
+ */
+int getNightMarketStallsCount(@Param("nightMarketId") Long nightMarketId,
+                               @Param("keyword") String keyword);
+```
+
+- [ ] **Step 2: 在 PosStoreMapper.xml 新增对应 SQL**
+
+在 `PosStoreMapper.xml` 的 `</mapper>` 标签前添加:
+
+```xml
+<!-- 夜市附近摊位列表(含搜索、距离排序) -->
+<select id="getNightMarketStalls" resultMap="PosStoreResult">
+    SELECT *,(st_distance(point(longitude,latitude),point(#{longitude},#{latitude}))*111195/1000) as juli
+    FROM pos_store
+    WHERE del_flag='0' AND off_shelf='0' AND is_stall=1 AND night_market_id=#{nightMarketId}
+    <if test="keyword != null and keyword != ''">
+        AND pos_name LIKE CONCAT('%', #{keyword}, '%')
+    </if>
+    ORDER BY juli ASC
+    LIMIT #{offset}, 20
+</select>
+
+<!-- 夜市摊位总数(含搜索) -->
+<select id="getNightMarketStallsCount" resultType="int">
+    SELECT COUNT(*) FROM pos_store
+    WHERE del_flag='0' AND off_shelf='0' AND is_stall=1 AND night_market_id=#{nightMarketId}
+    <if test="keyword != null and keyword != ''">
+        AND pos_name LIKE CONCAT('%', #{keyword}, '%')
+    </if>
+</select>
+```
+
+- [ ] **Step 3: 创建 NightMarketController.java**
+
+创建文件 `ruoyi-admin/src/main/java/com/ruoyi/app/nightmarket/NightMarketController.java`:
+
+```java
+package com.ruoyi.app.nightmarket;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.ruoyi.app.mendian.PosStoreEnrichService;
+import com.ruoyi.app.user.dto.StoreOutput;
+import com.ruoyi.common.annotation.Anonymous;
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.system.domain.*;
+import com.ruoyi.system.mapper.PosStoreMapper;
+import com.ruoyi.system.service.*;
+import com.ruoyi.system.utils.Auth;
+import com.ruoyi.system.utils.JwtUtil;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * 用户端夜市Controller
+ */
+@RestController
+@RequestMapping("/nightmarket")
+public class NightMarketController extends BaseController {
+
+    @Autowired
+    private PosStoreMapper posStoreMapper;
+    @Autowired
+    private PosStoreEnrichService posStoreEnrichService;
+    @Autowired
+    private IPosCollectService posCollectService;
+    @Autowired
+    private IPosOrderService posOrderService;
+    @Autowired
+    private IInfoUserService infoUserService;
+    @Autowired
+    private INightMarketService nightMarketService;
+
+    /**
+     * 夜市附近商家列表(含搜索)
+     * @param nightMarketId 夜市ID
+     * @param longitude 用户经度
+     * @param latitude 用户纬度
+     * @param page 页码(从1开始)
+     * @param keyword 搜索关键词(可选)
+     * @param language 语言(默认zh-TW)
+     */
+    @Anonymous
+    @GetMapping("/stores")
+    public AjaxResult nearbyStores(@RequestParam Long nightMarketId,
+                                   @RequestParam BigDecimal longitude,
+                                   @RequestParam BigDecimal latitude,
+                                   @RequestParam(defaultValue = "1") Integer page,
+                                   @RequestParam(required = false) String keyword,
+                                   @RequestParam(defaultValue = "zh-TW", required = false) String language) {
+        int offset = (page - 1) * 20;
+        List<PosStore> list = posStoreMapper.getNightMarketStalls(longitude, latitude, nightMarketId, keyword, offset);
+        posStoreEnrichService.enrichStoreList(list);
+        Page<StoreOutput> result = posStoreEnrichService.buildStoreOutputPage(list, language, page, list.size());
+        return success(result);
+    }
+}
+```
+
+- [ ] **Step 4: 编译验证**
+
+Run: `cd E:\QtwCode\foodie\foodie_server && mvn compile -pl ruoyi-admin -am -q`
+Expected: BUILD SUCCESS
+
+- [ ] **Step 5: 提交**
+
+```bash
+git add ruoyi-admin/src/main/java/com/ruoyi/app/nightmarket/NightMarketController.java ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosStoreMapper.java ruoyi-system/src/main/resources/mapper/PosStoreMapper.xml
+git commit -m "feat(nightmarket): 夜市附近商家列表接口(含搜索)"
+```
+
+---
+
+### Task 2: 常购/关注列表
+
+**Files:**
+- Modify: `ruoyi-admin/src/main/java/com/ruoyi/app/nightmarket/NightMarketController.java`
+
+- [ ] **Step 1: 在 PosStoreMapper.java 新增常购/关注查询方法**
+
+```java
+/**
+ * 夜市下用户关注的摊位列表
+ */
+List<PosStore> getFollowedStalls(@Param("longitude") BigDecimal longitude,
+                                  @Param("latitude") BigDecimal latitude,
+                                  @Param("nightMarketId") Long nightMarketId,
+                                  @Param("userId") Long userId,
+                                  @Param("offset") Integer offset);
+
+/**
+ * 夜市下用户常购的摊位列表(按下单次数排序)
+ */
+List<PosStore> getFrequentStalls(@Param("longitude") BigDecimal longitude,
+                                  @Param("latitude") BigDecimal latitude,
+                                  @Param("nightMarketId") Long nightMarketId,
+                                  @Param("userId") Long userId,
+                                  @Param("offset") Integer offset);
+```
+
+- [ ] **Step 2: 在 PosStoreMapper.xml 新增对应 SQL**
+
+```xml
+<!-- 夜市下用户关注的摊位 -->
+<select id="getFollowedStalls" resultMap="PosStoreResult">
+    SELECT ps.*, (st_distance(point(ps.longitude,ps.latitude),point(#{longitude},#{latitude}))*111195/1000) as juli
+    FROM pos_store ps
+    INNER JOIN pos_collect pc ON ps.id = pc.md_id
+    WHERE ps.del_flag='0' AND ps.off_shelf='0' AND ps.is_stall=1
+      AND ps.night_market_id=#{nightMarketId}
+      AND pc.user_id=#{userId}
+    ORDER BY pc.cretim DESC
+    LIMIT #{offset}, 20
+</select>
+
+<!-- 夜市下用户常购的摊位(按近30天下单次数排序) -->
+<select id="getFrequentStalls" resultMap="PosStoreResult">
+    SELECT ps.*, (st_distance(point(ps.longitude,ps.latitude),point(#{longitude},#{latitude}))*111195/1000) as juli
+    FROM pos_store ps
+    INNER JOIN (
+        SELECT md_id, COUNT(*) as order_count
+        FROM pos_order
+        WHERE user_id=#{userId}
+          AND DATE_FORMAT(cretim,'%Y-%m-%d') >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
+        GROUP BY md_id
+        ORDER BY order_count DESC
+    ) po ON ps.id = po.md_id
+    WHERE ps.del_flag='0' AND ps.off_shelf='0' AND ps.is_stall=1
+      AND ps.night_market_id=#{nightMarketId}
+    ORDER BY po.order_count DESC
+    LIMIT #{offset}, 20
+</select>
+```
+
+- [ ] **Step 3: 在 NightMarketController 新增常购/关注接口**
+
+在 `NightMarketController` 中添加两个接口方法:
+
+```java
+/**
+ * 夜市关注的摊位列表
+ */
+@Anonymous
+@Auth
+@GetMapping("/followed")
+public AjaxResult followedStores(@RequestHeader String token,
+                                 @RequestParam Long nightMarketId,
+                                 @RequestParam BigDecimal longitude,
+                                 @RequestParam BigDecimal latitude,
+                                 @RequestParam(defaultValue = "1") Integer page,
+                                 @RequestParam(defaultValue = "zh-TW", required = false) String language) {
+    JwtUtil jwtUtil = new JwtUtil();
+    String userId = jwtUtil.getusid(token);
+    int offset = (page - 1) * 20;
+    List<PosStore> list = posStoreMapper.getFollowedStalls(longitude, latitude, nightMarketId, Long.valueOf(userId), offset);
+    posStoreEnrichService.enrichStoreList(list);
+    Page<StoreOutput> result = posStoreEnrichService.buildStoreOutputPage(list, language, page, list.size());
+    return success(result);
+}
+
+/**
+ * 夜市常购的摊位列表
+ */
+@Anonymous
+@Auth
+@GetMapping("/frequent")
+public AjaxResult frequentStores(@RequestHeader String token,
+                                  @RequestParam Long nightMarketId,
+                                  @RequestParam BigDecimal longitude,
+                                  @RequestParam BigDecimal latitude,
+                                  @RequestParam(defaultValue = "1") Integer page,
+                                  @RequestParam(defaultValue = "zh-TW", required = false) String language) {
+    JwtUtil jwtUtil = new JwtUtil();
+    String userId = jwtUtil.getusid(token);
+    int offset = (page - 1) * 20;
+    List<PosStore> list = posStoreMapper.getFrequentStalls(longitude, latitude, nightMarketId, Long.valueOf(userId), offset);
+    posStoreEnrichService.enrichStoreList(list);
+    Page<StoreOutput> result = posStoreEnrichService.buildStoreOutputPage(list, language, page, list.size());
+    return success(result);
+}
+```
+
+别忘了在文件顶部增加 `@Autowired` 注入(如 Task 1 中已注入则跳过)。
+
+- [ ] **Step 4: 编译验证**
+
+Run: `cd E:\QtwCode\foodie\foodie_server && mvn compile -pl ruoyi-admin -am -q`
+Expected: BUILD SUCCESS
+
+- [ ] **Step 5: 提交**
+
+```bash
+git add ruoyi-admin/src/main/java/com/ruoyi/app/nightmarket/NightMarketController.java ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosStoreMapper.java ruoyi-system/src/main/resources/mapper/PosStoreMapper.xml
+git commit -m "feat(nightmarket): 常购/关注摊位列表接口"
+```
+
+---
+
+### Task 3: 店铺详情(含商品分类列表)
+
+**Files:**
+- Modify: `ruoyi-admin/src/main/java/com/ruoyi/app/nightmarket/NightMarketController.java`
+- Modify: `ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosFoodMapper.java`
+- Modify: `ruoyi-system/src/main/resources/mapper/PosFoodMapper.xml`(如果存在)
+
+- [ ] **Step 1: 在 PosFoodMapper.java 新增按店铺查商品方法**
+
+```java
+/**
+ * 按店铺查商品列表(审核通过、上架状态)
+ */
+List<PosFood> selectFoodsByStoreId(@Param("storeId") Long storeId,
+                                    @Param("language") String language);
+```
+
+- [ ] **Step 2: 在 PosFoodMapper.xml 新增 SQL**
+
+找到 `PosFoodMapper.xml` 并添加:
+
+```xml
+<select id="selectFoodsByStoreId" resultMap="PosFoodResult">
+    SELECT id, fl_id, mdid, name, image, price, introduce, recommend, sort, language, stacking_up, to_examine
+    FROM pos_food
+    WHERE mdid = #{storeId}
+      AND to_examine = '1'
+      AND stacking_up = '0'
+      AND language = #{language}
+    ORDER BY sort ASC, id DESC
+</select>
+```
+
+如果 PosFoodMapper.xml 中没有 `PosFoodResult` resultMap,使用 `resultType="PosFood"` 代替。
+
+- [ ] **Step 3: 在 NightMarketController 新增店铺详情接口**
+
+在 `NightMarketController` 中新增必要的注入和详情方法:
+
+```java
+@Autowired
+private IPosStoreService posStoreService;
+
+@Autowired
+private IOperatingHoursService operatingHoursService;
+
+/**
+ * 夜市摊位详情(含商品分类列表)
+ * @param storeId 摊位ID
+ * @param token 用户token(可选,用于判断是否已关注)
+ * @param language 语言
+ */
+@Anonymous
+@GetMapping("/store/detail")
+public AjaxResult storeDetail(@RequestParam Long storeId,
+                               @RequestHeader(defaultValue = "") String token,
+                               @RequestParam(defaultValue = "zh-TW", required = false) String language) {
+    PosStore store = posStoreService.getById(storeId);
+    if (store == null || !"0".equals(store.getDelFlag()) || store.getIsStall() == null || store.getIsStall() != 1) {
+        return error("摊位不存在");
+    }
+
+    // 判断是否已关注
+    boolean collected = false;
+    if (token != null && !token.isEmpty()) {
+        try {
+            JwtUtil jwtUtil = new JwtUtil();
+            String userId = jwtUtil.getusid(token);
+            QueryWrapper<PosCollect> qw = new QueryWrapper<>();
+            qw.eq("user_id", userId).eq("md_id", storeId);
+            collected = posCollectService.count(qw) > 0;
+        } catch (Exception ignored) {}
+    }
+
+    // 评分
+    QueryWrapper<PosReview> reviewQw = new QueryWrapper<>();
+    reviewQw.eq("md_id", storeId);
+    List<PosReview> reviews = posReviewService.list(reviewQw);
+    double rating = reviews.isEmpty() ? 4.5d :
+            Math.round(reviews.stream().mapToDouble(PosReview::getScore).average().orElse(4.5d) * 2.0) / 2.0;
+
+    // 月售
+    QueryWrapper<PosOrder> orderQw = new QueryWrapper<>();
+    orderQw.eq("md_id", storeId).apply("DATE_FORMAT(cretim,'%Y-%m') = DATE_FORMAT(NOW(),'%Y-%m')");
+    long monthlySales = posOrderService.count(orderQw);
+
+    // 商品列表
+    String langCode = "zh-CN".equals(language) ? "2" : ("en-US".equals(language) ? "1" : "3");
+    List<PosFood> foods = posFoodMapper.selectFoodsByStoreId(storeId, langCode);
+
+    // 组装返回
+    com.alibaba.fastjson.JSONObject result = new com.alibaba.fastjson.JSONObject();
+    result.put("id", store.getId());
+    result.put("posName", store.getPosName());
+    result.put("image", store.getImage());
+    result.put("logo", store.getLogo());
+    result.put("address", store.getAddress());
+    result.put("longitude", store.getLongitude());
+    result.put("latitude", store.getLatitude());
+    result.put("briefIntroduction", store.getBriefIntroduction());
+    result.put("telephone", store.getTelephone());
+    result.put("openBusiness", store.getOpenBusiness());
+    result.put("windingUp", store.getWindingUp());
+    result.put("rating", rating);
+    result.put("monthlySales", monthlySales);
+    result.put("collected", collected ? 1 : 0);
+    result.put("foodList", foods);
+    result.put("nightMarketId", store.getNightMarketId());
+
+    return success(result);
+}
+```
+
+需要在类顶部额外注入(如尚未注入):
+
+```java
+@Autowired
+private IPosReviewService posReviewService;
+
+@Autowired
+private PosFoodMapper posFoodMapper;
+```
+
+并确保有以下 import:
+
+```java
+import com.ruoyi.system.domain.PosFood;
+import com.ruoyi.system.domain.PosReview;
+import com.ruoyi.system.mapper.PosFoodMapper;
+import com.alibaba.fastjson.JSONObject;
+```
+
+- [ ] **Step 4: 编译验证**
+
+Run: `cd E:\QtwCode\foodie\foodie_server && mvn compile -pl ruoyi-admin -am -q`
+Expected: BUILD SUCCESS
+
+- [ ] **Step 5: 提交**
+
+```bash
+git add ruoyi-admin/src/main/java/com/ruoyi/app/nightmarket/NightMarketController.java ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosFoodMapper.java ruoyi-system/src/main/resources/mapper/PosFoodMapper.xml
+git commit -m "feat(nightmarket): 摊位详情接口(含商品分类列表、评分、关注状态)"
+```
+
+---
+
+## API Summary
+
+| Endpoint | Method | Auth | Description |
+|----------|--------|------|-------------|
+| `/nightmarket/stores` | GET | No | 夜市附近商家列表(支持搜索) |
+| `/nightmarket/followed` | GET | Yes | 关注的摊位列表 |
+| `/nightmarket/frequent` | GET | Yes | 常购的摊位列表 |
+| `/nightmarket/store/detail` | GET | Optional | 摊位详情(含商品) |
+
+### Request/Response
+
+**GET /nightmarket/stores**
+- Request: `nightMarketId`, `longitude`, `latitude`, `page`(默认1), `keyword`(可选), `language`(默认zh-TW)
+- Response: `Page<StoreOutput>` — 含摊位信息 + foodList
+
+**GET /nightmarket/followed**
+- Request: `token`(header), `nightMarketId`, `longitude`, `latitude`, `page`, `language`
+- Response: `Page<StoreOutput>` — 用户关注的摊位列表
+
+**GET /nightmarket/frequent**
+- Request: `token`(header), `nightMarketId`, `longitude`, `latitude`, `page`, `language`
+- Response: `Page<StoreOutput>` — 用户常购的摊位列表(按近30天下单次数排序)
+
+**GET /nightmarket/store/detail**
+- Request: `storeId`, `token`(header,可选), `language`(默认zh-TW)
+- Response: JSONObject — 摊位详情 + rating + monthlySales + collected + foodList

+ 22 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosFoodController.java

@@ -415,6 +415,28 @@ public class PosFoodController extends BaseController {
         return success(list);
     }
 
+    /**
+     * C端扫码:获取摊位商品列表(摊位码扫码入口)
+     */
+    @Anonymous
+    @GetMapping("/stallFoodList")
+    public AjaxResult stallFoodList(@RequestParam Long storeId,
+                                    @RequestParam(required = false) Long flId,
+                                    @RequestParam Integer page,
+                                    @RequestParam Integer size,
+                                    @RequestParam(required = false) Long tableId) {
+        IPage<PosFood> foodPage = new Page<>(page, size);
+        QueryWrapper<PosFood> queryWrapper = new QueryWrapper<>();
+        queryWrapper.eq("mdid", storeId);
+        queryWrapper.eq("to_examine", "1");
+        queryWrapper.eq("stacking_up", "1");
+        if (flId != null) {
+            queryWrapper.eq("fl_id", flId);
+        }
+        IPage<PosFood> result = posFoodService.page(foodPage, queryWrapper);
+        return success(result);
+    }
+
     /**
      * 查询商品列表
      */

+ 180 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/nightmarket/NightMarketController.java

@@ -0,0 +1,180 @@
+package com.ruoyi.app.nightmarket;
+
+import com.alibaba.fastjson.JSONObject;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.ruoyi.app.mendian.PosStoreEnrichService;
+import com.ruoyi.app.user.dto.StoreOutput;
+import com.ruoyi.common.annotation.Anonymous;
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.system.domain.PosCollect;
+import com.ruoyi.system.domain.PosFood;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.PosReview;
+import com.ruoyi.system.domain.PosStore;
+import com.ruoyi.system.mapper.PosFoodMapper;
+import com.ruoyi.system.mapper.PosStoreMapper;
+import com.ruoyi.system.service.IPosCollectService;
+import com.ruoyi.system.service.IPosOrderService;
+import com.ruoyi.system.service.IPosReviewService;
+import com.ruoyi.system.service.IPosStoreService;
+import com.ruoyi.system.utils.Auth;
+import com.ruoyi.system.utils.JwtUtil;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 用户端夜市Controller
+ */
+@RestController
+@RequestMapping("/nightmarket")
+public class NightMarketController extends BaseController {
+
+    @Autowired
+    private PosStoreMapper posStoreMapper;
+
+    @Autowired
+    private PosStoreEnrichService posStoreEnrichService;
+
+    @Autowired
+    private IPosStoreService posStoreService;
+
+    @Autowired
+    private IPosCollectService posCollectService;
+
+    @Autowired
+    private IPosOrderService posOrderService;
+
+    @Autowired
+    private IPosReviewService posReviewService;
+
+    @Autowired
+    private PosFoodMapper posFoodMapper;
+
+    /**
+     * 夜市附近商家列表(含搜索)
+     */
+    @Anonymous
+    @GetMapping("/stores")
+    public AjaxResult nearbyStores(@RequestParam Long nightMarketId,
+                                   @RequestParam BigDecimal longitude,
+                                   @RequestParam BigDecimal latitude,
+                                   @RequestParam(defaultValue = "1") Integer page,
+                                   @RequestParam(required = false) String keyword,
+                                   @RequestParam(defaultValue = "zh-TW", required = false) String language) {
+        int offset = (page - 1) * 20;
+        List<PosStore> list = posStoreMapper.getNightMarketStalls(longitude, latitude, nightMarketId, keyword, offset);
+        posStoreEnrichService.enrichStoreList(list);
+        int total = posStoreMapper.getNightMarketStallsCount(nightMarketId, keyword);
+        Page<StoreOutput> result = posStoreEnrichService.buildStoreOutputPage(list, language, page, total);
+        return success(result);
+    }
+
+    /**
+     * 夜市关注的摊位列表
+     */
+    @Anonymous
+    @Auth
+    @GetMapping("/followed")
+    public AjaxResult followedStores(@RequestHeader String token,
+                                     @RequestParam Long nightMarketId,
+                                     @RequestParam BigDecimal longitude,
+                                     @RequestParam BigDecimal latitude,
+                                     @RequestParam(defaultValue = "1") Integer page,
+                                     @RequestParam(defaultValue = "zh-TW", required = false) String language) {
+        JwtUtil jwtUtil = new JwtUtil();
+        String userId = jwtUtil.getusid(token);
+        int offset = (page - 1) * 20;
+        List<PosStore> list = posStoreMapper.getFollowedStalls(longitude, latitude, nightMarketId, Long.valueOf(userId), offset);
+        posStoreEnrichService.enrichStoreList(list);
+        Page<StoreOutput> result = posStoreEnrichService.buildStoreOutputPage(list, language, page, list.size());
+        return success(result);
+    }
+
+    /**
+     * 夜市常购的摊位列表
+     */
+    @Anonymous
+    @Auth
+    @GetMapping("/frequent")
+    public AjaxResult frequentStores(@RequestHeader String token,
+                                      @RequestParam Long nightMarketId,
+                                      @RequestParam BigDecimal longitude,
+                                      @RequestParam BigDecimal latitude,
+                                      @RequestParam(defaultValue = "1") Integer page,
+                                      @RequestParam(defaultValue = "zh-TW", required = false) String language) {
+        JwtUtil jwtUtil = new JwtUtil();
+        String userId = jwtUtil.getusid(token);
+        int offset = (page - 1) * 20;
+        List<PosStore> list = posStoreMapper.getFrequentStalls(longitude, latitude, nightMarketId, Long.valueOf(userId), offset);
+        posStoreEnrichService.enrichStoreList(list);
+        Page<StoreOutput> result = posStoreEnrichService.buildStoreOutputPage(list, language, page, list.size());
+        return success(result);
+    }
+
+    /**
+     * 夜市摊位详情(含商品分类列表)
+     */
+    @Anonymous
+    @GetMapping("/store/detail")
+    public AjaxResult storeDetail(@RequestParam Long storeId,
+                                   @RequestHeader(defaultValue = "") String token,
+                                   @RequestParam(defaultValue = "zh-TW", required = false) String language) {
+        PosStore store = posStoreService.getById(storeId);
+        if (store == null || !"0".equals(store.getDelFlag()) || store.getIsStall() == null || store.getIsStall() != 1) {
+            return error("摊位不存在");
+        }
+
+        // 判断是否已关注
+        boolean collected = false;
+        if (token != null && !token.isEmpty()) {
+            try {
+                JwtUtil jwtUtil = new JwtUtil();
+                String userId = jwtUtil.getusid(token);
+                QueryWrapper<PosCollect> qw = new QueryWrapper<>();
+                qw.eq("user_id", userId).eq("md_id", storeId);
+                collected = posCollectService.count(qw) > 0;
+            } catch (Exception ignored) {}
+        }
+
+        // 评分
+        QueryWrapper<PosReview> reviewQw = new QueryWrapper<>();
+        reviewQw.eq("md_id", storeId);
+        List<PosReview> reviews = posReviewService.list(reviewQw);
+        double rating = reviews.isEmpty() ? 4.5d :
+                Math.round(reviews.stream().mapToDouble(PosReview::getScore).average().orElse(4.5d) * 2.0) / 2.0;
+
+        // 月售
+        QueryWrapper<PosOrder> orderQw = new QueryWrapper<>();
+        orderQw.eq("md_id", storeId).apply("DATE_FORMAT(cretim,'%Y-%m') = DATE_FORMAT(NOW(),'%Y-%m')");
+        long monthlySales = posOrderService.count(orderQw);
+
+        // 商品列表
+        List<PosFood> foods = posFoodMapper.selectFoodsByStoreId(storeId, language);
+
+        // 组装返回
+        JSONObject result = new JSONObject();
+        result.put("id", store.getId());
+        result.put("posName", store.getPosName());
+        result.put("image", store.getImage());
+        result.put("logo", store.getLogo());
+        result.put("address", store.getAddress());
+        result.put("longitude", store.getLongitude());
+        result.put("latitude", store.getLatitude());
+        result.put("briefIntroduction", store.getBriefIntroduction());
+        result.put("telephone", store.getTelephone());
+        result.put("openBusiness", store.getOpenBusiness());
+        result.put("windingUp", store.getWindingUp());
+        result.put("rating", rating);
+        result.put("monthlySales", monthlySales);
+        result.put("collected", collected ? 1 : 0);
+        result.put("foodList", foods);
+        result.put("nightMarketId", store.getNightMarketId());
+
+        return success(result);
+    }
+}

+ 17 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/stall/StallController.java

@@ -1,5 +1,6 @@
 package com.ruoyi.app.stall;
 
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.ruoyi.common.annotation.Anonymous;
 import com.ruoyi.common.core.controller.BaseController;
 import com.ruoyi.common.core.domain.AjaxResult;
@@ -206,4 +207,20 @@ public class StallController extends BaseController {
         infoUserService.deleteInfoUserByUserId(userId);
         return success();
     }
+
+    /**
+     * C端扫码:获取夜市下所有摊位列表(公用码扫码入口)
+     */
+    @Anonymous
+    @GetMapping("/market/stores")
+    public AjaxResult marketStores(@RequestParam Long nightMarketId,
+                                   @RequestParam(required = false) Long tableId) {
+        QueryWrapper<PosStore> queryWrapper = new QueryWrapper<>();
+        queryWrapper.eq("night_market_id", nightMarketId);
+        queryWrapper.eq("is_stall", 1);
+        queryWrapper.eq("off_shelf", "0");
+        queryWrapper.select("id", "pos_name", "image", "logo", "brief_introduction");
+        List<PosStore> list = posStoreService.list(queryWrapper);
+        return success(list);
+    }
 }

+ 6 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosFoodMapper.java

@@ -98,4 +98,10 @@ public interface PosFoodMapper  extends BaseMapper<PosFood>
     List<PosFood> selectFoodsByStoreIdsWithLimit(@Param("storeIds") List<Long> storeIds,
                                                  @Param("language") String language,
                                                  @Param("limitPerStore") int limitPerStore);
+
+    /**
+     * 按店铺查商品列表(审核通过、上架状态)
+     */
+    List<PosFood> selectFoodsByStoreId(@Param("storeId") Long storeId,
+                                       @Param("language") String language);
 }

+ 33 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosStoreMapper.java

@@ -123,4 +123,37 @@ public interface PosStoreMapper extends BaseMapper<PosStore>
     public IPage<PosStore> selectStoresByFoodKeyword(IPage<PosStore> page,
                                                      @Param("keyword") String keyword,
                                                      @Param("language") String language);
+
+    /**
+     * 夜市附近摊位列表(含搜索)
+     */
+    List<PosStore> getNightMarketStalls(@Param("longitude") BigDecimal longitude,
+                                         @Param("latitude") BigDecimal latitude,
+                                         @Param("nightMarketId") Long nightMarketId,
+                                         @Param("keyword") String keyword,
+                                         @Param("offset") Integer offset);
+
+    /**
+     * 夜市摊位总数(含搜索)
+     */
+    int getNightMarketStallsCount(@Param("nightMarketId") Long nightMarketId,
+                                   @Param("keyword") String keyword);
+
+    /**
+     * 夜市下用户关注的摊位列表
+     */
+    List<PosStore> getFollowedStalls(@Param("longitude") BigDecimal longitude,
+                                      @Param("latitude") BigDecimal latitude,
+                                      @Param("nightMarketId") Long nightMarketId,
+                                      @Param("userId") Long userId,
+                                      @Param("offset") Integer offset);
+
+    /**
+     * 夜市下用户常购的摊位列表(按下单次数排序)
+     */
+    List<PosStore> getFrequentStalls(@Param("longitude") BigDecimal longitude,
+                                      @Param("latitude") BigDecimal latitude,
+                                      @Param("nightMarketId") Long nightMarketId,
+                                      @Param("userId") Long userId,
+                                      @Param("offset") Integer offset);
 }

+ 10 - 0
ruoyi-system/src/main/resources/mapper/chanting/PosFoodMapper.xml

@@ -112,4 +112,14 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             AND stacking_up = 1
         </where>
     </select>
+
+    <select id="selectFoodsByStoreId" resultMap="PosFoodResult">
+        SELECT id, fl_id, mdid, name, image, price, introduce, recommend, sort, language, stacking_up, to_examine
+        FROM pos_food
+        WHERE mdid = #{storeId}
+          AND to_examine = '1'
+          AND stacking_up = '0'
+          AND language = #{language}
+        ORDER BY sort ASC, id DESC
+    </select>
 </mapper>

+ 51 - 0
ruoyi-system/src/main/resources/mapper/chanting/PosStoreMapper.xml

@@ -173,4 +173,55 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
           AND  pf.name LIKE CONCAT('%', #{keyword}, '%') COLLATE utf8mb4_unicode_ci
         ORDER BY ps.id DESC
     </select>
+
+    <!-- 夜市附近摊位列表(含搜索、距离排序) -->
+    <select id="getNightMarketStalls" resultMap="PosStoreResult">
+        SELECT *,(st_distance(point(longitude,latitude),point(#{longitude},#{latitude}))*111195/1000) as juli
+        FROM pos_store
+        WHERE del_flag='0' AND off_shelf='0' AND is_stall=1 AND night_market_id=#{nightMarketId}
+        <if test="keyword != null and keyword != ''">
+            AND pos_name LIKE CONCAT('%', #{keyword}, '%')
+        </if>
+        ORDER BY juli ASC
+        LIMIT #{offset}, 20
+    </select>
+
+    <!-- 夜市摊位总数(含搜索) -->
+    <select id="getNightMarketStallsCount" resultType="int">
+        SELECT COUNT(*) FROM pos_store
+        WHERE del_flag='0' AND off_shelf='0' AND is_stall=1 AND night_market_id=#{nightMarketId}
+        <if test="keyword != null and keyword != ''">
+            AND pos_name LIKE CONCAT('%', #{keyword}, '%')
+        </if>
+    </select>
+
+    <!-- 夜市下用户关注的摊位 -->
+    <select id="getFollowedStalls" resultMap="PosStoreResult">
+        SELECT ps.*, (st_distance(point(ps.longitude,ps.latitude),point(#{longitude},#{latitude}))*111195/1000) as juli
+        FROM pos_store ps
+        INNER JOIN pos_collect pc ON ps.id = pc.md_id
+        WHERE ps.del_flag='0' AND ps.off_shelf='0' AND ps.is_stall=1
+          AND ps.night_market_id=#{nightMarketId}
+          AND pc.user_id=#{userId}
+        ORDER BY pc.cretim DESC
+        LIMIT #{offset}, 20
+    </select>
+
+    <!-- 夜市下用户常购的摊位(按近30天下单次数排序) -->
+    <select id="getFrequentStalls" resultMap="PosStoreResult">
+        SELECT ps.*, (st_distance(point(ps.longitude,ps.latitude),point(#{longitude},#{latitude}))*111195/1000) as juli
+        FROM pos_store ps
+        INNER JOIN (
+            SELECT md_id, COUNT(*) as order_count
+            FROM pos_order
+            WHERE user_id=#{userId}
+              AND DATE_FORMAT(cretim,'%Y-%m-%d') >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
+            GROUP BY md_id
+            ORDER BY order_count DESC
+        ) po ON ps.id = po.md_id
+        WHERE ps.del_flag='0' AND ps.off_shelf='0' AND ps.is_stall=1
+          AND ps.night_market_id=#{nightMarketId}
+        ORDER BY po.order_count DESC
+        LIMIT #{offset}, 20
+    </select>
 </mapper>

+ 0 - 0
specs/005-nightmarkt/spec.md


+ 1 - 0
sql/user_wallet_add_store_id.sql

@@ -0,0 +1 @@
+ALTER TABLE user_wallet ADD COLUMN store_id BIGINT DEFAULT NULL COMMENT '关联摊位id,非摊位钱包为NULL';