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