diff --git a/app/api/partner/route.ts b/app/api/partner/route.ts index 0cf3a1e..62a5bde 100644 --- a/app/api/partner/route.ts +++ b/app/api/partner/route.ts @@ -16,6 +16,7 @@ import { accountFromPublishLink, extractXhsPublishUrl, } from "../../../lib/partner-utils"; +import { parseClaimantIdentifier } from "../../../lib/claimant-identifier"; import { partnerOptions, withPartnerCors, @@ -32,6 +33,7 @@ type PartnerBody = { distributionIds?: string[]; delegationLabel?: string; delegationBundleId?: string; + claimantIdentifier?: string; claimantName?: string; quantity?: number; accountNickname?: string; @@ -411,16 +413,25 @@ async function handlePost(request: Request) { const db = getRawDb(); if (body.action === "recover") { - const claimantName = textValue(body.claimantName, 40); - if (claimantName.length < 2) { + const identifierInput = textValue( + body.claimantIdentifier ?? body.claimantName, + 64, + ); + const claimantIdentifier = parseClaimantIdentifier(identifierInput); + if (!claimantIdentifier && identifierInput.length < 2) { return Response.json( - { error: "请填写领取时使用的企微昵称或联系人" }, + { error: "请输入领取时使用的正确微信号或手机号" }, { status: 400 }, ); } - const partnerId = `partner-ext-${hashText( - `${task.id}:${claimantName.toLowerCase()}`, + const legacyPartnerId = `partner-ext-${hashText( + `${task.id}:${identifierInput.toLowerCase()}`, )}`; + const partnerId = claimantIdentifier + ? `partner-ext-${hashText( + `claimant:${claimantIdentifier.canonical}`, + )}` + : legacyPartnerId; const recovered = await db .prepare( `SELECT @@ -433,12 +444,12 @@ 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 = ? + WHERE c.task_id = ? AND c.partner_id IN (?, ?) 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.id, partnerId) + .bind(task.id, partnerId, legacyPartnerId) .all<{ claim_token: string; quantity: number; @@ -449,7 +460,7 @@ async function handlePost(request: Request) { }>(); if (recovered.results.length === 0) { return Response.json( - { error: "没有找到领取记录,请确认昵称与领取时完全一致" }, + { error: "没有找到领取记录,请确认微信号或手机号与领取时完全一致" }, { status: 404 }, ); } @@ -468,10 +479,17 @@ async function handlePost(request: Request) { if (task.status !== "active") { return Response.json({ error: "任务已结束,无法继续领取" }, { status: 409 }); } - const claimantName = textValue(body.claimantName, 40); + const identifierInput = textValue( + body.claimantIdentifier ?? body.claimantName, + 64, + ); + const claimantIdentifier = parseClaimantIdentifier(identifierInput); const quantity = quantityValue(body.quantity); - if (claimantName.length < 2) { - return Response.json({ error: "请填写企微昵称或联系人" }, { status: 400 }); + if (!claimantIdentifier) { + return Response.json( + { error: "请输入正确的微信号或手机号" }, + { status: 400 }, + ); } const available = await db .prepare( @@ -486,8 +504,9 @@ async function handlePost(request: Request) { return Response.json({ error: "当前任务已领完" }, { status: 409 }); } const partnerId = `partner-ext-${hashText( - `${task.id}:${claimantName.toLowerCase()}`, + `claimant:${claimantIdentifier.canonical}`, )}`; + const claimantName = claimantIdentifier.display; const claimId = uid("claim"); const claimToken = crypto.randomUUID().replaceAll("-", ""); const statements: D1PreparedStatement[] = [ diff --git a/koc-portal/README.md b/koc-portal/README.md index b795f97..c17da70 100644 --- a/koc-portal/README.md +++ b/koc-portal/README.md @@ -5,7 +5,7 @@ ## MVP 流程 1. KOC 通过带 `task` 参数的任务链接进入。 -2. 只填写企微昵称/联系人和领取数量。 +2. 只填写具有唯一性的微信号/手机号和领取数量。 3. 领取后只能看到本次领取的笔记。 4. 在单篇笔记详情页查看标题与正文,并一一回填发布账号昵称、发布链接和发布截图。 diff --git a/koc-portal/app/page.tsx b/koc-portal/app/page.tsx index 8fd2a0a..ca15503 100644 --- a/koc-portal/app/page.tsx +++ b/koc-portal/app/page.tsx @@ -291,7 +291,7 @@ export default function Home() { body: JSON.stringify({ action: "claim", taskToken, - claimantName, + claimantIdentifier: claimantName, quantity, }), }); @@ -335,7 +335,7 @@ export default function Home() { body: JSON.stringify({ action: "recover", taskToken, - claimantName, + claimantIdentifier: claimantName, }), }); const result = (await response.json()) as { @@ -1222,7 +1222,7 @@ export default function Home() {
找回历史记录
- 输入领取时使用的企微昵称;同一任务多次领取会分批展示。 + 输入领取时使用的微信号或手机号;同一任务多次领取会分批展示。
diff --git a/koc-portal/tests/rendered-html.test.mjs b/koc-portal/tests/rendered-html.test.mjs index fd23092..56c9c6f 100644 --- a/koc-portal/tests/rendered-html.test.mjs +++ b/koc-portal/tests/rendered-html.test.mjs @@ -26,7 +26,9 @@ test("keeps claiming minimal and backfill one-to-one", async () => { readFile(new URL("../.openai/hosting.json", import.meta.url), "utf8"), ]); - assert.match(page, /企微昵称\s*\/\s*联系人/); + assert.match(page, /微信号\s*\/\s*手机号/); + assert.doesNotMatch(page, /企微昵称\s*\/\s*联系人/); + assert.match(page, /claimantIdentifier:\s*claimantName/); assert.match(page, /const \[quantity, setQuantity\] = useState\(1\)/); assert.match(page, /distributionId:\s*selected\.id/); assert.match(page, /发布账号昵称/); diff --git a/lib/claimant-identifier.ts b/lib/claimant-identifier.ts new file mode 100644 index 0000000..0e58043 --- /dev/null +++ b/lib/claimant-identifier.ts @@ -0,0 +1,35 @@ +export type ClaimantIdentifier = { + canonical: string; + display: string; + kind: "phone" | "wechat"; +}; + +function stripLabel(value: string) { + return value + .replace(/^(?:微信号|微信|wechat|手机号|手机)\s*[::]?\s*/i, "") + .trim(); +} + +export function parseClaimantIdentifier(value: unknown): ClaimantIdentifier | null { + const input = stripLabel(String(value ?? "").trim()); + if (!input) return null; + + const phone = input.replace(/[\s-]/g, "").replace(/^\+?86/, ""); + if (/^1[3-9]\d{9}$/.test(phone)) { + return { + canonical: `phone:${phone}`, + display: phone, + kind: "phone", + }; + } + + if (/^[A-Za-z][A-Za-z0-9_-]{5,31}$/.test(input)) { + return { + canonical: `wechat:${input.toLowerCase()}`, + display: input, + kind: "wechat", + }; + } + + return null; +} diff --git a/tests/claimant-identifier.test.mjs b/tests/claimant-identifier.test.mjs new file mode 100644 index 0000000..b9885a9 --- /dev/null +++ b/tests/claimant-identifier.test.mjs @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { parseClaimantIdentifier } from "../lib/claimant-identifier.ts"; + +test("normalizes mainland mobile numbers into one unique identity", () => { + assert.deepEqual(parseClaimantIdentifier("+86 138-0013-8000"), { + canonical: "phone:13800138000", + display: "13800138000", + kind: "phone", + }); + assert.deepEqual(parseClaimantIdentifier("手机号:13800138000"), { + canonical: "phone:13800138000", + display: "13800138000", + kind: "phone", + }); +}); + +test("normalizes WeChat IDs case-insensitively while preserving display", () => { + assert.deepEqual(parseClaimantIdentifier("微信号:Koc_User-01"), { + canonical: "wechat:koc_user-01", + display: "Koc_User-01", + kind: "wechat", + }); +}); + +test("rejects nicknames and malformed identifiers", () => { + assert.equal(parseClaimantIdentifier("巫凤萍"), null); + assert.equal(parseClaimantIdentifier("1380013800"), null); + assert.equal(parseClaimantIdentifier("123456"), null); +}); diff --git a/tests/rendered-html.test.mjs b/tests/rendered-html.test.mjs index 3d84a2a..1a78362 100644 --- a/tests/rendered-html.test.mjs +++ b/tests/rendered-html.test.mjs @@ -108,6 +108,10 @@ test("issues external task links and supports one-to-one note submissions", asyn assert.match(partnerRoute, /请填写正确的曝光量和阅读量/); assert.match(partnerRoute, /笔记与领取凭证不匹配/); assert.match(partnerRoute, /action === "recover"/); + assert.match(partnerRoute, /parseClaimantIdentifier/); + assert.match(partnerRoute, /claimantIdentifier\.canonical/); + assert.match(partnerRoute, /legacyPartnerId/); + assert.match(partnerRoute, /微信号或手机号/); assert.match(partnerRoute, /extractXhsPublishUrl/); assert.match(partnerRoute, /小红书长链或短链/); assert.match(partnerRoute, /没有找到领取记录/);