feat: 新增项目中心——按项目组织任务/任务群/总群并一键推送

- 新表 projects / project_group_chats,partners 加 partner_type/wecom_userid,tasks 加 project_id(迁移 0010)
- 项目 CRUD、总群成员配置、任务群人工映射(群主→合作方)、任务挂项目
- 推送:总群成员逐人应用消息 + 任务群按群主企业群发,结果分项回显
This commit is contained in:
ABAPPLO
2026-08-31 16:35:17 +08:00
parent ed13262166
commit a6f5a5fd4a
11 changed files with 2557 additions and 15 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -58,6 +58,17 @@ import {
getAccountFieldRefreshStatus,
startAccountFieldRefresh,
} 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 = {
action?: string;
@@ -119,6 +130,7 @@ export async function POST(request: Request) {
dueAt,
platform,
contentFormat,
projectId: String(body.projectId ?? "").trim(),
},
env as unknown as FeishuBindings,
);
@@ -131,6 +143,7 @@ export async function POST(request: Request) {
instructions: String(body.instructions ?? "").trim(),
quantity: numberValue(body.quantity),
exampleImageKey: String(body.exampleImageKey ?? "").trim(),
projectId: String(body.projectId ?? "").trim(),
});
} else if (body.action === "claim") {
const partnerId = String(body.partnerId ?? "");
@@ -687,6 +700,59 @@ export async function POST(request: Request) {
const taskId = String(body.taskId ?? "").trim();
const pushes = await listGroupPushes(db, taskId || undefined);
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") {
const status = startAccountFieldRefresh(
db,
@@ -711,7 +777,8 @@ export async function POST(request: Request) {
status:
error instanceof FeishuSourceError ||
error instanceof DistributionReleaseError ||
error instanceof WecomClientError
error instanceof WecomClientError ||
error instanceof ProjectError
? error.status
: 500,
},

View File

@@ -5372,3 +5372,261 @@ label small {
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;
}
}

View File

@@ -22,6 +22,10 @@ export const partners = mysqlTable("partners", {
claimedTotal: int("claimed_total").notNull().default(0),
completedTotal: int("completed_total").notNull().default(0),
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"),
});
@@ -53,6 +57,7 @@ export const tasks = mysqlTable(
mode: "string",
fsp: 3,
}),
projectId: varchar("project_id", { length: 64 }).notNull().default(""),
createdAt: timestamp("created_at"),
},
(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)],
);
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(
"background_jobs",
{

View File

@@ -29,4 +29,4 @@ WECOM_NOTIFY_ENABLED=true
# 本地测试:让 /api/bootstrap 自动 seed 3 个测试 partner + 5 条待发布 distribution
# 上线后改为 false
SEED_DEMO_DATA=true
SEED_DEMO_DATA=false

160
lib/project-push-service.ts Normal file
View 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
View 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;
}

View File

@@ -12,6 +12,7 @@ export type CreateDistributionTaskInput = {
dueAt: string;
platform?: "小红书" | "抖音";
contentFormat?: "image_text" | "video";
projectId?: string;
};
export type CreateScreenshotTaskInput = {
@@ -22,6 +23,7 @@ export type CreateScreenshotTaskInput = {
instructions: string;
quantity: number;
exampleImageKey?: string;
projectId?: string;
};
export type DistributionTaskCreation = {
@@ -140,10 +142,10 @@ async function insertTaskFromSource(
.prepare(
`INSERT INTO tasks
(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,
share_token)
VALUES (?, ?, ?, ?, 0, ?, 'importing', ?, ?, ?, ?, ?, ?, ?)`,
VALUES (?, ?, ?, ?, 0, ?, 'importing', ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
taskId,
@@ -153,6 +155,7 @@ async function insertTaskFromSource(
input.dueAt,
input.platform ?? "小红书",
input.contentFormat ?? "image_text",
input.projectId,
source.url,
source.sheetId,
source.sheetName,
@@ -220,6 +223,7 @@ export async function createDistributionTask(
dueAt: normalizedDueDate(rawInput.dueAt),
platform: rawInput.platform === "抖音" ? "抖音" : "小红书",
contentFormat: rawInput.contentFormat === "video" ? "video" : "image_text",
projectId: normalizedValue(rawInput.projectId ?? ""),
};
if (!input.feishuUrl || !input.name || !input.brand) {
throw new Error("请补全飞书链接、任务名称和品牌/项目");
@@ -281,6 +285,7 @@ export async function createScreenshotTask(
instructions: normalizedValue(rawInput.instructions),
quantity: Math.floor(Number(rawInput.quantity)),
exampleImageKey: normalizedValue(rawInput.exampleImageKey ?? ""),
projectId: normalizedValue(rawInput.projectId ?? ""),
};
if (!input.name || !input.brand || !input.keyword || !input.instructions) {
throw new Error("请补全任务名称、品牌/项目、搜索关键词和任务说明");
@@ -304,9 +309,9 @@ export async function createScreenshotTask(
.prepare(
`INSERT INTO tasks
(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)
VALUES (?, ?, ?, ?, 0, ?, 'active', 'screenshot_collect', '', '', '', ?, '[]')`,
VALUES (?, ?, ?, ?, 0, ?, 'active', 'screenshot_collect', ?, '', '', '', ?, '[]')`,
)
.bind(
taskId,
@@ -314,6 +319,7 @@ export async function createScreenshotTask(
input.brand,
input.quantity,
input.dueAt,
input.projectId,
shareToken,
)
.run();

View 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
View 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),
);
});

View 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,
);
});