feat: ship self-hosted KOC LOOP workflows

This commit is contained in:
巫凤萍
2026-08-11 23:07:26 +08:00
parent 1f1887c860
commit ddad4b7659
88 changed files with 6056 additions and 8938 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,7 @@
import { env } from "cloudflare:workers";
import { getRequestExecutionContext } from "vinext/shims/request-context";
import { getRuntimeEnv } from "../../../lib/runtime-env";
import { runInBackground } from "../../../lib/background";
const env = getRuntimeEnv();
import { backfillAccountProfiles } from "../../../lib/account-enrichment-service";
import {
ensureSchema,
@@ -24,9 +26,10 @@ import {
readFeishuSource,
type FeishuBindings,
} from "../../../lib/feishu-client";
import { createDistributionTask } from "../../../lib/task-service";
export const runtime = "edge";
import {
createDistributionTask,
createScreenshotTask,
} from "../../../lib/task-service";
type ActionBody = {
action?: string;
@@ -79,6 +82,16 @@ export async function POST(request: Request) {
},
env as unknown as FeishuBindings,
);
} else if (body.action === "create_screenshot_task") {
await createScreenshotTask({
name: String(body.name ?? "").trim(),
brand: String(body.brand ?? "").trim(),
dueAt: String(body.dueAt ?? "").trim(),
keyword: String(body.keyword ?? "").trim(),
instructions: String(body.instructions ?? "").trim(),
quantity: numberValue(body.quantity),
exampleImageKey: String(body.exampleImageKey ?? "").trim(),
});
} else if (body.action === "claim") {
const partnerId = String(body.partnerId ?? "");
const taskId = String(body.taskId ?? "");
@@ -88,9 +101,9 @@ export async function POST(request: Request) {
`SELECT id FROM contents
WHERE task_id = ? AND status = 'available'
ORDER BY created_at, id
LIMIT ?`,
LIMIT ${quantity}`,
)
.bind(taskId, quantity)
.bind(taskId)
.all<{ id: string }>();
if (available.results.length === 0) {
return Response.json({ error: "当前没有可领取内容" }, { status: 409 });
@@ -146,12 +159,18 @@ export async function POST(request: Request) {
);
}
const task = await db
.prepare("SELECT id FROM tasks WHERE id = ?")
.prepare("SELECT id, task_type FROM tasks WHERE id = ?")
.bind(taskId)
.first<{ id: string }>();
.first<{ id: string; task_type?: string | null }>();
if (!task) {
return Response.json({ error: "任务不存在" }, { status: 404 });
}
if (task.task_type === "screenshot_collect") {
return Response.json(
{ error: "截图回收任务不需要设置数据采集计划" },
{ status: 400 },
);
}
await db.batch([
db
.prepare(
@@ -193,12 +212,7 @@ export async function POST(request: Request) {
"catchup",
taskId,
).catch(() => undefined);
const executionContext = getRequestExecutionContext();
if (executionContext) {
executionContext.waitUntil(catchup);
} else {
await catchup;
}
runInBackground(catchup, "collection catchup");
} else if (body.action === "run_due_collections") {
await runDueScheduledCollections(
db,
@@ -224,6 +238,24 @@ export async function POST(request: Request) {
if (body.action === "collect" && day === null) {
return Response.json({ error: "采集周期无效" }, { status: 400 });
}
const distribution = await db
.prepare(
`SELECT t.task_type
FROM distributions d
JOIN tasks t ON t.id = d.task_id
WHERE d.id = ?`,
)
.bind(distributionId)
.first<{ task_type?: string | null }>();
if (!distribution) {
return Response.json({ error: "笔记记录不存在" }, { status: 404 });
}
if (distribution.task_type === "screenshot_collect") {
return Response.json(
{ error: "截图回收任务不支持公开数据采集" },
{ status: 400 },
);
}
await collectDistributionMetrics(
db,
distributionId,
@@ -239,6 +271,19 @@ export async function POST(request: Request) {
if (!taskId) {
return Response.json({ error: "请选择需要补采的任务" }, { status: 400 });
}
const task = await db
.prepare("SELECT task_type FROM tasks WHERE id = ?")
.bind(taskId)
.first<{ task_type?: string | null }>();
if (!task) {
return Response.json({ error: "任务不存在" }, { status: 404 });
}
if (task.task_type === "screenshot_collect") {
return Response.json(
{ error: "截图回收任务没有需要补采的公开数据" },
{ status: 400 },
);
}
await retryFailedCollections(
db,
taskId,
@@ -254,12 +299,7 @@ export async function POST(request: Request) {
),
Math.max(1, Math.min(25, numberValue(body.limit, 10))),
).catch(() => undefined);
const executionContext = getRequestExecutionContext();
if (executionContext) {
executionContext.waitUntil(backfill);
} else {
await backfill;
}
runInBackground(backfill, "account profile backfill");
} else if (body.action === "set_public_account_ids") {
const items = Array.isArray(body.items) ? body.items.slice(0, 50) : [];
const normalized = items

View File

@@ -3,12 +3,11 @@ import {
createSessionCookie,
ensureInitialSuperAdmin,
normalizeUsername,
requestUsesHttps,
verifyPassword,
} from "../../../../lib/user-auth";
import { getRawDb } from "../../../../lib/mvp-db";
export const runtime = "edge";
export async function POST(request: Request) {
try {
await ensureInitialSuperAdmin();
@@ -33,7 +32,7 @@ export async function POST(request: Request) {
return Response.json({ error: "账号或密码错误" }, { status: 401 });
}
const token = await createSession(user.id);
const secure = new URL(request.url).protocol === "https:";
const secure = requestUsesHttps(request);
return Response.json(
{ user: { id: user.id, username: user.username, role: user.role } },
{ headers: { "Set-Cookie": createSessionCookie(token, secure) } },

View File

@@ -1,15 +1,14 @@
import {
clearSessionCookie,
deleteSession,
requestUsesHttps,
sessionCookieFromHeader,
} from "../../../../lib/user-auth";
export const runtime = "edge";
export async function POST(request: Request) {
const token = sessionCookieFromHeader(request.headers.get("cookie"));
await deleteSession(token);
const secure = new URL(request.url).protocol === "https:";
const secure = requestUsesHttps(request);
return Response.json(
{ loggedOut: true },
{ headers: { "Set-Cookie": clearSessionCookie(secure) } },

View File

@@ -1,5 +1,7 @@
import { env } from "cloudflare:workers";
import { getRequestExecutionContext } from "vinext/shims/request-context";
import { getRuntimeEnv } from "../../../lib/runtime-env";
import { runInBackground } from "../../../lib/background";
const env = getRuntimeEnv();
import { backfillAccountProfiles } from "../../../lib/account-enrichment-service";
import { runDueScheduledCollections } from "../../../lib/collection-service";
import {
@@ -14,8 +16,6 @@ import {
} from "../../../lib/mvp-db";
import { authForbidden, getRequestPrincipal } from "../../../lib/user-auth";
export const runtime = "edge";
export async function GET(request: Request) {
const principal = await getRequestPrincipal(request);
if (!principal) return authForbidden();
@@ -55,12 +55,7 @@ export async function GET(request: Request) {
collectionCatchup,
accountBackfill,
]);
const executionContext = getRequestExecutionContext();
if (executionContext) {
executionContext.waitUntil(catchup);
} else {
await catchup;
}
runInBackground(catchup, "bootstrap catchup");
const dashboard = await getDashboardData();
return Response.json(
principal.kind === "user" && principal.user.role === "user"

View File

@@ -2,8 +2,6 @@ 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 (!(await isAdminRequest(request))) return adminForbidden();
try {

View File

@@ -5,8 +5,6 @@ import {
} from "../../../lib/mvp-db";
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
export const runtime = "edge";
export async function GET(request: Request) {
if (!(await isAdminRequest(request))) return adminForbidden();
try {
@@ -43,7 +41,7 @@ export async function GET(request: Request) {
if (!headers.get("Content-Type")) {
headers.set("Content-Type", "image/jpeg");
}
return new Response(object.body, { headers });
return new Response(await object.arrayBuffer(), { headers });
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : "截图读取失败" },

24
app/api/health/route.ts Normal file
View File

@@ -0,0 +1,24 @@
import { checkDatabaseConnection } from "../../../lib/database";
import { getObjectStore } from "../../../lib/object-store";
export async function GET() {
try {
const database = await checkDatabaseConnection();
return Response.json({
status: database ? "ok" : "degraded",
database,
storage: getObjectStore().root,
scheduler: process.env.ENABLE_SCHEDULER !== "false",
timestamp: new Date().toISOString(),
}, { status: database ? 200 : 503 });
} catch (error) {
return Response.json(
{
status: "error",
database: false,
error: error instanceof Error ? error.message : "health check failed",
},
{ status: 503 },
);
}
}

View File

@@ -1,4 +1,4 @@
import { env } from "cloudflare:workers";
import { getRuntimeEnv } from "../../../lib/runtime-env";
import {
createMcpHandler,
McpServer,
@@ -16,10 +16,11 @@ import {
import { registerMcpOperationTools } from "../../../lib/mcp-tools";
import type { CollectionMcpBindings } from "../../../lib/mcp-collection-client";
export const runtime = "edge";
const env = getRuntimeEnv();
type McpBindings = FeishuBindings & CollectionMcpBindings & {
KOC_MCP_API_KEY?: string;
KOC_LOOP_MCP_API_KEY?: string;
KOC_PORTAL_URL?: string;
};
@@ -190,7 +191,10 @@ function originRejected(request: Request) {
}
async function authorize(request: Request) {
const expected = String(getBindings().KOC_MCP_API_KEY ?? "").trim();
const bindings = getBindings();
const expected = String(
bindings.KOC_MCP_API_KEY ?? bindings.KOC_LOOP_MCP_API_KEY ?? "",
).trim();
const authorization = request.headers.get("Authorization") ?? "";
const match = authorization.match(/^Bearer\s+(.+)$/i);
if (!expected || !match || !(await secretsMatch(match[1].trim(), expected))) {

View File

@@ -1,4 +1,4 @@
import { env } from "cloudflare:workers";
import { getRuntimeEnv } from "../../../lib/runtime-env";
import {
ensureSchema,
getRawDb,
@@ -12,8 +12,9 @@ import {
partnerOptions,
withPartnerCors,
} from "../../../lib/partner-cors";
import { parseResultScreenshotKeys } from "../../../lib/result-screenshots";
export const runtime = "edge";
const env = getRuntimeEnv();
type StoredAsset = {
index: number;
@@ -33,7 +34,8 @@ function findAsset(value: string, imageIndex: number) {
(asset) =>
asset.index === imageIndex &&
typeof asset.key === "string" &&
asset.key.startsWith("content-assets/") &&
(asset.key.startsWith("content-assets/") ||
asset.key.startsWith("task-assets/")) &&
(asset.fileToken === undefined ||
typeof asset.fileToken === "string"),
)
@@ -52,6 +54,7 @@ async function handleGet(request: Request) {
const delegationToken = textValue(url.searchParams.get("share"));
const distributionId = textValue(url.searchParams.get("distribution"));
const imageIndex = Number(url.searchParams.get("index"));
const imageKind = textValue(url.searchParams.get("kind"), 20);
if (
(!delegationToken && (!taskToken || !claimToken)) ||
!distributionId ||
@@ -63,7 +66,10 @@ async function handleGet(request: Request) {
const row = delegationToken
? await getRawDb()
.prepare(
`SELECT c.image_assets
`SELECT c.image_assets,
d.result_screenshot_key,
d.publish_screenshot_key,
d.screenshot_key
FROM distributions d
JOIN contents c ON c.id = d.content_id
JOIN delegation_bundles b ON b.id = d.delegation_bundle_id
@@ -72,10 +78,18 @@ async function handleGet(request: Request) {
AND b.status = 'active'`,
)
.bind(distributionId, delegationToken)
.first<{ image_assets: string }>()
.first<{
image_assets: string;
result_screenshot_key: string | null;
publish_screenshot_key: string | null;
screenshot_key: string | null;
}>()
: await getRawDb()
.prepare(
`SELECT c.image_assets
`SELECT c.image_assets,
d.result_screenshot_key,
d.publish_screenshot_key,
d.screenshot_key
FROM distributions d
JOIN contents c ON c.id = d.content_id
JOIN claims cl ON cl.id = d.claim_id
@@ -86,10 +100,35 @@ async function handleGet(request: Request) {
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 });
.first<{
image_assets: string;
result_screenshot_key: string | null;
publish_screenshot_key: string | null;
screenshot_key: string | null;
}>();
const asset =
imageKind === "result"
? row
? {
index: imageIndex,
key: parseResultScreenshotKeys(row.result_screenshot_key)[
imageIndex - 1
],
}
: undefined
: imageKind === "publish"
? row?.publish_screenshot_key?.startsWith("publish-evidence/")
? { index: 1, key: row.publish_screenshot_key }
: undefined
: imageKind === "creator"
? row?.screenshot_key?.startsWith("creator-center/")
? { index: 1, key: row.screenshot_key }
: undefined
: row
? findAsset(row.image_assets, imageIndex)
: undefined;
if (!asset?.key) {
return Response.json({ error: "没有找到这张图片" }, { status: 404 });
}
const bucket = getUploadBucket();
let object = await bucket.get(asset.key);
@@ -111,7 +150,7 @@ async function handleGet(request: Request) {
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 });
return new Response(await object.arrayBuffer(), { headers });
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : "图片读取失败" },

View File

@@ -8,8 +8,11 @@ import {
partnerOptions,
withPartnerCors,
} from "../../../lib/partner-cors";
export const runtime = "edge";
import {
MAX_RESULT_SCREENSHOTS,
parseResultScreenshotKeys,
serializeResultScreenshotKeys,
} from "../../../lib/result-screenshots";
async function readUpload(request: Request) {
const contentType = request.headers.get("content-type") ?? "";
@@ -64,7 +67,7 @@ async function handlePost(request: Request) {
!upload.distributionId ||
upload.fileBytes.byteLength === 0
) {
return Response.json({ error: "请选择发布截图" }, { status: 400 });
return Response.json({ error: "请选择需要上传的截图" }, { status: 400 });
}
if (
!upload.fileType.startsWith("image/") ||
@@ -76,21 +79,26 @@ async function handlePost(request: Request) {
);
}
const isCreatorCenter = upload.uploadKind === "creator-center";
const isTaskResult = upload.uploadKind === "task-result";
if (!["publish", "creator-center", "task-result"].includes(upload.uploadKind)) {
return Response.json({ error: "不支持的截图类型" }, { status: 400 });
}
const assignment = upload.delegationToken
? await getRawDb()
.prepare(
`SELECT d.id, d.publish_url
`SELECT d.id, d.publish_url, d.result_screenshot_key, t.task_type
FROM distributions d
JOIN delegation_bundles b ON b.id = d.delegation_bundle_id
JOIN tasks t ON t.id = d.task_id
WHERE d.id = ?
AND b.share_token = ?
AND b.status = 'active'`,
)
.bind(upload.distributionId, upload.delegationToken)
.first<{ id: string; publish_url: string | null }>()
.first<{ id: string; publish_url: string | null; result_screenshot_key: string | null; task_type: string }>()
: await getRawDb()
.prepare(
`SELECT d.id, d.publish_url
`SELECT d.id, d.publish_url, d.result_screenshot_key, t.task_type
FROM distributions d
JOIN claims c ON c.id = d.claim_id
JOIN tasks t ON t.id = d.task_id
@@ -100,7 +108,7 @@ async function handlePost(request: Request) {
AND c.task_id = t.id`,
)
.bind(upload.distributionId, upload.claimToken, upload.taskToken)
.first<{ id: string; publish_url: string | null }>();
.first<{ id: string; publish_url: string | null; result_screenshot_key: string | null; task_type: string }>();
if (!assignment) {
return Response.json({ error: "笔记与领取凭证不匹配" }, { status: 403 });
}
@@ -110,15 +118,45 @@ async function handlePost(request: Request) {
{ status: 409 },
);
}
if (isTaskResult && assignment.task_type !== "screenshot_collect") {
return Response.json({ error: "当前任务不支持截图结果回填" }, { status: 400 });
}
if (!isTaskResult && assignment.task_type === "screenshot_collect") {
return Response.json({ error: "截图回收任务请上传任务结果截图" }, { status: 400 });
}
const existingResultKeys = isTaskResult
? parseResultScreenshotKeys(assignment.result_screenshot_key)
: [];
if (isTaskResult && existingResultKeys.length >= MAX_RESULT_SCREENSHOTS) {
return Response.json(
{ error: `每份任务最多上传${MAX_RESULT_SCREENSHOTS}张截图` },
{ status: 409 },
);
}
const extension =
upload.fileName.split(".").at(-1)?.replace(/[^a-zA-Z0-9]/g, "") || "jpg";
const key = isCreatorCenter
const key = isTaskResult
? `task-results/${upload.distributionId}/${uid("shot")}.${extension}`
: 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) {
if (isTaskResult) {
await getRawDb()
.prepare(
`UPDATE distributions SET
result_screenshot_key = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(
serializeResultScreenshotKeys([...existingResultKeys, key]),
upload.distributionId,
)
.run();
} else if (isCreatorCenter) {
await getRawDb()
.prepare(
`UPDATE distributions SET
@@ -145,7 +183,12 @@ async function handlePost(request: Request) {
}
return Response.json({
uploaded: true,
kind: isCreatorCenter ? "creator-center" : "publish",
screenshotCount: isTaskResult ? existingResultKeys.length + 1 : undefined,
kind: isTaskResult
? "task-result"
: isCreatorCenter
? "creator-center"
: "publish",
});
} catch (error) {
return Response.json(

View File

@@ -1,5 +1,8 @@
import { env } from "cloudflare:workers";
import { getRequestExecutionContext } from "vinext/shims/request-context";
import { getRuntimeEnv } from "../../../lib/runtime-env";
import { runInBackground } from "../../../lib/background";
import type { DatabaseStatement } from "../../../lib/database";
const env = getRuntimeEnv();
import { enrichDistributionAccount } from "../../../lib/account-enrichment-service";
import { createCollectionRunTasks } from "../../../lib/collection-service";
import {
@@ -22,8 +25,6 @@ import {
withPartnerCors,
} from "../../../lib/partner-cors";
export const runtime = "edge";
type PartnerBody = {
action?: string;
taskToken?: string;
@@ -39,6 +40,7 @@ type PartnerBody = {
publishUrl?: string;
exposure?: number | string;
views?: number | string;
resultScreenshotKey?: string;
};
type ImageAsset = {
@@ -102,7 +104,7 @@ function publicImageAssets(value: unknown): ImageAsset[] {
async function findTask(taskToken: string) {
return getRawDb()
.prepare(
`SELECT id, name, brand, quantity, claimed_quantity, due_at, status
`SELECT id, name, brand, quantity, claimed_quantity, due_at, status, task_type
FROM tasks WHERE share_token = ?`,
)
.bind(taskToken)
@@ -114,6 +116,7 @@ async function findTask(taskToken: string) {
claimed_quantity: number;
due_at: string;
status: string;
task_type: string;
}>();
}
@@ -128,6 +131,7 @@ async function findDelegationAccess(delegationToken: string) {
t.claimed_quantity,
t.due_at,
t.status,
t.task_type,
b.id AS bundle_id,
b.label AS bundle_label,
b.quantity AS bundle_quantity,
@@ -145,6 +149,7 @@ async function findDelegationAccess(delegationToken: string) {
claimed_quantity: number;
due_at: string;
status: string;
task_type: string;
bundle_id: string;
bundle_label: string;
bundle_quantity: number;
@@ -165,7 +170,9 @@ async function findAccessibleAssignment(
d.account_id,
d.publish_url,
d.publish_screenshot_key,
d.screenshot_key`;
d.screenshot_key,
d.result_screenshot_key,
d.result_submitted_at`;
if (delegationToken) {
return db
.prepare(
@@ -185,6 +192,8 @@ async function findAccessibleAssignment(
publish_url: string | null;
publish_screenshot_key: string | null;
screenshot_key: string | null;
result_screenshot_key: string | null;
result_submitted_at: string | null;
}>();
}
if (!claimToken) return null;
@@ -203,6 +212,8 @@ async function findAccessibleAssignment(
publish_url: string | null;
publish_screenshot_key: string | null;
screenshot_key: string | null;
result_screenshot_key: string | null;
result_submitted_at: string | null;
}>();
}
@@ -263,6 +274,8 @@ async function handleGet(request: Request) {
d.publish_url,
d.publish_time,
d.publish_screenshot_key,
d.result_screenshot_key,
d.result_submitted_at,
d.screenshot_key AS creator_screenshot_key,
d.ocr_status,
d.exposure,
@@ -295,6 +308,7 @@ async function handleGet(request: Request) {
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.result_submitted_at IS NOT NULL THEN 1 ELSE 0 END) AS result_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
@@ -325,6 +339,8 @@ async function handleGet(request: Request) {
d.publish_url,
d.publish_time,
d.publish_screenshot_key,
d.result_screenshot_key,
d.result_submitted_at,
d.screenshot_key AS creator_screenshot_key,
d.ocr_status,
d.exposure,
@@ -344,7 +360,7 @@ async function handleGet(request: Request) {
.all();
delegation = {
id: delegationAccess.bundle_id,
label: "转派发布包",
label: task.task_type === "screenshot_collect" ? "转派截图任务包" : "转派发布包",
quantity: delegationAccess.bundle_quantity,
createdAt: delegationAccess.bundle_created_at,
assignments: assignments.results.map((assignment) => ({
@@ -362,6 +378,7 @@ async function handleGet(request: Request) {
brand: task.brand,
dueAt: task.due_at,
status: task.status,
type: task.task_type,
}
: {
name: task.name,
@@ -370,6 +387,7 @@ async function handleGet(request: Request) {
claimedQuantity: task.claimed_quantity,
dueAt: task.due_at,
status: task.status,
type: task.task_type,
availableQuantity: available?.count ?? 0,
},
claim,
@@ -402,10 +420,11 @@ async function handlePost(request: Request) {
if (
delegationToken &&
body.action !== "submit" &&
body.action !== "submit_creator_metrics"
body.action !== "submit_creator_metrics" &&
body.action !== "submit_screenshot_result"
) {
return Response.json(
{ error: "分享链接只能用于查看和回填包内笔记" },
{ error: "分享链接只能用于查看和回填包内任务" },
{ status: 403 },
);
}
@@ -438,7 +457,10 @@ async function handlePost(request: Request) {
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,
SUM(CASE
WHEN ? = 'screenshot_collect' AND d.result_submitted_at IS NOT NULL THEN 1
WHEN ? != 'screenshot_collect' AND 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
@@ -448,7 +470,7 @@ async function handlePost(request: Request) {
ORDER BY c.created_at DESC, c.id DESC
LIMIT 20`,
)
.bind(task.id, partnerId, legacyPartnerId)
.bind(task.task_type, task.task_type, task.id, partnerId, legacyPartnerId)
.all<{
claim_token: string;
quantity: number;
@@ -469,7 +491,7 @@ async function handlePost(request: Request) {
quantity: claim.note_count || claim.quantity,
completedCount: claim.completed_count || 0,
createdAt: claim.created_at,
firstTitle: claim.first_title || "领取的笔记",
firstTitle: claim.first_title || (task.task_type === "screenshot_collect" ? "领取的截图任务" : "领取的笔记"),
})),
});
}
@@ -495,9 +517,9 @@ async function handlePost(request: Request) {
`SELECT id FROM contents
WHERE task_id = ? AND status = 'available'
ORDER BY COALESCE(source_row, 999999), created_at, id
LIMIT ?`,
LIMIT ${quantity}`,
)
.bind(task.id, quantity)
.bind(task.id)
.all<{ id: string }>();
if (available.results.length === 0) {
return Response.json({ error: "当前任务已领完" }, { status: 409 });
@@ -508,7 +530,7 @@ async function handlePost(request: Request) {
const claimantName = claimantIdentifier.display;
const claimId = uid("claim");
const claimToken = crypto.randomUUID().replaceAll("-", "");
const statements: D1PreparedStatement[] = [
const statements: DatabaseStatement[] = [
db
.prepare(
`INSERT INTO partners
@@ -584,7 +606,7 @@ async function handlePost(request: Request) {
}
if (distributionIds.length === 0) {
return Response.json(
{ error: "请至少选择一篇待发布笔记" },
{ error: task.task_type === "screenshot_collect" ? "请至少选择一份待提交任务" : "请至少选择一篇待发布笔记" },
{ status: 400 },
);
}
@@ -602,7 +624,7 @@ async function handlePost(request: Request) {
const placeholders = distributionIds.map(() => "?").join(", ");
const selected = await db
.prepare(
`SELECT id, publish_url, delegation_bundle_id
`SELECT id, publish_url, result_submitted_at, delegation_bundle_id
FROM distributions
WHERE claim_id = ? AND id IN (${placeholders})`,
)
@@ -610,6 +632,7 @@ async function handlePost(request: Request) {
.all<{
id: string;
publish_url: string | null;
result_submitted_at: string | null;
delegation_bundle_id: string | null;
}>();
if (selected.results.length !== distributionIds.length) {
@@ -618,9 +641,13 @@ async function handlePost(request: Request) {
{ status: 403 },
);
}
if (selected.results.some((item) => item.publish_url)) {
if (selected.results.some((item) =>
task.task_type === "screenshot_collect"
? item.result_submitted_at
: item.publish_url,
)) {
return Response.json(
{ error: "已发布的笔记不能再次转派" },
{ error: task.task_type === "screenshot_collect" ? "已提交的截图任务不能再次转派" : "已发布的笔记不能再次转派" },
{ status: 409 },
);
}
@@ -632,7 +659,7 @@ async function handlePost(request: Request) {
}
const bundleId = uid("delegate");
const shareToken = crypto.randomUUID().replaceAll("-", "");
const statements: D1PreparedStatement[] = [
const statements: DatabaseStatement[] = [
db
.prepare(
`INSERT INTO delegation_bundles
@@ -658,6 +685,7 @@ async function handlePost(request: Request) {
WHERE id = ?
AND claim_id = ?
AND publish_url IS NULL
AND result_submitted_at IS NULL
AND delegation_bundle_id IS NULL`,
)
.bind(bundleId, distributionId, claimRow.id),
@@ -721,19 +749,18 @@ async function handlePost(request: Request) {
{ status: 404 },
);
}
const published = await db
const completed = await db
.prepare(
`SELECT COUNT(*) AS count
FROM distributions
WHERE delegation_bundle_id = ?
AND publish_url IS NOT NULL
AND publish_url != ''`,
AND (publish_url IS NOT NULL AND publish_url != '' OR result_submitted_at IS NOT NULL)`,
)
.bind(bundle.id)
.first<{ count: number }>();
if ((published?.count ?? 0) > 0) {
if ((completed?.count ?? 0) > 0) {
return Response.json(
{ error: "该分享包已有笔记发布需保留链接继续完成第7天数据回收" },
{ error: task.task_type === "screenshot_collect" ? "该分享包已有截图提交,不能撤销" : "该分享包已有笔记发布需保留链接继续完成第7天数据回收" },
{ status: 409 },
);
}
@@ -759,6 +786,9 @@ async function handlePost(request: Request) {
}
if (body.action === "submit") {
if (task.task_type === "screenshot_collect") {
return Response.json({ error: "截图回收任务无需填写发布链接" }, { status: 400 });
}
const claimToken = textValue(body.claimToken, 80);
const distributionId = textValue(body.distributionId, 80);
const publishInput = textValue(body.publishUrl, 5000);
@@ -796,7 +826,7 @@ async function handlePost(request: Request) {
const accountId =
reuseExistingAccount ||
`account-${hashText(`${account.platform}:${account.platformUid}`)}`;
const statements: D1PreparedStatement[] = [];
const statements: DatabaseStatement[] = [];
if (!reuseExistingAccount) {
statements.push(
db
@@ -884,17 +914,15 @@ async function handlePost(request: Request) {
env as unknown as CollectionMcpBindings,
),
).catch(() => undefined);
const executionContext = getRequestExecutionContext();
if (executionContext) {
executionContext.waitUntil(enrichment);
} else {
await enrichment;
}
runInBackground(enrichment, "distribution account enrichment");
}
return Response.json({ ok: true });
}
if (body.action === "submit_creator_metrics") {
if (task.task_type === "screenshot_collect") {
return Response.json({ error: "截图回收任务不需要创作者数据" }, { status: 400 });
}
const claimToken = textValue(body.claimToken, 80);
const distributionId = textValue(body.distributionId, 80);
const exposure = creatorMetricValue(body.exposure);
@@ -941,6 +969,49 @@ async function handlePost(request: Request) {
return Response.json({ submitted: true });
}
if (body.action === "submit_screenshot_result") {
if (task.task_type !== "screenshot_collect") {
return Response.json({ error: "当前任务不支持这种回填方式" }, { status: 400 });
}
const claimToken = textValue(body.claimToken, 80);
const distributionId = textValue(body.distributionId, 80);
const assignment = await findAccessibleAssignment(
task.id,
distributionId,
claimToken,
delegationToken,
);
if (!assignment) {
return Response.json({ error: "任务与领取凭证不匹配" }, { status: 403 });
}
if (!assignment.result_screenshot_key) {
return Response.json({ error: "请先上传任务截图" }, { status: 400 });
}
const statements: DatabaseStatement[] = [
db
.prepare(
`UPDATE distributions SET
result_submitted_at = CURRENT_TIMESTAMP,
status = 'complete',
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(assignment.id),
];
if (!assignment.result_submitted_at) {
statements.push(
db
.prepare(
`UPDATE partners SET completed_total = completed_total + 1
WHERE id = ?`,
)
.bind(assignment.partner_id),
);
}
await db.batch(statements);
return Response.json({ submitted: true });
}
return Response.json({ error: "不支持的操作" }, { status: 400 });
} catch (error) {
return Response.json(

View File

@@ -1,4 +1,5 @@
import { env } from "cloudflare:workers";
import { getRuntimeEnv } from "../../../lib/runtime-env";
import type { StoredObjectBody } from "../../../lib/object-store";
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
import { parseStoredDate } from "../../../lib/date-utils";
import {
@@ -17,7 +18,7 @@ import {
} from "../../../lib/recovery-workbook";
import { consumeMcpExportToken } from "../../../lib/mcp-export-token";
export const runtime = "edge";
const env = getRuntimeEnv();
type StoredAsset = {
index: number;
@@ -159,7 +160,7 @@ function collectionLabel(row: ExportRow) {
: label;
}
function contentTypeFromObject(object: R2ObjectBody) {
function contentTypeFromObject(object: StoredObjectBody) {
const headers = new Headers();
object.writeHttpMetadata(headers);
return headers.get("Content-Type") || "application/octet-stream";

View File

@@ -7,8 +7,6 @@ import {
} from "../../../lib/recovery-workbook";
import { consumeMcpExportToken } from "../../../lib/mcp-export-token";
export const runtime = "edge";
type AccountRow = {
id: string;
platform: string;
@@ -18,6 +16,7 @@ type AccountRow = {
ip_location: string;
followers: number;
post_count: number;
cooperation_source: string;
first_seen_at: string;
last_seen_at: string;
};
@@ -118,7 +117,15 @@ async function exportAccounts(accountIds: string[]) {
];
const rows: RecoveryWorkbookRow[] = accounts.map((account, index) => {
const cooperation = cooperationByAccount.get(account.id) ?? [];
const sources = [...new Set(cooperation.map((item) => item.partner_name))];
const sources = [
...new Set([
...cooperation.map((item) => item.partner_name),
...(account.cooperation_source || "")
.split(/[、,;|]/)
.map((item) => item.trim())
.filter(Boolean),
]),
];
const partnerManagedOnly =
cooperation.some((item) => item.delegation_bundle_id) &&
cooperation.every((item) => item.delegation_bundle_id);

View File

@@ -0,0 +1,302 @@
import { isManagerRequest, managerForbidden } from "../../../lib/user-auth";
import { ensureSchema, getRawDb } from "../../../lib/mvp-db";
import {
resolveCollectionMcpConfig,
resolveXhsProfileDetailsFromMcp,
resolveXhsPublicAccountDetails,
type CollectionMcpBindings,
} from "../../../lib/mcp-collection-client";
import { getRuntimeEnv } from "../../../lib/runtime-env";
import {
mergeCooperationSources,
normalizeProfileUrl,
parseResourceImportFile,
RESOURCE_IMPORT_MAX_BYTES,
resourcePlatformUid,
type ResourceImportRow,
} from "../../../lib/resource-import";
type AccountRow = {
id: string;
platform: string;
platform_uid: string;
public_account_id: string;
nickname: string;
profile_url: string;
ip_location: string;
followers: number;
cooperation_source: string;
};
type AnalyzedRow = ResourceImportRow & {
action: "create" | "update" | "error";
accountId: string;
platformUid: string;
cooperationSource: string;
};
function identityKey(platform: string, value: string) {
return `${platform.trim().toLocaleLowerCase("zh-CN")}|${value
.trim()
.toLocaleLowerCase("zh-CN")}`;
}
async function loadAccounts() {
return getRawDb()
.prepare(
`SELECT id, platform, platform_uid, public_account_id, nickname,
profile_url, ip_location, followers, cooperation_source
FROM accounts`,
)
.all<AccountRow>();
}
async function mapConcurrent<T, R>(
items: T[],
limit: number,
worker: (item: T) => Promise<R>,
) {
const results = new Array<R>(items.length);
let cursor = 0;
await Promise.all(
Array.from({ length: Math.min(limit, items.length) }, async () => {
while (cursor < items.length) {
const index = cursor;
cursor += 1;
results[index] = await worker(items[index]);
}
}),
);
return results;
}
async function enrichRows(rows: ResourceImportRow[], accounts: AccountRow[]) {
const existingByProfile = new Map<string, AccountRow>();
for (const account of accounts) {
const profileUrl = normalizeProfileUrl(account.profile_url || "");
if (profileUrl) {
existingByProfile.set(identityKey(account.platform, profileUrl), account);
}
}
const mcpConfig = resolveCollectionMcpConfig(
getRuntimeEnv() as unknown as CollectionMcpBindings,
);
return mapConcurrent(rows, 4, async (row) => {
if (row.errors.length > 0 || !row.profileUrl || row.platform !== "小红书") {
return row;
}
const existing = existingByProfile.get(identityKey(row.platform, row.profileUrl));
if (existing?.nickname && existing.public_account_id) {
return {
...row,
nickname: existing.nickname,
publicAccountId: existing.public_account_id,
ipLocation: existing.ip_location || "待识别",
followers: Number(existing.followers || 0),
};
}
let details = await resolveXhsProfileDetailsFromMcp(row.profileUrl, mcpConfig)
.catch(() => resolveXhsPublicAccountDetails(row.profileUrl));
if (!details.nickname || !details.redId || details.followers === null) {
const publicDetails = await resolveXhsPublicAccountDetails(row.profileUrl);
details = {
nickname: details.nickname || publicDetails.nickname,
redId: details.redId || publicDetails.redId,
followers: details.followers ?? publicDetails.followers,
ipLocation: details.ipLocation || publicDetails.ipLocation,
};
}
const errors = [...row.errors];
const nickname = details.nickname?.trim() || existing?.nickname || "";
const publicAccountId = details.redId?.trim() || existing?.public_account_id || "";
if (!nickname) errors.push("无法识别账号名称,请确认主页可公开访问");
if (!publicAccountId) errors.push("无法识别小红书号,请确认主页可公开访问");
return {
...row,
nickname,
publicAccountId,
ipLocation: details.ipLocation?.trim() || existing?.ip_location || "待识别",
followers: details.followers ?? Number(existing?.followers || 0),
errors,
};
});
}
function analyzeRows(rows: ResourceImportRow[], accountRows: AccountRow[]) {
const profileMap = new Map<string, AccountRow>();
const publicIdMap = new Map<string, AccountRow>();
const platformUidMap = new Map<string, AccountRow>();
for (const account of accountRows) {
const profileUrl = normalizeProfileUrl(account.profile_url || "");
if (profileUrl) profileMap.set(identityKey(account.platform, profileUrl), account);
if (account.public_account_id) {
publicIdMap.set(identityKey(account.platform, account.public_account_id), account);
}
platformUidMap.set(identityKey(account.platform, account.platform_uid), account);
}
return rows.map<AnalyzedRow>((row) => {
const platformUid = resourcePlatformUid(row);
const profileMatch = row.profileUrl
? profileMap.get(identityKey(row.platform, row.profileUrl))
: undefined;
const publicIdMatch = row.publicAccountId
? publicIdMap.get(identityKey(row.platform, row.publicAccountId))
: undefined;
const uidMatch = platformUidMap.get(identityKey(row.platform, platformUid));
const matches = [profileMatch, publicIdMatch, uidMatch].filter(
(account): account is AccountRow => Boolean(account),
);
const matchedIds = [...new Set(matches.map((account) => account.id))];
const errors = [...row.errors];
if (matchedIds.length > 1) {
errors.push("账号主页和账号ID匹配到不同的现有账号请先核对");
}
const existing = matchedIds.length === 1 ? matches[0] : undefined;
const accountId = existing?.id ?? `account-${crypto.randomUUID().slice(0, 12)}`;
const analyzed: AnalyzedRow = {
...row,
errors,
action: errors.length > 0 ? "error" : existing ? "update" : "create",
accountId,
platformUid: existing?.platform_uid ?? platformUid,
cooperationSource: mergeCooperationSources(
existing?.cooperation_source ?? "",
row.cooperationSource,
),
};
if (analyzed.action !== "error") {
const virtual: AccountRow = {
id: accountId,
platform: row.platform,
platform_uid: analyzed.platformUid,
public_account_id: row.publicAccountId || existing?.public_account_id || "",
nickname: row.nickname,
profile_url: row.profileUrl || existing?.profile_url || "",
ip_location: row.ipLocation || existing?.ip_location || "待识别",
followers: row.followers || existing?.followers || 0,
cooperation_source: analyzed.cooperationSource,
};
if (row.profileUrl) profileMap.set(identityKey(row.platform, row.profileUrl), virtual);
if (row.publicAccountId) {
publicIdMap.set(identityKey(row.platform, row.publicAccountId), virtual);
}
platformUidMap.set(identityKey(row.platform, analyzed.platformUid), virtual);
}
return analyzed;
});
}
function summarize(rows: AnalyzedRow[]) {
return {
total: rows.length,
create: rows.filter((row) => row.action === "create").length,
update: rows.filter((row) => row.action === "update").length,
error: rows.filter((row) => row.action === "error").length,
};
}
export async function POST(request: Request) {
if (!(await isManagerRequest(request))) return managerForbidden();
try {
await ensureSchema();
const form = await request.formData();
const file = form.get("file");
const mode = String(form.get("mode") ?? "preview");
if (!(file instanceof File)) {
return Response.json({ error: "请选择需要导入的 Excel 文件" }, { status: 400 });
}
if (file.size <= 0 || file.size > RESOURCE_IMPORT_MAX_BYTES) {
return Response.json({ error: "文件不能为空,且不能超过 5MB" }, { status: 400 });
}
const rows = parseResourceImportFile(file.name, new Uint8Array(await file.arrayBuffer()));
const accounts = await loadAccounts();
const enriched = await enrichRows(rows, accounts.results);
const analyzed = analyzeRows(enriched, accounts.results);
const summary = summarize(analyzed);
if (mode !== "commit") {
return Response.json({
summary,
rows: analyzed.slice(0, 100).map((row) => ({
rowNumber: row.rowNumber,
platform: row.platform,
nickname: row.nickname,
publicAccountId: row.publicAccountId,
profileUrl: row.profileUrl,
ipLocation: row.ipLocation,
followers: row.followers,
cooperationSource: row.cooperationSource,
action: row.action,
errors: row.errors,
})),
truncated: analyzed.length > 100,
});
}
if (summary.error > 0) {
return Response.json(
{ error: `${summary.error} 行数据未通过校验,请修正后重新上传`, summary },
{ status: 400 },
);
}
const db = getRawDb();
const statements = analyzed.map((row) =>
row.action === "update"
? db
.prepare(
`UPDATE accounts SET
nickname = ?,
public_account_id = CASE WHEN ? != '' THEN ? ELSE public_account_id END,
profile_url = CASE WHEN ? != '' THEN ? ELSE profile_url END,
ip_location = CASE
WHEN ? != '' AND ? != '待识别' THEN ? ELSE ip_location END,
followers = CASE WHEN ? > 0 OR followers = 0 THEN ? ELSE followers END,
cooperation_source = ?,
last_seen_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(
row.nickname,
row.publicAccountId,
row.publicAccountId,
row.profileUrl,
row.profileUrl,
row.ipLocation,
row.ipLocation,
row.ipLocation,
row.followers,
row.followers,
row.cooperationSource,
row.accountId,
)
: db
.prepare(
`INSERT INTO accounts
(id, platform, platform_uid, public_account_id, nickname,
profile_url, ip_location, followers, post_count, avg_views,
cooperation_source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?)`,
)
.bind(
row.accountId,
row.platform,
row.platformUid,
row.publicAccountId,
row.nickname,
row.profileUrl,
row.ipLocation,
row.followers,
row.cooperationSource,
),
);
if (statements.length > 0) await db.batch(statements);
return Response.json({
summary,
message: `已导入 ${summary.create} 个新账号,更新 ${summary.update} 个已有账号`,
});
} catch (error) {
const message = error instanceof Error ? error.message : "导入失败";
return Response.json({ error: message }, { status: 400 });
}
}

View File

@@ -0,0 +1,102 @@
import { zipSync, strToU8 } from "fflate";
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
import { ensureSchema, getRawDb, getUploadBucket } from "../../../lib/mvp-db";
import { parseResultScreenshotKeys } from "../../../lib/result-screenshots";
function safeName(value: string) {
return value.replace(/[\\/:*?"<>|\r\n]/g, "_").trim().slice(0, 60) || "KOC";
}
function csvCell(value: unknown) {
const text = String(value ?? "");
return `"${text.replaceAll('"', '""')}"`;
}
export async function GET(request: Request) {
if (!(await isAdminRequest(request))) return adminForbidden();
try {
await ensureSchema();
const taskId = new URL(request.url).searchParams.get("task")?.trim();
if (!taskId) {
return Response.json({ error: "缺少任务参数" }, { status: 400 });
}
const task = await getRawDb()
.prepare("SELECT name, task_type FROM tasks WHERE id = ?")
.bind(taskId)
.first<{ name: string; task_type: string }>();
if (!task || task.task_type !== "screenshot_collect") {
return Response.json({ error: "没有找到截图回收任务" }, { status: 404 });
}
const rows = await getRawDb()
.prepare(
`SELECT d.id, d.result_screenshot_key, d.result_submitted_at,
d.claimed_at, c.source_row, c.title, p.name AS partner_name,
cl.claimant_name
FROM distributions d
JOIN contents c ON c.id = d.content_id
JOIN partners p ON p.id = d.partner_id
LEFT JOIN claims cl ON cl.id = d.claim_id
WHERE d.task_id = ?
ORDER BY COALESCE(c.source_row, 999999), d.claimed_at, d.id`,
)
.bind(taskId)
.all<{
id: string;
result_screenshot_key: string | null;
result_submitted_at: string | null;
claimed_at: string;
source_row: number | null;
title: string;
partner_name: string;
claimant_name: string | null;
}>();
const files: Record<string, Uint8Array> = {};
const manifest = [
["序号", "搜索关键词", "领取人", "领取时间", "提交时间", "文件名"],
];
let exported = 0;
for (const [rowIndex, row] of rows.results.entries()) {
const fileNames: string[] = [];
const screenshotKeys = row.result_submitted_at
? parseResultScreenshotKeys(row.result_screenshot_key)
: [];
for (const [imageIndex, screenshotKey] of screenshotKeys.entries()) {
const object = await getUploadBucket().get(screenshotKey);
if (!object) continue;
const extension = screenshotKey.split(".").at(-1) || "jpg";
const fileName = `${String(row.source_row ?? rowIndex + 1).padStart(3, "0")}-${safeName(row.claimant_name || row.partner_name)}-${imageIndex + 1}.${extension}`;
files[fileName] = new Uint8Array(await object.arrayBuffer());
fileNames.push(fileName);
exported += 1;
}
manifest.push([
String(row.source_row ?? ""),
row.title,
row.claimant_name || row.partner_name,
row.claimed_at,
row.result_submitted_at || "",
fileNames.join(""),
]);
}
files["回收清单.csv"] = strToU8(
`\uFEFF${manifest.map((cells) => cells.map(csvCell).join(",")).join("\r\n")}`,
);
const archive = zipSync(files, { level: 0 });
const headers = new Headers({
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename*=UTF-8''${encodeURIComponent(`${safeName(task.name)}-截图回收.zip`)}`,
"Cache-Control": "private, no-store",
"X-KOC-Exported-Count": String(exported),
});
const body = archive.buffer.slice(
archive.byteOffset,
archive.byteOffset + archive.byteLength,
) as ArrayBuffer;
return new Response(body, { headers });
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : "截图打包失败" },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,30 @@
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
import { ensureSchema, getUploadBucket, uid } from "../../../lib/mvp-db";
export async function POST(request: Request) {
if (!(await isAdminRequest(request))) return adminForbidden();
try {
await ensureSchema();
const form = await request.formData();
const file = form.get("file");
if (!(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 extension =
file.name.split(".").at(-1)?.replace(/[^a-zA-Z0-9]/g, "") || "jpg";
const key = `task-assets/${uid("example")}.${extension}`;
await getUploadBucket().put(key, await file.arrayBuffer(), {
httpMetadata: { contentType: file.type },
customMetadata: { kind: "screenshot-task-example" },
});
return Response.json({ uploaded: true, key });
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : "示例截图上传失败" },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,49 @@
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
import { ensureSchema, getRawDb, getUploadBucket } from "../../../lib/mvp-db";
import { parseResultScreenshotKeys } from "../../../lib/result-screenshots";
export async function GET(request: Request) {
if (!(await isAdminRequest(request))) return adminForbidden();
try {
await ensureSchema();
const distributionId = new URL(request.url).searchParams
.get("distribution")
?.trim();
const imageIndex = Math.max(
1,
Number(new URL(request.url).searchParams.get("index") || 1),
);
if (!distributionId) {
return Response.json({ error: "缺少任务记录" }, { status: 400 });
}
const row = await getRawDb()
.prepare(
`SELECT result_screenshot_key FROM distributions
WHERE id = ?`,
)
.bind(distributionId)
.first<{ result_screenshot_key: string | null }>();
const screenshotKey = row
? parseResultScreenshotKeys(row.result_screenshot_key)[imageIndex - 1]
: undefined;
if (!screenshotKey) {
return Response.json({ error: "任务截图不存在" }, { status: 404 });
}
const object = await getUploadBucket().get(screenshotKey);
if (!object) {
return Response.json({ error: "任务截图不存在" }, { status: 404 });
}
const headers = new Headers({
"Cache-Control": "private, no-store",
"Content-Disposition": 'inline; filename="task-result-screenshot"',
"X-Content-Type-Options": "nosniff",
});
object.writeHttpMetadata(headers);
return new Response(await object.arrayBuffer(), { headers });
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : "截图读取失败" },
{ status: 500 },
);
}
}

View File

@@ -7,8 +7,6 @@ import {
} from "../../../lib/mvp-db";
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
export const runtime = "edge";
export async function POST(request: Request) {
if (!(await isAdminRequest(request))) return adminForbidden();
try {

View File

@@ -9,8 +9,6 @@ import {
} from "../../../lib/user-auth";
import { ensureSchema, getRawDb } from "../../../lib/mvp-db";
export const runtime = "edge";
type UserRow = {
id: string;
username: string;

View File

@@ -1,6 +1,8 @@
@import "tailwindcss";
:root {
--font-geist-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
--font-geist-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
--ink: #172321;
--ink-soft: #32423f;
--nav: #13201e;
@@ -242,6 +244,146 @@ a {
min-height: 100vh;
}
.task-type-picker {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin: 22px 0 6px;
}
.task-type-picker button {
display: flex;
min-height: 82px;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 7px;
padding: 16px;
border: 1px solid #dbe4df;
border-radius: 14px;
color: #53625d;
background: #fafcfb;
text-align: left;
}
.task-type-picker button.active {
border-color: #278b69;
color: #123f31;
background: #edf8f3;
box-shadow: 0 0 0 3px rgb(39 139 105 / 0.08);
}
.task-type-picker b {
font-size: 15px;
}
.task-type-picker span {
color: #87938f;
font-size: 11px;
line-height: 1.5;
}
.screenshot-task-fields textarea {
width: 100%;
min-height: 108px;
resize: vertical;
padding: 13px 14px;
border: 1px solid #d8e0dc;
border-radius: 12px;
outline: none;
font: inherit;
line-height: 1.65;
}
.screenshot-task-fields textarea:focus {
border-color: #63a88e;
box-shadow: 0 0 0 3px rgb(39 139 105 / 0.08);
}
.task-example-picker {
position: relative;
display: flex;
min-height: 82px;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 5px;
padding: 14px;
border: 1px dashed #bdccc5;
border-radius: 12px;
background: #fafcfb;
}
.task-example-picker.has-file {
border-style: solid;
border-color: #8ec3ae;
background: #f0f8f4;
}
.task-example-picker input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
.task-example-picker strong {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-example-picker small {
color: #8c9994;
}
.screenshot-task-table .creator-screenshot-link {
white-space: nowrap;
}
.admin-result-gallery {
display: flex;
min-width: 150px;
align-items: center;
flex-wrap: wrap;
gap: 5px;
}
.admin-result-gallery a,
.admin-result-gallery button {
display: block;
width: 42px;
height: 42px;
padding: 0;
overflow: hidden;
border: 1px solid #dce5e0;
border-radius: 8px;
background: #f4f7f5;
cursor: zoom-in;
}
.admin-result-gallery img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.admin-result-gallery > span {
color: #74817c;
font-size: 8px;
white-space: nowrap;
}
@media (max-width: 760px) {
.task-type-picker {
grid-template-columns: 1fr;
}
}
.sidebar {
position: fixed;
inset: 0 auto 0 0;
@@ -1566,6 +1708,22 @@ a {
font-size: 11px;
}
.resource-heading-actions {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
}
.resource-heading-actions > a {
text-decoration: none;
}
.resource-heading-actions .filter-chips {
margin-left: 4px;
}
.resource-toolbar {
display: flex;
align-items: center;
@@ -1658,6 +1816,224 @@ a {
font-size: 11px;
}
.resource-import-modal {
width: min(780px, calc(100vw - 32px));
max-height: min(86vh, 820px);
overflow-y: auto;
}
.import-guide-strip {
display: grid;
grid-template-columns: auto auto 1fr auto auto 1fr auto auto;
align-items: center;
gap: 9px;
margin-bottom: 18px;
padding: 12px 14px;
border-radius: 11px;
color: #687773;
background: #f4f8f5;
}
.import-guide-strip > span {
display: grid;
width: 24px;
height: 24px;
place-items: center;
border-radius: 8px;
color: var(--green-deep);
background: #dff0e9;
font-size: 10px;
font-weight: 750;
}
.import-guide-strip p {
margin: 0;
font-size: 10px;
white-space: nowrap;
}
.import-guide-strip i {
height: 1px;
background: #d5e0db;
}
.resource-import-dropzone {
position: relative;
display: flex;
min-height: 116px;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 7px;
border: 1px dashed #9dc9b9;
border-radius: 13px;
color: var(--green-deep);
background: #f8fcfa;
cursor: pointer;
}
.resource-import-dropzone:hover {
border-color: var(--green);
background: #f2faf6;
}
.resource-import-dropzone input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.resource-import-dropzone strong {
max-width: 88%;
overflow: hidden;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.resource-import-dropzone span {
color: #8a9893;
font-size: 9px;
}
.import-template-note {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
margin-top: 12px;
padding: 12px 14px;
border: 1px solid #edf0ee;
border-radius: 11px;
}
.import-template-note > div {
display: flex;
flex-direction: column;
gap: 4px;
}
.import-template-note strong {
font-size: 10px;
}
.import-template-note span {
color: #8b9692;
font-size: 9px;
}
.import-template-note a {
flex: 0 0 auto;
text-decoration: none;
}
.resource-import-preview {
margin-top: 14px;
}
.import-summary-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.import-summary-grid > div {
display: flex;
min-height: 58px;
align-items: center;
justify-content: space-between;
padding: 0 14px;
border: 1px solid #e7ece9;
border-radius: 10px;
background: #fafbf9;
}
.import-summary-grid span {
color: #87938f;
font-size: 9px;
}
.import-summary-grid strong {
font-size: 19px;
}
.import-summary-grid .create strong { color: var(--green); }
.import-summary-grid .update strong { color: #4c7890; }
.import-summary-grid .error strong { color: #c86548; }
.import-preview-table {
margin-top: 12px;
overflow: hidden;
border: 1px solid #e8ece9;
border-radius: 11px;
}
.import-preview-row {
display: grid;
grid-template-columns: 48px 1.4fr 1fr 1.15fr 1.15fr;
min-height: 50px;
align-items: center;
border-top: 1px solid #edf0ee;
font-size: 9px;
}
.import-preview-row.head {
min-height: 36px;
border-top: 0;
color: #8c9793;
background: #f6f8f6;
font-weight: 650;
}
.import-preview-row > span {
min-width: 0;
padding: 8px 10px;
overflow-wrap: anywhere;
}
.import-preview-row > span:not(:last-child) {
border-right: 1px solid #f0f2f1;
}
.import-preview-row strong,
.import-preview-row small {
display: block;
}
.import-preview-row small {
margin-top: 3px;
color: #9aa39f;
font-size: 8px;
}
.import-result {
color: #557269;
font-weight: 650;
}
.import-result.create { color: var(--green); }
.import-result.update { color: #47778d; }
.import-result.error { color: #bd644b; font-weight: 520; }
.import-preview-more {
margin: 8px 0 0;
color: #919c98;
font-size: 9px;
text-align: right;
}
.form-error {
margin-top: 12px;
padding: 10px 12px;
border: 1px solid #f0d5cc;
border-radius: 9px;
color: #a95037;
background: #fff7f3;
font-size: 10px;
}
.resource-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -1905,27 +2281,19 @@ a {
.recovery-title-link {
display: inline-flex;
align-items: center;
gap: 5px;
max-width: 310px;
color: #182822;
color: #168565;
text-decoration: none;
vertical-align: top;
cursor: pointer;
}
.recovery-title-link strong {
min-width: 0;
}
.recovery-title-link::after {
content: "↗";
flex: none;
color: #2b9875;
font-size: 10px;
font-weight: 700;
}
.recovery-title-link:hover strong {
color: #168565;
color: #116d54;
text-decoration: underline;
text-underline-offset: 3px;
}
@@ -2038,15 +2406,178 @@ a {
}
.creator-screenshot-link {
position: relative;
width: 72px;
height: 56px;
padding: 0;
overflow: hidden;
border: 1px solid #dce5e0;
border-radius: 8px;
color: var(--green-deep);
background: #f4f7f5;
font-size: 8px;
font-weight: 700;
text-decoration: none;
cursor: zoom-in;
}
.creator-screenshot-link:hover {
text-decoration: underline;
text-underline-offset: 3px;
border-color: #9fc9b8;
}
.creator-screenshot-link img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.creator-screenshot-link span {
position: absolute;
right: 4px;
bottom: 4px;
padding: 3px 5px;
border-radius: 5px;
color: white;
background: rgb(17 48 39 / 0.76);
font-size: 7px;
}
.admin-image-lightbox {
position: fixed;
z-index: 2000;
inset: 0;
display: grid;
place-items: center;
padding: 22px;
background: rgb(7 24 19 / 0.78);
}
.admin-image-lightbox-card {
width: min(980px, 100%);
max-height: calc(100vh - 44px);
display: flex;
overflow: hidden;
border: 1px solid rgb(255 255 255 / 0.18);
border-radius: 16px;
background: #f7faf8;
box-shadow: 0 28px 90px rgb(0 0 0 / 0.34);
flex-direction: column;
}
.admin-image-lightbox-heading {
display: flex;
min-height: 52px;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 10px 16px;
border-bottom: 1px solid #dce5e0;
}
.admin-image-lightbox-heading > div {
display: flex;
min-width: 0;
align-items: center;
gap: 10px;
}
.admin-image-lightbox-heading strong {
overflow: hidden;
color: var(--green-deep);
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-image-lightbox-heading span {
flex: 0 0 auto;
padding: 3px 7px;
border-radius: 999px;
color: #5f746b;
background: #e9f1ed;
font-size: 8px;
font-weight: 700;
}
.admin-image-lightbox-heading button {
flex: 0 0 auto;
height: 32px;
padding: 0 13px;
border: 1px solid #c7d8d0;
border-radius: 8px;
color: var(--green-deep);
background: white;
font-size: 8px;
font-weight: 700;
}
.admin-image-lightbox-stage {
position: relative;
min-height: 0;
display: grid;
flex: 1;
place-items: center;
overflow: hidden;
background: #14231e;
}
.admin-image-lightbox-stage > img {
width: 100%;
height: 100%;
min-height: 0;
max-height: calc(100vh - 108px);
display: block;
object-fit: contain;
}
.admin-image-lightbox-stage > button {
position: absolute;
z-index: 1;
top: 50%;
min-width: 58px;
height: 36px;
padding: 0 10px;
border: 1px solid rgb(255 255 255 / 0.42);
border-radius: 999px;
color: white;
background: rgb(8 31 24 / 0.78);
box-shadow: 0 6px 20px rgb(0 0 0 / 0.24);
font-size: 9px;
font-weight: 700;
transform: translateY(-50%);
transition: background 160ms ease, transform 160ms ease;
}
.admin-image-lightbox-stage > button:hover,
.admin-image-lightbox-stage > button:focus-visible {
background: rgb(23 112 83 / 0.94);
transform: translateY(-50%) scale(1.03);
}
.admin-image-lightbox-stage > button.previous {
left: 14px;
}
.admin-image-lightbox-stage > button.next {
right: 14px;
}
@media (max-width: 640px) {
.admin-image-lightbox-stage > button {
min-width: 52px;
height: 32px;
padding: 0 8px;
font-size: 8px;
}
.admin-image-lightbox-stage > button.previous {
left: 8px;
}
.admin-image-lightbox-stage > button.next {
right: 8px;
}
}
.creator-screenshot-pending,
@@ -3388,6 +3919,37 @@ label small {
justify-content: space-between;
}
.resource-heading-actions {
width: 100%;
justify-content: flex-start;
}
.resource-heading-actions .filter-chips {
width: 100%;
margin: 4px 0 0;
overflow-x: auto;
}
.import-guide-strip {
grid-template-columns: auto 1fr;
}
.import-guide-strip i {
display: none;
}
.import-summary-grid {
grid-template-columns: repeat(2, 1fr);
}
.import-preview-table {
overflow-x: auto;
}
.import-preview-row {
min-width: 620px;
}
.collection-schedule-panel {
padding: 18px;
}

View File

@@ -1,18 +1,7 @@
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");
@@ -60,9 +49,7 @@ export default function RootLayout({
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="zh-CN">
<body className={`${geistSans.variable} ${geistMono.variable}`}>
{children}
</body>
<body>{children}</body>
</html>
);
}