From 8e735ece88fca35ba29be10ef4c568f3a71ceba1 Mon Sep 17 00:00:00 2001 From: wuyanwanwu <104009119+wuyanwanwu@users.noreply.github.com.> Date: Sun, 9 Aug 2026 17:43:21 +0800 Subject: [PATCH] Initial daily news digest web --- .env.example | 43 +++ .gitignore | 6 + Dockerfile | 13 + README.md | 140 ++++++++ db/migrations/002_users_and_subscriptions.sql | 85 +++++ db/migrations/003_translation_fields.sql | 28 ++ db/schema.sql | 215 ++++++++++++ deploy/daily-news-db-tunnel.service.example | 21 ++ docker-compose.host-network.yml | 6 + docker-compose.yml | 10 + package.json | 14 + web/public/app.js | 72 ++++ web/public/index.html | 62 ++++ web/public/settings.html | 49 +++ web/public/settings.js | 62 ++++ web/public/styles.css | 88 +++++ web/server.js | 316 ++++++++++++++++++ 17 files changed, 1230 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 db/migrations/002_users_and_subscriptions.sql create mode 100644 db/migrations/003_translation_fields.sql create mode 100644 db/schema.sql create mode 100644 deploy/daily-news-db-tunnel.service.example create mode 100644 docker-compose.host-network.yml create mode 100644 docker-compose.yml create mode 100644 package.json create mode 100644 web/public/app.js create mode 100644 web/public/index.html create mode 100644 web/public/settings.html create mode 100644 web/public/settings.js create mode 100644 web/public/styles.css create mode 100644 web/server.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0e3b9a7 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9b3fd48 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.env +.env.* +!.env.example +*.log +data/ +backups/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e9354a9 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..6c8f230 --- /dev/null +++ b/README.md @@ -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 @ +``` + +这会把本机 `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 工作流完成。 diff --git a/db/migrations/002_users_and_subscriptions.sql b/db/migrations/002_users_and_subscriptions.sql new file mode 100644 index 0000000..b77fc11 --- /dev/null +++ b/db/migrations/002_users_and_subscriptions.sql @@ -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; diff --git a/db/migrations/003_translation_fields.sql b/db/migrations/003_translation_fields.sql new file mode 100644 index 0000000..695c1e2 --- /dev/null +++ b/db/migrations/003_translation_fields.sql @@ -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; diff --git a/db/schema.sql b/db/schema.sql new file mode 100644 index 0000000..cb68e9b --- /dev/null +++ b/db/schema.sql @@ -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); diff --git a/deploy/daily-news-db-tunnel.service.example b/deploy/daily-news-db-tunnel.service.example new file mode 100644 index 0000000..97affd8 --- /dev/null +++ b/deploy/daily-news-db-tunnel.service.example @@ -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 diff --git a/docker-compose.host-network.yml b/docker-compose.host-network.yml new file mode 100644 index 0000000..53ad218 --- /dev/null +++ b/docker-compose.host-network.yml @@ -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: [] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7fb96b0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,10 @@ +services: + web: + build: . + restart: unless-stopped + env_file: + - .env + ports: + - "3000:3000" + extra_hosts: + - "host.docker.internal:host-gateway" diff --git a/package.json b/package.json new file mode 100644 index 0000000..5569aad --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/web/public/app.js b/web/public/app.js new file mode 100644 index 0000000..2495e56 --- /dev/null +++ b/web/public/app.js @@ -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 = '数据库连接正常'; + else throw new Error(); + } catch { $('#healthLabel').innerHTML = '数据库暂不可用'; } +} + +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 `
+ +

${escapeHtml(title)}

+

${escapeHtml(summary)}

+
`; +} + +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 = '
还没有符合条件的日报n8n 完成第一次抓取后,内容会出现在这里。
'; + return; + } + $('#reportList').innerHTML = reports.map((report) => `
+
${escapeHtml(formatDate(report.report_date))}

${escapeHtml(report.title)}

${escapeHtml(report.introduction || '今日资讯已按主题整理。')}

${report.status === 'published' ? '已发布' : '草稿'}
+
${(report.articles || []).map(renderArticle).join('')}
+
`).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 = '
正在读取日报…
'; + 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 = `
暂时无法读取日报${escapeHtml(error.message)}
`; + } +} + +$('#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(); diff --git a/web/public/index.html b/web/public/index.html new file mode 100644 index 0000000..d34f5a8 --- /dev/null +++ b/web/public/index.html @@ -0,0 +1,62 @@ + + + + + + + 每日情报 · Daily Digest + + + +
+ + D + Daily Digest每日行业情报 + + +
+ +
+
+
+
DAILY SIGNALS
+

今天值得读的
行业情报

+

把 AI、Agent 与农业产业链的重要动态,按主题整理成一份可追踪的日报。

+
+
+
今日日期
+
+
正在检查数据库
+
+
+ +
+
ARCHIVE

日报档案

+
+ + + + +
+
+ +
+
报告数量按筛选条件
+
今日文章已收录资讯
+
信息状态来源持续更新中
+
+ +
+
+ +
DAILY DIGEST资讯原文版权归原作者所有
+ + + diff --git a/web/public/settings.html b/web/public/settings.html new file mode 100644 index 0000000..39b0a05 --- /dev/null +++ b/web/public/settings.html @@ -0,0 +1,49 @@ + + + + + + + 设置管理 · Daily Digest + + + +
+ DDaily Digest每日行业情报 + +
+ +
+
CONTROL ROOM

推送设置

管理日报收件邮箱,为每个邮箱选择一个或多个内容模块。

+ +
+
PRIVATE ACCESS

管理令牌

令牌只保存在当前浏览器会话中,用于保护邮箱管理接口。

+
+
+
+ +
+
+
RECIPIENT

添加收件邮箱

+
+ + + +
+ +
选择推送模块 可多选
+ + +
+
+ +
+
DELIVERY LIST

已配置邮箱

0
+
正在加载…
+
+
+
+
DAILY DIGEST只向已启用邮箱发送日报
+ + + diff --git a/web/public/settings.js b/web/public/settings.js new file mode 100644 index 0000000..798bb6f --- /dev/null +++ b/web/public/settings.js @@ -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) => ``).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 = '
还没有收件邮箱在左侧添加第一个日报收件人。
'; return; } + const names = Object.fromEntries(modules.map((module) => [module.module_key, module.module_name])); + $('#recipientList').innerHTML = recipients.map((recipient) => `
+
${escapeHtml(recipient.email)} ${recipient.enabled ? '' : '已停用'}
${escapeHtml(recipient.display_name || '未设置名称')} · 每天 ${escapeHtml(String(recipient.send_time).slice(0, 5))} · ${escapeHtml(recipient.timezone)}
${recipient.send_all_modules ? '全部模块' : (recipient.modules || []).map((key) => `${escapeHtml(names[key] || key)}`).join('')}
+
+
`).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 = `
无法读取邮箱设置${escapeHtml(error.message)}
`; } +} + +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'); } })(); diff --git a/web/public/styles.css b/web/public/styles.css new file mode 100644 index 0000000..07a2e2b --- /dev/null +++ b/web/public/styles.css @@ -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; } } diff --git a/web/server.js b/web/server.js new file mode 100644 index 0000000..faf4129 --- /dev/null +++ b/web/server.js @@ -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)); +});