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(
`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;