feat: 任务领取/找回支持企微身份票据,领取自动绑定催办通道

This commit is contained in:
ABAPPLO
2026-08-31 17:13:03 +08:00
parent b83a6a53c9
commit 6e590e3b08
2 changed files with 109 additions and 18 deletions

View File

@@ -23,6 +23,11 @@ import {
} from "../../../lib/partner-utils"; } from "../../../lib/partner-utils";
import { extractPublishUrl } from "../../../lib/publish-url"; import { extractPublishUrl } from "../../../lib/publish-url";
import { parseClaimantIdentifier } from "../../../lib/claimant-identifier"; import { parseClaimantIdentifier } from "../../../lib/claimant-identifier";
import {
resolveWecomClaimant,
verifyWecomIdentityTicket,
type WecomIdentity,
} from "../../../lib/wecom-oauth-service";
import { import {
partnerOptions, partnerOptions,
withPartnerCors, withPartnerCors,
@@ -39,6 +44,7 @@ type PartnerBody = {
delegationBundleId?: string; delegationBundleId?: string;
claimantIdentifier?: string; claimantIdentifier?: string;
claimantName?: string; claimantName?: string;
identityTicket?: string;
quantity?: number; quantity?: number;
publishUrl?: string; publishUrl?: string;
exposure?: number | string; exposure?: number | string;
@@ -446,13 +452,27 @@ async function handlePost(request: Request) {
} }
const db = getRawDb(); const db = getRawDb();
let wecomIdentity: WecomIdentity | null = null;
if (body.identityTicket) {
try {
wecomIdentity = await verifyWecomIdentityTicket(
textValue(body.identityTicket, 512),
);
} catch {
wecomIdentity = null;
}
}
if (body.action === "recover") { if (body.action === "recover") {
const identifierInput = textValue( const identifierInput = textValue(
body.claimantIdentifier ?? body.claimantName, body.claimantIdentifier ?? body.claimantName,
64, 64,
); );
const claimantIdentifier = parseClaimantIdentifier(identifierInput); const claimantIdentifier = parseClaimantIdentifier(identifierInput);
if (!claimantIdentifier && identifierInput.length < 2) { const wecomPartnerId = wecomIdentity
? (await resolveWecomClaimant(db, wecomIdentity.externalUserId)).partnerId
: null;
if (!claimantIdentifier && identifierInput.length < 2 && !wecomPartnerId) {
return Response.json( return Response.json(
{ error: "请输入领取时使用的正确微信号或手机号" }, { error: "请输入领取时使用的正确微信号或手机号" },
{ status: 400 }, { status: 400 },
@@ -466,6 +486,11 @@ async function handlePost(request: Request) {
`claimant:${claimantIdentifier.canonical}`, `claimant:${claimantIdentifier.canonical}`,
)}` )}`
: legacyPartnerId; : legacyPartnerId;
const partnerCandidates = [
...new Set(
wecomPartnerId ? [wecomPartnerId] : [partnerId, legacyPartnerId],
),
];
const recovered = await db const recovered = await db
.prepare( .prepare(
`SELECT `SELECT
@@ -481,12 +506,14 @@ async function handlePost(request: Request) {
FROM claims c FROM claims c
LEFT JOIN distributions d ON d.claim_id = c.id LEFT JOIN distributions d ON d.claim_id = c.id
LEFT JOIN contents co ON co.id = d.content_id LEFT JOIN contents co ON co.id = d.content_id
WHERE c.task_id = ? AND c.partner_id IN (?, ?) WHERE c.task_id = ? AND c.partner_id IN (${partnerCandidates
.map(() => "?")
.join(", ")})
GROUP BY c.id, c.claim_token, c.quantity, c.created_at GROUP BY c.id, c.claim_token, c.quantity, c.created_at
ORDER BY c.created_at DESC, c.id DESC ORDER BY c.created_at DESC, c.id DESC
LIMIT 20`, LIMIT 20`,
) )
.bind(task.task_type, task.task_type, task.id, partnerId, legacyPartnerId) .bind(task.task_type, task.task_type, task.id, ...partnerCandidates)
.all<{ .all<{
claim_token: string; claim_token: string;
quantity: number; quantity: number;
@@ -522,7 +549,7 @@ async function handlePost(request: Request) {
); );
const claimantIdentifier = parseClaimantIdentifier(identifierInput); const claimantIdentifier = parseClaimantIdentifier(identifierInput);
const quantity = quantityValue(body.quantity); const quantity = quantityValue(body.quantity);
if (!claimantIdentifier) { if (!claimantIdentifier && !wecomIdentity) {
return Response.json( return Response.json(
{ error: "请输入正确的微信号或手机号" }, { error: "请输入正确的微信号或手机号" },
{ status: 400 }, { status: 400 },
@@ -540,23 +567,45 @@ async function handlePost(request: Request) {
if (available.results.length === 0) { if (available.results.length === 0) {
return Response.json({ error: "当前任务已领完" }, { status: 409 }); return Response.json({ error: "当前任务已领完" }, { status: 409 });
} }
const partnerId = `partner-ext-${hashText( const partnerId = wecomIdentity
`claimant:${claimantIdentifier.canonical}`, ? (await resolveWecomClaimant(db, wecomIdentity.externalUserId)).partnerId
)}`; : `partner-ext-${hashText(
const claimantName = claimantIdentifier.display; `claimant:${claimantIdentifier?.canonical}`,
)}`;
const claimantName = wecomIdentity
? wecomIdentity.displayName
: (claimantIdentifier?.display ?? "");
const claimId = uid("claim"); const claimId = uid("claim");
const claimToken = crypto.randomUUID().replaceAll("-", ""); const claimToken = crypto.randomUUID().replaceAll("-", "");
const statements: DatabaseStatement[] = [ const statements: DatabaseStatement[] = [
db wecomIdentity
.prepare( ? db
`INSERT INTO partners .prepare(
(id, name, wecom_name, owner, claimed_total, completed_total) `INSERT INTO partners
VALUES (?, ?, ?, '外部KOC', 0, 0) (id, name, wecom_name, owner, claimed_total, completed_total,
ON CONFLICT(id) DO UPDATE SET wecom_external_user_id)
name = excluded.name, VALUES (?, ?, ?, '外部KOC', 0, 0, ?)
wecom_name = excluded.wecom_name`, ON CONFLICT(id) DO UPDATE SET
) name = excluded.name,
.bind(partnerId, claimantName, claimantName), wecom_name = excluded.wecom_name,
wecom_external_user_id = excluded.wecom_external_user_id`,
)
.bind(
partnerId,
claimantName,
claimantName,
wecomIdentity.externalUserId,
)
: db
.prepare(
`INSERT INTO partners
(id, name, wecom_name, owner, claimed_total, completed_total)
VALUES (?, ?, ?, '外部KOC', 0, 0)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
wecom_name = excluded.wecom_name`,
)
.bind(partnerId, claimantName, claimantName),
db db
.prepare( .prepare(
`INSERT INTO claims `INSERT INTO claims

View File

@@ -0,0 +1,42 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
const routeSource = readFileSync(
new URL("../app/api/partner/route.ts", import.meta.url),
"utf8",
);
test("partner route accepts identityTicket for wecom-backed claim", () => {
assert.match(routeSource, /identityTicket\?: string/);
assert.match(routeSource, /verifyWecomIdentityTicket/);
assert.match(routeSource, /resolveWecomClaimant/);
});
test("claim keeps manual wechat/phone path untouched when no ticket", () => {
assert.match(
routeSource,
/请输入正确的微信号或手机号/,
"manual validation message preserved",
);
assert.match(
routeSource,
/VALUES \(\?, \?, \?, '外部KOC', 0, 0\)\s*\n\s*ON CONFLICT\(id\) DO UPDATE SET\s*\n\s*name = excluded\.name,\s*\n\s*wecom_name = excluded\.wecom_name`/,
"manual partner upsert statement preserved",
);
});
test("claim with ticket upserts wecom_external_user_id for reminders", () => {
assert.match(
routeSource,
/wecom_external_user_id = excluded\.wecom_external_user_id/,
);
});
test("recover builds dynamic partner candidates including wecom identity", () => {
assert.match(
routeSource,
/wecomPartnerId \? \[wecomPartnerId\] : \[partnerId, legacyPartnerId\]/,
);
assert.match(routeSource, /\.bind\(task\.task_type, task\.task_type, task\.id, \.\.\.partnerCandidates\)/);
});