Use unique contact identifiers for KOC claims

This commit is contained in:
巫凤萍
2026-08-03 13:48:01 +08:00
parent d2cde2c9b2
commit b081946752
7 changed files with 119 additions and 21 deletions

View File

@@ -16,6 +16,7 @@ import {
accountFromPublishLink, accountFromPublishLink,
extractXhsPublishUrl, extractXhsPublishUrl,
} from "../../../lib/partner-utils"; } from "../../../lib/partner-utils";
import { parseClaimantIdentifier } from "../../../lib/claimant-identifier";
import { import {
partnerOptions, partnerOptions,
withPartnerCors, withPartnerCors,
@@ -32,6 +33,7 @@ type PartnerBody = {
distributionIds?: string[]; distributionIds?: string[];
delegationLabel?: string; delegationLabel?: string;
delegationBundleId?: string; delegationBundleId?: string;
claimantIdentifier?: string;
claimantName?: string; claimantName?: string;
quantity?: number; quantity?: number;
accountNickname?: string; accountNickname?: string;
@@ -411,16 +413,25 @@ async function handlePost(request: Request) {
const db = getRawDb(); const db = getRawDb();
if (body.action === "recover") { if (body.action === "recover") {
const claimantName = textValue(body.claimantName, 40); const identifierInput = textValue(
if (claimantName.length < 2) { body.claimantIdentifier ?? body.claimantName,
64,
);
const claimantIdentifier = parseClaimantIdentifier(identifierInput);
if (!claimantIdentifier && identifierInput.length < 2) {
return Response.json( return Response.json(
{ error: "请填写领取时使用的企微昵称或联系人" }, { error: "请输入领取时使用的正确微信号或手机号" },
{ status: 400 }, { status: 400 },
); );
} }
const partnerId = `partner-ext-${hashText( const legacyPartnerId = `partner-ext-${hashText(
`${task.id}:${claimantName.toLowerCase()}`, `${task.id}:${identifierInput.toLowerCase()}`,
)}`; )}`;
const partnerId = claimantIdentifier
? `partner-ext-${hashText(
`claimant:${claimantIdentifier.canonical}`,
)}`
: legacyPartnerId;
const recovered = await db const recovered = await db
.prepare( .prepare(
`SELECT `SELECT
@@ -433,12 +444,12 @@ 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 = ? WHERE c.task_id = ? AND c.partner_id IN (?, ?)
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.id, partnerId) .bind(task.id, partnerId, legacyPartnerId)
.all<{ .all<{
claim_token: string; claim_token: string;
quantity: number; quantity: number;
@@ -449,7 +460,7 @@ async function handlePost(request: Request) {
}>(); }>();
if (recovered.results.length === 0) { if (recovered.results.length === 0) {
return Response.json( return Response.json(
{ error: "没有找到领取记录,请确认昵称与领取时完全一致" }, { error: "没有找到领取记录,请确认微信号或手机号与领取时完全一致" },
{ status: 404 }, { status: 404 },
); );
} }
@@ -468,10 +479,17 @@ async function handlePost(request: Request) {
if (task.status !== "active") { if (task.status !== "active") {
return Response.json({ error: "任务已结束,无法继续领取" }, { status: 409 }); 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); const quantity = quantityValue(body.quantity);
if (claimantName.length < 2) { if (!claimantIdentifier) {
return Response.json({ error: "请填写企微昵称或联系人" }, { status: 400 }); return Response.json(
{ error: "请输入正确的微信号或手机号" },
{ status: 400 },
);
} }
const available = await db const available = await db
.prepare( .prepare(
@@ -486,8 +504,9 @@ async function handlePost(request: Request) {
return Response.json({ error: "当前任务已领完" }, { status: 409 }); return Response.json({ error: "当前任务已领完" }, { status: 409 });
} }
const partnerId = `partner-ext-${hashText( const partnerId = `partner-ext-${hashText(
`${task.id}:${claimantName.toLowerCase()}`, `claimant:${claimantIdentifier.canonical}`,
)}`; )}`;
const claimantName = claimantIdentifier.display;
const claimId = uid("claim"); const claimId = uid("claim");
const claimToken = crypto.randomUUID().replaceAll("-", ""); const claimToken = crypto.randomUUID().replaceAll("-", "");
const statements: D1PreparedStatement[] = [ const statements: D1PreparedStatement[] = [

View File

@@ -5,7 +5,7 @@
## MVP 流程 ## MVP 流程
1. KOC 通过带 `task` 参数的任务链接进入。 1. KOC 通过带 `task` 参数的任务链接进入。
2. 只填写企微昵称/联系人和领取数量。 2. 只填写具有唯一性的微信号/手机号和领取数量。
3. 领取后只能看到本次领取的笔记。 3. 领取后只能看到本次领取的笔记。
4. 在单篇笔记详情页查看标题与正文,并一一回填发布账号昵称、发布链接和发布截图。 4. 在单篇笔记详情页查看标题与正文,并一一回填发布账号昵称、发布链接和发布截图。

View File

@@ -291,7 +291,7 @@ export default function Home() {
body: JSON.stringify({ body: JSON.stringify({
action: "claim", action: "claim",
taskToken, taskToken,
claimantName, claimantIdentifier: claimantName,
quantity, quantity,
}), }),
}); });
@@ -335,7 +335,7 @@ export default function Home() {
body: JSON.stringify({ body: JSON.stringify({
action: "recover", action: "recover",
taskToken, taskToken,
claimantName, claimantIdentifier: claimantName,
}), }),
}); });
const result = (await response.json()) as { const result = (await response.json()) as {
@@ -1222,7 +1222,7 @@ export default function Home() {
</section> </section>
<div className="safe-tip"> <div className="safe-tip">
{isClaimOwner {isClaimOwner
? "以后再次打开原任务链接,填写相同企微昵称即可找回全部领取批次和分享记录。" ? "以后再次打开原任务链接,填写相同微信号或手机号即可找回全部领取批次和分享记录。"
: "请保存当前分享链接发布后仍需通过此链接回填第7天截图、曝光量和阅读量。"} : "请保存当前分享链接发布后仍需通过此链接回填第7天截图、曝光量和阅读量。"}
</div> </div>
{toast && <div className="toast">{toast}</div>} {toast && <div className="toast">{toast}</div>}
@@ -1276,11 +1276,15 @@ export default function Home() {
<h2></h2> <h2></h2>
</div> </div>
<label> <label>
<span> / </span> <span> / </span>
<input <input
value={claimantName} value={claimantName}
onChange={(event) => setClaimantName(event.target.value)} onChange={(event) => setClaimantName(event.target.value)}
placeholder="用于找回你的领取记录" placeholder="请输入微信号或手机号"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
maxLength={64}
required required
/> />
</label> </label>
@@ -1314,15 +1318,19 @@ export default function Home() {
<p className="micro"></p> <p className="micro"></p>
<h2></h2> <h2></h2>
<p className="recover-intro"> <p className="recover-intro">
使 使
</p> </p>
</div> </div>
<label> <label>
<span> / </span> <span> / </span>
<input <input
value={claimantName} value={claimantName}
onChange={(event) => setClaimantName(event.target.value)} onChange={(event) => setClaimantName(event.target.value)}
placeholder="需与领取时完全一致" placeholder="需与领取时完全一致"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
maxLength={64}
required required
/> />
</label> </label>

View File

@@ -26,7 +26,9 @@ test("keeps claiming minimal and backfill one-to-one", async () => {
readFile(new URL("../.openai/hosting.json", import.meta.url), "utf8"), 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, /const \[quantity, setQuantity\] = useState\(1\)/);
assert.match(page, /distributionId:\s*selected\.id/); assert.match(page, /distributionId:\s*selected\.id/);
assert.match(page, /发布账号昵称/); assert.match(page, /发布账号昵称/);

View File

@@ -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;
}

View File

@@ -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);
});

View File

@@ -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, /笔记与领取凭证不匹配/); assert.match(partnerRoute, /笔记与领取凭证不匹配/);
assert.match(partnerRoute, /action === "recover"/); 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, /extractXhsPublishUrl/);
assert.match(partnerRoute, /小红书长链或短链/); assert.match(partnerRoute, /小红书长链或短链/);
assert.match(partnerRoute, /没有找到领取记录/); assert.match(partnerRoute, /没有找到领取记录/);