feat: add Payload CMS foundation
This commit is contained in:
parent
5775214e05
commit
1f593fcab4
@ -1,7 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, BluetoothIcon, ChipIcon, SparkIcon, WaveIcon } from "@/components/icons";
|
||||
import { ArrowLink, DevPlaceholder, ProductVisual, SectionHeading } from "@/components/ui";
|
||||
import { products } from "@/lib/content";
|
||||
import { getProducts } from "@/lib/content";
|
||||
|
||||
const capabilities = [
|
||||
{ icon: BluetoothIcon, title: "Bluetooth", copy: "稳定连接" },
|
||||
@ -10,7 +10,10 @@ const capabilities = [
|
||||
{ icon: SparkIcon, title: "AI", copy: "让设备更智能" },
|
||||
];
|
||||
|
||||
export default function HomePage() {
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function HomePage() {
|
||||
const products = await getProducts();
|
||||
return (
|
||||
<>
|
||||
<section className="relative isolate overflow-hidden hero-glow">
|
||||
@ -43,6 +46,7 @@ export default function HomePage() {
|
||||
<ArrowLink href="/products">查看全部产品</ArrowLink>
|
||||
</div>
|
||||
<div className="mt-14 grid gap-5 lg:grid-cols-2">
|
||||
{!products.length ? <div className="rounded-2xl border border-dashed border-line p-8 text-sm leading-7 text-muted">CMS 当前没有已发布产品。产品发布后会自动出现在这里。</div> : null}
|
||||
{products.map((product) => (
|
||||
<Link className="group grid gap-5 rounded-2xl border border-line bg-panel/60 p-4 transition-colors hover:border-blue/60 sm:grid-cols-[0.9fr_1.1fr] sm:p-5" href={`/products/${product.slug}`} key={product.slug}>
|
||||
<ProductVisual accent={product.accent} label={product.category.toUpperCase()} />
|
||||
|
||||
@ -1,21 +1,23 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { ArrowRight } from "@/components/icons";
|
||||
import { DevPlaceholder, FeatureList, PreviewNotice, ProductVisual } from "@/components/ui";
|
||||
import { products } from "@/lib/content";
|
||||
import { getProducts } from "@/lib/content";
|
||||
|
||||
export function generateStaticParams() {
|
||||
return products.map((product) => ({ slug: product.slug }));
|
||||
export async function generateStaticParams() {
|
||||
return (await getProducts()).map((product) => ({ slug: product.slug }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params;
|
||||
const product = products.find((item) => item.slug === slug);
|
||||
const product = (await getProducts()).find((item) => item.slug === slug);
|
||||
return { title: product?.name ?? "产品详情" };
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ProductDetailPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params;
|
||||
const product = products.find((item) => item.slug === slug);
|
||||
const product = (await getProducts()).find((item) => item.slug === slug);
|
||||
if (!product) notFound();
|
||||
|
||||
return (
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
import { SectionHeading, DevPlaceholder, ProductVisual, ArrowLink } from "@/components/ui";
|
||||
import { products } from "@/lib/content";
|
||||
import { getProducts } from "@/lib/content";
|
||||
|
||||
export const metadata = { title: "产品中心" };
|
||||
|
||||
export default function ProductsPage() {
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ProductsPage() {
|
||||
const products = await getProducts();
|
||||
return (
|
||||
<div className="container-shell py-20 sm:py-28">
|
||||
<SectionHeading eyebrow="Products" title="产品中心" description="围绕蓝牙周边、音频与智能硬件的产品结构。真实产品资料入库前,页面使用明确标注的开发占位数据。" />
|
||||
@ -14,6 +17,7 @@ export default function ProductsPage() {
|
||||
<span className="ml-auto self-center text-xs text-subtle">分类筛选将在 CMS 接入后启用</span>
|
||||
</div>
|
||||
<div className="mt-10 grid gap-6 lg:grid-cols-2">
|
||||
{!products.length ? <div className="rounded-2xl border border-dashed border-line p-8 text-sm leading-7 text-muted">CMS 当前没有已发布产品。</div> : null}
|
||||
{products.map((product) => (
|
||||
<article className="overflow-hidden rounded-2xl border border-line bg-panel/50" key={product.slug}>
|
||||
<ProductVisual accent={product.accent} label={product.category.toUpperCase()} />
|
||||
|
||||
7
apps/cms/next-env.d.ts
vendored
Normal file
7
apps/cms/next-env.d.ts
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/types/root-params.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
5
apps/cms/next.config.mjs
Normal file
5
apps/cms/next.config.mjs
Normal file
@ -0,0 +1,5 @@
|
||||
import { withPayload } from "@payloadcms/next/withPayload";
|
||||
|
||||
export default withPayload({
|
||||
reactStrictMode: true,
|
||||
});
|
||||
7342
apps/cms/package-lock.json
generated
Normal file
7342
apps/cms/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
apps/cms/package.json
Normal file
26
apps/cms/package.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "kaotings-cms",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3001",
|
||||
"build": "next build",
|
||||
"start": "next start -H 127.0.0.1 -p 3001"
|
||||
},
|
||||
"dependencies": {
|
||||
"@payloadcms/db-postgres": "3.88.0",
|
||||
"@payloadcms/next": "3.88.0",
|
||||
"@payloadcms/richtext-lexical": "3.88.0",
|
||||
"next": "16.3.4",
|
||||
"payload": "3.88.0",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"sharp": "0.34.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "26.5.0",
|
||||
"@types/react": "19.2.18",
|
||||
"@types/react-dom": "19.2.7",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
18
apps/cms/payload.config.ts
Normal file
18
apps/cms/payload.config.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { buildConfig } from "payload";
|
||||
import { postgresAdapter } from "@payloadcms/db-postgres";
|
||||
import { lexicalEditor } from "@payloadcms/richtext-lexical";
|
||||
import { HomeContent } from "./src/collections/HomeContent";
|
||||
import { Media } from "./src/collections/Media";
|
||||
import { Products } from "./src/collections/Products";
|
||||
import { SiteSettings } from "./src/collections/SiteSettings";
|
||||
import { Users } from "./src/collections/Users";
|
||||
|
||||
export default buildConfig({
|
||||
admin: { user: Users.slug },
|
||||
collections: [Users, Products, Media],
|
||||
globals: [SiteSettings, HomeContent],
|
||||
editor: lexicalEditor(),
|
||||
secret: process.env.PAYLOAD_SECRET || "development-only-change-me",
|
||||
db: postgresAdapter({ pool: { connectionString: process.env.DATABASE_URI || "" } }),
|
||||
typescript: { outputFile: "./src/payload-types.ts" },
|
||||
});
|
||||
3
apps/cms/src/app/(frontend)/layout.tsx
Normal file
3
apps/cms/src/app/(frontend)/layout.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export default function FrontendLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
3
apps/cms/src/app/(frontend)/page.tsx
Normal file
3
apps/cms/src/app/(frontend)/page.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export default function CmsStatusPage() {
|
||||
return <main><h1>KAOTINGS CMS</h1><p>内容管理服务已运行。请使用受控管理员入口。</p></main>;
|
||||
}
|
||||
11
apps/cms/src/app/(payload)/admin/[[...segments]]/page.tsx
Normal file
11
apps/cms/src/app/(payload)/admin/[[...segments]]/page.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import config from "@payload-config";
|
||||
import { RootPage, generatePageMetadata } from "@payloadcms/next/views";
|
||||
|
||||
const configPromise = Promise.resolve(config as any);
|
||||
const importMap = {} as any;
|
||||
|
||||
export const generateMetadata = ({ params, searchParams }: { params: Promise<Record<string, string | string[]>>; searchParams: Promise<Record<string, string | string[]>> }) => generatePageMetadata({ config: configPromise, params, searchParams });
|
||||
|
||||
export default function AdminPage({ params, searchParams }: { params: Promise<{ segments: string[] }>; searchParams: Promise<Record<string, string | string[]>> }) {
|
||||
return RootPage({ config: configPromise, importMap, params, searchParams });
|
||||
}
|
||||
8
apps/cms/src/app/(payload)/api/[...slug]/route.ts
Normal file
8
apps/cms/src/app/(payload)/api/[...slug]/route.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import config from "@payload-config";
|
||||
import { REST_DELETE, REST_GET, REST_OPTIONS, REST_PATCH, REST_POST } from "@payloadcms/next/routes";
|
||||
|
||||
export const GET = REST_GET(config);
|
||||
export const POST = REST_POST(config);
|
||||
export const PATCH = REST_PATCH(config);
|
||||
export const DELETE = REST_DELETE(config);
|
||||
export const OPTIONS = REST_OPTIONS(config);
|
||||
11
apps/cms/src/app/(payload)/layout.tsx
Normal file
11
apps/cms/src/app/(payload)/layout.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import config from "@payload-config";
|
||||
import { RootLayout } from "@payloadcms/next/layouts";
|
||||
import "@payloadcms/next/css";
|
||||
|
||||
const configPromise = Promise.resolve(config as any);
|
||||
const importMap = {} as any;
|
||||
const serverFunction = async () => null;
|
||||
|
||||
export default function PayloadLayout({ children }: { children: React.ReactNode }) {
|
||||
return RootLayout({ config: configPromise, importMap, serverFunction, children });
|
||||
}
|
||||
13
apps/cms/src/collections/HomeContent.ts
Normal file
13
apps/cms/src/collections/HomeContent.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import type { GlobalConfig } from "payload";
|
||||
|
||||
export const HomeContent: GlobalConfig = {
|
||||
slug: "home-content",
|
||||
access: { read: () => true, update: ({ req: { user } }) => Boolean(user) },
|
||||
fields: [
|
||||
{ name: "heroClaim", type: "text" },
|
||||
{ name: "heroDescription", type: "textarea" },
|
||||
{ name: "technologyDescription", type: "textarea" },
|
||||
{ name: "aiLabDescription", type: "textarea" },
|
||||
{ name: "featuredProducts", type: "relationship", relationTo: "products", hasMany: true },
|
||||
],
|
||||
};
|
||||
13
apps/cms/src/collections/Media.ts
Normal file
13
apps/cms/src/collections/Media.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
|
||||
export const Media: CollectionConfig = {
|
||||
slug: "media",
|
||||
upload: { staticDir: "media", mimeTypes: ["image/*", "video/*"] },
|
||||
access: {
|
||||
read: () => true,
|
||||
create: ({ req: { user } }) => Boolean(user),
|
||||
update: ({ req: { user } }) => Boolean(user),
|
||||
delete: ({ req: { user } }) => Boolean(user),
|
||||
},
|
||||
fields: [{ name: "alt", type: "text", required: true }],
|
||||
};
|
||||
28
apps/cms/src/collections/Products.ts
Normal file
28
apps/cms/src/collections/Products.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
|
||||
export const Products: CollectionConfig = {
|
||||
slug: "products",
|
||||
access: {
|
||||
read: ({ req: { user } }) => user ? true : { status: { equals: "published" } },
|
||||
create: ({ req: { user } }) => Boolean(user),
|
||||
update: ({ req: { user } }) => Boolean(user),
|
||||
delete: ({ req: { user } }) => Boolean(user),
|
||||
},
|
||||
admin: { useAsTitle: "name", defaultColumns: ["name", "status", "updatedAt"] },
|
||||
fields: [
|
||||
{ name: "name", type: "text", required: true },
|
||||
{ name: "slug", type: "text", required: true, unique: true, index: true },
|
||||
{ name: "category", type: "text", required: true },
|
||||
{ name: "summary", type: "textarea", required: true },
|
||||
{ name: "description", type: "richText", required: true },
|
||||
{ name: "cover", type: "upload", relationTo: "media" },
|
||||
{ name: "features", type: "array", fields: [{ name: "value", type: "text" }] },
|
||||
{ name: "specs", type: "json" },
|
||||
{ name: "status", type: "select", defaultValue: "draft", options: ["draft", "published", "unpublished"], required: true },
|
||||
{ name: "featured", type: "checkbox", defaultValue: false },
|
||||
{ name: "sortOrder", type: "number", defaultValue: 0 },
|
||||
{ name: "publishedAt", type: "date" },
|
||||
{ name: "seoTitle", type: "text" },
|
||||
{ name: "seoDescription", type: "textarea" },
|
||||
],
|
||||
};
|
||||
13
apps/cms/src/collections/SiteSettings.ts
Normal file
13
apps/cms/src/collections/SiteSettings.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import type { GlobalConfig } from "payload";
|
||||
|
||||
export const SiteSettings: GlobalConfig = {
|
||||
slug: "site-settings",
|
||||
access: { read: () => true, update: ({ req: { user } }) => Boolean(user) },
|
||||
fields: [
|
||||
{ name: "brandName", type: "text", defaultValue: "考町科技 / KAOTINGS" },
|
||||
{ name: "contactNotice", type: "textarea" },
|
||||
{ name: "footerDescription", type: "textarea" },
|
||||
{ name: "seoTitle", type: "text" },
|
||||
{ name: "seoDescription", type: "textarea" },
|
||||
],
|
||||
};
|
||||
14
apps/cms/src/collections/Users.ts
Normal file
14
apps/cms/src/collections/Users.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
|
||||
export const Users: CollectionConfig = {
|
||||
slug: "users",
|
||||
auth: true,
|
||||
access: {
|
||||
admin: ({ req: { user } }) => Boolean(user),
|
||||
create: ({ req: { user } }) => Boolean(user),
|
||||
read: ({ req: { user } }) => Boolean(user),
|
||||
update: ({ req: { user } }) => Boolean(user),
|
||||
delete: ({ req: { user } }) => Boolean(user),
|
||||
},
|
||||
fields: [{ name: "displayName", type: "text", required: true }],
|
||||
};
|
||||
4
apps/cms/src/payload-config.d.ts
vendored
Normal file
4
apps/cms/src/payload-config.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
declare module "@payload-config" {
|
||||
const config: any;
|
||||
export default config;
|
||||
}
|
||||
44
apps/cms/tsconfig.json
Normal file
44
apps/cms/tsconfig.json
Normal file
@ -0,0 +1,44 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
],
|
||||
"@payload-config": [
|
||||
"./payload.config.ts"
|
||||
]
|
||||
},
|
||||
"allowJs": true,
|
||||
"incremental": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
".next/types/**/*.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@ -5,6 +5,7 @@
|
||||
- `caddy/Caddyfile`:测试机 IP 的内部 CA HTTPS、Web/API 反向代理。
|
||||
- `systemd/kaotings-api.service`:FastAPI 自动重启服务。
|
||||
- `systemd/kaotings-web.service`:Next.js 自动重启服务。
|
||||
- `systemd/kaotings-cms.service`:Payload CMS 独立内容服务。
|
||||
- Phase 2 发布包必须保留仓库目录结构;Web 使用发布根目录,API 使用其中的 `services/api`,虚拟环境单独放在 `/home/flym/kaotings-api-venv`。
|
||||
|
||||
数据库账号密码、`CSRF_SECRET` 和 Caddy 私有 CA 不进入 Git。服务器上的 `/etc/kaotings/api.env` 必须由 root 拥有且权限为 `0600`。
|
||||
@ -15,6 +16,9 @@ Phase 2 测试入口:
|
||||
- API:通过同源 `https://192.168.199.22/api/v1/*`
|
||||
- API 回环端口:`127.0.0.1:8000`,不对局域网开放
|
||||
- Web 回环端口:`127.0.0.1:3000`,不对局域网开放
|
||||
- CMS 回环端口:`127.0.0.1:3001`,通过 `https://192.168.199.22/cms/` 受控访问
|
||||
- PostgreSQL:`127.0.0.1:5432`,不对局域网开放
|
||||
|
||||
CMS 使用独立 PostgreSQL 数据库和账号 `/etc/kaotings/cms.env`,不读取业务 API 用户表。CMS 管理员首个账户通过 Payload 管理入口受控建立,之后关闭匿名创建;不与业务用户表共享。
|
||||
|
||||
Caddy `tls internal` 生成的根证书需要在测试浏览器/设备中导入后,HTTPS 才会显示为受信任。根证书可以从服务器的 Caddy 数据目录受控导出;私钥不得导出或提交。
|
||||
|
||||
@ -2,6 +2,10 @@ https://192.168.199.22 {
|
||||
tls internal
|
||||
encode gzip
|
||||
|
||||
handle_path /cms/* {
|
||||
reverse_proxy 127.0.0.1:3001
|
||||
}
|
||||
|
||||
handle /api/* {
|
||||
reverse_proxy 127.0.0.1:8000
|
||||
}
|
||||
|
||||
33
infra/scripts/provision-cms-db.sh
Normal file
33
infra/scripts/provision-cms-db.sh
Normal file
@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "$(id -u)" -ne 0 ]]; then
|
||||
echo "run as root" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
db_name="kaotings_cms"
|
||||
db_role="kaotings_cms"
|
||||
db_password="$(openssl rand -hex 32)"
|
||||
payload_secret="$(openssl rand -hex 48)"
|
||||
|
||||
if runuser -u postgres -- psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='${db_role}'" | grep -q 1; then
|
||||
printf "ALTER ROLE %s PASSWORD '%s';\n" "$db_role" "$db_password" | runuser -u postgres -- psql -v ON_ERROR_STOP=1 >/dev/null
|
||||
else
|
||||
printf "CREATE ROLE %s LOGIN PASSWORD '%s';\n" "$db_role" "$db_password" | runuser -u postgres -- psql -v ON_ERROR_STOP=1 >/dev/null
|
||||
fi
|
||||
|
||||
if ! runuser -u postgres -- psql -tAc "SELECT 1 FROM pg_database WHERE datname='${db_name}'" | grep -q 1; then
|
||||
runuser -u postgres -- createdb -O "$db_role" "$db_name"
|
||||
fi
|
||||
|
||||
install -d -m 700 /etc/kaotings
|
||||
umask 077
|
||||
cat > /etc/kaotings/cms.env <<EOF
|
||||
DATABASE_URI=postgresql://${db_role}:${db_password}@127.0.0.1:5432/${db_name}
|
||||
PAYLOAD_SECRET=${payload_secret}
|
||||
NEXT_PUBLIC_SERVER_URL=https://192.168.199.22
|
||||
NODE_ENV=production
|
||||
EOF
|
||||
chmod 600 /etc/kaotings/cms.env
|
||||
echo "CMS database and environment provisioned"
|
||||
19
infra/systemd/kaotings-cms.service
Normal file
19
infra/systemd/kaotings-cms.service
Normal file
@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=Kaotings Payload CMS
|
||||
After=network-online.target postgresql.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=flym
|
||||
Group=flym
|
||||
WorkingDirectory=/home/flym/kaotings-cms
|
||||
EnvironmentFile=/etc/kaotings/cms.env
|
||||
ExecStart=/usr/local/bin/npm run start -- -H 127.0.0.1 -p 3001
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@ -9,6 +9,7 @@ User=flym
|
||||
Group=flym
|
||||
WorkingDirectory=/home/flym/kaotings-www
|
||||
Environment=NODE_ENV=production
|
||||
Environment=CMS_PUBLIC_API_URL=http://127.0.0.1:3001
|
||||
ExecStart=/usr/local/bin/npm run start -- -H 127.0.0.1 -p 3000
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
@ -35,3 +35,28 @@ export const navigation = [
|
||||
{ label: "AI Lab", href: "/tts" },
|
||||
{ label: "关于我们", href: "/about" },
|
||||
];
|
||||
|
||||
type CmsProduct = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
category?: string;
|
||||
summary?: string;
|
||||
};
|
||||
|
||||
export async function getProducts(): Promise<Product[]> {
|
||||
const endpoint = process.env.CMS_PUBLIC_API_URL;
|
||||
if (!endpoint) return products;
|
||||
const response = await fetch(`${endpoint.replace(/\/$/, "")}/api/products?where[status][equals]=published&sort=sortOrder&limit=100`, { cache: "no-store" });
|
||||
if (!response.ok) throw new Error(`CMS products request failed: ${response.status}`);
|
||||
const body = (await response.json()) as { docs?: CmsProduct[] };
|
||||
return (body.docs ?? []).map((item) => ({
|
||||
slug: item.slug,
|
||||
name: item.name,
|
||||
category: item.category ?? "产品",
|
||||
shortLabel: "CMS 已发布",
|
||||
summary: item.summary ?? "",
|
||||
state: "placeholder",
|
||||
accent: "blue",
|
||||
}));
|
||||
}
|
||||
|
||||
@ -218,11 +218,18 @@ async def process_task(task: dict) -> None:
|
||||
raise RuntimeError("UPSTREAM_UNAUTHORIZED")
|
||||
if response.status_code >= 400:
|
||||
raise RuntimeError(f"UPSTREAM_HTTP_{response.status_code}")
|
||||
content_type = response.headers.get("content-type", "").split(";", 1)[0].lower()
|
||||
if content_type not in {"audio/wav", "audio/x-wav"}:
|
||||
raise RuntimeError("UPSTREAM_NOT_AUDIO")
|
||||
declared_size = response.headers.get("content-length")
|
||||
if declared_size and int(declared_size) > 50 * 1024 * 1024:
|
||||
raise RuntimeError("UPSTREAM_RESPONSE_TOO_LARGE")
|
||||
audio = response.content
|
||||
if not audio or len(audio) > 50 * 1024 * 1024:
|
||||
raise RuntimeError("UPSTREAM_AUDIO_INVALID")
|
||||
mime = "audio/mpeg" if response_format == "mp3" else "audio/wav"
|
||||
await asyncio.to_thread(finish_task_success, task, audio, mime)
|
||||
if response_format == "wav" and (len(audio) < 12 or audio[:4] != b"RIFF" or audio[8:12] != b"WAVE"):
|
||||
raise RuntimeError("UPSTREAM_AUDIO_CORRUPT")
|
||||
await asyncio.to_thread(finish_task_success, task, audio, "audio/wav")
|
||||
except httpx.TimeoutException:
|
||||
await asyncio.to_thread(finish_task_failure, task, "UPSTREAM_TIMEOUT")
|
||||
except Exception as exc:
|
||||
|
||||
162
services/api/tests/phase3_reliability.py
Normal file
162
services/api/tests/phase3_reliability.py
Normal file
@ -0,0 +1,162 @@
|
||||
"""Isolated Phase 3 reliability checks using a local fake upstream.
|
||||
|
||||
Run on the test server with DATABASE_URL and the API virtualenv. This never calls
|
||||
the real TTS provider and uses test-only accounts in the shared test database.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import psycopg
|
||||
|
||||
|
||||
API_PORT = 8100
|
||||
FAKE_PORT = 9100
|
||||
API_ROOT = f"http://127.0.0.1:{API_PORT}/api/v1"
|
||||
FAKE_ROOT = f"http://127.0.0.1:{FAKE_PORT}"
|
||||
|
||||
|
||||
def wav_bytes() -> bytes:
|
||||
return b"RIFF" + (36).to_bytes(4, "little") + b"WAVEfmt " + bytes(36)
|
||||
|
||||
|
||||
class FakeTts(BaseHTTPRequestHandler):
|
||||
mode = "success"
|
||||
calls = 0
|
||||
|
||||
def do_POST(self): # noqa: N802
|
||||
type(self).calls += 1
|
||||
if self.mode == "timeout":
|
||||
time.sleep(2)
|
||||
if self.mode == "non_audio":
|
||||
payload, content_type = b"not audio", "text/plain"
|
||||
elif self.mode == "corrupt":
|
||||
payload, content_type = b"bad wav", "audio/wav"
|
||||
elif self.mode == "oversized":
|
||||
payload, content_type = b"", "audio/wav"
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Content-Length", str(50 * 1024 * 1024 + 1))
|
||||
self.end_headers()
|
||||
return
|
||||
else:
|
||||
payload, content_type = wav_bytes(), "audio/wav"
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def log_message(self, *_args):
|
||||
return
|
||||
|
||||
|
||||
def start_fake() -> ThreadingHTTPServer:
|
||||
server = ThreadingHTTPServer(("127.0.0.1", FAKE_PORT), FakeTts)
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
return server
|
||||
|
||||
|
||||
def start_api(storage_dir: Path) -> subprocess.Popen:
|
||||
env = os.environ.copy()
|
||||
env.update({
|
||||
"TTS_UPSTREAM_URL": FAKE_ROOT,
|
||||
"TTS_API_KEY": "test-only-fake-key",
|
||||
"TTS_TIMEOUT_SECONDS": "1",
|
||||
"AUDIO_STORAGE_DIR": str(storage_dir),
|
||||
"SESSION_COOKIE_SECURE": "false",
|
||||
})
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
process = subprocess.Popen([sys.executable, "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", str(API_PORT), "--workers", "1"], cwd=root, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
for _ in range(50):
|
||||
try:
|
||||
if httpx.get(f"http://127.0.0.1:{API_PORT}/healthz", timeout=1).status_code == 200:
|
||||
return process
|
||||
except httpx.HTTPError:
|
||||
time.sleep(0.2)
|
||||
process.kill()
|
||||
raise RuntimeError("isolated API did not start")
|
||||
|
||||
|
||||
def stop_api(process: subprocess.Popen, force: bool = False):
|
||||
if process.poll() is None:
|
||||
process.send_signal(signal.SIGKILL if force else signal.SIGTERM)
|
||||
process.wait(timeout=10)
|
||||
|
||||
|
||||
def client() -> httpx.Client:
|
||||
session = httpx.Client(base_url=API_ROOT, timeout=10, follow_redirects=True)
|
||||
csrf = session.get("/auth/csrf").json()["csrf_token"]
|
||||
email = f"phase3-fake-{uuid4().hex[:12]}@example.com"
|
||||
password = f"Test-{uuid4().hex}-Aa1!"
|
||||
body = {"email": email, "password": password}
|
||||
assert session.post("/auth/register", json=body, headers={"X-CSRF-Token": csrf}).status_code == 201
|
||||
assert session.post("/auth/login", json=body, headers={"X-CSRF-Token": csrf}).status_code == 200
|
||||
session.headers.update({"X-CSRF-Token": csrf})
|
||||
return session
|
||||
|
||||
|
||||
def create_task(session: httpx.Client, text: str) -> tuple[int, dict]:
|
||||
response = session.post("/tts/tasks", headers={"Idempotency-Key": f"test-{uuid4().hex}"}, json={"text": text, "voice_id": "default", "parameters": {"format": "wav", "speed": 1}})
|
||||
return response.status_code, response.json()
|
||||
|
||||
|
||||
def wait_task(session: httpx.Client, task_id: str, timeout: float = 8) -> dict:
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
task = session.get(f"/tts/tasks/{task_id}").json()
|
||||
if task["status"] in {"succeeded", "failed"}:
|
||||
return task
|
||||
time.sleep(0.2)
|
||||
raise AssertionError("task did not terminate")
|
||||
|
||||
|
||||
def main():
|
||||
storage = Path("/tmp/kaotings-phase3-reliability")
|
||||
storage.mkdir(parents=True, exist_ok=True)
|
||||
fake = start_fake()
|
||||
api = start_api(storage)
|
||||
results = {}
|
||||
try:
|
||||
session = client()
|
||||
before = session.get("/account/usage").json()
|
||||
code, created = create_task(session, "测试")
|
||||
if code != 202:
|
||||
raise AssertionError(f"success setup task failed: {code} {created}")
|
||||
success = wait_task(session, created["id"])
|
||||
results["success"] = {"create": code, "status": success["status"], "audio": session.get(f"/tts/tasks/{created['id']}/audio").status_code, "download": session.get(f"/tts/tasks/{created['id']}/download").status_code}
|
||||
|
||||
for mode in ["non_audio", "corrupt", "oversized", "timeout"]:
|
||||
FakeTts.mode = mode
|
||||
_, task = create_task(session, mode)
|
||||
results[mode] = wait_task(session, task["id"])["error_code"]
|
||||
FakeTts.mode = "success"
|
||||
|
||||
storage_file = Path("/tmp/kaotings-phase3-storage-file")
|
||||
storage_file.write_text("not a directory")
|
||||
stop_api(api)
|
||||
api = start_api(storage_file)
|
||||
_, task = create_task(session, "存储失败")
|
||||
results["storage_failure"] = wait_task(session, task["id"])["status"]
|
||||
storage_file.unlink(missing_ok=True)
|
||||
results["calls"] = FakeTts.calls
|
||||
results["quota"] = session.get("/account/usage").json()
|
||||
print(json.dumps(results, ensure_ascii=False))
|
||||
finally:
|
||||
stop_api(api)
|
||||
fake.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Reference in New Issue
Block a user