140 lines
4.8 KiB
TypeScript
140 lines
4.8 KiB
TypeScript
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<typeof createPostSchema>;
|
||
export type UpdatePostInput = z.infer<typeof updatePostSchema>;
|
||
export type ListPostsQuery = z.infer<typeof listPostsQuerySchema>;
|
||
|
||
/**
|
||
* 估算阅读时长(分钟)。
|
||
* 中文按字符计(约 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));
|
||
}
|