CLOUDFLARE WORKERS · KV · 无服务器

序章 · 服务端积分后端

把原本存在浏览器 localStorage 里、改一下就能绕过的积分,搬到服务端存储与校验。零服务器、零费用,iPad 上点几下就能部署。

10新用户初始积分
1AI 对话 / 次
0.1流程图与公式 / 个
0.01二维码 / 次
① 后端代码
② 前端集成
③ 部署步骤
④ 管理面板

worker.js

整段复制,粘贴进 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

WORKER.JS · JAVASCRIPT
/* 序章 · 服务端积分后端  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/pointsuid查询余额,新 uid 自动开户送 10 分
/api/consumeuid, action, cost?扣费,余额不足返回 insufficient
/api/redeemuid, code兑换码充值,一码一次
/api/admin/*token管理接口,需 ADMIN_TOKEN

action 取值:chat = 1、diagram = 0.1、qrcode = 0.01、custom 配合 cost 自定义。

防刷:同一个 IP 每天最多开 20 个新 uid;consume 用读-改-写 + 回读校验,重复提交不会重复扣。
签名:若设置了 SIGN_SECRET,请求必须带 ts 与 HMAC 签名,5 分钟内有效。注意前端密钥可被提取,它只防误改与低级绕过;要真正锁死,得让 Worker 代理 AI 请求、前端不再持有 Key。

patch.js · 前端集成

放进序章 index.html 的 </body> 之前,把 CFG.base 换成你的 Worker 域名即可。它会自动:生成并保存本机 uid、拉取余额并刷新页面上的积分显示、在每次请求 /api/chat 前先扣 1 积分、余额不足时拦截请求并提示。

PATCH.JS · JAVASCRIPT
/* 序章 · 服务端积分客户端 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 积分的代码删掉,避免两套数值打架。

uid 存在 localStorage,换设备或清缓存积分会丢。想跨设备就让用户填一个自己起的名字当 uid:localStorage.setItem('xz_point_uid', name)。

部署 · 全程网页操作,不需要电脑

  1. 打开 dash.cloudflare.com → Workers 和 Pages → 创建 → 创建 Worker,名字填 xuzhang-points,点部署。
  2. 进入 Worker → 编辑代码,把上面的 worker.js 整段粘贴进去,点 保存并部署。
  3. 设置 → 绑定 → 添加 → KV 命名空间:变量名必须填 POINTS,命名空间新建 xuzhang-points-kv,保存部署。
  4. 设置 → 变量和机密 → 添加:
    ADMIN_TOKEN 管理密码,类型选「机密」,自己起一串长的
    INIT_POINTS = 10(新用户初始积分)
    SIGN_SECRET 可选,填了前端也要填同一个
    NEW_UID_LIMIT 可选,单 IP 每日开户上限,默认 20
  5. 回到 Worker 概览,复制域名 https://xuzhang-points.xxx.workers.dev,填进 patch.js 的 CFG.base。
  6. 浏览器打开 https://你的域名/,返回 {"ok":true} 说明跑通了。
免费额度:Workers 每天 10 万请求、KV 每天 10 万次读 + 1000 次写,积分这种量级随便用。

wrangler.toml · 以后想用命令行时用

WRANGLER.TOML
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。

用户列表

暂无数据

充值 / 扣减

生成兑换码