mirror of
https://github.com/Vb-Lg/class-history-template.git
synced 2026-11-03 18:46:40 +00:00
feat: initial class history template
This commit is contained in:
commit
92fa0f8f37
33 files changed
+10714
No files matched your search
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Cloudflare Pages Functions
|
||||
* GET /api/messages → 返回最新留言列表
|
||||
* POST /api/messages → 新增一条留言
|
||||
* 依赖绑定:环境变量 DB(D1 数据库)
|
||||
*/
|
||||
|
||||
const MAX_NAME = 12;
|
||||
const MAX_TEXT = 120;
|
||||
const MAX_LIST = 200; // 列表最多返回多少条
|
||||
const COOLDOWN = 20; // 同一 IP 两条留言的最小间隔(秒)
|
||||
|
||||
/* ---------- 工具 ---------- */
|
||||
function json(data, status = 200) {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
// 如果前端和接口不同域,可加上 CORS 头;同域可删掉
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function today() {
|
||||
const d = new Date();
|
||||
const p = n => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}.${p(d.getMonth() + 1)}.${p(d.getDate())}`;
|
||||
}
|
||||
|
||||
/* 清理文本:去掉控制字符、折叠多余空白 */
|
||||
function clean(str, max) {
|
||||
return String(str ?? '')
|
||||
.replace(/[\u0000-\u001F\u007F]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, max);
|
||||
}
|
||||
|
||||
/* ---------- OPTIONS(预检) ---------- */
|
||||
export async function onRequestOptions() {
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
'Access-Control-Max-Age': '86400'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- GET:拉取列表 ---------- */
|
||||
export async function onRequestGet({ env }) {
|
||||
if (!env.DB) return json({ ok: false, error: '数据库未绑定' }, 500);
|
||||
|
||||
try {
|
||||
const { results } = await env.DB
|
||||
.prepare(
|
||||
`SELECT id, name, text, time
|
||||
FROM messages
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT ?`
|
||||
)
|
||||
.bind(MAX_LIST)
|
||||
.all();
|
||||
|
||||
return json({ ok: true, data: results ?? [] });
|
||||
} catch (err) {
|
||||
console.error('[GET /api/messages]', err);
|
||||
return json({ ok: false, error: '读取失败,请稍后再试' }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- POST:新增留言 ---------- */
|
||||
export async function onRequestPost({ request, env }) {
|
||||
if (!env.DB) return json({ ok: false, error: '数据库未绑定' }, 500);
|
||||
|
||||
// 1. 解析请求体
|
||||
let body;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return json({ ok: false, error: '请求格式错误' }, 400);
|
||||
}
|
||||
|
||||
// 2. 校验内容
|
||||
const name = clean(body.name, MAX_NAME) || '匿名同学';
|
||||
const text = clean(body.text, MAX_TEXT);
|
||||
|
||||
if (!text) {
|
||||
return json({ ok: false, error: '内容不能为空' }, 400);
|
||||
}
|
||||
if (String(body.text ?? '').length > 500) {
|
||||
return json({ ok: false, error: '内容过长' }, 400);
|
||||
}
|
||||
|
||||
// 3. 简单限流:同一 IP 冷却时间内只能发一条
|
||||
const ip =
|
||||
request.headers.get('CF-Connecting-IP') ||
|
||||
request.headers.get('X-Forwarded-For') ||
|
||||
'';
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
try {
|
||||
if (ip) {
|
||||
const last = await env.DB
|
||||
.prepare(
|
||||
`SELECT created_at FROM messages
|
||||
WHERE ip = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1`
|
||||
)
|
||||
.bind(ip)
|
||||
.first();
|
||||
|
||||
if (last && now - last.created_at < COOLDOWN) {
|
||||
const wait = COOLDOWN - (now - last.created_at);
|
||||
return json(
|
||||
{ ok: false, error: `发得太快啦,请等 ${wait} 秒` },
|
||||
429
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 写入数据库
|
||||
const time = today();
|
||||
const res = await env.DB
|
||||
.prepare(
|
||||
`INSERT INTO messages (name, text, time, ip, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
)
|
||||
.bind(name, text, time, ip, now)
|
||||
.run();
|
||||
|
||||
// 5. 顺手裁剪:只保留最近 500 条,避免无限增长
|
||||
// 如果不需要可以删掉这一段
|
||||
try {
|
||||
await env.DB
|
||||
.prepare(
|
||||
`DELETE FROM messages
|
||||
WHERE id NOT IN (
|
||||
SELECT id FROM messages
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT 500
|
||||
)`
|
||||
)
|
||||
.run();
|
||||
} catch (e) {
|
||||
console.warn('[cleanup]', e);
|
||||
}
|
||||
|
||||
return json({
|
||||
ok: true,
|
||||
data: {
|
||||
id: res.meta?.last_row_id ?? null,
|
||||
name,
|
||||
text,
|
||||
time
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[POST /api/messages]', err);
|
||||
return json({ ok: false, error: '保存失败,请稍后再试' }, 500);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
// functions/api/submit.ts
|
||||
interface Env {
|
||||
GITHUB_APP_ID: string;
|
||||
GITHUB_INSTALLATION_ID: string;
|
||||
GITHUB_PRIVATE_KEY: string;
|
||||
GITHUB_OWNER: string;
|
||||
GITHUB_REPO: string;
|
||||
GITHUB_BASE_BRANCH?: string; // 默认 main
|
||||
CONTENT_DIR?: string; // 默认 src/content(对齐 Pages CMS 的 path)
|
||||
MEDIA_DIR?: string; // 默认 public/pages/static
|
||||
MEDIA_URL_PREFIX?: string; // 默认 /pages/static
|
||||
SONGS_DIR?: string; // 默认 src/songs(音乐投稿的目标目录)
|
||||
TURNSTILE_SECRET_KEY?: string; // Turnstile 私钥;未设置则跳过校验(本地开发)
|
||||
}
|
||||
|
||||
const enc = new TextEncoder();
|
||||
|
||||
// ---------- 编码/加密工具 ----------
|
||||
function b64url(bytes: Uint8Array | ArrayBuffer): string {
|
||||
const arr = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
||||
let bin = '';
|
||||
const chunk = 0x8000;
|
||||
for (let i = 0; i < arr.length; i += chunk) {
|
||||
bin += String.fromCharCode(...arr.subarray(i, i + chunk));
|
||||
}
|
||||
return btoa(bin).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
|
||||
}
|
||||
const b64utf8 = (s: string) => b64url(enc.encode(s));
|
||||
|
||||
function abToBase64(buf: ArrayBuffer): string {
|
||||
const arr = new Uint8Array(buf);
|
||||
let bin = '';
|
||||
const chunk = 0x8000;
|
||||
for (let i = 0; i < arr.length; i += chunk) bin += String.fromCharCode(...arr.subarray(i, i + chunk));
|
||||
return btoa(bin);
|
||||
}
|
||||
|
||||
function pemToArrayBuffer(pem: string): ArrayBuffer {
|
||||
const fixed = pem.includes('\n')
|
||||
? pem
|
||||
: pem.replace(/-----BEGIN [^-]+-----\s*/, '-----BEGIN PRIVATE KEY-----\n')
|
||||
.replace(/\s*-----END PRIVATE KEY-----/, '\n-----END PRIVATE KEY-----');
|
||||
const b64 = fixed
|
||||
.replace(/-----BEGIN [^-]+-----/, '')
|
||||
.replace(/-----END [^-]+-----/, '')
|
||||
.replace(/\s+/g, '');
|
||||
const bin = atob(b64);
|
||||
const buf = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
|
||||
return buf.buffer;
|
||||
}
|
||||
|
||||
async function makeAppJwt(appId: string, pem: string) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const head = b64utf8(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
|
||||
const body = b64utf8(JSON.stringify({ iat: now - 60, exp: now + 540, iss: appId }));
|
||||
const data = `${head}.${body}`;
|
||||
const key = await crypto.subtle.importKey(
|
||||
'pkcs8', pemToArrayBuffer(pem),
|
||||
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['sign'],
|
||||
);
|
||||
const sig = await crypto.subtle.sign('RSASSA-PKCS1-v1_5', key, enc.encode(data));
|
||||
return `${data}.${b64url(sig)}`;
|
||||
}
|
||||
|
||||
async function getInstallationToken(env: Env): Promise<string> {
|
||||
const jwt = await makeAppJwt(env.GITHUB_APP_ID, env.GITHUB_PRIVATE_KEY);
|
||||
const res = await fetch(
|
||||
`https://api.github.com/app/installations/${env.GITHUB_INSTALLATION_ID}/access_tokens`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${jwt}`,
|
||||
accept: 'application/vnd.github+json',
|
||||
'user-agent': 'astro-chronicle-fn',
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!res.ok) throw new Error(`installation token failed: ${res.status} ${await res.text()}`);
|
||||
return (await res.json()).token as string;
|
||||
}
|
||||
|
||||
// ---------- Turnstile ----------
|
||||
async function verifyTurnstile(
|
||||
secret: string,
|
||||
token: string,
|
||||
remoteIp?: string,
|
||||
): Promise<boolean> {
|
||||
const body = new URLSearchParams({ secret, response: token });
|
||||
if (remoteIp) body.set('remoteip', remoteIp);
|
||||
|
||||
const res = await fetch(
|
||||
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
},
|
||||
);
|
||||
if (!res.ok) return false;
|
||||
const data = (await res.json()) as { success?: boolean };
|
||||
return data.success === true;
|
||||
}
|
||||
|
||||
// ---------- GitHub REST ----------
|
||||
function gh(token: string) {
|
||||
return async function api<T = any>(path: string, init: RequestInit = {}): Promise<T | null> {
|
||||
const res = await fetch(`https://api.github.com${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
accept: 'application/vnd.github+json',
|
||||
'user-agent': 'astro-chronicle-fn',
|
||||
'content-type': 'application/json',
|
||||
...(init.headers || {}),
|
||||
},
|
||||
});
|
||||
if (res.status === 404) return null; // 用于探测文件是否存在
|
||||
if (!res.ok) throw new Error(`GitHub ${path} → ${res.status}: ${await res.text()}`);
|
||||
return res.json() as Promise<T>;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- 业务辅助 ----------
|
||||
const yyyymmdd = (iso: string) => iso.replace(/-/g, '');
|
||||
|
||||
function extFromMime(mime: string): string {
|
||||
const m = mime.toLowerCase();
|
||||
if (m.includes('png')) return 'png';
|
||||
if (m.includes('jpeg') || m.includes('jpg')) return 'jpg';
|
||||
if (m.includes('webp')) return 'webp';
|
||||
if (m.includes('gif')) return 'gif';
|
||||
if (m.includes('avif')) return 'avif';
|
||||
if (m.includes('svg')) return 'svg';
|
||||
return 'bin';
|
||||
}
|
||||
|
||||
function yamlQuote(s: string): string {
|
||||
// 始终用 JSON 字符串(YAML 兼容)
|
||||
return JSON.stringify(s);
|
||||
}
|
||||
|
||||
// 生成 frontmatter + 正文(严格对齐 Pages CMS 字段)
|
||||
function renderMarkdown(f: {
|
||||
title: string; date: string; summary?: string;
|
||||
author?: string; cover?: string; body: string;
|
||||
}) {
|
||||
const fm = ['---', `title: ${yamlQuote(f.title)}`, `date: ${yamlQuote(f.date)}`];
|
||||
if (f.summary) fm.push(`summary: ${yamlQuote(f.summary)}`);
|
||||
if (f.author) fm.push(`author: ${yamlQuote(f.author)}`);
|
||||
if (f.cover) fm.push(`cover: ${yamlQuote(f.cover)}`);
|
||||
fm.push('---');
|
||||
return `${fm.join('\n')}\n\n${f.body.trim()}\n`;
|
||||
}
|
||||
|
||||
// ---------- 音乐投稿 ----------
|
||||
function renderSongJson(f: {
|
||||
title: string; artist: string; link?: string;
|
||||
note?: string; author?: string; date: string;
|
||||
}) {
|
||||
const obj: Record<string, string> = {
|
||||
title: f.title,
|
||||
artist: f.artist,
|
||||
date: f.date,
|
||||
};
|
||||
if (f.link) obj.link = f.link;
|
||||
if (f.note) obj.note = f.note;
|
||||
if (f.author) obj.author = f.author;
|
||||
return JSON.stringify(obj, null, 2) + '\n';
|
||||
}
|
||||
|
||||
async function handleSongSubmit(
|
||||
env: Env, fd: FormData, json: (data: unknown, status?: number) => Response,
|
||||
): Promise<Response> {
|
||||
const title = (fd.get('title') as string | null)?.trim() ?? '';
|
||||
const artist = (fd.get('artist') as string | null)?.trim() ?? '';
|
||||
const link = (fd.get('link') as string | null)?.trim() ?? '';
|
||||
const note = (fd.get('note') as string | null)?.trim() ?? '';
|
||||
const author = (fd.get('author') as string | null)?.trim() ?? '';
|
||||
const message = (fd.get('message') as string | null)?.trim() ?? '';
|
||||
|
||||
if (!title) return json({ error: '歌名不能为空' }, 400);
|
||||
if (!artist) return json({ error: '歌手不能为空' }, 400);
|
||||
if (title.length > 200) return json({ error: '歌名过长' }, 400);
|
||||
if (artist.length > 120) return json({ error: '歌手名过长' }, 400);
|
||||
if (note.length > 500) return json({ error: '备注过长' }, 400);
|
||||
if (link && !/^https?:\/\//i.test(link)) {
|
||||
return json({ error: '链接必须以 http:// 或 https:// 开头' }, 400);
|
||||
}
|
||||
|
||||
const baseBranch = env.GITHUB_BASE_BRANCH ?? 'main';
|
||||
const songsDir = env.SONGS_DIR ?? 'src/songs';
|
||||
const owner = env.GITHUB_OWNER;
|
||||
const repo = env.GITHUB_REPO;
|
||||
|
||||
const token = await getInstallationToken(env);
|
||||
const api = gh(token);
|
||||
|
||||
// 歌曲文件名:YYYYMMDD-<随机>,避免冲突
|
||||
const today = new Date();
|
||||
const pad2 = (n: number) => String(n).padStart(2, '0');
|
||||
const ymd = `${today.getFullYear()}${pad2(today.getMonth() + 1)}${pad2(today.getDate())}`;
|
||||
const songPath = `${songsDir}/${ymd}-${Math.random().toString(36).slice(2, 8)}.json`;
|
||||
|
||||
const content = renderSongJson({ title, artist, link, note, author, date: ymd });
|
||||
|
||||
const baseRef = await api<{ object: { sha: string } }>(
|
||||
`/repos/${owner}/${repo}/git/ref/heads/${baseBranch}`,
|
||||
);
|
||||
if (!baseRef) throw new Error(`分支不存在: ${baseBranch}`);
|
||||
const baseCommitSha = baseRef.object.sha;
|
||||
const baseCommit = await api<{ tree: { sha: string } }>(
|
||||
`/repos/${owner}/${repo}/git/commits/${baseCommitSha}`,
|
||||
);
|
||||
if (!baseCommit) throw new Error('无法获取 base commit');
|
||||
|
||||
const blob = await api<{ sha: string }>(`/repos/${owner}/${repo}/git/blobs`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content, encoding: 'utf-8' }),
|
||||
});
|
||||
if (!blob) throw new Error('创建歌曲 blob 失败');
|
||||
|
||||
const newTree = await api<{ sha: string }>(`/repos/${owner}/${repo}/git/trees`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
base_tree: baseCommit.tree.sha,
|
||||
tree: [{ path: songPath, mode: '100644', type: 'blob', sha: blob.sha }],
|
||||
}),
|
||||
});
|
||||
if (!newTree) throw new Error('创建 tree 失败');
|
||||
|
||||
const commitMsg = `feat(music): add "${title}" - ${artist}` +
|
||||
(author ? ` by ${author}` : '');
|
||||
const newCommit = await api<{ sha: string }>(`/repos/${owner}/${repo}/git/commits`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
message: commitMsg,
|
||||
tree: newTree.sha,
|
||||
parents: [baseCommitSha],
|
||||
}),
|
||||
});
|
||||
if (!newCommit) throw new Error('创建 commit 失败');
|
||||
|
||||
const branch = `music/${ymd}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
await api(`/repos/${owner}/${repo}/git/refs`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ref: `refs/heads/${branch}`, sha: newCommit.sha }),
|
||||
});
|
||||
|
||||
const prBody = [
|
||||
'**投稿类型:** 🎵 音乐',
|
||||
`**歌名:** ${title}`,
|
||||
`**歌手:** ${artist}`,
|
||||
author ? `**推荐人:** ${author}` : '',
|
||||
link ? `**链接:** ${link}` : '',
|
||||
note ? `**备注:** ${note}` : '',
|
||||
'',
|
||||
`**目标文件:** \`${songPath}\``,
|
||||
'',
|
||||
message ? `**留言:**\n\n${message}` : '',
|
||||
'',
|
||||
'---',
|
||||
'由音乐投稿表单自动创建,请审核后合并。',
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
const pr = await api<{ html_url: string; number: number }>(
|
||||
`/repos/${owner}/${repo}/pulls`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: `🎵 音乐投稿:${title} - ${artist}`,
|
||||
head: branch,
|
||||
base: baseBranch,
|
||||
body: prBody,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!pr) throw new Error('创建 PR 失败');
|
||||
|
||||
return json({ prUrl: pr.html_url, number: pr.number, file: songPath });
|
||||
}
|
||||
|
||||
// ---------- 主处理 ----------
|
||||
export const onRequestPost: PagesFunction<Env> = async ({ request, env }) => {
|
||||
const json = (data: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8' },
|
||||
});
|
||||
|
||||
try {
|
||||
// 1) 解析 multipart
|
||||
const ct = request.headers.get('content-type') || '';
|
||||
if (!ct.includes('multipart/form-data')) {
|
||||
return json({ error: '需要 multipart/form-data' }, 400);
|
||||
}
|
||||
const fd = await request.formData();
|
||||
|
||||
// 1.5) Turnstile 人机验证(配置了 secret 才启用)
|
||||
const turnstileSecret = env.TURNSTILE_SECRET_KEY?.trim();
|
||||
if (turnstileSecret) {
|
||||
const raw = fd.get('cf-turnstile-response');
|
||||
const token = typeof raw === 'string' ? raw.trim() : '';
|
||||
if (!token) {
|
||||
return json({ error: '请先完成人机验证' }, 400);
|
||||
}
|
||||
const ip = request.headers.get('CF-Connecting-IP') ?? undefined;
|
||||
const ok = await verifyTurnstile(turnstileSecret, token, ip);
|
||||
if (!ok) {
|
||||
return json({ error: '人机验证未通过,请刷新页面后重试' }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
// 1.7) 音乐投稿分支(type=music)
|
||||
const submitType = (fd.get('type') as string | null)?.trim() ?? 'chronicle';
|
||||
if (submitType === 'music') {
|
||||
return await handleSongSubmit(env, fd, json);
|
||||
}
|
||||
|
||||
const title = (fd.get('title') as string | null)?.trim() ?? '';
|
||||
const date = (fd.get('date') as string | null)?.trim() ?? '';
|
||||
const summary = (fd.get('summary') as string | null)?.trim() ?? '';
|
||||
const author = (fd.get('author') as string | null)?.trim() ?? '';
|
||||
const message = (fd.get('message') as string | null)?.trim() ?? '';
|
||||
const body = (fd.get('body') as string | null)?.trim() ?? '';
|
||||
const cover = fd.get('cover');
|
||||
|
||||
if (!title) return json({ error: '标题不能为空' }, 400);
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return json({ error: '日期格式应为 YYYY-MM-DD' }, 400);
|
||||
if (!body) return json({ error: '正文不能为空' }, 400);
|
||||
if (title.length > 200) return json({ error: '标题过长' }, 400);
|
||||
if (body.length > 200_000) return json({ error: '正文过长' }, 400);
|
||||
|
||||
const baseBranch = env.GITHUB_BASE_BRANCH ?? 'main';
|
||||
const contentDir = env.CONTENT_DIR ?? 'src/content';
|
||||
const mediaDir = env.MEDIA_DIR ?? 'public/pages/static';
|
||||
const mediaUrl = env.MEDIA_URL_PREFIX ?? '/pages/static';
|
||||
const owner = env.GITHUB_OWNER;
|
||||
const repo = env.GITHUB_REPO;
|
||||
|
||||
const token = await getInstallationToken(env);
|
||||
const api = gh(token);
|
||||
|
||||
// 2) 处理封面图(若有)
|
||||
let coverPath: string | undefined; // 仓库内路径
|
||||
let coverUrl: string | undefined; // markdown 引用
|
||||
let coverBlob: { content: string; encoding: 'base64' } | undefined;
|
||||
|
||||
if (cover instanceof File && cover.size > 0) {
|
||||
if (cover.size > 5 * 1024 * 1024) return json({ error: '封面图超过 5 MB' }, 413);
|
||||
if (!cover.type.startsWith('image/')) return json({ error: '封面必须是图片' }, 400);
|
||||
|
||||
const ext = extFromMime(cover.type);
|
||||
const base = `${yyyymmdd(date)}-cover`;
|
||||
coverPath = `${mediaDir}/${base}.${ext}`;
|
||||
coverUrl = `${mediaUrl}/${base}.${ext}`;
|
||||
|
||||
const buf = await cover.arrayBuffer();
|
||||
coverBlob = { content: abToBase64(buf), encoding: 'base64' };
|
||||
}
|
||||
|
||||
// 3) 决定文章文件名(同日冲突 → -1, -2, …)
|
||||
let articleName = `${yyyymmdd(date)}.md`;
|
||||
let articlePath = `${contentDir}/${articleName}`;
|
||||
let n = 0;
|
||||
// 注意:Contents API 的 GET 返回 404 表示不存在;gh() 已把 404 转成 null
|
||||
while (await api(`/repos/${owner}/${repo}/contents/${encodeURIComponent(articlePath)}?ref=${baseBranch}`)) {
|
||||
n += 1;
|
||||
articleName = `${yyyymmdd(date)}-${n}.md`;
|
||||
articlePath = `${contentDir}/${articleName}`;
|
||||
if (n > 50) return json({ error: '同日投稿过多' }, 409);
|
||||
}
|
||||
|
||||
const markdown = renderMarkdown({
|
||||
title, date, summary, author, cover: coverUrl, body,
|
||||
});
|
||||
|
||||
// 4) 从 base 分支拿 commit & tree
|
||||
const baseRef = await api<{ object: { sha: string } }>(
|
||||
`/repos/${owner}/${repo}/git/ref/heads/${baseBranch}`,
|
||||
);
|
||||
if (!baseRef) throw new Error(`分支不存在: ${baseBranch}`);
|
||||
const baseCommitSha = baseRef.object.sha;
|
||||
|
||||
const baseCommit = await api<{ tree: { sha: string } }>(
|
||||
`/repos/${owner}/${repo}/git/commits/${baseCommitSha}`,
|
||||
);
|
||||
if (!baseCommit) throw new Error('无法获取 base commit');
|
||||
const baseTreeSha = baseCommit.tree.sha;
|
||||
|
||||
// 5) 上传 blobs
|
||||
const treeEntries: Array<{
|
||||
path: string; mode: '100644'; type: 'blob'; sha: string;
|
||||
}> = [];
|
||||
|
||||
// markdown blob(utf-8)
|
||||
const mdBlob = await api<{ sha: string }>(`/repos/${owner}/${repo}/git/blobs`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content: markdown, encoding: 'utf-8' }),
|
||||
});
|
||||
if (!mdBlob) throw new Error('创建 markdown blob 失败');
|
||||
treeEntries.push({ path: articlePath, mode: '100644', type: 'blob', sha: mdBlob.sha });
|
||||
|
||||
// 封面 blob(base64)
|
||||
if (coverBlob && coverPath) {
|
||||
const imgBlob = await api<{ sha: string }>(`/repos/${owner}/${repo}/git/blobs`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(coverBlob),
|
||||
});
|
||||
if (!imgBlob) throw new Error('创建封面 blob 失败');
|
||||
treeEntries.push({ path: coverPath, mode: '100644', type: 'blob', sha: imgBlob.sha });
|
||||
}
|
||||
|
||||
// 6) 建 tree / commit / 分支
|
||||
const newTree = await api<{ sha: string }>(`/repos/${owner}/${repo}/git/trees`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ base_tree: baseTreeSha, tree: treeEntries }),
|
||||
});
|
||||
if (!newTree) throw new Error('创建 tree 失败');
|
||||
|
||||
const commitMsg = `feat(chronicle): add "${title}" (${date})` +
|
||||
(author ? ` by ${author}` : '');
|
||||
|
||||
const newCommit = await api<{ sha: string }>(`/repos/${owner}/${repo}/git/commits`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
message: commitMsg,
|
||||
tree: newTree.sha,
|
||||
parents: [baseCommitSha],
|
||||
}),
|
||||
});
|
||||
if (!newCommit) throw new Error('创建 commit 失败');
|
||||
|
||||
const branch = `chronicle/${yyyymmdd(date)}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
await api(`/repos/${owner}/${repo}/git/refs`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ref: `refs/heads/${branch}`, sha: newCommit.sha }),
|
||||
});
|
||||
|
||||
// 7) 创建 PR
|
||||
const prBody = [
|
||||
`**标题:** ${title}`,
|
||||
`**日期:** ${date}`,
|
||||
author ? `**记录人:** ${author}` : '',
|
||||
summary ? `**摘要:** ${summary}` : '',
|
||||
coverUrl ? `**封面:** \`${coverUrl}\`` : '',
|
||||
'',
|
||||
`**目标文件:** \`${articlePath}\``,
|
||||
'',
|
||||
message ? `**留言:**\n\n${message}` : '',
|
||||
'',
|
||||
'---',
|
||||
'由 Astro 投稿表单自动创建,请审核后合并。',
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
const pr = await api<{ html_url: string; number: number }>(
|
||||
`/repos/${owner}/${repo}/pulls`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: `📝 班史投稿:${title}`,
|
||||
head: branch,
|
||||
base: baseBranch,
|
||||
body: prBody,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!pr) throw new Error('创建 PR 失败');
|
||||
|
||||
return json({
|
||||
prUrl: pr.html_url,
|
||||
number: pr.number,
|
||||
file: articlePath,
|
||||
cover: coverUrl ?? null,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
return json({ error: err?.message ?? '服务异常' }, 500);
|
||||
}
|
||||
};
|
||||
Reference in new issue
Block a user