216 lines
10 KiB
PL/PgSQL
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);
|