duoqi-api/src/routes/auth.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

135 lines
4.6 KiB
TypeScript

import { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { db } from '../db/client.js';
import { users } from '../db/schema.js';
import { eq } from 'drizzle-orm';
import { findOrCreateGuest, findOrCreateHuawei, refreshJwt } from '../services/auth/jwt.js';
import { verifyHuaweiToken } from '../services/auth/huawei-id-kit.js';
import { linkGuestAccount } from '../services/auth/account-link-service.js';
import { NotFoundError } from '../utils/errors.js';
const guestLoginSchema = z.object({
deviceId: z.string().min(1),
});
const huaweiLoginSchema = z.object({
authorizationCode: z.string().min(1),
});
const refreshTokenSchema = z.object({
refreshToken: z.string().min(1),
});
const linkSchema = z.object({
provider: z.enum(['apple']),
credential: z.object({
authorizationCode: z.string().optional(),
identityToken: z.string().min(1),
}),
mergePolicy: z.literal('server_account_first'),
clientMigrationId: z.string().min(1),
});
export async function authRoutes(app: FastifyInstance): Promise<void> {
// Auth endpoints: stricter rate limit (10 requests/minute)
app.post('/auth/guest', { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } }, async (request, reply) => {
const parsed = guestLoginSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
success: false,
data: null,
error: { code: 'VALIDATION_ERROR', message: parsed.error.issues[0]?.message ?? 'Invalid input' },
});
}
const result = await findOrCreateGuest(parsed.data.deviceId, app);
return reply.send({ success: true, data: result, error: null });
});
app.post('/auth/huawei', { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } }, async (request, reply) => {
const parsed = huaweiLoginSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
success: false,
data: null,
error: { code: 'VALIDATION_ERROR', message: parsed.error.issues[0]?.message ?? 'Invalid input' },
});
}
const userInfo = await verifyHuaweiToken(parsed.data.authorizationCode);
const result = await findOrCreateHuawei(userInfo.openId, userInfo.nickname, userInfo.avatarUrl, app);
return reply.send({ success: true, data: result, error: null });
});
// Phase 2: Phone login
app.post('/auth/phone', { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } }, async (_request, reply) => {
return reply.status(501).send({
success: false,
data: null,
error: { code: 'NOT_IMPLEMENTED', message: 'Phone login not implemented yet' },
});
});
app.post('/auth/refresh', { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } }, async (request, reply) => {
const parsed = refreshTokenSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
success: false,
data: null,
error: { code: 'VALIDATION_ERROR', message: parsed.error.issues[0]?.message ?? 'Invalid input' },
});
}
const result = await refreshJwt(app, parsed.data.refreshToken);
return reply.send({ success: true, data: result, error: null });
});
app.post('/auth/link', { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } }, async (request, reply) => {
const parsed = linkSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
success: false,
data: null,
error: { code: 'VALIDATION_ERROR', message: parsed.error.issues[0]?.message ?? 'Invalid input' },
});
}
const { userId } = request.user;
const result = await linkGuestAccount({
guestUserId: userId,
provider: parsed.data.provider,
credential: parsed.data.credential,
mergePolicy: parsed.data.mergePolicy,
clientMigrationId: parsed.data.clientMigrationId,
app,
});
return reply.send({ success: true, data: result, error: null });
});
app.get('/auth/me', async (request, reply) => {
const { userId } = request.user;
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
if (!user) {
throw new NotFoundError('User');
}
return reply.send({
success: true,
data: {
id: user.id,
nickname: user.nickname ?? null,
avatarUrl: user.avatarUrl ?? null,
tier: user.tier ?? 'free',
xpTotal: user.xpTotal ?? 0,
streakDays: user.streakDays ?? 0,
heartsRemaining: user.heartsRemaining ?? 5,
dailyXpEarned: user.dailyXpEarned ?? 0,
dailyXpGoal: user.dailyXpGoal ?? 50,
},
error: null,
});
});
}