书籍目录 展开 / 收起

完成核心业务:页面、登录、数据与文件存储

这一篇把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授权说明,权限判断要对每次请求执行,而非只在菜单里隐藏入口。

浏览器登录、接收会话Cookie、再次请求并获服务端验证的时序图
MDN会话图:登录后服务器返回会话Cookie,后续请求携带它并由服务端判断有效性。它只解释身份会话,不替代每条资源的归属授权。 图源:Mozilla Contributors,CC BY-SA 2.5;仅转换WebP并适配尺寸。

数据库只保存需要长期存在的东西

本轮匿名原稿不入库。以后保存历史时,一条记录至少需要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,关闭两个终端结束练习。接下来把固定结果替换为受控的模型调用时,沿用同样的输入验证、等待与失败路径;你只需要增加一层真正的生成能力,而不用再猜页面和接口有没有接通。

登录后可保存阅读进度

下一篇:调试与质量检查:错误定位、安全与测试登录保存进度