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