fix(mp): 接通微信小程序登录并补齐生产配置

- 前端落地页调用 wx.login → /mp/login,保存 token,并在每次带 scene 进入时刷新登录(F-130 前端断点)
- 后端 /mp/login 的名册/任务查询放入 TenantScope,修复带 taskId 的 scene 必然 500(F-130 后端断点)
- 小程序生产 API 改为 VITE_MP_API_BASE(.env.production),不再写死 localhost
- 生产配置补全:七牛目录 shenxue、连接池/健康检查/优雅停机等;学生端只发小程序,移除 H5 段
- PC 生产 API 改到 admin.shenxue.jxit.com;新增 deploy/prod.env.example 并忽略 prod.env
- ⚠️ application-prod.yml 含生产凭据(按部署要求直接写入),仓库访问范围需收紧
This commit is contained in:
2026-10-20 07:25:57 +08:00
parent 17c92b29c3
commit 1e71c164af
10 changed files with 505 additions and 87 deletions
+9
View File
@@ -0,0 +1,9 @@
# 微信小程序生产包(`pnpm build:mp-weixin`)的环境变量
#
# ⚠️ 必须是 **HTTPS 正式域名**,并且要在微信公众平台
# 「开发管理 → 开发设置 → 服务器域名」里配成 **request 合法域名**。
# 否则真机上所有请求都会以 `request:fail url not in domain list` 失败。
#
# ⚠️ 这里指向后端 API 的地址(PC 管理端 .env.production 的 VITE_API_BASE_URL 同源),
# 不是 mini program 的 appid/secret —— 那两项在后端 application-prod.yml 里。
VITE_MP_API_BASE = 'https://admin.shenxue.jxit.com'
+12 -1
View File
@@ -43,7 +43,18 @@ export const API_BASE_URL =
// #endif
// #ifndef H5
export const API_BASE_URL = 'http://localhost:8080'
/**
* 小程序:没有"同域"这个概念,必须写绝对地址。
*
* 正式包用构建变量 `VITE_MP_API_BASE` 指定(见 `.env.production`):
* 必须是 **HTTPS 正式域名**,并且要在微信公众平台
* 「开发管理 → 开发设置 → 服务器域名」里配成 **request 合法域名**。
*
* 不指定时回退到本机 8080 —— 只用于微信开发者工具调试
* (需要在「详情 → 本地设置」里勾"不校验合法域名")。
*/
const MP_API_BASE = (import.meta.env?.VITE_MP_API_BASE ?? '') as string
export const API_BASE_URL = MP_API_BASE !== '' ? MP_API_BASE : 'http://localhost:8080'
// #endif
/** 主题色(与 PC 管理端同一套腾讯云 TDesign 色板,两端视觉要一致)。 */
+63 -12
View File
@@ -44,6 +44,7 @@
import { computed, ref } from 'vue'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import { request } from '@/utils/request'
import { getToken, loginByWechat } from '@/utils/auth'
/**
* 扫码落地页。
@@ -89,15 +90,6 @@ const isError = ref(false)
const loading = ref(false)
const title = ref('材料填报')
/** 取登录态(授权回调把 token 放在 URL fragment 里,落地页存下来的)。 */
function token(): string {
try {
return uni.getStorageSync('token') || ''
} catch {
return ''
}
}
/**
* 当前 URL 里的 scene(授权回调回来时在 hash 路由的 query 里)。
*
@@ -158,7 +150,7 @@ async function load() {
// ★ 没有 scene 但有登录态 → 说明他是**回来看进度的**,不是扫码进来的。
// 以前这种情况一律报错"请扫描二维码进入",于是学生一旦找不到老师发的那条消息
// 就再也进不来了(通知渠道还没做,他只能靠历史消息)。现在直接送去「我的填报」。
if (token()) {
if (getToken()) {
uni.redirectTo({ url: '/pages/tasks/tasks' })
return
}
@@ -188,7 +180,7 @@ async function load() {
}
}
function start() {
async function start() {
const s = scene.value || sceneFromUrl()
if (!s) {
uni.showModal({
@@ -198,10 +190,69 @@ function start() {
})
return
}
// #ifdef MP-WEIXIN
// 扫码进来时 bootstrap 已经登录过;这里再兜一次:网络抖动、第一次登录失败后,
// 学生点"开始填报"时还有一次机会,而不是带着空 token 去核验页吃 40101。
if (!getToken()) {
try {
await loginByWechat(s)
} catch (e) {
uni.showModal({
title: '登录失败',
content: e instanceof Error ? e.message : '微信登录失败,请稍后重试',
showCancel: false
})
return
}
}
// #endif
// 带上 scene —— 见 sceneFromUrl 的说明(H5 跳转不保留 URL 参数)
uni.navigateTo({ url: `/pages/verify/verify?scene=${encodeURIComponent(s)}` })
}
/**
* 落地页启动。
*
* - 小程序:带 scene 进来就先 `wx.login` 换一次 token(token 7 天有效,
* 旧 token 过期后直接去核验页只会得到 40101);
* - 再读 scene-context,把"哪个学校 / 什么任务 / 能不能填"显示出来。
*
* 登录放在读任务信息**之前**:登录只需要一次 code 交换,很快;
* 这样页面渲染出「开始填报」时 token 一定已经落盘,不会出现
* "学生点得比登录快"导致同一页里发两次 `wx.login`。
* 登录失败也不挡任务信息展示 —— 点「开始填报」时会再试一次并明确报错。
*/
async function bootstrap() {
// #ifdef MP-WEIXIN
let loginError = ''
if (scene.value) {
loading.value = true
try {
// 每次带 scene 进来都换一次 token:小程序 token 有效期 7 天,
// 拿过期 token 去核验页只会得到 40101。登录本身很便宜(一次 code 换 openid)。
await loginByWechat(scene.value)
} catch (e) {
// 已有 token 时登录失败不致命(可能只是网络抖动),继续用旧 token;
// 连 token 都没有才把错误显示出来。
if (!getToken()) {
loginError = e instanceof Error ? e.message : '微信登录失败'
}
}
}
// #endif
await load()
// #ifdef MP-WEIXIN
if (loginError) {
isError.value = true
message.value = loginError
}
// #endif
}
onLoad((options) => {
rawScene.value = options?.scene || options?.q || ''
scene.value = pickScene(options)
@@ -225,7 +276,7 @@ onLoad((options) => {
}
}
load()
void bootstrap()
})
// 下拉刷新:任务被截止/恢复后,学生不必杀进程重进
+142
View File
@@ -0,0 +1,142 @@
import { request } from '@/utils/request'
/**
* 微信小程序登录(契约 §5.3 的 `/mp/login`)。
*
* <h3>为什么必须由前端调一次</h3>
*
* `wx.login()` 拿到的 `code` **一次有效、几分钟过期**,只能由小程序端现取。
* 后端拿它去 `jscode2session` 换 openid,再按 scene 里的租户找/建学生账号。
* 没有这一步就没有 token —— 后面所有接口都会 40101。
*
* <h3>与 H5 网页授权的区别</h3>
*
* H5 的 token 是后端 302 回来时放在 URL 里的(见 pages/index/index.vue 的
* `options.token`);小程序没有这个回调,必须主动调接口换。
* 生产已不部署 H5,学生端只走这一条路。
*/
/** 与后端 MpLoginService.LoginVO 对齐(字段名不要改,后端直接按 JSON 返回)。 */
export interface MpLoginResult {
token: string
expireSeconds: number
needBindPhone: boolean
user: {
id: number
realName: string | null
userType: number
}
/** 扫的是任务码、且这个学生已经绑定名册时才有。 */
boundRoster?: {
taskId: number
taskName: string | null
rosterId: number
} | null
}
const TOKEN_KEY = 'token'
const NEED_BIND_PHONE_KEY = 'needBindPhone'
const USER_KEY = 'mpUser'
const BOUND_ROSTER_KEY = 'boundRoster'
/** 登录态(H5 与小程序共用同一个 storage key)。 */
export function getToken(): string {
try {
return uni.getStorageSync(TOKEN_KEY) || ''
} catch {
return ''
}
}
/** 清掉登录态(切换学生 / 排查登录问题时用)。 */
export function clearToken(): void {
try {
uni.removeStorageSync(TOKEN_KEY)
uni.removeStorageSync(NEED_BIND_PHONE_KEY)
uni.removeStorageSync(USER_KEY)
uni.removeStorageSync(BOUND_ROSTER_KEY)
} catch {
// 存储不可用时本来也没有登录态可清
}
}
/** 后端提示"手机号还没绑"。页面上没有绑定入口时先存下来,不影响登录。 */
export function getNeedBindPhone(): boolean {
try {
return uni.getStorageSync(NEED_BIND_PHONE_KEY) === true
} catch {
return false
}
}
function saveLoginResult(r: MpLoginResult): void {
try {
uni.setStorageSync(TOKEN_KEY, r.token)
uni.setStorageSync(NEED_BIND_PHONE_KEY, r.needBindPhone === true)
if (r.user) {
uni.setStorageSync(USER_KEY, r.user)
}
if (r.boundRoster) {
uni.setStorageSync(BOUND_ROSTER_KEY, r.boundRoster)
}
} catch {
// ⚠️ 存不进去也不该让登录失败:本次页面还能用返回的 token,
// 只是下次请求会重新走一次登录。
}
}
/**
* 小程序登录:`wx.login` 拿 code → `POST /api/v1/mp/login` 换 token。
*
* ⚠️ `scene` 不能丢:它决定"你属于哪个学校"(统一应用 + scene 识别租户)。
* 缺失时后端明确报错,不会去猜一个租户 —— 猜错就是 A 校学生登进 B 校。
*/
export function loginByWechat(scene: string): Promise<MpLoginResult> {
// #ifdef MP-WEIXIN
return new Promise<MpLoginResult>((resolve, reject) => {
if (!scene) {
reject(new Error('缺少 scene,无法确定学校;请重新扫描班主任发的二维码'))
return
}
uni.login({
provider: 'weixin',
success: (res) => {
if (!res.code) {
reject(new Error('微信登录失败:没有拿到 code'))
return
}
request<MpLoginResult>({
url: '/api/v1/mp/login',
method: 'POST',
data: { code: res.code, scene }
})
.then((data) => {
if (!data || !data.token) {
reject(new Error('登录失败:后端没有返回 token'))
return
}
saveLoginResult(data)
resolve(data)
})
.catch(reject)
},
fail: (err) => {
reject(new Error(`微信登录失败:${err.errMsg || '未知原因'}`))
}
})
})
// #endif
// #ifndef MP-WEIXIN
return Promise.reject(new Error('当前环境不是微信小程序,无法使用微信登录'))
// #endif
}
/** 已有 token 就直接用;没有就走一次微信登录。 */
export async function ensureLogin(scene: string): Promise<string> {
const existing = getToken()
if (existing) {
return existing
}
const result = await loginByWechat(scene)
return result.token
}