Initial commit: KOC LOOP platform

This commit is contained in:
巫凤萍
2026-07-30 12:06:41 +08:00
commit d2cde2c9b2
98 changed files with 44076 additions and 0 deletions

11
.dev.vars.example Normal file
View File

@@ -0,0 +1,11 @@
KOC_PORTAL_URL=http://localhost:3000
ADMIN_ALLOWED_EMAIL=operator@example.com
ADMIN_INTERNAL_TOKEN=replace-with-a-random-secret
# Optional override. The production key must be stored as a runtime secret.
AI_TOOL_CENTER_MCP_URL=https://middle-aitool.gbotai.cn/mcp
AI_TOOL_CENTER_MCP_KEY=replace-with-mcp-key
# Feishu custom app credentials. Keep the secret out of source control.
FEISHU_APP_ID=cli_xxxxxxxxxxxxxxxxx
FEISHU_APP_SECRET=replace-with-feishu-app-secret

44
.gitignore vendored Normal file
View File

@@ -0,0 +1,44 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
/.playwright-cli/
# next.js
/.next/
/.vinext/
/out/
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
.dev.vars
# vercel
.vercel
# typescript
next-env.d.ts
/dist/
/.wrangler/
/outputs/
/work/

5
.openai/hosting.json Normal file
View File

@@ -0,0 +1,5 @@
{
"project_id": "appgprj_6a670019733c8191ab20ba068aa437cb",
"d1": "DB",
"r2": "UPLOADS"
}

104
README.md Normal file
View File

@@ -0,0 +1,104 @@
# KOC LOOP
KOC 内容分发与数据回收闭环,运行于 vinext、Cloudflare D1 和 R2。
## Prerequisites
- Node.js `>=22.13.0`
## Quick Start
```bash
npm install
npm run dev
npm run build
```
复制 `.dev.vars.example``.dev.vars` 并配置运行时变量。飞书动态导入需要:
- `FEISHU_APP_ID`
- `FEISHU_APP_SECRET`
飞书自建应用需开通电子表格读取、知识库节点读取和云文档素材下载权限,
并将应用添加到目标知识库或电子表格的文档应用中。
This starter does not use `wrangler.jsonc`.
## Included Shape
- edit site code under `app/`
- `.openai/hosting.json` declares optional Sites D1 and R2 bindings
- `vite.config.ts` simulates declared bindings for local development
- `db/schema.ts` starts intentionally empty
- `examples/d1/` contains an optional D1 example surface
- `drizzle.config.ts` supports local migration generation when needed
## Workspace Auth Headers
OpenAI workspace sites can read the current user's email from
`oai-authenticated-user-email`.
SIWC-authenticated workspace sites may also receive
`oai-authenticated-user-full-name` when the user's SIWC profile has a non-empty
`name` claim. The full-name value is percent-encoded UTF-8 and is accompanied by
`oai-authenticated-user-full-name-encoding: percent-encoded-utf-8`.
Treat the full name as optional and fall back to email when it is absent:
```tsx
import { headers } from "next/headers";
export default async function Home() {
const requestHeaders = await headers();
const email = requestHeaders.get("oai-authenticated-user-email");
const encodedFullName = requestHeaders.get("oai-authenticated-user-full-name");
const fullName =
encodedFullName &&
requestHeaders.get("oai-authenticated-user-full-name-encoding") ===
"percent-encoded-utf-8"
? decodeURIComponent(encodedFullName)
: null;
const displayName = fullName ?? email;
// ...
}
```
## Optional Dispatch-Owned ChatGPT Sign-In
Import the ready-to-use helpers from `app/chatgpt-auth.ts` when the site needs
optional or required ChatGPT sign-in:
- Use `getChatGPTUser()` for optional signed-in UI.
- Use `requireChatGPTUser(returnTo)` for server-rendered pages that should send
anonymous visitors through Sign in with ChatGPT.
- Use `chatGPTSignInPath(returnTo)` and `chatGPTSignOutPath(returnTo)` for
browser links or actions.
- Pass a same-origin relative `returnTo` path for the destination after sign-in
or sign-out. The helper validates and safely encodes it.
- Mark protected pages with `export const dynamic = "force-dynamic"` because
they depend on per-request identity headers.
Dispatch owns `/signin-with-chatgpt`, `/signout-with-chatgpt`, `/callback`, the
OAuth cookies, and identity header injection. Do not implement app routes for
those reserved paths. Routes that do not import and call the helper remain
anonymous-compatible.
SIWC establishes identity only; it does not prove workspace membership. Use the
Sites hosting platform's access policy controls for workspace-wide restrictions,
or enforce explicit server-side membership or allowlist checks.
Use SIWC for account pages, user-specific dashboards, saved records, and write
actions tied to the current ChatGPT user. Leave public content anonymous.
## Useful Commands
- `npm run dev`: start local development
- `npm run build`: verify the vinext build output
- `npm test`: build the starter and verify its rendered loading skeleton
- `npm run db:generate`: generate Drizzle migrations after schema changes
## Learn More
- [vinext Documentation](https://github.com/cloudflare/vinext)
- [Drizzle D1 Guide](https://orm.drizzle.team/docs/get-started/d1-new)

1675
app/admin-app.tsx Normal file

File diff suppressed because it is too large Load Diff

394
app/api/action/route.ts Normal file
View File

@@ -0,0 +1,394 @@
import { env } from "cloudflare:workers";
import { getRequestExecutionContext } from "vinext/shims/request-context";
import { backfillAccountProfiles } from "../../../lib/account-enrichment-service";
import {
ensureSchema,
getDashboardData,
getRawDb,
uid,
} from "../../../lib/mvp-db";
import {
collectDistributionMetrics,
createCollectionRunTasks,
retryFailedCollections,
runDueScheduledCollections,
shanghaiDateFromTimestamp,
} from "../../../lib/collection-service";
import {
resolveCollectionMcpConfig,
type CollectionMcpBindings,
} from "../../../lib/mcp-collection-client";
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
import {
FeishuSourceError,
readFeishuSource,
type FeishuBindings,
type FeishuSource,
} from "../../../lib/feishu-client";
export const runtime = "edge";
type ActionBody = {
action?: string;
[key: string]: unknown;
};
function numberValue(value: unknown, fallback = 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
async function createTaskFromSource(
source: FeishuSource,
name: string,
brand: string,
dueAt: string,
) {
const db = getRawDb();
const taskId = uid("task");
const shareToken = crypto.randomUUID().replaceAll("-", "");
await db
.prepare(
`INSERT INTO tasks
(id, name, brand, quantity, claimed_quantity, due_at, status,
source_url, source_sheet_id, source_sheet_name, source_synced_at,
share_token)
VALUES (?, ?, ?, ?, 0, ?, 'importing', ?, ?, ?, ?, ?)`,
)
.bind(
taskId,
name,
brand,
source.rows.length,
dueAt,
source.url,
source.sheetId,
source.sheetName,
source.syncedAt,
shareToken,
)
.run();
try {
const contentStatements = source.rows.map((row) => {
const contentId = uid("content");
const imageAssets = row.images.map((image) => ({
...image,
key: `content-assets/${taskId}/${contentId}/${image.index}`,
}));
return db
.prepare(
`INSERT INTO contents
(id, task_id, title, body, image_assets, status, source, source_row)
VALUES (?, ?, ?, ?, ?, 'available', ?, ?)`,
)
.bind(
contentId,
taskId,
row.title,
row.body,
JSON.stringify(imageAssets),
`飞书 · ${source.sheetName}`,
row.sourceRow,
);
});
for (let index = 0; index < contentStatements.length; index += 100) {
await db.batch(contentStatements.slice(index, index + 100));
}
await db
.prepare("UPDATE tasks SET status = 'active' WHERE id = ?")
.bind(taskId)
.run();
} catch (error) {
await db.batch([
db.prepare("DELETE FROM contents WHERE task_id = ?").bind(taskId),
db.prepare("DELETE FROM tasks WHERE id = ?").bind(taskId),
]);
throw error;
}
}
export async function POST(request: Request) {
if (!isAdminRequest(request)) return adminForbidden();
try {
await ensureSchema();
const body = (await request.json()) as ActionBody;
const db = getRawDb();
if (body.action === "inspect_feishu") {
const source = await readFeishuSource(
String(body.feishuUrl ?? "").trim(),
env as unknown as FeishuBindings,
);
return Response.json({
sheetId: source.sheetId,
sheetName: source.sheetName,
syncedAt: source.syncedAt,
rowCount: source.rows.length,
columns: source.columns,
preview: source.rows.slice(0, 3),
});
}
if (body.action === "create_task") {
const name = String(body.name ?? "").trim();
const brand = String(body.brand ?? "").trim();
const dueAt = String(body.dueAt ?? "").trim();
if (!name || !brand || !dueAt) {
return Response.json(
{ error: "请补全任务名称、品牌和截止日期" },
{ status: 400 },
);
}
const source = await readFeishuSource(
String(body.feishuUrl ?? "").trim(),
env as unknown as FeishuBindings,
);
await createTaskFromSource(source, name, brand, dueAt);
} else if (body.action === "claim") {
const partnerId = String(body.partnerId ?? "");
const taskId = String(body.taskId ?? "");
const quantity = Math.max(1, Math.min(50, numberValue(body.quantity, 1)));
const available = await db
.prepare(
`SELECT id FROM contents
WHERE task_id = ? AND status = 'available'
ORDER BY created_at, id
LIMIT ?`,
)
.bind(taskId, quantity)
.all<{ id: string }>();
if (available.results.length === 0) {
return Response.json({ error: "当前没有可领取内容" }, { status: 409 });
}
const statements = available.results.flatMap((content) => [
db
.prepare(
`INSERT INTO distributions
(id, task_id, content_id, partner_id, status)
VALUES (?, ?, ?, ?, 'claimed')`,
)
.bind(uid("dist"), taskId, content.id, partnerId),
db
.prepare("UPDATE contents SET status = 'allocated' WHERE id = ?")
.bind(content.id),
]);
statements.push(
db
.prepare(
`UPDATE tasks SET claimed_quantity = claimed_quantity + ?
WHERE id = ?`,
)
.bind(available.results.length, taskId),
db
.prepare(
`UPDATE partners SET claimed_total = claimed_total + ?
WHERE id = ?`,
)
.bind(available.results.length, partnerId),
);
await db.batch(statements);
} else if (body.action === "save_collection_schedule") {
const taskId = String(body.taskId ?? "").trim();
const startDate = String(body.startDate ?? "").trim();
const days = Array.isArray(body.days)
? [...new Set(body.days.map(Number))]
.filter(
(day) =>
Number.isInteger(day) && day >= 1 && day <= 7,
)
.sort((a, b) => a - b)
: [];
if (!taskId || !/^\d{4}-\d{2}-\d{2}$/.test(startDate)) {
return Response.json(
{ error: "请选择有效的开始采集日期" },
{ status: 400 },
);
}
if (days.length === 0) {
return Response.json(
{ error: "请至少选择一个自动采集日" },
{ status: 400 },
);
}
const task = await db
.prepare("SELECT id FROM tasks WHERE id = ?")
.bind(taskId)
.first<{ id: string }>();
if (!task) {
return Response.json({ error: "任务不存在" }, { status: 404 });
}
await db.batch([
db
.prepare(
`UPDATE tasks
SET collection_start_date = ?,
collection_days = ?,
collection_schedule_updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(startDate, JSON.stringify(days), taskId),
db
.prepare(
`UPDATE distributions
SET collection_status = CASE
WHEN latest_likes IS NULL THEN 'scheduled'
ELSE collection_status
END,
collection_status_description = CASE
WHEN latest_likes IS NULL THEN ?
ELSE collection_status_description
END,
updated_at = CURRENT_TIMESTAMP
WHERE task_id = ?
AND publish_url IS NOT NULL
AND publish_url != ''`,
)
.bind(
`已安排${days.length}个采集日每日10:00执行`,
taskId,
),
]);
await createCollectionRunTasks(db, taskId, startDate, days);
const catchup = runDueScheduledCollections(
db,
Date.now(),
resolveCollectionMcpConfig(
env as unknown as CollectionMcpBindings,
),
"catchup",
taskId,
).catch(() => undefined);
const executionContext = getRequestExecutionContext();
if (executionContext) {
executionContext.waitUntil(catchup);
} else {
await catchup;
}
} else if (body.action === "run_due_collections") {
await runDueScheduledCollections(
db,
Date.now(),
resolveCollectionMcpConfig(
env as unknown as CollectionMcpBindings,
),
"catchup",
);
} else if (
body.action === "collect_now" ||
body.action === "collect"
) {
const distributionId = String(body.distributionId ?? "");
const rawDay =
body.day === null || body.day === undefined
? null
: numberValue(body.day);
const day =
rawDay !== null && Number.isInteger(rawDay) && rawDay >= 1 && rawDay <= 7
? rawDay
: null;
if (body.action === "collect" && day === null) {
return Response.json({ error: "采集周期无效" }, { status: 400 });
}
await collectDistributionMetrics(
db,
distributionId,
shanghaiDateFromTimestamp(Date.now()),
day,
"manual",
resolveCollectionMcpConfig(
env as unknown as CollectionMcpBindings,
),
);
} else if (body.action === "retry_failed_collections") {
const taskId = String(body.taskId ?? "").trim();
if (!taskId) {
return Response.json({ error: "请选择需要补采的任务" }, { status: 400 });
}
await retryFailedCollections(
db,
taskId,
resolveCollectionMcpConfig(
env as unknown as CollectionMcpBindings,
),
);
} else if (body.action === "backfill_account_profiles") {
const backfill = backfillAccountProfiles(
db,
resolveCollectionMcpConfig(
env as unknown as CollectionMcpBindings,
),
Math.max(1, Math.min(25, numberValue(body.limit, 10))),
).catch(() => undefined);
const executionContext = getRequestExecutionContext();
if (executionContext) {
executionContext.waitUntil(backfill);
} else {
await backfill;
}
} else if (body.action === "set_public_account_ids") {
const items = Array.isArray(body.items) ? body.items.slice(0, 50) : [];
const normalized = items
.map((item) => {
const record =
item && typeof item === "object"
? (item as Record<string, unknown>)
: {};
return {
accountId: String(record.accountId ?? "").trim().slice(0, 80),
publicAccountId: String(record.publicAccountId ?? "")
.trim()
.slice(0, 80),
};
})
.filter(
(item) =>
item.accountId &&
/^[\p{L}\p{N}._-]{2,80}$/u.test(item.publicAccountId),
);
if (normalized.length === 0) {
return Response.json(
{ error: "没有可回填的账号号值" },
{ status: 400 },
);
}
await db.batch(
normalized.map((item) =>
db
.prepare(
`UPDATE accounts
SET public_account_id = ?,
last_seen_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(item.publicAccountId, item.accountId),
),
);
} else if (body.action === "manual_metrics") {
const distributionId = String(body.distributionId ?? "");
const exposure = Math.max(0, numberValue(body.exposure));
const views = Math.max(0, numberValue(body.views));
await db
.prepare(
`UPDATE distributions SET
exposure = ?,
views = ?,
ocr_status = 'manual',
status = CASE WHEN d7_likes IS NOT NULL THEN 'complete' ELSE status END,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(exposure, views, distributionId)
.run();
} else {
return Response.json({ error: "不支持的操作" }, { status: 400 });
}
return Response.json(await getDashboardData());
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : "操作失败" },
{ status: error instanceof FeishuSourceError ? error.status : 500 },
);
}
}

View File

@@ -0,0 +1,70 @@
import { env } from "cloudflare:workers";
import { getRequestExecutionContext } from "vinext/shims/request-context";
import { backfillAccountProfiles } from "../../../lib/account-enrichment-service";
import { runDueScheduledCollections } from "../../../lib/collection-service";
import {
resolveCollectionMcpConfig,
type CollectionMcpBindings,
} from "../../../lib/mcp-collection-client";
import {
ensureSchema,
getDashboardData,
getRawDb,
seedIfEmpty,
} from "../../../lib/mvp-db";
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
export const runtime = "edge";
export async function GET(request: Request) {
if (!isAdminRequest(request)) return adminForbidden();
try {
await ensureSchema();
await seedIfEmpty();
const db = getRawDb();
const mcpConfig = resolveCollectionMcpConfig(
env as unknown as CollectionMcpBindings,
);
const collectionCatchup = runDueScheduledCollections(
db,
Date.now(),
mcpConfig,
"catchup",
).catch((error) => {
console.error(
"KOC collection catchup failed",
error instanceof Error ? error.message : "unknown error",
);
});
const accountBackfill = backfillAccountProfiles(
db,
mcpConfig,
10,
)
.then((result) => {
console.info("KOC account backfill completed", result);
})
.catch((error) => {
console.error(
"KOC account backfill failed",
error instanceof Error ? error.message : "unknown error",
);
});
const catchup = Promise.all([
collectionCatchup,
accountBackfill,
]);
const executionContext = getRequestExecutionContext();
if (executionContext) {
executionContext.waitUntil(catchup);
} else {
await catchup;
}
return Response.json(await getDashboardData());
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : "加载失败" },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,53 @@
import feishuSnapshot from "../../../lib/feishu-source-snapshot.json";
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
import { ensureSchema, getUploadBucket } from "../../../lib/mvp-db";
export const runtime = "edge";
export async function POST(request: Request) {
if (!isAdminRequest(request)) return adminForbidden();
try {
await ensureSchema();
const form = await request.formData();
const sheetId = String(form.get("sheetId") ?? "").trim();
const sourceRow = Number(form.get("sourceRow"));
const imageIndex = Number(form.get("imageIndex"));
const file = form.get("file");
if (
sheetId !== feishuSnapshot.sheetId ||
!Number.isInteger(sourceRow) ||
!Number.isInteger(imageIndex) ||
!(file instanceof File) ||
file.size === 0
) {
return Response.json({ error: "图片映射参数无效" }, { status: 400 });
}
if (!file.type.startsWith("image/") || file.size > 8_000_000) {
return Response.json(
{ error: "仅支持8MB以内的图片" },
{ status: 400 },
);
}
const row = feishuSnapshot.rows.find(
(item) => item.sourceRow === sourceRow,
);
const asset = row?.images.find((item) => item.index === imageIndex);
if (!asset) {
return Response.json({ error: "图片不属于当前飞书内容表" }, { status: 422 });
}
await getUploadBucket().put(asset.key, await file.arrayBuffer(), {
httpMetadata: { contentType: file.type },
customMetadata: {
sheetId,
sourceRow: String(sourceRow),
imageIndex: String(imageIndex),
},
});
return Response.json({ uploaded: true });
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : "图片同步失败" },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,53 @@
import {
ensureSchema,
getRawDb,
getUploadBucket,
} from "../../../lib/mvp-db";
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
export const runtime = "edge";
export async function GET(request: Request) {
if (!isAdminRequest(request)) return adminForbidden();
try {
await ensureSchema();
const distributionId = new URL(request.url).searchParams
.get("distribution")
?.trim();
if (!distributionId) {
return Response.json({ error: "缺少分发记录" }, { status: 400 });
}
const row = await getRawDb()
.prepare(
`SELECT screenshot_key FROM distributions
WHERE id = ?`,
)
.bind(distributionId)
.first<{ screenshot_key: string | null }>();
if (
!row?.screenshot_key ||
!row.screenshot_key.startsWith("creator-center/")
) {
return Response.json({ error: "创作者截图不存在" }, { status: 404 });
}
const object = await getUploadBucket().get(row.screenshot_key);
if (!object) {
return Response.json({ error: "创作者截图不存在" }, { status: 404 });
}
const headers = new Headers({
"Cache-Control": "private, no-store",
"Content-Disposition": 'inline; filename="creator-center-screenshot"',
"X-Content-Type-Options": "nosniff",
});
object.writeHttpMetadata(headers);
if (!headers.get("Content-Type")) {
headers.set("Content-Type", "image/jpeg");
}
return new Response(object.body, { headers });
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : "截图读取失败" },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,129 @@
import { env } from "cloudflare:workers";
import {
ensureSchema,
getRawDb,
getUploadBucket,
} from "../../../lib/mvp-db";
import {
downloadFeishuMedia,
type FeishuBindings,
} from "../../../lib/feishu-client";
import {
partnerOptions,
withPartnerCors,
} from "../../../lib/partner-cors";
export const runtime = "edge";
type StoredAsset = {
index: number;
key: string;
fileToken?: string;
};
function textValue(value: string | null, maxLength = 100) {
return String(value ?? "").trim().slice(0, maxLength);
}
function findAsset(value: string, imageIndex: number) {
try {
const assets = JSON.parse(value) as StoredAsset[];
return Array.isArray(assets)
? assets.find(
(asset) =>
asset.index === imageIndex &&
typeof asset.key === "string" &&
asset.key.startsWith("content-assets/") &&
(asset.fileToken === undefined ||
typeof asset.fileToken === "string"),
)
: undefined;
} catch {
return undefined;
}
}
async function handleGet(request: Request) {
try {
await ensureSchema();
const url = new URL(request.url);
const taskToken = textValue(url.searchParams.get("task"));
const claimToken = textValue(url.searchParams.get("claim"));
const delegationToken = textValue(url.searchParams.get("share"));
const distributionId = textValue(url.searchParams.get("distribution"));
const imageIndex = Number(url.searchParams.get("index"));
if (
(!delegationToken && (!taskToken || !claimToken)) ||
!distributionId ||
!Number.isInteger(imageIndex) ||
imageIndex < 1
) {
return Response.json({ error: "图片链接不完整" }, { status: 400 });
}
const row = delegationToken
? await getRawDb()
.prepare(
`SELECT c.image_assets
FROM distributions d
JOIN contents c ON c.id = d.content_id
JOIN delegation_bundles b ON b.id = d.delegation_bundle_id
WHERE d.id = ?
AND b.share_token = ?
AND b.status = 'active'`,
)
.bind(distributionId, delegationToken)
.first<{ image_assets: string }>()
: await getRawDb()
.prepare(
`SELECT c.image_assets
FROM distributions d
JOIN contents c ON c.id = d.content_id
JOIN claims cl ON cl.id = d.claim_id
JOIN tasks t ON t.id = d.task_id
WHERE d.id = ?
AND cl.claim_token = ?
AND t.share_token = ?
AND cl.task_id = t.id`,
)
.bind(distributionId, claimToken, taskToken)
.first<{ image_assets: string }>();
const asset = row ? findAsset(row.image_assets, imageIndex) : undefined;
if (!asset) {
return Response.json({ error: "没有找到这张笔记图片" }, { status: 404 });
}
const bucket = getUploadBucket();
let object = await bucket.get(asset.key);
if (!object && asset.fileToken) {
const media = await downloadFeishuMedia(
asset.fileToken,
env as unknown as FeishuBindings,
);
await bucket.put(asset.key, media.bytes, {
httpMetadata: { contentType: media.contentType },
customMetadata: { source: "feishu-api" },
});
object = await bucket.get(asset.key);
}
if (!object) {
return Response.json({ error: "图片同步失败,请稍后重试" }, { status: 404 });
}
const headers = new Headers();
object.writeHttpMetadata(headers);
headers.set("Cache-Control", "private, max-age=3600");
headers.set("Content-Disposition", `inline; filename="note-${imageIndex}"`);
return new Response(object.body, { headers });
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : "图片读取失败" },
{ status: 500 },
);
}
}
export async function GET(request: Request) {
return withPartnerCors(request, await handleGet(request));
}
export async function OPTIONS(request: Request) {
return partnerOptions(request);
}

View File

@@ -0,0 +1,164 @@
import {
ensureSchema,
getRawDb,
getUploadBucket,
uid,
} from "../../../lib/mvp-db";
import {
partnerOptions,
withPartnerCors,
} from "../../../lib/partner-cors";
export const runtime = "edge";
async function readUpload(request: Request) {
const contentType = request.headers.get("content-type") ?? "";
if (contentType.startsWith("multipart/form-data")) {
const form = await request.formData();
const file = form.get("file");
if (!(file instanceof File)) return null;
return {
taskToken: String(form.get("taskToken") ?? "").trim(),
claimToken: String(form.get("claimToken") ?? "").trim(),
delegationToken: String(form.get("delegationToken") ?? "").trim(),
distributionId: String(form.get("distributionId") ?? "").trim(),
uploadKind: String(form.get("uploadKind") ?? "publish").trim(),
fileName: file.name,
fileType: file.type,
fileBytes: await file.arrayBuffer(),
};
}
const encodedName = request.headers.get("x-koc-file-name") ?? "screenshot.jpg";
let fileName = "screenshot.jpg";
try {
fileName = decodeURIComponent(encodedName);
} catch {
fileName = "screenshot.jpg";
}
return {
taskToken: String(request.headers.get("x-koc-task") ?? "").trim(),
claimToken: String(request.headers.get("x-koc-claim") ?? "").trim(),
delegationToken: String(
request.headers.get("x-koc-delegation") ?? "",
).trim(),
distributionId: String(
request.headers.get("x-koc-distribution") ?? "",
).trim(),
uploadKind: String(
request.headers.get("x-koc-upload-kind") ?? "publish",
).trim(),
fileName,
fileType: contentType.split(";")[0].trim(),
fileBytes: await request.arrayBuffer(),
};
}
async function handlePost(request: Request) {
try {
await ensureSchema();
const upload = await readUpload(request);
if (
!upload ||
(!upload.claimToken && !upload.delegationToken) ||
(!upload.delegationToken && !upload.taskToken) ||
!upload.distributionId ||
upload.fileBytes.byteLength === 0
) {
return Response.json({ error: "请选择发布截图" }, { status: 400 });
}
if (
!upload.fileType.startsWith("image/") ||
upload.fileBytes.byteLength > 8_000_000
) {
return Response.json(
{ error: "仅支持8MB以内的图片" },
{ status: 400 },
);
}
const isCreatorCenter = upload.uploadKind === "creator-center";
const assignment = upload.delegationToken
? await getRawDb()
.prepare(
`SELECT d.id, d.publish_url
FROM distributions d
JOIN delegation_bundles b ON b.id = d.delegation_bundle_id
WHERE d.id = ?
AND b.share_token = ?
AND b.status = 'active'`,
)
.bind(upload.distributionId, upload.delegationToken)
.first<{ id: string; publish_url: string | null }>()
: await getRawDb()
.prepare(
`SELECT d.id, d.publish_url
FROM distributions d
JOIN claims c ON c.id = d.claim_id
JOIN tasks t ON t.id = d.task_id
WHERE d.id = ?
AND c.claim_token = ?
AND t.share_token = ?
AND c.task_id = t.id`,
)
.bind(upload.distributionId, upload.claimToken, upload.taskToken)
.first<{ id: string; publish_url: string | null }>();
if (!assignment) {
return Response.json({ error: "笔记与领取凭证不匹配" }, { status: 403 });
}
if (isCreatorCenter && !assignment.publish_url) {
return Response.json(
{ error: "请先回填这篇笔记的发布信息" },
{ status: 409 },
);
}
const extension =
upload.fileName.split(".").at(-1)?.replace(/[^a-zA-Z0-9]/g, "") || "jpg";
const key = isCreatorCenter
? `creator-center/${upload.distributionId}/${uid("shot")}.${extension}`
: `publish-evidence/${upload.distributionId}/${uid("shot")}.${extension}`;
await getUploadBucket().put(key, upload.fileBytes, {
httpMetadata: { contentType: upload.fileType },
});
if (isCreatorCenter) {
await getRawDb()
.prepare(
`UPDATE distributions SET
screenshot_key = ?,
ocr_status = CASE
WHEN exposure IS NOT NULL AND views IS NOT NULL THEN ocr_status
ELSE 'uploaded'
END,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(key, upload.distributionId)
.run();
} else {
await getRawDb()
.prepare(
`UPDATE distributions SET
publish_screenshot_key = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(key, upload.distributionId)
.run();
}
return Response.json({
uploaded: true,
kind: isCreatorCenter ? "creator-center" : "publish",
});
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : "上传失败" },
{ status: 500 },
);
}
}
export async function POST(request: Request) {
return withPartnerCors(request, await handlePost(request));
}
export async function OPTIONS(request: Request) {
return partnerOptions(request);
}

951
app/api/partner/route.ts Normal file
View File

@@ -0,0 +1,951 @@
import { env } from "cloudflare:workers";
import { getRequestExecutionContext } from "vinext/shims/request-context";
import { enrichDistributionAccount } from "../../../lib/account-enrichment-service";
import { createCollectionRunTasks } from "../../../lib/collection-service";
import {
resolveCollectionMcpConfig,
type CollectionMcpBindings,
} from "../../../lib/mcp-collection-client";
import {
ensureSchema,
getRawDb,
hashText,
uid,
} from "../../../lib/mvp-db";
import {
accountFromPublishLink,
extractXhsPublishUrl,
} from "../../../lib/partner-utils";
import {
partnerOptions,
withPartnerCors,
} from "../../../lib/partner-cors";
export const runtime = "edge";
type PartnerBody = {
action?: string;
taskToken?: string;
claimToken?: string;
delegationToken?: string;
distributionId?: string;
distributionIds?: string[];
delegationLabel?: string;
delegationBundleId?: string;
claimantName?: string;
quantity?: number;
accountNickname?: string;
publishUrl?: string;
exposure?: number | string;
views?: number | string;
};
type ImageAsset = {
index: number;
width: number | null;
height: number | null;
};
function textValue(value: unknown, maxLength = 200) {
return String(value ?? "").trim().slice(0, maxLength);
}
function quantityValue(value: unknown) {
const parsed = Number(value);
return Number.isFinite(parsed) ? Math.max(1, Math.min(50, Math.floor(parsed))) : 1;
}
function creatorMetricValue(value: unknown) {
const normalized = String(value ?? "").trim();
if (!/^\d{1,12}$/.test(normalized)) return null;
const parsed = Number(normalized);
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
}
function idList(value: unknown) {
if (!Array.isArray(value)) return [];
return [
...new Set(
value
.map((item) => textValue(item, 80))
.filter(Boolean)
.slice(0, 50),
),
];
}
function publicImageAssets(value: unknown): ImageAsset[] {
try {
const assets = JSON.parse(String(value ?? "[]")) as Array<
Partial<ImageAsset> & { key?: string }
>;
if (!Array.isArray(assets)) return [];
return assets
.map((asset) => ({
index: Number(asset.index),
width:
typeof asset.width === "number" && Number.isFinite(asset.width)
? asset.width
: null,
height:
typeof asset.height === "number" && Number.isFinite(asset.height)
? asset.height
: null,
}))
.filter((asset) => Number.isInteger(asset.index) && asset.index > 0);
} catch {
return [];
}
}
async function findTask(taskToken: string) {
return getRawDb()
.prepare(
`SELECT id, name, brand, quantity, claimed_quantity, due_at, status
FROM tasks WHERE share_token = ?`,
)
.bind(taskToken)
.first<{
id: string;
name: string;
brand: string;
quantity: number;
claimed_quantity: number;
due_at: string;
status: string;
}>();
}
async function findDelegationAccess(delegationToken: string) {
return getRawDb()
.prepare(
`SELECT
t.id,
t.name,
t.brand,
t.quantity,
t.claimed_quantity,
t.due_at,
t.status,
b.id AS bundle_id,
b.label AS bundle_label,
b.quantity AS bundle_quantity,
b.created_at AS bundle_created_at
FROM delegation_bundles b
JOIN tasks t ON t.id = b.task_id
WHERE b.share_token = ? AND b.status = 'active'`,
)
.bind(delegationToken)
.first<{
id: string;
name: string;
brand: string;
quantity: number;
claimed_quantity: number;
due_at: string;
status: string;
bundle_id: string;
bundle_label: string;
bundle_quantity: number;
bundle_created_at: string;
}>();
}
async function findAccessibleAssignment(
taskId: string,
distributionId: string,
claimToken: string,
delegationToken: string,
) {
const db = getRawDb();
const select = `SELECT
d.id,
d.partner_id,
d.account_id,
d.publish_url,
d.publish_screenshot_key,
d.screenshot_key`;
if (delegationToken) {
return db
.prepare(
`${select}
FROM distributions d
JOIN delegation_bundles b ON b.id = d.delegation_bundle_id
WHERE d.id = ?
AND b.share_token = ?
AND b.task_id = ?
AND b.status = 'active'`,
)
.bind(distributionId, delegationToken, taskId)
.first<{
id: string;
partner_id: string;
account_id: string | null;
publish_url: string | null;
publish_screenshot_key: string | null;
screenshot_key: string | null;
}>();
}
if (!claimToken) return null;
return db
.prepare(
`${select}
FROM distributions d
JOIN claims c ON c.id = d.claim_id
WHERE d.id = ? AND c.claim_token = ? AND c.task_id = ?`,
)
.bind(distributionId, claimToken, taskId)
.first<{
id: string;
partner_id: string;
account_id: string | null;
publish_url: string | null;
publish_screenshot_key: string | null;
screenshot_key: string | null;
}>();
}
async function handleGet(request: Request) {
try {
await ensureSchema();
const url = new URL(request.url);
const taskToken = textValue(url.searchParams.get("task"), 80);
const claimToken = textValue(url.searchParams.get("claim"), 80);
const delegationToken = textValue(url.searchParams.get("share"), 80);
const delegationAccess = delegationToken
? await findDelegationAccess(delegationToken)
: null;
const task = delegationAccess ?? (await findTask(taskToken));
if (!task) {
return Response.json(
{
error: delegationToken
? "分享链接无效、已撤销或已失效"
: "任务链接无效或已失效",
},
{ status: 404 },
);
}
const available = await getRawDb()
.prepare(
`SELECT COUNT(*) AS count FROM contents
WHERE task_id = ? AND status = 'available'`,
)
.bind(task.id)
.first<{ count: number }>();
let claim: Record<string, unknown> | null = null;
let delegation: Record<string, unknown> | null = null;
if (claimToken && !delegationToken) {
const claimRow = await getRawDb()
.prepare(
`SELECT id, claimant_name, quantity, created_at
FROM claims
WHERE claim_token = ? AND task_id = ?`,
)
.bind(claimToken, task.id)
.first<{
id: string;
claimant_name: string;
quantity: number;
created_at: string;
}>();
if (!claimRow) {
return Response.json({ error: "领取凭证无效" }, { status: 403 });
}
const assignments = await getRawDb()
.prepare(
`SELECT
d.id,
d.status,
d.publish_url,
d.publish_time,
d.publish_screenshot_key,
d.screenshot_key AS creator_screenshot_key,
d.ocr_status,
d.exposure,
d.views,
c.title,
c.body,
c.source_row,
c.image_assets,
a.nickname AS account_nickname,
b.id AS delegation_bundle_id,
b.label AS delegation_label
FROM distributions d
JOIN contents c ON c.id = d.content_id
LEFT JOIN accounts a ON a.id = d.account_id
LEFT JOIN delegation_bundles b
ON b.id = d.delegation_bundle_id AND b.status = 'active'
WHERE d.claim_id = ?
ORDER BY d.claimed_at, d.id`,
)
.bind(claimRow.id)
.all();
const delegations = await getRawDb()
.prepare(
`SELECT
b.id,
b.label,
b.share_token,
b.quantity,
b.status,
b.created_at,
b.revoked_at,
SUM(CASE WHEN d.publish_url IS NOT NULL AND d.publish_url != '' THEN 1 ELSE 0 END) AS completed_count,
SUM(CASE WHEN d.screenshot_key IS NOT NULL AND d.exposure IS NOT NULL AND d.views IS NOT NULL THEN 1 ELSE 0 END) AS creator_completed_count
FROM delegation_bundles b
LEFT JOIN distributions d ON d.delegation_bundle_id = b.id
WHERE b.claim_id = ?
GROUP BY b.id, b.label, b.share_token, b.quantity, b.status, b.created_at, b.revoked_at
ORDER BY b.created_at DESC, b.id DESC`,
)
.bind(claimRow.id)
.all();
claim = {
id: claimRow.id,
claimantName: claimRow.claimant_name,
quantity: claimRow.quantity,
createdAt: claimRow.created_at,
assignments: assignments.results.map((assignment) => ({
...assignment,
images: publicImageAssets(assignment.image_assets),
image_assets: undefined,
})),
delegations: delegations.results,
};
} else if (delegationAccess) {
const assignments = await getRawDb()
.prepare(
`SELECT
d.id,
d.status,
d.publish_url,
d.publish_time,
d.publish_screenshot_key,
d.screenshot_key AS creator_screenshot_key,
d.ocr_status,
d.exposure,
d.views,
c.title,
c.body,
c.source_row,
c.image_assets,
a.nickname AS account_nickname
FROM distributions d
JOIN contents c ON c.id = d.content_id
LEFT JOIN accounts a ON a.id = d.account_id
WHERE d.delegation_bundle_id = ?
ORDER BY d.claimed_at, d.id`,
)
.bind(delegationAccess.bundle_id)
.all();
delegation = {
id: delegationAccess.bundle_id,
label: "转派发布包",
quantity: delegationAccess.bundle_quantity,
createdAt: delegationAccess.bundle_created_at,
assignments: assignments.results.map((assignment) => ({
...assignment,
images: publicImageAssets(assignment.image_assets),
image_assets: undefined,
})),
};
}
return Response.json({
task: delegationAccess
? {
name: task.name,
brand: task.brand,
dueAt: task.due_at,
status: task.status,
}
: {
name: task.name,
brand: task.brand,
quantity: task.quantity,
claimedQuantity: task.claimed_quantity,
dueAt: task.due_at,
status: task.status,
availableQuantity: available?.count ?? 0,
},
claim,
delegation,
});
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : "暂时无法打开任务" },
{ status: 500 },
);
}
}
async function handlePost(request: Request) {
try {
await ensureSchema();
const body = (await request.json()) as PartnerBody;
const taskToken = textValue(body.taskToken, 80);
const delegationToken = textValue(body.delegationToken, 80);
const delegationAccess = delegationToken
? await findDelegationAccess(delegationToken)
: null;
const task = delegationAccess ?? (await findTask(taskToken));
if (!task) {
return Response.json(
{ error: delegationToken ? "分享链接无效或已撤销" : "任务链接无效" },
{ status: 404 },
);
}
if (
delegationToken &&
body.action !== "submit" &&
body.action !== "submit_creator_metrics"
) {
return Response.json(
{ error: "分享链接只能用于查看和回填包内笔记" },
{ status: 403 },
);
}
const db = getRawDb();
if (body.action === "recover") {
const claimantName = textValue(body.claimantName, 40);
if (claimantName.length < 2) {
return Response.json(
{ error: "请填写领取时使用的企微昵称或联系人" },
{ status: 400 },
);
}
const partnerId = `partner-ext-${hashText(
`${task.id}:${claimantName.toLowerCase()}`,
)}`;
const recovered = await db
.prepare(
`SELECT
c.claim_token,
c.quantity,
c.created_at,
COUNT(d.id) AS note_count,
SUM(CASE WHEN d.publish_url IS NOT NULL AND d.publish_url != '' THEN 1 ELSE 0 END) AS completed_count,
MIN(co.title) AS first_title
FROM claims c
LEFT JOIN distributions d ON d.claim_id = c.id
LEFT JOIN contents co ON co.id = d.content_id
WHERE c.task_id = ? AND c.partner_id = ?
GROUP BY c.id, c.claim_token, c.quantity, c.created_at
ORDER BY c.created_at DESC, c.id DESC
LIMIT 20`,
)
.bind(task.id, partnerId)
.all<{
claim_token: string;
quantity: number;
created_at: string;
note_count: number;
completed_count: number;
first_title: string | null;
}>();
if (recovered.results.length === 0) {
return Response.json(
{ error: "没有找到领取记录,请确认昵称与领取时完全一致" },
{ status: 404 },
);
}
return Response.json({
claims: recovered.results.map((claim) => ({
claimToken: claim.claim_token,
quantity: claim.note_count || claim.quantity,
completedCount: claim.completed_count || 0,
createdAt: claim.created_at,
firstTitle: claim.first_title || "领取的笔记",
})),
});
}
if (body.action === "claim") {
if (task.status !== "active") {
return Response.json({ error: "任务已结束,无法继续领取" }, { status: 409 });
}
const claimantName = textValue(body.claimantName, 40);
const quantity = quantityValue(body.quantity);
if (claimantName.length < 2) {
return Response.json({ error: "请填写企微昵称或联系人" }, { status: 400 });
}
const available = await db
.prepare(
`SELECT id FROM contents
WHERE task_id = ? AND status = 'available'
ORDER BY COALESCE(source_row, 999999), created_at, id
LIMIT ?`,
)
.bind(task.id, quantity)
.all<{ id: string }>();
if (available.results.length === 0) {
return Response.json({ error: "当前任务已领完" }, { status: 409 });
}
const partnerId = `partner-ext-${hashText(
`${task.id}:${claimantName.toLowerCase()}`,
)}`;
const claimId = uid("claim");
const claimToken = crypto.randomUUID().replaceAll("-", "");
const statements: D1PreparedStatement[] = [
db
.prepare(
`INSERT INTO partners
(id, name, wecom_name, owner, claimed_total, completed_total)
VALUES (?, ?, ?, '外部KOC', 0, 0)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
wecom_name = excluded.wecom_name`,
)
.bind(partnerId, claimantName, claimantName),
db
.prepare(
`INSERT INTO claims
(id, task_id, partner_id, claimant_name, claim_token, quantity)
VALUES (?, ?, ?, ?, ?, ?)`,
)
.bind(
claimId,
task.id,
partnerId,
claimantName,
claimToken,
available.results.length,
),
];
for (const content of available.results) {
statements.push(
db
.prepare(
`INSERT INTO distributions
(id, task_id, content_id, partner_id, claim_id, status)
VALUES (?, ?, ?, ?, ?, 'claimed')`,
)
.bind(uid("dist"), task.id, content.id, partnerId, claimId),
db
.prepare(
`UPDATE contents SET status = 'allocated'
WHERE id = ? AND status = 'available'`,
)
.bind(content.id),
);
}
statements.push(
db
.prepare(
`UPDATE tasks SET claimed_quantity = claimed_quantity + ?
WHERE id = ?`,
)
.bind(available.results.length, task.id),
db
.prepare(
`UPDATE partners SET claimed_total = claimed_total + ?
WHERE id = ?`,
)
.bind(available.results.length, partnerId),
);
await db.batch(statements);
return Response.json({
claimToken,
claimedCount: available.results.length,
});
}
if (body.action === "create_delegation") {
const claimToken = textValue(body.claimToken, 80);
const label = textValue(body.delegationLabel, 24);
const distributionIds = idList(body.distributionIds);
if (!label) {
return Response.json(
{ error: "请填写接收人备注,例如 A 或小王" },
{ status: 400 },
);
}
if (distributionIds.length === 0) {
return Response.json(
{ error: "请至少选择一篇待发布笔记" },
{ status: 400 },
);
}
const claimRow = await db
.prepare(
`SELECT id, partner_id
FROM claims
WHERE claim_token = ? AND task_id = ?`,
)
.bind(claimToken, task.id)
.first<{ id: string; partner_id: string }>();
if (!claimRow) {
return Response.json({ error: "领取凭证无效" }, { status: 403 });
}
const placeholders = distributionIds.map(() => "?").join(", ");
const selected = await db
.prepare(
`SELECT id, publish_url, delegation_bundle_id
FROM distributions
WHERE claim_id = ? AND id IN (${placeholders})`,
)
.bind(claimRow.id, ...distributionIds)
.all<{
id: string;
publish_url: string | null;
delegation_bundle_id: string | null;
}>();
if (selected.results.length !== distributionIds.length) {
return Response.json(
{ error: "部分笔记不属于当前领取批次,请刷新后重试" },
{ status: 403 },
);
}
if (selected.results.some((item) => item.publish_url)) {
return Response.json(
{ error: "已发布的笔记不能再次转派" },
{ status: 409 },
);
}
if (selected.results.some((item) => item.delegation_bundle_id)) {
return Response.json(
{ error: "部分笔记已经转派,请刷新后重新选择" },
{ status: 409 },
);
}
const bundleId = uid("delegate");
const shareToken = crypto.randomUUID().replaceAll("-", "");
const statements: D1PreparedStatement[] = [
db
.prepare(
`INSERT INTO delegation_bundles
(id, task_id, claim_id, partner_id, label, share_token, quantity)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
bundleId,
task.id,
claimRow.id,
claimRow.partner_id,
label,
shareToken,
distributionIds.length,
),
];
for (const distributionId of distributionIds) {
statements.push(
db
.prepare(
`UPDATE distributions
SET delegation_bundle_id = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
AND claim_id = ?
AND publish_url IS NULL
AND delegation_bundle_id IS NULL`,
)
.bind(bundleId, distributionId, claimRow.id),
);
}
await db.batch(statements);
const bound = await db
.prepare(
`SELECT COUNT(*) AS count
FROM distributions
WHERE delegation_bundle_id = ?`,
)
.bind(bundleId)
.first<{ count: number }>();
if ((bound?.count ?? 0) !== distributionIds.length) {
await db.batch([
db
.prepare(
`UPDATE distributions
SET delegation_bundle_id = NULL, updated_at = CURRENT_TIMESTAMP
WHERE delegation_bundle_id = ?`,
)
.bind(bundleId),
db
.prepare("DELETE FROM delegation_bundles WHERE id = ?")
.bind(bundleId),
]);
return Response.json(
{ error: "部分笔记刚刚已被转派,请刷新后重新选择" },
{ status: 409 },
);
}
return Response.json({
delegation: {
id: bundleId,
label,
shareToken,
quantity: distributionIds.length,
},
});
}
if (body.action === "revoke_delegation") {
const claimToken = textValue(body.claimToken, 80);
const bundleId = textValue(body.delegationBundleId, 80);
const bundle = await db
.prepare(
`SELECT b.id
FROM delegation_bundles b
JOIN claims c ON c.id = b.claim_id
WHERE b.id = ?
AND b.task_id = ?
AND b.status = 'active'
AND c.claim_token = ?`,
)
.bind(bundleId, task.id, claimToken)
.first<{ id: string }>();
if (!bundle) {
return Response.json(
{ error: "没有找到可撤销的转派记录" },
{ status: 404 },
);
}
const published = await db
.prepare(
`SELECT COUNT(*) AS count
FROM distributions
WHERE delegation_bundle_id = ?
AND publish_url IS NOT NULL
AND publish_url != ''`,
)
.bind(bundle.id)
.first<{ count: number }>();
if ((published?.count ?? 0) > 0) {
return Response.json(
{ error: "该分享包已有笔记发布需保留链接继续完成第7天数据回收" },
{ status: 409 },
);
}
await db.batch([
db
.prepare(
`UPDATE distributions
SET delegation_bundle_id = NULL, updated_at = CURRENT_TIMESTAMP
WHERE delegation_bundle_id = ?`,
)
.bind(bundle.id),
db
.prepare(
`UPDATE delegation_bundles
SET status = 'revoked',
revoked_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(bundle.id),
]);
return Response.json({ revoked: true });
}
if (body.action === "submit") {
const claimToken = textValue(body.claimToken, 80);
const distributionId = textValue(body.distributionId, 80);
const accountNickname = textValue(body.accountNickname, 80);
const publishInput = textValue(body.publishUrl, 5000);
if (!accountNickname || !publishInput) {
return Response.json(
{ error: "请填写发布账号昵称和发布链接" },
{ status: 400 },
);
}
const publishUrl = extractXhsPublishUrl(publishInput);
if (!publishUrl) {
return Response.json(
{ error: "请粘贴包含小红书长链或短链的分享内容" },
{ status: 400 },
);
}
const account = accountFromPublishLink(publishUrl, accountNickname);
if (!account) {
return Response.json({ error: "发布链接格式不正确" }, { status: 400 });
}
const assignment = await findAccessibleAssignment(
task.id,
distributionId,
claimToken,
delegationToken,
);
if (!assignment) {
return Response.json({ error: "笔记与领取凭证不匹配" }, { status: 403 });
}
if (!assignment.publish_screenshot_key) {
return Response.json({ error: "请先上传发布截图" }, { status: 400 });
}
const reuseExistingAccount =
assignment.publish_url === publishUrl && assignment.account_id;
const accountId =
reuseExistingAccount ||
`account-${hashText(`${account.platform}:${account.platformUid}`)}`;
const statements: D1PreparedStatement[] = [];
if (!reuseExistingAccount) {
statements.push(
db
.prepare(
`INSERT INTO accounts
(id, platform, platform_uid, nickname, profile_url, post_count)
VALUES (?, ?, ?, ?, ?, 1)
ON CONFLICT(platform, platform_uid) DO UPDATE SET
nickname = excluded.nickname,
profile_url = excluded.profile_url,
post_count = accounts.post_count + ?,
last_seen_at = CURRENT_TIMESTAMP`,
)
.bind(
accountId,
account.platform,
account.platformUid,
account.nickname,
account.profileUrl,
assignment.publish_url ? 0 : 1,
),
);
}
statements.push(
db
.prepare(
`UPDATE distributions SET
account_id = ?,
publish_url = ?,
publish_time = CURRENT_TIMESTAMP,
status = 'published',
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(accountId, publishUrl, assignment.id),
);
if (!assignment.publish_url) {
statements.push(
db
.prepare(
`UPDATE partners SET completed_total = completed_total + 1
WHERE id = ?`,
)
.bind(assignment.partner_id),
);
}
await db.batch(statements);
const collectionSchedule = await db
.prepare(
`SELECT collection_start_date, collection_days
FROM tasks WHERE id = ?`,
)
.bind(task.id)
.first<{
collection_start_date: string | null;
collection_days: string;
}>();
if (
collectionSchedule?.collection_start_date &&
collectionSchedule.collection_days !== "[]"
) {
let collectionDays: number[] = [];
try {
const parsed = JSON.parse(collectionSchedule.collection_days);
if (Array.isArray(parsed)) collectionDays = parsed.map(Number);
} catch {
collectionDays = [];
}
if (collectionDays.length > 0) {
await createCollectionRunTasks(
db,
task.id,
collectionSchedule.collection_start_date,
collectionDays,
);
}
}
if (account.platform === "小红书") {
const enrichment = enrichDistributionAccount(
db,
assignment.id,
publishUrl,
accountNickname,
resolveCollectionMcpConfig(
env as unknown as CollectionMcpBindings,
),
).catch(() => undefined);
const executionContext = getRequestExecutionContext();
if (executionContext) {
executionContext.waitUntil(enrichment);
} else {
await enrichment;
}
}
return Response.json({ ok: true });
}
if (body.action === "submit_creator_metrics") {
const claimToken = textValue(body.claimToken, 80);
const distributionId = textValue(body.distributionId, 80);
const exposure = creatorMetricValue(body.exposure);
const views = creatorMetricValue(body.views);
if (exposure === null || views === null) {
return Response.json(
{ error: "请填写正确的曝光量和阅读量" },
{ status: 400 },
);
}
const assignment = await findAccessibleAssignment(
task.id,
distributionId,
claimToken,
delegationToken,
);
if (!assignment) {
return Response.json({ error: "笔记与领取凭证不匹配" }, { status: 403 });
}
if (!assignment.publish_url) {
return Response.json(
{ error: "请先回填这篇笔记的发布信息" },
{ status: 409 },
);
}
if (!assignment.screenshot_key) {
return Response.json(
{ error: "请先上传创作者中心截图" },
{ status: 400 },
);
}
await db
.prepare(
`UPDATE distributions SET
exposure = ?,
views = ?,
ocr_status = 'manual',
status = CASE WHEN d7_likes IS NOT NULL THEN 'complete' ELSE status END,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(exposure, views, assignment.id)
.run();
return Response.json({ submitted: true });
}
return Response.json({ error: "不支持的操作" }, { status: 400 });
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : "操作失败" },
{ status: 500 },
);
}
}
export async function GET(request: Request) {
const response = await handleGet(request);
response.headers.set("Cache-Control", "private, no-store");
response.headers.set("Referrer-Policy", "no-referrer");
return withPartnerCors(request, response);
}
export async function POST(request: Request) {
const response = await handlePost(request);
response.headers.set("Cache-Control", "private, no-store");
return withPartnerCors(request, response);
}
export async function OPTIONS(request: Request) {
return partnerOptions(request);
}

51
app/api/upload/route.ts Normal file
View File

@@ -0,0 +1,51 @@
import {
ensureSchema,
getDashboardData,
getRawDb,
getUploadBucket,
uid,
} from "../../../lib/mvp-db";
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
export const runtime = "edge";
export async function POST(request: Request) {
if (!isAdminRequest(request)) return adminForbidden();
try {
await ensureSchema();
const form = await request.formData();
const distributionId = String(form.get("distributionId") ?? "");
const file = form.get("file");
if (!distributionId || !(file instanceof File) || file.size === 0) {
return Response.json({ error: "请选择需要上传的截图" }, { status: 400 });
}
if (!file.type.startsWith("image/")) {
return Response.json({ error: "只支持图片文件" }, { status: 400 });
}
const extension = file.name.split(".").at(-1)?.replace(/[^a-zA-Z0-9]/g, "") ||
"jpg";
const key = `creator-center/${distributionId}/${uid("shot")}.${extension}`;
await getUploadBucket().put(key, await file.arrayBuffer(), {
httpMetadata: { contentType: file.type },
});
const db = getRawDb();
await db
.prepare(
`UPDATE distributions SET
screenshot_key = ?,
exposure = NULL,
views = NULL,
ocr_status = 'failed',
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(key, distributionId)
.run();
return Response.json(await getDashboardData());
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : "上传失败" },
{ status: 500 },
);
}
}

86
app/chatgpt-auth.ts Normal file
View File

@@ -0,0 +1,86 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export type ChatGPTUser = {
displayName: string;
email: string;
fullName: string | null;
};
const USER_EMAIL_HEADER = "oai-authenticated-user-email";
const USER_FULL_NAME_HEADER = "oai-authenticated-user-full-name";
const USER_FULL_NAME_ENCODING_HEADER =
"oai-authenticated-user-full-name-encoding";
const PERCENT_ENCODED_UTF8 = "percent-encoded-utf-8";
const SIGN_IN_PATH = "/signin-with-chatgpt";
const SIGN_OUT_PATH = "/signout-with-chatgpt";
const CALLBACK_PATH = "/callback";
export async function getChatGPTUser(): Promise<ChatGPTUser | null> {
const requestHeaders = await headers();
const email = requestHeaders.get(USER_EMAIL_HEADER);
if (!email) return null;
const encodedFullName = requestHeaders.get(USER_FULL_NAME_HEADER);
const fullName =
encodedFullName &&
requestHeaders.get(USER_FULL_NAME_ENCODING_HEADER) === PERCENT_ENCODED_UTF8
? safeDecodeURIComponent(encodedFullName)
: null;
return {
displayName: fullName ?? email,
email,
fullName,
};
}
export async function requireChatGPTUser(
returnTo: string,
): Promise<ChatGPTUser> {
const user = await getChatGPTUser();
if (user) return user;
redirect(chatGPTSignInPath(returnTo));
}
export function chatGPTSignInPath(returnTo: string): string {
const safeReturnTo = safeRelativeReturnPath(returnTo);
return `${SIGN_IN_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
}
export function chatGPTSignOutPath(returnTo = "/"): string {
const safeReturnTo = safeRelativeReturnPath(returnTo);
return `${SIGN_OUT_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
}
function safeRelativeReturnPath(value: string): string {
if (!value.startsWith("/") || value.startsWith("//")) return "/";
let url: URL;
try {
url = new URL(value, "https://app.local");
} catch {
return "/";
}
if (url.origin !== "https://app.local") return "/";
if (isReservedAuthPath(url.pathname)) return "/";
return `${url.pathname}${url.search}${url.hash}`;
}
function isReservedAuthPath(pathname: string): boolean {
return (
pathname === SIGN_IN_PATH ||
pathname === SIGN_OUT_PATH ||
pathname === CALLBACK_PATH
);
}
function safeDecodeURIComponent(value: string): string | null {
try {
return decodeURIComponent(value);
} catch {
return null;
}
}

3008
app/globals.css Normal file

File diff suppressed because it is too large Load Diff

68
app/layout.tsx Normal file
View File

@@ -0,0 +1,68 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { headers } from "next/headers";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export async function generateMetadata(): Promise<Metadata> {
const requestHeaders = await headers();
const host = requestHeaders.get("x-forwarded-host") || requestHeaders.get("host");
const protocol =
requestHeaders.get("x-forwarded-proto") ||
(host?.startsWith("localhost") ? "http" : "https");
const metadataBase = host
? new URL(`${protocol}://${host}`)
: new URL("https://koc-loop.example.com");
const description =
"面向KOC运营团队的轻量资源管理、内容分发与数据回收平台。";
return {
metadataBase,
title: "KOC LOOP内容分发闭环",
description,
icons: {
icon: "/favicon.svg",
shortcut: "/favicon.svg",
},
openGraph: {
title: "KOC LOOP内容分发闭环",
description,
type: "website",
images: [
{
url: new URL("/og.png", metadataBase).toString(),
width: 1200,
height: 630,
alt: "KOC LOOP 资源管理、内容分发与数据回收",
},
],
},
twitter: {
card: "summary_large_image",
title: "KOC LOOP内容分发闭环",
description,
images: [new URL("/og.png", metadataBase).toString()],
},
};
}
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="zh-CN">
<body className={`${geistSans.variable} ${geistMono.variable}`}>
{children}
</body>
</html>
);
}

24
app/page.tsx Normal file
View File

@@ -0,0 +1,24 @@
import { chatGPTSignOutPath, requireChatGPTUser } from "./chatgpt-auth";
import AdminApp from "./admin-app";
import { isAdminEmail } from "../lib/admin-auth";
export const dynamic = "force-dynamic";
export default async function Page() {
const user = await requireChatGPTUser("/");
if (!isAdminEmail(user.email)) {
return (
<main className="admin-access-denied">
<section>
<p>KOC LOOP</p>
<h1></h1>
<span></span>
<a href={chatGPTSignOutPath("/")}></a>
</section>
</main>
);
}
return <AdminApp />;
}

View File

@@ -0,0 +1,45 @@
import { access, cp, mkdir, rm } from "node:fs/promises";
import { resolve } from "node:path";
import type { Plugin } from "vite";
async function exists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return false;
}
throw error;
}
}
// Packages Sites metadata and migrations after Vite finishes compiling.
export function sites(): Plugin {
let root = process.cwd();
return {
name: "sites",
apply: "build",
configResolved(config) {
root = config.root;
},
async closeBundle() {
const outputDirectory = resolve(root, "dist", ".openai");
const hostingConfig = resolve(root, ".openai", "hosting.json");
const drizzleSource = resolve(root, "drizzle");
await rm(outputDirectory, { recursive: true, force: true });
await mkdir(outputDirectory, { recursive: true });
if (await exists(hostingConfig)) {
await cp(hostingConfig, resolve(outputDirectory, "hosting.json"));
}
if (await exists(drizzleSource)) {
await cp(drizzleSource, resolve(outputDirectory, "drizzle"), {
recursive: true,
});
}
},
};
}

13
db/index.ts Normal file
View File

@@ -0,0 +1,13 @@
import { env } from "cloudflare:workers";
import { drizzle } from "drizzle-orm/d1";
import * as schema from "./schema";
export function getDb() {
if (!env.DB) {
throw new Error(
"Cloudflare D1 binding `DB` is unavailable. Set the `d1` field in .openai/hosting.json to `DB` or let your control plane inject the real binding values before using the database."
);
}
return drizzle(env.DB, { schema });
}

188
db/schema.ts Normal file
View File

@@ -0,0 +1,188 @@
import { sql } from "drizzle-orm";
import {
index,
integer,
sqliteTable,
text,
uniqueIndex,
} from "drizzle-orm/sqlite-core";
export const partners = sqliteTable("partners", {
id: text("id").primaryKey(),
name: text("name").notNull(),
wecomName: text("wecom_name").notNull(),
owner: text("owner").notNull().default("运营组"),
claimedTotal: integer("claimed_total").notNull().default(0),
completedTotal: integer("completed_total").notNull().default(0),
createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
});
export const tasks = sqliteTable(
"tasks",
{
id: text("id").primaryKey(),
name: text("name").notNull(),
brand: text("brand").notNull(),
quantity: integer("quantity").notNull(),
claimedQuantity: integer("claimed_quantity").notNull().default(0),
dueAt: text("due_at").notNull(),
status: text("status").notNull().default("active"),
sourceUrl: text("source_url").notNull().default(""),
sourceSheetId: text("source_sheet_id").notNull().default(""),
sourceSheetName: text("source_sheet_name").notNull().default(""),
sourceSyncedAt: text("source_synced_at"),
shareToken: text("share_token"),
collectionStartDate: text("collection_start_date"),
collectionDays: text("collection_days").notNull().default("[]"),
collectionScheduleUpdatedAt: text("collection_schedule_updated_at"),
createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
},
(table) => [uniqueIndex("tasks_share_token_idx").on(table.shareToken)],
);
export const contents = sqliteTable("contents", {
id: text("id").primaryKey(),
taskId: text("task_id").notNull(),
title: text("title").notNull(),
body: text("body").notNull().default(""),
imageAssets: text("image_assets").notNull().default("[]"),
status: text("status").notNull().default("available"),
source: text("source").notNull().default("飞书内容表"),
sourceRow: integer("source_row"),
createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
});
export const accounts = sqliteTable(
"accounts",
{
id: text("id").primaryKey(),
platform: text("platform").notNull().default("小红书"),
platformUid: text("platform_uid").notNull(),
publicAccountId: text("public_account_id").notNull().default(""),
nickname: text("nickname").notNull(),
profileUrl: text("profile_url").notNull().default(""),
ipLocation: text("ip_location").notNull().default("待识别"),
followers: integer("followers").notNull().default(0),
postCount: integer("post_count").notNull().default(0),
avgViews: integer("avg_views").notNull().default(0),
firstSeenAt: text("first_seen_at").notNull().default(sql`CURRENT_TIMESTAMP`),
lastSeenAt: text("last_seen_at").notNull().default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("accounts_platform_uid_idx").on(
table.platform,
table.platformUid,
),
],
);
export const claims = sqliteTable(
"claims",
{
id: text("id").primaryKey(),
taskId: text("task_id").notNull(),
partnerId: text("partner_id").notNull(),
claimantName: text("claimant_name").notNull(),
claimToken: text("claim_token").notNull(),
quantity: integer("quantity").notNull(),
createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("claims_claim_token_idx").on(table.claimToken),
index("claims_task_partner_created_idx").on(
table.taskId,
table.partnerId,
table.createdAt,
),
],
);
export const delegationBundles = sqliteTable(
"delegation_bundles",
{
id: text("id").primaryKey(),
taskId: text("task_id").notNull(),
claimId: text("claim_id").notNull(),
partnerId: text("partner_id").notNull(),
label: text("label").notNull(),
shareToken: text("share_token").notNull(),
quantity: integer("quantity").notNull(),
status: text("status").notNull().default("active"),
createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at").notNull().default(sql`CURRENT_TIMESTAMP`),
revokedAt: text("revoked_at"),
},
(table) => [
uniqueIndex("delegation_bundles_share_token_idx").on(table.shareToken),
index("delegation_bundles_claim_created_idx").on(
table.claimId,
table.createdAt,
),
],
);
export const distributions = sqliteTable("distributions", {
id: text("id").primaryKey(),
taskId: text("task_id").notNull(),
contentId: text("content_id").notNull(),
partnerId: text("partner_id").notNull(),
claimId: text("claim_id"),
delegationBundleId: text("delegation_bundle_id"),
accountId: text("account_id"),
publishUrl: text("publish_url"),
publishTime: text("publish_time"),
publishScreenshotKey: text("publish_screenshot_key"),
status: text("status").notNull().default("claimed"),
claimedAt: text("claimed_at").notNull().default(sql`CURRENT_TIMESTAMP`),
screenshotKey: text("screenshot_key"),
ocrStatus: text("ocr_status").notNull().default("none"),
exposure: integer("exposure"),
views: integer("views"),
d2Likes: integer("d2_likes"),
d2Comments: integer("d2_comments"),
d2Collects: integer("d2_collects"),
d5Likes: integer("d5_likes"),
d5Comments: integer("d5_comments"),
d5Collects: integer("d5_collects"),
d7Likes: integer("d7_likes"),
d7Comments: integer("d7_comments"),
d7Collects: integer("d7_collects"),
latestLikes: integer("latest_likes"),
latestComments: integer("latest_comments"),
latestCollects: integer("latest_collects"),
collectionStatus: text("collection_status").notNull().default("pending"),
collectionStatusDescription: text("collection_status_description"),
collectionUpdatedAt: text("collection_updated_at"),
lastCollectionDay: integer("last_collection_day"),
updatedAt: text("updated_at").notNull().default(sql`CURRENT_TIMESTAMP`),
});
export const collectionRuns = sqliteTable(
"collection_runs",
{
id: text("id").primaryKey(),
taskId: text("task_id").notNull(),
distributionId: text("distribution_id").notNull(),
scheduledDate: text("scheduled_date").notNull(),
scheduleDay: integer("schedule_day"),
scheduledAt: text("scheduled_at").notNull(),
status: text("status").notNull().default("pending"),
likes: integer("likes"),
comments: integer("comments"),
collects: integer("collects"),
statusDescription: text("status_description"),
startedAt: text("started_at"),
completedAt: text("completed_at"),
createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("collection_runs_distribution_date_idx").on(
table.distributionId,
table.scheduledDate,
),
index("collection_runs_task_date_idx").on(
table.taskId,
table.scheduledDate,
),
],
);

7
drizzle.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
out: "./drizzle",
schema: "./db/schema.ts",
dialect: "sqlite",
});

View File

@@ -0,0 +1,71 @@
CREATE TABLE `accounts` (
`id` text PRIMARY KEY NOT NULL,
`platform` text DEFAULT '小红书' NOT NULL,
`platform_uid` text NOT NULL,
`nickname` text NOT NULL,
`profile_url` text DEFAULT '' NOT NULL,
`ip_location` text DEFAULT '待识别' NOT NULL,
`followers` integer DEFAULT 0 NOT NULL,
`post_count` integer DEFAULT 0 NOT NULL,
`avg_views` integer DEFAULT 0 NOT NULL,
`first_seen_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
`last_seen_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `accounts_platform_uid_idx` ON `accounts` (`platform`,`platform_uid`);--> statement-breakpoint
CREATE TABLE `contents` (
`id` text PRIMARY KEY NOT NULL,
`task_id` text NOT NULL,
`title` text NOT NULL,
`body` text DEFAULT '' NOT NULL,
`status` text DEFAULT 'available' NOT NULL,
`source` text DEFAULT '飞书内容表' NOT NULL,
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
CREATE TABLE `distributions` (
`id` text PRIMARY KEY NOT NULL,
`task_id` text NOT NULL,
`content_id` text NOT NULL,
`partner_id` text NOT NULL,
`account_id` text,
`publish_url` text,
`publish_time` text,
`status` text DEFAULT 'claimed' NOT NULL,
`claimed_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
`screenshot_key` text,
`ocr_status` text DEFAULT 'none' NOT NULL,
`exposure` integer,
`views` integer,
`d2_likes` integer,
`d2_comments` integer,
`d2_collects` integer,
`d5_likes` integer,
`d5_comments` integer,
`d5_collects` integer,
`d7_likes` integer,
`d7_comments` integer,
`d7_collects` integer,
`updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
CREATE TABLE `partners` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`wecom_name` text NOT NULL,
`owner` text DEFAULT '运营组' NOT NULL,
`claimed_total` integer DEFAULT 0 NOT NULL,
`completed_total` integer DEFAULT 0 NOT NULL,
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
CREATE TABLE `tasks` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`brand` text NOT NULL,
`quantity` integer NOT NULL,
`claimed_quantity` integer DEFAULT 0 NOT NULL,
`due_at` text NOT NULL,
`status` text DEFAULT 'active' NOT NULL,
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL
);

View File

@@ -0,0 +1,5 @@
ALTER TABLE `contents` ADD `source_row` integer;--> statement-breakpoint
ALTER TABLE `tasks` ADD `source_url` text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE `tasks` ADD `source_sheet_id` text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE `tasks` ADD `source_sheet_name` text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE `tasks` ADD `source_synced_at` text;

View File

@@ -0,0 +1,15 @@
CREATE TABLE `claims` (
`id` text PRIMARY KEY NOT NULL,
`task_id` text NOT NULL,
`partner_id` text NOT NULL,
`claimant_name` text NOT NULL,
`claim_token` text NOT NULL,
`quantity` integer NOT NULL,
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `claims_claim_token_idx` ON `claims` (`claim_token`);--> statement-breakpoint
ALTER TABLE `distributions` ADD `claim_id` text;--> statement-breakpoint
ALTER TABLE `distributions` ADD `publish_screenshot_key` text;--> statement-breakpoint
ALTER TABLE `tasks` ADD `share_token` text;--> statement-breakpoint
CREATE UNIQUE INDEX `tasks_share_token_idx` ON `tasks` (`share_token`);

View File

@@ -0,0 +1,2 @@
ALTER TABLE `contents` ADD `image_assets` text DEFAULT '[]' NOT NULL;--> statement-breakpoint
CREATE INDEX `claims_task_partner_created_idx` ON `claims` (`task_id`,`partner_id`,`created_at`);

View File

@@ -0,0 +1,29 @@
CREATE TABLE `collection_runs` (
`id` text PRIMARY KEY NOT NULL,
`task_id` text NOT NULL,
`distribution_id` text NOT NULL,
`scheduled_date` text NOT NULL,
`schedule_day` integer,
`scheduled_at` text NOT NULL,
`status` text DEFAULT 'pending' NOT NULL,
`likes` integer,
`comments` integer,
`collects` integer,
`status_description` text,
`started_at` text,
`completed_at` text,
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `collection_runs_distribution_date_idx` ON `collection_runs` (`distribution_id`,`scheduled_date`);--> statement-breakpoint
CREATE INDEX `collection_runs_task_date_idx` ON `collection_runs` (`task_id`,`scheduled_date`);--> statement-breakpoint
ALTER TABLE `distributions` ADD `latest_likes` integer;--> statement-breakpoint
ALTER TABLE `distributions` ADD `latest_comments` integer;--> statement-breakpoint
ALTER TABLE `distributions` ADD `latest_collects` integer;--> statement-breakpoint
ALTER TABLE `distributions` ADD `collection_status` text DEFAULT 'pending' NOT NULL;--> statement-breakpoint
ALTER TABLE `distributions` ADD `collection_status_description` text;--> statement-breakpoint
ALTER TABLE `distributions` ADD `collection_updated_at` text;--> statement-breakpoint
ALTER TABLE `distributions` ADD `last_collection_day` integer;--> statement-breakpoint
ALTER TABLE `tasks` ADD `collection_start_date` text;--> statement-breakpoint
ALTER TABLE `tasks` ADD `collection_days` text DEFAULT '[]' NOT NULL;--> statement-breakpoint
ALTER TABLE `tasks` ADD `collection_schedule_updated_at` text;

View File

@@ -0,0 +1 @@
ALTER TABLE `accounts` ADD `public_account_id` text DEFAULT '' NOT NULL;

View File

@@ -0,0 +1,17 @@
CREATE TABLE `delegation_bundles` (
`id` text PRIMARY KEY NOT NULL,
`task_id` text NOT NULL,
`claim_id` text NOT NULL,
`partner_id` text NOT NULL,
`label` text NOT NULL,
`share_token` text NOT NULL,
`quantity` integer NOT NULL,
`status` text DEFAULT 'active' NOT NULL,
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
`updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
`revoked_at` text
);
--> statement-breakpoint
CREATE UNIQUE INDEX `delegation_bundles_share_token_idx` ON `delegation_bundles` (`share_token`);--> statement-breakpoint
CREATE INDEX `delegation_bundles_claim_created_idx` ON `delegation_bundles` (`claim_id`,`created_at`);--> statement-breakpoint
ALTER TABLE `distributions` ADD `delegation_bundle_id` text;

View File

@@ -0,0 +1,492 @@
{
"version": "6",
"dialect": "sqlite",
"id": "32f31f92-71f7-4ee9-837f-de02bfa9b64e",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"accounts": {
"name": "accounts",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"platform": {
"name": "platform",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'小红书'"
},
"platform_uid": {
"name": "platform_uid",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"nickname": {
"name": "nickname",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"profile_url": {
"name": "profile_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"ip_location": {
"name": "ip_location",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'待识别'"
},
"followers": {
"name": "followers",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"post_count": {
"name": "post_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"avg_views": {
"name": "avg_views",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"first_seen_at": {
"name": "first_seen_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"last_seen_at": {
"name": "last_seen_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"accounts_platform_uid_idx": {
"name": "accounts_platform_uid_idx",
"columns": [
"platform",
"platform_uid"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"contents": {
"name": "contents",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"body": {
"name": "body",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'available'"
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'飞书内容表'"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"distributions": {
"name": "distributions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content_id": {
"name": "content_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"partner_id": {
"name": "partner_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"publish_url": {
"name": "publish_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"publish_time": {
"name": "publish_time",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'claimed'"
},
"claimed_at": {
"name": "claimed_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"screenshot_key": {
"name": "screenshot_key",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ocr_status": {
"name": "ocr_status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'none'"
},
"exposure": {
"name": "exposure",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"views": {
"name": "views",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d2_likes": {
"name": "d2_likes",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d2_comments": {
"name": "d2_comments",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d2_collects": {
"name": "d2_collects",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d5_likes": {
"name": "d5_likes",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d5_comments": {
"name": "d5_comments",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d5_collects": {
"name": "d5_collects",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d7_likes": {
"name": "d7_likes",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d7_comments": {
"name": "d7_comments",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d7_collects": {
"name": "d7_collects",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"partners": {
"name": "partners",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"wecom_name": {
"name": "wecom_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"owner": {
"name": "owner",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'运营组'"
},
"claimed_total": {
"name": "claimed_total",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"completed_total": {
"name": "completed_total",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tasks": {
"name": "tasks",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"brand": {
"name": "brand",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"quantity": {
"name": "quantity",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"claimed_quantity": {
"name": "claimed_quantity",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"due_at": {
"name": "due_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,530 @@
{
"version": "6",
"dialect": "sqlite",
"id": "30c1e381-2dbf-4c90-8e57-5453bcbd4433",
"prevId": "32f31f92-71f7-4ee9-837f-de02bfa9b64e",
"tables": {
"accounts": {
"name": "accounts",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"platform": {
"name": "platform",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'小红书'"
},
"platform_uid": {
"name": "platform_uid",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"nickname": {
"name": "nickname",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"profile_url": {
"name": "profile_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"ip_location": {
"name": "ip_location",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'待识别'"
},
"followers": {
"name": "followers",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"post_count": {
"name": "post_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"avg_views": {
"name": "avg_views",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"first_seen_at": {
"name": "first_seen_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"last_seen_at": {
"name": "last_seen_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"accounts_platform_uid_idx": {
"name": "accounts_platform_uid_idx",
"columns": [
"platform",
"platform_uid"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"contents": {
"name": "contents",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"body": {
"name": "body",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'available'"
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'飞书内容表'"
},
"source_row": {
"name": "source_row",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"distributions": {
"name": "distributions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content_id": {
"name": "content_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"partner_id": {
"name": "partner_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"publish_url": {
"name": "publish_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"publish_time": {
"name": "publish_time",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'claimed'"
},
"claimed_at": {
"name": "claimed_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"screenshot_key": {
"name": "screenshot_key",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ocr_status": {
"name": "ocr_status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'none'"
},
"exposure": {
"name": "exposure",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"views": {
"name": "views",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d2_likes": {
"name": "d2_likes",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d2_comments": {
"name": "d2_comments",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d2_collects": {
"name": "d2_collects",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d5_likes": {
"name": "d5_likes",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d5_comments": {
"name": "d5_comments",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d5_collects": {
"name": "d5_collects",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d7_likes": {
"name": "d7_likes",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d7_comments": {
"name": "d7_comments",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d7_collects": {
"name": "d7_collects",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"partners": {
"name": "partners",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"wecom_name": {
"name": "wecom_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"owner": {
"name": "owner",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'运营组'"
},
"claimed_total": {
"name": "claimed_total",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"completed_total": {
"name": "completed_total",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tasks": {
"name": "tasks",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"brand": {
"name": "brand",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"quantity": {
"name": "quantity",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"claimed_quantity": {
"name": "claimed_quantity",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"due_at": {
"name": "due_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"source_url": {
"name": "source_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"source_sheet_id": {
"name": "source_sheet_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"source_sheet_name": {
"name": "source_sheet_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"source_synced_at": {
"name": "source_synced_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,627 @@
{
"version": "6",
"dialect": "sqlite",
"id": "d8b5dda6-de54-4b63-9bdf-a26c09723eed",
"prevId": "30c1e381-2dbf-4c90-8e57-5453bcbd4433",
"tables": {
"accounts": {
"name": "accounts",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"platform": {
"name": "platform",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'小红书'"
},
"platform_uid": {
"name": "platform_uid",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"nickname": {
"name": "nickname",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"profile_url": {
"name": "profile_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"ip_location": {
"name": "ip_location",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'待识别'"
},
"followers": {
"name": "followers",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"post_count": {
"name": "post_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"avg_views": {
"name": "avg_views",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"first_seen_at": {
"name": "first_seen_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"last_seen_at": {
"name": "last_seen_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"accounts_platform_uid_idx": {
"name": "accounts_platform_uid_idx",
"columns": [
"platform",
"platform_uid"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"claims": {
"name": "claims",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"partner_id": {
"name": "partner_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"claimant_name": {
"name": "claimant_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"claim_token": {
"name": "claim_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"quantity": {
"name": "quantity",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"claims_claim_token_idx": {
"name": "claims_claim_token_idx",
"columns": [
"claim_token"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"contents": {
"name": "contents",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"body": {
"name": "body",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'available'"
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'飞书内容表'"
},
"source_row": {
"name": "source_row",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"distributions": {
"name": "distributions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content_id": {
"name": "content_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"partner_id": {
"name": "partner_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"claim_id": {
"name": "claim_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"publish_url": {
"name": "publish_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"publish_time": {
"name": "publish_time",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"publish_screenshot_key": {
"name": "publish_screenshot_key",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'claimed'"
},
"claimed_at": {
"name": "claimed_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"screenshot_key": {
"name": "screenshot_key",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ocr_status": {
"name": "ocr_status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'none'"
},
"exposure": {
"name": "exposure",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"views": {
"name": "views",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d2_likes": {
"name": "d2_likes",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d2_comments": {
"name": "d2_comments",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d2_collects": {
"name": "d2_collects",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d5_likes": {
"name": "d5_likes",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d5_comments": {
"name": "d5_comments",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d5_collects": {
"name": "d5_collects",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d7_likes": {
"name": "d7_likes",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d7_comments": {
"name": "d7_comments",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d7_collects": {
"name": "d7_collects",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"partners": {
"name": "partners",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"wecom_name": {
"name": "wecom_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"owner": {
"name": "owner",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'运营组'"
},
"claimed_total": {
"name": "claimed_total",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"completed_total": {
"name": "completed_total",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tasks": {
"name": "tasks",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"brand": {
"name": "brand",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"quantity": {
"name": "quantity",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"claimed_quantity": {
"name": "claimed_quantity",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"due_at": {
"name": "due_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"source_url": {
"name": "source_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"source_sheet_id": {
"name": "source_sheet_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"source_sheet_name": {
"name": "source_sheet_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"source_synced_at": {
"name": "source_synced_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"share_token": {
"name": "share_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"tasks_share_token_idx": {
"name": "tasks_share_token_idx",
"columns": [
"share_token"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,644 @@
{
"version": "6",
"dialect": "sqlite",
"id": "952052cf-d574-4c9e-abe3-36d27be39564",
"prevId": "d8b5dda6-de54-4b63-9bdf-a26c09723eed",
"tables": {
"accounts": {
"name": "accounts",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"platform": {
"name": "platform",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'小红书'"
},
"platform_uid": {
"name": "platform_uid",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"nickname": {
"name": "nickname",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"profile_url": {
"name": "profile_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"ip_location": {
"name": "ip_location",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'待识别'"
},
"followers": {
"name": "followers",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"post_count": {
"name": "post_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"avg_views": {
"name": "avg_views",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"first_seen_at": {
"name": "first_seen_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"last_seen_at": {
"name": "last_seen_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"accounts_platform_uid_idx": {
"name": "accounts_platform_uid_idx",
"columns": [
"platform",
"platform_uid"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"claims": {
"name": "claims",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"partner_id": {
"name": "partner_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"claimant_name": {
"name": "claimant_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"claim_token": {
"name": "claim_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"quantity": {
"name": "quantity",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"claims_claim_token_idx": {
"name": "claims_claim_token_idx",
"columns": [
"claim_token"
],
"isUnique": true
},
"claims_task_partner_created_idx": {
"name": "claims_task_partner_created_idx",
"columns": [
"task_id",
"partner_id",
"created_at"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"contents": {
"name": "contents",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"body": {
"name": "body",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"image_assets": {
"name": "image_assets",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'available'"
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'飞书内容表'"
},
"source_row": {
"name": "source_row",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"distributions": {
"name": "distributions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content_id": {
"name": "content_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"partner_id": {
"name": "partner_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"claim_id": {
"name": "claim_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"publish_url": {
"name": "publish_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"publish_time": {
"name": "publish_time",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"publish_screenshot_key": {
"name": "publish_screenshot_key",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'claimed'"
},
"claimed_at": {
"name": "claimed_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"screenshot_key": {
"name": "screenshot_key",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ocr_status": {
"name": "ocr_status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'none'"
},
"exposure": {
"name": "exposure",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"views": {
"name": "views",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d2_likes": {
"name": "d2_likes",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d2_comments": {
"name": "d2_comments",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d2_collects": {
"name": "d2_collects",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d5_likes": {
"name": "d5_likes",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d5_comments": {
"name": "d5_comments",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d5_collects": {
"name": "d5_collects",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d7_likes": {
"name": "d7_likes",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d7_comments": {
"name": "d7_comments",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d7_collects": {
"name": "d7_collects",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"partners": {
"name": "partners",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"wecom_name": {
"name": "wecom_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"owner": {
"name": "owner",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'运营组'"
},
"claimed_total": {
"name": "claimed_total",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"completed_total": {
"name": "completed_total",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tasks": {
"name": "tasks",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"brand": {
"name": "brand",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"quantity": {
"name": "quantity",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"claimed_quantity": {
"name": "claimed_quantity",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"due_at": {
"name": "due_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"source_url": {
"name": "source_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"source_sheet_id": {
"name": "source_sheet_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"source_sheet_name": {
"name": "source_sheet_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"source_synced_at": {
"name": "source_synced_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"share_token": {
"name": "share_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"tasks_share_token_idx": {
"name": "tasks_share_token_idx",
"columns": [
"share_token"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,843 @@
{
"version": "6",
"dialect": "sqlite",
"id": "54454371-d917-4b00-b1b3-1cd5947f801b",
"prevId": "952052cf-d574-4c9e-abe3-36d27be39564",
"tables": {
"accounts": {
"name": "accounts",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"platform": {
"name": "platform",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'小红书'"
},
"platform_uid": {
"name": "platform_uid",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"nickname": {
"name": "nickname",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"profile_url": {
"name": "profile_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"ip_location": {
"name": "ip_location",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'待识别'"
},
"followers": {
"name": "followers",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"post_count": {
"name": "post_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"avg_views": {
"name": "avg_views",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"first_seen_at": {
"name": "first_seen_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"last_seen_at": {
"name": "last_seen_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"accounts_platform_uid_idx": {
"name": "accounts_platform_uid_idx",
"columns": [
"platform",
"platform_uid"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"claims": {
"name": "claims",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"partner_id": {
"name": "partner_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"claimant_name": {
"name": "claimant_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"claim_token": {
"name": "claim_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"quantity": {
"name": "quantity",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"claims_claim_token_idx": {
"name": "claims_claim_token_idx",
"columns": [
"claim_token"
],
"isUnique": true
},
"claims_task_partner_created_idx": {
"name": "claims_task_partner_created_idx",
"columns": [
"task_id",
"partner_id",
"created_at"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"collection_runs": {
"name": "collection_runs",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"distribution_id": {
"name": "distribution_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"scheduled_date": {
"name": "scheduled_date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"schedule_day": {
"name": "schedule_day",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"scheduled_at": {
"name": "scheduled_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"likes": {
"name": "likes",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"comments": {
"name": "comments",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"collects": {
"name": "collects",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status_description": {
"name": "status_description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"started_at": {
"name": "started_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"collection_runs_distribution_date_idx": {
"name": "collection_runs_distribution_date_idx",
"columns": [
"distribution_id",
"scheduled_date"
],
"isUnique": true
},
"collection_runs_task_date_idx": {
"name": "collection_runs_task_date_idx",
"columns": [
"task_id",
"scheduled_date"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"contents": {
"name": "contents",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"body": {
"name": "body",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"image_assets": {
"name": "image_assets",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'available'"
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'飞书内容表'"
},
"source_row": {
"name": "source_row",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"distributions": {
"name": "distributions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content_id": {
"name": "content_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"partner_id": {
"name": "partner_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"claim_id": {
"name": "claim_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"publish_url": {
"name": "publish_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"publish_time": {
"name": "publish_time",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"publish_screenshot_key": {
"name": "publish_screenshot_key",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'claimed'"
},
"claimed_at": {
"name": "claimed_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"screenshot_key": {
"name": "screenshot_key",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ocr_status": {
"name": "ocr_status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'none'"
},
"exposure": {
"name": "exposure",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"views": {
"name": "views",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d2_likes": {
"name": "d2_likes",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d2_comments": {
"name": "d2_comments",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d2_collects": {
"name": "d2_collects",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d5_likes": {
"name": "d5_likes",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d5_comments": {
"name": "d5_comments",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d5_collects": {
"name": "d5_collects",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d7_likes": {
"name": "d7_likes",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d7_comments": {
"name": "d7_comments",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d7_collects": {
"name": "d7_collects",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"latest_likes": {
"name": "latest_likes",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"latest_comments": {
"name": "latest_comments",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"latest_collects": {
"name": "latest_collects",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"collection_status": {
"name": "collection_status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"collection_status_description": {
"name": "collection_status_description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"collection_updated_at": {
"name": "collection_updated_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_collection_day": {
"name": "last_collection_day",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"partners": {
"name": "partners",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"wecom_name": {
"name": "wecom_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"owner": {
"name": "owner",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'运营组'"
},
"claimed_total": {
"name": "claimed_total",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"completed_total": {
"name": "completed_total",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tasks": {
"name": "tasks",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"brand": {
"name": "brand",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"quantity": {
"name": "quantity",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"claimed_quantity": {
"name": "claimed_quantity",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"due_at": {
"name": "due_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"source_url": {
"name": "source_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"source_sheet_id": {
"name": "source_sheet_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"source_sheet_name": {
"name": "source_sheet_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"source_synced_at": {
"name": "source_synced_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"share_token": {
"name": "share_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"collection_start_date": {
"name": "collection_start_date",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"collection_days": {
"name": "collection_days",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"collection_schedule_updated_at": {
"name": "collection_schedule_updated_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"tasks_share_token_idx": {
"name": "tasks_share_token_idx",
"columns": [
"share_token"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,851 @@
{
"version": "6",
"dialect": "sqlite",
"id": "6db5592e-9523-4444-9be9-3354c616c341",
"prevId": "54454371-d917-4b00-b1b3-1cd5947f801b",
"tables": {
"accounts": {
"name": "accounts",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"platform": {
"name": "platform",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'小红书'"
},
"platform_uid": {
"name": "platform_uid",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"public_account_id": {
"name": "public_account_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"nickname": {
"name": "nickname",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"profile_url": {
"name": "profile_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"ip_location": {
"name": "ip_location",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'待识别'"
},
"followers": {
"name": "followers",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"post_count": {
"name": "post_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"avg_views": {
"name": "avg_views",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"first_seen_at": {
"name": "first_seen_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"last_seen_at": {
"name": "last_seen_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"accounts_platform_uid_idx": {
"name": "accounts_platform_uid_idx",
"columns": [
"platform",
"platform_uid"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"claims": {
"name": "claims",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"partner_id": {
"name": "partner_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"claimant_name": {
"name": "claimant_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"claim_token": {
"name": "claim_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"quantity": {
"name": "quantity",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"claims_claim_token_idx": {
"name": "claims_claim_token_idx",
"columns": [
"claim_token"
],
"isUnique": true
},
"claims_task_partner_created_idx": {
"name": "claims_task_partner_created_idx",
"columns": [
"task_id",
"partner_id",
"created_at"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"collection_runs": {
"name": "collection_runs",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"distribution_id": {
"name": "distribution_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"scheduled_date": {
"name": "scheduled_date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"schedule_day": {
"name": "schedule_day",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"scheduled_at": {
"name": "scheduled_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"likes": {
"name": "likes",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"comments": {
"name": "comments",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"collects": {
"name": "collects",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status_description": {
"name": "status_description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"started_at": {
"name": "started_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"collection_runs_distribution_date_idx": {
"name": "collection_runs_distribution_date_idx",
"columns": [
"distribution_id",
"scheduled_date"
],
"isUnique": true
},
"collection_runs_task_date_idx": {
"name": "collection_runs_task_date_idx",
"columns": [
"task_id",
"scheduled_date"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"contents": {
"name": "contents",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"body": {
"name": "body",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"image_assets": {
"name": "image_assets",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'available'"
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'飞书内容表'"
},
"source_row": {
"name": "source_row",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"distributions": {
"name": "distributions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content_id": {
"name": "content_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"partner_id": {
"name": "partner_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"claim_id": {
"name": "claim_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"publish_url": {
"name": "publish_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"publish_time": {
"name": "publish_time",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"publish_screenshot_key": {
"name": "publish_screenshot_key",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'claimed'"
},
"claimed_at": {
"name": "claimed_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"screenshot_key": {
"name": "screenshot_key",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ocr_status": {
"name": "ocr_status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'none'"
},
"exposure": {
"name": "exposure",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"views": {
"name": "views",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d2_likes": {
"name": "d2_likes",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d2_comments": {
"name": "d2_comments",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d2_collects": {
"name": "d2_collects",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d5_likes": {
"name": "d5_likes",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d5_comments": {
"name": "d5_comments",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d5_collects": {
"name": "d5_collects",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d7_likes": {
"name": "d7_likes",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d7_comments": {
"name": "d7_comments",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d7_collects": {
"name": "d7_collects",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"latest_likes": {
"name": "latest_likes",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"latest_comments": {
"name": "latest_comments",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"latest_collects": {
"name": "latest_collects",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"collection_status": {
"name": "collection_status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"collection_status_description": {
"name": "collection_status_description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"collection_updated_at": {
"name": "collection_updated_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_collection_day": {
"name": "last_collection_day",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"partners": {
"name": "partners",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"wecom_name": {
"name": "wecom_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"owner": {
"name": "owner",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'运营组'"
},
"claimed_total": {
"name": "claimed_total",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"completed_total": {
"name": "completed_total",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tasks": {
"name": "tasks",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"brand": {
"name": "brand",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"quantity": {
"name": "quantity",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"claimed_quantity": {
"name": "claimed_quantity",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"due_at": {
"name": "due_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"source_url": {
"name": "source_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"source_sheet_id": {
"name": "source_sheet_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"source_sheet_name": {
"name": "source_sheet_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"source_synced_at": {
"name": "source_synced_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"share_token": {
"name": "share_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"collection_start_date": {
"name": "collection_start_date",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"collection_days": {
"name": "collection_days",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"collection_schedule_updated_at": {
"name": "collection_schedule_updated_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"tasks_share_token_idx": {
"name": "tasks_share_token_idx",
"columns": [
"share_token"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,964 @@
{
"version": "6",
"dialect": "sqlite",
"id": "1d95532b-30a7-4a2e-b4d6-ea812d642fee",
"prevId": "6db5592e-9523-4444-9be9-3354c616c341",
"tables": {
"accounts": {
"name": "accounts",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"platform": {
"name": "platform",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'小红书'"
},
"platform_uid": {
"name": "platform_uid",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"public_account_id": {
"name": "public_account_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"nickname": {
"name": "nickname",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"profile_url": {
"name": "profile_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"ip_location": {
"name": "ip_location",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'待识别'"
},
"followers": {
"name": "followers",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"post_count": {
"name": "post_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"avg_views": {
"name": "avg_views",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"first_seen_at": {
"name": "first_seen_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"last_seen_at": {
"name": "last_seen_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"accounts_platform_uid_idx": {
"name": "accounts_platform_uid_idx",
"columns": [
"platform",
"platform_uid"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"claims": {
"name": "claims",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"partner_id": {
"name": "partner_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"claimant_name": {
"name": "claimant_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"claim_token": {
"name": "claim_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"quantity": {
"name": "quantity",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"claims_claim_token_idx": {
"name": "claims_claim_token_idx",
"columns": [
"claim_token"
],
"isUnique": true
},
"claims_task_partner_created_idx": {
"name": "claims_task_partner_created_idx",
"columns": [
"task_id",
"partner_id",
"created_at"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"collection_runs": {
"name": "collection_runs",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"distribution_id": {
"name": "distribution_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"scheduled_date": {
"name": "scheduled_date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"schedule_day": {
"name": "schedule_day",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"scheduled_at": {
"name": "scheduled_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"likes": {
"name": "likes",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"comments": {
"name": "comments",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"collects": {
"name": "collects",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status_description": {
"name": "status_description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"started_at": {
"name": "started_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"collection_runs_distribution_date_idx": {
"name": "collection_runs_distribution_date_idx",
"columns": [
"distribution_id",
"scheduled_date"
],
"isUnique": true
},
"collection_runs_task_date_idx": {
"name": "collection_runs_task_date_idx",
"columns": [
"task_id",
"scheduled_date"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"contents": {
"name": "contents",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"body": {
"name": "body",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"image_assets": {
"name": "image_assets",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'available'"
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'飞书内容表'"
},
"source_row": {
"name": "source_row",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"delegation_bundles": {
"name": "delegation_bundles",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"claim_id": {
"name": "claim_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"partner_id": {
"name": "partner_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"share_token": {
"name": "share_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"quantity": {
"name": "quantity",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"revoked_at": {
"name": "revoked_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"delegation_bundles_share_token_idx": {
"name": "delegation_bundles_share_token_idx",
"columns": [
"share_token"
],
"isUnique": true
},
"delegation_bundles_claim_created_idx": {
"name": "delegation_bundles_claim_created_idx",
"columns": [
"claim_id",
"created_at"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"distributions": {
"name": "distributions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content_id": {
"name": "content_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"partner_id": {
"name": "partner_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"claim_id": {
"name": "claim_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"delegation_bundle_id": {
"name": "delegation_bundle_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"publish_url": {
"name": "publish_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"publish_time": {
"name": "publish_time",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"publish_screenshot_key": {
"name": "publish_screenshot_key",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'claimed'"
},
"claimed_at": {
"name": "claimed_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"screenshot_key": {
"name": "screenshot_key",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ocr_status": {
"name": "ocr_status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'none'"
},
"exposure": {
"name": "exposure",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"views": {
"name": "views",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d2_likes": {
"name": "d2_likes",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d2_comments": {
"name": "d2_comments",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d2_collects": {
"name": "d2_collects",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d5_likes": {
"name": "d5_likes",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d5_comments": {
"name": "d5_comments",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d5_collects": {
"name": "d5_collects",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d7_likes": {
"name": "d7_likes",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d7_comments": {
"name": "d7_comments",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"d7_collects": {
"name": "d7_collects",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"latest_likes": {
"name": "latest_likes",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"latest_comments": {
"name": "latest_comments",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"latest_collects": {
"name": "latest_collects",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"collection_status": {
"name": "collection_status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"collection_status_description": {
"name": "collection_status_description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"collection_updated_at": {
"name": "collection_updated_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_collection_day": {
"name": "last_collection_day",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"partners": {
"name": "partners",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"wecom_name": {
"name": "wecom_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"owner": {
"name": "owner",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'运营组'"
},
"claimed_total": {
"name": "claimed_total",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"completed_total": {
"name": "completed_total",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tasks": {
"name": "tasks",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"brand": {
"name": "brand",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"quantity": {
"name": "quantity",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"claimed_quantity": {
"name": "claimed_quantity",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"due_at": {
"name": "due_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"source_url": {
"name": "source_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"source_sheet_id": {
"name": "source_sheet_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"source_sheet_name": {
"name": "source_sheet_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"source_synced_at": {
"name": "source_synced_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"share_token": {
"name": "share_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"collection_start_date": {
"name": "collection_start_date",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"collection_days": {
"name": "collection_days",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"collection_schedule_updated_at": {
"name": "collection_schedule_updated_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"tasks_share_token_idx": {
"name": "tasks_share_token_idx",
"columns": [
"share_token"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,55 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1785134871732,
"tag": "0000_pink_iron_monger",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1785152017151,
"tag": "0001_sloppy_blue_blade",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1785159119072,
"tag": "0002_wealthy_maelstrom",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1785163928142,
"tag": "0003_needy_doctor_strange",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1785230464686,
"tag": "0004_sharp_the_liberteens",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1785295052381,
"tag": "0005_foamy_sage",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1785380450391,
"tag": "0006_moaning_dark_phoenix",
"breakpoints": true
}
]
}

19
eslint.config.mjs Normal file
View File

@@ -0,0 +1,19 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"koc-portal/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

View File

@@ -0,0 +1,58 @@
import { desc } from "drizzle-orm";
import { getDb } from "../../../../../db";
import { notes } from "../../../db/schema";
function toRouteErrorMessage(error: unknown) {
const message = error instanceof Error ? error.message : "Unexpected error";
const detail =
error instanceof Error && error.cause instanceof Error ? error.cause.message : "";
const combined = `${message}\n${detail}`;
if (combined.includes("no such table") || combined.includes('from "notes"')) {
return "The notes table is unavailable. Generate the migration locally with `npm run db:generate`, then deploy so the platform can apply the generated SQL to the real D1 database.";
}
return message;
}
export async function GET() {
try {
const db = getDb();
const rows = await db
.select()
.from(notes)
.orderBy(desc(notes.createdAt), desc(notes.id))
.limit(20);
return Response.json({ notes: rows });
} catch (error) {
return Response.json(
{ error: toRouteErrorMessage(error) },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const payload = (await request.json()) as {
title?: string;
content?: string;
};
const title = payload.title?.trim() ?? "";
const content = payload.content?.trim() ?? "";
if (!title) {
return Response.json({ error: "title is required" }, { status: 400 });
}
const db = getDb();
const [note] = await db.insert(notes).values({ title, content }).returning();
return Response.json({ note }, { status: 201 });
} catch (error) {
return Response.json(
{ error: toRouteErrorMessage(error) },
{ status: 500 }
);
}
}

9
examples/d1/db/schema.ts Normal file
View File

@@ -0,0 +1,9 @@
import { sql } from "drizzle-orm";
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const notes = sqliteTable("notes", {
id: integer("id").primaryKey({ autoIncrement: true }),
title: text("title").notNull(),
content: text("content").notNull().default(""),
createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
});

43
koc-portal/.gitignore vendored Normal file
View File

@@ -0,0 +1,43 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/.vinext/
/out/
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
.dev.vars
# vercel
.vercel
# typescript
next-env.d.ts
/dist/
/.wrangler/
/outputs/
/work/

View File

@@ -0,0 +1,5 @@
{
"project_id": "appgprj_6a6760b6f5bc8191ae5baf5cfed80804",
"d1": null,
"r2": null
}

21
koc-portal/README.md Normal file
View File

@@ -0,0 +1,21 @@
# KOC LOOP 外部任务门户
独立公开站点,用于把后台分发任务发给外部 KOC。
## MVP 流程
1. KOC 通过带 `task` 参数的任务链接进入。
2. 只填写企微昵称/联系人和领取数量。
3. 领取后只能看到本次领取的笔记。
4. 在单篇笔记详情页查看标题与正文,并一一回填发布账号昵称、发布链接和发布截图。
门户不直接连接数据库。浏览器只调用后台隔离开放的 KOC 领取与回填接口,后台仍是任务、笔记和发布数据的唯一数据源;后台管理页面和管理接口需要管理员登录。
## 本地开发
先在 `3001` 端口启动后台,再运行:
```bash
npm install
npm run dev -- --port 3000
```

View File

@@ -0,0 +1,86 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export type ChatGPTUser = {
displayName: string;
email: string;
fullName: string | null;
};
const USER_EMAIL_HEADER = "oai-authenticated-user-email";
const USER_FULL_NAME_HEADER = "oai-authenticated-user-full-name";
const USER_FULL_NAME_ENCODING_HEADER =
"oai-authenticated-user-full-name-encoding";
const PERCENT_ENCODED_UTF8 = "percent-encoded-utf-8";
const SIGN_IN_PATH = "/signin-with-chatgpt";
const SIGN_OUT_PATH = "/signout-with-chatgpt";
const CALLBACK_PATH = "/callback";
export async function getChatGPTUser(): Promise<ChatGPTUser | null> {
const requestHeaders = await headers();
const email = requestHeaders.get(USER_EMAIL_HEADER);
if (!email) return null;
const encodedFullName = requestHeaders.get(USER_FULL_NAME_HEADER);
const fullName =
encodedFullName &&
requestHeaders.get(USER_FULL_NAME_ENCODING_HEADER) === PERCENT_ENCODED_UTF8
? safeDecodeURIComponent(encodedFullName)
: null;
return {
displayName: fullName ?? email,
email,
fullName,
};
}
export async function requireChatGPTUser(
returnTo: string,
): Promise<ChatGPTUser> {
const user = await getChatGPTUser();
if (user) return user;
redirect(chatGPTSignInPath(returnTo));
}
export function chatGPTSignInPath(returnTo: string): string {
const safeReturnTo = safeRelativeReturnPath(returnTo);
return `${SIGN_IN_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
}
export function chatGPTSignOutPath(returnTo = "/"): string {
const safeReturnTo = safeRelativeReturnPath(returnTo);
return `${SIGN_OUT_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
}
function safeRelativeReturnPath(value: string): string {
if (!value.startsWith("/") || value.startsWith("//")) return "/";
let url: URL;
try {
url = new URL(value, "https://app.local");
} catch {
return "/";
}
if (url.origin !== "https://app.local") return "/";
if (isReservedAuthPath(url.pathname)) return "/";
return `${url.pathname}${url.search}${url.hash}`;
}
function isReservedAuthPath(pathname: string): boolean {
return (
pathname === SIGN_IN_PATH ||
pathname === SIGN_OUT_PATH ||
pathname === CALLBACK_PATH
);
}
function safeDecodeURIComponent(value: string): string | null {
try {
return decodeURIComponent(value);
} catch {
return null;
}
}

View File

@@ -0,0 +1,26 @@
export function parseStoredDate(value: string) {
const trimmed = value.trim();
const normalized = /^\d{4}-\d{2}-\d{2}$/.test(trimmed)
? `${trimmed}T00:00:00+08:00`
: /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?$/.test(
trimmed,
)
? `${trimmed.replace(" ", "T")}Z`
: trimmed;
return new Date(normalized);
}
export function formatShanghaiDate(
value?: string | null,
withTime = false,
) {
if (!value) return "—";
const date = parseStoredDate(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
month: "2-digit",
day: "2-digit",
...(withTime ? { hour: "2-digit", minute: "2-digit" } : {}),
}).format(date);
}

1572
koc-portal/app/globals.css Normal file

File diff suppressed because it is too large Load Diff

77
koc-portal/app/layout.tsx Normal file
View File

@@ -0,0 +1,77 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { headers } from "next/headers";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export async function generateMetadata(): Promise<Metadata> {
const requestHeaders = await headers();
const host = requestHeaders.get("x-forwarded-host") || requestHeaders.get("host");
const protocol =
requestHeaders.get("x-forwarded-proto") ||
(host?.startsWith("localhost") ? "http" : "https");
const metadataBase = host
? new URL(`${protocol}://${host}`)
: new URL("https://koc-task.example.com");
const description =
"领取KOC内容任务逐篇查看笔记详情并一一回填发布账号、链接与截图。";
return {
metadataBase,
title: "KOC LOOP外部任务领取",
description,
robots: {
index: false,
follow: false,
noarchive: true,
nosnippet: true,
},
referrer: "no-referrer",
icons: {
icon: "/favicon.svg",
shortcut: "/favicon.svg",
},
openGraph: {
title: "KOC LOOP外部任务领取",
description,
type: "website",
images: [
{
url: new URL("/og.png", metadataBase).toString(),
width: 1200,
height: 630,
alt: "KOC LOOP 外部任务领取与逐篇回填",
},
],
},
twitter: {
card: "summary_large_image",
title: "KOC LOOP外部任务领取",
description,
images: [new URL("/og.png", metadataBase).toString()],
},
};
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN">
<body className={`${geistSans.variable} ${geistMono.variable}`}>
{children}
</body>
</html>
);
}

1363
koc-portal/app/page.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,45 @@
import { access, cp, mkdir, rm } from "node:fs/promises";
import { resolve } from "node:path";
import type { Plugin } from "vite";
async function exists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return false;
}
throw error;
}
}
// Packages Sites metadata and migrations after Vite finishes compiling.
export function sites(): Plugin {
let root = process.cwd();
return {
name: "sites",
apply: "build",
configResolved(config) {
root = config.root;
},
async closeBundle() {
const outputDirectory = resolve(root, "dist", ".openai");
const hostingConfig = resolve(root, ".openai", "hosting.json");
const drizzleSource = resolve(root, "drizzle");
await rm(outputDirectory, { recursive: true, force: true });
await mkdir(outputDirectory, { recursive: true });
if (await exists(hostingConfig)) {
await cp(hostingConfig, resolve(outputDirectory, "hosting.json"));
}
if (await exists(drizzleSource)) {
await cp(drizzleSource, resolve(outputDirectory, "drizzle"), {
recursive: true,
});
}
},
};
}

13
koc-portal/db/index.ts Normal file
View File

@@ -0,0 +1,13 @@
import { env } from "cloudflare:workers";
import { drizzle } from "drizzle-orm/d1";
import * as schema from "./schema";
export function getDb() {
if (!env.DB) {
throw new Error(
"Cloudflare D1 binding `DB` is unavailable. Set the `d1` field in .openai/hosting.json to `DB` or let your control plane inject the real binding values before using the database."
);
}
return drizzle(env.DB, { schema });
}

4
koc-portal/db/schema.ts Normal file
View File

@@ -0,0 +1,4 @@
// Intentionally empty by default.
// Add Drizzle tables here when the site actually needs a database.
// See examples/d1/db/schema.ts for an opt-in example.
export {};

View File

@@ -0,0 +1,7 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
out: "./drizzle",
schema: "./db/schema.ts",
dialect: "sqlite",
});

View File

@@ -0,0 +1,5 @@
{
"version": "7",
"dialect": "sqlite",
"entries": []
}

View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

View File

@@ -0,0 +1,58 @@
import { desc } from "drizzle-orm";
import { getDb } from "../../../../../db";
import { notes } from "../../../db/schema";
function toRouteErrorMessage(error: unknown) {
const message = error instanceof Error ? error.message : "Unexpected error";
const detail =
error instanceof Error && error.cause instanceof Error ? error.cause.message : "";
const combined = `${message}\n${detail}`;
if (combined.includes("no such table") || combined.includes('from "notes"')) {
return "The notes table is unavailable. Generate the migration locally with `npm run db:generate`, then deploy so the platform can apply the generated SQL to the real D1 database.";
}
return message;
}
export async function GET() {
try {
const db = getDb();
const rows = await db
.select()
.from(notes)
.orderBy(desc(notes.createdAt), desc(notes.id))
.limit(20);
return Response.json({ notes: rows });
} catch (error) {
return Response.json(
{ error: toRouteErrorMessage(error) },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const payload = (await request.json()) as {
title?: string;
content?: string;
};
const title = payload.title?.trim() ?? "";
const content = payload.content?.trim() ?? "";
if (!title) {
return Response.json({ error: "title is required" }, { status: 400 });
}
const db = getDb();
const [note] = await db.insert(notes).values({ title, content }).returning();
return Response.json({ note }, { status: 201 });
} catch (error) {
return Response.json(
{ error: toRouteErrorMessage(error) },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,9 @@
import { sql } from "drizzle-orm";
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const notes = sqliteTable("notes", {
id: integer("id").primaryKey({ autoIncrement: true }),
title: text("title").notNull(),
content: text("content").notNull().default(""),
createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
});

View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

11169
koc-portal/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

42
koc-portal/package.json Normal file
View File

@@ -0,0 +1,42 @@
{
"name": "koc-task-portal",
"version": "0.1.0",
"private": true,
"engines": {
"node": ">=22.13.0"
},
"scripts": {
"dev": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext dev",
"build": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext build",
"start": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext start",
"test": "npm run build && node --test tests/rendered-html.test.mjs",
"lint": "eslint . --ignore-pattern dist --ignore-pattern .next",
"db:generate": "drizzle-kit generate"
},
"dependencies": {
"drizzle-orm": "0.45.2",
"fflate": "0.7.4",
"next": "16.2.6",
"react": "19.2.6",
"react-dom": "19.2.6"
},
"devDependencies": {
"@cloudflare/vite-plugin": "1.37.1",
"@tailwindcss/postcss": "4.2.1",
"@types/node": "22.19.19",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.2",
"@vitejs/plugin-rsc": "0.5.26",
"drizzle-kit": "0.31.10",
"eslint": "9.39.4",
"eslint-config-next": "16.2.6",
"react-server-dom-webpack": "19.2.6",
"tailwindcss": "4.2.1",
"typescript": "5.9.3",
"vinext": "0.0.50",
"vite": "8.0.13",
"wrangler": "4.92.0"
},
"type": "module"
}

View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@@ -0,0 +1,5 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="32" height="32" rx="9" fill="#172D27"/>
<path d="M9 8V24M9 16L20 8M9 16L21 24" stroke="#68D0A6" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="23.5" cy="9.5" r="2.5" fill="#F39A70"/>
</svg>

After

Width:  |  Height:  |  Size: 338 B

View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
koc-portal/public/og.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 658 KiB

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1,106 @@
import assert from "node:assert/strict";
import { access, readFile } from "node:fs/promises";
import test from "node:test";
import {
formatShanghaiDate,
parseStoredDate,
} from "../app/date-utils.ts";
test("builds the branded external task shell", async () => {
const [page, layout] = await Promise.all([
readFile(new URL("../app/page.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/layout.tsx", import.meta.url), "utf8"),
]);
assert.match(layout, /KOC LOOP外部任务领取/);
assert.match(layout, /og\.png/);
assert.match(page, /正在打开任务/);
await access(new URL("../dist/server/index.js", import.meta.url));
});
test("keeps claiming minimal and backfill one-to-one", async () => {
const [page, layout, packageJson, hosting] =
await Promise.all([
readFile(new URL("../app/page.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/layout.tsx", import.meta.url), "utf8"),
readFile(new URL("../package.json", import.meta.url), "utf8"),
readFile(new URL("../.openai/hosting.json", import.meta.url), "utf8"),
]);
assert.match(page, /企微昵称\s*\/\s*联系人/);
assert.match(page, /const \[quantity, setQuantity\] = useState\(1\)/);
assert.match(page, /distributionId:\s*selected\.id/);
assert.match(page, /发布账号昵称/);
assert.match(page, /发布链接/);
assert.match(page, /inputMode="url"/);
assert.match(page, /长链、短链或整段分享文案/);
assert.doesNotMatch(page, /type="url"/);
assert.match(page, /发布截图/);
assert.match(page, /发布配图/);
assert.match(page, /复制标题/);
assert.match(page, /复制文案/);
assert.match(page, /下载原图/);
assert.match(page, /批量保存图片/);
assert.match(page, /navigator\.share/);
assert.match(page, /zipSync/);
assert.match(page, /navigator\.clipboard\.writeText/);
assert.match(page, /URL\.createObjectURL/);
assert.match(page, /\/api\/partner-image/);
assert.match(page, /找回领取记录/);
assert.match(page, /action:\s*"recover"/);
assert.match(page, /同一任务多次领取会分批展示/);
assert.match(page, /这篇笔记已回填,不会与其他笔记错配/);
assert.doesNotMatch(page, /批量回填/);
assert.doesNotMatch(page, /复制标题和正文/);
assert.match(page, /PRODUCTION_ADMIN_ORIGIN/);
assert.match(page, /\/api\/partner-upload/);
assert.match(page, /"X-KOC-Distribution":\s*selectedItem\.id/);
assert.match(page, /"X-KOC-Upload-Kind":\s*kind/);
assert.match(page, /上传截图并填写数据/);
assert.match(page, /提交创作者数据/);
assert.match(page, /submit_creator_metrics/);
assert.match(page, /creatorExposure/);
assert.match(page, /creatorViews/);
assert.match(page, /截图仅用于运营核对不再自动OCR/);
assert.match(page, /曝光量/);
assert.match(page, /阅读量/);
assert.doesNotMatch(page, /recognizeCreatorMetrics/);
assert.match(page, /note-index \$\{item\.publish_url \? "done" : ""\}/);
assert.match(packageJson, /"fflate":\s*"0\.7\.4"/);
assert.doesNotMatch(packageJson, /tesseract\.js/);
assert.match(layout, /逐篇查看笔记详情并一一回填/);
assert.doesNotMatch(packageJson, /react-loading-skeleton/);
const hostingConfig = JSON.parse(hosting);
assert.equal(hostingConfig.d1, null);
assert.equal(hostingConfig.r2, null);
await access(new URL("../public/og.png", import.meta.url));
await access(new URL("../public/favicon.svg", import.meta.url));
});
test("shows D1 timestamps in Beijing time", () => {
const stored = "2026-07-29 05:36:00";
assert.equal(parseStoredDate(stored).toISOString(), "2026-07-29T05:36:00.000Z");
assert.match(formatShanghaiDate(stored, true), /07\/29.*13:36/);
});
test("creates anonymous delegation bundles and reuses one-to-one backfill", async () => {
const [page, layout, styles] = await Promise.all([
readFile(new URL("../app/page.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/layout.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/globals.css", import.meta.url), "utf8"),
]);
assert.match(page, /选择笔记并分享/);
assert.match(page, /生成并复制分享链接/);
assert.match(page, /action:\s*"create_delegation"/);
assert.match(page, /action:\s*"revoke_delegation"/);
assert.match(page, /合作社转派 · 无需登录/);
assert.match(page, /"X-KOC-Delegation"/);
assert.match(page, /url\.searchParams\.set\("share", shareToken\)/);
assert.match(page, /请保存当前分享链接/);
assert.doesNotMatch(page, /底层KOC手机号|底层KOC企微|底层KOC微信/);
assert.match(layout, /index:\s*false/);
assert.match(layout, /referrer:\s*"no-referrer"/);
assert.match(styles, /\.share-checkbox/);
assert.match(styles, /\.delegation-history/);
});

34
koc-portal/tsconfig.json Normal file
View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}

59
koc-portal/vite.config.ts Normal file
View File

@@ -0,0 +1,59 @@
import vinext from "vinext";
import { defineConfig } from "vite";
import hostingConfig from "./.openai/hosting.json";
import { sites } from "./build/sites-vite-plugin";
const SITE_CREATOR_PLACEHOLDER_DATABASE_ID =
"00000000-0000-4000-8000-000000000000";
const { d1, r2 } = hostingConfig;
// macOS Seatbelt blocks FSEvents, so Codex previews need polling for HMR.
const isCodexSeatbeltSandbox = process.env.CODEX_SANDBOX === "seatbelt";
const localBindingConfig = {
main: "./worker/index.ts",
compatibility_flags: ["nodejs_compat"],
d1_databases: d1
? [
{
binding: d1,
database_name: "site-creator-d1",
database_id: SITE_CREATOR_PLACEHOLDER_DATABASE_ID,
},
]
: [],
r2_buckets: r2
? [
{
binding: r2,
bucket_name: "site-creator-r2",
},
]
: [],
};
export default defineConfig(async () => {
// Keep Wrangler and Miniflare state project-local. These are non-secret tool
// settings; application environment belongs in ignored `.env*` files.
process.env.WRANGLER_WRITE_LOGS ??= "false";
process.env.WRANGLER_LOG_PATH ??= ".wrangler/logs";
process.env.MINIFLARE_REGISTRY_PATH ??= ".wrangler/registry";
// Wrangler snapshots its log path while the Cloudflare plugin is imported.
const { cloudflare } = await import("@cloudflare/vite-plugin");
return {
server: isCodexSeatbeltSandbox
? { watch: { useFsEvents: false, usePolling: true } }
: undefined,
plugins: [
vinext(),
sites(),
cloudflare({
viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] },
config: localBindingConfig,
}),
],
};
});

View File

@@ -0,0 +1,47 @@
/** Cloudflare Worker entry point for the vinext-starter template. */
import { handleImageOptimization, DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES } from "vinext/server/image-optimization";
import handler from "vinext/server/app-router-entry";
interface Env {
ASSETS: Fetcher;
DB: D1Database;
IMAGES: {
input(stream: ReadableStream): {
transform(options: Record<string, unknown>): {
output(options: { format: string; quality: number }): Promise<{ response(): Response }>;
};
};
};
}
interface ExecutionContext {
waitUntil(promise: Promise<unknown>): void;
passThroughOnException(): void;
}
// Image security config. SVG sources with .svg extension auto-skip the
// optimization endpoint on the client side (served directly, no proxy).
// To route SVGs through the optimizer (with security headers), set
// dangerouslyAllowSVG: true in next.config.js and uncomment below:
// const imageConfig: ImageConfig = { dangerouslyAllowSVG: true };
const worker = {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/_vinext/image") {
const allowedWidths = [...DEFAULT_DEVICE_SIZES, ...DEFAULT_IMAGE_SIZES];
return handleImageOptimization(request, {
fetchAsset: (path) => env.ASSETS.fetch(new Request(new URL(path, request.url))),
transformImage: async (body, { width, format, quality }) => {
const result = await env.IMAGES.input(body).transform(width > 0 ? { width } : {}).output({ format, quality });
return result.response();
},
}, allowedWidths);
}
return handler.fetch(request, env, ctx);
},
};
export default worker;

View File

@@ -0,0 +1,318 @@
import {
resolveXhsPublicAccountDetails,
resolveXhsAccountProfileFromMcp,
resolveXhsProfileDetailsFromMcp,
type CollectionMcpConfig,
} from "./mcp-collection-client";
import { hashText } from "./mvp-db";
type DistributionAccountRow = {
id: string;
account_id: string | null;
publish_url: string | null;
};
type BackfillRow = DistributionAccountRow & {
nickname: string | null;
platform: string | null;
platform_uid: string | null;
public_account_id: string | null;
profile_url: string | null;
followers: number | null;
};
function isVerifiedXhsProfileUrl(value: string | null) {
if (!value) return false;
try {
const url = new URL(value);
return (
url.protocol === "https:" &&
(url.hostname === "xiaohongshu.com" ||
url.hostname.endsWith(".xiaohongshu.com")) &&
url.pathname.startsWith("/user/profile/")
);
} catch {
return false;
}
}
export async function enrichDistributionAccount(
db: D1Database,
distributionId: string,
publishUrl: string,
fallbackNickname: string,
mcpConfig: CollectionMcpConfig,
) {
const profile = await resolveXhsAccountProfileFromMcp(
publishUrl,
fallbackNickname,
mcpConfig,
);
const current = await db
.prepare(
`SELECT id, account_id, publish_url
FROM distributions
WHERE id = ?`,
)
.bind(distributionId)
.first<DistributionAccountRow>();
if (!current || current.publish_url !== publishUrl) {
return { updated: false, reason: "stale" as const };
}
const canonicalAccountId = `account-${hashText(
`小红书:${profile.platformUid}`,
)}`;
if (current.account_id === canonicalAccountId) {
await db
.prepare(
`UPDATE accounts SET
nickname = ?,
profile_url = ?,
public_account_id = CASE
WHEN ? != '' THEN ?
ELSE public_account_id
END,
ip_location = CASE
WHEN ? != '' AND ? != '待识别' THEN ?
ELSE ip_location
END,
followers = CASE
WHEN ? IS NOT NULL AND (? > 0 OR followers = 0) THEN ?
ELSE followers
END,
last_seen_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(
profile.nickname || fallbackNickname,
profile.profileUrl,
profile.redId,
profile.redId,
profile.ipLocation,
profile.ipLocation,
profile.ipLocation,
profile.followers,
profile.followers,
profile.followers,
canonicalAccountId,
)
.run();
return {
updated: true,
accountId: canonicalAccountId,
profileUrl: profile.profileUrl,
};
}
const provisionalAccountId = current.account_id;
await db.batch([
db
.prepare(
`INSERT INTO accounts
(id, platform, platform_uid, public_account_id, nickname, profile_url, ip_location, followers, post_count)
VALUES (?, '小红书', ?, ?, ?, ?, ?, ?, 1)
ON CONFLICT(platform, platform_uid) DO UPDATE SET
public_account_id = CASE
WHEN excluded.public_account_id != ''
THEN excluded.public_account_id
ELSE accounts.public_account_id
END,
nickname = excluded.nickname,
profile_url = excluded.profile_url,
ip_location = CASE
WHEN excluded.ip_location != '' AND excluded.ip_location != '待识别'
THEN excluded.ip_location
ELSE accounts.ip_location
END,
followers = CASE
WHEN excluded.followers > 0 OR accounts.followers = 0
THEN excluded.followers
ELSE accounts.followers
END,
post_count = accounts.post_count + 1,
last_seen_at = CURRENT_TIMESTAMP`,
)
.bind(
canonicalAccountId,
profile.platformUid,
profile.redId,
profile.nickname || fallbackNickname,
profile.profileUrl,
profile.ipLocation,
profile.followers ?? 0,
),
db
.prepare(
`UPDATE distributions SET
account_id = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND publish_url = ?`,
)
.bind(canonicalAccountId, distributionId, publishUrl),
]);
if (provisionalAccountId) {
await db
.prepare(
`DELETE FROM accounts
WHERE id = ?
AND id != ?
AND NOT EXISTS (
SELECT 1 FROM distributions WHERE account_id = ?
)`,
)
.bind(
provisionalAccountId,
canonicalAccountId,
provisionalAccountId,
)
.run();
}
return {
updated: true,
accountId: canonicalAccountId,
profileUrl: profile.profileUrl,
};
}
export async function backfillAccountProfiles(
db: D1Database,
mcpConfig: CollectionMcpConfig,
limit = 10,
) {
const rows = await db
.prepare(
`SELECT
d.id,
d.account_id,
d.publish_url,
a.nickname,
a.platform,
a.platform_uid,
a.public_account_id,
a.profile_url,
a.followers
FROM distributions d
LEFT JOIN accounts a ON a.id = d.account_id
WHERE d.publish_url IS NOT NULL
AND d.publish_url != ''
ORDER BY d.updated_at DESC
LIMIT 100`,
)
.all<BackfillRow>();
let attempted = 0;
let updated = 0;
let failed = 0;
for (const row of rows.results) {
if (attempted >= Math.max(1, Math.min(25, limit))) break;
const noteId = (() => {
try {
const url = new URL(row.publish_url ?? "");
return (
url.pathname.match(
/\/(?:discovery\/item|explore)\/([A-Za-z0-9_-]{8,80})/,
)?.[1] ?? ""
);
} catch {
return "";
}
})();
const isDemoAccount = row.platform_uid?.startsWith("xhs-");
let attemptedThisRow = false;
let publicProfileUpdated = false;
if (
row.platform === "小红书" &&
!isDemoAccount &&
isVerifiedXhsProfileUrl(row.profile_url) &&
(!row.public_account_id || Number(row.followers ?? 0) === 0)
) {
attempted += 1;
attemptedThisRow = true;
const details = await resolveXhsProfileDetailsFromMcp(
row.profile_url ?? "",
mcpConfig,
).catch(() =>
resolveXhsPublicAccountDetails(row.profile_url ?? ""),
);
if (
row.account_id &&
(details.redId || details.followers !== null)
) {
await db
.prepare(
`UPDATE accounts
SET public_account_id = CASE
WHEN ? != '' THEN ?
ELSE public_account_id
END,
followers = CASE
WHEN ? IS NOT NULL AND (? > 0 OR followers = 0) THEN ?
ELSE followers
END,
ip_location = CASE
WHEN ? != '' AND ? != '待识别' THEN ?
ELSE ip_location
END,
last_seen_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(
details.redId,
details.redId,
details.followers,
details.followers,
details.followers,
details.ipLocation ?? "",
details.ipLocation ?? "",
details.ipLocation ?? "",
row.account_id,
)
.run();
publicProfileUpdated = true;
}
if (
(details.redId || row.public_account_id) &&
Number(details.followers ?? row.followers ?? 0) > 0
) {
updated += 1;
continue;
}
if (attempted >= Math.max(1, Math.min(25, limit))) {
if (publicProfileUpdated) updated += 1;
continue;
}
}
const needsEnrichment =
!isDemoAccount &&
(!row.account_id ||
(row.platform === "小红书" &&
!isVerifiedXhsProfileUrl(row.profile_url)) ||
(row.platform === "小红书" && !row.public_account_id) ||
(row.platform === "小红书" &&
Number(row.followers ?? 0) === 0) ||
row.platform_uid?.startsWith("pending-") ||
Boolean(noteId && row.platform_uid === noteId));
if (!needsEnrichment || !row.publish_url) {
if (publicProfileUpdated) updated += 1;
continue;
}
if (!attemptedThisRow) attempted += 1;
try {
const result = await enrichDistributionAccount(
db,
row.id,
row.publish_url,
row.nickname || "待识别账号",
mcpConfig,
);
if (result.updated || publicProfileUpdated) updated += 1;
} catch {
if (publicProfileUpdated) updated += 1;
else failed += 1;
}
}
return { attempted, updated, failed };
}

35
lib/admin-auth.ts Normal file
View File

@@ -0,0 +1,35 @@
import { env } from "cloudflare:workers";
export function isAdminEmail(input: string | null | undefined) {
const allowedEmail = String(
(env as unknown as { ADMIN_ALLOWED_EMAIL?: string }).ADMIN_ALLOWED_EMAIL ??
"",
)
.trim()
.toLowerCase();
const requestEmail = String(input ?? "").trim().toLowerCase();
return Boolean(allowedEmail && requestEmail && requestEmail === allowedEmail);
}
export function isAdminRequest(request: Request) {
if (isAdminEmail(request.headers.get("oai-authenticated-user-email"))) {
return true;
}
const expectedToken = String(
(env as unknown as { ADMIN_INTERNAL_TOKEN?: string }).ADMIN_INTERNAL_TOKEN ??
"",
).trim();
const requestToken = String(
request.headers.get("x-koc-admin-token") ?? "",
).trim();
return Boolean(
expectedToken &&
requestToken &&
expectedToken.length === requestToken.length &&
expectedToken === requestToken,
);
}
export function adminForbidden() {
return Response.json({ error: "无后台操作权限" }, { status: 403 });
}

471
lib/collection-service.ts Normal file
View File

@@ -0,0 +1,471 @@
import {
collectXhsMetricsFromMcp,
type CollectionMcpConfig,
} from "./mcp-collection-client";
import { uid } from "./mvp-db";
type DistributionForCollection = {
id: string;
task_id: string;
publish_url: string | null;
ocr_status: string;
};
type ScheduledTask = {
id: string;
collection_start_date: string;
collection_days: string;
};
type CollectionSource = "automatic" | "catchup" | "manual";
function utcDay(value: string) {
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) return null;
return Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
}
function dateForScheduleDay(startDate: string, scheduleDay: number) {
const start = utcDay(startDate);
if (start === null) return null;
return new Date(start + (scheduleDay - 1) * 86_400_000)
.toISOString()
.slice(0, 10);
}
function parseDays(value: string) {
try {
const parsed = JSON.parse(value) as unknown;
if (!Array.isArray(parsed)) return [];
return [...new Set(parsed.map(Number))]
.filter((day) => Number.isInteger(day) && day >= 1 && day <= 7)
.sort((a, b) => a - b);
} catch {
return [];
}
}
export function shanghaiDateFromTimestamp(timestamp: number) {
return new Date(timestamp + 8 * 60 * 60 * 1000)
.toISOString()
.slice(0, 10);
}
function shanghaiHourFromTimestamp(timestamp: number) {
return Number(
new Date(timestamp + 8 * 60 * 60 * 1000)
.toISOString()
.slice(11, 13),
);
}
function dueSchedules(
startDate: string,
days: number[],
timestamp: number,
) {
const currentDate = shanghaiDateFromTimestamp(timestamp);
const currentHour = shanghaiHourFromTimestamp(timestamp);
return days
.map((scheduleDay) => ({
scheduleDay,
scheduledDate: dateForScheduleDay(startDate, scheduleDay),
}))
.filter(
(
value,
): value is { scheduleDay: number; scheduledDate: string } =>
Boolean(
value.scheduledDate &&
(value.scheduledDate < currentDate ||
(value.scheduledDate === currentDate && currentHour >= 10)),
),
);
}
export async function createCollectionRunTasks(
db: D1Database,
taskId: string,
startDate: string,
days: number[],
) {
const normalizedDays = [...new Set(days)]
.filter((day) => Number.isInteger(day) && day >= 1 && day <= 7)
.sort((a, b) => a - b);
const distributions = await db
.prepare(
`SELECT id FROM distributions
WHERE task_id = ?
AND publish_url IS NOT NULL
AND publish_url != ''`,
)
.bind(taskId)
.all<{ id: string }>();
await db
.prepare(
`DELETE FROM collection_runs
WHERE task_id = ?
AND status = 'pending'`,
)
.bind(taskId)
.run();
const statements: D1PreparedStatement[] = [];
for (const distribution of distributions.results) {
for (const scheduleDay of normalizedDays) {
const scheduledDate = dateForScheduleDay(startDate, scheduleDay);
if (!scheduledDate) continue;
statements.push(
db
.prepare(
`INSERT OR IGNORE INTO collection_runs
(id, task_id, distribution_id, scheduled_date, schedule_day,
scheduled_at, status, status_description)
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?)`,
)
.bind(
uid("run"),
taskId,
distribution.id,
scheduledDate,
scheduleDay,
`${scheduledDate}T10:00:00+08:00`,
`等待第${scheduleDay}天 10:00自动采集`,
),
);
}
}
for (let index = 0; index < statements.length; index += 100) {
await db.batch(statements.slice(index, index + 100));
}
return {
distributions: distributions.results.length,
days: normalizedDays.length,
tasks: statements.length,
};
}
export async function collectDistributionMetrics(
db: D1Database,
distributionId: string,
scheduledDate: string,
scheduleDay: number | null,
source: CollectionSource,
mcpConfig: CollectionMcpConfig,
) {
const current = await db
.prepare("SELECT * FROM distributions WHERE id = ?")
.bind(distributionId)
.first<DistributionForCollection>();
if (!current) throw new Error("分发记录不存在");
if (!current.publish_url) throw new Error("笔记尚未回填发布链接");
const scheduledAt = `${scheduledDate}T10:00:00+08:00`;
const runId = uid("run");
await db
.prepare(
`INSERT OR IGNORE INTO collection_runs
(id, task_id, distribution_id, scheduled_date, schedule_day,
scheduled_at, status, status_description)
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?)`,
)
.bind(
runId,
current.task_id,
distributionId,
scheduledDate,
scheduleDay,
scheduledAt,
source === "automatic"
? "等待自动采集"
: source === "catchup"
? "等待自动追采"
: "等待手动采集",
)
.run();
const run = await db
.prepare(
`SELECT id, status FROM collection_runs
WHERE distribution_id = ? AND scheduled_date = ?`,
)
.bind(distributionId, scheduledDate)
.first<{ id: string; status: string }>();
if (!run) throw new Error("采集任务创建失败");
if (run.status === "success") return { skipped: true };
const collectingDescription =
source === "automatic"
? `${scheduleDay ?? "—"}天自动采集中`
: source === "catchup"
? `${scheduleDay ?? "—"}天自动追采中`
: "正在手动采集";
await db.batch([
db
.prepare(
`UPDATE collection_runs
SET schedule_day = COALESCE(schedule_day, ?),
scheduled_at = ?,
status = 'collecting',
status_description = ?,
started_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(scheduleDay, scheduledAt, collectingDescription, run.id),
db
.prepare(
`UPDATE distributions
SET collection_status = 'collecting',
collection_status_description = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(collectingDescription, distributionId),
]);
try {
const { likes, comments, collects } =
await collectXhsMetricsFromMcp(current.publish_url, mcpConfig);
const dayWeight = scheduleDay ?? 1;
const successDescription =
source === "automatic"
? `成功 · 第${dayWeight}天 10:00自动采集`
: source === "catchup"
? `成功 · 第${dayWeight}天自动追采`
: "成功 · 手动采集";
await db.batch([
db
.prepare(
`UPDATE collection_runs
SET status = 'success',
likes = ?,
comments = ?,
collects = ?,
status_description = ?,
completed_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(
likes,
comments,
collects,
successDescription,
run.id,
),
db
.prepare(
`UPDATE distributions
SET latest_likes = ?,
latest_comments = ?,
latest_collects = ?,
collection_status = 'success',
collection_status_description = ?,
collection_updated_at = CURRENT_TIMESTAMP,
last_collection_day = ?,
status = CASE
WHEN exposure IS NOT NULL AND views IS NOT NULL THEN 'complete'
ELSE 'collecting'
END,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(
likes,
comments,
collects,
successDescription,
scheduleDay,
distributionId,
),
]);
return { skipped: false, likes, comments, collects };
} catch (error) {
const message =
error instanceof Error ? error.message : "公开数据采集失败";
await db.batch([
db
.prepare(
`UPDATE collection_runs
SET status = 'failed',
status_description = ?,
completed_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(message, run.id),
db
.prepare(
`UPDATE distributions
SET collection_status = 'failed',
collection_status_description = ?,
collection_updated_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(message, distributionId),
]);
throw error;
}
}
export async function runScheduledCollections(
db: D1Database,
scheduledTimestamp: number,
mcpConfig: CollectionMcpConfig,
) {
return runDueScheduledCollections(
db,
scheduledTimestamp,
mcpConfig,
"automatic",
);
}
export async function runDueScheduledCollections(
db: D1Database,
timestamp: number,
mcpConfig: CollectionMcpConfig,
source: Extract<CollectionSource, "automatic" | "catchup"> = "catchup",
onlyTaskId?: string,
) {
const currentDate = shanghaiDateFromTimestamp(timestamp);
const tasks = await db
.prepare(
`SELECT id, collection_start_date, collection_days
FROM tasks
WHERE collection_start_date IS NOT NULL
AND collection_start_date != ''
AND collection_days IS NOT NULL
AND collection_days != '[]'
AND (? IS NULL OR id = ?)`,
)
.bind(onlyTaskId ?? null, onlyTaskId ?? null)
.all<ScheduledTask>();
let dueTasks = 0;
let attempted = 0;
let succeeded = 0;
let failed = 0;
for (const task of tasks.results) {
const schedules = dueSchedules(
task.collection_start_date,
parseDays(task.collection_days),
timestamp,
);
for (const { scheduleDay, scheduledDate } of schedules) {
dueTasks += 1;
const distributions = await db
.prepare(
`SELECT d.id FROM distributions d
WHERE d.task_id = ?
AND d.publish_url IS NOT NULL
AND d.publish_url != ''
AND NOT EXISTS (
SELECT 1 FROM collection_runs r
WHERE r.distribution_id = d.id
AND r.scheduled_date = ?
AND (
r.status = 'success'
OR (
r.status = 'collecting'
AND r.started_at >= datetime('now', '-30 minutes')
)
OR (
r.status = 'failed'
AND r.completed_at >= datetime('now', '-15 minutes')
)
)
)`,
)
.bind(task.id, scheduledDate)
.all<{ id: string }>();
for (const distribution of distributions.results) {
attempted += 1;
try {
await collectDistributionMetrics(
db,
distribution.id,
scheduledDate,
scheduleDay,
source,
mcpConfig,
);
succeeded += 1;
} catch {
failed += 1;
}
}
}
}
return { currentDate, dueTasks, attempted, succeeded, failed };
}
export async function retryFailedCollections(
db: D1Database,
taskId: string,
mcpConfig: CollectionMcpConfig,
) {
const failedDistributions = await db
.prepare(
`SELECT
d.id,
COALESCE(
(
SELECT r.scheduled_date
FROM collection_runs r
WHERE r.distribution_id = d.id
AND r.status = 'failed'
ORDER BY COALESCE(r.completed_at, r.created_at) DESC
LIMIT 1
),
?
) AS scheduled_date,
(
SELECT r.schedule_day
FROM collection_runs r
WHERE r.distribution_id = d.id
AND r.status = 'failed'
ORDER BY COALESCE(r.completed_at, r.created_at) DESC
LIMIT 1
) AS schedule_day
FROM distributions d
WHERE d.task_id = ?
AND d.publish_url IS NOT NULL
AND d.publish_url != ''
AND d.collection_status = 'failed'
ORDER BY COALESCE(d.collection_updated_at, d.updated_at)
LIMIT 50`,
)
.bind(shanghaiDateFromTimestamp(Date.now()), taskId)
.all<{
id: string;
scheduled_date: string;
schedule_day: number | null;
}>();
let succeeded = 0;
let failed = 0;
for (const distribution of failedDistributions.results) {
try {
await collectDistributionMetrics(
db,
distribution.id,
distribution.scheduled_date,
distribution.schedule_day,
"manual",
mcpConfig,
);
succeeded += 1;
} catch {
failed += 1;
}
}
return {
attempted: failedDistributions.results.length,
succeeded,
failed,
};
}

26
lib/date-utils.ts Normal file
View File

@@ -0,0 +1,26 @@
export function parseStoredDate(value: string) {
const trimmed = value.trim();
const normalized = /^\d{4}-\d{2}-\d{2}$/.test(trimmed)
? `${trimmed}T00:00:00+08:00`
: /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?$/.test(
trimmed,
)
? `${trimmed.replace(" ", "T")}Z`
: trimmed;
return new Date(normalized);
}
export function formatShanghaiDate(
value: string | null | undefined,
withTime = false,
) {
if (!value) return "—";
const date = parseStoredDate(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
month: "2-digit",
day: "2-digit",
...(withTime ? { hour: "2-digit", minute: "2-digit" } : {}),
}).format(date);
}

557
lib/feishu-client.ts Normal file
View File

@@ -0,0 +1,557 @@
const FEISHU_API_ORIGIN = "https://open.feishu.cn";
const MAX_SHEET_ROWS = 5_000;
const MAX_SHEET_COLUMNS = 100;
const MAX_CONTENT_ROWS = 1_000;
const MAX_MEDIA_BYTES = 20_000_000;
export type FeishuBindings = {
FEISHU_APP_ID?: string;
FEISHU_APP_SECRET?: string;
};
export type FeishuSourceImage = {
index: number;
fileToken: string;
width: number | null;
height: number | null;
};
export type FeishuSourceRow = {
sourceRow: number;
title: string;
body: string;
images: FeishuSourceImage[];
};
export type FeishuSource = {
url: string;
wikiToken: string;
spreadsheetToken: string;
sheetId: string;
sheetName: string;
syncedAt: string;
columns: string[];
rows: FeishuSourceRow[];
};
type FetchLike = typeof fetch;
type FeishuEnvelope<T> = {
code?: number;
msg?: string;
data?: T;
tenant_access_token?: string;
expire?: number;
};
type SheetInfo = {
sheet_id?: string;
title?: string;
hidden?: boolean;
resource_type?: string;
grid_properties?: {
row_count?: number;
column_count?: number;
};
};
type CachedAccessToken = {
appId: string;
token: string;
expiresAt: number;
};
let cachedAccessToken: CachedAccessToken | null = null;
export class FeishuSourceError extends Error {
status: number;
constructor(message: string, status = 500) {
super(message);
this.name = "FeishuSourceError";
this.status = status;
}
}
function bindingValue(value: unknown) {
return String(value ?? "").trim();
}
function providerMessage(value: unknown) {
const message = String(value ?? "").trim();
return message
.replace(/https?:\/\/[^\s"'<>]+/gi, "[飞书权限链接]")
.slice(0, 240);
}
function normalizeHeader(value: unknown) {
return cellText(value)
.toLowerCase()
.replace(/[\s()【】[\]_\-—:/\\]+/g, "");
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function cellText(value: unknown): string {
if (value === null || value === undefined) return "";
if (typeof value === "string") return value.trim();
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
if (Array.isArray(value)) {
return value
.map(cellText)
.filter(Boolean)
.join("");
}
if (!isRecord(value) || value.type === "embed-image") return "";
if (typeof value.text === "string") return value.text.trim();
if (typeof value.value === "string") return value.value.trim();
return "";
}
function extractImages(value: unknown, output: FeishuSourceImage[]) {
if (Array.isArray(value)) {
for (const item of value) extractImages(item, output);
return;
}
if (!isRecord(value)) return;
const fileToken = bindingValue(value.fileToken ?? value.image_token);
if (value.type === "embed-image" && fileToken) {
output.push({
index: 0,
fileToken,
width:
typeof value.width === "number"
? value.width
: typeof value.image_width === "number"
? value.image_width
: null,
height:
typeof value.height === "number"
? value.height
: typeof value.image_height === "number"
? value.image_height
: null,
});
}
for (const child of Object.values(value)) {
if (child !== value.fileToken && child !== value.image_token) {
extractImages(child, output);
}
}
}
function columnName(index: number) {
let value = index + 1;
let result = "";
while (value > 0) {
const remainder = (value - 1) % 26;
result = String.fromCharCode(65 + remainder) + result;
value = Math.floor((value - 1) / 26);
}
return result;
}
function headerMatches(header: string, candidates: RegExp[]) {
return candidates.some((candidate) => candidate.test(header));
}
function findHeader(values: unknown[][]) {
let best:
| {
rowIndex: number;
idIndex: number;
titleIndex: number;
bodyIndex: number;
tagsIndex: number;
score: number;
}
| undefined;
for (let rowIndex = 0; rowIndex < Math.min(values.length, 20); rowIndex += 1) {
const headers = (values[rowIndex] ?? []).map(normalizeHeader);
const idIndex = headers.findIndex((header) =>
headerMatches(header, [/^id$/, /作品id/, /序号/, /编号/]),
);
const titleIndex = headers.findIndex((header) =>
headerMatches(header, [/标题/, /题目/]),
);
const bodyIndex = headers.findIndex((header) =>
headerMatches(header, [/笔记内容/, /正文/, /文案/, /^内容$/]),
);
const tagsIndex = headers.findIndex((header) =>
headerMatches(header, [/标签/, /话题/, /^tags?$/]),
);
const score =
(titleIndex >= 0 ? 5 : 0) +
(bodyIndex >= 0 ? 5 : 0) +
(idIndex >= 0 ? 1 : 0) +
(tagsIndex >= 0 ? 1 : 0);
if (!best || score > best.score) {
best = {
rowIndex,
idIndex,
titleIndex,
bodyIndex,
tagsIndex,
score,
};
}
}
if (!best || best.titleIndex < 0 || best.bodyIndex < 0) {
throw new FeishuSourceError(
"没有找到“标题”和“正文/笔记内容”列,请检查飞书表头",
422,
);
}
return best;
}
function parseRows(values: unknown[][]) {
const header = findHeader(values);
const headerRow = values[header.rowIndex] ?? [];
const usedSourceRows = new Set<number>();
const rows: FeishuSourceRow[] = [];
let maxImageCount = 0;
for (
let rowIndex = header.rowIndex + 1;
rowIndex < values.length && rows.length < MAX_CONTENT_ROWS;
rowIndex += 1
) {
const row = values[rowIndex] ?? [];
const title = cellText(row[header.titleIndex]);
if (!title) continue;
const rawBody = cellText(row[header.bodyIndex]);
const tags =
header.tagsIndex >= 0 ? cellText(row[header.tagsIndex]) : "";
const body =
tags && !rawBody.includes(tags)
? [rawBody, tags].filter(Boolean).join("\n\n")
: rawBody;
const idValue =
header.idIndex >= 0
? Number.parseInt(cellText(row[header.idIndex]), 10)
: Number.NaN;
let sourceRow =
Number.isInteger(idValue) && idValue > 0 ? idValue : rowIndex + 1;
if (usedSourceRows.has(sourceRow)) sourceRow = rowIndex + 1;
usedSourceRows.add(sourceRow);
const collectedImages: FeishuSourceImage[] = [];
for (const cell of row) extractImages(cell, collectedImages);
const seenTokens = new Set<string>();
const images = collectedImages
.filter((image) => {
if (seenTokens.has(image.fileToken)) return false;
seenTokens.add(image.fileToken);
return true;
})
.map((image, imageIndex) => ({ ...image, index: imageIndex + 1 }));
maxImageCount = Math.max(maxImageCount, images.length);
rows.push({ sourceRow, title, body, images });
}
if (rows.length === 0) {
throw new FeishuSourceError("表格中没有可导入的有效标题行", 422);
}
const matchedColumns = [
header.idIndex >= 0 ? cellText(headerRow[header.idIndex]) : "",
cellText(headerRow[header.titleIndex]) || "标题",
header.tagsIndex >= 0 ? cellText(headerRow[header.tagsIndex]) : "",
cellText(headerRow[header.bodyIndex]) || "正文",
...Array.from({ length: maxImageCount }, (_, index) => `图片${index + 1}`),
].filter(Boolean);
return {
columns: [...new Set(matchedColumns)],
rows,
};
}
async function jsonEnvelope<T>(
response: Response,
fallbackMessage: string,
): Promise<FeishuEnvelope<T>> {
const text = await response.text();
let payload: FeishuEnvelope<T>;
try {
payload = JSON.parse(text) as FeishuEnvelope<T>;
} catch {
throw new FeishuSourceError(
`${fallbackMessage}(飞书返回了异常响应)`,
502,
);
}
if (!response.ok || (typeof payload.code === "number" && payload.code !== 0)) {
const code = Number(payload.code);
const rawMessage = providerMessage(payload.msg);
if ([99991672, 99991679].includes(code)) {
throw new FeishuSourceError(
"飞书应用缺少电子表格或知识库读取权限,请先在飞书开放平台开通权限",
403,
);
}
if ([131006, 1310213].includes(code) || response.status === 403) {
throw new FeishuSourceError(
"飞书应用没有这张表的访问权限,请在表格中添加该文档应用或将应用加入知识库",
403,
);
}
if ([131005, 1310214].includes(code) || response.status === 404) {
throw new FeishuSourceError("没有找到对应的飞书表格", 404);
}
throw new FeishuSourceError(
rawMessage
? `${fallbackMessage}${rawMessage}`
: `${fallbackMessage}HTTP ${response.status}`,
response.status >= 400 ? response.status : 502,
);
}
return payload;
}
function feishuConfig(bindings: FeishuBindings) {
const appId = bindingValue(bindings.FEISHU_APP_ID);
const appSecret = bindingValue(bindings.FEISHU_APP_SECRET);
if (!appId || !appSecret) {
throw new FeishuSourceError(
"飞书 API 尚未配置,请先配置应用的 App ID 和 App Secret",
503,
);
}
return { appId, appSecret };
}
async function accessToken(
bindings: FeishuBindings,
fetchImpl: FetchLike,
) {
const { appId, appSecret } = feishuConfig(bindings);
if (
cachedAccessToken?.appId === appId &&
cachedAccessToken.expiresAt > Date.now() + 60_000
) {
return cachedAccessToken.token;
}
const response = await fetchImpl(
`${FEISHU_API_ORIGIN}/open-apis/auth/v3/tenant_access_token/internal`,
{
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({ app_id: appId, app_secret: appSecret }),
signal: AbortSignal.timeout(12_000),
},
);
const payload = await jsonEnvelope<never>(response, "获取飞书访问凭证失败");
const token = bindingValue(payload.tenant_access_token);
if (!token) {
throw new FeishuSourceError("飞书没有返回有效访问凭证", 502);
}
cachedAccessToken = {
appId,
token,
expiresAt: Date.now() + Math.max(300, Number(payload.expire) || 7_200) * 1_000,
};
return token;
}
async function feishuGet<T>(
path: string,
token: string,
fetchImpl: FetchLike,
fallbackMessage: string,
params?: URLSearchParams,
) {
const url = new URL(path, FEISHU_API_ORIGIN);
if (params) url.search = params.toString();
const response = await fetchImpl(url.toString(), {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json; charset=utf-8",
},
signal: AbortSignal.timeout(15_000),
});
const payload = await jsonEnvelope<T>(response, fallbackMessage);
if (!payload.data) {
throw new FeishuSourceError(`${fallbackMessage}(响应缺少数据)`, 502);
}
return payload.data;
}
function parseSourceUrl(input: string) {
let url: URL;
try {
url = new URL(input);
} catch {
throw new FeishuSourceError("飞书链接格式无效", 400);
}
const validHost =
url.hostname === "feishu.cn" ||
url.hostname.endsWith(".feishu.cn") ||
url.hostname === "larksuite.com" ||
url.hostname.endsWith(".larksuite.com");
if (!["http:", "https:"].includes(url.protocol) || !validHost) {
throw new FeishuSourceError("请粘贴飞书 Wiki 或电子表格链接", 400);
}
const match = url.pathname.match(/\/(wiki|sheets|spreadsheets)\/([^/?]+)/);
if (!match) {
throw new FeishuSourceError("请粘贴飞书 Wiki 或电子表格链接", 400);
}
return {
url,
kind: match[1],
token: match[2],
requestedSheetId: bindingValue(url.searchParams.get("sheet")),
};
}
export async function readFeishuSource(
input: string,
bindings: FeishuBindings,
fetchImpl: FetchLike = fetch,
): Promise<FeishuSource> {
const parsed = parseSourceUrl(input);
const token = await accessToken(bindings, fetchImpl);
let wikiToken = "";
let spreadsheetToken = parsed.token;
if (parsed.kind === "wiki") {
wikiToken = parsed.token;
const nodeData = await feishuGet<{
node?: { obj_type?: string; obj_token?: string };
}>(
"/open-apis/wiki/v2/spaces/get_node",
token,
fetchImpl,
"读取飞书知识库节点失败",
new URLSearchParams({ token: parsed.token }),
);
if (nodeData.node?.obj_type !== "sheet" || !nodeData.node.obj_token) {
throw new FeishuSourceError("该飞书 Wiki 链接不是电子表格", 422);
}
spreadsheetToken = nodeData.node.obj_token;
}
const sheetData = await feishuGet<{ sheets?: SheetInfo[] }>(
`/open-apis/sheets/v3/spreadsheets/${encodeURIComponent(spreadsheetToken)}/sheets/query`,
token,
fetchImpl,
"读取飞书工作表列表失败",
);
const visibleSheets = (sheetData.sheets ?? []).filter(
(sheet) =>
sheet.sheet_id &&
sheet.resource_type !== "bitable" &&
sheet.hidden !== true,
);
let selectedSheet = parsed.requestedSheetId
? visibleSheets.find((sheet) => sheet.sheet_id === parsed.requestedSheetId)
: undefined;
if (!parsed.requestedSheetId) {
if (visibleSheets.length === 1) {
selectedSheet = visibleSheets[0];
} else if (visibleSheets.length > 1) {
throw new FeishuSourceError(
"该表格包含多个工作表,请打开目标工作表后复制带 sheet 参数的链接",
422,
);
}
}
if (!selectedSheet?.sheet_id) {
throw new FeishuSourceError("链接中的工作表不存在或已隐藏", 404);
}
const rowCount = Math.max(
1,
Math.min(
MAX_SHEET_ROWS,
Number(selectedSheet.grid_properties?.row_count) || 200,
),
);
const columnCount = Math.max(
1,
Math.min(
MAX_SHEET_COLUMNS,
Number(selectedSheet.grid_properties?.column_count) || 26,
),
);
const range = `${selectedSheet.sheet_id}!A1:${columnName(columnCount - 1)}${rowCount}`;
const valuesParams = new URLSearchParams();
valuesParams.append("ranges", range);
const valuesData = await feishuGet<{
valueRanges?: Array<{ values?: unknown[][] }>;
}>(
`/open-apis/sheets/v2/spreadsheets/${encodeURIComponent(spreadsheetToken)}/values_batch_get`,
token,
fetchImpl,
"读取飞书表格内容失败",
valuesParams,
);
const values = valuesData.valueRanges?.[0]?.values;
if (!Array.isArray(values)) {
throw new FeishuSourceError("飞书表格没有返回可读取的单元格", 422);
}
const parsedRows = parseRows(values);
return {
url: parsed.url.toString(),
wikiToken,
spreadsheetToken,
sheetId: selectedSheet.sheet_id,
sheetName: bindingValue(selectedSheet.title) || "未命名工作表",
syncedAt: new Date().toISOString(),
columns: parsedRows.columns,
rows: parsedRows.rows,
};
}
export async function downloadFeishuMedia(
fileToken: string,
bindings: FeishuBindings,
fetchImpl: FetchLike = fetch,
) {
const normalizedToken = bindingValue(fileToken);
if (!/^[A-Za-z0-9_-]{8,240}$/.test(normalizedToken)) {
throw new FeishuSourceError("飞书图片标识无效", 400);
}
const token = await accessToken(bindings, fetchImpl);
const response = await fetchImpl(
`${FEISHU_API_ORIGIN}/open-apis/drive/v1/medias/${encodeURIComponent(normalizedToken)}/download`,
{
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(20_000),
},
);
const declaredSize = Number(response.headers.get("content-length"));
if (Number.isFinite(declaredSize) && declaredSize > MAX_MEDIA_BYTES) {
throw new FeishuSourceError("飞书图片超过20MB无法同步", 413);
}
if (!response.ok) {
throw new FeishuSourceError(
response.status === 403
? "飞书应用没有这张图片的下载权限"
: `下载飞书图片失败HTTP ${response.status}`,
response.status === 403 ? 403 : 502,
);
}
const bytes = await response.arrayBuffer();
if (bytes.byteLength > MAX_MEDIA_BYTES) {
throw new FeishuSourceError("飞书图片超过20MB无法同步", 413);
}
return {
bytes,
contentType:
response.headers.get("content-type")?.split(";")[0] ||
"application/octet-stream",
};
}
export function clearFeishuAccessTokenCacheForTests() {
cachedAccessToken = null;
}

View File

@@ -0,0 +1,828 @@
{
"wikiToken": "BSzxwRbGJi5dWoksauicUjtonks",
"sheetId": "954953",
"sheetName": "打包骑手500篇",
"sourceRange": "B1:E508",
"syncedAt": "2026-07-27T11:30:38.566Z",
"columns": [
"序号(不能改)",
"标题",
"笔记内容(正文+话题)",
"图片1",
"图片2",
"图片3"
],
"rows": [
{
"sourceRow": 1,
"title": "🚗宝妈开窍了!我的“接娃神车”有了新使命✨",
"body": "全职带娃第三年,每天除了接送娃就是在小区里瞎转悠😭\n刷到业主载骑手的帖子突然灵光一闪我这台“接娃神车”后座闲着也是闲着不如发挥点余热\n今天下午在小区门口蹲了十分钟真“打包”到一个送鲜花的某团骑手小哥💐\n他连声说谢谢我心里暖暖的❤ 这可是我三年来第一次觉得,原来我这小区,也需要我!\n这种被需要的感觉比带娃还治愈😭\n\n#宝妈兼职 #小区互助 #打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手",
"images": [
{
"index": 1,
"key": "content-assets/954953/1/1",
"width": 1242,
"height": 1656
},
{
"index": 2,
"key": "content-assets/954953/1/2",
"width": 1080,
"height": 1501
}
]
},
{
"sourceRow": 2,
"title": "🗺️小区“活地图”上线!骑手都看呆了🤩",
"body": "以前是导游,现在是小区专属“活地图”!谁懂啊家人们🤣\n哪栋楼在哪个岔口拐弯哪个减速带最颠我闭着眼都能开到比导航还准\n今天顺路带了个某团骑手小哥从小区门口到目的地三分钟直达\n他下车时惊到失语“姐你对这小区也太熟了吧”\n哈哈那是 姐的青春都献给这几条路啦,能帮到他真的很开心🥳\n\n#小区生活 #顺路带一程 #美团骑手 #打包骑手 #骑手#外卖小哥 #当了一把骑手的骑手",
"images": [
{
"index": 1,
"key": "content-assets/954953/2/1",
"width": 1242,
"height": 1656
},
{
"index": 2,
"key": "content-assets/954953/2/2",
"width": 960,
"height": 1280
}
]
},
{
"sourceRow": 3,
"title": "💖全职妈妈的顺风车,暖到某团骑手心坎里❤️",
"body": "全职妈妈的生活,好像只有娃和家,偶尔也会觉得有点枯燥😮💨\n今天送完娃回家在小区门口看到一个某团骑手小哥电动车停在路边对着手机地图一脸愁容\n我摇下车窗问他是不是迷路了他说第一次来导航绕得头晕😵💫\n一看他要去的楼栋正好在我家附近“上来吧我带你过去”\n小哥有点不好意思但还是坐了上来送到后连声道谢说省了好多时间\n原来能用自己的小车帮到别人比刷一下午剧有意义多了🥰\n\n#全职妈妈 #暖心瞬间 #当了一把骑手的骑手#打包骑手 #骑手 #美团骑手 #外卖小哥",
"images": [
{
"index": 1,
"key": "content-assets/954953/3/1",
"width": 1242,
"height": 1656
}
]
},
{
"sourceRow": 4,
"title": "全职宝妈终于不再是只会带娃的透明人啦!",
"body": "全职带娃3年每天围着娃转日子琐碎又单调👶总觉得自己没什么价值。\n今天接娃放学看见外卖小哥在小区门口急着找楼栋刚好顺路就载了他一程。小哥一个劲说谢谢娃还夸我厉害❤ 。\n原来举手之劳就能帮到别人以后碰到迷路的小哥我都愿意打包你们一程 \n\n#打包骑手 #全职妈妈 #暖心瞬间 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手",
"images": [
{
"index": 1,
"key": "content-assets/954953/4/1",
"width": 792,
"height": 1280
},
{
"index": 2,
"key": "content-assets/954953/4/2",
"width": 951,
"height": 1280
}
]
},
{
"sourceRow": 5,
"title": "🎒等娃间隙,宝妈顺手打包骑手小哥🚗",
"body": "每天下午校门口等娃🏫,无聊刷手机。今天看到一个骑手小哥在路边打电话,看样子是小区里哪栋楼又不好找了。我这 “老司机” 一看就知道他要去哪儿!“小哥,去 12栋吗我顺路带你” 他惊喜地跳上车,送到后连声感谢。等娃的空档期,还能顺手帮个忙,感觉时间都变得有意义了!😊# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈日常 #校门口 #等娃碎片",
"images": [
{
"index": 1,
"key": "content-assets/954953/5/1",
"width": 1242,
"height": 1656
}
]
},
{
"sourceRow": 6,
"title": "跟老公炫耀我的“新工作”,他惊了 😲",
"body": "晚饭时我神神秘秘地跟老公说:“我找到一份新工作了,时间自由,还能带着娃一起干。”他惊得筷子都掉了,问我到底干啥 🥢。我笑着说:“当本小区的野生导航员,专门拯救迷路的外卖小哥!” 😂 他听完哭笑不得,但还是给我竖了个大拇指。这工作虽然没工资,但情绪价值拉满!我愿意打包,做个快乐的“兼职妈妈”,让生活多一点乐趣 🎈。\n\n#打包骑手# #骑手 #美团骑手#外卖小哥##当了一把骑手的骑手",
"images": [
{
"index": 1,
"key": "content-assets/954953/6/1",
"width": 800,
"height": 1420
}
]
},
{
"sourceRow": 7,
"title": "🥳被骑手夸活菩萨!宝妈开心到开花🌸",
"body": "今天在小区门口,又 “打包” 了一个骑手小哥。他要去的那栋楼,正好是我家楼下。送到后,他感激地说:“姐,你真是活菩萨!” 哈哈,被夸得我心花怒放!其实就是举手之劳,但能帮到他们,看到他们轻松一点,我就觉得特别开心。全职妈妈的生活,也能找到自己的价值感!💪# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈生活 #价值感 #暖心日常",
"images": [
{
"index": 1,
"key": "content-assets/954953/7/1",
"width": 1242,
"height": 1656
}
]
},
{
"sourceRow": 8,
"title": "终于找到了一份不耽误接娃的活儿 🎒",
"body": "每次看到那些招聘广告写着“朝九晚五”,我就只能默默叹气。全职妈妈的时间太碎了 🧩。直到我在网上看到了#打包骑手#这个话题,我恍然大悟:这不就是为我量身定制的“兼职”吗!每天接送娃的必经之路上,顺手带个路、刷个门禁,完全不耽误事儿 ⏱️。我愿意打包,用我这碎片化的时间,拼凑出一点对社会的贡献 🧩。\n\n#打包骑手# #骑手 #美团骑手#外卖小哥##当了一把骑手的骑手",
"images": [
{
"index": 1,
"key": "content-assets/954953/8/1",
"width": 313,
"height": 557
}
]
},
{
"sourceRow": 9,
"title": "我闺女今天问我:妈妈你是超人吗 🦸‍♀️",
"body": "今天接闺女放学,路过门岗,我熟练地跟保安打了个招呼,把一个被拦住的骑手带了进来。闺女仰着头问我:“妈妈,你是专门救人的超人吗?” 🥺 听到这句话,我心都化了。全职妈妈在孩子眼里,原来也可以是个闪闪发光的超人 ✨。我愿意打包,为了成为孩子眼里的榜样,把这件充满正能量的“兼职”一直做下去 💪。\n\n#打包骑手# #骑手 #美团骑手#外卖小哥##当了一把骑手的骑手",
"images": [
{
"index": 1,
"key": "content-assets/954953/9/1",
"width": 3576,
"height": 6356
}
]
},
{
"sourceRow": 10,
"title": "我的小电驴,今天迎来了新乘客 🛵",
"body": "买完菜回来,看到一个骑手在小区门口🪫。我二话不说,让他坐上的小电驴后座,一路狂飙把他送到了楼下 💨。平时这后座只载我家那个小神兽,今天迎来了新乘客,感觉还挺奇妙的。原来我的小电驴还能发挥这么大作用!我愿意打包,让我的小电驴成为小区里的“爱心专车” 🚗。\n\n#打包骑手# #骑手 #美团骑手#外卖小哥##当了一把骑手的骑手",
"images": [
{
"index": 1,
"key": "content-assets/954953/10/1",
"width": 960,
"height": 1280
}
]
},
{
"sourceRow": 11,
"title": "在宝妈群里发了条消息,瞬间被点赞 💬",
"body": "刚才在小区的宝妈群里发了条消息:“以后大家遇到找不着路的骑手,就顺手指个路吧,就当给娃积德了。” 没想到瞬间收到了几十个点赞 👍。大家纷纷表示这主意好,不用花钱不用费时,还能教孩子助人为乐。原来全职妈妈们的能量这么大!我愿意打包,和宝妈们一起,把我们小区变成最有爱的地方 🌸。\n\n#打包骑手# #骑手 #美团骑手#外卖小哥##当了一把骑手的骑手",
"images": [
{
"index": 1,
"key": "content-assets/954953/11/1",
"width": 546,
"height": 940
}
]
},
{
"sourceRow": 12,
"title": "今天收到了一份特殊的“工资” 🎁",
"body": "我这“兼职向导”当了一个多星期了,今天帮一个骑手指完路,他突然从口袋里掏出一颗糖递给我家娃:“谢谢阿姨,也谢谢小朋友。” 🍬 看着娃开心的笑脸,我觉得这颗糖比任何工资都珍贵。全职妈妈的价值,不一定非要用金钱来衡量。我愿意打包,为了这份纯粹的感激,继续我的“带路事业” 🌟。\n\n#打包骑手# #骑手 #美团骑手#外卖小哥##当了一把骑手的骑手",
"images": [
{
"index": 1,
"key": "content-assets/954953/12/1",
"width": 313,
"height": 557
}
]
},
{
"sourceRow": 13,
"title": "💖骑手一句谢谢姐!宝妈暖心一整天✨",
"body": "今天在小区门口,又 “打包” 了一个骑手小哥。他要去的那栋楼,正好是我家楼下。送到后,他感激地对我说了声 “谢谢姐!” 就这么简单的一句话,却是我最好的 “勋章”。全职妈妈的生活,有时候会觉得有点枯燥,但这些小小的善意和被需要的感觉,真的能给我带来很多快乐和动力。🥰# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈心情 #暖心瞬间 #宝妈价值",
"images": [
{
"index": 1,
"key": "content-assets/954953/13/1",
"width": 1242,
"height": 1656
}
]
},
{
"sourceRow": 14,
"title": "被婆婆吐槽每天瞎忙,我却乐在其中 👵",
"body": "我婆婆最近总吐槽我,说我每天接个娃还要在小区里到处转悠,瞎忙活 🙄。她不知道,我这是在忙着我的“新事业”呢!帮骑手指路、按电梯、开单元门,这些事虽然微不足道,但让我觉得每天都很充实 🏃‍♀️。我愿意打包,不管别人怎么说,做自己觉得有意义的事,就是最大的快乐 😊。\n\n#打包骑手# #骑手 #美团骑手#外卖小哥##当了一把骑手的骑手",
"images": [
{
"index": 1,
"key": "content-assets/954953/14/1",
"width": 400,
"height": 527
}
]
},
{
"sourceRow": 15,
"title": "这大概是我做过,门槛最低的兼职了 🚪",
"body": "不用投简历,不用面试,只要有一颗热心肠,随时都能上岗!这大概是我这辈子做过门槛最低的兼职了 😂。每天带娃在小区广场玩的时候,顺便眼观六路耳听八方,看到迷路的骑手就上去搭把手 🚶。这种零压力的“工作”太适合全职妈妈了。我愿意打包在这个没有KPI的岗位上散发我所有的热量 ☀️。\n\n#打包骑手# #骑手 #美团骑手#外卖小哥##当了一把骑手的骑手",
"images": [
{
"index": 1,
"key": "content-assets/954953/15/1",
"width": 1080,
"height": 1369
}
]
},
{
"sourceRow": 16,
"title": "今天跟娃一起,完成了一项任务 🏆",
"body": "今天在楼下,我故意考我家大宝:“那个骑手叔叔好像找不到楼栋了,我们带他去好不好?”大宝兴奋地在前面带路,跑得比我还快 🏃‍♂️。看着他自豪的小背影,我觉得这是最好的言传身教。我不仅自己找到了价值,还给娃上了一堂生动的品德课 📚。我愿意打包,带着我的小帮手,一起完成这些温暖的小任务 🌟。\n\n#打包骑手# #骑手 #美团骑手#外卖小哥##当了一把骑手的骑手",
"images": [
{
"index": 1,
"key": "content-assets/954953/16/1",
"width": 552,
"height": 800
}
]
},
{
"sourceRow": 17,
"title": "我这“野生导航”的名号,算是打响了 📢",
"body": "今天有个常来我们小区的骑手大老远看到我就喊“姐8栋怎么走来着我又忘了” 🤣 看来我这“野生导航”的名号,在他们圈子里算是打响了。作为全职妈妈,能被这么信任和依赖,我心里还挺得意的 😎。我愿意打包,继续擦亮我这块金字招牌,做他们最可靠的活地图 🗺️。\n\n#打包骑手# #骑手 #美团骑手#外卖小哥##当了一把骑手的骑手",
"images": [
{
"index": 1,
"key": "content-assets/954953/17/1",
"width": 719,
"height": 1280
}
]
},
{
"sourceRow": 18,
"title": "💖骑手淳朴一句谢!宝妈收获满满幸福✨",
"body": "今天在小区门口,又 “打包” 了一个骑手小哥。他要去的那栋楼,正好是我家楼下。送到后,他感激地对我说了声 “谢谢姐!” 就这么简单的一句话,却是我最好的 “礼物”。全职妈妈的生活,有时候会觉得有点枯燥,但这些小小的善意和被需要的感觉,真的能给我带来很多快乐和动力。🥰# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈心情 #暖心礼物 #宝妈日常",
"images": [
{
"index": 1,
"key": "content-assets/954953/18/1",
"width": 1242,
"height": 1656
}
]
},
{
"sourceRow": 19,
"title": "今天被物业阿姨夸是个“热心肠” 👩‍🦱",
"body": "在小区里帮骑手捡掉在地上的外卖,刚好被保洁阿姨看到了。她笑着夸我:“你这媳妇真是个热心肠,谁家娶了你真有福气。” 😆 虽然是句客套话,但听着就是舒坦!全职妈妈的价值,不就是在这些柴米油盐和热心肠里体现的吗?我愿意打包,做个热心肠的好媳妇、好邻居 🏡。\n\n#打包骑手# #骑手 #美团骑手#外卖小哥##当了一把骑手的骑手",
"images": [
{
"index": 1,
"key": "content-assets/954953/19/1",
"width": 480,
"height": 848
}
]
},
{
"sourceRow": 20,
"title": "我决定,把这当成我的终身事业 🎯",
"body": "虽然这只是一份没有报酬的“兼职”,但我决定把它当成我的终身事业来做!🏆 只要我还在这个小区,只要我还能走得动,我就会一直帮下去。因为这份事业带来的快乐和满足,是任何东西都换不来的。我愿意打包,在这个平凡的岗位上,坚持做一件不平凡的小事 ✨。\n\n#打包骑手# #骑手 #美团骑手#外卖小哥##当了一把骑手的骑手",
"images": [
{
"index": 1,
"key": "content-assets/954953/20/1",
"width": 500,
"height": 688
}
]
},
{
"sourceRow": 21,
"title": "👩‍👧‍👦带娃当小天使!宝妈帮骑手一程❤️",
"body": "今天带娃出门👩‍👧‍👦。在小区门口看到一个骑手小哥。我赶紧停下车:“小哥,去哪儿?我带你一段!” 娃在旁边好奇地看着,还时不时地问我:“妈妈,我们是在帮助别人吗?” 小哥有点不好意思,但还是接受了我的帮助。送到他要去的楼栋,娃还帮我按了电梯。能给娃做个好榜样,言传身教,真好!🥰# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #亲子教育 #宝妈带娃 #小天使",
"images": [
{
"index": 1,
"key": "content-assets/954953/21/1",
"width": 1242,
"height": 1656
}
]
},
{
"sourceRow": 22,
"title": "🚗宝妈小面包,接娃打包两不误🍞",
"body": "谁说只有豪车才能 “打包”?我的家用小面包今天也成了 “打包神器”!在小区门口看到一个骑手小哥,外卖箱里装满了东西,正准备步行进小区。我赶紧停下车:“小哥,去哪儿?我带你!” 他有点惊讶,但还是把外卖箱放到了我的后座。送到楼下,他连声感谢。能用自己的车帮到别人,感觉特别有成就感!🥳# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈用车 #成就感 #小区兼职",
"images": [
{
"index": 1,
"key": "content-assets/954953/22/1",
"width": 1242,
"height": 1656
}
]
},
{
"sourceRow": 23,
"title": "💖骑手笑容太治愈!宝妈越做越开心😊",
"body": "今天在小区门口,又 “打包” 了一个骑手小哥。他要去的那栋楼,正好是我家楼下。送到后,他感激地对我笑了笑,说了声 “谢谢姐!” 就这么简单的一个笑容,却是我最好的 “回报”。全职妈妈的生活,有时候会觉得有点枯燥,但这些小小的善意和被需要的感觉,真的能给我带来很多快乐和动力。🥰# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈心情 #暖心回报 #宝妈日常",
"images": [
{
"index": 1,
"key": "content-assets/954953/23/1",
"width": 1242,
"height": 1656
}
]
},
{
"sourceRow": 24,
"title": "🏡小区坡太陡?宝妈带骑手轻松过坡🚗",
"body": "我们小区坡道又多又陡,每次看骑手小哥骑车往上冲都特别费劲。今天刚好出门,碰到一位小哥正慢慢爬坡,我立马摇下车窗喊他:“顺路,我捎你过去吧!”\n他又惊喜又感激上车后一直说太谢谢了。\n能帮奔波的人少费点力气自己心里也暖暖的。💖# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈帮忙 #小区坡道 #顺路",
"images": [
{
"index": 1,
"key": "content-assets/954953/24/1",
"width": 800,
"height": 1419
}
]
},
{
"sourceRow": 25,
"title": "🌧️宝妈出车!护骑手一路安全🌧️",
"body": "晚风特别舒服,我接宝宝下学,开车刚到小区门口,就看到一位骑手小哥正麻利地穿梭在车流间赶单。\n我摇下车窗喊住他“小哥我顺路捎你一程呀”\n他先是一愣随即笑着上了车一路上还跟我聊起今天跑了好多单特别有干劲。\n送到楼下时他连声道谢整个人都轻松了不少。\n原来一次小小的搭把手就能给奔波的人多一份顺畅。\n愿每一位努力生活的小哥都被温柔以待一路平安顺遂✨。# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #暴雨送餐 #宝妈安全 #暖心",
"images": [
{
"index": 1,
"key": "content-assets/954953/25/1",
"width": 599,
"height": 800
}
]
},
{
"sourceRow": 26,
"title": "👩‍👧‍👦带娃当小帮手!宝妈帮骑手推车🚗",
"body": "早上正好有空带娃一起出门,小家伙全程都要当我的 “小助手”。\n在小区门口碰到一位小哥电动车没电只能推着走我赶紧停下车说帮他一段。\n孩子在旁边认真地看着还奶声奶气问我是不是在做好事到楼下还主动帮按电梯。\n能用这样的小事给孩子做榜样比说多少道理都管用。🥰# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #亲子教育 #宝妈带娃 #小帮手",
"images": [
{
"index": 1,
"key": "content-assets/954953/26/1",
"width": 1046,
"height": 1390
}
]
},
{
"sourceRow": 27,
"title": "🚗宝妈小轿车,接娃打包刚刚好🚗",
"body": "谁说只有豪车才能 “打包”?我的家用代步车今天也成了 “打包神器”!在小区门口看到一个骑手小哥,外卖箱里装满了东西,正准备步行进小区。我赶紧停下车:“小哥,去哪儿?我带你!” 他有点惊讶,但还是把外卖箱放到了我的后座。送到楼下,他连声感谢。能用自己的车帮到别人,感觉特别有成就感!🥳# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈用车 #成就感 #小区兼职",
"images": [
{
"index": 1,
"key": "content-assets/954953/27/1",
"width": 1242,
"height": 1656
}
]
},
{
"sourceRow": 28,
"title": "💖骑手真诚道谢!宝妈心里超温暖✨",
"body": "今天在小区门口,又 “打包” 了一个骑手小哥。他要去的那栋楼,正好是我家楼下。送到后,他感激地对我说了声 “谢谢姐!” 就这么简单的一句话,却是我最好的 “慰藉”。全职妈妈的生活,有时候会觉得有点枯燥,但这些小小的善意和被需要的感觉,真的能给我带来很多快乐和动力。🥰# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈心情 #暖心慰藉 #宝妈日常",
"images": [
{
"index": 1,
"key": "content-assets/954953/28/1",
"width": 576,
"height": 795
}
]
},
{
"sourceRow": 29,
"title": "🏡小区九曲弯?宝妈带骑手抄近路🛣️",
"body": "我们小区里有很多九曲十八弯的小路,每次骑手小哥来送外卖,都要绕好大一圈。今天在小区门口看到一个骑手小哥,看着地图一脸无奈。我赶紧摇下车窗:“小哥,去哪儿?我带你走近路!” 他惊喜地上了车,送到后连声感谢。能帮他们省点时间,少走弯路,我也觉得挺好的。💖# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈指路 #小区弯路 #互助",
"images": [
{
"index": 1,
"key": "content-assets/954953/29/1",
"width": 1080,
"height": 1440
}
]
},
{
"sourceRow": 30,
"title": "宝妈门口出车!给骑手一路开心❄️",
"body": "阳光正好的午后,我开车到小区附近,看到一位骑手小哥正轻快地赶路,正好要进我们小区\n我笑着靠边停车朝他挥挥手“小哥我顺路带你一程呀”\n 他有些惊喜地坐上车,车里暖暖的,一路上都在开心地道谢。\n送到楼下时他连连道谢整个人都轻松了许多。\n 能在平凡的日常里,给努力生活的人多一份便利与温柔,自己心里也满是欢喜。\n愿每一位奔波的小哥都一路顺遂平安喜乐✨\n\n\n# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #雪天送餐 #宝妈暖心 #温暖",
"images": [
{
"index": 1,
"key": "content-assets/954953/30/1",
"width": 1050,
"height": 1396
}
]
},
{
"sourceRow": 31,
"title": "带娃当小棉袄!宝妈帮骑手一程❤️",
"body": "今天带娃出门逛街👩‍👧‍👦,小家伙主动要当我的专属小帮手。\n在小区门口碰到一位骑手小哥正慢悠悠地推着电动车往前走打算就近找地方充电。\n我笑着停下车喊他“小哥我帮你搭一段路吧”\n娃在旁边睁着大眼睛奶声奶气地问“妈妈我们是不是在做好事呀\n到了楼栋口小家伙还踮着脚帮我按了电梯。\n用这样小小的陪伴与举手之劳给孩子上一堂生动的善良课比讲再多道理都管用🥰。# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #亲子教育 #宝妈带娃 #小棉袄",
"images": [
{
"index": 1,
"key": "content-assets/954953/31/1",
"width": 1042,
"height": 1388
}
]
},
{
"sourceRow": 32,
"title": "宝妈代步车,接娃打包超方便🚗",
"body": "开车也要传递温暖~我的家用小轿车今天也变身暖心 “摆渡车” 啦!\n在小区门口碰到一位骑手小哥餐箱满满当当正准备步行进去配送。我立马摇下车窗“小哥上来吧我送你进去”\n他又惊喜又客气小心地把外卖箱放到后座。送到楼下后小哥一个劲地道谢笑容特别真诚。\n能用自己的小车帮奔波的人省点时间、少走点路真的超有成就感# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈用车 #成就感 #小区兼职",
"images": [
{
"index": 1,
"key": "content-assets/954953/32/1",
"width": 1058,
"height": 1398
}
]
},
{
"sourceRow": 33,
"title": "💖骑手朴实道谢!宝妈越做越有动力✨",
"body": "他拎着外卖找不着路口,刚好我熟悉附近路线。指引完,他笑着跟我说了声 “太感谢啦!” 就这么简单的一句话,却是我最暖的 “慰藉”。平凡日常的生活,偶尔会觉得平淡乏味,但这些小小的善意和被需要的感觉,真的能给我带来很多温暖和力量。🥰# 随手助人 #暖心瞬间 #平凡生活 #日常小美好 #人间温暖 #生活感悟",
"images": [
{
"index": 1,
"key": "content-assets/954953/33/1",
"width": 868,
"height": 1280
}
]
},
{
"sourceRow": 34,
"title": "🏡小区陡坡难走?宝妈带骑手轻松过⛰️",
"body": "今天在小区门口正好碰到一个小哥,正爬坡呢。我赶紧摇下车窗喊他:“小哥,你去哪边?我捎你一段!”\n\n他当时可惊喜了马上坐上我的车。送到之后一直跟我说谢谢。能帮他们省点力气我自己心里也暖暖的特别开心。💖\n\n#打包骑手 #骑手 #美团骑手 #外卖小哥 #宝妈帮忙 #小区陡坡 #顺路",
"images": [
{
"index": 1,
"key": "content-assets/954953/34/1",
"width": 443,
"height": 851
}
]
},
{
"sourceRow": 35,
"title": "🌪️大风天宝妈出车!稳送骑手一程🌪️",
"body": "今天在小区门口时,远远看见一个外卖小哥。\n\n我赶紧靠边停下车摇下车窗喊他“小哥快上车吧”\n\n他一开始还有点犹豫后来还是上了车。车里暖烘烘的他轻声说了句谢谢声音被风吹得有些沙哑。\n\n送到楼下后他一个劲地鞠躬道谢。给他一段平稳安稳的路我心里也踏实多了。\n\n真心希望每一位奔波在外的小哥都能平平安安回家。🙏\n\n#打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #大风送餐 #宝妈暖心 #平安出行",
"images": [
{
"index": 1,
"key": "content-assets/954953/35/1",
"width": 624,
"height": 825
}
]
},
{
"sourceRow": 36,
"title": "👩‍👧‍👦带娃当小暖炉!宝妈帮骑手一程❤️",
"body": "今天带娃出门👩‍👧‍👦,开到小区门口,看见一个外卖小哥,我赶紧停下车让他上来,带他进小区送单。\n\n娃在旁边一脸认真地看着还小声问我“妈妈我们是不是在帮助别人呀”我说“是呀我们这叫乐于助人”\n\n既能帮到别人又能给孩子做个好榜样这种言传身教的感觉真的太暖了🥰\n\n#打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #亲子教育 #宝妈带娃 #小暖炉",
"images": [
{
"index": 1,
"key": "content-assets/954953/36/1",
"width": 585,
"height": 840
}
]
},
{
"sourceRow": 37,
"title": "宝妈买菜车,顺手打包骑手好方便🛒",
"body": "谁说非得豪车才能“打包骑手”?\n我这台平平常常的买菜小破车今天直接变身打包神器了🥳\n\n在小区门口碰到个外卖小哥外卖箱在后面放着看样子准备走路进小区送餐。\n我赶紧一脚刹车停边上“上车不稍你一下”\n\n他当时都愣了一下有点意外后来还是开开心心把外卖箱放我后座坐上车了。\n送到楼下以后一个劲儿跟我说谢谢。\n\n其实就是顺手的事儿能用自己的小车帮上别人忙真的超有成就感\n\n#打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈买菜 #成就感 #小区兼职",
"images": [
{
"index": 1,
"key": "content-assets/954953/37/1",
"width": 1048,
"height": 1400
}
]
},
{
"sourceRow": 38,
"title": "💖骑手一句理解!宝妈心里超感动✨",
"body": "今天出门又在小区里碰到一个骑手小哥,看着他抱着一堆外卖匆匆忙忙赶路,我就顺口喊了一句:“小哥,去哪栋?我捎你过去!”\n\n 他一开始还有点不敢相信,笑着坐上了车。\n送到地方他特别真诚地说了句“姐太谢谢你了”\n\n 就这一句话,我心里瞬间暖暖的。\n全职在家带娃的日子每天围着家里转有时候真觉得自己没什么价值有点没劲。\n但每次帮到这些奔波的小哥被人真心实意地需要一次就觉得特别有意义整个人都充满劲儿了。\n原来小小的善意真的能治愈平凡日子里的小疲惫。🥰\n\n #打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈日常 #暖心瞬间 #全职妈妈心情",
"images": [
{
"index": 1,
"key": "content-assets/954953/38/1",
"width": 1728,
"height": 2304
}
]
},
{
"sourceRow": 39,
"title": "小区 Z 字路?宝妈带骑手抄近路🛣️",
"body": "我们小区全是那种拐来拐去的Z字小路外卖小哥进来基本都懵每次都要绕一大圈冤枉路。\n\n今天在小区门口就碰到一个小哥盯着地图一脸无奈一看就是绕晕了。\n我赶紧摇下车窗喊他“小哥你去哪栋我带你抄近路”\n\n他当时眼睛都亮了特别惊喜地上了车。\n送到地方以后一个劲儿跟我说谢谢。\n\n能帮他们省点时间、少走点弯路我自己心里也美滋滋的💖\n\n#打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈指路 #小区Z字路 #邻里互助",
"images": [
{
"index": 1,
"key": "content-assets/954953/39/1",
"width": 1052,
"height": 1394
}
]
},
{
"sourceRow": 40,
"title": "☀️清晨宝妈出车!给骑手满满活力☀️",
"body": "今天清晨,阳光明媚☀️。送娃上学路上,看到一个骑手小哥,精神抖擞地在赶路。我心里一动,赶紧靠边停车:“小哥,去哪儿?我带你一程!” 他犹豫了一下,还是上了车。车里放着轻快的音乐,小哥说了声谢谢。送到楼下,他连连鞠躬。能在这个清晨给他一份活力,也让我自己多了一份安心。希望每个小哥都能平安!🙏# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #清晨送餐 #宝妈活力 #暖心",
"images": [
{
"index": 1,
"key": "content-assets/954953/40/1",
"width": 573,
"height": 840
}
]
},
{
"sourceRow": 41,
"title": "👩‍👧‍👦带娃当小司机!宝妈帮骑手一程🚗",
"body": "带娃在小区遛弯的日常,遇见正在送单的骑手,举手之劳帮他带了路。\n孩子天真的提问、主动按电梯的模样都在告诉我\n善良是可以传承的。\n\n能在平凡日子里给孩子做一个温柔的榜样真的很幸福。\n\n#打包骑手 #外卖小哥 #宝妈心情 #亲子时光",
"images": [
{
"index": 1,
"key": "content-assets/954953/41/1",
"width": 998,
"height": 1330
}
]
},
{
"sourceRow": 42,
"title": "🚗宝妈 小轿车,接娃打包超实用🚗",
"body": "谁说只有豪车才能 “打包骑手” 啊?\n我这台家用 小轿车,今天直接变身打包神器!🥳\n\n在小区门口碰到个外卖小哥外卖箱塞得满满当当看样子打算走路进小区送餐。\n我赶紧停下车喊他“小哥你去哪啊要不要载你”\n\n他当时还愣了一下有点意外后来开开心心把外卖箱放我后座坐上车了。\n送到楼下一个劲儿跟我说谢谢。\n\n其实就是顺手的事儿能用自己的小车帮上忙真的超有成就感\n\n#打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈用车 #成就感 #小区兼职",
"images": [
{
"index": 1,
"key": "content-assets/954953/42/1",
"width": 1039,
"height": 1416
}
]
},
{
"sourceRow": 43,
"title": "💖骑手真心感激!宝妈收获满满幸福✨",
"body": "今天在小区门口,又 “打包” 了一个骑手小哥。他要去的那栋楼,正好是我家楼下。送到后,他感激地对我说了声 “谢谢姐!” 就这么简单的一句话,却是我最好的 “回馈”。全职妈妈的生活,有时候会觉得有点枯燥,但这些小小的善意和被需要的感觉,真的能给我带来很多快乐和动力。🥰# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈心情 #暖心回馈 #宝妈日常",
"images": [
{
"index": 1,
"key": "content-assets/954953/43/1",
"width": 432,
"height": 597
}
]
},
{
"sourceRow": 44,
"title": "🏡小区死胡同?宝妈带骑手抄近路🚧",
"body": "我们小区里有很多死胡同,每次骑手小哥来送外卖,都要绕好大一圈。今天在小区门口看到一个骑手小哥,看着地图一脸无奈。我赶紧摇下车窗:“小哥,去哪儿?我带你走近路!” 他惊喜地上了车,送到后连声感谢。能帮他们省点时间,少走弯路,我也觉得挺好的。💖# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈指路 #小区死胡同 #互助",
"images": [
{
"index": 1,
"key": "content-assets/954953/44/1",
"width": 4472,
"height": 7952
}
]
},
{
"sourceRow": 45,
"title": "宝妈出车!给骑手一路温暖🌧️",
"body": "快到小区门口时,远远看见一个外卖小哥在送外卖,赶紧靠边停车喊他:“小哥,你去哪?我捎你一程!”\n\n 他一开始还有点犹豫,后来还是上了车。\n车里暖气很足他小声说了句谢谢声音都有点哑。\n\n 送到楼下,能给小哥一点温暖,我自己心里也踏实很多。\n\n #打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #小雨送餐 #宝妈暖心 #温暖",
"images": [
{
"index": 1,
"key": "content-assets/954953/45/1",
"width": 1062,
"height": 1411
}
]
},
{
"sourceRow": 46,
"title": "👩‍👧‍👦带娃当小向导!宝妈帮骑手一程❤️",
"body": "刚到小区门口,就看见一个外卖小哥进不去送单,我想这是言传身教的好机会。\n我赶紧停下车问他“小哥你去哪栋我帮你一段”\n小哥一开始还有点不好意思后来也放心地接受了帮忙。\n到楼下的时候娃还主动跑过去帮我按了电梯。\n\n能顺手帮到奔波的人还能给孩子做个最真实的好榜样这种言传身教的感觉真的太暖了🥰\n\n#打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #亲子教育 #宝妈带娃 #小向导",
"images": [
{
"index": 1,
"key": "content-assets/954953/46/1",
"width": 1086,
"height": 1448
}
]
},
{
"sourceRow": 47,
"title": "🚗宝妈小货车,帮骑手运货超方便🚗",
"body": "谁说只有豪车才能 “打包”?我的家用小货车今天也成了 “打包神器”!在小区门口看到一个骑手小哥,外卖箱里装满了东西,正准备步行进小区。我赶紧停下车:“小哥,去哪儿?我带你!” 他有点惊讶,但还是把外卖箱放到了我的后座。送到楼下,他连声感谢。能用自己的车帮到别人,感觉特别有成就感!🥳# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈用车 #成就感 #小区兼职",
"images": [
{
"index": 1,
"key": "content-assets/954953/47/1",
"width": 800,
"height": 1067
}
]
},
{
"sourceRow": 48,
"title": "💖骑手淳朴道谢!宝妈心里甜滋滋✨",
"body": "又顺手 “打包” 了一个骑手小哥。\n他要去的楼栋正好就在我家楼下特别顺路。\n\n送到后他真诚地跟我说了句“谢谢啊”\n\n一句简单的感谢却成了我今天最温暖的收获。\n全职妈妈的日常偶尔会有些枯燥但这份小小的善意和被人需要的感觉真的能带来满满的快乐和力量。\n原来举手之劳也能双向温暖✨🥰\n\n#打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈心情 #暖心收获 #宝妈日常 #平凡生活里的小善意",
"images": [
{
"index": 1,
"key": "content-assets/954953/48/1",
"width": 1076,
"height": 1421
}
]
},
{
"sourceRow": 49,
"title": "🏡小区绿化带挡路?宝妈带骑手抄近路🌳",
"body": "我们小区里有很多绿化带,每次骑手小哥来送外卖,都要绕好大一圈。今天在小区门口看到一个骑手小哥,看着地图一脸无奈。我赶紧摇下车窗:“小哥,去哪儿?我带你走近路!” 他惊喜地上了车,送到后连声感谢。能帮他们省点时间,少走弯路,我也觉得挺好的。💖# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈指路 #小区绿化带 #互助",
"images": [
{
"index": 1,
"key": "content-assets/954953/49/1",
"width": 800,
"height": 1067
}
]
},
{
"sourceRow": 50,
"title": "☀️朝阳里宝妈出车!给骑手一份希望☀️",
"body": "今天朝阳初升☀️,天气也渐渐暖和了。今天送娃回家路上,看到一个骑手小哥,还在赶路。我心里一动,赶紧靠边停车:“小哥,去哪儿?我带你一程!” 他犹豫了一下,还是上了车。车里暖气开着,小哥说了声谢谢,声音有点沙哑。送到楼下,他连连鞠躬。能在这个朝阳中给他一份希望,也让我自己多了一份安心。希望每个小哥都能平安!🙏# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #朝阳送餐 #宝妈暖心 #希望",
"images": [
{
"index": 1,
"key": "content-assets/954953/50/1",
"width": 579,
"height": 772
}
]
},
{
"sourceRow": 51,
"title": "🚗宝妈小巴士,接娃打包超合适🚌",
"body": "我的家用小巴士,今天直接变身暖心打包神器!🥳\n\n在小区门口碰到一个骑手小哥外卖箱装得满满当当打算走路进小区送餐。\n我赶紧停下车喊他“小哥你去哪栋我捎你一段”\n\n他先是一愣有点意外随后把外卖箱放到后座开心地上了车。\n送到楼下后他不停地跟我说谢谢。\n\n不用多厉害的车只是一点小小的善意就能帮到奔波的人。\n这种双向的温暖真的让人快乐又有成就感。💛\n\n#打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈用车 #成就感 #平凡小善意",
"images": [
{
"index": 1,
"key": "content-assets/954953/51/1",
"width": 1086,
"height": 1448
}
]
},
{
"sourceRow": 52,
"title": "💖骑手善意暖心!宝妈越活越有光✨",
"body": "顺手 “打包” 了一个骑手小哥。\n他看了看地址刚好要去我家楼下那栋简直是无缝顺路。\n\n把他送到楼下后小哥特别真诚地说了一句“你人真好”\n就这一句简单又朴实的感谢没有华丽的词藻却像一股暖流一点点滋养着我日常的心情。\n\n其实全职妈妈的生活大多时候都是围着家庭打转重复又琐碎偶尔也会觉得枯燥、找不到价值感。\n但每次这样小小的举手之劳收获一句真心的谢谢感受到自己被需要、能给别人带去方便心里就特别踏实、特别快乐。\n\n这些细碎的善意看似不起眼却在悄悄治愈着平凡的日子也给了我继续温柔生活的满满动力。🥰\n\n#打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈心情 #暖心滋养 #宝妈日常 #平凡生活里的小美好",
"images": [
{
"index": 1,
"key": "content-assets/954953/52/1",
"width": 1072,
"height": 1404
}
]
},
{
"sourceRow": 53,
"title": "🏡地下车库绕晕?宝妈带骑手找对路🅿️",
"body": "我们小区里有几个地下车库,每次骑手小哥来送外卖,都要绕好大一圈。今天在小区门口看到一个骑手小哥,看着地图一脸无奈。我赶紧摇下车窗:“小哥,去哪儿?我带你走近路!” 他惊喜地上了车,送到后连声感谢。能帮他们省点时间,少走弯路,我也觉得挺好的。💖# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈指路 #地下车库 #互助",
"images": [
{
"index": 1,
"key": "content-assets/954953/53/1",
"width": 599,
"height": 800
}
]
},
{
"sourceRow": 54,
"title": "🌙月光下宝妈出车!给骑手一份安心🌙",
"body": "今天月光皎洁🌙,天气也渐渐凉了。今天送娃回家路上,看到一个骑手小哥,还在赶路。我心里一动,赶紧靠边停车:“小哥,去哪儿?我带你一程!” 他犹豫了一下,还是上了车。车里暖气开着,小哥说了声谢谢,声音有点沙哑。送到楼下,他连连鞠躬。能在这个月光中给他一份安心,也让我自己多了一份温暖。希望每个小哥都能平安!🙏# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #月光送餐 #宝妈安心 #温暖",
"images": [
{
"index": 1,
"key": "content-assets/954953/54/1",
"width": 904,
"height": 1200
}
]
},
{
"sourceRow": 55,
"title": "🚗宝妈小跑车,顺路打包骑手超酷🚗",
"body": "我这台家用小跑车,今天也当了一回“骑手的骑手”。\n刚到小区门口看到一个外卖小哥箱子塞得满满当当还要步行进去。那一瞬间就觉得——太辛苦了。\n我直接停下车喊他“小哥去哪栋我顺路带你”\n 他明显愣了一下,但还是把外卖箱放到了后座。\n送到楼下他一路都在说谢谢。\n 其实也就是顺手的事,但那一刻真的有种说不出来的满足感。\n原来帮别人一把比自己开心更开心。🥳\n#骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈日常 #小区生活 #温暖瞬间 #普通人的善意",
"images": [
{
"index": 1,
"key": "content-assets/954953/55/1",
"width": 1058,
"height": 1416
}
]
},
{
"sourceRow": 56,
"title": "💖宝妈动力满满向前冲✨",
"body": "骑手小哥🚗\n他要去的那栋楼刚好就在我家楼下。\n 我顺路带了他一程。\n下车的时候他很认真地说了一句\n “谢谢姐!”\n就这么简单的一句话\n 却莫名让我开心了很久。\n全职妈妈的生活有时候确实会有点重复、甚至有点枯燥。\n 但这些不经意的小瞬间——\n 被需要、被感谢、被回应,\n真的会变成我一天里最有“能量”的时刻🥰\n原来让别人轻松一点\n 自己也会更快乐一点。\n#骑手日常 #外卖小哥 #当了一把骑手的骑手 #宝妈生活 #被需要的感觉 #生活里的小确幸 #普通人的善意",
"images": [
{
"index": 1,
"key": "content-assets/954953/56/1",
"width": 1048,
"height": 1402
}
]
},
{
"sourceRow": 57,
"title": "🏡小区林荫道?宝妈带骑手抄近路🌳",
"body": "我们小区的林荫道特别多🌿\n 看起来很舒服,但对骑手来说,真的太容易绕路了。\n今天在门口看到一个骑手小哥\n 盯着手机地图,一脸“我到底在哪儿”的无奈。\n我忍不住摇下车窗“小哥去哪栋我带你走近路”\n 他愣了一下,然后立马笑了,赶紧上车。\n送到之后他一路都在说谢谢。\n 其实我也没做什么,只是帮他少绕了一圈路。\n但那一刻真的会觉得——\n 自己好像,也在让这个世界更顺一点点💖\n#骑手日常 #外卖小哥 #宝妈生活 #小区日常 #普通人的善意 #生活里的温柔 #互帮互助",
"images": [
{
"index": 1,
"key": "content-assets/954953/57/1",
"width": 1052,
"height": 1400
}
]
},
{
"sourceRow": 58,
"title": "我的小电驴,成了小区的专属摆渡车",
"body": "自从买了这个带儿童座椅的小电驴,接送娃方便多了 。今天送完娃回小区,刚好看到个小哥提着大包小包在找单元门。我直接一脚刹车停在他面前:“上来,姐带你过去!” \n\n小哥愣了一下然后开心地坐了上来。到了楼下他非要送我一瓶水表示感谢。我愿意打包反正车座空着也是空着顺手捎一程这种充满人情味的互动让我觉得全职妈妈的生活也很有趣 。\n\n#打包骑手# #骑手 #美团骑手#外卖小哥##当了一把骑手的骑手",
"images": [
{
"index": 1,
"key": "content-assets/954953/58/1",
"width": 1046,
"height": 1394
}
]
},
{
"sourceRow": 59,
"title": "🎒接娃路上!宝妈顺风车再打包骑手🚗",
"body": "每天下午接娃放学🏫,路上总能遇到急匆匆的骑手小哥。今天看到一个外卖箱都快装不下了,我赶紧停下车:“小哥,去哪儿?我带你!” 他有点不好意思,但还是把外卖放到了我的后座。送到楼下,他连声感谢。能用接娃的空档期帮到别人,感觉自己也挺有用的!😊# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈日常 #接娃顺风车 #顺路",
"images": [
{
"index": 1,
"key": "content-assets/954953/59/1",
"width": 1067,
"height": 1431
}
]
},
{
"sourceRow": 60,
"title": "这大概是我拿过,最满意的“退休金” 💰",
"body": "刚开始送外卖时,有一次因为迷路急哭了,觉得自己什么都做不好 😭。今天在小区里看到个年轻女孩送外卖,眼眶红红的在找路。我赶紧上前,不仅带她找到了地方,还安慰了她几句 💖。🌈。\n\n#打包骑手# #骑手 #美团骑手#外卖小哥##当了一把骑手的骑手",
"images": [
{
"index": 1,
"key": "content-assets/954953/60/1",
"width": 531,
"height": 774
}
]
},
{
"sourceRow": 61,
"title": "🏡小区像迷宫?宝妈带骑手抄近路🗺️",
"body": "我们小区的路,真的很“治愈”——\n 林荫道、弯弯绕绕、看起来很舒服🌿\n 但对骑手来说,其实更像个迷宫。\n今天在小区门口看到一个骑手小哥\n 盯着手机地图来回看,整个人有点懵。\n我一看就知道——肯定是第一次进我们小区。\n我直接摇下车窗“小哥去哪栋我带你走近路”\n 他先是愣了一下,然后立马笑了,赶紧上车。\n一路上我给他指路“这边走可以少绕一圈这条是近路。”\n 他边听边点头,还小声说:“这个小区真的太绕了……”\n送到楼下他连声说谢谢\n 那一刻其实挺有感触的——\n有时候你只是知道一条“近路”\n 对别人来说,就是省下的时间、少走的弯路。\n全职妈妈的生活可能没有太多“高光时刻”\n 但这种被需要的小瞬间,\n真的会让人觉得——\n 自己也在悄悄帮这个世界变得更顺一点点💖# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈指路 #小区迷宫 #互助",
"images": [
{
"index": 1,
"key": "content-assets/954953/61/1",
"width": 456,
"height": 756
}
]
}
]
}

1053
lib/mcp-collection-client.ts Normal file

File diff suppressed because it is too large Load Diff

721
lib/mvp-db.ts Normal file
View File

@@ -0,0 +1,721 @@
import { env } from "cloudflare:workers";
import feishuSnapshot from "./feishu-source-snapshot.json";
type D1ResultRow = Record<string, unknown>;
export function getRawDb(): D1Database {
const database = (env as unknown as { DB?: D1Database }).DB;
if (!database) {
throw new Error("数据库尚未连接");
}
return database;
}
export function getUploadBucket(): R2Bucket {
const bucket = (env as unknown as { UPLOADS?: R2Bucket }).UPLOADS;
if (!bucket) {
throw new Error("文件存储尚未连接");
}
return bucket;
}
export async function ensureSchema(database?: D1Database) {
const db = database ?? getRawDb();
const statements = [
`CREATE TABLE IF NOT EXISTS partners (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
wecom_name TEXT NOT NULL,
owner TEXT NOT NULL DEFAULT '运营组',
claimed_total INTEGER NOT NULL DEFAULT 0,
completed_total INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
brand TEXT NOT NULL,
quantity INTEGER NOT NULL,
claimed_quantity INTEGER NOT NULL DEFAULT 0,
due_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
source_url TEXT NOT NULL DEFAULT '',
source_sheet_id TEXT NOT NULL DEFAULT '',
source_sheet_name TEXT NOT NULL DEFAULT '',
source_synced_at TEXT,
share_token TEXT,
collection_start_date TEXT,
collection_days TEXT NOT NULL DEFAULT '[]',
collection_schedule_updated_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS contents (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT NOT NULL DEFAULT '',
image_assets TEXT NOT NULL DEFAULT '[]',
status TEXT NOT NULL DEFAULT 'available',
source TEXT NOT NULL DEFAULT '飞书内容表',
source_row INTEGER,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY,
platform TEXT NOT NULL DEFAULT '小红书',
platform_uid TEXT NOT NULL,
public_account_id TEXT NOT NULL DEFAULT '',
nickname TEXT NOT NULL,
profile_url TEXT NOT NULL DEFAULT '',
ip_location TEXT NOT NULL DEFAULT '待识别',
followers INTEGER NOT NULL DEFAULT 0,
post_count INTEGER NOT NULL DEFAULT 0,
avg_views INTEGER NOT NULL DEFAULT 0,
first_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS accounts_platform_uid_idx
ON accounts(platform, platform_uid)`,
`CREATE TABLE IF NOT EXISTS claims (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
partner_id TEXT NOT NULL,
claimant_name TEXT NOT NULL,
claim_token TEXT NOT NULL,
quantity INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS delegation_bundles (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
claim_id TEXT NOT NULL,
partner_id TEXT NOT NULL,
label TEXT NOT NULL,
share_token TEXT NOT NULL,
quantity INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
revoked_at TEXT
)`,
`CREATE TABLE IF NOT EXISTS distributions (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
content_id TEXT NOT NULL,
partner_id TEXT NOT NULL,
claim_id TEXT,
delegation_bundle_id TEXT,
account_id TEXT,
publish_url TEXT,
publish_time TEXT,
publish_screenshot_key TEXT,
status TEXT NOT NULL DEFAULT 'claimed',
claimed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
screenshot_key TEXT,
ocr_status TEXT NOT NULL DEFAULT 'none',
exposure INTEGER,
views INTEGER,
d2_likes INTEGER,
d2_comments INTEGER,
d2_collects INTEGER,
d5_likes INTEGER,
d5_comments INTEGER,
d5_collects INTEGER,
d7_likes INTEGER,
d7_comments INTEGER,
d7_collects INTEGER,
latest_likes INTEGER,
latest_comments INTEGER,
latest_collects INTEGER,
collection_status TEXT NOT NULL DEFAULT 'pending',
collection_status_description TEXT,
collection_updated_at TEXT,
last_collection_day INTEGER,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS collection_runs (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
distribution_id TEXT NOT NULL,
scheduled_date TEXT NOT NULL,
schedule_day INTEGER,
scheduled_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
likes INTEGER,
comments INTEGER,
collects INTEGER,
status_description TEXT,
started_at TEXT,
completed_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
];
for (const statement of statements) {
await db.prepare(statement).run();
}
const ensureColumn = async (
table: string,
column: string,
definition: string,
) => {
const info = await db
.prepare(`PRAGMA table_info(${table})`)
.all<{ name: string }>();
if (!info.results.some((item) => item.name === column)) {
await db.prepare(`ALTER TABLE ${table} ADD COLUMN ${definition}`).run();
}
};
await ensureColumn("tasks", "source_url", "source_url TEXT NOT NULL DEFAULT ''");
await ensureColumn(
"tasks",
"source_sheet_id",
"source_sheet_id TEXT NOT NULL DEFAULT ''",
);
await ensureColumn(
"tasks",
"source_sheet_name",
"source_sheet_name TEXT NOT NULL DEFAULT ''",
);
await ensureColumn("tasks", "source_synced_at", "source_synced_at TEXT");
await ensureColumn("tasks", "share_token", "share_token TEXT");
await ensureColumn(
"tasks",
"collection_start_date",
"collection_start_date TEXT",
);
await ensureColumn(
"tasks",
"collection_days",
"collection_days TEXT NOT NULL DEFAULT '[]'",
);
await ensureColumn(
"tasks",
"collection_schedule_updated_at",
"collection_schedule_updated_at TEXT",
);
await ensureColumn("contents", "source_row", "source_row INTEGER");
await ensureColumn(
"contents",
"image_assets",
"image_assets TEXT NOT NULL DEFAULT '[]'",
);
await ensureColumn(
"accounts",
"public_account_id",
"public_account_id TEXT NOT NULL DEFAULT ''",
);
await ensureColumn("distributions", "claim_id", "claim_id TEXT");
await ensureColumn(
"distributions",
"delegation_bundle_id",
"delegation_bundle_id TEXT",
);
await ensureColumn(
"distributions",
"publish_screenshot_key",
"publish_screenshot_key TEXT",
);
await ensureColumn(
"distributions",
"latest_likes",
"latest_likes INTEGER",
);
await ensureColumn(
"distributions",
"latest_comments",
"latest_comments INTEGER",
);
await ensureColumn(
"distributions",
"latest_collects",
"latest_collects INTEGER",
);
await ensureColumn(
"distributions",
"collection_status",
"collection_status TEXT NOT NULL DEFAULT 'pending'",
);
await ensureColumn(
"distributions",
"collection_status_description",
"collection_status_description TEXT",
);
await ensureColumn(
"distributions",
"collection_updated_at",
"collection_updated_at TEXT",
);
await ensureColumn(
"distributions",
"last_collection_day",
"last_collection_day INTEGER",
);
const tasksWithoutShare = await db
.prepare(
"SELECT id FROM tasks WHERE share_token IS NULL OR share_token = ''",
)
.all<{ id: string }>();
for (const task of tasksWithoutShare.results) {
await db
.prepare("UPDATE tasks SET share_token = ? WHERE id = ?")
.bind(crypto.randomUUID().replaceAll("-", ""), task.id)
.run();
}
await db
.prepare(
"CREATE UNIQUE INDEX IF NOT EXISTS tasks_share_token_idx ON tasks(share_token)",
)
.run();
await db
.prepare(
"CREATE UNIQUE INDEX IF NOT EXISTS claims_claim_token_idx ON claims(claim_token)",
)
.run();
await db
.prepare(
`CREATE INDEX IF NOT EXISTS claims_task_partner_created_idx
ON claims(task_id, partner_id, created_at)`,
)
.run();
await db
.prepare(
`CREATE UNIQUE INDEX IF NOT EXISTS delegation_bundles_share_token_idx
ON delegation_bundles(share_token)`,
)
.run();
await db
.prepare(
`CREATE INDEX IF NOT EXISTS delegation_bundles_claim_created_idx
ON delegation_bundles(claim_id, created_at)`,
)
.run();
await db
.prepare(
`CREATE UNIQUE INDEX IF NOT EXISTS collection_runs_distribution_date_idx
ON collection_runs(distribution_id, scheduled_date)`,
)
.run();
await db
.prepare(
`CREATE INDEX IF NOT EXISTS collection_runs_task_date_idx
ON collection_runs(task_id, scheduled_date)`,
)
.run();
await db
.prepare(
`UPDATE distributions
SET latest_likes = COALESCE(d7_likes, d5_likes, d2_likes),
latest_comments = COALESCE(d7_comments, d5_comments, d2_comments),
latest_collects = COALESCE(d7_collects, d5_collects, d2_collects),
collection_status = 'success',
collection_status_description = '历史采集数据已迁移',
collection_updated_at = updated_at,
last_collection_day = CASE
WHEN d7_likes IS NOT NULL THEN 7
WHEN d5_likes IS NOT NULL THEN 5
WHEN d2_likes IS NOT NULL THEN 2
ELSE NULL
END
WHERE latest_likes IS NULL
AND COALESCE(d7_likes, d5_likes, d2_likes) IS NOT NULL`,
)
.run();
const tasksNeedingImages = await db
.prepare(
`SELECT DISTINCT t.id
FROM tasks t
JOIN contents c ON c.task_id = t.id
WHERE t.source_sheet_id = ?
AND (c.image_assets IS NULL OR c.image_assets = '' OR c.image_assets = '[]')`,
)
.bind(feishuSnapshot.sheetId)
.all<{ id: string }>();
for (const task of tasksNeedingImages.results) {
const updates = feishuSnapshot.rows
.filter((row) => row.images.length > 0)
.map((row) =>
db
.prepare(
`UPDATE contents SET image_assets = ?
WHERE task_id = ? AND source_row = ?
AND (image_assets IS NULL OR image_assets = '' OR image_assets = '[]')`,
)
.bind(JSON.stringify(row.images), task.id, row.sourceRow),
);
if (updates.length > 0) await db.batch(updates);
}
}
export async function seedIfEmpty() {
const db = getRawDb();
const row = await db.prepare("SELECT COUNT(*) AS count FROM tasks").first<{
count: number;
}>();
if ((row?.count ?? 0) > 0) return;
const batch = [
db
.prepare(
`INSERT INTO partners
(id, name, wecom_name, owner, claimed_total, completed_total)
VALUES (?, ?, ?, ?, ?, ?)`,
)
.bind("partner-linlin", "林林KOC社群", "林林|母婴社群", "小吴", 24, 18),
db
.prepare(
`INSERT INTO partners
(id, name, wecom_name, owner, claimed_total, completed_total)
VALUES (?, ?, ?, ?, ?, ?)`,
)
.bind("partner-muzi", "木子", "木子同学", "小陈", 8, 7),
db
.prepare(
`INSERT INTO partners
(id, name, wecom_name, owner, claimed_total, completed_total)
VALUES (?, ?, ?, ?, ?, ?)`,
)
.bind("partner-xiaolu", "小鹿内容组", "鹿鹿日常", "小吴", 15, 12),
db
.prepare(
`INSERT INTO tasks
(id, name, brand, quantity, claimed_quantity, due_at, status)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
"task-summer",
"夏日轻盈计划",
"青柠实验室",
30,
9,
"2026-08-05",
"active",
),
db
.prepare(
`INSERT INTO tasks
(id, name, brand, quantity, claimed_quantity, due_at, status)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
"task-living",
"理想生活家",
"木屿家居",
18,
6,
"2026-08-02",
"active",
),
];
const summerTitles = [
"夏天轻松管理身材的小习惯",
"一周清爽饮食记录",
"通勤女生的轻负担早餐",
"周末宅家也要好好吃饭",
"最近让我状态变好的三件事",
"忙碌上班族的饮食搭配",
"清爽一夏的简单生活方式",
"我的夏日冰箱常备清单",
"下班后的低成本幸福感",
"在家也能完成的状态管理",
"夏日办公室好物分享",
"高温天的清爽仪式感",
"一人食也可以很认真",
"近期值得回购的小东西",
"我的轻盈生活观察",
"夏天拒绝疲惫感",
"简单好坚持的日常习惯",
"最近的通勤包里有什么",
"不费力的夏日松弛感",
"周末恢复能量的小计划",
"高效生活的三个小改变",
"一个人的清爽晚餐",
"办公室里的续航秘诀",
"生活需要一点轻盈感",
"最近在坚持的健康习惯",
"从早餐开始认真生活",
"我的低负担下午茶",
"夏日宅家幸福清单",
"忙碌生活中的小确幸",
"值得记录的轻盈一天",
];
const livingTitles = Array.from(
{ length: 18 },
(_, index) => `理想生活家的空间灵感 ${String(index + 1).padStart(2, "0")}`,
);
summerTitles.forEach((title, index) => {
batch.push(
db
.prepare(
`INSERT INTO contents (id, task_id, title, body, status)
VALUES (?, ?, ?, ?, ?)`,
)
.bind(
`content-s-${index + 1}`,
"task-summer",
title,
"来自飞书内容表的完整笔记正文与素材说明。",
index < 9 ? "allocated" : "available",
),
);
});
livingTitles.forEach((title, index) => {
batch.push(
db
.prepare(
`INSERT INTO contents (id, task_id, title, body, status)
VALUES (?, ?, ?, ?, ?)`,
)
.bind(
`content-l-${index + 1}`,
"task-living",
title,
"来自飞书内容表的完整笔记正文与素材说明。",
index < 6 ? "allocated" : "available",
),
);
});
const accountSeeds = [
["account-01", "xhs-8af3", "小满的轻生活", "上海", 12800, 4, 4360],
["account-02", "xhs-2bd8", "橘子汽水日记", "杭州", 8600, 3, 2980],
["account-03", "xhs-7ca1", "木木在成长", "广东", 21400, 2, 7620],
["account-04", "xhs-91ee", "一颗软糖", "江苏", 5200, 2, 1850],
];
accountSeeds.forEach(([id, uid, nickname, ip, followers, posts, avg]) => {
batch.push(
db
.prepare(
`INSERT INTO accounts
(id, platform, platform_uid, nickname, profile_url, ip_location, followers, post_count, avg_views)
VALUES (?, '小红书', ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
id,
uid,
nickname,
`https://www.xiaohongshu.com/user/profile/${uid}`,
ip,
followers,
posts,
avg,
),
);
});
const distributionSeeds = [
[
"dist-01",
"content-s-1",
"partner-linlin",
"account-01",
"https://www.xiaohongshu.com/explore/demo01",
"complete",
118,
24,
43,
168,
31,
61,
232,
38,
86,
18420,
9430,
"recognized",
],
[
"dist-02",
"content-s-2",
"partner-muzi",
"account-02",
"https://www.xiaohongshu.com/explore/demo02",
"collecting",
76,
12,
28,
103,
17,
39,
null,
null,
null,
null,
null,
"none",
],
[
"dist-03",
"content-s-3",
"partner-linlin",
"account-03",
"https://www.xiaohongshu.com/explore/demo03",
"published",
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
"none",
],
[
"dist-04",
"content-l-1",
"partner-xiaolu",
"account-04",
"https://www.xiaohongshu.com/explore/demo04",
"collecting",
42,
8,
16,
69,
12,
24,
91,
17,
35,
null,
null,
"uploaded",
],
];
distributionSeeds.forEach((seed, index) => {
const [
id,
contentId,
partnerId,
accountId,
publishUrl,
status,
d2Likes,
d2Comments,
d2Collects,
d5Likes,
d5Comments,
d5Collects,
d7Likes,
d7Comments,
d7Collects,
exposure,
views,
ocrStatus,
] = seed;
batch.push(
db
.prepare(
`INSERT INTO distributions (
id, task_id, content_id, partner_id, account_id, publish_url,
publish_time, status, claimed_at, ocr_status, exposure, views,
d2_likes, d2_comments, d2_collects,
d5_likes, d5_comments, d5_collects,
d7_likes, d7_comments, d7_collects
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
id,
index < 3 ? "task-summer" : "task-living",
contentId,
partnerId,
accountId,
publishUrl,
`2026-07-${String(22 + index).padStart(2, "0")} 10:30:00`,
status,
`2026-07-${String(20 + index).padStart(2, "0")} 09:00:00`,
ocrStatus,
exposure,
views,
d2Likes,
d2Comments,
d2Collects,
d5Likes,
d5Comments,
d5Collects,
d7Likes,
d7Comments,
d7Collects,
),
);
});
for (let index = 4; index < 9; index += 1) {
batch.push(
db
.prepare(
`INSERT INTO distributions
(id, task_id, content_id, partner_id, status, claimed_at)
VALUES (?, 'task-summer', ?, 'partner-linlin', 'claimed', ?)`,
)
.bind(
`dist-pending-${index}`,
`content-s-${index + 1}`,
`2026-07-${String(24 + (index % 2)).padStart(2, "0")} 11:00:00`,
),
);
}
await db.batch(batch);
}
export async function getDashboardData() {
const db = getRawDb();
const [partnersResult, tasksResult, accountsResult, distributionsResult] =
await Promise.all([
db.prepare("SELECT * FROM partners ORDER BY created_at DESC").all(),
db.prepare("SELECT * FROM tasks ORDER BY created_at DESC").all(),
db.prepare("SELECT * FROM accounts ORDER BY last_seen_at DESC").all(),
db
.prepare(
`SELECT
d.*,
c.title AS content_title,
p.name AS partner_name,
a.nickname AS account_nickname,
a.platform AS account_platform,
t.name AS task_name,
t.brand AS task_brand,
t.due_at AS due_at
FROM distributions d
JOIN contents c ON c.id = d.content_id
JOIN partners p ON p.id = d.partner_id
JOIN tasks t ON t.id = d.task_id
LEFT JOIN accounts a ON a.id = d.account_id
ORDER BY d.updated_at DESC, d.claimed_at DESC`,
)
.all(),
]);
return {
partners: partnersResult.results as D1ResultRow[],
tasks: tasksResult.results as D1ResultRow[],
accounts: accountsResult.results as D1ResultRow[],
distributions: distributionsResult.results as D1ResultRow[],
portal_url:
(env as unknown as { KOC_PORTAL_URL?: string }).KOC_PORTAL_URL ?? "",
};
}
export function uid(prefix: string) {
return `${prefix}-${crypto.randomUUID().slice(0, 8)}`;
}
export function hashText(value: string) {
let hash = 2166136261;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return Math.abs(hash >>> 0).toString(36);
}

41
lib/partner-cors.ts Normal file
View File

@@ -0,0 +1,41 @@
import { env } from "cloudflare:workers";
function allowedOrigin(request: Request) {
const origin = request.headers.get("origin");
if (!origin) return null;
const portalOrigin = String(
(env as unknown as { KOC_PORTAL_URL?: string }).KOC_PORTAL_URL ?? "",
).replace(/\/$/, "");
return origin === portalOrigin || origin === "http://localhost:3000"
? origin
: null;
}
export function withPartnerCors(request: Request, response: Response) {
const origin = allowedOrigin(request);
if (origin) {
response.headers.set("Access-Control-Allow-Origin", origin);
response.headers.set("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
response.headers.set(
"Access-Control-Allow-Headers",
[
"Content-Type",
"X-KOC-Task",
"X-KOC-Claim",
"X-KOC-Delegation",
"X-KOC-Distribution",
"X-KOC-File-Name",
"X-KOC-Upload-Kind",
"X-KOC-OCR-Exposure",
"X-KOC-OCR-Views",
"X-KOC-OCR-Status",
].join(", "),
);
response.headers.append("Vary", "Origin");
}
return response;
}
export function partnerOptions(request: Request) {
return withPartnerCors(request, new Response(null, { status: 204 }));
}

29
lib/partner-utils.ts Normal file
View File

@@ -0,0 +1,29 @@
import { hashText } from "./mvp-db";
import {
extractXhsPublishUrl,
safeHttpUrl,
} from "./publish-url";
export { extractXhsPublishUrl } from "./publish-url";
export function accountFromPublishLink(input: string, nickname: string) {
const url = safeHttpUrl(extractXhsPublishUrl(input));
if (!url) return null;
const platform =
url.hostname.includes("xiaohongshu") || url.hostname.includes("xhslink")
? "小红书"
: "其他平台";
const noteId =
url.pathname.match(
/\/(?:discovery\/item|explore)\/([A-Za-z0-9_-]{8,80})/,
)?.[1] ?? "";
const platformUid = `pending-${hashText(
noteId || `${url.origin}${url.pathname}`,
)}`;
return {
platform,
platformUid,
nickname,
profileUrl: "",
};
}

27
lib/publish-url.ts Normal file
View File

@@ -0,0 +1,27 @@
export function safeHttpUrl(input: string) {
try {
const url = new URL(input);
if (!["http:", "https:"].includes(url.protocol)) return null;
return url;
} catch {
return null;
}
}
export function extractXhsPublishUrl(input: string) {
const candidates =
input.match(/https?:\/\/[^\s<>"',。!?;:、【】()]+/gi) ?? [];
for (const candidate of candidates) {
const cleaned = candidate.replace(/[.,!?;:~)\]}]+$/g, "");
const url = safeHttpUrl(cleaned);
if (!url) continue;
const hostname = url.hostname.toLowerCase();
const isXhs =
hostname === "xiaohongshu.com" ||
hostname.endsWith(".xiaohongshu.com") ||
hostname === "xhslink.cn" ||
hostname.endsWith(".xhslink.cn");
if (isXhs) return url.toString();
}
return "";
}

7
next.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

11169
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

41
package.json Normal file
View File

@@ -0,0 +1,41 @@
{
"name": "site-creator-vinext-starter",
"version": "0.1.0",
"private": true,
"engines": {
"node": ">=22.13.0"
},
"scripts": {
"dev": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext dev",
"build": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext build",
"start": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext start",
"test": "npm run build && node --test tests/*.test.mjs",
"lint": "eslint . --ignore-pattern dist --ignore-pattern .next",
"db:generate": "drizzle-kit generate"
},
"dependencies": {
"drizzle-orm": "0.45.2",
"next": "16.2.6",
"react": "19.2.6",
"react-dom": "19.2.6"
},
"devDependencies": {
"@cloudflare/vite-plugin": "1.37.1",
"@tailwindcss/postcss": "4.2.1",
"@types/node": "22.19.19",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.2",
"@vitejs/plugin-rsc": "0.5.26",
"drizzle-kit": "0.31.10",
"eslint": "9.39.4",
"eslint-config-next": "16.2.6",
"react-server-dom-webpack": "19.2.6",
"tailwindcss": "4.2.1",
"typescript": "5.9.3",
"vinext": "0.0.50",
"vite": "8.0.13",
"wrangler": "4.92.0"
},
"type": "module"
}

7
postcss.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

5
public/favicon.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="32" height="32" rx="9" fill="#172D27"/>
<path d="M9 8V24M9 16L20 8M9 16L21 24" stroke="#68D0A6" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="23.5" cy="9.5" r="2.5" fill="#F39A70"/>
</svg>

After

Width:  |  Height:  |  Size: 338 B

1
public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
public/og.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

1
public/window.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1,43 @@
import { readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
const [, , inputPath, outputPath] = process.argv;
if (!inputPath || !outputPath) {
throw new Error("Usage: node scripts/build-feishu-snapshot.mjs <input.json> <output.json>");
}
const payload = JSON.parse(await readFile(resolve(inputPath), "utf8"));
const sheet = payload?.data?.sheets?.[0];
if (!sheet || !Array.isArray(sheet.data)) {
throw new Error("Invalid lark-cli +table-get payload");
}
const rows = sheet.data
.map((row, index) => ({
sourceRow: Number(row[0]) || index + 1,
title: String(row[2] ?? "").trim(),
body: String(row[3] ?? "").trim(),
}))
.filter((row) => row.title);
const snapshot = {
wikiToken: "BSzxwRbGJi5dWoksauicUjtonks",
sheetId: "954953",
sheetName: sheet.name,
sourceRange: sheet.range,
syncedAt: new Date().toISOString(),
columns: [
"序号(不能改)",
"标题",
"笔记内容(正文+话题)",
"图片1",
"图片2",
"图片3",
],
rows,
};
await writeFile(resolve(outputPath), `${JSON.stringify(snapshot, null, 2)}\n`, "utf8");
console.log(`Wrote ${rows.length} rows to ${outputPath}`);

View File

@@ -0,0 +1,315 @@
import { execFile } from "node:child_process";
import {
mkdir,
readFile,
readdir,
stat,
writeFile,
} from "node:fs/promises";
import { extname, resolve } from "node:path";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const projectRoot = resolve(import.meta.dirname, "..");
const snapshotPath = resolve(projectRoot, "lib/feishu-source-snapshot.json");
const downloadDir = "/private/tmp/koc-feishu-images-954953";
const uploadOriginArg = process.argv.find((item) =>
item.startsWith("--upload-origin="),
);
const uploadOrigin = uploadOriginArg
? uploadOriginArg.slice("--upload-origin=".length).replace(/\/$/, "")
: "";
const internalToken =
process.env.KOC_ADMIN_INTERNAL_TOKEN ??
process.env.ADMIN_INTERNAL_TOKEN ??
"";
const reuseDownloads = process.argv.includes("--reuse-downloads");
const fromRowArg = process.argv.find((item) => item.startsWith("--from-row="));
const fromRow = fromRowArg
? Math.max(1, Number(fromRowArg.slice("--from-row=".length)) || 1)
: 1;
if (uploadOrigin && !internalToken) {
throw new Error(
"KOC_ADMIN_INTERNAL_TOKEN is required when --upload-origin is provided",
);
}
const snapshot = JSON.parse(await readFile(snapshotPath, "utf8"));
const sourceUrl =
"https://eodzc79n5l.feishu.cn/wiki/BSzxwRbGJi5dWoksauicUjtonks?from=from_copylink&sheet=954953";
const quietEnv = {
...process.env,
LARKSUITE_CLI_NO_UPDATE_NOTIFIER: "1",
LARKSUITE_CLI_NO_SKILLS_NOTIFIER: "1",
};
await mkdir(downloadDir, { recursive: true });
const { stdout: cellsStdout } = await execFileAsync(
"lark-cli",
[
"sheets",
"+cells-get",
"--url",
sourceUrl,
"--sheet-id",
snapshot.sheetId,
"--range",
"F2:H62",
"--include",
"value",
"--max-chars",
"500000",
"--format",
"json",
],
{
cwd: projectRoot,
env: quietEnv,
maxBuffer: 2_000_000,
},
);
const cellsPayload = JSON.parse(cellsStdout);
if (!cellsPayload.ok || cellsPayload.data?.has_more) {
throw new Error("Feishu image cells were not returned completely");
}
const range = cellsPayload.data?.ranges?.[0];
if (!range || range.truncated || range.actual_range !== "F2:H62") {
throw new Error(
`Unexpected Feishu image range: ${range?.actual_range ?? "missing"}`,
);
}
const sourceRows = new Map(
snapshot.rows.map((row) => [Number(row.sourceRow), row]),
);
const assets = [];
for (let rowIndex = 0; rowIndex < range.cells.length; rowIndex += 1) {
const sheetRow = Number(range.row_indices[rowIndex]);
const sourceRow = sheetRow - 1;
if (!sourceRows.has(sourceRow)) continue;
const cells = range.cells[rowIndex] ?? [];
for (let columnIndex = 0; columnIndex < cells.length; columnIndex += 1) {
const column = range.col_indices[columnIndex];
const imageIndex = ["F", "G", "H"].indexOf(column) + 1;
if (imageIndex < 1) continue;
const richText = cells[columnIndex]?.rich_text ?? [];
const image = richText.find((item) => item.type === "embed-image");
if (!image?.image_token) continue;
assets.push({
sourceRow,
imageIndex,
token: image.image_token,
width: Number(image.image_width) || null,
height: Number(image.image_height) || null,
key: `content-assets/${snapshot.sheetId}/${sourceRow}/${imageIndex}`,
});
}
}
async function downloadAsset(asset) {
const baseName = `row-${asset.sourceRow}-image-${asset.imageIndex}`;
if (reuseDownloads) {
const existingName = (await readdir(downloadDir)).find((name) =>
name.startsWith(`${baseName}.`),
);
if (existingName) {
const localPath = resolve(downloadDir, existingName);
const extension = extname(existingName).toLowerCase();
const fileInfo = await stat(localPath);
return {
...asset,
localPath,
contentType:
extension === ".png"
? "image/png"
: extension === ".webp"
? "image/webp"
: "image/jpeg",
sizeBytes: fileInfo.size,
};
}
}
const { stdout } = await execFileAsync(
"lark-cli",
[
"docs",
"+media-download",
"--token",
asset.token,
"--output",
`./${baseName}`,
"--overwrite",
],
{
cwd: downloadDir,
env: quietEnv,
maxBuffer: 200_000,
},
);
const result = JSON.parse(stdout.slice(stdout.indexOf("{")));
if (!result.ok || !result.data?.saved_path) {
throw new Error(
`Failed to download row ${asset.sourceRow} image ${asset.imageIndex}`,
);
}
return {
...asset,
localPath: result.data.saved_path,
contentType: result.data.content_type || "application/octet-stream",
sizeBytes: Number(result.data.size_bytes) || 0,
};
}
const downloaded = [];
const queue = [...assets];
const workers = Array.from({ length: 4 }, async () => {
while (queue.length > 0) {
const asset = queue.shift();
if (!asset) return;
downloaded.push(await downloadAsset(asset));
}
});
await Promise.all(workers);
downloaded.sort(
(left, right) =>
left.sourceRow - right.sourceRow || left.imageIndex - right.imageIndex,
);
for (const row of snapshot.rows) {
row.images = downloaded
.filter((asset) => asset.sourceRow === Number(row.sourceRow))
.map((asset) => ({
index: asset.imageIndex,
key: asset.key,
width: asset.width,
height: asset.height,
}));
}
await writeFile(snapshotPath, `${JSON.stringify(snapshot, null, 2)}\n`, "utf8");
if (uploadOrigin) {
const sharp = (await import("sharp")).default;
const uploadConcurrency = new URL(uploadOrigin).hostname === "localhost" ? 4 : 1;
async function prepareUpload(asset) {
const original = await readFile(asset.localPath);
if (original.byteLength < 800_000) {
return {
bytes: original,
contentType: asset.contentType,
extension: extname(asset.localPath) || ".bin",
};
}
let quality = 86;
let compressed = await sharp(original)
.rotate()
.flatten({ background: "#ffffff" })
.resize({
width: 1800,
height: 2200,
fit: "inside",
withoutEnlargement: true,
})
.jpeg({ quality, mozjpeg: true })
.toBuffer();
while (compressed.byteLength > 800_000 && quality > 58) {
quality -= 7;
compressed = await sharp(original)
.rotate()
.flatten({ background: "#ffffff" })
.resize({
width: 1600,
height: 2000,
fit: "inside",
withoutEnlargement: true,
})
.jpeg({ quality, mozjpeg: true })
.toBuffer();
}
return {
bytes: compressed,
contentType: "image/jpeg",
extension: ".jpg",
};
}
const uploadQueue = downloaded.filter((asset) => asset.sourceRow >= fromRow);
const uploadWorkers = Array.from({ length: uploadConcurrency }, async () => {
while (uploadQueue.length > 0) {
const asset = uploadQueue.shift();
if (!asset) return;
const prepared = await prepareUpload(asset);
let uploaded = false;
let lastError = "unknown error";
for (let attempt = 1; attempt <= 3; attempt += 1) {
const form = new FormData();
form.append("sheetId", snapshot.sheetId);
form.append("sourceRow", String(asset.sourceRow));
form.append("imageIndex", String(asset.imageIndex));
form.append(
"file",
new File(
[prepared.bytes],
`image-${asset.imageIndex}${prepared.extension}`,
{
type: prepared.contentType,
},
),
);
const response = await fetch(
`${uploadOrigin}/api/content-image-upload`,
{
method: "POST",
headers: {
"X-KOC-Admin-Token": internalToken,
},
body: form,
},
);
const responseText = await response.text();
let result = {};
try {
result = JSON.parse(responseText);
} catch {
result = { error: responseText || `HTTP ${response.status}` };
}
if (response.ok) {
uploaded = true;
break;
}
lastError = result.error ?? `HTTP ${response.status}`;
if (response.status < 500 && response.status !== 404) break;
await new Promise((resolveDelay) =>
setTimeout(resolveDelay, attempt * 750),
);
}
if (!uploaded) {
throw new Error(
`Upload failed for row ${asset.sourceRow} image ${asset.imageIndex}: ${lastError}`,
);
}
}
});
await Promise.all(uploadWorkers);
}
const totalBytes = downloaded.reduce(
(sum, asset) => sum + asset.sizeBytes,
0,
);
console.log(
JSON.stringify({
rows: snapshot.rows.length,
images: downloaded.length,
totalBytes,
uploaded: Boolean(uploadOrigin),
uploadedImages: uploadOrigin
? downloaded.filter((asset) => asset.sourceRow >= fromRow).length
: 0,
downloadDir,
}),
);

16
tests/date-utils.test.mjs Normal file
View File

@@ -0,0 +1,16 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
formatShanghaiDate,
parseStoredDate,
} from "../lib/date-utils.ts";
test("treats D1 CURRENT_TIMESTAMP values as UTC and displays Beijing time", () => {
const stored = "2026-07-29 05:36:00";
assert.equal(parseStoredDate(stored).toISOString(), "2026-07-29T05:36:00.000Z");
assert.match(formatShanghaiDate(stored, true), /07\/29.*13:36/);
});
test("keeps date-only deadlines on the intended Shanghai calendar date", () => {
assert.match(formatShanghaiDate("2026-08-12"), /08\/12/);
});

View File

@@ -0,0 +1,177 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
clearFeishuAccessTokenCacheForTests,
readFeishuSource,
} from "../lib/feishu-client.ts";
const bindings = {
FEISHU_APP_ID: "cli_test",
FEISHU_APP_SECRET: "secret_test",
};
function apiResponse(data) {
return Response.json({ code: 0, msg: "success", data });
}
function fakeFeishu(options = {}) {
const calls = [];
const fetchImpl = async (input, init = {}) => {
const url = new URL(String(input));
calls.push({ url, init });
if (url.pathname.endsWith("/tenant_access_token/internal")) {
return Response.json({
code: 0,
msg: "success",
tenant_access_token: "tenant-test",
expire: 7200,
});
}
if (url.pathname.endsWith("/wiki/v2/spaces/get_node")) {
return apiResponse({
node: {
obj_type: "sheet",
obj_token: "spreadsheet-test",
},
});
}
if (url.pathname.endsWith("/sheets/query")) {
return apiResponse({
sheets:
options.sheets ??
[
{
sheet_id: "sheet-one",
title: "内容池",
resource_type: "sheet",
hidden: false,
grid_properties: { row_count: 20, column_count: 10 },
},
],
});
}
if (url.pathname.endsWith("/values_batch_get")) {
return apiResponse({
valueRanges: [
{
values: [
["作品ID", "标题", "标签", "正文", null, null],
[
7,
"一篇测试笔记",
"#测试 #KOC",
"测试正文",
{
type: "embed-image",
fileToken: "file-token-one",
width: 1080,
height: 1440,
},
[
{
type: "embed-image",
fileToken: "file-token-two",
width: 1080,
height: 1440,
},
],
],
[8, "", "", "空标题不会导入"],
],
},
],
});
}
throw new Error(`Unexpected request: ${url.pathname}`);
};
return { calls, fetchImpl };
}
test("resolves a wiki sheet and imports title, body, tags, and all images", async () => {
clearFeishuAccessTokenCacheForTests();
const { calls, fetchImpl } = fakeFeishu();
const source = await readFeishuSource(
"https://tenant.feishu.cn/wiki/wiki-test",
bindings,
fetchImpl,
);
assert.equal(source.spreadsheetToken, "spreadsheet-test");
assert.equal(source.sheetId, "sheet-one");
assert.equal(source.sheetName, "内容池");
assert.equal(source.rows.length, 1);
assert.equal(source.rows[0].sourceRow, 7);
assert.equal(source.rows[0].body, "测试正文\n\n#测试 #KOC");
assert.deepEqual(
source.rows[0].images.map((image) => image.fileToken),
["file-token-one", "file-token-two"],
);
assert.deepEqual(source.columns, [
"作品ID",
"标题",
"标签",
"正文",
"图片1",
"图片2",
]);
const valuesCall = calls.find((call) =>
call.url.pathname.endsWith("/values_batch_get"),
);
assert.equal(valuesCall.url.searchParams.get("ranges"), "sheet-one!A1:J20");
});
test("requires an exact sheet link when a workbook has multiple visible sheets", async () => {
clearFeishuAccessTokenCacheForTests();
const { fetchImpl } = fakeFeishu({
sheets: [
{
sheet_id: "one",
title: "内容A",
resource_type: "sheet",
hidden: false,
},
{
sheet_id: "two",
title: "内容B",
resource_type: "sheet",
hidden: false,
},
],
});
await assert.rejects(
readFeishuSource(
"https://tenant.feishu.cn/wiki/wiki-test",
bindings,
fetchImpl,
),
/包含多个工作表/,
);
});
test("rejects a wiki node that is not a spreadsheet", async () => {
clearFeishuAccessTokenCacheForTests();
const fetchImpl = async (input) => {
const url = new URL(String(input));
if (url.pathname.endsWith("/tenant_access_token/internal")) {
return Response.json({
code: 0,
msg: "success",
tenant_access_token: "tenant-test",
expire: 7200,
});
}
return apiResponse({
node: { obj_type: "docx", obj_token: "document-test" },
});
};
await assert.rejects(
readFeishuSource(
"https://tenant.feishu.cn/wiki/wiki-test",
bindings,
fetchImpl,
),
/不是电子表格/,
);
});

View File

@@ -0,0 +1,510 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
collectXhsMetricsFromMcp,
resolveCollectionMcpConfig,
resolveXhsPublicAccountDetails,
resolveXhsPublicAccountId,
resolveXhsAccountProfileFromMcp,
resolveXhsProfileDetailsFromMcp,
xhsNoteIdFromUrl,
} from "../lib/mcp-collection-client.ts";
function sse(payload, options = {}) {
return new Response(
`event: message\ndata: ${JSON.stringify(payload)}\n\n`,
{
status: 200,
...options,
headers: {
"content-type": "text/event-stream",
...(options.headers ?? {}),
},
},
);
}
function toolEnvelope(payload, isError = false) {
return {
jsonrpc: "2.0",
id: "tool-call",
result: {
isError,
content: [
{
type: "text",
text: JSON.stringify(payload),
},
],
},
};
}
function createFakeMcp(toolResults) {
let toolIndex = 0;
const calls = [];
const fetchImpl = async (url, init) => {
const body = JSON.parse(init.body);
calls.push({ url: String(url), body, headers: new Headers(init.headers) });
if (body.method === "initialize") {
return sse(
{
jsonrpc: "2.0",
id: body.id,
result: {
protocolVersion: "2025-03-26",
capabilities: { tools: { listChanged: false } },
serverInfo: { name: "ai-tool-center", version: "test" },
},
},
{ headers: { "mcp-session-id": "session-test" } },
);
}
if (body.method === "notifications/initialized") {
return new Response("", { status: 202 });
}
if (body.method === "tools/call") {
const result = toolResults[toolIndex];
toolIndex += 1;
return sse(result);
}
throw new Error(`Unexpected MCP method: ${body.method}`);
};
return { calls, fetchImpl };
}
test("collects likes, comments and favorites from the verified MCP shape", async () => {
const { calls, fetchImpl } = createFakeMcp([
toolEnvelope({
http_status: 200,
response: {
code: 200,
success: true,
msg: "获取内容详情成功",
data: {
likes: "483",
comments: "41",
collects: "519",
},
},
}),
]);
const result = await collectXhsMetricsFromMcp(
"https://www.xiaohongshu.com/discovery/item/test?xsec_token=valid",
{
endpoint: "https://collector.example/mcp",
key: "test-key",
},
fetchImpl,
);
assert.deepEqual(result, {
likes: 483,
comments: 41,
collects: 519,
});
assert.equal(calls.length, 3);
assert.equal(calls[2].body.params.name, "fetch_content_detail");
assert.equal(calls[2].body.params.arguments.include_comments, false);
assert.equal(calls[2].headers.get("mcp-session-id"), "session-test");
assert.equal(new URL(calls[0].url).searchParams.get("key"), "test-key");
});
test("resolves the real XHS account profile from a submitted note link", async () => {
const { calls, fetchImpl } = createFakeMcp([
toolEnvelope({
response: {
code: 200,
success: true,
data: {
note_detail: {
products: [
{
content_tags: [
{
notes: [
{
ip_location: "重庆",
note_id: "6a671108000000000f004bef",
user: {
nickname: "N我的麻辣烫好了吗",
profile_url:
"https://www.xiaohongshu.com/user/profile/6905cbca0000000037009f49",
red_id: "94329495984",
user_id: "6905cbca0000000037009f49",
},
},
],
},
],
},
],
},
},
},
}),
toolEnvelope({
response: {
code: 200,
success: true,
data: {
fans_count: "734",
},
},
}),
]);
const profile = await resolveXhsAccountProfileFromMcp(
"https://www.xiaohongshu.com/discovery/item/6a671108000000000f004bef",
"回填昵称",
{
endpoint: "https://collector.example/mcp",
key: "test-key",
},
fetchImpl,
);
assert.deepEqual(profile, {
platformUid: "6905cbca0000000037009f49",
nickname: "N我的麻辣烫好了吗",
profileUrl:
"https://www.xiaohongshu.com/user/profile/6905cbca0000000037009f49",
redId: "94329495984",
ipLocation: "重庆",
followers: 734,
});
assert.equal(calls.length, 4);
assert.equal(
calls[2].body.params.name,
"collect_xhs_wen_note_detail",
);
assert.equal(
calls[2].body.params.arguments.note_id,
"6a671108000000000f004bef",
);
assert.equal(calls[3].body.params.name, "parse_xhs_user_summary");
assert.equal(
calls[3].body.params.arguments.url,
"https://www.xiaohongshu.com/user/profile/6905cbca0000000037009f49",
);
assert.equal(
xhsNoteIdFromUrl(
"https://www.xiaohongshu.com/explore/6a671108000000000f004bef",
),
"6a671108000000000f004bef",
);
});
test("resolves followers directly from the supported XHS user summary tool", async () => {
const { calls, fetchImpl } = createFakeMcp([
toolEnvelope({
response: {
code: 200,
success: true,
msg: "用户摘要解析成功",
data: {
user: {
fansCount: 6,
ipLocation: "福建",
nickname: "555 五",
userId: "1020668113",
},
},
},
}),
]);
const details = await resolveXhsProfileDetailsFromMcp(
"https://www.xiaohongshu.com/user/profile/5fb21d32000000000101c23e",
{
endpoint: "https://collector.example/mcp",
key: "test-key",
},
fetchImpl,
);
assert.deepEqual(details, {
followers: 6,
redId: "1020668113",
ipLocation: "福建",
});
assert.equal(calls[2].body.params.name, "parse_xhs_user_summary");
});
test("resolves an xhslink short URL before requesting the author profile", async () => {
const fakeMcp = createFakeMcp([
toolEnvelope({
response: {
code: 200,
success: true,
data: {
note_detail: {
note_id: "6a572da40000000021018bd2",
ip_location: "上海",
user: {
nickname: "短链作者",
user_id: "6905cbca0000000037009f49",
},
},
},
},
}),
toolEnvelope({
response: {
code: 200,
success: true,
data: {
red_id: "1020668113",
followers: "1.2万",
},
},
}),
]);
const fetchImpl = async (url, init) => {
if (String(url) === "http://xhslink.cn/o/AJFyP5dnj7O") {
return new Response(null, {
status: 302,
headers: {
location:
"https://www.xiaohongshu.com/discovery/item/6a572da40000000021018bd2?xsec_token=valid",
},
});
}
return fakeMcp.fetchImpl(url, init);
};
const profile = await resolveXhsAccountProfileFromMcp(
"http://xhslink.cn/o/AJFyP5dnj7O",
"回填昵称",
{
endpoint: "https://collector.example/mcp",
key: "test-key",
},
fetchImpl,
);
assert.equal(profile.nickname, "短链作者");
assert.equal(profile.platformUid, "6905cbca0000000037009f49");
assert.equal(profile.redId, "1020668113");
assert.equal(profile.followers, 12_000);
assert.equal(
profile.profileUrl,
"https://www.xiaohongshu.com/user/profile/6905cbca0000000037009f49",
);
assert.equal(
fakeMcp.calls[2].body.params.arguments.note_id,
"6a572da40000000021018bd2",
);
});
test("uses the public note author when MCP profile lookup fails", async () => {
const fakeMcp = createFakeMcp([
toolEnvelope(
{
response: {
code: 500,
success: false,
msg: "作者详情暂时不可用",
},
},
true,
),
]);
const fetchImpl = async (url, init) => {
if (String(url) === "http://xhslink.cn/o/AJFyP5dnj7O") {
return new Response(
'<script>window.__STATE__={&quot;noteData&quot;:{&quot;desc&quot;:&quot;测试&quot;,&quot;user&quot;:{&quot;userId&quot;:&quot;5fb21d32000000000101c23e&quot;,&quot;nickName&quot;:&quot;555 五&quot;}}}</script>',
{
status: 200,
headers: {
"content-type": "text/html; charset=utf-8",
location:
"https://www.xiaohongshu.com/discovery/item/6a572da40000000021018bd2?xsec_token=valid",
},
},
);
}
if (
String(url) ===
"https://www.xiaohongshu.com/user/profile/5fb21d32000000000101c23e"
) {
return new Response(
'<script>{"redId":"1020668113","interactions":[{"name":"粉丝","count":"6","i18nCount":"6"}]}</script>',
{ status: 200 },
);
}
return fakeMcp.fetchImpl(url, init);
};
const profile = await resolveXhsAccountProfileFromMcp(
"http://xhslink.cn/o/AJFyP5dnj7O",
"回填昵称",
{
endpoint: "https://collector.example/mcp",
key: "test-key",
},
fetchImpl,
);
assert.deepEqual(profile, {
platformUid: "5fb21d32000000000101c23e",
nickname: "555 五",
profileUrl:
"https://www.xiaohongshu.com/user/profile/5fb21d32000000000101c23e",
redId: "1020668113",
ipLocation: "待识别",
followers: 6,
});
assert.equal(
fakeMcp.calls[2].body.params.name,
"collect_xhs_wen_note_detail",
);
});
test("reads the user-visible Xiaohongshu number from a public profile", async () => {
const fetchImpl = async () =>
new Response(
'<div class="redId">小红书号1020668113</div><script>{"redId":"1020668113","interactions":[{"name":"粉丝","count":"10+","i18nCount":"10+"}]}</script>',
{ status: 200 },
);
const accountId = await resolveXhsPublicAccountId(
"https://www.xiaohongshu.com/user/profile/5fb21d32000000000101c23e",
fetchImpl,
);
const details = await resolveXhsPublicAccountDetails(
"https://www.xiaohongshu.com/user/profile/5fb21d32000000000101c23e",
fetchImpl,
);
assert.equal(accountId, "1020668113");
assert.deepEqual(details, {
redId: "1020668113",
followers: 10,
ipLocation: "",
});
});
test("falls back to parse_xhs_note when the primary tool fails", async () => {
const { calls, fetchImpl } = createFakeMcp([
toolEnvelope(
{
response: {
code: 400,
success: false,
msg: "获取内容详情失败",
data: null,
},
},
true,
),
toolEnvelope({
response: {
code: 200,
success: true,
data: {
likes: "1.2万",
comments: 32,
collects: "2,345",
},
},
}),
]);
const result = await collectXhsMetricsFromMcp(
"https://www.xiaohongshu.com/explore/test",
{
endpoint: "https://collector.example/mcp?key=test-key",
},
fetchImpl,
);
assert.deepEqual(result, {
likes: 12_000,
comments: 32,
collects: 2_345,
});
assert.equal(calls[3].body.params.name, "parse_xhs_note");
});
test("requires the MCP key without sending a network request", async () => {
let requested = false;
await assert.rejects(
collectXhsMetricsFromMcp(
"https://www.xiaohongshu.com/explore/test",
resolveCollectionMcpConfig({}),
async () => {
requested = true;
return new Response();
},
),
/MCP采集密钥未配置/,
);
assert.equal(requested, false);
});
test("rebuilds the MCP session after a gateway session miss", async () => {
let initializeCount = 0;
const fetchImpl = async (_url, init) => {
const body = JSON.parse(init.body);
if (body.method === "initialize") {
initializeCount += 1;
return sse(
{
jsonrpc: "2.0",
id: body.id,
result: {
protocolVersion: "2025-03-26",
capabilities: { tools: { listChanged: false } },
serverInfo: { name: "ai-tool-center", version: "test" },
},
},
{
headers: {
"mcp-session-id": `session-${initializeCount}`,
},
},
);
}
if (
body.method === "notifications/initialized" &&
initializeCount === 1
) {
return Response.json(
{
jsonrpc: "2.0",
id: "server-error",
error: { code: -32600, message: "Session not found" },
},
{ status: 404 },
);
}
if (body.method === "notifications/initialized") {
return new Response("", { status: 202 });
}
if (body.method === "tools/call") {
return sse(
toolEnvelope({
response: {
code: 200,
success: true,
data: { likes: 8, comments: 2, collects: 5 },
},
}),
);
}
throw new Error(`Unexpected MCP method: ${body.method}`);
};
const result = await collectXhsMetricsFromMcp(
"https://www.xiaohongshu.com/explore/test",
{
endpoint: "https://collector.example/mcp",
key: "test-key",
},
fetchImpl,
);
assert.deepEqual(result, { likes: 8, comments: 2, collects: 5 });
assert.equal(initializeCount, 2);
});

View File

@@ -0,0 +1,30 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
extractXhsPublishUrl,
} from "../lib/publish-url.ts";
const longShareText =
"81 【汕头街头的夏天,有点可爱 - pyeong | 小红书 - 你的生活兴趣社区】 😆 qpF2vquW4v6E0uq 😆 https://www.xiaohongshu.com/discovery/item/6a5a407d000000000e0363c7?source=webshare&xhsshare=pc_web&xsec_token=ABQTS76SGS2MKA1rYfSay-1cyULvb_8k7kNIw4mKVrcDA=&xsec_source=pc_share";
const shortShareText =
"汕头街头的夏天,有点可爱 今天的关键词: 浅蓝色、草... http://xhslink.cn/o/6UDG4oUB8kR 保留这段,去【小红书】逛逛吧~";
test("extracts Xiaohongshu long and short URLs from full share text", () => {
const longUrl = extractXhsPublishUrl(longShareText);
const shortUrl = extractXhsPublishUrl(shortShareText);
assert.equal(
new URL(longUrl).pathname,
"/discovery/item/6a5a407d000000000e0363c7",
);
assert.equal(new URL(longUrl).searchParams.get("source"), "webshare");
assert.equal(shortUrl, "http://xhslink.cn/o/6UDG4oUB8kR");
});
test("rejects text that does not contain a Xiaohongshu URL", () => {
assert.equal(extractXhsPublishUrl("只有文案,没有链接"), "");
assert.equal(
extractXhsPublishUrl("https://example.com/not-xhs"),
"",
);
});

View File

@@ -0,0 +1,261 @@
import assert from "node:assert/strict";
import { access, readFile } from "node:fs/promises";
import test from "node:test";
test("builds the KOC LOOP product shell", async () => {
const [page, adminApp, layout] = await Promise.all([
readFile(new URL("../app/page.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/layout.tsx", import.meta.url), "utf8"),
]);
assert.match(page, /requireChatGPTUser/);
assert.match(page, /isAdminEmail/);
assert.match(adminApp, /KOC LOOP/);
assert.match(adminApp, /内容分发闭环/);
assert.match(adminApp, /分发工作台/);
assert.match(adminApp, /获取KOC领取链接/);
assert.match(layout, /KOC LOOP内容分发闭环/);
assert.doesNotMatch(adminApp, /codex-preview|Your site is taking shape/);
await access(new URL("../dist/server/index.js", import.meta.url));
await access(new URL("../dist/client/assets", import.meta.url));
});
test("ships persistence, uploads, metadata, and no starter preview", async () => {
const [adminApp, layout, packageJson, hosting] = await Promise.all([
readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/layout.tsx", import.meta.url), "utf8"),
readFile(new URL("../package.json", import.meta.url), "utf8"),
readFile(new URL("../.openai/hosting.json", import.meta.url), "utf8"),
]);
assert.doesNotMatch(adminApp, /批量回填发布链接/);
assert.match(adminApp, /复制领取链接/);
assert.match(adminApp, /compressScreenshot/);
assert.match(layout, /KOC LOOP内容分发闭环/);
assert.match(layout, /\/og\.png/);
assert.doesNotMatch(packageJson, /react-loading-skeleton/);
assert.match(hosting, /"d1": "DB"/);
assert.match(hosting, /"r2": "UPLOADS"/);
await access(new URL("../public/og.png", import.meta.url));
});
test("organizes distribution and recovery by task and imports the verified Feishu source", async () => {
const [adminApp, snapshotText, migration, imageMigration] = await Promise.all([
readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"),
readFile(new URL("../lib/feishu-source-snapshot.json", import.meta.url), "utf8"),
readFile(new URL("../drizzle/0001_sloppy_blue_blade.sql", import.meta.url), "utf8"),
readFile(new URL("../drizzle/0003_needy_doctor_strange.sql", import.meta.url), "utf8"),
]);
const snapshot = JSON.parse(snapshotText);
assert.match(adminApp, /选择一个分发任务/);
assert.match(adminApp, /选择一个数据回收任务/);
assert.match(adminApp, /读取表格/);
assert.match(adminApp, /当前仅展示/);
assert.equal(snapshot.sheetId, "954953");
assert.equal(snapshot.rows.length, 61);
assert.equal(
snapshot.rows.reduce((total, row) => total + row.images.length, 0),
64,
);
assert.ok(snapshot.rows.every((row) => row.images.length > 0));
assert.match(migration, /source_sheet_id/);
assert.match(migration, /source_row/);
assert.match(imageMigration, /image_assets/);
assert.match(imageMigration, /claims_task_partner_created_idx/);
});
test("issues external task links and supports one-to-one note submissions", async () => {
const [
adminApp,
actionRoute,
partnerRoute,
uploadRoute,
imageRoute,
imageUploadRoute,
cors,
migration,
accountEnrichment,
partnerUtils,
publishUrlUtils,
creatorScreenshotRoute,
] = await Promise.all([
readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/api/action/route.ts", import.meta.url), "utf8"),
readFile(new URL("../app/api/partner/route.ts", import.meta.url), "utf8"),
readFile(new URL("../app/api/partner-upload/route.ts", import.meta.url), "utf8"),
readFile(new URL("../app/api/partner-image/route.ts", import.meta.url), "utf8"),
readFile(new URL("../app/api/content-image-upload/route.ts", import.meta.url), "utf8"),
readFile(new URL("../lib/partner-cors.ts", import.meta.url), "utf8"),
readFile(new URL("../drizzle/0002_wealthy_maelstrom.sql", import.meta.url), "utf8"),
readFile(new URL("../lib/account-enrichment-service.ts", import.meta.url), "utf8"),
readFile(new URL("../lib/partner-utils.ts", import.meta.url), "utf8"),
readFile(new URL("../lib/publish-url.ts", import.meta.url), "utf8"),
readFile(new URL("../app/api/creator-screenshot/route.ts", import.meta.url), "utf8"),
]);
assert.match(adminApp, /复制领取链接/);
assert.match(adminApp, /小红书号/);
assert.match(adminApp, /public_account_id/);
assert.doesNotMatch(adminApp, /title=\{account\.platform_uid\}/);
assert.doesNotMatch(adminApp, /平均阅读/);
assert.doesNotMatch(adminApp, /批量回填发布链接/);
assert.doesNotMatch(actionRoute, /submit_links/);
assert.match(partnerRoute, /publish_screenshot_key/);
assert.match(partnerRoute, /creator_screenshot_key/);
assert.match(partnerRoute, /submit_creator_metrics/);
assert.match(partnerRoute, /ocr_status = 'manual'/);
assert.match(partnerRoute, /请填写正确的曝光量和阅读量/);
assert.match(partnerRoute, /笔记与领取凭证不匹配/);
assert.match(partnerRoute, /action === "recover"/);
assert.match(partnerRoute, /extractXhsPublishUrl/);
assert.match(partnerRoute, /小红书长链或短链/);
assert.match(partnerRoute, /没有找到领取记录/);
assert.match(partnerRoute, /publicImageAssets/);
assert.match(partnerRoute, /withPartnerCors/);
assert.match(partnerRoute, /enrichDistributionAccount/);
assert.match(partnerRoute, /getRequestExecutionContext/);
assert.match(partnerRoute, /executionContext\.waitUntil\(enrichment\)/);
assert.match(accountEnrichment, /profile_url = excluded\.profile_url/);
assert.match(accountEnrichment, /resolveXhsAccountProfileFromMcp/);
assert.match(accountEnrichment, /resolveXhsProfileDetailsFromMcp/);
assert.match(accountEnrichment, /isVerifiedXhsProfileUrl/);
assert.match(accountEnrichment, /endsWith\("\.xiaohongshu\.com"\)/);
assert.match(accountEnrichment, /DELETE FROM accounts/);
assert.match(accountEnrichment, /a\.followers/);
assert.match(accountEnrichment, /followers = CASE/);
assert.match(accountEnrichment, /Number\(row\.followers \?\? 0\) === 0/);
assert.match(partnerUtils, /pending-\$\{hashText/);
assert.match(partnerUtils, /profileUrl:\s*""/);
assert.match(publishUrlUtils, /xhslink\.cn/);
assert.doesNotMatch(partnerUtils, /user\/profile\/\$\{platformUid\}/);
assert.match(uploadRoute, /publish-evidence/);
assert.match(uploadRoute, /creator-center/);
assert.match(uploadRoute, /ELSE 'uploaded'/);
assert.doesNotMatch(uploadRoute, /ocrMetric/);
assert.match(uploadRoute, /x-koc-upload-kind/);
assert.match(uploadRoute, /x-koc-distribution/);
assert.match(uploadRoute, /request\.arrayBuffer/);
assert.match(imageRoute, /content-assets\//);
assert.match(imageRoute, /cl\.claim_token/);
assert.match(imageUploadRoute, /isAdminRequest/);
assert.match(cors, /KOC_PORTAL_URL/);
assert.match(cors, /X-KOC-Distribution/);
assert.match(cors, /X-KOC-Upload-Kind/);
assert.match(cors, /Access-Control-Allow-Origin/);
assert.match(adminApp, /hasCreatorMetrics/);
assert.match(adminApp, /待KOC填写数据/);
assert.match(creatorScreenshotRoute, /getUploadBucket/);
assert.match(creatorScreenshotRoute, /Content-Disposition/);
assert.match(creatorScreenshotRoute, /isAdminRequest/);
assert.match(migration, /share_token/);
assert.match(migration, /claim_token/);
});
test("supports task collection schedules and latest public metrics", async () => {
const [
adminApp,
actionRoute,
bootstrapRoute,
collectionService,
worker,
viteConfig,
migration,
accountMigration,
deployConfig,
] = await Promise.all([
readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/api/action/route.ts", import.meta.url), "utf8"),
readFile(new URL("../app/api/bootstrap/route.ts", import.meta.url), "utf8"),
readFile(new URL("../lib/collection-service.ts", import.meta.url), "utf8"),
readFile(new URL("../worker/index.ts", import.meta.url), "utf8"),
readFile(new URL("../vite.config.ts", import.meta.url), "utf8"),
readFile(new URL("../drizzle/0004_sharp_the_liberteens.sql", import.meta.url), "utf8"),
readFile(new URL("../drizzle/0005_foamy_sage.sql", import.meta.url), "utf8"),
readFile(new URL("../dist/server/wrangler.json", import.meta.url), "utf8"),
]);
for (const label of [
"点赞",
"收藏",
"评论",
"总互动",
"数据更新时间",
"采集状态",
"发布时间",
]) {
assert.match(adminApp, new RegExp(label));
}
assert.match(adminApp, /选择开始日期与采集日/);
assert.match(adminApp, /第1天到第7天/);
assert.match(adminApp, /内容 \/ 发布账号/);
assert.match(adminApp, /recovery-title-link/);
assert.match(adminApp, /打开小红书笔记/);
assert.match(adminApp, /noopener noreferrer/);
assert.match(actionRoute, /save_collection_schedule/);
assert.match(actionRoute, /collect_now/);
assert.match(actionRoute, /backfill_account_profiles/);
assert.match(actionRoute, /set_public_account_ids/);
assert.match(actionRoute, /run_due_collections/);
assert.match(actionRoute, /retry_failed_collections/);
assert.match(actionRoute, /createCollectionRunTasks/);
assert.match(bootstrapRoute, /runDueScheduledCollections/);
assert.match(collectionService, /INSERT OR IGNORE INTO collection_runs/);
assert.match(collectionService, /collectXhsMetricsFromMcp/);
assert.doesNotMatch(collectionService, /hashText/);
assert.match(collectionService, /runScheduledCollections/);
assert.match(collectionService, /runDueScheduledCollections/);
assert.match(collectionService, /retryFailedCollections/);
assert.match(collectionService, /exposure IS NOT NULL AND views IS NOT NULL/);
assert.match(collectionService, /等待第\$\{scheduleDay\}天 10:00自动采集/);
assert.doesNotMatch(collectionService, /latestDueSchedule/);
assert.match(collectionService, /自动追采/);
assert.match(worker, /async scheduled/);
assert.match(worker, /backfillAccountProfiles/);
assert.match(bootstrapRoute, /backfillAccountProfiles/);
assert.match(viteConfig, /"0 2 \* \* \*"/);
assert.match(migration, /latest_likes/);
assert.match(migration, /collection_runs_distribution_date_idx/);
assert.match(accountMigration, /public_account_id/);
assert.match(deployConfig, /"crons":\["0 2 \* \* \*"\]/);
});
test("supports anonymous partner delegation without creating a second data flow", async () => {
const [
adminApp,
partnerRoute,
uploadRoute,
imageRoute,
cors,
schema,
runtimeSchema,
migration,
] = await Promise.all([
readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/api/partner/route.ts", import.meta.url), "utf8"),
readFile(new URL("../app/api/partner-upload/route.ts", import.meta.url), "utf8"),
readFile(new URL("../app/api/partner-image/route.ts", import.meta.url), "utf8"),
readFile(new URL("../lib/partner-cors.ts", import.meta.url), "utf8"),
readFile(new URL("../db/schema.ts", import.meta.url), "utf8"),
readFile(new URL("../lib/mvp-db.ts", import.meta.url), "utf8"),
readFile(new URL("../drizzle/0006_moaning_dark_phoenix.sql", import.meta.url), "utf8"),
]);
assert.match(schema, /delegationBundles/);
assert.match(schema, /delegationBundleId/);
assert.match(runtimeSchema, /CREATE TABLE IF NOT EXISTS delegation_bundles/);
assert.match(migration, /delegation_bundles/);
assert.match(migration, /delegation_bundle_id/);
assert.match(partnerRoute, /action === "create_delegation"/);
assert.match(partnerRoute, /action === "revoke_delegation"/);
assert.match(partnerRoute, /findAccessibleAssignment/);
assert.match(partnerRoute, /b\.status = 'active'/);
assert.match(partnerRoute, /部分笔记刚刚已被转派/);
assert.match(partnerRoute, /分享链接只能用于查看和回填包内笔记/);
assert.match(partnerRoute, /private, no-store/);
assert.match(uploadRoute, /x-koc-delegation/);
assert.match(uploadRoute, /delegation_bundles/);
assert.match(imageRoute, /delegation_bundles/);
assert.match(cors, /X-KOC-Delegation/);
assert.match(adminApp, /合作社资源 · 不可直联/);
});

34
tsconfig.json Normal file
View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}

61
vite.config.ts Normal file
View File

@@ -0,0 +1,61 @@
import vinext from "vinext";
import { defineConfig } from "vite";
import hostingConfig from "./.openai/hosting.json";
import { sites } from "./build/sites-vite-plugin";
const SITE_CREATOR_PLACEHOLDER_DATABASE_ID =
"00000000-0000-4000-8000-000000000000";
const { d1, r2 } = hostingConfig;
// macOS Seatbelt blocks FSEvents, so Codex previews need polling for HMR.
const isCodexSeatbeltSandbox = process.env.CODEX_SANDBOX === "seatbelt";
const localBindingConfig = {
main: "./worker/index.ts",
compatibility_flags: ["nodejs_compat"],
// Cloudflare cron uses UTC. 02:00 UTC is 10:00 in Asia/Shanghai.
triggers: { crons: ["0 2 * * *"] },
d1_databases: d1
? [
{
binding: d1,
database_name: "site-creator-d1",
database_id: SITE_CREATOR_PLACEHOLDER_DATABASE_ID,
},
]
: [],
r2_buckets: r2
? [
{
binding: r2,
bucket_name: "site-creator-r2",
},
]
: [],
};
export default defineConfig(async () => {
// Keep Wrangler and Miniflare state project-local. These are non-secret tool
// settings; application environment belongs in ignored `.env*` files.
process.env.WRANGLER_WRITE_LOGS ??= "false";
process.env.WRANGLER_LOG_PATH ??= ".wrangler/logs";
process.env.MINIFLARE_REGISTRY_PATH ??= ".wrangler/registry";
// Wrangler snapshots its log path while the Cloudflare plugin is imported.
const { cloudflare } = await import("@cloudflare/vite-plugin");
return {
server: isCodexSeatbeltSandbox
? { watch: { useFsEvents: false, usePolling: true } }
: undefined,
plugins: [
vinext(),
sites(),
cloudflare({
viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] },
config: localBindingConfig,
}),
],
};
});

85
worker/index.ts Normal file
View File

@@ -0,0 +1,85 @@
/** Cloudflare Worker entry point for the vinext-starter template. */
import { handleImageOptimization, DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES } from "vinext/server/image-optimization";
import handler from "vinext/server/app-router-entry";
import { backfillAccountProfiles } from "../lib/account-enrichment-service";
import { ensureSchema } from "../lib/mvp-db";
import { runScheduledCollections } from "../lib/collection-service";
import {
resolveCollectionMcpConfig,
type CollectionMcpBindings,
} from "../lib/mcp-collection-client";
import type { FeishuBindings } from "../lib/feishu-client";
interface Env extends CollectionMcpBindings, FeishuBindings {
ASSETS: Fetcher;
DB: D1Database;
UPLOADS: R2Bucket;
IMAGES: {
input(stream: ReadableStream): {
transform(options: Record<string, unknown>): {
output(options: { format: string; quality: number }): Promise<{ response(): Response }>;
};
};
};
}
interface ExecutionContext {
waitUntil(promise: Promise<unknown>): void;
passThroughOnException(): void;
}
interface ScheduledController {
scheduledTime: number;
cron: string;
noRetry(): void;
}
// Image security config. SVG sources with .svg extension auto-skip the
// optimization endpoint on the client side (served directly, no proxy).
// To route SVGs through the optimizer (with security headers), set
// dangerouslyAllowSVG: true in next.config.js and uncomment below:
// const imageConfig: ImageConfig = { dangerouslyAllowSVG: true };
const worker = {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/_vinext/image") {
const allowedWidths = [...DEFAULT_DEVICE_SIZES, ...DEFAULT_IMAGE_SIZES];
return handleImageOptimization(request, {
fetchAsset: (path) => env.ASSETS.fetch(new Request(new URL(path, request.url))),
transformImage: async (body, { width, format, quality }) => {
const result = await env.IMAGES.input(body).transform(width > 0 ? { width } : {}).output({ format, quality });
return result.response();
},
}, allowedWidths);
}
return handler.fetch(request, env, ctx);
},
async scheduled(
controller: ScheduledController,
env: Env,
ctx: ExecutionContext,
) {
ctx.waitUntil(
(async () => {
await ensureSchema(env.DB);
const mcpConfig = resolveCollectionMcpConfig(env);
await runScheduledCollections(
env.DB,
controller.scheduledTime,
mcpConfig,
);
const accountBackfill = await backfillAccountProfiles(
env.DB,
mcpConfig,
10,
);
console.info("KOC scheduled account backfill completed", accountBackfill);
})(),
);
},
};
export default worker;