Files
moteva/refer/架构演进方案.md
T
root 44406b72db Initial commit: img-infinite-canvas AI design workbench MVP
Moteva-style AI design workbench replica. Home prompt creates a project;
the project page provides an infinite canvas with node drag, zoom/pan,
a design chat panel, history replay, image asset upload, and project
save/regenerate.

- Backend: go-zero, DDD layering, sqlc/pgx, optional PostgreSQL; memory
  or Redis cache; asynq job queue; MinIO/S3/R2/OSS object storage;
  sky-valley/pi agent runtime adapter.
- Frontend: Next.js App Router + Vite artifact build, TypeScript, i18n,
  shadcn/ui components, auth (OTP/Turnstile/Google/WeChat).
- Deploy: Docker Compose and k3s manifests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 23:15:37 +08:00

18 KiB
Raw Blame History

img-infinite-canvas 初期架构与演进方案

目标

当前阶段不要直接建设 Kafka、多微服务、多区域部署等终局复杂架构。推荐采用“可演进的模块化单体”:

  • 初期保持一个 go-zero API 项目,一个 PostgreSQL,一个 Redis,一个对象存储。
  • API 与 worker 可以先共用同一代码仓库,但部署为两个进程。
  • 所有关键边界按未来可拆服务设计,避免用户增长后推倒重来。
  • 新建项目必须登录,所有项目、资产、消息、任务、账本都必须绑定用户。

初期目标规模:

  • 1 万注册用户。
  • 数百级并发在线。
  • 数十到数百级并发生成任务。
  • 支持后续平滑演进到 Kafka 事件总线、独立 Billing/Agent/Realtime 服务。

当前主要问题

  1. 没有用户维度
    当前 projects 没有 user_id,项目列表和详情天然是全局资源。后续必须改成用户或租户隔离。

  2. 新建项目没有登录要求
    项目创建、保存、删除、生成、上传都应受登录态保护。未来新建项目必须登录。

  3. 画布保存是全量覆盖
    当前保存项目时会重写 nodes、connections、messages。用户增长后会造成写放大,并带来并发覆盖风险。

  4. 聊天消息和画布保存耦合
    agent_messages 应该 append-only,不应在保存画布时被删除重插。

  5. 异步任务依赖进程内 goroutine
    服务重启会丢任务,多实例部署时无法统一调度,也无法做全局并发控制、重试、死信队列。

  6. 实时通道轮询数据库
    WebSocket/SSE 当前通过高频读取完整项目推送状态。并发连接变多后会放大数据库和 JSON 序列化压力。

  7. 点数扣费尚未形成账本
    后续需要扣除点数,必须有账户、冻结、扣费、退款、流水和幂等设计。

  8. 配置和生产环境边界不足
    生产环境必须禁用 memory storage,密钥必须走环境变量或 secret,不应提交明文 API key。

初期简化架构

Next.js frontend
  -> go-zero API
      -> PostgreSQL: 用户、项目、画布、消息、点数账本、任务状态
      -> Redis: 缓存、限流、Asynq 队列、短期实时事件
      -> Object Storage: MinIO / S3 / R2 / OSS
      -> Worker process: 图片生成、图层合并、资产清理、扣费结算

初期暂不引入:

  • Kafka。
  • NATS。
  • 微服务拆分。
  • 多区域部署。
  • 复杂 organization/workspace。
  • 数据仓库事件流。

但要预留这些能力:

  • outbox_events 表,为未来接 Kafka 做准备。
  • 模块化目录和接口,未来可拆服务。
  • append-only 消息和账本,未来可审计、可回放。
  • 可替换 JobQueue 接口,未来可从 Asynq 切到 Kafka command topic。

初期技术选型

API

  • 继续使用 go-zero REST API。
  • handler 保持薄层,只做请求解析和调用 logic。
  • logic 调 application service。
  • application service 只依赖领域接口,不依赖具体 Redis/Kafka/Asynq 实现。

数据库

  • PostgreSQL 作为权威业务数据源。
  • 余额、扣点、冻结、退款必须在 PostgreSQL 事务内完成。
  • 所有核心数据表增加 user_id

缓存与任务

  • Redis 用于缓存、限流、短期实时事件。
  • 使用 Asynq 作为初期任务队列。

对象存储

  • 本地开发可用 MinIO。
  • 生产使用 S3 / R2 / OSS / 私有 MinIO。
  • 数据库只保存 object key、public URL、owner、project 归属和元数据。

初期模块边界

建议在单体内按未来服务边界拆模块:

server/internal/modules/auth
server/internal/modules/project
server/internal/modules/canvas
server/internal/modules/agent
server/internal/modules/billing
server/internal/modules/asset
server/internal/modules/job
server/internal/modules/realtime
server/internal/shared

也可以先不大规模移动现有文件,但新增代码应按这些边界组织。

模块职责:

  • auth: 登录、注册、JWT、refresh token、用户上下文。
  • project: 项目元数据、项目列表、项目删除、所有权校验。
  • canvas: 画布快照、节点保存、版本冲突。
  • agent: 会话线程、消息、生成编排。
  • billing: 点数账户、冻结、扣费、释放、退款、流水。
  • asset: 上传签名、对象存储、资产归属、清理。
  • job: 任务队列接口、Asynq/Postgres jobs 实现。
  • realtime: WebSocket/SSE 推送接口。

初期数据模型

最小可运营版本需要这些表:

users (
  id TEXT PRIMARY KEY,
  email TEXT NOT NULL UNIQUE,
  password_hash TEXT NOT NULL,
  name TEXT NOT NULL DEFAULT '',
  status TEXT NOT NULL DEFAULT 'active',
  created_at TIMESTAMPTZ NOT NULL,
  updated_at TIMESTAMPTZ NOT NULL
);

projects (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  title TEXT NOT NULL,
  brief TEXT NOT NULL,
  status TEXT NOT NULL,
  thumbnail TEXT NOT NULL DEFAULT '',
  current_snapshot_id TEXT NOT NULL DEFAULT '',
  version BIGINT NOT NULL DEFAULT 1,
  updated_at TIMESTAMPTZ NOT NULL,
  deleted_at TIMESTAMPTZ
);

canvas_snapshots (
  id TEXT PRIMARY KEY,
  project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  user_id TEXT NOT NULL REFERENCES users(id),
  version BIGINT NOT NULL,
  viewport_json JSONB NOT NULL,
  nodes_json JSONB NOT NULL,
  created_at TIMESTAMPTZ NOT NULL
);

agent_threads (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  mode TEXT NOT NULL DEFAULT 'design',
  status TEXT NOT NULL DEFAULT 'running',
  created_at TIMESTAMPTZ NOT NULL,
  updated_at TIMESTAMPTZ NOT NULL
);

agent_messages (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  thread_id TEXT NOT NULL REFERENCES agent_threads(id) ON DELETE CASCADE,
  role TEXT NOT NULL,
  message_type TEXT NOT NULL DEFAULT '',
  content TEXT NOT NULL,
  status TEXT NOT NULL DEFAULT '',
  created_at TIMESTAMPTZ NOT NULL
);

credit_accounts (
  user_id TEXT PRIMARY KEY REFERENCES users(id),
  balance BIGINT NOT NULL DEFAULT 0,
  reserved BIGINT NOT NULL DEFAULT 0,
  updated_at TIMESTAMPTZ NOT NULL
);

credit_reservations (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  project_id TEXT NOT NULL REFERENCES projects(id),
  job_id TEXT NOT NULL,
  amount BIGINT NOT NULL,
  status TEXT NOT NULL,
  expires_at TIMESTAMPTZ NOT NULL,
  created_at TIMESTAMPTZ NOT NULL,
  updated_at TIMESTAMPTZ NOT NULL
);

credit_ledger (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  project_id TEXT,
  job_id TEXT,
  reservation_id TEXT,
  ledger_type TEXT NOT NULL,
  amount BIGINT NOT NULL,
  balance_after BIGINT NOT NULL,
  idempotency_key TEXT NOT NULL UNIQUE,
  reason TEXT NOT NULL DEFAULT '',
  created_at TIMESTAMPTZ NOT NULL
);

generation_jobs (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  project_id TEXT NOT NULL REFERENCES projects(id),
  thread_id TEXT NOT NULL REFERENCES agent_threads(id),
  reservation_id TEXT,
  job_type TEXT NOT NULL,
  status TEXT NOT NULL,
  payload_json JSONB NOT NULL,
  attempts INTEGER NOT NULL DEFAULT 0,
  idempotency_key TEXT NOT NULL UNIQUE,
  created_at TIMESTAMPTZ NOT NULL,
  updated_at TIMESTAMPTZ NOT NULL
);

assets (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  project_id TEXT REFERENCES projects(id),
  object_key TEXT NOT NULL,
  public_url TEXT NOT NULL,
  content_type TEXT NOT NULL DEFAULT '',
  size_bytes BIGINT NOT NULL DEFAULT 0,
  created_at TIMESTAMPTZ NOT NULL,
  deleted_at TIMESTAMPTZ
);

outbox_events (
  id TEXT PRIMARY KEY,
  aggregate_type TEXT NOT NULL,
  aggregate_id TEXT NOT NULL,
  event_type TEXT NOT NULL,
  payload_json JSONB NOT NULL,
  status TEXT NOT NULL DEFAULT 'pending',
  published_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ NOT NULL
);

关键索引:

CREATE INDEX projects_user_updated_idx ON projects(user_id, updated_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX canvas_snapshots_project_version_idx ON canvas_snapshots(project_id, version DESC);
CREATE INDEX agent_threads_project_updated_idx ON agent_threads(project_id, updated_at DESC);
CREATE INDEX agent_messages_thread_created_idx ON agent_messages(thread_id, created_at ASC);
CREATE INDEX generation_jobs_status_updated_idx ON generation_jobs(status, updated_at);
CREATE INDEX assets_user_project_created_idx ON assets(user_id, project_id, created_at DESC);
CREATE INDEX outbox_events_status_created_idx ON outbox_events(status, created_at);

关键接口约束

所有项目相关 repository 方法必须带 userID

GetProject(ctx context.Context, userID string, projectID string) (Project, error)
ListProjects(ctx context.Context, userID string, cursor string, limit int64) (ProjectPage, error)
SaveCanvas(ctx context.Context, userID string, projectID string, req SaveCanvasRequest) (Project, error)
DeleteProject(ctx context.Context, userID string, projectID string) error

不要再使用只带 projectID 的业务方法。这样后续拆服务时权限边界不需要重做。

任务队列只暴露接口:

type JobQueue interface {
  EnqueueGeneration(ctx context.Context, job GenerationJob) error
  EnqueueAssetCleanup(ctx context.Context, job AssetCleanupJob) error
}

初期实现可以是 Asynq。未来如果切 Kafka,业务层不改,只替换 infrastructure。

计费模块只暴露业务语义:

type BillingService interface {
  ReserveCredits(ctx context.Context, req ReserveCreditsRequest) (Reservation, error)
  ChargeCredits(ctx context.Context, req ChargeCreditsRequest) error
  ReleaseCredits(ctx context.Context, req ReleaseCreditsRequest) error
}

生成模块不直接修改余额,只调用 BillingService。

登录与项目创建流程

1. 用户登录或注册。
2. API 生成 access token 和 refresh token。
3. 前端调用 POST /api/projects。
4. JWT middleware 解析 user_id。
5. ProjectService.CreateProject(ctx, userID, req)。
6. 创建 project 和初始 canvas_snapshot。
7. 写 outbox_events.ProjectCreated。
8. 返回项目。

硬性要求:

  • 未登录不能创建项目。
  • 未登录不能上传资产。
  • 未登录不能触发生成。
  • 用户只能访问自己的项目,除非后续增加协作授权。

生成与扣点流程

1. 用户点击生成。
2. API 解析 user_id、project_id、prompt、model。
3. ProjectService 校验项目归属。
4. BillingService 计算预估点数。
5. 开启 PostgreSQL 事务。
6. SELECT credit_accounts ... FOR UPDATE 锁定账户。
7. 检查 balance - reserved 是否足够。
8. 创建 credit_reservation,增加 reserved。
9. 创建 generation_job。
10. 写 credit_ledger(reserve)。
11. 写 outbox_events.GenerationRequested 和 CreditsReserved。
12. 提交事务。
13. JobQueue 入队。
14. worker 执行图片生成。
15. 成功后写节点/快照,ChargeCredits,释放多余冻结。
16. 失败后 ReleaseCredits。
17. 写 outbox_events.GenerationCompleted 或 GenerationFailed。
18. Realtime 推送前端。

扣点规则:

  • 生成前冻结点数,不直接扣除。
  • 成功后按实际消耗扣除。
  • 失败后释放冻结。
  • 所有扣点和释放必须写 credit_ledger
  • 所有扣点操作必须有 idempotency_key

画布保存流程

初期先用快照方式,不急着做复杂增量节点表:

1. 前端保存时带 base_version 和 snapshot。
2. API 校验 user_id + project_id。
3. 对 project 做乐观锁检查。
4. 如果 base_version != current version,返回 409。
5. 写入新的 canvas_snapshot。
6. 更新 projects.current_snapshot_id 和 version。
7. 不修改 agent_messages。
8. 写 outbox_events.CanvasSnapshotCreated。

这样可以先解决“保存画布重写消息”的问题,同时为未来增量节点保存预留空间。

实时推送初期方案

初期可以分两步:

第一步:

  • 保留现有 WebSocket/SSE 形态。
  • 降低数据库轮询频率。
  • 只在生成中开启轮询。
  • 项目静止时不轮询。

第二步:

  • worker 写 outbox 或 Redis pub/sub(用 outbox 保底、Redis 做实时加速)。
  • realtime 模块订阅事件。
  • WebSocket/SSE 只推增量事件,不再轮询完整 project。

事件示例:

{
  "type": "generation.completed",
  "projectId": "project-id",
  "threadId": "thread-id",
  "snapshotId": "snapshot-id",
  "messageId": "message-id"
}

Outbox 设计

初期即使不用 Kafka,也必须先建 outbox_events

写业务数据和写 outbox 必须在同一个数据库事务内:

BEGIN;
  update credit_accounts;
  insert credit_ledger;
  insert generation_jobs;
  insert outbox_events;
COMMIT;

初期可以用 Go poller 读取 outbox_events where status='pending',发布到 Redis 或直接调用 realtime broadcaster。

未来接 Kafka 时:

  • 使用 Debezium Outbox,或自研 outbox publisher。
  • Kafka topic 可以按 billing.eventsgeneration.eventsproject.events 拆分。
  • 业务事务无需重写。

架构演进路线

阶段 1:可运营单体

目标:能安全上线给真实用户使用。

改造项:

  • 新增用户登录。
  • 项目创建必须登录。
  • 所有核心表加 user_id
  • 所有项目接口按 user_id + project_id 查询。
  • 新增点数账户、冻结、流水。
  • 生成前冻结点数,成功后扣除,失败后释放。
  • 生产禁用 memory storage。
  • 明文密钥移出仓库。

部署:

api process
worker process
postgres
redis
object storage

阶段 2:可扩展单体

目标:API 和 worker 可独立扩容。

改造项:

  • 引入 Asynq。
  • 所有慢任务进入队列。
  • agent_messages append-only。
  • canvas_snapshots 替换全量 project 保存。
  • outbox poller 发布内部事件。
  • 基础限流:按 user、IP、model。
  • 基础指标:QPS、p95、DB pool、队列长度、生成耗时、扣点失败率。

部署:

api replicas
worker-image replicas
worker-cleanup replicas
postgres
redis
object storage

阶段 3:准终局架构

目标:将增长压力从 API 层剥离。

改造项:

  • realtime 独立进程。
  • worker 按任务类型拆池。
  • Redis pub/sub 或 stream 推送增量事件。
  • 画布保存支持乐观锁和冲突处理。
  • 历史快照清理或归档。
  • 点数账本增加审计后台。
  • 资产增加生命周期管理。

部署:

api replicas
realtime replicas
worker-image replicas
worker-billing replicas
worker-asset replicas
postgres primary/replica
redis
object storage + CDN

阶段 4:终局平滑拆分

触发条件:

  • API 单体发布频率互相影响。
  • Billing 需要独立合规和权限边界。
  • Agent worker 资源消耗显著高于其他模块。
  • Realtime 连接规模对 API 进程造成影响。
  • 多业务系统需要消费项目、计费、生成事件。

拆分顺序:

  1. 拆 Realtime 服务。
  2. 拆 Worker 服务。
  3. 拆 Billing 服务。
  4. 拆 Agent 服务。
  5. 接 Kafka 作为跨系统事件总线。

终局形态:

API Gateway
  -> Auth Service
  -> Project Service
  -> Canvas Service
  -> Billing Service
  -> Agent Service
  -> Asset Service
  -> Realtime Service

PostgreSQL / Redis / Kafka / Object Storage / CDN / Observability

现在不要做死的设计

  • 不要再出现全局项目列表。
  • 不要只用 project_id 查询项目。
  • 不要把扣点逻辑写在生成逻辑里。
  • 不要让生成任务只存在 goroutine 里。
  • 不要让保存画布重写消息。
  • 不要把实时事件只放内存里。
  • 不要让业务层依赖 Asynq/Kafka 具体实现。
  • 不要让 assets 只有 URL,没有 owner 和 project 归属。

初期实施顺序

  1. 清理密钥
    移除配置文件中的真实 API key,统一使用环境变量或 secret。

  2. 登录鉴权
    新增 users、登录接口、JWT middleware。项目创建、保存、删除、生成、上传全部要求登录。

  3. 数据隔离
    projectsuser_id,所有项目 repository 方法带 userID。迁移现有项目到默认管理员用户。

  4. 点数账本
    新增 credit_accountscredit_reservationscredit_ledger。生成前冻结点数。

  5. 任务队列
    新增 generation_jobs,引入 Asynq worker。替换进程内 go func() 生成任务。

  6. 消息解耦
    agent_messages append-only,画布保存不再重写消息。

  7. 画布快照
    新增 canvas_snapshots,保存时使用版本号和 base_version

  8. Outbox
    新增 outbox_events,业务事务内写事件,初期用 poller 发布。

  9. 实时优化
    由轮询完整 project 逐步改为推送增量事件。

  10. 压测和运维
    建立基础指标、日志、告警、备份恢复和压测脚本。

验收标准

上线前必须满足:

  • 未登录不能创建项目。
  • 用户不能读取、保存、删除别人的项目。
  • 生成任务重复提交不会重复扣点。
  • worker 重启后任务不会永久丢失。
  • 图片生成失败会释放冻结点数。
  • 保存画布不会重写聊天消息。
  • 项目列表按 user_id 分页查询。
  • 生产配置不使用 memory repository/cache/object storage。
  • 真实密钥不在仓库配置文件中。

增长到 1 万用户前应满足:

  • API 和 worker 可独立水平扩容。
  • 队列长度和失败率可观测。
  • PostgreSQL 慢查询可观测。
  • 点数账本可审计。
  • 资产可按用户和项目追踪。
  • WebSocket/SSE 不再高频拉完整 project。

参考方向

  • AWS Well-Architected: 持续演进、可靠性、可观测性。
  • Strangler Fig Pattern: 从模块化单体平滑演进到微服务。
  • Debezium Outbox Pattern: 业务事务与事件发布解耦。
  • PostgreSQL row lock: 点数账户冻结与扣费需要数据库事务和行锁。
  • OWASP Authentication Cheat Sheet: 登录和会话安全基线。