feat: 支持自动续期
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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' },
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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 }) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user