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