Przeglądaj źródła

auth: 移动端对齐两步登录契约 + roles string[] + org 出参

- shared/api/auth/login.ts:单步 LoginResult 改两步——LoginStep1{loginTicket,user,
  memberships[]} + TokenPair{accessToken,refreshToken,expiresAt,user,roles:string[],
  institutionId,campusId,menuTree,permissions};新增 issue();roles number[]→string[]。
- shared/store/login.ts:applyLogin 入参 LoginResult→TokenPair;roles Ref<number[]>→
  Ref<string[]>;tabTree 改从 data.menuTree 取(后端 ② 下发字段名为 menuTree,旧 tabTree
  字段名错误);realName 从 data.user.realName 取。
- shared/api/auth/wechat.ts:占位 per-campus authorize 改真实契约——GET qrcode 返
  {state,qrUrl,campusId,expiresIn};GET status 返 {status:pending|success|unbound|fail,
  loginTicket?,memberships?,expired?};success 不再直发 token,改为回 loginTicket+
  memberships 复用两步②。
- teacher login/index.vue:账密改两步(① login→自动取 memberships[0] 立即② issue→
  applyLogin+scope.set→跳首页;memberships 空提示联系管理员);微信 tab 改诚实占位
  (后端微信扫码为 PC 端扫码流程,移动端 App 内授权方案待拍板,不做假轮询)。
- teacher org/institution.ts + list.vue:InstitutionDTO 出参对齐后端 model(institutionName/
  institutionCode?/address?/phone?/status:'active'|'disabled'/createdAt/updatedAt),
  列表渲染 name/code/status:number 同步改。
- 门禁:typecheck(×2)/lint/build 全绿;dist grep 无 Vue 运行时(shims 经 globalThis.Vue,
  231kB 大 chunk 为 Vant(npm 编译)+业务逻辑)。
Q 3 dni temu
rodzic
commit
83a83f1aef

+ 15 - 11
apps/teacher/src/api/org/institution.ts

@@ -10,20 +10,24 @@
 import { http } from '@unified/shared/api/http';
 import type { PageR, PageQuery } from '@unified/shared/types/api';
 
-/** 机构 DTO(与后端 org 域 Institution 逐字段对齐,camelCase) */
+/** 机构 DTO(字段与后端 org 域 model.Institution 出参逐字段对齐,camelCase;status 为字符串枚举) */
 export interface InstitutionDTO {
-  /** 机构 id(作用域 institution) */
+  /** 机构 id(int64 自增,主键;旧系统 school 概念归一,作用域根) */
   institutionId: number;
   /** 机构名称 */
-  name: string;
-  /** 机构编码(旧系统 school 归一) */
-  code: string;
-  /** 联系人 */
-  contactName: string;
-  /** 联系电话(后端已脱敏) */
-  contactPhone: string;
-  /** 状态(1 启用 / 0 停用) */
-  status: number;
+  institutionName: string;
+  /** 机构编码(可空 → 后端 omitempty,可能缺省) */
+  institutionCode?: string;
+  /** 地址(可空 → omitempty,可能缺省) */
+  address?: string;
+  /** 电话(可空 → omitempty,可能缺省) */
+  phone?: string;
+  /** 状态:"active" 正常 / "disabled" 停用(VARCHAR(16),字符串枚举,非 0/1) */
+  status: 'active' | 'disabled';
+  /** 创建时间(RFC3339,后端 GORM CreatedAt) */
+  createdAt: string;
+  /** 更新时间(RFC3339,后端 GORM UpdatedAt) */
+  updatedAt: string;
 }
 
 /** 机构列表查询参数(page/pageSize + 作用域 + 关键词) */

+ 48 - 122
apps/teacher/src/views/login/index.vue

@@ -2,20 +2,27 @@
 /**
  * 登录页(views/login/index.vue;公开路由,守卫 meta.public)。
  *
- * 职责:两种登录方式 tab 切换——① 账号密码(POST /api/auth/login,真)② 微信 per-campus 授权
- * (占位接口,后端端点未就绪,UI 骨架完整可联调);成功则写 token-store + 初始化作用域
- * (移动端作用域来自登录响应 institutionId/campusId,无独立选择页)+ router.push 进首页;
- * 失败展示后端 message(直显,不包装)。
+ * 职责:两种登录方式 tab 切换——① 账号密码(两步:POST /api/auth/login → POST /api/auth/issue)
+ * ② 微信授权(占位:后端微信扫码端点为 PC 端扫码流程,移动端"App 内微信授权"接入方案待拍板,
+ *  此处显占位提示,不做假轮询);成功则写 token-store + 初始化作用域 + router.push 进首页;
+ *  失败展示后端 message(直显,不包装)。
+ *
+ * 两步登录流程(对齐后端 identity ADR-014 / T4 契约,2026-11-06):
+ *   ① POST /login(验账密)→ loginTicket + user + memberships[](**不签 token**):
+ *     - code=200 且 memberships 非空:移动端**无**独立作用域选择页 → 自动取 memberships[0]
+ *       立即 ② /issue(取该条 institution/campus)→ applyLogin → scope.set → 跳首页;
+ *     - code=200 且 memberships 空:提示"该账号暂无机构归属,请联系管理员";
+ *     - code≠200(1001 账号密码错 / 2001 参数 / 5002 系统):errorMsg 直显后端 message。
+ *   ② POST /issue(凭 loginTicket + 选定作用域)→ TokenPair(access+refresh+roles+menuTree+permissions):
+ *     - applyLogin 写 token + 同步 roles/permissions/tabTree;scope.set 设机构/校区作用域;跳首页。
  * SFC 骨架顺序(编码规范):imports → props/emits(无)→ store 注入 → 状态 → 生命周期 → 方法。
  *
  * 注:模板 van-* 组件经 main.ts 的 app.use(Vant) 全局注册,本页**不 import Vant 组件**
  * (仅 import Vant Message/Toast 命令式 API 作按需增强;与 PC 端"组件走全局、命令式按需引"一致)。
  */
-import { login } from '@unified/shared/api/auth/login';
-import { authorizeWechat, pollWechatStatus } from '@unified/shared/api/auth/wechat';
+import { issue, login } from '@unified/shared/api/auth/login';
 import { useLogin } from '@unified/shared/store/login';
 import { useScope } from '@unified/shared/store/scope';
-import { showToast } from 'vant';
 import type { FieldRule } from 'vant';
 
 const loginStore = useLogin();
@@ -32,43 +39,17 @@ const form = reactive({
   password: '',
 });
 
-/** 提交中(防重复提交;密码 tab 用) */
+/** 提交中(防重复提交;覆盖 ①② 两步全程) */
 const submitting = ref(false);
 /** 登录失败文案(后端 message 直显;空串 = 无错误;两 tab 共用) */
 const errorMsg = ref('');
 
-/**
- * 微信授权态(占位,后端端点未就绪——接口路径/字段见 shared/api/auth/wechat.ts 占位注释)。
- * 入参:无。
- * 出参:
- *   - authUrl string:授权 URL('' = 未取到,扫码/授权区展示"暂未开放"占位);
- *   - polling boolean:是否正在轮询(驱动"授权中"文案)。
- * 异常场景:
- *   - 后端未就绪(authorizeWechat reject):authUrl 保持 '',授权区展示占位文案。
- */
-const authUrl = ref('');
-const polling = ref(false);
-/** 轮询定时器句柄(组件卸载时必须清,防泄漏;占位接口下逻辑完整仍保留) */
-let pollTimer: ReturnType<typeof setInterval> | undefined;
-
 /** 表单校验规则(显式 rules 对象,编码规范;trigger 为 Vant FieldValidateTrigger:onBlur/onChange/onSubmit) */
 const rules: Record<'username' | 'password', FieldRule[]> = {
   username: [{ required: true, message: '请输入用户名', trigger: 'onBlur' }],
   password: [{ required: true, message: '请输入密码', trigger: 'onBlur' }],
 };
 
-/** 登录成功后统一落盘:applyLogin(写 token + 视图)+ 初始化作用域 + 跳首页 */
-async function onLoginSuccess(res: {
-  institutionId?: number;
-  campusId?: number;
-}): Promise<void> {
-  if (res.institutionId) {
-    // 移动端作用域来自登录响应(无独立选择页);institutionId 缺失则由守卫④拦回登录
-    scope.set({ institutionId: res.institutionId, campusId: res.campusId ?? 0 });
-  }
-  await router.push('/');
-}
-
 /**
  * 提交账密登录(handle 前缀:用户触发的提交类方法,编码规范命名规则)。
  *
@@ -76,7 +57,9 @@ async function onLoginSuccess(res: {
  *   - Promise<void>:恒 resolve;成功进首页,失败写 errorMsg。
  * 异常场景:
  *   - 表单校验失败:不发请求(van-form 标红);
- *   - code !== 200(如 1005 账号密码错误):errorMsg = 后端 message;
+ *   - ① code≠200(如 1001 账号密码错):errorMsg = 后端 message;
+ *   - ① 成功但 memberships 空:errorMsg 提示无机构归属;
+ *   - ② code≠200(1001 票据失效 / 1003 越权):errorMsg = 后端 message;
  *   - 网络层 reject:errorMsg = "网络连接异常"。
  */
 async function handleSubmit(): Promise<void> {
@@ -84,87 +67,39 @@ async function handleSubmit(): Promise<void> {
   errorMsg.value = '';
   submitting.value = true;
   try {
-    const res = await login({ username: form.username, password: form.password });
-    if (res.code === 200 && res.data) {
-      loginStore.applyLogin(res.data);
-      await onLoginSuccess(res.data);
+    // ① 验账密:拿一次性票据 + 作用域列表(不签 token)
+    const step1 = await login({ username: form.username, password: form.password });
+    if (step1.code !== 200 || !step1.data) {
+      errorMsg.value = step1.message || '登录失败';
+      return;
+    }
+    const memberships = step1.data.memberships ?? [];
+    // 无机构归属:提示联系管理员(该账号无法进入业务域)
+    if (memberships.length === 0) {
+      errorMsg.value = '该账号暂无机构归属,请联系管理员分配机构';
       return;
     }
-    errorMsg.value = res.message || '登录失败';
+    // 移动端无独立作用域选择页:自动取第一条作用域完成 ②(多机构切换为后续待拍板项)
+    const m = memberships[0];
+    const pair = await issue({
+      loginTicket: step1.data.loginTicket,
+      institutionId: m.institutionId,
+      campusId: m.campusId,
+    });
+    if (pair.code === 200 && pair.data) {
+      loginStore.applyLogin(pair.data); // 写 token + roles/permissions/tabTree
+      // 移动端作用域来自 ② 响应(institutionId/campusId);scope.set 失败(平台级 institutionId=0)由守卫④兜底
+      scope.set({ institutionId: pair.data.institutionId, campusId: pair.data.campusId });
+      await router.push('/');
+      return;
+    }
+    errorMsg.value = pair.message || '登录失败';
   } catch {
     errorMsg.value = '网络连接异常';
   } finally {
     submitting.value = false;
   }
 }
-
-/** 停止微信授权轮询(切 tab / 组件卸载 / 终态时调用;幂等) */
-function stopPolling(): void {
-  if (pollTimer !== undefined) {
-    clearInterval(pollTimer);
-    pollTimer = undefined;
-  }
-  polling.value = false;
-}
-
-/**
- * 切换登录方式 tab(切到微信时发起授权 + 启动轮询;切走时停轮询)。
- *
- * 入参:tab 'password' | 'wechat'(van-tabs @change 回调值)。
- * 出参:无(异步启动授权流程)。
- * 异常场景:
- *   - 后端未就绪:authorizeWechat reject → 停轮询,授权区展示占位文案。
- */
-async function handleTabChange(tab: string | number): Promise<void> {
-  activeTab.value = tab === 'wechat' ? 'wechat' : 'password';
-  stopPolling();
-  errorMsg.value = '';
-  if (activeTab.value === 'wechat') {
-    authUrl.value = '';
-    // 取当前作用域校区(未登录时 institutionId/campusId 为 0,后端取默认 appid)
-    const campusId = scope.current.campusId ?? 0;
-    try {
-      const auth = await authorizeWechat(campusId);
-      if (auth.code === 200 && auth.data) {
-        authUrl.value = auth.data.url;
-        // 轮询授权状态(1.5s 间隔;占位节奏,后端就绪后可调)
-        const state = auth.data.state;
-        polling.value = true;
-        pollTimer = setInterval(async () => {
-          try {
-            const s = await pollWechatStatus(state);
-            if (s.code !== 200 || !s.data) return; // 单次轮询业务错误:静默,下轮重试
-            const { status, token } = s.data;
-            if (status === 'success' && token) {
-              stopPolling();
-              loginStore.applyLogin(token);
-              await onLoginSuccess(token);
-            } else if (status === 'cancel' || status === 'expire') {
-              stopPolling();
-              authUrl.value = ''; // 终态:清空授权态,提示重试
-            }
-          } catch {
-            // 单次轮询网络故障:静默(不中断轮询,下轮重试)
-          }
-        }, 1500);
-      }
-    } catch {
-      // 后端未就绪 / 取授权失败:占位(保持 authUrl 空,授权区显"暂未开放")
-      stopPolling();
-      showToast('微信授权暂未开放');
-    }
-  }
-}
-
-// 组件卸载清轮询定时器(防内存泄漏;登录页被守卫跳转销毁时必清)
-onUnmounted(() => {
-  stopPolling();
-});
-
-/** 打开微信授权页(新窗口;占位——真实 per-campus 授权多为整页跳转,此处新窗口便于联调观察) */
-function openWechatAuth(): void {
-  if (authUrl.value) window.open(authUrl.value, '_blank');
-}
 </script>
 
 <template>
@@ -175,8 +110,8 @@ function openWechatAuth(): void {
 
     <van-notice-bar v-if="errorMsg" color="#ee0a24" background="#fff1f0" :text="errorMsg" class="login-error" />
 
-    <!-- 登录方式切换(账号密码 / 微信授权):@change 切到微信时发起授权 + 启动轮询 -->
-    <van-tabs v-model:active="activeTab" @change="handleTabChange" class="login-tabs">
+    <!-- 登录方式切换(账号密码 / 微信授权) -->
+    <van-tabs v-model:active="activeTab" class="login-tabs">
       <!-- 账号密码 -->
       <van-tab title="账号密码" name="password">
         <van-form @submit="handleSubmit">
@@ -207,20 +142,11 @@ function openWechatAuth(): void {
         </van-form>
       </van-tab>
 
-      <!-- 微信授权(占位:后端端点未就绪,UI 骨架完整;授权 URL 取到前显占位框) -->
+      <!-- 微信授权(占位:后端微信扫码为 PC 端扫码流程;移动端 App 内授权接入方案待拍板,显占位提示) -->
       <van-tab title="微信授权" name="wechat">
         <div class="login-wechat">
-          <van-icon v-if="authUrl" name="wechat" size="64" color="#07c160" />
-          <van-icon v-else name="cross" size="64" color="var(--app-text-secondary)" />
-          <p class="login-wechat__tip">
-            {{ polling ? '请使用微信完成授权…' : '切换至此自动发起授权' }}
-          </p>
-          <van-button v-if="authUrl" type="primary" round @click="openWechatAuth">
-            打开微信授权
-          </van-button>
-          <van-button v-else type="default" round plain disabled>
-            微信授权暂未开放
-          </van-button>
+          <van-icon name="wechat" size="64" color="#07c160" />
+          <p class="login-wechat__tip">微信授权暂未开放,请使用账号密码登录</p>
         </div>
       </van-tab>
     </van-tabs>

+ 3 - 3
apps/teacher/src/views/org/institution/list.vue

@@ -64,10 +64,10 @@ onMounted(() => {
       @refresh="onRefresh"
     >
       <template #default="{ row }">
-        <van-cell :title="row.name" :label="`编码 ${row.code}`">
+        <van-cell :title="row.institutionName" :label="row.institutionCode ? `编码 ${row.institutionCode}` : ''">
           <template #value>
-            <van-tag :type="row.status === 1 ? 'success' : 'danger'">
-              {{ row.status === 1 ? '启用' : '停用' }}
+            <van-tag :type="row.status === 'active' ? 'success' : 'danger'">
+              {{ row.status === 'active' ? '启用' : '停用' }}
             </van-tag>
           </template>
         </van-cell>

+ 103 - 35
packages/shared/src/api/auth/login.ts

@@ -1,74 +1,142 @@
 /**
- * login.ts —— 登录/登出接口封装(auth 域;对应后端 identity 模块 ADR-014 SSO 最小可行)。
+ * login.ts —— 登录/登出接口封装(auth 域;一域一目录、一资源一文件纪律)。
  *
- * 一域一目录、一资源一文件纪律:本文件只导出登录相关请求函数 + 对应 DTO。
- * 三端共用一份(对齐"底层用户统一"——同一 canonical_user_id 登录,角色差异由 permissions/tabTree 区分)。
+ * 两步登录协议(对齐后端 identity ADR-014 / T4 契约,2026-11-06;HTTP 恒 200、真实状态在 body code):
+ *   ① POST /api/auth/login   验账密 → 一次性票据(loginTicket) + 用户 + 作用域列表(memberships[]),**不签 token**;
+ *   ② POST /api/auth/issue   凭 loginTicket + 选定 (institutionId, campusId) → 凭证对(access+refresh)
+ *      + 选定作用域 + roles + RBAC menuTree/permissions。
+ *   票据 5min 有效、单次消费(后端 Redis GETDEL 原子),失效需重走①。
  *
- * 典型使用(shared/store/login.ts 内):
+ * 移动端与 PC 端差异(README §鉴权与微信授权):
+ *   - PC 端多条 membership 进"作用域选择页"由用户挑选;移动端**无**独立选择页——
+ *     登录① 成功后前端**自动取 memberships[0] 立即 ②**(单机构/校区直入;多机构切换为后续待拍板项)。
+ *   - 后端 ② 下发 `menuTree`(按角色过滤的导航树);PC 端作侧边菜单、移动端作底部 Tabbar(tabTree)消费——
+ *     字段名后端统一为 menuTree,store 层映射进 tabTree(消费组件不同,契约不变)。
+ *   - roles 为**字符串角色码**(super_admin/academic/finance/teacher/student/parent/market),非旧 number 段。
  *
- *   import { login } from '@unified/shared/api/auth/login';
- *   const res = await login({ username, password });
- *   if (res.code !== 200) return; // 错误文案 res.message 已由 http 层 toast
- *   loginStore.applyLogin(res.data);
+ * 本文件只导出登录相关请求函数 + 对应 DTO(与后端 identity/service 出参 DTO 逐字段对齐,camelCase)。
+ * 三端共用一份(对齐"底层用户统一"——同一 canonical_user_id 登录,角色差异由 permissions/tabTree 区分)。
  */
 import { http } from '../http';
 import type { MenuNode, R } from '../../types/api';
 
-/** 登录请求体(后端 username/password 单一口径,兼容期旧端点别名映射在后端完成) */
+/** 登录请求体(后端 username/password 单一口径;密码 6~128 位,后端 validate 校验) */
 export interface LoginReq {
-  /** 用户名(必填) */
+  /** 用户名(必填,≤64 位) */
   username: string;
-  /** 密码(必填;传输层 TLS 保护,前端不明文落盘) */
+  /** 密码(必填,6~128 位;传输层 TLS 保护,前端不明文落盘) */
   password: string;
 }
 
-/** 登录成功返回的用户凭证(后端 ADR-014:access 30min + refresh 30 天) */
-export interface LoginResult {
+/** 用户对外视图(后端 UserView;不含密码三列) */
+export interface LoginUser {
   /** canonical_user_id(ULID,VARCHAR(26),ADR-013) */
   id: string;
-  /** 用户名 */
+  /** 用户名(可空) */
   username: string;
-  /** 真实姓名 */
+  /** 真实姓名(可空) */
   realName: string;
-  /** 角色码数组(7 角色字典;市场 role 9 数据保留但登录入口暂禁用) */
-  roles: number[];
-  /** 所属机构 id(作用域 institution,登录响应即下发) */
+  /** 业务身份:student/teacher/parent/staff */
+  personType: string;
+  /** 账号状态:active/disabled/merged */
+  status: string;
+}
+
+/** 一条成员关系(登录① 作用域列表项;后端 MembershipView) */
+export interface MembershipView {
+  /** 成员关系 id */
+  membershipId: number;
+  /** 机构 id(作用域第一级;0 = 平台级超管) */
+  institutionId: number;
+  /** 校区 id(作用域第二级;0 = 机构级/不限) */
+  campusId: number;
+  /** 字符串角色码(super_admin/academic/finance/teacher/student/parent/market) */
+  role: string;
+  /** 成员状态:active 在籍 / inactive 离开 */
+  status: 'active' | 'inactive';
+}
+
+/** 两步登录第一步出参(验账密成功,未签 token;后端 LoginStep1) */
+export interface LoginStep1 {
+  /** 一次性票据(② 消费;32 hex,5min 有效,单次) */
+  loginTicket: string;
+  /** 登录用户基础信息 */
+  user: LoginUser;
+  /** 可选作用域列表(空 = 该用户暂无机构归属,前端提示"联系管理员分配机构") */
+  memberships: MembershipView[];
+}
+
+/** 两步登录第二步请求体(选定作用域后换取 token;后端 IssueReq) */
+export interface IssueReq {
+  /** ① 返回的一次性票据(32 hex) */
+  loginTicket: string;
+  /** 选定机构(0 = 平台级超管) */
   institutionId: number;
-  /** 所属校区 id(0 = 机构级,无校区约束) */
+  /** 选定校区(0 = 机构级/不限) */
   campusId: number;
+}
+
+/** 两步登录第二步出参(签发的凭证对 + 作用域 + 用户 + RBAC 菜单/权限码;后端 TokenPair) */
+export interface TokenPair {
   /** access token(RS256 JWT,30 分钟过期) */
   accessToken: string;
-  /** refresh token(不透明串,30 天过期) */
+  /** refresh token(不透明串,64 hex,30 天过期) */
   refreshToken: string;
   /** access 过期时间(Unix 毫秒) */
   expiresAt: number;
-  /** RBAC 权限码数组(`域.资源.动作` 三段式;菜单/按钮显隐唯一依据) */
+  /** 登录用户基础信息 */
+  user: LoginUser;
+  /** 选定作用域的角色码(字符串数组,通常单条;对齐后端 []string) */
+  roles: string[];
+  /** 选定机构 id(作用域 institution) */
+  institutionId: number;
+  /** 选定校区 id(0 = 机构级,无校区约束) */
+  campusId: number;
+  /** 可见导航树(后端按角色过滤后下发;移动端作底部 Tabbar 消费,store 层映射进 tabTree) */
+  menuTree: MenuNode[];
+  /** RBAC 权限码数组(`域.资源.动作` 三段式;含 button,菜单/按钮显隐唯一依据) */
   permissions: string[];
-  /**
-   * 可见底部导航树(后端按角色/权限过滤后下发;tabbar-menu 组件渲染,与 PC 端 menuTree 同构契约)。
-   * 移动端作 tabTree 消费(van-tabbar 各 tab),字段含义见 types/api.ts MenuNode。
-   */
-  tabTree: MenuNode[];
 }
 
 /**
- * 登录(POST /api/auth/login)。
+ * 登录第一步(POST /api/auth/login,公开)。
  *
  * 入参:
  *   - req LoginReq:username/password 必填;字段缺失由前端表单 rules 拦截,
- *     后端兜底 2001(参数缺失)。
+ *     密码 <6 位后端兜底 2001;账号不存在/密码错/禁用统一 1001(防枚举)。
+ *
+ * 出参:
+ *   - Promise<R<LoginStep1>>:
+ *     - code=200:data 为 {loginTicket, user, memberships[]};移动端据此自动取 memberships[0] 完成②;
+ *     - code=1001:账号或密码错误(防枚举统一文案);
+ *     - code=2001:参数错误(密码长度等);
+ *     - code=5002:系统异常(DB/票据写失败)。
+ *
+ * 异常场景:
+ *   - 网络层故障(断网/超时/CORS):Promise reject,调用方 catch 提示"网络连接异常"。
+ */
+export function login(req: LoginReq): Promise<R<LoginStep1>> {
+  return http.post<R<LoginStep1>>('/api/auth/login', req).then((r) => r.data);
+}
+
+/**
+ * 登录第二步(POST /api/auth/issue,公开,凭一次性票据)。
+ *
+ * 入参:
+ *   - req IssueReq:loginTicket 必填(① 返回);institutionId/campusId 为选定的作用域。
  *
  * 出参:
- *   - Promise<R<LoginResult>>:
- *     - code=200:data 为凭证;调用方写入 login store;
- *     - 账号密码错误:1005 段(后端定义),展示 message 即可;
- *     - code=5001:系统繁忙,展示 message 即可。
+ *   - Promise<R<TokenPair>>:
+ *     - code=200:data 为凭证对 + 作用域 + roles + menuTree/permissions;调用方写 token-store + 设 scope;
+ *     - code=1001:票据失效(已消费/过期/不存在,需重走①);
+ *     - code=1003:该 (机构,校区) 不属于当前账号(越权)/ 该归属已失效;
+ *     - code=5002:签发失败。
  *
  * 异常场景:
- *   - 网络层故障(断网/超时):Promise reject,调用方 catch 提示"网络连接异常"。
+ *   - 网络层故障:Promise reject,调用方 catch 提示"网络连接异常"。
  */
-export function login(req: LoginReq): Promise<R<LoginResult>> {
-  return http.post<R<LoginResult>>('/api/auth/login', req).then((r) => r.data);
+export function issue(req: IssueReq): Promise<R<TokenPair>> {
+  return http.post<R<TokenPair>>('/api/auth/issue', req).then((r) => r.data);
 }
 
 /**

+ 48 - 50
packages/shared/src/api/auth/wechat.ts

@@ -1,80 +1,78 @@
 /**
- * wechat.ts —— 微信 per-campus 授权接口封装(auth 域;家长端主入口,README §鉴权与微信授权)。
+ * wechat.ts —— 微信扫码登录接口封装(auth 域;一域一目录、一资源一文件纪律)。
  *
- * ⚠️ 占位说明(2026-11-06):后端微信授权端点**尚未就绪**。
- *   本文件的接口路径 / 字段为**占位**(按微信 per-campus 授权通用流程假设),联调前必须与后端核对:
- *     - POST /api/auth/wechat/authorize  发起授权(按 campusId 取对应 appid 的微信授权 URL + 本地 state)
- *     - GET  /api/auth/wechat/status      轮询/回调后的授权状态(pending/scanning/success/cancel/expire)
- *   微信 per-campus:campus → appid 映射由后端下发(一校区一公众号/小程序),前端只传 campusId,
- *   不接触 appid/secret(密钥侧全在后端)。
- *   后端就绪后按真实契约修订本文件字段名与路径(保持 camelCase,与后端 DTO 逐字段对齐)。
+ * 契约(对齐后端 identity 微信扫码端点,2026-11-06;HTTP 恒 200、真实状态在 body code):
+ *   - GET /api/auth/wechat/qrcode          生成扫码二维码:{state, qrUrl, campusId, expiresIn};
+ *     qrUrl 是**微信网页授权 URL(open.weixin.qq.com/connect/qrconnect)**——是 URL 字符串**非图片**,
+ *     前端用 qrcode 库把该 URL 编码成二维码图(见各端 login 页),用户扫的是这个二维码。
+ *   - GET /api/auth/wechat/status?state=   轮询扫码状态:{status, campusId, loginTicket?, userId?,
+ *     memberships?, expired?}。status 取值 pending/success/unbound/fail;expired 为布尔(与 status 互斥,
+ *     expired=true 时 status 为空)。
+ *   - 微信扫码成功后**不直接发 token**:success 返回 loginTicket + memberships,前端拿这两样走
+ *     与账密登录**完全同一套** ② /api/auth/issue 签 token(移动端自动取 memberships[0] 立即②)。
  *
- * 典型使用(apps/{端}/src/views/login 微信 tab,或家长端入口自动授权):
- *
- *   // 家长端入口:按作用域 campus 发起授权
- *   const auth = await authorizeWechat(campusId);
- *   window.location.href = auth.url; // 跳转微信授权页(携带 state)
- *   // 授权回跳后的落地页:轮询状态直到 success 拿凭证
- *   const timer = setInterval(async () => {
- *     const s = await pollWechatStatus(auth.state);
- *     if (s.status === 'success' && s.token) { loginStore.applyLogin(s.token); clearInterval(timer); }
- *     if (s.status === 'expire' || s.status === 'cancel') { clearInterval(timer); }
- *   }, 1500);
+ * 错误码(与后端 errors 包对齐):4004 微信扫码未配置;2001 缺 state 参数;5002 Redis/DB 故障(fail-closed)。
  */
 import { http } from '../http';
+import type { MembershipView } from './login';
 import type { R } from '../../types/api';
-import type { LoginResult } from './login';
 
-/** 授权状态机(前端轮询得到的状态;与后端枚举对齐,后端未定前为占位) */
-export type WechatAuthStatus = 'pending' | 'scanning' | 'success' | 'cancel' | 'expire';
+/** 扫码状态机取值(与后端 WechatQrcode* 常量逐字对齐) */
+export type WechatQrcodeStatus = 'pending' | 'success' | 'unbound' | 'fail';
 
-/** 发起授权返回体(占位 DTO,后端就绪后对齐) */
-export interface WechatAuthorizeResult {
-  /** 微信授权 URL(含 appid + state;前端整页跳转过去,用户授权后微信回调后端) */
-  url: string;
-  /** 本地 state 标识(轮询时回传,后端据此关联授权会话,防 CSRF) */
+/** 获取二维码返回体(后端 QRCodeResult;qrUrl 为微信网页授权 URL,需前端编码成二维码图) */
+export interface WechatQrcodeResult {
+  /** 扫码会话标识(32 hex;轮询 status 时回传,后端据此关联会话) */
   state: string;
-  /** 授权会话有效期(秒;前端据此提示"已过期请重试") */
-  expireSeconds: number;
+  /** 微信网页授权 URL(open.weixin.qq.com/connect/qrconnect…&state=…);前端编码为二维码图后供用户扫 */
+  qrUrl: string;
+  /** 本次会话选定的校区(提示"正在登录 X 校区") */
+  campusId: number;
+  /** 二维码有效期(秒;前端据此展示倒计时/到期停轮询) */
+  expiresIn: number;
 }
 
-/** 轮询授权状态返回体(占位 DTO;success 时携带登录凭证,结构同账号密码的 LoginResult) */
+/** 轮询扫码状态返回体(后端 StatusResult;success 携 loginTicket+memberships 走 ② issue,非直发 token) */
 export interface WechatStatusResult {
-  /** 当前授权状态 */
-  status: WechatAuthStatus;
-  /** 登录凭证(仅 status === 'success' 时非空;其余状态省略) */
-  token?: LoginResult;
+  /** 当前扫码状态(pending 继续轮 / success 走② / unbound·fail 终态停轮) */
+  status: WechatQrcodeStatus;
+  /** 本次会话的校区(success/unbound 时回显,供前端提示) */
+  campusId: number;
+  /** 登录票据(仅 status=success 时非空;前端携它走 ② /auth/issue 签 token) */
+  loginTicket?: string;
+  /** 绑定的用户 ULID(仅 success;前端可据此展示"已登录",可选用) */
+  userId?: string;
+  /** 绑定用户的可选作用域列表(仅 success;与账密登录同源,移动端自动取 memberships[0] 走 ②) */
+  memberships?: MembershipView[];
+  /** true = state 不存在/已过期(与 status 互斥;前端停轮询提示"二维码已失效,请重新扫码") */
+  expired?: boolean;
 }
 
 /**
- * 发起微信 per-campus 授权(POST /api/auth/wechat/authorize)。
+ * 获取微信扫码二维码(GET /api/auth/wechat/qrcode,公开)。
  *
- * 入参:
- *   - campusId number:当前作用域校区 id(后端据此查 appid 映射;0 = 机构级,后端取默认 appid)。
+ * 入参:无(后端自行选第一个配置的校区 + 生成 state 落 Redis(pending))。
  * 出参:
- *   - Promise<R<WechatAuthorizeResult>>:code=200 时 data 为授权 URL + state;失败展示 message。
+ *   - Promise<R<WechatQrcodeResult>>:code=200 时 data 为 {state, qrUrl, campusId, expiresIn};
+ *     失败(4004 未配置 / 5002 Redis 写失败)由调用方 catch 或读 code 展示 message。
  * 异常场景:
- *   - 后端未就绪(404/501):按占位语义,调用方 catch 提示"微信授权暂未开放";
- *   - 该 campus 无 appid 映射(4xxx 段):展示后端 message(如"该校区未配置微信入口")。
+ *   - 网络层故障:Promise reject,调用方 catch 提示"网络连接异常"。
  */
-export function authorizeWechat(campusId: number): Promise<R<WechatAuthorizeResult>> {
-  // TODO(后端就绪后):核对路径、入参名与返回结构
-  return http
-    .post<R<WechatAuthorizeResult>>('/api/auth/wechat/authorize', { campusId })
-    .then((r) => r.data);
+export function fetchWechatQrcode(): Promise<R<WechatQrcodeResult>> {
+  return http.get<R<WechatQrcodeResult>>('/api/auth/wechat/qrcode').then((r) => r.data);
 }
 
 /**
- * 轮询微信授权状态(GET /api/auth/wechat/status?state=xxx)。
+ * 轮询微信扫码状态(GET /api/auth/wechat/status?state=xxx,公开;只读快照,不消费会话)。
  *
  * 入参:
- *   - state string:authorizeWechat 返回的本地 state 标识。
+ *   - state string:fetchWechatQrcode 返回的会话标识。
  * 出参:
- *   - Promise<R<WechatStatusResult>>:code=200 时 data.status 为状态机值;success 时 data.token 非空。
+ *   - Promise<R<WechatStatusResult>>:code=200 时 data.expired=true → 二维码失效;
+ *     否则 data.status 为状态机值,success 时 data.loginTicket + data.memberships 非空。
  * 异常场景:
  *   - 网络故障:Promise reject,调用方 catch(轮询里静默跳过本次,下轮重试)。
  */
-export function pollWechatStatus(state: string): Promise<R<WechatStatusResult>> {
-  // TODO(后端就绪后):核对路径、query 参数名与状态枚举
+export function pollWechatQrcodeStatus(state: string): Promise<R<WechatStatusResult>> {
   return http.get<R<WechatStatusResult>>('/api/auth/wechat/status', { params: { state } }).then((r) => r.data);
 }

+ 22 - 15
packages/shared/src/store/login.ts

@@ -4,9 +4,15 @@
  * 职责边界:
  *   - 持有**响应式视图**(token/角色/权限码/tabTree),供组件 `useLogin()` 读取渲染 tab/按钮;
  *   - token 的**物理存取**全部经 identity/token-store(本文件与 http.ts 都不直接碰 localStorage);
- *   - 登录/登出动作封装在此(调 api/auth/login.ts → 写 token-store → 同步视图)。
+ *   - 登录② 凭证落盘封装在此(调 api/auth/login.ts 两步的② TokenPair → 写 token-store → 同步视图)。
  * 三端共用一份(对齐"底层用户统一")。
  *
+ * 两步登录状态机(对齐后端 identity ADR-014 / T4 契约,2026-11-06):
+ *   ① 验账密(POST /login)→ loginTicket + memberships[](**不签 token**);
+ *   ② 选定作用域(POST /issue)→ TokenPair(access+refresh+roles+menuTree+permissions)→ applyLogin 落盘。
+ *   移动端**无**独立作用域选择页:登录页在① 成功后自动取 memberships[0] 立即 ②,故本 store 只需
+ *   applyLogin(落 ② 的 TokenPair);不保留 ① 过渡态(票据一次性,① 未 ② 前刷新即丢,需重走①)。
+ *
  * 典型使用(组件内):
  *
  *   const login = useLogin();
@@ -14,7 +20,7 @@
  *   <template>{{ login.realName }}</template>
  *   <tabbar-menu :tab-tree="login.tabTree" />
  */
-import { logout as apiLogout, type LoginResult } from '../api/auth/login';
+import { logout as apiLogout, type TokenPair } from '../api/auth/login';
 import { clearTokens, getAccessToken, getExpiresAt, getRefreshToken, setTokens } from '../identity/token-store';
 import type { ComputedRef, Ref } from 'vue';
 import type { MenuNode } from '../types/api';
@@ -29,20 +35,20 @@ export interface LoginStore {
   accessToken: Ref<string | null>;
   /** 过期时间(Unix 毫秒;null = 无记录,按已过期处理) */
   expiresAt: Ref<number | null>;
-  /** 当前用户真实姓名(未登录为 '') */
+  /** 当前用户真实姓名(未登录为 '';② 下发 user.realName) */
   realName: Ref<string>;
-  /** 角色码数组(7 角色字典;未登录为 []) */
-  roles: Ref<number[]>;
+  /** 角色码数组(字符串角色码 7 角色字典;对齐后端 []string;未登录为 []) */
+  roles: Ref<string[]>;
   /** RBAC 权限码集合(`域.资源.动作`;菜单/按钮显隐唯一依据) */
   permissions: Ref<Set<string>>;
-  /** 可见底部导航树(登录响应下发;tabbar-menu 渲染。刷新后未恢复前为 [],tab 降级全隐藏) */
+  /** 可见底部导航树(② 响应 menuTree 下发;tabbar-menu 渲染。刷新后未恢复前为 [],tab 降级全隐藏) */
   tabTree: Ref<MenuNode[]>;
   /** 是否已登录(有 access token 且未过期;守卫链第一步) */
   isLoggedIn: ComputedRef<boolean>;
   /** 判断是否持有指定权限码(v-perm 指令用) */
   hasPerm: (perm: string) => boolean;
-  /** 登录成功后的凭证落盘(http 拦截器/组件不得自行写) */
-  applyLogin: (data: LoginResult) => void;
+  /** 登录② 成功后的凭证落盘(http 拦截器/组件不得自行写 token;同步视图) */
+  applyLogin: (data: TokenPair) => void;
   /** 执行登出(调后端吊销 → 清本地态) */
   doLogout: () => Promise<void>;
   /** 从 token-store 同步一次本地视图(页面刷新后 main.ts 调用,恢复 UI 状态) */
@@ -53,7 +59,7 @@ export const useLogin = defineStore('login', (): LoginStore => {
   const accessToken = ref<string | null>(null);
   const expiresAt = ref<number | null>(null);
   const realName = ref('');
-  const roles = ref<number[]>([]);
+  const roles = ref<string[]>([]);
   const permissions = ref(new Set<string>());
   // 可见底部导航树(仅内存态,不落 localStorage——tabTree 是响应式视图非凭证,
   // 刷新后随 roles/permissions 一样降级为空,完整恢复依赖后续 /api/auth/me 端点(规划项))
@@ -80,24 +86,25 @@ export const useLogin = defineStore('login', (): LoginStore => {
   }
 
   /**
-   * 登录成功凭证落盘(token-store 物理写入 + 响应式视图同步)。
+   * 登录② 成功凭证落盘(token-store 物理写入 + 响应式视图同步)。
    *
    * 入参:
-   *   - data LoginResult:api/auth/login.ts 返回的 data(code=200 时)。
+   *   - data TokenPair:api/auth/login.ts ②(issue)返回的 data(code=200 时)。
    *
    * 出参:无。
    * 异常场景:
    *   - data 缺 accessToken:视为后端契约违约,视图不落盘(isLoggedIn 保持 false)。
    */
-  function applyLogin(data: LoginResult): void {
+  function applyLogin(data: TokenPair): void {
     if (!data.accessToken) return;
     setTokens({ access: data.accessToken, refresh: data.refreshToken, expiresAt: data.expiresAt });
     accessToken.value = data.accessToken;
     expiresAt.value = data.expiresAt;
-    realName.value = data.realName;
+    realName.value = data.user?.realName ?? '';
     roles.value = data.roles ?? [];
     permissions.value = new Set(data.permissions ?? []);
-    tabTree.value = data.tabTree ?? [];
+    // 后端 ② 下发 menuTree;移动端作底部 Tabbar 消费(store 字段名 tabTree,契约字段名 menuTree)
+    tabTree.value = data.menuTree ?? [];
   }
 
   /**
@@ -125,7 +132,7 @@ export const useLogin = defineStore('login', (): LoginStore => {
   }
 
   /**
-   * 从 token-store 同步本地视图(main.ts 启动时调用,恢复刷新前的 UI 状态)。
+   * 从 token-store 同步一次本地视图(main.ts 启动时调用,恢复刷新前的 UI 状态)。
    *
    * 入参:无。
    *