duoqi-api/src/services/auth/jwt.ts
Wang Zhuoxuan b872b1cad9 feat: implement Phase 1b core features and Phase 1c commercialization
Phase 1b — Core Features:
- Huawei ID Kit login (token exchange + user info) with guest mode
- Quiz engine: randomized questions, distractor shuffling, answer verification
- XP service with combo bonuses (3/5/10-hit streaks), daily reset
- Streak service: >=3 correct/day, freeze, UTC date handling
- Hearts service: 5/day, 30min auto-restore, Pro unlimited
- 50 quiz questions across 3 categories (history/drama/crosstalk)
- 13 skill tree chapters with linear progression
- Idempotent seed import script (categories → skill tree → questions)
- 7 admin CRUD services (questions, categories, knowledge cards,
  skill tree, users, stats, feedback) with Zod validation
- All routes use Zod schema validation, /auth/me endpoint

Phase 1c — Commercialization:
- Leaderboard with live XP ranking, 10 tiers, weekly settlement
- Achievement system with 15 seed achievements and condition checking
- Huawei IAP receipt verification + subscription management
- Differentiated rate limiting (auth 10/min, quiz 60/min)
- Admin audit logging middleware

Infrastructure:
- Vitest test framework with DB mock utilities (19 tests passing)
- 12 DB tables (5 new: question_ratings, user_feedback, achievements,
  user_achievements, leaderboard_snapshots, subscriptions, admin_audit_log)
- TypeScript strict mode: zero errors
2026-04-09 00:12:12 +08:00

103 lines
2.9 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';
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(
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 };
}