feat: 项目负责人+内部任务群+portal 内部身份领取+群主链接归属

- 迁移 0012:projects.owner_userid / tasks.task_chat_id / claims.owner_partner_id
- lib/task-group-service.ts:懒建 appchat 任务群(首个内部群主领取时创建,成员 diff 补员),播报/卡片文案
- wecom-client 新增 appchat create/update/get/send 封装
- OAuth ticket 增可选 uid(v:1 兼容),内部成员领取按 wecom_userid 解析群主身份
- 外部 OAuth 领取人 upsert 打 partner_type='group_owner'
- portal 链接带 ?owner= 穿透 OAuth 往返,claims 记录归属群主
- 后台任务卡「群主链接」面板:复制链接/推送内部群主/任务群状态
- 催办与超期升级同步播报进任务群
This commit is contained in:
ABAPPLO
2026-09-03 11:30:26 +08:00
parent 5d2a17f5b5
commit bd80b2d7d8
17 changed files with 1466 additions and 39 deletions

View File

@@ -532,6 +532,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
const [taskModalOpen, setTaskModalOpen] = useState(false);
const [groupPushTask, setGroupPushTask] = 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 [taskForm, setTaskForm] = useState({
taskType: "content_publish" as "content_publish" | "screenshot_collect",
@@ -1175,6 +1176,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
onCopyShare={copyTaskShareLink}
onPushToGroups={(task) => setGroupPushTask(task)}
onOneClickPush={(task) => setOnePushTask(task)}
onOwnerLinks={(task) => setOwnerLinksTask(task)}
onRelease={async (distribution) =>
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>}
{working && <div className="working-bar" />}
</div>
@@ -2486,6 +2496,7 @@ type ProjectSummary = {
note: string;
createdAt: string;
mainGroupMemberIds: string[];
ownerUserid: string;
taskCount: number;
groupChatCount: number;
};
@@ -2541,11 +2552,12 @@ type ProjectEditorDraft = {
id: string;
name: string;
brand: string;
owner: string;
members: 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({
project,
@@ -2560,6 +2572,7 @@ function ProjectSection({
onCopyShare,
onPushToGroups,
onOneClickPush,
onOwnerLinks,
}: {
project: ProjectSummary;
tasks: Task[];
@@ -2573,6 +2586,7 @@ function ProjectSection({
onCopyShare: (task: Task) => void;
onPushToGroups: (task: Task) => void;
onOneClickPush: (task: Task) => void;
onOwnerLinks: (task: Task) => void;
}) {
const [expanded, setExpanded] = useState(false);
const [detail, setDetail] = useState<ProjectDetailData | null>(null);
@@ -2711,6 +2725,7 @@ function ProjectSection({
onCopyShare={onCopyShare}
onPushToGroups={onPushToGroups}
onOneClickPush={onOneClickPush}
onOwnerLinks={onOwnerLinks}
/>
))}
</div>
@@ -2910,6 +2925,15 @@ function ProjectEditorModal({
/>
</label>
</div>
<label>
<span>项目负责人 userid</span>
<input
value={draft.owner}
onChange={(event) => onChange({ ...draft, owner: event.target.value })}
placeholder="例如:zhangsan"
/>
<small>任务群创建时自动把负责人拉进群;请确保该成员在自建应用可见范围内。</small>
</label>
<label>
<span>项目总群成员 userid(一行一个)</span>
<textarea
@@ -3907,6 +3931,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({
task,
portalUrl,
@@ -3914,6 +4119,7 @@ function TaskRowCard({
onCopyShare,
onPushToGroups,
onOneClickPush,
onOwnerLinks,
}: {
task: Task;
portalUrl: string;
@@ -3921,6 +4127,7 @@ function TaskRowCard({
onCopyShare: (task: Task) => void;
onPushToGroups: (task: Task) => void;
onOneClickPush: (task: Task) => void;
onOwnerLinks: (task: Task) => void;
}) {
const progress = Math.round((task.claimed_quantity / task.quantity) * 100);
return (
@@ -3971,6 +4178,13 @@ function TaskRowCard({
>
一键推送
</button>
<button
className="share-link-button"
onClick={() => onOwnerLinks(task)}
disabled={!portalUrl || !task.share_token}
>
群主链接
</button>
{portalUrl && task.share_token && (
<a
className="round-arrow"
@@ -4000,6 +4214,7 @@ function TasksPage({
onCopyShare,
onPushToGroups,
onOneClickPush,
onOwnerLinks,
onRelease,
}: {
tasks: Task[];
@@ -4014,6 +4229,7 @@ function TasksPage({
onCopyShare: (task: Task) => void;
onPushToGroups: (task: Task) => void;
onOneClickPush: (task: Task) => void;
onOwnerLinks: (task: Task) => void;
onRelease: (distribution: Distribution) => Promise<boolean>;
}) {
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
@@ -4057,6 +4273,7 @@ function TasksPage({
id: project?.id ?? "",
name: project?.name ?? "",
brand: project?.brand ?? "",
owner: project?.ownerUserid ?? "",
members: (project?.mainGroupMemberIds ?? []).join("\n"),
note: project?.note ?? "",
});
@@ -4076,6 +4293,7 @@ function TasksPage({
id: editorDraft.id,
name: editorDraft.name,
brand: editorDraft.brand,
ownerUserid: editorDraft.owner,
mainGroupMemberIds: editorDraft.members,
note: editorDraft.note,
},
@@ -4182,6 +4400,7 @@ function TasksPage({
onCopyShare={onCopyShare}
onPushToGroups={onPushToGroups}
onOneClickPush={onOneClickPush}
onOwnerLinks={onOwnerLinks}
/>
))
)}
@@ -4202,6 +4421,7 @@ function TasksPage({
onCopyShare={onCopyShare}
onPushToGroups={onPushToGroups}
onOneClickPush={onOneClickPush}
onOwnerLinks={onOwnerLinks}
/>
))}
</div>
@@ -4223,6 +4443,7 @@ function TasksPage({
onCopyShare={onCopyShare}
onPushToGroups={onPushToGroups}
onOneClickPush={onOneClickPush}
onOwnerLinks={onOwnerLinks}
/>
))}
</div>

View File

@@ -731,6 +731,7 @@ export async function POST(request: Request) {
name: body.name,
brand: body.brand,
mainGroupMemberIds: body.mainGroupMemberIds,
ownerUserid: body.ownerUserid,
note: body.note,
}),
});
@@ -814,6 +815,85 @@ export async function POST(request: Request) {
return Response.json({ refresh: status });
} else if (body.action === "account_field_refresh_status") {
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") {
const groups = await listGroupChatRows(db);
return Response.json({ groups });

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -96,7 +96,7 @@ function parseDueDays(value: string | undefined) {
return Math.min(30, Math.floor(parsed));
}
function hasAppCredentials(config: WecomConfig) {
export function hasAppCredentials(config: WecomConfig) {
return Boolean(config.corpId && config.agentId && config.secret);
}
@@ -635,3 +635,135 @@ export async function fetchWecomExternalContactName(
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,
);
}

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,11 @@ const routeSource = readFileSync(
"utf8",
);
const portalSource = readFileSync(
new URL("../koc-portal/app/page.tsx", import.meta.url),
"utf8",
);
test("partner route accepts identityTicket for wecom-backed claim", () => {
assert.match(routeSource, /identityTicket\?: string/);
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", () => {
assert.match(
routeSource,

View File

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

View File

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

View File

@@ -3,14 +3,18 @@ import test from "node:test";
import {
buildWecomOAuthUrl,
clearWecomAccessTokenCacheForTests,
createAppChat,
createGroupMsgTemplate,
exchangeWecomOAuthCode,
fetchWecomExternalContactName,
getAppChat,
listCustomerGroupChats,
listExternalContacts,
resolveWecomConfig,
sendAppChatMessage,
sendWecomAppMessage,
sendWecomRobotMessage,
updateAppChat,
WecomClientError,
} from "../lib/wecom-client.ts";
@@ -771,3 +775,175 @@ test("fetchWecomExternalContactName resolves name and falls back to null", async
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;
},
);
});

View File

@@ -41,6 +41,48 @@ test("identity ticket sign → verify roundtrip", async () => {
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 () => {
const { ticket } = await signWecomIdentityTicket("wmExt123", " ", {
secret: SECRET,