2 Commits

Author SHA1 Message Date
ABAPPLO
5430624a43 feat: 企微通讯录选人替换手填 userid(项目负责人/总群成员/群主合作方)
- wecom-client 新增 listInternalContacts:department/list + 逐部门 user/simplelist(cursor 翻页、去重、部门名映射)
- 新 action wecom_list_internal_contacts(仅返回应用可见范围内成员)
- admin 新增 WecomUserPickerModal:单选点行填入/多选勾选合并,姓名+userid 搜索,5 分钟共享缓存
- 三处接线:项目负责人(单选)、项目总群成员(多选合并去重)、新建群主合作方(单选)
- 拉取失败(60020 等)提示手填兜底,手填路径不变
2026-09-03 13:52:10 +08:00
ABAPPLO
bd80b2d7d8 feat: 项目负责人+内部任务群+portal 内部身份领取+群主链接归属
- 迁移 0012:projects.owner_userid / tasks.task_chat_id / claims.owner_partner_id
- lib/task-group-service.ts:懒建 appchat 任务群(首个内部群主领取时创建,成员 diff 补员),播报/卡片文案
- wecom-client 新增 appchat create/update/get/send 封装
- OAuth ticket 增可选 uid(v:1 兼容),内部成员领取按 wecom_userid 解析群主身份
- 外部 OAuth 领取人 upsert 打 partner_type='group_owner'
- portal 链接带 ?owner= 穿透 OAuth 往返,claims 记录归属群主
- 后台任务卡「群主链接」面板:复制链接/推送内部群主/任务群状态
- 催办与超期升级同步播报进任务群
2026-09-03 11:30:26 +08:00
18 changed files with 2020 additions and 47 deletions

View File

@@ -532,6 +532,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
const [taskModalOpen, setTaskModalOpen] = useState(false); const [taskModalOpen, setTaskModalOpen] = useState(false);
const [groupPushTask, setGroupPushTask] = useState<Task | null>(null); const [groupPushTask, setGroupPushTask] = useState<Task | null>(null);
const [onePushTask, setOnePushTask] = useState<Task | null>(null); const [onePushTask, setOnePushTask] = useState<Task | null>(null);
const [ownerLinksTask, setOwnerLinksTask] = useState<Task | null>(null);
const [metricModal, setMetricModal] = useState<Distribution | null>(null); const [metricModal, setMetricModal] = useState<Distribution | null>(null);
const [taskForm, setTaskForm] = useState({ const [taskForm, setTaskForm] = useState({
taskType: "content_publish" as "content_publish" | "screenshot_collect", taskType: "content_publish" as "content_publish" | "screenshot_collect",
@@ -1175,6 +1176,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
onCopyShare={copyTaskShareLink} onCopyShare={copyTaskShareLink}
onPushToGroups={(task) => setGroupPushTask(task)} onPushToGroups={(task) => setGroupPushTask(task)}
onOneClickPush={(task) => setOnePushTask(task)} onOneClickPush={(task) => setOnePushTask(task)}
onOwnerLinks={(task) => setOwnerLinksTask(task)}
onRelease={async (distribution) => onRelease={async (distribution) =>
runAction( runAction(
{ {
@@ -1515,6 +1517,14 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
/> />
)} )}
{ownerLinksTask && (
<OwnerLinksModal
task={ownerLinksTask}
onClose={() => setOwnerLinksTask(null)}
setToast={setToast}
/>
)}
{toast && <div className="toast"><span>✓</span>{toast}</div>} {toast && <div className="toast"><span>✓</span>{toast}</div>}
{working && <div className="working-bar" />} {working && <div className="working-bar" />}
</div> </div>
@@ -1586,6 +1596,7 @@ function PartnersPage({
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [createDraft, setCreateDraft] = useState({ name: "", wecomUserid: "" }); const [createDraft, setCreateDraft] = useState({ name: "", wecomUserid: "" });
const [createSaving, setCreateSaving] = useState(false); const [createSaving, setCreateSaving] = useState(false);
const [userPickerOpen, setUserPickerOpen] = useState(false);
const filteredPartners = useMemo( const filteredPartners = useMemo(
() => () =>
@@ -2134,16 +2145,26 @@ function PartnersPage({
</label> </label>
<label> <label>
<span>企业微信 userid *</span> <span>企业微信 userid *</span>
<div className="field-with-action">
<input <input
value={createDraft.wecomUserid} value={createDraft.wecomUserid}
onChange={(event) => setCreateDraft({ ...createDraft, wecomUserid: event.target.value })} onChange={(event) => setCreateDraft({ ...createDraft, wecomUserid: event.target.value })}
placeholder="例如:zhangsan" placeholder="例如:zhangsan"
/> />
<button
type="button"
className="ghost-button"
onClick={() => setUserPickerOpen(true)}
disabled={createSaving}
>
从企微选择
</button>
</div>
</label> </label>
</div> </div>
<p className="project-chat-hint"> <p className="project-chat-hint">
userid 在企业微信管理后台「通讯录」成员详情中查看。群主合作方是内部员工, 可从企业微信通讯录选择,或手动填写 userid(在企业微信管理后台「通讯录」成员详情中查看)。
用于在任务中心的项目分组中把任务群映射到负责人。 群主合作方是内部员工,用于在任务中心的项目分组中把任务群映射到负责人。
</p> </p>
<div className="modal-actions"> <div className="modal-actions">
<button type="button" className="ghost-button" onClick={() => setCreateOpen(false)} disabled={createSaving}>取消</button> <button type="button" className="ghost-button" onClick={() => setCreateOpen(false)} disabled={createSaving}>取消</button>
@@ -2154,6 +2175,22 @@ function PartnersPage({
</div> </div>
</div> </div>
)} )}
{userPickerOpen && (
<WecomUserPickerModal
mode="single"
selected={
createDraft.wecomUserid.trim() ? [createDraft.wecomUserid.trim()] : []
}
onClose={() => setUserPickerOpen(false)}
onConfirm={(userids) => {
setCreateDraft((previous) => ({
...previous,
wecomUserid: userids[0] ?? "",
}));
setUserPickerOpen(false);
}}
/>
)}
{pickerPartner && ( {pickerPartner && (
<div className="modal-backdrop" role="presentation" onMouseDown={closePicker}> <div className="modal-backdrop" role="presentation" onMouseDown={closePicker}>
<div className="modal-card wecom-picker-modal" onMouseDown={(event) => event.stopPropagation()}> <div className="modal-card wecom-picker-modal" onMouseDown={(event) => event.stopPropagation()}>
@@ -2486,6 +2523,7 @@ type ProjectSummary = {
note: string; note: string;
createdAt: string; createdAt: string;
mainGroupMemberIds: string[]; mainGroupMemberIds: string[];
ownerUserid: string;
taskCount: number; taskCount: number;
groupChatCount: number; groupChatCount: number;
}; };
@@ -2541,11 +2579,12 @@ type ProjectEditorDraft = {
id: string; id: string;
name: string; name: string;
brand: string; brand: string;
owner: string;
members: string; members: string;
note: string; note: string;
}; };
const EMPTY_PROJECT_DRAFT: ProjectEditorDraft = { id: "", name: "", brand: "", members: "", note: "" }; const EMPTY_PROJECT_DRAFT: ProjectEditorDraft = { id: "", name: "", brand: "", owner: "", members: "", note: "" };
function ProjectSection({ function ProjectSection({
project, project,
@@ -2560,6 +2599,7 @@ function ProjectSection({
onCopyShare, onCopyShare,
onPushToGroups, onPushToGroups,
onOneClickPush, onOneClickPush,
onOwnerLinks,
}: { }: {
project: ProjectSummary; project: ProjectSummary;
tasks: Task[]; tasks: Task[];
@@ -2573,6 +2613,7 @@ function ProjectSection({
onCopyShare: (task: Task) => void; onCopyShare: (task: Task) => void;
onPushToGroups: (task: Task) => void; onPushToGroups: (task: Task) => void;
onOneClickPush: (task: Task) => void; onOneClickPush: (task: Task) => void;
onOwnerLinks: (task: Task) => void;
}) { }) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [detail, setDetail] = useState<ProjectDetailData | null>(null); const [detail, setDetail] = useState<ProjectDetailData | null>(null);
@@ -2711,6 +2752,7 @@ function ProjectSection({
onCopyShare={onCopyShare} onCopyShare={onCopyShare}
onPushToGroups={onPushToGroups} onPushToGroups={onPushToGroups}
onOneClickPush={onOneClickPush} onOneClickPush={onOneClickPush}
onOwnerLinks={onOwnerLinks}
/> />
))} ))}
</div> </div>
@@ -2868,6 +2910,223 @@ 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<InternalWecomContact[]>(
internalWecomContactsCache?.contacts ?? [],
);
const [fetchedAt, setFetchedAt] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [search, setSearch] = useState("");
const [checked, setChecked] = useState<string[]>(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 (
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
<div
className="modal-card wecom-picker-modal"
onMouseDown={(event) => event.stopPropagation()}
>
<div className="modal-heading">
<div>
<h2>选择企业微信成员</h2>
<p>
从企业微信通讯录(自建应用可见范围)中挑选,
{mode === "multi" ? "勾选后点击确定" : "点击成员即填入"}。
</p>
</div>
<button type="button" onClick={onClose} aria-label="关闭">×</button>
</div>
<div className="import-guide-strip">
<input
type="search"
className="wecom-picker-search"
placeholder="按姓名 / userid 搜索"
value={search}
onChange={(event) => setSearch(event.target.value)}
disabled={loading}
/>
<button
type="button"
className="ghost-button"
onClick={() => void loadContacts(true)}
disabled={loading}
>
{loading ? "拉取中…" : "刷新列表"}
</button>
{fetchedAt > 0 && !loading && (
<span className="wecom-picker-cache-hint">
缓存于 {new Date(fetchedAt).toLocaleTimeString()}(5 分钟内复用)
</span>
)}
</div>
{error && (
<p className="form-error">
{error} 可关闭此弹窗,直接在输入框手动填写 userid。
</p>
)}
<div className="wecom-picker-list">
{loading && contacts.length === 0 ? (
<div className="wecom-picker-empty">正在拉取企业微信通讯录…</div>
) : contacts.length === 0 ? (
<div className="wecom-picker-empty">
{error
? "拉取失败,请检查应用配置(通讯录权限 / 可信 IP)后重试"
: "未拉取到通讯录成员——仅显示自建应用可见范围内的成员,也可手动填写 userid"}
</div>
) : filteredContacts.length === 0 ? (
<div className="wecom-picker-empty">没有匹配的成员</div>
) : (
filteredContacts.map((contact) => {
const isSelected =
mode === "multi"
? checked.includes(contact.userid)
: selected.includes(contact.userid);
const rowBody = (
<>
<span className="wecom-picker-avatar placeholder" aria-hidden="true">
{(contact.name || contact.userid).slice(0, 1)}
</span>
<span className="wecom-picker-meta">
<span className="wecom-picker-name">
{contact.name || contact.userid}
</span>
<span className="wecom-picker-sub">
{contact.departmentNames.join(" / ") || "—"}
</span>
<code className="wecom-picker-id">{contact.userid}</code>
</span>
</>
);
return mode === "multi" ? (
<label key={contact.userid} className="wecom-picker-row">
{rowBody}
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleChecked(contact.userid)}
/>
</label>
) : (
<button
key={contact.userid}
type="button"
className="wecom-picker-row"
onClick={() => onConfirm([contact.userid])}
>
{rowBody}
{isSelected ? (
<span className="wecom-picker-current">当前</span>
) : null}
</button>
);
})
)}
</div>
{mode === "multi" && (
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={onClose}>
取消
</button>
<button
type="button"
className="primary-button"
onClick={() => onConfirm(checked)}
>
{checked.length > 0 ? `确定(${checked.length} 人)` : "确定"}
</button>
</div>
)}
</div>
</div>
);
}
function ProjectEditorModal({ function ProjectEditorModal({
draft, draft,
saving, saving,
@@ -2881,6 +3140,12 @@ function ProjectEditorModal({
onClose: () => void; onClose: () => void;
onSave: () => void; onSave: () => void;
}) { }) {
const [picker, setPicker] = useState<null | "owner" | "members">(null);
const memberLines = () =>
draft.members
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
return ( return (
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}> <div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
<div className="modal-card" onMouseDown={(event) => event.stopPropagation()}> <div className="modal-card" onMouseDown={(event) => event.stopPropagation()}>
@@ -2910,6 +3175,25 @@ function ProjectEditorModal({
/> />
</label> </label>
</div> </div>
<label>
<span>项目负责人 userid</span>
<div className="field-with-action">
<input
value={draft.owner}
onChange={(event) => onChange({ ...draft, owner: event.target.value })}
placeholder="例如:zhangsan"
/>
<button
type="button"
className="ghost-button"
onClick={() => setPicker("owner")}
disabled={saving}
>
从企微选择
</button>
</div>
<small>任务群创建时自动把负责人拉进群;请确保该成员在自建应用可见范围内。</small>
</label>
<label> <label>
<span>项目总群成员 userid(一行一个)</span> <span>项目总群成员 userid(一行一个)</span>
<textarea <textarea
@@ -2918,8 +3202,16 @@ function ProjectEditorModal({
onChange={(event) => onChange({ ...draft, members: event.target.value })} onChange={(event) => onChange({ ...draft, members: event.target.value })}
placeholder={"zhangsan\nlisi\nwangwu"} placeholder={"zhangsan\nlisi\nwangwu"}
/> />
<small>userid 在企业微信管理后台「通讯录」成员详情中查看;推送任务时会逐人发送应用消息。</small> <small>推送任务时会逐人发送应用消息;可从企业微信通讯录选择或手动填写。</small>
</label> </label>
<button
type="button"
className="ghost-button"
onClick={() => setPicker("members")}
disabled={saving}
>
从企微选择总群成员
</button>
<label> <label>
<span>备注</span> <span>备注</span>
<textarea <textarea
@@ -2936,6 +3228,32 @@ function ProjectEditorModal({
</button> </button>
</div> </div>
</div> </div>
{picker === "owner" && (
<WecomUserPickerModal
mode="single"
selected={draft.owner ? [draft.owner.trim()] : []}
onClose={() => setPicker(null)}
onConfirm={(userids) => {
onChange({ ...draft, owner: userids[0] ?? "" });
setPicker(null);
}}
/>
)}
{picker === "members" && (
<WecomUserPickerModal
mode="multi"
selected={memberLines()}
onClose={() => setPicker(null)}
onConfirm={(userids) => {
const merged = [...memberLines()];
for (const userid of userids) {
if (!merged.includes(userid)) merged.push(userid);
}
onChange({ ...draft, members: merged.join("\n") });
setPicker(null);
}}
/>
)}
</div> </div>
); );
} }
@@ -3907,6 +4225,187 @@ function OverviewPage({
); );
} }
type OwnerLinkRow = {
partnerId: string;
name: string;
wecomUserid: string;
isInternal: boolean;
link: string;
};
type TaskGroupLinksView = {
taskName: string;
taskChatId: string;
claimUrl: string;
owners: OwnerLinkRow[];
};
function OwnerLinksModal({
task,
onClose,
setToast,
}: {
task: Task;
onClose: () => void;
setToast: (message: string) => void;
}) {
const [view, setView] = useState<TaskGroupLinksView | null>(null);
const [loading, setLoading] = useState(true);
const [pushing, setPushing] = useState(false);
const [pushResult, setPushResult] = useState<{
sent: number;
failed: number;
failList: string[];
} | null>(null);
const postAction = useCallback(
async <T,>(payload: Record<string, unknown>, fallback: string) => {
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const parsed = await readApiResponse<T & { error?: string }>(
response,
fallback,
);
if (!response.ok) throw new Error(parsed.error || fallback);
return parsed;
},
[],
);
useEffect(() => {
const timer = window.setTimeout(() => {
void (async () => {
try {
const parsed = await postAction<TaskGroupLinksView>(
{ action: "task_group_links", taskId: task.id },
"读取群主链接失败",
);
setView(parsed);
} catch (reason) {
setToast(
reason instanceof Error ? reason.message : "读取群主链接失败",
);
} finally {
setLoading(false);
}
})();
}, 0);
return () => window.clearTimeout(timer);
}, [postAction, task.id, setToast]);
const copyLink = async (label: string, link: string) => {
try {
await navigator.clipboard.writeText(link);
setToast(`${label}已复制`);
} catch {
window.prompt(`复制${label}`, link);
}
};
const pushLinks = async () => {
setPushing(true);
try {
const parsed = await postAction<{
sent: number;
failed: number;
failList: string[];
}>({ action: "task_group_push", taskId: task.id }, "推送群主链接失败");
setPushResult(parsed);
setToast(
parsed.failList.length > 0
? `已送达 ${parsed.sent} 位群主;失败:${parsed.failList.join("、")}`
: `已送达 ${parsed.sent} 位群主`,
);
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "推送群主链接失败");
} finally {
setPushing(false);
}
};
const internalCount = view?.owners.filter((owner) => owner.isInternal).length ?? 0;
return (
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
<div className="modal-card" onMouseDown={(event) => event.stopPropagation()}>
<div className="modal-heading">
<div>
<p className="eyebrow">任务中心 · 群主领取链接</p>
<h2>{task.name}</h2>
</div>
<button type="button" onClick={onClose} aria-label="关闭">×</button>
</div>
{loading ? (
<div className="wecom-picker-empty">正在读取群主列表…</div>
) : !view || view.owners.length === 0 ? (
<div className="wecom-picker-empty">
还没有登记群主,请先在「合作方管理」新建群主合作方(需填写企业微信 userid)。
</div>
) : (
<>
<div className="project-detail-block-head">
<h4>任务群状态</h4>
<small>
{view.taskChatId
? "已建群,群主领取后自动进群"
: "未建群:内部群主领取任务后自动创建(需项目已填负责人)"}
</small>
</div>
{view.claimUrl && (
<p className="project-empty-line">
通用领取链接(不带群主归属):
<button
type="button"
className="share-link-button"
onClick={() => void copyLink("通用领取链接", view.claimUrl)}
>
复制
</button>
</p>
)}
<ul className="project-member-list">
{view.owners.map((owner) => (
<li key={owner.partnerId}>
<span>
{owner.name}
{owner.isInternal ? "" : "(外部群主)"}
</span>
<button
type="button"
className="share-link-button"
onClick={() => void copyLink(`${owner.name} 的领取链接`, owner.link)}
>
复制链接
</button>
</li>
))}
</ul>
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={onClose} disabled={pushing}>
关闭
</button>
<button
type="button"
className="primary-button"
onClick={() => void pushLinks()}
disabled={pushing || internalCount === 0}
>
{pushing ? "推送中…" : "推送内部群主(应用消息)"}
</button>
</div>
{pushResult && pushResult.failList.length > 0 && (
<small>推送失败:{pushResult.failList.join("、")}</small>
)}
</>
)}
</div>
</div>
);
}
function TaskRowCard({ function TaskRowCard({
task, task,
portalUrl, portalUrl,
@@ -3914,6 +4413,7 @@ function TaskRowCard({
onCopyShare, onCopyShare,
onPushToGroups, onPushToGroups,
onOneClickPush, onOneClickPush,
onOwnerLinks,
}: { }: {
task: Task; task: Task;
portalUrl: string; portalUrl: string;
@@ -3921,6 +4421,7 @@ function TaskRowCard({
onCopyShare: (task: Task) => void; onCopyShare: (task: Task) => void;
onPushToGroups: (task: Task) => void; onPushToGroups: (task: Task) => void;
onOneClickPush: (task: Task) => void; onOneClickPush: (task: Task) => void;
onOwnerLinks: (task: Task) => void;
}) { }) {
const progress = Math.round((task.claimed_quantity / task.quantity) * 100); const progress = Math.round((task.claimed_quantity / task.quantity) * 100);
return ( return (
@@ -3971,6 +4472,13 @@ function TaskRowCard({
> >
一键推送 一键推送
</button> </button>
<button
className="share-link-button"
onClick={() => onOwnerLinks(task)}
disabled={!portalUrl || !task.share_token}
>
群主链接
</button>
{portalUrl && task.share_token && ( {portalUrl && task.share_token && (
<a <a
className="round-arrow" className="round-arrow"
@@ -4000,6 +4508,7 @@ function TasksPage({
onCopyShare, onCopyShare,
onPushToGroups, onPushToGroups,
onOneClickPush, onOneClickPush,
onOwnerLinks,
onRelease, onRelease,
}: { }: {
tasks: Task[]; tasks: Task[];
@@ -4014,6 +4523,7 @@ function TasksPage({
onCopyShare: (task: Task) => void; onCopyShare: (task: Task) => void;
onPushToGroups: (task: Task) => void; onPushToGroups: (task: Task) => void;
onOneClickPush: (task: Task) => void; onOneClickPush: (task: Task) => void;
onOwnerLinks: (task: Task) => void;
onRelease: (distribution: Distribution) => Promise<boolean>; onRelease: (distribution: Distribution) => Promise<boolean>;
}) { }) {
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null); const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
@@ -4057,6 +4567,7 @@ function TasksPage({
id: project?.id ?? "", id: project?.id ?? "",
name: project?.name ?? "", name: project?.name ?? "",
brand: project?.brand ?? "", brand: project?.brand ?? "",
owner: project?.ownerUserid ?? "",
members: (project?.mainGroupMemberIds ?? []).join("\n"), members: (project?.mainGroupMemberIds ?? []).join("\n"),
note: project?.note ?? "", note: project?.note ?? "",
}); });
@@ -4076,6 +4587,7 @@ function TasksPage({
id: editorDraft.id, id: editorDraft.id,
name: editorDraft.name, name: editorDraft.name,
brand: editorDraft.brand, brand: editorDraft.brand,
ownerUserid: editorDraft.owner,
mainGroupMemberIds: editorDraft.members, mainGroupMemberIds: editorDraft.members,
note: editorDraft.note, note: editorDraft.note,
}, },
@@ -4182,6 +4694,7 @@ function TasksPage({
onCopyShare={onCopyShare} onCopyShare={onCopyShare}
onPushToGroups={onPushToGroups} onPushToGroups={onPushToGroups}
onOneClickPush={onOneClickPush} onOneClickPush={onOneClickPush}
onOwnerLinks={onOwnerLinks}
/> />
)) ))
)} )}
@@ -4202,6 +4715,7 @@ function TasksPage({
onCopyShare={onCopyShare} onCopyShare={onCopyShare}
onPushToGroups={onPushToGroups} onPushToGroups={onPushToGroups}
onOneClickPush={onOneClickPush} onOneClickPush={onOneClickPush}
onOwnerLinks={onOwnerLinks}
/> />
))} ))}
</div> </div>
@@ -4223,6 +4737,7 @@ function TasksPage({
onCopyShare={onCopyShare} onCopyShare={onCopyShare}
onPushToGroups={onPushToGroups} onPushToGroups={onPushToGroups}
onOneClickPush={onOneClickPush} onOneClickPush={onOneClickPush}
onOwnerLinks={onOwnerLinks}
/> />
))} ))}
</div> </div>

View File

@@ -43,6 +43,7 @@ import {
sendWecomAppMessage, sendWecomAppMessage,
sendWecomRobotMessage, sendWecomRobotMessage,
listExternalContacts, listExternalContacts,
listInternalContacts,
WecomClientError, WecomClientError,
type WecomBindings, type WecomBindings,
} from "../../../lib/wecom-client"; } from "../../../lib/wecom-client";
@@ -683,6 +684,12 @@ export async function POST(request: Request) {
); );
const contacts = await listExternalContacts(wecomConfig); const contacts = await listExternalContacts(wecomConfig);
return Response.json({ contacts }); return Response.json({ contacts });
} else if (body.action === "wecom_list_internal_contacts") {
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") { } else if (body.action === "wecom_sync_group_chats") {
const wecomConfig = resolveWecomConfig( const wecomConfig = resolveWecomConfig(
env as unknown as WecomBindings, env as unknown as WecomBindings,
@@ -731,6 +738,7 @@ export async function POST(request: Request) {
name: body.name, name: body.name,
brand: body.brand, brand: body.brand,
mainGroupMemberIds: body.mainGroupMemberIds, mainGroupMemberIds: body.mainGroupMemberIds,
ownerUserid: body.ownerUserid,
note: body.note, note: body.note,
}), }),
}); });
@@ -814,6 +822,85 @@ export async function POST(request: Request) {
return Response.json({ refresh: status }); return Response.json({ refresh: status });
} else if (body.action === "account_field_refresh_status") { } else if (body.action === "account_field_refresh_status") {
return Response.json({ refresh: getAccountFieldRefreshStatus() }); return Response.json({ refresh: getAccountFieldRefreshStatus() });
} else if (body.action === "task_group_links") {
const taskId = String(body.taskId ?? "").trim();
const task = await db
.prepare(
"SELECT id, name, share_token, task_chat_id FROM tasks WHERE id = ?",
)
.bind(taskId)
.first<{
id: string;
name: string;
share_token: string | null;
task_chat_id: string;
}>();
if (!task) return Response.json({ error: "任务不存在" }, { status: 404 });
const portalUrl = String(env.KOC_PORTAL_URL ?? "").replace(/\/$/, "");
const owners = await listGroupOwnerPartners(db);
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}`,
}))
: [],
});
} 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 = ?",
)
.bind(taskId)
.first<{ id: string; name: string; share_token: string | null }>();
if (!task) return Response.json({ error: "任务不存在" }, { status: 404 });
if (!task.share_token) {
return Response.json({ error: "任务缺少领取链接" }, { status: 400 });
}
const owners = (await listGroupOwnerPartners(db)).filter(
(owner) => owner.wecom_userid,
);
if (owners.length === 0) {
return Response.json(
{ error: "没有可推送的内部群主,请先在后台登记群主 userid" },
{ status: 400 },
);
}
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 {
failed += 1;
failList.push(owner.name);
}
}
return Response.json({ sent, failed, failList });
} else if (body.action === "wecom_group_chats") { } else if (body.action === "wecom_group_chats") {
const groups = await listGroupChatRows(db); const groups = await listGroupChatRows(db);
return Response.json({ groups }); return Response.json({ groups });

View File

@@ -36,6 +36,12 @@ import {
resolveWecomConfig, resolveWecomConfig,
type WecomBindings, type WecomBindings,
} from "../../../lib/wecom-client"; } from "../../../lib/wecom-client";
import {
ensureTaskChatForMembers,
sendTaskChatMessage,
taskChatIntroText,
taskClaimBroadcastText,
} from "../../../lib/task-group-service";
import { import {
partnerOptions, partnerOptions,
withPartnerCors, withPartnerCors,
@@ -53,6 +59,7 @@ type PartnerBody = {
claimantIdentifier?: string; claimantIdentifier?: string;
claimantName?: string; claimantName?: string;
identityTicket?: string; identityTicket?: string;
owner?: string;
quantity?: number; quantity?: number;
publishUrl?: string; publishUrl?: string;
exposure?: number | string; exposure?: number | string;
@@ -477,9 +484,22 @@ async function handlePost(request: Request) {
64, 64,
); );
const claimantIdentifier = parseClaimantIdentifier(identifierInput); const claimantIdentifier = parseClaimantIdentifier(identifierInput);
const wecomPartnerId = wecomIdentity let wecomPartnerId: string | null = null;
? (await resolveWecomClaimant(db, wecomIdentity.externalUserId)).partnerId if (wecomIdentity?.userId) {
: null; const internalPartner = await db
.prepare(
`SELECT id FROM partners
WHERE wecom_userid = ? AND partner_type = 'group_owner'
LIMIT 1`,
)
.bind(wecomIdentity.userId.trim())
.first<{ id: string }>();
wecomPartnerId = internalPartner?.id ?? null;
} else if (wecomIdentity?.externalUserId) {
wecomPartnerId = (
await resolveWecomClaimant(db, wecomIdentity.externalUserId)
).partnerId;
}
if (!claimantIdentifier && identifierInput.length < 2 && !wecomPartnerId) { if (!claimantIdentifier && identifierInput.length < 2 && !wecomPartnerId) {
return Response.json( return Response.json(
{ error: "请输入领取时使用的正确微信号或手机号" }, { error: "请输入领取时使用的正确微信号或手机号" },
@@ -563,14 +583,50 @@ async function handlePost(request: Request) {
{ status: 400 }, { status: 400 },
); );
} }
const partnerId = wecomIdentity const internalUserid = wecomIdentity?.userId?.trim() ?? "";
? (await resolveWecomClaimant(db, wecomIdentity.externalUserId)).partnerId const externalIdentity =
: `partner-ext-${hashText( !internalUserid && wecomIdentity ? wecomIdentity : null;
let partnerId: string;
let claimantName: string;
if (internalUserid) {
const ownerPartner = await db
.prepare(
`SELECT id, name FROM partners
WHERE wecom_userid = ? AND partner_type = 'group_owner'
LIMIT 1`,
)
.bind(internalUserid)
.first<{ id: string; name: string }>();
if (!ownerPartner) {
return Response.json(
{ error: "未找到你的群主身份,请先联系运营在后台登记" },
{ status: 403 },
);
}
partnerId = ownerPartner.id;
claimantName = ownerPartner.name || internalUserid;
} else if (externalIdentity) {
partnerId = (
await resolveWecomClaimant(db, externalIdentity.externalUserId)
).partnerId;
claimantName = externalIdentity.displayName;
} else {
partnerId = `partner-ext-${hashText(
`claimant:${claimantIdentifier?.canonical}`, `claimant:${claimantIdentifier?.canonical}`,
)}`; )}`;
const claimantName = wecomIdentity claimantName = claimantIdentifier?.display ?? "";
? wecomIdentity.displayName }
: (claimantIdentifier?.display ?? ""); const ownerInput = textValue(body.owner, 64);
let ownerPartnerId = "";
if (ownerInput) {
const ownerRow = await db
.prepare(
`SELECT id FROM partners WHERE id = ? AND partner_type = 'group_owner'`,
)
.bind(ownerInput)
.first<{ id: string }>();
if (ownerRow) ownerPartnerId = ownerRow.id;
}
const claimId = uid("claim"); const claimId = uid("claim");
const claimToken = crypto.randomUUID().replaceAll("-", ""); const claimToken = crypto.randomUUID().replaceAll("-", "");
const claimResult = await db.transaction(async (tx) => { const claimResult = await db.transaction(async (tx) => {
@@ -586,23 +642,24 @@ async function handlePost(request: Request) {
.all<{ id: string }>(); .all<{ id: string }>();
if (available.results.length === 0) return null; if (available.results.length === 0) return null;
const statements: DatabaseStatement[] = [ const statements: DatabaseStatement[] = [
wecomIdentity externalIdentity
? db ? db
.prepare( .prepare(
`INSERT INTO partners `INSERT INTO partners
(id, name, wecom_name, owner, claimed_total, completed_total, (id, name, wecom_name, owner, claimed_total, completed_total,
wecom_external_user_id) wecom_external_user_id, partner_type)
VALUES (?, ?, ?, '外部KOC', 0, 0, ?) VALUES (?, ?, ?, '外部KOC', 0, 0, ?, 'group_owner')
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
name = excluded.name, name = excluded.name,
wecom_name = excluded.wecom_name, wecom_name = excluded.wecom_name,
wecom_external_user_id = excluded.wecom_external_user_id`, wecom_external_user_id = excluded.wecom_external_user_id,
partner_type = 'group_owner'`,
) )
.bind( .bind(
partnerId, partnerId,
claimantName, claimantName,
claimantName, claimantName,
wecomIdentity.externalUserId, externalIdentity.externalUserId,
) )
: db : db
.prepare( .prepare(
@@ -617,13 +674,14 @@ async function handlePost(request: Request) {
db db
.prepare( .prepare(
`INSERT INTO claims `INSERT INTO claims
(id, task_id, partner_id, claimant_name, claim_token, quantity) (id, task_id, partner_id, owner_partner_id, claimant_name, claim_token, quantity)
VALUES (?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?)`,
) )
.bind( .bind(
claimId, claimId,
task.id, task.id,
partnerId, partnerId,
ownerPartnerId,
claimantName, claimantName,
claimToken, claimToken,
available.results.length, available.results.length,
@@ -668,6 +726,46 @@ async function handlePost(request: Request) {
if (!claimResult) { if (!claimResult) {
return Response.json({ error: "当前任务已领完" }, { status: 409 }); return Response.json({ error: "当前任务已领完" }, { status: 409 });
} }
runInBackground(
(async () => {
const wecomConfig = resolveWecomConfig(
env as unknown as WecomBindings,
);
const ensure = await ensureTaskChatForMembers(
db,
{
taskId: task.id,
...(internalUserid ? { addUserids: [internalUserid] } : {}),
},
wecomConfig,
);
if (!ensure.chatId) return;
if (ensure.created) {
await sendTaskChatMessage(
db,
task.id,
taskChatIntroText({
name: task.name,
brand: task.brand,
quantity: task.quantity,
dueAt: task.due_at,
}),
wecomConfig,
);
}
await sendTaskChatMessage(
db,
task.id,
taskClaimBroadcastText(
task.name,
claimantName,
claimResult.claimedCount,
),
wecomConfig,
);
})(),
`task-group-claim-${task.id}`,
);
return Response.json(claimResult); return Response.json(claimResult);
} }

View File

@@ -45,9 +45,19 @@ async function handlePost(request: Request) {
expiresIn, expiresIn,
}); });
} }
const userId = identity.userId ?? "";
if (!userId) {
return Response.json({ error: "企业微信未返回有效身份" }, { status: 400 });
}
const { ticket, expiresIn } = await signWecomIdentityTicket("", "", {
userId,
});
return Response.json({ return Response.json({
userType: "internal", userType: "internal",
userId: identity.userId ?? "", userId,
identityTicket: ticket,
displayName: userId,
expiresIn,
}); });
} catch (error) { } catch (error) {
const status = error instanceof WecomClientError ? error.status : 500; const status = error instanceof WecomClientError ? error.status : 500;

View File

@@ -5432,3 +5432,18 @@ label small {
flex-direction: column; flex-direction: column;
} }
} }
.field-with-action {
display: flex;
gap: 8px;
align-items: center;
}
.field-with-action > input {
flex: 1 1 auto;
min-width: 0;
}
.field-with-action > button {
flex: 0 0 auto;
}

View File

@@ -58,6 +58,7 @@ export const tasks = mysqlTable(
fsp: 3, fsp: 3,
}), }),
projectId: varchar("project_id", { length: 64 }).notNull().default(""), projectId: varchar("project_id", { length: 64 }).notNull().default(""),
taskChatId: varchar("task_chat_id", { length: 64 }).notNull().default(""),
createdAt: timestamp("created_at"), createdAt: timestamp("created_at"),
}, },
(table) => [uniqueIndex("tasks_share_token_idx").on(table.shareToken)], (table) => [uniqueIndex("tasks_share_token_idx").on(table.shareToken)],
@@ -112,6 +113,9 @@ export const claims = mysqlTable(
id: varchar("id", { length: 64 }).primaryKey(), id: varchar("id", { length: 64 }).primaryKey(),
taskId: varchar("task_id", { length: 64 }).notNull(), taskId: varchar("task_id", { length: 64 }).notNull(),
partnerId: varchar("partner_id", { length: 64 }).notNull(), partnerId: varchar("partner_id", { length: 64 }).notNull(),
ownerPartnerId: varchar("owner_partner_id", { length: 64 })
.notNull()
.default(""),
claimantName: varchar("claimant_name", { length: 255 }).notNull(), claimantName: varchar("claimant_name", { length: 255 }).notNull(),
claimToken: varchar("claim_token", { length: 128 }).notNull(), claimToken: varchar("claim_token", { length: 128 }).notNull(),
quantity: int("quantity").notNull(), quantity: int("quantity").notNull(),
@@ -310,6 +314,7 @@ export const projects = mysqlTable("projects", {
name: varchar("name", { length: 255 }).notNull(), name: varchar("name", { length: 255 }).notNull(),
brand: varchar("brand", { length: 255 }).notNull().default(""), brand: varchar("brand", { length: 255 }).notNull().default(""),
mainGroupMemberIds: text("main_group_member_ids").notNull(), mainGroupMemberIds: text("main_group_member_ids").notNull(),
ownerUserid: varchar("owner_userid", { length: 64 }).notNull().default(""),
status: varchar("status", { length: 32 }).notNull().default("active"), status: varchar("status", { length: 32 }).notNull().default("active"),
note: text("note").notNull(), note: text("note").notNull(),
createdAt: timestamp("created_at"), createdAt: timestamp("created_at"),

View File

@@ -133,6 +133,7 @@ type PortalParams = {
claim: string; claim: string;
share: string; share: string;
note: string; note: string;
owner: string;
}; };
function readSessionValue<T>(key: string): T | null { function readSessionValue<T>(key: string): T | null {
@@ -201,6 +202,7 @@ function buildWecomOAuthState(params: PortalParams): string {
if (params.claim) compact.set("c", params.claim); if (params.claim) compact.set("c", params.claim);
if (params.share) compact.set("s", params.share); if (params.share) compact.set("s", params.share);
if (params.note) compact.set("n", params.note); if (params.note) compact.set("n", params.note);
if (params.owner) compact.set("o", params.owner);
if (compact.toString().length <= WECOM_OAUTH_STATE_LIMIT) { if (compact.toString().length <= WECOM_OAUTH_STATE_LIMIT) {
return compact.toString(); return compact.toString();
} }
@@ -211,6 +213,7 @@ function buildWecomOAuthState(params: PortalParams): string {
const fallback = new URLSearchParams(); const fallback = new URLSearchParams();
if (params.task) fallback.set("t", params.task); if (params.task) fallback.set("t", params.task);
if (params.share) fallback.set("s", params.share); if (params.share) fallback.set("s", params.share);
if (params.owner) fallback.set("o", params.owner);
return fallback.toString(); return fallback.toString();
} }
@@ -225,14 +228,17 @@ function restoreParamsFromWecomState(state: string): PortalParams {
} catch { } catch {
// 忽略清理失败 // 忽略清理失败
} }
if (stored?.task || stored?.share) return stored; if (stored?.task || stored?.share) {
return { task: "", claim: "", share: "", note: "" }; return { ...stored, owner: stored.owner ?? "" };
}
return { task: "", claim: "", share: "", note: "", owner: "" };
} }
return { return {
task: parsed.get("t") ?? "", task: parsed.get("t") ?? "",
claim: parsed.get("c") ?? "", claim: parsed.get("c") ?? "",
share: parsed.get("s") ?? "", share: parsed.get("s") ?? "",
note: parsed.get("n") ?? "", note: parsed.get("n") ?? "",
owner: parsed.get("o") ?? "",
}; };
} }
@@ -434,6 +440,7 @@ export default function Home() {
const [claimToken, setClaimToken] = useState(""); const [claimToken, setClaimToken] = useState("");
const [delegationToken, setDelegationToken] = useState(""); const [delegationToken, setDelegationToken] = useState("");
const [selectedId, setSelectedId] = useState(""); const [selectedId, setSelectedId] = useState("");
const [ownerPartnerId, setOwnerPartnerId] = useState("");
const [payload, setPayload] = useState<TaskPayload | null>(null); const [payload, setPayload] = useState<TaskPayload | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [working, setWorking] = useState(false); const [working, setWorking] = useState(false);
@@ -546,7 +553,7 @@ export default function Home() {
}; };
if ( if (
response.ok && response.ok &&
result.userType === "external" && (result.userType === "external" || result.userType === "internal") &&
result.identityTicket result.identityTicket
) { ) {
const identity: WecomPortalIdentity = { const identity: WecomPortalIdentity = {
@@ -559,7 +566,11 @@ export default function Home() {
writeSessionValue(WECOM_IDENTITY_SESSION_KEY, identity); writeSessionValue(WECOM_IDENTITY_SESSION_KEY, identity);
setWecomIdentity(identity); setWecomIdentity(identity);
setManualEntry(false); setManualEntry(false);
setToast(`企微身份已确认:${identity.name}`); setToast(
result.userType === "internal"
? `企业内部身份已确认:${identity.name}`
: `企微身份已确认:${identity.name}`,
);
} else { } else {
markSessionFlag(WECOM_AUTH_FAILED_KEY); markSessionFlag(WECOM_AUTH_FAILED_KEY);
setToast( setToast(
@@ -576,6 +587,7 @@ export default function Home() {
setClaimToken(restored.claim); setClaimToken(restored.claim);
setDelegationToken(restored.share); setDelegationToken(restored.share);
setSelectedId(restored.note); setSelectedId(restored.note);
setOwnerPartnerId(restored.owner);
if (!restored.task && !restored.share) { if (!restored.task && !restored.share) {
setLoading(false); setLoading(false);
setError("任务链接不完整,请向运营重新获取"); setError("任务链接不完整,请向运营重新获取");
@@ -606,10 +618,12 @@ export default function Home() {
const claim = params.get("claim") ?? ""; const claim = params.get("claim") ?? "";
const delegation = params.get("share") ?? ""; const delegation = params.get("share") ?? "";
const note = params.get("note") ?? ""; const note = params.get("note") ?? "";
const owner = params.get("owner") ?? "";
setTaskToken(task); setTaskToken(task);
setClaimToken(claim); setClaimToken(claim);
setDelegationToken(delegation); setDelegationToken(delegation);
setSelectedId(note); setSelectedId(note);
setOwnerPartnerId(owner);
if (!task && !delegation) { if (!task && !delegation) {
setLoading(false); setLoading(false);
setError("任务链接不完整,请向运营重新获取"); setError("任务链接不完整,请向运营重新获取");
@@ -628,6 +642,7 @@ export default function Home() {
claim, claim,
share: delegation, share: delegation,
note, note,
owner,
}), }),
), ),
); );
@@ -682,6 +697,7 @@ export default function Home() {
...(wecomIdentity && !manualEntry ...(wecomIdentity && !manualEntry
? { identityTicket: wecomIdentity.ticket } ? { identityTicket: wecomIdentity.ticket }
: {}), : {}),
...(ownerPartnerId ? { owner: ownerPartnerId } : {}),
quantity, quantity,
}), }),
}); });

View File

@@ -5,6 +5,7 @@ export type ProjectRow = {
name: string; name: string;
brand: string; brand: string;
main_group_member_ids: string; main_group_member_ids: string;
owner_userid: string;
status: string; status: string;
note: string; note: string;
created_at: string; created_at: string;
@@ -18,6 +19,7 @@ export type ProjectSummary = {
note: string; note: string;
createdAt: string; createdAt: string;
mainGroupMemberIds: string[]; mainGroupMemberIds: string[];
ownerUserid: string;
taskCount: number; taskCount: number;
groupChatCount: number; groupChatCount: number;
}; };
@@ -95,7 +97,7 @@ function parseJsonArray(value: string): string[] {
export async function listProjects(db: DatabaseClient): Promise<ProjectSummary[]> { export async function listProjects(db: DatabaseClient): Promise<ProjectSummary[]> {
const result = await db const result = await db
.prepare( .prepare(
`SELECT p.id, p.name, p.brand, p.main_group_member_ids, p.status, p.note, p.created_at, `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 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 COUNT(*) FROM project_group_chats g WHERE g.project_id = p.id) AS group_chat_count
FROM projects p FROM projects p
@@ -110,6 +112,7 @@ export async function listProjects(db: DatabaseClient): Promise<ProjectSummary[]
note: row.note, note: row.note,
createdAt: row.created_at, createdAt: row.created_at,
mainGroupMemberIds: parseJsonArray(row.main_group_member_ids), mainGroupMemberIds: parseJsonArray(row.main_group_member_ids),
ownerUserid: row.owner_userid ?? "",
taskCount: Number(row.task_count), taskCount: Number(row.task_count),
groupChatCount: Number(row.group_chat_count), groupChatCount: Number(row.group_chat_count),
})); }));
@@ -122,7 +125,7 @@ export async function getProjectDetail(
const id = text(projectId); const id = text(projectId);
const project = await db const project = await db
.prepare( .prepare(
`SELECT id, name, brand, main_group_member_ids, status, note, created_at `SELECT id, name, brand, main_group_member_ids, owner_userid, status, note, created_at
FROM projects WHERE id = ?`, FROM projects WHERE id = ?`,
) )
.bind(id) .bind(id)
@@ -170,6 +173,7 @@ export async function getProjectDetail(
note: project.note, note: project.note,
createdAt: project.created_at, createdAt: project.created_at,
mainGroupMemberIds: parseJsonArray(project.main_group_member_ids), mainGroupMemberIds: parseJsonArray(project.main_group_member_ids),
ownerUserid: project.owner_userid ?? "",
taskCount: tasks.results.length, taskCount: tasks.results.length,
groupChatCount: chats.results.length, groupChatCount: chats.results.length,
}, },
@@ -192,6 +196,7 @@ export type SaveProjectInput = {
name?: unknown; name?: unknown;
brand?: unknown; brand?: unknown;
mainGroupMemberIds?: unknown; mainGroupMemberIds?: unknown;
ownerUserid?: unknown;
note?: unknown; note?: unknown;
}; };
@@ -204,6 +209,7 @@ export async function saveProject(
const brand = text(input.brand); const brand = text(input.brand);
const note = text(input.note ?? ""); const note = text(input.note ?? "");
const memberIds = parseMemberIds(input.mainGroupMemberIds); const memberIds = parseMemberIds(input.mainGroupMemberIds);
const ownerUserid = text(input.ownerUserid).slice(0, 64);
if (!name) throw new ProjectError("请填写项目名称"); if (!name) throw new ProjectError("请填写项目名称");
const now = new Date().toISOString(); const now = new Date().toISOString();
@@ -212,10 +218,10 @@ export async function saveProject(
const result = await db const result = await db
.prepare( .prepare(
`UPDATE projects `UPDATE projects
SET name = ?, brand = ?, main_group_member_ids = ?, note = ? SET name = ?, brand = ?, main_group_member_ids = ?, owner_userid = ?, note = ?
WHERE id = ?`, WHERE id = ?`,
) )
.bind(name, brand, JSON.stringify(memberIds), note, id) .bind(name, brand, JSON.stringify(memberIds), ownerUserid, note, id)
.run(); .run();
if (result.meta.changes === 0) throw new ProjectError("项目不存在", 404); if (result.meta.changes === 0) throw new ProjectError("项目不存在", 404);
} else { } else {
@@ -223,14 +229,15 @@ export async function saveProject(
await db await db
.prepare( .prepare(
`INSERT INTO projects `INSERT INTO projects
(id, name, brand, main_group_member_ids, status, note, created_at) (id, name, brand, main_group_member_ids, owner_userid, status, note, created_at)
VALUES (?, ?, ?, ?, 'active', ?, ?)`, VALUES (?, ?, ?, ?, ?, 'active', ?, ?)`,
) )
.bind( .bind(
projectId, projectId,
name, name,
brand, brand,
JSON.stringify(memberIds), JSON.stringify(memberIds),
ownerUserid,
note, note,
now, now,
) )

158
lib/task-group-service.ts Normal file
View File

@@ -0,0 +1,158 @@
import type { DatabaseClient } from "./database";
import {
createAppChat,
getAppChat,
hasAppCredentials,
sendAppChatMessage,
updateAppChat,
type WecomConfig,
} from "./wecom-client";
type FetchLike = typeof fetch;
export type TaskChatEnsureResult = {
chatId: string | null;
created: boolean;
reason?: "no_task" | "no_owner" | "not_enough_members" | "no_credentials";
};
type TaskChatRow = {
id: string;
name: string;
task_chat_id: string | null;
owner_userid: string | null;
};
function text(value: unknown) {
return String(value ?? "").trim();
}
export function taskChatId(taskId: string) {
return `tgc_${text(taskId)}`;
}
function chatNameForTask(taskName: string) {
return `【任务】${text(taskName)}`.slice(0, 20);
}
async function syncMembers(
config: WecomConfig,
chatid: string,
members: string[],
fetchImpl: FetchLike,
) {
const info = await getAppChat(config, chatid, fetchImpl);
if (!info) return;
const existing = new Set(info.userlist);
const missing = members.filter((userid) => !existing.has(userid));
if (missing.length > 0) {
await updateAppChat(config, { chatid, addUserList: missing }, fetchImpl);
}
}
export async function ensureTaskChatForMembers(
db: DatabaseClient,
params: { taskId: string; addUserids?: string[] },
config: WecomConfig,
fetchImpl: FetchLike = fetch,
): Promise<TaskChatEnsureResult> {
const taskId = text(params.taskId);
if (!taskId) return { chatId: null, created: false, reason: "no_task" };
if (!hasAppCredentials(config)) {
return { chatId: null, created: false, reason: "no_credentials" };
}
const task = await db
.prepare(
`SELECT t.id, t.name, t.task_chat_id, p.owner_userid AS owner_userid
FROM tasks t
LEFT JOIN projects p ON p.id = t.project_id
WHERE t.id = ?`,
)
.bind(taskId)
.first<TaskChatRow>();
if (!task) return { chatId: null, created: false, reason: "no_task" };
const ownerUserid = text(task.owner_userid);
if (!ownerUserid) {
return { chatId: null, created: false, reason: "no_owner" };
}
const members = [
...new Set(
[ownerUserid, ...(params.addUserids ?? []).map((id) => text(id))].filter(
Boolean,
),
),
];
let chatId = text(task.task_chat_id);
let createdNow = false;
if (!chatId) {
chatId = taskChatId(taskId);
const existing = await getAppChat(config, chatId, fetchImpl);
if (!existing) {
if (members.length < 2) {
return { chatId: null, created: false, reason: "not_enough_members" };
}
const created = await createAppChat(
config,
{ name: chatNameForTask(task.name), userlist: members, chatid: chatId },
fetchImpl,
);
chatId = created.chatid;
createdNow = true;
}
await db
.prepare("UPDATE tasks SET task_chat_id = ? WHERE id = ?")
.bind(chatId, taskId)
.run();
}
await syncMembers(config, chatId, members, fetchImpl);
return { chatId, created: createdNow };
}
export async function sendTaskChatMessage(
db: DatabaseClient,
taskId: string,
message: string,
config: WecomConfig,
fetchImpl: FetchLike = fetch,
): Promise<boolean> {
try {
const content = text(message);
if (!content) return false;
const row = await db
.prepare("SELECT task_chat_id FROM tasks WHERE id = ?")
.bind(text(taskId))
.first<{ task_chat_id: string | null }>();
const chatId = text(row?.task_chat_id);
if (!chatId) return false;
await sendAppChatMessage(config, { chatid: chatId, text: content }, fetchImpl);
return true;
} catch (error) {
console.error(
`[KOC LOOP] 任务群消息发送失败(task=${taskId})`,
error instanceof Error ? error.message : error,
);
return false;
}
}
export function taskClaimBroadcastText(
taskName: string,
claimantName: string,
quantity: number,
) {
return `【领取】${text(claimantName)} 认领 ${quantity} 份 · ${text(taskName)}`;
}
export function taskChatIntroText(task: {
name: string;
brand: string;
quantity: number;
dueAt: string;
}) {
return [
`【新任务】${text(task.name)}`,
`品牌:${text(task.brand)}|数量:${task.quantity} 份|截止:${text(task.dueAt)}`,
].join("\n");
}

View File

@@ -96,7 +96,7 @@ function parseDueDays(value: string | undefined) {
return Math.min(30, Math.floor(parsed)); return Math.min(30, Math.floor(parsed));
} }
function hasAppCredentials(config: WecomConfig) { export function hasAppCredentials(config: WecomConfig) {
return Boolean(config.corpId && config.agentId && config.secret); return Boolean(config.corpId && config.agentId && config.secret);
} }
@@ -635,3 +635,256 @@ export async function fetchWecomExternalContactName(
return null; return null;
} }
} }
async function postAppChat(
config: WecomConfig,
path: string,
body: Record<string, unknown>,
errorLabel: string,
fetchImpl: FetchLike,
): Promise<Record<string, unknown>> {
if (!hasAppCredentials(config)) {
throw new WecomClientError(
"企业微信自建应用未配置(WECOM_CORP_ID / WECOM_AGENT_ID / WECOM_SECRET)",
400,
);
}
const token = await fetchAccessToken(config, fetchImpl);
const url = `${WECOM_API_ORIGIN}${path}?access_token=${encodeURIComponent(token)}`;
const response = await fetchImpl(url, {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify(body),
signal: AbortSignal.timeout(12_000),
});
const payload = await readEnvelope(response, errorLabel);
if (!response.ok) {
throw new WecomClientError(`${errorLabel}(HTTP ${response.status})`, 502);
}
ensureOk(payload, errorLabel);
return payload;
}
export async function createAppChat(
config: WecomConfig,
params: { name: string; userlist: string[]; chatid: string },
fetchImpl: FetchLike = fetch,
): Promise<{ chatid: string }> {
const userlist = params.userlist
.map((id) => bindingValue(id))
.filter((id) => id.length > 0);
if (userlist.length < 2) {
throw new WecomClientError("创建群聊会话至少需要 2 名成员", 400);
}
const chatid = bindingValue(params.chatid);
if (!chatid || chatid.length > 32) {
throw new WecomClientError("群聊 chatid 缺失或超过 32 字符", 400);
}
const name = bindingValue(params.name).slice(0, 20);
const payload = await postAppChat(
config,
"/cgi-bin/appchat/create",
{ name, owner: Number(config.agentId), userlist, chatid },
"创建企业微信群聊会话失败",
fetchImpl,
);
return { chatid: bindingValue(payload.chatid) || chatid };
}
export async function updateAppChat(
config: WecomConfig,
params: {
chatid: string;
addUserList?: string[];
delUserList?: string[];
},
fetchImpl: FetchLike = fetch,
): Promise<void> {
const body: Record<string, unknown> = {
chatid: bindingValue(params.chatid),
};
const addUserList = (params.addUserList ?? [])
.map((id) => bindingValue(id))
.filter((id) => id.length > 0);
const delUserList = (params.delUserList ?? [])
.map((id) => bindingValue(id))
.filter((id) => id.length > 0);
if (addUserList.length === 0 && delUserList.length === 0) return;
if (addUserList.length > 0) body.add_user_list = addUserList;
if (delUserList.length > 0) body.del_user_list = delUserList;
await postAppChat(
config,
"/cgi-bin/appchat/update",
body,
"更新企业微信群聊会话失败",
fetchImpl,
);
}
export async function getAppChat(
config: WecomConfig,
chatid: string,
fetchImpl: FetchLike = fetch,
): Promise<{ chatId: string; name: string; userlist: string[] } | null> {
try {
const payload = await postAppChat(
config,
"/cgi-bin/appchat/get",
{ chatid: bindingValue(chatid) },
"获取企业微信群聊会话失败",
fetchImpl,
);
const detail = payload.chat_info as
| { chatid?: unknown; name?: unknown; userlist?: unknown[] }
| undefined;
if (!detail) return null;
return {
chatId: bindingValue(detail.chatid),
name: bindingValue(detail.name),
userlist: Array.isArray(detail.userlist)
? detail.userlist.map((id: unknown) => bindingValue(id)).filter(Boolean)
: [],
};
} catch {
return null;
}
}
export async function sendAppChatMessage(
config: WecomConfig,
params: { chatid: string; text: string },
fetchImpl: FetchLike = fetch,
): Promise<void> {
const chatid = bindingValue(params.chatid);
const content = bindingValue(params.text);
if (!chatid || !content) return;
await postAppChat(
config,
"/cgi-bin/appchat/send",
{ chatid, safe: 0, msgtype: "text", text: { content } },
"发送企业微信群聊消息失败",
fetchImpl,
);
}
async function getWecomJson(
config: WecomConfig,
path: string,
params: Record<string, string>,
errorLabel: string,
fetchImpl: FetchLike,
): Promise<Record<string, unknown>> {
if (!hasAppCredentials(config)) {
throw new WecomClientError(
"企业微信自建应用未配置(WECOM_CORP_ID / WECOM_AGENT_ID / WECOM_SECRET)",
400,
);
}
const token = await fetchAccessToken(config, fetchImpl);
const query = new URLSearchParams({ access_token: token, ...params });
const response = await fetchImpl(`${WECOM_API_ORIGIN}${path}?${query}`, {
method: "GET",
signal: AbortSignal.timeout(12_000),
});
const payload = await readEnvelope(response, errorLabel);
if (!response.ok) {
throw new WecomClientError(`${errorLabel}(HTTP ${response.status})`, 502);
}
ensureOk(payload, errorLabel);
return payload as Record<string, unknown>;
}
export type WecomInternalContact = {
userid: string;
name: string;
departmentNames: string[];
};
const INTERNAL_CONTACT_DEPARTMENT_LIMIT = 100;
const INTERNAL_CONTACT_CURSOR_ROUNDS = 10;
export async function listInternalContacts(
config: WecomConfig,
fetchImpl: FetchLike = fetch,
): Promise<WecomInternalContact[]> {
const deptPayload = await getWecomJson(
config,
"/cgi-bin/department/list",
{},
"获取企业微信部门列表失败",
fetchImpl,
);
const departments = (
Array.isArray(deptPayload.department)
? (deptPayload.department as { id?: unknown; name?: unknown }[])
: []
).slice(0, INTERNAL_CONTACT_DEPARTMENT_LIMIT);
const departmentNames = new Map<string, string>();
for (const dept of departments) {
const id = String(dept?.id ?? "");
if (id) departmentNames.set(id, bindingValue(dept?.name));
}
const contacts = new Map<string, WecomInternalContact>();
for (const dept of departments) {
const departmentId = String(dept?.id ?? "");
if (!departmentId) continue;
let cursor = "";
for (let round = 0; round < INTERNAL_CONTACT_CURSOR_ROUNDS; round += 1) {
const params: Record<string, string> = {
department_id: departmentId,
fetch_child: "0",
};
if (cursor) params.cursor = cursor;
const payload = await getWecomJson(
config,
"/cgi-bin/user/simplelist",
params,
"获取企业微信部门成员失败",
fetchImpl,
);
const userlist = Array.isArray(payload.userlist)
? (payload.userlist as {
userid?: unknown;
name?: unknown;
department?: unknown[];
}[])
: [];
for (const user of userlist) {
const userid = bindingValue(user?.userid);
if (!userid) continue;
const name = bindingValue(user?.name);
const deptIds = (
Array.isArray(user?.department)
? user.department.map((id) => String(id)).filter(Boolean)
: [departmentId]
);
const existing = contacts.get(userid);
if (existing) {
if (!existing.name && name) existing.name = name;
for (const deptId of deptIds) {
const deptName = departmentNames.get(deptId);
if (deptName && !existing.departmentNames.includes(deptName)) {
existing.departmentNames.push(deptName);
}
}
} else {
contacts.set(userid, {
userid,
name,
departmentNames: deptIds
.map((id) => departmentNames.get(id))
.filter((deptName): deptName is string => Boolean(deptName)),
});
}
}
const nextCursor = bindingValue(payload.next_cursor);
if (!nextCursor || nextCursor === cursor) break;
cursor = nextCursor;
}
}
return [...contacts.values()].sort((a, b) =>
`${a.name}${a.userid}`.localeCompare(`${b.name}${b.userid}`, "zh"),
);
}

View File

@@ -6,6 +6,7 @@ import {
sendWecomTemplateCard, sendWecomTemplateCard,
type WecomConfig, type WecomConfig,
} from "./wecom-client"; } from "./wecom-client";
import { sendTaskChatMessage } from "./task-group-service";
import { getRuntimeEnv } from "./runtime-env"; import { getRuntimeEnv } from "./runtime-env";
import { releaseUnfinishedDistribution } from "./distribution-release-service"; import { releaseUnfinishedDistribution } from "./distribution-release-service";
@@ -24,6 +25,7 @@ type DueSoonRow = {
partner_id: string; partner_id: string;
partner_name: string; partner_name: string;
wecom_external_user_id: string | null; wecom_external_user_id: string | null;
task_id: string;
task_name: string; task_name: string;
due_at: string; due_at: string;
content_title: string; content_title: string;
@@ -56,6 +58,7 @@ export async function runDueSoonWecomNotifications(
d.partner_id, d.partner_id,
p.name AS partner_name, p.name AS partner_name,
p.wecom_external_user_id, p.wecom_external_user_id,
t.id AS task_id,
t.name AS task_name, t.name AS task_name,
t.due_at, t.due_at,
c.title AS content_title c.title AS content_title
@@ -140,6 +143,31 @@ export async function runDueSoonWecomNotifications(
); );
} }
const byTask = new Map<
string,
{ taskName: string; dueAt: string; rows: DueSoonRow[] }
>();
for (const row of result.results) {
const entry = byTask.get(row.task_id) ?? {
taskName: row.task_name,
dueAt: row.due_at,
rows: [],
};
entry.rows.push(row);
byTask.set(row.task_id, entry);
}
for (const [taskId, 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,
`【催办】《${truncate(entry.taskName, 24)}》有 ${entry.rows.length} 条内容待发布(截止 ${entry.dueAt}):\n${partners.slice(0, 5).join("、")}${overflow}`,
config,
fetchImpl,
);
}
let digestSent = false; let digestSent = false;
if (config.robotWebhook && digestTasks.length > 0) { if (config.robotWebhook && digestTasks.length > 0) {
const digest = const digest =
@@ -276,6 +304,13 @@ export async function runOverdueEscalation(
const env = getRuntimeEnv(); const env = getRuntimeEnv();
const cardUrl = bindingValue(env.APP_ORIGIN) || bindingValue(env.KOC_PORTAL_URL); const cardUrl = bindingValue(env.APP_ORIGIN) || bindingValue(env.KOC_PORTAL_URL);
for (const [taskId, entry] of byTask) { for (const [taskId, entry] of byTask) {
await sendTaskChatMessage(
db,
taskId,
`【超期催办】《${truncate(entry.taskName, 24)}》(截止 ${entry.dueAt})有 ${entry.count} 条内容超期未发布,请跟进催办或释放。`,
config,
fetchImpl,
);
if (!entry.projectId) continue; if (!entry.projectId) continue;
try { try {
const project = await db const project = await db

View File

@@ -8,6 +8,7 @@ const WECOM_IDENTITY_GRACE_MS = 60 * 1_000;
type WecomIdentityPayload = { type WecomIdentityPayload = {
v: 1; v: 1;
ext: string; ext: string;
uid?: string;
name: string; name: string;
iat: number; iat: number;
exp: number; exp: number;
@@ -17,6 +18,7 @@ type WecomIdentityPayload = {
export type WecomIdentity = { export type WecomIdentity = {
externalUserId: string; externalUserId: string;
displayName: string; displayName: string;
userId?: string;
}; };
export function getWecomOAuthSecret(): string { export function getWecomOAuthSecret(): string {
@@ -74,13 +76,19 @@ function constantTimeEquals(a: Uint8Array, b: Uint8Array): boolean {
export async function signWecomIdentityTicket( export async function signWecomIdentityTicket(
externalUserId: string, externalUserId: string,
displayName: string, displayName: string,
options: { secret?: string; nowMs?: number; ttlMs?: number } = {}, options: {
secret?: string;
nowMs?: number;
ttlMs?: number;
userId?: string;
} = {},
): Promise<{ ticket: string; expiresIn: number }> { ): Promise<{ ticket: string; expiresIn: number }> {
const secret = options.secret ?? getWecomOAuthSecret(); const secret = options.secret ?? getWecomOAuthSecret();
const issuedAt = options.nowMs ?? Date.now(); const issuedAt = options.nowMs ?? Date.now();
const ttl = options.ttlMs ?? WECOM_IDENTITY_TTL_MS; const ttl = options.ttlMs ?? WECOM_IDENTITY_TTL_MS;
const ext = String(externalUserId ?? "").trim(); const ext = String(externalUserId ?? "").trim();
if (!ext) throw new Error("缺少企微外部联系人 ID"); const uid = String(options.userId ?? "").trim().slice(0, 64);
if (!ext && !uid) throw new Error("缺少企微身份 ID(外部联系人或内部成员)");
const payload: WecomIdentityPayload = { const payload: WecomIdentityPayload = {
v: 1, v: 1,
ext, ext,
@@ -89,6 +97,7 @@ export async function signWecomIdentityTicket(
exp: issuedAt + ttl, exp: issuedAt + ttl,
jti: toBase64Url(crypto.getRandomValues(new Uint8Array(12))), jti: toBase64Url(crypto.getRandomValues(new Uint8Array(12))),
}; };
if (uid) payload.uid = uid;
const payloadSegment = toBase64Url( const payloadSegment = toBase64Url(
new TextEncoder().encode(JSON.stringify(payload)), new TextEncoder().encode(JSON.stringify(payload)),
); );
@@ -122,11 +131,14 @@ export async function verifyWecomIdentityTicket(
if ( if (
payload?.v !== 1 || payload?.v !== 1 ||
typeof payload.ext !== "string" || typeof payload.ext !== "string" ||
!payload.ext.trim() ||
typeof payload.exp !== "number" typeof payload.exp !== "number"
) { ) {
return null; return null;
} }
const extId = payload.ext.trim();
const uid =
typeof payload.uid === "string" ? payload.uid.trim().slice(0, 64) : "";
if (!extId && !uid) return null;
const secret = options.secret ?? getWecomOAuthSecret(); const secret = options.secret ?? getWecomOAuthSecret();
const expected = await hmacSha256(secret, payloadSegment); const expected = await hmacSha256(secret, payloadSegment);
const provided = fromBase64Url(signatureSegment); const provided = fromBase64Url(signatureSegment);
@@ -134,8 +146,9 @@ export async function verifyWecomIdentityTicket(
const now = options.nowMs ?? Date.now(); const now = options.nowMs ?? Date.now();
if (now > payload.exp + WECOM_IDENTITY_GRACE_MS) return null; if (now > payload.exp + WECOM_IDENTITY_GRACE_MS) return null;
return { return {
externalUserId: payload.ext.trim(), externalUserId: extId,
displayName: payload.name || "企微客户", displayName: payload.name || "企微客户",
...(uid ? { userId: uid } : {}),
}; };
} }

View File

@@ -0,0 +1,5 @@
ALTER TABLE `projects` ADD COLUMN `owner_userid` VARCHAR(64) NOT NULL DEFAULT '' AFTER `main_group_member_ids`;
-- statement-breakpoint
ALTER TABLE `tasks` ADD COLUMN `task_chat_id` VARCHAR(64) NOT NULL DEFAULT '' AFTER `project_id`;
-- statement-breakpoint
ALTER TABLE `claims` ADD COLUMN `owner_partner_id` VARCHAR(64) NOT NULL DEFAULT '' AFTER `partner_id`;

View File

@@ -7,6 +7,11 @@ const routeSource = readFileSync(
"utf8", "utf8",
); );
const portalSource = readFileSync(
new URL("../koc-portal/app/page.tsx", import.meta.url),
"utf8",
);
test("partner route accepts identityTicket for wecom-backed claim", () => { test("partner route accepts identityTicket for wecom-backed claim", () => {
assert.match(routeSource, /identityTicket\?: string/); assert.match(routeSource, /identityTicket\?: string/);
assert.match(routeSource, /verifyWecomIdentityTicket/); assert.match(routeSource, /verifyWecomIdentityTicket/);
@@ -33,6 +38,72 @@ test("claim with ticket upserts wecom_external_user_id for reminders", () => {
); );
}); });
test("internal identity claim resolves the group_owner partner by wecom_userid", () => {
assert.match(
routeSource,
/WHERE wecom_userid = \? AND partner_type = 'group_owner'/,
);
assert.match(routeSource, /未找到你的群主身份/);
assert.match(
routeSource,
/claimantName = ownerPartner\.name \|\| internalUserid/,
);
});
test("external wecom claim upserts partner_type group_owner", () => {
assert.match(
routeSource,
/wecom_external_user_id, partner_type\)\s*\n\s*VALUES \(\?, \?, \?, '外部KOC', 0, 0, \?, 'group_owner'\)/,
);
assert.match(
routeSource,
/partner_type = 'group_owner'`/,
);
});
test("claim records owner_partner_id after validating a group_owner partner", () => {
assert.match(routeSource, /owner\?: string/);
assert.match(
routeSource,
/SELECT id FROM partners WHERE id = \? AND partner_type = 'group_owner'/,
);
assert.match(
routeSource,
/INSERT INTO claims\s*\n\s*\(\s*id, task_id, partner_id, owner_partner_id, claimant_name, claim_token, quantity\)/,
);
});
test("claim schedules task group creation and broadcast in background", () => {
assert.match(routeSource, /ensureTaskChatForMembers/);
assert.match(routeSource, /taskClaimBroadcastText/);
assert.match(routeSource, /taskChatIntroText/);
assert.match(routeSource, /runInBackground\(/);
assert.match(
routeSource,
/internalUserid \? \{ addUserids: \[internalUserid\] \} : \{\}/,
);
});
test("recover resolves internal identity via wecom_userid as well", () => {
assert.match(
routeSource,
/wecomIdentity\?\.userId[\s\S]{0,200}WHERE wecom_userid = \? AND partner_type = 'group_owner'/,
);
});
test("portal preserves owner param through the oauth round trip and sends it with claim", () => {
assert.match(portalSource, /owner: string;/);
assert.match(portalSource, /compact\.set\("o", params\.owner\)/);
assert.match(portalSource, /fallback\.set\("o", params\.owner\)/);
assert.match(portalSource, /parsed\.get\("o"\) \?\? ""/);
assert.match(portalSource, /params\.get\("owner"\) \?\? ""/);
assert.match(
portalSource,
/ownerPartnerId \? \{ owner: ownerPartnerId \} : \{\}/,
);
assert.match(portalSource, /userType === "internal"/);
});
test("recover builds dynamic partner candidates including wecom identity", () => { test("recover builds dynamic partner candidates including wecom identity", () => {
assert.match( assert.match(
routeSource, routeSource,

View File

@@ -27,20 +27,22 @@ function fakeProjectDb({
name: params[1], name: params[1],
brand: params[2], brand: params[2],
main_group_member_ids: params[3], main_group_member_ids: params[3],
owner_userid: params[4],
status: "active", status: "active",
note: params[4], note: params[5],
created_at: params[5], created_at: params[6],
}); });
return 1; return 1;
} }
if (/^UPDATE projects/.test(normalized)) { if (/^UPDATE projects/.test(normalized)) {
const row = projects.find((item) => item.id === params[4]); const row = projects.find((item) => item.id === params[5]);
if (!row) return 0; if (!row) return 0;
Object.assign(row, { Object.assign(row, {
name: params[0], name: params[0],
brand: params[1], brand: params[1],
main_group_member_ids: params[2], main_group_member_ids: params[2],
note: params[3], owner_userid: params[3],
note: params[4],
}); });
return 1; return 1;
} }
@@ -177,10 +179,12 @@ test("saveProject inserts with proj- prefixed id and JSON members", async () =>
name: "暑期焕新", name: "暑期焕新",
brand: "青柠实验室", brand: "青柠实验室",
mainGroupMemberIds: "zhangsan\nlisi\nzhangsan", mainGroupMemberIds: "zhangsan\nlisi\nzhangsan",
ownerUserid: "owner-zhang",
note: "备注", note: "备注",
}); });
assert.match(project.id, /^proj-/); assert.match(project.id, /^proj-/);
assert.equal(project.name, "暑期焕新"); assert.equal(project.name, "暑期焕新");
assert.equal(project.ownerUserid, "owner-zhang");
assert.deepEqual(project.mainGroupMemberIds, ["zhangsan", "lisi"]); assert.deepEqual(project.mainGroupMemberIds, ["zhangsan", "lisi"]);
assert.equal(db.projects.length, 1); assert.equal(db.projects.length, 1);
assert.equal(db.projects[0].status, "active"); assert.equal(db.projects[0].status, "active");
@@ -205,9 +209,11 @@ test("saveProject updates existing row and rejects blank name", async () => {
name: "新名", name: "新名",
brand: "品牌", brand: "品牌",
mainGroupMemberIds: ["wangwu"], mainGroupMemberIds: ["wangwu"],
ownerUserid: "owner-li",
}); });
assert.equal(db.projects[0].name, "新名"); assert.equal(db.projects[0].name, "新名");
assert.equal(db.projects[0].brand, "品牌"); 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"]);
assert.equal(db.projects.length, 1, "update must not insert"); assert.equal(db.projects.length, 1, "update must not insert");

View File

@@ -0,0 +1,352 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
ensureTaskChatForMembers,
sendTaskChatMessage,
taskChatId,
taskClaimBroadcastText,
taskChatIntroText,
} from "../lib/task-group-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",
};
test.beforeEach(() => {
clearWecomAccessTokenCacheForTests();
});
function fakeWecom(options = {}) {
const calls = [];
const chats = new Map(
options.chats ?? [
["tgc_task-existing", { name: "【任务】已有群", userlist: ["owner-a"] }],
],
);
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")) {
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);
}
return Response.json({ errcode: 0, errmsg: "ok" });
}
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/send")) {
const body = JSON.parse(String(init.body ?? "{}"));
if (!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 });
};
return { calls, fetchImpl, chats };
}
function fakeDb({ task }) {
const statements = [];
const db = {
prepare(sql) {
return {
bind(...values) {
return {
async first() {
if (/FROM tasks t\s+LEFT JOIN projects p/.test(sql)) {
return task;
}
if (/SELECT task_chat_id FROM tasks/.test(sql)) {
return { task_chat_id: task.task_chat_id ?? null };
}
return null;
},
async run() {
statements.push({ sql, values });
return { meta: { changes: 1, last_row_id: 0 } };
},
async all() {
return { results: [] };
},
};
},
};
},
};
return { db, statements };
}
test("taskChatId derives a deterministic chatid within 32 chars", () => {
const taskId = "task-1a2b3c4d";
const chatid = taskChatId(taskId);
assert.equal(chatid, `tgc_${taskId}`);
assert.ok(chatid.length <= 32);
});
test("ensureTaskChatForMembers skips when project has no owner", async () => {
const wecom = fakeWecom();
const { db } = fakeDb({
task: {
id: "task-1",
name: "任务一",
task_chat_id: null,
owner_userid: null,
},
});
const result = await ensureTaskChatForMembers(
db,
{ taskId: "task-1", addUserids: ["zhangsan"] },
resolveWecomConfig(baseBindings),
wecom.fetchImpl,
);
assert.equal(result.chatId, null);
assert.equal(result.reason, "no_owner");
assert.equal(wecom.calls.length, 0);
});
test("ensureTaskChatForMembers skips creation when fewer than 2 members", async () => {
const wecom = fakeWecom();
const { db } = fakeDb({
task: {
id: "task-1",
name: "任务一",
task_chat_id: null,
owner_userid: "owner-a",
},
});
const result = await ensureTaskChatForMembers(
db,
{ taskId: "task-1" },
resolveWecomConfig(baseBindings),
wecom.fetchImpl,
);
assert.equal(result.chatId, null);
assert.equal(result.reason, "not_enough_members");
assert.equal(
wecom.calls.filter((c) => c.url.pathname.endsWith("/cgi-bin/appchat/create"))
.length,
0,
);
});
test("ensureTaskChatForMembers creates the chat lazily and stores chat id", async () => {
const wecom = fakeWecom({ chats: [] });
const { db, statements } = fakeDb({
task: {
id: "task-1a2b3c4d",
name: "任务一",
task_chat_id: null,
owner_userid: "owner-a",
},
});
const result = await ensureTaskChatForMembers(
db,
{ taskId: "task-1a2b3c4d", addUserids: ["zhangsan"] },
resolveWecomConfig(baseBindings),
wecom.fetchImpl,
);
assert.equal(result.chatId, "tgc_task-1a2b3c4d");
assert.equal(result.created, true);
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"]);
assert.ok(
statements.some(
(statement) =>
/UPDATE tasks SET task_chat_id/.test(statement.sql) &&
statement.values[0] === "tgc_task-1a2b3c4d",
),
"chat id persisted to tasks",
);
});
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",
},
});
const result = await ensureTaskChatForMembers(
db,
{ taskId: "task-existing", addUserids: ["zhangsan"] },
resolveWecomConfig(baseBindings),
wecom.fetchImpl,
);
assert.equal(result.chatId, "tgc_task-existing");
assert.equal(result.created, false);
assert.equal(
statements.filter((s) => /UPDATE tasks/.test(s.sql)).length,
0,
"no redundant chat id update",
);
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, ["zhangsan"]);
});
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,
owner_userid: "owner-a",
},
});
const result = await ensureTaskChatForMembers(
db,
{ taskId: "task-existing", addUserids: ["zhangsan"] },
resolveWecomConfig(baseBindings),
wecom.fetchImpl,
);
assert.equal(result.chatId, "tgc_task-existing");
assert.equal(result.created, false);
assert.ok(statements.some((s) => /UPDATE tasks SET task_chat_id/.test(s.sql)));
});
test("ensureTaskChatForMembers skips without app credentials", async () => {
const wecom = fakeWecom();
const { db } = fakeDb({
task: {
id: "task-1",
name: "任务一",
task_chat_id: null,
owner_userid: "owner-a",
},
});
const result = await ensureTaskChatForMembers(
db,
{ taskId: "task-1", addUserids: ["zhangsan"] },
resolveWecomConfig({ ...baseBindings, WECOM_SECRET: "" }),
wecom.fetchImpl,
);
assert.equal(result.reason, "no_credentials");
assert.equal(wecom.calls.length, 0);
});
test("sendTaskChatMessage no-ops without chat id and swallows errors", async () => {
const wecom = fakeWecom();
const noChat = fakeDb({
task: { id: "task-1", task_chat_id: null },
});
assert.equal(
await sendTaskChatMessage(
noChat.db,
"task-1",
"hi",
resolveWecomConfig(baseBindings),
wecom.fetchImpl,
),
false,
);
assert.equal(wecom.calls.length, 0);
const failing = fakeWecom({ chats: [] });
const withChat = fakeDb({
task: { id: "task-existing", task_chat_id: "tgc_task-existing" },
});
assert.equal(
await sendTaskChatMessage(
withChat.db,
"task-existing",
"hi",
resolveWecomConfig(baseBindings),
failing.fetchImpl,
),
false,
"provider error swallowed",
);
});
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" },
});
assert.equal(
await sendTaskChatMessage(
db,
"task-existing",
"【领取】张三 认领 5 份",
resolveWecomConfig(baseBindings),
wecom.fetchImpl,
),
true,
);
const sendBody = JSON.parse(
String(
wecom.calls.find((c) => c.url.pathname.endsWith("/cgi-bin/appchat/send"))
.init.body,
),
);
assert.equal(sendBody.chatid, "tgc_task-existing");
assert.equal(sendBody.text.content, "【领取】张三 认领 5 份");
});
test("broadcast and intro text builders format messages", () => {
assert.equal(
taskClaimBroadcastText("任务一", "张三", 5),
"【领取】张三 认领 5 份 · 任务一",
);
const intro = taskChatIntroText({
name: "任务一",
brand: "品牌A",
quantity: 10,
dueAt: "2026-09-30",
});
assert.match(intro, /【新任务】任务一/);
assert.match(intro, /品牌:品牌A/);
assert.match(intro, /截止:2026-09-30/);
});

View File

@@ -3,14 +3,19 @@ import test from "node:test";
import { import {
buildWecomOAuthUrl, buildWecomOAuthUrl,
clearWecomAccessTokenCacheForTests, clearWecomAccessTokenCacheForTests,
createAppChat,
createGroupMsgTemplate, createGroupMsgTemplate,
exchangeWecomOAuthCode, exchangeWecomOAuthCode,
fetchWecomExternalContactName, fetchWecomExternalContactName,
getAppChat,
listCustomerGroupChats, listCustomerGroupChats,
listExternalContacts, listExternalContacts,
listInternalContacts,
resolveWecomConfig, resolveWecomConfig,
sendAppChatMessage,
sendWecomAppMessage, sendWecomAppMessage,
sendWecomRobotMessage, sendWecomRobotMessage,
updateAppChat,
WecomClientError, WecomClientError,
} from "../lib/wecom-client.ts"; } from "../lib/wecom-client.ts";
@@ -53,6 +58,56 @@ function fakeWecom(options = {}) {
invaliduser: invalid.join("|"), invaliduser: invalid.join("|"),
}); });
} }
if (url.pathname.endsWith("/cgi-bin/department/list")) {
if (options.departmentErrcode) {
return Response.json({
errcode: options.departmentErrcode,
errmsg: "not allow to access from your ip",
});
}
return Response.json({
errcode: 0,
errmsg: "ok",
department: options.departments ?? [
{ id: 1, name: "总办" },
{ id: 2, name: "运营部" },
],
});
}
if (url.pathname.endsWith("/cgi-bin/user/simplelist")) {
const departmentId = url.searchParams.get("department_id") ?? "";
const cursor = url.searchParams.get("cursor") ?? "";
const table = options.departmentUsers ?? {
"1": [
{ userid: "zhangsan", name: "张三", department: [1, 2] },
{ userid: "lisi", name: "李四", department: [1] },
],
"2": [
{ userid: "zhangsan", name: "张三", department: [1, 2] },
{ userid: "wangwu", name: "王五", department: [2] },
],
};
if (options.paginateSimplelist && departmentId === "1") {
if (!cursor) {
return Response.json({
errcode: 0,
errmsg: "ok",
userlist: table[departmentId] ?? [],
next_cursor: "cursor-2",
});
}
return Response.json({
errcode: 0,
errmsg: "ok",
userlist: [{ userid: "pagetwo", name: "第二页", department: [1] }],
});
}
return Response.json({
errcode: 0,
errmsg: "ok",
userlist: table[departmentId] ?? [],
});
}
return new Response("not found", { status: 404 }); return new Response("not found", { status: 404 });
}; };
return { calls, fetchImpl }; return { calls, fetchImpl };
@@ -771,3 +826,233 @@ test("fetchWecomExternalContactName resolves name and falls back to null", async
null, null,
); );
}); });
function fakeAppChat(options = {}) {
const calls = [];
const chats = new Map(
options.chats ?? [
["tgc_task-existing", { name: "【任务】已有群", userlist: ["owner-a"] }],
],
);
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")) {
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);
}
return Response.json({ errcode: 0, errmsg: "ok" });
}
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/send")) {
const body = JSON.parse(String(init.body ?? "{}"));
if (!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 });
};
return { calls, fetchImpl, chats };
}
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" },
fetchImpl,
);
assert.equal(result.chatid, "tgc_task-new");
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.userlist.length, 2);
assert.ok(body.name.length <= 20, "chat name truncated to 20 chars");
});
test("createAppChat rejects fewer than 2 members and oversized chatid", async () => {
const { calls, fetchImpl } = fakeAppChat();
const config = resolveWecomConfig(baseBindings);
await assert.rejects(
() => createAppChat(config, { name: "x", userlist: ["only"], chatid: "tgc_t" }, fetchImpl),
/至少需要 2 名成员/,
);
await assert.rejects(
() =>
createAppChat(
config,
{ name: "x", userlist: ["a", "b"], chatid: "c".repeat(33) },
fetchImpl,
),
/32 字符/,
);
assert.equal(calls.length, 0, "no API calls for invalid payload");
});
test("updateAppChat adds and removes members", async () => {
const { calls, fetchImpl, chats } = fakeAppChat();
await updateAppChat(
resolveWecomConfig(baseBindings),
{ chatid: "tgc_task-existing", addUserList: ["owner-b"], delUserList: [] },
fetchImpl,
);
const call = calls.find((c) => c.url.pathname.endsWith("/cgi-bin/appchat/update"));
assert.ok(call, "appchat/update was called");
const body = JSON.parse(String(call.init.body));
assert.deepEqual(body.add_user_list, ["owner-b"]);
assert.deepEqual(chats.get("tgc_task-existing").userlist, ["owner-a", "owner-b"]);
});
test("updateAppChat skips the API call when there is nothing to change", async () => {
const { calls, fetchImpl } = fakeAppChat();
await updateAppChat(
resolveWecomConfig(baseBindings),
{ chatid: "tgc_task-existing", addUserList: [], delUserList: [] },
fetchImpl,
);
assert.equal(
calls.filter((c) => c.url.pathname.endsWith("/cgi-bin/appchat/update")).length,
0,
);
});
test("getAppChat returns member list or null on error", async () => {
const { fetchImpl } = fakeAppChat();
const info = await getAppChat(
resolveWecomConfig(baseBindings),
"tgc_task-existing",
fetchImpl,
);
assert.equal(info.chatId, "tgc_task-existing");
assert.deepEqual(info.userlist, ["owner-a"]);
assert.equal(
await getAppChat(resolveWecomConfig(baseBindings), "tgc_task-missing", fetchImpl),
null,
);
});
test("sendAppChatMessage posts text payload to appchat/send", async () => {
const { calls, fetchImpl } = fakeAppChat();
await sendAppChatMessage(
resolveWecomConfig(baseBindings),
{ chatid: "tgc_task-existing", text: "【领取】张三 认领 5 份" },
fetchImpl,
);
const call = calls.find((c) => c.url.pathname.endsWith("/cgi-bin/appchat/send"));
assert.ok(call, "appchat/send was called");
const body = JSON.parse(String(call.init.body));
assert.equal(body.chatid, "tgc_task-existing");
assert.equal(body.msgtype, "text");
assert.equal(body.text.content, "【领取】张三 认领 5 份");
});
test("sendAppChatMessage throws WecomClientError when provider errors", async () => {
const { fetchImpl } = fakeAppChat({ chats: [] });
await assert.rejects(
() =>
sendAppChatMessage(
resolveWecomConfig(baseBindings),
{ chatid: "tgc_task-missing", text: "hello" },
fetchImpl,
),
(error) => {
assert.ok(error instanceof WecomClientError);
assert.equal(error.status, 502);
return true;
},
);
});
test("listInternalContacts merges departments, dedupes and maps dept names", async () => {
const { calls, fetchImpl } = fakeWecom();
const contacts = await listInternalContacts(
resolveWecomConfig(baseBindings),
fetchImpl,
);
assert.deepEqual(
contacts.map((contact) => `${contact.name}:${contact.userid}`),
["李四:lisi", "王五:wangwu", "张三:zhangsan"],
"deduped and sorted by name (zh)",
);
const zhangsan = contacts.find((contact) => contact.userid === "zhangsan");
assert.deepEqual(zhangsan.departmentNames.sort(), ["总办", "运营部"]);
const simplelistCalls = calls.filter((call) =>
call.url.pathname.endsWith("/cgi-bin/user/simplelist"),
);
assert.deepEqual(
simplelistCalls
.map((call) => call.url.searchParams.get("department_id"))
.sort(),
["1", "2"],
"one simplelist call per department",
);
assert.ok(
simplelistCalls.every((call) => call.url.searchParams.get("fetch_child") === "0"),
);
});
test("listInternalContacts follows simplelist cursor pages", async () => {
const { fetchImpl } = fakeWecom({ paginateSimplelist: true });
const contacts = await listInternalContacts(
resolveWecomConfig(baseBindings),
fetchImpl,
);
assert.ok(contacts.some((contact) => contact.userid === "pagetwo"));
});
test("listInternalContacts throws WecomClientError on department errcode", async () => {
const { fetchImpl } = fakeWecom({ departmentErrcode: 60020 });
await assert.rejects(
() => listInternalContacts(resolveWecomConfig(baseBindings), fetchImpl),
(error) =>
error instanceof WecomClientError && /部门列表/.test(error.message),
);
});
test("listInternalContacts rejects without app credentials", async () => {
const { fetchImpl } = fakeWecom();
await assert.rejects(
() =>
listInternalContacts(
resolveWecomConfig({ ...baseBindings, WECOM_SECRET: "" }),
fetchImpl,
),
/未配置/,
);
});

View File

@@ -41,6 +41,48 @@ test("identity ticket sign → verify roundtrip", async () => {
assert.deepEqual(identity, { externalUserId: "wmExt123", displayName: "张三" }); assert.deepEqual(identity, { externalUserId: "wmExt123", displayName: "张三" });
}); });
test("internal identity ticket carries userId and no external id", async () => {
const { ticket } = await signWecomIdentityTicket("", "", {
secret: SECRET,
userId: "zhangsan",
});
const identity = await verifyWecomIdentityTicket(ticket, { secret: SECRET });
assert.equal(identity.externalUserId, "");
assert.equal(identity.userId, "zhangsan");
});
test("signWecomIdentityTicket rejects when neither external nor internal id present", async () => {
await assert.rejects(
() => signWecomIdentityTicket("", "", { secret: SECRET }),
/缺少企微身份/,
);
});
test("internal identity ticket forgery is rejected", async () => {
const { ticket } = await signWecomIdentityTicket("", "", {
secret: SECRET,
userId: "zhangsan",
});
const [payload, signature] = ticket.split(".");
const decoded = JSON.parse(
Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString(
"utf8",
),
);
decoded.uid = "wangwu";
const forgedPayload = Buffer.from(JSON.stringify(decoded))
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
assert.equal(
await verifyWecomIdentityTicket(`${forgedPayload}.${signature}`, {
secret: SECRET,
}),
null,
);
});
test("identity ticket falls back to default display name when empty", async () => { test("identity ticket falls back to default display name when empty", async () => {
const { ticket } = await signWecomIdentityTicket("wmExt123", " ", { const { ticket } = await signWecomIdentityTicket("wmExt123", " ", {
secret: SECRET, secret: SECRET,