Explorar o código

feat(teacher): 移动端 App 内微信 webview 授权登录(snsapi_userinfo)

- shared wechat.ts: 新增 fetchWechatAuthorize(GET /api/auth/wechat/authorize →
  {state, authorizeUrl, campusId, expiresIn})+ fetchWechatCallback(GET /api/auth/wechat/callback
  ?code=&state= → status+loginTicket+memberships);注释区分 PC 扫码(snsapi_login/qrconnect)
  与移动 webview(snsapi_userinfo/oauth2/authorize),callback 两 scope 共用
- teacher login/index.vue: 微信 tab 由占位改真实流程——检测 UA MicroMessenger →
  fetchWechatAuthorize → location.href 整页跳 authorizeUrl(非微信环境提示);
  成功授权后微信重定向回 /wechat/callback 落地页
- 新增 views/wechat/callback.vue: 公开落地页,读 code/state → fetchWechatCallback 换
  loginTicket → 自动取 memberships[0] 走 ② issue → applyLogin → scope.set → 跳首页;
  unbound/fail/缺参/网络异常 直显 message + 返回登录;已登录幂等跳首页
- router: 新增 /wechat/callback 公开路由(meta.public + scopeRequired:false,守卫①放行)
- 门禁: typecheck(×2)/lint/build 全绿 + dist grep 无 Vue 运行时内部标识
Q hai 3 días
pai
achega
03cdb0027f

+ 11 - 1
apps/teacher/src/router/index.ts

@@ -40,7 +40,8 @@ declare module 'vue-router' {
  *   - 访问 '/' 自动 redirect 到首个业务页 /org/institutions。
  *
  * 出参:
- *   - RouteRecordRaw[]:/login / layout(children: org/institutions, academic/checkin) / 兜底 404。
+ *   - RouteRecordRaw[]:/login / /wechat/callback / layout(children: org/institutions,
+ *     academic/checkin) / 兜底 404。
  */
 export const routes: RouteRecordRaw[] = [
   {
@@ -49,6 +50,15 @@ export const routes: RouteRecordRaw[] = [
     component: () => import('@/views/login/index.vue'),
     meta: { public: true, scopeRequired: false, title: '登录' },
   },
+  {
+    // 微信 webview 授权落地页(snsapi_userinfo 回调;公开路由——未登录也能访问,
+    // 由页面内部读 code/state 调后端 callback 换票后走 ② issue;成功后跳首页)。
+    // 守卫白名单:meta.public=true,守卫①直接放行(不要求登录态)。
+    path: '/wechat/callback',
+    name: 'WechatCallback',
+    component: () => import('@/views/wechat/callback.vue'),
+    meta: { public: true, scopeRequired: false, title: '微信授权' },
+  },
   {
     // layout 父路由:套壳(顶部 nav-bar + 底部 tabbar + 内容区 router-view)。业务域路由嵌套其下。
     path: '/',

+ 78 - 4
apps/teacher/src/views/login/index.vue

@@ -3,8 +3,9 @@
  * 登录页(views/login/index.vue;公开路由,守卫 meta.public)。
  *
  * 职责:两种登录方式 tab 切换——① 账号密码(两步:POST /api/auth/login → POST /api/auth/issue)
- * ② 微信授权(占位:后端微信扫码端点为 PC 端扫码流程,移动端"App 内微信授权"接入方案待拍板,
- *  此处显占位提示,不做假轮询);成功则写 token-store + 初始化作用域 + router.push 进首页;
+ * ② 微信授权(移动端 App 内 webview 授权:检测微信内置浏览器 → GET /api/auth/wechat/authorize
+ *  拿 snsapi_userinfo 授权 URL → 整页跳转,授权后微信重定向回 /wechat/callback 落地页换票走 ②);
+ *  成功则写 token-store + 初始化作用域 + router.push 进首页;
  *  失败展示后端 message(直显,不包装)。
  *
  * 两步登录流程(对齐后端 identity ADR-014 / T4 契约,2026-11-06):
@@ -15,12 +16,21 @@
  *     - code≠200(1001 账号密码错 / 2001 参数 / 5002 系统):errorMsg 直显后端 message。
  *   ② POST /issue(凭 loginTicket + 选定作用域)→ TokenPair(access+refresh+roles+menuTree+permissions):
  *     - applyLogin 写 token + 同步 roles/permissions/tabTree;scope.set 设机构/校区作用域;跳首页。
+ *
+ * 微信 webview 授权流程(移动端 App 内,snsapi_userinfo;与 PC 扫码 snsapi_login 差异仅在 scope + URL 形态):
+ *   - 点击"微信登录"→ 检测 UA 含 MicroMessenger(微信内置浏览器);
+ *   - 非微信环境:提示"请在微信内打开后再使用微信登录";
+ *   - 微信环境:GET /api/auth/wechat/authorize 拿 {state, authorizeUrl}(authorizeUrl 是
+ *     connect/oauth2/authorize + snsapi_userinfo 的整页跳转 URL)→ location.href 整页跳转;
+ *   - 授权完成后微信重定向回 /wechat/callback?code=xxx&state=xxx → callback.vue 落地页
+ *     调后端 callback 换 loginTicket → 走与账密登录同一套 ② issue → applyLogin → 跳首页。
  * SFC 骨架顺序(编码规范):imports → props/emits(无)→ store 注入 → 状态 → 生命周期 → 方法。
  *
  * 注:模板 van-* 组件经 main.ts 的 app.use(Vant) 全局注册,本页**不 import Vant 组件**
  * (仅 import Vant Message/Toast 命令式 API 作按需增强;与 PC 端"组件走全局、命令式按需引"一致)。
  */
 import { issue, login } from '@unified/shared/api/auth/login';
+import { fetchWechatAuthorize } from '@unified/shared/api/auth/wechat';
 import { useLogin } from '@unified/shared/store/login';
 import { useScope } from '@unified/shared/store/scope';
 import type { FieldRule } from 'vant';
@@ -100,6 +110,52 @@ async function handleSubmit(): Promise<void> {
     submitting.value = false;
   }
 }
+
+/**
+ * 微信 webview 授权登录(handleWechat 前缀;用户触发的授权类方法,编码规范命名规则)。
+ *
+ * 流程:
+ *   1. 检测 UA 是否含 MicroMessenger(微信内置浏览器标识);
+ *   2. 非微信环境:errorMsg 提示"请在微信内打开后再使用微信登录",不发请求;
+ *   3. 微信环境:GET /api/auth/wechat/authorize 拿 {state, authorizeUrl}(authorizeUrl 是
+ *      connect/oauth2/authorize + snsapi_userinfo 的整页跳转 URL)→ location.href 整页跳转;
+ *   4. 授权完成后微信重定向回 /wechat/callback?code=xxx&state=xxx(前端路由,公开路由)→
+ *      callback.vue 落地页调后端 callback 换 loginTicket → 走 ② issue → applyLogin → 跳首页。
+ *
+ * 出参:
+ *   - Promise<void>:恒 resolve;整页跳转(location.href)后当前页被替换,Promise 不再有意义。
+ * 异常场景:
+ *   - 非微信环境:errorMsg 提示(不发请求);
+ *   - authorize code≠200(4004 未配置 / 5002 Redis 故障):errorMsg = 后端 message;
+ *   - authorizeUrl 为空(后端契约违约):errorMsg 提示"微信授权配置异常";
+ *   - 网络层 reject:errorMsg = "网络连接异常"。
+ */
+async function handleWechatLogin(): Promise<void> {
+  if (submitting.value) return;
+  errorMsg.value = '';
+  // 微信内置浏览器 UA 含 MicroMessenger(iOS 为 MicroMessenger/x.x.x,Android 同)
+  const isWechat = /MicroMessenger/i.test(navigator.userAgent);
+  if (!isWechat) {
+    errorMsg.value = '请在微信内打开后再使用微信登录';
+    return;
+  }
+  submitting.value = true;
+  try {
+    // 后端生成 webview 授权 URL(state 落 Redis pending;authorizeUrl 用 snsapi_userinfo)
+    const auth = await fetchWechatAuthorize();
+    if (auth.code !== 200 || !auth.data || !auth.data.authorizeUrl) {
+      errorMsg.value = auth.message || '微信授权配置异常';
+      return;
+    }
+    // 整页跳转到微信 webview 授权页(location.href 非 SPA 跳转,授权后微信重定向回 /wechat/callback)
+    window.location.href = auth.data.authorizeUrl;
+  } catch {
+    errorMsg.value = '网络连接异常';
+  } finally {
+    // location.href 跳转后页面被替换,submitting 重置无实际意义但保持对称
+    submitting.value = false;
+  }
+}
 </script>
 
 <template>
@@ -142,11 +198,25 @@ async function handleSubmit(): Promise<void> {
         </van-form>
       </van-tab>
 
-      <!-- 微信授权(占位:后端微信扫码为 PC 端扫码流程;移动端 App 内授权接入方案待拍板,显占位提示) -->
+      <!-- 微信授权(移动端 App 内 webview 授权:检测微信内置浏览器 → 整页跳 snsapi_userinfo 授权页 →
+           授权后微信重定向回 /wechat/callback 落地页换票走 ② issue) -->
       <van-tab title="微信授权" name="wechat">
         <div class="login-wechat">
           <van-icon name="wechat" size="64" color="#07c160" />
-          <p class="login-wechat__tip">微信授权暂未开放,请使用账号密码登录</p>
+          <p class="login-wechat__tip">
+            点击下方按钮跳转微信授权页,授权完成后自动返回并完成登录
+          </p>
+          <van-button
+            round
+            block
+            type="success"
+            icon="wechat"
+            :loading="submitting"
+            class="login-wechat__btn"
+            @click="handleWechatLogin"
+          >
+            微信登录
+          </van-button>
         </div>
       </van-tab>
     </van-tabs>
@@ -188,5 +258,9 @@ async function handleSubmit(): Promise<void> {
   margin: 0;
   font-size: var(--app-font-sm);
   color: var(--app-text-secondary);
+  text-align: center;
+}
+.login-wechat__btn {
+  width: 80%;
 }
 </style>

+ 173 - 0
apps/teacher/src/views/wechat/callback.vue

@@ -0,0 +1,173 @@
+<script setup lang="ts">
+/**
+ * 微信 webview 授权落地页(views/wechat/callback.vue;公开路由,守卫 meta.public)。
+ *
+ * 职责:移动端 App 内微信授权(snsapi_userinfo)的回调落地——
+ *   1. 从 URL query 读取微信重定向回带的 code + state;
+ *   2. 调 GET /api/auth/wechat/callback?code=&state=(后端 code 换 openid → 查 t_identity(wechat) →
+ *      发 loginTicket 写回 state=success);
+ *   3. success → 拿 loginTicket + memberships 走**与账密登录完全同一套** ② /api/auth/issue 签 token
+ *      (移动端自动取 memberships[0] 立即 ②)→ applyLogin → scope.set → 跳首页;
+ *   4. unbound/fail/error → 展示后端 message 直显,提供"返回登录"按钮。
+ *
+ * 与 PC 扫码差异:PC 端前端轮询 status 拿到 success 的 loginTicket;本端是微信直接重定向回来,
+ * 前端落地页直接调 callback 端点拿 loginTicket(后端 state 状态机同一套,callback 对两 scope 通用)。
+ *
+ * SFC 骨架顺序(编码规范):imports → store 注入 → 状态 → 生命周期(onMounted) → 方法。
+ *
+ * 注:模板 van-* 组件经 main.ts 的 app.use(Vant) 全局注册,本页**不 import Vant 组件**
+ * (仅 import Vant 命令式 API 作按需增强;与 PC 端"组件走全局、命令式按需引"一致)。
+ */
+import { fetchWechatCallback } from '@unified/shared/api/auth/wechat';
+import { issue, type MembershipView } from '@unified/shared/api/auth/login';
+import { useLogin } from '@unified/shared/store/login';
+import { useScope } from '@unified/shared/store/scope';
+
+const loginStore = useLogin();
+const scope = useScope();
+/** 路由实例(SPA 跳转,免全页刷新;裸标识符经 vite define 注入为 VueRouter.useRouter) */
+const router = useRouter();
+/** 当前路由 query(code/state 由微信重定向回带) */
+const route = useRoute();
+
+/** 页面状态机:processing 处理中 / error 失败(含 unbound/fail/网络异常) */
+const status = ref<'processing' | 'error'>('processing');
+/** 错误文案(后端 message 直显;processing 时为空) */
+const errorMsg = ref('');
+
+/**
+ * 执行回调换票 + ② issue(onMounted 触发;幂等——已登录直接跳首页,防重复消费 state)。
+ *
+ * 出参:无(副作用:跳首页或写 errorMsg)。
+ * 异常场景:
+ *   - 缺 code/state:errorMsg 提示"授权参数缺失",status=error;
+ *   - callback code=1001(state 失效/已终态):errorMsg 提示"授权会话已失效,请重新授权";
+ *   - callback code=4003(微信换 openid 失败):errorMsg 提示"微信授权失败,请重试";
+ *   - callback status=unbound:errorMsg 提示"该微信号未绑定账号,请使用账号密码登录";
+ *   - ② issue code≠200(1001 票据失效 / 1003 越权):errorMsg = 后端 message;
+ *   - 网络层 reject:errorMsg = "网络连接异常"。
+ */
+async function handleCallback(): Promise<void> {
+  // 幂等:已登录直接跳首页(防刷新/重复访问二次消费 state)
+  if (loginStore.isLoggedIn) {
+    await router.replace('/');
+    return;
+  }
+
+  const code = (route.query.code as string) ?? '';
+  const state = (route.query.state as string) ?? '';
+  if (!code || !state) {
+    status.value = 'error';
+    errorMsg.value = '授权参数缺失(缺 code 或 state),请重新发起微信授权';
+    return;
+  }
+
+  status.value = 'processing';
+  errorMsg.value = '';
+  try {
+    // ① 后端 callback:code 换 openid → 查绑定 → 发 loginTicket 写回 state=success
+    const cb = await fetchWechatCallback(code, state);
+    if (cb.code !== 200 || !cb.data) {
+      status.value = 'error';
+      errorMsg.value = cb.message || '微信授权失败';
+      return;
+    }
+    const { status: cbStatus, loginTicket, memberships } = cb.data;
+    // unbound:该微信号未绑定本平台账号
+    if (cbStatus === 'unbound') {
+      status.value = 'error';
+      errorMsg.value = '该微信号未绑定账号,请使用账号密码登录';
+      return;
+    }
+    // fail:微信换 openid 失败
+    if (cbStatus === 'fail') {
+      status.value = 'error';
+      errorMsg.value = '微信授权失败,请重试';
+      return;
+    }
+    // success:携 loginTicket + memberships 走与账密登录同一套 ② issue
+    if (cbStatus !== 'success' || !loginTicket) {
+      status.value = 'error';
+      errorMsg.value = '授权状态异常,请重试';
+      return;
+    }
+    const list: MembershipView[] = memberships ?? [];
+    if (list.length === 0) {
+      status.value = 'error';
+      errorMsg.value = '该账号暂无机构归属,请联系管理员分配机构';
+      return;
+    }
+    // 移动端无独立作用域选择页:自动取第一条作用域完成 ②(与账密登录同源)
+    const m = list[0];
+    const pair = await issue({
+      loginTicket,
+      institutionId: m.institutionId,
+      campusId: m.campusId,
+    });
+    if (pair.code === 200 && pair.data) {
+      loginStore.applyLogin(pair.data); // 写 token + roles/permissions/tabTree
+      scope.set({ institutionId: pair.data.institutionId, campusId: pair.data.campusId });
+      await router.replace('/');
+      return;
+    }
+    status.value = 'error';
+    errorMsg.value = pair.message || '登录失败';
+  } catch {
+    status.value = 'error';
+    errorMsg.value = '网络连接异常';
+  }
+}
+
+// 微信重定向回来后页面首帧即读 code/state 执行换票(onMounted 一次性触发)
+onMounted(() => {
+  void handleCallback();
+});
+</script>
+
+<template>
+  <div class="wechat-callback">
+    <!-- 处理中:占位加载态(微信重定向→换票→② issue 全程) -->
+    <div v-if="status === 'processing'" class="wechat-callback__status">
+      <van-loading size="32px" color="#07c160" />
+      <p class="wechat-callback__text">微信授权处理中,请稍候…</p>
+    </div>
+
+    <!-- 失败:后端 message 直显 + 返回登录 -->
+    <div v-else class="wechat-callback__status">
+      <van-icon name="warning" size="56" color="#ee0a24" />
+      <p class="wechat-callback__text wechat-callback__text--error">{{ errorMsg }}</p>
+      <van-button round type="primary" class="wechat-callback__btn" @click="router.push('/login')">
+        返回登录
+      </van-button>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.wechat-callback {
+  min-height: 100vh;
+  padding: var(--app-spacing-lg);
+  background: var(--app-bg-page);
+  box-sizing: border-box;
+}
+.wechat-callback__status {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: var(--app-spacing-md);
+  padding-top: 30vh;
+}
+.wechat-callback__text {
+  margin: 0;
+  font-size: var(--app-font-md);
+  color: var(--app-text-secondary);
+  text-align: center;
+}
+.wechat-callback__text--error {
+  color: var(--app-text-error, #ee0a24);
+}
+.wechat-callback__btn {
+  margin-top: var(--app-spacing-md);
+  min-width: 200px;
+}
+</style>

+ 74 - 6
packages/shared/src/api/auth/wechat.ts

@@ -1,17 +1,31 @@
 /**
- * wechat.ts —— 微信扫码登录接口封装(auth 域;一域一目录、一资源一文件纪律)。
+ * wechat.ts —— 微信登录接口封装(auth 域;一域一目录、一资源一文件纪律)。
  *
- * 契约(对齐后端 identity 微信扫码端点,2026-11-06;HTTP 恒 200、真实状态在 body code):
+ * 契约(对齐后端 identity 微信端点,2026-11-06;HTTP 恒 200、真实状态在 body code):
+ *
+ * **PC 扫码(snsapi_login)**:
  *   - GET /api/auth/wechat/qrcode          生成扫码二维码:{state, qrUrl, campusId, expiresIn};
- *     qrUrl 是**微信网页授权 URL(open.weixin.qq.com/connect/qrconnect)**——是 URL 字符串**非图片**,
- *     前端用 qrcode 库把该 URL 编码成二维码图(见各端 login 页),用户扫的是这个二维码。
+ *     qrUrl 是**微信网页授权 URL(connect/qrconnect + snsapi_login + #wechat_redirect)**——
+ *     是 URL 字符串**非图片**,前端用 qrcode 库把该 URL 编码成二维码图(见 PC 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,前端拿这两样走
+ *
+ * **移动 webview(snsapi_userinfo,App 内授权)**:
+ *   - GET /api/auth/wechat/authorize       生成 webview 授权 URL:{state, authorizeUrl, campusId, expiresIn};
+ *     authorizeUrl 是**微信 webview 网页授权 URL(connect/oauth2/authorize + snsapi_userinfo,无 #wechat_redirect)**,
+ *     移动端 H5 跑在微信内置浏览器里,前端**整页跳转**到该 URL 完成授权。
+ *   - 授权完成后微信重定向回 callbackURL(前端路由 /wechat/callback?code=xxx&state=xxx),
+ *     前端落地页读取 code/state 后调 GET /api/auth/wechat/callback?code=&state= 换 loginTicket。
+ *
+ * **共用**:
+ *   - GET /api/auth/wechat/callback?code=&state=  微信重定向落点(code 换 openid → 查 t_identity(wechat) →
+ *     发 loginTicket 写回 state)。PC 扫码与移动 webview **共用**同一端点(ExchangeCode 对两 scope 通用)。
+ *   - 成功后**不直接发 token**:success 返回 loginTicket + memberships,前端拿这两样走
  *     与账密登录**完全同一套** ② /api/auth/issue 签 token(移动端自动取 memberships[0] 立即②)。
  *
- * 错误码(与后端 errors 包对齐):4004 微信扫码未配置;2001 缺 state 参数;5002 Redis/DB 故障(fail-closed)。
+ * 错误码(与后端 errors 包对齐):4004 微信未配置;2001 缺 code/state 参数;1001 state 失效/已终态(防重放);
+ *   4003 微信换 openid 失败;5002 Redis/DB 故障(fail-closed)。
  */
 import { http } from '../http';
 import type { MembershipView } from './login';
@@ -76,3 +90,57 @@ export function fetchWechatQrcode(): Promise<R<WechatQrcodeResult>> {
 export function pollWechatQrcodeStatus(state: string): Promise<R<WechatStatusResult>> {
   return http.get<R<WechatStatusResult>>('/api/auth/wechat/status', { params: { state } }).then((r) => r.data);
 }
+
+/**
+ * 移动端 App 内微信 webview 授权返回体(后端 AuthorizeResult;与 WechatQrcodeResult 字段同形,
+ * 仅 URL 形态不同:authorizeUrl 是 connect/oauth2/authorize + snsapi_userinfo,前端整页跳转)。
+ */
+export interface WechatAuthorizeResult {
+  /** 授权会话标识(32 hex;微信重定向回 callback 后据此消费) */
+  state: string;
+  /** 微信 webview 网页授权 URL(connect/oauth2/authorize + snsapi_userinfo);前端整页跳转 */
+  authorizeUrl: string;
+  /** 本次会话选定的校区 */
+  campusId: number;
+  /** 会话有效期(秒;授权后须在窗口内回到 callback 才能完成换票) */
+  expiresIn: number;
+}
+
+/**
+ * 获取移动端 App 内微信授权 URL(GET /api/auth/wechat/authorize,公开)。
+ *
+ * 入参:无(后端自行选第一个配置的校区 + 生成 state 落 Redis(pending))。
+ * 出参:
+ *   - Promise<R<WechatAuthorizeResult>>:code=200 时 data 为 {state, authorizeUrl, campusId, expiresIn};
+ *     前端检测 UA 含 MicroMessenger 后整页跳转 authorizeUrl(location.href),授权后微信重定向回
+ *     前端路由 /wechat/callback?code=xxx&state=xxx。
+ * 异常场景:
+ *   - 4004 微信未配置 / 5002 Redis 写失败:Promise resolve 带 code≠200,调用方 catch 或读 code 展示 message。
+ *   - 网络层故障:Promise reject,调用方 catch 提示"网络连接异常"。
+ */
+export function fetchWechatAuthorize(): Promise<R<WechatAuthorizeResult>> {
+  return http.get<R<WechatAuthorizeResult>>('/api/auth/wechat/authorize').then((r) => r.data);
+}
+
+/**
+ * 微信重定向落点——code 换 openid(GET /api/auth/wechat/callback?code=&state=,公开)。
+ *
+ * PC 扫码与移动 webview **共用**此端点(后端 ExchangeCode 对 snsapi_login/snsapi_userinfo 两 scope 通用)。
+ *
+ * 入参:
+ *   - code  string:微信重定向回带的授权 code(一次性,数秒~分钟级有效)。
+ *   - state string:发起授权时后端生成的会话标识(防 CSRF + 关联 state 状态机)。
+ * 出参:
+ *   - Promise<R<WechatStatusResult>>:code=200 时 data.status=success → data.loginTicket +
+ *     data.memberships 非空(前端携这两样走 ② /api/auth/issue 签 token);
+ *     status=unbound → 该微信号未绑定账号;status=fail → 微信换 openid 失败。
+ * 异常场景:
+ *   - 1001 state 失效/已终态(防重放):调用方提示"授权会话已失效,请重新授权";
+ *   - 4003 微信换 openid 失败:调用方提示"微信授权失败,请重试";
+ *   - 网络故障:Promise reject。
+ */
+export function fetchWechatCallback(code: string, state: string): Promise<R<WechatStatusResult>> {
+  return http
+    .get<R<WechatStatusResult>>('/api/auth/wechat/callback', { params: { code, state } })
+    .then((r) => r.data);
+}