完成核心业务:页面、登录、数据与文件存储
这一篇把ClearCopy页面接到Fastify:点击检查,浏览器真的发送请求,结果与解释真的出现在页面里。先用固定行李清单材料走通链路,页面明确写“Local demo”,暂不调用付费模型。你会得到能运行的三个文件,并知道以后登录、历史与文件分别应加在哪里。
为什么先用固定结果?这样可以把页面错误、输入错误和模型输出质量分开。任意新原稿不会被悄悄返回同一段行李清单介绍;演示接口会明确提示只支持预填示例。跑通它也不代表自动文案工具已完成,更不能据此开始收费。
先替换本地API,不碰正式服务
沿用上一节的独立练习项目。在api目录新增app.mjs,内容如下。draftExample与factsExample是本轮可处理的完整示例;验证在服务端再次执行,不能只相信页面的限制。
import Fastify from 'fastify';
export const draftExample = 'This app can let you make trips and check your things. You can use a list again. It works offline.';
export const factsExample = 'Create trips; check off items; reuse lists; work offline.';
export function buildApp() {
const app = Fastify({ bodyLimit: 16 * 1024 });
app.get('/health', async () => ({ ok: true, service: 'clearcopy-api' }));
app.post('/api/check', async (request, reply) => {
const { draft, facts } = request.body ?? {};
if (typeof draft !== 'string' || !draft.trim()) {
return reply.code(400).send({ error: '请填写英文原稿' });
}
if (Array.from(draft.trim()).length > 500) {
return reply.code(400).send({ error: '原稿超过500个字符,请缩短后重试' });
}
if (typeof facts !== 'string' || !facts.trim() || Array.from(facts.trim()).length > 1000) {
return reply.code(400).send({ error: '请填写1000个字符以内的功能事实' });
}
if (draft.trim() !== draftExample || facts.trim() !== factsExample) {
return reply.code(422).send({ error: '本地演示只支持预填示例,尚未连接模型' });
}
return {
demo: true,
revisedText: 'Create a packing list for each trip, check off items as you pack, and reuse your lists for future journeys. Works offline.',
explanations: ['check off表达勾选;保留复用与离线;未添加账号、同步或AI规划。']
};
});
return app;
}
再把api/server.mjs替换成下面的启动文件,便于后续独立测试路由:
import { buildApp } from './app.mjs';
const app = buildApp();
await app.listen({ port: Number(process.env.PORT || 4301), host: process.env.HOST || '127.0.0.1' });
用本地代理把页面请求送给API
在web目录创建astro.config.mjs。这里的Vite代理只用于本地开发;静态构建发布后不会自动附带这个代理,正式部署时需要另行配置相同路径的转发。
import { defineConfig } from 'astro/config';
export default defineConfig({
vite: { server: { proxy: { '/api': 'http://127.0.0.1:4301' } } }
});
Astro配置参考允许配置Vite选项。现在创建web/src/pages/check.astro,让表单请求同一网站的/api/check,本地开发服务器再转给4301端口:
<html lang="en">
<head><meta charset="utf-8" /><title>ClearCopy local demo</title></head>
<body>
<h1>ClearCopy local demo</h1>
<p>Only the prefilled example works. No model connected.</p>
<form id="check-form">
<label for="draft">English draft</label>
<textarea id="draft" name="draft">This app can let you make trips and check your things. You can use a list again. It works offline.</textarea>
<p>最多500个字符</p>
<label for="facts">What your app already does</label>
<textarea id="facts" name="facts">Create trips; check off items; reuse lists; work offline.</textarea>
<button type="submit">Check description</button>
</form>
<p id="status" role="status"></p>
<h2>Revised description</h2><p id="result"></p>
<h2>Why it changed</h2><p id="reason"></p>
<script>
const form = document.querySelector<HTMLFormElement>('#check-form')!;
const button = form.querySelector<HTMLButtonElement>('button')!;
const status = document.querySelector('#status')!;
form.addEventListener('submit', async (event) => {
event.preventDefault();
if (button.disabled) return;
const data = new FormData(form);
const draft = String(data.get('draft') || '').trim();
const facts = String(data.get('facts') || '').trim();
if (!draft || Array.from(draft).length > 500 || !facts || Array.from(facts).length > 1000) {
status.textContent = !draft ? '请填写英文原稿' : Array.from(draft).length > 500 ? '原稿超过500个字符,请缩短后重试' : '请填写1000个字符以内的功能事实';
document.querySelector('#result')!.textContent = '';
document.querySelector('#reason')!.textContent = '';
return;
}
button.disabled = true;
status.textContent = 'Checking…';
document.querySelector('#result')!.textContent = '';
document.querySelector('#reason')!.textContent = '';
try {
const response = await fetch('/api/check', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ draft, facts })
});
const body = await response.json();
if (!response.ok) {
status.textContent = response.status >= 500
? '暂时未完成,请保留原稿后重试'
: body.error || '检查未完成';
return;
}
document.querySelector('#result')!.textContent = body.revisedText;
document.querySelector('#reason')!.textContent = body.explanations.join(' ');
status.textContent = 'Local demo result';
} catch (error) {
status.textContent = '暂时未完成,请保留原稿后重试';
} finally { button.disabled = false; }
});
</script>
</body></html>
启动两个终端:一个在项目根目录运行npm run dev:api,另一个运行npm run dev:web。按Astro终端显示的地址打开/check。保持两个服务都运行;只启动页面时,代理找不到API,会返回失败。
本次在独立目录使用Node 22.23.4、Astro 7.4.4和Fastify 5.12.7构建,并通过真实浏览器提交预填材料,页面显示了完整改写和中文解释。还实际检查了空白输入提示,Fastify注入请求分别得到正常200、空白400、超长400和非示例422。测试未连接模型、账号、付款或数据库。
看着页面完成一次提交
先不要改预填内容,点击Check description。应看到“Local demo result”,正文以“Create a packing list for each trip”开头,解释保留离线和复用。等待时按钮暂时禁用,完成后恢复。结果通过textContent显示,所以即使以后返回带尖括号的文本,也不会直接作为HTML执行。
然后把原稿清空,再提交。页面应提示“请填写英文原稿”,事实输入仍然保留。这次前端会在请求前拦截,后端也保留相同验证,防止有人绕过浏览器直接请求。把原稿改为另一款App,服务端返回422并说明尚未连接模型,不会把无关的固定答案伪装成检查结果。
当前页面还没有复制按钮;你可以选中正文复制。下一项小任务是只为正文添加复制入口,并用实际剪贴板内容核对是否混入解释。不要在连第一份请求都没跑通时,同时要求AI实现所有支付与历史功能。
登录解决“这份数据属于谁”
匿名短试用不必为了展示结果强迫注册。等提供购买额度或私人历史时,页面需要知道正在服务谁,服务端还必须验证这个身份。配图来自MDN Cookie说明:浏览器持有会话信息,后续请求带回服务端,服务端识别对应会话。
这不是把一个userId藏进页面就能完成的登录。浏览器传来“我是用户A”,不能据此读取A的历史;身份必须由服务端验证的会话产生。正式加入登录时使用维护中的身份方案,正确配置会话过期、退出与Cookie保护,再处理每个私人接口的权限。这里不提供一份省略校验的假登录代码,也不把本地演示说成账号系统。
把私人历史的行为先写成一个具体例子:用户A生成过结果,退出后不能继续访问;用户B登录,即使猜到A的记录ID也不能读取。按照OWASP授权说明,权限判断要对每次请求执行,而非只在菜单里隐藏入口。
数据库只保存需要长期存在的东西
本轮匿名原稿不入库。以后保存历史时,一条记录至少需要id、user_id、revised_text与创建时间;是否保存原稿应根据功能需要与明确告知决定,不能为了“以后可能分析”默认全收。
下面是未来使用PostgreSQL客户端时的查询设计示意,本文没有连接真实数据库执行。sessionUserId来自验证后的会话,不能从请求正文取值;参数化查询中的值与SQL分开传入。
const result = await pool.query(
'SELECT id, revised_text FROM checks WHERE id = $1 AND user_id = $2',
[checkId, sessionUserId]
);
代码中同时使用记录ID和归属用户,正是前面用户B不能读A历史的实现方向。pool、会话中间件和表结构需要在真正实现该功能时补齐,这个片段不能单独启动运行。
付费版的“扣一次成功额度”和“写一条成功记录”也要协调。可以用PostgreSQL事务把相互依赖的数据库变化放在一起,避免写了历史却没扣次,或失败后仍扣额度;外部模型请求与数据库事务的时机、重复请求处理会在支付章节继续设计,不能只靠一条余额更新。
文件上传先不加,结果先让用户拿走
ClearCopy首版只接收短文本,没有上传截图、PDF或Word文件的必要。下载一份纯文本结果也不要求你先搭建对象存储。减少文件功能,可以避开文件类型、大小、恶意内容和访问权限的一整组工作,让你先把核心结果做好。
如果以后确有上传需求,先写出“一位用户上传什么,谁可以下载,保留多久”。例如仅允许本人访问自己的说明文档,再设计文件校验和受控下载;不能把私人文档放到永久公开URL,只因为页面需要显示一个链接。
现在已经跑通的是输入到结果的匿名演示,而不是所有系统功能。保留实际可运行的页面和API,关闭两个终端结束练习。接下来把固定结果替换为受控的模型调用时,沿用同样的输入验证、等待与失败路径;你只需要增加一层真正的生成能力,而不用再猜页面和接口有没有接通。