feat: 项目总群自动建群+任务负责人后台视角+客户群活码进群+任务编辑

- 项目总群:≥3 人 appchat/create 自动建群(chatid=pgc+projectId),成员 diff 增删同步;DROP project_group_chats;推送改 appchat/send 群发、无群降级逐人
- 任务负责人:tasks.owner_userid(新建/编辑任务通讯录选人),task_set_owner 分配即预建 tgc 任务群发介绍,群主链接只归属负责人
- 任务编辑 task_update:基础字段+截图任务数量/关键词/说明,contents 调平只删 available 尾行
- 删除「一键推送」:task-one-click-push-service 整体移除(recordCardAck 移入 wecom-callback-events)
- 负责人后台视角:users.wecom_userid 绑定,lib/owner-scope 行级过滤(undefined=全量/""=空范围/非空=过滤),~30 action 补 managerGate/taskOwnerGate,owner 登录仅见工作台/任务中心/数据回收/复盘报表
- 任务绑定客户群+进群活码:tasks 增 customer_chat_id/name、join_config_id、join_qr_code 4 列,join_way add/update/get/del(换绑走 update 保 config_id 活码不变),后台「客户群」弹窗,portal 认领后扫码自助进群(满员自动开新群)
- 迁移 0013/0014/0015;催办支持按任务;企微可信域名 IP 验证文件
- 288 测试全绿;真机全链路验收通过(列群/活码四端点/portal 渲染/解绑清空)
This commit is contained in:
ABAPPLO
2026-09-04 18:03:46 +08:00
parent 5430624a43
commit dd5b603149
44 changed files with 4655 additions and 2039 deletions

View File

@@ -104,6 +104,10 @@ export async function ensureSchema(database?: DatabaseClient) {
collection_start_date TEXT,
collection_days TEXT NOT NULL DEFAULT '[]',
collection_schedule_updated_at TEXT,
customer_chat_id TEXT NOT NULL DEFAULT '',
customer_chat_name TEXT NOT NULL DEFAULT '',
join_config_id TEXT NOT NULL DEFAULT '',
join_qr_code TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS contents (
@@ -371,6 +375,31 @@ export async function ensureSchema(database?: DatabaseClient) {
"wecom_external_user_id",
"wecom_external_user_id TEXT",
);
await ensureColumn(
"users",
"wecom_userid",
"wecom_userid TEXT NOT NULL DEFAULT ''",
);
await ensureColumn(
"tasks",
"customer_chat_id",
"customer_chat_id TEXT NOT NULL DEFAULT ''",
);
await ensureColumn(
"tasks",
"customer_chat_name",
"customer_chat_name TEXT NOT NULL DEFAULT ''",
);
await ensureColumn(
"tasks",
"join_config_id",
"join_config_id TEXT NOT NULL DEFAULT ''",
);
await ensureColumn(
"tasks",
"join_qr_code",
"join_qr_code TEXT NOT NULL DEFAULT ''",
);
const tasksWithoutShare = await db
.prepare(
@@ -812,34 +841,21 @@ export async function seedIfEmpty() {
await db.batch(batch);
}
export async function getDashboardData() {
export async function getDashboardData(ownerUserid?: string) {
const db = getRawDb();
const [partnersResult, tasksResult, accountsResult, distributionsResult] =
await Promise.all([
db.prepare("SELECT * FROM partners ORDER BY created_at DESC").all(),
db.prepare("SELECT * FROM tasks ORDER BY created_at DESC").all(),
db
.prepare(
`SELECT
a.*,
COALESCE(
(
SELECT d.publish_url
FROM distributions d
WHERE d.account_id = a.id
AND TRIM(COALESCE(d.publish_url, '')) != ''
ORDER BY d.updated_at DESC, d.claimed_at DESC
LIMIT 1
),
''
) AS latest_publish_url
FROM accounts a
ORDER BY a.last_seen_at DESC`,
)
.all(),
db
.prepare(
`SELECT
// null=全量;""=已登录未绑定企微(空范围);非空=按负责人过滤
const ownerFilter = ownerUserid === undefined ? null : String(ownerUserid).trim();
const emptyResult = () =>
Promise.resolve({
results: [] as Record<string, unknown>[],
success: true,
meta: { changes: 0, last_row_id: 0 },
});
const tasksBase = `SELECT t.*,
(SELECT c.title FROM contents c WHERE c.task_id = t.id ORDER BY c.source_row LIMIT 1) AS keyword,
(SELECT c.body FROM contents c WHERE c.task_id = t.id ORDER BY c.source_row LIMIT 1) AS instructions
FROM tasks t`;
const distributionsBase = `SELECT
d.*,
c.title AS content_title,
p.name AS partner_name,
@@ -857,10 +873,57 @@ export async function getDashboardData() {
JOIN partners p ON p.id = d.partner_id
LEFT JOIN claims cl ON cl.id = d.claim_id
JOIN tasks t ON t.id = d.task_id
LEFT JOIN accounts a ON a.id = d.account_id
ORDER BY d.updated_at DESC, d.claimed_at DESC`,
)
.all(),
LEFT JOIN accounts a ON a.id = d.account_id`;
const [partnersResult, tasksResult, accountsResult, distributionsResult] =
await Promise.all([
ownerFilter === null
? db.prepare("SELECT * FROM partners ORDER BY created_at DESC").all()
: emptyResult(),
ownerFilter
? db
.prepare(
`${tasksBase} WHERE t.owner_userid = ? ORDER BY t.created_at DESC`,
)
.bind(ownerFilter)
.all()
: ownerFilter === null
? db.prepare(`${tasksBase} ORDER BY t.created_at DESC`).all()
: emptyResult(),
ownerFilter === null
? db
.prepare(
`SELECT
a.*,
COALESCE(
(
SELECT d.publish_url
FROM distributions d
WHERE d.account_id = a.id
AND TRIM(COALESCE(d.publish_url, '')) != ''
ORDER BY d.updated_at DESC, d.claimed_at DESC
LIMIT 1
),
''
) AS latest_publish_url
FROM accounts a
ORDER BY a.last_seen_at DESC`,
)
.all()
: emptyResult(),
ownerFilter
? db
.prepare(
`${distributionsBase} WHERE t.owner_userid = ? ORDER BY d.updated_at DESC, d.claimed_at DESC`,
)
.bind(ownerFilter)
.all()
: ownerFilter === null
? db
.prepare(
`${distributionsBase} ORDER BY d.updated_at DESC, d.claimed_at DESC`,
)
.all()
: emptyResult(),
]);
return {
@@ -903,10 +966,13 @@ export type TaskReportRow = {
latest_shares: number | null;
};
export async function getTaskReportData(db: DatabaseClient) {
const result = await db
.prepare(
`SELECT
export async function getTaskReportData(db: DatabaseClient, ownerUserid?: string) {
// undefined=全量;""=未绑定企微的空范围(不能绑定 '',否则会匹配 owner_userid='' 的任务)
if (ownerUserid !== undefined && String(ownerUserid).trim() === "") {
return [] as TaskReportRow[];
}
const ownerFilter = String(ownerUserid ?? "").trim();
const base = `SELECT
t.id,
t.name,
t.task_type,
@@ -937,11 +1003,15 @@ export async function getTaskReportData(db: DatabaseClient) {
SUM(d.latest_shares) AS latest_shares
FROM tasks t
LEFT JOIN distributions d ON d.task_id = t.id
LEFT JOIN projects p ON p.id = t.project_id
GROUP BY t.id
ORDER BY t.created_at DESC`,
)
.all<TaskReportRow>();
LEFT JOIN projects p ON p.id = t.project_id`;
const statement = ownerFilter
? db
.prepare(
`${base} WHERE t.owner_userid = ? GROUP BY t.id ORDER BY t.created_at DESC`,
)
.bind(ownerFilter)
: db.prepare(`${base} GROUP BY t.id ORDER BY t.created_at DESC`);
const result = await statement.all<TaskReportRow>();
return result.results;
}

61
lib/owner-scope.ts Normal file
View File

@@ -0,0 +1,61 @@
import type { DatabaseClient } from "./database";
import { getRequestPrincipal } from "./user-auth";
export type OwnerScope =
| { kind: "full" }
| { kind: "owner"; wecomUserId: string };
export async function getOwnerScope(request: Request): Promise<OwnerScope> {
const principal = await getRequestPrincipal(request);
if (!principal) return { kind: "owner", wecomUserId: "" };
if (principal.kind === "internal") return { kind: "full" };
if (
principal.user.role === "super_admin" ||
principal.user.role === "admin"
) {
return { kind: "full" };
}
return { kind: "owner", wecomUserId: principal.user.wecom_userid ?? "" };
}
export function ownerScopeUserid(scope: OwnerScope): string | undefined {
return scope.kind === "owner" ? scope.wecomUserId : undefined;
}
export async function isTaskOwner(
db: DatabaseClient,
taskId: string,
wecomUserId: string,
) {
const id = String(taskId ?? "").trim();
if (!id || !wecomUserId) return false;
const row = await db
.prepare("SELECT id FROM tasks WHERE id = ? AND owner_userid = ? LIMIT 1")
.bind(id, wecomUserId)
.first<{ id: string }>();
return Boolean(row);
}
export async function isDistributionOwner(
db: DatabaseClient,
distributionId: string,
wecomUserId: string,
) {
const id = String(distributionId ?? "").trim();
if (!id || !wecomUserId) return false;
const row = await db
.prepare(
`SELECT d.id
FROM distributions d
JOIN tasks t ON t.id = d.task_id
WHERE d.id = ? AND t.owner_userid = ?
LIMIT 1`,
)
.bind(id, wecomUserId)
.first<{ id: string }>();
return Boolean(row);
}
export function ownerForbidden() {
return Response.json({ error: "该任务未分配给您,无权操作" }, { status: 403 });
}

View File

@@ -1,16 +1,17 @@
import type { DatabaseClient } from "./database";
import { getRuntimeEnv } from "./runtime-env";
import {
sendAppChatMessage,
sendWecomAppMessage,
WecomClientError,
type WecomConfig,
} from "./wecom-client";
import {
pushTaskToGroupChats,
type GroupPushResult,
type TaskPushRow,
} from "./wecom-group-push-service";
ensureTaskChatForMembers,
sendTaskChatMessage,
} from "./task-group-service";
import { getProjectDetail, ProjectError } from "./project-service";
import type { TaskPushRow } from "./wecom-group-push-service";
type FetchLike = typeof fetch;
@@ -20,10 +21,18 @@ export type AppMessageResult = {
error: string;
};
export type MainChatMode = "group" | "per_member" | "skipped";
export type TaskChatPushResult = {
sent: boolean;
chatId: string;
reason?: string;
};
export type ProjectPushOutcome = {
mainChatMode: MainChatMode;
appResults: AppMessageResult[];
groupResults: GroupPushResult[];
groupChatCount: number;
taskChat: TaskChatPushResult;
skippedReasons: string[];
linkUrl: string;
};
@@ -64,6 +73,13 @@ export function buildProjectAppMessage(
].join("\n");
}
const TASK_CHAT_SKIP_REASONS: Record<string, string> = {
no_task: "任务不存在",
no_owner: "任务与项目都未填负责人",
not_enough_members: "任务群成员不足 2 人",
no_credentials: "企业微信未配置",
};
export async function pushProjectTask(
db: DatabaseClient,
params: {
@@ -86,7 +102,7 @@ export async function pushProjectTask(
const task = await db
.prepare(
`SELECT id, name, brand, quantity, due_at, task_type, platform,
content_format, share_token
content_format, share_token, project_id
FROM tasks WHERE id = ?`,
)
.bind(taskId)
@@ -103,55 +119,78 @@ export async function pushProjectTask(
const skippedReasons: string[] = [];
const appResults: AppMessageResult[] = [];
if (sendMainGroup) {
let mainChatMode: MainChatMode = "skipped";
const sendPerMember = async () => {
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 : "发送失败",
});
}
skippedReasons.push("项目总群未建且未配置成员");
return;
}
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 : "发送失败",
});
}
}
mainChatMode = "per_member";
};
if (sendMainGroup) {
if (detail.project.mainChatId) {
try {
await sendAppChatMessage(
config,
{ chatid: detail.project.mainChatId, text: message },
fetchImpl,
);
mainChatMode = "group";
} catch (error) {
skippedReasons.push(
`项目总群群发失败(${
error instanceof Error ? error.message : "未知错误"
}),已降级逐人私信`,
);
await sendPerMember();
}
} else {
skippedReasons.push("项目总群未建,已逐人私信");
await sendPerMember();
}
}
let groupResults: GroupPushResult[] = [];
let groupChatCount = 0;
let linkUrl =
let taskChat: TaskChatPushResult = { sent: false, chatId: "" };
if (sendGroupChats) {
const ensure = await ensureTaskChatForMembers(db, { taskId }, config, fetchImpl);
if (ensure.chatId) {
const sent = await sendTaskChatMessage(db, taskId, message, config, fetchImpl);
taskChat = sent
? { sent: true, chatId: ensure.chatId }
: { sent: false, chatId: ensure.chatId, reason: "消息发送失败" };
if (!sent) skippedReasons.push("任务群消息发送失败");
} else {
const reason =
TASK_CHAT_SKIP_REASONS[ensure.reason ?? ""] ?? "任务群暂不可用";
taskChat = { sent: false, chatId: "", reason };
skippedReasons.push(`任务群未发送:${reason}`);
}
}
const 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 {
mainChatMode,
appResults,
groupResults,
groupChatCount,
taskChat,
skippedReasons,
linkUrl,
};

View File

@@ -1,4 +1,14 @@
import type { DatabaseClient } from "./database";
import {
createAppChat,
getAppChat,
hasAppCredentials,
sanitizeChatid,
updateAppChat,
type WecomConfig,
} from "./wecom-client";
type FetchLike = typeof fetch;
export type ProjectRow = {
id: string;
@@ -6,6 +16,7 @@ export type ProjectRow = {
brand: string;
main_group_member_ids: string;
owner_userid: string;
main_chat_id: string;
status: string;
note: string;
created_at: string;
@@ -20,8 +31,8 @@ export type ProjectSummary = {
createdAt: string;
mainGroupMemberIds: string[];
ownerUserid: string;
mainChatId: string;
taskCount: number;
groupChatCount: number;
};
export type ProjectTaskRow = {
@@ -36,23 +47,31 @@ export type ProjectTaskRow = {
platform: string;
content_format: string;
share_token: string | null;
owner_userid: string;
task_chat_id: string;
};
export type ProjectGroupChat = {
export type ProjectMainChat = {
chatId: string;
name: string;
ownerUserId: string;
memberCount: number;
status: number;
partnerId: string;
partnerName: string;
missing: boolean;
members: string[];
};
export type ProjectDetail = {
project: ProjectSummary;
tasks: ProjectTaskRow[];
groupChats: ProjectGroupChat[];
mainChat: ProjectMainChat;
};
export type MainChatEnsureReason =
| "no_project"
| "no_credentials"
| "not_enough_members";
export type MainChatEnsureResult = {
chatId: string | null;
members: string[];
reason?: MainChatEnsureReason;
warning?: string;
};
export type GroupOwnerPartnerRow = {
@@ -94,16 +113,126 @@ function parseJsonArray(value: string): string[] {
}
}
export function effectiveProjectMembers(
memberIdsValue: unknown,
ownerUserid: unknown,
): string[] {
const raw =
typeof memberIdsValue === "string" && memberIdsValue.trim().startsWith("[")
? parseJsonArray(memberIdsValue)
: memberIdsValue;
const members = parseMemberIds(raw);
const owner = text(ownerUserid);
if (owner && !members.includes(owner)) members.push(owner);
return members;
}
export function projectMainChatId(projectId: string) {
return `pgc${sanitizeChatid(text(projectId))}`;
}
export async function ensureProjectMainChat(
db: DatabaseClient,
projectId: string,
config: WecomConfig,
fetchImpl: FetchLike = fetch,
): Promise<MainChatEnsureResult> {
const id = text(projectId);
if (!id) return { chatId: null, members: [], reason: "no_project" };
const project = await db
.prepare(
"SELECT id, name, main_group_member_ids, owner_userid, main_chat_id FROM projects WHERE id = ?",
)
.bind(id)
.first<ProjectRow>();
if (!project) return { chatId: null, members: [], reason: "no_project" };
const members = effectiveProjectMembers(
project.main_group_member_ids,
project.owner_userid,
);
const existingChatId = text(project.main_chat_id);
if (!hasAppCredentials(config)) {
return {
chatId: existingChatId || null,
members,
reason: existingChatId ? undefined : "no_credentials",
};
}
if (existingChatId) {
try {
const info = await getAppChat(config, existingChatId, fetchImpl);
if (info) {
const inGroup = new Set(info.userlist);
const addUserList = members.filter((userid) => !inGroup.has(userid));
const delUserList = info.userlist.filter(
(userid) => !members.includes(userid),
);
if (addUserList.length > 0 || delUserList.length > 0) {
await updateAppChat(
config,
{ chatid: existingChatId, addUserList, delUserList },
fetchImpl,
);
}
} else {
// appchat/get 要求应用可见范围为根部门,查不到成员时降级为只增同步(重复添加按成功处理)
await updateAppChat(
config,
{ chatid: existingChatId, addUserList: members },
fetchImpl,
);
}
return { chatId: existingChatId, members };
} catch (error) {
return {
chatId: existingChatId,
members,
warning: `项目总群成员同步失败:${error instanceof Error ? error.message : String(error)}`,
};
}
}
if (members.length < 3) {
return { chatId: null, members, reason: "not_enough_members" };
}
const candidate = projectMainChatId(id);
try {
const probed = await getAppChat(config, candidate, fetchImpl);
let chatId = candidate;
if (!probed) {
const created = await createAppChat(
config,
{ name: `【总群】${project.name}`, userlist: members, chatid: candidate },
fetchImpl,
);
chatId = created.chatid || candidate;
}
await db
.prepare("UPDATE projects SET main_chat_id = ? WHERE id = ?")
.bind(chatId, id)
.run();
return { chatId, members };
} catch (error) {
return {
chatId: null,
members,
warning: `项目总群创建失败:${error instanceof Error ? error.message : String(error)}`,
};
}
}
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.owner_userid, 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
`SELECT p.id, p.name, p.brand, p.main_group_member_ids, p.owner_userid, p.main_chat_id, p.status, p.note, p.created_at,
(SELECT COUNT(*) FROM tasks t WHERE t.project_id = p.id) AS task_count
FROM projects p
ORDER BY p.created_at DESC`,
)
.all<ProjectRow & { task_count: number; group_chat_count: number }>();
.all<ProjectRow & { task_count: number }>();
return result.results.map((row) => ({
id: row.id,
name: row.name,
@@ -113,8 +242,8 @@ export async function listProjects(db: DatabaseClient): Promise<ProjectSummary[]
createdAt: row.created_at,
mainGroupMemberIds: parseJsonArray(row.main_group_member_ids),
ownerUserid: row.owner_userid ?? "",
mainChatId: row.main_chat_id ?? "",
taskCount: Number(row.task_count),
groupChatCount: Number(row.group_chat_count),
}));
}
@@ -125,7 +254,7 @@ export async function getProjectDetail(
const id = text(projectId);
const project = await db
.prepare(
`SELECT id, name, brand, main_group_member_ids, owner_userid, status, note, created_at
`SELECT id, name, brand, main_group_member_ids, owner_userid, main_chat_id, status, note, created_at
FROM projects WHERE id = ?`,
)
.bind(id)
@@ -135,35 +264,13 @@ export async function getProjectDetail(
const tasks = await db
.prepare(
`SELECT id, name, brand, quantity, claimed_quantity, due_at, status,
task_type, platform, content_format, share_token
task_type, platform, content_format, share_token, owner_userid, task_chat_id
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,
@@ -174,20 +281,17 @@ export async function getProjectDetail(
createdAt: project.created_at,
mainGroupMemberIds: parseJsonArray(project.main_group_member_ids),
ownerUserid: project.owner_userid ?? "",
mainChatId: project.main_chat_id ?? "",
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,
})),
mainChat: {
chatId: project.main_chat_id ?? "",
members: effectiveProjectMembers(
project.main_group_member_ids,
project.owner_userid,
),
},
};
}
@@ -203,13 +307,15 @@ export type SaveProjectInput = {
export async function saveProject(
db: DatabaseClient,
input: SaveProjectInput,
): Promise<ProjectSummary> {
config?: WecomConfig,
fetchImpl: FetchLike = fetch,
): Promise<{ project: ProjectSummary; mainChat: MainChatEnsureResult }> {
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);
const ownerUserid = text(input.ownerUserid).slice(0, 64);
const memberIds = effectiveProjectMembers(input.mainGroupMemberIds, ownerUserid);
if (!name) throw new ProjectError("请填写项目名称");
const now = new Date().toISOString();
@@ -243,8 +349,15 @@ export async function saveProject(
)
.run();
}
const mainChat = config
? await ensureProjectMainChat(db, projectId, config, fetchImpl)
: {
chatId: null,
members: memberIds,
reason: "no_credentials" as MainChatEnsureReason,
};
const detail = await getProjectDetail(db, projectId);
return detail.project;
return { project: detail.project, mainChat };
}
export async function deleteProject(
@@ -258,74 +371,10 @@ export async function deleteProject(
.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),
};
await db
.prepare("UPDATE tasks SET project_id = '' WHERE project_id = ?")
.bind(id)
.run();
}
export async function listGroupOwnerPartners(
@@ -390,3 +439,23 @@ export async function saveGroupOwnerPartner(
if (!row) throw new ProjectError("合作方保存失败", 500);
return row;
}
export async function findOrCreateGroupOwnerPartner(
db: DatabaseClient,
wecomUserid: string,
): Promise<GroupOwnerPartnerRow> {
const userid = text(wecomUserid).slice(0, 128);
if (!userid) throw new ProjectError("缺少企业微信 userid");
const existing = await db
.prepare(
`SELECT id, name, wecom_userid, partner_type
FROM partners
WHERE partner_type = 'group_owner' AND wecom_userid = ?
ORDER BY created_at
LIMIT 1`,
)
.bind(userid)
.first<GroupOwnerPartnerRow>();
if (existing) return existing;
return saveGroupOwnerPartner(db, { name: userid, wecomUserid: userid });
}

View File

@@ -0,0 +1,127 @@
import type { DatabaseClient } from "./database";
import {
addTaskJoinWay,
deleteJoinWay,
getJoinWayQrCode,
hasAppCredentials,
listCustomerGroupChats,
updateTaskJoinWay,
type WecomConfig,
type WecomGroupChat,
} from "./wecom-client";
type FetchLike = typeof fetch;
function text(value: unknown) {
return String(value ?? "").trim();
}
export async function listOwnerCustomerChats(
config: WecomConfig,
ownerUserid: string,
fetchImpl: FetchLike = fetch,
): Promise<WecomGroupChat[]> {
if (!hasAppCredentials(config)) {
throw new Error("企业微信自建应用未配置(WECOM_CORP_ID / WECOM_AGENT_ID / WECOM_SECRET)");
}
const owner = text(ownerUserid);
if (!owner) return [];
return listCustomerGroupChats(config, fetchImpl, [owner]);
}
export type BindTaskCustomerChatResult = {
customerChatId: string;
customerChatName: string;
joinConfigId: string;
joinQrCode: string;
qrError?: string;
};
export async function bindTaskCustomerChat(
db: DatabaseClient,
params: { taskId: string; chatId: string; chatName: string },
config: WecomConfig,
fetchImpl: FetchLike = fetch,
): Promise<BindTaskCustomerChatResult> {
const taskId = text(params.taskId);
const chatId = text(params.chatId);
const chatName = text(params.chatName).slice(0, 255);
if (!taskId) throw new Error("缺少 taskId");
if (!chatId) throw new Error("请选择要绑定的客户群");
const task = await db
.prepare("SELECT id, name, join_config_id FROM tasks WHERE id = ?")
.bind(taskId)
.first<{ id: string; name: string; join_config_id: string | null }>();
if (!task) throw new Error("任务不存在");
const joinInput = {
chatIdList: [chatId],
roomBaseName: task.name,
state: `task-${taskId}`,
};
let configId = text(task.join_config_id);
if (configId) {
try {
await updateTaskJoinWay(config, { ...joinInput, configId }, fetchImpl);
} catch {
// 活码配置可能已被企微侧清理,降级为重新创建
configId = (await addTaskJoinWay(config, joinInput, fetchImpl)).configId;
}
} else {
configId = (await addTaskJoinWay(config, joinInput, fetchImpl)).configId;
}
let joinQrCode = "";
let qrError: string | undefined;
try {
joinQrCode = (await getJoinWayQrCode(config, configId, fetchImpl)).qrCode;
} catch (error) {
qrError = `活码二维码获取失败:${error instanceof Error ? error.message : String(error)}`;
}
await db
.prepare(
`UPDATE tasks
SET customer_chat_id = ?, customer_chat_name = ?, join_config_id = ?, join_qr_code = ?
WHERE id = ?`,
)
.bind(chatId, chatName, configId, joinQrCode, taskId)
.run();
return {
customerChatId: chatId,
customerChatName: chatName,
joinConfigId: configId,
joinQrCode,
qrError,
};
}
export async function unbindTaskCustomerChat(
db: DatabaseClient,
taskId: string,
config: WecomConfig,
fetchImpl: FetchLike = fetch,
): Promise<void> {
const id = text(taskId);
if (!id) throw new Error("缺少 taskId");
const task = await db
.prepare("SELECT join_config_id FROM tasks WHERE id = ?")
.bind(id)
.first<{ join_config_id: string | null }>();
if (!task) throw new Error("任务不存在");
const configId = text(task.join_config_id);
if (configId) {
await deleteJoinWay(config, configId, fetchImpl);
}
await db
.prepare(
`UPDATE tasks
SET customer_chat_id = '', customer_chat_name = '', join_config_id = '', join_qr_code = ''
WHERE id = ?`,
)
.bind(id)
.run();
}

View File

@@ -1,8 +1,14 @@
import type { DatabaseClient } from "./database";
import {
effectiveProjectMembers,
ensureProjectMainChat,
findOrCreateGroupOwnerPartner,
} from "./project-service";
import {
createAppChat,
getAppChat,
hasAppCredentials,
sanitizeChatid,
sendAppChatMessage,
updateAppChat,
type WecomConfig,
@@ -20,7 +26,8 @@ type TaskChatRow = {
id: string;
name: string;
task_chat_id: string | null;
owner_userid: string | null;
task_owner_userid: string | null;
project_owner_userid: string | null;
};
function text(value: unknown) {
@@ -28,7 +35,7 @@ function text(value: unknown) {
}
export function taskChatId(taskId: string) {
return `tgc_${text(taskId)}`;
return `tgc${sanitizeChatid(text(taskId))}`;
}
function chatNameForTask(taskName: string) {
@@ -42,7 +49,11 @@ async function syncMembers(
fetchImpl: FetchLike,
) {
const info = await getAppChat(config, chatid, fetchImpl);
if (!info) return;
if (!info) {
// appchat/get 要求应用可见范围为根部门,查不到时降级为只增同步(重复添加成员企微按成功处理)
await updateAppChat(config, { chatid, addUserList: members }, fetchImpl);
return;
}
const existing = new Set(info.userlist);
const missing = members.filter((userid) => !existing.has(userid));
if (missing.length > 0) {
@@ -63,7 +74,8 @@ export async function ensureTaskChatForMembers(
}
const task = await db
.prepare(
`SELECT t.id, t.name, t.task_chat_id, p.owner_userid AS owner_userid
`SELECT t.id, t.name, t.task_chat_id, t.owner_userid AS task_owner_userid,
p.owner_userid AS project_owner_userid
FROM tasks t
LEFT JOIN projects p ON p.id = t.project_id
WHERE t.id = ?`,
@@ -71,15 +83,18 @@ export async function ensureTaskChatForMembers(
.bind(taskId)
.first<TaskChatRow>();
if (!task) return { chatId: null, created: false, reason: "no_task" };
const ownerUserid = text(task.owner_userid);
if (!ownerUserid) {
const ownerUserid = text(task.project_owner_userid);
const taskOwnerUserid = text(task.task_owner_userid);
if (!ownerUserid && !taskOwnerUserid) {
return { chatId: null, created: false, reason: "no_owner" };
}
const members = [
...new Set(
[ownerUserid, ...(params.addUserids ?? []).map((id) => text(id))].filter(
Boolean,
),
[
ownerUserid,
taskOwnerUserid,
...(params.addUserids ?? []).map((id) => text(id)),
].filter(Boolean),
),
];
@@ -110,6 +125,100 @@ export async function ensureTaskChatForMembers(
return { chatId, created: createdNow };
}
export type TaskOwnerAssignment = {
ownerUserid: string;
partnerId: string;
chatId: string | null;
chatCreated: boolean;
warning?: string;
};
export async function assignTaskOwner(
db: DatabaseClient,
params: { taskId: string; ownerUserid: string },
config: WecomConfig,
fetchImpl: FetchLike = fetch,
): Promise<TaskOwnerAssignment> {
const taskId = text(params.taskId);
const ownerUserid = text(params.ownerUserid).slice(0, 64);
if (!taskId) throw new Error("缺少 taskId");
if (!ownerUserid) throw new Error("请填写任务负责人");
const task = await db
.prepare(
"SELECT id, name, brand, quantity, due_at, project_id FROM tasks WHERE id = ?",
)
.bind(taskId)
.first<{
id: string;
name: string;
brand: string;
quantity: number;
due_at: string;
project_id: string;
}>();
if (!task) throw new Error("任务不存在");
await db
.prepare("UPDATE tasks SET owner_userid = ? WHERE id = ?")
.bind(ownerUserid, taskId)
.run();
const partner = await findOrCreateGroupOwnerPartner(db, ownerUserid);
const warnings: string[] = [];
const projectId = text(task.project_id);
if (projectId) {
const project = await db
.prepare(
"SELECT main_group_member_ids, owner_userid FROM projects WHERE id = ?",
)
.bind(projectId)
.first<{ main_group_member_ids: string; owner_userid: string }>();
if (project) {
// 负责人不在成员列表则并入,保持「负责人都在总群」
const stored = effectiveProjectMembers(project.main_group_member_ids, "");
if (!stored.includes(ownerUserid)) {
await db
.prepare("UPDATE projects SET main_group_member_ids = ? WHERE id = ?")
.bind(JSON.stringify([...stored, ownerUserid]), projectId)
.run();
}
const mainChat = await ensureProjectMainChat(db, projectId, config, fetchImpl);
if (mainChat.warning) warnings.push(mainChat.warning);
}
}
let ensure: TaskChatEnsureResult = { chatId: null, created: false };
try {
ensure = await ensureTaskChatForMembers(db, { taskId }, config, fetchImpl);
if (ensure.chatId && ensure.created) {
await sendTaskChatMessage(
db,
taskId,
taskChatIntroText({
name: task.name,
brand: task.brand,
quantity: task.quantity,
dueAt: task.due_at,
}),
config,
fetchImpl,
);
}
} catch (error) {
warnings.push(`任务群创建失败:${error instanceof Error ? error.message : String(error)}`);
}
return {
ownerUserid,
partnerId: partner.id,
chatId: ensure.chatId,
chatCreated: ensure.created,
warning: warnings.length > 0 ? warnings.join(";") : undefined,
};
}
export async function sendTaskChatMessage(
db: DatabaseClient,
taskId: string,

View File

@@ -1,355 +0,0 @@
import type { DatabaseClient } from "./database";
import { getRuntimeEnv } from "./runtime-env";
import {
sendWecomAppMessage,
type WecomConfig,
} from "./wecom-client";
import { pushProjectTask, type ProjectPushOutcome } from "./project-push-service";
import type { TaskPushRow } from "./wecom-group-push-service";
type FetchLike = typeof fetch;
export class OneClickPushError extends Error {
status: number;
constructor(message: string, status = 400) {
super(message);
this.name = "OneClickPushError";
this.status = status;
}
}
export type BoundWecomPartner = {
id: string;
name: string;
wecomExternalUserId: string;
};
export type KocPushResult = {
partnerId: string;
partnerName: string;
externalUserId: string;
ok: boolean;
error: string;
};
export type TaskPushLogRow = {
id: string;
task_id: string;
project_id: string;
push_kind: string;
main_group_count: number;
koc_userids: string;
koc_ok_count: number;
koc_fail_list: string;
group_push_id: string;
detail: string;
created_at: string;
};
export type OneClickPushOutcome = {
linkUrl: string;
projectOutcome: ProjectPushOutcome | null;
kocResults: KocPushResult[];
kocSent: number;
kocFailed: number;
skippedReasons: string[];
logId: string;
};
function text(value: unknown) {
return String(value ?? "").trim();
}
export function buildKocDirectMessage(
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 || "领取链接生成失败,请联系管理员",
].join("\n");
}
export async function listBoundWecomPartners(
db: DatabaseClient,
): Promise<BoundWecomPartner[]> {
const result = await db
.prepare(
`SELECT id, name, wecom_external_user_id
FROM partners
WHERE wecom_external_user_id IS NOT NULL
AND wecom_external_user_id != ''
ORDER BY name ASC`,
)
.all<{
id: string;
name: string;
wecom_external_user_id: string;
}>();
return result.results.map((row) => ({
id: row.id,
name: row.name,
wecomExternalUserId: row.wecom_external_user_id,
}));
}
export async function findTodayTaskPushLog(
db: DatabaseClient,
taskId: string,
pushKind = "task_open",
): Promise<TaskPushLogRow | null> {
const result = await db
.prepare(
`SELECT id, task_id, project_id, push_kind, main_group_count,
koc_userids, koc_ok_count, koc_fail_list, group_push_id,
detail, created_at
FROM wecom_push_logs
WHERE task_id = ? AND push_kind = ?
AND DATE(created_at) = CURDATE()
ORDER BY created_at DESC
LIMIT 1`,
)
.bind(taskId, pushKind)
.first<TaskPushLogRow>();
return result ?? null;
}
export async function listTaskPushLogs(
db: DatabaseClient,
taskId: string,
): Promise<TaskPushLogRow[]> {
const filter = taskId ? "WHERE task_id = ?" : "";
const statement = db
.prepare(
`SELECT id, task_id, project_id, push_kind, main_group_count,
koc_userids, koc_ok_count, koc_fail_list, group_push_id,
detail, created_at
FROM wecom_push_logs
${filter}
ORDER BY created_at DESC
LIMIT 50`,
)
.bind(...(taskId ? [taskId] : []));
const result = await statement.all<TaskPushLogRow>();
return result.results;
}
export async function recordCardAck(
db: DatabaseClient,
params: { cardTaskId: string; btnKey: string; operator: string },
) {
const taskId = /^task:([^:]+):/.exec(params.cardTaskId)?.[1] ?? "";
await db
.prepare(
`INSERT INTO wecom_push_logs
(id, task_id, project_id, push_kind, main_group_count,
koc_userids, koc_ok_count, koc_fail_list, group_push_id, detail)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
`wpl-${crypto.randomUUID().slice(0, 12)}`,
taskId || params.cardTaskId,
"",
"card_ack",
0,
"[]",
0,
"[]",
"",
JSON.stringify({
cardTaskId: params.cardTaskId,
btnKey: params.btnKey,
operator: params.operator,
}),
)
.run();
}
export async function oneClickPushTask(
db: DatabaseClient,
params: {
taskId: unknown;
sendMainGroup?: unknown;
sendKoc?: unknown;
createGroupTemplate?: unknown;
selectedKocPartnerIds?: unknown;
force?: unknown;
text?: unknown;
},
config: WecomConfig,
fetchImpl: FetchLike = fetch,
): Promise<OneClickPushOutcome> {
const taskId = text(params.taskId);
if (!taskId) throw new OneClickPushError("缺少 taskId");
const sendMainGroup = params.sendMainGroup !== false;
const sendKoc = params.sendKoc !== false;
const createGroupTemplate = params.createGroupTemplate === true;
if (!sendMainGroup && !sendKoc && !createGroupTemplate) {
throw new OneClickPushError("请至少选择一个推送目标");
}
const task = await db
.prepare(
`SELECT id, name, brand, quantity, due_at, task_type, platform,
content_format, share_token, project_id
FROM tasks WHERE id = ?`,
)
.bind(taskId)
.first<TaskPushRow & { project_id: string }>();
if (!task) throw new OneClickPushError("任务不存在", 404);
if (!text(task.share_token)) {
throw new OneClickPushError("任务尚未生成领取链接,请先开放领取", 400);
}
if (params.force !== true) {
const existing = await findTodayTaskPushLog(db, taskId, "task_open");
if (existing) {
throw new OneClickPushError(
"今天已推送过该任务;如确需再次推送,请勾选「仍要推送」",
409,
);
}
}
const skippedReasons: string[] = [];
const wantsProjectTargets = sendMainGroup || createGroupTemplate;
let projectOutcome: ProjectPushOutcome | null = null;
if (wantsProjectTargets) {
if (!text(task.project_id)) {
skippedReasons.push("任务未挂项目,已跳过项目总群与任务群");
} else {
projectOutcome = await pushProjectTask(
db,
{
taskId,
sendMainGroup,
sendGroupChats: createGroupTemplate,
text: params.text,
},
config,
fetchImpl,
);
skippedReasons.push(...projectOutcome.skippedReasons);
}
}
const portalUrl = text(getRuntimeEnv().KOC_PORTAL_URL);
const linkUrl = task.share_token
? `${portalUrl.replace(/\/$/, "")}/?task=${task.share_token}`
: "";
const kocResults: KocPushResult[] = [];
if (sendKoc) {
const bound = await listBoundWecomPartners(db);
const selected = Array.isArray(params.selectedKocPartnerIds)
? params.selectedKocPartnerIds.map((id) => text(id)).filter(Boolean)
: null;
const targets = selected
? bound.filter((partner) => selected.includes(partner.id))
: bound;
if (targets.length === 0) {
skippedReasons.push("没有可直达的合作方(未勾选或均未绑定企微身份)");
} else {
const message = buildKocDirectMessage(task, portalUrl);
for (const partner of targets) {
try {
const sent = await sendWecomAppMessage(
[partner.wecomExternalUserId],
message,
config,
fetchImpl,
);
const failed = sent.sent === 0;
kocResults.push({
partnerId: partner.id,
partnerName: partner.name,
externalUserId: partner.wecomExternalUserId,
ok: !failed,
error: failed ? "企业微信未送达(invaliduser)" : "",
});
} catch (error) {
kocResults.push({
partnerId: partner.id,
partnerName: partner.name,
externalUserId: partner.wecomExternalUserId,
ok: false,
error: error instanceof Error ? error.message : "发送失败",
});
}
}
}
}
const kocSent = kocResults.filter((item) => item.ok).length;
const kocFailed = kocResults.length - kocSent;
const logId = `wpl-${crypto.randomUUID().slice(0, 12)}`;
const groupPushId =
projectOutcome?.groupResults.map((item) => item.msgid).join(",") ?? "";
await db
.prepare(
`INSERT INTO wecom_push_logs
(id, task_id, project_id, push_kind, main_group_count,
koc_userids, koc_ok_count, koc_fail_list, group_push_id, detail)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
logId,
taskId,
text(task.project_id),
"task_open",
projectOutcome ? projectOutcome.appResults.length : 0,
JSON.stringify(
kocResults.map((item) => ({
partnerId: item.partnerId,
name: item.partnerName,
externalUserId: item.externalUserId,
ok: item.ok,
error: item.error,
})),
),
kocSent,
JSON.stringify(kocResults.filter((item) => !item.ok)),
groupPushId,
JSON.stringify({
skippedReasons,
createGroupTemplate,
linkUrl,
}),
)
.run();
return {
linkUrl,
projectOutcome,
kocResults,
kocSent,
kocFailed,
skippedReasons,
logId,
};
}

View File

@@ -1,4 +1,5 @@
import { ensureSchema, getRawDb, uid } from "./mvp-db";
import type { DatabaseClient } from "./database";
import {
readFeishuSource,
type FeishuBindings,
@@ -13,6 +14,7 @@ export type CreateDistributionTaskInput = {
platform?: "小红书" | "抖音";
contentFormat?: "image_text" | "video";
projectId?: string;
ownerUserid?: string;
};
export type CreateScreenshotTaskInput = {
@@ -24,6 +26,7 @@ export type CreateScreenshotTaskInput = {
quantity: number;
exampleImageKey?: string;
projectId?: string;
ownerUserid?: string;
};
export type DistributionTaskCreation = {
@@ -142,10 +145,10 @@ async function insertTaskFromSource(
.prepare(
`INSERT INTO tasks
(id, name, brand, quantity, claimed_quantity, due_at, status,
platform, content_format, project_id,
platform, content_format, project_id, owner_userid,
source_url, source_sheet_id, source_sheet_name, source_synced_at,
share_token)
VALUES (?, ?, ?, ?, 0, ?, 'importing', ?, ?, ?, ?, ?, ?, ?, ?)`,
VALUES (?, ?, ?, ?, 0, ?, 'importing', ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
taskId,
@@ -156,6 +159,7 @@ async function insertTaskFromSource(
input.platform ?? "小红书",
input.contentFormat ?? "image_text",
input.projectId,
input.ownerUserid,
source.url,
source.sheetId,
source.sheetName,
@@ -224,6 +228,7 @@ export async function createDistributionTask(
platform: rawInput.platform === "抖音" ? "抖音" : "小红书",
contentFormat: rawInput.contentFormat === "video" ? "video" : "image_text",
projectId: normalizedValue(rawInput.projectId ?? ""),
ownerUserid: normalizedValue(rawInput.ownerUserid ?? "").slice(0, 64),
};
if (!input.feishuUrl || !input.name || !input.brand) {
throw new Error("请补全飞书链接、任务名称和品牌/项目");
@@ -286,6 +291,7 @@ export async function createScreenshotTask(
quantity: Math.floor(Number(rawInput.quantity)),
exampleImageKey: normalizedValue(rawInput.exampleImageKey ?? ""),
projectId: normalizedValue(rawInput.projectId ?? ""),
ownerUserid: normalizedValue(rawInput.ownerUserid ?? "").slice(0, 64),
};
if (!input.name || !input.brand || !input.keyword || !input.instructions) {
throw new Error("请补全任务名称、品牌/项目、搜索关键词和任务说明");
@@ -309,9 +315,9 @@ export async function createScreenshotTask(
.prepare(
`INSERT INTO tasks
(id, name, brand, quantity, claimed_quantity, due_at, status,
task_type, project_id, source_url, source_sheet_id, source_sheet_name,
task_type, project_id, owner_userid, source_url, source_sheet_id, source_sheet_name,
share_token, collection_days)
VALUES (?, ?, ?, ?, 0, ?, 'active', 'screenshot_collect', ?, '', '', '', ?, '[]')`,
VALUES (?, ?, ?, ?, 0, ?, 'active', 'screenshot_collect', ?, ?, '', '', '', ?, '[]')`,
)
.bind(
taskId,
@@ -320,6 +326,7 @@ export async function createScreenshotTask(
input.quantity,
input.dueAt,
input.projectId,
input.ownerUserid,
shareToken,
)
.run();
@@ -363,6 +370,182 @@ export async function createScreenshotTask(
};
}
export type UpdateTaskInput = {
taskId: string;
name: string;
brand: string;
dueAt: string;
projectId?: string;
platform?: "小红书" | "抖音";
contentFormat?: "image_text" | "video";
quantity?: number;
keyword?: string;
instructions?: string;
exampleImageKey?: string;
};
export type TaskUpdateResult = {
taskId: string;
quantity: number;
claimedQuantity: number;
};
type UpdatableTaskRow = {
id: string;
task_type: "content_publish" | "screenshot_collect";
quantity: number;
claimed_quantity: number;
platform: "小红书" | "抖音";
content_format: "image_text" | "video";
};
// 截图任务的 keyword/instructions 存在 contents 每行(title/body),数量与行数一一对应
async function syncScreenshotContents(
db: DatabaseClient,
taskId: string,
params: {
oldQuantity: number;
quantity: number;
keyword: string;
instructions: string;
exampleImageKey: string;
},
) {
const delta = params.quantity - params.oldQuantity;
if (delta > 0) {
const sample = await db
.prepare(
"SELECT image_assets FROM contents WHERE task_id = ? ORDER BY source_row LIMIT 1",
)
.bind(taskId)
.first<{ image_assets: string }>();
const imageAssets = sample?.image_assets ?? "[]";
const statements = Array.from({ length: delta }, (_, index) =>
db
.prepare(
`INSERT INTO contents
(id, task_id, title, body, image_assets, video_assets, status, source, source_row)
VALUES (?, ?, ?, ?, ?, '[]', 'available', '截图回收任务', ?)`,
)
.bind(
uid("content"),
taskId,
params.keyword,
params.instructions,
imageAssets,
params.oldQuantity + index + 1,
),
);
for (let index = 0; index < statements.length; index += 100) {
await db.batch(statements.slice(index, index + 100));
}
} else if (delta < 0) {
// 只删未领取的尾部行,已领取(claimed)的保留
await db
.prepare(
`DELETE FROM contents WHERE task_id = ? AND status = 'available'
ORDER BY source_row DESC LIMIT ?`,
)
.bind(taskId, -delta)
.run();
}
await db
.prepare("UPDATE contents SET title = ?, body = ? WHERE task_id = ?")
.bind(params.keyword, params.instructions, taskId)
.run();
if (params.exampleImageKey.startsWith("task-assets/")) {
const imageAssets = JSON.stringify([
{ index: 1, key: params.exampleImageKey, width: null, height: null },
]);
await db
.prepare("UPDATE contents SET image_assets = ? WHERE task_id = ?")
.bind(imageAssets, taskId)
.run();
}
}
export async function updateTask(
rawInput: UpdateTaskInput,
db: DatabaseClient = getRawDb(),
): Promise<TaskUpdateResult> {
const taskId = normalizedValue(rawInput.taskId);
if (!taskId) throw new Error("缺少 taskId");
const input = {
name: normalizedValue(rawInput.name),
brand: normalizedValue(rawInput.brand),
dueAt: normalizedDueDate(rawInput.dueAt),
projectId: normalizedValue(rawInput.projectId ?? ""),
platform: rawInput.platform === "抖音" || rawInput.platform === "小红书"
? rawInput.platform
: undefined,
contentFormat: rawInput.contentFormat === "video" || rawInput.contentFormat === "image_text"
? rawInput.contentFormat
: undefined,
quantity: Math.floor(Number(rawInput.quantity ?? 0)),
keyword: normalizedValue(rawInput.keyword ?? ""),
instructions: normalizedValue(rawInput.instructions ?? ""),
exampleImageKey: normalizedValue(rawInput.exampleImageKey ?? ""),
};
if (!input.name || !input.brand) {
throw new Error("请补全任务名称和品牌");
}
const row = await db
.prepare(
"SELECT id, task_type, quantity, claimed_quantity, platform, content_format FROM tasks WHERE id = ?",
)
.bind(taskId)
.first<UpdatableTaskRow>();
if (!row) throw new Error("任务不存在");
const claimed = Number(row.claimed_quantity);
let quantity = Number(row.quantity);
if (row.task_type === "screenshot_collect") {
if (!input.keyword || !input.instructions) {
throw new Error("请补全搜索关键词和任务说明");
}
if (
!Number.isInteger(input.quantity) ||
input.quantity < 1 ||
input.quantity > 500
) {
throw new Error("任务数量需为 1—500 份");
}
if (input.quantity < claimed) {
throw new Error(`任务数量不能低于已领取的 ${claimed} 份`);
}
quantity = input.quantity;
await syncScreenshotContents(db, taskId, {
oldQuantity: Number(row.quantity),
quantity,
keyword: input.keyword,
instructions: input.instructions,
exampleImageKey: input.exampleImageKey,
});
}
await db
.prepare(
`UPDATE tasks
SET name = ?, brand = ?, due_at = ?, project_id = ?,
platform = ?, content_format = ?, quantity = ?
WHERE id = ?`,
)
.bind(
input.name,
input.brand,
input.dueAt,
input.projectId,
input.platform ?? row.platform,
input.contentFormat ?? row.content_format,
quantity,
taskId,
)
.run();
return { taskId, quantity, claimedQuantity: claimed };
}
export function buildClaimUrl(portalOrigin: string, shareToken: string) {
const origin = normalizedValue(portalOrigin).replace(/\/$/, "");
if (!origin) throw new Error("KOC 领取站点地址尚未配置");

View File

@@ -9,6 +9,7 @@ export type AuthUser = {
id: string;
username: string;
role: UserRole;
wecom_userid: string;
};
export type RequestPrincipal =
@@ -244,16 +245,26 @@ export async function getUserFromSessionToken(token: string): Promise<AuthUser |
await ensureSchema();
const row = await getRawDb()
.prepare(
`SELECT u.id, u.username, u.role
`SELECT u.id, u.username, u.role, u.wecom_userid
FROM auth_sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = ? AND s.expires_at > ?
LIMIT 1`,
)
.bind(await sha256(token), new Date().toISOString())
.first<{ id: string; username: string; role: string }>();
.first<{
id: string;
username: string;
role: string;
wecom_userid: string | null;
}>();
if (!row || !["super_admin", "admin", "user"].includes(row.role)) return null;
return { id: row.id, username: row.username, role: row.role as UserRole };
return {
id: row.id,
username: row.username,
role: row.role as UserRole,
wecom_userid: String(row.wecom_userid ?? ""),
};
}
export async function getUserFromCookieHeader(cookieHeader: string | null) {

View File

@@ -1,5 +1,4 @@
import type { DatabaseClient } from "./database";
import { recordCardAck } from "./task-one-click-push-service";
export type WecomCallbackFields = Record<string, string>;
@@ -30,6 +29,37 @@ export function extractWecomEncryptField(body: string): string {
return fields.Encrypt ?? "";
}
export async function recordCardAck(
db: DatabaseClient,
params: { cardTaskId: string; btnKey: string; operator: string },
) {
const taskId = /^task:([^:]+):/.exec(params.cardTaskId)?.[1] ?? "";
await db
.prepare(
`INSERT INTO wecom_push_logs
(id, task_id, project_id, push_kind, main_group_count,
koc_userids, koc_ok_count, koc_fail_list, group_push_id, detail)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
`wpl-${crypto.randomUUID().slice(0, 12)}`,
taskId || params.cardTaskId,
"",
"card_ack",
0,
"[]",
0,
"[]",
"",
JSON.stringify({
cardTaskId: params.cardTaskId,
btnKey: params.btnKey,
operator: params.operator,
}),
)
.run();
}
export async function handleWecomCallbackEvent(
db: DatabaseClient,
fields: WecomCallbackFields,

View File

@@ -96,6 +96,11 @@ function parseDueDays(value: string | undefined) {
return Math.min(30, Math.floor(parsed));
}
// 企微 appchat 的 chatid 只接受字母和数字(下划线/连字符都会报 86001 chatid invalid)
export function sanitizeChatid(value: string) {
return String(value ?? "").replace(/[^a-zA-Z0-9]/g, "");
}
export function hasAppCredentials(config: WecomConfig) {
return Boolean(config.corpId && config.agentId && config.secret);
}
@@ -118,9 +123,10 @@ async function readEnvelope(
function ensureOk(
payload: WecomEnvelope,
fallbackMessage: string,
allowErrcodes: number[] = [],
) {
const code = Number(payload.errcode ?? 0);
if (code === 0) return;
if (code === 0 || allowErrcodes.includes(code)) return;
const message = safeMessage(payload.errmsg) || fallbackMessage;
if (code === 40014 || code === 42001) {
throw new WecomClientError(`企业微信 access_token 无效:${message}`, 401);
@@ -336,10 +342,14 @@ export type WecomGroupChat = {
export async function listCustomerGroupChats(
config: WecomConfig,
fetchImpl: FetchLike = fetch,
ownerUserids?: string[],
): Promise<WecomGroupChat[]> {
if (!hasAppCredentials(config)) return [];
const token = await fetchAccessToken(config, fetchImpl);
const ownerFilterUserids = (ownerUserids ?? [])
.map((id) => bindingValue(id))
.filter((id) => id.length > 0);
const chatIds: Array<{ chatId: string; status: number }> = [];
let cursor = "";
do {
@@ -347,7 +357,13 @@ export async function listCustomerGroupChats(
const listResp = await fetchImpl(listUrl, {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({ limit: 100, cursor }),
body: JSON.stringify({
limit: 100,
cursor,
...(ownerFilterUserids.length > 0
? { owner_filter: { userid_list: ownerFilterUserids } }
: {}),
}),
signal: AbortSignal.timeout(12_000),
});
const listPayload = await readEnvelope(listResp, "获取企业微信客户群列表失败");
@@ -642,6 +658,7 @@ async function postAppChat(
body: Record<string, unknown>,
errorLabel: string,
fetchImpl: FetchLike,
allowErrcodes: number[] = [],
): Promise<Record<string, unknown>> {
if (!hasAppCredentials(config)) {
throw new WecomClientError(
@@ -661,7 +678,7 @@ async function postAppChat(
if (!response.ok) {
throw new WecomClientError(`${errorLabel}(HTTP ${response.status})`, 502);
}
ensureOk(payload, errorLabel);
ensureOk(payload, errorLabel, allowErrcodes);
return payload;
}
@@ -684,9 +701,12 @@ export async function createAppChat(
const payload = await postAppChat(
config,
"/cgi-bin/appchat/create",
{ name, owner: Number(config.agentId), userlist, chatid },
// owner 不传:默认建群应用自己为群主,避免把 agentId 误当 userid
{ name, userlist, chatid },
"创建企业微信群聊会话失败",
fetchImpl,
// 86215 = chatid 已存在:chatid 由本项目派生(pgc/tgc+ID),视为本应用已建过的群
[86215],
);
return { chatid: bindingValue(payload.chatid) || chatid };
}
@@ -767,6 +787,105 @@ export async function sendAppChatMessage(
);
}
export type TaskJoinWayInput = {
chatIdList: string[];
roomBaseName: string;
state: string;
};
function joinWayBody(input: TaskJoinWayInput): Record<string, unknown> {
return {
scene: 2,
chat_id_list: input.chatIdList
.map((id) => bindingValue(id))
.filter((id) => id.length > 0)
.slice(0, 5),
auto_create_room: 1,
room_base_name: Array.from(input.roomBaseName).slice(0, 40).join(""),
state: Array.from(input.state).slice(0, 30).join(""),
};
}
export async function addTaskJoinWay(
config: WecomConfig,
input: TaskJoinWayInput,
fetchImpl: FetchLike = fetch,
): Promise<{ configId: string }> {
const payload = await postAppChat(
config,
"/cgi-bin/externalcontact/groupchat/add_join_way",
joinWayBody(input),
"创建企业微信进群活码失败",
fetchImpl,
);
const configId = bindingValue(payload.config_id);
if (!configId) {
throw new WecomClientError("创建企业微信进群活码失败(未返回 config_id)", 502);
}
return { configId };
}
export async function getJoinWayQrCode(
config: WecomConfig,
configId: string,
fetchImpl: FetchLike = fetch,
): Promise<{ qrCode: string }> {
const payload = await postAppChat(
config,
"/cgi-bin/externalcontact/groupchat/get_join_way",
{ config_id: bindingValue(configId) },
"获取企业微信进群活码二维码失败",
fetchImpl,
);
const joinWay = payload.join_way as { qr_code?: unknown } | undefined;
const qrCode = bindingValue(joinWay?.qr_code);
if (!qrCode) {
throw new WecomClientError("获取企业微信进群活码二维码失败(未返回二维码)", 502);
}
return { qrCode };
}
export async function updateTaskJoinWay(
config: WecomConfig,
input: TaskJoinWayInput & { configId: string },
fetchImpl: FetchLike = fetch,
): Promise<void> {
const configId = bindingValue(input.configId);
if (!configId) {
throw new WecomClientError("更新企业微信进群活码失败(缺少 config_id)", 400);
}
// update_join_way 为覆盖式更新,需重传全部字段(含 scene / auto_create_room)
await postAppChat(
config,
"/cgi-bin/externalcontact/groupchat/update_join_way",
{ ...joinWayBody(input), config_id: configId },
"更新企业微信进群活码失败",
fetchImpl,
);
}
export async function deleteJoinWay(
config: WecomConfig,
configId: string,
fetchImpl: FetchLike = fetch,
): Promise<void> {
const configIdValue = bindingValue(configId);
if (!configIdValue) return;
try {
await postAppChat(
config,
"/cgi-bin/externalcontact/groupchat/del_join_way",
{ config_id: configIdValue },
"删除企业微信进群活码失败",
fetchImpl,
);
} catch (error) {
// 活码配置可能已被企微侧清理(群解散等),删除是尽力而为,不阻断本地解绑
if (error instanceof WecomClientError) return;
throw error;
}
}
async function getWecomJson(
config: WecomConfig,
path: string,

View File

@@ -46,14 +46,14 @@ export async function runDueSoonWecomNotifications(
config: WecomConfig,
fetchImpl: FetchLike = fetch,
now: number = Date.now(),
taskId?: string,
): Promise<WecomNotifySummary> {
const today = shanghaiDateFromTimestamp(now);
const cutoff = computeDueCutoff(today, config.dueDays);
const portalUrl = bindingValue(getRuntimeEnv().KOC_PORTAL_URL);
const taskFilter = String(taskId ?? "").trim();
const result = await db
.prepare(
`SELECT
const dueSoonBase = `SELECT
d.id AS distribution_id,
d.partner_id,
p.name AS partner_name,
@@ -72,11 +72,15 @@ export async function runDueSoonWecomNotifications(
AND (d.publish_url IS NULL OR d.publish_url = ''))
)
AND t.due_at IS NOT NULL AND t.due_at != ''
AND t.due_at <= ?
ORDER BY t.due_at ASC, p.name ASC`,
AND t.due_at <= ?`;
const statement = db
.prepare(
taskFilter
? `${dueSoonBase} AND t.id = ? ORDER BY t.due_at ASC, p.name ASC`
: `${dueSoonBase} ORDER BY t.due_at ASC, p.name ASC`,
)
.bind(cutoff)
.all<DueSoonRow>();
.bind(...(taskFilter ? [cutoff, taskFilter] : [cutoff]));
const result = await statement.all<DueSoonRow>();
const grouped = new Map<
string,
@@ -156,12 +160,12 @@ export async function runDueSoonWecomNotifications(
entry.rows.push(row);
byTask.set(row.task_id, entry);
}
for (const [taskId, entry] of byTask) {
for (const [groupTaskId, entry] of byTask) {
const partners = [...new Set(entry.rows.map((row) => row.partner_name))];
const overflow = partners.length > 5 ? ` 等 ${partners.length} 位` : "";
await sendTaskChatMessage(
db,
taskId,
groupTaskId,
`【催办】《${truncate(entry.taskName, 24)}》有 ${entry.rows.length} 条内容待发布(截止 ${entry.dueAt}):\n${partners.slice(0, 5).join("、")}${overflow}`,
config,
fetchImpl,
@@ -169,7 +173,7 @@ export async function runDueSoonWecomNotifications(
}
let digestSent = false;
if (config.robotWebhook && digestTasks.length > 0) {
if (config.robotWebhook && digestTasks.length > 0 && !taskFilter) {
const digest =
`今日待发布催办(${today},截止 ≤ ${cutoff}):\n${digestTasks.join("\n")}`;
try {