把原本存在浏览器 localStorage 里、改一下就能绕过的积分,搬到服务端存储与校验。零服务器、零费用,iPad 上点几下就能部署。
整段复制,粘贴进 Cloudflare Worker 的在线编辑器。积分以「厘」为单位整数存储(1 积分 = 1000),彻底避开 0.1 / 0.01 的浮点误差。
POST /api/pointsPOST /api/consumePOST /api/redeem /api/admin/grant/api/admin/codes/api/admin/users/api/admin/stats
/* 序章 · 服务端积分后端 Cloudflare Workers + KV */
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
const path = url.pathname.replace(/\/+$/, '') || '/';
const cors = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Max-Age': '86400'
};
if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: cors });
if (path === '/' || path === '/health') {
return json({ ok: true, service: 'xuzhang-points', init: INIT_POINTS, cost: COST }, 200, cors);
}
let body = {};
if (request.method === 'POST') {
try { body = await request.json(); } catch (e) { body = {}; }
} else {
body = Object.fromEntries(url.searchParams);
}
try {
if (path === '/api/points') return json(await getPoints(env, body), 200, cors);
if (path === '/api/consume') return json(await consume(env, body, request), 200, cors);
if (path === '/api/redeem') return json(await redeem(env, body), 200, cors);
if (path === '/api/admin/grant') return json(await grant(env, body), 200, cors);
if (path === '/api/admin/codes') return json(await makeCodes(env, body), 200, cors);
if (path === '/api/admin/users') return json(await listUsers(env, body), 200, cors);
if (path === '/api/admin/stats') return json(await stats(env, body), 200, cors);
return json({ ok: false, error: 'not_found' }, 404, cors);
} catch (err) {
return json({ ok: false, error: 'server_error', detail: String(err) }, 500, cors);
}
}
};
const UNIT = 1000;
const INIT_POINTS = 10;
const COST = { chat: 1, diagram: 0.1, qrcode: 0.01 };
const NEW_UID_LIMIT = 20;
function json(data, status = 200, cors = {}) {
return new Response(JSON.stringify(data), {
status,
headers: { 'Content-Type': 'application/json; charset=utf-8', ...cors }
});
}
const toUnit = (p) => Math.round(Number(p) * UNIT);
const toPoint = (u) => Math.round(Number(u)) / UNIT;
const now = () => Date.now();
async function readUnits(env, uid) {
const raw = await env.POINTS.get('u:' + uid);
return raw === null ? null : (parseInt(raw, 10) || 0);
}
async function writeUnits(env, uid, units, extra) {
await env.POINTS.put('u:' + uid, String(units), {
metadata: { updatedAt: now(), ...extra }
});
}
async function ensureUser(env, uid, req) {
const cur = await readUnits(env, uid);
if (cur !== null) return cur;
const ip = (req && req.headers.get('cf-connecting-ip')) || (req && req.headers.get('x-forwarded-for')) || 'unknown';
const day = Math.floor(now() / 86400000);
const k = `ip:${ip}:${day}`;
const used = parseInt((await env.POINTS.get(k)) || '0', 10);
const limit = parseInt(env.NEW_UID_LIMIT || String(NEW_UID_LIMIT), 10);
if (used >= limit) return 'LIMIT';
await env.POINTS.put(k, String(used + 1), { expirationTtl: 172800 });
const units = toUnit(parseFloat(env.INIT_POINTS || String(INIT_POINTS)));
await writeUnits(env, uid, units, { createdAt: now(), ip });
return units;
}
async function costOf(env, action, custom) {
if (action === 'custom' && custom != null) return toUnit(Number(custom));
const table = { chat: 1, diagram: 0.1, qrcode: 0.01 };
const c = table[action];
if (c == null) return null;
return toUnit(parseFloat(env.COST_CHAT ? env.COST_CHAT : c));
}
async function getPoints(env, body) {
const uid = String(body.uid || '').trim();
if (!uid) return { ok: false, error: 'missing_uid' };
let units = await readUnits(env, uid);
if (units === null) units = await ensureUser(env, uid, null);
if (units === 'LIMIT') return { ok: false, error: 'new_uid_limit' };
return { ok: true, uid, points: toPoint(units), units };
}
async function consume(env, body, request) {
const uid = String(body.uid || '').trim();
const action = String(body.action || 'chat');
if (!uid) return { ok: false, error: 'missing_uid' };
if (!(await checkSign(env, body))) return { ok: false, error: 'bad_sign' };
const cost = await costOf(env, action, body.cost);
if (cost === null) return { ok: false, error: 'unknown_action' };
for (let i = 0; i < 6; i++) {
let units = await readUnits(env, uid);
if (units === null) units = await ensureUser(env, uid, request);
if (units === 'LIMIT') return { ok: false, error: 'new_uid_limit' };
if (units < cost) return { ok: false, error: 'insufficient', points: toPoint(units), need: toPoint(cost) };
const next = units - cost;
await writeUnits(env, uid, next, { lastAction: action });
const check = await readUnits(env, uid);
if (check === next || i === 5) {
return { ok: true, uid, action, cost: toPoint(cost), points: toPoint(next) };
}
}
return { ok: false, error: 'busy' };
}
async function redeem(env, body) {
const uid = String(body.uid || '').trim();
const code = String(body.code || '').trim().toUpperCase().replace(/[^A-Z0-9]/g, '');
if (!uid || !code) return { ok: false, error: 'missing_params' };
const key = 'c:' + code;
const val = await env.POINTS.get(key);
if (val === null) return { ok: false, error: 'invalid_code' };
const data = JSON.parse(val);
if (data.used) return { ok: false, error: 'used_code' };
await env.POINTS.delete(key);
let units = await readUnits(env, uid);
if (units === null) units = 0;
const next = units + toUnit(data.points);
await writeUnits(env, uid, next, { redeemed: code });
return { ok: true, uid, add: data.points, points: toPoint(next) };
}
async function grant(env, body) {
if (!adminOk(env, body.token)) return { ok: false, error: 'forbidden' };
const uid = String(body.uid || '').trim();
const amount = Number(body.amount);
if (!uid || isNaN(amount)) return { ok: false, error: 'missing_params' };
let units = await readUnits(env, uid);
if (units === null) units = 0;
const next = Math.max(0, units + toUnit(amount));
await writeUnits(env, uid, next, { byAdmin: true });
return { ok: true, uid, delta: amount, points: toPoint(next) };
}
async function makeCodes(env, body) {
if (!adminOk(env, body.token)) return { ok: false, error: 'forbidden' };
const count = Math.min(parseInt(body.count || '1', 10), 200);
const points = Number(body.points || '10');
const out = [];
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
for (let i = 0; i < count; i++) {
let raw = '';
const arr = new Uint8Array(12);
crypto.getRandomValues(arr);
for (let j = 0; j < 12; j++) raw += chars[arr[j] % chars.length];
const code = raw.match(/.{4}/g).join('-');
await env.POINTS.put('c:' + raw, JSON.stringify({ points, used: false, at: now() }));
out.push(code);
}
return { ok: true, count, points, codes: out };
}
async function listUsers(env, body) {
if (!adminOk(env, body.token)) return { ok: false, error: 'forbidden' };
const limit = Math.min(parseInt(body.limit || '100', 10), 500);
const list = await env.POINTS.list({ prefix: 'u:', limit });
const items = [];
for (const k of list.keys) {
const v = await env.POINTS.get(k.name);
const meta = k.metadata || {};
items.push({ uid: k.name.slice(2), points: toPoint(parseInt(v || '0', 10)), createdAt: meta.createdAt || 0, updatedAt: meta.updatedAt || 0 });
}
items.sort((a, b) => b.updatedAt - a.updatedAt);
return { ok: true, count: items.length, users: items };
}
async function stats(env, body) {
if (!adminOk(env, body.token)) return { ok: false, error: 'forbidden' };
const u = await env.POINTS.list({ prefix: 'u:', limit: 1000 });
const c = await env.POINTS.list({ prefix: 'c:', limit: 1000 });
let total = 0;
for (const k of u.keys) total += parseInt((await env.POINTS.get(k.name)) || '0', 10);
return { ok: true, users: u.keys.length, codesLeft: c.keys.length, totalPoints: toPoint(total) };
}
function adminOk(env, token) {
const t = String(token || '');
const r = String(env.ADMIN_TOKEN || '');
if (!r) return false;
if (t.length !== r.length) return false;
let diff = 0;
for (let i = 0; i < t.length; i++) diff |= t.charCodeAt(i) ^ r.charCodeAt(i);
return diff === 0;
}
async function checkSign(env, body) {
const secret = env.SIGN_SECRET;
if (!secret) return true;
const { uid, action, ts, sign } = body;
if (!ts || !sign || Math.abs(now() - Number(ts)) > 300000) return false;
const enc = new TextEncoder();
const key = await crypto.subtle.importKey('raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const sig = await crypto.subtle.sign('HMAC', key, enc.encode(`${uid}:${action}:${ts}`));
const hex = [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, '0')).join('');
return hex === String(sign).toLowerCase();
}
| 接口 | 参数 | 说明 |
|---|---|---|
| /api/points | uid | 查询余额,新 uid 自动开户送 10 分 |
| /api/consume | uid, action, cost? | 扣费,余额不足返回 insufficient |
| /api/redeem | uid, code | 兑换码充值,一码一次 |
| /api/admin/* | token | 管理接口,需 ADMIN_TOKEN |
action 取值:chat = 1、diagram = 0.1、qrcode = 0.01、custom 配合 cost 自定义。
放进序章 index.html 的 </body> 之前,把 CFG.base 换成你的 Worker 域名即可。它会自动:生成并保存本机 uid、拉取余额并刷新页面上的积分显示、在每次请求 /api/chat 前先扣 1 积分、余额不足时拦截请求并提示。
/* 序章 · 服务端积分客户端 v1
用法:把这段放在序章 index.html 的 </body> 前,改好 CFG.base 即可 */
(function () {
var CFG = {
base: 'https://xuzhang-points.YOUR-SUBDOMAIN.workers.dev',
displaySelector: '',
signSecret: '',
chatPath: '/api/chat',
autoHook: true
};
var UID_KEY = 'xz_point_uid';
var uid = localStorage.getItem(UID_KEY);
if (!uid) {
uid = (window.crypto && crypto.randomUUID)
? crypto.randomUUID()
: 'u' + Date.now().toString(36) + Math.random().toString(36).slice(2, 10);
localStorage.setItem(UID_KEY, uid);
}
function hex(buf) {
return Array.prototype.map.call(new Uint8Array(buf), function (b) {
return ('0' + b.toString(16)).slice(-2);
}).join('');
}
async function sign(action, ts) {
if (!CFG.signSecret) return '';
var enc = new TextEncoder();
var key = await crypto.subtle.importKey('raw', enc.encode(CFG.signSecret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
return hex(await crypto.subtle.sign('HMAC', key, enc.encode(uid + ':' + action + ':' + ts)));
}
async function call(path, payload) {
var ts = Date.now();
if (payload && payload.action) {
payload.ts = ts;
payload.sign = await sign(payload.action, ts);
}
var res = await fetch(CFG.base + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload || {})
});
return await res.json();
}
function paint(points) {
var el = CFG.displaySelector ? document.querySelector(CFG.displaySelector) : null;
if (!el) {
var walk = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
var node;
while ((node = walk.nextNode())) {
if (/积分\s*[::]?\s*[\d.]+/.test(node.nodeValue)) { el = node.parentElement; break; }
}
if (!el) return;
}
var t = el.textContent;
el.textContent = /[\d.]+/.test(t) ? t.replace(/[\d.]+/, String(points)) : t + ' ' + points;
}
var XZ = {
uid: uid,
points: 0,
get: function () {
return call('/api/points', { uid: uid }).then(function (r) {
if (r.ok) { XZ.points = r.points; paint(r.points); }
return r;
});
},
consume: function (action, cost) {
return call('/api/consume', { uid: uid, action: action || 'chat', cost: cost }).then(function (r) {
if (r.ok) { XZ.points = r.points; paint(r.points); }
else if (r.error === 'insufficient') XZ.blocked(r);
return r;
});
},
redeem: function (code) {
return call('/api/redeem', { uid: uid, code: code }).then(function (r) {
if (r.ok) { XZ.points = r.points; paint(r.points); }
return r;
});
},
setPoints: function (p) { XZ.points = p; paint(p); },
blocked: function (info) {
alert('积分不足,剩余 ' + info.points + '。可在设置里输入兑换码充值。');
}
};
window.XZPoints = XZ;
if (CFG.autoHook) {
var _fetch = window.fetch.bind(window);
window.fetch = async function (input, init) {
var url = typeof input === 'string' ? input : (input && input.url) || '';
if (url.indexOf(CFG.chatPath) !== -1) {
var r;
try { r = await XZ.consume('chat'); } catch (e) { r = { ok: false, error: 'network' }; }
if (!r.ok) {
if (r.error === 'network') XZ.blocked({ points: XZ.points });
return new Response(JSON.stringify({ error: r.error === 'insufficient' ? '积分不足,请充值' : '积分校验失败' }), {
status: r.error === 'insufficient' ? 402 : 403,
headers: { 'Content-Type': 'application/json' }
});
}
}
return _fetch(input, init);
};
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function () { XZ.get(); });
else XZ.get();
})();
聊天走 fetch 拦截自动扣费,流程图、公式、二维码是本地生成的,需要在对应位置手动调一行:
// 生成流程图 / 公式成功后
await XZPoints.consume('diagram'); // 0.1
// 生成二维码成功后
await XZPoints.consume('qrcode'); // 0.01
// 设置页显示兑换入口
async function redeem(code){
const r = await XZPoints.redeem(code);
if(r.ok) alert('充值成功,当前 ' + r.points + ' 积分');
else alert('兑换码无效');
}
页面上积分数字会自动刷新;找不到就手动指定 CFG.displaySelector = '#points'。顺手把原来读写 localStorage 积分的代码删掉,避免两套数值打架。
localStorage.setItem('xz_point_uid', name)。https://你的域名/,返回 {"ok":true} 说明跑通了。name = "xuzhang-points" main = "worker.js" compatibility_date = "2026-09-25" [[kv_namespaces]] binding = "POINTS" id = "在这里填KV命名空间ID" [vars] INIT_POINTS = "10" NEW_UID_LIMIT = "20" # ADMIN_TOKEN / SIGN_SECRET 请用 wrangler secret put 设置,别写进文件
部署好后在手机浏览器或任意终端跑一次,能返回积分就通了:
curl -X POST https://你的域名/api/points \
-H "Content-Type: application/json" \
-d '{"uid":"test-001"}'
# 扣一次对话积分
curl -X POST https://你的域名/api/consume \
-H "Content-Type: application/json" \
-d '{"uid":"test-001","action":"chat"}'
填好域名与密码后点「读取统计」。这个面板本身不存任何数据,只直接调你的 Worker。
暂无数据