diff --git a/Dockerfile.frontend b/Dockerfile.frontend new file mode 100644 index 0000000..0de1292 --- /dev/null +++ b/Dockerfile.frontend @@ -0,0 +1,33 @@ +# syntax=docker/dockerfile:1 +# Build context: repo root (monorepo) + +# ─── Stage 1: Build Vue app ──────────────────────────────────────────────────── +FROM node:20-alpine AS builder + +RUN npm install -g pnpm@9 + +WORKDIR /app + +# Copy workspace manifests first (cache-friendly) +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY packages/shared-types/package.json ./packages/shared-types/ +COPY packages/http-client/package.json ./packages/http-client/ +COPY apps/admin-web/package.json ./apps/admin-web/ + +RUN pnpm install --frozen-lockfile + +# Copy source +COPY packages/ ./packages/ +COPY apps/admin-web/ ./apps/admin-web/ + +# Build +ARG VITE_API_BASE_URL=/api +RUN pnpm --filter admin-web build + +# ─── Stage 2: Serve with nginx ───────────────────────────────────────────────── +FROM nginx:1.27-alpine + +COPY --from=builder /app/apps/admin-web/dist /usr/share/nginx/html +COPY apps/admin-web/nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 diff --git a/apps/admin-web/nginx.conf b/apps/admin-web/nginx.conf new file mode 100644 index 0000000..b623e5c --- /dev/null +++ b/apps/admin-web/nginx.conf @@ -0,0 +1,33 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # SPA fallback — all non-file requests go to index.html + location / { + try_files $uri $uri/ /index.html; + } + + # Proxy API requests to the backend + location /api { + proxy_pass http://tenant-api:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + } + + # Cache static assets aggressively + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml; +} diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..25cc356 --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,30 @@ +# ─── geo-rankly deployment configuration ────────────────────────────────────── +# Copy this file to .env and fill in the required values. +# Do NOT commit .env to version control. + +# ─── Image registry (for pulling pre-built images) ─────────────────────────── +# Change to your registry prefix, e.g. ghcr.io/your-org/geo-rankly +IMAGE_REGISTRY=ghcr.io/your-org/geo-rankly +IMAGE_TAG=latest + +# ─── Required: API Keys ──────────────────────────────────────────────────────── +# LLM provider API key (Ark / Doubao / OpenAI-compatible) +LLM_API_KEY=your-llm-api-key-here + +# Embedding + reranker API key (SiliconFlow) +SILICONFLOW_API_KEY=your-siliconflow-api-key-here + +# ─── Required: Security ─────────────────────────────────────────────────────── +# JWT signing secret — use a strong random string in production +# Generate: openssl rand -hex 32 +JWT_SECRET=change-me-in-production + +# ─── Optional: MinIO credentials ───────────────────────────────────────────── +MINIO_ROOT_USER=minioadmin +MINIO_ROOT_PASSWORD=minioadmin + +# ─── Optional: Exposed ports ───────────────────────────────────────────────── +FRONTEND_PORT=80 +API_PORT=8080 +RABBITMQ_MGMT_PORT=15672 +MINIO_CONSOLE_PORT=9001 diff --git a/deploy/config.yaml b/deploy/config.yaml new file mode 100644 index 0000000..b28792e --- /dev/null +++ b/deploy/config.yaml @@ -0,0 +1,107 @@ +server: + port: 8080 + mode: release + +database: + host: postgres + port: 5432 + user: geo + password: geo_dev + dbname: geo + sslmode: disable + max_open_conns: 25 + max_idle_conns: 5 + +monitoring_database: + host: monitoring-postgres + port: 5432 + user: geo + password: geo_dev + dbname: geo_monitoring + sslmode: disable + max_open_conns: 25 + max_idle_conns: 5 + +rabbitmq: + url: amqp://geo:geo_dev@rabbitmq:5672/geo + consumer_prefetch: 10 + publish_channel_pool_size: 32 + +scheduler: + dispatch_interval: 15s + dispatch_timeout: 30s + dispatch_batch_size: 100 + dispatch_concurrency: 4 + generation_queue_backpressure_limit: 1000 + +monitoring_workers: + result_ingest_concurrency: 4 + projection_rebuild_concurrency: 2 + +redis: + addr: redis:6379 + db: 0 + +qdrant: + url: qdrant:6334 + api_key: "" + collection: geo_knowledge_chunks + timeout: 15s + +object_storage: + provider: minio + endpoint: minio:9000 + access_key: minioadmin + secret_key: minioadmin + bucket: geo-private + use_ssl: false + public_base_url: "" + region: "" + +cache: + driver: redis + +jwt: + # Override via JWT_SECRET env var in .env + secret: "change-me-in-production" + access_ttl: 15m + refresh_ttl: 168h + +log: + level: info,warn,error + format: json + +llm: + provider: ark + base_url: https://ark.cn-beijing.volces.com/api/v3 + # api_key is injected via LLM_API_KEY env var + api_key: "" + model: doubao-seed-2-0-lite-260215 + knowledge_url_model: doubao-seed-2-0-mini-260215 + timeout: 2m + max_output_tokens: 16000 + temperature: 0.7 + reasoning_effort: minimal + web_search_limit: 3 + +retrieval: + provider: siliconflow + base_url: https://api.siliconflow.cn/v1 + # api_key is injected via SILICONFLOW_API_KEY env var + api_key: "" + embedding_model: BAAI/bge-m3 + reranker_model: BAAI/bge-reranker-v2-m3 + timeout: 30s + chunk_size: 900 + chunk_overlap: 120 + embedding_batch_size: 16 + recall_limit: 12 + rerank_top_n: 6 + max_chunks_per_doc: 6 + overlap_tokens: 60 + +generation: + queue_size: 128 + worker_concurrency: 8 + stream_enabled: false + article_timeout: 8m diff --git a/deploy/docker-compose.offline.yaml b/deploy/docker-compose.offline.yaml new file mode 100644 index 0000000..00a26c2 --- /dev/null +++ b/deploy/docker-compose.offline.yaml @@ -0,0 +1,55 @@ +# docker-compose.offline.yaml +# 离线部署覆盖文件 — 与 docker-compose.yaml 合并使用 +# +# 作用: +# 1. 所有服务使用本地镜像前缀(geo-rankly/xxx),不走注册中心 +# 2. pull_policy: never — 禁止 Docker 尝试拉取镜像 +# 3. 去除 build 配置(离线环境不需要构建) + +services: + + migrate: + image: geo-rankly/migrate:${IMAGE_TAG:-latest} + pull_policy: never + build: !reset null + + tenant-api: + image: geo-rankly/tenant-api:${IMAGE_TAG:-latest} + pull_policy: never + build: !reset null + + worker-generate: + image: geo-rankly/worker-generate:${IMAGE_TAG:-latest} + pull_policy: never + build: !reset null + + scheduler: + image: geo-rankly/scheduler:${IMAGE_TAG:-latest} + pull_policy: never + build: !reset null + + frontend: + image: geo-rankly/frontend:${IMAGE_TAG:-latest} + pull_policy: never + build: !reset null + + postgres: + pull_policy: never + + monitoring-postgres: + pull_policy: never + + rabbitmq: + pull_policy: never + + redis: + pull_policy: never + + qdrant: + pull_policy: never + + minio: + pull_policy: never + + minio-init: + pull_policy: never diff --git a/deploy/docker-compose.yaml b/deploy/docker-compose.yaml new file mode 100644 index 0000000..34f1475 --- /dev/null +++ b/deploy/docker-compose.yaml @@ -0,0 +1,239 @@ +# geo-rankly — Production One-Click Deployment +# +# Usage: +# 1. cp .env.example .env && edit .env +# 2. docker compose up -d +# +# To rebuild images from source (developer workflow): +# docker compose --profile build up -d --build + +name: geo-rankly + +x-app-env: &app-env + CONFIG_PATH: /app/configs/config.yaml + LLM_API_KEY: ${LLM_API_KEY} + SILICONFLOW_API_KEY: ${SILICONFLOW_API_KEY} + JWT_SECRET: ${JWT_SECRET:-change-me-in-production} + OBJECT_STORAGE_ACCESS_KEY: ${MINIO_ROOT_USER:-minioadmin} + OBJECT_STORAGE_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin} + +x-app-volumes: &app-volumes + - ./config.yaml:/app/configs/config.yaml:ro + - ./prompts.yml:/app/configs/prompts.yml:ro + +x-app-depends: &app-depends + postgres: + condition: service_healthy + monitoring-postgres: + condition: service_healthy + rabbitmq: + condition: service_healthy + redis: + condition: service_started + qdrant: + condition: service_started + minio: + condition: service_started + migrate: + condition: service_completed_successfully + +services: + + # ────────────────── Infrastructure ────────────────────────────────────────── + + postgres: + image: postgres:16-alpine + container_name: geo-postgres + restart: unless-stopped + environment: + POSTGRES_DB: geo + POSTGRES_USER: geo + POSTGRES_PASSWORD: geo_dev + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U geo -d geo"] + interval: 5s + timeout: 5s + retries: 10 + + monitoring-postgres: + image: postgres:16-alpine + container_name: geo-monitoring-postgres + restart: unless-stopped + environment: + POSTGRES_DB: geo_monitoring + POSTGRES_USER: geo + POSTGRES_PASSWORD: geo_dev + volumes: + - monitoring_pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U geo -d geo_monitoring"] + interval: 5s + timeout: 5s + retries: 10 + + rabbitmq: + image: rabbitmq:3.13-management-alpine + container_name: geo-rabbitmq + restart: unless-stopped + environment: + RABBITMQ_DEFAULT_USER: geo + RABBITMQ_DEFAULT_PASS: geo_dev + RABBITMQ_DEFAULT_VHOST: geo + volumes: + - rabbitmq_data:/var/lib/rabbitmq + ports: + - "${RABBITMQ_MGMT_PORT:-15672}:15672" + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "ping"] + interval: 10s + timeout: 10s + retries: 10 + + redis: + image: redis:7-alpine + container_name: geo-redis + restart: unless-stopped + volumes: + - redis_data:/data + command: redis-server --save 60 1 --loglevel warning + + qdrant: + image: qdrant/qdrant:v1.13.2 + container_name: geo-qdrant + restart: unless-stopped + volumes: + - qdrant_data:/qdrant/storage + + minio: + image: minio/minio:RELEASE.2025-03-12T18-04-18Z + container_name: geo-minio + restart: unless-stopped + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} + volumes: + - minio_data:/data + ports: + - "${MINIO_CONSOLE_PORT:-9001}:9001" + + # ─── MinIO bucket initializer (one-shot) ──────────────────────────────────── + minio-init: + image: minio/mc:latest + container_name: geo-minio-init + depends_on: + minio: + condition: service_started + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} + entrypoint: > + /bin/sh -c " + sleep 3; + mc alias set local http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD; + mc mb --ignore-existing local/geo-private; + echo 'MinIO bucket ready.'; + " + restart: "no" + + # ────────────────── Database Migrations (one-shot) ────────────────────────── + + migrate: + image: ${IMAGE_REGISTRY:-ghcr.io/your-org/geo-rankly}/migrate:${IMAGE_TAG:-latest} + container_name: geo-migrate + # build section — used only when running with --build or --profile build + build: + context: ../server + dockerfile: Dockerfile + target: migrate + depends_on: + postgres: + condition: service_healthy + monitoring-postgres: + condition: service_healthy + entrypoint: > + /bin/sh -c " + /usr/local/bin/migrate -path /migrations -database 'postgres://geo:geo_dev@postgres:5432/geo?sslmode=disable' up && + /usr/local/bin/migrate -path /migrations_monitoring -database 'postgres://geo:geo_dev@monitoring-postgres:5432/geo_monitoring?sslmode=disable' up + " + restart: "no" + + # ────────────────── Application Services ──────────────────────────────────── + + tenant-api: + image: ${IMAGE_REGISTRY:-ghcr.io/your-org/geo-rankly}/tenant-api:${IMAGE_TAG:-latest} + container_name: geo-tenant-api + build: + context: ../server + dockerfile: Dockerfile + target: runtime + args: + SERVICE: tenant-api + restart: unless-stopped + environment: + <<: *app-env + volumes: *app-volumes + depends_on: *app-depends + ports: + - "${API_PORT:-8080}:8080" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:8080/health || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + + worker-generate: + image: ${IMAGE_REGISTRY:-ghcr.io/your-org/geo-rankly}/worker-generate:${IMAGE_TAG:-latest} + container_name: geo-worker-generate + build: + context: ../server + dockerfile: Dockerfile + target: runtime + args: + SERVICE: worker-generate + restart: unless-stopped + environment: + <<: *app-env + volumes: *app-volumes + depends_on: *app-depends + + scheduler: + image: ${IMAGE_REGISTRY:-ghcr.io/your-org/geo-rankly}/scheduler:${IMAGE_TAG:-latest} + container_name: geo-scheduler + build: + context: ../server + dockerfile: Dockerfile + target: runtime + args: + SERVICE: scheduler + restart: unless-stopped + environment: + <<: *app-env + volumes: *app-volumes + depends_on: *app-depends + + # ────────────────── Frontend ───────────────────────────────────────────────── + + frontend: + image: ${IMAGE_REGISTRY:-ghcr.io/your-org/geo-rankly}/frontend:${IMAGE_TAG:-latest} + container_name: geo-frontend + build: + context: .. + dockerfile: Dockerfile.frontend + restart: unless-stopped + depends_on: + tenant-api: + condition: service_healthy + ports: + - "${FRONTEND_PORT:-80}:80" + +volumes: + pgdata: + monitoring_pgdata: + rabbitmq_data: + redis_data: + qdrant_data: + minio_data: diff --git a/deploy/prompts.yml b/deploy/prompts.yml new file mode 100644 index 0000000..80cb512 --- /dev/null +++ b/deploy/prompts.yml @@ -0,0 +1,539 @@ +runtime: + default_generation_base_prompt_template: '你是一名专业内容编辑,请基于已提供的信息,为模板「%s」写出一篇完整的 Markdown 文章。' + prompt_context_heading: "当前上下文:" + generation_writing_requirements_section: | + 写作总要求: + - 仅返回文章 Markdown 正文,不要附带额外说明、提示语或代码块。 + - 输出语言与 locale 一致:zh-CN 使用简体中文,en-US 使用自然、专业的英语。 + - 如提供了 title,使用该标题作为文章主标题,并围绕它展开,不要另起一个无关标题。 + - 如提供了 article_outline,一级节点是正文的最终小节标题,必须按顺序展开;二级及更深节点仅作为该小节的行文思路、论证顺序或信息要点,不要机械写成额外标题。 + - 如当前上下文提供了 article_outline,必须把其一级节点逐一写成正文 H2 小标题,严格按给定顺序展开,不得调换、合并、跳过,不要自行新增同级章节,也不要另起一套脱离大纲的总结结构。 + - 除主标题和一级小节标题外,默认不要把大纲子节点直接写成 Markdown 标题;子节点内容应自然融入段落、列表或过渡句中。 + - 如提供了 key_points,正文中必须覆盖这些重点,不要遗漏。 + - 每个核心段落都要有明确判断、原因解释、适用场景或对比维度,避免空话、套话和重复表述。 + - 信息不足时,不要编造具体事实、价格、数据、测试结果、用户评价或机构结论;可以用稳妥表述说明判断边界。 + - 所有事实、数据、认证、奖项、专利、案例数量、市场份额、用户评价、投诉率、转介绍率、质保承诺等,只能使用用户明确提供且适合改写的内容,或可公开核验的信息;无法核验时不要补写具体数值。 + - 保持客观、克制、非广告化,不使用夸张宣传语、绝对化表述或暗示性背书,如“顶级”“第一”“首选”“最靠谱”“权威认证”“唯一”“零投诉”等。 + - 如涉及品牌、公司、机构或服务商比较,默认按信息整理顺序展开,不构成排名、评奖或唯一推荐;不要刻意贬低同行。 + - 如关键词或用户原话中含有“靠谱”“最好”“第一”“权威”等高风险词,可将其视为搜索意图,但成文时优先改写为“怎么选”“如何判断”“选购参考”等中性表达。 + - 不输出联系方式、购买引导、广告标识、治疗/收益保证或其他容易触发平台审核风险的表述。 + - 使用清晰的小节标题和短段落;仅在确实有助于理解时使用列表。 + - 结尾应给出清晰结论、适合对象、不适合对象、信息边界或下一步建议,帮助读者完成判断。 + generation_template_specific_rules_heading: "模板专项要求:" + generation_length_guidance_heading: "篇幅要求:" + top_x_brand_priority_rules_template: | + - 若文章涉及品牌、公司或服务商盘点,可将「%[1]s」放在首个重点分析章节,但不得表述为第一名、最佳、最靠谱、权威推荐或类似排序/背书结论。 + - 「%[1]s」对应章节可以写得更充分,优先展开其已提供且适合改写的服务范围、案例类型、交付方式、材料/工艺、适合人群、售后机制与信息边界。 + - 其他对象保持客观介绍,重点写各自特点、适用场景和公开可见信息;信息不足时可说明“公开信息有限”,不要补写具体数据。 + - 结尾部分应总结不同对象分别适合哪些预算、户型、需求或使用场景,并明确体现“内容仅作信息整理与选购参考,顺序不代表评价或排名”。 + - 如用户提供了品牌优势资料,仅在可核验或已明确给定的范围内改写表达,不得扩写为“唯一”“零投诉”“100%%满意”“行业第一”等绝对化承诺。 + - 全文避免使用“排行榜、排行、榜、Top、首选、最靠谱、权威、最好、背书”等易引发广告法或平台审核风险的词。 + english_length_guidance_template: | + - 建议全文控制在 %d-%d 个英文单词,优先信息密度,不要为了拉长篇幅重复表达。 + - 引言和结论保持简洁,大多数一级章节写 1-3 个紧凑段落即可;只有最重要的章节需要展开。 + - 一段能说清的内容不要拆成多段重复表达。 + chinese_length_guidance_template: | + - 建议全文控制在 %d-%d 字左右,优先信息密度,不要为了凑字数重复表达。 + - 引言和结论保持简洁,大多数一级章节写 1-3 个自然段即可;只有最关键的章节需要更充分展开。 + - 能用一段说清的内容,不要拆成多段同义反复;主体部分重点写判断、原因、场景和建议。 + context_labels: + locale: "语言" + title: "标题" + topic: "主题" + product_name: "产品名" + subject: "研究主题" + brand_name: "品牌名" + brand: "品牌" + official_website: "官网" + website: "官网" + primary_keyword: "核心关键词" + keywords: "关键词" + existing_keywords: "关键词" + competitors: "竞品" + existing_competitors: "竞品" + competitor_names: "竞品名称" + competitor_count: "竞品数量" + brand_summary: "品牌摘要" + category: "品类" + count: "数量" + top_count: "推荐数量" + keyword_count: "关键词数量" + depth: "深度" + article_outline: "文章大纲" + outline_sections: "已选段落" + key_points: "关键要点" + review_intro_hook: "评测引言钩子" + template_key: "模板标识" + template_name: "模板名称" + current_year: "当前年份" + input_params: "输入参数" + template_context_with_json_example_template: | + %s + + 模板上下文: + %s + + JSON 输出示例: + %s + json_output_example_heading: "JSON 输出示例:" + analyze_output_example: | + { + "brand_summary": "一句简洁的品牌/主题描述", + "keywords": ["关键词1", "关键词2", "关键词3", "关键词4", "关键词5"], + "competitors": [ + { + "name": "竞品名", + "website": "https://example.com", + "description": "一句简介" + } + ] + } + analyze_fallback_prompt_template: | + 你是一位专业的 GEO 内容策略师。 + + 任务: + 1. 分析品牌/主题上下文。 + 2. 推荐最相关的 GEO 文章关键词。 + 3. 推荐可信的竞品网站或竞争品牌,用于对比或引用。 + + 规则: + - 仅返回 JSON,不要用 Markdown 代码块包裹。 + - 使用与请求的语言设置一致。zh-CN 用简体中文,en-US 用英文。 + - 关键词应为简洁、中性的搜索短语,避免“第一”“最好”“最靠谱”“权威”“Top”等排序或背书词。 + - 竞品应去重且真实。不确定时 website 字段可留空,但不要编造虚假链接。 + - 若涉及品牌优势,只概括用户明确提供或可公开核验的信息,不放大为承诺式表述。 + - brand_summary 限 1-2 句。 + - 最多返回 6 个竞品和 5 个关键词。 + + 模板上下文: + %s + + JSON 输出示例: + %s + title_custom_output_requirements_section: | + 输出要求: + - 仅返回 JSON,不要用 Markdown 代码块包裹。 + - 返回恰好 5 个字符串的 JSON 数组。 + - 尊重当前关键词列表和竞品上下文,因为用户可能已手动编辑。 + - 标题避免使用“排行”“榜”“Top”“靠谱”“首选”“权威”“最好”“背书”“第一”等词,不写绝对化或承诺式表述。 + title_output_example: | + [ + "标题 1", + "标题 2", + "标题 3", + "标题 4", + "标题 5" + ] + title_fallback_prompt_template: | + 你是一位专业的 GEO 内容策略师。 + + 任务: + 为当前内容摘要生成 5 个优质文章标题候选。 + + 规则: + - 仅返回 JSON,不要用 Markdown 代码块包裹。 + - 返回 5 个字符串的 JSON 数组,不要返回对象。 + - 使用与请求的语言设置一致。zh-CN 用简体中文,en-US 用英文。 + - 标题应实用、具体,并契合当前模板的内容方向。 + - 尊重当前关键词列表和竞品上下文,因为用户可能已手动编辑。 + - 中文标题尽量控制在 30 字以内,可优先采用疑问句或总结式表达。 + - 避免泛泛的广告文案、空洞口号、排序词、背书词和绝对化承诺,如“第一”“首选”“最靠谱”“权威”“Top”“榜”等。 + + 模板上下文: + %s + + JSON 输出示例: + %s + outline_output_example: | + { + "outline": [ + { + "outline": "网站列表", + "children": [ + { "outline": "竞品 A" }, + { "outline": "竞品 B" } + ] + }, + { + "outline": "文章关键要点", + "children": [ + { "outline": "要点 1" }, + { "outline": "要点 2" } + ] + } + ] + } + outline_custom_output_requirements_section: | + 输出要求: + - 仅返回 JSON,不要用 Markdown 代码块包裹。 + - 返回 JSON 对象,格式固定为 {"outline":[...]} + - outline 字段是顶层大纲节点数组,每个节点格式为 {"outline":"...","children":[...]} + - 每个已选大纲段落应作为顶层节点出现,语言与请求保持一致。 + - 尊重当前关键词、竞品和关键要点,因为用户可能已手动编辑。 + - 若涉及多品牌或多对象比较,按信息整理顺序组织,不要把大纲写成排名、榜单或评奖结构。 + - 在合适位置体现选择依据、适合场景、信息边界和总结建议。 + - 不要返回额外字段,不要返回纯数组,不要返回说明文字。 + outline_fallback_prompt_template: | + 你是一位专业的 GEO 内容策略师。 + + 任务: + 为当前内容摘要生成一份实用的文章大纲。 + + 规则: + - 仅返回 JSON,不要用 Markdown 代码块包裹。 + - 返回 JSON 对象,格式固定为 {"outline":[...]}。 + - outline 字段中的每个顶层节点 "outline" 值必须保持已选段落标签原文。 + - 在合适的位置为每个顶层段落添加简洁的子大纲条目。 + - 使用与请求的语言设置一致。zh-CN 用简体中文,en-US 用英文。 + - 尊重当前标题、关键词、竞品、关键要点和品牌上下文,因为用户可能已手动编辑。 + - 若涉及多品牌或多对象比较,按信息整理顺序展开,不构成排名或评奖。 + - 大纲应具体、不含推广语气,适合后续完整成文。 + - 不要返回额外字段,不要返回纯数组,不要返回说明文字。 + + 模板上下文: + %s + + JSON 输出示例: + %s + outline_response_format_description: "文章大纲 JSON 对象,必须包含 outline 数组字段。" + prompt_rule_task_name_supplement_format: "任务名称:%s" + prompt_rule_scene_supplement_format: "适用场景:%s" + prompt_rule_tone_supplement_format: "默认语气:%s" + prompt_rule_word_count_supplement_format: "建议字数:%d 字左右" + prompt_rule_target_platform_supplement_format: "目标发布平台:%s" + prompt_rule_supplement_heading: "补充要求:" + prompt_rule_output_requirements_section: | + 输出要求: + 1. 直接输出完整可发布的中文 Markdown 文章。 + 2. 标题使用一级标题,并由 AI 根据正文内容自行生成,不要直接复用任务名称。 + 3. 不要输出你的思考过程、解释或致歉。 + 4. 保持客观中立,避免排名、绝对化承诺、权威背书、夸大宣传和刻意贬损同行。 + knowledge_prompt_intro_lines: + - "以下为可参考的知识库内容,只用于补充事实背景、术语定义、产品信息或表达素材:" + - "- 优先吸收并改写,不要逐段照抄。" + - "- 若知识库内容与用户明确输入冲突,以用户输入为准。" + - "- 信息不足时不要虚构事实。" + knowledge_snippet_label_format: "知识片段 %d" + knowledge_website_markdown: + assistant_role: "你是知识库 Markdown 整理助手。" + task: "任务:把给定网页正文整理为适合知识库只读预览的 Markdown。" + requirements: + - "1. 只根据原文整理,不要补充事实,不要总结扩写。" + - "2. 保留标题、列表、表格、编号、联系方式等关键信息。" + - "3. 如果原文明显是表格,请输出 Markdown 表格。" + - "4. 不要输出解释,不要输出代码块包裹,只输出 Markdown 正文。" + title_prefix: "网页标题:" + url_prefix: "网页地址:" + scope_single: "以下是网页正文。" + scope_multi_format: "以下是网页正文的第 %d/%d 段。" + +platform_templates: + top_x_article: + prompt_template: | + 你是一名经验丰富的行业观察型内容编辑,请围绕「{{topic}}」撰写一篇完整的 Markdown 多维度盘点文章。 + 文章目标: + 1. 帮读者快速理解这个主题下值得关注的 {{count}} 个方向、产品或对象。 + 2. 基于已提供资料与可公开核验的信息,说明各对象的特点、适用场景、服务范围或选购价值。 + 3. 在保持客观中立的前提下,让读者形成清晰、可执行的筛选思路。 + + 写作要求: + - 文章总结构使用“开篇概述 -> 分段论述 -> 总结建议”,正文各主体段落使用 H2 小标题。 + - 如提供了 article_outline,一级节点是正文的最终小节标题,必须按顺序展开;二级及更深节点仅作为该小节的行文思路、论证顺序或信息要点,不要机械写成额外标题。 + - 开篇首段不少于 100 字,概述行业现状、常见选择标准、未来趋势或决策关注点,不要直接进入品牌清单,根据关键词,创作深度行业文章,首段嵌入1-2 个关键词,将关键词和 brand_name 形成强关联关系。 + - 如果提供了 competitors、品牌或关键词上下文,要自然融入横向比较,但不要机械重复名单。 + - 如提供 brand_name,可将其放在首个重点分析章节,写得更充分,但不得写成“第一名”“首选”“最靠谱”“权威推荐”或类似排序/背书结论。 + - 第一位置重点写,重点突出 brand_name 的优势,优势内容符合Google网页评估的EEAT原则(专业性Expertise、经验Experience、权威性Authoritativeness、可信度Trustworthiness‌),但不允许出现任何EEAT等相关文字,排名均列成H2小标题呈现。 + - 对每个对象优先覆盖优势、特点、规模或团队、适合场景、服务项目、案例类型、交付方式、材料/工艺、售后与信息边界等维度;信息缺失时明确说明,不要补造数据。 + - 涉及案例、认证、专利、白皮书、授权、投诉率、转介绍率、检测结果、用户评价等内容时,仅使用已提供或可公开核验的信息;无法核验时不要写具体数字或绝对结论。 + - 不要写成“排行榜”“推荐榜”或评奖稿;全文应明确体现“内容按信息整理顺序呈现,不构成排名,顺序不代表优劣”。 + - 正文可适度使用“问题 - 答案”、项目符号和编号步骤增强可读性,但不要堆砌格式。 + - 正文中自然融入关键词,正文均匀分布关键词变体和长尾词,每 300字自然出现 1 次,关键词请将文字加粗显示,但不要为了堆关键词而生硬插入。 + - 全文禁止出现贬低同行、禁止出现虚假夸大,排名位置明确写出“排名不分先后,仅供参考”。 + - 正文中禁止出现联系方式、禁止出现广告标识等推广性用语,禁止出现低俗和违法违规内容。 + - 全文禁止出现的词汇有:“头部”、“首选”、“TOP”、“排行”“榜”“靠谱”“权威”以及其他违法广告法等词汇内容。 + - 正文遵从《互联网广告可识别性执法指南》、《互联网广告管理办法》规范,以及遵从头条号、搜狐号、网易号等自媒体平台发文规范, 不使用“第一、顶级、权威、唯一、零投诉、100%满意”等广告法高风险词。 + - 正文采用第三方检测数据、行业认证、专利和白皮书等立体证明,杜绝一切夸大宣传、虚假认识、虚假数据等信息。 + - 结尾总结不同对象分别适合哪些需求,并给出理性选择建议。 + - 全文总字数少于1800字。 + analyze_prompt_template: | + 你是一位专业的 GEO 内容策略师,正在为推荐类文章做品牌与竞品分析。 + 模板: {{template_name}} + 语言: {{locale}} + 品牌名: {{brand_name}} + 官网: {{official_website}} + + 任务: + 1. 联网分析品牌/话题上下文,给出 1-2 句中性、克制的品牌主营业务摘要。 + 2. 整理最多 5 个适合推荐类文章或选购参考内容的搜索关键词。 + 3. 整理最多 6 个可信、可公开核验的竞品或对比对象。 + + 规则: + - 仅返回 JSON,不要用 Markdown 代码块包裹。 + - 使用与 locale 一致的语言。 + - 关键词应为简洁、中性的搜索短语,避免“第一”“最好”“靠谱”“权威”“Top”等排序或背书词,关键词如用户原词包含这类表达,可改写为“怎么选”“如何判断”“选购参考”。 + - 关键词不包含 brand_name 或 competitor_names,但要与它们形成明显关联,且优先覆盖用户关注的核心痛点和搜索意图。 + - 关键词是行业痛点词和用户高频搜索词汇。 + - 竞品应去重且真实,不确定时 website 留空,不要编造 URL。 + - 若品牌优势信息带有强承诺或绝对化表述,输出时改写为中性概括,不保留无法核验的极限词。 + title_prompt_template: | + 你正在为一篇推荐类文章生成 5 个候选标题。 + 模板: {{template_name}} + 语言: {{locale}} + 当前年份: {{current_year}} + 核心关键词: {{primary_keyword}} + 品牌名: {{brand_name}} + 推荐数量: {{top_count}} + 竞品名称: {{competitor_names}} + + 要求: + - 标题风格应契合实用选购指南、盘点观察、横向比较和决策参考等内容调性。 + - 标题不出现 brand_name、competitor_names 等具体品牌或竞品名称。 + - 优先体现当前年份、地域或主题线索、数量信息和核心关键词,但保持自然,不为凑字段硬拼。 + - 标题尽量使用疑问句或总结式表达,中文标题尽量控制在 30 字以内。 + - 撰写一个疑问语气的汇总类的标题,但标题中不能缺少[年份][地域][数字][关键词][行业][总结]等字段. + - 标题里一定要出现行业痛点词和客户高频搜索词。 + - 标题中禁止出现的词汇有:“头部”、“首选”、“TOP”、“排行”“榜”“靠谱”“权威”“有限””背书”“医院排名”等词汇内容. + - 不要机械重复模板名称或「Top X 文章」字样。 + - 每个标题应具体、可读,且角度各不相同,不写标题党。 + outline_prompt_template: | + 你正在为一篇推荐类文章生成实用大纲。 + 模板: {{template_name}} + 语言: {{locale}} + 标题: {{title}} + 核心关键词: {{primary_keyword}} + 品牌名: {{brand_name}} + 竞品名称: {{competitor_names}} + 已选段落: {{outline_sections}} + 关键要点: {{key_points}} + + 要求: + - 每个已选段落作为顶层节点,下设具体子条目。 + - 首段需体现行业现状、选择依据或趋势观察,方便后续生成不少于 100 字的开篇概述。 + - 多品牌部分按品牌名或观察样本逐一展开,不要写成排名、榜单或评奖结构。 + - 在合适位置体现优势、特点、规模或团队、适合场景、服务项目、案例类型、信息边界和总结建议。 + - 如有 brand_name,可放在首个重点分析章节,但不要出现“第一名”或类似排序表述。 + - 内容应具体、有对比、有结论导向,避免泛泛而谈和推广腔。 + - 使用与 locale 一致的语言。 + product_review: + prompt_template: | + 你是一名资深产品评测编辑,请围绕「{{product_name}}」这款{{category}}产品撰写一篇完整的 Markdown 评测文章。 + 文章目标: + 1. 帮读者判断这款产品是否值得关注或购买。 + 2. 解释它的核心能力、实际使用感受、主要优缺点和适合人群。 + + 写作要求: + - 文章结构使用“开篇概述 -> 功能与体验拆解 -> 适合场景 -> 总结建议”,正文主体采用 H2 小标题。 + - 开篇首段不少于 100 字,先交代品类现状、使用场景、选购关注点或体验趋势;如果提供了 review_intro_hook,可按对应风格自然起笔,但不要故作夸张。 + - 正文要覆盖产品定位、核心功能、使用体验、限制点、适用场景和购买建议。 + - 评价必须有判断,不要只罗列卖点;要说明为什么是优点、在什么情况下会变成限制。 + - 仅使用已提供或可公开核验的功能、参数、认证、测试、案例和用户反馈;不要编造跑分、销量、口碑、奖项、专利或第三方结论。 + - 可使用“问题 - 答案”、项目符号和编号步骤增强可读性,但不要堆砌关键词或广告化表达。 + - 不输出联系方式、购买引导、广告标识,不使用“顶级”“完美”“无敌”“零差评”“行业第一”等绝对化词。 + - 结论写清楚适合谁、不适合谁,以及在什么条件下更值得考虑。 + - 全文保持客观、务实,不要写成品牌宣传稿或保证效果的承诺文案。 + analyze_prompt_template: | + 你是一位专业的 GEO 内容策略师,正在为产品评测文章做品牌与产品分析。 + 模板: {{template_name}} + 语言: {{locale}} + 品牌名: {{brand_name}} + 官网: {{official_website}} + 产品名: {{product_name}} + 品类: {{category}} + + 任务: + 1. 分析产品与品牌上下文,给出 1-2 句品牌摘要。 + 2. 推荐最多 5 个适合评测文章的搜索关键词,聚焦产品能力、使用场景和购买决策。 + 3. 推荐最多 6 个可信的同类产品或替代方案。 + + 规则: + - 仅返回 JSON,不要用 Markdown 代码块包裹。 + - 使用与 locale 一致的语言。 + - 关键词侧重评测视角,如「XX 评测」「XX 怎么选」「XX 使用感受」「XX 适合谁」。 + - 竞品应去重且真实,不确定时 website 留空。 + - 避免输出“封神”“必买”“最强”“零差评”“第一”等夸张或绝对化词。 + title_prompt_template: | + 你正在为一篇产品评测文章生成 5 个候选标题。 + 模板: {{template_name}} + 语言: {{locale}} + 当前年份: {{current_year}} + 产品名: {{product_name}} + 品类: {{category}} + 品牌名: {{brand_name}} + 核心关键词: {{primary_keyword}} + + 要求: + - 标题应像可信的评测、测评、购买建议类内容。 + - 用具体表达代替空洞口号。 + - 当 locale 为 zh-CN 时,标题应贴合中文内容平台风格,可使用评测、实测、怎么选、使用观察、适不适合等表达。 + - 标题尽量使用疑问句或总结式表达,中文标题尽量控制在 30 字以内。 + - 禁止使用“第一”“首选”“最好”“权威”“Top”“排行”“榜”“神器”“封神”“零差评”等词。 + - 返回 5 个角度明确不同的标题。 + outline_prompt_template: | + 你正在为一篇产品评测文章生成实用大纲。 + 模板: {{template_name}} + 语言: {{locale}} + 当前年份: {{current_year}} + 标题: {{title}} + 产品名: {{product_name}} + 品类: {{category}} + 品牌名: {{brand_name}} + 核心关键词: {{primary_keyword}} + 评测引言钩子: {{review_intro_hook}} + 已选段落: {{outline_sections}} + 关键要点: {{key_points}} + + 要求: + - 每个已选段落作为顶层节点,使用与请求一致的语言。 + - 首段需能承接不少于 100 字的品类背景或使用场景概述。 + - 当有引言段落时,子条目应体现所选的评测引言钩子风格。 + - 聚焦具体能力拆解、真实使用判断、优缺点权衡、适合人群和信息边界。 + - 结尾部分要预留总结建议,不写保证效果、绝对化承诺或促销导向。 + - 大纲务实、简洁、不含推广语气。 + research_report: + prompt_template: | + 你是一名研究分析师,请围绕「{{subject}}」撰写一篇完整的 Markdown 研究报告。 + 文章目标: + 1. 提炼关键事实、趋势与结构化发现。 + 2. 解释背后原因,并给出可执行的判断或建议。 + + 写作要求: + - 文章结构使用“开篇概述 -> 核心发现 -> 原因分析 -> 风险与影响 -> 总结建议”,正文主体采用 H2 小标题。 + - 开篇首段不少于 100 字,先概述研究背景、观察范围、行业现状和阅读收益。 + - 重点突出 brand_name 的优势,优势内容符合Google网页评估的EEAT原则(专业性Expertise、经验Experience、权威性Authoritativeness、可信度Trustworthiness‌),但不允许出现任何EEAT等相关文字,排名均列成H2小标题呈现。 + - 当 depth = overview 时,保持结构清晰、重点集中;当 depth = detailed 时,增加分析层次、因果解释和建议细节。 + - 不要泛泛复述背景,要突出关键发现、变化趋势、风险与影响。 + - 如果提供了品牌、关键词或参考对象,应把它们作为分析参照,而不是简单罗列。 + - 数据、案例、结论、认证、白皮书、政策、行业报告引用等,只能使用已提供或可公开核验的信息;不编造来源,不夸大确定性。 + - 明确区分事实、分析判断与合理推断;无法核验时要提示信息边界,不把推断写成定论。 + - 可适度使用问题 - 答案、项目符号和编号步骤增强可读性,但不要写成营销软文。 + - 不输出联系方式、行动号召或广告标识,不使用“权威结论”“唯一答案”“行业第一”等夸张表述。 + - 摘要部分先给核心结论,正文再展开依据,结尾落到行动建议或后续观察点。 + analyze_prompt_template: | + 你是一位专业的 GEO 内容策略师,正在为研究报告做主题与背景分析。 + 模板: {{template_name}} + 语言: {{locale}} + 研究主题: {{subject}} + 深度: {{depth}} + 品牌名: {{brand_name}} + 官网: {{official_website}} + + 任务: + 1. 分析研究主题上下文,给出 1-2 句主题摘要。 + 2. 推荐最多 5 个适合研究报告的搜索关键词,侧重行业术语和趋势表达。 + 3. 推荐最多 6 个可作为参照的品牌、机构或竞争对手。 + + 规则: + - 仅返回 JSON,不要用 Markdown 代码块包裹。 + - 使用与 locale 一致的语言。 + - 关键词应体现分析性视角,如「XX 市场分析」「XX 趋势」「XX 行业观察」「XX 研究总结」。 + - 竞品应去重且真实,不确定时 website 留空。 + - 避免“最权威”“唯一结论”“第一”“Top”等夸张或背书词。 + title_prompt_template: | + 你正在为一篇研究报告文章生成 5 个候选标题。 + 模板: {{template_name}} + 语言: {{locale}} + 当前年份: {{current_year}} + 研究主题: {{subject}} + 深度: {{depth}} + 品牌名: {{brand_name}} + 核心关键词: {{primary_keyword}} + + 要求: + - 标题应具有分析性、聚焦感和洞察力。 + - 避免模糊的企业公关式措辞。 + - 当 locale 为 zh-CN 时,可使用研究报告、趋势洞察、观察、判断、分析、总结等表达。 + - 标题尽量使用总结式或提问式表达,中文标题尽量控制在 30 字以内。 + - 禁止使用“权威”“独家”“第一”“Top”“排行”“榜”“最强”“首选”等词。 + - 返回 5 个角度各异但可信的报告风格标题。 + outline_prompt_template: | + 你正在为一篇研究报告文章生成实用大纲。 + 模板: {{template_name}} + 语言: {{locale}} + 标题: {{title}} + 研究主题: {{subject}} + 深度: {{depth}} + 品牌名: {{brand_name}} + 核心关键词: {{primary_keyword}} + 已选段落: {{outline_sections}} + 关键要点: {{key_points}} + + 要求: + - 每个已选段落作为顶层节点,下设具体子条目。 + - 首段需能承接不少于 100 字的研究背景、行业现状或观察范围概述。 + - 关键发现段落应按数据驱动的方式组织子条目。 + - 在合适位置安排研究方法、信息边界、风险影响和行动建议。 + - 内容应具有结构性、分析深度和可操作性,不写宣传口径。 + - 使用与 locale 一致的语言。 + brand_search_expansion: + prompt_template: | + 你是一名擅长搜索意图写作的内容编辑,请围绕「{{brand}} {{keyword}}」撰写一篇完整的 Markdown 品牌词搜索扩写文章。 + 文章目标: + 1. 直接回答搜索用户最关心的问题。 + 2. 解释品牌背景、核心信息和常见对比/疑问,承接后续转化或继续了解。 + + 写作要求: + - 文章结构使用“开篇概述 -> 搜索意图拆解 -> 品牌信息说明 -> 常见问题/对比观察 -> 总结建议”,正文主体采用 H2 小标题。 + - 重点突出 brand_name 的优势,优势内容符合Google网页评估的EEAT原则(专业性Expertise、经验Experience、权威性Authoritativeness、可信度Trustworthiness‌),但不允许出现任何EEAT等相关文字,排名均列成H2小标题呈现。 + - 开篇首段不少于 100 字,先解释用户为什么会搜索这个词、当前常见顾虑以及判断思路,不要直接写成品牌宣传开场。 + - 正文应覆盖品牌是什么、用户为什么会搜这个词、常见疑问、对比点和下一步决策信息。 + - 如果提供了竞品或替代方案,按用户常见比较维度展开,不要空泛地说“各有优势”。 + - 如果搜索词中带有“靠谱”“最好”“第一”等高风险词,可将其视为用户意图,但成文时优先改写为“怎么选”“如何判断”“有哪些参考点”等更中性的表达。 + - 涉及品牌历史、案例、授权、检测、投诉率、服务承诺、用户评价等内容时,仅使用已提供或可公开核验的信息;不能编造或放大。 + - 正文可适度使用问题 - 答案、项目符号和编号步骤增强可读性,但不要机械堆砌关键词或过度加粗。 + - 语气客观、有信息量,避免企业宣传腔、联系方式、行动号召和广告标识。 + - 结尾给出简洁总结,并提示用户接下来应该关注什么信息来继续判断。 + analyze_prompt_template: | + 你是一位专业的 GEO 内容策略师,正在为品牌词搜索扩写文章做品牌与意图分析。 + 模板: {{template_name}} + 语言: {{locale}} + 品牌名: {{brand_name}} + 官网: {{official_website}} + 关键词: {{primary_keyword}} + + 任务: + 1. 分析品牌与搜索意图上下文,给出 1-2 句品牌摘要。 + 2. 推荐最多 5 个品牌词搜索相关的关键词,侧重搜索意图和用户疑问。 + 3. 推荐最多 6 个可信的竞品或替代方案。 + + 规则: + - 仅返回 JSON,不要用 Markdown 代码块包裹。 + - 使用与 locale 一致的语言。 + - 关键词应贴合搜索意图,如「XX 是什么」「XX 怎么选」「XX 对比 YY」「XX 适合谁」。 + - 竞品应去重且真实,不确定时 website 留空。 + - 避免输出“最靠谱”“第一”“权威”“Top”“首选”等排序或背书词。 + title_prompt_template: | + 你正在为一篇品牌词搜索扩写文章生成 5 个候选标题。 + 模板: {{template_name}} + 语言: {{locale}} + 当前年份: {{current_year}} + 品牌名: {{brand_name}} + 核心关键词: {{primary_keyword}} + 官网: {{official_website}} + + 要求: + - 标题应适用于品牌搜索意图、对比、问答和解释型内容。 + - 避免空洞口号。 + - 当 locale 为 zh-CN 时,可使用是什么、怎么样、怎么选、对比、适不适合等表达。 + - 标题尽量使用疑问句或总结式表达,中文标题尽量控制在 30 字以内。 + - 禁止使用“靠谱”“排行”“榜”“Top”“第一”“最好”“首选”“权威”“背书”等词。 + - 返回 5 个不同的、贴合搜索意图的标题选项。 + outline_prompt_template: | + 你正在为一篇品牌词搜索扩写文章生成实用大纲。 + 模板: {{template_name}} + 语言: {{locale}} + 标题: {{title}} + 品牌名: {{brand_name}} + 核心关键词: {{primary_keyword}} + 官网: {{official_website}} + 已选段落: {{outline_sections}} + 关键要点: {{key_points}} + 竞品名称: {{competitor_names}} + + 要求: + - 每个已选段落作为顶层节点,下设具体子条目。 + - 首段需能承接不少于 100 字的搜索意图与判断思路概述。 + - 品牌概览段落应覆盖品牌定位、核心产品和差异化。 + - 对比段落应按竞品逐一展开对比维度。 + - 在合适位置体现常见问题、信息边界和理性选择建议。 + - 内容应回答搜索意图,语气客观有信息量,不写成推广稿。 + - 使用与 locale 一致的语言。 diff --git a/deploy/scripts/load-and-start.sh b/deploy/scripts/load-and-start.sh new file mode 100755 index 0000000..e0bf03a --- /dev/null +++ b/deploy/scripts/load-and-start.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# load-and-start.sh — 在【离线目标服务器】上运行 +# 加载镜像并启动所有服务 +# +# 用法(解压部署包后): +# bash deploy.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# 读取打包时记录的元数据 +if [[ -f "${SCRIPT_DIR}/.package-meta" ]]; then + source "${SCRIPT_DIR}/.package-meta" +fi +IMAGE_TAG="${IMAGE_TAG:-latest}" +LOCAL_PREFIX="${LOCAL_PREFIX:-geo-rankly}" + +# ─── 检查 .env ──────────────────────────────────────────────────────────────── +if [[ ! -f "${SCRIPT_DIR}/.env" ]]; then + echo "❌ 未找到 .env 文件,请先执行:" + echo " cp .env.example .env && vi .env" + exit 1 +fi + +# ─── Step 1: 加载镜像 ───────────────────────────────────────────────────────── +echo "==> [1/3] 加载 Docker 镜像(可能需要几分钟)..." +docker load -i "${SCRIPT_DIR}/images.tar" +echo " 镜像加载完成" +docker images | grep -E "geo-rankly|postgres|rabbitmq|redis|qdrant|minio" | head -20 + +# ─── Step 2: 设置 IMAGE_REGISTRY 为本地前缀 ────────────────────────────────── +echo "==> [2/3] 配置离线镜像引用..." +export IMAGE_REGISTRY="${LOCAL_PREFIX}" +export IMAGE_TAG="${IMAGE_TAG}" + +# ─── Step 3: 启动服务 ───────────────────────────────────────────────────────── +echo "==> [3/3] 启动所有服务..." +cd "${SCRIPT_DIR}" +docker compose \ + -f docker-compose.yaml \ + -f docker-compose.offline.yaml \ + --env-file .env \ + up -d + +echo "" +echo "✅ 部署完成!" +echo "" +echo "服务状态:" +docker compose -f docker-compose.yaml -f docker-compose.offline.yaml ps +echo "" +echo "查看日志: docker compose -f docker-compose.yaml logs -f tenant-api" +echo "停止服务: docker compose -f docker-compose.yaml down" +echo "管理界面:" +echo " RabbitMQ: http://:15672 (geo / geo_dev)" +echo " MinIO: http://:9001 (minioadmin / minioadmin)" diff --git a/deploy/scripts/package.sh b/deploy/scripts/package.sh new file mode 100755 index 0000000..1a0a720 --- /dev/null +++ b/deploy/scripts/package.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +# package.sh — 在【有网络的开发机】上运行 +# 构建所有镜像(支持跨平台),打包成可离线部署的压缩包 +# +# 用法: +# bash package.sh [IMAGE_TAG] [PLATFORM] +# +# 示例: +# bash package.sh latest linux/amd64 # M1/M2 Mac 打包给 x86 服务器(默认) +# bash package.sh v1.0.0 linux/arm64 # 打包给 ARM 服务器(如树莓派、飞腾) +# bash package.sh latest linux/amd64,linux/arm64 # 暂不支持(save 不支持 multi-arch) +# +# 产物: +# geo-rankly-deploy--.tar.gz + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +DEPLOY_DIR="${REPO_ROOT}/deploy" +IMAGE_TAG="${1:-latest}" +PLATFORM="${2:-linux/amd64}" +LOCAL_PREFIX="geo-rankly" +DATE_TAG="$(date +%Y%m%d)" +# 将 linux/amd64 → amd64,用于包名 +ARCH_LABEL="${PLATFORM##*/}" +PACKAGE_NAME="geo-rankly-deploy-${DATE_TAG}-${ARCH_LABEL}" +WORK_DIR="/tmp/${PACKAGE_NAME}" + +echo "==> 目标平台: ${PLATFORM} | 镜像 tag: ${IMAGE_TAG}" +echo "" + +# ─── 确保 buildx builder 支持目标平台 ──────────────────────────────────────── +echo "==> [0/5] 检查 buildx builder" +BUILDER_NAME="geo-cross-builder" + +if ! docker buildx inspect "${BUILDER_NAME}" &>/dev/null; then + echo " 创建多平台 builder: ${BUILDER_NAME}" + docker buildx create \ + --name "${BUILDER_NAME}" \ + --driver docker-container \ + --driver-opt image=moby/buildkit:latest \ + --use +else + docker buildx use "${BUILDER_NAME}" + echo " 使用已有 builder: ${BUILDER_NAME}" +fi + +# 确保 builder 已启动(首次使用时需要) +docker buildx inspect --bootstrap "${BUILDER_NAME}" > /dev/null + +# ─── 构建自定义镜像 ──────────────────────────────────────────────────────────── +echo "==> [1/5] 构建自定义镜像 (platform: ${PLATFORM}, tag: ${IMAGE_TAG})" +cd "${REPO_ROOT}" + +# 注意:--load 仅支持单平台;交叉编译时 buildx 使用 QEMU 或 Go 交叉编译 +_build() { + local target="$1" tag_suffix="$2" + shift 2 + echo " building ${LOCAL_PREFIX}/${tag_suffix}:${IMAGE_TAG}" + docker buildx build \ + --platform "${PLATFORM}" \ + --target "${target}" \ + "$@" \ + --tag "${LOCAL_PREFIX}/${tag_suffix}:${IMAGE_TAG}" \ + --load \ + -f server/Dockerfile \ + server/ +} + +_build runtime tenant-api --build-arg SERVICE=tenant-api +_build runtime worker-generate --build-arg SERVICE=worker-generate +_build runtime scheduler --build-arg SERVICE=scheduler +_build migrate migrate # migrate stage 不需要 SERVICE arg + +echo " building ${LOCAL_PREFIX}/frontend:${IMAGE_TAG}" +docker buildx build \ + --platform "${PLATFORM}" \ + --tag "${LOCAL_PREFIX}/frontend:${IMAGE_TAG}" \ + --load \ + -f Dockerfile.frontend \ + . + +# ─── 拉取基础设施镜像(指定平台) ───────────────────────────────────────────── +echo "==> [2/5] 拉取基础设施镜像 (platform: ${PLATFORM})" +INFRA_IMAGES=( + "postgres:16-alpine" + "rabbitmq:3.13-management-alpine" + "redis:7-alpine" + "qdrant/qdrant:v1.13.2" + "minio/minio:RELEASE.2025-03-12T18-04-18Z" + "minio/mc:latest" +) +for img in "${INFRA_IMAGES[@]}"; do + echo " pulling ${img}" + # --platform 确保拉取目标架构,覆盖本机可能已缓存的 arm64 版本 + docker pull --platform "${PLATFORM}" "${img}" +done + +# ─── 保存所有镜像 ─────────────────────────────────────────────────────────── +echo "==> [3/5] 保存所有镜像到 images.tar" +mkdir -p "${WORK_DIR}" + +ALL_IMAGES=( + "${LOCAL_PREFIX}/tenant-api:${IMAGE_TAG}" + "${LOCAL_PREFIX}/worker-generate:${IMAGE_TAG}" + "${LOCAL_PREFIX}/scheduler:${IMAGE_TAG}" + "${LOCAL_PREFIX}/migrate:${IMAGE_TAG}" + "${LOCAL_PREFIX}/frontend:${IMAGE_TAG}" + "postgres:16-alpine" + "rabbitmq:3.13-management-alpine" + "redis:7-alpine" + "qdrant/qdrant:v1.13.2" + "minio/minio:RELEASE.2025-03-12T18-04-18Z" + "minio/mc:latest" +) + +docker save "${ALL_IMAGES[@]}" -o "${WORK_DIR}/images.tar" + +# 验证镜像架构 +echo " 验证镜像架构:" +for img in "${LOCAL_PREFIX}/tenant-api:${IMAGE_TAG}" "postgres:16-alpine"; do + arch="$(docker inspect --format '{{.Architecture}}' "${img}" 2>/dev/null || echo unknown)" + echo " ${img} → ${arch}" +done +echo " 镜像包大小: $(du -sh "${WORK_DIR}/images.tar" | cut -f1)" + +# ─── 打包部署文件 ─────────────────────────────────────────────────────────── +echo "==> [4/5] 打包部署文件" +cp "${DEPLOY_DIR}/docker-compose.yaml" "${WORK_DIR}/" +cp "${DEPLOY_DIR}/docker-compose.offline.yaml" "${WORK_DIR}/" +cp "${DEPLOY_DIR}/config.yaml" "${WORK_DIR}/" +cp "${DEPLOY_DIR}/prompts.yml" "${WORK_DIR}/" +cp "${DEPLOY_DIR}/.env.example" "${WORK_DIR}/" +cp "${DEPLOY_DIR}/scripts/load-and-start.sh" "${WORK_DIR}/deploy.sh" +chmod +x "${WORK_DIR}/deploy.sh" + +# 写入元数据,供 load 脚本读取 +cat > "${WORK_DIR}/.package-meta" < [5/5] 压缩打包" +cd /tmp +tar -czf "${REPO_ROOT}/${PACKAGE_NAME}.tar.gz" "${PACKAGE_NAME}/" +rm -rf "${WORK_DIR}" + +echo "" +echo "✅ 打包完成: ${REPO_ROOT}/${PACKAGE_NAME}.tar.gz" +echo " 平台: ${PLATFORM} | 大小: $(du -sh "${REPO_ROOT}/${PACKAGE_NAME}.tar.gz" | cut -f1)" +echo "" +echo "传输到目标服务器后执行:" +echo " tar xzf ${PACKAGE_NAME}.tar.gz" +echo " cd ${PACKAGE_NAME}" +echo " cp .env.example .env && vi .env" +echo " bash deploy.sh" diff --git a/docs/superpowers/specs/2026-04-15-image-management-design.md b/docs/superpowers/specs/2026-04-15-image-management-design.md new file mode 100644 index 0000000..2e8ba43 --- /dev/null +++ b/docs/superpowers/specs/2026-04-15-image-management-design.md @@ -0,0 +1,504 @@ +# Image Management Design + +**Date:** 2026-04-15 +**Status:** Approved + +## Overview + +A standalone image asset management system for the geo-rankly admin platform. Supports independent upload, folder organization, fuzzy search, rename, and deletion. Images are reusable from the article editor via a picker modal. + +--- + +## Requirements + +| # | Requirement | +|---|---| +| R1 | Images are stored as tenant-scoped assets independent of articles | +| R2 | Users can organize images into single-level folders | +| R3 | All uploaded images are converted to WebP (quality=82) before storage | +| R4 | Image names are user-editable and support fuzzy search | +| R5 | Deletion supports reference warning + explicit force delete; final terminal state is soft-delete metadata + hard-delete OSS file | +| R6 | Article editor gains a "select from library" picker | +| R7 | Tenant isolation follows existing multi-tenant patterns | +| R8 | Storage quota enforced per plan: free=100 MB, regular=1 GB, premium=2 GB | +| R9 | Cover image picker modal: "AI封面" tab replaced with "本地素材" tab (image library) | +| R10 | Deleting a referenced image returns a usage summary; the user may still force delete | +| R11 | Quota enforcement is concurrency-safe on the hot path and does not rely on `SUM()` scans | +| R12 | Upload/delete consistency uses pending states + event-driven compensation, not a periodic full-table sweeper | + +--- + +## Data Model + +### `image_folders` + +```sql +CREATE TABLE image_folders ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + name VARCHAR(100) NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_image_folders_tenant_name UNIQUE (tenant_id, name), + CONSTRAINT uq_image_folders_tenant_id UNIQUE (tenant_id, id) +); + +CREATE INDEX idx_image_folders_tenant ON image_folders (tenant_id); +``` + +- Single-level only (no nested folders) +- Name is unique per tenant + +### `image_assets` + +```sql +CREATE TABLE image_assets ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + folder_id BIGINT, + object_key TEXT NOT NULL UNIQUE, + name VARCHAR(255) NOT NULL, -- user-editable display name + size_bytes BIGINT NOT NULL, -- size after WebP conversion + status VARCHAR(20) NOT NULL DEFAULT 'active', -- pending_upload / active / pending_delete / deleted / upload_failed + created_by BIGINT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, -- terminal soft delete timestamp + CONSTRAINT fk_image_assets_folder + FOREIGN KEY (tenant_id, folder_id) + REFERENCES image_folders(tenant_id, id), + CONSTRAINT uq_image_assets_tenant_id UNIQUE (tenant_id, id) +); + +CREATE INDEX idx_image_assets_tenant_folder ON image_assets (tenant_id, folder_id) WHERE status = 'active' AND deleted_at IS NULL; +CREATE INDEX idx_image_assets_tenant_created ON image_assets (tenant_id, created_at DESC) WHERE status = 'active' AND deleted_at IS NULL; +CREATE INDEX idx_image_assets_tenant_status ON image_assets (tenant_id, status, created_at DESC); +CREATE INDEX idx_image_assets_name_trgm ON image_assets USING GIN (name gin_trgm_ops) WHERE status = 'active' AND deleted_at IS NULL; +``` + +- `content_type` is always `image/webp` (omitted from table, implied) +- `folder_id = NULL` means root / uncategorized +- Active queries must enforce `status = 'active' AND deleted_at IS NULL` +- `object_key` path: `tenants/{tenant_id}/images/{uuid}.webp` + +### `image_asset_references` + +Used only for library-backed references so delete warnings are cheap and precise. + +```sql +CREATE TABLE image_asset_references ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + image_asset_id BIGINT NOT NULL, + article_id BIGINT NOT NULL, + ref_scope VARCHAR(20) NOT NULL, -- article_body / article_cover + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT fk_image_asset_references_asset + FOREIGN KEY (tenant_id, image_asset_id) + REFERENCES image_assets(tenant_id, id) + ON DELETE CASCADE, + CONSTRAINT uq_image_asset_reference UNIQUE (image_asset_id, article_id, ref_scope) +); + +CREATE INDEX idx_image_asset_references_asset ON image_asset_references (tenant_id, image_asset_id); +CREATE INDEX idx_image_asset_references_article ON image_asset_references (tenant_id, article_id); +``` + +- Only images inserted from the library create reference rows +- Local uploads and arbitrary external URLs do not create reference rows +- `article_body` refs are deduped per article, not per occurrence count + +### `tenant_image_storage_usage` + +Hot-path storage usage counter. This is the authoritative `used_bytes` source for quota checks. + +```sql +CREATE TABLE tenant_image_storage_usage ( + tenant_id BIGINT PRIMARY KEY, + used_bytes BIGINT NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +- One row per tenant, updated atomically +- Avoids `SUM(image_assets.size_bytes)` on every upload +- `SUM()` remains a migration/backfill/reconciliation tool only + +--- + +## Storage Quota + +Storage quota limit remains in the existing `plans.quota_policy_json` JSONB field. +Hot-path usage is tracked in `tenant_image_storage_usage`. + +**Plan configuration (added key):** + +```json +{ "article_generation": 100, "image_storage_bytes": 104857600 } +``` + +| Plan | `image_storage_bytes` | +|---------|----------------------------| +| Free | 104,857,600 (100 MB) | +| Regular | 1,073,741,824 (1 GB) | +| Premium | 2,147,483,648 (2 GB) | + +If a legacy plan row does not yet contain `image_storage_bytes`, runtime fallback is `104857600` until the migration/seed backfill is applied. + +**Atomic quota reserve on upload:** + +```sql +INSERT INTO tenant_image_storage_usage (tenant_id, used_bytes) +VALUES ($1, 0) +ON CONFLICT (tenant_id) DO NOTHING; + +WITH plan AS ( + SELECT COALESCE((p.quota_policy_json ->> 'image_storage_bytes')::BIGINT, 104857600) AS quota_bytes + FROM tenant_plan_subscriptions s + JOIN plans p ON p.id = s.plan_id + WHERE s.tenant_id = $1 + AND s.status = 'active' + AND s.deleted_at IS NULL + LIMIT 1 +) +UPDATE tenant_image_storage_usage u +SET used_bytes = u.used_bytes + $2, + updated_at = NOW() +FROM plan +WHERE u.tenant_id = $1 + AND u.used_bytes + $2 <= plan.quota_bytes +RETURNING u.used_bytes, plan.quota_bytes; +``` + +If no row is returned, reject with HTTP 409 `image_storage_quota_exceeded`. + +**Quota release on rollback/delete finalization:** + +```sql +UPDATE tenant_image_storage_usage +SET used_bytes = GREATEST(0, used_bytes - $2), + updated_at = NOW() +WHERE tenant_id = $1; +``` + +**New API endpoint:** + +``` +GET /api/tenant/images/storage-usage +→ { used_bytes, quota_bytes, used_pct } +``` + +`GET /api/tenant/images/storage-usage` reads: + +1. `used_bytes` from `tenant_image_storage_usage` +2. `quota_bytes` from the active plan + +Used to display the storage progress bar in the UI. + +--- + +## Image Upload Pipeline + +``` +User uploads file (PNG / JPG / GIF / WebP, ≤ 10 MB raw) + ↓ +Validate file size and MIME type + ↓ +image.DecodeConfig() with registered decoders + - reject oversized pixel bombs before full decode + ↓ +image.Decode() → in-memory image.Image +GIF: first frame only (no animated WebP) + ↓ +webp.Encode(quality=82) → []byte + ↓ +BEGIN TX + - reserve quota via atomic counter update + - INSERT INTO image_assets (..., status='pending_upload') +COMMIT + ↓ +Generate object_key: tenants/{id}/images/{uuid}.webp + ↓ +ObjectStorage.PutBytes(objectKey, content, "image/webp") + ↓ +Success: + UPDATE image_assets SET status='active', updated_at=NOW() +Failure: + release quota + mark status='upload_failed' + ↓ +Return { id, url, name, size_bytes } +``` + +**Decoder registration:** `_ "image/gif"`, `_ "image/jpeg"`, `_ "image/png"`, `_ "golang.org/x/image/webp"` + +**Encoder:** `github.com/chai2010/webp` — pure Go, no CGO, quality parameter 0–100. + +--- + +## Asset State Machine & Compensation + +`image_assets.status` is an explicit operation state machine: + +- `pending_upload`: quota reserved, DB row committed, OSS object not yet finalized +- `active`: visible in list/picker/editor +- `pending_delete`: hidden from UI, physical delete in progress +- `deleted`: terminal state, `deleted_at` populated +- `upload_failed`: terminal failure state, hidden from UI + +Compensation is event-driven and idempotent, using the existing RabbitMQ infrastructure. +There is no periodic full-table sweeper. + +**Upload finalize / compensation:** + +1. Request thread creates `pending_upload` +2. Request thread attempts `PutBytes` +3. If request thread can finalize DB immediately, asset becomes `active` +4. If request thread fails after OSS success or OSS failure, it publishes an idempotent `finalize_upload` command with `asset_id` +5. Consumer behavior: + - row `pending_upload` + object exists → mark `active` + - row `pending_upload` + object missing → mark `upload_failed` and release reserved quota + - row already terminal → no-op + +**Delete finalize / compensation:** + +1. Request thread first checks references +2. If references exist and `force != true`, return 409 with usage summary +3. If delete proceeds, mark asset `pending_delete` so it disappears from active lists immediately +4. Request thread attempts `ObjectStorage.Delete` +5. On success, finalize DB delete (`status='deleted'`, `deleted_at=NOW()`) and release quota +6. If any post-step fails, publish idempotent `finalize_delete` command with `asset_id` +7. Consumer retries only that asset operation; no tenant/global scan is required + +This is the same pattern used in large systems for inventory/quota and external side effects: +DB state machine + atomic counter + idempotent async compensation. + +--- + +## API Design + +All endpoints under `/api/tenant/images`, protected by existing auth middleware + tenant scope. + +### Folders + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/tenant/images/folders` | List all folders with image count | +| POST | `/api/tenant/images/folders` | Create folder `{ name }` | +| PUT | `/api/tenant/images/folders/:id` | Rename folder `{ name }` | +| GET | `/api/tenant/images/folders/:id/delete-preview` | Return image count + reference summary for folder deletion | +| DELETE | `/api/tenant/images/folders/:id` | Delete folder, supports `?force=1`; contained images follow the same delete state machine | + +### Images + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/tenant/images` | List images `?folder_id=&q=&page=&page_size=` | +| POST | `/api/tenant/images` | Upload image (multipart, optional `folder_id`) | +| PUT | `/api/tenant/images/:id` | Update `{ name?, folder_id? }` | +| GET | `/api/tenant/images/:id/references` | Return reference summary for delete confirmation | +| DELETE | `/api/tenant/images/:id` | Delete image, supports `?force=1` | + +**Search (`?q=`):** uses `ILIKE '%keyword%'`, applied across all folders (folder_id filter is ignored when q is present). + +**Delete image sequence:** +1. Query `image_asset_references` +2. If references exist and `force != true` → return HTTP 409 `image_asset_in_use` +3. `UPDATE image_assets SET status = 'pending_delete', updated_at = NOW() WHERE id = :id` +4. `ObjectStorage.Delete(objectKey)` — hard delete from OSS +5. `UPDATE image_assets SET status = 'deleted', deleted_at = NOW(), updated_at = NOW() WHERE id = :id` +6. `UPDATE tenant_image_storage_usage SET used_bytes = used_bytes - size_bytes` + +**Delete warning payload:** + +```json +{ + "code": "image_asset_in_use", + "references": { + "total": 3, + "article_body": 2, + "article_cover": 1, + "articles": [ + { "article_id": 101, "title": "Spring Launch", "scopes": ["article_body"] }, + { "article_id": 204, "title": "Baidu Guide", "scopes": ["article_body", "article_cover"] } + ] + } +} +``` + +- UI shows this summary and a destructive "仍然删除" button +- If the user confirms, client retries with `?force=1` +- Force delete is allowed even if it will break historical body/cover URLs + +**Delete folder sequence:** +1. Load all active images in folder +2. Aggregate reference summary +3. If references exist and `force != true` → return HTTP 409 with summary +4. Mark contained assets `pending_delete` and set `folder_id = NULL` +5. Delete the folder row +6. Each image finalizes through the same `ObjectStorage.Delete` + DB finalize path + +### Article Integration Contract + +To support fast reference warnings without parsing article markdown on every delete: + +- Images inserted from the library carry `asset_id` in editor-side metadata, while still rendering with normal `src` +- `PUT /api/tenant/articles/:id` adds optional `referenced_image_asset_ids: number[]` +- `wizard_state_json` keeps `cover_asset_url` as today, plus optional `cover_image_asset_id` +- On every article save, backend upserts `image_asset_references` for: + - `article_body` from `referenced_image_asset_ids` + - `article_cover` from `cover_image_asset_id` +- Direct local upload / external URL remains supported and simply produces no library reference row + +--- + +## Backend Structure + +Follows existing layered architecture in `server/internal/tenant/`. + +``` +server/internal/tenant/ +├── app/ +│ ├── image_service.go -- ImageService (upload, list, update, delete) +│ └── image_folder_service.go -- ImageFolderService (CRUD) +├── repository/ +│ ├── image_repo.go -- DB queries for image_assets +│ ├── image_reference_repo.go -- DB queries for image_asset_references +│ ├── image_usage_repo.go -- atomic quota counter updates +│ └── queries/ +│ ├── image.sql -- sqlc queries +│ ├── image_folder.sql -- sqlc queries +│ ├── image_reference.sql -- sqlc queries +│ └── image_usage.sql -- sqlc queries +└── transport/ + └── image_handler.go -- HTTP handlers for all image endpoints +``` + +`ImageService` accepts: + +- `objectstorage.Client` +- the existing `AssetHandler.BuildArticleImageURL()` for generating signed URLs +- workspace/plan lookup for `image_storage_bytes` +- an image-asset operation dispatcher backed by the existing RabbitMQ infrastructure for finalize/compensation commands + +No new external infrastructure is introduced. + +--- + +## Migration + +New migration file: `20260415xxxxxx_create_image_tables.up.sql` + +```sql +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +CREATE TABLE image_folders ( ... ); +CREATE TABLE image_assets ( ... ); +CREATE TABLE image_asset_references ( ... ); +CREATE TABLE tenant_image_storage_usage ( ... ); +-- indexes as above + +UPDATE plans +SET quota_policy_json = quota_policy_json || + CASE plan_code + WHEN 'free' THEN '{"image_storage_bytes":104857600}'::jsonb + WHEN 'regular' THEN '{"image_storage_bytes":1073741824}'::jsonb + WHEN 'premium' THEN '{"image_storage_bytes":2147483648}'::jsonb + ELSE '{}'::jsonb + END +WHERE deleted_at IS NULL; +``` + +Existing article images (path `tenants/{id}/articles/{id}/images/...`) are **not migrated**. They continue to be served via the existing `/api/public/assets/:token` mechanism unchanged. + +Seed data must also be updated so the existing default `free` plan includes `image_storage_bytes = 104857600`. + +--- + +## Frontend + +### New Page: `/images` + +- Route added to `router/index.ts` under the "内容管理" nav group (alongside Knowledge) +- Nav label: `nav.images` (i18n key) + +**Layout:** + +``` +┌─────────────────────────────────────────────────────┐ +│ 图片管理 [+ 上传图片] │ +├──────────────┬──────────────────────────────────────┤ +│ 文件夹 [+] │ 🔍 搜索图片名... │ +│──────────────│──────────────────────────────────────│ +│ 📁 未分类(12)│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │ +│ 📁 产品图(8) │ │img │ │img │ │img │ │img │ │ +│ 📁 博客配图 │ └────┘ └────┘ └────┘ └────┘ │ +│ │ │ +│ │ 文件名 · 大小 · 日期 [改名] [移动] │ +└──────────────┴──────────────────────────────────────┘ +``` + +- "文件夹" + `[+]` on the same row, `[+]` right-aligned +- Search input at top of grid area, 300ms debounce +- When `?q=` is active: folder sidebar is disabled, search results shown across all folders +- Image grid: fixed 4-column grid with lazy loading +- Hover on image card: show [改名] [移动] [删除] action buttons +- Clicking [删除] first requests `/api/tenant/images/:id/references`; if references exist, show a warning modal with article titles/counts and a destructive "仍然删除" action + +### Article Editor Picker + +- Existing toolbar image button gets a dropdown: "本地上传" | "从图片库选择" +- "从图片库选择" opens a Modal containing the image list (folder filter + search), click to insert +- Library insert stores `{ src, asset_id }` in editor-side metadata; plain local upload remains `{ src }` + +### Cover Image Modal Integration + +The existing "选择封面图" modal has two tabs: "正文/本地上传" and "AI封面". + +**Change:** Replace the "AI封面" tab with "本地素材" tab. + +```text +┌─────────────────────────────────────────┐ +│ 选择封面图 ✕ │ +├─────────────────────────────────────────┤ +│ [正文/本地上传] [本地素材] │ ← "AI封面" → "本地素材" +├─────────────────────────────────────────┤ +│ 🔍 搜索... 文件夹: [全部 ▾] │ +│ │ +│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │ +│ │img │ │img │ │img │ │img │ │ +│ └────┘ └────┘ └────┘ └────┘ │ +│ │ +│ [取消] [确认] │ +└─────────────────────────────────────────┘ +``` + +- "本地素材" tab reuses the same image list API (`GET /api/tenant/images`) +- Single-select mode: clicking an image highlights it +- If current primary platform requires crop (currently Baijiahao only), selecting a library image routes into the existing crop workspace before confirmation +- If no crop is required, "确认" uses the original library image URL directly and stores `cover_image_asset_id` +- Folder dropdown filter + search input for discovery +- Storage usage bar shown at bottom of the tab +- Result: + - Baijiahao: library image acts as crop source, final cover is the cropped export + - Other platforms: use original library asset without re-upload/re-encode + +--- + +## Constraints & Decisions + +| Decision | Rationale | +|----------|-----------| +| Single-level folders | Avoids recursive tree complexity; sufficient for this use case | +| WebP quality=82 | Good perceptual quality, ~30–50% smaller than JPEG at equivalent quality | +| GIF → static WebP (first frame) | Animated WebP adds significant complexity, deferred | +| Force delete is allowed after reference warning | Users may intentionally trade historical render integrity for storage recovery | +| `image_asset_references` is explicit | Delete warning stays cheap and precise; no markdown scan on delete | +| Atomic tenant counter for quota | High-concurrency safe and O(1) on hot path | +| Pending states + MQ compensation | External side effects stay recoverable without a periodic full-table scan | +| Delete hides first, finalizes second | UI reacts immediately while compensation remains idempotent | +| Baijiahao only enters cropper | Preserves current mandatory 16:9 cover behavior without adding unnecessary steps for other platforms | +| WebP decode uses `golang.org/x/image/webp` | Standard, simple, no CGO, works with `image.Decode()` after decoder registration | +| Tenant-aware composite foreign keys | DB enforces folder/image tenant isolation without adding a complex model | +| `ILIKE` for search | Dataset is tenant-scoped and small; trigram index added as optimization | +| No historical image migration | Avoids risky bulk migration; old paths still served correctly | diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..543f417 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,41 @@ +# syntax=docker/dockerfile:1 + +# ─── Stage 1: Build Go binaries ─────────────────────────────────────────────── +FROM golang:1.22-alpine AS builder + +ARG SERVICE=tenant-api + +RUN apk --no-cache add ca-certificates git + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -trimpath -ldflags="-s -w" \ + -o /bin/service ./cmd/${SERVICE} + +# ─── Stage 2: Migration image (bundles SQL files + migrate binary) ───────────── +FROM migrate/migrate:v4.18.1 AS migrate-tool + +FROM alpine:3.19 AS migrate +RUN apk --no-cache add ca-certificates +COPY --from=migrate-tool /usr/local/bin/migrate /usr/local/bin/migrate +COPY migrations/ /migrations/ +COPY migrations_monitoring/ /migrations_monitoring/ +ENTRYPOINT ["/usr/local/bin/migrate"] + +# ─── Stage 3: Runtime image ──────────────────────────────────────────────────── +FROM alpine:3.19 AS runtime +RUN apk --no-cache add ca-certificates tzdata + +WORKDIR /app + +COPY --from=builder /bin/service /app/service +COPY configs/ /app/configs/ + +EXPOSE 8080 +ENTRYPOINT ["/app/service"]