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

@@ -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(
`claimant:${claimantIdentifier?.canonical}`,
)}`;
const claimantName = wecomIdentity
? wecomIdentity.displayName
: (claimantIdentifier?.display ?? "");
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}`,
)}`;
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;