feat: 支持自动续期

This commit is contained in:
richarjiang
2026-09-23 15:02:18 +08:00
parent 524bb9dcf3
commit a76afb19de
8 changed files with 92 additions and 15 deletions

View File

@@ -13,7 +13,7 @@ import {
getMembershipRenewalHint, getMembershipRenewalHint,
} from '@mp-pilates/shared' } from '@mp-pilates/shared'
import { wxLogin, isLoggedIn, logout as authLogout } from '../utils/auth' 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 { get, put } from '../utils/request'
import { ROUTES } from '../utils/routes' import { ROUTES } from '../utils/routes'
import { cacheSubscriptionMessageTemplateConfig, resetSubscriptionMessageTemplateCache } from '../utils/wechat-subscription' import { cacheSubscriptionMessageTemplateConfig, resetSubscriptionMessageTemplateCache } from '../utils/wechat-subscription'
@@ -34,6 +34,7 @@ export const useUserStore = defineStore('user', () => {
const statsError = ref(false) const statsError = ref(false)
let membershipRequestId = 0 let membershipRequestId = 0
let statsRequestId = 0 let statsRequestId = 0
let sessionId = 0
const token = ref<string>(uni.getStorageSync('token') as string || '') const token = ref<string>(uni.getStorageSync('token') as string || '')
// Getters // Getters
@@ -53,6 +54,7 @@ export const useUserStore = defineStore('user', () => {
async function login() { async function login() {
try { try {
const result = await wxLogin() const result = await wxLogin()
sessionId++
token.value = result.token token.value = result.token
user.value = result.user user.value = result.user
await useInviteStore().refresh().catch(() => {}) await useInviteStore().refresh().catch(() => {})
@@ -93,39 +95,39 @@ export const useUserStore = defineStore('user', () => {
async function fetchStats(): Promise<boolean> { async function fetchStats(): Promise<boolean> {
if (!isLoggedIn()) return false if (!isLoggedIn()) return false
const id = ++statsRequestId const id = ++statsRequestId
const session = token.value const session = sessionId
statsLoading.value = true statsLoading.value = true
statsError.value = false statsError.value = false
try { try {
const result = await get<UserStatsResponse>('/user/stats') const result = await get<UserStatsResponse>('/user/stats')
if (id !== statsRequestId || session !== token.value) return false if (id !== statsRequestId || session !== sessionId) return false
stats.value = result stats.value = result
return true return true
} catch { } catch {
if (id === statsRequestId && session === token.value) statsError.value = true if (id === statsRequestId && session === sessionId) statsError.value = true
return false return false
} finally { } finally {
if (id === statsRequestId && session === token.value) statsLoading.value = false if (id === statsRequestId && session === sessionId) statsLoading.value = false
} }
} }
async function fetchMemberships(): Promise<boolean> { async function fetchMemberships(): Promise<boolean> {
if (!isLoggedIn()) return false if (!isLoggedIn()) return false
const id = ++membershipRequestId const id = ++membershipRequestId
const session = token.value const session = sessionId
membershipsLoading.value = true membershipsLoading.value = true
membershipsError.value = false membershipsError.value = false
try { try {
const result = await get<MembershipWithCardType[]>('/membership/my') const result = await get<MembershipWithCardType[]>('/membership/my')
if (id !== membershipRequestId || session !== token.value) return false if (id !== membershipRequestId || session !== sessionId) return false
memberships.value = [...result] memberships.value = [...result]
membershipsLoaded.value = true membershipsLoaded.value = true
return true return true
} catch { } catch {
if (id === membershipRequestId && session === token.value) membershipsError.value = true if (id === membershipRequestId && session === sessionId) membershipsError.value = true
return false return false
} finally { } 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() { function clearSession() {
sessionId++
membershipRequestId++ membershipRequestId++
statsRequestId++ statsRequestId++
membershipsLoading.value = false membershipsLoading.value = false
@@ -171,6 +174,7 @@ export const useUserStore = defineStore('user', () => {
} }
setUnauthorizedHandler(clearSession) setUnauthorizedHandler(clearSession)
setRenewedHandler((renewedToken) => { token.value = renewedToken })
return { return {
membershipsLoading, membershipsLoading,

View File

@@ -77,6 +77,7 @@ export async function wxLogin(): Promise<LoginResponse> {
inviterId: inviterId || undefined, inviterId: inviterId || undefined,
}) })
uni.setStorageSync('token', result.token) uni.setStorageSync('token', result.token)
uni.setStorageSync('token_renewed_at', Date.now())
if (result.isNewUser && inviterId) { if (result.isNewUser && inviterId) {
uni.removeStorageSync('invite_inviter_id') uni.removeStorageSync('invite_inviter_id')
} }
@@ -105,4 +106,5 @@ export function isLoggedIn(): boolean {
export function logout(): void { export function logout(): void {
uni.removeStorageSync('token') uni.removeStorageSync('token')
uni.removeStorageSync('token_renewed_at')
} }

View File

@@ -1,8 +1,10 @@
import type { ApiResponse, PaginatedData } from '@mp-pilates/shared' 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 BASE_URL = 'https://focus.richarjiang.com/api'
const RENEW_INTERVAL_MS = 24 * 60 * 60 * 1000
let renewal: Promise<void> | null = null
interface RequestOptions { interface RequestOptions {
readonly url: string readonly url: string
@@ -18,10 +20,8 @@ export class HttpRequestError extends Error {
} }
} }
export function request<T>(options: RequestOptions): Promise<T> { function send<T>(options: RequestOptions, token: string): Promise<T> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const token = uni.getStorageSync('token') as string
uni.request({ uni.request({
url: `${BASE_URL}${options.url}`, url: `${BASE_URL}${options.url}`,
method: options.method || 'GET', method: options.method || 'GET',
@@ -33,7 +33,7 @@ export function request<T>(options: RequestOptions): Promise<T> {
}, },
success: (res) => { success: (res) => {
if (res.statusCode === 401) { if (res.statusCode === 401) {
if (!options.url.startsWith('/auth/login')) { if (token && uni.getStorageSync('token') === token && options.url !== '/auth/login') {
notifyUnauthorized() notifyUnauthorized()
} }
reject(new Error('Unauthorized')) reject(new Error('Unauthorized'))
@@ -58,6 +58,35 @@ export function request<T>(options: RequestOptions): Promise<T> {
}) })
} }
async function renewIfDue(token: string): Promise<void> {
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<T>(options: RequestOptions): Promise<T> {
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<T>(options, currentToken)
}
export function get<T>(url: string, data?: Record<string, unknown>): Promise<T> { export function get<T>(url: string, data?: Record<string, unknown>): Promise<T> {
return request<T>({ url, method: 'GET', data }) return request<T>({ url, method: 'GET', data })
} }

View File

@@ -1,12 +1,23 @@
const UNAUTHORIZED_TOAST_GAP_MS = 2000 const UNAUTHORIZED_TOAST_GAP_MS = 2000
let unauthorizedHandler: (() => void) | null = null let unauthorizedHandler: (() => void) | null = null
let renewedHandler: ((token: string) => void) | null = null
let lastUnauthorizedAt = 0 let lastUnauthorizedAt = 0
export function setUnauthorizedHandler(handler: () => void): void { export function setUnauthorizedHandler(handler: () => void): void {
unauthorizedHandler = handler 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. */ /** Clear persisted token and in-memory session after the server rejects auth. */
export function notifyUnauthorized(): void { export function notifyUnauthorized(): void {
const now = Date.now() const now = Date.now()
@@ -14,6 +25,7 @@ export function notifyUnauthorized(): void {
lastUnauthorizedAt = now lastUnauthorizedAt = now
uni.removeStorageSync('token') uni.removeStorageSync('token')
uni.removeStorageSync('token_renewed_at')
unauthorizedHandler?.() unauthorizedHandler?.()
if (shouldToast) { if (shouldToast) {

View File

@@ -240,6 +240,23 @@ describe('AuthService', () => {
// ── bindPhone ────────────────────────────────────────────────────────────── // ── 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', () => { describe('bindPhone', () => {
const encryptedData = 'encrypted_phone_data' const encryptedData = 'encrypted_phone_data'
const iv = 'init_vector' const iv = 'init_vector'

View File

@@ -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') @Post('phone')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)

View File

@@ -24,7 +24,7 @@ import { BodyPortraitModule } from '../body-portrait/body-portrait.module'
inject: [ConfigService], inject: [ConfigService],
useFactory: (configService: ConfigService) => ({ useFactory: (configService: ConfigService) => ({
secret: configService.getOrThrow<string>('JWT_SECRET'), secret: configService.getOrThrow<string>('JWT_SECRET'),
signOptions: { expiresIn: '7d' }, signOptions: { expiresIn: '30d' },
}), }),
}), }),
], ],

View File

@@ -190,4 +190,10 @@ export class AuthService {
await this.portraitLifecycle?.onPhoneBound(userId) await this.portraitLifecycle?.onPhoneBound(userId)
return updated 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 }) }
}
} }