Initial daily news digest web
This commit is contained in:
@@ -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);
|
||||
Reference in New Issue
Block a user