feat: 实现 POST /v1/auth/link 游客账号关联与数据合并
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

新增游客到正式账号的关联接口,支持 Apple Sign In,
采用 server_account_first 合并策略:
- 场景 A(新用户):游客行原地升级为 Apple 账号
- 场景 B(老用户):事务内合并答题记录、奖励流水等,
  不覆盖老账号的订阅、余额、库存、连续学习

包含幂等迁移追踪(accountMigrations 表)、
Apple identity token 验证(jose + JWKS)、
防竞态的原子迁移槽位抢占,
以及 12 个单元测试覆盖两种场景和各类边界。
This commit is contained in:
Wang Zhuoxuan 2026-05-23 13:50:16 +08:00
parent 7dcaf65585
commit 1116b9a2ec
13 changed files with 818 additions and 2 deletions

View File

@ -13,6 +13,7 @@
"dotenv": "^16.5.0",
"drizzle-orm": "^0.44.0",
"fastify": "^5.3.0",
"jose": "^6.2.3",
"mysql2": "^3.12.0",
"pino": "^9.6.0",
"uuid": "^11.1.0",
@ -447,6 +448,8 @@
"istanbul-reports": ["istanbul-reports@3.2.0", "https://registry.npmmirror.com/istanbul-reports/-/istanbul-reports-3.2.0.tgz", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="],
"jose": ["jose@6.2.3", "https://registry.npmmirror.com/jose/-/jose-6.2.3.tgz", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
"joycon": ["joycon@3.1.1", "https://registry.npmmirror.com/joycon/-/joycon-3.1.1.tgz", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="],
"js-tokens": ["js-tokens@10.0.0", "https://registry.npmmirror.com/js-tokens/-/js-tokens-10.0.0.tgz", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="],

View File

@ -27,6 +27,7 @@
"dotenv": "^16.5.0",
"drizzle-orm": "^0.44.0",
"fastify": "^5.3.0",
"jose": "^6.2.3",
"mysql2": "^3.12.0",
"pino": "^9.6.0",
"uuid": "^11.1.0",

View File

@ -0,0 +1,280 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { db } from '../../../db/client.js';
import { linkGuestAccount } from '../../../services/auth/account-link-service.js';
import { mockSelectQueue as queueSelect } from '../../helpers/db-mock.js';
vi.mock('../../../services/auth/apple-id-token.js', () => ({
verifyAppleIdentityToken: vi.fn().mockResolvedValue({
appleSub: 'apple-user-123',
email: 'test@example.com',
}),
}));
const mockApp = {
jwt: {
sign: vi.fn().mockReturnValue('mock-jwt-token'),
},
} as never;
const baseGuestUser = {
id: 'guest-001',
authType: 'guest',
authId: 'device-abc',
nickname: null,
avatarUrl: null,
tier: 'free',
xpTotal: 0,
streakDays: 0,
heartsRemaining: 5,
heartsLastRestore: null,
dailyXpGoal: 50,
dailyXpEarned: 0,
dailyXpDate: null,
currentTheme: 'inkTeal',
activeTrackId: 'track-1',
dailyAttemptsLeft: 5,
dailyAttemptsDate: null,
checkInDays: 0,
lastCheckInDate: null,
streakProtectedUntil: null,
createdAt: new Date(),
updatedAt: new Date(),
};
const baseFormalUser = {
...baseGuestUser,
id: 'formal-001',
authType: 'apple',
authId: 'apple-user-123',
nickname: '老用户',
xpTotal: 500,
streakDays: 10,
activeTrackId: null,
};
function mockUpdateChain() {
return { set: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }) } as never;
}
function mockInsertSuccess() {
return { values: vi.fn().mockResolvedValue(undefined) } as never;
}
function makeDupError() {
const err = new Error('Duplicate entry') as Error & { code: string };
err.code = 'ER_DUP_ENTRY';
return err;
}
describe('linkGuestAccount', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('Scenario A: upgrades guest in-place when no formal user exists', async () => {
// Select queue: [guest user], [formal user (none)], [re-read upgraded]
queueSelect(vi.mocked(db.select), [
[baseGuestUser],
[],
[{ ...baseGuestUser, authType: 'apple', authId: 'apple-user-123' }],
]);
vi.mocked(db.insert).mockReturnValue(mockInsertSuccess());
vi.mocked(db.update).mockReturnValue(mockUpdateChain());
const result = await linkGuestAccount({
guestUserId: 'guest-001',
provider: 'apple',
credential: { identityToken: 'valid-apple-token' },
mergePolicy: 'server_account_first',
clientMigrationId: 'migration-001',
app: mockApp,
});
expect(result.session.user.id).toBe('guest-001');
expect(result.migrationSummary.imported).toEqual({});
expect(result.migrationSummary.conflicts).toEqual([]);
expect(result.bootstrap).toBeNull();
});
it('Scenario B: merges guest data when formal user exists', async () => {
const formalUser = { ...baseFormalUser };
// db.transaction mock
const mockTx = {
select: vi.fn(),
update: vi.fn().mockReturnValue(mockUpdateChain()),
execute: vi.fn().mockResolvedValue([{ affectedRows: 1 }]),
};
// Set up mockTx.select for merge queries inside transaction
// The merge function uses .select().from().where() chains and awaits them
const txSelectResponses = [
[{ questionId: 'q1' }, { questionId: 'q2' }], // guest answers
[{ questionId: 'q1' }], // formal answered q1
[{ activeTrackId: 'track-1' }], // guest activeTrackId
];
let txSelectIdx = 0;
mockTx.select.mockImplementation(() => {
const rows = txSelectResponses[txSelectIdx] ?? [];
txSelectIdx++;
// Chainable + thenable: supports .from().where() chaining AND `await`
const chain = {
then: (resolve: (v: unknown) => unknown, reject?: (r: unknown) => unknown) =>
Promise.resolve(rows).then(resolve, reject),
} as Record<string, unknown>;
const from = vi.fn().mockReturnValue(chain);
const where = vi.fn().mockReturnValue(chain);
const limit = vi.fn().mockResolvedValue(rows);
chain.from = from;
chain.where = where;
chain.limit = limit;
return { from, where, limit };
});
vi.mocked(db.transaction).mockImplementation(async (fn) => fn(mockTx as never) as never);
vi.mocked(db.insert).mockReturnValue(mockInsertSuccess());
vi.mocked(db.update).mockReturnValue(mockUpdateChain());
// Outer selects: [guest user], [formal user]
queueSelect(vi.mocked(db.select), [
[baseGuestUser],
[formalUser],
]);
const result = await linkGuestAccount({
guestUserId: 'guest-001',
provider: 'apple',
credential: { identityToken: 'valid-apple-token' },
mergePolicy: 'server_account_first',
clientMigrationId: 'migration-002',
app: mockApp,
});
expect(result.session.user.id).toBe('formal-001');
expect(result.migrationSummary.imported.answers).toBe(1);
expect(result.migrationSummary.skipped.duplicateAnswers).toBe(1);
expect(mockTx.execute).toHaveBeenCalled();
});
it('returns idempotent result for duplicate clientMigrationId', async () => {
const formalUser = { ...baseFormalUser };
const existingMigration = {
id: 'mig-001',
guestUserId: 'guest-001',
formalUserId: 'formal-001',
provider: 'apple' as const,
providerUserId: 'apple-user-123',
clientMigrationId: 'migration-dup',
status: 'completed',
migrationSummary: { policy: 'server_account_first', imported: { answers: 1 }, skipped: {}, conflicts: [] },
createdAt: new Date(),
completedAt: new Date(),
};
// insert().values() throws duplicate key → handleExistingMigration → selects
vi.mocked(db.insert).mockReturnValueOnce({
values: vi.fn().mockRejectedValue(makeDupError()),
} as never);
queueSelect(vi.mocked(db.select), [
[baseGuestUser], // guest user
[existingMigration], // migration found
[formalUser], // formal user for token
]);
const result = await linkGuestAccount({
guestUserId: 'guest-001',
provider: 'apple',
credential: { identityToken: 'valid-apple-token' },
mergePolicy: 'server_account_first',
clientMigrationId: 'migration-dup',
app: mockApp,
});
expect(result.session.user.id).toBe('formal-001');
expect(result.migrationSummary.imported.answers).toBe(1);
});
it('throws AUTH_LINK_CONFLICT for non-guest user', async () => {
const nonGuestUser = { ...baseGuestUser, authType: 'apple' as const };
queueSelect(vi.mocked(db.select), [[nonGuestUser]]);
await expect(
linkGuestAccount({
guestUserId: 'guest-001',
provider: 'apple',
credential: { identityToken: 'valid-apple-token' },
mergePolicy: 'server_account_first',
clientMigrationId: 'migration-003',
app: mockApp,
}),
).rejects.toThrow('Account is not a guest account');
});
it('throws AUTH_MIGRATION_DUPLICATE when migration is in_progress', async () => {
const inProgressMigration = {
id: 'mig-002',
guestUserId: 'guest-001',
formalUserId: null,
provider: 'apple' as const,
providerUserId: 'apple-user-123',
clientMigrationId: 'migration-in-progress',
status: 'in_progress',
migrationSummary: null,
createdAt: new Date(),
completedAt: null,
};
vi.mocked(db.insert).mockReturnValueOnce({
values: vi.fn().mockRejectedValue(makeDupError()),
} as never);
queueSelect(vi.mocked(db.select), [
[baseGuestUser],
[inProgressMigration],
]);
await expect(
linkGuestAccount({
guestUserId: 'guest-001',
provider: 'apple',
credential: { identityToken: 'valid-apple-token' },
mergePolicy: 'server_account_first',
clientMigrationId: 'migration-in-progress',
app: mockApp,
}),
).rejects.toThrow('Migration already in progress');
});
it('throws VALIDATION_ERROR for unsupported provider', async () => {
queueSelect(vi.mocked(db.select), [[baseGuestUser]]);
await expect(
linkGuestAccount({
guestUserId: 'guest-001',
provider: 'google',
credential: { identityToken: 'some-token' },
mergePolicy: 'server_account_first',
clientMigrationId: 'migration-004',
app: mockApp,
}),
).rejects.toThrow('Provider not supported');
});
it('throws when user not found', async () => {
queueSelect(vi.mocked(db.select), [[]]);
await expect(
linkGuestAccount({
guestUserId: 'nonexistent',
provider: 'apple',
credential: { identityToken: 'valid-apple-token' },
mergePolicy: 'server_account_first',
clientMigrationId: 'migration-005',
app: mockApp,
}),
).rejects.toThrow('User not found');
});
});

View File

@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock jose module
vi.mock('jose', () => ({
createRemoteJWKSet: vi.fn().mockReturnValue('mock-jwks'),
jwtVerify: vi.fn(),
}));
const { verifyAppleIdentityToken } = await import('../../../services/auth/apple-id-token.js');
const { jwtVerify } = await import('jose');
describe('verifyAppleIdentityToken', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns Apple user info for valid token', async () => {
vi.mocked(jwtVerify).mockResolvedValue({
payload: { sub: 'apple-sub-123', email: 'user@example.com', iat: Math.floor(Date.now() / 1000) },
} as never);
const result = await verifyAppleIdentityToken('valid-identity-token');
expect(result).toEqual({
appleSub: 'apple-sub-123',
email: 'user@example.com',
});
});
it('returns null email when not present', async () => {
vi.mocked(jwtVerify).mockResolvedValue({
payload: { sub: 'apple-sub-456', iat: Math.floor(Date.now() / 1000) },
} as never);
const result = await verifyAppleIdentityToken('valid-identity-token');
expect(result).toEqual({
appleSub: 'apple-sub-456',
email: null,
});
});
it('throws UnauthorizedError when verification fails', async () => {
vi.mocked(jwtVerify).mockRejectedValue(new Error('Invalid signature'));
await expect(verifyAppleIdentityToken('bad-token')).rejects.toThrow(
'Apple identity token verification failed',
);
});
it('throws UnauthorizedError when sub is missing', async () => {
vi.mocked(jwtVerify).mockResolvedValue({
payload: { iat: Math.floor(Date.now() / 1000) },
} as never);
await expect(verifyAppleIdentityToken('token-no-sub')).rejects.toThrow(
'Apple identity token missing sub claim',
);
});
it('rejects tokens issued more than 5 minutes ago', async () => {
const oldIat = Math.floor(Date.now() / 1000) - 600; // 10 minutes ago
vi.mocked(jwtVerify).mockResolvedValue({
payload: { sub: 'apple-sub-old', iat: oldIat },
} as never);
await expect(verifyAppleIdentityToken('old-token')).rejects.toThrow(
'Apple identity token expired',
);
});
});

View File

@ -8,6 +8,7 @@ vi.mock('../utils/config.js', () => ({
ADMIN_TOKEN: 'test-admin-token',
HUAWEI_CLIENT_ID: 'test-huawei-id',
HUAWEI_CLIENT_SECRET: 'test-huawei-secret',
APPLE_BUNDLE_ID: 'com.duoqi.app',
PORT: 3000,
NODE_ENV: 'test',
LOG_LEVEL: 'error',
@ -33,6 +34,7 @@ vi.mock('../db/client.js', () => {
set: vi.fn().mockReturnThis(),
delete: vi.fn().mockReturnThis(),
execute: vi.fn().mockResolvedValue([]),
transaction: vi.fn(),
$dynamic: vi.fn().mockReturnThis(),
};

View File

@ -480,6 +480,32 @@ export const adRecoverySessions = mysqlTable('ad_recovery_sessions', {
foreignKey({ columns: [table.userId], foreignColumns: [users.id] }),
]);
// ── Account Migrations ─────────────────────────────────────────────
// 游客账号关联正式账号的迁移记录,用于幂等追踪。
export const accountMigrations = mysqlTable('account_migrations', {
id: char('id', { length: 36 }).primaryKey(),
guestUserId: char('guest_user_id', { length: 36 }).notNull(),
formalUserId: char('formal_user_id', { length: 36 }),
provider: mysqlEnum('provider', ['apple', 'google', 'phone']).notNull(),
providerUserId: varchar('provider_user_id', { length: 255 }).notNull(),
clientMigrationId: varchar('client_migration_id', { length: 80 }).notNull(),
status: mysqlEnum('status', ['in_progress', 'completed', 'failed']).default('in_progress'),
migrationSummary: json('migration_summary').$type<{
policy: string;
imported: Record<string, number>;
skipped: Record<string, number>;
conflicts: readonly string[];
}>(),
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`),
completedAt: datetime('completed_at'),
}, (table) => [
uniqueIndex('uk_migration_guest_client').on(table.guestUserId, table.clientMigrationId),
uniqueIndex('uk_migration_guest_provider').on(table.guestUserId, table.provider),
foreignKey({ columns: [table.guestUserId], foreignColumns: [users.id] }),
foreignKey({ columns: [table.formalUserId], foreignColumns: [users.id] }),
]);
// ── Admin Audit Log ────────────────────────────────────────────────
// 管理端操作审计日志数据。

View File

@ -5,6 +5,7 @@ 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({
@ -19,6 +20,16 @@ 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) => {
@ -73,6 +84,29 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
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);

View File

@ -0,0 +1,330 @@
import type { FastifyInstance } from 'fastify';
import { eq, and, sql, inArray } from 'drizzle-orm';
import { db } from '../../db/client.js';
import {
users,
accountMigrations,
userProgress,
} from '../../db/schema.js';
import type { AccountLinkResponse, MigrationSummary } from '../../types/auth.js';
import { signTokens } from './jwt.js';
import { verifyAppleIdentityToken } from './apple-id-token.js';
import {
AppError,
ForbiddenError,
ConflictError,
ValidationError,
} from '../../utils/errors.js';
// DB users table auth_type enum (excludes 'admin')
type UserAuthType = 'huawei' | 'guest' | 'phone' | 'apple' | 'google';
const MYSQL_DUP_ENTRY = 'ER_DUP_ENTRY';
interface LinkParams {
guestUserId: string;
provider: string;
credential: { authorizationCode?: string; identityToken?: string };
mergePolicy: string;
clientMigrationId: string;
app: FastifyInstance;
}
async function verifyProvider(
provider: string,
credential: { authorizationCode?: string; identityToken?: string },
): Promise<{ providerUserId: string; email: string | null }> {
if (provider !== 'apple') {
throw new ValidationError('Provider not supported');
}
if (!credential.identityToken) {
throw new ValidationError('identityToken is required for Apple login');
}
const info = await verifyAppleIdentityToken(credential.identityToken);
return { providerUserId: info.appleSub, email: info.email };
}
function emptySummary(): MigrationSummary {
return {
policy: 'server_account_first',
imported: {},
skipped: {},
conflicts: [],
};
}
function isDuplicateKeyError(error: unknown): boolean {
return error instanceof Error && 'code' in error && (error as { code: string }).code === MYSQL_DUP_ENTRY;
}
export async function linkGuestAccount(params: LinkParams): Promise<AccountLinkResponse> {
const { guestUserId, provider, credential, clientMigrationId, app } = params;
// Step 1: Verify guest user
const [guestUser] = await db.select().from(users).where(eq(users.id, guestUserId)).limit(1);
if (!guestUser) {
throw new ForbiddenError('User not found');
}
if (guestUser.authType !== 'guest') {
throw new ConflictError('Account is not a guest account', 'AUTH_LINK_CONFLICT');
}
// Step 2: Verify provider credential
const { providerUserId } = await verifyProvider(provider, credential);
// Step 3: Idempotency — try to claim migration slot atomically
const migrationId = crypto.randomUUID();
try {
await db.insert(accountMigrations).values({
id: migrationId,
guestUserId,
provider: provider as 'apple' | 'google' | 'phone',
providerUserId,
clientMigrationId,
status: 'in_progress',
});
} catch (error: unknown) {
if (isDuplicateKeyError(error)) {
// Duplicate clientMigrationId or guest+provider — fetch existing
return handleExistingMigration(guestUserId, clientMigrationId, app);
}
throw new AppError('Unable to start account linking', 500, 'AUTH_MERGE_FAILED');
}
// Step 4: Find formal account
const [formalUser] = await db
.select()
.from(users)
.where(and(eq(users.authType, provider as UserAuthType), eq(users.authId, providerUserId)))
.limit(1);
try {
// Scenario A: No existing formal user — upgrade guest in-place
if (!formalUser) {
await db
.update(users)
.set({ authType: provider as UserAuthType, authId: providerUserId })
.where(eq(users.id, guestUserId));
const [upgradedUser] = await db.select().from(users).where(eq(users.id, guestUserId)).limit(1);
if (!upgradedUser) throw new AppError('Failed to upgrade user', 500, 'AUTH_MERGE_FAILED');
const tokens = signTokens(app, upgradedUser.id, upgradedUser.authType as UserAuthType, upgradedUser.tier ?? 'free');
const summary = emptySummary();
await completeMigration(migrationId, guestUserId, summary);
return {
session: { user: buildUserObj(upgradedUser), tokens },
migrationSummary: summary,
bootstrap: null,
};
}
// Scenario B: Existing formal user — merge guest data in transaction
const summary = await db.transaction(async (tx) => {
const result = await mergeGuestToFormal(guestUserId, formalUser.id, tx);
// Mark guest as consumed
const consumedAuthId = `consumed:${guestUser.authId}`;
await tx
.update(users)
.set({ authId: consumedAuthId })
.where(eq(users.id, guestUserId));
return result;
});
await completeMigration(migrationId, formalUser.id, summary);
const tokens = signTokens(app, formalUser.id, formalUser.authType as UserAuthType, formalUser.tier ?? 'free');
return {
session: { user: buildUserObj(formalUser), tokens },
migrationSummary: summary,
bootstrap: null,
};
} catch (error: unknown) {
// Mark migration as failed so retry creates a new attempt
await db
.update(accountMigrations)
.set({ status: 'failed' })
.where(eq(accountMigrations.id, migrationId));
if (isDuplicateKeyError(error)) {
throw new ConflictError('Account already linked', 'AUTH_LINK_CONFLICT');
}
throw new AppError('Unable to complete account linking', 500, 'AUTH_MERGE_FAILED');
}
}
async function handleExistingMigration(
guestUserId: string,
clientMigrationId: string,
app: FastifyInstance,
): Promise<AccountLinkResponse> {
const [existing] = await db
.select()
.from(accountMigrations)
.where(
and(
eq(accountMigrations.guestUserId, guestUserId),
eq(accountMigrations.clientMigrationId, clientMigrationId),
),
)
.limit(1);
if (!existing) {
// Duplicate was on guest+provider unique key — already linked
throw new ConflictError('Account already linked to this provider', 'AUTH_LINK_CONFLICT');
}
if (existing.status === 'completed' && existing.formalUserId) {
const [formalUser] = await db.select().from(users).where(eq(users.id, existing.formalUserId)).limit(1);
if (formalUser) {
const tokens = signTokens(app, formalUser.id, formalUser.authType as UserAuthType, formalUser.tier ?? 'free');
return {
session: { user: buildUserObj(formalUser), tokens },
migrationSummary: (existing.migrationSummary as MigrationSummary | null) ?? emptySummary(),
bootstrap: null,
};
}
}
if (existing.status === 'in_progress') {
throw new ConflictError('Migration already in progress', 'AUTH_MIGRATION_DUPLICATE');
}
throw new ConflictError('Unable to complete account linking', 'AUTH_MERGE_FAILED');
}
async function completeMigration(
migrationId: string,
formalUserId: string,
summary: MigrationSummary,
): Promise<void> {
await db
.update(accountMigrations)
.set({
formalUserId,
status: 'completed',
migrationSummary: summary,
completedAt: new Date(),
})
.where(eq(accountMigrations.id, migrationId));
}
function buildUserObj(user: typeof users.$inferSelect) {
return {
id: user.id,
nickname: user.nickname ?? null,
avatarUrl: user.avatarUrl ?? null,
tier: user.tier ?? 'free',
xpTotal: user.xpTotal ?? 0,
streakDays: user.streakDays ?? 0,
};
}
async function mergeGuestToFormal<T extends { select: typeof db.select; update: typeof db.update; execute: typeof db.execute }>(
guestId: string,
formalId: string,
queryable: T,
): Promise<MigrationSummary> {
const imported: Record<string, number> = {};
const skipped: Record<string, number> = {};
// userProgress: insert guest answers, skip questions formal user already answered
const guestAnswers = await queryable
.select({ questionId: userProgress.questionId })
.from(userProgress)
.where(eq(userProgress.userId, guestId));
if (guestAnswers.length > 0) {
const formalAnswered = new Set(
(
await queryable
.select({ questionId: userProgress.questionId })
.from(userProgress)
.where(eq(userProgress.userId, formalId))
).map((r) => r.questionId),
);
const newAnswers = guestAnswers.filter((a) => !formalAnswered.has(a.questionId));
const dupAnswers = guestAnswers.length - newAnswers.length;
if (dupAnswers > 0) {
skipped.duplicateAnswers = dupAnswers;
}
if (newAnswers.length > 0) {
const newQuestionIds = newAnswers.map((a) => a.questionId);
await queryable
.update(userProgress)
.set({ userId: formalId })
.where(and(eq(userProgress.userId, guestId), inArray(userProgress.questionId, newQuestionIds)));
imported.answers = newAnswers.length;
}
}
// userChapterProgress: INSERT IGNORE via raw SQL
const chapterResult = await queryable.execute(sql`
INSERT IGNORE INTO user_chapter_progress (id, user_id, chapter_id, status, best_correct_count, attempts, completed_at)
SELECT UUID(), ${formalId}, chapter_id, status, best_correct_count, attempts, completed_at
FROM user_chapter_progress
WHERE user_id = ${guestId}
`);
const chapterRows = Number(chapterResult[0].affectedRows) || 0;
if (chapterRows > 0) imported.chapterProgress = chapterRows;
// rewardLedger: INSERT IGNORE
const rewardResult = await queryable.execute(sql`
INSERT IGNORE INTO reward_ledger (id, user_id, source_type, source_id, idempotency_key, status, reward_snapshot, resource_deltas, state_before, state_after, failure_reason, settled_at, created_at, updated_at)
SELECT UUID(), ${formalId}, source_type, source_id, idempotency_key, status, reward_snapshot, resource_deltas, state_before, state_after, failure_reason, settled_at, created_at, updated_at
FROM reward_ledger
WHERE user_id = ${guestId}
`);
const rewardRows = Number(rewardResult[0].affectedRows) || 0;
if (rewardRows > 0) imported.rewardEvents = rewardRows;
// inventoryTransactions: INSERT IGNORE
const txResult = await queryable.execute(sql`
INSERT IGNORE INTO inventory_transactions (id, user_id, inventory_item_id, item_id, direction, quantity_delta, balance_after, source_type, source_id, idempotency_key, snapshot, created_at)
SELECT UUID(), ${formalId}, inventory_item_id, item_id, direction, quantity_delta, balance_after, source_type, source_id, idempotency_key, snapshot, created_at
FROM inventory_transactions
WHERE user_id = ${guestId}
`);
const txRows = Number(txResult[0].affectedRows) || 0;
if (txRows > 0) imported.inventoryTransactions = txRows;
// userAchievements: INSERT IGNORE
const achieveResult = await queryable.execute(sql`
INSERT IGNORE INTO user_achievements (id, user_id, achievement_id, unlocked_at)
SELECT UUID(), ${formalId}, achievement_id, unlocked_at
FROM user_achievements
WHERE user_id = ${guestId}
`);
const achieveRows = Number(achieveResult[0].affectedRows) || 0;
if (achieveRows > 0) imported.achievements = achieveRows;
// questionRatings: INSERT IGNORE
const ratingResult = await queryable.execute(sql`
INSERT IGNORE INTO question_ratings (id, user_id, question_id, rating, created_at)
SELECT UUID(), ${formalId}, question_id, rating, created_at
FROM question_ratings
WHERE user_id = ${guestId}
`);
const ratingRows = Number(ratingResult[0].affectedRows) || 0;
if (ratingRows > 0) imported.questionRatings = ratingRows;
// activeTrackId: copy only if formal user doesn't have one
const [guestRow] = await queryable.select({ activeTrackId: users.activeTrackId }).from(users).where(eq(users.id, guestId)).limit(1);
if (guestRow?.activeTrackId) {
await queryable
.update(users)
.set({ activeTrackId: guestRow.activeTrackId })
.where(and(eq(users.id, formalId), sql`active_track_id IS NULL`));
}
return { policy: 'server_account_first', imported, skipped, conflicts: [] };
}

View File

@ -0,0 +1,43 @@
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { config } from '../../utils/config.js';
import { UnauthorizedError } from '../../utils/errors.js';
export interface AppleUserInfo {
appleSub: string;
email: string | null;
}
const APPLE_JWKS = createRemoteJWKSet(new URL('https://appleid.apple.com/auth/keys'));
export async function verifyAppleIdentityToken(identityToken: string): Promise<AppleUserInfo> {
if (!config.APPLE_BUNDLE_ID) {
throw new Error('APPLE_BUNDLE_ID is not configured');
}
let payload: Record<string, unknown>;
try {
const result = await jwtVerify(identityToken, APPLE_JWKS, {
issuer: 'https://appleid.apple.com',
audience: config.APPLE_BUNDLE_ID,
});
payload = result.payload as Record<string, unknown>;
} catch {
throw new UnauthorizedError('Apple identity token verification failed');
}
// Reject tokens issued more than 5 minutes ago (replay protection)
const iat = payload.iat;
if (typeof iat === 'number' && Date.now() / 1000 - iat > 300) {
throw new UnauthorizedError('Apple identity token expired');
}
const sub = payload.sub;
if (typeof sub !== 'string' || !sub) {
throw new UnauthorizedError('Apple identity token missing sub claim');
}
return {
appleSub: sub,
email: typeof payload.email === 'string' ? payload.email : null,
};
}

View File

@ -5,14 +5,14 @@ import { users } from '../../db/schema.js';
import { eq, and } from 'drizzle-orm';
import type { JwtPayload, LoginResponse, AuthType } from '../../types/auth.js';
function signTokens(app: FastifyInstance, userId: string, authType: AuthType, tier: string) {
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 };
}
function buildLoginResponse(
export function buildLoginResponse(
user: typeof users.$inferSelect,
accessToken: string,
refreshToken: string,

View File

@ -30,3 +30,21 @@ export interface AdminLoginResponse {
role: string;
};
}
// Account Link
export interface MigrationSummary {
policy: 'server_account_first';
imported: Record<string, number>;
skipped: Record<string, number>;
conflicts: readonly string[];
}
export interface AccountLinkResponse {
session: {
user: LoginResponse['user'];
tokens: { accessToken: string; refreshToken: string };
};
migrationSummary: MigrationSummary;
bootstrap: null;
}

View File

@ -15,6 +15,7 @@ const envSchema = z.object({
OSS_REGION: z.string().optional(),
HUAWEI_IAP_URL: z.string().optional(),
HUAWEI_MERCHANT_ID: z.string().optional(),
APPLE_BUNDLE_ID: z.string().optional(),
PORT: z.coerce.number().default(3000),
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),

View File

@ -39,6 +39,13 @@ export class ValidationError extends AppError {
}
}
export class ConflictError extends AppError {
constructor(message: string, code = 'CONFLICT') {
super(message, 409, code);
this.name = 'ConflictError';
}
}
export function errorHandler(
error: Error,
_request: FastifyRequest,