duoqi-api/src/routes/auth.ts
Wang Zhuoxuan 0ca06df078
Some checks failed
CI/CD Pipeline / Code Quality (push) Failing after 19s
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Build & Deploy Test (push) Has been skipped
CI/CD Pipeline / Build & Deploy Production (push) Has been skipped
refactor: 移除阿里云融合认证集成,保留基础设施以备后续使用
移除 fusion-auth-client、融合认证路由和阿里云 SDK 依赖,
同时保留 findOrCreatePhone、appSettings 表、auth-providers
管理端和 /auth/providers 端点等基础设施。
2026-06-01 10:18:15 +08:00

234 lines
8.1 KiB
TypeScript

import { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { db } from '../db/client.js';
import { users, appSettings } from '../db/schema.js';
import { eq, like } from 'drizzle-orm';
import { findOrCreateGuest, findOrCreateHuawei, findOrCreatePhone as _findOrCreatePhone, 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';
import { config } from '../utils/config.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),
});
const providersQuerySchema = z.object({
platform: z.enum(['ios', 'android', 'harmony']),
});
type Platform = 'ios' | 'android' | 'harmony';
interface ProviderEntry {
id: string;
name: string;
type: 'primary' | 'secondary' | 'third_party';
iconKey?: string;
enabled: boolean;
}
const PLATFORM_AVAILABILITY: Record<string, Platform[]> = {
phone_sms: ['ios', 'android', 'harmony'],
huawei: ['android', 'harmony'],
apple: ['ios'],
wechat: ['ios', 'android', 'harmony'],
qq: ['ios', 'android', 'harmony'],
};
const PROVIDER_METADATA: Record<string, { name: string; type: ProviderEntry['type']; iconKey?: string }> = {
phone_sms: { name: '短信验证码登录', type: 'secondary' },
huawei: { name: '通过华为账号登录', type: 'third_party', iconKey: 'huawei' },
apple: { name: '通过 Apple 登录', type: 'third_party', iconKey: 'apple' },
wechat: { name: '通过微信登录', type: 'third_party', iconKey: 'wechat' },
qq: { name: '通过 QQ 登录', type: 'third_party', iconKey: 'qq' },
};
const CREDENTIAL_PROVIDERS = new Set(['phone_sms', 'huawei', 'apple']);
const DB_TOGGLE_PROVIDERS = new Set(['wechat', 'qq']);
function isCredentialConfigured(providerId: string, _platform?: Platform): boolean {
switch (providerId) {
case 'phone_sms':
return !!(config.ALIYUN_ACCESS_KEY_ID && config.ALIYUN_ACCESS_KEY_SECRET);
case 'huawei':
return !!(config.HUAWEI_CLIENT_ID && config.HUAWEI_CLIENT_SECRET);
case 'apple':
return !!config.APPLE_BUNDLE_ID;
default:
return false;
}
}
async function getDbProviderToggles(): Promise<Map<string, boolean>> {
const rows = await db.select().from(appSettings).where(like(appSettings.key, 'auth_provider_%_enabled'));
const map = new Map<string, boolean>();
for (const row of rows) {
const providerId = row.key.replace('auth_provider_', '').replace('_enabled', '');
map.set(providerId, row.value === 'true');
}
return map;
}
function buildProviderList(platform: Platform, dbToggles: Map<string, boolean>): ProviderEntry[] {
const providers: ProviderEntry[] = [];
for (const [id, meta] of Object.entries(PROVIDER_METADATA)) {
if (!PLATFORM_AVAILABILITY[id]?.includes(platform)) continue;
const enabled = CREDENTIAL_PROVIDERS.has(id)
? isCredentialConfigured(id, platform)
: DB_TOGGLE_PROVIDERS.has(id)
? (dbToggles.get(id) ?? false)
: false;
providers.push({
id,
name: meta.name,
type: meta.type,
...(meta.iconKey ? { iconKey: meta.iconKey } : {}),
enabled,
});
}
return providers;
}
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' },
});
});
// ── Login Providers ──────────────────────────────────────────────
app.get('/auth/providers', async (request, reply) => {
const parsed = providersQuerySchema.safeParse(request.query);
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 dbToggles = await getDbProviderToggles();
const providers = buildProviderList(parsed.data.platform, dbToggles);
return reply.send({ success: true, data: { providers }, error: null });
});
// ── Token Refresh ────────────────────────────────────────────────
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,
});
});
}