feat: 登录过期自动续登并校验同一账号
This commit is contained in:
+1
-1
@@ -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)) {
|
||||
|
||||
@@ -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
@@ -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 = {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user