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
103 lines
2.9 KiB
TypeScript
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 };
|
|
}
|