# 夜市用户端 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
