准备工作
在上传代码之前,先确认你手上有这几样东西。本教程的生产环境是一台 CentOS 7 云服务器,装了宝塔(BT)面板做可视化管理。
45.207.222.25),root 或具 sudo 权限用户22756(非默认 22,云厂商常见安全做法)/usr/local/bin/node),应用零 npm 依赖80 / 443 / 22;SELinux 已关闭本项目不依赖任何第三方 npm 包,server.js 用 Node 内置模块即可运行,数据存于 data/*.json。这意味着部署极简,没有 npm install 环节,也不会有依赖冲突。
上传代码到服务器
项目自带一键部署脚本 deploy/deploy.py(基于 paramiko + scp,支持密码登录)。它会自动排除本地订单、日志、备份,只上传运行所需文件,生产环境从空订单起步。
# 在本机(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
脚本内部上传时会排除以下本地内容(避免污染生产数据):
data/orders.json # 生产从空订单开始
data/backups # 本地备份不传
server.log # 本地日志不传
node_modules # 零依赖,本来就没有
.git / .workbuddy / __pycache__
生产环境切勿整包重跑 deploy.py:它会遍历整个项目上传,连 data/*.json(商品 / 卡密 / 设置)都会覆盖,误用会冲掉线上数据。生产增量修复请走「针对性上传单文件 + 重启服务」(见下方运维命令)。
如果你更习惯手动上传,用 rsync 等价排除即可:
rsync -avz --exclude 'data/orders.json' --exclude 'data/backups' \
--exclude 'server.log' --exclude 'node_modules' --exclude '.workbuddy' \
./ root@45.207.222.25:/opt/faka
配置 systemd 守护进程
用 systemd 把应用注册成常驻服务,崩溃自动拉起、开机自启。生产目录 /opt/faka,专用运行用户 faka,应用监听 端口 3001。
[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
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 反代暴露出去。
宝塔 Nginx 反向代理
宝塔自带的 Nginx 由面板管理,不能直接改 /etc/nginx/conf.d/——宝塔不读那个目录。站点配置要写在 /www/server/panel/vhost/nginx/*.conf(主配置第 95 行 include 进来)。
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:
/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。两种方式任选。
域名与 Let's Encrypt HTTPS
先把域名 A 记录指向服务器公网 IP,再用 certbot 的 webroot 模式签发证书——这样完全不动宝塔配置,证书和 Nginx 解耦,最稳。
# 签发证书(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:
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):
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 进入后台。
后台配置(admin.html)
访问 https://yun.xingsu.store/admin.html,默认账号密码 admin / admin123。登录后按下面顺序配置。
设置页第一时间把 admin123 改成强密码。这是上线第一要务,别留默认口令。
个人卖家模式用微信 / 支付宝收款码图片(不对接第三方支付网关)。上传你的收款码到后台对应位置。
新建商品、填价格、上传真实商品图,导入卡密(一行一条)。买家付款后填微信订单号,你在后台「确认收款」即自动发货。
源码里部分示例商品库存为 0 / 已下架,上线前在后台把要卖的补足库存并上架。
后台默认密码 admin123 务必改掉。且演示站 / 商品截图里的收款码要打码,避免站外交易违规与隐私泄露(详见第 08 节安全合规)。
上架与日常运营
商城跑起来后,运营动作主要在外围渠道(如闲鱼 / 私域)。两个工程侧的关键点先记牢:
?v= 版本号 +1 再上传,否则 Nginx 按 URL 缓存会一直返回旧文件systemctl restart faka 即可,不要整包重传cp -r /opt/faka/data /opt/faka/data.bak.$(date +%F),建议每天定时备份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 端专属功能,网页版没有入口;商品标题发布后会被锁定(编辑页无标题输入框),想换标题只能下架重发。运营重心放在描述优化、真实截图与私域引流上。
安全与合规
发卡网涉及资金与隐私,这几条是从生产事故里总结的硬规矩:
deploy.py 会传整个项目,连商品/卡密/设置 JSON 都覆盖。生产修复一律「针对性上传 + 重启 faka.service」。
部署脚本里别硬编码密码。改用环境变量(如 FAKA_SSH_PASS)或 SSH 密钥登录,且不要提交进版本库。
admin123 必须改;后台路径 /admin.html 可被枚举,强密码是第一道防线。
商品图/演示站截图里的微信、支付宝收款码要脱敏打码;描述里用「详聊」「私」等软话术,不露明文联系方式,规避站外交易违规与封号风险。
常见问题与排错
访问域名报 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 整个目录即可(含商品、卡密、订单、设置)。恢复时停服、替换目录、重启。
网站源码速览
发卡网是纯 Node.js 零依赖应用。下面是与你拿到手一模一样的真实源码结构——核心后端 server.js(720 行)已完整贴在下方,可直接一键复制;其余前端与数据文件可在 src/ 目录查看,或下载整包。
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 行)
// 发卡网后端服务:仅使用 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 → 后台 → 运营 → 安全,八步走完。把这份教程发给买源码的朋友,他也能照着装起来。