feat: 登录过期自动续登并校验同一账号

This commit is contained in:
2026-08-07 02:53:03 +08:00
parent 0f3132b08e
commit 2182b6dfc9
39 changed files with 704 additions and 74 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ export function assetUrl(path?: string | null): string {
/** 从旧存储中解析商品图标:兼容本地 /icon、远程 URL 和老版 emoji 缺省值 */
export function resolveStoredIcon(
value?: string,
fallback = '/icon/四角星.png',
fallback = '/icon/四角星.svg',
): string {
if (!value) return assetUrl(fallback)
if (value.startsWith('/icon/') || /^https?:/i.test(value)) {
+8 -8
View File
@@ -1,7 +1,7 @@
/**
* 产品品类配置文件
*
* ⚠️ 重要:当实际产品尺寸确定后,请修改本文件中的 mask 参数
* 注意:当实际产品尺寸确定后,请修改本文件中的 mask 参数
* 修改后页面会自动生效,无需改动其他代码
*/
@@ -13,7 +13,7 @@ const PRODUCTS_LOCAL: ProductCategory[] = [
id: 'notebook-small',
name: '微雕笔记本(小)',
desc: '便携随行,记录点滴',
icon: '📓',
icon: '/icon/书本.png',
iconImg: '/icon/书本.png',
price: 12,
originalPrice: 18,
@@ -45,7 +45,7 @@ const PRODUCTS_LOCAL: ProductCategory[] = [
id: 'notebook-large',
name: '微雕笔记本(大)',
desc: '大开本,更多创作空间',
icon: '📔',
icon: '/icon/书本_大.png',
iconImg: '/icon/书本_大.png',
price: 45,
originalPrice: 58,
@@ -77,7 +77,7 @@ const PRODUCTS_LOCAL: ProductCategory[] = [
id: 'coaster',
name: '铜质杯垫',
desc: '金属质感,桌面艺术',
icon: '',
icon: '/icon/杯子.png',
iconImg: '/icon/杯子.png',
price: 25,
originalPrice: 35,
@@ -108,7 +108,7 @@ const PRODUCTS_LOCAL: ProductCategory[] = [
id: 'penbox',
name: '竹制笔盒',
desc: '自然竹纹,文房雅器',
icon: '✏️',
icon: '/icon/笔盒.png',
iconImg: '/icon/笔盒.png',
price: 75,
originalPrice: 98,
@@ -140,7 +140,7 @@ const PRODUCTS_LOCAL: ProductCategory[] = [
id: 'booklamp',
name: '书本型灯',
desc: '温暖光影,点亮心意',
icon: '💡',
icon: '/icon/书灯.png',
iconImg: '/icon/书灯.png',
price: 45,
originalPrice: 68,
@@ -207,6 +207,6 @@ export const PRODUCT_ICON_MAP: Record<string, string> = {
*/
export function getProductIconImg(product: any): string {
if (product?.iconImg) return assetUrl(product.iconImg)
if (product?.id) return assetUrl(PRODUCT_ICON_MAP[product.id] || '/icon/四角星.png')
return assetUrl('/icon/四角星.png')
if (product?.id) return assetUrl(PRODUCT_ICON_MAP[product.id] || '/icon/四角星.svg')
return assetUrl('/icon/四角星.svg')
}
+56 -22
View File
@@ -51,6 +51,8 @@ export interface RequestOptions {
headers?: Record<string, string>
/** 是否自动携带 token,默认 true */
auth?: boolean
/** 内部参数:会话刷新内部的请求不再触发 401 自动刷新 */
skipAuthRefresh?: boolean
}
// ---------- request ----------
@@ -59,37 +61,75 @@ export interface RequestOptions {
* 发请求。成功(code===0)返回 data;业务失败抛 Error(message)HTTP 401 清 token 并抛错。
* 调用方可自行 catch 或用 onUnauthorized 钩子统一处理跳登录。
*/
export let onUnauthorized: (() => void) | null = null
export function setOnUnauthorized(handler: () => void): void {
onUnauthorized = handler
}
let sessionRefreshHandler: (() => Promise<boolean>) | null = null
export function setSessionRefreshHandler(handler: (() => Promise<boolean>) | null): void {
sessionRefreshHandler = handler
}
async function sendRequest(
path: string,
method: string,
data: unknown,
finalHeaders: Record<string, string>,
): Promise<Taro.request.SuccessCallbackResult> {
return Taro.request({
url: `${BASE_URL}${path}`,
method,
data,
header: finalHeaders,
})
}
export async function request<T = unknown>(
path: string,
options: RequestOptions = {},
): Promise<T> {
const { method = 'GET', data, headers = {}, auth = true } = options
const { method = 'GET', data, headers = {}, auth = true, skipAuthRefresh = false } = options
const token = getToken()
const finalHeaders: Record<string, string> = {
'Content-Type': 'application/json',
...(auth && token ? { Authorization: `Bearer ${token}` } : {}),
...headers,
const buildHeaders = (): Record<string, string> => {
const token = getToken()
return {
'Content-Type': 'application/json',
...(auth && token ? { Authorization: `Bearer ${token}` } : {}),
...headers,
}
}
let res: Taro.request.SuccessCallbackResult
try {
res = await Taro.request({
url: `${BASE_URL}${path}`,
method,
data,
header: finalHeaders,
})
res = await sendRequest(path, method, data, buildHeaders())
} catch (e) {
// 网络异常
throw new Error('网络异常,请稍后重试')
const err = new Error('网络异常,请稍后重试') as Error & { statusCode?: number }
err.statusCode = -1
throw err
}
// HTTP 未授权:清 token,交回调处理
// HTTP 未授权:先尝试自动续登一次,成功则用新 token 重试原请求
if (res.statusCode === 401 && !skipAuthRefresh && sessionRefreshHandler) {
const refreshed = await sessionRefreshHandler()
if (refreshed) {
try {
res = await sendRequest(path, method, data, buildHeaders())
} catch (e) {
const err = new Error('网络异常,请稍后重试') as Error & { statusCode?: number }
err.statusCode = -1
throw err
}
}
}
// 仍然未授权:清 token,交回调处理
if (res.statusCode === 401) {
clearToken()
onUnauthorized?.()
throw new Error('登录已过期,请重新登录')
const err = new Error('登录已过期,请重新登录') as Error & { statusCode?: number }
err.statusCode = 401
throw err
}
const body = res.data as ApiResponse<T>
@@ -102,12 +142,6 @@ export async function request<T = unknown>(
return (body && typeof body.code === 'number' ? body.data : body) as T
}
/** 401 钩子,由业务方注册(如跳转登录页) */
export let onUnauthorized: (() => void) | null = null
export function setOnUnauthorized(handler: () => void): void {
onUnauthorized = handler
}
// ---------- 便捷方法 ----------
export const http = {
+68
View File
@@ -0,0 +1,68 @@
import Taro from '@tarojs/taro'
import http, { getToken, setToken, clearToken } from './request'
import { getUserInfoRaw, clearUserInfo, isMockOpenid } from './store'
let refreshInflight: Promise<boolean> | null = null
/**
* 重新获取登录态:wx.login 换新 code → 后端续签 accessToken →
* getMe 校验 openid 与本地账号一致才放行,确保不会串号。
*/
export function refreshSession(): Promise<boolean> {
if (refreshInflight) return refreshInflight
refreshInflight = doRefreshSession().finally(() => {
refreshInflight = null
})
return refreshInflight
}
async function doRefreshSession(): Promise<boolean> {
const localUser = getUserInfoRaw()
if (!localUser?.openid || isMockOpenid(localUser.openid)) return false
try {
const wxLogin = await Taro.login()
if (!wxLogin.code) return false
const result = await http.post<{ accessToken?: string }>(
'/api/auth/login',
{ code: wxLogin.code },
{ auth: false, skipAuthRefresh: true },
)
if (!result?.accessToken) return false
setToken(result.accessToken)
const me = await http.get<{ openid?: string | null }>('/api/users/me', {
skipAuthRefresh: true,
})
if (me?.openid === localUser.openid) {
return true
}
// 新 token 对应了不同账号,不能静默切换,清掉旧会话
clearToken()
clearUserInfo()
return false
} catch {
return false
}
}
/**
* 小程序启动时主动校验一次:token 还在就先 getMe 验签,
* 只有 401 才触发自动续登;网络异常时保留本地会话,后续 401 再续。
*/
export async function ensureSessionFresh(): Promise<boolean> {
const localUser = getUserInfoRaw()
if (!localUser?.openid || isMockOpenid(localUser.openid)) return false
if (!getToken()) return refreshSession()
try {
await http.get('/api/users/me', { skipAuthRefresh: true })
return true
} catch (e) {
const statusCode = (e as Error & { statusCode?: number })?.statusCode
if (statusCode === 401) return refreshSession()
return !!getToken()
}
}
+2 -2
View File
@@ -176,7 +176,7 @@ export function setDesignList(list: DesignItem[]) {
export function addDesign(product: any, count: number): DesignItem {
const list = getDesignList()
const iconImg = assetUrl(product?.iconImg || PRODUCT_ICON_MAP[product?.id] || '/icon/四角星.png')
const iconImg = assetUrl(product?.iconImg || PRODUCT_ICON_MAP[product?.id] || '/icon/四角星.svg')
const item: DesignItem = {
id: 'DSG' + Date.now(),
productId: product.id,
@@ -229,7 +229,7 @@ export function designToOrder(designId: string): OrderItem | null {
const rawIcon = design.productIcon?.startsWith('/icon/') || /^https?:/i.test(design.productIcon || '')
? design.productIcon
: PRODUCT_ICON_MAP[design.productId] || '/icon/四角星.png'
: PRODUCT_ICON_MAP[design.productId] || '/icon/四角星.svg'
const iconImg = assetUrl(rawIcon)