Kaynağa Gözat

新增客户端 api 模块接口第一批(22 个),独立 api 登录态

- 新增 cn.yoshop.api 包:auth(ApiTokenStore / ApiAuthInterceptor)、support(ApiContext / ApiView)、service 5 个、controller 6 个
- api 登录态独立前缀 yoshop:api:token:,拦截器只解析不强制,需要登录的接口由 ApiContext.requireUser() 抛 401
- 已实现:首页/店铺/地区/文章/商品/营销(优惠券)第一阶段的 22 个读接口
- WebMvcConfig 增加 /api/** 拦截器注册,application.yml 增加 api-token-prefix 与 ttl
- 新增验证脚本 tools/test_api_batch1.py(52 项断言,全部通过)
- 新增文档 接口实现进度-第10批-api.md,联调说明补充 api 章节
tanlie 4 saat önce
ebeveyn
işleme
6825ce3444
20 değiştirilmiş dosya ile 2565 ekleme ve 2 silme
  1. 265 0
      tools/test_api_batch1.py
  2. 62 0
      yoshop-java/src/main/java/cn/yoshop/api/auth/ApiAuthInterceptor.java
  3. 121 0
      yoshop-java/src/main/java/cn/yoshop/api/auth/ApiTokenStore.java
  4. 108 0
      yoshop-java/src/main/java/cn/yoshop/api/controller/ApiContentController.java
  5. 126 0
      yoshop-java/src/main/java/cn/yoshop/api/controller/ApiGoodsController.java
  6. 21 0
      yoshop-java/src/main/java/cn/yoshop/api/controller/ApiIndexController.java
  7. 118 0
      yoshop-java/src/main/java/cn/yoshop/api/controller/ApiMarketingController.java
  8. 38 0
      yoshop-java/src/main/java/cn/yoshop/api/controller/ApiRegionController.java
  9. 36 0
      yoshop-java/src/main/java/cn/yoshop/api/controller/ApiStoreController.java
  10. 247 0
      yoshop-java/src/main/java/cn/yoshop/api/service/ApiCommentService.java
  11. 355 0
      yoshop-java/src/main/java/cn/yoshop/api/service/ApiContentService.java
  12. 131 0
      yoshop-java/src/main/java/cn/yoshop/api/service/ApiCouponService.java
  13. 557 0
      yoshop-java/src/main/java/cn/yoshop/api/service/ApiGoodsService.java
  14. 134 0
      yoshop-java/src/main/java/cn/yoshop/api/service/ApiSettingService.java
  15. 73 0
      yoshop-java/src/main/java/cn/yoshop/api/support/ApiContext.java
  16. 74 0
      yoshop-java/src/main/java/cn/yoshop/api/support/ApiView.java
  17. 8 2
      yoshop-java/src/main/java/cn/yoshop/config/WebMvcConfig.java
  18. 3 0
      yoshop-java/src/main/resources/application.yml
  19. 77 0
      接口实现进度-第10批-api.md
  20. 11 0
      联调说明.md

+ 265 - 0
tools/test_api_batch1.py

@@ -0,0 +1,265 @@
+# -*- coding: utf-8 -*-
+"""客户端 api 第 1 批接口冒烟:只读接口结构 + 关键字段断言。
+
+用法:起 Java 18080 后执行
+    python tools/test_api_batch1.py
+环境变量:YOSHOP_API_BASE(默认 http://127.0.0.1:18080)、YOSHOP_TOKEN(已登录用户 token,可选)
+"""
+import json
+import os
+import re
+import sys
+
+import pymysql
+import requests
+
+from _root import ROOT
+
+BASE = os.environ.get("YOSHOP_API_BASE", "http://127.0.0.1:18080")
+TOKEN = os.environ.get("YOSHOP_TOKEN", "").strip()
+
+YML = str(ROOT / "yoshop-java/src/main/resources/application.yml")
+PWD = re.findall(r"password:\s*(.+)", open(YML, encoding="utf-8").read())[0].strip().strip("\"'")
+
+S = requests.Session()
+S.trust_env = False
+
+passed = 0
+failed = 0
+
+
+def check(name, cond, detail=""):
+    global passed, failed
+    if cond:
+        passed += 1
+        print(f"  PASS  {name}")
+    else:
+        failed += 1
+        print(f"  FAIL  {name}  {detail}")
+
+
+def get(path, params=None, token=None):
+    headers = {"platform": "MP-WEIXIN"}
+    if token:
+        headers["Access-Token"] = token
+    resp = S.get(BASE + path, params=params or {}, headers=headers, timeout=30)
+    try:
+        return resp.json()
+    except Exception:
+        return {"status": resp.status_code, "raw": resp.text[:200]}
+
+
+def post(path, body=None, token=None):
+    headers = {"platform": "MP-WEIXIN"}
+    if token:
+        headers["Access-Token"] = token
+    resp = S.post(BASE + path, json=body or {}, headers=headers, timeout=30)
+    try:
+        return resp.json()
+    except Exception:
+        return {"status": resp.status_code, "raw": resp.text[:200]}
+
+
+def snake_keys(obj, prefix="$"):
+    """收集所有 snake_case 键"""
+    found = []
+    if isinstance(obj, dict):
+        for k, v in obj.items():
+            if isinstance(k, str) and re.fullmatch(r"[a-z0-9]+(_[a-z0-9]+)+", k):
+                found.append(f"{prefix}.{k}")
+            found.extend(snake_keys(v, f"{prefix}.{k}"))
+    elif isinstance(obj, list):
+        for i, v in enumerate(obj[:3]):
+            found.extend(snake_keys(v, f"{prefix}[{i}]"))
+    return found
+
+
+def db(sql):
+    conn = pymysql.connect(host="192.168.1.177", port=3306, user="root", password=PWD,
+                           database="shop", charset="utf8mb4", autocommit=True)
+    try:
+        cur = conn.cursor()
+        cur.execute(sql)
+        return cur.fetchall()
+    finally:
+        conn.close()
+
+
+def main():
+    print("== 客户端 api 第 1 批(基础 / 内容 / 商品 / 营销 只读) ==")
+
+    # ---------- 基础信息
+    r = get("/api/store/data")
+    data = r.get("data") or {}
+    check("store/data status=200", r.get("status") == 200, r.get("message"))
+    check("store/data 含 storeInfo/client/setting/clientData",
+          all(k in data for k in ("storeInfo", "client", "setting", "clientData")), list(data))
+    check("store/data client=MP-WEIXIN", data.get("client") == "MP-WEIXIN", data.get("client"))
+    store_info = data.get("storeInfo") or {}
+    check("storeInfo 隐藏 sort/isRecycle/isDelete", not any(
+        k in store_info for k in ("sort", "isRecycle", "isDelete")), list(store_info))
+    check("storeInfo 含 imageUrl 键", "imageUrl" in store_info, list(store_info))
+    check("clientData.h5.setting 存在", "setting" in ((data.get("clientData") or {}).get("h5") or {}))
+
+    r = get("/api/setting/data")
+    setting = r.get("data") or {}
+    check("setting/data 六个公共项", set(setting) == {
+        "appTheme", "pageCategoryTemplate", "points", "recharge", "register", "customer"}, list(setting))
+    check("setting/data register 有 registerMethod",
+          "registerMethod" in (setting.get("register") or {}), setting.get("register"))
+
+    # ---------- 地区 / 分类 / 内容
+    r = get("/api/region/all")
+    region_all = (r.get("data") or {}).get("list") or {}
+    check("region/all 非空", len(region_all) > 3000, len(region_all))
+    r = get("/api/region/tree")
+    region_tree = (r.get("data") or {}).get("list") or {}
+    check("region/tree 省级 34 条", len(region_tree) >= 30, len(region_tree))
+
+    r = get("/api/category/list")
+    cats = (r.get("data") or {}).get("list") or []
+    check("category/list 是数组", isinstance(cats, list), type(cats))
+    check("category/list 有子分类或至少 1 条", len(cats) >= 1, len(cats))
+    if cats:
+        keys = set(cats[0])
+        check("分类隐藏 storeId/createTime/updateTime",
+              not ({"storeId", "createTime", "updateTime"} & keys), keys)
+
+    # 文章:库里现有文章已被软删除,临时插一条用于验证,跑完删除
+    article_id = None
+    try:
+        db("INSERT INTO yoshop_article (title, show_type, category_id, image_id, content, sort, status, "
+           "virtual_views, actual_views, is_delete, store_id, create_time, update_time) "
+           "VALUES ('api自测文章', 10, 0, 0, '<p>正文</p>', 100, 1, 0, 0, 0, 10001, "
+           "UNIX_TIMESTAMP(), UNIX_TIMESTAMP())")
+        article_id = db("SELECT MAX(article_id) FROM yoshop_article")[0][0]
+
+        r = get("/api/article.category/list")
+        check("article.category/list status=200", r.get("status") == 200, r.get("message"))
+        r = get("/api/article/list")
+        page_list = (r.get("data") or {}).get("list") or {}
+        check("article/list status=200 且分页结构", r.get("status") == 200
+              and all(k in page_list for k in ("total", "perPage", "currentPage", "lastPage")),
+              r.get("message"))
+        check("article/list 命中临时文章", any(a.get("articleId") == article_id for a in (page_list.get("data") or [])))
+        r = get("/api/article/detail", {"articleId": article_id})
+        article = (r.get("data") or {}).get("detail") or {}
+        check("article/detail 返回文章", article.get("articleId") == article_id,
+              article.get("message") or r.get("message"))
+        check("文章隐藏 virtualViews/actualViews",
+              "actualViews" not in article and "virtualViews" not in article)
+        check("文章 content 已反转义", "<" not in str(article.get("content")), article.get("content"))
+        views = db(f"SELECT actual_views FROM yoshop_article WHERE article_id={article_id}")[0][0]
+        check("文章阅读数 +1", views == 1, views)
+    finally:
+        if article_id:
+            db(f"DELETE FROM yoshop_article WHERE article_id={article_id}")
+
+    r = get("/api/help/list")
+    check("help/list status=200", r.get("status") == 200, r.get("message"))
+
+    # ---------- 页面
+    r = get("/api/page/detail")
+    page_data = (r.get("data") or {}).get("pageData") or {}
+    check("page/detail 默认首页有 page/items", "page" in page_data and "items" in page_data, list(page_data))
+    items = page_data.get("items") or []
+    check("页面组件已去掉 defaultData/demo",
+          all("defaultData" not in it and "demo" not in it for it in items))
+
+    # ---------- 商品
+    r = get("/api/goods/list")
+    page = (r.get("data") or {}).get("list") or {}
+    goods_list = page.get("data") or []
+    check("goods/list 有数据", len(goods_list) > 0, r.get("message"))
+    check("goods/list 分页 15", page.get("perPage") == 15, page.get("perPage"))
+    if goods_list:
+        g = goods_list[0]
+        check("列表隐藏 content/goodsImages/images",
+              not ({"content", "goodsImages", "images"} & set(g)), set(g) & {"content", "goodsImages", "images"})
+        check("列表含 goodsImage/goodsSales/isUserGrade",
+              all(k in g for k in ("goodsImage", "goodsSales", "isUserGrade")), list(g))
+
+    goods_ids = [g["goodsId"] for g in goods_list[:3]]
+    r = get("/api/goods/detail", {"goodsId": 10004})
+    detail = (r.get("data") or {}).get("detail") or {}
+    check("goods/detail 多规格含 skuList/specList",
+          bool(detail.get("skuList")) and bool(detail.get("specList")), list(detail))
+    check("详情隐藏 images", "images" not in detail, "images" in detail)
+    check("详情含 goodsImages", "goodsImages" in detail, list(detail))
+    check("详情 content 已反转义(不含 <)", "<" not in str(detail.get("content")))
+
+    r = get("/api/goods/detail", {"goodsId": 999999})
+    check("goods/detail 不存在时 status=500", r.get("status") == 500, r.get("status"))
+
+    r = get("/api/goods/basic", {"goodsId": 10003})
+    basic = (r.get("data") or {}).get("detail") or {}
+    check("goods/basic 不含 skuList/video", "skuList" not in basic and "video" not in basic, list(basic))
+    check("goods/basic 含 content", "content" in basic)
+
+    r = get("/api/goods/spec_data", {"goodsId": 10004})
+    spec = (r.get("data") or {}).get("specData") or {}
+    check("goods/spec_data 含 skuList/specList", bool(spec.get("skuList")) and bool(spec.get("specList")))
+    sku0 = (spec.get("skuList") or [{}])[0]
+    check("skuList 隐藏 image/storeId/createTime", not ({"image", "storeId", "createTime"} & set(sku0)), set(sku0))
+    check("skuList 含 imageUrl", "imageUrl" in sku0, list(sku0))
+
+    sku_id = sku0.get("goodsSkuId")
+    if sku_id:
+        r = get("/api/goods/sku_info", {"goodsId": 10004, "goodsSkuId": sku_id})
+        sku_info = (r.get("data") or {}).get("skuInfo") or {}
+        check("goods/sku_info 命中 SKU", sku_info.get("goodsSkuId") == sku_id, sku_info)
+
+    r = get("/api/goods.service/list", {"goodsId": 10004})
+    check("goods.service/list status=200", r.get("status") == 200, r.get("message"))
+    services = (r.get("data") or {}).get("list") or []
+    if services:
+        check("服务承诺隐藏 status/sort/isDefault",
+              not ({"status", "sort", "isDefault"} & set(services[0])), set(services[0]))
+
+    # ---------- 评价 / 优惠券 / 快递
+    for path, key in [("/api/comment/list", "list"), ("/api/comment/list_rows", "list")]:
+        r = get(path, {"goodsId": 10003})
+        check(f"{path} status=200", r.get("status") == 200, r.get("message"))
+    r = get("/api/comment/total", {"goodsId": 10003})
+    total = r.get("data") or {}
+    check("comment/total 四个统计项", set(total) == {"all", "praise", "review", "negative"}, total)
+
+    r = get("/api/coupon/list")
+    coupons = (r.get("data") or {}).get("list") or []
+    check("coupon/list 有数据", len(coupons) >= 1, r.get("message"))
+    if coupons:
+        c = coupons[0]
+        check("优惠券隐藏 totalNum/receiveNum/status",
+              not ({"totalNum", "receiveNum", "status"} & set(c)), set(c))
+        check("未登录不返回 isReceive", "isReceive" not in c, list(c))
+
+    r = get("/api/express/list")
+    express = (r.get("data") or {}).get("list") or []
+    check("express/list 有数据", len(express) > 0, r.get("message"))
+    if express:
+        check("快递隐藏 kuaidi100Code/kdniaoCode",
+              not ({"kuaidi100Code", "kdniaoCode"} & set(express[0])), set(express[0]))
+
+    # ---------- 登录态相关(带 token 才有 isReceive / 会员价)
+    if TOKEN:
+        r = get("/api/coupon/list", token=TOKEN)
+        coupons = (r.get("data") or {}).get("list") or []
+        if coupons:
+            check("登录后 coupon/list 返回 isReceive", "isReceive" in coupons[0], list(coupons[0]))
+    else:
+        print("  SKIP  带 token 的 isReceive 检查(未设置 YOSHOP_TOKEN)")
+
+    # ---------- 驼峰检查
+    for path, params in [("/api/goods/list", {}), ("/api/store/data", {}),
+                         ("/api/goods/detail", {"goodsId": 10004}),
+                         ("/api/category/list", {})]:
+        r = get(path, params)
+        bad = snake_keys(r.get("data"))
+        check(f"{path} 无 snake_case 键", not bad, bad[:5])
+
+    print(f"\n结果:{passed} passed, {failed} failed")
+    return 0 if failed == 0 else 1
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 62 - 0
yoshop-java/src/main/java/cn/yoshop/api/auth/ApiAuthInterceptor.java

@@ -0,0 +1,62 @@
+package cn.yoshop.api.auth;
+
+import cn.yoshop.common.entity.User;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.stereotype.Component;
+import org.springframework.web.servlet.HandlerInterceptor;
+
+/**
+ * 客户端登录态解析拦截器(只解析,不拦截)。
+ *
+ * <p>对齐 PHP api 模块的登录校验方式:基类 initialize() 只校验 storeId / 商城状态 / 客户端开关,
+ * <b>登录校验由各接口自行决定</b>(getLoginUser(true) 才强制)。因此这里不加白名单也不返回 401,
+ * 只把 token 能解析出的用户放进 request attribute:
+ * <ul>
+ *   <li>{@code loginUser}:{@link User},未登录为 null</li>
+ *   <li>{@code apiToken}:原始 token(可能为 null)</li>
+ * </ul>
+ *
+ * 需要强制登录的接口调用 {@code ApiContext.requireUser(request)},未登录时抛 401。
+ */
+@Component
+public class ApiAuthInterceptor implements HandlerInterceptor {
+
+    public static final String ATTR_LOGIN_USER = "loginUser";
+    public static final String ATTR_API_TOKEN = "apiToken";
+
+    private final ApiTokenStore tokenStore;
+
+    public ApiAuthInterceptor(ApiTokenStore tokenStore) {
+        this.tokenStore = tokenStore;
+    }
+
+    @Override
+    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
+        // 预检请求不解析
+        if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
+            return true;
+        }
+        String token = request.getHeader("Access-Token");
+        if (token == null || token.isBlank()) {
+            // 调试便利:PHP 在 debug 模式下也允许从 param 取
+            token = request.getParameter("Access-Token");
+        }
+        if (token == null || token.isBlank()) {
+            request.setAttribute(ATTR_LOGIN_USER, null);
+            request.setAttribute(ATTR_API_TOKEN, null);
+            return true;
+        }
+        User user;
+        try {
+            user = tokenStore.getUser(token);
+        } catch (Exception e) {
+            // Redis 不可用时不阻断请求:匿名接口照常返回,需要登录的接口由 requireUser 抛 401
+            user = null;
+        }
+        request.setAttribute(ATTR_LOGIN_USER, user);
+        request.setAttribute(ATTR_API_TOKEN, token);
+        return true;
+    }
+}

+ 121 - 0
yoshop-java/src/main/java/cn/yoshop/api/auth/ApiTokenStore.java

@@ -0,0 +1,121 @@
+package cn.yoshop.api.auth;
+
+import cn.yoshop.common.entity.User;
+import cn.yoshop.common.mapper.UserMapper;
+
+import cn.yoshop.utils.RedisUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+/**
+ * 客户端(小程序 / H5)登录态存储(Redis)。
+ *
+ * <p>与商家端、平台端同构,但<b>只缓存用户 ID</b>:
+ * <ul>
+ *   <li>key = {@code ${yoshop.auth.api-token-prefix} + token},默认 {@code yoshop:api:token:{token}}</li>
+ *   <li>value = 用户 ID 的十进制字符串</li>
+ *   <li>有效期默认 7 天({@code ${yoshop.auth.api-token-ttl}})</li>
+ * </ul>
+ *
+ * <p>为什么不像后台那样缓存整份用户 JSON:客户端会频繁变动余额、积分、成长值、登录时间,
+ * 缓存快照很容易与库内不一致(下单成功、充值到账后立刻展示旧数据)。
+ * 这里按 PHP 的做法只存 ID,业务接口再用 {@link UserMapper} 回查,代价是一次主键查询。
+ */
+@Component
+public class ApiTokenStore {
+
+    private static final Logger log = LoggerFactory.getLogger(ApiTokenStore.class);
+
+    private final RedisUtils redis;
+    private final UserMapper userMapper;
+
+    @Value("${yoshop.auth.api-token-prefix:yoshop:api:token:}")
+    private String prefix;
+
+    @Value("${yoshop.auth.api-token-ttl:604800}")
+    private long defaultTtlSeconds;
+
+    public ApiTokenStore(RedisUtils redis, UserMapper userMapper) {
+        this.redis = redis;
+        this.userMapper = userMapper;
+    }
+
+    /** 写入登录态(有效期取配置,默认 7 天) */
+    public void save(String token, int userId) {
+        save(token, userId, defaultTtlSeconds);
+    }
+
+    /** 写入登录态;ttlSeconds <= 0 表示不设过期 */
+    public void save(String token, int userId, long ttlSeconds) {
+        if (token == null || token.isBlank()) {
+            return;
+        }
+        redis.setString(key(token), String.valueOf(userId), ttlSeconds);
+    }
+
+    /** 按 token 取用户 ID;token 为空、键不存在或内容损坏返回 null */
+    public Integer getUserId(String token) {
+        if (token == null || token.isBlank()) {
+            return null;
+        }
+        try {
+            String value = redis.getString(key(token));
+            if (value == null || value.isBlank()) {
+                return null;
+            }
+            return Integer.valueOf(value.trim());
+        } catch (NumberFormatException e) {
+            log.warn("登录态内容非法 token={} : {}", token, e.getMessage());
+            return null;
+        } catch (Exception e) {
+            log.warn("读取登录态失败 token={} : {}", token, e.getMessage());
+            return null;
+        }
+    }
+
+    /**
+     * 按 token 取用户记录。用户被软删除时返回 null(按未登录处理,对齐 PHP getUserByToken)。
+     */
+    public User getUser(String token) {
+        Integer userId = getUserId(token);
+        if (userId == null) {
+            return null;
+        }
+        User user = userMapper.selectById(userId);
+        if (user == null || Integer.valueOf(1).equals(user.getIsDelete())) {
+            return null;
+        }
+        return user;
+    }
+
+    /** 删除登录态(退出登录) */
+    public void delete(String token) {
+        if (token == null || token.isBlank()) {
+            return;
+        }
+        try {
+            redis.deleteString(key(token));
+        } catch (Exception e) {
+            log.warn("清除登录态失败 token={} : {}", token, e.getMessage());
+        }
+    }
+
+    /** 剩余有效期(秒);-2 键不存在,-1 无过期时间 */
+    public long remainingTtlSeconds(String token) {
+        if (token == null || token.isBlank()) {
+            return -2L;
+        }
+        try {
+            Long ttl = redis.ttl(key(token));
+            return ttl == null ? -2L : ttl;
+        } catch (Exception e) {
+            return -2L;
+        }
+    }
+
+    private String key(String token) {
+        return prefix + token;
+    }
+}

+ 108 - 0
yoshop-java/src/main/java/cn/yoshop/api/controller/ApiContentController.java

@@ -0,0 +1,108 @@
+package cn.yoshop.api.controller;
+
+import cn.yoshop.api.service.ApiContentService;
+import cn.yoshop.common.api.R;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * 商品分类、文章、帮助中心、DIY 页面,对齐 PHP app/api/controller/{Category, Article, Help, Page}
+ * 与 app/api/controller/article/Category。
+ */
+@RestController
+@RequestMapping("/api")
+@RequiredArgsConstructor
+public class ApiContentController {
+
+    private final ApiContentService apiContentService;
+
+    /** /api/category/list 商品分类树 */
+    @RequestMapping(value = "/category/list", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Map<String, Object>> categoryList() {
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("list", apiContentService.categoryList());
+        return R.ok(data);
+    }
+
+    /** /api/article/list 文章列表(分页 15) */
+    @RequestMapping(value = "/article/list", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Map<String, Object>> articleList(@org.springframework.web.bind.annotation.RequestBody(required = false)
+                                              Map<String, Object> body,
+                                              jakarta.servlet.http.HttpServletRequest request) {
+        Map<String, Object> params = cn.yoshop.api.support.ApiContext.params(request, body);
+        Integer categoryId = intOrNull(params.get("categoryId"));
+        int page = intVal(params.get("page"), 1);
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("list", apiContentService.articleList(categoryId, page));
+        return R.ok(data);
+    }
+
+    /** /api/article/detail 文章详情(阅读数 +1) */
+    @RequestMapping(value = "/article/detail", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Map<String, Object>> articleDetail(@org.springframework.web.bind.annotation.RequestBody(required = false)
+                                                Map<String, Object> body,
+                                                jakarta.servlet.http.HttpServletRequest request) {
+        Map<String, Object> params = cn.yoshop.api.support.ApiContext.params(request, body);
+        Integer articleId = intOrNull(params.get("articleId"));
+        if (articleId == null) {
+            throw cn.yoshop.common.exception.BizException.silent("参数错误:articleId");
+        }
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("detail", apiContentService.articleDetail(articleId));
+        return R.ok(data);
+    }
+
+    /** /api/article.category/list 文章分类 */
+    @RequestMapping(value = "/article.category/list", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Map<String, Object>> articleCategoryList() {
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("list", apiContentService.articleCategoryList());
+        return R.ok(data);
+    }
+
+    /** /api/help/list 帮助中心(分页 15) */
+    @RequestMapping(value = "/help/list", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Map<String, Object>> helpList(@org.springframework.web.bind.annotation.RequestBody(required = false)
+                                           Map<String, Object> body,
+                                           jakarta.servlet.http.HttpServletRequest request) {
+        Map<String, Object> params = cn.yoshop.api.support.ApiContext.params(request, body);
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("list", apiContentService.helpList(intVal(params.get("page"), 1)));
+        return R.ok(data);
+    }
+
+    /** /api/page/detail DIY 页面数据(不传 pageId 取首页) */
+    @RequestMapping(value = "/page/detail", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Map<String, Object>> pageDetail(@org.springframework.web.bind.annotation.RequestBody(required = false)
+                                             Map<String, Object> body,
+                                             jakarta.servlet.http.HttpServletRequest request) {
+        Map<String, Object> params = cn.yoshop.api.support.ApiContext.params(request, body);
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("pageData", apiContentService.pageDetail(intOrNull(params.get("pageId")),
+                cn.yoshop.api.support.ApiContext.user(request)));
+        return R.ok(data);
+    }
+
+    private static Integer intOrNull(Object value) {
+        if (value == null || String.valueOf(value).isBlank()) {
+            return null;
+        }
+        try {
+            return (int) Double.parseDouble(String.valueOf(value));
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+
+    private static int intVal(Object value, int defaultValue) {
+        Integer v = intOrNull(value);
+        return v == null || v <= 0 ? defaultValue : v;
+    }
+}

+ 126 - 0
yoshop-java/src/main/java/cn/yoshop/api/controller/ApiGoodsController.java

@@ -0,0 +1,126 @@
+package cn.yoshop.api.controller;
+
+import cn.yoshop.api.service.ApiGoodsService;
+import cn.yoshop.api.support.ApiContext;
+import cn.yoshop.common.api.R;
+import cn.yoshop.common.entity.User;
+import cn.yoshop.common.exception.BizException;
+import cn.yoshop.common.page.PageVO;
+
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 客户端商品接口,对齐 PHP app/api/controller/Goods(/api/goods/**)
+ * 与 app/api/controller/goods/Service(/api/goods.service/list)。
+ */
+@RestController
+@RequestMapping("/api")
+@RequiredArgsConstructor
+public class ApiGoodsController {
+
+    private final ApiGoodsService apiGoodsService;
+
+    /** 商品列表(固定只返回上架商品,分页 15) */
+    @RequestMapping(value = "/goods/list", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Map<String, Object>> list(@RequestBody(required = false) Map<String, Object> body,
+                                       HttpServletRequest request) {
+        Map<String, Object> params = ApiContext.params(request, body);
+        PageVO<ObjectNode> page = apiGoodsService.getList(params, ApiContext.user(request));
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("list", page);
+        return R.ok(data);
+    }
+
+    /** 商品详情(含 SKU / 规格 / 视频) */
+    @RequestMapping(value = "/goods/detail", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Map<String, Object>> detail(@RequestBody(required = false) Map<String, Object> body,
+                                         HttpServletRequest request) {
+        Map<String, Object> params = ApiContext.params(request, body);
+        int goodsId = requiredInt(params, "goodsId");
+        boolean verifyStatus = boolVal(params.get("verifyStatus"), true);
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("detail", apiGoodsService.getDetail(goodsId, verifyStatus, ApiContext.user(request)));
+        return R.ok(data);
+    }
+
+    /** 商品基础信息(仅主信息 + 图片) */
+    @RequestMapping(value = "/goods/basic", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Map<String, Object>> basic(@RequestBody(required = false) Map<String, Object> body,
+                                        HttpServletRequest request) {
+        Map<String, Object> params = ApiContext.params(request, body);
+        int goodsId = requiredInt(params, "goodsId");
+        boolean verifyStatus = boolVal(params.get("verifyStatus"), true);
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("detail", apiGoodsService.getBasic(goodsId, verifyStatus, ApiContext.user(request)));
+        return R.ok(data);
+    }
+
+    /** 规格数据:{skuList, specList} */
+    @RequestMapping(value = "/goods/spec_data", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Map<String, Object>> specData(@RequestBody(required = false) Map<String, Object> body,
+                                           HttpServletRequest request) {
+        Map<String, Object> params = ApiContext.params(request, body);
+        int goodsId = requiredInt(params, "goodsId");
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("specData", apiGoodsService.getSpecData(goodsId));
+        return R.ok(data);
+    }
+
+    /** 指定 SKU 信息 */
+    @RequestMapping(value = "/goods/sku_info", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Map<String, Object>> skuInfo(@RequestBody(required = false) Map<String, Object> body,
+                                          HttpServletRequest request) {
+        Map<String, Object> params = ApiContext.params(request, body);
+        int goodsId = requiredInt(params, "goodsId");
+        Object goodsSkuId = params.get("goodsSkuId");
+        if (goodsSkuId == null || String.valueOf(goodsSkuId).isBlank()) {
+            throw BizException.silent("参数错误:goodsSkuId");
+        }
+        User user = ApiContext.user(request);
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("skuInfo", apiGoodsService.getSkuInfo(goodsId, String.valueOf(goodsSkuId), user));
+        return R.ok(data);
+    }
+
+    /** 商品服务与承诺 */
+    @RequestMapping(value = "/goods.service/list", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Map<String, Object>> serviceList(@RequestBody(required = false) Map<String, Object> body,
+                                              HttpServletRequest request) {
+        Map<String, Object> params = ApiContext.params(request, body);
+        int goodsId = requiredInt(params, "goodsId");
+        List<ObjectNode> list = apiGoodsService.getServiceList(goodsId);
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("list", list);
+        return R.ok(data);
+    }
+
+    private static int requiredInt(Map<String, Object> params, String key) {
+        Object value = params.get(key);
+        if (value == null || String.valueOf(value).isBlank()) {
+            throw BizException.silent("参数错误:" + key);
+        }
+        try {
+            return (int) Double.parseDouble(String.valueOf(value));
+        } catch (NumberFormatException e) {
+            throw BizException.silent("参数错误:" + key);
+        }
+    }
+
+    private static boolean boolVal(Object value, boolean defaultValue) {
+        if (value == null || String.valueOf(value).isBlank()) {
+            return defaultValue;
+        }
+        String s = String.valueOf(value);
+        return !("false".equalsIgnoreCase(s) || "0".equals(s) || "off".equalsIgnoreCase(s));
+    }
+}

+ 21 - 0
yoshop-java/src/main/java/cn/yoshop/api/controller/ApiIndexController.java

@@ -0,0 +1,21 @@
+package cn.yoshop.api.controller;
+
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 客户端默认控制器,对齐 PHP app/api/controller/Index::index。
+ *
+ * <p>PHP 是一个纯文本提示(不是 JSON),H5 站点由 index.html 兜底,这里保持一致。
+ */
+@RestController
+@RequestMapping("/api/index")
+public class ApiIndexController {
+
+    @RequestMapping(value = "/index", method = {RequestMethod.GET, RequestMethod.POST},
+            produces = "text/plain;charset=UTF-8")
+    public String index() {
+        return "当前访问的index.php,请将index.html设为默认站点入口";
+    }
+}

+ 118 - 0
yoshop-java/src/main/java/cn/yoshop/api/controller/ApiMarketingController.java

@@ -0,0 +1,118 @@
+package cn.yoshop.api.controller;
+
+import cn.yoshop.api.service.ApiCommentService;
+import cn.yoshop.api.service.ApiCouponService;
+import cn.yoshop.api.support.ApiContext;
+import cn.yoshop.common.api.R;
+import cn.yoshop.common.entity.Express;
+import cn.yoshop.common.exception.BizException;
+import cn.yoshop.common.mapper.ExpressMapper;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 客户端评价、优惠券、物流公司,对齐 PHP app/api/controller/{Comment, Coupon, Express}。
+ */
+@RestController
+@RequestMapping("/api")
+@RequiredArgsConstructor
+public class ApiMarketingController {
+
+    private final ApiCommentService apiCommentService;
+    private final ApiCouponService apiCouponService;
+    private final ExpressMapper expressMapper;
+    private final cn.yoshop.api.support.ApiView view;
+
+    /** /api/comment/list 商品评价(分页 15,scoreType 10/20/30) */
+    @RequestMapping(value = "/comment/list", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Map<String, Object>> commentList(@RequestBody(required = false) Map<String, Object> body,
+                                              HttpServletRequest request) {
+        Map<String, Object> params = ApiContext.params(request, body);
+        int goodsId = requiredInt(params, "goodsId");
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("list", apiCommentService.list(goodsId, intOrNull(params.get("scoreType")),
+                intVal(params.get("page"), 1)));
+        return R.ok(data);
+    }
+
+    /** /api/comment/total 评价统计 {all, praise, review, negative} */
+    @RequestMapping(value = "/comment/total", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Map<String, Object>> commentTotal(@RequestBody(required = false) Map<String, Object> body,
+                                               HttpServletRequest request) {
+        Map<String, Object> params = ApiContext.params(request, body);
+        return R.ok(apiCommentService.total(requiredInt(params, "goodsId")));
+    }
+
+    /** /api/comment/list_rows 限条数评价(默认 5 条) */
+    @RequestMapping(value = "/comment/list_rows", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Map<String, Object>> commentListRows(@RequestBody(required = false) Map<String, Object> body,
+                                                  HttpServletRequest request) {
+        Map<String, Object> params = ApiContext.params(request, body);
+        int goodsId = requiredInt(params, "goodsId");
+        int limit = intVal(params.get("limit"), 5);
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("list", apiCommentService.listRows(goodsId, limit));
+        return R.ok(data);
+    }
+
+    /** /api/coupon/list 优惠券中心 */
+    @RequestMapping(value = "/coupon/list", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Map<String, Object>> couponList(HttpServletRequest request) {
+        List<ObjectNode> list = apiCouponService.list(null, false, null, List.of(), ApiContext.user(request));
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("list", list);
+        return R.ok(data);
+    }
+
+    /** /api/express/list 物流公司(隐藏 kuaidi100Code / kdniaoCode / sort) */
+    @RequestMapping(value = "/express/list", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Map<String, Object>> expressList() {
+        List<Express> list = expressMapper.selectList(Wrappers.<Express>lambdaQuery()
+                .orderByAsc(Express::getSort)
+                .orderByAsc(Express::getExpressId));
+        List<ObjectNode> nodes = new ArrayList<>();
+        for (Express express : list) {
+            nodes.add(view.hide(view.of(express),
+                    "kuaidi100Code", "kdniaoCode", "sort", "storeId", "isDelete", "createTime", "updateTime"));
+        }
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("list", nodes);
+        return R.ok(data);
+    }
+
+    private static int requiredInt(Map<String, Object> params, String key) {
+        Integer value = intOrNull(params.get(key));
+        if (value == null) {
+            throw BizException.silent("参数错误:" + key);
+        }
+        return value;
+    }
+
+    private static Integer intOrNull(Object value) {
+        if (value == null || String.valueOf(value).isBlank()) {
+            return null;
+        }
+        try {
+            return (int) Double.parseDouble(String.valueOf(value));
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+
+    private static int intVal(Object value, int defaultValue) {
+        Integer v = intOrNull(value);
+        return v == null || v <= 0 ? defaultValue : v;
+    }
+}

+ 38 - 0
yoshop-java/src/main/java/cn/yoshop/api/controller/ApiRegionController.java

@@ -0,0 +1,38 @@
+package cn.yoshop.api.controller;
+
+import cn.yoshop.common.api.R;
+import cn.yoshop.store.service.RegionService;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * 地区数据,对齐 PHP app/api/controller/Region(all / tree)。
+ * 出参结构与商家端一致(键为地区 ID 的字符串)。
+ */
+@RestController
+@RequestMapping("/api/region")
+@RequiredArgsConstructor
+public class ApiRegionController {
+
+    private final RegionService regionService;
+
+    @RequestMapping(value = "/all", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Map<String, Object>> all() {
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("list", regionService.getAll());
+        return R.ok(data);
+    }
+
+    @RequestMapping(value = "/tree", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Map<String, Object>> tree() {
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("list", regionService.getTree());
+        return R.ok(data);
+    }
+}

+ 36 - 0
yoshop-java/src/main/java/cn/yoshop/api/controller/ApiStoreController.java

@@ -0,0 +1,36 @@
+package cn.yoshop.api.controller;
+
+import cn.yoshop.api.service.ApiSettingService;
+import cn.yoshop.api.support.ApiContext;
+import cn.yoshop.common.api.R;
+
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Map;
+
+/**
+ * 商城基础信息与公共设置,对齐 PHP app/api/controller/Store::data、Setting::data。
+ */
+@RestController
+@RequestMapping("/api")
+@RequiredArgsConstructor
+public class ApiStoreController {
+
+    private final ApiSettingService apiSettingService;
+
+    /** /api/store/data:{storeInfo, client, setting, clientData} */
+    @RequestMapping(value = "/store/data", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Map<String, Object>> storeData(HttpServletRequest request) {
+        return R.ok(apiSettingService.storeData(ApiContext.platform(request)));
+    }
+
+    /** /api/setting/data:仅暴露可公开的设置项 */
+    @RequestMapping(value = "/setting/data", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Map<String, Object>> settingData() {
+        return R.ok(apiSettingService.settingData());
+    }
+}

+ 247 - 0
yoshop-java/src/main/java/cn/yoshop/api/service/ApiCommentService.java

@@ -0,0 +1,247 @@
+package cn.yoshop.api.service;
+
+import cn.yoshop.api.support.ApiView;
+import cn.yoshop.common.entity.Comment;
+import cn.yoshop.common.entity.CommentImage;
+import cn.yoshop.common.entity.OrderGoods;
+import cn.yoshop.common.entity.UploadFile;
+import cn.yoshop.common.entity.User;
+import cn.yoshop.common.mapper.CommentImageMapper;
+import cn.yoshop.common.mapper.CommentMapper;
+import cn.yoshop.common.mapper.OrderGoodsMapper;
+import cn.yoshop.common.mapper.UploadFileMapper;
+import cn.yoshop.common.mapper.UserMapper;
+import cn.yoshop.common.page.PageVO;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+/**
+ * 客户端商品评价,对齐 PHP app/api/model/Comment:
+ * getCommentList(分页 15,with user.avatar / orderGoods / images.file)、
+ * listRows(限条数,with user.avatar)、rowsTotal / getTotal(按评分统计)。
+ *
+ * <p>隐藏字段(camelCase):userId / status / sort / orderId / goodsId / orderGoodsId / storeId / isDelete / updateTime。
+ */
+@Service
+@RequiredArgsConstructor
+public class ApiCommentService {
+
+    private static final String[] HIDDEN = {
+            "userId", "status", "sort", "orderId", "goodsId", "orderGoodsId",
+            "storeId", "isDelete", "updateTime"
+    };
+
+    private final CommentMapper commentMapper;
+    private final CommentImageMapper commentImageMapper;
+    private final OrderGoodsMapper orderGoodsMapper;
+    private final UserMapper userMapper;
+    private final UploadFileMapper uploadFileMapper;
+    private final ApiView view;
+
+    @Value("${yoshop.base-url}")
+    private String baseUrl;
+
+    /** 商品评价列表(分页 15),scoreType 10好评 / 20中评 / 30差评 */
+    public PageVO<ObjectNode> list(int goodsId, Integer scoreType, int page) {
+        QueryWrapper<Comment> qw = filter(goodsId, scoreType);
+        qw.orderByAsc("sort");
+        qw.orderByDesc("create_time");
+        Page<Comment> result = commentMapper.selectPage(new Page<>(page, 15), qw);
+        List<Comment> records = result.getRecords();
+        assemble(records, true);
+        PageVO<ObjectNode> vo = new PageVO<>();
+        vo.setTotal(result.getTotal());
+        vo.setPerPage(result.getSize());
+        vo.setCurrentPage(result.getCurrent());
+        vo.setLastPage(result.getPages());
+        vo.setData(nodes(records, true));
+        return vo;
+    }
+
+    /** 限制条数的评价列表(商品详情的“评价”区块),只带 user.avatar */
+    public List<ObjectNode> listRows(int goodsId, int limit) {
+        QueryWrapper<Comment> qw = filter(goodsId, null);
+        qw.orderByAsc("sort");
+        qw.orderByAsc("comment_id");
+        qw.last("limit " + Math.max(limit, 1));
+        List<Comment> records = commentMapper.selectList(qw);
+        assemble(records, false);
+        return nodes(records, false);
+    }
+
+    /** 评价总数与各评分数量:{all, praise, review, negative} */
+    public Map<String, Object> total(int goodsId) {
+        long all = commentMapper.selectCount(Wrappers.<Comment>lambdaQuery()
+                .eq(Comment::getGoodsId, goodsId)
+                .eq(Comment::getStatus, 1));
+        long praise = countByScore(goodsId, 10);
+        long review = countByScore(goodsId, 20);
+        long negative = countByScore(goodsId, 30);
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("all", all);
+        data.put("praise", praise);
+        data.put("review", review);
+        data.put("negative", negative);
+        return data;
+    }
+
+    // ------------------------------------------------------------------ 内部
+
+    private long countByScore(int goodsId, int score) {
+        return commentMapper.selectCount(Wrappers.<Comment>lambdaQuery()
+                .eq(Comment::getGoodsId, goodsId)
+                .eq(Comment::getScore, score)
+                .eq(Comment::getStatus, 1));
+    }
+
+    private QueryWrapper<Comment> filter(int goodsId, Integer scoreType) {
+        QueryWrapper<Comment> qw = new QueryWrapper<>();
+        qw.eq("goods_id", goodsId);
+        qw.eq("status", 1);
+        if (scoreType != null && scoreType > 0) {
+            qw.eq("score", scoreType);
+        }
+        return qw;
+    }
+
+    /** 装配 user / orderGoods / images(withImages=true 时加载评价图片) */
+    private void assemble(List<Comment> records, boolean withImages) {
+        if (records == null || records.isEmpty()) {
+            return;
+        }
+        List<Integer> userIds = records.stream().map(Comment::getUserId)
+                .filter(id -> id != null && id > 0).distinct().toList();
+        Map<Integer, User> users = userIds.isEmpty() ? Collections.emptyMap()
+                : userMapper.selectBatchIds(userIds).stream()
+                .collect(Collectors.toMap(User::getUserId, Function.identity(), (a, b) -> a));
+        fillAvatar(new ArrayList<>(users.values()));
+
+        List<Integer> orderGoodsIds = records.stream().map(Comment::getOrderGoodsId)
+                .filter(id -> id != null && id > 0).distinct().toList();
+        Map<Integer, OrderGoods> goodsMap = orderGoodsIds.isEmpty() ? Collections.emptyMap()
+                : orderGoodsMapper.selectBatchIds(orderGoodsIds).stream()
+                .collect(Collectors.toMap(OrderGoods::getOrderGoodsId, Function.identity(), (a, b) -> a));
+        fillGoodsImage(new ArrayList<>(goodsMap.values()));
+
+        Map<Integer, List<CommentImage>> imagesByComment = withImages
+                ? loadImages(records) : Collections.emptyMap();
+
+        records.forEach(c -> {
+            c.setUser(userNode(users.get(c.getUserId())));
+            c.setOrderGoods(orderGoodsNode(goodsMap.get(c.getOrderGoodsId())));
+            if (withImages) {
+                c.setImages(imagesByComment.getOrDefault(c.getCommentId(), List.of()));
+            }
+        });
+    }
+
+    private void fillAvatar(List<User> users) {
+        if (users.isEmpty()) {
+            return;
+        }
+        List<Integer> avatarIds = users.stream().map(User::getAvatarId)
+                .filter(id -> id != null && id > 0).distinct().toList();
+        if (avatarIds.isEmpty()) {
+            return;
+        }
+        Map<Integer, UploadFile> files = uploadFileMapper.selectBatchIds(avatarIds).stream()
+                .peek(f -> f.setBaseUrl(baseUrl))
+                .collect(Collectors.toMap(UploadFile::getFileId, Function.identity()));
+        users.forEach(u -> u.setAvatar(files.get(u.getAvatarId())));
+    }
+
+    private void fillGoodsImage(List<OrderGoods> goodsList) {
+        if (goodsList.isEmpty()) {
+            return;
+        }
+        List<Integer> imageIds = goodsList.stream().map(OrderGoods::getImageId)
+                .filter(id -> id != null && id > 0).distinct().toList();
+        if (imageIds.isEmpty()) {
+            return;
+        }
+        Map<Integer, UploadFile> files = uploadFileMapper.selectBatchIds(imageIds).stream()
+                .peek(f -> f.setBaseUrl(baseUrl))
+                .collect(Collectors.toMap(UploadFile::getFileId, Function.identity()));
+        goodsList.forEach(g -> g.setImage(files.get(g.getImageId())));
+    }
+
+    private Map<Integer, List<CommentImage>> loadImages(List<Comment> records) {
+        List<Integer> commentIds = records.stream().map(Comment::getCommentId).toList();
+        List<CommentImage> images = commentImageMapper.selectList(Wrappers.<CommentImage>lambdaQuery()
+                .in(CommentImage::getCommentId, commentIds)
+                .orderByAsc(CommentImage::getId));
+        if (images.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        List<Integer> fileIds = images.stream().map(CommentImage::getImageId)
+                .filter(Objects::nonNull).distinct().toList();
+        Map<Integer, UploadFile> files = fileIds.isEmpty() ? Collections.emptyMap()
+                : uploadFileMapper.selectBatchIds(fileIds).stream()
+                .peek(f -> f.setBaseUrl(baseUrl))
+                .collect(Collectors.toMap(UploadFile::getFileId, Function.identity()));
+        images.forEach(img -> img.setFile(files.get(img.getImageId())));
+        return images.stream().collect(Collectors.groupingBy(CommentImage::getCommentId));
+    }
+
+    /** 买家信息:PHP 关联 user.avatar,隐藏 isDelete / storeId / createTime / updateTime,手机号打码 */
+    private Map<String, Object> userNode(User user) {
+        if (user == null) {
+            return null;
+        }
+        ObjectNode node = view.of(user);
+        view.hide(node, "isDelete", "storeId", "createTime", "updateTime");
+        node.put("mobile", maskMobile(user.getMobile()));
+        return view.mapper().convertValue(node, new com.fasterxml.jackson.core.type.TypeReference<LinkedHashMap<String, Object>>() {
+        });
+    }
+
+    /** 订单商品:PHP api\model\OrderGoods 隐藏 image / content / storeId / createTime,绑定 goodsImage */
+    private Map<String, Object> orderGoodsNode(OrderGoods goods) {
+        if (goods == null) {
+            return null;
+        }
+        ObjectNode node = view.of(goods);
+        view.hide(node, "image", "content", "storeId", "createTime");
+        String imageUrl = goods.getImage() == null ? null : goods.getImage().getPreviewUrl();
+        node.put("goodsImage", imageUrl);
+        return view.mapper().convertValue(node, new com.fasterxml.jackson.core.type.TypeReference<LinkedHashMap<String, Object>>() {
+        });
+    }
+
+    private List<ObjectNode> nodes(List<Comment> records, boolean withImages) {
+        List<ObjectNode> nodes = new ArrayList<>();
+        for (Comment comment : records) {
+            ObjectNode node = view.of(comment);
+            view.hide(node, HIDDEN);
+            if (!withImages) {
+                node.remove("images");
+            }
+            nodes.add(node);
+        }
+        return nodes;
+    }
+
+    /** 手机号中间四位打码,对齐 PHP hide_mobile */
+    private static String maskMobile(String mobile) {
+        if (mobile == null || mobile.length() != 11) {
+            return mobile;
+        }
+        return mobile.substring(0, 3) + "****" + mobile.substring(7);
+    }
+}

+ 355 - 0
yoshop-java/src/main/java/cn/yoshop/api/service/ApiContentService.java

@@ -0,0 +1,355 @@
+package cn.yoshop.api.service;
+
+import cn.yoshop.api.support.ApiContext;
+import cn.yoshop.api.support.ApiView;
+import cn.yoshop.common.entity.Article;
+import cn.yoshop.common.entity.ArticleCategory;
+import cn.yoshop.common.entity.Category;
+import cn.yoshop.common.entity.Help;
+import cn.yoshop.common.entity.PageEntity;
+import cn.yoshop.common.entity.UploadFile;
+import cn.yoshop.common.entity.User;
+import cn.yoshop.common.exception.BizException;
+import cn.yoshop.common.mapper.ArticleCategoryMapper;
+import cn.yoshop.common.mapper.ArticleMapper;
+import cn.yoshop.common.mapper.HelpMapper;
+import cn.yoshop.common.mapper.PageEntityMapper;
+import cn.yoshop.common.mapper.UploadFileMapper;
+import cn.yoshop.common.page.PageVO;
+import cn.yoshop.common.util.PhpStr;
+import cn.yoshop.store.service.CategoryService;
+import cn.yoshop.store.service.PageDesignService;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+/**
+ * 客户端内容服务:商品分类、文章、帮助中心、DIY 页面。
+ *
+ * <p>对齐 PHP:
+ * app/api/model/Category::getListPublic、app/api/model/Article、app/api/model/article/Category、
+ * app/api/model/Page::getPageData、app/api/model/Help。
+ */
+@Service
+@RequiredArgsConstructor
+public class ApiContentService {
+
+    private static final String[] HIDDEN_COMMON = {"storeId", "createTime", "updateTime"};
+
+    private final CategoryService categoryService;
+    private final ArticleMapper articleMapper;
+    private final ArticleCategoryMapper articleCategoryMapper;
+    private final HelpMapper helpMapper;
+    private final PageEntityMapper pageMapper;
+    private final UploadFileMapper uploadFileMapper;
+    private final PageDesignService pageDesignService;
+    private final ApiGoodsService apiGoodsService;
+    private final ApiCouponService apiCouponService;
+    private final ApiView view;
+
+    @Value("${yoshop.base-url}")
+    private String baseUrl;
+
+    // ---------------------------------------------------------------- 商品分类
+
+    /** 商品分类树(仅显示 status=1),隐藏 storeId / createTime / updateTime */
+    public ArrayNode categoryList() {
+        List<Category> tree = categoryService.getList(1);
+        ArrayNode nodes = view.mapper().createArrayNode();
+        for (Category category : tree) {
+            nodes.add(categoryNode(category));
+        }
+        return nodes;
+    }
+
+    private ObjectNode categoryNode(Category category) {
+        ObjectNode node = view.of(category);
+        view.hide(node, HIDDEN_COMMON);
+        JsonNode children = node.get("children");
+        if (children instanceof ArrayNode array) {
+            for (int i = 0; i < array.size(); i++) {
+                ObjectNode child = (ObjectNode) array.get(i);
+                view.hide(child, HIDDEN_COMMON);
+                hideCategoryChildren(child);
+            }
+        }
+        return node;
+    }
+
+    private void hideCategoryChildren(ObjectNode node) {
+        JsonNode children = node.get("children");
+        if (children instanceof ArrayNode array) {
+            for (JsonNode child : array) {
+                if (child instanceof ObjectNode childNode) {
+                    view.hide(childNode, HIDDEN_COMMON);
+                    hideCategoryChildren(childNode);
+                }
+            }
+        }
+    }
+
+    // ---------------------------------------------------------------- 文章
+
+    /** 文章列表,对齐 PHP api\model\Article::getList:分页 15,隐藏 content 与阅读数 */
+    public PageVO<ObjectNode> articleList(Integer categoryId, int page) {
+        QueryWrapper<Article> qw = new QueryWrapper<>();
+        qw.select(Article.class, info -> !"content".equals(info.getColumn()));
+        if (categoryId != null && categoryId > 0) {
+            qw.eq("category_id", categoryId);
+        }
+        qw.eq("status", 1);
+        qw.eq("is_delete", 0);
+        qw.orderByAsc("sort");
+        qw.orderByDesc("create_time");
+        Page<Article> result = articleMapper.selectPage(new Page<>(page, 15), qw);
+        assembleArticles(result.getRecords());
+        PageVO<ObjectNode> vo = new PageVO<>();
+        vo.setTotal(result.getTotal());
+        vo.setPerPage(result.getSize());
+        vo.setCurrentPage(result.getCurrent());
+        vo.setLastPage(result.getPages());
+        vo.setData(articleNodes(result.getRecords()));
+        return vo;
+    }
+
+    /** 文章详情:阅读数 +1,content 反转义,隐藏 virtualViews / actualViews / isDelete */
+    public ObjectNode articleDetail(int articleId) {
+        Article article = articleMapper.selectById(articleId);
+        if (article == null || Integer.valueOf(1).equals(article.getIsDelete())) {
+            throw new BizException("很抱歉,当前文章不存在");
+        }
+        article.setContent(PhpStr.htmlspecialcharsDecode(article.getContent()));
+        assembleArticles(List.of(article));
+        ObjectNode node = articleNodes(List.of(article)).get(0);
+        // 累积实际阅读数(对齐 PHP setIncActualViews,放在读出之后,不影响本次返回)
+        articleMapper.update(null, Wrappers.<Article>lambdaUpdate()
+                .eq(Article::getArticleId, articleId)
+                .setSql("actual_views = actual_views + 1"));
+        return node;
+    }
+
+    /** 文章分类(status=1),隐藏 isDelete */
+    public ArrayNode articleCategoryList() {
+        List<ArticleCategory> list = articleCategoryMapper.selectList(
+                Wrappers.<ArticleCategory>lambdaQuery()
+                        .eq(ArticleCategory::getStatus, 1)
+                        .orderByAsc(ArticleCategory::getSort)
+                        .orderByAsc(ArticleCategory::getCategoryId));
+        ArrayNode nodes = view.mapper().createArrayNode();
+        for (ArticleCategory category : list) {
+            nodes.add(view.hideWith(view.of(category), HIDDEN_COMMON, "isDelete"));
+        }
+        return nodes;
+    }
+
+    private void assembleArticles(List<Article> articles) {
+        if (articles == null || articles.isEmpty()) {
+            return;
+        }
+        List<Integer> imageIds = articles.stream().map(Article::getImageId)
+                .filter(id -> id != null && id > 0).distinct().toList();
+        Map<Integer, UploadFile> files = imageIds.isEmpty() ? Collections.emptyMap()
+                : uploadFileMapper.selectBatchIds(imageIds).stream()
+                .peek(f -> f.setBaseUrl(baseUrl))
+                .collect(Collectors.toMap(UploadFile::getFileId, Function.identity()));
+        List<Integer> categoryIds = articles.stream().map(Article::getCategoryId)
+                .filter(Objects::nonNull).distinct().toList();
+        Map<Integer, ArticleCategory> categories = categoryIds.isEmpty() ? Collections.emptyMap()
+                : articleCategoryMapper.selectBatchIds(categoryIds).stream()
+                .collect(Collectors.toMap(ArticleCategory::getCategoryId, Function.identity()));
+        articles.forEach(a -> {
+            a.setImage(files.get(a.getImageId()));
+            a.setCategory(categories.get(a.getCategoryId()));
+        });
+    }
+
+    private List<ObjectNode> articleNodes(List<Article> articles) {
+        List<ObjectNode> nodes = new ArrayList<>();
+        for (Article article : articles) {
+            ObjectNode node = view.of(article);
+            view.hideWith(node, HIDDEN_COMMON, "virtualViews", "actualViews", "isDelete");
+            nodes.add(node);
+        }
+        return nodes;
+    }
+
+    // ---------------------------------------------------------------- 帮助中心
+
+    /** 帮助列表:分页 15,隐藏 isDelete */
+    public PageVO<ObjectNode> helpList(int page) {
+        Page<Help> result = helpMapper.selectPage(new Page<>(page, 15),
+                Wrappers.<Help>lambdaQuery()
+                        .orderByAsc(Help::getSort)
+                        .orderByAsc(Help::getHelpId));
+        PageVO<ObjectNode> vo = new PageVO<>();
+        vo.setTotal(result.getTotal());
+        vo.setPerPage(result.getSize());
+        vo.setCurrentPage(result.getCurrent());
+        vo.setLastPage(result.getPages());
+        List<ObjectNode> nodes = new ArrayList<>();
+        for (Help help : result.getRecords()) {
+            nodes.add(view.hideWith(view.of(help), HIDDEN_COMMON, "isDelete"));
+        }
+        vo.setData(nodes);
+        return vo;
+    }
+
+    // ---------------------------------------------------------------- DIY 页面
+
+    /**
+     * 页面数据,对齐 PHP api\model\Page::getPageData:
+     * pageId 为空取首页;page_data 合并默认组件参数后,按组件类型填充动态数据。
+     */
+    public Map<String, Object> pageDetail(Integer pageId, User user) {
+        PageEntity page = pageId != null && pageId > 0
+                ? pageMapper.selectById(pageId)
+                : pageMapper.selectOne(Wrappers.<PageEntity>lambdaQuery()
+                .eq(PageEntity::getPageType, 10)
+                .last("limit 1"));
+        if (page == null) {
+            throw new BizException("很抱歉,未找到该页面");
+        }
+        Map<String, Object> pageData = pageDesignService.mergePageData(page.getPageData());
+        Object itemsValue = pageData.get("items");
+        if (itemsValue instanceof List<?> items) {
+            for (Object raw : items) {
+                if (raw instanceof Map<?, ?> item) {
+                    fillItemData(castMap(item), user);
+                }
+            }
+        }
+        return pageData;
+    }
+
+    @SuppressWarnings("unchecked")
+    private void fillItemData(Map<String, Object> item, User user) {
+        item.remove("defaultData");
+        item.remove("demo");
+        Object type = item.get("type");
+        if (!(type instanceof String typeName)) {
+            return;
+        }
+        Map<String, Object> params = castMap(item.get("params"));
+        switch (typeName) {
+            case "window" -> {
+                Object data = item.get("data");
+                if (data instanceof Map<?, ?> map) {
+                    item.put("data", new ArrayList<>(map.values()));
+                }
+            }
+            case "goods" -> item.put("data", goodsComponentData(item, params, user));
+            case "coupon" -> item.put("data", couponComponentData(item, params));
+            case "article", "special" -> item.put("data", articleComponentData(params));
+            default -> {
+                // 其他组件保持原样
+            }
+        }
+    }
+
+    private List<Map<String, Object>> goodsComponentData(Map<String, Object> item,
+                                                         Map<String, Object> params, User user) {
+        Map<String, Object> auto = castMap(params.get("auto"));
+        List<ObjectNode> goodsList;
+        if ("choice".equals(String.valueOf(params.get("source")))) {
+            List<Integer> goodsIds = new ArrayList<>();
+            Object data = item.get("data");
+            if (data instanceof List<?> list) {
+                for (Object row : list) {
+                    if (row instanceof Map<?, ?> map && map.get("goodsId") != null) {
+                        goodsIds.add((int) Double.parseDouble(String.valueOf(map.get("goodsId"))));
+                    }
+                }
+            }
+            goodsList = apiGoodsService.getListByIds(goodsIds, user);
+        } else {
+            Map<String, Object> query = new LinkedHashMap<>();
+            query.put("status", 10);
+            query.put("categoryId", auto.get("category"));
+            query.put("sortType", auto.get("goodsSort"));
+            query.put("pageSize", auto.get("showNum"));
+            goodsList = apiGoodsService.getListForComponent(query, user, intOf(auto.get("showNum"), 6));
+        }
+        List<Map<String, Object>> data = new ArrayList<>();
+        for (ObjectNode goods : goodsList) {
+            Map<String, Object> row = new LinkedHashMap<>();
+            row.put("goodsId", goods.path("goodsId").asInt(0));
+            row.put("goodsName", goods.path("goodsName").asText(null));
+            row.put("specType", goods.path("specType").asInt(0));
+            row.put("sellingPoint", goods.path("sellingPoint").asText(null));
+            row.put("goodsImage", goods.path("goodsImage").asText(null));
+            row.put("goodsPriceMin", goods.path("goodsPriceMin").asText(null));
+            row.put("goodsPriceMax", goods.path("goodsPriceMax").asText(null));
+            row.put("linePriceMin", goods.path("linePriceMin").asText(null));
+            row.put("linePriceMax", goods.path("linePriceMax").asText(null));
+            row.put("goodsSales", goods.path("goodsSales").asInt(0));
+            data.add(row);
+        }
+        return data;
+    }
+
+    private List<ObjectNode> couponComponentData(Map<String, Object> item, Map<String, Object> params) {
+        List<Integer> couponIds = new ArrayList<>();
+        // PHP: helper::getArrayColumn($item['data'], 'coupon_id')
+        Object data = item.get("data");
+        if (data instanceof List<?> list) {
+            for (Object row : list) {
+                if (row instanceof Map<?, ?> map && map.get("couponId") != null) {
+                    couponIds.add((int) Double.parseDouble(String.valueOf(map.get("couponId"))));
+                }
+            }
+        }
+        int showNum = intOf(params.get("showNum"), 0);
+        return apiCouponService.list(showNum > 0 ? showNum : null, true, null, couponIds, null);
+    }
+
+    private List<ObjectNode> articleComponentData(Map<String, Object> params) {
+        Map<String, Object> auto = castMap(params.get("auto"));
+        int categoryId = intOf(auto.get("category"), 0);
+        int showNum = intOf(auto.get("showNum"), 15);
+        QueryWrapper<Article> qw = new QueryWrapper<>();
+        qw.select(Article.class, info -> !"content".equals(info.getColumn()));
+        if (categoryId > 0) {
+            qw.eq("category_id", categoryId);
+        }
+        qw.eq("status", 1);
+        qw.eq("is_delete", 0);
+        qw.orderByAsc("sort");
+        qw.orderByDesc("create_time");
+        qw.last("limit " + Math.max(showNum, 1));
+        List<Article> articles = articleMapper.selectList(qw);
+        assembleArticles(articles);
+        return articleNodes(articles);
+    }
+
+    @SuppressWarnings("unchecked")
+    private static Map<String, Object> castMap(Object value) {
+        return value instanceof Map<?, ?> map ? (Map<String, Object>) map : new LinkedHashMap<>();
+    }
+
+    private static int intOf(Object value, int defaultValue) {
+        if (value == null || String.valueOf(value).isBlank()) {
+            return defaultValue;
+        }
+        try {
+            return (int) Double.parseDouble(String.valueOf(value));
+        } catch (NumberFormatException e) {
+            return defaultValue;
+        }
+    }
+}

+ 131 - 0
yoshop-java/src/main/java/cn/yoshop/api/service/ApiCouponService.java

@@ -0,0 +1,131 @@
+package cn.yoshop.api.service;
+
+import cn.yoshop.api.support.ApiContext;
+import cn.yoshop.api.support.ApiView;
+import cn.yoshop.common.entity.Coupon;
+import cn.yoshop.common.entity.User;
+import cn.yoshop.common.entity.UserCoupon;
+import cn.yoshop.common.mapper.CouponMapper;
+import cn.yoshop.common.mapper.UserCouponMapper;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 客户端优惠券服务,对齐 PHP app/api/model/Coupon::getList。
+ *
+ * <p>规则:
+ * <ul>
+ *   <li>只查 status=1、is_delete=0;</li>
+ *   <li>onlyReceive 时排除已领完 / 已过期;</li>
+ *   <li>指定 couponIds 时按传入顺序返回且忽略 limit;否则 limit + sort asc、create_time desc;</li>
+ *   <li>已登录用户追加 isReceive(是否已领取);</li>
+ *   <li>goodsId 过滤适用范围(20 指定商品 / 30 排除商品)。</li>
+ * </ul>
+ */
+@Service
+@RequiredArgsConstructor
+public class ApiCouponService {
+
+    /** 优惠券隐藏字段(对齐 PHP api\model\Coupon::$hidden) */
+    private static final String[] HIDDEN = {
+            "totalNum", "receiveNum", "status", "isDelete", "storeId", "createTime", "updateTime"
+    };
+
+    private final CouponMapper couponMapper;
+    private final UserCouponMapper userCouponMapper;
+    private final ApiView view;
+
+    public List<ObjectNode> list(Integer limit, boolean onlyReceive, Integer goodsId,
+                                 List<Integer> couponIds, User user) {
+        QueryWrapper<Coupon> qw = new QueryWrapper<>();
+        qw.eq("store_id", ApiContext.STORE_ID);
+        qw.eq("status", 1);
+        if (onlyReceive) {
+            qw.apply("IF (total_num > -1, receive_num < total_num, 1 = 1)");
+            qw.apply("IF (expire_type = 20, (end_time + 86400) >= {0}, 1 = 1)",
+                    System.currentTimeMillis() / 1000);
+        }
+        boolean byIds = couponIds != null && !couponIds.isEmpty();
+        if (byIds) {
+            qw.in("coupon_id", couponIds);
+        } else {
+            if (limit != null && limit > 0) {
+                qw.last("limit " + limit);
+            }
+            qw.orderByAsc("sort");
+            qw.orderByDesc("create_time");
+        }
+        List<Coupon> coupons = couponMapper.selectList(qw);
+        if (byIds) {
+            // PHP orderRaw('field(coupon_id, ...)'):按传入顺序
+            Map<Integer, Integer> order = new HashMap<>();
+            for (int i = 0; i < couponIds.size(); i++) {
+                order.putIfAbsent(couponIds.get(i), i);
+            }
+            coupons.sort(Comparator.comparingInt(c -> order.getOrDefault(c.getCouponId(), Integer.MAX_VALUE)));
+        }
+        coupons = screenByGoodsId(coupons, goodsId);
+        Set<Integer> received = receivedCouponIds(user);
+        List<ObjectNode> nodes = new ArrayList<>();
+        for (Coupon coupon : coupons) {
+            ObjectNode node = view.hide(view.of(coupon), HIDDEN);
+            if (user != null) {
+                node.put("isReceive", received.contains(coupon.getCouponId()));
+            }
+            nodes.add(node);
+        }
+        return nodes;
+    }
+
+    /** 已登录用户已领取的优惠券 ID 集(PHP UserCoupon::getUserCouponIds) */
+    private Set<Integer> receivedCouponIds(User user) {
+        if (user == null) {
+            return Set.of();
+        }
+        List<UserCoupon> list = userCouponMapper.selectList(Wrappers.<UserCoupon>lambdaQuery()
+                .eq(UserCoupon::getUserId, user.getUserId())
+                .select(UserCoupon::getCouponId));
+        Set<Integer> ids = new LinkedHashSet<>();
+        list.forEach(c -> ids.add(c.getCouponId()));
+        return ids;
+    }
+
+    /** 按商品过滤适用范围:20=仅指定商品可用,30=指定商品不可用 */
+    @SuppressWarnings("unchecked")
+    private List<Coupon> screenByGoodsId(List<Coupon> coupons, Integer goodsId) {
+        if (goodsId == null || goodsId <= 0) {
+            return coupons;
+        }
+        List<Coupon> filtered = new ArrayList<>();
+        for (Coupon coupon : coupons) {
+            Object config = coupon.getApplyRangeConfigJson();
+            List<Integer> goodsIds = List.of();
+            if (config instanceof Map<?, ?> map && map.get("goodsIds") instanceof List<?> list) {
+                goodsIds = ((List<Object>) list).stream()
+                        .filter(v -> v != null)
+                        .map(v -> (int) Double.parseDouble(String.valueOf(v)))
+                        .toList();
+            }
+            if (Integer.valueOf(20).equals(coupon.getApplyRange()) && !goodsIds.contains(goodsId)) {
+                continue;
+            }
+            if (Integer.valueOf(30).equals(coupon.getApplyRange()) && goodsIds.contains(goodsId)) {
+                continue;
+            }
+            filtered.add(coupon);
+        }
+        return filtered;
+    }
+}

+ 557 - 0
yoshop-java/src/main/java/cn/yoshop/api/service/ApiGoodsService.java

@@ -0,0 +1,557 @@
+package cn.yoshop.api.service;
+
+import cn.yoshop.api.support.ApiContext;
+import cn.yoshop.api.support.ApiView;
+import cn.yoshop.common.entity.Goods;
+import cn.yoshop.common.entity.GoodsImage;
+import cn.yoshop.common.entity.GoodsSku;
+import cn.yoshop.common.entity.GoodsSpecRel;
+import cn.yoshop.common.entity.Spec;
+import cn.yoshop.common.entity.SpecValue;
+import cn.yoshop.common.entity.UploadFile;
+import cn.yoshop.common.entity.User;
+import cn.yoshop.common.entity.UserGrade;
+import cn.yoshop.common.exception.BizException;
+import cn.yoshop.common.mapper.GoodsCategoryRelMapper;
+import cn.yoshop.common.mapper.GoodsImageMapper;
+import cn.yoshop.common.mapper.GoodsMapper;
+import cn.yoshop.common.mapper.GoodsServiceMapper;
+import cn.yoshop.common.mapper.GoodsServiceRelMapper;
+import cn.yoshop.common.mapper.GoodsSkuMapper;
+import cn.yoshop.common.mapper.GoodsSpecRelMapper;
+import cn.yoshop.common.mapper.SpecMapper;
+import cn.yoshop.common.mapper.SpecValueMapper;
+import cn.yoshop.common.mapper.UploadFileMapper;
+import cn.yoshop.common.mapper.UserGradeMapper;
+import cn.yoshop.common.page.PageVO;
+import cn.yoshop.common.util.PhpStr;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+/**
+ * 客户端商品服务,对齐 PHP:
+ * app/api/model/Goods(getList / getDetails / getBasic / getSpecData)、
+ * app/api/service/Goods::getSkuInfo、app/api/model/goods/Service::getListByGoods。
+ *
+ * <p>字段口径:PHP 各模型用 $hidden 裁剪,这里在序列化后用 {@link ApiView#hide} 移除同名键。
+ * 商品基础隐藏集:images / delivery / deductStockType / salesInitial / salesActual /
+ * sort / isDelete / storeId / createTime / updateTime。
+ *
+ * <p>会员折扣:登录用户有有效等级且商品参与折扣时,goodsPriceMin/Max 与 sku.goodsPrice
+ * 按等级折扣率重算,并置 isUserGrade=true(对齐 PHP setGoodsGradeMoney)。
+ */
+@Service
+@RequiredArgsConstructor
+public class ApiGoodsService {
+
+    /** 商品基础隐藏字段(对齐 PHP api\model\Goods::$hidden) */
+    private static final String[] HIDDEN_BASE = {
+            "images", "delivery", "deductStockType", "salesInitial", "salesActual",
+            "sort", "isDelete", "storeId", "createTime", "updateTime"
+    };
+
+    private final GoodsMapper goodsMapper;
+    private final GoodsImageMapper goodsImageMapper;
+    private final UploadFileMapper uploadFileMapper;
+    private final GoodsSkuMapper goodsSkuMapper;
+    private final GoodsSpecRelMapper goodsSpecRelMapper;
+    private final SpecMapper specMapper;
+    private final SpecValueMapper specValueMapper;
+    private final GoodsCategoryRelMapper goodsCategoryRelMapper;
+    private final GoodsServiceMapper goodsServiceMapper;
+    private final GoodsServiceRelMapper goodsServiceRelMapper;
+    private final UserGradeMapper userGradeMapper;
+    private final ApiView view;
+
+    @Value("${yoshop.base-url}")
+    private String baseUrl;
+
+    // ------------------------------------------------------------------ 列表
+
+    /**
+     * 商品列表,对齐 PHP api\model\Goods::getList:
+     * 固定 status=10(上架),支持 listType / categoryId / goodsName / goodsNo / sortType / sortPrice,
+     * 分页 15,列表隐藏 content / goodsImages / images。
+     */
+    public PageVO<ObjectNode> getList(Map<String, Object> params, User user) {
+        int page = intVal(params.get("page"), 1);
+        int listRows = 15;
+        QueryWrapper<Goods> qw = buildQuery(params);
+
+        Page<Goods> result = goodsMapper.selectPage(new Page<>(page, listRows), qw);
+        List<Goods> records = result.getRecords();
+        assemble(records);
+        PageVO<ObjectNode> vo = new PageVO<>();
+        vo.setTotal(result.getTotal());
+        vo.setPerPage(result.getSize());
+        vo.setCurrentPage(result.getCurrent() > result.getPages() && result.getPages() > 0
+                ? result.getPages() : result.getCurrent());
+        vo.setLastPage(result.getPages());
+        vo.setData(toApiNodes(records, user, true));
+        return vo;
+    }
+
+    /** DIY 商品组件(自动来源):按 showNum 取前 N 条,不分页 */
+    public List<ObjectNode> getListForComponent(Map<String, Object> params, User user, int limit) {
+        QueryWrapper<Goods> qw = buildQuery(params);
+        qw.last("limit " + Math.max(limit, 1));
+        List<Goods> records = goodsMapper.selectList(qw);
+        assemble(records);
+        return toApiNodes(records, user, true);
+    }
+
+    /** 列表检索条件(列表与 DIY 组件共用) */
+    private QueryWrapper<Goods> buildQuery(Map<String, Object> params) {
+        String listType = strVal(params.get("listType"), "all");
+        Integer categoryId = intOrNull(params.get("categoryId"));
+        QueryWrapper<Goods> qw = new QueryWrapper<>();
+        qw.select(Goods.class, info -> !"content".equals(info.getColumn()));
+        qw.eq("is_delete", 0);
+        qw.eq("store_id", ApiContext.STORE_ID);
+        switch (listType) {
+            case "off_sale" -> qw.eq("status", 20);
+            case "sold_out" -> qw.eq("stock_total", 0);
+            default -> {
+                // on_sale / all 均落到下面的固定上架条件
+            }
+        }
+        // PHP: array_merge($param, ['status' => ON_SALE]) —— 固定只查上架商品
+        qw.eq("status", 10);
+        if (categoryId != null && categoryId > 0) {
+            List<Integer> goodsIds = goodsCategoryRelMapper.selectGoodsIds(ApiContext.STORE_ID, categoryId);
+            qw.in("goods_id", goodsIds.isEmpty() ? List.of(-1) : goodsIds);
+        }
+        String goodsName = strOrNull(params.get("goodsName"));
+        if (StringUtils.hasText(goodsName)) {
+            qw.like("goods_name", goodsName);
+        }
+        String goodsNo = strOrNull(params.get("goodsNo"));
+        if (StringUtils.hasText(goodsNo)) {
+            qw.like("goods_no", goodsNo);
+        }
+        applySort(qw, strVal(params.get("sortType"), "all"), boolVal(params.get("sortPrice")));
+        return qw;
+    }
+
+    /** 按 ID 集取上架商品(DIY 页面商品组件用),保持传入顺序 */
+    public List<ObjectNode> getListByIds(List<Integer> goodsIds, User user) {
+        if (goodsIds == null || goodsIds.isEmpty()) {
+            return List.of();
+        }
+        QueryWrapper<Goods> qw = new QueryWrapper<>();
+        qw.select(Goods.class, info -> !"content".equals(info.getColumn()));
+        qw.in("goods_id", goodsIds);
+        qw.eq("status", 10);
+        qw.eq("is_delete", 0);
+        List<Goods> records = goodsMapper.selectList(qw);
+        Map<Integer, Integer> order = new HashMap<>();
+        for (int i = 0; i < goodsIds.size(); i++) {
+            order.putIfAbsent(goodsIds.get(i), i);
+        }
+        records.sort(Comparator.comparingInt(g -> order.getOrDefault(g.getGoodsId(), Integer.MAX_VALUE)));
+        assemble(records);
+        return toApiNodes(records, user, true);
+    }
+
+    // ------------------------------------------------------------------ 详情
+
+    /** 商品详情(含 SKU / 视频 / 规格),对齐 PHP api\model\Goods::getDetails */
+    public ObjectNode getDetail(int goodsId, boolean verifyStatus, User user) {
+        Goods goods = loadGoods(goodsId, verifyStatus);
+        assemble(List.of(goods));
+        goods.setSkuList(skuList(goodsId, true));
+        goods.setVideo(findFile(goods.getVideoId()));
+        goods.setVideoCover(findFile(goods.getVideoCoverId()));
+        goods.setSpecList(new ArrayList<>(getSpecList(goodsId)));
+        ObjectNode node = view.of(goods);
+        // PHP getDetails 额外隐藏 images(保留 goodsImages / goodsImage)
+        view.hide(node, HIDDEN_BASE);
+        applyGrade(node, user);
+        return node;
+    }
+
+    /** 商品基础信息,对齐 PHP api\model\Goods::getBasic(仅主信息 + 图片) */
+    public ObjectNode getBasic(int goodsId, boolean verifyStatus, User user) {
+        Goods goods = loadGoods(goodsId, verifyStatus);
+        assemble(List.of(goods));
+        ObjectNode node = view.of(goods);
+        // 基础信息不含 skuList / video / specList / videoCover(未查询,实体侧为 null,直接移除)
+        view.hide(node, HIDDEN_BASE);
+        node.remove(List.of("skuList", "specList", "video", "videoCover",
+                "categoryIds", "serviceIds", "isSpecLocked"));
+        applyGrade(node, user);
+        return node;
+    }
+
+    /** 规格数据:{skuList, specList},对齐 PHP api\model\Goods::getSpecData */
+    public Map<String, Object> getSpecData(int goodsId) {
+        Map<String, Object> data = new LinkedHashMap<>();
+        ArrayNode skuNodes = view.mapper().createArrayNode();
+        for (GoodsSku sku : skuList(goodsId, false)) {
+            ObjectNode node = view.of(sku);
+            view.hide(node, "image", "storeId", "createTime", "updateTime");
+            node.put("imageUrl", sku.getImage() == null ? null : sku.getImage().getPreviewUrl());
+            skuNodes.add(node);
+        }
+        data.put("skuList", skuNodes);
+        data.put("specList", view.mapper().valueToTree(getSpecList(goodsId)));
+        return data;
+    }
+
+    /** 指定 SKU 信息(含 goodsImage),对齐 PHP api\service\Goods::getSkuInfo */
+    public ObjectNode getSkuInfo(int goodsId, String goodsSkuId, User user) {
+        GoodsSku sku = goodsSkuMapper.selectOne(Wrappers.<GoodsSku>lambdaQuery()
+                .eq(GoodsSku::getGoodsId, goodsId)
+                .eq(GoodsSku::getGoodsSkuId, goodsSkuId)
+                .last("limit 1"));
+        if (sku == null) {
+            throw new BizException("很抱歉,商品规格信息不存在");
+        }
+        UploadFile image = findFile(sku.getImageId());
+        sku.setImage(image);
+        ObjectNode node = view.of(sku);
+        view.hide(node, "image", "storeId", "createTime", "updateTime");
+        node.put("goodsImage", image == null ? null : image.getPreviewUrl());
+        node.put("imageUrl", image == null ? null : image.getPreviewUrl());
+        // SKU 出参的会员价(PHP Goods::getSkuInfo 会对 skuInfo 应用折扣)
+        applySkuGrade(node, user);
+        return node;
+    }
+
+    // ------------------------------------------------------------------ 规格
+
+    /**
+     * 商品服务与承诺,对齐 PHP api\model\goods\Service::getListByGoods:
+     * 按商品已绑定的 service_id 取启用记录,排序 sort asc + id asc。
+     */
+    public List<ObjectNode> getServiceList(int goodsId) {
+        List<Integer> serviceIds = goodsServiceRelMapper.selectServiceIds(goodsId);
+        if (serviceIds.isEmpty()) {
+            return List.of();
+        }
+        List<cn.yoshop.common.entity.GoodsService> list = goodsServiceMapper.selectList(
+                Wrappers.<cn.yoshop.common.entity.GoodsService>lambdaQuery()
+                        .in(cn.yoshop.common.entity.GoodsService::getServiceId, serviceIds)
+                        .eq(cn.yoshop.common.entity.GoodsService::getStatus, 1)
+                        .orderByAsc(cn.yoshop.common.entity.GoodsService::getSort)
+                        .orderByAsc(cn.yoshop.common.entity.GoodsService::getServiceId));
+        List<ObjectNode> nodes = new ArrayList<>();
+        for (cn.yoshop.common.entity.GoodsService service : list) {
+            ObjectNode node = view.of(service);
+            view.hide(node, "isDefault", "status", "sort", "isDelete", "storeId", "createTime", "updateTime");
+            nodes.add(node);
+        }
+        return nodes;
+    }
+
+    /** 规格属性列表,对齐 PHP GoodsSpecRel::getSpecList(结构与 store 端一致) */
+    public List<Map<String, Object>> getSpecList(int goodsId) {
+        List<GoodsSpecRel> rels = goodsSpecRelMapper.selectList(
+                Wrappers.<GoodsSpecRel>lambdaQuery()
+                        .eq(GoodsSpecRel::getGoodsId, goodsId)
+                        .orderByAsc(GoodsSpecRel::getId));
+        if (rels.isEmpty()) {
+            return List.of();
+        }
+        List<Integer> specIds = rels.stream().map(GoodsSpecRel::getSpecId).distinct().toList();
+        Map<Integer, Spec> specMap = specMapper.selectBatchIds(specIds).stream()
+                .collect(Collectors.toMap(Spec::getSpecId, Function.identity()));
+        List<Integer> valueIds = rels.stream().map(GoodsSpecRel::getSpecValueId).distinct().toList();
+        Map<Integer, SpecValue> valueMap = specValueMapper.selectBatchIds(valueIds).stream()
+                .collect(Collectors.toMap(SpecValue::getSpecValueId, Function.identity()));
+
+        List<Map<String, Object>> specList = new ArrayList<>();
+        Map<Integer, Map<String, Object>> groupIndex = new LinkedHashMap<>();
+        for (GoodsSpecRel rel : rels) {
+            if (groupIndex.containsKey(rel.getSpecId())) {
+                continue;
+            }
+            Spec spec = specMap.get(rel.getSpecId());
+            Map<String, Object> group = new LinkedHashMap<>();
+            group.put("specId", spec == null ? rel.getSpecId() : spec.getSpecId());
+            group.put("specName", spec == null ? "" : spec.getSpecName());
+            group.put("key", groupIndex.size());
+            group.put("valueList", new ArrayList<Map<String, Object>>());
+            groupIndex.put(rel.getSpecId(), group);
+            specList.add(group);
+        }
+        for (GoodsSpecRel rel : rels) {
+            Map<String, Object> group = groupIndex.get(rel.getSpecId());
+            if (group == null) {
+                continue;
+            }
+            @SuppressWarnings("unchecked")
+            List<Map<String, Object>> valueList = (List<Map<String, Object>>) group.get("valueList");
+            SpecValue value = valueMap.get(rel.getSpecValueId());
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("key", valueList.size());
+            item.put("groupKey", group.get("key"));
+            item.put("specValueId", value == null ? rel.getSpecValueId() : value.getSpecValueId());
+            item.put("specValue", value == null ? "" : value.getSpecValue());
+            valueList.add(item);
+        }
+        return specList;
+    }
+
+    // ------------------------------------------------------------------ 内部
+
+    private Goods loadGoods(int goodsId, boolean verifyStatus) {
+        Goods goods = goodsMapper.selectById(goodsId);
+        if (goods == null || Integer.valueOf(1).equals(goods.getIsDelete())) {
+            throw new BizException("很抱歉,商品信息不存在");
+        }
+        if (verifyStatus && Integer.valueOf(20).equals(goods.getStatus())) {
+            throw new BizException("很抱歉,当前商品已下架");
+        }
+        // content 在实体上 select=false(列表不查长文本),详情单独查一次并做 HTML 反转义
+        Goods contentHolder = goodsMapper.selectOne(
+                Wrappers.<Goods>query().select("goods_id", "content").eq("goods_id", goodsId));
+        if (contentHolder != null && contentHolder.getContent() != null) {
+            goods.setContent(PhpStr.htmlspecialcharsDecode(contentHolder.getContent()));
+        }
+        return goods;
+    }
+
+    private List<GoodsSku> skuList(int goodsId, boolean withImage) {
+        List<GoodsSku> skus = goodsSkuMapper.selectList(Wrappers.<GoodsSku>lambdaQuery()
+                .eq(GoodsSku::getGoodsId, goodsId)
+                .orderByAsc(GoodsSku::getId));
+        skus.forEach(sku -> {
+            sku.setGoodsProps(blankToNull(sku.getGoodsProps()));
+            sku.setSpecValueIds(blankToNull(sku.getSpecValueIds()));
+        });
+        if (withImage) {
+            fillSkuImage(skus);
+        }
+        return skus;
+    }
+
+    private void fillSkuImage(List<GoodsSku> skuList) {
+        List<Integer> imageIds = skuList.stream().map(GoodsSku::getImageId)
+                .filter(id -> id != null && id > 0).distinct().toList();
+        if (imageIds.isEmpty()) {
+            return;
+        }
+        Map<Integer, UploadFile> files = uploadFileMapper.selectBatchIds(imageIds).stream()
+                .peek(f -> f.setBaseUrl(baseUrl))
+                .collect(Collectors.toMap(UploadFile::getFileId, Function.identity()));
+        skuList.forEach(sku -> sku.setImage(files.get(sku.getImageId())));
+    }
+
+    private UploadFile findFile(Integer fileId) {
+        if (fileId == null || fileId == 0) {
+            return null;
+        }
+        UploadFile file = uploadFileMapper.selectById(fileId);
+        if (file != null) {
+            file.setBaseUrl(baseUrl);
+        }
+        return file;
+    }
+
+    /** 组装图片(goods_image / goods_images),对齐 PHP setGoodsData */
+    private void assemble(List<Goods> records) {
+        if (records.isEmpty()) {
+            return;
+        }
+        List<Integer> goodsIds = records.stream().map(Goods::getGoodsId).toList();
+        List<GoodsImage> rels = goodsImageMapper.selectList(Wrappers.<GoodsImage>lambdaQuery()
+                .in(GoodsImage::getGoodsId, goodsIds)
+                .orderByAsc(GoodsImage::getId));
+        List<Integer> fileIds = rels.stream().map(GoodsImage::getImageId)
+                .filter(Objects::nonNull).distinct().toList();
+        Map<Integer, UploadFile> fileMap = fileIds.isEmpty() ? Collections.emptyMap()
+                : uploadFileMapper.selectBatchIds(fileIds).stream()
+                .peek(f -> f.setBaseUrl(baseUrl))
+                .collect(Collectors.toMap(UploadFile::getFileId, Function.identity()));
+        Map<Integer, List<GoodsImage>> byGoods = rels.stream()
+                .collect(Collectors.groupingBy(GoodsImage::getGoodsId, LinkedHashMap::new, Collectors.toList()));
+        for (Goods goods : records) {
+            List<GoodsImage> images = byGoods.getOrDefault(goods.getGoodsId(), List.of());
+            images.forEach(img -> img.setFile(fileMap.get(img.getImageId())));
+            goods.setImages(images);
+            List<UploadFile> files = images.stream().map(GoodsImage::getFile)
+                    .filter(Objects::nonNull).toList();
+            goods.setGoodsImages(files);
+            goods.setGoodsImage(files.isEmpty() ? null : files.get(0).getPreviewUrl());
+        }
+    }
+
+    /** 列表/详情出参:裁剪字段 + 会员价 */
+    private List<ObjectNode> toApiNodes(List<Goods> records, User user, boolean hideImages) {
+        List<ObjectNode> nodes = new ArrayList<>();
+        for (Goods goods : records) {
+            ObjectNode node = view.of(goods);
+            view.hide(node, HIDDEN_BASE);
+            if (hideImages) {
+                node.remove(List.of("images", "goodsImages"));
+            }
+            applyGrade(node, user);
+            nodes.add(node);
+        }
+        return nodes;
+    }
+
+    // ------------------------------------------------------------------ 会员折扣
+
+    /**
+     * 商品会员价,对齐 PHP api\model\Goods::setGoodsGradeMoney:
+     * 未登录 / 无等级 / 商品不参与折扣时 isUserGrade=false,价格不变。
+     */
+    private void applyGrade(ObjectNode node, User user) {
+        node.put("isUserGrade", false);
+        UserGrade grade = currentGrade(user);
+        if (grade == null || !Integer.valueOf(1).equals(node.path("isEnableGrade").asInt(0))) {
+            return;
+        }
+        BigDecimal ratio = discountRatio(grade, node.get("isAloneGrade"), node.get("aloneGradeEquity"), grade.getGradeId());
+        if (ratio == null || ratio.compareTo(BigDecimal.ZERO) == 0) {
+            return;
+        }
+        node.put("isUserGrade", true);
+        node.put("goodsPriceMin", discountPrice(node.path("goodsPriceMin").asText(null), ratio));
+        node.put("goodsPriceMax", discountPrice(node.path("goodsPriceMax").asText(null), ratio));
+        JsonNode skuList = node.get("skuList");
+        if (skuList instanceof ArrayNode array) {
+            for (JsonNode sku : array) {
+                if (sku instanceof ObjectNode skuNode) {
+                    skuNode.put("goodsPrice", discountPrice(skuNode.path("goodsPrice").asText(null), ratio));
+                }
+            }
+        }
+    }
+
+    private void applySkuGrade(ObjectNode skuNode, User user) {
+        UserGrade grade = currentGrade(user);
+        if (grade == null) {
+            return;
+        }
+        // SKU 单独查询时没有商品折扣配置,按等级默认折扣处理(PHP 传 goodsInfo 时同样读取商品配置)
+        BigDecimal ratio = discountRatio(grade, null, null, grade.getGradeId());
+        if (ratio == null || ratio.compareTo(BigDecimal.ZERO) == 0) {
+            return;
+        }
+        skuNode.put("goodsPrice", discountPrice(skuNode.path("goodsPrice").asText(null), ratio));
+    }
+
+    private UserGrade currentGrade(User user) {
+        if (user == null || user.getGradeId() == null || user.getGradeId() == 0) {
+            return null;
+        }
+        UserGrade grade = userGradeMapper.selectById(user.getGradeId());
+        if (grade == null || Integer.valueOf(1).equals(grade.getIsDelete())
+                || !Integer.valueOf(1).equals(grade.getStatus())) {
+            return null;
+        }
+        return grade;
+    }
+
+    /** 折扣比例:优先商品单独配置(按等级 ID),否则取等级权益 discount */
+    private BigDecimal discountRatio(UserGrade grade, JsonNode isAloneGrade, JsonNode aloneEquity, int gradeId) {
+        Object equityJson = PhpStr.jsonDecodeArray(grade.getEquity());
+        BigDecimal ratio = equityJson instanceof Map<?, ?> equity ? toDecimal(equity.get("discount")) : null;
+        boolean alone = isAloneGrade != null && isAloneGrade.asInt(0) == 1;
+        if (alone && aloneEquity instanceof ObjectNode equityNode && equityNode.has(String.valueOf(gradeId))) {
+            BigDecimal aloneRatio = toDecimal(equityNode.get(String.valueOf(gradeId)).asText(null));
+            if (aloneRatio != null) {
+                ratio = aloneRatio;
+            }
+        }
+        return ratio;
+    }
+
+    /** 对齐 PHP getDiscountPrice:round(price * discount / 10, 2),出参两位小数字符串 */
+    private static String discountPrice(String price, BigDecimal ratio) {
+        if (price == null || price.isBlank()) {
+            return null;
+        }
+        BigDecimal value = new BigDecimal(price)
+                .multiply(ratio.divide(BigDecimal.TEN, 6, RoundingMode.HALF_UP))
+                .setScale(2, RoundingMode.HALF_UP);
+        return value.toPlainString();
+    }
+
+    private static BigDecimal toDecimal(Object value) {
+        if (value == null) {
+            return null;
+        }
+        try {
+            return new BigDecimal(String.valueOf(value));
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+
+    private static void applySort(QueryWrapper<Goods> qw, String sortType, boolean sortPrice) {
+        switch (sortType) {
+            case "sales" -> qw.orderByDesc("(sales_initial + sales_actual)");
+            case "price" -> {
+                if (sortPrice) {
+                    qw.orderByDesc("goods_price_max");
+                } else {
+                    qw.orderByAsc("goods_price_min");
+                }
+            }
+            default -> qw.orderByAsc("sort");
+        }
+        qw.orderByDesc("goods_id");
+    }
+
+    private static String blankToNull(String value) {
+        return value == null || value.isBlank() ? null : value;
+    }
+
+    private static String strVal(Object value, String defaultValue) {
+        return value == null || String.valueOf(value).isBlank() ? defaultValue : String.valueOf(value);
+    }
+
+    private static String strOrNull(Object value) {
+        return value == null || String.valueOf(value).isBlank() ? null : String.valueOf(value);
+    }
+
+    private static boolean boolVal(Object value) {
+        if (value == null) {
+            return false;
+        }
+        String s = String.valueOf(value);
+        return "true".equalsIgnoreCase(s) || "1".equals(s) || "on".equalsIgnoreCase(s);
+    }
+
+    private static Integer intOrNull(Object value) {
+        if (value == null || String.valueOf(value).isBlank()) {
+            return null;
+        }
+        try {
+            return (int) Double.parseDouble(String.valueOf(value));
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+
+    private static int intVal(Object value, int defaultValue) {
+        Integer v = intOrNull(value);
+        return v == null || v <= 0 ? defaultValue : v;
+    }
+}

+ 134 - 0
yoshop-java/src/main/java/cn/yoshop/api/service/ApiSettingService.java

@@ -0,0 +1,134 @@
+package cn.yoshop.api.service;
+
+import cn.yoshop.api.support.ApiContext;
+import cn.yoshop.api.support.ApiView;
+import cn.yoshop.common.entity.Store;
+import cn.yoshop.common.entity.StoreSetting;
+import cn.yoshop.common.mapper.StoreSettingMapper;
+import cn.yoshop.common.util.PhpStr;
+import cn.yoshop.store.service.ClientSettingService;
+import cn.yoshop.store.service.StoreInfoService;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 客户端公共数据,对齐 PHP:
+ * app/api/service/Setting(getPublic)、app/api/service/Store(data)、app/api/service/Client(getPublic)。
+ */
+@Service
+@RequiredArgsConstructor
+public class ApiSettingService {
+
+    private final StoreSettingMapper storeSettingMapper;
+    private final StoreInfoService storeInfoService;
+    private final ClientSettingService clientSettingService;
+    private final ApiView view;
+
+    @Value("${yoshop.base-url}")
+    private String baseUrl;
+
+    // ---------------------------------------------------------------- 设置项
+
+    /**
+     * 读取设置项 values,库内无该项返回空数组(对齐 PHP Setting::getItem)。
+     * 返回值可能是 Map(正常设置)或 List(缺失项,PHP 空数组序列化为 [])。
+     */
+    public Object getItem(String key) {
+        StoreSetting setting = storeSettingMapper.selectOne(Wrappers.<StoreSetting>lambdaQuery()
+                .eq(StoreSetting::getKey, key)
+                .eq(StoreSetting::getStoreId, ApiContext.storeId())
+                .last("limit 1"));
+        if (setting == null || setting.getValues() == null || setting.getValues().isBlank()) {
+            return new ArrayList<>();
+        }
+        Object decoded = PhpStr.jsonDecodeArray(setting.getValues());
+        return decoded == null ? new ArrayList<>() : decoded;
+    }
+
+    /**
+     * 商城公共设置(仅暴露可公开项),对齐 PHP Setting::getPublic:
+     * appTheme / pageCategoryTemplate / points / recharge / register / customer。
+     */
+    public Map<String, Object> getPublicSetting() {
+        Map<String, Object> data = new LinkedHashMap<>();
+        // 店铺页面风格设置:PHP 取 getItem(app_theme)['data']
+        Object appThemeItem = getItem("app_theme");
+        Object appTheme = appThemeItem instanceof Map<?, ?> map ? map.get("data") : null;
+        data.put("appTheme", appTheme);
+        data.put("pageCategoryTemplate", getItem("page_category_template"));
+        data.put("points", pick(getItem("points"), "points_name", "describe"));
+        data.put("recharge", pick(getItem("recharge"), "is_entrance", "is_custom", "describe"));
+        data.put("register", pick(getItem("register"),
+                "registerMethod", "isManualBind", "isOauthMpweixin", "isOauthMobileMpweixin"));
+        data.put("customer", pick(getItem("customer"), "enabled", "provider", "config"));
+        return data;
+    }
+
+    // ---------------------------------------------------------------- 商城信息
+
+    /** /api/store/data 出参,对齐 PHP api\service\Store::data */
+    public Map<String, Object> storeData(String platform) {
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("storeInfo", storeInfo());
+        data.put("client", platform);
+        data.put("setting", getPublicSetting());
+        data.put("clientData", clientData());
+        return data;
+    }
+
+    /** /api/setting/data 出参,对齐 PHP api\service\Setting::getPublic */
+    public Map<String, Object> settingData() {
+        return getPublicSetting();
+    }
+
+    /** 店铺基本信息:隐藏 sort / isRecycle / isDelete / createTime / updateTime,补 imageUrl */
+    private ObjectNode storeInfo() {
+        Store store = storeInfoService.getInfo(ApiContext.storeId());
+        ObjectNode node = view.of(store);
+        view.hide(node, "sort", "isRecycle", "isDelete", "createTime", "updateTime");
+        // 对齐 PHP logoImage()->bind(['image_url' => 'preview_url'])
+        String imageUrl = store.getLogoImage() == null ? null : store.getLogoImage().getPreviewUrl();
+        node.put("imageUrl", imageUrl);
+        return node;
+    }
+
+    /** 客户端公共数据:H5 基础设置 */
+    private Map<String, Object> clientData() {
+        Object item = clientSettingService.getH5Item("basic", baseUrl, ApiContext.storeId());
+        Map<String, Object> h5 = new LinkedHashMap<>();
+        h5.put("setting", pick(item, "enabled", "baseUrl"));
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("h5", h5);
+        return data;
+    }
+
+    // ---------------------------------------------------------------- 工具
+
+    /**
+     * 对齐 PHP helper::pick:按源数据顺序取指定键。
+     * 源不是对象(缺失项返回的 []) 时同样返回 [],与 PHP 空数组一致。
+     */
+    @SuppressWarnings("unchecked")
+    private static Object pick(Object source, String... columns) {
+        if (!(source instanceof Map<?, ?> sourceMap)) {
+            return new ArrayList<>();
+        }
+        List<String> keys = List.of(columns);
+        Map<String, Object> picked = new LinkedHashMap<>();
+        for (Map.Entry<String, Object> entry : ((Map<String, Object>) sourceMap).entrySet()) {
+            if (keys.contains(entry.getKey())) {
+                picked.put(entry.getKey(), entry.getValue());
+            }
+        }
+        return picked;
+    }
+}

+ 73 - 0
yoshop-java/src/main/java/cn/yoshop/api/support/ApiContext.java

@@ -0,0 +1,73 @@
+package cn.yoshop.api.support;
+
+import cn.yoshop.api.auth.ApiAuthInterceptor;
+import cn.yoshop.common.entity.User;
+import cn.yoshop.common.exception.BizException;
+
+import jakarta.servlet.http.HttpServletRequest;
+
+/**
+ * 客户端请求上下文。
+ *
+ * <p>PHP 端 {@code getStoreId()} 当前固定返回 10001(单商城版),{@code storeId} 不来自请求;
+ * 平台标识来自请求头 {@code platform}(H5 / MP-WEIXIN / APP)。
+ */
+public final class ApiContext {
+
+    /** 单商城版本固定商城 ID,对齐 PHP app/common.php getStoreId() */
+    public static final int STORE_ID = 10001;
+
+    /** 未登录提示,对齐 PHP config('status.not_logged') = 401 */
+    private static final String NOT_LOGGED_MESSAGE = "请先登录后再访问";
+
+    private ApiContext() {
+    }
+
+    public static int storeId() {
+        return STORE_ID;
+    }
+
+    /** 当前客户端标识(请求头 platform,缺省 null) */
+    public static String platform(HttpServletRequest request) {
+        String platform = request.getHeader("platform");
+        if (platform == null || platform.isBlank()) {
+            platform = request.getParameter("platform");
+        }
+        return platform == null || platform.isBlank() ? null : platform;
+    }
+
+    /** 当前登录用户,未登录返回 null(不报错) */
+    public static User user(HttpServletRequest request) {
+        Object value = request.getAttribute(ApiAuthInterceptor.ATTR_LOGIN_USER);
+        return value instanceof User u ? u : null;
+    }
+
+    /** 当前登录用户,未登录抛 401(对齐 PHP getLoginUser(true)) */
+    public static User requireUser(HttpServletRequest request) {
+        User user = user(request);
+        if (user == null) {
+            throw new BizException(NOT_LOGGED_MESSAGE, 401);
+        }
+        return user;
+    }
+
+    public static String token(HttpServletRequest request) {
+        Object value = request.getAttribute(ApiAuthInterceptor.ATTR_API_TOKEN);
+        return value == null ? null : String.valueOf(value);
+    }
+
+    /**
+     * 合并请求参数:先取 query / form,再用 JSON body 覆盖。
+     * 对齐 PHP {@code request()->param()}(GET 与 POST body 统一读取)。
+     */
+    public static java.util.Map<String, Object> params(HttpServletRequest request,
+                                                       java.util.Map<String, Object> body) {
+        java.util.Map<String, Object> params = new java.util.LinkedHashMap<>();
+        request.getParameterMap().forEach((key, values) ->
+                params.put(key, values != null && values.length > 0 ? values[0] : null));
+        if (body != null) {
+            params.putAll(body);
+        }
+        return params;
+    }
+}

+ 74 - 0
yoshop-java/src/main/java/cn/yoshop/api/support/ApiView.java

@@ -0,0 +1,74 @@
+package cn.yoshop.api.support;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+
+import org.springframework.stereotype.Component;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 客户端出参组装工具。
+ *
+ * <p>实体序列化后按接口裁剪字段:PHP 各模型用 {@code $hidden} 控制“同一实体的不同接口字段不同”,
+ * 这里用 {@link #hide(ObjectNode, String...)} 移除该接口不应出现的键,避免为每个接口单独建 DTO。
+ */
+@Component
+public class ApiView {
+
+    private final ObjectMapper objectMapper;
+
+    public ApiView(ObjectMapper objectMapper) {
+        this.objectMapper = objectMapper;
+    }
+
+    /** 实体 → JSON 对象(字段名按实体上的 @JsonProperty) */
+    public ObjectNode of(Object bean) {
+        return objectMapper.valueToTree(bean);
+    }
+
+    /** 移除指定字段,返回同一对象便于链式调用 */
+    public ObjectNode hide(ObjectNode node, String... fields) {
+        if (node != null) {
+            node.remove(List.of(fields));
+        }
+        return node;
+    }
+
+    /** 隐藏“基础字段集 + 追加字段”的组合场景 */
+    public ObjectNode hideWith(ObjectNode node, String[] base, String... extra) {
+        if (node == null) {
+            return null;
+        }
+        node.remove(List.of(base));
+        if (extra.length > 0) {
+            node.remove(List.of(extra));
+        }
+        return node;
+    }
+
+    /** 批量实体 → 裁剪后的 JSON 数组 */
+    public ArrayNode list(List<?> beans, String... fields) {
+        ArrayNode array = objectMapper.createArrayNode();
+        for (Object bean : beans) {
+            array.add(hide(of(bean), fields));
+        }
+        return array;
+    }
+
+    /** 有序 Map 构造:map("a", 1, "b", 2) */
+    public Map<String, Object> map(Object... kv) {
+        Map<String, Object> data = new LinkedHashMap<>();
+        for (int i = 0; i + 1 < kv.length; i += 2) {
+            data.put(String.valueOf(kv[i]), kv[i + 1]);
+        }
+        return data;
+    }
+
+    public ObjectMapper mapper() {
+        return objectMapper;
+    }
+}

+ 8 - 2
yoshop-java/src/main/java/cn/yoshop/config/WebMvcConfig.java

@@ -1,5 +1,6 @@
 package cn.yoshop.config;
 
+import cn.yoshop.api.auth.ApiAuthInterceptor;
 import cn.yoshop.common.auth.AdminAuthInterceptor;
 import cn.yoshop.common.auth.StoreAuthInterceptor;
 import org.springframework.beans.factory.annotation.Value;
@@ -20,6 +21,7 @@ public class WebMvcConfig implements WebMvcConfigurer {
 
     private final StoreAuthInterceptor storeAuthInterceptor;
     private final AdminAuthInterceptor adminAuthInterceptor;
+    private final ApiAuthInterceptor apiAuthInterceptor;
 
     @Value("${yoshop.upload-path:runtime/uploads}")
     private String uploadPath;
@@ -29,9 +31,11 @@ public class WebMvcConfig implements WebMvcConfigurer {
     private String exportPath;
 
     public WebMvcConfig(StoreAuthInterceptor storeAuthInterceptor,
-                        AdminAuthInterceptor adminAuthInterceptor) {
+                        AdminAuthInterceptor adminAuthInterceptor,
+                        ApiAuthInterceptor apiAuthInterceptor) {
         this.storeAuthInterceptor = storeAuthInterceptor;
         this.adminAuthInterceptor = adminAuthInterceptor;
+        this.apiAuthInterceptor = apiAuthInterceptor;
     }
 
     /**
@@ -63,6 +67,8 @@ public class WebMvcConfig implements WebMvcConfigurer {
                 .addPathPatterns("/store/**");
         registry.addInterceptor(adminAuthInterceptor)
                 .addPathPatterns("/admin/**");
-        // 客户端 /api/** 使用用户 token,体系不同,后续单独做
+        // 客户端 /api/**:只解析 token(不拦截),是否强制登录由各接口决定
+        registry.addInterceptor(apiAuthInterceptor)
+                .addPathPatterns("/api/**");
     }
 }

+ 3 - 0
yoshop-java/src/main/resources/application.yml

@@ -59,3 +59,6 @@ yoshop:
     # 平台后台(admin)登录态,与商家端分开存,互不串号
     admin-token-prefix: "yoshop:admin:token:"
     admin-token-ttl: 604800
+    # 客户端(小程序/H5)用户登录态:只存用户 ID,业务接口回查用户表
+    api-token-prefix: "yoshop:api:token:"
+    api-token-ttl: 604800

+ 77 - 0
接口实现进度-第10批-api.md

@@ -0,0 +1,77 @@
+# 接口实现进度(第 10 批 · 客户端 api 第 1 阶段)
+
+> 目标:把 PHP `app/api`(客户端:微信小程序 / H5)重写为 Java。
+> 接口总量 74 个,按 4 批交付;本批次为**第 1 批:基础设施 + 商品/内容/营销只读接口(22 个)**。
+
+## 一、本批已实现(22 / 74)
+
+| 分组 | 接口 | 说明 |
+|---|---|---|
+| 基础 | `index/index` | 纯文本提示(非 JSON),与 PHP 一致 |
+| 商城 | `store/data` | `{storeInfo, client, setting, clientData}` |
+| 设置 | `setting/data` | 仅公开项:appTheme / pageCategoryTemplate / points / recharge / register / customer |
+| 地区 | `region/all`、`region/tree` | 复用 `RegionService`,键为地区 ID 字符串 |
+| 分类 | `category/list` | 树状,仅 status=1 |
+| 文章 | `article/list`、`article/detail`、`article.category/list` | 详情阅读数 +1、content 反转义 |
+| 帮助 | `help/list` | 分页 15 |
+| 页面 | `page/detail` | DIY 页面:window / goods / coupon / article / special 组件动态填充 |
+| 商品 | `goods/list`、`detail`、`basic`、`spec_data`、`sku_info` | 固定只出上架商品,含会员价 |
+| 服务承诺 | `goods.service/list` | 按商品绑定关系取启用项 |
+| 评价 | `comment/list`、`comment/total`、`comment/list_rows` | user.avatar / orderGoods / images.file |
+| 优惠券 | `coupon/list` | 已登录追加 `isReceive` |
+| 物流 | `express/list` | 隐藏快递100 / 快递鸟编码 |
+
+## 二、代码结构
+
+新增包 `cn.yoshop.api`:
+
+```
+api/
+  auth/ApiTokenStore          # Redis 登录态:key=yoshop:api:token:{token},value=用户ID
+  auth/ApiAuthInterceptor     # 只解析 token 塞 loginUser,不拦截
+  support/ApiContext          # storeId(10001)/platform(header)/user()/requireUser()/params()
+  support/ApiView             # 实体→ObjectNode + 按接口裁剪字段(对应 PHP $hidden)
+  service/ApiSettingService   # store/data、setting/data
+  service/ApiContentService   # 分类 / 文章 / 帮助 / DIY 页面
+  service/ApiGoodsService     # 商品列表、详情、基础、规格、SKU、服务承诺、会员价
+  service/ApiCommentService   # 评价列表 / 统计 / 限量列表
+  service/ApiCouponService    # 优惠券列表(含 isReceive、适用范围过滤)
+  controller/ApiIndexController / ApiStoreController / ApiRegionController
+             ApiContentController / ApiGoodsController / ApiMarketingController
+```
+
+## 三、关键口径
+
+1. **登录态**:`yoshop:api:token:{token}` → 只存**用户 ID**。客户端余额/积分变动频繁,缓存整份用户会读到旧数据,业务接口按 ID 回查 `yoshop_user`(PHP 也是这么做的)。
+2. **不做全局登录拦截**:PHP `initialize()` 只校验 storeId / 商城状态 / 客户端开关,登录由各接口自行决定。`/api/**` 用一个只解析不拦截的 `ApiAuthInterceptor`,需要登录的接口(后续批次的购物车、订单等)调 `ApiContext.requireUser(request)` 抛 401「请先登录后再访问」。
+3. **storeId 固定 10001**:PHP `getStoreId()` 就是硬编码,不读请求参数。
+4. **平台标识**:请求头 `platform`(MP-WEIXIN / H5),缺省回落到 query 参数。
+5. **出入参驼峰**:与 store / admin 统一,不再与 PHP snake_case 逐字段对齐。
+6. **字段裁剪**:PHP 每个模型用 `$hidden` 控制字段集合,Java 侧序列化后用 `ApiView.hide(...)` 移除同名键,不额外建 DTO。
+7. **会员价**:登录用户有有效等级且商品 `isEnableGrade=1` 时,`goodsPriceMin/Max` 与 `skuList[].goodsPrice` 按 `round(price × discount/10, 2)` 重算,并置 `isUserGrade=true`;商品单独配置优先(`isAloneGrade` + `aloneGradeEquity[gradeId]`)。
+
+## 四、验证
+
+`python tools/test_api_batch1.py`(需 18080 在跑):**52 项断言全过**。
+
+覆盖点:
+- `store/data` 四段结构、storeInfo 隐藏字段与 `imageUrl`、client 取请求头;
+- `setting/data` 六项,缺失项按 PHP 语义输出空数组;
+- 地区全量/树、分类树隐藏字段、文章列表/详情(临时造数据、阅读数 +1、跑完删除);
+- DIY 首页 4 个组件:banner 3 条、goods 6 条且字段裁剪正确;
+- 商品列表(分页 15、隐藏 content/images/goodsImages、含 goodsImage/goodsSales/isUserGrade)、
+  多规格详情(skuList + specList)、基础信息(无 skuList/video、有 content)、
+  规格数据、SKU 详情、服务承诺;
+- 评价三接口、优惠券列表(未登录不返回 isReceive)、物流公司;
+- 出参**零 snake_case 键**(商品列表 / 商城信息 / 商品详情 / 分类)。
+
+## 五、后续批次(52 个)
+
+| 批次 | 范围 | 接口数 |
+|---|---|---|
+| 第 2 批 | 购物车、结算预览、下单、订单列表/详情/取消/收货/角标、订单评价、收货地址 | 22 |
+| 第 3 批 | 用户中心(info/assets/bind_mobile/personal)、我的优惠券、余额/积分明细、退款、充值、上传 | 18 |
+| 第 4 批 | 登录(含微信小程序登录)、图形/短信验证码、收银台、支付回调 | 12 |
+
+外部依赖口径(已确认):微信小程序登录(jscode2session)与图形验证码真做;
+微信/支付宝支付、短信验证码先做可插拔骨架,未配置时返回明确错误文案。

+ 11 - 0
联调说明.md

@@ -113,3 +113,14 @@ NODE_OPTIONS=--openssl-legacy-provider ./node_modules/.bin/vue-cli-service serve
 - 顺手修了两个前端原有 bug:回收站与 API 权限列表的列配置挂了不存在的 scopedSlots 导致整列空白,已去掉。
 - 一键验证:起 Java 18080 + 前端 8110,浏览器登录 admin(测试口令 123456,原哈希备份在
   2026-10-18 工作日志)后逐页点一遍即可。
+
+## 七、客户端(api)联调
+
+第 10 批完成客户端 api 第一阶段(22 / 74 个接口),详见 `接口实现进度-第10批-api.md`。
+
+- 基础地址:`/api/**`,与 PHP 的 `/index.php?s=/api/xxx` 对应
+- 请求头:`platform`(MP-WEIXIN / H5)、`Access-Token`(用户 token,可无)
+- 登录态:Redis `yoshop:api:token:{token}`,值=用户 ID;`/api/**` 只解析不拦截,
+  需登录的接口(购物车、订单等,第 2 批起)由接口内部判定并返回 401「请先登录后再访问」
+- 出参:驼峰,沿用 store / admin 口径;列表分页结构 `{total, perPage, currentPage, lastPage, data}`
+- 一键验证:`python tools/test_api_batch1.py`(需 18080 在跑,52 项断言)