feat: add Payload CMS foundation

This commit is contained in:
flym 2026-09-09 17:02:46 +08:00
parent 5775214e05
commit 1f593fcab4
28 changed files with 7839 additions and 11 deletions

View File

@ -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()} />

View File

@ -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 (

View File

@ -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
View 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
View 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

File diff suppressed because it is too large Load Diff

26
apps/cms/package.json Normal file
View 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"
}
}

View 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" },
});

View File

@ -0,0 +1,3 @@
export default function FrontendLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

View File

@ -0,0 +1,3 @@
export default function CmsStatusPage() {
return <main><h1>KAOTINGS CMS</h1><p>使</p></main>;
}

View 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 });
}

View 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);

View 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 });
}

View 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 },
],
};

View 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 }],
};

View 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" },
],
};

View 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" },
],
};

View 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
View File

@ -0,0 +1,4 @@
declare module "@payload-config" {
const config: any;
export default config;
}

44
apps/cms/tsconfig.json Normal file
View 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"
]
}

View File

@ -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 数据目录受控导出;私钥不得导出或提交。

View File

@ -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
}

View 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"

View 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

View File

@ -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

View File

@ -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",
}));
}

View File

@ -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:

View 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()