import { z } from "zod"; /** * 文章的输入校验契约(单一事实来源)。 * * API 路由、seed 脚本、未来的后台表单都复用这套 schema, * 避免"接口校验一套、脚本又一套"导致的不一致。 */ /** slug:小写字母/数字,用连字符分隔,与数据库 CHECK 约束保持一致 */ export const slugSchema = z .string() .trim() .min(1, "slug 不能为空") .max(200, "slug 最长 200 个字符") .regex( /^[a-z0-9]+(-[a-z0-9]+)*$/, "slug 只能包含小写字母、数字和连字符,且不能以连字符开头或结尾", ); /** 状态枚举,与数据库 CHECK 约束保持一致 */ export const postStatusSchema = z.enum(["draft", "published"]); /** 标签:去空白、去重、限制数量与长度 */ export const tagsSchema = z .array(z.string().trim().min(1).max(40, "单个标签最长 40 个字符")) .max(10, "最多 10 个标签") .default([]) .transform((tags) => [...new Set(tags)]); /** 日期:接受 YYYY-MM-DD 或完整 ISO 字符串,统一转为 Date */ export const dateInputSchema = z .string() .trim() .min(1) .refine((value) => !Number.isNaN(new Date(value).getTime()), { message: "不是合法的日期", }) .transform((value) => new Date(value)); /** 创建文章 */ export const createPostSchema = z.object({ slug: slugSchema, title: z.string().trim().min(1, "标题不能为空").max(200, "标题最长 200 个字符"), summary: z .string() .trim() .min(1, "摘要不能为空") .max(500, "摘要最长 500 个字符"), content: z.string().min(1, "正文不能为空"), tags: tagsSchema, status: postStatusSchema.default("draft"), /** 仅当 status=published 时有意义;不传则用当前时间 */ publishedAt: dateInputSchema.optional(), cover: z.string().trim().min(1).nullish(), description: z.string().trim().max(500).nullish(), }); /** * 更新文章:所有字段可选,但至少要传一个。 * * ⚠️ `.partial()` 的陷阱:它只把字段变成可选,**不会移除 `.default()`**。 * `createPostSchema` 里 `tags` 有 `.default([])`、`status` 有 `.default("draft")`, * 于是 `PATCH {}` 会被解析成 `{ tags: [], status: "draft" }`, * 既绕过了"至少传一个字段"的校验,又会在用户只想改标题时 * 意外把文章状态重置为草稿。 * * 因此这里基于 **字段定义** 重建 schema:所有字段都显式声明为 `.optional()`, * 不带任何默认值。未传 = 不修改。 */ export const updatePostSchema = z .object({ slug: slugSchema.optional(), title: z .string() .trim() .min(1, "标题不能为空") .max(200, "标题最长 200 个字符") .optional(), summary: z .string() .trim() .min(1, "摘要不能为空") .max(500, "摘要最长 500 个字符") .optional(), content: z.string().min(1, "正文不能为空").optional(), tags: z .array(z.string().trim().min(1).max(40, "单个标签最长 40 个字符")) .max(10, "最多 10 个标签") .transform((tags) => [...new Set(tags)]) .optional(), status: postStatusSchema.optional(), publishedAt: dateInputSchema.optional(), cover: z.string().trim().min(1).nullish(), description: z.string().trim().max(500).nullish(), }) // 至少要有一个"真正传了值"的键(nullish 字段传 null 也算显式修改) .refine((data) => Object.keys(data).length > 0, { message: "至少需要提供一个要更新的字段", }); /** 列表查询参数 */ export const listPostsQuerySchema = z.object({ /** 返回数量,1–100 */ limit: z.coerce.number().int().min(1).max(100).default(20), /** 偏移量,用于分页 */ offset: z.coerce.number().int().min(0).default(0), /** 按标签过滤 */ tag: z.string().trim().min(1).optional(), /** 按状态过滤;不传则默认只返回已发布 */ status: postStatusSchema.optional(), /** 是否包含草稿(仅用于内部/管理场景) */ includeDrafts: z .enum(["true", "false"]) .default("false") .transform((value) => value === "true"), }); export type CreatePostInput = z.infer; export type UpdatePostInput = z.infer; export type ListPostsQuery = z.infer; /** * 估算阅读时长(分钟)。 * 中文按字符计(约 350 字/分钟),英文按单词计(约 200 词/分钟)。 * * 写入时计算并缓存到 reading_minutes 列,避免列表页反复解析正文。 * 与 `lib/posts/reading-time.ts` 共用同一实现,保证行为一致。 */ export function estimateReadingMinutes(content: string): number { const chineseCharacters = content.match(/[\u4e00-\u9fa5]/g)?.length ?? 0; const latinWords = content .replace(/[\u4e00-\u9fa5]/g, " ") .match(/[A-Za-z0-9]+/g)?.length ?? 0; const minutes = chineseCharacters / 350 + latinWords / 200; return Math.max(1, Math.round(minutes)); }