Rename to hkt.sh

This commit is contained in:
mango
2026-03-21 01:10:53 +08:00
parent 76a263d0f9
commit 8f1171fe99
6676 changed files with 1724268 additions and 0 deletions

5
aff-monitor/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
db/*.sqlite
db/*.sqlite-journal
db/*.sqlite-wal
.env

354
aff-monitor/README.md Normal file
View File

@@ -0,0 +1,354 @@
# VPS补货监控 — 商家产品库存监控 + Telegram 推送
前台公开浏览 + 后台登录管理。轻量级库存监控系统,支持自动检测 VPS/独服库存变化并通过 Telegram 推送通知。
## 架构概览
```
前台(公开) 后台(需登录)
/ → 首页 /admin → 管理仪表盘
/plans → 产品列表 /admin/merchants → 商家管理
/plans/:slug → 产品详情 /admin/products → 产品管理(含编辑)
/plans/:id → 兼容旧链接 /admin/aff-links → Aff 链接管理
(301→slug) /admin/channels → TG 频道配置
/admin/tasks → 监控任务 & 检测
/admin/settings → 系统设置TG Token 等)
/admin/login → 登录页
/admin/logout → 退出登录
```
## 技术栈
| 组件 | 选型 | 说明 |
|------|------|------|
| 运行时 | Node.js 18+ | 部署到 Debian 13 兼容 |
| Web 框架 | Express 4 | 最省心的 Node 后台框架 |
| 模板引擎 | EJS | 服务端渲染,无需构建步骤 |
| 数据库 | SQLite (better-sqlite3) | 单文件、零配置 |
| 认证 | express-session | 轻量 session 登录 |
| 配置 | dotenv | .env 文件管理 |
## 项目结构
```
aff-monitor/
├── db/
│ ├── init.js # 建表 + 种子数据
│ ├── migrate-add-product-fields.js # 迁移 001: 产品扩展字段
│ ├── migrate-002-checker-fields.js # 迁移 002: 检测相关字段
│ ├── migrate-003-public-fields.js # 迁移 003: 前台展示字段
│ ├── migrate-004-pid-slug.js # 迁移 004: PID & Slug 字段
│ ├── migrate-005-aff-code.js # 迁移 005: Aff Code 字段
│ ├── migrate-006-settings.js # 迁移 006: Settings 表 & generated_aff_url
│ └── monitor.sqlite # 数据库文件 (运行后生成)
├── scripts/
│ └── run-task.js # CLI: 手动执行/列出任务
├── src/
│ ├── app.js # Express 入口(含 session + 路由挂载)
│ ├── db.js # 数据库单例
│ ├── routes/
│ │ ├── public.js # 前台公开路由(首页/产品列表/详情)
│ │ ├── auth.js # 登录/登出路由
│ │ ├── admin.js # 后台仪表盘
│ │ ├── merchants.js # 商家 CRUD
│ │ ├── products.js # 产品 CRUD + 编辑
│ │ ├── channels.js # TG 频道 CRUD
│ │ ├── affLinks.js # Aff 链接 CRUD
│ │ ├── tasks.js # 监控任务 + 检测 + 推送 + 调度
│ │ └── settings.js # 系统设置TG Token 等)
│ ├── utils/
│ │ ├── checker.js # 库存检测器
│ │ ├── telegram.js # Telegram Bot 推送
│ │ ├── taskRunner.js # 任务执行器
│ │ ├── scheduler.js # 轻量调度器
│ │ ├── pushTemplate.js # 推送消息模板
│ │ ├── pidHelper.js # PID & Slug 自动生成/解析
│ │ ├── affHelper.js # Aff 链接解析/生成
│ │ └── settings.js # 系统设置工具DB优先.env兜底
│ ├── views/
│ │ ├── login.ejs # 登录页
│ │ ├── admin/ # 后台模板
│ │ │ ├── index.ejs # 仪表盘
│ │ │ ├── merchants.ejs # 商家管理
│ │ │ ├── products.ejs # 产品管理
│ │ │ ├── product-edit.ejs # 产品编辑
│ │ │ ├── channels.ejs # 频道配置
│ │ │ ├── affLinks.ejs # Aff 链接
│ │ │ ├── tasks.ejs # 监控任务
│ │ │ └── settings.ejs # 系统设置
│ │ ├── public/ # 前台模板
│ │ │ ├── home.ejs # 首页
│ │ │ ├── plans.ejs # 产品列表
│ │ │ ├── detail.ejs # 产品详情
│ │ │ └── 404.ejs # 404 页面
│ │ └── partials/
│ │ ├── admin-header.ejs # 后台导航
│ │ ├── public-header.ejs # 前台导航
│ │ └── footer.ejs # 统一页脚
│ └── public/ # 静态资源
├── .env.example
├── .gitignore
├── package.json
└── README.md
```
## 环境变量
| 变量 | 必填 | 默认值 | 说明 |
|------|------|--------|------|
| `PORT` | 否 | `3900` | Web 服务端口 |
| `DB_PATH` | 否 | `db/monitor.sqlite` | SQLite 数据库路径 |
| `ADMIN_USERNAME` | 否 | `admin` | 后台登录用户名 |
| `ADMIN_PASSWORD` | 否 | `admin` | 后台登录密码 |
| `SESSION_SECRET` | **建议设置** | 内置默认值 | Session 签名密钥,生产环境请换成随机长字符串 |
| `TG_BOT_TOKEN` | 推送时必填 | - | Telegram Bot Token可在后台「系统设置」页面配置数据库优先 |
| `TG_DEFAULT_CHANNEL_ID` | 否 | - | 默认推送频道的 chat_id可在后台「系统设置」页面配置 |
| `MONITOR_INTERVAL` | 否 | `300` | 调度器检测间隔(秒) |
| `SCHEDULER_ENABLED` | 否 | `false` | 启动时是否自动开启调度器 |
> **配置优先级**TG_BOT_TOKEN、TG_DEFAULT_CHANNEL_ID、SITE_NAME、SITE_TG_URL 等配置项,优先从数据库 `settings` 表读取,数据库没有则回退到 .env 文件。推荐在后台「系统设置」页面直接配置。
## 本地启动
```bash
# 1. 安装依赖
cd aff-monitor
npm install
# 2. 复制环境变量(首次)
cp .env.example .env
# 编辑 .env至少设置 ADMIN_PASSWORD 和 SESSION_SECRET
# 3. 初始化数据库 + 运行迁移
npm run db:init
npm run db:migrate
# 4. 启动
npm run dev # 开发模式 (--watch 自动重载)
# 或
npm start # 生产模式
# 5. 打开
# 前台http://localhost:3900
# 后台http://localhost:3900/admin/login
```
## 前台功能
- **首页 `/`**Landing Page 风格Hero 区展示品牌、刷新状态、Telegram 频道入口;产品卡片流展示推荐和最新产品,移动端友好设计
- **产品列表 `/plans`**:按商家、地区筛选,支持关键词搜索
- **产品详情 `/plans/:slug`**:查看产品配置、价格、购买链接、同商家其他产品(也兼容 `/plans/:id`,自动 301 重定向到 slug
前台只展示 `is_public = 1` 的产品。
## 产品 PID & Slug
每个产品有三个标识字段:
| 字段 | 说明 | 示例 |
|------|------|------|
| `internal_pid` | 系统内部编号,自动生成,不可改 | `VPS-000001` |
| `provider_pid` | 商家产品 ID从购买链接自动解析 | `28`(来自 `pid=28` |
| `slug` | 前台友好 URL自动生成可手改 | `gomami-hkgpulsemini` |
### 自动解析规则
当用户填写购买链接(`buy_url`)时,系统自动从 URL 中解析 `provider_pid`
- **高优先级参数**`pid``product``product_id``plan``plan_id``package``package_id`
- **谨慎参数**`id`(仅当 URL 路径含 product/plan/cart/aff/billing 等关键词时才识别)
- **路径模式**`/product/28``/plan/28`
示例:`https://console.po0.com/aff.php?aff=5&pid=28``provider_pid = 28`
## Aff 链接自动生成
系统支持用户设置自己的 aff 标识,自动生成最终推广链接。
### 工作流程
1. **录入产品时**填写基础购买链接(`buy_url`)和你的 aff 值(`aff_code`
2. 系统自动生成最终 aff 链接:`buy_url` + `aff_param=aff_code`
3. 生成的链接会自动出现在产品管理页和 Aff 链接页
### 三种使用方式
| 方式 | 操作 | 效果 |
|------|------|------|
| **直接粘贴 aff 链接** | buy_url 填 `https://example.com/aff.php?aff=5&pid=28` | 自动识别 `aff_code=5``provider_pid=28` |
| **分开填写** | buy_url 填干净链接aff 值填 `5` | 自动生成完整 aff 链接 |
| **批量生成** | 在 Aff 链接页点"批量生成" | 为所有有 aff_code 但没有 aff_link 的产品一键生成 |
### 字段说明
| 字段 | 说明 | 示例 |
|------|------|------|
| `aff_code` | 你的 aff 标识值 | `5` |
| `aff_param` | aff 参数名(默认 `aff` | `aff``ref``affiliate` |
支持识别的 aff 参数名:`aff``affid``aff_id``ref``refid``ref_id``referral``partner``affiliate`
## 系统设置(后台)
访问 `/admin/settings` 可在网页中配置:
- **TG_BOT_TOKEN**Telegram Bot Token用于推送消息
- **TG_DEFAULT_CHANNEL_ID**:默认推送频道 ID
- **SITE_NAME**:站点名称
- **DEFAULT_AFF_CODE**:新产品默认 aff 值
配置保存到数据库 `settings` 表,优先级高于 .env 文件。页面支持:
- 保存配置
- 测试 Telegram 连接(发送测试消息到指定频道)
## 推送链接优先级
补货推送时,系统按以下优先级选择链接:
1. **产品生成的 Aff 链接**`products.generated_aff_url`,由 buy_url + aff_code 自动生成)
2. **Aff 链接表**`aff_links` 表中的链接,支持手工添加或批量生成)
3. **产品的 buy_url**(原始购买链接)
4. **产品的 url**(产品页链接)
在产品编辑页可以看到"推送用的 Aff 链接",明确标识哪个链接会被用于推送。
## 后台功能
访问 `/admin/login` 登录后进入管理后台:
- 仪表盘、商家/产品/Aff 链接/频道/任务的增删改
- 产品支持完整编辑(名称、价格、地区、配置、流量、优惠码、检测模式等)
- 产品可设置"前台展示"、"推荐"、"排序值"来控制前台展示
## 手动执行检测
### Web 界面
后台「监控任务」页面,点击任务行的 **▶ 执行** 按钮。
### CLI
```bash
npm run task:list # 列出所有任务
npm run task:run -- 1 # 执行指定任务
npm run task:run-all # 执行所有启用的任务
```
### API
```bash
curl -X POST http://localhost:3900/admin/tasks/api/1/run
```
## 部署到 Debian 13 服务器
```bash
# 1. 安装 Node.js
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo bash -
sudo apt-get install -y nodejs
# 2. 上传代码
rsync -avz --exclude node_modules --exclude 'db/*.sqlite*' aff-monitor/ root@your-server:~/aff-monitor/
# 3. 服务器上安装 + 初始化
cd ~/aff-monitor
npm install --production
cp .env.example .env
# 编辑 .env设置 ADMIN_PASSWORD, SESSION_SECRET, TG_BOT_TOKEN 等
npm run db:init
npm run db:migrate
# 4. 用 systemd 管理进程
sudo tee /etc/systemd/system/aff-monitor.service << 'EOF'
[Unit]
Description=VPS补货监控
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/root/aff-monitor
ExecStart=/usr/bin/node src/app.js
Restart=on-failure
RestartSec=5
Environment=NODE_ENV=production
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now aff-monitor
```
## 数据表概览
| 表名 | 用途 |
|------|------|
| `merchants` | 商家信息 |
| `products` | 产品 + 检测配置 + 前台展示控制 |
| `aff_links` | Aff 推广链接 |
| `tg_channels` | Telegram 频道配置 |
| `monitor_tasks` | 监控任务 |
| `check_logs` | 每次检测的记录 |
| `settings` | 系统配置TG_BOT_TOKEN 等) |
### products 表关键字段
| 字段 | 类型 | 默认 | 说明 |
|------|------|------|------|
| `internal_pid` | TEXT | 自动生成 | 系统内部编号VPS-000001 |
| `provider_pid` | TEXT | 自动解析 | 商家产品 ID |
| `slug` | TEXT | 自动生成 | 前台友好 URL |
| `aff_code` | TEXT | 自动解析 | 用户的 aff 标识值 |
| `aff_param` | TEXT | `aff` | aff 参数名 |
| `generated_aff_url` | TEXT | 自动生成 | 自动生成的 aff 链接(推送优先使用) |
| `is_public` | INTEGER | 1 | 是否在前台展示 |
| `is_featured` | INTEGER | 0 | 是否推荐(首页推荐区) |
| `sort_order` | INTEGER | 100 | 排序值(越小越靠前) |
### settings 表
| 字段 | 类型 | 说明 |
|------|------|------|
| `key` | TEXT | 配置键名(主键) |
| `value` | TEXT | 配置值 |
| `updated_at` | TEXT | 更新时间 |
## Changelog
### v0.5.0
- ✅ 新增后台「系统设置」页面,支持网页配置 TG_BOT_TOKEN / TG_DEFAULT_CHANNEL_ID / SITE_NAME
- ✅ 配置优先级:数据库优先,.env 兜底
- ✅ 设置页支持测试 Telegram 连接
- ✅ 产品表新增 `generated_aff_url` 字段,缓存自动生成的 aff 链接
- ✅ 产品编辑页显示"推送用的 Aff 链接",明确标识推送时使用哪个链接
- ✅ 推送链接优先级generated_aff_url → aff_links 表 → buy_url → url
- ✅ Aff 链接页显示链接来源(系统生成/手工添加)
### v0.4.0
- ✅ 新增 internal_pid系统内部编号自动生成 VPS-000001
- ✅ 新增 provider_pid商家产品 ID从购买链接自动解析
- ✅ 新增 slug前台友好 URL自动生成
- ✅ 前台详情路由支持 `/plans/:slug`,旧数字 ID 自动 301 重定向
- ✅ 后台产品表单增加 PID/Slug 字段展示与编辑
- ✅ 购买链接输入时实时解析 provider_pid前端 AJAX
- ✅ 迁移脚本自动回填已有产品的三个字段
- ✅ 新增 aff_code / aff_param 字段,支持设置用户自己的 aff 标识
- ✅ 购买链接粘贴时自动识别 aff 参数aff/ref/affiliate 等)
- ✅ 基础购买链接 + aff 值 → 自动生成最终 aff 推广链接
- ✅ Aff 链接页支持批量为所有有 aff_code 的产品一键生成
- ✅ 产品列表展示 aff 状态和生成的链接
- ✅ 前端实时预览生成的 aff 链接
### v0.3.0
- ✅ 新增前台公开页面(首页、产品列表、产品详情)
- ✅ 后台登录保护express-session + .env 账号密码)
- ✅ 后台路由统一移到 /admin 前缀
- ✅ 产品编辑功能
- ✅ 新增 is_public / is_featured / sort_order 字段
- ✅ 前后台导航分离
### v0.2.0
- 库存检测HTTP + 关键词匹配)
- Telegram 推送
- 定时调度器
- 推送文案模板
### v0.1.0
- 基础 CRUD商家、产品、Aff 链接、频道、任务)

108
aff-monitor/db/init.js Normal file
View File

@@ -0,0 +1,108 @@
/**
* 数据库初始化 — 建表 + 种子数据
*/
const Database = require('better-sqlite3');
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
const dbPath = path.resolve(__dirname, '..', process.env.DB_PATH || 'db/monitor.sqlite');
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(`
-- ========== 商家 ==========
CREATE TABLE IF NOT EXISTS merchants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
website TEXT,
notes TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
-- ========== 产品 ==========
CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
merchant_id INTEGER NOT NULL REFERENCES merchants(id) ON DELETE CASCADE,
name TEXT NOT NULL,
url TEXT,
sku TEXT,
price TEXT,
in_stock INTEGER DEFAULT 0, -- 0=未知 1=有货 2=缺货
last_checked TEXT,
notes TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
-- ========== Aff 链接 ==========
CREATE TABLE IF NOT EXISTS aff_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
platform TEXT NOT NULL DEFAULT 'default', -- e.g. default / telegram / twitter
url TEXT NOT NULL,
notes TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
-- ========== Telegram 频道配置 ==========
CREATE TABLE IF NOT EXISTS tg_channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
chat_id TEXT NOT NULL,
enabled INTEGER DEFAULT 1,
notes TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
-- ========== 监控任务 ==========
CREATE TABLE IF NOT EXISTS monitor_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
tg_channel_id INTEGER REFERENCES tg_channels(id) ON DELETE SET NULL,
cron_expr TEXT DEFAULT '*/5 * * * *',
enabled INTEGER DEFAULT 1,
last_run TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
-- ========== 检测记录 ==========
CREATE TABLE IF NOT EXISTS check_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL REFERENCES monitor_tasks(id) ON DELETE CASCADE,
product_id INTEGER NOT NULL,
status TEXT NOT NULL, -- in_stock / out_of_stock / error
message TEXT,
notified INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_check_logs_task ON check_logs(task_id);
CREATE INDEX IF NOT EXISTS idx_check_logs_date ON check_logs(created_at);
CREATE INDEX IF NOT EXISTS idx_products_merchant ON products(merchant_id);
`);
console.log('✅ 数据库初始化完成:', dbPath);
// 种子数据(仅当 merchants 为空时插入)
const count = db.prepare('SELECT count(*) AS c FROM merchants').get().c;
if (count === 0) {
const insertMerchant = db.prepare('INSERT INTO merchants (name, website, notes) VALUES (?, ?, ?)');
const insertProduct = db.prepare('INSERT INTO products (merchant_id, name, url, price) VALUES (?, ?, ?, ?)');
const insertChannel = db.prepare('INSERT INTO tg_channels (name, chat_id) VALUES (?, ?)');
db.transaction(() => {
insertMerchant.run('示例商家 A', 'https://example-a.com', '这是一个演示商家');
insertMerchant.run('示例商家 B', 'https://example-b.com', null);
insertProduct.run(1, 'VPS 套餐 Basic', 'https://example-a.com/vps-basic', '$4.99/mo');
insertProduct.run(1, 'VPS 套餐 Pro', 'https://example-a.com/vps-pro', '$9.99/mo');
insertProduct.run(2, '独服 E3', 'https://example-b.com/dedi-e3', '€29/mo');
insertChannel.run('测试频道', '-1001234567890');
})();
console.log('🌱 种子数据已插入');
}
db.close();

View File

@@ -0,0 +1,47 @@
/**
* Migration 002 — 添加检测相关字段到 products
*
* - check_mode: 检测模式 (keyword | api | manual)
* - in_stock_keywords: 有货关键词 (逗号分隔)
* - out_of_stock_keywords: 缺货关键词 (逗号分隔)
*/
const Database = require('better-sqlite3');
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
const dbPath = path.resolve(__dirname, '..', process.env.DB_PATH || 'db/monitor.sqlite');
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
function ensureColumn(table, column, sql) {
const cols = db.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name);
if (!cols.includes(column)) {
db.exec(`ALTER TABLE ${table} ADD COLUMN ${sql}`);
console.log(`+ ${table}.${column}`);
} else {
console.log(` ${table}.${column} (already exists)`);
}
}
ensureColumn('products', 'check_mode', "check_mode TEXT DEFAULT 'keyword'");
ensureColumn('products', 'in_stock_keywords', 'in_stock_keywords TEXT');
ensureColumn('products', 'out_of_stock_keywords', 'out_of_stock_keywords TEXT');
// 为 GoMami 测试样例设置默认关键词
const gomami = db.prepare("SELECT id FROM products WHERE name LIKE '%Pulse%' AND check_mode IS NULL").all();
if (gomami.length > 0) {
const stmt = db.prepare(`
UPDATE products
SET check_mode = 'keyword',
in_stock_keywords = 'Add to Cart,立即购买,加入购物车',
out_of_stock_keywords = 'Out of Stock,缺货,售罄,Sold Out,Currently Unavailable'
WHERE id = ?
`);
for (const row of gomami) {
stmt.run(row.id);
console.log(`🏷 设置 product#${row.id} 的检测关键词`);
}
}
console.log('✅ migration-002 done:', dbPath);
db.close();

View File

@@ -0,0 +1,31 @@
/**
* Migration 003 — 前台展示字段
*
* - is_public: 是否在前台展示 (0/1, 默认 1)
* - is_featured: 是否推荐 (0/1, 默认 0)
* - sort_order: 排序值 (数字越小越靠前, 默认 100)
*/
const Database = require('better-sqlite3');
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
const dbPath = path.resolve(__dirname, '..', process.env.DB_PATH || 'db/monitor.sqlite');
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
function ensureColumn(table, column, sql) {
const cols = db.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name);
if (!cols.includes(column)) {
db.exec(`ALTER TABLE ${table} ADD COLUMN ${sql}`);
console.log(`+ ${table}.${column}`);
} else {
console.log(` ${table}.${column} (already exists)`);
}
}
ensureColumn('products', 'is_public', 'is_public INTEGER DEFAULT 1');
ensureColumn('products', 'is_featured', 'is_featured INTEGER DEFAULT 0');
ensureColumn('products', 'sort_order', 'sort_order INTEGER DEFAULT 100');
console.log('✅ migration-003 done:', dbPath);
db.close();

View File

@@ -0,0 +1,86 @@
/**
* Migration 004 — PID & Slug 字段
*
* - internal_pid: 系统内部编号,如 VPS-000001唯一自动生成
* - provider_pid: 商家产品 ID从购买链接自动解析如 pid=28
* - slug: 前台友好 URL唯一
*/
const Database = require('better-sqlite3');
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
const dbPath = path.resolve(__dirname, '..', process.env.DB_PATH || 'db/monitor.sqlite');
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
function ensureColumn(table, column, sql) {
const cols = db.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name);
if (!cols.includes(column)) {
db.exec(`ALTER TABLE ${table} ADD COLUMN ${sql}`);
console.log(`+ ${table}.${column}`);
} else {
console.log(` ${table}.${column} (already exists)`);
}
}
// 添加新字段
ensureColumn('products', 'internal_pid', 'internal_pid TEXT');
ensureColumn('products', 'provider_pid', 'provider_pid TEXT');
ensureColumn('products', 'slug', 'slug TEXT');
// 创建唯一索引(如果尚不存在)
try {
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_products_internal_pid ON products(internal_pid) WHERE internal_pid IS NOT NULL');
console.log('+ index: idx_products_internal_pid');
} catch (e) {
console.log(' index idx_products_internal_pid:', e.message);
}
try {
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_products_slug ON products(slug) WHERE slug IS NOT NULL');
console.log('+ index: idx_products_slug');
} catch (e) {
console.log(' index idx_products_slug:', e.message);
}
// 为已有产品回填 internal_pid 和 slug
const { generateInternalPid, generateSlug, parseProviderPid } = require('../src/utils/pidHelper');
const products = db.prepare('SELECT id, name, merchant_id, url, buy_url, internal_pid, slug, provider_pid FROM products').all();
const updateStmt = db.prepare('UPDATE products SET internal_pid = ?, slug = ?, provider_pid = ? WHERE id = ?');
const getMerchantName = db.prepare('SELECT name FROM merchants WHERE id = ?');
const existingSlugs = new Set(
db.prepare("SELECT slug FROM products WHERE slug IS NOT NULL AND slug != ''").all().map(r => r.slug)
);
db.transaction(() => {
for (const p of products) {
let ipid = p.internal_pid;
let slug = p.slug;
let ppid = p.provider_pid;
// 生成 internal_pid
if (!ipid) {
ipid = generateInternalPid(db);
}
// 生成 slug
if (!slug) {
const merchant = getMerchantName.get(p.merchant_id);
const merchantName = merchant ? merchant.name : '';
slug = generateSlug(p.name, merchantName, existingSlugs);
existingSlugs.add(slug);
}
// 解析 provider_pid
if (!ppid) {
ppid = parseProviderPid(p.buy_url) || parseProviderPid(p.url) || null;
}
updateStmt.run(ipid, slug, ppid, p.id);
console.log(` product #${p.id}: internal_pid=${ipid}, slug=${slug}, provider_pid=${ppid || '(none)'}`);
}
})();
console.log('✅ migration-004 done:', dbPath);
db.close();

View File

@@ -0,0 +1,46 @@
/**
* Migration 005 — Aff Code 字段
*
* - aff_code: 用户的 aff 标识值(如 "5"),用于自动生成 aff 链接
* - aff_param: aff 参数名(默认 "aff"),支持不同商家用不同参数名
*/
const Database = require('better-sqlite3');
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
const dbPath = path.resolve(__dirname, '..', process.env.DB_PATH || 'db/monitor.sqlite');
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
function ensureColumn(table, column, sql) {
const cols = db.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name);
if (!cols.includes(column)) {
db.exec(`ALTER TABLE ${table} ADD COLUMN ${sql}`);
console.log(`+ ${table}.${column}`);
} else {
console.log(` ${table}.${column} (already exists)`);
}
}
ensureColumn('products', 'aff_code', 'aff_code TEXT');
ensureColumn('products', 'aff_param', "aff_param TEXT DEFAULT 'aff'");
// 回填:从已有 buy_url 中解析 aff_code
const { parseAffCode } = require('../src/utils/affHelper');
const products = db.prepare('SELECT id, buy_url, url, aff_code FROM products').all();
const updateStmt = db.prepare('UPDATE products SET aff_code = ? WHERE id = ?');
db.transaction(() => {
for (const p of products) {
if (p.aff_code) continue; // 已有值,跳过
const parsed = parseAffCode(p.buy_url) || parseAffCode(p.url);
if (parsed) {
updateStmt.run(parsed.value, p.id);
console.log(` product #${p.id}: aff_code=${parsed.value} (from ${parsed.param}=${parsed.value})`);
}
}
})();
console.log('✅ migration-005 done:', dbPath);
db.close();

View File

@@ -0,0 +1,65 @@
/**
* Migration 006 — Settings 表 + products.generated_aff_url
*
* - settings: 键值对存储系统配置TG_BOT_TOKEN, TG_DEFAULT_CHANNEL_ID 等)
* - products.generated_aff_url: 缓存自动生成的 aff 链接,方便查询
*/
const Database = require('better-sqlite3');
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
const dbPath = path.resolve(__dirname, '..', process.env.DB_PATH || 'db/monitor.sqlite');
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
// ── 创建 settings 表 ──
db.exec(`
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT,
updated_at TEXT DEFAULT (datetime('now'))
);
`);
console.log('+ settings table');
// ── products 加 generated_aff_url 列 ──
function ensureColumn(table, column, sql) {
const cols = db.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name);
if (!cols.includes(column)) {
db.exec(`ALTER TABLE ${table} ADD COLUMN ${sql}`);
console.log(`+ ${table}.${column}`);
} else {
console.log(` ${table}.${column} (already exists)`);
}
}
ensureColumn('products', 'generated_aff_url', 'generated_aff_url TEXT');
// ── 回填 generated_aff_url兼容老库aff_code/aff_param 可能还不存在) ──
const cols = db.prepare(`PRAGMA table_info(products)`).all().map(c => c.name);
const hasAffCode = cols.includes('aff_code');
const hasAffParam = cols.includes('aff_param');
if (hasAffCode) {
const { buildAffUrl } = require('../src/utils/affHelper');
const selectSql = hasAffParam
? "SELECT id, buy_url, aff_code, aff_param FROM products WHERE aff_code IS NOT NULL AND aff_code != ''"
: "SELECT id, buy_url, aff_code, NULL as aff_param FROM products WHERE aff_code IS NOT NULL AND aff_code != ''";
const products = db.prepare(selectSql).all();
const updateStmt = db.prepare('UPDATE products SET generated_aff_url = ? WHERE id = ?');
db.transaction(() => {
for (const p of products) {
const baseUrl = p.buy_url;
if (!baseUrl) continue;
const affUrl = buildAffUrl(baseUrl, p.aff_code, p.aff_param || 'aff');
updateStmt.run(affUrl, p.id);
console.log(` product #${p.id}: generated_aff_url=${affUrl}`);
}
})();
} else {
console.log(' skip backfill: products.aff_code not found yet');
}
console.log('✅ migration-006 done:', dbPath);
db.close();

View File

@@ -0,0 +1,28 @@
const Database = require('better-sqlite3');
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
const dbPath = path.resolve(__dirname, '..', process.env.DB_PATH || 'db/monitor.sqlite');
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
function ensureColumn(table, column, sql) {
const cols = db.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name);
if (!cols.includes(column)) {
db.exec(`ALTER TABLE ${table} ADD COLUMN ${sql}`);
console.log(`+ ${table}.${column}`);
}
}
ensureColumn('products', 'location', 'location TEXT');
ensureColumn('products', 'spec_summary', 'spec_summary TEXT');
ensureColumn('products', 'traffic', 'traffic TEXT');
ensureColumn('products', 'billing_cycle', 'billing_cycle TEXT');
ensureColumn('products', 'coupon_code', 'coupon_code TEXT');
ensureColumn('products', 'annual_price', 'annual_price TEXT');
ensureColumn('products', 'tags', 'tags TEXT');
ensureColumn('products', 'buy_url', 'buy_url TEXT');
ensureColumn('products', 'push_intro', 'push_intro TEXT');
console.log('✅ migration done:', dbPath);
db.close();

Binary file not shown.

1399
aff-monitor/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
aff-monitor/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "vps-restock-monitor",
"version": "0.4.0",
"description": "vps补货监控商家产品库存监控 + Telegram 频道推送",
"main": "src/app.js",
"scripts": {
"start": "node src/app.js",
"dev": "node --watch src/app.js",
"db:init": "node db/init.js",
"db:migrate": "node db/migrate-add-product-fields.js && node db/migrate-002-checker-fields.js && node db/migrate-003-public-fields.js && node db/migrate-004-pid-slug.js && node db/migrate-005-aff-code.js && node db/migrate-006-settings.js",
"db:migrate:004": "node db/migrate-004-pid-slug.js",
"db:migrate:005": "node db/migrate-005-aff-code.js",
"db:migrate:006": "node db/migrate-006-settings.js",
"db:migrate:002": "node db/migrate-002-checker-fields.js",
"db:migrate:003": "node db/migrate-003-public-fields.js",
"task:run": "node scripts/run-task.js",
"task:list": "node scripts/run-task.js --list",
"task:run-all": "node scripts/run-task.js --all",
"test:push": "node scripts/run-task.js --test-push"
},
"dependencies": {
"better-sqlite3": "^11.7.0",
"dotenv": "^16.4.7",
"ejs": "^3.1.10",
"express": "^4.21.0",
"express-session": "^1.19.0"
}
}

View File

@@ -0,0 +1,95 @@
#!/usr/bin/env node
/**
* CLI: 手动执行监控任务
*
* 用法:
* node scripts/run-task.js <task_id> — 执行指定任务
* node scripts/run-task.js --all — 执行所有启用的任务
* node scripts/run-task.js --test-push [chat] — 测试 Telegram 推送
*/
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
const db = require('../src/db');
const { runTask } = require('../src/utils/taskRunner');
const { sendTestMessage } = require('../src/utils/telegram');
async function main() {
const args = process.argv.slice(2);
if (args.length === 0) {
console.log(`
用法:
node scripts/run-task.js <task_id> 执行指定任务
node scripts/run-task.js --all 执行所有启用的任务
node scripts/run-task.js --test-push [chat] 测试 TG 推送
node scripts/run-task.js --list 列出所有任务
环境变量:
TG_BOT_TOKEN Telegram Bot Token
TG_DEFAULT_CHANNEL_ID 默认推送频道
`);
process.exit(0);
}
if (args[0] === '--test-push') {
const chatId = args[1] || undefined;
console.log('🧪 发送测试推送...');
const result = await sendTestMessage(chatId);
console.log(result.ok ? '✅ 推送成功' : `❌ 推送失败: ${result.description}`);
process.exit(result.ok ? 0 : 1);
}
if (args[0] === '--list') {
const tasks = db.prepare(`
SELECT t.id, t.enabled, t.cron_expr, t.last_run, p.name AS product_name, m.name AS merchant_name
FROM monitor_tasks t
LEFT JOIN products p ON t.product_id = p.id
LEFT JOIN merchants m ON p.merchant_id = m.id
ORDER BY t.id
`).all();
if (tasks.length === 0) {
console.log('暂无任务');
} else {
console.log('ID | 状态 | 产品 | 上次运行');
console.log('-'.repeat(70));
for (const t of tasks) {
const status = t.enabled ? '✅' : '⏸ ';
console.log(`${String(t.id).padEnd(4)}| ${status} | ${(t.merchant_name + ' — ' + t.product_name).padEnd(30)}| ${t.last_run || '-'}`);
}
}
process.exit(0);
}
if (args[0] === '--all') {
const tasks = db.prepare('SELECT id FROM monitor_tasks WHERE enabled = 1').all();
if (tasks.length === 0) {
console.log('没有启用的任务');
process.exit(0);
}
console.log(`🔄 执行 ${tasks.length} 个任务...\n`);
for (const task of tasks) {
console.log(`── 任务 #${task.id} ──`);
const result = await runTask(task.id);
console.log(` ${result.message}\n`);
}
console.log('✅ 全部完成');
process.exit(0);
}
// 指定 task_id
const taskId = parseInt(args[0], 10);
if (isNaN(taskId)) {
console.error('❌ 无效的 task_id:', args[0]);
process.exit(1);
}
console.log(`🔄 执行任务 #${taskId}...`);
const result = await runTask(taskId);
console.log(result.message);
process.exit(result.success ? 0 : 1);
}
main().catch(err => {
console.error('❌ 致命错误:', err);
process.exit(1);
});

67
aff-monitor/src/app.js Normal file
View File

@@ -0,0 +1,67 @@
/**
* aff-monitor — 主入口
* 前台公开 + 后台登录管理
*/
require('dotenv').config();
const express = require('express');
const session = require('express-session');
const path = require('path');
const app = express();
const PORT = process.env.PORT || 3900;
// ── 中间件 ──
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));
// Session
app.use(session({
secret: process.env.SESSION_SECRET || 'vps-monitor-default-secret',
resave: false,
saveUninitialized: false,
cookie: {
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 天
httpOnly: true,
},
}));
// 把登录状态传给所有模板
app.use((req, res, next) => {
res.locals.isAdmin = !!req.session.isAdmin;
next();
});
// ── 认证中间件 ──
function requireAdmin(req, res, next) {
if (req.session.isAdmin) return next();
res.redirect('/admin/login');
}
// ── 前台公开路由(无需登录) ──
app.use('/', require('./routes/public'));
// ── 登录/登出路由 ──
app.use('/admin', require('./routes/auth'));
// ── 后台管理路由(需要登录) ──
app.use('/admin', requireAdmin, require('./routes/admin'));
app.use('/admin/merchants', requireAdmin, require('./routes/merchants'));
app.use('/admin/products', requireAdmin, require('./routes/products'));
app.use('/admin/channels', requireAdmin, require('./routes/channels'));
app.use('/admin/aff-links', requireAdmin, require('./routes/affLinks'));
app.use('/admin/tasks', requireAdmin, require('./routes/tasks'));
app.use('/admin/settings', requireAdmin, require('./routes/settings'));
// ── 启动 ──
app.listen(PORT, () => {
console.log(`🚀 vps补货监控 运行中: http://localhost:${PORT}`);
// 自动启动调度器(如果配置了 SCHEDULER_ENABLED=true
if (process.env.SCHEDULER_ENABLED === 'true') {
const scheduler = require('./utils/scheduler');
scheduler.start();
}
});

13
aff-monitor/src/db.js Normal file
View File

@@ -0,0 +1,13 @@
/**
* 数据库单例
*/
const Database = require('better-sqlite3');
const path = require('path');
const dbPath = path.resolve(__dirname, '..', process.env.DB_PATH || 'db/monitor.sqlite');
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
module.exports = db;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,42 @@
/**
* 后台管理 — 仪表盘
*/
const router = require('express').Router();
const db = require('../db');
const { buildPushMessage } = require('../utils/pushTemplate');
router.get('/', (req, res) => {
const stats = {
merchants: db.prepare('SELECT COUNT(*) AS n FROM merchants').get().n,
products: db.prepare('SELECT COUNT(*) AS n FROM products').get().n,
channels: db.prepare('SELECT COUNT(*) AS n FROM tg_channels').get().n,
tasks: db.prepare('SELECT COUNT(*) AS n FROM monitor_tasks').get().n,
recentLogs: db.prepare(`SELECT COUNT(*) AS n FROM check_logs WHERE created_at >= datetime('now', '-1 day')`).get().n,
};
const sampleProduct = db.prepare(`
SELECT p.*, m.name AS merchant_name,
p.generated_aff_url AS product_aff_url,
(
SELECT url FROM aff_links a
WHERE a.product_id = p.id AND a.platform = 'telegram'
ORDER BY a.id DESC LIMIT 1
) AS tg_aff_url,
(
SELECT url FROM aff_links a
WHERE a.product_id = p.id
ORDER BY a.id DESC LIMIT 1
) AS any_aff_url
FROM products p
LEFT JOIN merchants m ON p.merchant_id = m.id
ORDER BY p.id DESC LIMIT 1
`).get();
const preview = sampleProduct
? buildPushMessage({ ...sampleProduct, aff_url: sampleProduct.product_aff_url || sampleProduct.tg_aff_url || sampleProduct.any_aff_url || sampleProduct.buy_url || sampleProduct.url || null })
: null;
res.render('admin/index', { stats, preview });
});
module.exports = router;

View File

@@ -0,0 +1,74 @@
const router = require('express').Router();
const db = require('../db');
const { buildAffUrl, parseAffCode } = require('../utils/affHelper');
router.get('/', (req, res) => {
const links = db.prepare(`
SELECT a.*, p.name AS product_name, m.name AS merchant_name, p.generated_aff_url
FROM aff_links a
LEFT JOIN products p ON a.product_id = p.id
LEFT JOIN merchants m ON p.merchant_id = m.id
ORDER BY a.id DESC
`).all();
const products = db.prepare(`
SELECT p.id, p.name, p.buy_url, p.url, p.aff_code, p.aff_param, p.generated_aff_url, m.name AS merchant_name
FROM products p LEFT JOIN merchants m ON p.merchant_id = m.id
ORDER BY m.name, p.name
`).all();
res.render('admin/affLinks', { links, products });
});
router.post('/', (req, res) => {
const { product_id, platform, url, notes } = req.body;
if (!product_id) return res.redirect('/admin/aff-links');
let finalUrl = url;
// 如果没有手填链接,尝试从产品的 buy_url + aff_code 自动生成
if (!finalUrl || !finalUrl.trim()) {
const product = db.prepare('SELECT buy_url, url, aff_code, aff_param FROM products WHERE id = ?').get(product_id);
if (product && product.aff_code) {
const baseUrl = product.buy_url || product.url;
if (baseUrl) {
finalUrl = buildAffUrl(baseUrl, product.aff_code, product.aff_param || 'aff');
}
}
}
if (!finalUrl) return res.redirect('/admin/aff-links');
db.prepare('INSERT INTO aff_links (product_id, platform, url, notes) VALUES (?, ?, ?, ?)')
.run(product_id, platform || 'default', finalUrl, notes || null);
res.redirect('/admin/aff-links');
});
// 批量为所有有 aff_code 但没有 aff_link 的产品生成
router.post('/generate-all', (req, res) => {
const products = db.prepare(`
SELECT p.id, p.buy_url, p.url, p.aff_code, p.aff_param
FROM products p
WHERE p.aff_code IS NOT NULL AND p.aff_code != ''
AND p.id NOT IN (SELECT DISTINCT product_id FROM aff_links)
`).all();
let count = 0;
const insert = db.prepare('INSERT INTO aff_links (product_id, platform, url, notes) VALUES (?, ?, ?, ?)');
db.transaction(() => {
for (const p of products) {
const baseUrl = p.buy_url || p.url;
if (!baseUrl) continue;
const affUrl = buildAffUrl(baseUrl, p.aff_code, p.aff_param || 'aff');
insert.run(p.id, 'default', affUrl, '批量自动生成');
count++;
}
})();
res.redirect('/admin/aff-links');
});
router.post('/:id/delete', (req, res) => {
db.prepare('DELETE FROM aff_links WHERE id = ?').run(req.params.id);
res.redirect('/admin/aff-links');
});
module.exports = router;

View File

@@ -0,0 +1,33 @@
/**
* 登录 / 登出路由
*/
const router = require('express').Router();
// 登录页
router.get('/login', (req, res) => {
if (req.session.isAdmin) return res.redirect('/admin');
res.render('login', { error: null });
});
// 登录提交
router.post('/login', (req, res) => {
const { username, password } = req.body;
const adminUser = process.env.ADMIN_USERNAME || 'admin';
const adminPass = process.env.ADMIN_PASSWORD || 'admin';
if (username === adminUser && password === adminPass) {
req.session.isAdmin = true;
return res.redirect('/admin');
}
res.render('login', { error: '用户名或密码错误' });
});
// 登出
router.get('/logout', (req, res) => {
req.session.destroy(() => {
res.redirect('/');
});
});
module.exports = router;

View File

@@ -0,0 +1,26 @@
const router = require('express').Router();
const db = require('../db');
router.get('/', (req, res) => {
const channels = db.prepare('SELECT * FROM tg_channels ORDER BY id DESC').all();
res.render('admin/channels', { channels });
});
router.post('/', (req, res) => {
const { name, chat_id, notes } = req.body;
if (!name || !chat_id) return res.redirect('/admin/channels');
db.prepare('INSERT INTO tg_channels (name, chat_id, notes) VALUES (?, ?, ?)').run(name, chat_id, notes || null);
res.redirect('/admin/channels');
});
router.post('/:id/toggle', (req, res) => {
db.prepare('UPDATE tg_channels SET enabled = NOT enabled WHERE id = ?').run(req.params.id);
res.redirect('/admin/channels');
});
router.post('/:id/delete', (req, res) => {
db.prepare('DELETE FROM tg_channels WHERE id = ?').run(req.params.id);
res.redirect('/admin/channels');
});
module.exports = router;

View File

@@ -0,0 +1,24 @@
const router = require('express').Router();
const db = require('../db');
// 列表
router.get('/', (req, res) => {
const merchants = db.prepare('SELECT * FROM merchants ORDER BY id DESC').all();
res.render('admin/merchants', { merchants });
});
// 新建
router.post('/', (req, res) => {
const { name, website, notes } = req.body;
if (!name) return res.redirect('/admin/merchants');
db.prepare('INSERT INTO merchants (name, website, notes) VALUES (?, ?, ?)').run(name, website || null, notes || null);
res.redirect('/admin/merchants');
});
// 删除
router.post('/:id/delete', (req, res) => {
db.prepare('DELETE FROM merchants WHERE id = ?').run(req.params.id);
res.redirect('/admin/merchants');
});
module.exports = router;

View File

@@ -0,0 +1,266 @@
const router = require('express').Router();
const db = require('../db');
const { buildPushMessage } = require('../utils/pushTemplate');
const { autoFillPidFields, parseProviderPid } = require('../utils/pidHelper');
const { parseAffCode, buildAffUrl } = require('../utils/affHelper');
function getProducts() {
return db.prepare(`
SELECT p.*, m.name AS merchant_name,
p.generated_aff_url AS product_aff_url,
(
SELECT url FROM aff_links a
WHERE a.product_id = p.id AND a.platform = 'telegram'
ORDER BY a.id DESC LIMIT 1
) AS tg_aff_url,
(
SELECT url FROM aff_links a
WHERE a.product_id = p.id
ORDER BY a.id DESC LIMIT 1
) AS any_aff_url
FROM products p
LEFT JOIN merchants m ON p.merchant_id = m.id
ORDER BY p.sort_order ASC, p.id DESC
`).all().map((p) => {
// 计算最终 aff 链接优先级1. 产品生成的 aff 链接 2. aff_links 表 3. buy_url
let aff_url = p.product_aff_url || p.tg_aff_url || p.any_aff_url || null;
// 生成的完整 aff 链接(始终计算,用于展示)
const generated_aff_url = p.product_aff_url || ((p.aff_code && p.buy_url)
? buildAffUrl(p.buy_url, p.aff_code, p.aff_param || 'aff')
: null);
return {
...p,
aff_url,
generated_aff_url,
push_preview: buildPushMessage({ ...p, aff_url })
};
});
}
router.get('/', (req, res) => {
const products = getProducts();
const merchants = db.prepare('SELECT id, name FROM merchants ORDER BY name').all();
res.render('admin/products', { products, merchants });
});
// 新增产品
router.post('/', (req, res) => {
const {
merchant_id, name, url, buy_url, sku, price, annual_price, location,
spec_summary, traffic, billing_cycle, coupon_code, tags, push_intro, notes,
check_mode, in_stock_keywords, out_of_stock_keywords,
is_public, is_featured, sort_order
} = req.body;
if (!name || !merchant_id) return res.redirect('/admin/products');
// 自动填充 PID 字段
const pidFields = autoFillPidFields(db, {
internal_pid: req.body.internal_pid || null,
slug: req.body.slug || null,
provider_pid: req.body.provider_pid || null,
name,
merchant_id,
buy_url,
url,
});
// 处理 aff_code用户填的优先否则从 buy_url 自动解析
let aff_code = req.body.aff_code || null;
let aff_param = req.body.aff_param || 'aff';
if (!aff_code && buy_url) {
const parsed = parseAffCode(buy_url);
if (parsed) {
aff_code = parsed.value;
aff_param = parsed.param;
}
}
// 计算生成的 aff 链接
const generated_aff_url = (aff_code && buy_url)
? buildAffUrl(buy_url, aff_code, aff_param)
: null;
db.prepare(`
INSERT INTO products (
merchant_id, name, url, buy_url, sku, price, annual_price, location,
spec_summary, traffic, billing_cycle, coupon_code, tags, push_intro, notes,
check_mode, in_stock_keywords, out_of_stock_keywords,
is_public, is_featured, sort_order,
internal_pid, provider_pid, slug,
aff_code, aff_param, generated_aff_url
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
merchant_id,
name,
url || null,
buy_url || null,
sku || null,
price || null,
annual_price || null,
location || null,
spec_summary || null,
traffic || null,
billing_cycle || null,
coupon_code || null,
tags || null,
push_intro || null,
notes || null,
check_mode || 'keyword',
in_stock_keywords || null,
out_of_stock_keywords || null,
is_public ? 1 : 0,
is_featured ? 1 : 0,
parseInt(sort_order, 10) || 100,
pidFields.internal_pid,
pidFields.provider_pid || null,
pidFields.slug,
aff_code || null,
aff_param || 'aff',
generated_aff_url
);
// 自动创建 aff_link 记录(如果有 aff_code 且 buy_url
if (aff_code && buy_url) {
const newProduct = db.prepare('SELECT id FROM products ORDER BY id DESC LIMIT 1').get();
if (newProduct) {
const affUrl = buildAffUrl(buy_url, aff_code, aff_param);
db.prepare('INSERT INTO aff_links (product_id, platform, url, notes) VALUES (?, ?, ?, ?)')
.run(newProduct.id, 'default', affUrl, '自动生成');
}
}
res.redirect('/admin/products');
});
// 编辑页面
router.get('/:id/edit', (req, res) => {
const product = db.prepare('SELECT * FROM products WHERE id = ?').get(req.params.id);
if (!product) return res.redirect('/admin/products');
const merchants = db.prepare('SELECT id, name FROM merchants ORDER BY name').all();
// 计算生成的 aff 链接用于展示
product.generated_aff_url = (product.aff_code && product.buy_url)
? buildAffUrl(product.buy_url, product.aff_code, product.aff_param || 'aff')
: null;
res.render('admin/product-edit', { product, merchants });
});
// 保存编辑
router.post('/:id/edit', (req, res) => {
const {
merchant_id, name, url, buy_url, sku, price, annual_price, location,
spec_summary, traffic, billing_cycle, coupon_code, tags, push_intro, notes,
check_mode, in_stock_keywords, out_of_stock_keywords,
is_public, is_featured, sort_order
} = req.body;
if (!name || !merchant_id) return res.redirect(`/admin/products/${req.params.id}/edit`);
// 获取当前产品的 internal_pid保留已有值
const existing = db.prepare('SELECT internal_pid, aff_code, aff_param FROM products WHERE id = ?').get(req.params.id);
// 自动填充 PID 字段
const pidFields = autoFillPidFields(db, {
internal_pid: (existing && existing.internal_pid) || null,
slug: req.body.slug || null,
provider_pid: req.body.provider_pid || null,
name,
merchant_id,
buy_url,
url,
id: parseInt(req.params.id, 10),
});
// 处理 aff_code
let aff_code = req.body.aff_code || null;
let aff_param = req.body.aff_param || 'aff';
if (!aff_code && buy_url) {
const parsed = parseAffCode(buy_url);
if (parsed) {
aff_code = parsed.value;
aff_param = parsed.param;
}
}
// 计算生成的 aff 链接
const generated_aff_url = (aff_code && buy_url)
? buildAffUrl(buy_url, aff_code, aff_param)
: null;
db.prepare(`
UPDATE products SET
merchant_id = ?, name = ?, url = ?, buy_url = ?, sku = ?, price = ?,
annual_price = ?, location = ?, spec_summary = ?, traffic = ?,
billing_cycle = ?, coupon_code = ?, tags = ?, push_intro = ?, notes = ?,
check_mode = ?, in_stock_keywords = ?, out_of_stock_keywords = ?,
is_public = ?, is_featured = ?, sort_order = ?,
internal_pid = ?, provider_pid = ?, slug = ?,
aff_code = ?, aff_param = ?, generated_aff_url = ?,
updated_at = datetime('now')
WHERE id = ?
`).run(
merchant_id,
name,
url || null,
buy_url || null,
sku || null,
price || null,
annual_price || null,
location || null,
spec_summary || null,
traffic || null,
billing_cycle || null,
coupon_code || null,
tags || null,
push_intro || null,
notes || null,
check_mode || 'keyword',
in_stock_keywords || null,
out_of_stock_keywords || null,
is_public ? 1 : 0,
is_featured ? 1 : 0,
parseInt(sort_order, 10) || 100,
pidFields.internal_pid,
pidFields.provider_pid || null,
pidFields.slug,
aff_code || null,
aff_param || 'aff',
generated_aff_url,
req.params.id
);
res.redirect('/admin/products');
});
// API: 从 URL 解析 provider_pid前端 AJAX 用)
router.get('/api/parse-pid', (req, res) => {
const { url } = req.query;
const pid = parseProviderPid(url);
const aff = parseAffCode(url);
res.json({
provider_pid: pid,
aff_code: aff ? aff.value : null,
aff_param: aff ? aff.param : null,
});
});
// API: 生成 aff 链接预览
router.get('/api/build-aff-url', (req, res) => {
const { base_url, aff_code, aff_param } = req.query;
if (!base_url || !aff_code) {
return res.json({ aff_url: null });
}
const affUrl = buildAffUrl(base_url, aff_code, aff_param || 'aff');
res.json({ aff_url: affUrl });
});
// 删除
router.post('/:id/delete', (req, res) => {
db.prepare('DELETE FROM products WHERE id = ?').run(req.params.id);
res.redirect('/admin/products');
});
module.exports = router;

View File

@@ -0,0 +1,122 @@
/**
* 前台公开路由 — 首页 / 产品列表 / 产品详情
* 支持 /plans/:slug优先和 /plans/:id兼容
*/
const router = require('express').Router();
const db = require('../db');
const { getSiteTgUrl } = require('../utils/settings');
// 首页
router.get('/', (req, res) => {
// 推荐产品
const featured = db.prepare(`
SELECT p.*, m.name AS merchant_name
FROM products p
LEFT JOIN merchants m ON p.merchant_id = m.id
WHERE p.is_public = 1 AND p.is_featured = 1
ORDER BY p.sort_order ASC, p.id DESC
LIMIT 6
`).all();
// 最新产品
const latest = db.prepare(`
SELECT p.*, m.name AS merchant_name
FROM products p
LEFT JOIN merchants m ON p.merchant_id = m.id
WHERE p.is_public = 1
ORDER BY p.id DESC
LIMIT 6
`).all();
const stats = {
products: db.prepare('SELECT COUNT(*) AS n FROM products WHERE is_public = 1').get().n,
merchants: db.prepare('SELECT COUNT(*) AS n FROM merchants').get().n,
};
// 获取 Telegram 频道链接
const tgUrl = getSiteTgUrl();
res.render('public/home', { featured, latest, stats, tgUrl });
});
// 产品列表
router.get('/plans', (req, res) => {
const { merchant, location, q } = req.query;
let where = 'WHERE p.is_public = 1';
const params = [];
if (merchant) {
where += ' AND p.merchant_id = ?';
params.push(merchant);
}
if (location) {
where += ' AND p.location LIKE ?';
params.push(`%${location}%`);
}
if (q) {
where += ' AND (p.name LIKE ? OR p.spec_summary LIKE ? OR p.tags LIKE ?)';
params.push(`%${q}%`, `%${q}%`, `%${q}%`);
}
const products = db.prepare(`
SELECT p.*, m.name AS merchant_name
FROM products p
LEFT JOIN merchants m ON p.merchant_id = m.id
${where}
ORDER BY p.sort_order ASC, p.id DESC
`).all(...params);
const merchants = db.prepare('SELECT DISTINCT m.id, m.name FROM merchants m INNER JOIN products p ON p.merchant_id = m.id WHERE p.is_public = 1 ORDER BY m.name').all();
// 收集所有地区
const allLocations = db.prepare("SELECT DISTINCT location FROM products WHERE is_public = 1 AND location IS NOT NULL AND location != ''").all().map(r => r.location);
res.render('public/plans', { products, merchants, allLocations, query: req.query });
});
// 产品详情 — 支持 slug 和 id 双访问
router.get('/plans/:identifier', (req, res) => {
const identifier = req.params.identifier;
let product;
// 先尝试按 slug 查找
product = db.prepare(`
SELECT p.*, m.name AS merchant_name, m.website AS merchant_website
FROM products p
LEFT JOIN merchants m ON p.merchant_id = m.id
WHERE p.slug = ? AND p.is_public = 1
`).get(identifier);
// 如果没找到且 identifier 是纯数字,按 id 查找(兼容旧链接)
if (!product && /^\d+$/.test(identifier)) {
product = db.prepare(`
SELECT p.*, m.name AS merchant_name, m.website AS merchant_website
FROM products p
LEFT JOIN merchants m ON p.merchant_id = m.id
WHERE p.id = ? AND p.is_public = 1
`).get(identifier);
// 如果找到了且该产品有 slug301 重定向到 slug 地址
if (product && product.slug) {
return res.redirect(301, `/plans/${product.slug}`);
}
}
if (!product) {
return res.status(404).render('public/404');
}
// 同商家其他产品
const related = db.prepare(`
SELECT p.*, m.name AS merchant_name
FROM products p
LEFT JOIN merchants m ON p.merchant_id = m.id
WHERE p.merchant_id = ? AND p.id != ? AND p.is_public = 1
ORDER BY p.sort_order ASC
LIMIT 4
`).all(product.merchant_id, product.id);
res.render('public/detail', { product, related });
});
module.exports = router;

View File

@@ -0,0 +1,92 @@
/**
* 后台设置页 — TG Bot Token / 默认频道等
*/
const router = require('express').Router();
const db = require('../db');
const { getSetting, setSetting, getAllSettings, maskToken } = require('../utils/settings');
const { sendTestMessage } = require('../utils/telegram');
// 设置页
router.get('/', (req, res) => {
const settings = getAllSettings();
// 准备展示数据
const formData = {
TG_BOT_TOKEN: settings.TG_BOT_TOKEN || '',
TG_BOT_TOKEN_MASKED: settings.TG_BOT_TOKEN ? maskToken(settings.TG_BOT_TOKEN) : '',
TG_DEFAULT_CHANNEL_ID: settings.TG_DEFAULT_CHANNEL_ID || '',
SITE_NAME: settings.SITE_NAME || process.env.SITE_NAME || 'VPS补货监控',
SITE_TG_URL: settings.SITE_TG_URL || '',
DEFAULT_AFF_CODE: settings.DEFAULT_AFF_CODE || '',
};
res.render('admin/settings', { formData, flash: req.query.flash || null });
});
// 保存设置
router.post('/', (req, res) => {
const { TG_BOT_TOKEN, TG_DEFAULT_CHANNEL_ID, SITE_NAME, SITE_TG_URL, DEFAULT_AFF_CODE } = req.body;
// 只有填了值才更新(空字符串也更新,表示清空)
if (TG_BOT_TOKEN !== undefined) {
setSetting('TG_BOT_TOKEN', TG_BOT_TOKEN.trim());
}
if (TG_DEFAULT_CHANNEL_ID !== undefined) {
setSetting('TG_DEFAULT_CHANNEL_ID', TG_DEFAULT_CHANNEL_ID.trim());
}
if (SITE_NAME !== undefined) {
setSetting('SITE_NAME', SITE_NAME.trim());
}
if (SITE_TG_URL !== undefined) {
setSetting('SITE_TG_URL', SITE_TG_URL.trim());
}
if (DEFAULT_AFF_CODE !== undefined) {
setSetting('DEFAULT_AFF_CODE', DEFAULT_AFF_CODE.trim());
}
res.redirect('/admin/settings?flash=' + encodeURIComponent('✅ 设置已保存'));
});
// 测试 Telegram 连接
router.post('/test-telegram', async (req, res) => {
const { chat_id } = req.body;
// 动态获取最新的 tokenDB 优先)
const token = getSetting('TG_BOT_TOKEN');
const defaultChannel = getSetting('TG_DEFAULT_CHANNEL_ID');
if (!token) {
return res.redirect('/admin/settings?flash=' + encodeURIComponent('❌ 未配置 TG_BOT_TOKEN'));
}
const targetChatId = chat_id || defaultChannel;
if (!targetChatId) {
return res.redirect('/admin/settings?flash=' + encodeURIComponent('❌ 未指定测试频道且无默认频道'));
}
// 发送测试消息
const result = await sendTestMessage(targetChatId);
const msg = result.ok
? encodeURIComponent(`✅ 测试成功!已发送到 ${targetChatId}`)
: encodeURIComponent(`❌ 测试失败: ${result.description}`);
res.redirect(`/admin/settings?flash=${msg}`);
});
// 获取 Bot 信息API
router.get('/api/bot-info', async (req, res) => {
const token = getSetting('TG_BOT_TOKEN');
if (!token) {
return res.json({ ok: false, error: '未配置 TG_BOT_TOKEN' });
}
try {
const fetchRes = await fetch(`https://api.telegram.org/bot${token}/getMe`);
const data = await fetchRes.json();
res.json(data);
} catch (err) {
res.json({ ok: false, error: err.message });
}
});
module.exports = router;

View File

@@ -0,0 +1,147 @@
const router = require('express').Router();
const db = require('../db');
const { runTask } = require('../utils/taskRunner');
const { sendTestMessage, sendMessage } = require('../utils/telegram');
const { buildPushMessage } = require('../utils/pushTemplate');
const { getTgDefaultChannelId } = require('../utils/settings');
const scheduler = require('../utils/scheduler');
router.get('/', (req, res) => {
const tasks = db.prepare(`
SELECT t.*, p.name AS product_name, m.name AS merchant_name, c.name AS channel_name
FROM monitor_tasks t
LEFT JOIN products p ON t.product_id = p.id
LEFT JOIN merchants m ON p.merchant_id = m.id
LEFT JOIN tg_channels c ON t.tg_channel_id = c.id
ORDER BY t.id DESC
`).all();
const products = db.prepare(`
SELECT p.id, p.name, m.name AS merchant_name
FROM products p LEFT JOIN merchants m ON p.merchant_id = m.id
ORDER BY m.name, p.name
`).all();
const channels = db.prepare('SELECT id, name FROM tg_channels WHERE enabled = 1 ORDER BY name').all();
const logs = db.prepare(`
SELECT l.*, p.name AS product_name
FROM check_logs l
LEFT JOIN products p ON l.product_id = p.id
ORDER BY l.id DESC LIMIT 50
`).all();
const schedulerRunning = scheduler.isRunning();
res.render('admin/tasks', { tasks, products, channels, logs, schedulerRunning, flash: req.query.flash || null });
});
router.post('/', (req, res) => {
const { product_id, tg_channel_id, cron_expr } = req.body;
if (!product_id) return res.redirect('/admin/tasks');
db.prepare('INSERT INTO monitor_tasks (product_id, tg_channel_id, cron_expr) VALUES (?, ?, ?)')
.run(product_id, tg_channel_id || null, cron_expr || '*/5 * * * *');
res.redirect('/admin/tasks');
});
router.post('/:id/toggle', (req, res) => {
db.prepare('UPDATE monitor_tasks SET enabled = NOT enabled WHERE id = ?').run(req.params.id);
res.redirect('/admin/tasks');
});
router.post('/:id/delete', (req, res) => {
db.prepare('DELETE FROM monitor_tasks WHERE id = ?').run(req.params.id);
res.redirect('/admin/tasks');
});
// ── 手动执行任务 ──
router.post('/:id/run', async (req, res) => {
try {
const result = await runTask(parseInt(req.params.id, 10));
const msg = encodeURIComponent(result.message);
res.redirect(`/admin/tasks?flash=${msg}`);
} catch (err) {
const msg = encodeURIComponent(`执行失败: ${err.message}`);
res.redirect(`/admin/tasks?flash=${msg}`);
}
});
// ── 测试 Telegram 推送 ──
router.post('/test-push', async (req, res) => {
const { chat_id } = req.body;
const result = await sendTestMessage(chat_id || undefined);
const msg = result.ok
? encodeURIComponent('✅ 测试推送成功!')
: encodeURIComponent(`❌ 推送失败: ${result.description}`);
res.redirect(`/admin/tasks?flash=${msg}`);
});
// ── 手动推送产品消息 ──
router.post('/:id/push', async (req, res) => {
const task = db.prepare(`
SELECT t.*, c.chat_id AS channel_chat_id
FROM monitor_tasks t
LEFT JOIN tg_channels c ON t.tg_channel_id = c.id
WHERE t.id = ?
`).get(req.params.id);
if (!task) {
return res.redirect('/admin/tasks?flash=' + encodeURIComponent('任务不存在'));
}
// 数据库优先获取默认频道
const chatId = task.channel_chat_id || getTgDefaultChannelId();
if (!chatId) {
return res.redirect('/admin/tasks?flash=' + encodeURIComponent('未配置推送频道'));
}
const product = db.prepare(`
SELECT p.*, m.name AS merchant_name,
p.generated_aff_url AS product_aff_url,
(SELECT url FROM aff_links a WHERE a.product_id = p.id AND a.platform = 'telegram' ORDER BY a.id DESC LIMIT 1) AS tg_aff_url,
(SELECT url FROM aff_links a WHERE a.product_id = p.id ORDER BY a.id DESC LIMIT 1) AS any_aff_url
FROM products p
LEFT JOIN merchants m ON p.merchant_id = m.id
WHERE p.id = ?
`).get(task.product_id);
if (!product) {
return res.redirect('/admin/tasks?flash=' + encodeURIComponent('产品不存在'));
}
// 链接优先级1. 产品生成的 aff 链接 2. aff_links 表 3. buy_url
const pushText = buildPushMessage({
...product,
aff_url: product.product_aff_url || product.tg_aff_url || product.any_aff_url || null
});
const result = await sendMessage(chatId, pushText);
const msg = result.ok
? encodeURIComponent('✅ 推送成功!')
: encodeURIComponent(`❌ 推送失败: ${result.description}`);
res.redirect(`/admin/tasks?flash=${msg}`);
});
// ── 调度器控制 ──
router.post('/scheduler/start', (req, res) => {
scheduler.start();
res.redirect('/admin/tasks?flash=' + encodeURIComponent('⏰ 调度器已启动'));
});
router.post('/scheduler/stop', (req, res) => {
scheduler.stop();
res.redirect('/admin/tasks?flash=' + encodeURIComponent('⏰ 调度器已停止'));
});
// ── API 接口 (JSON) ──
router.post('/api/:id/run', async (req, res) => {
try {
const result = await runTask(parseInt(req.params.id, 10));
res.json(result);
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
});
router.post('/api/test-push', async (req, res) => {
const { chat_id } = req.body;
const result = await sendTestMessage(chat_id || undefined);
res.json(result);
});
module.exports = router;

View File

@@ -0,0 +1,83 @@
/**
* Aff 链接工具函数
*
* - parseAffCode(url): 从 URL 中提取 aff 参数
* - buildAffUrl(baseUrl, affCode, affParam): 向基础链接注入 aff 参数,生成最终 aff 链接
* - stripAffParam(url): 去掉 URL 中的 aff 参数,得到干净的基础链接
*/
const { URL } = require('url');
/** 常见 aff 参数名(按优先级) */
const AFF_PARAM_NAMES = ['aff', 'affid', 'aff_id', 'ref', 'refid', 'ref_id', 'referral', 'partner', 'affiliate'];
/**
* 从 URL 中提取 aff 参数
* @param {string} urlStr
* @returns {{ param: string, value: string } | null}
*/
function parseAffCode(urlStr) {
if (!urlStr) return null;
try {
const u = new URL(urlStr);
for (const key of AFF_PARAM_NAMES) {
const val = u.searchParams.get(key);
if (val) {
return { param: key, value: val };
}
}
return null;
} catch {
return null;
}
}
/**
* 向基础 URL 注入 aff 参数,生成最终 aff 链接
* 如果 URL 已经包含该参数,替换为新值
*
* @param {string} baseUrl 基础链接(可以是含或不含 aff 的)
* @param {string} affCode aff 值
* @param {string} [affParam='aff'] aff 参数名
* @returns {string} 最终 aff 链接
*/
function buildAffUrl(baseUrl, affCode, affParam) {
if (!baseUrl) return '';
if (!affCode) return baseUrl;
affParam = affParam || 'aff';
try {
const u = new URL(baseUrl);
u.searchParams.set(affParam, affCode);
return u.toString();
} catch {
// URL 格式异常,尝试简单拼接
const sep = baseUrl.includes('?') ? '&' : '?';
return `${baseUrl}${sep}${affParam}=${encodeURIComponent(affCode)}`;
}
}
/**
* 去掉 URL 中的 aff 参数,返回干净的基础链接
* @param {string} urlStr
* @returns {string}
*/
function stripAffParam(urlStr) {
if (!urlStr) return '';
try {
const u = new URL(urlStr);
for (const key of AFF_PARAM_NAMES) {
u.searchParams.delete(key);
}
return u.toString();
} catch {
return urlStr;
}
}
module.exports = {
AFF_PARAM_NAMES,
parseAffCode,
buildAffUrl,
stripAffParam,
};

View File

@@ -0,0 +1,103 @@
/**
* 产品库存检测器 — 最小实现
*
* 支持模式:
* keyword — HTTP 抓取页面,关键词匹配判断库存状态
*/
/**
* 根据关键词检测页面内容
* @param {string} html — 页面 HTML
* @param {string} inStockKw — 有货关键词 (逗号分隔)
* @param {string} outOfStockKw — 缺货关键词 (逗号分隔)
* @returns {{ status: string, matchedKeyword: string|null }}
*/
function matchKeywords(html, inStockKw, outOfStockKw) {
const htmlLower = html.toLowerCase();
// 先检查缺货关键词(优先级更高:商品页通常同时含 "Add to Cart" 和 "Out of Stock"
if (outOfStockKw) {
const keywords = outOfStockKw.split(',').map(k => k.trim()).filter(Boolean);
for (const kw of keywords) {
if (htmlLower.includes(kw.toLowerCase())) {
return { status: 'out_of_stock', matchedKeyword: kw };
}
}
}
// 再检查有货关键词
if (inStockKw) {
const keywords = inStockKw.split(',').map(k => k.trim()).filter(Boolean);
for (const kw of keywords) {
if (htmlLower.includes(kw.toLowerCase())) {
return { status: 'in_stock', matchedKeyword: kw };
}
}
}
// 都没匹配到
return { status: 'out_of_stock', matchedKeyword: null };
}
/**
* 对一个产品执行检测
* @param {object} product — products 表的一行 (必须有 url, check_mode 等字段)
* @returns {Promise<{ status: string, message: string }>}
*/
async function checkProduct(product) {
if (!product.url) {
return { status: 'error', message: '产品未配置 URL' };
}
const mode = product.check_mode || 'keyword';
if (mode === 'keyword') {
return checkByKeyword(product);
}
return { status: 'error', message: `不支持的检测模式: ${mode}` };
}
/**
* keyword 模式检测
*/
async function checkByKeyword(product) {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);
const res = await fetch(product.url, {
signal: controller.signal,
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.5,zh-CN;q=0.3',
},
});
clearTimeout(timeout);
if (!res.ok) {
return { status: 'error', message: `HTTP ${res.status} ${res.statusText}` };
}
const html = await res.text();
const { status, matchedKeyword } = matchKeywords(
html,
product.in_stock_keywords,
product.out_of_stock_keywords
);
const msg = matchedKeyword
? `关键词匹配: "${matchedKeyword}"`
: '未匹配到任何关键词,默认缺货';
return { status, message: msg };
} catch (err) {
if (err.name === 'AbortError') {
return { status: 'error', message: '请求超时 (15s)' };
}
return { status: 'error', message: err.message };
}
}
module.exports = { checkProduct, matchKeywords };

View File

@@ -0,0 +1,170 @@
/**
* PID & Slug 工具函数
*
* - generateInternalPid(db): 生成系统内部编号 VPS-000001
* - generateSlug(name, merchantName, existingSlugs): 生成前台友好 URL slug
* - parseProviderPid(url): 从购买链接解析商家产品 ID
* - autoFillPidFields(db, data): 创建/编辑产品时自动填充字段
*/
const { URL } = require('url');
/**
* 生成下一个系统内部 PID: VPS-000001, VPS-000002, ...
*/
function generateInternalPid(db) {
const row = db.prepare(
"SELECT internal_pid FROM products WHERE internal_pid IS NOT NULL AND internal_pid LIKE 'VPS-%' ORDER BY internal_pid DESC LIMIT 1"
).get();
let nextNum = 1;
if (row && row.internal_pid) {
const match = row.internal_pid.match(/^VPS-(\d+)$/);
if (match) {
nextNum = parseInt(match[1], 10) + 1;
}
}
return `VPS-${String(nextNum).padStart(6, '0')}`;
}
/**
* 将字符串转为 URL 安全的 slug
* - 英文:小写 + 连字符
* - 中文/其他pinyin 不引入,直接用 encodeURIComponent 兜底
* - 去掉特殊字符
*/
function slugify(str) {
return str
.toLowerCase()
.replace(/[^\w\u4e00-\u9fa5\s-]/g, '') // 保留字母、数字、中文、空格、连字符
.replace(/[\s_]+/g, '-') // 空格/下划线 → 连字符
.replace(/-+/g, '-') // 多个连字符合并
.replace(/^-|-$/g, ''); // 去掉首尾连字符
}
/**
* 生成 slug格式: merchantName-productName自动处理冲突
* @param {string} productName 产品名
* @param {string} merchantName 商家名
* @param {Set<string>} existingSlugs 已存在的 slug 集合
* @returns {string}
*/
function generateSlug(productName, merchantName, existingSlugs) {
const parts = [];
if (merchantName) parts.push(slugify(merchantName));
parts.push(slugify(productName));
let base = parts.filter(Boolean).join('-') || 'product';
let candidate = base;
let i = 2;
while (existingSlugs && existingSlugs.has(candidate)) {
candidate = `${base}-${i}`;
i++;
}
return candidate;
}
/**
* 从 URL 解析商家产品 IDprovider_pid
*
* 解析规则(按优先级):
* 1. pid=XX — 最明确
* 2. product=XX / product_id=XX
* 3. plan=XX / plan_id=XX
* 4. package=XX / package_id=XX
* 5. id=XX — 仅当 URL path 包含特定关键词时才识别(避免误判)
*
* @param {string} urlStr URL 字符串
* @returns {string|null} 商家产品 ID或 null
*/
function parseProviderPid(urlStr) {
if (!urlStr) return null;
try {
const u = new URL(urlStr);
const params = u.searchParams;
// 高优先级参数(直接识别)
const highPriorityKeys = ['pid', 'product', 'product_id', 'productid', 'plan', 'plan_id', 'planid', 'package', 'package_id', 'packageid'];
for (const key of highPriorityKeys) {
const val = params.get(key);
if (val && /^\d+$/.test(val)) {
return val;
}
}
// id 参数 — 仅在 URL 路径包含商品/产品相关关键词时识别
const idVal = params.get('id');
if (idVal && /^\d+$/.test(idVal)) {
const pathAndSearch = (u.pathname + u.search).toLowerCase();
const productContextKeywords = [
'product', 'plan', 'package', 'cart', 'order', 'store', 'shop',
'buy', 'purchase', 'checkout', 'aff', 'billing', 'hosting',
'vps', 'server', 'dedi', 'service'
];
if (productContextKeywords.some(kw => pathAndSearch.includes(kw))) {
return idVal;
}
}
// 尝试从 URL 路径解析,如 /product/28 或 /plan/28
const pathMatch = u.pathname.match(/\/(product|plan|package|pid)[s]?\/(\d+)/i);
if (pathMatch) {
return pathMatch[2];
}
return null;
} catch {
return null;
}
}
/**
* 创建/编辑产品时自动填充 internal_pid / slug / provider_pid
*
* @param {object} db better-sqlite3 实例
* @param {object} data 产品数据 { internal_pid, slug, provider_pid, name, merchant_id, buy_url, url, id? }
* - id: 编辑时传入,新建时不传
* @returns {object} 填充后的 { internal_pid, slug, provider_pid }
*/
function autoFillPidFields(db, data) {
let { internal_pid, slug, provider_pid, name, merchant_id, buy_url, url, id } = data;
// 1. internal_pid
if (!internal_pid) {
internal_pid = generateInternalPid(db);
}
// 2. slug
if (!slug) {
const merchant = db.prepare('SELECT name FROM merchants WHERE id = ?').get(merchant_id);
const merchantName = merchant ? merchant.name : '';
// 收集已有 slug排除自己
let query = "SELECT slug FROM products WHERE slug IS NOT NULL AND slug != ''";
const params = [];
if (id) {
query += ' AND id != ?';
params.push(id);
}
const existingSlugs = new Set(db.prepare(query).all(...params).map(r => r.slug));
slug = generateSlug(name, merchantName, existingSlugs);
}
// 3. provider_pid
if (!provider_pid) {
provider_pid = parseProviderPid(buy_url) || parseProviderPid(url) || null;
}
return { internal_pid, slug, provider_pid };
}
module.exports = {
generateInternalPid,
generateSlug,
slugify,
parseProviderPid,
autoFillPidFields,
};

View File

@@ -0,0 +1,54 @@
function splitTags(tags) {
if (!tags) return [];
return String(tags)
.split(/[,#\s]+/)
.map(s => s.trim())
.filter(Boolean);
}
function buildPushMessage(product) {
const intro = product.push_intro || '🔥 商家产品精选推荐';
const merchant = product.merchant_name || '未知商家';
const lines = [intro];
if (product.spec_summary || product.traffic || product.coupon_code) {
const extras = [];
if (product.spec_summary) extras.push(`💻 ${product.spec_summary}`);
if (product.coupon_code) extras.push(`🎟 优惠码:${product.coupon_code}`);
lines.push(...extras);
}
lines.push('');
const titleBits = [];
if (product.location) titleBits.push(product.location);
titleBits.push(product.name);
lines.push(`🇭🇰 ${titleBits.join('')}`.replace('🇭🇰 ', product.location ? `${product.location}` : ''));
const detail = [];
if (product.price) detail.push(product.price);
if (product.spec_summary) detail.push(product.spec_summary);
if (product.traffic) detail.push(product.traffic);
if (detail.length) lines.push(detail.join(''));
if (product.annual_price) {
const annualPrefix = product.billing_cycle ? `${product.billing_cycle}` : '年付:';
lines.push(`${annualPrefix}${product.annual_price}`);
}
const link = product.aff_url || product.buy_url || product.url;
if (link) lines.push(`✅ 购买: ${link}`);
const tags = splitTags(product.tags);
if (tags.length) {
lines.push('');
lines.push(tags.map(t => (t.startsWith('#') ? t : `#${t}`)).join(' '));
}
lines.push('');
lines.push(`#${merchant.replace(/\s+/g, '')}`);
return lines.filter((line, idx, arr) => !(line === '' && arr[idx - 1] === '')).join('\n').trim();
}
module.exports = { buildPushMessage };

View File

@@ -0,0 +1,74 @@
/**
* 轻量调度器 — 简单 setInterval 实现
*
* 使用环境变量:
* MONITOR_INTERVAL — 检测间隔(秒),默认 300
* SCHEDULER_ENABLED — 是否启动调度器,默认 false
*
* 逻辑:
* 每隔 MONITOR_INTERVAL 秒,查找所有 enabled=1 的任务并依次执行
* 简单串行执行,不并发,不崩溃整个进程
*/
const db = require('../db');
const { runTask } = require('./taskRunner');
let intervalHandle = null;
let running = false;
function getIntervalMs() {
return (parseInt(process.env.MONITOR_INTERVAL, 10) || 300) * 1000;
}
async function tick() {
if (running) {
console.log('⏭ 调度器: 上一轮还在跑,跳过');
return;
}
running = true;
try {
const tasks = db.prepare('SELECT id FROM monitor_tasks WHERE enabled = 1').all();
if (tasks.length === 0) {
return;
}
console.log(`🔄 调度器: 开始执行 ${tasks.length} 个任务`);
for (const task of tasks) {
try {
const result = await runTask(task.id);
console.log(` 任务#${task.id}: ${result.message}`);
} catch (err) {
console.error(` 任务#${task.id} 异常:`, err.message);
}
}
console.log('✅ 调度器: 本轮完成');
} finally {
running = false;
}
}
function start() {
if (intervalHandle) {
console.log('调度器已在运行');
return;
}
const ms = getIntervalMs();
console.log(`⏰ 调度器启动: 每 ${ms / 1000}s 执行一次`);
intervalHandle = setInterval(tick, ms);
// 启动后立即执行一轮
tick();
}
function stop() {
if (intervalHandle) {
clearInterval(intervalHandle);
intervalHandle = null;
console.log('⏰ 调度器已停止');
}
}
function isRunning() {
return intervalHandle !== null;
}
module.exports = { start, stop, isRunning, tick };

View File

@@ -0,0 +1,109 @@
/**
* 系统设置工具
*
* 数据库优先,.env 兜底
* - getSetting(key): 从 DB 读取,没有则回退到 process.env[key]
* - setSetting(key, value): 写入 DB
* - getAllSettings(): 获取所有设置(脱敏)
*/
const db = require('../db');
/**
* 获取设置值DB 优先,.env 兜底)
* @param {string} key 设置键名
* @param {string} fallback 手动指定的兜底值
* @returns {string|null}
*/
function getSetting(key, fallback) {
// 1. 先查数据库
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
if (row && row.value !== undefined && row.value !== null && row.value !== '') {
return row.value;
}
// 2. 回退到 .env
if (process.env[key] !== undefined && process.env[key] !== '') {
return process.env[key];
}
// 3. 手动兜底
return fallback ?? null;
}
/**
* 写入设置到数据库
* @param {string} key
* @param {string} value
*/
function setSetting(key, value) {
db.prepare(`
INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now'))
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')
`).run(key, value ?? '');
}
/**
* 获取所有设置(带脱敏)
* @returns {object}
*/
function getAllSettings() {
const rows = db.prepare('SELECT key, value FROM settings').all();
const result = {};
// 从 DB 读取的
for (const row of rows) {
result[row.key] = row.value;
}
// 敏感字段列表(需要脱敏)
const sensitiveKeys = ['TG_BOT_TOKEN', 'SESSION_SECRET', 'ADMIN_PASSWORD'];
// 返回完整设置对象(前台展示时脱敏由调用方处理)
return result;
}
/**
* 获取 Telegram Bot TokenDB 优先)
*/
function getTgBotToken() {
return getSetting('TG_BOT_TOKEN');
}
/**
* 获取默认频道 IDDB 优先)
*/
function getTgDefaultChannelId() {
return getSetting('TG_DEFAULT_CHANNEL_ID');
}
/**
* 获取站点名称
*/
function getSiteName() {
return getSetting('SITE_NAME', 'VPS补货监控');
}
/**
* 获取 Telegram 频道/群链接
*/
function getSiteTgUrl() {
return getSetting('SITE_TG_URL', '');
}
/**
* 脱敏显示 token只显示前6位和后4位
*/
function maskToken(token) {
if (!token || token.length < 12) return '******';
return token.slice(0, 6) + '****' + token.slice(-4);
}
module.exports = {
getSetting,
setSetting,
getAllSettings,
getTgBotToken,
getTgDefaultChannelId,
getSiteName,
getSiteTgUrl,
maskToken,
};

View File

@@ -0,0 +1,105 @@
/**
* 任务执行器 — 执行单个监控任务的核心逻辑
*
* 流程:
* 1. 查询任务 + 关联产品信息
* 2. 调用 checker 检测库存
* 3. 写入 check_logs
* 4. 更新 products.in_stock / last_checked
* 5. 更新 monitor_tasks.last_run
* 6. 如果状态变化且配置了频道 → 推送 Telegram
*/
const db = require('../db');
const { checkProduct } = require('./checker');
const { sendMessage } = require('./telegram');
const { buildPushMessage } = require('./pushTemplate');
/**
* 执行一个监控任务
* @param {number} taskId — monitor_tasks.id
* @returns {Promise<{ success: boolean, status: string, message: string, notified: boolean }>}
*/
async function runTask(taskId) {
// 1. 查询任务 + 产品
const task = db.prepare(`
SELECT t.*, c.chat_id AS channel_chat_id, c.name AS channel_name
FROM monitor_tasks t
LEFT JOIN tg_channels c ON t.tg_channel_id = c.id
WHERE t.id = ?
`).get(taskId);
if (!task) {
return { success: false, status: 'error', message: `任务 #${taskId} 不存在`, notified: false };
}
const product = db.prepare(`
SELECT p.*, m.name AS merchant_name,
p.generated_aff_url AS product_aff_url,
(SELECT url FROM aff_links a WHERE a.product_id = p.id AND a.platform = 'telegram' ORDER BY a.id DESC LIMIT 1) AS tg_aff_url,
(SELECT url FROM aff_links a WHERE a.product_id = p.id ORDER BY a.id DESC LIMIT 1) AS any_aff_url
FROM products p
LEFT JOIN merchants m ON p.merchant_id = m.id
WHERE p.id = ?
`).get(task.product_id);
if (!product) {
return { success: false, status: 'error', message: `产品 #${task.product_id} 不存在`, notified: false };
}
// 记录之前的状态
const prevStock = product.in_stock;
// 2. 执行检测
const { status, message } = await checkProduct(product);
const now = new Date().toISOString();
// 3. 状态码映射: in_stock=1, out_of_stock=2, error=0(保持不变)
const stockCode = status === 'in_stock' ? 1 : status === 'out_of_stock' ? 2 : product.in_stock;
// 4. 写入 check_logs
db.prepare(`
INSERT INTO check_logs (task_id, product_id, status, message) VALUES (?, ?, ?, ?)
`).run(taskId, product.id, status, message);
// 5. 更新 products
db.prepare(`
UPDATE products SET in_stock = ?, last_checked = ?, updated_at = ? WHERE id = ?
`).run(stockCode, now, now, product.id);
// 6. 更新 monitor_tasks.last_run
db.prepare(`
UPDATE monitor_tasks SET last_run = ? WHERE id = ?
`).run(now, taskId);
// 7. 判断是否需要推送
let notified = false;
const stateChanged = prevStock !== stockCode;
const becameInStock = stockCode === 1 && prevStock !== 1;
if (becameInStock && task.channel_chat_id) {
// 补货了!推送通知
// 链接优先级1. 产品生成的 aff 链接 2. aff_links 表 3. buy_url
const affUrl = product.product_aff_url || product.tg_aff_url || product.any_aff_url || null;
const pushText = buildPushMessage({ ...product, aff_url: affUrl, merchant_name: product.merchant_name });
const tgResult = await sendMessage(task.channel_chat_id, pushText);
notified = tgResult.ok === true;
// 更新 check_logs.notified
if (notified) {
db.prepare(`
UPDATE check_logs SET notified = 1 WHERE task_id = ? AND product_id = ? ORDER BY id DESC LIMIT 1
`).run(taskId, product.id);
}
}
const statusLabel = { in_stock: '有货 ✅', out_of_stock: '缺货 ❌', error: '错误 ⚠️' };
const resultMsg = [
`${statusLabel[status] || status}: ${message}`,
stateChanged ? `(状态变化: ${prevStock}${stockCode}` : '(状态无变化)',
notified ? '📨 已推送' : '',
].filter(Boolean).join(' ');
return { success: true, status, message: resultMsg, notified };
}
module.exports = { runTask };

View File

@@ -0,0 +1,65 @@
/**
* Telegram 推送模块 — 最小实现
*
* 配置来源(数据库优先,.env 兜底):
* TG_BOT_TOKEN — Bot Token
* TG_DEFAULT_CHANNEL_ID — 默认推送频道 (可被 tg_channels 表覆盖)
*/
const TG_API = 'https://api.telegram.org';
const { getTgBotToken, getTgDefaultChannelId } = require('./settings');
/**
* 发送文本消息到 Telegram
* @param {string} chatId — 频道/群组 chat_id
* @param {string} text — 消息文本
* @param {object} opts — 可选: { parseMode, disablePreview }
* @returns {Promise<object>} Telegram API 返回
*/
async function sendMessage(chatId, text, opts = {}) {
// 数据库优先,.env 兜底
const token = getTgBotToken();
if (!token) {
console.warn('⚠️ TG_BOT_TOKEN 未配置,跳过推送');
return { ok: false, description: 'TG_BOT_TOKEN not set' };
}
const url = `${TG_API}/bot${token}/sendMessage`;
const body = {
chat_id: chatId,
text,
parse_mode: opts.parseMode || undefined,
disable_web_page_preview: opts.disablePreview ?? true,
};
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
if (!data.ok) {
console.error('❌ Telegram API error:', data.description);
}
return data;
} catch (err) {
console.error('❌ Telegram send failed:', err.message);
return { ok: false, description: err.message };
}
}
/**
* 发送测试消息
* @param {string} chatId — 可选,默认用 TG_DEFAULT_CHANNEL_ID
*/
async function sendTestMessage(chatId) {
// 数据库优先,.env 兜底
const target = chatId || getTgDefaultChannelId();
if (!target) {
return { ok: false, description: 'No chat_id provided and TG_DEFAULT_CHANNEL_ID not set' };
}
return sendMessage(target, `🧪 AFF Monitor 推送测试\n\n时间: ${new Date().toISOString()}\n状态: ✅ 连接正常`);
}
module.exports = { sendMessage, sendTestMessage };

View File

@@ -0,0 +1,111 @@
<%- include('../partials/admin-header') %>
<h1>Aff 链接管理</h1>
<div class="form-card">
<h2>添加 Aff 链接</h2>
<form method="POST" action="/admin/aff-links">
<div class="form-row">
<label>产品
<select name="product_id" required id="aff_product_select" onchange="onProductChange(this)">
<option value="">选择产品</option>
<% products.forEach(p => { %>
<option value="<%= p.id %>"
data-buy-url="<%= p.buy_url || '' %>"
data-url="<%= p.url || '' %>"
data-aff-code="<%= p.aff_code || '' %>"
data-aff-param="<%= p.aff_param || 'aff' %>"
data-generated-url="<%= p.generated_aff_url || '' %>"
><%= p.merchant_name %> — <%= p.name %><%= p.aff_code ? ' [aff=' + p.aff_code + ']' : '' %></option>
<% }) %>
</select>
</label>
<label>平台 <input name="platform" placeholder="default" value="default"></label>
<label style="flex:2">链接
<input name="url" id="aff_url_input" placeholder="留空则从产品的 buy_url + aff_code 自动生成">
</label>
<label>备注 <input name="notes"></label>
<button type="submit" class="btn btn-primary" style="align-self:end">添加</button>
</div>
<div id="aff_auto_hint" style="display:none;padding:4px 12px;margin:-4px 0 8px;background:#e8f5e9;border-radius:4px;font-size:13px;word-break:break-all">
<strong>系统生成:</strong><span id="aff_auto_hint_url"></span>
</div>
</form>
<div style="margin-top:12px;border-top:1px solid #eee;padding-top:12px">
<form method="POST" action="/admin/aff-links/generate-all" style="display:inline" onsubmit="return confirm('为所有有 aff_code 但没有 aff 链接的产品批量生成?')">
<button type="submit" class="btn btn-sm" style="background:#28a745;color:#fff">🔄 批量生成缺失的 Aff 链接</button>
</form>
<span class="muted" style="margin-left:8px;font-size:13px">为所有设置了 aff_code 但还没有 aff_links 记录的产品自动生成</span>
</div>
</div>
<table>
<thead><tr><th>ID</th><th>商家</th><th>产品</th><th>平台</th><th>链接</th><th>来源</th><th>操作</th></tr></thead>
<tbody>
<% links.forEach(l => { %>
<tr>
<td><%= l.id %></td>
<td><%= l.merchant_name || '-' %></td>
<td><%= l.product_name || '-' %></td>
<td><%= l.platform %></td>
<td style="max-width:350px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"><a href="<%= l.url %>" target="_blank" title="<%= l.url %>"><%= l.url %></a></td>
<td>
<% if (l.generated_aff_url && l.url === l.generated_aff_url) { %>
<span class="badge" style="background:#d4edda;color:#155724">系统生成</span>
<% } else if (l.notes && l.notes.includes('自动')) { %>
<span class="badge" style="background:#d4edda;color:#155724">自动生成</span>
<% } else { %>
<span class="badge" style="background:#e2e3e5;color:#383d41">手工</span>
<% } %>
</td>
<td>
<form class="inline" method="POST" action="/admin/aff-links/<%= l.id %>/delete" onsubmit="return confirm('确定删除?')">
<button class="btn btn-danger btn-sm">删除</button>
</form>
</td>
</tr>
<% }) %>
</tbody>
</table>
<div class="form-card">
<h2>链接优先级说明</h2>
<p class="muted" style="font-size:13px">推送时链接使用优先级:</p>
<ol style="font-size:13px;color:#666;margin:8px 0 0 16px">
<li><strong>产品生成的 Aff 链接</strong>(产品页 buy_url + aff_code 自动生成)</li>
<li><strong>Aff 链接表</strong>(本页手工或自动添加的链接)</li>
<li><strong>产品的 buy_url</strong>(原始购买链接)</li>
</ol>
</div>
<script>
function onProductChange(select) {
var opt = select.options[select.selectedIndex];
var generatedUrl = opt.getAttribute('data-generated-url');
var buyUrl = opt.getAttribute('data-buy-url');
var affCode = opt.getAttribute('data-aff-code');
var affParam = opt.getAttribute('data-aff-param') || 'aff';
var hint = document.getElementById('aff_auto_hint');
var hintUrl = document.getElementById('aff_auto_hint_url');
var urlInput = document.getElementById('aff_url_input');
// 如果已有生成的链接,直接显示
if (generatedUrl && !urlInput.value.trim()) {
hintUrl.innerHTML = '<a href="' + generatedUrl + '" target="_blank">' + generatedUrl + '</a>';
hint.style.display = 'block';
} else if (buyUrl && affCode && !urlInput.value.trim()) {
fetch('/admin/products/api/build-aff-url?base_url=' + encodeURIComponent(buyUrl) + '&aff_code=' + encodeURIComponent(affCode) + '&aff_param=' + encodeURIComponent(affParam))
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.aff_url) {
hintUrl.innerHTML = '<a href="' + data.aff_url + '" target="_blank">' + data.aff_url + '</a>';
hint.style.display = 'block';
}
})
.catch(function() {});
} else {
hint.style.display = 'none';
}
}
</script>
<%- include('../partials/footer') %>

View File

@@ -0,0 +1,40 @@
<%- include('../partials/admin-header') %>
<h1>Telegram 频道配置</h1>
<div class="form-card">
<h2>添加频道</h2>
<form method="POST" action="/admin/channels">
<div class="form-row">
<label>频道名称 <input name="name" required></label>
<label>Chat ID <input name="chat_id" required placeholder="-100..."></label>
<label>备注 <input name="notes"></label>
<button type="submit" class="btn btn-primary">添加</button>
</div>
</form>
</div>
<table>
<thead><tr><th>ID</th><th>名称</th><th>Chat ID</th><th>状态</th><th>备注</th><th>操作</th></tr></thead>
<tbody>
<% channels.forEach(c => { %>
<tr>
<td><%= c.id %></td>
<td><%= c.name %></td>
<td><code><%= c.chat_id %></code></td>
<td>
<span class="badge <%= c.enabled ? 'badge-on' : 'badge-off' %>"><%= c.enabled ? '启用' : '禁用' %></span>
</td>
<td><%= c.notes || '-' %></td>
<td>
<form class="inline" method="POST" action="/admin/channels/<%= c.id %>/toggle">
<button class="btn btn-sm btn-primary"><%= c.enabled ? '禁用' : '启用' %></button>
</form>
<form class="inline" method="POST" action="/admin/channels/<%= c.id %>/delete" onsubmit="return confirm('确定删除?')">
<button class="btn btn-danger btn-sm">删除</button>
</form>
</td>
</tr>
<% }) %>
</tbody>
</table>
<%- include('../partials/footer') %>

View File

@@ -0,0 +1,20 @@
<%- include('../partials/admin-header') %>
<h1>仪表盘</h1>
<div class="stats">
<div class="stat-card"><div class="num"><%= stats.merchants %></div><div class="label">商家</div></div>
<div class="stat-card"><div class="num"><%= stats.products %></div><div class="label">产品</div></div>
<div class="stat-card"><div class="num"><%= stats.channels %></div><div class="label">TG 频道</div></div>
<div class="stat-card"><div class="num"><%= stats.tasks %></div><div class="label">监控任务</div></div>
<div class="stat-card"><div class="num"><%= stats.recentLogs %></div><div class="label">24h 检测</div></div>
</div>
<p style="color:#888; font-size:14px; margin-bottom:20px;">这是 VPS补货监控 的管理后台。从导航栏进入各模块管理数据。</p>
<div class="form-card">
<h2>当前推送文案预览</h2>
<% if (preview) { %>
<pre class="preview-box"><%= preview %></pre>
<% } else { %>
<p class="muted">还没有产品数据,先去「产品」里添加一条。</p>
<% } %>
</div>
<%- include('../partials/footer') %>

View File

@@ -0,0 +1,35 @@
<%- include('../partials/admin-header') %>
<h1>商家管理</h1>
<div class="form-card">
<h2>添加商家</h2>
<form method="POST" action="/admin/merchants">
<div class="form-row">
<label>名称 <input name="name" required></label>
<label>网站 <input name="website" placeholder="https://..."></label>
<label>备注 <input name="notes"></label>
<button type="submit" class="btn btn-primary">添加</button>
</div>
</form>
</div>
<table>
<thead><tr><th>ID</th><th>名称</th><th>网站</th><th>备注</th><th>创建时间</th><th>操作</th></tr></thead>
<tbody>
<% merchants.forEach(m => { %>
<tr>
<td><%= m.id %></td>
<td><%= m.name %></td>
<td><%= m.website || '-' %></td>
<td><%= m.notes || '-' %></td>
<td><%= m.created_at %></td>
<td>
<form class="inline" method="POST" action="/admin/merchants/<%= m.id %>/delete" onsubmit="return confirm('确定删除?')">
<button class="btn btn-danger btn-sm">删除</button>
</form>
</td>
</tr>
<% }) %>
</tbody>
</table>
<%- include('../partials/footer') %>

View File

@@ -0,0 +1,148 @@
<%- include('../partials/admin-header') %>
<h1>编辑产品 #<%= product.id %></h1>
<div class="form-card">
<form method="POST" action="/admin/products/<%= product.id %>/edit">
<div class="form-row">
<label>商家
<select name="merchant_id" required>
<% merchants.forEach(m => { %>
<option value="<%= m.id %>" <%= product.merchant_id === m.id ? 'selected' : '' %>><%= m.name %></option>
<% }) %>
</select>
</label>
<label>产品名 <input name="name" value="<%= product.name %>" required></label>
<label>地区 <input name="location" value="<%= product.location || '' %>"></label>
</div>
<div class="form-row">
<label style="flex:2">产品页 URL <input name="url" value="<%= product.url || '' %>"></label>
</div>
<div class="form-row">
<label style="flex:2">购买链接 <input name="buy_url" id="edit_buy_url" value="<%= product.buy_url || '' %>" onchange="autoParseUrl(this)"></label>
<label>Aff 参数名 <input name="aff_param" id="edit_aff_param" value="<%= product.aff_param || 'aff' %>" style="width:80px"></label>
<label>Aff 值 <input name="aff_code" id="edit_aff_code" value="<%= product.aff_code || '' %>" placeholder="如 5" oninput="previewAffUrl()"></label>
</div>
<% if (product.generated_aff_url) { %>
<div id="edit_aff_preview" style="padding:8px 12px;margin:-4px 0 8px;background:#e8f5e9;border-radius:4px;font-size:13px;word-break:break-all">
<strong>🔗 推送用的 Aff 链接:</strong><span id="edit_aff_preview_url"><a href="<%= product.generated_aff_url %>" target="_blank"><%= product.generated_aff_url %></a></span>
<span style="color:#28a745;font-size:11px;margin-left:8px">✓ 补货推送时使用此链接</span>
</div>
<% } else { %>
<div id="edit_aff_preview" style="display:none;padding:8px 12px;margin:-4px 0 8px;background:#e8f5e9;border-radius:4px;font-size:13px;word-break:break-all">
<strong>🔗 推送用的 Aff 链接:</strong><span id="edit_aff_preview_url"></span>
<span style="color:#28a745;font-size:11px;margin-left:8px">✓ 补货推送时使用此链接</span>
</div>
<% } %>
<!-- PID & Slug 字段 -->
<div class="form-row" style="background:#f8f9fa;padding:8px 12px;border-radius:6px;margin:8px 0">
<label>系统 PID
<input value="<%= product.internal_pid || '(保存后自动生成)' %>" readonly style="background:#e9ecef;color:#6c757d;cursor:not-allowed">
</label>
<label>商家 PID
<input name="provider_pid" id="edit_provider_pid" value="<%= product.provider_pid || '' %>" placeholder="从购买链接自动识别 / 手填">
</label>
<label style="flex:1">Slug
<input name="slug" value="<%= product.slug || '' %>" placeholder="留空自动生成">
</label>
</div>
<div class="form-row">
<label>价格 <input name="price" value="<%= product.price || '' %>"></label>
<label>年付价 <input name="annual_price" value="<%= product.annual_price || '' %>"></label>
<label>配置摘要 <input name="spec_summary" value="<%= product.spec_summary || '' %>"></label>
<label>流量 <input name="traffic" value="<%= product.traffic || '' %>"></label>
<label>计费说明 <input name="billing_cycle" value="<%= product.billing_cycle || '' %>"></label>
</div>
<div class="form-row">
<label>优惠码 <input name="coupon_code" value="<%= product.coupon_code || '' %>"></label>
<label>标签 <input name="tags" value="<%= product.tags || '' %>"></label>
<label>SKU <input name="sku" value="<%= product.sku || '' %>"></label>
<label style="flex:1">推送开头
<input name="push_intro" value="<%= product.push_intro || '' %>">
</label>
</div>
<div class="form-row">
<label>检测模式
<select name="check_mode">
<option value="keyword" <%= product.check_mode === 'keyword' ? 'selected' : '' %>>keyword</option>
<option value="manual" <%= product.check_mode === 'manual' ? 'selected' : '' %>>manual</option>
</select>
</label>
<label style="flex:1">有货关键词
<input name="in_stock_keywords" value="<%= product.in_stock_keywords || '' %>">
</label>
<label style="flex:1">缺货关键词
<input name="out_of_stock_keywords" value="<%= product.out_of_stock_keywords || '' %>">
</label>
</div>
<div class="form-row">
<label>
<input type="checkbox" name="is_public" value="1" <%= product.is_public ? 'checked' : '' %>> 前台展示
</label>
<label>
<input type="checkbox" name="is_featured" value="1" <%= product.is_featured ? 'checked' : '' %>> 推荐
</label>
<label>排序值 <input name="sort_order" type="number" value="<%= product.sort_order || 100 %>" style="width:80px"></label>
<label style="flex:1">备注 <input name="notes" value="<%= product.notes || '' %>"></label>
</div>
<div class="form-row" style="justify-content:space-between;margin-top:12px">
<a href="/admin/products" class="btn" style="background:#e9ecef;color:#333">← 返回列表</a>
<button type="submit" class="btn btn-primary" style="padding:8px 24px">保存修改</button>
</div>
</form>
</div>
<script>
function autoParseUrl(input) {
var url = input.value.trim();
if (!url) return;
fetch('/admin/products/api/parse-pid?url=' + encodeURIComponent(url))
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.provider_pid) {
var el = document.getElementById('edit_provider_pid');
if (el && !el.value) { el.value = data.provider_pid; flashGreen(el); }
}
if (data.aff_code) {
var el2 = document.getElementById('edit_aff_code');
if (el2 && !el2.value) { el2.value = data.aff_code; flashGreen(el2); }
}
if (data.aff_param) {
var el3 = document.getElementById('edit_aff_param');
if (el3) { el3.value = data.aff_param; }
}
previewAffUrl();
})
.catch(function() {});
}
function previewAffUrl() {
var buyUrl = document.getElementById('edit_buy_url').value.trim();
var affCode = document.getElementById('edit_aff_code').value.trim();
var affParam = document.getElementById('edit_aff_param').value.trim() || 'aff';
var preview = document.getElementById('edit_aff_preview');
var previewUrl = document.getElementById('edit_aff_preview_url');
if (buyUrl && affCode) {
fetch('/admin/products/api/build-aff-url?base_url=' + encodeURIComponent(buyUrl) + '&aff_code=' + encodeURIComponent(affCode) + '&aff_param=' + encodeURIComponent(affParam))
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.aff_url) {
previewUrl.innerHTML = '<a href="' + data.aff_url + '" target="_blank">' + data.aff_url + '</a>';
preview.style.display = 'block';
}
})
.catch(function() {});
} else {
preview.style.display = 'none';
}
}
function flashGreen(el) {
el.style.borderColor = '#28a745';
setTimeout(function() { el.style.borderColor = ''; }, 2000);
}
</script>
<%- include('../partials/footer') %>

View File

@@ -0,0 +1,171 @@
<%- include('../partials/admin-header') %>
<h1>产品管理</h1>
<div class="form-card">
<h2>添加产品</h2>
<form method="POST" action="/admin/products">
<div class="form-row">
<label>商家
<select name="merchant_id" required>
<option value="">选择商家</option>
<% merchants.forEach(m => { %>
<option value="<%= m.id %>"><%= m.name %></option>
<% }) %>
</select>
</label>
<label>产品名 <input name="name" required placeholder="HKG.Pulse.Mini"></label>
<label>地区 <input name="location" placeholder="🇭🇰 HKG / 🇯🇵 JPN"></label>
<label>产品页 URL <input name="url" placeholder="https://...(商家产品页)"></label>
</div>
<div class="form-row">
<label style="flex:2">购买链接 <input name="buy_url" id="add_buy_url" placeholder="https://...(基础购买链接 或 含aff的链接" onchange="autoParseUrl(this)"></label>
<label>Aff 参数名 <input name="aff_param" id="add_aff_param" value="aff" placeholder="aff" style="width:80px"></label>
<label>Aff 值 <input name="aff_code" id="add_aff_code" placeholder="如 5" oninput="previewAffUrl()"></label>
<label>商家 PID <input name="provider_pid" id="add_provider_pid" placeholder="自动识别"></label>
</div>
<div id="add_aff_preview" style="display:none;padding:4px 12px;margin:-4px 0 8px;background:#e8f5e9;border-radius:4px;font-size:13px;word-break:break-all">
<strong>生成的 Aff 链接:</strong><span id="add_aff_preview_url"></span>
</div>
<div class="form-row">
<label>价格 <input name="price" placeholder="$49/月"></label>
<label>年付价 <input name="annual_price" placeholder="$470.4/年"></label>
<label>配置摘要 <input name="spec_summary" placeholder="2C / 4G / 40G1Gbps"></label>
<label>流量 <input name="traffic" placeholder="1000G 单向"></label>
<label>计费说明 <input name="billing_cycle" placeholder="年付 8 折"></label>
</div>
<div class="form-row">
<label>优惠码 <input name="coupon_code" placeholder="gomami365"></label>
<label>标签 <input name="tags" placeholder="gomami,pulse,香港"></label>
<label>SKU <input name="sku"></label>
<label style="flex:1">推送开头
<input name="push_intro" placeholder="🔥 推荐产品">
</label>
</div>
<div class="form-row">
<label>Slug <input name="slug" placeholder="自动生成"></label>
</div>
<div class="form-row">
<label>检测模式
<select name="check_mode">
<option value="keyword">keyword</option>
<option value="manual">manual</option>
</select>
</label>
<label style="flex:1">有货关键词
<input name="in_stock_keywords" placeholder="Add to Cart,立即购买">
</label>
<label style="flex:1">缺货关键词
<input name="out_of_stock_keywords" placeholder="Out of Stock,缺货,售罄">
</label>
</div>
<div class="form-row">
<label>
<input type="checkbox" name="is_public" value="1" checked> 前台展示
</label>
<label>
<input type="checkbox" name="is_featured" value="1"> 推荐
</label>
<label>排序值 <input name="sort_order" type="number" value="100" style="width:80px"></label>
<label style="flex:1">备注 <input name="notes" placeholder="备注"></label>
<button type="submit" class="btn btn-primary" style="align-self:end">添加</button>
</div>
</form>
</div>
<table>
<thead><tr><th>系统PID</th><th>商家</th><th>产品</th><th>商家PID</th><th>Aff</th><th>价格</th><th>库存</th><th>前台</th><th>操作</th></tr></thead>
<tbody>
<% products.forEach(p => { %>
<tr>
<td><code style="font-size:11px;color:#6c757d"><%= p.internal_pid || '-' %></code></td>
<td><%= p.merchant_name || '-' %></td>
<td>
<div><strong><%= p.location ? p.location + '' : '' %><%= p.name %></strong></div>
<% if (p.spec_summary) { %><div class="muted"><%= p.spec_summary %></div><% } %>
<% if (p.slug) { %><div class="muted" style="font-size:11px">/<%= p.slug %></div><% } %>
</td>
<td><%= p.provider_pid || '-' %></td>
<td style="max-width:180px">
<% if (p.aff_code) { %>
<span class="badge" style="background:#d4edda;color:#155724;font-size:11px"><%= p.aff_param || 'aff' %>=<%= p.aff_code %></span>
<% if (p.generated_aff_url) { %>
<div class="muted" style="font-size:11px;margin-top:2px;word-break:break-all;max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="<%= p.generated_aff_url %>">
<a href="<%= p.generated_aff_url %>" target="_blank">🔗 aff链接</a>
<% if (p.product_aff_url) { %><span style="color:#28a745">✓推送用</span><% } %>
</div>
<% } %>
<% } else { %>-<% } %>
</td>
<td>
<div><%= p.price || '-' %></div>
<% if (p.annual_price) { %><div class="muted"><%= p.billing_cycle || '年付' %><%= p.annual_price %></div><% } %>
</td>
<td>
<% if (p.in_stock === 1) { %><span class="badge badge-on">有货</span>
<% } else if (p.in_stock === 2) { %><span class="badge badge-off">缺货</span>
<% } else { %>未知<% } %>
</td>
<td><%= p.is_public ? '✅' : '❌' %></td>
<td style="white-space:nowrap">
<a href="/admin/products/<%= p.id %>/edit" class="btn btn-sm" style="background:#17a2b8;color:#fff">编辑</a>
<form class="inline" method="POST" action="/admin/products/<%= p.id %>/delete" onsubmit="return confirm('确定删除?')">
<button class="btn btn-danger btn-sm">删除</button>
</form>
</td>
</tr>
<% }) %>
</tbody>
</table>
<script>
function autoParseUrl(input) {
var url = input.value.trim();
if (!url) return;
fetch('/admin/products/api/parse-pid?url=' + encodeURIComponent(url))
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.provider_pid) {
var el = document.getElementById('add_provider_pid');
if (el && !el.value) { el.value = data.provider_pid; flashGreen(el); }
}
if (data.aff_code) {
var el2 = document.getElementById('add_aff_code');
if (el2 && !el2.value) { el2.value = data.aff_code; flashGreen(el2); }
}
if (data.aff_param) {
var el3 = document.getElementById('add_aff_param');
if (el3) { el3.value = data.aff_param; }
}
previewAffUrl();
})
.catch(function() {});
}
function previewAffUrl() {
var buyUrl = document.getElementById('add_buy_url').value.trim();
var affCode = document.getElementById('add_aff_code').value.trim();
var affParam = document.getElementById('add_aff_param').value.trim() || 'aff';
var preview = document.getElementById('add_aff_preview');
var previewUrl = document.getElementById('add_aff_preview_url');
if (buyUrl && affCode) {
fetch('/admin/products/api/build-aff-url?base_url=' + encodeURIComponent(buyUrl) + '&aff_code=' + encodeURIComponent(affCode) + '&aff_param=' + encodeURIComponent(affParam))
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.aff_url) {
previewUrl.textContent = data.aff_url;
preview.style.display = 'block';
}
})
.catch(function() {});
} else {
preview.style.display = 'none';
}
}
function flashGreen(el) {
el.style.borderColor = '#28a745';
setTimeout(function() { el.style.borderColor = ''; }, 2000);
}
</script>
<%- include('../partials/footer') %>

View File

@@ -0,0 +1,103 @@
<%- include('../partials/admin-header') %>
<h1>系统设置</h1>
<% if (flash) { %>
<div class="flash-msg"><%= flash %></div>
<% } %>
<div class="form-card">
<h2>Telegram 配置</h2>
<form method="POST" action="/admin/settings">
<div class="form-row">
<label style="flex:2">
Bot Token
<input name="TG_BOT_TOKEN" type="password" placeholder="从 @BotFather 获取" value="<%= formData.TG_BOT_TOKEN || '' %>" autocomplete="off">
<% if (formData.TG_BOT_TOKEN_MASKED) { %>
<span class="muted">当前: <%= formData.TG_BOT_TOKEN_MASKED %></span>
<% } %>
</label>
<label style="flex:1">
默认频道 ID
<input name="TG_DEFAULT_CHANNEL_ID" placeholder="-1001234567890" value="<%= formData.TG_DEFAULT_CHANNEL_ID || '' %>">
</label>
</div>
<div class="form-row">
<label style="flex:1">
站点名称
<input name="SITE_NAME" placeholder="VPS补货监控" value="<%= formData.SITE_NAME || '' %>">
</label>
<label style="flex:1">
默认 Aff 值
<input name="DEFAULT_AFF_CODE" placeholder="如 5新产品默认 aff" value="<%= formData.DEFAULT_AFF_CODE || '' %>">
</label>
</div>
<div class="form-row">
<label style="flex:2">
Telegram 频道/群链接
<input name="SITE_TG_URL" placeholder="https://t.me/your_channel" value="<%= formData.SITE_TG_URL || '' %>">
<span class="muted">前台首页「加入频道」按钮的跳转链接</span>
</label>
</div>
<div class="form-row" style="justify-content:space-between;margin-top:12px">
<span class="muted">设置保存到数据库,.env 中的值作为默认兜底</span>
<button type="submit" class="btn btn-primary" style="padding:8px 24px">保存设置</button>
</div>
</form>
</div>
<div class="form-card">
<h2>测试 Telegram 连接</h2>
<form method="POST" action="/admin/settings/test-telegram">
<div class="form-row">
<label style="flex:1">
测试频道 ID可选留空用默认频道
<input name="chat_id" placeholder="-1001234567890">
</label>
<button type="submit" class="btn" style="background:#17a2b8;color:#fff;align-self:end">📤 发送测试消息</button>
</div>
</form>
<div style="margin-top:12px;padding:12px;background:#f8f9fa;border-radius:6px;font-size:13px">
<strong>获取 Bot Token:</strong> 在 Telegram 搜索 <a href="https://t.me/BotFather" target="_blank">@BotFather</a>,发送 /newbot 创建机器人,复制返回的 Token。<br>
<strong>获取频道 ID:</strong> 将 Bot 加入频道并设为管理员,转发频道消息到 <a href="https://t.me/RawDataBot" target="_blank">@RawDataBot</a>,查看 <code>chat.id</code>。
</div>
</div>
<div class="form-card">
<h2>配置说明</h2>
<table style="font-size:13px">
<thead><tr><th>配置项</th><th>说明</th><th>来源</th></tr></thead>
<tbody>
<tr>
<td><code>TG_BOT_TOKEN</code></td>
<td>Telegram Bot Token用于发送推送消息</td>
<td>数据库优先,.env 兜底</td>
</tr>
<tr>
<td><code>TG_DEFAULT_CHANNEL_ID</code></td>
<td>默认推送频道的 chat_id</td>
<td>数据库优先,.env 兜底</td>
</tr>
<tr>
<td><code>SITE_NAME</code></td>
<td>站点名称,显示在前台标题</td>
<td>数据库优先,.env 兜底</td>
</tr>
<tr>
<td><code>SITE_TG_URL</code></td>
<td>Telegram 频道/群链接,前台「加入频道」按钮</td>
<td>数据库</td>
</tr>
<tr>
<td><code>DEFAULT_AFF_CODE</code></td>
<td>新产品的默认 aff 值</td>
<td>数据库</td>
</tr>
</tbody>
</table>
</div>
<%- include('../partials/footer') %>

View File

@@ -0,0 +1,108 @@
<%- include('../partials/admin-header') %>
<h1>监控任务 &amp; 检测记录</h1>
<% if (flash) { %>
<div class="flash-msg"><%= decodeURIComponent(flash) %></div>
<% } %>
<div class="form-card">
<h2>创建监控任务</h2>
<form method="POST" action="/admin/tasks">
<div class="form-row">
<label>产品
<select name="product_id" required>
<option value="">选择产品</option>
<% products.forEach(p => { %>
<option value="<%= p.id %>"><%= p.merchant_name %> — <%= p.name %></option>
<% }) %>
</select>
</label>
<label>推送频道
<select name="tg_channel_id">
<option value="">不推送</option>
<% channels.forEach(c => { %>
<option value="<%= c.id %>"><%= c.name %></option>
<% }) %>
</select>
</label>
<label>Cron 表达式 <input name="cron_expr" value="*/5 * * * *" placeholder="*/5 * * * *"></label>
<button type="submit" class="btn btn-primary" style="align-self:end">创建</button>
</div>
</form>
</div>
<div class="form-card" style="display:flex;gap:12px;align-items:center;flex-wrap:wrap">
<h2 style="margin:0">调度器</h2>
<span class="badge <%= schedulerRunning ? 'badge-on' : 'badge-off' %>"><%= schedulerRunning ? '运行中' : '已停止' %></span>
<% if (schedulerRunning) { %>
<form method="POST" action="/admin/tasks/scheduler/stop" class="inline">
<button class="btn btn-danger btn-sm">停止调度器</button>
</form>
<% } else { %>
<form method="POST" action="/admin/tasks/scheduler/start" class="inline">
<button class="btn btn-primary btn-sm">启动调度器</button>
</form>
<% } %>
<form method="POST" action="/admin/tasks/test-push" class="inline" style="margin-left:auto">
<button class="btn btn-sm" style="background:#28a745;color:#fff">🧪 测试 TG 推送</button>
</form>
</div>
<h2 style="margin-bottom:12px">任务列表</h2>
<table>
<thead><tr><th>ID</th><th>产品</th><th>频道</th><th>Cron</th><th>状态</th><th>上次运行</th><th>操作</th></tr></thead>
<tbody>
<% tasks.forEach(t => { %>
<tr>
<td><%= t.id %></td>
<td><%= t.merchant_name %> — <%= t.product_name %></td>
<td><%= t.channel_name || '-' %></td>
<td><code><%= t.cron_expr %></code></td>
<td><span class="badge <%= t.enabled ? 'badge-on' : 'badge-off' %>"><%= t.enabled ? '启用' : '禁用' %></span></td>
<td><%= t.last_run || '-' %></td>
<td>
<form class="inline" method="POST" action="/admin/tasks/<%= t.id %>/run">
<button class="btn btn-sm" style="background:#17a2b8;color:#fff" title="手动执行一次检测">▶ 执行</button>
</form>
<form class="inline" method="POST" action="/admin/tasks/<%= t.id %>/push">
<button class="btn btn-sm" style="background:#6f42c1;color:#fff" title="手动推送产品消息">📨 推送</button>
</form>
<form class="inline" method="POST" action="/admin/tasks/<%= t.id %>/toggle">
<button class="btn btn-sm btn-primary"><%= t.enabled ? '禁用' : '启用' %></button>
</form>
<form class="inline" method="POST" action="/admin/tasks/<%= t.id %>/delete" onsubmit="return confirm('确定删除?')">
<button class="btn btn-danger btn-sm">删除</button>
</form>
</td>
</tr>
<% }) %>
<% if (tasks.length === 0) { %>
<tr><td colspan="7" style="text-align:center;color:#999">暂无任务,请先创建</td></tr>
<% } %>
</tbody>
</table>
<h2 style="margin:24px 0 12px">最近检测记录(最新 50 条)</h2>
<table>
<thead><tr><th>ID</th><th>产品</th><th>状态</th><th>消息</th><th>已推送</th><th>时间</th></tr></thead>
<tbody>
<% logs.forEach(l => { %>
<tr>
<td><%= l.id %></td>
<td><%= l.product_name || l.product_id %></td>
<td>
<% if (l.status === 'in_stock') { %><span class="badge badge-on">有货</span>
<% } else if (l.status === 'out_of_stock') { %><span class="badge badge-off">缺货</span>
<% } else { %><span class="badge"><%= l.status %></span><% } %>
</td>
<td><%= l.message || '-' %></td>
<td><%= l.notified ? '✅' : '❌' %></td>
<td><%= l.created_at %></td>
</tr>
<% }) %>
<% if (logs.length === 0) { %>
<tr><td colspan="6" style="text-align:center;color:#999">暂无记录</td></tr>
<% } %>
</tbody>
</table>
<%- include('../partials/footer') %>

View File

@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录 - VPS补货监控</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div class="login-page">
<div class="login-card">
<h1>📦 VPS补货监控</h1>
<p class="muted" style="margin-bottom:20px">管理后台登录</p>
<% if (error) { %>
<div class="flash-msg" style="background:#f8d7da;border-color:#f5c6cb;color:#721c24;margin-bottom:16px"><%= error %></div>
<% } %>
<form method="POST" action="/admin/login">
<label>用户名
<input type="text" name="username" required autofocus autocomplete="username">
</label>
<label>密码
<input type="password" name="password" required autocomplete="current-password">
</label>
<button type="submit" class="btn btn-primary" style="width:100%;padding:10px;font-size:15px;margin-top:8px">登录</button>
</form>
<p style="text-align:center;margin-top:16px"><a href="/" style="font-size:13px;color:#888">← 返回首页</a></p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= typeof pageTitle !== 'undefined' ? pageTitle + ' - ' : '' %>VPS补货监控 · 管理后台</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<nav>
<a href="/admin" class="brand">📦 VPS补货监控 <span class="nav-badge">管理</span></a>
<a href="/admin/merchants">商家</a>
<a href="/admin/products">产品</a>
<a href="/admin/aff-links">Aff 链接</a>
<a href="/admin/channels">TG 频道</a>
<a href="/admin/tasks">监控任务</a>
<a href="/admin/settings">系统设置</a>
<div style="margin-left:auto;display:flex;gap:12px;align-items:center">
<a href="/" style="font-size:12px;opacity:0.7">← 前台</a>
<a href="/admin/logout" style="font-size:12px;opacity:0.7">退出</a>
</div>
</nav>
<main>

View File

@@ -0,0 +1,7 @@
</main>
<footer>
<div style="margin-bottom:4px">VPS补货监控</div>
<div style="opacity:0.6;font-size:12px">实时监控 · 即时推送 · 不再错过</div>
</footer>
</body>
</html>

View File

@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= typeof pageTitle !== 'undefined' ? pageTitle + ' - ' : '' %>VPS补货监控</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<nav class="nav-public">
<a href="/" class="brand">VPS补货监控</a>
<div class="nav-links">
<a href="/">首页</a>
<a href="/plans">全部产品</a>
<% if (typeof isAdmin !== 'undefined' && isAdmin) { %>
<a href="/admin" class="nav-admin-link">后台管理</a>
<% } else { %>
<a href="/admin/login" class="nav-admin-link">管理登录</a>
<% } %>
</div>
</nav>
<main>

View File

@@ -0,0 +1,9 @@
<%- include('../partials/public-header') %>
<div style="text-align:center;padding:60px 0">
<h1 style="font-size:48px;color:#ccc;margin-bottom:16px">404</h1>
<p style="font-size:16px;color:#888;margin-bottom:24px">产品不存在或未公开</p>
<a href="/plans" class="btn btn-primary" style="padding:10px 24px">← 浏览全部产品</a>
</div>
<%- include('../partials/footer') %>

View File

@@ -0,0 +1,117 @@
<%- include('../partials/public-header') %>
<div class="breadcrumb">
<a href="/">首页</a> <a href="/plans">全部产品</a> <%= product.name %>
</div>
<div class="detail-page">
<div class="detail-card">
<div class="detail-header">
<div>
<span class="product-merchant"><%= product.merchant_name || '未知商家' %></span>
<% if (product.in_stock === 1) { %><span class="badge badge-on">✓ 有货</span>
<% } else if (product.in_stock === 2) { %><span class="badge badge-off">✗ 缺货</span>
<% } %>
<% if (product.is_featured) { %><span class="badge" style="background:linear-gradient(135deg,#fef3c7,#fde68a);color:#92400e;font-weight:600">⭐ 推荐</span><% } %>
</div>
</div>
<h1 style="margin-top:12px"><%= product.location ? product.location + ' ' : '' %><%= product.name %></h1>
<div class="detail-info">
<% if (product.spec_summary) { %>
<div class="detail-row">
<span class="detail-label">配置</span>
<span><%= product.spec_summary %></span>
</div>
<% } %>
<% if (product.traffic) { %>
<div class="detail-row">
<span class="detail-label">流量</span>
<span><%= product.traffic %></span>
</div>
<% } %>
<% if (product.price) { %>
<div class="detail-row">
<span class="detail-label">价格</span>
<span><strong class="price-highlight"><%= product.price %></strong></span>
</div>
<% } %>
<% if (product.annual_price) { %>
<div class="detail-row">
<span class="detail-label"><%= product.billing_cycle || '年付' %></span>
<span><%= product.annual_price %></span>
</div>
<% } %>
<% if (product.location) { %>
<div class="detail-row">
<span class="detail-label">地区</span>
<span><%= product.location %></span>
</div>
<% } %>
<% if (product.coupon_code) { %>
<div class="detail-row">
<span class="detail-label">优惠码</span>
<span class="coupon-tag"><%= product.coupon_code %></span>
</div>
<% } %>
<% if (product.last_checked) { %>
<div class="detail-row">
<span class="detail-label">最后检测</span>
<span class="muted"><%= product.last_checked.replace('T', ' ').slice(0,19) %></span>
</div>
<% } %>
</div>
<% if (product.push_intro) { %>
<div class="detail-intro"><%= product.push_intro %></div>
<% } %>
<% if (product.notes) { %>
<div class="detail-notes"><%= product.notes %></div>
<% } %>
<div class="detail-actions">
<% if (product.buy_url) { %>
<a href="<%= product.buy_url %>" target="_blank" rel="noopener" class="btn btn-primary" style="padding:14px 28px;font-size:15px;font-weight:600">🛒 前往购买</a>
<% } %>
<% if (product.url && product.url !== product.buy_url) { %>
<a href="<%= product.url %>" target="_blank" rel="noopener" class="btn" style="padding:14px 24px;font-size:14px;background:linear-gradient(135deg,#f8fafc,#f1f5f9);color:#475569;border:1px solid #e2e8f0">查看产品页</a>
<% } %>
<% if (product.merchant_website) { %>
<a href="<%= product.merchant_website %>" target="_blank" rel="noopener" class="btn" style="padding:14px 24px;font-size:14px;background:linear-gradient(135deg,#f8fafc,#f1f5f9);color:#475569;border:1px solid #e2e8f0">商家官网</a>
<% } %>
</div>
</div>
<% if (related.length > 0) { %>
<section class="section" style="margin-top:32px">
<h2>同商家其他产品</h2>
<div class="product-grid">
<% related.forEach(p => { %>
<a href="/plans/<%= p.slug || p.id %>" class="product-card">
<div class="product-card-header">
<span class="product-merchant"><%= p.merchant_name %></span>
<% if (p.in_stock === 1) { %><span class="badge badge-on">✓ 有货</span>
<% } else if (p.in_stock === 2) { %><span class="badge badge-off">✗ 缺货</span>
<% } %>
</div>
<h3><%= p.location ? p.location + ' ' : '' %><%= p.name %></h3>
<% if (p.spec_summary) { %><div class="product-spec"><%= p.spec_summary %></div><% } %>
<div class="product-price">
<% if (p.price) { %><strong><%= p.price %></strong><% } %>
</div>
</a>
<% }) %>
</div>
</section>
<% } %>
</div>
<%- include('../partials/footer') %>

View File

@@ -0,0 +1,152 @@
<%- include('../partials/public-header') %>
<!-- Hero 区域 -->
<div class="hero-new">
<div class="hero-content">
<h1 class="hero-title">VPS 补货监控</h1>
<p class="hero-desc">实时监控热门 VPS / 独服库存变化,第一时间推送补货通知</p>
<!-- 状态提示 -->
<div class="status-badge">
<span class="status-dot"></span>
<span>每 5 分钟自动检测</span>
</div>
<!-- 入口按钮 -->
<div class="hero-actions">
<% if (tgUrl) { %>
<a href="<%= tgUrl %>" target="_blank" class="btn-hero btn-tg">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm4.64 6.8c-.15 1.58-.8 5.42-1.13 7.19-.14.75-.42 1-.68 1.03-.58.05-1.02-.38-1.58-.75-.88-.58-1.38-.94-2.23-1.5-.99-.65-.35-1.01.22-1.59.15-.15 2.71-2.48 2.76-2.69a.2.2 0 00-.05-.18c-.06-.05-.14-.03-.21-.02-.09.02-1.49.95-4.22 2.79-.4.27-.76.41-1.08.4-.36-.01-1.04-.2-1.55-.37-.63-.2-1.12-.31-1.08-.66.02-.18.27-.36.74-.55 2.92-1.27 4.86-2.11 5.83-2.51 2.78-1.16 3.35-1.36 3.73-1.36.08 0 .27.02.39.12.1.08.13.19.14.27-.01.06.01.24 0 .38z"/>
</svg>
Telegram 频道
</a>
<% } %>
<a href="/plans" class="btn-hero btn-primary-hero">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<line x1="3" y1="9" x2="21" y2="9"/>
<line x1="9" y1="21" x2="9" y2="9"/>
</svg>
浏览全部产品
</a>
</div>
<!-- 订阅占位 -->
<div class="subscribe-placeholder">
<span class="coming-soon">邮箱订阅功能即将上线</span>
</div>
</div>
</div>
<!-- 统计数据 -->
<div class="stats-bar">
<div class="stat-item">
<span class="stat-num"><%= stats.products %></span>
<span class="stat-label">款产品</span>
</div>
<div class="stat-item">
<span class="stat-num"><%= stats.merchants %></span>
<span class="stat-label">家商家</span>
</div>
</div>
<% if (featured.length > 0) { %>
<!-- 推荐产品 -->
<section class="section-new">
<div class="section-header">
<h2>推荐产品</h2>
<a href="/plans" class="view-all">查看全部 →</a>
</div>
<div class="product-grid-new">
<% featured.forEach(p => { %>
<div class="product-card-new">
<div class="card-header">
<span class="merchant-tag"><%= p.merchant_name || '未知商家' %></span>
<% if (p.in_stock === 1) { %><span class="stock-tag in-stock">有货</span>
<% } else if (p.in_stock === 2) { %><span class="stock-tag out-stock">缺货</span>
<% } else { %><span class="stock-tag unknown">未知</span><% } %>
</div>
<h3 class="card-title"><%= p.location ? p.location + ' · ' : '' %><%= p.name %></h3>
<% if (p.spec_summary) { %>
<div class="card-spec"><%= p.spec_summary %></div>
<% } %>
<% if (p.traffic) { %>
<div class="card-spec">流量 <%= p.traffic %></div>
<% } %>
<div class="card-price">
<% if (p.price) { %><strong><%= p.price %></strong><% } %>
<% if (p.annual_price) { %><span class="annual"><%= p.billing_cycle || '年付' %> <%= p.annual_price %></span><% } %>
</div>
<% if (p.coupon_code) { %>
<div class="card-coupon" onclick="navigator.clipboard.writeText('<%= p.coupon_code %>').then(() => this.classList.add('copied'))">
<span class="coupon-icon">✦</span>
<span class="coupon-label">优惠码</span>
<span class="coupon-code"><%= p.coupon_code %></span>
<span class="coupon-hint">点击复制</span>
</div>
<% } %>
<div class="card-actions">
<% if (p.test_ip) { %>
<a href="javascript:navigator.clipboard.writeText('<%= p.test_ip %>').then(() => alert('已复制: <%= p.test_ip %>'))" class="btn-card btn-copy">测试 IP</a>
<% } %>
<a href="/plans/<%= p.slug || p.id %>" class="btn-card btn-detail">详情</a>
</div>
</div>
<% }) %>
</div>
</section>
<% } %>
<!-- 最新产品 -->
<section class="section-new">
<div class="section-header">
<h2>最新产品</h2>
<a href="/plans" class="view-all">查看全部 →</a>
</div>
<div class="product-grid-new">
<% latest.forEach(p => { %>
<div class="product-card-new">
<div class="card-header">
<span class="merchant-tag"><%= p.merchant_name || '未知商家' %></span>
<% if (p.in_stock === 1) { %><span class="stock-tag in-stock">有货</span>
<% } else if (p.in_stock === 2) { %><span class="stock-tag out-stock">缺货</span>
<% } else { %><span class="stock-tag unknown">未知</span><% } %>
</div>
<h3 class="card-title"><%= p.location ? p.location + ' · ' : '' %><%= p.name %></h3>
<% if (p.spec_summary) { %>
<div class="card-spec"><%= p.spec_summary %></div>
<% } %>
<% if (p.traffic) { %>
<div class="card-spec">流量 <%= p.traffic %></div>
<% } %>
<div class="card-price">
<% if (p.price) { %><strong><%= p.price %></strong><% } %>
<% if (p.annual_price) { %><span class="annual"><%= p.billing_cycle || '年付' %> <%= p.annual_price %></span><% } %>
</div>
<% if (p.coupon_code) { %>
<div class="card-coupon" onclick="navigator.clipboard.writeText('<%= p.coupon_code %>').then(() => this.classList.add('copied'))">
<span class="coupon-icon">✦</span>
<span class="coupon-label">优惠码</span>
<span class="coupon-code"><%= p.coupon_code %></span>
<span class="coupon-hint">点击复制</span>
</div>
<% } %>
<div class="card-actions">
<% if (p.test_ip) { %>
<a href="javascript:navigator.clipboard.writeText('<%= p.test_ip %>').then(() => alert('已复制: <%= p.test_ip %>'))" class="btn-card btn-copy">测试 IP</a>
<% } %>
<a href="/plans/<%= p.slug || p.id %>" class="btn-card btn-detail">详情</a>
</div>
</div>
<% }) %>
</div>
<% if (latest.length === 0) { %>
<div class="empty-state">
<p>暂无产品数据</p>
<p class="muted">请登录后台添加产品</p>
</div>
<% } %>
</section>
<%- include('../partials/footer') %>

View File

@@ -0,0 +1,61 @@
<%- include('../partials/public-header') %>
<h1>全部产品</h1>
<div class="filter-bar">
<form method="GET" action="/plans" class="form-row" style="margin-bottom:0">
<label>商家
<select name="merchant" onchange="this.form.submit()">
<option value="">全部</option>
<% merchants.forEach(m => { %>
<option value="<%= m.id %>" <%= query.merchant == m.id ? 'selected' : '' %>><%= m.name %></option>
<% }) %>
</select>
</label>
<label>地区
<select name="location" onchange="this.form.submit()">
<option value="">全部</option>
<% allLocations.forEach(loc => { %>
<option value="<%= loc %>" <%= query.location === loc ? 'selected' : '' %>><%= loc %></option>
<% }) %>
</select>
</label>
<label>搜索
<input name="q" value="<%= query.q || '' %>" placeholder="关键词...">
</label>
<button type="submit" class="btn btn-primary" style="align-self:end">筛选</button>
<% if (query.merchant || query.location || query.q) { %>
<a href="/plans" class="btn" style="align-self:end;background:#eee;color:#333">清除</a>
<% } %>
</form>
</div>
<div class="product-grid">
<% products.forEach(p => { %>
<a href="/plans/<%= p.slug || p.id %>" class="product-card">
<div class="product-card-header">
<span class="product-merchant"><%= p.merchant_name || '未知商家' %></span>
<% if (p.is_featured) { %><span class="badge" style="background:linear-gradient(135deg,#fef3c7,#fde68a);color:#92400e;font-weight:600">⭐ 推荐</span><% } %>
<% if (p.in_stock === 1) { %><span class="badge badge-on">✓ 有货</span>
<% } else if (p.in_stock === 2) { %><span class="badge badge-off">✗ 缺货</span>
<% } %>
</div>
<h3><%= p.location ? p.location + ' ' : '' %><%= p.name %></h3>
<% if (p.spec_summary) { %><div class="product-spec"><%= p.spec_summary %></div><% } %>
<% if (p.traffic) { %><div class="product-spec">流量:<%= p.traffic %></div><% } %>
<div class="product-price">
<% if (p.price) { %><strong><%= p.price %></strong><% } %>
<% if (p.annual_price) { %><span class="muted" style="margin-left:8px"><%= p.billing_cycle || '年付' %><%= p.annual_price %></span><% } %>
</div>
<% if (p.coupon_code) { %><div class="product-coupon">🎫 优惠码: <%= p.coupon_code %></div><% } %>
</a>
<% }) %>
</div>
<% if (products.length === 0) { %>
<p class="muted" style="text-align:center;padding:40px 0">没有找到匹配的产品</p>
<% } %>
<p class="muted" style="text-align:center;margin-top:24px">共 <%= products.length %> 款产品</p>
<%- include('../partials/footer') %>