Files
daily-news-digest/db/schema.sql
T

216 lines
10 KiB
PL/PgSQL

-- 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);