Use unique contact identifiers for KOC claims
This commit is contained in:
@@ -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[] = [
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
## MVP 流程
|
## MVP 流程
|
||||||
|
|
||||||
1. KOC 通过带 `task` 参数的任务链接进入。
|
1. KOC 通过带 `task` 参数的任务链接进入。
|
||||||
2. 只填写企微昵称/联系人和领取数量。
|
2. 只填写具有唯一性的微信号/手机号和领取数量。
|
||||||
3. 领取后只能看到本次领取的笔记。
|
3. 领取后只能看到本次领取的笔记。
|
||||||
4. 在单篇笔记详情页查看标题与正文,并一一回填发布账号昵称、发布链接和发布截图。
|
4. 在单篇笔记详情页查看标题与正文,并一一回填发布账号昵称、发布链接和发布截图。
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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, /发布账号昵称/);
|
||||||
|
|||||||
35
lib/claimant-identifier.ts
Normal file
35
lib/claimant-identifier.ts
Normal 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;
|
||||||
|
}
|
||||||
30
tests/claimant-identifier.test.mjs
Normal file
30
tests/claimant-identifier.test.mjs
Normal 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);
|
||||||
|
});
|
||||||
@@ -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, /没有找到领取记录/);
|
||||||
|
|||||||
Reference in New Issue
Block a user