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:
150
lib/mvp-db.ts
150
lib/mvp-db.ts
@@ -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
61
lib/owner-scope.ts
Normal 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 });
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
127
lib/task-customer-chat-service.ts
Normal file
127
lib/task-customer-chat-service.ts
Normal 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();
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 领取站点地址尚未配置");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user