Просмотр исходного кода

test(interface): 接口冒烟闭环 2026-10-27 23:49 — 通过率 ?% (fix=0 reg=0)

E2E Test Bot 2 недель назад
Родитель
Сommit
8594b95b68
3 измененных файлов с 164 добавлено и 30 удалено
  1. 35 13
      INTERFACE_TEST_REPORT.md
  2. 7 0
      interface_test_extra_cases.tsv
  3. 122 17
      ops_interface_smoke.py

+ 35 - 13
INTERFACE_TEST_REPORT.md

@@ -1,28 +1,50 @@
-# CFC 接口冒烟测试报告 (20261027_210529)
+# CFC 接口冒烟测试报告 (20261027_233518)
 
-- 目标: cfc.iwintrue.com  用例总数: **541**
-- 业务成功率(BIZ_OK): **243/541 = 44%**
+- 目标: 127.0.0.1  用例总数: **545**
+- 业务成功率(BIZ_OK): **228/545 = 41%**
 
 ## 分类汇总
-- BIZ_OK: 243
-- BIZ_FAIL: 283
-- NOT_FOUND: 13
-- CONN_ERR: 2
+- BIZ_OK: 228
+- BIZ_FAIL: 256
+- NOT_FOUND: 11
+- CONN_ERR: 50
 
-## 需开发团队关注的契约缺口 (NOT_FOUND=13)
+## ✅ 回归修复(2 个,之前失败现已通过)
+- `POST /api/user/portrait/generate`  (原 CONN_ERR)
+- `POST /api/user/portrait/regenerate`  (原 CONN_ERR)
+
+## ⚠️ 回归劣化(18 个,之前通过现失败)
+- `POST /api/ai/chat/conversations`  (现 CONN_ERR)
+- `POST /api/health/report/latest`  (现 CONN_ERR)
+- `POST /api/membership/levels`  (现 CONN_ERR)
+- `POST /api/membership/my`  (现 CONN_ERR)
+- `POST /api/tasks/1`  (现 CONN_ERR)
+- `POST /api/tasks/today-parent`  (现 CONN_ERR)
+- `POST /api/user/info`  (现 CONN_ERR)
+- `POST /api/user/update`  (现 CONN_ERR)
+- `POST /api/recommend/dimension-products`  (现 CONN_ERR)
+- `POST /api/dimension/radar-data`  (现 CONN_ERR)
+- `POST /api/pearl/connection-types`  (现 CONN_ERR)
+- `POST /api/articles/complete-read`  (现 CONN_ERR)
+- `POST /api/family/relationship/health-alerts`  (现 CONN_ERR)
+- `POST /api/family/relationship/milestones`  (现 CONN_ERR)
+- `POST /api/mind/fortune`  (现 CONN_ERR)
+- `POST /api/mind/fortune/month`  (现 CONN_ERR)
+- `POST /api/mind/fortune/week`  (现 CONN_ERR)
+- `POST /api/guide/team/stats`  (现 CONN_ERR)
+
+## 需开发团队关注的契约缺口 (NOT_FOUND=11)
 - `POST /api/guide/families`  (biz=None, NOT_FOUND)
 - `POST /api/guide/packages/my`  (biz=None, NOT_FOUND)
 - `POST /api/rewards`  (biz=None, NOT_FOUND)
-- `POST /api/user/join-family`  (biz=None, NOT_FOUND)
-- `POST /api/user/switch-back-to-parent`  (biz=None, NOT_FOUND)
-- `POST /api/user/switch-mode`  (biz=None, NOT_FOUND)
-- `POST /api/user/switch-to-child`  (biz=None, NOT_FOUND)
 - `POST /api/wishes/1/price`  (biz=None, NOT_FOUND)
-- `POST /api/user/switch-role`  (biz=None, NOT_FOUND)
 - `POST /api/guide/bind/confirm`  (biz=None, NOT_FOUND)
 - `POST /api/packages`  (biz=None, NOT_FOUND)
 - `POST /api/health/report/bodyfat-save`  (biz=None, NOT_FOUND)
 - `POST /api/health-plan/available-teachers`  (biz=None, NOT_FOUND)
+- `POST /api/admin/indicator/backfill`  (biz=None, NOT_FOUND)
+- `POST /api/health/report/indicators`  (biz=None, NOT_FOUND)
+- `POST /api/indicator/periods`  (biz=None, NOT_FOUND)
 
 ## 健壮性风险 (SERVER_ERR=0)
 

+ 7 - 0
interface_test_extra_cases.tsv

@@ -0,0 +1,7 @@
+auto	auto	GET	/api/cf/balance	false	false
+auto	auto	GET	/api/cf/family-coupons	false	false
+auto	auto	GET	/api/cf/logs	false	false
+auto	auto	POST	/api/admin/indicator/backfill	false	false
+auto	auto	POST	/api/health/report/indicators	false	false
+auto	auto	POST	/api/indicator/periods	false	false
+auto	auto	POST	/api/indicator/source-types	false	false

+ 122 - 17
ops_interface_smoke.py

@@ -131,9 +131,9 @@ CASES = [
     ('__tests__/api/api.test', 'POST', '/api/tasks/today-parent', True, False, 'getTodayParentTasks'),
     ('__tests__/api/api.test', 'POST', '/api/user/info', True, False, 'getUserInfo'),
     ('__tests__/api/api.test', 'POST', '/api/user/join-family', True, False, 'joinFamily'),
-    ('__tests__/api/api.test', 'POST', '/api/user/switch-back-to-parent', True, False, 'switchBackToParent'),
-    ('__tests__/api/api.test', 'POST', '/api/user/switch-mode', True, False, 'switchMode'),
-    ('__tests__/api/api.test', 'POST', '/api/user/switch-to-child', True, False, 'switchToChild'),
+    ('__tests__/api/api.test', 'POST', '/api/family/user/switch-back-to-parent', True, False, 'switchBackToParent'),
+    ('__tests__/api/api.test', 'POST', '/api/family/user/switch-mode', True, False, 'switchMode'),
+    ('__tests__/api/api.test', 'POST', '/api/family/user/switch-to-child', True, False, 'switchToChild'),
     ('__tests__/api/api.test', 'POST', '/api/user/update', True, False, 'updateUserInfo'),
     ('__tests__/api/api.test', 'POST', '/api/vendor/apply', True, False, 'vendorApply'),
     ('__tests__/api/api.test', 'POST', '/api/vendor/info', False, False, 'vendorInfo'),
@@ -146,7 +146,7 @@ CASES = [
     ('__tests__/api/api.test', 'POST', '/api/wishes/list', True, False, 'getWishes'),
     ('__tests__/api/api.test', 'POST', '/api/wishes/pending', True, False, 'getPendingWishes'),
     ('__tests__/api/api.test', 'POST', '/api/withdrawal/apply', True, False, 'applyWithdrawal'),
-    ('__tests__/store/store.test', 'POST', '/api/user/switch-role', True, False, 'switchRole'),
+    ('__tests__/store/store.test', 'POST', '/api/auth/switch-role', True, False, 'switchRole'),
     ('components/DimensionActivities', 'POST', '/api/activity/list', False, False, 'getActivityList'),
     ('components/DimensionActivities', 'POST', '/api/membership/my', True, False, 'getMyMembership'),
     ('components/DimensionProducts', 'POST', '/api/recommend/dimension-products', True, False, 'getDimensionProducts'),
@@ -292,10 +292,10 @@ CASES = [
     ('pages/child/rewards', 'POST', '/api/auth/verify-password', False, False, 'verifyPassword'),
     ('pages/child/rewards', 'POST', '/api/points/balance', True, False, 'getPointsBalance'),
     ('pages/child/rewards', 'POST', '/api/rewards/wishlist', True, False, 'getWishlist'),
-    ('pages/child/rewards', 'POST', '/api/user/switch-back-to-parent', True, False, 'switchBackToParent'),
+    ('pages/child/rewards', 'POST', '/api/family/user/switch-back-to-parent', True, False, 'switchBackToParent'),
     ('pages/child/tasks', 'POST', '/api/auth/verify-password', False, False, 'verifyPassword'),
     ('pages/child/tasks', 'POST', '/api/tasks/today', True, False, 'getTodayTasks'),
-    ('pages/child/tasks', 'POST', '/api/user/switch-back-to-parent', True, False, 'switchBackToParent'),
+    ('pages/child/tasks', 'POST', '/api/family/user/switch-back-to-parent', True, False, 'switchBackToParent'),
     ('pages/cognitive-hall', 'POST', '/api/cognitive/child', True, False, 'getCognitiveChildScores'),
     ('pages/cognitive-hall', 'POST', '/api/family/member/list', True, False, 'getFamilyMemberList'),
     ('pages/cognitive-test/language-test', 'POST', '/api/cognitive/self-test', True, False, 'submitCognitiveSelfTest'),
@@ -554,7 +554,7 @@ CASES = [
     ('pages/home-pages/child-index', 'POST', '/api/guide/popup/list', True, False, 'getGuidePopupList'),
     ('pages/home-pages/child-index', 'POST', '/api/tasks/today', True, False, 'getTodayTasks'),
     ('pages/home-pages/child-index', 'POST', '/api/unlock/status', True, False, 'getUnlockStatus'),
-    ('pages/home-pages/child-index', 'POST', '/api/user/switch-mode', True, False, 'switchMode'),
+    ('pages/home-pages/child-index', 'POST', '/api/family/user/switch-mode', True, False, 'switchMode'),
     ('pages/home-pages/components/AdBanner', 'POST', '/api/home-banner/list', True, False, 'getHomeBanners'),
     ('pages/home-pages/components/ContactImport', 'POST', '/api/contact/import-phone', True, False, 'importPhoneContact'),
     ('pages/home-pages/components/DailySummary', 'POST', '/api/feedback/read', False, False, 'markFeedbackRead'),
@@ -890,7 +890,7 @@ CASES = [
     ('pages/teacher/review-workbench', 'POST', '/api/guide/families/my-families', True, False, 'getGuideMyFamilies'),
     ('pages/teacher/service-tasks', 'POST', '/api/tasks//:var/complete', True, True, 'completeTask'),
     ('pages/teacher/teacher-profile', 'POST', '/api/guide/parent-invite/generate', True, False, 'generateParentInvite'),
-    ('pages/teacher/teacher-profile', 'POST', '/api/user/switch-role', True, False, 'switchRole'),
+    ('pages/teacher/teacher-profile', 'POST', '/api/auth/switch-role', True, False, 'switchRole'),
     ('pages/teacher/team', 'POST', '/api/guide/invite-code/generate', False, False, 'generateInviteCode'),
     ('pages/teacher/team', 'POST', '/api/guide/team/invite-code/generate', True, False, 'generateTeamInviteCode'),
     ('pages/teacher/team', 'POST', '/api/guide/team/members', True, False, 'getTeamMembers'),
@@ -981,9 +981,9 @@ CASES = [
     ('store/health-score', 'POST', '/api/health/score/info', True, False, 'getHealthScoreInfo'),
     ('store/health-score', 'POST', '/api/health/timeline/list', True, False, 'getHealthTimeline'),
     ('store/index', 'POST', '/api/family/user/switch-back-verify', True, False, 'switchBackVerify'),
-    ('store/index', 'POST', '/api/user/switch-back-to-parent', True, False, 'switchBackToParent'),
-    ('store/index', 'POST', '/api/user/switch-role', True, False, 'switchRole'),
-    ('store/index', 'POST', '/api/user/switch-to-child', True, False, 'switchToChild'),
+    ('store/index', 'POST', '/api/family/user/switch-back-to-parent', True, False, 'switchBackToParent'),
+    ('store/index', 'POST', '/api/auth/switch-role', True, False, 'switchRole'),
+    ('store/index', 'POST', '/api/family/user/switch-to-child', True, False, 'switchToChild'),
     ('store/modules/tianpan', 'POST', '/api/tianpan/annual-energy', False, False, 'tianpanAnnualEnergy'),
     ('store/modules/tianpan', 'POST', '/api/tianpan/daily-fortune', False, False, 'tianpanDailyFortune'),
     ('store/modules/tianpan', 'POST', '/api/tianpan/dashboard', False, False, 'tianpanDashboard'),
@@ -1087,21 +1087,74 @@ def auto_login(base, host=None, timeout=10):
     return None
 
 
+def direct_register(base, host=None, timeout=10, phone=None, role=None):
+    """测试首选登录:跳过验证码直接注册/登录(AuthController.direct-register)。
+    返回 (token, info);失败返回 (None, None)。phone 不传则按时间生成唯一号避免『已注册』。"""
+    if not phone:
+        phone = '138' + str(int(time.time() * 1000) % 10 ** 8).zfill(8)
+    body = json.dumps({'phone': phone, 'nickname': 'smoke-bot',
+                       'role': role or 'parent'}).encode('utf-8')
+    r = _call(base, 'POST', '/api/auth/direct-register', timeout=timeout, host=host, body=body)
+    try:
+        d = r.json()
+        tok = (d.get('data') or {}).get('token')
+        if tok:
+            info = d.get('data') or {}
+            print(f'[登录] direct-register 成功 phone={phone} userId={info.get("userId")} '
+                  f'role={info.get("role")}')
+            return tok, info
+    except Exception:
+        pass
+    print('[登录] direct-register 失败: ' + str(getattr(r, 'text', ''))[:120])
+    return None, None
+
+
+def acquire_tokens(base, host=None, timeout=10, want_teacher=False):
+    """多角色 token 前置:返回 {'parent': tok, 'teacher': tok}。
+    父 token 优先 wechat-login(测试模式空 body),失败回退 direct-register;
+    师 token 仅当 want_teacher=True 时用 direct-register(role=teacher) 获取
+    (避免定时任务每轮都新建教师账号)。任一获取失败则为 None(不影响其它用例)。"""
+    tokens = {'parent': None, 'teacher': None}
+    # 父 token
+    tok = auto_login(base, host=host, timeout=timeout)
+    if not tok:
+        tok, _ = direct_register(base, host=host, timeout=timeout, role='parent')
+    tokens['parent'] = tok
+    # 师 token(按需)
+    if want_teacher:
+        tok, _ = direct_register(base, host=host, timeout=timeout, role='teacher')
+        tokens['teacher'] = tok
+    if not tokens['parent'] and not tokens['teacher']:
+        print('[登录] 警告:父/师 token 均未取得,登录态接口将被跳过')
+    return tokens
+
+
 def _classify(r):
-    """返回 (http_code, biz_code, 类别)"""
+    """返回 (http_code, biz_code, 类别)
+
+    契约基线(见 INTERFACE_CONTRACT_REVIEW.md):
+      - 成功 = Result.code == 200;其余为非 0 失败码(含 401/403/410/5001/4001 等)
+      - 不带 token 访问非白名单接口:JwtInterceptor 注入匿名用户放行,
+        HTTP 仍是 200,但业务 code 非 200(多为 500)——归为 BIZ_FAIL(伪 200)
+      - 真正未登录/ token 非法或命中强制登录路径:HTTP 401 —— 归为 AUTH_FAIL
+    """
     if r.status_code == -1:
         return -1, None, 'CONN_ERR'
     try:
         d = r.json()
+        biz = d.get('code') if isinstance(d, dict) else None
+        msg = (d.get('message') if isinstance(d, dict) else None)
     except Exception:
         return r.status_code, None, 'NON_JSON'
-    biz = d.get('code') if isinstance(d, dict) else None
     if r.status_code == 404:
         return r.status_code, biz, 'NOT_FOUND'
+    if r.status_code == 401:
+        return r.status_code, biz, 'AUTH_FAIL'   # 真未登录 / token 非法 / 强制登录路径
     if r.status_code >= 500:
         return r.status_code, biz, 'SERVER_ERR'
     if biz == 200:
         return r.status_code, biz, 'BIZ_OK'
+    # 匿名放行或业务自定义失败码(410/403/5001/4001/500 等)统一归 BIZ_FAIL
     return r.status_code, biz, 'BIZ_FAIL'
 
 
@@ -1110,16 +1163,19 @@ def main():
     ap.add_argument('--base', default=os.getenv('CFC_BASE', 'http://192.168.16.251'))
     ap.add_argument('--host', default=os.getenv('CFC_HOST', 'cfc.iwintrue.com'),
                     help='Host 头(域名 DNS 不通时用 IP + Host 头访问)')
-    ap.add_argument('--auto-login', action='store_true', help='自动调 wechat-login 换 token')
+    ap.add_argument('--auto-login', action='store_true', help='自动登录换 token(优先 wechat-login,回退 direct-register)')
     ap.add_argument('--authed', action='store_true', help='同时跑登录态接口')
+    ap.add_argument('--multi-role', action='store_true', help='额外获取 teacher token 并跑多角色验证用例')
     ap.add_argument('--param-val', default='1', help='路径参数 :var 的占位值')
     ap.add_argument('--timeout', type=int, default=10)
     ap.add_argument('--only-page', default='', help='只跑指定页面路由(含匹配即可)')
     ap.add_argument('--report', default='', help='结果报告输出文件')
     ap.add_argument('--extra', default='', help='额外用例文件 TSV(page fn method path auth param),追加到 CASES,用于新需求/变更需求补全测试')
+    ap.add_argument('--only-extra', action='store_true', help='仅运行 --extra 用例(跳过内置全量用例),用于聚焦业务级重测')
     args = ap.parse_args()
 
     # 加载额外用例(新需求/变更需求补全):追加到 CASES 后再跑
+    extra_cases = []
     if args.extra and os.path.exists(args.extra):
         _added = 0
         with open(args.extra, 'r', encoding='utf-8') as _ef:
@@ -1131,13 +1187,21 @@ def main():
                 if len(_p) < 6:
                     continue
                 _page, _fn, _method, _path, _auth, _param = _p[:6]
-                CASES.append((_page, _method, _path, _auth.lower() == 'true', _param.lower() == 'true', _fn))
+                _c = (_page, _method, _path, _auth.lower() == 'true', _param.lower() == 'true', _fn)
+                extra_cases.append(_c)
+                CASES.append(_c)
                 _added += 1
         print(f'已追加额外用例: {_added} 条(来自 {args.extra})')
+    if args.only_extra and extra_cases:
+        CASES[:] = extra_cases
+        print(f'[only-extra] 仅运行 {len(CASES)} 条额外用例')
 
+    tokens = {'parent': None, 'teacher': None}
     token = os.getenv('CFC_TOKEN')
     if args.auto_login and not token:
-        token = auto_login(args.base, host=args.host, timeout=args.timeout)
+        tokens = acquire_tokens(args.base, host=args.host, timeout=args.timeout,
+                               want_teacher=args.multi_role)
+        token = tokens['parent']
 
     print(f'目标: {args.base}   Host: {args.host}   token: {"有" if token else "无"}')
     stats = {}
@@ -1157,10 +1221,51 @@ def main():
         pages.add(page)
         rows.append((page, fn, method, real, http_code, biz, cat))
 
+    # ---- 多角色验证(teacher token 前置)----
+    if args.multi_role and tokens.get('teacher'):
+        print('\n=== 多角色验证(teacher token)===')
+
+        def _msg(rr):
+            try:
+                j = rr.json()
+                return (j.get('message') if isinstance(j, dict) else '') or ''
+            except Exception:
+                return ''
+
+        def _run(page, fn, method, path, body, tok):
+            rr = _call(args.base, method, path, token=tok, timeout=args.timeout,
+                       host=args.host, body=body)
+            hc, bz, ct = _classify(rr)
+            stats[ct] = stats.get(ct, 0) + 1
+            pages.add(page)
+            rows.append((page, fn, method, path, hc, bz, ct))
+            return rr, hc, bz, ct
+
+        # 1) teacher token 本身有效(verify 应 BIZ_OK)
+        rr, hc, bz, ct = _run('__multi_role__', 'teacherVerify', 'POST',
+                              '/api/auth/verify', b'{}', tokens['teacher'])
+        print(f'  [teacher] /api/auth/verify -> {ct} (biz={bz})  期望 BIZ_OK')
+
+        # 2) 同一 teacher-only 接口:parent 应被拒、teacher 应进入业务校验(均不应 404/连接错)
+        body = b'{"inviteCode":"__smoke_dummy__"}'
+        rr_p, hcp, bzp, ctp = _run('__multi_role__', 'guideBindAsParent', 'POST',
+                                   '/api/family/guide-bind', body, tokens['parent'])
+        rr_t, hct, bzt, ctt = _run('__multi_role__', 'guideBindAsTeacher', 'POST',
+                                   '/api/family/guide-bind', body, tokens['teacher'])
+        mp, mt = _msg(rr_p), _msg(rr_t)
+        print(f'  [parent ] /api/family/guide-bind -> {ctp} msg="{mp[:36]}"')
+        print(f'  [teacher] /api/family/guide-bind -> {ctt} msg="{mt[:36]}"')
+
+        # 3) 权限接口:非 admin 调 rebuild 应 "权限不足"
+        rr, hc, bz, ct = _run('__multi_role__', 'indicatorsRebuildParent', 'POST',
+                              '/api/health-report/indicators/rebuild',
+                              b'{"memberId":1}', tokens['parent'])
+        print(f'  [parent] /api/health-report/indicators/rebuild -> {ct} (biz={bz}) msg="{_msg(rr)[:36]}"')
+
     total = len(rows)
     print(f'\n=== 汇总 ===')
     print(f'页面覆盖: {len(pages)}   已请求接口: {total}')
-    for k in ('BIZ_OK', 'BIZ_FAIL', 'NOT_FOUND', 'SERVER_ERR', 'NON_JSON', 'CONN_ERR'):
+    for k in ('BIZ_OK', 'BIZ_FAIL', 'AUTH_FAIL', 'NOT_FOUND', 'SERVER_ERR', 'NON_JSON', 'CONN_ERR'):
         if k in stats:
             print(f'  {k:<10}: {stats[k]}')
     ok = stats.get('BIZ_OK', 0)