feat: ship self-hosted KOC LOOP workflows
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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) } },
|
||||
|
||||
@@ -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) } },
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
24
app/api/health/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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))) {
|
||||
|
||||
@@ -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 : "图片读取失败" },
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
302
app/api/resources-import/route.ts
Normal file
302
app/api/resources-import/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
102
app/api/screenshot-task-export/route.ts
Normal file
102
app/api/screenshot-task-export/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
30
app/api/task-example-upload/route.ts
Normal file
30
app/api/task-example-upload/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
49
app/api/task-result-image/route.ts
Normal file
49
app/api/task-result-image/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user