diff --git a/api/certs/README_wechat_cert.md b/api/certs/README_wechat_cert.md new file mode 100644 index 0000000..c9fdc3c --- /dev/null +++ b/api/certs/README_wechat_cert.md @@ -0,0 +1,29 @@ +# 微信支付 401 签名错误排查 + +## 原因 + +**WECHAT_PAY_CERT_SERIAL_NO** 必须是「商户 API 证书」的序列号,且该证书与 `apiclient_key.pem`(商户私钥)为一对。 +若序列号与私钥不对应,微信会返回 401 签名错误。 + +## 获取正确序列号 + +### 方式一:从商户证书文件读取(推荐) + +若你有 `apiclient_cert.pem`(与 `apiclient_key.pem` 同时从商户平台下载): + +```bash +cd api +openssl x509 -in certs/apiclient_cert.pem -noout -serial +``` + +输出示例:`serial=55D97D542DBAA9CB9B7D0ACDB810CC99C208D328` +将等号后面的十六进制(可全部大写)填到 `.env` 的 `WECHAT_PAY_CERT_SERIAL_NO`。 + +### 方式二:从商户平台查看 + +登录 [微信支付商户平台](https://pay.weixin.qq.com) → **账户中心** → **API 安全** → **API 证书** → 查看与当前私钥对应的**证书序列号**,复制到 `WECHAT_PAY_CERT_SERIAL_NO`。 + +## 其他可能 + +- **私钥与证书不是一对**:重新在商户平台下载 API 证书,使用新的 `apiclient_key.pem` 与证书序列号。 +- **服务器时间偏差**:与标准时间误差不要超过约 5 分钟。 diff --git a/api/migrations/README.md b/api/migrations/README.md new file mode 100644 index 0000000..d66f907 --- /dev/null +++ b/api/migrations/README.md @@ -0,0 +1,15 @@ +# 数据库迁移 + +## 说明 + +- **`sync_schema_to_models.sql`**:与当前 `api/database/models.py` 保持一致的幂等迁移脚本,**部署工作流会在每次部署后自动执行**。可重复执行,已存在的表/列会跳过。 +- 其余 `add_*.sql` 为历史增量迁移,可按需单独执行;若已运行过 `sync_schema_to_models.sql`,通常不必再跑。 + +## 手动执行 + +```bash +# 从项目根目录 +psql -U -d -f api/migrations/sync_schema_to_models.sql +``` + +若使用与 docker-compose 默认不同的数据库用户/库名,可在 GitHub 仓库中配置 Secrets:`MIGRATION_DB_USER`、`MIGRATION_DB_NAME`,工作流将用其执行迁移。 diff --git a/api/migrations/sync_schema_to_models.sql b/api/migrations/sync_schema_to_models.sql new file mode 100644 index 0000000..fae8478 --- /dev/null +++ b/api/migrations/sync_schema_to_models.sql @@ -0,0 +1,130 @@ +-- 数据库结构同步迁移脚本(与 api/database/models.py 保持一致) +-- 幂等:可重复执行,已存在的表/列会跳过。 +-- 执行方式: psql -U -d -f api/migrations/sync_schema_to_models.sql +-- 执行时间: 2026-02 + +-- ========== 1. users 表缺失列 ========== +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'users' AND column_name = 'subscription_type') THEN + ALTER TABLE users ADD COLUMN subscription_type VARCHAR DEFAULT 'free'; + RAISE NOTICE '已添加 users.subscription_type'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'users' AND column_name = 'subscription_expires_at') THEN + ALTER TABLE users ADD COLUMN subscription_expires_at TIMESTAMP WITH TIME ZONE DEFAULT NULL; + RAISE NOTICE '已添加 users.subscription_expires_at'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'users' AND column_name = 'openid') THEN + ALTER TABLE users ADD COLUMN openid VARCHAR UNIQUE; + RAISE NOTICE '已添加 users.openid'; + END IF; +END $$; + +-- ========== 2. refresh_tokens 表缺失列 ========== +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'refresh_tokens' AND column_name = 'device_info') THEN + ALTER TABLE refresh_tokens ADD COLUMN device_info VARCHAR; + RAISE NOTICE '已添加 refresh_tokens.device_info'; + END IF; +END $$; + +-- ========== 3. conversations 表缺失列 ========== +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'conversations' AND column_name = 'current_topic') THEN + ALTER TABLE conversations ADD COLUMN current_topic VARCHAR; + RAISE NOTICE '已添加 conversations.current_topic'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'conversations' AND column_name = 'conversation_stage') THEN + ALTER TABLE conversations ADD COLUMN conversation_stage VARCHAR; + RAISE NOTICE '已添加 conversations.conversation_stage'; + END IF; +END $$; + +-- ========== 4. chapters 表缺失列 ========== +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'chapters' AND column_name = 'category') THEN + ALTER TABLE chapters ADD COLUMN category VARCHAR; + RAISE NOTICE '已添加 chapters.category'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'chapters' AND column_name = 'is_new') THEN + ALTER TABLE chapters ADD COLUMN is_new BOOLEAN DEFAULT TRUE; + RAISE NOTICE '已添加 chapters.is_new'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'chapters' AND column_name = 'source_segments') THEN + ALTER TABLE chapters ADD COLUMN source_segments JSONB; + RAISE NOTICE '已添加 chapters.source_segments'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'chapters' AND column_name = 'images') THEN + ALTER TABLE chapters ADD COLUMN images JSONB; + RAISE NOTICE '已添加 chapters.images'; + END IF; +END $$; + +-- ========== 5. books 表缺失列 ========== +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'books' AND column_name = 'has_update') THEN + ALTER TABLE books ADD COLUMN has_update BOOLEAN DEFAULT FALSE; + RAISE NOTICE '已添加 books.has_update'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'books' AND column_name = 'last_update_chapter_id') THEN + ALTER TABLE books ADD COLUMN last_update_chapter_id VARCHAR; + RAISE NOTICE '已添加 books.last_update_chapter_id'; + END IF; +END $$; + +-- ========== 6. orders 表(若无则创建) ========== +CREATE TABLE IF NOT EXISTS orders ( + id VARCHAR NOT NULL PRIMARY KEY, + user_id VARCHAR NOT NULL REFERENCES users(id), + plan_id VARCHAR NOT NULL, + plan_name VARCHAR NOT NULL, + amount INTEGER NOT NULL, + currency VARCHAR DEFAULT 'CNY', + payment_method VARCHAR NOT NULL, + status VARCHAR DEFAULT 'pending', + trade_no VARCHAR, + paid_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + expired_at TIMESTAMP WITH TIME ZONE +); +CREATE INDEX IF NOT EXISTS ix_orders_user_id ON orders(user_id); +CREATE INDEX IF NOT EXISTS ix_orders_trade_no ON orders(trade_no); +CREATE INDEX IF NOT EXISTS ix_orders_status ON orders(status); + +-- ========== 7. sms_verification_codes 表(若无则创建) ========== +CREATE TABLE IF NOT EXISTS sms_verification_codes ( + id VARCHAR PRIMARY KEY, + phone VARCHAR NOT NULL, + code VARCHAR NOT NULL, + purpose VARCHAR NOT NULL, + is_used BOOLEAN DEFAULT FALSE, + is_expired BOOLEAN DEFAULT FALSE, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + verified_at TIMESTAMP WITH TIME ZONE, + ip_address VARCHAR +); +CREATE INDEX IF NOT EXISTS idx_sms_phone ON sms_verification_codes(phone); +CREATE INDEX IF NOT EXISTS idx_sms_created_at ON sms_verification_codes(created_at); +CREATE INDEX IF NOT EXISTS idx_sms_purpose ON sms_verification_codes(purpose); +CREATE INDEX IF NOT EXISTS idx_sms_phone_purpose ON sms_verification_codes(phone, purpose); + +-- ========== 8. memoir_states 表(若无则创建,供 create_all 未执行环境使用) ========== +CREATE TABLE IF NOT EXISTS memoir_states ( + id VARCHAR NOT NULL PRIMARY KEY, + user_id VARCHAR NOT NULL UNIQUE REFERENCES users(id), + stage_order JSONB DEFAULT '[]'::jsonb, + current_stage VARCHAR DEFAULT 'childhood', + covered_stages JSONB DEFAULT '[]'::jsonb, + slots JSONB NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +DO $$ +BEGIN + RAISE NOTICE 'sync_schema_to_models 迁移执行完成'; +END $$;