Browse Source

fix(auth): 废弃裸 openid 换 token 接口,静默续期改走 code2Session

/api/auth/auto-login 仅凭请求参数里的明文 openid 就签发完整 JWT,不做任何持有性
证明。而 openid 并非密钥:它会随登录响应返回、明文存于小程序本地 storage、并可能
经日志或截图泄露。任何拿到他人 openid 的人都能直接换取该账号的合法 token,等同
于完全冒充该账号。

按 AGENTS.md「废弃接口标记 410 Gone 并注释说明,不直接删除」,保留路由与注释并
返回 410,待线上旧版本小程序全部升级后再移除路由。UserService#autoLoginByOpenid
标记 @Deprecated。

替代路径:前端 uni.login() 取微信签发的一次性 code,服务端 code2Session 换
openid。code 单次使用、5 分钟过期,且能证明调用方持有该微信设备,不可伪造,同样
零交互静默完成。本地 openid 仅用于判断「本设备曾登录过」,绝不作为凭证上送。

其他修复:
- App.vue 冷启动即恢复登录态:原先在 splash 分支提前 return,导致普通冷启动(非
  分享/小程序码进入)完全拿不到任何续期机会
- 移除 develop 环境下对 verifyStoredToken/tryAutoLogin 的屏蔽,否则开发版无法验证
  续期逻辑
- 401 重放改传未拼接 query 的原始路径,避免 URL 出现 ?a=1&a=1 重复参数
- 新增 _noRelogin:续期请求自身返回 401 时直接失败,防止递归续期/误清登录态
- 启动窗口内(pages/login 为分包,首次访问前尚未下载注册)只清登录态不跳转,避免
  「Page has not been registered yet」
- JWT 有效期 1 天 -> 30 天,配合静默续期用户几乎无需手动重新登录

验证:前端 jest 176 项全部通过;后端 mvn clean compile(release 8)BUILD SUCCESS
liaoxg 1 day ago
parent
commit
58ac6de74f

+ 16 - 10
cfc-backend/src/main/java/com/etotem/cfc/controller/auth/AuthController.java

@@ -114,19 +114,25 @@ public class AuthController {
     }
 
     /**
-     * 通过openid自动登录
+     * 通过openid自动登录 —— 已废弃(410 Gone)
+     *
+     * <p>废弃原因(安全漏洞,2026-09 修复):本接口只凭请求参数里的明文 openid 就签发完整 JWT,
+     * 不做任何持有性证明。而 openid 并非密钥——它会随登录响应返回、明文存于小程序本地 storage、
+     * 并可能经日志/截图泄露。任何拿到他人 openid 的人都能直接换取该账号的合法 token,
+     * 等同于完全冒充。
+     *
+     * <p>替代方案:{@code /api/auth/silent-login}。前端调 {@code uni.login()} 拿微信签发的
+     * 一次性 code,服务端 code2Session 换 openid。code 单次使用、5 分钟过期,且能证明调用方
+     * 持有该微信设备,因此不可伪造,同样零交互静默完成。
+     *
+     * <p>按项目规范(AGENTS.md「废弃接口标记 410 Gone 并注释说明,不直接删除」):
+     * 保留路由与注释,待线上旧版本小程序全部升级后再移除路由。
      */
-    @Operation(summary = "openid自动登录")
+    @Operation(summary = "openid自动登录(已废弃,返回 410,请改用 /api/auth/silent-login)")
     @PostMapping("/auto-login")
     public Result<LoginResultDTO> autoLogin(@RequestBody WechatLoginDTO dto) {
-        if (dto.getOpenid() == null) {
-            return Result.error("openid不能为空");
-        }
-        LoginResultDTO result = userService.autoLoginByOpenid(dto.getOpenid());
-        if (result == null) {
-            return Result.error("用户不存在,请先登录");
-        }
-        return Result.success(result);
+        log.warn("/api/auth/auto-login 已被调用(已废弃接口):该接口仅凭 openid 签发 token 存在越权风险,已拒绝。请前端改用 /api/auth/silent-login");
+        return Result.error(410, "该接口已废弃,请改用 /api/auth/silent-login");
     }
 
     /**

+ 7 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java

@@ -307,8 +307,14 @@ private FamilyInvitationService familyInvitationService;
     }
 
     /**
-     * 通过openid自动登录(前端已缓存openid时调用)
+     * 通过openid自动登录
+     *
+     * @deprecated 仅凭明文 openid 签发 token,无任何持有性证明,构成越权风险,禁止调用。
+     * 对外接口 /api/auth/auto-login 已改为 410 Gone。保留实现仅为过渡期兼容,
+     * 待线上旧版本小程序全部升级后连同路由一并移除。
+     * 请改用 {@link #silentLogin(String)}(微信 code2Session,可证明设备持有)。
      */
+    @Deprecated
     public LoginResultDTO autoLoginByOpenid(String openid) {
         User user = userMapper.selectOne(new LambdaQueryWrapper<User>()
                 .eq(User::getOpenid, openid));

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/api/UserServiceInterface.java

@@ -9,6 +9,13 @@ public interface UserServiceInterface {
     LoginResultDTO phoneLogin(PhoneLoginDTO dto);
     LoginResultDTO wechatPhoneLogin(WechatPhoneLoginDTO dto);
     LoginResultDTO silentLogin(String code);
+
+    /**
+     * @deprecated 仅凭明文 openid 签发 token,无任何持有性证明,构成越权风险。
+     * 对外接口 /api/auth/auto-login 已改为 410 Gone。请改用 {@link #silentLogin(String)},
+     * 由前端 uni.login() 提供微信签发的一次性 code 走 code2Session。
+     */
+    @Deprecated
     LoginResultDTO autoLoginByOpenid(String openid);
     boolean setPassword(Long userId, String password);
     boolean setSwitchPassword(Long userId, String password);

+ 5 - 1
cfc-backend/src/main/resources/application.yml

@@ -60,7 +60,11 @@ mybatis-plus:
 
 jwt:
   secret: ${JWT_SECRET:cfc-prod-jwt-secret-2026-keep-it-safe!}
-  expiration: 86400000
+  # 30 天(2592000000ms)。配合小程序端 wx.login 静默续期(/api/auth/silent-login),
+  # 用户几乎无需手动重新登录。
+  # 安全前提:续期只能走 code2Session(微信签发的单次 code,能证明设备持有),
+  # 严禁恢复「裸 openid 换 token」——那等于任何拿到 openid 的人都能冒充该账号。
+  expiration: 2592000000
 
 zkxz:
   enabled: false

+ 55 - 74
cfc-frontend/App.vue

@@ -1,7 +1,7 @@
 <script>
 import config from '@/config.js'
 import Vue from 'vue'
-import { checkUserFamily, verifyToken, bindReferralIfNotSelf, request } from './utils/api.js'
+import { checkUserFamily, verifyToken, bindReferralIfNotSelf, silentRelogin } from './utils/api.js'
 Vue.prototype.$config = config
 
 export default {
@@ -9,7 +9,13 @@ export default {
     console.log('App Launch', options)
     // 捕获分享/邀请参数(普通分享链接 ?inviteCode=xxx):未登录写入 storage 供注册登录后绑定推荐人,已登录直接绑定
     this.captureReferralParams(options)
-// 分享/深链冷启动(携带分享参数)放行到落地页,不套 splash,避免分享的文章/小程序页面打不开
+    // 恢复登录态:必须放在 splash 分支之前。
+    // 原实现在普通冷启动(无深链)时于 splash 跳转处 return,导致 verifyStoredToken / tryAutoLogin
+    // 只在「分享/小程序码进入」时才执行,普通用户冷启动拿不到任何续期机会。
+    // 现在冷启动即异步校验/静默续期,与 2 秒 splash 动画并行,用户落地即已是有效登录态。
+    // 本方法不做任何页面跳转,导航交由 splash 或当前页面处理,避免与 splash 的 reLaunch 抢路由。
+    this.restoreSession()
+    // 分享/深链冷启动(携带分享参数)放行到落地页,不套 splash,避免分享的文章/小程序页面打不开
     // inviteCode=推荐码(文章/活动/商品分享)| refCode=推广码 | token/familyInviteCode=家庭邀请令牌 | id=内容详情
     var hasDeepLink = options && options.query && (
       options.query.inviteCode || options.query.refCode || options.query.token ||
@@ -23,25 +29,6 @@ export default {
     }
     // 处理小程序码场景值(从 wxacode 扫码进入,跳过 splash)
     this.handleScene(options)
-    
-    // TabBar 始终显示 5 个底签,不因登录状态隐藏
-    const token = uni.getStorageSync('token')
-    if (token) {
-      this.$store.commit('setToken', token)
-      // 启动时校验 token 是否仍有效:过期则清理本地登录态并引导重新登录
-      this.verifyStoredToken()
-      // 冷启动恢复 familyId(登录时已持久化,hasFamily getter 依赖)
-      var storedFamilyId = uni.getStorageSync('familyId')
-      if (storedFamilyId) {
-        this.$store.commit('setFamilyId', storedFamilyId)
-      } else {
-        // 历史用户(8ddc5b55 后未重新登录):本地无 familyId,从接口恢复
-        this.restoreFamilyIdFromApi()
-      }
-    } else {
-      // 无token时尝试自动登录
-      this.tryAutoLogin()
-    }
   },
   onShow: function(options) {
     console.log('App Show', options)
@@ -88,20 +75,40 @@ bindReferralIfNotSelf(code).then(function(res) {
         uni.setStorageSync('inviteType', options.query.inviteType)
       }
     },
-    // 校验本地 token 是否有效(开发环境跳过,避免无后端时误报)
-    verifyStoredToken() {
-      try {
-        var envInfo = uni.getAccountInfoSync()
-        if (envInfo && envInfo.miniProgram && envInfo.miniProgram.envVersion === 'develop') {
-          return
+    // 冷启动恢复登录态(异步执行,不做任何页面跳转)
+    // 1) 有 token:先同步到 Vuex。若 token 已过期/失效,/api/auth/verify 会返回 code=401,
+    //    由 request 封装统一走 _handle401 → silentRelogin 静默续期并重放,全程用户无感。
+    // 2) 无 token 但本地有 openid(此前登录过、token 已被清理):走静默续期。
+    // 3) 两者都没有:本设备从未登录,属新用户,保持未登录态,由登录页引导。
+    restoreSession() {
+      // TabBar 始终显示 5 个底签,不因登录状态隐藏
+      const token = uni.getStorageSync('token')
+      if (token) {
+        this.$store.commit('setToken', token)
+        // 冷启动恢复 familyId(登录时已持久化,hasFamily getter 依赖)
+        var storedFamilyId = uni.getStorageSync('familyId')
+        if (storedFamilyId) {
+          this.$store.commit('setFamilyId', storedFamilyId)
+        } else {
+          // 历史用户(8ddc5b55 后未重新登录):本地无 familyId,从接口恢复
+          this.restoreFamilyIdFromApi()
         }
-      } catch (e) { /* 非小程序环境 */ }
-      var self = this
-      request('/api/auth/verify', 'POST').then(function() {
+        // 启动时校验 token 是否仍有效,过期则触发静默续期
+        this.verifyStoredToken()
+      } else {
+        this.tryAutoLogin()
+      }
+    },
+    // 校验本地 token 是否有效。
+    // 注意:/api/auth/verify 在 JwtInterceptor 的白名单里,不会被拦截器判 401,而是由
+    // AuthController 自行解析后返回 code=401 的响应体 —— 同样会被 request 封装识别并续期。
+    // 早期版本在 develop 环境直接 return,导致开发版完全无法验证续期逻辑,已移除该屏蔽。
+    verifyStoredToken() {
+      verifyToken().then(function() {
         // token 有效,无需处理
       }).catch(function(e) {
-        // 401:由 request 封装统一触发 _handle401(静默 auto-login 续期或清空重登录)
-        // _handle401 内部已有完整逻辑:用缓存 openid auto-login → 成功则重放原请求;失败则清空并跳登录页
+        // 401:由 request 封装统一触发 _handle401(silentRelogin 静默续期后重放原请求);
+        //      只有续期也失败时才由 _clearAuthAndRedirect 清空登录态并跳登录页。
         // 其它错误(如网络失败)静默忽略,不打扰用户
         console.log('[App] token 校验未通过:', e && e.code)
       })
@@ -118,32 +125,25 @@ bindReferralIfNotSelf(code).then(function(res) {
         console.log('[App] familyId 接口恢复失败(页面按无家庭态渲染)', e)
       })
     },
+    // 无本地 token 时尝试静默续期。
+    // 走 uni.login() → /api/auth/silent-login(服务端 code2Session):微信签发的一次性 code
+    // 能证明调用方持有该微信设备,因此不可伪造;本地 openid 仅用于判断「本设备曾登录过」,
+    // 绝不作为凭证上送(旧的 autoLogin(openid) 已被后端废弃为 410 Gone)。
+    // 早期版本在 develop 环境直接 return,开发版无法验证续期,已移除该屏蔽。
+    // 不做页面跳转:冷启动由 splash 在 2 秒后统一 reLaunch 到首页,
+    // 此处若再 switchTab 会与其抢路由(splash 先落地时 switchTab 落在已销毁页面上)。
     async tryAutoLogin() {
-      // 开发环境下跳过自动登录,避免无后端时 10s 超时阻塞
-      try {
-        var envInfo = uni.getAccountInfoSync()
-        if (envInfo && envInfo.miniProgram && envInfo.miniProgram.envVersion === 'develop') {
-          return
-        }
-      } catch (e) { /* 非小程序环境 */ }
       try {
-        // 1. 先尝试用缓存的openid自动登录
-        const cachedOpenid = uni.getStorageSync('openid')
-        if (cachedOpenid) {
-          const res = await this.request('/api/auth/auto-login', 'POST', { openid: cachedOpenid })
-          if (res && res.data) {
-            this.saveLoginInfo(res.data)
-            console.log('openid自动登录成功')
-            // 刷新页面到登录态
-            uni.switchTab({ url: '/pages/index-home/index' })
-            return
-          }
+        var loginData = await silentRelogin()
+        if (loginData) {
+          this.saveLoginInfo(loginData)
+          console.log('[App] 静默续期成功,已恢复登录态')
+        } else {
+          // 本设备从未登录过,或续期失败:保持未登录态,由登录页引导
+          console.log('[App] 静默续期未成功,需手动登录')
         }
-
-        // 2. openid自动登录失败,不自动调用uni.login(避免DevTools环境超时阻塞渲染层)
-        //    用户会看到登录页,手动选择登录方式
       } catch (e) {
-        console.log('自动登录失败,需要手动登录', e)
+        console.log('[App] 静默续期异常,需手动登录', e)
       }
     },
     saveLoginInfo(data) {
@@ -164,28 +164,9 @@ bindReferralIfNotSelf(code).then(function(res) {
         familyId: data.familyId
       })
       this.$store.commit('setToken', data.token)
-      // 同步 familyId 到 store(hasFamily getter 依赖;auto-login 路径)
+      // 同步 familyId 到 store(hasFamily getter 依赖;静默续期路径)
       this.$store.commit('setFamilyId', data.familyId)
     },
-    request(url, method, data) {
-      return new Promise((resolve, reject) => {
-        uni.request({
-          url: config.api(url),
-          method: method,
-          data: data,
-          timeout: 10000,
-          header: { 'Content-Type': 'application/json' },
-          success: (res) => {
-            if (res.data.code === 200) {
-              resolve(res.data)
-            } else {
-              reject(res.data)
-            }
-          },
-          fail: reject
-        })
-      })
-    },
     // 处理小程序码场景值,跳转到邀请页
     handleScene(options) {
       if (!options || !options.query) return

+ 146 - 7
cfc-frontend/__tests__/api/api.test.js

@@ -46,10 +46,18 @@ beforeEach(() => {
   global.uni._storage = {}
   global.uni.request.mockReset()
   global.uni.showToast.mockReset()
+  global.uni.reLaunch.mockReset()
+  global.uni.login.mockReset()
+  global.getCurrentPages.mockReset()
+  global.getCurrentPages.mockReturnValue([{ route: 'pages/index-home/index' }])
   global.uni.getAccountInfoSync.mockReset()
   global.uni.getAccountInfoSync.mockReturnValue({
     miniProgram: { envVersion: 'develop' }
   })
+  // api.js 的 3 秒请求去重缓存是模块级单例,跨用例残留会让后续用例拿到上一个用例缓存的
+  // Promise(同 URL+method+body),表现为「本该 401 却是 200」「lastRequest() 为 undefined」。
+  // 每个用例前显式清空,保证用例间隔离。
+  api.clearApiDedup()
 })
 
 /** Capture the last uni.request call options */
@@ -99,10 +107,16 @@ describe('auth API', () => {
     expect(lastRequest().data.code).toBe('wxcode')
   })
 
-  test('autoLogin sends POST with openid', async () => {
-    mockSuccess({ code: 200 })
-    await api.autoLogin('openid_xxx')
-    expect(lastRequest().data.openid).toBe('openid_xxx')
+  // 已废弃:后端 /api/auth/auto-login 已改为 410 Gone(裸 openid 换 token 属越权漏洞)。
+  // 导出仅为排查历史调用保留,不得在新代码中使用;续期请走 silentRelogin()。
+  test('autoLogin (deprecated) still targets /api/auth/auto-login', async () => {
+    mockSuccess({ code: 410, message: '该接口已废弃,请改用 /api/auth/silent-login' })
+    await expect(api.autoLogin('openid_xxx')).rejects.toEqual(
+      { code: 410, message: '该接口已废弃,请改用 /api/auth/silent-login' }
+    )
+    const opts = lastRequest()
+    expect(opts.url).toMatch(/\/api\/auth\/auto-login$/)
+    expect(opts.data.openid).toBe('openid_xxx')
   })
 
   test('setPassword and verifyPassword send correct endpoints', async () => {
@@ -166,11 +180,22 @@ describe('auth API', () => {
     jest.useRealTimers()
   })
 
-  test('401 with cached openid silently re-logins and retries original request', async () => {
+  // 静默续期 = uni.login() 取微信签发的一次性 code → /api/auth/silent-login(服务端 code2Session)。
+  // 本地 openid 只作为「本设备曾登录过」的触发条件,不作为凭证上送。
+  function mockUniLoginCode(code) {
+    global.uni.login.mockImplementation((opts) => {
+      process.nextTick(() => {
+        if (opts.success) opts.success({ code: code || 'wxcode' })
+      })
+    })
+  }
+
+  test('401 silently re-logins via wx.login + code2Session and retries original request', async () => {
     global.uni.reLaunch.mockReset()
     global.uni.showToast.mockReset()
     global.uni._storage.openid = 'openid_abc'
     global.uni._storage.token = 'expired-token'
+    mockUniLoginCode('fresh-wxcode')
     mockSequence([
       { data: { code: 401, message: 'Token无效或已过期', data: null } },
       { data: { code: 200, data: { token: 'new-jwt', userId: 42, role: 'parent', familyId: 1, openid: 'openid_abc' } } },
@@ -179,17 +204,86 @@ describe('auth API', () => {
     const res = await api.verifyToken()
     expect(res.data.userId).toBe(42)
     expect(global.uni._storage.token).toBe('new-jwt')
+    // 续期必须走 code2Session,且不得把 openid 当凭证发送
+    const calls = global.uni.request.mock.calls.map((c) => c[0])
+    const renewReq = calls.find((c) => /\/api\/auth\/silent-login/.test(c.url))
+    expect(renewReq).toBeDefined()
+    expect(renewReq.data.code).toBe('fresh-wxcode')
+    expect(renewReq.data.openid).toBeUndefined()
+    // 已废弃的裸 openid 换 token 接口不得被调用
+    expect(calls.find((c) => /\/api\/auth\/auto-login/.test(c.url))).toBeUndefined()
     expect(global.uni.reLaunch).not.toHaveBeenCalled()
     expect(global.uni.showToast).not.toHaveBeenCalled()
   })
 
-  test('401 with cached openid falls back to login when auto-login fails', async () => {
+  test('401 retry does not duplicate query params in the replayed request URL', async () => {
+    global.uni._storage.openid = 'openid_abc'
+    global.uni._storage.token = 'expired-token'
+    mockUniLoginCode('fresh-wxcode')
+    mockSequence([
+      { data: { code: 401, message: 'Token无效或已过期', data: null } },
+      { data: { code: 200, data: { token: 'new-jwt', userId: 42, role: 'parent', familyId: 1, openid: 'openid_abc' } } },
+      { data: { code: 200, data: { ok: true } } }
+    ])
+    // publishPackage 通过 options 传 query(?publish=true),是最容易暴露重复拼接的调用形态
+    await api.publishPackage(7, true)
+    const calls = global.uni.request.mock.calls.map((c) => c[0])
+    const hits = calls.filter((c) => /\/api\/packages\/7\/publish/.test(c.url))
+    expect(hits.length).toBe(2) // 原始请求 1 次 + 401 续期后重放 1 次
+    // 修复前:_handle401 收到的是已拼过 query 的 url,重放时 options 又拼一次 → ?publish=true&publish=true
+    hits.forEach((c) => {
+      expect(c.url).toBe(c.url.split('?')[0] + '?publish=true')
+      expect((c.url.match(/publish=/g) || []).length).toBe(1)
+    })
+  })
+
+  test('401 during app launch window clears auth but does NOT navigate to login subpackage', async () => {
+    jest.useFakeTimers()
+    global.uni._storage.openid = 'openid_abc'
+    global.uni._storage.token = 'expired-token'
+    // 仍停在 splash:pages/login 是分包,此刻尚未下载,
+    // reLaunch 过去会报 'Page "pages/login/login" has not been registered yet'
+    global.getCurrentPages.mockReturnValue([{ route: 'pages/splash/index' }])
+    mockUniLoginCode()
+    mockSequence([
+      { data: { code: 401, message: 'Token无效或已过期', data: null } },
+      { data: { code: 500, message: '用户不存在,请先登录', data: null } }
+    ])
+    await expect(api.verifyToken()).rejects.toEqual({ code: 401, message: 'Token无效或已过期', data: null })
+    jest.runAllTimers()
+    // 登录态必须清掉
+    expect(global.uni._storage.token).toBeUndefined()
+    // 但启动窗口内绝不跳登录页,交由 splash 落地首页(主包)
+    expect(global.uni.reLaunch).not.toHaveBeenCalled()
+    jest.useRealTimers()
+  })
+
+  test('401 during app launch window (no page registered yet) also skips navigation', async () => {
     jest.useFakeTimers()
     global.uni._storage.openid = 'openid_abc'
     global.uni._storage.token = 'expired-token'
+    // 首屏尚未注册,getCurrentPages() 为空
+    global.getCurrentPages.mockReturnValue([])
+    mockUniLoginCode()
     mockSequence([
       { data: { code: 401, message: 'Token无效或已过期', data: null } },
-      { data: { code: 401, message: '用户不存在', data: null } }
+      { data: { code: 500, message: '用户不存在,请先登录', data: null } }
+    ])
+    await expect(api.verifyToken()).rejects.toEqual({ code: 401, message: 'Token无效或已过期', data: null })
+    jest.runAllTimers()
+    expect(global.uni._storage.token).toBeUndefined()
+    expect(global.uni.reLaunch).not.toHaveBeenCalled()
+    jest.useRealTimers()
+  })
+
+  test('401 falls back to login page when silent re-login is refused', async () => {
+    jest.useFakeTimers()
+    global.uni._storage.openid = 'openid_abc'
+    global.uni._storage.token = 'expired-token'
+    mockUniLoginCode()
+    mockSequence([
+      { data: { code: 401, message: 'Token无效或已过期', data: null } },
+      { data: { code: 500, message: '用户不存在,请先登录', data: null } }
     ])
     await expect(api.verifyToken()).rejects.toEqual({ code: 401, message: 'Token无效或已过期', data: null })
     jest.runAllTimers()
@@ -198,6 +292,51 @@ describe('auth API', () => {
     jest.useRealTimers()
   })
 
+  test('401 with no cached openid (never logged in on this device) goes straight to login', async () => {
+    jest.useFakeTimers()
+    global.uni._storage.token = 'expired-token'
+    mockSequence([
+      { data: { code: 401, message: 'Token无效或已过期', data: null } }
+    ])
+    await expect(api.verifyToken()).rejects.toEqual({ code: 401, message: 'Token无效或已过期', data: null })
+    // 本设备从未登录过 → 不应调用 uni.login 浪费一次 code2Session
+    expect(global.uni.login).not.toHaveBeenCalled()
+    jest.runAllTimers()
+    expect(global.uni.reLaunch).toHaveBeenCalledWith({ url: '/pages/login/login' })
+    jest.useRealTimers()
+  })
+
+  test('silentRelogin returns null when wx.login yields no code', async () => {
+    global.uni._storage.openid = 'openid_abc'
+    global.uni.login.mockImplementation((opts) => {
+      process.nextTick(() => { if (opts.success) opts.success({}) })
+    })
+    await expect(api.silentRelogin()).resolves.toBeNull()
+    expect(global.uni.request).not.toHaveBeenCalled()
+  })
+
+  test('silentRelogin returns null when wx.login fails', async () => {
+    global.uni._storage.openid = 'openid_abc'
+    global.uni.login.mockImplementation((opts) => {
+      process.nextTick(() => { if (opts.fail) opts.fail({ errMsg: 'login:fail' }) })
+    })
+    await expect(api.silentRelogin()).resolves.toBeNull()
+    expect(global.uni.request).not.toHaveBeenCalled()
+  })
+
+  test('silentRelogin does not issue a renew request on 401 (no recursive renewal)', async () => {
+    global.uni._storage.openid = 'openid_abc'
+    global.uni._storage.token = 'expired-token'
+    mockUniLoginCode()
+    // 续期接口自身返回 401:必须直接失败,不能再触发一次 silentRelogin,否则会递归续期/误清登录态
+    mockSuccess({ code: 401, message: 'Token无效或已过期', data: null })
+    await expect(api.silentRelogin()).resolves.toBeNull()
+    expect(global.uni.request.mock.calls.length).toBe(1)
+    // 续期失败不得清空登录态(那是「主动登出」才做的事)
+    expect(global.uni._storage.token).toBe('expired-token')
+    expect(global.uni.reLaunch).not.toHaveBeenCalled()
+  })
+
   test('Authorization header includes Bearer token', async () => {
     mockSuccess({ code: 200 })
     global.uni._storage.token = 'my-jwt-token'

+ 15 - 8
cfc-frontend/__tests__/config/config.test.js

@@ -13,34 +13,41 @@ beforeEach(() => {
   })
 })
 
+// 与 config.js 保持一致:develop / trial / release 三端统一指向同一生产域名;
+// 非小程序环境、env 缺失或未知 envVersion 时,回落到模块顶部的默认值。
+// 换域名时只需改这两行(对应 config.js 的 switch 分支与顶部默认值)。
+const PROD_HOST = 'https://cfc.etotem.com.cn'
+const FALLBACK_HOST = 'http://cfc.iwintrue.com'
+
 describe('config.js', () => {
   beforeEach(() => {
     // Reset the module registry so each test gets a fresh config
+    // (config.js 在模块加载时就求值 API_BASE_URL,必须重新 require 才会重跑这段逻辑)
     jest.resetModules()
   })
 
-  test('uses develop defaults when getAccountInfoSync returns develop', () => {
+  test('uses production host when getAccountInfoSync returns develop', () => {
     global.uni.getAccountInfoSync.mockReturnValue({
       miniProgram: { envVersion: 'develop' }
     })
     const cfg = require('@/config').default
-    expect(cfg.API_BASE_URL).toBe('http://localhost:9082')
+    expect(cfg.API_BASE_URL).toBe(PROD_HOST)
   })
 
-  test('uses trial URLs when envVersion is trial', () => {
+  test('uses production host when envVersion is trial', () => {
     global.uni.getAccountInfoSync.mockReturnValue({
       miniProgram: { envVersion: 'trial' }
     })
     const cfg = require('@/config').default
-    expect(cfg.API_BASE_URL).toBe('https://cfc.iwintrue.com')
+    expect(cfg.API_BASE_URL).toBe(PROD_HOST)
   })
 
-  test('uses release URLs when envVersion is release', () => {
+  test('uses production host when envVersion is release', () => {
     global.uni.getAccountInfoSync.mockReturnValue({
       miniProgram: { envVersion: 'release' }
     })
     const cfg = require('@/config').default
-    expect(cfg.API_BASE_URL).toBe('https://cfc.etotem.com.cn')
+    expect(cfg.API_BASE_URL).toBe(PROD_HOST)
   })
 
   test('uses defaults when getAccountInfoSync throws', () => {
@@ -48,7 +55,7 @@ describe('config.js', () => {
       throw new Error('not in mini-program')
     })
     const cfg = require('@/config').default
-    expect(cfg.API_BASE_URL).toBe('http://localhost:9082')
+    expect(cfg.API_BASE_URL).toBe(FALLBACK_HOST)
   })
 
   test('uses defaults for unknown envVersion', () => {
@@ -57,7 +64,7 @@ describe('config.js', () => {
     })
     const cfg = require('@/config').default
     // Falls through the switch without matching, keeps defaults
-    expect(cfg.API_BASE_URL).toBe('http://localhost:9082')
+    expect(cfg.API_BASE_URL).toBe(FALLBACK_HOST)
   })
 
   test('api(path) returns full URL joining API_BASE_URL + path', () => {

+ 8 - 0
cfc-frontend/__tests__/setup.js

@@ -49,6 +49,14 @@ global.uni = {
   }))
 }
 
+// ---- Mock getCurrentPages ----
+// 小程序运行时全局(不在 uni 上)。utils/api.js 的 _inLaunchWindow() 用它判断
+// 是否仍处于启动窗口(splash 未切走),因为 pages/login 是分包,
+// 启动窗口内 reLaunch 到未下载的分包页会报 "Page has not been registered yet"。
+// 默认返回一个「已渲染的非 splash 页面」,即正常交互态(此时应允许跳登录页)。
+// 用例内可覆盖 global.getCurrentPages 返回 [] 或 splash 页面来测启动窗口分支。
+global.getCurrentPages = jest.fn(() => [{ route: 'pages/index-home/index' }])
+
 // ---- Mock Vue ----
 jest.mock('vue', () => {
   const Vue = jest.requireActual('vue')

+ 13 - 1
cfc-frontend/__tests__/store/store.test.js

@@ -55,12 +55,24 @@ describe('Vuex Store', () => {
       expect(store.state.isSwitchedChild).toBe(false)
 
       // Verify storage sync
+      // 注意:familyId 的持久化由 setFamilyId 负责(见下一个用例),
+      // setUserInfo 只负责 state.familyId,不写 storage
       expect(global.uni._storage.userId).toBe('100')
       expect(global.uni._storage.role).toBe('parent')
-      expect(global.uni._storage.familyId).toBe('1')
       expect(global.uni._storage.isSwitchedChild).toBe(false)
     })
 
+    test('setFamilyId persists familyId to storage for cold start', () => {
+      store.commit('setFamilyId', '42')
+      expect(store.state.familyId).toBe('42')
+      expect(global.uni._storage.familyId).toBe('42')
+
+      // 无家庭时写入空串而非 undefined,保证冷启动 getStorageSync 能取到键
+      store.commit('setFamilyId', '')
+      expect(store.state.familyId).toBe('')
+      expect(global.uni._storage.familyId).toBe('')
+    })
+
     test('setUserInfo clears switched-child flag', () => {
       global.uni._storage.isSwitchedChild = true
       store.commit('setUserInfo', {

+ 108 - 19
cfc-frontend/utils/api.js

@@ -46,6 +46,30 @@ function _clearDedup() {
 }
 export { _clearDedup as clearApiDedup }
 
+// 判断当前是否仍处于「小程序启动窗口」(首屏 splash 尚未切走)。
+//
+// 背景:pages/login 是分包(pages.json subPackages,root=pages/login),
+// 而 pages/splash/index、pages/index-home/index 都在主包。分包页面在首次访问前
+// 需要下载并注册;若在 App.onLaunch 阶段就 reLaunch 到未下载的分包,微信会报
+// 「Page "pages/login/login" has not been registered yet」。
+// 其它 28 处跳登录都是「已渲染页面上的用户点击」,框架已可下载分包,故不受影响。
+function _inLaunchWindow() {
+  try {
+    var pages = typeof getCurrentPages === 'function' ? getCurrentPages() : null
+    // 首屏尚未注册:仍在启动窗口
+    if (!pages || pages.length === 0) return true
+    var cur = pages[pages.length - 1]
+    var route = ''
+    if (cur) {
+      route = cur.route || (cur.$page && cur.$page.route) || ''
+    }
+    // 还停在 splash:启动窗口内(约 2 秒),不与 splash 自己的 reLaunch 抢路由
+    return route === 'pages/splash/index'
+  } catch (e) {
+    return false
+  }
+}
+
 function _clearAuthAndRedirect() {
   if (_redirectingToLogin) return
   _redirectingToLogin = true
@@ -58,6 +82,13 @@ function _clearAuthAndRedirect() {
   uni.removeStorageSync('currentChildId')
   uni.showToast({ title: '登录已过期,请重新登录', icon: 'none' })
   setTimeout(function() {
+    // 启动窗口内只清登录态、不跳转:让 splash 正常落地首页(主包),
+    // 首页按未登录态渲染登录引导,与「首次进入的新用户」路径一致。
+    if (_inLaunchWindow()) {
+      console.warn('[Auth] 启动窗口内不跳转登录页,交由 splash 落地首页(避免未注册分包页报错)')
+      _redirectingToLogin = false
+      return
+    }
     uni.reLaunch({ url: '/pages/login/login' })
     _redirectingToLogin = false
   }, 1500)
@@ -94,7 +125,7 @@ function _applyLoginData(data) {
   } catch (e) { /* 非页面环境无 Vuex,静默忽略 */ }
 }
 
-// 401 处理:优先用缓存 openid 静默续期并重放原请求;续期失败或无 openid 时回退跳登录页
+// 401 处理:优先静默续期(wx.login code2Session)并重放原请求;续期失败则回退跳登录页
 function _handle401(url, method, data, options, resolve, reject, resData) {
   // 续期接口自身 401 / 重试仍 401 时,避免递归续期死循环
   if (_reloginLock) {
@@ -102,17 +133,11 @@ function _handle401(url, method, data, options, resolve, reject, resData) {
     reject(resData)
     return
   }
-  var openid = uni.getStorageSync('openid')
-  if (!openid) {
-    _clearAuthAndRedirect()
-    reject(resData)
-    return
-  }
   _reloginLock = true
-  autoLogin(openid).then(function(res) {
-    if (res && res.data && res.data.token) {
+  silentRelogin().then(function(loginData) {
+    if (loginData && loginData.token) {
       console.warn('[API] 401 静默续期成功,重放原请求 ' + url)
-      _applyLoginData(res.data)
+      _applyLoginData(loginData)
       // 重放原请求;重试期间保持锁,防止重试仍 401 时再次触发续期
       // 401 重放请求跳过去重(原请求已 reject,缓存中的 promise 已过期)
       var _retryOpts = Object.assign({}, options, { _skipDedup: true })
@@ -135,13 +160,65 @@ function _handle401(url, method, data, options, resolve, reject, resData) {
   })
 }
 
+// 静默续期:uni.login() 取微信签发的一次性 code,服务端 code2Session 换 openid 并签发新 token。
+//
+// 为什么不能拿本地 openid 直接换 token:openid 是明文标识符而非密钥,它会随登录响应返回、
+// 明文存于本地 storage、并可能经日志/截图泄露;凭它签发 token 等同于任何拿到 openid 的人
+// 都能完全冒充该账号(旧的 /api/auth/auto-login 就是这么做的,已废弃返回 410)。
+// code 则由微信签发、单次使用、5 分钟过期,且能证明调用方持有该微信设备,不可伪造,
+// 同样零交互静默完成 —— 这才是「用小程序缓存完成自动登录」的安全实现方式。
+//
+// 本地 openid 在这里仅作为「本设备此前登录过」的判断依据(决定是否尝试续期),绝不作为凭证上送。
+//
+// 返回:成功返回 LoginResultDTO 数据对象;本设备从未登录过 / 续期失败返回 null(由调用方决定兜底)
+export function silentRelogin() {
+  return new Promise(function(resolve) {
+    // 无 openid 缓存 = 本设备从未成功登录过,属新用户,不做静默续期(直接引导手动登录)
+    var cachedOpenid = uni.getStorageSync('openid')
+    if (!cachedOpenid) {
+      resolve(null)
+      return
+    }
+    uni.login({
+      provider: 'weixin',
+      success: function(loginRes) {
+        if (!loginRes || !loginRes.code) {
+          console.warn('[Auth] uni.login 未返回 code,静默续期跳过')
+          resolve(null)
+          return
+        }
+        // _noRelogin:本次请求就是续期请求本身,不能再触发 _handle401 造成递归续期
+        silentLogin(loginRes.code, { _noRelogin: true, _skipDedup: true }).then(function(res) {
+          if (res && res.data && res.data.token) {
+            resolve(res.data)
+          } else {
+            console.warn('[Auth] 静默续期未返回 token')
+            resolve(null)
+          }
+        }).catch(function(e) {
+          console.warn('[Auth] 静默续期请求失败', e && e.code)
+          resolve(null)
+        })
+      },
+      fail: function(e) {
+        console.warn('[Auth] uni.login 调用失败,静默续期跳过', e)
+        resolve(null)
+      }
+    })
+  })
+}
+
 const request = (url, method = 'POST', data = {}, options = {}) => {
+  // 401 重放时 request(url, method, data, options) 会用同一份 options 再次拼 query,
+  // 所以这里必须保存未拼接 query 的原始 path 传给 _handle401,否则重放请求的 URL 会
+  // 出现 ?a=1&a=1(query 重复)。fullUrl 仍用拼接后的 url,日志和去重粒度不变。
+  var basePath = url
   // Append query params from options to URL if provided (e.g., { guideId } for GET requests)
-  // `timeout`/`_skipDedup` 是请求配置项,不是 query 参数,需排除
+  // `timeout`/`_skipDedup`/`_noRelogin` 是请求配置项,不是 query 参数,需排除
   if (options && Object.keys(options).length > 0) {
     const separator = url.includes('?') ? '&' : '?'
     const query = Object.entries(options)
-      .filter(([k, v]) => k !== 'timeout' && k !== '_skipDedup' && v !== undefined && v !== null && v !== '')
+      .filter(([k, v]) => k !== 'timeout' && k !== '_skipDedup' && k !== '_noRelogin' && v !== undefined && v !== null && v !== '')
       .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
       .join('&')
     if (query) url = url + separator + query
@@ -187,8 +264,16 @@ const request = (url, method = 'POST', data = {}, options = {}) => {
       },
       success: (res) => {
         if (res.statusCode === 401 || res.data.code === 401) {
+          // _noRelogin:本次请求就是静默续期请求本身(或上层已明确不走自动续期),
+          // 直接 reject。否则会再次进入 _handle401 → 递归续期,甚至误清登录态把用户踢出。
+          if (options._noRelogin) {
+            console.warn('[API] 401 ' + method + ' ' + fullUrl + '(已禁用自动续期,直接失败)')
+            reject(res.data)
+            return
+          }
           console.warn('[API] 401 ' + method + ' ' + fullUrl)
-          _handle401(url, method, data, options, resolve, reject, res.data)
+          // 传 basePath(未拼 query)而非 url:_handle401 重放时会用 options 再拼一次
+          _handle401(basePath, method, data, options, resolve, reject, res.data)
           return
         }
         // 5001: 用户无家庭 — resolve 带标记,不 reject;由组件层各自渲染 FamilyEmptyState
@@ -322,14 +407,18 @@ export const getNotices = () => {
   return request('/api/admin/notices/all', 'POST', {})
 }
 
-// 微信静默登录
-export const silentLogin = (code) => {
-  return request('/api/auth/silent-login', 'POST', { code })
+// 微信静默登录:code 由 uni.login() 签发,服务端 code2Session 换 openid 并签发新 token。
+// 这是自动续期的唯一安全通道(可证明设备持有,openid 本身不可作为凭证)。
+export const silentLogin = (code, options) => {
+  return request('/api/auth/silent-login', 'POST', { code }, options)
 }
 
-// 通过openid自动登录
+// 【已废弃 · 不要使用】通过裸 openid 换取 token。
+// 后端 /api/auth/auto-login 已改为 410 Gone:openid 是明文标识符而非密钥,
+// 凭它签发 token 等同于任何拿到 openid 的人都能完全冒充该账号。
+// 保留导出仅为便于排查历史调用,请改用 silentRelogin()(内部走 silentLogin)。
 export const autoLogin = (openid) => {
-  return request('/api/auth/auto-login', 'POST', { openid })
+  return request('/api/auth/auto-login', 'POST', { openid }, { _noRelogin: true })
 }
 
 export const setPassword = (password) => {
@@ -3175,4 +3264,4 @@ export function getNodeTemplateList(params = {}) {
   return request('/api/admin/task-templates', 'POST', params)
 }
 
-export const getHomeBanners = () => request('/api/home-banner/list', 'POST', {})
+export const getHomeBanners = () => request('/api/home-banner/list', 'POST', {})