Initial commit: KOC LOOP platform
11
.dev.vars.example
Normal 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
@@ -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
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"project_id": "appgprj_6a670019733c8191ab20ba068aa437cb",
|
||||
"d1": "DB",
|
||||
"r2": "UPLOADS"
|
||||
}
|
||||
104
README.md
Normal 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
394
app/api/action/route.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
import { env } from "cloudflare:workers";
|
||||
import { getRequestExecutionContext } from "vinext/shims/request-context";
|
||||
import { backfillAccountProfiles } from "../../../lib/account-enrichment-service";
|
||||
import {
|
||||
ensureSchema,
|
||||
getDashboardData,
|
||||
getRawDb,
|
||||
uid,
|
||||
} from "../../../lib/mvp-db";
|
||||
import {
|
||||
collectDistributionMetrics,
|
||||
createCollectionRunTasks,
|
||||
retryFailedCollections,
|
||||
runDueScheduledCollections,
|
||||
shanghaiDateFromTimestamp,
|
||||
} from "../../../lib/collection-service";
|
||||
import {
|
||||
resolveCollectionMcpConfig,
|
||||
type CollectionMcpBindings,
|
||||
} from "../../../lib/mcp-collection-client";
|
||||
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
|
||||
import {
|
||||
FeishuSourceError,
|
||||
readFeishuSource,
|
||||
type FeishuBindings,
|
||||
type FeishuSource,
|
||||
} from "../../../lib/feishu-client";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
type ActionBody = {
|
||||
action?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
function numberValue(value: unknown, fallback = 0) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
async function createTaskFromSource(
|
||||
source: FeishuSource,
|
||||
name: string,
|
||||
brand: string,
|
||||
dueAt: string,
|
||||
) {
|
||||
const db = getRawDb();
|
||||
const taskId = uid("task");
|
||||
const shareToken = crypto.randomUUID().replaceAll("-", "");
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO tasks
|
||||
(id, name, brand, quantity, claimed_quantity, due_at, status,
|
||||
source_url, source_sheet_id, source_sheet_name, source_synced_at,
|
||||
share_token)
|
||||
VALUES (?, ?, ?, ?, 0, ?, 'importing', ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
taskId,
|
||||
name,
|
||||
brand,
|
||||
source.rows.length,
|
||||
dueAt,
|
||||
source.url,
|
||||
source.sheetId,
|
||||
source.sheetName,
|
||||
source.syncedAt,
|
||||
shareToken,
|
||||
)
|
||||
.run();
|
||||
|
||||
try {
|
||||
const contentStatements = source.rows.map((row) => {
|
||||
const contentId = uid("content");
|
||||
const imageAssets = row.images.map((image) => ({
|
||||
...image,
|
||||
key: `content-assets/${taskId}/${contentId}/${image.index}`,
|
||||
}));
|
||||
return db
|
||||
.prepare(
|
||||
`INSERT INTO contents
|
||||
(id, task_id, title, body, image_assets, status, source, source_row)
|
||||
VALUES (?, ?, ?, ?, ?, 'available', ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
contentId,
|
||||
taskId,
|
||||
row.title,
|
||||
row.body,
|
||||
JSON.stringify(imageAssets),
|
||||
`飞书 · ${source.sheetName}`,
|
||||
row.sourceRow,
|
||||
);
|
||||
});
|
||||
for (let index = 0; index < contentStatements.length; index += 100) {
|
||||
await db.batch(contentStatements.slice(index, index + 100));
|
||||
}
|
||||
await db
|
||||
.prepare("UPDATE tasks SET status = 'active' WHERE id = ?")
|
||||
.bind(taskId)
|
||||
.run();
|
||||
} catch (error) {
|
||||
await db.batch([
|
||||
db.prepare("DELETE FROM contents WHERE task_id = ?").bind(taskId),
|
||||
db.prepare("DELETE FROM tasks WHERE id = ?").bind(taskId),
|
||||
]);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!isAdminRequest(request)) return adminForbidden();
|
||||
try {
|
||||
await ensureSchema();
|
||||
const body = (await request.json()) as ActionBody;
|
||||
const db = getRawDb();
|
||||
|
||||
if (body.action === "inspect_feishu") {
|
||||
const source = await readFeishuSource(
|
||||
String(body.feishuUrl ?? "").trim(),
|
||||
env as unknown as FeishuBindings,
|
||||
);
|
||||
return Response.json({
|
||||
sheetId: source.sheetId,
|
||||
sheetName: source.sheetName,
|
||||
syncedAt: source.syncedAt,
|
||||
rowCount: source.rows.length,
|
||||
columns: source.columns,
|
||||
preview: source.rows.slice(0, 3),
|
||||
});
|
||||
}
|
||||
|
||||
if (body.action === "create_task") {
|
||||
const name = String(body.name ?? "").trim();
|
||||
const brand = String(body.brand ?? "").trim();
|
||||
const dueAt = String(body.dueAt ?? "").trim();
|
||||
if (!name || !brand || !dueAt) {
|
||||
return Response.json(
|
||||
{ error: "请补全任务名称、品牌和截止日期" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const source = await readFeishuSource(
|
||||
String(body.feishuUrl ?? "").trim(),
|
||||
env as unknown as FeishuBindings,
|
||||
);
|
||||
await createTaskFromSource(source, name, brand, dueAt);
|
||||
} else if (body.action === "claim") {
|
||||
const partnerId = String(body.partnerId ?? "");
|
||||
const taskId = String(body.taskId ?? "");
|
||||
const quantity = Math.max(1, Math.min(50, numberValue(body.quantity, 1)));
|
||||
const available = await db
|
||||
.prepare(
|
||||
`SELECT id FROM contents
|
||||
WHERE task_id = ? AND status = 'available'
|
||||
ORDER BY created_at, id
|
||||
LIMIT ?`,
|
||||
)
|
||||
.bind(taskId, quantity)
|
||||
.all<{ id: string }>();
|
||||
if (available.results.length === 0) {
|
||||
return Response.json({ error: "当前没有可领取内容" }, { status: 409 });
|
||||
}
|
||||
const statements = available.results.flatMap((content) => [
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO distributions
|
||||
(id, task_id, content_id, partner_id, status)
|
||||
VALUES (?, ?, ?, ?, 'claimed')`,
|
||||
)
|
||||
.bind(uid("dist"), taskId, content.id, partnerId),
|
||||
db
|
||||
.prepare("UPDATE contents SET status = 'allocated' WHERE id = ?")
|
||||
.bind(content.id),
|
||||
]);
|
||||
statements.push(
|
||||
db
|
||||
.prepare(
|
||||
`UPDATE tasks SET claimed_quantity = claimed_quantity + ?
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.bind(available.results.length, taskId),
|
||||
db
|
||||
.prepare(
|
||||
`UPDATE partners SET claimed_total = claimed_total + ?
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.bind(available.results.length, partnerId),
|
||||
);
|
||||
await db.batch(statements);
|
||||
} else if (body.action === "save_collection_schedule") {
|
||||
const taskId = String(body.taskId ?? "").trim();
|
||||
const startDate = String(body.startDate ?? "").trim();
|
||||
const days = Array.isArray(body.days)
|
||||
? [...new Set(body.days.map(Number))]
|
||||
.filter(
|
||||
(day) =>
|
||||
Number.isInteger(day) && day >= 1 && day <= 7,
|
||||
)
|
||||
.sort((a, b) => a - b)
|
||||
: [];
|
||||
if (!taskId || !/^\d{4}-\d{2}-\d{2}$/.test(startDate)) {
|
||||
return Response.json(
|
||||
{ error: "请选择有效的开始采集日期" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (days.length === 0) {
|
||||
return Response.json(
|
||||
{ error: "请至少选择一个自动采集日" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const task = await db
|
||||
.prepare("SELECT id FROM tasks WHERE id = ?")
|
||||
.bind(taskId)
|
||||
.first<{ id: string }>();
|
||||
if (!task) {
|
||||
return Response.json({ error: "任务不存在" }, { status: 404 });
|
||||
}
|
||||
await db.batch([
|
||||
db
|
||||
.prepare(
|
||||
`UPDATE tasks
|
||||
SET collection_start_date = ?,
|
||||
collection_days = ?,
|
||||
collection_schedule_updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.bind(startDate, JSON.stringify(days), taskId),
|
||||
db
|
||||
.prepare(
|
||||
`UPDATE distributions
|
||||
SET collection_status = CASE
|
||||
WHEN latest_likes IS NULL THEN 'scheduled'
|
||||
ELSE collection_status
|
||||
END,
|
||||
collection_status_description = CASE
|
||||
WHEN latest_likes IS NULL THEN ?
|
||||
ELSE collection_status_description
|
||||
END,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE task_id = ?
|
||||
AND publish_url IS NOT NULL
|
||||
AND publish_url != ''`,
|
||||
)
|
||||
.bind(
|
||||
`已安排${days.length}个采集日,每日10:00执行`,
|
||||
taskId,
|
||||
),
|
||||
]);
|
||||
await createCollectionRunTasks(db, taskId, startDate, days);
|
||||
const catchup = runDueScheduledCollections(
|
||||
db,
|
||||
Date.now(),
|
||||
resolveCollectionMcpConfig(
|
||||
env as unknown as CollectionMcpBindings,
|
||||
),
|
||||
"catchup",
|
||||
taskId,
|
||||
).catch(() => undefined);
|
||||
const executionContext = getRequestExecutionContext();
|
||||
if (executionContext) {
|
||||
executionContext.waitUntil(catchup);
|
||||
} else {
|
||||
await catchup;
|
||||
}
|
||||
} else if (body.action === "run_due_collections") {
|
||||
await runDueScheduledCollections(
|
||||
db,
|
||||
Date.now(),
|
||||
resolveCollectionMcpConfig(
|
||||
env as unknown as CollectionMcpBindings,
|
||||
),
|
||||
"catchup",
|
||||
);
|
||||
} else if (
|
||||
body.action === "collect_now" ||
|
||||
body.action === "collect"
|
||||
) {
|
||||
const distributionId = String(body.distributionId ?? "");
|
||||
const rawDay =
|
||||
body.day === null || body.day === undefined
|
||||
? null
|
||||
: numberValue(body.day);
|
||||
const day =
|
||||
rawDay !== null && Number.isInteger(rawDay) && rawDay >= 1 && rawDay <= 7
|
||||
? rawDay
|
||||
: null;
|
||||
if (body.action === "collect" && day === null) {
|
||||
return Response.json({ error: "采集周期无效" }, { status: 400 });
|
||||
}
|
||||
await collectDistributionMetrics(
|
||||
db,
|
||||
distributionId,
|
||||
shanghaiDateFromTimestamp(Date.now()),
|
||||
day,
|
||||
"manual",
|
||||
resolveCollectionMcpConfig(
|
||||
env as unknown as CollectionMcpBindings,
|
||||
),
|
||||
);
|
||||
} else if (body.action === "retry_failed_collections") {
|
||||
const taskId = String(body.taskId ?? "").trim();
|
||||
if (!taskId) {
|
||||
return Response.json({ error: "请选择需要补采的任务" }, { status: 400 });
|
||||
}
|
||||
await retryFailedCollections(
|
||||
db,
|
||||
taskId,
|
||||
resolveCollectionMcpConfig(
|
||||
env as unknown as CollectionMcpBindings,
|
||||
),
|
||||
);
|
||||
} else if (body.action === "backfill_account_profiles") {
|
||||
const backfill = backfillAccountProfiles(
|
||||
db,
|
||||
resolveCollectionMcpConfig(
|
||||
env as unknown as CollectionMcpBindings,
|
||||
),
|
||||
Math.max(1, Math.min(25, numberValue(body.limit, 10))),
|
||||
).catch(() => undefined);
|
||||
const executionContext = getRequestExecutionContext();
|
||||
if (executionContext) {
|
||||
executionContext.waitUntil(backfill);
|
||||
} else {
|
||||
await backfill;
|
||||
}
|
||||
} else if (body.action === "set_public_account_ids") {
|
||||
const items = Array.isArray(body.items) ? body.items.slice(0, 50) : [];
|
||||
const normalized = items
|
||||
.map((item) => {
|
||||
const record =
|
||||
item && typeof item === "object"
|
||||
? (item as Record<string, unknown>)
|
||||
: {};
|
||||
return {
|
||||
accountId: String(record.accountId ?? "").trim().slice(0, 80),
|
||||
publicAccountId: String(record.publicAccountId ?? "")
|
||||
.trim()
|
||||
.slice(0, 80),
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(item) =>
|
||||
item.accountId &&
|
||||
/^[\p{L}\p{N}._-]{2,80}$/u.test(item.publicAccountId),
|
||||
);
|
||||
if (normalized.length === 0) {
|
||||
return Response.json(
|
||||
{ error: "没有可回填的账号号值" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
await db.batch(
|
||||
normalized.map((item) =>
|
||||
db
|
||||
.prepare(
|
||||
`UPDATE accounts
|
||||
SET public_account_id = ?,
|
||||
last_seen_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.bind(item.publicAccountId, item.accountId),
|
||||
),
|
||||
);
|
||||
} else if (body.action === "manual_metrics") {
|
||||
const distributionId = String(body.distributionId ?? "");
|
||||
const exposure = Math.max(0, numberValue(body.exposure));
|
||||
const views = Math.max(0, numberValue(body.views));
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE distributions SET
|
||||
exposure = ?,
|
||||
views = ?,
|
||||
ocr_status = 'manual',
|
||||
status = CASE WHEN d7_likes IS NOT NULL THEN 'complete' ELSE status END,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.bind(exposure, views, distributionId)
|
||||
.run();
|
||||
} else {
|
||||
return Response.json({ error: "不支持的操作" }, { status: 400 });
|
||||
}
|
||||
|
||||
return Response.json(await getDashboardData());
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ error: error instanceof Error ? error.message : "操作失败" },
|
||||
{ status: error instanceof FeishuSourceError ? error.status : 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
70
app/api/bootstrap/route.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { env } from "cloudflare:workers";
|
||||
import { getRequestExecutionContext } from "vinext/shims/request-context";
|
||||
import { backfillAccountProfiles } from "../../../lib/account-enrichment-service";
|
||||
import { runDueScheduledCollections } from "../../../lib/collection-service";
|
||||
import {
|
||||
resolveCollectionMcpConfig,
|
||||
type CollectionMcpBindings,
|
||||
} from "../../../lib/mcp-collection-client";
|
||||
import {
|
||||
ensureSchema,
|
||||
getDashboardData,
|
||||
getRawDb,
|
||||
seedIfEmpty,
|
||||
} from "../../../lib/mvp-db";
|
||||
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (!isAdminRequest(request)) return adminForbidden();
|
||||
try {
|
||||
await ensureSchema();
|
||||
await seedIfEmpty();
|
||||
const db = getRawDb();
|
||||
const mcpConfig = resolveCollectionMcpConfig(
|
||||
env as unknown as CollectionMcpBindings,
|
||||
);
|
||||
const collectionCatchup = runDueScheduledCollections(
|
||||
db,
|
||||
Date.now(),
|
||||
mcpConfig,
|
||||
"catchup",
|
||||
).catch((error) => {
|
||||
console.error(
|
||||
"KOC collection catchup failed",
|
||||
error instanceof Error ? error.message : "unknown error",
|
||||
);
|
||||
});
|
||||
const accountBackfill = backfillAccountProfiles(
|
||||
db,
|
||||
mcpConfig,
|
||||
10,
|
||||
)
|
||||
.then((result) => {
|
||||
console.info("KOC account backfill completed", result);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
"KOC account backfill failed",
|
||||
error instanceof Error ? error.message : "unknown error",
|
||||
);
|
||||
});
|
||||
const catchup = Promise.all([
|
||||
collectionCatchup,
|
||||
accountBackfill,
|
||||
]);
|
||||
const executionContext = getRequestExecutionContext();
|
||||
if (executionContext) {
|
||||
executionContext.waitUntil(catchup);
|
||||
} else {
|
||||
await catchup;
|
||||
}
|
||||
return Response.json(await getDashboardData());
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ error: error instanceof Error ? error.message : "加载失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
53
app/api/content-image-upload/route.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import feishuSnapshot from "../../../lib/feishu-source-snapshot.json";
|
||||
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
|
||||
import { ensureSchema, getUploadBucket } from "../../../lib/mvp-db";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!isAdminRequest(request)) return adminForbidden();
|
||||
try {
|
||||
await ensureSchema();
|
||||
const form = await request.formData();
|
||||
const sheetId = String(form.get("sheetId") ?? "").trim();
|
||||
const sourceRow = Number(form.get("sourceRow"));
|
||||
const imageIndex = Number(form.get("imageIndex"));
|
||||
const file = form.get("file");
|
||||
if (
|
||||
sheetId !== feishuSnapshot.sheetId ||
|
||||
!Number.isInteger(sourceRow) ||
|
||||
!Number.isInteger(imageIndex) ||
|
||||
!(file instanceof File) ||
|
||||
file.size === 0
|
||||
) {
|
||||
return Response.json({ error: "图片映射参数无效" }, { status: 400 });
|
||||
}
|
||||
if (!file.type.startsWith("image/") || file.size > 8_000_000) {
|
||||
return Response.json(
|
||||
{ error: "仅支持8MB以内的图片" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const row = feishuSnapshot.rows.find(
|
||||
(item) => item.sourceRow === sourceRow,
|
||||
);
|
||||
const asset = row?.images.find((item) => item.index === imageIndex);
|
||||
if (!asset) {
|
||||
return Response.json({ error: "图片不属于当前飞书内容表" }, { status: 422 });
|
||||
}
|
||||
await getUploadBucket().put(asset.key, await file.arrayBuffer(), {
|
||||
httpMetadata: { contentType: file.type },
|
||||
customMetadata: {
|
||||
sheetId,
|
||||
sourceRow: String(sourceRow),
|
||||
imageIndex: String(imageIndex),
|
||||
},
|
||||
});
|
||||
return Response.json({ uploaded: true });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ error: error instanceof Error ? error.message : "图片同步失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
53
app/api/creator-screenshot/route.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
ensureSchema,
|
||||
getRawDb,
|
||||
getUploadBucket,
|
||||
} from "../../../lib/mvp-db";
|
||||
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (!isAdminRequest(request)) return adminForbidden();
|
||||
try {
|
||||
await ensureSchema();
|
||||
const distributionId = new URL(request.url).searchParams
|
||||
.get("distribution")
|
||||
?.trim();
|
||||
if (!distributionId) {
|
||||
return Response.json({ error: "缺少分发记录" }, { status: 400 });
|
||||
}
|
||||
const row = await getRawDb()
|
||||
.prepare(
|
||||
`SELECT screenshot_key FROM distributions
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.bind(distributionId)
|
||||
.first<{ screenshot_key: string | null }>();
|
||||
if (
|
||||
!row?.screenshot_key ||
|
||||
!row.screenshot_key.startsWith("creator-center/")
|
||||
) {
|
||||
return Response.json({ error: "创作者截图不存在" }, { status: 404 });
|
||||
}
|
||||
const object = await getUploadBucket().get(row.screenshot_key);
|
||||
if (!object) {
|
||||
return Response.json({ error: "创作者截图不存在" }, { status: 404 });
|
||||
}
|
||||
const headers = new Headers({
|
||||
"Cache-Control": "private, no-store",
|
||||
"Content-Disposition": 'inline; filename="creator-center-screenshot"',
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
});
|
||||
object.writeHttpMetadata(headers);
|
||||
if (!headers.get("Content-Type")) {
|
||||
headers.set("Content-Type", "image/jpeg");
|
||||
}
|
||||
return new Response(object.body, { headers });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ error: error instanceof Error ? error.message : "截图读取失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
129
app/api/partner-image/route.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { env } from "cloudflare:workers";
|
||||
import {
|
||||
ensureSchema,
|
||||
getRawDb,
|
||||
getUploadBucket,
|
||||
} from "../../../lib/mvp-db";
|
||||
import {
|
||||
downloadFeishuMedia,
|
||||
type FeishuBindings,
|
||||
} from "../../../lib/feishu-client";
|
||||
import {
|
||||
partnerOptions,
|
||||
withPartnerCors,
|
||||
} from "../../../lib/partner-cors";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
type StoredAsset = {
|
||||
index: number;
|
||||
key: string;
|
||||
fileToken?: string;
|
||||
};
|
||||
|
||||
function textValue(value: string | null, maxLength = 100) {
|
||||
return String(value ?? "").trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
function findAsset(value: string, imageIndex: number) {
|
||||
try {
|
||||
const assets = JSON.parse(value) as StoredAsset[];
|
||||
return Array.isArray(assets)
|
||||
? assets.find(
|
||||
(asset) =>
|
||||
asset.index === imageIndex &&
|
||||
typeof asset.key === "string" &&
|
||||
asset.key.startsWith("content-assets/") &&
|
||||
(asset.fileToken === undefined ||
|
||||
typeof asset.fileToken === "string"),
|
||||
)
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGet(request: Request) {
|
||||
try {
|
||||
await ensureSchema();
|
||||
const url = new URL(request.url);
|
||||
const taskToken = textValue(url.searchParams.get("task"));
|
||||
const claimToken = textValue(url.searchParams.get("claim"));
|
||||
const delegationToken = textValue(url.searchParams.get("share"));
|
||||
const distributionId = textValue(url.searchParams.get("distribution"));
|
||||
const imageIndex = Number(url.searchParams.get("index"));
|
||||
if (
|
||||
(!delegationToken && (!taskToken || !claimToken)) ||
|
||||
!distributionId ||
|
||||
!Number.isInteger(imageIndex) ||
|
||||
imageIndex < 1
|
||||
) {
|
||||
return Response.json({ error: "图片链接不完整" }, { status: 400 });
|
||||
}
|
||||
const row = delegationToken
|
||||
? await getRawDb()
|
||||
.prepare(
|
||||
`SELECT c.image_assets
|
||||
FROM distributions d
|
||||
JOIN contents c ON c.id = d.content_id
|
||||
JOIN delegation_bundles b ON b.id = d.delegation_bundle_id
|
||||
WHERE d.id = ?
|
||||
AND b.share_token = ?
|
||||
AND b.status = 'active'`,
|
||||
)
|
||||
.bind(distributionId, delegationToken)
|
||||
.first<{ image_assets: string }>()
|
||||
: await getRawDb()
|
||||
.prepare(
|
||||
`SELECT c.image_assets
|
||||
FROM distributions d
|
||||
JOIN contents c ON c.id = d.content_id
|
||||
JOIN claims cl ON cl.id = d.claim_id
|
||||
JOIN tasks t ON t.id = d.task_id
|
||||
WHERE d.id = ?
|
||||
AND cl.claim_token = ?
|
||||
AND t.share_token = ?
|
||||
AND cl.task_id = t.id`,
|
||||
)
|
||||
.bind(distributionId, claimToken, taskToken)
|
||||
.first<{ image_assets: string }>();
|
||||
const asset = row ? findAsset(row.image_assets, imageIndex) : undefined;
|
||||
if (!asset) {
|
||||
return Response.json({ error: "没有找到这张笔记图片" }, { status: 404 });
|
||||
}
|
||||
const bucket = getUploadBucket();
|
||||
let object = await bucket.get(asset.key);
|
||||
if (!object && asset.fileToken) {
|
||||
const media = await downloadFeishuMedia(
|
||||
asset.fileToken,
|
||||
env as unknown as FeishuBindings,
|
||||
);
|
||||
await bucket.put(asset.key, media.bytes, {
|
||||
httpMetadata: { contentType: media.contentType },
|
||||
customMetadata: { source: "feishu-api" },
|
||||
});
|
||||
object = await bucket.get(asset.key);
|
||||
}
|
||||
if (!object) {
|
||||
return Response.json({ error: "图片同步失败,请稍后重试" }, { status: 404 });
|
||||
}
|
||||
const headers = new Headers();
|
||||
object.writeHttpMetadata(headers);
|
||||
headers.set("Cache-Control", "private, max-age=3600");
|
||||
headers.set("Content-Disposition", `inline; filename="note-${imageIndex}"`);
|
||||
return new Response(object.body, { headers });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ error: error instanceof Error ? error.message : "图片读取失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
return withPartnerCors(request, await handleGet(request));
|
||||
}
|
||||
|
||||
export async function OPTIONS(request: Request) {
|
||||
return partnerOptions(request);
|
||||
}
|
||||
164
app/api/partner-upload/route.ts
Normal file
@@ -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
@@ -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
@@ -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
@@ -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
68
app/layout.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { headers } from "next/headers";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const requestHeaders = await headers();
|
||||
const host = requestHeaders.get("x-forwarded-host") || requestHeaders.get("host");
|
||||
const protocol =
|
||||
requestHeaders.get("x-forwarded-proto") ||
|
||||
(host?.startsWith("localhost") ? "http" : "https");
|
||||
const metadataBase = host
|
||||
? new URL(`${protocol}://${host}`)
|
||||
: new URL("https://koc-loop.example.com");
|
||||
const description =
|
||||
"面向KOC运营团队的轻量资源管理、内容分发与数据回收平台。";
|
||||
|
||||
return {
|
||||
metadataBase,
|
||||
title: "KOC LOOP|内容分发闭环",
|
||||
description,
|
||||
icons: {
|
||||
icon: "/favicon.svg",
|
||||
shortcut: "/favicon.svg",
|
||||
},
|
||||
openGraph: {
|
||||
title: "KOC LOOP|内容分发闭环",
|
||||
description,
|
||||
type: "website",
|
||||
images: [
|
||||
{
|
||||
url: new URL("/og.png", metadataBase).toString(),
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "KOC LOOP 资源管理、内容分发与数据回收",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "KOC LOOP|内容分发闭环",
|
||||
description,
|
||||
images: [new URL("/og.png", metadataBase).toString()],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
24
app/page.tsx
Normal file
@@ -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 />;
|
||||
}
|
||||
45
build/sites-vite-plugin.ts
Normal 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
@@ -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
@@ -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
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
out: "./drizzle",
|
||||
schema: "./db/schema.ts",
|
||||
dialect: "sqlite",
|
||||
});
|
||||
71
drizzle/0000_pink_iron_monger.sql
Normal 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
|
||||
);
|
||||
5
drizzle/0001_sloppy_blue_blade.sql
Normal 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;
|
||||
15
drizzle/0002_wealthy_maelstrom.sql
Normal 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`);
|
||||
2
drizzle/0003_needy_doctor_strange.sql
Normal 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`);
|
||||
29
drizzle/0004_sharp_the_liberteens.sql
Normal 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;
|
||||
1
drizzle/0005_foamy_sage.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `accounts` ADD `public_account_id` text DEFAULT '' NOT NULL;
|
||||
17
drizzle/0006_moaning_dark_phoenix.sql
Normal 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;
|
||||
492
drizzle/meta/0000_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
530
drizzle/meta/0001_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
627
drizzle/meta/0002_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
644
drizzle/meta/0003_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
843
drizzle/meta/0004_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
851
drizzle/meta/0005_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
964
drizzle/meta/0006_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
55
drizzle/meta/_journal.json
Normal 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
@@ -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;
|
||||
58
examples/d1/app/api/notes/route.ts
Normal 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
@@ -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
@@ -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/
|
||||
5
koc-portal/.openai/hosting.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"project_id": "appgprj_6a6760b6f5bc8191ae5baf5cfed80804",
|
||||
"d1": null,
|
||||
"r2": null
|
||||
}
|
||||
21
koc-portal/README.md
Normal 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
|
||||
```
|
||||
86
koc-portal/app/chatgpt-auth.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export type ChatGPTUser = {
|
||||
displayName: string;
|
||||
email: string;
|
||||
fullName: string | null;
|
||||
};
|
||||
|
||||
const USER_EMAIL_HEADER = "oai-authenticated-user-email";
|
||||
const USER_FULL_NAME_HEADER = "oai-authenticated-user-full-name";
|
||||
const USER_FULL_NAME_ENCODING_HEADER =
|
||||
"oai-authenticated-user-full-name-encoding";
|
||||
const PERCENT_ENCODED_UTF8 = "percent-encoded-utf-8";
|
||||
const SIGN_IN_PATH = "/signin-with-chatgpt";
|
||||
const SIGN_OUT_PATH = "/signout-with-chatgpt";
|
||||
const CALLBACK_PATH = "/callback";
|
||||
|
||||
export async function getChatGPTUser(): Promise<ChatGPTUser | null> {
|
||||
const requestHeaders = await headers();
|
||||
const email = requestHeaders.get(USER_EMAIL_HEADER);
|
||||
if (!email) return null;
|
||||
|
||||
const encodedFullName = requestHeaders.get(USER_FULL_NAME_HEADER);
|
||||
const fullName =
|
||||
encodedFullName &&
|
||||
requestHeaders.get(USER_FULL_NAME_ENCODING_HEADER) === PERCENT_ENCODED_UTF8
|
||||
? safeDecodeURIComponent(encodedFullName)
|
||||
: null;
|
||||
|
||||
return {
|
||||
displayName: fullName ?? email,
|
||||
email,
|
||||
fullName,
|
||||
};
|
||||
}
|
||||
|
||||
export async function requireChatGPTUser(
|
||||
returnTo: string,
|
||||
): Promise<ChatGPTUser> {
|
||||
const user = await getChatGPTUser();
|
||||
if (user) return user;
|
||||
|
||||
redirect(chatGPTSignInPath(returnTo));
|
||||
}
|
||||
|
||||
export function chatGPTSignInPath(returnTo: string): string {
|
||||
const safeReturnTo = safeRelativeReturnPath(returnTo);
|
||||
return `${SIGN_IN_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
|
||||
}
|
||||
|
||||
export function chatGPTSignOutPath(returnTo = "/"): string {
|
||||
const safeReturnTo = safeRelativeReturnPath(returnTo);
|
||||
return `${SIGN_OUT_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
|
||||
}
|
||||
|
||||
function safeRelativeReturnPath(value: string): string {
|
||||
if (!value.startsWith("/") || value.startsWith("//")) return "/";
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value, "https://app.local");
|
||||
} catch {
|
||||
return "/";
|
||||
}
|
||||
if (url.origin !== "https://app.local") return "/";
|
||||
if (isReservedAuthPath(url.pathname)) return "/";
|
||||
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
}
|
||||
|
||||
function isReservedAuthPath(pathname: string): boolean {
|
||||
return (
|
||||
pathname === SIGN_IN_PATH ||
|
||||
pathname === SIGN_OUT_PATH ||
|
||||
pathname === CALLBACK_PATH
|
||||
);
|
||||
}
|
||||
|
||||
function safeDecodeURIComponent(value: string): string | null {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
26
koc-portal/app/date-utils.ts
Normal 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
77
koc-portal/app/layout.tsx
Normal 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
45
koc-portal/build/sites-vite-plugin.ts
Normal 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
@@ -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
@@ -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 {};
|
||||
7
koc-portal/drizzle.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
out: "./drizzle",
|
||||
schema: "./db/schema.ts",
|
||||
dialect: "sqlite",
|
||||
});
|
||||
5
koc-portal/drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": []
|
||||
}
|
||||
18
koc-portal/eslint.config.mjs
Normal 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;
|
||||
58
koc-portal/examples/d1/app/api/notes/route.ts
Normal 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
koc-portal/examples/d1/db/schema.ts
Normal 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`),
|
||||
});
|
||||
7
koc-portal/next.config.ts
Normal 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
42
koc-portal/package.json
Normal 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"
|
||||
}
|
||||
7
koc-portal/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
5
koc-portal/public/favicon.svg
Normal 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
koc-portal/public/file.svg
Normal 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
koc-portal/public/globe.svg
Normal 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
|
After Width: | Height: | Size: 658 KiB |
1
koc-portal/public/window.svg
Normal 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 |
106
koc-portal/tests/rendered-html.test.mjs
Normal 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
@@ -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
@@ -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,
|
||||
}),
|
||||
],
|
||||
};
|
||||
});
|
||||
47
koc-portal/worker/index.ts
Normal 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;
|
||||
318
lib/account-enrichment-service.ts
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
828
lib/feishu-source-snapshot.json
Normal 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
721
lib/mvp-db.ts
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
41
package.json
Normal 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
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
5
public/favicon.svg
Normal 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
@@ -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
@@ -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
|
After Width: | Height: | Size: 1.8 MiB |
1
public/window.svg
Normal 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 |
43
scripts/build-feishu-snapshot.mjs
Normal 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}`);
|
||||
315
scripts/sync-feishu-images.mjs
Normal 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
@@ -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/);
|
||||
});
|
||||
177
tests/feishu-client.test.mjs
Normal 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,
|
||||
),
|
||||
/不是电子表格/,
|
||||
);
|
||||
});
|
||||
510
tests/mcp-collection-client.test.mjs
Normal 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__={"noteData":{"desc":"测试","user":{"userId":"5fb21d32000000000101c23e","nickName":"555 五"}}}</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);
|
||||
});
|
||||
30
tests/partner-utils.test.mjs
Normal 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"),
|
||||
"",
|
||||
);
|
||||
});
|
||||
261
tests/rendered-html.test.mjs
Normal 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
@@ -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
@@ -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
@@ -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;
|
||||