diff --git a/packages/app/src/stores/user.ts b/packages/app/src/stores/user.ts index c523e04..084088f 100644 --- a/packages/app/src/stores/user.ts +++ b/packages/app/src/stores/user.ts @@ -13,7 +13,7 @@ import { getMembershipRenewalHint, } from '@mp-pilates/shared' import { wxLogin, isLoggedIn, logout as authLogout } from '../utils/auth' -import { setUnauthorizedHandler } from '../utils/session' +import { setUnauthorizedHandler, setRenewedHandler } from '../utils/session' import { get, put } from '../utils/request' import { ROUTES } from '../utils/routes' import { cacheSubscriptionMessageTemplateConfig, resetSubscriptionMessageTemplateCache } from '../utils/wechat-subscription' @@ -34,6 +34,7 @@ export const useUserStore = defineStore('user', () => { const statsError = ref(false) let membershipRequestId = 0 let statsRequestId = 0 + let sessionId = 0 const token = ref(uni.getStorageSync('token') as string || '') // Getters @@ -53,6 +54,7 @@ export const useUserStore = defineStore('user', () => { async function login() { try { const result = await wxLogin() + sessionId++ token.value = result.token user.value = result.user await useInviteStore().refresh().catch(() => {}) @@ -93,39 +95,39 @@ export const useUserStore = defineStore('user', () => { async function fetchStats(): Promise { if (!isLoggedIn()) return false const id = ++statsRequestId - const session = token.value + const session = sessionId statsLoading.value = true statsError.value = false try { const result = await get('/user/stats') - if (id !== statsRequestId || session !== token.value) return false + if (id !== statsRequestId || session !== sessionId) return false stats.value = result return true } catch { - if (id === statsRequestId && session === token.value) statsError.value = true + if (id === statsRequestId && session === sessionId) statsError.value = true return false } finally { - if (id === statsRequestId && session === token.value) statsLoading.value = false + if (id === statsRequestId && session === sessionId) statsLoading.value = false } } async function fetchMemberships(): Promise { if (!isLoggedIn()) return false const id = ++membershipRequestId - const session = token.value + const session = sessionId membershipsLoading.value = true membershipsError.value = false try { const result = await get('/membership/my') - if (id !== membershipRequestId || session !== token.value) return false + if (id !== membershipRequestId || session !== sessionId) return false memberships.value = [...result] membershipsLoaded.value = true return true } catch { - if (id === membershipRequestId && session === token.value) membershipsError.value = true + if (id === membershipRequestId && session === sessionId) membershipsError.value = true return false } finally { - if (id === membershipRequestId && session === token.value) membershipsLoading.value = false + if (id === membershipRequestId && session === sessionId) membershipsLoading.value = false } } @@ -149,6 +151,7 @@ export const useUserStore = defineStore('user', () => { } function clearSession() { + sessionId++ membershipRequestId++ statsRequestId++ membershipsLoading.value = false @@ -171,6 +174,7 @@ export const useUserStore = defineStore('user', () => { } setUnauthorizedHandler(clearSession) + setRenewedHandler((renewedToken) => { token.value = renewedToken }) return { membershipsLoading, diff --git a/packages/app/src/utils/auth.ts b/packages/app/src/utils/auth.ts index 3e88803..04f6717 100644 --- a/packages/app/src/utils/auth.ts +++ b/packages/app/src/utils/auth.ts @@ -77,6 +77,7 @@ export async function wxLogin(): Promise { inviterId: inviterId || undefined, }) uni.setStorageSync('token', result.token) + uni.setStorageSync('token_renewed_at', Date.now()) if (result.isNewUser && inviterId) { uni.removeStorageSync('invite_inviter_id') } @@ -105,4 +106,5 @@ export function isLoggedIn(): boolean { export function logout(): void { uni.removeStorageSync('token') + uni.removeStorageSync('token_renewed_at') } diff --git a/packages/app/src/utils/request.ts b/packages/app/src/utils/request.ts index c07b1e4..8e78962 100644 --- a/packages/app/src/utils/request.ts +++ b/packages/app/src/utils/request.ts @@ -1,8 +1,10 @@ import type { ApiResponse, PaginatedData } from '@mp-pilates/shared' -import { notifyUnauthorized } from './session' +import { notifyRenewed, notifyUnauthorized } from './session' // 统一使用线上服务地址 const BASE_URL = 'https://focus.richarjiang.com/api' +const RENEW_INTERVAL_MS = 24 * 60 * 60 * 1000 +let renewal: Promise | null = null interface RequestOptions { readonly url: string @@ -18,10 +20,8 @@ export class HttpRequestError extends Error { } } -export function request(options: RequestOptions): Promise { +function send(options: RequestOptions, token: string): Promise { return new Promise((resolve, reject) => { - const token = uni.getStorageSync('token') as string - uni.request({ url: `${BASE_URL}${options.url}`, method: options.method || 'GET', @@ -33,7 +33,7 @@ export function request(options: RequestOptions): Promise { }, success: (res) => { if (res.statusCode === 401) { - if (!options.url.startsWith('/auth/login')) { + if (token && uni.getStorageSync('token') === token && options.url !== '/auth/login') { notifyUnauthorized() } reject(new Error('Unauthorized')) @@ -58,6 +58,35 @@ export function request(options: RequestOptions): Promise { }) } +async function renewIfDue(token: string): Promise { + const lastRenewed = Number(uni.getStorageSync('token_renewed_at')) || 0 + if (Date.now() - lastRenewed < RENEW_INTERVAL_MS) return + + if (!renewal) { + renewal = send<{ token: string }>({ url: '/auth/renew', method: 'POST' }, token) + .then((result) => { + if (uni.getStorageSync('token') === token) notifyRenewed(result.token) + }) + .finally(() => { renewal = null }) + } + await renewal +} + +export async function request(options: RequestOptions): Promise { + const token = uni.getStorageSync('token') as string || '' + if (token && options.url !== '/auth/renew' && options.url !== '/auth/login') { + try { + await renewIfDue(token) + } catch (err) { + // A network failure should not discard a still-valid session. + if (!uni.getStorageSync('token')) throw err + } + } + const currentToken = uni.getStorageSync('token') as string || '' + if (token && !currentToken && options.url !== '/auth/login') throw new Error('Unauthorized') + return send(options, currentToken) +} + export function get(url: string, data?: Record): Promise { return request({ url, method: 'GET', data }) } diff --git a/packages/app/src/utils/session.ts b/packages/app/src/utils/session.ts index 42907d0..cffeeab 100644 --- a/packages/app/src/utils/session.ts +++ b/packages/app/src/utils/session.ts @@ -1,12 +1,23 @@ const UNAUTHORIZED_TOAST_GAP_MS = 2000 let unauthorizedHandler: (() => void) | null = null +let renewedHandler: ((token: string) => void) | null = null let lastUnauthorizedAt = 0 export function setUnauthorizedHandler(handler: () => void): void { unauthorizedHandler = handler } +export function setRenewedHandler(handler: (token: string) => void): void { + renewedHandler = handler +} + +export function notifyRenewed(token: string): void { + uni.setStorageSync('token', token) + uni.setStorageSync('token_renewed_at', Date.now()) + renewedHandler?.(token) +} + /** Clear persisted token and in-memory session after the server rejects auth. */ export function notifyUnauthorized(): void { const now = Date.now() @@ -14,6 +25,7 @@ export function notifyUnauthorized(): void { lastUnauthorizedAt = now uni.removeStorageSync('token') + uni.removeStorageSync('token_renewed_at') unauthorizedHandler?.() if (shouldToast) { diff --git a/packages/server/src/auth/__tests__/auth.service.spec.ts b/packages/server/src/auth/__tests__/auth.service.spec.ts index d27ffcc..ede6647 100644 --- a/packages/server/src/auth/__tests__/auth.service.spec.ts +++ b/packages/server/src/auth/__tests__/auth.service.spec.ts @@ -240,6 +240,23 @@ describe('AuthService', () => { // ── bindPhone ────────────────────────────────────────────────────────────── + describe('renew', () => { + it('issues a fresh token using the current database role', async () => { + mockPrismaService.user.findUnique.mockResolvedValue({ ...mockUser, role: UserRole.ADMIN }) + + await expect(authService.renew(USER_ID)).resolves.toEqual({ token: JWT_TOKEN }) + expect(mockJwtService.sign).toHaveBeenCalledWith({ sub: USER_ID, role: UserRole.ADMIN }) + }) + + it('rejects deleted users', async () => { + mockPrismaService.user.findUnique.mockResolvedValue(null) + + await expect(authService.renew(USER_ID)).rejects.toThrow(UnauthorizedException) + expect(mockJwtService.sign).not.toHaveBeenCalled() + }) + }) + + describe('bindPhone', () => { const encryptedData = 'encrypted_phone_data' const iv = 'init_vector' diff --git a/packages/server/src/auth/auth.controller.ts b/packages/server/src/auth/auth.controller.ts index 8242393..8098a5b 100644 --- a/packages/server/src/auth/auth.controller.ts +++ b/packages/server/src/auth/auth.controller.ts @@ -34,6 +34,13 @@ export class AuthController { ) } + @Post('renew') + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + renew(@Request() req: AuthenticatedRequest): Promise<{ token: string }> { + return this.authService.renew(req.user.sub) + } + @Post('phone') @UseGuards(JwtAuthGuard) @HttpCode(HttpStatus.OK) diff --git a/packages/server/src/auth/auth.module.ts b/packages/server/src/auth/auth.module.ts index 48afb68..f4853de 100644 --- a/packages/server/src/auth/auth.module.ts +++ b/packages/server/src/auth/auth.module.ts @@ -24,7 +24,7 @@ import { BodyPortraitModule } from '../body-portrait/body-portrait.module' inject: [ConfigService], useFactory: (configService: ConfigService) => ({ secret: configService.getOrThrow('JWT_SECRET'), - signOptions: { expiresIn: '7d' }, + signOptions: { expiresIn: '30d' }, }), }), ], diff --git a/packages/server/src/auth/auth.service.ts b/packages/server/src/auth/auth.service.ts index 54d56aa..ae9f29a 100644 --- a/packages/server/src/auth/auth.service.ts +++ b/packages/server/src/auth/auth.service.ts @@ -190,4 +190,10 @@ export class AuthService { await this.portraitLifecycle?.onPhoneBound(userId) return updated } + + async renew(userId: string): Promise<{ token: string }> { + const user = await this.prisma.user.findUnique({ where: { id: userId } }) + if (!user) throw new UnauthorizedException('User no longer exists') + return { token: this.jwtService.sign({ sub: user.id, role: user.role as UserRole }) } + } }