Compare commits
2 Commits
5d2a17f5b5
...
5430624a43
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5430624a43 | ||
|
|
bd80b2d7d8 |
@@ -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>
|
||||||
<input
|
<div className="field-with-action">
|
||||||
value={createDraft.wecomUserid}
|
<input
|
||||||
onChange={(event) => setCreateDraft({ ...createDraft, wecomUserid: event.target.value })}
|
value={createDraft.wecomUserid}
|
||||||
placeholder="例如:zhangsan"
|
onChange={(event) => setCreateDraft({ ...createDraft, wecomUserid: event.target.value })}
|
||||||
/>
|
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>
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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;
|
||||||
`claimant:${claimantIdentifier?.canonical}`,
|
let partnerId: string;
|
||||||
)}`;
|
let claimantName: string;
|
||||||
const claimantName = wecomIdentity
|
if (internalUserid) {
|
||||||
? wecomIdentity.displayName
|
const ownerPartner = await db
|
||||||
: (claimantIdentifier?.display ?? "");
|
.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}`,
|
||||||
|
)}`;
|
||||||
|
claimantName = 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
158
lib/task-group-service.ts
Normal 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");
|
||||||
|
}
|
||||||
@@ -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"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
5
mysql/0012_project_owner_task_chat.sql
Normal file
5
mysql/0012_project_owner_task_chat.sql
Normal 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`;
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
352
tests/task-group-service.test.mjs
Normal file
352
tests/task-group-service.test.mjs
Normal 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/);
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
/未配置/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user