This commit is contained in:
2026-09-13 02:28:46 +08:00
parent df5492a48a
commit 42f9530a59
27 changed files with 979 additions and 149 deletions
+7
View File
@@ -1,4 +1,8 @@
import { STORAGE_KEYS } from './config'
import { logger } from './logger'
// 日志模块名
const LOG = 'auth'
export interface TokenData {
accessToken: string
@@ -22,12 +26,15 @@ export function setTokens(data: TokenData): void {
wx.setStorageSync(STORAGE_KEYS.ACCESS_TOKEN, data.accessToken)
wx.setStorageSync(STORAGE_KEYS.REFRESH_TOKEN, data.refreshToken)
wx.setStorageSync(STORAGE_KEYS.EXPIRE_TIME, data.expireTime)
// 不打印 token 明文,只记录过期时间便于核对单位(秒/毫秒)
logger.debug(LOG, 'token 已写入, expireTime=', data.expireTime)
}
export function clearTokens(): void {
wx.removeStorageSync(STORAGE_KEYS.ACCESS_TOKEN)
wx.removeStorageSync(STORAGE_KEYS.REFRESH_TOKEN)
wx.removeStorageSync(STORAGE_KEYS.EXPIRE_TIME)
logger.info(LOG, 'token 已清理')
}
export function isLoggedIn(): boolean {
+21
View File
@@ -154,3 +154,24 @@ export const ERROR_MESSAGES: Record<number, string> = {
19003: '服务繁忙,请稍后重试',
19999: '服务繁忙,请稍后重试',
}
// ===== 日志配置 =====
// 注意:本文件保持零 import(纯常量),LogLevel 定义在此以避免 config→logger→config 循环依赖
// 日志级别:数值越大越严重,低于当前级别的日志不输出
export enum LogLevel {
DEBUG = 0,
INFO = 1,
WARN = 2,
ERROR = 3,
SILENT = 4, // 全部关闭
}
// 全局日志级别开关:
// null = 按运行环境自动判定(develop=DEBUG / trial=INFO / release=WARN)
// LogLevel.DEBUG = 手动写死,临时调级用(调试完记得改回 null)
export const LOG_LEVEL: LogLevel | null = null
// 是否写入微信实时日志(真机可在小程序后台「实时日志」查看线上问题)
// 仅 WARN 及以上写入,避免击穿实时日志配额
export const LOG_TO_REALTIME = true
+141
View File
@@ -0,0 +1,141 @@
import { LogLevel, LOG_LEVEL, LOG_TO_REALTIME } from './config'
/**
* 全项目统一日志出口。
*
* 用法:logger.debug('模块名', '消息', 任意参数)
* 级别:DEBUG < INFO < WARN < ERROR < SILENT,低于当前生效级别的调用直接返回,
* 且级别判断前置,不做任何字符串拼接,热路径(如每次请求)零开销。
*
* 输出:
* 1. console —— 按级别选用 log / warn / error,开发者工具与真机调试可见
* 2. 微信实时日志 —— 仅 WARN 及以上写入,可在小程序后台「实时日志」查询线上问题
*/
// 手动覆盖的级别(LOG_LEVEL 为 null 时按环境自动推导)
let manualLevel: LogLevel | null = LOG_LEVEL
// 解析后的生效级别缓存,避免每次调用都读环境信息
let resolvedLevel: LogLevel | null = null
const LEVEL_NAMES: Record<number, string> = {
[LogLevel.DEBUG]: 'DEBUG',
[LogLevel.INFO]: 'INFO',
[LogLevel.WARN]: 'WARN',
[LogLevel.ERROR]: 'ERROR',
}
// 按运行环境推导默认级别:开发版最详细,正式版只看异常
function resolveByEnv(): LogLevel {
try {
const info = wx.getAccountInfoSync()
const env = info.miniProgram && info.miniProgram.envVersion
if (env === 'develop') return LogLevel.DEBUG
if (env === 'trial') return LogLevel.INFO
return LogLevel.WARN
} catch (e) {
// 取不到环境信息时(低版本基础库等)退化为 DEBUG,保证开发期可见
return LogLevel.DEBUG
}
}
// 当前生效级别
export function getLevel(): LogLevel {
if (resolvedLevel === null) {
resolvedLevel = manualLevel === null ? resolveByEnv() : manualLevel
}
return resolvedLevel
}
/**
* 运行时调整日志级别(调级后对全局生效)。
* 传 null 恢复为「按环境自动」。
*/
export function setLevel(level: LogLevel | null): void {
manualLevel = level
resolvedLevel = null
}
// 实时日志单例(惰性初始化,取不到时降级为仅 console,绝不因日志抛错影响业务)
let realtimeManager: any = null
let realtimeInited = false
function getRealtimeManager(): any {
if (realtimeInited) return realtimeManager
realtimeInited = true
try {
if (typeof wx.getRealtimeLogManager === 'function') {
realtimeManager = wx.getRealtimeLogManager()
}
} catch (e) {
realtimeManager = null
}
return realtimeManager
}
// 序列化日志参数:实时日志有单条长度限制,做截断保护
function stringify(args: any[]): string {
try {
return args
.map((a) => {
if (typeof a === 'string') return a
try {
return JSON.stringify(a)
} catch (e) {
return String(a)
}
})
.join(' ')
.slice(0, 1000)
} catch (e) {
return ''
}
}
function emit(level: LogLevel, module: string, args: any[]): void {
if (level < getLevel()) return
const prefix = `[${LEVEL_NAMES[level]}][${module}]`
// console 通道
if (level === LogLevel.ERROR) {
console.error(prefix, ...args)
} else if (level === LogLevel.WARN) {
console.warn(prefix, ...args)
} else {
console.log(prefix, ...args)
}
// 实时日志通道:仅 WARN 及以上,避免击穿配额
if (!LOG_TO_REALTIME || level < LogLevel.WARN) return
const manager = getRealtimeManager()
if (!manager) return
const text = `${prefix} ${stringify(args)}`
try {
if (level === LogLevel.ERROR && typeof manager.error === 'function') manager.error(text)
else if (typeof manager.warn === 'function') manager.warn(text)
} catch (e) {
// 日志写入失败不影响业务
}
}
export const logger = {
/** 开发期流程细节:页面入参、请求发起/成功、数据条数 */
debug(module: string, ...args: any[]): void {
emit(LogLevel.DEBUG, module, args)
},
/** 关键流程节点:登录成功、刷新成功、下单/上传/分享成功 */
info(module: string, ...args: any[]): void {
emit(LogLevel.INFO, module, args)
},
/** 可恢复异常与静默降级:静默 catch、跳转失败、非关键请求失败 */
warn(module: string, ...args: any[]): void {
emit(LogLevel.WARN, module, args)
},
/** 真正的错误:网络异常、业务错误码、账号禁用、登录态失效 */
error(module: string, ...args: any[]): void {
emit(LogLevel.ERROR, module, args)
},
}
export default logger
+29 -10
View File
@@ -1,5 +1,9 @@
import { BASE_URL, API_PREFIX, ERROR_MESSAGES } from './config'
import * as auth from './auth'
import { logger } from './logger'
// 日志模块名
const LOG = 'request'
// 业务错误对象(携带 code,页面可按 code 定制处理)
export class ApiError extends Error {
@@ -44,12 +48,12 @@ function isAuthExpiredCode(code: number, statusCode?: number): boolean {
// 登录失效后的收尾:非关键请求只 reject,不动登录态
function failAuth(code: number, msg: string, silentAuth: boolean, showError: boolean) {
if (!silentAuth) {
console.log('[auth] failAuth 清理登录态并跳转登录页, code=', code, 'msg=', msg)
logger.warn(LOG, '登录态失效,清理 token 并跳转登录页, code=', code, 'msg=', msg)
auth.clearTokens()
handleAuthExpired()
if (showError) showToast('登录已过期,请重新登录')
} else {
console.log('[auth] failAuth 静默失败(不动登录态), code=', code)
logger.warn(LOG, '登录态失效但静默处理(不动登录态), code=', code)
}
return new ApiError(code, msg || '登录已过期')
}
@@ -69,11 +73,11 @@ function doRefresh(): Promise<boolean> {
const refreshToken = auth.getRefreshToken()
const accessToken = auth.getAccessToken()
if (!refreshToken || !accessToken) {
console.log('[auth] doRefresh 无 token,放弃刷新')
logger.warn(LOG, 'doRefresh 缺少 refresh/access token,放弃刷新')
resolve(false)
return
}
console.log('[auth] doRefresh 发起刷新')
logger.debug(LOG, 'doRefresh 发起刷新')
wx.request({
url: `${BASE_URL}${API_PREFIX}/user/refresh-token`,
method: 'POST',
@@ -88,15 +92,15 @@ function doRefresh(): Promise<boolean> {
refreshToken: body.data.refresh_token,
expireTime: body.data.expire_time,
})
console.log('[auth] doRefresh 成功')
logger.debug(LOG, 'doRefresh 成功')
resolve(true)
} else {
console.log('[auth] doRefresh 响应异常, code=', body && body.code)
logger.error(LOG, 'doRefresh 响应异常, code=', body && body.code)
resolve(false)
}
},
fail: () => {
console.log('[auth] doRefresh 网络失败')
logger.error(LOG, 'doRefresh 网络失败')
resolve(false)
},
})
@@ -127,17 +131,17 @@ let redirecting = false
*/
export function redirectToLogin(): void {
if (redirecting) {
console.log('[auth] redirectToLogin 幂等锁生效,丢弃本次跳转')
logger.debug(LOG, 'redirectToLogin 幂等锁生效,丢弃本次跳转')
return
}
const pages = typeof getCurrentPages === 'function' ? getCurrentPages() : []
const current = pages[pages.length - 1]
if (current && current.route === 'pages/login/login') {
console.log('[auth] redirectToLogin 已在登录页,跳过')
logger.debug(LOG, 'redirectToLogin 已在登录页,跳过')
return
}
console.log('[auth] redirectToLogin 跳转登录页, 当前页=', current && current.route, '栈深=', pages.length)
logger.info(LOG, '跳转登录页, 当前页=', current && current.route, '栈深=', pages.length)
redirecting = true
wx.reLaunch({
url: '/pages/login/login',
@@ -171,6 +175,7 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
return new Promise<T>((resolve, reject) => {
// 未登录:直接失败,不发请求,避免无 token 时批量 401 引发重定向风暴
if (needAuth && !auth.isLoggedIn()) {
logger.debug(LOG, '未登录直接 reject,不发请求, url=', url)
reject(new ApiError(NOT_LOGIN_CODE, '未登录'))
return
}
@@ -185,6 +190,7 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
if (token) finalHeader['Authorization'] = `Bearer ${token}`
}
logger.debug(LOG, '请求发起', method, fullUrl, 'needAuth=', needAuth, 'retried=', retried)
wx.request({
url: fullUrl,
method,
@@ -196,6 +202,7 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
// 网络层异常:响应体无 code
if (!body || typeof body.code !== 'number') {
logger.error(LOG, '响应体无 code(网络层异常)', method, fullUrl, 'statusCode=', res.statusCode)
if (showError) showToast('网络异常,请稍后重试')
reject(new Error('network error'))
return
@@ -205,6 +212,7 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
// 成功
if (code === 0) {
logger.debug(LOG, '请求成功', method, fullUrl)
resolve(body.data as T)
return
}
@@ -215,6 +223,7 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
reject(failAuth(code, body.msg, silentAuth, showError))
return
}
logger.info(LOG, '命中登录失效码,尝试刷新后重试, code=', code, 'url=', fullUrl)
doRefresh().then((ok) => {
if (ok) {
doCall(true)
@@ -229,6 +238,7 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
// 账号禁用(HTTP 200,需单独处理)
if (code === 21001) {
logger.error(LOG, '账号被禁用,清理登录态, code=', code)
auth.clearTokens()
handleAuthExpired()
if (showError) showToast(ERROR_MESSAGES[21001] || '账号已被禁用')
@@ -237,6 +247,7 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
}
// 其余业务错误
logger.warn(LOG, '业务错误, code=', code, 'url=', fullUrl, 'msg=', body.msg)
if (showError) {
const msg =
(body.details && body.details[0]) || ERROR_MESSAGES[code] || body.msg || '请求失败'
@@ -245,6 +256,7 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
reject(new ApiError(code, body.msg || '请求失败', body.details))
},
fail: () => {
logger.error(LOG, 'wx.request 失败(网络层)', method, fullUrl)
if (showError) showToast('网络异常,请检查网络后重试')
reject(new Error('request fail'))
},
@@ -276,6 +288,7 @@ export function uploadFile<T = any>(
return new Promise<T>((resolve, reject) => {
// 未登录:直接失败,不发请求
if (!auth.isLoggedIn()) {
logger.debug(LOG, '未登录直接 reject,不上传, url=', url)
reject(new ApiError(NOT_LOGIN_CODE, '未登录'))
return
}
@@ -285,6 +298,7 @@ export function uploadFile<T = any>(
const token = auth.getAccessToken()
if (token) header['Authorization'] = `Bearer ${token}`
logger.debug(LOG, '上传发起, url=', url, 'retried=', retried)
wx.uploadFile({
url: url.startsWith('http') ? url : `${BASE_URL}${API_PREFIX}${url}`,
filePath,
@@ -300,12 +314,14 @@ export function uploadFile<T = any>(
}
if (!body || typeof body.code !== 'number') {
logger.error(LOG, '上传响应解析失败(非 JSON)', url, 'raw=', res.data)
if (showError) showToast('上传失败,请稍后重试')
reject(new Error('upload fail'))
return
}
if (body.code === 0) {
logger.info(LOG, '上传成功, url=', url)
resolve(body.data as T)
return
}
@@ -316,6 +332,7 @@ export function uploadFile<T = any>(
reject(failAuth(body.code, body.msg, silentAuth, showError))
return
}
logger.info(LOG, '上传命中登录失效码,尝试刷新后重试, code=', body.code)
doRefresh().then((ok) => {
if (ok) {
doUpload(true)
@@ -326,12 +343,14 @@ export function uploadFile<T = any>(
return
}
logger.warn(LOG, '上传业务错误, code=', body.code, 'url=', url, 'msg=', body.msg)
const msg =
(body.details && body.details[0]) || ERROR_MESSAGES[body.code] || body.msg || '上传失败'
if (showError) showToast(msg)
reject(new ApiError(body.code, body.msg || '上传失败', body.details))
},
fail: () => {
logger.error(LOG, 'wx.uploadFile 失败(网络层), url=', url)
if (showError) showToast('上传失败,请检查网络后重试')
reject(new Error('upload fail'))
},
+9
View File
@@ -16,3 +16,12 @@ export function syncTabBarUnread(force = true): void {
const tabBar = getTabBarInstance()
if (tabBar && typeof tabBar.syncUnread === 'function') tabBar.syncUnread(force)
}
// 更新选中态:走 tabBar 的 setSelected(内部保证首帧安全),
// 老版本实例没有该方法时降级为 setData
export function setTabBarSelected(index: number): void {
const tabBar = getTabBarInstance()
if (!tabBar) return
if (typeof tabBar.setSelected === 'function') tabBar.setSelected(index)
else tabBar.setData({ selected: index })
}