Initial commit: KOC LOOP platform
This commit is contained in:
1675
app/admin-app.tsx
Normal file
1675
app/admin-app.tsx
Normal file
File diff suppressed because it is too large
Load Diff
394
app/api/action/route.ts
Normal file
394
app/api/action/route.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
import { env } from "cloudflare:workers";
|
||||
import { getRequestExecutionContext } from "vinext/shims/request-context";
|
||||
import { backfillAccountProfiles } from "../../../lib/account-enrichment-service";
|
||||
import {
|
||||
ensureSchema,
|
||||
getDashboardData,
|
||||
getRawDb,
|
||||
uid,
|
||||
} from "../../../lib/mvp-db";
|
||||
import {
|
||||
collectDistributionMetrics,
|
||||
createCollectionRunTasks,
|
||||
retryFailedCollections,
|
||||
runDueScheduledCollections,
|
||||
shanghaiDateFromTimestamp,
|
||||
} from "../../../lib/collection-service";
|
||||
import {
|
||||
resolveCollectionMcpConfig,
|
||||
type CollectionMcpBindings,
|
||||
} from "../../../lib/mcp-collection-client";
|
||||
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
|
||||
import {
|
||||
FeishuSourceError,
|
||||
readFeishuSource,
|
||||
type FeishuBindings,
|
||||
type FeishuSource,
|
||||
} from "../../../lib/feishu-client";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
type ActionBody = {
|
||||
action?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
function numberValue(value: unknown, fallback = 0) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
async function createTaskFromSource(
|
||||
source: FeishuSource,
|
||||
name: string,
|
||||
brand: string,
|
||||
dueAt: string,
|
||||
) {
|
||||
const db = getRawDb();
|
||||
const taskId = uid("task");
|
||||
const shareToken = crypto.randomUUID().replaceAll("-", "");
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO tasks
|
||||
(id, name, brand, quantity, claimed_quantity, due_at, status,
|
||||
source_url, source_sheet_id, source_sheet_name, source_synced_at,
|
||||
share_token)
|
||||
VALUES (?, ?, ?, ?, 0, ?, 'importing', ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
taskId,
|
||||
name,
|
||||
brand,
|
||||
source.rows.length,
|
||||
dueAt,
|
||||
source.url,
|
||||
source.sheetId,
|
||||
source.sheetName,
|
||||
source.syncedAt,
|
||||
shareToken,
|
||||
)
|
||||
.run();
|
||||
|
||||
try {
|
||||
const contentStatements = source.rows.map((row) => {
|
||||
const contentId = uid("content");
|
||||
const imageAssets = row.images.map((image) => ({
|
||||
...image,
|
||||
key: `content-assets/${taskId}/${contentId}/${image.index}`,
|
||||
}));
|
||||
return db
|
||||
.prepare(
|
||||
`INSERT INTO contents
|
||||
(id, task_id, title, body, image_assets, status, source, source_row)
|
||||
VALUES (?, ?, ?, ?, ?, 'available', ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
contentId,
|
||||
taskId,
|
||||
row.title,
|
||||
row.body,
|
||||
JSON.stringify(imageAssets),
|
||||
`飞书 · ${source.sheetName}`,
|
||||
row.sourceRow,
|
||||
);
|
||||
});
|
||||
for (let index = 0; index < contentStatements.length; index += 100) {
|
||||
await db.batch(contentStatements.slice(index, index + 100));
|
||||
}
|
||||
await db
|
||||
.prepare("UPDATE tasks SET status = 'active' WHERE id = ?")
|
||||
.bind(taskId)
|
||||
.run();
|
||||
} catch (error) {
|
||||
await db.batch([
|
||||
db.prepare("DELETE FROM contents WHERE task_id = ?").bind(taskId),
|
||||
db.prepare("DELETE FROM tasks WHERE id = ?").bind(taskId),
|
||||
]);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!isAdminRequest(request)) return adminForbidden();
|
||||
try {
|
||||
await ensureSchema();
|
||||
const body = (await request.json()) as ActionBody;
|
||||
const db = getRawDb();
|
||||
|
||||
if (body.action === "inspect_feishu") {
|
||||
const source = await readFeishuSource(
|
||||
String(body.feishuUrl ?? "").trim(),
|
||||
env as unknown as FeishuBindings,
|
||||
);
|
||||
return Response.json({
|
||||
sheetId: source.sheetId,
|
||||
sheetName: source.sheetName,
|
||||
syncedAt: source.syncedAt,
|
||||
rowCount: source.rows.length,
|
||||
columns: source.columns,
|
||||
preview: source.rows.slice(0, 3),
|
||||
});
|
||||
}
|
||||
|
||||
if (body.action === "create_task") {
|
||||
const name = String(body.name ?? "").trim();
|
||||
const brand = String(body.brand ?? "").trim();
|
||||
const dueAt = String(body.dueAt ?? "").trim();
|
||||
if (!name || !brand || !dueAt) {
|
||||
return Response.json(
|
||||
{ error: "请补全任务名称、品牌和截止日期" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const source = await readFeishuSource(
|
||||
String(body.feishuUrl ?? "").trim(),
|
||||
env as unknown as FeishuBindings,
|
||||
);
|
||||
await createTaskFromSource(source, name, brand, dueAt);
|
||||
} else if (body.action === "claim") {
|
||||
const partnerId = String(body.partnerId ?? "");
|
||||
const taskId = String(body.taskId ?? "");
|
||||
const quantity = Math.max(1, Math.min(50, numberValue(body.quantity, 1)));
|
||||
const available = await db
|
||||
.prepare(
|
||||
`SELECT id FROM contents
|
||||
WHERE task_id = ? AND status = 'available'
|
||||
ORDER BY created_at, id
|
||||
LIMIT ?`,
|
||||
)
|
||||
.bind(taskId, quantity)
|
||||
.all<{ id: string }>();
|
||||
if (available.results.length === 0) {
|
||||
return Response.json({ error: "当前没有可领取内容" }, { status: 409 });
|
||||
}
|
||||
const statements = available.results.flatMap((content) => [
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO distributions
|
||||
(id, task_id, content_id, partner_id, status)
|
||||
VALUES (?, ?, ?, ?, 'claimed')`,
|
||||
)
|
||||
.bind(uid("dist"), taskId, content.id, partnerId),
|
||||
db
|
||||
.prepare("UPDATE contents SET status = 'allocated' WHERE id = ?")
|
||||
.bind(content.id),
|
||||
]);
|
||||
statements.push(
|
||||
db
|
||||
.prepare(
|
||||
`UPDATE tasks SET claimed_quantity = claimed_quantity + ?
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.bind(available.results.length, taskId),
|
||||
db
|
||||
.prepare(
|
||||
`UPDATE partners SET claimed_total = claimed_total + ?
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.bind(available.results.length, partnerId),
|
||||
);
|
||||
await db.batch(statements);
|
||||
} else if (body.action === "save_collection_schedule") {
|
||||
const taskId = String(body.taskId ?? "").trim();
|
||||
const startDate = String(body.startDate ?? "").trim();
|
||||
const days = Array.isArray(body.days)
|
||||
? [...new Set(body.days.map(Number))]
|
||||
.filter(
|
||||
(day) =>
|
||||
Number.isInteger(day) && day >= 1 && day <= 7,
|
||||
)
|
||||
.sort((a, b) => a - b)
|
||||
: [];
|
||||
if (!taskId || !/^\d{4}-\d{2}-\d{2}$/.test(startDate)) {
|
||||
return Response.json(
|
||||
{ error: "请选择有效的开始采集日期" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (days.length === 0) {
|
||||
return Response.json(
|
||||
{ error: "请至少选择一个自动采集日" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const task = await db
|
||||
.prepare("SELECT id FROM tasks WHERE id = ?")
|
||||
.bind(taskId)
|
||||
.first<{ id: string }>();
|
||||
if (!task) {
|
||||
return Response.json({ error: "任务不存在" }, { status: 404 });
|
||||
}
|
||||
await db.batch([
|
||||
db
|
||||
.prepare(
|
||||
`UPDATE tasks
|
||||
SET collection_start_date = ?,
|
||||
collection_days = ?,
|
||||
collection_schedule_updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.bind(startDate, JSON.stringify(days), taskId),
|
||||
db
|
||||
.prepare(
|
||||
`UPDATE distributions
|
||||
SET collection_status = CASE
|
||||
WHEN latest_likes IS NULL THEN 'scheduled'
|
||||
ELSE collection_status
|
||||
END,
|
||||
collection_status_description = CASE
|
||||
WHEN latest_likes IS NULL THEN ?
|
||||
ELSE collection_status_description
|
||||
END,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE task_id = ?
|
||||
AND publish_url IS NOT NULL
|
||||
AND publish_url != ''`,
|
||||
)
|
||||
.bind(
|
||||
`已安排${days.length}个采集日,每日10:00执行`,
|
||||
taskId,
|
||||
),
|
||||
]);
|
||||
await createCollectionRunTasks(db, taskId, startDate, days);
|
||||
const catchup = runDueScheduledCollections(
|
||||
db,
|
||||
Date.now(),
|
||||
resolveCollectionMcpConfig(
|
||||
env as unknown as CollectionMcpBindings,
|
||||
),
|
||||
"catchup",
|
||||
taskId,
|
||||
).catch(() => undefined);
|
||||
const executionContext = getRequestExecutionContext();
|
||||
if (executionContext) {
|
||||
executionContext.waitUntil(catchup);
|
||||
} else {
|
||||
await catchup;
|
||||
}
|
||||
} else if (body.action === "run_due_collections") {
|
||||
await runDueScheduledCollections(
|
||||
db,
|
||||
Date.now(),
|
||||
resolveCollectionMcpConfig(
|
||||
env as unknown as CollectionMcpBindings,
|
||||
),
|
||||
"catchup",
|
||||
);
|
||||
} else if (
|
||||
body.action === "collect_now" ||
|
||||
body.action === "collect"
|
||||
) {
|
||||
const distributionId = String(body.distributionId ?? "");
|
||||
const rawDay =
|
||||
body.day === null || body.day === undefined
|
||||
? null
|
||||
: numberValue(body.day);
|
||||
const day =
|
||||
rawDay !== null && Number.isInteger(rawDay) && rawDay >= 1 && rawDay <= 7
|
||||
? rawDay
|
||||
: null;
|
||||
if (body.action === "collect" && day === null) {
|
||||
return Response.json({ error: "采集周期无效" }, { status: 400 });
|
||||
}
|
||||
await collectDistributionMetrics(
|
||||
db,
|
||||
distributionId,
|
||||
shanghaiDateFromTimestamp(Date.now()),
|
||||
day,
|
||||
"manual",
|
||||
resolveCollectionMcpConfig(
|
||||
env as unknown as CollectionMcpBindings,
|
||||
),
|
||||
);
|
||||
} else if (body.action === "retry_failed_collections") {
|
||||
const taskId = String(body.taskId ?? "").trim();
|
||||
if (!taskId) {
|
||||
return Response.json({ error: "请选择需要补采的任务" }, { status: 400 });
|
||||
}
|
||||
await retryFailedCollections(
|
||||
db,
|
||||
taskId,
|
||||
resolveCollectionMcpConfig(
|
||||
env as unknown as CollectionMcpBindings,
|
||||
),
|
||||
);
|
||||
} else if (body.action === "backfill_account_profiles") {
|
||||
const backfill = backfillAccountProfiles(
|
||||
db,
|
||||
resolveCollectionMcpConfig(
|
||||
env as unknown as CollectionMcpBindings,
|
||||
),
|
||||
Math.max(1, Math.min(25, numberValue(body.limit, 10))),
|
||||
).catch(() => undefined);
|
||||
const executionContext = getRequestExecutionContext();
|
||||
if (executionContext) {
|
||||
executionContext.waitUntil(backfill);
|
||||
} else {
|
||||
await backfill;
|
||||
}
|
||||
} else if (body.action === "set_public_account_ids") {
|
||||
const items = Array.isArray(body.items) ? body.items.slice(0, 50) : [];
|
||||
const normalized = items
|
||||
.map((item) => {
|
||||
const record =
|
||||
item && typeof item === "object"
|
||||
? (item as Record<string, unknown>)
|
||||
: {};
|
||||
return {
|
||||
accountId: String(record.accountId ?? "").trim().slice(0, 80),
|
||||
publicAccountId: String(record.publicAccountId ?? "")
|
||||
.trim()
|
||||
.slice(0, 80),
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(item) =>
|
||||
item.accountId &&
|
||||
/^[\p{L}\p{N}._-]{2,80}$/u.test(item.publicAccountId),
|
||||
);
|
||||
if (normalized.length === 0) {
|
||||
return Response.json(
|
||||
{ error: "没有可回填的账号号值" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
await db.batch(
|
||||
normalized.map((item) =>
|
||||
db
|
||||
.prepare(
|
||||
`UPDATE accounts
|
||||
SET public_account_id = ?,
|
||||
last_seen_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.bind(item.publicAccountId, item.accountId),
|
||||
),
|
||||
);
|
||||
} else if (body.action === "manual_metrics") {
|
||||
const distributionId = String(body.distributionId ?? "");
|
||||
const exposure = Math.max(0, numberValue(body.exposure));
|
||||
const views = Math.max(0, numberValue(body.views));
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE distributions SET
|
||||
exposure = ?,
|
||||
views = ?,
|
||||
ocr_status = 'manual',
|
||||
status = CASE WHEN d7_likes IS NOT NULL THEN 'complete' ELSE status END,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.bind(exposure, views, distributionId)
|
||||
.run();
|
||||
} else {
|
||||
return Response.json({ error: "不支持的操作" }, { status: 400 });
|
||||
}
|
||||
|
||||
return Response.json(await getDashboardData());
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ error: error instanceof Error ? error.message : "操作失败" },
|
||||
{ status: error instanceof FeishuSourceError ? error.status : 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
70
app/api/bootstrap/route.ts
Normal file
70
app/api/bootstrap/route.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { env } from "cloudflare:workers";
|
||||
import { getRequestExecutionContext } from "vinext/shims/request-context";
|
||||
import { backfillAccountProfiles } from "../../../lib/account-enrichment-service";
|
||||
import { runDueScheduledCollections } from "../../../lib/collection-service";
|
||||
import {
|
||||
resolveCollectionMcpConfig,
|
||||
type CollectionMcpBindings,
|
||||
} from "../../../lib/mcp-collection-client";
|
||||
import {
|
||||
ensureSchema,
|
||||
getDashboardData,
|
||||
getRawDb,
|
||||
seedIfEmpty,
|
||||
} from "../../../lib/mvp-db";
|
||||
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (!isAdminRequest(request)) return adminForbidden();
|
||||
try {
|
||||
await ensureSchema();
|
||||
await seedIfEmpty();
|
||||
const db = getRawDb();
|
||||
const mcpConfig = resolveCollectionMcpConfig(
|
||||
env as unknown as CollectionMcpBindings,
|
||||
);
|
||||
const collectionCatchup = runDueScheduledCollections(
|
||||
db,
|
||||
Date.now(),
|
||||
mcpConfig,
|
||||
"catchup",
|
||||
).catch((error) => {
|
||||
console.error(
|
||||
"KOC collection catchup failed",
|
||||
error instanceof Error ? error.message : "unknown error",
|
||||
);
|
||||
});
|
||||
const accountBackfill = backfillAccountProfiles(
|
||||
db,
|
||||
mcpConfig,
|
||||
10,
|
||||
)
|
||||
.then((result) => {
|
||||
console.info("KOC account backfill completed", result);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
"KOC account backfill failed",
|
||||
error instanceof Error ? error.message : "unknown error",
|
||||
);
|
||||
});
|
||||
const catchup = Promise.all([
|
||||
collectionCatchup,
|
||||
accountBackfill,
|
||||
]);
|
||||
const executionContext = getRequestExecutionContext();
|
||||
if (executionContext) {
|
||||
executionContext.waitUntil(catchup);
|
||||
} else {
|
||||
await catchup;
|
||||
}
|
||||
return Response.json(await getDashboardData());
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ error: error instanceof Error ? error.message : "加载失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
53
app/api/content-image-upload/route.ts
Normal file
53
app/api/content-image-upload/route.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import feishuSnapshot from "../../../lib/feishu-source-snapshot.json";
|
||||
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
|
||||
import { ensureSchema, getUploadBucket } from "../../../lib/mvp-db";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!isAdminRequest(request)) return adminForbidden();
|
||||
try {
|
||||
await ensureSchema();
|
||||
const form = await request.formData();
|
||||
const sheetId = String(form.get("sheetId") ?? "").trim();
|
||||
const sourceRow = Number(form.get("sourceRow"));
|
||||
const imageIndex = Number(form.get("imageIndex"));
|
||||
const file = form.get("file");
|
||||
if (
|
||||
sheetId !== feishuSnapshot.sheetId ||
|
||||
!Number.isInteger(sourceRow) ||
|
||||
!Number.isInteger(imageIndex) ||
|
||||
!(file instanceof File) ||
|
||||
file.size === 0
|
||||
) {
|
||||
return Response.json({ error: "图片映射参数无效" }, { status: 400 });
|
||||
}
|
||||
if (!file.type.startsWith("image/") || file.size > 8_000_000) {
|
||||
return Response.json(
|
||||
{ error: "仅支持8MB以内的图片" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const row = feishuSnapshot.rows.find(
|
||||
(item) => item.sourceRow === sourceRow,
|
||||
);
|
||||
const asset = row?.images.find((item) => item.index === imageIndex);
|
||||
if (!asset) {
|
||||
return Response.json({ error: "图片不属于当前飞书内容表" }, { status: 422 });
|
||||
}
|
||||
await getUploadBucket().put(asset.key, await file.arrayBuffer(), {
|
||||
httpMetadata: { contentType: file.type },
|
||||
customMetadata: {
|
||||
sheetId,
|
||||
sourceRow: String(sourceRow),
|
||||
imageIndex: String(imageIndex),
|
||||
},
|
||||
});
|
||||
return Response.json({ uploaded: true });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ error: error instanceof Error ? error.message : "图片同步失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
53
app/api/creator-screenshot/route.ts
Normal file
53
app/api/creator-screenshot/route.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
ensureSchema,
|
||||
getRawDb,
|
||||
getUploadBucket,
|
||||
} from "../../../lib/mvp-db";
|
||||
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (!isAdminRequest(request)) return adminForbidden();
|
||||
try {
|
||||
await ensureSchema();
|
||||
const distributionId = new URL(request.url).searchParams
|
||||
.get("distribution")
|
||||
?.trim();
|
||||
if (!distributionId) {
|
||||
return Response.json({ error: "缺少分发记录" }, { status: 400 });
|
||||
}
|
||||
const row = await getRawDb()
|
||||
.prepare(
|
||||
`SELECT screenshot_key FROM distributions
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.bind(distributionId)
|
||||
.first<{ screenshot_key: string | null }>();
|
||||
if (
|
||||
!row?.screenshot_key ||
|
||||
!row.screenshot_key.startsWith("creator-center/")
|
||||
) {
|
||||
return Response.json({ error: "创作者截图不存在" }, { status: 404 });
|
||||
}
|
||||
const object = await getUploadBucket().get(row.screenshot_key);
|
||||
if (!object) {
|
||||
return Response.json({ error: "创作者截图不存在" }, { status: 404 });
|
||||
}
|
||||
const headers = new Headers({
|
||||
"Cache-Control": "private, no-store",
|
||||
"Content-Disposition": 'inline; filename="creator-center-screenshot"',
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
});
|
||||
object.writeHttpMetadata(headers);
|
||||
if (!headers.get("Content-Type")) {
|
||||
headers.set("Content-Type", "image/jpeg");
|
||||
}
|
||||
return new Response(object.body, { headers });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ error: error instanceof Error ? error.message : "截图读取失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
129
app/api/partner-image/route.ts
Normal file
129
app/api/partner-image/route.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { env } from "cloudflare:workers";
|
||||
import {
|
||||
ensureSchema,
|
||||
getRawDb,
|
||||
getUploadBucket,
|
||||
} from "../../../lib/mvp-db";
|
||||
import {
|
||||
downloadFeishuMedia,
|
||||
type FeishuBindings,
|
||||
} from "../../../lib/feishu-client";
|
||||
import {
|
||||
partnerOptions,
|
||||
withPartnerCors,
|
||||
} from "../../../lib/partner-cors";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
type StoredAsset = {
|
||||
index: number;
|
||||
key: string;
|
||||
fileToken?: string;
|
||||
};
|
||||
|
||||
function textValue(value: string | null, maxLength = 100) {
|
||||
return String(value ?? "").trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
function findAsset(value: string, imageIndex: number) {
|
||||
try {
|
||||
const assets = JSON.parse(value) as StoredAsset[];
|
||||
return Array.isArray(assets)
|
||||
? assets.find(
|
||||
(asset) =>
|
||||
asset.index === imageIndex &&
|
||||
typeof asset.key === "string" &&
|
||||
asset.key.startsWith("content-assets/") &&
|
||||
(asset.fileToken === undefined ||
|
||||
typeof asset.fileToken === "string"),
|
||||
)
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGet(request: Request) {
|
||||
try {
|
||||
await ensureSchema();
|
||||
const url = new URL(request.url);
|
||||
const taskToken = textValue(url.searchParams.get("task"));
|
||||
const claimToken = textValue(url.searchParams.get("claim"));
|
||||
const delegationToken = textValue(url.searchParams.get("share"));
|
||||
const distributionId = textValue(url.searchParams.get("distribution"));
|
||||
const imageIndex = Number(url.searchParams.get("index"));
|
||||
if (
|
||||
(!delegationToken && (!taskToken || !claimToken)) ||
|
||||
!distributionId ||
|
||||
!Number.isInteger(imageIndex) ||
|
||||
imageIndex < 1
|
||||
) {
|
||||
return Response.json({ error: "图片链接不完整" }, { status: 400 });
|
||||
}
|
||||
const row = delegationToken
|
||||
? await getRawDb()
|
||||
.prepare(
|
||||
`SELECT c.image_assets
|
||||
FROM distributions d
|
||||
JOIN contents c ON c.id = d.content_id
|
||||
JOIN delegation_bundles b ON b.id = d.delegation_bundle_id
|
||||
WHERE d.id = ?
|
||||
AND b.share_token = ?
|
||||
AND b.status = 'active'`,
|
||||
)
|
||||
.bind(distributionId, delegationToken)
|
||||
.first<{ image_assets: string }>()
|
||||
: await getRawDb()
|
||||
.prepare(
|
||||
`SELECT c.image_assets
|
||||
FROM distributions d
|
||||
JOIN contents c ON c.id = d.content_id
|
||||
JOIN claims cl ON cl.id = d.claim_id
|
||||
JOIN tasks t ON t.id = d.task_id
|
||||
WHERE d.id = ?
|
||||
AND cl.claim_token = ?
|
||||
AND t.share_token = ?
|
||||
AND cl.task_id = t.id`,
|
||||
)
|
||||
.bind(distributionId, claimToken, taskToken)
|
||||
.first<{ image_assets: string }>();
|
||||
const asset = row ? findAsset(row.image_assets, imageIndex) : undefined;
|
||||
if (!asset) {
|
||||
return Response.json({ error: "没有找到这张笔记图片" }, { status: 404 });
|
||||
}
|
||||
const bucket = getUploadBucket();
|
||||
let object = await bucket.get(asset.key);
|
||||
if (!object && asset.fileToken) {
|
||||
const media = await downloadFeishuMedia(
|
||||
asset.fileToken,
|
||||
env as unknown as FeishuBindings,
|
||||
);
|
||||
await bucket.put(asset.key, media.bytes, {
|
||||
httpMetadata: { contentType: media.contentType },
|
||||
customMetadata: { source: "feishu-api" },
|
||||
});
|
||||
object = await bucket.get(asset.key);
|
||||
}
|
||||
if (!object) {
|
||||
return Response.json({ error: "图片同步失败,请稍后重试" }, { status: 404 });
|
||||
}
|
||||
const headers = new Headers();
|
||||
object.writeHttpMetadata(headers);
|
||||
headers.set("Cache-Control", "private, max-age=3600");
|
||||
headers.set("Content-Disposition", `inline; filename="note-${imageIndex}"`);
|
||||
return new Response(object.body, { headers });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ error: error instanceof Error ? error.message : "图片读取失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
return withPartnerCors(request, await handleGet(request));
|
||||
}
|
||||
|
||||
export async function OPTIONS(request: Request) {
|
||||
return partnerOptions(request);
|
||||
}
|
||||
164
app/api/partner-upload/route.ts
Normal file
164
app/api/partner-upload/route.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import {
|
||||
ensureSchema,
|
||||
getRawDb,
|
||||
getUploadBucket,
|
||||
uid,
|
||||
} from "../../../lib/mvp-db";
|
||||
import {
|
||||
partnerOptions,
|
||||
withPartnerCors,
|
||||
} from "../../../lib/partner-cors";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
async function readUpload(request: Request) {
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
if (contentType.startsWith("multipart/form-data")) {
|
||||
const form = await request.formData();
|
||||
const file = form.get("file");
|
||||
if (!(file instanceof File)) return null;
|
||||
return {
|
||||
taskToken: String(form.get("taskToken") ?? "").trim(),
|
||||
claimToken: String(form.get("claimToken") ?? "").trim(),
|
||||
delegationToken: String(form.get("delegationToken") ?? "").trim(),
|
||||
distributionId: String(form.get("distributionId") ?? "").trim(),
|
||||
uploadKind: String(form.get("uploadKind") ?? "publish").trim(),
|
||||
fileName: file.name,
|
||||
fileType: file.type,
|
||||
fileBytes: await file.arrayBuffer(),
|
||||
};
|
||||
}
|
||||
const encodedName = request.headers.get("x-koc-file-name") ?? "screenshot.jpg";
|
||||
let fileName = "screenshot.jpg";
|
||||
try {
|
||||
fileName = decodeURIComponent(encodedName);
|
||||
} catch {
|
||||
fileName = "screenshot.jpg";
|
||||
}
|
||||
return {
|
||||
taskToken: String(request.headers.get("x-koc-task") ?? "").trim(),
|
||||
claimToken: String(request.headers.get("x-koc-claim") ?? "").trim(),
|
||||
delegationToken: String(
|
||||
request.headers.get("x-koc-delegation") ?? "",
|
||||
).trim(),
|
||||
distributionId: String(
|
||||
request.headers.get("x-koc-distribution") ?? "",
|
||||
).trim(),
|
||||
uploadKind: String(
|
||||
request.headers.get("x-koc-upload-kind") ?? "publish",
|
||||
).trim(),
|
||||
fileName,
|
||||
fileType: contentType.split(";")[0].trim(),
|
||||
fileBytes: await request.arrayBuffer(),
|
||||
};
|
||||
}
|
||||
|
||||
async function handlePost(request: Request) {
|
||||
try {
|
||||
await ensureSchema();
|
||||
const upload = await readUpload(request);
|
||||
if (
|
||||
!upload ||
|
||||
(!upload.claimToken && !upload.delegationToken) ||
|
||||
(!upload.delegationToken && !upload.taskToken) ||
|
||||
!upload.distributionId ||
|
||||
upload.fileBytes.byteLength === 0
|
||||
) {
|
||||
return Response.json({ error: "请选择发布截图" }, { status: 400 });
|
||||
}
|
||||
if (
|
||||
!upload.fileType.startsWith("image/") ||
|
||||
upload.fileBytes.byteLength > 8_000_000
|
||||
) {
|
||||
return Response.json(
|
||||
{ error: "仅支持8MB以内的图片" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const isCreatorCenter = upload.uploadKind === "creator-center";
|
||||
const assignment = upload.delegationToken
|
||||
? await getRawDb()
|
||||
.prepare(
|
||||
`SELECT d.id, d.publish_url
|
||||
FROM distributions d
|
||||
JOIN delegation_bundles b ON b.id = d.delegation_bundle_id
|
||||
WHERE d.id = ?
|
||||
AND b.share_token = ?
|
||||
AND b.status = 'active'`,
|
||||
)
|
||||
.bind(upload.distributionId, upload.delegationToken)
|
||||
.first<{ id: string; publish_url: string | null }>()
|
||||
: await getRawDb()
|
||||
.prepare(
|
||||
`SELECT d.id, d.publish_url
|
||||
FROM distributions d
|
||||
JOIN claims c ON c.id = d.claim_id
|
||||
JOIN tasks t ON t.id = d.task_id
|
||||
WHERE d.id = ?
|
||||
AND c.claim_token = ?
|
||||
AND t.share_token = ?
|
||||
AND c.task_id = t.id`,
|
||||
)
|
||||
.bind(upload.distributionId, upload.claimToken, upload.taskToken)
|
||||
.first<{ id: string; publish_url: string | null }>();
|
||||
if (!assignment) {
|
||||
return Response.json({ error: "笔记与领取凭证不匹配" }, { status: 403 });
|
||||
}
|
||||
if (isCreatorCenter && !assignment.publish_url) {
|
||||
return Response.json(
|
||||
{ error: "请先回填这篇笔记的发布信息" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
const extension =
|
||||
upload.fileName.split(".").at(-1)?.replace(/[^a-zA-Z0-9]/g, "") || "jpg";
|
||||
const key = isCreatorCenter
|
||||
? `creator-center/${upload.distributionId}/${uid("shot")}.${extension}`
|
||||
: `publish-evidence/${upload.distributionId}/${uid("shot")}.${extension}`;
|
||||
await getUploadBucket().put(key, upload.fileBytes, {
|
||||
httpMetadata: { contentType: upload.fileType },
|
||||
});
|
||||
if (isCreatorCenter) {
|
||||
await getRawDb()
|
||||
.prepare(
|
||||
`UPDATE distributions SET
|
||||
screenshot_key = ?,
|
||||
ocr_status = CASE
|
||||
WHEN exposure IS NOT NULL AND views IS NOT NULL THEN ocr_status
|
||||
ELSE 'uploaded'
|
||||
END,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.bind(key, upload.distributionId)
|
||||
.run();
|
||||
} else {
|
||||
await getRawDb()
|
||||
.prepare(
|
||||
`UPDATE distributions SET
|
||||
publish_screenshot_key = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.bind(key, upload.distributionId)
|
||||
.run();
|
||||
}
|
||||
return Response.json({
|
||||
uploaded: true,
|
||||
kind: isCreatorCenter ? "creator-center" : "publish",
|
||||
});
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ error: error instanceof Error ? error.message : "上传失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
return withPartnerCors(request, await handlePost(request));
|
||||
}
|
||||
|
||||
export async function OPTIONS(request: Request) {
|
||||
return partnerOptions(request);
|
||||
}
|
||||
951
app/api/partner/route.ts
Normal file
951
app/api/partner/route.ts
Normal file
@@ -0,0 +1,951 @@
|
||||
import { env } from "cloudflare:workers";
|
||||
import { getRequestExecutionContext } from "vinext/shims/request-context";
|
||||
import { enrichDistributionAccount } from "../../../lib/account-enrichment-service";
|
||||
import { createCollectionRunTasks } from "../../../lib/collection-service";
|
||||
import {
|
||||
resolveCollectionMcpConfig,
|
||||
type CollectionMcpBindings,
|
||||
} from "../../../lib/mcp-collection-client";
|
||||
import {
|
||||
ensureSchema,
|
||||
getRawDb,
|
||||
hashText,
|
||||
uid,
|
||||
} from "../../../lib/mvp-db";
|
||||
import {
|
||||
accountFromPublishLink,
|
||||
extractXhsPublishUrl,
|
||||
} from "../../../lib/partner-utils";
|
||||
import {
|
||||
partnerOptions,
|
||||
withPartnerCors,
|
||||
} from "../../../lib/partner-cors";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
type PartnerBody = {
|
||||
action?: string;
|
||||
taskToken?: string;
|
||||
claimToken?: string;
|
||||
delegationToken?: string;
|
||||
distributionId?: string;
|
||||
distributionIds?: string[];
|
||||
delegationLabel?: string;
|
||||
delegationBundleId?: string;
|
||||
claimantName?: string;
|
||||
quantity?: number;
|
||||
accountNickname?: string;
|
||||
publishUrl?: string;
|
||||
exposure?: number | string;
|
||||
views?: number | string;
|
||||
};
|
||||
|
||||
type ImageAsset = {
|
||||
index: number;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
};
|
||||
|
||||
function textValue(value: unknown, maxLength = 200) {
|
||||
return String(value ?? "").trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
function quantityValue(value: unknown) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? Math.max(1, Math.min(50, Math.floor(parsed))) : 1;
|
||||
}
|
||||
|
||||
function creatorMetricValue(value: unknown) {
|
||||
const normalized = String(value ?? "").trim();
|
||||
if (!/^\d{1,12}$/.test(normalized)) return null;
|
||||
const parsed = Number(normalized);
|
||||
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
|
||||
}
|
||||
|
||||
function idList(value: unknown) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return [
|
||||
...new Set(
|
||||
value
|
||||
.map((item) => textValue(item, 80))
|
||||
.filter(Boolean)
|
||||
.slice(0, 50),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function publicImageAssets(value: unknown): ImageAsset[] {
|
||||
try {
|
||||
const assets = JSON.parse(String(value ?? "[]")) as Array<
|
||||
Partial<ImageAsset> & { key?: string }
|
||||
>;
|
||||
if (!Array.isArray(assets)) return [];
|
||||
return assets
|
||||
.map((asset) => ({
|
||||
index: Number(asset.index),
|
||||
width:
|
||||
typeof asset.width === "number" && Number.isFinite(asset.width)
|
||||
? asset.width
|
||||
: null,
|
||||
height:
|
||||
typeof asset.height === "number" && Number.isFinite(asset.height)
|
||||
? asset.height
|
||||
: null,
|
||||
}))
|
||||
.filter((asset) => Number.isInteger(asset.index) && asset.index > 0);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function findTask(taskToken: string) {
|
||||
return getRawDb()
|
||||
.prepare(
|
||||
`SELECT id, name, brand, quantity, claimed_quantity, due_at, status
|
||||
FROM tasks WHERE share_token = ?`,
|
||||
)
|
||||
.bind(taskToken)
|
||||
.first<{
|
||||
id: string;
|
||||
name: string;
|
||||
brand: string;
|
||||
quantity: number;
|
||||
claimed_quantity: number;
|
||||
due_at: string;
|
||||
status: string;
|
||||
}>();
|
||||
}
|
||||
|
||||
async function findDelegationAccess(delegationToken: string) {
|
||||
return getRawDb()
|
||||
.prepare(
|
||||
`SELECT
|
||||
t.id,
|
||||
t.name,
|
||||
t.brand,
|
||||
t.quantity,
|
||||
t.claimed_quantity,
|
||||
t.due_at,
|
||||
t.status,
|
||||
b.id AS bundle_id,
|
||||
b.label AS bundle_label,
|
||||
b.quantity AS bundle_quantity,
|
||||
b.created_at AS bundle_created_at
|
||||
FROM delegation_bundles b
|
||||
JOIN tasks t ON t.id = b.task_id
|
||||
WHERE b.share_token = ? AND b.status = 'active'`,
|
||||
)
|
||||
.bind(delegationToken)
|
||||
.first<{
|
||||
id: string;
|
||||
name: string;
|
||||
brand: string;
|
||||
quantity: number;
|
||||
claimed_quantity: number;
|
||||
due_at: string;
|
||||
status: string;
|
||||
bundle_id: string;
|
||||
bundle_label: string;
|
||||
bundle_quantity: number;
|
||||
bundle_created_at: string;
|
||||
}>();
|
||||
}
|
||||
|
||||
async function findAccessibleAssignment(
|
||||
taskId: string,
|
||||
distributionId: string,
|
||||
claimToken: string,
|
||||
delegationToken: string,
|
||||
) {
|
||||
const db = getRawDb();
|
||||
const select = `SELECT
|
||||
d.id,
|
||||
d.partner_id,
|
||||
d.account_id,
|
||||
d.publish_url,
|
||||
d.publish_screenshot_key,
|
||||
d.screenshot_key`;
|
||||
if (delegationToken) {
|
||||
return db
|
||||
.prepare(
|
||||
`${select}
|
||||
FROM distributions d
|
||||
JOIN delegation_bundles b ON b.id = d.delegation_bundle_id
|
||||
WHERE d.id = ?
|
||||
AND b.share_token = ?
|
||||
AND b.task_id = ?
|
||||
AND b.status = 'active'`,
|
||||
)
|
||||
.bind(distributionId, delegationToken, taskId)
|
||||
.first<{
|
||||
id: string;
|
||||
partner_id: string;
|
||||
account_id: string | null;
|
||||
publish_url: string | null;
|
||||
publish_screenshot_key: string | null;
|
||||
screenshot_key: string | null;
|
||||
}>();
|
||||
}
|
||||
if (!claimToken) return null;
|
||||
return db
|
||||
.prepare(
|
||||
`${select}
|
||||
FROM distributions d
|
||||
JOIN claims c ON c.id = d.claim_id
|
||||
WHERE d.id = ? AND c.claim_token = ? AND c.task_id = ?`,
|
||||
)
|
||||
.bind(distributionId, claimToken, taskId)
|
||||
.first<{
|
||||
id: string;
|
||||
partner_id: string;
|
||||
account_id: string | null;
|
||||
publish_url: string | null;
|
||||
publish_screenshot_key: string | null;
|
||||
screenshot_key: string | null;
|
||||
}>();
|
||||
}
|
||||
|
||||
async function handleGet(request: Request) {
|
||||
try {
|
||||
await ensureSchema();
|
||||
const url = new URL(request.url);
|
||||
const taskToken = textValue(url.searchParams.get("task"), 80);
|
||||
const claimToken = textValue(url.searchParams.get("claim"), 80);
|
||||
const delegationToken = textValue(url.searchParams.get("share"), 80);
|
||||
const delegationAccess = delegationToken
|
||||
? await findDelegationAccess(delegationToken)
|
||||
: null;
|
||||
const task = delegationAccess ?? (await findTask(taskToken));
|
||||
if (!task) {
|
||||
return Response.json(
|
||||
{
|
||||
error: delegationToken
|
||||
? "分享链接无效、已撤销或已失效"
|
||||
: "任务链接无效或已失效",
|
||||
},
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const available = await getRawDb()
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count FROM contents
|
||||
WHERE task_id = ? AND status = 'available'`,
|
||||
)
|
||||
.bind(task.id)
|
||||
.first<{ count: number }>();
|
||||
|
||||
let claim: Record<string, unknown> | null = null;
|
||||
let delegation: Record<string, unknown> | null = null;
|
||||
if (claimToken && !delegationToken) {
|
||||
const claimRow = await getRawDb()
|
||||
.prepare(
|
||||
`SELECT id, claimant_name, quantity, created_at
|
||||
FROM claims
|
||||
WHERE claim_token = ? AND task_id = ?`,
|
||||
)
|
||||
.bind(claimToken, task.id)
|
||||
.first<{
|
||||
id: string;
|
||||
claimant_name: string;
|
||||
quantity: number;
|
||||
created_at: string;
|
||||
}>();
|
||||
if (!claimRow) {
|
||||
return Response.json({ error: "领取凭证无效" }, { status: 403 });
|
||||
}
|
||||
const assignments = await getRawDb()
|
||||
.prepare(
|
||||
`SELECT
|
||||
d.id,
|
||||
d.status,
|
||||
d.publish_url,
|
||||
d.publish_time,
|
||||
d.publish_screenshot_key,
|
||||
d.screenshot_key AS creator_screenshot_key,
|
||||
d.ocr_status,
|
||||
d.exposure,
|
||||
d.views,
|
||||
c.title,
|
||||
c.body,
|
||||
c.source_row,
|
||||
c.image_assets,
|
||||
a.nickname AS account_nickname,
|
||||
b.id AS delegation_bundle_id,
|
||||
b.label AS delegation_label
|
||||
FROM distributions d
|
||||
JOIN contents c ON c.id = d.content_id
|
||||
LEFT JOIN accounts a ON a.id = d.account_id
|
||||
LEFT JOIN delegation_bundles b
|
||||
ON b.id = d.delegation_bundle_id AND b.status = 'active'
|
||||
WHERE d.claim_id = ?
|
||||
ORDER BY d.claimed_at, d.id`,
|
||||
)
|
||||
.bind(claimRow.id)
|
||||
.all();
|
||||
const delegations = await getRawDb()
|
||||
.prepare(
|
||||
`SELECT
|
||||
b.id,
|
||||
b.label,
|
||||
b.share_token,
|
||||
b.quantity,
|
||||
b.status,
|
||||
b.created_at,
|
||||
b.revoked_at,
|
||||
SUM(CASE WHEN d.publish_url IS NOT NULL AND d.publish_url != '' THEN 1 ELSE 0 END) AS completed_count,
|
||||
SUM(CASE WHEN d.screenshot_key IS NOT NULL AND d.exposure IS NOT NULL AND d.views IS NOT NULL THEN 1 ELSE 0 END) AS creator_completed_count
|
||||
FROM delegation_bundles b
|
||||
LEFT JOIN distributions d ON d.delegation_bundle_id = b.id
|
||||
WHERE b.claim_id = ?
|
||||
GROUP BY b.id, b.label, b.share_token, b.quantity, b.status, b.created_at, b.revoked_at
|
||||
ORDER BY b.created_at DESC, b.id DESC`,
|
||||
)
|
||||
.bind(claimRow.id)
|
||||
.all();
|
||||
claim = {
|
||||
id: claimRow.id,
|
||||
claimantName: claimRow.claimant_name,
|
||||
quantity: claimRow.quantity,
|
||||
createdAt: claimRow.created_at,
|
||||
assignments: assignments.results.map((assignment) => ({
|
||||
...assignment,
|
||||
images: publicImageAssets(assignment.image_assets),
|
||||
image_assets: undefined,
|
||||
})),
|
||||
delegations: delegations.results,
|
||||
};
|
||||
} else if (delegationAccess) {
|
||||
const assignments = await getRawDb()
|
||||
.prepare(
|
||||
`SELECT
|
||||
d.id,
|
||||
d.status,
|
||||
d.publish_url,
|
||||
d.publish_time,
|
||||
d.publish_screenshot_key,
|
||||
d.screenshot_key AS creator_screenshot_key,
|
||||
d.ocr_status,
|
||||
d.exposure,
|
||||
d.views,
|
||||
c.title,
|
||||
c.body,
|
||||
c.source_row,
|
||||
c.image_assets,
|
||||
a.nickname AS account_nickname
|
||||
FROM distributions d
|
||||
JOIN contents c ON c.id = d.content_id
|
||||
LEFT JOIN accounts a ON a.id = d.account_id
|
||||
WHERE d.delegation_bundle_id = ?
|
||||
ORDER BY d.claimed_at, d.id`,
|
||||
)
|
||||
.bind(delegationAccess.bundle_id)
|
||||
.all();
|
||||
delegation = {
|
||||
id: delegationAccess.bundle_id,
|
||||
label: "转派发布包",
|
||||
quantity: delegationAccess.bundle_quantity,
|
||||
createdAt: delegationAccess.bundle_created_at,
|
||||
assignments: assignments.results.map((assignment) => ({
|
||||
...assignment,
|
||||
images: publicImageAssets(assignment.image_assets),
|
||||
image_assets: undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
task: delegationAccess
|
||||
? {
|
||||
name: task.name,
|
||||
brand: task.brand,
|
||||
dueAt: task.due_at,
|
||||
status: task.status,
|
||||
}
|
||||
: {
|
||||
name: task.name,
|
||||
brand: task.brand,
|
||||
quantity: task.quantity,
|
||||
claimedQuantity: task.claimed_quantity,
|
||||
dueAt: task.due_at,
|
||||
status: task.status,
|
||||
availableQuantity: available?.count ?? 0,
|
||||
},
|
||||
claim,
|
||||
delegation,
|
||||
});
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ error: error instanceof Error ? error.message : "暂时无法打开任务" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePost(request: Request) {
|
||||
try {
|
||||
await ensureSchema();
|
||||
const body = (await request.json()) as PartnerBody;
|
||||
const taskToken = textValue(body.taskToken, 80);
|
||||
const delegationToken = textValue(body.delegationToken, 80);
|
||||
const delegationAccess = delegationToken
|
||||
? await findDelegationAccess(delegationToken)
|
||||
: null;
|
||||
const task = delegationAccess ?? (await findTask(taskToken));
|
||||
if (!task) {
|
||||
return Response.json(
|
||||
{ error: delegationToken ? "分享链接无效或已撤销" : "任务链接无效" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
if (
|
||||
delegationToken &&
|
||||
body.action !== "submit" &&
|
||||
body.action !== "submit_creator_metrics"
|
||||
) {
|
||||
return Response.json(
|
||||
{ error: "分享链接只能用于查看和回填包内笔记" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
const db = getRawDb();
|
||||
|
||||
if (body.action === "recover") {
|
||||
const claimantName = textValue(body.claimantName, 40);
|
||||
if (claimantName.length < 2) {
|
||||
return Response.json(
|
||||
{ error: "请填写领取时使用的企微昵称或联系人" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const partnerId = `partner-ext-${hashText(
|
||||
`${task.id}:${claimantName.toLowerCase()}`,
|
||||
)}`;
|
||||
const recovered = await db
|
||||
.prepare(
|
||||
`SELECT
|
||||
c.claim_token,
|
||||
c.quantity,
|
||||
c.created_at,
|
||||
COUNT(d.id) AS note_count,
|
||||
SUM(CASE WHEN d.publish_url IS NOT NULL AND d.publish_url != '' THEN 1 ELSE 0 END) AS completed_count,
|
||||
MIN(co.title) AS first_title
|
||||
FROM claims c
|
||||
LEFT JOIN distributions d ON d.claim_id = c.id
|
||||
LEFT JOIN contents co ON co.id = d.content_id
|
||||
WHERE c.task_id = ? AND c.partner_id = ?
|
||||
GROUP BY c.id, c.claim_token, c.quantity, c.created_at
|
||||
ORDER BY c.created_at DESC, c.id DESC
|
||||
LIMIT 20`,
|
||||
)
|
||||
.bind(task.id, partnerId)
|
||||
.all<{
|
||||
claim_token: string;
|
||||
quantity: number;
|
||||
created_at: string;
|
||||
note_count: number;
|
||||
completed_count: number;
|
||||
first_title: string | null;
|
||||
}>();
|
||||
if (recovered.results.length === 0) {
|
||||
return Response.json(
|
||||
{ error: "没有找到领取记录,请确认昵称与领取时完全一致" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
return Response.json({
|
||||
claims: recovered.results.map((claim) => ({
|
||||
claimToken: claim.claim_token,
|
||||
quantity: claim.note_count || claim.quantity,
|
||||
completedCount: claim.completed_count || 0,
|
||||
createdAt: claim.created_at,
|
||||
firstTitle: claim.first_title || "领取的笔记",
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (body.action === "claim") {
|
||||
if (task.status !== "active") {
|
||||
return Response.json({ error: "任务已结束,无法继续领取" }, { status: 409 });
|
||||
}
|
||||
const claimantName = textValue(body.claimantName, 40);
|
||||
const quantity = quantityValue(body.quantity);
|
||||
if (claimantName.length < 2) {
|
||||
return Response.json({ error: "请填写企微昵称或联系人" }, { status: 400 });
|
||||
}
|
||||
const available = await db
|
||||
.prepare(
|
||||
`SELECT id FROM contents
|
||||
WHERE task_id = ? AND status = 'available'
|
||||
ORDER BY COALESCE(source_row, 999999), created_at, id
|
||||
LIMIT ?`,
|
||||
)
|
||||
.bind(task.id, quantity)
|
||||
.all<{ id: string }>();
|
||||
if (available.results.length === 0) {
|
||||
return Response.json({ error: "当前任务已领完" }, { status: 409 });
|
||||
}
|
||||
const partnerId = `partner-ext-${hashText(
|
||||
`${task.id}:${claimantName.toLowerCase()}`,
|
||||
)}`;
|
||||
const claimId = uid("claim");
|
||||
const claimToken = crypto.randomUUID().replaceAll("-", "");
|
||||
const statements: D1PreparedStatement[] = [
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO partners
|
||||
(id, name, wecom_name, owner, claimed_total, completed_total)
|
||||
VALUES (?, ?, ?, '外部KOC', 0, 0)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
wecom_name = excluded.wecom_name`,
|
||||
)
|
||||
.bind(partnerId, claimantName, claimantName),
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO claims
|
||||
(id, task_id, partner_id, claimant_name, claim_token, quantity)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
claimId,
|
||||
task.id,
|
||||
partnerId,
|
||||
claimantName,
|
||||
claimToken,
|
||||
available.results.length,
|
||||
),
|
||||
];
|
||||
for (const content of available.results) {
|
||||
statements.push(
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO distributions
|
||||
(id, task_id, content_id, partner_id, claim_id, status)
|
||||
VALUES (?, ?, ?, ?, ?, 'claimed')`,
|
||||
)
|
||||
.bind(uid("dist"), task.id, content.id, partnerId, claimId),
|
||||
db
|
||||
.prepare(
|
||||
`UPDATE contents SET status = 'allocated'
|
||||
WHERE id = ? AND status = 'available'`,
|
||||
)
|
||||
.bind(content.id),
|
||||
);
|
||||
}
|
||||
statements.push(
|
||||
db
|
||||
.prepare(
|
||||
`UPDATE tasks SET claimed_quantity = claimed_quantity + ?
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.bind(available.results.length, task.id),
|
||||
db
|
||||
.prepare(
|
||||
`UPDATE partners SET claimed_total = claimed_total + ?
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.bind(available.results.length, partnerId),
|
||||
);
|
||||
await db.batch(statements);
|
||||
return Response.json({
|
||||
claimToken,
|
||||
claimedCount: available.results.length,
|
||||
});
|
||||
}
|
||||
|
||||
if (body.action === "create_delegation") {
|
||||
const claimToken = textValue(body.claimToken, 80);
|
||||
const label = textValue(body.delegationLabel, 24);
|
||||
const distributionIds = idList(body.distributionIds);
|
||||
if (!label) {
|
||||
return Response.json(
|
||||
{ error: "请填写接收人备注,例如 A 或小王" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (distributionIds.length === 0) {
|
||||
return Response.json(
|
||||
{ error: "请至少选择一篇待发布笔记" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const claimRow = await db
|
||||
.prepare(
|
||||
`SELECT id, partner_id
|
||||
FROM claims
|
||||
WHERE claim_token = ? AND task_id = ?`,
|
||||
)
|
||||
.bind(claimToken, task.id)
|
||||
.first<{ id: string; partner_id: string }>();
|
||||
if (!claimRow) {
|
||||
return Response.json({ error: "领取凭证无效" }, { status: 403 });
|
||||
}
|
||||
const placeholders = distributionIds.map(() => "?").join(", ");
|
||||
const selected = await db
|
||||
.prepare(
|
||||
`SELECT id, publish_url, delegation_bundle_id
|
||||
FROM distributions
|
||||
WHERE claim_id = ? AND id IN (${placeholders})`,
|
||||
)
|
||||
.bind(claimRow.id, ...distributionIds)
|
||||
.all<{
|
||||
id: string;
|
||||
publish_url: string | null;
|
||||
delegation_bundle_id: string | null;
|
||||
}>();
|
||||
if (selected.results.length !== distributionIds.length) {
|
||||
return Response.json(
|
||||
{ error: "部分笔记不属于当前领取批次,请刷新后重试" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
if (selected.results.some((item) => item.publish_url)) {
|
||||
return Response.json(
|
||||
{ error: "已发布的笔记不能再次转派" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
if (selected.results.some((item) => item.delegation_bundle_id)) {
|
||||
return Response.json(
|
||||
{ error: "部分笔记已经转派,请刷新后重新选择" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
const bundleId = uid("delegate");
|
||||
const shareToken = crypto.randomUUID().replaceAll("-", "");
|
||||
const statements: D1PreparedStatement[] = [
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO delegation_bundles
|
||||
(id, task_id, claim_id, partner_id, label, share_token, quantity)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
bundleId,
|
||||
task.id,
|
||||
claimRow.id,
|
||||
claimRow.partner_id,
|
||||
label,
|
||||
shareToken,
|
||||
distributionIds.length,
|
||||
),
|
||||
];
|
||||
for (const distributionId of distributionIds) {
|
||||
statements.push(
|
||||
db
|
||||
.prepare(
|
||||
`UPDATE distributions
|
||||
SET delegation_bundle_id = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
AND claim_id = ?
|
||||
AND publish_url IS NULL
|
||||
AND delegation_bundle_id IS NULL`,
|
||||
)
|
||||
.bind(bundleId, distributionId, claimRow.id),
|
||||
);
|
||||
}
|
||||
await db.batch(statements);
|
||||
const bound = await db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM distributions
|
||||
WHERE delegation_bundle_id = ?`,
|
||||
)
|
||||
.bind(bundleId)
|
||||
.first<{ count: number }>();
|
||||
if ((bound?.count ?? 0) !== distributionIds.length) {
|
||||
await db.batch([
|
||||
db
|
||||
.prepare(
|
||||
`UPDATE distributions
|
||||
SET delegation_bundle_id = NULL, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE delegation_bundle_id = ?`,
|
||||
)
|
||||
.bind(bundleId),
|
||||
db
|
||||
.prepare("DELETE FROM delegation_bundles WHERE id = ?")
|
||||
.bind(bundleId),
|
||||
]);
|
||||
return Response.json(
|
||||
{ error: "部分笔记刚刚已被转派,请刷新后重新选择" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
return Response.json({
|
||||
delegation: {
|
||||
id: bundleId,
|
||||
label,
|
||||
shareToken,
|
||||
quantity: distributionIds.length,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (body.action === "revoke_delegation") {
|
||||
const claimToken = textValue(body.claimToken, 80);
|
||||
const bundleId = textValue(body.delegationBundleId, 80);
|
||||
const bundle = await db
|
||||
.prepare(
|
||||
`SELECT b.id
|
||||
FROM delegation_bundles b
|
||||
JOIN claims c ON c.id = b.claim_id
|
||||
WHERE b.id = ?
|
||||
AND b.task_id = ?
|
||||
AND b.status = 'active'
|
||||
AND c.claim_token = ?`,
|
||||
)
|
||||
.bind(bundleId, task.id, claimToken)
|
||||
.first<{ id: string }>();
|
||||
if (!bundle) {
|
||||
return Response.json(
|
||||
{ error: "没有找到可撤销的转派记录" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
const published = await db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM distributions
|
||||
WHERE delegation_bundle_id = ?
|
||||
AND publish_url IS NOT NULL
|
||||
AND publish_url != ''`,
|
||||
)
|
||||
.bind(bundle.id)
|
||||
.first<{ count: number }>();
|
||||
if ((published?.count ?? 0) > 0) {
|
||||
return Response.json(
|
||||
{ error: "该分享包已有笔记发布,需保留链接继续完成第7天数据回收" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
await db.batch([
|
||||
db
|
||||
.prepare(
|
||||
`UPDATE distributions
|
||||
SET delegation_bundle_id = NULL, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE delegation_bundle_id = ?`,
|
||||
)
|
||||
.bind(bundle.id),
|
||||
db
|
||||
.prepare(
|
||||
`UPDATE delegation_bundles
|
||||
SET status = 'revoked',
|
||||
revoked_at = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.bind(bundle.id),
|
||||
]);
|
||||
return Response.json({ revoked: true });
|
||||
}
|
||||
|
||||
if (body.action === "submit") {
|
||||
const claimToken = textValue(body.claimToken, 80);
|
||||
const distributionId = textValue(body.distributionId, 80);
|
||||
const accountNickname = textValue(body.accountNickname, 80);
|
||||
const publishInput = textValue(body.publishUrl, 5000);
|
||||
if (!accountNickname || !publishInput) {
|
||||
return Response.json(
|
||||
{ error: "请填写发布账号昵称和发布链接" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const publishUrl = extractXhsPublishUrl(publishInput);
|
||||
if (!publishUrl) {
|
||||
return Response.json(
|
||||
{ error: "请粘贴包含小红书长链或短链的分享内容" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const account = accountFromPublishLink(publishUrl, accountNickname);
|
||||
if (!account) {
|
||||
return Response.json({ error: "发布链接格式不正确" }, { status: 400 });
|
||||
}
|
||||
const assignment = await findAccessibleAssignment(
|
||||
task.id,
|
||||
distributionId,
|
||||
claimToken,
|
||||
delegationToken,
|
||||
);
|
||||
if (!assignment) {
|
||||
return Response.json({ error: "笔记与领取凭证不匹配" }, { status: 403 });
|
||||
}
|
||||
if (!assignment.publish_screenshot_key) {
|
||||
return Response.json({ error: "请先上传发布截图" }, { status: 400 });
|
||||
}
|
||||
const reuseExistingAccount =
|
||||
assignment.publish_url === publishUrl && assignment.account_id;
|
||||
const accountId =
|
||||
reuseExistingAccount ||
|
||||
`account-${hashText(`${account.platform}:${account.platformUid}`)}`;
|
||||
const statements: D1PreparedStatement[] = [];
|
||||
if (!reuseExistingAccount) {
|
||||
statements.push(
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO accounts
|
||||
(id, platform, platform_uid, nickname, profile_url, post_count)
|
||||
VALUES (?, ?, ?, ?, ?, 1)
|
||||
ON CONFLICT(platform, platform_uid) DO UPDATE SET
|
||||
nickname = excluded.nickname,
|
||||
profile_url = excluded.profile_url,
|
||||
post_count = accounts.post_count + ?,
|
||||
last_seen_at = CURRENT_TIMESTAMP`,
|
||||
)
|
||||
.bind(
|
||||
accountId,
|
||||
account.platform,
|
||||
account.platformUid,
|
||||
account.nickname,
|
||||
account.profileUrl,
|
||||
assignment.publish_url ? 0 : 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
statements.push(
|
||||
db
|
||||
.prepare(
|
||||
`UPDATE distributions SET
|
||||
account_id = ?,
|
||||
publish_url = ?,
|
||||
publish_time = CURRENT_TIMESTAMP,
|
||||
status = 'published',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.bind(accountId, publishUrl, assignment.id),
|
||||
);
|
||||
if (!assignment.publish_url) {
|
||||
statements.push(
|
||||
db
|
||||
.prepare(
|
||||
`UPDATE partners SET completed_total = completed_total + 1
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.bind(assignment.partner_id),
|
||||
);
|
||||
}
|
||||
await db.batch(statements);
|
||||
const collectionSchedule = await db
|
||||
.prepare(
|
||||
`SELECT collection_start_date, collection_days
|
||||
FROM tasks WHERE id = ?`,
|
||||
)
|
||||
.bind(task.id)
|
||||
.first<{
|
||||
collection_start_date: string | null;
|
||||
collection_days: string;
|
||||
}>();
|
||||
if (
|
||||
collectionSchedule?.collection_start_date &&
|
||||
collectionSchedule.collection_days !== "[]"
|
||||
) {
|
||||
let collectionDays: number[] = [];
|
||||
try {
|
||||
const parsed = JSON.parse(collectionSchedule.collection_days);
|
||||
if (Array.isArray(parsed)) collectionDays = parsed.map(Number);
|
||||
} catch {
|
||||
collectionDays = [];
|
||||
}
|
||||
if (collectionDays.length > 0) {
|
||||
await createCollectionRunTasks(
|
||||
db,
|
||||
task.id,
|
||||
collectionSchedule.collection_start_date,
|
||||
collectionDays,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (account.platform === "小红书") {
|
||||
const enrichment = enrichDistributionAccount(
|
||||
db,
|
||||
assignment.id,
|
||||
publishUrl,
|
||||
accountNickname,
|
||||
resolveCollectionMcpConfig(
|
||||
env as unknown as CollectionMcpBindings,
|
||||
),
|
||||
).catch(() => undefined);
|
||||
const executionContext = getRequestExecutionContext();
|
||||
if (executionContext) {
|
||||
executionContext.waitUntil(enrichment);
|
||||
} else {
|
||||
await enrichment;
|
||||
}
|
||||
}
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
if (body.action === "submit_creator_metrics") {
|
||||
const claimToken = textValue(body.claimToken, 80);
|
||||
const distributionId = textValue(body.distributionId, 80);
|
||||
const exposure = creatorMetricValue(body.exposure);
|
||||
const views = creatorMetricValue(body.views);
|
||||
if (exposure === null || views === null) {
|
||||
return Response.json(
|
||||
{ error: "请填写正确的曝光量和阅读量" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const assignment = await findAccessibleAssignment(
|
||||
task.id,
|
||||
distributionId,
|
||||
claimToken,
|
||||
delegationToken,
|
||||
);
|
||||
if (!assignment) {
|
||||
return Response.json({ error: "笔记与领取凭证不匹配" }, { status: 403 });
|
||||
}
|
||||
if (!assignment.publish_url) {
|
||||
return Response.json(
|
||||
{ error: "请先回填这篇笔记的发布信息" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
if (!assignment.screenshot_key) {
|
||||
return Response.json(
|
||||
{ error: "请先上传创作者中心截图" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE distributions SET
|
||||
exposure = ?,
|
||||
views = ?,
|
||||
ocr_status = 'manual',
|
||||
status = CASE WHEN d7_likes IS NOT NULL THEN 'complete' ELSE status END,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.bind(exposure, views, assignment.id)
|
||||
.run();
|
||||
return Response.json({ submitted: true });
|
||||
}
|
||||
|
||||
return Response.json({ error: "不支持的操作" }, { status: 400 });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ error: error instanceof Error ? error.message : "操作失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const response = await handleGet(request);
|
||||
response.headers.set("Cache-Control", "private, no-store");
|
||||
response.headers.set("Referrer-Policy", "no-referrer");
|
||||
return withPartnerCors(request, response);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const response = await handlePost(request);
|
||||
response.headers.set("Cache-Control", "private, no-store");
|
||||
return withPartnerCors(request, response);
|
||||
}
|
||||
|
||||
export async function OPTIONS(request: Request) {
|
||||
return partnerOptions(request);
|
||||
}
|
||||
51
app/api/upload/route.ts
Normal file
51
app/api/upload/route.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
ensureSchema,
|
||||
getDashboardData,
|
||||
getRawDb,
|
||||
getUploadBucket,
|
||||
uid,
|
||||
} from "../../../lib/mvp-db";
|
||||
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!isAdminRequest(request)) return adminForbidden();
|
||||
try {
|
||||
await ensureSchema();
|
||||
const form = await request.formData();
|
||||
const distributionId = String(form.get("distributionId") ?? "");
|
||||
const file = form.get("file");
|
||||
if (!distributionId || !(file instanceof File) || file.size === 0) {
|
||||
return Response.json({ error: "请选择需要上传的截图" }, { status: 400 });
|
||||
}
|
||||
if (!file.type.startsWith("image/")) {
|
||||
return Response.json({ error: "只支持图片文件" }, { status: 400 });
|
||||
}
|
||||
const extension = file.name.split(".").at(-1)?.replace(/[^a-zA-Z0-9]/g, "") ||
|
||||
"jpg";
|
||||
const key = `creator-center/${distributionId}/${uid("shot")}.${extension}`;
|
||||
await getUploadBucket().put(key, await file.arrayBuffer(), {
|
||||
httpMetadata: { contentType: file.type },
|
||||
});
|
||||
const db = getRawDb();
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE distributions SET
|
||||
screenshot_key = ?,
|
||||
exposure = NULL,
|
||||
views = NULL,
|
||||
ocr_status = 'failed',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.bind(key, distributionId)
|
||||
.run();
|
||||
return Response.json(await getDashboardData());
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ error: error instanceof Error ? error.message : "上传失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
86
app/chatgpt-auth.ts
Normal file
86
app/chatgpt-auth.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export type ChatGPTUser = {
|
||||
displayName: string;
|
||||
email: string;
|
||||
fullName: string | null;
|
||||
};
|
||||
|
||||
const USER_EMAIL_HEADER = "oai-authenticated-user-email";
|
||||
const USER_FULL_NAME_HEADER = "oai-authenticated-user-full-name";
|
||||
const USER_FULL_NAME_ENCODING_HEADER =
|
||||
"oai-authenticated-user-full-name-encoding";
|
||||
const PERCENT_ENCODED_UTF8 = "percent-encoded-utf-8";
|
||||
const SIGN_IN_PATH = "/signin-with-chatgpt";
|
||||
const SIGN_OUT_PATH = "/signout-with-chatgpt";
|
||||
const CALLBACK_PATH = "/callback";
|
||||
|
||||
export async function getChatGPTUser(): Promise<ChatGPTUser | null> {
|
||||
const requestHeaders = await headers();
|
||||
const email = requestHeaders.get(USER_EMAIL_HEADER);
|
||||
if (!email) return null;
|
||||
|
||||
const encodedFullName = requestHeaders.get(USER_FULL_NAME_HEADER);
|
||||
const fullName =
|
||||
encodedFullName &&
|
||||
requestHeaders.get(USER_FULL_NAME_ENCODING_HEADER) === PERCENT_ENCODED_UTF8
|
||||
? safeDecodeURIComponent(encodedFullName)
|
||||
: null;
|
||||
|
||||
return {
|
||||
displayName: fullName ?? email,
|
||||
email,
|
||||
fullName,
|
||||
};
|
||||
}
|
||||
|
||||
export async function requireChatGPTUser(
|
||||
returnTo: string,
|
||||
): Promise<ChatGPTUser> {
|
||||
const user = await getChatGPTUser();
|
||||
if (user) return user;
|
||||
|
||||
redirect(chatGPTSignInPath(returnTo));
|
||||
}
|
||||
|
||||
export function chatGPTSignInPath(returnTo: string): string {
|
||||
const safeReturnTo = safeRelativeReturnPath(returnTo);
|
||||
return `${SIGN_IN_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
|
||||
}
|
||||
|
||||
export function chatGPTSignOutPath(returnTo = "/"): string {
|
||||
const safeReturnTo = safeRelativeReturnPath(returnTo);
|
||||
return `${SIGN_OUT_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
|
||||
}
|
||||
|
||||
function safeRelativeReturnPath(value: string): string {
|
||||
if (!value.startsWith("/") || value.startsWith("//")) return "/";
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value, "https://app.local");
|
||||
} catch {
|
||||
return "/";
|
||||
}
|
||||
if (url.origin !== "https://app.local") return "/";
|
||||
if (isReservedAuthPath(url.pathname)) return "/";
|
||||
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
}
|
||||
|
||||
function isReservedAuthPath(pathname: string): boolean {
|
||||
return (
|
||||
pathname === SIGN_IN_PATH ||
|
||||
pathname === SIGN_OUT_PATH ||
|
||||
pathname === CALLBACK_PATH
|
||||
);
|
||||
}
|
||||
|
||||
function safeDecodeURIComponent(value: string): string | null {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
3008
app/globals.css
Normal file
3008
app/globals.css
Normal file
File diff suppressed because it is too large
Load Diff
68
app/layout.tsx
Normal file
68
app/layout.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { headers } from "next/headers";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const requestHeaders = await headers();
|
||||
const host = requestHeaders.get("x-forwarded-host") || requestHeaders.get("host");
|
||||
const protocol =
|
||||
requestHeaders.get("x-forwarded-proto") ||
|
||||
(host?.startsWith("localhost") ? "http" : "https");
|
||||
const metadataBase = host
|
||||
? new URL(`${protocol}://${host}`)
|
||||
: new URL("https://koc-loop.example.com");
|
||||
const description =
|
||||
"面向KOC运营团队的轻量资源管理、内容分发与数据回收平台。";
|
||||
|
||||
return {
|
||||
metadataBase,
|
||||
title: "KOC LOOP|内容分发闭环",
|
||||
description,
|
||||
icons: {
|
||||
icon: "/favicon.svg",
|
||||
shortcut: "/favicon.svg",
|
||||
},
|
||||
openGraph: {
|
||||
title: "KOC LOOP|内容分发闭环",
|
||||
description,
|
||||
type: "website",
|
||||
images: [
|
||||
{
|
||||
url: new URL("/og.png", metadataBase).toString(),
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "KOC LOOP 资源管理、内容分发与数据回收",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "KOC LOOP|内容分发闭环",
|
||||
description,
|
||||
images: [new URL("/og.png", metadataBase).toString()],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
24
app/page.tsx
Normal file
24
app/page.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { chatGPTSignOutPath, requireChatGPTUser } from "./chatgpt-auth";
|
||||
import AdminApp from "./admin-app";
|
||||
import { isAdminEmail } from "../lib/admin-auth";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function Page() {
|
||||
const user = await requireChatGPTUser("/");
|
||||
|
||||
if (!isAdminEmail(user.email)) {
|
||||
return (
|
||||
<main className="admin-access-denied">
|
||||
<section>
|
||||
<p>KOC LOOP</p>
|
||||
<h1>当前账号没有后台权限</h1>
|
||||
<span>请切换为管理员账号后再试。</span>
|
||||
<a href={chatGPTSignOutPath("/")}>切换账号</a>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return <AdminApp />;
|
||||
}
|
||||
Reference in New Issue
Block a user