From dd5b603149c8b982155fd942a2e16aff2097e06c Mon Sep 17 00:00:00 2001 From: ABAPPLO <1134226402@qq.com> Date: Fri, 4 Sep 2026 18:03:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=A1=B9=E7=9B=AE=E6=80=BB=E7=BE=A4?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=BB=BA=E7=BE=A4+=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E8=B4=9F=E8=B4=A3=E4=BA=BA=E5=90=8E=E5=8F=B0=E8=A7=86=E8=A7=92?= =?UTF-8?q?+=E5=AE=A2=E6=88=B7=E7=BE=A4=E6=B4=BB=E7=A0=81=E8=BF=9B?= =?UTF-8?q?=E7=BE=A4+=E4=BB=BB=E5=8A=A1=E7=BC=96=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 项目总群:≥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 渲染/解绑清空) --- app/admin-app.tsx | 1305 +++++++------------ app/api/action/route.ts | 407 ++++-- app/api/auth/login/route.ts | 12 +- app/api/bootstrap/route.ts | 10 +- app/api/partner/route.ts | 6 + app/api/recovery-export/route.ts | 10 + app/api/screenshot-task-export/route.ts | 8 + app/api/task-report-export/route.ts | 6 +- app/api/task-result-image/route.ts | 8 + app/api/users/route.ts | 39 +- app/globals.css | 41 +- app/task-customer-chat-modal.tsx | 218 ++++ app/users-page.tsx | 113 +- app/wecom-user-picker.tsx | 239 ++++ db/schema.ts | 28 +- koc-portal/app/globals.css | 42 + koc-portal/app/page.tsx | 12 + lib/mvp-db.ts | 150 ++- lib/owner-scope.ts | 61 + lib/project-push-service.ts | 127 +- lib/project-service.ts | 311 +++-- lib/task-customer-chat-service.ts | 127 ++ lib/task-group-service.ts | 127 +- lib/task-one-click-push-service.ts | 355 ----- lib/task-service.ts | 191 ++- lib/user-auth.ts | 17 +- lib/wecom-callback-events.ts | 32 +- lib/wecom-client.ts | 127 +- lib/wecom-notifier-service.ts | 24 +- mysql/0013_project_main_chat_task_owner.sql | 5 + mysql/0014_users_wecom_userid.sql | 1 + mysql/0015_task_customer_chat.sql | 7 + public/kocdev@43.139.210.125 | 1 + tests/owner-permissions.test.mjs | 204 +++ tests/owner-scope.test.mjs | 108 ++ tests/project-push.test.mjs | 250 ++-- tests/project-service.test.mjs | 554 ++++++-- tests/rendered-html.test.mjs | 9 +- tests/task-customer-chat-service.test.mjs | 219 ++++ tests/task-group-service.test.mjs | 327 ++++- tests/task-one-click-push.test.mjs | 250 ---- tests/task-update.test.mjs | 366 ++++++ tests/wecom-client.test.mjs | 176 ++- tests/wecom-notifier.test.mjs | 64 +- 44 files changed, 4655 insertions(+), 2039 deletions(-) create mode 100644 app/task-customer-chat-modal.tsx create mode 100644 app/wecom-user-picker.tsx create mode 100644 lib/owner-scope.ts create mode 100644 lib/task-customer-chat-service.ts delete mode 100644 lib/task-one-click-push-service.ts create mode 100644 mysql/0013_project_main_chat_task_owner.sql create mode 100644 mysql/0014_users_wecom_userid.sql create mode 100644 mysql/0015_task_customer_chat.sql create mode 100644 public/kocdev@43.139.210.125 create mode 100644 tests/owner-permissions.test.mjs create mode 100644 tests/owner-scope.test.mjs create mode 100644 tests/task-customer-chat-service.test.mjs delete mode 100644 tests/task-one-click-push.test.mjs create mode 100644 tests/task-update.test.mjs diff --git a/app/admin-app.tsx b/app/admin-app.tsx index 3279220..87f000d 100644 --- a/app/admin-app.tsx +++ b/app/admin-app.tsx @@ -21,6 +21,11 @@ import { type SortDirection, } from "../lib/sort-utils"; import UsersPage from "./users-page"; +import { + readApiResponse, + WecomUserPickerModal, +} from "./wecom-user-picker"; +import { TaskCustomerChatModal } from "./task-customer-chat-modal"; type Partner = { id: string; @@ -46,6 +51,14 @@ type Task = { platform: "小红书" | "抖音"; content_format: "image_text" | "video"; project_id?: string; + owner_userid?: string; + task_chat_id?: string; + customer_chat_id?: string; + customer_chat_name?: string; + join_config_id?: string; + join_qr_code?: string; + keyword?: string; + instructions?: string; source_url: string; source_sheet_id: string; source_sheet_name: string; @@ -227,22 +240,6 @@ const EMPTY_DATA: DashboardData = { portal_url: "", }; -async function readApiResponse(response: Response, fallback: string) { - const text = await response.text(); - const contentType = response.headers.get("content-type") ?? ""; - if (!contentType.includes("json")) { - if (response.status === 403) { - throw new Error("请求被安全网关拦截,请刷新页面后重试"); - } - throw new Error(`${fallback}(服务返回异常,HTTP ${response.status})`); - } - try { - return JSON.parse(text) as T & { error?: string }; - } catch { - throw new Error(`${fallback}(服务返回的数据无法解析)`); - } -} - function formatNumber(value: number | null | undefined) { if (value === null || value === undefined) return "—"; if (value >= 10000) return `${(value / 10000).toFixed(value >= 100000 ? 0 : 1)}万`; @@ -531,10 +528,11 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { const [menuOpen, setMenuOpen] = useState(false); const [taskModalOpen, setTaskModalOpen] = useState(false); const [groupPushTask, setGroupPushTask] = useState(null); - const [onePushTask, setOnePushTask] = useState(null); const [ownerLinksTask, setOwnerLinksTask] = useState(null); + const [customerChatTask, setCustomerChatTask] = useState(null); const [metricModal, setMetricModal] = useState(null); const [taskForm, setTaskForm] = useState({ + id: "", taskType: "content_publish" as "content_publish" | "screenshot_collect", platform: "小红书" as "小红书" | "抖音", contentFormat: "image_text" as "image_text" | "video", @@ -546,7 +544,9 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { instructions: "在小红书搜索指定关键词,打开 AI 总结并上传完整截图。", quantity: 1, projectId: "", + ownerUserid: "", }); + const [taskOwnerPickerOpen, setTaskOwnerPickerOpen] = useState(false); const [taskExampleImage, setTaskExampleImage] = useState(null); const [taskFormProjects, setTaskFormProjects] = useState>([]); const [sourcePreview, setSourcePreview] = useState(null); @@ -555,10 +555,12 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { const [exportingTaskId, setExportingTaskId] = useState(null); const [exportingResources, setExportingResources] = useState(false); const isManager = currentUser.role === "super_admin" || currentUser.role === "admin"; + const isTaskOwnerView = currentUser.role === "user"; const visibleNavItems = NAV_ITEMS.filter((item) => { - if (item.key === "resources" && currentUser.role === "user") return false; + if (isTaskOwnerView && ["resources", "partners"].includes(item.key)) { + return false; + } if (item.key === "users" && !isManager) return false; - if (item.key === "reports" && !isManager) return false; return true; }); @@ -623,11 +625,15 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); - const result = await readApiResponse(response, "操作失败"); + const result = await readApiResponse< + DashboardData & { ownerWarning?: string } + >(response, "操作失败"); if (!response.ok) throw new Error(result.error || "操作失败"); setData(result); - setToast(successMessage); - return true; + setToast( + result.ownerWarning ? `${successMessage};${result.ownerWarning}` : successMessage, + ); + return result; } catch (reason) { setToast(reason instanceof Error ? reason.message : "操作失败"); return false; @@ -664,20 +670,26 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { .slice(0, 6); const navigate = (key: NavKey) => { - if (key === "resources" && currentUser.role === "user") return; + if ( + currentUser.role === "user" && + (key === "resources" || key === "partners") + ) { + return; + } if (key === "users" && !isManager) return; - if (key === "reports" && !isManager) return; setActiveNav(key); setMenuOpen(false); }; const submitTask = async (event: FormEvent) => { event.preventDefault(); - if (taskForm.taskType === "content_publish" && !sourcePreview) { + const editing = Boolean(taskForm.id); + if (!editing && taskForm.taskType === "content_publish" && !sourcePreview) { setToast("请先读取飞书表格"); return; } if ( + !editing && taskForm.taskType === "content_publish" && taskForm.contentFormat === "video" && (sourcePreview?.videoCount ?? 0) === 0 @@ -709,29 +721,51 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { return; } } - const success = await runAction( - taskForm.taskType === "screenshot_collect" - ? { - action: "create_screenshot_task", - name: taskForm.name, - brand: taskForm.brand, - dueAt: taskForm.dueAt, - keyword: taskForm.keyword, - instructions: taskForm.instructions, - quantity: taskForm.quantity, - exampleImageKey, - projectId: taskForm.projectId, - } - : { action: "create_task", ...taskForm }, - taskForm.taskType === "screenshot_collect" - ? `截图回收任务已创建,${taskForm.quantity} 份任务进入可领取池` - : `任务已创建,${sourcePreview?.rowCount ?? 0} 篇内容进入可领取池`, - ); + const sharedFields = { + name: taskForm.name, + brand: taskForm.brand, + dueAt: taskForm.dueAt, + projectId: taskForm.projectId, + ownerUserid: taskForm.ownerUserid, + }; + const screenshotFields = { + ...sharedFields, + keyword: taskForm.keyword, + instructions: taskForm.instructions, + quantity: taskForm.quantity, + exampleImageKey, + }; + const publishFields = { + ...sharedFields, + platform: taskForm.platform, + contentFormat: taskForm.contentFormat, + feishuUrl: taskForm.feishuUrl, + }; + const success = editing + ? await runAction( + { + action: "task_update", + taskId: taskForm.id, + ...(taskForm.taskType === "screenshot_collect" + ? screenshotFields + : publishFields), + }, + "任务已更新", + ) + : await runAction( + taskForm.taskType === "screenshot_collect" + ? { action: "create_screenshot_task", ...screenshotFields } + : { action: "create_task", ...publishFields }, + taskForm.taskType === "screenshot_collect" + ? `截图回收任务已创建,${taskForm.quantity} 份任务进入可领取池` + : `任务已创建,${sourcePreview?.rowCount ?? 0} 篇内容进入可领取池`, + ); if (success) { setTaskModalOpen(false); setSourcePreview(null); setTaskExampleImage(null); setTaskForm({ + id: "", taskType: "content_publish", platform: "小红书", contentFormat: "image_text", @@ -743,6 +777,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { instructions: "在小红书搜索指定关键词,打开 AI 总结并上传完整截图。", quantity: 1, projectId: "", + ownerUserid: "", }); } }; @@ -882,12 +917,42 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { ); }; + const remindDueSoon = async (taskId: string, pendingCount: number) => { + if (pendingCount === 0) { + setToast("当前没有待发布的笔记"); + return; + } + setWorking(true); + try { + const response = await fetch("/api/action", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "trigger_wecom_due_soon", taskId }), + }); + const result = await readApiResponse<{ + dueSoonSent?: number; + dueSoonSkipped?: number; + dueSoonFailed?: number; + error?: string; + }>(response, "催办发送失败"); + if (!response.ok) throw new Error(result.error || "催办发送失败"); + const parts = [`送达 ${result.dueSoonSent ?? 0} 位`]; + if (result.dueSoonSkipped) parts.push(`未绑定企微 ${result.dueSoonSkipped} 位`); + if (result.dueSoonFailed) parts.push(`失败 ${result.dueSoonFailed} 位`); + setToast(`催办已发出:${parts.join(",")}`); + } catch (reason) { + setToast(reason instanceof Error ? reason.message : "催办发送失败"); + } finally { + setWorking(false); + } + }; + const saveCollectionSchedule = async ( taskId: string, startDate: string, days: number[], - ) => { - return runAction( + ): Promise => { + const result = await runAction( { action: "save_collection_schedule", taskId, @@ -896,6 +961,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { }, `采集任务已创建,将在所选日期09:00自动执行`, ); + return result !== false; }; const exportRecoveryData = async (task: Task) => { @@ -1119,9 +1185,11 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { - + {isManager && ( + + )} @@ -1153,6 +1221,14 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { ) : ( <> + {isTaskOwnerView && !currentUser.wecom_userid && ( +
+
+

账号尚未绑定企业微信

+

绑定任务负责人的企微 userid 后,这里会展示您负责的任务进度、领取明细与数据回收情况。请联系管理员在「用户管理」中绑定。

+
+
+ )} {activeNav === "overview" && ( setTaskModalOpen(true)} + onCreate={(projectId) => { + if (projectId) { + setTaskForm((form) => ({ ...form, projectId })); + } + setTaskModalOpen(true); + }} + onEditTask={(task) => { + setSourcePreview(null); + setTaskExampleImage(null); + setTaskForm({ + id: task.id, + taskType: task.task_type, + platform: task.platform, + contentFormat: task.content_format, + name: task.name, + brand: task.brand, + dueAt: task.due_at, + feishuUrl: "", + keyword: task.keyword ?? "", + instructions: task.instructions ?? "", + quantity: task.quantity, + projectId: task.project_id ?? "", + ownerUserid: task.owner_userid ?? "", + }); + setTaskModalOpen(true); + }} onCopyShare={copyTaskShareLink} onPushToGroups={(task) => setGroupPushTask(task)} - onOneClickPush={(task) => setOnePushTask(task)} onOwnerLinks={(task) => setOwnerLinksTask(task)} - onRelease={async (distribution) => - runAction( + onBindCustomerChat={(task) => setCustomerChatTask(task)} + onRelease={async (distribution) => { + const result = await runAction( { action: "release_distribution", distributionId: distribution.id, }, `“${distribution.content_title}”已释放,可重新领取`, - ) - } + ); + return result !== false; + }} /> )} {activeNav === "resources" && ( @@ -1208,7 +1310,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { {activeNav === "users" && isManager && ( )} - {activeNav === "reports" && isManager && ( + {activeNav === "reports" && ( )} {activeNav === "recovery" && ( @@ -1218,12 +1320,14 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { working={working} collectingDistributionIds={collectingDistributionIds} canUpdatePublishUrl={isManager} + isManager={isManager} onCollect={collectMetrics} onUpdatePublishUrl={updateDistributionPublishUrl} onSaveSchedule={saveCollectionSchedule} onUpload={uploadScreenshot} onFallback={openMetricFallback} onRetryFailed={retryFailedMetrics} + onRemind={remindDueSoon} onExport={exportRecoveryData} onExportScreenshots={exportScreenshotResults} exportingTaskId={exportingTaskId} @@ -1239,8 +1343,10 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
event.stopPropagation()}>
-

任务类型明确,回收链路独立

-

新建任务

+

+ {taskForm.id ? "任务创建后仍可调整基础信息" : "任务类型明确,回收链路独立"} +

+

{taskForm.id ? "编辑任务" : "新建任务"}

@@ -1248,6 +1354,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { + + 保存后自动拉进项目总群并预建任务群(负责人+项目负责人)。 +
)} + {taskOwnerPickerOpen && ( + setTaskOwnerPickerOpen(false)} + onConfirm={(userids) => { + setTaskForm((current) => ({ ...current, ownerUserid: userids[0] ?? "" })); + setTaskOwnerPickerOpen(false); + }} + /> + )} + {metricModal && (
setMetricModal(null)}>
event.stopPropagation()}> @@ -1509,14 +1662,6 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { /> )} - {onePushTask && ( - setOnePushTask(null)} - setToast={setToast} - /> - )} - {ownerLinksTask && ( )} + {customerChatTask && ( + setCustomerChatTask(null)} + onBound={() => { + void loadData(); + }} + /> + )} + {toast &&
✓{toast}
} {working &&
}
@@ -1995,7 +2155,7 @@ function PartnersPage({

合作方

-

群主型合作方用于项目任务群分发;外部KOC由领取流程自动生成,绑定外部联系人后接收催办

+

群主型合作方由任务负责人自动生成(负责任务群分发);外部KOC由领取流程自动生成,绑定外部联系人后接收催办

- {detail.project.mainGroupMemberIds.length === 0 ? ( -

未配置,编辑项目可填写成员 userid

+ {detail.mainChat.chatId ? ( + <> +

+ 已建群 {detail.mainChat.chatId},推送任务直接群发进群。 +

+
    + {detail.mainChat.members.map((userid) => ( +
  • {userid}
  • + ))} +
+ ) : ( -
    - {detail.project.mainGroupMemberIds.map((userid) => ( -
  • {userid}
  • - ))} -
+

+ {mainChatMissingHint(detail.mainChat.members.length)} +

)}
-

任务群({detail.groupChats.length})

- +

+ 任务群({detail.tasks.filter((task) => task.task_chat_id).length}/{detail.tasks.length}) +

+
- {detail.groupChats.length === 0 ? ( -

未关联任务群,点击「管理关联」从客户群中选择

+ + 新建任务自动挂入「{project.name}」;分配负责人后自动建群,领取人领取后自动进群 + + {detail.tasks.length === 0 ? ( +

项目下还没有任务,点击右上「新建任务」创建。

) : ( - + - {detail.groupChats.map((chat) => ( - - - - - + {detail.tasks.map((task) => ( + + + + + + ))} @@ -2825,77 +3010,19 @@ function ProjectSection({ )} - {chatManaging && detail && ( -
setChatManaging(false)}> -
event.stopPropagation()}> -
-
-

项目任务群 · 人工映射

-

管理「{detail.project.name}」的任务群

-
- -
-

- 勾选要纳入项目的客户群;每个群选择对应的合作方(群主)。群主信息从企微同步后只读, - 真正调整群主请去企业微信客户端操作后重新同步。 -

-
- {chatLoading ? ( -
正在读取客户群与合作方…
- ) : chatGroups.length === 0 ? ( -
- 暂无客户群,请先在「任务分发」里同步客户群列表,或在「合作方」里新建群主合作方。 -
- ) : ( - chatGroups.map((group) => { - const selected = chatDraft.some((item) => item.chatId === group.chat_id); - const partnerId = chatDraft.find((item) => item.chatId === group.chat_id)?.partnerId ?? ""; - return ( -
- - {selected && ( - - )} -
- ); - }) - )} -
- {chatDraft.some((item) => !chatGroups.some((group) => group.chat_id === item.chatId)) && ( -

- 还有已关联但同步不到的群(列表中不存在),保存后会被移除。 -

- )} -
- - -
-
-
+ {ownerPickerTaskId && detail && ( + task.id === ownerPickerTaskId)?.owner_userid.trim() + ? [detail.tasks.find((task) => task.id === ownerPickerTaskId)!.owner_userid.trim()] + : [] + } + onClose={() => setOwnerPickerTaskId(null)} + onConfirm={(userids) => { + void assignOwner(ownerPickerTaskId, userids[0] ?? ""); + }} + /> )} {pushProject && ( @@ -2910,223 +3037,6 @@ function ProjectSection({ ); } -type InternalWecomContact = { - userid: string; - name: string; - departmentNames: string[]; -}; - -let internalWecomContactsCache: { - contacts: InternalWecomContact[]; - fetchedAt: number; -} | null = null; - -const INTERNAL_WECOM_CONTACTS_TTL_MS = 5 * 60 * 1000; - -function WecomUserPickerModal({ - mode, - selected, - onClose, - onConfirm, -}: { - mode: "single" | "multi"; - selected: string[]; - onClose: () => void; - onConfirm: (userids: string[]) => void; -}) { - const [contacts, setContacts] = useState( - internalWecomContactsCache?.contacts ?? [], - ); - const [fetchedAt, setFetchedAt] = useState(0); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(""); - const [search, setSearch] = useState(""); - const [checked, setChecked] = useState(selected.filter(Boolean)); - - const loadContacts = useCallback(async (force = false) => { - const snapshot = internalWecomContactsCache; - if ( - !force && - snapshot && - Date.now() - snapshot.fetchedAt <= INTERNAL_WECOM_CONTACTS_TTL_MS - ) { - setContacts(snapshot.contacts); - setFetchedAt(snapshot.fetchedAt); - setLoading(false); - return; - } - setLoading(true); - setError(""); - try { - const response = await fetch("/api/action", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action: "wecom_list_internal_contacts" }), - }); - const parsed = await readApiResponse<{ - contacts?: InternalWecomContact[]; - error?: string; - }>(response, "获取企业微信通讯录失败"); - if (!response.ok) { - throw new Error(parsed.error || "获取企业微信通讯录失败"); - } - const list = Array.isArray(parsed.contacts) ? parsed.contacts : []; - internalWecomContactsCache = { contacts: list, fetchedAt: Date.now() }; - setContacts(list); - setFetchedAt(internalWecomContactsCache.fetchedAt); - } catch (reason) { - setError( - reason instanceof Error ? reason.message : "获取企业微信通讯录失败", - ); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - const timer = window.setTimeout(() => { - void loadContacts(); - }, 0); - return () => window.clearTimeout(timer); - }, [loadContacts]); - - const keyword = search.trim().toLowerCase(); - const filteredContacts = - keyword.length === 0 - ? contacts - : contacts.filter((contact) => - `${contact.name} ${contact.userid}`.toLowerCase().includes(keyword), - ); - - const toggleChecked = (userid: string) => { - setChecked((previous) => - previous.includes(userid) - ? previous.filter((item) => item !== userid) - : [...previous, userid], - ); - }; - - return ( -
-
event.stopPropagation()} - > -
-
-

选择企业微信成员

-

- 从企业微信通讯录(自建应用可见范围)中挑选, - {mode === "multi" ? "勾选后点击确定" : "点击成员即填入"}。 -

-
- -
-
- setSearch(event.target.value)} - disabled={loading} - /> - - {fetchedAt > 0 && !loading && ( - - 缓存于 {new Date(fetchedAt).toLocaleTimeString()}(5 分钟内复用) - - )} -
- {error && ( -

- {error} 可关闭此弹窗,直接在输入框手动填写 userid。 -

- )} -
- {loading && contacts.length === 0 ? ( -
正在拉取企业微信通讯录…
- ) : contacts.length === 0 ? ( -
- {error - ? "拉取失败,请检查应用配置(通讯录权限 / 可信 IP)后重试" - : "未拉取到通讯录成员——仅显示自建应用可见范围内的成员,也可手动填写 userid"} -
- ) : filteredContacts.length === 0 ? ( -
没有匹配的成员
- ) : ( - filteredContacts.map((contact) => { - const isSelected = - mode === "multi" - ? checked.includes(contact.userid) - : selected.includes(contact.userid); - const rowBody = ( - <> - - - - {contact.name || contact.userid} - - - {contact.departmentNames.join(" / ") || "—"} - - {contact.userid} - - - ); - return mode === "multi" ? ( - - ) : ( - - ); - }) - )} -
- {mode === "multi" && ( -
- - -
- )} -
-
- ); -} - function ProjectEditorModal({ draft, saving, @@ -3328,11 +3238,11 @@ function ProjectPushModal({ const [sendGroupChats, setSendGroupChats] = useState(true); const [sending, setSending] = useState(false); const [outcome, setOutcome] = useState<{ + mainChatMode: "group" | "per_member" | "skipped"; appResults: Array<{ userid: string; ok: boolean; error: string }>; appSent: number; appFailedCount: number; - groupResults: WecomGroupPushResult[]; - groupChatCount: number; + taskChat: { sent: boolean; chatId: string; reason?: string }; skippedReasons: string[]; hint?: string; } | null>(null); @@ -3414,7 +3324,11 @@ function ProjectPushModal({ /> 项目总群 - {detail.project.mainGroupMemberIds.length} 名成员,逐人发送应用消息 + + {detail.mainChat.chatId + ? "已建群,消息直接群发进群" + : `${detail.project.mainGroupMemberIds.length} 名成员,总群未建时逐人私信`} + @@ -3445,8 +3359,12 @@ function ProjectPushModal({ {outcome && (

- 应用消息:成功 {outcome.appSent} 人、失败 {outcome.appFailedCount} 人; - 任务群群发:覆盖 {outcome.groupChatCount} 个群。 + {outcome.mainChatMode === "group" + ? "项目总群:已群发进群。" + : outcome.mainChatMode === "per_member" + ? `项目总群:逐人私信,成功 ${outcome.appSent} 人、失败 ${outcome.appFailedCount} 人。` + : "项目总群:未发送。"} + 任务群:{outcome.taskChat.sent ? `已发送到 ${outcome.taskChat.chatId}` : `未发送(${outcome.taskChat.reason ?? "原因未知"})`}。 {outcome.skippedReasons.length > 0 ? `(${outcome.skippedReasons.join(";")})` : ""} {outcome.hint ? ` ${outcome.hint}。` : ""}

@@ -3459,18 +3377,6 @@ function ProjectPushModal({ ))} )} - {outcome.groupResults.length > 0 && ( -
    - {outcome.groupResults.map((item) => ( -
  • - 群主 {item.sender} · {item.chatCount} 个群 · msgid {item.msgid || "—"} - {item.failList.length > 0 && ( - (失败群:{item.failList.join("、")}) - )} -
  • - ))} -
- )}
)}
@@ -3489,36 +3395,6 @@ function ProjectPushModal({ ); } -type BoundWecomPartnerRow = { - id: string; - name: string; - wecomExternalUserId: string; -}; - -type TaskPushLogRecord = { - id: string; - task_id: string; - push_kind: string; - main_group_count: number; - koc_ok_count: number; - detail: string; - created_at: string; -}; - -type OneClickPushOutcomeView = { - linkUrl: string; - kocSent: number; - kocFailed: number; - kocResults: Array<{ partnerId: string; partnerName: string; ok: boolean; error: string }>; - skippedReasons: string[]; - hint?: string; -}; - -const PUSH_KIND_LABELS: Record = { - task_open: "任务推送", - card_ack: "卡片确认", -}; - type TaskReportRow = { id: string; name: string; @@ -3556,290 +3432,6 @@ const REPORT_STATUS_FILTERS = [ { key: "completed", label: "已完结" }, ]; -function formatPushLogTime(value: string) { - const date = new Date(value); - if (Number.isNaN(date.getTime())) return value; - return `${formatDate(date.toISOString().slice(0, 10))} ${date.toTimeString().slice(0, 5)}`; -} - -function OneClickPushModal({ - task, - onClose, - setToast, -}: { - task: Task; - onClose: () => void; - setToast: (message: string) => void; -}) { - const [partners, setPartners] = useState([]); - const [loading, setLoading] = useState(true); - const [search, setSearch] = useState(""); - const [selected, setSelected] = useState([]); - const [sendMainGroup, setSendMainGroup] = useState(true); - const [createGroupTemplate, setCreateGroupTemplate] = useState(true); - const [sendKoc, setSendKoc] = useState(true); - const [force, setForce] = useState(false); - const [pushedToday, setPushedToday] = useState(false); - const [sending, setSending] = useState(false); - const [result, setResult] = useState(null); - const [logs, setLogs] = useState([]); - - const postAction = useCallback(async (payload: Record, fallback: string) => { - const response = await fetch("/api/action", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - const parsed = await readApiResponse(response, fallback); - if (!response.ok) throw new Error(parsed.error || fallback); - return parsed; - }, []); - - const loadLogs = useCallback(async () => { - try { - const parsed = await postAction<{ logs: TaskPushLogRecord[] }>( - { action: "task_push_logs", taskId: task.id }, - "读取推送记录失败", - ); - setLogs(parsed.logs); - } catch { - // 推送记录加载失败不阻断弹窗 - } - }, [postAction, task.id]); - - useEffect(() => { - const timer = window.setTimeout(() => { - void (async () => { - try { - const parsed = await postAction<{ - partners: BoundWecomPartnerRow[]; - pushedToday: unknown; - }>({ action: "wecom_bound_partners", taskId: task.id }, "读取合作方失败"); - setPartners(parsed.partners); - setSelected(parsed.partners.map((partner) => partner.id)); - setPushedToday(Boolean(parsed.pushedToday)); - } catch (reason) { - setToast(reason instanceof Error ? reason.message : "读取合作方失败"); - } finally { - setLoading(false); - } - })(); - void loadLogs(); - }, 0); - return () => window.clearTimeout(timer); - }, [postAction, loadLogs, task.id, setToast]); - - const filtered = partners.filter((partner) => { - const keyword = search.trim().toLowerCase(); - if (!keyword) return true; - return ( - partner.name.toLowerCase().includes(keyword) || - partner.wecomExternalUserId.toLowerCase().includes(keyword) - ); - }); - const allSelected = partners.length > 0 && selected.length === partners.length; - - const togglePartner = (partnerId: string) => { - setSelected((previous) => - previous.includes(partnerId) - ? previous.filter((id) => id !== partnerId) - : [...previous, partnerId], - ); - }; - - const submitPush = async () => { - if (!sendMainGroup && !sendKoc && !createGroupTemplate) { - setToast("请至少选择一个推送目标"); - return; - } - if (sendKoc && selected.length === 0) { - setToast("请至少勾选一位合作方,或取消勾选「KOC 个人直达」"); - return; - } - setSending(true); - try { - const parsed = await postAction( - { - action: "task_one_click_push", - taskId: task.id, - sendMainGroup, - sendKoc, - createGroupTemplate, - selectedKocPartnerIds: selected, - force, - }, - "一键推送失败", - ); - setResult(parsed); - setPushedToday(true); - setToast("推送已执行"); - await loadLogs(); - } catch (reason) { - const message = reason instanceof Error ? reason.message : "一键推送失败"; - if (message.includes("仍要推送")) setPushedToday(true); - setToast(message); - } finally { - setSending(false); - } - }; - - return ( -
-
event.stopPropagation()}> -
-
-

企业微信 · 一键推送

-

推送「{task.name}」

-
- -
- {pushedToday && ( - - )} -
- - - -
- {sendKoc && ( -
- {loading ? ( - 正在读取已绑定合作方… - ) : partners.length === 0 ? ( - 还没有已绑定企微身份的合作方;合作方在企微内打开领取链接领取后会自动绑定。 - ) : ( - <> -
- setSearch(event.target.value)} - placeholder="搜索合作方名称 / 企微 ID" - disabled={sending} - /> - -
-
- {filtered.map((partner) => ( - - ))} - {filtered.length === 0 && 没有匹配的合作方} -
- - )} -
- )} - {result && ( -
-

- KOC 直达:成功 {result.kocSent} 人、失败 {result.kocFailed} 人。 - {result.skippedReasons.length > 0 ? `(${result.skippedReasons.join(";")})` : ""} - {result.hint ? ` ${result.hint}。` : ""} -

- {result.kocResults.some((item) => !item.ok) && ( -
    - {result.kocResults.filter((item) => !item.ok).map((item) => ( -
  • - {item.partnerName}:{item.error || "发送失败"} -
  • - ))} -
- )} -
- )} - {logs.length > 0 && ( -
- 推送记录 -
    - {logs.slice(0, 5).map((log) => ( -
  • - {formatPushLogTime(log.created_at)} · {PUSH_KIND_LABELS[log.push_kind] ?? log.push_kind} - {log.push_kind === "task_open" - ? `(总群 ${log.main_group_count} 人,KOC 直达 ${log.koc_ok_count} 人)` - : ""} -
  • - ))} -
-
- )} -
- - -
-
-
- ); -} - function ReportsPage({ setToast }: { setToast: (message: string) => void }) { const [reports, setReports] = useState([]); const [loading, setLoading] = useState(true); @@ -4342,7 +3934,7 @@ function OwnerLinksModal({
正在读取群主列表…
) : !view || view.owners.length === 0 ? (
- 还没有登记群主,请先在「合作方管理」新建群主合作方(需填写企业微信 userid)。 + 该任务未分配负责人,请先在任务中心分配任务负责人。
) : ( <> @@ -4350,8 +3942,8 @@ function OwnerLinksModal({

任务群状态

{view.taskChatId - ? "已建群,群主领取后自动进群" - : "未建群:内部群主领取任务后自动创建(需项目已填负责人)"} + ? "已建群,领取人自动进群" + : "未建群:分配任务负责人后自动创建(负责人+领取人)"}
{view.claimUrl && ( @@ -4409,19 +4001,23 @@ function OwnerLinksModal({ function TaskRowCard({ task, portalUrl, + isManager, onDetail, + onEdit, onCopyShare, onPushToGroups, - onOneClickPush, onOwnerLinks, + onBindCustomerChat, }: { task: Task; portalUrl: string; + isManager: boolean; onDetail: () => void; + onEdit: (task: Task) => void; onCopyShare: (task: Task) => void; onPushToGroups: (task: Task) => void; - onOneClickPush: (task: Task) => void; onOwnerLinks: (task: Task) => void; + onBindCustomerChat: (task: Task) => void; }) { const progress = Math.round((task.claimed_quantity / task.quantity) * 100); return ( @@ -4439,6 +4035,7 @@ function TaskRowCard({ {task.source_sheet_name ? `飞书 ${task.source_sheet_name}` : "历史示例内容表"} )} + {task.customer_chat_name ? 客户群:{task.customer_chat_name} : null}
@@ -4452,26 +4049,29 @@ function TaskRowCard({ > 分发明细 - + {isManager && ( + + )} + {isManager && ( + + )} - + {portalUrl && task.share_token && ( void; onTasksChanged: () => Promise; - onCreate: () => void; + onCreate: (projectId?: string) => void; + onEditTask: (task: Task) => void; onCopyShare: (task: Task) => void; onPushToGroups: (task: Task) => void; - onOneClickPush: (task: Task) => void; onOwnerLinks: (task: Task) => void; + onBindCustomerChat: (task: Task) => void; onRelease: (distribution: Distribution) => Promise; }) { const [selectedTaskId, setSelectedTaskId] = useState(null); @@ -4581,7 +4189,9 @@ function TasksPage({ } setEditorSaving(true); try { - await postAction( + const parsed = await postAction<{ + mainChat?: { chatId: string | null; warning?: string }; + }>( { action: "project_save", id: editorDraft.id, @@ -4594,7 +4204,13 @@ function TasksPage({ "保存项目失败", ); setEditorOpen(false); - setToast(editorDraft.id ? "项目已更新" : "项目已创建"); + setToast( + parsed.mainChat?.warning + ? `${editorDraft.id ? "项目已更新" : "项目已创建"};总群同步失败:${parsed.mainChat.warning}` + : editorDraft.id + ? "项目已更新" + : "项目已创建", + ); await loadProjects(); setDetailVersion((version) => version + 1); } catch (reason) { @@ -4649,7 +4265,9 @@ function TasksPage({
{isManager && } - + {isManager && ( + + )}
@@ -4690,11 +4308,13 @@ function TasksPage({ onProjectsChanged={loadProjects} onEditProject={openEditor} onDeleteProject={setDeleteTarget} + onCreateTask={onCreate} onOpenTaskDetail={setSelectedTaskId} + onEditTask={onEditTask} onCopyShare={onCopyShare} onPushToGroups={onPushToGroups} - onOneClickPush={onOneClickPush} onOwnerLinks={onOwnerLinks} + onBindCustomerChat={onBindCustomerChat} /> )) )} @@ -4711,11 +4331,13 @@ function TasksPage({ key={task.id} task={task} portalUrl={portalUrl} + isManager={isManager} onDetail={() => setSelectedTaskId(task.id)} + onEdit={onEditTask} onCopyShare={onCopyShare} onPushToGroups={onPushToGroups} - onOneClickPush={onOneClickPush} onOwnerLinks={onOwnerLinks} + onBindCustomerChat={onBindCustomerChat} /> ))} @@ -4733,11 +4355,13 @@ function TasksPage({ key={task.id} task={task} portalUrl={portalUrl} + isManager={isManager} onDetail={() => setSelectedTaskId(task.id)} + onEdit={onEditTask} onCopyShare={onCopyShare} onPushToGroups={onPushToGroups} - onOneClickPush={onOneClickPush} onOwnerLinks={onOwnerLinks} + onBindCustomerChat={onBindCustomerChat} /> ))} @@ -6333,12 +5957,14 @@ function RecoveryPage({ working, collectingDistributionIds, canUpdatePublishUrl, + isManager, onCollect, onUpdatePublishUrl, onSaveSchedule, onUpload, onFallback, onRetryFailed, + onRemind, onExport, onExportScreenshots, exportingTaskId, @@ -6348,6 +5974,7 @@ function RecoveryPage({ working: boolean; collectingDistributionIds: ReadonlySet; canUpdatePublishUrl: boolean; + isManager: boolean; onCollect: (distribution: Distribution) => void; onUpdatePublishUrl: (distribution: Distribution) => void; onSaveSchedule: ( @@ -6358,6 +5985,7 @@ function RecoveryPage({ onUpload: (distribution: Distribution, event: ChangeEvent) => void; onFallback: (distribution: Distribution) => void; onRetryFailed: (taskId: string, failedCount: number) => void; + onRemind: (taskId: string, pendingCount: number) => void; onExport: (task: Task) => void; onExportScreenshots: (task: Task) => void; exportingTaskId: string | null; @@ -6474,16 +6102,33 @@ function RecoveryPage({ ? "正在生成 Excel…" : "导出全部数据"} - + {isManager && ( + + )} + {(() => { + const pendingCount = taskDistributions.filter( + (item) => !item.publish_url && !item.result_submitted_at, + ).length; + return ( + + ); + })()}
diff --git a/app/api/action/route.ts b/app/api/action/route.ts index 6d358f1..d86a26d 100644 --- a/app/api/action/route.ts +++ b/app/api/action/route.ts @@ -33,6 +33,7 @@ import { import { createDistributionTask, createScreenshotTask, + updateTask, } from "../../../lib/task-service"; import { DistributionReleaseError, @@ -58,7 +59,19 @@ import { pushTaskToGroupChats, syncGroupChats, } from "../../../lib/wecom-group-push-service"; -import { isManagerRequest } from "../../../lib/user-auth"; +import { + bindTaskCustomerChat, + listOwnerCustomerChats, + unbindTaskCustomerChat, +} from "../../../lib/task-customer-chat-service"; +import { isManagerRequest, managerForbidden } from "../../../lib/user-auth"; +import { + getOwnerScope, + isDistributionOwner, + isTaskOwner, + ownerForbidden, + ownerScopeUserid, +} from "../../../lib/owner-scope"; import { extractPublishUrl } from "../../../lib/publish-url"; import { getAccountFieldRefreshStatus, @@ -66,22 +79,17 @@ import { } from "../../../lib/account-field-refresh-service"; import { deleteProject, + ensureProjectMainChat, + findOrCreateGroupOwnerPartner, getProjectDetail, listGroupOwnerPartners, listProjects, ProjectError, saveGroupOwnerPartner, saveProject, - setProjectGroupChats, } from "../../../lib/project-service"; +import { assignTaskOwner } from "../../../lib/task-group-service"; import { pushProjectTask } from "../../../lib/project-push-service"; -import { - findTodayTaskPushLog, - listBoundWecomPartners, - listTaskPushLogs, - OneClickPushError, - oneClickPushTask, -} from "../../../lib/task-one-click-push-service"; type ActionBody = { action?: string; @@ -93,14 +101,49 @@ function numberValue(value: unknown, fallback = 0) { return Number.isFinite(parsed) ? parsed : fallback; } +async function assignOwnerQuietly(taskId: string, ownerUserid: unknown) { + const owner = String(ownerUserid ?? "").trim(); + if (!owner) return ""; + try { + const assignment = await assignTaskOwner( + getRawDb(), + { taskId, ownerUserid: owner }, + resolveWecomConfig(env as unknown as WecomBindings), + ); + return assignment.warning ?? ""; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } +} + export async function POST(request: Request) { if (!(await isAdminRequest(request))) return adminForbidden(); try { await ensureSchema(); const body = (await request.json()) as ActionBody; const db = getRawDb(); + let ownerWarning = ""; + const scope = await getOwnerScope(request); + const managerGate = () => + scope.kind === "owner" ? managerForbidden() : null; + const taskOwnerGate = async (taskId: unknown) => { + if (scope.kind === "full") return null; + const id = String(taskId ?? "").trim(); + return id && (await isTaskOwner(db, id, scope.wecomUserId)) + ? null + : ownerForbidden(); + }; + const distributionOwnerGate = async (distributionId: unknown) => { + if (scope.kind === "full") return null; + const id = String(distributionId ?? "").trim(); + return id && (await isDistributionOwner(db, id, scope.wecomUserId)) + ? null + : ownerForbidden(); + }; if (body.action === "inspect_feishu") { + const forbidden = managerGate(); + if (forbidden) return forbidden; const source = await readFeishuSource( String(body.feishuUrl ?? "").trim(), env as unknown as FeishuBindings, @@ -124,6 +167,8 @@ export async function POST(request: Request) { } if (body.action === "create_task") { + const forbidden = managerGate(); + if (forbidden) return forbidden; const name = String(body.name ?? "").trim(); const brand = String(body.brand ?? "").trim(); const dueAt = String(body.dueAt ?? "").trim(); @@ -135,7 +180,7 @@ export async function POST(request: Request) { { status: 400 }, ); } - await createDistributionTask( + const creation = await createDistributionTask( { feishuUrl: String(body.feishuUrl ?? "").trim(), name, @@ -144,11 +189,15 @@ export async function POST(request: Request) { platform, contentFormat, projectId: String(body.projectId ?? "").trim(), + ownerUserid: String(body.ownerUserid ?? "").trim(), }, env as unknown as FeishuBindings, ); + ownerWarning = await assignOwnerQuietly(creation.taskId, body.ownerUserid); } else if (body.action === "create_screenshot_task") { - await createScreenshotTask({ + const forbidden = managerGate(); + if (forbidden) return forbidden; + const creation = await createScreenshotTask({ name: String(body.name ?? "").trim(), brand: String(body.brand ?? "").trim(), dueAt: String(body.dueAt ?? "").trim(), @@ -157,8 +206,12 @@ export async function POST(request: Request) { quantity: numberValue(body.quantity), exampleImageKey: String(body.exampleImageKey ?? "").trim(), projectId: String(body.projectId ?? "").trim(), + ownerUserid: String(body.ownerUserid ?? "").trim(), }); + ownerWarning = await assignOwnerQuietly(creation.taskId, body.ownerUserid); } else if (body.action === "claim") { + const forbidden = managerGate(); + if (forbidden) return forbidden; const partnerId = String(body.partnerId ?? ""); const taskId = String(body.taskId ?? ""); const quantity = Math.max(1, Math.min(50, numberValue(body.quantity, 1))); @@ -361,6 +414,8 @@ export async function POST(request: Request) { "account enrichment after publish URL update", ); } else if (body.action === "save_collection_schedule") { + const forbidden = managerGate(); + if (forbidden) return forbidden; const taskId = String(body.taskId ?? "").trim(); const startDate = String(body.startDate ?? "").trim(); const days = Array.isArray(body.days) @@ -439,6 +494,8 @@ export async function POST(request: Request) { ).catch(() => undefined); runInBackground(catchup, "collection catchup"); } else if (body.action === "run_due_collections") { + const forbidden = managerGate(); + if (forbidden) return forbidden; await runDueScheduledCollections( db, Date.now(), @@ -451,6 +508,8 @@ export async function POST(request: Request) { body.action === "collect_now" || body.action === "collect" ) { + const forbidden = managerGate(); + if (forbidden) return forbidden; const distributionId = String(body.distributionId ?? ""); const rawDay = body.day === null || body.day === undefined @@ -492,6 +551,8 @@ export async function POST(request: Request) { ), ); } else if (body.action === "retry_failed_collections") { + const forbidden = managerGate(); + if (forbidden) return forbidden; const taskId = String(body.taskId ?? "").trim(); if (!taskId) { return Response.json({ error: "请选择需要补采的任务" }, { status: 400 }); @@ -517,6 +578,8 @@ export async function POST(request: Request) { ), ); } else if (body.action === "backfill_account_profiles") { + const forbidden = managerGate(); + if (forbidden) return forbidden; const backfill = backfillAccountProfiles( db, resolveCollectionMcpConfig( @@ -526,6 +589,8 @@ export async function POST(request: Request) { ).catch(() => undefined); runInBackground(backfill, "account profile backfill"); } else if (body.action === "set_public_account_ids") { + const forbidden = managerGate(); + if (forbidden) return forbidden; const items = Array.isArray(body.items) ? body.items.slice(0, 50) : []; const normalized = items .map((item) => { @@ -564,6 +629,8 @@ export async function POST(request: Request) { ), ); } else if (body.action === "manual_metrics") { + const forbidden = await distributionOwnerGate(body.distributionId); + if (forbidden) return forbidden; const distributionId = String(body.distributionId ?? ""); const exposure = Math.max(0, numberValue(body.exposure)); const views = Math.max(0, numberValue(body.views)); @@ -580,6 +647,8 @@ export async function POST(request: Request) { .bind(exposure, views, distributionId) .run(); } else if (body.action === "bind_wecom_external_id") { + const forbidden = managerGate(); + if (forbidden) return forbidden; const partnerId = String(body.partnerId ?? "").trim().slice(0, 80); const externalId = String(body.wecomExternalUserId ?? "") .trim() @@ -612,6 +681,8 @@ export async function POST(request: Request) { wecomName, }); } else if (body.action === "send_test_wecom") { + const forbidden = managerGate(); + if (forbidden) return forbidden; const wecomConfig = resolveWecomConfig( env as unknown as WecomBindings, ); @@ -662,16 +733,23 @@ export async function POST(request: Request) { dueDays: wecomConfig.dueDays, }); } else if (body.action === "trigger_wecom_due_soon") { + const taskId = String(body.taskId ?? "").trim(); + const forbidden = await taskOwnerGate(taskId || undefined); + if (forbidden) return forbidden; const wecomConfig = resolveWecomConfig( env as unknown as WecomBindings, ); const summary = await runDueSoonWecomNotifications( db, wecomConfig, + undefined, + undefined, + taskId || undefined, ); return Response.json(summary); } else if (body.action === "trigger_wecom_overdue_escalation") { - if (!isManagerRequest(request)) return adminForbidden(); + const forbidden = managerGate(); + if (forbidden) return forbidden; const wecomConfig = resolveWecomConfig( env as unknown as WecomBindings, ); @@ -679,24 +757,32 @@ export async function POST(request: Request) { const autoRelease = await runOverdueAutoRelease(db, wecomConfig); return Response.json({ escalation, autoRelease }); } else if (body.action === "wecom_list_external_contacts") { + const forbidden = managerGate(); + if (forbidden) return forbidden; const wecomConfig = resolveWecomConfig( env as unknown as WecomBindings, ); const contacts = await listExternalContacts(wecomConfig); return Response.json({ contacts }); } else if (body.action === "wecom_list_internal_contacts") { + const forbidden = managerGate(); + if (forbidden) return forbidden; const wecomConfig = resolveWecomConfig( env as unknown as WecomBindings, ); const contacts = await listInternalContacts(wecomConfig); return Response.json({ contacts }); } else if (body.action === "wecom_sync_group_chats") { + const forbidden = managerGate(); + if (forbidden) return forbidden; const wecomConfig = resolveWecomConfig( env as unknown as WecomBindings, ); const groups = await syncGroupChats(db, wecomConfig); return Response.json({ groups, syncedCount: groups.length }); } else if (body.action === "wecom_push_task_to_groups") { + const forbidden = await taskOwnerGate(body.taskId); + if (forbidden) return forbidden; const wecomConfig = resolveWecomConfig( env as unknown as WecomBindings, ); @@ -725,34 +811,102 @@ export async function POST(request: Request) { }); } else if (body.action === "wecom_task_group_pushes") { const taskId = String(body.taskId ?? "").trim(); + const forbidden = await taskOwnerGate(taskId || undefined); + if (forbidden) return forbidden; const pushes = await listGroupPushes(db, taskId || undefined); return Response.json({ pushes }); } else if (body.action === "project_list") { + const forbidden = managerGate(); + if (forbidden) return forbidden; return Response.json({ projects: await listProjects(db) }); } else if (body.action === "project_detail") { + const forbidden = managerGate(); + if (forbidden) return forbidden; return Response.json(await getProjectDetail(db, String(body.projectId ?? ""))); } else if (body.action === "project_save") { - return Response.json({ - project: await saveProject(db, { + const forbidden = managerGate(); + if (forbidden) return forbidden; + const saved = await saveProject( + db, + { id: body.id, name: body.name, brand: body.brand, mainGroupMemberIds: body.mainGroupMemberIds, ownerUserid: body.ownerUserid, note: body.note, - }), - }); + }, + resolveWecomConfig(env as unknown as WecomBindings), + ); + return Response.json(saved); } else if (body.action === "project_delete") { + const forbidden = managerGate(); + if (forbidden) return forbidden; await deleteProject(db, String(body.projectId ?? "")); return Response.json({ ok: true }); - } else if (body.action === "project_set_group_chats") { - const detail = await setProjectGroupChats( + } else if (body.action === "project_ensure_main_chat") { + const forbidden = managerGate(); + if (forbidden) return forbidden; + const result = await ensureProjectMainChat( db, String(body.projectId ?? ""), - Array.isArray(body.items) ? body.items : [], + resolveWecomConfig(env as unknown as WecomBindings), ); - return Response.json(detail); + return Response.json(result); + } else if (body.action === "task_set_owner") { + const forbidden = managerGate(); + if (forbidden) return forbidden; + const taskId = String(body.taskId ?? "").trim(); + const ownerUserid = String(body.ownerUserid ?? "").trim(); + if (!taskId) { + return Response.json({ error: "缺少 taskId" }, { status: 400 }); + } + if (!ownerUserid) { + return Response.json({ error: "请选择任务负责人" }, { status: 400 }); + } + const existing = await db + .prepare("SELECT id FROM tasks WHERE id = ?") + .bind(taskId) + .first<{ id: string }>(); + if (!existing) { + return Response.json({ error: "任务不存在" }, { status: 404 }); + } + const assignment = await assignTaskOwner( + db, + { taskId, ownerUserid }, + resolveWecomConfig(env as unknown as WecomBindings), + ); + const task = await db + .prepare("SELECT owner_userid, task_chat_id FROM tasks WHERE id = ?") + .bind(taskId) + .first<{ owner_userid: string; task_chat_id: string }>(); + return Response.json({ ok: true, assignment, task }); + } else if (body.action === "task_update") { + const forbidden = managerGate(); + if (forbidden) return forbidden; + const platform = body.platform === "抖音" || body.platform === "小红书" + ? (body.platform as "小红书" | "抖音") + : undefined; + const contentFormat = body.contentFormat === "video" || body.contentFormat === "image_text" + ? (body.contentFormat as "image_text" | "video") + : undefined; + const update = await updateTask({ + taskId: String(body.taskId ?? ""), + name: String(body.name ?? ""), + brand: String(body.brand ?? ""), + dueAt: String(body.dueAt ?? ""), + projectId: String(body.projectId ?? ""), + platform, + contentFormat, + quantity: numberValue(body.quantity), + keyword: String(body.keyword ?? ""), + instructions: String(body.instructions ?? ""), + exampleImageKey: String(body.exampleImageKey ?? ""), + }); + ownerWarning = await assignOwnerQuietly(update.taskId, body.ownerUserid); } else if (body.action === "partner_save") { + const forbidden = managerGate(); + if (forbidden) return forbidden; return Response.json({ partner: await saveGroupOwnerPartner(db, { id: body.id, @@ -761,8 +915,12 @@ export async function POST(request: Request) { }), }); } else if (body.action === "partner_group_owners") { + const forbidden = managerGate(); + if (forbidden) return forbidden; return Response.json({ partners: await listGroupOwnerPartners(db) }); } else if (body.action === "project_push_task") { + const forbidden = managerGate(); + if (forbidden) return forbidden; const wecomConfig = resolveWecomConfig(env as unknown as WecomBindings); const outcome = await pushProjectTask( db, @@ -779,41 +937,15 @@ export async function POST(request: Request) { ...outcome, appSent: outcome.appResults.length - appFailed.length, appFailedCount: appFailed.length, - hint: "任务群走企业群发,群主需在企微客户端「群发助手」点击发送后,消息才会送达客户群", + hint: "任务群消息已直接发送至任务内部群(负责人与领取人即时可见)", }); - } else if (body.action === "wecom_bound_partners") { - const partners = await listBoundWecomPartners(db); - const taskId = String(body.taskId ?? "").trim(); - const pushedToday = taskId - ? await findTodayTaskPushLog(db, taskId, "task_open") - : null; - return Response.json({ partners, pushedToday }); - } else if (body.action === "task_one_click_push") { - const wecomConfig = resolveWecomConfig(env as unknown as WecomBindings); - const outcome = await oneClickPushTask( - db, - { - taskId: body.taskId, - sendMainGroup: body.sendMainGroup, - sendKoc: body.sendKoc, - createGroupTemplate: body.createGroupTemplate, - selectedKocPartnerIds: body.selectedKocPartnerIds, - force: body.force, - text: body.text, - }, - wecomConfig, - ); - return Response.json({ - ...outcome, - hint: "KOC 直达消息已即时送达;任务群走企业群发,群主需在企微客户端「群发助手」点击发送后才会送达客户群", - }); - } else if (body.action === "task_push_logs") { - const logs = await listTaskPushLogs(db, String(body.taskId ?? "").trim()); - return Response.json({ logs }); } else if (body.action === "task_report_list") { - if (!isManagerRequest(request)) return adminForbidden(); - return Response.json({ reports: await getTaskReportData(db) }); + return Response.json({ + reports: await getTaskReportData(db, ownerScopeUserid(scope)), + }); } else if (body.action === "refresh_account_fields") { + const forbidden = managerGate(); + if (forbidden) return forbidden; const status = startAccountFieldRefresh( db, resolveCollectionMcpConfig(env as unknown as CollectionMcpBindings), @@ -821,12 +953,16 @@ export async function POST(request: Request) { ); return Response.json({ refresh: status }); } else if (body.action === "account_field_refresh_status") { + const forbidden = managerGate(); + if (forbidden) return forbidden; return Response.json({ refresh: getAccountFieldRefreshStatus() }); } else if (body.action === "task_group_links") { const taskId = String(body.taskId ?? "").trim(); + const forbidden = await taskOwnerGate(taskId); + if (forbidden) return forbidden; const task = await db .prepare( - "SELECT id, name, share_token, task_chat_id FROM tasks WHERE id = ?", + "SELECT id, name, share_token, task_chat_id, owner_userid FROM tasks WHERE id = ?", ) .bind(taskId) .first<{ @@ -834,71 +970,162 @@ export async function POST(request: Request) { name: string; share_token: string | null; task_chat_id: string; + owner_userid: string; }>(); if (!task) return Response.json({ error: "任务不存在" }, { status: 404 }); const portalUrl = String(env.KOC_PORTAL_URL ?? "").replace(/\/$/, ""); - const owners = await listGroupOwnerPartners(db); + const ownerUserid = String(task.owner_userid ?? "").trim(); + let owners: { + partnerId: string; + name: string; + wecomUserid: string; + isInternal: boolean; + link: string; + }[] = []; + if (task.share_token && ownerUserid) { + try { + const partner = await findOrCreateGroupOwnerPartner(db, ownerUserid); + owners = [ + { + partnerId: partner.id, + name: partner.name, + wecomUserid: partner.wecom_userid, + isInternal: Boolean(partner.wecom_userid), + link: `${portalUrl}/?task=${task.share_token}&owner=${partner.id}`, + }, + ]; + } catch { + owners = []; + } + } return Response.json({ taskName: task.name, taskChatId: task.task_chat_id ?? "", claimUrl: task.share_token ? `${portalUrl}/?task=${task.share_token}` : "", - owners: task.share_token - ? owners.map((owner) => ({ - partnerId: owner.id, - name: owner.name, - wecomUserid: owner.wecom_userid, - isInternal: Boolean(owner.wecom_userid), - link: `${portalUrl}/?task=${task.share_token}&owner=${owner.id}`, - })) - : [], + owners, + }); + } else if (body.action === "task_customer_chats") { + const taskId = String(body.taskId ?? "").trim(); + const forbidden = await taskOwnerGate(taskId); + if (forbidden) return forbidden; + const task = await db + .prepare( + "SELECT id, owner_userid, customer_chat_id FROM tasks WHERE id = ?", + ) + .bind(taskId) + .first<{ id: string; owner_userid: string; customer_chat_id: string }>(); + if (!task) return Response.json({ error: "任务不存在" }, { status: 404 }); + const ownerUserid = ( + scope.kind === "full" + ? String(task.owner_userid ?? "") + : scope.wecomUserId + ).trim(); + if (!ownerUserid) { + return Response.json( + { error: "请先设置任务负责人,再绑定客户群" }, + { status: 400 }, + ); + } + const wecomConfig = resolveWecomConfig(env as unknown as WecomBindings); + const chats = await listOwnerCustomerChats(wecomConfig, ownerUserid); + return Response.json({ + ownerUserid, + chats, + boundChatId: String(task.customer_chat_id ?? ""), + }); + } else if (body.action === "task_bind_customer_chat") { + const taskId = String(body.taskId ?? "").trim(); + const forbidden = await taskOwnerGate(taskId); + if (forbidden) return forbidden; + const task = await db + .prepare( + "SELECT id, owner_userid, customer_chat_id FROM tasks WHERE id = ?", + ) + .bind(taskId) + .first<{ id: string; owner_userid: string; customer_chat_id: string }>(); + if (!task) return Response.json({ error: "任务不存在" }, { status: 404 }); + const ownerUserid = ( + scope.kind === "full" + ? String(task.owner_userid ?? "") + : scope.wecomUserId + ).trim(); + const chatId = String(body.chatId ?? "").trim(); + if (chatId && !ownerUserid) { + return Response.json( + { error: "请先设置任务负责人,再绑定客户群" }, + { status: 400 }, + ); + } + const wecomConfig = resolveWecomConfig(env as unknown as WecomBindings); + if (chatId) { + const chatName = String(body.chatName ?? "").trim(); + const result = await bindTaskCustomerChat( + db, + { taskId, chatId, chatName }, + wecomConfig, + ); + return Response.json({ task: result }); + } + await unbindTaskCustomerChat(db, taskId, wecomConfig); + return Response.json({ + task: { + customerChatId: "", + customerChatName: "", + joinConfigId: "", + joinQrCode: "", + }, }); } else if (body.action === "task_group_push") { const taskId = String(body.taskId ?? "").trim(); const task = await db .prepare( - "SELECT id, name, share_token FROM tasks WHERE id = ?", + "SELECT id, name, share_token, owner_userid FROM tasks WHERE id = ?", ) .bind(taskId) - .first<{ id: string; name: string; share_token: string | null }>(); + .first<{ + id: string; + name: string; + share_token: string | null; + owner_userid: string; + }>(); if (!task) return Response.json({ error: "任务不存在" }, { status: 404 }); + const forbidden = await taskOwnerGate(taskId); + if (forbidden) return forbidden; if (!task.share_token) { return Response.json({ error: "任务缺少领取链接" }, { status: 400 }); } - const owners = (await listGroupOwnerPartners(db)).filter( - (owner) => owner.wecom_userid, - ); - if (owners.length === 0) { + const ownerUserid = String(task.owner_userid ?? "").trim(); + if (!ownerUserid) { return Response.json( - { error: "没有可推送的内部群主,请先在后台登记群主 userid" }, + { error: "该任务未分配负责人,请先在任务中心分配" }, { status: 400 }, ); } + const owner = await findOrCreateGroupOwnerPartner(db, ownerUserid); const portalUrl = String(env.KOC_PORTAL_URL ?? "").replace(/\/$/, ""); const wecomConfig = resolveWecomConfig(env as unknown as WecomBindings); let sent = 0; let failed = 0; const failList: string[] = []; - for (const owner of owners) { - const link = `${portalUrl}/?task=${task.share_token}&owner=${owner.id}`; - try { - const result = await sendWecomAppMessage( - [owner.wecom_userid], - `【任务派发】${task.name}\n请把领取链接转发给你的 KOC(链接已绑定你的群主身份):\n${link}`, - wecomConfig, - ); - if (result.skipped) { - failed += 1; - failList.push(owner.name); - } else { - sent += result.sent; - failed += result.failed; - } - } catch { + const link = `${portalUrl}/?task=${task.share_token}&owner=${owner.id}`; + try { + const result = await sendWecomAppMessage( + [owner.wecom_userid], + `【任务派发】${task.name}\n请把领取链接转发给你的 KOC(链接已绑定你的群主身份):\n${link}`, + wecomConfig, + ); + if (result.skipped) { failed += 1; failList.push(owner.name); + } else { + sent += result.sent; + failed += result.failed; } + } catch { + failed += 1; + failList.push(owner.name); } return Response.json({ sent, failed, failList }); } else if (body.action === "wecom_group_chats") { @@ -908,7 +1135,10 @@ export async function POST(request: Request) { return Response.json({ error: "不支持的操作" }, { status: 400 }); } - return Response.json(await getDashboardData()); + const dashboard = await getDashboardData(ownerScopeUserid(scope)); + return Response.json( + ownerWarning ? { ...dashboard, ownerWarning } : dashboard, + ); } catch (error) { return Response.json( { error: error instanceof Error ? error.message : "操作失败" }, @@ -917,8 +1147,7 @@ export async function POST(request: Request) { error instanceof FeishuSourceError || error instanceof DistributionReleaseError || error instanceof WecomClientError || - error instanceof ProjectError || - error instanceof OneClickPushError + error instanceof ProjectError ? error.status : 500, }, diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts index ed1c176..eb1dc14 100644 --- a/app/api/auth/login/route.ts +++ b/app/api/auth/login/route.ts @@ -16,7 +16,7 @@ export async function POST(request: Request) { const password = String(body.password ?? ""); const user = await getRawDb() .prepare( - `SELECT id, username, role, password_hash, password_salt, password_iterations + `SELECT id, username, role, wecom_userid, password_hash, password_salt, password_iterations FROM users WHERE username = ? LIMIT 1`, ) .bind(username) @@ -24,6 +24,7 @@ export async function POST(request: Request) { id: string; username: string; role: string; + wecom_userid: string | null; password_hash: string; password_salt: string; password_iterations: number; @@ -34,7 +35,14 @@ export async function POST(request: Request) { const token = await createSession(user.id); const secure = requestUsesHttps(request); return Response.json( - { user: { id: user.id, username: user.username, role: user.role } }, + { + user: { + id: user.id, + username: user.username, + role: user.role, + wecom_userid: String(user.wecom_userid ?? ""), + }, + }, { headers: { "Set-Cookie": createSessionCookie(token, secure) } }, ); } catch (error) { diff --git a/app/api/bootstrap/route.ts b/app/api/bootstrap/route.ts index 4d92c1b..8aec982 100644 --- a/app/api/bootstrap/route.ts +++ b/app/api/bootstrap/route.ts @@ -15,6 +15,7 @@ import { seedIfEmpty, } from "../../../lib/mvp-db"; import { authForbidden, getRequestPrincipal } from "../../../lib/user-auth"; +import { getOwnerScope, ownerScopeUserid } from "../../../lib/owner-scope"; export async function GET(request: Request) { const principal = await getRequestPrincipal(request); @@ -56,12 +57,9 @@ export async function GET(request: Request) { accountBackfill, ]); runInBackground(catchup, "bootstrap catchup"); - const dashboard = await getDashboardData(); - return Response.json( - principal.kind === "user" && principal.user.role === "user" - ? { ...dashboard, accounts: [] } - : dashboard, - ); + const scope = await getOwnerScope(request); + const dashboard = await getDashboardData(ownerScopeUserid(scope)); + return Response.json(dashboard); } catch (error) { return Response.json( { error: error instanceof Error ? error.message : "加载失败" }, diff --git a/app/api/partner/route.ts b/app/api/partner/route.ts index 003d7bb..2841b47 100644 --- a/app/api/partner/route.ts +++ b/app/api/partner/route.ts @@ -346,6 +346,11 @@ async function handleGet(request: Request) { ) .bind(claimRow.id) .all(); + const joinQrRow = await getRawDb() + .prepare("SELECT join_qr_code FROM tasks WHERE id = ?") + .bind(task.id) + .first<{ join_qr_code: string | null }>(); + const joinQrCode = String(joinQrRow?.join_qr_code ?? "").trim(); claim = { id: claimRow.id, claimantName: claimRow.claimant_name, @@ -359,6 +364,7 @@ async function handleGet(request: Request) { video_assets: undefined, })), delegations: delegations.results, + joinQrCode, }; } else if (delegationAccess) { const assignments = await getRawDb() diff --git a/app/api/recovery-export/route.ts b/app/api/recovery-export/route.ts index 6c8cad5..05954ef 100644 --- a/app/api/recovery-export/route.ts +++ b/app/api/recovery-export/route.ts @@ -1,6 +1,7 @@ import { getRuntimeEnv } from "../../../lib/runtime-env"; import type { StoredObjectBody } from "../../../lib/object-store"; import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth"; +import { getOwnerScope, isTaskOwner, ownerForbidden } from "../../../lib/owner-scope"; import { parseStoredDate } from "../../../lib/date-utils"; import { downloadFeishuMedia, @@ -241,6 +242,15 @@ export async function GET(request: Request) { return Response.json({ error: "缺少导出任务" }, { status: 400 }); } const db = getRawDb(); + if (!tokenPayload) { + const scope = await getOwnerScope(request); + if ( + scope.kind === "owner" && + !(await isTaskOwner(db, taskId, scope.wecomUserId)) + ) { + return ownerForbidden(); + } + } const task = await db .prepare("SELECT id, name, brand, platform FROM tasks WHERE id = ?") .bind(taskId) diff --git a/app/api/screenshot-task-export/route.ts b/app/api/screenshot-task-export/route.ts index 55810ae..a6245e0 100644 --- a/app/api/screenshot-task-export/route.ts +++ b/app/api/screenshot-task-export/route.ts @@ -2,6 +2,7 @@ import { zipSync, strToU8 } from "fflate"; import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth"; import { ensureSchema, getRawDb, getUploadBucket } from "../../../lib/mvp-db"; import { parseResultScreenshotKeys } from "../../../lib/result-screenshots"; +import { getOwnerScope, isTaskOwner, ownerForbidden } from "../../../lib/owner-scope"; function safeName(value: string) { return value.replace(/[\\/:*?"<>|\r\n]/g, "_").trim().slice(0, 60) || "KOC"; @@ -20,6 +21,13 @@ export async function GET(request: Request) { if (!taskId) { return Response.json({ error: "缺少任务参数" }, { status: 400 }); } + const scope = await getOwnerScope(request); + if ( + scope.kind === "owner" && + !(await isTaskOwner(getRawDb(), taskId, scope.wecomUserId)) + ) { + return ownerForbidden(); + } const task = await getRawDb() .prepare("SELECT name, task_type FROM tasks WHERE id = ?") .bind(taskId) diff --git a/app/api/task-report-export/route.ts b/app/api/task-report-export/route.ts index 5279efb..015cb13 100644 --- a/app/api/task-report-export/route.ts +++ b/app/api/task-report-export/route.ts @@ -5,12 +5,16 @@ import { getRawDb, getTaskReportData, } from "../../../lib/mvp-db"; +import { getOwnerScope, ownerScopeUserid } from "../../../lib/owner-scope"; export async function GET(request: Request) { try { if (!(await isAdminRequest(request))) return adminForbidden(); await ensureSchema(); - const rows = await getTaskReportData(getRawDb()); + const rows = await getTaskReportData( + getRawDb(), + ownerScopeUserid(await getOwnerScope(request)), + ); const fileName = `任务复盘报表.csv`; return new Response(buildTaskReportCsv(rows), { headers: { diff --git a/app/api/task-result-image/route.ts b/app/api/task-result-image/route.ts index 2a05c73..9e12cac 100644 --- a/app/api/task-result-image/route.ts +++ b/app/api/task-result-image/route.ts @@ -1,6 +1,7 @@ import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth"; import { ensureSchema, getRawDb, getUploadBucket } from "../../../lib/mvp-db"; import { parseResultScreenshotKeys } from "../../../lib/result-screenshots"; +import { getOwnerScope, isDistributionOwner, ownerForbidden } from "../../../lib/owner-scope"; export async function GET(request: Request) { if (!(await isAdminRequest(request))) return adminForbidden(); @@ -9,6 +10,13 @@ export async function GET(request: Request) { const distributionId = new URL(request.url).searchParams .get("distribution") ?.trim(); + const scope = await getOwnerScope(request); + if ( + scope.kind === "owner" && + !(await isDistributionOwner(getRawDb(), distributionId ?? "", scope.wecomUserId)) + ) { + return ownerForbidden(); + } const imageIndex = Math.max( 1, Number(new URL(request.url).searchParams.get("index") || 1), diff --git a/app/api/users/route.ts b/app/api/users/route.ts index d8bf75f..7db188a 100644 --- a/app/api/users/route.ts +++ b/app/api/users/route.ts @@ -13,6 +13,7 @@ type UserRow = { id: string; username: string; role: UserRole; + wecom_userid: string | null; created_at: string; updated_at: string; }; @@ -28,7 +29,7 @@ async function manager(request: Request) { async function listUsers() { const result = await getRawDb() .prepare( - `SELECT id, username, role, created_at, updated_at + `SELECT id, username, role, wecom_userid, created_at, updated_at FROM users ORDER BY CASE role WHEN 'super_admin' THEN 1 WHEN 'admin' THEN 2 ELSE 3 END, created_at ASC`, @@ -54,6 +55,7 @@ export async function POST(request: Request) { password?: string; role?: UserRole; userId?: string; + wecomUserid?: string; }; const db = getRawDb(); @@ -65,6 +67,9 @@ export async function POST(request: Request) { const passwordRecord = await createPasswordRecord(password); const username = normalizeUsername(body.username); const role = body.role === "admin" ? "admin" : "user"; + const wecomUserid = String(body.wecomUserid ?? "") + .trim() + .slice(0, 128); if (!validateUsername(username)) { return Response.json( { error: "账号需为2—32位中文、字母、数字或 _ . @ + -" }, @@ -77,8 +82,8 @@ export async function POST(request: Request) { await db .prepare( `INSERT INTO users - (id, username, password_hash, password_salt, password_iterations, role) - VALUES (?, ?, ?, ?, ?, ?)`, + (id, username, password_hash, password_salt, password_iterations, role, wecom_userid) + VALUES (?, ?, ?, ?, ?, ?, ?)`, ) .bind( crypto.randomUUID(), @@ -87,8 +92,36 @@ export async function POST(request: Request) { passwordRecord.passwordSalt, passwordRecord.passwordIterations, role, + wecomUserid, ) .run(); + } else if (body.action === "set_wecom") { + const wecomUserid = String(body.wecomUserid ?? "") + .trim() + .slice(0, 128); + const target = await db + .prepare("SELECT id, role FROM users WHERE id = ?") + .bind(String(body.userId ?? "")) + .first<{ id: string; role: UserRole }>(); + if (!target) { + return Response.json({ error: "没有找到这个账号" }, { status: 404 }); + } + if (target.role === "super_admin") { + return Response.json( + { error: "不能修改超级管理员账号" }, + { status: 403 }, + ); + } + if (currentUser.role !== "super_admin" && target.role !== "user") { + return managerForbidden(); + } + await db + .prepare( + `UPDATE users SET wecom_userid = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ?`, + ) + .bind(wecomUserid, target.id) + .run(); } else if (body.action === "reset_password") { const password = String(body.password ?? ""); if (!validatePassword(password)) { diff --git a/app/globals.css b/app/globals.css index 43e6604..436b172 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1550,6 +1550,30 @@ a { border-radius: 12px; } +.join-qr-preview { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + margin-top: 12px; + padding: 12px; + border: 1px solid rgb(20 40 34 / 0.12); + border-radius: 12px; + background: white; +} + +.join-qr-preview img { + width: 180px; + height: 180px; + object-fit: contain; + border-radius: 8px; +} + +.join-qr-preview span { + color: rgb(20 40 34 / 0.55); + font-size: 11px; +} + .wecom-picker-row { display: grid; grid-template-columns: 40px 1fr auto; @@ -4397,12 +4421,27 @@ label small { .user-table-head, .user-table-row { display: grid; - grid-template-columns: minmax(160px, 1.4fr) minmax(100px, 0.7fr) minmax(110px, 0.75fr) minmax(180px, auto); + grid-template-columns: minmax(140px, 1.2fr) minmax(90px, 0.6fr) minmax(150px, 1.2fr) minmax(100px, 0.7fr) minmax(170px, auto); gap: 12px; align-items: center; padding: 14px 16px; } +.user-wecom-cell { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + +.user-wecom-cell code { + font-size: 10px; + color: #3f6f5f; + background: #eef4f0; + border-radius: 6px; + padding: 3px 6px; +} + .user-table-head { color: #899490; background: #f5f7f5; diff --git a/app/task-customer-chat-modal.tsx b/app/task-customer-chat-modal.tsx new file mode 100644 index 0000000..5b22e6b --- /dev/null +++ b/app/task-customer-chat-modal.tsx @@ -0,0 +1,218 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { readApiResponse } from "./wecom-user-picker"; + +type CustomerChat = { + chatId: string; + name: string; + ownerUserId: string; + memberCount: number; + status: number; +}; + +type BindResult = { + customerChatId: string; + customerChatName: string; + joinConfigId: string; + joinQrCode: string; + qrError?: string; +}; + +export function TaskCustomerChatModal({ + task, + onClose, + onBound, +}: { + task: { id: string; name: string; customerChatId: string; customerChatName: string }; + onClose: () => void; + onBound: () => void; +}) { + const [chats, setChats] = useState([]); + const [boundChatId, setBoundChatId] = useState(task.customerChatId); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + const [notice, setNotice] = useState(""); + const [selectedChatId, setSelectedChatId] = useState(task.customerChatId); + const [bindResult, setBindResult] = useState(null); + + const loadChats = useCallback(async () => { + setLoading(true); + setError(""); + try { + const response = await fetch("/api/action", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "task_customer_chats", taskId: task.id }), + }); + const parsed = await readApiResponse<{ + chats?: CustomerChat[]; + boundChatId?: string; + error?: string; + }>(response, "获取客户群列表失败"); + if (!response.ok) { + throw new Error(parsed.error || "获取客户群列表失败"); + } + setChats(Array.isArray(parsed.chats) ? parsed.chats : []); + const bound = String(parsed.boundChatId ?? ""); + setBoundChatId(bound); + setSelectedChatId(bound); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "获取客户群列表失败"); + } finally { + setLoading(false); + } + }, [task.id]); + + useEffect(() => { + const timer = window.setTimeout(() => { + void loadChats(); + }, 0); + return () => window.clearTimeout(timer); + }, [loadChats]); + + const submitBind = async (chatId: string) => { + const chat = chats.find((item) => item.chatId === chatId); + setSaving(true); + setError(""); + setNotice(""); + try { + const response = await fetch("/api/action", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "task_bind_customer_chat", + taskId: task.id, + chatId, + chatName: chat?.name ?? "", + }), + }); + const parsed = await readApiResponse<{ task?: BindResult; error?: string }>( + response, + "绑定客户群失败", + ); + if (!response.ok) { + throw new Error(parsed.error || "绑定客户群失败"); + } + const result = parsed.task ?? null; + setBindResult(result); + setBoundChatId(chatId); + setSelectedChatId(chatId); + setNotice( + chatId + ? result?.qrError + ? `已绑定,但${result.qrError},可重新保存刷新` + : `已绑定「${result?.customerChatName || chat?.name || chatId}」,认领人扫码即可进群` + : "已解绑,认领页不再展示进群二维码", + ); + onBound(); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "绑定客户群失败"); + } finally { + setSaving(false); + } + }; + + const unbind = async () => { + if (!window.confirm("解绑后认领页将不再展示进群二维码,确定解绑?")) return; + await submitBind(""); + }; + + return ( +
+
event.stopPropagation()} + > +
+
+

绑定客户群

+

+ 列出任务负责人名下的企业微信客户群,绑定后生成进群活码,认领人在领取页扫码进群。 +

+
+ +
+
+ + 任务:{task.name} + {boundChatId ? ` · 当前绑定:${task.customerChatName || boundChatId}` : " · 未绑定"} + + +
+ {error &&

{error}

} + {notice &&

{notice}

} + {bindResult?.joinQrCode && ( +
+ 进群活码 + 进群活码(已同步展示在认领页) +
+ )} +
+ {loading ? ( +
正在拉取负责人名下客户群…
+ ) : chats.length === 0 ? ( +
+ 未拉取到客户群——请任务负责人先在企微客户端创建客户群(内外部成员都可拉入), + 再点击「刷新列表」。 +
+ ) : ( + chats.map((chat) => { + const isBound = chat.chatId === boundChatId; + return ( + + ); + }) + )} +
+
+ {boundChatId ? ( + + ) : null} + + +
+
+
+ ); +} diff --git a/app/users-page.tsx b/app/users-page.tsx index 5417653..e05ac12 100644 --- a/app/users-page.tsx +++ b/app/users-page.tsx @@ -2,11 +2,13 @@ import { FormEvent, useCallback, useEffect, useState } from "react"; import type { AuthUser, UserRole } from "../lib/user-auth"; +import { WecomUserPickerModal } from "./wecom-user-picker"; type ManagedUser = { id: string; username: string; role: UserRole; + wecom_userid: string | null; created_at: string; updated_at: string; }; @@ -31,6 +33,9 @@ export default function UsersPage({ currentUser }: { currentUser: AuthUser }) { const [resetTarget, setResetTarget] = useState(null); const [resetPassword, setResetPassword] = useState(""); const [deleteTarget, setDeleteTarget] = useState(null); + const [createWecom, setCreateWecom] = useState(""); + const [createPickerOpen, setCreatePickerOpen] = useState(false); + const [bindTarget, setBindTarget] = useState(null); const [message, setMessage] = useState(""); const [working, setWorking] = useState(false); @@ -58,13 +63,20 @@ export default function UsersPage({ currentUser }: { currentUser: AuthUser }) { await fetch("/api/users", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action: "create", username, password, role }), + body: JSON.stringify({ + action: "create", + username, + password, + role, + wecomUserid: createWecom, + }), }), ); setUsers(next); setUsername(""); setPassword(""); setRole("user"); + setCreateWecom(""); setMessage("账号已创建,可以把账号和密码交给用户登录"); } catch (reason) { setMessage(reason instanceof Error ? reason.message : "创建失败"); @@ -126,6 +138,27 @@ export default function UsersPage({ currentUser }: { currentUser: AuthUser }) { } }; + const bindWecom = async (userId: string, wecomUserid: string) => { + setWorking(true); + setMessage(""); + try { + const next = await userApi( + await fetch("/api/users", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "set_wecom", userId, wecomUserid }), + }), + ); + setUsers(next); + setBindTarget(null); + setMessage(wecomUserid ? "企微绑定已更新,立即生效" : "已解除企微绑定"); + } catch (reason) { + setMessage(reason instanceof Error ? reason.message : "绑定失败"); + } finally { + setWorking(false); + } + }; + const canReset = (item: ManagedUser) => { if (currentUser.role === "super_admin") { return item.role !== "super_admin" || item.id === currentUser.id; @@ -133,6 +166,11 @@ export default function UsersPage({ currentUser }: { currentUser: AuthUser }) { return item.role === "user"; }; + const canBind = (item: ManagedUser) => { + if (item.role === "super_admin") return false; + return currentUser.role === "super_admin" || item.role === "user"; + }; + const canDelete = (item: ManagedUser) => { if (item.id === currentUser.id || item.role === "super_admin") return false; return currentUser.role === "super_admin" || item.role === "user"; @@ -182,6 +220,22 @@ export default function UsersPage({ currentUser }: { currentUser: AuthUser }) { )} + @@ -198,12 +252,45 @@ export default function UsersPage({ currentUser }: { currentUser: AuthUser }) {
- 登录账号角色更新时间操作 + 登录账号角色企微绑定更新时间操作
{users.map((item) => (
{item.username} {ROLE_LABEL[item.role]} + + {item.wecom_userid ? ( + {item.wecom_userid} + ) : ( + — + )} + {canBind(item) && ( + item.wecom_userid ? ( + <> + + + + ) : ( + + ) + )} + {new Date(item.updated_at).toLocaleDateString("zh-CN", { timeZone: "Asia/Shanghai" })}
{canReset(item) && ( @@ -307,6 +394,28 @@ export default function UsersPage({ currentUser }: { currentUser: AuthUser }) {
)} + {createPickerOpen && ( + setCreatePickerOpen(false)} + onConfirm={(userids) => { + setCreateWecom(userids[0] ?? ""); + setCreatePickerOpen(false); + }} + /> + )} + + {bindTarget && ( + setBindTarget(null)} + onConfirm={(userids) => { + void bindWecom(bindTarget.id, userids[0] ?? ""); + }} + /> + )}
); } diff --git a/app/wecom-user-picker.tsx b/app/wecom-user-picker.tsx new file mode 100644 index 0000000..f0dc4e2 --- /dev/null +++ b/app/wecom-user-picker.tsx @@ -0,0 +1,239 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; + +export async function readApiResponse( + response: Response, + fallback: string, +) { + const text = await response.text(); + const contentType = response.headers.get("content-type") ?? ""; + if (!contentType.includes("json")) { + if (response.status === 403) { + throw new Error("请求被安全网关拦截,请刷新页面后重试"); + } + throw new Error(`${fallback}(服务返回异常,HTTP ${response.status})`); + } + try { + return JSON.parse(text) as T & { error?: string }; + } catch { + throw new Error(`${fallback}(服务返回的数据无法解析)`); + } +} + +export type InternalWecomContact = { + userid: string; + name: string; + departmentNames: string[]; +}; + +let internalWecomContactsCache: { + contacts: InternalWecomContact[]; + fetchedAt: number; +} | null = null; + +const INTERNAL_WECOM_CONTACTS_TTL_MS = 5 * 60 * 1000; + +export function WecomUserPickerModal({ + mode, + selected, + onClose, + onConfirm, +}: { + mode: "single" | "multi"; + selected: string[]; + onClose: () => void; + onConfirm: (userids: string[]) => void; +}) { + const [contacts, setContacts] = useState( + internalWecomContactsCache?.contacts ?? [], + ); + const [fetchedAt, setFetchedAt] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [search, setSearch] = useState(""); + const [checked, setChecked] = useState(selected.filter(Boolean)); + + const loadContacts = useCallback(async (force = false) => { + const snapshot = internalWecomContactsCache; + if ( + !force && + snapshot && + Date.now() - snapshot.fetchedAt <= INTERNAL_WECOM_CONTACTS_TTL_MS + ) { + setContacts(snapshot.contacts); + setFetchedAt(snapshot.fetchedAt); + setLoading(false); + return; + } + setLoading(true); + setError(""); + try { + const response = await fetch("/api/action", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "wecom_list_internal_contacts" }), + }); + const parsed = await readApiResponse<{ + contacts?: InternalWecomContact[]; + error?: string; + }>(response, "获取企业微信通讯录失败"); + if (!response.ok) { + throw new Error(parsed.error || "获取企业微信通讯录失败"); + } + const list = Array.isArray(parsed.contacts) ? parsed.contacts : []; + internalWecomContactsCache = { contacts: list, fetchedAt: Date.now() }; + setContacts(list); + setFetchedAt(internalWecomContactsCache.fetchedAt); + } catch (reason) { + setError( + reason instanceof Error ? reason.message : "获取企业微信通讯录失败", + ); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + const timer = window.setTimeout(() => { + void loadContacts(); + }, 0); + return () => window.clearTimeout(timer); + }, [loadContacts]); + + const keyword = search.trim().toLowerCase(); + const filteredContacts = + keyword.length === 0 + ? contacts + : contacts.filter((contact) => + `${contact.name} ${contact.userid}`.toLowerCase().includes(keyword), + ); + + const toggleChecked = (userid: string) => { + setChecked((previous) => + previous.includes(userid) + ? previous.filter((item) => item !== userid) + : [...previous, userid], + ); + }; + + return ( +
+
event.stopPropagation()} + > +
+
+

选择企业微信成员

+

+ 从企业微信通讯录(自建应用可见范围)中挑选, + {mode === "multi" ? "勾选后点击确定" : "点击成员即填入"}。 +

+
+ +
+
+ setSearch(event.target.value)} + disabled={loading} + /> + + {fetchedAt > 0 && !loading && ( + + 缓存于 {new Date(fetchedAt).toLocaleTimeString()}(5 分钟内复用) + + )} +
+ {error && ( +

+ {error} 可关闭此弹窗,直接在输入框手动填写 userid。 +

+ )} +
+ {loading && contacts.length === 0 ? ( +
正在拉取企业微信通讯录…
+ ) : contacts.length === 0 ? ( +
+ {error + ? "拉取失败,请检查应用配置(通讯录权限 / 可信 IP)后重试" + : "未拉取到通讯录成员——仅显示自建应用可见范围内的成员,也可手动填写 userid"} +
+ ) : filteredContacts.length === 0 ? ( +
没有匹配的成员
+ ) : ( + filteredContacts.map((contact) => { + const isSelected = + mode === "multi" + ? checked.includes(contact.userid) + : selected.includes(contact.userid); + const rowBody = ( + <> + + + + {contact.name || contact.userid} + + + {contact.departmentNames.join(" / ") || "—"} + + {contact.userid} + + + ); + return mode === "multi" ? ( + + ) : ( + + ); + }) + )} +
+ {mode === "multi" && ( +
+ + +
+ )} +
+
+ ); +} diff --git a/db/schema.ts b/db/schema.ts index 93352f5..136ef5b 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -59,6 +59,17 @@ export const tasks = mysqlTable( }), projectId: varchar("project_id", { length: 64 }).notNull().default(""), taskChatId: varchar("task_chat_id", { length: 64 }).notNull().default(""), + ownerUserid: varchar("owner_userid", { length: 64 }).notNull().default(""), + customerChatId: varchar("customer_chat_id", { length: 128 }) + .notNull() + .default(""), + customerChatName: varchar("customer_chat_name", { length: 255 }) + .notNull() + .default(""), + joinConfigId: varchar("join_config_id", { length: 128 }) + .notNull() + .default(""), + joinQrCode: text("join_qr_code").notNull().default(""), createdAt: timestamp("created_at"), }, (table) => [uniqueIndex("tasks_share_token_idx").on(table.shareToken)], @@ -230,6 +241,7 @@ export const users = mysqlTable( passwordSalt: varchar("password_salt", { length: 255 }).notNull(), passwordIterations: int("password_iterations").notNull(), role: varchar("role", { length: 32 }).notNull(), + wecomUserid: varchar("wecom_userid", { length: 128 }).notNull().default(""), createdAt: timestamp("created_at"), updatedAt: timestamp("updated_at"), }, @@ -315,26 +327,12 @@ export const projects = mysqlTable("projects", { brand: varchar("brand", { length: 255 }).notNull().default(""), mainGroupMemberIds: text("main_group_member_ids").notNull(), ownerUserid: varchar("owner_userid", { length: 64 }).notNull().default(""), + mainChatId: varchar("main_chat_id", { length: 64 }).notNull().default(""), status: varchar("status", { length: 32 }).notNull().default("active"), note: text("note").notNull(), createdAt: timestamp("created_at"), }); -export const projectGroupChats = mysqlTable( - "project_group_chats", - { - id: varchar("id", { length: 64 }).primaryKey(), - projectId: varchar("project_id", { length: 64 }).notNull(), - chatId: varchar("chat_id", { length: 128 }).notNull(), - partnerId: varchar("partner_id", { length: 64 }).notNull().default(""), - createdAt: timestamp("created_at"), - }, - (table) => [ - uniqueIndex("project_group_chats_uniq").on(table.projectId, table.chatId), - index("project_group_chats_project_idx").on(table.projectId), - ], -); - export const backgroundJobs = mysqlTable( "background_jobs", { diff --git a/koc-portal/app/globals.css b/koc-portal/app/globals.css index 95ce82c..65dc4e4 100644 --- a/koc-portal/app/globals.css +++ b/koc-portal/app/globals.css @@ -718,6 +718,48 @@ footer { color: #74cfa9; } +.join-qr-card { + margin-top: 14px; + padding: 18px 20px; + border-radius: 18px; + background: white; +} + +.join-qr-body { + display: flex; + align-items: center; + gap: 18px; +} + +.join-qr-body img { + width: 132px; + height: 132px; + border-radius: 12px; + border: 1px solid rgb(20 40 34 / 0.1); + background: white; + flex: none; +} + +.join-qr-body h2 { + margin: 0; + font-size: 17px; + color: #17241f; +} + +.join-qr-body p { + margin: 8px 0 0; + font-size: 12px; + color: rgb(23 36 31 / 0.6); + line-height: 1.6; +} + +@media (max-width: 560px) { + .join-qr-body { + flex-direction: column; + text-align: center; + } +} + .completion-ring { display: grid; width: 90px; diff --git a/koc-portal/app/page.tsx b/koc-portal/app/page.tsx index e5d8048..8b45140 100644 --- a/koc-portal/app/page.tsx +++ b/koc-portal/app/page.tsx @@ -82,6 +82,7 @@ type TaskPayload = { createdAt: string; assignments: Assignment[]; delegations: DelegationSummary[]; + joinQrCode?: string; }; delegation: null | { id: string; @@ -1961,6 +1962,17 @@ export default function Home() { 已回填 + {isClaimOwner && payload.claim?.joinQrCode ? ( +
+
+ 扫码进任务群二维码 +
+

扫码加入任务群

+

用微信扫描二维码进入本任务沟通群,进群后按群内指引发布内容。

+
+
+
+ ) : null}
diff --git a/lib/mvp-db.ts b/lib/mvp-db.ts index bc5d98e..ab37362 100644 --- a/lib/mvp-db.ts +++ b/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[], + 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(); + 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(); return result.results; } diff --git a/lib/owner-scope.ts b/lib/owner-scope.ts new file mode 100644 index 0000000..4760461 --- /dev/null +++ b/lib/owner-scope.ts @@ -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 { + 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 }); +} diff --git a/lib/project-push-service.ts b/lib/project-push-service.ts index ce3c3f5..c2b6119 100644 --- a/lib/project-push-service.ts +++ b/lib/project-push-service.ts @@ -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 = { + 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, }; diff --git a/lib/project-service.ts b/lib/project-service.ts index f221b66..5bd0565 100644 --- a/lib/project-service.ts +++ b/lib/project-service.ts @@ -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 { + 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(); + 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 { 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(); + .all(); return result.results.map((row) => ({ id: row.id, name: row.name, @@ -113,8 +242,8 @@ export async function listProjects(db: DatabaseClient): Promise(); - 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 { + 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 { - const id = text(projectId); - await getProjectDetail(db, id); - const seen = new Set(); - 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 { - 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 { + 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(); + if (existing) return existing; + return saveGroupOwnerPartner(db, { name: userid, wecomUserid: userid }); +} diff --git a/lib/task-customer-chat-service.ts b/lib/task-customer-chat-service.ts new file mode 100644 index 0000000..b7d66b9 --- /dev/null +++ b/lib/task-customer-chat-service.ts @@ -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 { + 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 { + 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 { + 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(); +} diff --git a/lib/task-group-service.ts b/lib/task-group-service.ts index 956e349..f762db7 100644 --- a/lib/task-group-service.ts +++ b/lib/task-group-service.ts @@ -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(); 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 { + 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, diff --git a/lib/task-one-click-push-service.ts b/lib/task-one-click-push-service.ts deleted file mode 100644 index eac4513..0000000 --- a/lib/task-one-click-push-service.ts +++ /dev/null @@ -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 { - 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 { - 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(); - return result ?? null; -} - -export async function listTaskPushLogs( - db: DatabaseClient, - taskId: string, -): Promise { - 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(); - 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 { - 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(); - 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, - }; -} diff --git a/lib/task-service.ts b/lib/task-service.ts index 3f66a95..a837f92 100644 --- a/lib/task-service.ts +++ b/lib/task-service.ts @@ -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 { + 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(); + 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 领取站点地址尚未配置"); diff --git a/lib/user-auth.ts b/lib/user-auth.ts index e1fb4a8..47a11e3 100644 --- a/lib/user-auth.ts +++ b/lib/user-auth.ts @@ -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 ? 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) { diff --git a/lib/wecom-callback-events.ts b/lib/wecom-callback-events.ts index 968dca9..d54c11c 100644 --- a/lib/wecom-callback-events.ts +++ b/lib/wecom-callback-events.ts @@ -1,5 +1,4 @@ import type { DatabaseClient } from "./database"; -import { recordCardAck } from "./task-one-click-push-service"; export type WecomCallbackFields = Record; @@ -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, diff --git a/lib/wecom-client.ts b/lib/wecom-client.ts index 930f4b1..187b12c 100644 --- a/lib/wecom-client.ts +++ b/lib/wecom-client.ts @@ -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 { 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, errorLabel: string, fetchImpl: FetchLike, + allowErrcodes: number[] = [], ): Promise> { 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 { + 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 { + 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 { + 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, diff --git a/lib/wecom-notifier-service.ts b/lib/wecom-notifier-service.ts index a7e533a..7d5f1df 100644 --- a/lib/wecom-notifier-service.ts +++ b/lib/wecom-notifier-service.ts @@ -46,14 +46,14 @@ export async function runDueSoonWecomNotifications( config: WecomConfig, fetchImpl: FetchLike = fetch, now: number = Date.now(), + taskId?: string, ): Promise { 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(); + .bind(...(taskFilter ? [cutoff, taskFilter] : [cutoff])); + const result = await statement.all(); 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 { diff --git a/mysql/0013_project_main_chat_task_owner.sql b/mysql/0013_project_main_chat_task_owner.sql new file mode 100644 index 0000000..1553172 --- /dev/null +++ b/mysql/0013_project_main_chat_task_owner.sql @@ -0,0 +1,5 @@ +ALTER TABLE `projects` ADD COLUMN `main_chat_id` VARCHAR(64) NOT NULL DEFAULT '' AFTER `owner_userid`; +-- statement-breakpoint +ALTER TABLE `tasks` ADD COLUMN `owner_userid` VARCHAR(64) NOT NULL DEFAULT '' AFTER `task_chat_id`; +-- statement-breakpoint +DROP TABLE IF EXISTS `project_group_chats`; diff --git a/mysql/0014_users_wecom_userid.sql b/mysql/0014_users_wecom_userid.sql new file mode 100644 index 0000000..a342c7a --- /dev/null +++ b/mysql/0014_users_wecom_userid.sql @@ -0,0 +1 @@ +ALTER TABLE `users` ADD COLUMN `wecom_userid` VARCHAR(128) NOT NULL DEFAULT '' AFTER `role`; diff --git a/mysql/0015_task_customer_chat.sql b/mysql/0015_task_customer_chat.sql new file mode 100644 index 0000000..0c2555a --- /dev/null +++ b/mysql/0015_task_customer_chat.sql @@ -0,0 +1,7 @@ +ALTER TABLE `tasks` ADD COLUMN `customer_chat_id` VARCHAR(128) NOT NULL DEFAULT '' AFTER `owner_userid`; +-- statement-breakpoint +ALTER TABLE `tasks` ADD COLUMN `customer_chat_name` VARCHAR(255) NOT NULL DEFAULT '' AFTER `customer_chat_id`; +-- statement-breakpoint +ALTER TABLE `tasks` ADD COLUMN `join_config_id` VARCHAR(128) NOT NULL DEFAULT '' AFTER `customer_chat_name`; +-- statement-breakpoint +ALTER TABLE `tasks` ADD COLUMN `join_qr_code` TEXT NOT NULL AFTER `join_config_id`; diff --git a/public/kocdev@43.139.210.125 b/public/kocdev@43.139.210.125 new file mode 100644 index 0000000..b020d81 --- /dev/null +++ b/public/kocdev@43.139.210.125 @@ -0,0 +1 @@ +9mrNk9UbWcjjhPZc \ No newline at end of file diff --git a/tests/owner-permissions.test.mjs b/tests/owner-permissions.test.mjs new file mode 100644 index 0000000..8b8f500 --- /dev/null +++ b/tests/owner-permissions.test.mjs @@ -0,0 +1,204 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { readFileSync } from "node:fs"; + +const actionRoute = readFileSync( + new URL("../app/api/action/route.ts", import.meta.url), + "utf8", +); +const bootstrapRoute = readFileSync( + new URL("../app/api/bootstrap/route.ts", import.meta.url), + "utf8", +); +const taskReportExportRoute = readFileSync( + new URL("../app/api/task-report-export/route.ts", import.meta.url), + "utf8", +); +const recoveryExportRoute = readFileSync( + new URL("../app/api/recovery-export/route.ts", import.meta.url), + "utf8", +); +const screenshotTaskExportRoute = readFileSync( + new URL("../app/api/screenshot-task-export/route.ts", import.meta.url), + "utf8", +); +const taskResultImageRoute = readFileSync( + new URL("../app/api/task-result-image/route.ts", import.meta.url), + "utf8", +); +const usersRoute = readFileSync( + new URL("../app/api/users/route.ts", import.meta.url), + "utf8", +); +const mvpDb = readFileSync( + new URL("../lib/mvp-db.ts", import.meta.url), + "utf8", +); + +function section(source, action) { + const start = source.indexOf(`"${action}"`); + assert.ok(start >= 0, `action ${action} 不存在于路由源码`); + const next = source.indexOf("} else if (body.action ===", start); + return source.slice(start, next === -1 ? undefined : next); +} + +test("manager-only actions gate on owner scope", () => { + const managerOnly = [ + "inspect_feishu", + "create_task", + "create_screenshot_task", + "claim", + "save_collection_schedule", + "run_due_collections", + "retry_failed_collections", + "backfill_account_profiles", + "set_public_account_ids", + "bind_wecom_external_id", + "send_test_wecom", + "trigger_wecom_overdue_escalation", + "wecom_list_external_contacts", + "wecom_list_internal_contacts", + "wecom_sync_group_chats", + "project_list", + "project_detail", + "project_save", + "project_delete", + "project_ensure_main_chat", + "task_set_owner", + "task_update", + "partner_save", + "partner_group_owners", + "project_push_task", + "refresh_account_fields", + "account_field_refresh_status", + ]; + for (const action of managerOnly) { + const body = section(actionRoute, action); + assert.match( + body, + /managerGate\(\)/, + `${action} 必须 managerGate() 拒绝任务负责人`, + ); + } +}); + +test("collect actions stay manager-only", () => { + const body = section(actionRoute, "collect_now"); + assert.match(body, /managerGate\(\)/); +}); + +test("task-scoped actions verify ownership for task owners", () => { + for (const action of [ + "wecom_push_task_to_groups", + "task_group_links", + "task_group_push", + "task_customer_chats", + "task_bind_customer_chat", + ]) { + const body = section(actionRoute, action); + assert.match( + body, + /taskOwnerGate\(/, + `${action} 必须校验任务归属`, + ); + } + const dueSoon = section(actionRoute, "trigger_wecom_due_soon"); + assert.match(dueSoon, /taskOwnerGate\(/); + assert.match(dueSoon, /runDueSoonWecomNotifications\(\s*db,\s*wecomConfig,\s*undefined,\s*undefined,\s*taskId \|\| undefined,/); + + const pushes = section(actionRoute, "wecom_task_group_pushes"); + assert.match(pushes, /taskOwnerGate\(/); + + const manual = section(actionRoute, "manual_metrics"); + assert.match(manual, /distributionOwnerGate\(/); +}); + +test("task_report_list scopes reports by owner and drops the broken gate", () => { + const body = section(actionRoute, "task_report_list"); + assert.match(body, /getTaskReportData\(db, ownerScopeUserid\(scope\)\)/); + assert.ok( + !/isManagerRequest/.test(body), + "task_report_list 不再是管理员专属(负责人按 scope 过滤)", + ); +}); + +test("no unawaited isManagerRequest gates remain", () => { + assert.ok( + !actionRoute.includes("if (!isManagerRequest(request))"), + "isManagerRequest 返回 Promise,缺 await 的门形同虚设", + ); +}); + +test("action fallthrough dashboard is owner-scoped", () => { + assert.match( + actionRoute, + /getDashboardData\(ownerScopeUserid\(scope\)\)/, + ); +}); + +test("customer chat actions require a task owner before binding", () => { + for (const action of ["task_customer_chats", "task_bind_customer_chat"]) { + const body = section(actionRoute, action); + assert.match( + body, + /请先设置任务负责人/, + `${action} 必须在无负责人时明确报错`, + ); + assert.match(body, /Response\.json\(/); + assert.ok( + !/getDashboardData/.test(body), + `${action} 显式返回,不能落兜底 dashboard`, + ); + } + const bind = section(actionRoute, "task_bind_customer_chat"); + assert.match(bind, /bindTaskCustomerChat\(/); + assert.match(bind, /unbindTaskCustomerChat\(/); +}); + +test("partner claim detail exposes join qr only for claimed tasks", () => { + const partnerRoute = readFileSync( + new URL("../app/api/partner/route.ts", import.meta.url), + "utf8", + ); + assert.match(partnerRoute, /join_qr_code FROM tasks/); + assert.match(partnerRoute, /joinQrCode,/); + const portalPage = readFileSync( + new URL("../koc-portal/app/page.tsx", import.meta.url), + "utf8", + ); + assert.match(portalPage, /joinQrCode\?: string/); + assert.match(portalPage, /join-qr-card/); + assert.match(portalPage, /isClaimOwner && payload\.claim\?\.joinQrCode/); +}); + +test("bootstrap scopes dashboard data by owner scope", () => { + assert.match(bootstrapRoute, /getOwnerScope\(request\)/); + assert.match(bootstrapRoute, /getDashboardData\(ownerScopeUserid\(scope\)\)/); +}); + +test("getDashboardData filters tasks and distributions by owner", () => { + const dashboard = mvpDb.slice( + mvpDb.indexOf("export async function getDashboardData"), + mvpDb.indexOf("export type TaskReportRow"), + ); + const occurrences = dashboard.match(/WHERE t\.owner_userid = \?/g) ?? []; + assert.equal( + occurrences.length, + 2, + "tasks 与 distributions 两条查询都要按 owner 过滤", + ); +}); + +test("export routes enforce owner scope", () => { + assert.match(taskReportExportRoute, /getOwnerScope\(request\)/); + assert.match(taskReportExportRoute, /ownerScopeUserid\(await getOwnerScope\(request\)\)/); + assert.match(recoveryExportRoute, /isTaskOwner\(/); + assert.match(screenshotTaskExportRoute, /isTaskOwner\(/); + assert.match(taskResultImageRoute, /isDistributionOwner\(/); +}); + +test("users route supports wecom binding lifecycle", () => { + assert.match(usersRoute, /action === "set_wecom"/); + assert.match(usersRoute, /INSERT INTO users[\s\S]*?wecom_userid/); + assert.match(usersRoute, /SELECT id, username, role, wecom_userid, created_at/); +}); diff --git a/tests/owner-scope.test.mjs b/tests/owner-scope.test.mjs new file mode 100644 index 0000000..0f3d3cc --- /dev/null +++ b/tests/owner-scope.test.mjs @@ -0,0 +1,108 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + isDistributionOwner, + isTaskOwner, + ownerScopeUserid, +} from "../lib/owner-scope.ts"; +import { getTaskReportData } from "../lib/mvp-db.ts"; + +function captureDb(rowsByPattern) { + const captured = []; + const makeStatement = (sql, values) => ({ + async first() { + captured.push({ sql, values }); + for (const [pattern, rows] of rowsByPattern) { + if (pattern.test(sql)) return rows(values) ?? null; + } + return null; + }, + async all() { + captured.push({ sql, values }); + for (const [pattern, rows] of rowsByPattern) { + if (pattern.test(sql)) return { results: rows(values) ?? [] }; + } + return { results: [] }; + }, + }); + const db = { + prepare(sql) { + const unbound = makeStatement(sql, []); + return { + bind(...values) { + return makeStatement(sql, values); + }, + first: unbound.first, + all: unbound.all, + }; + }, + }; + return { db, captured }; +} + +test("isTaskOwner matches task owner_userid binding", async () => { + const { db, captured } = captureDb([ + [ + /FROM tasks WHERE id = \? AND owner_userid = \?/, + (values) => + values[0] === "task-1" && values[1] === "zhangsan" + ? { id: "task-1" } + : null, + ], + ]); + assert.equal(await isTaskOwner(db, "task-1", "zhangsan"), true); + assert.equal(await isTaskOwner(db, "task-2", "zhangsan"), false); + assert.equal(captured.length, 2); +}); + +test("isTaskOwner guards empty taskId / wecomUserId without querying", async () => { + const { db, captured } = captureDb([]); + assert.equal(await isTaskOwner(db, "task-1", ""), false); + assert.equal(await isTaskOwner(db, "", "zhangsan"), false); + assert.equal(await isTaskOwner(db, " ", "zhangsan"), false); + assert.equal(captured.length, 0); +}); + +test("isDistributionOwner resolves distribution → task owner", async () => { + const { db } = captureDb([ + [ + /FROM distributions d\s+JOIN tasks t ON t\.id = d\.task_id/, + (values) => + values[0] === "dist-1" && values[1] === "zhangsan" + ? { id: "dist-1" } + : null, + ], + ]); + assert.equal(await isDistributionOwner(db, "dist-1", "zhangsan"), true); + assert.equal(await isDistributionOwner(db, "dist-9", "zhangsan"), false); + assert.equal(await isDistributionOwner(db, "dist-1", ""), false); +}); + +test("ownerScopeUserid maps scope kinds to filter value", () => { + assert.equal(ownerScopeUserid({ kind: "full" }), undefined); + assert.equal(ownerScopeUserid({ kind: "owner", wecomUserId: "zhangsan" }), "zhangsan"); + assert.equal(ownerScopeUserid({ kind: "owner", wecomUserId: "" }), ""); +}); + +test("getTaskReportData filters by owner when ownerUserid given", async () => { + const { db, captured } = captureDb([[/FROM tasks t/, () => []]]); + await getTaskReportData(db, "zhangsan"); + assert.equal(captured.length, 1); + assert.match(captured[0].sql, /WHERE t\.owner_userid = \?/); + assert.deepEqual(captured[0].values, ["zhangsan"]); +}); + +test("getTaskReportData keeps global aggregation without ownerUserid", async () => { + const { db, captured } = captureDb([[/FROM tasks t/, () => []]]); + await getTaskReportData(db); + assert.equal(captured.length, 1); + assert.ok(!/owner_userid = \?/.test(captured[0].sql)); + assert.ok(!captured[0].values?.length); +}); + +test("getTaskReportData returns empty list for unbound owner (empty string scope)", async () => { + const { db, captured } = captureDb([[/FROM tasks t/, () => [{ id: "task-x" }]]]); + const rows = await getTaskReportData(db, ""); + assert.deepEqual(rows, []); + assert.equal(captured.length, 0, "空范围不应执行查询(绑定 '' 会误配 owner_userid='' 的任务)"); +}); diff --git a/tests/project-push.test.mjs b/tests/project-push.test.mjs index 3f0d50e..627ef74 100644 --- a/tests/project-push.test.mjs +++ b/tests/project-push.test.mjs @@ -27,32 +27,23 @@ const taskRow = { content_format: "image_text", share_token: "tok-123", project_id: "proj-1", - claimed_quantity: 0, - status: "active", + owner_userid: "owner-task", + task_chat_id: null, }; const projectRow = { id: "proj-1", name: "项目一", brand: "品牌A", - main_group_member_ids: JSON.stringify(["zhangsan", "lisi"]), + main_group_member_ids: JSON.stringify(["zhangsan", "lisi", "owner-proj"]), + owner_userid: "owner-proj", + main_chat_id: "", status: "active", note: "", created_at: "2026-08-01", }; -function fakePushDb({ - task = taskRow, - project = projectRow, - links = [ - { project_id: "proj-1", chat_id: "wr-1", partner_id: "p-1" }, - { project_id: "proj-1", chat_id: "wr-2", partner_id: "" }, - ], - groupChats = [ - { chat_id: "wr-1", name: "一群", owner_user_id: "zhangsan", member_count: 10, status: 0 }, - { chat_id: "wr-2", name: "二群", owner_user_id: "lisi", member_count: 5, status: 0 }, - ], -} = {}) { +function fakePushDb({ task = { ...taskRow }, project = { ...projectRow } } = {}) { const executed = []; const makeStatement = (sql, params = []) => ({ sql, @@ -62,10 +53,25 @@ function fakePushDb({ }, async run() { executed.push({ sql, params }); + if (/UPDATE tasks SET task_chat_id/.test(sql)) { + task.task_chat_id = params[0]; + } return { meta: { changes: 1 } }; }, async first() { executed.push({ sql, params }); + if (/FROM tasks t\s+LEFT JOIN projects p/.test(sql)) { + return { + id: task.id, + name: task.name, + task_chat_id: task.task_chat_id ?? null, + task_owner_userid: task.owner_userid ?? null, + project_owner_userid: project ? (project.owner_userid ?? null) : null, + }; + } + if (/SELECT task_chat_id FROM tasks/.test(sql)) { + return { task_chat_id: task.task_chat_id ?? null }; + } if (/FROM tasks WHERE id = \?/.test(sql)) return task; if (/FROM projects WHERE id = \?/.test(sql)) return project; return null; @@ -75,36 +81,12 @@ function fakePushDb({ if (/FROM tasks WHERE project_id/.test(sql)) { return { results: task ? [{ ...task }] : [] }; } - if (/FROM project_group_chats g/.test(sql)) { - return { - results: links - .filter((link) => link.project_id === params[0]) - .map((link) => { - const chat = groupChats.find((item) => item.chat_id === link.chat_id); - return { - chat_id: link.chat_id, - partner_id: link.partner_id, - chat_name: chat ? chat.name : null, - owner_user_id: chat ? chat.owner_user_id : null, - member_count: chat ? chat.member_count : null, - chat_status: chat ? chat.status : null, - partner_name: null, - }; - }), - }; - } - if (/FROM wecom_group_chats/.test(sql)) { - return { - results: sql.includes(" IN (") - ? groupChats.filter((row) => params.includes(row.chat_id)) - : groupChats, - }; - } return { results: [] }; }, }); return { executed, + task, prepare(sql) { return makeStatement(sql); }, @@ -114,7 +96,7 @@ function fakePushDb({ }; } -function fakeWecomFetch(calls, { failAppFor = [] } = {}) { +function fakeWecomFetch(calls, chats, { failAppFor = [], failChatSend = [] } = {}) { return async (input, init = {}) => { const url = new URL(String(input)); calls.push({ url, init }); @@ -133,8 +115,40 @@ function fakeWecomFetch(calls, { failAppFor = [] } = {}) { } return Response.json({ errcode: 0, errmsg: "ok" }); } - if (url.pathname.endsWith("/cgi-bin/externalcontact/add_msg_template")) { - return Response.json({ errcode: 0, errmsg: "ok", msgid: "msg-1", fail_list: [] }); + if (url.pathname.endsWith("/cgi-bin/appchat/create")) { + const body = JSON.parse(String(init.body ?? "{}")); + chats.set(body.chatid, { name: body.name, userlist: body.userlist }); + return Response.json({ errcode: 0, errmsg: "ok", chatid: body.chatid }); + } + if (url.pathname.endsWith("/cgi-bin/appchat/get")) { + const body = JSON.parse(String(init.body ?? "{}")); + const chat = chats.get(body.chatid); + if (!chat) { + return Response.json({ errcode: 60111, errmsg: "chat not found" }); + } + return Response.json({ + errcode: 0, + errmsg: "ok", + chat_info: { chatid: body.chatid, name: chat.name, userlist: chat.userlist }, + }); + } + if (url.pathname.endsWith("/cgi-bin/appchat/update")) { + const body = JSON.parse(String(init.body ?? "{}")); + const chat = chats.get(body.chatid); + if (!chat) { + return Response.json({ errcode: 60111, errmsg: "chat not found" }); + } + for (const userid of body.add_user_list ?? []) { + if (!chat.userlist.includes(userid)) chat.userlist.push(userid); + } + return Response.json({ errcode: 0, errmsg: "ok" }); + } + if (url.pathname.endsWith("/cgi-bin/appchat/send")) { + const body = JSON.parse(String(init.body ?? "{}")); + if (failChatSend.includes(body.chatid) || !chats.has(body.chatid)) { + return Response.json({ errcode: 60111, errmsg: "chat not found" }); + } + return Response.json({ errcode: 0, errmsg: "ok" }); } return new Response("not found", { status: 404 }); }; @@ -151,86 +165,150 @@ test("buildProjectAppMessage targets internal members with claim url", () => { assert.match(message, /领取链接:https:\/\/portal\.example\.com\/\?task=tok-123/); }); -test("pushProjectTask sends per-member app messages and group templates", async () => { +test("pushProjectTask broadcasts once into the main chat and sends the task chat", async () => { process.env.KOC_PORTAL_URL = "https://portal.example.com"; try { - const db = fakePushDb(); + const db = fakePushDb({ + project: { ...projectRow, main_chat_id: "pgcproj1" }, + }); + const chats = new Map([ + ["pgcproj1", { name: "【总群】项目一", userlist: ["zhangsan", "lisi", "owner-proj"] }], + ]); const calls = []; const outcome = await pushProjectTask( db, { taskId: "task-1", sendMainGroup: true, sendGroupChats: true }, resolveWecomConfig(baseBindings), - fakeWecomFetch(calls), + fakeWecomFetch(calls, chats), ); - const appCalls = calls.filter((call) => - call.url.pathname.endsWith("/cgi-bin/message/send"), + assert.equal(outcome.mainChatMode, "group"); + assert.deepEqual(outcome.appResults, [], "no per-member sends when chat exists"); + const chatSends = calls.filter((call) => + call.url.pathname.endsWith("/cgi-bin/appchat/send"), ); assert.deepEqual( - appCalls.map((call) => JSON.parse(String(call.init.body)).touser), - ["zhangsan", "lisi"], - "one message/send per main group member", + chatSends.map((call) => JSON.parse(String(call.init.body)).chatid).sort(), + ["pgcproj1", "tgctask1"], + "one send into main chat and one into task chat", ); - for (const call of appCalls) { - assert.match(JSON.parse(String(call.init.body)).text.content, /【项目任务】测试任务/); - } - assert.equal(outcome.appResults.length, 2); - assert.ok(outcome.appResults.every((item) => item.ok)); - const templateCalls = calls.filter((call) => - call.url.pathname.endsWith("/cgi-bin/externalcontact/add_msg_template"), - ); - assert.equal(templateCalls.length, 2, "one template per owner"); - assert.equal(outcome.groupResults.length, 2); - assert.equal(outcome.groupChatCount, 2); + assert.equal(calls.filter((call) => call.url.pathname.endsWith("/cgi-bin/message/send")).length, 0); assert.deepEqual(outcome.skippedReasons, []); + assert.equal(outcome.taskChat.sent, true); + assert.equal(outcome.taskChat.chatId, "tgctask1"); assert.equal(outcome.linkUrl, "https://portal.example.com/?task=tok-123"); } finally { delete process.env.KOC_PORTAL_URL; } }); -test("single member failure does not block the rest of the push", async () => { +test("pushProjectTask falls back to per-member messages when main chat missing", async () => { process.env.KOC_PORTAL_URL = "https://portal.example.com"; try { - const db = fakePushDb(); + const db = fakePushDb({ project: { ...projectRow, main_chat_id: "" } }); + const chats = new Map(); const calls = []; const outcome = await pushProjectTask( db, - { taskId: "task-1" }, + { taskId: "task-1", sendMainGroup: true, sendGroupChats: true }, resolveWecomConfig(baseBindings), - fakeWecomFetch(calls, { failAppFor: ["zhangsan"] }), + fakeWecomFetch(calls, chats), ); + assert.equal(outcome.mainChatMode, "per_member"); assert.deepEqual( - outcome.appResults.map((item) => [item.userid, item.ok]), - [ - ["zhangsan", false], - ["lisi", true], - ], + outcome.appResults.map((item) => item.userid).sort(), + ["lisi", "owner-proj", "zhangsan"], ); - assert.match(outcome.appResults[0].error, /userid not found/); - const templateCalls = calls.filter((call) => - call.url.pathname.endsWith("/cgi-bin/externalcontact/add_msg_template"), - ); - assert.equal(templateCalls.length, 2, "group push still runs"); + assert.ok(outcome.appResults.every((item) => item.ok)); + assert.ok(outcome.skippedReasons.includes("项目总群未建,已逐人私信")); + assert.equal(outcome.taskChat.sent, true); } finally { delete process.env.KOC_PORTAL_URL; } }); -test("pushProjectTask skips empty targets instead of failing", async () => { +test("single member failure does not block the rest of the per-member push", async () => { + process.env.KOC_PORTAL_URL = "https://portal.example.com"; + try { + const db = fakePushDb({ project: { ...projectRow, main_chat_id: "" } }); + const calls = []; + const outcome = await pushProjectTask( + db, + { taskId: "task-1", sendMainGroup: true, sendGroupChats: false }, + resolveWecomConfig(baseBindings), + fakeWecomFetch(calls, new Map(), { failAppFor: ["zhangsan"] }), + ); + const byUserid = Object.fromEntries( + outcome.appResults.map((item) => [item.userid, item.ok]), + ); + assert.equal(byUserid.zhangsan, false, "failed member recorded"); + assert.equal(byUserid.lisi, true, "other members still sent"); + assert.equal(byUserid["owner-proj"], true, "project owner also notified"); + assert.match( + outcome.appResults.find((item) => !item.ok).error, + /userid not found/, + ); + } finally { + delete process.env.KOC_PORTAL_URL; + } +}); + +test("main chat send failure degrades to per-member push", async () => { + process.env.KOC_PORTAL_URL = "https://portal.example.com"; + try { + const db = fakePushDb({ + project: { ...projectRow, main_chat_id: "pgcproj1" }, + }); + const chats = new Map([ + ["pgcproj1", { name: "【总群】项目一", userlist: ["zhangsan"] }], + ]); + const calls = []; + const outcome = await pushProjectTask( + db, + { taskId: "task-1", sendMainGroup: true, sendGroupChats: false }, + resolveWecomConfig(baseBindings), + fakeWecomFetch(calls, chats, { failChatSend: ["pgcproj1"] }), + ); + assert.equal(outcome.mainChatMode, "per_member"); + assert.ok( + outcome.skippedReasons.some((reason) => /降级逐人私信/.test(reason)), + ); + assert.equal(outcome.appResults.length, 3); + } finally { + delete process.env.KOC_PORTAL_URL; + } +}); + +test("task chat is skipped with reason when no owner at all", async () => { const db = fakePushDb({ - project: { ...projectRow, main_group_member_ids: "[]" }, - links: [{ project_id: "proj-1", chat_id: "wr-gone", partner_id: "" }], - groupChats: [], + task: { ...taskRow, owner_userid: "" }, + project: { ...projectRow, owner_userid: "", main_group_member_ids: "[]" }, }); const calls = []; const outcome = await pushProjectTask( db, - { taskId: "task-1" }, + { taskId: "task-1", sendMainGroup: false, sendGroupChats: true }, resolveWecomConfig(baseBindings), - fakeWecomFetch(calls), + fakeWecomFetch(calls, new Map()), ); - assert.deepEqual(outcome.skippedReasons, ["项目总群未配置成员", "项目没有可用的任务群"]); - assert.equal(calls.length, 0, "no wecom api calls expected"); + assert.equal(outcome.taskChat.sent, false); + assert.equal(outcome.taskChat.chatId, ""); + assert.equal(outcome.taskChat.reason, "任务与项目都未填负责人"); + assert.ok(outcome.skippedReasons.includes("任务群未发送:任务与项目都未填负责人")); + assert.equal(calls.length, 0); +}); + +test("task chat is skipped when fewer than 2 members", async () => { + const db = fakePushDb({ + task: { ...taskRow, owner_userid: "" }, + project: { ...projectRow, owner_userid: "owner-proj", main_group_member_ids: "[]" }, + }); + const outcome = await pushProjectTask( + db, + { taskId: "task-1", sendMainGroup: false, sendGroupChats: true }, + resolveWecomConfig(baseBindings), + fakeWecomFetch([], new Map()), + ); + assert.equal(outcome.taskChat.reason, "任务群成员不足 2 人"); }); test("pushProjectTask rejects task without project or unknown task", async () => { @@ -241,7 +319,7 @@ test("pushProjectTask rejects task without project or unknown task", async () => unlinked, { taskId: "task-1" }, resolveWecomConfig(baseBindings), - fakeWecomFetch([]), + fakeWecomFetch([], new Map()), ), (error) => error instanceof ProjectError && /未挂项目/.test(error.message), ); @@ -257,7 +335,7 @@ test("pushProjectTask rejects task without project or unknown task", async () => fakePushDb(), { taskId: "task-1", sendMainGroup: false, sendGroupChats: false }, resolveWecomConfig(baseBindings), - fakeWecomFetch([]), + fakeWecomFetch([], new Map()), ), (error) => error instanceof ProjectError && /至少选择一个发送目标/.test(error.message), ); diff --git a/tests/project-service.test.mjs b/tests/project-service.test.mjs index 1f895be..0604f6c 100644 --- a/tests/project-service.test.mjs +++ b/tests/project-service.test.mjs @@ -1,23 +1,100 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + projectMainChatId, deleteProject, + ensureProjectMainChat, + effectiveProjectMembers, + findOrCreateGroupOwnerPartner, getProjectDetail, listProjects, parseMemberIds, ProjectError, saveGroupOwnerPartner, saveProject, - setProjectGroupChats, } from "../lib/project-service.ts"; +import { + clearWecomAccessTokenCacheForTests, + resolveWecomConfig, +} from "../lib/wecom-client.ts"; -function fakeProjectDb({ - projects = [], - links = [], - tasks = [], - groupChats = [], - partners = [], -} = {}) { +const baseBindings = { + WECOM_CORP_ID: "corp-test", + WECOM_AGENT_ID: "1000001", + WECOM_SECRET: "secret-test", +}; + +test.beforeEach(() => { + clearWecomAccessTokenCacheForTests(); +}); + +function fakeWecom(options = {}) { + const calls = []; + const chats = new Map(options.chats ?? []); + const failCreate = options.failCreate ?? false; + const failGet = options.failGet ?? false; + const createExisted = options.createExisted ?? false; + const fetchImpl = async (input, init = {}) => { + const url = new URL(String(input)); + calls.push({ url, init }); + if (url.pathname.endsWith("/cgi-bin/gettoken")) { + return Response.json({ + errcode: 0, + errmsg: "ok", + access_token: "token-test", + expires_in: 7200, + }); + } + if (url.pathname.endsWith("/cgi-bin/appchat/create")) { + if (createExisted) { + return Response.json({ errcode: 86215, errmsg: "chatid existed" }); + } + if (failCreate) { + return Response.json({ errcode: 60111, errmsg: "user not in scope" }); + } + const body = JSON.parse(String(init.body ?? "{}")); + chats.set(body.chatid, { name: body.name, userlist: body.userlist }); + return Response.json({ errcode: 0, errmsg: "ok", chatid: body.chatid }); + } + if (url.pathname.endsWith("/cgi-bin/appchat/update")) { + const body = JSON.parse(String(init.body ?? "{}")); + const chat = chats.get(body.chatid); + if (!chat) { + return Response.json({ errcode: 60111, errmsg: "chat not found" }); + } + for (const userid of body.add_user_list ?? []) { + if (!chat.userlist.includes(userid)) chat.userlist.push(userid); + } + for (const userid of body.del_user_list ?? []) { + chat.userlist = chat.userlist.filter((id) => id !== userid); + } + return Response.json({ errcode: 0, errmsg: "ok" }); + } + if (url.pathname.endsWith("/cgi-bin/appchat/get")) { + if (failGet) { + return Response.json({ errcode: 86001, errmsg: "chatid invalid" }); + } + const body = JSON.parse(String(init.body ?? "{}")); + const chat = chats.get(body.chatid); + if (!chat) { + return Response.json({ errcode: 60111, errmsg: "chat not found" }); + } + return Response.json({ + errcode: 0, + errmsg: "ok", + chat_info: { + chatid: body.chatid, + name: chat.name, + userlist: chat.userlist, + }, + }); + } + return new Response("not found", { status: 404 }); + }; + return { calls, fetchImpl, chats }; +} + +function fakeProjectDb({ projects = [], tasks = [], partners = [] } = {}) { const executed = []; const applyWrite = (sql, params) => { const normalized = sql.trim(); @@ -28,12 +105,18 @@ function fakeProjectDb({ brand: params[2], main_group_member_ids: params[3], owner_userid: params[4], + main_chat_id: "", status: "active", note: params[5], created_at: params[6], }); return 1; } + if (/^UPDATE projects SET main_chat_id/.test(normalized)) { + const row = projects.find((item) => item.id === params[1]); + if (row) row.main_chat_id = params[0]; + return row ? 1 : 0; + } if (/^UPDATE projects/.test(normalized)) { const row = projects.find((item) => item.id === params[5]); if (!row) return 0; @@ -52,16 +135,6 @@ function fakeProjectDb({ projects.splice(index, 1); return 1; } - if (/^INSERT INTO project_group_chats/.test(normalized)) { - links.push({ project_id: params[1], chat_id: params[2], partner_id: params[3] }); - return 1; - } - if (/^DELETE FROM project_group_chats/.test(normalized)) { - for (let index = links.length - 1; index >= 0; index -= 1) { - if (links[index].project_id === params[0]) links.splice(index, 1); - } - return 1; - } if (/^UPDATE tasks SET project_id = ''/.test(normalized)) { for (const task of tasks) { if (task.project_id === params[0]) task.project_id = ""; @@ -103,6 +176,15 @@ function fakeProjectDb({ if (/FROM partners WHERE id = \?/.test(sql)) { return partners.find((item) => item.id === params[0]) ?? null; } + if (/partner_type = 'group_owner' AND wecom_userid = \?/.test(sql)) { + return ( + partners.find( + (item) => + item.partner_type === "group_owner" && + item.wecom_userid === params[0], + ) ?? null + ); + } return null; }, async all() { @@ -112,7 +194,6 @@ function fakeProjectDb({ results: projects.map((item) => ({ ...item, task_count: tasks.filter((task) => task.project_id === item.id).length, - group_chat_count: links.filter((link) => link.project_id === item.id).length, })), }; } @@ -123,25 +204,6 @@ function fakeProjectDb({ .map((task) => ({ ...task })), }; } - if (/FROM project_group_chats g/.test(sql)) { - return { - results: links - .filter((link) => link.project_id === params[0]) - .map((link) => { - const chat = groupChats.find((item) => item.chat_id === link.chat_id); - return { - chat_id: link.chat_id, - partner_id: link.partner_id, - chat_name: chat ? chat.name : null, - owner_user_id: chat ? chat.owner_user_id : null, - member_count: chat ? chat.member_count : null, - chat_status: chat ? chat.status : null, - partner_name: - partners.find((item) => item.id === link.partner_id)?.name ?? null, - }; - }), - }; - } if (/FROM partners/.test(sql)) { return { results: partners.map((item) => ({ ...item })) }; } @@ -150,9 +212,7 @@ function fakeProjectDb({ }); return { projects, - links, tasks, - groupChats, partners, executed, prepare(sql) { @@ -166,6 +226,19 @@ function fakeProjectDb({ }; } +const projectRow = (overrides = {}) => ({ + id: "proj-1", + name: "A", + brand: "", + main_group_member_ids: "[]", + owner_userid: "", + main_chat_id: "", + status: "active", + note: "", + created_at: "2026-08-01", + ...overrides, +}); + test("parseMemberIds accepts lines, arrays and mixed separators, dedupes", () => { assert.deepEqual(parseMemberIds("zhangsan\nlisi zhangsan"), ["zhangsan", "lisi"]); assert.deepEqual(parseMemberIds(["a", " b ", "a"]), ["a", "b"]); @@ -173,9 +246,18 @@ test("parseMemberIds accepts lines, arrays and mixed separators, dedupes", () => assert.deepEqual(parseMemberIds(" \n "), []); }); -test("saveProject inserts with proj- prefixed id and JSON members", async () => { +test("effectiveProjectMembers parses stored JSON and merges owner", () => { + assert.deepEqual( + effectiveProjectMembers('["u1","u2"]', "owner-a"), + ["u1", "u2", "owner-a"], + ); + assert.deepEqual(effectiveProjectMembers("u1\nu1,u2", ""), ["u1", "u2"]); + assert.deepEqual(effectiveProjectMembers('["u1"]', "u1"), ["u1"]); +}); + +test("saveProject inserts with proj- prefixed id and JSON members incl owner", async () => { const db = fakeProjectDb(); - const project = await saveProject(db, { + const { project, mainChat } = await saveProject(db, { name: "暑期焕新", brand: "青柠实验室", mainGroupMemberIds: "zhangsan\nlisi\nzhangsan", @@ -185,24 +267,20 @@ test("saveProject inserts with proj- prefixed id and JSON members", async () => assert.match(project.id, /^proj-/); assert.equal(project.name, "暑期焕新"); assert.equal(project.ownerUserid, "owner-zhang"); - assert.deepEqual(project.mainGroupMemberIds, ["zhangsan", "lisi"]); + assert.deepEqual(project.mainGroupMemberIds, ["zhangsan", "lisi", "owner-zhang"]); assert.equal(db.projects.length, 1); assert.equal(db.projects[0].status, "active"); + assert.deepEqual(JSON.parse(db.projects[0].main_group_member_ids), [ + "zhangsan", + "lisi", + "owner-zhang", + ]); + assert.equal(mainChat.reason, "no_credentials", "no config → lazy chat"); }); test("saveProject updates existing row and rejects blank name", async () => { const db = fakeProjectDb({ - projects: [ - { - id: "proj-1", - name: "旧名", - brand: "", - main_group_member_ids: "[]", - status: "active", - note: "", - created_at: "2026-08-01", - }, - ], + projects: [projectRow({ id: "proj-1", name: "旧名" })], }); await saveProject(db, { id: "proj-1", @@ -214,7 +292,10 @@ test("saveProject updates existing row and rejects blank name", async () => { assert.equal(db.projects[0].name, "新名"); assert.equal(db.projects[0].brand, "品牌"); assert.equal(db.projects[0].owner_userid, "owner-li"); - assert.deepEqual(JSON.parse(db.projects[0].main_group_member_ids), ["wangwu"]); + assert.deepEqual(JSON.parse(db.projects[0].main_group_member_ids), [ + "wangwu", + "owner-li", + ]); assert.equal(db.projects.length, 1, "update must not insert"); await assert.rejects( @@ -231,63 +312,81 @@ test("saveProject rejects unknown id on update", async () => { ); }); -test("listProjects aggregates task and group chat counts", async () => { +test("saveProject with config creates main chat once members reach 3", async () => { + const db = fakeProjectDb(); + const wecom = fakeWecom(); + const { project, mainChat } = await saveProject( + db, + { + name: "满三人群", + mainGroupMemberIds: "u1\nu2", + ownerUserid: "owner-a", + }, + resolveWecomConfig(baseBindings), + wecom.fetchImpl, + ); + assert.equal(mainChat.chatId, projectMainChatId(project.id)); + assert.equal(mainChat.reason, undefined); + assert.equal(db.projects[0].main_chat_id, projectMainChatId(project.id)); + const createBody = JSON.parse( + String( + wecom.calls.find((c) => c.url.pathname.endsWith("/cgi-bin/appchat/create")) + .init.body, + ), + ); + assert.equal(createBody.chatid, projectMainChatId(project.id)); + assert.deepEqual(createBody.userlist.sort(), ["owner-a", "u1", "u2"]); +}); + +test("listProjects aggregates task counts and exposes mainChatId", async () => { const db = fakeProjectDb({ projects: [ - { + projectRow({ id: "proj-1", - name: "A", - brand: "", - main_group_member_ids: "[\"u1\",\"u2\"]", - status: "active", - note: "", - created_at: "2026-08-01", - }, + main_group_member_ids: '["u1","u2"]', + main_chat_id: "pgcproj1", + }), ], tasks: [ { id: "t1", project_id: "proj-1" }, { id: "t2", project_id: "proj-1" }, { id: "t3", project_id: "" }, ], - links: [{ project_id: "proj-1", chat_id: "wr-1", partner_id: "" }], }); const projects = await listProjects(db); assert.equal(projects.length, 1); assert.equal(projects[0].taskCount, 2); - assert.equal(projects[0].groupChatCount, 1); + assert.equal(projects[0].mainChatId, "pgcproj1"); assert.deepEqual(projects[0].mainGroupMemberIds, ["u1", "u2"]); }); -test("getProjectDetail marks missing chats and dangling partners", async () => { +test("getProjectDetail returns tasks with owner and mainChat members", async () => { const db = fakeProjectDb({ projects: [ - { + projectRow({ id: "proj-1", - name: "A", - brand: "", - main_group_member_ids: "[]", - status: "active", - note: "", - created_at: "2026-08-01", + main_group_member_ids: '["u1"]', + owner_userid: "owner-a", + main_chat_id: "pgcproj1", + }), + ], + tasks: [ + { + id: "t1", + name: "任务一", + project_id: "proj-1", + owner_userid: "owner-a", + task_chat_id: "tgc_t1", }, ], - links: [ - { project_id: "proj-1", chat_id: "wr-1", partner_id: "p-1" }, - { project_id: "proj-1", chat_id: "wr-gone", partner_id: "" }, - ], - groupChats: [ - { chat_id: "wr-1", name: "一群", owner_user_id: "zhangsan", member_count: 12, status: 0 }, - ], - partners: [], }); const detail = await getProjectDetail(db, "proj-1"); - assert.equal(detail.groupChats.length, 2); - const alive = detail.groupChats.find((chat) => chat.chatId === "wr-1"); - assert.equal(alive.missing, false); - assert.equal(alive.partnerName, "", "dangling partner shows blank name"); - const gone = detail.groupChats.find((chat) => chat.chatId === "wr-gone"); - assert.equal(gone.missing, true); - assert.equal(gone.name, ""); + assert.equal(detail.project.taskCount, 1); + assert.equal(detail.project.mainChatId, "pgcproj1"); + assert.equal(detail.tasks[0].owner_userid, "owner-a"); + assert.equal(detail.tasks[0].task_chat_id, "tgc_t1"); + assert.equal(detail.mainChat.chatId, "pgcproj1"); + assert.deepEqual(detail.mainChat.members, ["u1", "owner-a"]); }); test("getProjectDetail throws 404 for unknown project", async () => { @@ -298,51 +397,9 @@ test("getProjectDetail throws 404 for unknown project", async () => { ); }); -test("setProjectGroupChats replaces links and dedupes chat ids", async () => { +test("deleteProject unlinks tasks", async () => { const db = fakeProjectDb({ - projects: [ - { - id: "proj-1", - name: "A", - brand: "", - main_group_member_ids: "[]", - status: "active", - note: "", - created_at: "2026-08-01", - }, - ], - links: [{ project_id: "proj-1", chat_id: "wr-old", partner_id: "" }], - groupChats: [ - { chat_id: "wr-1", name: "一群", owner_user_id: "zhangsan", member_count: 12, status: 0 }, - ], - partners: [{ id: "p-1", name: "张三", wecom_userid: "zhangsan" }], - }); - const detail = await setProjectGroupChats(db, "proj-1", [ - { chatId: "wr-1", partnerId: "p-1" }, - { chatId: "wr-1", partnerId: "p-1" }, - { chatId: " ", partnerId: "" }, - ]); - assert.equal(detail.groupChats.length, 1); - assert.equal(detail.groupChats[0].chatId, "wr-1"); - assert.equal(detail.groupChats[0].partnerName, "张三"); - assert.equal(db.links.length, 1); - assert.equal(db.links[0].chat_id, "wr-1"); -}); - -test("deleteProject removes links and unlinks tasks", async () => { - const db = fakeProjectDb({ - projects: [ - { - id: "proj-1", - name: "A", - brand: "", - main_group_member_ids: "[]", - status: "active", - note: "", - created_at: "2026-08-01", - }, - ], - links: [{ project_id: "proj-1", chat_id: "wr-1", partner_id: "" }], + projects: [projectRow({ id: "proj-1" })], tasks: [ { id: "t1", project_id: "proj-1" }, { id: "t2", project_id: "proj-2" }, @@ -350,7 +407,6 @@ test("deleteProject removes links and unlinks tasks", async () => { }); await deleteProject(db, "proj-1"); assert.equal(db.projects.length, 0); - assert.equal(db.links.length, 0); assert.equal(db.tasks[0].project_id, ""); assert.equal(db.tasks[1].project_id, "proj-2"); @@ -360,6 +416,216 @@ test("deleteProject removes links and unlinks tasks", async () => { ); }); +test("ensureProjectMainChat returns no_project for unknown ids", async () => { + const wecom = fakeWecom(); + const db = fakeProjectDb(); + const result = await ensureProjectMainChat( + db, + "proj-404", + resolveWecomConfig(baseBindings), + wecom.fetchImpl, + ); + assert.equal(result.reason, "no_project"); + assert.equal(result.chatId, null); +}); + +test("ensureProjectMainChat skips without app credentials", async () => { + const wecom = fakeWecom(); + const db = fakeProjectDb({ + projects: [projectRow({ main_group_member_ids: '["u1","u2","u3"]' })], + }); + const result = await ensureProjectMainChat( + db, + "proj-1", + resolveWecomConfig({ ...baseBindings, WECOM_SECRET: "" }), + wecom.fetchImpl, + ); + assert.equal(result.reason, "no_credentials"); + assert.equal(result.chatId, null); + assert.equal(wecom.calls.length, 0); +}); + +test("ensureProjectMainChat skips creation when fewer than 3 members", async () => { + const wecom = fakeWecom(); + const db = fakeProjectDb({ + projects: [projectRow({ main_group_member_ids: '["u1","u2"]' })], + }); + const result = await ensureProjectMainChat( + db, + "proj-1", + resolveWecomConfig(baseBindings), + wecom.fetchImpl, + ); + assert.equal(result.reason, "not_enough_members"); + assert.equal(result.chatId, null); + assert.equal(db.projects[0].main_chat_id, ""); + assert.equal( + wecom.calls.filter((c) => c.url.pathname.endsWith("/cgi-bin/appchat/create")) + .length, + 0, + ); +}); + +test("ensureProjectMainChat probes before creating and persists chat id", async () => { + const wecom = fakeWecom(); + const db = fakeProjectDb({ + projects: [ + projectRow({ + name: "项目一", + main_group_member_ids: '["u1","u2"]', + owner_userid: "owner-a", + }), + ], + }); + const result = await ensureProjectMainChat( + db, + "proj-1", + resolveWecomConfig(baseBindings), + wecom.fetchImpl, + ); + assert.equal(result.chatId, "pgcproj1"); + assert.deepEqual(result.members.sort(), ["owner-a", "u1", "u2"]); + assert.equal(db.projects[0].main_chat_id, "pgcproj1"); + const paths = wecom.calls.map((c) => c.url.pathname); + assert.ok(paths.some((p) => p.endsWith("/cgi-bin/appchat/get")), "probe first"); + assert.ok(paths.some((p) => p.endsWith("/cgi-bin/appchat/create"))); + assert.ok( + paths.indexOf(paths.find((p) => p.endsWith("/cgi-bin/appchat/get"))) < + paths.indexOf(paths.find((p) => p.endsWith("/cgi-bin/appchat/create"))), + "probe precedes create", + ); +}); + +test("ensureProjectMainChat is idempotent when chat already exists remotely", async () => { + const wecom = fakeWecom({ + chats: [["pgcproj1", { name: "【总群】项目一", userlist: ["u1", "u2", "owner-a"] }]], + }); + const db = fakeProjectDb({ + projects: [ + projectRow({ + name: "项目一", + main_group_member_ids: '["u1","u2"]', + owner_userid: "owner-a", + }), + ], + }); + const result = await ensureProjectMainChat( + db, + "proj-1", + resolveWecomConfig(baseBindings), + wecom.fetchImpl, + ); + assert.equal(result.chatId, "pgcproj1"); + assert.equal(db.projects[0].main_chat_id, "pgcproj1", "id persisted on recovery"); + assert.equal( + wecom.calls.filter((c) => c.url.pathname.endsWith("/cgi-bin/appchat/create")) + .length, + 0, + "no duplicate create", + ); +}); + +test("ensureProjectMainChat diffs members add and del on existing chat", async () => { + const wecom = fakeWecom({ + chats: [["pgcproj1", { name: "【总群】项目一", userlist: ["u1", "gone-user"] }]], + }); + const db = fakeProjectDb({ + projects: [ + projectRow({ + main_group_member_ids: '["u1","u2"]', + owner_userid: "owner-a", + main_chat_id: "pgcproj1", + }), + ], + }); + const result = await ensureProjectMainChat( + db, + "proj-1", + resolveWecomConfig(baseBindings), + wecom.fetchImpl, + ); + assert.equal(result.chatId, "pgcproj1"); + const updateBody = JSON.parse( + String( + wecom.calls.find((c) => c.url.pathname.endsWith("/cgi-bin/appchat/update")) + .init.body, + ), + ); + assert.deepEqual(updateBody.add_user_list.sort(), ["owner-a", "u2"]); + assert.deepEqual(updateBody.del_user_list, ["gone-user"]); +}); + +test("ensureProjectMainChat reports warning and keeps main_chat_id empty on create failure", async () => { + const wecom = fakeWecom({ failCreate: true }); + const db = fakeProjectDb({ + projects: [projectRow({ main_group_member_ids: '["u1","u2","u3"]' })], + }); + const result = await ensureProjectMainChat( + db, + "proj-1", + resolveWecomConfig(baseBindings), + wecom.fetchImpl, + ); + assert.equal(result.chatId, null); + assert.match(result.warning, /创建失败/); + assert.equal(db.projects[0].main_chat_id, "", "chat id not persisted on failure"); +}); + +test("ensureProjectMainChat adopts chat when create reports chatid existed", async () => { + // 可见范围非根部门时 appchat/get 永远 86001,但群实际已建过(create 返回 86215) + const wecom = fakeWecom({ failGet: true, createExisted: true }); + const db = fakeProjectDb({ + projects: [ + projectRow({ + name: "项目一", + main_group_member_ids: '["u1","u2"]', + owner_userid: "owner-a", + }), + ], + }); + const result = await ensureProjectMainChat( + db, + "proj-1", + resolveWecomConfig(baseBindings), + wecom.fetchImpl, + ); + assert.equal(result.chatId, "pgcproj1"); + assert.equal(result.warning, undefined); + assert.equal(db.projects[0].main_chat_id, "pgcproj1", "existing chat id adopted"); +}); + +test("ensureProjectMainChat falls back to add-only sync when get is unavailable", async () => { + const wecom = fakeWecom({ + failGet: true, + chats: [["pgcproj1", { name: "【总群】项目一", userlist: ["u1", "gone-user"] }]], + }); + const db = fakeProjectDb({ + projects: [ + projectRow({ + main_group_member_ids: '["u1","u2"]', + owner_userid: "owner-a", + main_chat_id: "pgcproj1", + }), + ], + }); + const result = await ensureProjectMainChat( + db, + "proj-1", + resolveWecomConfig(baseBindings), + wecom.fetchImpl, + ); + assert.equal(result.chatId, "pgcproj1"); + assert.equal(result.warning, undefined); + const updateBody = JSON.parse( + String( + wecom.calls.find((c) => c.url.pathname.endsWith("/cgi-bin/appchat/update")) + .init.body, + ), + ); + assert.deepEqual(updateBody.add_user_list.sort(), ["owner-a", "u1", "u2"]); + assert.equal(updateBody.del_user_list, undefined, "no removals without member list"); +}); + test("saveGroupOwnerPartner inserts and updates group_owner partners", async () => { const db = fakeProjectDb(); const created = await saveGroupOwnerPartner(db, { @@ -382,3 +648,23 @@ test("saveGroupOwnerPartner inserts and updates group_owner partners", async () (error) => error instanceof ProjectError, ); }); + +test("findOrCreateGroupOwnerPartner returns existing row without duplicating", async () => { + const db = fakeProjectDb({ + partners: [ + { id: "partner-go-old", name: "负责人", wecom_userid: "owner-a", partner_type: "group_owner" }, + ], + }); + const found = await findOrCreateGroupOwnerPartner(db, "owner-a"); + assert.equal(found.id, "partner-go-old"); + assert.equal(db.partners.length, 1); +}); + +test("findOrCreateGroupOwnerPartner creates partner with userid fallback name", async () => { + const db = fakeProjectDb(); + const created = await findOrCreateGroupOwnerPartner(db, "owner-b"); + assert.match(created.id, /^partner-go-/); + assert.equal(created.name, "owner-b"); + assert.equal(created.wecom_userid, "owner-b"); + assert.equal(db.partners.length, 1); +}); diff --git a/tests/rendered-html.test.mjs b/tests/rendered-html.test.mjs index 0cd10e0..4a5cba5 100644 --- a/tests/rendered-html.test.mjs +++ b/tests/rendered-html.test.mjs @@ -462,10 +462,13 @@ test("provides simple username-password login and three server-enforced roles", assert.match(usersPage, /不开放注册和个人改密/); assert.match(usersPage, /普通用户/); assert.match(usersPage, /管理员/); - assert.match(adminApp, /item\.key === "resources" && currentUser\.role === "user"/); + assert.match( + adminApp, + /isTaskOwnerView && \["resources", "partners"\]\.includes\(item\.key\)/, + ); assert.match(adminApp, /item\.key === "users" && !isManager/); - assert.match(bootstrapRoute, /principal\.user\.role === "user"/); - assert.match(bootstrapRoute, /accounts: \[\]/); + assert.match(bootstrapRoute, /getOwnerScope\(request\)/); + assert.match(bootstrapRoute, /getDashboardData\(ownerScopeUserid\(scope\)\)/); assert.match(resourceExportRoute, /isManagerRequest/); assert.match(schema, /authSessions/); assert.match(schema, /backgroundJobs/); diff --git a/tests/task-customer-chat-service.test.mjs b/tests/task-customer-chat-service.test.mjs new file mode 100644 index 0000000..5087ac8 --- /dev/null +++ b/tests/task-customer-chat-service.test.mjs @@ -0,0 +1,219 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + bindTaskCustomerChat, + listOwnerCustomerChats, + unbindTaskCustomerChat, +} from "../lib/task-customer-chat-service.ts"; +import { resolveWecomConfig } from "../lib/wecom-client.ts"; + +const baseBindings = { + WECOM_CORP_ID: "corp-test", + WECOM_AGENT_ID: "1000001", + WECOM_SECRET: "secret-test", +}; + +function fakeWecom(options = {}) { + const calls = []; + const fetchImpl = async (input, init = {}) => { + const url = new URL(String(input)); + calls.push({ url, init }); + if (url.pathname.endsWith("/cgi-bin/gettoken")) { + return Response.json({ + errcode: 0, + errmsg: "ok", + access_token: "token-test", + expires_in: 7200, + }); + } + if (url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/add_join_way")) { + if (options.addErrcode) { + return Response.json({ errcode: options.addErrcode, errmsg: "add failed" }); + } + return Response.json({ errcode: 0, errmsg: "ok", config_id: options.configId ?? "cfg-new" }); + } + if (url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/update_join_way")) { + if (options.updateErrcode) { + return Response.json({ errcode: options.updateErrcode, errmsg: "update failed" }); + } + return Response.json({ errcode: 0, errmsg: "ok" }); + } + if (url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/get_join_way")) { + if (options.getErrcode) { + return Response.json({ errcode: options.getErrcode, errmsg: "get failed" }); + } + return Response.json({ + errcode: 0, + errmsg: "ok", + join_way: { qr_code: options.qrCode ?? "https://p.qpic.cn/qr-live.png" }, + }); + } + if (url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/del_join_way")) { + if (options.delErrcode) { + return Response.json({ errcode: options.delErrcode, errmsg: "del failed" }); + } + return Response.json({ errcode: 0, errmsg: "ok" }); + } + return new Response("not found", { status: 404 }); + }; + return { calls, fetchImpl }; +} + +function captureDb(taskRow) { + const captured = []; + const makeStatement = (sql, values) => ({ + async first() { + captured.push({ sql, values }); + if (/FROM tasks WHERE id = \?/.test(sql)) return taskRow ?? null; + return null; + }, + async run() { + captured.push({ sql, values }); + return { changes: 1 }; + }, + }); + const db = { + prepare(sql) { + const unbound = makeStatement(sql, []); + return { + bind(...values) { + return makeStatement(sql, values); + }, + first: unbound.first, + run: unbound.run, + }; + }, + }; + return { db, captured }; +} + +function callsTo(calls, suffix) { + return calls.filter((c) => c.url.pathname.endsWith(suffix)); +} + +test("listOwnerCustomerChats rejects without app credentials and ignores empty owner", async () => { + const config = resolveWecomConfig({ ...baseBindings, WECOM_SECRET: "" }); + await assert.rejects(() => listOwnerCustomerChats(config, "zhangsan"), /未配置/); + const { calls, fetchImpl } = fakeWecom(); + assert.deepEqual(await listOwnerCustomerChats(resolveWecomConfig(baseBindings), " ", fetchImpl), []); + assert.equal(calls.length, 0, "empty owner short-circuits without API calls"); +}); + +test("bindTaskCustomerChat creates join way when task has no config", async () => { + const { db, captured } = captureDb({ + id: "task-1", + name: "美团探店任务", + join_config_id: null, + }); + const { calls, fetchImpl } = fakeWecom(); + const result = await bindTaskCustomerChat( + db, + { taskId: "task-1", chatId: "wr-normal-1", chatName: "KOC 一群" }, + resolveWecomConfig(baseBindings), + fetchImpl, + ); + assert.equal(callsTo(calls, "/cgi-bin/externalcontact/groupchat/add_join_way").length, 1); + assert.equal(callsTo(calls, "/cgi-bin/externalcontact/groupchat/update_join_way").length, 0); + const addBody = JSON.parse(String(callsTo(calls, "/add_join_way")[0].init.body)); + assert.deepEqual(addBody.chat_id_list, ["wr-normal-1"]); + assert.equal(addBody.room_base_name, "美团探店任务"); + assert.equal(addBody.state, "task-task-1"); + assert.equal(result.joinConfigId, "cfg-new"); + assert.equal(result.joinQrCode, "https://p.qpic.cn/qr-live.png"); + assert.equal(result.qrError, undefined); + const update = captured.find((c) => /UPDATE tasks/.test(c.sql)); + assert.ok(update, "tasks row updated"); + assert.match(update.sql, /customer_chat_id = \?/); + assert.deepEqual(update.values, ["wr-normal-1", "KOC 一群", "cfg-new", "https://p.qpic.cn/qr-live.png", "task-1"]); +}); + +test("bindTaskCustomerChat reuses update_join_way on rebind and falls back to add", async () => { + const { db } = captureDb({ id: "task-1", name: "任务", join_config_id: "cfg-old" }); + const { calls, fetchImpl } = fakeWecom(); + const result = await bindTaskCustomerChat( + db, + { taskId: "task-1", chatId: "wr-normal-2", chatName: "KOC 二群" }, + resolveWecomConfig(baseBindings), + fetchImpl, + ); + assert.equal(callsTo(calls, "/cgi-bin/externalcontact/groupchat/update_join_way").length, 1); + assert.equal(callsTo(calls, "/cgi-bin/externalcontact/groupchat/add_join_way").length, 0); + assert.equal(result.joinConfigId, "cfg-old", "config id preserved so qr stays valid"); + + const { db: db2 } = captureDb({ id: "task-1", name: "任务", join_config_id: "cfg-old" }); + const { calls: calls2, fetchImpl: fetch2 } = fakeWecom({ + updateErrcode: 40068, + configId: "cfg-rebuilt", + }); + const result2 = await bindTaskCustomerChat( + db2, + { taskId: "task-1", chatId: "wr-normal-2", chatName: "KOC 二群" }, + resolveWecomConfig(baseBindings), + fetch2, + ); + assert.equal(callsTo(calls2, "/cgi-bin/externalcontact/groupchat/update_join_way").length, 1); + assert.equal(callsTo(calls2, "/cgi-bin/externalcontact/groupchat/add_join_way").length, 1); + assert.equal(result2.joinConfigId, "cfg-rebuilt"); +}); + +test("bindTaskCustomerChat tolerates qr fetch failure and records qrError", async () => { + const { db } = captureDb({ id: "task-1", name: "任务", join_config_id: null }); + const { fetchImpl } = fakeWecom({ getErrcode: 40068 }); + const result = await bindTaskCustomerChat( + db, + { taskId: "task-1", chatId: "wr-normal-1", chatName: "KOC 一群" }, + resolveWecomConfig(baseBindings), + fetchImpl, + ); + assert.equal(result.joinQrCode, ""); + assert.match(result.qrError, /活码二维码获取失败/); +}); + +test("bindTaskCustomerChat validates missing task / chat", async () => { + const { db: missingTask } = captureDb(null); + await assert.rejects( + () => + bindTaskCustomerChat( + missingTask, + { taskId: "task-x", chatId: "wr-1", chatName: "群" }, + resolveWecomConfig(baseBindings), + fakeWecom().fetchImpl, + ), + /任务不存在/, + ); + const { db } = captureDb({ id: "task-1", name: "任务", join_config_id: null }); + await assert.rejects( + () => + bindTaskCustomerChat( + db, + { taskId: "task-1", chatId: "", chatName: "" }, + resolveWecomConfig(baseBindings), + fakeWecom().fetchImpl, + ), + /请选择要绑定的客户群/, + ); +}); + +test("unbindTaskCustomerChat deletes join way (tolerating failure) and clears columns", async () => { + const { db, captured } = captureDb({ id: "task-1", join_config_id: "cfg-old" }); + const { calls, fetchImpl } = fakeWecom(); + await unbindTaskCustomerChat(db, "task-1", resolveWecomConfig(baseBindings), fetchImpl); + assert.equal(callsTo(calls, "/cgi-bin/externalcontact/groupchat/del_join_way").length, 1); + const update = captured.find((c) => /UPDATE tasks/.test(c.sql)); + assert.ok(update); + assert.match(update.sql, /customer_chat_id = ''/); + + const { db: db2, captured: captured2 } = captureDb({ id: "task-1", join_config_id: "cfg-old" }); + const { fetchImpl: fetch2 } = fakeWecom({ delErrcode: 40068 }); + await unbindTaskCustomerChat(db2, "task-1", resolveWecomConfig(baseBindings), fetch2); + assert.ok( + captured2.some((c) => /UPDATE tasks/.test(c.sql)), + "local columns still cleared when del_join_way fails", + ); + + const { db: db3, captured: captured3 } = captureDb({ id: "task-1", join_config_id: "" }); + const { calls: calls3, fetchImpl: fetch3 } = fakeWecom(); + await unbindTaskCustomerChat(db3, "task-1", resolveWecomConfig(baseBindings), fetch3); + assert.equal(calls3.length, 0, "no wecom calls when nothing bound"); + assert.ok(captured3.some((c) => /UPDATE tasks/.test(c.sql))); +}); diff --git a/tests/task-group-service.test.mjs b/tests/task-group-service.test.mjs index 417a4a0..0c8a630 100644 --- a/tests/task-group-service.test.mjs +++ b/tests/task-group-service.test.mjs @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + assignTaskOwner, ensureTaskChatForMembers, sendTaskChatMessage, taskChatId, @@ -26,9 +27,10 @@ function fakeWecom(options = {}) { const calls = []; const chats = new Map( options.chats ?? [ - ["tgc_task-existing", { name: "【任务】已有群", userlist: ["owner-a"] }], + ["tgctaskexisting", { name: "【任务】已有群", userlist: ["owner-a"] }], ], ); + const failGet = options.failGet ?? false; const fetchImpl = async (input, init = {}) => { const url = new URL(String(input)); calls.push({ url, init }); @@ -57,6 +59,9 @@ function fakeWecom(options = {}) { return Response.json({ errcode: 0, errmsg: "ok" }); } if (url.pathname.endsWith("/cgi-bin/appchat/get")) { + if (failGet) { + return Response.json({ errcode: 86001, errmsg: "chatid invalid" }); + } const body = JSON.parse(String(init.body ?? "{}")); const chat = chats.get(body.chatid); if (!chat) { @@ -118,7 +123,8 @@ function fakeDb({ task }) { test("taskChatId derives a deterministic chatid within 32 chars", () => { const taskId = "task-1a2b3c4d"; const chatid = taskChatId(taskId); - assert.equal(chatid, `tgc_${taskId}`); + assert.equal(chatid, "tgctask1a2b3c4d"); + assert.ok(!/-/.test(chatid), "hyphens rejected by wecom chatid rules"); assert.ok(chatid.length <= 32); }); @@ -129,7 +135,8 @@ test("ensureTaskChatForMembers skips when project has no owner", async () => { id: "task-1", name: "任务一", task_chat_id: null, - owner_userid: null, + task_owner_userid: null, + project_owner_userid: null, }, }); const result = await ensureTaskChatForMembers( @@ -150,7 +157,8 @@ test("ensureTaskChatForMembers skips creation when fewer than 2 members", async id: "task-1", name: "任务一", task_chat_id: null, - owner_userid: "owner-a", + task_owner_userid: "owner-a", + project_owner_userid: null, }, }); const result = await ensureTaskChatForMembers( @@ -175,7 +183,8 @@ test("ensureTaskChatForMembers creates the chat lazily and stores chat id", asyn id: "task-1a2b3c4d", name: "任务一", task_chat_id: null, - owner_userid: "owner-a", + task_owner_userid: "owner-a", + project_owner_userid: null, }, }); const result = await ensureTaskChatForMembers( @@ -184,7 +193,7 @@ test("ensureTaskChatForMembers creates the chat lazily and stores chat id", asyn resolveWecomConfig(baseBindings), wecom.fetchImpl, ); - assert.equal(result.chatId, "tgc_task-1a2b3c4d"); + assert.equal(result.chatId, "tgctask1a2b3c4d"); assert.equal(result.created, true); const createBody = JSON.parse( String( @@ -197,20 +206,48 @@ test("ensureTaskChatForMembers creates the chat lazily and stores chat id", asyn statements.some( (statement) => /UPDATE tasks SET task_chat_id/.test(statement.sql) && - statement.values[0] === "tgc_task-1a2b3c4d", + statement.values[0] === "tgctask1a2b3c4d", ), "chat id persisted to tasks", ); }); +test("ensureTaskChatForMembers dedupes project owner, task owner and claimants", async () => { + const wecom = fakeWecom({ chats: [] }); + const { db } = fakeDb({ + task: { + id: "task-1", + name: "任务一", + task_chat_id: null, + task_owner_userid: "owner-a", + project_owner_userid: "owner-a", + }, + }); + const result = await ensureTaskChatForMembers( + db, + { taskId: "task-1", addUserids: ["owner-a", "zhangsan"] }, + resolveWecomConfig(baseBindings), + wecom.fetchImpl, + ); + assert.equal(result.chatId, "tgctask1"); + const createBody = JSON.parse( + String( + wecom.calls.find((c) => c.url.pathname.endsWith("/cgi-bin/appchat/create")) + .init.body, + ), + ); + assert.deepEqual(createBody.userlist.sort(), ["owner-a", "zhangsan"]); +}); + test("ensureTaskChatForMembers reuses stored chat and diffs members", async () => { const wecom = fakeWecom(); const { db, statements } = fakeDb({ task: { id: "task-existing", name: "已有任务", - task_chat_id: "tgc_task-existing", - owner_userid: "owner-a", + task_chat_id: "tgctaskexisting", + task_owner_userid: "owner-a", + project_owner_userid: null, }, }); const result = await ensureTaskChatForMembers( @@ -219,7 +256,7 @@ test("ensureTaskChatForMembers reuses stored chat and diffs members", async () = resolveWecomConfig(baseBindings), wecom.fetchImpl, ); - assert.equal(result.chatId, "tgc_task-existing"); + assert.equal(result.chatId, "tgctaskexisting"); assert.equal(result.created, false); assert.equal( statements.filter((s) => /UPDATE tasks/.test(s.sql)).length, @@ -235,14 +272,19 @@ test("ensureTaskChatForMembers reuses stored chat and diffs members", async () = assert.deepEqual(updateBody.add_user_list, ["zhangsan"]); }); -test("ensureTaskChatForMembers recovers when chat exists remotely but not in db", async () => { - const wecom = fakeWecom(); - const { db, statements } = fakeDb({ +test("ensureTaskChatForMembers syncs add-only when get is unavailable", async () => { + // 可见范围非根部门时 appchat/get 拿不到群成员,降级为全量只增同步 + const wecom = fakeWecom({ + failGet: true, + chats: [["tgctaskexisting", { name: "【任务】已有群", userlist: ["owner-a"] }]], + }); + const { db } = fakeDb({ task: { id: "task-existing", name: "已有任务", - task_chat_id: null, - owner_userid: "owner-a", + task_chat_id: "tgctaskexisting", + task_owner_userid: "owner-a", + project_owner_userid: null, }, }); const result = await ensureTaskChatForMembers( @@ -251,7 +293,35 @@ test("ensureTaskChatForMembers recovers when chat exists remotely but not in db" resolveWecomConfig(baseBindings), wecom.fetchImpl, ); - assert.equal(result.chatId, "tgc_task-existing"); + assert.equal(result.chatId, "tgctaskexisting"); + const updateBody = JSON.parse( + String( + wecom.calls.find((c) => c.url.pathname.endsWith("/cgi-bin/appchat/update")) + .init.body, + ), + ); + assert.deepEqual(updateBody.add_user_list.sort(), ["owner-a", "zhangsan"]); + assert.equal(updateBody.del_user_list, undefined, "add-only fallback"); +}); + +test("ensureTaskChatForMembers recovers when chat exists remotely but not in db", async () => { + const wecom = fakeWecom(); + const { db, statements } = fakeDb({ + task: { + id: "task-existing", + name: "已有任务", + task_chat_id: null, + task_owner_userid: "owner-a", + project_owner_userid: null, + }, + }); + const result = await ensureTaskChatForMembers( + db, + { taskId: "task-existing", addUserids: ["zhangsan"] }, + resolveWecomConfig(baseBindings), + wecom.fetchImpl, + ); + assert.equal(result.chatId, "tgctaskexisting"); assert.equal(result.created, false); assert.ok(statements.some((s) => /UPDATE tasks SET task_chat_id/.test(s.sql))); }); @@ -263,7 +333,8 @@ test("ensureTaskChatForMembers skips without app credentials", async () => { id: "task-1", name: "任务一", task_chat_id: null, - owner_userid: "owner-a", + task_owner_userid: "owner-a", + project_owner_userid: null, }, }); const result = await ensureTaskChatForMembers( @@ -295,7 +366,7 @@ test("sendTaskChatMessage no-ops without chat id and swallows errors", async () const failing = fakeWecom({ chats: [] }); const withChat = fakeDb({ - task: { id: "task-existing", task_chat_id: "tgc_task-existing" }, + task: { id: "task-existing", task_chat_id: "tgctaskexisting" }, }); assert.equal( await sendTaskChatMessage( @@ -313,7 +384,7 @@ test("sendTaskChatMessage no-ops without chat id and swallows errors", async () test("sendTaskChatMessage sends text into the task chat", async () => { const wecom = fakeWecom(); const { db } = fakeDb({ - task: { id: "task-existing", task_chat_id: "tgc_task-existing" }, + task: { id: "task-existing", task_chat_id: "tgctaskexisting" }, }); assert.equal( await sendTaskChatMessage( @@ -331,7 +402,7 @@ test("sendTaskChatMessage sends text into the task chat", async () => { .init.body, ), ); - assert.equal(sendBody.chatid, "tgc_task-existing"); + assert.equal(sendBody.chatid, "tgctaskexisting"); assert.equal(sendBody.text.content, "【领取】张三 认领 5 份"); }); @@ -350,3 +421,219 @@ test("broadcast and intro text builders format messages", () => { assert.match(intro, /品牌:品牌A/); assert.match(intro, /截止:2026-09-30/); }); + +function assignDb({ task, project, partners = [] }) { + const store = { + task: { ...task }, + project: project ? { ...project } : null, + partners: partners.map((item) => ({ ...item })), + }; + const statements = []; + const db = { + prepare(sql) { + return { + bind(...values) { + return { + async first() { + statements.push({ sql, values }); + if (/FROM tasks t\s+LEFT JOIN projects p/.test(sql)) { + return { + id: store.task.id, + name: store.task.name, + task_chat_id: store.task.task_chat_id ?? null, + task_owner_userid: store.task.owner_userid ?? null, + project_owner_userid: store.project?.owner_userid ?? null, + }; + } + if (/SELECT task_chat_id FROM tasks/.test(sql)) { + return { task_chat_id: store.task.task_chat_id ?? null }; + } + if (/SELECT id, name, brand, quantity, due_at, project_id FROM tasks/.test(sql)) { + return { ...store.task }; + } + if (/SELECT main_group_member_ids, owner_userid FROM projects/.test(sql)) { + return store.project + ? { + main_group_member_ids: store.project.main_group_member_ids, + owner_userid: store.project.owner_userid ?? "", + } + : null; + } + if (/SELECT id, name, main_group_member_ids, owner_userid, main_chat_id FROM projects/.test(sql)) { + return store.project + ? { + id: store.project.id, + name: store.project.name, + main_group_member_ids: store.project.main_group_member_ids, + owner_userid: store.project.owner_userid ?? "", + main_chat_id: store.project.main_chat_id ?? "", + } + : null; + } + if (/partner_type = 'group_owner' AND wecom_userid = \?/.test(sql)) { + return ( + store.partners.find( + (item) => + item.partner_type === "group_owner" && + item.wecom_userid === values[0], + ) ?? null + ); + } + if (/FROM partners WHERE id = \?/.test(sql)) { + return store.partners.find((item) => item.id === values[0]) ?? null; + } + return null; + }, + async run() { + statements.push({ sql, values }); + if (/UPDATE tasks SET owner_userid/.test(sql)) { + store.task.owner_userid = values[0]; + } else if (/UPDATE tasks SET task_chat_id/.test(sql)) { + store.task.task_chat_id = values[0]; + } else if (/UPDATE projects SET main_group_member_ids/.test(sql)) { + if (store.project) store.project.main_group_member_ids = values[0]; + } else if (/UPDATE projects SET main_chat_id/.test(sql)) { + if (store.project) store.project.main_chat_id = values[0]; + } else if (/INSERT INTO partners/.test(sql)) { + store.partners.push({ + id: values[0], + name: values[1], + wecom_userid: values[2], + partner_type: "group_owner", + }); + } + return { meta: { changes: 1, last_row_id: 0 } }; + }, + async all() { + statements.push({ sql, values }); + return { results: [] }; + }, + }; + }, + }; + }, + }; + return { db, statements, store }; +} + +test("assignTaskOwner provisions partner, main chat and task chat with intro", async () => { + const wecom = fakeWecom({ chats: [] }); + const { db, statements, store } = assignDb({ + task: { + id: "task-1", + name: "任务一", + brand: "品牌A", + quantity: 10, + due_at: "2026-09-30", + project_id: "proj-1", + owner_userid: "", + task_chat_id: null, + }, + project: { + id: "proj-1", + name: "项目一", + owner_userid: "proj-owner", + main_group_member_ids: JSON.stringify(["proj-owner", "u1", "u2"]), + main_chat_id: "", + }, + }); + const result = await assignTaskOwner( + db, + { taskId: "task-1", ownerUserid: "owner-a" }, + resolveWecomConfig(baseBindings), + wecom.fetchImpl, + ); + assert.equal(result.ownerUserid, "owner-a"); + assert.equal(result.chatCreated, true); + assert.equal(result.chatId, "tgctask1"); + assert.match(result.partnerId, /^partner-go-/); + assert.equal(store.task.owner_userid, "owner-a"); + assert.deepEqual( + JSON.parse(store.project.main_group_member_ids).sort(), + ["proj-owner", "u1", "u2", "owner-a"].sort(), + "task owner merged into main group members", + ); + assert.equal(store.project.main_chat_id, "pgcproj1", "main chat created lazily"); + assert.ok(wecom.chats.has("pgcproj1")); + assert.ok(wecom.chats.has("tgctask1")); + const sendBodies = wecom.calls + .filter((c) => c.url.pathname.endsWith("/cgi-bin/appchat/send")) + .map((c) => JSON.parse(String(c.init.body))); + assert.equal(sendBodies.length, 1, "intro message sent once to task chat"); + assert.equal(sendBodies[0].chatid, "tgctask1"); + assert.match(sendBodies[0].text.content, /【新任务】任务一/); + assert.ok( + statements.some((s) => /INSERT INTO partners/.test(s.sql)), + "partner insert recorded", + ); +}); + +test("assignTaskOwner reuses the existing group_owner partner", async () => { + const wecom = fakeWecom({ chats: [] }); + const { db, statements, store } = assignDb({ + task: { + id: "task-1", + name: "任务一", + brand: "品牌A", + quantity: 10, + due_at: "2026-09-30", + project_id: "", + owner_userid: "", + task_chat_id: null, + }, + partners: [ + { id: "partner-go-existing", name: "老负责人", wecom_userid: "owner-a", partner_type: "group_owner" }, + ], + }); + const result = await assignTaskOwner( + db, + { taskId: "task-1", ownerUserid: "owner-a" }, + resolveWecomConfig(baseBindings), + wecom.fetchImpl, + ); + assert.equal(result.partnerId, "partner-go-existing"); + assert.equal( + statements.filter((s) => /INSERT INTO partners/.test(s.sql)).length, + 0, + "no duplicate partner insert", + ); + assert.equal(store.partners.length, 1); +}); + +test("assignTaskOwner collects wecom failure as warning instead of throwing", async () => { + const fetchImpl = async (input) => { + const url = new URL(String(input)); + if (url.pathname.endsWith("/cgi-bin/gettoken")) { + return Response.json({ errcode: 0, errmsg: "ok", access_token: "t", expires_in: 7200 }); + } + return Response.json({ errcode: 60111, errmsg: "chat not found" }); + }; + const { db, store } = assignDb({ + task: { + id: "task-1", + name: "任务一", + brand: "品牌A", + quantity: 10, + due_at: "2026-09-30", + project_id: "proj-1", + owner_userid: "", + task_chat_id: null, + }, + project: { + id: "proj-1", + name: "项目一", + owner_userid: "proj-owner", + main_group_member_ids: JSON.stringify(["u1", "u2"]), + main_chat_id: "", + }, + }); + const result = await assignTaskOwner( + db, + { taskId: "task-1", ownerUserid: "owner-a" }, + resolveWecomConfig(baseBindings), + fetchImpl, + ); + assert.equal(result.ownerUserid, "owner-a"); + assert.ok(result.warning, "wecom failure surfaced as warning"); + assert.equal(store.task.owner_userid, "owner-a", "owner still persisted"); +}); diff --git a/tests/task-one-click-push.test.mjs b/tests/task-one-click-push.test.mjs deleted file mode 100644 index 443d416..0000000 --- a/tests/task-one-click-push.test.mjs +++ /dev/null @@ -1,250 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { - buildKocDirectMessage, - oneClickPushTask, - OneClickPushError, -} from "../lib/task-one-click-push-service.ts"; -import { - clearWecomAccessTokenCacheForTests, - resolveWecomConfig, -} from "../lib/wecom-client.ts"; - -const baseBindings = { - WECOM_CORP_ID: "corp-test", - WECOM_AGENT_ID: "1000001", - WECOM_SECRET: "secret-test", -}; - -const taskRow = { - id: "task-1", - name: "测试任务", - brand: "品牌A", - quantity: 3, - due_at: "2026-09-05", - task_type: "content_publish", - platform: "小红书", - content_format: "image_text", - share_token: "tok-123", - project_id: "", -}; - -const boundPartners = [ - { id: "p-1", name: "合作方甲", wecom_external_user_id: "wmOK1" }, - { id: "p-2", name: "合作方乙", wecom_external_user_id: "wmOK2" }, - { id: "p-3", name: "合作方丙", wecom_external_user_id: "wmFAIL" }, -]; - -function fakePushDb({ - task = taskRow, - partners = boundPartners, - todayLog = null, -} = {}) { - const executed = []; - const makeStatement = (sql, params = []) => ({ - sql, - params, - bind(...args) { - return makeStatement(sql, args); - }, - async run() { - executed.push({ sql, params }); - return { meta: { changes: 1 } }; - }, - async first() { - executed.push({ sql, params }); - if (/FROM tasks WHERE id = \?/.test(sql)) return task; - if (/FROM wecom_push_logs/.test(sql) && /DATE\(created_at\) = CURDATE\(\)/.test(sql)) { - return todayLog; - } - return null; - }, - async all() { - executed.push({ sql, params }); - if (/FROM partners/.test(sql) && /wecom_external_user_id/.test(sql)) { - return { results: partners }; - } - return { results: [] }; - }, - }); - return { - executed, - prepare(sql) { - return makeStatement(sql); - }, - }; -} - -function fakeWecomFetch(calls, { invalidUsers = [] } = {}) { - return async (input, init = {}) => { - const url = new URL(String(input)); - calls.push({ url, init }); - if (url.pathname.endsWith("/cgi-bin/gettoken")) { - return Response.json({ - errcode: 0, - errmsg: "ok", - access_token: "token-test", - expires_in: 7200, - }); - } - if (url.pathname.endsWith("/cgi-bin/message/send")) { - const body = JSON.parse(String(init.body)); - if (invalidUsers.includes(body.touser)) { - return Response.json({ - errcode: 0, - errmsg: "ok", - invaliduser: body.touser, - }); - } - return Response.json({ errcode: 0, errmsg: "ok" }); - } - return new Response("not found", { status: 404 }); - }; -} - -test.beforeEach(() => { - clearWecomAccessTokenCacheForTests(); -}); - -test("buildKocDirectMessage targets KOC with claim url and OAuth hint", () => { - const message = buildKocDirectMessage(taskRow, "https://portal.example.com/"); - assert.match(message, /【新任务】测试任务/); - assert.match(message, /先到先得/); - assert.match(message, /免输身份/); - assert.match(message, /https:\/\/portal\.example\.com\/\?task=tok-123/); -}); - -test("oneClickPushTask sends only to selected partners and logs the push", async () => { - process.env.KOC_PORTAL_URL = "https://portal.example.com"; - try { - const db = fakePushDb(); - const calls = []; - const outcome = await oneClickPushTask( - db, - { - taskId: "task-1", - sendMainGroup: false, - sendKoc: true, - createGroupTemplate: false, - selectedKocPartnerIds: ["p-1", "p-3"], - }, - resolveWecomConfig(baseBindings), - fakeWecomFetch(calls, { invalidUsers: ["wmFAIL"] }), - ); - const appCalls = calls.filter((call) => - call.url.pathname.endsWith("/cgi-bin/message/send"), - ); - assert.deepEqual( - appCalls.map((call) => JSON.parse(String(call.init.body)).touser), - ["wmOK1", "wmFAIL"], - ); - for (const call of appCalls) { - assert.match(JSON.parse(String(call.init.body)).text.content, /【新任务】测试任务/); - } - assert.equal(outcome.kocSent, 1); - assert.equal(outcome.kocFailed, 1); - assert.equal(outcome.kocResults[1].partnerName, "合作方丙"); - assert.match(outcome.kocResults[1].error, /invaliduser/); - assert.equal(outcome.linkUrl, "https://portal.example.com/?task=tok-123"); - - const insert = db.executed.find((item) => /INSERT INTO wecom_push_logs/.test(item.sql)); - assert.ok(insert, "push log should be written"); - assert.equal(insert.params[1], "task-1"); - assert.equal(insert.params[3], "task_open"); - assert.equal(insert.params[6], 1); - const kocUserids = JSON.parse(insert.params[5]); - assert.equal(kocUserids.length, 2); - assert.equal(kocUserids[0].externalUserId, "wmOK1"); - } finally { - delete process.env.KOC_PORTAL_URL; - } -}); - -test("oneClickPushTask blocks duplicate same-day push unless forced", async () => { - process.env.KOC_PORTAL_URL = "https://portal.example.com"; - try { - const db = fakePushDb({ - todayLog: { id: "wpl-old", task_id: "task-1", push_kind: "task_open" }, - }); - const calls = []; - await assert.rejects( - () => - oneClickPushTask( - db, - { taskId: "task-1", sendMainGroup: false, sendKoc: true }, - resolveWecomConfig(baseBindings), - fakeWecomFetch(calls), - ), - (error) => - error instanceof OneClickPushError && /仍要推送/.test(error.message) && error.status === 409, - ); - assert.equal(calls.length, 0, "no wecom api calls on duplicate block"); - - const outcome = await oneClickPushTask( - db, - { - taskId: "task-1", - sendMainGroup: false, - sendKoc: true, - selectedKocPartnerIds: ["p-2"], - force: true, - }, - resolveWecomConfig(baseBindings), - fakeWecomFetch(calls), - ); - assert.equal(outcome.kocSent, 1); - } finally { - delete process.env.KOC_PORTAL_URL; - } -}); - -test("oneClickPushTask without project skips project targets and still reaches KOC", async () => { - process.env.KOC_PORTAL_URL = "https://portal.example.com"; - try { - const db = fakePushDb({ task: { ...taskRow, project_id: "" } }); - const calls = []; - const outcome = await oneClickPushTask( - db, - { taskId: "task-1", sendMainGroup: true, sendKoc: true }, - resolveWecomConfig(baseBindings), - fakeWecomFetch(calls), - ); - assert.ok( - outcome.skippedReasons.some((reason) => /未挂项目/.test(reason)), - "should explain project skip", - ); - assert.equal(outcome.projectOutcome, null); - assert.equal(outcome.kocSent, 3); - assert.equal( - calls.filter((call) => call.url.pathname.includes("/cgi-bin/externalcontact/")).length, - 0, - "no group template calls expected", - ); - } finally { - delete process.env.KOC_PORTAL_URL; - } -}); - -test("oneClickPushTask requires share token and at least one target", async () => { - await assert.rejects( - () => - oneClickPushTask( - fakePushDb({ task: { ...taskRow, share_token: "" } }), - { taskId: "task-1", sendMainGroup: false, sendKoc: true }, - resolveWecomConfig(baseBindings), - fakeWecomFetch([]), - ), - (error) => error instanceof OneClickPushError && /领取链接/.test(error.message), - ); - - await assert.rejects( - () => - oneClickPushTask( - fakePushDb(), - { taskId: "task-1", sendMainGroup: false, sendKoc: false, createGroupTemplate: false }, - resolveWecomConfig(baseBindings), - fakeWecomFetch([]), - ), - (error) => error instanceof OneClickPushError && /至少选择一个推送目标/.test(error.message), - ); -}); diff --git a/tests/task-update.test.mjs b/tests/task-update.test.mjs new file mode 100644 index 0000000..de9c640 --- /dev/null +++ b/tests/task-update.test.mjs @@ -0,0 +1,366 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { updateTask } from "../lib/task-service.ts"; + +function fakeDb({ task, contents = [] }) { + const tasks = task ? [task] : []; + const rows = contents.map((row) => ({ ...row })); + const db = { + prepare(sql) { + return { + bind(...values) { + return { + async first() { + if (/FROM tasks WHERE id = \?/.test(sql)) { + return tasks.find((row) => row.id === values[0]) ?? null; + } + if (/SELECT image_assets FROM contents/.test(sql)) { + const sorted = rows + .filter((row) => row.task_id === values[0]) + .sort((a, b) => a.source_row - b.source_row); + return sorted[0] ? { image_assets: sorted[0].image_assets } : null; + } + return null; + }, + async run() { + const normalized = sql.trim(); + if (/^UPDATE tasks/.test(normalized)) { + const [name, brand, due_at, project_id, platform, content_format, quantity, id] = + values; + const row = tasks.find((item) => item.id === id); + if (!row) return { meta: { changes: 0 } }; + Object.assign(row, { + name, + brand, + due_at, + project_id, + platform, + content_format, + quantity, + }); + return { meta: { changes: 1 } }; + } + if (/^INSERT INTO contents/.test(normalized)) { + const [id, task_id, title, body, image_assets, source_row] = values; + rows.push({ + id, + task_id, + title, + body, + image_assets, + status: "available", + source_row, + }); + return { meta: { changes: 1 } }; + } + if (/^DELETE FROM contents/.test(normalized)) { + const [taskId, limit] = values; + const available = rows + .filter((row) => row.task_id === taskId && row.status === "available") + .sort((a, b) => b.source_row - a.source_row); + const doomed = new Set(available.slice(0, limit).map((row) => row.id)); + for (let index = rows.length - 1; index >= 0; index -= 1) { + if (doomed.has(rows[index].id)) rows.splice(index, 1); + } + return { meta: { changes: doomed.size } }; + } + if (/^UPDATE contents SET title/.test(normalized)) { + const [title, body, taskId] = values; + for (const row of rows) { + if (row.task_id === taskId) { + row.title = title; + row.body = body; + } + } + return { meta: { changes: rows.length } }; + } + if (/^UPDATE contents SET image_assets/.test(normalized)) { + const [image_assets, taskId] = values; + for (const row of rows) { + if (row.task_id === taskId) row.image_assets = image_assets; + } + return { meta: { changes: rows.length } }; + } + return { meta: { changes: 0 } }; + }, + async all() { + return { results: [] }; + }, + }; + }, + }; + }, + async batch(statements) { + const results = []; + for (const statement of statements) { + results.push(await statement.run()); + } + return results; + }, + }; + return { db, tasks, contents: rows }; +} + +function screenshotTask(overrides = {}) { + return { + id: "task-shot-1", + task_type: "screenshot_collect", + quantity: 3, + claimed_quantity: 0, + platform: "小红书", + content_format: "image_text", + name: "旧名称", + brand: "旧品牌", + due_at: "2026-08-01", + project_id: "", + ...overrides, + }; +} + +function screenshotContents(quantity, overrides = {}) { + return Array.from({ length: quantity }, (_, index) => ({ + id: `content-${index + 1}`, + task_id: "task-shot-1", + title: overrides.title ?? "旧关键词", + body: overrides.body ?? "旧说明", + image_assets: overrides.imageAssets ?? "[]", + status: index < (overrides.claimed ?? 0) ? "claimed" : "available", + source_row: index + 1, + })); +} + +test("updateTask rewrites basic fields and syncs keyword/instructions on all rows", async () => { + const { db, tasks, contents } = fakeDb({ + task: screenshotTask(), + contents: screenshotContents(3), + }); + const result = await updateTask( + { + taskId: "task-shot-1", + name: "新名称", + brand: "新品牌", + dueAt: "2026-09-30", + projectId: "proj-1", + keyword: "新关键词", + instructions: "新说明", + quantity: 3, + }, + db, + ); + assert.equal(result.quantity, 3); + assert.equal(tasks[0].name, "新名称"); + assert.equal(tasks[0].brand, "新品牌"); + assert.equal(tasks[0].due_at, "2026-09-30"); + assert.equal(tasks[0].project_id, "proj-1"); + assert.equal(contents.length, 3); + for (const row of contents) { + assert.equal(row.title, "新关键词"); + assert.equal(row.body, "新说明"); + } +}); + +test("updateTask grows placeholder contents when quantity increases", async () => { + const imageAssets = JSON.stringify([{ index: 1, key: "task-assets/a.png", width: null, height: null }]); + const { db, tasks, contents } = fakeDb({ + task: screenshotTask(), + contents: screenshotContents(3, { imageAssets }), + }); + await updateTask( + { + taskId: "task-shot-1", + name: "n", + brand: "b", + dueAt: "2026-09-30", + keyword: "k", + instructions: "i", + quantity: 5, + }, + db, + ); + assert.equal(tasks[0].quantity, 5); + assert.equal(contents.length, 5); + const added = contents.filter((row) => row.source_row > 3); + assert.equal(added.length, 2); + for (const row of added) { + assert.equal(row.status, "available"); + assert.equal(row.image_assets, imageAssets, "new rows copy example image"); + assert.equal(row.title, "k"); + } +}); + +test("updateTask shrinks only available tail rows when quantity decreases", async () => { + const { db, tasks, contents } = fakeDb({ + task: screenshotTask({ quantity: 5, claimed_quantity: 2 }), + contents: screenshotContents(5, { claimed: 2 }), + }); + await updateTask( + { + taskId: "task-shot-1", + name: "n", + brand: "b", + dueAt: "2026-09-30", + keyword: "k", + instructions: "i", + quantity: 3, + }, + db, + ); + assert.equal(tasks[0].quantity, 3); + assert.equal(contents.length, 3); + const claimedRows = contents.filter((row) => row.status === "claimed"); + assert.equal(claimedRows.length, 2, "claimed rows preserved"); + assert.ok( + contents.every((row) => row.source_row <= 3), + "tail rows removed", + ); +}); + +test("updateTask rejects quantity below claimed_quantity", async () => { + const { db } = fakeDb({ + task: screenshotTask({ quantity: 5, claimed_quantity: 2 }), + contents: screenshotContents(5, { claimed: 2 }), + }); + await assert.rejects( + () => + updateTask( + { + taskId: "task-shot-1", + name: "n", + brand: "b", + dueAt: "2026-09-30", + keyword: "k", + instructions: "i", + quantity: 1, + }, + db, + ), + /不能低于已领取的 2 份/, + ); +}); + +test("updateTask validates screenshot quantity and keyword", async () => { + const { db } = fakeDb({ task: screenshotTask(), contents: screenshotContents(3) }); + await assert.rejects( + () => + updateTask( + { taskId: "task-shot-1", name: "n", brand: "b", dueAt: "2026-09-30", keyword: "k", instructions: "i", quantity: 0 }, + db, + ), + /1—500/, + ); + await assert.rejects( + () => + updateTask( + { taskId: "task-shot-1", name: "n", brand: "b", dueAt: "2026-09-30", keyword: " ", instructions: "i", quantity: 3 }, + db, + ), + /请补全搜索关键词和任务说明/, + ); +}); + +test("updateTask replaces example image across contents when re-uploaded", async () => { + const { db, contents } = fakeDb({ + task: screenshotTask(), + contents: screenshotContents(3), + }); + await updateTask( + { + taskId: "task-shot-1", + name: "n", + brand: "b", + dueAt: "2026-09-30", + keyword: "k", + instructions: "i", + quantity: 3, + exampleImageKey: "task-assets/new.png", + }, + db, + ); + for (const row of contents) { + assert.equal(JSON.parse(row.image_assets)[0].key, "task-assets/new.png"); + } +}); + +test("updateTask ignores quantity and keyword for content_publish tasks", async () => { + const { db, tasks, contents } = fakeDb({ + task: screenshotTask({ + id: "task-pub-1", + task_type: "content_publish", + quantity: 18, + platform: "抖音", + content_format: "video", + }), + contents: [ + { + id: "content-pub-1", + task_id: "task-pub-1", + title: "笔记标题", + body: "笔记正文", + image_assets: "[]", + status: "available", + source_row: 1, + }, + ], + }); + await updateTask( + { + taskId: "task-pub-1", + name: "新名称", + brand: "新品牌", + dueAt: "2026-09-30", + projectId: "proj-2", + quantity: 99, + keyword: "ignored", + instructions: "ignored", + }, + db, + ); + assert.equal(tasks[0].quantity, 18, "feishu row count untouched"); + assert.equal(tasks[0].platform, "抖音", "platform kept when not provided"); + assert.equal(tasks[0].content_format, "video"); + assert.equal(contents[0].title, "笔记标题", "contents untouched"); +}); + +test("updateTask applies provided platform/contentFormat for content_publish", async () => { + const { db, tasks } = fakeDb({ + task: screenshotTask({ + id: "task-pub-1", + task_type: "content_publish", + platform: "小红书", + content_format: "image_text", + }), + }); + await updateTask( + { + taskId: "task-pub-1", + name: "n", + brand: "b", + dueAt: "2026-09-30", + platform: "抖音", + contentFormat: "video", + }, + db, + ); + assert.equal(tasks[0].platform, "抖音"); + assert.equal(tasks[0].content_format, "video"); +}); + +test("updateTask rejects unknown tasks and invalid dates", async () => { + const { db } = fakeDb({ task: screenshotTask() }); + await assert.rejects( + () => + updateTask( + { taskId: "missing", name: "n", brand: "b", dueAt: "2026-09-30" }, + db, + ), + /任务不存在/, + ); + await assert.rejects( + () => + updateTask( + { taskId: "task-shot-1", name: "n", brand: "b", dueAt: "2026-13-01" }, + db, + ), + /截止日期/, + ); +}); diff --git a/tests/wecom-client.test.mjs b/tests/wecom-client.test.mjs index d3dbef7..25b2292 100644 --- a/tests/wecom-client.test.mjs +++ b/tests/wecom-client.test.mjs @@ -1,13 +1,16 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + addTaskJoinWay, buildWecomOAuthUrl, clearWecomAccessTokenCacheForTests, createAppChat, createGroupMsgTemplate, + deleteJoinWay, exchangeWecomOAuthCode, fetchWecomExternalContactName, getAppChat, + getJoinWayQrCode, listCustomerGroupChats, listExternalContacts, listInternalContacts, @@ -16,6 +19,7 @@ import { sendWecomAppMessage, sendWecomRobotMessage, updateAppChat, + updateTaskJoinWay, WecomClientError, } from "../lib/wecom-client.ts"; @@ -428,6 +432,14 @@ function fakeGroupPush(options = {}) { "wr-normal-1": { name: "KOC 一群", owner: "zhangsan", member_count: 42 }, "wr-normal-2": { name: "KOC 二群", owner: "lisi", member_count: 7 }, }; + const joinWay = { + addErrcode: options.joinAddErrcode ?? 0, + updateErrcode: options.joinUpdateErrcode ?? 0, + getErrcode: options.joinGetErrcode ?? 0, + delErrcode: options.joinDelErrcode ?? 0, + qrCode: options.joinQrCode ?? "https://p.qpic.cn/qr-test.png", + configId: options.joinConfigId ?? "cfgtest123", + }; const fetchImpl = async (input, init = {}) => { const url = new URL(String(input)); calls.push({ url, init }); @@ -466,6 +478,34 @@ function fakeGroupPush(options = {}) { fail_list: options.failList ?? [], }); } + if (url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/add_join_way")) { + if (joinWay.addErrcode !== 0) { + return Response.json({ errcode: joinWay.addErrcode, errmsg: "add failed" }); + } + return Response.json({ errcode: 0, errmsg: "ok", config_id: joinWay.configId }); + } + if (url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/get_join_way")) { + if (joinWay.getErrcode !== 0) { + return Response.json({ errcode: joinWay.getErrcode, errmsg: "get failed" }); + } + return Response.json({ + errcode: 0, + errmsg: "ok", + join_way: { qr_code: joinWay.qrCode, scene: 2 }, + }); + } + if (url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/update_join_way")) { + if (joinWay.updateErrcode !== 0) { + return Response.json({ errcode: joinWay.updateErrcode, errmsg: "update failed" }); + } + return Response.json({ errcode: 0, errmsg: "ok" }); + } + if (url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/del_join_way")) { + if (joinWay.delErrcode !== 0) { + return Response.json({ errcode: joinWay.delErrcode, errmsg: "del failed" }); + } + return Response.json({ errcode: 0, errmsg: "ok" }); + } return new Response("not found", { status: 404 }); }; return { calls, fetchImpl }; @@ -524,6 +564,118 @@ test("listCustomerGroupChats skips groups whose detail lookup fails", async () = ); }); +test("listCustomerGroupChats sends owner_filter when ownerUserids given", async () => { + const { calls, fetchImpl } = fakeGroupPush({ + pages: [{ next_cursor: "", group_chat_list: [{ chat_id: "wr-normal-1", status: 0 }] }], + details: { + "wr-normal-1": { name: "KOC 一群", owner: "zhangsan", member_count: 42 }, + }, + }); + const groups = await listCustomerGroupChats( + resolveWecomConfig(baseBindings), + fetchImpl, + ["zhangsan", ""], + ); + assert.equal(groups.length, 1); + const listCall = calls.find((c) => + c.url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/list"), + ); + const body = JSON.parse(String(listCall.init.body)); + assert.deepEqual(body.owner_filter, { userid_list: ["zhangsan"] }); +}); + +test("addTaskJoinWay posts scene/auto_create_room/payload and returns configId", async () => { + const { calls, fetchImpl } = fakeGroupPush(); + const result = await addTaskJoinWay( + resolveWecomConfig(baseBindings), + { + chatIdList: ["wr-normal-1"], + roomBaseName: "每".repeat(50), + state: "task-task-baf7fd59", + }, + fetchImpl, + ); + assert.equal(result.configId, "cfgtest123"); + const call = calls.find((c) => + c.url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/add_join_way"), + ); + assert.ok(call, "add_join_way was called"); + assert.equal(call.url.searchParams.get("access_token"), "token-test"); + const body = JSON.parse(String(call.init.body)); + assert.equal(body.scene, 2); + assert.equal(body.auto_create_room, 1); + assert.deepEqual(body.chat_id_list, ["wr-normal-1"]); + assert.equal(body.room_base_name.length, 40, "room_base_name truncated to 40 code points"); + assert.equal(body.state, "task-task-baf7fd59"); +}); + +test("addTaskJoinWay surfaces upstream errcode as WecomClientError", async () => { + const { fetchImpl } = fakeGroupPush({ joinAddErrcode: 60011 }); + await assert.rejects( + () => + addTaskJoinWay( + resolveWecomConfig(baseBindings), + { chatIdList: ["wr-normal-1"], roomBaseName: "群", state: "task-t1" }, + fetchImpl, + ), + (error) => + error instanceof WecomClientError && /创建企业微信进群活码失败/.test(error.message), + ); +}); + +test("getJoinWayQrCode returns qr_code url from join_way", async () => { + const { calls, fetchImpl } = fakeGroupPush({ joinQrCode: "https://p.qpic.cn/qr-x.png" }); + const result = await getJoinWayQrCode( + resolveWecomConfig(baseBindings), + "cfgtest123", + fetchImpl, + ); + assert.equal(result.qrCode, "https://p.qpic.cn/qr-x.png"); + const call = calls.find((c) => + c.url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/get_join_way"), + ); + assert.deepEqual(JSON.parse(String(call.init.body)), { config_id: "cfgtest123" }); +}); + +test("updateTaskJoinWay re-sends full body including scene for overwrite semantics", async () => { + const { calls, fetchImpl } = fakeGroupPush(); + await updateTaskJoinWay( + resolveWecomConfig(baseBindings), + { + configId: "cfgtest123", + chatIdList: ["wr-normal-2"], + roomBaseName: "新群名", + state: "task-t2", + }, + fetchImpl, + ); + const call = calls.find((c) => + c.url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/update_join_way"), + ); + const body = JSON.parse(String(call.init.body)); + assert.equal(body.config_id, "cfgtest123"); + assert.equal(body.scene, 2); + assert.equal(body.auto_create_room, 1); + assert.deepEqual(body.chat_id_list, ["wr-normal-2"]); + assert.equal(body.room_base_name, "新群名"); +}); + +test("deleteJoinWay tolerates upstream rejection but still calls del endpoint", async () => { + const { calls, fetchImpl } = fakeGroupPush({ joinDelErrcode: 40068 }); + await deleteJoinWay(resolveWecomConfig(baseBindings), "cfgtest123", fetchImpl); + const call = calls.find((c) => + c.url.pathname.endsWith("/cgi-bin/externalcontact/groupchat/del_join_way"), + ); + assert.ok(call, "del_join_way was called"); + assert.deepEqual(JSON.parse(String(call.init.body)), { config_id: "cfgtest123" }); +}); + +test("deleteJoinWay skips empty configId", async () => { + const { calls, fetchImpl } = fakeGroupPush(); + await deleteJoinWay(resolveWecomConfig(baseBindings), " ", fetchImpl); + assert.equal(calls.length, 0, "no API calls for empty config id"); +}); + test("createGroupMsgTemplate posts group payload with text and link attachment", async () => { const { calls, fetchImpl } = fakeGroupPush(); const result = await createGroupMsgTemplate( @@ -834,6 +986,7 @@ function fakeAppChat(options = {}) { ["tgc_task-existing", { name: "【任务】已有群", userlist: ["owner-a"] }], ], ); + const createExisted = options.createExisted ?? false; const fetchImpl = async (input, init = {}) => { const url = new URL(String(input)); calls.push({ url, init }); @@ -846,6 +999,9 @@ function fakeAppChat(options = {}) { }); } if (url.pathname.endsWith("/cgi-bin/appchat/create")) { + if (createExisted) { + return Response.json({ errcode: 86215, errmsg: "chatid existed" }); + } const body = JSON.parse(String(init.body ?? "{}")); chats.set(body.chatid, { name: body.name, userlist: body.userlist }); return Response.json({ errcode: 0, errmsg: "ok", chatid: body.chatid }); @@ -893,15 +1049,15 @@ test("createAppChat posts name/userlist/chatid and returns chatid", async () => const { calls, fetchImpl } = fakeAppChat(); const result = await createAppChat( resolveWecomConfig(baseBindings), - { name: "【任务】任务名超长会被截断到二十个字符", userlist: ["owner-a", "owner-b"], chatid: "tgc_task-new" }, + { name: "【任务】任务名超长会被截断到二十个字符", userlist: ["owner-a", "owner-b"], chatid: "tgcnewchat" }, fetchImpl, ); - assert.equal(result.chatid, "tgc_task-new"); + assert.equal(result.chatid, "tgcnewchat"); const call = calls.find((c) => c.url.pathname.endsWith("/cgi-bin/appchat/create")); assert.ok(call, "appchat/create was called"); assert.equal(call.url.searchParams.get("access_token"), "token-test"); const body = JSON.parse(String(call.init.body)); - assert.equal(body.chatid, "tgc_task-new"); + assert.equal(body.chatid, "tgcnewchat"); assert.equal(body.userlist.length, 2); assert.ok(body.name.length <= 20, "chat name truncated to 20 chars"); }); @@ -925,6 +1081,16 @@ test("createAppChat rejects fewer than 2 members and oversized chatid", async () assert.equal(calls.length, 0, "no API calls for invalid payload"); }); +test("createAppChat treats chatid existed (86215) as success", async () => { + const { fetchImpl } = fakeAppChat({ createExisted: true }); + const result = await createAppChat( + resolveWecomConfig(baseBindings), + { name: "【总群】项目一", userlist: ["a", "b"], chatid: "pgcproj1" }, + fetchImpl, + ); + assert.equal(result.chatid, "pgcproj1"); +}); + test("updateAppChat adds and removes members", async () => { const { calls, fetchImpl, chats } = fakeAppChat(); await updateAppChat( @@ -962,7 +1128,7 @@ test("getAppChat returns member list or null on error", async () => { assert.equal(info.chatId, "tgc_task-existing"); assert.deepEqual(info.userlist, ["owner-a"]); assert.equal( - await getAppChat(resolveWecomConfig(baseBindings), "tgc_task-missing", fetchImpl), + await getAppChat(resolveWecomConfig(baseBindings), "tgcmissingchat", fetchImpl), null, ); }); @@ -988,7 +1154,7 @@ test("sendAppChatMessage throws WecomClientError when provider errors", async () () => sendAppChatMessage( resolveWecomConfig(baseBindings), - { chatid: "tgc_task-missing", text: "hello" }, + { chatid: "tgcmissingchat", text: "hello" }, fetchImpl, ), (error) => { diff --git a/tests/wecom-notifier.test.mjs b/tests/wecom-notifier.test.mjs index 6250993..3dff534 100644 --- a/tests/wecom-notifier.test.mjs +++ b/tests/wecom-notifier.test.mjs @@ -1,6 +1,9 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { computeDueCutoff } from "../lib/wecom-notifier-service.ts"; +import { + computeDueCutoff, + runDueSoonWecomNotifications, +} from "../lib/wecom-notifier-service.ts"; test("computeDueCutoff advances by dueDays and crosses month/year", () => { assert.equal(computeDueCutoff("2026-08-18", 3), "2026-08-21"); @@ -13,3 +16,62 @@ test("computeDueCutoff advances by dueDays and crosses month/year", () => { test("computeDueCutoff returns input unchanged when malformed", () => { assert.equal(computeDueCutoff("not-a-date", 3), "not-a-date"); }); + +function captureDb() { + const executed = []; + const db = { + prepare(sql) { + const statement = (values) => ({ + async all() { + executed.push({ sql, values }); + return { results: [] }; + }, + async first() { + executed.push({ sql, values }); + return null; + }, + }); + return { + bind(...values) { + return statement(values); + }, + ...statement([]), + }; + }, + }; + return { db, executed }; +} + +const NOTIFY_CONFIG = { + corpId: "", + agentId: "", + secret: "", + robotWebhook: "", + dueDays: 3, +}; + +test("runDueSoonWecomNotifications filters by taskId when given", async () => { + const { db, executed } = captureDb(); + const now = Date.UTC(2026, 7, 18, 2, 0, 0); + const summary = await runDueSoonWecomNotifications( + db, + NOTIFY_CONFIG, + undefined, + now, + "task-9", + ); + assert.equal(executed.length, 1); + assert.match(executed[0].sql, /AND t\.id = \?/); + assert.deepEqual(executed[0].values, ["2026-08-21", "task-9"]); + assert.equal(summary.dueSoonAttempted, 0); + assert.equal(summary.digestSent, false); +}); + +test("runDueSoonWecomNotifications keeps global scan without taskId", async () => { + const { db, executed } = captureDb(); + const now = Date.UTC(2026, 7, 18, 2, 0, 0); + await runDueSoonWecomNotifications(db, NOTIFY_CONFIG, undefined, now); + assert.equal(executed.length, 1); + assert.ok(!/AND t\.id = \?/.test(executed[0].sql)); + assert.deepEqual(executed[0].values, ["2026-08-21"]); +});
群名称群主 userid人数对应合作方
任务负责人群状态领取进度
{chat.missing ? `${chat.name || chat.chatId}(群不存在)` : chat.name || "(未命名群)"}{chat.ownerUserId || "—"}{chat.missing ? "—" : chat.memberCount}{chat.partnerId ? chat.partnerName || "(已删除)" : 未映射}
{task.name} + {task.owner_userid ? ( + {task.owner_userid} + ) : ( + 未分配 + )} + {task.task_chat_id ? {task.task_chat_id} : "未建群"}{task.claimed_quantity} / {task.quantity} + +