feat: 任务一键推送企微(总群+KOC个人直达+群发,含推送记录)
This commit is contained in:
@@ -533,6 +533,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [taskModalOpen, setTaskModalOpen] = useState(false);
|
const [taskModalOpen, setTaskModalOpen] = useState(false);
|
||||||
const [groupPushTask, setGroupPushTask] = useState<Task | null>(null);
|
const [groupPushTask, setGroupPushTask] = useState<Task | null>(null);
|
||||||
|
const [onePushTask, setOnePushTask] = useState<Task | null>(null);
|
||||||
const [metricModal, setMetricModal] = useState<Distribution | null>(null);
|
const [metricModal, setMetricModal] = useState<Distribution | null>(null);
|
||||||
const [taskForm, setTaskForm] = useState({
|
const [taskForm, setTaskForm] = useState({
|
||||||
taskType: "content_publish" as "content_publish" | "screenshot_collect",
|
taskType: "content_publish" as "content_publish" | "screenshot_collect",
|
||||||
@@ -1181,6 +1182,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
onCreate={() => setTaskModalOpen(true)}
|
onCreate={() => setTaskModalOpen(true)}
|
||||||
onCopyShare={copyTaskShareLink}
|
onCopyShare={copyTaskShareLink}
|
||||||
onPushToGroups={(task) => setGroupPushTask(task)}
|
onPushToGroups={(task) => setGroupPushTask(task)}
|
||||||
|
onOneClickPush={(task) => setOnePushTask(task)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeNav === "distributions" && (
|
{activeNav === "distributions" && (
|
||||||
@@ -1518,6 +1520,14 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{onePushTask && (
|
||||||
|
<OneClickPushModal
|
||||||
|
task={onePushTask}
|
||||||
|
onClose={() => setOnePushTask(null)}
|
||||||
|
setToast={setToast}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{toast && <div className="toast"><span>✓</span>{toast}</div>}
|
{toast && <div className="toast"><span>✓</span>{toast}</div>}
|
||||||
{working && <div className="working-bar" />}
|
{working && <div className="working-bar" />}
|
||||||
</div>
|
</div>
|
||||||
@@ -3224,6 +3234,320 @@ function ProjectPushModal({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BoundWecomPartnerRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
wecomExternalUserId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TaskPushLogRecord = {
|
||||||
|
id: string;
|
||||||
|
task_id: string;
|
||||||
|
push_kind: string;
|
||||||
|
main_group_count: number;
|
||||||
|
koc_ok_count: number;
|
||||||
|
detail: string;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OneClickPushOutcomeView = {
|
||||||
|
linkUrl: string;
|
||||||
|
kocSent: number;
|
||||||
|
kocFailed: number;
|
||||||
|
kocResults: Array<{ partnerId: string; partnerName: string; ok: boolean; error: string }>;
|
||||||
|
skippedReasons: string[];
|
||||||
|
hint?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PUSH_KIND_LABELS: Record<string, string> = {
|
||||||
|
task_open: "任务推送",
|
||||||
|
card_ack: "卡片确认",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatPushLogTime(value: string) {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return `${formatDate(date.toISOString().slice(0, 10))} ${date.toTimeString().slice(0, 5)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function OneClickPushModal({
|
||||||
|
task,
|
||||||
|
onClose,
|
||||||
|
setToast,
|
||||||
|
}: {
|
||||||
|
task: Task;
|
||||||
|
onClose: () => void;
|
||||||
|
setToast: (message: string) => void;
|
||||||
|
}) {
|
||||||
|
const [partners, setPartners] = useState<BoundWecomPartnerRow[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [selected, setSelected] = useState<string[]>([]);
|
||||||
|
const [sendMainGroup, setSendMainGroup] = useState(true);
|
||||||
|
const [createGroupTemplate, setCreateGroupTemplate] = useState(true);
|
||||||
|
const [sendKoc, setSendKoc] = useState(true);
|
||||||
|
const [force, setForce] = useState(false);
|
||||||
|
const [pushedToday, setPushedToday] = useState(false);
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [result, setResult] = useState<OneClickPushOutcomeView | null>(null);
|
||||||
|
const [logs, setLogs] = useState<TaskPushLogRecord[]>([]);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadLogs = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const parsed = await postAction<{ logs: TaskPushLogRecord[] }>(
|
||||||
|
{ action: "task_push_logs", taskId: task.id },
|
||||||
|
"读取推送记录失败",
|
||||||
|
);
|
||||||
|
setLogs(parsed.logs);
|
||||||
|
} catch {
|
||||||
|
// 推送记录加载失败不阻断弹窗
|
||||||
|
}
|
||||||
|
}, [postAction, task.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const parsed = await postAction<{
|
||||||
|
partners: BoundWecomPartnerRow[];
|
||||||
|
pushedToday: unknown;
|
||||||
|
}>({ action: "wecom_bound_partners", taskId: task.id }, "读取合作方失败");
|
||||||
|
setPartners(parsed.partners);
|
||||||
|
setSelected(parsed.partners.map((partner) => partner.id));
|
||||||
|
setPushedToday(Boolean(parsed.pushedToday));
|
||||||
|
} catch (reason) {
|
||||||
|
setToast(reason instanceof Error ? reason.message : "读取合作方失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
void loadLogs();
|
||||||
|
}, 0);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [postAction, loadLogs, task.id, setToast]);
|
||||||
|
|
||||||
|
const filtered = partners.filter((partner) => {
|
||||||
|
const keyword = search.trim().toLowerCase();
|
||||||
|
if (!keyword) return true;
|
||||||
|
return (
|
||||||
|
partner.name.toLowerCase().includes(keyword) ||
|
||||||
|
partner.wecomExternalUserId.toLowerCase().includes(keyword)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const allSelected = partners.length > 0 && selected.length === partners.length;
|
||||||
|
|
||||||
|
const togglePartner = (partnerId: string) => {
|
||||||
|
setSelected((previous) =>
|
||||||
|
previous.includes(partnerId)
|
||||||
|
? previous.filter((id) => id !== partnerId)
|
||||||
|
: [...previous, partnerId],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitPush = async () => {
|
||||||
|
if (!sendMainGroup && !sendKoc && !createGroupTemplate) {
|
||||||
|
setToast("请至少选择一个推送目标");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sendKoc && selected.length === 0) {
|
||||||
|
setToast("请至少勾选一位合作方,或取消勾选「KOC 个人直达」");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
const parsed = await postAction<OneClickPushOutcomeView>(
|
||||||
|
{
|
||||||
|
action: "task_one_click_push",
|
||||||
|
taskId: task.id,
|
||||||
|
sendMainGroup,
|
||||||
|
sendKoc,
|
||||||
|
createGroupTemplate,
|
||||||
|
selectedKocPartnerIds: selected,
|
||||||
|
force,
|
||||||
|
},
|
||||||
|
"一键推送失败",
|
||||||
|
);
|
||||||
|
setResult(parsed);
|
||||||
|
setPushedToday(true);
|
||||||
|
setToast("推送已执行");
|
||||||
|
await loadLogs();
|
||||||
|
} catch (reason) {
|
||||||
|
const message = reason instanceof Error ? reason.message : "一键推送失败";
|
||||||
|
if (message.includes("仍要推送")) setPushedToday(true);
|
||||||
|
setToast(message);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
|
||||||
|
<div className="modal-card group-push-modal" 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>
|
||||||
|
{pushedToday && (
|
||||||
|
<label className="project-push-target">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={force}
|
||||||
|
onChange={(event) => setForce(event.target.checked)}
|
||||||
|
disabled={sending}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<b>今天已推送过,仍要推送</b>
|
||||||
|
<small>重复推送会再次给合作方发送应用消息,请确认有必要</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div className="project-push-targets">
|
||||||
|
<label className="project-push-target">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={sendMainGroup}
|
||||||
|
onChange={(event) => setSendMainGroup(event.target.checked)}
|
||||||
|
disabled={sending}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<b>项目总群</b>
|
||||||
|
<small>应用消息逐人发送(任务未挂项目时自动跳过)</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="project-push-target">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={createGroupTemplate}
|
||||||
|
onChange={(event) => setCreateGroupTemplate(event.target.checked)}
|
||||||
|
disabled={sending}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<b>项目任务群</b>
|
||||||
|
<small>以群主身份创建企业群发,群主确认后送达客户群</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="project-push-target">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={sendKoc}
|
||||||
|
onChange={(event) => setSendKoc(event.target.checked)}
|
||||||
|
disabled={sending}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<b>KOC 个人直达</b>
|
||||||
|
<small>应用消息直发已绑定企微身份的合作方,点开链接免输身份</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{sendKoc && (
|
||||||
|
<div className="koc-picker">
|
||||||
|
{loading ? (
|
||||||
|
<small>正在读取已绑定合作方…</small>
|
||||||
|
) : partners.length === 0 ? (
|
||||||
|
<small>还没有已绑定企微身份的合作方;合作方在企微内打开领取链接领取后会自动绑定。</small>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="koc-picker-toolbar">
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
|
placeholder="搜索合作方名称 / 企微 ID"
|
||||||
|
disabled={sending}
|
||||||
|
/>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allSelected}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSelected(event.target.checked ? partners.map((partner) => partner.id) : [])
|
||||||
|
}
|
||||||
|
disabled={sending}
|
||||||
|
/>
|
||||||
|
全选({selected.length}/{partners.length})
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="koc-picker-list">
|
||||||
|
{filtered.map((partner) => (
|
||||||
|
<label key={partner.id} className="koc-picker-item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.includes(partner.id)}
|
||||||
|
onChange={() => togglePartner(partner.id)}
|
||||||
|
disabled={sending}
|
||||||
|
/>
|
||||||
|
<span>{partner.name || partner.id}</span>
|
||||||
|
<small>{partner.wecomExternalUserId}</small>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{filtered.length === 0 && <small>没有匹配的合作方</small>}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{result && (
|
||||||
|
<div className="group-push-result">
|
||||||
|
<p>
|
||||||
|
KOC 直达:成功 {result.kocSent} 人、失败 {result.kocFailed} 人。
|
||||||
|
{result.skippedReasons.length > 0 ? `(${result.skippedReasons.join(";")})` : ""}
|
||||||
|
{result.hint ? ` ${result.hint}。` : ""}
|
||||||
|
</p>
|
||||||
|
{result.kocResults.some((item) => !item.ok) && (
|
||||||
|
<ul>
|
||||||
|
{result.kocResults.filter((item) => !item.ok).map((item) => (
|
||||||
|
<li key={item.partnerId} className="group-push-fail">
|
||||||
|
{item.partnerName}:{item.error || "发送失败"}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{logs.length > 0 && (
|
||||||
|
<div className="group-push-result">
|
||||||
|
<b>推送记录</b>
|
||||||
|
<ul>
|
||||||
|
{logs.slice(0, 5).map((log) => (
|
||||||
|
<li key={log.id}>
|
||||||
|
{formatPushLogTime(log.created_at)} · {PUSH_KIND_LABELS[log.push_kind] ?? log.push_kind}
|
||||||
|
{log.push_kind === "task_open"
|
||||||
|
? `(总群 ${log.main_group_count} 人,KOC 直达 ${log.koc_ok_count} 人)`
|
||||||
|
: ""}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button type="button" className="ghost-button" onClick={onClose} disabled={sending}>关闭</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="primary-button"
|
||||||
|
onClick={() => void submitPush()}
|
||||||
|
disabled={sending || (pushedToday && !force) || !task.share_token}
|
||||||
|
>
|
||||||
|
{sending ? "推送中…" : "一键推送"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function LoadingState() {
|
function LoadingState() {
|
||||||
return (
|
return (
|
||||||
<div className="loading-layout" aria-label="正在加载">
|
<div className="loading-layout" aria-label="正在加载">
|
||||||
@@ -3388,12 +3712,14 @@ function TasksPage({
|
|||||||
onCreate,
|
onCreate,
|
||||||
onCopyShare,
|
onCopyShare,
|
||||||
onPushToGroups,
|
onPushToGroups,
|
||||||
|
onOneClickPush,
|
||||||
}: {
|
}: {
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
portalUrl: string;
|
portalUrl: string;
|
||||||
onCreate: () => void;
|
onCreate: () => void;
|
||||||
onCopyShare: (task: Task) => void;
|
onCopyShare: (task: Task) => void;
|
||||||
onPushToGroups: (task: Task) => void;
|
onPushToGroups: (task: Task) => void;
|
||||||
|
onOneClickPush: (task: Task) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
@@ -3447,6 +3773,13 @@ function TasksPage({
|
|||||||
>
|
>
|
||||||
发布到企微群
|
发布到企微群
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="share-link-button"
|
||||||
|
onClick={() => onOneClickPush(task)}
|
||||||
|
disabled={!portalUrl || !task.share_token}
|
||||||
|
>
|
||||||
|
一键推送
|
||||||
|
</button>
|
||||||
{portalUrl && task.share_token && (
|
{portalUrl && task.share_token && (
|
||||||
<a
|
<a
|
||||||
className="round-arrow"
|
className="round-arrow"
|
||||||
|
|||||||
@@ -69,6 +69,13 @@ import {
|
|||||||
setProjectGroupChats,
|
setProjectGroupChats,
|
||||||
} from "../../../lib/project-service";
|
} from "../../../lib/project-service";
|
||||||
import { pushProjectTask } from "../../../lib/project-push-service";
|
import { pushProjectTask } from "../../../lib/project-push-service";
|
||||||
|
import {
|
||||||
|
findTodayTaskPushLog,
|
||||||
|
listBoundWecomPartners,
|
||||||
|
listTaskPushLogs,
|
||||||
|
OneClickPushError,
|
||||||
|
oneClickPushTask,
|
||||||
|
} from "../../../lib/task-one-click-push-service";
|
||||||
|
|
||||||
type ActionBody = {
|
type ActionBody = {
|
||||||
action?: string;
|
action?: string;
|
||||||
@@ -753,6 +760,35 @@ export async function POST(request: Request) {
|
|||||||
appFailedCount: appFailed.length,
|
appFailedCount: appFailed.length,
|
||||||
hint: "任务群走企业群发,群主需在企微客户端「群发助手」点击发送后,消息才会送达客户群",
|
hint: "任务群走企业群发,群主需在企微客户端「群发助手」点击发送后,消息才会送达客户群",
|
||||||
});
|
});
|
||||||
|
} else if (body.action === "wecom_bound_partners") {
|
||||||
|
const partners = await listBoundWecomPartners(db);
|
||||||
|
const taskId = String(body.taskId ?? "").trim();
|
||||||
|
const pushedToday = taskId
|
||||||
|
? await findTodayTaskPushLog(db, taskId, "task_open")
|
||||||
|
: null;
|
||||||
|
return Response.json({ partners, pushedToday });
|
||||||
|
} else if (body.action === "task_one_click_push") {
|
||||||
|
const wecomConfig = resolveWecomConfig(env as unknown as WecomBindings);
|
||||||
|
const outcome = await oneClickPushTask(
|
||||||
|
db,
|
||||||
|
{
|
||||||
|
taskId: body.taskId,
|
||||||
|
sendMainGroup: body.sendMainGroup,
|
||||||
|
sendKoc: body.sendKoc,
|
||||||
|
createGroupTemplate: body.createGroupTemplate,
|
||||||
|
selectedKocPartnerIds: body.selectedKocPartnerIds,
|
||||||
|
force: body.force,
|
||||||
|
text: body.text,
|
||||||
|
},
|
||||||
|
wecomConfig,
|
||||||
|
);
|
||||||
|
return Response.json({
|
||||||
|
...outcome,
|
||||||
|
hint: "KOC 直达消息已即时送达;任务群走企业群发,群主需在企微客户端「群发助手」点击发送后才会送达客户群",
|
||||||
|
});
|
||||||
|
} else if (body.action === "task_push_logs") {
|
||||||
|
const logs = await listTaskPushLogs(db, String(body.taskId ?? "").trim());
|
||||||
|
return Response.json({ logs });
|
||||||
} else if (body.action === "refresh_account_fields") {
|
} else if (body.action === "refresh_account_fields") {
|
||||||
const status = startAccountFieldRefresh(
|
const status = startAccountFieldRefresh(
|
||||||
db,
|
db,
|
||||||
@@ -778,7 +814,8 @@ export async function POST(request: Request) {
|
|||||||
error instanceof FeishuSourceError ||
|
error instanceof FeishuSourceError ||
|
||||||
error instanceof DistributionReleaseError ||
|
error instanceof DistributionReleaseError ||
|
||||||
error instanceof WecomClientError ||
|
error instanceof WecomClientError ||
|
||||||
error instanceof ProjectError
|
error instanceof ProjectError ||
|
||||||
|
error instanceof OneClickPushError
|
||||||
? error.status
|
? error.status
|
||||||
: 500,
|
: 500,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
handleWecomCallbackEvent,
|
handleWecomCallbackEvent,
|
||||||
parseWecomCallbackXml,
|
parseWecomCallbackXml,
|
||||||
} from "../../../lib/wecom-callback-events";
|
} from "../../../lib/wecom-callback-events";
|
||||||
|
import { ensureSchema, getRawDb } from "../../../lib/mvp-db";
|
||||||
import { isEnabled, getRuntimeEnv } from "../../../lib/runtime-env";
|
import { isEnabled, getRuntimeEnv } from "../../../lib/runtime-env";
|
||||||
|
|
||||||
type CallbackConfig = {
|
type CallbackConfig = {
|
||||||
@@ -112,7 +113,12 @@ export async function POST(request: Request) {
|
|||||||
return new Response("receiveid 校验失败", { status: 403 });
|
return new Response("receiveid 校验失败", { status: 403 });
|
||||||
}
|
}
|
||||||
const fields = parseWecomCallbackXml(message);
|
const fields = parseWecomCallbackXml(message);
|
||||||
handleWecomCallbackEvent(fields);
|
try {
|
||||||
|
await ensureSchema();
|
||||||
|
await handleWecomCallbackEvent(getRawDb(), fields);
|
||||||
|
} catch (eventError) {
|
||||||
|
console.error("[KOC LOOP] wecom callback event handling failed", eventError);
|
||||||
|
}
|
||||||
return new Response("", { status: 200 });
|
return new Response("", { status: 200 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof WecomCallbackCryptoError) {
|
if (error instanceof WecomCallbackCryptoError) {
|
||||||
|
|||||||
@@ -5571,6 +5571,62 @@ label small {
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.koc-picker {
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.koc-picker-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.koc-picker-toolbar input[type="text"],
|
||||||
|
.koc-picker-toolbar input:not([type]) {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.koc-picker-toolbar label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.koc-picker-list {
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #e2e8e4;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #fbfcfb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.koc-picker-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
border-bottom: 1px solid #eef2ee;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.koc-picker-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.koc-picker-item span {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.koc-picker-item small {
|
||||||
|
margin-left: auto;
|
||||||
|
color: #8a968f;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── 合作方类型 ───────────────────────────────────────── */
|
/* ── 合作方类型 ───────────────────────────────────────── */
|
||||||
|
|
||||||
.partner-type-tabs {
|
.partner-type-tabs {
|
||||||
|
|||||||
18
db/schema.ts
18
db/schema.ts
@@ -287,6 +287,24 @@ export const wecomGroupPushes = mysqlTable(
|
|||||||
(table) => [index("wecom_group_pushes_task_idx").on(table.taskId)],
|
(table) => [index("wecom_group_pushes_task_idx").on(table.taskId)],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const wecomPushLogs = mysqlTable(
|
||||||
|
"wecom_push_logs",
|
||||||
|
{
|
||||||
|
id: varchar("id", { length: 64 }).primaryKey(),
|
||||||
|
taskId: varchar("task_id", { length: 64 }).notNull(),
|
||||||
|
projectId: varchar("project_id", { length: 64 }).notNull().default(""),
|
||||||
|
pushKind: varchar("push_kind", { length: 32 }).notNull().default("task_open"),
|
||||||
|
mainGroupCount: int("main_group_count").notNull().default(0),
|
||||||
|
kocUserids: text("koc_userids").notNull(),
|
||||||
|
kocOkCount: int("koc_ok_count").notNull().default(0),
|
||||||
|
kocFailList: text("koc_fail_list").notNull(),
|
||||||
|
groupPushId: varchar("group_push_id", { length: 128 }).notNull().default(""),
|
||||||
|
detail: text("detail").notNull(),
|
||||||
|
createdAt: timestamp("created_at"),
|
||||||
|
},
|
||||||
|
(table) => [index("wecom_push_logs_task_idx").on(table.taskId, table.pushKind)],
|
||||||
|
);
|
||||||
|
|
||||||
export const projects = mysqlTable("projects", {
|
export const projects = mysqlTable("projects", {
|
||||||
id: varchar("id", { length: 64 }).primaryKey(),
|
id: varchar("id", { length: 64 }).primaryKey(),
|
||||||
name: varchar("name", { length: 255 }).notNull(),
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
|
|||||||
355
lib/task-one-click-push-service.ts
Normal file
355
lib/task-one-click-push-service.ts
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
import type { DatabaseClient } from "./database";
|
||||||
|
import { getRuntimeEnv } from "./runtime-env";
|
||||||
|
import {
|
||||||
|
sendWecomAppMessage,
|
||||||
|
type WecomConfig,
|
||||||
|
} from "./wecom-client";
|
||||||
|
import { pushProjectTask, type ProjectPushOutcome } from "./project-push-service";
|
||||||
|
import type { TaskPushRow } from "./wecom-group-push-service";
|
||||||
|
|
||||||
|
type FetchLike = typeof fetch;
|
||||||
|
|
||||||
|
export class OneClickPushError extends Error {
|
||||||
|
status: number;
|
||||||
|
|
||||||
|
constructor(message: string, status = 400) {
|
||||||
|
super(message);
|
||||||
|
this.name = "OneClickPushError";
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BoundWecomPartner = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
wecomExternalUserId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type KocPushResult = {
|
||||||
|
partnerId: string;
|
||||||
|
partnerName: string;
|
||||||
|
externalUserId: string;
|
||||||
|
ok: boolean;
|
||||||
|
error: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TaskPushLogRow = {
|
||||||
|
id: string;
|
||||||
|
task_id: string;
|
||||||
|
project_id: string;
|
||||||
|
push_kind: string;
|
||||||
|
main_group_count: number;
|
||||||
|
koc_userids: string;
|
||||||
|
koc_ok_count: number;
|
||||||
|
koc_fail_list: string;
|
||||||
|
group_push_id: string;
|
||||||
|
detail: string;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OneClickPushOutcome = {
|
||||||
|
linkUrl: string;
|
||||||
|
projectOutcome: ProjectPushOutcome | null;
|
||||||
|
kocResults: KocPushResult[];
|
||||||
|
kocSent: number;
|
||||||
|
kocFailed: number;
|
||||||
|
skippedReasons: string[];
|
||||||
|
logId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function text(value: unknown) {
|
||||||
|
return String(value ?? "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildKocDirectMessage(
|
||||||
|
task: Pick<
|
||||||
|
TaskPushRow,
|
||||||
|
| "name"
|
||||||
|
| "brand"
|
||||||
|
| "quantity"
|
||||||
|
| "due_at"
|
||||||
|
| "task_type"
|
||||||
|
| "platform"
|
||||||
|
| "content_format"
|
||||||
|
| "share_token"
|
||||||
|
>,
|
||||||
|
portalUrl: string,
|
||||||
|
): string {
|
||||||
|
const portal = text(portalUrl).replace(/\/$/, "");
|
||||||
|
const claimUrl =
|
||||||
|
portal && task.share_token ? `${portal}/?task=${task.share_token}` : "";
|
||||||
|
const formatLabel =
|
||||||
|
task.task_type === "screenshot_collect"
|
||||||
|
? "截图回收"
|
||||||
|
: task.content_format === "video"
|
||||||
|
? "视频"
|
||||||
|
: "图文";
|
||||||
|
return [
|
||||||
|
`【新任务】${task.name}`,
|
||||||
|
`品牌:${task.brand}|数量:${task.quantity} 份|截止:${task.due_at}`,
|
||||||
|
`平台:${task.platform}|形式:${formatLabel}`,
|
||||||
|
"数量有限,先到先得,点击链接直接领取(企微内打开免输身份):",
|
||||||
|
claimUrl || "领取链接生成失败,请联系管理员",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listBoundWecomPartners(
|
||||||
|
db: DatabaseClient,
|
||||||
|
): Promise<BoundWecomPartner[]> {
|
||||||
|
const result = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, name, wecom_external_user_id
|
||||||
|
FROM partners
|
||||||
|
WHERE wecom_external_user_id IS NOT NULL
|
||||||
|
AND wecom_external_user_id != ''
|
||||||
|
ORDER BY name ASC`,
|
||||||
|
)
|
||||||
|
.all<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
wecom_external_user_id: string;
|
||||||
|
}>();
|
||||||
|
return result.results.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
wecomExternalUserId: row.wecom_external_user_id,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findTodayTaskPushLog(
|
||||||
|
db: DatabaseClient,
|
||||||
|
taskId: string,
|
||||||
|
pushKind = "task_open",
|
||||||
|
): Promise<TaskPushLogRow | null> {
|
||||||
|
const result = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, task_id, project_id, push_kind, main_group_count,
|
||||||
|
koc_userids, koc_ok_count, koc_fail_list, group_push_id,
|
||||||
|
detail, created_at
|
||||||
|
FROM wecom_push_logs
|
||||||
|
WHERE task_id = ? AND push_kind = ?
|
||||||
|
AND DATE(created_at) = CURDATE()
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
)
|
||||||
|
.bind(taskId, pushKind)
|
||||||
|
.first<TaskPushLogRow>();
|
||||||
|
return result ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listTaskPushLogs(
|
||||||
|
db: DatabaseClient,
|
||||||
|
taskId: string,
|
||||||
|
): Promise<TaskPushLogRow[]> {
|
||||||
|
const filter = taskId ? "WHERE task_id = ?" : "";
|
||||||
|
const statement = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, task_id, project_id, push_kind, main_group_count,
|
||||||
|
koc_userids, koc_ok_count, koc_fail_list, group_push_id,
|
||||||
|
detail, created_at
|
||||||
|
FROM wecom_push_logs
|
||||||
|
${filter}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 50`,
|
||||||
|
)
|
||||||
|
.bind(...(taskId ? [taskId] : []));
|
||||||
|
const result = await statement.all<TaskPushLogRow>();
|
||||||
|
return result.results;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordCardAck(
|
||||||
|
db: DatabaseClient,
|
||||||
|
params: { cardTaskId: string; btnKey: string; operator: string },
|
||||||
|
) {
|
||||||
|
const taskId = /^task:([^:]+):/.exec(params.cardTaskId)?.[1] ?? "";
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO wecom_push_logs
|
||||||
|
(id, task_id, project_id, push_kind, main_group_count,
|
||||||
|
koc_userids, koc_ok_count, koc_fail_list, group_push_id, detail)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
`wpl-${crypto.randomUUID().slice(0, 12)}`,
|
||||||
|
taskId || params.cardTaskId,
|
||||||
|
"",
|
||||||
|
"card_ack",
|
||||||
|
0,
|
||||||
|
"[]",
|
||||||
|
0,
|
||||||
|
"[]",
|
||||||
|
"",
|
||||||
|
JSON.stringify({
|
||||||
|
cardTaskId: params.cardTaskId,
|
||||||
|
btnKey: params.btnKey,
|
||||||
|
operator: params.operator,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function oneClickPushTask(
|
||||||
|
db: DatabaseClient,
|
||||||
|
params: {
|
||||||
|
taskId: unknown;
|
||||||
|
sendMainGroup?: unknown;
|
||||||
|
sendKoc?: unknown;
|
||||||
|
createGroupTemplate?: unknown;
|
||||||
|
selectedKocPartnerIds?: unknown;
|
||||||
|
force?: unknown;
|
||||||
|
text?: unknown;
|
||||||
|
},
|
||||||
|
config: WecomConfig,
|
||||||
|
fetchImpl: FetchLike = fetch,
|
||||||
|
): Promise<OneClickPushOutcome> {
|
||||||
|
const taskId = text(params.taskId);
|
||||||
|
if (!taskId) throw new OneClickPushError("缺少 taskId");
|
||||||
|
|
||||||
|
const sendMainGroup = params.sendMainGroup !== false;
|
||||||
|
const sendKoc = params.sendKoc !== false;
|
||||||
|
const createGroupTemplate = params.createGroupTemplate === true;
|
||||||
|
if (!sendMainGroup && !sendKoc && !createGroupTemplate) {
|
||||||
|
throw new OneClickPushError("请至少选择一个推送目标");
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, name, brand, quantity, due_at, task_type, platform,
|
||||||
|
content_format, share_token, project_id
|
||||||
|
FROM tasks WHERE id = ?`,
|
||||||
|
)
|
||||||
|
.bind(taskId)
|
||||||
|
.first<TaskPushRow & { project_id: string }>();
|
||||||
|
if (!task) throw new OneClickPushError("任务不存在", 404);
|
||||||
|
if (!text(task.share_token)) {
|
||||||
|
throw new OneClickPushError("任务尚未生成领取链接,请先开放领取", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.force !== true) {
|
||||||
|
const existing = await findTodayTaskPushLog(db, taskId, "task_open");
|
||||||
|
if (existing) {
|
||||||
|
throw new OneClickPushError(
|
||||||
|
"今天已推送过该任务;如确需再次推送,请勾选「仍要推送」",
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const skippedReasons: string[] = [];
|
||||||
|
const wantsProjectTargets = sendMainGroup || createGroupTemplate;
|
||||||
|
let projectOutcome: ProjectPushOutcome | null = null;
|
||||||
|
if (wantsProjectTargets) {
|
||||||
|
if (!text(task.project_id)) {
|
||||||
|
skippedReasons.push("任务未挂项目,已跳过项目总群与任务群");
|
||||||
|
} else {
|
||||||
|
projectOutcome = await pushProjectTask(
|
||||||
|
db,
|
||||||
|
{
|
||||||
|
taskId,
|
||||||
|
sendMainGroup,
|
||||||
|
sendGroupChats: createGroupTemplate,
|
||||||
|
text: params.text,
|
||||||
|
},
|
||||||
|
config,
|
||||||
|
fetchImpl,
|
||||||
|
);
|
||||||
|
skippedReasons.push(...projectOutcome.skippedReasons);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const portalUrl = text(getRuntimeEnv().KOC_PORTAL_URL);
|
||||||
|
const linkUrl = task.share_token
|
||||||
|
? `${portalUrl.replace(/\/$/, "")}/?task=${task.share_token}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const kocResults: KocPushResult[] = [];
|
||||||
|
if (sendKoc) {
|
||||||
|
const bound = await listBoundWecomPartners(db);
|
||||||
|
const selected = Array.isArray(params.selectedKocPartnerIds)
|
||||||
|
? params.selectedKocPartnerIds.map((id) => text(id)).filter(Boolean)
|
||||||
|
: null;
|
||||||
|
const targets = selected
|
||||||
|
? bound.filter((partner) => selected.includes(partner.id))
|
||||||
|
: bound;
|
||||||
|
if (targets.length === 0) {
|
||||||
|
skippedReasons.push("没有可直达的合作方(未勾选或均未绑定企微身份)");
|
||||||
|
} else {
|
||||||
|
const message = buildKocDirectMessage(task, portalUrl);
|
||||||
|
for (const partner of targets) {
|
||||||
|
try {
|
||||||
|
const sent = await sendWecomAppMessage(
|
||||||
|
[partner.wecomExternalUserId],
|
||||||
|
message,
|
||||||
|
config,
|
||||||
|
fetchImpl,
|
||||||
|
);
|
||||||
|
const failed = sent.sent === 0;
|
||||||
|
kocResults.push({
|
||||||
|
partnerId: partner.id,
|
||||||
|
partnerName: partner.name,
|
||||||
|
externalUserId: partner.wecomExternalUserId,
|
||||||
|
ok: !failed,
|
||||||
|
error: failed ? "企业微信未送达(invaliduser)" : "",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
kocResults.push({
|
||||||
|
partnerId: partner.id,
|
||||||
|
partnerName: partner.name,
|
||||||
|
externalUserId: partner.wecomExternalUserId,
|
||||||
|
ok: false,
|
||||||
|
error: error instanceof Error ? error.message : "发送失败",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const kocSent = kocResults.filter((item) => item.ok).length;
|
||||||
|
const kocFailed = kocResults.length - kocSent;
|
||||||
|
const logId = `wpl-${crypto.randomUUID().slice(0, 12)}`;
|
||||||
|
const groupPushId =
|
||||||
|
projectOutcome?.groupResults.map((item) => item.msgid).join(",") ?? "";
|
||||||
|
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO wecom_push_logs
|
||||||
|
(id, task_id, project_id, push_kind, main_group_count,
|
||||||
|
koc_userids, koc_ok_count, koc_fail_list, group_push_id, detail)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
logId,
|
||||||
|
taskId,
|
||||||
|
text(task.project_id),
|
||||||
|
"task_open",
|
||||||
|
projectOutcome ? projectOutcome.appResults.length : 0,
|
||||||
|
JSON.stringify(
|
||||||
|
kocResults.map((item) => ({
|
||||||
|
partnerId: item.partnerId,
|
||||||
|
name: item.partnerName,
|
||||||
|
externalUserId: item.externalUserId,
|
||||||
|
ok: item.ok,
|
||||||
|
error: item.error,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
kocSent,
|
||||||
|
JSON.stringify(kocResults.filter((item) => !item.ok)),
|
||||||
|
groupPushId,
|
||||||
|
JSON.stringify({
|
||||||
|
skippedReasons,
|
||||||
|
createGroupTemplate,
|
||||||
|
linkUrl,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return {
|
||||||
|
linkUrl,
|
||||||
|
projectOutcome,
|
||||||
|
kocResults,
|
||||||
|
kocSent,
|
||||||
|
kocFailed,
|
||||||
|
skippedReasons,
|
||||||
|
logId,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import type { DatabaseClient } from "./database";
|
||||||
|
import { recordCardAck } from "./task-one-click-push-service";
|
||||||
|
|
||||||
export type WecomCallbackFields = Record<string, string>;
|
export type WecomCallbackFields = Record<string, string>;
|
||||||
|
|
||||||
export type WecomCallbackEventResult = {
|
export type WecomCallbackEventResult = {
|
||||||
@@ -27,9 +30,10 @@ export function extractWecomEncryptField(body: string): string {
|
|||||||
return fields.Encrypt ?? "";
|
return fields.Encrypt ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleWecomCallbackEvent(
|
export async function handleWecomCallbackEvent(
|
||||||
|
db: DatabaseClient,
|
||||||
fields: WecomCallbackFields,
|
fields: WecomCallbackFields,
|
||||||
): WecomCallbackEventResult {
|
): Promise<WecomCallbackEventResult> {
|
||||||
const event = fields.Event ?? "";
|
const event = fields.Event ?? "";
|
||||||
const taskId = fields.TaskId ?? "";
|
const taskId = fields.TaskId ?? "";
|
||||||
const btnKey = fields.BtnKey ?? "";
|
const btnKey = fields.BtnKey ?? "";
|
||||||
@@ -37,10 +41,8 @@ export function handleWecomCallbackEvent(
|
|||||||
|
|
||||||
if (fields.MsgType === "event" && event === "template_card_event") {
|
if (fields.MsgType === "event" && event === "template_card_event") {
|
||||||
if (btnKey === "mark_handled") {
|
if (btnKey === "mark_handled") {
|
||||||
console.info(
|
await recordCardAck(db, { cardTaskId: taskId, btnKey, operator });
|
||||||
"[KOC LOOP] wecom card ack",
|
console.info("[KOC LOOP] wecom card ack recorded", { taskId, operator });
|
||||||
{ taskId, operator },
|
|
||||||
);
|
|
||||||
return { recognized: true, action: "card_ack", taskId, btnKey, operator };
|
return { recognized: true, action: "card_ack", taskId, btnKey, operator };
|
||||||
}
|
}
|
||||||
return { recognized: true, action: "ignored", taskId, btnKey, operator };
|
return { recognized: true, action: "ignored", taskId, btnKey, operator };
|
||||||
|
|||||||
15
mysql/0011_wecom_push_logs.sql
Normal file
15
mysql/0011_wecom_push_logs.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS `wecom_push_logs` (
|
||||||
|
`id` VARCHAR(64) NOT NULL,
|
||||||
|
`task_id` VARCHAR(64) NOT NULL,
|
||||||
|
`project_id` VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
`push_kind` VARCHAR(32) NOT NULL DEFAULT 'task_open',
|
||||||
|
`main_group_count` INT NOT NULL DEFAULT 0,
|
||||||
|
`koc_userids` TEXT NOT NULL,
|
||||||
|
`koc_ok_count` INT NOT NULL DEFAULT 0,
|
||||||
|
`koc_fail_list` TEXT NOT NULL,
|
||||||
|
`group_push_id` VARCHAR(128) NOT NULL DEFAULT '',
|
||||||
|
`detail` TEXT NOT NULL,
|
||||||
|
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `wecom_push_logs_task_idx` (`task_id`,`push_kind`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||||
250
tests/task-one-click-push.test.mjs
Normal file
250
tests/task-one-click-push.test.mjs
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import {
|
||||||
|
buildKocDirectMessage,
|
||||||
|
oneClickPushTask,
|
||||||
|
OneClickPushError,
|
||||||
|
} from "../lib/task-one-click-push-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",
|
||||||
|
};
|
||||||
|
|
||||||
|
const taskRow = {
|
||||||
|
id: "task-1",
|
||||||
|
name: "测试任务",
|
||||||
|
brand: "品牌A",
|
||||||
|
quantity: 3,
|
||||||
|
due_at: "2026-09-05",
|
||||||
|
task_type: "content_publish",
|
||||||
|
platform: "小红书",
|
||||||
|
content_format: "image_text",
|
||||||
|
share_token: "tok-123",
|
||||||
|
project_id: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const boundPartners = [
|
||||||
|
{ id: "p-1", name: "合作方甲", wecom_external_user_id: "wmOK1" },
|
||||||
|
{ id: "p-2", name: "合作方乙", wecom_external_user_id: "wmOK2" },
|
||||||
|
{ id: "p-3", name: "合作方丙", wecom_external_user_id: "wmFAIL" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function fakePushDb({
|
||||||
|
task = taskRow,
|
||||||
|
partners = boundPartners,
|
||||||
|
todayLog = null,
|
||||||
|
} = {}) {
|
||||||
|
const executed = [];
|
||||||
|
const makeStatement = (sql, params = []) => ({
|
||||||
|
sql,
|
||||||
|
params,
|
||||||
|
bind(...args) {
|
||||||
|
return makeStatement(sql, args);
|
||||||
|
},
|
||||||
|
async run() {
|
||||||
|
executed.push({ sql, params });
|
||||||
|
return { meta: { changes: 1 } };
|
||||||
|
},
|
||||||
|
async first() {
|
||||||
|
executed.push({ sql, params });
|
||||||
|
if (/FROM tasks WHERE id = \?/.test(sql)) return task;
|
||||||
|
if (/FROM wecom_push_logs/.test(sql) && /DATE\(created_at\) = CURDATE\(\)/.test(sql)) {
|
||||||
|
return todayLog;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
async all() {
|
||||||
|
executed.push({ sql, params });
|
||||||
|
if (/FROM partners/.test(sql) && /wecom_external_user_id/.test(sql)) {
|
||||||
|
return { results: partners };
|
||||||
|
}
|
||||||
|
return { results: [] };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
executed,
|
||||||
|
prepare(sql) {
|
||||||
|
return makeStatement(sql);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeWecomFetch(calls, { invalidUsers = [] } = {}) {
|
||||||
|
return 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/message/send")) {
|
||||||
|
const body = JSON.parse(String(init.body));
|
||||||
|
if (invalidUsers.includes(body.touser)) {
|
||||||
|
return Response.json({
|
||||||
|
errcode: 0,
|
||||||
|
errmsg: "ok",
|
||||||
|
invaliduser: body.touser,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Response.json({ errcode: 0, errmsg: "ok" });
|
||||||
|
}
|
||||||
|
return new Response("not found", { status: 404 });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeEach(() => {
|
||||||
|
clearWecomAccessTokenCacheForTests();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildKocDirectMessage targets KOC with claim url and OAuth hint", () => {
|
||||||
|
const message = buildKocDirectMessage(taskRow, "https://portal.example.com/");
|
||||||
|
assert.match(message, /【新任务】测试任务/);
|
||||||
|
assert.match(message, /先到先得/);
|
||||||
|
assert.match(message, /免输身份/);
|
||||||
|
assert.match(message, /https:\/\/portal\.example\.com\/\?task=tok-123/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("oneClickPushTask sends only to selected partners and logs the push", async () => {
|
||||||
|
process.env.KOC_PORTAL_URL = "https://portal.example.com";
|
||||||
|
try {
|
||||||
|
const db = fakePushDb();
|
||||||
|
const calls = [];
|
||||||
|
const outcome = await oneClickPushTask(
|
||||||
|
db,
|
||||||
|
{
|
||||||
|
taskId: "task-1",
|
||||||
|
sendMainGroup: false,
|
||||||
|
sendKoc: true,
|
||||||
|
createGroupTemplate: false,
|
||||||
|
selectedKocPartnerIds: ["p-1", "p-3"],
|
||||||
|
},
|
||||||
|
resolveWecomConfig(baseBindings),
|
||||||
|
fakeWecomFetch(calls, { invalidUsers: ["wmFAIL"] }),
|
||||||
|
);
|
||||||
|
const appCalls = calls.filter((call) =>
|
||||||
|
call.url.pathname.endsWith("/cgi-bin/message/send"),
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
appCalls.map((call) => JSON.parse(String(call.init.body)).touser),
|
||||||
|
["wmOK1", "wmFAIL"],
|
||||||
|
);
|
||||||
|
for (const call of appCalls) {
|
||||||
|
assert.match(JSON.parse(String(call.init.body)).text.content, /【新任务】测试任务/);
|
||||||
|
}
|
||||||
|
assert.equal(outcome.kocSent, 1);
|
||||||
|
assert.equal(outcome.kocFailed, 1);
|
||||||
|
assert.equal(outcome.kocResults[1].partnerName, "合作方丙");
|
||||||
|
assert.match(outcome.kocResults[1].error, /invaliduser/);
|
||||||
|
assert.equal(outcome.linkUrl, "https://portal.example.com/?task=tok-123");
|
||||||
|
|
||||||
|
const insert = db.executed.find((item) => /INSERT INTO wecom_push_logs/.test(item.sql));
|
||||||
|
assert.ok(insert, "push log should be written");
|
||||||
|
assert.equal(insert.params[1], "task-1");
|
||||||
|
assert.equal(insert.params[3], "task_open");
|
||||||
|
assert.equal(insert.params[6], 1);
|
||||||
|
const kocUserids = JSON.parse(insert.params[5]);
|
||||||
|
assert.equal(kocUserids.length, 2);
|
||||||
|
assert.equal(kocUserids[0].externalUserId, "wmOK1");
|
||||||
|
} finally {
|
||||||
|
delete process.env.KOC_PORTAL_URL;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("oneClickPushTask blocks duplicate same-day push unless forced", async () => {
|
||||||
|
process.env.KOC_PORTAL_URL = "https://portal.example.com";
|
||||||
|
try {
|
||||||
|
const db = fakePushDb({
|
||||||
|
todayLog: { id: "wpl-old", task_id: "task-1", push_kind: "task_open" },
|
||||||
|
});
|
||||||
|
const calls = [];
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
oneClickPushTask(
|
||||||
|
db,
|
||||||
|
{ taskId: "task-1", sendMainGroup: false, sendKoc: true },
|
||||||
|
resolveWecomConfig(baseBindings),
|
||||||
|
fakeWecomFetch(calls),
|
||||||
|
),
|
||||||
|
(error) =>
|
||||||
|
error instanceof OneClickPushError && /仍要推送/.test(error.message) && error.status === 409,
|
||||||
|
);
|
||||||
|
assert.equal(calls.length, 0, "no wecom api calls on duplicate block");
|
||||||
|
|
||||||
|
const outcome = await oneClickPushTask(
|
||||||
|
db,
|
||||||
|
{
|
||||||
|
taskId: "task-1",
|
||||||
|
sendMainGroup: false,
|
||||||
|
sendKoc: true,
|
||||||
|
selectedKocPartnerIds: ["p-2"],
|
||||||
|
force: true,
|
||||||
|
},
|
||||||
|
resolveWecomConfig(baseBindings),
|
||||||
|
fakeWecomFetch(calls),
|
||||||
|
);
|
||||||
|
assert.equal(outcome.kocSent, 1);
|
||||||
|
} finally {
|
||||||
|
delete process.env.KOC_PORTAL_URL;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("oneClickPushTask without project skips project targets and still reaches KOC", async () => {
|
||||||
|
process.env.KOC_PORTAL_URL = "https://portal.example.com";
|
||||||
|
try {
|
||||||
|
const db = fakePushDb({ task: { ...taskRow, project_id: "" } });
|
||||||
|
const calls = [];
|
||||||
|
const outcome = await oneClickPushTask(
|
||||||
|
db,
|
||||||
|
{ taskId: "task-1", sendMainGroup: true, sendKoc: true },
|
||||||
|
resolveWecomConfig(baseBindings),
|
||||||
|
fakeWecomFetch(calls),
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
outcome.skippedReasons.some((reason) => /未挂项目/.test(reason)),
|
||||||
|
"should explain project skip",
|
||||||
|
);
|
||||||
|
assert.equal(outcome.projectOutcome, null);
|
||||||
|
assert.equal(outcome.kocSent, 3);
|
||||||
|
assert.equal(
|
||||||
|
calls.filter((call) => call.url.pathname.includes("/cgi-bin/externalcontact/")).length,
|
||||||
|
0,
|
||||||
|
"no group template calls expected",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
delete process.env.KOC_PORTAL_URL;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("oneClickPushTask requires share token and at least one target", async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
oneClickPushTask(
|
||||||
|
fakePushDb({ task: { ...taskRow, share_token: "" } }),
|
||||||
|
{ taskId: "task-1", sendMainGroup: false, sendKoc: true },
|
||||||
|
resolveWecomConfig(baseBindings),
|
||||||
|
fakeWecomFetch([]),
|
||||||
|
),
|
||||||
|
(error) => error instanceof OneClickPushError && /领取链接/.test(error.message),
|
||||||
|
);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
oneClickPushTask(
|
||||||
|
fakePushDb(),
|
||||||
|
{ taskId: "task-1", sendMainGroup: false, sendKoc: false, createGroupTemplate: false },
|
||||||
|
resolveWecomConfig(baseBindings),
|
||||||
|
fakeWecomFetch([]),
|
||||||
|
),
|
||||||
|
(error) => error instanceof OneClickPushError && /至少选择一个推送目标/.test(error.message),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -84,8 +84,23 @@ test("parse callback xml handles CDATA and plain fields", () => {
|
|||||||
assert.equal(extractWecomEncryptField(body), "RW5jcnlwdGVkQmFzZTY0PT0=");
|
assert.equal(extractWecomEncryptField(body), "RW5jcnlwdGVkQmFzZTY0PT0=");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("event dispatcher classifies template_card mark_handled and ignores others", () => {
|
test("event dispatcher classifies template_card mark_handled and ignores others", async () => {
|
||||||
const ack = handleWecomCallbackEvent({
|
const inserts = [];
|
||||||
|
const fakeDb = {
|
||||||
|
prepare(sql) {
|
||||||
|
return {
|
||||||
|
bind(...params) {
|
||||||
|
return {
|
||||||
|
async run() {
|
||||||
|
inserts.push({ sql, params });
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const ack = await handleWecomCallbackEvent(fakeDb, {
|
||||||
MsgType: "event",
|
MsgType: "event",
|
||||||
Event: "template_card_event",
|
Event: "template_card_event",
|
||||||
TaskId: "task:t123:due",
|
TaskId: "task:t123:due",
|
||||||
@@ -96,8 +111,12 @@ test("event dispatcher classifies template_card mark_handled and ignores others"
|
|||||||
assert.equal(ack.action, "card_ack");
|
assert.equal(ack.action, "card_ack");
|
||||||
assert.equal(ack.taskId, "task:t123:due");
|
assert.equal(ack.taskId, "task:t123:due");
|
||||||
assert.equal(ack.operator, "opsUser01");
|
assert.equal(ack.operator, "opsUser01");
|
||||||
|
assert.equal(inserts.length, 1);
|
||||||
|
assert.match(inserts[0].sql, /INSERT INTO wecom_push_logs/);
|
||||||
|
assert.equal(inserts[0].params[0].startsWith("wpl-"), true);
|
||||||
|
assert.equal(inserts[0].params[1], "t123");
|
||||||
|
|
||||||
const view = handleWecomCallbackEvent({
|
const view = await handleWecomCallbackEvent(fakeDb, {
|
||||||
MsgType: "event",
|
MsgType: "event",
|
||||||
Event: "template_card_event",
|
Event: "template_card_event",
|
||||||
TaskId: "task:t123:due",
|
TaskId: "task:t123:due",
|
||||||
@@ -106,7 +125,7 @@ test("event dispatcher classifies template_card mark_handled and ignores others"
|
|||||||
assert.equal(view.recognized, true);
|
assert.equal(view.recognized, true);
|
||||||
assert.equal(view.action, "ignored");
|
assert.equal(view.action, "ignored");
|
||||||
|
|
||||||
const other = handleWecomCallbackEvent({
|
const other = await handleWecomCallbackEvent(fakeDb, {
|
||||||
MsgType: "event",
|
MsgType: "event",
|
||||||
Event: "change_contact",
|
Event: "change_contact",
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user