feat: 新增项目中心——按项目组织任务/任务群/总群并一键推送
- 新表 projects / project_group_chats,partners 加 partner_type/wecom_userid,tasks 加 project_id(迁移 0010) - 项目 CRUD、总群成员配置、任务群人工映射(群主→合作方)、任务挂项目 - 推送:总群成员逐人应用消息 + 任务群按群主企业群发,结果分项回显
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -58,6 +58,17 @@ import {
|
|||||||
getAccountFieldRefreshStatus,
|
getAccountFieldRefreshStatus,
|
||||||
startAccountFieldRefresh,
|
startAccountFieldRefresh,
|
||||||
} from "../../../lib/account-field-refresh-service";
|
} from "../../../lib/account-field-refresh-service";
|
||||||
|
import {
|
||||||
|
deleteProject,
|
||||||
|
getProjectDetail,
|
||||||
|
listGroupOwnerPartners,
|
||||||
|
listProjects,
|
||||||
|
ProjectError,
|
||||||
|
saveGroupOwnerPartner,
|
||||||
|
saveProject,
|
||||||
|
setProjectGroupChats,
|
||||||
|
} from "../../../lib/project-service";
|
||||||
|
import { pushProjectTask } from "../../../lib/project-push-service";
|
||||||
|
|
||||||
type ActionBody = {
|
type ActionBody = {
|
||||||
action?: string;
|
action?: string;
|
||||||
@@ -119,6 +130,7 @@ export async function POST(request: Request) {
|
|||||||
dueAt,
|
dueAt,
|
||||||
platform,
|
platform,
|
||||||
contentFormat,
|
contentFormat,
|
||||||
|
projectId: String(body.projectId ?? "").trim(),
|
||||||
},
|
},
|
||||||
env as unknown as FeishuBindings,
|
env as unknown as FeishuBindings,
|
||||||
);
|
);
|
||||||
@@ -131,6 +143,7 @@ export async function POST(request: Request) {
|
|||||||
instructions: String(body.instructions ?? "").trim(),
|
instructions: String(body.instructions ?? "").trim(),
|
||||||
quantity: numberValue(body.quantity),
|
quantity: numberValue(body.quantity),
|
||||||
exampleImageKey: String(body.exampleImageKey ?? "").trim(),
|
exampleImageKey: String(body.exampleImageKey ?? "").trim(),
|
||||||
|
projectId: String(body.projectId ?? "").trim(),
|
||||||
});
|
});
|
||||||
} else if (body.action === "claim") {
|
} else if (body.action === "claim") {
|
||||||
const partnerId = String(body.partnerId ?? "");
|
const partnerId = String(body.partnerId ?? "");
|
||||||
@@ -687,6 +700,59 @@ export async function POST(request: Request) {
|
|||||||
const taskId = String(body.taskId ?? "").trim();
|
const taskId = String(body.taskId ?? "").trim();
|
||||||
const pushes = await listGroupPushes(db, taskId || undefined);
|
const pushes = await listGroupPushes(db, taskId || undefined);
|
||||||
return Response.json({ pushes });
|
return Response.json({ pushes });
|
||||||
|
} else if (body.action === "project_list") {
|
||||||
|
return Response.json({ projects: await listProjects(db) });
|
||||||
|
} else if (body.action === "project_detail") {
|
||||||
|
return Response.json(await getProjectDetail(db, String(body.projectId ?? "")));
|
||||||
|
} else if (body.action === "project_save") {
|
||||||
|
return Response.json({
|
||||||
|
project: await saveProject(db, {
|
||||||
|
id: body.id,
|
||||||
|
name: body.name,
|
||||||
|
brand: body.brand,
|
||||||
|
mainGroupMemberIds: body.mainGroupMemberIds,
|
||||||
|
note: body.note,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} else if (body.action === "project_delete") {
|
||||||
|
await deleteProject(db, String(body.projectId ?? ""));
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
} else if (body.action === "project_set_group_chats") {
|
||||||
|
const detail = await setProjectGroupChats(
|
||||||
|
db,
|
||||||
|
String(body.projectId ?? ""),
|
||||||
|
Array.isArray(body.items) ? body.items : [],
|
||||||
|
);
|
||||||
|
return Response.json(detail);
|
||||||
|
} else if (body.action === "partner_save") {
|
||||||
|
return Response.json({
|
||||||
|
partner: await saveGroupOwnerPartner(db, {
|
||||||
|
id: body.id,
|
||||||
|
name: body.name,
|
||||||
|
wecomUserid: body.wecomUserid,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} else if (body.action === "partner_group_owners") {
|
||||||
|
return Response.json({ partners: await listGroupOwnerPartners(db) });
|
||||||
|
} else if (body.action === "project_push_task") {
|
||||||
|
const wecomConfig = resolveWecomConfig(env as unknown as WecomBindings);
|
||||||
|
const outcome = await pushProjectTask(
|
||||||
|
db,
|
||||||
|
{
|
||||||
|
taskId: body.taskId,
|
||||||
|
sendMainGroup: body.sendMainGroup,
|
||||||
|
sendGroupChats: body.sendGroupChats,
|
||||||
|
text: body.text,
|
||||||
|
},
|
||||||
|
wecomConfig,
|
||||||
|
);
|
||||||
|
const appFailed = outcome.appResults.filter((item) => !item.ok);
|
||||||
|
return Response.json({
|
||||||
|
...outcome,
|
||||||
|
appSent: outcome.appResults.length - appFailed.length,
|
||||||
|
appFailedCount: appFailed.length,
|
||||||
|
hint: "任务群走企业群发,群主需在企微客户端「群发助手」点击发送后,消息才会送达客户群",
|
||||||
|
});
|
||||||
} else if (body.action === "refresh_account_fields") {
|
} else if (body.action === "refresh_account_fields") {
|
||||||
const status = startAccountFieldRefresh(
|
const status = startAccountFieldRefresh(
|
||||||
db,
|
db,
|
||||||
@@ -711,7 +777,8 @@ export async function POST(request: Request) {
|
|||||||
status:
|
status:
|
||||||
error instanceof FeishuSourceError ||
|
error instanceof FeishuSourceError ||
|
||||||
error instanceof DistributionReleaseError ||
|
error instanceof DistributionReleaseError ||
|
||||||
error instanceof WecomClientError
|
error instanceof WecomClientError ||
|
||||||
|
error instanceof ProjectError
|
||||||
? error.status
|
? error.status
|
||||||
: 500,
|
: 500,
|
||||||
},
|
},
|
||||||
|
|||||||
258
app/globals.css
258
app/globals.css
@@ -5372,3 +5372,261 @@ label small {
|
|||||||
animation-iteration-count: 1 !important;
|
animation-iteration-count: 1 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 项目中心 ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.project-hero-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-panel {
|
||||||
|
padding: 18px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card-title h3 {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card-title small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 3px;
|
||||||
|
color: #8a968f;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-note {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
color: #7d8a83;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-link-button.danger {
|
||||||
|
color: #c0453e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-detail {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px dashed #dfe6e1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(240px, 0.9fr) 1.4fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-detail-block {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-detail-block-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-detail-block-head h4 {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-detail-block-head small {
|
||||||
|
color: #97a29c;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-member-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-member-list code {
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #eef2ef;
|
||||||
|
color: #556861;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-empty-line {
|
||||||
|
color: #9aa39f;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-chat-table,
|
||||||
|
.project-task-table {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-chat-missing td {
|
||||||
|
color: #b0685f;
|
||||||
|
background: #fdf6f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-chat-missing td:first-child {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-push-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-push-bar small {
|
||||||
|
color: #97a29c;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-chat-hint {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
color: #7d8a83;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-chat-edit-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-chat-edit-row .group-push-row {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-chat-edit-row select {
|
||||||
|
max-width: 220px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-delete-warning {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #fdf6f5;
|
||||||
|
color: #a2564e;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-push-targets {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-push-target {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #e2e8e4;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #fbfcfb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-push-target b {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-push-target small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
color: #8a968f;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 合作方类型 ───────────────────────────────────────── */
|
||||||
|
|
||||||
|
.partner-type-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-type-tabs button {
|
||||||
|
padding: 5px 12px;
|
||||||
|
border: 1px solid #dfe6e1;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fbfcfb;
|
||||||
|
color: #687773;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-type-tabs button.active {
|
||||||
|
border-color: #205843;
|
||||||
|
background: #205843;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-type-badge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 6px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-style: normal;
|
||||||
|
vertical-align: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-type-badge.group-owner {
|
||||||
|
background: #e7f2ec;
|
||||||
|
color: #1f6b4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-type-badge.external-koc {
|
||||||
|
background: #eef1f6;
|
||||||
|
color: #4c5f85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-owner-hint {
|
||||||
|
color: #9aa39f;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.project-detail-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-hero-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
30
db/schema.ts
30
db/schema.ts
@@ -22,6 +22,10 @@ export const partners = mysqlTable("partners", {
|
|||||||
claimedTotal: int("claimed_total").notNull().default(0),
|
claimedTotal: int("claimed_total").notNull().default(0),
|
||||||
completedTotal: int("completed_total").notNull().default(0),
|
completedTotal: int("completed_total").notNull().default(0),
|
||||||
wecomExternalUserId: varchar("wecom_external_user_id", { length: 128 }),
|
wecomExternalUserId: varchar("wecom_external_user_id", { length: 128 }),
|
||||||
|
partnerType: varchar("partner_type", { length: 32 })
|
||||||
|
.notNull()
|
||||||
|
.default("external_koc"),
|
||||||
|
wecomUserid: varchar("wecom_userid", { length: 128 }).notNull().default(""),
|
||||||
createdAt: timestamp("created_at"),
|
createdAt: timestamp("created_at"),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -53,6 +57,7 @@ export const tasks = mysqlTable(
|
|||||||
mode: "string",
|
mode: "string",
|
||||||
fsp: 3,
|
fsp: 3,
|
||||||
}),
|
}),
|
||||||
|
projectId: varchar("project_id", { length: 64 }).notNull().default(""),
|
||||||
createdAt: timestamp("created_at"),
|
createdAt: timestamp("created_at"),
|
||||||
},
|
},
|
||||||
(table) => [uniqueIndex("tasks_share_token_idx").on(table.shareToken)],
|
(table) => [uniqueIndex("tasks_share_token_idx").on(table.shareToken)],
|
||||||
@@ -282,6 +287,31 @@ export const wecomGroupPushes = mysqlTable(
|
|||||||
(table) => [index("wecom_group_pushes_task_idx").on(table.taskId)],
|
(table) => [index("wecom_group_pushes_task_idx").on(table.taskId)],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const projects = mysqlTable("projects", {
|
||||||
|
id: varchar("id", { length: 64 }).primaryKey(),
|
||||||
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
|
brand: varchar("brand", { length: 255 }).notNull().default(""),
|
||||||
|
mainGroupMemberIds: text("main_group_member_ids").notNull(),
|
||||||
|
status: varchar("status", { length: 32 }).notNull().default("active"),
|
||||||
|
note: text("note").notNull(),
|
||||||
|
createdAt: timestamp("created_at"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const projectGroupChats = mysqlTable(
|
||||||
|
"project_group_chats",
|
||||||
|
{
|
||||||
|
id: varchar("id", { length: 64 }).primaryKey(),
|
||||||
|
projectId: varchar("project_id", { length: 64 }).notNull(),
|
||||||
|
chatId: varchar("chat_id", { length: 128 }).notNull(),
|
||||||
|
partnerId: varchar("partner_id", { length: 64 }).notNull().default(""),
|
||||||
|
createdAt: timestamp("created_at"),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("project_group_chats_uniq").on(table.projectId, table.chatId),
|
||||||
|
index("project_group_chats_project_idx").on(table.projectId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
export const backgroundJobs = mysqlTable(
|
export const backgroundJobs = mysqlTable(
|
||||||
"background_jobs",
|
"background_jobs",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -29,4 +29,4 @@ WECOM_NOTIFY_ENABLED=true
|
|||||||
|
|
||||||
# 本地测试:让 /api/bootstrap 自动 seed 3 个测试 partner + 5 条待发布 distribution
|
# 本地测试:让 /api/bootstrap 自动 seed 3 个测试 partner + 5 条待发布 distribution
|
||||||
# 上线后改为 false
|
# 上线后改为 false
|
||||||
SEED_DEMO_DATA=true
|
SEED_DEMO_DATA=false
|
||||||
|
|||||||
160
lib/project-push-service.ts
Normal file
160
lib/project-push-service.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import type { DatabaseClient } from "./database";
|
||||||
|
import { getRuntimeEnv } from "./runtime-env";
|
||||||
|
import {
|
||||||
|
sendWecomAppMessage,
|
||||||
|
WecomClientError,
|
||||||
|
type WecomConfig,
|
||||||
|
} from "./wecom-client";
|
||||||
|
import {
|
||||||
|
pushTaskToGroupChats,
|
||||||
|
type GroupPushResult,
|
||||||
|
type TaskPushRow,
|
||||||
|
} from "./wecom-group-push-service";
|
||||||
|
import { getProjectDetail, ProjectError } from "./project-service";
|
||||||
|
|
||||||
|
type FetchLike = typeof fetch;
|
||||||
|
|
||||||
|
export type AppMessageResult = {
|
||||||
|
userid: string;
|
||||||
|
ok: boolean;
|
||||||
|
error: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectPushOutcome = {
|
||||||
|
appResults: AppMessageResult[];
|
||||||
|
groupResults: GroupPushResult[];
|
||||||
|
groupChatCount: number;
|
||||||
|
skippedReasons: string[];
|
||||||
|
linkUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function text(value: unknown) {
|
||||||
|
return String(value ?? "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildProjectAppMessage(
|
||||||
|
task: Pick<
|
||||||
|
TaskPushRow,
|
||||||
|
| "name"
|
||||||
|
| "brand"
|
||||||
|
| "quantity"
|
||||||
|
| "due_at"
|
||||||
|
| "task_type"
|
||||||
|
| "platform"
|
||||||
|
| "content_format"
|
||||||
|
| "share_token"
|
||||||
|
>,
|
||||||
|
portalUrl: string,
|
||||||
|
): string {
|
||||||
|
const portal = text(portalUrl).replace(/\/$/, "");
|
||||||
|
const claimUrl =
|
||||||
|
portal && task.share_token ? `${portal}/?task=${task.share_token}` : "";
|
||||||
|
const formatLabel =
|
||||||
|
task.task_type === "screenshot_collect"
|
||||||
|
? "截图回收"
|
||||||
|
: task.content_format === "video"
|
||||||
|
? "视频"
|
||||||
|
: "图文";
|
||||||
|
return [
|
||||||
|
`【项目任务】${task.name}`,
|
||||||
|
`品牌:${task.brand}|数量:${task.quantity} 份|截止:${task.due_at}`,
|
||||||
|
`平台:${task.platform}|形式:${formatLabel}`,
|
||||||
|
"请把任务转发到你负责的项目任务群,并跟进群内成员领取与发布。",
|
||||||
|
claimUrl ? `领取链接:${claimUrl}` : "领取链接生成失败,请联系管理员",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pushProjectTask(
|
||||||
|
db: DatabaseClient,
|
||||||
|
params: {
|
||||||
|
taskId: unknown;
|
||||||
|
sendMainGroup?: unknown;
|
||||||
|
sendGroupChats?: unknown;
|
||||||
|
text?: unknown;
|
||||||
|
},
|
||||||
|
config: WecomConfig,
|
||||||
|
fetchImpl: FetchLike = fetch,
|
||||||
|
): Promise<ProjectPushOutcome> {
|
||||||
|
const taskId = text(params.taskId);
|
||||||
|
if (!taskId) throw new ProjectError("缺少 taskId");
|
||||||
|
const sendMainGroup = params.sendMainGroup !== false;
|
||||||
|
const sendGroupChats = params.sendGroupChats !== false;
|
||||||
|
if (!sendMainGroup && !sendGroupChats) {
|
||||||
|
throw new ProjectError("请至少选择一个发送目标(项目总群或任务群)");
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, name, brand, quantity, due_at, task_type, platform,
|
||||||
|
content_format, share_token
|
||||||
|
FROM tasks WHERE id = ?`,
|
||||||
|
)
|
||||||
|
.bind(taskId)
|
||||||
|
.first<TaskPushRow & { project_id: string }>();
|
||||||
|
if (!task) throw new ProjectError("任务不存在", 404);
|
||||||
|
if (!text(task.project_id)) {
|
||||||
|
throw new ProjectError("该任务未挂项目,请先在任务中心关联项目");
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail = await getProjectDetail(db, task.project_id);
|
||||||
|
const portalUrl = text(getRuntimeEnv().KOC_PORTAL_URL);
|
||||||
|
const fallback = buildProjectAppMessage(task, portalUrl);
|
||||||
|
const message = text(params.text) || fallback;
|
||||||
|
|
||||||
|
const skippedReasons: string[] = [];
|
||||||
|
const appResults: AppMessageResult[] = [];
|
||||||
|
if (sendMainGroup) {
|
||||||
|
const members = detail.project.mainGroupMemberIds;
|
||||||
|
if (members.length === 0) {
|
||||||
|
skippedReasons.push("项目总群未配置成员");
|
||||||
|
} else {
|
||||||
|
for (const userid of members) {
|
||||||
|
try {
|
||||||
|
await sendWecomAppMessage([userid], message, config, fetchImpl);
|
||||||
|
appResults.push({ userid, ok: true, error: "" });
|
||||||
|
} catch (error) {
|
||||||
|
appResults.push({
|
||||||
|
userid,
|
||||||
|
ok: false,
|
||||||
|
error: error instanceof Error ? error.message : "发送失败",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let groupResults: GroupPushResult[] = [];
|
||||||
|
let groupChatCount = 0;
|
||||||
|
let linkUrl =
|
||||||
|
task.share_token && portalUrl
|
||||||
|
? `${portalUrl.replace(/\/$/, "")}/?task=${task.share_token}`
|
||||||
|
: "";
|
||||||
|
if (sendGroupChats) {
|
||||||
|
const chatIds = detail.groupChats
|
||||||
|
.filter((chat) => !chat.missing)
|
||||||
|
.map((chat) => chat.chatId);
|
||||||
|
if (chatIds.length === 0) {
|
||||||
|
skippedReasons.push("项目没有可用的任务群");
|
||||||
|
} else {
|
||||||
|
const summary = await pushTaskToGroupChats(
|
||||||
|
db,
|
||||||
|
{ taskId, chatIds, text: message },
|
||||||
|
config,
|
||||||
|
fetchImpl,
|
||||||
|
);
|
||||||
|
groupResults = summary.results;
|
||||||
|
groupChatCount = chatIds.length;
|
||||||
|
linkUrl = summary.linkUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
appResults,
|
||||||
|
groupResults,
|
||||||
|
groupChatCount,
|
||||||
|
skippedReasons,
|
||||||
|
linkUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { WecomClientError };
|
||||||
385
lib/project-service.ts
Normal file
385
lib/project-service.ts
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
import type { DatabaseClient } from "./database";
|
||||||
|
|
||||||
|
export type ProjectRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
brand: string;
|
||||||
|
main_group_member_ids: string;
|
||||||
|
status: string;
|
||||||
|
note: string;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectSummary = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
brand: string;
|
||||||
|
status: string;
|
||||||
|
note: string;
|
||||||
|
createdAt: string;
|
||||||
|
mainGroupMemberIds: string[];
|
||||||
|
taskCount: number;
|
||||||
|
groupChatCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectTaskRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
brand: string;
|
||||||
|
quantity: number;
|
||||||
|
claimed_quantity: number;
|
||||||
|
due_at: string;
|
||||||
|
status: string;
|
||||||
|
task_type: string;
|
||||||
|
platform: string;
|
||||||
|
content_format: string;
|
||||||
|
share_token: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectGroupChat = {
|
||||||
|
chatId: string;
|
||||||
|
name: string;
|
||||||
|
ownerUserId: string;
|
||||||
|
memberCount: number;
|
||||||
|
status: number;
|
||||||
|
partnerId: string;
|
||||||
|
partnerName: string;
|
||||||
|
missing: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectDetail = {
|
||||||
|
project: ProjectSummary;
|
||||||
|
tasks: ProjectTaskRow[];
|
||||||
|
groupChats: ProjectGroupChat[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GroupOwnerPartnerRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
wecom_userid: string;
|
||||||
|
partner_type: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function text(value: unknown) {
|
||||||
|
return String(value ?? "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProjectError extends Error {
|
||||||
|
status: number;
|
||||||
|
constructor(message: string, status = 400) {
|
||||||
|
super(message);
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMemberIds(value: unknown): string[] {
|
||||||
|
const raw = Array.isArray(value)
|
||||||
|
? value.map((item) => text(item))
|
||||||
|
: text(value).split(/[\s,;,;]+/);
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const item of raw) {
|
||||||
|
if (item) seen.add(item);
|
||||||
|
}
|
||||||
|
return [...seen];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonArray(value: string): string[] {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
return Array.isArray(parsed) ? parsed.map((item) => String(item)) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listProjects(db: DatabaseClient): Promise<ProjectSummary[]> {
|
||||||
|
const result = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT p.id, p.name, p.brand, p.main_group_member_ids, p.status, p.note, p.created_at,
|
||||||
|
(SELECT COUNT(*) FROM tasks t WHERE t.project_id = p.id) AS task_count,
|
||||||
|
(SELECT COUNT(*) FROM project_group_chats g WHERE g.project_id = p.id) AS group_chat_count
|
||||||
|
FROM projects p
|
||||||
|
ORDER BY p.created_at DESC`,
|
||||||
|
)
|
||||||
|
.all<ProjectRow & { task_count: number; group_chat_count: number }>();
|
||||||
|
return result.results.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
brand: row.brand,
|
||||||
|
status: row.status,
|
||||||
|
note: row.note,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
mainGroupMemberIds: parseJsonArray(row.main_group_member_ids),
|
||||||
|
taskCount: Number(row.task_count),
|
||||||
|
groupChatCount: Number(row.group_chat_count),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProjectDetail(
|
||||||
|
db: DatabaseClient,
|
||||||
|
projectId: string,
|
||||||
|
): Promise<ProjectDetail> {
|
||||||
|
const id = text(projectId);
|
||||||
|
const project = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, name, brand, main_group_member_ids, status, note, created_at
|
||||||
|
FROM projects WHERE id = ?`,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.first<ProjectRow>();
|
||||||
|
if (!project) throw new ProjectError("项目不存在", 404);
|
||||||
|
|
||||||
|
const tasks = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, name, brand, quantity, claimed_quantity, due_at, status,
|
||||||
|
task_type, platform, content_format, share_token
|
||||||
|
FROM tasks WHERE project_id = ?
|
||||||
|
ORDER BY created_at DESC`,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.all<ProjectTaskRow>();
|
||||||
|
|
||||||
|
const chats = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT g.chat_id, g.partner_id,
|
||||||
|
c.name AS chat_name, c.owner_user_id, c.member_count, c.status AS chat_status,
|
||||||
|
p.name AS partner_name
|
||||||
|
FROM project_group_chats g
|
||||||
|
LEFT JOIN wecom_group_chats c ON c.chat_id = g.chat_id
|
||||||
|
LEFT JOIN partners p ON p.id = g.partner_id
|
||||||
|
WHERE g.project_id = ?
|
||||||
|
ORDER BY g.created_at`,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.all<{
|
||||||
|
chat_id: string;
|
||||||
|
partner_id: string;
|
||||||
|
chat_name: string | null;
|
||||||
|
owner_user_id: string | null;
|
||||||
|
member_count: number | null;
|
||||||
|
chat_status: number | null;
|
||||||
|
partner_name: string | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
project: {
|
||||||
|
id: project.id,
|
||||||
|
name: project.name,
|
||||||
|
brand: project.brand,
|
||||||
|
status: project.status,
|
||||||
|
note: project.note,
|
||||||
|
createdAt: project.created_at,
|
||||||
|
mainGroupMemberIds: parseJsonArray(project.main_group_member_ids),
|
||||||
|
taskCount: tasks.results.length,
|
||||||
|
groupChatCount: chats.results.length,
|
||||||
|
},
|
||||||
|
tasks: tasks.results,
|
||||||
|
groupChats: chats.results.map((row) => ({
|
||||||
|
chatId: row.chat_id,
|
||||||
|
name: row.chat_name ?? "",
|
||||||
|
ownerUserId: row.owner_user_id ?? "",
|
||||||
|
memberCount: Number(row.member_count ?? 0),
|
||||||
|
status: Number(row.chat_status ?? 0),
|
||||||
|
partnerId: row.partner_id,
|
||||||
|
partnerName: row.partner_name ?? "",
|
||||||
|
missing: row.chat_name === null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SaveProjectInput = {
|
||||||
|
id?: unknown;
|
||||||
|
name?: unknown;
|
||||||
|
brand?: unknown;
|
||||||
|
mainGroupMemberIds?: unknown;
|
||||||
|
note?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function saveProject(
|
||||||
|
db: DatabaseClient,
|
||||||
|
input: SaveProjectInput,
|
||||||
|
): Promise<ProjectSummary> {
|
||||||
|
const id = text(input.id);
|
||||||
|
const name = text(input.name);
|
||||||
|
const brand = text(input.brand);
|
||||||
|
const note = text(input.note ?? "");
|
||||||
|
const memberIds = parseMemberIds(input.mainGroupMemberIds);
|
||||||
|
if (!name) throw new ProjectError("请填写项目名称");
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
let projectId = id;
|
||||||
|
if (id) {
|
||||||
|
const result = await db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE projects
|
||||||
|
SET name = ?, brand = ?, main_group_member_ids = ?, note = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
)
|
||||||
|
.bind(name, brand, JSON.stringify(memberIds), note, id)
|
||||||
|
.run();
|
||||||
|
if (result.meta.changes === 0) throw new ProjectError("项目不存在", 404);
|
||||||
|
} else {
|
||||||
|
projectId = `proj-${crypto.randomUUID().slice(0, 12)}`;
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO projects
|
||||||
|
(id, name, brand, main_group_member_ids, status, note, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, 'active', ?, ?)`,
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
projectId,
|
||||||
|
name,
|
||||||
|
brand,
|
||||||
|
JSON.stringify(memberIds),
|
||||||
|
note,
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
const detail = await getProjectDetail(db, projectId);
|
||||||
|
return detail.project;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteProject(
|
||||||
|
db: DatabaseClient,
|
||||||
|
projectId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const id = text(projectId);
|
||||||
|
if (!id) throw new ProjectError("缺少项目 ID");
|
||||||
|
const result = await db
|
||||||
|
.prepare("DELETE FROM projects WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.run();
|
||||||
|
if (result.meta.changes === 0) throw new ProjectError("项目不存在", 404);
|
||||||
|
await db.batch([
|
||||||
|
db.prepare("DELETE FROM project_group_chats WHERE project_id = ?").bind(id),
|
||||||
|
db
|
||||||
|
.prepare("UPDATE tasks SET project_id = '' WHERE project_id = ?")
|
||||||
|
.bind(id),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProjectGroupChatInput = {
|
||||||
|
chatId: string;
|
||||||
|
partnerId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function setProjectGroupChats(
|
||||||
|
db: DatabaseClient,
|
||||||
|
projectId: string,
|
||||||
|
items: ProjectGroupChatInput[],
|
||||||
|
): Promise<ProjectDetail> {
|
||||||
|
const id = text(projectId);
|
||||||
|
await getProjectDetail(db, id);
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const rows: ProjectGroupChatInput[] = [];
|
||||||
|
for (const item of Array.isArray(items) ? items : []) {
|
||||||
|
const chatId = text(item?.chatId);
|
||||||
|
if (!chatId || seen.has(chatId)) continue;
|
||||||
|
seen.add(chatId);
|
||||||
|
rows.push({ chatId, partnerId: text(item?.partnerId) });
|
||||||
|
}
|
||||||
|
const statements = [
|
||||||
|
db.prepare("DELETE FROM project_group_chats WHERE project_id = ?").bind(id),
|
||||||
|
...rows.map((row) =>
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO project_group_chats
|
||||||
|
(id, project_id, chat_id, partner_id, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
`pgc-${crypto.randomUUID().slice(0, 12)}`,
|
||||||
|
id,
|
||||||
|
row.chatId,
|
||||||
|
row.partnerId ?? "",
|
||||||
|
new Date().toISOString(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
await db.batch(statements);
|
||||||
|
return getProjectDetail(db, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProjectPushTargets = {
|
||||||
|
project: { id: string; name: string; mainGroupMemberIds: string[] };
|
||||||
|
chatIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getProjectPushTargets(
|
||||||
|
db: DatabaseClient,
|
||||||
|
projectId: string,
|
||||||
|
): Promise<ProjectPushTargets> {
|
||||||
|
const detail = await getProjectDetail(db, projectId);
|
||||||
|
return {
|
||||||
|
project: {
|
||||||
|
id: detail.project.id,
|
||||||
|
name: detail.project.name,
|
||||||
|
mainGroupMemberIds: detail.project.mainGroupMemberIds,
|
||||||
|
},
|
||||||
|
chatIds: detail.groupChats.filter((chat) => !chat.missing).map((chat) => chat.chatId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listGroupOwnerPartners(
|
||||||
|
db: DatabaseClient,
|
||||||
|
): Promise<GroupOwnerPartnerRow[]> {
|
||||||
|
const result = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, name, wecom_userid, partner_type
|
||||||
|
FROM partners
|
||||||
|
WHERE partner_type = 'group_owner'
|
||||||
|
ORDER BY created_at DESC`,
|
||||||
|
)
|
||||||
|
.all<GroupOwnerPartnerRow>();
|
||||||
|
return result.results;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SavePartnerInput = {
|
||||||
|
id?: unknown;
|
||||||
|
name?: unknown;
|
||||||
|
wecomUserid?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function saveGroupOwnerPartner(
|
||||||
|
db: DatabaseClient,
|
||||||
|
input: SavePartnerInput,
|
||||||
|
): Promise<GroupOwnerPartnerRow> {
|
||||||
|
const id = text(input.id);
|
||||||
|
const name = text(input.name);
|
||||||
|
const wecomUserid = text(input.wecomUserid);
|
||||||
|
if (!name) throw new ProjectError("请填写合作方名称");
|
||||||
|
if (!wecomUserid) throw new ProjectError("请填写企业微信 userid");
|
||||||
|
|
||||||
|
let partnerId = id;
|
||||||
|
if (id) {
|
||||||
|
const result = await db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE partners
|
||||||
|
SET name = ?, wecom_userid = ?, partner_type = 'group_owner'
|
||||||
|
WHERE id = ?`,
|
||||||
|
)
|
||||||
|
.bind(name, wecomUserid, id)
|
||||||
|
.run();
|
||||||
|
if (result.meta.changes === 0) throw new ProjectError("合作方不存在", 404);
|
||||||
|
} else {
|
||||||
|
partnerId = `partner-go-${crypto.randomUUID().slice(0, 12)}`;
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO partners
|
||||||
|
(id, name, wecom_name, owner, claimed_total, completed_total,
|
||||||
|
partner_type, wecom_userid, created_at)
|
||||||
|
VALUES (?, ?, '', '运营组', 0, 0, 'group_owner', ?, ?)`,
|
||||||
|
)
|
||||||
|
.bind(partnerId, name, wecomUserid, new Date().toISOString())
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
const row = await db
|
||||||
|
.prepare(
|
||||||
|
"SELECT id, name, wecom_userid, partner_type FROM partners WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(partnerId)
|
||||||
|
.first<GroupOwnerPartnerRow>();
|
||||||
|
if (!row) throw new ProjectError("合作方保存失败", 500);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ export type CreateDistributionTaskInput = {
|
|||||||
dueAt: string;
|
dueAt: string;
|
||||||
platform?: "小红书" | "抖音";
|
platform?: "小红书" | "抖音";
|
||||||
contentFormat?: "image_text" | "video";
|
contentFormat?: "image_text" | "video";
|
||||||
|
projectId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CreateScreenshotTaskInput = {
|
export type CreateScreenshotTaskInput = {
|
||||||
@@ -22,6 +23,7 @@ export type CreateScreenshotTaskInput = {
|
|||||||
instructions: string;
|
instructions: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
exampleImageKey?: string;
|
exampleImageKey?: string;
|
||||||
|
projectId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DistributionTaskCreation = {
|
export type DistributionTaskCreation = {
|
||||||
@@ -140,10 +142,10 @@ async function insertTaskFromSource(
|
|||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO tasks
|
`INSERT INTO tasks
|
||||||
(id, name, brand, quantity, claimed_quantity, due_at, status,
|
(id, name, brand, quantity, claimed_quantity, due_at, status,
|
||||||
platform, content_format,
|
platform, content_format, project_id,
|
||||||
source_url, source_sheet_id, source_sheet_name, source_synced_at,
|
source_url, source_sheet_id, source_sheet_name, source_synced_at,
|
||||||
share_token)
|
share_token)
|
||||||
VALUES (?, ?, ?, ?, 0, ?, 'importing', ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, 0, ?, 'importing', ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
)
|
)
|
||||||
.bind(
|
.bind(
|
||||||
taskId,
|
taskId,
|
||||||
@@ -153,6 +155,7 @@ async function insertTaskFromSource(
|
|||||||
input.dueAt,
|
input.dueAt,
|
||||||
input.platform ?? "小红书",
|
input.platform ?? "小红书",
|
||||||
input.contentFormat ?? "image_text",
|
input.contentFormat ?? "image_text",
|
||||||
|
input.projectId,
|
||||||
source.url,
|
source.url,
|
||||||
source.sheetId,
|
source.sheetId,
|
||||||
source.sheetName,
|
source.sheetName,
|
||||||
@@ -220,6 +223,7 @@ export async function createDistributionTask(
|
|||||||
dueAt: normalizedDueDate(rawInput.dueAt),
|
dueAt: normalizedDueDate(rawInput.dueAt),
|
||||||
platform: rawInput.platform === "抖音" ? "抖音" : "小红书",
|
platform: rawInput.platform === "抖音" ? "抖音" : "小红书",
|
||||||
contentFormat: rawInput.contentFormat === "video" ? "video" : "image_text",
|
contentFormat: rawInput.contentFormat === "video" ? "video" : "image_text",
|
||||||
|
projectId: normalizedValue(rawInput.projectId ?? ""),
|
||||||
};
|
};
|
||||||
if (!input.feishuUrl || !input.name || !input.brand) {
|
if (!input.feishuUrl || !input.name || !input.brand) {
|
||||||
throw new Error("请补全飞书链接、任务名称和品牌/项目");
|
throw new Error("请补全飞书链接、任务名称和品牌/项目");
|
||||||
@@ -281,6 +285,7 @@ export async function createScreenshotTask(
|
|||||||
instructions: normalizedValue(rawInput.instructions),
|
instructions: normalizedValue(rawInput.instructions),
|
||||||
quantity: Math.floor(Number(rawInput.quantity)),
|
quantity: Math.floor(Number(rawInput.quantity)),
|
||||||
exampleImageKey: normalizedValue(rawInput.exampleImageKey ?? ""),
|
exampleImageKey: normalizedValue(rawInput.exampleImageKey ?? ""),
|
||||||
|
projectId: normalizedValue(rawInput.projectId ?? ""),
|
||||||
};
|
};
|
||||||
if (!input.name || !input.brand || !input.keyword || !input.instructions) {
|
if (!input.name || !input.brand || !input.keyword || !input.instructions) {
|
||||||
throw new Error("请补全任务名称、品牌/项目、搜索关键词和任务说明");
|
throw new Error("请补全任务名称、品牌/项目、搜索关键词和任务说明");
|
||||||
@@ -304,9 +309,9 @@ export async function createScreenshotTask(
|
|||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO tasks
|
`INSERT INTO tasks
|
||||||
(id, name, brand, quantity, claimed_quantity, due_at, status,
|
(id, name, brand, quantity, claimed_quantity, due_at, status,
|
||||||
task_type, source_url, source_sheet_id, source_sheet_name,
|
task_type, project_id, source_url, source_sheet_id, source_sheet_name,
|
||||||
share_token, collection_days)
|
share_token, collection_days)
|
||||||
VALUES (?, ?, ?, ?, 0, ?, 'active', 'screenshot_collect', '', '', '', ?, '[]')`,
|
VALUES (?, ?, ?, ?, 0, ?, 'active', 'screenshot_collect', ?, '', '', '', ?, '[]')`,
|
||||||
)
|
)
|
||||||
.bind(
|
.bind(
|
||||||
taskId,
|
taskId,
|
||||||
@@ -314,6 +319,7 @@ export async function createScreenshotTask(
|
|||||||
input.brand,
|
input.brand,
|
||||||
input.quantity,
|
input.quantity,
|
||||||
input.dueAt,
|
input.dueAt,
|
||||||
|
input.projectId,
|
||||||
shareToken,
|
shareToken,
|
||||||
)
|
)
|
||||||
.run();
|
.run();
|
||||||
|
|||||||
27
mysql/0010_projects_and_partner_type.sql
Normal file
27
mysql/0010_projects_and_partner_type.sql
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS `projects` (
|
||||||
|
`id` VARCHAR(64) NOT NULL,
|
||||||
|
`name` VARCHAR(255) NOT NULL,
|
||||||
|
`brand` VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
`main_group_member_ids` TEXT NOT NULL,
|
||||||
|
`status` VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||||
|
`note` TEXT NOT NULL,
|
||||||
|
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||||
|
-- statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS `project_group_chats` (
|
||||||
|
`id` VARCHAR(64) NOT NULL,
|
||||||
|
`project_id` VARCHAR(64) NOT NULL,
|
||||||
|
`chat_id` VARCHAR(128) NOT NULL,
|
||||||
|
`partner_id` VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `project_group_chats_uniq` (`project_id`,`chat_id`),
|
||||||
|
KEY `project_group_chats_project_idx` (`project_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||||
|
-- statement-breakpoint
|
||||||
|
ALTER TABLE `partners` ADD COLUMN `partner_type` VARCHAR(32) NOT NULL DEFAULT 'external_koc';
|
||||||
|
-- statement-breakpoint
|
||||||
|
ALTER TABLE `partners` ADD COLUMN `wecom_userid` VARCHAR(128) NOT NULL DEFAULT '';
|
||||||
|
-- statement-breakpoint
|
||||||
|
ALTER TABLE `tasks` ADD COLUMN `project_id` VARCHAR(64) NOT NULL DEFAULT '';
|
||||||
264
tests/project-push.test.mjs
Normal file
264
tests/project-push.test.mjs
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import {
|
||||||
|
buildProjectAppMessage,
|
||||||
|
pushProjectTask,
|
||||||
|
} from "../lib/project-push-service.ts";
|
||||||
|
import { ProjectError } from "../lib/project-service.ts";
|
||||||
|
import {
|
||||||
|
clearWecomAccessTokenCacheForTests,
|
||||||
|
resolveWecomConfig,
|
||||||
|
} from "../lib/wecom-client.ts";
|
||||||
|
|
||||||
|
const baseBindings = {
|
||||||
|
WECOM_CORP_ID: "corp-test",
|
||||||
|
WECOM_AGENT_ID: "1000001",
|
||||||
|
WECOM_SECRET: "secret-test",
|
||||||
|
};
|
||||||
|
|
||||||
|
const taskRow = {
|
||||||
|
id: "task-1",
|
||||||
|
name: "测试任务",
|
||||||
|
brand: "品牌A",
|
||||||
|
quantity: 3,
|
||||||
|
due_at: "2026-09-01",
|
||||||
|
task_type: "content_publish",
|
||||||
|
platform: "小红书",
|
||||||
|
content_format: "image_text",
|
||||||
|
share_token: "tok-123",
|
||||||
|
project_id: "proj-1",
|
||||||
|
claimed_quantity: 0,
|
||||||
|
status: "active",
|
||||||
|
};
|
||||||
|
|
||||||
|
const projectRow = {
|
||||||
|
id: "proj-1",
|
||||||
|
name: "项目一",
|
||||||
|
brand: "品牌A",
|
||||||
|
main_group_member_ids: JSON.stringify(["zhangsan", "lisi"]),
|
||||||
|
status: "active",
|
||||||
|
note: "",
|
||||||
|
created_at: "2026-08-01",
|
||||||
|
};
|
||||||
|
|
||||||
|
function fakePushDb({
|
||||||
|
task = taskRow,
|
||||||
|
project = projectRow,
|
||||||
|
links = [
|
||||||
|
{ project_id: "proj-1", chat_id: "wr-1", partner_id: "p-1" },
|
||||||
|
{ project_id: "proj-1", chat_id: "wr-2", partner_id: "" },
|
||||||
|
],
|
||||||
|
groupChats = [
|
||||||
|
{ chat_id: "wr-1", name: "一群", owner_user_id: "zhangsan", member_count: 10, status: 0 },
|
||||||
|
{ chat_id: "wr-2", name: "二群", owner_user_id: "lisi", member_count: 5, status: 0 },
|
||||||
|
],
|
||||||
|
} = {}) {
|
||||||
|
const executed = [];
|
||||||
|
const makeStatement = (sql, params = []) => ({
|
||||||
|
sql,
|
||||||
|
params,
|
||||||
|
bind(...args) {
|
||||||
|
return makeStatement(sql, args);
|
||||||
|
},
|
||||||
|
async run() {
|
||||||
|
executed.push({ sql, params });
|
||||||
|
return { meta: { changes: 1 } };
|
||||||
|
},
|
||||||
|
async first() {
|
||||||
|
executed.push({ sql, params });
|
||||||
|
if (/FROM tasks WHERE id = \?/.test(sql)) return task;
|
||||||
|
if (/FROM projects WHERE id = \?/.test(sql)) return project;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
async all() {
|
||||||
|
executed.push({ sql, params });
|
||||||
|
if (/FROM tasks WHERE project_id/.test(sql)) {
|
||||||
|
return { results: task ? [{ ...task }] : [] };
|
||||||
|
}
|
||||||
|
if (/FROM project_group_chats g/.test(sql)) {
|
||||||
|
return {
|
||||||
|
results: links
|
||||||
|
.filter((link) => link.project_id === params[0])
|
||||||
|
.map((link) => {
|
||||||
|
const chat = groupChats.find((item) => item.chat_id === link.chat_id);
|
||||||
|
return {
|
||||||
|
chat_id: link.chat_id,
|
||||||
|
partner_id: link.partner_id,
|
||||||
|
chat_name: chat ? chat.name : null,
|
||||||
|
owner_user_id: chat ? chat.owner_user_id : null,
|
||||||
|
member_count: chat ? chat.member_count : null,
|
||||||
|
chat_status: chat ? chat.status : null,
|
||||||
|
partner_name: null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (/FROM wecom_group_chats/.test(sql)) {
|
||||||
|
return {
|
||||||
|
results: sql.includes(" IN (")
|
||||||
|
? groupChats.filter((row) => params.includes(row.chat_id))
|
||||||
|
: groupChats,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { results: [] };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
executed,
|
||||||
|
prepare(sql) {
|
||||||
|
return makeStatement(sql);
|
||||||
|
},
|
||||||
|
async batch(statements) {
|
||||||
|
for (const statement of statements) await statement.run();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeWecomFetch(calls, { failAppFor = [] } = {}) {
|
||||||
|
return async (input, init = {}) => {
|
||||||
|
const url = new URL(String(input));
|
||||||
|
calls.push({ url, init });
|
||||||
|
if (url.pathname.endsWith("/cgi-bin/gettoken")) {
|
||||||
|
return Response.json({
|
||||||
|
errcode: 0,
|
||||||
|
errmsg: "ok",
|
||||||
|
access_token: "token-test",
|
||||||
|
expires_in: 7200,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.pathname.endsWith("/cgi-bin/message/send")) {
|
||||||
|
const body = JSON.parse(String(init.body));
|
||||||
|
if (failAppFor.includes(body.touser)) {
|
||||||
|
return Response.json({ errcode: 60111, errmsg: "userid not found" });
|
||||||
|
}
|
||||||
|
return Response.json({ errcode: 0, errmsg: "ok" });
|
||||||
|
}
|
||||||
|
if (url.pathname.endsWith("/cgi-bin/externalcontact/add_msg_template")) {
|
||||||
|
return Response.json({ errcode: 0, errmsg: "ok", msgid: "msg-1", fail_list: [] });
|
||||||
|
}
|
||||||
|
return new Response("not found", { status: 404 });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeEach(() => {
|
||||||
|
clearWecomAccessTokenCacheForTests();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildProjectAppMessage targets internal members with claim url", () => {
|
||||||
|
const message = buildProjectAppMessage(taskRow, "https://portal.example.com/");
|
||||||
|
assert.match(message, /【项目任务】测试任务/);
|
||||||
|
assert.match(message, /请把任务转发到你负责的项目任务群/);
|
||||||
|
assert.match(message, /领取链接:https:\/\/portal\.example\.com\/\?task=tok-123/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pushProjectTask sends per-member app messages and group templates", async () => {
|
||||||
|
process.env.KOC_PORTAL_URL = "https://portal.example.com";
|
||||||
|
try {
|
||||||
|
const db = fakePushDb();
|
||||||
|
const calls = [];
|
||||||
|
const outcome = await pushProjectTask(
|
||||||
|
db,
|
||||||
|
{ taskId: "task-1", sendMainGroup: true, sendGroupChats: true },
|
||||||
|
resolveWecomConfig(baseBindings),
|
||||||
|
fakeWecomFetch(calls),
|
||||||
|
);
|
||||||
|
const appCalls = calls.filter((call) =>
|
||||||
|
call.url.pathname.endsWith("/cgi-bin/message/send"),
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
appCalls.map((call) => JSON.parse(String(call.init.body)).touser),
|
||||||
|
["zhangsan", "lisi"],
|
||||||
|
"one message/send per main group member",
|
||||||
|
);
|
||||||
|
for (const call of appCalls) {
|
||||||
|
assert.match(JSON.parse(String(call.init.body)).text.content, /【项目任务】测试任务/);
|
||||||
|
}
|
||||||
|
assert.equal(outcome.appResults.length, 2);
|
||||||
|
assert.ok(outcome.appResults.every((item) => item.ok));
|
||||||
|
const templateCalls = calls.filter((call) =>
|
||||||
|
call.url.pathname.endsWith("/cgi-bin/externalcontact/add_msg_template"),
|
||||||
|
);
|
||||||
|
assert.equal(templateCalls.length, 2, "one template per owner");
|
||||||
|
assert.equal(outcome.groupResults.length, 2);
|
||||||
|
assert.equal(outcome.groupChatCount, 2);
|
||||||
|
assert.deepEqual(outcome.skippedReasons, []);
|
||||||
|
assert.equal(outcome.linkUrl, "https://portal.example.com/?task=tok-123");
|
||||||
|
} finally {
|
||||||
|
delete process.env.KOC_PORTAL_URL;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("single member failure does not block the rest of the push", async () => {
|
||||||
|
process.env.KOC_PORTAL_URL = "https://portal.example.com";
|
||||||
|
try {
|
||||||
|
const db = fakePushDb();
|
||||||
|
const calls = [];
|
||||||
|
const outcome = await pushProjectTask(
|
||||||
|
db,
|
||||||
|
{ taskId: "task-1" },
|
||||||
|
resolveWecomConfig(baseBindings),
|
||||||
|
fakeWecomFetch(calls, { failAppFor: ["zhangsan"] }),
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
outcome.appResults.map((item) => [item.userid, item.ok]),
|
||||||
|
[
|
||||||
|
["zhangsan", false],
|
||||||
|
["lisi", true],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.match(outcome.appResults[0].error, /userid not found/);
|
||||||
|
const templateCalls = calls.filter((call) =>
|
||||||
|
call.url.pathname.endsWith("/cgi-bin/externalcontact/add_msg_template"),
|
||||||
|
);
|
||||||
|
assert.equal(templateCalls.length, 2, "group push still runs");
|
||||||
|
} finally {
|
||||||
|
delete process.env.KOC_PORTAL_URL;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pushProjectTask skips empty targets instead of failing", async () => {
|
||||||
|
const db = fakePushDb({
|
||||||
|
project: { ...projectRow, main_group_member_ids: "[]" },
|
||||||
|
links: [{ project_id: "proj-1", chat_id: "wr-gone", partner_id: "" }],
|
||||||
|
groupChats: [],
|
||||||
|
});
|
||||||
|
const calls = [];
|
||||||
|
const outcome = await pushProjectTask(
|
||||||
|
db,
|
||||||
|
{ taskId: "task-1" },
|
||||||
|
resolveWecomConfig(baseBindings),
|
||||||
|
fakeWecomFetch(calls),
|
||||||
|
);
|
||||||
|
assert.deepEqual(outcome.skippedReasons, ["项目总群未配置成员", "项目没有可用的任务群"]);
|
||||||
|
assert.equal(calls.length, 0, "no wecom api calls expected");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pushProjectTask rejects task without project or unknown task", async () => {
|
||||||
|
const unlinked = fakePushDb({ task: { ...taskRow, project_id: "" } });
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
pushProjectTask(
|
||||||
|
unlinked,
|
||||||
|
{ taskId: "task-1" },
|
||||||
|
resolveWecomConfig(baseBindings),
|
||||||
|
fakeWecomFetch([]),
|
||||||
|
),
|
||||||
|
(error) => error instanceof ProjectError && /未挂项目/.test(error.message),
|
||||||
|
);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => pushProjectTask(fakePushDb(), { taskId: "" }, resolveWecomConfig(baseBindings)),
|
||||||
|
(error) => error instanceof ProjectError,
|
||||||
|
);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
pushProjectTask(
|
||||||
|
fakePushDb(),
|
||||||
|
{ taskId: "task-1", sendMainGroup: false, sendGroupChats: false },
|
||||||
|
resolveWecomConfig(baseBindings),
|
||||||
|
fakeWecomFetch([]),
|
||||||
|
),
|
||||||
|
(error) => error instanceof ProjectError && /至少选择一个发送目标/.test(error.message),
|
||||||
|
);
|
||||||
|
});
|
||||||
378
tests/project-service.test.mjs
Normal file
378
tests/project-service.test.mjs
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import {
|
||||||
|
deleteProject,
|
||||||
|
getProjectDetail,
|
||||||
|
listProjects,
|
||||||
|
parseMemberIds,
|
||||||
|
ProjectError,
|
||||||
|
saveGroupOwnerPartner,
|
||||||
|
saveProject,
|
||||||
|
setProjectGroupChats,
|
||||||
|
} from "../lib/project-service.ts";
|
||||||
|
|
||||||
|
function fakeProjectDb({
|
||||||
|
projects = [],
|
||||||
|
links = [],
|
||||||
|
tasks = [],
|
||||||
|
groupChats = [],
|
||||||
|
partners = [],
|
||||||
|
} = {}) {
|
||||||
|
const executed = [];
|
||||||
|
const applyWrite = (sql, params) => {
|
||||||
|
const normalized = sql.trim();
|
||||||
|
if (/^INSERT INTO projects/.test(normalized)) {
|
||||||
|
projects.push({
|
||||||
|
id: params[0],
|
||||||
|
name: params[1],
|
||||||
|
brand: params[2],
|
||||||
|
main_group_member_ids: params[3],
|
||||||
|
status: "active",
|
||||||
|
note: params[4],
|
||||||
|
created_at: params[5],
|
||||||
|
});
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (/^UPDATE projects/.test(normalized)) {
|
||||||
|
const row = projects.find((item) => item.id === params[4]);
|
||||||
|
if (!row) return 0;
|
||||||
|
Object.assign(row, {
|
||||||
|
name: params[0],
|
||||||
|
brand: params[1],
|
||||||
|
main_group_member_ids: params[2],
|
||||||
|
note: params[3],
|
||||||
|
});
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (/^DELETE FROM projects/.test(normalized)) {
|
||||||
|
const index = projects.findIndex((item) => item.id === params[0]);
|
||||||
|
if (index < 0) return 0;
|
||||||
|
projects.splice(index, 1);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (/^INSERT INTO project_group_chats/.test(normalized)) {
|
||||||
|
links.push({ project_id: params[1], chat_id: params[2], partner_id: params[3] });
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (/^DELETE FROM project_group_chats/.test(normalized)) {
|
||||||
|
for (let index = links.length - 1; index >= 0; index -= 1) {
|
||||||
|
if (links[index].project_id === params[0]) links.splice(index, 1);
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (/^UPDATE tasks SET project_id = ''/.test(normalized)) {
|
||||||
|
for (const task of tasks) {
|
||||||
|
if (task.project_id === params[0]) task.project_id = "";
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (/^INSERT INTO partners/.test(normalized)) {
|
||||||
|
partners.push({
|
||||||
|
id: params[0],
|
||||||
|
name: params[1],
|
||||||
|
wecom_userid: params[2],
|
||||||
|
partner_type: "group_owner",
|
||||||
|
});
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (/^UPDATE partners/.test(normalized)) {
|
||||||
|
const row = partners.find((item) => item.id === params[2]);
|
||||||
|
if (!row) return 0;
|
||||||
|
Object.assign(row, { name: params[0], wecom_userid: params[1] });
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
};
|
||||||
|
const makeStatement = (sql, params = []) => ({
|
||||||
|
sql,
|
||||||
|
params,
|
||||||
|
bind(...args) {
|
||||||
|
return makeStatement(sql, args);
|
||||||
|
},
|
||||||
|
async run() {
|
||||||
|
executed.push({ sql, params });
|
||||||
|
return { meta: { changes: applyWrite(sql, params) } };
|
||||||
|
},
|
||||||
|
async first() {
|
||||||
|
executed.push({ sql, params });
|
||||||
|
if (/FROM projects WHERE id = \?/.test(sql)) {
|
||||||
|
return projects.find((item) => item.id === params[0]) ?? null;
|
||||||
|
}
|
||||||
|
if (/FROM partners WHERE id = \?/.test(sql)) {
|
||||||
|
return partners.find((item) => item.id === params[0]) ?? null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
async all() {
|
||||||
|
executed.push({ sql, params });
|
||||||
|
if (/FROM projects p/.test(sql)) {
|
||||||
|
return {
|
||||||
|
results: projects.map((item) => ({
|
||||||
|
...item,
|
||||||
|
task_count: tasks.filter((task) => task.project_id === item.id).length,
|
||||||
|
group_chat_count: links.filter((link) => link.project_id === item.id).length,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (/FROM tasks WHERE project_id/.test(sql)) {
|
||||||
|
return {
|
||||||
|
results: tasks
|
||||||
|
.filter((task) => task.project_id === params[0])
|
||||||
|
.map((task) => ({ ...task })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (/FROM project_group_chats g/.test(sql)) {
|
||||||
|
return {
|
||||||
|
results: links
|
||||||
|
.filter((link) => link.project_id === params[0])
|
||||||
|
.map((link) => {
|
||||||
|
const chat = groupChats.find((item) => item.chat_id === link.chat_id);
|
||||||
|
return {
|
||||||
|
chat_id: link.chat_id,
|
||||||
|
partner_id: link.partner_id,
|
||||||
|
chat_name: chat ? chat.name : null,
|
||||||
|
owner_user_id: chat ? chat.owner_user_id : null,
|
||||||
|
member_count: chat ? chat.member_count : null,
|
||||||
|
chat_status: chat ? chat.status : null,
|
||||||
|
partner_name:
|
||||||
|
partners.find((item) => item.id === link.partner_id)?.name ?? null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (/FROM partners/.test(sql)) {
|
||||||
|
return { results: partners.map((item) => ({ ...item })) };
|
||||||
|
}
|
||||||
|
return { results: [] };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
projects,
|
||||||
|
links,
|
||||||
|
tasks,
|
||||||
|
groupChats,
|
||||||
|
partners,
|
||||||
|
executed,
|
||||||
|
prepare(sql) {
|
||||||
|
return makeStatement(sql);
|
||||||
|
},
|
||||||
|
async batch(statements) {
|
||||||
|
const output = [];
|
||||||
|
for (const statement of statements) output.push(await statement.run());
|
||||||
|
return output;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("parseMemberIds accepts lines, arrays and mixed separators, dedupes", () => {
|
||||||
|
assert.deepEqual(parseMemberIds("zhangsan\nlisi zhangsan"), ["zhangsan", "lisi"]);
|
||||||
|
assert.deepEqual(parseMemberIds(["a", " b ", "a"]), ["a", "b"]);
|
||||||
|
assert.deepEqual(parseMemberIds("a,b;c,d"), ["a", "b", "c", "d"]);
|
||||||
|
assert.deepEqual(parseMemberIds(" \n "), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("saveProject inserts with proj- prefixed id and JSON members", async () => {
|
||||||
|
const db = fakeProjectDb();
|
||||||
|
const project = await saveProject(db, {
|
||||||
|
name: "暑期焕新",
|
||||||
|
brand: "青柠实验室",
|
||||||
|
mainGroupMemberIds: "zhangsan\nlisi\nzhangsan",
|
||||||
|
note: "备注",
|
||||||
|
});
|
||||||
|
assert.match(project.id, /^proj-/);
|
||||||
|
assert.equal(project.name, "暑期焕新");
|
||||||
|
assert.deepEqual(project.mainGroupMemberIds, ["zhangsan", "lisi"]);
|
||||||
|
assert.equal(db.projects.length, 1);
|
||||||
|
assert.equal(db.projects[0].status, "active");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("saveProject updates existing row and rejects blank name", async () => {
|
||||||
|
const db = fakeProjectDb({
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
id: "proj-1",
|
||||||
|
name: "旧名",
|
||||||
|
brand: "",
|
||||||
|
main_group_member_ids: "[]",
|
||||||
|
status: "active",
|
||||||
|
note: "",
|
||||||
|
created_at: "2026-08-01",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await saveProject(db, {
|
||||||
|
id: "proj-1",
|
||||||
|
name: "新名",
|
||||||
|
brand: "品牌",
|
||||||
|
mainGroupMemberIds: ["wangwu"],
|
||||||
|
});
|
||||||
|
assert.equal(db.projects[0].name, "新名");
|
||||||
|
assert.equal(db.projects[0].brand, "品牌");
|
||||||
|
assert.deepEqual(JSON.parse(db.projects[0].main_group_member_ids), ["wangwu"]);
|
||||||
|
assert.equal(db.projects.length, 1, "update must not insert");
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => saveProject(db, { id: "proj-1", name: " " }),
|
||||||
|
(error) => error instanceof ProjectError && error.status === 400,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("saveProject rejects unknown id on update", async () => {
|
||||||
|
const db = fakeProjectDb();
|
||||||
|
await assert.rejects(
|
||||||
|
() => saveProject(db, { id: "proj-404", name: "x" }),
|
||||||
|
(error) => error instanceof ProjectError && error.status === 404,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("listProjects aggregates task and group chat counts", async () => {
|
||||||
|
const db = fakeProjectDb({
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
id: "proj-1",
|
||||||
|
name: "A",
|
||||||
|
brand: "",
|
||||||
|
main_group_member_ids: "[\"u1\",\"u2\"]",
|
||||||
|
status: "active",
|
||||||
|
note: "",
|
||||||
|
created_at: "2026-08-01",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tasks: [
|
||||||
|
{ id: "t1", project_id: "proj-1" },
|
||||||
|
{ id: "t2", project_id: "proj-1" },
|
||||||
|
{ id: "t3", project_id: "" },
|
||||||
|
],
|
||||||
|
links: [{ project_id: "proj-1", chat_id: "wr-1", partner_id: "" }],
|
||||||
|
});
|
||||||
|
const projects = await listProjects(db);
|
||||||
|
assert.equal(projects.length, 1);
|
||||||
|
assert.equal(projects[0].taskCount, 2);
|
||||||
|
assert.equal(projects[0].groupChatCount, 1);
|
||||||
|
assert.deepEqual(projects[0].mainGroupMemberIds, ["u1", "u2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getProjectDetail marks missing chats and dangling partners", async () => {
|
||||||
|
const db = fakeProjectDb({
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
id: "proj-1",
|
||||||
|
name: "A",
|
||||||
|
brand: "",
|
||||||
|
main_group_member_ids: "[]",
|
||||||
|
status: "active",
|
||||||
|
note: "",
|
||||||
|
created_at: "2026-08-01",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
links: [
|
||||||
|
{ project_id: "proj-1", chat_id: "wr-1", partner_id: "p-1" },
|
||||||
|
{ project_id: "proj-1", chat_id: "wr-gone", partner_id: "" },
|
||||||
|
],
|
||||||
|
groupChats: [
|
||||||
|
{ chat_id: "wr-1", name: "一群", owner_user_id: "zhangsan", member_count: 12, status: 0 },
|
||||||
|
],
|
||||||
|
partners: [],
|
||||||
|
});
|
||||||
|
const detail = await getProjectDetail(db, "proj-1");
|
||||||
|
assert.equal(detail.groupChats.length, 2);
|
||||||
|
const alive = detail.groupChats.find((chat) => chat.chatId === "wr-1");
|
||||||
|
assert.equal(alive.missing, false);
|
||||||
|
assert.equal(alive.partnerName, "", "dangling partner shows blank name");
|
||||||
|
const gone = detail.groupChats.find((chat) => chat.chatId === "wr-gone");
|
||||||
|
assert.equal(gone.missing, true);
|
||||||
|
assert.equal(gone.name, "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getProjectDetail throws 404 for unknown project", async () => {
|
||||||
|
const db = fakeProjectDb();
|
||||||
|
await assert.rejects(
|
||||||
|
() => getProjectDetail(db, "proj-404"),
|
||||||
|
(error) => error instanceof ProjectError && error.status === 404,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("setProjectGroupChats replaces links and dedupes chat ids", async () => {
|
||||||
|
const db = fakeProjectDb({
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
id: "proj-1",
|
||||||
|
name: "A",
|
||||||
|
brand: "",
|
||||||
|
main_group_member_ids: "[]",
|
||||||
|
status: "active",
|
||||||
|
note: "",
|
||||||
|
created_at: "2026-08-01",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
links: [{ project_id: "proj-1", chat_id: "wr-old", partner_id: "" }],
|
||||||
|
groupChats: [
|
||||||
|
{ chat_id: "wr-1", name: "一群", owner_user_id: "zhangsan", member_count: 12, status: 0 },
|
||||||
|
],
|
||||||
|
partners: [{ id: "p-1", name: "张三", wecom_userid: "zhangsan" }],
|
||||||
|
});
|
||||||
|
const detail = await setProjectGroupChats(db, "proj-1", [
|
||||||
|
{ chatId: "wr-1", partnerId: "p-1" },
|
||||||
|
{ chatId: "wr-1", partnerId: "p-1" },
|
||||||
|
{ chatId: " ", partnerId: "" },
|
||||||
|
]);
|
||||||
|
assert.equal(detail.groupChats.length, 1);
|
||||||
|
assert.equal(detail.groupChats[0].chatId, "wr-1");
|
||||||
|
assert.equal(detail.groupChats[0].partnerName, "张三");
|
||||||
|
assert.equal(db.links.length, 1);
|
||||||
|
assert.equal(db.links[0].chat_id, "wr-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deleteProject removes links and unlinks tasks", async () => {
|
||||||
|
const db = fakeProjectDb({
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
id: "proj-1",
|
||||||
|
name: "A",
|
||||||
|
brand: "",
|
||||||
|
main_group_member_ids: "[]",
|
||||||
|
status: "active",
|
||||||
|
note: "",
|
||||||
|
created_at: "2026-08-01",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
links: [{ project_id: "proj-1", chat_id: "wr-1", partner_id: "" }],
|
||||||
|
tasks: [
|
||||||
|
{ id: "t1", project_id: "proj-1" },
|
||||||
|
{ id: "t2", project_id: "proj-2" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await deleteProject(db, "proj-1");
|
||||||
|
assert.equal(db.projects.length, 0);
|
||||||
|
assert.equal(db.links.length, 0);
|
||||||
|
assert.equal(db.tasks[0].project_id, "");
|
||||||
|
assert.equal(db.tasks[1].project_id, "proj-2");
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => deleteProject(db, "proj-404"),
|
||||||
|
(error) => error instanceof ProjectError && error.status === 404,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("saveGroupOwnerPartner inserts and updates group_owner partners", async () => {
|
||||||
|
const db = fakeProjectDb();
|
||||||
|
const created = await saveGroupOwnerPartner(db, {
|
||||||
|
name: "张三",
|
||||||
|
wecomUserid: "zhangsan",
|
||||||
|
});
|
||||||
|
assert.match(created.id, /^partner-go-/);
|
||||||
|
assert.equal(db.partners[0].partner_type, "group_owner");
|
||||||
|
|
||||||
|
const updated = await saveGroupOwnerPartner(db, {
|
||||||
|
id: created.id,
|
||||||
|
name: "张三丰",
|
||||||
|
wecomUserid: "zhangsanfeng",
|
||||||
|
});
|
||||||
|
assert.equal(updated.name, "张三丰");
|
||||||
|
assert.equal(db.partners.length, 1);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => saveGroupOwnerPartner(db, { name: "李四", wecomUserid: "" }),
|
||||||
|
(error) => error instanceof ProjectError,
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user