纯 Node.js · 零依赖 · 自动发货

发卡网(卡网)
从零到上线的完整搭建教程

一份跟着做就能跑起来的实战手册。覆盖服务器准备 → 代码上传 → systemd 守护 → 宝塔 Nginx 反代 → Let's Encrypt 证书 → 后台配置 → 上架运营 → 安全合规全流程。所有命令均来自真实生产环境(域名 yun.xingsu.store),可直接照抄。

CentOS 7 + 宝塔 systemd 守护 Nginx 反代 Let's Encrypt 后台 admin.html
01

准备工作

在上传代码之前,先确认你手上有这几样东西。本教程的生产环境是一台 CentOS 7 云服务器,装了宝塔(BT)面板做可视化管理。

服务器公网 IP(示例 45.207.222.25),root 或具 sudo 权限用户
SSH 端口示例 22756(非默认 22,云厂商常见安全做法)
面板宝塔(BT)面板,用于管理 Nginx 与站点
运行环境Node.js(生产路径 /usr/local/bin/node),应用零 npm 依赖
域名可选;有域名自动走 HTTPS,仅 IP 则走 HTTP
防火墙放行 80 / 443 / 22;SELinux 已关闭

本项目不依赖任何第三方 npm 包,server.js 用 Node 内置模块即可运行,数据存于 data/*.json。这意味着部署极简,没有 npm install 环节,也不会有依赖冲突。

02

上传代码到服务器

项目自带一键部署脚本 deploy/deploy.py(基于 paramiko + scp,支持密码登录)。它会自动排除本地订单、日志、备份,只上传运行所需文件,生产环境从空订单起步。

bash
# 在本机(Windows Git Bash / 终端)运行,先装依赖
pip install paramiko scp

# 上传并部署(--domain 留空则纯 IP / HTTP)
python deploy/deploy.py \
  --host 45.207.222.25 \
  --port 22756 \
  --user root \
  --password '你的SSH密码' \
  --domain yun.xingsu.store \
  --admin-email admin@example.com

脚本内部上传时会排除以下本地内容(避免污染生产数据):

text
data/orders.json      # 生产从空订单开始
data/backups          # 本地备份不传
server.log            # 本地日志不传
node_modules          # 零依赖,本来就没有
.git / .workbuddy / __pycache__

生产环境切勿整包重跑 deploy.py:它会遍历整个项目上传,连 data/*.json(商品 / 卡密 / 设置)都会覆盖,误用会冲掉线上数据。生产增量修复请走「针对性上传单文件 + 重启服务」(见下方运维命令)。

如果你更习惯手动上传,用 rsync 等价排除即可:

bash
rsync -avz --exclude 'data/orders.json' --exclude 'data/backups' \
  --exclude 'server.log' --exclude 'node_modules' --exclude '.workbuddy' \
  ./ root@45.207.222.25:/opt/faka
03

配置 systemd 守护进程

用 systemd 把应用注册成常驻服务,崩溃自动拉起、开机自启。生产目录 /opt/faka,专用运行用户 faka,应用监听 端口 3001

ini · /etc/systemd/system/faka.service
[Unit]
Description=Faka Card Network
After=network.target

[Service]
Type=simple
User=faka
WorkingDirectory=/opt/faka
ExecStart=/usr/local/bin/node /opt/faka/server.js
Environment=PORT=3001
Restart=always
RestartSec=3

[Install]
WantedBy=multi-user.target
bash
systemctl daemon-reload
systemctl enable faka          # 开机自启
systemctl start faka           # 启动
systemctl status faka          # 查看状态
journalctl -u faka -f          # 实时日志

服务起来后,本地先验证一下:curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3001/ 应返回 200。此时应用已在 3001 端口工作,外网还进不来——下一步用 Nginx 反代暴露出去。

04

宝塔 Nginx 反向代理

宝塔自带的 Nginx 由面板管理,不能直接改 /etc/nginx/conf.d/——宝塔不读那个目录。站点配置要写在 /www/server/panel/vhost/nginx/*.conf(主配置第 95 行 include 进来)。

nginx · /www/server/panel/vhost/nginx/faka.conf
server {
    listen 80;
    server_name yun.xingsu.store;

    # Let's Encrypt 文件验证挑战目录
    location ^~ /.well-known/acme-challenge/ {
        root /var/www/acme;
    }

    location / {
        proxy_pass http://127.0.0.1:3001;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

改完配置不要systemctl restart nginx——宝塔的 Nginx 是 SysV 封装,会报 invalid。正确姿势是先 test 再 reload:

bash
/www/server/nginx/sbin/nginx -t -c /www/server/nginx/conf/nginx.conf \
  && /www/server/nginx/sbin/nginx -s reload -c /www/server/nginx/conf/nginx.conf

宝塔面板里也能在「网站 → 设置 → 配置文件」直接编辑这段,保存即生效,等价于上面的 reload。两种方式任选。

05

域名与 Let's Encrypt HTTPS

先把域名 A 记录指向服务器公网 IP,再用 certbot 的 webroot 模式签发证书——这样完全不动宝塔配置,证书和 Nginx 解耦,最稳。

bash
# 签发证书(webroot 挑战目录即上一步配置的 /var/www/acme)
certbot certonly --webroot -w /var/www/acme -d yun.xingsu.store

证书默认落在 /etc/letsencrypt/live/yun.xingsu.store/。把第 04 步的 vhost 升级成 443,并让 80 跳转 443:

nginx · 443 server block
server {
    listen 443 ssl;
    server_name yun.xingsu.store;
    ssl_certificate     /etc/letsencrypt/live/yun.xingsu.store/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yun.xingsu.store/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:3001;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

# 80 → 443 强制跳转
server {
    listen 80;
    server_name yun.xingsu.store;
    location ^~ /.well-known/acme-challenge/ { root /var/www/acme; }
    location / { return 301 https://$host$request_uri; }
}

证书 90 天有效期,加一条每日凌晨自动续期的 cron(生产环境设在 03:00):

bash · crontab -e
0 3 * * * certbot renew --quiet && /www/server/nginx/sbin/nginx -s reload -c /www/server/nginx/conf/nginx.conf

到此站点已上线:浏览器访问 https://yun.xingsu.store/ 应看到发卡网首页,/admin.html 进入后台。

06

后台配置(admin.html)

访问 https://yun.xingsu.store/admin.html,默认账号密码 admin / admin123。登录后按下面顺序配置。

1
改默认密码

设置页第一时间把 admin123 改成强密码。这是上线第一要务,别留默认口令。

2
配置收款码

个人卖家模式用微信 / 支付宝收款码图片(不对接第三方支付网关)。上传你的收款码到后台对应位置。

3
上架商品 + 卡密

新建商品、填价格、上传真实商品图,导入卡密(一行一条)。买家付款后填微信订单号,你在后台「确认收款」即自动发货。

4
补充库存

源码里部分示例商品库存为 0 / 已下架,上线前在后台把要卖的补足库存并上架。

后台默认密码 admin123 务必改掉。且演示站 / 商品截图里的收款码要打码,避免站外交易违规与隐私泄露(详见第 08 节安全合规)。

07

上架与日常运营

商城跑起来后,运营动作主要在外围渠道(如闲鱼 / 私域)。两个工程侧的关键点先记牢:

静态缓存改了 CSS/JS/HTML 后,务必把引用处的 ?v= 版本号 +1 再上传,否则 Nginx 按 URL 缓存会一直返回旧文件
增量更新生产改单文件:上传该文件后 systemctl restart faka 即可,不要整包重传
数据备份cp -r /opt/faka/data /opt/faka/data.bak.$(date +%F),建议每天定时备份
bash · 常用运维
systemctl restart faka            # 改完代码重启服务
journalctl -u faka -f             # 实时看日志
tail -f /var/log/faka/app.log     # 应用日志
cp -r /opt/faka/data /opt/faka/data.bak.$(date +%F)   # 备份

闲鱼等平台「擦亮」是 App 端专属功能,网页版没有入口;商品标题发布后会被锁定(编辑页无标题输入框),想换标题只能下架重发。运营重心放在描述优化、真实截图与私域引流上。

08

安全与合规

发卡网涉及资金与隐私,这几条是从生产事故里总结的硬规矩:

A
别整包覆盖生产数据

deploy.py 会传整个项目,连商品/卡密/设置 JSON 都覆盖。生产修复一律「针对性上传 + 重启 faka.service」。

B
SSH 密码别明文进脚本

部署脚本里别硬编码密码。改用环境变量(如 FAKA_SSH_PASS)或 SSH 密钥登录,且不要提交进版本库。

C
改掉后台默认密码

admin123 必须改;后台路径 /admin.html 可被枚举,强密码是第一道防线。

D
收款码打码 + 软引流

商品图/演示站截图里的微信、支付宝收款码要脱敏打码;描述里用「详聊」「私」等软话术,不露明文联系方式,规避站外交易违规与封号风险。

09

常见问题与排错

访问域名报 502 / 空白页?

多半是 faka 服务没起来或端口对不上。先 systemctl status faka 确认 Running,再确认 vhost 里 proxy_pass 的端口(生产是 3001)和 server.js 实际监听的一致。

Nginx 改完不生效?

别用 systemctl restart nginx。宝塔 Nginx 是 SysV 封装,用 nginx -t ... && nginx -s reload ...(带 -c 指定主配置)才正确。

证书快过期 / 续期失败?

确认 cron 里 certbot renew 在跑,且 80 端口的 /.well-known/acme-challenge/ 挑战目录配置没被删。续期后记得 reload Nginx。

改了 CSS 但浏览器还是旧的?

Nginx 按 URL 缓存静态文件。把引用处的 ?v= 版本号加 1 再上传,强制客户端拉新文件。

数据怎么备份恢复?

备份 /opt/faka/data 整个目录即可(含商品、卡密、订单、设置)。恢复时停服、替换目录、重启。

10

网站源码速览

发卡网是纯 Node.js 零依赖应用。下面是与你拿到手一模一样的真实源码结构——核心后端 server.js(720 行)已完整贴在下方,可直接一键复制;其余前端与数据文件可在 src/ 目录查看,或下载整包。

text · 目录结构
faka/
├── server.js            # 核心后端(HTTP 服务 + 路由 + 自动发货)
├── lib/store.js         # 数据层:读写 data/*.json
├── seed.js              # 示例数据初始化
├── package.json
├── public/              # 前端(iOS 玻璃拟态)
│   ├── index.html       # 商城首页
│   ├── product.html     # 商品详情
│   ├── checkout.html    # 收银台(微信/支付宝收款码)
│   ├── cart.html  orders.html  success.html
│   ├── admin.html       # 管理后台
│   ├── css/style.css
│   ├── js/api.js
│   └── logo.svg
└── data/                # JSON 存储(products/codes/coupons/settings)

核心后端:server.js(完整 720 行)

javascript · server.js
// 发卡网后端服务:仅使用 Node 内置模块,无第三方依赖
// 发货方式:code(卡密自动发货) / link(网盘/激活链接) / file(文件下载)
// 增强:优惠券、促销价、限购、订单超时自动释放、支付通知(webhook)、自动备份
const http = require('http');
const https = require('https');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const zlib = require('zlib');
const { URL } = require('url');
const store = require('./lib/store');

const PORT = process.env.PORT || 3000;
const PUBLIC_DIR = path.join(__dirname, 'public');
const UPLOAD_DIR = store.UPLOAD_DIR;

store.ensure();

const MIME = {
  '.html': 'text/html; charset=utf-8',
  '.css': 'text/css; charset=utf-8',
  '.js': 'application/javascript; charset=utf-8',
  '.json': 'application/json; charset=utf-8',
  '.png': 'image/png',
  '.jpg': 'image/jpeg',
  '.jpeg': 'image/jpeg',
  '.gif': 'image/gif',
  '.webp': 'image/webp',
  '.svg': 'image/svg+xml',
  '.ico': 'image/x-icon',
};

// 上传文件允许的 MIME→扩展名(白名单,杜绝 .php/.html 等可执行/可渲染类型被写入站点)
const UPLOAD_EXT = {
  'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp',
  'image/svg+xml': 'svg', 'image/bmp': 'bmp', 'image/x-icon': 'ico', 'image/vnd.microsoft.icon': 'ico',
  'application/pdf': 'pdf', 'application/zip': 'zip', 'application/x-zip-compressed': 'zip',
  'application/x-rar-compressed': 'rar', 'text/plain': 'txt', 'text/csv': 'csv',
};

function send(res, status, body, headers = {}) {
  const payload = typeof body === 'string' || Buffer.isBuffer(body) ? body : JSON.stringify(body);
  res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', ...headers });
  res.end(payload);
}
function sendJson(res, status, obj) {
  send(res, status, obj, { 'Content-Type': 'application/json; charset=utf-8' });
}
function readBody(req) {
  return new Promise((resolve, reject) => {
    let data = '';
    let size = 0;
    req.on('data', (chunk) => {
      size += chunk.length;
      if (size > 8 * 1024 * 1024) { reject(new Error('payload too large')); req.destroy(); return; }
      data += chunk;
    });
    req.on('end', () => {
      if (!data) return resolve({});
      try { resolve(JSON.parse(data)); } catch (e) { reject(new Error('invalid json')); }
    });
    req.on('error', reject);
  });
}

function checkAdmin(req, body) {
  const pass = (req.headers['x-admin-pass'] || (body && body.adminPass) || '').toString();
  return pass && pass === store.getSettings().adminPass;
}

function adminSlug() { return store.getSettings().adminSlug || 'admin'; }

// ---------------- 通知(webhook,零依赖)----------------
// 支持企业微信群机器人(msgtype=text)与通用 JSON webhook
function postWebhook(webhookUrl, text) {
  return new Promise((resolve) => {
    if (!webhookUrl) return resolve({ ok: false, error: '未配置 webhook' });
    let u;
    try { u = new URL(webhookUrl); } catch (e) { return resolve({ ok: false, error: 'webhook 地址无效' }); }
    const payload = JSON.stringify({ msgtype: 'text', text: { content: text } });
    const lib = u.protocol === 'https:' ? https : http;
    const opts = {
      method: 'POST',
      hostname: u.hostname,
      port: u.port || (u.protocol === 'https:' ? 443 : 80),
      path: u.pathname + u.search,
      headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
      timeout: 8000,
    };
    const r = lib.request(opts, (resp) => {
      let d = '';
      resp.on('data', (c) => (d += c));
      resp.on('end', () => resolve({ ok: resp.statusCode >= 200 && resp.statusCode < 300, status: resp.statusCode, body: d.slice(0, 200) }));
    });
    r.on('error', (e) => resolve({ ok: false, error: e.message }));
    r.on('timeout', () => { r.destroy(); resolve({ ok: false, error: '请求超时' }); });
    r.write(payload);
    r.end();
  });
}

// 支付成功后给卖家 / 买家发通知(异步,失败不影响主流程)
async function notifyPaid(order) {
  const s = store.getSettings();
  const n = s.notify || {};
  const goods = (order.items || []).map((it) => `${it.name} x${it.quantity}`).join('、');
  if (n.wechatWebhook) {
    const text = `【${s.shopName}】新订单已支付\n订单号:${order.id}\n商品:${goods}\n实付:¥${order.finalAmount != null ? order.finalAmount : order.amount}\n联系方式:${order.contact}\n时间:${(order.paidAt || '').replace('T', ' ').slice(0, 19)}`;
    postWebhook(n.wechatWebhook, text).catch(() => {});
  }
  if (n.buyerWebhook) {
    postWebhook(n.buyerWebhook, `【${s.shopName}】您的订单 ${order.id} 已由卖家确认收款并发货,请到「我的订单」凭联系方式查看卡密/下载内容。`).catch(() => {});
  }
}

// 买家点击「我已支付」后,立即提醒卖家去后台确认收款(核心:下单后即时通知)
async function notifyBuyerPaid(order) {
  const s = store.getSettings();
  const n = s.notify || {};
  if (!n.wechatWebhook) return;
  const goods = (order.items || []).map((it) => `${it.name} x${it.quantity}`).join('、');
  const text = `【${s.shopName}】新订单待确认收款\n订单号:${order.id}\n商品:${goods}\n应付:¥${order.finalAmount != null ? order.finalAmount : order.amount}\n联系方式:${order.contact}\n付款备注:${order.payNote || '-'}\n付款单号:${order.payTxnNo || '-'}\n提交时间:${(order.buyerPaidAt || '').replace('T', ' ').slice(0, 19)}`;
  postWebhook(n.wechatWebhook, text).catch(() => {});
}

// ---------------- 订单超时扫描:释放占用的卡密库存 ----------------
function sweepExpiredOrders() {
  try {
    const now = Date.now();
    let cancelled = 0;
    for (const o of store.getOrders()) {
      if (o.status === 'pending' && o.expiresAt && new Date(o.expiresAt).getTime() < now) {
        store.cancelOrder(o.id);
        cancelled++;
      }
    }
    if (cancelled) console.log(`[sweep] 已取消 ${cancelled} 个超时未支付订单`);
  } catch (e) { /* ignore */ }
}

// ---------------- 每日自动备份 ----------------
function maybeBackup() {
  try {
    const last = store.lastBackupAt();
    const need = !last || (Date.now() - new Date(last).getTime()) > 24 * 3600 * 1000;
    if (need) {
      const dir = store.backupData();
      console.log('[backup] 已自动备份到', dir);
    }
  } catch (e) { /* ignore */ }
}

// 处理下单的公共逻辑:构建 items、校验限购/库存
function buildItems(body) {
  const s = store.getSettings();
  const globalMax = s.maxPerOrder || 0;
  const items = [];
  const rawItems = Array.isArray(body.items) && body.items.length
    ? body.items
    : (body.productId ? [{ productId: body.productId, qty: body.quantity }] : []);
  for (const it of rawItems) {
    const p = store.getProduct(it.productId);
    if (!p || p.status !== 'on') return { error: '商品不可购买或已下架' };
    const qty = Math.max(1, parseInt(it.qty, 10) || 1);
    // 限购:商品级优先,其次全局
    const perMax = (p.maxPerOrder && p.maxPerOrder > 0) ? p.maxPerOrder : globalMax;
    if (perMax && qty > perMax) return { error: `「${p.name}」每单限购 ${perMax} 件` };
    if (store.isCodeType(p) && store.stockOf(p.id) < qty) {
      return { error: `「${p.name}」库存不足,仅剩 ${store.stockOf(p.id)} 件` };
    }
    const price = store.effectivePrice(p);
    items.push({ productId: p.id, name: p.name, price, quantity: qty, amount: Math.round(price * qty * 100) / 100, deliveryType: p.deliveryType || 'code' });
  }
  return { items };
}

// ---------------- API handlers ----------------
async function handleApi(req, res, url) {
  const parts = url.pathname.split('/').filter(Boolean);
  const seg = parts.slice(1);
  const method = req.method;

  // ---- public config ----
  if (method === 'GET' && seg[0] === 'config') {
    const s = store.getSettings();
    return sendJson(res, 200, { shopName: s.shopName, hasWechat: !!s.wechatQr, hasAlipay: !!s.alipayQr, orderExpireMinutes: s.orderExpireMinutes || 0 });
  }

  // ---- public products ----
  if (method === 'GET' && seg[0] === 'products') {
    const list = store.getProducts().filter((p) => p.status === 'on').map(store.publicProduct);
    return sendJson(res, 200, { products: list });
  }
  if (method === 'GET' && seg[0] === 'product' && seg[1]) {
    const p = store.getProduct(seg[1]);
    if (!p) return sendJson(res, 404, { error: '商品不存在' });
    return sendJson(res, 200, { product: store.publicProduct(p) });
  }

  // ---- 优惠券校验(公开,结算页用) ----
  if (method === 'POST' && seg[0] === 'coupon' && seg[1] === 'check') {
    const body = await readBody(req);
    const c = store.couponByCode(body.code);
    if (!c) return sendJson(res, 404, { error: '优惠券不存在或已停用' });
    const amount = Math.max(0, parseFloat(body.amount) || 0);
    const d = store.calcCouponDiscount(c, amount);
    if (d === -1) return sendJson(res, 400, { error: '优惠券尚未开始' });
    if (d === -2) return sendJson(res, 400, { error: '优惠券已过期' });
    if (d === -3) return sendJson(res, 400, { error: '优惠券已被领完' });
    if (d === -4) return sendJson(res, 400, { error: `需满 ¥${c.minAmount} 可用` });
    return sendJson(res, 200, { code: c.code, discount: d, type: c.type, value: c.value, desc: couponDesc(c) });
  }

  // ---- create order (支持购物车多商品 + 优惠券 + 限购 + 超时) ----
  if (method === 'POST' && seg[0] === 'orders') {
    const body = await readBody(req);
    const built = buildItems(body);
    if (built.error) return sendJson(res, 400, { error: built.error });
    const items = built.items;
    if (!items.length) return sendJson(res, 400, { error: '购物车为空' });
    if (!body.contact || !String(body.contact).trim()) return sendJson(res, 400, { error: '请填写联系方式(邮箱/QQ)以便查询' });

    const amount = Math.round(items.reduce((s, it) => s + it.amount, 0) * 100) / 100;
    // 优惠券
    let discount = 0;
    let couponCode = '';
    if (body.coupon) {
      const c = store.couponByCode(body.coupon);
      const d = c ? store.calcCouponDiscount(c, amount) : null;
      if (c && typeof d === 'number' && d >= 0) { discount = d; couponCode = c.code; }
    }
    const finalAmount = Math.round(Math.max(0, amount - discount) * 100) / 100;

    const s = store.getSettings();
    const expireMin = s.orderExpireMinutes || 0;
    const order = {
      id: store.newOrderId(),
      contact: String(body.contact).trim(),
      status: 'pending',
      items,
      amount,
      discount,
      couponCode,
      finalAmount,
      payAmount: finalAmount,
      delivered: {},
      viewed: false,
      createdAt: new Date().toISOString(),
      expiresAt: expireMin ? new Date(Date.now() + expireMin * 60000).toISOString() : null,
      paidAt: null,
      buyerPaid: false,
    };
    store.addOrder(order);
    return sendJson(res, 200, { orderId: order.id, amount: order.amount, discount: order.discount, finalAmount: order.finalAmount, payAmount: order.payAmount, expiresAt: order.expiresAt });
  }

  // ---- order lookup (查单:支持「订单号+联系方式」精确查询 / 「仅联系方式」查全部) ----
  if (method === 'POST' && seg[0] === 'order' && seg[1] === 'lookup') {
    const body = await readBody(req);
    const orderId = String(body.orderId || '').trim();
    const contact = String(body.contact || '').trim();
    // 仅联系方式:返回该联系方式下全部订单(倒序),方便用户忘记订单号时一次查全
    if (!orderId) {
      if (!contact) return sendJson(res, 400, { error: '请填写联系方式' });
      const list = store.getOrders()
        .filter((o) => o.contact === contact)
        .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
        .map(safeOrder);
      if (!list.length) return sendJson(res, 404, { error: '未找到该联系方式下的订单' });
      return sendJson(res, 200, { orders: list });
    }
    // 按订单号查询:需联系方式校验,保护卡密不外泄
    const o = store.getOrder(orderId);
    if (!o) return sendJson(res, 404, { error: '订单不存在' });
    if (!contact) return sendJson(res, 400, { error: '请填写联系方式以验证' });
    if (o.contact !== contact) return sendJson(res, 403, { error: '联系方式不匹配' });
    return sendJson(res, 200, { order: safeOrder(o) });
  }

  // ---- get single order(需联系方式校验,防止订单信息/卡密泄露) ----
  if (method === 'GET' && seg[0] === 'order' && seg[1] && !seg[2]) {
    const o = store.getOrder(seg[1]);
    if (!o) return sendJson(res, 404, { error: '订单不存在' });
    const contact = String(url.searchParams.get('contact') || '').trim();
    if (!contact) return sendJson(res, 400, { error: '请填写联系方式以验证' });
    if (o.contact !== contact) return sendJson(res, 403, { error: '联系方式不匹配' });
    return sendJson(res, 200, { order: safeOrder(o) });
  }

  // ---- mark viewed (卡密已展示,标记一次;需联系方式校验) ----
  if (method === 'POST' && seg[0] === 'order' && seg[1] && seg[2] === 'mark-viewed') {
    const o = store.getOrder(seg[1]);
    if (!o) return sendJson(res, 404, { error: '订单不存在' });
    const contact = String(url.searchParams.get('contact') || '').trim();
    if (!contact) return sendJson(res, 400, { error: '请填写联系方式以验证' });
    if (o.contact !== contact) return sendJson(res, 403, { error: '联系方式不匹配' });
    if (o.status === 'paid' && !o.viewed) { o.viewed = true; store.updateOrder(o); }
    return sendJson(res, 200, { ok: true });
  }

  // ---- 手动确认:买家点「我已支付」仅标记为已付款,等待卖家后台确认发货 ----
  if (method === 'POST' && seg[0] === 'order' && seg[1] && seg[2] === 'pay') {
    const o = store.getOrder(seg[1]);
    if (!o) return sendJson(res, 404, { error: '订单不存在' });
    const body = await readBody(req);
    const contact = String(body.contact || '').trim();
    if (!contact) return sendJson(res, 400, { error: '请填写联系方式以验证' });
    if (o.contact !== contact) return sendJson(res, 403, { error: '联系方式不匹配' });
    if (o.status === 'paid') return sendJson(res, 200, { order: o });
    if (o.status === 'cancelled') return sendJson(res, 400, { error: '订单已超时取消,请重新下单' });
    if (o.expiresAt && new Date(o.expiresAt).getTime() < Date.now()) {
      store.cancelOrder(o.id);
      return sendJson(res, 400, { error: '订单已超时,请重新下单' });
    }
    const wasPaid = o.buyerPaid;
    o.buyerPaid = true;
    o.buyerPaidAt = new Date().toISOString();
    if (body.payNote) o.payNote = String(body.payNote).trim().slice(0, 200);
    if (body.payTxnNo) o.payTxnNo = String(body.payTxnNo).trim().replace(/\s/g, '').slice(0, 64);
    store.updateOrder(o);
    if (!wasPaid) notifyBuyerPaid(o);
    return sendJson(res, 200, { order: o });
  }

  // ================= ADMIN =================
  if (seg[0] === 'admin') {
    // 商品收款码等设置对买家公开(结算页需展示扫码图),仅 GET 公开
    if (seg[1] === 'settings' && method === 'GET') {
      const s = store.getSettings();
      return sendJson(res, 200, { settings: { shopName: s.shopName, wechatQr: s.wechatQr || '', alipayQr: s.alipayQr || '' } });
    }

    let body = {};
    if (method !== 'GET') body = await readBody(req);
    if (!checkAdmin(req, body)) return sendJson(res, 401, { error: '后台密码错误' });

    // 登录校验(受保护,仅密码正确返回 200)
    if (seg[1] === 'verify' && method === 'GET') {
      return sendJson(res, 200, { ok: true });
    }

    // 概览统计:近7日销售 + 低库存数 + 备份时间
    if (seg[1] === 'stats' && method === 'GET') {
      const s = store.getSettings();
      return sendJson(res, 200, {
        daily: store.dailyRevenue(7),
        lowStock: store.lowStockCount(s.lowStockThreshold || 5),
        lowStockThreshold: s.lowStockThreshold || 5,
        lastBackupAt: store.lastBackupAt(),
      });
    }

    // 完整设置(受保护,含通知/限购/过期/slug)
    if (seg[1] === 'fullsettings' && method === 'GET') {
      const s = store.getSettings();
      return sendJson(res, 200, { settings: {
        shopName: s.shopName, wechatQr: s.wechatQr || '', alipayQr: s.alipayQr || '',
        lowStockThreshold: s.lowStockThreshold, adminSlug: s.adminSlug,
        maxPerOrder: s.maxPerOrder, orderExpireMinutes: s.orderExpireMinutes,
        notify: s.notify || { wechatWebhook: '', buyerWebhook: '' },
      } });
    }

    // 订单导出 CSV(受保护)
    if (seg[1] === 'orders' && seg[2] === 'export' && method === 'GET') {
      const csv = buildOrdersCsv(store.getOrders());
      return send(res, 200, '\uFEFF' + csv, {
        'Content-Type': 'text/csv; charset=utf-8',
        'Content-Disposition': 'attachment; filename="orders_' + new Date().toISOString().slice(0, 10) + '.csv"',
      });
    }

    if (seg[1] === 'settings' && method === 'POST') {
      const s = store.getSettings();
      if (body.shopName !== undefined) s.shopName = String(body.shopName).trim() || s.shopName;
      if (body.wechatQr !== undefined) s.wechatQr = body.wechatQr;
      if (body.alipayQr !== undefined) s.alipayQr = body.alipayQr;
      if (body.lowStockThreshold !== undefined) s.lowStockThreshold = Math.max(0, parseInt(body.lowStockThreshold, 10) || 0);
      if (body.maxPerOrder !== undefined) s.maxPerOrder = Math.max(0, parseInt(body.maxPerOrder, 10) || 0);
      if (body.orderExpireMinutes !== undefined) s.orderExpireMinutes = Math.max(0, parseInt(body.orderExpireMinutes, 10) || 0);
      if (body.adminSlug !== undefined) s.adminSlug = String(body.adminSlug).trim().replace(/[^a-zA-Z0-9_-]/g, '') || 'admin';
      if (body.notify !== undefined && typeof body.notify === 'object') {
        s.notify = {
          wechatWebhook: String(body.notify.wechatWebhook || '').trim(),
          buyerWebhook: String(body.notify.buyerWebhook || '').trim(),
        };
      }
      if (body.adminPass) s.adminPass = String(body.adminPass);
      store.saveSettings(s);
      return sendJson(res, 200, { ok: true });
    }

    // 通知测试发送
    if (seg[1] === 'notify' && seg[2] === 'test' && method === 'POST') {
      const s = store.getSettings();
      const target = body.target === 'buyer' ? (s.notify && s.notify.buyerWebhook) : (s.notify && s.notify.wechatWebhook);
      const r = await postWebhook(target, `【${s.shopName}】通知测试成功 ✅ 时间 ${new Date().toLocaleString('zh-CN')}`);
      return sendJson(res, 200, r);
    }

    // 手动备份
    if (seg[1] === 'backup' && method === 'POST') {
      const dir = store.backupData();
      return sendJson(res, 200, { ok: true, dir: path.basename(dir), at: new Date().toISOString() });
    }

    // ---- 优惠券管理 ----
    if (seg[1] === 'coupons' && method === 'GET') {
      return sendJson(res, 200, { coupons: store.getCoupons() });
    }
    if (seg[1] === 'coupons' && method === 'POST' && !seg[2]) {
      const type = ['percent', 'amount'].includes(body.type) ? body.type : 'amount';
      const c = {
        id: store.newId('C'),
        code: String(body.code || '').trim().toUpperCase() || ('COUPON' + crypto.randomBytes(2).toString('hex').toUpperCase()),
        type,
        value: parseFloat(body.value) || 0,
        minAmount: parseFloat(body.minAmount) || 0,
        maxDiscount: parseFloat(body.maxDiscount) || 0,
        usageLimit: parseInt(body.usageLimit, 10) || 0,
        usedCount: 0,
        startsAt: body.startsAt || '',
        expiresAt: body.expiresAt || '',
        status: body.status === 'off' ? 'off' : 'on',
        createdAt: new Date().toISOString(),
      };
      if (store.couponByCode(c.code)) return sendJson(res, 400, { error: '优惠码已存在' });
      store.upsertCoupon(c);
      return sendJson(res, 200, { coupon: c });
    }
    if (seg[1] === 'coupons' && seg[2] && method === 'PUT') {
      const list = store.getCoupons();
      const c = list.find((x) => x.id === seg[2]);
      if (!c) return sendJson(res, 404, { error: '优惠券不存在' });
      ['type', 'value', 'minAmount', 'maxDiscount', 'usageLimit', 'startsAt', 'expiresAt', 'status'].forEach((k) => {
        if (body[k] !== undefined) c[k] = ['value', 'minAmount', 'maxDiscount'].includes(k) ? (parseFloat(body[k]) || 0) : (k === 'usageLimit' ? (parseInt(body[k], 10) || 0) : body[k]);
      });
      store.upsertCoupon(c);
      return sendJson(res, 200, { coupon: c });
    }
    if (seg[1] === 'coupons' && seg[2] && method === 'DELETE') {
      store.deleteCoupon(seg[2]);
      return sendJson(res, 200, { ok: true });
    }

    // products list (all)
    if (seg[1] === 'products' && method === 'GET') {
      const s = store.getSettings();
      const list = store.getProducts().map((p) => {
        const pub = store.publicProduct(p);
        return { ...pub, salePrice: p.salePrice != null ? p.salePrice : '', saleStart: p.saleStart || '', saleEnd: p.saleEnd || '', totalCodes: store.getCodes(p.id).length, lowStock: store.isCodeType(p) && pub.stock <= (s.lowStockThreshold || 5) };
      });
      return sendJson(res, 200, { products: list });
    }
    // create product
    if (seg[1] === 'products' && method === 'POST' && !seg[2]) {
      const deliveryType = ['code', 'link', 'file'].includes(body.deliveryType) ? body.deliveryType : 'code';
      const p = {
        id: store.newId('P'),
        name: String(body.name || '').trim(),
        description: String(body.description || ''),
        price: parseFloat(body.price) || 0,
        salePrice: body.salePrice !== undefined && body.salePrice !== '' ? parseFloat(body.salePrice) : null,
        saleStart: body.saleStart || '',
        saleEnd: body.saleEnd || '',
        maxPerOrder: parseInt(body.maxPerOrder, 10) || 0,
        category: String(body.category || '').trim(),
        image: body.image || '',
        deliveryType,
        linkUrl: deliveryType === 'link' ? String(body.linkUrl || '').trim() : '',
        fileUrl: deliveryType === 'file' ? String(body.fileUrl || '') : '',
        fileName: deliveryType === 'file' ? String(body.fileName || 'file') : '',
        status: body.status === 'off' ? 'off' : 'on',
        createdAt: new Date().toISOString(),
      };
      if (!p.name) return sendJson(res, 400, { error: '请填写商品名称' });
      if (deliveryType === 'link' && !p.linkUrl) return sendJson(res, 400, { error: '请填写发货链接' });
      store.upsertProduct(p);
      return sendJson(res, 200, { product: p });
    }
    // update / delete product
    if (seg[1] === 'products' && seg[2] && seg[3] !== 'codes') {
      const p = store.getProduct(seg[2]);
      if (!p) return sendJson(res, 404, { error: '商品不存在' });
      if (method === 'PUT' || method === 'POST') {
        if (body.name !== undefined) p.name = String(body.name).trim();
        if (body.description !== undefined) p.description = String(body.description);
        if (body.price !== undefined) p.price = parseFloat(body.price) || 0;
        if (body.salePrice !== undefined) p.salePrice = body.salePrice === '' ? null : (parseFloat(body.salePrice) || 0);
        if (body.saleStart !== undefined) p.saleStart = body.saleStart || '';
        if (body.saleEnd !== undefined) p.saleEnd = body.saleEnd || '';
        if (body.maxPerOrder !== undefined) p.maxPerOrder = parseInt(body.maxPerOrder, 10) || 0;
        if (body.tags !== undefined) p.tags = Array.isArray(body.tags) ? body.tags.filter((t) => t === 'hot' || t === 'new') : [];
        if (body.category !== undefined) p.category = String(body.category).trim();
        if (body.image !== undefined) p.image = body.image;
        if (body.status !== undefined) p.status = body.status === 'off' ? 'off' : 'on';
        if (body.deliveryType !== undefined && ['code', 'link', 'file'].includes(body.deliveryType)) p.deliveryType = body.deliveryType;
        if (body.linkUrl !== undefined) p.linkUrl = String(body.linkUrl || '').trim();
        if (body.fileUrl !== undefined) p.fileUrl = String(body.fileUrl || '');
        if (body.fileName !== undefined) p.fileName = String(body.fileName || 'file');
        store.upsertProduct(p);
        return sendJson(res, 200, { product: p });
      }
      if (method === 'DELETE') {
        store.deleteProduct(seg[2]);
        return sendJson(res, 200, { ok: true });
      }
    }
    // add codes (only code type)
    if (seg[1] === 'products' && seg[2] && seg[3] === 'codes' && (method === 'POST')) {
      const text = String(body.codes || '');
      const list = text.split(/[\r\n]+/);
      const n = store.addCodes(seg[2], list);
      return sendJson(res, 200, { added: n, stock: store.stockOf(seg[2]) });
    }

    // upload image/file (base64) -> returns /uploads/filename(扩展名白名单校验)
    if (seg[1] === 'upload' && method === 'POST') {
      const m = /^data:([\w\/+.\-]+);base64,(.+)$/.exec(body.data || '');
      if (!m) return sendJson(res, 400, { error: '无效文件数据' });
      const ext = UPLOAD_EXT[m[1]];
      if (!ext) return sendJson(res, 400, { error: '不支持的文件类型' });
      const buf = Buffer.from(m[2], 'base64');
      const fname = store.newId('u') + '.' + ext;
      fs.writeFileSync(path.join(UPLOAD_DIR, fname), buf);
      return sendJson(res, 200, { url: '/uploads/' + fname });
    }

    // orders
    if (seg[1] === 'orders' && method === 'GET') {
      const list = store.getOrders();
      const totalRevenue = list.filter((o) => o.status === 'paid').reduce((s, o) => s + (o.finalAmount != null ? o.finalAmount : o.amount), 0);
      return sendJson(res, 200, { orders: list, totalRevenue: Math.round(totalRevenue * 100) / 100, paidCount: list.filter((o) => o.status === 'paid').length });
    }
    if (seg[1] === 'orders' && seg[2] && seg[3] === 'confirm' && method === 'POST') {
      const o = store.getOrder(seg[2]);
      if (!o) return sendJson(res, 404, { error: '订单不存在' });
      if (o.status === 'paid') return sendJson(res, 200, { order: o });
      if (o.status === 'cancelled') return sendJson(res, 400, { error: '订单已取消,无法确认收款' });
      const result = deliverOrder(o);
      if (result.error) return sendJson(res, 400, { error: result.error });
      if (o.couponCode) store.useCoupon(o.couponCode);
      notifyPaid(o);
      return sendJson(res, 200, { order: o });
    }
  }

  return sendJson(res, 404, { error: '接口不存在' });
}

// 发货核心逻辑(供买家支付与后台确认收款复用)
function deliverOrder(o) {
  const delivered = {};
  for (const it of o.items) {
    const p = store.getProduct(it.productId);
    if (!p) return { error: '商品已不存在,请联系卖家' };
    if ((p.deliveryType || 'code') === 'code') {
      const codes = store.takeCodes(it.productId, it.quantity, o.id);
      if (!codes) {
        store.releaseCodes(o.id);
        return { error: '库存不足,发卡失败,请联系卖家' };
      }
      delivered[it.productId] = { type: 'code', codes };
    } else if (p.deliveryType === 'link') {
      if (!p.linkUrl) return { error: '该商品发货链接未配置,请联系卖家' };
      delivered[it.productId] = { type: 'link', url: p.linkUrl };
    } else if (p.deliveryType === 'file') {
      if (!p.fileUrl) return { error: '该商品发货文件未配置,请联系卖家' };
      delivered[it.productId] = { type: 'file', url: p.fileUrl, name: p.fileName || 'file' };
    }
  }
  o.status = 'paid';
  o.delivered = delivered;
  o.paidAt = new Date().toISOString();
  store.updateOrder(o);
  return { ok: true };
}

// 返回给前端的订单:未支付不暴露发货内容
function safeOrder(o) {
  const safe = { ...o };
  if (o.status !== 'paid') safe.delivered = {};
  return safe;
}

function couponDesc(c) {
  const cond = c.minAmount ? `满¥${c.minAmount}` : '无门槛';
  if (c.type === 'percent') return `${cond}打${(c.value / 10).toFixed(1)}折${c.maxDiscount ? '(最高减¥' + c.maxDiscount + ')' : ''}`;
  return `${cond}减¥${c.value}`;
}

function csvCell(v) {
  const s = String(v == null ? '' : v);
  return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
}
function buildOrdersCsv(orders) {
  const rows = [['订单号', '下单时间', '支付时间', '联系方式', '状态', '商品', '原价合计', '优惠', '实付', '发货内容']];
  for (const o of orders) {
    const items = o.items || [];
    const goods = items.map((it) => `${it.name} x${it.quantity}`).join(' | ');
    const deliver = (o.delivered && o.status === 'paid')
      ? items.map((it) => {
          const d = o.delivered[it.productId];
          if (!d) return it.name + ': -';
          if (d.type === 'code') return it.name + ': ' + d.codes.join(' / ');
          if (d.type === 'link') return it.name + ': ' + d.url;
          if (d.type === 'file') return it.name + ': ' + d.url;
          return it.name + ': -';
        }).join(' | ')
      : '';
    const statusText = o.status === 'paid' ? '已支付' : (o.status === 'cancelled' ? '已取消' : '待支付');
    rows.push([
      o.id,
      (o.createdAt || '').replace('T', ' ').slice(0, 19),
      (o.paidAt || '').replace('T', ' ').slice(0, 19),
      o.contact,
      statusText,
      goods,
      o.amount,
      o.discount || 0,
      o.finalAmount != null ? o.finalAmount : o.amount,
      deliver,
    ]);
  }
  return rows.map((r) => r.map(csvCell).join(',')).join('\r\n');
}

// ---------------- static + upload serving ----------------
// gzip 结果内存缓存:避免每个请求重复压缩,key 为文件路径,值绑定 mtime+size 判断失效
const _gzipCache = new Map();
function _tryGzip(req, res, fp, headers, stat) {
  const accept = req.headers['accept-encoding'] || '';
  if (!accept.includes('gzip') || stat.size <= 1024) return false;
  let gz = _gzipCache.get(fp);
  if (!gz || gz.mtime !== stat.mtimeMs || gz.size !== stat.size) {
    gz = { buf: zlib.gzipSync(fs.readFileSync(fp), { level: 9 }), mtime: stat.mtimeMs, size: stat.size };
    _gzipCache.set(fp, gz);
    if (_gzipCache.size > 200) _gzipCache.delete(_gzipCache.keys().next().value); // 简单上限,防内存膨胀
  }
  headers['Content-Encoding'] = 'gzip';
  headers['Content-Length'] = gz.buf.length;
  headers['Vary'] = 'Accept-Encoding';
  res.writeHead(200, headers);
  res.end(gz.buf);
  return true;
}

// 发送静态文件:支持 gzip 压缩 + ETag/Last-Modified 协商缓存(304)
function streamFile(req, res, fp, type, cache) {
  const stat = fs.statSync(fp);
  const mtime = stat.mtimeMs;
  const etag = '"' + mtime.toString(36) + '-' + stat.size.toString(36) + '"';
  const headers = {
    'Content-Type': type,
    'Cache-Control': cache,
    'ETag': etag,
    'Last-Modified': new Date(stat.mtime).toUTCString(),
  };
  // 协商缓存:客户端已有最新版本则直接 304,零传输
  const noneMatch = req.headers['if-none-match'];
  const modSince = req.headers['if-modified-since'];
  if ((noneMatch && noneMatch === etag) || (modSince && new Date(modSince).getTime() >= Math.floor(mtime))) {
    res.writeHead(304, headers);
    return res.end();
  }
  if (_tryGzip(req, res, fp, headers, stat)) return;
  const buf = fs.readFileSync(fp);
  headers['Content-Length'] = buf.length;
  res.writeHead(200, headers);
  res.end(buf);
}

function serveStatic(req, res, url) {
  let p = decodeURIComponent(url.pathname);
  // 后台隐藏路径:仅通过 /<slug>.html 访问,slug 变更后 /admin.html 返回 404
  const slug = adminSlug();
  if (p === '/' + slug + '.html') p = '/admin.html';
  else if (p === '/admin.html' && slug !== 'admin') return send(res, 404, 'not found');

  if (p.startsWith('/uploads/')) {
    const fp = path.join(UPLOAD_DIR, path.basename(p));
    if (fs.existsSync(fp)) {
      const ext = path.extname(fp).toLowerCase();
      return streamFile(req, res, fp, MIME[ext] || 'application/octet-stream', 'public, max-age=86400');
    }
    return send(res, 404, 'not found');
  }
  if (p === '/') p = '/index.html';
  const fp = path.join(PUBLIC_DIR, p);
  if (fp !== PUBLIC_DIR && !fp.startsWith(PUBLIC_DIR + path.sep)) return send(res, 403, 'forbidden');
  if (fs.existsSync(fp) && fs.statSync(fp).isFile()) {
    const ext = path.extname(fp).toLowerCase();
    const type = MIME[ext] || 'text/plain';
    // HTML 每次拉取最新(no-store);带 ?v= 的 JS/CSS 长期缓存(immutable);其余 1h
    const cache = ext === '.html'
      ? 'no-store, no-cache, must-revalidate'
      : (url.searchParams.has('v') ? 'public, max-age=86400, immutable' : 'public, max-age=3600');
    return streamFile(req, res, fp, type, cache);
  }
  return send(res, 404, 'not found');
}

const server = http.createServer((req, res) => {
  const url = new URL(req.url, `http://${req.headers.host}`);
  if (url.pathname.startsWith('/api/')) {
    handleApi(req, res, url).catch((e) => sendJson(res, 500, { error: e.message || 'server error' }));
  } else {
    serveStatic(req, res, url);
  }
});

server.listen(PORT, () => {
  console.log(`发卡网已启动: http://localhost:${PORT}  (后台路径: /${adminSlug()}.html)`);
  sweepExpiredOrders();
  maybeBackup();
});

// 定时任务:每分钟扫描超时订单;每小时检查是否需要每日备份
setInterval(sweepExpiredOrders, 60 * 1000);
setInterval(maybeBackup, 3600 * 1000);

其余文件查看:前台首页 · 后台 · 数据层 store.js · 种子数据 · package.json

完整源码打包下载:下载 faka 源码包(.zip,246K)

源码包已剔除 deploy/ 部署脚本与 orders/backups/uploads 运行数据,避免泄露服务器 SSH 凭据与买家隐私。部署流程见上方第 02、03、04、05 章。

到这里,你的发卡网就完整上线了

环境 → 上传 → 守护 → 反代 → HTTPS → 后台 → 运营 → 安全,八步走完。把这份教程发给买源码的朋友,他也能照着装起来。