表单与用户交互:把一次提交做成可信的系统 | 自在学
表单与用户交互:把一次提交做成可信的系统
表单看起来很简单:几个输入框,一个按钮,点击以后把数据存进数据库。真正把它放进协作产品,问题马上就变了。用户可能在 JavaScript 还没加载时提交,可能连续点击两次,也可能从两个标签页同时发布;浏览器交来的 FormData 没有类型保证,缓存不会因为数据库发生变化就自动知道该更新,附件的文件名和 MIME 类型也都不能直接相信。
这一章继续做 BoardFlow。我们不再拿“创建用户”这种孤立示例讲语法,而是沿着一张任务卡的生命周期往前走:成员创建任务,保存草稿,编辑后发布,留下评论,再上传附件。每一步都要回答四个问题:
请求在什么边界进入服务器;
服务器怎样验证输入、身份与权限;
成功或失败怎样回到界面;
重试、并发、缓存和部署切换时,结果是否仍然可信。
本章以课程仓库实际安装的 Next.js 16.1.6、React 19.2.3 和 Zod 3.25.76 为基线。React 19 的正式文档使用 useActionState;旧资料常见的 useFormState 仍可能出现在兼容类型中,但不是本章采用的主 API。Zod 3 的错误整理使用 error.flatten()。仓库没有把 react-hook-form 声明为直接依赖,因此正文从浏览器表单、React Action 与服务端验证建立完整主线,不假设额外表单库已经可用。
完成本章后,BoardFlow 的写入路径会形成下面这条闭环:
先把名字说准:Server Function 与 Server Action
很多文章把所有带 'use server' 的函数都叫 Server Action。这样讲入门快,却会把两个层次揉在一起。
Server Function 是在服务器上执行、可以被 React 跨网络引用的异步函数。Server Action 是被放进 Action 语境的 Server Function,例如传给表单的 action、按钮的 formAction,或在 Transition 中调用。换句话说,Server Action 描述的是“这个 Server Function 被拿来处理一次用户动作”,不是另一种函数语法。
当 action 的值是函数时,React 会接管提交:浏览器字段被整理为 FormData,请求按 POST 语义发送,返回的服务器组件结果与客户端状态再被 React 合并到当前界面。这里没有“浏览器直接调用服务器内存里的函数”。函数引用在客户端会变成协议中的引用,服务器仍然把它当作一个可到达的请求入口。
Server Action 不是隐藏的内部函数。只要它能进入客户端构建产物或被表单引用,就应把它看成一个公开的 POST 边界。攻击者不需要先看见按钮,也能构造请求。因此,隐藏按钮、只在 Server Component 中调用、使用 TypeScript 类型,都不能替代函数内部的认证、授权和输入验证。
下面的交互实验会把一次提交拆成浏览器收集字段、Action 请求、服务端校验、数据库写入和 React 回传五段。稍后生成资产时,这里会替换成真正的 iframe。
1 下列哪个描述准确区分了 Server Function 与 Server Action?
A. 带 use server 的文件都是 Server Component B. Server Action 是在 Action 语境中使用的 Server Function C. Server Function 只能由表单触发 D. Server Action 不会形成网络入口
从一张能提交的任务卡开始
先做最小但真实的版本:成员在看板里输入标题、说明和优先级,提交后创建任务并进入详情页。我们先不加客户端 Hook,因为服务器表单本身就应该成立。
建议把写入函数放在离路由较近、名字明确的文件里:
app/
└── boards/
└── [boardId]/
├── actions.ts
└── tasks/
└── new/
└── page.tsx
lib/
├── auth/
│ └── require-board-permission.ts
└── db.ts
写入函数负责完整的服务端边界
// app/boards/[boardId]/actions.ts
'use server'
import { redirect } from 'next/navigation'
import { updateTag } from 'next/cache'
import { db } from '@/lib/db'
import { requireBoardPermission } from '@/lib/auth/require-board-permission'
function readText ( formData : FormData , name : string
这个例子有意保留了最朴素的验证,方便先看清请求路径。boardId 虽然来自服务器渲染时的 bind,仍然会随请求回来,所以函数再次用它查权限。数据库写完后,updateTag 让当前成员接下来读到新的任务列表,最后才重定向。
Server Component 直接输出原生表单
// app/boards/[boardId]/tasks/new/page.tsx
import { createTask } from '../../actions'
type NewTaskPageProps = {
params : Promise < { boardId : string } >
}
export default async function NewTaskPage ({
params ,
} : NewTaskPageProps
bind(null, boardId) 会把 boardId 放到 FormData 之前,成为函数的第一个参数。它比隐藏字段少暴露一段页面标记,也能保持渐进增强,但它不让参数变得可信。真正的可信关系来自服务端重新验证“当前用户是否能在这个 board 创建任务”。
这里也没有手写 method="post" 或 encType。当 action 是函数时,React 会管理方法和编码。普通文本表单与后面的文件表单都不应再用字符串表单时代的属性去覆盖它。
到这一步,即使浏览器禁用 JavaScript,表单仍然可以提交、创建任务并跳转。客户端代码应当增强这条路径,而不是成为它成立的前提。
'use server' 放在哪里,边界就画在哪里
'use server' 有两种常用位置。它们都能创建 Server Function,但可见范围和导入方式不同。
内联函数适合紧贴一个 Server Component
// app/boards/[boardId]/tasks/[taskId]/page.tsx
import { revalidatePath } from 'next/cache'
import { db } from '@/lib/db'
import { requireBoardPermission } from '@/lib/auth/require-board-permission'
type TaskPageProps = {
params : Promise < { boardId : string ; taskId : string }
函数闭包捕获了 boardId 与 taskId。Next.js 会对需要发到客户端的闭包值加密,但“被加密”不等于“已经授权”。函数执行时仍然要读取会话并验证成员关系。
文件级指令适合复用和 Client Component 导入
// app/boards/[boardId]/task-actions.ts
'use server'
import { updateTag } from 'next/cache'
import { db } from '@/lib/db'
import { requireBoardPermission } from '@/lib/auth/require-board-permission'
export async function completeTask (
boardId : string ,
taskId : string ,
) : Promise
文件顶部使用指令后,运行时导出项必须是异步函数。共享常量、Zod schema 和普通同步工具函数应放到另一个没有 'use server' 的模块,再由 Action 文件导入。类型导出在编译后会被擦除,可以使用 export type,但不要从这个文件导出 initialState 之类的运行时对象。
Client Component 不能在组件函数内部声明内联 'use server' 函数。需要在客户端引用时,应从文件级 Server Function 模块导入:
// app/boards/[boardId]/complete-task-button.tsx
'use client'
import { useTransition } from 'react'
import { completeTask } from './task-actions'
type CompleteTaskButtonProps = {
boardId : string
taskId : string
}
export function CompleteTaskButton ({
boardId ,
taskId
这里的 startTransition 把调用放进 Action 语境,并让 React 提供 pending 状态。对于有字段的提交,优先使用语义完整的表单;按钮式命令才适合这种调用。
渐进增强不是口号:脚本没到也能提交
渐进增强关注的不是“页面完全不用 JavaScript”,而是核心动作在脚本迟到、加载失败或水合尚未完成时仍有结果。Server Component 输出并直接绑定 Server Action 的函数表单,可以由浏览器在没有 JavaScript 时提交。Client Component 中引用 Server Action 的表单,如果脚本只是尚未加载,Next.js 会把提交排队并优先完成水合;使用 useActionState 的 permalink 后,React 还可以在 Server Function 表单于水合前提交时导航到一个稳定页面,并把返回状态接回同一表单。
permalink 解决的是动态页面的“同一个表单在哪里”
假设新建任务表单也会出现在动态活动流里。活动流 URL 可能带筛选参数,不适合成为提交后的稳定落点。我们给表单一个永久地址:
// app/boards/[boardId]/tasks/new/task-draft-form.tsx
'use client'
import { useActionState } from 'react'
import { saveTaskDraft } from '../../task-form-actions'
import type { TaskFormState } from '@/lib/tasks/task-form-state'
const initialState : TaskFormState = {
status : 'idle' ,
message : '' ,
fieldErrors :
对应的 Server Function 签名必须是:
export async function saveTaskDraft (
boardId : string ,
previousState : TaskFormState ,
formData : FormData ,
) : Promise < TaskFormState > {
// boardId 来自 bind
// previousState 与 formData 由 useActionState 依次补入
return previousState
}
permalink 不是成功后的跳转地址,也不是随手填的字符串。它只参与“页面尚未完成水合时怎样延续这次 Action 状态”;页面可交互以后,这个参数不再影响提交。目标 URL 必须渲染同一个表单、同一个 Server Function,并保持相同的 permalink;否则 React 无法把服务器返回的状态接回正确组件。
渐进增强要求服务器返回有意义的结果。只在客户端 onSubmit 里调用 fetch、再用本地 state 拼出成功提示,脚本失效时整条路径也会消失。关键写入应先有服务端表单路径,再添加客户端体验。
浏览器会按字段名组装 FormData。服务端读取时,formData.get(name) 的结果是 string | File | null;复选框、多选列表等重复字段要用 getAll。TypeScript 里的 as string 只会让编译器闭嘴,不会阻止攻击者发来文件、空值或任意字符串。
下面这些值都应当视为不可信:
文本框、选择框和复选框;
URL 动态段与查询参数;
隐藏字段;
通过 bind 传回来的参数;
文件名与 file.type;File.size 可限制收到的原始字节,却不能证明内容类型或解压后的资源消耗;
客户端生成的幂等键。
先把浏览器值整理成明确的原始对象
// lib/tasks/task-input.ts
import { z } from 'zod'
export const taskInputSchema = z . object ( {
title : z
. string ()
. trim ()
. min ( 2 , '标题至少需要 2 个字符' )
. max ( 80 ,
这个 schema 使用 Zod 3.25.76 的 API。safeParse 不会因为普通字段错误抛异常,而是返回一个判别联合;错误可以通过 flatten() 整理为字段错误。
import {
taskInputSchema ,
taskValuesFromFormData ,
} from '@/lib/tasks/task-input'
const rawValues = taskValuesFromFormData (formData)
const parsed = taskInputSchema . safeParse (rawValues)
if ( ! parsed . success) {
return {
status : 'error' ,
message : '请检查标出的字段' ,
不要对整个表单直接写 Object.fromEntries(formData) 就以为完成了转换。重复的 labelId 会被压成单值,文件字段仍然是 File,数字、布尔值和日期也不会自动变成业务类型;Server Action 表单还可能带有 $ACTION_ 前缀的协议字段。最好显式列出允许字段,让“浏览器字段怎样进入 schema”成为一段能审查的代码。
HTML 约束负责及时提醒,服务器负责最终裁决
required、minLength、maxLength 和合适的输入类型能在浏览器里更早提示用户,也给辅助技术提供语义。它们不是安全边界,因为自定义请求可以完全绕开页面。
2 读取 FormData 时,下列哪些做法是正确的?
用 Zod 3 和 useActionState 建立稳定反馈
有了 schema,下一步不是把每个错误都 throw 出去。标题太短、描述太长、发布条件未满足,这些都是用户可以修正的预期失败 。Action 应返回一个稳定、可序列化的状态,让表单知道错误属于哪个字段,并尽可能保留用户刚才的输入。
先定义表单与 Action 之间的协议
// lib/tasks/task-form-state.ts
export type TaskFormValues = {
title : string
description : string
priority : string
labelIds : string []
}
export type TaskFormState = {
status : ' idle ' | '
这个文件没有 'use server',所以可以安全导出对象。状态只包含字符串、字符串数组、普通对象和数字。数据库客户端实例、Error、类实例、React 元素和带方法的领域对象都不应塞进 Action state。
// app/boards/[boardId]/task-form-actions.ts
'use server'
import { updateTag } from 'next/cache'
import { db } from '@/lib/db'
import { requireBoardPermission } from '@/lib/auth/require-board-permission'
import {
taskInputSchema ,
taskValuesFromFormData ,
} from '@/lib/tasks/task-input'
import type { TaskFormState } from '@/lib/tasks/task-form-state'
export
一旦函数交给 useActionState,签名就从原来的 action(formData) 变成 action(previousState, formData)。这里又通过 bind 提前放入 boardId,所以最终顺序是 boardId → previousState → formData。参数顺序写错,是这类表单最常见也最隐蔽的错误之一。
Client Component 把错误放回对应字段
// app/boards/[boardId]/tasks/new/task-draft-form.tsx
'use client'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { saveTaskDraft } from '../../task-form-actions'
import {
initialTaskFormState ,
type TaskFormState ,
} from '@/lib/tasks/task-form-state'
function FieldError ({
id ,
这里使用 revision 作为 form 的 key。每次 Action 返回后,表单会按服务器给出的 values 重新挂载:验证失败时恢复刚才的输入,保存成功时显示空白的新表单。真实产品也可以改用受控字段,但必须明确处理“成功后清空”和“失败后保留”这两个结果。React 的函数 Action 成功完成后会重置非受控字段,不能把字段保留交给偶然行为。
状态机可以压缩成三条边:
idle → error:服务端拒绝预期输入,返回字段错误与原值;
idle → saved:数据库写入成功,返回成功消息与空值;
任意状态 → 抛出异常:交给最近的 error.tsx,不伪装成字段错误。
isPending 来自当前 useActionState,适合让整个表单显示忙碌状态;useFormStatus 读取最近父表单,适合让按钮、状态条等子组件响应提交。两者作用范围不同,不需要为了“统一”而只保留一个。
useFormStatus 来自 react-dom。在 React 19.2.3 中,它返回 pending、data、method 和 action:
它有一个很容易误解的限制:Hook 从组件所在位置向上 查找最近的表单。一个组件在自己的返回值里创建 <form>,然后在同一个组件顶部调用 useFormStatus,读不到这个新表单的状态,因为那个表单不是它的父节点。
// 错误示意:这里读到的是更外层表单,或者始终是空闲状态
'use client'
import { useFormStatus } from 'react-dom'
export function TaskForm () {
const { pending } = useFormStatus ()
return (
< form >
< button disabled = { pending } > 保存 </ button >
正确做法是把需要状态的部分拆成表单子组件:
'use client'
import { useFormStatus } from 'react-dom'
export function SubmitFeedback () {
const { pending , data } = useFormStatus ()
const titleValue = data ?. get ( 'title' )
const title =
typeof titleValue === 'string' ? titleValue
按钮文字从“保存任务”变成“保存中…”,同时 role="status" 给辅助技术一条非阻断提示。不要只把按钮改成灰色;颜色本身不能说明发生了什么。对于耗时稍长的提交,还可以在 form 上设置 aria-busy。
禁用 pending 按钮只是一层用户体验保护。它减少连续点击,却不能阻止刷新重试、两个标签页、移动网络重传或直接构造请求。真正的重复写入要靠后文的服务端幂等设计。
3 在返回 form 的同一个组件里调用 useFormStatus,一定能读到这个 form 的 pending 状态。
同一张表单也可以有多种动作
编辑任务时,“保存草稿”和“发布任务”通常共享标题、说明、优先级等字段,却有不同的业务结果。HTML 按钮支持 formAction,React 也允许它接收另一个 Server Action。这样不必复制整张表单,也不必用客户端点击事件手工拼请求。
两个 Action 必须分别守住自己的边界
// app/boards/[boardId]/task-lifecycle-actions.ts
'use server'
import { redirect } from 'next/navigation'
import { updateTag } from 'next/cache'
import { db } from '@/lib/db'
import { requireBoardPermission } from '@/lib/auth/require-board-permission'
import {
taskInputSchema ,
taskValuesFromFormData ,
} from '@/lib/tasks/task-input'
export async
这个版本为了突出多动作结构,把验证失败交给异常边界。生产表单通常会像上一节那样返回字段状态。无论反馈方式是什么,两个函数都必须各自认证、授权和验证,不能因为“发布按钮只有管理员看得见”就让 publishTask 少一道检查。
// app/boards/[boardId]/tasks/[taskId]/task-editor.tsx
import {
publishTask ,
saveExistingDraft ,
} from '../../task-lifecycle-actions'
import { TaskActionButtons } from './task-action-buttons'
type TaskEditorProps = {
boardId : string
task : {
id : string
title : string
description
// app/boards/[boardId]/tasks/[taskId]/task-action-buttons.tsx
'use client'
import { useFormStatus } from 'react-dom'
type FormAction = ( formData : FormData ) => Promise < void >
type TaskActionButtonsProps = {
saveDraftAction : FormAction
publishAction : FormAction
按下 Enter 时,浏览器通常走表单自身的 action,也就是保存草稿。明确设置默认 Action 能避免键盘提交意外触发发布。useFormStatus().action 则能指出这次点击实际选择了哪个函数。这里还把 Client Component 的函数 props 命名为 saveDraftAction 和 publishAction;这种 action / Action 后缀让 Next.js 的 TypeScript 检查器能把它们识别为可跨边界传递的 Server Function,而不是普通客户端函数。
还有一种做法是让两个按钮共享一个 Action,通过 name="intent" 和 value="publish" 分支。它适合两个动作的大部分权限和事务都相同的场景;如果发布比保存需要更高权限,拆成两个函数更容易审查。
认证、授权与 CSRF 是三道不同的门
表单字段通过 Zod,不代表当前请求有权修改这张任务。安全检查至少分成三个问题:
认证 :请求者是谁,会话是否仍然有效;
授权 :这个人能否在这个 board 对这条 task 执行当前动作;
请求来源保护 :浏览器是否被其他站点诱导携带 Cookie 发起写请求。
它们不能互相替代。登录用户也可能越权,合法 Origin 也可能提交恶意字段,Zod 验证成功也不能证明成员关系。
每个 Action 在数据边界重新授权
// lib/auth/require-board-permission.ts
import { redirect } from 'next/navigation'
import { db } from '@/lib/db'
import { verifySession } from '@/lib/auth/session'
type BoardPermission =
| ' task:create '
| ' task:edit '
| ' task:publish '
| '
真实查询还应把 taskId 和 boardId 一起放进更新条件,避免只验证“用户属于某个 board”,却更新了另一个 board 的任务。updateMany({ where: { id, boardId } }) 返回受影响行数后,也应检查是否为 1;为 0 可能表示资源不存在、已改变状态或发生竞争。
Next.js 的 Origin 检查与 allowedOrigins
Server Actions 只接受 POST。收到带 Origin 的请求时,Next.js 会比较它与 Host 或 X-Forwarded-Host;不匹配时默认中止 Action。这是框架提供的 CSRF 防线。当前 16.1.6 处理器遇到缺少 Origin 的旧客户端请求会记录警告而不是直接拒绝,因此 SameSite Cookie、认证与 Action 内授权仍不能省略。反向代理确实会让公开 Origin 与应用看到的 Host 不同,才需要在配置中增加明确的允许来源:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig : NextConfig = {
experimental : {
serverActions : {
allowedOrigins : [
'boardflow.example.com' ,
'*.internal.boardflow.example.com' ,
] ,
},
},
}
export default nextConfig
allowedOrigins 写主机模式,不要为了排除代理问题就放开到不受控域名。还要确认代理正确覆盖 Host / X-Forwarded-Host,并清理客户端伪造的转发头。这个配置只调整来源检查,不会替你完成用户授权。
不要从隐藏字段读取 userId,也不要让客户端提交角色后直接写数据库。用户身份应来自服务器验证过的会话;资源权限应由服务器查询成员关系。隐藏字段适合携带普通上下文,不适合携带“相信我有权限”的结论。
把预期失败与程序故障分开
用户把标题写得太短,与数据库连接中断,不应显示成同一种“提交失败”。我们可以用一个简单判断来分流:
redirect 要放在 try/catch 外面
redirect 通过抛出一个特殊控制流信号终止当前渲染。若把它放进宽泛的 try/catch,就可能被当成普通错误吞掉。
// app/boards/[boardId]/publish-actions.ts
'use server'
import { redirect } from 'next/navigation'
import { updateTag } from 'next/cache'
import { db } from '@/lib/db'
import { requireBoardPermission } from '@/lib/auth/require-board-permission'
type PublishState = {
status : ' idle ' |
验证与状态竞争是调用者能理解的结果,所以返回 state。数据库异常不是用户改一个字段就能修复的,记录服务端上下文后继续抛出。日志里不要写完整 FormData、Cookie、令牌或附件内容。
error.tsx 处理意外故障
// app/boards/[boardId]/error.tsx
'use client'
import { useEffect } from 'react'
export default function BoardError ({
error ,
reset ,
} : {
error : Error & { digest ?: string }
reset : () =>
reset() 会尝试重新渲染错误边界所在的路由段,不会自动重放刚才失败的写入。用户回到表单后应先看服务器当前事实,再决定是否重新提交;这也是写入仍需幂等的原因。对外文案保持克制,不把 SQL、堆栈或资源是否存在等信息直接交给用户。服务端日志用请求编号、用户编号和资源编号关联排查,但应避开敏感值。
数据写完后,界面怎样看到新结果
数据库成功写入只是第一步。若任务列表来自带标签的缓存,界面可能继续显示旧数据;若当前客户端树里还有本地状态,也可能需要重新请求服务器组件。Next.js 16.1.6 提供的几个 API 解决的是不同问题。
updateTag 与 refresh 只能在 Server Action 中使用。revalidateTag 和 revalidatePath 可以在 Server Function 与 Route Handler 中调用,但不能放进 Client Component、Proxy、渲染过程或缓存函数内部。Next.js 16 推荐给 revalidateTag 传 'max',让下次访问先返回旧内容、再后台更新;单参数的立即过期用法已弃用,安装类型也要求第二个参数。
标签必须先由读取路径通过 fetch(..., { next: { tags } }),或在启用 Cache Components 后由缓存函数的 cacheTag 绑定。随手调用一个从未绑定到缓存数据的字符串,不会建立关系,也不会让普通数据库查询凭空获得缓存能力。本章示例假设任务与评论读取端已经使用相同标签;完整缓存设计留到缓存章节。
下面的实验台会让读者选择“数据是否有 tag、是否必须 read-your-writes、是否要换页”,再观察不同 API 的可见结果。
不要每次写入都把四个 API 全调用一遍
添加评论时,评论列表有明确标签,而且用户期望马上看到刚写的内容:
// app/boards/[boardId]/comment-actions.ts
'use server'
import { updateTag } from 'next/cache'
import { db } from '@/lib/db'
import { requireBoardPermission } from '@/lib/auth/require-board-permission'
export async function addComment (
boardId : string ,
taskId : string ,
formData :
如果“本周任务数”允许短暂显示旧值,可以在写入后使用:
import { revalidateTag } from 'next/cache'
revalidateTag ( `board:` + boardId + `:weekly-stats` , 'max' )
如果一个写入改变了某个路由的整体结构,例如任务从草稿区移动到已发布区,而且数据没有统一标签,可以按路径处理:
import { revalidatePath } from 'next/cache'
revalidatePath ( `/boards/` + boardId)
如果当前页面读的是不缓存的个性化数据,只需要重新拉取当前 RSC 树:
'use server'
import { refresh } from 'next/cache'
import { db } from '@/lib/db'
import { verifySession } from '@/lib/auth/session'
export async function dismissTaskNotice ( noticeId : string ) {
const session = await verifySession ()
if ( !
refresh() 不会让带标签的 Data Cache 自动过期。updateTag 只处理命中该标签的数据;如果 Action 返回时自然重渲染了这些读取端,通常不必再机械调用 refresh。如果当前客户端还依赖没有被该标签覆盖的动态数据,refresh 仍可能有用。先找出数据的缓存方式和界面读取范围,再选最小组合。
顺序保持固定
先完成认证、授权和输入验证。被拒绝的请求不应触碰数据库,也不需要失效缓存。
在事务中提交真正的数据变化。只有数据库确认成功,后续界面更新才有事实依据。
根据读取方式选择 updateTag、revalidateTag、revalidatePath 或 refresh。不要靠“多调用几个更保险”来掩盖缓存模型不清楚。
最后调用 redirect。因为 redirect 会终止当前控制流,放在它之后的失效代码不会执行。
乐观更新只适合能够撤销的承诺
评论是一个适合 useOptimistic 的场景。用户点击发送后,我们可以先把一条带“发送中”标记的评论放进列表;服务端成功后,真正的评论从服务器数据回来,临时项消失;失败时临时项回滚并显示错误。
发布任务、扣款、删除整个看板则不适合只靠乐观界面制造“已经完成”的感觉。这些动作影响大,失败后的恢复成本也高。
用服务器结果校正临时状态
// app/boards/[boardId]/tasks/[taskId]/comment-list.tsx
'use client'
import { useOptimistic , useState } from 'react'
import { addComment } from '../../comment-actions'
type Comment = {
id : string
body : string
authorName : string
pending ?: boolean
}
传给表单 action 的异步函数本身处于 Action 语境,所以可以在提交开始时调用 appendOptimistic。若是在普通点击回调里触发乐观更新,应把它放进 startTransition。
useOptimistic 没有替代数据库数据。它只在异步 Action 进行期间提供一个临时视图;Action 完成后,状态回到服务器传入的 comments。上一节的 updateTag 会让成功写入的评论随新的服务器组件结果回来。服务端返回 { ok: false } 时,代码显示可修正消息;服务端直接抛错时,错误交给边界。只要基础 comments 没有被错误地改写,这两条失败路径都会让临时项回退。
输入框在这里使用受控 draft:成功后主动清空,失败时保留原文。这个乐观包装函数依赖客户端完成水合;需要覆盖“完全无 JavaScript 也能评论”的产品,应另外保留直接指向 addComment 的服务器表单路径,再把乐观列表当作客户端增强。不要把一个只能在浏览器运行的包装函数误写成渐进增强基线。
评估一个动作是否适合乐观更新,可以问三个问题:
成功概率是否足够高;
临时结果能否明确标成“处理中”;
失败时能否无损撤销,并告诉用户下一步。
乐观 UI 不能证明服务端成功,也不能承担幂等职责。不要因为界面已经出现一条评论,就跳过服务端结果;也不要用客户端生成的临时 ID 直接当成数据库主键和授权凭据。
禁用按钮不等于防重复写入
Next.js 客户端当前会逐个派发并等待 Server Function,但官方明确把它列为可能改变的实现细节,不能当作全局锁。两个标签页是两个客户端,用户可以刷新重提,代理可能重试,攻击者也能直接发 POST。即使按钮在第一毫秒就变成 disabled,服务器仍可能同时收到两次语义相同的请求。
真正的目标不是“尽量少点两次”,而是:同一个业务意图被重复执行时,数据库只产生一个结果;相同幂等键若搭配了不同内容,服务器明确拒绝。
幂等键要与用户、载荷一起核对
BoardFlow 可以为“创建任务”生成一次性意图键。数据库保存四类信息:
CREATE TABLE mutation_receipt (
id UUID PRIMARY KEY ,
actor_id UUID NOT NULL ,
idempotency_key UUID NOT NULL ,
payload_fingerprint TEXT NOT NULL ,
status TEXT NOT NULL CHECK ( status IN ( 'processing' , 'completed' )),
response_json JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now () ,
completed_at TIMESTAMPTZ
唯一约束的作用范围是 actor_id + idempotency_key。同一把键不能让另一个用户读取结果;payload_fingerprint 则阻止调用者拿已经成功的键换一份任务内容。
服务端渲染表单时生成键:
// app/boards/[boardId]/tasks/new/idempotent-task-form.tsx
import { randomUUID } from 'node:crypto'
import { redirect } from 'next/navigation'
import { createTaskOnce } from '../../idempotent-task-actions'
export function IdempotentTaskForm ({
boardId ,
} : {
boardId : string
}) {
const idempotencyKey
隐藏字段仍然不可信。服务器必须验证它是 UUID,再让数据库唯一约束承担最终裁决。幂等键只表达“这是哪一次意图”,不表达“谁有权限”。这个外层表单为保持示例聚焦,只把预期失败重定向成稳定状态码;实际编辑表单应沿用前文的 useActionState,返回字段错误并保留用户输入。
任务与回执放进同一个事务
// app/boards/[boardId]/idempotent-task-actions.ts
'use server'
import { createHash , randomUUID } from 'node:crypto'
import { updateTag } from 'next/cache'
import { z } from 'zod'
import { db } from '@/lib/db'
import { requireBoardPermission } from '@/lib/auth/require-board-permission'
import {
taskInputSchema ,
第一次事务会同时创建回执和任务。第二个并发请求在唯一约束处失败,随后读取第一次已经提交的回执:指纹相同就返回原任务编号,指纹不同就拒绝。标签编号先排序,避免同一组标签仅因提交顺序不同就产生不同指纹。数据库驱动的唯一错误判断还要核对冲突约束或字段,不能把任务表上另一个唯一冲突误判成幂等重放,也不要靠匹配英文错误消息。
重放路径再次调用 updateTag,用于覆盖“事务已经提交,但进程在第一次缓存失效前退出”的窄窗口。回执需要设置与客户端重试周期匹配的保留时间和清理任务;清理太早会把迟到重试重新当成新请求,永久保留则会让表无限增长。
如果创建任务还要发邮件或推送,不能把网络调用塞进事务后又宣称“恰好一次”。更稳妥的方式是在同一事务写一条 outbox 事件,由后台消费者发送,并让消费者本身也按事件编号幂等。这样数据库事实与待发送事件不会一半成功、一半丢失。
客户端 pending、服务端幂等键、数据库唯一约束和事务分别解决不同层次的问题。pending 减少误操作;键标识意图;唯一约束裁决并发;事务让回执与业务数据一起提交。少一层都可能在特定故障下露出缝隙。
文件上传是资源与安全问题
FormData 可以包含 File,Server Action 也能接收文件,但“能收到”不等于“适合把所有文件都穿过 Action”。Next.js 16.1.6 的 Server Action 请求体默认上限是 1 MB ,而且 multipart 边界与其他字段也计入总大小。一个标称 1 MB 的文件,整个请求通常会超过默认限制;在当前 Node Action 处理器中,超过限制会以 413 终止,托管平台还可能有更小的上游限制。
对于 BoardFlow,我们把附件分成两类:
函数表单不需要手写 encType
import { uploadSmallAttachment } from './attachment-actions'
export function AttachmentForm ({
boardId ,
taskId ,
} : {
boardId : string
taskId : string
}) {
const upload = uploadSmallAttachment . bind ( null , boardId
传统字符串 URL 表单需要 encType="multipart/form-data"。当 action 是函数时,React 会处理编码,手写 method 或 encType 反而会被覆盖并触发警告。accept 只是文件选择器提示,攻击者可以绕开。
服务器检查真实内容,再生成自己的存储键
// app/boards/[boardId]/tasks/[taskId]/attachment-actions.ts
'use server'
import { randomUUID } from 'node:crypto'
import { updateTag } from 'next/cache'
import { db } from '@/lib/db'
import { storage } from '@/lib/storage'
import { requireBoardPermission } from '@/lib/auth/require-board-permission'
const MAX_ACTION_FILE_BYTES = 750_000
type
这段代码先用 boardId + taskId 确认资源归属,没有用原始文件名做对象键,也没有仅靠 entry.type 判断格式。entry.size 适合限制服务器实际收到的原始字节;它不能告诉我们压缩包展开后多大,也不能证明文件内容安全。PNG 和 PDF 的文件头检查只是最小示范;真实系统还应使用成熟解析器,限制解压后的大小与图片像素,做恶意软件扫描,并在扫描通过前保持 quarantine 状态。
对象存储与数据库不是同一个事务。示例在登记失败时尽力删除对象,同时记录清理失败;生产环境还应按 quarantine 前缀和上传会话定期回收孤儿对象。公开下载时应设置安全的 Content-Disposition,不要让上传内容在应用主域名下随意以内联 HTML 执行。
调大 bodySizeLimit 要知道代价
如果业务明确需要略大 Action 请求,可以配置:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig : NextConfig = {
experimental : {
serverActions : {
bodySizeLimit : '2mb' ,
},
},
}
export default nextConfig
这个设置改变请求解析上限,不会给文件扫描、流式传输或内存容量。十个并发 20 MB 上传可能让应用进程同时缓冲大量数据,所以“把限制调到 50 MB”通常不是大文件方案。
大文件更适合这条流程:
客户端先调用 Server Action,请求一个短期、限定对象键和大小的预签名上传地址;Action 验证会话、任务权限与配额。
浏览器把文件直接上传到对象存储。应用服务器不读取整份文件,也不替客户端提供无限制的存储凭据。
上传完成后,客户端把对象键和上传会话编号提交给 finalize Action。服务器重新检查对象大小、服务端识别的类型和所有者。
对象先进入隔离区,异步扫描通过后才标记为可下载;失败或超时对象由清理任务删除。
不要把用户上传内容直接写进代码仓库的 public 目录。无服务器或多实例部署里,本地磁盘通常不持久,也不在实例之间共享;公开目录还会绕过隔离和下载授权。对象存储与数据库元数据才是合适边界。
序列化、闭包与部署会在边界处相遇
Server Function 跨越了客户端与服务器,参数和返回值必须符合 React 的序列化协议。为了让表单状态容易维护,本章一直把返回值限制在普通对象、字符串、数字、布尔值、数组与 null。
闭包加密不是访问控制
内联 Server Action 捕获的变量需要随服务器组件结果到达客户端。Next.js 会加密这些闭包值,并且默认在每次构建生成新的私钥。它降低明文暴露,但没有改变两件事:
请求者仍可调用这个 Action;
Action 仍要重新验证会话、资源与操作权限。
自托管到多个实例时,各实例必须使用同一把构建密钥,否则 A 实例生成的闭包载荷交给 B 实例可能无法解密。NEXT_SERVER_ACTIONS_ENCRYPTION_KEY 必须是 Base64 编码的 16、24 或 32 字节 AES 密钥,Next.js 默认生成 32 字节密钥。它在构建时嵌入产物,因此要在 next build 时由 secret 管理器提供,并让同一构建的所有实例使用同一产物;滚动窗口中需要共存的构建也应按密钥轮换方案保持兼容。不要把密钥提交进仓库,也不要在日志中打印。
旧页面与新服务器可能不认识同一个 Action
Server Action 标识与构建产物绑定。用户长时间开着旧标签页,期间服务器切到新构建,再点击旧页面按钮,就可能提交一个新版本已不认识的 Action 标识。滚动发布如果同时运行多个版本,也会出现请求落到不匹配实例的情况。
可以从五层降低版本偏斜:
同一版本的所有实例使用相同代码产物与 Action 加密密钥;
发布期间保留旧版本处理旧客户端,或使用版本感知的路由与粘性策略;
给发布设置 deploymentId,让 Next.js 在资源请求与导航中识别部署版本;
多实例共享缓存或协调 tag 失效,避免一个实例更新后另一个实例继续提供旧缓存;
对找不到旧 Action 的结果提供可恢复界面,引导用户刷新,而不是让用户反复点击。
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig : NextConfig = {
deploymentId : process . env . DEPLOYMENT_VERSION ,
experimental : {
serverActions : {
bodySizeLimit : '2mb' ,
allowedOrigins : [ 'boardflow.example.com' ] ,
},
配置 deploymentId 后,本地静态资源 URL 会带 ?dpl=<deploymentId>,客户端导航与 Server Action 请求会携带 x-deployment-id。服务器发现导航版本不匹配时会触发整页加载,让浏览器回到同一发布版本的资源。它不会替服务器保存所有旧 Action 实现;负载均衡、旧版本保留时间和密钥分发仍然是部署架构的一部分。
静态导出没有 Server Action 运行时
如果项目使用 output: 'export' 生成纯静态站点,就没有 Next.js 服务器接收 Server Action。此时表单必须提交到外部 API、托管表单服务或另一个动态后端。不要在开发服务器里看到 Action 能运行,就认为导出的静态文件也带着服务器。
bind 参数、隐藏字段和加密闭包的共同点是:它们都能携带上下文,却都不能替代授权。真正可信的结论应在 Action 执行时由服务器根据会话与数据库关系重新计算。
用测试走完真实提交路径,也让每个人能操作
表单测试不能只调用一个函数然后断言返回值。我们需要分层:纯 schema 测输入,领域服务测事务与权限,浏览器测试真正的提交、导航和无 JavaScript 路径。当前课程仓库没有声明 Vitest 或 Playwright 为直接依赖,下面代码展示的是团队加入测试工具后的组织方式,不假装这些命令今天已经存在。
schema 测试锁定输入边界
// lib/tasks/task-input.test.ts
import { describe , expect , it } from 'vitest'
import {
taskInputSchema ,
taskValuesFromFormData ,
} from './task-input'
describe ( 'taskInputSchema' , () => {
it ( '整理空格并保留多个标签字段' , () => {
const formData = new
领域服务测试还应覆盖:
viewer 调用创建、发布和上传时被拒绝;
task 属于另一个 board 时,即使 taskId 存在也不能更新;
label 属于另一个 board 时,不能通过 connect 关联;
相同幂等键并发两次只创建一行任务;
相同键搭配不同 payload 返回冲突;
数据库写入失败时不生成 completed 回执;
附件登记失败时隔离对象被清理。
浏览器测试不要依赖内部 Action URL
Action 标识属于构建协议,测试不应硬编码它。通过可见表单提交,才能一起验证字段名、Server Action 绑定、缓存更新与重定向。
// e2e/create-task.spec.ts
import { expect , test } from '@playwright/test'
test ( '成员创建任务并进入详情页' , async ({ page }) => {
await page . goto ( '/boards/demo/tasks/new' )
await page . getByLabel ( '任务标题' ) . fill ( '整理发布检查表'
还应增加两个浏览器场景:先提交非法数据,确认错误与字段关联且输入被保留;再用两个并发页面提交相同幂等键,确认最终只有一个任务。
可访问性从 HTML 关系开始
一个可用表单至少要做到:
每个控件有可见 label,htmlFor 与 id 对应;
相关字段用 fieldset 和 legend 分组;
错误字段设置 aria-invalid="true";
aria-describedby 指向字段的帮助与错误文本;
提交结果用 role="status" 或合适的 live region,不只改变颜色;
验证失败后把焦点移到错误摘要,让键盘与读屏用户知道发生了什么;
多动作按钮文字明确,键盘按 Enter 的默认动作安全;
pending 状态有文字,而且不会永久锁住表单。
前面的 FieldError 只通过 aria-describedby 与输入框关联,没有给每个字段都加 role="alert",是为了避免一次提交同时触发多段抢占式播报。错误摘要集中宣布一次,再让用户移动到字段时听到具体错误;最终效果仍要用目标读屏软件验证。
下面是一个聚焦错误摘要的最小组件:
'use client'
import { useEffect , useRef } from 'react'
import type { TaskFormState } from '@/lib/tasks/task-form-state'
export function TaskErrorSummary ({
state ,
} : {
state : TaskFormState
}) {
const summaryRef = useRef < HTMLDivElement
自动化可检查标签、名称和常见 ARIA 问题,但仍要用键盘走一遍:Tab 顺序是否合理,焦点是否看得见,错误后焦点去了哪里,Enter 会不会误发布,读屏是否把“发送中”和失败原因读出来。
把一次提交从界面追到数据库
现在把本章内容收束成一个完整练习:为 BoardFlow 实现“创建并发布任务”。要求页面在 JavaScript 关闭时仍能创建草稿;启用 JavaScript 后显示字段错误、pending 和成功反馈;经理可以发布,普通成员只能保存;重复请求不会创建两张任务;任务列表在成功后读到新数据。
先画清边界。Server Component 读取 board 与标签选项;Client Component 只承担提交状态;独立的文件级 Server Functions 负责保存与发布。
用显式函数把 FormData 整理成原始对象,再交给 Zod 3 schema。不要把隐藏字段、bind 参数或 TypeScript 断言当成可信证明。
在每个 Action 内验证会话、board 成员关系和具体 capability。发布函数单独要求 task:publish,查询与更新条件同时包含 boardId 和 taskId。
用 useActionState 返回可序列化的字段状态,把 useFormStatus 放进按钮子组件。给动态入口设置稳定 permalink,并实际关闭 JavaScript 验证核心路径。
练习:审查一段“看起来能用”的 Action
下面的代码有多处问题。先自己找,再展开参考答案。
'use server'
export async function publish ( formData : FormData ) {
const userId = formData . get ( 'userId' ) as string
const taskId = formData . get ( 'taskId' ) as string
const title = formData . get
查看审查结果 这段代码至少有八个问题。第一,userId 来自隐藏字段,攻击者可以改写,身份应来自服务器会话。第二,没有检查当前用户是否属于任务所在 board,也没有检查发布权限。第三,taskId 只做了类型断言,没有运行时验证。第四,标题没有长度与业务验证。第五,更新条件只有 taskId,缺少 boardId 与草稿状态,越权和竞争都可能发生。第六,redirect 放在 try 中,会被 catch 当作错误吞掉。第七,revalidatePath 写在 redirect 之后,永远不会执行。第八,catch 吞掉所有未知异常,既不给错误边界,也没有可观察日志。若发布可能被重试,还缺少幂等键、唯一约束或状态条件。
4 使用 useActionState 后,绑定 boardId 的 Action 参数顺序应该是什么?
A. formData、boardId、previousState B. previousState、boardId、formData C. boardId、previousState、formData D. boardId、formData、previousState
5 一次创建任务成功后,下列哪些措施分别有实际作用?
6 只要文件 input 写了 accept=image/png,服务器就可以相信上传文件一定是 PNG。
7 React 19 中,用来读取最近父表单 pending 状态的 Hook 是 ____。
最后可以用这张表做提交前检查:
当这十行都能给出代码或测试证据时,BoardFlow 的表单才不只是“按钮点了有反应”。它已经是一条可以在失败、重试、并发和部署切换中继续解释自己行为的写入路径。
)
:
string
{
const value = formData . get (name)
return typeof value === 'string' ? value . trim () : ''
}
const priorities = [ 'low' , 'medium' , 'high' ] as const
type TaskPriority = ( typeof priorities )[ number ]
function isTaskPriority ( value : string ) : value is TaskPriority {
return priorities . some ( ( priority ) => priority === value)
}
export async function createTask (
boardId : string ,
formData : FormData ,
) : Promise < void > {
const actor = await requireBoardPermission (boardId , 'task:create' )
const title = readText (formData , 'title' )
const description = readText (formData , 'description' )
const priority = readText (formData , 'priority' )
if (title . length < 2 || title . length > 80 ) {
throw new Error ( 'INVALID_TASK_TITLE' )
}
if (description . length > 2000 ) {
throw new Error ( 'INVALID_TASK_DESCRIPTION' )
}
if ( ! isTaskPriority (priority)) {
throw new Error ( 'INVALID_TASK_PRIORITY' )
}
const task = await db . task . create ( {
data : {
boardId ,
title ,
description : description || null ,
priority ,
status : 'draft' ,
createdById : actor . userId ,
},
select : { id : true },
} )
updateTag ( `board:` + boardId + `:tasks` )
redirect ( `/boards/` + boardId + `/tasks/` + task . id)
}
)
{
const { boardId } = await params
const createTaskForBoard = createTask . bind ( null , boardId)
return (
< main >
< h1 > 创建任务 </ h1 >
< form action = { createTaskForBoard } >
< div >
< label htmlFor = "title" > 任务标题 </ label >
< input
id = "title"
name = "title"
minLength = { 2 }
maxLength = { 80 }
required
/>
</ div >
< div >
< label htmlFor = "description" > 任务说明 </ label >
< textarea
id = "description"
name = "description"
maxLength = { 2000 }
/>
</ div >
< div >
< label htmlFor = "priority" > 优先级 </ label >
< select id = "priority" name = "priority" defaultValue = "medium" >
< option value = "low" > 低 </ option >
< option value = "medium" > 中 </ option >
< option value = "high" > 高 </ option >
</ select >
</ div >
< button type = "submit" > 创建任务 </ button >
</ form >
</ main >
)
}
>
}
export default async function TaskPage ({ params } : TaskPageProps ) {
const { boardId , taskId } = await params
await requireBoardPermission (boardId , 'task:edit' )
const task = await db . task . findFirstOrThrow ( {
where : { id : taskId , boardId },
} )
async function renameTask ( formData : FormData ) {
'use server'
await requireBoardPermission (boardId , 'task:edit' )
const value = formData . get ( 'title' )
const title = typeof value === 'string' ? value . trim () : ''
if (title . length < 2 || title . length > 80 ) {
throw new Error ( 'INVALID_TASK_TITLE' )
}
const updated = await db . task . updateMany ( {
where : { id : taskId , boardId },
data : { title },
} )
if (updated . count !== 1 ) {
throw new Error ( 'TASK_NOT_FOUND' )
}
revalidatePath ( `/boards/` + boardId + `/tasks/` + taskId)
}
return (
< form action = { renameTask } >
< label htmlFor = "title" > 任务标题 </ label >
< input
id = "title"
name = "title"
defaultValue = { task . title }
required
/>
< button type = "submit" > 更新标题 </ button >
</ form >
)
}
<
void
>
{
await requireBoardPermission (boardId , 'task:complete' )
const updated = await db . task . updateMany ( {
where : {
id : taskId ,
boardId ,
status : { not : 'completed' },
},
data : { status : 'completed' },
} )
if (updated . count !== 1 ) {
throw new Error ( 'TASK_NOT_COMPLETABLE' )
}
updateTag ( `task:` + taskId)
updateTag ( `board:` + boardId + `:tasks` )
}
,
} : CompleteTaskButtonProps ) {
const [ isPending , startTransition ] = useTransition ()
return (
< button
type = "button"
disabled = { isPending }
onClick = {() => {
startTransition ( async () => {
await completeTask (boardId , taskId)
} )
}}
>
{ isPending ? '完成中…' : '标记完成' }
</ button >
)
}
{},
values : {
title : '' ,
description : '' ,
priority : 'medium' ,
labelIds : [] ,
},
revision : 0 ,
}
export function TaskDraftForm ({ boardId } : { boardId : string }) {
const saveForBoard = saveTaskDraft . bind ( null , boardId)
const permalink = `/boards/` + boardId + `/tasks/new`
const [ state , formAction , isPending ] = useActionState (
saveForBoard ,
initialState ,
permalink ,
)
return (
< form action = { formAction } aria-busy = { isPending } >
< label htmlFor = "title" > 任务标题 </ label >
< input id = "title" name = "title" required />
< button type = "submit" > 保存草稿 </ button >
< p aria-live = "polite" > { state . message } </ p >
</ form >
)
}
'标题不能超过 80 个字符'
)
,
description : z
. string ()
. trim ()
. max ( 2000 , '说明不能超过 2000 个字符' ) ,
priority : z . enum ([ 'low' , 'medium' , 'high' ] , {
required_error : '请选择优先级' ,
invalid_type_error : '优先级格式不正确' ,
} ) ,
labelIds : z
. array (z . string () . uuid ( '标签编号格式不正确' ))
. max ( 10 , '一张任务最多选择 10 个标签' )
. refine (
( ids ) => new Set (ids) . size === ids . length ,
'请不要重复选择同一个标签' ,
) ,
} )
export type TaskInput = z . infer < typeof taskInputSchema >
function textValue ( formData : FormData , name : string ) : string {
const value = formData . get (name)
return typeof value === 'string' ? value : ''
}
export function taskValuesFromFormData ( formData : FormData ) {
return {
title : textValue (formData , 'title' ) ,
description : textValue (formData , 'description' ) ,
priority : textValue (formData , 'priority' ) ,
labelIds : formData
. getAll ( 'labelId' )
. filter ( ( value ) : value is string => typeof value === 'string' ) ,
}
}
fieldErrors : parsed . error . flatten () . fieldErrors ,
values : rawValues ,
}
}
const input = parsed . data
error
'
|
'
saved
'
message : string
fieldErrors : {
title ?: string []
description ?: string []
priority ?: string []
labelIds ?: string []
}
values : TaskFormValues
revision : number
}
export const initialTaskFormState : TaskFormState = {
status : 'idle' ,
message : '' ,
fieldErrors : {},
values : {
title : '' ,
description : '' ,
priority : 'medium' ,
labelIds : [] ,
},
revision : 0 ,
}
async
function
saveTaskDraft
(
boardId : string ,
previousState : TaskFormState ,
formData : FormData ,
) : Promise < TaskFormState > {
const actor = await requireBoardPermission (boardId , 'task:create' )
const values = taskValuesFromFormData (formData)
const parsed = taskInputSchema . safeParse (values)
if ( ! parsed . success) {
return {
status : 'error' ,
message : '草稿还没有保存,请检查标出的字段。' ,
fieldErrors : parsed . error . flatten () . fieldErrors ,
values ,
revision : previousState . revision + 1 ,
}
}
const matchingLabelCount = await db . label . count ( {
where : {
boardId ,
id : { in : parsed . data . labelIds },
},
} )
if (matchingLabelCount !== parsed . data . labelIds . length) {
return {
status : 'error' ,
message : '有标签不属于当前看板,请重新选择。' ,
fieldErrors : {
labelIds : [ '标签不存在,或不属于当前看板' ] ,
},
values ,
revision : previousState . revision + 1 ,
}
}
await db . task . create ( {
data : {
boardId ,
title : parsed . data . title ,
description : parsed . data . description || null ,
priority : parsed . data . priority ,
status : 'draft' ,
createdById : actor . userId ,
labels : {
connect : parsed . data . labelIds . map ( ( id ) => ( { id } )) ,
},
},
} )
updateTag ( `board:` + boardId + `:tasks` )
return {
status : 'saved' ,
message : '草稿已保存,可以继续创建下一张任务。' ,
fieldErrors : {},
values : {
title : '' ,
description : '' ,
priority : 'medium' ,
labelIds : [] ,
},
revision : previousState . revision + 1 ,
}
}
errors ,
} : {
id : string
errors : string [] | undefined
}) {
if ( ! errors ?. length) return null
return (
< p id = { id } >
{ errors . join ( ';' ) }
</ p >
)
}
function SaveDraftButton () {
const { pending } = useFormStatus ()
return (
< button type = "submit" disabled = { pending } >
{ pending ? '保存中…' : '保存草稿' }
</ button >
)
}
export function TaskDraftForm ({ boardId } : { boardId : string }) {
const saveForBoard = saveTaskDraft . bind ( null , boardId)
const permalink = `/boards/` + boardId + `/tasks/new`
const [ state , formAction , isPending ] = useActionState <
TaskFormState ,
FormData
> (saveForBoard , initialTaskFormState , permalink)
return (
< form
key = { state . revision }
action = { formAction }
aria-busy = { isPending }
>
< div >
< label htmlFor = "title" > 任务标题 </ label >
< input
id = "title"
name = "title"
defaultValue = { state . values . title }
aria-invalid = { Boolean (state . fieldErrors . title) }
aria-describedby = {
state . fieldErrors . title ? 'title-error' : undefined
}
minLength = { 2 }
maxLength = { 80 }
required
/>
< FieldError id = "title-error" errors = { state . fieldErrors . title } />
</ div >
< div >
< label htmlFor = "description" > 任务说明 </ label >
< textarea
id = "description"
name = "description"
defaultValue = { state . values . description }
aria-invalid = { Boolean (state . fieldErrors . description) }
aria-describedby = {
state . fieldErrors . description
? 'description-error'
: undefined
}
maxLength = { 2000 }
/>
< FieldError
id = "description-error"
errors = { state . fieldErrors . description }
/>
</ div >
< div >
< label htmlFor = "priority" > 优先级 </ label >
< select
id = "priority"
name = "priority"
defaultValue = { state . values . priority }
aria-invalid = { Boolean (state . fieldErrors . priority) }
aria-describedby = {
state . fieldErrors . priority ? 'priority-error' : undefined
}
>
< option value = "low" > 低 </ option >
< option value = "medium" > 中 </ option >
< option value = "high" > 高 </ option >
</ select >
< FieldError
id = "priority-error"
errors = { state . fieldErrors . priority }
/>
</ div >
< SaveDraftButton />
< p role = "status" aria-live = "polite" >
{ state . message }
</ p >
</ form >
)
}
</
form
>
)
}
.
trim
()
:
''
return (
< div >
< button type = "submit" disabled = { pending } >
{ pending ? '保存中…' : '保存任务' }
</ button >
< p role = "status" aria-live = "polite" >
{ pending
? title
? '正在保存“' + title + '”…'
: '正在保存任务…'
: '' }
</ p >
</ div >
)
}
function
saveExistingDraft
(
boardId : string ,
taskId : string ,
formData : FormData ,
) : Promise < void > {
await requireBoardPermission (boardId , 'task:edit' )
const parsed = taskInputSchema . safeParse (
taskValuesFromFormData (formData) ,
)
if ( ! parsed . success) {
throw new Error ( 'INVALID_TASK_INPUT' )
}
const updated = await db . task . updateMany ( {
where : {
id : taskId ,
boardId ,
status : 'draft' ,
},
data : {
title : parsed . data . title ,
description : parsed . data . description || null ,
priority : parsed . data . priority ,
},
} )
if (updated . count !== 1 ) {
throw new Error ( 'TASK_NOT_EDITABLE' )
}
updateTag ( `task:` + taskId)
}
export async function publishTask (
boardId : string ,
taskId : string ,
formData : FormData ,
) : Promise < void > {
await requireBoardPermission (boardId , 'task:publish' )
const parsed = taskInputSchema . safeParse (
taskValuesFromFormData (formData) ,
)
if ( ! parsed . success) {
throw new Error ( 'INVALID_TASK_INPUT' )
}
const updated = await db . task . updateMany ( {
where : {
id : taskId ,
boardId ,
status : 'draft' ,
},
data : {
title : parsed . data . title ,
description : parsed . data . description || null ,
priority : parsed . data . priority ,
status : 'published' ,
publishedAt : new Date () ,
},
} )
if (updated . count !== 1 ) {
throw new Error ( 'TASK_NOT_PUBLISHABLE' )
}
updateTag ( `task:` + taskId)
updateTag ( `board:` + boardId + `:tasks` )
redirect ( `/boards/` + boardId + `/tasks/` + taskId)
}
:
string
|
null
priority : ' low ' | ' medium ' | ' high '
}
}
export function TaskEditor ({ boardId , task } : TaskEditorProps ) {
const saveDraftAction = saveExistingDraft . bind (
null ,
boardId ,
task . id ,
)
const publishAction = publishTask . bind ( null , boardId , task . id)
return (
< form action = { saveDraftAction } >
< label htmlFor = "title" > 任务标题 </ label >
< input
id = "title"
name = "title"
defaultValue = { task . title }
required
/>
< label htmlFor = "description" > 任务说明 </ label >
< textarea
id = "description"
name = "description"
defaultValue = { task . description ?? '' }
/>
< label htmlFor = "priority" > 优先级 </ label >
< select
id = "priority"
name = "priority"
defaultValue = { task . priority }
>
< option value = "low" > 低 </ option >
< option value = "medium" > 中 </ option >
< option value = "high" > 高 </ option >
</ select >
< TaskActionButtons
saveDraftAction = { saveDraftAction }
publishAction = { publishAction }
/>
</ form >
)
}
}
export function TaskActionButtons ({
saveDraftAction ,
publishAction ,
} : TaskActionButtonsProps ) {
const { pending , action } = useFormStatus ()
const publishing = pending && action === publishAction
return (
< div >
< button
type = "submit"
formAction = { saveDraftAction }
disabled = { pending }
>
{ pending && ! publishing ? '保存中…' : '保存草稿' }
</ button >
< button
type = "submit"
formAction = { publishAction }
disabled = { pending }
>
{ publishing ? '发布中…' : '发布任务' }
</ button >
</ div >
)
}
task:complete
'
| ' comment:create '
| ' attachment:create '
const rolePermissions : Record < string , Set < BoardPermission >> = {
viewer : new Set () ,
member : new Set ([
'task:create' ,
'task:edit' ,
'task:complete' ,
'comment:create' ,
'attachment:create' ,
]) ,
manager : new Set ([
'task:create' ,
'task:edit' ,
'task:publish' ,
'task:complete' ,
'comment:create' ,
'attachment:create' ,
]) ,
}
export async function requireBoardPermission (
boardId : string ,
permission : BoardPermission ,
) : Promise < { userId : string ; role : string } > {
const session = await verifySession ()
if ( ! session) {
redirect ( '/login' )
}
const membership = await db . boardMember . findUnique ( {
where : {
boardId_userId : {
boardId ,
userId : session . userId ,
},
},
select : { role : true },
} )
if (
! membership ||
! rolePermissions[membership . role] ?. has (permission)
) {
throw new Error ( 'FORBIDDEN' )
}
return {
userId : session . userId ,
role : membership . role ,
}
}
'
error
'
message : string
revision : number
}
export async function publishTaskWithState (
boardId : string ,
taskId : string ,
previousState : PublishState ,
formData : FormData ,
) : Promise < PublishState > {
await requireBoardPermission (boardId , 'task:publish' )
const confirmation = formData . get ( 'confirmation' )
if (confirmation !== 'publish' ) {
return {
status : 'error' ,
message : '请先确认发布。' ,
revision : previousState . revision + 1 ,
}
}
try {
const updated = await db . task . updateMany ( {
where : {
id : taskId ,
boardId ,
status : 'draft' ,
},
data : {
status : 'published' ,
publishedAt : new Date () ,
},
} )
if (updated . count !== 1 ) {
return {
status : 'error' ,
message : '任务不存在,或已经被其他成员发布。' ,
revision : previousState . revision + 1 ,
}
}
} catch (error) {
console . error ( 'publish task failed' , {
boardId ,
taskId ,
error ,
} )
throw error
}
updateTag ( `task:` + taskId)
updateTag ( `board:` + boardId + `:tasks` )
redirect ( `/boards/` + boardId + `/tasks/` + taskId)
}
void
}) {
useEffect ( () => {
console . error ( 'board route error' , error . digest)
}, [error])
return (
< section role = "alert" >
< h2 > 这次操作没有完成 </ h2 >
< p > 服务器暂时无法处理请求。请先重新显示页面并确认当前数据;若问题持续,请联系管理员。 </ p >
< button type = "button" onClick = { reset } >
重新显示页面
</ button >
</ section >
)
}
FormData
,
) : Promise < { ok : true } | { ok : false ; message : string } > {
const actor = await requireBoardPermission (boardId , 'comment:create' )
const value = formData . get ( 'body' )
const body = typeof value === 'string' ? value . trim () : ''
if (body . length < 1 || body . length > 2000 ) {
return {
ok : false ,
message : '评论需要 1 到 2000 个字符。' ,
}
}
const task = await db . task . findFirst ( {
where : { id : taskId , boardId },
select : { id : true },
} )
if ( ! task) {
return {
ok : false ,
message : '任务不存在,或你无权访问。' ,
}
}
await db . comment . create ( {
data : {
taskId ,
authorId : actor . userId ,
body ,
},
} )
updateTag ( `task:` + taskId + `:comments` )
return { ok : true }
}
session)
{
throw new Error ( 'UNAUTHENTICATED' )
}
const updated = await db . notice . updateMany ( {
where : {
id : noticeId ,
userId : session . userId ,
},
data : { dismissedAt : new Date () },
} )
if (updated . count !== 1 ) {
throw new Error ( 'NOTICE_NOT_FOUND' )
}
refresh ()
}
type CommentListProps = {
boardId : string
taskId : string
comments : Comment[]
currentUserName : string
}
export function CommentList ({
boardId ,
taskId ,
comments ,
currentUserName ,
} : CommentListProps ) {
const [ message , setMessage ] = useState ( '' )
const [ draft , setDraft ] = useState ( '' )
const [ optimisticComments , appendOptimistic ] = useOptimistic (
comments ,
( current , pendingComment : Comment ) => [
... current ,
pendingComment ,
] ,
)
async function submitComment ( formData : FormData ) {
const body = draft . trim ()
if ( ! body) {
setMessage ( '请输入评论内容。' )
return
}
setMessage ( '' )
appendOptimistic ( {
id : `pending-` + crypto . randomUUID () ,
body ,
authorName : currentUserName ,
pending : true ,
} )
const result = await addComment (boardId , taskId , formData)
if (result . ok) {
setDraft ( '' )
} else {
setMessage (result . message)
}
}
return (
< section aria-labelledby = "comments-title" >
< h2 id = "comments-title" > 评论 </ h2 >
< ol >
{ optimisticComments . map ( ( comment ) => (
< li
key = { comment . id }
aria-label = {
comment . pending
? comment . authorName + ' 的评论,发送中'
: undefined
}
>
< strong > { comment . authorName } </ strong >
< p > { comment . body } </ p >
{ comment . pending ? < small > 发送中… </ small > : null }
</ li >
)) }
</ ol >
< form action = { submitComment } >
< label htmlFor = "comment-body" > 添加评论 </ label >
< textarea
id = "comment-body"
name = "body"
value = { draft }
onChange = {( event ) => setDraft (event . target . value) }
minLength = { 1 }
maxLength = { 2000 }
required
/>
< button type = "submit" > 发送评论 </ button >
</ form >
< p role = "alert" > { message } </ p >
</ section >
)
}
,
UNIQUE (actor_id, idempotency_key)
);
=
randomUUID
()
async function createAndOpen ( formData : FormData ) {
'use server'
const result = await createTaskOnce (boardId , formData)
if ( ! result . ok) {
redirect (
`/boards/` + boardId + `/tasks/new?submission=invalid` ,
)
}
redirect ( `/boards/` + boardId + `/tasks/` + result . taskId)
}
return (
< form action = { createAndOpen } >
< input
type = "hidden"
name = "idempotencyKey"
value = { idempotencyKey }
/>
< label htmlFor = "title" > 任务标题 </ label >
< input id = "title" name = "title" required />
< label htmlFor = "description" > 任务说明 </ label >
< textarea id = "description" name = "description" />
< label htmlFor = "priority" > 优先级 </ label >
< select id = "priority" name = "priority" defaultValue = "medium" >
< option value = "low" > 低 </ option >
< option value = "medium" > 中 </ option >
< option value = "high" > 高 </ option >
</ select >
< button type = "submit" > 创建任务 </ button >
</ form >
)
}
taskValuesFromFormData
,
} from '@/lib/tasks/task-input'
import { isUniqueConstraintError } from '@/lib/db/errors'
const idempotencyKeySchema = z . string () . uuid ()
const storedResultSchema = z . object ( {
ok : z . literal ( true ) ,
taskId : z . string () . min ( 1 ) ,
replayed : z . boolean () ,
} )
type CreateTaskResult =
| { ok : true ; taskId : string ; replayed : boolean }
| { ok : false ; message : string }
function fingerprint ( input : {
boardId : string
title : string
description : string
priority : string
labelIds : string []
}) : string {
return createHash ( 'sha256' )
. update ( JSON . stringify (input))
. digest ( 'hex' )
}
export async function createTaskOnce (
boardId : string ,
formData : FormData ,
) : Promise < CreateTaskResult > {
const actor = await requireBoardPermission (boardId , 'task:create' )
const keyResult = idempotencyKeySchema . safeParse (
formData . get ( 'idempotencyKey' ) ,
)
const taskResult = taskInputSchema . safeParse (
taskValuesFromFormData (formData) ,
)
if ( ! keyResult . success || ! taskResult . success) {
return {
ok : false ,
message : '提交内容不完整,请检查后重试。' ,
}
}
const matchingLabelCount = await db . label . count ( {
where : {
boardId ,
id : { in : taskResult . data . labelIds },
},
} )
if (matchingLabelCount !== taskResult . data . labelIds . length) {
return {
ok : false ,
message : '有标签不存在,或不属于当前看板。' ,
}
}
const payload = {
boardId ,
... taskResult . data ,
labelIds : [ ... taskResult . data . labelIds] . sort () ,
}
const payloadFingerprint = fingerprint (payload)
try {
const result = await db . $transaction ( async ( tx ) => {
const receipt = await tx . mutationReceipt . create ( {
data : {
id : randomUUID () ,
actorId : actor . userId ,
idempotencyKey : keyResult . data ,
payloadFingerprint ,
status : 'processing' ,
},
} )
const task = await tx . task . create ( {
data : {
boardId ,
title : taskResult . data . title ,
description : taskResult . data . description || null ,
priority : taskResult . data . priority ,
status : 'draft' ,
createdById : actor . userId ,
labels : {
connect : taskResult . data . labelIds . map ( ( id ) => ( { id } )) ,
},
},
select : { id : true },
} )
const response = {
ok : true as const ,
taskId : task . id ,
replayed : false ,
}
await tx . mutationReceipt . update ( {
where : { id : receipt . id },
data : {
status : 'completed' ,
responseJson : response ,
completedAt : new Date () ,
},
} )
return response
} )
updateTag ( `board:` + boardId + `:tasks` )
return result
} catch (error) {
if (
! isUniqueConstraintError (error , [
'actorId' ,
'idempotencyKey' ,
])
) {
throw error
}
const receipt = await db . mutationReceipt . findUnique ( {
where : {
actorId_idempotencyKey : {
actorId : actor . userId ,
idempotencyKey : keyResult . data ,
},
},
} )
if ( ! receipt || receipt . status !== 'completed' ) {
throw new Error ( 'IDEMPOTENCY_RECEIPT_UNAVAILABLE' )
}
if (receipt . payloadFingerprint !== payloadFingerprint) {
return {
ok : false ,
message : '这次提交键已经用于另一份内容,请刷新表单。' ,
}
}
const stored = storedResultSchema . safeParse (receipt . responseJson)
if ( ! stored . success) {
throw new Error ( 'INVALID_IDEMPOTENCY_RESPONSE' )
}
updateTag ( `board:` + boardId + `:tasks` )
return {
... stored . data ,
replayed : true ,
}
}
}
,
taskId)
return (
< form action = { upload } >
< label htmlFor = "attachment" > 添加 PNG 或 PDF 附件 </ label >
< input
id = "attachment"
name = "attachment"
type = "file"
accept = "image/png,application/pdf"
required
/>
< button type = "submit" > 上传附件 </ button >
</ form >
)
}
DetectedFile
=
{
extension : ' png ' | ' pdf '
contentType : ' image/png ' | ' application/pdf '
}
function detectAllowedFile ( bytes : Uint8Array ) : DetectedFile | null {
const isPng =
bytes . length >= 8 &&
bytes[ 0 ] === 0x89 &&
bytes[ 1 ] === 0x50 &&
bytes[ 2 ] === 0x4e &&
bytes[ 3 ] === 0x47 &&
bytes[ 4 ] === 0x0d &&
bytes[ 5 ] === 0x0a &&
bytes[ 6 ] === 0x1a &&
bytes[ 7 ] === 0x0a
if (isPng) {
return {
extension : 'png' ,
contentType : 'image/png' ,
}
}
const isPdf =
bytes . length >= 5 &&
bytes[ 0 ] === 0x25 &&
bytes[ 1 ] === 0x50 &&
bytes[ 2 ] === 0x44 &&
bytes[ 3 ] === 0x46 &&
bytes[ 4 ] === 0x2d
if (isPdf) {
return {
extension : 'pdf' ,
contentType : 'application/pdf' ,
}
}
return null
}
function safeDisplayName ( name : string ) : string {
return name
. replace ( / [ \u0000 - \u001f\u007f ] / g , '' )
. slice ( 0 , 120 )
}
export async function uploadSmallAttachment (
boardId : string ,
taskId : string ,
formData : FormData ,
) : Promise < { ok : true } | { ok : false ; message : string } > {
const actor = await requireBoardPermission (
boardId ,
'attachment:create' ,
)
const task = await db . task . findFirst ( {
where : { id : taskId , boardId },
select : { id : true },
} )
if ( ! task) {
return {
ok : false ,
message : '任务不存在,或你无权访问。' ,
}
}
const entry = formData . get ( 'attachment' )
if ( ! (entry instanceof File ) || entry . size === 0 ) {
return { ok : false , message : '请选择一个文件。' }
}
if (entry . size > MAX_ACTION_FILE_BYTES) {
return {
ok : false ,
message : '文件过大,请使用大文件上传入口。' ,
}
}
const bytes = new Uint8Array ( await entry . arrayBuffer ())
const detected = detectAllowedFile (bytes)
if ( ! detected) {
return {
ok : false ,
message : '只接受内容有效的 PNG 或 PDF 文件。' ,
}
}
const objectKey =
`quarantine/boards/` +
boardId +
`/` +
randomUUID () +
`.` +
detected . extension
await storage . put (objectKey , bytes , {
contentType : detected . contentType ,
} )
try {
await db . attachment . create ( {
data : {
boardId ,
taskId ,
objectKey ,
originalName : safeDisplayName (entry . name) ,
contentType : detected . contentType ,
size : bytes . byteLength ,
status : 'quarantined' ,
uploadedById : actor . userId ,
},
} )
} catch (error) {
try {
await storage . delete (objectKey)
} catch (cleanupError) {
console . error ( 'orphan attachment cleanup failed' , {
objectKey ,
cleanupError ,
} )
}
throw error
}
updateTag ( `task:` + taskId + `:attachments` )
return { ok : true }
}
},
}
export default nextConfig
FormData
()
formData . set ( 'title' , ' 修复登录页 ' )
formData . set ( 'description' , ' 补充错误反馈 ' )
formData . set ( 'priority' , 'high' )
formData . append ( 'labelId' , '4f826f15-c502-47ed-990d-30593803721d' )
formData . append ( 'labelId' , 'a92c2165-95af-4bee-97c4-7ac8a1a93517' )
const result = taskInputSchema . safeParse (
taskValuesFromFormData (formData) ,
)
expect (result . success) . toBe ( true )
if (result . success) {
expect (result . data . title) . toBe ( '修复登录页' )
expect (result . data . labelIds) . toHaveLength ( 2 )
}
} )
it ( '拒绝伪造优先级与过长标题' , () => {
const result = taskInputSchema . safeParse ( {
title : 'x' . repeat ( 81 ) ,
description : '' ,
priority : 'root' ,
labelIds : [] ,
} )
expect (result . success) . toBe ( false )
if ( ! result . success) {
const errors = result . error . flatten () . fieldErrors
expect (errors . title) . toBeDefined ()
expect (errors . priority) . toBeDefined ()
}
} )
it ( '拒绝重复的标签编号' , () => {
const labelId = '4f826f15-c502-47ed-990d-30593803721d'
const result = taskInputSchema . safeParse ( {
title : '整理发布检查表' ,
description : '' ,
priority : 'medium' ,
labelIds : [labelId , labelId] ,
} )
expect (result . success) . toBe ( false )
} )
} )
)
await page . getByLabel ( '任务说明' ) . fill ( '覆盖回滚与缓存检查' )
await page . getByLabel ( '优先级' ) . selectOption ( 'high' )
await page . getByRole ( 'button' , { name : '创建任务' } ) . click ()
await expect (page) . toHaveURL (
/\/ boards \/ demo \/ tasks \/ [ a-zA-Z0-9 - ] + / ,
)
await expect (
page . getByRole ( 'heading' , { name : '整理发布检查表' } ) ,
) . toBeVisible ()
} )
test . describe ( '没有 JavaScript 的核心路径' , () => {
test . use ( { javaScriptEnabled : false } )
test ( '仍能提交服务器表单' , async ({ page }) => {
await page . goto ( '/boards/demo/tasks/new' )
await page . getByLabel ( '任务标题' ) . fill ( '检查渐进增强' )
await page . getByLabel ( '优先级' ) . selectOption ( 'medium' )
await page . getByRole ( 'button' , { name : '创建任务' } ) . click ()
await expect (page) . toHaveURL (
/\/ boards \/ demo \/ tasks \/ [ a-zA-Z0-9 - ] + / ,
)
} )
} )
>
(
null
)
const hasErrors =
state . status === 'error' &&
Object . keys (state . fieldErrors) . length > 0
useEffect ( () => {
if (hasErrors) {
summaryRef . current ?. focus ()
}
}, [hasErrors , state . revision])
if ( ! hasErrors) return null
return (
< div
ref = { summaryRef }
role = "alert"
tabIndex = { - 1 }
aria-labelledby = "task-error-title"
>
< h2 id = "task-error-title" > 任务还不能保存 </ h2 >
< p > { state . message } </ p >
< p > 请检查下面标出的字段。 </ p >
</ div >
)
}
为保存与发布设置不同 formAction,明确 Enter 的默认动作。预期失败返回 state,未知故障抛给 error.tsx,redirect 放在 try/catch 外。
在事务里写任务与幂等回执,依靠数据库唯一约束裁决并发。成功提交后根据读取方式选择 updateTag、revalidateTag、revalidatePath 或 refresh。
最后补齐浏览器测试、权限测试、并发测试与键盘测试。只有界面反馈、服务器事实和缓存读取一致,这次提交才算完成。
(
'title'
)
as
string
try {
await db . task . update ( {
where : { id : taskId },
data : {
title ,
status : 'published' ,
publishedById : userId ,
},
} )
redirect ( '/tasks/' + taskId)
revalidatePath ( '/tasks' )
} catch {
return { error : '发布失败' }
}
}