Rename to hkt.sh
This commit is contained in:
5
aff-monitor/.gitignore
vendored
Normal file
5
aff-monitor/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
db/*.sqlite
|
||||
db/*.sqlite-journal
|
||||
db/*.sqlite-wal
|
||||
.env
|
||||
354
aff-monitor/README.md
Normal file
354
aff-monitor/README.md
Normal 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
108
aff-monitor/db/init.js
Normal 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();
|
||||
47
aff-monitor/db/migrate-002-checker-fields.js
Normal file
47
aff-monitor/db/migrate-002-checker-fields.js
Normal 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();
|
||||
31
aff-monitor/db/migrate-003-public-fields.js
Normal file
31
aff-monitor/db/migrate-003-public-fields.js
Normal 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();
|
||||
86
aff-monitor/db/migrate-004-pid-slug.js
Normal file
86
aff-monitor/db/migrate-004-pid-slug.js
Normal 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();
|
||||
46
aff-monitor/db/migrate-005-aff-code.js
Normal file
46
aff-monitor/db/migrate-005-aff-code.js
Normal 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();
|
||||
65
aff-monitor/db/migrate-006-settings.js
Normal file
65
aff-monitor/db/migrate-006-settings.js
Normal 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();
|
||||
28
aff-monitor/db/migrate-add-product-fields.js
Normal file
28
aff-monitor/db/migrate-add-product-fields.js
Normal 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();
|
||||
BIN
aff-monitor/db/monitor.sqlite-shm
Normal file
BIN
aff-monitor/db/monitor.sqlite-shm
Normal file
Binary file not shown.
1399
aff-monitor/package-lock.json
generated
Normal file
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
28
aff-monitor/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
95
aff-monitor/scripts/run-task.js
Normal file
95
aff-monitor/scripts/run-task.js
Normal 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
67
aff-monitor/src/app.js
Normal 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
13
aff-monitor/src/db.js
Normal 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;
|
||||
1596
aff-monitor/src/public/css/style.css
Normal file
1596
aff-monitor/src/public/css/style.css
Normal file
File diff suppressed because it is too large
Load Diff
42
aff-monitor/src/routes/admin.js
Normal file
42
aff-monitor/src/routes/admin.js
Normal 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;
|
||||
74
aff-monitor/src/routes/affLinks.js
Normal file
74
aff-monitor/src/routes/affLinks.js
Normal 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;
|
||||
33
aff-monitor/src/routes/auth.js
Normal file
33
aff-monitor/src/routes/auth.js
Normal 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;
|
||||
26
aff-monitor/src/routes/channels.js
Normal file
26
aff-monitor/src/routes/channels.js
Normal 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;
|
||||
24
aff-monitor/src/routes/merchants.js
Normal file
24
aff-monitor/src/routes/merchants.js
Normal 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;
|
||||
266
aff-monitor/src/routes/products.js
Normal file
266
aff-monitor/src/routes/products.js
Normal 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;
|
||||
122
aff-monitor/src/routes/public.js
Normal file
122
aff-monitor/src/routes/public.js
Normal 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);
|
||||
|
||||
// 如果找到了且该产品有 slug,301 重定向到 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;
|
||||
92
aff-monitor/src/routes/settings.js
Normal file
92
aff-monitor/src/routes/settings.js
Normal 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;
|
||||
|
||||
// 动态获取最新的 token(DB 优先)
|
||||
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;
|
||||
147
aff-monitor/src/routes/tasks.js
Normal file
147
aff-monitor/src/routes/tasks.js
Normal 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;
|
||||
83
aff-monitor/src/utils/affHelper.js
Normal file
83
aff-monitor/src/utils/affHelper.js
Normal 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,
|
||||
};
|
||||
103
aff-monitor/src/utils/checker.js
Normal file
103
aff-monitor/src/utils/checker.js
Normal 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 };
|
||||
170
aff-monitor/src/utils/pidHelper.js
Normal file
170
aff-monitor/src/utils/pidHelper.js
Normal 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 解析商家产品 ID(provider_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,
|
||||
};
|
||||
54
aff-monitor/src/utils/pushTemplate.js
Normal file
54
aff-monitor/src/utils/pushTemplate.js
Normal 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 };
|
||||
74
aff-monitor/src/utils/scheduler.js
Normal file
74
aff-monitor/src/utils/scheduler.js
Normal 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 };
|
||||
109
aff-monitor/src/utils/settings.js
Normal file
109
aff-monitor/src/utils/settings.js
Normal 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 Token(DB 优先)
|
||||
*/
|
||||
function getTgBotToken() {
|
||||
return getSetting('TG_BOT_TOKEN');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取默认频道 ID(DB 优先)
|
||||
*/
|
||||
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,
|
||||
};
|
||||
105
aff-monitor/src/utils/taskRunner.js
Normal file
105
aff-monitor/src/utils/taskRunner.js
Normal 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 };
|
||||
65
aff-monitor/src/utils/telegram.js
Normal file
65
aff-monitor/src/utils/telegram.js
Normal 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 };
|
||||
111
aff-monitor/src/views/admin/affLinks.ejs
Normal file
111
aff-monitor/src/views/admin/affLinks.ejs
Normal 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') %>
|
||||
40
aff-monitor/src/views/admin/channels.ejs
Normal file
40
aff-monitor/src/views/admin/channels.ejs
Normal 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') %>
|
||||
20
aff-monitor/src/views/admin/index.ejs
Normal file
20
aff-monitor/src/views/admin/index.ejs
Normal 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') %>
|
||||
35
aff-monitor/src/views/admin/merchants.ejs
Normal file
35
aff-monitor/src/views/admin/merchants.ejs
Normal 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') %>
|
||||
148
aff-monitor/src/views/admin/product-edit.ejs
Normal file
148
aff-monitor/src/views/admin/product-edit.ejs
Normal 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') %>
|
||||
171
aff-monitor/src/views/admin/products.ejs
Normal file
171
aff-monitor/src/views/admin/products.ejs
Normal 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 / 40G|1Gbps"></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') %>
|
||||
103
aff-monitor/src/views/admin/settings.ejs
Normal file
103
aff-monitor/src/views/admin/settings.ejs
Normal 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') %>
|
||||
108
aff-monitor/src/views/admin/tasks.ejs
Normal file
108
aff-monitor/src/views/admin/tasks.ejs
Normal file
@@ -0,0 +1,108 @@
|
||||
<%- include('../partials/admin-header') %>
|
||||
<h1>监控任务 & 检测记录</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') %>
|
||||
30
aff-monitor/src/views/login.ejs
Normal file
30
aff-monitor/src/views/login.ejs
Normal 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>
|
||||
23
aff-monitor/src/views/partials/admin-header.ejs
Normal file
23
aff-monitor/src/views/partials/admin-header.ejs
Normal 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>
|
||||
7
aff-monitor/src/views/partials/footer.ejs
Normal file
7
aff-monitor/src/views/partials/footer.ejs
Normal 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>
|
||||
22
aff-monitor/src/views/partials/public-header.ejs
Normal file
22
aff-monitor/src/views/partials/public-header.ejs
Normal 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>
|
||||
9
aff-monitor/src/views/public/404.ejs
Normal file
9
aff-monitor/src/views/public/404.ejs
Normal 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') %>
|
||||
117
aff-monitor/src/views/public/detail.ejs
Normal file
117
aff-monitor/src/views/public/detail.ejs
Normal 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') %>
|
||||
152
aff-monitor/src/views/public/home.ejs
Normal file
152
aff-monitor/src/views/public/home.ejs
Normal 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') %>
|
||||
61
aff-monitor/src/views/public/plans.ejs
Normal file
61
aff-monitor/src/views/public/plans.ejs
Normal 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') %>
|
||||
Reference in New Issue
Block a user