duoqi-api/src/services/auth/jwt.ts
Wang Zhuoxuan 1116b9a2ec
All checks were successful
CI/CD Pipeline / Code Quality (push) Successful in 27s
CI/CD Pipeline / Unit Tests (push) Successful in 17s
CI/CD Pipeline / Build & Deploy Test (push) Has been skipped
CI/CD Pipeline / Build & Deploy Production (push) Successful in 1m29s
feat: 实现 POST /v1/auth/link 游客账号关联与数据合并
新增游客到正式账号的关联接口,支持 Apple Sign In,
采用 server_account_first 合并策略:
- 场景 A(新用户):游客行原地升级为 Apple 账号
- 场景 B(老用户):事务内合并答题记录、奖励流水等,
  不覆盖老账号的订阅、余额、库存、连续学习

包含幂等迁移追踪(accountMigrations 表)、
Apple identity token 验证(jose + JWKS)、
防竞态的原子迁移槽位抢占,
以及 12 个单元测试覆盖两种场景和各类边界。
2026-05-23 13:50:16 +08:00

103 lines
3.0 KiB
TypeScript

import type { FastifyInstance } from 'fastify';
import { v4 as uuid } from 'uuid';
import { db } from '../../db/client.js';
import { users } from '../../db/schema.js';
import { eq, and } from 'drizzle-orm';
import type { JwtPayload, LoginResponse, AuthType } from '../../types/auth.js';
export function signTokens(app: FastifyInstance, userId: string, authType: AuthType, tier: string) {
const payload: JwtPayload = { userId, authType, tier };
const accessToken = app.jwt.sign(payload, { expiresIn: '1h' });
const refreshToken = app.jwt.sign(payload, { expiresIn: '30d' });
return { accessToken, refreshToken };
}
export function buildLoginResponse(
user: typeof users.$inferSelect,
accessToken: string,
refreshToken: string,
): LoginResponse {
return {
accessToken,
refreshToken,
user: {
id: user.id,
nickname: user.nickname ?? null,
avatarUrl: user.avatarUrl ?? null,
tier: user.tier ?? 'free',
xpTotal: user.xpTotal ?? 0,
streakDays: user.streakDays ?? 0,
},
};
}
export async function findOrCreateGuest(deviceId: string, app: FastifyInstance): Promise<LoginResponse> {
const [existing] = await db
.select()
.from(users)
.where(and(eq(users.authType, 'guest'), eq(users.authId, deviceId)))
.limit(1);
let user = existing;
if (!user) {
const newId = uuid();
await db.insert(users).values({
id: newId,
authType: 'guest',
authId: deviceId,
});
const [created] = await db.select().from(users).where(eq(users.id, newId)).limit(1);
user = created;
}
if (!user) throw new Error('Failed to create user');
const tokens = signTokens(app, user.id, 'guest', user.tier ?? 'free');
return buildLoginResponse(user, tokens.accessToken, tokens.refreshToken);
}
export async function findOrCreateHuawei(
openId: string,
nickname: string | null,
avatarUrl: string | null,
app: FastifyInstance,
): Promise<LoginResponse> {
const [existing] = await db
.select()
.from(users)
.where(and(eq(users.authType, 'huawei'), eq(users.authId, openId)))
.limit(1);
let user = existing;
if (!user) {
const newId = uuid();
await db.insert(users).values({
id: newId,
authType: 'huawei',
authId: openId,
nickname,
avatarUrl,
});
const [created] = await db.select().from(users).where(eq(users.id, newId)).limit(1);
user = created;
}
if (!user) throw new Error('Failed to create user');
const tokens = signTokens(app, user.id, 'huawei', user.tier ?? 'free');
return buildLoginResponse(user, tokens.accessToken, tokens.refreshToken);
}
export async function refreshJwt(app: FastifyInstance, refreshToken: string): Promise<{ accessToken: string }> {
const decoded = app.jwt.verify<JwtPayload>(refreshToken);
const payload: JwtPayload = {
userId: decoded.userId,
authType: decoded.authType,
tier: decoded.tier,
};
const accessToken = app.jwt.sign(payload, { expiresIn: '1h' });
return { accessToken };
}