Skip to content

Commit cc28ff2

Browse files
committed
migate function to framework 2
1 parent a72e83d commit cc28ff2

File tree

13 files changed

+400
-577
lines changed

13 files changed

+400
-577
lines changed

scripts/copy-templates.js

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,28 @@
11
const fs = require('fs');
22
const path = require('path');
33

4-
const srcDir = path.join(__dirname, '..', 'src', 'templates');
5-
const distDir = path.join(__dirname, '..', 'dist', 'templates');
4+
const appTemplatesSrc = path.join(__dirname, '..', 'src', 'templates');
5+
const appTemplatesDist = path.join(__dirname, '..', 'dist', 'templates');
6+
const frameworkTemplatesSrc = path.join(__dirname, '..', 'src', 'framework', 'templates');
7+
const frameworkTemplatesDist = path.join(__dirname, '..', 'dist', 'framework', 'templates');
68

7-
if (!fs.existsSync(distDir)) {
8-
fs.mkdirSync(distDir, { recursive: true });
9+
if (!fs.existsSync(appTemplatesDist)) fs.mkdirSync(appTemplatesDist, { recursive: true });
10+
if (!fs.existsSync(frameworkTemplatesDist)) fs.mkdirSync(frameworkTemplatesDist, { recursive: true });
11+
12+
if (fs.existsSync(frameworkTemplatesSrc)) {
13+
fs.readdirSync(frameworkTemplatesSrc).forEach(file => {
14+
fs.copyFileSync(path.join(frameworkTemplatesSrc, file), path.join(frameworkTemplatesDist, file));
15+
});
16+
console.log('✅ Framework templates copied');
17+
} else {
18+
console.log('ℹ️ No framework templates');
919
}
1020

11-
if (fs.existsSync(srcDir)) {
12-
fs.readdirSync(srcDir).forEach(file => {
13-
fs.copyFileSync(path.join(srcDir, file), path.join(distDir, file));
21+
if (fs.existsSync(appTemplatesSrc)) {
22+
fs.readdirSync(appTemplatesSrc).forEach(file => {
23+
fs.copyFileSync(path.join(appTemplatesSrc, file), path.join(appTemplatesDist, file));
1424
});
15-
console.log('✅ Templates copied successfully');
25+
console.log('✅ App templates copied');
1626
} else {
17-
console.log('⚠️ No templates directory found');
18-
}
27+
console.log('ℹ️ No app templates');
28+
}

src/app.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,16 @@ import { fastify, FastifyInstance } from 'fastify';
22
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
33
import cors from '@fastify/cors';
44
import * as jwt from 'jsonwebtoken';
5-
import { authRouter } from './routers/auth';
6-
import { userRouter } from './routers/user';
5+
import { authRouter } from './framework/routers/auth';
6+
import { userRouter } from './framework/routers/user';
77
import { announcementRouter } from './framework/routers/announcement';
88
import { corsDebugRouter } from './framework/routers/cors-debug';
99
import { echoRouter } from './framework/routers/echo';
1010
import { router } from './trpc';
1111
import { join } from 'path';
1212
import { corsPluginOptions, corsMiddleware } from './middleware';
1313
import { getCorsConfig } from './config/cors';
14-
import { testCors } from './utils/cors-test';
14+
import { testCors } from './framework/utils/cors-test';
1515

1616
export type AppRouter = ReturnType<typeof createAppRouter>;
1717

src/framework/routers/auth.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { z } from 'zod';
2+
import { publicProcedure, router } from '../../trpc';
3+
import { prisma } from '../../db';
4+
import { Resend } from 'resend';
5+
import { randomBytes, createHash } from 'crypto';
6+
import * as jwt from 'jsonwebtoken';
7+
import * as fs from 'fs';
8+
import * as path from 'path';
9+
import { config } from '../../config';
10+
11+
const resend = new Resend(process.env.RESEND_API_KEY);
12+
13+
const emailTemplatePath = path.join(__dirname, '../templates/magic-link-email.html');
14+
const emailTemplate = fs.readFileSync(emailTemplatePath, 'utf-8');
15+
16+
function buildMagicLink(token: string): string {
17+
const frontendUrl = config.getFrontendUrl();
18+
if (frontendUrl.includes('#')) {
19+
const [base, hash] = frontendUrl.split('#', 2);
20+
const url = new URL(base);
21+
url.searchParams.set('token', token);
22+
return `${url.toString()}#${hash}`;
23+
}
24+
const url = new URL(frontendUrl);
25+
url.searchParams.set('token', token);
26+
return url.toString();
27+
}
28+
29+
export const authRouter = router({
30+
healthCheck: publicProcedure
31+
.query(async () => {
32+
const result = {
33+
status: 'ok',
34+
timestamp: new Date().toISOString(),
35+
message: 'Backend is online'
36+
};
37+
return result;
38+
}),
39+
40+
requestLoginLink: publicProcedure
41+
.input(z.object({ email: z.string().email() }))
42+
.mutation(async ({ input: { email } }) => {
43+
const user = await prisma.user.upsert({
44+
where: { email },
45+
update: {},
46+
create: { email },
47+
});
48+
49+
const rawToken = randomBytes(32).toString('hex');
50+
const hashedToken = createHash('sha256').update(rawToken).digest('hex');
51+
const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
52+
53+
await prisma.authToken.create({
54+
data: { token: hashedToken, userId: user.id, expiresAt },
55+
});
56+
57+
const magicLink = buildMagicLink(rawToken);
58+
59+
const emailHtml = emailTemplate.replace(/\{\{magicLink\}\}/g, magicLink);
60+
const emailText = `请点击以下链接登录:\n${magicLink}\n\n如果按钮无法点击,请将链接复制到浏览器中打开。该链接15分钟内有效。`;
61+
62+
if (!config.isProduction) {
63+
console.log(`✨ Magic Link for ${email}: ${magicLink}`);
64+
}
65+
66+
try {
67+
await resend.emails.send({
68+
from: `${config.email.fromName || 'YourApp'} <${config.email.from}>`,
69+
to: email,
70+
subject: '登录到 YourApp',
71+
html: emailHtml,
72+
text: emailText,
73+
});
74+
console.log(`📧 Email sent successfully to ${email}`);
75+
} catch (emailError) {
76+
console.error(`❌ Failed to send email to ${email}:`, emailError);
77+
}
78+
79+
return { success: true };
80+
}),
81+
82+
verifyMagicToken: publicProcedure
83+
.input(z.object({ token: z.string() }))
84+
.query(async ({ input }) => {
85+
const hashedToken = createHash('sha256').update(input.token).digest('hex');
86+
const authToken = await prisma.authToken.findUnique({ where: { token: hashedToken } });
87+
if (!authToken || new Date() > authToken.expiresAt) {
88+
throw new Error('链接无效或已过期。');
89+
}
90+
const sessionToken = jwt.sign(
91+
{ userId: authToken.userId },
92+
process.env.JWT_SECRET!,
93+
{ expiresIn: '7d' }
94+
);
95+
await prisma.authToken.delete({ where: { id: authToken.id } });
96+
return { sessionToken };
97+
}),
98+
});
99+
100+

src/framework/routers/llm-proxy.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { FastifyInstance, FastifyPluginCallback } from 'fastify';
2-
import { LlmClient, ChatCompletionParams } from '../../utils/llm-client';
2+
import { LlmClient, ChatCompletionParams } from '../utils/llm-client';
33

44
export const llmProxyRoutes: FastifyPluginCallback = (server: FastifyInstance, _opts, done) => {
55
server.post('/v1/chat/completions', async (request, reply) => {

src/framework/routers/user.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { protectedProcedure, router } from '../../trpc';
2+
import { prisma } from '../../db';
3+
4+
export const userRouter = router({
5+
getMe: protectedProcedure.query(async ({ ctx }) => {
6+
const user = await prisma.user.findUnique({
7+
where: { id: ctx.user.userId },
8+
select: { id: true, email: true, createdAt: true },
9+
});
10+
return user;
11+
}),
12+
});
13+
14+
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<!DOCTYPE html>
2+
<html lang="zh-CN">
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+
<title>安全登录 | Carrot Web Game</title>
7+
<style>
8+
* { margin: 0; padding: 0; box-sizing: border-box; font-family: 'Segoe UI', 'Helvetica Neue', Arial, sans-serif; }
9+
body { background-color: #f8fcf6; padding: 20px; display: flex; justify-content: center; align-items: center; min-height: 100vh; color: #2c3e29; }
10+
.container { max-width: 600px; width: 100%; background-color: #ffffff; border-radius: 12px; overflow: hidden; box-shadow: 0 5px 25px rgba(46, 125, 50, 0.1); }
11+
.header { background: linear-gradient(to right, #4CAF50, #2E7D32); padding: 30px 40px; text-align: center; }
12+
.header h1 { color: white; font-size: 26px; font-weight: 600; margin-bottom: 5px; }
13+
.header p { color: rgba(255, 255, 255, 0.85); font-size: 16px; }
14+
.content { padding: 40px; line-height: 1.6; }
15+
.content p { margin-bottom: 25px; font-size: 16px; color: #4a4a4a; }
16+
.login-button-container { text-align: center; margin: 35px 0; }
17+
.login-button { display: inline-block; background: linear-gradient(to right, #43A047, #2E7D32); color: white; padding: 16px 45px; border-radius: 8px; font-weight: 600; font-size: 18px; text-decoration: none; transition: all 0.3s ease; box-shadow: 0 4px 15px rgba(76, 175, 80, 0.3); border: none; cursor: pointer; }
18+
.login-button:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(76, 175, 80, 0.4); }
19+
.validity { display: flex; align-items: center; justify-content: center; background: #FFF8E1; color: #F57F17; padding: 12px; border-radius: 8px; font-size: 15px; font-weight: 500; margin: 25px 0; }
20+
.validity svg { margin-right: 10px; }
21+
.security-note { background: #E8F5E9; border-left: 4px solid #4CAF50; padding: 18px; border-radius: 0 8px 8px 0; margin-top: 30px; font-size: 14.5px; color: #2E7D32; }
22+
.footer { padding: 25px; text-align: center; background: #f5f9f4; color: #5a6e56; font-size: 13px; border-top: 1px solid #e8f5e9; }
23+
.tech-stack { display: flex; justify-content: center; gap: 12px; margin-top: 15px; flex-wrap: wrap; }
24+
.tech-item { background: #e8f5e9; color: #2E7D32; padding: 6px 14px; border-radius: 20px; font-size: 12px; font-weight: 500; }
25+
@media (max-width: 480px) { .content { padding: 25px; } .header { padding: 25px 20px; } .login-button { padding: 14px 35px; font-size: 16px; } }
26+
</style>
27+
</head>
28+
<body>
29+
<div class="container">
30+
<div class="header">
31+
<h1>安全登录 Carrot Web Game</h1>
32+
<p>只需点击即可访问您的账户</p>
33+
</div>
34+
<div class="content">
35+
<p>您好!</p>
36+
<p>我们已收到您的登录请求。请点击下方按钮直接安全登录您的账户:</p>
37+
<div class="login-button-container">
38+
<a href="{{magicLink}}" class="login-button">立即登录</a>
39+
</div>
40+
<p style="word-break: break-all; color:#2E7D32; font-size:14px; margin-top:10px;">
41+
如果按钮无法点击,请将以下链接复制到浏览器中打开:<br/>
42+
<a href="{{magicLink}}" style="color:#2E7D32;">{{magicLink}}</a>
43+
</p>
44+
<div class="validity">
45+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
46+
<circle cx="12" cy="12" r="10"></circle>
47+
<polyline points="12 6 12 12 16 14"></polyline>
48+
</svg>
49+
此链接将在15分钟后失效
50+
</div>
51+
<p>如果您未请求登录,请忽略此邮件。您的账户安全对我们至关重要。</p>
52+
<div class="security-note">
53+
<strong>安全提示:</strong> 请勿将此邮件或登录链接分享给任何人。我们的系统绝不会要求您提供密码。
54+
</div>
55+
</div>
56+
<div class="footer">
57+
<div class="tech-stack">
58+
<div class="tech-item">Fastify</div>
59+
<div class="tech-item">tRPC</div>
60+
<div class="tech-item">Prisma</div>
61+
<div class="tech-item">PostgreSQL</div>
62+
</div>
63+
<p>&copy; 2025 Carrot Web Game | 端到端类型安全架构</p>
64+
</div>
65+
</div>
66+
</body>
67+
</html>
68+
69+

src/framework/utils/cors-test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { getCorsConfig, isOriginAllowed } from '../../config/cors';
2+
3+
export class CorsTester {
4+
private corsConfig = getCorsConfig();
5+
6+
testOrigin(origin: string) {
7+
const details: string[] = [];
8+
const allowed = isOriginAllowed(origin, this.corsConfig);
9+
details.push(`测试源: ${origin}`);
10+
details.push(`是否允许: ${allowed ? '✅ 是' : '❌ 否'}`);
11+
details.push(`CORS启用状态: ${this.corsConfig.enabled ? '✅ 启用' : '❌ 禁用'}`);
12+
details.push(`环境: ${process.env.NODE_ENV || 'development'}`);
13+
details.push(`允许的源列表:`);
14+
this.corsConfig.origins.forEach((orig, index) => { details.push(` ${index + 1}. ${orig}`); });
15+
return { allowed, config: this.corsConfig, details };
16+
}
17+
18+
printConfig() {
19+
console.log('🔧 CORS配置详情:');
20+
console.log('='.repeat(50));
21+
console.log(`启用状态: ${this.corsConfig.enabled ? '✅ 启用' : '❌ 禁用'}`);
22+
console.log(`环境: ${process.env.NODE_ENV || 'development'}`);
23+
console.log(`最大缓存时间: ${this.corsConfig.maxAge}秒`);
24+
console.log(`允许的方法: ${this.corsConfig.methods.join(', ')}`);
25+
console.log(`允许的头部: ${this.corsConfig.allowedHeaders.join(', ')}`);
26+
console.log(`支持凭据: ${this.corsConfig.credentials ? '是' : '否'}`);
27+
console.log('允许的源:');
28+
this.corsConfig.origins.forEach((origin, index) => { console.log(` ${index + 1}. ${origin}`); });
29+
console.log('='.repeat(50));
30+
}
31+
32+
testCommonOrigins() {
33+
console.log('🧪 测试常见前端源:');
34+
console.log('='.repeat(50));
35+
const commonOrigins = [
36+
'http://localhost:3000',
37+
'http://localhost:5173',
38+
'http://localhost:5174',
39+
'http://127.0.0.1:3000',
40+
'http://127.0.0.1:5173',
41+
'http://127.0.0.1:5174',
42+
'https://tobenot.top',
43+
'https://bwb.tobenot.top',
44+
'https://invalid-domain.com',
45+
];
46+
commonOrigins.forEach(origin => { const result = this.testOrigin(origin); console.log(`${result.allowed ? '✅' : '❌'} ${origin}`); });
47+
console.log('='.repeat(50));
48+
}
49+
}
50+
51+
export function testCors(origin?: string) {
52+
const tester = new CorsTester();
53+
if (origin) {
54+
const result = tester.testOrigin(origin);
55+
console.log('🧪 CORS测试结果:');
56+
result.details.forEach(detail => console.log(detail));
57+
} else {
58+
tester.printConfig();
59+
tester.testCommonOrigins();
60+
}
61+
}
62+
63+

0 commit comments

Comments
 (0)