Initial daily news digest web
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
# Application
|
||||
APP_ENV=production
|
||||
APP_PORT=
|
||||
APP_BASE_URL=
|
||||
APP_SECRET_KEY=
|
||||
APP_ADMIN_TOKEN=
|
||||
|
||||
# PostgreSQL through an SSH tunnel.
|
||||
# For a process running on the host use 127.0.0.1.
|
||||
# For a Docker container use host.docker.internal (see README.md).
|
||||
PGHOST=
|
||||
PGPORT=
|
||||
PGDATABASE=
|
||||
PGUSER=
|
||||
PGPASSWORD=
|
||||
PGSSLMODE=prefer
|
||||
|
||||
# SMTP - leave blank until email delivery is configured.
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM=
|
||||
SMTP_TO=
|
||||
SMTP_USE_TLS=
|
||||
|
||||
# Optional machine translation for non-Chinese articles. This is separate
|
||||
# from AI analysis; leave blank/false to keep the original language.
|
||||
TRANSLATION_ENABLED=false
|
||||
TRANSLATION_PROVIDER=
|
||||
TRANSLATION_API_URL=
|
||||
TRANSLATION_API_KEY=
|
||||
TRANSLATION_TARGET_LANGUAGE=ZH
|
||||
|
||||
# n8n (only needed when wiring the workflow to this application)
|
||||
N8N_BASE_URL=
|
||||
N8N_WEBHOOK_SECRET=
|
||||
|
||||
# SSH tunnel is intentionally not automated by this file.
|
||||
# Keep the private key on the server/operator machine.
|
||||
SSH_TUNNEL_LOCAL_PORT=15432
|
||||
SSH_TUNNEL_REMOTE_HOST=127.0.0.1
|
||||
SSH_TUNNEL_REMOTE_PORT=5432
|
||||
@@ -0,0 +1,6 @@
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.log
|
||||
data/
|
||||
backups/
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install --omit=dev --no-audit --no-fund
|
||||
|
||||
COPY web ./web
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV APP_PORT=3000
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "web/server.js"]
|
||||
@@ -0,0 +1,140 @@
|
||||
# Daily News Digest
|
||||
|
||||
这是一个面向个人服务器的资讯日报项目,第一阶段不接入 AI:n8n 负责定时抓取 RSS,按关键词分类、去重并写入 PostgreSQL;网页读取 PostgreSQL 展示当天和历史记录;邮件发送当天完整日报。
|
||||
|
||||
## 当前范围
|
||||
|
||||
- AI 与 Agent
|
||||
- 农业硬件
|
||||
- 农业方案落地
|
||||
- 农业服务
|
||||
- 政策与市场
|
||||
- 国内与国际资讯
|
||||
|
||||
## 数据库初始化
|
||||
|
||||
1. 在 PostgreSQL 中创建一个独立数据库和用户,例如 `news_digest` / `news_app`。
|
||||
2. 使用 `db/schema.sql` 建表:
|
||||
|
||||
```bash
|
||||
psql -h 127.0.0.1 -p 15432 -U news_app -d news_digest -f db/schema.sql
|
||||
```
|
||||
|
||||
Windows PowerShell 示例:
|
||||
|
||||
```powershell
|
||||
psql -h 127.0.0.1 -p 15432 -U news_app -d news_digest -f .\db\schema.sql
|
||||
```
|
||||
|
||||
如果 PostgreSQL 不在本机,先建立 SSH 隧道:
|
||||
|
||||
```bash
|
||||
ssh -N -L 15432:127.0.0.1:5432 <ssh-user>@<server-ip>
|
||||
```
|
||||
|
||||
这会把本机 `127.0.0.1:15432` 转发到 SSH 服务器可访问的 `127.0.0.1:5432`。数据库本身不需要暴露公网端口。
|
||||
|
||||
## Docker 中访问 SSH 隧道
|
||||
|
||||
如果 n8n 或网页容器运行在 Docker 中,容器里的 `127.0.0.1` 指向容器自身,不是宿主机。此时:
|
||||
|
||||
- Linux Docker:让隧道监听宿主机地址,并将 `host.docker.internal` 映射到宿主机网关;
|
||||
- Docker Desktop:通常可直接使用 `host.docker.internal`;
|
||||
- `.env` 中的 `PGHOST` 应填写 `host.docker.internal`,`PGPORT` 填写隧道本地端口(默认 `15432`)。
|
||||
|
||||
本项目不会自动保存或生成 SSH 私钥。建议使用 systemd、Windows 服务或 Docker sidecar 管理 SSH 隧道,并为隧道设置自动重连。
|
||||
|
||||
Linux 云服务器上推荐使用 `docker-compose.host-network.yml`:SSH 隧道监听宿主机 `127.0.0.1:15432`,网页容器使用宿主机网络,`.env` 中填写 `PGHOST=127.0.0.1`、`PGPORT=15432`。这样不会把数据库隧道端口暴露到公网。模板见 `deploy/daily-news-db-tunnel.service.example`。
|
||||
|
||||
## 配置
|
||||
|
||||
复制 `.env.example` 为 `.env`,再在服务器上填写实际值。仓库中不提交 `.env`、密码、SMTP 授权码或 SSH 私钥。
|
||||
|
||||
## 当前完成度
|
||||
|
||||
- 网页日报和历史查询已完成
|
||||
- 收件邮箱、多模块订阅设置已完成
|
||||
- PostgreSQL 访问层和 Docker 部署配置已完成
|
||||
- n8n RSS 抓取、日报生成和 SMTP 发送工作流待接入
|
||||
|
||||
网页和邮件会读取同一份 PostgreSQL 数据,因此当天内容与历史记录保持一致。
|
||||
|
||||
`news_articles.category` 用于主要栏目;`news_articles.topics` 支持一篇文章同时标记多个主题,例如同时属于 `ai_agent` 和 `agri_solutions`。历史搜索由网页服务使用 `ILIKE` 查询标题、摘要和关键词,避免依赖 PostgreSQL 内置的中文分词。
|
||||
|
||||
外文翻译是可选的独立步骤,不等同于 AI 内容分析。`news_articles.title` 和 `summary` 保留原文;`translated_title` 和 `translated_summary` 保存中文翻译。`translation_status` 记录是否翻译成功。翻译服务可以使用阿里云机器翻译、DeepL、Google Cloud Translation,或以后改成本地翻译服务;API 密钥只放在服务器 `.env` 中。
|
||||
|
||||
## 用户和邮件订阅
|
||||
|
||||
`app_users` 保存网页用户账号;密码只保存 Argon2id/bcrypt 哈希,不保存明文密码。`newsletter_recipients` 保存实际收件邮箱,`recipient_modules` 是邮箱与模块的多对多关系,因此一个邮箱可以选择多个模块。`newsletter_modules` 是可扩展的模块目录。
|
||||
|
||||
如果原始 `schema.sql` 已经执行过,请按顺序执行:
|
||||
|
||||
```text
|
||||
db/migrations/002_users_and_subscriptions.sql
|
||||
db/migrations/003_translation_fields.sql
|
||||
```
|
||||
|
||||
不要为了增加用户功能删除已有表。以后新环境从零创建时,直接执行完整的 `db/schema.sql` 即可。
|
||||
|
||||
示例:创建一个收件邮箱并订阅多个模块:
|
||||
|
||||
```sql
|
||||
INSERT INTO newsletter_recipients (email, display_name)
|
||||
VALUES ('your@example.com', '我的日报')
|
||||
RETURNING id;
|
||||
|
||||
-- 将上一步返回的 id 替换为 1
|
||||
INSERT INTO recipient_modules (recipient_id, module_key)
|
||||
VALUES
|
||||
(1, 'ai_agent'),
|
||||
(1, 'agri_solutions'),
|
||||
(1, 'agri_services');
|
||||
```
|
||||
|
||||
## Gitea 部署方式
|
||||
|
||||
项目完成后,将整个 `daily-news-digest` 目录作为一个独立仓库推送到 Gitea。服务器只保存运行所需的工作副本,实际配置文件 `.env` 在服务器本地填写,不提交到 Gitea。
|
||||
|
||||
推荐的第一阶段流程:
|
||||
|
||||
```text
|
||||
本地修改
|
||||
↓
|
||||
提交并推送到 Gitea
|
||||
↓
|
||||
服务器 git pull
|
||||
↓
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
使用 Linux 主机网络时改为:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.host-network.yml up -d --build
|
||||
```
|
||||
|
||||
后续如果启用 Gitea Actions 或 Woodpecker CI,可以把最后两步自动化;在当前没有业务代码、需要先稳定运行的阶段,先采用服务器手动拉取更新更容易排错。
|
||||
|
||||
服务器首次部署时大致是:
|
||||
|
||||
```bash
|
||||
git clone <你的 Gitea 仓库地址> /opt/daily-news-digest
|
||||
cd /opt/daily-news-digest
|
||||
cp .env.example .env
|
||||
# 只在服务器本地填写 .env
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
SSH 隧道、PostgreSQL 密码、SMTP 授权码都不放进仓库;Gitea 只管理源码、SQL、工作流模板和部署文件。
|
||||
|
||||
## 网页和收件人设置
|
||||
|
||||
网页服务默认监听 `3000` 端口。部署前在服务器 `.env` 中填写:
|
||||
|
||||
```env
|
||||
APP_ADMIN_TOKEN=请设置一个随机的长令牌
|
||||
```
|
||||
|
||||
打开 `/settings` 后输入这个令牌,即可添加、编辑和删除收件邮箱,并为每个邮箱选择一个或多个推送模块。令牌只保存在当前浏览器会话中;没有配置令牌时,邮箱管理接口会拒绝请求。
|
||||
|
||||
日报页面地址为 `/`,设置页面地址为 `/settings`。网页只读取 PostgreSQL 中的日报和订阅设置,邮件实际发送仍由后续 n8n 工作流完成。
|
||||
@@ -0,0 +1,85 @@
|
||||
-- Run this migration after the original schema.sql has already been created.
|
||||
-- PostgreSQL 12+; safe to run once on the existing database.
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_users (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
email VARCHAR(320) NOT NULL,
|
||||
display_name VARCHAR(120),
|
||||
password_hash TEXT,
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'user'
|
||||
CHECK (role IN ('user', 'admin')),
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
email_verified_at TIMESTAMPTZ,
|
||||
last_login_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_app_users_email UNIQUE (email)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS newsletter_recipients (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
user_id BIGINT REFERENCES app_users(id) ON DELETE SET NULL,
|
||||
email VARCHAR(320) NOT NULL,
|
||||
display_name VARCHAR(120),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
send_time TIME NOT NULL DEFAULT TIME '07:30',
|
||||
timezone VARCHAR(64) NOT NULL DEFAULT 'Asia/Shanghai',
|
||||
send_all_modules BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_newsletter_recipients_email UNIQUE (email)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS newsletter_modules (
|
||||
module_key VARCHAR(40) PRIMARY KEY,
|
||||
module_name VARCHAR(120) NOT NULL,
|
||||
description TEXT,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
INSERT INTO newsletter_modules (module_key, module_name, description, sort_order)
|
||||
VALUES
|
||||
('general', '综合热点', '国内外综合资讯', 10),
|
||||
('ai_agent', 'AI 与 Agent', '大模型、AI 应用和 Agent', 20),
|
||||
('agri_hardware', '农业硬件', '传感器、无人机、机器人和农机', 30),
|
||||
('agri_solutions', '农业方案落地', '智慧农场、温室、灌溉和数字化方案', 40),
|
||||
('agri_services', '农业服务', '农业 SaaS、农技、托管、供应链和运维服务', 50),
|
||||
('policy_market', '政策与市场', '政策、补贴、投资、招标和市场动态', 60),
|
||||
('domestic', '国内资讯', '国内来源或国内市场相关资讯', 70),
|
||||
('international', '国际资讯', '国际来源或国际市场相关资讯', 80)
|
||||
ON CONFLICT (module_key) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recipient_modules (
|
||||
recipient_id BIGINT NOT NULL REFERENCES newsletter_recipients(id) ON DELETE CASCADE,
|
||||
module_key VARCHAR(40) NOT NULL REFERENCES newsletter_modules(module_key) ON DELETE RESTRICT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (recipient_id, module_key)
|
||||
);
|
||||
|
||||
ALTER TABLE email_deliveries
|
||||
ADD COLUMN IF NOT EXISTS recipient_id BIGINT,
|
||||
ADD COLUMN IF NOT EXISTS modules TEXT[] NOT NULL DEFAULT '{}';
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'fk_email_deliveries_recipient'
|
||||
) THEN
|
||||
ALTER TABLE email_deliveries
|
||||
ADD CONSTRAINT fk_email_deliveries_recipient
|
||||
FOREIGN KEY (recipient_id) REFERENCES newsletter_recipients(id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_newsletter_recipients_user
|
||||
ON newsletter_recipients (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_newsletter_recipients_enabled
|
||||
ON newsletter_recipients (enabled, send_time);
|
||||
CREATE INDEX IF NOT EXISTS idx_recipient_modules_module
|
||||
ON recipient_modules (module_key);
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,28 @@
|
||||
-- Optional machine-translation fields. This does not enable AI analysis.
|
||||
-- Run after 002_users_and_subscriptions.sql.
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE news_articles
|
||||
ADD COLUMN IF NOT EXISTS language_code VARCHAR(16) NOT NULL DEFAULT 'zh',
|
||||
ADD COLUMN IF NOT EXISTS translated_title TEXT,
|
||||
ADD COLUMN IF NOT EXISTS translated_summary TEXT,
|
||||
ADD COLUMN IF NOT EXISTS translation_status VARCHAR(20) NOT NULL DEFAULT 'not_needed',
|
||||
ADD COLUMN IF NOT EXISTS translation_provider VARCHAR(80),
|
||||
ADD COLUMN IF NOT EXISTS translated_at TIMESTAMPTZ;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'news_articles_translation_status_check'
|
||||
) THEN
|
||||
ALTER TABLE news_articles
|
||||
ADD CONSTRAINT news_articles_translation_status_check
|
||||
CHECK (translation_status IN ('not_needed', 'pending', 'translated', 'failed', 'skipped'));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_articles_translation_status
|
||||
ON news_articles (translation_status, language_code);
|
||||
|
||||
COMMIT;
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
-- Daily news digest schema.
|
||||
-- PostgreSQL 12+; no application credentials are stored here.
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS news_sources (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
homepage_url TEXT,
|
||||
feed_url TEXT NOT NULL,
|
||||
region VARCHAR(20) NOT NULL DEFAULT 'international'
|
||||
CHECK (region IN ('domestic', 'international')),
|
||||
category VARCHAR(40) NOT NULL DEFAULT 'general'
|
||||
CHECK (category IN (
|
||||
'general', 'ai_agent', 'agri_hardware',
|
||||
'agri_solutions', 'agri_services', 'policy_market'
|
||||
)),
|
||||
priority SMALLINT NOT NULL DEFAULT 50 CHECK (priority BETWEEN 0 AND 100),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
last_fetched_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_news_sources_feed_url UNIQUE (feed_url)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS daily_reports (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
report_date DATE NOT NULL,
|
||||
title VARCHAR(300) NOT NULL,
|
||||
introduction TEXT,
|
||||
item_count INTEGER NOT NULL DEFAULT 0 CHECK (item_count >= 0),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'draft'
|
||||
CHECK (status IN ('draft', 'published', 'failed')),
|
||||
email_status VARCHAR(20) NOT NULL DEFAULT 'pending'
|
||||
CHECK (email_status IN ('pending', 'sent', 'failed', 'skipped')),
|
||||
email_sent_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_daily_reports_report_date UNIQUE (report_date)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS news_articles (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
source_id BIGINT REFERENCES news_sources(id) ON DELETE SET NULL,
|
||||
external_id TEXT,
|
||||
title TEXT NOT NULL,
|
||||
summary TEXT,
|
||||
url TEXT NOT NULL,
|
||||
author VARCHAR(300),
|
||||
language_code VARCHAR(16) NOT NULL DEFAULT 'zh',
|
||||
translated_title TEXT,
|
||||
translated_summary TEXT,
|
||||
translation_status VARCHAR(20) NOT NULL DEFAULT 'not_needed'
|
||||
CHECK (translation_status IN ('not_needed', 'pending', 'translated', 'failed', 'skipped')),
|
||||
translation_provider VARCHAR(80),
|
||||
translated_at TIMESTAMPTZ,
|
||||
region VARCHAR(20) NOT NULL DEFAULT 'international'
|
||||
CHECK (region IN ('domestic', 'international')),
|
||||
category VARCHAR(40) NOT NULL DEFAULT 'general'
|
||||
CHECK (category IN (
|
||||
'general', 'ai_agent', 'agri_hardware',
|
||||
'agri_solutions', 'agri_services', 'policy_market'
|
||||
)),
|
||||
topics TEXT[] NOT NULL DEFAULT '{}',
|
||||
keywords TEXT[] NOT NULL DEFAULT '{}',
|
||||
importance_score SMALLINT NOT NULL DEFAULT 50 CHECK (importance_score BETWEEN 0 AND 100),
|
||||
published_at TIMESTAMPTZ,
|
||||
discovered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
is_read BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
content_hash CHAR(64),
|
||||
raw_payload JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_news_articles_url UNIQUE (url),
|
||||
CONSTRAINT uq_news_articles_source_external UNIQUE (source_id, external_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS report_articles (
|
||||
report_id BIGINT NOT NULL REFERENCES daily_reports(id) ON DELETE CASCADE,
|
||||
article_id BIGINT NOT NULL REFERENCES news_articles(id) ON DELETE CASCADE,
|
||||
section VARCHAR(40) NOT NULL DEFAULT 'general'
|
||||
CHECK (section IN (
|
||||
'general', 'ai_agent', 'agri_hardware',
|
||||
'agri_solutions', 'agri_services', 'policy_market'
|
||||
)),
|
||||
display_order INTEGER NOT NULL DEFAULT 0 CHECK (display_order >= 0),
|
||||
is_highlight BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (report_id, article_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS email_deliveries (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
report_id BIGINT NOT NULL REFERENCES daily_reports(id) ON DELETE CASCADE,
|
||||
recipient_id BIGINT,
|
||||
recipient VARCHAR(320) NOT NULL,
|
||||
modules TEXT[] NOT NULL DEFAULT '{}',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'sent', 'failed')),
|
||||
provider_id TEXT,
|
||||
error_message TEXT,
|
||||
sent_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Web users. Never store a plaintext password; password_hash is produced by
|
||||
-- the web service using Argon2id or bcrypt.
|
||||
CREATE TABLE IF NOT EXISTS app_users (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
email VARCHAR(320) NOT NULL,
|
||||
display_name VARCHAR(120),
|
||||
password_hash TEXT,
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'user'
|
||||
CHECK (role IN ('user', 'admin')),
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
email_verified_at TIMESTAMPTZ,
|
||||
last_login_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_app_users_email UNIQUE (email)
|
||||
);
|
||||
|
||||
-- A user may have one or more receiving addresses. This also supports a
|
||||
-- standalone subscription that is not linked to a web login (user_id NULL).
|
||||
CREATE TABLE IF NOT EXISTS newsletter_recipients (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
user_id BIGINT REFERENCES app_users(id) ON DELETE SET NULL,
|
||||
email VARCHAR(320) NOT NULL,
|
||||
display_name VARCHAR(120),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
send_time TIME NOT NULL DEFAULT TIME '07:30',
|
||||
timezone VARCHAR(64) NOT NULL DEFAULT 'Asia/Shanghai',
|
||||
send_all_modules BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_newsletter_recipients_email UNIQUE (email)
|
||||
);
|
||||
|
||||
-- Module catalog. Add new modules here without changing the recipient table.
|
||||
CREATE TABLE IF NOT EXISTS newsletter_modules (
|
||||
module_key VARCHAR(40) PRIMARY KEY,
|
||||
module_name VARCHAR(120) NOT NULL,
|
||||
description TEXT,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
INSERT INTO newsletter_modules (module_key, module_name, description, sort_order)
|
||||
VALUES
|
||||
('general', '综合热点', '国内外综合资讯', 10),
|
||||
('ai_agent', 'AI 与 Agent', '大模型、AI 应用和 Agent', 20),
|
||||
('agri_hardware', '农业硬件', '传感器、无人机、机器人和农机', 30),
|
||||
('agri_solutions', '农业方案落地', '智慧农场、温室、灌溉和数字化方案', 40),
|
||||
('agri_services', '农业服务', '农业 SaaS、农技、托管、供应链和运维服务', 50),
|
||||
('policy_market', '政策与市场', '政策、补贴、投资、招标和市场动态', 60),
|
||||
('domestic', '国内资讯', '国内来源或国内市场相关资讯', 70),
|
||||
('international', '国际资讯', '国际来源或国际市场相关资讯', 80)
|
||||
ON CONFLICT (module_key) DO NOTHING;
|
||||
|
||||
-- Many-to-many preference: one recipient can select any number of modules.
|
||||
CREATE TABLE IF NOT EXISTS recipient_modules (
|
||||
recipient_id BIGINT NOT NULL REFERENCES newsletter_recipients(id) ON DELETE CASCADE,
|
||||
module_key VARCHAR(40) NOT NULL REFERENCES newsletter_modules(module_key) ON DELETE RESTRICT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (recipient_id, module_key)
|
||||
);
|
||||
|
||||
ALTER TABLE email_deliveries
|
||||
ADD COLUMN IF NOT EXISTS recipient_id BIGINT,
|
||||
ADD COLUMN IF NOT EXISTS modules TEXT[] NOT NULL DEFAULT '{}';
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'fk_email_deliveries_recipient'
|
||||
) THEN
|
||||
ALTER TABLE email_deliveries
|
||||
ADD CONSTRAINT fk_email_deliveries_recipient
|
||||
FOREIGN KEY (recipient_id) REFERENCES newsletter_recipients(id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_articles_published_at
|
||||
ON news_articles (published_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_articles_category_published
|
||||
ON news_articles (category, published_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_articles_region_published
|
||||
ON news_articles (region, published_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_articles_content_hash
|
||||
ON news_articles (content_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_report_articles_order
|
||||
ON report_articles (report_id, section, display_order);
|
||||
CREATE INDEX IF NOT EXISTS idx_articles_keywords_gin
|
||||
ON news_articles USING GIN (keywords);
|
||||
CREATE INDEX IF NOT EXISTS idx_articles_topics_gin
|
||||
ON news_articles USING GIN (topics);
|
||||
CREATE INDEX IF NOT EXISTS idx_newsletter_recipients_user
|
||||
ON newsletter_recipients (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_newsletter_recipients_enabled
|
||||
ON newsletter_recipients (enabled, send_time);
|
||||
CREATE INDEX IF NOT EXISTS idx_recipient_modules_module
|
||||
ON recipient_modules (module_key);
|
||||
|
||||
-- The web app uses ILIKE for history searches. This is intentional because
|
||||
-- PostgreSQL's built-in text configurations do not segment Chinese reliably.
|
||||
-- A generated tsvector column is deliberately not used: some PostgreSQL
|
||||
-- versions/drivers reject the expression as non-immutable.
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- Optional starter sources. Review the URLs before inserting them.
|
||||
-- INSERT INTO news_sources (name, homepage_url, feed_url, region, category, priority)
|
||||
-- VALUES
|
||||
-- ('Example source', 'https://example.com', 'https://example.com/feed.xml', 'international', 'general', 50);
|
||||
@@ -0,0 +1,21 @@
|
||||
[Unit]
|
||||
Description=Daily News Digest PostgreSQL SSH tunnel
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=YOUR_LINUX_USER
|
||||
ExecStart=/usr/bin/ssh -N -T \
|
||||
-i /etc/daily-news-digest/db-tunnel_ed25519 \
|
||||
-o BatchMode=yes \
|
||||
-o ExitOnForwardFailure=yes \
|
||||
-o ServerAliveInterval=30 \
|
||||
-o ServerAliveCountMax=3 \
|
||||
-L 127.0.0.1:15432:127.0.0.1:5432 \
|
||||
YOUR_SSH_USER@YOUR_DB_SSH_HOST
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,6 @@
|
||||
# Linux cloud-server option: lets the container reach an SSH tunnel bound to
|
||||
# 127.0.0.1 on the host. Use together with docker-compose.yml.
|
||||
services:
|
||||
web:
|
||||
network_mode: host
|
||||
ports: []
|
||||
@@ -0,0 +1,10 @@
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "3000:3000"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "daily-news-digest-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Self-hosted daily news digest dashboard and recipient settings",
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
"start": "node web/server.js",
|
||||
"check": "node --check web/server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"pg": "^8.13.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
const escapeHtml = (value) => String(value ?? '').replace(/[&<>'"]/g, (char) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char]));
|
||||
const categoryNames = { general: '综合', ai_agent: 'AI 与 Agent', agri_hardware: '农业硬件', agri_solutions: '方案落地', agri_services: '农业服务', policy_market: '政策市场' };
|
||||
|
||||
function today() { return new Date().toISOString().slice(0, 10); }
|
||||
function formatDate(value) { return value ? new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: 'long', day: 'numeric', weekday: 'short' }).format(new Date(`${value}T00:00:00`)) : '—'; }
|
||||
function formatTime(value) { return value ? new Intl.DateTimeFormat('zh-CN', { month: 'short', day: 'numeric' }).format(new Date(value)) : '近期'; }
|
||||
|
||||
async function loadHealth() {
|
||||
try {
|
||||
const response = await fetch('/api/health');
|
||||
const label = $('#healthLabel');
|
||||
if (response.ok) label.innerHTML = '<span class="status-dot"></span>数据库连接正常';
|
||||
else throw new Error();
|
||||
} catch { $('#healthLabel').innerHTML = '<span class="status-dot error"></span>数据库暂不可用'; }
|
||||
}
|
||||
|
||||
function renderArticle(article) {
|
||||
const title = article.translated_title || article.title || '无标题';
|
||||
const summary = article.translated_summary || article.summary || '暂无摘要';
|
||||
const category = categoryNames[article.section] || categoryNames[article.category] || '综合';
|
||||
return `<article class="article">
|
||||
<div class="article-meta"><span class="tag">${escapeHtml(category)}</span><span>${escapeHtml(article.source_name || '未知来源')}</span><span>·</span><span>${escapeHtml(formatTime(article.published_at))}</span></div>
|
||||
<h3><a href="${escapeHtml(article.url)}" target="_blank" rel="noreferrer">${escapeHtml(title)}</a></h3>
|
||||
<p>${escapeHtml(summary)}</p>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function renderReports(payload) {
|
||||
$('#reportCount').textContent = payload.total ?? 0;
|
||||
const reports = payload.reports || [];
|
||||
const articleCount = reports.reduce((sum, report) => sum + (report.articles || []).length, 0);
|
||||
$('#articleCount').textContent = articleCount;
|
||||
$('#sourceCount').textContent = new Set(reports.flatMap((report) => (report.articles || []).map((item) => item.source_name).filter(Boolean))).size;
|
||||
if (!reports.length) {
|
||||
$('#reportList').innerHTML = '<div class="empty-state"><strong>还没有符合条件的日报</strong><span>n8n 完成第一次抓取后,内容会出现在这里。</span></div>';
|
||||
return;
|
||||
}
|
||||
$('#reportList').innerHTML = reports.map((report) => `<article class="report-card card">
|
||||
<div class="report-top"><div><div class="report-date">${escapeHtml(formatDate(report.report_date))}</div><h2>${escapeHtml(report.title)}</h2><p class="report-intro">${escapeHtml(report.introduction || '今日资讯已按主题整理。')}</p></div><span class="report-status">${report.status === 'published' ? '已发布' : '草稿'}</span></div>
|
||||
<div class="article-grid">${(report.articles || []).map(renderArticle).join('')}</div>
|
||||
</article>`).join('');
|
||||
}
|
||||
|
||||
async function loadReports() {
|
||||
const params = new URLSearchParams();
|
||||
const date = $('#dateInput').value;
|
||||
const q = $('#searchInput').value.trim();
|
||||
const category = $('#categoryInput').value;
|
||||
if (date) params.set('date', date);
|
||||
if (q) params.set('q', q);
|
||||
if (category) params.set('category', category);
|
||||
$('#reportList').innerHTML = '<div class="empty-state"><span>正在读取日报…</span></div>';
|
||||
try {
|
||||
const response = await fetch(`/api/reports?${params}`);
|
||||
const payload = await response.json();
|
||||
if (!response.ok) throw new Error(payload.error || '读取失败');
|
||||
renderReports(payload);
|
||||
} catch (error) {
|
||||
$('#reportList').innerHTML = `<div class="empty-state"><strong>暂时无法读取日报</strong><span>${escapeHtml(error.message)}</span></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
$('#todayLabel').textContent = formatDate(today());
|
||||
$('#dateInput').value = today();
|
||||
$('#dateInput').addEventListener('change', loadReports);
|
||||
$('#categoryInput').addEventListener('change', loadReports);
|
||||
let searchTimer;
|
||||
$('#searchInput').addEventListener('input', () => { clearTimeout(searchTimer); searchTimer = setTimeout(loadReports, 280); });
|
||||
$('#clearFilters').addEventListener('click', () => { $('#dateInput').value = ''; $('#categoryInput').value = ''; $('#searchInput').value = ''; loadReports(); });
|
||||
loadHealth();
|
||||
loadReports();
|
||||
@@ -0,0 +1,62 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#123c34">
|
||||
<title>每日情报 · Daily Digest</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<a class="brand" href="/">
|
||||
<span class="brand-mark">D</span>
|
||||
<span><strong>Daily Digest</strong><small>每日行业情报</small></span>
|
||||
</a>
|
||||
<nav><a class="nav-link active" href="/">日报</a><a class="nav-link" href="/settings">设置管理</a></nav>
|
||||
</header>
|
||||
|
||||
<main class="shell">
|
||||
<section class="hero">
|
||||
<div class="hero-copy">
|
||||
<div class="eyebrow"><span class="live-dot"></span> DAILY SIGNALS</div>
|
||||
<h1>今天值得读的<br><em>行业情报</em></h1>
|
||||
<p>把 AI、Agent 与农业产业链的重要动态,按主题整理成一份可追踪的日报。</p>
|
||||
</div>
|
||||
<div class="hero-side">
|
||||
<div class="date-label">今日日期</div>
|
||||
<div id="todayLabel" class="today-date">—</div>
|
||||
<div id="healthLabel" class="health-label"><span class="status-dot"></span>正在检查数据库</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="toolbar card">
|
||||
<div class="toolbar-heading"><span class="section-kicker">ARCHIVE</span><h2>日报档案</h2></div>
|
||||
<div class="filters">
|
||||
<label class="search-box"><span>⌕</span><input id="searchInput" type="search" placeholder="搜索标题、摘要或关键词" aria-label="搜索日报"></label>
|
||||
<label class="date-box"><span>日期</span><input id="dateInput" type="date" aria-label="选择日期"></label>
|
||||
<select id="categoryInput" aria-label="选择栏目">
|
||||
<option value="">全部栏目</option>
|
||||
<option value="ai_agent">AI 与 Agent</option>
|
||||
<option value="agri_hardware">农业硬件</option>
|
||||
<option value="agri_solutions">农业方案落地</option>
|
||||
<option value="agri_services">农业服务</option>
|
||||
<option value="policy_market">政策与市场</option>
|
||||
</select>
|
||||
<button id="clearFilters" class="button button-ghost" type="button">清除</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="summary-grid" aria-label="日报统计">
|
||||
<div class="stat-card"><span class="stat-label">报告数量</span><strong id="reportCount">—</strong><small>按筛选条件</small></div>
|
||||
<div class="stat-card"><span class="stat-label">今日文章</span><strong id="articleCount">—</strong><small>已收录资讯</small></div>
|
||||
<div class="stat-card accent-card"><span class="stat-label">信息状态</span><strong id="sourceCount">—</strong><small>来源持续更新中</small></div>
|
||||
</section>
|
||||
|
||||
<section id="reportList" class="report-list" aria-live="polite"></section>
|
||||
</main>
|
||||
|
||||
<footer class="footer"><span>DAILY DIGEST</span><span>资讯原文版权归原作者所有</span></footer>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,49 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#123c34">
|
||||
<title>设置管理 · Daily Digest</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<a class="brand" href="/"><span class="brand-mark">D</span><span><strong>Daily Digest</strong><small>每日行业情报</small></span></a>
|
||||
<nav><a class="nav-link" href="/">日报</a><a class="nav-link active" href="/settings">设置管理</a></nav>
|
||||
</header>
|
||||
|
||||
<main class="shell settings-shell">
|
||||
<section class="page-heading"><div class="eyebrow">CONTROL ROOM</div><h1>推送设置</h1><p>管理日报收件邮箱,为每个邮箱选择一个或多个内容模块。</p></section>
|
||||
|
||||
<section class="token-card card">
|
||||
<div><span class="section-kicker">PRIVATE ACCESS</span><h2>管理令牌</h2><p>令牌只保存在当前浏览器会话中,用于保护邮箱管理接口。</p></div>
|
||||
<div class="token-controls"><input id="tokenInput" type="password" autocomplete="off" placeholder="输入服务器 APP_ADMIN_TOKEN"><button id="saveToken" class="button button-dark" type="button">保存令牌</button></div>
|
||||
<div id="tokenMessage" class="form-message" role="status"></div>
|
||||
</section>
|
||||
|
||||
<div class="settings-grid">
|
||||
<section class="card form-card">
|
||||
<div class="card-heading"><div><span class="section-kicker">RECIPIENT</span><h2 id="formTitle">添加收件邮箱</h2></div><button id="cancelEdit" class="text-button hidden" type="button">取消编辑</button></div>
|
||||
<form id="recipientForm">
|
||||
<input id="recipientId" type="hidden">
|
||||
<label>邮箱地址<input id="emailInput" type="email" required placeholder="name@example.com"></label>
|
||||
<label>显示名称<span class="optional">可选</span><input id="nameInput" type="text" maxlength="120" placeholder="我的日报"></label>
|
||||
<div class="form-row"><label>发送时间<input id="timeInput" type="time" value="07:30"></label><label>时区<input id="timezoneInput" type="text" value="Asia/Shanghai"></label></div>
|
||||
<label class="check-line"><input id="allModulesInput" type="checkbox"><span>订阅全部模块</span></label>
|
||||
<div class="module-picker"><div class="picker-label">选择推送模块 <span>可多选</span></div><div id="moduleOptions" class="module-options"></div></div>
|
||||
<button class="button button-primary full-button" type="submit"><span id="submitLabel">保存邮箱设置</span><span>↗</span></button>
|
||||
<div id="formMessage" class="form-message" role="alert"></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card recipients-card">
|
||||
<div class="card-heading"><div><span class="section-kicker">DELIVERY LIST</span><h2>已配置邮箱</h2></div><span id="recipientCount" class="count-pill">0</span></div>
|
||||
<div id="recipientList" class="recipient-list"><div class="loading-line">正在加载…</div></div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
<footer class="footer"><span>DAILY DIGEST</span><span>只向已启用邮箱发送日报</span></footer>
|
||||
<script src="/settings.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,62 @@
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
const escapeHtml = (value) => String(value ?? '').replace(/[&<>'"]/g, (char) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char]));
|
||||
let modules = [];
|
||||
let editingId = null;
|
||||
|
||||
function token() { return sessionStorage.getItem('daily_digest_admin_token') || ''; }
|
||||
function headers(json = false) { const value = { 'X-Admin-Token': token() }; if (json) value['Content-Type'] = 'application/json'; return value; }
|
||||
function message(selector, text, type = '') { const el = $(selector); el.textContent = text; el.className = `form-message ${type}`; }
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const response = await fetch(path, { ...options, headers: { ...headers(Boolean(options.body)), ...(options.headers || {}) } });
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error || `请求失败 (${response.status})`);
|
||||
return payload;
|
||||
}
|
||||
|
||||
function renderModuleOptions(selected = []) {
|
||||
$('#moduleOptions').innerHTML = modules.map((module) => `<label class="module-option"><input type="checkbox" value="${escapeHtml(module.module_key)}" ${selected.includes(module.module_key) ? 'checked' : ''}><span>${escapeHtml(module.module_name)}</span></label>`).join('');
|
||||
}
|
||||
function selectedModules() { return [...document.querySelectorAll('#moduleOptions input:checked')].map((input) => input.value); }
|
||||
|
||||
function renderRecipients(recipients) {
|
||||
$('#recipientCount').textContent = recipients.length;
|
||||
if (!recipients.length) { $('#recipientList').innerHTML = '<div class="empty-state"><strong>还没有收件邮箱</strong><span>在左侧添加第一个日报收件人。</span></div>'; return; }
|
||||
const names = Object.fromEntries(modules.map((module) => [module.module_key, module.module_name]));
|
||||
$('#recipientList').innerHTML = recipients.map((recipient) => `<div class="recipient-item">
|
||||
<div><div class="recipient-email">${escapeHtml(recipient.email)} ${recipient.enabled ? '' : '<span class="tag">已停用</span>'}</div><div class="recipient-sub">${escapeHtml(recipient.display_name || '未设置名称')} · 每天 ${escapeHtml(String(recipient.send_time).slice(0, 5))} · ${escapeHtml(recipient.timezone)}</div><div class="chips">${recipient.send_all_modules ? '<span class="chip">全部模块</span>' : (recipient.modules || []).map((key) => `<span class="chip">${escapeHtml(names[key] || key)}</span>`).join('')}</div></div>
|
||||
<div class="recipient-actions"><button class="icon-button" type="button" data-edit="${recipient.id}" title="编辑">✎</button><button class="icon-button delete" type="button" data-delete="${recipient.id}" title="删除">×</button></div>
|
||||
</div>`).join('');
|
||||
document.querySelectorAll('[data-edit]').forEach((button) => button.addEventListener('click', () => editRecipient(recipients.find((item) => item.id === Number(button.dataset.edit)))));
|
||||
document.querySelectorAll('[data-delete]').forEach((button) => button.addEventListener('click', () => removeRecipient(Number(button.dataset.delete))));
|
||||
}
|
||||
|
||||
async function loadRecipients() {
|
||||
try { renderRecipients((await api('/api/recipients')).recipients); }
|
||||
catch (error) { $('#recipientList').innerHTML = `<div class="empty-state"><strong>无法读取邮箱设置</strong><span>${escapeHtml(error.message)}</span></div>`; }
|
||||
}
|
||||
|
||||
function editRecipient(recipient) {
|
||||
editingId = recipient.id; $('#formTitle').textContent = '编辑收件邮箱'; $('#submitLabel').textContent = '保存修改'; $('#cancelEdit').classList.remove('hidden');
|
||||
$('#recipientId').value = recipient.id; $('#emailInput').value = recipient.email; $('#emailInput').disabled = true; $('#nameInput').value = recipient.display_name || ''; $('#timeInput').value = String(recipient.send_time).slice(0, 5); $('#timezoneInput').value = recipient.timezone || 'Asia/Shanghai'; $('#allModulesInput').checked = recipient.send_all_modules; renderModuleOptions(recipient.modules || []); window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
function resetForm() { editingId = null; $('#formTitle').textContent = '添加收件邮箱'; $('#submitLabel').textContent = '保存邮箱设置'; $('#cancelEdit').classList.add('hidden'); $('#recipientForm').reset(); $('#emailInput').disabled = false; $('#timeInput').value = '07:30'; $('#timezoneInput').value = 'Asia/Shanghai'; renderModuleOptions([]); message('#formMessage', ''); }
|
||||
|
||||
async function removeRecipient(id) {
|
||||
if (!window.confirm('确定删除这个收件邮箱吗?历史发送记录会保留,但以后不会再发送。')) return;
|
||||
try { await api(`/api/recipients/${id}`, { method: 'DELETE' }); message('#tokenMessage', '邮箱已删除', 'success'); await loadRecipients(); }
|
||||
catch (error) { message('#tokenMessage', error.message, 'error'); }
|
||||
}
|
||||
|
||||
$('#saveToken').addEventListener('click', async () => { const value = $('#tokenInput').value.trim(); if (!value) return message('#tokenMessage', '请输入管理令牌', 'error'); sessionStorage.setItem('daily_digest_admin_token', value); message('#tokenMessage', '令牌已保存,正在验证…'); await loadRecipients(); });
|
||||
$('#tokenInput').value = token();
|
||||
$('#cancelEdit').addEventListener('click', resetForm);
|
||||
$('#allModulesInput').addEventListener('change', (event) => { document.querySelectorAll('#moduleOptions input').forEach((input) => { input.disabled = event.target.checked; }); });
|
||||
$('#recipientForm').addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const body = { email: $('#emailInput').value.trim(), display_name: $('#nameInput').value.trim(), send_time: $('#timeInput').value, timezone: $('#timezoneInput').value.trim(), send_all_modules: $('#allModulesInput').checked, modules: selectedModules() };
|
||||
try { await api(editingId ? `/api/recipients/${editingId}` : '/api/recipients', { method: editingId ? 'PUT' : 'POST', body: JSON.stringify(body) }); message('#formMessage', editingId ? '邮箱设置已更新' : '邮箱已添加', 'success'); resetForm(); await loadRecipients(); }
|
||||
catch (error) { message('#formMessage', error.message, 'error'); }
|
||||
});
|
||||
|
||||
(async function init() { try { modules = (await fetch('/api/modules').then((response) => response.json())).modules || []; renderModuleOptions(); await loadRecipients(); } catch (error) { message('#tokenMessage', error.message, 'error'); } })();
|
||||
@@ -0,0 +1,88 @@
|
||||
:root {
|
||||
--ink: #17342e;
|
||||
--muted: #70827d;
|
||||
--line: #dbe6e0;
|
||||
--paper: #f6f8f5;
|
||||
--card: #ffffff;
|
||||
--green: #1d594c;
|
||||
--green-dark: #123c34;
|
||||
--mint: #cfe7d9;
|
||||
--orange: #e57b4b;
|
||||
--shadow: 0 14px 36px rgba(31, 67, 56, .07);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--paper); color: var(--ink); font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; line-height: 1.55; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
button, input, select { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
.topbar { height: 76px; padding: 0 max(24px, calc((100vw - 1180px) / 2)); display: flex; align-items: center; justify-content: space-between; background: rgba(246,248,245,.9); border-bottom: 1px solid var(--line); }
|
||||
.brand { display: inline-flex; align-items: center; gap: 11px; }
|
||||
.brand-mark { display: grid; place-items: center; width: 33px; height: 33px; border-radius: 9px; color: #fff; background: var(--green-dark); font-weight: 800; letter-spacing: -.08em; }
|
||||
.brand strong, .brand small { display: block; line-height: 1.15; }
|
||||
.brand strong { font-size: 14px; letter-spacing: .04em; }
|
||||
.brand small { margin-top: 3px; color: var(--muted); font-size: 10px; letter-spacing: .12em; }
|
||||
nav { display: flex; gap: 8px; }
|
||||
.nav-link { padding: 9px 14px; border-radius: 10px; color: var(--muted); font-size: 13px; }
|
||||
.nav-link:hover, .nav-link.active { color: var(--green-dark); background: #e9f1eb; }
|
||||
.shell { width: min(1180px, calc(100% - 48px)); margin: 0 auto; padding: 54px 0 72px; }
|
||||
.hero { display: flex; justify-content: space-between; gap: 32px; padding: 26px 32px 34px; min-height: 238px; border-radius: 25px; color: #fff; background: var(--green-dark); overflow: hidden; position: relative; box-shadow: var(--shadow); }
|
||||
.hero::after { content: ""; width: 350px; height: 350px; position: absolute; right: -80px; bottom: -210px; border: 1px solid rgba(211,241,220,.24); border-radius: 50%; box-shadow: 0 0 0 36px rgba(211,241,220,.05), 0 0 0 72px rgba(211,241,220,.04); }
|
||||
.hero-copy, .hero-side { position: relative; z-index: 1; }
|
||||
.eyebrow, .section-kicker { color: var(--orange); font-size: 10px; font-weight: 800; letter-spacing: .2em; }
|
||||
.hero h1 { margin: 14px 0 10px; font-size: clamp(34px, 5vw, 56px); line-height: .98; letter-spacing: -.06em; }
|
||||
.hero h1 em { color: #bde7ca; font-style: normal; }
|
||||
.hero p { max-width: 470px; margin: 0; color: #b7d1c4; font-size: 14px; }
|
||||
.live-dot { display: inline-block; width: 7px; height: 7px; margin-right: 6px; border-radius: 50%; background: #8ee4a9; box-shadow: 0 0 0 5px rgba(142,228,169,.12); }
|
||||
.hero-side { align-self: flex-end; min-width: 180px; padding: 14px 0 0 30px; border-left: 1px solid rgba(207,231,217,.24); }
|
||||
.date-label { color: #8fb7a7; font-size: 11px; letter-spacing: .12em; }
|
||||
.today-date { margin: 3px 0 10px; font-size: 21px; font-weight: 700; }
|
||||
.health-label { color: #a9cfba; font-size: 11px; }
|
||||
.status-dot { display: inline-block; width: 6px; height: 6px; margin-right: 5px; border-radius: 50%; background: #8ee4a9; }
|
||||
.status-dot.error { background: #ff9e7c; }
|
||||
.card { background: var(--card); border: 1px solid var(--line); border-radius: 18px; box-shadow: var(--shadow); }
|
||||
.toolbar { display: flex; gap: 24px; align-items: center; justify-content: space-between; margin-top: -28px; padding: 19px 22px; position: relative; z-index: 2; }
|
||||
.toolbar-heading h2, .card-heading h2, .token-card h2 { margin: 3px 0 0; font-size: 19px; letter-spacing: -.03em; }
|
||||
.filters { display: flex; flex: 1; justify-content: flex-end; gap: 8px; }
|
||||
.search-box, .date-box, select { height: 40px; display: flex; align-items: center; border: 1px solid var(--line); border-radius: 9px; background: #fbfcfb; color: var(--muted); font-size: 12px; }
|
||||
.search-box { width: min(260px, 32vw); padding: 0 11px; gap: 7px; }
|
||||
.search-box span { font-size: 21px; line-height: 0; }
|
||||
.search-box input, .date-box input, select { min-width: 0; border: 0; outline: 0; color: var(--ink); background: transparent; }
|
||||
.search-box input { width: 100%; }
|
||||
.date-box { padding: 0 9px; gap: 5px; }
|
||||
.date-box input { width: 120px; font-size: 12px; }
|
||||
select { padding: 0 9px; }
|
||||
.button { border: 0; border-radius: 9px; padding: 10px 14px; font-size: 12px; font-weight: 700; transition: transform .15s, background .15s; }
|
||||
.button:hover { transform: translateY(-1px); }
|
||||
.button-primary { display: flex; justify-content: space-between; align-items: center; color: #fff; background: var(--green); }
|
||||
.button-dark { color: #fff; background: var(--green-dark); }
|
||||
.button-ghost { color: var(--green); background: #edf4ef; }
|
||||
.full-button { width: 100%; margin-top: 23px; }
|
||||
.summary-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 13px; margin: 21px 0 30px; }
|
||||
.stat-card { padding: 19px 21px; border: 1px solid var(--line); border-radius: 15px; background: #fff; }
|
||||
.stat-card strong { display: block; margin: 4px 0 0; font-size: 28px; letter-spacing: -.05em; }
|
||||
.stat-label, .stat-card small { color: var(--muted); font-size: 11px; }
|
||||
.stat-card small { display: block; margin-top: 2px; }
|
||||
.accent-card { background: #e5f1e7; border-color: #c5dfcb; }
|
||||
.report-list { display: grid; gap: 15px; }
|
||||
.report-card { padding: 25px 27px; }
|
||||
.report-top { display: flex; justify-content: space-between; gap: 20px; align-items: flex-start; border-bottom: 1px solid var(--line); padding-bottom: 17px; }
|
||||
.report-date { color: var(--muted); font-size: 12px; letter-spacing: .08em; }
|
||||
.report-card h2 { margin: 4px 0 5px; font-size: 22px; letter-spacing: -.04em; }
|
||||
.report-intro { margin: 0; color: var(--muted); font-size: 13px; }
|
||||
.report-status { padding: 5px 9px; border-radius: 99px; color: var(--green); background: #e4f1e8; font-size: 10px; white-space: nowrap; }
|
||||
.article-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1px 28px; margin-top: 11px; }
|
||||
.article { padding: 16px 0; border-bottom: 1px solid #edf1ee; }
|
||||
.article-meta { display: flex; gap: 7px; align-items: center; color: var(--muted); font-size: 10px; }
|
||||
.tag { display: inline-block; padding: 3px 7px; border-radius: 5px; color: var(--green); background: #e8f2ea; font-weight: 700; }
|
||||
.article h3 { margin: 7px 0 5px; font-size: 15px; line-height: 1.4; }
|
||||
.article h3 a:hover { color: var(--orange); }
|
||||
.article p { display: -webkit-box; margin: 0; overflow: hidden; color: var(--muted); font-size: 12px; line-height: 1.6; -webkit-box-orient: vertical; -webkit-line-clamp: 3; }
|
||||
.empty-state { padding: 64px 24px; text-align: center; border: 1px dashed #cbdad0; border-radius: 18px; color: var(--muted); background: rgba(255,255,255,.56); }
|
||||
.empty-state strong { display: block; margin-bottom: 5px; color: var(--ink); }
|
||||
.loading-line { color: var(--muted); font-size: 13px; }
|
||||
.footer { display: flex; justify-content: space-between; width: min(1180px, calc(100% - 48px)); margin: 0 auto; padding: 0 0 28px; color: #94a49e; font-size: 10px; letter-spacing: .1em; }
|
||||
.page-heading { margin: 8px 0 31px; }.page-heading h1 { margin: 9px 0 4px; font-size: 42px; letter-spacing: -.06em; }.page-heading p { margin: 0; color: var(--muted); font-size: 14px; }
|
||||
.settings-shell { padding-top: 46px; }.token-card { display: grid; grid-template-columns: 1fr minmax(310px, 430px); gap: 20px; align-items: center; padding: 22px 25px; margin-bottom: 18px; }.token-card p { margin: 7px 0 0; color: var(--muted); font-size: 12px; }.token-controls { display: flex; gap: 8px; }.token-controls input, .form-card input[type="email"], .form-card input[type="text"], .form-card input[type="time"] { min-width: 0; width: 100%; height: 40px; padding: 0 11px; border: 1px solid var(--line); border-radius: 8px; outline: none; color: var(--ink); background: #fbfcfb; }.token-controls input:focus, .form-card input:focus { border-color: #8ebda0; box-shadow: 0 0 0 3px #e8f2eb; }.settings-grid { display: grid; grid-template-columns: .88fr 1.12fr; gap: 18px; }.form-card, .recipients-card { padding: 24px; }.card-heading { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 23px; }.form-card form > label, .form-row label { display: block; margin-bottom: 15px; color: var(--ink); font-size: 12px; font-weight: 700; }.form-card label input { display: block; margin-top: 6px; }.optional, .picker-label span { margin-left: 5px; color: var(--muted); font-size: 10px; font-weight: 400; }.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }.check-line { display: flex !important; gap: 8px; align-items: center; font-weight: 600 !important; }.check-line input { accent-color: var(--green); }.module-picker { padding: 14px; border-radius: 11px; background: #f4f8f4; }.picker-label { margin-bottom: 9px; font-size: 12px; font-weight: 700; }.module-options { display: flex; flex-wrap: wrap; gap: 7px; }.module-option { position: relative; }.module-option input { position: absolute; opacity: 0; }.module-option span { display: block; padding: 7px 9px; border: 1px solid #d5e4d8; border-radius: 7px; color: #5d7169; background: #fff; font-size: 11px; cursor: pointer; }.module-option input:checked + span { border-color: #9bc7a8; color: var(--green-dark); background: #dcefe0; font-weight: 700; }.text-button { border: 0; color: var(--green); background: transparent; font-size: 12px; }.hidden { display: none !important; }.form-message { min-height: 18px; margin-top: 9px; color: var(--muted); font-size: 11px; }.form-message.error { color: #b74d37; }.form-message.success { color: var(--green); }.count-pill { min-width: 25px; padding: 3px 8px; border-radius: 20px; color: var(--green); background: #e6f1e8; font-size: 11px; text-align: center; }.recipient-list { display: grid; gap: 10px; }.recipient-item { display: flex; justify-content: space-between; gap: 12px; padding: 14px 0; border-bottom: 1px solid #edf1ee; }.recipient-item:last-child { border-bottom: 0; }.recipient-email { font-size: 13px; font-weight: 700; }.recipient-sub { margin-top: 4px; color: var(--muted); font-size: 11px; }.chips { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px; }.chip { padding: 3px 6px; border-radius: 5px; color: var(--green); background: #edf5ee; font-size: 10px; }.recipient-actions { display: flex; align-items: center; gap: 7px; }.icon-button { width: 30px; height: 30px; border: 1px solid var(--line); border-radius: 7px; color: var(--muted); background: #fff; }.icon-button:hover { color: var(--green); border-color: #9bc7a8; }.icon-button.delete:hover { color: #bd523c; border-color: #e9b0a1; }
|
||||
@media (max-width: 840px) { .hero { min-height: auto; flex-direction: column; }.hero-side { align-self: flex-start; padding: 14px 0 0; border-left: 0; border-top: 1px solid rgba(207,231,217,.24); width: 100%; }.toolbar { align-items: flex-start; flex-direction: column; margin-top: 15px; }.filters { width: 100%; justify-content: flex-start; flex-wrap: wrap; }.search-box { flex: 1; width: auto; }.settings-grid { grid-template-columns: 1fr; }.token-card { grid-template-columns: 1fr; }.article-grid { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 540px) { .topbar { height: 66px; padding: 0 18px; }.nav-link { padding: 8px; font-size: 11px; }.shell { width: calc(100% - 28px); padding-top: 30px; }.hero { padding: 24px 22px 26px; border-radius: 20px; }.hero h1 { font-size: 39px; }.summary-grid { gap: 7px; }.stat-card { padding: 13px 12px; }.stat-card strong { font-size: 22px; }.stat-card small { font-size: 9px; }.report-card { padding: 20px 17px; }.report-top { flex-direction: column; gap: 9px; }.footer { width: calc(100% - 28px); }.page-heading h1 { font-size: 35px; }.token-card, .form-card, .recipients-card { padding: 19px; }.token-controls { flex-direction: column; } }
|
||||
+316
@@ -0,0 +1,316 @@
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { URL } = require('url');
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const PORT = Number(process.env.APP_PORT || 3000);
|
||||
const PUBLIC_DIR = path.join(__dirname, 'public');
|
||||
const ADMIN_TOKEN = process.env.APP_ADMIN_TOKEN || '';
|
||||
|
||||
const pool = new Pool({
|
||||
host: process.env.PGHOST || '127.0.0.1',
|
||||
port: Number(process.env.PGPORT || 5432),
|
||||
database: process.env.PGDATABASE,
|
||||
user: process.env.PGUSER,
|
||||
password: process.env.PGPASSWORD,
|
||||
ssl: process.env.PGSSLMODE === 'require' ? { rejectUnauthorized: false } : undefined,
|
||||
max: 5,
|
||||
idleTimeoutMillis: 30000,
|
||||
});
|
||||
|
||||
const MIME_TYPES = {
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
};
|
||||
|
||||
function sendJson(res, status, payload) {
|
||||
const body = JSON.stringify(payload);
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
function sendError(res, status, message) {
|
||||
sendJson(res, status, { error: message });
|
||||
}
|
||||
|
||||
function requireAdmin(req, res) {
|
||||
if (!ADMIN_TOKEN) {
|
||||
sendError(res, 503, 'APP_ADMIN_TOKEN 尚未配置');
|
||||
return false;
|
||||
}
|
||||
const supplied = req.headers['x-admin-token'] || '';
|
||||
const expected = Buffer.from(ADMIN_TOKEN);
|
||||
const actual = Buffer.from(String(supplied));
|
||||
if (expected.length !== actual.length || !crypto.timingSafeEqual(expected, actual)) {
|
||||
sendError(res, 401, '管理令牌无效');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = '';
|
||||
req.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
if (data.length > 1024 * 1024) reject(new Error('请求内容过大'));
|
||||
});
|
||||
req.on('end', () => {
|
||||
if (!data) return resolve({});
|
||||
try {
|
||||
resolve(JSON.parse(data));
|
||||
} catch {
|
||||
reject(new Error('请求必须是 JSON'));
|
||||
}
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function validateEmail(value) {
|
||||
return typeof value === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
|
||||
}
|
||||
|
||||
function normalizeModules(modules) {
|
||||
if (!Array.isArray(modules)) return [];
|
||||
return [...new Set(modules.filter((item) => typeof item === 'string').map((item) => item.trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
async function listModules() {
|
||||
const result = await pool.query(
|
||||
'SELECT module_key, module_name, description, sort_order FROM newsletter_modules WHERE enabled = TRUE ORDER BY sort_order, module_key',
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async function listRecipients() {
|
||||
const result = await pool.query(`
|
||||
SELECT r.id, r.email, r.display_name, r.enabled, r.send_time, r.timezone,
|
||||
r.send_all_modules,
|
||||
COALESCE(array_agg(rm.module_key ORDER BY rm.module_key)
|
||||
FILTER (WHERE rm.module_key IS NOT NULL), '{}') AS modules
|
||||
FROM newsletter_recipients r
|
||||
LEFT JOIN recipient_modules rm ON rm.recipient_id = r.id
|
||||
GROUP BY r.id
|
||||
ORDER BY r.enabled DESC, r.email
|
||||
`);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async function createRecipient(body) {
|
||||
const email = String(body.email || '').trim().toLowerCase();
|
||||
if (!validateEmail(email)) throw new Error('请输入有效邮箱');
|
||||
const modules = normalizeModules(body.modules);
|
||||
if (!body.send_all_modules && modules.length === 0) throw new Error('至少选择一个推送模块');
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const result = await client.query(`
|
||||
INSERT INTO newsletter_recipients
|
||||
(email, display_name, send_time, timezone, send_all_modules)
|
||||
VALUES ($1, NULLIF($2, ''), COALESCE(NULLIF($3, '')::time, TIME '07:30'),
|
||||
COALESCE(NULLIF($4, ''), 'Asia/Shanghai'), $5)
|
||||
RETURNING id
|
||||
`, [email, String(body.display_name || '').trim(), String(body.send_time || ''), String(body.timezone || ''), Boolean(body.send_all_modules)]);
|
||||
const recipientId = result.rows[0].id;
|
||||
if (modules.length) {
|
||||
await client.query(
|
||||
'INSERT INTO recipient_modules (recipient_id, module_key) SELECT $1, unnest($2::text[]) ON CONFLICT DO NOTHING',
|
||||
[recipientId, modules],
|
||||
);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
return recipientId;
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
if (error.code === '23505') throw new Error('这个邮箱已经存在');
|
||||
if (error.code === '23503') throw new Error('选择了不存在的模块');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function updateRecipient(id, body) {
|
||||
const modules = normalizeModules(body.modules);
|
||||
if (!body.send_all_modules && modules.length === 0) throw new Error('至少选择一个推送模块');
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const result = await client.query(`
|
||||
UPDATE newsletter_recipients
|
||||
SET display_name = NULLIF($2, ''),
|
||||
enabled = COALESCE($3, enabled),
|
||||
send_time = COALESCE(NULLIF($4, '')::time, send_time),
|
||||
timezone = COALESCE(NULLIF($5, ''), timezone),
|
||||
send_all_modules = $6,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id
|
||||
`, [id, String(body.display_name || '').trim(), body.enabled === undefined ? null : Boolean(body.enabled), String(body.send_time || ''), String(body.timezone || ''), Boolean(body.send_all_modules)]);
|
||||
if (!result.rowCount) throw new Error('收件邮箱不存在');
|
||||
await client.query('DELETE FROM recipient_modules WHERE recipient_id = $1', [id]);
|
||||
if (modules.length) {
|
||||
await client.query(
|
||||
'INSERT INTO recipient_modules (recipient_id, module_key) SELECT $1, unnest($2::text[]) ON CONFLICT DO NOTHING',
|
||||
[id, modules],
|
||||
);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRecipient(id) {
|
||||
const result = await pool.query('DELETE FROM newsletter_recipients WHERE id = $1 RETURNING id', [id]);
|
||||
if (!result.rowCount) throw new Error('收件邮箱不存在');
|
||||
}
|
||||
|
||||
async function listReports(query) {
|
||||
const page = Math.max(1, Number(query.get('page') || 1));
|
||||
const pageSize = Math.min(30, Math.max(1, Number(query.get('page_size') || 10)));
|
||||
const params = [];
|
||||
const conditions = [];
|
||||
const date = query.get('date');
|
||||
const q = query.get('q');
|
||||
const category = query.get('category');
|
||||
if (date) {
|
||||
params.push(date);
|
||||
conditions.push(`r.report_date = $${params.length}::date`);
|
||||
}
|
||||
if (q) {
|
||||
params.push(`%${q}%`);
|
||||
conditions.push(`EXISTS (
|
||||
SELECT 1 FROM report_articles rqa
|
||||
JOIN news_articles nqa ON nqa.id = rqa.article_id
|
||||
WHERE rqa.report_id = r.id
|
||||
AND (nqa.title ILIKE $${params.length}
|
||||
OR COALESCE(nqa.summary, '') ILIKE $${params.length}
|
||||
OR COALESCE(array_to_string(nqa.keywords, ' '), '') ILIKE $${params.length})
|
||||
)`);
|
||||
}
|
||||
if (category) {
|
||||
params.push(category);
|
||||
conditions.push(`EXISTS (
|
||||
SELECT 1 FROM report_articles rca
|
||||
JOIN news_articles nca ON nca.id = rca.article_id
|
||||
WHERE rca.report_id = r.id
|
||||
AND (rca.section = $${params.length} OR nca.category = $${params.length})
|
||||
)`);
|
||||
}
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const countResult = await pool.query(`SELECT COUNT(*)::int AS count FROM daily_reports r ${where}`, params);
|
||||
const total = countResult.rows[0].count;
|
||||
params.push(pageSize, (page - 1) * pageSize);
|
||||
const result = await pool.query(`
|
||||
SELECT r.id, r.report_date, r.title, r.introduction, r.item_count,
|
||||
r.status, r.email_status, r.created_at,
|
||||
COALESCE(json_agg(json_build_object(
|
||||
'id', a.id, 'title', a.title, 'translated_title', a.translated_title,
|
||||
'summary', a.summary, 'translated_summary', a.translated_summary,
|
||||
'url', a.url, 'source_name', s.name, 'category', a.category,
|
||||
'topics', a.topics, 'region', a.region, 'published_at', a.published_at,
|
||||
'section', ra.section, 'display_order', ra.display_order,
|
||||
'is_highlight', ra.is_highlight
|
||||
) ORDER BY ra.section, ra.display_order)
|
||||
FILTER (WHERE a.id IS NOT NULL), '[]') AS articles
|
||||
FROM daily_reports r
|
||||
LEFT JOIN report_articles ra ON ra.report_id = r.id
|
||||
LEFT JOIN news_articles a ON a.id = ra.article_id
|
||||
LEFT JOIN news_sources s ON s.id = a.source_id
|
||||
${where}
|
||||
GROUP BY r.id
|
||||
ORDER BY r.report_date DESC
|
||||
LIMIT $${params.length - 1} OFFSET $${params.length}
|
||||
`, params);
|
||||
return { page, page_size: pageSize, total, reports: result.rows };
|
||||
}
|
||||
|
||||
async function routeApi(req, res, requestUrl) {
|
||||
const pathname = requestUrl.pathname;
|
||||
if (req.method === 'GET' && pathname === '/api/health') {
|
||||
try {
|
||||
await pool.query('SELECT 1');
|
||||
return sendJson(res, 200, { ok: true });
|
||||
} catch (error) {
|
||||
return sendError(res, 503, `数据库连接失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
if (req.method === 'GET' && pathname === '/api/modules') {
|
||||
try { return sendJson(res, 200, { modules: await listModules() }); }
|
||||
catch (error) { return sendError(res, 500, error.message); }
|
||||
}
|
||||
if (req.method === 'GET' && pathname === '/api/reports') {
|
||||
try { return sendJson(res, 200, await listReports(requestUrl.searchParams)); }
|
||||
catch (error) { return sendError(res, 500, error.message); }
|
||||
}
|
||||
if (pathname.startsWith('/api/recipients')) {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
if (req.method === 'GET' && pathname === '/api/recipients') return sendJson(res, 200, { recipients: await listRecipients() });
|
||||
if (req.method === 'POST' && pathname === '/api/recipients') {
|
||||
const body = await readBody(req);
|
||||
const id = await createRecipient(body);
|
||||
return sendJson(res, 201, { id });
|
||||
}
|
||||
const id = Number(pathname.split('/').pop());
|
||||
if (!Number.isInteger(id)) return sendError(res, 400, '无效的收件邮箱 ID');
|
||||
if (req.method === 'PUT') {
|
||||
await updateRecipient(id, await readBody(req));
|
||||
return sendJson(res, 200, { ok: true });
|
||||
}
|
||||
if (req.method === 'DELETE') {
|
||||
await deleteRecipient(id);
|
||||
return sendJson(res, 200, { ok: true });
|
||||
}
|
||||
} catch (error) {
|
||||
return sendError(res, error.message.includes('不存在') || error.message.includes('有效邮箱') || error.message.includes('选择') || error.message.includes('已经存在') ? 400 : 500, error.message);
|
||||
}
|
||||
}
|
||||
return sendError(res, 404, '接口不存在');
|
||||
}
|
||||
|
||||
function serveStatic(req, res, pathname) {
|
||||
let relative = pathname === '/' ? 'index.html' : pathname.slice(1);
|
||||
if (relative === 'settings') relative = 'settings.html';
|
||||
if (relative.includes('..')) return sendError(res, 400, '非法路径');
|
||||
const filePath = path.join(PUBLIC_DIR, relative);
|
||||
fs.readFile(filePath, (error, content) => {
|
||||
if (error) return sendError(res, 404, '页面不存在');
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
res.writeHead(200, { 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream' });
|
||||
res.end(content);
|
||||
});
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const requestUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
||||
if (requestUrl.pathname.startsWith('/api/')) return await routeApi(req, res, requestUrl);
|
||||
if (req.method !== 'GET') return sendError(res, 405, '只支持 GET');
|
||||
return serveStatic(req, res, requestUrl.pathname);
|
||||
} catch (error) {
|
||||
sendError(res, 500, error.message || '服务器错误');
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`Daily news digest web listening on ${PORT}`);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', async () => {
|
||||
await pool.end();
|
||||
server.close(() => process.exit(0));
|
||||
});
|
||||
Reference in New Issue
Block a user