Browse Source

修复 setting/data 缺少 setting 包裹导致 uniapp 登录页报错

- 现象:H5 登录页 Cannot read properties of undefined (reading 'register')
- 根因:PHP 控制器是 renderSuccess(compact('setting')),出参为 {setting: {...}};
  Java 直接把六个设置项平铺在 data 上,客户端 common/model/Setting.js 读
  result.data.setting 得到 undefined,再取 ['register'] 就抛错
- 修改:ApiSettingService.settingData() 包一层 setting;store/data 结构不变
- 回归:test_api_batch1 改为断言外层包裹(53 项全过)、test_api_batch4 77 项全过
- 新增 tools/test_uniapp_contract.py:按 uniapp 真实 {form:{...}} 结构跑
  「图形验证码 → 发送短信 → 手机号登录 → user/info」9 项断言,跑完删测试用户
- 文档 接口实现进度-第10批-api.md 补该包裹说明
tanlie 8 hours ago
parent
commit
74ee9c15ca

+ 4 - 1
tools/test_api_batch1.py

@@ -102,7 +102,10 @@ def main():
     check("clientData.h5.setting 存在", "setting" in ((data.get("clientData") or {}).get("h5") or {}))
 
     r = get("/api/setting/data")
-    setting = r.get("data") or {}
+    # PHP 控制器是 renderSuccess(compact('setting')),客户端读 result.data.setting
+    setting = (r.get("data") or {}).get("setting") or {}
+    check("setting/data 外层有 setting 包裹", isinstance((r.get("data") or {}).get("setting"), dict),
+          list(r.get("data") or {}))
     check("setting/data 六个公共项", set(setting) == {
         "appTheme", "pageCategoryTemplate", "points", "recharge", "register", "customer"}, list(setting))
     check("setting/data register 有 registerMethod",

+ 114 - 0
tools/test_uniapp_contract.py

@@ -0,0 +1,114 @@
+# -*- coding: utf-8 -*-
+"""uniapp 客户端契约测试:按 uniapp 的真实提交/解包结构验证关键链路。
+
+与 test_api_batch*.py 的区别:那些脚本用平铺 JSON,这里用 uniapp 实际发出的
+`{form: {...}}` 包裹结构,并覆盖客户端对响应外层键的硬依赖。
+
+覆盖:
+  1. /api/setting/data 必须带 setting 包裹(登录页 SettingModel 读 result.data.setting)
+  2. 图形验证码 → 发送短信 → 手机号登录(全部用 {form: ...} 包裹)
+  3. 登录后的 token 能否访问 /api/user/info
+
+前置:Java 服务运行在 18080,Redis / MySQL 可连(读 application.yml 取密码)。
+"""
+import json
+import re
+import socket
+import sys
+import requests
+import pymysql
+
+from redis_probe import cmd
+
+BASE = "http://127.0.0.1:18080"
+MOBILE = "13900002222"
+
+YML = open("../yoshop-java/src/main/resources/application.yml", encoding="utf-8").read()
+DB_PWD = re.findall(r"password:\s*(.+)", YML)[0].strip().strip("\"'")
+REDIS_PWD = re.findall(r"password:\s*(.+)", YML)[1].strip().strip("\"'")
+
+S = requests.Session()
+S.trust_env = False
+passed = 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 redis_cmd(*args):
+    sock = socket.create_connection(("192.168.1.177", 6379), timeout=5)
+    try:
+        cmd(sock, "AUTH", REDIS_PWD)
+        return cmd(sock, *args)
+    finally:
+        sock.close()
+
+
+def main():
+    # 1. 设置项结构
+    r = S.get(BASE + "/api/setting/data", timeout=10).json()
+    setting = (r.get("data") or {}).get("setting")
+    check("setting/data 有 setting 包裹", isinstance(setting, dict), list(r.get("data") or {}))
+    check("setting.register 存在", isinstance((setting or {}).get("register"), dict),
+          (setting or {}).get("register"))
+    check("registerMethod 有值", (setting or {}).get("register", {}).get("registerMethod") == 10,
+          (setting or {}).get("register"))
+
+    # 2. 图形验证码
+    r = S.get(BASE + "/api/captcha/image", timeout=10).json()
+    data = r.get("data") or {}
+    code = json.loads(redis_cmd("GET", "yoshop:api:captcha:" + str(data.get("key"))))["code"]
+    check("验证码返回 key/base64", bool(data.get("key")) and str(data.get("base64", "")).startswith("data:image"))
+
+    # 3. 发送短信(uniapp 结构:{form: {...}})
+    r = S.post(BASE + "/api/captcha/sendSmsCaptcha",
+               json={"form": {"mobile": MOBILE, "captchaCode": code, "captchaKey": data.get("key")}},
+               timeout=10).json()
+    check("form 包裹的发送短信成功", r.get("status") == 200, r)
+    sms = json.loads(redis_cmd("GET", "yoshop:api:sms:" + MOBILE))["code"]
+
+    # 4. 登录(uniapp 结构)
+    r = S.post(BASE + "/api/passport/login",
+               json={"form": {"mobile": MOBILE, "smsCode": sms, "isParty": False, "partyData": {}}},
+               timeout=10).json()
+    d = r.get("data") or {}
+    check("form 包裹的登录成功", r.get("status") == 200 and r.get("message") == "登录成功", r)
+    check("返回 userId/token", isinstance(d.get("userId"), int)
+          and re.fullmatch(r"[0-9a-f]{32}", str(d.get("token"))) is not None, d)
+
+    # 5. token 访问用户中心
+    token = d.get("token")
+    r = S.get(BASE + "/api/user/info", headers={"Access-Token": token}, timeout=10).json()
+    info = (r.get("data") or {}).get("userInfo") or {}
+    check("user/info 可访问", r.get("status") == 200, r)
+    check("userInfo 有 userId/nickName", info.get("userId") == d.get("userId"), list(info)[:8])
+
+    # 清理:删掉本次登录自动注册的用户与临时键
+    conn = pymysql.connect(host="192.168.1.177", user="root", password=DB_PWD,
+                           database="shop", charset="utf8mb4", autocommit=True)
+    try:
+        cur = conn.cursor()
+        cur.execute("select user_id from yoshop_user where mobile=%s", (MOBILE,))
+        row = cur.fetchone()
+        if row:
+            cur.execute("delete from yoshop_user where user_id=%s", (row[0],))
+            print(f"  CLEAN 已删除测试用户 {row[0]}")
+    finally:
+        conn.close()
+    for key in [f"yoshop:api:sms:{MOBILE}", f"yoshop:api:sms:send:{MOBILE}",
+                "yoshop:api:token:" + str(token)]:
+        redis_cmd("DEL", key)
+
+    print(f"\n结果:PASS {passed} / FAIL {failed}")
+    return 0 if failed == 0 else 1
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 17 - 15
yoshop-java/src/main/java/cn/yoshop/api/controller/ApiContentController.java

@@ -1,14 +1,16 @@
 package cn.yoshop.api.controller;
 
 import cn.yoshop.api.service.ApiContentService;
+import cn.yoshop.api.support.ApiContext;
 import cn.yoshop.common.api.R;
-import tools.jackson.databind.node.ArrayNode;
-
+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.Map;
 
@@ -33,10 +35,10 @@ public class ApiContentController {
 
     /** /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)
+    public R<Map<String, Object>> articleList(@RequestBody(required = false)
                                               Map<String, Object> body,
-                                              jakarta.servlet.http.HttpServletRequest request) {
-        Map<String, Object> params = cn.yoshop.api.support.ApiContext.params(request, body);
+                                              HttpServletRequest request) {
+        Map<String, Object> params = ApiContext.params(request, body);
         Integer categoryId = intOrNull(params.get("categoryId"));
         int page = intVal(params.get("page"), 1);
         Map<String, Object> data = new LinkedHashMap<>();
@@ -46,10 +48,10 @@ public class ApiContentController {
 
     /** /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)
+    public R<Map<String, Object>> articleDetail(@RequestBody(required = false)
                                                 Map<String, Object> body,
-                                                jakarta.servlet.http.HttpServletRequest request) {
-        Map<String, Object> params = cn.yoshop.api.support.ApiContext.params(request, body);
+                                                HttpServletRequest request) {
+        Map<String, Object> params = ApiContext.params(request, body);
         Integer articleId = intOrNull(params.get("articleId"));
         if (articleId == null) {
             throw cn.yoshop.common.exception.BizException.silent("参数错误:articleId");
@@ -69,10 +71,10 @@ public class ApiContentController {
 
     /** /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)
+    public R<Map<String, Object>> helpList(@RequestBody(required = false)
                                            Map<String, Object> body,
-                                           jakarta.servlet.http.HttpServletRequest request) {
-        Map<String, Object> params = cn.yoshop.api.support.ApiContext.params(request, body);
+                                           HttpServletRequest request) {
+        Map<String, Object> params = ApiContext.params(request, body);
         Map<String, Object> data = new LinkedHashMap<>();
         data.put("list", apiContentService.helpList(intVal(params.get("page"), 1)));
         return R.ok(data);
@@ -80,13 +82,13 @@ public class ApiContentController {
 
     /** /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)
+    public R<Map<String, Object>> pageDetail(@RequestBody(required = false)
                                              Map<String, Object> body,
-                                             jakarta.servlet.http.HttpServletRequest request) {
-        Map<String, Object> params = cn.yoshop.api.support.ApiContext.params(request, body);
+                                             HttpServletRequest request) {
+        Map<String, Object> params = 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)));
+                ApiContext.user(request)));
         return R.ok(data);
     }
 

+ 8 - 2
yoshop-java/src/main/java/cn/yoshop/api/service/ApiSettingService.java

@@ -85,9 +85,15 @@ public class ApiSettingService {
         return data;
     }
 
-    /** /api/setting/data 出参,对齐 PHP api\service\Setting::getPublic */
+    /**
+     * /api/setting/data 出参,对齐 PHP api\service\Setting::getPublic
+     * 注意:PHP 控制器是 renderSuccess(compact('setting')),所以外层必须有 setting 包裹,
+     * 客户端 common/model/Setting.js 读的是 result.data.setting。
+     */
     public Map<String, Object> settingData() {
-        return getPublicSetting();
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("setting", getPublicSetting());
+        return data;
     }
 
     /** 店铺基本信息:隐藏 sort / isRecycle / isDelete / createTime / updateTime,补 imageUrl */

+ 2 - 1
接口实现进度-第10批-api.md

@@ -56,7 +56,8 @@ api/
 
 覆盖点:
 - `store/data` 四段结构、storeInfo 隐藏字段与 `imageUrl`、client 取请求头;
-- `setting/data` 六项,缺失项按 PHP 语义输出空数组;
+- `setting/data` 六项,外层带 `setting` 包裹(对齐 PHP `renderSuccess(compact('setting'))`,
+  客户端 `common/model/Setting.js` 读的是 `result.data.setting`),缺失项按 PHP 语义输出空数组;
 - 地区全量/树、分类树隐藏字段、文章列表/详情(临时造数据、阅读数 +1、跑完删除);
 - DIY 首页 4 个组件:banner 3 条、goods 6 条且字段裁剪正确;
 - 商品列表(分页 15、隐藏 content/images/goodsImages、含 goodsImage/goodsSales/isUserGrade)、