Compare commits

..

287 Commits

Author SHA1 Message Date
ryan ae3b792e16 feat(core): sync framework security hardening and accessibility improvements
- add util.Go with panic recovery for background goroutines
- add util.EscapeLike and explicit ESCAPE clause for SQL LIKE queries
- add DummyCheckPassword and subtle.ConstantTimeCompare against timing attacks
- enforce session ID rotation upon login/oauth callback to prevent session fixation
- add sliding window login failure rate limiting and oauth state rate limiting
- fix redis client capture race in pubsub listeners and wait on stop channel
- adjust global --primary to oklch(51.1% 0.262 276.966) for WCAG AA contrast
- fix semantic heading levels and missing aria-labels across UI components
- document security, concurrency, and a11y standards in AGENTS.md
2026-08-27 23:01:28 +08:00
ryan b66cf3ae9c feat(log): PG 分区清理与 logstore import-lint
CleanupExpired 先按月 DROP 过期分区,再删边界行并清理空分区;apps 禁止直连 analytics。
2026-08-16 16:48:15 +08:00
ryan a8fcf6087a chore(message-gateway): swagger and format 2026-08-16 12:27:03 +08:00
ryan 30acdb91e8 feat(message-gateway): add profile bot pairing card 2026-08-16 12:23:44 +08:00
ryan 124ce9bebb feat(message-gateway): add admin channel cards and per-type forms 2026-08-16 12:22:28 +08:00
ryan 635c1760ad feat(message-gateway): add user bind and unbind APIs 2026-08-16 12:19:32 +08:00
ryan 69d39d906f feat(message-gateway): add admin channel CRUD APIs 2026-08-16 12:17:05 +08:00
ryan 09ec9d0af3 feat(message-gateway): run adapters on worker and handle pairing inbound 2026-08-16 12:14:17 +08:00
ryan 7ca6dbe272 feat(message-gateway): add QQ official C2C botgo adapter
Pin github.com/tencent-connect/botgo v0.2.1. Connect uses C2C intent
only via the official WebSocket session manager.
2026-08-16 12:10:15 +08:00
ryan ef97ca5c7e feat(message-gateway): add Telegram private-chat telebot adapter 2026-08-16 12:06:03 +08:00
ryan cc86370e50 feat(message-gateway): emit message_gateway.inbound domain events 2026-08-16 12:04:55 +08:00
ryan 60afdf7c7b feat(message-gateway): add channel, binding, and pairing repositories 2026-08-16 12:04:14 +08:00
ryan 9b1ef1cf7f feat(message-gateway): add w_message_* models and goose migrations 2026-08-16 12:03:23 +08:00
ryan 8ea4c7e13e feat(message-gateway): add channel types, registry, and pairing codes 2026-08-16 12:01:52 +08:00
ryan a4db79e9bd chore: ignore local git worktrees directory 2026-08-16 12:00:42 +08:00
ryan 8b1eb9ca0d docs(message-gateway): add Wavelet message gateway implementation plan 2026-08-16 11:59:59 +08:00
ryan e36db8a56c docs(message-gateway): add Wavelet inbound channel gateway design spec 2026-08-16 11:55:57 +08:00
ryan 37d5a87c9b chore: docs 2026-08-16 11:32:46 +08:00
ryan e52592b16d feat(log): 解耦用户访问日志存储,支持切换日志主库
用户访问日志可在 ClickHouse、PostgreSQL、SQLite 之间切换。
关闭 ClickHouse 时由主库承接写入与查询;切换任务会冻结写入、复制数据后翻转主库。
启动时校验日志主库与运行配置一致,定期清理按各库保留天数删除过期记录。
2026-08-16 11:17:55 +08:00
ryan 6a53619dd2 feat(framework): 回灌 OpenFlare 分层、安全与运行时改进
将平台域持久化收敛为 repository 唯一入口,model 去掉 IO。
邮件头写入前清除 CR/LF,防止 header 注入。
httppool 支持可配置 Transport;batchwriter 增加 MinBatchSize/Stats,flush 失败交回批次;任务 PermanentError 作为 SkipRetry 终态。
设置与推送页的确认改为 AlertDialog;axios 去尾斜杠并按 Gin 数组序列化查询参数。
升级共享 Go 依赖(Gin、Asynq、OTel、GORM、Redis 等)。
2026-08-16 11:07:20 +08:00
ryan b9b42e3174 ci: make canary 2026-08-16 10:13:39 +08:00
ryan 20830d31bd chore: guideline 2026-08-16 10:01:56 +08:00
ryan 284eec54f7 chore: guideline 2026-08-12 12:40:06 +08:00
ryan a3ad0d2c97 chore: rename .agent to .agents and update skill path references 2026-08-12 12:39:15 +08:00
ryan 92322c7a22 feat(push): log upstream webhook response in task history
Pusher.Send now returns the upstream response body alongside the error,
so the push task handler can print what the webhook actually replied
(custom channel e.g. {"errcode":0,"errmsg":"ok"} or a rejection
like {"errcode":93000,...}) into the task log on both success and
failure. Other pushers (lark/telegram/email) return an empty string,
keeping their behavior unchanged.

fix(push): surface webhook business errors in custom channel audit

CustomPusher.Send only checked the HTTP status code. WeChat Work /
DingTalk webhooks return HTTP 200 with a non-zero errcode in the body
even when the message is rejected (e.g. template_card requires
card_action.url when type=1), so rejected pushes were recorded as
'success' in the notification history. Parse the response body and
return an error when errcode is non-zero, matching the Lark pusher.
2026-08-12 12:32:23 +08:00
ryan f499645cdc chore: guideline 2026-08-12 12:02:14 +08:00
ryan 776c6b397c ci: canary 2026-08-03 21:54:08 +08:00
ryan db17965b1a chore: docker-compose 2026-08-03 21:48:34 +08:00
ryan ae35b4e733 ci: docker hub 2026-08-03 21:45:50 +08:00
ryan c6d22549ce ci: canary 2026-08-03 21:32:42 +08:00
ryan 9f15f42b47 fix(frontend): keep browser API on same-origin rewrites in dev
Document that NEXT_PUBLIC_WAVELET_BACKEND_URL must stay unset for local
next dev so axios hits /api/* and Next rewrites proxy to the backend.
2026-08-03 21:25:22 +08:00
ryan a4588b05e0 fix(frontend): suppress body hydration warning from extensions
Browser extensions can inject classes like vc-init onto body before
React hydrates; ignore attribute mismatches on body.
2026-07-24 16:30:49 +08:00
ryan 1625cfb2fb feat(frontend): add next-intl bilingual i18n for core paths
Wire next-intl without locale routes, add zh-CN/en catalogs, language
switcher, and migrate layout/auth/settings UI copy. Document i18n rules
in AGENTS.md and keep static export builds working.
2026-07-24 16:25:41 +08:00
ryan 9c1369186f docs(i18n): add frontend bilingual i18n design spec
Capture the approved next-intl non-routing approach for zh-CN/en,
locale resolution, static-export constraints, and phase-1 core-path scope.
2026-07-24 16:08:45 +08:00
ryan fbbb75095f refactor(structure): group platform, infra, and shared packages
Move process wiring, technical adapters, and cross-cutting contracts out of flat internal/ packages so new code has a clear home without changing business layout.
2026-07-24 15:28:39 +08:00
ryan 290427d5fa chore: remove 2026-07-24 14:29:07 +08:00
ryan 72dbfac3fc chore: update pg version 2026-07-24 11:18:01 +08:00
ryan 018f237384 perf: AGENTS.md 2026-07-22 23:05:23 +08:00
ryan ef28cdc3ad perf: makefile 2026-07-22 22:52:31 +08:00
ryan b1f2241d0a perf: skill 2026-07-22 22:31:18 +08:00
ryan fae83ab0fa perf: biome 2026-07-22 22:26:02 +08:00
ryan b498117f32 perf(skill): clean 2026-07-22 22:19:24 +08:00
ryan ef4e9c9edc feat(frontend): biome 2026-07-22 22:19:07 +08:00
ryan 5fd056f99f chore(release): bump version to v1.4.1
### 🛠 修复
- 修复了 Redis 维护通知(maintenance notifications)在启动阶段默认开启协商可能影响启动流程的问题,新增 `maint_notifications` 启动开关并将其默认值调整为禁用,同时统一应用到平台 Redis 与 Asynq 任务客户端。
- 修复了 PostgreSQL SQL 日志级别配置未按预期生效的问题,将常规 SQL 语句输出统一收敛至 debug 级别,避免在生产日志中产生冗余输出。
- 修复了 CI 镜像构建流水线中缺少 IMAGE 环境变量、导致后续步骤无法正确引用镜像地址的问题。
- 修复了前端 prettier 配置缺失的问题。

### ⚡️ 优化与改进
- 在 API、Worker、Scheduler 等进程启动并完成监听器绑定后,统一打印服务 Banner 信息,便于快速识别各进程的运行状态、监听端口与数据库迁移进度。
- 管理员设置页面在浏览器刷新后自动保持当前选中的标签页,通过 URL query 参数持久化并校验非法取值,提升后台操作的连续性。

### 💄 其他/体验
- 新增前端 prettier 配置文件与忽略规则,统一代码格式化规范。
2026-07-13 16:05:55 +08:00
ryan 08fac67f2a feat(startup): print service banner after listener ready 2026-07-13 15:59:29 +08:00
ryan 9cac25696a fix(redis): add maintenance notification startup switch
Default Redis maintenance notification negotiation to disabled and apply the startup-only setting to both platform and Asynq clients.
2026-07-13 15:48:31 +08:00
ryan a938a5e67f fix(db): log SQL statements at debug level 2026-07-13 15:41:04 +08:00
ryan e0eb4e5975 prettier 2026-07-13 15:17:45 +08:00
ryan 08e61ce833 fix(frontend): prettier config 2026-07-13 15:14:47 +08:00
ryan c293a255d0 feat(frontend): persist selected tab on admin settings page refresh
Store the selected tab of the admin settings page in the URL query string under the tab parameter. Defend against invalid values by validating it against the list of known tabs.
2026-06-30 20:52:24 +08:00
ryan 1855b48028 fix ci 2026-06-28 17:31:57 +08:00
ryan 2310e1d12a chore(release): bump version to v1.4.0
### 🛠 修复
- 修复了前端人机验证(Captcha)中请求和响应信封格式不匹配的问题。
- 修复了由于自定义 CSS 变量命名冲突导致用户删除确认按钮在特定主题模式下变黑的问题。
- 修复了由于注册 `reset-passwd` 命令行后触发 Cobra 严格子命令校验、导致原有以参数形式启动的 `all`、`api`、`worker`、`scheduler` 等模式命令失效的 Bug。

### ⚡️ 优化与改进
- 重构了 OAuth 缓存,将其替换为系统标准 RAM 内存缓存并引入了多节点之间的 Pub/Sub 订阅发布缓存同步机制,增强高频鉴权的稳定性。
- 后端新增 `reset-passwd` 命令行工具,支持管理员在后台快速重置指定用户的密码并使该用户的 Token 缓存立即失效。
- 后台用户管理支持在详情中直接编辑个人资料和重置密码,并在列表页新增“邮箱”字段与支持邮箱前缀 LIKE 模糊条件搜索。
- 前端引入本地自托管的 Inter 字体,搭配系统原生“苹方”(PingFang SC)与微软雅黑,极大提升了西文、数字及中文字符在不同操作系统下的显示与排版效果。
- 优化了 OpenTelemetry 链路追踪配置,使用 Schemaless 规范避免 semconv 产生 Schema 版本冲突。

### 💄 其他/体验
- 清理了系统设置及个人中心等页面中所有硬编码的靛蓝(indigo)样式色彩,统一改用标准的 Primary 主题配色,使其完美支持多主题与亮暗色模式切换。
- 移除了用户编辑弹窗和徽章(Badge)上不合规的硬编码前背景色,确保所有基础组件的配色由主题系统统一驱动。
- 优化了前端 UI,微调了侧边栏激活项的字体颜色,并修复了危险动作(Destructive)按钮在特定模式下的色彩对比度。
- 优化了 CI 流程,提高构建流水线的执行效率。
2026-06-28 11:40:59 +08:00
ryan f5ee19405f fix(cmd): register all app modes as subcommands in cobra
Resolve unknown command error when launching all/api/worker/scheduler modes due to Cobra strict subcommand validation triggered by reset-passwd. Subcommands now run database migrations via dynamic PreRun hooks.
2026-06-28 11:37:26 +08:00
ryan de605834b7 style(frontend): remove hardcoded indigo colors in settings components
Replace all manual bg-indigo and text-indigo overrides with standard CSS variables such as bg-primary/10 and text-primary across common settings modules to support theme integration.
2026-06-28 11:30:52 +08:00
ryan 5a6ffd600a feat(frontend): integrate self-hosted Inter Google font
Use next/font/google to download and host Inter locally, injecting it into html root via Tailwind CSS variables and fallback to PingFang SC for Chinese characters.
2026-06-28 11:20:02 +08:00
ryan bdbc42c22d style(frontend): remove hardcoded text and bg color overrides on components
Remove redundant text/bg style overrides on Button and Badge components in UserDetailSheet and UserFilterBar to allow proper default and destructive theme variants.
2026-06-28 11:08:11 +08:00
ryan 84626f9613 fix(frontend): remove custom classes to fix black delete confirmation button
Remove custom className from AlertDialogAction to prevent CSS variables overlap, restoring the default red background with white text styling.
2026-06-28 11:06:51 +08:00
ryan 932f0c65b9 feat(admin): support user profile editing, password resetting, and email column with search
- Add UpdateUser API and logics supporting nickname, email, admin flag modification, and password reset.
- Relocate user delete button and confirmation Alert into the EditUserModal.
- Optimize admin Switch change to trigger instant API request with rollback support.
- Fix missing email field in edit form initialization by fetching full profile metadata.
- Render email column in users list and support email-based filtering in UserFilterBar.
- Remove hardcoded styles and sizes from Switch components to follow global theme.
2026-06-28 11:05:57 +08:00
ryan 206f8b59c9 feat(cmd): add reset-passwd command to reset user password
- Added ./wavelet reset-passwd subcommand to reset user passwords via CLI
- Supported --user flag; if not specified, prompts for username interactively
- Supported --password flag; if not specified, generates a secure random password
- Handled access token deletion and cache invalidation
- Added comprehensive unit tests
2026-06-28 10:50:15 +08:00
ryan a25a570973 fix(frontend): adjust sidebar active text color and fix destructive button contrast
- Update sidebar active menu button text color to use theme dynamic `sidebar-primary` variable instead of hardcoded hex value.

- Add missing `destructive-foreground` variables to default theme config and styles, resolving the black-on-black text contrast issue on confirmation dialog delete buttons.

- Update changelog to track these fixes.
2026-06-28 10:50:15 +08:00
ryan 681de3b8cc refactor(oauth): replace legacy oauth cache with standard ram cache and add pubsub synchronization
- Replaced custom map-based cache in apps/oauth/cache.go with standard pkg/cache/ram framework.
- Implemented Redis Pub/Sub invalidation channels for distributed token and user cache synchronization.
- Created apps/oauth/cache_test.go to verify local cache operations and pub/sub broadcasts.

refactor(cache): generic RAM cache with CoW and unified preheating

Replaced L2 Redis cache and old cache package with process-local generic pkg/cache/ram. Implemented Copy-on-Write for reads, fine-grained locks per type for writes, and unified preheating in bootstrap. Changed cache invalidation to lazy-loading to resolve SQLite deadlocks during transactions.
2026-06-27 14:17:26 +08:00
ryan a4f6c2ae34 fix(frontend): cap envelope mismatch 2026-06-21 11:20:36 +08:00
ryan 6e6bce6a03 Optimize CI 2026-06-20 13:53:54 +08:00
ryan ef5d5b46af chore(release): bump version to v1.3.1
### 🛠 修复
- 修复了风控中间件测试在 ClickHouse 批写架构迁移后无法正确初始化的问题。
- 修复了 OpenTelemetry trace provider 初始化时 semconv Schema URL 版本冲突导致进程无法启动的问题。

### ⚡️ 优化与改进
- 新增 ClickHouse 独立 OLAP 管线,以 goose 迁移作为唯一 schema 来源,并抽取 analytics repository 统一访问日志读写。
- 新增通用 ClickHouse batchwriter 批量写入框架,支持各业务域独立缓冲 flush 管道与默认批处理调优。
- 风控访问日志与管理端日志查询改为经 repository 层批写与查询,移除内联 SQL 与手动 DDL 维护路径。
- OAuth Access Token 与会话校验引入 RAM+Redis 缓存,降低高频鉴权路径的数据库读取压力。
- 上传元数据、Auth Source 与系统配置批量读取接入三层缓存(RAM→Redis→DB),并通过 pub/sub 支持多节点失效同步。
- 上传统计增量更新收敛为单事务写入,减少 ingest/remove 路径的锁竞争与统计偏差风险。
- ClickHouse 迁移与连接初始化增加进程隔离,避免与主库迁移互相阻塞。
- 引入 lifecycle 优雅停机钩子,确保进程退出前 flush 批写缓冲与释放资源。

### 💄 其他/体验
- 新增 cache-framework 与 clickhouse-batchwriter 开发技能,并更新 AGENTS.md Skill 关联索引。
- 登录与 Token 校验路径增加缓存预热,缩短冷启动后首次鉴权延迟。
2026-06-20 11:20:11 +08:00
ryan a1f6459c09 fix(trace): 使用 NewSchemaless 避免 semconv schema 版本冲突
业务 Resource 改为 NewSchemaless 合并,继承 resource.Default() 的 SDK 内置
schema URL,不再硬编码 semconv 版本路径。
2026-06-20 11:20:01 +08:00
ryan 280bb63cbd chore(release): bump version to v1.3.1
### 🛠 修复
- 修复了风控中间件测试在 ClickHouse 批写架构迁移后无法正确初始化的问题。

### ⚡️ 优化与改进
- 新增 ClickHouse 独立 OLAP 管线,以 goose 迁移作为唯一 schema 来源,并抽取 analytics repository 统一访问日志读写。
- 新增通用 ClickHouse batchwriter 批量写入框架,支持各业务域独立缓冲 flush 管道与默认批处理调优。
- 风控访问日志与管理端日志查询改为经 repository 层批写与查询,移除内联 SQL 与手动 DDL 维护路径。
- OAuth Access Token 与会话校验引入 RAM+Redis 缓存,降低高频鉴权路径的数据库读取压力。
- 上传元数据、Auth Source 与系统配置批量读取接入三层缓存(RAM→Redis→DB),并通过 pub/sub 支持多节点失效同步。
- 上传统计增量更新收敛为单事务写入,减少 ingest/remove 路径的锁竞争与统计偏差风险。
- ClickHouse 迁移与连接初始化增加进程隔离,避免与主库迁移互相阻塞。
- 引入 lifecycle 优雅停机钩子,确保进程退出前 flush 批写缓冲与释放资源。

### 💄 其他/体验
- 新增 cache-framework 与 clickhouse-batchwriter 开发技能,并更新 AGENTS.md Skill 关联索引。
- 登录与 Token 校验路径增加缓存预热,缩短冷启动后首次鉴权延迟。
2026-06-20 10:51:27 +08:00
ryan f52c8db21a perf(cache): 三层缓存框架补强
- 新增 cache-framework skill,规范 RAM→Redis→DB 读路径、失效与 pub/sub
- 上传元数据 Otter+Redis 缓存与多节点失效;Auth Source 缓存与 pub/sub
- ListSystemConfigsByKeys 补 Redis 层;上传统计单事务;登录/Token 缓存预热
- cleanup 任务补 upload meta 失效钩子
2026-06-20 10:20:35 +08:00
ryan d490030b75 fix: test 2026-06-20 09:58:37 +08:00
ryan 200525a1ab perf: refactor 2026-06-20 09:54:48 +08:00
ryan cac6e88bc0 perf: refactor 2026-06-20 09:46:29 +08:00
ryan 080be1e03a perf: access token cache 2026-06-20 09:46:29 +08:00
ryan d0a9958711 perf: clickhouse isolation 2026-06-19 21:31:03 +08:00
ryan a98d266278 feat(db): add ClickHouse batchwriter framework and skill
Introduce internal/db/batchwriter as a reusable generic buffered writer
for per-domain ClickHouse flush pipelines, with unit tests and default
batch tuning aligned with audit log ingestion.

Add clickhouse-batchwriter agent skill and cross-references in AGENTS.md
and database-migration. Business layers are not wired yet.
2026-06-19 20:59:07 +08:00
ryan 00593742c3 feat(clickhouse): integrate goose migrations and analytics repository
Add a separate ClickHouse OLAP pipeline with goose/clickhouse DDL as the
sole schema source, model/analytics for ORM mapping, and repository/analytics
for reads (ChDB/GORM) and batch writes (ChConn). Refactor risk_control and
admin/logs to use the repository layer instead of inline SQL. Remove the
manual support-files DDL and document the workflow in database-migration skill.
2026-06-19 12:04:22 +08:00
ryan 13e9fead35 chore(release): bump version to v1.3.0
### 🛠 修复
- 修复了存储驱动切换保存后上传记录未同步指向新后端的问题。
- 修复了 S3 不可达时无法暂存本地存储配置的问题。
- 修复了文件软删除后增量统计未正确扣减的问题。
- 修复了 Snowflake ID 生成异常或出现负值时的不可靠行为,增加重试与集中错误处理。
- 修复了自更新流程中解压文件名不匹配导致更新失败的问题。
- 修复了用户列表按 ID 排序方向不正确的问题。
- 修复了前端服务层循环依赖导致构建失败的问题。

### ⚡️ 优化与改进
- 新增 upload.Ingest 程序化上传域服务,统一对象写入、上传记录与增量统计,支持秒传、Worker 摄取与镜像去重策略。
- 新增文件上传统计全量重建异步任务,可在管理端触发以修复历史统计偏差。
- 存储配置收敛为单一 storage_config 来源,移除逐条上传记录的 storage_driver 冗余字段。
- 抽取 repository 层并瘦身 HTTP Handler,统一 Abort 系列错误响应与链路追踪集成。
- 将进程级初始化从 router 迁至 bootstrap/cmd,任务与推送改为显式装配,消除 init 副作用。
- 认证与用户模块通过 listener 域事件解耦推送,避免核心业务直接依赖通知模块。
- 分离 user 模块 Handler 与 Logic 边界,便于 Worker 与单元测试复用业务逻辑。
- 引入系统配置内存缓存与验证码运行时配置快照,降低高频配置读取的数据库压力。
- 优化上传热路径与管理端增量统计表,文件统计查询由全表扫描降为常数级读取。
- 前端拆分管理端 bundle、并行化鉴权与公共配置加载,并虚拟化日志列表以提升首屏与滚动性能。

### 💄 其他/体验
- 新增 file-upload 开发技能,并将 AGENTS.md Skill 索引整理为分类表格。
- 优化文件管理统计页在 Tab 切换与再次进入时的自动刷新,避免展示过期缓存数据。
- 优化异步任务日志的展示与清理体验。
- 前端管理组件就近归位并拆分服务层目录,提升代码可维护性。
2026-06-18 15:14:38 +08:00
ryan 83b5475d69 fix(frontend): refresh file storage stats on tab and page revisit
Set stats query staleTime to 0 with refetchOnMount always, and unmount
inactive file admin tabs so returning to the stats view triggers a new fetch
instead of serving the 30s React Query cache.
2026-06-18 15:13:09 +08:00
ryan 990e3a6f51 feat(upload): add rebuild stats async task 2026-06-18 15:05:52 +08:00
ryan 1cad0c5c55 feat(upload): add programmatic Ingest service and file-upload skill
Introduce upload/ingest as the single domain entry for storing files,
writing w_uploads records, and maintaining incremental stats. Refactor
HTTP UploadFile and delete handlers to delegate to ingest, fix stats
decrement ordering on Remove, and document usage in the file-upload skill.
2026-06-18 14:57:47 +08:00
ryan 9c6c697d95 fix(user): user id asc 2026-06-18 14:00:21 +08:00
ryan 410ff14795 refactor(storage): drop per-upload storage_driver, use storage_config as single source
Remove w_uploads.storage_driver and route all read/write/delete paths through
storage.Active() backed by storage_config.driver. Block direct driver switches
when uploads exist; require migration task instead. Simplify migration to
cursor-based file_path iteration without per-row driver updates.
2026-06-18 13:56:31 +08:00
ryan 1f391e9ec2 fix(storage): apply driver switch on save and repoint upload records
Saving storage settings now activates the selected driver immediately
instead of staging it until migration. When the driver changes, existing
upload rows are repointed to the new storage_driver so /f/{id} reads use
the correct backend. ForDriver can also open non-active drivers from the
saved multi-backend config.
2026-06-18 13:40:29 +08:00
ryan 6fa7034172 update AGENTS.md 2026-06-18 13:32:44 +08:00
ryan e766066a75 fix(storage): allow staging local config when S3 is unreachable
When switching storage drivers in admin settings, save now validates
connectivity against the selected target backend instead of retesting
the still-active driver. The active driver remains unchanged until
migration completes, so unreachable MinIO no longer blocks saving local
storage settings.
2026-06-18 13:28:56 +08:00
ryan 2648f3f3b7 merge main into repository-context-enabled-background 2026-06-18 12:13:45 +08:00
ryan 1b2e083aec refactor(api): extract repository layer and thin HTTP handlers
Introduce internal/repository for data access and cache-backed system
config reads. Move business logic into logics.go across admin push,
user, template, cache, system_config, and upload/handler packages.

Remove Gin from internal/util by relocating request-scoped helpers to
oauth/gin_context.go. Propagate request context for config lookups in
user flows. Slim model entities and delete model-level DB/cache helpers.

Wire handlers to logics/repository so targeted packages no longer call
db.DB directly. Update admin router tests to use ErrorHandlerMiddleware.
2026-06-18 12:12:49 +08:00
ryan dd991909af test(response): fix AbortWithError router tests and oauth/bootstrap reliability
- Add middleware_test.go covering ErrorHandlerMiddleware and Abort helpers
- Switch router test setups to testhelper.NewTestGinEngine for error JSON
- Fix OAuth provider cache to use mock HTTP client and normalize issuer URLs
- Add ResetInitRuntimeOnceForTest to make bootstrap tests hermetic under -count
- Update admin/task test imports for upload/task package move
2026-06-18 12:05:56 +08:00
ryan e5b3a60f73 merge main into handler-model-logics-user 2026-06-18 10:55:18 +08:00
ryan beae8d2dd9 refactor(frontend): colocate admin components and split service layer
Move route-specific admin components from components/common/admin into
app/(main)/admin/<feature>/components/. Split AdminService god object into
domain services, fix UploadService to use BaseService, add AdminUploadService,
consolidate DB export into DbManageService, and migrate consumers to the
unified services entry.
2026-06-18 10:55:14 +08:00
ryan 3d25a377cb merge: integrate http-handler-abort-error architecture refactor
Resolve user package conflicts by keeping main's service-layer logics and
applying Abort* error handling in routers. Align oauth callback with
listener.EmitAdminLoggedIn from main.
2026-06-18 10:53:03 +08:00
ryan 9af84c8ed6 refactor(api): unify error handling and split upload/oauth god modules
Replace c.JSON(200, response.Err) and middleware gin.H bypasses with
response.Abort* helpers so errors flow through Gin Error chain and
ErrorHandlerMiddleware for OTel trace correlation.

Split oauth/sources.go into domain-focused files and decompose upload
into handler/filesrv/stats/task/cache/storage/util subpackages with a
root facade preserving existing import paths.
2026-06-18 10:51:25 +08:00
ryan 13f823e5e3 test(bootstrap): fix review findings and sync architecture docs
- Register tasks in admin/task test setup after init() removal
- Strengthen bootstrap test: RegisterPushDomainEvents before Init
- Add admin_login auth→push listener integration test
- Update AGENTS.md and push/new-async-task/new-api skills for
  bootstrap composition root, listener domain events, and explicit
  test wiring conventions introduced since 50c45db5
2026-06-18 10:50:45 +08:00
ryan dac1c979d1 chore: remove accidental .grok hook files from merge 2026-06-18 10:39:37 +08:00
ryan de8a21a49f merge main: resolve bootstrap and router init conflicts 2026-06-18 10:38:55 +08:00
ryan 03fa5d948d refactor(bootstrap): move runtime init from router to cmd layer
Extract SyncEvents and InitLogWriter from router.Serve into bootstrap.Init
called from cmd entry points with trace-aware context. Preserve existing
Register* wiring for task and push domain integrations.
2026-06-18 10:38:08 +08:00
ryan fcf17db2e7 merge: replace init registration with bootstrap wiring 2026-06-18 10:34:57 +08:00
ryan 22c2ad5c73 refactor(task): replace init registration with bootstrap wiring
Introduce internal/bootstrap as the composition root with sync.Once
guards for task handler registration and push listener wiring. Replace
the single OnTaskCompleted global hook with multi-subscriber handlers
and remove init()-driven side effects from worker, admin task, and push.
2026-06-18 10:34:49 +08:00
ryan 1135347a96 merge: decouple auth from push via domain events 2026-06-18 10:31:14 +08:00
ryan b659f47b62 merge: refactor(user) separate Handler and Logic layer boundaries 2026-06-18 10:31:12 +08:00
ryan 6066eb114b refactor(auth): decouple auth from push via domain events
Introduce internal/listener as a domain event bus so oauth and user
modules emit AdminLoggedIn without depending on admin/push. Register
push handlers explicitly at the router composition root, replacing
init() side-effect registration and blank imports.
2026-06-18 10:31:11 +08:00
ryan 50c45db561 refactor(user): separate Handler and Logic layer boundaries
Move HTTP handlers out of logics.go and replace gin.Context-coupled
login email verification with context-only processLoginEmailVerification.
Add logics_test.go for pure business logic unit tests.
2026-06-18 10:31:09 +08:00
ryan eb99628cd6 dmux 2026-06-18 10:29:48 +08:00
ryan f826807cf1 feat(task): clean task log 2026-06-17 14:29:38 +08:00
ryan 333e45572a fix(frontend): optimize 2026-06-17 13:58:32 +08:00
ryan 3a05e7f09c refactor(idgen): centralize negative ID handling via panic
Restore NextUint64ID() to uint64-only API so callers need no error
checks. Retry logic stays in idgen; after 3 negative values it panics
instead of returning 0, preventing silent NULL primary keys.
2026-06-17 13:14:17 +08:00
ryan 92dc6a59c0 fix(idgen): retry snowflake generation and error on negative ID
NextUint64ID now retries up to 3 times when Int64() is negative, then
returns an error instead of 0 or Fatalf. All call sites propagate the
error to avoid GORM omitting zero-value primary keys.
2026-06-17 13:11:16 +08:00
ryan a27113feb2 fix(frontend): break service layer circular imports for build
Point service implementations at @/lib/services/core for BaseService
instead of the barrel index, fixing static export prerender failure on
ConfigService initialization.
2026-06-17 12:17:05 +08:00
ryan a44043262e perf(frontend): split admin bundles and tighten data fetching
Add next/dynamic lazy loading for database, logs, and settings heavy
modules; migrate access-logs and task-executions to React Query; parallelize
login auth-sources with public config; consolidate users table tooltips;
enable lazy image decoding; and replace barrel @/lib/services imports with
direct module paths.
2026-06-17 12:12:47 +08:00
ryan 9550fa6ff3 perf(cap): add runtime settings snapshot for dynamic config
Replace per-request GetByKey calls with a singleflight-backed
RuntimeSettings snapshot loaded via ListSystemConfigsByKeys.
Invalidate snapshot on admin cap_* writes and via Redis pub/sub.
Simplify VerifyMiddleware and update PERFORMANCE.md status.
2026-06-17 12:07:02 +08:00
ryan d3ba767087 perf(config): add Otter RAM cache layer for system configs
Introduce pkg/cache/ram on top of existing Redis/DB config reads.
GetByKey now checks local RAM before Redis. Unified invalidation clears
RAM and Redis hash fields on admin writes, with pub/sub for multi-node
RAM eviction. Add tests and wire create/update/migrator write paths.
2026-06-17 11:57:04 +08:00
ryan ca21e0eca7 perf(frontend,config): parallelize auth, virtualize logs, cache public configs
Frontend layout renders immediately; pages gate via RequireAuth and
useAuthRedirect. Login/register skip getUserInfo. Admin log panel uses
useVirtualizer to avoid 2000-row DOM. ListVisibleSystemConfigs caches
visibility=1 configs in Redis with invalidation on create/update.
PERFORMANCE.md marks completed optimizations.
2026-06-17 11:41:24 +08:00
ryan 6a8f9a7aea perf(upload): optimize file hot paths and incremental admin stats
- Use RWMutex for disk cache reads and singleflight for WebP cache misses
- Cache migration read-only state and file access whitelist with pub/sub invalidation
- Add w_upload_stats incremental counters updated on upload/delete
- Add w_uploads composite indexes and goose backfill migrations
- Document performance analysis in docs/PERFORMANCE.md
- Fix RegisterCustomRoutes to accept apiV1Router parameter
2026-06-17 11:33:48 +08:00
ryan e057cd5de5 fix(update): extracted filename mismatch 2026-06-16 10:51:58 +08:00
ryan 82155b8599 chore(release): bump version to v1.2.0
### 🛠 修复
- 修复了异步任务列表在成功状态下允许重试、失败状态下无重试按钮的问题。
- 修复了启用推送事件时,在无可用推送渠道配置下仍尝试触发并阻塞的问题。
- 修复了 WebDAV 存储驱动在 Put/Get/Delete 等操作中由于丢弃 context.Context 导致 HTTP 链路追踪断裂(生成无源 Root Span)的问题。
- 修复了结构化日志在没有 active span 时仍强制打印全零 traceID/spanID 产生的日志冗余噪音。

### ⚡️ 优化与改进
- 实现了全新的系统通知推送机制,支持 Telegram Bot、Lark 机器人及自定义 Webhook 等多种推送渠道。
- 优化了路由结构设计,按照 V1 分类与业务模块实现扁平化的路由解耦。
- 引入了全局 OpenTelemetry 链路追踪(Tracer)框架,集成 Gin, GORM 与 Redis 自动化耗时度量,并补充了统一的全局错误处理中间件。
- 优化了采样器命名,将 ParentBasedErrorAwareSampler 重命名为更契合其真实机制的 ParentBasedRatioSampler。
- 重构并统一了项目架构为基于 Feature 的功能模块化结构,将 internal/util/ 拆分得更加纯净,优化了验证码等公共库提取(pkg/cap)。
- 实现了推送事件与自定义通道的 Redis 缓存机制,极大降低了推送触发时的高频 DB 查询压力。

### 💄 其他/体验
- 优化了前端界面布局,同步系统菜单与侧边栏配置显示。
- 优化了前端自定义通道表格的布局与样式,使其与事件管理 Tab 页面保持一致。
- 更新了项目开发技能手册(Skills),包括新增接口路由规范(new-api)和异步任务开发指南(new-async-task)。
2026-06-16 10:39:22 +08:00
ryan f129a9cfca feat(api): implement global error handler middleware and trace integration
- Introduce APIError type and AbortWithError helper in response package
- Implement errorHandlerMiddleware to record Go errors to Otel Spans and format JSON response
- Register errorHandlerMiddleware globally in router
- Refactor logs analytics handler to use the new unified error pattern
2026-06-16 10:24:14 +08:00
ryan 9fa38dcfdc feat(push): implement Redis caching for push events and custom channels
- Implement cached queries in GetActivePushEventByKey and GetActivePushChannelByName.
- Set TTL for cached items to 24 hours via activePushEventCacheTTL and activePushChannelCacheTTL constants.
- Implement GORM hooks (AfterSave and AfterDelete) on PushEvent and PushChannel to auto-evict Redis caches, guaranteeing cache consistency.
- Evict Redis caches manually inside API handlers for Create/Update/Delete/Toggle event/channel endpoints.
- Update events.go to query models through the new caching methods.
2026-06-16 10:19:06 +08:00
ryan 9515f0811b chore: fix release ci 2026-06-16 10:08:21 +08:00
ryan c6df73e522 tracer 2026-06-16 09:58:15 +08:00
ryan bfa00ae6ac feat(push): validate push channels presence before enabling push event
- Reject toggling an event to enabled in ToggleEvent handler if channels list is empty.
- Add model-level validation in PushEvent.Validate() to prevent enabling events without channels during creation/updates.
- Update TestPushRouters/toggle_event_status unit test to cover this verification.
2026-06-16 08:59:03 +08:00
ryan 6a913701b9 refactor(push): remove legacy push_global_token setting
- Clean up redundant push_global_token INSERT and DELETE statements from 202606140004 migration.
- Add DELETE for push_global_token in 202606160001 migration to wipe out legacy settings on update.
- Adjust expected w_system_configs count in migrator_test.go to 30.
2026-06-16 08:57:35 +08:00
ryan ff60f1c699 refactor(push): clean up legacy push_config and w_push_events hardcoded insertions
- Remove ConfigKeyPushConfig and delete legacy push_config query logic from EventTrigger.Trigger.
- Simplify the push event notification dispatching engine to rely purely on database custom channels.
- Remove w_push_events hardcoded INSERT statement from migration files, letting SyncEvents handle default event registration.
- Rewrite unit tests to use model.PushChannel instead of push_config.
2026-06-16 08:54:32 +08:00
ryan e50e600bc8 docs(skill): update router packaging and categorization guidelines in documentation
- Updated `new-api` skill `SKILL.md` to document the centralized v1.go route registration and domain-driven sub-routing patterns
- Updated `AGENTS.md` instructions with the new router subpackages (`root`, `v1`) and route file layout guidelines
2026-06-16 00:00:53 +08:00
ryan 0adc5e8189 refactor(router): organize root routes and centralize v1 route registration
- Created `internal/router/root` package to register root-level paths
- Moved files serving, robots.txt, and Swagger docs to `root/default.go`
- Registered custom root routes under `root/custom.go`
- Centralized all v1 routes registration in `internal/router/v1/v1.go`
- Simplified `internal/router/router.go` by delegating route registration
- Updated `new-api` skill `SKILL.md` to document the new router structure
2026-06-15 23:42:48 +08:00
ryan dfcbdfe47c docs(skill): update new-api guidelines to reflect router v1 structure
- Updated package structure diagram and routing instructions to point to the new `internal/router/v1` package
- Changed custom routes registration guide to `internal/router/v1/custom.go`
2026-06-15 23:30:45 +08:00
ryan 5016103774 refactor(router): split route registration into v1 package categorizations
- Created `internal/router/v1` subpackage
- Split routes into `admin.go`, `user.go`, `public.go`, and `custom.go`
- Cleaned up imports and helper registration functions from `router.go`
- Removed obsolete `internal/router/custom.go`
2026-06-15 23:30:16 +08:00
ryan 363d2cb4cc refactor(router): merge frontend.go and frontend_embedded.go into one file
- Declared package-level `registerFrontend` variable in `router.go` as a no-op fallback
- Configured `frontend.go` under `embed_frontend` build tag to override `registerFrontend` on package initialization
- Removed separate `frontend_embedded.go`
- Resolved related revive linter warnings
2026-06-15 23:24:09 +08:00
ryan 34de1639f5 feat(frontend): sync menu display config items with sidebar layout
- Added missing routes `/files` and `/admin/push` to menu display management
- Standardized icons, labels and descriptions to match the sidebar layout
- Declared MenuItem/MenuGroup interfaces for type safety
2026-06-15 23:18:04 +08:00
ryan 7f00ed6a65 fix(task): fix manual retry button visibility and validation
- Only show the manual retry button for failed tasks (status === "failed") on the frontend.
- Remove the maximum retry limit validation for manual retries in the task executor.
- Remove obsolete test cases verifying maximum retry limit for manual retries.
2026-06-15 23:10:36 +08:00
ryan 3e8e879661 docs(architecture): update architectural guidelines to reflect feature-based service design
- Updated AGENTS.md, new-api SKILL.md, and new-async-task SKILL.md to remove references to the deleted global internal/service/ package.
- Documented feature-based local logics/services design within internal/apps/<module>/.
- Updated package comments in internal/diskcache to point to pkg/cache/disk.
2026-06-15 17:24:44 +08:00
ryan 239711cea7 refactor(service): adopt feature-based architecture and rename pkg/diskcache
- Moved GORM/Redis CAPTCHA manager from internal/service/cap directly into the cohesive CAPTCHA app folder at internal/apps/cap/.
- Moved background system cleanup handler from internal/service/cleanup.go into internal/apps/upload/cleanup.go.
- Completely removed the global internal/service directory to keep module logic self-contained.
- Renamed the core utility engine pkg/diskcache to pkg/cache/disk to separate underlying utility code from db/config integrations.
- Renamed DiskCache struct in pkg/cache/disk to Cache to resolve revive package-name stuttering warning.
- Regenerated Swagger API documentation and confirmed all tests compile and pass with 0 linter issues.
2026-06-15 16:45:26 +08:00
ryan 953af7d8db refactor(util): move response helper to common/response and session logic to oauth
- Relocated generic HTTP response helpers (Response, OK, Err, etc.) from internal/util/ to a dedicated internal/common/response/ package.
- Renamed ResponseAny to Any to resolve revive stuttering warnings.
- Moved session building options and cookie headers logic from internal/util/ to internal/apps/oauth/.
- Removed all direct imports of Gin/Sessions/HTTP frameworks from internal/util/ to keep general utilities 100% pure.
- Regenerated Swagger API documentation via make swagger.
- All tests and make code-check compile and pass with 0 issues.
2026-06-15 16:39:55 +08:00
ryan b3ed94342c refactor(backend): extract to pkg/cap 2026-06-15 16:39:55 +08:00
ryan 84ae4ec27e refactor(frontend): optimization 2026-06-15 16:39:55 +08:00
ryan 1425fd1dbb feat(frontend): align custom channels table layout and styling with events tab 2026-06-15 16:39:55 +08:00
ryan 2a3b9f8a5f feat(push-task): connect notification module with task module
- Add task_type to w_push_events table and GORM models.
- Implement OnTaskCompleted callback hook in task executor to avoid circular dependencies.
- Implement task listener in push package to trigger notifications on task completion.
- Automatically resolve User objects from payload and results.
- Enhance UI to select task completed events and preview default templates.
- Update Swagger documentation.
2026-06-15 16:39:55 +08:00
ryan de58b118b4 feat(push): add telegram bot push notification channel
Implement TelegramPusher in pkg/push, register config schema in channels_definition.go, add validation in model/push_channel.go, update task routing, and update settings-tab.tsx UI validation.
2026-06-15 15:00:49 +08:00
ryan cb018b3b60 feat(push): implement system notification and push framework 2026-06-14 23:07:08 +08:00
ryan aee457093d chore(release): v1.1.0
### 🛠 修复
- 修复了 CAPTCHA 验证的逻辑和安全性。

### ⚡️ 优化与改进
- 新增了动态存储配置与多后端迁移机制。
- 新增了手动触发存储迁移的 Web GUI 操作界面。
- 实现了存储迁移的并发处理(使用 Group)与上传后 SHA-256 完整性自动校验机制。
- 实现了基于 Redis 的分布式锁与集群多节点缓存失效广播机制,防止迁移任务冲突。
- 实现了 Redis 锁续期守护协程 (watchdog) 防止超长迁移任务锁过期。
- 优化了更新存储配置时的连通性自动校验机制,防止配置错误。
- 优化了存储配置的内存缓存机制,并引入了全局共享连接池以提高 TCP 复用率。
- 优化了上传前已有同名文件的比对逻辑,跳过下载阶段,仅比对 Content-Length 以实现零网络流量跳过。
- 新增了普通用户的独立文件管理 Dashboard 与控制接口。
- 优化了文件管理接口命名空间,将全局管理迁移至管理员级 API Namespace 隔离控制。
- 优化了管理员新建用户接口,增加了邮箱(Email)字段的必填要求。

### 💄 其他/体验
- 重构并优化了文件管理页面,引入了多标签页多维度统计数据大屏。
- 基于 shadcn 原生 UI 组件重构并优化了文件详情和仪表盘组件。
- 抽取后端核心路由注册流程,降低主路由文件圈复杂度,使路由配置更易维护。
2026-06-13 17:13:36 +08:00
ryan 5698169f20 refactor(router): extract route registration into helper methods 2026-06-13 16:29:27 +08:00
ryan 922f764241 feat(user): require email when admin creates a user
- Add `email` as a required field in `createUserRequest`
- Enforce email format verification and database uniqueness checks in the admin user creation handler
- Update the admin user creation frontend modal with validation and form field
- Update the corresponding backend unit tests and regenerate Swagger docs
2026-06-13 16:23:07 +08:00
ryan 5b13d5b464 feat(storage): implement user-group level file management dashboard and APIs
- Expose user-scoped CRUD APIs under `/api/v1/upload` (my files query, stats, rename, delete)
- Update backend handlers and routers with ownership validation checks
- Create a dedicated frontend personal file manager card-list and upload button under `/files`
- Add comprehensive backend test coverage and update API docs
2026-06-13 16:16:43 +08:00
ryan 8b19ffed90 refactor(storage): move file management routes from user to admin namespace
- Remove file list, stats, download, and deletion routes from '/api/v1/upload'
- Move these endpoints under '/api/v1/admin/uploads'
- Remove user-specific filtering from files query and statistics to aggregate system-wide uploads by default
- Allow admins to bypass ownership check when downloading private files
- Update backend unit tests, Swagger documentation, and frontend service client and components
2026-06-13 16:04:21 +08:00
ryan dbabe8b8d7 feat(system_config): validate storage configuration connectivity on update
- Add a live test connectivity check in UpdateSystemConfig before saving the storage configuration.
- Merge masked placeholder secrets from current configuration prior to testing and database storage.
- Extract validation and test logic to validateAndMergeStorageConfig helper to satisfy cyclomatic complexity.
- Add TestUpdateStorageConfigValidation covering successful updates and failed checks.
2026-06-13 15:57:41 +08:00
ryan 1d37242a8b refactor(storage): update Backend.Put to return PutResult and encapsulate bucket mapping
- Update Backend.Put method signature in storage.go to return (PutResult, error).
- Adjust all backend implementations (local, oss, s3, webdav) to return a PutResult enclosing Key and Bucket.
- Refactor storeUploadFile in upload routers.go to extract key/bucket from PutResult, eliminating manual config bucket lookups.
- Remove the unused cfgBucket helper from storage_ops.go.
- Adjust storage_migration_task.go and tests to accommodate the updated method signature.
2026-06-13 15:48:41 +08:00
ryan ce9423f877 feat(upload): implement Redis lock renewal watchdog for storage migration
- Add a watchdog goroutine that periodically extends the Redis lock TTL every 10 minutes to prevent premature lease expiration for long-running migrations.
- Use context.Background with timeout contexts for lock renewal and final deletion to prevent parent context cancellation from aborting lock cleanup.
- Add nolint directives for gosec and contextcheck and define constants for durations to satisfy strict code quality gates.
2026-06-13 15:31:39 +08:00
ryan 66e0a69666 feat(frontend): support manual storage migration triggering
- Modify storage-config-tab.tsx to separate storage configuration saving from task dispatching.
- Save operations now always preserve the currently active storage driver to prevent premature storage engine switching.
- Add a manual '开始迁移' button to trigger storage migration explicitly.
- Display a warning notice to the operator when the selected storage type differs from the active one.
2026-06-13 15:31:39 +08:00
ryan 26a1e71a27 feat(storage): implement Redis distributed lock and cache invalidation broadcasting
- Add Redis distributed lock in Execute of MigrationHandler to prevent concurrent storage migrations.
- Define storage:config_invalidation Redis pub/sub channel to broadcast cache invalidation events.
- Implement background pub/sub listener on all nodes to evict config memory cache concurrently.
- Add integration tests for distributed locking and invalidation propagation using miniredis.
2026-06-13 15:31:39 +08:00
ryan acd430836f feat(upload): implement parallel storage migration and integrity check
- Parallelize storage migration using `errgroup` with a concurrency limit of 10.
- Perform post-copy SHA-256 data integrity validation to prevent silent data corruption.
- Add test case verifying migration with both incorrect and correct hashes.
2026-06-13 15:31:39 +08:00
ryan bc18800b58 perf(storage): add config caching and shared HTTP connection pool
- Implement thread-safe local config caching with a 5s TTL check.
- Reuse backend client singletons in storage.Active and storage.ForDriver.
- Reset in-memory config cache on configuration saves and updates.
- Create internal/httppool package to manage shared HTTP transports.
- Configure WebDAV client and CDN retrieval to reuse the shared pool.
- Add unit tests for httppool and storage caching behaviors.
2026-06-13 15:31:39 +08:00
ryan 9a18bea324 feat(storage): add dynamic storage config and migration
Move storage backend configuration from startup YAML to system_config-backed runtime configuration. Add local, S3-compatible, R2, MinIO, OSS, and WebDAV backend support.

Add a storage migration async task using the existing task dispatch framework. Migration target config is carried in task payload, and maintenance mode is derived from task execution state.

Split upload file management and storage operations, add the admin storage configuration tab, and update migrations and Swagger docs.
2026-06-13 15:31:38 +08:00
ryan 4bf8a4806e refactor(frontend): split files coordinator into separate tab components 2026-06-13 12:53:15 +08:00
ryan 9453af2aa0 refactor(frontend): style files stats tab with native shadcn
refactor(frontend): style files stats tab with native shadcn charts
2026-06-13 12:49:29 +08:00
ryan b262880189 refactor(auth): improve session security, CAPTCHA validation and code hygiene
- Integrate CapWidget with dual-scope capability on the frontend and protect registration/send-email-code endpoints on the backend.
- Set session cookie SameSite mode to Lax.
- Propagate request context through auth source database operations and optimize username uniqueness validation.
- Standardize local error naming to camelCase and resolve references.
- Fix linter rules, missing SheetContent closing tag, and unit tests.
2026-06-13 12:33:01 +08:00
ryan 04280a7b11 feat(upload): refactor file management with statistics and multi-tab layout 2026-06-13 12:32:42 +08:00
ryan 40e83a832f add(skill): code review 2026-06-13 11:33:47 +08:00
ryan e5c661f957 add(skill): code review 2026-06-13 11:28:17 +08:00
ryan 49bd850c8b fix(frontend): repair login session flow
Resolve login-page 401 hangs and redirect races by relying on the shared user state. Keep protected-route redirects intact, clean pending requests without unhandled rejections, and allow the dynamic icon route through the page proxy.
2026-06-13 11:27:30 +08:00
ryan 9a6bb04bf5 fix(frontend): distinguish initial authentication state on login page mount
- Add wasUserPresentRef to detect if the user was already authenticated on initial page load.
- Guard the useEffect redirect block so that it only redirects automatically if the user was already authenticated when mounting.
- Prevent duplicate concurrent router.replace calls from canceling each other when logging in via the form.
2026-06-13 11:09:55 +08:00
ryan 0df4712831 fix(frontend): resolve login redirect loop and clean up info tab
- Prevent infinite session probe requests on the login page by guarding the check with the authenticated user state and using the Latest Ref pattern.
- Decouple useEffect from resolveRedirectTarget by using resolveRedirectTargetRef to avoid searchParams dependency loops.
- Remove the unused '服务连接' Card from the settings info tab and clean up unused imports, queries, and properties.
2026-06-13 11:07:27 +08:00
ryan 30aa89f686 chore(release): v1.0.2
### 🛠 修复
- 修复了修改密码时不会吊销现有会话和访问 Token 的问题,确保密码更改后,所有其它客户端会话和 Access Token 立即失效,防范被盗凭据的持续利用。
- 修复了 WebSocket 连接未校验 Origin 的问题,引入严格的 Origin 允许列表校验,防范跨源 WebSocket 劫持攻击 (CSWSH)。
- 修复了未配置服务地址时 CORS 中间件原样反射 Origin 的问题,严格限制允许跨域访问的源为精确配置的 Origin 列表,增强跨域请求安全性。
- 修复了已认证用户可以通过上传记录 ID 直接越权读取其他用户私有文件的问题,引入了基于文件所有权及权限模式的严格访问控制。
- 修复了 OIDC 策略强制执行不严以及未登录用户能够触发自动绑定导致账户接管的问题,增强了 OAuth 绑定过程中的策略校验。
- 修复了 OAuth 流程中 State 校验不严的问题,在 Session 中强制绑定 State 并在回调时进行一致性验证,防止 CSRF 和账号接管攻击。
- 修复了登录或认证重定向时未对 URL 目标域进行限制的问题,实现了重定向目标 URL 的安全净化与源校验,防范开放重定向与反射型 XSS 漏洞。
2026-06-13 10:31:54 +08:00
ryan 26e12594a2 fix(user): revoke all sessions and access tokens on password change
- Store user password hash in session during login

- Validate password hash compatibility on requests to prevent session reuse

- Revoke all user access tokens and clear session on ChangePassword
2026-06-13 10:27:28 +08:00
ryan eb999eba09 fix(logs): restrict websocket origin to prevent cswsh (LOG-2)
- Restrict WebSocket upgrade to same-origin or configured server_address allowed origins.
- Add comprehensive test suite in utils_test.go to verify origin matching rules.
2026-06-13 10:25:25 +08:00
ryan f6f8c25930 fix(router): restrict CORS origin reflection to allowed hosts (AUTH-ROUTE-5)
- Extract isOriginAllowed helper to match Origin against server_address configurations
- Ensure arbitrary origins are not reflected and credentials are not allowed when server_address is unconfigured or mismatched
- Trim trailing slashes from allowed origins configuration for robust matching
- Add TestCORSMiddleware to cover all CORS matching and rejection scenarios
2026-06-13 10:23:20 +08:00
ryan 7b379863b4 fix(upload): restrict cross-user private file access (UPLOAD-1)
- Add access_mode column to w_uploads table (0 = private, 1 = public) and initialize data in a single migration script
- Enforce strict ownership check for private files during download
- Allow public files to follow whitelisted public-access rules
- Default access_mode to public for avatars and private for generic uploads
- Update frontend service to support optional accessMode parameter
2026-06-13 10:13:41 +08:00
ryan 5412c385dc fix(oauth): remove pending oauth auto-binding and enforce oidc policies
- Complete removal of completePendingOAuthBinding logic to prevent unintended account takeovers (AUTH-ROUTE-1).
- Add strict OIDC policy checks (global switch and source active states) across authorization and callback paths (AUTH-POLICY-1).
- Fix OIDC test cases to properly clear the Redis-backed system config cache using composite keys.
2026-06-13 10:06:49 +08:00
ryan 895788974c fix(oauth): secure OAuth state session binding to prevent account takeover
Bind OAuth state payloads to the initiating session token and user ID.
Verifies session token hash continuity during callback, and validates that
the user ID completing the binding flow matches the user ID that initiated it.
2026-06-13 09:55:07 +08:00
ryan f48426dbf8 fix(frontend): sanitize redirect targets to prevent XSS/open redirect
Sanitize and validate redirect targets from callbackUrl parameter and sessionStorage in login, registration, and OAuth callback flows.
Introduced safeRedirectTarget helper which rejects protocol-relative URLs, non-relative schemes, control characters, backslashes, and encoding bypasses.
2026-06-13 09:51:30 +08:00
ryan 50d21b431b feat(valkey): migrate redis to valkey and fix updater custom prefix selection
Replace redis:7-alpine with valkey:8.0-alpine and configure MaintNotificationsConfig ModeDisabled to suppress handshake warnings on Valkey. Resolve updater bug by dynamically matching custom repository asset name prefixes like PixezSync.
2026-06-12 15:44:49 +08:00
ryan 3228574a8c 兼容包名 2026-06-12 15:32:47 +08:00
ryan 752101612e 打印日志 2026-06-12 15:17:45 +08:00
ryan c9642fb5e2 修复退出异常问题 2026-06-12 14:32:27 +08:00
ryan 179a23f1a0 MAKE 调整 2026-06-12 14:29:20 +08:00
ryan 55831efd44 界面优化 2026-06-12 14:17:53 +08:00
ryan c916f566d9 系统控制台完成更新 2026-06-12 14:12:03 +08:00
ryan 407c1edf74 更新指导 2026-06-12 13:42:17 +08:00
ryan 654d7fd646 修复导出编译问题 2026-06-12 13:10:39 +08:00
ryan 16604e5f86 设置增加站名配置 2026-06-12 11:57:30 +08:00
ryan 6a1f89936e 登录界面优化 2026-06-12 11:35:48 +08:00
ryan d9df78d2c7 邮箱注册要求 2026-06-12 10:55:24 +08:00
ryan 63e3ade7f4 登录注册分开 2026-06-12 10:27:03 +08:00
ryan 1fe5118029 登录状态记录 2026-06-12 10:15:29 +08:00
ryan 62fcd245f2 fix: 映射后台任务查询接口的 task_type 参数为 Asynq 任务名以解决类型过滤无数据问题 2026-06-11 23:28:50 +08:00
ryan 01b80ac376 优化CI 2026-06-11 20:32:42 +08:00
ryan 50533e1837 build: 优化 Docker/Workflow 构建,使用 Next.js 环境变量注入版本和构建时间,并移除对 package.json 的硬编码替换 2026-06-11 20:18:49 +08:00
ryan 7c125ccef2 fix: 修复任务执行记录列表按任务类型和状态过滤失效的 Bug 并更新 Swagger 2026-06-11 20:14:02 +08:00
ryan 46760d4286 缓存处理修补 2026-06-11 19:07:22 +08:00
ryan 5915b31519 图片预热任务 2026-06-11 17:49:08 +08:00
ryan 7da4b72d24 任务日志优化 2026-06-11 16:56:53 +08:00
ryan f14875a8de 图片缓存不过期 2026-06-11 15:57:31 +08:00
ryan 1eef5336e3 修复文件管理页面分页问题 2026-06-11 15:52:35 +08:00
ryan 533f783268 质量优化 2026-06-11 15:36:46 +08:00
ryan 6b93320404 接口参数调整 2026-06-11 15:32:44 +08:00
ryan ad97ca7df1 图片压缩调用缓存 2026-06-11 15:23:15 +08:00
ryan 0221d4de14 缓存框架 2026-06-11 15:12:09 +08:00
ryan e8e0326879 图片压缩 2026-06-11 14:58:17 +08:00
ryan 2a3a17b6fe 优化 2026-06-11 14:28:07 +08:00
ryan eb9f6023f0 文件管理权限控制 2026-06-11 14:17:36 +08:00
ryan 5e0de01c4b 文件管理权限控制 2026-06-11 14:09:32 +08:00
ryan 312bc7d4d5 框架表改名 w_{name} 2026-06-11 09:27:15 +08:00
ryan 786fe71778 优化定时任务 2026-06-11 09:20:28 +08:00
ryan b5a1707898 优化 2026-06-11 09:10:06 +08:00
ryan 616242fad8 去除 access_token 访问写库逻辑 2026-06-11 09:09:17 +08:00
ryan 6cbc368dc1 修复任务日志显示问题 2026-06-11 09:03:40 +08:00
ryan c1a1904a67 修复重复注册问题 2026-06-11 09:03:24 +08:00
ryan 3023d47eec 解耦任务框架与业务任务 2026-06-11 08:52:44 +08:00
ryan 5dedff1324 修复任务参数类型转换 2026-06-11 08:40:08 +08:00
ryan 8964054fd8 修复任务参数类型转换 2026-06-11 08:26:30 +08:00
ryan 5241055030 根据名称获取认证源(名称比较不区分大小写) 2026-06-11 08:06:46 +08:00
ryan 983228227e AccessToken 默认非管理员权限 2026-06-10 22:50:44 +08:00
ryan bff9e8811d 界面优化 2026-06-10 20:25:21 +08:00
ryan 3ebead0d15 界面优化 2026-06-10 20:22:10 +08:00
ryan e6a6182a0c 优化任务管理 2026-06-10 20:15:12 +08:00
ryan db163034c0 优化任务管理 2026-06-10 19:36:35 +08:00
ryan 69edb1252b 升级 nextjs 2026-06-10 15:18:01 +08:00
ryan 15b3c83625 更新示例 2026-06-10 15:11:42 +08:00
ryan 57944398e6 质量优化 2026-06-10 13:50:58 +08:00
ryan 3ed4dec4a3 db manager 2026-06-10 13:42:42 +08:00
ryan d05acd804f 精简 2026-06-10 11:33:33 +08:00
ryan df1961c42d 压缩 2026-06-09 22:00:01 +08:00
ryan 1b5d37a8e8 修复CI 2026-06-09 21:54:34 +08:00
ryan ebf644adb7 修复单位问题 2026-06-09 21:54:03 +08:00
ryan 7aeaa7b0d2 调整CI 2026-06-09 21:50:13 +08:00
ryan 32e4a32462 make build 2026-06-09 21:45:57 +08:00
ryan 05ef5ed7bd 数据导出 2026-06-09 21:18:58 +08:00
ryan 0ead6348f6 make bin 2026-06-09 21:01:13 +08:00
ryan 949c021d45 质量优化 2026-06-09 20:39:22 +08:00
ryan 1c76c7158a 质量优化 2026-06-09 20:38:14 +08:00
ryan 673061265c 质量优化 2026-06-09 20:28:05 +08:00
ryan 50f39a6983 修复创建账号逻辑 2026-06-09 20:07:25 +08:00
ryan 055005688a 升级数据库后更新缓存 2026-06-09 16:47:49 +08:00
ryan b1c161b255 skill 2026-06-09 16:47:49 +08:00
ryan 6b3c0217f0 goose 迁移 2026-06-09 16:47:49 +08:00
ryan 40e8a7cfa3 重构获取公共参数 2026-06-09 16:47:49 +08:00
ryan bff09241d3 skill 2026-06-09 16:47:49 +08:00
ryan 73b220de3c 用户详情 2026-06-09 16:47:49 +08:00
ryan 92664273ed async task skill 2026-06-09 16:47:49 +08:00
ryan d705f2ff64 skill 2026-06-09 16:47:49 +08:00
ryan 8801b3976c go dev skill
go dev skill

go dev skill

shadcn skill
2026-06-09 16:47:49 +08:00
ryan c6eea8111d fix(revive): rename unused parameters to _ for lint compliance 2026-06-09 15:03:13 +08:00
ryan e06f76436e refactor: extract magic numbers to named constants for mnd lint compliance 2026-06-09 13:44:29 +08:00
ryan b05d26c9c6 docs: add package and exported symbol comments for revive lint compliance 2026-06-09 13:42:06 +08:00
ryan 4ac9857fe8 代码质量优化 2026-06-09 12:28:12 +08:00
ryan f428839602 前端优化 2026-06-09 11:35:31 +08:00
ryan 31f0fb4ceb 规约 2026-06-09 11:26:57 +08:00
ryan e100441e8a 规约 2026-06-09 11:16:02 +08:00
ryan d7521dc49a 界面优化 2026-06-09 10:40:43 +08:00
ryan d4d214d074 界面优化 2026-06-09 10:39:59 +08:00
ryan 61bc569bd8 clickhouse 日志采集 2026-06-09 10:39:59 +08:00
ryan b41457553d 优化 2026-06-09 09:19:56 +08:00
ryan 7a62a78fad smtp 发件前验证配置 2026-06-09 09:01:14 +08:00
ryan 2ae55da52e seo 检索开关 2026-06-09 08:56:25 +08:00
ryan b0023787c5 fix 2026-06-09 08:51:53 +08:00
ryan 7579d3865f eslint 2026-06-09 08:19:38 +08:00
ryan d14d222ede env load 2026-06-08 23:14:57 +08:00
ryan ddda7c44ef docker-compose.yml 2026-06-08 23:14:57 +08:00
ryan 957116d983 修改ci 2026-06-08 21:13:18 +08:00
ryan 356b3df81e 融合模式启动 2026-06-08 21:12:55 +08:00
ryan db0d503bb4 ci 2026-06-08 20:59:00 +08:00
ryan 2cce8a3175 ci 2026-06-08 20:55:18 +08:00
ryan a998f02f2b ci 2026-06-08 20:47:48 +08:00
ryan 31253eb23d remove idea 2026-06-08 20:41:35 +08:00
ryan cd3d0c9f82 重构 2026-06-08 20:38:17 +08:00
ryan 02f458856d 用户优化 2026-06-08 20:38:17 +08:00
ryan d99bd5231a 安全加固 2026-06-08 20:38:17 +08:00
ryan 0f65202659 个人信息页面增强 2026-06-08 20:38:17 +08:00
ryan b96624a251 模板系统 2026-06-08 20:38:17 +08:00
ryan 3aff95d256 优化 2026-06-08 20:38:17 +08:00
ryan 85f91b1ed7 更新 license .github 2026-06-08 20:38:17 +08:00
ryan e3ef6c9d27 修改路径 2026-06-08 20:38:17 +08:00
ryan c1fbf73f7e 更新项目 2026-06-08 20:38:01 +08:00
ryan c6cc8c3305 logs 2026-06-08 20:38:01 +08:00
ryan 5c7c995a95 框架化 2026-06-08 20:38:01 +08:00
ryan b03d2d7ea6 改名 2026-06-08 20:38:01 +08:00
ryan f136c9abdb 邮箱 2026-06-08 20:38:01 +08:00
ryan 72c74803be 优化 2026-06-08 20:38:01 +08:00
ryan db68a130ce 嵌入与邮箱 2026-06-08 20:37:57 +08:00
ryan 62bd5d09d4 移除 risk 2026-06-08 20:37:57 +08:00
ryan 47c9bc53fa skill 2026-06-08 20:37:52 +08:00
ryan 474e3da3b7 代码优化
界面优化
2026-06-08 20:37:52 +08:00
ryan 72d72810b2 cap 与系统信息 2026-06-08 20:37:47 +08:00
ryan 9d0f9f0576 user 2026-06-08 20:37:40 +08:00
ryan 4b72419a96 去除默认OIDC 2026-06-08 20:37:40 +08:00
ryan 53a4c92176 设置界面优化 2026-06-08 20:37:40 +08:00
ryan 589ae08318 async task framework 2026-06-08 20:37:40 +08:00
ryan 70a13dc107 upload+accessKey 2026-06-08 20:37:40 +08:00
ryan 360a26f109 oauth 2026-06-08 20:34:28 +08:00
ryan 48d414e197 裁剪
swagger

移除 merchant

swagger

改造首页内容为通用后台管理系统定位

- 修改首页标题从 'LINUX DO Credit' 改为 'Modern Platform'
- 更新副标题为 '为二次开发而生'
- 更新首页描述为通用平台的特点
- 更新首页特性标签为 '开箱即用、高度可扩展、工业级基建'
- 修改展示卡片为技术栈和二次开发相关
- 更新开发者示例代码为通用的注册和 API Key 获取示例
- 更新页脚品牌名为 'Modern Platform'
- 调整页脚导航链接为通用平台相关内容

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

去除遗留

裁剪
移除 /api/v1/user/pay-key 相关代码

- 删除后端 UpdatePayKey 处理器函数和 UpdatePayKeyRequest 结构体
- 删除 User 模型中的 PayKey 字段
- 删除 User.VerifyPayKey 方法
- 删除 EncryptPayKeyFailed 错误常量
- 删除 /api/v1/user/pay-key PUT 路由
- 删除 OAuth 返回中的 IsPayKey 字段
- 删除前端 UserService.updatePayKey 方法
- 删除前端所有支付密钥 UI 和逻辑
- 更新相关的导出和注释

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

去除遗留

api 修正

系统配置

前端裁剪

后端裁剪

init
2026-06-08 20:34:28 +08:00
ryan 8a782525de 压缩历史至 95081aff 2026-06-08 20:34:27 +08:00
1277 changed files with 25206 additions and 218046 deletions
+22 -28
View File
@@ -7,7 +7,7 @@ description: "Wavelet 项目专用:当新增或修改 ClickHouse 批量写入
开始前阅读根目录 `AGENTS.md`。ClickHouse 是辅助 OLAP 存储,**厌恶高频单条写入**(过多小 part);写入路径必须优先批量或异步聚合。
DDL 与表结构变更见 `database-migration` 技能;本技能只覆盖**运行时写入架构**。
DDL 与表结构变更见 `database-migration` 技能。日志/分析用途表的判定、三库回落与切换见 `logstore` 技能。本技能只覆盖**运行时写入架构**。
## 分层职责
@@ -17,7 +17,7 @@ DDL 与表结构变更见 `database-migration` 技能;本技能只覆盖**运
| 批量框架 | `internal/infra/persistence/batchwriter/` | 泛型队列 + 按条数/时间 flush + 非阻塞入队 + 优雅停机;**各业务域独立实例** |
| Model | `internal/model/analytics/` | 列定义、`TableName()`、`BatchInsertSQL()`(及可选 `InsertColumns()`) |
| Repository | `internal/repository/analytics/` | `BatchInsert*` / `BatchInsertNodeAccessLogs` 等;`PrepareBatch` + 多行 `Append` + 一次 `Send` |
| Apps | `internal/apps/<domain>/` | 采集、入队、背压;`FlushFunc` 只调 repository,不写 SQL、不 `PrepareBatch` |
| Apps | `internal/apps/<domain>/` | 采集、入队、背压;`FlushFunc` 只调 logstore / repository,不写 SQL、不 `PrepareBatch` |
| 装配 | `internal/platform/bootstrap/bootstrap.go` | 进程启动时调用 `Writer.Start`;初始化时需调用 `lifecycle.OnShutdown` 挂载停机钩子 |
| 生命周期 | `internal/platform/lifecycle/lifecycle.go` | 统一协调全局并发优雅停机,业务包无需在 `bootstrap.go` 中硬编码 `Stop` 逻辑 |
@@ -37,6 +37,7 @@ writer.Stop(stopCtx) // close 队列 + drain + 最终 flush
- `QueueSize`: 10_000
- `MaxBatchSize`: 1_000
- `MinBatchSize`: 50(未达阈值则跳过按时间 flush,除非设了 `MaxFlushWait`)
- `FlushInterval`: 1s
各域可独立覆盖;可观测低频指标可用更小 `MaxBatchSize`(如 100)与更长 `FlushInterval`(如 2–5s),但**不要**退化为逐条 `Send`。
@@ -49,7 +50,8 @@ writer.Stop(stopCtx) // close 队列 + drain + 最终 flush
### FlushFunc 规范
- 签名:`func(ctx context.Context, items []T) error`
- 内部调用 `internal/repository/analytics` 的 `BatchInsert*`(传入 `[]analyticsmodel.X`)
- **日志/分析用途表**:`logstore.Active(ctx)` 再调对应 `BatchInsert*`。禁止 apps 直连 `analyticsrepo` 或 `db.ChConn`。
- 仅 CH、无需主库回落的分析表:才直接调 `repository/analytics` 的 `BatchInsert*`。
- 在 flush 边界记录一次错误日志,不要把 DB 驱动错误直接暴露给 HTTP 客户端
- `Start` 使用 `context.WithoutCancel(parent)`,避免请求 ctx 取消中断后台 flush
@@ -57,28 +59,27 @@ writer.Stop(stopCtx) // close 队列 + drain + 最终 flush
每个业务域拥有自己的 `Writer`、配置与 `FlushFunc`:
| 域 | 表 | 现状 | 目标形态 |
| :--- | :--- | :--- | :--- |
| 管理端审计 | `w_user_access_logs` | `risk_control` → `batchwriter` + `analyticsrepo.BatchInsert` | 已接入 |
| 边缘访问日志 | `of_node_access_logs` | `openflare/chwriter` 异步 flush | 已接入 |
| 可观测时序 | `of_node_metric_snapshots` 等 5 表 | `openflare/chwriter` 五表独立 writer + 进程内短 TTL 去重 | 已接入 |
| 域 | 表 | 写入路径 |
| :--- | :--- | :--- |
| 管理端审计 | `w_user_access_logs` | `risk_control` → `batchwriter` → `logstore.Active` |
**不要**把 audit、access log、observability 并入同一 channel。
**不要**把不同日志域并入同一 channel。新日志表先按 `logstore` skill 判定,再为本域建独立 writer。
## 新增 ClickHouse 写入工作流
1. **Model**:在 `internal/model/analytics/` 定义 struct 与 `BatchInsertSQL()`(列顺序与 goose DDL 一致)。
2. **Goose DDL**:在 `internal/infra/persistence/migrator/goose/clickhouse/` 新增迁移(见 `database-migration`)。
3. **Repository**:实现 `BatchInsertX(ctx, []analyticsmodel.X) error`:
- `len(items)==0` 直接返回
- `db.ChConn == nil` 返回明确错误
- 一次 `PrepareBatch` → 循环 `Append` → 一次 `Send`
4. **Writer 胶水**(`internal/apps/<domain>/` 或 `internal/repository/analytics/<domain>_writer.go`):
- `len(items)==0` 直接返回
- `db.ChConn == nil` 返回明确错误
- 一次 `PrepareBatch` → 循环 `Append` → 一次 `Send`
4. **Writer 胶水**(`internal/apps/<domain>/`):
- `New` + `Start`,并在初始化逻辑内通过 `lifecycle.OnShutdown("your_writer_name", Stop)` 注册停机回调
- 日志表的 `FlushFunc` 调 `logstore.Active`(见 `logstore` skill)
- 业务路径 `TryEnqueue`;HTTP 背压用 `IsFull()`
5. **测试**:
- repository:mock `ChConn` 验证 `BatchInsertSQL` 与 append 列数
- batchwriter:`go test ./internal/infra/persistence/batchwriter`
- repository:mock `ChConn` 验证 `BatchInsertSQL` 与 append 列数
- batchwriter:`go test ./internal/infra/persistence/batchwriter`
6. 运行 `make code-check`;有 API 变更时 `make swagger`。
## 背压与丢弃策略
@@ -86,8 +87,7 @@ writer.Stop(stopCtx) // close 队列 + drain + 最终 flush
| 场景 | 推荐策略 |
| :--- | :--- |
| 管理端 API 审计 | 队列满 → `IsFull()` 触发 429(见 `risk_control` middleware) |
| Agent 心跳指标 | 队列满 → `WithDropHandler` 记 warn;不阻塞心跳响应 |
| 边缘 access log | 优先扩大队列与 batch;必要时丢弃最旧或采样 |
| 可丢弃的高频日志 | 队列满 → `WithDropHandler` 记 warn;不阻塞请求 |
## 禁止写法
@@ -123,14 +123,9 @@ var globalChan chan any
```go
// internal/platform/bootstrap/bootstrap.go(示意)
var userAccessLogWriter *batchwriter.Writer[*analytics.UserAccessLog]
func RegisterAPI(ctx context.Context) {
// ...
if config.Config.ClickHouse.Enabled {
initUserAccessLogWriter(ctx) // Start writer
risk_control.BindWriter(userAccessLogWriter) // 或逐步替换 InitLogWriter
}
// 日志 writer 不依赖 clickhouse.enabled:flush 时由 logstore 选库
risk_control.InitLogWriter(ctx)
}
```
@@ -149,15 +144,14 @@ make code-check
- flush 按 `MaxBatchSize` 与 `FlushInterval` 触发
- `Stop` 能 drain 队列内剩余项
- repository 层无 goroutine、无 channel
- `clickhouse.enabled: false` 时不 `Start` writer、不入队
- 日志表:`clickhouse.enabled: false` 时 writer 仍 `Start`,flush 走主库 logstore
- 仅 CH 的分析表:未启用 CH 时不要 `Start`、不要入队
## 相关文件速查
- 框架:`internal/infra/persistence/batchwriter/{config,writer,errs}.go`
- 连接:`internal/infra/persistence/clickhouse.go`
- 审计写入:`internal/apps/risk_control/logics.go`
- OpenFlare 写入胶水:`internal/apps/openflare/chwriter/writer.go`
- 节点访问日志 repository:`internal/repository/analytics/node_access_log_writer.go`
- 可观测 repository:`internal/repository/analytics/node_observability_writer.go`
- 日志抽象:`internal/repository/logstore`
- 生命周期管理器:`internal/platform/lifecycle/lifecycle.go`
- Bootstrap:`internal/platform/bootstrap/bootstrap.go`
+3 -3
View File
@@ -81,7 +81,7 @@ make code-check
ClickHouse 是**辅助 OLAP 存储**,与 PostgreSQL/SQLite 主库**完全独立**的迁移与访问管线:
- 主库(PG/SQLite):业务事务数据、`goose_db_version`、双方言 SQL。
- 分析库(ClickHouse):访问日志、统计聚合等分析型数据、`goose_clickhouse_version`、单方言 SQL。
- 分析库(ClickHouse):分析型数据、`goose_clickhouse_version`、单方言 SQL。日志用途表还必须在主库建回落并走 `logstore`(见该 skill);CH 目录仍只放 CH DDL。
**不要**把 ClickHouse 表结构混入 PG/SQLite 迁移目录,也**不要**在 `support-files/`、`internal/apps/` 或 `internal/repository/` 中手写 DDL。
@@ -92,7 +92,7 @@ ClickHouse 是**辅助 OLAP 存储**,与 PostgreSQL/SQLite 主库**完全独
| `internal/infra/persistence/migrator/goose/clickhouse/` | **唯一** ClickHouse DDL 来源(goose SQL,嵌入二进制) |
| `internal/model/analytics/` | 分析表 Go model,列名须与 goose DDL 一致 |
| `internal/repository/analytics/` | 所有 ClickHouse 读写(批量写入、查询、聚合) |
| `internal/infra/persistence/clickhouse.go` | 连接初始化(`ChConn` 原生批量、`ChDB` GORM 查询) |
| `internal/infra/persistence/clickhouse.go` | 连接初始化(`ChConn` 原生批量、`chDB` GORM 查询) |
### 迁移入口与版本表
@@ -118,7 +118,7 @@ ClickHouse 是**辅助 OLAP 存储**,与 PostgreSQL/SQLite 主库**完全独
1. **Model**:在 `internal/model/analytics/` 定义 struct,`gorm:"column:..."` 与 DDL 列名一一对应;实现 `TableName()`,批量写入表可提供 `InsertColumns()` / `BatchInsertSQL()`。
2. **Goose SQL**:在 `internal/infra/persistence/migrator/goose/clickhouse/` 新增递增版本文件(格式同主库,如 `YYYYMMDDNNNN_create_xxx.sql`),编写 `-- +goose Up` / `-- +goose Down`。
3. **Repository**:在 `internal/repository/analytics/` 实现 `BatchInsert*`(`db.ChConn` 一次 `PrepareBatch` + 多行 `Append` + 一次 `Send`)与查询(`db.ChDB`);连接未初始化时返回明确错误,**不要**在 handler 写 SQL,**不要**在 repository 内维护 channel/goroutine。
4. **Apps**:在 `internal/apps/<domain>/` 编排采集与入队;高频写入通过 `internal/infra/persistence/batchwriter` 各域独立实例异步 flush(详见 `clickhouse-batchwriter` 技能),`FlushFunc` 只调 repository `BatchInsert*`;管理端统计 API 只读 repository,不触达 DDL。
4. **Apps**:在 `internal/apps/<domain>/` 编排采集与入队;高频写入通过 `internal/infra/persistence/batchwriter` 各域独立实例异步 flush(详见 `clickhouse-batchwriter` 技能)。**日志/分析用途表**还要同时建 PG/SQLite 回落并接入 `logstore`(见 `logstore` 技能),`FlushFunc` 调 `logstore.Active` 而不是 `analyticsrepo`;普通业务分析表仍只读 repository。
### ClickHouse 验证
+167
View File
@@ -0,0 +1,167 @@
---
name: go-documentation
description: 在编写或审查 Go 包、类型、函数或方法的文档时使用。在创建新的导出类型、函数或包时也应主动使用,即使用户没有明确询问文档问题。不涵盖未导出符号的代码注释(参见 go-style-core)。
license: Apache-2.0
metadata:
sources: "Google 风格指南"
allowed-tools: Bash(bash:*)
---
# Go 文档
## 可用脚本
- **`scripts/check-docs.sh`** — 报告缺少文档注释的导出函数、类型、方法、常量和包。运行 `bash scripts/check-docs.sh --help` 查看选项。
> 在为新包或导出类型编写文档注释并需要所有文档约定的完整参考时,请参阅 `assets/doc-template.go`。
---
## 文档注释
> **规范**:所有顶层导出名称必须有文档注释。
### 基本规则
1. 以被描述对象的名称开头
2. 冠词("a"、"an"、"the")可以放在名称前面
3. 使用完整句子(首字母大写,带标点符号)
```go
// A Request represents a request to run a command.
type Request struct { ...
// Encode writes the JSON encoding of req to w.
func Encode(w io.Writer, req *Request) { ...
```
行为不明显的未导出类型/函数也应有文档注释。
> **验证**:添加文档注释后,运行 `bash scripts/check-docs.sh` 验证是否有导出符号缺少文档。修复所有缺失后再继续。
---
## 注释语句
> **规范**:文档注释必须是完整的句子。
- 首字母大写,以标点符号结尾
- 例外:如果含义清晰,可以以小写标识符开头
- 结构体字段的行尾注释可以是短语
---
## 注释行长度
> **建议**:目标约 80 列,但不设硬性限制。
根据标点符号换行。不要拆分长 URL。
---
## 结构体文档
使用段落注释对字段分组。标记可选字段及默认值:
```go
type Options struct {
// 通用设置:
Name string
Group *FooGroup
// 自定义设置:
LargeGroupThreshold int // 可选;默认值:10
}
```
---
## 包注释
> **规范**:每个包必须有且仅有一个包注释。
```go
// Package math provides basic constants and mathematical functions.
package math
```
- 对于 `main` 包,使用二进制名称:`// The seed_generator command ...`
- 对于较长的包注释,使用 `doc.go` 文件
> 在编写包级文档、main 包注释、doc.go 文件或可运行示例时,请阅读 [references/EXAMPLES.md](references/EXAMPLES.md)。
---
## 文档编写要点
> **建议**:记录非显而易见的行为,显而易见的行为无需记录。
| 主题 | 何时记录... | 何时跳过... |
|------|------------|------------|
| 参数 | 非显而易见的行为、边界情况 | 只是重复类型签名 |
| 上下文 | 行为与标准取消不同 | 标准 `ctx.Err()` 返回 |
| 并发 | 线程安全性不明确(例如,看似读取但内部修改) | 只读安全、修改不安全 |
| 清理 | 始终记录资源释放要求 | — |
| 错误 | 哨兵值、错误类型(使用 `*PathError`) | — |
| 命名返回值 | 多个同类型参数、面向操作命名 | 类型本身已足够清晰 |
关键原则:
- 上下文取消返回 `ctx.Err()` 是隐含的 — 不要重复说明
- 只读操作默认线程安全;修改操作默认不安全 — 不要重复说明
- 始终记录清理要求(例如,`Call Stop to release resources`)
- 在错误类型文档中使用指针(`*PathError`),以确保 `errors.Is`/`errors.As` 正确使用
- 不要仅为启用裸返回而命名返回值 — 清晰性 > 简洁性
> 在记录参数行为、上下文取消、并发安全性、清理要求、错误返回或函数文档注释中的命名返回参数时,请阅读 [references/CONVENTIONS.md](references/CONVENTIONS.md)。
---
## 可运行示例
> **建议**:在测试文件(`*_test.go`)中提供可运行示例。
```go
func ExampleConfig_WriteTo() {
cfg := &Config{Name: "example"}
cfg.WriteTo(os.Stdout)
// Output:
// {"name": "example"}
}
```
示例会出现在 Godoc 中,附加到对应的文档元素上。
> 在编写可运行 Example 函数、选择示例命名约定(Example vs ExampleType_Method)或添加包级 doc.go 文件时,请阅读 [references/EXAMPLES.md](references/EXAMPLES.md)。
---
## Godoc 格式化
> 在格式化 godoc 标题、链接、列表或代码块,使用信号增强来标记弃用通知,或在本地预览文档输出时,请阅读 [references/FORMATTING.md](references/FORMATTING.md)。
---
## 快速参考
| 主题 | 关键规则 |
|------|---------|
| 文档注释 | 以名称开头,使用完整句子 |
| 行长度 | 约 80 字符,优先考虑可读性 |
| 包注释 | 每个包一个,放在 `package` 声明之前 |
| 参数 | 仅记录非显而易见的行为 |
| 上下文 | 记录与隐含行为不同的例外情况 |
| 并发 | 记录线程安全性不明确的情况 |
| 清理 | 始终记录资源释放要求 |
| 错误 | 记录哨兵值和类型(注意指针) |
| 示例 | 在测试文件中使用可运行示例 |
| 格式化 | 空行分隔段落,缩进表示代码 |
---
## 相关技能
- **命名约定**:在为文档注释描述的标识符选择名称时,参见 [go-naming](../go-naming/SKILL.md)
- **测试示例**:在编写出现在 godoc 中的可运行 `Example` 测试函数时,参见 [go-testing](../go-testing/SKILL.md)
- **Lint 强制执行**:在使用 revive 或其他 linter 强制执行文档注释存在性时,参见 [go-linting](../go-linting/SKILL.md)
- **风格原则**:在平衡文档详细程度与清晰简洁时,参见 [go-style-core](../go-style-core/SKILL.md)
@@ -0,0 +1,61 @@
// Package example demonstrates proper Go documentation conventions.
//
// This package shows how to write doc comments for packages, types,
// functions, methods, and constants following Google Go Style Guide
// conventions.
//
// # Getting Started
//
// Create a new Widget with [NewWidget]:
//
// w := example.NewWidget("name")
// defer w.Close()
package example
import "errors"
// ErrNotFound is returned when a requested item does not exist.
var ErrNotFound = errors.New("example: not found")
// MaxRetries is the default number of retry attempts.
const MaxRetries = 3
// Widget processes items with configurable options.
//
// A zero-value Widget is not valid; use [NewWidget] to create one.
// Widget is safe for concurrent use.
//
// # Cleanup
//
// Call [Widget.Close] when done to release resources.
type Widget struct {
name string
}
// NewWidget creates a Widget with the given name.
//
// Name must be non-empty; NewWidget panics otherwise.
func NewWidget(name string) *Widget {
if name == "" {
panic("example: name must be non-empty")
}
return &Widget{name: name}
}
// Process handles the given input and returns the result.
//
// Process returns [ErrNotFound] if the input references
// a missing item.
func (w *Widget) Process(input string) (string, error) {
return input, nil
}
// Close releases resources held by the Widget.
func (w *Widget) Close() error {
return nil
}
// Deprecated: Use [NewWidget] with functional options instead.
func NewWidgetLegacy(name string) *Widget {
return NewWidget(name)
}
@@ -0,0 +1,239 @@
# 文档约定参考
## 参数和配置
> **建议**:记录容易出错或非显而易见的参数,而非所有参数。
```go
// 不好:重复了显而易见的信息
// Sprintf formats according to a format specifier and returns the resulting string.
//
// format is the format, and data is the interpolation data.
func Sprintf(format string, data ...any) string
// 好:记录了非显而易见的行为
// Sprintf formats according to a format specifier and returns the resulting string.
//
// The provided data is used to interpolate the format string. If the data does
// not match the expected format verbs or the amount of data does not satisfy
// the format specification, the function will inline warnings about formatting
// errors into the output string.
func Sprintf(format string, data ...any) string
```
---
## 上下文
> **建议**:不要重复隐含的上下文行为;记录例外情况。
上下文取消被隐含地认为会中断函数并返回 `ctx.Err()`。不要记录这一点。
```go
// 不好:重复了隐含的行为
// Run executes the worker's run loop.
//
// The method will process work until the context is cancelled.
func (Worker) Run(ctx context.Context) error
// 好:只记录关键信息
// Run executes the worker's run loop.
func (Worker) Run(ctx context.Context) error
```
**当行为不同时记录:**
```go
// 好:非标准的取消行为
// Run executes the worker's run loop.
//
// If the context is cancelled, Run returns a nil error.
func (Worker) Run(ctx context.Context) error
// 好:特殊的上下文要求
// NewReceiver starts receiving messages sent to the specified queue.
// The context should not have a deadline.
func NewReceiver(ctx context.Context) *Receiver
```
---
## 并发
> **建议**:记录非显而易见的线程安全特性。
只读操作被认为是安全的;修改操作被认为是不安全的。不要重复说明这一点。
**何时记录:**
```go
// 不明确的操作(看似只读但内部有修改)
// Lookup returns the data associated with the key from the cache.
//
// This operation is not safe for concurrent use.
func (*Cache) Lookup(key string) (data []byte, ok bool)
// API 提供同步机制
// NewFortuneTellerClient returns an *rpc.Client for the FortuneTeller service.
// It is safe for simultaneous use by multiple goroutines.
func NewFortuneTellerClient(cc *rpc.ClientConn) *FortuneTellerClient
// 接口有并发要求
// A Watcher reports the health of some entity (usually a backend service).
//
// Watcher methods are safe for simultaneous use by multiple goroutines.
type Watcher interface {
Watch(changed chan<- bool) (unwatch func())
Health() error
}
```
---
## 清理
> **建议**:始终记录显式清理要求。
```go
// 好:
// NewTicker returns a new Ticker containing a channel that will send the
// current time on the channel after each tick.
//
// Call Stop to release the Ticker's associated resources when done.
func NewTicker(d Duration) *Ticker
// 好:展示如何清理
// Get issues a GET to the specified URL.
//
// When err is nil, resp always contains a non-nil resp.Body.
// Caller should close resp.Body when done reading from it.
//
// resp, err := http.Get("http://example.com/")
// if err != nil {
// // handle error
// }
// defer resp.Body.Close()
// body, err := io.ReadAll(resp.Body)
func (c *Client) Get(url string) (resp *Response, err error)
```
---
## 错误
> **建议**:记录重要的错误哨兵值和类型。
```go
// 好:记录哨兵值
// Read reads up to len(b) bytes from the File and stores them in b.
//
// At end of file, Read returns 0, io.EOF.
func (*File) Read(b []byte) (n int, err error)
// 好:记录错误类型(包含指针接收者)
// Chdir changes the current working directory to the named directory.
//
// If there is an error, it will be of type *PathError.
func Chdir(dir string) error
```
注意使用 `*PathError`(而非 `PathError`)可以确保 `errors.Is` 和 `errors.As` 的正确使用。
对于包级别的错误约定,在包注释中记录。
---
## 命名返回参数
> **建议**:在类型本身不够清晰时用于文档说明。
```go
// 好:多个同类型参数
func (n *Node) Children() (left, right *Node, err error)
// 好:面向操作的名称阐明了用法
// The caller must arrange for the returned cancel function to be called.
func WithTimeout(parent Context, d time.Duration) (ctx Context, cancel func())
// 不好:类型已经很清晰,命名没有增加信息
func (n *Node) Parent1() (node *Node)
func (n *Node) Parent2() (node *Node, err error)
// 好:类型已足够
func (n *Node) Parent1() *Node
func (n *Node) Parent2() (*Node, error)
```
不要仅为启用裸返回而命名返回值。清晰性 > 简洁性。
---
## 弃用通知
> **建议**:使用 `// Deprecated:` 注释标记符号为已弃用。
`Deprecated:` 段落必须出现在文档注释中紧接在符号之前。应说明使用什么替代。
**标准格式:**
```
// Deprecated: Use NewThing instead.
```
Godoc 会以特殊的视觉样式渲染 `Deprecated:` 注释,使其容易被发现。
**函数弃用:**
```go
// EstimateSize returns an approximate byte count.
//
// Deprecated: Use [Size] instead, which returns an exact count.
func EstimateSize(r io.Reader) (int64, error)
```
**类型弃用:**
```go
// LegacyClient talks to the v1 API.
//
// Deprecated: Use [Client] instead, which supports v2.
type LegacyClient struct{ /* ... */ }
```
**包弃用** — 在包文档注释中添加 `Deprecated:`:
```go
// Package old provides the original implementation.
//
// Deprecated: Use package example/new instead.
package old
```
始终建议具体的替代方案,让调用者知道迁移目标。
---
## 注释语句 — 详细说明
> **规范**:文档注释必须是完整的句子。
- 首字母大写,以标点符号结尾
- 例外:如果含义清晰,可以以小写标识符开头
- 结构体字段的行尾注释可以是短语:
```go
// 好:
// A Server handles serving quotes from Shakespeare.
type Server struct {
// BaseDir points to the base directory for Shakespeare's works.
//
// Expected structure:
// {BaseDir}/manifest.json
// {BaseDir}/{name}/{name}-part{number}.txt
BaseDir string
WelcomeMessage string // 用户登录时显示
ProtocolVersion string // 与传入请求进行校验
PageLength int // 每页行数(可选;默认值:20)
}
```
@@ -0,0 +1,107 @@
# 包注释和示例参考
## 包注释
> **规范**:每个包必须有且仅有一个包注释。
```go
// 好:
// Package math provides basic constants and mathematical functions.
//
// This package does not guarantee bit-identical results across architectures.
package math
```
### Main 包
使用二进制名称(与 BUILD 文件匹配):
```go
// 好:
// The seed_generator command is a utility that generates a Finch seed file
// from a set of JSON study configs.
package main
```
有效格式:`Binary seed_generator`、`Command seed_generator`、`The seed_generator command`、`Seed_generator ...`
### doc.go
- 对于较长的包注释,使用仅包含包注释和 `package` 声明的 `doc.go` 文件
- 放在 import 之后的维护者注释不会出现在 Godoc 中
- 保持 doc.go 文件专注于面向用户的文档
```go
// Package complex provides advanced mathematical operations for
// complex number arithmetic, including polar form conversion,
// matrix operations, and numerical integration.
//
// Basic usage
//
// Create a complex number and perform operations:
//
// z := complex.New(3, 4)
// magnitude := z.Abs() // 5.0
// conjugate := z.Conj() // (3, -4)
//
// Matrix operations
//
// The package supports complex-valued matrices:
//
// m := complex.NewMatrix(2, 2)
// m.Set(0, 0, complex.New(1, 0))
// det := m.Det()
package complex
```
---
## 可运行示例
> **建议**:提供可运行示例来展示包的用法。
将示例放在测试文件(`*_test.go`)中:
```go
// 好:
func ExampleConfig_WriteTo() {
cfg := &Config{
Name: "example",
}
if err := cfg.WriteTo(os.Stdout); err != nil {
log.Exitf("Failed to write config: %s", err)
}
// Output:
// {
// "name": "example"
// }
}
```
示例会出现在 Godoc 中,附加到对应的文档元素上。
### 命名约定
| 函数名称 | 文档对象 |
|----------|---------|
| `Example()` | 包级别示例 |
| `ExampleFoo()` | 函数 `Foo` |
| `ExampleBar_Baz()` | 方法 `Bar.Baz` |
| `ExampleFoo_suffix()` | `Foo` 示例的命名变体 |
### 技巧
- 使用 `// Output:` 注释使示例可通过 `go test` 进行测试和验证
- 保持示例专注于展示一个概念
- 使用真实但精简的数据
- 对于复杂的设置,使用 `testMain` 或辅助函数保持示例主体简洁
- 同一符号的多个示例使用小写 `_suffix`:
```go
func ExampleNewClient_withTimeout() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
client := NewClient(ctx)
// ...
}
```
@@ -0,0 +1,85 @@
# Godoc 格式化参考
## Godoc 格式化
> **建议**:使用 godoc 语法编写格式良好的文档。
**段落** - 用空行分隔:
```go
// 好:
// LoadConfig reads a configuration out of the named file.
//
// See some/shortlink for config file format details.
```
**逐字/代码块** - 额外缩进两个空格:
```go
// 好:
// Update runs the function in an atomic transaction.
//
// This is typically used with an anonymous TransactionFunc:
//
// if err := db.Update(func(state *State) { state.Foo = bar }); err != nil {
// //...
// }
```
**列表和表格** - 使用逐字格式:
```go
// 好:
// LoadConfig treats the following keys in special ways:
// "import" will make this configuration inherit from the named file.
// "env" if present will be populated with the system environment.
```
**标题** - 单行,首字母大写,无标点(括号/逗号除外),后跟段落:
```go
// 好:
// Using headings
//
// Headings come with autogenerated anchor tags for easy linking.
```
---
## 信号增强
> **建议**:添加注释以突出不寻常或容易被忽略的模式。
以下两种情况很难区分:
```go
if err := doSomething(); err != nil { // 常见
// ...
}
if err := doSomething(); err == nil { // 不寻常!
// ...
}
```
添加注释来增强信号:
```go
// 好:
if err := doSomething(); err == nil { // 如果没有错误
// ...
}
```
---
## 文档预览
> **建议**:在代码审查之前和期间预览文档。
```bash
go install golang.org/x/pkgsite/cmd/pkgsite@latest
pkgsite
```
这可以验证 godoc 格式化是否正确渲染。
+298
View File
@@ -0,0 +1,298 @@
#!/usr/bin/env bash
set -euo pipefail
VERSION="1.0.0"
SCRIPT_NAME="$(basename "$0")"
usage() {
cat <<EOF
$SCRIPT_NAME v$VERSION — Check for missing doc comments on exported Go symbols
USAGE
bash $SCRIPT_NAME [options] [path]
DESCRIPTION
Scans Go source files for exported functions, types, methods, constants,
and variables that lack doc comments. Go convention requires all exported
symbols to have a doc comment starting with the symbol name.
Exits 0 if all exports are documented, 1 if undocumented exports found,
2 on error.
OPTIONS
-h, --help Show this help message
-v, --version Show version
--json Output results as JSON
--strict Also check unexported types/functions with 5+ lines
--limit N Show at most N results (default: all)
ARGUMENTS
path Directory or file to check (default: ./...)
EXAMPLES
bash $SCRIPT_NAME
bash $SCRIPT_NAME ./pkg/api
bash $SCRIPT_NAME --json .
bash $SCRIPT_NAME --strict ./internal/server
EOF
}
JSON_OUTPUT=false
STRICT=false
LIMIT=0
TARGET=""
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) usage; exit 0 ;;
-v|--version) echo "$SCRIPT_NAME v$VERSION"; exit 0 ;;
--json) JSON_OUTPUT=true; shift ;;
--strict) STRICT=true; shift ;;
--limit) LIMIT="${2:?error: --limit requires a number}"; shift 2 ;;
-*) echo "error: unknown option: $1" >&2; usage >&2; exit 2 ;;
*) TARGET="$1"; shift ;;
esac
done
TARGET="${TARGET:-./...}"
json_escape() {
local s="$1"
s="${s//\\/\\\\}"
s="${s//\"/\\\"}"
s="${s//$'\t'/\\t}"
s="${s//$'\r'/}"
s="${s//$'\n'/\\n}"
printf '%s' "$s"
}
find_go_files() {
local t="$1"
if [[ -f "$t" ]]; then
echo "$t"
elif [[ -d "$t" ]]; then
find "$t" -name '*.go' ! -name '*_test.go' ! -path '*/vendor/*' ! -path '*/.git/*' 2>/dev/null
else
local dir="${t%%/...}"
dir="${dir:-.}"
if [[ -d "$dir" ]]; then
find "$dir" -name '*.go' ! -name '*_test.go' ! -path '*/vendor/*' ! -path '*/.git/*' 2>/dev/null
else
echo "error: path not found: $t" >&2
exit 2
fi
fi
}
MISSING=()
add_missing() {
local file="$1" line="$2" kind="$3" name="$4"
MISSING+=("${file}:${line}|${kind}|${name}")
}
check_file() {
local file="$1"
local prev_line=""
local prev_prev_line=""
local line_num=0
local in_grouped_block=false
local grouped_kind=""
local re_method='^func[[:space:]]+\([^)]+\)[[:space:]]+([A-Z][a-zA-Z0-9]*)\('
local re_func='^func[[:space:]]+([A-Z][a-zA-Z0-9]*)\('
local re_unexported_func='^func[[:space:]]+([a-z][a-zA-Z0-9]*)\('
local re_grouped_open='^(const|var|type)[[:space:]]*\($'
local re_exported_type='^type[[:space:]]+([A-Z][a-zA-Z0-9]*)[[:space:]]'
local re_unexported_type='^type[[:space:]]+([a-z][a-zA-Z0-9]*)[[:space:]]'
local re_exported_const='^const[[:space:]]+([A-Z][a-zA-Z0-9]*)[[:space:]]'
local re_exported_var='^var[[:space:]]+([A-Z][a-zA-Z0-9]*)[[:space:]]'
local re_grouped_exported='^[[:space:]]+([A-Z][a-zA-Z0-9]*)'
local re_grouped_unexported='^[[:space:]]+([a-z][a-zA-Z0-9]*)'
while IFS= read -r line; do
line_num=$((line_num + 1))
# Check exported function/method declarations
if [[ "$line" =~ ^func[[:space:]] ]]; then
local name=""
local kind=""
# Method: func (r *Type) Name(
if [[ "$line" =~ $re_method ]]; then
name="${BASH_REMATCH[1]}"
kind="method"
# Function: func Name(
elif [[ "$line" =~ $re_func ]]; then
name="${BASH_REMATCH[1]}"
kind="function"
fi
if [[ -n "$name" ]]; then
if ! is_documented "$prev_line" "$prev_prev_line"; then
add_missing "$file" "$line_num" "$kind" "$name"
fi
fi
# Strict mode: also check unexported functions
if $STRICT && [[ -z "$name" ]] && [[ "$line" =~ $re_unexported_func ]]; then
name="${BASH_REMATCH[1]}"
if ! is_documented "$prev_line" "$prev_prev_line"; then
add_missing "$file" "$line_num" "function" "$name"
fi
fi
fi
# Check exported type declarations
if [[ "$line" =~ $re_exported_type ]]; then
local name="${BASH_REMATCH[1]}"
if ! is_documented "$prev_line" "$prev_prev_line"; then
add_missing "$file" "$line_num" "type" "$name"
fi
fi
# Strict mode: also check unexported type declarations
if $STRICT && [[ "$line" =~ $re_unexported_type ]]; then
local name="${BASH_REMATCH[1]}"
if ! is_documented "$prev_line" "$prev_prev_line"; then
add_missing "$file" "$line_num" "type" "$name"
fi
fi
# Check exported const (single-line, not in block)
if [[ "$line" =~ $re_exported_const ]]; then
local name="${BASH_REMATCH[1]}"
if ! is_documented "$prev_line" "$prev_prev_line"; then
add_missing "$file" "$line_num" "const" "$name"
fi
fi
# Check exported var (single-line, not blank identifier)
if [[ "$line" =~ $re_exported_var ]]; then
local name="${BASH_REMATCH[1]}"
if ! is_documented "$prev_line" "$prev_prev_line"; then
add_missing "$file" "$line_num" "var" "$name"
fi
fi
# Check package comment
if [[ "$line" =~ ^package[[:space:]]+ ]]; then
if ! is_documented "$prev_line" "$prev_prev_line"; then
local pkg_name
pkg_name=$(echo "$line" | sed 's/^package[[:space:]]*//;s/[[:space:]]*$//')
add_missing "$file" "$line_num" "package" "$pkg_name"
fi
fi
# Track grouped declaration blocks: const ( ... ), var ( ... ), type ( ... )
if [[ "$line" =~ $re_grouped_open ]]; then
in_grouped_block=true
grouped_kind="${BASH_REMATCH[1]}"
fi
if $in_grouped_block && [[ "$line" =~ ^\)[[:space:]]*$ ]]; then
in_grouped_block=false
grouped_kind=""
fi
if $in_grouped_block && [[ -n "$grouped_kind" ]]; then
# Check for exported names inside grouped block
if [[ "$line" =~ $re_grouped_exported ]]; then
local gname="${BASH_REMATCH[1]}"
if ! is_documented "$prev_line" "$prev_prev_line"; then
add_missing "$file" "$line_num" "$grouped_kind" "$gname"
fi
fi
# Strict: also check unexported names in grouped blocks
if $STRICT && [[ "$line" =~ $re_grouped_unexported ]]; then
local gname="${BASH_REMATCH[1]}"
if ! is_documented "$prev_line" "$prev_prev_line"; then
add_missing "$file" "$line_num" "$grouped_kind" "$gname"
fi
fi
fi
prev_prev_line="$prev_line"
prev_line="$line"
done < "$file"
}
is_documented() {
local prev="$1"
local prev_prev="$2"
# Previous line is a comment (// or end of block comment */)
if [[ "$prev" =~ ^[[:space:]]*//.* ]] || [[ "$prev" =~ \*/[[:space:]]*$ ]]; then
return 0
fi
# Previous line might be empty but line before is comment (allow one blank line)
if [[ -z "${prev// /}" ]] && [[ "$prev_prev" =~ ^[[:space:]]*//.* ]]; then
return 0
fi
return 1
}
FILES=()
while IFS= read -r f; do
[[ -n "$f" ]] && FILES+=("$f")
done < <(find_go_files "$TARGET")
if [[ ${#FILES[@]} -eq 0 ]]; then
if $JSON_OUTPUT; then
echo '{"missing":[],"count":0,"status":"no_go_files"}'
else
echo "No Go files found in: $TARGET"
fi
exit 0
fi
for file in "${FILES[@]}"; do
check_file "$file"
done
# Truncation
TOTAL=${#MISSING[@]}
TRUNCATED=false
if [[ $LIMIT -gt 0 && $TOTAL -gt $LIMIT ]]; then
MISSING=("${MISSING[@]:0:$LIMIT}")
TRUNCATED=true
fi
if $JSON_OUTPUT; then
echo "{"
echo ' "missing": ['
first=true
for entry in "${MISSING[@]+"${MISSING[@]}"}"; do
IFS='|' read -r location kind name <<< "$entry"
file="${location%%:*}"
line="${location#*:}"
$first || echo ","
first=false
printf ' {"file":"%s","line":%s,"kind":"%s","name":"%s"}' \
"$(json_escape "$file")" "$line" "$(json_escape "$kind")" "$(json_escape "$name")"
done
echo ""
echo " ],"
printf ' "total": %d,\n' "$TOTAL"
printf ' "truncated": %s\n' "$TRUNCATED"
echo "}"
else
if [[ $TOTAL -eq 0 ]]; then
echo "All exported symbols are documented."
exit 0
fi
echo "Undocumented exported symbols:"
echo ""
for entry in "${MISSING[@]}"; do
IFS='|' read -r location kind name <<< "$entry"
printf " %s [%s] %s\n" "$location" "$kind" "$name"
done
if $TRUNCATED; then
echo " ... and $((TOTAL - LIMIT)) more (use --limit to adjust)"
fi
echo ""
echo "Total: $TOTAL undocumented symbol(s)"
fi
if [[ $TOTAL -gt 0 ]]; then
exit 1
fi
exit 0
-138
View File
@@ -1,138 +0,0 @@
---
name: go-packages
description: Use when creating Go packages, organizing imports, managing dependencies, or deciding how to structure Go code into packages. Also use when starting a new Go project or splitting a growing codebase into packages, even if the user doesn't explicitly ask about package organization. Does not cover naming individual identifiers (see go-naming).
license: Apache-2.0
metadata:
sources: "Google Style Guide, Uber Style Guide, Go Wiki CodeReviewComments"
---
# Go 包和 Import
> **本技能不适用的场景**:对于包内单个标识符的命名,参见 [go-naming](../go-naming/SKILL.md)。对于单文件中函数的组织,参见 [go-functions](../go-functions/SKILL.md)。对于强制执行 import 规则的 linter 配置,参见 [go-linting](../go-linting/SKILL.md)。
## 包组织
### 避免 Util 包
包名应描述包提供的内容。避免使用 `util`、`helper`、`common` 等泛化名称——它们会模糊含义并导致 import 冲突。
```go
// 好:有意义的包名
db := spannertest.NewDatabaseFromFile(...)
_, err := f.Seek(0, io.SeekStart)
// 不好:模糊的名称遮蔽含义
db := test.NewDatabaseFromFile(...)
_, err := f.Seek(0, common.SeekStart)
```
泛化名称可以作为名称的*一部分*(例如 `stringutil`),但不应成为整个包名。
### Package Size
| 问题 | 操作 |
|------|------|
| 你能用一句话描述它的用途吗? | 不能 → 按职责拆分 |
| 文件中从未共享未导出的符号? | 这些文件可以是独立的包 |
| 不同的用户群体使用不同部分? | 按用户边界拆分 |
| Godoc 页面过于庞大? | 拆分以提高可发现性 |
**不要拆分**的原因仅仅是文件很长、创建只有单一类型的包,或会产生循环依赖。
> 在决定是否拆分或合并包、组织包内文件或构建 CLI 程序时,阅读 [references/PACKAGE-SIZE.md](references/PACKAGE-SIZE.md)。
---
## Import
Import 按组组织,组之间用空行分隔。标准库包始终放在第一组。使用
[goimports](https://pkg.go.dev/golang.org/x/tools/cmd/goimports) 自动管理。
```go
import (
"fmt"
"os"
"github.com/foo/bar"
"rsc.io/goversion/version"
)
```
**快速规则:**
| 规则 | 指导 |
|------|------|
| 分组 | 标准库优先,然后是外部包。扩展分组:标准库 → 其他 → proto → 副作用 |
| 重命名 | 除非冲突,否则避免重命名。重命名最本地的 import。Proto 包加 `pb` 后缀 |
| 空白 import(`import _`) | 仅在 `main` 包或测试中使用 |
| 点 import(`import .`) | 永不使用,除非用于循环依赖的测试文件 |
> 在组织扩展分组的 import、重命名 proto 包或决定使用空白/点 import 时,阅读 [references/IMPORTS.md](references/IMPORTS.md)。
---
## 避免 init()
尽可能避免 `init()`。当不可避免时,它必须是:
1. 完全确定性的
2. 不依赖于其他 `init()` 的执行顺序
3. 不依赖环境状态(环境变量、工作目录、参数)
4. 不进行 I/O(文件系统、网络、系统调用)
**可接受的使用场景**:无法用单个赋值完成的复杂表达式、可插拔钩子(例如 `database/sql` 方言)、确定性预计算。
> 在需要将 init() 重构为显式函数或理解可接受的 init() 使用场景时,阅读 [references/PACKAGE-SIZE.md](references/PACKAGE-SIZE.md)。
---
## Main 中的退出
仅在 `main()` 中调用 `os.Exit` 或 `log.Fatal*`。所有其他函数应返回 error。
**原因**:不明显的控制流、不可测试、`defer` 语句被跳过。
**最佳实践**:使用 `run()` 模式——将逻辑提取到
`func run() error` 中,在 `main()` 中调用并使用单一退出点:
```go
func main() {
if err := run(); err != nil {
log.Fatal(err)
}
}
```
> 在实现 run() 模式、构建 CLI 子命令或选择 flag 命名约定时,阅读 [references/PACKAGE-SIZE.md](references/PACKAGE-SIZE.md)。
---
## 命令行 Flag
> **建议**:仅在 `package main` 中定义 flag。
- Flag 名称使用 `snake_case`:`--output_dir` 而非 `--outputDir`
- 库应通过参数接收配置,而非直接读取 flag——
这使它们可测试且可复用
- 优先使用标准 `flag` 包;仅在需要 POSIX 约定
(双破折号、单字符快捷方式)时使用 `pflag`
```go
// 好:Flag 在 main 中定义,作为参数传递给库
func main() {
outputDir := flag.String("output_dir", ".", "directory for output files")
flag.Parse()
if err := mylib.Generate(*outputDir); err != nil {
log.Fatal(err)
}
}
```
---
## 相关技能
- **包命名**:在选择包名、避免名称重复或命名导出符号时,参见 [go-naming](../go-naming/SKILL.md)
- **跨包的错误处理**:在使用 `%w` vs `%v` 在包边界包装错误时,参见 [go-error-handling](../go-error-handling/SKILL.md)
- **Import linting**:在配置 goimports local-prefixes 或强制执行 import 分组时,参见 [go-linting](../go-linting/SKILL.md)
- **全局状态**:在用显式初始化替换 `init()` 或避免可变全局变量时,参见 [go-defensive](../go-defensive/SKILL.md)
@@ -1,110 +0,0 @@
# Import 组织
Go import 组织的详细规则和示例。
## Import 分组
Import 按组组织,组之间用空行分隔。标准库包始终放在第一组。
**最小分组(Uber):** 标准库,然后其他所有。
**扩展分组(Google):** 标准库 → 其他 → protocol buffers → 副作用。
```go
// 好:标准库与外部包分开
import (
"fmt"
"os"
"go.uber.org/atomic"
"golang.org/x/sync/errgroup"
)
```
```go
// 好:完整分组,包含 proto 和副作用
import (
"fmt"
"os"
"github.com/dsnet/compress/flate"
"golang.org/x/text/encoding"
foopb "myproj/foo/proto/proto"
_ "myproj/rpc/protocols/dial"
)
```
## Import 重命名
避免重命名 import,除非为了避免名称冲突;好的包名不需要重命名。
在发生冲突时,**优先重命名最本地的或项目特定的 import**。
**必须重命名:** 与其他 import 冲突、生成的 protocol buffer 包
(删除下划线,添加 `pb` 后缀)。
**可以重命名:** 无意义的名称(例如 `v1`)、与本地变量冲突。
```go
// 好:Proto 包用 pb 后缀重命名
import (
foosvcpb "path/to/package/foo_service_go_proto"
)
// 好:当需要 url 变量时使用 urlpkg
import (
urlpkg "net/url"
)
func parseEndpoint(url string) (*urlpkg.URL, error) {
return urlpkg.Parse(url)
}
```
## 空白 Import(`import _`)
仅为副作用而导入的包(使用 `import _ "pkg"`)
应仅在程序的主包(main)或需要它们的测试中导入。
```go
// 好:在主包中使用空白 import
package main
import (
_ "time/tzdata"
_ "image/jpeg"
)
```
## 点 Import(`import .`)
**不要**使用点 import。它们使程序难以阅读,因为不清楚
`Quux` 这样的名称是当前包中的顶层标识符还是导入包中的。
**例外:** `import .` 形式在由于循环依赖而无法成为被测试包的一部分的测试文件中可能有用:
```go
package foo_test
import (
"bar/testutil" // 也导入了 "foo"
. "foo"
)
```
在这种情况下,测试文件不能是 `foo` 包,因为它使用了
`bar/testutil`,而后者导入了 `foo`。因此 `import .` 形式让文件
假装是 `foo` 包的一部分,即使实际上不是。
**除了这一种情况外,不要在程序中使用 `import .`。**
```go
// 不好:点 import 隐藏了来源
import . "foo"
var myThing = Bar() // Bar 来自哪里?
// 好:显式限定
import "foo"
var myThing = foo.Bar()
```
@@ -1,214 +0,0 @@
# 包大小、程序结构和 CLI
关于包拆分、避免 init()、run() 模式和 CLI 结构的详细指南。
## 何时拆分包
```
包是否变得太大?
├─ 你能用一句话描述它的用途吗?
│ ├─ 不能 → 按职责拆分
│ └─ 能 → 保留,但检查以下内容
├─ 包中的文件是否从未导入彼此的未导出符号?
│ └─ 是 → 这些文件可以是独立的包
├─ 包是否有不同的用户群体使用不同部分?
│ └─ 是 → 按用户边界拆分
└─ godoc 页面是否过于庞大?
└─ 是 → 拆分以提高可发现性
```
### 何时不应拆分
- 不要仅因为文件很长就拆分——聚焦的包中的大文件是可以的
- 不要创建只包含一个类型或函数的包
- 如果会产生循环依赖则不要拆分
- 避免将内部辅助工具拆分到 `util` 或 `internal/helpers` 包中
### 何时合并包
- 如果客户端代码很可能需要两个类型交互,保持它们在一起
- 如果类型有紧密耦合的实现
- 如果用户需要同时导入两个包才能有意义地使用其中任何一个
### 文件组织
Go 中没有"一个类型一个文件"的惯例。文件应该足够聚焦以便知道哪个文件包含什么内容,且足够小以便轻松查找。
---
## 避免 init()
优先使用显式函数而非 `init()`:
```go
// 不好:init() 带有 I/O 和环境依赖
var _config Config
func init() {
cwd, _ := os.Getwd()
raw, _ := os.ReadFile(path.Join(cwd, "config.yaml"))
yaml.Unmarshal(raw, &_config)
}
```
```go
// 好:用于加载配置的显式函数
func loadConfig() (Config, error) {
cwd, err := os.Getwd()
if err != nil {
return Config{}, err
}
raw, err := os.ReadFile(path.Join(cwd, "config.yaml"))
if err != nil {
return Config{}, err
}
var config Config
if err := yaml.Unmarshal(raw, &config); err != nil {
return Config{}, err
}
return config, nil
}
```
**init() 的可接受使用场景:**
- 无法用单个赋值完成的复杂表达式
- 可插拔钩子(例如 `database/sql` 方言、编码注册表)
- 确定性预计算
---
## Main 中的退出
仅在 `main()` 中调用 `os.Exit` 或 `log.Fatal*`。所有其他函数应
返回 error 来表示失败。
**为什么这很重要:**
- 不明显的控制流:任何函数都可以退出程序
- 难以测试:退出程序的函数也会退出测试
- 跳过的清理:`defer` 语句会被跳过
```go
// 不好:在辅助函数中使用 log.Fatal
func readFile(path string) string {
f, err := os.Open(path)
if err != nil {
log.Fatal(err) // 退出程序,跳过 defer
}
b, err := io.ReadAll(f)
if err != nil {
log.Fatal(err)
}
return string(b)
}
```
```go
// 好:返回 error,让 main() 决定是否退出
func main() {
body, err := readFile(path)
if err != nil {
log.Fatal(err)
}
fmt.Println(body)
}
func readFile(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
b, err := io.ReadAll(f)
if err != nil {
return "", err
}
return string(b), nil
}
```
### run() 模式
优先在 `main()` 中**最多调用一次** `os.Exit` 或 `log.Fatal`。将
业务逻辑提取到返回 error 的独立函数中。
```go
func main() {
if err := run(); err != nil {
log.Fatal(err)
}
}
func run() error {
args := os.Args[1:]
if len(args) != 1 {
return errors.New("missing file")
}
f, err := os.Open(args[0])
if err != nil {
return err
}
defer f.Close() // 将始终执行
b, err := io.ReadAll(f)
if err != nil {
return err
}
// 处理 b...
return nil
}
```
**`run()` 模式的优势:**
- 简短的 `main()` 函数,单一退出点
- 所有业务逻辑都可测试
- `defer` 语句始终执行
---
## 命令行接口
### Flag 命名
使用小写、连字符分隔的 flag 名称:
```go
// 好
flag.String("output-dir", ".", "directory for output files")
flag.Bool("dry-run", false, "print actions without executing")
// 不好
flag.String("outputDir", ".", "") // camelCase
flag.String("output_dir", ".", "") // 下划线
```
### 子命令
对于带有子命令的复杂 CLI,为每个子命令使用 `flag.NewFlagSet`:
```go
func main() {
serveCmd := flag.NewFlagSet("serve", flag.ExitOnError)
port := serveCmd.Int("port", 8080, "listen port")
migrateCmd := flag.NewFlagSet("migrate", flag.ExitOnError)
dryRun := migrateCmd.Bool("dry-run", false, "preview changes")
switch os.Args[1] {
case "serve":
serveCmd.Parse(os.Args[2:])
runServe(*port)
case "migrate":
migrateCmd.Parse(os.Args[2:])
runMigrate(*dryRun)
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
os.Exit(1)
}
}
```
对于更大的 CLI,考虑使用 `cobra` 或 `urfave/cli` 等库。仅从
`main()` 退出。
-152
View File
@@ -1,152 +0,0 @@
---
name: go-performance
description: Use when optimizing Go code, investigating slow performance, or writing performance-critical sections. Also use when a user mentions slow Go code, string concatenation in loops, or asks about benchmarking, even if the user doesn't explicitly mention performance patterns. Does not cover concurrent performance patterns (see go-concurrency).
license: Apache-2.0
metadata:
sources: "Uber Style Guide, Google Style Guide, Go Wiki CodeReviewComments"
allowed-tools: Bash(bash:*)
---
# Go 性能模式
## 可用脚本
- **`scripts/bench-compare.sh`** — 运行 Go 基准测试 N 次,并可选通过 benchstat 进行基线比较。支持保存结果以供未来比较。运行 `bash scripts/bench-compare.sh --help` 查看选项。
性能特定的指南仅适用于**热点路径**。不要过早优化——将这些模式集中在最重要的地方。
---
## 优先使用 strconv 而非 fmt
在基本类型和字符串之间转换时,`strconv` 比 `fmt` 更快:
```go
s := strconv.Itoa(rand.Int()) // 比 fmt.Sprint() 快约 2 倍
```
| 方式 | 速度 | 分配次数 |
|------|------|---------|
| `fmt.Sprint` | 143 ns/op | 2 allocs/op |
| `strconv.Itoa` | 64.2 ns/op | 1 allocs/op |
> 在 strconv 和 fmt 之间选择类型转换方式时,或需要完整的转换对照表时,阅读 [references/STRING-OPTIMIZATION.md](references/STRING-OPTIMIZATION.md)。
---
## 避免重复的字符串到字节转换
将固定字符串在循环外转换为 `[]byte` 一次:
```go
data := []byte("Hello world")
for i := 0; i < b.N; i++ {
w.Write(data) // 比每次迭代 []byte("...") 快约 7 倍
}
```
> 在优化热点循环中的重复字节转换时,阅读 [references/STRING-OPTIMIZATION.md](references/STRING-OPTIMIZATION.md)。
---
## 优先指定容器容量
尽可能指定容器容量,以便预先分配内存。这可以最大程度减少后续添加元素时因复制和调整大小而产生的分配。
### Map 容量提示
使用 `make()` 初始化 map 时提供容量提示:
```go
m := make(map[string]os.DirEntry, len(files))
```
**注意**:与 slice 不同,map 的容量提示不保证完整的预分配——它只是近似计算所需的哈希桶数量。
### Slice 容量
使用 `make()` 初始化 slice 时提供容量提示,特别是在追加时:
```go
data := make([]int, 0, size)
```
与 map 不同,slice 容量**不是提示**——编译器会精确分配那么多内存。后续的 `append()` 操作在达到容量之前不会产生任何分配。
| 方式 | 时间(1 亿次迭代) |
|------|------------------------|
| 无容量 | 2.48s |
| 指定容量 | 0.21s |
指定容量的版本**快约 12 倍**,因为追加期间零重新分配。
---
## 传值
不要仅为了节省几个字节就将指针作为函数参数传递。如果函数在整个函数体中仅通过 `*x` 引用其参数 `x`,则该参数不应该是`指针。
```go
func process(s string) { // 不是 *string —— string 是小的固定大小头部
fmt.Println(s)
}
```
**常见的按值传递类型**:`string`、`io.Reader`、小结构体。
**例外**:
- 复制代价高的大结构体
- 未来可能增长的小结构体
---
## 字符串拼接
根据复杂度选择正确的策略:
| 方法 | 最佳用途 |
|------|---------|
| `+` | 少量字符串,简单拼接 |
| `fmt.Sprintf` | 混合类型的格式化输出 |
| `strings.Builder` | 循环/逐段构建 |
| `strings.Join` | 连接 slice |
| 反引号字面量 | 常量多行文本 |
> 在选择字符串拼接策略、在循环中使用 strings.Builder 或在 fmt.Sprintf 和手动拼接之间做决定时,阅读 [references/STRING-OPTIMIZATION.md](references/STRING-OPTIMIZATION.md)。
---
## 基准测试和性能分析
在优化前后始终要进行测量。使用 Go 内置的基准测试框架和性能分析工具。
```bash
go test -bench=. -benchmem -count=10 ./...
```
> 在编写基准测试、使用 benchstat 比较结果、使用 pprof 进行性能分析或解读基准测试输出时,阅读 [references/BENCHMARKS.md](references/BENCHMARKS.md)。
> **验证**:在应用优化后,运行 `bash scripts/bench-compare.sh` 测量实际影响。只保留有可衡量改进的优化。
---
## 快速参考
| 模式 | 不好 | 好 | 改进 |
|------|-----|------|-------------|
| 整数转字符串 | `fmt.Sprint(n)` | `strconv.Itoa(n)` | 快约 2 倍 |
| 重复 `[]byte` | 循环中 `[]byte("str")` | 在循环外转换一次 | 快约 7 倍 |
| Map 初始化 | `make(map[K]V)` | `make(map[K]V, size)` | 更少分配 |
| Slice 初始化 | `make([]T, 0)` | `make([]T, 0, cap)` | 快约 12 倍 |
| 小型固定大小参数 | `*string`、`*io.Reader` | `string`、`io.Reader` | 无间接引用 |
| 简单字符串连接 | `s1 + " " + s2` | (已经很好) | 对少量字符串使用 `+` |
| 循环构建字符串 | 重复 `+=` | `strings.Builder` | O(n) vs O(n²) |
---
## 相关技能
- **数据结构**:在 slice、map 和数组之间选择或理解分配语义时,参见 [go-data-structures](../go-data-structures/SKILL.md)
- **声明模式**:在使用 `make` 配合容量提示或初始化 map 和 slice 时,参见 [go-declarations](../go-declarations/SKILL.md)
- **并发**:在跨 goroutine 并行化工作或使用 sync.Pool 复用缓冲区时,参见 [go-concurrency](../go-concurrency/SKILL.md)
- **风格原则**:在判断优化是否值得牺牲可读性时,参见 [go-style-core](../go-style-core/SKILL.md)
@@ -1,281 +0,0 @@
# 基准测试方法
## 编写基准测试
Go 基准测试使用 `testing.B` 类型,位于 `_test.go` 文件中。
基准测试函数名必须以 `Benchmark` 开头。
```go
func BenchmarkStrconv(b *testing.B) {
for i := 0; i < b.N; i++ {
s := strconv.Itoa(rand.Int())
_ = s
}
}
func BenchmarkFmtSprint(b *testing.B) {
for i := 0; i < b.N; i++ {
s := fmt.Sprint(rand.Int())
_ = s
}
}
```
关键规则:
- 使用 `b.N` 作为循环边界——框架会调整它以获得稳定的计时
- 将结果赋值给变量(或 `_`),防止编译器优化掉调用
- 在不需要测量的昂贵设置之后使用 `b.ResetTimer()`
- 使用 `b.ReportAllocs()` 或 `-benchmem` 标志跟踪分配情况
### 子基准测试
```go
func BenchmarkConvert(b *testing.B) {
for _, size := range []int{10, 100, 1000} {
b.Run(fmt.Sprintf("size=%d", size), func(b *testing.B) {
data := make([]byte, size)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = string(data)
}
})
}
}
```
---
## 运行基准测试
```bash
# 运行包中的所有基准测试
go test -bench=. ./...
# 运行特定基准测试并显示内存统计
go test -bench=BenchmarkStrconv -benchmem ./...
# 多次运行以获得统计显著性
go test -bench=. -benchmem -count=10 ./...
```
`-benchmem` 标志报告每次操作的分配次数。`-count` 标志将每个基准测试运行 N 次以获得统计显著性。
---
## 解读结果
```
BenchmarkStrconv-8 18705042 64.2 ns/op 16 B/op 1 allocs/op
BenchmarkFmtSprint-8 8249536 143.0 ns/op 16 B/op 2 allocs/op
```
| 字段 | 含义 |
|------|------|
| `-8` | GOMAXPROCS |
| `18705042` | 迭代次数 |
| `64.2 ns/op` | 每次操作时间 |
| `16 B/op` | 每次操作分配的字节数 |
| `1 allocs/op` | 每次操作的堆分配次数 |
---
## 使用 benchstat 进行比较
`benchstat` 对基准测试结果进行统计比较。安装它并将基准测试输出保存到文件:
```bash
# 安装 benchstat
go install golang.org/x/perf/cmd/benchstat@latest
# 运行基准测试并保存结果
go test -bench=. -benchmem -count=10 ./... > old.txt
# 进行修改后再次运行
go test -bench=. -benchmem -count=10 ./... > new.txt
# 比较结果
benchstat old.txt new.txt
```
### 解读 benchstat 输出
```
name old time/op new time/op delta
Strconv-8 64.2ns ± 2% 61.8ns ± 1% -3.74% (p=0.001 n=10+10)
```
- **delta**:变化百分比(负数 = 更快)
- **p-value**:统计显著性(p < 0.05 为显著)
- **n**:使用的有效样本数量
提示:
- 始终使用 `-count=10` 或更高以获得可靠结果
- 小的 p 值确认变化是真实的,而非噪声
- 如果 benchstat 显示 `~`(波浪号),则差异不具有统计显著性
---
## 来自性能模式的基准测试示例
### strconv vs fmt
| 方式 | 速度 | 分配次数 |
|------|------|---------|
| `fmt.Sprint` | 143 ns/op | 2 allocs/op |
| `strconv.Itoa` | 64.2 ns/op | 1 allocs/op |
### 重复字节转换
```go
func BenchmarkRepeatedConversion(b *testing.B) {
var buf bytes.Buffer
for i := 0; i < b.N; i++ {
buf.Write([]byte("Hello world"))
}
}
func BenchmarkSingleConversion(b *testing.B) {
var buf bytes.Buffer
data := []byte("Hello world")
for i := 0; i < b.N; i++ {
buf.Write(data)
}
}
```
| 方式 | 速度 |
|------|------|
| 重复转换 | 22.2 ns/op |
| 单次转换 | 3.25 ns/op |
### Slice 容量
```go
func BenchmarkNoCapacity(b *testing.B) {
for n := 0; n < b.N; n++ {
data := make([]int, 0)
for k := 0; k < 1000; k++ {
data = append(data, k)
}
}
}
func BenchmarkWithCapacity(b *testing.B) {
for n := 0; n < b.N; n++ {
data := make([]int, 0, 1000)
for k := 0; k < 1000; k++ {
data = append(data, k)
}
}
}
```
| 方式 | 时间(1 亿次迭代) |
|------|------------------------|
| 无容量 | 2.48s |
| 指定容量 | 0.21s |
---
## 使用 pprof 进行性能分析
使用 `pprof` 在优化前识别瓶颈。基准测试衡量改进效果;pprof 找到需要改进的地方。
### CPU 性能分析
```bash
# 从基准测试生成 CPU 分析文件
go test -bench=BenchmarkHotPath -cpuprofile=cpu.prof ./...
# 使用 pprof 分析
go tool pprof cpu.prof
```
常用 pprof 命令:
```
(pprof) top10 # 按 CPU 时间排列的前 10 个函数
(pprof) list funcName # 某个函数的带注释源码
(pprof) web # 浏览器中的交互式图表
```
### 内存性能分析
```bash
# 生成内存分析文件
go test -bench=BenchmarkHotPath -memprofile=mem.prof ./...
# 分析分配情况
go tool pprof -alloc_space mem.prof
```
### 运行中服务的 HTTP 性能分析
```go
import _ "net/http/pprof"
func main() {
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
// ... 应用程序代码 ...
}
```
通过 `http://localhost:6060/debug/pprof/` 访问性能分析数据。
### 性能分析工作流
1. 对疑似热点路径进行**基准测试**
2. 使用 pprof **分析**以确认时间花在了哪里
3. 使用本技能中的模式进行**优化**
4. **重新基准测试**以用 benchstat 验证改进
5. **重新分析**以检查是否出现新的瓶颈
---
## 常见错误
### 忽略 b.N
测试框架会调整 `b.N` 以获得稳定的计时。使用固定迭代次数会产生无意义的结果:
```go
// 不好:忽略 b.N —— 基准测试框架无法校准
func BenchmarkFixed(b *testing.B) {
for i := 0; i < 1000; i++ {
doWork()
}
}
// 好:使用 b.N 作为循环边界
func BenchmarkCorrect(b *testing.B) {
for i := 0; i < b.N; i++ {
doWork()
}
}
```
### 未防止编译器优化消除
如果函数调用的结果未被使用,编译器可能会完全优化掉该调用。将结果赋值给包级变量:
```go
// 不好:编译器可能会优化掉调用
func BenchmarkElided(b *testing.B) {
for i := 0; i < b.N; i++ {
expensiveFunc()
}
}
// 好:赋值给包级变量以防止优化消除
var benchResult int
func BenchmarkKept(b *testing.B) {
var r int
for i := 0; i < b.N; i++ {
r = expensiveFunc()
}
benchResult = r
}
```
@@ -1,134 +0,0 @@
# 字符串优化模式
## strconv vs fmt
在基本类型和字符串之间转换时,`strconv` 比 `fmt` 更快,因为 `fmt` 使用反射并处理任意类型。
**不好:**
```go
for i := 0; i < b.N; i++ {
s := fmt.Sprint(rand.Int())
}
```
**好:**
```go
for i := 0; i < b.N; i++ {
s := strconv.Itoa(rand.Int())
}
```
**基准测试比较:**
| 方式 | 速度 | 分配次数 |
|------|------|---------|
| `fmt.Sprint` | 143 ns/op | 2 allocs/op |
| `strconv.Itoa` | 64.2 ns/op | 1 allocs/op |
常用转换:
| 任务 | `fmt` | `strconv` |
|------|-------|-----------|
| Int → string | `fmt.Sprint(n)` | `strconv.Itoa(n)` |
| Int64 → string | `fmt.Sprint(n)` | `strconv.FormatInt(n, 10)` |
| Float → string | `fmt.Sprint(f)` | `strconv.FormatFloat(f, 'f', -1, 64)` |
| String → int | — | `strconv.Atoi(s)` |
| Bool → string | `fmt.Sprint(b)` | `strconv.FormatBool(b)` |
---
## 重复的字符串到字节转换
不要重复从固定字符串创建字节切片。应该只转换一次并保存结果。
**不好:**
```go
for i := 0; i < b.N; i++ {
w.Write([]byte("Hello world"))
}
```
**好:**
```go
data := []byte("Hello world")
for i := 0; i < b.N; i++ {
w.Write(data)
}
```
**基准测试比较:**
| 方式 | 速度 |
|------|------|
| 重复转换 | 22.2 ns/op |
| 单次转换 | 3.25 ns/op |
好的版本**快约 7 倍**,因为它避免了每次迭代都分配新的字节切片。
---
## 字符串拼接
根据复杂度选择正确的字符串构建策略。
### 简单场景使用 `+`
```go
key := "projectid: " + p
```
`+` 运算符对于少量、固定数量的字符串是高效的。编译器通常可以优化相邻的字符串字面量。
### 格式化使用 `fmt.Sprintf`
```go
// 好:清晰的格式化
str := fmt.Sprintf("%s [%s:%d]-> %s", src, qos, mtu, dst)
// 不好:使用 + 手动转换
str := src.String() + " [" + qos.String() + ":" + strconv.Itoa(mtu) + "]-> " + dst.String()
```
当写入 `io.Writer` 时,直接使用 `fmt.Fprintf` 而不是先用 `fmt.Sprintf` 构建临时字符串。
### 逐段构建使用 `strings.Builder`
`strings.Builder` 花费摊销线性时间,而重复使用 `+` 或
`fmt.Sprintf` 在构建大字符串时花费二次时间:
```go
b := new(strings.Builder)
for i, d := range digitsOfPi {
fmt.Fprintf(b, "the %d digit of pi is: %d\n", i, d)
}
str := b.String()
```
### 常量多行字符串使用反引号
```go
// 好:原始字符串字面量
usage := `Usage:
custom_tool [args]`
// 不好:使用转义序列拼接
usage := "" +
"Usage:\n" +
"\n" +
"custom_tool [args]"
```
### 策略总结
| 方法 | 最佳用途 | 性能 |
|------|---------|------|
| `+` | 少量字符串,简单拼接 | 小 n 时 O(n) |
| `fmt.Sprintf` | 格式化输出 | 较慢,但更清晰 |
| `strings.Builder` | 循环/逐段构建 | 摊销 O(n) |
| `strings.Join` | 连接 slice | O(n) |
| 反引号字面量 | 常量多行文本 | 零开销 |
@@ -1,252 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
VERSION="1.1.0"
SCRIPT_NAME="$(basename "$0")"
usage() {
cat <<EOF
$SCRIPT_NAME v$VERSION — Run Go benchmarks with optional comparison
USAGE
bash $SCRIPT_NAME [options] [package]
DESCRIPTION
Wrapper around 'go test -bench' that runs benchmarks multiple times and
optionally compares results against a saved baseline using benchstat.
Results can be saved to a file for future comparison. If benchstat is
installed and a baseline is provided, a statistical comparison is shown.
EXIT CODES
0 Benchmarks ran successfully
1 go test failed (compilation error, test failure, no benchmarks found)
2 Usage error (missing arguments, bad flags, file exists without --force)
OPTIONS
-h, --help Show this help message
-v, --version Show version
-n, --count N Number of benchmark iterations (default: 5)
-b, --baseline FILE Compare results against this baseline file
-s, --save FILE Save benchmark results to this file
-f, --filter REGEX Benchmark filter regex (default: ".")
--json Output metadata as JSON (human output goes to stderr)
--benchmem Include memory allocation stats (default: on)
--no-benchmem Disable memory allocation stats
--force Allow --save to overwrite existing files
--limit N Max benchmark result lines to include (default: 0 = all)
ARGUMENTS
package Go package to benchmark (default: ./...)
EXAMPLES
bash $SCRIPT_NAME
bash $SCRIPT_NAME -n 10 ./pkg/parser
bash $SCRIPT_NAME --save baseline.txt ./...
bash $SCRIPT_NAME --baseline baseline.txt --save current.txt ./...
bash $SCRIPT_NAME --filter BenchmarkSort -n 3
bash $SCRIPT_NAME --json --limit 5 ./...
bash $SCRIPT_NAME --save results.txt --force ./...
EOF
}
json_escape() {
local s="$1"
s="${s//\\/\\\\}"
s="${s//\"/\\\"}"
s="${s//$'\t'/\\t}"
s="${s//$'\r'/}"
s="${s//$'\n'/\\n}"
printf '%s' "$s"
}
# Print human-readable output: stdout in text mode, stderr in JSON mode.
log() {
if $JSON_OUTPUT; then
echo "$@" >&2
else
echo "$@"
fi
}
COUNT=5
BASELINE=""
SAVE=""
FILTER="."
PACKAGE=""
JSON_OUTPUT=false
BENCHMEM=true
FORCE=false
LIMIT=0
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) usage; exit 0 ;;
-v|--version) echo "$SCRIPT_NAME v$VERSION"; exit 0 ;;
-n|--count) COUNT="${2:?error: --count requires a number}"; shift 2 ;;
-b|--baseline) BASELINE="${2:?error: --baseline requires a file path}"; shift 2 ;;
-s|--save) SAVE="${2:?error: --save requires a file path}"; shift 2 ;;
-f|--filter) FILTER="${2:?error: --filter requires a regex}"; shift 2 ;;
--json) JSON_OUTPUT=true; shift ;;
--benchmem) BENCHMEM=true; shift ;;
--no-benchmem) BENCHMEM=false; shift ;;
--force) FORCE=true; shift ;;
--limit) LIMIT="${2:?error: --limit requires a number}"; shift 2 ;;
-*) echo "error: unknown option: $1" >&2; usage >&2; exit 2 ;;
*) PACKAGE="$1"; shift ;;
esac
done
PACKAGE="${PACKAGE:-./...}"
if ! command -v go &>/dev/null; then
echo "error: 'go' command not found in PATH" >&2
exit 2
fi
if ! [[ "$COUNT" =~ ^[1-9][0-9]*$ ]]; then
echo "error: --count must be a positive integer, got: $COUNT" >&2
exit 2
fi
if ! [[ "$LIMIT" =~ ^[0-9]+$ ]]; then
echo "error: --limit must be a non-negative integer, got: $LIMIT" >&2
exit 2
fi
if [[ -n "$BASELINE" && ! -f "$BASELINE" ]]; then
echo "error: baseline file not found: $BASELINE" >&2
exit 2
fi
if [[ -n "$SAVE" && -f "$SAVE" ]] && ! $FORCE; then
echo "error: save target already exists: $SAVE (use --force to overwrite)" >&2
exit 2
fi
HAS_BENCHSTAT=false
if command -v benchstat &>/dev/null; then
HAS_BENCHSTAT=true
fi
BENCH_ARGS=(-bench "$FILTER" -count "$COUNT" -run '^$')
if $BENCHMEM; then
BENCH_ARGS+=(-benchmem)
fi
TMPFILE=$(mktemp "${TMPDIR:-/tmp}/bench-XXXXXX.txt")
trap 'rm -f "$TMPFILE"' EXIT
log "Running benchmarks: go test ${BENCH_ARGS[*]} $PACKAGE"
log "Iterations: $COUNT"
log ""
GO_EXIT=0
if $JSON_OUTPUT; then
go test "${BENCH_ARGS[@]}" "$PACKAGE" 2>&1 | tee "$TMPFILE" >&2 || GO_EXIT=$?
else
go test "${BENCH_ARGS[@]}" "$PACKAGE" 2>&1 | tee "$TMPFILE" || GO_EXIT=$?
fi
BENCH_COUNT=$(grep -cE '^Benchmark' "$TMPFILE" || true)
TRUNCATED=false
if [[ $LIMIT -gt 0 && $BENCH_COUNT -gt $LIMIT ]]; then
TRUNCATED=true
fi
if ! $JSON_OUTPUT && $TRUNCATED; then
log ""
log "Note: $BENCH_COUNT benchmark results found, showing first $LIMIT (--limit $LIMIT)"
fi
if [[ -n "$SAVE" ]]; then
cp "$TMPFILE" "$SAVE"
log ""
log "Results saved to: $SAVE"
fi
if [[ -n "$BASELINE" ]]; then
log ""
log "=== Comparison with baseline: $BASELINE ==="
log ""
if $HAS_BENCHSTAT; then
if $JSON_OUTPUT; then
benchstat "$BASELINE" "$TMPFILE" >&2 || true
else
benchstat "$BASELINE" "$TMPFILE" || true
fi
else
log "note: install benchstat for statistical comparison:"
log " go install golang.org/x/perf/cmd/benchstat@latest"
log ""
log "--- Baseline ---"
if $JSON_OUTPUT; then
grep -E '^Benchmark' "$BASELINE" >&2 || true
else
grep -E '^Benchmark' "$BASELINE" || true
fi
log ""
log "--- Current ---"
if $JSON_OUTPUT; then
grep -E '^Benchmark' "$TMPFILE" >&2 || true
else
grep -E '^Benchmark' "$TMPFILE" || true
fi
fi
fi
FINAL_EXIT=0
if [[ $GO_EXIT -ne 0 ]]; then
FINAL_EXIT=1
if ! $JSON_OUTPUT; then
log ""
log "error: go test exited with code $GO_EXIT"
fi
elif [[ $BENCH_COUNT -eq 0 ]]; then
FINAL_EXIT=1
if ! $JSON_OUTPUT; then
log ""
log "error: no benchmarks found matching filter: $FILTER"
fi
fi
if $JSON_OUTPUT; then
BENCH_OUTPUT=$(<"$TMPFILE")
if $TRUNCATED; then
limited=""
bench_seen=0
while IFS= read -r line; do
if [[ "$line" =~ ^Benchmark ]]; then
bench_seen=$((bench_seen + 1))
if [[ $bench_seen -le $LIMIT ]]; then
limited+="$line"$'\n'
fi
else
limited+="$line"$'\n'
fi
done < "$TMPFILE"
BENCH_OUTPUT="$limited"
fi
escaped_package=$(json_escape "$PACKAGE")
escaped_filter=$(json_escape "$FILTER")
escaped_baseline=$(json_escape "$BASELINE")
escaped_save=$(json_escape "$SAVE")
escaped_output=$(json_escape "$BENCH_OUTPUT")
printf '{"count":%d,' "$COUNT"
printf '"package":"%s",' "$escaped_package"
printf '"filter":"%s",' "$escaped_filter"
printf '"benchmarks_found":%d,' "$BENCH_COUNT"
printf '"baseline":"%s",' "$escaped_baseline"
printf '"save":"%s",' "$escaped_save"
printf '"exit_code":%d,' "$GO_EXIT"
printf '"output":"%s"' "$escaped_output"
if $TRUNCATED; then
printf ',"truncated":true'
fi
printf '}\n'
fi
exit $FINAL_EXIT
+92
View File
@@ -0,0 +1,92 @@
---
name: "logstore"
description: "Wavelet 项目专用:当新增或修改日志/分析用途表(访问日志、审计流水、可观测时序)、接入 internal/repository/logstore、切换日志主库、实现 PG/SQLite 回落,或判断一张表该走业务主库还是日志库时必须使用。"
---
# 日志用途表开发
开始前阅读根目录 `AGENTS.md`。DDL 用 `database-migration`;高频写入队列用 `clickhouse-batchwriter`;切换任务用 `new-async-task`。本技能只回答:**这张表是不是日志表,以及如何接入可切换的日志主库。**
分层与切换协议见 [日志用途表](../../../docs/LOGSTORE.md)。
## 先判定
日志表同时满足:
- 追加写入、几乎不更新单行
- 按时间查询/聚合,允许按保留天数删除
- 关闭 ClickHouse 后仍要能写、能查
- 不参与用户/配置/任务等事务一致性
**不要**做成日志表:用户、配置、任务执行、上传元数据、需要事务或强一致的业务实体。这些走主库 `repository`,不要进 `logstore`。
当前框架已接入的日志表:`w_user_access_logs`(管理端 API 访问审计)。
## 分层
| 层级 | 路径 | 职责 |
| :--- | :--- | :--- |
| 抽象 | `internal/repository/logstore` | 接口 + `Active`/`BuildForMigration`;apps **只**面向这里 |
| CH 实现 | `logstore` 委托 `internal/repository/analytics` | 原生 `PrepareBatch` / `ChDB` 查询 |
| 主库实现 | `logstore` GORM | PG(按月分区)与 SQLite(普通表) |
| Model | `internal/model/analytics` | 实体、`TableName`、`InsertColumns`、`BatchInsertSQL`,无 IO |
| 入队 | `internal/apps/<domain>` + `batchwriter` | `FlushFunc` 调 `logstore.Active().….BatchInsert` |
| 切换 | `internal/apps/admin/logs` 的 `logs:db_switch` | 冻结写入 → 排空 → 复制 → 翻转 `log_database` |
| 清理 | `logstore.CleanupExpired`,由 `system:cleanup` 调用 | 按库读取保留天数后 `DeleteBefore` |
`log_database` ∈ {`postgres`,`sqlite`,`clickhouse`},且只能是「随主库」或 ClickHouse:主库为 PG 时日志不能是 SQLite,反之亦然。`log_database` / `log_db_migration` 受保护,禁止管理端手动改。
## 新增一张日志表
按顺序做,列名三库必须一致。
1. **Model**
在 `internal/model/analytics/` 定义 struct;实现 `TableName()`;批量写再提供 `InsertColumns()` / `BatchInsertSQL()`。
2. **三套 DDL**(`database-migration`)
- ClickHouse:`goose/clickhouse/`,`MergeTree`,`PARTITION BY toYYYYMM(时间列)`。
- PostgreSQL:`goose/postgres/`,高频表用 `PARTITION BY RANGE (时间列)`,复合主键必须包含分区键。
- SQLite:`goose/sqlite/`,普通表 + 时间/过滤列索引。
不要在 PG/SQLite 上复制 CH 物化视图;聚合在查询时实时算。
3. **logstore 接口**
在对应 Store(现有 `UserAccessLogStore`,或新域自建接口并挂到 `Store`)补齐至少:
- 写入:`BatchInsert`(flush 目标;内调 `ensureWritable`)
- 查询:业务需要的 List/Count/聚合
- 迁移:`ListForMigration(afterID, limit)`、`MigrationRange`、`DeleteAll`、`EnsurePartitions`(PG 按月预建,CH/SQLite no-op)
- 清理:`DeleteBefore(cutoff)`、`DropEmptyPartitions`、`DropExpiredPartitions`(仅 PG;CH/SQLite no-op)
4. **双实现**
- CH:委托 `analyticsrepo`,零额外查询路径。
- GORM:PG/SQLite 共用一套;方言 SQL 只放小函数(如按日 `to_char` / `strftime`)。零值 `id` 落库前用 `idgen.NextUint64ID()`。
5. **`buildStore`**
在 `provider.go` 的 CH / GORM 分支同时挂上新域。
6. **写入**
apps 用独立 `batchwriter` 实例;`FlushFunc` → `logstore.Active(ctx)` → `BatchInsert`。禁止 `analyticsrepo.BatchInsert`、禁止 `db.ChConn`。迁移任务调用域的 `Drain`(等队列空一个 flush 周期,不要 `Stop` writer)。
7. **切换任务**
在 `copy*` 流程增加该表:`DeleteAll` 目标 → `MigrationRange` + `EnsurePartitions` → 按 id 分页复制。不要改切换协议(仍冻结写入、源数据不删、成功才翻转)。
8. **清理**
`CleanupExpired`:PG 先 `DropExpiredPartitions`(整月过期分区),再 `DeleteBefore`(边界月),最后 `DropEmptyPartitions`。保留天数用已有 `log_retention_days_*`。apps 禁止 import `repository/analytics`(`imports_test.go`)。
## 禁止
- apps 直接 `import` `internal/repository/analytics` 或 `db.ChConn` / `db.ChDB` 做日志读写
- 只建 CH 表、不建 PG/SQLite 回落
- 在 Handler 里逐条 `PrepareBatch` + `Send`
- 把业务表「顺便」放进 logstore 以便关 CH
- 管理端 API 改 `log_database` / `log_db_migration`
## 验证
```bash
go test ./internal/repository/logstore ./internal/repository/analytics
go test ./internal/apps/admin/logs ./internal/apps/risk_control ./internal/platform/bootstrap
make swagger # 若改了状态/查询 API
make code-check
```
对照:`w_user_access_logs` 的 model、三库 goose、`logstore` GORM/CH、`risk_control.InitLogWriter`、`logs.LogDBSwitchHandler`、`system:cleanup`。
+177 -104
View File
@@ -1,146 +1,219 @@
---
name: "new-api"
description: "Wavelet 项目专用:当新增或修改自定义业务 API、新增业务路由、新增 service 层核心逻辑时必须使用。本技能指导包职责划分、推荐文件结构、路由解耦、Swagger 文档生成与质量门禁验证。"
description: "Wavelet 项目专用:当新增或修改业务 API、Handler、服务层逻辑、路由注册时必须使用。本技能指导 apps 业务包划分、路由注册、Handler/logics 分层、Swagger 与质量门禁;纠正把一切塞进 custom.go / apps/custom 或产品伞包的错误写法。"
---
# 新增业务 API 开发与路由注册规范
本技能是 Wavelet 项目接口开发与路由注册的唯一指导规范。在开发任何新接口前,请严格按照本指南进行架构决策与路由注册。
本技能是 Wavelet 接口开发与路由注册的唯一指导规范。在开发任何新接口前,请按本指南做架构决策与路由注册。
---
## 核心路由准则与防线 (Routing Governance & Guardrails)
## 先搞清:脚手架 vs 产品化
Wavelet 后端路由采用了**严格的框架层与业务层隔离机制**。请牢记以下开发原则:
Wavelet 是**通用全栈脚手架**。仓库里的 `custom` 相关代码是**示例/占位**,不是产品业务的标准落点。
1. **禁止修改框架级路由文件**:
- 以下文件属于系统框架/平台级接口,**禁止为了添加自定义业务接口而进行任何修改**:
- `internal/router/router.go`(核心入口委派)
- `internal/router/root/default.go`(公开文件服务、robots.txt、Swagger 及 /api/health 路由)
- `internal/router/root/frontend.go`(前端静态服务)
- `internal/router/v1/v1.go`(V1 分发层协调器)
- `internal/router/v1/admin.go`(框架管理员端管理接口)
- `internal/router/v1/user.go`(框架普通用户端基础接口、OAuth及公开接口)
2. **仅允许在 `custom.go` 中注册业务接口**:
- 所有的自定义/业务相关接口注册,有且仅有以下两个合法的承载点:
- [internal/router/root/custom.go](file:///Users/ryan/DEV/Go/Wavelet/internal/router/root/custom.go)(用于挂载到根路径的特殊业务接口)
- [internal/router/v1/custom.go](file:///Users/ryan/DEV/Go/Wavelet/internal/router/v1/custom.go)(用于挂载在 API V1 下的标准自定义业务接口)
| 层级 | 含义 | 典型包 |
| :--- | :--- | :--- |
| **平台能力** | 脚手架自带、与具体产品无关 | `oauth`、`user`、`admin/*`、`upload`、`cap`、`config`、`health`、`risk_control` |
| **产品业务** | 基于脚手架做具体产品时新增的域 | 直接落在 `internal/apps/<domain>/`,与平台包**平级** |
**一旦用脚手架开发具体产品,整个仓库就是该产品**——例如要做「消息平台」,业务模块应是 `apps/channel`、`apps/conversation`、`apps/delivery` 等,而不是先建 `apps/message` 伞包再往里塞子模块。
---
## 路由归属判定表 (Where should I register my new API?)
## 反模式(AI 最常踩的坑)
根据接口的**访问路径特征**和**访问身份/限制条件**,决定将新开发的 API 挂载至何处:
### 1. 把所有业务路由塞进 `custom.go` / 路径前缀 `/custom`
| 目标 API 路径特征 | 访问身份/条件限制 | 对应的路由注册入口 | 是否允许修改 |
| :--- | :--- | :--- | :--- |
| **`/my-custom-path`** (挂载在根路径下的特殊业务接口) | 自定义控制 | `root/custom.go` 中的 `RegisterCustomRootRoutes` | **允许修改 (业务自定义入口)** |
| **`/api/v1/custom/...`** (API v1 下的定制业务接口) | 自定义控制 | `v1/custom.go` 中的 `RegisterCustomRoutes` | **允许修改 (业务自定义入口)** |
| **`/api/v1/admin/...`** (系统管理员管理端接口) | 需要管理员登录 (`admin.LoginAdminRequired()`) | `v1/admin.go` | **禁止修改 (仅限系统框架路由)** |
| **`/api/v1/user/...`** (框架普通用户基础接口) | 需要普通用户登录 (`oauth.LoginRequired()`) | `v1/user.go` | **禁止修改 (仅限系统框架路由)** |
| **`/api/v1/public/...`** (Captcha、Config 等系统公开接口) | 所有人 (无条件 / 公开) | `v1/user.go` | **禁止修改 (仅限系统框架路由)** |
| **`GET /f/:id`**, **`GET /robots.txt`**, **`GET /api/health`** (系统级默认及公开接口) | 所有人 (无条件 / 公开) | `root/default.go` | **禁止修改 (仅限系统框架路由)** |
仓库中的:
- `internal/router/v1/custom.go`
- `internal/router/root/custom.go`
- `internal/apps/custom/`
是**演示如何挂一条示例接口**(`GET /api/v1/custom/hello`),**不是**「所有自定义业务必须写在这里」的规定。
| 错误 | 正确 |
| :--- | :--- |
| 新功能一律改 `v1/custom.go`,路径全是 `/api/v1/custom/...` | 按域新建 `apps/<domain>/`,路由用语义化路径(如 `/api/v1/channels`),在 `router/v1/` 下用**独立注册文件**挂载 |
| 把 `custom` 包当成业务垃圾桶 | 保留或删除示例均可;真正业务用独立包名 |
### 2. 产品伞包 + 深层子包
| 错误 | 正确 |
| :--- | :--- |
| `apps/message/channel`、`apps/message/inbox`、`apps/message/delivery`(先套一层产品名) | `apps/channel`、`apps/inbox`、`apps/delivery`(域模块与 `oauth`/`user` 平级) |
| `apps/myapp/...` 再嵌套所有业务 | 仓库即产品,**不要**再包一层产品根 |
**判定**:模块名应对齐**业务能力/限界上下文**(channel、order、invoice),而不是对齐产品营销名(message-platform、myapp)。
### 3. 其它仍须遵守的防线
- 不要在 `internal/router/router.go` 里直接挂业务 Handler(只做高层委派)。
- 不要破坏平台模块既有语义去硬塞无关业务(例如把消息逻辑塞进 `apps/user`)。
- 错误响应使用 `response.Abort*`,禁止 `c.JSON(..., response.Err(...))`(见 `AGENTS.md`)。
---
## 两个自定义路由包的用法与区别 (Root Custom vs V1 Custom)
## 路由注册模型
### 1. 根路径自定义包:`root/custom.go`
### 谁可以改
* **适用场景**:适用于需要**直接挂载在主域名根路径下**的特殊自定义业务接口(如第三方 Webhook 回调、特定的短链接重定向、外部数据接口等,不需要 `/api/v1` 前缀)。
* **用法示例**:
在 [root/custom.go](file:///Users/ryan/DEV/Go/Wavelet/internal/router/root/custom.go) 中实现:
```go
package root
| 文件 | 角色 | 产品化时 |
| :--- | :--- | :--- |
| `internal/router/router.go` | 引擎、中间件、委派入口 | 一般不改;特殊全局中间件才动 |
| `internal/router/v1/v1.go` | V1 分发:调用各 `Register*Routes` | **允许**:增加对新业务注册函数的一行调用 |
| `internal/router/v1/user.go` / `admin.go` | 平台用户端 / 管理端路由 | **优先不改**;仅当扩展平台能力(OAuth、上传、用户资料)时修改 |
| `internal/router/v1/<domain>.go`(新建) | 产品业务路由注册 | **推荐落点** |
| `internal/router/v1/custom.go` | **示例** | 可删可留;**不要**把真实业务堆在这里 |
| `internal/router/root/default.go` / `frontend.go` | 文件服务、health、前端静态 | 平台级,勿塞产品 API |
| `internal/router/root/custom.go` | 根路径**示例**占位 | 仅当确需根路径回调/短链时,用**语义路径**注册,或新建 `root/<domain>.go` 并由 `root.go` 调用 |
import (
"github.com/Rain-kl/Wavelet/internal/apps/custom"
"github.com/gin-gonic/gin"
)
### 路径归属(产品 API 用语义路径)
// RegisterCustomRootRoutes registers custom business routes that belong to the root path.
func RegisterCustomRootRoutes(r *gin.Engine) {
// 挂载到根路径下,如 GET /my-custom-webhook
r.GET("/my-custom-webhook", custom.HandleRootWebhook)
}
```
*(注:该函数已由 `root.go` 自动加载,你无需修改任何其他核心文件。)*
| 目标路径特征 | 注册位置 | 说明 |
| :--- | :--- | :--- |
| `/api/v1/<domain>/...`(如 `/api/v1/channels`) | `v1/<domain>.go` 的 `Register<Domain>Routes`,在 `v1.go` 调用 | **产品业务默认做法** |
| `/api/v1/admin/<domain>/...` | 管理端:可在 `admin.go` 增加小组,或 `v1/admin_<domain>.go` 再由 `RegisterAdminRoutes`/ `v1.go` 组装 | 需 `admin.LoginAdminRequired()` |
| `/api/v1/user/...`、`/oauth/...`、`/upload/...` 等 | `user.go` 等平台文件 | 平台能力,勿把无关产品塞进来 |
| 根路径特殊接口(Webhook、短链) | `root` 下独立注册函数 | **不要**默认塞进 `custom` 前缀 |
| `GET /f/:id`、`/api/health`、`robots.txt` | `root/default.go` | 平台,勿改用途 |
### 2. V1 API 自定义包:`v1/custom.go`
* **适用场景**:适用于普通的**自定义业务 API**,需要规范挂载在标准 API V1 路径下(即自动带有 `/api/v1/custom/...` 前缀,可选择性配置用户/管理员登录中间件)。
* **用法示例**:
在 [v1/custom.go](file:///Users/ryan/DEV/Go/Wavelet/internal/router/v1/custom.go) 中实现:
```go
package v1
import (
"github.com/Rain-kl/Wavelet/internal/apps/custom"
"github.com/gin-gonic/gin"
)
// RegisterCustomRoutes registers standard custom API routes under /api/v1.
func RegisterCustomRoutes(apiV1Router *gin.RouterGroup) {
customRouter := apiV1Router.Group("/custom")
{
// 挂载到 /api/v1/custom 下,例如:POST /api/v1/custom/action
customRouter.POST("/action", custom.DoActionHandler)
}
}
```
*(注:该函数已由 `v1/v1.go` 自动加载,你无需修改任何其他核心文件。)*
`custom.go` 里现有的 `/api/v1/custom/...` **仅作脚手架演示**,不代表业务必须挂在 `/custom` 下。
---
## 建议创建/修改的文件结构 (Recommended Directory Structure)
## 推荐目录结构(产品业务)
当新增一套定制的业务接口(例如名为 `custom` 的业务模块)时,建议采用以下标准文件结构:
以「频道 / channel」域为例(消息平台中的一个限界上下文):
```text
internal/
├── router/
│ ├── root/
│ │ └── custom.go # [修改] 若为根路径 API,在此处注册,将路由委派给 apps/custom
│ └── v1/
│ └── custom.go # [修改] 若为 v1 API,在此处注册,将路由委派给 apps/custom
│ ├── v1.go # [修改] 调用 RegisterChannelRoutes
│ └── channel.go # [新建] 只负责挂载 channel 路由
└── apps/
└── custom/
├── routers.go # [新建] HTTP Handlers (Gin),负责参数绑定、校验与响应
├── logics.go # [新建] 业务逻辑层:承载模块内闭环的纯 Go 业务逻辑,不依赖 gin.Context
└── errs.go # [新建] 存放模块特有的业务错误常量定义(可选)
└── channel/ # 与 oauth、user、upload 平级
├── routers.go # HTTP Handlers(绑定、鉴权上下文、响应)
├── logics.go # 纯业务:context.Context,无 gin
├── errs.go # 模块错误文案常量(可选)
└── ... # 需要时再加 service.go、tasks.go 等
```
---
**不要**建成:
## 核心开发步骤 (Step-by-Step Flow)
```text
internal/apps/message/ # ❌ 产品伞包
channel/
inbox/
internal/apps/custom/ # ❌ 示例包当业务垃圾桶
channel_handler.go
```
### 步骤 1:数据库定义与迁移
如果自定义功能涉及新表或字段,请参考 [database-migration](../database-migration/SKILL.md) 技能,在 `internal/infra/persistence/migrator/goose/` 目录下编写迁移文件,在 `internal/model/` 中定义 GORM 实体(无 CRUD / 无 DB 访问),并在 `internal/repository/` 中实现数据访问(**repository 为唯一持久化入口**)。
### 步骤 2:在模块内实现业务逻辑 (`logics.go` / `service.go`)
业务逻辑逻辑应当实现于 `internal/apps/custom/` 目录下:
- **优先使用纯函数(`logics.go`)**:定义接收 `context.Context` 且不依赖 `*gin.Context` 的函数,易于单元测试与 Worker 复用。参考 `internal/apps/user/logics.go`。
- **有状态服务(`service.go`)**:若需注入依赖(如 DB 连接、外部客户端等),可定义 Service 结构体和构造函数。
- **跨模块副作用(推送、任务监听等)**:核心业务代码通过 `internal/listener` 发射域事件,禁止直接 `import` push 模块;装配在 `internal/platform/bootstrap` 完成(参见 `push-notification` skill)。
### 步骤 3:编写 HTTP Handler (`routers.go`)
在 `internal/apps/custom/routers.go` 中编写 Handler:
- 负责请求参数绑定与校验(使用 `ShouldBindJSON`/`ShouldBindQuery`)。
- 负责提取 Session / 用户身份。
- 调用业务逻辑层,并使用 `github.com/Rain-kl/Wavelet/internal/shared/response` 统一返回响应:
- 成功时返回:`response.OK(data)` 或 `response.OKNil()`
- 失败时返回:`response.Err(msg)`
- 编写规范的 Swagger 注释。
### 步骤 4:在自定义包中注册路由并委派
根据 **路由归属判定表**,在 [root/custom.go](file:///Users/ryan/DEV/Go/Wavelet/internal/router/root/custom.go) 或 [v1/custom.go](file:///Users/ryan/DEV/Go/Wavelet/internal/router/v1/custom.go) 中编写注册代码,将路由路径绑定到步骤 3 中编写的 Handler。
模块内若复杂度高,可在**该域包内**分子目录(如 `apps/channel/handler`),但仍是一个域包,不是「产品名/子域」两层品牌结构。
---
## 质量验证门禁 (Quality Gates)
## 路由注册示例
每次新增或修改接口后,必须运行并验证以下各项:
1. **自动授权许可**:`make license`(新增 Go 文件时自动添加许可头)
2. **重新生成 Swagger 文档**:`make swagger`(若有 Swagger 注释修改)
3. **静态代码及风格检查**:`make code-check`(确保通过 golangci-lint 和前端 TS 检查)
4. **自动化单元测试**:`go test ./...`(确保所有测试 100% 通过)
### `internal/router/v1/channel.go`(产品业务)
```go
package v1
import (
"github.com/Rain-kl/Wavelet/internal/apps/channel"
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
"github.com/gin-gonic/gin"
)
// RegisterChannelRoutes mounts channel domain APIs under /api/v1.
func RegisterChannelRoutes(apiV1Router *gin.RouterGroup) {
r := apiV1Router.Group("/channels")
r.Use(oauth.LoginRequired())
{
r.GET("", channel.ListChannels)
r.POST("", channel.CreateChannel)
r.GET("/:id", channel.GetChannel)
}
}
```
### `internal/router/v1/v1.go`(增加一行委派)
```go
func RegisterV1Routes(apiV1Router *gin.RouterGroup, apiGroup *gin.RouterGroup) {
RegisterUserRoutes(apiV1Router, apiGroup)
RegisterAdminRoutes(apiV1Router)
RegisterChannelRoutes(apiV1Router) // 产品域
RegisterCustomRoutes(apiV1Router) // 可选:仅保留脚手架示例
}
```
### 根路径 Webhook(确有需要时)
在 `root` 用语义路径,例如 `POST /webhooks/stripe`,注册函数可放在 `root/webhooks.go` 或扩展现有 root 注册;**不要**为了「只能写 custom」而使用无意义的 `/custom` 前缀。
---
## 核心开发步骤
### 步骤 1:划定域包名
- 用**业务能力**命名:`channel`、`order`、`invoice`。
- 与现有 `apps/` 下平台包平级;禁止产品伞包。
### 步骤 2:库表与 model
若涉及新表/字段:按 [database-migration](../database-migration/SKILL.md) 在 goose 迁移与 `internal/model/` 中定义。
### 步骤 3:`logics.go` / `service.go`
放在 `internal/apps/<domain>/`:
- **优先**纯函数 `logics.go`:`context.Context` 入参,无 `*gin.Context`。
- 有状态依赖时用 `service.go` 构造注入。
- 跨模块副作用(推送、任务)经 `internal/listener` + `bootstrap`,禁止业务直接 import push(见 `push-notification`)。
### 步骤 4:Handler(`routers.go`)
- `ShouldBindJSON` / `ShouldBindQuery`。
- 成功:`c.JSON(http.StatusOK, response.OK(data))` 或 `response.OKNil()`。
- 失败:`response.AbortBadRequest` / `AbortUnauthorized` / `AbortNotFound` / `AbortInternal` 等,**禁止** `response.Err` 直接 `c.JSON`。
- 完整 Swagger 注释;`@Router` 使用真实语义路径。
参考:`references/handler_example.go`、`logics_example.go`、`service_example.go`(示例域名,非强制包名 `custom`)。
### 步骤 5:注册路由
新建 `internal/router/v1/<domain>.go`,在 `v1.go` 调用;管理端按需挂到 admin 组。
---
## 与平台路由的边界
- **扩展平台能力**(用户资料字段、上传策略、OAuth 源):改对应平台 `apps/*` 与 `user.go`/`admin.go`。
- **新产品功能**:新建 `apps/<domain>` + `router/v1/<domain>.go`,**不要**塞进 `custom` 或某个无关平台包。
- 管理端产品配置页 API:路径宜为 `/api/v1/admin/<domain>/...`,中间件与现有 admin 组一致。
---
## 质量验证门禁
1. `make license`(新 Go 文件许可头)
2. `make swagger`(Handler/Swagger 有变时)
3. `make format` 与 `make code-check`
4. `go test` 覆盖相关包
---
## 自检清单
- [ ] 未把真实业务堆进 `apps/custom` 或 `v1/custom.go`
- [ ] 未创建 `apps/<产品名>/` 伞包再塞子域
- [ ] 业务包与 `oauth`/`user`/`upload` 平级,路径语义化(非强制 `/custom`)
- [ ] 路由在 `router/v1/<domain>.go`(或 admin 对应处)注册,并由 `v1.go` 委派
- [ ] Handler 用 `response.Abort*` / `response.OK`,logics 不依赖 gin
- [ ] 需要时已跑 swagger / code-check
@@ -6,53 +6,50 @@ package references
import (
"net/http"
"github.com/Rain-kl/Wavelet/internal/service"
"github.com/Rain-kl/Wavelet/internal/util"
"github.com/Rain-kl/Wavelet/internal/shared/response"
"github.com/gin-gonic/gin"
)
// customRequest 客户端请求体 DTO
type customRequest struct {
Payload string `json:"payload" binding:"required,min=1,max=100"`
// createChannelRequest 客户端请求体 DTO
type createChannelRequest struct {
Name string `json:"name" binding:"required,min=1,max=100"`
}
// customResponse API 响应体 DTO
type customResponse struct {
Result string `json:"result"`
// createChannelResponse API 响应体 DTO
type createChannelResponse struct {
ID int64 `json:"id"`
Name string `json:"name"`
}
// HandleCustomBusiness 示例 API Handler
// @Summary 示例定制业务接口
// @Description 接收数据载荷,调用 Service 执行核心逻辑,并返回统一格式的 JSON 结果。
// @Tags custom
// CreateChannel 示例:产品域 Handler(应放在 internal/apps/channel/routers.go)
// @Summary 创建频道
// @Description 示例:语义路径下的业务接口,而非 /api/v1/custom/...
// @Tags channel
// @Accept json
// @Produce json
// @Param request body customRequest true "业务请求参数"
// @Success 200 {object} util.ResponseAny{data=customResponse} "操作成功"
// @Router /api/v1/custom/business [post]
func HandleCustomBusiness(c *gin.Context) {
// 1. 参数绑定与校验
var req customRequest
// @Param request body createChannelRequest true "业务请求参数"
// @Success 200 {object} response.Any{data=createChannelResponse} "操作成功"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Router /api/v1/channels [post]
func CreateChannel(c *gin.Context) {
var req createChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err("参数校验失败:载荷不能为空且在 1-100 字符内"))
response.AbortBadRequest(c, "参数校验失败")
return
}
// 2. 模拟获取当前上下文与已登录用户(例如从 Session 中提取)
// 通常结合 oauth.LoginRequired() 等中间件使用
// 通常结合 oauth.LoginRequired();此处仅演示从上下文取用户
userID := int64(9527)
// 3. 实例化业务 Service 并调用核心逻辑
// 注意传入 c.Request.Context() 以正确传递 OpenTelemetry Tracing 等上下文信息
svc := service.NewCustomService()
resText, err := svc.ProcessBusinessData(c.Request.Context(), userID, req.Payload)
result, err := CreateChannelLogic(c.Request.Context(), userID, req.Name)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
response.AbortBadRequest(c, err.Error())
return
}
// 4. 返回符合外层形状规范 { "error_msg": "", "data": ... } 的统一成功响应
c.JSON(http.StatusOK, util.OK(customResponse{
Result: resText,
c.JSON(http.StatusOK, response.OK(createChannelResponse{
ID: result.ID,
Name: result.Name,
}))
}
@@ -12,21 +12,27 @@ import (
"go.uber.org/zap"
)
// ProcessLocalBusiness 示例的模块内部闭环业务逻辑
// 1. 存放在 apps/custom/logics.go 下,遵循纯 Go 规范,不强依赖 gin.Context,以便逻辑清晰和便于单元测试。
// 2. 用于当前应用模块内的简单业务或通用过程。
func ProcessLocalBusiness(ctx context.Context, userID int64, param string) (string, error) {
if param == "" {
return "", errors.New("param cannot be empty")
// channelCreated 示例 logics 返回值(真实代码可用 model 或专用 DTO)
type channelCreated struct {
ID int64
Name string
}
// CreateChannelLogic 示例:模块内闭环业务(放在 apps/channel/logics.go)
// 接收 context.Context,不依赖 gin.Context,便于单测与 Worker 复用。
func CreateChannelLogic(ctx context.Context, userID int64, name string) (*channelCreated, error) {
if name == "" {
return nil, errors.New("name cannot be empty")
}
logger.Info(ctx, "processing local business inside apps/custom/logics",
logger.Info(ctx, "creating channel",
zap.Int64("user_id", userID),
zap.String("param", param),
zap.String("name", name),
)
// 执行轻量级、无需跨模块/多入口复用的本地计算或模型操作
result := fmt.Sprintf("Processed local logic for user %d: %s", userID, param)
return result, nil
// 轻量级本地逻辑;复杂持久化可进 model/repository
return &channelCreated{
ID: 1,
Name: fmt.Sprintf("%s (by %d)", name, userID),
}, nil
}
@@ -12,34 +12,29 @@ import (
"go.uber.org/zap"
)
// CustomService 示例业务 Service 结构体(通常放在 internal/apps/custom/service.go 中)
type CustomService struct {
// 这里可以注入数据库连接、配置对象或者其他基础服务的客户端
// 例如:db *gorm.DB
// ChannelService 示例有状态 Service(放在 internal/apps/channel/service.go)
// 需要注入 DB/客户端时使用;简单逻辑优先 logics.go 纯函数。
type ChannelService struct {
// 例如:repo ChannelRepository
}
// NewCustomService 创建 CustomService 实例的构造函数
func NewCustomService() *CustomService {
return &CustomService{}
// NewChannelService 构造函数
func NewChannelService() *ChannelService {
return &ChannelService{}
}
// ProcessBusinessData 演示核心业务处理逻辑的 Service 方法
// 1. 首位参数必须是 context.Context,以传播链路追踪 (OTel) 和超时控制。
// 2. 方法签名应该只包含纯 Go 的参数与返回值,禁止导入 Gin 或与 HTTP 相关的协议依赖。
// 3. 将可能发生的核心异常通过 error 返回给上层,而不是在这一层转换成 HTTP 状态码。
func (s *CustomService) ProcessBusinessData(ctx context.Context, userID int64, payload string) (string, error) {
if payload == "" {
return "", errors.New("payload cannot be empty")
// Create 核心业务:首位参数必须是 context.Context;禁止依赖 Gin。
func (s *ChannelService) Create(ctx context.Context, userID int64, name string) (int64, error) {
if name == "" {
return 0, errors.New("name cannot be empty")
}
// 模拟执行业务逻辑...
logger.Info(ctx, "processing custom business data in service",
logger.Info(ctx, "channel service create",
zap.Int64("user_id", userID),
zap.String("payload", payload),
zap.String("name", name),
)
// 这里可以包含数据库读写、事务控制、或者远程 API 调用等复杂逻辑。
result := fmt.Sprintf("Success processed data for user %d: %s", userID, payload)
return result, nil
// DB 事务、远程调用等
_ = fmt.Sprintf("user=%d name=%s", userID, name)
return 1, nil
}
+2 -3
View File
@@ -19,8 +19,7 @@ description: "Wavelet 项目专用:新增或修改 Asynq 异步任务、后台
- `internal/infra/task/worker/worker.go`:Worker 路由和队列
- `internal/infra/task/scheduler/scheduler.go`:定时调度
- `internal/apps/admin/task/routers.go`:Admin 任务 API
- `internal/model/task_execution.go`:执行记录实体与 DTO
- `internal/repository/task_execution.go`:执行记录和日志持久化
- `internal/model/task_execution.go`:执行记录和日志持久化
需要模板时阅读 [references/CODE-EXAMPLES.md](references/CODE-EXAMPLES.md)。
@@ -43,7 +42,7 @@ description: "Wavelet 项目专用:新增或修改 Asynq 异步任务、后台
- 成功返回 `&task.TaskResult{Message: ..., Detail: ...}`。
- 失败返回 error,由任务框架处理状态和重试。
- 不要吞掉关键错误。
- 持久化只通过 `internal/repository/`(唯一入口);业务编排放模块内 `logics.go` / `service.go`。`internal/model` 仅实体/DTO,禁止 CRUD 与 DB 访问。
- 复杂 SQL 放到 `internal/model/` 或模块内的业务服务层(如 `internal/apps/<module>/service.go` 或 `logics.go`)。
### 注册
@@ -10,7 +10,7 @@
package upload
import (
"github.com/Rain-kl/Wavelet/internal/infra/task"
"github.com/Rain-kl/Wavelet/internal/task"
)
// 异步任务类型标识。格式建议为 "{module}:{action}"。
@@ -83,7 +83,7 @@ package upload
import (
"context"
"github.com/Rain-kl/Wavelet/internal/infra/task"
"github.com/Rain-kl/Wavelet/internal/task"
)
type CleanupUnusedUploadsHandler struct{}
@@ -114,7 +114,7 @@ import (
"fmt"
"strings"
"github.com/Rain-kl/Wavelet/internal/infra/task"
"github.com/Rain-kl/Wavelet/internal/task"
)
type SendEmailPayload struct {
@@ -171,7 +171,7 @@ package handlers
import (
"github.com/Rain-kl/Wavelet/internal/apps/upload"
"github.com/Rain-kl/Wavelet/internal/apps/user"
"github.com/Rain-kl/Wavelet/internal/infra/task"
"github.com/Rain-kl/Wavelet/internal/task"
)
func Register() {
+9 -11
View File
@@ -14,7 +14,7 @@ description: "Wavelet 项目专用:当新增或修改启动时设置、数据
Wavelet 当前有两套设置入口:
- 启动时设置:来自 `config.yaml` 或环境变量,适合进程启动前必须确定、通常不热更新的基础配置。
- 系统设置:保存于数据库 `system_configs`,经 `model.SystemConfig` 实体(key 常量在 model)与 `repository` 读取层(含 Redis hash 缓存)访问,支持运行时热更新。管理入口是 `/admin/system` 和 `/admin/settings`。
- 系统设置:保存于数据库 `system_configs`,经 `model.SystemConfig` 和 Redis hash 缓存读取,支持运行时热更新。管理入口是 `/admin/system` 和 `/admin/settings`。
系统设置分三种使用语义:
@@ -30,8 +30,7 @@ Wavelet 当前有两套设置入口:
修改前快速查看这些文件,确认当前实现没有漂移:
- `internal/model/system_configs.go`: 配置 key 常量(`ConfigKey*`)、`SystemConfig` 实体与字段语义;**不含**持久化读取 API。
- `internal/repository/system_config.go`: 配置读取与缓存(`GetSystemConfigByKey`、`GetBoolByKey`、`GetIntByKey`、`GetDecimalByKey`、`ListVisibleSystemConfigs` 等)。
- `internal/model/system_configs.go`: 配置 key 常量、`SystemConfig` 模型、`GetByKey`、`GetBoolByKey`、`GetIntByKey`、`GetDecimalByKey` 等读取方法。
- `internal/infra/persistence/migrator/goose/postgres/*.sql` 和 `internal/infra/persistence/migrator/goose/sqlite/*.sql`: `system_configs` 表结构、初始化 seed、后续升级迁移。
- `internal/infra/persistence/migrator/migrator.go`: goose 迁移入口和 PostgreSQL/SQLite 方言选择。
- `internal/testhelper/test_helper.go`: Go 测试用默认系统配置 seed。
@@ -62,13 +61,12 @@ Wavelet 当前有两套设置入口:
- 如果相关 Go 包测试依赖默认配置,同步 `internal/testhelper/test_helper.go` 的 `seedDefaultConfigs` 和公共 key 列表。
3. 读取配置。
- 后端业务代码通过 `internal/repository` 读取:`repository.GetBoolByKey`、`repository.GetIntByKey`、`repository.GetDecimalByKey` 或 `repository.GetSystemConfigByKey`;key 常量仍用 `model.ConfigKey*`。
- 禁止新增或调用 `model.Get*ByKey` / `model.ListVisibleSystemConfigs` 等数据访问 API(model 无 CRUD)。
- 后端业务代码优先使用 `model.GetBoolByKey`、`model.GetIntByKey`、`model.GetDecimalByKey` 或 `SystemConfig.GetByKey`。
- 运行时可热更新的规则不要放进 `config.Config`;启动时设置才走 `internal/infra/config/model.go` 和 `config.example.yaml`。
- 不要在 handler 或业务代码里直接读 `os.Getenv()`。
4. 如果前端需要未登录或全局消费,暴露为公共可见配置。
- 把该配置的 `visibility` 设为 `1`,`GetPublicConfig` 会通过 `repository.ListVisibleSystemConfigs` 返回所有可见 key/value。
- 把该配置的 `visibility` 设为 `1`,`GetPublicConfig` 会通过 `model.ListVisibleSystemConfigs` 返回所有可见 key/value。
- `/api/v1/config/public` 的 `data` 是动态对象:后端返回 `map[string]string`,前端类型是 `Record<string, string | undefined>`。
- 前端读取时按配置 key 访问,必要时在消费侧把字符串转换为 boolean/number/JSON。
- 检查使用方的 query key,更新后需要 invalidate `["public-config"]`。
@@ -101,9 +99,9 @@ Wavelet 当前有两套设置入口:
### 布尔公共设置
- model key:`ConfigKeyFeatureEnabled = "feature_enabled"`(定义在 `internal/model`)
- model key:`ConfigKeyFeatureEnabled = "feature_enabled"`
- goose SQL 默认值:`value='false'`,`type` 按语义选 `"system"` 或 `"business"`,`visibility=1`。
- 后端读取:`repository.GetBoolByKey(ctx, model.ConfigKeyFeatureEnabled)`。
- 后端读取:`model.GetBoolByKey(ctx, model.ConfigKeyFeatureEnabled)`。
- 公共响应:`/api/v1/config/public` 的 `data.feature_enabled` 为字符串 `"true"` 或 `"false"`。
- 前端图形控件:`Switch`,保存时写 `"true"` / `"false"`。
@@ -111,13 +109,13 @@ Wavelet 当前有两套设置入口:
- model key:`ConfigKeyMaxSomething = "max_something"`。
- goose SQL 默认值:例如 `"5"`,`type` 通常为 `"business"`,只有前端公共消费时才设 `visibility=1`。
- 后端读取:`repository.GetIntByKey` 或 `repository.GetDecimalByKey`。
- 后端读取:`model.GetIntByKey` 或 `model.GetDecimalByKey`。
- 前端图形控件:`Input type="number"` 或合适的 shadcn 数值控件;保存前做最小必要校验,错误用 toast。
### JSON 设置
- 默认值使用合法 JSON,例如 `"{}"` 或 `"[]"`。
- 在 repository 或业务 logics 中提供解析函数,像 `repository.GetMenuDisplayConfig` 一样把 JSON 解析错误包装成清晰错误;不要在 model 中做 IO。
- 在 model 或 service 层提供解析函数,像 `GetMenuDisplayConfig` 一样把 JSON 解析错误包装成清晰错误。
- 前端不要直接拼接 JSON 字符串;用 `JSON.stringify` 写入,用类型化对象在组件中操作。
## 验证
@@ -127,7 +125,7 @@ Wavelet 当前有两套设置入口:
- 新增或修改系统配置默认值、visibility 或公共配置读取:至少运行相关 Go 包测试,例如:
```bash
go test ./internal/repository ./internal/apps/config ./internal/apps/admin/system_config
go test ./internal/model ./internal/apps/config ./internal/apps/admin/system_config
```
- 新增 goose 迁移后,至少用当前数据库方言跑一次迁移;如果 SQL 同时改了 PostgreSQL 和 SQLite,尽量覆盖两种方言。涉及 schema/seed 的任务还应遵循 database-migration skill。
+19 -11
View File
@@ -1,13 +1,13 @@
---
name: "release-guide"
description: "Wavelet 项目专用:根据自上一个正式版本 Tag 以来的提交记录,整理生成规范的 Version Bump Commit Message,用于触发自动双语 Release。"
description: "项目专用:根据自上一个正式版本 Tag 以来的提交记录,整理生成规范的 Version Bump Commit Message,用于触发自动双语 Release。"
---
# Release Commit Message Guide
## 目标
当用户准备发布 Wavelet 新版本时,本 Skill 负责:
当用户准备发布新版本时,本 Skill 负责:
1. 根据上一正式版本 Tag 以来的提交,整理面向用户的发版说明;
2. 新建 **独立的** `chore(release): vX.Y.Z` 提交(可附带将 `docs/changelog` 从 `[unreleased]` 落版)。
@@ -28,16 +28,14 @@ description: "Wavelet 项目专用:根据自上一个正式版本 Tag 以来
1. 合并重复或相近提交。
2. 删除无意义提交,例如格式化、临时调试、无关重构。
3. 将内部实现描述改写为用户可理解的变更。
4. 每条使用完整中文句子。
5. 尽量说明“修复/优化了什么”以及“带来的效果”。
6. 不要编造 commit log 中没有的信息。
7. 不要加入 token、密钥、私有地址等敏感信息。
8. 如果某个分类没有内容,可以省略。
3. 将内部实现描述改写为用户可理解的变更, 说明“修复/优化了什么”以及“带来的效果”。
4. 不要写技术细节:只描述用户可感知的行为与效果,禁止内部实现描述,例如字段名/表名/SQL(`node_id = ''`)、框架或库名称(shadcn、GORM、OpenResty)、配置或协议细节(RFC3339、ClickHouse/PostgreSQL 差异)、代码机制(`proxy_intercept_errors`、Lua 过滤器、雪花 ID)。数据库名称仅在说明受影响用户范围时使用(如「PostgreSQL 日志库下无数据」)。
5. 如果某个分类没有内容,则省略。
固定使用以下分类:
```text
### ✨ 新功能
### 🛠 修复
### ⚡️ 优化与改进
### 💄 其他/体验
@@ -45,19 +43,29 @@ description: "Wavelet 项目专用:根据自上一个正式版本 Tag 以来
分类规则:
- 新功能、新能力、新配置、新任务:放入 ### ✨ 新功能
- Bug、异常行为、错误逻辑:放入 ### 🛠 修复
- 性能、稳定性、接口、架构、兼容性:放入 ### ⚡️ 优化与改进
- 日志、文案、UI、文档、开发体验:放入 ### 💄 其他/体验
「修复/优化」与「新增」的判定(关键):
- **判定标准是“该功能在上一正式版本中是否已存在”**:
- 已存在 → 本次对其 bug 的修正可计入「🛠 修复」,对其行为/性能的改进可计入「⚡️ 优化与改进」;
- 不存在(本版本新增)→ 该功能的一切内容——包括开发过程中修的 bug、做的性能优化、补的索引——都只属于新功能开发的一部分,不应该在发布说明中提及。
- 禁止把新功能的开发期修复/优化写进「修复」或「优化」:新功能此前版本没有,谈不上“修复/优化了旧行为”。
示例:
```
chore(release): v3.3.0
### ✨ 新功能
- 新增笔记库快照备份功能,支持定时备份与手动一键恢复(仅说明新增的功能, 禁止提及新功能开发时期的优化修复等内容)。
### 🛠 修复
- 修复了通过 MCP 接口操作时笔记库范围限制未正确生效的问题。
- 修复了 MCP 接口返回数据格式不一致的问题。
- 修复了 WebSocket 客户端异常断开后僵尸连接未及时清理的问题。
- 修复首页「来源分布」卡片在 PostgreSQL/SQLite 日志库下无数据的问题。
- 修复源站错误页「仅针对 GET 请求」未真正透传非 GET 响应的问题:POST/PUT 等非 GET 请求现可完整看到源站原始报错内容。
### ⚡️ 优化与改进
- 优化了 WebGUI 登录机制,引入设备令牌自动轮转,减少因 IP 变化产生的冗余令牌。
Symlink
+1
View File
@@ -0,0 +1 @@
.agents
+9 -14
View File
@@ -1,27 +1,21 @@
.git
.idea
.vscode
.github
anubis-source
**/node_modules
**/.next
**/build
**/dist
**/.cache
**/coverage
**/*.db
**/*.log
tmp
logs
.DS_Store
Thumbs.db
config.yaml
.env
.env.*
docker-compose*.yml
config.yaml
bin/
build/
dist/
data/
logs/
uploads/
s3_cache/
frontend/node_modules/
frontend/.next/
frontend/out/
@@ -31,5 +25,6 @@ frontend/.env
frontend/next-env.d.ts
frontend/*.tsbuildinfo
frontend/package-lock.json
internal/router/dist/
internal/router/root/dist/
-19
View File
@@ -1,19 +0,0 @@
root = true
[*]
indent_style = space
indent_size = 4
charset = utf-8
end_of_line = lf
trim_trailing_whitespace = true
insert_final_newline = true
[*.{json,yml,yaml}]
indent_size = 2
[*.md]
insert_final_newline = false
trim_trailing_whitespace = false
[*.{js,ts,css,html,jsx,tsx,vue}]
indent_size = 2
+18 -22
View File
@@ -1,5 +1,5 @@
# ──────────────────────────────────────────────────────────────────────────────
# openflare — 环境变量配置模板
# wavelet — 环境变量配置模板
# 复制此文件为 .env 并填入实际值: cp .env.example .env
# 环境变量优先级高于 config.yaml
# docker compose 会读取本文件(env_file: .env)并替换 compose 中的 ${VAR}
@@ -9,13 +9,13 @@
TZ=Asia/Shanghai
# ─── 应用配置 ──────────────────────────────────────────────────────────────────
APP_NAME=openflare
APP_NAME=wavelet
APP_ENV=production
APP_ADDR=:3000
APP_ADDR=:8000
APP_NODE_ID=1
APP_API_PREFIX=/api
# APP_GRACEFUL_SHUTDOWN_TIMEOUT=30
APP_SESSION_COOKIE_NAME=openflare_session_id
APP_SESSION_COOKIE_NAME=wavelet_session_id
APP_SESSION_SECRET=change-me-to-a-random-string-in-production
# APP_SESSION_DOMAIN=
APP_SESSION_AGE=86400
@@ -27,13 +27,12 @@ APP_SESSION_SECURE=true
# 设置 DB_HOST 后自动启用 PostgreSQL,也可通过 DB_ENABLED 显式控制
# DB_ENABLED=false 时使用 SQLite 作为后备数据库
DB_ENABLED=true
# SQLITE_PATH=./data/openflare.db
# compose 内应用连服务名;本机直连 Docker 映射端口时用 127.0.0.1
# SQLITE_PATH=./data/wavelet.db
DB_HOST=postgres
DB_PORT=5432
DB_USERNAME=openflare
DB_PASSWORD=replace-with-strong-password
DB_NAME=openflare
DB_USERNAME=postgres
DB_PASSWORD=postgres
DB_NAME=wavelet
DB_SSL_MODE=disable
DB_TIMEZONE=Asia/Shanghai
# DB_LOG_LEVEL=info
@@ -47,23 +46,20 @@ REDIS_ADDR=redis:6379
# REDIS_USERNAME=
# REDIS_PASSWORD=
# REDIS_DB=0
REDIS_KEY_PREFIX=openflare:
REDIS_KEY_PREFIX=wavelet:
# REDIS_POOL_SIZE=100
# 启动时开关;修改后需重启服务
REDIS_MAINT_NOTIFICATIONS=false
# compose 宿主机映射端口(仅 docker-compose 使用)
# REDIS_PORT=6379
# ─── ClickHouse(必需)────────────────────────────────────────────────────────
# CLICKHOUSE_HOST 设置后会自动启用;测试环境可显式 CLICKHOUSE_ENABLED=true 做 live 联调
CLICKHOUSE_ENABLED=true
# compose 内:clickhouse:9000;本机连映射端口:127.0.0.1:9000
CLICKHOUSE_HOST=clickhouse:9000
CLICKHOUSE_USERNAME=default
# 须与 compose clickhouse 服务密码一致(首次初始化后改密码需清 data/clickhouse_data)
CLICKHOUSE_PASSWORD=replace-with-clickhouse-password
CLICKHOUSE_NAME=openflare
# ─── ClickHouse(可选,默认关闭)──────────────────────────────────────────
# 设置 CLICKHOUSE_HOST 后自动启用,也可显式控制
# CLICKHOUSE_ENABLED=false
# CLICKHOUSE_HOST=clickhouse:9000
# CLICKHOUSE_USERNAME=default
# CLICKHOUSE_PASSWORD=
# CLICKHOUSE_NAME=wavelet
# ─── 日志 ──────────────────────────────────────────────────────────────────────
LOG_LEVEL=info
@@ -76,8 +72,8 @@ OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4317
OTEL_EXPORTER_OTLP_INSECURE=true
# 设为 0 关闭 tracing;本地 Jaeger 调试建议设为 1.0
OTEL_SAMPLING_RATE=0.0
# 全局 Tracer 命名空间,默认为 github.com/Rain-kl/OpenFlare
# OTEL_TRACER_NAME=github.com/Rain-kl/OpenFlare
# 全局 Tracer 命名空间,默认为 github.com/Rain-kl/Wavelet
# OTEL_TRACER_NAME=github.com/Rain-kl/Wavelet
# compose 可选端口覆盖
# JAEGER_VERSION=2.19.0
# JAEGER_UI_PORT=16686
-1
View File
@@ -1 +0,0 @@
* -text
+1 -1
View File
@@ -12,7 +12,7 @@
- 新增功能时考虑向后兼容性和 API 稳定性
- 遵循项目的 Apache2.0 许可证要求
- 遵循语义化版本控制规范
- 新增异步任务时使用项目技能 `.agent/new-async-task/SKILL.md`
- 新增异步任务时使用项目技能 `.agents/new-async-task/SKILL.md`
## 后端规范
@@ -1,197 +0,0 @@
name: Build Image (openflare-agent)
on:
workflow_dispatch:
inputs:
version:
description: "Image version/tag to publish, for example v1.0.0-beta"
required: false
type: string
push:
tags: ["v*"]
permissions:
contents: read
packages: write
attestations: write
id-token: write
env:
IMAGE_NAME: openflare-agent
DOCKERFILE: docker/Dockerfile.agent
jobs:
build:
name: Build (${{ matrix.arch }})
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: arm64
platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-tags: true
fetch-depth: 0
persist-credentials: false
- name: Set image metadata
shell: bash
env:
INPUT_VERSION: ${{ github.event.inputs.version }}
run: |
POINTED_TAG="$(git tag --points-at HEAD --list 'v*' | sort -V | tail -n1)"
INPUT_VERSION="${INPUT_VERSION//[[:space:]]/}"
OWNER="${GITHUB_REPOSITORY_OWNER,,}"
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
VERSION="${GITHUB_REF_NAME}"
elif [[ -n "$INPUT_VERSION" ]]; then
VERSION="$INPUT_VERSION"
elif [[ -n "$POINTED_TAG" ]]; then
VERSION="$POINTED_TAG"
else
echo "workflow_dispatch requires an explicit version input when HEAD is not tagged" >&2
exit 1
fi
echo "IMAGE=ghcr.io/${OWNER}/openflare-agent" >> "$GITHUB_ENV"
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log into registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
id: build
uses: docker/build-push-action@v7
with:
context: .
file: ${{ env.DOCKERFILE }}
platforms: ${{ matrix.platform }}
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
build-args: |
VERSION=${{ env.VERSION }}
cache-from: type=gha,scope=docker-${{ env.IMAGE_NAME }}-${{ matrix.arch }}
cache-to: type=gha,mode=max,ignore-error=true,timeout=20m,scope=docker-${{ env.IMAGE_NAME }}-${{ matrix.arch }}
- name: Export digest
shell: bash
run: |
mkdir -p "/tmp/${{ env.IMAGE_NAME }}-digests"
touch "/tmp/${{ env.IMAGE_NAME }}-digests/${DIGEST#sha256:}"
env:
DIGEST: ${{ steps.build.outputs.digest }}
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: ${{ env.IMAGE_NAME }}-digests-${{ matrix.arch }}
path: /tmp/${{ env.IMAGE_NAME }}-digests/*
if-no-files-found: error
retention-days: 1
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v3
with:
subject-name: ${{ env.IMAGE }}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
merge:
name: Merge multi-arch manifest
runs-on: ubuntu-24.04
needs: build
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-tags: true
fetch-depth: 0
persist-credentials: false
- name: Set image metadata
shell: bash
env:
INPUT_VERSION: ${{ github.event.inputs.version }}
run: |
POINTED_TAG="$(git tag --points-at HEAD --list 'v*' | sort -V | tail -n1)"
INPUT_VERSION="${INPUT_VERSION//[[:space:]]/}"
OWNER="${GITHUB_REPOSITORY_OWNER,,}"
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
VERSION="${GITHUB_REF_NAME}"
elif [[ -n "$INPUT_VERSION" ]]; then
VERSION="$INPUT_VERSION"
elif [[ -n "$POINTED_TAG" ]]; then
VERSION="$POINTED_TAG"
else
echo "workflow_dispatch requires an explicit version input when HEAD is not tagged" >&2
exit 1
fi
echo "IMAGE=ghcr.io/${OWNER}/openflare-agent" >> "$GITHUB_ENV"
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/${{ env.IMAGE_NAME }}-digests
pattern: ${{ env.IMAGE_NAME }}-digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log into registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create and push manifest list
working-directory: /tmp/${{ env.IMAGE_NAME }}-digests
shell: bash
run: |
shopt -s nullglob
references=()
for digest in *; do
references+=("${IMAGE}@sha256:${digest}")
done
if [ ${#references[@]} -eq 0 ]; then
echo "No digests found in /tmp/${{ env.IMAGE_NAME }}-digests" >&2
exit 1
fi
if [[ "${VERSION}" =~ (alpha|beta|rc) ]]; then
FLOATING_TAG="beta"
else
FLOATING_TAG="latest"
fi
docker buildx imagetools create \
-t "${IMAGE}:${VERSION}" \
-t "${IMAGE}:${FLOATING_TAG}" \
"${references[@]}"
env:
IMAGE: ${{ env.IMAGE }}
- name: Inspect image
run: docker buildx imagetools inspect "${{ env.IMAGE }}:${{ env.VERSION }}"
@@ -1,197 +0,0 @@
name: Build Image (openflare-relay)
on:
workflow_dispatch:
inputs:
version:
description: "Image version/tag to publish, for example v1.0.0-beta"
required: false
type: string
push:
tags: ["v*"]
permissions:
contents: read
packages: write
attestations: write
id-token: write
env:
IMAGE_NAME: openflare-relay
DOCKERFILE: docker/Dockerfile.relay
jobs:
build:
name: Build (${{ matrix.arch }})
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: arm64
platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-tags: true
fetch-depth: 0
persist-credentials: false
- name: Set image metadata
shell: bash
env:
INPUT_VERSION: ${{ github.event.inputs.version }}
run: |
POINTED_TAG="$(git tag --points-at HEAD --list 'v*' | sort -V | tail -n1)"
INPUT_VERSION="${INPUT_VERSION//[[:space:]]/}"
OWNER="${GITHUB_REPOSITORY_OWNER,,}"
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
VERSION="${GITHUB_REF_NAME}"
elif [[ -n "$INPUT_VERSION" ]]; then
VERSION="$INPUT_VERSION"
elif [[ -n "$POINTED_TAG" ]]; then
VERSION="$POINTED_TAG"
else
echo "workflow_dispatch requires an explicit version input when HEAD is not tagged" >&2
exit 1
fi
echo "IMAGE=ghcr.io/${OWNER}/openflare-relay" >> "$GITHUB_ENV"
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log into registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
id: build
uses: docker/build-push-action@v7
with:
context: .
file: ${{ env.DOCKERFILE }}
platforms: ${{ matrix.platform }}
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
build-args: |
VERSION=${{ env.VERSION }}
cache-from: type=gha,scope=docker-${{ env.IMAGE_NAME }}-${{ matrix.arch }}
cache-to: type=gha,mode=max,ignore-error=true,timeout=20m,scope=docker-${{ env.IMAGE_NAME }}-${{ matrix.arch }}
- name: Export digest
shell: bash
run: |
mkdir -p "/tmp/${{ env.IMAGE_NAME }}-digests"
touch "/tmp/${{ env.IMAGE_NAME }}-digests/${DIGEST#sha256:}"
env:
DIGEST: ${{ steps.build.outputs.digest }}
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: ${{ env.IMAGE_NAME }}-digests-${{ matrix.arch }}
path: /tmp/${{ env.IMAGE_NAME }}-digests/*
if-no-files-found: error
retention-days: 1
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v3
with:
subject-name: ${{ env.IMAGE }}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
merge:
name: Merge multi-arch manifest
runs-on: ubuntu-24.04
needs: build
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-tags: true
fetch-depth: 0
persist-credentials: false
- name: Set image metadata
shell: bash
env:
INPUT_VERSION: ${{ github.event.inputs.version }}
run: |
POINTED_TAG="$(git tag --points-at HEAD --list 'v*' | sort -V | tail -n1)"
INPUT_VERSION="${INPUT_VERSION//[[:space:]]/}"
OWNER="${GITHUB_REPOSITORY_OWNER,,}"
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
VERSION="${GITHUB_REF_NAME}"
elif [[ -n "$INPUT_VERSION" ]]; then
VERSION="$INPUT_VERSION"
elif [[ -n "$POINTED_TAG" ]]; then
VERSION="$POINTED_TAG"
else
echo "workflow_dispatch requires an explicit version input when HEAD is not tagged" >&2
exit 1
fi
echo "IMAGE=ghcr.io/${OWNER}/openflare-relay" >> "$GITHUB_ENV"
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/${{ env.IMAGE_NAME }}-digests
pattern: ${{ env.IMAGE_NAME }}-digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log into registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create and push manifest list
working-directory: /tmp/${{ env.IMAGE_NAME }}-digests
shell: bash
run: |
shopt -s nullglob
references=()
for digest in *; do
references+=("${IMAGE}@sha256:${digest}")
done
if [ ${#references[@]} -eq 0 ]; then
echo "No digests found in /tmp/${{ env.IMAGE_NAME }}-digests" >&2
exit 1
fi
if [[ "${VERSION}" =~ (alpha|beta|rc) ]]; then
FLOATING_TAG="beta"
else
FLOATING_TAG="latest"
fi
docker buildx imagetools create \
-t "${IMAGE}:${VERSION}" \
-t "${IMAGE}:${FLOATING_TAG}" \
"${references[@]}"
env:
IMAGE: ${{ env.IMAGE }}
- name: Inspect image
run: docker buildx imagetools inspect "${{ env.IMAGE }}:${{ env.VERSION }}"
@@ -1,197 +0,0 @@
name: Build Image (openflared)
on:
workflow_dispatch:
inputs:
version:
description: "Image version/tag to publish, for example v1.0.0-beta"
required: false
type: string
push:
tags: ["v*"]
permissions:
contents: read
packages: write
attestations: write
id-token: write
env:
IMAGE_NAME: openflared
DOCKERFILE: docker/Dockerfile.flared
jobs:
build:
name: Build (${{ matrix.arch }})
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: arm64
platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-tags: true
fetch-depth: 0
persist-credentials: false
- name: Set image metadata
shell: bash
env:
INPUT_VERSION: ${{ github.event.inputs.version }}
run: |
POINTED_TAG="$(git tag --points-at HEAD --list 'v*' | sort -V | tail -n1)"
INPUT_VERSION="${INPUT_VERSION//[[:space:]]/}"
OWNER="${GITHUB_REPOSITORY_OWNER,,}"
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
VERSION="${GITHUB_REF_NAME}"
elif [[ -n "$INPUT_VERSION" ]]; then
VERSION="$INPUT_VERSION"
elif [[ -n "$POINTED_TAG" ]]; then
VERSION="$POINTED_TAG"
else
echo "workflow_dispatch requires an explicit version input when HEAD is not tagged" >&2
exit 1
fi
echo "IMAGE=ghcr.io/${OWNER}/openflared" >> "$GITHUB_ENV"
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log into registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
id: build
uses: docker/build-push-action@v7
with:
context: .
file: ${{ env.DOCKERFILE }}
platforms: ${{ matrix.platform }}
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
build-args: |
VERSION=${{ env.VERSION }}
cache-from: type=gha,scope=docker-${{ env.IMAGE_NAME }}-${{ matrix.arch }}
cache-to: type=gha,mode=max,ignore-error=true,timeout=20m,scope=docker-${{ env.IMAGE_NAME }}-${{ matrix.arch }}
- name: Export digest
shell: bash
run: |
mkdir -p "/tmp/${{ env.IMAGE_NAME }}-digests"
touch "/tmp/${{ env.IMAGE_NAME }}-digests/${DIGEST#sha256:}"
env:
DIGEST: ${{ steps.build.outputs.digest }}
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: ${{ env.IMAGE_NAME }}-digests-${{ matrix.arch }}
path: /tmp/${{ env.IMAGE_NAME }}-digests/*
if-no-files-found: error
retention-days: 1
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v3
with:
subject-name: ${{ env.IMAGE }}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
merge:
name: Merge multi-arch manifest
runs-on: ubuntu-24.04
needs: build
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-tags: true
fetch-depth: 0
persist-credentials: false
- name: Set image metadata
shell: bash
env:
INPUT_VERSION: ${{ github.event.inputs.version }}
run: |
POINTED_TAG="$(git tag --points-at HEAD --list 'v*' | sort -V | tail -n1)"
INPUT_VERSION="${INPUT_VERSION//[[:space:]]/}"
OWNER="${GITHUB_REPOSITORY_OWNER,,}"
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
VERSION="${GITHUB_REF_NAME}"
elif [[ -n "$INPUT_VERSION" ]]; then
VERSION="$INPUT_VERSION"
elif [[ -n "$POINTED_TAG" ]]; then
VERSION="$POINTED_TAG"
else
echo "workflow_dispatch requires an explicit version input when HEAD is not tagged" >&2
exit 1
fi
echo "IMAGE=ghcr.io/${OWNER}/openflared" >> "$GITHUB_ENV"
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/${{ env.IMAGE_NAME }}-digests
pattern: ${{ env.IMAGE_NAME }}-digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log into registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create and push manifest list
working-directory: /tmp/${{ env.IMAGE_NAME }}-digests
shell: bash
run: |
shopt -s nullglob
references=()
for digest in *; do
references+=("${IMAGE}@sha256:${digest}")
done
if [ ${#references[@]} -eq 0 ]; then
echo "No digests found in /tmp/${{ env.IMAGE_NAME }}-digests" >&2
exit 1
fi
if [[ "${VERSION}" =~ (alpha|beta|rc) ]]; then
FLOATING_TAG="beta"
else
FLOATING_TAG="latest"
fi
docker buildx imagetools create \
-t "${IMAGE}:${VERSION}" \
-t "${IMAGE}:${FLOATING_TAG}" \
"${references[@]}"
env:
IMAGE: ${{ env.IMAGE }}
- name: Inspect image
run: docker buildx imagetools inspect "${{ env.IMAGE }}:${{ env.VERSION }}"
@@ -1,4 +1,4 @@
name: Build Image (openflare)
name: Build Image
on:
workflow_dispatch:
@@ -13,7 +13,7 @@ on:
# One active run per ref (e.g. canary); newer runs cancel older in-progress builds.
concurrency:
group: build-image-openflare-${{ github.ref }}
group: build-image-${{ github.ref }}
cancel-in-progress: true
permissions:
@@ -23,7 +23,7 @@ permissions:
id-token: write
env:
IMAGE_NAME: openflare
IMAGE_NAME: wavelet
DOCKERFILE: docker/Dockerfile
jobs:
+1 -143
View File
@@ -11,7 +11,7 @@ on:
type: string
env:
APP_NAME: openflare-server
APP_NAME: wavelet
GO_MAIN: ./main.go
GO_BUILD_TAGS: embed_frontend
GO_LDFLAGS: -s -w
@@ -268,145 +268,3 @@ jobs:
with:
tag_name: ${{ needs.create-release.outputs.version }}
files: ${{ steps.package.outputs.artifact }}
build-agent-binaries:
name: Build agent ${{ matrix.goos }}/${{ matrix.goarch }}
runs-on: ubuntu-latest
needs: create-release
strategy:
fail-fast: false
matrix:
include:
- goos: linux
goarch: amd64
asset_name: openflare-agent-linux-amd64
- goos: linux
goarch: arm64
asset_name: openflare-agent-linux-arm64
- goos: darwin
goarch: amd64
asset_name: openflare-agent-darwin-amd64
- goos: darwin
goarch: arm64
asset_name: openflare-agent-darwin-arm64
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
# GeoIP MMDB is not embedded; Docker images COPY mmdb files, bare binaries seed via download on first start.
- name: Build Agent
env:
CGO_ENABLED: 0
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
ASSET_NAME: ${{ matrix.asset_name }}
VERSION: ${{ needs.create-release.outputs.version }}
run: |
go mod download
mkdir -p dist
go build -trimpath -ldflags "-s -w -X 'github.com/Rain-kl/Wavelet/internal/apps/agent/config.Version=$VERSION'" -o "dist/$ASSET_NAME" ./cmd/agent/main.go
- name: Upload release artifact
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.create-release.outputs.version }}
files: dist/${{ matrix.asset_name }}
build-relay-binaries:
name: Build relay ${{ matrix.goos }}/${{ matrix.goarch }}
runs-on: ubuntu-latest
needs: create-release
strategy:
fail-fast: false
matrix:
include:
- goos: linux
goarch: amd64
asset_name: openflare-relay-linux-amd64
- goos: linux
goarch: arm64
asset_name: openflare-relay-linux-arm64
- goos: darwin
goarch: amd64
asset_name: openflare-relay-darwin-amd64
- goos: darwin
goarch: arm64
asset_name: openflare-relay-darwin-arm64
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Build Relay
env:
CGO_ENABLED: 0
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
ASSET_NAME: ${{ matrix.asset_name }}
VERSION: ${{ needs.create-release.outputs.version }}
run: |
go mod download
mkdir -p dist
go build -trimpath -ldflags "-s -w -X 'github.com/Rain-kl/Wavelet/internal/apps/relay/config.Version=$VERSION'" -o "dist/$ASSET_NAME" ./cmd/relay/main.go
- name: Upload release artifact
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.create-release.outputs.version }}
files: dist/${{ matrix.asset_name }}
build-flared-binaries:
name: Build flared ${{ matrix.goos }}/${{ matrix.goarch }}
runs-on: ubuntu-latest
needs: create-release
strategy:
fail-fast: false
matrix:
include:
- goos: linux
goarch: amd64
asset_name: openflared-linux-amd64
- goos: linux
goarch: arm64
asset_name: openflared-linux-arm64
- goos: darwin
goarch: amd64
asset_name: openflared-darwin-amd64
- goos: darwin
goarch: arm64
asset_name: openflared-darwin-arm64
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Build Flared
env:
CGO_ENABLED: 0
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
ASSET_NAME: ${{ matrix.asset_name }}
VERSION: ${{ needs.create-release.outputs.version }}
run: |
go mod download
mkdir -p dist
go build -trimpath -ldflags "-s -w -X 'github.com/Rain-kl/Wavelet/internal/apps/flared/config.Version=$VERSION'" -o "dist/$ASSET_NAME" ./cmd/flared/main.go
- name: Upload release artifact
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.create-release.outputs.version }}
files: dist/${{ matrix.asset_name }}
-24
View File
@@ -1,24 +0,0 @@
name: Close Ticket
on:
schedule:
- cron: "0 0 * * *"
jobs:
close_ticket:
runs-on: ubuntu-24.04
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v9
with:
days-before-issue-stale: 14
days-before-issue-close: 14
stale-issue-message: "此 issue 长期无活动,将在 14 天后自动关闭。如需继续讨论请回复"
close-issue-message: "此 issue 因长期无活动已自动关闭,如有需要请重新开启"
days-before-pr-stale: 14
days-before-pr-close: 14
stale-pr-message: "此 PR 长期无活动,将在 14 天后自动关闭。如需继续讨论请回复"
close-pr-message: "此 PR 因长期无活动已自动关闭,如有需要请重新开启"
-48
View File
@@ -1,48 +0,0 @@
name: "Copilot Setup Steps"
on:
workflow_dispatch:
push:
paths:
- .github/workflows/copilot-setup-steps.yml
pull_request:
paths:
- .github/workflows/copilot-setup-steps.yml
jobs:
copilot-setup-steps:
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10.10.0
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "pnpm"
cache-dependency-path: frontend/pnpm-lock.yaml
- name: Install JavaScript dependencies
working-directory: frontend
run: pnpm install
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.25"
check-latest: true
- name: Install dependencies
run: |
go mod download
go install github.com/swaggo/swag/cmd/swag@v1.16.6
-32
View File
@@ -1,32 +0,0 @@
name: Check PR Template Checklist
on:
pull_request:
types: [opened, edited, synchronize]
jobs:
check-pr-template:
runs-on: ubuntu-24.04
steps:
- name: check all checklist items are checked
uses: actions/github-script@v7
with:
script: |
// get the pull request body
const prBody = context.payload.pull_request.body || '';
// regex to match all checklist items in the template
// matches lines like: - [ ] ... or - [x] ...
const checklistRegex = /^- \[( |x|X)\] .+$/gm;
const matches = prBody.match(checklistRegex) || [];
// check if any checklist item is not checked
const unchecked = matches.filter(line => line.startsWith('- [ ]'));
// if any unchecked, fail the workflow
if (unchecked.length > 0) {
core.setFailed(`PR checklist 未全部勾选,请确保所有 checklist 项都已勾选。未勾选项如下:\n${unchecked.join('\n')}`);
} else {
console.log('all checklist items are checked.');
}
+10 -30
View File
@@ -12,8 +12,6 @@
# config
config.yaml
.env
.env.*
!.env.example
# sqlite
*.db
@@ -29,6 +27,8 @@ frontend/.next/*
frontend/next-env.d.ts
frontend/package-lock.json
frontend/.env
.env.*
!.env.example
*.tsbuildinfo
# os
@@ -46,39 +46,19 @@ go.work.sum
main
# upload
/uploads/
s3_cache
uploads/*
s3_cache
/frontend/.next/
/data/
/internal/router/dist/
/frontend/out/
/.idea/
/uploads/
/*-source/
/*-source.zip
/.cache/
/internal/router/root/dist/
.dmux/
# test coverage
*.out
coverage.*
*.coverprofile
profile.cov
# generic ignores
.cache
.gocache*
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*-source
*-source.zip
.codex*
.grok
/.gomodcache/
*.mmdb
# Server control-plane MaxMind Country seed (Country only; Agent does not embed)
!internal/apps/openflare/geoip/data/GeoLite2-Country.mmdb
/.superpowers/
/.worktrees/
.worktrees/
+81 -146
View File
@@ -64,165 +64,100 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
## Skills(匹配任务时必读)
## Git 提交规范
遵循 Conventional Commits:`<type>(<scope>): <subject>`(例:`feat(auth): support email login`)。
## 务必阅读匹配的 Skill
| Skill | 何时使用 |
| :--- | :--- |
| `new-api` | 业务 API、Handler、服务层、路由注册 |
| `new-async-task` | Asynq 任务、定时任务、TaskHandler、任务元数据 |
| `new-setting` | 系统/业务/公开设置、`/admin/system`、`/admin/settings` |
| `database-migration` | 表结构、goose 迁移(PG/SQLite/ClickHouse)、seed |
| `clickhouse-batchwriter` | CH 批量写入、batchwriter、分析表 flush/背压 |
| `file-upload` | 上传/摄取、`upload.Ingest`、文件访问、`w_uploads` |
| `cache-framework` | 业务缓存(RAM/Redis/DB)、失效、多节点同步 |
| `push-notification` | 通知推送事件、统一触发器、带推送的业务 |
| `release-guide` | Version Bump 提交信息(触发双语 Release) |
| `shadcn` | 添加/修改/组合 shadcn/ui 组件 |
| `new-api` | 添加或修改自定义业务 API、Handler、服务层逻辑、自定义路由注册 |
| `new-async-task` | 添加或修改 Asynq 任务、定时任务、TaskHandler、任务元数据 |
| `new-setting` | 添加或修改系统/业务/公开设置、`/admin/system` 参数或 `/admin/settings` 图形化设置 |
| `database-migration` | 数据库表结构变更、goose SQL 迁移(PG/SQLite/ClickHouse)、seed 数据 |
| `logstore` | 日志/分析用途表、`internal/repository/logstore`、切换日志主库、PG/SQLite 回落 |
| `clickhouse-batchwriter` | ClickHouse 批量写入、`internal/infra/persistence/batchwriter` 接入、分析表异步 flush、背压与写入路径改造 |
| `file-upload` | 业务上传文件、Worker 程序化摄取、`upload.Ingest` 策略选型、文件访问与 `w_uploads` / 统计排查 |
| `cache-framework` | 新增或修改业务缓存(RAM/Redis/DB 三层读路径)、缓存失效、多节点 pub/sub 同步、评估高频读是否应接入缓存 |
| `push-notification` | 系统通知推送事件、统一触发器投递、带消息推送的业务功能 |
| `release-guide` | 根据自上一正式版本 Tag 以来的提交整理 Version Bump 提交信息以触发双语 Release |
| `shadcn` | 添加、修改或组合 shadcn/ui 组件 |
## 硬性约束
## 严格遵循事项 (Guardrails)
- 禁止删除 `frontend/node_modules`。
- `pkg/util/` 保持纯净:禁止导入 Gin、GORM、sessions 等 HTTP/Web/DB 框架(会话选项在 `internal/apps/oauth/session.go`)。
- 测试临时目录只用 `t.TempDir()`,禁止硬编码相对路径写源码树。
- HTTP 路由仅在 `internal/router/router.go` 注册;`Serve()` 只挂路由与中间件,禁止进程级初始化(如 `SyncEvents`、`InitLogWriter`)。
- API 变更后:`make swagger`;开发完成:`make code-check`;提交前:`make format`。
- 缓存/文件管理复用平台实现,业务包禁止自建缓存目录或旁路存储后端。
- 文件摄取走 `upload.Ingest`(`PolicyCreate` / `PolicyDedupNewRecord` / `PolicyResolveExisting`);删除走 `upload.Remove` / `upload.RemoveOwned`。禁止业务直接 `repository.CreateUpload` / `SoftDeleteUpload` 或 `db.Create(&model.Upload{})`。
- 切勿删除 `frontend/node_modules`。
- 保持 `internal/util/` 绝对纯净,禁止导入 Gin、GORM、sessions 等 Web/数据库框架包。
- 测试用例禁止硬编码相对路径创建临时目录,统一使用 Go 内置 `t.TempDir()`。
- 所有 HTTP 路由仅在 `internal/router/router.go` 中作为高层分发注册。
- 修改 API Handler 后运行 `make swagger`,完成代码开发后必须依次运行 `make code-check` 与 `make format`。
- 业务模块必须复用平台缓存/文件服务:文件摄取统一用 `upload.Ingest`,删除用 `upload.Remove`/`upload.RemoveOwned`;禁止直接写 `w_uploads` 或绕过 upload 域直接操作 `infra/objectstore`。
- 禁止在 `init()` 中注册跨模块集成(任务 Handler、推送事件、域事件监听器等),统一在 `internal/platform/bootstrap` 显式装配并在 `internal/cmd` 入口调用。
- 核心业务模块(`oauth`、`user`)禁止直接 import `push` 或 `custom_events` 触发通知,须通过 `internal/listener` 发射域事件。
- API 错误响应必须通过 `response.Abort*` 中断请求,由 `ErrorHandlerMiddleware` 统一写出 JSON 并记录 Trace;禁止在 Handler/中间件中直接 `c.JSON(status, response.Err(...))` 或 `200` 返回 `error_msg`。
- **分层**:`apps → repository → model`,`repository → infra/persistence`;禁止 `model → repository`。
- `model`:实体、表名、配置 key、查询 DTO、无 IO 规则。禁止 `db.DB` / Redis / CH;禁止 `import repository`。GORM hook 仅可 mutate 自身字段,禁止在 hook 内再查 DB/缓存。
- `repository`:唯一持久化入口。apps/logics 禁止为业务 CRUD 直调 `db.DB`(管理端 SQL 控制台、infra 内部等例外保留)。禁止新增 `model.Get/List/Create/...` 类数据访问 API。
- 跨模块集成(任务 Handler、推送事件、域监听、完成钩子)禁止 `init()` 注册;经 `internal/platform/bootstrap` 在 `internal/cmd` 入口显式装配。
- 核心业务(如 `oauth`、`user`)禁止直接 import push/custom_events;经 `internal/listener` 发域事件,push 在 bootstrap 订阅。
- 依赖任务/推送注册的测试须显式 `bootstrap.RegisterTasks()` / `RegisterPushDomainEvents()` 等,不依赖 `init()`。
- API 错误必须 `response.Abort*` + `ErrorHandlerMiddleware`;禁止 Handler 直接 `c.JSON(..., response.Err(...))` 或用 HTTP 200 表示失败。
- 日志/分析表(访问日志、审计流水、可观测时序)走 `internal/repository/logstore`,禁止 apps 直连 `repository/analytics` 或 `db.ChConn`/`db.ChDB`。判定与接入步骤见 `logstore` skill。
### 文档与 Changelog
## 技术栈与项目目录结构
- 内容变更同步**中文文档**(不同步英文)。
- 代码/配置变更写入 [`docs/changelog/index.md`](./docs/changelog/index.md) 的 `[Unreleased]`;纯文档变更不写 changelog。
- Changelog:合并相近项;不记格式化/调试/无关重构;用户可读完整中文句;说明效果;不编造;不写密钥等敏感信息;空分类可省略。
### 技术栈
- **后端**:Go 1.25+、Gin、GORM、PostgreSQL、可选 ClickHouse、Redis、Asynq、Cobra、Viper、Swaggo、OpenTelemetry、Zap、AWS SDK v2。
- **前端**:Next.js (App Router)、TypeScript、Tailwind CSS、pnpm、shadcn/ui。
## 技术栈
## 后端开发规范
- **后端**:Go 1.25+、Gin、GORM、PostgreSQL、可选 ClickHouse、Redis、Asynq、Cobra、Viper、Swaggo、OTel、Zap、AWS SDK v2、Snowflake IDs
- **前端**:Next.js App Router、TypeScript、Tailwind、pnpm、shadcn/ui
### API 响应规范
- **统一信封**:`{ "error_msg": "", "data": ... }`
- **成功**:HTTP 200,写出 `c.JSON(http.StatusOK, response.OK(data))` 或 `response.OKNil()`。
- **失败**:使用 `internal/shared/response` 的 `Abort*` 系列函数(如 `AbortBadRequest`、`AbortUnauthorized`、`AbortNotFound`、`AbortInternal`)中断请求。
- **错误文案**:使用模块内 `errs.go` 中的 camelCase 字符串常量(如 `errBindParamsFailed`),禁止暴露底层数据库/系统错误细节给客户端。
- **Logics 分工**:`logics.go` 只接受 `context.Context`,返回 `(result, error)`,严禁依赖 `*gin.Context` 或调用 `c.JSON`/`Abort*`。
- **错误日志**:底层错误在 Handler/Logic 边界用 `pkg/logger` 打印日志,禁止使用 `_ = ...` 静默吞掉关键错误。
## Git
### 数据库操作
- 平台域(user、auth_source、access_token、schedule、task_execution)的持久化必须走 `internal/repository`,禁止在 `internal/model` 中调用 `db.DB` / Redis。
- 管理员代码推荐使用 `db.DB(ctx)`(`internal/infra/persistence`,包名 `db`)保证 Trace 链路透传。
- 禁止在 Handler 写复杂 SQL;迁移文件位于 `internal/infra/persistence/migrator/goose/`(禁止 GORM AutoMigrate)。
- 不创建物理外键(显式建索引);Go 模型零值需与数据库默认值匹配。
- **SQL LIKE 查询防注入与转义**:所有含用户输入的模糊查询必须调用 `pkg/util.EscapeLike` 转义通配符,并显式指定 `ESCAPE '\\'` 语法(如 `Where("username LIKE ? ESCAPE '\\'", util.EscapeLike(keyword)+"%")`),同时兼容 PostgreSQL 与 SQLite 方言并杜绝通配符注入攻击。
Conventional Commits:`<type>(<scope>): <subject>`(例:`feat(auth): support email login`)。
### 并发与安全防护规范
- **Goroutine 安全**:禁止直接使用裸 `go func()`;统一使用 `pkg/util.Go`,确保具备未捕获 panic 恢复和调用栈日志记录能力。
- **Pub/Sub 监听并发安全**:启动 Redis Pub/Sub 订阅监听前,必须捕获局部客户端实例(如 `redisClient := db.Redis`),禁止在 goroutine 闭包中直读可变全局 `db.Redis`;提供 `Stop*Listener` 时必须维护 `done` 通道等待 goroutine 完整退出后再重置状态,消除测试或重连时的数据竞争。
- **Session 固定攻击防御**:用户登录/授权成功后,必须调用 `oauth.SetLoginSession`(内部执行 Session ID 轮换),防止 Session 固定攻击。
- **防账户枚举与时序攻击**:
- 登录失败统一返回模糊报错;当查询用户不存在时,必须调用 `pkg/util.DummyCheckPassword` 执行同等开销的 bcrypt 哈希计算,彻底消除时序侧信道攻击。
- 验证码、签名 Token 等敏感字符串比对必须使用 `crypto/subtle.ConstantTimeCompare` 常量时间比对。
- **敏感端点限流**:登录尝试、OAuth 授权发起等敏感接口必须接入基于 Redis 的滑动窗口限流机制,防止暴力破解与缓存资源耗尽。
---
## 前端开发规范
## 后端
### 命名
| 类别 | 规则 | 例 |
|------|------|-----|
| 包/文件 | 小写蛇形 | `auth_source`、`postgres_logger.go` |
| 导出/未导出标识符 | PascalCase / camelCase | — |
| 请求/响应结构体 | camelCase + 后缀 | `listUsersRequest` |
| 错误文案常量 | camelCase 字符串 `const`(非包级 `error`) | `errBindParamsFailed` |
| YAML 键 | 小写蛇形 | — |
### Handler
- 命名:动词 + 名词(`ListUsers`);绑定用 `ShouldBindQuery` / `ShouldBindJSON`。
- 每个 HTTP API 需完整 Swagger 注释;API 变更后 `make swagger`。
- Handler:绑定 → 调 logic → 映射为 `Abort*` 或 `response.OK`。
- `logics.go`:接受 `context.Context`,返回结果/error;**禁止**依赖 `*gin.Context`、调用 `Abort*` / `c.JSON`。参考 `internal/apps/user/logics.go`。
### API 响应
信封:`{ "error_msg": "", "data": ... }`。成功 `error_msg` 空、`data` 为载荷;失败 `data` 为 `null`。分页:`data: { total, results }`。
**成功**(始终 HTTP 200):
```go
c.JSON(http.StatusOK, response.OK(data))
c.JSON(http.StatusOK, response.OKNil())
```
**失败**:仅用 `response.Abort*`(挂 `c.Errors` 并 `Abort`,由 `ErrorHandlerMiddleware` 统一写出并记 OTel),阅读/internal/shared/response/abort.go使用已有函数
中间件同规则(`oauth.LoginRequired` → Unauthorized;`admin.LoginAdminRequired` → NotFound;`cap.VerifyMiddleware` → Unauthorized)。
- 用户可见错误:模块内 `errs.go` 的 camelCase 字符串常量;禁止向客户端暴露驱动错误/堆栈。
- `response.Err` 仅供中间件构造 JSON,业务禁止用于 `c.JSON`。
**禁止**:`c.JSON(200, response.Err(...))`;Handler 直接 `c.JSON(4xx/5xx, response.Err(...))`;手写 `gin.H` 错误体;在 `logics.go` 里 `Abort*`。
Swagger:`@Success 200` 用具体类型或 `response.Any`;每个可能 Abort 状态声明 `@Failure`。
### 日志
- 运行时错误(DB/Redis/第三方/IO)在 Handler 或 logic 边界用 `pkg/logger`(带 `ctx`)记录,再返回安全 Abort/业务错误。
- 吞错、转通用响应、worker 忽略前必须先记日志。
- 禁止 `_ = err` 静默丢弃重要错误;best-effort 可忽略时加简短注释。
- 只在处理/抑制边界记一次,避免重复刷日志。
### 路由与装配
- `router.go` 只做高层分发,禁止直接挂业务 Handler。归属与开发步骤见 `new-api` skill。
- 跨模块副作用:在 `bootstrap` 增 `Register*`,于对应 `internal/cmd/*.go` 调用(`RegisterAPI` / `RegisterWorker` / `RegisterAll`)。
- API/`all` 模式:`bootstrap.Init` 须在 `RegisterPushDomainEvents()` **之后**调用,保证 `SyncEvents` 同步内置推送元数据。
### 中间件
- 全局:`gin.Recovery()`、`otelgin`、日志、session。
- 登录组:`oauth.LoginRequired()`;管理组:`admin.LoginAdminRequired()`。
### 配置
- 运行时只读 `config.Config`,禁止 `os.Getenv()`。
- 新增配置同步 `config.example.yaml` 与 `internal/infra/config/model.go`。
### 数据库
- 持久化只经 `repository`(或 analytics);复杂查询不进 Handler;编排在 logics。
- repository 内用 `db.DB(ctx)`(链路追踪)。
- 迁移:`internal/infra/persistence/migrator/goose/` SQL;禁止 GORM AutoMigrate。
- 不建物理外键,关系字段加显式索引。
- 列默认值与 Go 零值(`nil`/`0`/`false`/`""`)一致。
---
## 前端
- Next.js:以 `node_modules/next/dist/docs/` 为准(训练数据可能过时)。
- 示例:`frontend/app/(main)/admin/demo`。
### 样式
- shadcn 用 `variant` + CSS 变量;业务 `className` 不硬编码颜色/背景/阴影。
- 变体不足时扩展组件 variant,不写一次性颜色。
### 页面结构
- 根容器全宽 `w-full`;禁止页面级 `max-w-*`(主布局负责宽度)。
- 外层间距:`py-6` 或 `py-6 px-1`。
- 标题行:`flex items-center gap-2`(有右侧操作则加 `justify-between`)。
- 图标:Lucide 直接放标题容器,`size-5 text-primary`;禁止背景卡片/边框包裹。
- 标题:仅 `h1 className="text-2xl font-semibold tracking-tight"`。
- 多 Tab:各 Tab 独立文件;`page.tsx` 只管 Tabs 状态与触发器;禁止 `page.tsx` 仅转发同名空壳。
- 单文件 > ~600 行或状态过重时拆局部 `components/`;跨页复用放 `frontend/components/common/`。标杆:`/admin/database`。
### 组件放置
| 类型 | 路径 |
|------|------|
| 跨页业务 | `frontend/components/common/` |
| shadcn 原语 | `frontend/components/ui/` |
| 路由专属 | 邻近 feature 目录 |
### Services
```text
frontend/lib/services/<name>/
types.ts
<name>.service.ts
index.ts
```
- 继承 `BaseService`,定义 `basePath`,有类型静态方法;在 `frontend/lib/services/index.ts` 注册。
- 回调/`mutationFn`/`queryFn` **禁止**直接传静态方法引用(丢 `this`);用箭头:`(p) => XxxService.create(p)`。
- 新特性开发前参考 Next.js 文档与 `frontend/app/(main)/admin/demo` 示例代码。
- **页面容器与标题栏**:
- 页面根容器统一使用全宽 `w-full`,最外层统一用 `py-6` 或 `py-6 px-1` 对齐边距。
- 标题容器统一 `flex items-center gap-2`(带操作按钮用 `justify-between`)。
- 图标直接使用 Lucide 组件(`size-5 text-primary`),禁止包裹背景小卡片或装饰边框。
- 标题文字统一使用 `<h1 className="text-2xl font-semibold tracking-tight">`。
- **无障碍语义与色彩规范 (a11y & WCAG)**:
- **标题层级规范 (Heading Hierarchy)**:页面中非顶级结构化标题(如空状态提示、加载提示、卡片眉题/卡片标题、抽屉区块名)严禁滥用 `<h3>`/`<h4>`,统一使用 `<p>` 配合样式,保证屏幕阅读器感知的标题层级连续。
- **无文本控件无障碍**:所有仅包含图标的按钮(如仅有 Icon 的 Button、Switch、无文本的 SelectTrigger)必须显式添加 `aria-label`。
- **色彩对比度**:正文、提示、徽章等小字颜色在亮色/暗色模式下必须满足 WCAG AA(对比度 ≥ 4.5:1)。
- **组件拆分与维护**:
- 物理路由页面 `page.tsx` 仅维护高级骨架与布局。
- 单文件超过 600 行或含多 Tab/大复杂区块时,必须按就近原则拆分为子组件存放在路由同级的 `components/` 局部目录中(参考 `/admin/database` 的模块化拆分结构)。
- **样式与服务**:
- 优先使用 shadcn/ui 的 `variant` 和全局 CSS 变量,不要在业务代码中硬编码颜色/背景。
- 前端请求统一在 `frontend/lib/services/<name>/` 中继承 `BaseService` 编写并在 `index.ts` 注册。
- **国际化 (i18n)**:
- 使用 `next-intl`(**无 URL locale 前缀** / non-routing provider 模式),兼容 `NEXT_STANDALONE_EXPORT` 静态导出。
- 支持语言:`zh-CN`、`en`;默认 `zh-CN`。
- 解析优先级:cookie `NEXT_LOCALE`(用户显式选择)→ 浏览器语言 → 默认 `zh-CN`。
- 文案统一放在 `frontend/messages/{locale}.json`,按命名空间嵌套(`common` / `layout` / `auth` / `settings` / 业务域)。
- 组件内用户可见文案必须通过 `useTranslations()` / `getTranslations()` 读取;**禁止**新增中英硬编码 UI 字符串(后端返回的 `error_msg`、日志、调试信息除外)。
- key 使用 camelCase 分层(如 `auth.login.submit`);完整短语作为 value,禁止在组件内拼接句子。
- 新增或修改文案时必须**同步**更新 `zh-CN.json` 与 `en.json`,保持 key 树一致。
- 语言选项展示用自称:`中文` / `English`(不随当前 UI 语言翻译)。
- 日期/数字格式化使用 locale 感知 helper(如 `formatDateTime`),禁止写死 `'zh-CN'` / `date-fns` 的 `zhCN`(除非该路径尚未迁移且不在本次改动范围)。
- 设计说明见 `docs/superpowers/specs/2026-07-24-frontend-i18n-design.md`。
+6 -33
View File
@@ -1,4 +1,4 @@
.PHONY: swagger license license-check format build-embedded build-test cross-build code-check build-backend build-frontend build-agent build-relay build-flared build-all
.PHONY: swagger license license-check build-embedded build-test cross-build code-check format canary
VERSION ?= dev
BUILD_DATE ?= $(shell date -u +'%Y-%m-%dT%H:%M:%SZ')
@@ -14,13 +14,9 @@ license-check:
scripts/update_go_license.sh --check
format:
@echo "==> Formatting backend Go source and removing unused imports..."
@command -v goimports >/dev/null 2>&1 || { \
echo "goimports not found, installing..."; \
go install golang.org/x/tools/cmd/goimports@latest; \
}
goimports -w $$(find . -type f -name '*.go' -not -path './.git/*' -not -path './frontend/*')
@echo "==> Formatting frontend source and removing unused imports..."
@echo "==> Formatting backend Go source..."
gofmt -w $$(find . -type f -name '*.go' -not -path './.git/*' -not -path './frontend/*')
@echo "==> Formatting frontend source..."
cd frontend && pnpm format
build-embedded:
@@ -34,7 +30,7 @@ build-embedded:
go build \
-tags embed_frontend \
-ldflags "-s -w -X '$(MODULE)/internal/buildinfo.Version=$(VERSION)' -X '$(MODULE)/internal/buildinfo.BuildTime=$(BUILD_DATE)'" \
-o bin/openflare-server \
-o bin/wavelet \
main.go
code-check:
@@ -51,32 +47,9 @@ build-backend:
@echo "==> Building backend version=$(VERSION) build_date=$(BUILD_DATE)..."
go build \
-ldflags "-s -w -X '$(MODULE)/internal/buildinfo.Version=$(VERSION)' -X '$(MODULE)/internal/buildinfo.BuildTime=$(BUILD_DATE)'" \
-o bin/openflare-server \
-o bin/wavelet \
main.go
build-agent:
@echo "==> Building agent version=$(VERSION)..."
go build \
-ldflags "-s -w -X '$(MODULE)/internal/apps/agent/config.Version=$(VERSION)'" \
-o bin/openflare-agent \
cmd/agent/main.go
build-relay:
@echo "==> Building relay version=$(VERSION)..."
go build \
-ldflags "-s -w -X '$(MODULE)/internal/apps/relay/config.Version=$(VERSION)'" \
-o bin/openflare-relay \
cmd/relay/main.go
build-flared:
@echo "==> Building flared version=$(VERSION)..."
go build \
-ldflags "-s -w -X '$(MODULE)/internal/apps/flared/config.Version=$(VERSION)'" \
-o bin/flared \
cmd/flared/main.go
build-all: build-backend build-agent build-relay build-flared
build-frontend:
@echo "==> Building frontend version=$(VERSION) build_date=$(BUILD_DATE)..."
cd frontend && \
+4 -4
View File
@@ -1,9 +1,9 @@
OpenFlare
Wavelet
This product includes software derived from Wavelet.
This product includes software derived from LinuxDO Credit.
Wavelet:
Copyright 2025 Arctel.net
LinuxDO Credit:
Copyright 2025 linux.do
Licensed under the Apache License, Version 2.0.
This distribution includes modifications by Arctel.net.
-225
View File
@@ -1,225 +0,0 @@
<div align="center">
# OpenFlare
**[English](./README.en.md) | [📖 中文](./README.md)**
OpenFlare is an open-source CDN orchestration and edge security platform. It supports reverse proxies, centralized configuration synchronization, secure intranet penetration (Tunnels), dynamic WAF protection, and anti-CC challenges.
</div>
<p align="center">
<a href="https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/LICENSE">
<img src="https://img.shields.io/github/license/Rain-kl/OpenFlare?color=brightgreen" alt="license">
</a>
<a href="https://github.com/Rain-kl/OpenFlare/releases/latest">
<img src="https://img.shields.io/github/v/release/Rain-kl/OpenFlare?color=brightgreen&include_prereleases" alt="release">
</a>
<a href="https://github.com/Rain-kl/OpenFlare/pkgs/container/openflare">
<img src="https://img.shields.io/badge/GHCR-ghcr.io%2Frain--kl%2Fopenflare-brightgreen" alt="ghcr">
</a>
</p>
> [!WARNING]
> After logging in for the first time with the `root` user, make sure to change the default password `123456`.
>
> The BETA version is a temporary product for the development and testing phase. It may contain unknown issues and should not be used in production environments.
## Documentation
**https://open-flare.pages.dev**
Quick links:
* [Quick Start](https://open-flare.pages.dev/en/guide/quick-start)
* [Deployment Guide](https://open-flare.pages.dev/en/deployment/deployment)
* [Configuration Reference](https://open-flare.pages.dev/reference/configuration)
* [System Design](https://open-flare.pages.dev/design/)
## Core Features
* **Reverse Proxy Management**: Website rules as the aggregation boundary, supporting multi-domain binding and multi-upstream load balancing with unified management of all OpenResty node configurations.
* **Immutable Config Version Control**: Full-snapshot publish model based on version numbers (`YYYYMMDD-NNN`), with pre-publish diff preview, a single globally active version, and one-click sub-second rollback.
* **Secure Intranet Penetration (Tunnels)**: An open-source alternative to Cloudflare Tunnels. Securely expose local intranet Web services to the public network via Relay and OpenFlared clients — no public IP or open inbound ports required.
* **Edge WAF Safety Protection**: Provides global and custom rule groups, supporting manual/automatic/subscription IP groups, MaxMind GeoIP country-level access control, Checksum-based differential IP group sync (no Nginx reload), and custom block responses.
* **Anti-CC & Human-Machine Challenge (PoW)**: Built-in high-performance client-side cryptographic Proof of Work challenges (similar to Turnstile) to block and intercept botnets and scrapers at the gateway edge in seconds.
* **Pages Static Hosting**: Upload pre-built ZIP packages directly; edge Agents pull and serve them via local OpenResty, with SPA Fallback and built-in API reverse proxy configuration.
* **Automated TLS Certificate Management**: Supports dynamic certificate upload, automatic multi-domain certificate matching and binding, and ACME-based automatic issuance and renewal via Let's Encrypt.
* **Uptime Kuma Monitoring Sync**: Integrates with Uptime Kuma to automatically sync the monitoring site list using differential updates, providing real-time awareness of node availability and service health.
* **SSO Single Sign-On**: Supports GitHub OAuth and standard OIDC protocol for seamless integration with enterprise identity providers.
* **Unified Observability**: Aggregates node request metrics, real-time access log details, host/Nginx resource snapshots, health events, and a re-upload buffer for network fluctuations.
## Quick Start
### 1. Launch Server
```yaml
services:
openflare:
image: ghcr.io/rain-kl/openflare:latest
restart: unless-stopped
env_file: .env
environment:
TZ: ${TZ:-Asia/Shanghai}
ports:
- "3000:3000"
volumes:
- openflare_uploads:/app/uploads
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
clickhouse:
condition: service_healthy
postgres:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${DB_NAME:-openflare}
POSTGRES_USER: ${DB_USERNAME:-openflare}
POSTGRES_PASSWORD: ${DB_PASSWORD:-replace-with-strong-password}
volumes:
- openflare_postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-openflare} -d ${DB_NAME:-openflare}"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: valkey/valkey:8.0-alpine
restart: unless-stopped
command: ["valkey-server", "--appendonly", "yes"]
volumes:
- openflare_redis_data:/data
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
start_period: 5s
clickhouse:
image: clickhouse/clickhouse-server:25.3-alpine
restart: unless-stopped
environment:
CLICKHOUSE_DB: ${CLICKHOUSE_NAME:-openflare}
CLICKHOUSE_USER: ${CLICKHOUSE_USERNAME:-default}
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-replace-with-clickhouse-password}
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1
TZ: ${TZ:-Asia/Shanghai}
volumes:
- openflare_clickhouse_data:/var/lib/clickhouse
healthcheck:
test: ["CMD", "clickhouse-client", "--user", "${CLICKHOUSE_USERNAME:-default}", "--password", "${CLICKHOUSE_PASSWORD:-replace-with-clickhouse-password}", "--query", "SELECT 1"]
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
volumes:
openflare_uploads:
openflare_postgres_data:
openflare_redis_data:
openflare_clickhouse_data:
```
```bash
docker compose up -d
```
Access at: `http://localhost:3000`
Default credentials:
* Username: `root`
* Password: `123456`
### 2. Install Agent
Before installing an Agent, please install OpenResty on the target node first, or use the Agent Docker image with OpenResty built-in.
You can copy the installation command from **Node Management -> Details -> Node Info -> Node Token & Deployment** in the control panel, or directly use the scripts below:
#### Docker Deployment
For Docker deployment, you can directly run the Agent image:
```bash
docker pull ghcr.io/rain-kl/openflare-agent:latest
docker rm -f openflare-agent 2>/dev/null || true
docker run -d --name openflare-agent --restart unless-stopped \
-p 80:80 -p 443:443/tcp -p 443:443/udp \
-e OPENFLARE_SERVER_URL=http://your-server:3000 \
-e OPENFLARE_AGENT_TOKEN=YOUR_AGENT_TOKEN \
ghcr.io/rain-kl/openflare-agent:latest
```
#### Local Installation
Using `discovery_token` to register:
```bash
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
--server-url http://your-server:3000 \
--discovery-token YOUR_DISCOVERY_TOKEN
```
Using node-specific `agent_token`:
```bash
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
--server-url http://your-server:3000 \
--agent-token YOUR_AGENT_TOKEN
```
The installation script defaults to `/opt/openflare-agent`, creates a `openflare-agent.service`, automatically searches for `openresty`, and can be executed repeatedly to reinstall or upgrade the Agent.
### 3. Uninstall Agent
To completely uninstall the Agent and clear local data, run:
```bash
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/uninstall-agent.sh | bash
```
The uninstallation script will stop and remove the `openflare-agent.service`, and delete the entire `/opt/openflare-agent` directory. It will not delete the local OpenResty installation.
### 4. Publish Your First Configuration
1. Log in to the management panel and add a reverse proxy rule.
2. View the preview or change summary before publishing.
3. Activate the new version.
4. Agents will receive the configuration and apply it via WebSocket notification or subsequent heartbeats.
The version number format is fixed as `YYYYMMDD-NNN`. Historical versions are immutable, and rollback is achieved by reactivating an older version.
## UI Preview
### Dashboard Overview
![OpenFlare dashboard overview](./docs/assets/readme/dashboard-overview.png)
### Node Details
![OpenFlare node detail](./docs/assets/readme/node-detail.png)
### Proxy Configuration
![OpenFlare version release](./docs/assets/readme/proxy-route-detail.png)
## License
This project is licensed under [Apache License 2.0](./LICENSE).
## Star History
<a href="https://www.star-history.com/?repos=Rain-kl%2FOpenFlare&type=date&legend=bottom-right">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=Rain-kl/OpenFlare&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=Rain-kl/OpenFlare&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=Rain-kl/OpenFlare&type=date&legend=top-left" />
</picture>
</a>
+324 -174
View File
@@ -1,202 +1,352 @@
<div align="center">
# wavelet
# OpenFlare
🚀 A modern, production-ready full-stack boilerplate for building scalable web applications
**[📖 中文](./README.md) | [English](./README.en.md)**
[中文](./README_zh.md)
OpenFlare 是开源 CDN 编排与边缘安全平台。它支持反向代理、集中式配置同步、内网穿透(Tunnels)、动态 WAF 防护以及防 CC 挑战。
[![License: Apache2.0](https://img.shields.io/badge/License-Apache2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![Go Version](https://img.shields.io/badge/Go-1.25+-blue.svg)](https://golang.org/)
[![Next.js](https://img.shields.io/badge/Next.js-16-black.svg)](https://nextjs.org/)
[![React](https://img.shields.io/badge/React-19-blue.svg)](https://reactjs.org/)
</div>
## 📖 Introduction
<p align="center">
<a href="https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/LICENSE">
<img src="https://img.shields.io/github/license/Rain-kl/OpenFlare?color=brightgreen" alt="license">
</a>
<a href="https://github.com/Rain-kl/OpenFlare/releases/latest">
<img src="https://img.shields.io/github/v/release/Rain-kl/OpenFlare?color=brightgreen&include_prereleases" alt="release">
</a>
<a href="https://github.com/Rain-kl/OpenFlare/pkgs/container/openflare">
<img src="https://img.shields.io/badge/GHCR-ghcr.io%2Frain--kl%2Fopenflare-brightgreen" alt="ghcr">
</a>
</p>
**wavelet** is a generic, production-ready full-stack boilerplate built with **Go (Gin + GORM)** on the backend and **Next.js (App Router + Shadcn UI)** on the frontend. It ships with everything you need to bootstrap a modern SaaS, internal tool, or developer platform — without the boilerplate headaches.
> [!WARNING]
> 使用 `admin` 用户初次登录系统后,务必修改默认密码 `12345678`。
>
> BETA 版本为开发测试阶段的临时产物,可能存在未知问题,请勿在生产环境使用。
The project was designed from the ground up to be **framework-first and business-agnostic**: plug in your own domain logic while reusing the battle-tested infrastructure that comes out of the box.
## 文档
### ✨ Key Features
**https://open-flare.pages.dev**
- 🔐 **Multi-auth System** — Local password login/registration + pluggable OIDC/OAuth2 providers (supports multiple auth sources simultaneously)
- 🗝️ **Personal Access Tokens** — API key management for programmatic access; supports `Authorization: Bearer` and `X-Access-Token` headers
- 👤 **User Management** — Admin panel for listing, searching, filtering, enabling/disabling user accounts
- ⚙️ **Dynamic System Config** — Key-value system configuration management with live reload, controllable from the admin UI
- 📋 **Async Task Queue** — Background job processing with [Asynq](https://github.com/hibiken/asynq) (Redis-backed), including a scheduling dashboard
- 📁 **S3 File Storage** — Unified file upload/download via S3-compatible APIs with local disk cache
- 📊 **Observability** — Structured logging (Zap) + distributed tracing (OpenTelemetry)
- 🎨 **Modern UI** — Responsive, dark-mode-ready design system built with Tailwind CSS 4 and Shadcn UI
- 📖 **Built-in Documentation** — Integrated docs portal with usage guides, API reference, privacy policy, and terms of service
常用入口:
## 🏗️ Architecture Overview
* [快速开始](https://open-flare.pages.dev/guide/quick-start)
* [部署说明](https://open-flare.pages.dev/deployment/deployment)
* [配置项参考](https://open-flare.pages.dev/reference/configuration)
* [系统设计](https://open-flare.pages.dev/design/)
```
┌─────────────────┐ ┌─────────────────────────────┐ ┌─────────────────┐
│ Frontend │ │ Backend │ │ Database │
│ (Next.js) │◄──►│ (Go) │◄──►│ (PostgreSQL) │
│ │ │ │ │ │
│ • React 19 │ │ • Gin HTTP Framework │ │ • PostgreSQL │
│ • TypeScript │ │ • GORM ORM │ │ • Redis Cache │
│ • Tailwind 4 │ │ • Multi-provider Auth │ │ │
│ • Shadcn UI │ │ • AccessToken Middleware │ │ │
│ │ │ • Asynq Task Queue │ │ │
│ │ │ • OpenTelemetry Tracing │ │ │
│ │ │ • Swagger API Docs │ │ │
└─────────────────┘ └─────────────────────────────┘ └─────────────────┘
│
┌──────────┴──────────┐
│ Multi-Process CLI │
│ (Cobra + Viper) │
│ • api (HTTP) │
│ • worker (Queue) │
│ • scheduler(Cron) │
└─────────────────────┘
```
## 核心能力
## 🛠️ Tech Stack
* **反代配置管理**:以网站规则为聚合边界,支持多域名绑定与多上游负载均衡,统一管理所有 OpenResty 节点的反代配置。
* **安全内网穿透(Tunnels)**:开源版的 Cloudflare Tunnels。无须公网 IP 或暴露入向端口,通过 Relay 中继节点与 OpenFlared 客户端安全反向穿透内网 Web 服务至公网。
* **边缘 WAF 安全防护**:提供全局与自定义规则组,支持手动/自动/订阅型 IP 组、MaxMind GeoIP 国家级地域准入、IP 组成员 Checksum 差分同步(无需 Nginx 重载)以及自定义拦截响应。
* **防 CC 与人机挑战(PoW)**:内置高性能客户端密码学 Proof of Work 挑战(类似 Turnstile),在网关边缘秒级拦截并阻断僵尸网络与爬虫。
* **Pages 静态托管**:支持上传或从受限 Remote URL、公开 GitHub Release asset 同步预构建产物;GitHub latest 可定时检查并可选自动发布。所有来源统一生成不可变部署,由边缘 Agent 拉取并通过 OpenResty 本地提供服务,支持回滚、SPA Fallback 与 API 反向代理。
* **TLS 证书自动化**:支持证书动态上传、多域名证书自动匹配绑定,以及通过 ACME 协议向 Let's Encrypt 自动申请与续期证书。
* **Uptime Kuma 监控同步**:与 Uptime Kuma 集成,自动差分同步监控站点列表,实时感知节点存活与服务可用状态。
* **SSO 单点登录**:支持 GitHub OAuth 与标准 OIDC 协议,无缝接入企业身份提供商实现统一登录。
* **统一观测**:聚合节点请求指标、实时访问日志明细、宿主机与 Nginx 资源快照、健康事件以及网络波动补传缓冲。
### Backend
- **[Go 1.25+](https://go.dev/doc)** — Primary language
- **[Gin](https://github.com/gin-gonic/gin)** — HTTP web framework
- **[GORM](https://github.com/go-gorm/gorm)** — ORM with PostgreSQL & ClickHouse support
- **[Redis](https://github.com/redis/redis)** — Cache, session store, and task queue backend
- **[Asynq](https://github.com/hibiken/asynq)** — Distributed task queue (Redis-backed)
- **[Cobra + Viper](https://github.com/spf13/cobra)** — CLI entrypoint and configuration management
- **[OpenTelemetry](https://opentelemetry.io)** — Distributed tracing and observability
- **[Zap](https://github.com/uber-go/zap)** — Structured, high-performance logging
- **[Swagger (Swaggo)](https://github.com/swaggo/swag)** — Auto-generated API documentation
- **[AWS SDK v2](https://github.com/aws/aws-sdk-go-v2)** — S3-compatible file storage
- **[Snowflake](https://github.com/bwmarrin/snowflake)** — Distributed ID generation
## 界面预览
### Frontend
- **[Next.js 16](https://github.com/vercel/next.js)** — React framework with App Router
- **[React 19](https://github.com/facebook/react)** — UI library
- **[TypeScript](https://github.com/microsoft/TypeScript)** — Type safety
- **[Tailwind CSS 4](https://github.com/tailwindlabs/tailwindcss)** — Utility-first styling
- **[Shadcn UI](https://github.com/shadcn-ui/ui)** — Accessible, composable component library
- **[Lucide Icons](https://github.com/lucide-icons/lucide)** — Icon library
### 仪表盘总览
## 📋 Requirements
![OpenFlare dashboard overview](./docs/assets/readme/dashboard-overview.png)
- **Go** >= 1.25
- **Node.js** >= 18.0
- **PostgreSQL** >= 14
- **Redis** >= 6.0
- **pnpm** >= 8.0 (recommended)
### 节点详情
## 🚀 Quick Start
![OpenFlare node detail](./docs/assets/readme/node-detail.png)
### 配置新增
![OpenFlare version release](./docs/assets/readme/proxy-route-detail.png)
## 快速开始
### 1. 启动 Server
使用 docker-compose
### 1. Clone the Repository
```bash
# 下载环境变量模板并创建 .env 文件
curl -o .env.example https://raw.githubusercontent.com/Rain-kl/OpenFlare/refs/heads/main/.env.example
cp .env.example .env
# ClickHouse 服务端:curl performance.xml 到 ./config/clickhouse,并以单文件方式挂载到 config.d
mkdir -p ./config/clickhouse
curl -fsSL -o ./config/clickhouse/performance.xml \
https://raw.githubusercontent.com/Rain-kl/OpenFlare/refs/heads/main/config/clickhouse/performance.xml
git clone https://github.com/Rain-kl/Wavelet.git refreshing
cd refreshing
```
```yaml
services:
openflare:
image: ghcr.io/rain-kl/openflare:latest
restart: unless-stopped
env_file: .env
environment:
TZ: ${TZ:-Asia/Shanghai}
ports:
- "3000:3000"
volumes:
- openflare_uploads:/app/uploads
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
clickhouse:
condition: service_healthy
postgres:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${DB_NAME:-openflare}
POSTGRES_USER: ${DB_USERNAME:-openflare}
POSTGRES_PASSWORD: ${DB_PASSWORD:-replace-with-strong-password}
volumes:
- openflare_postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-openflare} -d ${DB_NAME:-openflare}"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: valkey/valkey:8.0-alpine
restart: unless-stopped
command: ["valkey-server", "--appendonly", "yes"]
volumes:
- openflare_redis_data:/data
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
start_period: 5s
clickhouse:
image: clickhouse/clickhouse-server:25.3-alpine
restart: unless-stopped
environment:
CLICKHOUSE_DB: ${CLICKHOUSE_NAME:-openflare}
CLICKHOUSE_USER: ${CLICKHOUSE_USERNAME:-default}
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-replace-with-clickhouse-password}
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1
TZ: ${TZ:-Asia/Shanghai}
ulimits:
nofile:
soft: 262144
hard: 262144
volumes:
- openflare_clickhouse_data:/var/lib/clickhouse
- ./config/clickhouse/performance.xml:/etc/clickhouse-server/config.d/performance.xml:ro
healthcheck:
test: ["CMD", "clickhouse-client", "--user", "${CLICKHOUSE_USERNAME:-default}", "--password", "${CLICKHOUSE_PASSWORD:-replace-with-clickhouse-password}", "--query", "SELECT 1"]
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
volumes:
openflare_uploads:
openflare_postgres_data:
openflare_redis_data:
openflare_clickhouse_data:
```
详细部署说明见 [部署文档](https://open-flare.pages.dev/deployment/deployment)。
访问地址:`http://localhost:3000`
默认账号:
* 用户名:`admin`
* 密码:`12345678`
### 2. 安装 Agent
安装 Agent 前请先在节点上安装 OpenResty,或改用内置 OpenResty 的 Agent Docker 镜像。
你可以在控制面板的节点管理->详情->节点信息->节点标识与部署复制安装命令,或直接使用下面的脚本:
#### Docker 部署
Docker 部署可直接运行 Agent 镜像:
### 2. Configure Environment
```bash
docker pull ghcr.io/rain-kl/openflare-agent:latest
docker rm -f openflare-agent 2>/dev/null || true
docker run -d --name openflare-agent --restart unless-stopped \
-p 80:80 -p 443:443/tcp -p 443:443/udp \
-v openflare-agent-pages:/data/var/lib/openflare/pages \
-e OPENFLARE_SERVER_URL=http://your-server:3000 \
-e OPENFLARE_AGENT_TOKEN=YOUR_AGENT_TOKEN \
ghcr.io/rain-kl/openflare-agent:latest
cp config.example.yaml config.yaml
```
## 开源协议
Edit `config.yaml` to configure your database and Redis. OIDC auth sources are configured at runtime in the admin settings page.
本项目采用 [Apache License 2.0](./LICENSE) 开源。
### 3. Initialize Database
## Star History
```bash
# Start local dependencies (PostgreSQL + Redis)
docker compose up -d
<a href="https://www.star-history.com/?repos=Rain-kl%2FOpenFlare&type=date&legend=bottom-right">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=Rain-kl/OpenFlare&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=Rain-kl/OpenFlare&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=Rain-kl/OpenFlare&type=date&legend=top-left" />
</picture>
</a>
# Optional: also start ClickHouse
docker compose --profile clickhouse up -d
# If you use an external PostgreSQL instance instead of Docker, create the database manually
createdb -h <host> -p 5432 -U postgres refreshing
# Database schema is auto-migrated on first startup
```
### 4. Start the Backend
```bash
# Install Go dependencies
go mod tidy
# Generate Swagger API documentation
make swagger
# Start the HTTP API server
go run main.go api
```
> The backend also supports separate `scheduler` and `worker` processes for async task processing:
> ```bash
> go run main.go scheduler # Cron job scheduler
> go run main.go worker # Asynq task worker
> ```
### 5. Start the Frontend
```bash
cd frontend
# Install dependencies
pnpm install
# Start dev server (Turbopack)
pnpm dev
```
### 6. Access the Application
| Service | URL |
|---------|-----|
| Frontend | http://localhost:3000 |
| Swagger API Docs | http://localhost:8000/swagger/index.html |
| Health Check | http://localhost:8000/api/health |
## ⚙️ Configuration
Key configuration options (see `config.example.yaml` for the full reference):
| Option | Description | Example |
|--------|-------------|---------|
| `app.addr` | Backend listen address | `:8000` |
| `database.host` | PostgreSQL host | `127.0.0.1` |
| `database.database` | Database name | `refreshing` |
| `redis.host` | Redis host | `127.0.0.1` |
| `storage.endpoint` | S3-compatible endpoint | `s3.amazonaws.com` |
## 🔧 Development Guide
### Backend
```bash
# Run API server
go run main.go api
# Run task scheduler
go run main.go scheduler
# Run async worker
go run main.go worker
# Regenerate Swagger docs (required after controller changes)
make swagger
# Format & vet code
make tidy
```
### Frontend
```bash
cd frontend
# Development mode (Turbopack)
pnpm dev
# Production build
pnpm build
# Start production server
pnpm start
# Lint & format
pnpm lint
pnpm format
```
## 📁 Project Structure
```
wavelet/
├── main.go # Entry point (delegates to internal/cmd)
├── config.example.yaml # Configuration template
├── Makefile # Common commands (swagger, tidy, license, cross-build)
├── docker/ # Docker image build files (integrated/frontend/backend)
├── docs/ # Swagger auto-generated docs
├── frontend/ # Next.js frontend application
│ ├── app/ # App Router pages
│ ├── components/ # React components (ui, common, layout)
│ ├── lib/services/ # API service layer
│ └── types/ # TypeScript type definitions
└── internal/ # Go backend (private)
├── cmd/ # CLI commands (api, scheduler, worker)
├── apps/ # Business modules (oauth, user, admin, upload)
├── model/ # GORM entities and business methods
├── router/ # HTTP route registration
├── task/ # Async task definitions and workers
├── db/ # Database and Redis initialization
├── storage/ # S3 file storage abstraction
└── common/ # Shared utilities and response helpers
```
## 📚 API Documentation
Swagger API documentation is auto-generated and available once the backend is running:
```
http://localhost:8000/swagger/index.html
```
The built-in frontend docs portal at `/docs` includes:
- **Usage Guide** — Step-by-step walkthrough for getting started
- **API Reference** — Detailed interface documentation
- **Privacy Policy** — Template privacy policy (customize as needed)
- **Terms of Service** — Template terms of service
## 🧪 Testing
```bash
# Backend tests
go test ./...
# Frontend lint
cd frontend && pnpm lint
```
## 🚀 Deployment
### Cross-platform Binary
Build static binaries for all 6 targets (Linux / macOS / Windows × amd64 / arm64) with a single command.
The compiled frontend is embedded in every binary — no separate deployment needed.
**Prerequisites:** Docker with BuildKit enabled (Docker 23+ defaults to on).
```bash
# Build all 6 binaries → ./bin/
make cross-build
# Stamp a release version
make cross-build VERSION=v1.2.3
# Build only a specific OS (both architectures)
make cross-build GOOS=linux
make cross-build GOOS=darwin
make cross-build GOOS=windows
# Build only a specific architecture (all OSes)
make cross-build GOARCH=amd64
make cross-build GOARCH=arm64
# Combine filters — single binary
make cross-build GOOS=linux GOARCH=arm64
make cross-build GOOS=darwin GOARCH=amd64 VERSION=v1.2.3
```
Output files in `./bin/`:
| File | Platform |
|------|----------|
| `wavelet_linux_amd64` | Linux x86-64 |
| `wavelet_linux_arm64` | Linux ARM64 |
| `wavelet_darwin_amd64` | macOS Intel |
| `wavelet_darwin_arm64` | macOS Apple Silicon |
| `wavelet_windows_amd64.exe` | Windows x86-64 |
| `wavelet_windows_arm64.exe` | Windows ARM64 |
> The version string is accessible at runtime via `wavelet --version`.
### Docker
```bash
# Build image
docker build -t refreshing .
# Run (pass your config as a volume mount)
docker run -d -p 8000:8000 \
-v $(pwd)/config.yaml:/app/config.yaml \
refreshing api
```
### Production
1. Build the frontend:
```bash
cd frontend && pnpm build
```
2. Compile the backend:
```bash
go build -o refreshing main.go
```
3. Configure `config.yaml` for production.
4. Start services:
```bash
./refreshing api # HTTP API
./refreshing scheduler # Cron scheduler (optional)
./refreshing worker # Task worker (optional)
```
## 🤝 Contributing
We welcome contributions! Please read the following before submitting code:
- [Contributing Guidelines](CONTRIBUTING.md)
- [Code of Conduct](CODE_OF_CONDUCT.md)
- [Contributor License Agreement](CLA.md)
### Workflow
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/your-feature`)
3. Commit your changes (`git commit -am 'Add your feature'`)
4. Push to the branch (`git push origin feature/your-feature`)
5. Open a Pull Request
## 📄 License
This project is licensed under the [Apache 2.0 License](LICENSE).
+352
View File
@@ -0,0 +1,352 @@
# wavelet
🚀 现代化、生产就绪的全栈应用脚手架
[English](./README.md)
[![License: Apache2.0](https://img.shields.io/badge/License-Apache2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![Go Version](https://img.shields.io/badge/Go-1.25+-blue.svg)](https://golang.org/)
[![Next.js](https://img.shields.io/badge/Next.js-16-black.svg)](https://nextjs.org/)
[![React](https://img.shields.io/badge/React-19-blue.svg)](https://reactjs.org/)
## 📖 项目简介
**wavelet** 是一个通用型、生产就绪的现代全栈脚手架,后端采用 **Go(Gin + GORM)**,前端采用 **Next.js(App Router + Shadcn UI)**。项目开箱即用,内置构建现代 SaaS、内部工具或开发者平台所需的核心基础设施。
项目设计理念是 **框架优先、业务中立**:您可以在沿用经过实战检验的底层基础设施的同时,自由接入自己的业务逻辑。
### ✨ 主要特性
- 🔐 **多认证方式** — 本地账号密码登录/注册 + 可插拔 OIDC/OAuth2 认证源(支持同时配置多个认证源)
- 🗝️ **个人访问令牌** — API Key 管理,支持程序化接口访问;兼容 `Authorization: Bearer` 和 `X-Access-Token` 请求头
- 👤 **用户管理** — 管理后台提供用户列表、搜索筛选、启用/禁用账号等功能
- ⚙️ **动态系统配置** — KV 系统配置管理,支持实时变更,可通过管理后台界面直接操作
- 📋 **异步任务队列** — 基于 [Asynq](https://github.com/hibiken/asynq)(Redis 驱动)的后台任务处理系统,含任务调度面板
- 📁 **S3 文件存储** — 通过 S3 兼容 API 统一处理文件上传/下载,支持本地磁盘缓存
- 📊 **可观测性** — 结构化日志(Zap)+ 分布式链路追踪(OpenTelemetry)
- 🎨 **现代化 UI** — 基于 Tailwind CSS 4 和 Shadcn UI 构建的响应式、支持深色模式的设计系统
- 📖 **内置文档中心** — 集成文档门户,包含使用指南、接口文档、隐私政策和服务条款
## 🏗️ 架构概览
```
┌─────────────────┐ ┌─────────────────────────────┐ ┌─────────────────┐
│ 前端 │ │ 后端 │ │ 数据库 │
│ (Next.js) │◄──►│ (Go) │◄──►│ (PostgreSQL) │
│ │ │ │ │ │
│ • React 19 │ │ • Gin HTTP 框架 │ │ • PostgreSQL │
│ • TypeScript │ │ • GORM ORM │ │ • Redis 缓存 │
│ • Tailwind 4 │ │ • 多认证源适配 │ │ │
│ • Shadcn UI │ │ • AccessToken 中间件 │ │ │
│ │ │ • Asynq 任务队列 │ │ │
│ │ │ • OpenTelemetry 链路追踪 │ │ │
│ │ │ • Swagger 接口文档 │ │ │
└─────────────────┘ └─────────────────────────────┘ └─────────────────┘
│
┌──────────┴──────────┐
│ 多进程 CLI 入口 │
│ (Cobra + Viper) │
│ • api (HTTP) │
│ • worker (队列) │
│ • scheduler(定时) │
└─────────────────────┘
```
## 🛠️ 技术栈
### 后端
- **[Go 1.25+](https://go.dev/doc)** — 主语言
- **[Gin](https://github.com/gin-gonic/gin)** — HTTP Web 框架
- **[GORM](https://github.com/go-gorm/gorm)** — ORM,支持 PostgreSQL 和 ClickHouse
- **[Redis](https://github.com/redis/redis)** — 缓存、Session 存储、任务队列后端
- **[Asynq](https://github.com/hibiken/asynq)** — 分布式任务队列(Redis 驱动)
- **[Cobra + Viper](https://github.com/spf13/cobra)** — CLI 入口 + 配置管理
- **[OpenTelemetry](https://opentelemetry.io)** — 分布式链路追踪与可观测性
- **[Zap](https://github.com/uber-go/zap)** — 结构化高性能日志
- **[Swagger (Swaggo)](https://github.com/swaggo/swag)** — 自动生成 API 文档
- **[AWS SDK v2](https://github.com/aws/aws-sdk-go-v2)** — S3 兼容文件存储
- **[Snowflake](https://github.com/bwmarrin/snowflake)** — 分布式 ID 生成
### 前端
- **[Next.js 16](https://github.com/vercel/next.js)** — React 框架(App Router)
- **[React 19](https://github.com/facebook/react)** — UI 库
- **[TypeScript](https://github.com/microsoft/TypeScript)** — 类型安全
- **[Tailwind CSS 4](https://github.com/tailwindlabs/tailwindcss)** — 原子化 CSS 框架
- **[Shadcn UI](https://github.com/shadcn-ui/ui)** — 可访问、可组合的组件库
- **[Lucide Icons](https://github.com/lucide-icons/lucide)** — 图标库
## 📋 环境要求
- **Go** >= 1.25
- **Node.js** >= 18.0
- **PostgreSQL** >= 14
- **Redis** >= 6.0
- **pnpm** >= 8.0(推荐)
## 🚀 快速开始
### 1. 克隆仓库
```bash
git clone https://github.com/Rain-kl/Wavelet.git refreshing
cd refreshing
```
### 2. 配置环境
```bash
cp config.example.yaml config.yaml
```
编辑 `config.yaml`,配置数据库和 Redis。OIDC 认证源统一在管理后台的系统设置页面运行时配置。
### 3. 初始化数据库
```bash
# 启动本地依赖服务(PostgreSQL + Redis)
docker compose up -d
# 可选:同时启动 ClickHouse
docker compose --profile clickhouse up -d
# 如果使用外部 PostgreSQL,而不是 Docker 内置服务,则手动创建数据库
createdb -h <主机> -p 5432 -U postgres refreshing
# 数据库表结构在首次启动时自动迁移,无需手动执行
```
### 4. 启动后端
```bash
# 安装 Go 依赖
go mod tidy
# 生成 Swagger 接口文档
make swagger
# 启动 HTTP API 服务器
go run main.go api
```
> 后端也支持独立运行 `scheduler` 和 `worker` 进程来处理异步任务:
> ```bash
> go run main.go scheduler # 定时任务调度器
> go run main.go worker # Asynq 任务处理工作进程
> ```
### 5. 启动前端
```bash
cd frontend
# 安装依赖
pnpm install
# 启动开发服务器(Turbopack)
pnpm dev
```
### 6. 访问应用
| 服务 | 地址 |
|------|------|
| 前端界面 | http://localhost:3000 |
| Swagger 接口文档 | http://localhost:8000/swagger/index.html |
| 健康检查 | http://localhost:8000/api/health |
## ⚙️ 配置说明
主要配置项(完整说明请参考 `config.example.yaml`):
| 配置项 | 说明 | 示例 |
|--------|------|------|
| `app.addr` | 后端监听地址 | `:8000` |
| `database.host` | PostgreSQL 主机 | `127.0.0.1` |
| `database.database` | 数据库名称 | `refreshing` |
| `redis.host` | Redis 主机 | `127.0.0.1` |
| `storage.endpoint` | S3 兼容存储端点 | `s3.amazonaws.com` |
## 🔧 开发指南
### 后端
```bash
# 运行 API 服务器
go run main.go api
# 运行定时任务调度器
go run main.go scheduler
# 运行异步任务工作进程
go run main.go worker
# 修改 Controller 后重新生成 Swagger 文档(必须执行)
make swagger
# 代码格式化与检查
make tidy
```
### 前端
```bash
cd frontend
# 开发模式(Turbopack)
pnpm dev
# 构建生产版本
pnpm build
# 启动生产服务器
pnpm start
# 代码 Lint 和格式化
pnpm lint
pnpm format
```
## 📁 项目结构
```
wavelet/
├── main.go # 程序入口(委托给 internal/cmd)
├── config.example.yaml # 配置模板
├── Makefile # 常用命令(swagger、tidy、license、cross-build)
├── docker/ # Docker 镜像构建文件(集成/前端/后端)
├── docs/ # Swagger 自动生成文档
├── frontend/ # Next.js 前端应用
│ ├── app/ # App Router 页面
│ ├── components/ # React 组件(ui、common、layout)
│ ├── lib/services/ # API 服务层
│ └── types/ # TypeScript 类型定义
└── internal/ # Go 后端(private)
├── cmd/ # CLI 命令(api、scheduler、worker)
├── apps/ # 业务模块(oauth、user、admin、upload)
├── model/ # GORM 实体与业务方法
├── router/ # HTTP 路由注册
├── task/ # 异步任务定义与工作进程
├── db/ # 数据库与 Redis 初始化
├── storage/ # S3 文件存储抽象层
└── common/ # 公共工具与响应封装
```
## 📚 接口文档
Swagger 接口文档在后端启动后自动可用:
```
http://localhost:8000/swagger/index.html
```
前端文档中心(路径 `/docs`)内置以下内容:
- **使用指南** — 分步入门教程
- **接口文档** — 详细接口说明
- **隐私政策** — 隐私政策模板(请按需自定义)
- **服务条款** — 服务条款模板
## 🧪 测试
```bash
# 后端测试
go test ./...
# 前端 Lint
cd frontend && pnpm lint
```
## 🚀 部署
### 跨平台二进制编译
一条命令构建全部 6 个平台的静态二进制文件(Linux / macOS / Windows × amd64 / arm64)。
前端已内嵌到每个二进制文件中,无需单独部署。
**前提条件:** 已安装 Docker 且启用 BuildKit(Docker 23+ 默认开启)。
```bash
# 构建全部 6 个二进制文件 → ./bin/
make cross-build
# 指定版本号
make cross-build VERSION=v1.2.3
# 只构建指定系统(两种架构均会构建)
make cross-build GOOS=linux
make cross-build GOOS=darwin
make cross-build GOOS=windows
# 只构建指定架构(所有系统均会构建)
make cross-build GOARCH=amd64
make cross-build GOARCH=arm64
# 同时指定系统和架构 — 只生成单个文件
make cross-build GOOS=linux GOARCH=arm64
make cross-build GOOS=darwin GOARCH=amd64 VERSION=v1.2.3
```
输出到 `./bin/` 目录:
| 文件名 | 平台 |
|--------|------|
| `wavelet_linux_amd64` | Linux x86-64 |
| `wavelet_linux_arm64` | Linux ARM64 |
| `wavelet_darwin_amd64` | macOS Intel |
| `wavelet_darwin_arm64` | macOS Apple Silicon |
| `wavelet_windows_amd64.exe` | Windows x86-64 |
| `wavelet_windows_arm64.exe` | Windows ARM64 |
> 版本号可通过 `wavelet --version` 在运行时查看。
### Docker
```bash
# 构建镜像
docker build -t refreshing .
# 运行(通过卷挂载传入配置文件)
docker run -d -p 8000:8000 \
-v $(pwd)/config.yaml:/app/config.yaml \
refreshing api
```
### 生产环境
1. 构建前端资源:
```bash
cd frontend && pnpm build
```
2. 编译后端程序:
```bash
go build -o refreshing main.go
```
3. 配置生产环境的 `config.yaml`。
4. 启动服务:
```bash
./refreshing api # HTTP API
./refreshing scheduler # 定时调度器(可选)
./refreshing worker # 任务工作进程(可选)
```
## 🤝 贡献指南
我们欢迎社区贡献!请在提交代码前阅读以下文档:
- [贡献指南](CONTRIBUTING.md)
- [行为准则](CODE_OF_CONDUCT.md)
- [贡献者许可协议](CLA.md)
### 贡献流程
1. Fork 本仓库
2. 创建特性分支 (`git checkout -b feature/your-feature`)
3. 提交更改 (`git commit -am 'Add your feature'`)
4. 推送到分支 (`git push origin feature/your-feature`)
5. 创建 Pull Request
## 📄 许可证
本项目基于 [Apache 2.0 许可证](LICENSE) 开源。
-152
View File
@@ -1,152 +0,0 @@
// Command agent runs the OpenFlare edge agent daemon.
package main
import (
"context"
"flag"
"log/slog"
"os"
"os/signal"
"syscall"
"github.com/Rain-kl/Wavelet/internal/apps/agent/agent"
"github.com/Rain-kl/Wavelet/internal/apps/agent/config"
"github.com/Rain-kl/Wavelet/internal/apps/agent/geoipupdate"
"github.com/Rain-kl/Wavelet/internal/apps/agent/heartbeat"
"github.com/Rain-kl/Wavelet/internal/apps/agent/httpclient"
"github.com/Rain-kl/Wavelet/internal/apps/agent/logging"
"github.com/Rain-kl/Wavelet/internal/apps/agent/nginx"
"github.com/Rain-kl/Wavelet/internal/apps/agent/runtimeuser"
"github.com/Rain-kl/Wavelet/internal/apps/agent/state"
syncservice "github.com/Rain-kl/Wavelet/internal/apps/agent/sync"
"github.com/Rain-kl/Wavelet/internal/apps/agent/updater"
"github.com/Rain-kl/Wavelet/internal/apps/agent/wsclient"
)
func main() {
logging.Setup()
configPath := flag.String("config", "./agent.json", "agent config path")
flag.Parse()
cfg, err := config.Load(*configPath)
if err != nil {
slog.Error("load agent config failed", "error", err)
os.Exit(1)
}
if err = runtimeuser.EnsureProcessUser(); err != nil {
slog.Error("ensure runtime user failed", "error", err)
os.Exit(1)
}
if err = runtimeuser.EnsurePathOwnership(cfg.DataDir, runtimeuser.DefaultDirPerm, runtimeuser.DefaultFilePerm); err != nil {
slog.Error("ensure data dir ownership failed", "error", err, "data_dir", cfg.DataDir)
os.Exit(1)
}
cfg.ExtVersion = nginx.DetectVersion(
context.Background(),
nginx.ExecutorOptions{
NginxPath: cfg.OpenrestyPath,
MainConfigPath: cfg.MainConfigPath,
RouteConfigPath: cfg.RouteConfigPath,
CertDir: cfg.CertDir,
NginxCertDir: cfg.OpenrestyCertDir,
LuaDir: cfg.LuaDir,
NginxLuaDir: cfg.OpenrestyLuaDir,
OpenrestyObservabilityPort: cfg.OpenrestyObservabilityPort,
},
)
slog.Info("agent config loaded",
"server", cfg.ServerURL,
"node", cfg.NodeName,
"ip", cfg.NodeIP,
"heartbeat_interval", cfg.HeartbeatInterval,
"route_config", cfg.RouteConfigPath,
"access_log", cfg.AccessLogPath,
"cert_dir", cfg.CertDir,
"lua_dir", cfg.LuaDir,
"runtime_config_dir", cfg.RuntimeConfigDir,
"mmdb_path", cfg.MMDBPath,
"city_mmdb_path", cfg.CityMMDBPath,
)
client := httpclient.New(cfg.ServerURL, cfg.InitialAuthToken(), cfg.RequestTimeout.Duration())
wsClient := wsclient.New(cfg.ServerURL, cfg.InitialAuthToken(), cfg.RequestTimeout.Duration())
stateStore := state.NewStore(cfg.StatePath)
observabilityBuffer := state.NewObservabilityBufferStore(cfg.ObservabilityBufferPath)
runtimeManager := &nginx.Manager{
MainConfigPath: cfg.MainConfigPath,
RouteConfigPath: cfg.RouteConfigPath,
AccessLogPath: cfg.AccessLogPath,
CertDir: cfg.CertDir,
NginxCertDir: cfg.OpenrestyCertDir,
LuaDir: cfg.LuaDir,
NginxLuaDir: cfg.OpenrestyLuaDir,
RuntimeConfigDir: cfg.RuntimeConfigDir,
MMDBPath: cfg.MMDBPath,
CityMMDBPath: cfg.CityMMDBPath,
PagesDir: cfg.PagesDir,
OpenrestyObservabilityListen: nginx.ObservabilityListenAddress(cfg.OpenrestyObservabilityPort),
OpenrestyObservabilityPort: cfg.OpenrestyObservabilityPort,
OpenrestyResolverDirective: "",
Executor: nginx.NewExecutor(nginx.ExecutorOptions{
NginxPath: cfg.OpenrestyPath,
MainConfigPath: cfg.MainConfigPath,
RouteConfigPath: cfg.RouteConfigPath,
CertDir: cfg.CertDir,
NginxCertDir: cfg.OpenrestyCertDir,
LuaDir: cfg.LuaDir,
NginxLuaDir: cfg.OpenrestyLuaDir,
OpenrestyObservabilityPort: cfg.OpenrestyObservabilityPort,
}),
}
if err = runtimeManager.EnsureLuaAssets(); err != nil {
slog.Error("ensure managed lua assets failed", "error", err)
os.Exit(1)
}
syncService := syncservice.New(client, runtimeManager, stateStore)
syncService.SetPagesDir(cfg.PagesDir)
heartbeatService := heartbeat.New(client)
updateService := updater.New()
runner := &agent.Runner{
Config: cfg,
StateStore: stateStore,
HeartbeatCycle: &heartbeat.Cycle{
Config: cfg,
StateStore: stateStore,
ObservabilityBuffer: observabilityBuffer,
Heartbeat: heartbeatService,
Sync: syncService,
Updater: updateService,
},
HeartbeatService: heartbeatService,
SyncService: syncService,
RuntimeManager: runtimeManager,
WebSocketService: wsClient,
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
geoIPUpdater := newGeoIPUpdater(cfg)
if err = geoIPUpdater.EnsureInitialDatabases(ctx); err != nil {
slog.Warn("failed to prepare GeoIP databases before agent startup", "error", err)
}
go geoIPUpdater.Run(ctx)
slog.Info("agent process started")
if err = runner.Run(ctx); err != nil && err != context.Canceled {
slog.Error("agent process exited with error", "error", err)
stop()
os.Exit(1)
}
stop()
slog.Info("agent process stopped")
}
func newGeoIPUpdater(cfg *config.Config) *geoipupdate.Updater {
return &geoipupdate.Updater{
MMDBPath: cfg.MMDBPath,
DownloadURL: cfg.MMDBDownloadURL,
CityMMDBPath: cfg.CityMMDBPath,
CityDownloadURL: cfg.CityMMDBDownloadURL,
UpdateInterval: cfg.MMDBUpdateInterval.Duration(),
}
}
-24
View File
@@ -1,24 +0,0 @@
package main
import (
"testing"
"time"
"github.com/Rain-kl/Wavelet/internal/apps/agent/config"
)
func TestNewGeoIPUpdaterWiresCountryAndCity(t *testing.T) {
cfg := &config.Config{
MMDBPath: "/data/GeoLite2-Country.mmdb",
MMDBDownloadURL: "https://geo.example/GeoLite2-Country.mmdb",
CityMMDBPath: "/data/GeoLite2-City.mmdb",
CityMMDBDownloadURL: "https://geo.example/GeoLite2-City.mmdb",
MMDBUpdateInterval: config.MillisecondDuration(time.Hour),
}
updater := newGeoIPUpdater(cfg)
if updater.MMDBPath != cfg.MMDBPath || updater.DownloadURL != cfg.MMDBDownloadURL ||
updater.CityMMDBPath != cfg.CityMMDBPath || updater.CityDownloadURL != cfg.CityMMDBDownloadURL ||
updater.UpdateInterval != time.Hour {
t.Fatalf("GeoIP updater wiring incomplete: %#v", updater)
}
}
-73
View File
@@ -1,73 +0,0 @@
// Command flared runs the OpenFlare tunnel client daemon.
package main
import (
"context"
"flag"
"log/slog"
"os"
"os/signal"
"syscall"
edgelogging "github.com/Rain-kl/Wavelet/internal/apps/edge/logging"
"github.com/Rain-kl/Wavelet/internal/apps/flared/config"
"github.com/Rain-kl/Wavelet/internal/apps/flared/flared"
"github.com/Rain-kl/Wavelet/internal/apps/flared/frpc"
"github.com/Rain-kl/Wavelet/internal/apps/flared/heartbeat"
"github.com/Rain-kl/Wavelet/internal/apps/flared/httpclient"
"github.com/Rain-kl/Wavelet/internal/apps/flared/sync"
"github.com/Rain-kl/Wavelet/internal/apps/flared/wsclient"
)
func main() {
edgelogging.Setup(edgelogging.Options{})
configPath := flag.String("config", "./flared.json", "flared config path")
flag.Parse()
cfg, err := config.Load(*configPath)
if err != nil {
slog.Error("load flared config failed", "error", err)
os.Exit(1)
}
slog.Info("flared config loaded",
"server", cfg.ServerURL,
"frpc_path", cfg.FrpcPath,
"data_dir", cfg.DataDir,
"heartbeat_interval", cfg.HeartbeatInterval,
"sync_interval", cfg.SyncInterval,
)
frpcManager := frpc.NewManager(cfg)
_ = frpcManager.LoadState()
slog.Info("detected frpc version", "version", frpcManager.GetVersion(context.Background()))
httpClient := httpclient.New(cfg.ServerURL, cfg.InitialAuthToken(), cfg.RequestTimeout.Duration())
wsClient := wsclient.New(cfg.ServerURL, cfg.InitialAuthToken(), cfg.RequestTimeout.Duration())
syncService := sync.New(httpClient, frpcManager, cfg)
heartbeatService := heartbeat.New(httpClient, frpcManager, cfg)
runner := &flared.Runner{
Config: cfg,
FrpcManager: frpcManager,
HTTPClient: httpClient,
WebSocketService: wsClient,
HeartbeatService: heartbeatService,
SyncService: syncService,
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
slog.Info("flared process started")
if err := runner.Run(ctx); err != nil && err != context.Canceled {
slog.Error("flared process exited with error", "error", err)
stop()
os.Exit(1)
}
stop()
slog.Info("flared process stopped")
}
-73
View File
@@ -1,73 +0,0 @@
// Command relay runs the OpenFlare relay node daemon.
package main
import (
"context"
"flag"
"log/slog"
"os"
"os/signal"
"syscall"
edgelogging "github.com/Rain-kl/Wavelet/internal/apps/edge/logging"
"github.com/Rain-kl/Wavelet/internal/apps/relay/config"
"github.com/Rain-kl/Wavelet/internal/apps/relay/frps"
"github.com/Rain-kl/Wavelet/internal/apps/relay/heartbeat"
"github.com/Rain-kl/Wavelet/internal/apps/relay/httpclient"
"github.com/Rain-kl/Wavelet/internal/apps/relay/relay"
"github.com/Rain-kl/Wavelet/internal/apps/relay/state"
"github.com/Rain-kl/Wavelet/internal/apps/relay/wsclient"
)
func main() {
edgelogging.Setup(edgelogging.Options{})
configPath := flag.String("config", "./relay.json", "relay config path")
flag.Parse()
cfg, err := config.Load(*configPath)
if err != nil {
slog.Error("load relay config failed", "error", err)
os.Exit(1)
}
slog.Info("relay config loaded",
"server", cfg.ServerURL,
"node", cfg.NodeName,
"ip", cfg.NodeIP,
"frps_path", cfg.FrpsPath,
"data_dir", cfg.DataDir,
"heartbeat_interval", cfg.HeartbeatInterval,
)
stateStore := state.NewStore(cfg.StatePath)
_ = stateStore // In the future we may use stateStore for auth caching
frpsManager := frps.NewManager(cfg.FrpsPath, cfg.DataDir, cfg.InitialAuthToken())
slog.Info("detected frps version", "version", frpsManager.GetVersion(context.Background()))
httpClient := httpclient.New(cfg.ServerURL, cfg.InitialAuthToken(), cfg.RequestTimeout.Duration())
wsClient := wsclient.New(cfg.ServerURL, cfg.InitialAuthToken(), cfg.RequestTimeout.Duration())
runner := &relay.Runner{
Config: cfg,
StateStore: stateStore,
FrpsManager: frpsManager,
HTTPClient: httpClient,
WebSocketService: wsClient,
HeartbeatService: heartbeat.New(httpClient, frpsManager, cfg, stateStore),
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
slog.Info("relay process started")
if err := runner.Run(ctx); err != nil && err != context.Canceled {
slog.Error("relay process exited with error", "error", err)
stop()
os.Exit(1)
}
stop()
slog.Info("relay process stopped")
}
+20 -23
View File
@@ -1,15 +1,15 @@
# openflare — Platform Config
# wavelet — Full-Stack Boilerplate Config
# Copy this file to config.yaml and fill in your values.
# Fields marked with <...> are required; others have sensible defaults.
# ─── Application ────────────────────────────────────────────────────────────────
app:
app_name: "openflare"
env: "production" # development | testing | production
addr: ":3000"
app_name: "wavelet"
env: "development" # development | testing | production
addr: ":8000"
node_id: 1 # Snowflake node ID (0-1023). Must be unique per instance.
graceful_shutdown_timeout: 30
session_cookie_name: "openflare_session_id" # Change to something unique before deploy
session_cookie_name: "wavelet_session_id" # Change to something unique before deploy
session_secret: "<uniq-random-string>" # Cannot be changed after first start
session_domain: "" # e.g. ".yourdomain.com"
session_age: 86400 # Session lifetime in seconds (default: 24h)
@@ -21,12 +21,12 @@ app:
# Supports Standalone and Primary-Replica (read/write split) modes.
database:
enabled: true
sqlite_path: "openflare.db" # PostgreSQL 禁用时使用此 SQLite 文件路径
sqlite_path: "wavelet.db" # PostgreSQL 禁用时使用此 SQLite 文件路径
host: "127.0.0.1"
port: 5432
username: "openflare"
password: "replace-with-strong-password"
database: "openflare"
username: "postgres"
password: "postgres"
database: "wavelet"
max_idle_conn: 16
max_open_conn: 128
conn_max_lifetime: 1800
@@ -34,7 +34,7 @@ database:
log_level: "info" # error | warn | info | debug | silent;SQL 语句仅在 log.level=debug 时输出
ssl_mode: "disable"
time_zone: "UTC"
application_name: "openflare-server"
application_name: "wavelet-server"
prefer_simple_protocol: false
search_path: "public"
statement_cache_capacity: 256
@@ -58,7 +58,7 @@ redis:
db: 0 # Ignored in Cluster mode
cluster_mode: false # Set true to enable Cluster mode
master_name: "" # Set non-empty to enable Sentinel mode
key_prefix: "openflare:"
key_prefix: "wavelet:"
pool_size: 100
min_idle_conn: 10
dial_timeout: 5
@@ -96,22 +96,19 @@ worker:
# ─── OpenTelemetry Tracing ──────────────────────────────────────────────────────
otel:
sampling_rate: 0.0 # Trace sampling rate (0.0 – 1.0)
tracer_name: "github.com/Rain-kl/OpenFlare" # Global tracer instrumentation name
tracer_name: "github.com/Rain-kl/Wavelet" # Global tracer instrumentation name
# ─── ClickHouse (required) ──────────────────────────────────────────────────────
# Analytics / observability OLAP store. Telemetry writes are best-effort (async batch).
# ─── ClickHouse (optional) ──────────────────────────────────────────────────────
clickhouse:
enabled: true
enabled: false
hosts:
- "127.0.0.1:9000" # compose 内应用可用 clickhouse:9000(经 CLICKHOUSE_HOST)
- "127.0.0.1:9000"
username: "default"
password: "replace-with-clickhouse-password" # 与 .env / compose CLICKHOUSE_PASSWORD 一致
database: "openflare"
max_idle_conn: 8 # keep warm sockets low to save client + server RAM
max_open_conn: 16 # cap concurrent native sessions on modest CH boxes
password: ""
database: "wavelet"
max_idle_conn: 10
max_open_conn: 100
conn_max_lifetime: 3600
dial_timeout: 5
block_buffer_size: 32 # rows buffered per block; 32 is enough for our batch sizes
# Runtime client also enables async_insert (wait_for_async_insert=1, busy_timeout≈2s)
# in internal/infra/persistence/clickhouse.go — not configured via YAML.
block_buffer_size: 10
-25
View File
@@ -1,25 +0,0 @@
<?xml version="1.0"?>
<!--
Tuned for small control-plane hosts (e.g. 3c6g).
background_pool_size * background_merges_mutations_concurrency_ratio must stay
greater than merge_tree number_of_free_entries_in_pool_to_execute_mutation
(ClickHouse 25.x refuses to start otherwise). Keep the merge free-entry
thresholds low so a small pool remains valid.
-->
<clickhouse>
<max_concurrent_queries>20</max_concurrent_queries>
<background_pool_size>4</background_pool_size>
<background_merges_mutations_concurrency_ratio>2</background_merges_mutations_concurrency_ratio>
<background_schedule_pool_size>4</background_schedule_pool_size>
<background_common_pool_size>2</background_common_pool_size>
<background_fetches_pool_size>2</background_fetches_pool_size>
<background_move_pool_size>1</background_move_pool_size>
<mark_cache_size>268435456</mark_cache_size>
<uncompressed_cache_size>0</uncompressed_cache_size>
<merge_tree>
<number_of_free_entries_in_pool_to_execute_mutation>2</number_of_free_entries_in_pool_to_execute_mutation>
<number_of_free_entries_in_pool_to_lower_max_size_of_merge>2</number_of_free_entries_in_pool_to_lower_max_size_of_merge>
<number_of_free_entries_in_pool_to_execute_optimize_entire_partition>2</number_of_free_entries_in_pool_to_execute_optimize_entire_partition>
</merge_tree>
</clickhouse>
-145
View File
@@ -1,145 +0,0 @@
services:
openflare:
build:
context: .
dockerfile: docker/Dockerfile
args:
VERSION: v0.9.9
# image: ghcr.io/rain-kl/openflare:latest
restart: unless-stopped
env_file: .env
environment:
TZ: ${TZ:-Asia/Shanghai}
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://jaeger:4317}
OTEL_EXPORTER_OTLP_INSECURE: ${OTEL_EXPORTER_OTLP_INSECURE:-true}
OTEL_SAMPLING_RATE: ${OTEL_SAMPLING_RATE:-1.0}
ports:
- "3000:3000"
volumes:
- ./uploads:/app/uploads
- ./data/sqlite:/app/data
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
clickhouse:
condition: service_healthy
jaeger:
condition: service_started
postgres:
image: postgres:17-alpine
restart: unless-stopped
ports:
- "5432:5432"
environment:
POSTGRES_DB: ${DB_NAME:-openflare}
POSTGRES_USER: ${DB_USERNAME:-openflare}
POSTGRES_PASSWORD: ${DB_PASSWORD:-replace-with-strong-password}
volumes:
- ./data/postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-openflare} -d ${DB_NAME:-openflare}"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: valkey/valkey:8.0-alpine
restart: unless-stopped
command: ["valkey-server", "--appendonly", "yes"]
ports:
- "${REDIS_PORT:-6379}:6379"
volumes:
- ./data/valkey:/data
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
start_period: 5s
jaeger:
image: jaegertracing/jaeger:${JAEGER_VERSION:-2.19.0}
restart: unless-stopped
environment:
TZ: ${TZ:-Asia/Shanghai}
ports:
- "${JAEGER_UI_PORT:-16686}:16686"
- "${JAEGER_OTLP_GRPC_PORT:-4317}:4317"
- "${JAEGER_OTLP_HTTP_PORT:-4318}:4318"
clickhouse:
image: clickhouse/clickhouse-server:25.3-alpine
restart: unless-stopped
environment:
CLICKHOUSE_DB: ${CLICKHOUSE_NAME:-openflare}
CLICKHOUSE_USER: ${CLICKHOUSE_USERNAME:-default}
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-replace-with-clickhouse-password}
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1
TZ: ${TZ:-Asia/Shanghai}
ulimits:
nofile:
soft: 262144
hard: 262144
ports:
- "8123:8123"
- "9000:9000"
volumes:
- ./data/clickhouse_data:/var/lib/clickhouse
- ./config/clickhouse/performance.xml:/etc/clickhouse-server/config.d/performance.xml:ro
healthcheck:
test: ["CMD", "clickhouse-client", "--user", "${CLICKHOUSE_USERNAME:-default}", "--password", "${CLICKHOUSE_PASSWORD:-replace-with-clickhouse-password}", "--query", "SELECT 1"]
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
agent:
build:
context: .
dockerfile: docker/Dockerfile.agent
container_name: openflare-agent
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "127.0.0.1:18081:18081"
volumes:
- ./data/agent/:/data
environment:
OPENFLARE_SERVER_URL: "http://host.docker.internal:3000"
OPENFLARE_AGENT_TOKEN: "af2fb112f36a0055ec25dd164c908fea"
LOG_LEVEL: "debug"
extra_hosts:
- "host.docker.internal:host-gateway"
relay:
build:
context: .
dockerfile: docker/Dockerfile.relay
container_name: openflare-relay
network_mode: host
restart: unless-stopped
volumes:
- ./data/relay/:/app/data
environment:
OPENFLARE_SERVER_URL: http://host.docker.internal:3000
OPENFLARE_DISCOVERY_TOKEN: 85464eeb72c49abc430569d6b9c77f78
LOG_LEVEL: "debug"
extra_hosts:
- "host.docker.internal:host-gateway"
flared:
build:
context: .
dockerfile: docker/Dockerfile.flared
container_name: openflare-flared
network_mode: "host"
restart: unless-stopped
volumes:
- ./data/flared/:/app/data
environment:
OPENFLARE_SERVER_URL: "http://host.docker.internal:3000"
OPENFLARE_TUNNEL_TOKEN: deb0783ac1e264a9d86440169aca0f09
+92
View File
@@ -0,0 +1,92 @@
services:
wavelet:
build:
context: .
dockerfile: docker/Dockerfile
args:
VERSION: canary
image: ghcr.io/rain-kl/wavelet:canary
restart: unless-stopped
env_file: .env
environment:
TZ: ${TZ:-Asia/Shanghai}
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://jaeger:4317}
OTEL_EXPORTER_OTLP_INSECURE: ${OTEL_EXPORTER_OTLP_INSECURE:-true}
OTEL_SAMPLING_RATE: ${OTEL_SAMPLING_RATE:-1.0}
ports:
- "${APP_PORT:-8000}:8000"
volumes:
- ./data/uploads:/app/uploads
- ./data/sqlite:/app/data
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
jaeger:
condition: service_started
postgres:
image: postgres:18-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-wavelet}
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
TZ: ${TZ:-Asia/Shanghai}
ports:
- "${POSTGRES_PORT:-5432}:5432"
volumes:
- ./data/postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-wavelet}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
redis:
image: valkey/valkey:8.0-alpine
restart: unless-stopped
command: ["valkey-server", "--appendonly", "yes"]
ports:
- "${REDIS_PORT:-6379}:6379"
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
start_period: 5s
jaeger:
image: jaegertracing/jaeger:${JAEGER_VERSION:-2.19.0}
restart: unless-stopped
environment:
TZ: ${TZ:-Asia/Shanghai}
ports:
- "${JAEGER_UI_PORT:-16686}:16686"
- "${JAEGER_OTLP_GRPC_PORT:-4317}:4317"
- "${JAEGER_OTLP_HTTP_PORT:-4318}:4318"
#
# clickhouse:
# image: clickhouse/clickhouse-server:25.3-alpine
# restart: unless-stopped
# profiles:
# - clickhouse
# environment:
# CLICKHOUSE_DB: ${CLICKHOUSE_DB:-wavelet}
# CLICKHOUSE_USER: ${CLICKHOUSE_USER:-default}
# CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-123456}
# CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1
# TZ: ${TZ:-Asia/Shanghai}
# ports:
# - "${CLICKHOUSE_HTTP_PORT:-8123}:8123"
# - "${CLICKHOUSE_NATIVE_PORT:-9000}:9000"
# volumes:
# - ./data/clickhouse_data:/var/lib/clickhouse
# healthcheck:
# test: ["CMD", "clickhouse-client", "--query", "SELECT 1"]
# interval: 10s
# timeout: 5s
# retries: 5
# start_period: 15s
+4 -4
View File
@@ -46,7 +46,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build \
-tags embed_frontend \
-trimpath \
-ldflags="-s -w -X github.com/Rain-kl/Wavelet/internal/buildinfo.Version=${VERSION} -X github.com/Rain-kl/Wavelet/internal/buildinfo.BuildTime=${BUILD_DATE}" \
-o /out/openflare-server \
-o /out/wavelet \
./main.go
FROM alpine:${ALPINE_VERSION}
@@ -59,10 +59,10 @@ RUN apk add --no-cache ca-certificates tzdata postgresql-client && \
WORKDIR /app
COPY --from=backend-builder /out/openflare-server ./openflare-server
COPY --from=backend-builder /out/wavelet ./wavelet
COPY docs ./docs
EXPOSE 3000
EXPOSE 8000
ENTRYPOINT ["./openflare-server"]
ENTRYPOINT ["./wavelet"]
CMD ["all"]
-55
View File
@@ -1,55 +0,0 @@
# syntax=docker/dockerfile:1.7
# Agent image: slim binary + MMDB files on disk (not embedded in the binary).
ARG VERSION=dev
FROM golang:1.25-alpine AS builder
ARG VERSION
ARG TARGETOS=linux
ARG TARGETARCH
ENV CGO_ENABLED=0 \
GOOS=${TARGETOS} \
GOARCH=${TARGETARCH}
WORKDIR /build
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go build -trimpath -ldflags "-s -w -X 'github.com/Rain-kl/Wavelet/internal/apps/agent/config.Version=$VERSION'" -o /build/bin/openflare-agent ./cmd/agent/main.go
# Fetch MMDB into dist/geoip for COPY into the runtime image (not go:embed).
RUN apk add --no-cache bash curl \
&& bash scripts/fetch-agent-geoip-mmdb.sh
FROM openresty/openresty:alpine
RUN apk add --no-cache ca-certificates tzdata perl libmaxminddb su-exec libcap \
&& ln -sf /usr/lib/libmaxminddb.so.0 /usr/lib/libmaxminddb.so \
&& opm get anjia0532/lua-resty-maxminddb \
&& addgroup -S openflare \
&& adduser -S -G openflare -H -h /data -s /sbin/nologin openflare \
&& mkdir -p /etc/openflare /data/etc/openflare \
&& chown -R openflare:openflare /etc/openflare /data \
&& setcap 'cap_net_bind_service=+ep' /usr/local/openresty/nginx/sbin/nginx
ENV OPENFLARE_OPENRESTY_PATH=openresty \
OPENFLARE_DATA_DIR=/data
COPY --from=builder /build/bin/openflare-agent /usr/local/bin/openflare-agent
# Default agent paths: data_dir/etc/openflare/GeoLite2-*.mmdb
COPY --from=builder /build/dist/geoip/GeoLite2-Country.mmdb /data/etc/openflare/GeoLite2-Country.mmdb
COPY --from=builder /build/dist/geoip/GeoLite2-City.mmdb /data/etc/openflare/GeoLite2-City.mmdb
RUN chown openflare:openflare /data/etc/openflare/GeoLite2-Country.mmdb /data/etc/openflare/GeoLite2-City.mmdb \
&& chmod 644 /data/etc/openflare/GeoLite2-Country.mmdb /data/etc/openflare/GeoLite2-City.mmdb
COPY scripts/agent-entrypoint.sh /usr/local/bin/openflare-agent-entrypoint.sh
RUN chmod +x /usr/local/bin/openflare-agent-entrypoint.sh
EXPOSE 80 443 18081
ENTRYPOINT ["/usr/local/bin/openflare-agent-entrypoint.sh"]
CMD ["-config", "/etc/openflare/agent.json"]
+4 -4
View File
@@ -20,7 +20,7 @@ COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build \
-trimpath \
-ldflags="-s -w -X github.com/Rain-kl/Wavelet/internal/buildinfo.Version=${VERSION} -X github.com/Rain-kl/Wavelet/internal/buildinfo.BuildTime=${BUILD_DATE}" \
-o /out/openflare-server \
-o /out/wavelet \
./main.go
FROM alpine:${ALPINE_VERSION}
@@ -33,10 +33,10 @@ RUN apk add --no-cache ca-certificates tzdata postgresql-client && \
WORKDIR /app
COPY --from=builder /out/openflare-server ./openflare-server
COPY --from=builder /out/wavelet ./wavelet
COPY docs ./docs
EXPOSE 3000
EXPOSE 8000
ENTRYPOINT ["./openflare-server"]
ENTRYPOINT ["./wavelet"]
CMD ["api"]
+1 -1
View File
@@ -90,7 +90,7 @@ RUN set -e; \
[ -n "$FILTER_ARCH" ] && [ "$GOARCH" != "$FILTER_ARCH" ] && continue; \
EXT=""; \
[ "$GOOS" = "windows" ] && EXT=".exe"; \
OUTPUT="/out/openflare-server_${GOOS}_${GOARCH}${EXT}"; \
OUTPUT="/out/wavelet_${GOOS}_${GOARCH}${EXT}"; \
echo "==> Building ${OUTPUT} (version=${VERSION})..."; \
CGO_ENABLED=0 GOOS=${GOOS} GOARCH=${GOARCH} \
go build \
-30
View File
@@ -1,30 +0,0 @@
# syntax=docker/dockerfile:1.7
ARG VERSION=dev
FROM golang:1.25-alpine AS builder
ARG VERSION
WORKDIR /build
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "-s -w -X 'github.com/Rain-kl/Wavelet/internal/apps/flared/config.Version=$VERSION'" -o flared ./cmd/flared/main.go
# Final runtime image
FROM fatedier/frpc:v0.69.0
WORKDIR /app
# Copy openflared binary
COPY --from=builder /build/flared .
ENV OPENFLARE_DATA_DIR=/app/data
ENV OPENFLARE_FRPC_PATH=/usr/bin/frpc
ENTRYPOINT ["/app/flared"]
CMD []
-31
View File
@@ -1,31 +0,0 @@
# syntax=docker/dockerfile:1.7
ARG VERSION=dev
FROM golang:1.25-alpine AS builder
ARG VERSION
WORKDIR /build
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "-s -w -X 'github.com/Rain-kl/Wavelet/internal/apps/relay/config.Version=$VERSION'" -o /build/bin/openflare-relay ./cmd/relay/main.go
# Final runtime image
FROM fatedier/frps:v0.69.0
WORKDIR /app
# Copy openflare-relay binary
COPY --from=builder /build/bin/openflare-relay ./openflare-relay
VOLUME ["/app/data"]
ENV OPENFLARE_FRPS_PATH=/usr/bin/frps
ENV OPENFLARE_DATA_DIR=/app/data
ENTRYPOINT ["/app/openflare-relay"]
-18
View File
@@ -1,18 +0,0 @@
/coverage
/src/client/shared.ts
/src/node/shared.ts
*.log
*.tgz
.DS_Store
.idea
.temp
.vite_opt_cache
.vscode
dist
cache
temp
examples-temp
node_modules
pnpm-global
TODOs.md
*.timestamp-*.mjs
-8
View File
@@ -1,8 +0,0 @@
{
"plugins": {
"postcss-rtlcss": {
"ltrPrefix": ":where([dir=\"ltr\"])",
"rtlPrefix": ":where([dir=\"rtl\"])"
}
}
}
-86
View File
@@ -1,86 +0,0 @@
import { defineConfig, type HeadConfig, resolveSiteDataByRoute } from 'vitepress'
import llmstxt from 'vitepress-plugin-llms'
const prod = !!process.env.NETLIFY
export default defineConfig({
title: 'OpenFlare',
lastUpdated: true,
cleanUrls: true,
ignoreDeadLinks: true,
metaChunk: true,
srcExclude: [
'zh/**',
'components/**',
'snippets/**',
'plan/**',
'guideline/**'
],
markdown: {
math: true
},
sitemap: {
hostname: 'https://openflare.io'
},
head: [
['meta', { name: 'theme-color', content: '#10b981' }],
['meta', { property: 'og:type', content: 'website' }],
['meta', { property: 'og:site_name', content: 'OpenFlare' }],
['meta', { property: 'og:url', content: 'https://openflare.io/' }],
['script', { async: '', src: 'https://www.googletagmanager.com/gtag/js?id=G-TBZPQFMLFH' }],
[
'script',
{},
`window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-TBZPQFMLFH');`
]
],
themeConfig: {
socialLinks: [
{ icon: 'github', link: 'https://github.com/Rain-kl/OpenFlare' }
],
search: {
provider: 'local'
}
},
locales: {
root: { label: '简体中文', lang: 'zh-Hans', dir: 'ltr' },
en: { label: 'English', lang: 'en-US', dir: 'ltr' }
},
vite: {
plugins: [
prod &&
llmstxt({
workDir: '.',
ignoreFiles: ['index.md']
})
],
experimental: {
enableNativePlugin: true
}
},
transformPageData: prod
? (pageData, ctx) => {
const site = resolveSiteDataByRoute(
ctx.siteConfig.site,
pageData.relativePath
)
const title = `${pageData.title || site.title} | ${
pageData.description || site.description
}`
;((pageData.frontmatter.head ??= []) as HeadConfig[]).push(
['meta', { property: 'og:locale', content: site.lang }],
['meta', { property: 'og:title', content: title }]
)
}
: undefined
})
-4
View File
@@ -1,4 +0,0 @@
import Theme from 'vitepress/theme'
import './styles.css'
export default Theme
-20
View File
@@ -1,20 +0,0 @@
:root {
--vp-c-brand-1: #059669;
--vp-c-brand-2: #10b981;
--vp-c-brand-3: #34d399;
--vp-c-brand-soft: rgba(16, 185, 129, 0.16);
--vp-home-hero-name-color: transparent;
--vp-home-hero-name-background: linear-gradient(120deg, #059669, #2563eb);
--vp-font-family-base:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
'Segoe UI', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
}
.VPHomeHero .text,
.VPHomeHero .tagline {
max-width: 760px;
}
.VPFeature {
border-radius: 8px;
}
+335
View File
@@ -0,0 +1,335 @@
# wavelet 部署指南
本文档详细介绍了 **wavelet** 脚手架系统在不同业务阶段的部署方案,涵盖从**最小化单机部署**到**最大化高可用分布式部署**的全生命周期架构。
---
## 一、 系统组件概览
在部署系统前,请了解各运行组件及其角色:
| 组件名称 | 运行命令/形式 | 职责说明 | 必选/可选 |
| :--- | :--- | :--- | :--- |
| **HTTP API 服务** | `bin/wavelet api` | 接收并处理前端及第三方的 RESTful API 请求 | **必选** |
| **异步任务工作进程** | `bin/wavelet worker` | 消费并处理异步队列任务(如邮件发送、清理上传文件等) | **必选** |
| **定时任务调度器** | `bin/wavelet scheduler` | 定时向 Redis 队列下发 Cron 任务(仅负责触发,不负责执行) | **必选** |
| **前端服务 (Node.js)** | `pnpm start` | 提供 React/Next.js 页面服务(在分离部署时使用) | 分离模式必选 |
| **PostgreSQL** | 关系型主数据库 | 存储用户、系统配置、认证源、任务执行记录等核心数据 | **必选** |
| **Redis** | 缓存与消息队列中间件 | 存储 Session 会话、临时缓存以及 Asynq 异步任务队列数据 | **必选** |
| **ClickHouse** | 分析型数据库 | 可选的日志主库;关闭时访问审计由 PostgreSQL/SQLite 承接 | 可选 |
| **对象存储 (S3)** | 兼容 S3 的云存储/私有云 | 存放用户上传的静态文件、图片等 | 可选 |
---
## 二、 部署配置准备
系统在启动前会从当前目录加载 `config.yaml` 配置文件。
生产环境部署前,请复制 `config.example.yaml` 为 `config.yaml`,并至少确认以下关键参数的配置:
```yaml
app:
env: "production" # 生产环境标识
addr: ":8000" # API 服务监听端口
session_secret: "prod-random-secret" # 极其重要的加密密钥,首发启动后不可更改
session_domain: ".yourdomain.com" # 跨域共享 Session 时需配置
database:
host: "db.yourdomain.com"
port: 5432
username: "postgres"
password: "YOUR_DB_PASSWORD"
database: "refreshing"
redis:
addrs:
- "redis.yourdomain.com:6379"
password: "YOUR_REDIS_PASSWORD"
```
---
## 三、 方案一:最小部署 — 单机嵌入式极简版 (推荐)
此部署方案将**前端静态网页全部直接打入 Go 后端二进制文件中**,极大地简化了部署运维,是中小型应用、内部系统、SaaS 早期阶段的首选。
### 📊 架构设计
- **服务载体**:单台云服务器 (1核2G 即可)。
- **依赖服务**:在一台机器上启动轻量级 PostgreSQL 与 Redis(可采用 Docker 部署)。
- **进程管理**:在一台机器上直接拉起打包好的 Go 单文件,并分别运行 `api`、`worker`、`scheduler` 进程。
- **前端托管**:Go 服务直接在 8000 端口承载前端的所有页面,不需要额外配置 Node.js 生产服务器。
### 🛠️ 步骤说明
#### 1. 单机依赖服务初始化 (使用 Docker Compose)
在机器上准备以下 `docker-compose.yml` 快速启动 PostgreSQL 和 Redis:
```yaml
version: '3.8'
services:
postgres:
image: postgres:15-alpine
container_name: refreshing-db
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: YOUR_DB_PASSWORD
POSTGRES_DB: refreshing
ports:
- "5432:5432"
volumes:
- ./data/pg:/var/lib/postgresql/data
restart: always
redis:
image: valkey/valkey:8.0-alpine
container_name: refreshing-redis
command: valkey-server --requirepass YOUR_REDIS_PASSWORD
ports:
- "6379:6379"
volumes:
- ./data/redis:/data
restart: always
```
执行命令启动:
```bash
docker compose up -d
```
#### 2. 前后端一键嵌入式打包
在开发或编译机上,运行编译指令:
```bash
make build-embedded
```
该命令会自动完成前端的静态编译导出 (`frontend/out`)、复制到 Go 后端目录,最后使用 `-tags embed_frontend` 生成后端单文件:
- 产物路径:`bin/wavelet`
#### 3. 进程管理 (使用 Systemd)
将 `bin/wavelet` 拷贝到生产服务器 `/usr/local/bin/wavelet`,并为 `api`、`worker` 和 `scheduler` 配置 Systemd 管理服务。
新建 API 进程服务文件 `/etc/systemd/system/wavelet-api.service`:
```ini
[Unit]
Description=Refreshing API Service
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/app
ExecStart=/usr/local/bin/wavelet api
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
```
同理,新建 Worker 服务 `/etc/systemd/system/wavelet-worker.service`(将命令改为 `wavelet worker`),以及 Scheduler 服务 `/etc/systemd/system/wavelet-scheduler.service`(将命令改为 `wavelet scheduler`)。
启动并启用所有服务:
```bash
systemctl daemon-reload
systemctl enable --now refreshing-api refreshing-worker refreshing-scheduler
```
#### 4. 配置 Nginx 证书
配置 Nginx 作为反向代理并启用 HTTPS 证书:
```nginx
server {
listen 80;
server_name yourdomain.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name yourdomain.com;
ssl_certificate /path/to/cert.crt;
ssl_certificate_key /path/to/cert.key;
location / {
proxy_pass http://127.0.0.1:8000;
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;
}
}
```
---
## 四、 方案二:标准部署 — 前后端物理分离架构
此方案中前端与后端彻底解耦。前端采用 SSR/ISR (Next.js Node 服务) 运行,后端采用独立的 API 服务运行。
### 📊 架构设计
- **前端部署**:单独部署到 Node.js 托管环境(如多台前端机器或 Vercel/Cloudflare Pages)。
- **后端部署**:多台后端云服务器,统一指向云数据库 RDS 与云缓存 Redis。
- **通信方式**:前后端通过 Nginx 规则路由或独立域名(如 `app.yourdomain.com` 访问前端,`api.yourdomain.com` 访问后端)进行跨域通信。
### 🛠️ 步骤说明
#### 1. 部署后端 Go 服务
1. 编译后端:
```bash
go build -o bin/wavelet main.go
```
2. 在后端服务器上,同样使用 Systemd 或 Docker 守护启动 `wavelet api`、`wavelet worker` 和 `wavelet scheduler`。
3. 配置后端 Nginx 将客户端 API 请求(如 `/api/...`)反向代理至后端绑定的端口(如 `:8000`)。
#### 2. 部署前端 Next.js 服务
1. 前端服务器环境确保已安装 Node.js 和 pnpm。
2. 安装依赖并编译生产版本:
```bash
cd frontend
pnpm install
pnpm build
```
3. 使用 PM2 守护前端 Node.js 服务运行。新建 `ecosystem.config.js`:
```javascript
module.exports = {
apps: [
{
name: 'refreshing-frontend',
script: 'node_modules/next/dist/bin/next',
args: 'start -p 3000',
instances: 'max',
exec_mode: 'cluster',
env: {
NODE_ENV: 'production',
WAVELET_BACKEND_URL: 'https://api.yourdomain.com'
}
}
]
};
```
启动前端服务:
```bash
pm2 start ecosystem.config.js
```
#### 3. 跨域与 Cookie 说明
- 若前后端使用**不同子域名**部署(例如 `app.yourdomain.com` 和 `api.yourdomain.com`),必须在 `config.yaml` 中将 `app.session_domain` 显式设置为顶级域名(`.yourdomain.com`),以确保 Session Cookie 可以在子域间顺利透传。
- 在跨域状态下,前端请求必须配置 `withCredentials: true`,API 端的跨域中间件(`corsMiddleware`)会自动将该域添加至允许源中。
---
## 五、 方案三:最大部署 — 企业级高可用分布式架构 (Max)
当系统面临高并发流量、海量后台任务或极高的可用性要求时,需要将所有组件拆分为无状态水平扩容,并引入高可用的云基础设施。
### 📊 架构设计图
```
┌────────────────────────┐
│ 域名 / 负载均衡器 │
│ (SLB / Cloudflare) │
└──────────┬─────────────┘
│
┌──────────────────┴──────────────────┐
▼ ▼
┌─────────────────────┐ ┌─────────────────────┐
│ 前端集群 │ │ 后端 API 集群 │
│ (Next.js Node) │ │ (Go 无状态实例) │
│ [弹性扩容 / 8台+] │ │ [弹性扩容 / 8台+] │
└─────────────────────┘ └──────────┬──────────┘
│
┌────────────────────────────────────────┼────────────────────────────────────────┐
▼ ▼ ▼
┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐
│ 异步 Worker 集群 │ │ 定时 Scheduler │ │ S3 对象存储集群 │
│ (多节点并发处理) │ │ (主备模式,限单节点)│ │(R2/MinIO/AWS S3) │
└─────────┬─────────┘ └─────────┬─────────┘ └───────────────────┘
│ │
└───────────────────┬────────────────────┘
│
┌───────────────────┴────────────────────┐
▼ ▼
┌───────────────────────────────────┐ ┌───────────────────────────────────┐
│ Redis 哨兵/集群 │ │ PG 主从读写分离集群 │
│ (高可用缓存/Asynq 队列) │ │ (RDS Primary-Replica) │
└───────────────────────────────────┘ └───────────────────────────────────┘
```
### ⚙️ 最大部署配置要点
#### 1. 数据库高可用 (主从读写分离)
在 `config.yaml` 中配置 `database` 的主库写与从库读:
```yaml
database:
enabled: true
host: "pg-primary.yourdomain.com" # 主库地址(写)
port: 5432
username: "postgres"
password: "YOUR_DB_PASSWORD"
database: "refreshing"
# 配置读写分离只读副本(GORM 自动轮询读,支持配置多个从库)
replicas:
- host: "pg-replica-1.yourdomain.com"
port: 5432
username: "postgres"
password: "YOUR_DB_PASSWORD"
- host: "pg-replica-2.yourdomain.com"
port: 5432
username: "postgres"
password: "YOUR_DB_PASSWORD"
```
#### 2. Redis 高可用 (哨兵/Sentinel 或集群)
- **Sentinel 哨兵模式**:通过配置 `redis.master_name` 启用,SDK 会自动监视 Master 的主备切换。
- **Cluster 集群模式**:将 `redis.cluster_mode` 设为 `true`,并提供所有集群节点的 `addrs`。
```yaml
redis:
addrs:
- "redis-node-1.yourdomain.com:6379"
- "redis-node-2.yourdomain.com:6379"
- "redis-node-3.yourdomain.com:6379"
cluster_mode: true
```
#### 3. 对象存储与缓存分离 (S3 + Local Cache)
高可用集群下,本地文件系统不再可共享。文件存储必须启用 S3 兼容服务,并在多节点间开启本地高速磁盘缓存加速读取:
```yaml
s3:
enabled: true
endpoint: "https://your-r2-or-s3-id.r2.cloudflarestorage.com"
region: "auto"
bucket: "refreshing-assets"
access_key_id: "YOUR_S3_KEY"
secret_access_key: "YOUR_S3_SECRET"
local_cache:
enabled: true # 开启本地磁盘缓存
cache_dir: "/data/s3_cache" # 本地高性能 SSD 挂载点
```
#### 4. 后端进程横向拆分部署
- **API 集群**:启动数十个甚至上百个 `wavelet api` 无状态容器。它们可以通过负载均衡器直接挂载,支持随时弹性缩容扩容。
- **Worker 集群**:启动多个 `wavelet worker` 容器。因为 `Asynq` 基于 Redis 分布式处理,多个 Worker 进程可以安全地同时运行并竞抢同一队列的异步任务,自动保障任务的并发吞吐能力。
- **Scheduler 独占**:**【注意】** 为避免重复触发定时 Cron 任务,`wavelet scheduler` 定时调度器进程**同一时间应仅运行单个活跃实例**(主备高可用可以通过容器平台的单实例保障或 K8s Job 机制来限制实例数为 1)。
#### 5. ClickHouse 高并发同步
访问审计等日志表默认写在当前业务主库。数据量大、需要列式扫描时,开启 ClickHouse,再在任务管理运行「切换日志数据库」迁到 ClickHouse(迁移期间冻结写入,源数据不删)。开发约定见 [日志用途表](./LOGSTORE.md)。
```yaml
clickhouse:
enabled: true
hosts:
- "ch-node-1.yourdomain.com:9000"
- "ch-node-2.yourdomain.com:9000"
```
#### 6. OpenTelemetry 分布式链路追踪
最大部署架构必须引入链路追踪(Jaeger 或 OTel Collector)以便排查节点间请求延迟或网络问题。
在生产环境,通过配置 OTel 将 Span 发送至公共日志分析平台。
```yaml
otel:
sampling_rate: 0.05 # 开启 5% 的流量追踪采样率以减少开销
```
---
## 六、 部署方案对比与选择建议
| 指标维度 | 方案一:最小单机嵌入版 | 方案二:标准前后端分离版 | 方案三:最大高可用分布式版 |
| :--- | :--- | :--- | :--- |
| **支持流量/并发** | 1,000 ~ 5,000 QPS (视机器性能) | 5,000 ~ 20,000 QPS | 20,000 ~ 100,000+ QPS (无限扩展) |
| **服务器数量** | 1 台 | 3 ~ 5 台 | 10 台以上集群 |
| **运维复杂度** | 极简 (只需部署一个程序) | 中等 (需维护 Node 和 Go 两套环境) | 较高 (K8s/多组件集群维护) |
| **适合场景** | 个人项目、内部系统、SaaS 早期起步 | 正常线上运营项目、有中等规模团队 | 大型企业级应用、高并发核心交易系统 |
+45
View File
@@ -0,0 +1,45 @@
# 日志用途表
Wavelet 的访问审计等日志表不绑死 ClickHouse。`internal/repository/logstore` 按 `log_database` 在 PostgreSQL / SQLite / ClickHouse 之间切换;关闭 ClickHouse 时由当前业务主库承接写入、查询与清理。
逐步落地步骤见 `.agents/skills/logstore/SKILL.md`。本文只约定判定、分层与切换协议。
## 什么算日志表
同时满足才进 logstore:
- 追加写入,几乎不更新单行
- 按时间查询或聚合,允许按保留天数删除
- 关闭 ClickHouse 后仍要能写、能查
- 不参与用户 / 配置 / 任务等事务一致性
用户、系统配置、任务执行、上传元数据走业务主库 `repository`,不要塞进 logstore。
当前已接入:`w_user_access_logs`(管理端 API 访问审计),接口 `UserAccessLogStore`。
## 分层
| 层级 | 路径 | 职责 |
| :--- | :--- | :--- |
| 抽象 | `internal/repository/logstore` | 接口 + `Active` / `BuildForMigration`;apps 只面向这里 |
| CH 实现 | `logstore` 委托 `repository/analytics` | 原生批量与现有查询 |
| 主库实现 | `logstore` GORM | PostgreSQL 按月分区;SQLite 普通表 |
| 入队 | `risk_control` + `batchwriter` | `FlushFunc` → `logstore.Active` |
| 切换 | `logs:db_switch` | 冻结 → 排空 → 复制 → 翻转 |
| 清理 | `logstore.CleanupExpired` | `system:cleanup` 按库读 `log_retention_days_*`:PG 先 `DropExpiredPartitions` 再 `DeleteBefore`,最后 `DropEmptyPartitions` |
`log_database` 只能是「随业务主库」或 `clickhouse`。`log_database` / `log_db_migration` 受保护,管理端不可改。
## 切换协议
1. 校验 `target` 合法且不等于当前库。
2. 写 `log_db_migration=migrating`,`Drain` 在途队列(不要 `Stop` writer);写入返回明确错误,不排队。
3. 清空目标表后按 id 分页复制;PostgreSQL 目标先 `EnsurePartitions`。
4. 全部成功才翻转 `log_database`;失败清标记,写入继续走源库。
5. 源数据不删。
不要另起切换协议,也不要在任务或 Handler 里直连 `analyticsrepo` / `db.ChConn`。
## 新增一张日志表
必须同时提供 ClickHouse / PostgreSQL / SQLite 三套 goose,列名一致。接口至少包含 `BatchInsert`、业务查询、`ListForMigration` / `MigrationRange` / `DeleteAll` / `EnsurePartitions`、`DeleteBefore`。`FlushFunc` 调 `logstore.Active`。细节与禁止项见 `logstore` skill。
+501
View File
@@ -0,0 +1,501 @@
# Wavelet 系统性能分析与优化建议
> 分析日期:2026-06-17
> 范围:Go 后端 + Next.js 前端
> 目标:识别可能在生产环境真实出现的性能问题,并给出高 ROI 优化路线
**状态图例**:`✅ 已完成` · `🔶 部分完成` · `⬜ 待做`
| 修复批次 | 范围 | 状态 |
|----------|------|------|
| P0 后端 #1–#4 | WebP 锁、文件路径缓存、增量统计、复合索引 | ✅ |
| P0 前端 #6–#7 | 认证并行化、日志虚拟化 | ✅ |
| P1 #9 | 公共配置 Redis 列表缓存 | ✅ |
| P1 参数中心 | 系统配置 Otter RAM 缓存 + 统一失效 + 多节点 pub/sub | ✅ |
| P1 CAPTCHA | 运行时配置快照 + 批量加载 + pub/sub 失效 | ✅ |
| P0 前端 #12–#19 | dynamic 分割、React Query、登录并行、Tooltip、lazy、barrel 收窄 | ✅ |
---
## 目录
- [架构概览与核心瓶颈](#架构概览与核心瓶颈)
- [Critical — 高概率生产问题](#critical--高概率生产问题)
- [Medium — 中等风险](#medium--中等风险)
- [高价值优化路线图](#高价值优化路线图)
- [已做得好的设计](#已做得好的设计)
- [场景风险矩阵](#场景风险矩阵)
- [优先行动清单](#优先行动清单)
---
## 架构概览与核心瓶颈
```mermaid
flowchart LR
subgraph frontend["前端 (Static Export)"]
A[HTML 静态壳] --> B[Hydrate]
B --> C["UserProvider.getUserInfo()"]
C --> D[页面数据请求]
D --> E[渲染]
end
subgraph backend["后端热点路径"]
F["/f/{id}?quality=..."] --> G[DB 查 upload]
G --> H[迁移状态 DB 查询]
H --> I[白名单 Redis/DB]
I --> J{WebP 缓存命中?}
J -->|否| K["全量读文件 + 编码 + 磁盘缓存(全局锁)"]
J -->|是| L[返回]
end
C -.->|已解除阻塞| D
```
**参数中心读路径**(`SystemConfig.GetByKey`):
```mermaid
flowchart LR
R[业务调用 GetByKey] --> A{RAM 命中?}
A -->|是| Z[返回]
A -->|否| B{Redis HGET 命中?}
B -->|是| C[写入 RAM]
C --> Z
B -->|否| D[查 PostgreSQL]
D --> E[回写 Redis + RAM]
E --> Z
W[管理员 Create/Update] --> F[写 DB]
F --> G["InvalidateSystemConfigCache(key)"]
G --> H[清本机 RAM + Redis field]
G --> I[pub/sub 通知其他节点清 RAM]
```
当前最大的结构性问题(2026-06-17 更新):
1. **前端**:~~全局认证瀑布流~~ ✅ 已改为 layout 即时渲染 + 子页面 `RequireAuth` 自行处理未登录态;~~Admin 重模块无 `dynamic()` 分割~~ ✅ database/logs/settings 已懒加载子模块。其余路由 `page.tsx` 仍为 `"use client"`(静态导出下 RSC 收益有限,待逐步薄壳化)。
2. **后端**:文件服务路径(`/f/{id}`)仍是最高频热点;~~磁盘缓存全局互斥锁~~ ✅ 已改为 `RWMutex` + `singleflight`,但 WebP miss 仍在请求线程内同步编码,部署预热与异步回退原图尚未落地。
3. **参数中心**:~~`GetByKey` 每次直打 Redis~~ ✅ 已统一使用底层的进程内缓存库(`pkg/cache/store`),读路径直接为 RAM → DB(无 Redis 数据缓存);管理员写配置后通过 Redis pub/sub 进行广播(`system:config_broadcast`),多节点本地触发全量预热/刷新,实现最终一致性。
---
## Critical — 高概率生产问题
### 1. 图片 WebP 服务:请求路径阻塞 + 全局锁串行化 `🔶 部分完成`
**涉及文件**:
- `internal/apps/upload/file_server.go`
- `pkg/cache/disk/cache.go`
**问题描述**:
缓存未命中时,在 HTTP 请求 goroutine 内执行:
1. `io.ReadAll` 将原始文件全量读入内存
2. 进程内 WebP 解码 + 编码
3. 写入磁盘缓存
同时,磁盘缓存 `Get`/`Set` 使用**全局 `sync.Mutex`**,所有并发图片请求在缓存层完全串行。
```go
// file_server.go — 缓存 miss 时的重操作
origBytes, err := getOriginalFileBytes(ctx, upload) // io.ReadAll
webpBytes, err = CompressImageToWebP(bytes.NewReader(origBytes), quality)
cache.Set(cacheKey, webpBytes, diskcache.NoExpiration)
// pkg/cache/disk/cache.go — 全局互斥锁
func (c *Cache) Get(key string) ([]byte, error) {
c.mu.Lock()
defer c.mu.Unlock()
// ...
}
```
**生产表现**:
- 首次访问或缓存淘汰后,P99 延迟从几十毫秒飙升到数秒
- 并发图片请求形成「隐形队列」
- 大文件全量读入带来内存尖峰,可能触发 OOM 或 GC 停顿
**优化价值**:⭐⭐⭐⭐⭐
**建议**:
- [x] ✅ 磁盘缓存改用 `RWMutex`,读路径不互斥 — `pkg/cache/disk/cache.go`
- [x] ✅ 对同一 cache key 使用 `singleflight` 合并并发 miss — `internal/apps/upload/file_server.go`
- [ ] 部署后强制执行 `upload:warm_image_cache` 异步预热任务
- [ ] 考虑 miss 时先返回原图,后台异步生成 WebP
---
### 2. 文件访问路径:每次请求多次 DB/Redis 查询 `✅ 已完成`
**涉及文件**:
- `internal/apps/upload/storage_ops.go`
- `internal/apps/upload/file_server.go`
**问题描述**:
存储迁移状态**无进程内缓存**,每次文件操作都查询 `w_task_executions`:
```go
// storage_ops.go
func StorageReadOnly(ctx context.Context) bool {
execution, ok, err := latestStorageMigrationExecution(ctx)
// ...
}
func backendForStoredDriver(ctx context.Context, driver storage.Driver) (storage.Backend, error) {
// 可能再次调用 currentMigrationTargetConfig → 又一次相同 DB 查询
}
```
公开文件白名单每次走 Redis/DB:
```go
// file_server.go
func isFilePublic(ctx context.Context, uploadType string) bool {
sc.GetByKey(ctx, model.ConfigKeyFileAccessWhitelist)
// JSON 解析 + 遍历
}
```
对比:`storage.Active()` 已有 5 秒内存缓存 + Redis pub/sub 失效机制,迁移状态却未复用该模式。
**生产表现**:
- 每个 `/f/{id}` 请求额外 2–4 次 DB/Redis 往返
- 图片站/CDN 场景下 QPS 放大后 PostgreSQL 连接池压力明显
**优化价值**:⭐⭐⭐⭐⭐
**建议**:
- [x] ✅ 为 `StorageReadOnly` / `latestStorageMigrationExecution` 增加 5s TTL 进程内缓存 — `internal/apps/upload/access_cache.go`
- [x] ✅ 配置变更或迁移状态变化时通过 Redis pub/sub 失效 — `access_cache.go` + `system_config/routers.go`
- [x] ✅ `file_access_whitelist` 增加进程内缓存,复用 `GetByKey` 的失效机制 — `access_cache.go`
---
### 3. Admin 文件统计:无界全表扫描 `✅ 已完成`
**涉及文件**:`internal/apps/upload/stats.go`
**问题描述**:
```go
err = db.DB(ctx).Model(&model.Upload{}).
Select("extension, mime_type, file_size").
Where("status != ?", model.UploadStatusDeleted).
Scan(&fileRaws).Error
// 然后在 Go 中遍历全量结果做分类统计
```
**生产表现**:
- 10 万+ 文件时,管理端「文件统计」接口耗时数秒
- 占用数百 MB 内存,可能拖垮 admin API
**优化价值**:⭐⭐⭐⭐
**建议**:
- [ ] 改为 SQL `GROUP BY` + `CASE WHEN` 聚合(未采用)
- [x] ✅ 维护增量统计表,上传/删除时更新计数 — `w_upload_stats` + `stats_counter.go` + `GetFileStats` 读统计表
---
### 4. `w_uploads` 索引缺口 `✅ 已完成`
**涉及文件**:`internal/infra/persistence/migrator/goose/postgres/202606090001_initial_schema.sql`
**当前索引**:`user_id`, `file_path`, `hash`, `type`
**缺失的高频查询索引**:
| 查询场景 | 建议索引 |
|----------|----------|
| 清理任务 `status + created_at` | `(status, created_at)` |
| 存储迁移 `storage_driver + status` | `(storage_driver, status)` |
| 秒传去重 `hash + file_size + status` | `(hash, file_size, status)` |
**生产表现**:
- 数据量增长后,清理 worker、迁移任务、上传去重退化为顺序扫描
- 后台任务积压,admin 操作变慢
**优化价值**:⭐⭐⭐⭐
**建议**:
- [x] ✅ 通过 goose migration 新增上述复合索引(PostgreSQL + SQLite 双方言)— `202606170001_add_upload_composite_indexes.sql`
---
### 5. 批量 ZIP 下载:无上限 + 同步阻塞
**涉及文件**:`internal/apps/upload/routers.go` — `BatchDownloadFiles`
**问题描述**:
- `req.IDs` 无数量上限
- 在请求 goroutine 内串行打开每个文件并 `io.Copy` 到 ZIP
- 远端 S3 场景下单个文件就可能耗时数秒
**生产表现**:
- 网关超时、连接耗尽
- Admin 批量下载操作卡死
**优化价值**:⭐⭐⭐⭐
**建议**:
- [ ] 限制单次批量数量(如 max 50)
- [ ] 或改为 Asynq 后台任务生成 ZIP,前端轮询下载链接
---
### 6. 前端全局认证瀑布流 `✅ 已完成`
**涉及文件**:
- `frontend/contexts/user-context.tsx`
- `frontend/app/(main)/layout.tsx`
**问题描述**:
```tsx
// user-context.tsx — 挂载时获取用户
useEffect(() => {
fetchUser()
}, [fetchUser])
// layout.tsx — 阻塞所有子页面渲染
if (loading || !user) {
return <LoadingPage text="登录状态" badgeText="Auth" />
}
```
**生产表现**:
- 每次进入 `/home`、`/files`、`/admin/*` 都先等 `getUserInfo`(约 200–800ms)
- 页面级数据请求无法并行启动,TTI 被硬性拉长
**优化价值**:⭐⭐⭐⭐⭐
**建议**:
- [x] ✅ Layout 不阻塞渲染,子页面自行处理未登录状态 — `layout.tsx` + `RequireAuth` / `RequireAdminAuth`
- [ ] 或 Server Component 通过 cookie 预取 session,消除客户端首屏等待
- [x] ✅ `/login`、`/register` 跳过 `getUserInfo` — `user-context.tsx`
---
### 7. 实时日志面板:2000 行 DOM 无虚拟化 `✅ 已完成`
**涉及文件**:`frontend/components/common/admin/app-logs.tsx`
**问题描述**:
- 日志上限 2000 行(内存有界,但 DOM 无界)
- 每行渲染完整 `<div>`,无虚拟滚动
- `@tanstack/react-virtual` 已在 `package.json` 但未使用
**生产表现**:
- 管理员开着日志 Tab 时 CPU/内存持续升高
- 滚动卡顿,长时间运行拖慢整台机器
**优化价值**:⭐⭐⭐⭐
**建议**:
- [x] ✅ 使用 `useVirtualizer` 只渲染可视区域行 — `app-logs.tsx`
- [x] ✅ 行组件 `React.memo` 避免无效重渲染 — `LogLine`
---
## Medium — 中等风险
| # | 问题 | 位置 | 影响 |
|---|------|------|------|
| 1 | ~~公共配置接口无 Redis 缓存~~ ✅ | `internal/model/system_configs.go` — `ListVisibleSystemConfigs` | ~~每次前端启动/登录直查 PostgreSQL~~ → Redis 列表缓存 + Create/Update 时失效 |
| 2 | ~~CAPTCHA 每次 5 次独立 `GetByKey`~~ ✅ | `internal/apps/cap/runtime_settings.go` | ~~登录高峰 5× 配置读取~~ → `CurrentSettings` 快照一次加载 6 个 key,`Generate`/`Redeem`/中间件零 `GetByKey` |
| 3 | ~~系统配置单 key 无进程内缓存~~ ✅ | `system_config_cache.go`, `pkg/cache/ram` | ~~热路径重复 Redis HGET~~ → Otter RAM + 写后 `InvalidateSystemConfigCache` + pub/sub |
| 4 | OIDC 每次 `oidc.NewProvider` 无缓存 | `internal/apps/oauth/sources.go:164` | 登录发起/回调多一次外部 HTTP |
| 5 | CORS 每次跨域查 `server_address` 配置 `🔶` | `internal/router/middlewares.go:75` | 预检请求仍每次调用 `GetByKey`,但 `server_address` 已受益于 RAM 缓存 |
| 6 | 推送通知无界 goroutine + 逐 target DB 查询 | `internal/apps/admin/push/events.go:102` | 通知风暴时 goroutine/DB 双压 |
| 7 | 上传清理:每文件一个事务 | `internal/apps/upload/cleanup.go` | 大量 pending 文件时 commit 风暴 |
| 8 | ClickHouse 风控:每请求 `json.Marshal` 全部 headers | `internal/apps/risk_control/middleware.go:58` | 高 QPS 时 CPU 开销(写入本身已异步批处理) |
| 9 | 存储迁移日志大量写 Redis | `internal/apps/upload/storage_migration_task.go` | 迁移期间 Redis CPU/内存压力 |
| 10 | 存储迁移后二次 SHA 全量读取验证 | `storage_migration_task.go` | 迁移期间对象 I/O 翻倍 |
| 11 | Admin 状态页 5s 轮询 | `frontend/components/common/admin/status.tsx` | Tab 常驻时持续打后端 |
| 12 | 路由切换 500ms fade 动画 | `frontend/app/(main)/layout.tsx:53-60` | 即使数据已缓存,感知仍慢 |
| 13 | ~~无 `next/dynamic` 代码分割~~ ✅ | `database/`, `logs/`, `settings/` page-client | Admin 重模块拆分为独立 chunk |
| 14 | 19/24 个 `page.tsx` 为 `"use client"` `🔶` | 各路由 | database/logs/settings 已薄壳化;其余待迁移 |
| 15 | ~~Admin 部分页面用 `useEffect` 而非 React Query~~ ✅ | `access-logs.tsx`, `task-executions.tsx` | 列表/详情走 React Query 缓存去重 |
| 16 | ~~登录页 OIDC sources 等待 public config~~ ✅ | `login-form.tsx` | public config 与 auth sources 并行请求 |
| 17 | ~~Users 表每行嵌套 3 个 `TooltipProvider`~~ ✅ | `admin/users/page.tsx` | 表格外层单一 Provider |
| 18 | ~~缩略图用原生 `<img>` 无 lazy loading~~ ✅ | `file-list.tsx`, `file-manager.tsx` | `loading="lazy"` + `decoding="async"` |
| 19 | ~~`@/lib/services` barrel 导入~~ ✅ | 全前端消费侧 | 改为 `@/lib/services/<module>` 直接导入 |
| 20 | SQLite 模式无连接池调优 | `internal/infra/persistence/postgres.go` | 默认 SQLite 写锁瓶颈 |
| 21 | Session Redis 仅用第一个地址 | `internal/router/router.go` | Sentinel/Cluster 场景不一致 |
---
## 高价值优化路线图
### P0 — 立即做(1–2 周,收益最大)
| # | 优化项 | 涉及模块 | 预期收益 | 复杂度 | 状态 |
|---|--------|----------|----------|--------|------|
| 1 | WebP:`singleflight` + `RWMutex` + 强制预热 | `file_server.go`, `pkg/cache/disk/` | 图片 P99 ↓ 80%+,并发吞吐 ↑ 5–10x | 中 | 🔶 锁与去重已完成,预热待做 |
| 2 | 缓存 `StorageReadOnly` / 迁移状态 | `access_cache.go` | 每文件请求减少 1–3 次 DB | 低 | ✅ |
| 3 | 内存缓存 `file_access_whitelist` | `access_cache.go` | 每公开文件请求减少 1 次 Redis | 低 | ✅ |
| 4 | `GetFileStats` 增量统计表 | `stats.go`, `w_upload_stats` | Admin 统计从 O(n) → O(1) | 低 | ✅ |
| 5 | 新增 `w_uploads` 复合索引 | goose migration | 清理/迁移/秒传全面加速 | 低 | ✅ |
| 6 | 前端日志虚拟化 | `app-logs.tsx` | Admin 日志 Tab 流畅度质变 | 低 | ✅ |
| 7 | Admin 重模块 `dynamic()` 懒加载 | `database/page-client.tsx`, `logs/page-client.tsx`, `settings/page-client.tsx` | 首包 JS ↓ 150–300KB | 低 | ✅ |
### P1 — 短期(2–4 周)
| # | 优化项 | 预期收益 | 状态 |
|---|--------|----------|------|
| 8 | 认证并行化:layout 不阻塞 / Server 预取 session | TTI ↓ 200–800ms | 🔶 客户端并行化已完成,RSC 预取待做 |
| 9 | `ListVisibleSystemConfigs` 加 Redis 缓存 | 前端冷启动加速 | ✅ |
| 10 | 系统配置 Otter RAM 缓存 + 统一失效 | 热路径 `GetByKey` 零 Redis RTT(命中后) | ✅ |
| 11 | CAPTCHA 运行时配置快照 | 验证码路径配置读取 → O(1) 快照 | ✅ |
| 12 | OIDC Provider/JWKS 进程内缓存(TTL 1h) | 登录延迟 ↓ 100–500ms | ⬜ |
| 13 | 批量下载限制(max 50)或异步任务 | 消除网关超时风险 | ⬜ |
| 14 | Admin `useEffect` 数据获取迁移到 React Query | 去重、缓存、后台刷新 | 🔶 access-logs / task-executions 已完成 |
| 15 | 登录页并行请求 public config + auth sources | 登录页 ↓ 100–300ms | ✅ |
| 16 | 状态轮询在 `document.hidden` 时暂停 | 降低后台 + 客户端负载 | ⬜ |
### P2 — 中期架构演进
| # | 优化项 | 预期收益 |
|---|--------|----------|
| 16 | 批量 ZIP 改为 Asynq 后台任务 | 彻底解耦长耗时操作 |
| 17 | 存储迁移日志降噪 + 跳过已验证文件二次 SHA | 迁移期间 Redis/I/O ↓ 50% |
| 18 | 推送通知 target 批量解析(`WHERE id IN ?`) | 通知风暴 DB 查询 ↓ N 倍 |
| 19 | 上传清理改为批量 UPDATE + 异步存储删除 | 减少 DB commit 频率 |
| 20 | 路由动画 0.5s → 0.15s 或纯 CSS | 导航感知速度 ↑ |
| 21 | ~~服务导入收窄(直接 import 具体 Service)~~ ✅ | 每路由 bundle ↓ 10–30KB |
| 22 | Admin 路由级 `loading.tsx` + Suspense | 渐进式渲染体验 |
| 23 | ~~缩略图 `loading="lazy"` + 固定尺寸~~ ✅ | 文件管理页初始 paint 加速 |
---
## 已做得好的设计
以下设计说明团队已有性能意识,优化应在此基础上增量改进,**不必重复造轮子**:
| # | 设计 | 位置 |
|---|------|------|
| 1 | 系统配置两层缓存 RAM → DB | `pkg/cache/store`, `system_config_cache.go`, `GetByKey` |
| 2 | 系统配置统一刷新 + 多节点 pub/sub 预热广播 | `InvalidateSystemConfigCache`, `InvalidateAllSystemConfigCaches` |
| 3 | Storage Backend 单例 + 5s TTL + pub/sub 失效 | `internal/infra/objectstore/storage.go` — `Active()` |
| 4 | 推送事件/渠道 24h Redis 缓存 + GORM hook 失效 | `internal/model/push_event.go`, `push_channel.go` |
| 5 | 风控日志异步批写 ClickHouse(1 万缓冲 + 1000 条/1s + 429 背压) | `internal/apps/risk_control/` |
| 6 | HTTP 连接池统一(`httppool` + OTel) | `pkg/httppool/` |
| 7 | DB/Redis 连接池显式配置 | `config.yaml`, `internal/infra/persistence/` |
| 8 | 游标分批处理(`id > ? LIMIT n`) | `cleanup.go`, image warmup |
| 9 | 存储迁移并发上限 `errgroup.SetLimit(10)` | `storage_migration_task.go` |
| 10 | 邮件/推送走 Asynq,不在 HTTP 路径同步发送 | `user/logics.go`, `push/events.go` |
| 11 | 文件服务 ETag/304 + 原图 `DataFromReader` 流式返回 | `file_server.go` |
| 12 | 无 GORM `Preload` 滥用 | 全项目 |
| 13 | 前端 API 请求去重(`pendingRequests` Map) | `frontend/lib/services/core/api-client.ts` |
| 14 | React Query 全局 30s `staleTime` | `frontend/components/providers/query-provider.tsx` |
| 15 | React Compiler 已启用 | `frontend/next.config.ts` |
| 16 | 读副本支持(`dbresolver`) | `internal/infra/persistence/postgres.go` |
| 17 | 任务执行日志 Redis 缓冲 + 批量回写 | `internal/model/task_execution.go` |
| 18 | 公共配置列表 Redis 缓存 + 写后失效 | `ListVisibleSystemConfigs`, `InvalidateVisibleSystemConfigsCache` |
| 19 | 上传文件统计增量表 `w_upload_stats` | `stats_counter.go`, 上传/删除 hook |
| 20 | 文件访问路径进程内缓存 + pub/sub | `internal/apps/upload/access_cache.go` |
| 21 | 磁盘缓存读路径 `RWMutex` + WebP `singleflight` | `pkg/cache/disk/cache.go`, `file_server.go` |
| 22 | 前端认证非阻塞 + 页面级鉴权 | `use-auth-redirect.ts`, `require-auth.tsx` |
| 23 | Admin 实时日志虚拟滚动 | `frontend/components/common/admin/app-logs.tsx` |
| 24 | CAPTCHA 运行时配置快照 + 批量加载 | `runtime_settings.go`, `ListSystemConfigsByKeys` |
---
## 场景风险矩阵
| 场景 | 最可能爆的点 | 对应优先级 |
|------|-------------|-----------|
| 图片站 / 公开相册 | WebP miss(锁/白名单已优化) | P0 #1 预热待做 |
| 文件量 10 万+ | 清理慢(统计/索引已优化) | P2 #19 清理批量化 |
| 管理端日常使用 | ~~大 bundle~~(dynamic 分割 + barrel 收窄已落地) | P2 #22 路由 loading.tsx |
| 存储迁移进行中 | Redis 日志风暴 | P2 #17 |
| 登录高峰 | OIDC discovery 无缓存 | P1 #12 OIDC |
| 多租户 / 跨域前端 | CORS 仍每次调 `GetByKey`(`server_address` 已 RAM 缓存) | 可选 CORS 快照 |
| 参数热更新 | 多节点 RAM 一致性 | ✅ `system:config_invalidation` pub/sub |
| 批量文件操作 | ZIP 同步打包无上限 | P0 #5, P1 #12 |
---
## 优先行动清单
如果只选 **3 件事** 先做(预计用户感知延迟降低 50–70%):
1. ~~**WebP 路径解耦**~~ ✅ `singleflight` + `RWMutex` 已落地;**下一步**:部署后预热 + miss 异步回退原图
2. ~~**文件路径查询缓存**~~ ✅ 迁移状态 + 白名单进程内缓存已落地
3. ~~**前端认证与首屏并行化**~~ ✅ 全局 auth gate 已移除;~~Admin `dynamic()` 代码分割~~ ✅ 已落地;**下一步**:其余 Admin 路由薄壳化 + `loading.tsx`
### 实施检查清单
```
P0 后端
[x] disk cache RWMutex + singleflight ✅ 2026-06-17
[x] StorageReadOnly 5s 缓存 + pub/sub 失效 ✅ 2026-06-17
[x] file_access_whitelist 进程内缓存 ✅ 2026-06-17
[x] GetFileStats 增量统计表 (w_upload_stats) ✅ 2026-06-17
[x] w_uploads 复合索引 migration ✅ 2026-06-17
[ ] 批量下载数量上限
[ ] WebP 部署预热 + miss 异步回退原图
P0 前端
[x] app-logs.tsx 虚拟滚动 ✅ 2026-06-17
[x] SQLConsole / Settings Tabs / Logs Tabs dynamic import ✅ 2026-06-17
[x] 认证 gate 并行化 ✅ 2026-06-17
[x] 登录页 public config + auth sources 并行 ✅ 2026-06-17
[x] access-logs / task-executions → React Query ✅ 2026-06-17
[x] Users TooltipProvider 合并 ✅ 2026-06-17
[x] 缩略图 loading="lazy" ✅ 2026-06-17
[x] @/lib/services barrel 导入收窄 ✅ 2026-06-17
P1
[x] ListVisibleSystemConfigs Redis 缓存 ✅ 2026-06-17
[x] 系统配置 Otter RAM 缓存 + 统一失效 + pub/sub ✅ 2026-06-17
[x] CAPTCHA 运行时配置快照 ✅ 2026-06-17
[ ] OIDC Provider 缓存
[ ] Admin useEffect → React Query 统一(database overview 等待)
[ ] 状态轮询 visibility 感知
[ ] Server Component session 预取
```
---
## 附录:关键代码路径索引
| 路径 | 文件 | 说明 |
|------|------|------|
| 图片服务 | `internal/apps/upload/file_server.go` | `/f/{id}` 热点 |
| 磁盘缓存 | `pkg/cache/disk/cache.go` | ✅ RWMutex 读路径 |
| 迁移/白名单缓存 | `internal/apps/upload/access_cache.go` | ✅ 5s TTL + pub/sub |
| 文件统计 | `internal/apps/upload/stats.go` | ✅ 读 `w_upload_stats` |
| 公共配置列表 | `internal/model/system_configs.go` | ✅ Redis 列表缓存 |
| RAM 缓存封装 | `pkg/cache/ram/cache.go` | ✅ Otter v2 薄封装 |
| 系统配置缓存 | `internal/model/system_config_cache.go` | ✅ RAM + 失效 + pub/sub |
| 参数失效 API | `InvalidateSystemConfigCache` | ✅ 清 RAM + Redis field |
| CAPTCHA 快照 | `internal/apps/cap/runtime_settings.go` | ✅ `CurrentSettings` + pub/sub |
| 批量下载 | `internal/apps/upload/routers.go` | 同步 ZIP |
| 上传索引 | `internal/infra/persistence/migrator/goose/*202606170001*.sql` | ✅ 复合索引已加 |
| 认证 gate | `frontend/app/(main)/layout.tsx` | ✅ 即时渲染 + `useAuthRedirect` |
| 页面鉴权 | `frontend/components/auth/require-auth.tsx` | ✅ 子页面按需拦截 |
| 用户上下文 | `frontend/contexts/user-context.tsx` | ✅ 登录/注册页跳过 fetch |
| 实时日志 | `frontend/components/common/admin/app-logs.tsx` | ✅ `useVirtualizer` |
| API 去重 | `frontend/lib/services/core/api-client.ts` | 已有,可复用模式 |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

-684
View File
@@ -1,684 +0,0 @@
---
sidebar: false
---
# 更新日志
本文件记录 OpenFlare 每个版本的重要变更。
格式基于 [Keep a Changelog](http://keepachangelog.com/),版本号遵循 [语义化版本](http://semver.org/)。
## 重大变更
> [!IMPORTANT]
>
> 3.1.2 版本更新了 CLickHouse 部署配置。
>
> 3.0.0 版本为 Wavelet 平台迁移与架构重构版本,涉及数据库表结构、环境变量以及前后端底层架构的重大变更。请务必在升级前备份数据库,并且更新到 V2.3.4。
> 目前已知的兼容性问题:
>
> - Pages 无法迁移, 升级前请先手动下载并备份 Pages 静态站点的 ZIP 包,升级后重新创建。
> - 性能调优参数重置, 升级后请重新配置
## [v3.4.5] - 2026-08-08
### 改进
- 源站错误页支持「仅针对 GET 请求」:开启后仅对 GET 的匹配错误状态码返回自定义错误页,其它 HTTP 方法透传源站响应。
- 升级后端 Go 依赖至最新稳定版(Gin、GORM、OpenTelemetry、ClickHouse 驱动、AWS SDK、Redis 客户端等),并完成升级兼容性适配:OpenTelemetry 资源 schema 与语义约定版本对齐,ClickHouse 驱动新增格式查询/插入接口的测试替身补齐。
- 升级前端 npm 依赖至最新稳定版(Next.js 16.3、React 19.2、recharts 3、react-day-picker 10、lucide-react 1.x、Tailwind CSS 4.3 等),适配图表/日历组件 API 变化,并将 ESLint 配置迁移为 eslint-config-next 16 的 flat config。
- Agent 不再将 GeoLite2 Country/City MMDB 嵌入二进制:Docker 镜像在默认数据目录 COPY 数据库文件,裸二进制首次启动时按需下载,显著减小 Agent 包体积;OpenResty 仍从磁盘路径读取 MMDB。Server 控制面仍仅内嵌 Country MMDB(不含 City),供可选 MaxMind 提供方离线初始化。
## [v3.4.4] - 2026-08-06
### 新增
- 新增全局源站错误页:可在「网站管理 → 错误页」配置开关、触发状态码(支持 `500-599` 区间与单码)与自定义 HTML;默认启用 OpenFlare 极简错误页并保持真实 HTTP 状态码,修改后随配置版本发布下发到边缘,关闭后恢复透传。
- 源站错误页支持「仅针对 GET 请求」:开启后仅对 GET 的匹配错误状态码返回自定义错误页,其它 HTTP 方法透传源站响应。
- 新增 Cloudflare DNS 指向管理:可复用现有 Cloudflare DNS 账号或配置独立 Token,按分组将 ZoneDomain 的单条 A 记录异步同步到边缘节点 IPv4,并支持成员橙云、同步状态与节点 IP 变更联动。
### 修复
- 修复源站错误页在边缘返回 HTTP 200、页面状态码显示异常(如 0)的问题:错误响应现在正确透传上游状态码,并在页面中展示真实状态码。
- 修复 Agent 在配置已对齐但磁盘校验和不一致时,Pages 等对账成功后仍保留 `LastError` 的问题,避免偶发网络失败被健康事件长期显示为「活动中」且无法自动恢复。
### 改进
- 删除、撤销与未保存离开等确认操作统一改用页面内 AlertDialog,不再使用浏览器原生 `confirm` 弹窗,交互风格与系统其余对话框保持一致。
- Cloudflare 分组添加域名成员时支持按顶级域分层展示、搜索筛选与批量勾选,可一次加入多个域名并排队同步。
- Cloudflare 首页展示域名同步(sync_member)与分组同步(sync_group)任务执行记录,可筛选状态、查看详情与失败重试。
- Cloudflare 域名/分组同步任务日志补充域名、分组、生效节点 IP、橙云状态及逐域名进度等关键信息,便于排查同步结果。
- Cloudflare 域名同步与分组同步任务改为可在任务管理中调度的标准任务类型,并提供成员 ID / 分组 ID 参数表单。
- Cloudflare 首页直接提供指向分组管理,并为分组详情增加自动刷新与手动刷新,减少页面跳转并及时展示同步状态。
- 统一数据访问分层:业务持久化经 `internal/repository`,`internal/model` 仅保留实体与无 IO 领域规则,避免双轨 CRUD 与职责混淆。
- 构建检查增加 `internal/model` 禁止直接访问数据库/Redis 的架构守卫,并收敛 model 与 repository 的错误文案定义边界。
## [v3.4.3] - 2026-07-24
### 新增
- 安全性限流支持全局与站点级单 IP 请求频率限制(如 10r/s、100r/m):站点可空/0 继承全局、-1 关闭或自定义;触发时边缘返回 429,并按站点隔离计数。
- 反代站点「流量限制」页可直接配置上述请求频率策略。
### 改进
- 边缘缓存对齐 Cloudflare 默认模型:不再因请求会话 Cookie、Authorization 或客户端 Cache-Control 一律跳过缓存;响应带 Set-Cookie 时不写入边缘;无源站缓存头时按状态码使用默认 Edge TTL;标准静态扩展名默认不再包含 JSON。生效需重新发布节点配置。
- IP 组自动规则中的 `StatusCount` / `StatusRatio` 支持状态码类写法(如 `"2xx"`、`"4xx"`、`"5xx"`),便于按整类错误率匹配。
- IP 组同步间隔下限由 5 分钟调整为 1 分钟,便于更频繁同步自动/订阅名单。
- 自动 IP 组回看窗口字段由 `lookback_minutes` 调整为 `lookback`,支持 `60m`、`1h` 等时长写法,并移除最小 5 分钟限制(兼容旧字段)。
- 限流页请求压力图的 RPS 纵轴按可见时间窗口最高值的 1.5 倍动态缩放,拖动底部时间范围条时同步更新。
### 修复
- 修复 Cloudflare DNS 指向功能在 PostgreSQL 初始化迁移时因 `authorization` 保留关键字导致启动失败的问题。
- 修复 IP 组自动抓取使用预设规则时未写入 `ttl` 字段的问题,避免配置 JSON 缺少封禁时长。
- 修复限流相关迁移中表名错误,确保升级脚本正确执行。
## [v3.4.2] - 2026-07-19
### 新增
- 安全性新增「限流」设置:可为边缘站点配置默认并发与带宽;站点未设置时继承,填 `-1` 可显式关闭。
- Pages 项目新增持久部署源,可配置 Remote URL 或公开 GitHub Release,并支持手动检查、同步发布、来源状态查看与同一 Release 资源替换确认;GitHub latest 来源可按设定间隔自动检查并发布更新,部署历史会保留安全的来源快照。
- Pages 部署源默认扫描间隔调整为每天一次,部署源任务可在任务管理中查看与调度。
### 改进
- 站点流量限制语义调整为空或 `0` 继承全局默认、`-1` 关闭、大于 `0` 自定义;修改全局默认后需发布配置版本生效。
- Agent Docker 部署命令默认挂载命名卷 `openflare-agent-pages` 持久化 Pages 目录,重建容器时无需重新拉取静态站点包。
- 限流页新增「分析」视图:默认展示近 24 小时请求压力(RPS)与独立访客双轴趋势(3 分钟桶),支持域名过滤与 24 小时/3 天预设,并按窗口平均 RPS 排行域名与 IP;原全局默认配置迁入「配置」页签。
- Pages 详情页重构为「部署 / 设置」Tab,部署源卡片样式更紧凑统一,Remote URL 改为明文编辑。
### 修复
- 修复 Pages 部署包路径校验、归档展开限额、历史版本裁剪、代理路由绑定与 Agent 下载过程中的安全和一致性问题;大包改为流式处理,部署入口、旧版目录切换、保留版本及上传记录在并发场景下更加可靠,异常中断遗留的部署包也会被安全补偿清理。
## [v3.4.1] - 2026-07-19
### 新增
- WAF 规则编排新增「UA 检查」节点:可要求携带 User-Agent、按浏览器/操作系统白名单(且/或)匹配,并优先屏蔽常见爬虫、非正常 UA(不含爬虫)与自定义正则 UA。
- WAF 规则编排新增「安全防护」节点:可开关路径穿越、文件包含、SQL 注入、XSS、命令注入、SSRF、恶意上传、XXE 与 CRLF 等基础特征检测;默认仅开启路径穿越与文件包含。
### 改进
- 新建反代规则时默认开启边缘缓存,策略为仅缓存标准静态资源。
- 节点详情页 Tab 调整为「概览」与「状态与部署」:原数据看板并入概览;运行状态与配置信息并入状态与部署;边缘节点新增可自动填充 Server URL 与 Agent Token 的 Docker 部署命令卡片。
- 节点详情「运行诊断」摘要不再展示具体错误日志,避免长日志撑破布局。
- WAF 规则编辑器支持为节点自定义显示名称,并从节点库拖放到画布指定位置添加节点。
- WAF 规则画布支持右键删除节点或连线,并屏蔽浏览器默认右键菜单。
- WAF 规则编辑器支持一键格式化布局,按流程层次自动整理节点位置。
- 优化边缘 WAF「安全防护」与「UA 检查」热路径:SQL/命令/XSS 等仅扫描 Query、Cookie、Referer 与有限 Body,避免对全部请求头做特征匹配;路径检测不再重复扫描完整 `request_uri`;无请求体时跳过 Body 读取;UA 分类仅小写一次并加速白名单匹配,显著降低开启基础防护时的 CPU 占用。
- 优化边缘 WAF「IP 匹配」:IP 组与节点 IP/CIDR 在加载时编译为索引(优先随 Agent 下发的 `resty.ipmatcher` 基数树,否则 exact 哈希 + 预解析 CIDR),查询与名单规模解耦,避免大名单线性扫描打满 CPU。
- Agent 内嵌 `resty.ipmatcher`,部署时不再依赖无效 opm 包。
### 修复
- 收紧 WAF 安全防护特征,降低对常见正常请求的误伤(含避免 SQL 特征 `/* */` 误匹配 `Accept: */*`)。
- 优化 WAF 规则编辑器返回按钮、列表操作与属性栏布局体验。
## [v3.4.0] - 2026-07-19
### 新增
- 访问日志重构为「概览」「IP 明细」与「日志明细」:概览含请求量/访问量/带宽趋势与 Top 排行;IP 明细可按时间窗查看请求数、2xx 比例、入出站流量并支持详情分析;日志明细展示完整请求字段。
- 边缘访问日志支持 User-Agent 与 `cache_status`(命中/回源/未缓存);概览新增设备类型、浏览器、操作系统与状态码分布。
- 访问日志概览支持按 Zone/域名多选筛选;明细列表在 IP 旁展示地区信息。
- 新建站点开启缓存时推荐「标准静态资源」(不含 HTML);原按 URL/空策略存量行为保留为「所有可缓存 GET」。
- Pages 现支持上传 zip、tar.gz、tar.xz、tar.bz2、tar、7z 等常用压缩格式的部署包。
- 管理员可在运维设置中配置 Pages 部署包大小上限与每个项目的历史部署保留数量。
- Pages 支持从 URL 导入部署包:填写下载链接后由控制面代为拉取并创建部署。
- 观测存储新增 `of_node_edge_health` 与 `of_access_log_hourly`,业务趋势优先读访问日志小时汇总。
- 访问日志增加 `request_length` / `request_time_ms`,用于接收数据与耗时统计。
### 修复
- 修复访问日志概览按域名筛选无效的问题,现已兼容 `hosts` / `hosts[]` 参数。
- 修复 Agent 观测缓冲合并访问日志时忽略 `cache_status` 导致缓存状态被去重丢弃的问题。
- 修复访问日志概览在 ClickHouse 查询失败时静默吞错的问题,现会输出错误日志。
- 修复数据看板业务流量趋势与已提供数据口径不一致的问题:业务量统一由访问日志聚合。
- 修复节点地图在缺少精确经纬度时,把香港/新加坡/台湾等地区错误标到占位坐标的问题。
### 变更
- 边缘观测改为「访问日志为业务唯一真相」:Agent 仅上报明细、主机指标与 OpenResty 健康/连接;协议去掉旧兼容字段,**升级需重建或替换 Agent**。
- Agent 默认心跳改为 3 秒、离线判定 60 秒,离线补传窗口默认 60 分钟。
- 看板 UV 使用窗口内真正去重;Zone 曲线标明分桶 UV;磁盘读写改为按小时速率(B/s)展示。
- 不再采集或展示宿主机网卡入/出站;网络趋势仅保留访问日志已提供/接收数据。
- Pages 包大小与历史保留可配置,边缘按项目只保留最新激活部署;创建规则表单与详情一致支持直连/隧道/Pages 源站类型。
- 优化 Pages 部署包校验性能:不再为包内每个文件计算哈希,整包校验和保障完整性。
- 优化访问日志排行榜与饼图布局;页签状态支持 URL 参数记忆。
- 启用 `cache_status` 与边缘缓存策略变更需执行相关迁移并重新发布节点配置。
### 移除
- 移除请求预聚合表与 OpenResty 吞吐观测相关路径;管理端不再返回 `traffic_reports` 与 `openresty_rx|tx`。
- 访问日志已移除时间折叠视图;IP 情报从日志明细详情迁出至 IP 明细。
## [v3.3.0] - 2026-07-14
### 新增
- WAF 规则现支持可视化编排、版本冲突保护和按顺序绑定路由,便于创建和维护复杂的防护策略。
- WAF IP 组现支持按城市匹配来源地址,帮助更精细地控制访问范围。
### 变更
- 优化了 WAF 规则编辑器的初始视图和操作方式,编辑规则时可看到更多上下文并可直接管理节点、连线和启用状态。
- WAF 地域匹配编辑器改用完整国家与一级行政区数据,国家选项同时显示中文名称和 ISO 代码,行政区支持按名称或代码搜索。
- Agent 现内置国家和城市地址库,首次启动无需下载即可使用地区匹配功能,并会在后续自动更新数据。
- 默认关闭 Redis maintenance notifications 自动协商,减少不支持该功能的 Redis 服务产生兼容性警告。
### 移除
- 移除了 WAF 旧版固定名单与人机验证配置;升级后请在发布前使用新的可视化规则重新编排防护策略。
## [v3.2.0] - 2026-07-12
### 新增
- 新增网站和域名管理能力,并提供 24 小时、7 天和 30 天的流量概览,便于集中查看访问趋势和已提供的数据量。
### 变更
- 网站管理入口调整为网站详情中的概览、域名、路由、证书和设置页面,域名与证书的关联方式更加统一。
- 配置发布、边缘代理和监控现统一从网站域名读取域名与证书,减少配置不一致导致的运行问题。
- 自动清理说明明确了分析数据的最短保留期限,便于管理员预期数据保存时间。
### 移除
- 移除了旧版托管域名管理入口,请改用网站及网站域名管理功能。
- 移除了网站、域名、路由和 WAF 相关对象的备注字段;证书和源站备注仍可继续使用。
### 修复
- 修复了网站概览中已提供的数据量无法统计的问题,使流量数据更加准确。
- 修复了嵌入式前端打开网站详情时可能错误跳回首页的问题。
- 修复了 Docker 部署中 ClickHouse 可能无法从宿主机访问的问题。
## [v3.1.2] - 2026-07-10
### 修复
- 修复了节点和仪表盘在 24 小时范围内容量、网络与磁盘趋势数据不完整的问题。
- 优化了 ClickHouse 的写入、查询和后台处理方式,降低节点空闲时的资源占用并提升高负载下的稳定性。
- 修复了数据保留清理和写入失败重试的统计问题,使清理结果和运行状态更可信。
- 改进了小规格环境下的 ClickHouse 部署配置,减少启动和连接争用问题。
## [v3.1.1] - 2026-07-06
### 修改
- 默认关闭登录页面的人机验证,减少普通登录流程的额外操作;管理员仍可按需启用。
## [v3.1.0] - 2026-07-04
### 变更
- 优化了分析数据的写入、查询、缓存和自动过期策略,降低高频心跳和访问日志对系统资源的影响。
- 调整了 ClickHouse 的连接、批处理和 Docker 部署配置,提升小规格环境下的运行稳定性。
- 收紧了审计访问日志的请求头记录范围并进行脱敏,减少敏感数据暴露风险。
- 更新了管理后台的文档入口和全局搜索范围,使常用功能更容易查找。
### 修复
- 修复了数据库迁移、系统自更新和设置页跳转可能失败的问题。
## [v3.0.2] - 2026-06-30
### 修复
- 修复了历史数据迁移后 PostgreSQL 自增编号可能与现有数据冲突的问题,避免后续创建记录失败。
## [v3.0.1] - 2026-06-30
### 新增
- 新增用户资料编辑、密码重置和按邮箱搜索功能,便于管理员维护用户账号。
- 新增命令行密码重置工具,方便无法登录管理后台时恢复账号访问。
### 修复
- 修复了创建 DNS 账号可能失败的问题。
- 修复了主题切换后侧边栏和危险操作按钮颜色异常的问题,提升界面可读性。
- 修复了部分服务运行模式无法正确启动的问题。
## [v3.0.0] - 2026-06-27
### 升级与迁移注意事项
> [!WARNING]
> 本次重构涉及数据库表结构以及环境变量的重大变更,老版本务必从 v2.3.4 最新版本升级迁移,否则可能导致数据库结构不兼容或管理端 API 无法访问。
> 升级前务必备份数据库
### 重大重构说明
本版本完成了控制面的重大升级:
- 管理后台重构为统一的用户、登录验证和系统设置体验,配置管理更加集中。
- 网站管理拆分为域名、路由、静态托管、WAF 和缓存等独立能力,更适合维护复杂站点配置。
- Tunnel 节点统一纳入节点管理,配置发布和运行状态查看更加一致。
## [v2.3.4] - 2026-06-17
### 变更
- 访问日志列表查询将分页与计数下推到数据库执行,避免百万级数据全量加载到内存。
- 访问日志 `total_ip` 统计改为 SQL `UNION` + `COUNT(*)` 下推执行,分片计数与分页查询并行化。
- 访问日志折叠视图、IP 汇总与趋势改为 SQL `GROUP BY` 聚合;过滤条件改为 `node_id` 精确匹配及其他字段前缀匹配以利用索引。
- 标准化 Server Go 目录结构,引入 `cmd/server`、`openflare-server/internal` 与根级 `pkg` 分层,并拆分原 `utils` 公共能力包。
## [v2.3.3] - 2026-06-06
### 新增
- 新增密码登录人机验证(基于 Proof-of-Work 和无感浏览器检测的 Cap 验证码防护)
- 新增后端 PoW 校验服务,实现 FNV-1a/XORShift PRNG 难题生成、验证及 JWT 难题校验算法,支持基于路由路径参数 `scope` 进行验证流的强校验与安全隔离
- 新增线程安全的内存 TTL 核销缓存,支持高并发与 Single-use 难题令牌防重放
- 新增 Gin 拦截中间件与参数化路由 `/api/cap/:scope/challenge` 和 `/api/cap/:scope/redeem`,登录接口 `POST /api/user/login` 自动从 HTTP 请求头校验 `X-Cap-Token` 并放行
- 前端登录页集成 cap-widget 组件,配置 `/api/cap/login/` 隔离端点按需加载 CDN 脚本,实现静默 PoW 求解与令牌提交
- 管理后台系统设置页“登录与注册开关”中新增“启用登录人机验证”开关,支持热更新全局防护状态
- 新增 Agent 交互式安装向导,支持选择本地安装和 Docker 运行模式;未传参数时自动进入交互菜单
- 新增 Docker 运行模式的智能环境检查,检测到未安装 Docker 时支持一键在线安装,中国大陆环境支持多镜像源自动测速优选与加速器配置
- 新增 Agent 交互式卸载向导,支持选择本地卸载和 Docker 容器卸载模式;未传参数时自动进入交互菜单
### 变更
- 重构 `install-agent.sh` 安装脚本与 `uninstall-agent.sh` 卸载脚本以兼容交互式导引、非交互式命令行参数及 Docker 部署/卸载参数(`--docker`/`--method docker`)
- 重构 Go 包依赖结构为统一模块(Monorepo),模块命名为 `github.com/rain-kl/openflare`
- 移除各子目录下独立的 `go.mod`/`go.sum` 文件,统一由根目录 `go.mod` 进行全局依赖管理与依赖版本锁定
- 替换全仓库 Go源文件中的内部引用路径,由本地相对路径迁移为标准 GitHub 绝对导入路径
- 适配 Docker 镜像构建,所有组件镜像的 Dockerfile 调整为基于根目录的上下文编译
- 更新 GitHub release 自动化发布流水线,适配全新 monorepo 包结构与符号信息注入路径
- 简化并重构数据库历史迁移校验逻辑,将版本 2 至 6 的中间校验函数合并到基线校验函数 `validateDatabaseSchemaV7` 中,消除冗余代码
- 重构数据库历史迁移校验架构,引入基于 GORM 反射解析(`schema.Parse`)的通用自动表结构校验,彻底废弃老版本中大量手动编写的 `HasTable`/`HasColumn` 结构字段存在性检测代码
---
## [v2.3.2] - 2026-06-04
### 说明
> [!IMPORTANT]
> 2.3.2 开始使用 JWT_SECRET 环境变量替代 SESSION_SECRET 进行管理端 API 的 JWT 签名密钥管理。SESSION_SECRET 将会在之后的版本中逐步废弃,请务必尽快迁移到 JWT_SECRET。
### 新增
- 新增 `JWT_SECRET` 环境变量,专用于管理端 API JWT 签名密钥;生产环境必须显式配置
- 新增 VitePress 更新日志页面(`docs/changelog/index.md`),记录所有版本变更历史
### 变更
- 管理端 API 鉴权框架迁移至 `gin-jwt`
- 认证方式变更为 Headers 认证.
- `JWT_SECRET` 优先于 `SESSION_SECRET` 用于 JWT 签名;未配置时回退到 `SESSION_SECRET`,向下兼容
- 屏蔽手动升级入口(`/api/update/manual-upload`、`/api/update/manual-upgrade`),前端隐藏对应 UI 组件
---
## [v2.3.1] - 2026-06-03
### 变更
- 屏蔽手动升级入口,前端隐藏对应 UI 组件
- POW 与 WAF 规则合并, 统一逻辑处理
---
## [v2.3.0] - 2026-06-03
### 新增
- WAF IP 组支持订阅模式,可从远程文本或 JSON 源定时同步
- 新增 Pages 静态站点托管,支持 SPA fallback 路由配置
- Agent 实现 WebSocket 实时推送,Server 发布配置后立即通知在线 Agent
### 变更
- Agent 数据面与 OpenResty 合并为集成镜像部署方式
- 访问日志与观测数据支持数据库分片,按 ID 分片替代原有逻辑
---
## [v2.2.8] - 2026-06-03
### 修复
- 修复多域名部署场景下跨域认证绕过安全漏洞
---
## [v2.2.6] - 2026-06-02
### 新增
- 新增 Uptime Kuma 集成,支持自动同步监控任务
- WAF 新增 PoW(工作量证明)防护能力,可配置有效期
### 变更
- 内网穿透支持 TunnelRelay 中继节点(frps),新增 OpenFlared 客户端(frpc)
---
## [v2.2.5] - 2026-06-02
### 新增
- 新增 WAF 自动 IP 组,支持基于 Expr 规则定时聚合请求日志更新名单
- WAF IP 组黑白名单支持直接引用 IP 组对象
### 变更
- WAF 规则组与网站解耦,支持全局规则组和自定义规则组独立管理
---
## [v2.2.4] - 2026-06-02
### 新增
- WAF 规则组新增拦截返回配置 Tab
### 修复
- 修复 WAF 配置发布后部分规则不生效的问题
---
## [v2.2.3] - 2026-06-02
### 新增
- 新增 WAF 安全防护模块,支持 IP 黑白名单和地域拦截规则
---
## [v2.2.2] - 2026-06-01
### 变更
- 观测数据支持按时间窗口自动清理,新增数据库自动清理调度器
---
## [v2.2.1] - 2026-06-01
### 修复
- 修复仪表板概览数据压缩与规范化问题
---
## [v2.2.0] - 2026-06-01
### 新增
- 新增 TLS 证书转换为 ACME 托管证书的接口(`/convert-acme`)
- 新增 ACME 账号与 DNS 账号管理页面
- 支持 Let's Encrypt 自动申请与续期
---
## [v2.1.1] - 2026-06-01
### 变更
- Agent 架构调整,采用集成镜像方式内置 OpenResty
---
## [v2.0.3] - 2026-05-31
### 修复
- 修复版本号生成逻辑,确保使用当日最大序列号
---
## [v2.0.1] - 2026-05-30
### 修复
- 修复 GitHub 登录逻辑异常
---
## [v2.0.0] - 2026-05-30
### 新增
- 全面重构发布模型,引入配置版本不可变快照机制
- 支持配置版本回滚(重新激活旧版本)
- 新增 `source_config_json` 与 `support_files` 供 Agent 获取完整配置包
- 新增节点专属 Agent Token 与 Discovery Token 双轨鉴权
### 变更
- 数据库迁移框架切换至 goose,统一管理版本升级步骤
- Agent API 与管理端 API 鉴权完全分离
---
## [v1.9.3] - 2026-05-30
### 修复
- 修复节点 IP 自动探测逻辑,优先使用公网地址
---
## [v1.9.2] - 2026-05-29
### 变更
- Agent 心跳超时后自动退回 HTTP 轮询模式
---
## [v1.9.1] - 2026-05-29
### 修复
- 修复 Agent WebSocket 升级失败时的重连逻辑
---
## [v1.9.0] - 2026-05-29
### 新增
- Agent 支持 WebSocket 长连接,Server 发布后实时推送配置变更
---
## [v1.8.0] - 2026-05-26
### 新增
- 支持自定义 DNS 解析器(`OpenRestyResolvers`)
- 新增历史配置快照清理功能
### 变更
- CORS 配置支持动态源与凭证
- 上游统一渲染为命名 `upstream` 并启用 keepalive
---
## [v1.7.0] - 2026-05-25
### 新增
- 新增 ACME 和 DNS 账号管理功能,支持证书申请与续期
### 变更
- 移除新用户注册功能
- 更新 Go 版本要求至 1.25+
---
## [v1.6.1] - 2026-05-13
### 修复
- 修复个人设置页无法查看第三方认证源及解绑功能
---
## [v1.6.0] - 2026-05-13
### 新增
- 支持 OIDC 单点登录(SSO)
---
## [v1.5.0] - 2026-04-25
### 新增
- 集成 PoW(Anubis)防护,支持有效期配置
---
## [v1.4.0] - 2026-04-01
### 新增
- 支持域名级别独立绑定 TLS 证书,每个域名可单独选择证书
- 新增批量更新配置项接口
- 新增 Agent 卸载脚本
### 变更
- 禁用新用户自助注册
- 默认服务器块新增 HTTPS 握手拒绝支持
---
## [v1.3.2] - 2026-03-30
### 新增
- 网站配置支持多域名绑定与共享设置
- 新增抽屉式规则创建组件
---
## [v1.3.1] - 2026-03-20
### 新增
- 新增源站管理功能,支持源站创建、更新与删除
### 变更
- 重构代理路由页面,优化输入组件与样式
---
## [v1.3.0] - 2026-03-19
### 新增
- 新增数据库观测数据手动和自动清理策略
- 节点访问日志支持数据库分片,按 ID 分片
### 变更
- 数据库版本管理与迁移逻辑重构
---
## [v1.2.0] - 2026-03-19
### 新增
- 支持多上游地址负载均衡
- 新增缓存策略配置(路径前缀、精确路径)
- 节点健康事件清理功能
### 变更
- 上游渲染改为命名 upstream 并启用 keepalive
- 更新 HTTPS 配置,启用 reuseport 与 epoll 事件模型
---
## [v1.1.2] - 2026-03-18
### 变更
- HTTPS 启用 HTTP/2 支持
---
## [v1.1.1] - 2026-03-18
### 新增
- 新增获取配置版本详情 API
### 变更
- 仪表板概览数据结构优化,添加压缩与规范化
---
## [v1.1.0] - 2026-03-18
### 新增
- 新增应用日志分页查询与清理功能
- 新增访问日志 IP 汇总与趋势查询
- 新增 OpenResty DNS 解析器指令支持
- Docker 部署支持在运行中容器内执行 reload
### 修复
- 修复应用结果警告逻辑
- Lua 和证书文件管理重构,优化文件同步与清理机制
---
## [v1.0.2] - 2026-03-17
### 新增
- 支持 PostgreSQL 数据库,添加数据库迁移逻辑
- 新增 Docker Compose 配置,支持 PostgreSQL 联动部署
### 变更
- 多个管理端 API 请求方法从 PUT/DELETE 统一改为 POST
---
## [v1.0.1] - 2026-03-16
### 新增
- 新增 `origin_host` 字段,支持覆盖回源请求的 Host 头
### 修复
- 修复代理配置中 SSL 服务器名称和主机头覆盖逻辑
---
## [v1.0.0] - 2026-03-15
OpenFlare 首个正式版本发布。
### 新增
- 管理端 UI、管理 API、Agent API 基础功能
- 反向代理配置管理与 OpenResty 配置渲染
- 配置版本发布与 Agent 同步
- TLS 证书导入与管理
- 节点注册、心跳与状态观测
- SQLite 数据库支持
-146
View File
@@ -1,146 +0,0 @@
import {type DefaultTheme, defineAdditionalConfig} from 'vitepress'
export default defineAdditionalConfig({
description:
'OpenFlare 是轻量、自托管的 OpenResty 控制面,用于管理反向代理、配置发布、节点同步、TLS 证书与基础观测。',
themeConfig: {
nav: nav(),
sidebar: {
'/guide/': { base: '/guide/', items: sidebarGuide() },
'/reference/': { base: '/reference/', items: sidebarReference() },
'/deployment/': { base: '/deployment/', items: sidebarDeployment() },
'/design/': { base: '/design/', items: sidebarDesign() },
'/changelog/': { base: '/changelog/', items: [] }
},
editLink: {
pattern: 'https://github.com/Rain-kl/OpenFlare/edit/main/docs/:path',
text: '在 GitHub 上编辑此页面'
},
footer: {
message: '基于 Apache License 2.0 发布',
copyright: 'Copyright © OpenFlare contributors'
},
docFooter: {
prev: '上一页',
next: '下一页'
},
outline: {
label: '页面导航'
},
lastUpdated: {
text: '最后更新于'
},
notFound: {
title: '页面未找到',
quote: '这份文档还没有对应页面。',
linkLabel: '前往首页',
linkText: '回到 OpenFlare 文档'
},
langMenuLabel: '语言',
returnToTopLabel: '回到顶部',
sidebarMenuLabel: '菜单',
darkModeSwitchLabel: '主题',
lightModeSwitchTitle: '切换到浅色模式',
darkModeSwitchTitle: '切换到深色模式',
skipToContentLabel: '跳转到内容'
}
})
function nav(): DefaultTheme.NavItem[] {
return [
{ text: '指南', link: '/guide/', activeMatch: '/guide/' },
{ text: '部署', link: '/deployment/', activeMatch: '/deployment/' },
{ text: '参考', link: '/reference/', activeMatch: '/reference/' },
{ text: '设计', link: '/design/', activeMatch: '/design/' },
{ text: '更新日志', link: '/changelog/', activeMatch: '/changelog/' }
]
}
function sidebarGuide(): DefaultTheme.SidebarItem[] {
return [
{
text: '指南',
items: [
{ text: '概览', link: '' },
{ text: '快速开始', link: 'quick-start' },
{ text: 'TLS 证书与自动续期', link: 'certificates' },
{ text: 'Zone 域名迁移', link: 'zone-domain-migration' },
{ text: '新建反代配置', link: 'proxy-config' },
{ text: 'Pages 静态托管使用', link: 'pages-usage' },
{ text: '内网穿透与隧道使用', link: 'tunnel-usage' },
{ text: 'WAF 安全防护使用', link: 'waf-usage' },
{ text: 'WAF 自动 IP 组语法', link: 'waf-ip-group-expr' },
{ text: 'Uptime Kuma 监控同步', link: 'uptime-kuma' },
{ text: 'SSO 登录配置', link: 'sso' },
{ text: '发布第一份配置', link: 'first-site' },
{ text: '故障排查', link: 'troubleshooting' },
{ text: '引用与致谢', link: 'credits' }
]
}
]
}
function sidebarReference(): DefaultTheme.SidebarItem[] {
return [
{
text: '参考',
items: [
{ text: '概览', link: '' },
{ text: '配置项', link: 'configuration' },
{ text: '命令与脚本', link: 'cli' }
]
}
]
}
function sidebarDeployment(): DefaultTheme.SidebarItem[] {
return [
{
text: '部署',
items: [
{ text: '概览', link: '' },
{ text: '部署说明', link: 'deployment' },
{ text: '启动 Server', link: 'server' },
{ text: '接入 Agent', link: 'agent' },
{ text: '部署 Relay (Tunnel)', link: 'relay' },
{ text: '部署 OpenFlared', link: 'openflared' },
{ text: '升级与维护', link: 'upgrade' }
]
}
]
}
function sidebarDesign(): DefaultTheme.SidebarItem[] {
return [
{
text: '设计',
items: [
{ text: '产品边界', link: '' },
{ text: '系统架构', link: 'architecture' },
{ text: 'Zone 与域名资源设计', link: 'zone-design' },
{ text: 'Cloudflare DNS 指向设计', link: 'cloudflare-pointing' },
{ text: 'Agent 与发布模型', link: 'agent-design' },
{ text: '内网穿透隧道设计', link: 'tunnel-design' },
{ text: 'WAF 设计', link: 'waf-design' },
{ text: 'WAF 可编排规则设计', link: 'waf-orchestration-design' },
{ text: 'Pages 静态托管设计', link: 'pages-design' },
{ text: '边缘缓存策略设计', link: 'edge-cache-design' },
{ text: '源站错误页设计', link: 'origin-error-page' },
{ text: '边缘可观测与业务流量统计', link: 'observability-design' },
{ text: '观测数据传输模型', link: 'observability-transport-model' },
{ text: '观测上报协议与表结构', link: 'observability-data-model' },
{ text: 'Uptime Kuma 监控同步设计', link: 'kuma-design' },
{ text: '登录验证码设计', link: 'login-captcha' }
]
}
]
}
-232
View File
@@ -1,232 +0,0 @@
# 接入 Agent
你会学到:Agent 的职责、两种接入 Token 的区别、安装脚本参数、`agent.json` 配置方式,以及如何确认节点已经上线。
OpenFlare Agent 运行在代理节点侧。它不会接收远程 shell 指令,而是通过 Agent API 拉取控制面发布的配置版本,在本地写入 OpenResty 文件、执行配置校验、reload,并在失败时尝试回滚到可运行配置。
## 接入方式
| 方式 | 适用场景 |
| --- | --- |
| `discovery_token` | 首次自动注册节点,由 Server 置换为节点专属凭证 |
| `agent_token` | 已在管理端创建或分配节点,直接使用节点专属凭证接入 |
`agent_token` 与 `discovery_token` 至少填写一个。
### 凭证获取路径
- **`discovery_token`(自动注册凭证)**:登录管理端后台,导航至「系统设置」->「自动注册」,在页面中可直接生成、查看和复制全局的自动注册凭证。
- **`agent_token`(节点专属凭证)**:登录管理端后台,导航至「节点管理」->「新增节点」,填写节点基本信息保存后,在节点详情页面即可直接复制该节点专属的接入 Token。
## 一键安装
### 交互式安装 (推荐)
如果在不传递任何参数的情况下运行安装脚本,脚本将进入交互模式。您将可以通过向导选择安装方式(本地运行 / Docker 容器运行),并配置 Server 地址与认证 Token(若选择 Docker 方式且本地没有 Docker,脚本还会询问并智能安装 Docker):
```bash
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash
```
### 自动化 (非交互式) 安装
如果在执行脚本时附加了任何参数,脚本将进入自动化安装模式,不需要任何交互。
使用 `discovery_token` 进行本地安装:
```bash
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
--server-url http://your-server:3000 \
--discovery-token YOUR_DISCOVERY_TOKEN
```
使用节点专属 `agent_token` 进行本地安装:
```bash
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
--server-url http://your-server:3000 \
--agent-token YOUR_AGENT_TOKEN
```
使用 Docker 容器自动化安装:
```bash
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
--server-url http://your-server:3000 \
--discovery-token YOUR_DISCOVERY_TOKEN \
--docker
```
安装脚本在本地安装模式下会下载最新 Agent,默认写入 `/opt/openflare-agent`,生成 `agent.json`,自动检测并创建低权限系统账号 `openflare`(将整个安装目录赋权给该用户),并在 Linux + systemd 环境创建 `openflare-agent.service` 服务。该服务将以 `openflare` 普通用户运行,并通过 Linux Capabilities(`CAP_NET_BIND_SERVICE`)保障其监听特权端口(如 80、443)的能力。
支持参数:
| 参数 | 说明 |
| --- | --- |
| `--server-url` | Server 地址 |
| `--discovery-token` | 首次自动注册 Token |
| `--agent-token` | 节点专属 Token |
| `--install-dir` | 安装目录,默认 `/opt/openflare-agent`(仅本地安装生效) |
| `--openresty-path` | OpenResty 二进制路径,未传时自动查找 `openresty`(仅本地安装生效) |
| `--repo` | 下载 Agent 的 GitHub 仓库,默认 `Rain-kl/OpenFlare` |
| `--no-service` | 不创建 systemd 服务(仅本地安装生效) |
| `--docker` | 使用 Docker 容器方式安装 |
| `--method` | 安装方式,可选 `local` 或 `docker`(默认 `local`) |
## 配置文件
默认配置文件路径:
```text
/opt/openflare-agent/agent.json
```
本地配置示例:
```json
{
"server_url": "http://127.0.0.1:3000",
"agent_token": "replace-with-node-auth-token",
"data_dir": "./data",
"openresty_path": "openresty",
"openresty_observability_port": 18081,
"observability_replay_minutes": 60,
"heartbeat_interval": 3000,
"request_timeout": 10000
}
```
自定义 OpenResty 路径示例:
```json
{
"server_url": "http://127.0.0.1:3000",
"agent_token": "replace-with-node-auth-token",
"data_dir": "/var/lib/openflare-agent",
"openresty_path": "/usr/local/openresty/nginx/sbin/openresty",
"main_config_path": "/var/lib/openflare-agent/etc/nginx/nginx.conf",
"route_config_path": "/var/lib/openflare-agent/etc/nginx/conf.d/openflare_routes.conf",
"access_log_path": "/var/lib/openflare-agent/var/log/openflare/access.log",
"cert_dir": "/var/lib/openflare-agent/etc/nginx/certs",
"lua_dir": "/var/lib/openflare-agent/etc/nginx/lua",
"runtime_config_dir": "/var/lib/openflare-agent/etc/openflare",
"heartbeat_interval": 3000,
"request_timeout": 10000
}
```
如果不配置 `openresty_path`,Agent 默认调用 `openresty`。完整字段见 [配置项参考](../reference/configuration.md#agent-配置字段)。
## Docker 运行
Docker 部署时直接运行内置 OpenResty 的 Agent 镜像:
```bash
docker pull ghcr.io/rain-kl/openflare-agent:latest
docker rm -f openflare-agent 2>/dev/null || true
docker run -d --name openflare-agent --restart unless-stopped \
-p 80:80 -p 443:443/tcp -p 443:443/udp \
-v openflare-agent-pages:/data/var/lib/openflare/pages \
-e OPENFLARE_SERVER_URL=http://your-server:3000 \
-e OPENFLARE_AGENT_TOKEN=YOUR_AGENT_TOKEN \
ghcr.io/rain-kl/openflare-agent:latest
```
> [!NOTE]
> **Pages 持久化**
> 默认将 Pages 部署目录挂载到 Docker 命名卷 `openflare-agent-pages`(容器内路径 `/data/var/lib/openflare/pages`)。重建或升级 Agent 容器时无需重新拉取静态站点包。
> [!NOTE]
> **非 Root 安全加固运行**
> Agent 容器内部已完成安全加固,在启动后会统一以低权限非 root 用户 `openflare` 运行。
> 容器已内置了 `cap_net_bind_service` 内核能力,使得低权限进程依然能够正常监听宿主机的 `80` 和 `443` 特权端口。
> 同时,OpenResty 运行时所需的各种临时路径(包括 PID 路径、各类临时缓存目录如 `client_body_temp_path`、`proxy_temp_path` 等)都由 Agent 控制器动态渲染并自动重定向至容器内的 `/data` 目录,彻底避免在非 root 权限运行时写入默认系统路径而导致的权限拒绝错误(Permission Denied)。
> 具体物理缓存写入路径为:
> * 临时缓存目录:`/data/var/cache/nginx`
> * 代理缓存目录:`/data/var/cache/openflare_proxy`
## 启动与验证
systemd 环境:
```bash
systemctl status openflare-agent
journalctl -u openflare-agent -f
```
手动启动:
```bash
/opt/openflare-agent/openflare-agent -config /opt/openflare-agent/agent.json
```
源码运行:
```bash
export LOG_LEVEL='info'
go run ./cmd/agent -config /path/to/agent.json
```
编译后二进制运行:
```bash
go build -o openflare-agent ./cmd/agent
export LOG_LEVEL='info'
./openflare-agent -config /path/to/agent.json
```
在管理端确认:
| 位置 | 期望结果 |
| --- | --- |
| 节点列表 | 节点在线 |
| 节点详情 | 能看到心跳时间、当前版本和基础资源信息 |
| 应用记录 | 发布配置后出现应用结果 |
## 卸载
### 交互式卸载 (推荐)
如果在不传递任何参数的情况下运行卸载脚本,脚本将进入交互模式。您可以通过提示菜单选择卸载方式(本地卸载 / Docker 容器卸载):
```bash
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/uninstall-agent.sh | bash
```
### 自动化 (非交互式) 卸载
使用命令行传参进行无人值守卸载。
本地卸载(默认):
```bash
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/uninstall-agent.sh | bash -s -- --install-dir /opt/openflare-agent
```
Docker 容器卸载:
```bash
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/uninstall-agent.sh | bash -s -- --docker
```
支持参数:
| 参数 | 说明 |
| --- | --- |
| `--install-dir` | 安装目录,默认 `/opt/openflare-agent`(仅本地卸载生效) |
| `--service-name` | systemd 服务名,默认 `openflare-agent`(仅本地卸载生效) |
| `--docker` | 使用 Docker 容器方式卸载 |
| `--method` | 卸载方式,可选 `local` 或 `docker`(默认 `local`) |
本地卸载只会移除 Agent 服务、进程和安装目录,不会删除本机 OpenResty。Docker 卸载会停止并删除 `openflare-agent` 容器,交互模式下还可以选择是否清理对应的 Docker 镜像。
## 常见问题
| 现象 | 处理步骤 |
| --- | --- |
| `agent_token 和 discovery_token 不能同时为空` | 检查 `agent.json` 至少配置了一个 Token |
| 节点一直离线 | 在 Agent 节点执行 `curl -I http://your-server:3000`,确认 Server 地址可达 |
| OpenResty 没有启动 | 查看 `journalctl -u openflare-agent`,确认 `openresty_path` 可执行,80/443 端口未被占用,且运行用户(如 `openflare`)对数据目录具有读写权限 |
| 发布后重复失败 | Agent 会阻断同一 `version + checksum` 的重复应用;需要修正配置后重新发布,或激活旧版本回滚 |
-209
View File
@@ -1,209 +0,0 @@
# 部署说明
你会学到:OpenFlare 的推荐部署方式、Server 与 Agent 的运行要求、源码启动方式、联调步骤、升级与卸载入口。
生产环境建议使用 PostgreSQL 作为 Server 数据库,并通过 `config.yaml` 或环境变量配置 `APP_SESSION_SECRET` 等参数。完整 Docker Compose 部署还需 Redis 与 ClickHouse(见仓库根目录 `docker-compose.yaml`)。Agent 部署方式推荐为 Docker 部署(即直接使用内置 OpenResty 的 Agent 镜像);亦支持通过安装脚本或手动本地运行。
## 部署拓扑
### 标准反代流量路径
```text
Browser
|
v
OpenFlare Server :3000
|
| Agent API / heartbeat / config pull
v
OpenFlare Agent
|
v
OpenResty binary
|
v
Origin service
```
### 内网穿透流量路径
```text
Browser
|
v
OpenResty (Agent, WAF/HTTPS 终结) <-- TunnelRelay 节点
|
| proxy_pass (127.0.0.1:{vhost_port})
v
OpenFlareRelay (frps 进程) <-- TunnelRelay 节点
|
| frp 隧道协议
v
OpenFlared (frpc 客户端) <-- 内网服务器
|
v
Internal Service (192.168.x.x)
```
## 前置条件
Server:
| 项目 | 要求 |
| --- | --- |
| Go | `1.25+`,仅源码运行需要 |
| Node.js | `18+`,仅源码构建管理端需要 |
| 数据库 | 可写 SQLite 文件目录,或可访问的 PostgreSQL 实例 |
| 端口 | 默认监听 `3000` |
Agent:
| 项目 | 要求 |
| --- | --- |
| 系统 | 安装脚本支持 Linux 和 macOS;systemd 服务仅在 Linux + systemd 环境创建 |
| 架构 | `amd64` 或 `arm64` |
| OpenResty | 本地部署需要可执行 `openresty`,或通过 `--openresty-path` 指定路径 |
| Docker | 仅 Docker 部署 Agent 镜像时需要 |
| 网络 | Agent 节点必须能访问 Server 地址 |
| GeoIP | WAF 地域规则使用 OpenResty 读取本地 MaxMind mmdb;镜像内置文件或首次下载,Agent 负责周期更新 |
### 硬件配置推荐
| 组件 | 最低硬件配额 | 推荐硬件配额 | 说明 |
| --- | --- | --- | --- |
| **Server 控制面** | 1 核 CPU / 1 GB 内存 / 10 GB 磁盘 | 2 核 CPU / 4 GB 内存 / 50 GB+ 磁盘 | 磁盘用量需根据访问日志留存时长与并发流量合理扩容 |
| **Agent 数据面** | 1 核 CPU / 512 MB 内存 / 2 GB 磁盘 | 2 核 CPU / 2 GB 内存 / 10 GB+ 磁盘 | 根据 OpenResty 的并发代理连接量与 WAF 拦截处理扩容 |
| **Relay 中继节点**| 1 核 CPU / 1 GB 内存 / 5 GB 磁盘 | 2 核 CPU / 2 GB 内存 / 20 GB 磁盘 | frps 传输中继吞吐量主要受带宽与 CPU 吞吐能力限制 |
| **OpenFlared 客户端**| 1 核 CPU / 256 MB 内存 / 1 GB 磁盘 | 1 核 CPU / 512 MB 内存 / 5 GB 磁盘 | 独立运行于内网,自身资源占用极小,保障网络吞吐即可 |
## Docker Compose 部署 Server
仓库根目录已提供完整 `docker-compose.yaml`(含 PostgreSQL、Redis、ClickHouse、Jaeger)。
```bash
curl -o .env.example https://raw.githubusercontent.com/Rain-kl/OpenFlare/refs/heads/main/.env.example
cp .env.example .env
# 编辑 .env,至少修改 APP_SESSION_SECRET 与数据库密码
docker compose up -d
docker compose ps
docker compose logs -f openflare
```
首次访问 `http://localhost:3000`,默认账号为 `admin` / `12345678`。登录后请立即修改默认密码。
## 源码启动 Server
先构建管理端前端:
```bash
cd frontend
corepack enable
pnpm install
pnpm build:embed
```
再启动 Server(仓库根目录):
```bash
cp config.example.yaml config.yaml
export APP_SESSION_SECRET='replace-with-a-long-random-string'
# 可选:使用 PostgreSQL
# export DB_HOST=127.0.0.1 DB_USERNAME=postgres DB_PASSWORD=postgres DB_NAME=openflare
go run main.go all
```
默认监听 `:3000`(由 `config.yaml` 的 `app.addr` 或 `APP_ADDR` 控制)。
## Docker 运行 Agent(推荐)
Docker 部署是 Agent 推荐的部署方式。Docker 部署时直接运行 Agent 镜像,该镜像基于 OpenResty 镜像制作,内置 Agent 控制器与 OpenResty 二进制。未显式配置 `node_ip` 时,Agent 会优先通过第三方 API 获取真实出口 IP,避免把 Docker 网桥地址登记为节点 IP。
```bash
docker pull ghcr.io/rain-kl/openflare-agent:latest
docker rm -f openflare-agent 2>/dev/null || true
docker run -d --name openflare-agent --restart unless-stopped \
-p 80:80 -p 443:443/tcp -p 443:443/udp \
-v openflare-agent-pages:/data/var/lib/openflare/pages \
-e OPENFLARE_SERVER_URL=http://your-server:3000 \
-e OPENFLARE_AGENT_TOKEN=YOUR_AGENT_TOKEN \
ghcr.io/rain-kl/openflare-agent:latest
```
命名卷 `openflare-agent-pages` 持久化 Pages 部署目录,重建容器时无需重新拉取静态站点包。
## Agent 接入(脚本安装)
除了 Docker 部署外,也支持通过安装脚本将 Agent 部署在本地宿主机上。安装脚本会自动在本地 Linux 系统中注册低权限的 `openflare` 服务账号,并将 systemd 服务配置为以该用户身份运行,利用 Linux Capabilities 安全地监听 80/443 特权端口。
使用 `discovery_token` 自动注册:
```bash
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
--server-url http://your-server:3000 \
--discovery-token YOUR_DISCOVERY_TOKEN
```
使用节点专属 `agent_token`:
```bash
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
--server-url http://your-server:3000 \
--agent-token YOUR_AGENT_TOKEN
```
安装脚本支持参数:
| 参数 | 说明 |
| --- | --- |
| `--server-url` | Server 地址,必填 |
| `--discovery-token` | 首次自动注册 Token,与 `--agent-token` 二选一 |
| `--agent-token` | 节点专属 Token,与 `--discovery-token` 二选一 |
| `--install-dir` | 安装目录,默认 `/opt/openflare-agent` |
| `--openresty-path` | OpenResty 二进制路径,未传时自动查找 `openresty` |
| `--repo` | 下载 Agent 的 GitHub 仓库,默认 `Rain-kl/OpenFlare` |
| `--no-service` | 不创建 systemd 服务 |
确认状态:
```bash
systemctl status openflare-agent
journalctl -u openflare-agent -f
```
## 手动运行 Agent
源码运行:
```bash
export LOG_LEVEL='info'
go run ./cmd/agent -config /path/to/agent.json
```
编译后二进制运行:
```bash
go build -o openflare-agent ./cmd/agent
export LOG_LEVEL='info'
./openflare-agent -config /path/to/agent.json
```
最小 `agent.json` 示例:
```json
{
"server_url": "http://127.0.0.1:3000",
"agent_token": "replace-with-node-auth-token",
"data_dir": "./data",
"openresty_path": "openresty",
"heartbeat_interval": 3000,
"request_timeout": 10000
}
```
未配置 `openresty_path` 时,Agent 默认调用 `openresty`。
默认情况下,Agent 在 HTTP 心跳成功后会尝试升级为 WebSocket。升级成功时,Server 发布或激活配置会立即通知 Agent;如果 WebSocket 无法建立或意外断开,Agent 会自动退回 HTTP 心跳同步。
WAF 地域规则依赖 Agent 本地 `GeoLite2-Country.mmdb` / `GeoLite2-City.mmdb`(OpenResty `resty.maxminddb` 读磁盘路径)。Docker 镜像会将 MMDB COPY 到 `data_dir/etc/openflare/`;裸二进制安装时若文件缺失则首次启动按配置 URL 下载。Agent 按配置周期尝试更新;更新失败只记录警告,不影响配置同步与 OpenResty reload。MMDB **不**再嵌入 agent 二进制。Server 控制面可选 MaxMind 提供方仍**仅内嵌 Country**(约 9MB,不含 City)用于离线 seed。
-24
View File
@@ -1,24 +0,0 @@
# 部署与升级
本分区提供 OpenFlare Server、Agent、Relay 中继以及 OpenFlared 内网穿透客户端的详细部署指南、配置说明和升级维护步骤。
## 内容导航
### 快速开始
* **[快速开始](../guide/quick-start.md)**:5 分钟内使用 Docker Compose 启动 Server 和首个 Agent(推荐新用户)
### Server 部署
* **[启动 Server](./server.md)**:从源码构建前端、启动 Server、选择 SQLite 或 PostgreSQL
### Agent 部署
* **[部署 Agent](./agent.md)**:Agent 接入方式、Docker 部署、脚本安装、配置文件及故障排查
### Tunnel 内网穿透部署
* **[部署 Relay](./relay.md)**:TunnelRelay 节点的配置说明、Docker 部署与宿主机运行指南
* **[部署 OpenFlared](./openflared.md)**:内网穿透客户端配置说明、Docker 运行与自同步机制
### 升级与维护
* **[升级与维护](./upgrade.md)**:Server 与 Agent 升级步骤、数据清理策略、验证命令
### 参考资料
* **[部署说明](./deployment.md)**:部署拓扑、前置条件、Docker Compose 配置示例、多种部署方式综览
-118
View File
@@ -1,118 +0,0 @@
# 部署 OpenFlared 客户端
你会学到:OpenFlared 客户端的职责、配置参数与环境变量、基于 Docker 运行客户端的方法,以及如何在内网服务器上通过二进制方式独立部署。
**OpenFlared** 是部署在用户内网(局域网、私有云等无法被公网直接访问的环境)的隧道客户端。它的核心职责是通过 `X-Tunnel-Token` 与控制面(OpenFlare Server)建立通信,并在本地自动拉起并管理一个或多个 **frpc (快速反向代理客户端)** 进程,从而将内网的 HTTP 流量安全、稳定地穿透至外网的中继节点。
---
## 前置条件
1. **获取 Tunnel Token**:在 OpenFlare 管理端的「内网穿透」或「隧道管理」页面中,创建一个新的隧道实例,系统会自动生成唯一的 `tunnel_id` 与 `tunnel_token`(形如 `tun-<32hex>`)。
2. **网络出方向权限**:内网服务器无需任何公网入方向 IP 或端口映射,但必须能够通过网络访问公网上的 **OpenFlare Server 地址** 以及对应的 **TunnelRelay 节点中继端口 (默认 7000)**。
3. **软件依赖**(仅限宿主机直接部署):
- 本地需有可执行的 `frpc` 二进制文件(建议版本为 `v0.61.0+` 或最新稳定版 `v0.69.0`),或通过参数显式指定路径。
---
## 配置文件与环境变量
`openflared` 启动时默认会读取当前目录下的 `flared.json`。同时也完全支持通过环境变量进行覆盖。
### 配置字段详情
| JSON 字段 | 环境变量 | 说明 | 默认值 |
| --- | --- | --- | --- |
| `server_url` | `OPENFLARE_SERVER_URL` | OpenFlare Server 接口服务地址 | **无(必填)** |
| `tunnel_token` | `OPENFLARE_TUNNEL_TOKEN` | 隧道客户端专属认证 Token | **无(必填)** |
| `frpc_path` | `OPENFLARE_FRPC_PATH` | frpc 可执行二进制文件路径 | `"frpc"` |
| `data_dir` | `OPENFLARE_DATA_DIR` | 本地数据与生成的 `frpc_{relayNodeID}.toml` 存放目录 | `"./data"` |
| `state_path` | - | 本地状态记录文件路径(保存最后应用的配置版本)| `"{data_dir}/flared-state.json"` |
| `heartbeat_interval`| - | 状态心跳上报周期(支持毫秒数或 Go Duration 字符串) | `10000` (10s) |
| `sync_interval` | - | 隧道配置拉取同步周期(支持毫秒数或 Go Duration 字符串) | `30000` (30s) |
| `request_timeout` | - | 接口网络请求超时时长 | `10000` (10s) |
---
## Docker 运行(推荐)
Docker 部署是内网运行最简单也最安全的方式。官方的 `openflared` 镜像已经内置了客户端控制器以及 `frpc v0.69.0` 二进制运行时,无需额外搭建环境。
```bash
docker pull ghcr.io/rain-kl/openflared:latest
docker rm -f openflared 2>/dev/null || true
docker run -d --name openflared --restart unless-stopped \
-e OPENFLARE_SERVER_URL=http://your-server:3000 \
-e OPENFLARE_TUNNEL_TOKEN=YOUR_TUNNEL_TOKEN \
-v openflared-data:/app/data \
ghcr.io/rain-kl/openflared:latest
```
---
## 宿主机手动运行
如果您需要直接在内网的 Linux/macOS/Windows 宿主机上独立运行:
### 1. 编译二进制
```bash
go build -o bin/flared ./cmd/flared
```
### 2. 准备 `flared.json`
在程序同级目录下创建 `flared.json` 配置文件:
```json
{
"server_url": "http://your-server-ip:3000",
"tunnel_token": "your-tunnel-auth-token",
"frpc_path": "/usr/local/bin/frpc",
"data_dir": "./data",
"heartbeat_interval": "10s",
"sync_interval": "30s"
}
```
### 3. 运行服务
```bash
export LOG_LEVEL='info'
./flared -config ./flared.json
```
---
## 启动与验证
### 1. 自动同步逻辑
启动成功后,OpenFlared 将执行以下工作流:
- **心跳与配置获取**:周期性向 Server 的 `/api/v1/tunnel/heartbeat` 和 `/api/v1/tunnel/config/active` 接口发起同步,验证 Token 并检测配置版本。
- **文件渲染**:当检测到配置版本(或校验和 Checksum)变化时,会自动拉取该隧道的完整路由规则。如果绑定了多个中继 Relay,将为每个 Relay 分别在 `data_dir` 下渲染出 `frpc_{relayNodeID}.toml`。
- **热重载或重启**:拉起对应的 `frpc` 子进程,或在配置文件发生改变时执行 `frpc reload` / 重启动作,以确保流量映射保持最新。
- **异常自恢复**:如果本地 `frpc` 隧道进程异常退出,主控程序会在 5 秒的退避惩罚后自动尝试重新启动。
### 2. 查看日志与连接状态
```bash
# Docker 容器日志
docker logs -f openflared
```
若进程运行无误,您会在日志中看到类似如下输出:
```text
flared config loaded ...
detected frpc version v0.69.0
flared process started
applying new tunnel config {"version": "...", "checksum": "..."}
frpc process missing, starting {"relay_id": "..."}
```
### 3. 管理端确认
打开管理后台的 **「内网穿透」** 页面:
- 查看对应隧道的在线状态,此时应当绿灯显示 **「在线」**。
- 您可以清晰地看到该隧道目前连接了哪些中继节点,以及各内网服务的穿透路由详情。
-131
View File
@@ -1,131 +0,0 @@
# 部署 Relay (Tunnel 中继)
你会学到:TunnelRelay 节点的职责、`openflare-relay` 的配置项与环境变量、使用 Docker 运行 Relay 的方法,以及如何通过源码手动构建并部署 Relay。
在 OpenFlare 的内网穿透体系中,**TunnelRelay 节点** 扮演着关键的角色。它与普通的边缘节点(Edge Node)不同,除了运行传统的 Agent(托管 OpenResty 进行 HTTPS/WAF 处理)外,还同机运行了 **Relay (frps 隧道管理器)** 服务,负责监听内网客户端(OpenFlared)的隧道连接并进行流量中继。
---
## 前置条件
在部署 TunnelRelay 节点之前,请确保:
1. **已注册为 TunnelRelay 类型节点**:在 OpenFlare 管理端「节点管理」中,添加一个类型为 `tunnel_relay` 的节点,并获取其专属的 `agent_token` 或使用全局 `discovery_token`。
2. **网络端口**:
- 必须确保 `bindPort`(frpc 连接端口,默认 `7000`)可被公网/内网客户端访问。
- 必须确保 `vhostHTTPPort`(HTTP Vhost 端口,默认 `8080`)处于空闲状态,Agent 将在此端口上与 frps 进行流量传递。
3. **软件依赖**(仅限宿主机直接部署):
- 本地需有可执行的 `frps` 二进制文件(建议版本为 `v0.61.0+` 或最新稳定版 `v0.69.0`),或通过参数显式指定路径。
---
## 配置文件与环境变量
`openflare-relay` 启动时默认会读取当前目录下的 `relay.json`。同时也完全支持通过环境变量进行覆盖。
### 配置字段详情
| JSON 字段 | 环境变量 | 说明 | 默认值 |
| --- | --- | --- | --- |
| `server_url` | `OPENFLARE_SERVER_URL` | OpenFlare Server 接口服务地址 | **无(必填)** |
| `agent_token` | `OPENFLARE_AGENT_TOKEN` | 节点专属 Token | 与下者二选一 |
| `discovery_token` | `OPENFLARE_DISCOVERY_TOKEN` | 自动注册 Token | 与上者二选一 |
| `node_name` | `OPENFLARE_NODE_NAME` | 节点标识名称 | 默认获取本机主机名 |
| `node_ip` | `OPENFLARE_NODE_IP` | 节点出口/监听 IP | 自动检测真实出口 IP |
| `frps_path` | `OPENFLARE_FRPS_PATH` | frps 可执行二进制文件路径 | `"frps"` |
| `data_dir` | `OPENFLARE_DATA_DIR` | 本地数据与生成的 `frps.toml` 存放目录 | `"./data"` |
| `state_path` | - | 本地状态 JSON 记录文件路径 | `"{data_dir}/relay-state.json"` |
| `heartbeat_interval`| - | 心跳周期(支持毫秒数或 Go Duration 字符串) | `10000` (10s) |
| `request_timeout` | - | 接口请求超时时长 | `10000` (10s) |
---
## Docker 运行(推荐)
Docker 运行是 TunnelRelay 节点最便捷的部署方案。官方镜像内置了 `openflare-relay` 控制器与 `frps v0.69.0` 运行时,开箱即用。
```bash
docker pull ghcr.io/rain-kl/openflare-relay:latest
docker rm -f openflare-relay 2>/dev/null || true
docker run -d --name openflare-relay --restart unless-stopped \
-p 7000:7000 \
-p 17500:17500 \
-e OPENFLARE_SERVER_URL=http://your-server:3000 \
-e OPENFLARE_AGENT_TOKEN=YOUR_AGENT_TOKEN \
-v openflare-relay-data:/app/data \
ghcr.io/rain-kl/openflare-relay:latest
```
> [!TIP]
> 这里的 `-p 7000:7000` 映射的是 `frpc` 客户端连接中继的端口。如果管理端配置了自定义的 `relay_bind_port`,请对应修改宿主机端口映射。
> [!NOTE]
> **开启内嵌 frps Web UI**:
> 如果在 Server 控制端开启了中继流量监控面板(即数据库/系统设置中的 `relay_frps_web_ui_enabled` 设为 `true`),你需要将 Web 端口(默认是 `17500`,由系统设置中的 `relay_frps_web_ui_port` 控制)也通过 `-p 17500:17500` 映射到宿主机。
> 登录 Web UI 时的用户名固定为 `admin`,密码为当前中继节点的 `agent_token`。
---
## 宿主机手动运行
如果您倾向于在物理机或虚拟机上直接运行:
### 1. 编译二进制
```bash
go build -o bin/openflare-relay ./cmd/relay
```
### 2. 准备 `relay.json`
在程序同级目录下创建 `relay.json` 配置文件:
```json
{
"server_url": "http://127.0.0.1:3000",
"agent_token": "your-relay-node-agent-token",
"frps_path": "/usr/local/bin/frps",
"data_dir": "./data",
"heartbeat_interval": "10s",
"request_timeout": "10s"
}
```
### 3. 运行服务
```bash
export LOG_LEVEL='info'
./openflare-relay -config ./relay.json
```
---
## 启动与验证
### 1. 查看进程日志
```bash
# Docker 容器日志
docker logs -f openflare-relay
```
如果是在 Linux 上通过 Systemd 托管的,可执行:
```bash
journalctl -u openflare-relay -f
```
### 2. 验证运行状态
启动成功后,Relay 将进行以下工作:
- 向控制面发送 HTTP 心跳以注册/上线。
- 从控制面获取最新的 frps 基础配置(包括 `bindPort`、`vhostHTTPPort` 与自动生成的隧道认证凭证 `auth_token`)。
- 在本地自动渲染出 `data/frps.toml` 配置文件。
- 自动拉起子进程 `frps -c data/frps.toml`。
- 如果进程意外崩溃,Relay 将在 2 秒后自动拉起它。
### 3. 管理端确认
登录管理后台,导航至 **「节点管理」**,确认:
- 该 TunnelRelay 节点状态标记为 **「在线」**。
- 节点类型正确标记为 **中继节点** 且 frps 运行状态为 **正常 (Healthy)**。
-440
View File
@@ -1,440 +0,0 @@
# 启动 Server
你会学到:如何使用 Docker(分为快速启动、生产推荐、进阶版)部署,以及如何从源码本地部署 OpenFlare Server。
OpenFlare Server 是 Gin + GORM 单体控制面,负责管理端 UI、管理 API、Agent API、配置渲染、版本发布、数据存储与聚合查询。
> [!IMPORTANT]
> **关于外部依赖**:
> OpenFlare 系统内建了对后台异步任务(Asynq 框架)及海量节点日志分析与度量指标(观测面板)的支持。因此,**无论采用何种部署模式,系统都必须依赖 Redis(或 Valkey)与 ClickHouse 的运行**。各个部署方案的主要差异在于主关系型数据库的选择(SQLite vs PostgreSQL)以及是否启用链路追踪服务(Jaeger)。
> [!TIP]
> **ClickHouse 服务端性能配置(推荐挂载)**
> 控制面常见为小规格主机(如 3c6g)。仓库提供的 `performance.xml` 会收紧后台 merge/mutation 线程池,避免默认配置在小机器上静置 CPU 偏高或 ClickHouse 25.x 启动校验失败。
> 将本地 `./config/clickhouse/performance.xml` 以单文件方式挂载到容器 `/etc/clickhouse-server/config.d/performance.xml`,以保留官方镜像内置的 Docker 网络监听配置。
部署前将配置拉到本地:
```bash
mkdir -p ./config/clickhouse
curl -fsSL -o ./config/clickhouse/performance.xml \
https://raw.githubusercontent.com/Rain-kl/OpenFlare/refs/heads/main/config/clickhouse/performance.xml
```
在 ClickHouse 服务的 `volumes` 中增加(与数据卷并列):
```yaml
volumes:
- ./data/clickhouse_data:/var/lib/clickhouse # 或 named volume
- ./config/clickhouse/performance.xml:/etc/clickhouse-server/config.d/performance.xml:ro
```
修改 `performance.xml` 后需 `docker compose restart clickhouse` 才生效。
---
## 方式一:Docker 部署 (推荐)
使用 Docker 部署可以免去本地配置 Go 与 Node.js 前端构建环境的麻烦。根据你的服务器硬件配置及业务需求,你可以选择以下三种方案之一:
### 1. 快速启动 (SQLite + Redis + ClickHouse)
> **适用场景**:测试体验、轻量化单机部署。
>
> **特点**:主关系型数据库使用内建的 SQLite 文件
创建 `docker-compose.yaml` 文件:
```yaml
version: '3.8'
services:
openflare:
image: ghcr.io/rain-kl/openflare:latest
container_name: openflare-server
restart: unless-stopped
ports:
- "3000:3000"
volumes:
- ./openflare-data:/data
- ./uploads:/app/uploads
environment:
TZ: Asia/Shanghai
APP_SESSION_SECRET: 'replace-with-a-long-random-string' # 生产环境请替换为长随机字符串
DB_ENABLED: "false" # 禁用 PostgreSQL,自动启用内置 SQLite 后备
SQLITE_PATH: "/data/openflare.db"
REDIS_ENABLED: "true"
REDIS_ADDR: "redis:6379"
CLICKHOUSE_ENABLED: "true"
CLICKHOUSE_HOST: "clickhouse:9000"
depends_on:
redis:
condition: service_healthy
clickhouse:
condition: service_healthy
redis:
image: valkey/valkey:8.0-alpine
restart: unless-stopped
command: ["valkey-server", "--appendonly", "yes"]
volumes:
- ./data/valkey:/data
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
clickhouse:
image: clickhouse/clickhouse-server:25.3-alpine
restart: unless-stopped
environment:
CLICKHOUSE_DB: openflare
CLICKHOUSE_USER: default
CLICKHOUSE_PASSWORD: 123456
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1
TZ: Asia/Shanghai
ulimits:
nofile:
soft: 262144
hard: 262144
volumes:
- ./data/clickhouse_data:/var/lib/clickhouse
- ./config/clickhouse/performance.xml:/etc/clickhouse-server/config.d/performance.xml:ro
healthcheck:
test: ["CMD", "clickhouse-client", "--user", "default", "--password", "123456", "--query", "SELECT 1"]
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
```
运行启动命令:
```bash
mkdir -p ./config/clickhouse
curl -fsSL -o ./config/clickhouse/performance.xml \
https://raw.githubusercontent.com/Rain-kl/OpenFlare/refs/heads/main/config/clickhouse/performance.xml
docker compose up -d
```
---
### 2. 生产推荐 (PostgreSQL + Redis + ClickHouse)
> **适用场景**:生产环境、多节点集群管理、高并发高可用要求。
>
> **特点**:完全分层架构。启用专用的 PostgreSQL 服务作为主关系数据库,Redis 负责高并发分布式锁、会话缓存与异步队列,ClickHouse 承载海量日志异步 Flush 与观测指标。
创建 `docker-compose.yaml` 文件:
```yaml
services:
openflare:
image: ghcr.io/rain-kl/openflare:latest
restart: unless-stopped
env_file: .env
environment:
TZ: ${TZ:-Asia/Shanghai}
ports:
- "3000:3000"
volumes:
- openflare_uploads:/app/uploads
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
clickhouse:
condition: service_healthy
postgres:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${DB_NAME:-openflare}
POSTGRES_USER: ${DB_USERNAME:-openflare}
POSTGRES_PASSWORD: ${DB_PASSWORD:-replace-with-strong-password}
volumes:
- openflare_postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-openflare} -d ${DB_NAME:-openflare}"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: valkey/valkey:8.0-alpine
restart: unless-stopped
command: ["valkey-server", "--appendonly", "yes"]
volumes:
- openflare_redis_data:/data
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
start_period: 5s
clickhouse:
image: clickhouse/clickhouse-server:25.3-alpine
restart: unless-stopped
environment:
CLICKHOUSE_DB: ${CLICKHOUSE_NAME:-openflare}
CLICKHOUSE_USER: ${CLICKHOUSE_USERNAME:-default}
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-replace-with-clickhouse-password}
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1
TZ: ${TZ:-Asia/Shanghai}
ulimits:
nofile:
soft: 262144
hard: 262144
volumes:
- openflare_clickhouse_data:/var/lib/clickhouse
- ./config/clickhouse/performance.xml:/etc/clickhouse-server/config.d/performance.xml:ro
healthcheck:
test: ["CMD", "clickhouse-client", "--user", "${CLICKHOUSE_USERNAME:-default}", "--password", "${CLICKHOUSE_PASSWORD:-replace-with-clickhouse-password}", "--query", "SELECT 1"]
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
volumes:
openflare_uploads:
openflare_postgres_data:
openflare_redis_data:
openflare_clickhouse_data:
```
创建对应的 `.env` 文件来配置系统环境变量(可复制并修改根目录下的 `.env.example`):
```bash
mkdir -p ./config/clickhouse
curl -fsSL -o ./config/clickhouse/performance.xml \
https://raw.githubusercontent.com/Rain-kl/OpenFlare/refs/heads/main/config/clickhouse/performance.xml
curl -o .env.example https://raw.githubusercontent.com/Rain-kl/OpenFlare/refs/heads/main/.env.example
cp .env.example .env
# 编辑 .env 文件,填入对应的数据库、Redis、ClickHouse 连接地址、密码与 APP_SESSION_SECRET
docker compose up -d
```
---
### 3. 进阶版 (含 Jaeger 链路追踪的完整编排)
> **适用场景**:开发者调试、系统深度性能诊断、高级可观测性追溯。
>
> **特点**:在“生产推荐”全家桶的基础上,联动拉起 Jaeger 作为 OpenTelemetry (OTel) 链路追踪的后端,收集 Server 运行时各个 API 请求的 Span Trace 信息。
创建 `docker-compose.yaml` 文件:
```yaml
version: '3.8'
services:
openflare:
image: ghcr.io/rain-kl/openflare:latest
restart: unless-stopped
env_file: .env
environment:
TZ: ${TZ:-Asia/Shanghai}
OTEL_EXPORTER_OTLP_ENDPOINT: "http://jaeger:4317"
OTEL_EXPORTER_OTLP_INSECURE: "true"
OTEL_SAMPLING_RATE: "1.0" # 本地调试建议设为 1.0 以采样所有 Trace
ports:
- "3000:3000"
volumes:
- openflare_uploads:/app/uploads
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
clickhouse:
condition: service_healthy
jaeger:
condition: service_started
postgres:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${DB_NAME:-openflare}
POSTGRES_USER: ${DB_USERNAME:-openflare}
POSTGRES_PASSWORD: ${DB_PASSWORD:-replace-with-strong-password}
volumes:
- openflare_postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-openflare} -d ${DB_NAME:-openflare}"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: valkey/valkey:8.0-alpine
restart: unless-stopped
command: ["valkey-server", "--appendonly", "yes"]
volumes:
- openflare_redis_data:/data
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
start_period: 5s
jaeger:
image: jaegertracing/jaeger:2.19.0
restart: unless-stopped
environment:
TZ: ${TZ:-Asia/Shanghai}
ports:
- "16686:16686" # Web UI 端口
- "4317:4317" # OTLP gRPC 接收端口
- "4318:4318" # OTLP HTTP 接收端口
clickhouse:
image: clickhouse/clickhouse-server:25.3-alpine
restart: unless-stopped
environment:
CLICKHOUSE_DB: ${CLICKHOUSE_NAME:-openflare}
CLICKHOUSE_USER: ${CLICKHOUSE_USERNAME:-default}
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-replace-with-clickhouse-password}
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1
TZ: ${TZ:-Asia/Shanghai}
ulimits:
nofile:
soft: 262144
hard: 262144
volumes:
- openflare_clickhouse_data:/var/lib/clickhouse
- ./config/clickhouse/performance.xml:/etc/clickhouse-server/config.d/performance.xml:ro
healthcheck:
test: ["CMD", "clickhouse-client", "--user", "${CLICKHOUSE_USERNAME:-default}", "--password", "${CLICKHOUSE_PASSWORD:-replace-with-clickhouse-password}", "--query", "SELECT 1"]
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
volumes:
openflare_uploads:
openflare_postgres_data:
openflare_redis_data:
openflare_clickhouse_data:
```
启动并验证:
```bash
mkdir -p ./config/clickhouse
curl -fsSL -o ./config/clickhouse/performance.xml \
https://raw.githubusercontent.com/Rain-kl/OpenFlare/refs/heads/main/config/clickhouse/performance.xml
curl -o .env.example https://raw.githubusercontent.com/Rain-kl/OpenFlare/refs/heads/main/.env.example
cp .env.example .env
# 编辑 .env 文件并确保设置好 APP_SESSION_SECRET 密码
docker compose up -d
```
启动后可以通过访问 `http://localhost:16686` 打开 Jaeger 监控端查看系统 Span 链路。
---
## 方式二:本地部署 (源码/二进制启动)
如果你不希望使用 Docker,也可以直接在本地或虚拟机上从源码构建和运行 Server。由于后台异步任务和可观测指标分析为系统核心防线,**本地部署时依然需要连接外部 Redis 与 ClickHouse 实例**。
### 前置条件
| 项目 | 要求 |
| --- | --- |
| Go | `1.25+` |
| Node.js | `18+` |
| pnpm | 推荐通过 `corepack enable` 使用项目声明的 pnpm |
| 外部服务 | 必须在本地或远端运行 Redis (Valkey) 和 ClickHouse 实例;ClickHouse 建议挂载仓库提供的 `performance.xml`(见上文「ClickHouse 服务端性能配置」) |
### 1. 构建管理端前端
Go Server 运行时需要嵌入前端静态资源。编译 Go 二进制前需要先构建前端静态产物并输出到 Go 服务目录:
```bash
cd frontend
corepack enable
pnpm install
pnpm build:embed
cd ..
```
> **常用前端代码检查命令**:
> * `pnpm lint`
> * `pnpm typecheck`
### 2. 使用 SQLite 启动
关系数据库存储在本地 SQLite 文件,但依然需要提供 Redis 和 ClickHouse 连接配置:
```bash
cp config.example.yaml config.yaml
# 编辑 config.yaml:
# 1. 设置 app.session_secret 为一个随机的长字符串
# 2. 将 database.enabled 设为 false 以启用内置 SQLite
# 3. 将 redis.addrs 与 clickhouse.hosts 修改为你的本地/局域网服务连接信息
# 启动 Server(默认融合模式)
go run main.go all
```
### 3. 使用 PostgreSQL 启动
```bash
cp config.example.yaml config.yaml
# 编辑 config.yaml:
# 1. 设置 app.session_secret
# 2. 将 database.enabled 设为 true,并完整设置 database.*、redis.*、clickhouse.* 字段连接参数
# 启动 Server(默认融合模式)
go run main.go all
```
---
## 首次登录
Server 默认监听 `3000` 端口,启动成功后可以使用浏览器访问:`http://localhost:3000`。
默认管理员账户信息如下:
| 用户名 | 密码 |
| --- | --- |
| `admin` | `12345678` |
> [!WARNING]
> 为了你的系统安全,首次登录后请立即前往个人设置页面修改默认密码。
---
## 常用运维指南
### 1. 命令行子服务分进程启动
在大型生产部署中,你可以选择将 Server 按职责拆分为多个进程运行:
```bash
go run main.go api # 仅启动管理端与节点通信的 API 服务
go run main.go worker # 仅启动后台任务的 Worker 服务
go run main.go scheduler # 仅启动定时任务的 Scheduler 服务
go run main.go all # 融合模式(在一进程内运行上述所有服务,默认)
```
### 2. 状态验证
```bash
# 验证编译是否通过
go build ./...
# 运行内部单元测试
go test ./internal/apps/openflare/... -count=1
# 检查服务健康状态
curl http://127.0.0.1:3000/api/v1/d/status
```
-20
View File
@@ -1,20 +0,0 @@
# 升级与维护
你会学到:如何升级 Server 与 Agent、如何清理观测数据,以及维护前后应该执行哪些验证命令。
升级前建议先确认当前激活版本、最近一次 Agent 应用结果和数据库备份策略。生产环境不要在发布配置、Agent 大规模重连或数据库迁移进行中同时升级。
## Server 升级
拉取最新镜像升级
```bash
docker compose pull
docker compose up
```
如果是源码部署,重新启动 Server 后确认日志中没有数据库迁移或启动错误。
## Agent 升级
Agent 是完全无状态的,升级时直接拉取最新镜像重建容器即可。具体部署命令与安装方式请参考 **[接入 Agent](./agent.md)**。
-177
View File
@@ -1,177 +0,0 @@
# Agent 设计文档
你会学到:Agent 的设计原则、核心功能模块、与 Server 的交互链路,以及如何通过不可变版本模型与三阶段容灾机制来保证配置应用的安全性和可靠性。
---
## 需求分析
在分布式反向代理与边缘安全网关场景中,Agent 扮演着打通控制面(Server)与数据面(OpenResty)的核心角色。由于 Agent 运行在用户实际的节点服务器上,其设计必须遵循以下核心安全与高可用需求:
1. **主动拉取(Pull 模型)而非被动接收**:Server 不直接持有节点的 SSH 秘钥,也不主动发起向节点的入向连接。所有控制指令与配置更新均由 Agent 主动通过心跳(Heartbeat)或长连接(WebSocket)向上拉取。这消除了节点侧的入向防火墙安全隐患,防止了控制通道被劫持。
2. **极低侵入性**:Agent 作为一个独立的 Go 二进制进程运行,只与本地 OpenResty 进程进行基于文件的配置重写与信号通知交互,不干涉节点上的其他系统服务。
3. **极强容灾与自愈能力**:由于网络抖动、磁盘写满或异常配置等因素极易导致配置同步失败,Agent 必须具备零依赖的本地回滚自愈能力,严防因单次配置失误导致整机服务彻底瘫痪。
4. **纯粹的数据与状态落地**:Agent 仅负责承载 Server 渲染好的文件与控制意图落地,不包含复杂的业务逻辑校验、多端租户鉴权等控制面职责,确保了节点侧的高效与轻量。
---
## 核心功能
Agent 主要由以下核心子模块组成,共同配合完成其完整的生命周期管理:
| 模块名称 | 对应目录 | 功能职责 |
| :--- | :--- | :--- |
| **配置同步** | `sync/` | 负责拉取完整配置包,写入文件,触发重载,记录并回报同步状态。 |
| **心跳管理** | `heartbeat/` | 定期向 Server 上报节点健康状态、资源指标,并获取最新激活版本摘要。 |
| **WebSocket** | `wsclient/` | 保持与 Server 的长连接,提供秒级实时的配置推送与控制面指令响应。 |
| **OpenResty 管控** | `nginx/` | 执行 Nginx 配置校验 (`openresty -t`)、重写、平滑重载 (`reload`) 及进程自启动。 |
| **本地状态库** | `state/` | 持久化记录本地应用版本、错误日志及未成功上报的可观测性指标缓冲。 |
| **自更新服务** | `updater/` | 监听 Server 自更新指令,安全拉取新版本二进制并完成原地热升级。 |
| **可观测性** | `observability/` | 采集宿主机资源读数、OpenResty 健康/连接,并 tail 访问日志明细上报;**不做** UV/TopN/吞吐等业务预聚合。详见 [边缘可观测与业务流量统计](./observability-design.md)。 |
| **GeoIP 维护** | `geoipdata/` `geoipupdate/` | 维护并定期更新本地 GeoIP 数据库,为 WAF 地域过滤提供支撑。 |
---
## 与 Server 的交互链路
Agent 在生命周期中主要通过 **基于 Token 的自动注册** 和 **心跳/WebSocket 双通道** 与控制面通信。
### 1. 自动注册流程
若 Agent 启动时本地 `agent.json` 的 `access_token` 为空,但配置了 `discovery_token`,将触发自动注册流程:
1. Agent 向控制面 `/api/v1/agent/nodes/register` 发送注册请求,携带本地硬件摘要、IP 及主机名。
2. Server 校验 `discovery_token` 有效后,在数据库生成唯一的 `NodeID` 与专属 `AccessToken`(即 `agent_token`)并返回。
3. Agent 将获取的专用 Token 写入本地配置文件,擦除一次性 `discovery_token`,后续所有的通信均基于专属 `AccessToken` 进行鉴权认证。
### 2. 双通道心跳与同步机制
* **HTTP 轮询通道(兜底与探测)**:Agent 默认按设定的 `heartbeat_interval` 间隔发送 POST 心跳包。上报指标的同时获取当前激活版本的摘要信息(Version & Checksum)。
* **WebSocket 通道(实时通信)**:在 HTTP 心跳成功后,Agent 自动尝试将连接升级为 WebSocket (`/api/v1/agent/ws`)。
* WS 连接建立后,心跳与指标上报全面转移到 WS 管道,降低网络开销。
* Server 发布或激活新版本时,通过 WS 广播通知 Agent。Agent 收到变更事件后,**立即触发同步流程**,实现秒级配置生效。
* 若 WS 链路因网络问题断开,Agent 自动降级为 HTTP 轮询,并采用指数退避机制尝试重建 WS。
### 3. 交互时序图
```mermaid
sequenceDiagram
autonumber
participant Agent as OpenFlare Agent
participant OR as 本地 OpenResty
participant Server as OpenFlare Server
Note over Agent: 首次启动 (无 AccessToken)
Agent->>Server: 1. 自动注册请求 (携带 discovery_token)
Server-->>Agent: 2. 颁发 NodeID 与专属 AccessToken (agent_token)
Note over Agent: 存储 Token 至本地配置文件
rect rgb(240, 248, 255)
Note over Agent, Server: HTTP 兜底与 WebSocket 升级
Agent->>Server: 3. 发送 HTTP Heartbeat (上报系统状态与健康度)
Server-->>Agent: 4. 返回 ActiveConfig 摘要及 AgentSettings
Agent->>Server: 5. 发起 WebSocket 升级请求 (/api/v1/agent/ws)
Server-->>Agent: 6. 升级成功 (建立双向持久实时通道)
end
rect rgb(245, 245, 245)
Note over Agent, Server: 实时配置发布应用链路
Note over Server: 管理员在 UI 点击发布配置
Server->>Agent: 7. 通过 WS 广播新配置摘要 (WSMessageTypeActiveConfig)
Agent->>Server: 8. 请求拉取完整配置详情 (携带目标 Version/Checksum)
Server-->>Agent: 9. 返回完整配置快照 (Nginx配置、证书、WAF规则等)
Note over Agent: 备份旧文件,写入新配置至本地临时路径
Agent->>OR: 10. 执行配置语法校验 (openresty -t)
OR-->>Agent: 11. 返回语法校验结果 (OK)
Agent->>OR: 12. 平滑重载信号 (openresty -s reload)
Agent->>Server: 13. 上报应用成功状态 (Apply Log & ActiveVersion)
end
```
---
## OpenResty 的管控
Agent 对数据面 OpenResty 的管控实现了端到端的闭环,包含配置落地、语法验证、平滑重载和异常状态捕获:
### 1. 配置文件的落地组织
同步成功后,Agent 会将配置按照特定的物理结构写入到本地 `/etc/nginx/openflare-lua/` 目录下(或配置指定的 `LuaDir`):
* `nginx.conf`:主配置文件(替换相关占位符,配置性能参数、Shared Dictionaries 及全局 Server)。
* `routes.conf`:路由配置文件(由 Agent 生成,包含所有代理网站的 Server 块、证书路径、缓存及速率限制指令)。
* `certs/`:证书存放目录(文件命名为 `{cert_id}.crt` 和 `{cert_id}.key`)。
* `waf/` 与 `pow/`:WAF 及防 CC 挑战所需的专用 Lua 运行时脚本。
* `waf_config.json` 与 `waf_ip_groups.json`:WAF 过滤引擎所需的结构化规则配置文件。
* `pages_dir`:Pages 静态站点部署目录,默认位于 `data_dir/var/lib/openflare/pages`。当激活配置引用 Pages **项目**时,Agent 按 `project_id` 请求控制面「最新激活包」(hash + package),以流式方式写入临时文件并执行实际响应上限与 SHA-256 校验,再安全解压到 `projects/{project_id}/releases/{hash}`。解压后会复核文件数与总字节,绝对防御上限为 2 GiB 包、1,000 个文件、单文件及总量 8 GiB;随后原子切换 `current` 并**立即删除同项目其它历史 release**(仅保留最新)。项目内切换激活无需重发主配置;多项目对账时单项目失败不阻塞其它项目。
### 2. 精细化的重载动作
1. **备份当前配置**:在写入新文件之前,Agent 会将现有的配置文件复制到 `.backup` 临时目录下,保留完整的现场快照。
2. **写入并替换占位符**:将最新拉取的模板写入,自动将模板中的绝对路径占位符(如 `__OPENFLARE_LUA_DIR__`、`__OPENFLARE_PAGES_DIR__`)替换为本地实际运行路径。
3. **语法校验**:调用 `openresty -t -c <temp_nginx.conf>` 进行严格的语法测试。
4. **平滑重载**:若校验通过,将新配置移至正式路径,执行 `openresty -s reload`。若 OpenResty 处于未启动状态,则使用当前配置拉起进程。
5. **捕获异常**:校验或重载失败时,Agent 会截获标准错误输出(stderr),提取前 2000 个字符的详细报错信息。
---
## 发布与配置应用模型
OpenFlare 摒弃了动态 Patch 节点配置的落后方式,采用 **不可变配置版本发布模型**。
```text
修改规则 -> 预览 / 查看 diff -> 发布 -> 生成完整配置版本 -> 激活版本 -> Agent 拉取 -> 本地应用 -> 上报结果
```
### 1. 核心设计原则
* **完整发布**:每次发布均是对当前控制面所有启用路由、证书、Pages 部署引用、全局与局部 WAF 规则进行一次性全量编译,生成带唯一 `checksum` 的完整版本。
* **版本格式**:采用 `YYYYMMDD-NNN` 递增格式,确保版本历史直观、具备单调递增性。
* **全局单激活版本**:系统同时只有一个处于 `active` 状态的全局配置版本。回滚时无需逆向打补丁,只需将历史某个健康版本的状态改为 `active`,Agent 重新拉取应用即可。
### 2. 三阶段容灾回滚机制
当 Agent 发现配置应用(或平滑重载)失败时,将自动激活以下三阶段容灾防瘫痪链路:
```mermaid
graph TD
A[配置应用失败] --> B[第一阶段: 尝试本地备份恢复]
B -- 备份文件存在 --> C[写入本地备份文件]
C --> D[执行 openresty -t 校验]
D -- 校验成功 --> E[reload 恢复旧版本运行]
D -- 校验失败 --> F[进入第二阶段]
B -- 无备份 --> F[第二阶段: 写入内置安全兜底配置]
F --> G[写入兜底 nginx.conf: 仅监听 80 端口]
G --> H[启用 stub_status 健康检查]
G --> I[其他路由统一返回 503 且拦截异常配置]
G --> J[尝试拉起 OpenResty 维持基础存活]
J --> K[进入第三阶段]
E --> L[上报 Apply Warning]
K --> M[本地阻断该异常版本重复应用]
M --> N[上报 Apply Error 并保留详细报错]
```
1. **第一阶段:本地备份回退**
* Agent 尝试从前一步保存的 `.backup` 目录恢复主配置、路由及证书。
* 写入备份文件后,重新执行 `openresty -t` 校验。若成功,重载回退并向 Server 上报 `Warning`(警告:应用新版本失败,已自动退回历史健康版本)。
2. **第二阶段:内置安全兜底运行**
* 若本地不存在备份配置(如首次部署即配置错误),或者回退备份配置依然校验失败,Agent 将激活最终自愈机制——写入**内置安全兜底配置**。
* **安全兜底配置规范**:
* 仅监听 `80` 端口,不包含任何用户的真实反代路由。
* 除 `/openflare/stub_status` 健康监测路由返回正常外,其他一切访问请求统一返回状态码 `503 Service Unavailable`,响应体固定为 `OpenFlare: No Valid Configuration`。
* 尝试以此极简配置拉起 OpenResty。这能够确保 Nginx 进程自身不瘫痪,保留了底层的健康检查与探针通道,防止容器/Pod 因健康检查失败而被调度系统不断销毁重启,同时保护了敏感路由的安全性。
3. **第三阶段:本地配置阻断**
* Agent 会将当前导致崩溃的配置 `version + checksum` 记录在本地状态库的阻断名单中。
* 在控制面未激活新的配置(`checksum` 发生变化)之前,Agent 心跳将阻断对此异常版本的重复同步拉取,防止节点陷入“心跳 -> 拉取崩溃配置 -> 崩溃回滚”的死循环。
### 3. WAF IP 组运行时异步同步
为了避免高频变动的恶意 IP 黑名单频繁触发主配置的全量发布与 reload(平滑重载对 Nginx 依然有微小的 CPU 与连接开销),IP 组成员采用了与发布版解耦的**异步差分同步设计**:
* **静态发布快照**:发布生成的 `waf_config.json` 中仅包含规则组对 IP 组的引用关系(即 `ip_whitelist_group_ids` / `ip_blacklist_group_ids`),不包含具体的 IP 成员列表。
* **心跳差分对比**:Agent 在心跳包中上报本地已缓存 IP 组的 MD5 Checksum 映射表。
* **差分下发**:Server 比对当前激活版本引用的 IP 组哈希,仅向 Agent 下发缺失或发生变更的 IP 组成员,写入本地 `waf_ip_groups.json`,实现极速差分同步。
* **WebSocket 实时通知**:当 Server 手动更新 IP 组、订阅源自动同步成功、或安全规则自动触发临时封禁时,Server 会立即通过 WebSocket 广播受影响的 IP 组更新包,Agent 接收落地并即时生效,全程**无须 reload Nginx**。
---
## 设计约束
为保证数据与控制链路的安全边界,Agent 代码编写与二次开发必须严格遵守以下工程约束:
1. **零特权指令通道**:Server 绝对禁止向 Agent 传递任何任意 shell 命令或远程执行脚本(如 exec/eval 等)。所有系统控制原语(如启动、停止、重载、更新)必须硬编码在 Agent 二进制内部。
2. **严格的 Token 过滤与前缀验证**:Agent 侧向 Server 请求资源时,接口端点固定以 `/api/v1/agent/` 为前缀,并强制携带 `X-Agent-Token` 进行签名或令牌核验。
3. **节点自治原则**:Agent 须具备完备的离线工作能力。在与 Server 失去连接期间,本地 OpenResty 必须依靠本地已落地的配置保持反向代理服务的绝对正常运行。
4. **观测只上报事实**:访问日志以明细形式上送;主机指标上报计数器/瞬时读数。禁止在 Agent 内计算业务 UV、Top 域名、24h 已提供数据等结论性指标(由 Server 聚合)。详见 [边缘可观测与业务流量统计](./observability-design.md)。
5. **Pages 只消费控制面产物**:Remote URL、GitHub Release、自动 scanner,以及未来仓库 checkout/build executor 均属于 Server 职责。Agent 不接收外部 URL、访问令牌、仓库凭据或任意 clone/install/build 命令,只拉取已经激活且带完整性元数据的部署包。
-224
View File
@@ -1,224 +0,0 @@
# 系统架构
你会学到:OpenFlare 的整体架构、各核心组件(Server, Agent, OpenResty, Relay, Client)的职责分工,以及主要数据与请求流的宏观流向。
OpenFlare 是一套自托管的 OpenResty 控制面。它在物理上由 Server(控制面)、Agent(配置落地端)、节点本地 OpenResty(数据面)、内网穿透组件(Relay 与 OpenFlared,数据面扩展)以及管理端前端组成。
---
## 流量路径概览
根据不同的网站上游类型,OpenFlare 支持三种不同的数据面流量路径:
### 1. 标准反代流量路径
```text
Browser
|
| HTTPS/HTTP request
v
OpenResty (WAF, TLS, Rate Limit, 可选源站错误页)
|
| reverse proxy (proxy_pass)
v
Origin Server (直连公网/局域网上游)
```
源站或网关返回配置列表内错误状态码时,可返回全局自定义/默认 HTML,且保持真实 HTTP 状态码;详见 [源站错误页设计](./origin-error-page.md)。
### 2. 内网穿透流量路径
适用于内网受限服务器上的源站服务接入:
```text
Browser
|
| HTTPS/HTTP request
v
OpenResty (Agent 宿主机, TLS/WAF)
|
| proxy_pass http://localhost:vhost_port (Host header preserved)
v
OpenFlareRelay (frps) <-- 与 Agent 同机部署,提供中继
|
| frp tunnel protocol (Host header routing)
v
OpenFlared (frpc) <-- 内网受限服务器
|
| HTTP/HTTPS forward
v
Internal Service (192.168.x.x)
```
### 3. Pages 静态托管流量路径
适用于预构建的单页应用(SPA)或静态网站托管:
```text
Browser
|
| HTTPS/HTTP request
v
OpenResty (Agent, TLS/WAF)
|
+---> [静态服务] root/try_files ---> Agent 本地 Pages 部署目录
|
+---> [API 反代] proxy_pass ---> 后端 API 服务 (如果启用了 API 代理)
```
---
## 组件职责
| 组件 | 职责 | 详细设计参考 |
| --------------- | ---------------------------------------------------------------------- | ------------ |
| **Server** | 管理端 UI/API、控制面状态持久化、配置编译渲染、发布版本控制、Pages 部署包存储、Cloudflare A 记录指向、访问日志入库与业务流量聚合、Uptime Kuma 监控同步与登录验证码防护 | [Agent 与发布模型](./agent-design.md) / [Cloudflare DNS 指向设计](./cloudflare-pointing.md) / [边缘可观测与业务流量统计](./observability-design.md) / [Uptime Kuma 监控同步设计](./kuma-design.md) / [登录验证码设计](./login-captcha.md) |
| **Agent** | 周期心跳与 WS 同步、静态资源包拉取与解压、OpenResty 配置写入/校验/重载与自愈;观测仅上报访问明细与主机/健康读数,不做业务预聚合 | [Agent 与发布模型](./agent-design.md) / [边缘可观测与业务流量统计](./observability-design.md) |
| **OpenResty** | 接收真实流量,执行 WAF 过滤、PoW 防护、Basic Auth 认证、静态/反代服务与可选源站错误页 | [WAF 设计](./waf-design.md) / [Pages 设计](./pages-design.md) / [源站错误页设计](./origin-error-page.md) |
| **Relay** | 部署于边缘节点,管理 `frps` 守护进程生命周期,接受心跳派发的穿透中继配置 | [内网穿透设计](./tunnel-design.md) |
| **OpenFlared** | 部署于内网,管理 `frpc` 进程组,向多个 Relay 建立反向隧道,上报连接状态 | [内网穿透设计](./tunnel-design.md) |
---
## 组件架构与分工
### 1. Server (控制面)
仓库根目录的 Go 后端(模块 `github.com/Rain-kl/Wavelet`)是 OpenFlare 控制面,基于 Wavelet 全栈脚手架构建:
* 提供管理端 REST API(`/api/v1/d/*`),通过 **Session Cookie** 鉴权,可选 `X-Access-Token` 访问令牌。
* 边缘节点协议走 `/api/v1/agent|relay|tunnel/*`,分别使用 `X-Agent-Token` / `X-Tunnel-Token` 鉴权。
* 包含配置编译器(Compiler),将数据库中的规则、证书与全局参数统一编译为不可变的配置快照及 OpenResty 物理配置文件文本。
* 统一接收 Pages 本地上传、Remote URL 与公开 GitHub Release 预构建产物,完成来源检查、受限下载、归档校验和不可变 deployment;manual 上传生成待显式激活的 candidate,持久来源 sync 才 create-or-load 并原子激活。Server 向 Agent 提供受控的 latest 下载接口;内部 scanner 负责 GitHub latest 的限量检查、租约恢复、可选自动发布与孤儿上传记录补偿,通用任务管理入口不能修改该排程。未来仓库源码构建由独立 Server build executor 扩展,Agent 不执行第三方拉取或构建命令。
* 提供可选的 Cloudflare DNS 指向控制面:以 ZoneDomain 为成员维护分组期望状态,通过 Asynq 将单条 A 记录幂等同步到当前生效节点 IPv4;节点 IP 变化只做 best-effort 入队,一期不执行自动故障切换。
* 后台集成 Uptime Kuma 监控同步服务,自动为可用站点维护 HTTP 探测任务。
* 启动入口为根目录 `main.go` + `internal/cmd/`(`api` / `worker` / `scheduler` / `all`);OpenFlare 业务在 `internal/apps/openflare/`,边缘协议处理在 `internal/apps/openflare/{agent,relay,flared}/`。
* *详细设计请参阅:[Agent 与发布模型设计](./agent-design.md) 以及 [Uptime Kuma 监控同步设计](./kuma-design.md)*
### 2. Agent (配置落地端)
`openflare-agent` 是运行在节点本地的守护进程:
* 启动后维持与控制面的周期性心跳,并通过可选的 WebSocket 接收实时的配置发布广播。
* 负责拉取最新激活版本的配置文件及证书,写入本地目录,并通过 `openresty -t` 执行安全校验后平滑重载 (`reload`)。
* 在本地处理 Pages 部署包的下载、SHA-256 校验与解压缩切换。
* *详细设计请参阅:[Agent 与发布模型设计](./agent-design.md)*
### 3. OpenResty (数据面)
接收访客流量并执行最终的业务落地:
* 流量入口,支持 HTTP/2、HTTP/3(QUIC)和 TLS 证书动态绑定。
* 嵌入 Lua 逻辑,在 `access_by_lua` 阶段高效过滤 WAF 规则、验证工作量证明 (PoW) 挑战,并在此之后执行连接数/速率限制及基础缓存(策略见 [边缘缓存策略设计](./edge-cache-design.md))。
* *详细设计请参阅:[WAF 设计文档](./waf-design.md) 与 [Pages 静态托管设计文档](./pages-design.md)*
### 4. Relay 与 OpenFlared (穿透组件)
扩展数据面反穿透能力:
* `openflare-relay` 守护本地 `frps`,接受 Server 的配置派发,自动更新中继端口。
* `openflared` 在内网守护一组 `frpc` 客户端进程,实现多中继就近建连与高可用容灾。
* *详细设计请参阅:[内网穿透隧道设计文档](./tunnel-design.md)*
---
## 数据与请求流概览
### 1. 配置发布与同步流
```text
管理端修改配置 -> 发布新版本 -> 生成全局唯一 Checksum 激活版本
|
+------------------+------------------+
| (WebSocket 广播或周期 Heartbeat) |
v v
[边缘节点 Agent] [内网 OpenFlared]
拉取最新 OpenResty 配置/证书 拉取最新 Tunnel 映射配置
增量拉取/解压 Pages 静态部署包 生成/重写 frpc.toml
Nginx 校验配置并平滑重载 (reload) 平滑重载或拉起 frpc 进程
上报应用状态 (Success / Error) 上报隧道连接状态与活跃指标
```
* *同步与自愈的精细时序及回滚模型详见:[Agent 与发布模型设计](./agent-design.md)*
### 2. 静态托管与 API 代理流
* 静态资源解压落地于 Agent 节点的 `projects/{project_id}/current` 下(按项目 latest 拉取,仅保留最新包),OpenResty 通过 `root`/`index`/`try_files` 在边缘直接提供静态资源服务。
* 当启用 API 代理时,OpenResty 自动根据站点配置的 `api_proxy_path`(如 `/api`)将 API 请求重写并转发(`proxy_pass`)给后端动态接口。
* 管理员操作和内部 scanner 都只生成受约束的 artifact candidate,并复用统一 inspect、`upload.Ingest` 与 deployment pipeline。manual 上传创建新的未激活 candidate;持久来源 sync/scanner 才 create-or-load 并原子激活。未来 repository build executor 也只能向同一 artifact pipeline 输出产物;Agent 始终只是 active deployment 消费者。
* *部署包校验、解压逃逸防御及 Nginx 规则渲染详见:[Pages 静态托管设计文档](./pages-design.md)*
### 3. WAF 安全过滤流
* WAF 引擎嵌入在 OpenResty 请求生命周期中。
* WAF 规则由控制面以可视化 DAG 编排,发布时编译为运行态图;OpenResty reload 后由每个 Worker 加载一次,后续请求只遍历内存对象。
* 全局规则固定前置,路由绑定规则按显式顺序执行;当前规则抵达“通过”后继续下一条,抵达“阻止”则立即返回该节点配置的拦截响应。
* IP 组成员独立热更新:协调 Worker 每 5 秒检查一次 checksum,仅在变化时加载完整快照,各 Worker 的请求路径始终读取本地内存对象。
* *IP 组来源与同步机制详见:[WAF 设计文档](./waf-design.md);图模型、执行语义与发布约束详见:[WAF 可编排规则设计](./waf-orchestration-design.md)。*
### 4. 边缘可观测与业务流量统计流
```text
OpenResty access.log(业务事实)
|
| Agent tail 增量明细(不 sum/count/uniq)
v
Server 入库 ClickHouse
|
+---> 全局聚合 --> 看板「已提供数据 / 请求 / UV」
+---> host∈Zone --> Zone「已提供数据」等(同一套语义)
+---> node_id 过滤 --> 节点业务量
主机 /proc 网卡与 CPU 等 --> Agent 读数快照 --> 宿主机资源趋势(与业务交付分开展示)
OpenResty 健康与连接数 --> 边缘健康(瞬时,不作 24h 业务总量)
```
* **原则**:Agent 只上报事实,Server 解释事实;业务流量唯一真相为访问日志。`openresty_tx` 与「已提供数据」不得双轨并存。
* *传输模型、示例与采集频率详见:[观测数据传输模型](./observability-transport-model.md);字段收敛与迁移详见:[边缘可观测与业务流量统计](./observability-design.md)*
### 5. Cloudflare DNS 指向流
```text
管理员配置连接/分组/成员 -> Server 持久化期望状态 -> Asynq 同步任务
|
v
Cloudflare Zone / DNS API
|
v
单条 A 记录 -> active_node IPv4
节点 IP 手动更新或 Agent 心跳变化 --------------------> 按节点 best-effort 入队
```
* Cloudflare 模块只管理其缓存或接管的唯一同名 A 记录,不把 Zone 核心扩展为权威 DNS 控制面;同名多 A 时停止同步并要求管理员先在 Cloudflare 清理。
* 分组备用节点与生效节点为后续故障切换预留,一期固定使用主节点,不根据心跳离线状态自动切换。
* *连接、模型、幂等同步与分期边界详见:[Cloudflare DNS 指向设计](./cloudflare-pointing.md)。*
---
## 核心对象
当前系统核心实体包括:
* **反代与配置**:`zones` (根域管理边界), `zone_domains` (明确域名与证书/路由关联), `proxy_routes` (路由策略), `origins` (源站), `config_versions` (配置版本), `tls_certificates` (证书). 详见 [Zone 与域名资源设计](./zone-design.md)。
* **Cloudflare DNS 指向**:`of_cf_connections` (全局连接), `of_cf_pointing_groups` (主/备/生效节点与默认橙云), `of_cf_pointing_members` (ZoneDomain 成员、记录缓存与同步状态). 详见 [Cloudflare DNS 指向设计](./cloudflare-pointing.md)。
* **Pages 静态托管**:`of_pages_projects` (Pages项目), `of_pages_project_sources` / `of_pages_project_source_runtime` (可变来源配置与运行态), `of_pages_deployments` (不可变部署), `of_pages_deployment_files` (部署文件清单).
* **节点与穿透**:`nodes` (节点), `tunnels` (隧道客户端), `node_system_profiles` (系统概况), `apply_logs` (应用日志).
* **WAF 与安全**:`waf_rule_groups` (WAF规则组), `waf_ip_groups` (WAF IP组), `waf_rule_group_bindings` (网站WAF绑定).
* **系统与账号**:`acme_accounts` (ACME账户), `dns_accounts` (DNS账户), `geoip_update_configs` (GeoIP更新配置).
---
## 关键设计决策
| 决策 | 原因 |
| ------------------------------ | --------------------------------------------------------------------------- |
| 完整配置版本,而不是在线 patch | 让预览、激活、历史和回滚有稳定边界,保证节点状态一致 |
| Agent 主动拉取 | Server 不需要 SSH 权限,降低安全风险;支持 HTTP 与 WebSocket 双协议灵活切换 |
| 全局单激活版本 | 降低控制面复杂度,保证所有节点默认一致;提供一键秒级回滚的稳定机制 |
| Zone 域名与路由策略分离 | Zone 提供根域入口与域名边界;路由仍可复用同一套站点级策略并按域名绑定证书 |
| Cloudflare 指向独立于 Zone 核心 | ZoneDomain 只提供明确 FQDN;Cloudflare 模块以库表期望状态驱动单 A 记录,不扩大 Zone 为通用 DNS 控制面 |
| 内网穿透基于 frp 整合 | 复用成熟隧道协议,避免自研隧道引起稳定性风险;其 Vhost 机制天然适配反代路由 |
| 运行时配置与控制库解耦 | WAF 规则发布时编译并随 OpenResty reload 加载;动态 IP 组通过 checksum 驱动的内存快照独立刷新 |
| 业务流量以访问日志为唯一真相 | Agent 禁止业务预聚合;看板与 Zone 共用 Server 侧聚合,避免 openresty_tx 与 bytes_sent 双轨 |
| 业务交付 / 边缘健康 / 主机资源分层 | 已提供数据≠宿主机网卡出站≠OpenResty 连接数,UI 与 API 分名分区 |
| Pages artifact 与仓库构建分离 | 现有来源只导入预构建产物;未来 checkout/build 由 Server 隔离 executor 完成并复用 artifact pipeline,Agent 不执行第三方构建 |
---
## 贡献者阅读建议
修改系统架构或开发新功能前,请按以下顺序阅读:
1. **[产品边界](./index.md)**:了解 OpenFlare 核心定位与不允许逾越的设计边界。
2. **[Agent 与发布模型](./agent-design.md)**:理解版本快照同步及失败回滚的安全兜底逻辑。
3. **细分领域设计**:
* Zone 与域名相关开发:阅读 [Zone 与域名资源设计](./zone-design.md)。
* Cloudflare DNS 指向开发:阅读 [Cloudflare DNS 指向设计](./cloudflare-pointing.md)。
* 穿透相关开发:阅读 [内网穿透隧道设计](./tunnel-design.md)。
* WAF 相关开发:阅读 [WAF 设计](./waf-design.md) 与 [WAF 可编排规则设计](./waf-orchestration-design.md)。
* Pages 托管开发:阅读 [Pages 静态托管设计](./pages-design.md)。
* 监控同步开发:阅读 [Uptime Kuma 监控同步设计](./kuma-design.md)。
* 看板/访问日志/节点指标开发:阅读 [观测数据传输模型](./observability-transport-model.md) 与 [边缘可观测与业务流量统计](./observability-design.md)。
4. **[仓库结构](./index.md#仓库结构)**:明确各个物理目录分层职责,避免堆砌和重复开发。
-233
View File
@@ -1,233 +0,0 @@
# Cloudflare DNS 指向设计
## 目标
通过 Cloudflare API 将 OpenFlare 中的 **ZoneDomain(明确 FQDN)** 快速指向边缘节点 IP,替代在 CF 控制台手工改 A 记录。用户以 **指向分组** 组织域名:每组配置主节点与备用节点、默认橙云策略;成员可单独覆盖橙云。系统以库表为期望状态,幂等同步远端 DNS。
本模块是 **可选对接能力**,不把 Zone 本身变成权威 DNS 控制面。Zone 仍只负责根域边界、域名、证书与反代关联;DNS A 记录的创建/更新/删除由本模块驱动 Cloudflare。
## 范围与分期
### 一期(本设计落地范围)
* 侧边栏 **Cloudflare** 入口与 Token 就绪门禁
* 连接配置:从现有 DNS 账号导入 **或** 模块内独立录入(混合来源),加密存储
* 指向分组 CRUD:主节点、备用节点(预留)、分组默认橙云
* 成员管理:以 `zone_domain_id` 为粒度加入/移出;成员级橙云
* 同步:将每个成员写成 Cloudflare 上 **单条 A 记录** → 当前生效节点 IPv4
* 触发:手动同步、加入成员、改节点/橙云、节点 IP 变更入队
* 异步任务批量同步;成员同步状态与可读错误
### 二期
* Agent 心跳离线判定主节点故障 → `active_node` 切至备用 → 整组自动同步
* 可选自动回切、故障通知推送
### 明确不做(更远或永久)
* 多 Cloudflare 账号并行(全局一份连接配置)
* AAAA / 多 A 负载 / CNAME 到节点主机名
* 管理 MX/TXT/Page Rules 等非本模块 A 记录
* 非 Cloudflare DNS 厂商
* 将 DNS 记录管理并入 Zone 核心模型
## 与现有能力的关系
| 现有能力 | 关系 |
| --- | --- |
| `of_zones` / `of_zone_domains` | 提供可指向的 FQDN 清单;本模块只引用 `zone_domain_id` |
| `of_nodes.ip` | A 记录 `content` 来源;建议限制 edge 节点且 IP 为合法 IPv4 |
| `of_dns_accounts` + `sealSensitive` | ACME DNS-01 已支持 Cloudflare Token;本模块可 **导入** 同一账号,也可独立存 Token |
| lego Cloudflare provider | **仅** TXT/DNS-01;本模块自建 CF HTTP 客户端做 Zone/DNS Record API |
## 核心模型
```mermaid
erDiagram
CF_CONNECTIONS ||--o| DNS_ACCOUNTS : optional_import
CF_POINTING_GROUPS ||--o{ CF_POINTING_MEMBERS : contains
ZONE_DOMAINS ||--o| CF_POINTING_MEMBERS : pointed_as
NODES ||--o{ CF_POINTING_GROUPS : primary
NODES ||--o{ CF_POINTING_GROUPS : backup
NODES ||--o{ CF_POINTING_GROUPS : active
CF_CONNECTIONS {
uint id PK
string source
uint dns_account_id
string authorization
string status
time verified_at
}
CF_POINTING_GROUPS {
uint id PK
string name
uint primary_node_id
uint backup_node_id
uint active_node_id
bool default_proxied
bool enabled
}
CF_POINTING_MEMBERS {
uint id PK
uint group_id
uint zone_domain_id UK
bool proxied
string cf_zone_id
string cf_record_id
string desired_ip
string sync_status
string last_error
time synced_at
}
```
### `of_cf_connections`(全局一份有效连接)
| 字段 | 说明 |
| --- | --- |
| `source` | `dns_account` \| `standalone` |
| `dns_account_id` | `source=dns_account` 时关联 `of_dns_accounts`(type=cloudflare) |
| `authorization` | `source=standalone` 时加密存储,载荷形状 `{"api_token":"..."}`,与 DNS 账号一致;API **永不回传** |
| `status` / `verified_at` | 连通校验结果与时间 |
**Token 解析:** `dns_account` → 解密关联账号;`standalone` → 解密本行。关联账号删除或校验失败 → 模块未就绪,禁止同步。
**建议权限:** Cloudflare API Token 含 `Zone:Read`、`DNS:Edit`。
### `of_cf_pointing_groups`
| 字段 | 说明 |
| --- | --- |
| `name` | 展示名 |
| `primary_node_id` | 主节点 |
| `backup_node_id` | 备用(可空;一期仅存储) |
| `active_node_id` | 当前生效节点;一期等于 primary;二期 failover 改写 |
| `default_proxied` | 分组默认橙云;**仅影响新加入成员** |
| `enabled` | 是否参与同步 |
约束:主备不得为同一节点;选作生效目标的节点须有合法 IPv4。
### `of_cf_pointing_members`
| 字段 | 说明 |
| --- | --- |
| `group_id` | 所属分组 |
| `zone_domain_id` | 全局唯一:一域名最多在一个分组 |
| `proxied` | 成员橙云(运行时唯一依据) |
| `cf_zone_id` / `cf_record_id` | Cloudflare 缓存,用于幂等更新 |
| `desired_ip` / `sync_status` / `last_error` / `synced_at` | 期望与同步状态 |
`sync_status`:`pending` \| `syncing` \| `ok` \| `error`。
无物理外键;`zone_domain_id` 唯一索引;`group_id` 等查询索引。
## 橙云优先级
1. **成员 `proxied`**:同步时写入 CF 的唯一依据。
2. **分组 `default_proxied`**:成员 **加入时** 拷贝到 `proxied`。
3. 之后修改分组默认值 **不回写** 已有成员。
## 同步语义
### 期望状态
OpenFlare 库表为 Source of Truth。每个成员期望:
| 项 | 值 |
| --- | --- |
| type | `A` |
| name | ZoneDomain 的 FQDN |
| content | 分组 `active_node` 的 IPv4 |
| proxied | 成员 `proxied` |
| ttl | 橙云开启时由 CF 强制 Auto;关闭时使用统一默认(如 300) |
一期不写 AAAA。节点 IP 非合法 IPv4 → 该成员 `error`。
### 触发
| 触发 | 行为 |
| --- | --- |
| 手动同步(全部 / 组 / 成员) | reconcile |
| 成员加入 | 初始化 `proxied` 后入队同步 |
| 成员移出 / 删组 | 默认删除本模块管理的远端 A(可配置保留) |
| 改主节点 / active / 成员 proxied | 对应范围重新同步 |
| 节点 IP 变更(心跳或手动) | `active_node_id` 指向该节点的成员入队 |
| Token 未就绪 | 拒绝同步 |
一期不做定时全量对账。
### Reconcile(单成员,幂等)
1. 用 FQDN 注册根域解析 CF Zone,缓存 `cf_zone_id`。
2. 有 `cf_record_id` 则优先 Update;失效则按 `name+type=A` 列举。
3. **0 条** → Create;**恰好 1 条** → 接管并 Update;**多条** → 失败,提示用户在 CF 清理。
4. 写回 `cf_record_id`、`desired_ip`、`sync_status`、`synced_at` / `last_error`。
5. 限流时有限次退避重试。
**所有权:** 只管理本模块缓存或「唯一同名 A」接管的记录;不清空 Zone、不改其它类型记录。用户在 CF 控制台改动后,下次同步以 OpenFlare 期望覆盖。
### 执行载体
* 单条:可在请求路径同步。
* 整组 / 按节点批量:Asynq 任务(`cloudflare:sync_member` / `sync_group` / `sync_by_node`),`bootstrap` 注册。
* 同成员互斥,防止并发双写。
* 节点 IP 变更路径 **best-effort** 投递任务,不阻断心跳。
## API(管理端)
前缀:`/api/v1/d/cloudflare`,Session 管理员鉴权。包:`internal/apps/openflare/cloudflare/`;路由:`internal/router/v1/openflare/register_cloudflare.go`。
| 资源 | 方法与路径 |
| --- | --- |
| 连接 | `GET/PUT /connection`,`POST /connection/verify`,`POST /connection/clear` |
| 总览 | `GET /overview` |
| 分组 | `GET/POST /groups`,`GET /groups/:id`,`POST /groups/:id/update|delete|sync` |
| 成员 | `GET/POST /groups/:id/members`,`POST .../members/:memberId/update|remove|sync` |
| 可选域名 | `GET /domains/available` |
* 成功 `response.OK`;失败 `response.Abort*`;**永不**在 JSON 中返回 Token。
* Handler 与 `logics.go` 分离;CF 客户端可 mock 接口。
* 变更后维护 Swagger(`make swagger`)。
## 前端
* 导航:`frontend/lib/navigation/openflare-nav.ts` 增加 **Cloudflare** → `/cloudflare`(建议放在网站管理组、DNS 账号附近)。
* 路由:
* `/cloudflare`:总览;未就绪则引导配置
* `/cloudflare/settings`:混合 Token 配置与测试连接
* `/cloudflare/groups`、`/cloudflare/groups/[id]`:列表与详情(成员、橙云、同步)
* 服务:`frontend/lib/services/openflare/` 下独立 service,继承 `BaseService`。
* 页面遵循现有标题栏与组件拆分规范;危险操作二次确认。
* 必须可见的文案:同步覆盖本模块管理的 A;多条同名 A 需手动清理;移出默认删远端记录;一期无自动故障切换。
## 错误与安全
* 用户可见文案为模块内常量;内部错误打 `pkg/logger`。
* 典型:未配置 Token、Token 无效、节点无 IP、CF 无 Zone、同名多 A、限流。
* Token 仅服务端解密使用;响应与日志禁止明文 Token。
## 数据迁移与测试
* goose 双方言(PG/SQLite)新建三张表;默认值与 Go 零值一致。
* 单测:Token 解析、reconcile 0/1/多条、橙云只初始化新成员、移出删远端(mock)、节点 IP 变更入队。
* 禁止单测打真实 Cloudflare。
## 文档与边界同步
* 更新 [Zone 与域名资源设计](./zone-design.md):Zone 仍不内建权威 DNS;可选本模块负责 CF A 指向。
* 更新 [系统架构](./architecture.md) 核心对象与阅读建议。
* 更新 [产品边界](./index.md) 能力表。
* 实现完成后写入 `docs/changelog/index.md` 的 `[Unreleased]`(纯设计文档变更不写 changelog)。
## 关键决策摘要
| 决策 | 结论 |
| --- | --- |
| 模块形态 | 独立 Cloudflare 指向模块,非 Zone 内嵌字段 |
| Token | 混合:DNS 账号导入或独立加密 |
| 域名粒度 | ZoneDomain(FQDN) |
| 记录形态 | 单 A → active 节点 IPv4 |
| 故障切换 | 二期;心跳离线;一期只存 backup/active |
| 橙云 | 成员级生效;分组默认仅初始化 |
| SoT | 库表期望状态驱动 CF |
-290
View File
@@ -1,290 +0,0 @@
# 边缘缓存策略设计
你会学到:OpenFlare 边缘 `proxy_cache` 如何在「该缓存」与「不该缓存」之间对齐 Cloudflare 默认闭环:请求 eligible(扩展名/策略)× 响应可共享缓存(源站 `Cache-Control` / `Expires` / `Set-Cookie`),以及与过往过严请求旁路的差异。
本设计是 [系统架构](./architecture.md) 中「基础缓存」的产品化专章;访问日志中的缓存结果见 [观测数据模型 §3.5.1](./observability-data-model.md)。
---
## 1. 目标与非目标
### 1.1 目标
* **开箱接近 CF 默认**:路由开启缓存后,**默认只缓存静态扩展名**,不默认缓存 HTML;**不因请求会话 Cookie / Authorization / 客户端 Cache-Control 一律 BYPASS**。
* **该缓存的能命中**:带登录 Cookie 的用户访问 `/_app/**/*.js` 等静态资源可出现 `MISS` → `HIT`。
* **不该缓存的仍挡住**:策略不 eligible(等价 CF `DYNAMIC`);源站 `private` / `no-store`;响应带 **`Set-Cookie` 不入库**(对齐 CF OCC 默认);`all` 为高级选项并文档警示。
* **无源站 freshness 时有默认 Edge TTL**:对齐 CF 按状态码的默认 TTL(见 §3.5)。
* **可观测一致**:继续依赖 `$upstream_cache_status` → `cache_status` 明细三态。
* **兼容存量**:旧路由 `cache_policy=url` 映射为 `all`;策略枚举与迁移规则保持 [§5](#5-兼容与迁移)。
### 1.2 非目标(后续迭代)
* Cache Rules 表达式引擎
* 忽略源站 `Cache-Control` 的强制 Edge TTL(CF Cache Rules「Ignore cache-control」)
* Purge(按 URL/前缀/全站)
* 浏览器 TTL 改写、客户端 `CF-Cache-Status` 响应头
* 完整 RFC 条件:`Authorization` 仅当响应含 `public`/`s-maxage`/`must-revalidate` 才缓存(需 Lua;本期删除请求侧一律旁路,依赖策略 + 源站头)
* HEAD 转 GET 再缓存
* 命中率看板
---
## 2. Cloudflare 判定闭环(对齐基准)
CF 默认是 **两段决策**,**不是**「请求带 Cookie 就不缓存」。
### 2.1 阶段 A — 请求时 Eligible
| 条件 | CF 结果 |
| --- | --- |
| 非 GET | 默认不缓存 |
| 扩展名不在默认可缓存表,且无 Rules 强制 Eligible | **`DYNAMIC`**(不查缓存) |
| 扩展名在默认表,或 Rules Eligible | 继续阶段 B |
| **请求 Cookie** | **默认不影响** |
| Cache Rules Bypass | `DYNAMIC` |
CF 默认可缓存扩展名按 **扩展名** 而非 MIME;**默认不缓存 HTML / JSON**。
### 2.2 阶段 B — 响应是否可入库(OCC on,Free/Pro/Biz 默认)
| 条件 | 结果 |
| --- | --- |
| `Cache-Control: no-store` / `private` | 不入库 |
| `public` + `max-age>0`,或未来 `Expires` | 可缓存 |
| 无 Cache-Control / Expires | 按状态码 **默认 Edge TTL** 仍可缓存(如 200 → 120m) |
| 响应 **`Set-Cookie`**(默认缓存级别 + OCC) | **不入库**,状态倾向 **BYPASS** |
| 请求 `Authorization` | 仅当响应另有 `public` / `s-maxage` / `must-revalidate` 才可缓存(完整条件本期用 Nginx 简化,见 §3.4) |
### 2.3 状态语义(对照观测)
| CF | 含义 | OpenFlare `cache_status` |
| --- | --- | --- |
| HIT / STALE / UPDATING / REVALIDATED | 命中类 | 同名或等价 |
| MISS / EXPIRED | 回源取内容 | 同名 |
| BYPASS | 请求时 eligible,响应不可缓存 | `BYPASS` → UI「未缓存」 |
| DYNAMIC | 请求时不 eligible | 策略 skip 多为 `BYPASS` 或空 → UI「未缓存」 |
---
## 3. 产品语义
### 3.1 双层开关(不变)
* **全局** `openresty_cache_enabled`:生成 `proxy_cache_path` 等;关闭则路由级缓存指令不生效。
* **路由** `cache_enabled`:是否在该站点 `location` 启用 `proxy_cache`。
两者均开启时才进入缓存逻辑。
### 3.2 策略枚举
| `cache_policy` | 含义 | 新建默认 | 旧值兼容 |
| --- | --- | --- | --- |
| **`static`** | 仅 URI 匹配**标准静态扩展名**才 eligible | **是** | — |
| **`all`** | 过方法旁路后,不限制路径/扩展名(高级,风险类似 CF Cache Everything) | 否 | 存量 `url` → `all` |
| **`suffix`** | 自定义扩展名列表(`cache_rules`) | 否 | 保持 |
| **`path_prefix`** | 自定义路径前缀 | 否 | 保持 |
| **`path_exact`** | 自定义精确路径 | 否 | 保持 |
渲染层:历史值 `url` 按 `all` 处理;API/UI 只暴露上表枚举。
### 3.3 标准静态扩展名(内置)
对齐 CF 默认「不缓存 HTML/JSON」;保留现代前端常用增强项:
```text
css js mjs map
ico cur gif jpg jpeg png webp avif svg svgz
ttf otf woff woff2 eot
mp3 mp4 webm ogg flac
wasm pdf
zip 7z gz tar
```
* **不含** `html` / `htm` / **`json`**(对齐 CF 默认不缓存 JSON)。
* **含** `map` / `mjs` / `wasm`(有意增强,提高 sourcemap / ES module / WASM 命中)。
* 匹配:`$uri` 扩展名,大小写不敏感:
`if ($uri !~* \.(?:css|js|…)$) { set $openflare_skip_cache 1; }`
### 3.4 请求侧旁路(对齐 CF 后)
仅保留:
1. `$request_method != GET`(含 HEAD,与现网一致;不做 CF 的 HEAD→GET)
**删除(过往过严,导致缓存率过低):**
* 会话类 Cookie 正则
* `$http_authorization != ""`
* 请求 `$http_cache_control` 匹配 `no-cache|no-store|private`
**安全如何仍成立:**
| 威胁 | 闸门 |
| --- | --- |
| 误缓存 HTML/API | 默认 `static` 扩展名(不含 html/json) |
| 个性化内容 | 源站 `private` / `no-store`(Nginx 尊重) |
| 响应写会话 | **`Set-Cookie` → 不入库**(§3.6) |
| `all` 过宽 | UI/文档警告:需源站正确 Cache-Control |
| 带 Bearer 的 API | 依赖策略(勿对 API 用 `all`)+ 源站头;完整 Auth 条件缓存为后续 |
### 3.5 默认 Edge TTL(无源站 freshness 时)
对齐 CF 无 `Cache-Control`/`Expires` 时的状态码默认 TTL,在启用缓存的 location 输出:
| 状态码 | TTL |
| --- | --- |
| 200, 206, 301 | 120m |
| 302, 303 | 20m |
| 404, 410 | 3m |
```nginx
proxy_cache_valid 200 206 301 120m;
proxy_cache_valid 302 303 20m;
proxy_cache_valid 404 410 3m;
```
* 源站提供合法 `Cache-Control` / `Expires` 时,仍以源站 freshness 为准(不 `proxy_ignore_headers`)。
* **不做**强制忽略源站头的 Edge TTL 覆盖。
### 3.6 响应侧:Set-Cookie 不入库
对齐 CF OCC 默认:eligible 请求若源站返回 **`Set-Cookie`**,**不写入** `proxy_cache`(可读路径仍可能 MISS/BYPASS 语义)。
```nginx
proxy_no_cache $openflare_skip_cache $upstream_http_set_cookie;
```
(`proxy_no_cache` 多参数:任一非空且非 `"0"` 则不写入。)
`proxy_cache_bypass` 仍仅绑定 `$openflare_skip_cache`(请求侧 skip);响应侧只影响**写入**,与 CF「eligible 但响应不可缓存」一致。
### 3.7 与源站头的关系
* **是否 eligible**:策略 + 方法旁路。
* **是否入库 / 存多久**:源站 `Cache-Control` / `Expires` + 默认 `proxy_cache_valid` + Set-Cookie 闸门 + 全局 `inactive`。
---
## 4. 渲染与数据流
```text
全局 cache_enabled?
│ no → 不生成 proxy_cache_*
▼ yes
路由 cache_enabled?
│ no → location 无 proxy_cache
▼ yes
set $openflare_skip_cache 0
→ 非 GET → 置 1
→ 策略 if(static/all/suffix/…)→ 可置 1
proxy_cache openflare_cache
proxy_cache_methods GET
proxy_cache_bypass $openflare_skip_cache
proxy_no_cache $openflare_skip_cache $upstream_http_set_cookie
proxy_cache_valid …
→
access.log cache_status=$upstream_cache_status
```
### 4.1 策略 → Nginx 条件
| 策略 | 额外条件 |
| --- | --- |
| `static` | `$uri` 不匹配内置扩展名表 → skip |
| `all` | 无额外路径条件 |
| `suffix` | 不匹配 `cache_rules` 扩展名 → skip |
| `path_prefix` / `path_exact` | 同现实现 |
### 4.2 涉及代码面
| 区域 | 路径 |
| --- | --- |
| 渲染 | `pkg/render/openresty/render.go`(旁路、Set-Cookie、`proxy_cache_valid`、扩展名常量) |
| 校验 | `internal/apps/openflare/proxy_route/helpers.go` |
| 模型/默认 | 创建路由默认 `cache_policy=static`;读写时 `url`→`all` |
| 快照 | `config_version` 快照规范化 |
| UI | `proxy-routes/detail/components/cache-section.tsx` |
| 测试 | `pkg/render/openresty/render_test.go` 等 |
---
## 5. 兼容与迁移
| 数据 | 处理 |
| --- | --- |
| DB 中 `cache_policy=''` 或 `url`(且已启用缓存) | 读 / 快照 / 渲染 → **`all`** |
| API 写入 enabled 且 policy 为空 | 规范为 **`all`**;UI 新建开启时**显式提交** `static` |
| 新建路由 | 开启缓存时默认 **`static`** |
| 旁路行为变更 | **破坏性相对旧实现**:带 Cookie/Auth 的流量从「未缓存」变为可 HIT;需 **重新发布节点配置** 后生效 |
| 默认扩展名 | 自表中 **移除 `json`**;已依赖缓存 `*.json` 的站点可改 `suffix` 自定义或 `all` |
**发布说明:** 说明本次对齐 CF 默认模型;命中率预期上升;`all` 与错误源站头风险需运维自查。
---
## 6. UI 文案要点(缓存 Tab)
* 开启缓存后默认:**标准静态资源**(摘要扩展名,**不含 HTML/JSON**;含 map/mjs 等)。
* 选项:标准静态 / 所有可缓存 GET(高级)/ 自定义后缀 / 路径前缀 / 精确路径。
* 说明对齐 CF:
* 登录 Cookie **不会**单独跳过缓存;
* 源站 `private` / `no-store` / 响应 **`Set-Cookie`** 不会写入边缘缓存;
* 无源站缓存头时使用默认 Edge TTL。
* **高级 `all`**:警告「类似 Cache Everything,个性化页面必须由源站声明 private/no-store」。
* 全局 Performance 缓存总开关须开启。
---
## 7. 验证要点
* 渲染:无 Cookie/Auth/请求 Cache-Control 旁路;含 `proxy_cache_valid` 三行;`proxy_no_cache` 含 `$upstream_http_set_cookie`。
* 单测:内置表含 `css`/`js`/`map`/`mjs`,**不含** `html`/`json`。
* 手动:
* 带 session Cookie 请求 `/a.js` → 第二次 `HIT`;
* `/index.html` + `static` → 未缓存;
* 源站对 eligible 路径返回 `Set-Cookie` → 不入库(持续 MISS/不 HIT);
* 源站 `Cache-Control: private` → 不入库。
* 观测:access log 三态与原始 `cache_status` 一致。
* 生效:配置版本发布并节点应用后验证。
---
## 8. 决策矩阵(防漏判)
| 场景 | CF | OpenFlare(本设计) |
| --- | --- | --- |
| GET 静态 + session Cookie + 源站 public max-age | HIT | HIT |
| GET HTML + static 策略 | DYNAMIC | 策略 skip → 未缓存 |
| GET + all + 源站 private | 不入库 | 不入库 |
| GET 静态 + 响应 Set-Cookie | BYPASS(OCC) | 不入库 |
| GET + Authorization + 静态 public | 条件缓存 | 可缓存(简化;依赖源站勿对敏感 API 乱标 public) |
| GET + 无 CC 的 200 静态 | 默认 120m | `proxy_cache_valid` 120m |
| DevTools Disable cache(请求 no-cache) | 边缘默认可仍 HIT | 边缘默认可仍 HIT |
| POST | 不缓存 | 非 GET skip |
---
## 9. 后续路线图
1. Auth 完整 RFC/CF 条件缓存(Lua)
2. 强制 Edge TTL / `proxy_ignore_headers`(Cache Rules 级)
3. Purge API
4. Cache Rules(有序规则 + 动作)
5. 全局默认可缓存扩展名可配置;可选对齐 CF 更长扩展名表
6. HEAD→GET
---
## 10. 决策记录
| 决策 | 选择 | 原因 |
| --- | --- | --- |
| 请求 Cookie 旁路 | **删除** | 对齐 CF;恢复登录用户静态命中率 |
| 请求 Authorization / Cache-Control 旁路 | **删除** | 对齐 CF 请求 eligible 模型;响应闸门兜底 |
| Set-Cookie | **proxy_no_cache 绑定** | 对齐 CF OCC「响应 Set-Cookie 不入库」 |
| 默认 Edge TTL | **按状态码 proxy_cache_valid** | 对齐 CF 无头时默认 TTL,避免「永不入库」 |
| 默认表去掉 json | **是** | 对齐 CF 默认不缓存 JSON |
| 保留 map/mjs/wasm | **是** | 现代前端有用命中,有意增强 |
| 默认可缓存范围 | 开启缓存默认 `static` | 对标 CF,降低 HTML/API 误缓存 |
| 旧 `url` | 映射 `all` | 存量行为不收窄 |
| 完整 Auth 条件 / Purge / Rules | 后续 | 先闭合默认闭环再扩展 |
-197
View File
@@ -1,197 +0,0 @@
# 产品边界
你会学到:OpenFlare 是什么、当前稳定能力,以及开发时应遵守的核心产品边界与仓库结构目录分工。
OpenFlare 是一套自托管的 OpenResty 控制面,面向单团队或单组织内部运维场景。
---
## 项目定位
OpenFlare 适合需要统一管理多台 OpenResty 代理节点的团队,具备以下定位:
* **控制与落地分离**:Server 控制面不直接 SSH 到代理节点,而是通过 Agent 主动拉取版本并应用。
* **不可变配置发布**:采用完整的配置版本进行预览、发布、激活和一键回滚。
* **一体化网关托管**:在同一个控制面内集成网站反代、TLS 证书自动续期申请、WAF 防护拦截、内网穿透(Tunnel)以及 Pages 静态网站托管。
**非本产品定位**:多租户云平台、Kubernetes Ingress Controller、服务网格或通用日志平台。
---
## 当前能力
| 能力 | 说明 | 详细设计/使用指南 |
| --- | --- | --- |
| **反代配置管理** | 以网站规则(Proxy Route)为聚合边界,支持多域名与多上游负载均衡 | [新建反代配置](../guide/proxy-config.md) |
| **源站错误页** | 全局可配置:源站/网关匹配状态码时返回 OpenFlare 默认或自定义 HTML,HTTP 状态码保持原值 | [源站错误页设计](./origin-error-page.md) |
| **边缘缓存** | 单节点 OpenResty `proxy_cache`;默认 static 扩展名 + 源站头/Set-Cookie 闸门 + 默认 Edge TTL(对标 CF 默认模型) | [边缘缓存策略设计](./edge-cache-design.md) |
| **Zone 与域名管理** | 以可注册根域为管理入口,聚合明确域名、域名证书与反代路由 | [Zone 与域名资源设计](./zone-design.md) |
| **Cloudflare DNS 指向** | 以 ZoneDomain 为粒度,将单条 Cloudflare A 记录幂等指向边缘节点 IPv4;支持连接配置、分组、成员橙云与异步同步,一期不含自动故障切换 | [Cloudflare DNS 指向设计](./cloudflare-pointing.md) |
| **配置版本控制** | 支持全局单一激活版本的预览、发布、不可变快照历史与秒级一键回滚 | [Agent 与发布模型](./agent-design.md) |
| **WAF 安全防护** | 支持可视化 DAG 编排规则、手动/自动/订阅型 IP 组、GeoIP 匹配与 PoW CC 防护 | [WAF 设计](./waf-design.md) / [WAF 可编排规则设计](./waf-orchestration-design.md) / [WAF 使用指南](../guide/waf-usage.md) |
| **内网穿透** | 通过中继节点(Relay)与内网客户端(OpenFlared),反向穿透暴露内网 Web 服务 | [内网穿透设计](./tunnel-design.md) / [穿透使用指南](../guide/tunnel-usage.md) |
| **Pages 静态托管** | 支持上传或从 Remote URL、公开 GitHub Release 同步预构建产物;GitHub latest 可定时检查并可选自动发布。不可变部署由边缘节点拉取并由 OpenResty 本地服务,支持回滚、API 反代与 SPA Fallback | [Pages 静态托管设计](./pages-design.md) / [Pages 使用指南](../guide/pages-usage.md) |
| **TLS 证书自动续期** | 将证书显式绑定到 Zone 域名,并通过 ACME 协议向 Let's Encrypt 申请/续期证书 | [Zone 与域名资源设计](./zone-design.md) |
| **多节点监控与观测** | 访问日志为业务流量唯一真相;Agent 只上报明细与主机读数,Server 统一聚合;与 Zone/看板对账 | [观测数据传输模型](./observability-transport-model.md) / [边缘可观测与业务流量统计](./observability-design.md) / [上报协议与表结构](./observability-data-model.md) / [系统架构](./architecture.md) |
---
## 核心产品边界与约束
在开发与贡献代码时,**必须严格遵守**以下业务边界与技术约束,禁止为了临时需求而绕过限制:
### 1. 网站配置与上游约束
* **单站点域名共享策略**:一条路由规则对应一个网站,该站点下的多域名共享限流、缓存与反代上游等配置,不支持在同一规则内为不同域名做差异化服务配置。
* **上游类型互斥**:上游必须是直连地址(`direct`)、内网穿透(`tunnel`)或 Pages 静态托管(`pages`)三者之一,不允许在同一规则中混用。
* **直连类型限制**:直连上游可以是纯 `http://` 或 `https://` 的单个或多个地址(多地址仅支持纯 `scheme://host[:port]`),不支持非 HTTP 协议(如 TCP/UDP)上游。
### 2. WAF 安全边界
* **白名单优先原则**:白名单拥有绝对匹配权。若未命中白名单规则,才依次触发全局和自定义黑名单过滤。
* **GeoIP 弱依赖性**:地域准入解析完全依赖节点本地 MaxMind 库。当 GeoIP 异常或解析失败时,系统必须自动忽略地域规则,**绝对不能**破坏 IP 组过滤和反代主链路的可用性。
* **运行时数据解耦**:OpenResty 拦截时仅读取 Agent 同步至本地的 JSON,不与 Server 数据库通信。IP 组成员同步与版本发布解耦,通过 Checksum 差分拉取以实现零重载平滑生效。
### 3. 内网穿透边界
* **仅限 HTTP 流量**:穿透组件仅支持 HTTP/HTTPS 协议(底层依靠 frp 虚拟主机 Vhost 机制实现单端口域名路由复用),暂不支持单独的 TCP/UDP 端口分配。
* **中继配置动态化控制**:中继节点(Relay)在连接至 Server 后,可通过心跳周期性动态拉取并同步全局系统配置(例如是否开启内嵌 FRPS Web UI 及其监听端口),但不直接纳入控制面的不可变配置版本发布体系。
* **Tunnel 与 Node 体系隔离**:Tunnel 客户端在内网发起出向建连,与控制面托管的边缘 Node(公网节点)是独立的实体,使用专属的 `tunnel_token` 进行鉴权。
### 4. Pages 静态托管边界
* **预构建产物来源**:项目可保持手动上传,或配置一个 Remote URL / 公开 GitHub Release asset 来源。Remote 与固定 tag 只支持手动操作;只有 GitHub latest 进入定时检查并可选择自动更新。来源可切换,但不可变 deployment 与当前生产版本不会随 source 编辑或删除而丢失。
* **归档与资源上限**:支持 `zip`、`tar.gz` / `tgz`、`tar.xz` / `txz`、`tar.bz2` / `tbz2`、`tar`、`7z`。压缩包上限由 `pages_max_package_size_mb` 控制(默认 100 MiB,范围 1~2048);展开后的单文件和总量上限为包上限的 4 倍且最低 100 MiB,最多 1,000 个常规文件。Server 与 Agent 都校验实际字节,并拒绝路径逃逸、软/硬链接与特殊文件。
* **构建与运行时边界**:当前不从外部 Git 仓库拉取源码或执行构建,也不提供边缘 Serverless、动态 SSR 或二级预览域名。未来仓库集成必须使用独立 `git_repository` Provider 与 Server 侧隔离 build executor,只向统一 artifact 管线输出受限产物;Agent 不接收仓库凭据、外部 URL 或 clone/install/build 命令。
### 5. 系统与版本边界
* **全局单一激活版本**:所有节点拉取并消费同一份全局激活配置。不进行按节点分组的差异化配置发布。
* **单租户架构**:OpenFlare 仅供单团队在受信任的内部网络部署使用。采用单租户设计,不支持细粒度的多用户角色或多租户资源隔离。
* **外部基础设施依赖性**:Server 虽支持 SQLite 作为本地轻量关系数据库,但**系统必须强制依赖外部 Redis(或 Valkey)及 ClickHouse 实例**。Redis 用于处理分布式协调、后台异步队列(Asynq 框架)及系统级全局缓存;ClickHouse 用于接收海量节点访问日志与基础观测的异步 Flush。系统不支持完全脱离这两个组件运行。
---
## 仓库结构
OpenFlare 已收敛为**单 monorepo**(Go 模块 `github.com/Rain-kl/Wavelet`)。控制面 Server 与边缘组件(Agent、Relay、OpenFlared)共享同一仓库,业务代码按 Wavelet `internal/apps/` 领域模块组织。
在贡献代码时,请严格遵守以下物理分层与目录分工:
| 路径 | 职责 |
| --- | --- |
| `main.go` | Server 唯一入口,委派给 `internal/cmd/` |
| `cmd/agent`、`cmd/relay`、`cmd/flared` | 边缘组件 CLI 入口(**不含** Server) |
| `internal/` | 控制面与边缘运行时实现 |
| `frontend/` | Next.js 管理端,构建产物嵌入 Go Server |
| `pkg/` | 跨组件共享库(协议、渲染、GeoIP 等) |
| `scripts/` | Swagger 生成、安装脚本等 |
| `docs/` | VitePress 文档站与设计基线 |
| `docker/` | 各组件 Dockerfile |
| `uploads/`、`data/` | 运行时上传目录与静态数据(`.gitignore` 忽略) |
### 1. Server 分层(`main.go` + `internal/`)
| 目录 | 职责 |
| --- | --- |
| `main.go` | Server 启动入口 |
| `internal/cmd/` | Cobra 子命令:`api`、`worker`、`scheduler`、`all`(默认融合模式) |
| `internal/platform/bootstrap/` | 跨模块装配:任务 Handler、推送域事件、进程级初始化 |
| `internal/router/` | HTTP 路由注册与全局中间件 |
| `internal/router/v1/openflare/` | OpenFlare 路由注册器(`register_*.go`) |
| `internal/apps/openflare/` | OpenFlare 控制面业务域(`routers.go` + `logics.go`) |
| `internal/apps/{admin,user,oauth,upload,cap,...}/` | Wavelet 平台能力(用户、认证、任务、推送等) |
| `internal/apps/openflare/{agent,relay,flared}/` | **Server 侧**边缘协议处理器(鉴权、心跳、WS) |
| `internal/model/` | GORM 实体 / DTO / 无 IO 领域规则(`openflare_*.go` + 平台模型);**不含** DB 访问 |
| `internal/infra/persistence/migrator/goose/` | goose SQL 迁移(PostgreSQL / SQLite / ClickHouse) |
| `internal/repository/` | 数据访问层(平台 + OpenFlare 业务 CRUD、缓存、ClickHouse 分析读写);**唯一**持久化入口 |
| `internal/infra/task/` | Asynq 异步任务(Worker + Scheduler) |
| `internal/infra/config/` | Viper 配置加载 |
| `internal/shared/` | 统一 API 响应封装(`response/`) |
| `pkg/protocol/` | Relay / Tunnel 共享 HTTP/WS 协议结构 |
| `pkg/render/`、`pkg/geoip/`、`pkg/wsclient/` | OpenResty 配置渲染、GeoIP、WebSocket 客户端 |
**API 路由前缀:**
| 前缀 | 用途 | 鉴权 |
| --- | --- | --- |
| `/api/v1/d/*` | OpenFlare 管理控制台 API | Session Cookie + 可选 `X-Access-Token` |
| `/api/v1/agent/*` | Agent 节点协议 | `X-Agent-Token` |
| `/api/v1/relay/*` | Relay 中继协议 | `X-Agent-Token` |
| `/api/v1/tunnel/*` | Tunnel 客户端协议 | `X-Tunnel-Token` |
| `/api/v1/admin/*` | Wavelet 平台管理 API | 管理员 Session |
### 2. Agent 模块 (`internal/apps/agent/` / `cmd/agent/`)
| 目录/模块 | 职责 |
| ----------------------------- | -------------------------------------------- |
| `cmd/agent/` | Agent 命令行启动入口及主函数 |
| `internal/apps/agent/config/` | 配置读取与默认值 |
| `internal/apps/agent/heartbeat/` | 心跳与版本摘要判断 |
| `internal/apps/agent/sync/` | 配置拉取与应用编排 |
| `internal/apps/agent/nginx/` | OpenResty 文件写入、校验、reload、启动与回滚 |
| `internal/apps/agent/state/` | 本地状态与观测补报缓冲 |
| `internal/apps/agent/httpclient/` | Server 通信 |
| `internal/apps/agent/wsclient/` | WebSocket 客户端通信 |
| `internal/apps/agent/protocol/` | Agent API 协议类型 |
| `internal/apps/agent/updater/` | Agent 自更新逻辑 |
| `internal/apps/agent/logging/` | 日志处理 |
| `internal/apps/agent/observability/`| 可观测性(指标、链路等) |
| `internal/apps/agent/geoipdata/` | GeoIP 数据处理 |
| `internal/apps/agent/geoipupdate/` | GeoIP 数据更新 |
| `internal/apps/agent/agent/` | 核心 Agent 逻辑与生命周期 |
### 3. Frontend 分层 (`frontend/`)
基于 Wavelet Next.js 脚手架,OpenFlare 业务 UI 以路由共置方式组织在 `app/(main)/` 下。
| 目录 | 职责 |
| --- | --- |
| `app/` | Next.js App Router;`(main)` 控制台、`(auth)` 认证、`(docs)` 文档页 |
| `app/(main)/<domain>/` | 业务页面与域内组件(路由共置) |
| `components/` | 跨域复用 UI(`ui/`、`layout/`、`common/` 等) |
| `lib/services/` | API 服务层:`core/` 基类 + `openflare/` 业务 API |
| `lib/navigation/` | OpenFlare 侧栏导航配置(`openflare-nav.ts`) |
| `lib/theme/` | 主题解析与切换 |
| `contexts/` | 跨页面 UI 状态(用户、通知等) |
| `hooks/`、`lib/hooks/` | 可复用 React Hooks |
| `public/` | 静态资源与主题 CSS |
| `scripts/` | 构建辅助脚本 |
| `proxy.ts` | 开发/生产代理:API 限流与页面鉴权 |
**API 约定**:OpenFlare 业务接口统一前缀 `/api/v1/d/*`,通过 `OpenFlareBaseService` 封装;页面数据获取使用 `@tanstack/react-query`。
### 4. Relay 模块 (`internal/apps/relay/` / `cmd/relay/`)
| 模块 | 职责 |
| ---------------- | ------------------------------------------------ |
| `cmd/relay/` | Relay 命令行启动入口及初始化主函数 |
| `internal/apps/relay/config/`| 本地配置文件解析与默认参数初始化 |
| `internal/apps/relay/frps/` | 管理 frps 进程生命周期、端口与 Token 并监控运行 |
| `internal/apps/relay/heartbeat/`| 周期性 HTTP 心跳通信、上报状态并获取更新请求 |
| `internal/apps/relay/httpclient/`| Server 的通用 API 客户端调用工具类 |
| `internal/apps/relay/observability/`| 采集本地宿主机、frps 的基础运行指标并进行预聚合 |
| `internal/apps/relay/relay/` | 协调中继的核心生命周期、初始化与清理 |
| `internal/apps/relay/state/` | 本地运行时状态、错误记录与持久化缓存 |
| `internal/apps/relay/updater/`| Relay 升级检查、下载安装与重启机制 |
| `internal/apps/relay/wsclient/`| 与 Server 保持的长连接 WebSocket 双向通信管道 |
### 5. OpenFlared (Client) 模块 (`internal/apps/flared/` / `cmd/flared/`)
| 模块 | 职责 |
| ---------------- | ------------------------------------------------ |
| `cmd/flared/` | Client 命令行启动入口及初始化主函数 |
| `internal/apps/flared/config/`| 本地客户端配置加载与解析 |
| `internal/apps/flared/flared/`| 内网穿透客户端的核心调度与状态管理机制 |
| `internal/apps/flared/frpc/` | 热重载/动态生成多 Relay 的 `frpc_{relayNodeID}.toml` 并监控 frpc |
| `internal/apps/flared/heartbeat/`| 与控制面进行的心跳通信,包含 Token 校验机制 |
| `internal/apps/flared/httpclient/`| 客户端通用 API 通信(`/api/v1/tunnel/*`) |
| `internal/apps/flared/sync/` | 增量拉取最新 Tunnel 路由绑定关系、生成快照并应用 |
| `internal/apps/flared/updater/`| 客户端自更新、新版检查与更新落地逻辑 |
| `internal/apps/flared/wsclient/`| 用于实时监听 Server 端隧道配置变更推送的 WS 信道 |
> **说明**:OpenFlared 无独立 `state/` 包;版本与 checksum 由 `frpc/manager.go` 持久化到 `flared-state.json`。
---
## 文档维护原则
* 产品范围或系统边界变化:更新本文档([产品边界](./index.md))。
* 系统结构、组件分工变化:更新 [系统架构](./architecture.md)。
* 发布、同步、回滚与 Agent 模型变化:更新 [Agent 与发布模型](./agent-design.md)。
* 部署方式变化:更新 [部署说明](../deployment/deployment.md) 与 README。
* 配置项变化:更新 [配置项参考](../reference/configuration.md)。
-109
View File
@@ -1,109 +0,0 @@
# Uptime Kuma 监控同步设计
你会学到:OpenFlare 与 Uptime Kuma 监控服务集成的设计背景、基于 Socket.IO 协议的控制流设计、以标签隔离为核心的防污染模型,以及差分增量同步的状态机比对逻辑。
---
## 需求分析
在多节点的网关架构中,监控系统的状态与反向代理路由的状态通常是相互脱节的:
1. **录入开销大**:每当网关控制面新增或下线一个站点,管理员都必须在监控系统(如 Uptime Kuma)中重复配置对应的探测地址与告警策略。
2. **数据不一致**:当代理路由域名发生变更或切换 HTTPS 时,容易遗漏修改监控参数,导致监控系统误报或漏报。
3. **环境污染隐患**:如果简单的在监控中执行全量“删除-重建”同步,不仅会清空监控系统中的历史统计指标和 SLA 曲线,还会影响到用户在此监控实例上自行配置的、与网关无关的其他监控任务。
为了解决这些痛点,OpenFlare 引入了基于客户端/服务器模式的 **Uptime Kuma 自动监控同步机制**,实现网关站点路由定义与可用性监测系统的强一致、低开销以及零污染同步。
---
## 核心架构设计
Uptime Kuma 同步子系统完全运行在 **Server 控制面** 的后台调度器中。
```text
[ OpenFlare 控制面 / 数据库 ] [ Uptime Kuma 实例 ]
│ │
1. 定时 Cron 触发 (Job) │
│ │
2. 读取代理路由与选项配置 │
│ │
3. 连接 Socket.IO 接口 <──── 4. Socket.IO 握手 & 登录 ───┤
│ │
├────── 5. 校验 / 创建 "OpenFlare" 标签 ────────►│
├────── 6. 比对监测站点属性与 Kuma 监控清单 ──────►│
│ │
└────── 7. 执行差分指令 (add / edit / delete) ─►│
```
同步子系统不经过数据面的 Agent 节点,而是由 Server 通过 Uptime Kuma 暴露的 Socket.IO 端点直接交互。这种设计可以降低边缘节点的网络开销,并将鉴权凭证(Kuma 用户名与密码)安全收拢在控制面中。
---
## 标签隔离与防污染设计
为了在一个共享的 Uptime Kuma 实例中安全运行,而不干扰用户手动创建的其他监控项,设计上采用了 **专属标签隔离机制**:
1. **`OpenFlare` 专属标签**:
* 同步程序首次连接时,会调用 `getTags` 接口拉取实例中的所有标签。
* 检查是否存在名为 `OpenFlare` 的标签(默认颜色为靛蓝色 `#4f46e5`)。如果不存在,则通过 `addTag` 接口在 Kuma 中自动创建它。
2. **过滤范围收拢**:
* 同步任务在拉取 Uptime Kuma 的监控列表(`monitorList`)后,仅会保留**打有 `OpenFlare` 标签**的监控项。
* 所有的修改比对(`editMonitor`)和下线清理(`deleteMonitor`)**仅在此过滤子集内进行**。任何未绑定 `OpenFlare` 标签的监控项对同步程序均是“隐形”的,实现了完美的防污染隔离。
---
## 差分同步状态机逻辑
同步程序每次执行时,会对 OpenFlare 本地配置与 Uptime Kuma 数据进行差分计算,根据比对结果执行不同的 Socket.IO 事件:
```mermaid
stateDiagram-v2
[*] --> 检查站点状态与监控范围
state "检查监控范围" as Scope {
[*] --> 校验站点是否启用并且在 Scope 内
校验站点是否启用并且在 Scope 内 --> 在Scope内 : 是
校验站点是否启用并且在 Scope 内 --> 不在Scope内 : 否
}
不在Scope内 --> 检查Kuma中是否存在同名且带标签的监控
检查Kuma中是否存在同名且带标签的监控 --> 执行清理 : 存在
检查Kuma中是否存在同名且带标签的监控 --> 忽略 : 不存在
在Scope内 --> 检查Kuma中是否存在同名监控
state "比对属性" as Compare {
[*] --> 检查是否存在
检查是否存在 --> 新建监控项 : 否
检查是否存在 --> 比对元数据 : 是
比对元数据 --> 属性一致 : 匹配
比对元数据 --> 属性不一致 : 不匹配
}
新建监控项 --> 发送add指令并绑定Tag
属性不一致 --> 发送editMonitor指令
属性一致 --> 忽略
执行清理 --> 发送deleteMonitor指令
忽略 --> [*]
```
### 1. 监测 URL 规范化
站点路由在 OpenFlare 中可配置多个域名,同步程序自动提取其主域名(Primary Domain)并根据是否启用 HTTPS 组装为标准的 `http://` 或 `https://` 前缀。
### 2. 比对属性清单
如果同名且带标签的监控已存在,同步程序会细致比对以下 5 个关键字段是否与当前网关全局 Option 一致。只要有一个字段不匹配,便会触发更新:
* **URL 地址**:`Url`
* **探测频率**:`Interval`(默认 60s)
* **重试次数**:`MaxRetries`
* **重试间隔**:`RetryInterval`(默认 60s)
* **请求超时**:`Timeout`(默认 48s)
---
## 调度器与高并发保护
1. **基于 Cron 的单线程执行**:
* Server 周期性(每 1 分钟)通过后台的 Cron Job 探测是否达到配置的同步间隔(`UptimeKumaSyncInterval`)。
* 任务内部设计了互斥锁(Mutex Locking)。如果前一次同步请求因为网络延迟等原因尚未结束,下一次调度将自动跳过,防止并发多个 Socket.IO 连接对 Uptime Kuma 实例造成 DDOS 冲击。
2. **WebSocket 状态监听**:
* 同步程序利用 Socket.IO 的事件监听机制,在连接建立后,必须等到监听到 `monitorList` 事件的完整列表推送后,才允许向下执行差分算法,以规避因为数据加载不完整导致误删监控项的边界情况。
-127
View File
@@ -1,127 +0,0 @@
# 登录验证码设计 (Login CAPTCHA Integration)
本文档阐述在 OpenFlare 控制面中引入基于 Proof-of-Work (PoW) 与无感浏览器指纹特征的开源 CAPTCHA 方案 —— Cap,以防止对登录 API 进行暴力破解与爬虫撞库攻击的设计。
---
## 1. 业务背景与产品范围
### 背景与痛点
根据我们的系统安全分析,OpenFlare 的登录端点 `/api/user/login` 虽然配置了基于 IP 的限流限制,但由于缺少用户维度的防护机制,攻击者可使用代理池绕过 IP 限制对高权限账户(如 `root`)实施撞库和暴力破解。同时,对于系统登录页面,标准的视觉验证码对用户体验和无障碍不够友好。
### 产品范围与技术选型
* **技术选型**:Cap (Proof-of-Work 驱动的无感无图像验证码解决方案)。
- **核心原理**:客户端(Widget/网页)从服务器获取工作量证明 (PoW) 的难题,使用浏览器后台计算求解并将答案回传。服务器验证答案的正确性,完成人机识别。
- **优势**:无感、无图像验证、不依赖任何外部第三方 API 节点(私密)、包极小。
* **接入范围**:控制面 Server 登录 API(`/api/user/login`)以及前端登录页面。
* **配置粒度**:支持管理员通过控制台 Option 表随时开启/关闭验证码(`CapLoginEnabled`)。
---
## 2. 系统架构与交互时序
### 2.1 模块分工
1. **Frontend (前端)**:
* 在登录页面引入 `cap-widget`(React 19 自定义元素)。
* 提交表单时,伴随提交由 Widget 求解出并得到的 `cap-token`。
2. **Server (控制面后端)**:
* 暴露 `POST /api/cap/challenge` 接口,为客户端分发 PoW 难题和签名的 JWT Token。
* 暴露 `POST /api/cap/redeem` 接口,校验客户端提交的 PoW 解答并核发带有失效时间的登录凭证(Redeem Token)。
* 将 Redeem Token 与对应过期时间保存在内存缓存/Redis 缓存中。
* 在 `POST /api/user/login` 接口中,若启用了验证码保护,先校验并消耗(单次失效)对应的 `cap-token`。
### 2.2 验证流时序图
```mermaid
sequenceDiagram
autonumber
actor User as 用户
participant Browser as 浏览器 (前端 Web)
participant Server as OpenFlare Server (后端)
participant Cache as 内存/Redis 缓存
User->>Browser: 打开登录页面
Browser->>Server: POST /api/cap/challenge (获取难题)
Server->>Browser: 返回 {challenge, token, expires} (JWT 格式)
Note over Browser: Widget 在后台(WASM/Worker)执行 PoW 难题计算
Browser->>Server: POST /api/cap/redeem (提交 solutions + token)
alt 校验 PoW 解答通过
Server->>Cache: 存储 Redeem Token (tokenKey:expires)
Server->>Browser: 返回 {success: true, token} (即 cap-token)
else 校验失败
Server->>Browser: 返回 {success: false, reason}
end
User->>Browser: 输入账号密码,点击登录
Browser->>Server: POST /api/user/login (在 HTTP 请求头中携带 X-Cap-Token)
alt CapLoginEnabled = true
Server->>Server: Middleware (CapAuth) 校验并消费 X-Cap-Token
alt token 合法且未过期且未被消费
Server->>Server: c.Next() -> 执行常规登录逻辑 (密码 Bcrypt 校验)
Server->>Browser: 返回登录成功 (Session Cookie)
else token 无效或已被消费
Server->>Browser: 拦截并返回验证码错误 (401 Unauthorized)
end
else CapLoginEnabled = false
Server->>Server: c.Next() -> 执行常规登录逻辑
end
```
---
## 3. 核心接口与数据模型
### 3.1 接口定义
#### 1. 获取难题 (GET/POST /api/cap/challenge)
* **请求方式**:`POST`
* **接口权限**:公开
* **响应负载**(统一 API 信封,`data` 为业务载荷):
```json
{
"error_msg": "",
"data": {
"challenge": {
"c": 50,
"s": 32,
"d": 4
},
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expires": 1717660800000
}
}
```
#### 2. 核销难题 (POST /api/cap/redeem)
* **请求方式**:`POST`
* **请求负载**:
```json
{
"token": "challenge_jwt_token_here",
"solutions": [12345, 67890, 54321]
}
```
* **响应负载 (成功)**:
```json
{
"success": true,
"token": "random_id:ver_token",
"expires": 1717661000000
}
```
#### 3. 登录接口 (POST /api/user/login)
* **请求负载保持不变**:
```json
{
"username": "root",
"password": "your_password"
}
```
* **验证码载体**:放置于 HTTP Request Header `X-Cap-Token` 中。
---
## 4. 重放攻击防护与安全性权衡
1. **JWT 临时状态绑定**:难题在生成时就被签入 JWT payload,包含过期时间限制(10 分钟)。
2. **Replay 拦截(Nonce 消耗)**:当客户端调用 `/redeem` 提交解答时,后端在缓存中标记该 JWT Signature 已使用。重复提交相同的解密包将返回 `already_redeemed`。
3. **Redeem 一次性核销(单次失效)**:当客户端登录并提交 `cap-token` 时,后端在检验到合法性后立即从缓存中删除该 Key,防止黑客提取历史正确的 `cap-token` 进行重放登录。
4. **验证机制无感化**:通过调整 `c (难题数)=50`,`d (难度)=4`,普通用户在桌面端和移动端只需 0.5 秒至 1.5 秒即可静默解出,极大地兼顾了用户体验和反爬效果。
-779
View File
@@ -1,779 +0,0 @@
# Agent 上报协议与观测落库数据模型
你会学到:重构后 Agent 心跳/WS 上报的 **数据结构**、Server **如何解析与写入**、ClickHouse / 关系库 **目标表结构**。
**无协议兼容层**:Agent 以销毁重建或二进制替换升级;旧字段不解析、旧缓冲整文件丢弃。
本设计是 [边缘可观测与业务流量统计重构](./observability-design.md) 的 **协议与存储专章**,实现时以本文字段与 DDL 为准。
**先读传输全景与示例:** [观测数据传输模型](./observability-transport-model.md)。
---
## 1. 设计目标
| 目标 | 说明 |
| --- | --- |
| Agent 只报事实 | 明细 + 主机读数 + 边缘健康瞬时态;无业务预聚合 |
| 一张业务明细表 | 访问日志是 L1 唯一写入路径 |
| 聚合在库内/控制面 | 小时汇总由 ClickHouse MV 或查询生成,Agent 不写汇总表 |
| 字段不重叠 | `bytes_sent` = 已提供数据;网卡 `network_*` = 宿主机;不再有业务 `openresty_tx` |
| 可演进 | 新字段可选;缺省数值填 0,不解析已删除的旧协议字段 |
---
## 2. 分层与写入总览
```text
Agent NodePayload (v2)
│
┌───────────────┼───────────────┐
▼ ▼ ▼
access_logs host_metrics edge_health
(L1 明细) (L3 读数) (L2 瞬时)
│ │ │
▼ ▼ ▼
of_node_access_logs of_node_metric_ of_node_edge_health
│ snapshots │
│ │ │
▼ ▼ │
of_access_log_hourly of_node_metric_ │
(MV, Server 侧) capacity_hourly (MV) │
│ │ │
└─────── 管理端聚合 API ───────────┘
关系库 (PostgreSQL/SQLite):节点最新状态、Profile、健康事件(非明细湖)
```
| 层 | 含义 | Agent 上报块 | ClickHouse 事实表 |
| --- | --- | --- | --- |
| L1 | 业务交付 | `access_logs` | `of_node_access_logs` |
| L2 | 边缘健康 | `edge_health` | `of_node_edge_health` |
| L3 | 宿主机资源 | `host_metrics` | `of_node_metric_snapshots` |
---
## 3. Agent 上报数据结构(协议 v2)
### 3.1 顶层 `NodePayload`
传输:HTTP 心跳 body 与 WebSocket `status` 消息共用同一结构。
```json
{
"schema_version": 2,
"node_id": "n_xxx",
"name": "edge-1",
"ip": "1.2.3.4",
"version": "3.3.0",
"ext_version": "",
"current_version": "cfg-checksum-or-version",
"last_error": "",
"profile": { },
"host_metrics": { },
"edge_health": { },
"access_logs": [ ],
"buffered": [ ],
"health_events": [ ],
"waf_ip_group_checksums": { "1": "md5..." }
}
```
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `schema_version` | int | 建议 | 固定为 `2`(本设计) |
| `node_id` | string | ✅ | 节点 ID |
| `name` | string | ✅ | 显示名 |
| `ip` | string | ✅ | 上报 IP |
| `version` / `ext_version` | string | ✅ | Agent 版本 |
| `current_version` | string | | 本地激活配置版本摘要 |
| `last_error` | string | | 最近同步/运行错误,可空 |
| `openresty_status` | string | ✅(有 OpenResty 时) | **最新健康态权威字段** → 写 PG 节点表 |
| `openresty_message` | string | | **最新健康说明权威字段** → 写 PG 节点表(**不进 CH**) |
| `profile` | object | | 主机概况,变化时上报(可节流) |
| `host_metrics` | object | 建议每拍 | L3 资源快照 |
| `edge_health` | object | 建议每拍 | L2 连接时序 + 与顶层一致的 status |
| `access_logs` | array | | 本拍增量访问明细 |
| `buffered` | array | | 离线补传的事实批次(见 §3.6) |
| `health_events` | array | | 边缘健康事件 |
| `waf_ip_group_checksums` | map | | 差分同步用,非观测湖 |
**已删除、Server 不再解析的字段(无兼容层):**
| 旧字段 | 处置 |
| --- | --- |
| `traffic_report` | 不存在于协议;不落库 |
| `openresty_observation` | 不存在;连接与状态走 `edge_health` |
| `snapshot` | 不存在;仅用 `host_metrics` |
| `buffered_observability` | 不存在;仅用 `buffered` |
### 3.2 `profile` — 主机概况(低频)
对应关系库 `of_node_system_profiles`(或现有等价表),**不进 ClickHouse 明细湖**。
```json
{
"hostname": "edge-1",
"os_name": "linux",
"os_version": "...",
"kernel_version": "...",
"architecture": "amd64",
"cpu_model": "...",
"cpu_cores": 8,
"total_memory_bytes": 16106127360,
"total_disk_bytes": 107374182400,
"uptime_seconds": 864000,
"reported_at_unix": 1720000000
}
```
| 字段 | 语义 |
| --- | --- |
| 硬件/OS 描述字段 | 事实读数 |
| `reported_at_unix` | Agent 采集时刻(UTC 秒) |
### 3.3 `host_metrics` — 宿主机资源(L3)
**全部为读数,不做 24h 业务总量。**
网卡/磁盘字节为 **内核累计计数器原值**(单调递增,重启可归零);CPU 为瞬时百分比;内存/磁盘占用为当前用量。
```json
{
"captured_at_unix": 1720000000,
"cpu_usage_percent": 12.5,
"memory_used_bytes": 4294967296,
"memory_total_bytes": 16106127360,
"storage_used_bytes": 50000000000,
"storage_total_bytes": 107374182400,
"disk_read_bytes": 9000000000,
"disk_write_bytes": 12000000000,
"network_rx_bytes": 500000000000,
"network_tx_bytes": 800000000000
}
```
| 字段 | 类型 | 语义 | Server 如何用 |
| --- | --- | --- | --- |
| `captured_at_unix` | int64 | 采样时刻 | `captured_at` |
| `cpu_usage_percent` | float | 瞬时 CPU% | 直接存;趋势取平均 |
| `memory_*` / `storage_*` | int64 | 当前用量/总量 | 直接存;算占用率 |
| `disk_read_bytes` / `disk_write_bytes` | int64 | **累计** IO 字节 | 存原值;查询时相邻差分 |
| `network_rx_bytes` / `network_tx_bytes` | int64 | **累计** 网卡字节 | 存原值;查询时相邻差分 →「宿主机网卡入/出站」 |
> Agent **禁止** 在上报前对网卡/磁盘做「本周期增量」替换累计值(否则 Server 差分会错)。
### 3.4 `edge_health` — OpenResty 边缘健康(L2)
**仅瞬时态,不包含业务吞吐。**
```json
{
"captured_at_unix": 1720000000,
"status": "healthy",
"message": "",
"connections": 42
}
```
| 字段 | 类型 | 语义 |
| --- | --- | --- |
| `status` | string | `healthy` / `unhealthy` / `unknown`(须与顶层 `openresty_status` 一致) |
| `message` | string | 状态说明(上报可带;**仅用于回填 PG 最新态,不进 CH**) |
| `connections` | int64 | stub_status Active connections |
#### 健康状态权威源(收敛)
| 数据 | 权威存储 | 说明 |
| --- | --- | --- |
| **当前** OpenResty 是否健康 + 说明文案 | **PG 节点表** `openresty_status` / `openresty_message` | UI 徽章、列表、告警以这里为准 |
| **时序** 健康 status + 连接数 | **CH** `of_node_edge_health`(`status`, `connections`) | 连接曲线 / 健康状态历史;**无 message 列** |
| Agent 上报 | 顶层 status/message + `edge_health` | Server 归一化后二者 status 对齐;message **只写 PG** |
因此:查「现在是否 unhealthy」→ 读 PG;查「过去 24h 连接数」→ 读 CH。
### 3.5 `access_logs[]` — 访问明细(L1,业务唯一事实)
Agent:tail access.log → 解析 JSON 行 → 原样字段上报(可截断 path)。
```json
{
"logged_at_unix": 1720000001,
"remote_addr": "203.0.113.10",
"host": "www.example.com",
"path": "/api/v1/ping",
"status_code": 200,
"bytes_sent": 1024,
"request_length": 128,
"request_time_ms": 15,
"user_agent": "Mozilla/5.0 ...",
"cache_status": "HIT"
}
```
| 字段 | 类型 | 必填 | 来源(OpenResty) | 业务含义 |
| --- | --- | --- | --- | --- |
| `logged_at_unix` | int64 | ✅ | `$time_iso8601` 解析 | 请求完成时间 |
| `remote_addr` | string | ✅ | `$remote_addr` | 客户端 IP → UV |
| `host` | string | ✅ | `$host` | 域名 → Zone 归属 |
| `path` | string | ✅ | `$request_uri`,Agent 可截断 | 路径 |
| `status_code` | int | ✅ | `$status` | 状态码 |
| `bytes_sent` | int64 | ✅ | **`$body_bytes_sent`** | **已提供数据**(响应体) |
| `request_length` | int64 | 建议 | `$request_length` | **接收数据** |
| `request_time_ms` | int64 | 可选 | `$request_time * 1000` | 耗时;缺省 0 |
| `user_agent` | string | 建议 | `$http_user_agent` | UA;可截断入库 |
| `cache_status` | string | 建议 | **`$upstream_cache_status`** | 边缘缓存结果(见 §3.5.1) |
**明确不由 Agent 上报(由 Server 写入):**
* `region` / 国家:入库时 GeoIP 解析
* `id` / `created_at`:Server 生成
* `node_id`:取自 payload / 鉴权上下文
**明确不上报:**
* `upstream_addr` / 回源地址 / `origin_fetched`:不做回源端点追踪;「是否回源」仅由 `cache_status` 在控制面推导(§3.5.1)
### 3.5.1 `cache_status` — 缓存命中与回源(明细优先)
**目标(第一期):** 访问日志明细/详情能展示「是否命中缓存 / 是否回源 / 未使用缓存」。
**口径:** 只存 OpenResty `$upstream_cache_status` 原始值;**不上报** upstream 地址。
#### 原始值(入库)
| 值 | 含义(OpenResty) |
| --- | --- |
| `HIT` | 命中缓存 |
| `MISS` | 未命中,向 upstream 取内容 |
| `BYPASS` | 跳过缓存(如 method/cookie/策略导致 `$openflare_skip_cache`) |
| `EXPIRED` | 缓存过期后回源 |
| `STALE` | 提供陈旧缓存(stale) |
| `UPDATING` | 后台更新中,可能返回旧缓存 |
| `REVALIDATED` | 协商验证后仍用缓存 |
| `-` 或空 | 未经过 `proxy_cache`(如 Pages 本地静态、非代理 location) |
#### UI 三态推导(不落库)
控制面展示用派生枚举 `cache_outcome`,**不写 CH**:
| 三态 | 条件(`cache_status`) | 列表标签建议 |
| --- | --- | --- |
| **命中缓存** | `HIT` / `STALE` / `REVALIDATED` / `UPDATING` | 命中 |
| **回源** | `MISS` / `EXPIRED` | 回源 |
| **未使用缓存** | `BYPASS` / `-` / `""` | 未缓存 |
详情可同时显示三态 + 原始 `cache_status`。
#### 边界
* Pages 静态 / 无 `proxy_cache` 的 location:多为空或 `-` → **未使用缓存**,不得标成「命中」。
* 第一期只做明细可见;命中率看板、hourly 维度可后续用同一列聚合。
**单次心跳条数建议:**
* 软上限例如 2000 条/拍;超出进入 `buffered` 下一批,**禁止** 在 Agent 压成 TrafficReport。
### 3.6 `buffered[]` — 离线补传(只装事实)
```json
{
"captured_at_unix": 1719999900,
"host_metrics": { },
"edge_health": { },
"access_logs": [ ]
}
```
| 字段 | 说明 |
| --- | --- |
| `captured_at_unix` | 该批次采集/缓冲时刻,用于 ack 与去重窗口 |
| `host_metrics` / `edge_health` / `access_logs` | 与主 payload 同结构;可省略空块 |
**禁止** 在 buffered 中携带 `traffic_report` 或 rx/tx 吞吐。
### 3.7 `health_events[]`
```json
{
"event_type": "openresty_unhealthy",
"severity": "critical",
"message": "...",
"triggered_at_unix": 1720000000,
"metadata": { }
}
```
写入关系库健康事件表(现有模型即可),不进访问日志湖。
### 3.8 Go 协议草图(目标)
```go
// pkg/protocol/agent.go(目标形态,实现时替换旧类型)
type NodePayload struct {
SchemaVersion int `json:"schema_version,omitempty"`
NodeID string `json:"node_id"`
Name string `json:"name"`
IP string `json:"ip"`
Version string `json:"version"`
ExtVersion string `json:"ext_version"`
CurrentVersion string `json:"current_version"`
LastError string `json:"last_error"`
OpenrestyStatus string `json:"openresty_status"` // PG 最新态权威
OpenrestyMessage string `json:"openresty_message"` // PG 最新态权威;不进 CH
Profile *NodeSystemProfile `json:"profile,omitempty"`
HostMetrics *NodeHostMetrics `json:"host_metrics,omitempty"`
EdgeHealth *NodeEdgeHealth `json:"edge_health,omitempty"`
AccessLogs []NodeAccessLog `json:"access_logs,omitempty"`
Buffered []BufferedFacts `json:"buffered,omitempty"`
HealthEvents []NodeHealthEvent `json:"health_events"`
WAFIPGroupChecksums map[string]string `json:"waf_ip_group_checksums,omitempty"`
}
type NodeHostMetrics struct {
CapturedAtUnix int64 `json:"captured_at_unix"`
CPUUsagePercent float64 `json:"cpu_usage_percent"`
MemoryUsedBytes int64 `json:"memory_used_bytes"`
MemoryTotalBytes int64 `json:"memory_total_bytes"`
StorageUsedBytes int64 `json:"storage_used_bytes"`
StorageTotalBytes int64 `json:"storage_total_bytes"`
DiskReadBytes int64 `json:"disk_read_bytes"`
DiskWriteBytes int64 `json:"disk_write_bytes"`
NetworkRxBytes int64 `json:"network_rx_bytes"`
NetworkTxBytes int64 `json:"network_tx_bytes"`
}
type NodeEdgeHealth struct {
CapturedAtUnix int64 `json:"captured_at_unix"`
Status string `json:"status"`
Message string `json:"message"`
Connections int64 `json:"connections"`
}
type NodeAccessLog struct {
LoggedAtUnix int64 `json:"logged_at_unix"`
RemoteAddr string `json:"remote_addr"`
Host string `json:"host"`
Path string `json:"path"`
UserAgent string `json:"user_agent,omitempty"`
CacheStatus string `json:"cache_status,omitempty"` // $upstream_cache_status
StatusCode int `json:"status_code"`
BytesSent int64 `json:"bytes_sent"` // body_bytes_sent,已提供数据
RequestLength int64 `json:"request_length"` // 接收数据
RequestTimeMs int64 `json:"request_time_ms"` // 可选
}
type BufferedFacts struct {
CapturedAtUnix int64 `json:"captured_at_unix"`
HostMetrics *NodeHostMetrics `json:"host_metrics,omitempty"`
EdgeHealth *NodeEdgeHealth `json:"edge_health,omitempty"`
AccessLogs []NodeAccessLog `json:"access_logs,omitempty"`
}
```
---
## 4. Server 解析与落库流程
### 4.1 入口
* HTTP:`POST /api/v1/agent/...` 心跳(现有路径)
* WebSocket:`type=status` payload = `NodePayload`
* 鉴权:`X-Agent-Token` → 绑定 `node_id`(payload.node_id 必须与 token 节点一致)
### 4.2 处理流水线(单次 payload)
```text
1. 反序列化 NodePayload
2. 归一化(normalize)
- schema_version < 2:
host_metrics ← snapshot
edge_health.status ← openresty_status
edge_health.connections ← openresty_observation.connections(若有)
traffic_report → drop
openresty_observation.rx/tx → drop
buffered ← buffered_observability
- path 再截断、status 范围钳制、负数字节 → 0
3. 关系库事务(节点最新态)
- 更新 node 在线时间、IP、版本、edge_health.status/message
- upsert profile(若有)
- insert health_events(若有)
4. ClickHouse 异步 batch(失败记日志,不阻断心跳响应的配置下发)
a. access_logs + buffered[].access_logs
→ 补 region(GeoIP)
→ 分配 snowflake id
→ BatchInsert of_node_access_logs
b. host_metrics + buffered[].host_metrics
→ of_node_metric_snapshots
c. edge_health + buffered[].edge_health
→ of_node_edge_health(仅 connections + status 快照可选)
5. 返回心跳响应(settings / active_config / waf 差分)
6. 若使用 buffer ack:按 buffered.captured_at_unix 列表确认
```
### 4.3 归一化规则(硬约束)
| 规则 | 行为 |
| --- | --- |
| `logged_at` 超前 now+5m | 钳制为 now 或丢弃该条(实现选定一种并单测) |
| `logged_at` 早于 now−TTL | 仍可写入,依赖表 TTL 清理 |
| 空 `host` | 允许,聚合进「未归属」 |
| `bytes_sent` / `request_length` < 0 | 置 0 |
| 单批 access_logs > N | 截断并打点监控(或只入 buffer 队列),不改为预聚合 |
| 重复补传 | CH 允许少量重复行;查询用 sum 近似(不强制精确去重) |
### 4.4 字段映射表(上报 → 表)
| 上报路径 | 目标存储 | 列 |
| --- | --- | --- |
| `access_logs[]` | CH `of_node_access_logs` | 见 §5.1 |
| `host_metrics` | CH `of_node_metric_snapshots` | 见 §5.2 |
| `edge_health` | CH `of_node_edge_health` + PG node 最新状态 | 见 §5.3 / §5.6 |
| `profile` | PG `of_node_system_profiles` | 现有列 |
| `health_events` | PG 健康事件表 | 现有模型 |
| `waf_ip_group_checksums` | 不落观测表 | 同步逻辑 |
| `traffic_report`(旧) | **不写** | — |
| `openresty_rx/tx`(旧) | **不写** | — |
### 4.5 查询侧(不落新「业务出站」列)
| 产品指标 | SQL 语义(示意) |
| --- | --- |
| 已提供数据 | `sum(bytes_sent)` |
| 接收数据 | `sum(request_length)` |
| 请求数 | `count()` |
| UV | `uniqExact(remote_addr)` |
| 5xx | `countIf(status_code >= 500)` |
| 按域名/状态码/地区 | `GROUP BY host / status_code / region` |
| 宿主机网卡出站 | 对 `network_tx_bytes` 按 node 时间序非负差分后 sum |
| OpenResty 连接 | `of_node_edge_health.connections` 最新或平均 |
---
## 5. 表结构(目标 DDL)
> 引擎与 TTL 与现网一致倾向:访问日志 90 天,指标 30 天。
> `id` 使用控制面 Snowflake/唯一 UInt64。
### 5.1 L1 事实表:`of_node_access_logs`
```sql
CREATE TABLE IF NOT EXISTS of_node_access_logs
(
id UInt64,
node_id String,
logged_at DateTime64(3, 'UTC'),
remote_addr String,
region String, -- Server GeoIP 写入,Agent 不传
host String,
path String,
user_agent String DEFAULT '', -- $http_user_agent
cache_status String DEFAULT '', -- $upstream_cache_status
status_code Int32,
bytes_sent UInt64, -- 已提供数据(body)
request_length UInt64 DEFAULT 0, -- 接收数据
request_time_ms UInt32 DEFAULT 0, -- 可选
created_at DateTime64(3, 'UTC')
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(logged_at)
ORDER BY (node_id, logged_at, host, status_code, remote_addr)
TTL toDateTime(logged_at) + INTERVAL 90 DAY
SETTINGS index_granularity = 8192;
```
| 列 | 类型 | 来源 |
| --- | --- | --- |
| `id` | UInt64 | Server |
| `node_id` | String | 鉴权/payload |
| `logged_at` | DateTime64(3) | `logged_at_unix` |
| `remote_addr` | String | 上报 |
| `region` | String | Server GeoIP |
| `host` | String | 上报 |
| `path` | String | 上报 |
| `user_agent` | String | 上报(可空) |
| `cache_status` | String | 上报(可空)→ **缓存状态** |
| `status_code` | Int32 | 上报 |
| `bytes_sent` | UInt64 | 上报 → **已提供数据** |
| `request_length` | UInt64 | 上报 → **接收数据** |
| `request_time_ms` | UInt32 | 上报可选 |
| `created_at` | DateTime64(3) | Server now |
**迁移:** 现表已有 `bytes_sent` / `request_length` / `request_time_ms` / `user_agent`;缓存状态新增:
```sql
ALTER TABLE of_node_access_logs
ADD COLUMN IF NOT EXISTS cache_status String DEFAULT '';
```
### 5.2 L1 小时汇总(Server 侧 MV)
**禁止 Agent 写入。** 供看板/节点 24h 快速查询请求数、错误数、字节量。
**已实现选型:`SummingMergeTree` + 不含 UV 列。**
```sql
CREATE TABLE IF NOT EXISTS of_access_log_hourly
(
node_id String,
hour DateTime('UTC'),
host String,
request_count UInt64,
error_count UInt64,
bytes_sent UInt64,
request_length UInt64
)
ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(hour)
ORDER BY (node_id, hour, host)
TTL hour + INTERVAL 90 DAY;
CREATE MATERIALIZED VIEW IF NOT EXISTS of_access_log_hourly_mv
TO of_access_log_hourly
AS
SELECT
node_id,
toStartOfHour(logged_at) AS hour,
host,
toUInt64(count()) AS request_count,
toUInt64(countIf(status_code >= 500)) AS error_count,
sum(bytes_sent) AS bytes_sent,
sum(request_length) AS request_length
FROM of_node_access_logs
GROUP BY node_id, hour, host;
```
历史小时(MV 创建前已入库的明细)需一次性回填,见迁移 `202607180003_backfill_access_log_hourly.sql`(ANTI JOIN 防重)。
#### UV 策略(必须遵守)
| 场景 | 数据源 | 算法 | 说明 |
| --- | --- | --- | --- |
| **窗口总 UV**(看板汇总、节点卡片、Zone 汇总) | `of_node_access_logs` 明细 | `uniqExact(remote_addr)`(`TrafficSummary` / 节点聚合) | **唯一权威**;不可用小时 UV 相加 |
| **24h 趋势折线请求/错误/字节** | `of_access_log_hourly` 优先,缺数据回落明细桶 | `sum(request_count)` 等 | 小时路径 **不填** `unique_visitor_count`(恒为 0) |
| **24h 趋势折线分时 UV** | 仅明细桶路径 | 桶内 `uniqExact` | 走 hourly 时 UI 应展示空/0 或隐藏 UV 序列,**禁止**对小时行做 `sum(UV)` |
**为何 hourly 不存 UV:**
1. `SummingMergeTree` 只能安全合并可加和计数;`uniqExact` 跨 part 合并需要 `AggregatingMergeTree` + state,实现与查询更重。
2. 即便存每小时 UV,对多小时窗口 **相加会严重高估**(同一 IP 跨小时重复计)。
3. 产品「24h 独立访客」只认整窗 `uniqExact`;趋势图主序列是请求量/错误/字节,分时 UV 非主指标。
可选未来:若需要分时 UV 曲线,再单独加 `AggregatingMergeTree` 状态表或查询时对明细做 `uniqExact` 按小时 group(成本更高,不阻塞当前看板)。
### 5.3 L3 事实表:`of_node_metric_snapshots`(保留,语义明确)
```sql
CREATE TABLE IF NOT EXISTS of_node_metric_snapshots
(
id UInt64,
node_id String,
captured_at DateTime64(3, 'UTC'),
cpu_usage_percent Float64,
memory_used_bytes Int64,
memory_total_bytes Int64,
storage_used_bytes Int64,
storage_total_bytes Int64,
disk_read_bytes Int64, -- 累计原值
disk_write_bytes Int64,
network_rx_bytes Int64, -- 累计原值 → 宿主机网卡入站
network_tx_bytes Int64, -- 累计原值 → 宿主机网卡出站
created_at DateTime64(3, 'UTC')
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(captured_at)
ORDER BY (node_id, captured_at, id)
TTL toDateTime(captured_at) + INTERVAL 30 DAY
SETTINGS index_granularity = 8192;
```
列与现网一致;**文档与 API 必须标注 network_* 为宿主机网卡累计值**。
### 5.4 L3 小时汇总:`of_node_metric_capacity_hourly`(保留)
现有 min/max 用于累计计数器小时增量近似 + CPU/内存平均。逻辑不变:
* `network_tx_max - network_tx_min` ≈ 该小时宿主机出站
* **不得** 用于「已提供数据」
### 5.5 L2 事实表:`of_node_edge_health`(新建,替换吞吐型 openresty 表)
```sql
CREATE TABLE IF NOT EXISTS of_node_edge_health
(
id UInt64,
node_id String,
captured_at DateTime64(3, 'UTC'),
status LowCardinality(String), -- healthy / unhealthy / unknown
connections Int64,
created_at DateTime64(3, 'UTC')
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(captured_at)
ORDER BY (node_id, captured_at, id)
TTL toDateTime(captured_at) + INTERVAL 30 DAY
SETTINGS index_granularity = 8192;
```
| 列 | 说明 |
| --- | --- |
| `status` | 瞬时健康(与 PG 当前态同源;用于时序,非唯一 UI 权威) |
| `connections` | 当前连接数 |
**无** `message` 列(说明文案仅 PG 最新态)。
**无** `openresty_rx_bytes` / `openresty_tx_bytes`。
### 5.6 关系库(节点最新态,非分析湖)
与观测湖分离,保持「最新一份」:
| 表(逻辑名) | 用途 | 关键列 |
| --- | --- | --- |
| `of_nodes`(或现节点表) | 在线、版本、IP | `last_seen_at`, `openresty_status`, `openresty_message`, `agent_version` |
| `of_node_system_profiles` | profile upsert | hostname, cpu_cores, total_memory_bytes, ... |
| 健康事件表 | `health_events` | event_type, severity, message, triggered_at |
> 具体物理表名以仓库现有 GORM 模型为准;本设计不强制改名,只强制 **不再把业务吞吐写进节点表**。
### 5.7 废弃表(停止写入 → TTL 后删除)
| 表 | 原因 | 替代 |
| --- | --- | --- |
| `of_node_request_reports` | Agent 预聚合 | `of_node_access_logs` + hourly |
| `of_node_traffic_hourly` + MV | 依赖 request_reports | `of_access_log_hourly` |
| `of_node_obs_openresty` | 含业务 rx/tx | `of_node_edge_health` |
| `of_node_openresty_hourly` + MV | 业务吞吐差分 | `of_access_log_hourly` 的 bytes_* |
Relay 专用 `of_node_obs_frps` / `of_node_obs_frpc` **保留**(非本 Agent 主路径,但同属 CH 观测)。
---
## 6. 表与协议对照总表
| 产品概念 | 协议字段 | 表.列 | 聚合 |
| --- | --- | --- | --- |
| 已提供数据 | `access_logs[].bytes_sent` | `of_node_access_logs.bytes_sent` | `sum` |
| 接收数据 | `access_logs[].request_length` | `...request_length` | `sum` |
| 请求数 | 行数 | — | `count` |
| UV(窗口总) | `remote_addr` | 同左明细 | `uniqExact`(**禁止** sum 小时 UV) |
| Top 域名 | `host` | 同左 | `group by` |
| 状态码分布 | `status_code` | 同左 | `group by` |
| 来源地区 | — | `region`(Server) | `group by` |
| 宿主机网卡出站 | `host_metrics.network_tx_bytes` | `of_node_metric_snapshots.network_tx_bytes` | 时间序差分 |
| 宿主机网卡入站 | `network_rx_bytes` | 同左 | 差分 |
| 磁盘读/写 | `disk_*_bytes` | 同左 | 差分 |
| CPU/内存 | 瞬时字段 | 同左 | avg |
| OpenResty 连接 | `edge_health.connections` | `of_node_edge_health.connections` | 最新/avg |
| OpenResty 健康 | `edge_health.status` | 节点表 + 可选 CH | 最新 |
**不再存在的映射:**
| 旧概念 | 旧字段 | 处置 |
| --- | --- | --- |
| OpenResty 出站 | `openresty_tx_bytes` | 删除;用已提供数据 |
| OpenResty 入站 | `openresty_rx_bytes` | 删除;用接收数据 |
| 窗口请求报告 | `traffic_report` | 删除 |
---
## 7. OpenResty 日志格式(与明细对齐)
目标 `log_format`(保证 `bytes_sent` 键 = body;含 UA 与缓存状态):
```nginx
log_format openflare_json escape=json
'{"ts":"$time_iso8601","host":"$host","path":"$request_uri",'
'"remote_addr":"$remote_addr","status":$status,'
'"request_time":$request_time,'
'"bytes_sent":$body_bytes_sent,"request_length":$request_length,'
'"user_agent":"$http_user_agent",'
'"cache_status":"$upstream_cache_status"}';
```
Agent 解析:
* `ts` → `logged_at_unix`
* `bytes_sent` → 协议 `bytes_sent`(已提供)
* `request_length` → 协议 `request_length`
* `request_time` → 可选 `request_time_ms = round(sec * 1000)`
* `user_agent` → 协议 `user_agent`
* `cache_status` → 协议 `cache_status`(原样透传,不做三态压缩)
---
## 8. 升级策略(无兼容层)
| 项 | 策略 |
| --- | --- |
| Agent 升级 | **销毁重建**优先;允许**二进制替换** |
| 协议 | 仅 schema v2 字段;旧 JSON 字段不解析 |
| 本地观测缓冲 | 若仍是旧格式(含 `snapshot` / `openresty_observation` / `traffic_report`)或损坏 → **整文件删除**,运行中重建 |
| 读路径 | 业务 API **只读** access_logs(及 hourly);健康当前态读 PG;连接时序读 CH edge_health |
| 旧 Agent | 必须升级;控制面不提供 v1 双读路径 |
---
## 9. 示例:一次心跳的落库结果
**Agent 上报(节选):**
```json
{
"schema_version": 2,
"node_id": "n1",
"host_metrics": {
"captured_at_unix": 1720000000,
"cpu_usage_percent": 10,
"memory_used_bytes": 1,
"memory_total_bytes": 2,
"storage_used_bytes": 3,
"storage_total_bytes": 4,
"disk_read_bytes": 100,
"disk_write_bytes": 200,
"network_rx_bytes": 1000,
"network_tx_bytes": 2000
},
"edge_health": {
"captured_at_unix": 1720000000,
"status": "healthy",
"message": "",
"connections": 5
},
"access_logs": [
{
"logged_at_unix": 1720000001,
"remote_addr": "1.1.1.1",
"host": "a.example.com",
"path": "/",
"status_code": 200,
"bytes_sent": 500,
"request_length": 80
}
]
}
```
**写入:**
1. PG 节点最新态:`openresty_status` / `openresty_message`(若上报)
2. `of_node_metric_snapshots` 1 行(network_tx=2000 累计)
3. `of_node_edge_health` 1 行(status + connections=5;**无 message**)
4. `of_node_access_logs` 1 行(bytes_sent=500, request_length=80, region=Server 填充)
5. MV 异步计入 `of_access_log_hourly`
**查询 24h 已提供数据:** `sum(bytes_sent)` → 至少 500(加历史)
**查询宿主机出站:** 对 snapshots 差分,与 500 **无强制相等关系**。
---
## 10. 实现检查清单
- [x] `pkg/protocol`:仅 v2 字段,无兼容别名
- [x] Agent:只组 `host_metrics` / `edge_health` / `access_logs` / `buffered`
- [x] Server:无 request_reports / openresty 吞吐;健康当前态 PG、时序 CH
- [x] CH migration:`request_length`、`request_time_ms`、`of_node_edge_health`、`of_access_log_hourly`、hourly 回填
- [x] 看板/Zone API 统一读 access log 聚合
- [x] UV:整窗 uniqExact;Zone 曲线标明分桶 UV;小时趋势不绘 UV
---
## 11. 修订记录
| 日期 | 说明 |
| --- | --- |
| 2026-07-17 | 初稿:协议 v2、Server 落库流水线、CH/关系库目标表结构与废弃表清单 |
-585
View File
@@ -1,585 +0,0 @@
# 边缘可观测与业务流量统计重构设计
你会学到:当前观测链路为何出现「看板 OpenResty 出站」与「Zone 已提供数据」不一致、字段与聚合为何冗余,以及目标架构如何让 **Agent 只上报事实、Server 只解释事实**,业务流量以访问日志为唯一真相源。
---
## 1. 目标
### 1.1 要解决的问题
1. **双真相源**:业务吞吐同时来自访问日志聚合与 OpenResty 观测差分,数值长期对不上。
2. **Agent 越权计算**:边缘预聚合 `TrafficReport`、吞吐累计,控制面再聚合一遍,语义难演进、难对账。
3. **字段语义重叠**:「OpenResty 出站」与「已提供数据」对用户是同一业务问题,系统却用两套字段、两条管道。
4. **瞬时与累计混用**:60 秒窗口计数被当成进程累计做 24h 差分,造成严重偏低。
5. **UI 诱导错误对比**:看板与 Zone 页使用相近「流量/数据」文案,却未声明范围与口径差异。
### 1.2 重构目标
| 目标 | 说明 |
| --- | --- |
| **单一业务真相** | 请求数、已提供数据、UV、状态码分布、Top 域名等 **只** 从访问日志(及其 Server 侧派生汇总)得出 |
| **Agent 只上报事实** | 明细日志 + 机器读数 + 健康瞬时态;**禁止** 业务 UV/TopN/24h 总量等预聚合 |
| **字段收敛** | 一个业务概念对应一个权威字段;机器网卡与业务交付严格分名 |
| **可对账** | 全局「已提供数据」≈ 各 Zone「已提供数据」之和(差仅为未绑定/未知 Host) |
| **可演进** | 改时间窗、TopN、归属规则只改 Server,不升 Agent |
### 1.3 非目标(本设计不覆盖)
* 建成通用日志平台、全量日志长期归档或检索产品。
* 替换 ClickHouse / 取消分析库依赖。
* 改造 Relay / OpenFlared 的主机指标采集(可对齐原则,但不在本轮协议主路径)。
* 实时流式告警引擎、APM 链路追踪(OpenTelemetry 服务端已有,与本业务流量模型正交)。
---
## 2. 范围与约束
### 2.1 产品约束(继承)
* 单租户、全局单激活配置;观测不引入多租户计费隔离。
* ClickHouse 为访问日志与时序观测的强制分析存储。
* Agent 无入向控制、Pull 模型;离线期间本地 OpenResty 继续服务,观测可本地缓冲后补传。
### 2.2 工程约束
* Agent 保持轻量:解析日志行、读 `/proc`、健康检查;不做业务分析。
* 控制面 API 错误仍走统一信封与 `response.Abort*`。
* 访问日志字段变更须同时更新 OpenResty `log_format` 与 Agent 解析器;Agent 与控制面同版本发布,不保留旧协议解析。
---
## 3. 设计原则
### 原则 P1:Agent 上报事实,Server 解释事实
```text
Agent = 采集 + 可靠投递(原始/近原始)
Server = 入库 + 聚合 + 归属 + 趋势 + 对账
```
**允许的边缘处理(采集)**
* 将 JSON access.log 行解析为结构化字段
* path 长度上限、丢弃非法行、跳过观测端口自身请求
* 读取网卡/CPU/内存等计数器 **原值**
* 批量、压缩、离线缓冲与重试
**禁止的边缘处理(业务计算)**
* UV / Top 域名 / 状态码直方图 / 窗口 request_count 作为权威指标
* 为看板单独维护「业务入出站累计」
* Zone / 域名归属统计、国家分布(国家可在 Server 入库时解析)
### 原则 P2:业务流量唯一真相 = 访问日志
| 业务问题 | 唯一答案 |
| --- | --- |
| 提供了多少数据 | `sum(bytes_sent)` |
| 多少请求 | `count()` |
| 多少独立访客 | `uniqExact(remote_addr)`(或产品约定哈希) |
| 状态码 / Top 域名 | 对日志 `group by` |
### 原则 P3:三层指标互不混用
| 层 | 名称 | 用途 | 典型字段 |
| --- | --- | --- | --- |
| L1 业务交付 | Business Traffic | 用户与 Zone 对账、看板业务趋势 | access log |
| L2 边缘健康 | Edge Health | OpenResty 是否活着、当前连接 | status、connections |
| L3 宿主机资源 | Host Capacity | 容量规划、机器是否打满 | CPU、内存、磁盘、**网卡** |
禁止将 L3 网卡或 L2 瞬时计数命名为「已提供数据」;禁止将 L1 与 L3 画在同一摘要卡片上却不标注语义。
### 原则 P4:一个业务概念一个字段
* **已提供数据** ≡ 响应体交付量 ≡ 历史文案中的「OpenResty 出站(业务含义)」→ **只保留 `bytes_sent` 聚合**
* **接收数据**(可选)≡ 请求侧体量 → 日志 `request_length` 聚合
* **宿主机出站** ≡ `network_tx` 差分,文案必须含「宿主机/网卡」
---
## 4. 现状问题(基线)
### 4.1 当前数据流(冗余)
```text
一次 HTTP 请求
│
├─ access.log 一行
│ → Agent tail → AccessLogs[]
│ → CH of_node_access_logs
│ → Zone「已提供数据」✅
│
├─ Lua shared dict 窗口/累计计数
│ → /openflare/observability
│ → TrafficReport + OpenrestyObservation(rx/tx)
│ → CH request_reports / obs_openresty
│ → 看板「OpenResty 入/出站」❌ 易与 Zone 不一致
│
├─ access.log 二次汇总(观测 endpoint 失败时回退)
│ → 又一份 TrafficReport / 吞吐
│
└─ 宿主机 network_rx/tx
→ Snapshot → 网络趋势中的「主机」曲线
```
### 4.2 字段重叠
| 用户感知 | 系统字段 A | 系统字段 B | 问题 |
| --- | --- | --- | --- |
| 出站 / 已提供 | `openresty_tx_bytes` | `bytes_sent` | 业务语义重复 |
| 入站 | `openresty_rx_bytes` | `request_length`(日志) | 业务语义重复 |
| 请求数 | `TrafficReport.request_count` | `count(access_logs)` | 聚合重复且窗口易重计 |
| 出站(机器) | `network_tx_bytes` | (无业务对应) | 应单独命名,勿与业务对账 |
### 4.3 典型故障模式
1. 窗口计数被当累计差分 → 24h 业务吞吐严重偏低。
2. 小时 rollup `max−min` 对重置型计数失效。
3. Zone 用日志、看板用观测 → 用户认为系统算错。
4. 改口径需同步改 Lua、Agent 状态累计、Server 差分、前端文案。
---
## 5. 目标架构
### 5.1 目标数据流
```mermaid
flowchart TB
subgraph edge [边缘节点]
OR[OpenResty]
LOG[access.log]
PROC[主机 /proc 与磁盘]
STUB[stub_status 连接数]
AG[Agent]
OR -->|log_format 写行| LOG
LOG -->|仅 tail 增量明细| AG
PROC -->|读数快照| AG
STUB -->|瞬时连接| AG
OR -->|健康探测| AG
end
subgraph server [控制面 Server]
HB[心跳 / WS 接收]
CH[(ClickHouse)]
AGG[聚合查询层]
API[管理端 API]
HB --> CH
CH --> AGG
AGG --> API
end
subgraph ui [管理端]
DASH[看板:全局业务趋势]
ZONE[Zone:按域名过滤]
NODE[节点:主机资源 + 健康]
end
AG -->|AccessLogs + HostSnapshot + Health| HB
API --> DASH
API --> ZONE
API --> NODE
```
### 5.2 职责矩阵
| 能力 | Agent | Server | 前端 |
| --- | --- | --- | --- |
| 写 access.log | OpenResty | — | — |
| 读并上报明细 | ✅ | 入库 | — |
| sum/count/uniq/TopN | ❌ | ✅ | 展示 |
| Zone 域名过滤 | ❌ | ✅ | 选择 Zone |
| 主机 CPU/内存/网卡 | 读原值上报 | 差分/平均 | 节点/看板资源区 |
| OpenResty 连接数 | 读瞬时上报 | 最近值 | 节点健康 |
| 业务 24h 入出站 | ❌ | 日志聚合 | 统一称「已提供/接收数据」 |
---
## 6. 指标与字段模型
### 6.1 权威字段表(目标)
#### L1 业务交付(来自访问日志)
| 概念 | 存储字段 | 聚合 | 展示名 |
| --- | --- | --- | --- |
| 请求时间 | `logged_at` | 时间窗过滤 | — |
| 节点 | `node_id` | group | — |
| 客户端 IP | `remote_addr` | `uniq` → UV | 唯一访问者 |
| Host | `host` | group / Zone 映射 | 域名 |
| 路径 | `path` | 可选 | — |
| 状态码 | `status_code` | group | 状态码分布 |
| **已提供数据** | **`bytes_sent`** | **`sum`** | **已提供数据** |
| **接收数据** | **`request_length`** | **`sum`** | **接收数据**(可选展示) |
| 地区 | `region`(Server 解析写入) | group | 来源地区 |
> 说明:OpenResty `log_format` 中 JSON 键名可继续叫 `bytes_sent`,值必须来自 **`$body_bytes_sent`**(与现网一致),表示响应体交付量,即「已提供数据」。
#### L2 边缘健康(瞬时,不做 24h 业务总量)
| 概念 | 字段 | 说明 |
| --- | --- | --- |
| OpenResty 健康 | `openresty_status` / message | 已有 |
| 当前连接 | `openresty_connections` | stub_status |
| (可选)近窗 QPS 粗估 | 仅节点详情「此刻」,**不得**作为 24h 总量权威 | 若实现须标明「瞬时」 |
#### L3 宿主机资源
| 概念 | 字段 | 展示名 |
| --- | --- | --- |
| CPU / 内存 / 磁盘占用 | `host_metrics` | 保持 |
| 网卡累计字节 | `network_rx_bytes` / `network_tx_bytes` | **宿主机网卡入/出站** |
| 磁盘 IO 累计 | `disk_read_bytes` / `disk_write_bytes` | 磁盘读/写 |
### 6.2 已删除字段(无兼容层)
| 原字段 | 处置 | 原因 |
| --- | --- | --- |
| `openresty_tx_bytes` / `openresty_rx_bytes` | **删除** | 业务字节以 access log 为准 |
| `TrafficReport` 及 TopN/窗内 UV | **删除** | 边缘预聚合 |
| Agent state 内业务 lifetime 累计 | 删除 | 违背 P1 |
| Lua shared dict 业务吞吐/窗口请求计数 | 删除 | 非投递主路径 |
### 6.3 命名对照(前端文案强制)
| 禁止混用文案 | 正确文案 | 数据来源 |
| --- | --- | --- |
| OpenResty 出站(指业务量) | **已提供数据** | `sum(bytes_sent)` |
| OpenResty 入站(指业务量) | **接收数据** | `sum(request_length)` |
| 网络出站(未说明) | **宿主机网卡出站** | `network_tx` 差分 |
| 已提供数据 vs 出站 两套卡片 | **只保留一套业务卡片** | 日志 |
---
## 7. Agent 设计
### 7.1 心跳载荷(目标协议)
保留并强化:
```text
NodePayload
identity / version / openresty_status / openresty_message # 最新态 → PG
profile # 主机概况(低频)
host_metrics # L3 资源读数(含网卡累计原值)
edge_health # L2:status + connections(CH 时序;message 不进 CH)
access_logs[] # L1 明细(主路径)
health_events[]
buffered[] # 缓冲的是上述事实,不是报表
waf_ip_group_checksums
```
协议中已删除(无兼容层):
```text
traffic_report
openresty_observation
snapshot / buffered_observability 别名
```
### 7.2 Access log 上报要求
每条明细至少包含:
| 字段 | 必填 | 备注 |
| --- | --- | --- |
| `logged_at_unix` | ✅ | 请求完成时间 |
| `remote_addr` | ✅ | UV |
| `host` | ✅ | Zone 映射 |
| `path` | ✅ | 可截断 |
| `status_code` | ✅ | |
| `bytes_sent` | ✅ | body 字节,已提供数据 |
| `request_length` | ✅ | 接收数据 |
Agent 职责:
1. 按 offset tail `access.log`(截断/轮转时重置 offset,**只上报文件中仍存在的新行**)。
2. 结构化解析后批量放入心跳 / WS。
3. 离线写入本地 buffer,连通后按窗口补传。
4. **不对明细做 sum/count/uniq。**
### 7.3 主机 Snapshot
* 继续上报网卡/磁盘 **累计计数器原值**(非业务预聚合)。
* Server 侧对累计值做相邻采样非负差分 → 宿主机趋势。
* 这与「已提供数据」无关,UI 必须分区展示。
### 7.4 OpenResty 本地观测
**收敛后建议:**
* 保留:健康检查、`stub_status` 当前连接。
* 删除主路径依赖:`log.lua` 中对 request/status/domain/rx/tx 的 shared dict 业务计数,以及 `/openflare/observability` 作为 TrafficReport 来源。
* 若短期内保留 endpoint 供调试,不得再写入 Server 权威分析表。
### 7.5 与 Agent 设计文档的关系
本设计强化 [Agent 与发布模型](./agent-design.md) 中的「纯粹数据落地」:
* 配置与证书:落地与上报应用状态。
* 观测:只搬运事实,不搬运业务结论。
---
## 8. Server 设计
### 8.1 入库
| 输入 | 表 | 说明 |
| --- | --- | --- |
| `access_logs[]` | `of_node_access_logs` | 权威业务明细 |
| `host_metrics` | `of_node_metric_snapshots` | L3;网卡/磁盘累计 |
| `openresty_status` / `openresty_message` | **PG 节点表** | L2 **最新态权威**(message 仅此) |
| `edge_health` | `of_node_edge_health` | L2 时序:status + connections(**无 message**) |
GeoIP:继续在 Server 入库路径解析 `remote_addr` → `region`,不在 Agent 做。
### 8.2 聚合层(统一)
所有业务趋势与 Zone 统计共用同一查询语义:
```text
过滤:logged_at ∈ [since, until]
可选:node_id / host IN (...)
指标:
request_count = count()
unique_visitors = uniqExact(remote_addr)
bytes_provided = sum(bytes_sent) -- 已提供数据
bytes_received = sum(request_length) -- 接收数据
按 hour/bucket 折叠 series
按 status_code / host / region 分布
```
实现位置:
* Zone:`GET .../zones/:id/stats`(已有,对齐字段命名)
* 看板:overview 的 traffic / 业务网络趋势 **改为调用同一聚合**(全局、无 host 过滤或 Top 过滤)
* 节点详情:业务量 = 该 `node_id` 过滤的同一聚合;主机网卡仍走 metric 差分
### 8.3 派生汇总(可选性能路径)
当明细查询在 24h 全量节点上过重时,允许 **Server 侧** 物化视图:
```text
of_access_log_hourly
(hour, node_id, host, request_count, bytes_sent, bytes_received, ...)
```
约束:
* 仅由 CH 从 `of_node_access_logs` 派生,**禁止** Agent 直接写该表。
* Zone / 看板优先读 rollup,缺口回退明细(与现有 metric hourly 策略类似)。
### 8.4 停用的分析路径
| 路径 | 迁移后 |
| --- | --- |
| `BuildNetworkTrendPoints` 对 openresty_rx/tx 差分 | 删除或仅保留 network_* 主机曲线 |
| `of_node_obs_openresty` 吞吐字段 | 停止写入;TTL 过期后删表或缩列 |
| `of_node_request_reports` + traffic hourly | 业务趋势不再依赖;可整表废弃 |
| Dashboard compact 中 openresty_tx 序列 | 改为 bytes_provided 序列 |
---
## 9. API 与前端
### 9.1 语义统一的响应字段
建议在业务统计 API 中统一使用:
```json
{
"request_count": 0,
"unique_visitors": 0,
"bytes_provided": 0,
"bytes_received": 0,
"series": [
{
"bucket_started_at": "...",
"request_count": 0,
"unique_visitors": 0,
"bytes_provided": 0,
"bytes_received": 0
}
]
}
```
API 业务字节字段使用 `bytes_provided` / `bytes_received`(访问日志聚合);不再返回 openresty 吞吐别名。
### 9.2 看板
* **业务区**:请求趋势、已提供数据、接收数据(可选)、状态码、Top 域名、来源地区 —— 全部 L1。
* **资源区**:CPU/内存、**宿主机网卡**、磁盘 IO —— 全部 L3。
* **禁止**:在业务区展示「OpenResty 入/出站」作为与 Zone 对账的指标。
「24 小时网络与磁盘趋势」建议拆分或改标题:
* 「24 小时业务流量」→ `bytes_provided` / `bytes_received` / 请求
* 「24 小时宿主机网络与磁盘」→ `network_*` / `disk_*`
### 9.3 Zone `/websites/:id`
* 保持「已提供的数据总计」等卡片。
* 数据与看板业务区 **同一聚合函数**,仅 `hosts = zone 域名列表`。
* 文档与 UI 可注明:全局看板含全部 Host;本页仅本 Zone。
### 9.4 节点详情
* 业务吞吐:该节点 `sum(bytes_sent)` 等。
* OpenResty:健康 + 当前连接。
* 网卡:明确「宿主机」。
---
## 10. OpenResty 与日志格式
### 10.1 保持
现有 JSON `log_format` 核心字段:
```text
ts, host, path, remote_addr, status, request_time,
bytes_sent (= $body_bytes_sent), request_length
```
### 10.2 变更
* 不再依赖 log phase 写入业务 shared dict 计数作为控制面输入。
* 观测端口请求继续不写业务统计(或 access_log off)。
### 10.3 Agent 解析
* 协议 `NodeAccessLog` 增加 `request_length`。
* 旧日志行缺字段时按 0,不阻断整批。
---
## 11. 升级与迁移(无兼容层)
### 11.1 阶段回顾(已落地)
| 阶段 | 内容 |
| --- | --- |
| **M1–M5** | 读路径切 access log;协议 v2;停预聚合;edge_health + access_log_hourly;删旧表与 API 兼容字段 |
### 11.2 升级策略
* **Agent:销毁重建优先**;允许二进制替换。
* 二进制替换时:本地旧观测缓冲(含 `snapshot` / `openresty_observation` / `traffic_report`)**整文件删除**,运行后重建。
* Server **不**解析 v1 字段,**不**双读 request_reports / openresty 吞吐。
* 明细缺失时段:业务图为空或仅部分;**不得**用网卡或已删除的 openresty 吞吐冒充已提供数据。
### 11.3 数据回填
* 历史「已提供数据」以 access log 为准。
* `of_access_log_hourly` 创建前历史用 goose 回填 SQL(ANTI JOIN 防重)。
### 11.4 健康状态权威
* **当前态**:PG `openresty_status` / `openresty_message`。
* **时序**:CH `of_node_edge_health`(status + connections;无 message)。
### 11.5 UV
* **整窗独立访客**:`uniqExact(remote_addr)`(看板合计、Zone 合计)。
* **分桶 UV**(Zone 曲线):桶内 uniq,**不可跨桶相加**;UI 须标明。
* **小时趋势路径**:不绘 / 不填分时 UV(hourly 表不含 UV)。
---
## 12. 存储与容量
* 业务趋势依赖明细或 hourly rollup,需关注 `of_node_access_logs` TTL 与采样。
* 若明细量过大:优先 **Server 侧 rollup**,而不是恢复 Agent 预聚合。
* 可对 path 高基数场景限制明细 path 长度(已有),聚合默认不按完整 path 做全局 Top。
---
## 13. 验证标准
### 13.1 对账
在仅有单一 Zone 产生流量的环境:
```text
看板「已提供数据」(24h) ≈ Zone「已提供的数据总计」(24h)
误差仅来自时间窗对齐(整点截断)与未计入 Host
```
多 Zone 时:
```text
sum(各 Zone 已提供) + sum(未归属 Host) = 全局已提供
```
### 13.2 回归
* Agent 单测:只解析与 offset,不出现业务 sum 断言为「上报契约」。
* Server:Zone stats 与 dashboard business traffic 共用聚合测例。
* 前端:文案快照/测试中不再出现业务含义的「OpenResty 出站」与「已提供数据」双卡片。
### 13.3 性能
* 24h 看板聚合 P95 可接受(必要时 hourly MV)。
* 心跳 payload 体积:明细批量有上限;超限拆缓冲,不在 Agent 做摘要替代。
---
## 14. 风险与权衡
| 风险 | 缓解 |
| --- | --- |
| 明细量大导致 CH 与心跳变重 | 批量、压缩、采样策略评估;Server rollup;限制单次条数 |
| 短暂丢失日志导致业务量偏低 | 本地 buffer 与轮转处理;监控 access log 采集滞后 |
| 用户仍对比「网卡出站」与「已提供」 | UI 分区与文案强制「宿主机」前缀 |
| 旧 Agent 长期在线 | **无兼容层**;必须升级/重建 Agent |
**为何不保留 Agent 预聚合作为优化?**
* 省带宽的代价是再次分裂真相、口径漂移、本次问题重演。
* 优化应落在 Server 派生表与查询,而不是边缘业务计算。
---
## 15. 关键决策摘要
| 决策 | 选择 | 否决方案 |
| --- | --- | --- |
| 业务流量真相 | 访问日志 | OpenResty dict / TrafficReport |
| Agent 角色 | 只上报事实 | 边缘 UV/TopN/吞吐累计 |
| 「出站」与「已提供」 | 合并为已提供数据 | 双字段双管道长期并存 |
| 网卡流量 | 独立 L3,单独文案 | 与业务出站并列对账 |
| 性能 | CH rollup | Agent 预聚合 |
| 迁移 | 先切读路径再瘦身 Agent | 先删明细依赖预聚合 |
---
## 16. 文档与代码映射(落地时)
| 区域 | 主要路径 |
| --- | --- |
| 协议 | `pkg/protocol/agent.go` |
| Agent 采集 | `internal/apps/agent/observability/`、`heartbeat/` |
| OpenResty 日志与 Lua | `pkg/render/openresty/`、`internal/apps/agent/nginx/observability_assets.go` |
| Server 入库 | `internal/apps/openflare/agent/observability.go` |
| 日志聚合 | `internal/repository/analytics/node_access_log*.go`、`internal/apps/openflare/zone/stats.go` |
| 看板 | `internal/apps/openflare/dashboard/`、`internal/apps/openflare/observability/analytics.go` |
| 前端 | `frontend/app/(main)/page.tsx`、`components/dashboard/*`、`websites/.../zone-overview.tsx` |
实现计划见:`docs/plan/20260717-observability-redesign.md`。
**推荐阅读顺序:**
1. **[观测数据传输模型](./observability-transport-model.md)**(最新:传什么、从哪采、频率、示例 JSON)
2. [Agent 上报协议与观测落库数据模型](./observability-data-model.md)(协议字段与 DDL)
---
## 17. 修订记录
| 日期 | 说明 |
| --- | --- |
| 2026-07-17 | 初稿:针对双真相、Agent 预聚合、字段冗余给出目标架构与迁移阶段 |
| 2026-07-17 | 增补协议/表结构专章链接 `observability-data-model.md` |
@@ -1,503 +0,0 @@
# 边缘观测数据传输模型(现行目标版)
> **本文是「Agent ↔ Server 观测数据怎么传」的最新权威说明。**
> 读完应能回答:传什么、从哪采、多久采一次、Server 怎么存、产品指标从哪查。
> 协议字段与 DDL 细节另见 [观测上报协议与表结构](./observability-data-model.md);问题背景见 [边缘可观测与业务流量统计](./observability-design.md)。
---
## 0. 先记住三层(不要混)
| 层 | 回答的问题 | 唯一数据来源 | 产品例子 |
| --- | --- | --- | --- |
| **L1 业务交付** | 提供了多少数据?多少请求? | **access.log 明细** | 已提供数据、请求数、UV、状态码、Top 域名 |
| **L2 边缘健康** | OpenResty 活着吗?现在多少连接? | **本机 `/openflare/observability`** | 节点健康、当前连接 |
| **L3 宿主机资源** | CPU/内存/磁盘/网卡怎样? | **操作系统读数** | 容量趋势、宿主机网卡 |
**三层互不对账。**
「已提供数据」≠「当前连接」≠「宿主机网卡出站」。
---
## 1. 总览:谁采集、谁上报、谁聚合
```text
┌─────────────────────────────────────────────────────────────┐
│ 边缘节点 │
│ │
│ 访客请求 ──► OpenResty │
│ │ │
│ ├─ access.log(每请求一行) ←── L1 采集点 │
│ │ │
│ └─ 连接状态(进程内维护) │
│ │ │
│ ▼ │
│ GET /openflare/observability ←── L2 读快照 │
│ (不扫日志、不重算业务量) │
│ │
│ 操作系统 /proc 等 ────────────────────── L3 读快照 │
│ │
│ ┌────────── Agent ──────────┐ │
│ │ 默认每 3s 组一包 NodePayload │ │
│ │ · tail access.log 增量 │ │
│ │ · GET 本机 observability │ │
│ │ · 读 host_metrics │ │
│ └────────────┬──────────────┘ │
└─────────────────────────────│──────────────────────────────────┘
│ HTTP 心跳 或 WebSocket status
▼
┌─────────────────────────────────────────────────────────────┐
│ Server(控制面) │
│ · 明细 → ClickHouse of_node_access_logs │
│ · 健康 → 节点最新态 + of_node_edge_health │
│ · 主机 → of_node_metric_snapshots │
│ · 业务趋势 / Zone 统计 = 只对 access_logs 做 sum/count/uniq │
└─────────────────────────────────────────────────────────────┘
```
| 角色 | 做什么 | 不做什么 |
| --- | --- | --- |
| OpenResty | 写 access.log;维护连接数 | 不向控制面直接上报 |
| Agent | **采集事实并上报** | **不算** UV/TopN/24h 已提供数据 |
| Server | 入库 + **聚合解释** | 不信任边缘业务预汇总 |
---
## 2. 采集频率(默认)
| 动作 | 默认频率 | 配置 |
| --- | --- | --- |
| Agent → Server 上报 | **每 3 秒** 一次完整 payload | `heartbeat_interval` / 控制面 `agent_heartbeat_interval`(毫秒,默认 `3000`) |
| 组包时 tail access.log | **随上报**(两次上报之间的新行) | 同上 |
| 组包时 GET `/openflare/observability` | **随上报**(读**当前**连接快照) | 同上 |
| 组包时读主机指标 | **随上报** | 同上 |
| OpenResty 写 access.log | **每个请求结束时** 1 行 | 与心跳无关 |
| 连接数在进程内更新 | **连接变化时**(内核维护) | 与心跳无关 |
| 离线补传窗口 | 默认保留约 **60 分钟** | `observability_replay_minutes` |
| 节点离线判定 | 约 **60 秒** 无成功心跳 | `node_offline_threshold`(默认 `60000` 毫秒) |
**说明:**
- Agent **没有**单独的「采样时钟」;**采样点 = 上报点**(默认 3s)。
- access.log 是「请求级连续写入」;Agent 只是周期性 **搬运增量行**。
- `/openflare/observability` **不是**「被调用才开始统计业务」;对连接而言是 **读 Nginx 已有瞬时值**。
传输通道:
- **HTTP 心跳**:按间隔 POST 整包。
- **WebSocket**:连通后按同一间隔发 `status` 消息(内容同构);此时不再走 HTTP 心跳双发。
---
## 3. Agent → Server 数据包(NodePayload v2)
### 3.1 结构骨架
```json
{
"schema_version": 2,
"node_id": "n_01hxyz",
"name": "edge-shanghai-1",
"ip": "203.0.113.10",
"version": "3.4.0",
"ext_version": "",
"current_version": "20260718-abc",
"last_error": "",
"profile": { },
"host_metrics": { },
"edge_health": { },
"access_logs": [ ],
"buffered": [ ],
"health_events": [ ],
"waf_ip_group_checksums": { }
}
```
| 字段 | 层 | 含义 |
| --- | --- | --- |
| 身份/版本/last_error | 控制 | 节点是谁、跑什么版本 |
| `profile` | 低频概况 | 主机名、核数等(变化才报) |
| `access_logs` | **L1** | 访问明细增量 |
| `edge_health` | **L2** | OpenResty 健康 + 当前连接 |
| `host_metrics` | **L3** | CPU/内存/磁盘/网卡读数 |
| `buffered` | 补传 | 离线期间攒的事实批次 |
| `health_events` | 事件 | 如 openresty_unhealthy |
| `waf_ip_group_checksums` | 同步 | 非观测湖 |
**协议已删除(无兼容层,旧 Agent 必须升级):**
- `traffic_report`
- `openresty_observation`(含 rx/tx)
- `snapshot` / `buffered_observability`
- 业务含义的 openresty 吞吐字段
---
## 4. L1 业务:access_logs
### 4.1 采集从哪里来
| 步骤 | 位置 | 说明 |
| --- | --- | --- |
| 1 | OpenResty `log_format openflare_json` | 每请求写一行 JSON 到 `access_log_path` |
| 2 | Agent 按文件 offset **tail 增量** | 两次心跳之间的新行 |
| 3 | 解析后放入 `access_logs[]` | 可截断过长 path;**不做 sum/count** |
日志格式(OpenResty 变量):
```text
ts ← $time_iso8601
host ← $host
path ← $request_uri
remote_addr ← $remote_addr
status ← $status
request_time ← $request_time
bytes_sent ← $body_bytes_sent 【已提供数据 = 响应体字节】
request_length← $request_length 【接收数据】
user_agent ← $http_user_agent
cache_status ← $upstream_cache_status 【缓存状态;UI 可推导命中/回源/未缓存】
```
观测端口请求 **不写** 业务 access.log(独立 server `access_log off`)。
### 4.2 上报示例
```json
"access_logs": [
{
"logged_at_unix": 1721289601,
"remote_addr": "198.51.100.20",
"host": "www.example.com",
"path": "/api/v1/ping",
"status_code": 200,
"bytes_sent": 1024,
"request_length": 128,
"request_time_ms": 15,
"user_agent": "curl/8.0",
"cache_status": "MISS"
},
{
"logged_at_unix": 1721289602,
"remote_addr": "198.51.100.21",
"host": "www.example.com",
"path": "/index.html",
"status_code": 200,
"bytes_sent": 8192,
"request_length": 300,
"request_time_ms": 8,
"user_agent": "Mozilla/5.0",
"cache_status": "HIT"
}
]
```
| 字段 | 解释 |
| --- | --- |
| `bytes_sent` | **已提供数据**(单请求);全局/Zone 合计 = Server `sum` |
| `request_length` | **接收数据**(单请求) |
| `logged_at_unix` | 请求完成时间(业务时间轴) |
| `host` | 用于 Zone 域名过滤 |
| `cache_status` | `$upstream_cache_status` 原样;详情/列表可推导三态(命中/回源/未缓存);**不上报** upstream 地址 |
| 无 `region` | **Server 入库时** GeoIP 写入 |
### 4.3 Server 如何用(产品指标)
| 产品指标 | 算法(仅 L1) |
| --- | --- |
| 已提供数据 | `sum(bytes_sent)` |
| 接收数据 | `sum(request_length)` |
| 请求数 | `count()` |
| UV | `uniqExact(remote_addr)` |
| 状态码分布 | `group by status_code` |
| Top 域名 | `group by host` |
| Zone 页 | 同上 + `host IN (该 Zone 域名)` |
| 看板业务区 | 同上,全局或 Top 过滤 |
落库表:`of_node_access_logs`(可选 Server 侧 `of_access_log_hourly` 加速,**Agent 不写**)。
### 4.4 频率再强调
```text
请求发生 ──立即──► 写 access.log
Agent 每 3s ──搬运──► 这 3s 内新行(可能 0 行,也可能很多行)
Server ──立即/批量──► CH
```
业务量正确性 **不依赖** 3s 对齐;3s 只影响「明细到达控制面的延迟」和单包条数。
---
## 5. L2 健康:edge_health 与 `/openflare/observability`
### 5.1 本机监测口(合并后目标)
**只保留一个接口:**
```http
GET http://127.0.0.1:{openresty_observability_port}/openflare/observability
```
默认端口:**18081**(`openresty_observability_port`)。
**职责:** 回答「OpenResty 此刻怎样」,**不**回答业务已提供多少数据。
#### 返回示例(目标 JSON)
```json
{
"ok": true,
"captured_at_unix": 1721289600,
"connections": {
"active": 42,
"reading": 0,
"writing": 1,
"waiting": 41
}
}
```
| 字段 | 是否瞬时 | 从哪来 | 说明 |
| --- | --- | --- | --- |
| `ok` | 当次探测 | 能返回 200 即 true | 探活 |
| `captured_at_unix` | 采样时刻 | `ngx.time()` | 与上报对齐 |
| `connections.active` | **瞬时** | Nginx 连接状态(原 stub_status Active) | 当前活跃连接 |
| `reading` / `writing` / `waiting` | **瞬时** | 同上细分 | 可选但建议带 |
**不返回(已从目标模型删除):**
| 旧字段 | 原因 |
| --- | --- |
| `request_count` / `error_count` / UV / status_codes / top_domains | 业务窗汇总,改由 access log |
| `openresty_rx_bytes` / `openresty_tx_bytes` | 与已提供/接收数据重复且易错 |
| `source_countries` | 从未实现;国家走 Server GeoIP |
| `server.accepts/handled/requests` | 进程累计 counter,易与业务请求混淆;主路径不收录 |
**`/openflare/stub_status`:** 合并进上述 JSON 后 **删除**(过渡期可双挂,Agent 只打合并口)。
### 5.2 采集机制(读快照,不是「调用才开始统计业务」)
```text
Nginx 在连接建立/释放时维护 Active connections 等
│
Agent GET /openflare/observability
│
只读取「当前值」拼 JSON 返回
```
- **不是** GET 一次才去扫 access.log。
- **不是** 60 秒业务均值。
- 是 **瞬时 gauge 快照**。
### 5.3 上报示例(装进 NodePayload)
```json
"edge_health": {
"captured_at_unix": 1721289600,
"status": "healthy",
"message": "",
"connections": 42
}
```
| 字段 | 来源 |
| --- | --- |
| `status` / `message` | Agent 健康探测(配置校验/进程等,可与观测口 `ok` 配合);须与顶层 `openresty_status` / `openresty_message` 对齐 |
| `connections` | 观测口 `connections.active` |
**落库拆分(权威源):**
| 内容 | 写入 |
| --- | --- |
| 最新 `status` + `message` | **PG 节点表**(UI / 列表 / 告警) |
| 时序 `status` + `connections` | **CH `of_node_edge_health`**(**无 message**) |
---
## 6. L3 主机:host_metrics
### 6.1 采集从哪里来
Agent 读本机(如 `/proc`、磁盘统计等),**每次组包时读一次**。
| 字段 | 语义 | 说明 |
| --- | --- | --- |
| `cpu_usage_percent` | 瞬时 | 当前 CPU% |
| `memory_*` / `storage_*` | 瞬时用量/总量 | 占用率在 Server 或展示层算 |
| `disk_read_bytes` / `disk_write_bytes` | **累计 counter** | 内核累计 IO |
| `network_rx_bytes` / `network_tx_bytes` | **累计 counter** | **宿主机网卡**,不是已提供数据 |
### 6.2 上报示例
```json
"host_metrics": {
"captured_at_unix": 1721289600,
"cpu_usage_percent": 12.5,
"memory_used_bytes": 4294967296,
"memory_total_bytes": 16106127360,
"storage_used_bytes": 50000000000,
"storage_total_bytes": 107374182400,
"disk_read_bytes": 9000000000,
"disk_write_bytes": 12000000000,
"network_rx_bytes": 500000000000,
"network_tx_bytes": 800000000000
}
```
### 6.3 Server 如何处理累计字段
```text
存原值时间序列
展示「这段时间网卡出站」时:
delta = 本次 - 上次
若 delta < 0 → 视为重启/计数器归零,本段增量记 0,从新基线继续
若 delta >= 0 → 记入该时段增量
```
- Agent **上报原值**,不在边缘算 24h 总量。
- **禁止** 对累计原值做 `sum` 当业务量。
- 文案必须是 **「宿主机网卡」**,禁止叫「已提供数据 / OpenResty 出站」。
落库:`of_node_metric_snapshots`(可选 capacity hourly MV)。
---
## 7. 一次完整上报示例(拼起来)
```json
{
"schema_version": 2,
"node_id": "n_01hxyz",
"name": "edge-shanghai-1",
"ip": "203.0.113.10",
"version": "3.4.0",
"ext_version": "",
"current_version": "20260718-abc",
"last_error": "",
"host_metrics": {
"captured_at_unix": 1721289600,
"cpu_usage_percent": 12.5,
"memory_used_bytes": 4294967296,
"memory_total_bytes": 16106127360,
"storage_used_bytes": 50000000000,
"storage_total_bytes": 107374182400,
"disk_read_bytes": 9000000000,
"disk_write_bytes": 12000000000,
"network_rx_bytes": 500000000000,
"network_tx_bytes": 800000000000
},
"edge_health": {
"captured_at_unix": 1721289600,
"status": "healthy",
"message": "",
"connections": 42
},
"access_logs": [
{
"logged_at_unix": 1721289595,
"remote_addr": "198.51.100.20",
"host": "www.example.com",
"path": "/",
"status_code": 200,
"bytes_sent": 4096,
"request_length": 200,
"request_time_ms": 12
}
],
"buffered": [],
"health_events": [],
"waf_ip_group_checksums": {
"1": "d41d8cd98f00b204e9800998ecf8427e"
}
}
```
**Server 落库示意:**
| payload 块 | 写入 |
| --- | --- |
| `access_logs[0]` | CH 一行,`bytes_sent=4096`,`region` 由 GeoIP 填 |
| `edge_health` | 节点 `openresty_status=healthy`,connections=42 |
| `host_metrics` | CH metric 一行累计/瞬时字段 |
**产品查询示意(24h):**
- 已提供数据 = 该节点(或全局)日志 `sum(bytes_sent)`
- 当前连接 = 最新 `edge_health.connections`
- 宿主机网卡出站 = metric 上 `network_tx` 非负差分之和
三者数字 **不必相等**。
---
## 8. 离线补传 `buffered`
Agent 上报失败时,把 **同一类事实** 按窗口缓存在本地(默认约 60 分钟),恢复后塞进 `buffered[]`:
```json
"buffered": [
{
"captured_at_unix": 1721289500,
"host_metrics": { },
"edge_health": { },
"access_logs": [ ]
}
]
```
- 只装事实,不装旧 TrafficReport。
- Server 处理逻辑与主字段相同。
---
## 9. 端到端时序(默认 3s)
```text
t=0.0s 访客请求完成 → 写 access.log 一行;连接数可能变化
t=0.1s 又一请求 → 又一行 log
…
t=3s Agent 心跳:
· 读走 2 行 access_logs
· GET observability → connections=42
· 读 host_metrics
· 发给 Server
t=3s+ Server 入库;看板/Zone 查询时聚合日志
t=6s 下一轮…
```
---
## 10. 旧模型对照(帮助消歧)
| 旧做法 | 新模型 |
| --- | --- |
| Lua dict 60s 窗 request_count + Agent 10s 拉 + Server sum | **删除**;请求数 = 日志 count |
| openresty_tx 当「出站」 | **删除**;已提供数据 = `sum(bytes_sent)` |
| 两个口 observability + stub_status | **合并为一个** observability,只返回连接/探活 |
| TrafficReport 预聚合 | **删除**;协议与 API 均无此路径 |
| 业务与网卡混称「流量」 | **分文案、分 API、分表** |
| 健康 status/message | **PG 最新态权威**;CH 仅 status+连接时序 |
---
## 11. 配置与实现索引
| 项 | 位置/键 |
| --- | --- |
| 心跳间隔 | Agent `heartbeat_interval`;控制面 `agent_heartbeat_interval`(默认 3000ms) |
| 离线阈值 | 控制面 `node_offline_threshold`(默认 60000ms) |
| 观测端口 | `openresty_observability_port`(默认 18081) |
| access.log 路径 | `access_log_path` |
| 补传分钟数 | `observability_replay_minutes`(默认 60) |
| 协议类型 | `pkg/protocol/agent.go`(落地时按 v2 演进) |
| 表结构 DDL | [observability-data-model.md](./observability-data-model.md) |
---
## 12. 修订记录
| 日期 | 说明 |
| --- | --- |
| 2026-07-18 | 初稿:作为「最新传输模型」单页说明——三层、频率、示例 JSON、采集来源、与旧模型对照 |
| 2026-07-18 | 默认上报间隔 3s;离线阈值 60s;补传窗口 60 分钟 |
| 2026-07-18 | M5:edge_health 表、access_log_hourly、废弃 request_reports/obs_openresty 吞吐表 |
| 2026-07-18 | 无兼容层:删除「兼容期可忽略」表述;健康 message 仅 PG、CH 无 message |
-239
View File
@@ -1,239 +0,0 @@
# 源站错误页设计
你会学到:源站或网关返回指定错误状态码时,OpenFlare 如何用全局可配置页面替代透传响应;配置如何进入不可变配置版本,以及边缘 OpenResty 如何保持真实 HTTP 状态码并在页面中展示该状态码。
本设计是 [系统架构](./architecture.md) 中反代流量路径的产品化补充;配置发布模型见 [Agent 与发布模型](./agent-design.md)。
---
## 1. 目标与非目标
### 1.1 目标
* **可拦截**:在用户配置的状态码集合上,用统一 HTML 替换原先透传的源站/Nginx 默认错误响应。
* **可关闭**:全局开关关闭后行为与现状一致(透传 / Nginx 默认页)。
* **默认可视**:默认启用,默认状态码标签 `500-599`,默认 OpenFlare 极简错误页。
* **可自定义**:管理员可在线编辑完整 HTML;空 HTML 表示使用内置默认模板。
* **状态码透传**:HTTP 响应 `status` 保持原错误码(如 502、522);页面正文通过 `{{status}}` 展示同一数值。
* **全局统一**:侧栏「网站管理 → 错误页」单一配置,全站反代路由共用。
* **与发布一致**:配置经 Option 持久化,进入配置版本快照后随发布/回滚下发。
### 1.2 非目标
* 按反代路由 / Zone 覆盖错误页
* 通过上传文件托管错误页(仅在线 HTML)
* 修改 WAF / PoW / 限流自有响应页(除非用户把对应状态码加入列表)
* Pages 静态路由错误页
* 多语言错误页、品牌资源 CDN
---
## 2. 产品行为
### 2.1 何时替换
| 条件 | 行为 |
| --- | --- |
| 开关开启,且响应状态码落在展开后的集合内 | 返回自定义/默认 HTML,**status 不变** |
| 开关关闭 | 不生成 `error_page` 相关指令,透传 |
| 状态码不在集合内 | 不替换 |
| Pages 上游路由 | 不应用本功能 |
| 源站成功返回 2xx/3xx/4xx(未配置时) | 不替换 |
实现上对反代 `location` 启用 `proxy_intercept_errors on`,因此**源站返回的**匹配 5xx 等也会被拦截,而不仅是网关本地生成的 502。
### 2.2 状态码标签语法
Tags Input 每条标签:
| 形式 | 示例 | 含义 |
| --- | --- | --- |
| 单码 | `522` | 仅该码 |
| 闭区间 | `500-599` | 含端点展开 |
* 合法范围:单码与区间两端均在 **400–599**;`lo ≤ hi`。
* 默认标签列表:`["500-599"]`。
* 持久化存**原始标签**(JSON 数组字符串);渲染时展开、去重、排序。
* 启用时展开结果为空 → 保存拒绝。
* 非法标签 → 保存拒绝并返回可读错误。
### 2.3 页面占位符
| 占位符 | 含义 |
| --- | --- |
| `{{status}}` | 当前响应状态码(与 HTTP status 一致) |
| `{{host}}` | 请求 Host |
自定义 HTML 与默认模板均支持上述占位符;运行时在边缘替换。未使用的占位符可不出现在模板中。
### 2.4 默认页
内置 OpenFlare 极简白底默认页:大号透传状态码、简短英文说明、Host 与品牌页脚。支持占位符 `{{status}}` / `{{host}}`;前端可在编辑页从内置模板目录加载预制风格。
---
## 3. 配置模型
### 3.1 Option keys(`w_system_configs` / OpenFlare Option API)
| Key | 类型 | 默认 | 说明 |
| --- | --- | --- | --- |
| `origin_error_page_enabled` | bool 字符串 | `true` | 总开关 |
| `origin_error_page_status_codes` | JSON 字符串数组 | `["500-599"]` | 原始标签 |
| `origin_error_page_html` | 文本 | `""` | 空 = 内置默认;最大 **256 KiB** |
API 复用:
* `GET /api/v1/d/option`
* `POST /api/v1/d/option/update-batch`
不新增独立资源路由。goose 迁移写入 seed;常量定义于 `internal/model` 配置 key 区。
### 3.2 校验(update-batch)
1. `enabled`:可解析为 bool。
2. `status_codes`:合法 JSON 数组;每项 `^\d{3}$` 或 `^\d{3}-\d{3}$`;展开后均在 400–599;启用时非空。
3. `html`:长度 ≤ 256 KiB(按字节);允许空。
4. 解析/展开逻辑为**纯函数**,供 API 与 `pkg/render/openresty` 共用,避免前后端/渲染语义分叉。
不对 HTML 做 XSS 消毒:属管理员全局运维配置,与边缘公开展示一致;文档提示勿嵌入不可信第三方脚本。
### 3.3 配置版本快照
`ConfigSnapshot` 增加字段:
```text
OriginErrorPageEnabled bool
OriginErrorPageStatusCodes []string // 原始标签
OriginErrorPageHTML string // 空则渲染器用内置默认
```
构建快照时从 Option 读取;Agent 只消费快照,不直读控制面 DB。
---
## 4. 边缘渲染
### 4.1 启用时生成内容
1. **SupportFile**:错误页模板(如 `error_pages/origin_error.html.tmpl`),内容为自定义 HTML 或内置默认,保留 `{{status}}` / `{{host}}`。
2. **每个反代 proxy server**(含 HTTP/HTTPS 反代;不含 Pages):
```nginx
proxy_intercept_errors on;
error_page <expanded codes...> = /__openflare_origin_error;
location = /__openflare_origin_error {
internal;
default_type text/html;
charset utf-8;
# 保持 ngx.status 为原错误码
# 读取模板,替换 {{status}} / {{host}} 后输出 body
}
```
### 4.2 运行时替换
采用 **internal location 内轻量 Lua(或现有 resty 能力)** 读模板并 `string.gsub` 替换占位符,**不**把 status 固化进静态文件(请求间状态码不同)。
禁止将错误页统一改为 HTTP 200。
### 4.3 关闭时
不输出 `proxy_intercept_errors`、`error_page`、内部 location 与对应 SupportFile(或文件可写但不被引用)。
### 4.4 与缓存 / stale
若全局 `proxy_cache_use_stale` 在部分错误码上返回过期缓存,**成功返回 stale 内容时不会进入 error_page**。仅当实际上对客户端产生配置列表内错误状态时才展示错误页。行为依赖现有缓存指令,本功能不改 stale 策略。
---
## 5. 前端
### 5.1 入口
* 侧栏「网站管理」新增:**错误页** → `/error-pages`
* 更新 `openflareWebsiteNavGroup`、`openflareWebsiteSubNav`(若使用)、全局搜索关键词
### 5.2 页面结构
* 页头说明:保存后需到「版本发布」发布才生效。
* **开关 + Tags Input**(shadcn-extension Tags Input:`@/components/ui/tags-input`):状态码标签。
* **HTML 编辑区** +「加载默认模板」「恢复默认(清空)」+ 占位符说明。
* **客户端预览**:用示例 `status=502`、`host=example.com` 替换后 sandbox/iframe 预览。
* 保存:`OptionService.updateBatch`;权限与性能调优页一致(管理员)。
### 5.3 组件依赖
若仓库尚无 Tags Input,按项目 shadcn 流程添加;样式与现有 UI 一致。
---
## 6. 数据流
```text
管理员 /error-pages
→ Option update-batch(校验标签与 HTML)
→ w_system_configs
发布配置版本
→ 快照写入 OriginErrorPage*
→ 渲染 OpenResty conf + SupportFile
→ Agent 拉取并 reload
访客请求反代域名
→ 源站/网关产生匹配状态码
→ error_page → internal location
→ 替换占位符,status 保持原码,返回 HTML
```
---
## 7. 测试与验收
### 7.1 自动化
* 状态码解析:单码、区间、去重、越界、反序、默认 `500-599`
* 渲染:enabled/disabled conf 片段;空 HTML 用默认;自定义进 SupportFile
* Option 校验:非法标签 / 超大 HTML → 4xx
### 7.2 手动
1. 默认配置:源站不可达 → CF 风格页,真实 502/504,页内数字一致
2. 源站返回 503 → 替换页,status 503
3. 仅标签 `522` → 仅 522 替换
4. 关闭开关并发布 → 透传恢复
5. 自定义 HTML 占位符预览与线上一致
6. Pages 路由不受影响
### 7.3 文档
* 本设计文档;`docs/design/index.md` 能力表;`docs/config.ts` 侧栏
* changelog `[Unreleased]` 用户可读改进条
---
## 8. 实现要点清单(供计划拆分)
1. goose seed 三个 Option key + model 常量
2. 状态码解析/校验纯函数 + 单测
3. Option update 路径挂接校验
4. 快照填充 `ConfigSnapshot` 新字段
5. `pkg/render/openresty`:error_page 块、SupportFile、默认 HTML、单测
6. Agent 侧若需 Lua 辅助文件,随现有 nginx lua 目录同步
7. 前端 Tags Input + `/error-pages` 页 + 导航
8. changelog 与设计索引
---
## 9. 决策记录
| 决策 | 选择 | 原因 |
| --- | --- | --- |
| 配置范围 | 全局 | 产品要求;实现与运维简单 |
| 存储 | Option + 配置版本 | 与性能调优一致,可回滚 |
| 状态码输入 | 标签:单码与区间 | 默认整段 5xx,又可点名 522 |
| 响应 status | 保持原码 | 监控/SEO/客户端语义正确 |
| 运行时替换 | internal + 轻量模板替换 | 每请求 status 不同 |
| 自定义方式 | 在线 HTML | 灵活且无需文件上传链路 |
`}
-259
View File
@@ -1,259 +0,0 @@
# Pages 静态托管设计文档
你会学到:OpenFlare Pages 静态站点托管的架构设计、不可变部署与安全解压流程、OpenResty 的静态服务与 API 反向代理配置渲染,以及控制面与 Agent 的协同工作流。
---
## 需求分析
在现代 Web 运维中,除了动态应用的反向代理,静态前端站点(如 React、Vue 等构建的单页应用 SPA,或者 Hugo、VitePress 等静态生成器产物)的部署与托管也是极高频的场景。
传统方案中,静态站点的发布通常面临以下痛点:
1. **发布与反代配置脱节**:前端构建产物上传到 Nginx 宿主机后,还需要手动或通过其他脚本修改 Nginx 虚拟主机配置,容易出错且缺乏版本控制。
2. **多节点分发困难**:当控制面管理多台边缘节点时,将静态文件同步分发到所有节点,并确保文件一致性,需要维护复杂的同步脚本(如 rsync 等)。
3. **回滚缺乏一致性**:一旦新前端包发布失败或存在严重缺陷,不仅要恢复静态文件,还要恢复对应的反代规则,很难做到原子回滚。
为了解决这些问题,OpenFlare 引入了受 Cloudflare Pages 启发的 **Pages 静态托管** 功能。该功能将“预构建产物导入”与“网站代理规则配置”纳入同一控制面,依托 OpenFlare 的 pull-based(拉取式)协同架构,以不可变 deployment、单节点原子切换和周期对账实现多 Agent 最终收敛,并支持快速回滚。
---
## 核心功能
Pages 静态托管子系统包含以下核心能力:
* **预构建产物部署**:支持直接上传静态资源压缩包,也可为项目保存一个 Remote URL 或公开 GitHub Release asset 来源。外部来源只由 Server 访问,成功同步后统一创建或复用不可变 deployment 并原子激活。
* **不可变部署快照**:本地上传每次创建新的候选 deployment;持久来源同步按 source identity/revision 创建或复用 deployment 并激活。所有部署都有唯一 ID 和整包 SHA-256,支持按系统配置保留最近 N 个历史版本并随时回滚。
* **检查与自动更新**:GitHub latest 可按项目间隔定时检查;默认只提示可用更新,管理员显式开启后才按检查到的精确 revision 自动同步并发布。
* **SPA Fallback 支持**:支持对单页应用(SPA)进行 Fallback 路由配置,请求找不到静态文件时自动重定向到入口文件。
* **内置 API 反代服务**:支持在 Pages 规则内一键启用 API 代理,消除跨域问题,将请求转发给指定的后端服务。
* **安全包校验与解压缩**:内置路径逃逸防御、防软链接劫持、文件大小/数量上限与可配置上传包体积控制,保障节点物理安全。
* **可配置限额**:管理员可在运维设置中调整「部署包大小上限」与「历史部署保留数」。
### 部署源与未来构建边界
项目当前支持 manual、Remote URL、GitHub Release 三种来源视图。无 source 记录即 manual;切换或删除 source 不删除历史 deployment,也不改变当前 active deployment。Remote URL 只允许手动“同步并发布”;GitHub Release 支持 latest/tag 手动检查与同步,只有 latest 可选择定时检查和自动更新。
source 是可变配置,deployment 是不可变事实。source 配置与运行态游标、状态、租约分别存储;deployment 只保存创建时的安全 provenance 快照。所有产物都复用“下载或接收产物 → 真实字节与入口校验 → `upload.Ingest` → deployment”的 artifact pipeline:manual 上传停在 candidate,等待管理员显式激活;持久来源 sync 才在同一业务事务中 create-or-load 并原子激活。Agent 只消费 active deployment,不感知来源类型。
后续从 Git 仓库拉取源码并自动构建时,将新增独立 `git_repository` provider 与隔离的 build executor。它输出受限的预构建产物后继续复用上述导入管线;不得把 clone、依赖安装或任意构建命令下发给 Agent,也不得把 branch/build/env 字段塞入现有 `github_release` source。当前 V2 不增加这些未来字段或空任务,只稳定 provider 输出、source discriminated view 与 deployment provenance 三个扩展边界。
管理端信息架构参考 Cloudflare Pages 当前把 [Git integration](https://developers.cloudflare.com/pages/configuration/git-integration/) 与 [Direct Upload](https://developers.cloudflare.com/pages/get-started/direct-upload/) 分离、并统一展示生产状态与历史部署的方式:OpenFlare 项目详情按“当前生产部署 → 部署源 → 部署历史”组织。OpenFlare 仍允许切换来源并保留历史部署,不采用 Cloudflare 项目创建后来源不可切换的限制。
---
## Pages 静态托管架构
Pages 静态托管在逻辑上分为 **控制面 (Control Plane)** 与 **数据面 (Data Plane)**。
```mermaid
graph TD
%% 数据流
Browser[1. 浏览器 / 访客] -->|HTTPS 请求 / 流量| OpenResty[2. OpenResty / WAF]
OpenResty -->|1. 静态服务 try_files| StaticFiles[3. 边缘节点本地静态目录 current]
OpenResty -->|2. 转发 API 代理| BackEnd[4. 后端 API 服务]
%% 控制流与心跳
Admin[管理员 / CI] -->|上传或配置来源| Server[OpenFlare Server 控制面]
Providers[Remote / GitHub Provider] -->|受限 artifact candidate| Server
Scanner[内部 scanner / action task] -->|检查与自动同步| Server
Server <-->|Agent API / Heartbeat| Agent[openflare-agent 进程]
Server -.->|统一 upload.Ingest| UploadStore[(平台 upload backend)]
Agent -->|1. 发现新版本| Server
Agent -->|2. 下载部署包| Server
Agent -->|3. 校验、解压并原子切换| StaticFiles
style Browser fill:#f9f,stroke:#333,stroke-width:2px
style StaticFiles fill:#9f9,stroke:#333,stroke-width:2px
style Server fill:#f96,stroke:#333,stroke-width:2px
```
* **控制面(Control Plane)**:Server 接收本地上传,或通过受限 Provider 获取 Remote/GitHub 预构建产物;action task 与内部 scanner 负责检查、同步和自动更新。所有产物经统一 inspect 与 `upload.Ingest` 写入平台存储后端;manual 上传创建新的 candidate,持久来源 sync 则 create-or-load deployment 并原子激活。配置发布时只编译稳定的项目锚点与静态服务元数据。
* **数据面(Data Plane)**:Agent 在心跳/WS 对账中发现配置引用的 Pages 项目,通过专属 API 拉取该项目当前激活包并执行校验解压缩。OpenResty 在本地提供静态文件服务;Agent 不感知产物来自上传、Remote、GitHub 或未来 build executor。
---
## 数据模型与元数据设计
### 1. 核心数据库实体
* **Pages 项目 (`of_pages_projects`)**:
* 记录项目的业务名称、Slug 标识(URL 友好型)、启用状态、静态服务根目录(RootDir,可为空)、入口文件名(EntryFile,默认 `index.html`)、SPA Fallback 设置,以及 API 反向代理配置(APIProxyPath, APIProxyPass, APIProxyRewrite)。
* **部署源配置 (`of_pages_project_sources`)**:
* 每个项目最多一条可变来源配置,使用 `source_type` 区分 Remote URL 与 GitHub Release。`config_version` 用于 fence 旧任务;Remote 完整 URL 只保存在配置表中,不会进入响应、日志、任务 payload 或 deployment provenance。V2 不承诺数据库列加密。
* **部署源运行态 (`of_pages_project_source_runtime`)**:
* 与 source 1:1 保存 ETag、seen/applied revision、最近检查/同步、下次检查、错误和 lease。状态固定为 `idle | checking | update_available | syncing | failed | attention`,排队/完成状态由 `TaskExecution` 承担。
* **Pages 部署 (`of_pages_deployments`)**:
* 记录不可变部署事实:项目内递增部署号、整包 SHA-256、`upload_id`、文件数/总字节、创建者,以及可空的 source identity/revision、来源安全快照与 trigger。`artifact_path` 仅为旧数据兼容字段,不再是新部署的存储真相。
* **部署文件清单 (`of_pages_deployment_files`)**:
* 存储每次部署的完整常规文件路径与实际字节数,供控制台展示与统计。
* 不再为包内每个文件计算内容哈希;完整性由**整包** SHA-256(`of_pages_deployments.checksum`)保证,Agent 拉取时校验整包 hash。
* 控制面 inspect 通过文件句柄读取归档,流式消费每个常规文件体并核对声明大小与实际字节,避免将整包 `ReadFile` 进内存,也避免逐文件落盘计算 hash。
### 2. 路由关联与快照
`proxy_routes` 路由规则通过 `upstream_type = "pages"` 及 `pages_project_id` 关联 Pages 项目。当路由类型为 `pages` 且该项目存在已激活的部署时,才允许将该路由加入发布流程。
发布时生成的版本快照中包含 `snapshotPagesDeployment`,主要结构为:
```json
{
"project_id": 1,
"project_slug": "my-spa-app",
"deployment_id": 12,
"deployment_number": 3,
"checksum": "a7b3c2...",
"entry_file": "index.html",
"spa_fallback_enabled": true,
"spa_fallback_path": "/index.html",
"api_proxy_enabled": true,
"api_proxy_path": "/api",
"api_proxy_pass": "http://api.internal:8000",
"api_proxy_rewrite": "/api/(.*) /$1",
"local_root": "__OPENFLARE_PAGES_DIR__/projects/1/current"
}
```
### 3. 与主配置版本的双轨关系(项目锚点 + latest 拉取)
* **主配置版本**与 **Pages 部署** 是两套独立的版本体系。
* 主配置中 Pages 路由的稳定锚点是 **`pages_project_id`(项目 ID)**,不是某次部署 ID。
* OpenResty `root` 使用项目级路径:`__OPENFLARE_PAGES_DIR__/projects/{project_id}/current`,激活切换时路径不变,无需为换包而重发主配置。
* Agent 按项目请求「最新激活包」(类似 `github/release/latest`):
* `GET /api/v1/agent/pages/projects/:project_id/latest/hash`
* `GET /api/v1/agent/pages/projects/:project_id/latest/package`
* 控制面根据该项目**当前激活部署**返回 deployment ID、哈希、包大小与展开清单元数据。Agent 用 deployment ID 与其它 latest 元数据识别下载期间的指针竞态,但主配置和本地目录的稳定锚点仍是 project ID。
* 因此:在项目内切换激活部署后,**不必发布主配置**;Agent 在周期性对账时轮询 latest hash,发现变化即下载并切换 `current`。
* 快照中的 `pages_deployment` 字段仍可记录发布时元数据(入口文件、SPA/API 代理等),但不作为 Agent 拉包的版本锁定。
---
## Server 端 (控制面) 职责与生命周期
### 1. 部署包安全校验与分析
为了避免不可信产物攻击服务器,控制面对本地上传和所有外部来源执行同一套严格校验:
* **格式支持**:`zip`、`tar.gz` / `tgz`、`tar.xz` / `txz`、`tar.bz2` / `tbz2`、`tar`、`7z`。
* **大小限制**:压缩包体积由系统配置 `pages_max_package_size_mb` 控制(默认 100 MiB,范围 1~2048);展开后的单文件与总体积上限为「包大小 × 4」且不低于 100 MiB。inspect 始终流式读取常规文件体,核对声明大小与实际字节并按实际值执行上限。
* **数量限制**:压缩包中包含的静态文件总数不得超过 1,000 个。
* **软链接阻断**:遍历归档文件,一旦检测到任何软链接,立即抛出错误并拒绝上传,防御软链接劫持攻击。
* **路径逃逸防御**:对每个压缩文件路径进行 `Clean` 并检查是否包含 `..` 或以 `/` 开头,防御目录跨越漏洞,防止写入系统敏感路径。
* **入口文件校验**:项目指定的入口文件(例如 `index.html`,可在 `project.RootDir` 下)必须在部署包中存在,否则拒绝上传。
* **公共根目录去噪**:许多打包工具会包含一个多余的主文件夹作为公共根前缀。控制面自动探测公共根前缀并将其安全剥离。
* **整包完整性**:上传/导入时对压缩包字节计算一次 SHA-256,写入部署记录;Agent 拉包后按整包 hash 对账。包内单文件不做内容哈希。
* **实际体积复核**:`InspectOptions.VerifySizes` 只保留兼容意义;当前 inspect 无论该值为何都会读取常规文件体、核对声明值并累计实际大小,但仍不为单文件计算内容 hash。
* **历史保留**:系统配置 `pages_max_history_count`(默认 20,0 表示不限制)在部署成功后执行裁剪。通常语义为:**每个项目最多保留 N 条部署**;当前激活部署始终保留,其余名额按部署 ID 从新到旧填充。`history_count=1` 时,manual 上传会临时保留 active 与最新 candidate 两条,下一次上传替换旧 candidate;candidate 激活后恢复严格上限。超出的非激活 deployment 与文件清单会删除,对应 upload record 通过平台原语幂等软删除;Pages 不直接物理删除可能被 dedup 共享的 blob。部署已成功时裁剪失败只记日志、不回滚激活;并发操作下可能短暂超过 N,后续裁剪会收敛回 N。主配置版本回滚不依赖旧 Pages 包(见上节双轨关系)。
### 2. 部署包存储规划
控制面通过统一上传框架(`upload.Ingest`)把本地、Remote 和 GitHub 产物存入配置的本地/S3 后端,并在数据库中记录 `upload_id` 与文件清单。**大体积静态包不写入 config_versions 记录和任何配置推送通道**,以保障控制面数据同步的轻量与高效。
### 3. 来源检查、自动更新与上传补偿
* `openflare:pages_source_action` 执行管理员 check/sync 或 scanner 派发的精确 revision sync;payload 不携带 URL、Token、ETag 或 lease token。手动 sync 只接受真实用户 actor,自动 sync 只接受系统 actor 与 `scheduled_auto_update` trigger。
* `openflare:pages_source_scan` 是固定 `*/5 * * * *` 的 internal-only TaskHandler,只接受 `{}`,不会出现在通用任务类型与排程管理界面。每轮按“恢复过期 lease → 补偿 orphan upload → 扫描到期来源”执行。
* scanner 按 `next_check_at, source_id` 稳定排序,每批最多串行检查 20 个 GitHub latest source;ETag/304 仍推进检查时间,403/429 记录状态码和实际退避截止时间,单来源失败不阻塞后续来源。
* 发现更新总会先保存 seen cursor。只有 `auto_update_enabled=true` 且状态为普通 `update_available` 时,才携带本次检查得到的精确 revision 派发同步;`attention`、Remote 和固定 tag 不会自动发布。人工激活其它 deployment 会 fence 在途任务并关闭 auto。
* orphan 补偿每轮最多检查 100 条至少隔离 2 小时的 upload record,并要求 system owner、Pages 保留 type、V2 marker、无 deployment 引用。候选在 `project → source → runtime → upload` 锁序内复查,只通过上传框架软删除 record 和更新统计,不直接物理删除可能被 dedup 共享的 blob。
---
## Agent 端 (数据落地) 职责与自愈
Agent 运行在各边缘代理节点上:首次应用引用 Pages 项目的配置时,以及后续周期性 latest 对账时,都会把当前激活的静态资源“原子”地拉取到节点本地。
### 1. 按项目拉取 latest
1. Agent 从激活主配置中解析 `UpstreamType == "pages"` 的路由,收集稳定锚点 **`pages_project_id`**。
2. 对每个项目调用 `GET /api/v1/agent/pages/projects/:project_id/latest/hash` 获取控制面当前激活包哈希(类似 latest 指针)。
3. 若本地 `projects/{project_id}/releases/{hash}` 尚未就绪,再把 `.../latest/package` 流式下载到临时文件,执行真实响应上限与 SHA-256;下载后 **再次请求 hash**,避免激活切换造成的竞态,不一致则有限次重试。
4. 请求头携带节点 `X-Agent-Token`。
### 2. 安全解压缩、原子切换与只保留最新
1. 包体绝对上限为 2 GiB;下载内容的 SHA-256 须与「下载后再次查询」的 latest hash 一致,整个包不会进入 `[]byte`。
2. 解压至 `projects/{project_id}/releases/.{hash}-<random>.tmp` 随机 staging 目录(支持 zip / tar.* / 7z),拒绝路径逃逸、链接和特殊文件。Agent 同时服从 Server metadata 上限与本地绝对上限:最多 1,000 个文件,单文件及总量最多 8 GiB。
3. 解压完成后遍历实际文件树,精确复核文件数与总字节是否等于 Server metadata;不一致时拒绝切换。
4. 写入 `.openflare-pages.json` 后 rename 为 `releases/{hash}`。
5. **原子切换** `projects/{project_id}/current` 指向新 release(优先 symlink,失败则拷贝)。
6. **仅当新包已就绪且 current 切换成功后**,删除该项目下其它 `releases/*`(含 `.tmp`),**不保留历史部署包**。边缘节点每个项目永远只保留一份最新内容。
7. 多项目对账时 **隔离失败**:单个项目失败记日志并继续其它项目,最后汇总返回错误。
---
## OpenResty (静态服务与代理) 配置渲染
对于 Pages 托管站点,控制面自动渲染对应的 `server` 块,取代常规代理路由中的 `proxy_pass`。
### 1. 静态服务指令渲染
* **`root` 与 `index`**:
Server 将 `root` 指向项目级占位路径 `__OPENFLARE_PAGES_DIR__/projects/{project_id}/current`(可再追加 `RootDir`)。激活切换只换目录内容,路径不变,无需为换包重发主配置。
```nginx
server {
listen 80;
server_name myapp.example.com;
root "/var/lib/openflare/pages/projects/3/current";
index "index.html";
...
}
```
### 2. try_files 与 SPA Fallback 机制
* **禁用 SPA Fallback (默认)**:
仅匹配物理存在的文件,否则返回 strict 404:
```nginx
location / {
try_files $uri $uri/ =404;
}
```
* **启用 SPA Fallback**:
若请求的文件不存在,重定向到项目配置的入口 Fallback 文件(通常为 `/index.html`):
```nginx
location / {
try_files $uri $uri/ /index.html;
}
```
### 3. API 反向代理与重写 (Rewrite) 渲染
当静态前端项目需要请求后端 API 且不希望面临跨域问题时,可开启 API 反代。OpenResty 渲染器会自动在其对应的静态 `server` 块内嵌套专属的 API `location` 分支:
```nginx
server {
listen 80;
server_name myapp.example.com;
...
# API 代理路径匹配
location /api {
# 如果配置了 Rewrite 规则,应用重写逻辑
rewrite ^/api/(.*)$ /v1/$1 break;
rewrite ^/api$ / break;
proxy_pass http://api.internal:8000;
proxy_http_version 1.1;
proxy_set_header Host $http_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_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
location / {
try_files $uri $uri/ /index.html;
}
}
```
---
## 交互逻辑与同步流程
一次完整的预构建产物导入与生效生命周期如下。首次绑定项目需要发布主配置;后续 active deployment 变化通过项目 latest 独立收敛:
```text
[管理员 / scanner] [Server 控制面] [Agent] [OpenResty]
| | | |
|-- manual 上传 ------>|-- inspect / Ingest ---->| |
| |-- 创建 candidate | |
|-- 显式激活 candidate ->|-- 切换 active | |
| | | |
|-- source sync ------>|-- inspect / Ingest | |
| |-- create/load + 原子激活 | |
| | | |
|-- 首次绑定项目并发布 ->|-- 广播项目锚点 -------->|-- 写入/重载路由 ---------->|
| | | |
|-- 后续激活/同步/回滚 ->|-- active latest 改变 ---| |
| |<-- latest 元数据对账 ----| |
| |--- 流式返回 package ---->| |
| | |-- 校验、解压、复核 --------|
| | |-- 原子切换 current -------->|
```
-130
View File
@@ -1,130 +0,0 @@
# 内网穿透隧道设计文档
你会学到:OpenFlare 内网穿透隧道的架构设计、双端管控组件(Relay 与 Client)的内部原理、交互逻辑以及数据面与控制面的通信流程。
---
## 需求分析
在典型的 Web 应用托管场景中,许多源站(Origin Server)部署在内网环境(如本地开发机、局域网服务器或受防火墙限制的内网集群)。这些服务器通常:
1. **无公网 IP**:无法直接被公网流量访问。
2. **安全合规限制**:不允许随意在边界路由器上配置端口映射(NAT)。
3. **动态 IP 变动**:传统的 DDNS 方案延迟高且极不稳定。
为了让内网源站能够无缝接入 OpenFlare 全局数据网关并享受 WAF 地域防护、TLS 证书托管等增值服务,OpenFlare 设计了基于 **反向中继穿透隧道** 的整体解决方案。在该架构中,公网边缘节点作为反代入口和流量中继,内网侧仅需发起安全出向连接,即可实现公网流量安全、稳定地反向穿透到内网源站。
---
## 核心功能
内网穿透隧道子系统包含以下核心能力:
* **Relay 节点动态管理**:由控制面动态派发中继服务(frps),动态分发服务端口与认证令牌(Token)。
* **多隧道反向代理映射**:支持在单个内网客户端上映射多个内网 Web 端口,并将多域名路由绑定至对应的中继节点。
* **独立进程生命周期管控**:中继与客户端均为 Go 编写的独立二进制守护进程,内部负责拉起、监控、自愈及热升级底层的 frp 引擎。
* **基于 Token 的独立认证隔离**:中继端使用 `agent_token`,内网客户端使用专属 `tunnel_token`,权限与路由边界隔离。
* **配置校验与增量热重载**:仅在隧道绑定关系、证书或 Relay 拓扑发生实际变化时,才重写配置文件并平滑重载进程,降低运行开销。
---
## 内网穿透与隧道架构
内网穿透子系统基于成熟的 `frp` 高性能隧道协议进行整合,分为 **控制面 (Control Plane)** 与 **数据面 (Data Plane)**。
```mermaid
graph TD
%% 数据流
Browser[1. 浏览器 / 访客] -->|HTTPS 请求| Agent[2. OpenResty / Agent]
Agent -->|本机转发 proxy_pass| RelayFrps[3. OpenFlare Relay / frps]
RelayFrps -->|加密隧道协议| FlaredFrpc[4. OpenFlared / frpc]
FlaredFrpc -->|转发本地请求| LocalOrigin[5. 内网源站 192.168.x.x]
%% 控制流与心跳
Server[OpenFlare Server 控制面] <-->|Relay API / Heartbeat| RelayManager[openflare-relay 进程]
Server <-->|Client API / Heartbeat| ClientManager[openflared 进程]
RelayManager -.->|管控进程及配置| RelayFrps
ClientManager -.->|管控多 Relay 进程| FlaredFrpc
style Browser fill:#f9f,stroke:#333,stroke-width:2px
style LocalOrigin fill:#9f9,stroke:#333,stroke-width:2px
style Server fill:#f96,stroke:#333,stroke-width:2px
```
* **控制面(Control Plane)**:Server 维护数据库状态;中继节点上的 `openflare-relay` 进程与内网服务器上的 `openflared` 进程通过 HTTP 心跳与 WebSocket 长通道同步隧道配置。
* **数据面(Data Plane)**:公网流量首先进入公网边缘的 Agent (OpenResty),在此完成 HTTPS 握手、TLS 终止和 WAF 过滤,接着通过 `proxy_pass` 转发到同机部署的 `openflare-relay (frps)`。`frps` 再将请求封包通过与内网 `openflared (frpc)` 建立的持久隧道传输过去,最后由 `frpc` 拆包并分发给内网实际的源站服务。
---
## Relay (中继端) 设计
`openflare-relay` 是部署在公网边缘的中继管理器,运行在 `tunnel_relay` 类型的节点上。
### 1. 核心架构与逻辑
* **进程守护**:Relay 进程内部持有 `frps` 二进制,通过 `exec.Command` 拉起 `frps -c frps.toml` 子进程,并启动 goroutine 异步监听其退出状态。如果发现 `frps` 异常退出,会结合退避机制自动拉起。
* **动态配置渲染**:通过 HTTP 心跳向控制面同步状态,获取当前的 `RelayConfig`,主要参数包括:
* `bindPort`:frps 用于监听内网 frpc 客户端连接的公网控制端口。
* `vhostHTTPPort`:虚拟主机(Virtual Host)HTTP 流量监听端口,Agent 的 proxy_pass 会指向此端口。
* `authToken`:客户端连接时进行握手校验的安全凭证。
* `webServer`:开启 frps 的仪表盘 API,Relay 基于此接口或管理控制端口收集实时的活跃隧道数和流量指标。
* **状态上报**:Relay 每周期心跳会向控制面上报底层 `frps` 的活跃连接数、注册客户端数、各个代理通道的实时状态以及 Relay 版本。
---
## Openflared (客户端) 设计
`openflared` 是运行在用户内网服务器侧的客户端管理器,使用独立的 `tunnel_token` 进行鉴权。
### 1. 核心设计机制
* **多 Relay 支持(多路复用)**:
为保障高可用或就近接入,控制面可能会将客户端连接调度到多个公网 Relay。`openflared` 会读取 `TunnelConfig` 中下发的 Relays 列表,在本地为每一个 Relay 节点独立生成一个专用的配置文件(命名为 `frpc_<relay_node_id>.toml`),并分别为每个 Relay 进程分配独立的 cancelable context。
* **子进程独立监控**:
`openflared` 内部维护一个 `processes` 映射表,对每个 `frpc` 子进程进行独立的生命周期管控。当控制面增加或移除 Relay 时,客户端会增量拉起新进程或优雅注销老进程,避免影响其他正常工作的隧道。
* **动态 TOML 生成**:
为每个 Relay 渲染 TOML 时,客户端会遍历 Proxies 列表,将每个内网服务的 `LocalAddr`、`LocalPort`、绑定的 `CustomDomains` 写入到 `[[proxies]]` 块中。
---
## 交互逻辑与流量模型
内网穿透子系统实现了一致性版本控制和状态反馈。
### 1. 控制面发布与同步流程
```text
管理员修改隧道/内网端口映射 -> 提交发布 -> 生成新 Tunnel 版本与 Checksum
|
v (推送或心跳拉取)
+-------------------------------------------+-------------------------------------------+
| |
v (中继端) v (内网客户端)
openflare-relay 心跳检测到 frps 端口/Token 变化 openflared 心跳检测到 tunnel_version 发生变更
重新渲染本地 frps.toml 请求拉取最新代理映射包
Kill 并重新拉起 frps 进程 重新渲染 frpc_<relay_id>.toml
上报健康状态为 healthy 对有变更的 Relay 进程执行重启与配置热重载
上报应用结果 (Apply Success/Error)
```
1. **版本化控制**:所有内网隧道的路由和映射关系与主路由系统类似,也经过版本化控制,下发 `version` 与 `checksum`,确保客户端不重复写入和频繁重载进程。
2. **应用结果闭环**:客户端应用新配置后,会在心跳中携带应用结果上报控制面。若因内网端口不可达或证书配置有误导致 frpc 无法建连,客户端会截获进程输出将 `LastError` 上报,管理员在 Server 即可直观查看穿透失败原因。
### 2. 数据面流量模型
1. **公网入口 (Agent)**:
```nginx
server {
listen 443 ssl;
server_name intranet.example.com;
# ... TLS 证书与 WAF 过滤逻辑 ...
location / {
proxy_pass http://127.0.0.1:18080; # 指向本地 frps 的虚拟主机端口
proxy_set_header Host $host; # 必须保留原 Host,因为 frps 依靠 Host 进行内部路由分发
proxy_set_header X-Real-IP $remote_addr;
}
}
```
2. **中继节点 (frps)**:
`frps` 监听到 `18080` 端口有 HTTP 请求进来,读取 HTTP 请求头中的 `Host: intranet.example.com`,在其已注册的活跃隧道表中检索该域名对应的加密 TCP 连接(由内网 frpc 建立)。
3. **加密隧道传输 (TCP)**:
`frps` 将 HTTP 请求封装进内部 TCP 隧道协议,发送给内网的 `frpc` 客户端。
4. **内网客户端分发 (frpc)**:
`openflared` 管理的 `frpc` 收到封包,根据本地配置(`localIP = "127.0.0.1"`, `localPort = 8080`)将请求建立本地 TCP 连接转发给内网 Web 服务,并将 Web 服务的响应原路打包返回,最终呈现给公网用户。
-15
View File
@@ -1,15 +0,0 @@
# WAF 设计
OpenFlare WAF 的现行规则模型是可视化 DAG。节点语义、图约束、多规则顺序、发布编译与迁移边界统一以 [WAF 可编排规则设计](./waf-orchestration-design.md) 为准。
## 系统边界
Server 保存带坐标和修订号的编辑图,发布时再次校验并编译为紧凑运行图;Agent 原子写入快照并 reload OpenResty;请求热路径只遍历 Worker 内存中的不可变图。
IP 组独立于规则拓扑更新。手动、订阅和自动 IP 组由控制面维护,Agent 先原子替换 JSON、最后更新 checksum。协调 Worker 每 5 秒检查 checksum,仅变化时读取完整快照并分发给其它 Worker;失败时保留上一份有效数据。完整运行时快照上限为 20 MiB,Server 发布/同步与 Agent 落盘使用同一序列化校验;OpenResty 使用独立的 64 MiB 共享字典和非淘汰写入,容量不足时拒绝新版本而不破坏已提交快照。
地域节点使用 Country 与 City MMDB。Agent 首次启动时从程序内嵌数据库初始化缺失文件,后续按配置周期下载更新,请求处理始终读取 OpenResty 已加载的数据库。数据库不可用时地域匹配返回 `false` 并限频告警,不允许因数据损坏意外放行其它执行错误。
## 安全顺序
启用的全局规则固定前置;路由规则按绑定 sequence 执行。阻止节点立即终止,通过节点仅结束当前规则,全部规则通过后才进入回源链路。未知节点、缺失出口或步数超限一律阻止请求。
-128
View File
@@ -1,128 +0,0 @@
# WAF 可编排规则设计
本文定义 OpenFlare WAF 从固定判定链重构为可视化有向无环图(DAG)的目标架构、数据模型、执行语义、发布模型与迁移边界。IP 组的来源与成员计算仍遵循 [WAF 设计](./waf-design.md),本文只改变规则如何组合和执行。
## 目标与边界
用户新增 WAF 规则时只输入名称。Server 随即创建一张合法的默认图 `开始 → 通过`,前端进入基于 React Flow 的独立编排页面。用户通过添加处理单元、配置节点并连接分支构建策略,不再填写固定顺序的黑白名单与 PoW 表单。
第一阶段支持以下节点:
| 节点 | 数量约束 | 输入 | 输出 | 配置 |
| --- | --- | --- | --- | --- |
| 开始 | 每张图恰好一个 | 无 | `next` | 无 |
| 通过 | 每张图恰好一个 | 一个或多个 | 无 | 无 |
| 阻止 | 可创建多个 | 一个或多个 | 无 | HTTP 状态码、HTML 响应体 |
| IP 匹配 | 可创建多个 | 一个或多个 | `true`、`false` | IP、CIDR、IP 组 ID |
| 地域匹配 | 可创建多个 | 一个或多个 | `true`、`false` | 国家代码、地区代码 |
| UA 检查 | 可创建多个 | 一个或多个 | `true`、`false` | 要求携带 UA、浏览器/OS 白名单与 and/or、屏蔽爬虫/非正常 UA(不含爬虫)/自定义正则 |
| 安全防护 | 可创建多个 | 一个或多个 | `true`、`false` | 基础特征检测(路径穿越/文件包含默认开;SQL/XSS/命令注入/SSRF/上传/XXE/CRLF 可开关);命中任一已启用规则为 false |
| PoW | 可创建多个 | 一个或多个 | `next` | 算法、难度、会话 TTL、挑战 TTL |
IP 匹配、地域匹配、UA 检查与安全防护不区分黑名单或白名单。`true` 只表示请求通过该节点判定,`false` 只表示未通过;放行或阻止的业务含义完全由连线决定。UA 检查的求值顺序为:要求携带 UA → 屏蔽爬虫/非正常 UA → 白名单匹配。安全防护在请求 Path/Query/Header/Cookie/Body(有限)上做特征匹配。PoW 验证完成后沿 `next` 继续,未完成时由挑战页面接管当前请求,不产生 `false` 分支。
不在第一阶段实现循环、脚本节点、任意表达式节点、子图调用和跨规则跳转。
## 控制面架构
规则图采用控制面编辑态和数据面运行态分离的双模型:
1. React Flow 编辑器提交版本化图 JSON,其中包含节点 ID、节点类型、显示名称、坐标、类型化配置和连线。
2. Server 对整张图执行权威校验,通过后以单个事务保存图并递增修订号。
3. 配置发布时,Server 再次校验所有启用规则,将图编译为不含坐标、标签等 UI 字段的紧凑运行时 DAG,并收集被引用的 IP 组 ID。
4. Agent 原子落盘完整发布快照并 reload OpenResty。新 Worker 启动时只加载和解析一次规则 JSON。
5. 请求热路径只遍历 Worker 内存中的不可变运行时图,不读取文件、不计算 checksum、不解析 JSON。
编辑态 JSON 使用明确的 `schema_version`。节点配置使用按节点类型区分的结构,不允许用无约束键值对象绕过 Server 校验。初始安全上限为每条规则 128 个节点、256 条边和 256 KiB 编辑态 JSON;这些限制由 API 和发布编译器共同执行。
## 图结构约束
规则保存与发布必须满足全部约束:
* 图是有向无环图,禁止自环和任意循环。
* 恰好存在一个开始节点和一个通过节点;阻止节点可以存在多个。
* 开始节点无入边且恰好有一个 `next` 出口;通过和阻止节点无出口。
* IP 匹配、地域匹配、UA 检查与安全防护的 `true`、`false` 出口必须各连接一次;PoW 的 `next` 必须连接一次。
* 除终止节点外不得存在悬空出口;每个非开始节点至少有一条入边。
* 所有节点都必须从开始节点可达,且从每个可执行节点出发都能抵达通过或阻止。
* 边的源端口必须属于源节点类型;同一源端口不得连接多个目标。
* 节点 ID 在图内唯一,边 ID 在图内唯一,所有边引用的节点必须存在。
* 节点配置必须通过对应类型的字段、范围、引用存在性和体积校验。
前端提供即时校验和连线限制以改善体验,但 Server 是唯一权威校验方。删除节点时前端同步删除关联边并将规则标记为未保存;图恢复合法前禁止保存。
## 多规则执行语义
一个路由可以绑定多条自定义规则。绑定关系是有序列表,并遵循以下顺序:
1. 启用的全局规则固定最先执行,不参与路由侧排序。
2. 路由绑定的启用规则按绑定顺序依次执行。
3. 当前规则抵达阻止节点时立即输出该节点配置的响应并终止请求。
4. 当前规则抵达通过节点时,只表示当前规则执行完成;若仍有后续规则则继续执行。
5. 全部规则均抵达通过节点后,请求才真正放行并进入后续 OpenResty/回源链路。
运行时图在发布前已经过完整校验。若 Lua 执行器仍遇到未知节点、未知端口、缺失目标或超过节点步数上限,则记录限频错误并阻止请求,避免损坏的安全配置意外放行。
## IP 组内存刷新
规则拓扑只在发布并 reload OpenResty 时生效;IP 组成员仍可由手动、订阅或自动任务独立更新,不要求发布或 reload。
IP 组采用协调 Worker、共享快照和 Worker 本地对象的两级缓存:
1. 请求始终读取当前 Worker 内存中的 IP 组对象,不访问文件或共享字典中的 JSON。
2. 每 5 秒只有一个取得共享锁的 Worker 读取轻量 checksum 文件。
3. checksum 未变化时立即结束,不读取完整 `waf_ip_groups.json`。
4. checksum 变化时,协调 Worker 读取并验证一次完整 JSON,再把原始快照按 checksum 写入独立的 64 MiB `ngx.shared.openflare_waf_ip_groups`,最后更新提交指针。
5. 其他 Worker 发现共享版本变化后,从共享内存取得快照、解析并原子替换各自的本地对象,不重复读取磁盘。
6. 刷新失败时继续使用上一份有效对象,限频记录错误,并在下一周期重试。
Agent 必须先原子替换 IP 组 JSON,最后原子更新 checksum,使 Worker 永远不会把半写入文件识别为新版本。Server 发布/同步和 Agent 落盘共同执行 20 MiB 聚合快照上限;共享字典使用不会强制淘汰旧键的安全写入,失败时保留当前与上一代不可变快照。
## API 与编辑器
创建接口只接受规则名称,创建成功后返回带默认图的规则详情。规则元数据、图保存和路由绑定使用独立操作,避免修改启用状态或绑定时覆盖画布。
图详情包含 `revision`。保存请求提交 `revision + graph`,Server 仅在修订号匹配时更新并递增修订号;不匹配时返回冲突,前端提示重新加载,禁止静默覆盖其他页面的修改。路由绑定接口接受有序规则 ID 数组。
React Flow 编辑页采用全宽画布和固定右侧属性栏:
* 顶部提供返回、规则名称、启用状态、校验状态和保存操作。
* 画布使用紧凑高度和较小的首次适配缩放,支持缩放、平移、框选、删除、自动布局和 MiniMap/Controls 等必要导航能力;节点拖动由 React Flow 本地受控状态实时处理,拖动结束后才把坐标写回编辑图。
* “添加处理单元”提供 IP 匹配、地域匹配、UA 检查、安全防护、PoW 和阻止;开始与通过由默认图提供且不可删除或重复添加。
* 选中普通节点或连线后可使用画布删除按钮或 Delete/Backspace 删除;删除节点时同步移除关联连线。
* 右侧属性栏默认隐藏,选中节点后才显示并用于编辑配置;点击连线或画布空白处时收起。
* 地域匹配属性使用完整国家与 ISO 3166-2 一级行政区数据;国家选项同时显示本地化名称与代码,行政区支持按国家名、行政区名或代码搜索,避免一次渲染数千个选项。
* 离开存在未保存变更的页面前必须提示;保存冲突和 Server 校验错误应定位到相关节点或边。
WAF 列表展示规则名称、启用状态、节点数量、应用路由数量和更新时间。新建规则的对话框只有名称字段,成功后立即导航到编排页面。
## 持久化与迁移
规则记录增加版本化图 JSON 与修订号;绑定记录增加执行顺序。图作为一个聚合整体保存,不拆成节点表和边表,以保证编辑操作的事务边界,并让新增节点类型不必频繁扩展数据库 Schema。
升级现有安装时:
* 保留规则名称、全局标记、启用状态及路由绑定关系。
* 所有规则图重置为 `开始 → 通过`,不迁移旧 IP/地域名单、PoW 或拦截响应配置。
* 现有绑定按稳定顺序写入顺序字段;全局规则仍固定前置。
* 新图和运行时稳定后移除旧规则字段、固定顺序编译逻辑和旧前端表单,不长期维护双执行器。
该迁移会让旧防护配置停止生效,升级说明必须显著提示管理员在发布下一版本前重新编排规则。
## 发布、失败与回滚
规则图只在配置发布时生效。发布前校验或编译失败时拒绝发布,当前活动版本保持不变。Agent 写入、OpenResty 配置检查或 reload 失败时,应用流程失败并恢复上一份有效发布版本。
新 Worker 只接受完整且可解析的规则运行态配置。旧 Worker 在 OpenResty 优雅 reload 期间继续使用旧内存图,新 Worker 使用新图,因此请求不会观察到半更新状态。
地域数据库不可用时,地域匹配返回 `false` 并限频告警,保持现有行为。IP 组刷新失败时保留旧内存快照。PoW 未完成由挑战模块接管请求,不视为执行错误;PoW 节点配置先以短期键写入 OpenResty 共享内存,再通过 `ngx.exec` 的显式参数传给内部挑战处理器,不能依赖内部重定向保留 `ngx.ctx` 或隐式继承请求参数。发布快照中的空规则绑定必须编码为 JSON 空数组;运行时将旧快照中的 `null` 可选数组按空数组处理,禁止因 `cjson` 的 `ngx.null` userdata 中断请求。
## 测试与验收
* Go 单元测试覆盖图结构、端口、可达性、终止性、节点配置、体积限制、编译结果、修订冲突和绑定顺序。
* 数据库测试覆盖 PostgreSQL/SQLite 迁移、默认图、旧绑定稳定排序和回滚。
* Lua 测试覆盖所有节点出口、多规则顺序、全局规则前置、多个阻止响应、PoW 接管和损坏运行时图保护。
* Agent/OpenResty 测试覆盖发布 reload、加载一次、失败回滚、IP 组五秒 checksum 刷新和旧快照保留。
* 前端测试覆盖创建后导航、特殊节点唯一性、连线限制、属性编辑、即时校验、未保存提示和并发冲突。
* 集成测试从控制面创建并编排规则,发布后用真实请求验证放行、阻止、PoW 和 IP 组热刷新。
* API 变更后运行 `make swagger`;完成实现后运行前端检查与构建以及 `make code-check`。
-100
View File
@@ -1,100 +0,0 @@
# Zone 与域名资源设计
## 目标
将“网站”重构为以可注册根域为入口的 Zone 管理体验。`example.com` 之类的 Zone 是稳定的管理边界;用户通过稳定 ID 路径进入该 Zone,查看并维护其中明确声明的域名、域名所绑定的反代路由和证书,以及路由级 WAF、Pages 等能力。
本设计替代 `managed_domains` 的概念、表与 API。Zone 核心 **不** 内建权威 DNS 解析记录管理;若需将 ZoneDomain 的 A 记录指向边缘节点,使用可选模块 [Cloudflare DNS 指向](./cloudflare-pointing.md)。
## 范围与约束
* Zone 根域使用 Public Suffix List 解析,例如 `api.example.co.uk` 归属 `example.co.uk`。
* URL 使用 ID:列表为 `/websites`,详情为 `/websites/:zoneId`;不使用域名作为 URL 参数。
* Zone 域名必须是明确的 FQDN,禁止录入 `*.example.com`。TLS 证书可仍含通配符 SAN,并用于覆盖明确的 Zone 域名。
* 一个 Zone 域名至多关联一条反代路由;一条反代路由可关联多个 Zone 域名,因而可跨 Zone 共享同一套上游、缓存、限流、WAF 与 Pages 配置。
* Zone 模型本身不新增 DNS 记录、边缘函数、预览子域或租户隔离能力。对外 DNS A 记录的创建/更新由独立的 Cloudflare 指向模块负责,且不改变 Zone / ZoneDomain 表职责。
## 核心模型
```mermaid
erDiagram
ZONES ||--o{ ZONE_DOMAINS : contains
PROXY_ROUTES ||--o{ ZONE_DOMAINS : serves
TLS_CERTIFICATES ||--o{ ZONE_DOMAINS : secures
PROXY_ROUTES ||--o{ WAF_RULE_GROUP_BINDINGS : applies
PAGES_PROJECTS ||--o{ PROXY_ROUTES : backs
ZONES {
uint id PK
string domain UK
}
ZONE_DOMAINS {
uint id PK
uint zone_id
uint proxy_route_id
string domain UK
uint cert_id
}
```
### `of_zones`
保存根域、创建时间与更新时间。根域全局唯一且创建后不可原地修改;需要变更时新建 Zone 并迁移域名。删除 Zone 前必须先清空其 Zone 域名。
### `of_zone_domains`
保存 `zone_id`、明确 `domain`、可空的 `proxy_route_id`、可空的 `cert_id` 及时间戳。`domain` 全局唯一;所有关系字段建立索引但不建立物理外键。`proxy_route_id` 允许为空,以承接已准备证书但尚未配置反代的历史域名。
`of_proxy_routes` 逐步移除 `domain`、`domains`、`cert_id`、`cert_ids` 与 `domain_cert_ids` 等域名/证书冗余列。路由不得再指定任何 TLS 证书;路由名称 `site_name` 成为稳定的人类可读标识,编译器从关联的 Zone 域名读取 `server_name` 与其 `cert_id`。这使每个明确域名的证书只有一个来源。
## 业务与 API
管理端新增 Zone 资源:
* `GET/POST /api/v1/d/zones`
* `GET/POST /api/v1/d/zones/:id/update`
* `POST /api/v1/d/zones/:id/delete`
* `GET/POST /api/v1/d/zones/:id/domains`
* `POST /api/v1/d/zones/:id/domains/:domainID/update`
* `POST /api/v1/d/zones/:id/domains/:domainID/delete`
* `GET /api/v1/d/zones/:id/overview`
反代路由的创建、更新请求改用 `zone_domain_ids`,不再提交 `domains`、`cert_id`、`cert_ids` 或 `domain_cert_ids`。服务端在事务中验证域名归属、全局唯一性和证书 SAN 覆盖;失败通过 `response.Abort*` 统一返回。删除已绑定路由的 Zone 域名必须先解除或删除该路由;删除仍有域名的 Zone 必须拒绝。
WAF、Pages、上游与发布版本仍属于 `proxy_routes`。Zone 概览只聚合展示其域名关联的路由状态,不复制或重新定义这些配置。
## 前端体验
`/websites` 只展示 Zone 根域,显示已配置域名数、路由数与状态,并提供搜索、创建和操作菜单。点击进入 `/websites/:zoneId`。
详情页包含:
* 概览:域名、路由和有效证书统计;域名—路由—证书摘要;路由级 WAF 与 Pages 摘要。
* 域名:明确 FQDN 的列表、证书选择和关联路由;不显示或接受通配符域名。
* 路由:筛选到当前 Zone 的路由并链接到既有路由详情。
* 证书:当前 Zone 域名实际引用的证书。
* 设置:Zone 备注和受保护的删除操作。
新增路由时从 Zone 域名中选择;用户也可以先在 Zone 中登记域名,再绑定路由。全局反代路由入口保留,但改用同一套 Zone 域名选择器。
## 数据迁移
本次改造分两个发布阶段,以免 SQL 用错误的“末两段域名”规则处理多级公共后缀。操作细则见 [Zone 域名迁移与发布验收](../guide/zone-domain-migration.md)。
1. **第一阶段 DDL**:PostgreSQL 与 SQLite 同版本 Goose 创建 `of_zones` / `of_zone_domains`;暂时保留 `of_managed_domains` 与路由冗余列。
2. **数据导入(自动)**:Server 启动时 `migrator.Migrate()` 先应用 goose SQL 至 `202607120002`,再自动导入旧路由域名 / `managed_domains`(`publicsuffix` 解析注册根域,写入 `cert_id` 与 `proxy_route_id`),最后继续后续 SQL。冲突时启动失败;修复后重启可幂等重试。无需手动命令。
3. **代码切换**:控制面 API、配置快照、渲染、前端均以 Zone 域名为唯一来源;路由写入仅使用 `zone_domain_ids`。
4. **第二阶段清理**:Goose SQL `202607130001_drop_legacy_route_domain_columns` 删除 `of_managed_domains` 与 `of_proxy_routes` 冗余列。Down 仅恢复开发库空结构,不回填历史数据。
### 运行时模型边界
* 持久化:域名与证书只存在于 `of_zone_domains`;`of_proxy_routes` 仅保存路由策略(上游、缓存、限流、WAF 绑定键等)。
* 渲染:配置快照在内存中组装临时 `Domains` / `DomainCertIDs` 供 OpenResty 渲染,不写回数据库。
* 结构迁移仅使用 `internal/infra/persistence/migrator/goose/{postgres,sqlite}/*.sql`;启动时自动导入历史域名,第二阶段后旧列不存在则为空操作。
## 验证
* 单元测试:Public Suffix List 分组、FQDN / 通配符拒绝、跨 Zone 路由、证书 SAN 覆盖、删除保护及迁移幂等性;清理后断言旧列/旧表不存在。
* 集成测试:Zone、Zone 域名与路由 API 的成功与失败响应;现有路由迁移后生成相同 OpenResty 域名与证书配置。
* 前端测试:Zone 列表、ID 路由、详情加载 / 错误 / 空状态、域名选择器与 API 负载。
* 手动验证:迁移前后比较激活配置快照中的 `server_name` 和证书路径,发布后使用根域及各子域请求验证路由。
+567 -13297
View File
File diff suppressed because it is too large Load Diff
-68
View File
@@ -1,68 +0,0 @@
# TLS 证书与自动续期
本指南介绍如何在 OpenFlare 中管理 TLS 证书。为了使用 HTTPS 安全加密流量,你需要配置对应的证书。OpenFlare 支持**手动导入已有证书**以及**通过 ACME 自动申请与托管续期**。
---
## 方式一:手动导入已有证书
如果你已经从第三方服务商(如腾讯云、阿里云等)申请了免费或收费的证书,或者在本地生成了自签名证书:
1. 登录管理端控制面板,进入左侧导航 **「网站管理」->「TLS证书」** 页面。
2. 点击右上角的 **「导入证书」**。
3. 填写配置信息:
* **证书名称**:输入一个易于识别的别名(如 `my-domain-cert`)。
* **证书内容 (PEM)**:复制并粘贴 PEM 格式 of 证书公钥内容(通常以 `-----BEGIN CERTIFICATE-----` 开头)。
* **证书私钥 (KEY)**:复制并粘贴证书的私钥内容(通常以 `-----BEGIN PRIVATE KEY-----` 或 `-----BEGIN RSA PRIVATE KEY-----` 开头)。
4. 点击 **「保存」**。导入成功后,该证书即可在配置域名时直接绑定使用。
---
## 方式二:自动申请与到期自动续签 (ACME)
OpenFlare 内置了 ACME 客户端并对接了 **Asynq 异步任务队列**。通过配合云解析服务商的 DNS API,系统能自动完成 DNS-01 挑战(Challenge)校验,并向 CA(默认 Let's Encrypt)申请通配符/单域名证书,并在**到期前 30 天自动触发后台秒级续签**。
### 第一步:在 Cloudflare 申请 DNS API Token
为了使 OpenFlare 能够自动在你的域名下添加 TXT 记录以完成 DNS 校验,你需要准备一个具有特定权限的 Cloudflare API Token。
> [!IMPORTANT]
> 安全起见,**强烈建议使用限定权限的 API Token**,而非全局 API Key (Global API Key)。
1. 登录 [Cloudflare 控制台](https://dash.cloudflare.com/)。
2. 点击右上角的用户头像,选择 **「我的个人资料 (My Profile)」**。
3. 在左侧菜单中选择 **「API 令牌 (API Tokens)」**,然后点击 **「创建令牌 (Create Token)」**。
4. 找到 **「编辑区域 DNS (Edit Zone DNS)」** 模板,点击 **「使用模板 (Use template)」**。
5. 配置令牌权限与范围(保持默认或根据实际情况限定):
* **权限 (Permissions)**:
* `区域 (Zone)` - `DNS` - `编辑 (Edit)` (必须,ACME 写入 TXT 记录用)
* `区域 (Zone)` - `区域 (Zone)` - `读取 (Read)` (必须,用于列出和检索区域 ID)
* **区域资源 (Zone Resources)**:
* 选择 **「包括 (Include)」** -> **「所有区域 (All zones)」**,或者选择 **「特定区域 (Specific zone)」** 并指向你托管的特定域名。
6. 点击 **「继续以转到摘要 (Continue to summary)」**,确认无误后点击 **「创建令牌 (Create Token)」**。
7. 复制生成的 **API 令牌 (Token)** 字符串。*注意:该令牌仅展示一次,请妥善保存*。
### 第二步:在控制端添加 DNS 账号
1. 登录 OpenFlare 管理端,进入左侧导航 **「网站管理」->「DNS账号」**。
2. 点击 **「添加账号」**。
3. 填写配置信息:
* **账号名称**:如 `cloudflare-main`。
* **DNS 服务商**:选择 `Cloudflare`。
* **API Token**:填入刚刚在 Cloudflare 复制的 API 令牌(该值在入库时会自动加密存储,保障安全)。
4. 点击 **「保存」**。
### 第三步:提交证书申请任务
1. 进入左侧导航 **「网站管理」->「TLS证书」**,点击右上角 **「申请证书」**。
2. 在申请表单中填写:
* **证书名称**:自定义名称(如 `wildcard-example-cert`)。
* **主域名**:你申请的主域名(支持通配符,如 `example.com` 或 `*.example.com`)。
* **关联域名**:如有多个,在此处追加(支持通配符,多个域名间用英文逗号分隔)。
* **DNS 账号**:在下拉列表中选择刚才添加的 DNS 账号(如 `cloudflare-main`)。
3. 点击 **「保存并申请」**。
### 第四步:查看申请进度与续期状态
- **查看实时进度**:保存后,系统会向 Asynq 队列投递单证书续期/申请任务(`of_ssl_single_renew`)。你可以进入管理后台的任务或节点日志页面,实时查看每一步(添加 TXT 记录、DNS 记录全球生效探测、ACME 验证、证书颁发落地等)的详细日志。
- **自动续期**:所有通过 ACME 申请的证书都会被系统自动托管。后台的 Scheduler 每日会自动扫描证书有效期,在到期前 30 天自动通过异步任务触发续签,无需任何手动维护。
-29
View File
@@ -1,29 +0,0 @@
# 引用与致谢
OpenFlare 本质上是一个方案整合项目, 在设计与实现过程中借鉴了众多开源项目的优秀理念、架构设计和技术实现。以下是 OpenFlare 在核心底层引擎、安全防护机制以及前后端系统框架等方面所引用的关键开源项目,以及对这些项目及其社区的感谢。
---
### 1. OpenResty
* **项目定位**:基于 Nginx 与 Lua 的高性能 Web 平台。
* **在 OpenFlare 中的作用**:作为全局数据面(Data Plane)的边缘网关。所有的公网 Web 流量均首先由 OpenResty 接收,在此处进行高并发的 HTTPS 握手、WAF 安全规则比对、防 CC 人机验证,并最终执行反向代理转发。
* **项目链接**:[OpenResty 官网](https://openresty.org/)
### 2. FRP (Fast Reverse Proxy)
* **项目定位**:高性能的反向代理应用,专注于内网穿透。
* **在 OpenFlare 中的作用**:作为内网穿透子系统的底层隧道引擎。中继端管理器 `openflare-relay` 负责守护和调度 `frps` 引擎,而内网客户端 `openflared` 则负责在本地自动生成 TOML 配置并守护多路复用 `frpc` 子进程。
* **项目链接**:[fatedier/frp (GitHub)](https://github.com/fatedier/frp)
---
### 3. Anubis (PoW 方案)
* **项目定位**:基于工作量证明(Proof of Work)的轻量级人机验证防护方案。
* **在 OpenFlare 中的作用**:为网关 WAF 提供了核心的**无感防 CC 人机挑战**能力。
---
### 4. gin-template
* **项目定位**:基于 Go Gin 与前端构建的现代化全栈开发脚手架模板。
* **在 OpenFlare 中的作用**:为 OpenFlare 控制面(Server)提供了规范、统一的前后端系统架构雏形。
---
-72
View File
@@ -1,72 +0,0 @@
# 发布第一份配置
你会学到:如何以最简单的方式创建第一条反向代理规则、发布配置版本,并确认 Agent 已经拉取并应用配置。
OpenFlare 的发布链路以“不可变配置版本”为核心。你在管理端修改规则后,需要发布并激活新版本,在线的 Agent 才会自动同步并应用。
---
## 发布前检查
在开始发布前,请确保以下条件已满足:
| 检查项 | 状态要求 |
| --- | --- |
| **Server** | 控制面板已正常启动,且能顺利登录管理端 |
| **Agent** | 至少有一个 Agent 节点处于在线状态(可在「节点管理」中确认) |
| **源站** | 确认你的后端源站服务可从 Agent 宿主机正常访问 |
| **域名/测试** | 域名已完成 DNS 解析,或者准备好在客户端使用本地 hosts / curl 命令行 Host 头进行测试 |
---
## 步骤一:创建首个网站配置
为了快速验证,我们首先部署一个最基础的 HTTP 反代站点:
1. 登录控制面板,进入左侧导航 **「网站管理」->「域名列表」**,点击 **「新增网站」**。
2. 填写域名配置:
* **域名**:输入用于测试的域名(如 `first.example.com`)。
* **绑定证书**:选择不绑定证书(作为 HTTP 快速验证)。
* 点击保存,完成网站登记。
3. 进入左侧导航 **「规则管理」**,点击 **「新增规则」**:
* **规则名称**:输入简易标识(如 `first-app-route`)。
* **域名匹配**:填入你的测试域名(如 `first.example.com`)。
* 在下方 **「反向代理」** 选项卡中,配置 **源站类型** 为「标准反代」 (Direct)。
* **上游地址**:填写后端服务地址(如测试专用的 `http://httpbin.org`)。
* 点击保存创建规则。
> [!TIP]
> **关于 HTTPS 与证书准备**
> 本节仅引导快速部署基础 HTTP 规则。若你需要导入已有的 SSL 证书或通过 ACME 协议向 Let's Encrypt 自动申请证书并开启 443 端口 HTTPS 代理,请前往 [新建反代配置](./proxy-config.md) 查阅详细步骤。
---
## 步骤二:预览并发布配置版本
新增的网站配置仍保存在 Server 的数据库中,处于草稿状态,需要通过发布版本分发到数据面:
1. 点击控制面板右上角的 **「配置预览」** 按钮,系统会展示本次新增路由的物理配置文件 Diff 差异。
2. 确认渲染出的配置内容正确无误后,点击 **「发布并激活」**。
3. 控制面将生成一个唯一的配置版本号(格式为 `YYYYMMDD-NNN`)。
---
## 步骤三:验证 Agent 生效状态
发布成功后,控制面会立即通过 WebSocket 通知在线 Agent(若 WebSocket 离线,则会在 Agent 的心跳中作为差分感知):
1. **管理端验证**:进入「节点管理」-> 点击节点进入详情,检查**当前版本号**是否已成功变为刚刚发布的最新激活版本,且「应用记录」显示为成功。
2. **边缘节点验证**:你可以在 Agent 节点宿主机上通过日志检查应用情况:
```bash
# 如果是 Docker 部署的 Agent
docker logs openflare-agent
# 如果是本地 systemd 部署的 Agent
journalctl -u openflare-agent -n 50 --no-pager
```
3. **连通性测试**:
在客户端电脑上,使用 `curl` 携带测试 Host 请求 Agent 节点的 IP 地址进行最终验证:
```bash
curl -I -H "Host: first.example.com" http://AGENT_NODE_IP
```
若返回的状态码与后端源站响应一致,即代表你的第一条反代规则已成功在边缘节点落地生效!
-50
View File
@@ -1,50 +0,0 @@
# 指南
你会学到:OpenFlare 文档如何组织、首次运行应该读哪些页面,以及部署、使用、排查和开发分别从哪里开始。
OpenFlare 是一套自托管的 OpenResty 控制面。它把反向代理网站配置、配置版本发布、Agent 节点同步、TLS 证书和基础观测放到一个管理端中,适合单团队或单组织管理多台代理节点。
## 推荐阅读路径
如果你第一次接触 OpenFlare,按下面顺序阅读:
1. [快速开始](./quick-start.md):用 Docker Compose 启动 Server,登录管理端,并接入第一个 Agent。
2. [发布第一份配置](./first-site.md):快速新建一条最基础的 HTTP 反代站点规则,并验证节点生效状态。
3. [新建反代配置](./proxy-config.md):一步一步了解如何从证书导入与申请开始,配置 HTTPS 加密、上游源站与边缘缓存。
4. [Zone 域名迁移](./zone-domain-migration.md):从旧托管域名/路由内嵌域名升级到 Zone 模型(goose 自动导入),含备份、验收与回滚说明。
5. [Pages 静态托管使用](./pages-usage.md):了解静态项目 ZIP 上传限制、SPA Fallback、以及内置 API 反向代理配置。
6. [内网穿透与隧道使用](./tunnel-usage.md):部署 Relay 与 Client,实现安全、无公网 IP 反向穿透。
7. [WAF 安全防护使用](./waf-usage.md):配置 WAF 规则组,掌握 IP 黑白名单、自动/订阅 IP 组、地域限制与 PoW CC 防护。
8. [WAF 自动 IP 组语法](./waf-ip-group-expr.md):编写自动 IP 组 Expr 规则,了解关键字含义和预设规则。
9. [Uptime Kuma 监控同步](./uptime-kuma.md):配置并使用 Uptime Kuma 自动差分同步和监控范围控制。
10. [SSO 登录配置](./sso.md):配置 GitHub 或 OIDC 实现第三方单点登录 (SSO) 接入。
11. [故障排查](./troubleshooting.md):按症状排查登录、数据库、节点同步、OpenResty、边缘缓存命中与前端构建问题。
12. [引用与致谢](./credits.md):查看系统依赖的优秀开源项目与社区致谢清单。
## 按角色查找
| 你想做什么 | 推荐入口 |
| --- | --- |
| 5 分钟内跑起管理端 | [快速开始](./quick-start.md) |
| 发布第一条反向代理配置 | [发布第一份配置](./first-site.md) |
| 配置域名证书、反代与边缘缓存 | [新建反代配置](./proxy-config.md)(含缓存说明) |
| 静态资源不命中缓存 | [故障排查 · 边缘缓存](./troubleshooting.md#边缘缓存命中率异常) |
| 托管单页应用或静态网站 | [Pages 静态托管使用](./pages-usage.md) |
| 配置内网穿透映射 | [内网穿透与隧道使用](./tunnel-usage.md) |
| 配置防 CC 与 IP 组拦截 | [WAF 安全防护使用](./waf-usage.md) |
| 编写自动 IP 组规则 | [WAF 自动 IP 组语法](./waf-ip-group-expr.md) |
| 自动同步监测站点状态 | [Uptime Kuma 监控同步](./uptime-kuma.md) |
| 接入或重装节点 Agent | [接入 Agent](../deployment/agent.md) |
| 从源码启动 Server | [启动 Server](../deployment/server.md) |
| 配置 GitHub 或 OIDC 登录 | [SSO 登录配置](./sso.md) |
| 升级 Server 或 Agent | [升级与维护](../deployment/upgrade.md) |
| 理解架构和发布模型 | [系统架构](../design/architecture.md) 与 [Agent 与发布模型](../design/agent-design.md) |
| 查看开源引用与致谢 | [引用与致谢](./credits.md) |
## 文档分区
`guide/` 面向使用者和部署者,提供从安装到日常操作的可执行步骤。
`reference/` 收敛稳定事实,例如配置字段、命令、API 响应约定和仓库结构。
`design/` 面向维护者和贡献者,描述产品边界、系统架构、Agent 与发布模型和工程约束。新增能力或改变边界前,应先更新对应设计文档。

Some files were not shown because too many files have changed in this diff Show More