新增游客到正式账号的关联接口,支持 Apple Sign In, 采用 server_account_first 合并策略: - 场景 A(新用户):游客行原地升级为 Apple 账号 - 场景 B(老用户):事务内合并答题记录、奖励流水等, 不覆盖老账号的订阅、余额、库存、连续学习 包含幂等迁移追踪(accountMigrations 表)、 Apple identity token 验证(jose + JWKS)、 防竞态的原子迁移槽位抢占, 以及 12 个单元测试覆盖两种场景和各类边界。
135 lines
4.6 KiB
TypeScript
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,
|
|
});
|
|
});
|
|
}
|