diff --git a/.gitignore b/.gitignore index 5138193..d0dd727 100644 --- a/.gitignore +++ b/.gitignore @@ -46,5 +46,3 @@ backend/data/ # Logs *.log -# Docker -.dockerignore diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..e16719a --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,13 @@ +__pycache__ +*.pyc +*.pyo +.env +.git +.gitignore +README.md +test.db +dev.db +node_modules +.venv +venv +*.log diff --git a/backend/Dockerfile b/backend/Dockerfile index b65cc55..b3fbd46 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,14 +1,23 @@ +# syntax=docker/dockerfile:1 +# 构建加速(2026-08-10):腾讯 pip/apt 源 + BuildKit pip 缓存 +# 首次 build:基镜像(daemon registry-mirrors 已配腾讯)+pip 全量(腾讯源) ≈ 几分钟 +# 之后代码级 build:pip 层命中缓存,只重算 COPY . . → 秒~1 分钟 + # ---- Build stage ---- FROM python:3.12-slim AS builder WORKDIR /build -RUN apt-get update && apt-get install -y --no-install-recommends \ +RUN sed -i 's|deb.debian.org|mirrors.tencentyun.com|g; s|security.debian.org|mirrors.tencentyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \ + sed -i 's|deb.debian.org|mirrors.tencentyun.com|g; s|security.debian.org|mirrors.tencentyun.com|g' /etc/apt/sources.list; \ + apt-get update && apt-get install -y --no-install-recommends \ build-essential libpq-dev \ && rm -rf /var/lib/apt/lists/* +RUN printf '[global]\nindex-url = http://mirrors.tencentyun.com/pypi/simple\ntrusted-host = mirrors.tencentyun.com\n' > /etc/pip.conf + COPY pyproject.toml . -RUN pip install --no-cache-dir . +RUN --mount=type=cache,target=/root/.cache/pip pip install . # ---- Runtime stage ---- FROM python:3.12-slim diff --git a/backend/alembic/versions/1421ea169bb6_change_5_date_fields_to_date.py b/backend/alembic/versions/1421ea169bb6_change_5_date_fields_to_date.py index 224dd3c..a58c009 100644 --- a/backend/alembic/versions/1421ea169bb6_change_5_date_fields_to_date.py +++ b/backend/alembic/versions/1421ea169bb6_change_5_date_fields_to_date.py @@ -31,7 +31,7 @@ def upgrade() -> None: def downgrade() -> None: - for col in reverse(DATE_FIELDS): + for col in reversed(DATE_FIELDS): op.alter_column("global_literature", col, existing_type=sa.Date(), type_=postgresql.TIMESTAMP(timezone=True), diff --git a/backend/alembic/versions/55105f0bb1d7_change_journal_iso_pages_from_varchar_.py b/backend/alembic/versions/55105f0bb1d7_change_journal_iso_pages_from_varchar_.py new file mode 100644 index 0000000..72771c3 --- /dev/null +++ b/backend/alembic/versions/55105f0bb1d7_change_journal_iso_pages_from_varchar_.py @@ -0,0 +1,41 @@ +"""change journal_iso/pages from varchar(100) to text + +Revision ID: 55105f0bb1d7 +Revises: 95c18ebf31e4 +Create Date: 2026-07-30 08:38:08.376834 +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + + +revision: str = '55105f0bb1d7' +down_revision: Union[str, None] = '95c18ebf31e4' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('global_literature', 'journal_iso', + existing_type=sa.VARCHAR(length=100), + type_=sa.Text(), + existing_nullable=True) + op.alter_column('global_literature', 'pages', + existing_type=sa.VARCHAR(length=100), + type_=sa.Text(), + existing_nullable=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('global_literature', 'pages', + existing_type=sa.Text(), + type_=sa.VARCHAR(length=100), + existing_nullable=True) + op.alter_column('global_literature', 'journal_iso', + existing_type=sa.Text(), + type_=sa.VARCHAR(length=100), + existing_nullable=True) + # ### end Alembic commands ### diff --git a/backend/alembic/versions/95c18ebf31e4_change_volume_issue_from_varchar_200_to_.py b/backend/alembic/versions/95c18ebf31e4_change_volume_issue_from_varchar_200_to_.py new file mode 100644 index 0000000..4a64bfc --- /dev/null +++ b/backend/alembic/versions/95c18ebf31e4_change_volume_issue_from_varchar_200_to_.py @@ -0,0 +1,41 @@ +"""change volume/issue from varchar(200) to text + +Revision ID: 95c18ebf31e4 +Revises: d166cde6083b +Create Date: 2026-07-30 08:02:07.251190 +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + + +revision: str = '95c18ebf31e4' +down_revision: Union[str, None] = 'd166cde6083b' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('global_literature', 'volume', + existing_type=sa.VARCHAR(length=200), + type_=sa.Text(), + existing_nullable=True) + op.alter_column('global_literature', 'issue', + existing_type=sa.VARCHAR(length=200), + type_=sa.Text(), + existing_nullable=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('global_literature', 'issue', + existing_type=sa.Text(), + type_=sa.VARCHAR(length=200), + existing_nullable=True) + op.alter_column('global_literature', 'volume', + existing_type=sa.Text(), + type_=sa.VARCHAR(length=200), + existing_nullable=True) + # ### end Alembic commands ### diff --git a/backend/alembic/versions/cb07d6b1df01_jsonb_migration_setweight_add_entrez_.py b/backend/alembic/versions/cb07d6b1df01_jsonb_migration_setweight_add_entrez_.py index 9904de6..13cb730 100644 --- a/backend/alembic/versions/cb07d6b1df01_jsonb_migration_setweight_add_entrez_.py +++ b/backend/alembic/versions/cb07d6b1df01_jsonb_migration_setweight_add_entrez_.py @@ -5,8 +5,7 @@ 2. 新增 3 列(print_date [PPDAT], create_date [CRDT], entrez_date [EDAT]) 3. 新增 entry_terms 到 global_tags 4. search_tsv 触发器更新(setweight A/B + authors 已为 jsonb 不再需要 CAST) -5. search_tsv 回填全部已有记录 -6. 新增搜索索引(B-tree × 4 + GIN × 4) +5. 新增搜索索引(B-tree × 4 + GIN × 4) Revision ID: cb07d6b1df01 Revises: 6b662a8c5235 @@ -36,17 +35,6 @@ _TSVEC = """setweight(to_tsvector('english', COALESCE(NEW.title, '')), 'A') || '') ), 'A')""" -_UPDATE = """setweight(to_tsvector('english', COALESCE(title, '')), 'A') || - setweight(to_tsvector('english', COALESCE(abstract, '')), 'B') || - setweight(to_tsvector('english', - COALESCE( - (SELECT string_agg( - value->>'family' || ' ' || COALESCE(value->>'affiliation', ''), - ' ') - FROM jsonb_array_elements(authors)), - '') - ), 'A')""" - # 需改为 JSONB 的列列表 _JSON_TO_JSONB_COLS = [ "authors", "pub_types", "mesh_headings", "keywords", "grants", @@ -96,12 +84,8 @@ def upgrade() -> None: EXECUTE FUNCTION update_literature_search_tsv() """) - # ─── 5. 回填全部已有记录的 search_tsv ─── - op.execute(f""" - UPDATE global_literature - SET search_tsv = {_UPDATE} - WHERE search_tsv IS NOT NULL; - """) + # ─── 5. 跳过 search_tsv 回填。后续迁移 g0h1i2j3k4l5 会做全量回填, + # 此处回填会被完全覆盖,属于无效 I/O。 # ─── 6. 新增 B-tree 索引(先删后建,应对部分已存在的索引)─── op.execute("DROP INDEX IF EXISTS ix_gl_doi") diff --git a/backend/alembic/versions/d166cde6083b_extend_volume_issue_to_varchar_200.py b/backend/alembic/versions/d166cde6083b_extend_volume_issue_to_varchar_200.py new file mode 100644 index 0000000..54be547 --- /dev/null +++ b/backend/alembic/versions/d166cde6083b_extend_volume_issue_to_varchar_200.py @@ -0,0 +1,41 @@ +"""extend volume/issue to varchar(200) + +Revision ID: d166cde6083b +Revises: af4a8b2ec873 +Create Date: 2026-07-30 00:57:23.198425 +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + + +revision: str = 'd166cde6083b' +down_revision: Union[str, None] = 'af4a8b2ec873' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('global_literature', 'volume', + existing_type=sa.VARCHAR(length=50), + type_=sa.String(length=200), + existing_nullable=True) + op.alter_column('global_literature', 'issue', + existing_type=sa.VARCHAR(length=50), + type_=sa.String(length=200), + existing_nullable=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('global_literature', 'issue', + existing_type=sa.String(length=200), + type_=sa.VARCHAR(length=50), + existing_nullable=True) + op.alter_column('global_literature', 'volume', + existing_type=sa.String(length=200), + type_=sa.VARCHAR(length=50), + existing_nullable=True) + # ### end Alembic commands ### diff --git a/backend/alembic/versions/e0764f6d7c21_add_tag_ids_array_brin_indexes.py b/backend/alembic/versions/e0764f6d7c21_add_tag_ids_array_brin_indexes.py index 178f053..f2e61dd 100644 --- a/backend/alembic/versions/e0764f6d7c21_add_tag_ids_array_brin_indexes.py +++ b/backend/alembic/versions/e0764f6d7c21_add_tag_ids_array_brin_indexes.py @@ -17,9 +17,9 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f('ix_audit_action'), table_name='audit_logs') - op.drop_index(op.f('ix_audit_actor'), table_name='audit_logs') - op.drop_table('audit_logs') + # audit_logs 保留:该表有生产数据且 admin_roles.py 通过 audit_log() 写入。 + # 模型 (app/models/audit.py) 虽未在 __init__.py 导入,但代码中活跃使用。 + # autogenerate 误判为孤立表而生成 DROP,此处移除 DROP 操作。 op.add_column('global_literature', sa.Column('tag_ids', postgresql.ARRAY(sa.Uuid()), nullable=True)) op.drop_index(op.f('ix_gl_authors_text_trgm'), table_name='global_literature', postgresql_ops={'(authors::text)': 'gin_trgm_ops'}, postgresql_using='gin') op.create_index('ix_gl_is_negative_true', 'global_literature', ['is_negative_result'], unique=False, postgresql_where=sa.text('is_negative_result = TRUE')) @@ -35,6 +35,7 @@ def upgrade() -> None: def downgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### + # audit_logs 操作已从 upgrade 移除(保留生产数据),downgrade 同步跳过 op.drop_index('ix_gl_tag_ids_gin', table_name='global_literature', postgresql_using='gin') op.drop_index('ix_gl_retracted_true', table_name='global_literature', postgresql_where=sa.text('retracted = TRUE')) op.drop_index('ix_gl_pub_year_brin', table_name='global_literature', postgresql_using='brin') @@ -45,18 +46,4 @@ def downgrade() -> None: op.drop_index('ix_gl_is_negative_true', table_name='global_literature', postgresql_where=sa.text('is_negative_result = TRUE')) op.create_index(op.f('ix_gl_authors_text_trgm'), 'global_literature', [sa.literal_column('(authors::text)')], unique=False, postgresql_ops={'(authors::text)': 'gin_trgm_ops'}, postgresql_using='gin') op.drop_column('global_literature', 'tag_ids') - op.create_table('audit_logs', - sa.Column('id', sa.UUID(), autoincrement=False, nullable=False), - sa.Column('actor_id', sa.UUID(), autoincrement=False, nullable=False), - sa.Column('tenant_id', sa.UUID(), autoincrement=False, nullable=True), - sa.Column('action', sa.VARCHAR(length=50), autoincrement=False, nullable=False), - sa.Column('target_type', sa.VARCHAR(length=50), autoincrement=False, nullable=True), - sa.Column('target_id', sa.VARCHAR(length=100), autoincrement=False, nullable=True), - sa.Column('detail', sa.TEXT(), autoincrement=False, nullable=True), - sa.Column('ip_address', sa.VARCHAR(length=45), autoincrement=False, nullable=True), - sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=False), - sa.PrimaryKeyConstraint('id', name=op.f('audit_logs_pkey')) - ) - op.create_index(op.f('ix_audit_actor'), 'audit_logs', ['actor_id', 'created_at'], unique=False) - op.create_index(op.f('ix_audit_action'), 'audit_logs', ['action', 'created_at'], unique=False) # ### end Alembic commands ### diff --git a/backend/app/api/v1/admin.py b/backend/app/api/v1/admin.py index b1e253f..eeb2161 100644 --- a/backend/app/api/v1/admin.py +++ b/backend/app/api/v1/admin.py @@ -174,27 +174,35 @@ async def dashboard_stats(): async with async_session() as db: tc = (await db.execute(select(func.count(Tenant.id)))).scalar() or 0 uc = (await db.execute(select(func.count(User.id)))).scalar() or 0 - lc = (await db.execute(select(func.count(GlobalLiterature.id)))).scalar() or 0 nc = (await db.execute(select(func.count(UserNote.id)))).scalar() or 0 sc = (await db.execute(select(func.count(UserLiterature.id)))).scalar() or 0 wau = (await db.execute(select(func.count(func.distinct(LoginLog.user_id))).where(LoginLog.created_at >= week_ago))).scalar() or 0 recent_pipelines = (await db.execute(select(PipelineRun).order_by(PipelineRun.created_at.desc()).limit(5))).scalars().all() - r = await db.execute(select(func.count(GlobalLiterature.id)).where(GlobalLiterature.abstract.isnot(None), GlobalLiterature.abstract != "")) - abstract_ok = r.scalar() or 0 - r = await db.execute(select(func.count(GlobalLiterature.id)).where( - (GlobalLiterature.full_text_sections.isnot(None)) | (GlobalLiterature.full_text_path.isnot(None)) - )) - fulltext_ok = r.scalar() or 0 + # 合并所有 global_literature 聚合到一个查询(1次全扫描 vs 7次独立扫描) + lit_agg = (await db.execute( + select( + func.count(GlobalLiterature.id).label("lc"), + func.count(GlobalLiterature.id).filter( + GlobalLiterature.abstract.isnot(None), GlobalLiterature.abstract != "" + ).label("abstract_ok"), + func.count(GlobalLiterature.id).filter( + (GlobalLiterature.full_text_sections.isnot(None)) | (GlobalLiterature.full_text_path.isnot(None)) + ).label("fulltext_ok"), + func.count(GlobalLiterature.id).filter(GlobalLiterature.pico.isnot(None)).label("pico_ok"), + func.count(GlobalLiterature.id).filter(GlobalLiterature.study_design.isnot(None)).label("design_ok"), + func.count(GlobalLiterature.id).filter(GlobalLiterature.cited_by_count.isnot(None)).label("cited_ok"), + func.count(GlobalLiterature.id).filter(GlobalLiterature.doi.isnot(None)).label("doi_ok"), + ) + )).one() + lc = lit_agg.lc or 0 + abstract_ok = lit_agg.abstract_ok or 0 + fulltext_ok = lit_agg.fulltext_ok or 0 + pico_ok = lit_agg.pico_ok or 0 + design_ok = lit_agg.design_ok or 0 + cited_ok = lit_agg.cited_ok or 0 + doi_ok = lit_agg.doi_ok or 0 r = await db.execute(select(func.count(func.distinct(GlobalLiteratureTag.literature_id)))) tagged = r.scalar() or 0 - r = await db.execute(select(func.count(GlobalLiterature.id)).where(GlobalLiterature.pico.isnot(None))) - pico_ok = r.scalar() or 0 - r = await db.execute(select(func.count(GlobalLiterature.id)).where(GlobalLiterature.study_design.isnot(None))) - design_ok = r.scalar() or 0 - r = await db.execute(select(func.count(GlobalLiterature.id)).where(GlobalLiterature.cited_by_count.isnot(None))) - cited_ok = r.scalar() or 0 - r = await db.execute(select(func.count(GlobalLiterature.id)).where(GlobalLiterature.doi.isnot(None))) - doi_ok = r.scalar() or 0 r = await db.execute( select(PipelineRun.run_type, func.count(PipelineRun.id), func.sum(PipelineRun.articles_new), func.sum(PipelineRun.feeds_generated)) diff --git a/backend/app/api/v1/literature.py b/backend/app/api/v1/literature.py index d255c4d..b3b7f3d 100644 --- a/backend/app/api/v1/literature.py +++ b/backend/app/api/v1/literature.py @@ -1,12 +1,13 @@ """文献 API:个性化 Feed、搜索、文献详情(需登录)""" +import logging import uuid from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import PlainTextResponse from pydantic import BaseModel -from sqlalchemy import case, func, or_, select, text +from sqlalchemy import case, func, literal, or_, select, text from sqlalchemy.ext.asyncio import AsyncSession from app.core.permissions import get_current_user @@ -19,6 +20,8 @@ from app.services.cos_client import cached_get_full_text from app.services.search_engine import AdvancedSearchEngine, _escape_ilike from app.services.tag_loader import load_tags_for_literature +logger = logging.getLogger(__name__) + router = APIRouter() DISMISS_THRESHOLD = 3 # 同一标签被忽略 N 次后,Feed 引擎不再推送该标签下的文章 @@ -270,18 +273,15 @@ async def search_literature( return {"items": [], "total": 0, "error": "查询词过多(最多 100 个词),请简化搜索条件"} try: offset = (page - 1) * page_size - await db.execute(text("SET LOCAL statement_timeout = '30s'")) - like = f"%{_escape_ilike(q)}%" - # tsvector 主搜索 + ILIKE 兜底 - search_cond = or_( - GlobalLiterature.search_tsv.op("@@")(func.plainto_tsquery("english", q)), - GlobalLiterature.title.ilike(like), - GlobalLiterature.abstract.ilike(like), - ) + await db.execute(text("SET LOCAL statement_timeout = '60s'")) + # 主搜索:tsvector 全文检索(性能远优于多字段 OR + ILIKE) + tsq = func.plainto_tsquery("english", q) + search_cond = GlobalLiterature.search_tsv.op("@@")(tsq) # 中文搜索:自动匹配 GlobalTag.name_zh → 注入标签条件 import re as _cn_re _CHINESE_RE = _cn_re.compile(r'[一-鿿㐀-䶿豈-﫿]') if _CHINESE_RE.search(q): + like = f"%{_escape_ilike(q)}%" _tag_matches = (await db.execute( select(GlobalTag.id).where( GlobalTag.source.in_(["mesh", "manual"]), @@ -293,15 +293,29 @@ async def search_literature( GlobalLiteratureTag.tag_id.in_(list(_tag_matches)) ) search_cond = or_(search_cond, GlobalLiterature.id.in_(_tag_lit_subq)) - count_q = select(func.count(GlobalLiterature.id)).where(search_cond) - total = (await db.execute(count_q)).scalar() or 0 - tsq = func.plainto_tsquery("english", q) - best_match_rank = AdvancedSearchEngine._best_match_order(tsq) + # COUNT:使用 LIMIT 10001 截断,避免大结果集的精确计数 + # 结果集 ≤10000 时返回精确值,否则返回 10001+ + MAX_EXACT = 10000 + truncated_count = await db.execute( + select(func.count()).select_from( + select(literal("1")).where(search_cond).limit(MAX_EXACT + 1).subquery() + ) + ) + cnt = truncated_count.scalar() or 0 + total = cnt if cnt <= MAX_EXACT else cnt + # ORDER BY:使用 pub_date DESC(比 best_match 公式更快且符合用户预期) result = await db.execute( select(GlobalLiterature).where(search_cond) - .order_by(best_match_rank).offset(offset).limit(page_size) + .order_by(GlobalLiterature.pub_date.desc().nulls_last(), GlobalLiterature.id.desc()) + .offset(offset).limit(page_size + 1) ) lit_list = result.scalars().all() + has_more = len(lit_list) > page_size + lit_list = lit_list[:page_size] + if page > 1 and total == 0: + total = offset + len(lit_list) + (1 if has_more else 0) + elif total == 0: + total = len(lit_list) tm = await load_tags_for_literature(db, [str(lit.id) for lit in lit_list]) items = [] for lit in lit_list: diff --git a/backend/app/models/literature.py b/backend/app/models/literature.py index 0cfd9b6..20fec07 100644 --- a/backend/app/models/literature.py +++ b/backend/app/models/literature.py @@ -21,10 +21,10 @@ class GlobalLiterature(Base): doi: Mapped[str | None] = mapped_column(String(500)) journal: Mapped[str | None] = mapped_column(String(500)) journal_issn: Mapped[str | None] = mapped_column(String(20)) - journal_iso: Mapped[str | None] = mapped_column(String(100)) # NLM ISO 缩写 (e.g. "N Engl J Med") - volume: Mapped[str | None] = mapped_column(String(50)) - issue: Mapped[str | None] = mapped_column(String(50)) - pages: Mapped[str | None] = mapped_column(String(100)) + journal_iso: Mapped[str | None] = mapped_column(Text) # NLM ISO 缩写 (e.g. "N Engl J Med") + volume: Mapped[str | None] = mapped_column(Text) + issue: Mapped[str | None] = mapped_column(Text) + pages: Mapped[str | None] = mapped_column(Text) pub_date: Mapped[date | None] = mapped_column(Date) # 纸质出版日期(期刊卷期日期,纯电子刊则为电子日期)【PubMed: PubDate】 print_date: Mapped[date | None] = mapped_column(Date) # 纸质出版日期(仅纸质版见刊日期)【PubMed: PPDAT】 pub_year: Mapped[int | None] = mapped_column(Integer) diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index f0e4d83..fa1a4b3 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -18,7 +18,7 @@ SMTP_PORT: int = settings.SMTP_PORT or 587 SMTP_USER: str = settings.SMTP_USER or "" SMTP_PASSWORD: str = settings.SMTP_PASSWORD or "" FROM_EMAIL: str = settings.SMTP_FROM or "noreply@scilit-oncology.com" -FROM_NAME: str = "OncoLit 肿瘤科研文献中心" +FROM_NAME: str = "客户服务" async def send_email(to_email: str, subject: str, html_body: str) -> bool: diff --git a/backend/app/services/search_engine.py b/backend/app/services/search_engine.py index 2f396ca..e3c72ec 100644 --- a/backend/app/services/search_engine.py +++ b/backend/app/services/search_engine.py @@ -247,7 +247,7 @@ class AdvancedSearchEngine: } # 30 秒查询超时(放在缓存检查之后,缓存命中不执行) - await db.execute(text("SET LOCAL statement_timeout = '30s'")) + await db.execute(text("SET LOCAL statement_timeout = '60s'")) # ─── PubMed 语法检测与解析 ─── _pubmed_parsed = None diff --git a/backend/scripts/migrate_prod.sh b/backend/scripts/migrate_prod.sh new file mode 100644 index 0000000..844bd30 --- /dev/null +++ b/backend/scripts/migrate_prod.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# ============================================================================= +# migrate_prod.sh — 生产数据迁移分批执行脚本 +# +# 用法: +# ./migrate_prod.sh <批次 1|2|3|4> +# +# 批次划分(对应 docs/17-生产数据迁移.md 第 4 节): +# 1: [01-03] 基础 schema —— JSON→JSONB 全表重写、meshed_date 回填、5 日期字段→DATE(锁表) +# 2: [04-11] 轻量加列/新表 —— pipeline_runs/journals/literature 增列 + user_saved_filters +# 3: [12-16] 索引 + 触发器 —— 筛选列/mesh_headings/trgm 索引 + tsvector 触发器 +# 4: [17-24] 大数据量 —— author_names/search_tsv/tag_ids 回填 + volume/pages→Text(锁表) +# +# 前置条件(必须全部满足,否则脚本拦截): +# ① /root/scilit/backend 已同步最新代码(含 alembic/versions/ 的新迁移文件) +# ② 已构建新 backend 镜像: +# docker compose -f docker-compose.prod.yml build backend +# ③ postgres 容器 healthy +# ④ 脚本放在 /root/scilit/ 目录执行(与 docker-compose.prod.yml 同目录) +# +# 说明: +# - 每个批次执行到指定 checkpoint revision,验证后停止;下一批由人工决定何时跑 +# - 全部使用 `run --no-deps --rm`(防依赖链拉起 migrate 服务重复跑迁移) +# - 批次 1/4 含锁表操作,建议低峰执行 +# ============================================================================= +set -euo pipefail +cd "$(dirname "$0")" + +# 数组:命令 + 参数("$COMPOSE" 双引号会把整串当一个命令名,导致 command not found) +COMPOSE=(docker compose -f docker-compose.prod.yml) +LOCAL_HEAD="55105f0bb1d7" # 本地最新迁移 revision([24]) + +# 批次 → 目标 revision +declare -A TARGET=( + [1]="1421ea169bb6" # [03] 5 日期字段→DATE + [2]="cac545862583" # [11] user_saved_filters + [3]="e0f1a2b3c4d5" # [16] chemical/gene tsvector 触发器 + [4]="head" # [24] journal_iso/pages→Text +) +# 批次 → 预期起点 revision(DB 当前应在此处,防止乱序) +declare -A EXPECT_START=( + [1]="6b662a8c5235" # 生产当前 head + [2]="1421ea169bb6" + [3]="cac545862583" + [4]="e0f1a2b3c4d5" +) +declare -A DESC=( + [1]="[01-03] 基础 schema:JSON→JSONB、meshed_date 回填、5 日期字段→DATE(⚠️ destructive:日期收窄,须与发新代码同窗口,不能脱离部署单独跑)" + [2]="[04-11] 轻量加列/新表:pipeline_runs/journals/literature 增列 + user_saved_filters(纯 additive,可提前任意时段跑)" + [3]="[12-16] 索引 + 触发器:筛选列/mesh_headings/trgm 索引 + tsvector 触发器(additive,可提前,建议低峰)" + [4]="[17-24] 大数据量回填 + 类型锁表:author_names_text/search_tsv/tag_ids 回填 + volume/pages→Text(含 destructive 类型变更,须在新代码已部署后、深夜低峰)" +) + +get_db_rev() { + # 返回 DB 当前 revision 的短 hash(12 位);alembic_version 表不存在则报错 + # -c alembic/alembic.ini 必须带:run 覆盖 command 后默认 cwd 不是 /app,不带找不到 script_location + # 2>/dev/null 丢 docker 的 "Container ... Creating" stderr;tail -1 取 alembic 真正输出的最后一行 + # (否则容器 ID 的 12 位 hex 会被 grep 误抓成 revision,报 "not expected start") + "${COMPOSE[@]}" run --no-deps --rm backend alembic -c alembic/alembic.ini current 2>/dev/null \ + | grep -oE '[0-9a-f]{12}' | tail -1 +} + +check_image_fresh() { + echo "── 检查 backend 镜像是否含最新迁移文件(本地 head: $LOCAL_HEAD)──" + local heads + heads="$("${COMPOSE[@]}" run --no-deps --rm backend alembic -c alembic/alembic.ini heads 2>/dev/null)" || true + if ! echo "$heads" | grep -q "$LOCAL_HEAD"; then + echo "✗ 镜像不含本地 head($LOCAL_HEAD)。" + echo " 当前镜像 alembic heads:" + echo "$heads" | sed 's/^/ /' + echo "" + echo " 请先在生产完成以下步骤再跑迁移:" + echo " 1. 同步最新代码到 /root/scilit/backend" + echo " 2. docker compose -f docker-compose.prod.yml build backend" + echo " 用旧镜像跑迁移会报 Can't locate revision(8-09 事故根因)。" + exit 1 + fi + echo "✓ 镜像包含本地 head,可执行迁移" +} + +usage() { + echo "用法: $0 <批次 1|2|3|4>" + echo "" + for n in 1 2 3 4; do + echo " 批次 $n: ${DESC[$n]}" + done +} + +if [[ $# -ne 1 || ! ${TARGET[$1]+x} ]]; then + usage + exit 1 +fi +BATCH=$1 +REV=${TARGET[$BATCH]} +START=${EXPECT_START[$BATCH]} + +echo "════════════════════════════════════════════════════════════" +echo " 批次 $BATCH/4:${DESC[$BATCH]}" +echo " 目标 revision: $REV" +echo "════════════════════════════════════════════════════════════" + +# 前置 1: 镜像新鲜度 +check_image_fresh + +# 前置 2: DB 起点校验(防乱序/重复) +CUR="$(get_db_rev)" +echo "── DB 当前 revision: $CUR ──" +if [[ "$CUR" == "$REV" || ( "$BATCH" == "4" && "$CUR" == "$LOCAL_HEAD" ) ]]; then + echo "✓ DB 已在该批次目标 revision,无需执行(幂等跳过)" + exit 0 +fi +if [[ "$CUR" != "$START" ]]; then + echo "✗ DB 当前 revision($CUR)不是批次 $BATCH 的预期起点($START)。" + echo " 请确认前一批次已执行完成,或检查批次顺序。" + echo " 当前 migration 链:" + "${COMPOSE[@]}" run --no-deps --rm backend alembic -c alembic/alembic.ini history 2>&1 | tail -30 || true + exit 1 +fi + +# 执行迁移 +echo "── 开始执行迁移(可能耗时,请勿中断)──" +"${COMPOSE[@]}" run --no-deps --rm backend alembic -c alembic/alembic.ini upgrade "$REV" + +# 验证 +AFTER="$(get_db_rev)" +echo "── 迁移后 DB revision: $AFTER ──" +if [[ "$AFTER" != "$REV" && ! ( "$BATCH" == "4" && "$AFTER" == "$LOCAL_HEAD" ) ]]; then + echo "✗ 迁移后 revision 未达到目标,异常!" + exit 1 +fi +echo "" +echo "✓ 批次 $BATCH 完成。" +if [[ $BATCH -lt 4 ]]; then + echo " 下一步: 验证无误后,可运行 ./migrate_prod.sh $((BATCH+1))" +else + echo " 全部 4 个批次执行完毕,DB 已升级到 head($LOCAL_HEAD)。" +fi diff --git a/backend/scripts/seed_data.py b/backend/scripts/seed_data.py index 5cbf907..c49f625 100644 --- a/backend/scripts/seed_data.py +++ b/backend/scripts/seed_data.py @@ -6,12 +6,12 @@ import os import sys from datetime import date, datetime -from app.compat import UTC - sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) import uuid +from app.compat import UTC + from sqlalchemy import func, select from app.core.security import hash_password diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index a3da29d..6922ba4 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -260,7 +260,7 @@ services: max-file: "3" gitea: - image: gitea/gitea:latest-rootless + image: gitea/gitea:1.27.1-rootless restart: unless-stopped networks: - scilit diff --git a/docs/16-部署运维方案.md b/docs/16-部署运维方案.md new file mode 100644 index 0000000..abc521b --- /dev/null +++ b/docs/16-部署运维方案.md @@ -0,0 +1,275 @@ +# 部署运维方案:生产工程化(一期 + 二期) + +> **日期:** 2026-08-08(v16 更新于 2026-08-10) +> **版本:** v16.0(当前) **状态:** 一期 3 项已执行(磁盘/备份/TLS)+ 构建加速(Dockerfile 卫生)+ gitea TLS,其余待执行 +> **关联:** [docs/10-生产部署文档.md](10-生产部署文档.md)、[docs/11-20260717部署事故分析.md](11-20260717部署事故分析.md)、[docs/12-部署实际操作记录.md](12-部署实际操作记录.md)、[docs/17-生产数据迁移.md](17-生产数据迁移.md)(24 迁移专项,2026-08-09) +> +> **版本记录:** +> - **v16** — 2026-08-10:§3 补批量迁移执行序 + destructive ordering(批次 1 日期收窄须与发代码同窗口);§7 补 gitea TLS 落地;§6 补构建加速落地(腾讯 pip/npm 源 + BuildKit,详见记忆 docker_build_optimization) +> - **v1** — 初稿(生产工程化框架,一期 + 二期) +> - **v2** — 吸收三档补强:破坏性迁移硬判定、backup.sh 上机查清、扩对盘、compose 范围澄清、挂卷后 logrotate、pre-deploy 快照留存、registry token 轮换、alembic head 采集、增强级 +> - **v3** — 二次反查:`.dockerignore` 脱离 git、backup.sh 连库路径两个真 bug + frontend healthcheck/双 tag/治理策略打架/工作区检查/判定扫描时机/image 写法六个设计漏洞 +> - **v4** — 吸收 9 条遗漏:worker 优雅停机、回滚复用 rollback.sh、alembic 采集命令、pull 限定服务、index.html 缓存、pgvector 升 major、并发部署锁、dump 清理排序、worker 健康检查、BuildKit secret +> - **v5** — 固化 v3 未落点 + 10 条遗漏:§5 改 count 制、FRONTEND_TAG/前端 healthcheck/双 tag 提升主体、versions.log 双 tag schema、破坏性判定统一为 log、工作区检查入 §2、真 bug 定修复(#5/#6)、引用统一、act_runner Docker 能力、alembic_version 容错、13 条规则内联;**磁盘扩容已完成 100G** +> - **v6** — 吸收 10 条遗漏:`--no-deps` 绕过 migrate 依赖(需显式验 migrate 退出码)、predeploy 快照全量无排除、logrotate copytruncate、.env/config 回滚耦合、compose 服务名上机 #7、自动回滚数据源 = 最近 predeploy、registry 盘余量、CI/手动共享 flock、/healthz 端点、4 workers 语义澄清 +> - **v7** — 吸收 10 条遗漏:**frontend healthcheck 改 busybox wget(nginx:alpine 无 curl,真 bug 修正)**、二期 image 改 registry 全地址固化、growpart 补步、公网 HTTP registry 凭证嗅探、回滚边界(未完成=清理/部分完成=回滚)、migrate成功+backend失败子场景、恢复演练双备份、alembic before/after 顺序、rollback 追 log、§3 加注 +> - **v8** — 吸收 25 条(P1-P15 + E1-E10):**migrate 验证改 `docker compose run --rm` 前台阻塞**(P1 up -d 异步下 ExitCode 误判 0 + P2 restart:no 二次不重跑,合并修法)、P3 predeploy 快照迁移窗口局限、P4 前端 VITE build 期固化、P5 采集命令免密码(run alembic current)、P6 daemon.json 改需重启 docker、P7 restart 已存在确认、P8 backend 无卷已查/未来加卷防线、P9 日志轮转二选一、**P10 TLS 新小节**、P11 备份推 COS 异地、P12 destructive 判定细化、P13 资源 limits、P14 冷启动 runbook、P15 alembic 多 head、E1 predeploy 清理保护、E2 rollback 双 tag 原子、E3 CONCURRENTLY/pgbouncer 迁移限制、E4 worker grace 统一 stop_grace_period、E5 rollback 跳 rollback 事件行、E6 外部反代、E7 TZ 决策、E8 RPO、E9 worker 任务幂等、E10 nginx.conf 改需 rebuild +> - **v9** — 吸收 8 条(N1-N8):**N1(最要紧)失败自动回滚数据丢失洞——`.pending-deploy` 标记机制**(versions.log 只在成功后才写,失败处理器读它必读到"上一次成功"的 destructive=否 → 朴素换 tag → 破坏性迁移已落地 + 旧代码丢数据;改为 migrate 前写 `.pending-deploy` 含 sha/destructive/predeploy 快照路径,失败处理器读标记,成功后才挪进 versions.log)、N2 deploy.sh 合成单一序列(migrate 插 tag↔up 之间)、N3 versions.log 采集改 run --rm、N4 N1 同根因单列、N5 冷启动门对无 healthcheck 服务改判 running、N6 worker 健康门控落到可执行方案、N7 备份路径统一、N8 跑迁移前显式 export BACKEND_TAG +> - **v10** — 吸收 8 条(M1-M4 + V5-V7):**M1 nginx /api proxy_pass 必须写 compose 服务名 `backend:8000`**(写 localhost 会跨容器调自己必失败;实测 nginx.conf:83 已正确,文档固化防改错);**M2 worker/migrate 显式用 `${BACKEND_TAG}`**;**M3 首次部署无 prev 的 destructive 判定小注**;**M4 worker/migrate 只写 `image:` 不写 `build:`**(复用预构建镜像);**V5(真实)worker healthcheck 改 python 探活**(worker 镜像无 redis-cli,N6 的 redis-cli 探活会 not found → 永远 unhealthy → 门控卡死);**V6 内置验证轮询等健康**(up 后 start_period 内勿立刻 curl);**V7 迁移期间无并发改口 run --rm 阻塞保证** +> - **v11** — 吸收 3 条 + 2 阻塞项重申:**①gitea_data 卷备份缺口**(含 git 仓库 + 二期 registry blobs,§4 只 pg_dump 漏了它——busybox tar + COS 异地,一期标注二期前补);**②破坏性回滚数据回退落到命令形态**(优先 `run --rm backend alembic downgrade <上一 revision>` 确定性脚本化,predeploy dump 兜底,写进 rollback.sh);**③后端日志落盘提前到一期 §8**(镜像重建 json-file 日志即丢、查日志是日常刚需,与北极星直接相关;一期对齐前端 nginx 挂卷 + logrotate copytruncate + P9 二选一,二期 §4 只留 Loki/Prometheus 高级归集);阻塞级 #1 backup.sh、#8 TLS 维持不变 +> - **v12** — 吸收 5 条(A-E):**A(真实会踩)§8 backend 日志卷激活 P8 权限坑**——backend 是 `USER scilit` 非 root,挂 root 属主主机目录写不进 → 首跑即 PermissionError(nginx root 跑无此问题);按 §6 P8 结论 chown/固定 UID+卷初始化;**B(概念陷阱)破坏性回滚主路径改为 predeploy dump,downgrade 降级**——alembic downgrade 只反向 schema 不恢复数据(DROP COLUMN 要么 no-op 要么 NotImplementedError,被删列数据回不来),主路径 = pg_restore 快照;**C frontend 一期构建机制写清**(compose 含 frontend build + Dockerfile.prod,deploy.sh build 覆盖前后端);**D .pending-deploy 落盘位置明确**(部署目录持久路径、gitignore、别放 /tmp);**E gitea_data tar 备份加数量保留 N=3**;阻塞级 #1/#8 维持不变 +> - **v13** — 吸收 5 条(F-J):**F destructive 判定基线钉死**——`` = `git pull` 前的本地 HEAD,deploy.sh 在 pull 前 `PREV_SHA=$(git rev-parse HEAD)` 采集(此刻 HEAD = 服务器当前生产版本),`` = pull 后新 HEAD;严禁写成 pull 后的 `HEAD~1..HEAD`(一次 pull 多 commit 迁移会漏判/错判);**G deploy.sh 开头 `set -a; source <部署目录>/.env`**——`${PG_PASSWORD}`/`${REDIS_PASSWORD}` 用于 psql 备选采集、worker 探活,不 source 则变量为空 → 连不上/密码错;**H predeploy 用 `pg_dump -Fc`**——pg_restore 只吃 custom 格式,plain 只能 psql -f 恢复;-Fc 兼容 + 大库并行恢复 -j;**I deploy.sh 开头检测遗留 `.pending-deploy`**——上次中途崩溃(如重启)残留,先打印"上一次部署异常退出,请确认状态"再继续,不静默覆盖;**J 宿主机建 `/etc/logrotate.d/scilit-backend`**——backend + nginx 两路径同块、`copytruncate`,cron.daily 自动轮转 → **v14** — 吸收 2 条(K-L):**K 迁移 run --rm 加 `--no-deps`**(backend 的 depends_on 含 migrate 服务,`docker compose run` 默认会先拉起 migrate 依赖再跑 → migrate 服务先跑一遍 upgrade head、紧接显式那条又跑一遍——虽 alembic 幂等不报错,但迁移跑两次、与"迁移统一由 run --rm 做"的立意自相矛盾;全部 run 命令加 `--no-deps`,postgres 由冷启动健康门保证已 healthy);**L 破坏性回滚 pg_restore 补覆盖方式**(H 改 `-Fc` 后直接 `pg_restore -d scilit` 会因对象已存在报错——明确 `pg_restore --clean --if-exists -d scilit <快照>` 先清后恢复,或临时库恢复再 rename) +> - **v15** — 2026-08-09 首轮执行 + 一次生产事故复盘:**✅ §0 磁盘扩容全部落地**(growpart + **xfs_growfs**——文件系统是 xfs 不是 ext4,resize2fs 会报 Bad magic number,§0 正文已改);**✅ §4 backup 落地**(服务器 `/root/scilit/scripts/backup.sh`,compose-exec pg_dump -Fc 机制,crontab `0 3 * * *`,手动验证 2.5GB/302 TOC/46 表);**✅ §7 TLS 走②前置 Caddy 落地**(frontend 宿主端口 80→8080、容器内仍 80,Caddy 占 80/443 自动证书,证书经 tls-alpn-01 签发成功,80→308 跳转,PUBLIC_BASE_URL/CORS_ORIGINS 已同步 https);**⚠️ 事故复盘(真坑,补进 §2/§3)**:手动 `docker compose up -d frontend` **未加 `--no-deps`** → 拉起 frontend→backend→migrate 依赖链 → migrate 以**旧镜像**跑 `alembic upgrade head` 报 `Can't locate revision '6b662a8c5235'`(**生产镜像 2-3 周未更新、落后于 DB schema**,DB head 6b662a8c5235 旧镜像不认识)→ backend/frontend 全部 Created 未启动、应用中断。恢复:恢复旧 backend 镜像 + `up -d --no-deps --no-build backend frontend`。**结论固化**:任何 `up/run` 动应用容器必须 `--no-deps`(本方案已写,执行时务必照做);**生产镜像落后于 DB schema 是部署事故隐患**——当前运行镜像(backend a6d197830cc2/frontend 95f910a68bd9,7月中)比 DB(head 6b662a8c5235)旧,真正部署新代码前需先对齐 + +## 北极星定位 + +**核心诉求 = 方便运维**——日常更新/部署更快、更稳、可回滚、少踩坑、可观测、可恢复。 + +**关键认知:** +- 易运维的关键是**生产侧可复现、可回滚、可观测、可恢复**,不是"本地 = 生产" +- 宿主机层(Windows vs 腾讯云 CVM)不可能完全一致;容器化保证**容器层一致**(同镜像 → 依赖 / schema / 迁移 / 构建产物一致) +- 驱动痛点的根源是**手工多步部署脆弱**(docker cp 不持久、`__pycache__`、worker 不重启、assets 污染、迁移文件、无版本回滚) +- **生产磁盘允许扩容**(腾讯云 EBS 可在线扩容,非破坏性)——CI + Registry 的磁盘约束随之解除 + +**总体形态**: +- **一期(基座)**:不依赖 CI,立刻可用——版本化 + 脚本化 + 迁移安全 + 备份演练 + 镜像治理 + Dockerfile 卫生 +- **二期(自动化)**:Gitea Actions + Container Registry 全自动 CI/CD + 可观测 + feature flag +- 一期是二期的**前置条件**(版本化、健康门控、镜像治理必须先有),二者衔接不冲突 + +--- + +## 一期:生产工程化基座(独立可落地) + +### 0. 磁盘扩容(✅ 已全部落地 2026-08-09) +- **2026-08-08 云盘从 20G 扩至 100G**(腾讯云 EBS);**2026-08-09 补做分区 + 文件系统扩容**:`growpart /dev/vda 1 && xfs_growfs /` → 生效 100G,可用 25G、76% +- **⚠️ 文件系统是 xfs,不是 ext4(真坑,v15 修正)**:`df -h` 未生效时完整三步 = ①控制台扩 → ②`growpart /dev/<盘> <分区号>` 扩分区 → ③**`xfs_growfs /`** 扩文件系统。**绝不能写 `resize2fs`(ext4 专用,xfs 上直接报 `Bad magic number in super-block`)**——xfs 用 `xfs_growfs`,且 xfs 不支持缩容。先 `lsblk -f` 确认 FSTYPE 再选工具 +- 保留认知(扩容时已确认目标盘):pgdata/redisdata/esdata/miniodata/gitea_data(registry 复用 gitea_data)是**不同卷、可能在不同挂载点**,CI 缓存(二期)在 runner 本地——已扩 pgdata 所在吃紧盘 +- 容量依据:pgdata 卷增长 + 多版本镜像(N=5,后端 ~500M×5)+ CI 构建缓存(二期)+ registry 存储(二期,复用 gitea_data 卷)+ 日志 + +### 1. 版本化镜像标签(镜像即版本) +- 每次构建打 **git-sha 标签**:`docker tag scilit/backend:latest scilit/backend:`(前端同理) +- **compose 用环境变量插值引用 git-sha(后端 + 前端双变量,v5 固化到主体)**:`image: scilit/backend:${BACKEND_TAG:-latest}`、`image: scilit/frontend:${FRONTEND_TAG:-latest}`,deploy.sh 同时传 `BACKEND_TAG= FRONTEND_TAG=`——compose 文件稳定、git pull 不冲突,sha 只在运行时传入。不用 `latest` 当生产版本(`latest` 不指向确定提交,仅便捷别名) +- 保留最近 N 个旧 tag(磁盘允许下 N=5–10);**回滚 = 换 tag 重启**,秒级 +- 前置:给 backend/worker/**migrate**/frontend 在 `docker-compose.prod.yml` 显式 `image:`(当前由 compose project 名生成,基线不稳定);**worker/migrate 与 backend 共用同一镜像 + 标签,显式用 `${BACKEND_TAG}`(M2)**——backend/worker/migrate 三服务 `image: scilit/backend:${BACKEND_TAG:-latest}`、frontend `image: scilit/frontend:${FRONTEND_TAG:-latest}`,**跑迁移的代码版本必须 = 生产版本**(否则 migrate 用的是旧镜像,迁移与代码不同步);**worker/migrate 只写 `image:`、不写 `build:`(M4)**——复用 deploy.sh 预构建的版本镜像,移除 `build: ./backend`,避免"声明了 build 但部署时不 build"的矛盾语义(backend 保留 build 作为构建源) +- **前端缓存策略配套(换 frontend 镜像后浏览器缓存 404)**:旧浏览器缓存的 `index.html` 会去请求已不存在的旧 hash 资源 → 404。nginx 对 `index.html` 设 `Cache-Control: no-cache`,对 `assets/*`(哈希文件名)长缓存 `immutable`——改 `frontend/nginx.conf`;**⚠️ nginx.conf 是 COPY 进镜像的(E10)**——改它必须 rebuild 前端镜像重新部署,`docker cp` 进容器不持久、重启即丢 +- **⚠️ nginx /api 的 proxy_pass 必须写 compose 服务名 `backend:8000`(M1,文档固化防改错)**:实测 `frontend/nginx.conf:83` 已正确写 `set $backend_upstream http://backend:8000;`——**写 `localhost:8000` 会跨容器调自己、必然失败**(frontend 容器内 8000 无服务)。约束固化:proxy_pass 目标永远用** compose 服务名 + 端口**(backend:8000),不用 localhost/127.0.0.1;nginx 侧 resolver 动态解析(nginx.conf 已配 `resolver 127.0.0.11`)配合 backend 容器重建后 IP 变化 +- **frontend healthcheck + 双 tag 原子回滚(v5 提升到主体)**:frontend 需自身 healthcheck,不能只靠 `depends_on: backend: service_started`;**⚠️ nginx:alpine 默认不含 curl(v7 真 bug 修正)**——healthcheck 用 **busybox `wget`**(alpine 自带):`wget -q -O- http://localhost/ || exit 1`,或 Dockerfile 另装 curl;**回滚 = backend+frontend 双 tag 原子切换**——两者同一次部署、同一条 versions.log 记录,绝不允许只回一个导致前后端版本错配 + +### 2. 脚本化部署(消灭 13 条规则的坑) +> **"13 条规则"来源内联(v5)**:docs/10-生产部署文档.md §12 的 13 条严格部署规则(记忆 production_deployment_rules.md)——本方案将其固化为脚本,此处不重复罗列,执行时以脚本为准 +- **compose 范围澄清(阻塞级)**:`up -d --no-deps backend worker frontend` 意味着 gitea/postgres/redis/es/minio 必须已在跑——**先确认这些服务与 backend 在同一 `docker-compose.prod.yml`**(`docker compose ps` 实查),部署前基础设施已在跑,避免漏起或误动 +- **冷启动 vs 热部署(P14)**:`up -d --no-deps` 假设基础设施已在跑,**服务器重启后全栈 down 时直接跑 deploy.sh 会连不上 db**——deploy.sh 顶部加**基础设施健康门**:postgres/redis/es/minio 状态异常即中止并提示先冷启动;**⚠️ 门控判定按"有 healthcheck 判 healthy、无 healthcheck 判 running"(N5——不能一刀切要求 healthy,否则对没配 healthcheck 的服务门控永远失败、deploy 永远跑不了)**;注:当前 prod compose 四服务均已配 healthcheck(pg_isready / redis-cli ping / es curl / minio curl),但脚本仍按 running 兜底、防未来新服务漏配;冷启动 runbook(写进 docs/10):`docker compose up -d postgres redis es minio gitea` → 等 `docker compose ps` 达门控标准 → 再走 deploy.sh +- **`deploy/deploy.sh`(v9 合成单一序列,N2——migrate 编进有序步骤,照做不漏步)**:**脚本开头先 `set -a; source <部署目录>/.env; set +a`(G——`${PG_PASSWORD}`/`${REDIS_PASSWORD}` 在 psql 备选采集、worker 探活里用到,不 source 则变量为空 → psql 连不上/探活密码错;`.env` 不进 git,靠运行时加载)** → ①**工作区干净校验**(`git status --porcelain` 为空,否则 hotfix 残留导致 pull 冲突,冲突即中止)→ **①.5 采集 `PREV_SHA=$(git rev-parse HEAD)`(F——destructive 判定基线,必须在本步 pull 之前,见 §3)** → ②`git pull` → ③**预部署 pg_dump 快照(安全网)** → ④build + 打 git-sha 标签 + **`export BACKEND_TAG= FRONTEND_TAG=`**(给后续 `run --rm`/`up` 用,N8;**frontend 镜像一期来源写清(C)**:compose 已含 `frontend: build: {context: ./frontend, dockerfile: Dockerfile.prod}`([frontend/Dockerfile.prod](frontend/Dockerfile.prod))——deploy.sh 的 build 步对 backend/frontend 都 `docker compose build`,一期无 CI 也不用手动 `docker build -f`) → ⑤**采 before alembic head → 写 `.pending-deploy` 标记(N1,见下条)** → ⑥**跑迁移** `docker compose run --no-deps --rm backend alembic upgrade head`(K——**必须 `--no-deps`**:backend 的 depends_on 含 migrate 服务,`run` 默认先拉起 migrate 依赖再跑 → migrate 服务先跑一遍 upgrade、紧接这条又跑一遍,迁移跑两次且与"迁移统一由 run --rm 做"自相矛盾;postgres 由冷启动健康门保证已 healthy,`--no-deps` 不会连不上;前置:postgres healthy;**退出码非 0 即中止**,见下条)**→ 成功后采 after alembic head** → ⑦`up -d --no-deps backend worker frontend`(显式指定,不动基础设施;migrate 的 depends_on 门控作双保险) → ⑧**内置验证——轮询等健康(V6)**:up -d 后新 backend 仍在 start_period(healthcheck 未过),**立刻 curl 会命中启动中、误判失败**——**轮询 `/health` 直到 healthy 或超时(如 60s×5s)**,再验前端 200 → ⑨**全部成功后才把 `.pending-deploy` 挪进 versions.log**(追加一行 + 删标记) → ⑩镜像治理清理。**失败处理分两段(v7 边界 + v9 读标记修正)**:**①部署未完成(③-⑥之间失败,迁移未跑)** → 容器还是旧的、**无需数据回滚**,只清理失败中间态(临时镜像/标签)即可;**②部署部分完成(迁移已跑/容器已切)** → 调用 `rollback.sh`——**读本次 `.pending-deploy` 而非 versions.log(N1,见下条)**;**回滚数据源(v6 补)**:需数据回退时**优先用 `.pending-deploy` 里记的本次 predeploy 快照**(部署前最新状态),**绝不用更早的 dump** +- **⚠️ `.pending-deploy` 标记(N1 最要紧——失败自动回滚的数据丢失洞修复)**:versions.log **只在部署成功后才写**——若某次部署跑了**破坏性迁移(已成功落地)**、紧接着 backend up 失败,失败处理器调 rollback.sh 时读 versions.log,读到的必然是**上一次成功部署**的 `destructive=否` → **朴素换 tag、不回退数据** → 破坏性迁移已落地 + 旧代码 = 数据/代码不匹配、**丢数据**。**修复:deploy.sh 在 ③快照后、⑥迁移前写 `.pending-deploy`**,内容含 ` <本次 predeploy 快照路径>`(destructive 由 §3 的 git diff 判定此时即算出)——**失败处理器②只读 `.pending-deploy`,绝不读 versions.log**;**N4(N1 同根因单列)**:任何"部署失败时读 versions.log 判定本次 destructive"的做法都是这个洞——versions.log 记录的是上次成功,覆盖不了本次失败,统一只认 `.pending-deploy`。**⑨全部成功后才把它挪进 versions.log**(追加 + 删除标记);失败路径回滚完成后同样清除标记;**⚠️ 落盘位置(D)**:`.pending-deploy` 是 deploy.sh 在主机写的**运行时标记**——必须落在**持久主机路径**,即部署目录内稳定位置 `/.pending-deploy`(**别放 /tmp**,重启即清;**别放会被 `git clean`/hotfix 清理或与 `git pull` 冲突的位置**),且该文件加入 **`.gitignore`**(运行时状态不进 git);**⚠️ 遗留标记检测(I)**:deploy.sh 开头(source .env / 工作区校验前)先查 `/.pending-deploy` 是否存在——存在说明**上一次部署中途崩溃(如服务器重启)未正常收尾**,deploy.sh **不得静默覆盖**:先打印"⚠️ 上一次部署异常退出,残留 `.pending-deploy`,请先确认当前状态(容器/迁移/versions.log)再继续",人工确认后清理标记或由脚本继续 +- **强制重建**:`up -d` 默认镜像 tag 变才重建,若 `${BACKEND_TAG:-latest}` 解析出的 latest 与旧容器一致会**不重建容器**("改了代码没更新"经典坑)——deploy.sh **始终传 BACKEND_TAG=<新sha>** 或加 `--force-recreate` +- **migrate 时机 + 显式验证(v8 关键重写——P1+P2 合并修法)**:`up -d --no-deps` 会**跳过全部 depends_on 检查**,只靠时间顺序无法保证 backend 等 migrate 退出。且**原 v6 的"`up -d migrate` + `docker inspect ExitCode`"方案本身有两个真 bug**:**P1** `up -d` 异步返回,容器仍在 running 时 `inspect .State.ExitCode` 恒为 0(running 状态 ExitCode 无意义)→ **migrate 还没跑完就被误判成功**;**P2** migrate `restart: "no"`,首次跑完即 exited,**二次部署时 `up -d migrate` 对已 exited 且配置未变的容器不重跑** → **迁移静默跳过**。**合并修法:迁移统一用 `docker compose run --no-deps --rm backend alembic upgrade head`(K——必加 `--no-deps`,见上条)**——①前台阻塞到容器退出,**退出码即真值**(无 P1 误判);②每次都是全新容器**确定性重跑**(无 P2 跳过);③容器内走 `DATABASE_URL` 自带密码(顺带免 P5 的 PGPASSWORD 问题);④`--no-deps` 不拉起 migrate 依赖,避免迁移跑两次。**⚠️ 跑 `run --no-deps --rm backend` 前必须先 `export BACKEND_TAG=<新sha>`(N8)**——`run --no-deps --rm` 用的是 compose 的 `image:` 字段,`${BACKEND_TAG:-latest}` 未设则解析成 latest → **拿旧镜像跑迁移**(旧迁移、对不上新代码);deploy.sh 在 ④build/tag 时已 export(见上条),但脚本内 ⑥迁移步骤前显式再确认一次(幂等,防手改脚本漏掉)。前置:postgres 必须 healthy(冷启动门已保证,见上条 P14)。**退出码非 0 即中止部署**(这同时落实了 §3"失败即中止/迁移期间无并发"的真正保证点)→ 确认退出 0 后再 `up -d --no-deps backend worker frontend`。migrate 服务保留在 compose(作为声明式迁移入口 + 非 `--no-deps` 路径的 depends_on 门控),但**脚本判定只认 `run --no-deps --rm` 的退出码** +- **`deploy/rollback.sh`(v9 双入口修正,N1 配套)**:换旧 tag + 重启;**两种入口读不同来源——①手动回滚(人主动跑、目标是任意历史版本)**:旧 sha + destructive 从 **versions.log** 读(**跳过 rollback 事件行,E5**——取最近的非 rollback deploy 行,否则会回滚到"上一次回滚"、甚至反复回滚循环);**②部署失败自动回滚(deploy.sh 失败处理②调)**:读**本次 `.pending-deploy`** 的 sha + destructive + 快照路径——**绝不用 versions.log**(那是上一次成功的判定,正是 N1 的洞);**先判迁移**(本次 sha 是否带新迁移,决定是否先数据回退再换 tag);**破坏性数据回退落到命令形态(v12 概念修正——downgrade 不恢复数据)**:**数据恢复主路径 = `pg_restore` 本次 predeploy 快照**(数据 + schema 一起回);**⚠️ 覆盖现有库的方式(L——H 改 `-Fc` 后的收尾)**:目标库已有旧 schema/数据,直接 `pg_restore -d scilit <快照>` 会因**对象已存在**报错——必须**先清后恢复**:`pg_restore --clean --if-exists --no-owner -d scilit <快照>`(`--clean` 先 DROP 已存在对象、`--if-exists` 缺对象不报错、`--no-owner` 免属主匹配),或更稳妥的**临时库恢复再 rename**(起临时库 `createdb scilit_restore_tmp` → 恢复 → 校验 count → 停 backend → `ALTER DATABASE scilit RENAME TO scilit_old; ALTER DATABASE scilit_restore_tmp RENAME TO scilit` → 起 backend;rename 方式迁移窗口更短,适合大库);**⚠️ alembic `downgrade()` 只反向 schema、不恢复数据**——DROP COLUMN 的迁移 downgrade 要么 no-op 要么 NotImplementedError,**被删列的数据永远回不来**,若优先 downgrade 会"以为安全其实不安全"。downgrade 仅用于**可逆的非破坏性 schema 调整**这类少见场景(且同样丢该 schema 内数据);downgrade 目标 revision(如用)= `.pending-deploy`/versions.log 里的 **before alembic head**;**回滚动作也追加一条 versions.log(标记 rollback 事件,v7 补)**——便于审计回溯「何时部署、何时回滚、回滚到哪个 sha」;**双 tag 原子切换(E2)**:`BACKEND_TAG=<旧sha> FRONTEND_TAG=<旧sha>` **同时传**、一次性 `up -d`——绝不分两次 up(中间态前后端版本错配) +- **`deploy/hotfix.sh`**:仅紧急单文件,**强制回流**——用后必须 git 提交 + 正式部署,杜绝"手工改与仓库不一致"重演;**末尾强制收口**:打印"docker cp 不进镜像、容器重建即丢"警告 + 提示限时完成正式部署(hotfix 属脆弱窗口) +- **.env/config 与代码版本耦合(v6 补,回滚漏项)**:新代码可能依赖新 env 变量,回滚旧代码后 .env 仍是新的——旧代码若**缺必需变量会启动失败**。处理:①代码对新增配置**尽量给默认值/可选**(向后兼容),回滚旧代码总能启动;②`.env` 本体**仍不进 git**(含密钥),非机密配置默认值随 `docker-compose.prod.yml`/`.env.example` 走 git 版本;③回滚 = 换 tag 时**确认旧代码不依赖本次新增的必需变量**(deploy.sh 可在回滚前 diff 校验);**前端是 build 期固化(P4,与后端机制不同)**:Vue 的 `VITE_*` 变量在 `npm run build` 时写死进 dist——运行时改 compose 的 `VITE_API_BASE` **不生效**,改前端任何构建期变量 = 必须 rebuild 前端镜像重新部署(当前 compose 里 frontend 的 `VITE_API_BASE: /api/v1` 是部署时摆设,真值在构建时已固化) +- **Worker 优雅停机(防长任务丢失)**:镜像优先会重建 worker,但 `up -d` 默认立刻 kill——worker 正跑长任务会丢/坏任务。**worker 捕获 SIGTERM 完成当前任务或重新入队(ARQ 支持 graceful shutdown),compose 设 `stop_grace_period: 60s`(E4 统一此处,删掉 deploy.sh 手动 SIGTERM——compose 重建时本来就会先 SIGTERM 再等 grace,手动发是重复机制)**;**⚠️ 重入队的前提是任务幂等(E9)**:SIGTERM 后任务回到队列重跑,若任务**非幂等**(重复执行有副作用,如重复发邮件/重复扣款/重复写重复数据)则优雅停机反而造成重复执行——部署前确认所有 ARQ 任务幂等(作业内做去重/幂等键),否则仅完成当前任务、不重入队 +- **并发部署锁**:两人/两终端同时 deploy 会抢 tag 和 versions.log。**deploy.sh/rollback.sh 顶部 `flock` 单实例锁**(`exec 9>/tmp/scilit-deploy.lock; flock -n 9`),拿不到锁即中止。一期手动风险低,二期 CI 自动部署后变硬需求——现在定习惯成本最低 +- **`deploy/versions.log`**:每次部署记录 `<时间> <迁移前/后 alembic head> <镜像> `(v5 双 tag schema)——**后端/前端独立 tag**,供 rollback 双 tag 原子回滚读旧版本;**alembic head 由 deploy.sh 采集(宿主机无 venv,必须经容器执行)**——**顺序(v9 统一 run --rm,N3):①迁移前采 before ②`docker compose run --no-deps --rm backend alembic upgrade head` 成功(退出码 0)后采 after**(原 v7 写的是 `up -d migrate`,与 P1 改 run --rm 矛盾,统一;before 采集在写 `.pending-deploy` 前、after 在⑥迁移后),各执行 **`docker compose run --no-deps --rm backend alembic current`**(容器内走 `DATABASE_URL` 自带密码,**无需 PGPASSWORD——P5**;psql 备选 `docker compose exec -T postgres psql -U scilit -d scilit -tAc "SELECT version_num FROM alembic_version"` 在 pg_hba 非 trust 时会要密码,须加 `PGPASSWORD=${PG_PASSWORD}` 前缀)落库(一次性 migrate 容器跑完即退,无法自行回传,必须 deploy.sh 代采);**⚠️ 首次部署 alembic_version 表可能不存在(v5 容错)**:采集前先 `SELECT to_regclass('alembic_version')` 判存在,表不存在 → 记空,否则首跑即 abort;**自身轮转**——每次追加后 `tail -n 200` 截断(或配 logrotate),防高频部署下无限增长 + +### 3. 迁移安全(高频升级最易翻车点) +- migrate 独立成服务 + `depends_on: migrate: service_completed_successfully`(已在 prod compose) +- **向后兼容规范**:先加字段/表,不删不改旧结构;旧代码全下线后下一版再清理 +- **失败即中止**:migrate 失败 → backend/worker 不启动,不替换容器;**⚠️ 加注(v7 起)**:在手动 `up -d --no-deps` 流程下,这**并非 compose 自动保证**——`--no-deps` 跳过 depends_on,真正保证在 §2 的「`run --rm` 前台跑迁移、退出码非 0 即中止」步骤(v8 起判定方式见 §2),勿误读为 compose 自动行为 +- **迁移期间无并发(V7 改口,与 §2 一致)**:**`run --rm` 前台阻塞保证** migrate 完成(exit 0)后才起 backend/worker(手动 `--no-deps` 路径下 compose 的 depends_on 顺序**不生效**,同 §2 N2/N3——真正保证在 §2 的 run --rm 前台阻塞 + 退出码判定),避免"新迁移 + 旧代码"并发跑在旧 schema 上;首次/失败路径也要确认不出现并发(部署窗口内 backend 保持旧版直至 migrate 通过) +- **⚠️ 2026-08-09 事故复盘(真坑,v15 补)**:手动 `docker compose up -d --no-build frontend` **没带 `--no-deps`** → compose 按依赖链拉起 frontend→backend→migrate;migrate 以 `scilit-migrate:latest`(**旧镜像,2-3 周未更新**)跑 `alembic upgrade head` → **`Can't locate revision '6b662a8c5235'`**——**生产 DB schema(head 6b662a8c5235)比运行中镜像认识的 head 新**,旧镜像的 alembic/versions 里没有该 revision → migrate 退出 255 → backend(depends_on migrate service_completed_successfully)与 frontend 全部 **Created 未启动、应用中断**。**恢复**:`docker tag <旧backend镜像> scilit-backend:latest` + `docker compose up -d --no-deps --no-build backend frontend`。**教训固化**:①任何动应用容器的 `up/run` **必须 `--no-deps`**(§2 已写死,执行时照做,别省略);②**生产镜像落后于 DB schema 是隐患**——当前运行镜像(backend a6d197830cc2 / frontend 95f910a68bd9,2026-07-17 构建)比 DB head 旧,`upgrade head` 在这种状态下**必然失败**;真正部署新代码前需先把镜像更新到与 DB 对齐的版本(§1 版本化镜像正是解药);③`up -d <服务>` 的依赖链是 **frontend→backend→migrate**,误触发 migrate 的代价是整条链全停——冷启动/单独起某服务一律用 `--no-deps` +- **迁移回滚 runbook(写进 docs/10)**: + - **明文约定**:只要坚持"向后兼容、只增不删",回滚(换旧 tag)就是安全的——旧代码对新加的列/表可忽略 + - **破坏性迁移硬判定(v5 统一为 deploy 时落 log,替代扫工作区)**:破坏性迁移(删列/改类型/重建表)文件统一命名 `destructive_*.py`(或迁移文件头部醒目 `# DESTRUCTIVE` 注释)**作双保险**;**主判定改为 deploy.sh 部署时基于 `git diff .. -- alembic/versions/` 判定,把"是否 destructive"写进 versions.log**;**⚠️ `` 基线钉死(F)**:`` = **`git pull` 前的本地 HEAD**——deploy.sh 在 ①工作区校验后、②`git pull` 前采 `PREV_SHA=$(git rev-parse HEAD)`(此刻 HEAD = 服务器当前生产版本),`` = pull 后新 HEAD,`git diff PREV_SHA..` = 本次部署真正引入的迁移;**严禁写成 pull 后的 `HEAD~1..HEAD`**——一次 pull 常带入多个 commit 的迁移,`HEAD~1` 不是上次生产版本,diff 会漏判/错判 destructive;**⚠️ 判定规则必须细化,不能笼统"检测 drop/alter"(P12——过粗会让每次 `ADD COLUMN` 都误触发、强制数据回退)**:判 destructive **只看**真正破坏性操作——`DROP TABLE / DROP COLUMN / DROP INDEX`、`ALTER COLUMN TYPE`(类型变更)、`RENAME`(表/列/索引重命名)、`ALTER COLUMN SET/DROP NOT NULL` 收紧、重建表(create_table 后 drop 原表)、破坏性数据变更(批量 UPDATE/DELETE);**明确不计入(良性)**:`ADD COLUMN`、新建表、`CREATE INDEX`(非 CONCURRENTLY)、`ALTER COLUMN SET DEFAULT / DROP DEFAULT`、加约束——向后兼容,标非破坏性——`rollback.sh` 回滚时**读 log 的 destructive 字段**而非扫当前工作区(扫工作区会被后续版本删除/改名骗过 → 漏判破坏性迁移 → 以为安全回滚其实丢数据)。标记 destructive → 强制先数据回退(**主路径 pg_restore 本次 predeploy 快照,downgrade 仅做 schema 反向不恢复数据——v12 概念修正,命令形态见 §2 rollback.sh**)再换旧 tag;无 → 直接换 tag 安全;**首次部署无 prev(M3,小注)**:`git diff ..` 无基线 → **改为直接对当前 `alembic/versions/` 目录做同样的 keyword 检测**——初始迁移多为 CREATE 建表,实测判非破坏性、风险低,不需特殊流程,小注记录即可 + - **代码回滚 ≠ 迁移回滚**:Alembic 单向递增,回滚代码一般不回退迁移;含破坏性迁移时才触发数据回退流程 + - **migrate 成功 + backend 失败子场景(v7 补)**:此时数据已是新 schema(且向后兼容规范下**只增不删**)——回滚只需换 tag、**不需数据回退**,与 rollback.sh 读 destructive=非破坏性(直接换 tag)一致 +- **Postgres 大版本锁定**:`pgvector:pg16` 的 major 与数据卷**强绑定**;**升 major 必须 pg_dump/restore 迁数据**,绝不直接 `up -d` 换镜像(否则数据卷不兼容起不来)——此条写进 docs/10 显眼位置 +- **pgvector 升 major 的特殊性(restore 前必查)**:dump/restore 恢复 vector 数据时,**目标库必须先 `CREATE EXTENSION vector`**,且扩展版本与目标 pgvector 镜像匹配;vector 索引(ivfflat/hnsw)恢复依赖扩展存在,先建扩展再恢复,否则首次升 major 必踩 +- **alembic 多 head 必须提前拦住(P15)**:并行 PR / 单人多分支各自新增迁移、同 down_revision → `alembic heads` 返回多个 → `upgrade head` **直接报错中止、不应用任何迁移**。防线:①CI 加一步 `alembic heads` 校验,>1 即失败;②deploy.sh 迁移前 `docker compose run --no-deps --rm backend alembic heads` 预检,多 head 即中止;③多人协作约定:合并前先 `alembic merge` 或串行 rebase 迁移(一人一个 base) +- **迁移执行受限操作(E3)**:`CREATE INDEX CONCURRENTLY` **不能跑在事务块内**——alembic 默认把迁移包在事务里,需并发建索引用 `with op.get_context().autocommit_block():`;未来若上 pgbouncer 事务池,长事务迁移会被池限制影响(迁移建议直连 postgres 服务、绕过池) +- **批量迁移执行序 + destructive ordering(v16 补,2026-08-10,专项见 docs/17)**:生产 DB 落后本地多个迁移时,**不能整条链一次 `upgrade head`**,须分批执行到中间 checkpoint(`migrate_prod.sh 1|2|3|4`,见 docs/17 §4)。**关键纪律——destructive 批次不能提前单独跑**: + - **批次 1(`1421ea169bb6` 日期 TIMESTAMPTZ→DATE)是类型收窄**——老代码读 datetime 会崩,**必须与发新代码同窗口**(迁移完几秒内新镜像接管),绝不能提前单独跑 + - **批次 2/3 纯 additive**(加列/新表/索引),老代码兼容,**可提前任意时段跑**,缩小维护窗口 + - **批次 4(`95c18ebf31e4`/`55105f0bb1d7` VARCHAR→Text + 大数据量回填)须新代码已部署后深夜跑** + - 推荐序:**批次 2/3(提前)→ 维护窗口:build 新镜像 → 批次 1 → 发新 backend/worker/frontend → 深夜:批次 4** + - 配套:build 加速已落地(§6 Dockerfile 卫生),迁移用 `run --no-deps --rm backend alembic upgrade `(新镜像自带新迁移,勿退回老容器 exec) +- **统一备份脚本(✅ 已落地 2026-08-09,原阻塞级 #1 查清)**:上机确认 **crontab 原本无任何 backup 条目、backup.sh 根本没在跑**(历史 docs/10 的 `/home/scilit/backup.sh` 不存在),且仓库版 `PG_HOST=localhost` 连不上(postgres `ports: []` 无宿主端口)——**已新建 `/root/scilit/scripts/backup.sh` 并落 crontab `0 3 * * *`**,机制为 **`docker compose -f docker-compose.prod.yml exec -T postgres pg_dump`**(不经宿主端口,容器内 pg_dump 16.14);排除表 pipeline_runs/api_usage_logs;`--format=custom --no-owner --no-privileges`;备份目录 `/data/backups`;保留 30 天;**手动验证成功**:2.5GB / `pg_restore -l` 302 TOC / 46 表 / Format CUSTOM。**路径统一约定(N7)**:脚本 = **仓库 `backend/scripts/backup.sh`**(随 git 分发,服务器部署目录内执行),备份目录 = **`/data/backups`**——服务器实际路径 `/root/scilit/scripts/backup.sh` 与仓库 `backend/scripts/backup.sh` 需在正式部署时统一对齐(当前以服务器实际为准);docs/10 的 `/home/scilit/backup.sh`、`/backup` 等历史路径**全部废弃** +- **`.env` 单点备份**:含 SMTP/JWT/API Key 全部密钥,git pull 不动它但只存服务器——**离线备份(不进 git)**,防丢密钥 +- **pre-deploy 快照 = 全量 pg_dump,与日常备份分开(v6 修正)**:§2 用 predeploy dump 当"全量安全网",但若复用排除 pipeline_runs/api_usage_logs 的 backup.sh,安全网本身就缺这两表、与"全量"定位冲突——**predeploy 必须用不带排除的完整 `pg_dump`**(含 pipeline_runs/api_usage_logs),与日常 backup.sh 分开执行;**⚠️ 用 `-Fc` custom 格式(H——pg_restore 只吃 custom 格式)**:§2 rollback.sh / §4 restore-drill 的数据回退都走 **`pg_restore`**,而 **pg_restore 要求 dump 为 `-Fc` custom 格式**——plain 文本格式(pg_dump 默认)只能 `psql -f` 恢复,pg_restore 直接拒绝;**格式钉死:predeploy 用 `pg_dump -Fc`**(custom 兼容 pg_restore,且大库可并行恢复 `-j`);日常 backup.sh 已用 `--format=custom`,核对生产实际跑的那份保持一致;**⚠️ 快照是"尽力安全网",不覆盖迁移窗口写入(P3)**:快照在**旧 backend 仍在服务时**拍的——拍完到新 backend 上线之间(migrate + 容器切换窗口)仍有业务写入,此窗口内回退会**丢这几分钟数据**。这是快照式安全网的固有局限、非 bug:长窗口靠日常 03:00 backup + RPO 预期(见 E8)兜底,部署窗口的分钟级丢失在低峰 + 短迁移下可接受——**不做"先停写再拍快照"的 drain 步骤**(单机不值当),但认知要写清 +- **pre-deploy 快照留存策略(容易漏)**:deploy.sh 每次部署前 dump 当安全网,高频部署下吃磁盘——**保留最近 N=3 个**(按数量清理,如 `ls predeploy_*.dump | sort | head -n -3 | xargs rm`),否则备份目录先爆;**⚠️ head -n -3 语义保护(E1)**:GNU `head -n -3` 是"去掉最后 3 行"——文件 ≤3 个时输出为空、`xargs rm` 无输入不执行,**不会误删但语义易读错**。deploy.sh 显式写成 `count=$(ls ... | wc -l); [ "$count" -gt 3 ] && ls ... | sort | head -n -3 | xargs -r rm`(`-r` 空输入不执行),防笔误 +- **⚠️ 清理依赖文件名可排序**:上面的 `ls | sort` 要靠文件名里嵌入**可排序的 ISO 时间戳**(`YYYYMMDD_HHMMSS`)才正确挑最旧;用别的格式(如相对时间命名)会删错文件——deploy.sh 统一命名规范 +- **补恢复演练**:`restore-drill.sh`(起临时 postgres → pg_restore → 校验 count)或文档化步骤,定期演练——否则备份等于没备;**演练覆盖两种备份(v7 补)**:①全量 predeploy(含 pipeline_runs/api_usage_logs)②日常排除 backup——**分别校验**,不能只验一种(排除表缺失/为空是否可接受要在两套上各自确认) +- **恢复校验覆盖排除表**:backup.sh 排除了 pipeline_runs/api_usage_logs,恢复演练**校验这些表缺失/为空是可接受的**(避免"count 一致但关键排除表没恢复"的误判) +- **备份必须异地(P11——同盘非真 DR)**:backup.sh 写 `/data/backups`(同 CVM 磁盘),**磁盘故障时备份与库俱毁,备份等于没备**。补:备份完成后自动上传**腾讯云 COS**(项目已有 `COS_SECRET_ID/KEY/BUCKET` 凭据,S3 兼容,coscli/rclone 均可)或 rsync 到另一节点;**上传失败必须告警**(备份不能静默失败);`.env` 同样纳入离线异地(已有原则) +- **gitea_data 卷备份(v11 补——真实缺口)**:§4 只做 pg_dump,但 **gitea_data 卷(含 git 仓库 + 二期 registry 镜像 blobs)完全没进备份范围**——此卷一丢,所有仓库 + 二期镜像全没、要全部重 push。**一期先标注"此卷需单独备份",二期前补执行**:`docker run --rm -v gitea_data:/src -v /data/backups:/dst busybox tar czf /dst/gitea_data_.tar.gz -C /src .`,同样传 COS 异地;注意 gitea 容器运行中 tar 的一致性(git 仓库文件持久、轻微不一致可接受;严格则先 `docker compose stop gitea` 再 tar);registry blobs 量大,纳入异地时评估体积/频率(可低频率如每周);**保留策略(E)**:tar 备份**按数量清理**——保留最近 N=3 个(同 predeploy 的 N=3 思路),文件名嵌可排序 ISO 时间戳(同 §4 命名规范),`ls gitea_data_*.tar.gz | sort | head -n -3 | xargs -r rm`,防盘爆 +- **RPO 预期明示(E8)**:日常 backup 每日 03:00 → **最坏 RPO ≤ 24h**(backup 失败可能拖到 48h,靠告警兜底);predeploy 快照随每次部署拍 → 部署窗口内 RPO 分钟级。**需用户确认这个 RPO 是否可接受**,不可接受则加密日常备份频率(如每 6h)——先写清预期,不擅自设默认值 + +### 5. 镜像治理(扩盘后仍需防爆) +- **应用镜像只按 count 清理(v5 修正,消除与 §1 矛盾)**:保留最近 N=5-10 个**带 tag 的版本镜像**(与 §1 一致),超出删除——**绝不按 age 清理带 tag 的应用镜像**(否则低频部署时 7 天外的保留 tag 被 age-prune 清掉 → 回滚点静默丢失) +- **age-prune 只作用于 dangling/build cache**:`docker image prune --filter "until=168h"` 只清不带 tag 的中间层 + build cache(`-a` 仍绝不使用,避免误删构建缓存) +- 磁盘监控:定期 `df -h`,超阈值告警 + +### 6. Dockerfile 卫生(构建加速) +> **一期 deploy.sh 在生产机 build 的前提**:Phase 1 无 CI,镜像在 CVM 上 `docker build`——生产机必须**能拉基础镜像**(python:3.12-slim、node:20-alpine、nginx:alpine、gitea 基础镜像可达)+ **具编译能力**(psycopg2/pgvector 编译,需 build-essential/libpq-dev,backend Dockerfile 已装)。腾讯 apt/pip 镜像已配,这块已具备;**首次跑前确认**网络与源可达。 +- `backend/Dockerfile`:腾讯 apt/pip 镜像(回归历史 `docs/12` §4.2–4.5 的加速做法,当前已丢失) +- `frontend/Dockerfile.prod`:`npm ci` + package-lock.json + npmmirror +- **`.dockerignore` 统一补齐**(防密钥进镜像层 / 防旧字节码 / 缩构建上下文): + - backend:`__pycache__/`、`*.pyc`、`.env`、`tests/`、`scripts/`、`data/`、`.pytest_cache/`、`*.egg-info/`(现有已含 `__pycache__`/`.env`,补齐其余) + - frontend:补 **`.env`**(当前未排除,可能把 dev 环境变量打进镜像)、`__pycache__`、`*.pyc`;保留 `node_modules`/`dist` 排除 +- **新增 `.gitattributes`**:`* text=auto eol=lf`——防 Windows 编辑 .sh/Dockerfile 的 CRLF 在 Linux 容器内报错 +- **构建期密钥防进镜像层(防未来踩坑)**:若 backend 未来需私有 pip 源 token,别用 build ARG 写进镜像层——用 BuildKit `--mount=type=secret`。当前腾讯公开源不需要,但一句话防未来私有源踩坑 +- **非 root 容器 + 卷权限(P8,已查实当前无卷、须防未来)**:backend 以 `USER scilit` 跑(backend/Dockerfile:29),**当前 backend/worker 容器没有任何卷挂载**(文件存储走 MinIO/COS,`docker compose config` 实查确认)→ **现无此问题**;但**未来任何给 backend 加本地卷都会踩经典坑**——命名卷首次挂载是 root 属主,非 root 进程写不进 → PermissionError。防线(加卷时必做):Dockerfile 加 ENTRYPOINT 启动前 `chown` 卷目录,或用固定 UID(`useradd -u 10001`)+ 卷初始化,禁止裸加卷 + +### 7. TLS / HTTPS(✅ 已落地 2026-08-09,走②前置 Caddy) + +> **上机确认结论**:此前是裸公网 80 直连 frontend nginx,无任何加密。已按用户选定方案②落地。 + +- **已实施(2026-08-09)**: + - `docker-compose.prod.yml`:frontend 宿主端口 **`80:80`→`8080:80`**(容器内 nginx 仍监听 80,Caddy 经 compose 网络 `frontend:80` 反代);新增 **caddy** 服务(`caddy:2-alpine`,`80:80`+`443:443`,挂 `Caddyfile` + `caddy_data`/`caddy_config` 卷);volumes 加 caddy_data/caddy_config + - **`/root/scilit/Caddyfile`**:`oncolit.gonsun.com { reverse_proxy frontend:80 }`(**必须写 compose 服务名 `frontend:80`**——8080 是宿主映射、compose 网络内服务在容器端口 80;写 8080 会连不上) + - **证书自动签发成功**:Let's Encrypt,经 **tls-alpn-01** 挑战(443 可达,说明腾讯云安全组 443 已放行);80 端口 Caddy 自动 **308 跳转 HTTPS** + - `PUBLIC_BASE_URL`:`http://`→`https://oncolit.gonsun.com`;`CORS_ORIGINS` 追加 `https://oncolit.gonsun.com`(改 .env 后 backend 需重启生效) +- **验证**:`curl -k https://oncolit.gonsun.com` → 200 + 前端 HTML;`/health` 经 Caddy→nginx→backend 返回 `db: ok`;证书 CN=oncolit.gonsun.com,90 天自动续期 +- **⚠️ frontend 宿主 8080 已收紧(2026-08-09)**:`8080:80` → **`127.0.0.1:8080:80`**(只绑回环,公网无法直连绕过 Caddy;排障时本机 `curl 127.0.0.1:8080` 仍可用;Caddy 经 Docker 网络走 `frontend:80` 不受影响)。安全组仍建议只放行 80/443(禁 3000/2222 对公网),在腾讯云控制台配置 +- **✅ gitea TLS 已落地(2026-08-09)**:DNS 已加 `gitea.oncolit.gonsun.com` → `123.207.9.209`;Caddyfile gitea 子站块已启用(`gitea.oncolit.gonsun.com { reverse_proxy gitea:3000 }`),证书经 tls-alpn-01 自动签发;gitea `ROOT_URL`/`DOMAIN`/`SSH_DOMAIN` 已改 `https://gitea.oncolit.gonsun.com`。**⚠️ 本地 git remote 需同步改 https**:`http://123.207.9.209:3000/scilit/backend` → `https://gitea.oncolit.gonsun.com/scilit/backend`;SSH clone 地址变为 `gitea.oncolit.gonsun.com:2222` +- **未做**:registry 的 TLS(二期 §2——registry 走明文 HTTP,公网 IP 下 token 有嗅探面,须监听内网或加 TLS)。前端 nginx 容器内直接终止的路线①未采用 +- **⚠️ 域名前提**:Caddy 自动 HTTPS 要求域名 DNS 已解析到本机(oncolit.gonsun.com → 123.207.9.209 ✓) + +### 8. 日志落盘(v11 提前到一期——日常运维刚需) + +> **原放二期 §4,提前到一期**:查日志是日常运维刚需,镜像优先部署每次重建容器、json-file 日志随容器删除即丢——与北极星"可观测/可恢复"直接相关,不该等二期。一期就能做(前端 nginx 早已挂卷,后端对齐即可);二期只补 Loki/Prometheus 等高级归集。 + +- **后端 uvicorn 日志挂宿主机卷**(对齐前端已挂 `/var/log/scilit/nginx`):backend 挂 `/var/log/scilit/backend`,uvicorn 配置 `--access-logfile`/`--error-logfile` 指向该卷内文件(否则默认 stdout 走 json-file、随容器删除即丢);前端 nginx 侧已有 `/var/log/scilit/nginx` 挂载;**⚠️ backend 非 root 写权限(A——v11 的 §8 挂卷正好激活 P8 预告的坑)**:backend 容器是 `USER scilit` 非 root(backend/Dockerfile:29),主机绑定目录 `/var/log/scilit/backend` 是 **root 属主**——scilit 用户写不进去 → **首跑日志落盘即 PermissionError**(nginx:alpine 以 root 跑、无此问题)。**按 §6 P8 现成结论处理,缺一不可**:挂卷前宿主机 `chown /var/log/scilit/backend`,或固定 UID(`useradd -u 10001`)+ 卷初始化——**§8 挂卷不配权限处理,一期首跑必崩** +- **⚠️ 挂卷后 json-file 轮转失效(容易漏)**:日志挂宿主机卷后,docker 自带 10m×3 轮转**不再覆盖这块日志**——须另配宿主机 **logrotate**(nginx 的 `/var/log/scilit/nginx` 同样要查),否则卷无限涨 +- **⚠️ 轮转机制二选一(P9)**:宿主机 logrotate **或** uvicorn `RotatingFileHandler` **只能选一种**——两个都配会互相 rename 竞争、日志错乱。**定案:用宿主机 logrotate(下条 copytruncate 细则),uvicorn 保持 stdout 落盘、不配 RotatingFileHandler** +- **logrotate 必须 `copytruncate`(v6 执行级真坑)**:容器内进程**不响应日志文件的 rename**——普通 logrotate(rename + create)配了也白配,文件经旧句柄继续涨。**必须配 `copytruncate`,或轮转时发 USR1 让进程重开文件句柄**,否则卷照样无限涨;**⚠️ 配置落地(J)**:在宿主机建 `/etc/logrotate.d/scilit-backend`(root 创建)——一个配置文件同时覆盖 backend + nginx 两段路径:`/var/log/scilit/backend/*.log /var/log/scilit/nginx/*.log { daily; rotate 7; compress; delaycompress; missingok; notifempty; copytruncate }`(**两段都必须 `copytruncate`**,此块已含);配好即由宿主机 `cron.daily` 每日自动轮转,无需重启任何服务 + +--- + +## 二期:CI + Registry 全自动化 + 可观测 + +### 1. Gitea Actions(构建即验证,唯一构建入口) +- **当前 CI 从未生效**:`.github/workflows/ci.yml` 是 GitHub 格式,Gitea 不读;需迁到 `.gitea/workflows/`(Gitea Actions 格式) +- 迁移现有 ci.yml 逻辑:backend(pgvector:pg16 service + pytest + ruff)+ frontend(vue-tsc + build)→ `.gitea/workflows/ci.yml` +- 部署 **act_runner**(服务器容器),注册到 Gitea +- **act_runner 需 Docker 能力才能构建镜像(v5 补)**:挂载宿主 `docker.sock`(复用宿主 daemon,简单)或 DinD + privileged(隔离强但重)——落地时确认 runner 内能 `docker build` +- **CI 与手动部署共享同一 flock(v6 补)**:Phase 2 CI 触发自动部署时,脚本必须用**同一 `/tmp/scilit-deploy.lock` 路径**——否则 CI 与手动部署仍可能并发抢 tag/versions.log +- 启用 Gitea Actions(compose 加 `GITEA__actions__ENABLED: "true"`) + +### 2. Container Registry(生产只 pull,不构建) +- Gitea 1.27 原生支持 Container Registry(`:3000/scilit/backend`) +- 生产 docker daemon 配 **insecure-registries**(Gitea 无 HTTPS):`["123.207.9.209:3000"]`;**⚠️ 改 daemon.json 后必须重启 docker(P6)**:`systemctl restart docker` 会**重启本机所有容器**(除非预先设 `live-restore: true`)→ 属停机操作,**必须在维护窗口手动做**,不能夹在 deploy.sh 里静默执行(否则一次"配 registry"把整栈全重启一遍) +- **⚠️ 公网 IP + HTTP registry 凭证嗅探风险(v7 补)**:`123.207.9.209` 是**公网 IP**,registry 走明文 HTTP——token 在公网/同网段可被嗅探。原「内网可信」假设**不成立**。必须:①registry 仅监听内网/防火墙限定来源,或 ②反代加 TLS(长期);否则 CI push / 生产 pull 的 token 有泄露面 +- **registry 需认证**:Gitea registry 非匿名,CI push + 生产 pull 都需 `docker login :3000`(token)——token 存服务器 CI secrets / 生产凭据文件,不进 git;**token 有有效期 + 凭据文件本身敏感 → 纳入离线备份(同 `.env` 级)+ 定轮换策略**,过期/泄露即换,写进 docs/10 +- **registry 盘余量(v6 补)**:registry 复用 gitea_data 卷——§0 扩的是 pgdata 吃紧盘,**二期前必须确认 gitea_data 所在盘余量**,否则 registry 满、镜像推不上去 +- **二期 image: 改 registry 全地址(v3 提过、v7 固化到二期主体)**:二期 compose 的 image: 必须为 `123.207.9.209:3000/scilit/backend:${BACKEND_TAG:-latest}`(frontend 同理)——**CI push 地址与生产 pull 地址必须完全一致**(同一全限定 registry 前缀),否则 `docker compose pull` 拉的是无 registry 前缀的本地名、推不下来 +- CI 构建镜像 → 推 registry(打 git-sha 标签)→ 生产 `docker compose pull && up -d` +- 保留策略:registry 侧定期清旧版本 + +### 3. 生产部署链路(二期形态) +- push → CI 构建(带测试)→ 推 registry → 生产 `git pull`(compose 文件)+ `docker compose pull`(镜像)+ `up -d`(migrate 前置 + 健康门控) +- **pull 必须限定服务**:`docker compose pull backend worker frontend migrate`——全局 pull 会连带尝试更新 postgres/gitea 等基础设施(尤其 `:latest` 镜像),造成"无意中升级基础设施";**pull 范围与 up 范围一致** +- 仍走 deploy.sh 包装(一期脚本),把 build 换成 pull;**迁移同样用 `docker compose run --no-deps --rm backend alembic upgrade head`(K——必加 `--no-deps` 防 migrate 服务重复跑迁移;退出码非 0 即中止,N3 一致性)→ `up -d --no-deps backend worker frontend`** +- **健康门控**:healthcheck + depends_on service_healthy 已有;坏版本自动不接流量,换旧 tag 回滚,分钟级恢复 +- **worker 健康检查形态(N6 落可执行方案——现为弱探活)**:worker 无 HTTP 端点,compose 现有 `grep -q arq /proc/1/cmdline` 只探**进程存在**、不探**消费能力**——坏 worker 会被判健康。**落为可执行(写进 docs/10 + worker 实现)**:①**worker 侧提供心跳键**——ARQ 启动钩子每 N 秒 `SETEX arq_worker_heartbeat 60 `(复用已有 `REDIS_URL`,无需新 HTTP 端点);②**compose healthcheck 改探心跳——⚠️ 用 python 探,不用 redis-cli(V5,真实会踩)**:worker 镜像是 `python:3.12-slim` 底(backend/Dockerfile,只装 libpq-dev/curl),**不含 redis-cli**——healthcheck 里写 `redis-cli` 会 `not found` → worker **永远 unhealthy** → 部署健康门控反而卡死。**改用容器内已装的 redis-py(ARQ 依赖,零镜像改动)**:`python -c "import redis,sys;r=redis.Redis(host='redis',port=6379,password='${REDIS_PASSWORD}');sys.exit(0 if r.get('arq_worker_heartbeat') else 1)"`;替代方案:worker 镜像 `apt install redis-tools` 装 redis-cli(约几 MB)。探 TTL 内有效 → healthy——探**活性**而非进程存在,卡死/僵死的 worker 心跳过期 → unhealthy → 部署健康门控不再因"进程在"误放行。若后续 worker 挂独立 HTTP 服务,改探 `/healthz` 亦可,心跳键是当前最小改动 +- **单机停机窗口(写进文档)**:单机无零停机(蓝绿/滚动需多实例);容器重建 + 健康检查 start_period 20s → **部署窗口 ~30s-1min**,选低峰执行;迁移+重建时更久。**"4 workers"语义澄清(v6)**:指 [backend/Dockerfile:34](backend/Dockerfile#L34) `uvicorn --workers 4`——**单个 backend 容器内的 4 个 uvicorn worker**,非 4 个容器;另一个 worker 容器(ARQ)单独存在。停机窗口按单容器重启估算即可;**外部反向代理优雅(E6)**:若前置有反代/LB(见一期 §7),backend 容器重建瞬间 upstream 会短暂 502——反代侧配健康检查剔除 / `proxy_next_upstream`,或接受该次 TCP 闪断(keep-alive 复用连接失败会重连);当前是否有反代需上机确认 + +### 4. 可观测增强 +- 已有:SENTRY_DSN、日志轮转(json-file 10m×3)、healthz + 健康门控 +- **日志挂卷 + logrotate 已提前到一期 §8(v11)**:后端日志挂卷、logrotate copytruncate、轮转机制二选一(P9)见**一期 §8**——二期不重复,此处只补高级归集 +- 补:日志归集(如 Loki 或 filebeat → ES)、基础监控(cAdvisor/node-exporter + Prometheus + Grafana)、告警(磁盘/健康/错误率) + +### 5. feature flag(部署/发布解耦) +- 代码常上、功能 flag 控制显隐;坏功能一键关,不靠回滚镜像 +- 用环境变量/配置中心实现,后续按需引入 + +--- + +## 可选支持层:本地 Docker(不强制) + +- 定位:环境一致是"提前暴露差异"的手段,主要靠 **CI 集成测试**挡差异(用生产同镜像起 Postgres 跑测试),不靠本地复现 +- 若做(轻量,不迁 38G):WSL2/Docker Desktop 跑 dev compose 中间件,本地原生 uvicorn;价值是 Dockerfile 本地 build 验证 + 冒烟 +- **不影响一期/二期进度,可随时后补** + +--- + +## 关键文件 + +| 文件 | 改动 | +|---|---| +| `docker-compose.prod.yml` | backend/worker/frontend 显式 `image:`;**`deploy.resources.limits` 资源上限(P13)**——backend/worker/es 至少设 memory limit(防单容器 OOM 拖垮宿主,es 已有 `ES_JAVA_OPTS` 但未设 cgroup 上限);gitea 加 Actions/registry 配置 | +| `.gitea/workflows/ci.yml`(新) | 由 `.github/workflows/ci.yml` 迁移,Gitea Actions 格式 | +| `deploy/*.sh`(新) | deploy / rollback / hotfix / versions.log | +| `backend/Dockerfile`、`frontend/Dockerfile.prod` | 构建加速 + 卫生 | +| `backend/.dockerignore`、`frontend/.dockerignore` | 构建上下文排除 | +| `.gitattributes`(新) | `* text=auto eol=lf` | +| **应用 `/health` 端点(v6 重申)** | 健康门控/回滚判定全依赖它——backend 已有 `/health`(nginx 已代理);**新接手者不可漏**,若未来拆分服务需各提供 | +| `docs/10-生产部署文档.md` | **既有部署操作手册**,§12 重写为镜像优先流程 + 迁移规范 + 恢复演练(v5 注明:本文件 docs/16 是方案,docs/10 是操作手册,分工不同,不冲突) | +| 记忆 `deploy_image_first.md`(新) | 决策 + 脚本用法 | + +--- + +## 验证 + +**一期**: +- 本地 `deploy.sh --dry-run` 只打印命令;Dockerfile 静态核对 +- 用户服务器首跑:`/health` db ok、前端 200、versions.log 记 sha +- 回滚演练:rollback.sh 回上一 sha,无新迁移、服务正常 +- 恢复演练:pg_restore 到临时库验证数据完整 +- 首次切镜像会重建容器(migrate 跑迁移),需用户确认窗口 + +**二期**: +- CI 触发 push → `.gitea/workflows` 跑 lint + pytest + 前端构建,全绿 +- CI 构建镜像推 registry,生产 `docker pull 123.207.9.209:3000/scilit/backend:` 成功 +- 生产 pull + up -d,健康门控生效(坏版本不接流量) +- 可观测:Grafana 出图、告警规则触发一次 + +--- + +## 首次上机确认清单(阻塞级,先查清再动手) + +| # | 待确认 | 出处 | 判定动作 | +|---|---|---|---| +| 1 | ✅ **backup 落地**(原无 crontab、脚本没跑) | §4 备份 | 已解决:新建 `/root/scilit/scripts/backup.sh`(compose-exec pg_dump)+ crontab `0 3 * * *` + 手动验证成功 | +| 2 | ✅ **磁盘扩至 100G**(2026-08-08/09 分区分文件系统补齐) | §0 扩容 | 已完成:`growpart /dev/vda 1` + `xfs_growfs /` → 100G、可用 25G、76% | +| 3 | ✅ **gitea/postgres/redis/es/minio 同 compose 文件** | §2 脚本化 | 已实查:全部属 `/root/scilit/docker-compose.prod.yml`(`docker inspect` label 确认);同目录另有 dev 版 `docker-compose.yml`(**命令必须带 `-f docker-compose.prod.yml`**,否则读到 dev 文件报 no such service) | +| 4 | compose 是否 v2.x(`service_completed_successfully` 依赖 v2,非 v1) | §3 迁移 | 实查 v2(依赖门控已工作:2026-08-09 migrate 失败即拦下 backend) | +| 5 | ✅ **backup.sh 生产连库路径**(postgres `ports: []`,宿主机 localhost 连不上) | §4 备份 | 已解决:统一为 `docker compose exec -T postgres pg_dump`(不经宿主端口) | +| 6 | **.dockerignore 脱离 git**(`.gitignore` 第 50 行忽略了它) | §6 卫生 | **已定修复:从 `.gitignore` 移除 `.dockerignore`**(两个 .dockerignore 进 git),执行项 | +| 7 | ✅ **compose 服务名统一** | §2 脚本化 | 已实查:prod compose 为 `postgres`,采集命令用对名字 | +| 8 | ✅ **TLS 已落地**(走②前置 Caddy) | 一期 §7 TLS | 已完成:frontend→8080、Caddy 80/443、证书签发、PUBLIC_BASE_URL 同步 https;⚠️ 安全组需禁 8080/3000/2222 公网(只放 80/443) | + +--- + +## 增强级(后续可选,不阻塞) + +- **同机蓝绿(停机窗口缓解)**:~30s-1min 中断若落在业务高峰不可接受,预留**同机蓝绿**——两份 backend 容器 + nginx upstream 切换(后端日志挂卷 + 镜像版本化已为此铺路);先记下,有需要再做 +- **hotfix 脆弱窗口**:docker cp 进容器的修复不进镜像,容器一重启即丢——hotfix.sh 末尾强制收口提醒 + 限时正式部署(已落地于 §2) + +--- + +## 二次反查新发现(2026-08-08,已验证) + +> 独立反查补充,非吸收外部意见。两条真 bug + 六条设计漏洞。 +> **v5 更新:** 本节 v3 的 6 条已全部固化到主体现——§1 FRONTEND_TAG/前端 healthcheck/双 tag 原子回滚、§2 工作区检查 + 双 tag log、§3 判定统一为 log、§5 count 制、§6 .dockerignore 定修复。本节保留为历史记录。 + +**真 bug:** +- **`.dockerignore` 被 `.gitignore` 忽略**(根 `.gitignore` 第 50 行):backend/frontend 两个 `.dockerignore` 未被 git 跟踪(`git ls-files` 确认),但 §6 要把它们作为部署单元随 git 分发——服务器 pull 不到。**修复:从 `.gitignore` 移除 `.dockerignore`**,或明确它为服务器本地手工同步 +- **backup.sh 生产连库路径不成立**:仓库版默认 `PG_HOST=localhost:5432`,但 postgres `ports: []` 不发布端口,宿主机 `pg_dump` 连接拒绝。**统一到仓库版前先定连库机制**(`docker compose exec postgres pg_dump` 或加 `127.0.0.1:5432:5432` 映射),已入上机清单 #5 + +**设计漏洞:** +- **frontend 缺 healthcheck**:只有 `depends_on: backend: service_started`(容器起来即可),无自身健康门控——"坏版本不接流量"对前端失效,补 `curl -sf http://localhost/` 或 nginx 配置校验 +- **frontend tag 插值缺失**:仅 `BACKEND_TAG`,frontend 是独立 nginx 镜像,需 `FRONTEND_TAG`;**回滚必须 backend+frontend 双 tag 原子切换**,versions.log 记录两个 +- **镜像治理策略打架**:§1 "保留 N=5-10 tag"(count 制)vs §5 `prune until=168h`(age 制)——低频部署时 7 天外的保留 tag 被 age-prune 清掉 → 回滚点丢失。**统一:应用镜像只按 count 清理,age-prune 只作用于 dangling/build cache** +- **deploy.sh 未查工作区干净**:生产机 `git pull` 前需 `git status --porcelain` 为空,否则 hotfix 残留导致 pull 冲突;冲突即中止 +- **破坏性判定扫描时机**:回滚时扫当前工作区 ≠ 回滚目标版本——destructive 文件可能已在后续版本删除/改名。**改为 deploy 时基于 `git diff .. -- alembic/versions/` 检测 drop/alter 并写进 versions.log,回滚读 log** +- **一期/二期 image: 写法切换**:本地 build 用 `scilit/backend:`,二期 pull 用 `123.207.9.209:3000/scilit/backend:`——切换时 compose 的 image: 字段必须改为全限定 registry 地址,需在文档标注 + +--- + +## 风险与注意 + +- **磁盘**:✅ 已扩至 100G(2026-08-08);绝不 `docker builder prune -a`(毁缓存)、绝不 `docker compose down -v`(毁数据卷) +- **restart 策略(P7 已查实存在,无需新增)**:prod compose 全部服务(postgres/redis/es/minio/backend/worker/frontend/gitea)已是 `restart: unless-stopped`——**服务器重启后栈自动拉起**;migrate `restart: "no"`(一次性服务,正确)。冷启动/回滚场景都依赖此自愈,部署后 `docker compose ps` 复核各容器 restart 策略未被误改 +- **TZ 时区决策(E7)**:项目刻意用 **UTC 存储**(ARQ 任务时间全 UTC,见 CLAUDE.md;DB 列均为 TIMESTAMPTZ/UTC)——**不设 `TZ=Asia/Shanghai`**,避免容器本地时间与 DB UTC 混读;日志/时间戳用 ISO8601 带时区(如 `+08:00`),前端展示层本地化。若日后运维强烈偏好本地时区可统一设 TZ,但须知 DB 仍 UTC、两时区并存易混——先记录决策,不擅自改 +- **insecure-registries**:Gitea 无 HTTPS,生产/runner 需配明文 registry——**公网 IP 下「内网可信」不成立(v7)**:registry 仅监听内网/防火墙限定来源,或反代加 TLS,否则 CI/生产 token 有泄露面(见二期 §2) +- **CI 一次性配置门槛高**:act_runner 注册、registry 开启、Gitea Actions 启用——过渡期注意测试 +- 生产部署由用户执行,agent 不直接 SSH;部署前确认;计划批准≠执行绿灯 diff --git a/docs/17-生产数据迁移.md b/docs/17-生产数据迁移.md new file mode 100644 index 0000000..416d4ad --- /dev/null +++ b/docs/17-生产数据迁移.md @@ -0,0 +1,158 @@ +# 17. 生产数据迁移实施方案 + +> **日期:** 2026-08-09 **版本:** v1.0 +> **关联:** [16-部署运维方案.md](16-部署运维方案.md)(deploy.sh 部署流程)、[10-生产部署文档.md](10-生产部署文档.md)(操作手册) +> **状态:** 待执行——生产 DB 落后本地 24 个迁移,本方案拆分 4 批,大数据量批次延后手工执行 + +--- + +## 1. 背景与现状 + +生产服务器(123.207.9.209)的数据库 schema 停留在 **2026-07-17**(alembic head = `6b662a8c5235`),而本地开发代码已演进到 **2026-07-30**(head = `55105f0bb1d7`),**中间有 24 个迁移待执行**。 + +关键数据规模(影响迁移耗时与锁表风险): +- `global_literature`:**123 万行** +- `global_literature_tags`:**348 万行** +- `global_tags`:2.1 万行 + +**⚠️ 核心约束:** 24 个迁移形成**线性链**(01→24 顺序依赖),无法跳过任何一个直接跑到 head。所以"分开做"= 把链切成 4 段,每段执行到中间 checkpoint,**大数据量/锁表操作集中在后段**,由人工控制执行时机。 + +--- + +## 2. 迁移链全景(24 个迁移,生产 head → 本地 head) + +| # | Revision | 内容 | 数据量/风险 | +|---|---|---|---| +| 01 | `cb07d6b1df01` | 19 列 JSON→JSONB(全表重写)+ 3 新列 + entry_terms + search_tsv 触发器重建 + 8 索引(B-tree×4 + GIN×4) | 🔴 **重:全表重写** | +| 02 | `6492b887b779` | `meshed_date` 列 + UPDATE 回填(`date_completed` 非空行) | 🟠 中:全表回填 | +| 03 | `1421ea169bb6` | 5 个日期字段 TIMESTAMPTZ→DATE | 🔴 **重:锁表** | +| 04 | `f3b135d62407` | pipeline_runs.processed_date | 🟢 轻 | +| 05 | `0ee585329fc6` | pipeline_runs.metadata | 🟢 轻 | +| 06 | `db1cc822f2da` | global_journals.nlm_subsets | 🟢 轻 | +| 07 | `34bc08516f3a` | global_literature.is_preprint | 🟢 轻 | +| 08 | `d641e2f7a4ee` | auid_data / cois_statement / vernacular 相关 | 🟢 轻 | +| 09 | `52ec204acfd9` | pharmacological_actions JSONB | 🟢 轻 | +| 10 | `38bb4e1f8498` | investigators / personal_name | 🟢 轻 | +| 11 | `cac545862583` | 新建 user_saved_filters 表 | 🟢 轻 | +| 12 | `d8f6c3563587` | 筛选列 B-tree×4 + mesh_headings GIN | 🟠 中:大表建索引 | +| 13 | `a4b7c8d9e0f1` | journal_iso trgm GIN | 🟠 中:大表建索引 | +| 14 | `e5f6a7b8c9d0` | search_tsv 触发器函数(作者 simple 词典) | 🟢 轻 | +| 15 | `d9e7c8b1a2f3` | authors trgm GIN | 🟠 中:大表建索引 | +| 16 | `e0f1a2b3c4d5` | search_tsv 触发器重建(+chemical/gene,无回填) | 🟢 轻 | +| 17 | `f1a2b3c4d5e6` | author_names_text 列 + UPDATE 回填 + trgm 索引 | 🔴 **重:全表回填** | +| 18 | `g0h1i2j3k4l5` | search_tsv 全量回填(+mesh/keywords)+ 触发器 | 🔴 **重:全表回填** | +| 19 | `e0764f6d7c21` | tag_ids ARRAY + BRIN×2 + 部分索引×4 + tag_ids GIN | 🟠 中:大表建索引 | +| 20 | `b01b8f27c596` | **tag_ids 回填**(348 万行 array_agg 聚合) | 🔴 **重:大数据量回填** | +| 21 | `af4a8b2ec873` | search_tsv 全量回填(修复 mesh_headings key)+ 触发器 | 🔴 **重:全表回填** | +| 22 | `d166cde6083b` | volume/issue VARCHAR(50)→VARCHAR(200) | 🟢 轻 | +| 23 | `95c18ebf31e4` | volume/issue VARCHAR(200)→Text | 🔴 **重:锁表** | +| 24 | `55105f0bb1d7` | journal_iso/pages VARCHAR(100)→Text | 🔴 **重:锁表** | + +--- + +## 3. 分段原则 + +1. **链是线性的**(01→24),不能跳段;分段 = 每批执行到中间 checkpoint,不改变迁移内部顺序。 +2. **大数据量放后边**:🔴 重活集中在批次 1(链头,无法避免,见下)和**批次 4**(全部延后)。 +3. **新代码依赖前置 schema**:批次 1 的 JSON→JSONB / 日期类型变更,本地模型代码已经按 JSONB/Date 定义——**在批次 1 完成前,新代码无法运行**。这是必须最先执行、无法延后的部分。 +4. **批次 4 = 最重**:含 348 万行 tag_ids 回填 + 3 次全量 search_tsv 回填 + 2 次锁表类型变更,**全部延后到深夜低峰手工跑**。 + +--- + +## 4. 批次划分与目标 Checkpoint + +| 批次 | 覆盖迁移 | 目标 checkpoint | 脚本参数 | 内容摘要 | 建议时段 | +|---|---|---|---|---|---| +| 1 | [01]–[03] | `1421ea169bb6` | `./migrate_prod.sh 1` | JSON→JSONB 全表重写 + meshed_date 回填 + 日期锁表 | ⚠️ **必须与发新代码同窗口**(含 destructive,见下) | +| 2 | [04]–[11] | `cac545862583` | `./migrate_prod.sh 2` | 纯加列/新表,additive | 可提前,任意时段 | +| 3 | [12]–[16] | `e0f1a2b3c4d5` | `./migrate_prod.sh 3` | 索引(大表建)+ 触发器重建,additive | 可提前,建议低峰 | +| 4 | [17]–[24] | `head`(`55105f0bb1d7`) | `./migrate_prod.sh 4` | **大数据量回填 + 类型锁表(含 destructive)** | **新代码已部署后,深夜低峰** | + +> **⚠️ destructive 窗口纪律(2026-08-10 修正,防止批次 1 单独跑崩老代码):** +> - **批次 1 含 `1421ea169bb6`(5 日期字段 TIMESTAMPTZ→DATE)——类型收窄,属 destructive**。老代码把 `date_completed`/`pubmed_revised` 等读成 datetime,迁移后 DB 返回 `date`,`datetime` 专属调用会崩。**因此批次 1 绝不能脱离代码更新单独跑**——必须和 build 新镜像 + 发新代码同一维护窗口(迁移完几秒内新镜像接管),或新代码先发再跑批次 1(但新代码依赖 JSONB,见 §3 约束,建议同窗口)。 +> - **批次 2/3 纯 additive**(加列/新表/索引/触发器重建),老代码完全兼容,**可在部署前任意时段提前跑**,缩小维护窗口。 +> - **批次 4 含 `95c18ebf31e4`/`55105f0bb1d7`(VARCHAR→Text)**——类型加宽、老代码兼容,但按 destructive 判定仍算;**必须在新代码已部署后、深夜低峰跑**(大数据量回填 + 锁表)。 +> - 推荐执行序:**批次 2/3(提前)→ 维护窗口:build 新镜像 → 批次 1 → 发新 backend/worker/frontend → 深夜:批次 4**。批次 1 与发代码之间的窗口必须控制在分钟级内。 + +--- + +## 5. 前置条件(执行迁移前必须完成) + +以下由部署流程(docs/16 §2 deploy.sh)或人工准备: + +1. **新 backend 镜像已构建**(必须包含 24 个新迁移文件)。生产镜像当前是 07-17 旧版,**用旧镜像跑迁移会报 `Can't locate revision '6b662a8c5235'`**(2026-08-09 事故根因)。验证方式: + ```bash + docker compose -f docker-compose.prod.yml build backend + ``` + > **构建加速已落地(2026-08-10):** backend Dockerfile 已加腾讯 pip/apt 源 + BuildKit 缓存,frontend Dockerfile.prod 已加腾讯 npm 源 + `npm ci`。**首次 build 约几分钟**(pip 全量从腾讯源装),**之后代码级 build 秒~1 分钟**(只 `COPY . .`)。构建前提:宿主机 daemon 已配腾讯 registry-mirrors(✅ 已配)、BuildKit 需 Docker 22.06+/23.05+(✅ 29.6.1)。 +2. **⚠️ 批次 1 必须与发新代码同窗口**(见 §4 destructive 纪律):批次 1 的日期收窄迁移会让老代码读崩,**不能提前单独跑**。批次 2/3 可提前 additive 跑。 +2. **数据库快照**(安全网,回滚用):部署前拍全量 `pg_dump -Fc`。 +3. **postgres 容器 healthy**。 +4. 脚本位于 `/root/scilit/`(与 `docker-compose.prod.yml` 同目录)。 + +--- + +## 6. 实施步骤(生产服务器手工执行) + +### 6.1 脚本位置与用法 + +脚本:`backend/scripts/migrate_prod.sh`(已随代码提交,部署时同步到 `/root/scilit/`) + +```bash +cd /root/scilit +chmod +x migrate_prod.sh # 首次 +./migrate_prod.sh 1 # 批次 1 +./migrate_prod.sh 2 # 批次 2 +./migrate_prod.sh 3 # 批次 3 +./migrate_prod.sh 4 # 批次 4(最重,深夜) +``` + +脚本内置安全网: +- **镜像新鲜度检查**:执行前确认 backend 镜像含本地 head(`55105f0bb1d7`),否则报错并给出修复指引(防 08-09 事故重演)。 +- **起点校验**:检查 DB 当前 revision 是否等于该批次的预期起点,防止乱序/重复。 +- **幂等**:DB 已在目标 checkpoint 时直接跳过。 +- **退出码门控**:迁移命令失败(非 0)即中止,`set -euo pipefail`。 + +### 6.2 每个批次的验证点 + +```bash +# 执行后确认当前版本 +docker compose -f docker-compose.prod.yml run --no-deps --rm backend alembic current +``` + +预期结果(逐批): + +| 批次 | 执行后 `alembic current` | +|---|---| +| 1 | `1421ea169bb6` | +| 2 | `cac545862583` | +| 3 | `e0f1a2b3c4d5` | +| 4 | `55105f0bb1d7 (head)` | + +--- + +## 7. 注意事项 + +1. **执行序(destructive 窗口纪律)**:**批次 2/3(additive)可提前跑 → 维护窗口内:build → 批次 1 → 发新代码 → 深夜:批次 4**。批次 1(日期收窄)与发代码必须同窗口(分钟级内衔接),批次 4(大数据量回填 + Text 加宽)须在新代码已部署后深夜跑。 +2. **锁表窗口**:批次 1(日期锁表)、批次 4(volume/issue/pages→Text 锁表)期间,对 `global_literature` 的写入被阻塞。建议选业务低峰。123 万行上的回填(02/17/18/20/21)预计每步数秒到数分钟,总计约 10-20 分钟。 +3. **批次 4 完成后,DB 即升级到本地最新 head**——代码与 schema 完全对齐。批次 4 未跑时新代码可运行,但 `tag_ids` 为空、搜索 tsvector 覆盖不全(回填数据缺失),功能受限但不报错。 +4. **失败处理**:若某批次迁移失败,脚本退出非 0;DB 停在失败前一个 revision,可重跑该批次(alembic 只应用未执行的迁移)。涉及数据回退时用 §5 快照(`pg_restore --clean --if-exists`,见 docs/16 §2 rollback.sh)。 + +--- + +## 8. 回滚预案 + +- **迁移失败 / 需回退数据**:使用部署前快照 `pg_restore --clean --if-exists --no-owner -d scilit <快照>` 恢复(docs/16 §2 rollback.sh 完整流程)。 +- **代码回滚**:换回旧镜像 tag + `up -d --no-deps`(docs/16 §1 双 tag 原子回滚)。 +- **迁移本身是单向的**:alembic 各迁移均有 `downgrade()`,但**破坏性迁移(类型变更/回填)downgrade 不恢复数据**——正式回滚走快照,不走 downgrade。 + +--- + +## 9. 执行记录 + +| 日期 | 批次 | 执行结果 | 备注 | +|---|---|---|---| +| (待填) | 1 | | | +| (待填) | 2 | | | +| (待填) | 3 | | | +| (待填) | 4 | | | diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..a32ad76 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,7 @@ +node_modules +dist +.git +.gitignore +README.md +*.local +.env diff --git a/frontend/Dockerfile.prod b/frontend/Dockerfile.prod index bba0ec2..9bb37f0 100644 --- a/frontend/Dockerfile.prod +++ b/frontend/Dockerfile.prod @@ -1,7 +1,11 @@ +# 构建加速(2026-08-10):腾讯 npm 源 + npm ci(依赖 lockfile 精确安装) +# syntax=docker/dockerfile:1 FROM node:20-alpine AS builder WORKDIR /app -COPY package.json . -RUN npm install +# 腾讯 npm 镜像(内网可达,快) +RUN npm config set registry https://mirrors.cloud.tencent.com/npm/ +COPY package.json package-lock.json ./ +RUN --mount=type=cache,target=/root/.npm npm ci COPY . . RUN npm run build