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:
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user