Compare commits

..

1306 Commits

Author SHA1 Message Date
ryan 0099ce7cb4 Merge branch 'main' into cordis 2026-09-17 21:43:50 +08:00
ryan c6ae84de81 refactor(layout): adopt wavelet manifest layout for configs and docker while preserving docker-compose 2026-09-03 10:46:51 +08:00
ryan 953a224245 refactor(arch): decouple private imports, enforce contracts and comply with cordis architecture 2026-09-03 10:40:02 +08:00
ryan dbcf485d8a Merge remote-tracking branch 'wavelet/main' 2026-09-03 10:27:18 +08:00
ryan 14d9bddf46 feat(contracts): implement driver.Valuer and sql.Scanner on UploadMetadataDTO 2026-09-03 10:27:08 +08:00
ryan 630803d5a3 merge: sync upstream wavelet/main (clean contracts DTOs and configs) 2026-09-03 10:25:52 +08:00
ryan 99fff5d5af fix(contracts): decouple DTOs from ORM tags and table name mappings 2026-09-03 10:17:11 +08:00
ryan 807343c82c feat(config): load manifest config.default.yaml with config.yaml override and clean root configs 2026-09-03 10:13:57 +08:00
ryan 25c3249ce2 Merge branch 'wavelet/main' into main 2026-09-03 10:08:50 +08:00
ryan 967c3e4209 feat(contracts): add TableName and gorm serializer tags to DTOs 2026-09-03 10:08:11 +08:00
ryan 52daf8129d Merge remote-tracking branch 'wavelet/main' 2026-09-03 09:56:59 +08:00
ryan 1d4bfea63b feat(contracts): add UploadMetadataDTO to contracts.UploadDTO 2026-09-03 09:56:54 +08:00
ryan 911a42fa10 Merge remote-tracking branch 'wavelet/main' 2026-09-03 09:42:15 +08:00
ryan c88c1c7b4e feat(contracts): provide SystemConfigService, UploadService and TaskService.GetExecutionByTaskID 2026-09-03 09:42:12 +08:00
ryan ed57e44e3c ci(arch): include openflare in cordis architecture checks 2026-09-03 09:32:43 +08:00
ryan b183e80565 merge(wavelet): sync upstream changes 2026-09-03 09:29:28 +08:00
ryan a190bd6a13 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	AGENTS.md
#	backend/docs/docs.go
#	backend/docs/swagger.json
#	backend/docs/swagger.yaml
#	frontend/components/providers/title-updater.tsx
#	frontend/messages/fragments/admin.en.json
#	frontend/messages/fragments/admin.zh-CN.json
#	frontend/proxy.ts
2026-09-03 09:16:22 +08:00
ryan 4407589b62 refactor(auth): modularize auth plugin with physical subpackages and decoupled services 2026-09-03 09:12:44 +08:00
ryan 2124bce7ca fix(core): optimize ioc interface caching, event parallel timeout and router teardown reversibility 2026-09-03 09:09:33 +08:00
ryan 30ab8810cc refactor(auth): merge cap domain plugin into auth 2026-09-03 08:58:34 +08:00
ryan 6b28adfacf refactor(admin): decouple system cleanup with event bus and enforce single owner principle 2026-09-03 08:50:37 +08:00
ryan 1e19d8114a refactor(msg_gateway): decouple bot gateway and push notification architecture
- Split shared monolithic consts into bot, push, and errs with typed sentinel errors
- Restructure model layer into distinct bot and push subdomains
- Refactor DAO layer to enforce single-owner principle and remove cross-table raw SQL queries
- Decompose 1150+ line service/push.go into push_channel, push_event, push_trigger, push_worker, and push_template
- Clean up controller layer with generic request handlers and parameter validation in controller/base.go
- Streamline plugin.go to core Cordis lifecycle orchestration and remove re-export bloat
- Verify all unit tests, race tests, Cordis architecture rules, and Swagger generation pass cleanly
2026-09-02 23:21:36 +08:00
ryan 8395dd5019 refactor(msg_gateway): restructure and rename message_gateway aligned with custom_example 2026-09-02 22:50:40 +08:00
ryan 87e3bfd0e6 chore: doc 2026-09-02 22:41:06 +08:00
ryan fff3a7589c docs(plugin): unify plugin development template based on custom_example 2026-09-02 22:39:13 +08:00
ryan 6388c28b91 chore: template 2026-09-02 22:34:19 +08:00
ryan 7df31befb5 feat(message_gateway): integrate nikoksr/notify engine and support multi-channel push 2026-09-02 22:30:59 +08:00
ryan 90f3efdd50 ci(arch): forbid pkg packages from depending on project core packages 2026-09-02 22:22:04 +08:00
ryan 9632604958 feat(auth): implement decoupled sliding-window rate limiting for login and oauth 2026-09-02 22:15:21 +08:00
ryan 39f02b5d7a refactor(message_gateway): upgrade notification template engine with text/template and rich helpers 2026-09-02 22:04:48 +08:00
ryan cf0cba0679 refactor(mail): modernize smtp sending with go-mail and unify message gateway pusher 2026-09-02 21:59:47 +08:00
ryan 88ed98b013 chore: remove accidental test upload
fix(user): backfill snowflake id on login for legacy zero user
2026-09-02 21:47:12 +08:00
ryan 40c2212dd3 fix(upload): apply login middleware to /f/:id route
/f/:id had no LoginRequired middleware, so AuthUserObjKey was never
populated and GetCurrentUser/GetUserIDFromContext could not authenticate
even logged-in users, returning 401 未登录 on private files. Add loginMW.
2026-09-02 20:32:33 +08:00
ryan df6aa9ff4d fix(auth): encode snowflake user ids as strings in session and /user-info
Registered users get snowflake ids above JS MAX_SAFE_INTEGER.
/user-info emitted them as JSON numbers and login stored uint64 in
the session. Both now use decimal strings. Tests cover admin vs
non-admin cookie access to /user/self, /user-info, and /upload/my.
2026-09-02 20:22:22 +08:00
ryan c27da41b64 fix(auth): forward session cookies through the Next API proxy
Login Set-Cookie was dropped by Next rewrites, so non-admin sessions
never stuck and every later API looked unauthenticated. Proxy JSON
APIs in proxy.ts, copy Set-Cookie, send 401 to login and 403 to /403.
2026-09-02 18:49:42 +08:00
ryan 353a5f9f75 fix(user): assign snowflake IDs on registration
The HTTP register path left ID at 0, so SQLite/GORM filled a
serial primary key. CreateUser now generates a snowflake ID when
none is set, matching admin create and OAuth signup.
2026-09-02 18:41:37 +08:00
ryan 05606dfb56 feat(frontend): add a dedicated 403 forbidden page
Show /403 instead of toasting or staying on the denied screen when
the API returns 403 or a non-admin opens an admin route.
2026-09-02 18:39:11 +08:00
ryan b7e5e811d1 fix(auth): register CAP scope, 400 on captcha, 403 for permission
Navigating from login reused a send_email_code token on register.
Captcha failure used 401 so the client stored /register as the
post-login target and never left the page. Permission denials now
return 403, and the API client no longer wipes the session on 401.
2026-09-02 18:26:22 +08:00
ryan df7ad453cc fix(tasks): canonicalize triggered_by so execution labels resolve
Unknown values such as http and inproc_cron made the admin UI call
t(undefined). Dispatch sites now write system/manual/retry/schedule,
the list API maps legacy rows, and the table skips missing i18n keys.
2026-09-02 18:04:32 +08:00
ryan 1f1f4efec7 fix(admin): stop console Intl errors and log websocket drops
Use raw i18n for push template hints so ICU does not parse
{{placeholders}}. Pass total into the user list record count.
Allow log websocket origins behind the Next rewrite, skip the
proxy on Upgrade, and do not open a socket after unmount.
2026-09-02 17:52:57 +08:00
ryan 8aa0753b12 fix(logs): flush small access-log batches within two seconds
Default MinBatchSize of 50 left quiet admin traffic in memory
forever because MaxFlushWait was unset. Force a timed flush so
the logs page can show recent authenticated requests.
2026-09-02 17:40:43 +08:00
ryan bec1352ef7 fix(logs): collect access logs regardless of plugin order
Global Router.Use middleware is applied at HTTP Start instead of
being snapshotted when each route is registered, so risk_control
still wraps admin APIs that mount earlier. Access-log collection
is enabled by default on SQLite/Postgres, not only ClickHouse.
2026-09-02 17:39:40 +08:00
ryan ef88811ccb fix(frontend): call versioned CAP challenge and redeem APIs
Point the PoW solver at /api/v1/cap/{challenge,redeem} so login
verification hits the routes registered by the cap plugin.
2026-09-02 17:31:06 +08:00
ryan 455e2f8be5 fix(config): serve public settings and enforce login CAP
Public config now comes from admin as a flat visibility=1 map instead of
a cross-plugin query that compared an integer column to "visible". Login
and register resolve CaptchaService per request so CAP is not skipped
when user applies before cap.
2026-09-02 17:07:07 +08:00
ryan 4f50f6a8f9 feat(core): bind request services and implement registered tasks
Wire plugin services through Bind/InjectFrom and AppContext so HTTP and
workers resolve dependencies after Apply. Register TaskHandler objects
with persisted results, and implement send_email_code, mail:send,
cleanup_inactive_users, and dispatch_bot_msg.
2026-09-02 16:59:00 +08:00
ryan 30bbe965bf fix(task): execute dispatched jobs and persist run records
Asynq func handlers now go through ProcessTask so admin execution
rows leave pending. The in-process worker resolves admin type
identifiers and writes the same w_task_executions table. Remove
the no-op admin system_cleanup that shadowed the upload handler.
2026-09-02 16:11:45 +08:00
ryan 33f28ad671 fix: sql 2026-09-02 15:37:06 +08:00
ryan 18339ee3d4 fix: pg sql 2026-08-31 15:48:28 +08:00
ryan f975c4f7cc merge(cordis): abort helpers and gold upgrade fixture
Bring Wavelet AbortNotFoundIfMissing/AbortBadRequestOnError, switch OpenFlare handlers to them, and pin the golden upgrade test to v3.5.4 via git archive.
2026-08-30 18:03:04 +08:00
ryan c22ca408d4 merge: merge branch 'feat/cordis-router-raw-routes' into main 2026-08-30 17:57:38 +08:00
ryan 374289bfda fix(api): align upload permissions and mount robots and swagger routes 2026-08-30 17:53:42 +08:00
ryan 7ce75d1dd0 test(cmd): extract gold v3.5.4 via git archive
Build the upgrade fixture from commit 9f79fb99 instead of the gold working tree, which no longer has main.go at the repo root.
2026-08-30 17:48:50 +08:00
ryan b216175ee2 refactor(share): move githubrelease to openflare/share 2026-08-30 17:47:44 +08:00
ryan b72b1cfc16 refactor(server): use Wavelet response abort helpers
Call response.AbortNotFoundIfMissing and AbortBadRequestOnError from handlers and drop the OpenFlare-local copies.
2026-08-30 17:46:45 +08:00
ryan 7c5c196ede feat(response): add AbortNotFoundIfMissing and AbortBadRequestOnError
Lift the handler helpers that map a non-nil error to Abort* so plugins do not each reimplement record-not-found vs bad-request branching.
2026-08-30 17:45:06 +08:00
ryan a7c3b6a670 merge(wavelet): pull AbortNotFoundIfMissing helpers
Use Wavelet pkg/response for record-not-found and bad-request abort helpers.
2026-08-30 17:45:06 +08:00
ryan c93ff6674f refactor(backend): rename OpenFlare directory to lowercase openflare 2026-08-30 17:43:23 +08:00
ryan 06d5fedbfc refactor(server): nest kernel and domain under classified roots
Keep only plugin entry, httpapi, migrate and updater at the server root. Shared model/repository/adapters live in kernel/; product bounded contexts live in domain/.
2026-08-30 17:35:26 +08:00
ryan f064237081 refactor(server): group OpenFlare domains into bounded contexts
Drop the openflare/ and router/v1 nesting. Product code lives under site, fleet, pages, waf, tls, cloudflare, observability, dashboard and option; HTTP wiring is httpapi. Shared model/repository stay the kernel.
2026-08-30 17:31:15 +08:00
ryan f14e9591e0 test(cmd): assert fresh install seeds of_* schedules
Cover the compressed 00001 seed path so new sqlite databases get the four OpenFlare schedules and not of_database_auto_cleanup.
2026-08-30 16:58:48 +08:00
ryan 7af6fee5d5 fix(core): apply earlier pending plugins before later ones
Rescan the Use() list after each Load so a consumer registered before its provider still runs before later consumers that became ready in the same pass.
2026-08-30 16:58:20 +08:00
ryan fb5cfbfd30 merge(wavelet): honor Use() order when reconciling plugins
Pull the core reconcile rescan so admin migrations run before OpenFlare server seeds that insert into w_schedules.
2026-08-30 16:58:20 +08:00
ryan d531d71778 refactor(server): merge migrate package and drop unused admin/migrator trees
Collapse stamp, of_* SQL and ClickHouse into server/migrate, hoist updater out of admin, and delete the unused 76-file historical chain.
2026-08-30 16:54:08 +08:00
ryan 3e8a777817 fix(server): seed OpenFlare schedules and configs in 00001
Fold gold-minus-Wavelet w_system_configs keys and the four of_* schedules into the compressed initial migration so new installs get product defaults.
2026-08-30 16:48:11 +08:00
ryan 471d237af0 test(cmd): require v1 cap and /api/healthz only
Update route assertions and regenerate swagger so the process no longer documents /api/cap or /api/health.
2026-08-30 16:44:10 +08:00
ryan 8931c3559c fix(frontend): call /api/v1/cap and document /api/healthz
Point the login captcha solver at the versioned challenge/redeem endpoints and document the remaining health probe.
2026-08-30 16:42:33 +08:00
ryan 0d53be2896 merge(wavelet): pull v1-only cap and healthz routes
Bring Wavelet feat/cordis-alignment so OpenFlare registers /api/v1/cap and GET /api/healthz only.
2026-08-30 16:41:24 +08:00
ryan 6553ac7782 fix(system): expose only GET /api/healthz
Remove /healthz and /api/health so the process advertises a single probe at /api/healthz with {status: ok}.
2026-08-30 16:41:07 +08:00
ryan 12b4c3e54c fix(cap): keep only /api/v1/cap routes
Drop the unversioned /api/cap aliases so Challenge and Redeem exist only under /api/v1/cap.
2026-08-30 16:39:40 +08:00
ryan d1e5c9acd8 docs(cordis): 添加 API 收口与 server 目录整理计划
Wavelet 去掉 cap/health 别名,前端改 v1;00001 补 OF 种子;
合并 migrate/updater 并删除 76 条历史 SQL。
2026-08-30 16:36:19 +08:00
ryan 14232838dd docs(cordis): 记录 API 收口与 server 目录整理设计
cap/health 只留 v1 或 /api/healthz;server 合并 migrate 包;
压缩 00001 必须含 of_* 建表与 OpenFlare 种子数据。
2026-08-30 16:33:22 +08:00
ryan a347c33608 chore(cordis): persist merge.ours and isolate wavelet extras
Add a repo .gitconfig so merge=ours in .gitattributes can actually run.
Disable the Wavelet canary image workflow and keep docker-compose.yaml
as the OpenFlare default so a merge cannot ship the wrong product.
2026-08-30 15:00:44 +08:00
ryan 220c469ee7 fix(build): embed frontend into driver_http dist
Copy the static export to backend/plugins/drivers/driver_http/dist so
Wavelet's //go:embed all:dist actually ships the OpenFlare UI. Point
release builds at backend/go.mod and assert index.html after copy.
2026-08-30 15:00:30 +08:00
ryan df271ff708 chore(cordis): drop rsync sync after git upstream is connected 2026-08-30 14:32:18 +08:00
ryan c68038ad06 Merge remote-tracking branch 'wavelet/feat/cordis-alignment' into cordis
# Conflicts:
#	.agents/skills/cache-framework/SKILL.md
#	.agents/skills/clickhouse-batchwriter/SKILL.md
#	.agents/skills/database-migration/SKILL.md
#	.agents/skills/file-upload/SKILL.md
#	.agents/skills/logstore/SKILL.md
#	.agents/skills/new-api/SKILL.md
#	.agents/skills/new-api/references/handler_example.go
#	.agents/skills/new-api/references/logics_example.go
#	.agents/skills/new-api/references/service_example.go
#	.agents/skills/new-async-task/SKILL.md
#	.agents/skills/new-async-task/references/CODE-EXAMPLES.md
#	.agents/skills/new-setting/SKILL.md
#	.agents/skills/push-notification/SKILL.md
#	.agents/skills/release-guide/SKILL.md
#	.auto/checks.sh
#	.auto/ideas.md
#	.auto/log.jsonl
#	.auto/measure.sh
#	.auto/prompt.md
#	.dockerignore
#	.env.example
#	.github/copilot-instructions.md
#	.github/workflows/build-release.yml
#	.gitignore
#	.golangci.yml
#	AGENTS.md
#	Makefile
#	README.md
#	backend/cmd/app.go
#	backend/cmd/app_test.go
#	backend/cmd/banner.go
#	backend/cmd/banner_test.go
#	backend/docs/docs.go
#	backend/docs/swagger.json
#	backend/docs/swagger.yaml
#	backend/go.mod
#	backend/go.sum
#	backend/main.go
#	config.example.yaml
#	docker/Dockerfile
#	docker/Dockerfile.backend
#	docker/Dockerfile.cross
#	scripts/swagger.sh
#	scripts/update_go_license.sh
2026-08-30 14:30:56 +08:00
ryan 4fb47e65f4 chore(git): keep OpenFlare-owned paths on merge 2026-08-30 14:28:38 +08:00
ryan 56c650c5b5 docs(swagger): restore gold @Router comments on platform APIs 2026-08-30 14:20:21 +08:00
ryan 6cabad3197 fix(cordis): satisfy L2 test and swagger gates 2026-08-30 14:17:51 +08:00
ryan 2d0f2ef3df test(cmd): upgrade sqlite/postgres from OpenFlare v3.5.4 golden 2026-08-30 14:03:51 +08:00
ryan d3a91dcc3f feat(server): stamp legacy goose versions and own of_* migrations only 2026-08-30 13:43:38 +08:00
ryan 6c7090aa8e fix(server): route system config and lookups through Wavelet contracts 2026-08-30 13:25:11 +08:00
ryan 8b24af1310 refactor(server): drop Wavelet-duplicated domains and use contracts 2026-08-30 13:00:13 +08:00
ryan e48485f27d feat(cmd): assemble control plane via Wavelet plugins plus server 2026-08-30 12:13:43 +08:00
ryan e847a7adb3 chore(cordis): sync Wavelet core/pkg/plugins after W1-W9 2026-08-30 11:46:58 +08:00
ryan a617457a3c feat(core): add WithMigrationBaseline hook before goose Up 2026-08-30 11:41:00 +08:00
ryan 4ce7110e23 feat(platform): add GET /api/health and GET /api/v1/user/self 2026-08-30 11:32:03 +08:00
ryan 9a5c2fa643 feat(message_gateway): expose PushRegistry contract 2026-08-30 11:23:01 +08:00
ryan 177d771acf feat(upload): mount existing my/update/download routes on user API 2026-08-30 11:17:11 +08:00
ryan 6f25618e83 fix(config): decode *bool so trailing-slash redirect binds from yaml/env 2026-08-30 11:10:36 +08:00
ryan b4c4b0a27e feat(http): make trailing-slash redirect configurable 2026-08-30 11:07:22 +08:00
ryan b8ad06f49f feat(system): allow PublicConfigProvider to replace public config payload 2026-08-30 11:03:17 +08:00
ryan 254533c013 feat(cap): expose CaptchaService and unversioned /api/cap routes 2026-08-30 10:53:43 +08:00
ryan be79eb4eb7 feat(core): add HandleRaw and BasePath for trailing-slash routes 2026-08-30 10:44:14 +08:00
ryan dd333643f2 docs(cordis): 添加与 Wavelet 对齐的实施计划
按 W1–W9、装配根、删平台副本、stamp 升级与 git merge 拆成
带测试的任务,金标准 v3.5.4 只读。
2026-08-30 10:37:13 +08:00
ryan 823bec1272 docs(cordis): 记录与 Wavelet 对齐的 Cordis 设计
明确框架与业务边界、Wavelet 通用扩展点、stamp 升级路径、
金标准库验证,以及以 git merge 接入上游且不改写历史。
2026-08-30 10:26:37 +08:00
ryan dbaa3bf140 feat(cordis): add OpenFlare Cordis 架构改造设计
docs(changelog): 修正表述笔误

refactor(cordis): 磁盘缓存改用上上游能力并清理本地副本

按上游/下游归属规约:类型断言守卫已回流 Wavelet(f3d85d5,附回归用例),
本仓库删除 OpenFlare/plugins/server/pkg/cache 整包并改 import 到
Wavelet/pkg/cache/disk,同步后与上游零漂移。

验证:go build 通过;go test ./... exit 0(137 包 ok);256 条路由对拍与
232 条 swagger 操作均零差异;make build-all 四进制;前端零改动。

docs(cordis): 记录 T1 清理结果与五个复用阻塞点

refactor(cordis): server 复用上游 pkg 能力并删除等价本地副本

按上游/下游归属规约清理重复实现,删除 7 个与上游等价的本地包并改 import:
shared/response→pkg/response、pkg/{logger,mail,trace,httppool,cache/ram}→
上游同名包、infra/persistence/batchwriter→pkg/batchwriter。逐项核过差异:
httppool 逐字节相同;logger 的 Config 字段完全一致;response 的 7 个 Abort*
一致;cache/ram 换过去顺带把裸 go 变回带 panic 恢复的 util.Go。

两处非等价差异按语义处理:
- batchwriter.Stats 与 status DTO 原为类型别名,改为消费侧逐字段转换,
  避免 model 反向依赖基础设施类型;
- 上游 pkg/idgen 要求显式 Init(本地副本为懒加载自动初始化),本次保留本地
  副本,待与 infra 初始化一并迁移(已登记在清理计划)。

验证:go build 通过;go test ./... exit 0(138 包 ok);256 条路由对拍零差异;
make swagger 232 条操作零增减,且归一化后与旧文档深度相等——差异仅为
response.Any / logger.LogEntry 两个定义名随包路径改名,接口形状未变。

chore(cordis): 回流内核与 pkg/util 通用能力并清理 vendoring 污染

按新增的上游/下游归属规约:HandleRaw/BasePath 与版本比较、网络、格式化助手
属通用能力,已提交到 Wavelet 分支 feat/cordis-router-raw-routes,本仓库改为
纯同步获取(pkg/util 已零漂移),补丁登记保留至上游合并。

同时修掉我此前 git add -A 造成的污染:首次 vendoring 把上游工作区里被
gitignore 的运行期产物一起提交进来(upload 的 diskcache 缓存块 650 个与
driver_http/dist 前端构建物 380 个,共 12872 行/1030 文件)。sync-upstream.sh
现显式排除 uploads/dist/data/*.db,.gitignore 补上对应兜底规则。

AGENTS.md 增加上游/下游改动归属规约,并把仍指向前 Cordis 布局的硬性约束
(internal/router + Serve、internal/repository/logstore、internal/platform/bootstrap、
internal/cmd)改到当前插件路径。

验证:go build 通过;go test ./... exit 0(144 包 ok);make swagger 232 条
操作与基线逐条一致;make build-all 四进制;gofmt 干净。

feat(cordis): server 插件化并改由内核挂载控制面路由

新增 plugins/server/plugin.go:Apply 以 ctx.Router().Group(app.api_prefix)
声明根级与 /v1 全部路由;33 个注册函数由 *gin.RouterGroup 改为
core.RouterExtension,RegisterCollection 改用内核新增的 HandleRaw 保留
尾部斜杠变体,AdminMiddlewares 返回 []any(Go 不允许把 []T 展开为 ...any)。
删除 router.Serve 与 registerRoutes,装配根改为 core.App +
driver_http.New(WithEngine(router.BuildEngine())),监听、信号与优雅退出归内核;
前端 SPA 的 NoRoute 兜底因内核暂无贡献点而保留在引擎层。

路由保真证据:plugin_parity_test 对拍 baseline/routes-engine.txt 的 256 条
(方法 路径) 零差异;go test ./... exit 0(144 包 ok,含真实 handler 的
openflare/integration 用例走同一条挂载路径);make swagger 232 条操作与基线
逐条一致;golangci-lint 0 issues;make build-all 四进制;embed_frontend
标签编译通过;前端零改动。

已知待补:带 Redis 的实机 HTTP 冒烟(本机 6379 未启动,session store 与
改造前一样在建店阶段即 fatal),以及 bootstrap 的任务/设置/迁移注册迁入 Apply。

feat(core): RouterExtension 增加 HandleRaw 与 BasePath 以保真尾部斜杠路由

server 插件化的前置:Handle 经 cleanPath 会剥掉尾部斜杠,无法表达
/resource 与 /resource/ 两条不同路由,而 OpenFlare 有 20 个历史 list
端点两者都注册且部署关闭了 RedirectTrailingSlash,缺失即 404。新增
HandleRaw 与 BasePath(作用域包装器同样登记反注册),补 extpoints 用例;
并把 router.Serve 拆出 BuildEngine 以便交给 driver_http.WithEngine 复用,
新增路由表导出 harness,固化 256 条 (方法 路径) 基线供插件化对拍。
上游补丁登记于 backend/OpenFlare/upstream-patches.md,同步脚本改为按目录
前缀输出差异并在同步后提醒确认补丁是否仍在。

验证:go build 通过;go test ./... exit 0(143 包 ok);gofmt 干净。

docs(cordis): 记录 server 插件接入内核的可行路径与内核能力缺口

feat(cordis): agent/relay/flared 落地为内核驱动插件

三个边缘守护进程各新增 plugin.go,实现 core.Plugin + core.Driver
(自定义 DriverType 与同名 profile),装配与生命周期从 main 迁入
Apply/Start/Stop:Apply 负责 JSON 配置加载、运行环境与用户确保、
openresty/frps/frpc 管理器与各服务装配;Start 以 util.Go 拉起阻塞式
runner 与 GeoIP 周期更新;Stop 收敛主循环结果并在超时时报错而非静默。

入口改为 core.NewApp(core.WithProfile(...)) + Prepare/Run,保持
-config 旗标、默认路径、退出码与启动/停止日志不变。

验证:go build 通过;go test ./... exit 0(143 包 ok,含 3 个插件身份
与配置失败路径测试);make build-all 四进制产出;三进制实跑缺失配置
均 exit 1 且错误链保留 load {agent,relay,flared} config 原因;gofmt 干净。

refactor(cordis): 按功能职责拆分为 4 个插件与 share 共享层

backend/OpenFlare 不再平铺遗留分层,改为 plugins/{server,agent,relay,flared}
加 share/:控制面业务(openflare/admin/oauth/user/upload/cap/config/health 与
repository/model/infra/router 等支撑层)归 server;三个边缘守护进程各自成插件;
被两个以上插件消费的 protocol/geoip/wsclient/render/pagesarchive/edge 归 share。
同时把 pkg/util 与 buildinfo 合并回上游 pkg(上游已覆盖全部符号,仅 8 个函数与
2 个类型为 OpenFlare 独有,已一并迁入),装配根统一到 backend/cmd(含三个 daemon
入口),Dockerfile 与 release 工作流的构建路径和 -X 注入路径同步更新。

验证:go build 通过;go test ./... exit 0(141 包 ok);make swagger exit 0 且
232 条 API 操作与基线逐条一致;make build-all 产出 4 进制;-X 注入经二进制
strings 实测生效;日志后端直连门禁改写为按 server 插件业务域扫描并在扫描数为 0
时报错(防门禁静默失效);前端零改动。

feat(cordis): 落地 backend/share 共享层与上游同步脚本

跨插件共享资源(控制消息协议、GeoIP+iputil、边缘守护进程日志)从下游包
移入 backend/share,并声明其只能依赖 core/pkg 与标准/第三方库,禁止反向
引用下游业务与具体插件实现;新增 scripts/sync-upstream.sh 只覆盖
backend/{core,pkg,plugins},同步后 --check 报告零差异,证明与上游逐字一致。

go build 通过,go test ./... exit 0(142 包 ok),前端零改动。

refactor(cordis): 采用与 Wavelet 同构的单模块布局并引入上游内核

按上游结构落位:backend/{core,pkg,plugins} 为 Wavelet 上游拷贝,OpenFlare
全部业务收拢到上游 downstream 所对应的位置 backend/OpenFlare/,模块名保持
Wavelet 以保证上游 import 路径逐字一致、同步零改写;三个 daemon 入口移至
backend/OpenFlare/cmd,backend/cmd 与 main.go 作为控制面装配根。

行为不变:go build 通过,142 个测试包全绿(含上游插件测试),232 条 API
操作与改造前逐条一致,四进制产物正常,前端零改动。swagger 暂只扫描下游代码,
待 P4 挂载上游路由后再纳入 plugins/。

style: 修正模块路径改写导致的 import 分组排序漂移

refactor(layout): Go 代码迁入 backend/ 并将模块名简化为 OpenFlare

对齐上游 Wavelet 的仓库布局,为以第二 module 形态 vendoring Cordis 内核与
平台插件做准备:模块路径整体改写为 OpenFlare,Go 目标加 cd backend,
swaggo 产物移至 backend/docs 并把 json/yaml 复制回 docs/ 供站点消费,
Dockerfile 与 release 工作流的构建目录、ldflags 模块路径同步更新。

行为保持不变:232 条路由与改造前逐条一致,95 个测试包全绿,
四进制产物正常,前端零改动。

chore(cordis): 落地改造计划与 schema/路由基线

新增 legacy_dump_test 迁移快照 harness:在临时 sqlite 库上按生产顺序
(goose.UpTo → zone 导入 → goose.Up)跑完 76 个历史迁移并导出 schema 与
版本序列,作为改造前后一致性门禁的唯一事实来源。同时记录 232 条路由清单
与 foundation 实施计划。

docs(cordis): add OpenFlare Cordis 架构改造设计

明确上游以第二 module 形态 vendoring 进 backend/Wavelet、4 个插件
(server/agent/relay/flared) 全部装载内核,并规定保留 76 个历史 goose
迁移 + 一次性版本 stamp 桥接的迁移方案,配套三方 schema 一致性门禁,
确保已部署库不重跑历史、不丢数据。
2026-08-30 10:12:52 +08:00
ryan f3d85d51fb fix(pkg/cache/disk): LRU 节点类型断言失败时降级而非 panic
items 与 evictList 的不变量一旦被破坏,读、写、删除与淘汰路径上的裸类型断言
会直接崩掉进程。改为带 ok 检查:Get 退化为缓存未命中,Set 报告污染条目,
deleteUnlocked 跳过容量回退,evict 移除坏节点后继续。

新增 cache_corruption_test.go 锁住该行为:去掉守卫后用例会以
「interface conversion: interface {} is string, not *disk.cacheItem」失败,
加上守卫后 4 个用例全通过。

验证:go build 通过;go test ./pkg/cache/disk/ 全绿(含原有 5 个用例);
golangci-lint 0 issues;check_cordis_architecture.sh 0 violations。
2026-08-30 01:00:02 +08:00
ryan 8ff017b5e8 feat(pkg/util): 补齐版本比较、网络与格式化通用助手
下游 OpenFlare 的边缘守护进程与发布流程需要这些与业务无关的纯函数,
按上游/下游归属规约回流到平台层,避免下游在上游目录里长期携带本地文件:

- version / version_compare:CompareVersions、ParseVersionInfo(版本区间比较)
- network:GetIP、IsPrivateIPv4
- format / value / string / slice:Bytes2Size、Seconds2Time、Interface2String、
  TrimStringFields、UniqueAndCleanStringSlice 与 IdentifiableTimeRecord

验证:go build 通过;go test ./... exit 0(48 包 ok);
check_cordis_architecture.sh 0 violations;golangci-lint 0 issues;gofmt 干净。
2026-08-30 00:36:13 +08:00
ryan bad6fa785d refactor(core): Handle 与 HandleRaw 共用 addRoute
消除注册逻辑重复,并修正 HandleRaw 里 append(g.registry.middlewares, ...)
复用底层数组的隐患:中间件快照统一在 addRoute 内构造为新切片。

验证:go build 通过;go test ./core/... 全绿;golangci-lint ./core/... 0 issues。
2026-08-30 00:26:01 +08:00
ryan cb339ab0dc feat(core): RouterExtension 增加 HandleRaw 与 BasePath
Handle 经 cleanPath 归一化会剥掉尾部斜杠,插件无法同时声明 /resource 与
/resource/ 两条路由;部署关闭 gin 的 RedirectTrailingSlash 时,缺失的那条
直接 404。下游 OpenFlare 有 20 个历史列表接口依赖该行为。

- HandleRaw:与组前缀拼接但保留尾部斜杠,分配独立路由 ID;
- BasePath:返回组的绝对前缀(根注册表为空串);
- 作用域包装器为 HandleRaw 同样登记 OnDispose 反注册。

验证:go build 通过;go test ./... exit 0(48 包 ok);
check_cordis_architecture.sh 0 violations;gofmt 干净。
2026-08-30 00:22:56 +08:00
ryan 3b24d248a7 docs(autoresearch): proposals for the five deferred architectural items 2026-08-29 19:32:35 +08:00
ryan 350bd422f5 chore(autoresearch): log iter 35 2026-08-29 19:31:41 +08:00
ryan d7c851bc47 autoresearch iter 35: BUGFIX a failed whitelist read is no longer cached as an admin decision 2026-08-29 19:29:25 +08:00
ryan db9d12f8c9 chore(autoresearch): log iter 34 2026-08-29 19:20:50 +08:00
ryan b22f8633ba autoresearch iter 34: BUGFIX an unreadable SMTP config no longer looks like an unconfigured mailer 2026-08-29 19:19:17 +08:00
ryan 578b4618ce chore(autoresearch): log iter 33 2026-08-29 19:15:27 +08:00
ryan 99fca9ee09 autoresearch iter 33: BUGFIX storage migration no longer migrates from a config it could not read 2026-08-29 19:12:58 +08:00
ryan 608cce19c9 docs(autoresearch): lesson 12 and harness standing notes 2026-08-29 19:06:00 +08:00
ryan 6e5ed979e4 chore(autoresearch): log iter 32 2026-08-29 19:05:17 +08:00
ryan f7a86d3608 autoresearch iter 32: PERF whitelist parses patterns once, 14 allocs/op to 1 2026-08-29 19:03:27 +08:00
ryan 22a491ff37 chore(autoresearch): log iter 31 2026-08-29 18:57:29 +08:00
ryan 5193bd0451 autoresearch iter 31: isolate lint result cache per checkout in harness 2026-08-29 18:55:36 +08:00
ryan 53fc3a81dc chore(autoresearch): log iter 30 2026-08-29 18:51:56 +08:00
ryan 9ea0e2bdff autoresearch iter 30: enforce user lookup column allow-list instead of trusting a comment 2026-08-29 18:49:37 +08:00
ryan f29ac19673 chore(autoresearch): log iter 29 2026-08-29 18:46:21 +08:00
ryan 2ff0cb87c9 autoresearch iter 29: CORDIS contracts DTO must not carry a table name 2026-08-29 18:45:17 +08:00
ryan 4412093d05 chore(autoresearch): log iter 28 discard 2026-08-29 18:42:44 +08:00
ryan 50370971ec Revert "autoresearch iter 28: CORDIS gate contracts must not carry table names"
This reverts commit 2cb8d9a892.
2026-08-29 18:42:31 +08:00
ryan 2cb8d9a892 autoresearch iter 28: CORDIS gate contracts must not carry table names 2026-08-29 18:40:04 +08:00
ryan 9f79fb9969 chore(release): v3.5.4
### ✨ 新功能
- 控制台接入中英双语(next-intl,无 URL 语言前缀):默认中文,可在顶栏或「外观设置」切换,选择写入 cookie 后刷新生效。

### 🛠 修复
- 修复在网站列表中删除已加入 Cloudflare 指向分组的域名后,访问 Cloudflare 指向分组详情报错「Cloudflare 资源不存在」的问题。
- 修复自定义 Webhook 推送在企业微信/钉钉返回 HTTP 200 但 `errcode` 非零时仍记为成功的问题;任务日志会记录上游响应体。
- 修复 OpenTelemetry Resource 绑定 semconv schema 版本导致 SDK 升级后可能无法启动的问题。
- 修复静态导出(build:embed)部署下切换语言无效的问题:此前页面在构建时固定为默认中文,运行时不再读取 `NEXT_LOCALE`;现在客户端会按 cookie/浏览器语言重新解析并切换界面语言与 `html lang`。
- 修复 frpc 子进程在被杀后孤儿进程继续持有管道导致退出阻塞的问题。

### 💄 其他/体验
- 前端使用 `next/font` 自托管 Inter 字体,并忽略浏览器扩展改写 `body` 属性引起的 hydration 警告。
2026-08-29 18:34:07 +08:00
ryan 8b746e1d0e chore(autoresearch): log iter 27 2026-08-29 18:34:01 +08:00
ryan 31f3af61c5 autoresearch iter 27: drop one dead contextcheck suppression, document the other 2026-08-29 18:32:54 +08:00
ryan bbd7f72c2d fix(cloudflare): clean up pointing member when zone domain is deleted 2026-08-29 18:32:02 +08:00
ryan ea97b64407 fix(task): restore task metadata contract and type fields in task types api 2026-08-29 12:22:51 +08:00
ryan 49f9d1076f fix(admin): move w_task_executions and w_schedules migrations to admin plugin
- Include w_schedules and w_task_executions DDL in admin initial migrations for both SQLite and PostgreSQL dialects
- Remove driver-specific migration registrations from driver_asynq_worker and driver_asynq_cron
- Fix missing table error when running in Zero-Redis standalone mode without Asynq
- Update white paper table ownership mapping and ensure test cleanup
2026-08-29 11:58:56 +08:00
ryan e568b96388 fix(auth): add missing masked_token column to w_access_tokens table in migrations 2026-08-29 11:57:04 +08:00
ryan 7786f416f8 perf(auth): eliminate redundant password queries during user info retrieval 2026-08-29 11:54:46 +08:00
ryan 64fe1658d4 fix(user): clear need_change_password and invalidate cache on password change 2026-08-29 11:52:41 +08:00
ryan d3d7c783a9 fix(auth): synchronize need_change_password across login, user-info and repositories 2026-08-29 11:49:14 +08:00
ryan 107251891f fix(user): restore plaintext default password checking and warning mechanism 2026-08-29 11:46:39 +08:00
ryan 86c750077f fix(user): seed default administrator account in initial migration 2026-08-29 11:42:43 +08:00
ryan d043e7366f docs: update developer guide and white paper with router whitelist and session fallback 2026-08-29 11:40:50 +08:00
ryan e0f2309520 feat(router): add whitelist mechanism for http driver and auth plugin
- implement route whitelist registration and wildcard matching in RouterExtension
- add cookie store session fallback when Redis is disabled in driver_http
- actively register public auth endpoints to whitelist in auth plugin
- update user handlers to persist session and clear cookie on logout
- document router whitelist mechanism in AGENTS.md and new-api skill
2026-08-29 11:39:13 +08:00
Ryan 53ae3007d0 fix(cordis): fail-closed auth guards for user/message_gateway/admin (#1)
* autoresearch iter 23: fail-closed auth guarding for user/message_gateway

Both plugins resolve contracts.AuthService in Apply to build their route
middleware, but declared only DBService in Inject(). The kernel gates a
plugin's Apply solely on declared deps, and cmd/app.go registers user
before auth, so user mounted first, core.Inject failed, and loginMW
silently degraded to a pass-through closure — leaving /api/v1/user
change-password, profile and access-tokens unguarded. message_gateway
was saved only by its later list position.

Declare AuthService in Inject() for both, and pin the property with a
reconcile-level test that mirrors production registration order and
asserts the real auth middleware reaches the route table.

* autoresearch iter 24: make auth middleware fallbacks fail closed

user, message_gateway and admin each fell back to a c.Next() closure when
contracts.AuthService could not be resolved, so a route would be served as
if authenticated. For admin this is reachable at runtime: OnDispose calls
service.ResetServices(), which nils the global the per-request guard reads,
so requests still in flight during dispose bypass authorization entirely.

Add ginutil.AuthUnavailable() and bind every fallback to it, with a test
that drives each plugin's registered guard without an auth service present
and asserts the request is aborted rather than passed through.

* chore(autoresearch): log iter 23 (fail-open auth ordering, proven)

* autoresearch iter 24 follow-up: let staticcheck infer the auth guard type

* docs(autoresearch): log iters 24-25 and lessons 9-11 (declared-dep bug class, gate discipline)
2026-08-29 11:21:04 +08:00
ryan b624de9620 feat(cmd): log actual plugin migration version instead of up-to-date 2026-08-29 11:11:51 +08:00
ryan 4d65e57f9a merge: feat(core): implement cordis configuration extension and migrate all plugins 2026-08-29 10:54:28 +08:00
ryan ed8491addf feat(core): implement cordis configuration extension and migrate all plugins 2026-08-29 10:53:53 +08:00
ryan b43c429544 chore(arch): forbid viper and mapstructure inside the micro-kernel
配置装载实现必须留在 plugins/infra/config 适配器里,内核只依赖
ConfigSource 抽象;把 viper 与 mapstructure 加入 1.1 禁止清单,防止配置
装载依赖重新渗回 core(已用临时探针文件反向验证检查生效)。
2026-08-29 10:17:57 +08:00
ryan 8bd59511a8 refactor(config): make legacy loader reentrant and add engine parity test
load(configPath, testMode) 用私有 viper 实例替代包级全局,使同一输入可反复
求值;对拍测试以入库的 config.example.yaml 为必备基准(本地 config.yaml
存在时加测),在四类 env 场景下逐 key 比对新引擎与旧装载器,并以变异检验
确认其能发现漂移。
2026-08-29 10:15:32 +08:00
ryan 696809899e feat(infra): add viper backed configuration source adapter
实现 core.ConfigSource:按 CONFIG_PATH 或向上查找定位 config.yaml,缺文件
降级为纯环境变量来源,坏文件返回错误而非 log.Fatalf,并把 key 命中与"设为
零值"区分开来。viper 依赖被隔离在此包,内核保持零具体运行时依赖。
2026-08-29 10:06:46 +08:00
ryan 4acb529b85 feat(core): add config resolution barrier and plugin gating to App
App 新增 WithConfigSource / WithConfigDecl / Prepare / ShutdownTimeout /
SetShutdownTimeout;Use 收集门禁插件的提前声明,调和循环内求值门禁并跳过
被关闭的插件,使组合根无需再跨插件读配置选实现。未注入配置源的 App 保持
原行为,被门禁但无配置源则 fail fast 点名原因。
2026-08-29 10:03:56 +08:00
ryan b3c4d6cb99 docs(autoresearch): lessons 6-8 (audit verification, counting doubles, gate+veto discipline) 2026-08-29 09:56:24 +08:00
ryan 6011effade chore(autoresearch): log iter 22 (debt 79 -> 54) 2026-08-29 09:55:24 +08:00
ryan ad8384182c autoresearch iter 22: delete lint suppressions that suppress nothing
24 of the 96 nolint directives were dead: they covered findings that no
longer exist. A stale suppression is not inert — it silently claims any
future finding for that linter in that scope, so a real problem raised
there would vanish without anyone noticing. Explanatory prose was kept as
ordinary comments.

Two directives proved load-bearing under the project gate even though
nolintlint reported them unused, and removing them exposed verified
contextcheck false positives: App.Run does forward a sigCtx derived from
the caller's context to Start, and the migration lock renewal must keep
its own deadline because the task context may already be canceled. Both
were restored, narrowed to the live linter, and given the reason the
originals lacked.
2026-08-29 09:54:33 +08:00
ryan afaa8f79eb feat(core): add skipped fiber state for configuration gates
Fiber 新增 SKIPPED 态与 Skip/Skipped 方法:门禁为假的插件在 Apply 之前
即被排除并释放其作用域 Context,为互斥实现(cache 与 cache_memory 等)
同时挂载由内核择一激活铺路。
2026-08-29 09:53:10 +08:00
ryan 39a81f7723 feat(core): mount the configuration extension point on the kernel Context
Context 新增 Config() 访问器,注册表随 Fork 共享(配置声明是进程级事实),
并在 types.go 导出配置别名与 ConfigGatedPlugin 可选接口,为插件门禁做准备。
2026-08-29 09:47:59 +08:00
ryan c0869cb878 feat(core): expose read-only config view, generic getter and redacted dump
补齐 Value/String/Bool/Int/Duration/Strings/WasSet/Origin 只读访问器与
按 secret 脱敏的 Entries 导出,新增 core.ConfigGet[T] 泛型读取入口,并用
编译期断言钉住 ConfigRegistry 对 ConfigExtension 的完整实现。
2026-08-29 09:43:33 +08:00
ryan b6f2221280 chore(autoresearch): log iter 21 2026-08-29 09:41:30 +08:00
ryan 1023fa3adb autoresearch iter 21: remove the telegram inbound media scratch dir after handling
downloadMedia created a fresh os.MkdirTemp for every private message carrying
a photo or document, and no code path anywhere reads Attachment.Path, so each
message permanently grew the disk while burning a Bot API download. The
handler now removes the directory once onInbound returns.

No mechanical proof is possible here: exercising downloadMedia needs a live
telebot download. Verified by reading every consumer of InboundMessage
.Attachments instead.
2026-08-29 09:40:49 +08:00
ryan 4653afc554 feat(core): resolve declared configuration with env and file precedence
按 显式 env > autoEnable > 配置文件 > default 的优先级链解析每个已声明
key,支持标量 env 填充切片、duration 与结构体切片解码,非法 env 值不再
静默回退而是报 ErrConfigType。
2026-08-29 09:38:39 +08:00
ryan 9c0f31fad0 chore(autoresearch): log iter 20
Note: iter 20's commit also captured an in-flight edit to
docs/superpowers/plans/2026-08-29-cordis-config-extension.md belonging to a
concurrent session, because it used 'git add -A'. Content is intact; later
iterations stage explicit paths only.
2026-08-29 09:35:50 +08:00
ryan c77b5358e2 feat(core): add configuration declaration registry
配置读取框架的内核侧抽象:插件用带 config/env/default/autoEnable/secret
tag 的结构体声明自己读哪些字段,注册表按 key 归集并对重复声明做一致性
校验,为后续按声明解析与门禁求值提供基础。
2026-08-29 09:32:53 +08:00
ryan efa75558af autoresearch iter 20: give the telegram poller a real long-poll window
telebot types LongPoller.Timeout as time.Duration and sends
int(timeout / time.Second) to getUpdates, so the literal 10 meant ten
nanoseconds: Telegram received timeout=0, long polling never held the
connection, and the adapter polled the Bot API in a tight loop instead.
Use 10 seconds and extract the settings so the conversion is asserted.

The adapter also has no media temp-dir cleanup (downloadMedia creates an
MkdirTemp per attachment and nothing removes it); that is left as a separate
change rather than bundled here.
2026-08-29 09:32:27 +08:00
ryan ae8bbd98f8 chore(autoresearch): log iter 19 2026-08-29 09:24:33 +08:00
ryan 84eaf3f555 autoresearch iter 19: make task handlers driver-agnostic so they run under both workers
upload's four real background tasks (system cleanup, stats rebuild, storage
migration, image warmup) plus the admin and user stubs registered handlers
typed as func(ctx, *asynq.Task) error. Only the asynq worker accepts that
shape; the Redis-free in-process worker's invokeHandler rejects it with
'unsupported handler type', so none of those tasks could ever run in that
deployment mode. Take payload bytes instead, which both drivers support.

Adds architecture gate check 7 forbidding asynq imports from business and
infrastructure plugins. It deliberately does not cover robfig/cron: the admin
plugin uses cron.ParseStandard only to validate a user-entered spec, which is
a library call rather than a driver binding, and the in-process scheduler
already normalizes 5-field specs.
2026-08-29 09:23:01 +08:00
ryan fd83496a05 docs(config): add implementation plan for the Cordis config extension point
覆盖 P1+P2:core 配置引擎、viper 适配器隔离、门禁与 FiberSkipped、
新旧解析对拍。迁移 27 个消费文件与旧单例退场由后续计划承接。
2026-08-29 09:20:22 +08:00
ryan 020ebebfaa chore(autoresearch): log iter 18 2026-08-29 09:12:03 +08:00
ryan 8c4955c835 autoresearch iter 18: remove the phantom user:daily_audit schedule
The user plugin registered a cron dispatching to user:daily_audit, a task
pattern it never registers, and no audit logic exists anywhere in the plugin.
The daily run therefore went nowhere while a test asserted the schedule was
registered — proving the wiring existed, not that it worked. Implementing a
real daily audit is unstarted functionality, so the schedule is removed rather
than stubbed.

The combined domain test now asserts the real invariant across all applied
plugins: every schedule's task type must have a registered handler.
2026-08-29 09:11:16 +08:00
ryan d80f9d209b chore(autoresearch): log iter 17 2026-08-29 09:03:54 +08:00
ryan 1b1c45206c autoresearch iter 17: wire the pairing-code cleanup cron to a real handler
message_gateway scheduled message_gateway:cleanup_pairing_codes every 10
minutes but never registered a task under that pattern, so every dispatch
went to a task type with no handler and expired pairing rows accumulated
forever, even though repository.DeleteExpiredPairingCodes already existed.
Add a test that fails for any schedule whose task pattern is unregistered:
it reports the exact orphan rather than relying on a schedule-exists assert.
2026-08-29 09:03:33 +08:00
ryan 84946977bf chore(autoresearch): log iter 16 (perf, contract batch) 2026-08-29 08:52:18 +08:00
ryan 976f9b15ae autoresearch iter 16: add batch user lookup and use it for log enrichment
enrichAccessLogsWithUsers preferred the UserService contract over the local
repository — correct layering, but it looped GetUserByID and issued up to a
page-size worth of separate SELECTs against w_users, while the single-query
WHERE id IN variant was only reached in the no-contract fallback branch.
Give the contract a GetUsersByIDs so callers can keep the layering and drop
the N+1. The test asserts 1 query batched against 3 per-id, so the counting
itself is checked.
2026-08-29 08:51:30 +08:00
ryan 5df282f296 chore(autoresearch): log iter 15 (perf, proven) 2026-08-29 08:45:21 +08:00
ryan 2c415638fd autoresearch iter 15: stop CORS from querying the database on every request
isOriginAllowed read server_address from w_system_configs for every request
carrying an Origin header — one uncached primary-DB round-trip plus a split
and trim loop per browser request, while sibling config reads in the storage
driver are already TTL cached. Read it through the shared CacheService with
the same 5s window, falling back to the database when no cache is bound.
driver_http now binds CacheService in Apply the way it already binds DBService.
2026-08-29 08:44:37 +08:00
ryan 2654eb6e2c chore(autoresearch): correct iter 14 log (debt held at 79, kept via proven-fix gate) 2026-08-29 08:39:53 +08:00
ryan 45bf1d8933 chore(autoresearch): log iter 14 2026-08-29 08:39:38 +08:00
ryan 6932b54a30 autoresearch iter 14: stop a cache read error from clobbering the buffered task log
AppendTaskExecutionLog discarded the error from its read of the buffer, so a
transient cache failure looked like an empty buffer and the very next write
replaced the whole accumulated log with just the newest line. Flush already
distinguished miss from failure; append now does the same.
2026-08-29 08:38:55 +08:00
ryan 00ab727791 chore(autoresearch): log iter 12 (debt 80 -> 79) 2026-08-29 08:35:26 +08:00
ryan 101cb2ff7a autoresearch iter 12: inproc driver reports untracked executions as an error
GetExecution returned (nil, nil) under the in-process driver where the asynq
driver returns an error, so the same contract call meant 'empty' in one
deployment mode and 'failed' in the other.
2026-08-29 08:35:00 +08:00
ryan 2c8020188d chore(autoresearch): log iter 11 (debt 84 -> 80) 2026-08-29 08:33:56 +08:00
ryan 3d2038a251 autoresearch iter 11: unimplemented auth mocks fail loudly instead of returning (nil, nil)
Authenticate, CreateAuthSource, UpdateAuthSource and ToggleAuthSource claimed
success with a nil record, so any test that reached them surfaced a nil
pointer dereference instead of the actual cause. Full suite confirms no test
relied on the silent behaviour.
2026-08-29 08:33:16 +08:00
ryan 643bfca996 chore(autoresearch): log iter 10 (debt 87 -> 84, errorlint 12 -> 0) 2026-08-29 08:30:33 +08:00
ryan c4068efd54 autoresearch iter 10: keep error identity at the last errorlint sites
RunPushTest flattened channel validation failures with %v, telegram's
fallback path discarded the original send error, and the config loader's
type assertion on viper.ConfigFileNotFoundError would miss a wrapped form
and fatally abort over a merely missing file. errorlint now reports zero.
2026-08-29 08:29:40 +08:00
ryan f7fd980429 chore(autoresearch): log iter 9 (debt 89 -> 87) 2026-08-29 08:27:21 +08:00
ryan 22ecafdbc2 autoresearch iter 9: drop always-nil error results from task log loaders
loadTaskExecutionLog and loadTaskExecutionLogs could never fail, yet four
call sites branched on their error as if they could, presenting unreachable
code as error handling.
2026-08-29 08:26:39 +08:00
ryan a529700ed3 chore(autoresearch): log iter 8 (proven bug fix, debt held at 89) 2026-08-29 08:24:09 +08:00
ryan 18820b17f6 autoresearch iter 8: render synthesized notification content in stable order
bodyContent's fallback ranged over the body map, and Go randomizes map
iteration, so the same notification rendered its fields in a different order
on every send. Observed failing before the fix: the second call already
reordered the output. Iterate sorted keys instead.
2026-08-29 08:23:23 +08:00
ryan 03f48a9a80 chore(autoresearch): log iter 7 (debt 92 -> 89) 2026-08-29 08:21:24 +08:00
ryan c66399eedc autoresearch iter 7: share body field extraction across push channels
email, telegram and lark each re-implemented the title/content/level lookup
with only their markup differing, and each carried a dead content := ""
initialization that every branch overwrote. Three small helpers in template.go
now own that logic.
2026-08-29 08:20:43 +08:00
ryan bf364f4036 chore(autoresearch): log iter 6, distinguish compile-level from assertion-level proof 2026-08-29 08:17:59 +08:00
ryan ce33997c23 autoresearch iter 6: reject negative cursor instead of silently using 0
parsePositiveInt reported invalidity through a bool that both call sites
discarded, and returned (false, nil) whenever Atoi succeeded on a negative
number. GetLogs therefore accepted ?cursor=-5 and served it as cursor 0
('latest') instead of the documented 400. Validity now travels through the
error result, which no caller can ignore.
2026-08-29 08:16:50 +08:00
ryan 850f99a2c8 docs(config): add Cordis config extension point design
pkg/config 以全局单例暴露全量配置,使组合根可跨插件判断 redis.enabled、
配置读者与所有者无约束。本设计将配置读取框架下沉为内核扩展点,由 infra
适配器隔离 viper,各插件声明自读字段并以门禁谓词取代组合根选型判断,
一次性迁移全部 27 个消费文件。
2026-08-29 08:16:24 +08:00
ryan 58c34ad5c1 chore(autoresearch): log iter 5 (4 bare goroutines hardened, gate widened) 2026-08-29 08:13:17 +08:00
ryan 381c79417e autoresearch iter 5: recover panics in background cleanup loops
Four long-running goroutines were launched with a bare go statement, so a
panic in any of them took down the whole process: the RAM cache's expired-key
eviction, the batch writer's flush worker, the disk cache cleanup worker and
the PoW memory store sweeper. Route them through util.Go.

The architecture gate only grepped for 'go func(', which is why the named-call
form went unnoticed; widen it to cover both launch styles.
2026-08-29 08:12:27 +08:00
ryan 57b39f7fcf chore(autoresearch): log iter 4 (proven bug fix, debt held at 93) 2026-08-29 08:08:16 +08:00
ryan 7e6b9e7c2f autoresearch iter 4: stop one disconnected client from failing a shared image flight
EnsureCompressedImageCache passed the arriving caller's request context into
the singleflight body, which runs once for every concurrent requester of that
cache key. If the first client disconnected, gin canceled the context, the
shared generation aborted, and every follower received that failure and fell
back to the uncompressed original. Detach cancellation with
context.WithoutCancel so trace values still propagate but the shared work
outlives any single requester.
2026-08-29 08:07:19 +08:00
ryan 5b84fd906d chore(autoresearch): log iter 3 (debt 95 -> 93) 2026-08-29 08:03:26 +08:00
ryan 686e3ef5a6 autoresearch iter 3: share upload-record error mapping via filesrv helper
ServeFileByID and DownloadFile duplicated the lookup failure mapping and
each used an unchecked *strconv.NumError assertion that cannot match a
wrapped error. One helper now classifies 404 vs 400 via errors.As; each
endpoint keeps its own fallback for unclassified failures. ErrInvalidUploadID
became unused once both sites report ErrInvalidFileID for a malformed ID.
2026-08-29 08:02:42 +08:00
ryan 4e6209bf61 chore(autoresearch): log iter 2 (debt 100 -> 95) 2026-08-29 07:59:39 +08:00
ryan 37ad58699d autoresearch iter 2: compare error sentinels with errors.Is
Five sites used == against sentinels (redis.Nil, ingest.ErrForbidden,
errs.ErrDatabaseUninitialized). The neighbouring not-found checks already
went through errors.Is helpers, so a wrapped error would silently downgrade
a 403 to a 400 and a 500 to a 400.
2026-08-29 07:58:59 +08:00
ryan edf0c0e934 chore(autoresearch): log iter 1 (debt 102 -> 100, proven fix) 2026-08-29 07:57:10 +08:00
ryan 1c5731bf75 autoresearch iter 1: keep Using2/Using3 dependency causes reachable
Using2/Using3 flattened per-dependency injection failures with %v while
Using1 wrapped with %w, so errors.Is could not see ErrServiceNotFound
through a multi-dependency resolution failure.
2026-08-29 07:56:02 +08:00
ryan 5971e2a9ed chore(autoresearch): re-baseline harness on pinned real-risk yardstick
The committed golangci gate now reports 0 issues, so the previous
lint_issues metric was saturated and could no longer measure progress.
Measure debt against an immutable .auto/lint.ref.yaml snapshot that adds
analyzers for genuine defects (panics, error unwrapping, dead stores,
missing enum cases, method ordering, suppression hygiene) while excluding
cosmetic churn (tagliatelle, wrapcheck). Guard enforces build, vet, tests,
the Cordis architecture gate, and anti-cheat floors: the yardstick cannot
be edited, the project gate may only be strengthened, nolint directives may
only shrink, and no test may disappear.
2026-08-29 07:52:03 +08:00
ryan 4f30e2d57b fix(docker): repair embed packaging for backend/ module layout
The Dockerfiles and the release workflow still assumed the pre-refactor tree:
go.mod, go.sum and main.go now live under backend/, so `COPY go.mod go.sum`
failed outright, and the Go module is the bare `Wavelet`, so the buildinfo
ldflags pointed at github.com/Rain-kl/Wavelet and stamped nothing. The
frontend export was also overlaid onto ./plugins/... rather than the
driver_http path that `//go:embed all:dist` actually reads.

Repoint every packaging path at backend/, correct the ldflags module prefix,
and assert dist/index.html in both docker stages, in build-embedded and in the
release workflow, so a silently empty static export can no longer ship an
API-only binary. Also carry pnpm-workspace.yaml into the cached install layer.
2026-08-29 07:40:57 +08:00
ryan f4975d6732 refactor(plugins): restructure admin and message_gateway into standard layered sub-packages 2026-08-28 22:33:26 +08:00
ryan 85b383a4e0 skills: autoresearch 2026-08-28 20:36:15 +08:00
ryan b68060255f docs(plugins): add plugin layered architecture spec and templates 2026-08-28 20:35:11 +08:00
ryan 0035e548a5 fix(risk_control,message_gateway): fix SQL LIKE escape syntax and use UserService contract 2026-08-28 20:19:24 +08:00
ryan df351cbd33 refactor(core): decouple gin from pkg/util and reduce code duplication 2026-08-28 20:15:47 +08:00
ryan c52b7c4abf test(upload): move storage task fixtures to in-memory mock 2026-08-28 18:54:23 +08:00
ryan 4d6be2fa77 fix(drivers): propagate app-lifetime context through inproc cron/worker drivers
cron 触发与 worker 执行的任务现在继承应用生命周期 context(关闭时级联取消,带超时子上下文),
替代裸 context.Background()。contextcheck 清零。
lint_issues 26→24
2026-08-28 17:04:44 +08:00
ryan 02b93a3b20 chore(autoresearch): log iter 2-3 2026-08-28 16:53:51 +08:00
ryan 867fcb2288 refactor: revive cleanup — unused params to _, add missing doc comments
- admin/db_helper GetCache/GetUserService/GetAuthService: ctx -> _ (签名对称保留)
- validateMergedStorageConfig / ParseMigrationTargetConfig / MockStorageService.Put 未用参数 -> _
- SetDBServiceForTest、StorageDriver 常量组补充文档注释
lint_issues 33→26
2026-08-28 16:53:32 +08:00
ryan 6e12a7fd5d fix(admin): propagate cache errors in FlushTaskExecutionLog
缓存故障(非 ErrCacheMiss)不再被静默吞掉:上抛包装错误,避免缓冲任务日志丢失并误报持久化成功;
ErrCacheMiss 仍视为无日志的正常路径。附 3 个回归测试(故障/未命中/持久化+清理)。
lint_issues 34→33 (nilerr 清零)
2026-08-28 16:50:03 +08:00
ryan cf85a56aa7 refactor: eliminate string/magic-number literals (goconst, mnd) and fix const-type grouping (SA9004)
- upload/task: taskCategoryUpload/taskQueueDefault 常量替代 8 处字面量
- admin: 复用既有 logDBNameSQLite 常量替代 3 处 "sqlite" 字面量
- pkg/cache/disk: defaultCleanupInterval 命名常量
- driver_asynq_worker/executor: 分离 contextKey 类型常量组
lint_issues 45→34, tests 44/44
2026-08-28 16:38:18 +08:00
ryan 528240026d chore(lint): unify formatting on golangci-lint fmt (gofumpt), uncap issue reporting, fix gofumpt drift
- make format 现在与 code-check 使用同一格式化器(golangci-lint fmt),消除 goimports -local 与 gofumpt 的格式拉锯
- .golangci.yml 关闭默认 50/3 截断,完整上报所有问题(只增强不弱化)
- 全库 gofumpt 规范化(203 files, 纯格式无行为变更)
2026-08-28 16:31:53 +08:00
ryan 078ad9b2f3 chore(autoresearch): init cordis-quality session files 2026-08-28 16:21:34 +08:00
ryan 3a61c38dc5 fix(core): delegate driver and task extension lookups to root context and improve test reliability 2026-08-28 16:09:18 +08:00
ryan 7fca53823a feat(cmd): restore startup banner display in CLI entrypoints 2026-08-28 15:46:58 +08:00
ryan 3df621637f fix(db): ensure sqlite data directory exists before opening database 2026-08-28 15:42:24 +08:00
ryan 692b4b4851 feat(db): split migrations into dialect-specific sqlite and postgres packages 2026-08-28 15:36:03 +08:00
ryan 06508b6f13 refactor(drivers): decouple inproc cron from inproc worker and satisfy linter 2026-08-28 15:17:11 +08:00
ryan 0ff117da47 feat(cmd): support dynamic zero-redis pluggable assembly in app bootstrap 2026-08-28 15:15:26 +08:00
ryan e434e6dc0a feat(drivers): provide TaskService implementation in inproc worker 2026-08-28 15:14:27 +08:00
ryan f048dc528e fix(docs): move swagger generated docs.go into backend/docs module directory 2026-08-28 15:13:34 +08:00
ryan 25647df3cf feat(drivers): implement in-process cron scheduler driver plugin 2026-08-28 15:13:24 +08:00
ryan d8e69bb31b feat(drivers): implement in-process async worker driver plugin 2026-08-28 15:12:42 +08:00
ryan 98d2c1cbd0 feat(infra): implement zero-dependency pure in-memory cache plugin 2026-08-28 15:11:46 +08:00
ryan acad091127 docs: add implementation plan for zero-redis pluggable architecture 2026-08-28 15:11:03 +08:00
ryan 299ac30ee4 refactor(core): align with cordis spatiotemporal composability architecture
- Purify core micro-kernel by removing context hardcoded helpers and reverse dependencies
- Eliminate init() side effects in infra plugins with reversible lifecycle disposal
- Completely isolate plugins by removing cross-plugin imports and using core/contracts
- Introduce TaskService and RiskControlService contracts for unified cross-plugin APIs
- Regenerate Swagger documentation and update developer guide matrix
- Achieve 0 violations in check_cordis_architecture.sh and 100% test pass
2026-08-28 15:05:31 +08:00
ryan fc7fae7b0e docs: add design spec for zero-redis pluggable architecture 2026-08-28 14:02:28 +08:00
ryan 48211fa587 chore: code-check 2026-08-28 13:42:49 +08:00
ryan 9213ee79b3 refactor(core): finalize context test and standalone repository fallback 2026-08-28 13:38:22 +08:00
ryan a296818922 refactor(user): decouple repository from direct database infra import 2026-08-28 13:37:13 +08:00
ryan 3570ccd5c3 feat(core): implement plugin fiber state machine and reactive dependency reconciler 2026-08-28 13:35:43 +08:00
ryan 33294b3fae feat(core): implement scoped revertible effects for context extpoints 2026-08-28 13:34:07 +08:00
ryan 0b4e928d1a docs(core): add cordis architecture alignment implementation plan 2026-08-28 13:32:55 +08:00
ryan 0bc19e34cb docs(core): add cordis architecture alignment design spec 2026-08-28 13:32:05 +08:00
ryan b92ba54707 docs(core): update developer guide and white paper with cordis composability standards 2026-08-28 13:30:47 +08:00
ryan e19bf36580 refactor(core): align architecture with cordis spatiotemporal composability 2026-08-28 13:28:58 +08:00
ryan 9f8890d159 refactor(module): simplify module name to Wavelet and standardize import paths
- Declared module Wavelet in backend/go.mod
- Replaced github.com/Rain-kl/Wavelet/ with clean Wavelet/ import paths across backend codebase
- Updated architecture guards, Makefile, swagger, and build tests
- 100% passed all tests, lint checks, and binary compilation
2026-08-28 13:10:30 +08:00
ryan f2ab94501c refactor(layout): relocate go.mod to backend/ and clean import paths to module root
- Relocated go.mod and go.sum into backend/ root directory
- Stripped redundant backend/ segments from all Go imports (github.com/Rain-kl/Wavelet/...)
- Unified Makefile, swagger, and build-test to execute in backend/ module context
- Ensured 100% build-test, code-check, format, and swagger pass
2026-08-28 13:01:51 +08:00
ryan 43dc97e48c refactor(layout): consolidate backend codebase into backend/ package and clean root directory
- Moved cmd/, core/, plugins/, pkg/, downstream/, and main.go into backend/ directory
- Batch updated all Go source files to import github.com/Rain-kl/Wavelet/backend/...
- Updated Makefile, scripts/swagger.sh, architecture guards, and platform skills
- Passed all quality gates (100% tests, 0 lint issues, clean build)
2026-08-28 12:56:02 +08:00
ryan 33b38f8687 docs(migration): update docs and skills for new migration architecture
Update all relevant documentation and skills to reflect:
- w_schema_versions shared version table with plugin_id discriminator
- Single 00001_initial.sql per plugin (merged from multi-file approach)
- gooseEngine uses goose.NewProvider with goose.WithStore(sharedStore)
- pkg/migrator deleted, all 26 global SQL files moved to per-plugin
- DDL/DML single-file approach (merged seed + schema)

Files updated:
- .agents/skills/database-migration/SKILL.md (full rewrite)
- docs/WAVELET_WHITE_PAPER.md (table matrix + migration check)
- docs/WAVELET_DEVELOPER_GUIDE.md (scenario 9)
- docs/superpowers/specs/2026-08-27-cordis-plugin-architecture-design.md
- docs/superpowers/specs/2026-08-27-cordis-downstream-developer-guide.md
2026-08-28 12:45:58 +08:00
ryan 9bd012a271 fix(migration): use single w_schema_versions table with plugin_id discriminator
Replace per-plugin goose version tables with a single shared table
shared across all plugins, discriminated by plugin_id.

Implementation:
- Implement custom goosedb.Store (sharedStore) that uses a single
  w_schema_versions(plugin_id, version_id, applied_at) table
- Each store instance binds to a specific pluginID, filtering all
  CRUD operations by that plugin_id
- Goose provider created via goose.WithStore(store) instead of
  WithTableName, eliminating per-plugin goose_version_* tables
- Placeholder formatting (PostgreSQL  vs SQLite ?) handled by
  sharedStore.placeholder() based on dialect

This means:
  SELECT * FROM w_schema_versions ORDER BY plugin_id, version_id;
shows the complete migration state of every plugin at a glance.
2026-08-28 12:39:28 +08:00
ryan 65c7c282f5 refactor(migration): merge per-plugin SQL into single initial migration
Each plugin now has exactly one migration file (00001_initial.sql)
containing its complete table schema and seed data, replacing the
multi-file approach with split ALTER/INSERT steps.

Changes per plugin:
- auth: w_auth_sources, w_external_accounts, w_access_tokens + login session seed
- user: w_users + system user seed (removed w_access_tokens — belongs to auth)
- admin: w_system_configs, w_templates + all 32 config seeds + 2 template seeds
  (removed w_schedules, w_task_executions — belong to driver plugins)
- upload: w_upload_stats + indexes + access_mode + backfill
- message_gateway: all w_message_*, w_push_*, w_push_channels, w_push_histories
- risk_control/logstore: w_user_access_logs (PG + ClickHouse)
- driver_asynq_cron: w_schedules + cleanup seed
- driver_asynq_worker: w_task_executions

All CREATE TABLE use IF NOT EXISTS, all INSERT use ON CONFLICT DO NOTHING.
go:embed patterns remain 'migrations/*.sql' — unchanged.
2026-08-28 12:28:37 +08:00
ryan 530a9dd3ee refactor(migration): delete pkg/migrator, move SQL to per-plugin embed
BREAKING: pkg/migrator/ deleted entirely. Migration SQL files are now
owned by each plugin in its own migrations/ directory.

Architecture:
- Delete pkg/migrator/ (26 global SQL files + ClickHouse migration)
- Move global SQL to per-plugin migrations/ with go:embed + Register()
- Rewrite cmd/app.go gooseEngine: uses Inject[DBService] for DB, iterates
  all plugin-registered MigrationEntry, runs goose.Up per entry
- core.MigrationEngine.Migrate signature changed: *Context instead of
  context.Context, so engine can resolve services via IoC

Per-plugin migration ownership:
  auth/             → w_access_tokens, w_auth_sources, w_external_accounts
  user/             → w_users (seed system user)
  admin/            → w_system_configs, w_templates (seeds)
  upload/           → w_uploads, w_upload_stats
  message_gateway/  → w_push_*, w_message_*
  risk_control/     → w_user_access_logs (PG + ClickHouse)
  driver_asynq_cron/  → w_schedules
  driver_asynq_worker/ → w_task_executions

Dependencies:
- cmd/banner.go: removed migration report display (migrations are automatic)
- cmd/reset_passwd.go: removed PreRun migrator.Migrate() call
- go.mod: clickhouse-go kept (used by plugins/infra/database/clickhouse.go)
2026-08-28 12:19:25 +08:00
ryan c9b702d234 refactor(core): fix cross-domain auth imports, unify migration, add downstream scaffold
Architecture:
- Move GetFromContext/SetToContext from plugins/domain/auth to pkg/util
- Move auth context key constants to core/contracts (AuthUserObjKey, AuthTokenAuthKey, etc.)
- Add AuthUserIDKey, AuthUserNameKey, GetCurrentUserID, RevokeToken to contracts.AuthService
- All 4 domain plugin Apply() methods now resolve AuthService via core.Using IoC
- Plugin route middleware uses authSvc.RequireAuthMiddleware() cast to gin.HandlerFunc
- DisallowTokenAuth added to AuthService contract

Migration:
- Replace cmd/app.go SetMigrationRunner bridge with gooseEngine implementing core.MigrationEngine
- Remove cmd/root.go PreRun migration hooks and runMigrations() function
- Migrations now run via core.App.Start() → RunMigrations()

Events:
- Add complete domain event topic catalog and payload DTOs to core/contracts/events.go
- 15 event topics across auth, user, admin, upload, message_gateway, risk_control

Downstream:
- Create downstream/ directory with README and custom_example plugin scaffold

CI:
- Update Makefile code-check architecture guards for Cordis layering
- Enforce: core no gin/gorm/asynq, contracts no plugins/, pkg no plugins/, domain no cross-domain
2026-08-28 11:51:48 +08:00
ryan 416603b616 fix(persistence): migrate all pkg/persistence imports to plugins/infra/database and plugins/infra/cache
- Replace db.DB(ctx) with database.DB(ctx) from plugins/infra/database
- Replace db.Redis/db.PrefixedKey/db.GetJSON/db.SetJSON with cachepkg.* from plugins/infra/cache
- Replace pkg/persistence/idgen with pkg/idgen (already exists)
- Replace pkg/persistence/batchwriter with pkg/batchwriter (already exists)
- Replace pkg/persistence/migrator with pkg/migrator (already exists)
- Replace pkg/persistence/logstore with plugins/domain/risk_control/logstore
- Delete defunct pkg/{persistence,cap,message_gateway,push,shared,task}
- Fix vet issues: db alias in domain_test.go, driver_asynq_worker.TaskHandler reference
- Update Makefile architecture guard
- Update docs and skill references
- Update go.mod: gorilla/sessions promotion to direct dependency
2026-08-28 10:59:24 +08:00
ryan fb6a3edb89 refactor(architecture): eliminate internal package and complete cordis single-owner model and repository migration
- Physically purged all legacy internal/ packages, centralized pkg/model/ and pkg/repository/
- Migrated domain models and database repositories into self-contained owner plugins (user, auth, message_gateway, admin, upload, risk_control)
- Decoupled cross-plugin interactions via pure core/contracts and typed EventBus
- Ensured 100% test coverage pass, zero data races (-race clean), and 0 lint issues in make code-check
2026-08-28 08:40:43 +08:00
ryan 1f348fd425 docs(whitepaper): update white paper to reflect 100% pure Cordis single-track architecture 2026-08-28 07:28:55 +08:00
ryan dc49b72c29 refactor(core): completely decommission legacy internal/apps, internal/platform/bootstrap, and internal/router/v1 2026-08-28 07:28:45 +08:00
ryan df3c5ae756 docs(whitepaper): update white paper with deep physical migration status and zero-lint test report 2026-08-28 07:16:40 +08:00
ryan 56ef70d81f feat(cmd): register all 5 domain plugins in newWaveletApp and fix all lint issues 2026-08-28 07:16:30 +08:00
ryan b259f35bb4 refactor(plugins): complete physical encapsulation of auth, admin, message_gateway, and risk_control domain plugins 2026-08-28 07:15:17 +08:00
ryan e750fadacd chore: docs 2026-08-28 00:11:54 +08:00
ryan 5a00879b63 docs(whitepaper): update white paper with comprehensive QA and E2E test report 2026-08-28 00:07:07 +08:00
ryan b6bfa120fc feat(core): implement app profile lifecycle dispatcher and wire cli commands 2026-08-28 00:02:30 +08:00
ryan a4c3f70f0b feat(plugins): migrate auth, user, message_gateway, risk_control, admin to domain plugins 2026-08-27 23:59:38 +08:00
ryan 75f226cfbf docs(agents): update AGENTS.md and development skills for cordis architecture 2026-08-27 23:56:16 +08:00
ryan 94c5aa4cd3 docs: publish WAVELET architecture white paper and official developer guide 2026-08-27 23:51:37 +08:00
ryan a25bf7a4f9 feat(plugins): package database, cache, logger, and storage as infra plugins 2026-08-27 23:51:10 +08:00
ryan 4efc2c92b7 feat(plugins): implement runtime drivers for http, asynq worker, and cron 2026-08-27 23:48:18 +08:00
ryan ef6296bd22 feat(core): add typed eventbus and domain extension points 2026-08-27 23:47:58 +08:00
ryan c53a5461ab feat(core): add typed eventbus and domain extension points 2026-08-27 23:47:57 +08:00
ryan d853a41eae docs: initialize WAVELET white paper and developer guide 2026-08-27 23:43:06 +08:00
ryan a6ab158855 feat(core): implement context service hub and generic ioc container 2026-08-27 23:41:12 +08:00
ryan 3792313797 docs: add cordis plugin architecture implementation plan 2026-08-27 23:38:02 +08:00
ryan 40de54fe5b docs: add cordis downstream developer guide and cookbook 2026-08-27 23:33:12 +08:00
ryan 360f4f432c docs: add cordis microkernel and plugin architecture design spec 2026-08-27 23:24:34 +08:00
ryan 81739f9cb4 chore: format 2026-08-26 15:13:23 +08:00
ryan 2b69f4d8d7 #60 GeoIP 共享单例化:消除访问日志 region 解析每批次的 mmdb 重建开销与无界缓存,ctx 贯穿下载路径
Result: {"status":"keep","total_issues":8,"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":81,"tsc_errors":0,"vitest_failed":0,"vitest_total":126}
2026-08-26 13:48:44 +08:00
ryan b56f27632a #59 -shuffle=on 扫描抓到测试顺序依赖:config_version RAM 配置缓存跨测试污染,setup/cleanup 接入 ram.ResetForTest() 修复
Result: {"status":"keep","total_issues":8,"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":66,"tsc_errors":0,"vitest_failed":0,"vitest_total":126}
2026-08-26 13:35:20 +08:00
ryan fc733d0295 #57 enqueue 修复的同型残留收口:SendFlaredPong/SendRelayPong 合并 select 随机选择 bug,委托 client.enqueue 去重修复
Result: {"status":"keep","total_issues":8,"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":77,"tsc_errors":0,"vitest_failed":0,"vitest_total":126}
2026-08-26 13:20:35 +08:00
ryan 453f7e5d90 周期性 -race 重跑抓到真实 bug:wsClientCore.enqueue close 后 select 随机选择致契约违反;确定性先查 done 修复+测试循环加固+gofmt 存量漂移清理
Result: {"status":"keep","total_issues":8,"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":95,"tsc_errors":0,"vitest_failed":0,"vitest_total":126}
2026-08-26 13:14:59 +08:00
ryan 63e3b85294 富交互页 a11y 抽查收尾:8+3 页扫描,修复 cloudflare 筛选器无名/access-token amber 对比度/notifications 缺 h1 共 3 处,全部复扫归零;基准 total_issues 保持 8
Result: {"status":"keep","total_issues":8,"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":85,"tsc_errors":0,"vitest_failed":0,"vitest_total":126}
2026-08-26 13:05:16 +08:00
ryan e66dea9090 a11y 收尾:主题级对比度根因修复(indigo-500→600)+12 处控件 accessible name+4 处 heading-order,7 页复扫全 0 违规;基准 total_issues 保持 8
Result: {"status":"keep","total_issues":8,"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":85,"tsc_errors":0,"vitest_failed":0,"vitest_total":126}
2026-08-26 12:51:48 +08:00
ryan 451ce52592 认证页 axe a11y 审计+修复:7 处布局级真实违规全修,复扫验证 dashboard/admin/system 归零;基准 total_issues 保持 8 不变(纯质量收益)
Result: {"status":"keep","total_issues":8,"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":93,"tsc_errors":0,"vitest_failed":0,"vitest_total":126}
2026-08-26 12:26:37 +08:00
ryan bbf79199aa docs(autoresearch): record run #50 dead-end linter sweep 2026-08-26 11:26:48 +08:00
ryan 40232d86ba 修复 frpc restartProcess 发布未初始化 exec.Cmd 的数据竞争:proc.Cmd/Status 改为 Start 成功后加锁发布
Result: {"status":"keep","total_issues":8,"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":75,"tsc_errors":0,"vitest_failed":0,"vitest_total":126}
2026-08-26 11:19:18 +08:00
ryan 55db1c01ec 后台 goroutine panic 防护:新增 pkg/util.Go 共享助手(recover+调用点日志),全仓 22 个裸 go func() 站点统一收口
Result: {"status":"keep","total_issues":8,"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":112,"tsc_errors":0,"vitest_failed":0,"vitest_total":126}
2026-08-26 11:06:23 +08:00
ryan 3528323b50 GORM 实体搜索 LIKE 转义收尾:6 站点复用 EscapeLike + 显式 ESCAPE 子句,含 OAuth 用户名冲突误报修复
Result: {"status":"keep","total_issues":8,"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":102,"tsc_errors":0,"vitest_failed":0,"vitest_total":126}
2026-08-26 10:49:15 +08:00
ryan 2cb339258c LIKE 过滤器转义修复:日志搜索含 %/_ 的输入不再被当通配符;pkg/util 新增 EscapeLike 共享助手 + 单测
Result: {"status":"keep","total_issues":8,"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":106,"tsc_errors":0,"vitest_failed":0,"vitest_total":126}
2026-08-26 10:41:56 +08:00
ryan 63007fc8c7 全仓 race 扫描发现 upload/cache 监听器 DATA RACE:捕获 redis 客户端消除全局读竞争 + Stop 等待 done + 同型监听器(oauth×2/repository×2)加固
Result: {"status":"keep","total_issues":8,"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":92,"tsc_errors":0,"vitest_failed":0,"vitest_total":126}
2026-08-26 10:24:52 +08:00
ryan 4f8e7e66e3 修复 frps/frpc TOML 配置注入:新增 protocol.TOMLQuote 并在两处配置渲染全部使用
Result: {"status":"keep","total_issues":8,"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":77,"tsc_errors":0,"vitest_failed":0,"vitest_total":126}
2026-08-26 10:01:33 +08:00
ryan ed1efd3d54 补 wsClientCore 并发测试 + close() 防 nil conn 守卫
Result: {"status":"keep","total_issues":8,"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":77,"tsc_errors":0,"vitest_failed":0,"vitest_total":126}
2026-08-26 09:53:42 +08:00
ryan efd8268a5d websocket 三 client 结构体去重:嵌入共享 wsClientCore(close/enqueue 单份实现)
Result: {"status":"keep","total_issues":8,"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":75,"tsc_errors":0,"vitest_failed":0,"vitest_total":126}
2026-08-26 09:49:35 +08:00
ryan 0dd2cf9e80 websocket 三 hub 去重:抽 runWritePump 共享写泵 + 合并 agent 广播函数为 broadcastAgent
Result: {"status":"keep","total_issues":8,"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":77,"tsc_errors":0,"vitest_failed":0,"vitest_total":126}
2026-08-26 09:39:30 +08:00
ryan be5d067af9 auth_cache negative 缓存加上限防 DoS + relay/flared 删除重复 authenticateAccessToken 改用 agent 共享缓存版
Result: {"status":"keep","total_issues":8,"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":76,"tsc_errors":0,"vitest_failed":0,"vitest_total":126}
2026-08-26 09:34:12 +08:00
ryan 3e5f3cc562 autoresearch: 记录 #29-#38 实验日志(unconvert 修复、a11y 审计、未授权面安全修复) 2026-08-26 09:17:56 +08:00
ryan d73aa5f9f0 公开密码登录口按 IP 限制 10 分钟内最多 20 次失败,堵住未授权爆破。metric 持平 8。
Result: {"status":"keep","total_issues":8,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":85,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126}
2026-08-26 09:17:56 +08:00
ryan c9fc9c0eea 公开 OAuth 登录/授权入口按会话限制 10 分钟内最多 20 个 state,堵住未授权 Redis 洪水。metric 持平 8。
Result: {"status":"keep","total_issues":8,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":95,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126}
2026-08-26 09:17:56 +08:00
ryan cdc7474d7e 注册开关读取失败时改为关闭,堵住配置缺失时未授权开注册;OAuth 自动注册同样 fail-closed。metric 持平 8。
Result: {"status":"keep","total_issues":8,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":86,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126}
2026-08-26 09:17:56 +08:00
ryan 983cce3e80 去掉公开 CAP 口硬编码默认密钥;SessionSecret 为空时拒绝签发/核销,防止未授权伪造 PoW。metric 持平 8。
Result: {"status":"keep","total_issues":8,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":75,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126}
2026-08-26 09:17:56 +08:00
ryan 76e9d5b0e7 登录/注册/OAuth 回调统一走 SetLoginSession,保存前清空 Redis 会话 ID,堵住未授权会话固定。metric 持平 8。
Result: {"status":"keep","total_issues":8,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":90,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126}
2026-08-26 09:17:56 +08:00
ryan 6b75706b8e 未授权登录口补哑 bcrypt 比较,用户不存在与密码错误耗时对齐;禁用账号不再返回不同文案,堵住用户枚举。metric 持平 8。
Result: {"status":"keep","total_issues":8,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":99,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126}
2026-08-26 09:17:56 +08:00
ryan 59fd8cda7b 公开登录/注册邮箱验证码比较改为 SHA-256 后恒定时间 Compare,堵住未授权口的计时侧信道。metric 持平 8。
Result: {"status":"keep","total_issues":8,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":81,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126}
2026-08-26 09:17:56 +08:00
ryan cb94081ebd 未授权 Agent 注册口的 discovery token 改为 SHA-256 后恒定时间比较,堵住计时侧信道;空 token / 末字节翻转用例同步补上。metric 持平 8。
Result: {"status":"keep","total_issues":8,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":71,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126}
2026-08-26 09:17:56 +08:00
ryan 3de0a54d47 feat(a11y): 设置页无障碍审计并修复开关可访问名称
axe 结构性规则扩展到设置页(安全 Tab + 其他 Tab):
- security-tab 8 个 Switch/SelectTrigger 补可访问名称(Label htmlFor 关联 + aria-label)
- other-tab / auth-source-modal 各 1 个未命名 Switch 补 aria-label
- 新增 2 个 axe 测试,vitest 126 全绿
- 翻译键 loginCaptchaEnabled 加入 fragments(admin.zh-CN/en)
2026-08-17 00:02:51 +08:00
ryan e7b8fb2f99 docs(i18n): 同步 24 篇旧英文文档与中文最新内容
guide 9 篇(quick-start/first-site/sso/troubleshooting/tunnel-usage/waf-usage/waf-ip-group-expr/credits/index)、deployment 7 篇(deployment/server/agent/relay/openflared/upgrade/index)、reference 3 篇(configuration/cli/index)、design 5 篇(architecture/agent-design/tunnel-design/waf-design/index)全部按中文最新版重写同步;waf-usage/waf-design 按新版 DAG 模型重写;修复 reference 中文锚点链接;vitepress 构建 43 个英文页面全绿
2026-08-16 23:27:18 +08:00
ryan 454542c1d0 docs(i18n): 恢复并补齐英文版 vitepress,README 默认改为英文
- README 默认英文:README.en.md → README.md(英文为默认),中文移至 README.zh-CN.md,语言切换链接同步
- 恢复被删除的 docs/en/ 英文文档(git 历史 cc5e53c5^),删除 4 篇已废弃文件
- 英文导航 config.ts 对齐中文结构(新增 Deployment/Changelog 侧栏,同步 Guide/Design 条目)
- 翻译 15 篇中文新增文档:guide 5 篇(certificates/pages-usage/proxy-config/uptime-kuma/zone-domain-migration)+ design 10 篇(zone-design/cloudflare-pointing/waf-orchestration/origin-error-page/edge-cache-design/pages-design/logstore/kuma-design/login-captcha/observability 三篇)
- en 首页更新(新增 Pages 特性、tagline 同步);changelog 英文入口指向中文版
- vitepress 构建验证:43 个英文页面全部渲染

注意:29 篇旧英文文档为恢复版,部分内容(如 deployment/server、reference/configuration)可能落后于中文,需后续逐篇同步
2026-08-16 23:18:29 +08:00
ryan 580c51a73a refactor(i18n): ci 2026-08-16 22:55:08 +08:00
ryan 6b7df5a6b5 refactor(i18n): ci 2026-08-16 22:51:06 +08:00
ryan 497edfd564 refactor(i18n): fragments 为唯一源,主包改为生成物
- 一次性回灌:主包最新内容(含 565 个未同步键)全量写回 6 个 fragment 文件,fragments 成为完整唯一源;新增 core fragment(common/docs/home 等 9 个此前仅存在于主包的命名空间)
- merge-i18n-fragments.mjs 改为从零重建主包(不再以主包为基础),重建结果与原主包语义零差异
- 主包 messages/{zh-CN,en}.json 移出跟踪并加入 .gitignore
- 生成接入入口:predev/prebuild/prebuild:embed/precheck:i18n 钩子 + Makefile code-check 与 checks.sh vitest 前置生成
- 修复既有漂移:configVersions.title 现以 fragments(Config versions)为准
2026-08-16 22:38:50 +08:00
ryan d7d510ec97 chore(i18n): simplify cleanup and previewPublish labels in ops files 2026-08-16 21:53:50 +08:00
ryan f4ec58c0e6 Revert "chore(i18n): simplify cleanup and previewPublish labels in ops files"
This reverts commit 2ba28417b3.
2026-08-16 21:53:23 +08:00
ryan 2ba28417b3 chore(i18n): simplify cleanup and previewPublish labels in ops files 2026-08-16 21:52:50 +08:00
ryan 3f97193280 chore(docs): purge 2026-08-16 21:44:28 +08:00
ryan 2aa0a70762 chore(i18n): simplify cleanup and previewPublish labels in ops files 2026-08-16 21:42:04 +08:00
ryan 55c1fcd5f9 chore: format 2026-08-16 21:39:25 +08:00
ryan e5d2e4b6d5 fix(linux): cast stat.Bsize to int64 for accurate calculations 2026-08-16 21:38:59 +08:00
ryan 4f360cc7a9 Merge finalize branch: 03-frontend-any-cleanup 2026-08-16 21:29:29 +08:00
ryan da43de56ac Merge finalize branch: 02-axe-a11y-audit 2026-08-16 21:29:29 +08:00
ryan f538670e1f Merge finalize branch: 01-quality-sweep 2026-08-16 21:29:29 +08:00
ryan 2f60329886 后端与全仓代码质量清理(golangci 扩展集 · 测试质量 · 并发安全 · 文档同步)
代码质量全量清理,零行为变化:golangci 扩展集 13 类 linter(gosec/modernize/perfsprint/canonicalheader/usestdlibvars/wastedassign/intrange/errorlint/forcetypeassert/recvcheck/exhaustive/unparam)全量修复,测试代码质量(testifylint/thelper/usetesting)25→0,frpc 进程生命周期真 bug(进程组击杀)、全仓 go test -race 6 类数据竞争(含 1 个生产竞争)、SPDX license 头补齐 131 文件、前端测试套件 next-intl 迁移后 44 失败→全绿、过期 swagger 文档重新生成、pnpm-workspace 构建审批。

Experiments: #2-#17, #18, #20, #21, #23
Metric: total_issues 108 → 8 (-92.6%)
2026-08-16 21:24:14 +08:00
ryan c76c5a697b 前端显式 any 类型清理
全前端显式 any 计数 2→0:Slot children?: any → ReactNode | MotionValue 联合(motion 真实类型),顺带修复潜在崩溃(原代码在 isValidElement 前访问 children.type,children 缺失时 TypeError,现无效 children 返回 null,hooks 无条件合规);useControlledState Rest extends any[] → unknown[]。两处 eslint-disable no-explicit-any 注释随之删除。

Experiments: #28
Metric: 全前端 any 2 → 0,tsc/eslint/vitest 全绿
2026-08-16 21:23:38 +08:00
ryan 9609bec8b0 前端 axe 可访问性审计
axe-core(jsdom 结构性规则)真实 a11y 审计,超出 eslint 静态 jsx-a11y 的动态可访问性验证:登录页、注册页、登录 OTP 验证表单(input-otp 分段输入)、人机验证小部件(手动模式)、注册页开启人机验证(自动求解已通过态)五个表单态均零违规。环境修复:tests/setup.ts 补 ResizeObserver mock。

Experiments: #25, #26, #27
Metric: vitest 116 → 121,axe 结构性规则零违规
2026-08-16 21:23:37 +08:00
ryan 9b89d3c630 Merge branch 'autoresearch/code-quality-2026-08-16' 2026-08-16 21:15:13 +08:00
ryan e87f445218 chore(quality): 记录 run #28 实验结果 2026-08-16 21:15:10 +08:00
ryan 40eee778ac 前端显式 any 类型清理 2→0:Slot children?: any → ReactNode | MotionValue 联合(motion 真实类型),顺带修复潜在崩溃(原代码在 isValidElement 前访问 children.type,缺失时 TypeError,现无效 children 返回 null,hooks 无条件合规);useControlledState Rest extends any[] → unknown[]。两处 eslint-disable 注释删除。tsc/eslint/vitest 121 全绿。
Result: {"status":"keep","total_issues":8,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":121,"measure_s":0}
2026-08-16 21:14:46 +08:00
ryan 6c128e0be5 axe a11y 审计扩展到最复杂认证路径:注册页开启人机验证(CapWidget 自动求解→已通过状态 + 完整表单),mock getCapToken 避免 jsdom 无 Worker 环境限制。零违规。vitest 120→121 全绿。
Result: {"status":"keep","total_issues":8,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":121,"measure_s":71}
2026-08-16 21:05:36 +08:00
ryan 7d03154a8a axe a11y 审计扩展到登录 OTP 验证表单(input-otp 分段输入,FieldLabel htmlFor 正确关联,零违规)与人机验证小部件手动模式(零违规)。环境修复:tests/setup.ts 加 ResizeObserver mock(input-otp 依赖,jsdom 未内置)。vitest 118→120 全绿。
Result: {"status":"keep","total_issues":8,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":120,"measure_s":71}
2026-08-16 20:58:24 +08:00
ryan 7f8e257d33 前端真实 a11y 审计:新增 axe-core(devDep)+ tests/a11y.test.tsx,对登录页与注册页渲染完整表单后运行 axe 结构性规则(label/button-name/heading-order/landmark/aria),两页均零违规。摸清并处理了渲染依赖(UserProvider 会话检查、publicConfigQuery 门控、configBool 字符串语义)。vitest 116→118 全绿。
Result: {"status":"keep","total_issues":8,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":118,"measure_s":71}
2026-08-16 20:49:55 +08:00
ryan 4d78bc1c38 chore(quality): 会话收敛收尾 — 更新 prompt/ideas 记录最终状态 2026-08-16 20:15:08 +08:00
ryan 1813bbdba3 chore(quality): checks.sh 增加 license-check 门禁(防 SPDX 头再缺失) 2026-08-16 20:11:15 +08:00
ryan aa4faddade 补齐 131 个 .go 文件的 SPDX license 头(repo 自带 make license 约定,早于约定新增的文件含 2 个生产文件;纯注释插入零行为影响),make license-check 转绿。go mod tidy -diff 确认干净。
Result: {"status":"keep","total_issues":8,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":116,"measure_s":81}
2026-08-16 20:09:24 +08:00
ryan ab70633e9f checks.sh 新增并发密集包 -race 门禁(8 个快速包,全仓 -race 清零后纳入防回归;frpc/frps 慢套件留作周期全量验证)。核查 7 处 t.Skip 均为合法环境门控。
Result: {"status":"keep","total_issues":8,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":116,"measure_s":68}
2026-08-16 20:01:40 +08:00
ryan e1b439d6a5 全仓 go test -race 扫描(93 包)→ 全绿。修复 6 类数据竞争:frpc/frps 测试的锁外读与并发 Wait;oauth/repository 4 个 Pub/Sub 监听器 goroutine 读可变包变量(局部捕获 + done 通道等待);oauth 测试换 db.Redis 前停监听器;【真实生产 bug】tls 响应快照与异步续签 goroutine 并发写 cert 竞争(先快照再起 goroutine);upload/cache 监听器 goroutine 内读 db.Redis(调用方捕获)。
Result: {"status":"keep","total_issues":8,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":116,"measure_s":74}
2026-08-16 19:56:19 +08:00
ryan 4962bf90d1 两处真实质量修复:(1) 过期 swagger 文档重新生成(status_2xx/4xx/5xx_count 字段随 a4dd5ca9 加入后未同步 docs,违反 repo 约定,swag init 后差异仅真实新增字段);(2) generate-themes.js 输出补尾换行,themes.json 构建可复现(此前每次 build 弄脏工作树)。验证 next build 成功、musttag/tagalign 调查无真实问题。
Result: {"status":"keep","total_issues":8,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":116,"measure_s":86}
2026-08-16 19:19:04 +08:00
ryan c455be3002 基准扩展第 5 维度(文档化):前端 vitest 失败数纳入 total_issues(vitest_failed=0, total=116)。5 维全部处于下限,total=8 不变。
Result: {"status":"keep","total_issues":8,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":116,"measure_s":62}
2026-08-16 19:11:59 +08:00
ryan ab0e5fecf2 chore(quality): 基准扩展第 5 维度 — 前端 vitest(全绿后纳入防回归) 2026-08-16 19:09:58 +08:00
ryan f5c9da03f4 前端测试套件 44 失败→全绿:10 个测试文件补 NextIntlClientProvider 包装(含 React19 createElement 类型修复、.ts→.tsx 重命名);修复真实 i18n ICU bug(githubUrlInvalid 的 {owner}/{repo} 未转义导致生产渲染成 key,zh/en + fragment 4 文件同步转义);更新 2 处过期测试期望。vitest 116/116 + tsc + eslint 全绿,checks.sh 增加前端测试门禁。
Result: {"status":"keep","total_issues":8,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":39}
2026-08-16 19:08:21 +08:00
ryan a16be014d4 修复 frpc 进程生命周期真 bug(agent 生产代码):exec.CommandContext 默认只杀直接子进程,被杀 shell 的孤儿 sleep 继续持有 stderr 管道,cmd.Wait() 阻塞到其自然退出(Stop/重启可挂起秒级)。改 Setpgid 进程组 + Kill(-pid) 整组击杀。连带修复两个测试 bug(Manager 拥有 Cmd 的并发 Wait 竞态 → Signal(0) 探测;ssl_renew 用 miniredis 替代 init() 创建的真实 redis 客户端)。go test ./internal/... ./pkg/... 全绿,checks.sh 升级为真实测试门禁。
Result: {"status":"keep","total_issues":8,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":40}
2026-08-16 18:47:46 +08:00
ryan c85373ff47 unparam 死代码清理 12→2(保留 2 处 objectstore 构造函数统一签名):移除 10 处恒 nil error / 从未使用的结果(getPoWConfigForRoute 的恒 nil *PoWConfig、getSQLiteOverview/getPostgresOverview/getStatus/loadKumaConfig/filterExpectedRoutes 的恒 nil error、rawJSONString/parsePositiveInt 的弃用 bool、buildProxyRoute 的弃用 []ZoneDomain、getLocked 的恒 nil error),同步简化 12+ 处调用方与死错误检查。9 个受影响包测试通过。metric 持平 8(改进在基准之外)。
Result: {"status":"keep","total_issues":8,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":43}
2026-08-16 18:29:51 +08:00
ryan d7b8f44f90 修复 geoip/runtime.go 真死代码:ensureServerMMDB 的 os.Stat 错误被 if-init 遮蔽,err != nil && !os.IsNotExist(err) 恒为 false(外层 err 恒 nil),防御检查从未生效;改为显式捕获 statErr,stat 非 not-exist 错误现在正确返回。基准新增第 4 维度 govet nilness+unusedwrite(文档化扩展),当前 0。
Result: {"status":"keep","total_issues":8,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":37}
2026-08-16 18:25:49 +08:00
ryan 85321888e0 chore(quality): 会话收尾 — 更新 prompt/ideas 记录 14 个实验结论与刻意保留项 2026-08-16 18:21:01 +08:00
ryan 65c02ef7a5 基准扩展 exhaustive(文档化)+ 12→0:枚举 switch 补显式 case(全部与现有 default 行为等价,fail-explicit 防未来枚举静默落入 default);source_tasks.go 为控制复杂度合并两个等价校验条件。
Result: {"status":"keep","total_issues":8,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":36}
2026-08-16 18:19:08 +08:00
ryan 63a24da9ee 测试代码质量 25→0:assert↔require 一致性(fail-fast)、float 精确比较→InDelta、Equal("",x)→Empty、Equal(len)→Len、errors.Is/As→ErrorIs/ErrorAs、JSON 字符串→JSONEq、handler goroutine 内 require→assert(真健壮性修复)、t.Helper()、os.MkdirTemp→t.TempDir()(符合 repo AGENTS 约束)。
Result: {"status":"keep","total_issues":8,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":39}
2026-08-16 18:12:58 +08:00
ryan e5f6b0ad90 基准扩展(文档化):新增测试代码质量维度 25 处(testifylint 20 + thelper 3 + usetesting 2),生产代码 8 处刻意保留不变。新基线 total=33。
Result: {"status":"keep","total_issues":33,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":20,"golint_test_thelper":3,"golint_test_usetesting":2,"golint_test_total":25,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":37}
2026-08-16 18:05:44 +08:00
ryan 111d2900d7 eslint 1→0:pages-source-card useEffect 补 t 依赖(next-intl 稳定引用)。modernize 补 1 处 time.Time omitzero。剩余 8 全部为刻意保留项。
Result: {"status":"keep","total_issues":8,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":38}
2026-08-16 18:02:58 +08:00
ryan 73d8173018 recvcheck 7→1:6 个 GORM 模型 TableName 改为指针接收者(GORM 源码确认 reflect.New 判定 Tabler,兼容;模型单测通过)。MillisecondDuration 刻意保留(encoding/json 要求 Marshal 值/Unmarshal 指针的混合)。
Result: {"status":"keep","total_issues":9,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":45}
2026-08-16 18:00:45 +08:00
ryan 4ecec2cf1b forcetypeassert 6→0(缓存 list 断言、relay/flared 中间件契约断言、图片压缩 flight 断言,全部带检查+安全失败路径);errname 1→0;prealloc 2 处(另 1 处与 repo mnd 冲突,用命名常量解决)。nilnil 保留(not-found/可选结果惯例,含接口契约注释)。
Result: {"status":"keep","total_issues":15,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":7,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":14,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":43}
2026-08-16 17:58:03 +08:00
ryan 86fad02c41 errorlint 12→1:3 处 cmd 入口 err!=context.Canceled→errors.Is(防御性,当前 runner 不 wrap 语义不变);2 处 strconv.NumError 断言、1 处 viper 断言、2 处 ==io.EOF、2 处 ==redis.Nil、1 处 ==gorm.ErrRecordNotFound→errors.As/Is;8 处 %v→%w 保留错误链。刻意保留 telegram.go 单处 %v(原始错误仅作上下文文本,wrap 会改变 errors.Is 匹配语义)。
Result: {"status":"keep","total_issues":22,"golint_canonicalheader":0,"golint_errname":1,"golint_errorlint":1,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":21,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":46}
2026-08-16 17:52:03 +08:00
ryan 600a7acdfb docs: 核查并润色文档,对齐项目实际实现
- 删除未经验证的环境要求(Docker 版本号、浏览器条目)与括号废话
- 故障排查改为真实处理路径(升级→重新发布→强制同步→重建 Agent→提交 issue),删除仅开发时用的排障章节
- 删除设计文档中的测试与验收、实现检查清单、贡献者阅读建议等开发内容
- 修正与代码不符的事实:reset-passwd 命令名、证书续签窗口 7 天、Pages 检查间隔 1440 分钟、Relay vhost 端口 8080、SSO 仅支持 OIDC 等
- 去除口语化表述与无意义括号,改写「不是…而是…」句式
- 同步修正文档站链接锚点,构建验证通过
2026-08-16 17:49:57 +08:00
ryan 288b74d104 intrange 3→0 + modernize 5→3:for i:=0;i<len/N;i++ → range len/N(8 处);time.Time 字段 omitempty→omitzero(wire 输出一致);SplitSeq;min() 简化。刻意保留 lark.go omitzero(会改变 wire 行为)。
Result: {"status":"keep","total_issues":33,"golint_canonicalheader":0,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":32,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":45}
2026-08-16 17:47:09 +08:00
ryan ce28f63659 wastedassign 7→0:删除 7 处死初始化(snapshot.go 三连、push 三件套 content、format.go numStr),改 var 声明,零行为变化。
Result: {"status":"keep","total_issues":38,"golint_canonicalheader":0,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":3,"golint_modernize":5,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":37,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":47}
2026-08-16 17:44:45 +08:00
ryan d0414b402a canonicalheader 8→0 + usestdlibvars 3→0:header key 改为 Go 规范大小写(wire 格式本就如此,纯代码修正)、HTTP 方法常量替代字符串字面量。
Result: {"status":"keep","total_issues":45,"golint_canonicalheader":0,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":3,"golint_modernize":5,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":0,"golint_wastedassign":7,"golint_total":44,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":38}
2026-08-16 17:38:18 +08:00
ryan 699e95f12c perfsprint 18→0:strconv.Itoa/FormatInt/FormatUint/FormatBool 替代 fmt.Sprintf、无动词 fmt.Errorf→errors.New、纯字符串拼接。全部语义等价(已核对 diff)。修正 fixer 遗留的 import 问题(引入 goimports 统一整理)。
Result: {"status":"keep","total_issues":56,"golint_canonicalheader":8,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":3,"golint_modernize":5,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":3,"golint_wastedassign":7,"golint_total":55,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":47}
2026-08-16 17:34:44 +08:00
ryan 4e3d79c001 fix(frontend): 修复 useEffect 缺失依赖导致 code-check 失败 2026-08-16 17:29:14 +08:00
ryan 5aaaf8f197 fix(frontend): 修复静态导出下切换语言无效的问题 2026-08-16 17:29:14 +08:00
ryan b76f707c8b modernize 37→5(-32):interface{}→any、内置 max/min、slices/maps 辅助、strings.Cut/SplitSeq、strings.Builder(修复 mail.go O(n²) 拼接)。逐 hunk 核对语义等价;omitzero 冲突修复被自动跳过(wire 格式不变);手动清 4 处遗留 sort import + 2 处 QF1012。
Result: {"status":"keep","total_issues":74,"golint_canonicalheader":8,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":3,"golint_modernize":5,"golint_nilnil":3,"golint_perfsprint":18,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":3,"golint_wastedassign":7,"golint_total":73,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":38}
2026-08-16 17:28:22 +08:00
ryan f1f6bb858a 修复 internal/apps/edge/observability/linux.go 的 2 个 gosec G115 整数溢出转换:helper 改为接收 int64 b,用 gosec 认可的饱和乘法模式(uint64 域乘积 + 上界比较),去掉原 //nolint:gosec,语义不变(Bsize 恒为正)。repo 自带 gate 首次全绿。
Result: {"status":"keep","total_issues":106,"golint_canonicalheader":8,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":3,"golint_modernize":37,"golint_nilnil":3,"golint_perfsprint":18,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":3,"golint_wastedassign":7,"golint_total":105,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":38}
2026-08-16 17:21:04 +08:00
ryan 305d609d0d chore(quality): 启动代码质量 autoresearch 会话
- .auto/ 会话文件(prompt/measure/checks/ideas)
- pnpm-workspace.yaml 认可 @parcel/watcher 与 @swc/core 构建脚本,
  修复 pnpm 11 下 make code-check 无法运行的问题
2026-08-16 17:10:28 +08:00
ryan 5a8722ff07 feat(frontend): 控制台 next-intl 中英双语
接入无 URL 前缀的 zh-CN/en,顶栏与外观设置可切换语言;选择写入 cookie 后刷新生效。
2026-08-16 16:49:16 +08:00
ryan 64fbaa7ef1 chore: docs 2026-08-16 11:32:13 +08:00
ryan fa689aedbc feat(sync): 同步 Wavelet 推送审计、OTel schema 与前端字体
自定义 Webhook 在 HTTP 200 但业务 errcode 非零时记为失败,任务日志记录上游响应。
OTel Resource 改为 NewSchemaless,避免 semconv 与 SDK 版本冲突。
前端用 next/font 自托管 Inter,并忽略浏览器扩展改写 body 引起的 hydration 警告。
2026-08-16 11:06:54 +08:00
ryan f960511cc0 chore(release): v3.5.3
### 新增
- 访问日志「日志明细」支持按 HTTP 状态码筛选,可直接输入任意状态码。
- 访问日志「日志明细」支持自定义时间范围筛选,可按起止时间检索日志。
- 首页看板改版:24 小时请求趋势拆分展示请求总量与 2xx/4xx/5xx 状态码类请求量并独占一行;移除宿主机磁盘指标,24 小时容量趋势(CPU/内存)并入业务流量卡片展示。

### 🛠 修复
- 修复首页「来源分布」卡片在 PostgreSQL/SQLite 日志库下无数据的问题。
- 修复源站错误页「仅针对 GET 请求」未真正透传非 GET 响应的问题:POST/PUT 等非 GET 请求现可完整看到源站原始报错内容。
2026-08-13 11:44:08 +08:00
ryan 465440fa5b fix(access-logs): 修复状态码自定义 2026-08-13 11:33:12 +08:00
ryan a4dd5ca9e1 feat(dashboard): 首页请求趋势拆分状态码并合并容量到业务流量
- 24 小时请求趋势拆分展示请求总量与 200/400/500 状态码请求量,独占一行;
  时间桶聚合新增 status_200/400/500_count(CH countIf、PG FILTER),
  请求趋势改为基于原始桶聚合(小时 rollup 无状态码口径)
- 首页移除宿主机磁盘指标,容量趋势(CPU/内存)并入业务流量卡片展示
- 压缩协议 traffic_24h 扩展为 7 元组,前端归一化同步更新
2026-08-13 11:10:37 +08:00
ryan a9e4237bbf feat(access-logs): 状态码支持手动输入,新增时间范围筛选
- 状态码筛选支持预设快捷选项 + 手动输入任意 100-599 状态码(数字校验)
- 新增时间范围筛选:shadcn 日期+时间选择器(Popover+Calendar+时分 Select),
  起止时间以 RFC3339 成对传入,后端校验格式与先后关系,非法值返回 400
- 默认显示来源 IP/访问域名/状态码,节点 ID/请求路径/时间范围折叠进「更多筛选」
2026-08-13 10:27:40 +08:00
ryan 75d1fcf345 feat(access-logs): 日志明细支持按状态码筛选并折叠次要搜索项,修复首页来源分布无数据
- 修复 PostgreSQL/SQLite 日志库下首页「来源分布」卡片无数据:RegionCounts 对空
  节点 ID 误拼 node_id = '' 恒空条件,改为空节点 ID 表示全节点聚合(对齐 CH 语义),
  并过滤空白归属地
- /access-logs?tab=list 新增状态码筛选:状态码下拉含常用 2xx/3xx/4xx/5xx 选项,
  校验 100-599,非法值返回 400;ClickHouse 与 PostgreSQL/SQLite 日志库均支持
- 搜索框折叠:默认仅显示来源 IP 与状态码,节点 ID/访问域名/请求路径折叠进
  「更多筛选」
2026-08-13 09:59:32 +08:00
ryan f1577bf092 fix(openresty): 修复源站错误页「仅针对 GET 请求」覆盖非 GET 原始报错数据
proxy_intercept_errors 会在 Lua 判断前丢弃源站错误响应体,POST/PUT 等
请求收到 503 时被 OpenResty 自带错误页覆盖原始报错数据。现改为在代理
location 内用 Lua header/body 过滤器仅对 GET 请求替换错误页,非 GET
请求完整透传源站原始状态码与响应体;非仅 GET 模式继续使用命名 location
承载错误页。
2026-08-09 19:38:44 +08:00
ryan 01ed2c5e36 chore(release): v3.5.2
修复几个遗漏bug
2026-08-09 14:08:04 +08:00
ryan 80696c12fa fix: lint 2026-08-09 13:47:40 +08:00
ryan 3d4d99081e fix(log): PG 日志库批量写入为零 ID 行生成雪花 ID
PostgreSQL 日志表 id 为 NOT NULL 且无默认值,而 GORM 将零值 uint64
主键视为自增并省略 id 列,导致 node access log / 可观测指标等批量
落库持续报 "null value in column id violates not-null constraint"。
在 BatchInsert* 落库前为零 ID 行生成雪花 ID(与 ClickHouse 写入路径
一致),并新增单元回归与 PG 集成回归测试覆盖六张日志表。
2026-08-09 13:47:13 +08:00
ryan 0639855653 fix(openresty): 修复源站错误页「仅针对 GET 请求」未生效
error_page 的 URI 内部重定向会把请求方法改写成 GET,导致内部
Lua 中 ngx.req.get_method() 恒为 GET,get_only 判断永不命中,
POST/PUT 等请求仍返回自定义错误页。

改为命名 location(@__openflare_origin_error)承载错误页:
命名 location 保留原始请求方法与原始错误状态码,非 GET 请求
直接以原状态码退出、不再注入自定义 HTML。附带回归断言,禁止
回退到 URI 内部重定向形式。
2026-08-09 13:42:38 +08:00
ryan 0524ae1da4 chore(release): v3.5.1
### ✨ 新功能
- 日志存储解耦:ClickHouse 变为可选项,不启用时由 PostgreSQL/SQLite 承担全部日志功能;新增「切换日志数据库」任务支持 PostgreSQL/SQLite 与 ClickHouse 间数据迁移(迁移期间冻结日志写入,成功后自动切换主库并保留源数据);`log_database` / `log_db_migration` 设为受保护配置;ClickHouse 改为默认关闭。
- 新增 PostgreSQL/SQLite 日志存储实现:节点访问日志按月分区,统计查询合并为单次扫描、IP 汇总归属地取查询窗口内最新记录、WAF 按 IP 聚合减少扫描次数,并新增 `logged_at` 前导索引与主机名小写表达式索引;过期清理直接删除完全过期的整月分区,启动时兜底预建当月及未来 2 个月分区。
- 性能指标与访问日志的保留时长解耦:新增三库共用的 `metric_retention_days` 配置(默认 3 天),每日垃圾清理按独立短留存清理指标快照。

### 🛠 修复
- 修复 UptimeKuma 同步调试日志泄露凭据:Socket.IO 事件日志不再打印 payload 内容(仅记录长度),避免凭据进入日志。
- 修复日志保留天数配置继承旧键导致的误删风险:`log_retention_days_*` 不再继承 `database_auto_cleanup_retention_days`,统一默认 30 天。

### ⚡️ 优化与改进
- 系统定期垃圾清理由每 2 小时改为每日执行一次(凌晨 3 点,Asia/Shanghai),降低非必要高频扫描。

### 💄 其他/体验
- 服务工作者(SW)注入挑战页改为前台无感知:不再显示「加载中…」文案,页面空白,仅通过浏览器控制台输出 `[sw-challenge]` 调试信息,注入过程不打扰访客。
- 用户访问日志(`w_user_access_logs`)记录禁用:不再采集与写入新的用户访问日志,存量数据与管理端访问日志统计页面保留。
2026-08-09 11:39:28 +08:00
ryan adee4f7b27 docs: update 2026-08-09 11:22:35 +08:00
ryan 1d0f2d6342 fix(log): hard-set log retention days default to 30, drop legacy inheritance
log_retention_days_* 迁移不再继承旧键 database_auto_cleanup_retention_days
的值,统一默认 30 天。此前若旧键残留异常小值(如 2 天)会被静默带入,
导致升级后首次垃圾清理把大部分日志直接删掉。PG/SQLite 双方言同步修改,
文档默认值 90 -> 30。
2026-08-09 10:48:45 +08:00
ryan e3f603f72a fix(security): stop logging UptimeKuma socket payload content 2026-08-09 10:42:21 +08:00
ryan 3b010bb15e feat(log): disable user access log recording
- 移除全局用户访问日志采集中间件与批写入 writer(risk_control 包整包删除),
  不再写入 w_user_access_logs;存量数据与管理端访问日志统计页面保留
- 日志库迁移任务不再排空用户访问日志队列,状态接口不再展示其缓冲队列统计
- 迁移测试的系统配置 seed 计数断言更新为当前实际值(86 → 95),
  注释改为提示新增配置 seed 时同步更新
2026-08-09 10:35:39 +08:00
ryan f530cd4025 perf(log): optimize PG log store queries and expired partition cleanup
- Count/节点访问日志统计改为单次扫描聚合,WAF 按 IP 聚合由三次扫描合并为两次
- IPSummaries 归属地改为取过滤窗口内最新记录(对齐 ClickHouse argMax 口径),
  子查询带窗口条件,可分区裁剪并命中索引
- 新增 goose 迁移:of_node_access_logs (logged_at DESC, id DESC) 前导索引与
  lower(trim(host)) 表达式索引,加速列表排序与主机过滤
- 过期日志清理先按数据校验直接 DROP 完全过期整月分区,再对边界月逐行删除;
  启动时兜底预建当月及未来 2 个月分区,跨月停机重启后首次写入不再报
  "no partition of relation found"
2026-08-09 10:20:58 +08:00
ryan 08d28c2c8e feat(log): drop empty old-month PG partitions during cleanup
系统垃圾清理任务删除过期日志后,顺带清理旧月份空分区表:
PostgreSQL 按月分区的访问日志表(节点/用户)在数据删除后若该月
分区已无数据,则自动删除对应分区表,避免历史分区表无限累积。

- 仅删除「当前月之前」且为空的月份分区,当月/未来月及仍有数据的分区保留
- ClickHouse/SQLite 为 no-op(CH 分区随数据删除自动消失)
- 修复既有集成测试 pg_inherits 查询(inhrelid → inhparent)
- 新增单元测试与 PG 集成测试
2026-08-09 09:33:59 +08:00
ryan 34a0896ff8 chore(task): run system garbage cleanup once daily
系统定期垃圾清理 cron 由每 2 小时(0 */2 * * *)改为每日凌晨 3 点
(0 3 * * *,Asia/Shanghai),降低非必要高频扫描。新增 PG/SQLite
双方言 goose 迁移(含 Down 回滚)与迁移测试。
2026-08-09 09:25:30 +08:00
ryan 0c22e76f4b fix(frontend): optimization 2026-08-09 09:14:54 +08:00
ryan bd2183c8bb feat(log): add independent short retention for performance metrics
性能指标(CPU/内存/磁盘/网络)不再跟随 log_retention_days_*,新增三库共用
的 metric_retention_days 配置(默认 3 天),系统垃圾清理按独立短留存清理
指标快照;访问日志保留时长不变。新增 PG/SQLite 双方言 goose 迁移 seed。
2026-08-09 09:04:33 +08:00
ryan 9df2437e47 fix(config): set ClickHouse to disabled by default and update related documentation 2026-08-09 08:54:26 +08:00
ryan e8c414aa12 fix: ch migrate 2026-08-09 08:47:56 +08:00
ryan 7e8aa5fa0f Merge branch 'codex/log-database-decoupling'
# Conflicts:
#	docs/changelog/index.md
#	frontend/app/(main)/error-pages/page.tsx
#	internal/infra/persistence/migrator/migrator_test.go
2026-08-09 08:37:37 +08:00
ryan 7e518987de chore(release): v3.5.0
### 🛠 修复
- 修复 PoW 挑战页潜在 XSS 风险,状态与错误文案改用纯文本渲染,并限制跳转 URL 仅允许 http/https 协议。
- 修复邮件发送的邮件头注入风险,写入邮件头前自动清除 CR/LF 换行符(CWE-93)。
- 修复 UptimeKuma 同步调试日志泄露凭据问题,输出日志前对密码和 Token 等敏感字段打码。

### ⚡️ 优化与改进
- 新增 Service Worker 离线兜底功能,为启用 HTTPS 的网站自动下发 Service Worker 并缓存离线页,域名不可达时展示离线兜底页面。
- 重构响应页面设置,将源站错误页与 Service Worker 离线页整合至统一的「响应页面」(/responses)标签页,并增加 URL 查询参数 tab 状态同步。

### 💄 其他/体验
- 新增离线页内置预制模板套件(「极简白底」、「线框拓扑」、「包豪斯」),与源站错误页模板风格保持一致,支持编辑界面一键加载与预览。
2026-08-08 23:08:12 +08:00
ryan 61484090f9 fix: 修复源站错误页「仅针对 GET 请求」导致配置发布失败并回滚 2026-08-08 22:37:40 +08:00
ryan 94b74d72f6 fix(frontend): fallback empty offline page html in editor workspace preview 2026-08-08 21:56:44 +08:00
ryan 6487ce666d fix(security): harden PoW XSS, email header injection and UptimeKuma log redaction 2026-08-08 21:52:46 +08:00
ryan c4be34b214 fix(frontend): fallback empty offline page html to default template in preview 2026-08-08 21:52:02 +08:00
ryan fda727cf53 docs: rename contact page references to offline page in changelog
refactor(frontend): rename contact page to offline page and update routes/components

feat(frontend): add preset templates suite for offline contact page
2026-08-08 21:36:58 +08:00
ryan f083da20f2 feat(frontend): add dedicated edit and preview routes for response pages and sync tab state to url 2026-08-08 21:18:53 +08:00
ryan 9797fcdb2f fix(openflare): improve SWOfflineDomains validation and snapshot diff logic 2026-08-08 20:52:07 +08:00
Ryan 93ec3096f3 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-08 20:48:54 +08:00
Ryan 0e34301c92 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-08 20:48:37 +08:00
Ryan 12b5271f92 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-08 20:47:33 +08:00
Ryan 8ee966434d Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-08 20:46:54 +08:00
ryan 6882481a56 Merge remote-tracking branch 'origin/feature/service-worker' into feature/service-worker 2026-08-08 20:44:27 +08:00
ryan b675038bba refactor(frontend): unify error pages into responses page and remove error-pages route 2026-08-08 20:43:58 +08:00
ryan d17ec9d17d fix(docs): resolve vitepress build error by escaping raw tags and excluding superpowers dir 2026-08-08 20:43:56 +08:00
ryan 8758f9a061 refactor(frontend): unify error pages into responses page and remove error-pages route 2026-08-08 20:40:05 +08:00
ryan 7d93d3d2a1 fix(log): address remaining CodeRabbit suggestions for log database switch and migrations 2026-08-08 20:36:04 +08:00
Ryan 074edf17a1 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-08 20:30:36 +08:00
Ryan 6faf525af0 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-08 20:30:17 +08:00
ryan a6fc2b7737 fix(log): address CodeRabbit review findings for log database decoupling 2026-08-08 20:15:36 +08:00
ryan ca21ff3a5b feat(option): add sw offline
fix(openresty): scope sw injection per cert partition

fix(lint): satisfy revive and biome format for sw offline feature

docs: sw offline scope changelog

fix(frontend): use scoped query key for sw scope zones

fix(frontend): hide preview link in sw contact page editor

feat(frontend): add sw scope domain picker and contact page fields

refactor(frontend): generalize html editor workspace for reuse

feat(openresty): scope sw offline injection by route domains

feat(openresty): add sw offline domains snapshot field

feat(option): add sw offline domains scope option

docs: fill html editor workspace generalization detail

docs: sw offline scope implementation plan

docs: sw offline scope design

test(openresty): assert single merged access block in sw enabled servers

fix(openresty): restrict sw intercept to https server blocks

fix(openresty): version sw offline cache by html content

fix(agent): escape redir in sw challenge page to prevent xss

fix(agent): return sw.runtime module table and add lua spec

docs: sw offline fallback changelog

fix(frontend): memoize option map to preserve unsaved contact page edits

feat(frontend): add response pages module with contact page tab

feat(agent): ship sw offline lua assets and placeholder substitution

feat(config): wire sw offline options into config snapshot

feat(openresty): render sw offline assets and challenge intercept

feat(openresty): add sw offline ConfigSnapshot fields and placeholder

feat(db): seed sw offline options

feat(option): add sw offline config keys and validation

docs: add service worker offline fallback implementation plan

docs: adopt global-option pattern for SW offline fallback (matches origin error page)

docs: unify offline contact page with error pages as response pages

docs: service worker offline fallback design (issue #23)
2026-08-08 20:14:28 +08:00
ryan 7d71f1e4e1 feat(log): decouple log storage from ClickHouse with switchable logstore
- New internal/repository/logstore abstraction: exported domain interfaces
  (AccessLogStore/ObservabilityStore/UserAccessLogStore/StatusStore),
  config-driven provider (Active/Build/Migrating/SetConfigReader), GORM
  implementation for PostgreSQL/SQLite (incl. hourly rollups computed in
  real time, migration listers, PG partition maintenance), and a ClickHouse
  wrapper preserving the native batch path; repository facade delegates to
  logstore; import-lint test enforces apps never import analyticsrepo.
- ClickHouse is now optional: the log DB is either the main DB (postgres
  when database.enabled, else sqlite) or clickhouse; boot validation +
  first-run seed; log_database / log_db_migration are protected keys.
- New user task 切换日志数据库 (of_log_db_switch): freeze log writes,
  drain batch writers, copy all 6 raw log tables by id (preserving IDs)
  with target-partition pre-creation for PG, flip log_database on success,
  clear the freeze flag on failure.
- Per-store retention (log_retention_days_*) with expiry cleanup folded
  into the daily system_cleanup task; legacy database_auto_cleanup_* and
  of_database_auto_cleanup decommissioned.
- goose migrations: 6 log tables in PG (2 monthly-partitioned) + SQLite,
  retention config seeds, schedule cleanup; GET
  /api/v1/admin/status/log-database endpoint; frontend retention settings,
  switch-task UI and status badge; changelog and docs updated.

docs(plan): log database decoupling implementation plan

docs(design): log database decoupling design (ClickHouse optional)
2026-08-08 19:43:01 +08:00
ryan 734fe45baa chore(release): v3.4.5
### ⚡️ 优化与改进

- 源站错误页新增「仅针对 GET 请求」开关:开启后仅对 GET 请求的匹配错误状态码返回自定义错误页,其它 HTTP 方法透传源站响应。
- 升级前后端依赖至最新稳定版
- Agent 不再将 GeoLite2 Country/City MMDB 嵌入二进制:Docker 镜像在默认数据目录 COPY 数据库文件,裸二进制首次启动时按需下载,显著减小 Agent 包体积;OpenResty 仍从磁盘路径读取 MMDB,Server 控制面仍仅内嵌 Country MMDB(不含 City)。
2026-08-08 11:25:42 +08:00
ryan ef22ecc5dc refactor(error-pages): merge trigger policy into one card
Combine enable, GET-only, and status code settings into a single
strategy card with the save action in the header for a cleaner layout.
2026-08-06 22:29:09 +08:00
ryan 6738abdec1 feat(openresty): add origin error page GET-only option
Allow restricting custom origin error HTML to GET requests so other
methods pass through origin responses. Adds option seed, snapshot field,
edge limit_except/Lua handling, and admin UI switch.
2026-08-06 20:22:44 +08:00
ryan d17d8457f3 chore: upgrade dependence 2026-08-06 17:41:06 +08:00
ryan 16f34928c9 chore: AGENTS.md 2026-08-06 16:40:13 +08:00
ryan 3328d3d121 refactor(agent): stop embedding GeoIP MMDB in agent binary
Agent ships without City/Country MMDB in the binary; Docker images COPY
databases into data_dir, bare installs seed via download on first start.
Server keeps Country-only embed for optional MaxMind control-plane use.
Also harden fetch script nonempty check and reject non-file MMDB paths.
2026-08-06 16:24:57 +08:00
ryan fb08002e99 chore(release): v3.4.4
### 新增

- 新增全局源站错误页:可在「网站管理 → 错误页」配置开关、触发状态码(支持 `500-599` 区间与单码)与自定义 HTML;默认启用 OpenFlare 极简错误页并保持真实 HTTP 状态码,修改后随配置版本发布下发到边缘,关闭后恢复透传。
- 新增 Cloudflare DNS 指向管理:可复用现有 Cloudflare DNS 账号或配置独立 Token,按分组将 ZoneDomain 的单条 A 记录异步同步到边缘节点 IPv4,并支持成员橙云、同步状态与节点 IP 变更联动。

### 修复

- 修复 Agent 在配置已对齐但磁盘校验和不一致时,Pages 等对账成功后仍保留 `LastError` 的问题,避免偶发网络失败被健康事件长期显示为「活动中」且无法自动恢复。

### 改进

- 删除、撤销与未保存离开等确认操作统一改用页面内 AlertDialog,不再使用浏览器原生 `confirm` 弹窗,交互风格与系统其余对话框保持一致。
2026-08-06 15:52:26 +08:00
ryan f650214bbb fix(openresty): preserve origin error status on custom error pages
Remove error_page '=' form that adopted the internal URI status (often 200)
and left ngx.status as 0. Resolve the original code from $status/upstream
and set ngx.status before rendering the HTML body.
2026-08-06 15:45:41 +08:00
ryan ba1c9222c2 refactor(error-pages): preset templates with OpenFlare branding 2026-08-06 15:25:39 +08:00
ryan 076bf8b95c Merge branch 'feat/origin-error-page' 2026-08-06 14:16:28 +08:00
ryan 835c50dbaa docs: origin error page configuration and changelog
Document three origin error page Option keys in configuration reference
and merge the unreleased changelog entry into a user-readable description.
2026-08-06 14:10:03 +08:00
ryan 42f7f47716 feat(frontend): add origin error page settings under websites 2026-08-06 14:05:21 +08:00
ryan 7d47db1f34 feat(option): seed and validate origin error page options 2026-08-06 13:59:54 +08:00
ryan 68d8f786cc feat(openresty): render origin error page directives
Wire origin error page into OpenResty proxy route rendering: ConfigSnapshot
fields, default HTML template SupportFile, proxy_intercept_errors + error_page
with status-preserving internal Lua location, and Agent placeholder substitution
for __OPENFLARE_ERROR_PAGE_TMPL__. Pages routes are excluded.
2026-08-06 13:52:40 +08:00
ryan 9d93dc0b9f merge: fix/agent-clear-last-error-on-sync-success
Merge agent sticky LastError clear fix into main.
2026-08-06 13:48:14 +08:00
ryan 1a4a03a20d fix(agent): clear sticky LastError on successful sync paths
Pages reconcile could succeed while agent state retained a previous
network error, so health events stayed active indefinitely. Clear
LastError whenever sync completes successfully without re-applying
config, and cover the paths with regression tests.
2026-08-06 13:47:48 +08:00
ryan 07e835c543 feat(openresty): add status code tag expand helper 2026-08-06 13:46:58 +08:00
ryan 1f5bebd18a docs(plan): add origin error page implementation plan
拆分为状态码解析、OpenResty 渲染、Option/快照、前端设置页与文档验收五步任务。
2026-08-06 13:44:45 +08:00
ryan fd62570431 docs(design): add origin error page design
全局可配置源站错误页:默认 500-599、Cloudflare 风格模板、
状态码透传与在线 HTML;配置进 Option 与配置版本快照。
2026-08-06 13:42:41 +08:00
ryan 484b49d79d fix(frontend): replace browser confirm dialogs with AlertDialog
统一删除、撤销与未保存离开等确认操作为 shadcn AlertDialog,避免
window.confirm/alert 打断界面风格;同步更新 WAF 编辑器相关单测与 changelog。
2026-08-06 13:08:43 +08:00
ryan cffa009b8c fix 2026-08-04 13:50:41 +08:00
ryan 4eced2b721 feat(cloudflare): add DNS pointing integration 2026-08-04 13:30:40 +08:00
ryan ea7658815a fix(migration): quote reserved authorization column 2026-08-04 12:49:58 +08:00
ryan 3edcdb9e9f feat(cloudflare): add DNS pointing integration
Implement Cloudflare connection management, pointing groups and members, asynchronous A-record reconciliation, node IP triggers, admin APIs, management pages, migrations, tests, and documentation.
2026-08-04 12:32:37 +08:00
ryan 99f0f63b99 agents rename 2026-08-04 11:40:32 +08:00
ryan 21fb303ef2 doc: cloudflare 对接 2026-08-04 11:31:11 +08:00
ryan 3aa4d98cd6 ci: canary version 2026-08-03 21:53:52 +08:00
ryan 1f71c9f25b ci: canary version 2026-08-03 21:53:08 +08:00
ryan 943818f7d4 refactor(repository): 收敛 model/repository 分层为唯一持久化入口
将 OpenFlare 与平台业务的数据访问从 model 与 apps 直连迁入 repository,
model 仅保留实体与无 IO 规则;补充 code-check 架构守卫与开发规范。
2026-07-24 17:00:17 +08:00
ryan 23a5488203 refactor(http): remove dead internal/util HTTP client wrapper
Drop internal/util (unused httppool wrapper and dead StringArray) and
rely on pkg/httppool plus oauth context injection for HTTP clients.
2026-07-24 15:49:52 +08:00
ryan d99c5b7c43 refactor(pkg): merge pkg/utils into pkg/util
Consolidate pure helper packages under pkg/util and update imports.
2026-07-24 15:45:10 +08:00
ryan 33a1c32cf8 refactor(structure): group platform, infra, and shared packages
Reorganize internal packages into platform/infra/shared layers and update
imports, docs, and seed-count tests to match current system configs.
2026-07-24 15:41:59 +08:00
ryan 68d730a388 chore(release): v3.4.3
### 🛠 修复
- 修复了 IP 组自动抓取使用预设规则时未写入 ttl 的问题,避免配置缺少封禁时长。
- 修复了限流相关数据库迁移中的表名错误,确保升级脚本正确执行。

### ⚡️ 优化与改进
- 边缘缓存对齐 Cloudflare 默认模型:不再因登录 Cookie 等请求头一律跳过缓存,登录用户可命中静态资源;响应 Set-Cookie 不入库,并补充默认 Edge TTL。生效需重新发布节点配置。
- 新增全局与站点级单 IP 请求频率限制,触发时返回 429,并支持继承、关闭与按站点隔离。
- IP 组自动规则支持 2xx/4xx/5xx 类状态码写法,同步间隔下限降至 1 分钟,回看窗口支持 60m/1h 等时长写法。
- 限流页请求压力图 RPS 纵轴按可见窗口峰值动态缩放,低流量更易读。

### 💄 其他/体验
- 补充边缘缓存运维与故障排查说明,并对「所有可缓存 GET」策略增加风险提示。
2026-07-24 00:04:20 +08:00
ryan f94767fbc7 perf(cache): 边缘缓存对齐 Cloudflare 默认模型 2026-07-23 23:39:15 +08:00
ryan 5b1e27d0a3 feat(frontend): biome 2026-07-22 22:25:30 +08:00
ryan f28aa6520e fix(lint): 消除 linter 告警 2026-07-20 15:53:53 +08:00
ryan d58b4b6b0e feat(waf): 自动 IP 组 lookback 支持 60m/1h 时长写法
将 lookback_minutes 替换为 lookback,移除最小 5 分钟回看限制,并兼容旧字段。
2026-07-20 15:48:16 +08:00
ryan 866f1df5e3 fix(waf): IP 组同步间隔下限改为 1 分钟
移除同步周期 5 分钟限制;回看窗口仍保持最小 5 分钟。
2026-07-20 15:41:31 +08:00
ryan 351e8ce78c feat(waf): StatusRatio/StatusCount 支持 2xx/4xx/5xx 类写法
自动 IP 组表达式可按状态码类汇总占比与计数,兼容原有精确状态码。
2026-07-20 15:39:14 +08:00
ryan 67a30eb5ed fix(waf): IP 组预设规则写入默认 ttl
点击自动抓取预设规则时补齐 ttl=-1,并规范化自动配置默认 JSON。
2026-07-20 15:36:06 +08:00
ryan 80c47f6ff3 feat(rate-limit): 站点级请求频率限制支持继承与自定义
在站点详情流量限制中配置 limit_req_per_ip;渲染按 effective rate 生成多 limit_req_zone,并以站点+IP 隔离计数。
2026-07-20 15:02:38 +08:00
ryan a261c01a9c fix(db): correct table name to of_proxy_routes in migration 2026-07-20 14:06:07 +08:00
ryan ae5345c03e feat(rate-limit): add default request rate limit configuration
- Support openresty_default_limit_req_per_ip in system_configs.
- Add limit_req and limit_req_status 429 directive generation in openresty renderer.
- Implement route-level limit_req_per_ip override and explicit disable.
- Add frontend UI inputs and validation in rate limits tab config.
- Update swagger API docs and changelog for v3.4.3-beta.3.
2026-07-20 10:58:13 +08:00
ryan fda8d7fcb1 fix(rate-limits): 请求压力纵轴随 dataZoom 可见区间缩放
拖动底部时间范围条时按可见窗口最高 RPS×1.5 更新纵轴,访客轴同步按可见数据重算。
2026-07-20 09:02:31 +08:00
ryan b91c848256 fix(rate-limits): RPS 纵轴按峰值 1.5 倍动态缩放
请求压力图不再使用固定美化刻度上限,改为当前时段最高 RPS × 1.5,低流量更易读、高峰不易裁切。
2026-07-20 08:49:46 +08:00
ryan 36cff502f7 chore(release): v3.4.2
### 🛠 修复
- 修复 Pages 部署包路径校验、归档展开限额、历史版本裁剪、代理路由绑定与 Agent
  下载过程中的安全和一致性问题;大包改为流式处理,异常中断遗留的部署包会安全补偿清理。

### ⚡️ 优化与改进
- Pages 项目新增持久部署源,支持 Remote URL 或公开 GitHub Release;GitHub latest
  可按设定间隔自动检查并发布更新,默认间隔为每天一次。
- Remote URL 默认允许公网与内网地址,新增「允许不安全连接」开关。
- Pages 详情页重构为「部署 / 设置」Tab,部署源卡片样式更紧凑统一。
- 安全性新增「限流」设置,可为边缘站点配置默认并发与带宽;填 -1 可关闭。
- 限流页新增分析视图,展示请求压力与独立访客趋势,支持域名过滤与时间预设。

### 💄 其他/体验
- 精简部署源数据模型,去除脱敏与无用字段。
- Pages 部署源任务不再隐藏,可在任务管理中查看。
- make prettier 支持自动清理前后端无用 import。
- 限流趋势桶调整至 3 分钟粒度,范围扩展至 24h/3d。
- Agent 部署命令增加 Pages 命名卷持久化。
2026-07-19 20:54:57 +08:00
ryan fa588797bf chore: eslint fix 先于 prettier 执行 2026-07-19 20:52:01 +08:00
ryan a963b8bf54 chore(prettier): format 并清理无用 import
make prettier 统一格式化,goimports 与 eslint unused-imports 自动移除未使用导入。
2026-07-19 20:51:12 +08:00
ryan d9663f91d6 chore: make prettier 自动清理前后端无用 import
前端接入 eslint-plugin-unused-imports,pnpm format 同步执行 eslint --fix;
后端将 gofmt 替换为 goimports,并修复 danger-zone 未使用导入。
2026-07-19 20:47:28 +08:00
ryan 4481677ef3 refactor(pages): 公开部署源任务并默认每日扫描
移除 Pages 部署源任务的 InternalOnly 限制,任务管理可查看与调度;
将 scanner cron 与 GitHub latest 默认检查间隔调整为每天一次,
并优化部署历史列表展示。
2026-07-19 20:41:09 +08:00
ryan 20249d917c feat(rate-limits): use 3-minute trend buckets
Rate-limit analysis requests overview with bucket_minutes=3; RPS uses
count/180. Overview still defaults to 60-minute buckets.
2026-07-19 20:30:20 +08:00
ryan abe8fb8268 refactor(pages): 精简部署源模型并重构详情页交互
将 Remote 网络策略收敛为 allow_insecure,去掉脱敏与无用字段;
Pages 详情拆为部署/设置 Tab,统一卡片样式与来源信息展示。
2026-07-19 20:23:31 +08:00
ryan f0b51a99b3 fix(db): 重编号 Pages 部署源迁移避免版本冲突
main 已占用 202607190001(OpenResty 默认限流),
将 Pages source runtime / scanner seed 顺延为
202607190002、202607190003,并同步迁移测试期望配置数。
2026-07-19 19:41:30 +08:00
ryan c92f986978 merge: 合并 PR #22 Pages 部署源 V2 到 feat/pages-source-sync-v2
基于最新 main 合并 deqiying/feat/pages-source-sync-v2,
解决 docs/changelog/index.md 与限流相关条目的冲突。
2026-07-19 19:36:48 +08:00
deqiying ccea08fe47 docs(pages): 收口部署源 V2 实现
同步 Pages、总体架构、Agent 与使用指南,记录阶段提交、验证结果和生产验收边界。
2026-07-19 19:25:28 +08:00
ryan 72962beb0f feat(rate-limits): use 1m buckets and 24h/3d ranges
Allow overview bucket_minutes=1; rate-limit analysis uses 1-minute
buckets and replaces 7d preset with 3 days.
2026-07-19 19:20:59 +08:00
ryan b56290d79d feat(rate-limits): use 5m trend buckets and 24h/7d ranges
Overview API accepts bucket_minutes (5|60); rate-limit analysis uses
5-minute buckets and drops 15d/30d presets. Widen rank value column.
2026-07-19 19:18:15 +08:00
deqiying 67b051c2bc feat(frontend): 支持 Pages 自动更新交互
在 GitHub latest 来源中提供自动更新开关、检查间隔和运行状态。\n页面按来源到期时间低频刷新,并在自动发布或人工回滚后同步项目与部署历史。
2026-07-19 19:09:57 +08:00
deqiying 999428cf9a feat(pages): 增加来源扫描与自动更新
为 GitHub latest 来源增加五分钟 scanner、按来源间隔检查、精确 revision 自动发布与租约恢复。\n记录退避和投递统计,并为 PostgreSQL 与 SQLite 幂等创建内部排程。
2026-07-19 19:09:21 +08:00
ryan 86d2d6b0ad feat(rate-limits): add RPS analysis tab with dual-axis chart
Split rate-limits into analysis/config tabs; reuse access-log overview
filters; chart hourly RPS vs visits with dataZoom; rank top hosts/IPs
by window-average RPS.
2026-07-19 19:09:01 +08:00
deqiying 848884d8cd fix(pages): 增加部署包孤儿补偿
按项目、来源、运行时与上传记录锁序补偿异常中断遗留的部署包。\n同时隐藏并保护系统内部排程,避免通用任务管理入口修改 scanner。
2026-07-19 19:08:43 +08:00
ryan f783a1e6fa docs: add rate-limit analytics design
Tabs for analysis/config, dual-axis RPS chart with overview filters
and average RPS rankings from access-log overview.
2026-07-19 19:06:06 +08:00
deqiying c39a3edcc3 feat(pages): 支持 GitHub Release 部署源
增加 latest/tag 手动检查与同步、ETag 与限流退避、资源替换确认,以及对应的前端来源管理和部署来源展示。
2026-07-19 18:31:42 +08:00
ryan 4c17f5277a feat(agent): persist Pages dir in Docker deploy volume
Mount openflare-agent-pages to /data/var/lib/openflare/pages so
container rebuilds keep local Pages packages.
2026-07-19 18:28:35 +08:00
ryan 0e097a66c4 docs: document default edge rate limits 2026-07-19 18:19:20 +08:00
ryan 39cba821d5 feat(frontend): add security rate-limits page and inherit UI 2026-07-19 18:17:16 +08:00
ryan c5f8105db8 feat(proxy-route): allow -1 to disable rate limits 2026-07-19 18:14:16 +08:00
ryan 2bc2d82ad0 feat(config): add openresty default rate limit system options 2026-07-19 18:12:51 +08:00
ryan a3125c8276 feat(openresty): merge global default limits at route render 2026-07-19 18:10:51 +08:00
ryan 4d7b63f217 docs: add default edge rate limit implementation plan
Task breakdown for global OpenResty limit defaults, route inherit
semantics, security rate-limits page, and render-time merge.
2026-07-19 18:05:43 +08:00
ryan fada04c373 docs: add edge default rate limit design
Specify global OpenResty limit defaults with per-route inherit (-1 off)
and render-time merge in RenderRouteConfig.
2026-07-19 18:02:13 +08:00
deqiying 38b0516937 feat(pages): 支持 Remote 部署源同步
新增部署源配置与运行态模型、安全下载、租约续期、原子激活和失败补偿。

接入内部任务与脱敏前端交互,并阻止数据库 Trace 和日志展开敏感查询参数。
2026-07-19 17:36:51 +08:00
deqiying 4e8ec23264 fix(pages): 收紧部署包与 Agent 同步边界
完成 V2 Phase 0 安全与一致性前置:统一真实归档限额、流式拉取、候选裁剪、保留上传删除语义及 Pages 路由引用锁。
2026-07-19 16:42:45 +08:00
deqiying f386674464 docs(pages): 完善部署源 V2 实现方案 2026-07-19 16:14:09 +08:00
ryan e0398397a9 chore(release): v3.4.1
### 🛠 修复
- 收紧 WAF 安全防护特征,降低对常见正常请求的误伤(含避免 SQL 特征 /* */ 误匹配 Accept: */*)。
- 优化 WAF 规则编辑器返回按钮、列表操作与属性栏布局体验。
- 节点详情「运行诊断」摘要不再展示具体错误日志,避免长日志撑破布局。

### ⚡️ 优化与改进
- WAF 规则编排新增「UA 检查」与「安全防护」节点,支持浏览器/操作系统白名单、爬虫与自定义正则屏蔽,以及路径穿越、注入类等基础特征检测。
- 优化边缘 WAF 安全防护、UA 检查与 IP 匹配热路径,降低开启基础防护时的 CPU 占用。
- Agent 内嵌 resty.ipmatcher,部署时不再依赖无效 opm 包。
- 新建反代规则时默认开启边缘缓存(标准静态资源策略)。
- 节点详情页调整为「概览」与「状态与部署」,边缘节点支持自动填充部署命令。

### 💄 其他/体验
- WAF 规则编辑器支持节点自定义命名、拖放添加、右键删除与一键格式化布局。
2026-07-19 15:43:05 +08:00
ryan fafee0055a feat(nodes): 优化 2026-07-19 15:42:02 +08:00
ryan 6619f5b650 fix(nodes): 运行诊断不再展示具体错误日志
摘要区仅保留异常数量与事件类型,避免长日志撑破卡片布局。
2026-07-19 15:25:02 +08:00
ryan a65d0f291b feat(nodes): 调整节点详情 Tab
将数据看板并入概览,运行状态与配置合并为状态与部署;。
2026-07-19 15:16:26 +08:00
ryan c00ead9aa0 feat(nodes): 调整节点详情 Tab 并新增边缘部署命令
将数据看板并入概览,运行状态与配置合并为状态与部署;
边缘节点支持自动填充 Server URL 与 Agent Token 的 Docker 部署卡片。
2026-07-19 15:01:38 +08:00
ryan 7366832e12 fix(agent): 内嵌 resty.ipmatcher,移除无效 opm 依赖
OPM 无 api7/lua-resty-ipmatcher 账号导致镜像构建失败;改为 vendor
api7 v0.6.1 并随 ManagedWAFLuaFiles 部署到 lua 目录。
2026-07-19 14:57:51 +08:00
ryan 1a7e5e6c41 perf(waf): IP 匹配改为索引查询(ipmatcher / 预编译)
加载时编译 IP 组与节点 IP/CIDR 索引,优先 resty.ipmatcher 基数树,
否则 exact 哈希 + 预解析 CIDR,避免大名单线性扫描打满边缘 CPU。
2026-07-19 14:48:07 +08:00
ryan 46ce7de513 perf(waf): 收窄安全防护扫描面并优化 UA 热路径
注入类检测仅扫 Query/Cookie/Referer/有限 Body,避免全 Header 匹配拖垮边缘 CPU;
按开关采集输入、GET 跳过 read_body,UA 仅 lower 一次并用 set 匹配白名单。
2026-07-19 14:16:34 +08:00
ryan 39473cb370 chore: prettier 2026-07-19 13:00:01 +08:00
ryan 64e40a7c18 fix(waf): 移除编辑器未使用的图标导入以通过 code-check 2026-07-19 12:58:24 +08:00
ryan 53ddb45614 fix(waf): 规则编辑器返回按钮对齐 websites 详情样式 2026-07-19 12:55:49 +08:00
ryan ad6621fce9 fix(waf): 收紧安全防护特征,降低常见正常请求误伤
- SSRF 仅匹配 URL 形态,避免 Chrome/x.0.0.0 误中
- 命令注入去掉裸 &&/|| 与裸 shell 名
- SQL sleep/benchmark 要求数字参数
- XSS javascript:/eval 要求更像代码的上下文
- 路径穿越去掉过宽的 c:\windows;CRLF 去掉单独 %0a/%0d
2026-07-19 12:54:01 +08:00
ryan 60d6e3e846 fix(waf): 调整编辑器返回与格式化布局按钮位置
返回置于标题上方;格式化布局移至保存按钮左侧。
2026-07-19 12:52:12 +08:00
ryan fd9348b7bd feat(waf): 规则编辑器一键格式化节点布局
按从开始节点出发的层次从左到右整理坐标,并 fitView 到画布。
2026-07-19 12:49:42 +08:00
ryan 32113eb790 fix(waf): 列表操作改为直接图标按钮
规则组与 IP 组表格去掉「…」菜单,操作以图标平铺展示。
2026-07-19 12:47:02 +08:00
ryan 1ba05ec0bd fix(waf): 避免 SQL 特征 /* */ 误匹配 Accept: */*
开启 SQL 注入防护时不再把正常 Accept 头当成攻击。
2026-07-19 12:46:23 +08:00
ryan b75f985815 feat(waf): 新增安全防护节点 security_check
基础特征检测九项可开关;默认开启路径穿越与文件包含;命中任意规则走 false。
2026-07-19 12:33:13 +08:00
ryan db89f68547 docs(waf): 规格 — 安全防护节点 security_check
九项基础特征检测可开关;默认仅路径穿越与文件包含;命中任意规则 false。
2026-07-19 12:20:51 +08:00
ryan 74106474ca fix(waf): UA 检查说明改为问号悬浮提示
将屏蔽/匹配相关 FieldDescription 收敛为 CircleHelp Tooltip。
2026-07-19 11:52:29 +08:00
ryan 1d97ea69d0 fix(waf): UA 检查属性栏将屏蔽区块移到匹配上方 2026-07-19 11:49:55 +08:00
ryan 53d9572508 refactor(waf): 移除规则画布右上角删除按钮
删除改为右键菜单与键盘快捷键。
2026-07-19 11:48:54 +08:00
ryan 8f3ff59567 feat(waf): 规则画布右键删除节点与连线
覆盖画布默认右键菜单;节点/连线右键弹出删除项,系统节点禁用。
2026-07-19 11:47:04 +08:00
ryan d47ceb9971 feat(waf): UA 非正常不含爬虫,并支持自定义正则屏蔽
block_abnormal_ua 仅 Other/Unknown;新增 block_custom_ua 与 custom_ua_patterns。
2026-07-19 11:43:45 +08:00
ryan 7476c86976 fix(waf): UA 检查开启后才显示匹配与屏蔽并补充说明
未开启 require_ua 时隐藏匹配/屏蔽区块;爬虫与非正常 UA 开关增加分类提示。
2026-07-19 11:38:18 +08:00
ryan 28eef0bbcd feat(waf): 新增 UA 检查节点 ua_check
支持要求携带 UA、浏览器/OS 白名单 and-or 匹配,以及优先屏蔽爬虫与非正常 UA。
2026-07-19 11:35:31 +08:00
ryan 047ed6554d docs(waf): 规格 — UA 检查节点 ua_check
定义 require/白名单 and-or/屏蔽优先级及与访问日志一致的 UA 分类标签。
2026-07-19 11:27:55 +08:00
ryan b5e27fabde feat(waf): 规则编辑器节点自定义命名与拖放添加
对齐后端 label 字段;属性栏可编辑显示名称;节点库改为拖到画布落点创建。
2026-07-19 11:01:24 +08:00
ryan 4166cc9861 docs(waf): 规格 — 规则编辑器节点命名与拖放添加
确认仅前端消费已有 label,节点库改为拖到画布落点,不做备注。
2026-07-19 10:57:28 +08:00
ryan 24862dcbed chore(release): v3.4.0
### 🛠 修复
- 修复了访问日志概览按域名筛选无效的问题,现已兼容 hosts 与 hosts[] 参数。
- 修复了 Agent 观测缓冲合并访问日志时忽略 cache_status 导致缓存状态被去重丢弃的问题。
- 修复了访问日志概览在 ClickHouse 查询失败时静默吞错的问题,现会输出错误日志便于排查。
- 修复了数据看板业务流量趋势与已提供数据口径不一致的问题,业务量统一由访问日志聚合。
- 修复了节点地图在缺少精确经纬度时,把香港/新加坡/台湾等地区错误标到占位坐标的问题。

### ⚡️ 优化与改进
- 访问日志重构为概览、IP 明细与日志明细:支持时间窗聚合 IP 请求数/2xx 比例/入出站流量与详情分析,明细展示完整请求字段。
- 边缘访问日志支持 User-Agent 与 cache_status(命中/回源/未缓存),概览增加设备/浏览器/系统与状态码分布。
- 新建站点开启缓存时推荐仅缓存标准静态资源(不含 HTML);存量空策略与按 URL 行为保留为所有可缓存 GET。
- 边缘观测以访问日志为业务唯一真相;Agent 仅上报明细与主机读数,升级需重建或替换 Agent。
- Pages 支持更多压缩格式上传、URL 导入部署包,以及可配置的包大小与历史保留策略。

### 💄 其他/体验
- 优化了访问日志排行榜与饼图布局,页签状态支持 URL 参数记忆。
- 启用 cache_status 与边缘缓存策略变更后,需执行相关迁移并重新发布节点配置。
2026-07-19 10:45:40 +08:00
ryan 920a530aa7 feat(access-logs): 新增 IP 明细 Tab 并完善日志详情字段
按时间窗聚合 IP 请求数/2xx 比例/入出站流量,支持排序与详情分析;
日志明细详情仅展示请求业务字段,IP 情报迁至独立详情弹窗。
2026-07-19 00:42:59 +08:00
ryan bfd9de69af fix(access-logs): 修复概览域名筛选参数 hosts[] 被 Gin 忽略
Axios 默认序列化为 hosts[]=,Gin QueryArray("hosts") 读不到导致筛选失效;
后端兼容 hosts/hosts[],前端改为重复键序列化。
2026-07-19 00:28:37 +08:00
ryan 204f6d9a8b fix(cache): 存量空/url 策略规范为 all,避免静默收窄
评审修复:enabled 且 policy 为空或 url 时,写入/展示/快照/渲染均映射为 all,
保证旧站点宽缓存范围不变;新建 UI 仍显式提交 static 作为推荐默认。
2026-07-19 00:02:52 +08:00
ryan 04f029c705 feat(cache): 开启缓存默认仅缓存标准静态资源
路由缓存策略新增 static(内置扩展名,不含 HTML)与 all;
存量 url 规范为 all。OpenResty 渲染与代理路由 UI 同步。
2026-07-18 23:32:49 +08:00
ryan 7401f5d0b4 docs(design): 边缘缓存默认可缓存范围对标 Cloudflare
约定开启缓存默认 static 扩展名策略,存量 url 映射为 all,
并明确第一期不做 Edge TTL/Purge/Cache Rules。
2026-07-18 23:24:23 +08:00
ryan 0bb6830047 feat(access-logs): 概览支持 Zone/域名多选筛选
概览可按 Zone→Domain 层级多选域名并折叠展开;明细列表 IP 旁展示地区。
后端 overview 支持 hosts 多域名精确匹配。
2026-07-18 23:07:19 +08:00
ryan 6f221b042e fix(agent): 观测缓冲去重纳入 cache_status 并保留原始 -
避免同一请求不同缓存状态被合并丢弃;OpenResty 的 - 原样入库便于详情区分。
2026-07-18 22:56:10 +08:00
ryan fb5a4e5b59 feat(access-logs): 上报并展示边缘缓存状态 cache_status
OpenResty 日志输出 $upstream_cache_status;Agent/协议/ClickHouse 贯通入库。
明细列表与详情按 HIT/MISS 等推导命中、回源、未缓存三态标签。
2026-07-18 22:50:46 +08:00
ryan ee9d651c8a docs(obs): 约定访问日志 cache_status 与明细三态展示
仅上报 $upstream_cache_status,不上报回源地址;UI 由原始值推导
命中缓存 / 回源 / 未使用缓存。
2026-07-18 22:46:46 +08:00
ryan 9aec984bee feat(access-logs): 明细详情支持 IP 分析与 URL Tab 记忆
- 新增单 IP 分析接口,趋势时间范围支持至 30 天
- 明细详情弹窗展示趋势、汇总与 Top 分布,可快捷管理 IP 组
- 访问日志页签改为 URL 参数记忆,筛选后保持当前 Tab
2026-07-18 22:40:21 +08:00
ryan bf71bc540b feat(access-logs): 优化概览饼图布局并在查询出错时增加日志记录
- 将设备类型与状态码饼图的断点由 xl 降为 lg,在大屏/笔记本视口下保持双列展示
- 修复 valueCountDistribution 在 ClickHouse 查询出错时静默吞掉错误的缺陷,引入 logger.ErrorF 捕获
- 补充 unreleased 变更日志
2026-07-18 22:02:09 +08:00
ryan e49078ac3b feat(access-logs): 接入 User-Agent 与设备/浏览器/状态码分布
- Agent 访问日志上报 user_agent,OpenResty log_format 输出 http_user_agent
- of_node_access_logs 新增 user_agent 列并写入 ClickHouse
- 访问日志概览新增:设备类型饼图、状态码饼图、浏览器/OS/User-Agent 排行
- 日志明细列表增加 User-Agent 列
- 扩展 UA 解析工具(browser/os/device)并支持 CLI 识别
2026-07-18 21:18:59 +08:00
ryan 177578ef4e feat(access-logs): 重构访问日志为概览与明细双 Tab
新增访问日志概览 API 与前端页面:汇总请求量/访问量/带宽趋势与 Top 排行,
明细列表保留检索;排行榜改为紧凑列表样式。
2026-07-18 20:58:32 +08:00
ryan 4c0c389122 chore(release): v3.3.1
### 🛠 修复
- 修复了看板业务流量趋势与 Zone 已提供数据口径不一致的问题,业务量统一由访问日志聚合,避免边缘预聚合窗口差分导致近 24 小时趋势严重偏低。
- 修复了节点地图在缺少精确经纬度时,将香港、新加坡、台湾等地区错误回退到南美等占位坐标的问题,补全质心数据并改进复合地名匹配。

### ⚡️ 优化与改进
- 重构边缘可观测模型:访问日志为业务唯一真相,Agent 仅上报明细、主机指标与 OpenResty 健康连接;新增 edge_health 与 access_log_hourly,删除请求预聚合与 OpenResty 吞吐路径。
- 协议去掉旧兼容层,Agent 升级需销毁重建或二进制替换;旧本地观测缓冲会自动删除并在运行中重建。
- 调整 Agent 默认心跳为 3 秒、离线判定为 60 秒、离线补传窗口为 60 分钟,使节点状态与观测数据刷新更及时。
- 看板 UV 改为窗口内真正去重,Zone 分桶 UV 明确不可跨桶相加;网络趋势仅保留已提供/接收数据,磁盘读写改为按秒速率展示。
- Pages 支持多压缩格式上传、URL 导入部署包,以及可配置的包大小上限与历史保留数量;边缘按项目只保留最新激活部署,切换版本无需重发主配置。
- 新建代理规则时可选择直连、隧道或 Pages 源站类型,与详情页一致。
- 优化 Pages 部署包校验性能,不再为包内每个文件计算哈希,改由整包校验和保障完整性。

### 💄 其他/体验
- 更新可观测设计文档与运维说明,明确健康状态权威源与升级策略。
- 同步 Swagger 与变更日志,便于对照 API 与发布说明。
2026-07-18 16:37:20 +08:00
ryan a0ccafc6ee fix(geo): 修复香港等节点地图质心缺失落到南美占位
补全 Hong Kong/Singapore/Taiwan 质心数据,并改进复合地名与 ISO 匹配,
避免 geo 无精确经纬度时错误回退到巴西等地的占位坐标。
2026-07-18 13:52:20 +08:00
ryan 26057514a1 feat(obs): 去掉宿主机网卡趋势,磁盘读写改按速率展示
Agent 不再采集网卡累计字节,看板与节点网络图仅保留访问日志已提供/接收。
磁盘 IO 按小时换算为 B/s 曲线,摘要为近 24 小时平均速率,并同步 Swagger。
2026-07-18 13:17:13 +08:00
ryan 55f8c9a527 fix(obs): 对齐无兼容层与健康/UV 权威语义
Agent 本地旧观测缓冲直接删除并运行重建;设计文档去掉兼容期表述。
健康当前态以 PG status/message 为准,CH 仅存 status 与连接时序;
Zone 曲线标明分桶 UV,顶部为整窗独立访客。
2026-07-18 12:09:27 +08:00
ryan 802d516f5b docs: 观测重构 changelog 与 Swagger 同步
补充 unreleased 变更说明,并重新生成 Swagger 以匹配去兼容层后的 API。
2026-07-18 11:53:11 +08:00
ryan 71ce028f91 feat(frontend): 观测网络字节字段与 UV 文案对齐
网络图仅使用 bytes_provided/received;看板与节点 UV 改为 24h/查询窗口
独立访客;清理 traffic_reports 与 openresty 吞吐兼容字段;运维清理目标
改为 node_edge_health。
2026-07-18 11:53:11 +08:00
ryan f0e234df1f feat(obs): 访问日志 SSOT 与 edge_health,去掉协议兼容层
Agent 仅上报 host_metrics/edge_health/access_logs;业务流量与 UV 由
Server 侧访问日志聚合。新增 of_node_edge_health 与 of_access_log_hourly,
删除 request_reports/openresty 吞吐路径;API 不再暴露 traffic_reports
与 openresty_rx|tx。心跳/离线默认阈值与回填迁移一并入库。
2026-07-18 11:53:11 +08:00
ryan 9a0974cce8 docs(obs): 边缘可观测 SSOT 设计与实施计划
补充 observability 设计/数据模型/传输模型,更新架构与 Agent 文档侧栏,
并写入 M5 迁移与小时汇总回填运维说明。
2026-07-18 11:53:11 +08:00
ryan 3b9f4daa4e perf(pages): skip per-file hashes during package inspect
Inspect deployment archives via file handles and declared sizes instead of loading the whole package and hashing every member, while keeping whole-package checksums for Agent integrity checks.
2026-07-17 18:37:34 +08:00
ryan 285f127d48 feat(proxy-routes): align create form with upstream type selection
Let new proxy rules choose direct, tunnel, or Pages origin the same way as the detail reverse-proxy section, instead of only accepting a single upstream URL.
2026-07-17 18:20:35 +08:00
ryan 79820b33eb feat(pages): import deployment packages from URL
Add upload-from-url so admins can paste an HTTP(S) link and let the control
plane download the archive with browser-like headers. Private/LAN hosts and
insecure TLS certificates are allowed for internal artifact stores; package
size and format checks reuse the existing local-upload pipeline.
2026-07-17 17:55:12 +08:00
ryan ce736e2de4 feat(pages): pull latest by project and keep a single edge release
Agents now treat pages_project_id as the stable anchor and fetch the
control-plane active package via project latest APIs, so activating a
deployment updates edges without republishing main config. Local roots use
projects/{id}/current, only the newest release is retained after a successful
switch, hash/package races retry, and per-project failures no longer block
siblings.
2026-07-17 17:43:40 +08:00
ryan a0fcf9f627 feat(pages): configurable limits, multi-format packages, and dual versioning
Make Pages package size and history retention system-configurable, support
zip/tar.gz/tar.xz/tar.bz2/tar/7z uploads, prune history with clear keep-N
semantics, and rebind agent config to the live active Pages deployment so
main-config rollback never depends on pruned packages.
2026-07-17 17:16:48 +08:00
ryan 368df3f76b chore(release): v3.3.0
### 🛠 修复
- 修复了 WAF 站点为空绑定规则时请求异常的问题,规范空站点绑定为数组并兼容历史 JSON 空值,避免请求时 Lua 处理失败。
- 修复了 WAF PoW 验证在部分场景下的异常。
- 修复了 Pages 部署文件列表请求与后端路由不一致的问题,并补充回归测试。
- 默认关闭 Redis maintenance notification 自动协商,并应用到平台与 Asynq 客户端,减少在不支持的 Redis 服务上的兼容性警告。
- 修复了 WAF 规则编辑器画布状态不稳定的问题:改用 React Flow 受控节点状态,支持删除节点与连线,节点属性仅在选中后展示。

### ⚡️ 优化与改进
- 新增 WAF 可组合规则可视化编排能力,提供基于 React Flow 的规则编辑器、有序图形 API 与运行时 DAG 执行;规则仅在 OpenResty reload 时发布,并通过校验和驱动的 IP 组快照在受界共享内存中协调。
- 完善 WAF 地域匹配数据,使用完整国家与一级行政区数据,国家选项同时显示中文名称与 ISO 代码,行政区支持按名称或代码搜索。
- Agent 现内置国家与城市地址库,首次启动无需下载即可使用地区匹配,并会在后续自动更新数据。
- 优化了 SQL 日志输出:常规查询日志下调至 debug 级别,慢查询与错误日志不再输出 SQL 文本,避免敏感参数在生产日志中暴露。

### 💄 其他/体验
- 优化了 WAF 规则编辑器初始视图,缩小编排区高度与首次适配缩放比例,默认显示更多画布上下文。
- 服务启动监听就绪后再打印服务横幅,避免在监听失败时显示误导性信息。
- 改进了日志打印输出。
2026-07-14 09:07:06 +08:00
ryan 15e614b304 fix(waf): pow 2026-07-13 17:07:49 +08:00
ryan 46941f65d5 fix(waf): handle empty rule bindings
Encode empty site bindings as arrays and normalize legacy JSON null values in the OpenResty runtime to prevent request-time Lua failures.
2026-07-13 16:49:55 +08:00
ryan 0c2961ae6d fix(waf): complete geography match options
Use full country and ISO subdivision data, show localized country names with codes, and add searchable region selection.
2026-07-13 16:34:18 +08:00
ryan a61d55bb1b chore: improve log print 2026-07-13 16:12:30 +08:00
ryan 6b6c786cfe feat(startup): print service banner after listener ready 2026-07-13 15:49:19 +08:00
ryan 26be762c3a prettier 2026-07-13 15:44:03 +08:00
ryan 0548a8a5d4 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:43:15 +08:00
ryan 60bc03f519 fix(db): log SQL statements at debug level
Move routine GORM SQL output to debug and omit SQL text from slow-query and query-error logs to prevent sensitive parameters from being emitted at production log levels.
2026-07-13 15:39:48 +08:00
ryan 9dc3983e0f fix(frontend): WAF 规则编辑器缩小编排区高度和首次适配缩放比例,默认显示更多画布上下文 2026-07-13 15:39:48 +08:00
ryan 1eff7878a1 prettier 2026-07-13 15:10:28 +08:00
ryan 08e8eea932 fix(frontend): prettier config 2026-07-13 15:04:28 +08:00
ryan 85d5c8568c fix(frontend): stabilize WAF rule canvas
Use React Flow controlled node state, support deleting nodes and edges, and show node properties only after selection.
2026-07-13 14:59:20 +08:00
ryan 74ddf97b36 feat(agent): embed GeoLite2 City database
Initialize missing Country and City databases from embedded assets and use FyraLabs releases for periodic updates.
2026-07-13 14:38:36 +08:00
ryan a1a997bcda feat(waf): complete composable rule orchestration
Add the React Flow rule editor, ordered graph APIs and runtime DAG execution.\n\nPublish rules only on OpenResty reload and reconcile checksum-driven IP group snapshots in bounded shared memory.
2026-07-13 14:17:15 +08:00
ryan d36409fbf9 refactor(frontend): order waf rule bindings 2026-07-13 12:12:49 +08:00
ryan 4000366856 feat(frontend): create orchestrated waf rules 2026-07-13 12:00:24 +08:00
ryan af20e2e838 feat(waf): add composable rule graph core 2026-07-13 11:57:36 +08:00
ryan 2fff30e188 docs(waf): split orchestration migrations 2026-07-13 11:35:16 +08:00
ryan 74c2f57453 docs(waf): plan composable rule implementation 2026-07-13 11:23:34 +08:00
ryan 30e09f5985 docs(waf): design composable rule graph 2026-07-13 11:14:10 +08:00
ryan 43e293e062 fix: frontend optimization 2026-07-13 10:31:09 +08:00
ryan 439ac41da8 fix(frontend): correct Pages deployment files route
Align the Pages deployment file-list request with the backend route and add a regression test.
2026-07-13 09:29:22 +08:00
ryan d97581fb1e chore(release): v3.2.0
### 🛠 修复
- 修复了嵌入式静态前端访问 Zone 详情页时回退到默认首页 HTML,导致界面显示错误并触发 React hydration 异常的问题。
- 修复了 Zone 概览中“已提供的数据总计”长期为 0 的问题,确保 Agent 上报的访问日志流量字节数可以正确入库并用于统计。
- 修复了 Zone 域名导入、删除和更新相关接口与前端交互中的异常,提升域名管理流程的稳定性。
- 修复了 Docker 部署 ClickHouse 时监听配置被覆盖的问题,避免宿主机无法访问 ClickHouse 服务。

### ⚡️ 优化与改进
- 新增 Zone 与正规化 Zone 域名管理能力,将网站、域名、证书与反代路由关系收敛到更稳定的资源模型。
- 管理端网站入口调整为 Zone 列表与 Zone 详情页,支持在概览、域名、路由、证书和设置之间统一管理网站资源。
- 配置快照、OpenResty 渲染、Tunnel 与 Uptime Kuma 监控改为从 Zone 域名绑定读取域名和证书,减少反代路由中的冗余字段。
- 新增 Cloudflare 风格的 Zone 流量概览图,支持按 24 小时、7 天和 30 天查看唯一访问者、请求数与已提供数据趋势。
- 支持在系统设置中管理控制台菜单展示范围,便于按使用场景精简侧边栏入口。

### 💄 其他/体验
- 调整数据库自动清理设置文案,明确自动清理遵循 ClickHouse 表 TTL,并说明访问日志与观测数据的保留下限。
- 优化 Zone 列表、Zone 详情页和系统设置页面布局,使域名、路由和快捷创建流程更清晰。
- 补充 Zone 域名迁移与发布验证文档,便于升级前后核对配置快照与回滚策略。
2026-07-12 19:24:37 +08:00
ryan 83f126795d fix: 调整数据库自动清理设置文案 2026-07-12 19:07:56 +08:00
ryan 69467914fc fix(server): 修复 Agent 上报访问日志的 bytes_sent 在 Server 入库链路丢失,导致 Zone 概览“已提供的数据总计”长期为 0 的问题。 2026-07-12 19:02:52 +08:00
ryan c624512da6 fix(frontend): serve zone detail static export fallback 2026-07-12 18:51:01 +08:00
ryan 50717d1baf fix(frontend): support static export dynamic routes compliance via client useParams 2026-07-12 18:07:43 +08:00
ryan fc569d1758 fix(frontend): dynamically import zone dashboard with ssr: false to cure hydration mismatch
- Dynamically import `ZonePageClient` with `ssr: false` in `websites/[zoneId]/page.tsx`.
- Remove `generateStaticParams` to prevent dynamic paths from building with inconsistent SSG/ISR outputs.
- Remove redundant `mounted` check from `page-client.tsx` since dashboard is client-only.
- Add `bytes_sent` to `NodeAccessLog` on both Agent and Master Server.
- Create ClickHouse migration `202607120001_add_bytes_sent_to_node_access_logs.sql`.
- Refactor duplicate stats structs by centralizing them into `analyticsmodel` package with type aliases.
- Simplify access log store delegations and remove redundant mapping loops.
- Support full console menu display management in settings other-tab.
- Regenerate Swagger documentation.
- Update changelog index.md.
2026-07-12 17:52:40 +08:00
ryan ec4f1d4d23 feat(settings): support full console menu display management in settings other-tab
- Rebuild MENU_GROUPS in `other-tab.tsx` to include all 13 business console items with safety read-only constraints on Dashboard.
- Support reactive filtering in `OpenFlareSidebarMenu` using `menu_display_config` to hide items and empty collapsible groups.
- Fix React Hydration Error #418 when directly loading dynamic websites on SSR by wrapping client component with mounted state hook.
- Add `bytes_sent` to `NodeAccessLog` on both Agent and Master Server.
- Create ClickHouse migration `202607120001_add_bytes_sent_to_node_access_logs.sql`.
- Refactor duplicate stats structs by centralizing them into `analyticsmodel` package with type aliases.
- Simplify access log store delegations and remove redundant mapping loops.
- Regenerate Swagger documentation.
- Update changelog index.md.
2026-07-12 17:46:49 +08:00
ryan 9268acb84c feat(api): support traffic bytes tracking, refactor analytics models and fix website hydration
- Add `bytes_sent` to `NodeAccessLog` on both Agent and Master Server.
- Create ClickHouse migration `202607120001_add_bytes_sent_to_node_access_logs.sql`.
- Refactor duplicate stats structs by centralizing them into `analyticsmodel` package with type aliases.
- Simplify access log store delegations and remove redundant mapping loops.
- Fix React Hydration Error #418 when directly loading dynamic websites on SSR by wrapping client component with mounted state hook.
- Regenerate Swagger documentation.
- Update changelog index.md.
2026-07-12 17:42:19 +08:00
ryan 41cd23a64d feat(api): support traffic bytes tracking in edge access logs and refactor analytics models
- Add `bytes_sent` to `NodeAccessLog` on both Agent and Master Server.
- Create ClickHouse migration `202607120001_add_bytes_sent_to_node_access_logs.sql`.
- Refactor duplicate stats structs by centralizing them into `analyticsmodel` package with type aliases.
- Simplify access log store delegations and remove redundant mapping loops.
- Regenerate Swagger documentation.
- Update changelog index.md.
2026-07-12 17:27:57 +08:00
ryan f02fc9676a fix: resolve all build-test and code-check failures
- Fix Go backend mnd (magic number) and revive lint issues in zone stats.
- Remove unused React/Lucide imports and variables in zone overview.
- Add generateStaticParams and Suspense wrapper for websites/[zoneId] page to support Next.js static HTML export.
- Remove next/font/google dependency to allow fully offline frontend compilation.
- Fix hardcoded time dependency in ssl_renew_test.go.
- Fix async_tasks_test.go to respect minimum 90-day retention clamping in database auto-cleanup.
- Add Zone and ZoneDomain models to test database AutoMigrate schemas.
- Update integration tests to use the new zone_domain_ids route binding scheme.
2026-07-12 17:05:52 +08:00
ryan 31b4886a14 feat(zone): add Cloudflare-style traffic overview charts
Expose Zone stats API with multi-host access-log aggregates and time
series, and render unique visitors, requests and data served on the
Zone overview with 24h/7d/30d range controls.
2026-07-12 16:38:35 +08:00
ryan efcf61e32d feat(web): polish zone list and settings layout
Show Zone list as a table, align detail back navigation with proxy
route detail, and move edit/delete actions into the settings tab.
2026-07-12 16:22:16 +08:00
ryan d615d85a26 refactor: remove unused remarks and add quick domain create
Drop remark fields from Zone, Zone domains, proxy routes, WAF rule
groups and IP groups across models, APIs, UI and DB columns (keep
certificate/origin remarks). Add quick-create domain input for short
labels, @ apex and full FQDNs when binding domains.
2026-07-12 16:16:56 +08:00
ryan 8afd103751 fix(zone): register domain delete/update APIs and drop edit UI
补全 Zone 与 Zone 域名的 update/delete 路由与业务逻辑,修复删除域名
404;前端域名列表移除编辑入口,仅保留添加与删除。
2026-07-12 15:56:45 +08:00
ryan 7ef84cce52 feat(web): improve zone domain list and detail navigation
合并 Zone 域名与路由展示,上游地址多行显示并支持路由详情跳转;
域名列表对齐用户管理页样式;Zone 页支持 ?tab= 定位;反代详情返回
使用浏览器历史上一级。
2026-07-12 15:50:43 +08:00
ryan 96b8ddc077 fix(migrator): only import zone domains during upgrade window
Zone 历史导入仅在 goose 版本位于 [202607120002, 202607130001) 时执行,
phase-2 删列完成后日常启动不再进入导入逻辑。
2026-07-12 15:38:28 +08:00
ryan 03b81e5f74 refactor(migrator): use goose SQL only and auto-import zones on upgrade
移除 Go goose 迁移(bridge/legacy data/zone import),改为 goose SQL 占位
与结构迁移脚本;启动时 Migrate 在删旧列前自动导入历史域名,去掉
migrate-zones 手动命令及文档中的人工导入步骤。
2026-07-12 15:36:00 +08:00
ryan 5b52acdd6c refactor(zone): remove legacy route domain storage
第二阶段清理:删除 of_managed_domains 与 of_proxy_routes 冗余域名/证书列,
移除 ManagedDomain 模型与 API、路由侧 legacy 字段维护,以及前端 WebsiteService。
ImportLegacy 在旧列/旧表缺失时跳过对应源,保持幂等。
2026-07-12 15:31:01 +08:00
ryan fb3dd5afe6 docs(zone): add migration and release verification guide
补充 Zone 域名迁移操作指南(备份、migrate-zones、预览等价性、发布与回滚),
更新设计文档阶段说明、指南导航与 Unreleased 变更日志。
2026-07-12 15:31:01 +08:00
ryan 1160d5846a refactor(web): select route domains from zones
反代路由创建/域名配置改为绑定 zone_domain_ids,移除手写域名列表与
旧证书字段;列表与 WAF/UptimeKuma 消费端同步读取 zone_domains。
2026-07-12 15:23:48 +08:00
ryan 350b433cc1 feat(web): add zone-based website management
以稳定 Zone ID 替换旧托管域名详情页:列表展示根域与域名计数,详情提供
概览/域名/路由/证书/设置 Tabs,并补齐 ZoneService 与 vitest 行为测试。
Zone 列表 API 返回 domain_count;同步 Swagger 与重构计划进度。
2026-07-12 15:23:43 +08:00
ryan d4d9bad74d refactor(config): render routes from zone domains 2026-07-12 15:03:24 +08:00
ryan d0536fcdd5 refactor(proxy): bind routes through zone domains 2026-07-12 14:52:19 +08:00
ryan e51f1e583d chore: remove execution report artifact 2026-07-12 14:41:22 +08:00
ryan b835144cd0 merge: zone domain foundation 2026-07-12 14:41:04 +08:00
ryan 53c868e99b feat(zone): add zone management api and legacy importer 2026-07-12 14:35:53 +08:00
ryan 50678756d4 feat(zone): add normalized zone domain schema 2026-07-12 14:23:25 +08:00
ryan 3eb670c674 chore: ignore local worktrees 2026-07-12 14:17:07 +08:00
ryan d10132fb02 docs(plan): add zone domain refactor plan 2026-07-12 14:14:25 +08:00
ryan 61cf581621 docs(zone): clarify certificate ownership 2026-07-12 14:08:43 +08:00
ryan e2ac531abb docs(zone): define zone domain management model 2026-07-12 14:05:04 +08:00
ryan c8b1289043 fix(docker): preserve clickhouse listener config 2026-07-12 12:28:34 +08:00
ryan 13c5073bf8 chore(release): v3.1.2
### 🛠 修复
- 修复了节点与仪表盘 24 小时容量、网络、磁盘 IO 趋势在限流查询下几乎为空的问题,改为小时级聚合与计数器增量统计,历史时段可正常展示。
- 修复了静置场景下 ClickHouse CPU 偏高的问题,可观测与访问日志写入改为批量凑批并限制小 part 产生。
- 修复了数据清理接口将「物化表 TTL」误报为已删除行数的问题,短于表 TTL 的保留天数会被明确拒绝。
- 修复了可观测去重在入队/刷盘失败后仍占用键、导致故障窗口数据更易丢失的问题,并在 flush 失败时短重试与释放键。
- 修复了 Dashboard「每节点最新指标」被全局 LIMIT 截断导致安静节点缺失的问题,改为按节点取最新快照。
- 修复了 Docker 部署 ClickHouse 25.x 因后台池与 mutation 空闲阈值不兼容而无法启动的问题。
- 修复了小时预聚合仅有迁移后少量数据时 24 小时趋势再次残缺的问题:读路径按小时 merge(窗口完整走 rollup,缺口用 raw 补齐),并增加历史 backfill 迁移。

### ⚡️ 优化与改进
- 为容量与 OpenResty 指标增加小时预聚合表,窗口完整时优先走 rollup 降低查询压力。
- 审计日志写入增加最长等待刷盘,管理端可观测状态接口暴露 batch writer 队列深度、丢弃与 flush 错误指标。
- 小规格场景下调 ClickHouse 客户端连接池默认值,并调整 async_insert 合并超时与流量小时表 TTL。
- 流量独立访客在小时汇总中改为窗口峰值估计,并修正界面文案,避免被误解为全局真实 UV。

### 💄 其他/体验
- 同步环境变量与配置模板中的 ClickHouse 说明;部署文档改为将 performance.xml 下载到 ./config/clickhouse 后挂载,且不挂载 listen 配置。
2026-07-10 11:42:49 +08:00
ryan da1dd92404 fix(observability): merge rollup+raw hourly trends and backfill history
Prefer capacity/openresty rollups only when they cover the 24h window;
otherwise merge per hour so raw fills pre-MV gaps and rollup wins on
overlap. Add a one-time ANTI JOIN backfill migration for the last 30 days.
2026-07-10 11:27:51 +08:00
ryan 4b11279662 fix(observability): fall back to raw hourly when rollup is incomplete
Materialized capacity/openresty hourly tables only hold data after the MV
exists. Preferring any non-empty rollup hid full raw history and left 24h
charts with only recent hours. Use rollup only when its earliest bucket
covers the query window start.
2026-07-10 11:27:51 +08:00
ryan bbadcca294 ### 🛠 修复
- 修复了节点与仪表盘 24 小时容量、网络、磁盘 IO 趋势在限流查询下几乎为空的问题,改为小时级聚合与计数器增量统计,历史时段可正常展示。
- 修复了静置场景下 ClickHouse CPU 偏高的问题,可观测与访问日志写入改为批量凑批并限制小 part 产生。
- 修复了数据清理接口将「物化表 TTL」误报为已删除行数的问题,短于表 TTL 的保留天数会被明确拒绝。
- 修复了可观测去重在入队/刷盘失败后仍占用键、导致故障窗口数据更易丢失的问题,并在 flush 失败时短重试与释放键。
- 修复了 Dashboard「每节点最新指标」被全局 LIMIT 截断导致安静节点缺失的问题,改为按节点取最新快照。
- 修复了 Docker 部署 ClickHouse 25.x 因后台池与 mutation 空闲阈值不兼容而无法启动的问题。

### ⚡️ 优化与改进
- 为容量与 OpenResty 指标增加小时预聚合表,读路径优先 rollup,降低 24 小时趋势查询压力。
- 审计日志写入增加最长等待刷盘,管理端可观测状态接口暴露 batch writer 队列深度、丢弃与 flush 错误指标。
- 小规格场景下调 ClickHouse 客户端连接池默认值,并调整 async_insert 合并超时与流量小时表 TTL。
- 流量独立访客在小时汇总中改为窗口峰值估计,并修正界面文案,避免被误解为全局真实 UV。

### 💄 其他/体验
- 同步环境变量与配置模板中的 ClickHouse 说明;部署文档改为将 performance.xml 下载到 ./config/clickhouse 后挂载,且不挂载 listen 配置。
2026-07-10 11:27:51 +08:00
ryan 44ce6497a1 docs(docker): use ./config/clickhouse for CH performance mount
Move performance.xml to config/clickhouse and mount the directory to
/etc/clickhouse-server/config.d; update docs and compose paths.
2026-07-10 11:10:32 +08:00
ryan 4b83f91b31 docs(docker): use ./config/clickhouse for CH performance mount
Move performance.xml to config/clickhouse and mount the directory to
/etc/clickhouse-server/config.d; update docs and compose paths.
2026-07-10 10:59:21 +08:00
ryan 9d2fac5d4c docs(config): sync .env.example and config.example.yaml for CH defaults
Align ClickHouse pool/password placeholders and docker-compose env docs with
runtime defaults and published ports used in local Docker testing.
2026-07-10 10:48:30 +08:00
ryan b4b93ff4ed fix(docker): make ClickHouse startable on 25.x and reachable from host
Lower merge-tree free-entry thresholds for small background pools, bind
listen_host to 0.0.0.0 for published ports, and allow CLICKHOUSE_ENABLED=true
in tests for live_ch smoke coverage.
2026-07-10 10:44:49 +08:00
ryan 160e63558f fix(clickhouse): harden R/W path P0–P3 (cleanup, durability, rollups)
Honest TTL cleanup semantics; enqueue-safe dedup with flush retry and writer
metrics; model insert hooks; latest-per-node and hourly metric/openresty
rollups; small-host pool/async defaults, traffic hourly TTL, and UV labeling.
2026-07-10 10:34:04 +08:00
ryan 9b3555c569 fix(clickhouse): cut idle CPU from tiny parts and oversized merge pools
Observability writers flushed every few seconds with MinBatchSize unset,
creating constant small parts and merge load. Enable MinBatchSize with
MaxFlushWait, batch access logs more aggressively, and shrink ClickHouse
background pools for 3c hosts.
2026-07-10 10:08:32 +08:00
ryan b928928958 fix(observability): restore 24h capacity/network/disk trends via CH hourly agg
Node and dashboard 24h capacity, network, and disk IO charts only used the
latest limited raw snapshots (120/500 rows), so historical hour buckets stayed
empty. Prefer ClickHouse hourly aggregates with counter deltas, and fall back
to raw snapshots when aggregation is unavailable.
2026-07-10 09:48:45 +08:00
ryan b312460ddf chore(release): v3.1.1
### ⚡️ 优化与改进
- 将 cap_login_enabled 默认值由 true 变更为 false,默认关闭登录界面 PoW 人机验证。
2026-07-06 12:29:53 +08:00
ryan 50f7257d93 docs: readme 2026-07-05 23:53:26 +08:00
ryan 336185f01c release: v3.1.0
### 🛠 修复
- 修复 ClickHouse TTL 迁移中 DateTime64 时间列无法直接设置 TTL 导致 goose 启动失败的问题,改为通过 toDateTime() 转换后再应用 TTL。
- 修复 ClickHouse 迁移尝试缩短 ORDER BY 排序键时与隐式主键前缀冲突导致迁移失败的问题,移除不支持的 MODIFY ORDER BY 操作。
- 修复系统设置页面 URL tab 参数未包含 openflare-ops 选项卡导致无法正确定位的问题,并在无参数时默认选中 OpenFlare 选项卡。
- 修复系统自更新检测上游 GitHub Release 时,因资产包名称前缀 openflare-server 与仓库名不完全一致导致匹配失败并报错「未找到兼容的 Release」的问题。
- 修复全局搜索数据源覆盖不全的问题,补全所有核心业务控制台页面及管理员专有页面的检索支持。

### ⚡️ 优化与改进
- ClickHouse 启用 async_insert 异步写入缓冲,并调高 block_buffer_size 与连接池默认值,降低小 part 生成与连接争用。
- 优化 ClickHouse 写入路径:移除 Agent 心跳中的同步 ALTER DELETE 保留清理,batchwriter 新增 MinBatchSize 抑制过小批次定时 flush,可观测 writer 批次与 flush 间隔调优并补全去重。
- ClickHouse 分析表新增 TTL 自动过期策略,访问日志 180 天、节点访问日志 90 天、其余观测与聚合表 30 天自动清理。
- 访问日志与 WAF IP 组查询改为 ClickHouse 侧聚合与 SQL 分页,默认限制近 7 天查询窗口,浏览器分布查询增加 Top 100 限制。
- Dashboard 与节点可观测 API 消除无 LIMIT 全表扫描,增加短 TTL 内存缓存,前端轮询间隔分别调整为 60s/30s。
- ClickHouse 遗留治理 Phase 2:保留期清理改为 TTL MATERIALIZE TTL,统一 ChConn 读路径,新增 /admin/status/clickhouse 运维指标与 of_node_traffic_hourly 预聚合 MV。
- 审计访问日志写入时仅保留安全相关请求头并以 SHA-256 脱敏,将 headers 载荷上限收紧至 2KB,减小行宽与 merge CPU 开销。
- Docker 部署为 ClickHouse 增加 nofile ulimits 与性能配置挂载,限制 max_concurrent_queries 与后台合并争用。
- 数据库自动清理任务新增 OpenResty、FRPS、FRPC 观测表清理目标。

### 💄 其他/体验
- 隐藏侧边栏文档库中的「规范示例」与「接口文档」,将「使用文档」及其他相关页面链接统一跳转至外部文档站 https://open-flare.pages.dev/。
- 移除系统设置 OpenFlare 标签页下的版本信息卡片及对应升级管理弹窗逻辑。
- 系统设置页面支持通过 URL 持久化当前选中的 Tab 状态。
2026-07-04 09:43:31 +08:00
ryan 44bba0f19a fix(clickhouse): remove unsupported MODIFY ORDER BY from migration
ClickHouse keeps the implicit PRIMARY KEY when shortening ORDER BY,
which fails with "Primary key must be a prefix of the sorting key".
TTL-only changes are safe and unblock goose startup; narrowing ORDER BY
would require table recreation.
2026-07-02 16:45:48 +08:00
ryan f0eca028f9 fix(clickhouse): cast DateTime64 to DateTime in TTL migration 2026-07-02 16:21:30 +08:00
ryan 58624db397 perf(clickhouse): Phase 2 legacy governance — TTL cleanup, unified pool, MV, ops API
- Replace retention ALTER DELETE with MATERIALIZE TTL; use TRUNCATE for delete-all
- Remove GORM ClickHouse pool; migrate user access log reads to ChConn
- Drop query-side trim(remote_addr); enable wait_for_async_insert=1
- Add of_node_traffic_hourly MV and dashboard traffic trend fallback
- Add GET /admin/status/clickhouse operational metrics endpoint
2026-07-02 15:47:38 +08:00
ryan 38946d1af5 fix(clickhouse): resolve lint issues from optimization stack 2026-07-02 15:28:49 +08:00
ryan caf2ffcff4 perf(clickhouse): P1 TTL migrations, ORDER BY tune, remote_addr normalization 2026-07-02 15:25:03 +08:00
ryan 0e86fe3547 perf(clickhouse): P2 docker server tuning and audit log payload reduction 2026-07-02 15:23:17 +08:00
ryan 6525bef15d perf(clickhouse): P0/P1 access log and WAF query aggregation and SQL pagination 2026-07-02 15:23:17 +08:00
ryan 3e910f1961 perf(clickhouse): P0 dashboard/observability query limits, cache, slower polling 2026-07-02 15:23:17 +08:00
ryan 28c14eb054 perf(clickhouse): enable async_insert and tune connection/buffer defaults 2026-07-02 15:23:17 +08:00
ryan ae618905a3 perf(clickhouse): P0 write path — remove heartbeat DELETE, batchwriter MinBatchSize, tune chwriter 2026-07-02 15:23:17 +08:00
ryan 5ad151469c feat(frontend): delete unused version-upgrade-dialog component and use-openflare-server-upgrade hook
- Permanently delete version-upgrade-dialog.tsx and use-openflare-server-upgrade.ts as they are no longer referenced after removing the version info card from openflare-ops settings.
2026-06-30 21:04:52 +08:00
ryan 6467b32d8e fix(frontend): include openflare-ops tab in whitelist and make it default
- Include 'openflare-ops' in the list of validTabs so that specifying ?tab=openflare-ops correctly loads the OpenFlare settings tab.
- Set fallback tab default to 'openflare-ops' when no tab parameter is specified.
- Document changes in changelog.
2026-06-30 20:56:34 +08:00
ryan cf72420815 feat(frontend): persist selected tab on admin settings page 2026-06-30 20:53:33 +08:00
ryan 34225cb88a fix(updater): resolve release asset name matching for openflare-server
- Update expectedAssetNames helper to match lowercase repoName prefix and lowercase repoName with -server suffix (e.g. openflare-server).
- Fixes 'no compatible release found' error when checking GitHub Action releases.
2026-06-30 20:47:32 +08:00
ryan 389f02b6b0 feat(frontend): update navigation links and expand search coverage
- Hide 'Specification Examples' and 'API Docs' from sidebar documents group, pointing 'Use Docs' externally to pages.dev.
- Complete searchData array to cover all console business pages and missing admin-only pages.
- Add changelog records for these adjustments.
2026-06-30 20:36:55 +08:00
ryan 2fcbb945fb docs: update 2026-06-30 16:52:17 +08:00
ryan 23501259b2 docs: update 2026-06-30 16:42:41 +08:00
ryan c561e65cd3 docs: update 2026-06-30 16:38:53 +08:00
ryan 97095e8f12 release: v3.0.2
### 🛠 修复
- 修复 PostgreSQL 自增主键序列在历史数据迁移(INSERT 指定显式 ID)后与实际数据不同步的问题,通过新增全局序列同步脚本一键重置所有相关表的自增计数器。
2026-06-30 16:17:21 +08:00
ryan 23be2f9296 fix(db): 新增 PostgreSQL 数据库自增序列全局同步迁移脚本
为了解决因历史数据以显式 ID 方式迁移导致 PostgreSQL 自增序列计数器不同步,产生主键冲突唯一性约束报错(如 WAF 规则组和 IP 组保存失败)的问题,在 PostgreSQL 迁移中加入了对所有相关表 pg_get_serial_sequence 重置的代码。同时,在 SQLite 中补齐了对应的同名迁移文件。
2026-06-30 16:13:37 +08:00
ryan 113ea25aa4 release: v3.0.1
### ⚡️ 优化与改进
- 新增管理后台用户个人信息编辑与重置密码功能。
- 新增用户列表邮箱列展示以及基于邮箱的搜索过滤。
- 后端新增 `reset-passwd` 命令行工具,支持通过命令行直接重置用户密码。

### 🛠 修复
- 修复添加 DNS 账号时,因直接传递类静态方法作为 React Query 的 mutationFn 导致 JavaScript 运行时丢失 `this` 上下文报错 `this.post is not a function` 的问题。
- 修复侧边栏一级菜单项当前页面字体颜色被硬编码为 `#6366F1` 的问题,改用 CSS 主题变量 `text-sidebar-primary`,以保证多主题色彩一致。
- 修复默认主题(Default)遗漏声明 `destructive-foreground` 变量,导致删除确认按钮在某些状态下渲染为黑底黑字而无法阅读的问题。
- 修复 Cobra 命令行初始化注册逻辑,确保所有应用运行模式(All, API, Worker, Scheduler)都正确注册为 Cobra 子命令。
- 优化系统设置页面的色彩定义,移除硬编码的 Indigo 靛蓝色以适配多主题切换。

### 💄 其他/体验
- 在 `AGENTS.md` 规范中新增关于防止服务类静态方法 callback 上下文丢失的开发规范指南。
2026-06-30 11:58:09 +08:00
ryan c230d5a744 fix(frontend): 解决添加DNS账号时this.post is not a function报错
在 DnsAccountCreateDialog 组件中,mutationFn 错误地直接传递了类静态方法 DnsAccountService.create,导致执行时丢失 class constructor 上下文。现将其修改为使用箭头函数包裹,以保证 this 指向正确。
2026-06-30 11:52:24 +08:00
ryan c02b649b46 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:50 +08:00
ryan fb54d6da61 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:31:20 +08:00
ryan 9c7896df50 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:22:11 +08:00
ryan 01ebec6dfb 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:44 +08:00
ryan 2816152536 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:34:20 +08:00
ryan b029714c7a docs: update 2026-06-28 10:11:51 +08:00
ryan e0f452eaae docs: update 2026-06-28 10:09:43 +08:00
ryan d721a8fd74 docs: update 2026-06-28 10:08:31 +08:00
ryan 1e349e5cde docs: update default credentials to admin and 12345678 in documents 2026-06-28 10:03:45 +08:00
ryan f03c88ffad docs: update 2026-06-27 16:29:58 +08:00
ryan 3038304382 docs(design): update product boundaries and configuration references
- Update docs/design/index.md to document system constraints, including mandatory Redis/ClickHouse dependencies and dynamic Relay Web UI settings.
- Update docs/reference/configuration.md to reflect w_system_configs table keys, option groups, and new environment variable mappings.
- Clean up old plan files.
2026-06-27 16:03:16 +08:00
ryan 196bdabc80 docs(docs): update server start and quick-start docs to require PG/SQLite, Redis, and ClickHouse
- Restructure docs/deployment/server.md into Docker (Quick Start, Production Recommended, Advanced with Jaeger) and Local deployment.
- Update docs/guide/quick-start.md default docker-compose to use PostgreSQL, Redis, and ClickHouse as default.
2026-06-27 15:57:55 +08:00
ryan 77931c3c1e docs(docs): add google analytics tracking tag to head config 2026-06-27 15:41:08 +08:00
ryan a97d87acf5 fix(frontend): fix hasConfigDiff function missing WAF and site changes
Add check for waf_config_changed, added_sites, removed_sites, and modified_sites in hasConfigDiff helper to prevent disabling the publish button on WAF/site edits.
2026-06-27 15:40:16 +08:00
ryan 8d8814b416 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:32:27 +08:00
ryan 02ebb81929 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:26:13 +08:00
ryan 60222acf7e refactor(db): use version as primary key for ConfigVersion and reuse model layer
- Transition `of_config_versions` primary key from `id` to `version` string.
- Add database migration files for PostgreSQL and SQLite.
- Introduce GORM hooks to preserve JSON backward compatibility.
- Remove all localized private structures (`configVersionRecord`, `configVersionRow`) across `agent` and `flared` modules.
- Remove local database Row structures (`tlsCertificateRow`, `tunnelNodeRow`, `pagesProjectRow`) in `proxy_route` module.
- Reuse `model` query methods directly to fetch active config, tunnel nodes, and pages projects.
- Cache IP detection results in memory with a 10-minute TTL to prevent frequent HTTP egress queries to realip.cc.
- Integrate multiple fallback IP lookup providers (ifconfig.me, ip.sb, icanhazip.com) to guarantee IP detection reliability.
2026-06-27 14:00:55 +08:00
ryan 7b1fea8194 refactor(db): use version string as primary key for ConfigVersion
-transition `of_config_versions` primary key from `id` to `version` string.
-add database migration files `202606270001_make_version_primary_key.sql` for PostgreSQL and SQLite.
-introduce AfterFind/AfterCreate GORM hooks to preserve JSON backward compatibility.
-refactor API controllers, logics, and front-end typescript definitions to receive `string` parameter.
2026-06-27 13:46:55 +08:00
ryan ac7b776378 refactor(db): use version string as primary key for ConfigVersion
-transition `of_config_versions` primary key from `id` to `version` string.
-add database migration files `202606270001_make_version_primary_key.sql` for PostgreSQL and SQLite.
-introduce AfterFind/AfterCreate GORM hooks to preserve JSON backward compatibility.
-refactor API controllers, logics, and front-end typescript definitions to receive `string` parameter.
2026-06-27 13:41:50 +08:00
ryan 13d6966cb5 refactor(db): use version string as primary key for ConfigVersion
-transition `of_config_versions` primary key from `id` to `version` string.
-add database migration files `202606270001_make_version_primary_key.sql` for PostgreSQL and SQLite.
-introduce AfterFind/AfterCreate GORM hooks to preserve JSON backward compatibility.
-refactor API controllers, logics, and front-end typescript definitions to receive `string` parameter.
2026-06-27 13:23:30 +08:00
ryan b48414e1fe docs: update 2026-06-26 21:25:18 +08:00
ryan 894f8f1ea2 ui 优化 2026-06-26 20:48:24 +08:00
ryan b89dc9ec7e fix(waf): correct whitelist logic to bypass and add config/IP-group edit broadcasts
- Transition WAF whitelist filter from strict block-on-miss to bypass-on-hit logic

- Hook up broadcastIPGroupToAgents to CreateIPGroup and UpdateIPGroup WAF logics

- Hook up BroadcastActiveConfig to PublishConfigVersion and ActivateConfigVersion version logics

- Update WAF Lua tests in manager_test.go
2026-06-26 20:47:30 +08:00
ryan 49eae80c78 修复目录权限问题 2026-06-22 23:21:04 +08:00
ryan 8ed91dbf97 修复证书问题 2026-06-22 22:56:10 +08:00
ryan 92ceecc6ce 迁移配置表 2026-06-22 22:23:49 +08:00
ryan d9b8dc81ee fix(relay): prioritize IPv4 in dual-stack outbound IP detection
修复 Relay 节点在双栈网络环境下可能上报 IPv6 地址,导致 Tunnel frpc 客户端无法连接 frps 的问题。

根因:
- HTTPOutboundIPStrategy 首次尝试使用 tcp4 强制 IPv4 连接
- 失败时回退到双栈 tcp 客户端,此时可能通过 IPv6 连接并返回 IPv6 地址
- Relay 心跳将 IPv6 地址上报给 Server
- Tunnel frpc 尝试连接该 IPv6 地址失败

修复:
- 在回退到双栈客户端后,仍优先返回 IPv4 地址(通过 ip.To4() 转换)
- 仅在完全无 IPv4 路由时才返回 IPv6
- 确保 Relay 心跳上报的 IP 与 frpc 连接兼容

影响范围:
- pkg/geoip.HTTPOutboundIPStrategy.GetOutboundIP
- internal/apps/relay/heartbeat.Service(通过 nodeip.DetectWithContext 调用)

测试:
- 新增 TestHTTPOutboundIPStrategyPrioritizesIPv4 验证 IPv4 优先逻辑
- 所有现有测试保持通过
2026-06-22 15:49:08 +08:00
ryan deb232d840 refactor(edge): unify dynamic IP detection and prioritize IPv4 reporting
- Align agent, relay, and flared to dynamically resolve IP during heartbeat using the nodeip package (when not manually configured).
- Update GeoIP outbound IP strategy to prefer IPv4 HTTP client lookup using tcp4 dialer and fall back to dual-stack tcp.
- Optimize agent profile fingerprinting to exclude dynamic UptimeSeconds and ReportedAtUnix fields, preventing redundant updates.
- Refactor unit tests to prevent outbound network queries during tests.
2026-06-22 15:06:39 +08:00
ryan 346979344f docs(changelog): 补充 WS EOF 修复与 frpc stderr 日志条目 2026-06-22 14:22:52 +08:00
ryan 1e5f35b9a3 fix(openflare): refresh WS read deadline on JSON pong; capture frpc stderr
- read_pump: 收到客户端 JSON {"type":"pong"} 时调用 conn.SetReadDeadline 刷新
  服务端读超时。修复前,服务端仅在 WebSocket 协议层 Pong 帧时刷新 deadline,
  而客户端使用 JSON 应用层 pong 回复,导致服务端 90s 后超时关闭连接,
  客户端收到 EOF 并触发无限重连循环。在 Cloudflare 代理场景下,
  100s 空闲超时进一步加剧了此问题。

- frpc/manager: 捕获 frpc 子进程 stderr 并在进程异常退出时
  将其内容记录到结构化日志 stderr 字段,便于诊断 exit status 1 的
  具体原因(如配置格式错误、Auth Token 失败、relay 服务端不可达等)。
2026-06-22 14:22:20 +08:00
ryan 346024f346 feat(relay): support configurable frps webui port and fix node detail integration
- Implement configurable FRPS WebUI switch and custom port setting (relay_frps_web_ui_port) in system configs.
- Integrate settings into Relay Node detail manage page instead of global settings.
- Dynamically query server version to select matching Docker image tag for Relay installation.
- Clean up legacy code and fix backend linter/test warnings.
2026-06-22 12:48:54 +08:00
ryan ffe98f6307 移除Notice 2026-06-22 12:00:43 +08:00
ryan 6719c02d05 fix sidebar 2026-06-22 11:58:40 +08:00
ryan 0a3cd250d1 fix relay 2026-06-22 11:56:31 +08:00
ryan 53e6efa33b fix agent 2026-06-22 11:49:36 +08:00
ryan 3d15c65afd perf 2026-06-22 11:32:24 +08:00
ryan 16b02fd3f1 修复 Agent 升级版本比对逻辑 2026-06-22 11:26:55 +08:00
ryan e8778a8641 perf 2026-06-22 11:22:26 +08:00
ryan 7b14e15afb refactor(frontend): simplify routing paths for certificates, dns-accounts and ip-groups
- Remove /websites prefix from TLS certificates and DNS accounts routes.
- Remove /waf prefix from IP groups route.
- Correct relative import paths for shared components.
2026-06-22 10:56:40 +08:00
ryan 3a2878d070 feat(api): integrate TLS certificate renewal into async task framework
Replace native goroutines in RenewCertificate logic with Asynq task dispatching to support queue execution, retry capability, and detailed task execution logs.
2026-06-22 10:54:15 +08:00
ryan df3bcd3d19 perf 2026-06-21 14:51:08 +08:00
ryan 895dec208f fix(agent): write nginx pid and temp dirs under data_dir for non-root runtime
OpenResty running as openflare can no longer write pid or client/proxy temp
paths under the OpenResty install prefix. Templates and apply-time rendering
now use __OPENFLARE_PID_PATH__ and __OPENFLARE_NGINX_CACHE_DIR__ under
data_dir/var/run and data_dir/var/cache/nginx, with legacy pid path patched
at apply. Consolidate runtimeuser path helpers into the main package file so
IDEs resolve references across build tags.
2026-06-21 14:40:10 +08:00
ryan 9d56f02e64 chore(agent): move docker entrypoint script to scripts/
Relocate agent-entrypoint.sh from docker/ to scripts/ so operational
shell scripts live in one directory and update Dockerfile.agent copy path.
2026-06-21 14:26:25 +08:00
ryan 40291136b7 fix(openresty): disable server version disclosure in main config template
Add server_tokens off to the default OpenResty main config template, seeded
option template, and agent safe fallback config so responses no longer
expose nginx/OpenResty version numbers in Server headers or error pages.
2026-06-21 14:25:32 +08:00
ryan d3777eac2d fix(agent): unify agent and openresty runtime user as openflare
Introduce the shared openflare service account for the agent process and
OpenResty workers, normalize data_dir ownership on startup, and ensure
managed paths are chowned with 0755/0644 during sync and apply. Docker
entrypoint fixes volume ownership before dropping privileges; local systemd
install runs the service as openflare with CAP_NET_BIND_SERVICE.
2026-06-21 14:25:20 +08:00
ryan ee047cb351 修复 Pages 站点在未启用 SPA Fallback 时访问根路径 / 返回 404:OpenResty 渲染增加 location = / 精确匹配,通过 try_files 提供入口文件(index 指令在 try_files ... =404 场景下不会作用于根路径)。 2026-06-21 12:18:20 +08:00
ryan 6ed3c0c81f 收敛 Pages 部署包读取路径 2026-06-21 12:04:54 +08:00
ryan 13a375e042 修复 Agent 部署 Pages 问题 2026-06-21 11:53:54 +08:00
ryan 36f11c6ecb 修复代理路由详情认证配置 Tab:移除 PoW 配置(PoW 仅在 WAF 规则组中设置);保留 Basic Auth 保存能力;移除页头重复的「保存当前分区」按钮。 2026-06-21 11:28:50 +08:00
ryan 0f904b4b6d 修复代理路由详情认证配置 Tab:移除 PoW 配置(PoW 仅在 WAF 规则组中设置);保留 Basic Auth 保存能力;移除页头重复的「保存当前分区」按钮。 2026-06-21 11:26:37 +08:00
ryan e479ae75e6 修复 Pages 路由发布失败并报 pages module is not available:配置快照发布流程补齐 Pages 项目激活部署解析与 pages_deployment 写入。 2026-06-21 11:12:37 +08:00
ryan 6f267bbf21 修复仪表盘与节点详情「24 小时网络趋势」误按速率展示:改为 OpenResty 入/出站小时流量与近 24 小时总量摘要,Y 轴与 tooltip 自动换算 B/KB/MB/GB。 2026-06-21 11:08:25 +08:00
ryan ce2b931a78 修复 Pages 上传或节点同步时报 pages file size out of bounds:允许 ZIP 包内的 0 字节文件,并兼容未声明解压大小的 ZIP 条目。 2026-06-21 10:55:48 +08:00
ryan 6e86901a58 修复节点详情 OpenResty 连接数与吞吐显示为「—」:节点可观测 API 将 OpenResty 观测数据合并进 metric_snapshots;指标文案改为「请求/分钟」(近 60 秒窗口),连接数为 0 时正常显示 0。 2026-06-21 10:52:06 +08:00
ryan 42896a8473 仪表盘「24 小时请求趋势」摘要误显示当前小时请求量/错误量:改为汇总近 24 小时总量 2026-06-21 10:43:26 +08:00
ryan 665dd09e11 fix: 修复 Pages 部署包上传报「请求超时,请稍后重试」 2026-06-21 10:39:08 +08:00
ryan b12a9b0185 fix: 修复应用日志异常膨胀 2026-06-21 10:21:18 +08:00
ryan a343c7a605 fix: 修复 Agent 使用 volume 映射时 PoW/WAF 运行时配置无法加载 2026-06-21 10:15:18 +08:00
ryan 117d473c27 fix: 修复 WAF 规则组保存/绑定网站时报 of_waf_rule_group_bindings_pkey 冲突 2026-06-20 22:05:30 +08:00
ryan 99f6f3231a fix: 修复 WAF 规则组保存/绑定网站时报 of_waf_rule_group_bindings_pkey 冲突 2026-06-20 21:33:46 +08:00
ryan b04a358e5e fix: 配置版本列表按 created_at 倒序展示 2026-06-20 21:32:18 +08:00
ryan 6fc39d9e80 fix: 修复 WAF 规则组 PoW 策略发布后边缘不生效 2026-06-20 21:05:25 +08:00
ryan 889e79c8b8 fix: 收敛子代理站点标识双轨逻辑 2026-06-20 21:03:13 +08:00
ryan 9bf7e3cd1b fix: 修复 WAF 规则组 PoW 策略发布后边缘不生效 2026-06-20 20:47:38 +08:00
ryan 8751c0dee3 fix(openflare): mmdb 国家名节点在世界地图使用正确质心
- 从 world-geo 生成国家质心表,Server 在仅有 ISO/国家名时补全 geo 坐标
- 全球态势板在缺少经纬度时按 geo_name 解析质心,避免 fallback 到美国
2026-06-20 19:46:33 +08:00
ryan 498a9ed3ff fix(openflare): Agent 上报 IP 后由 Server 自动解析节点地理位置
- 启动时按 of_options.GeoIPProvider 初始化 pkg/geoip(bootstrap + runtime)
- mmdb 模式从内置 GeoLite2 种子到 data/;保存归属方式后热刷新 Provider
- Agent/Relay 心跳在服务端根据 IP 写入 geo 字段,尊重 geo_manual_override
- ipinfo 归属名称改为 City, Region, Country 可读格式
2026-06-20 19:36:17 +08:00
ryan ec53629971 fix(frontend): 修复配置版本快照侧栏无法滚动
将快照与发布预览 Sheet 内容区改为 flex-1 min-h-0 overflow-y-auto,
并固定侧栏高度为 h-svh,与项目内其他可滚动 Sheet 一致。
2026-06-20 19:22:23 +08:00
ryan 6c46f5d24f fix(openflare): Pages 部署包经 upload 存储下载
Agent 下载 Pages 包时统一通过 upload_id 走文件存储 API;legacy
artifact_path 仅用于一次性回填 upload 并清空路径。部署视图暴露
upload_id,并补充回归测试与 changelog。
2026-06-20 19:21:12 +08:00
ryan e077b12328 fix(frontend): cap envelope mismatch 2026-06-20 19:04:55 +08:00
ryan 570b639e07 fix(agent): commit GeoLite2 mmdb as build fallback
Vendor GeoLite2-Country.mmdb in the repository so agent builds still
work when the remote download is unavailable. Update the fetch script
and agent Dockerfile to prefer a fresh download and fall back to the
committed database file.
2026-06-20 14:04:42 +08:00
ryan 3a368119e5 fix(ci): fetch GeoLite2 mmdb before agent build
Agent embeds GeoLite2-Country.mmdb but the file is not checked into the
repository. Download it in CI, Docker, and Makefile build paths via
scripts/fetch-agent-geoip-mmdb.sh, and correct the gitignore exception
path for the geoipdata package.
2026-06-20 14:03:43 +08:00
ryan 6975a6c290 sync ci 2026-06-20 13:44:24 +08:00
ryan cdac1f8a45 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:23 +08:00
ryan 8c872f9b32 refactor(bootstrap): 引入解耦的全局生命周期管理器以隔离业务停机钩子
- 新建 internal/lifecycle 包,提供全局线程安全的 Shutdown 钩子注册与调度能力。
- 在 chwriter 与 risk_control 初始化阶段通过 OnShutdown 将其 Stop 函数注册到管理器中。
- bootstrap.Stop() 函数仅委托调用 lifecycle.Stop(),不再硬编码引入业务包,防止后续框架同步产生合并冲突。
2026-06-20 09:58:59 +08:00
ryan 2a0ebd16fa fix(backend): 修复优雅停机失效、系统设置并发读写冲突、文件服务API信封绕过以及测试uploads目录污染
- 修复 ClickHouse Batch Writer 与 RiskControl LogWriter 优雅停机,确保退出前队列数据正确刷盘。
- 修复 OpenFlare 系统配置参数全局变量并发读写的 Data Race 冲突,在读取配置时引入读锁保护。
- 修复 upload 模块的 API 错误响应格式,使用 response.Abort* 代替原始的 c.AbortWithStatus 与 c.JSON,保证全局信封格式统一。
- 修复 pkg/utils/network 的 isPrivateIPv4 以使用标准库 net.IP.IsPrivate() 校验,修复 format 的 Bytes2Size 边界,修改大小单位因子变量为只读常量。
- 重构 upload 文件服务与路由器测试,使用 t.TempDir() 代替硬编码的 uploads 相对路径写入和删除,解决测试目录文件污染问题。
2026-06-20 09:33:42 +08:00
ryan b3a55d4ab5 refactor(core): optimize performance, fix concurrency and clean up AGENTS.md design violations
- Concurrency: Added lock protection to WebSocket writes, fixed timer leaks, and prevented config cache listener context leaks.
- Performance: Added memory cache in ObservabilityBufferStore, periodic cleaning in CH Deduplicator, and buffered ZIP batch download writes.
- Design: Introduced Redis caching for OAuth session/tokens, sanitized raw DB error messages, segregated handlers and logics, and standard CAP response envelopes.
2026-06-20 09:09:02 +08:00
ryan 5bed2bae9f migrate database 2026-06-19 22:18:52 +08:00
ryan b6c4181c70 fix(openflare): serialize access log snowflake IDs as strings
- Return AccessLogView.id as string to avoid JS Number precision loss
- Store OpenFlareAccessLog IDs as uint64 with json id,string
- Update frontend AccessLogItem.id type to string
2026-06-19 20:10:09 +08:00
ryan 3187934f72 fix(openflare): ClickHouse count scan and dashboard traffic metrics
- Scan ClickHouse count()/countIf() aggregates as uint64 before int64 conversion
- Fix access log list count, region stats, and delete pre-count queries
- Aggregate dashboard 24h traffic totals from hourly trend buckets
- Show current hour value in traffic trend chart summary
- Rename docker-compose service to openflare
- Remove redundant config preview section from performance page
2026-06-19 17:34:19 +08:00
ryan 9491b2a744 feat(clickhouse): wire batchwriter into business ingestion paths
Migrate risk_control audit logs to internal/db/batchwriter and add
openflare/chwriter with per-table async flush for observability
timeseries and node access logs.

Replace single-row ClickHouse inserts and pre-insert SELECT count()
dedup with repository BatchInsert* APIs, in-process TTL dedup for
metric/report snapshots, and bootstrap initialization on API startup.
2026-06-19 17:20:39 +08:00
ryan ca6c20ebd9 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 17:20:39 +08:00
ryan 578110f615 observation clickhouse 2026-06-19 17:20:39 +08:00
ryan 32861c5db9 fix lint 2026-06-19 17:20:39 +08:00
ryan 0b34792709 refactor(edge): Server 协议统一与 wsclient 收敛(Phase 3 Batch 3)
- openflare/agent/relay/flared 协议类型改为 pkg/protocol 别名,删除重复 struct
- 新增 edge/wsclient Preset 表,三组件 wsclient 改为薄包装
- 更新设计文档、计划与 changelog
2026-06-19 15:00:46 +08:00
ryan db9a9f98fd refactor(edge): 抽取边缘运行时共享包并完成 Phase 3 重构
- 新增 internal/apps/edge/,三组件改为薄包装,删除 3000+ 行重复代码
- Agent 心跳周期下沉至 heartbeat/cycle.go
- 协议类型迁入 pkg/protocol/agent.go
- 补充设计文档与 changelog
2026-06-19 14:56:00 +08:00
ryan cc5e53c51e 文档更新 2026-06-19 14:45:17 +08:00
ryan 9eeeb09d2f feat(repo): rename output server binary from wavelet to openflare-server 2026-06-19 14:36:46 +08:00
ryan 84303d25b2 feat(repo): add build targets for agent, relay, and flared to Makefile 2026-06-19 14:33:32 +08:00
ryan 63cd906cfc refactor(repo): consolidate openflare-server to root and move subprojects to internal/apps
- Merge all files inside openflare-server to the repository root directory.
- Relocate agent, relay, and flared subprojects from internal/ to internal/apps/.
- Combine docker-compose files and update build context paths to root.
- Update GitHub workflows and Dockerfiles to refer to new directories and package names.
- Rewrite Go package imports across all files.
- Resolve database renew test race condition and clean up docs.
2026-06-19 14:23:29 +08:00
ryan 19d476ed7f refactor 2026-06-19 14:10:21 +08:00
ryan 8506a03f1c refactor(clickhouse): route of_node_access_logs through analytics repository
Move all ClickHouse DML for OpenFlare node access logs into
internal/repository/analytics; model layer keeps domain aggregation and
in-memory test store via a thin adapter. Aligns with goose-managed DDL
and openflare database startup dependency.
2026-06-19 12:13:18 +08:00
ryan 8a00f53b16 feat(clickhouse): integrate goose migrations and analytics repository
Merge upstream Wavelet patch to manage ClickHouse DDL via goose
(goose_clickhouse_version), add GORM ChDB access, and centralize
w_user_access_logs reads/writes in internal/repository/analytics.
OpenFlare of_node_access_logs DDL moves to goose/clickhouse; remove
manual clickhouse_schema startup and support-files SQL duplicates.
2026-06-19 12:11:41 +08:00
ryan fff26aa343 merge: migrate of_node_access_logs to ClickHouse
Integrate clickhouse worktree: node access logs now stored in ClickHouse
(openflare database) with mandatory startup dependency.
2026-06-19 11:45:51 +08:00
ryan 425ca89765 feat(openflare): migrate of_node_access_logs to ClickHouse
Move node access log storage from PostgreSQL/SQLite to ClickHouse
(database: openflare). System startup now requires a healthy ClickHouse
connection and auto-initializes the schema. Agent heartbeat writes access
logs via batch insert; goose migration drops the legacy relational table.
2026-06-19 11:45:48 +08:00
ryan 7eb943f02f update doc 2026-06-19 11:45:22 +08:00
ryan d78449cbc9 refactor(repo): replace legacy openflare-server with Wavelet rename
Delete the old monolithic openflare-server implementation and rename
Wavelet/ to openflare-server/ to complete the migration consolidation.
Update CI workflows, agent Dockerfiles, and deployment docs for the new
layout (frontend/, docker/Dockerfile).
2026-06-19 11:37:34 +08:00
ryan aa11c0f52a merge: replace legacy openflare-server with Wavelet rename 2026-06-19 11:30:05 +08:00
ryan 88360350a0 refactor(repo): replace legacy openflare-server with Wavelet rename
Delete the old monolithic openflare-server implementation and rename
Wavelet/ to openflare-server/ to complete the migration consolidation.
Update CI workflows, agent Dockerfiles, and deployment docs for the new
layout (frontend/, docker/Dockerfile).
2026-06-19 11:29:17 +08:00
ryan e3353cd09d docs(openflare): complete Swagger for protocol and dashboard APIs
Add Swagger annotations for all Agent, Relay, and Tunnel protocol
endpoints under /api/v1. Register AgentTokenAuth and TunnelTokenAuth
security definitions. Align dashboard API docs to return 404 for
insufficient admin permission.
2026-06-19 11:26:56 +08:00
ryan 46123d62ae Merge remote-tracking branch 'origin/dev' into dmux-2026-06-19-110554 2026-06-19 11:23:35 +08:00
ryan 68ddad98fb merge: unify protocol API responses to Wavelet format 2026-06-19 11:23:19 +08:00
ryan 33b4123444 refactor(openflare): unify protocol API responses to Wavelet format
Migrate Agent/Relay/Tunnel handlers from compat {success,message,data}
to response.OK and response.Abort* with real HTTP status codes. Remove
the compat package and update openflare-agent, openflare-relay, and
openflared clients to parse {error_msg,data}.
2026-06-19 11:23:12 +08:00
ryan 16e97d0dc3 merge: remove unused About page and API 2026-06-19 11:18:20 +08:00
ryan 7aa4cc908d merge: remove legacy openflare update API 2026-06-19 11:18:15 +08:00
ryan 8fb988ba0a refactor(openflare): remove unused About page and API
Delete AboutService and GET /api/v1/d/about since the about page was
never implemented in the Wavelet frontend.
2026-06-19 11:18:15 +08:00
ryan 53bd451450 refactor(openflare): remove legacy update API, use admin updater
Drop the migrated /api/v1/openflare/update compatibility layer and point
the OpenFlare upgrade UI at Wavelet's /api/v1/admin/update endpoints.
2026-06-19 11:17:45 +08:00
ryan 42ca0ec642 merge: fix(openflare) align admin permission model with Wavelet
Merge dmux-2026-06-19-110554; resolve changelog conflict keeping /api/v1/d/*
path migration and new AdminMiddlewares permission model.
2026-06-19 11:14:30 +08:00
ryan 49d32d0b25 fix(openflare): align admin permission model with Wavelet
Unify OpenFlare console auth to user.IsAdmin and token_admin instead of
the legacy Admin/Root role tiers. Route groups now use
apiutil.AdminMiddlewares (LoginRequired + LoginAdminRequired) so admin
checks cannot be skipped. Add middleware tests and extend integration
coverage for 401/404/400 responses.
2026-06-19 11:13:57 +08:00
ryan 915c00eb34 docs: 同步 OpenFlare 迁移文档与当前实码
更新后端 handover、实现计划 §12 端点对照表与 changelog,
反映 legacy 层已撤销、控制台 /api/v1/d/* 与协议路径现状,
并消除 OpenFlare-Token 桥接相关矛盾表述。
2026-06-19 11:13:10 +08:00
ryan e3309df336 merge: remove legacy global API rate limit options 2026-06-19 11:09:13 +08:00
ryan c69378f0de refactor(openflare): remove legacy global API rate limit options
Drop unused GlobalApi/Web/Critical rate limit settings migrated from the
old server but never wired up in Wavelet, including validation, defaults,
seed data, and a cleanup migration for existing databases.
2026-06-19 11:08:58 +08:00
ryan 71ebfa555f refactor(api): unify routes under /api/v1/d, agent, relay, tunnel
Remove the /openflare prefix from management APIs (/api/v1/d/*) and move
Agent, Relay, and Tunnel protocol endpoints under /api/v1. Update Wavelet
frontend services and node client binaries to match.
2026-06-19 11:08:06 +08:00
ryan 6bd7dc91fc refactor(openflare): mount console APIs under /api/v1/openflare
Move OpenFlare route registration from RegisterCustomRoutes to
v1.RegisterV1Routes so business APIs are mounted directly at
/api/v1/openflare instead of under the /custom example prefix.
Update handler Swagger annotations, frontend service base paths,
and regenerated swagger docs accordingly.
2026-06-18 21:25:20 +08:00
ryan 399c1bc88d refactor(openflare): migrate console APIs to v1 and centralize routing
Move OpenFlare management endpoints to /api/v1/custom/openflare with
Wavelet response envelopes and Abort* error handling. Keep agent, relay,
and flared protocol routes on /api/* with the legacy compat format.

- Add apiutil helpers and Swagger annotations for console handlers
- Register all OpenFlare routes in internal/router/openflare (not apps)
- Switch frontend services to OpenFlareBaseService and v1 paths
- Remove dead auth/compat code and legacy-base.service.ts
- Update integration tests and changelog
2026-06-18 21:25:20 +08:00
ryan aa348a1b48 merge: 规则组编辑改用 Sheet 抽屉 2026-06-18 20:24:23 +08:00
ryan a2f838605c feat(waf): 规则组新建/编辑改用右侧 Sheet 抽屉
将 RuleGroupDialog 替换为 RuleGroupSheet,使用 shadcn Sheet 侧栏交互,
与站点绑定等操作保持一致;添加规则条目仍保留弹窗。
2026-06-18 20:23:56 +08:00
ryan fb7d7ff3a4 merge: 实装 PoW 配置面板 2026-06-18 20:21:29 +08:00
ryan 89b3f5d843 feat(wavelet): 实装完整 PoW 配置面板
迁移旧前端 PoW 难度/TTL 与 IP/路径/UA 黑白名单编辑,替换占位提示。
2026-06-18 20:21:17 +08:00
ryan 056c75a853 前端优化 2026-06-18 20:20:34 +08:00
ryan 676899cab0 前端优化 2026-06-18 20:12:10 +08:00
ryan cf5c2b7258 前端优化 2026-06-18 20:07:22 +08:00
ryan 391823a915 feat(wavelet): 侧栏新增安全性折叠组收纳 WAF 与 IP 组
移除 WAF / IP 组页面右上角交叉导航,改由侧栏统一入口。
2026-06-18 20:00:04 +08:00
ryan b56b4c93b6 refactor(wavelet): 侧栏导航改为 openflareSidebarNav 动态配置
用 kind: item | group 的单一数组描述侧栏顺序,移除 slice(0, 4) 硬编码切分。
2026-06-18 19:54:08 +08:00
ryan 29176da35f feat(openflare): Pages 部署包改用 upload 本地文件存储框架
通过 upload.Ingest 摄取 zip 部署包并记录 upload_id,删除部署时调用
upload.Remove;Agent 下载改为 OpenDeploymentPackage 从存储后端流式输出。
新增 of_pages_deployments.upload_id 迁移,保留 artifact_path 兼容旧数据。
2026-06-18 19:52:59 +08:00
ryan 9ac5ff6925 fix(wavelet): 移除网站管理页面右上角互相跳转按钮
侧栏已有「网站管理」折叠组,去掉网站/证书/DNS 页头交叉导航。
2026-06-18 19:52:17 +08:00
ryan 1283aa5175 Merge branch 'main' into dev
合并侧栏「网站管理」折叠组:保留 dev 导航命名(版本发布等),
源站移入折叠组,并采用 matchesNavPath / isNavGroupActive 路由匹配逻辑。
2026-06-18 19:49:31 +08:00
ryan 1208f7ee94 feat(wavelet): 侧栏新增网站管理折叠组
使用 shadcn Collapsible 与 SidebarMenuSub 收纳网站、证书、DNS、源站入口。
2026-06-18 19:48:30 +08:00
ryan 0c5136e59e 前端优化 2026-06-18 19:43:22 +08:00
ryan 8b3d220d73 merge: 合并 main 至 dev
同步节点网络/磁盘趋势图 ECharts 样式对齐。
2026-06-18 19:35:45 +08:00
ryan 134d279f0c style(wavelet): 节点网络/磁盘趋势图对齐原版 ECharts
改用 TrendChart 组件,恢复系列摘要卡与平滑面积折线图样式。
2026-06-18 19:35:32 +08:00
ryan 2eb84fc7f8 前端优化 2026-06-18 19:33:59 +08:00
ryan dde0117a37 style(wavelet): 全球态势板地图调整为 2:3 比例
固定宽:高约 2:3 的竖向视口并居中展示,降低 ECharts 横向拉伸。
2026-06-18 19:15:06 +08:00
ryan ce72de2d63 Merge branch 'wavelet-top-domain' into dev 2026-06-18 19:15:06 +08:00
ryan bb5c626cd9 merge: 合并 OpenFlare 定时任务 Asynq 迁移至 dev 2026-06-18 19:14:52 +08:00
ryan 3342d3c39d feat(openflare): 将定时任务迁入 Wavelet Asynq 异步任务框架
将 SSL 续期、可观测数据清理、WAF IP 组同步与 Uptime Kuma 同步
从 API 进程内 cron 迁移为 Asynq Handler,并通过 w_schedules 种子迁移
注册默认定时调度;移除 bootstrap 中的 in-process cron 启动逻辑。
2026-06-18 19:14:47 +08:00
ryan 49c40b20b7 fix(wavelet): 修复全球态势板地图容器尺寸为 0
地图注册完成后再挂载 ResizeObserver,并在获得有效宽高后才初始化 ECharts。
2026-06-18 19:12:08 +08:00
ryan 194560c887 merge: 全球态势板布局紧凑化 2026-06-18 19:09:33 +08:00
ryan 7b05e2dd3c style(wavelet): 紧凑化全球态势板布局
地图固定高度约 260px,指标改为侧栏密集单元格,图例行内化以降低首屏占用。
2026-06-18 19:09:20 +08:00
ryan b59a2f3be3 merge: 合并 main 至 dev
同步节点详情 Tabs 布局重构。
2026-06-18 19:08:54 +08:00
ryan f1badac257 refactor(wavelet): 节点详情页改为 Tabs 布局
统一 Edge/Relay/Tunnel 详情壳层,拆分概览、数据看板与配置部署,
支持 tab URL 参数深链并优化信息层次与可读性。
2026-06-18 19:08:49 +08:00
ryan 2cc0f3e673 style(wavelet): 全球态势板对齐系统 Card/Badge 风格
移除旧前端渐变外壳,指标卡与图例复用总览页虚线 Card 与状态 Badge 样式。
2026-06-18 19:06:25 +08:00
ryan 769045af15 merge: 全球态势板视觉对齐系统风格 2026-06-18 19:06:25 +08:00
ryan 0d5fac1621 merge: 合并全球态势板迁移
将旧前端 WorldStage 世界地图板块纳入 dev 总览仪表盘。
2026-06-18 19:02:56 +08:00
ryan fbc668c7bf feat(wavelet): 迁移总览全球态势板
从旧前端移植 WorldStage 世界地图与摘要指标,替换顶部统计卡片区域。
2026-06-18 19:02:52 +08:00
ryan caa6649297 merge: 合并 main 至 dev
同步边缘节点详情数据看板与 main 分支最新变更。
2026-06-18 18:59:09 +08:00
ryan e875ffd865 merge: 合并 wavelet-top-domain 总览仪表盘图表改造
仅合并 ECharts 图表迁移与分布榜单板块,不包含 main 分支其他变更。
2026-06-18 18:58:17 +08:00
ryan e51ada4d60 fix(wavelet): 趋势图组件支持可配置标题与描述
便于节点详情页复用请求与容量趋势图。
2026-06-18 18:58:12 +08:00
ryan 22fbfc92c6 dmux 2026-06-18 18:58:07 +08:00
ryan 32d300ce58 merge: 合并 wavelet-top-domain 总览仪表盘图表改造
合并 ECharts 图表样式迁移与 Top Domain / 分布榜单板块,
保留节点详情页趋势图的可配置标题与描述参数。
2026-06-18 18:55:46 +08:00
ryan 1857fe4c98 feat(wavelet): 总览仪表盘对齐旧前端 ECharts 图表样式
将请求/容量趋势图迁移至 ECharts,并补充网络磁盘趋势、Top 节点榜单、
来源分布、状态码分布与 Top Domain 板块。
2026-06-18 18:54:53 +08:00
ryan 0b646238e9 补全 Wavelet 边缘节点详情数据看板
将旧版 openflare-server 节点观测界面迁移至 Wavelet 前端,包含运行诊断摘要、
系统信息、实时资源、网络流量、24 小时趋势图、请求结构分布与健康事件时间线。
2026-06-18 18:54:47 +08:00
ryan 48421c12de migrate frontend 2026-06-18 18:34:20 +08:00
ryan 75140fc3bb migrate frontend 2026-06-18 18:18:10 +08:00
ryan 15fa943d41 migrate frontend 2026-06-18 18:12:10 +08:00
ryan e7b8993181 migrate frontend 2026-06-18 18:05:00 +08:00
ryan 31b114b0cc migrate frontend 2026-06-18 17:43:11 +08:00
ryan a9fd0bd128 migrate frontend 2026-06-18 17:26:57 +08:00
ryan d7fe4ef8be migrate 2026-06-18 17:12:39 +08:00
ryan 001f5c968e migrate 2026-06-18 17:08:43 +08:00
ryan 01c28bb88b migrate 2026-06-18 17:08:21 +08:00
ryan dfc480c73a migrate 2026-06-18 16:59:02 +08:00
ryan e3bfd9ca6d migrate 2026-06-18 16:51:06 +08:00
ryan 61cfacba55 migrate key 2026-06-18 16:43:02 +08:00
ryan 5943372ce4 migrate db 2026-06-18 16:38:24 +08:00
ryan e889247387 migrate 3000 2026-06-18 16:24:43 +08:00
ryan 4ddda8e733 migrate 2026-06-18 16:18:40 +08:00
ryan 2480d74410 migrate 2026-06-18 16:13:39 +08:00
ryan 772962c2e9 migrate 2026-06-18 16:08:48 +08:00
ryan 3366edb3a1 plan 2026-06-18 15:42:06 +08:00
ryan 99738bbc17 wavelet init 2026-06-18 15:24:48 +08:00
ryan d6a7011885 更新文档 2026-06-17 10:48:05 +08:00
ryan 6ea2c90f75 [优化] 日志查询优化 2026-06-17 10:45:30 +08:00
ryan 959b134d67 [优化] 目录调整 2026-06-06 16:50:24 +08:00
ryan 314229b7ee [优化] Update 2026-06-06 15:49:15 +08:00
ryan 7ed75e79ce [优化] Update 2026-06-06 12:25:55 +08:00
ryan 52efc629b7 [优化] Update 2026-06-06 12:23:52 +08:00
ryan 123140b762 [优化] POW 系统接口方案 2026-06-06 12:21:55 +08:00
ryan 6bf5023af1 [优化] POW 系统接口方案 2026-06-06 12:13:55 +08:00
ryan 4be7c19798 [优化] POW 系统接口方案 2026-06-06 12:07:39 +08:00
ryan 32e1a07157 [优化] 重构数据库历史迁移校验架构 2026-06-06 11:50:57 +08:00
ryan 2662c65f57 [优化] 部署脚本优化 2026-06-06 11:06:45 +08:00
ryan 3cfefb4367 [优化] go 引用调整 2026-06-06 10:46:40 +08:00
ryan ee1110b752 [优化] 文档优化 2026-06-05 12:17:07 +08:00
ryan 9959397934 [优化] 文档优化 2026-06-05 11:49:50 +08:00
ryan 5f82f72a6f [优化] 文档优化 2026-06-05 11:44:25 +08:00
ryan 43e8ef154f [优化] response 结构调整 2026-06-05 11:42:25 +08:00
ryan 4dc4c745c8 [优化] response 结构调整 2026-06-05 11:32:10 +08:00
ryan a5257be319 [优化] 文档更新 2026-06-05 11:28:02 +08:00
ryan 30f36efe5a [优化] 文档更新 2026-06-05 11:27:49 +08:00
ryan 5a6c5cf72c [优化] 文档更新 2026-06-05 11:13:07 +08:00
ryan 189916d1db [优化] 文档更新 2026-06-05 11:12:06 +08:00
ryan 546856594e [优化] 文档更新 2026-06-05 10:42:06 +08:00
ryan 457b3397fd [优化] 文档更新 2026-06-05 10:33:18 +08:00
ryan 47ff33c653 [优化] 白名单优先级高于黑名单 2026-06-04 12:35:52 +08:00
ryan 1d41b108fc [优化] 界面优化 2026-06-04 12:19:04 +08:00
ryan b7a101fc60 [优化] CI 2026-06-04 12:11:55 +08:00
ryan c85d9104ec [优化] 每日定时清理预发布版本 2026-06-04 12:08:33 +08:00
ryan f3cdbdd7d5 [优化] 修复文档 2026-06-04 12:05:36 +08:00
ryan e93131ab46 [优化] 修复依赖 2026-06-04 12:03:00 +08:00
ryan 161e6c4e86 [优化] 更换 JWT 认证机制 2026-06-04 11:54:53 +08:00
ryan 3dbc7b3045 [优化] Header 认证 2026-06-04 11:18:20 +08:00
ryan 4a4189705a [优化] 禁用手动升级功能 2026-06-04 11:13:29 +08:00
ryan 6aa71a4da8 [优化] 更新认证机制,使用 OPENFLARE_TOKEN 替代 Bearer Token 2026-06-04 11:10:06 +08:00
ryan bdc96f6d8e [优化] 合并 2026-06-04 10:28:12 +08:00
ryan 1c1063f448 [优化] 修复错误 2026-06-04 10:21:07 +08:00
ryan 29fdc378a1 [优化] 代码优化 2026-06-04 10:12:53 +08:00
ryan bd659d493d [优化] 操作优化 2026-06-04 10:07:40 +08:00
ryan 6a2a6028c3 [优化] 日志优化 2026-06-04 10:00:37 +08:00
ryan 6e85f1158b [优化] 增加根目录选项 2026-06-04 09:56:03 +08:00
ryan e117e314d9 [优化] 代码优化Format 2026-06-04 09:31:27 +08:00
ryan fbb0909638 [优化] 上传部署包功能及相关界面优化 2026-06-04 09:13:29 +08:00
ryan 3eecd31868 [新增] 添加 API 反向代理功能支持 2026-06-03 22:48:04 +08:00
ryan 68d70bbbe8 [新增] 界面优化 2026-06-03 19:53:54 +08:00
ryan 08ec945e59 [新增] 界面优化 2026-06-03 19:52:38 +08:00
ryan 4401cb0d66 [新增] POW 与 WAF 规则合并 2026-06-03 19:09:01 +08:00
ryan 36ae6247f9 [新增] OpenFlare Pages 2026-06-03 17:48:01 +08:00
ryan 1088086399 [新增] OpenFlare Pages 2026-06-03 17:31:14 +08:00
ryan 2c74d042ed [新增] OpenFlare Pages 2026-06-03 17:09:02 +08:00
ryan 3ec607106d [新增] 对接 Uptime Kuma 2026-06-03 12:17:46 +08:00
ryan f671a96d8c [新增] 对接 Uptime Kuma 2026-06-03 11:58:00 +08:00
ryan fe5cf9021f [优化] 更新Dockerfile,优化构建过程 2026-06-02 23:49:17 +08:00
ryan 1be2461716 [优化] 修复构建 2026-06-02 23:43:10 +08:00
ryan a0e9484e37 [优化] 重构数据库迁移逻辑,整合遗留和Goose迁移处理 2026-06-02 23:35:02 +08:00
ryan 4ca6f2957b [优化] 重构数据库迁移逻辑,整合遗留和Goose迁移处理 2026-06-02 22:27:04 +08:00
ryan dfd040a9de [优化] 确保所有管理的进程在停止时被正确取消和清理 2026-06-02 21:30:48 +08:00
ryan f29292dd81 [优化] 添加进程管理功能,支持PID文件处理和孤儿进程清理 2026-06-02 21:08:04 +08:00
ryan 4566fc1f53 [优化] 增强 WebSocket 处理逻辑,添加上下文取消支持和关闭机制
[优化] 重构 WebSocket 处理逻辑,添加消息处理接口和心跳机制
2026-06-02 19:37:03 +08:00
ryan 4e58bdd85b [优化] 重构 WebSocket 客户端,整合共享连接逻辑并简化代码 2026-06-02 17:35:32 +08:00
ryan c009b9e283 [优化] 修复增强 2026-06-02 17:24:12 +08:00
ryan 4e33e0e521 [优化] 添加进程重启机制和指数退避策略以增强稳定性 2026-06-02 17:18:14 +08:00
ryan 7252fb6285 [优化] 重构进程管理逻辑,添加自动重启和退避机制 2026-06-02 17:12:34 +08:00
ryan 2220e45989 [优化] 更新 Docker 部署命令,增加对 HTTP3的支持 2026-06-02 16:49:15 +08:00
ryan 6158a487cf [优化] 添加多语言支持的验证页面文本 2026-06-02 16:11:46 +08:00
ryan 9f9c609809 [优化] 添加 HTTP/3 支持配置选项 2026-06-02 16:07:44 +08:00
ryan e4c6ce9062 [优化] 添加自定义状态码匹配方法 2026-06-02 08:34:03 +08:00
ryan 81dd44c8fc [优化] 添加自定义状态码匹配方法 2026-06-02 08:31:39 +08:00
ryan 3825a7f29a [优化] 自定义不存在页面返回状态码 2026-06-02 08:14:15 +08:00
ryan d21643feed [优化] 更新文档 2026-06-02 00:15:51 +08:00
ryan 2525664013 [优化] 更新文档 2026-06-02 00:13:39 +08:00
ryan a850b0a188 [优化] 修复重启 frpc 挂掉问题 2026-06-01 22:45:21 +08:00
ryan fd8148c0db [优化] 界面优化 2026-06-01 22:33:41 +08:00
ryan edd98f4ff0 [优化] 优化 2026-06-01 22:24:41 +08:00
ryan d8f98e218f [优化] 修复一个升级数据库的错误 2026-06-01 22:00:37 +08:00
ryan fefe205158 [优化] 增加 FRPS WebUI 支持,添加相关配置和数据库迁移 2026-06-01 21:59:42 +08:00
ryan d6e7e2baa2 [优化] 增加自动更新功能,支持更新请求和版本管理 2026-06-01 21:50:22 +08:00
ryan cc50cc695e [优化] 更新 Docker 镜像名称并调整日志级别配置 2026-06-01 21:35:48 +08:00
ryan e43312d4c6 [优化] 增加 IP 处理逻辑并优化心跳负载 2026-06-01 21:16:14 +08:00
ryan 92df7d5c84 [优化] 增加中继 Vhost HTTP 端口支持并优化相关逻辑 2026-06-01 21:11:25 +08:00
ryan 9632b4e3b8 [优化] 增加中继 Vhost HTTP 端口支持并优化相关逻辑 2026-06-01 21:02:15 +08:00
ryan 3b979eb5d5 [优化] 修复隧道相关配置支持 2026-06-01 20:34:02 +08:00
ryan 2635a47d29 [优化] 优化脚本 2026-06-01 20:07:50 +08:00
ryan e00d67f2d9 [优化] 压缩脚本到 V16 2026-06-01 19:36:52 +08:00
ryan 581822d905 [优化] 修复 Agent CI 2026-06-01 19:04:01 +08:00
ryan b5ebfff19b [优化] Action 稳定性提升 2026-06-01 18:53:21 +08:00
ryan eed227b999 [优化] 修复迁移 2026-06-01 18:43:10 +08:00
ryan 8f08a962e6 [优化] 修复迁移 2026-06-01 17:50:49 +08:00
ryan d3ce26414c [优化] 移除数据库迁移中的 ApplyCurrentSchema 调用 2026-06-01 17:32:30 +08:00
ryan 663da01bda [优化] 修复数据库迁移问题 2026-06-01 17:15:17 +08:00
ryan df63b0113a [优化] 添加 OpenFlared API 支持,增强心跳和配置管理功能 2026-06-01 16:41:46 +08:00
ryan d09e64ddc6 [优化] 修复 2026-06-01 16:33:22 +08:00
ryan b968043117 [优化] 修复 2026-06-01 16:20:19 +08:00
ryan 31d10195ca [优化] 提升WS连接稳定性 2026-06-01 16:02:15 +08:00
ryan 76f3428f5d [优化] 字段修复 2026-06-01 16:02:02 +08:00
ryan 9de33f7064 [优化] 数据库结构优化 2026-06-01 15:32:08 +08:00
ryan fe13db95c2 [优化] Relay节点 IP 检测功能,自动设置 NodeIP 配置 2026-06-01 14:42:50 +08:00
ryan 6ddd2da2e8 [优化] 重构节点详情页面 2026-06-01 14:39:33 +08:00
ryan 054dc1a8a8 [优化] 添加 Relay frps 连接和代理计数字段,重构相关逻辑以支持监控和可观测性 2026-06-01 14:31:42 +08:00
ryan 394e3c4855 [优化] 更新数据库迁移逻辑,添加 v19 版本验证,重构隧道相关表结构 2026-06-01 14:06:36 +08:00
ryan af36676e2e Merge branch 'doc' 2026-06-01 14:01:40 +08:00
ryan 6fa31cafc7 [优化] 更新节点类型支持,添加隧道客户端,重构相关路由和配置 2026-06-01 14:01:05 +08:00
ryan a8e8a940a0 [优化] 优化 WAF IP 组同步功能及相关文档更新 2026-06-01 13:55:04 +08:00
ryan a092935623 [优化] 文档优化 2026-06-01 12:22:16 +08:00
ryan 5af13d0709 [优化] 修复 docker 2026-06-01 12:03:09 +08:00
ryan 9a2616dc0e [优化] 文档 2026-06-01 11:48:55 +08:00
ryan c4e9e94117 [优化] 修复 Docker 启动 2026-06-01 11:35:15 +08:00
ryan fce2e014e5 [修复] 节点类型字段名不匹配:前端 type 改为 node_type 对齐后端 JSON tag 2026-06-01 11:26:01 +08:00
ryan 7372ac230b [优化] 优化界面 2026-06-01 11:11:48 +08:00
ryan 77bdb8bf0e [优化] 优化界面 2026-06-01 11:05:00 +08:00
ryan fd745d33cb [优化] 更新 InlineMessage 组件,支持动态反馈和静态警告显示 2026-06-01 10:45:24 +08:00
ryan 65f899d334 [新增] 集成 Sonner 通知库,添加 Toaster 组件并在 InlineMessage 中使用 2026-06-01 10:45:24 +08:00
ryan f034b73a47 [新增] 添加数据库迁移和 GORM 模型验证测试,确保所有模型均已注册 2026-06-01 10:33:59 +08:00
ryan bd7f008322 [新增] 添加自动 IP 组规则的抓取记录功能,支持查看已抓取 IP 列表及其到期状态 2026-06-01 10:33:59 +08:00
ryan 2dc7e72621 [优化] 更新 go.mod 和 go.sum,移除不必要的依赖并添加新的依赖项 2026-06-01 10:11:51 +08:00
ryan 2d542733f9 [优化] 更新清理预发布标签的脚本,支持删除未绑定的正式标签和悬空的 GitHub 发布 2026-06-01 10:06:08 +08:00
ryan c677edba06 [新增] action 2026-06-01 09:57:41 +08:00
ryan b827baf19f [新增] 添加自动 IP 组规则测试功能,支持在保存前验证 Expr 规则命中情况 2026-06-01 09:50:43 +08:00
ryan 73beedfc09 [优化] 调整 openflared 和 openflare_relay 基础镜像为 frp 官方镜像 2026-06-01 09:37:54 +08:00
ryan bc1b861841 [优化] 添加自动 IP 组功能,支持按 Expr 规则聚合请求日志并更新 IP 列表 2026-06-01 09:34:33 +08:00
ryan dfb3972b15 [优化] DockerFile 2026-06-01 09:34:17 +08:00
ryan 330771e7c7 [新增] 内网穿透隧道前端管理与代理规则绑定支持 (P5) 2026-06-01 09:20:45 +08:00
ryan 9ded8c71da [优化] Phase4 2026-06-01 09:03:55 +08:00
ryan 77ad3ea7e3 [优化] 代码优化 2026-06-01 09:03:35 +08:00
ryan 95d7045b4a [优化] 添加 WAF IP 组功能,包括 CRUD 接口和前端页面支持 2026-06-01 08:53:03 +08:00
ryan d5f46138d5 [优化] Phase3 2026-06-01 08:47:09 +08:00
ryan 4196343ad3 [优化] Phase2 2026-06-01 08:38:31 +08:00
ryan 78047d1b38 [优化] 更新 docker-image.yml,调整浮动标签逻辑以支持 beta 版本 2026-05-31 22:16:49 +08:00
ryan c2bd416daf [优化] 更新 README.md,添加 BETA 版本警告信息 2026-05-31 22:10:39 +08:00
ryan 6e5d49c988 [优化] 更新 README.md,添加 BETA 版本警告信息 2026-05-31 22:10:23 +08:00
ryan 9da1ce8456 [优化] 移除清理预发布标签工作流中的确认输入 2026-05-31 22:04:06 +08:00
ryan 9f9cbd4ede [优化] 移除清理预发布标签工作流中的确认输入 2026-05-31 22:02:49 +08:00
ryan da1409fdac [优化] 优化界面 2026-05-31 21:58:30 +08:00
ryan 174198c283 [修复] 更新 OpenResty 模板和 Lua 逻辑以增强可读性和稳定性 2026-05-31 21:49:43 +08:00
ryan 796bf1c22f [优化] 添加错误日志路径占位符并更新相关逻辑 2026-05-31 21:26:45 +08:00
ryan 80dd5f8b31 [优化] 更新 Lua 包路径检查逻辑以避免重复添加 2026-05-31 21:17:36 +08:00
ryan 14d41ad807 [优化] 代码优化 2026-05-31 21:03:41 +08:00
ryan fe2414ead5 [优化] 代码优化 2026-05-31 20:48:15 +08:00
ryan 649287a775 [优化] 代码优化 2026-05-31 20:47:03 +08:00
ryan 2514e7edc4 [优化] 代码优化 2026-05-31 20:31:57 +08:00
ryan 7ab11154e3 [优化] 代码优化 2026-05-31 20:29:15 +08:00
ryan 97c10b8d0b [优化] 代码优化 2026-05-31 20:21:46 +08:00
ryan ceae693a20 [优化] 代码优化 2026-05-31 20:14:19 +08:00
ryan edb356f40e [优化] 代码优化 2026-05-31 20:09:40 +08:00
ryan 81ba309650 [优化] 代码优化 2026-05-31 20:05:35 +08:00
ryan 5612403d48 [优化] 代码优化 2026-05-31 20:02:01 +08:00
ryan 4775e5cb73 [优化] 代码优化 2026-05-31 19:59:09 +08:00
ryan bc3d9ee285 [优化] 代码优化 2026-05-31 19:54:17 +08:00
ryan e654441127 [优化] 配置渲染从 Server 转移到 Agent 2026-05-31 19:51:25 +08:00
ryan a987c0d681 [优化] 优化界面 2026-05-31 15:31:18 +08:00
ryan a85919fd9e [优化] 更新文档 2026-05-31 15:24:02 +08:00
ryan 4cb8928e4e [优化] 移除 Turnstile 相关功能和配置 2026-05-31 15:22:30 +08:00
ryan 57616626fd [优化] 优化代码 2026-05-31 14:53:04 +08:00
ryan 449d0a5c5b [优化] 更新文档 2026-05-31 14:52:37 +08:00
ryan 1c89db8ffa [优化] 补充测试 2026-05-31 14:49:21 +08:00
ryan 46f49cc349 [优化] 重构数据库迁移逻辑,添加版本管理和验证功能 2026-05-31 14:39:37 +08:00
ryan f365b3d331 [优化] 重构数据库迁移逻辑,添加版本管理和验证功能 2026-05-31 14:32:43 +08:00
ryan 4ae6c2718f [优化] 添加节点 IP 手动覆盖功能,更新相关文档和测试用例 2026-05-31 14:12:29 +08:00
ryan 8894620b92 [优化] 代码优化 2026-05-31 14:02:49 +08:00
ryan c2fcd2eddf [优化] 重构 ACME 客户端逻辑,简化证书获取和 DNS 提供者设置 2026-05-31 14:02:49 +08:00
ryan ec70794577 [优化] 重构 API 处理逻辑,简化参数解析和响应处理 2026-05-31 14:02:48 +08:00
ryan bcd669722e [优化] 优化代码 2026-05-31 13:23:49 +08:00
ryan 9975ac90c4 [优化] 邮件工具类去耦合 2026-05-31 13:19:47 +08:00
ryan 632c455229 docs: update deployment instructions for Agent to recommend Docker method 2026-05-31 13:13:52 +08:00
ryan b60cde02ac docs: sync and translate english documentation 2026-05-31 13:10:51 +08:00
ryan 21ed214ba9 [优化] 移除过时的 Docker 相关字段和测试用例 2026-05-31 13:09:53 +08:00
ryan f4a53d6b5f [优化] 增加断开 WebSocket 客户端的功能,优化连接管理 2026-05-30 19:05:20 +08:00
ryan cef3694d11 [优化] 界面优化 2026-05-30 18:07:18 +08:00
ryan 631d32e5d0 [优化] POW 与 WAF 合并 2026-05-30 17:42:23 +08:00
ryan c74b70b62e [优化] 界面优化 2026-05-30 16:31:26 +08:00
ryan fa9ecb5690 [优化] 界面优化 2026-05-30 16:25:17 +08:00
ryan b9cde88bf6 [优化] 重构 WAF 和 PoW 处理逻辑,使用 require 加载运行时模块,更新相关测试以验证新行为 2026-05-30 16:19:55 +08:00
ryan f03718ce8c [优化] 更新 Docker 部署指令,添加镜像拉取和容器移除命令 2026-05-30 16:08:43 +08:00
ryan 3423175006 [优化] 重构升级处理逻辑,添加备份二进制文件移除功能,更新相关测试以验证新行为 2026-05-30 16:05:48 +08:00
ryan d619deec96 [优化] 添加 WAF 阻止逻辑以短路 PoW 处理,更新测试以验证新行为 2026-05-30 15:46:34 +08:00
ryan e094f4a3b7 [优化] 移除不必要的支持文件过滤函数,更新相关测试以验证 WAF 配置包含 2026-05-30 15:39:18 +08:00
ryan 1bff2dadd4 [优化] 合并 WAF 和 PoW 访问处理逻辑,更新相关函数以支持新的配置格式 2026-05-30 15:12:49 +08:00
ryan 602e7f5e9c [优化] 修复 --version 错误 2026-05-30 13:24:23 +08:00
ryan 9ec3d5b42d [优化] 格式化 2026-05-30 13:15:20 +08:00
ryan 28b1305906 [优化] WAF 界面优化 2026-05-30 13:12:57 +08:00
ryan a80376972c [优化] 使用 slog 替代 fmt 进行日志输出 2026-05-30 12:27:00 +08:00
ryan 8300d3ec1c [新增] 添加 WAF 规则组及其绑定的 API 支持,更新前端页面以集成 WAF 功能 2026-05-30 12:16:28 +08:00
ryan 290ddd7b51 [优化] 添加获取折叠访问日志 IP 概要的 API 和前端支持 2026-05-30 10:48:06 +08:00
ryan 5d7a4469ea [优化] 增加 noop apply 报告逻辑,确保在配置未变更时记录应用日志 2026-05-30 10:27:10 +08:00
ryan 4e339caa9a [优化] 增加对节点 IP 的自动探测,优先通过第三方 API 获取公网 IP 2026-05-30 10:19:47 +08:00
ryan 2a00d21987 [文档] Doc 2026-05-30 09:51:36 +08:00
ryan f086edda3b [文档] Doc 2026-05-29 12:00:09 +08:00
ryan 899b4e6068 [优化] 优化 Docker 部署命令,移除不必要的端口映射 2026-05-29 11:46:09 +08:00
ryan fa23cad9e9 [#12] Auto-update downloads and executes binary with no signature or checksum verification 2026-05-29 11:29:33 +08:00
ryan 806863f303 [修复] 修复 WS 连接下更新无法下发 2026-05-29 11:08:05 +08:00
ryan ab8e3d4705 [优化] 更新 openresty_observability_port 描述,增强健康检查逻辑,使用 stub_status 代替 openresty -t 2026-05-29 10:52:25 +08:00
ryan fe7f7da537 [优化] 更新 openresty_observability_port 描述,增强健康检查逻辑,使用 stub_status 代替 openresty -t 2026-05-29 10:50:13 +08:00
ryan 944b98d4d0 [新增] 实现节点强制同步功能,允许通过 API 请求强制同步配置 2026-05-29 10:44:14 +08:00
ryan 32dc7ef68e [新增] 实现节点强制同步功能,允许通过 API 请求强制同步配置 2026-05-29 10:34:25 +08:00
ryan 32762fdf3c [新增] 实现安全兜底配置功能,允许在无历史配置时启动 OpenResty 并返回 503 状态 2026-05-29 10:07:49 +08:00
ryan 79ed8fd6ab [新增] 实现 Agent WebSocket 连接升级功能,支持状态上报和配置广播 2026-05-29 09:52:34 +08:00
ryan 4257b6fd5a [优化] 移除 Docker 运行命令中的数据卷挂载 2026-05-29 09:39:49 +08:00
ryan 8dfe31c1c5 [新增] 补充旧版本 agent 卸载脚本 2026-05-29 09:13:31 +08:00
ryan 37486eb0c9 [优化] 增强 OSCommandRunner 的命令执行逻辑,添加临时文件处理和详细日志记录 2026-05-28 23:50:07 +08:00
ryan 462deb4820 [新增] 添加 Docker 安装命令构建逻辑并更新节点详情页面 2026-05-28 23:41:18 +08:00
ryan f8509eed26 [优化] 优化对主配置路径的存在性检查以增强健康检查逻辑 2026-05-28 23:29:01 +08:00
ryan 6e0b6df314 [新增] 添加 MIME 类型支持和更新 Docker Compose 配置 2026-05-28 23:20:53 +08:00
ryan 21962db3bf [新增] 添加 Docker Compose 2026-05-28 23:15:04 +08:00
ryan b0117b7c84 [新增] 同步更新英文版文档 2026-05-28 23:00:48 +08:00
ryan 95d58eb724 [新增] Agent 架构调整, 采用集成镜像方式 2026-05-28 22:59:50 +08:00
ryan c856faca50 [新增] 更新文档 2026-05-28 22:56:39 +08:00
ryan 5a0821274b [新增] 更新文档 2026-05-28 22:50:24 +08:00
ryan b69bdf838d [新增] 优化版本号生成逻辑,确保使用最大日序列号 2026-05-26 21:29:03 +08:00
ryan c35eb749c9 [新增] 添加转换上传的 TLS 证书为 ACME 管理证书的功能 2026-05-26 21:16:03 +08:00
ryan e3c84c017a [新增] 添加转换上传的 TLS 证书为 ACME 管理证书的功能 2026-05-26 21:06:50 +08:00
ryan 112694f860 [新增] 添加删除预发布标签清理工作流 2026-05-26 11:18:20 +08:00
ryan bd69ac51b5 [新增] 添加预览预发布标签清理工作流 2026-05-26 11:15:48 +08:00
ryan 46fb1a2b79 Revert "[优化] 添加基本鉴权支持,更新相关逻辑以生成 htpasswd 文件"
This reverts commit c9a532db65.
2026-05-26 10:57:21 +08:00
ryan c9a532db65 [优化] 添加基本鉴权支持,更新相关逻辑以生成 htpasswd 文件 2026-05-26 10:41:41 +08:00
ryan be68b581e9 [优化] 添加基础鉴权支持,包括用户名和密码字段,并更新相关逻辑和测试用例 2026-05-26 10:36:00 +08:00
ryan 8853933adc [优化] 修复基本鉴权逻辑,确保 Lua 块正确关闭并添加相关测试用例 2026-05-26 10:29:40 +08:00
ryan 048f6e4535 [优化] 调整 Nginx 配置生成逻辑,优化访问控制和代理位置块的渲染顺序 2026-05-26 10:17:37 +08:00
ryan 7b9c8996f9 [优化] 更新数据库模式版本至12,添加基础鉴权字段支持 2026-05-26 09:57:56 +08:00
ryan 8947bdc8d8 [优化] 更新 PublishConfigVersion 函数以支持强制发布选项,并调整相关调用 2026-05-26 09:56:18 +08:00
ryan baef42f920 [优化] 添加基础鉴权配置支持,包括用户名和密码 2026-05-26 09:37:42 +08:00
ryan dd58e0df66 [优化] 添加 OpenRestyResolvers 配置支持自定义 DNS 解析器 2026-05-26 09:18:52 +08:00
ryan bddf641bf1 [优化] 添加 OpenRestyResolvers 配置支持自定义 DNS 解析器 2026-05-26 09:10:08 +08:00
ryan 83a426d3d6 [优化] 添加清理历史快照功能 2026-05-25 16:41:07 +08:00
ryan 4f698be0a5 [优化] 更新 CORS 配置以支持动态源和凭证 2026-05-25 16:29:07 +08:00
ryan e9fb331214 [fix] 修复构建 2026-05-25 16:22:51 +08:00
ryan 5d6d68d0a1 [优化] 更新 Go 版本要求至 1.25+ 2026-05-25 16:18:07 +08:00
ryan c8e2c3620e [优化] 结构优化 2026-05-25 16:12:22 +08:00
ryan af8e9b477e [优化] 导航调整 2026-05-25 16:05:56 +08:00
ryan 314f6fd3f4 [优化] 移除注册相关功能的代码和配置 2026-05-25 16:03:38 +08:00
ryan 7eee788720 [优化] UI improve 2026-05-25 15:47:56 +08:00
ryan f6e4967a9a [新增] 添加 ACME 和 DNS 账号管理功能,支持证书申请与续期 2026-05-25 14:56:05 +08:00
ryan 7afe4e5d78 [新增] 添加 ACME 和 DNS 账号管理功能,支持证书申请与续期 2026-05-25 14:53:22 +08:00
Ryan c6a055d5d3 Update README.md 2026-05-13 14:00:53 +08:00
ryan 9a89428405 [修复] 个人设置查看第三方认证源与增加解绑功能 2026-05-13 12:09:15 +08:00
ryan 370d58ac4d OIDC 文档 2026-05-13 11:46:33 +08:00
ryan e85df49962 OIDC 2026-05-13 11:44:01 +08:00
ryan 856e3f46d2 gitignore 2026-05-13 10:21:18 +08:00
ryan 2d6cc908f5 优化文档 2026-05-09 18:10:19 +08:00
ryan 797a15ae70 vite-press init 2026-05-09 17:37:06 +08:00
ryan 8730f99fef UPDATE README 2026-04-26 10:25:44 +08:00
ryan 8ad4defcc7 [功能] POW 有效期优化 2026-04-25 20:49:08 +08:00
ryan d3d32a6b6b [fix] anubis 2026-04-25 19:48:30 +08:00
ryan 9c57ec2f5c [功能] POW 集成 2026-04-19 23:00:57 +08:00
ryan f8c1fe804d [修复] 修复github登录问题 2026-04-01 10:44:34 +08:00
ryan 89489c8488 [功能] 添加卸载脚本以支持彻底卸载 OpenFlare Agent 并清空本地数据 2026-04-01 10:24:05 +08:00
ryan d425e34f71 [优化] 更新默认服务器块,添加 HTTPS 支持并启用 SSL 握手拒绝 2026-04-01 10:02:40 +08:00
ryan 49472b54bf [功能] 添加域名证书绑定支持,允许为每个域名单独选择证书并优化相关逻辑 2026-04-01 09:57:40 +08:00
ryan a002d98f3a [优化] 更新域名列表输入组件,优化按钮样式并支持自定义容器类型 2026-04-01 09:34:17 +08:00
ryan 77457250cf [功能] 更新域名列表输入组件,支持为每个域名选择证书并优化相关逻辑 2026-04-01 09:27:41 +08:00
ryan cff815bd47 [功能] 支持为 HTTPS 启用多个证书,更新相关逻辑和测试 2026-03-31 14:16:32 +08:00
ryan 97fa56b1af [功能] 添加域名列表输入组件,支持动态建议和批量输入 2026-03-31 13:29:30 +08:00
ryan 355791f2e4 [优化] 文本优化 2026-03-31 13:16:00 +08:00
ryan 65ecc27907 [优化] 文本优化 2026-03-31 13:13:38 +08:00
ryan c2184affed [功能] 添加批量更新选项接口,支持一次性更新多个配置项,更新相关逻辑和测试 2026-03-30 16:48:10 +08:00
ryan 7d9190a8d8 [功能] 禁用新用户注册功能,更新相关逻辑和测试 2026-03-30 16:01:25 +08:00
ryan 4b1e75f86b [修改] 文本优化 2026-03-30 15:46:54 +08:00
ryan 25fe178cb2 [功能] 添加网站创建抽屉组件,支持域名和上游地址输入,更新相关逻辑和测试 2026-03-30 15:07:59 +08:00
ryan 383a039338 [功能] 接口与校验改造 2026-03-30 14:45:28 +08:00
ryan e39a8995f6 [功能] 添加站点名称和多域名支持到代理路由,更新相关逻辑和测试 2026-03-30 14:11:30 +08:00
ryan 894745d43a [功能] 优化节点 IP 解析逻辑,优先使用公网地址并添加相关测试 2026-03-30 13:09:30 +08:00
ryan 39d54c2fe4 [文档] 升级代理路由规则为网站配置,支持多域名绑定与共享设置 2026-03-30 11:13:57 +08:00
ryan fdadd76945 [功能] 添加抽屉组件并重构代理路由页面,优化规则创建体验 2026-03-30 10:32:49 +08:00
ryan 6e109fd3f7 [功能] 移除前端开发规范中的禁止项和测试交付要求,简化文档内容 2026-03-27 13:55:15 +08:00
ryan f14ba66a11 [功能] 更新组件库hero3.0.1 2026-03-27 13:34:35 +08:00
ryan 6b1d2e8af9 [功能] 移除代理路由页面中的缓存和请求头列,简化显示内容 2026-03-27 11:18:37 +08:00
ryan a0fff76fcb [?] update 2026-03-24 19:02:00 +08:00
ryan 4fa8f073a3 [功能] 重构代理路由页面,优化输入组件和样式 2026-03-20 23:29:18 +08:00
ryan a6787ac30d [功能] 添加新的输入、文本区域、标签和开关组件,优化样式和功能 2026-03-20 23:15:35 +08:00
ryan 2c87254bb3 [功能] 更新代理路由页面,集成新的输入和选择组件,优化域名选择逻辑 2026-03-20 22:52:57 +08:00
ryan 1fd4b22b9c [功能] 重构代理路由页面的单元测试,优化fetch模拟和输入验证逻辑 2026-03-20 22:37:02 +08:00
ryan be9744abc6 [功能] 重构代理路由页面的单元测试,优化fetch模拟和输入验证逻辑 2026-03-20 22:20:13 +08:00
ryan afd891f0f6 [功能] 添加源站管理功能,包括源站的创建、更新、删除及列表展示 2026-03-20 20:01:42 +08:00
ryan edd31da527 [功能] 添加代理路由页面的单元测试,支持通配符和精确域名的规则生成 2026-03-20 19:42:29 +08:00
ryan 7b9377eb21 [文档] 文档更新 2026-03-19 21:17:23 +08:00
ryan dc72c78b7f [优化] 界面优化 2026-03-19 21:00:43 +08:00
ryan 9eeccb5fc6 [功能] 添加数据库观测数据清理功能,支持手动和自动清理策略 2026-03-19 20:48:45 +08:00
ryan a1b3204204 [功能] 添加遗留观察性索引和表的删除逻辑,优化数据库迁移过程 2026-03-19 20:26:18 +08:00
ryan 8737e146d1 [修改] 分片逻辑修改为基于ID 2026-03-19 17:57:30 +08:00
ryan ae72f2da9a [功能] 实现数据库版本管理与迁移逻辑,确保数据库结构与版本一致性 2026-03-19 16:45:22 +08:00
ryan f26fcd028e [功能] 添加迁移遗留观察性列的功能,支持从 raw_json 填充 metadata_json 2026-03-19 16:31:05 +08:00
ryan dd49b2777d [功能] 实现节点访问日志的分片支持,优化日志查询和管理逻辑 2026-03-19 16:19:46 +08:00
ryan 891cb7b9c1 [优化] 更新 swaggo/swag 依赖版本至 v1.16.4,并更新文档生成指令 2026-03-19 09:28:57 +08:00
ryan 007b1d8929 [优化] 移除 OpenRestyResolvers 配置,统一上游渲染为带 keepalive 的 named upstream 2026-03-18 23:24:37 +08:00
ryan 782304012c [功能] 添加节点健康事件清理功能,优化节点观测数据管理 2026-03-18 23:11:48 +08:00
ryan 4945b8b44f [修复] 更新数据库字段类型为text,添加消息截断逻辑以支持更长的消息内容 2026-03-18 22:57:14 +08:00
ryan 1fbe156a7c [功能] 添加支持多个上游地址,优化代理路由配置和负载均衡逻辑 2026-03-18 22:24:05 +08:00
ryan c844f4c784 [优化] 更新HTTPS配置,启用reuseport和epoll事件模型,优化性能 2026-03-18 22:15:48 +08:00
ryan 67197220ae [优化] 添加命名上游支持,优化代理配置生成逻辑 2026-03-18 22:15:48 +08:00
ryan 0cb4e06b11 [功能] 添加缓存策略支持,优化代理路由配置和验证逻辑 2026-03-18 22:08:55 +08:00
ryan c84d5bd540 [功能] 更新OpenResty配置,添加连接升级映射和默认服务器块,优化HTTPS和HTTP重定向逻辑 2026-03-18 22:02:32 +08:00
ryan 51a875ab50 [功能] 更新HTTPS配置,启用HTTP/2支持并优化相关文档 2026-03-18 21:34:47 +08:00
ryan ed38aa1d79 [功能] 优化仪表板概览数据结构,添加压缩和规范化功能 2026-03-18 15:37:14 +08:00
ryan 9ced0eb6f0 [功能] 添加获取配置版本详情的API,优化配置版本管理逻辑 2026-03-18 15:19:39 +08:00
ryan 4433ab5af4 [功能] 添加应用日志分页查询和清理功能,优化日志管理逻辑 2026-03-18 14:34:25 +08:00
ryan 0ab0145f8d [修复] 精简access-logs-page和performance-page组件的导入和属性设置 2026-03-18 14:05:17 +08:00
ryan 7a22167997 [功能] 添加OpenRestyResolvers支持,优化DNS解析器配置和验证逻辑 2026-03-18 13:52:58 +08:00
ryan e2202d1456 [功能] 添加对应用结果的警告支持,优化配置激活和回滚逻辑 2026-03-18 13:09:28 +08:00
ryan 2ece13d08e [功能] 重构Lua和证书文件管理逻辑,优化文件同步和清理机制 2026-03-18 12:28:41 +08:00
ryan 4c4f7f9ced [功能] 添加OpenResty解析器指令支持,增强配置模板和运行时解析能力 2026-03-18 11:16:56 +08:00
ryan bb284c2f37 [功能] 优化DockerExecutor的Reload方法,添加挂载源验证并支持在运行中的容器内重载 2026-03-18 10:46:19 +08:00
ryan 915be62ca1 [修复] 添加对Docker挂载源的验证,确保配置文件和目录的有效性 2026-03-18 10:38:45 +08:00
ryan 29a6fedbe9 [功能] 添加访问日志折叠、IP汇总和趋势查询功能,并实现日志清理功能 2026-03-18 10:33:35 +08:00
ryan 2e875f583b [功能] 调整访问日志分页大小为20,并更新相关组件以支持动态分页 2026-03-18 09:52:39 +08:00
ryan 70da7c772c 更新README 2026-03-17 19:48:41 +08:00
ryan f1d469c18f 更新README 2026-03-17 19:23:21 +08:00
ryan 244a43ba77 更新README 2026-03-17 19:22:36 +08:00
ryan 5e8007da17 更新README 2026-03-17 15:46:48 +08:00
ryan b60ebf231c [功能] 添加 Docker Compose 配置以支持 PostgreSQL 数据库和 Openflare 服务 2026-03-17 14:06:47 +08:00
ryan cfd7c3d7ca [功能] 支持 PostgreSQL 数据库,添加数据库迁移逻辑并更新相关文档 2026-03-17 10:24:12 +08:00
ryan 2c17f3289b [重构] 将多个 API 接口的请求方法从 PUT 和 DELETE 更改为 POST,并更新相关路由 2026-03-17 09:57:38 +08:00
ryan 2cdb844010 [功能] 添加可选版本输入以支持自定义 Docker 镜像和发布版本 2026-03-16 12:53:01 +08:00
ryan 5e2503ca50 [修复] 更新代理配置以支持 SSL 服务器名称和主机头覆盖 2026-03-16 12:39:34 +08:00
ryan 4daf681eff [功能] 添加 origin_host 字段以覆盖回源请求的 Host 头 2026-03-16 12:20:02 +08:00
ryan ce08099de1 [修复] 移除不必要的日志记录 2026-03-15 22:47:41 +08:00
ryan 9f99f5cf0b [文档] 文档更新 2026-03-15 19:09:06 +08:00
ryan 7442d8dd58 [修复] 增加对未知子域名的请求返回404的处理 2026-03-15 19:04:50 +08:00
ryan 0a003034e4 [修复] 修复地图显示 2026-03-15 18:04:11 +08:00
ryan 6ffe76dfa4 [修复] 修复地图显示 2026-03-15 17:25:52 +08:00
ryan b2eb4befba [文档] 文档更新 2026-03-15 17:11:17 +08:00
ryan 5858e30af6 [修复] 修复遗漏文件 2026-03-15 17:07:00 +08:00
ryan d4e38ee6fb [版本] 更新版本号至1.0.x 2026-03-15 16:56:54 +08:00
ryan 6f0867948e [优化] 精简多个组件的描述文本 2026-03-15 16:55:54 +08:00
ryan 6caee17e2d [修复] 修复组件显示问题 2026-03-15 16:43:52 +08:00
ryan a255f3fa33 [优化] 优化静态导出流程,添加重命名功能以简化目录处理 2026-03-15 16:32:56 +08:00
ryan 34cf8bae63 [文档] Apache-2.0 2026-03-15 16:26:56 +08:00
ryan 32d90ba641 [优化] 改名 2026-03-15 16:26:56 +08:00
ryan d68773c554 [优化] 添加来源热度显示功能,更新相关数据结构和前端组件 2026-03-15 16:00:44 +08:00
ryan 6d5d47c216 [优化] 添加 .gitignore 文件,忽略 data 目录;调整仪表盘组件布局 2026-03-15 15:35:56 +08:00
ryan 7c89ad8c7b [优化] 添加访问日志地区解析功能,更新相关数据结构和前端展示 2026-03-15 15:21:00 +08:00
ryan 640dd6c82c [优化] 新增 GeoIP 测试功能,更新相关 API 路由和前端组件 2026-03-15 14:43:28 +08:00
ryan 5bb25d2203 [优化] 添加访问日志汇总功能,更新相关数据结构和测试用例 2026-03-15 14:19:21 +08:00
ryan 25004fefae [优化] 添加度量工具函数和测试用例,优化仪表盘和趋势图组件 2026-03-15 14:11:16 +08:00
ryan 072930d55b Revert "[优化] 添加 WebSocket 连接升级映射,更新主配置模板和测试用例"
This reverts commit 4024c85a0c.
2026-03-15 14:00:48 +08:00
ryan 4024c85a0c [优化] 添加 WebSocket 连接升级映射,更新主配置模板和测试用例 2026-03-15 13:57:32 +08:00
ryan b1be887287 [优化] 更新开发环境配置,统一代理 HTTP 和 WebSocket 请求 2026-03-15 13:41:53 +08:00
ryan 6e1eac2c86 [优化] 添加 WebSocket 升级支持,更新配置和测试用例 2026-03-15 13:29:19 +08:00
ryan 3eb78ebfca [修复] 修复升级版本检测 2026-03-15 13:24:01 +08:00
ryan f4d36be2e6 Refactor observability configuration and support files
- Introduced `filterCertificateSupportFiles` function to filter support files for certificates in `agent.go`.
- Updated placeholder constants in `config_version.go` to reflect changes from support directory to certificate directory.
- Modified tests in `https_phase1_test.go` to align with new directory structure and removed obsolete checks for observability Lua scripts.
- Removed observability assets from `openresty_observability_assets.go` and created a new file `observability_assets.go` in `atsf_agent/internal/nginx` to manage observability Lua scripts.
- Updated documentation to reflect changes in configuration paths for certificates and Lua scripts.
- Ensured that the agent writes to the new `cert_dir` and `lua_dir` instead of the old `support_dir`.
2026-03-15 12:32:24 +08:00
ryan e1efbf3868 [优化] 增强流量可观测性,添加请求长度和字节发送字段;更新支持文件权限设置 2026-03-15 12:13:07 +08:00
ryan eb9a2a8814 [优化] 添加服务器升级日志的WebSocket流 2026-03-15 11:57:52 +08:00
ryan ae70ba1cce [优化] 环境分离 2026-03-15 11:49:21 +08:00
ryan 93bd3704e7 [优化] 安装脚本更新 2026-03-15 00:09:52 +08:00
ryan 932f2fc6fa [优化] 代码优化 2026-03-14 23:56:34 +08:00
ryan 3d52ddc933 [优化] 引如缓存库ristretto 2026-03-14 23:54:31 +08:00
ryan 37deb84986 [修复] 修复网站添加无法输入 2026-03-14 23:33:43 +08:00
ryan 4dec7f8133 [优化] 工作流触发调整 2026-03-14 23:33:09 +08:00
ryan 78a0b99011 [优化] 交互优化 2026-03-14 23:32:32 +08:00
ryan 0a28d7bb2a feat: 添加响应式地图缩放和容器大小调整功能,优化性能页面模板样式 2026-03-14 23:23:49 +08:00
ryan 8d406f5ade feat: 添加访问日志分页功能,优化相关接口和前端组件 2026-03-14 23:18:49 +08:00
ryan b7d38590ba feat: add access log functionality and related components
- Introduced NodeAccessLog model and corresponding database migrations.
- Implemented access log retrieval in the service layer.
- Created API endpoint for accessing logs with appropriate security measures.
- Developed frontend components for displaying access logs, including filtering and summary statistics.
- Updated observability buffer to include access logs and ensure proper merging and retention.
- Enhanced traffic report and observability tests to validate new access log features.
- Updated documentation to reflect changes in access log handling and data retention policies.
2026-03-14 18:21:48 +08:00
ryan e25b41fd75 feat: add observability buffer for heartbeat data
- Introduced `ObservabilityBufferStore` to manage buffered observability records.
- Implemented methods for upserting, replaying, and acknowledging records in the buffer.
- Enhanced `AgentNodePayload` and `NodePayload` to include buffered observability data.
- Updated tests to cover new functionality for observability buffering.
- Modified existing services to persist and handle buffered observability data during heartbeats.
- Added configuration options for observability buffer path and replay minutes.
- Updated documentation to reflect new observability features and configurations.
2026-03-14 17:57:50 +08:00
ryan 7628397785 feat: 添加可观察性监听地址支持,优化配置和测试用例 2026-03-14 17:42:06 +08:00
ryan 4be44733e8 feat: replace cert_dir with support_dir in agent and server configurations
- Updated README.md to reflect the new support_dir for auxiliary files.
- Refactored agent main.go to use support_dir instead of cert_dir.
- Modified config.go to replace cert_dir with support_dir and added legacy support.
- Adjusted config tests to validate support_dir usage.
- Changed nginx manager to utilize support_dir for file paths.
- Updated server configuration to use support_dir for SSL certificates.
- Revised documentation to clarify the new configuration parameters.
- Enhanced security checks for support file paths to prevent traversal attacks.
2026-03-14 17:16:54 +08:00
ryan bdfa80f214 feat: enhance observability metrics collection and reporting
- Updated BuildSnapshot function to include OpenResty metrics.
- Enhanced BuildTrafficReport to utilize managed OpenResty metrics.
- Introduced new functions for parsing access logs in both JSON and combined formats.
- Added tests for traffic report generation from combined access logs.
- Implemented local observability metrics collection from OpenResty.
- Created Lua scripts for OpenResty to gather observability data.
- Updated configuration documentation to include new observability port and settings.
- Added support for OpenResty observability in the server configuration.
2026-03-14 17:07:47 +08:00
ryan 8cc839669c 添加节点详情页选项卡功能,支持在数据看板和节点信息之间切换,优化用户体验 2026-03-14 16:50:35 +08:00
ryan e801d2bb32 [优化] 界面优化 2026-03-14 16:33:33 +08:00
ryan 271ac772c4 重构仪表板组件,移除冗余的 OverviewMetric 组件,优化布局和样式,增强可读性 2026-03-14 16:28:56 +08:00
ryan 590e1d7a8a 优化仪表板页面,移除冗余组件,简化代码结构 2026-03-14 16:17:43 +08:00
ryan ee104f0ad8 调整 WorldStageMap 组件的布局和样式,优化地图显示效果 2026-03-14 16:07:01 +08:00
ryan fe3c6312f9 Refactor dashboard overview: remove active alerts and lagging nodes from summary, update tests and components accordingly
- Removed active alerts and lagging nodes from DashboardSummary and related types.
- Updated DashboardOverview component to reflect changes in data structure.
- Adjusted tests to align with the new dashboard overview structure, ensuring no active alerts are displayed.
- Simplified the dashboard metrics and risk signals, focusing on essential health and capacity metrics.
- Enhanced the WorldStage component to present updated traffic and capacity information.
- Removed unused alert-related functions and components to streamline the codebase.
2026-03-14 16:00:41 +08:00
ryan f33e9514dc feat: add geo_manual_override to NodeItem and NodeMutationPayload
fix: update default GeoIPProvider to ipinfo in settings

feat: implement WorldStageMap component for visualizing node health on a world map

feat: create NodeEditorModal for editing node details with manual geo location options

docs: update app-config documentation to reflect changes in GeoIPProvider default value
2026-03-14 15:50:02 +08:00
ryan 4a50762092 feat(settings): add GeoIP provider selection to settings page
- Introduced a new ResourceSelect component for selecting the GeoIP provider.
- Added options for disabling GeoIP, using MaxMind mmdb, and various external GeoIP services.
- Implemented saving functionality for the selected GeoIP provider with a corresponding button.
- Updated default operation fields to include GeoIPProvider with a default value of 'disabled'.
2026-03-14 15:19:46 +08:00
ryan f6dd7df55c [优化] geoip 工具 2026-03-14 15:15:34 +08:00
ryan 83f461efd0 Refactor code structure for improved readability and maintainability 2026-03-14 15:03:15 +08:00
ryan 605a70b428 [优化] 工具整理 2026-03-14 15:03:05 +08:00
ryan f1476610f6 feat(dashboard): enhance dashboard overview and world stage components
- Implemented normalization for dashboard overview data to handle null or undefined values.
- Added ECharts map integration for visualizing node data on a world map.
- Introduced loading and error states for the map component.
- Updated UI components to support dark and light themes.
- Enhanced testing for dashboard overview to cover empty states and data fetching.
- Added type definitions for echarts-maps module.
- Updated package dependencies to include echarts-maps.
2026-03-14 14:45:04 +08:00
ryan 4bfad019d0 feat: 更新节点详情页,添加健康事件过滤和流量分析功能 2026-03-14 14:31:51 +08:00
ryan 91cafa99b1 feat(node-detail): 完成节点详情页第二轮重构,增加核心卡片与趋势展示 2026-03-14 13:09:23 +08:00
ryan fc3db065db feat: add geo metadata to nodes and dashboard
- Extend DashboardNodeHealth and NodeItem interfaces to include geo_name, geo_latitude, and geo_longitude.
- Update NodeDetailPage and NodesPage components to handle geo metadata in forms and payloads.
- Implement validation for geo fields in NodesPage using Zod.
- Enhance dashboard overview tests to include geo metadata for nodes.
- Create WorldStage component to visualize node health and geo locations on a world map.
- Update design and development documentation to reflect the addition of geo metadata for nodes.
2026-03-14 12:59:22 +08:00
ryan b991e2e635 feat: 添加节点和仪表板的流量分析、趋势和健康状态功能 2026-03-14 12:36:29 +08:00
ryan 39a98863f4 [文档] 更新开发进度 2026-03-14 12:32:21 +08:00
ryan 15fc2f533f feat(nodes): 添加健康事件筛选功能和节点列表过滤描述 2026-03-14 12:29:17 +08:00
ryan 0ad26e3904 feat(node-detail): 添加主状态码、Top Domain 和已恢复事件统计信息 2026-03-14 12:24:08 +08:00
ryan 7ff642ca7f feat: 更新发布工作流以删除 Windows 资产构建 2026-03-14 12:23:52 +08:00
ryan 50b7c798ea feat: update workflow triggers to use main branch for push events 2026-03-14 12:13:43 +08:00
ryan 497289b462 feat(dashboard): enhance risk summary and peak node display in dashboard overview
- Added risk summary metrics including critical alerts, high CPU/memory nodes, and lagging nodes to the dashboard overview.
- Introduced peak node metrics for busiest and riskiest nodes with detailed display.
- Updated types for dashboard overview to include risk and peak summaries.
- Implemented new components for displaying risk signals and peak cards.
- Enhanced unit tests to cover new risk and peak metrics in the dashboard overview.

fix(update): improve error messages for server upgrade processes

- Translated and clarified error messages related to server upgrades and binary uploads.
- Ensured consistent error handling and messaging throughout the upgrade process.

test(update): refactor test utilities for server upgrade

- Renamed roundTripFunc to serverUpdateRoundTripFunc for clarity in test code.
- Updated test cases to utilize the new naming convention.

feat(node-detail): add traffic structure distribution and health event timeline

- Implemented traffic breakdown visualization for status codes and top domains in node detail page.
- Added health event timeline to display active and resolved events for better observability.

feat(rank-chart): create reusable rank chart component

- Developed a new RankChart component for visualizing ranked data distributions.
- Integrated RankChart into node detail page for displaying status code and domain distributions.
2026-03-14 12:12:38 +08:00
ryan 16bb9e5879 feat: add 24-hour traffic and capacity trend charts to dashboard and node detail pages
- Implemented TrendChart component for visualizing traffic and capacity trends.
- Enhanced DashboardOverview and NodeDetailPage components to display 24-hour request and capacity trends.
- Updated types to include traffic and capacity trend data structures.
- Created observability trends service to aggregate traffic and capacity data.
- Added tests for traffic report generation and trend data handling.
- Introduced new dependencies for charting (echarts and echarts-for-react).
2026-03-14 11:48:05 +08:00
ryan e94132a7d9 feat: 添加对主分支的推送触发支持 2026-03-14 11:47:27 +08:00
ryan a0aefce486 feat: add dashboard overview health summary 2026-03-14 11:44:28 +08:00
ryan ebd452cb36 feat: refresh node detail observability view 2026-03-14 11:44:28 +08:00
ryan 1f294d72b9 feat: 添加节点可观测性查询功能,支持获取节点的系统配置、指标快照、流量报告和健康事件 2026-03-14 11:44:28 +08:00
ryan d9d02e749a feat: add phase-one node observability ingestion 2026-03-14 11:44:28 +08:00
ryan 295340dc4b [优化] 更新开发计划文档,明确第六版目标与实施步骤,增加节点数据采集与访问分析能力 2026-03-14 11:44:28 +08:00
ryan df6b1f0fed 【更新】 V6 2026-03-14 11:26:52 +08:00
ryan ff6e4bb5b8 [优化] 添加服务器升级日志功能,记录升级状态和日志信息;更新相关类型定义以支持日志展示 2026-03-14 11:26:06 +08:00
ryan 8beaed85e3 [优化] 在 PublishPreviewCard 组件中添加当前活动主配置与待处理主配置的比较,增强用户对配置变更的理解;在 ProxyRoutesPage 中提取 hasConfigChanges 函数以简化变更检查逻辑 2026-03-13 17:38:06 +08:00
ryan e4b62eba85 Merge branch 'main' of https://git.arctel.net/Arctel/ATSFlare 2026-03-13 17:16:13 +08:00
ryan 2756178355 [优化] 将 OpenRestyProxyRequestBufferingEnabled 设置为 false,并更新相关文档 2026-03-13 17:16:08 +08:00
ryan 8f38041af3 [优化] 更新版本升级模态框的上传状态提示,改善用户体验 2026-03-13 17:12:37 +08:00
ryan 6cb1ce5392 [优化] 界面优化 2026-03-13 17:07:08 +08:00
ryan 5b7175bfaa [优化] 调整 ToggleField 组件的标签样式,使其更符合布局需求 2026-03-13 17:01:57 +08:00
ryan 139eacab89 [优化] 界面优化 2026-03-13 16:58:19 +08:00
ryan c33ce96176 Merge remote-tracking branch 'origin/main' 2026-03-13 16:34:27 +08:00
ryan 4b4b6bf80e feat: 将日志级别从 Info 调整为 Debug,以减少生产环境中的日志噪声 2026-03-13 16:33:56 +08:00
ryan 3c09a1d608 Merge remote-tracking branch 'origin/main' 2026-03-13 16:13:59 +08:00
ryan 23df162eda [优化] 界面优化 2026-03-13 16:13:26 +08:00
ryan 8aef32c0cc feat: 重构响应处理,添加通用响应函数以简化代码 2026-03-13 16:08:59 +08:00
ryan b8488785f8 Merge branch 'main' of https://git.arctel.net/Arctel/ATSFlare 2026-03-13 16:02:05 +08:00
ryan 8c3dd75802 feat: 添加支持文件路径处理函数,增强证书目录路径验证和日志记录功能 2026-03-13 16:01:52 +08:00
ryan 17f88917f4 feat: 添加整理维护期改进计划文档,集中推进代码质量与安全治理 2026-03-13 15:44:26 +08:00
ryan cf105ff042 [优化] 日志优化 2026-03-13 15:27:12 +08:00
ryan 6d58d00c9d feat: 重构日志处理,使用slog包替换原有日志函数,增强日志记录能力 2026-03-13 15:25:33 +08:00
ryan 79a7e024d3 feat: 重构日志处理,使用自定义文本处理器增强日志格式和属性支持 2026-03-13 15:12:36 +08:00
ryan aeb7118b30 Refactor logging to use slog package across the application
- Replaced standard log package with log/slog in httpclient, nginx manager, sync service, updater, and other components for structured logging.
- Introduced environment variable `LOG_LEVEL` to control logging levels (debug, info, warn, error).
- Updated documentation to reflect changes in logging configuration and requirements.
- Added a new logging setup function in the ats_agent internal package to initialize the slog logger.
2026-03-13 14:53:51 +08:00
ryan 06d4831d55 feat: 添加证书PEM和私钥PEM复制功能,优化证书详情模态框 2026-03-13 14:20:51 +08:00
ryan 4ba479576b feat: 添加获取TLS证书内容的API和前端支持,更新相关组件 2026-03-13 14:14:53 +08:00
ryan 63c3204726 feat: 添加TLS证书页面,支持证书的查看、导入、编辑和删除功能 2026-03-13 14:06:38 +08:00
ryan a0f920cf05 feat: 添加TLS证书详情和编辑功能,更新相关API和前端组件 2026-03-13 13:45:29 +08:00
ryan 55d1a3f2c8 [优化] 更新缓存控制逻辑,增加静态资源和文档请求的处理 2026-03-13 13:34:04 +08:00
ryan dbecf690f5 feat: add website detail page and certificate import functionality
- Implemented `WebsiteDetailRoute` to handle website details based on search parameters.
- Created `WebsiteDetailPage` component to display detailed information about a website, including associated certificates and managed domains.
- Added `CertificateImportModal` for importing TLS certificates either manually or via file upload.
- Developed `WebsiteEditorModal` for editing website details and binding certificates.
- Introduced schemas for managing domain and certificate imports using Zod for validation.
- Added utility functions for handling domain and certificate operations, including error handling and payload transformations.
2026-03-13 13:15:33 +08:00
ryan 6b91dd8f3d [修复] GIN日志打印等级统一 2026-03-13 12:25:51 +08:00
ryan c3f8bd20b3 [优化] 增加日志等级配置,更新服务器启动日志信息 2026-03-13 11:30:42 +08:00
ryan 3fb4cec99c [优化] 接口优化, 心跳请求返回规则摘要 2026-03-13 11:22:37 +08:00
ryan b33923d5f7 [优化] 界面优化 2026-03-13 10:47:00 +08:00
ryan 8a46a66bf5 [优化] 界面优化 2026-03-13 09:21:36 +08:00
ryan e34446bce8 [优化] 增加 OpenResty 配置选项和性能页面工具提示 2026-03-13 00:18:09 +08:00
ryan a693d98457 [优化] 增加 OpenResty 配置选项和性能页面工具提示 2026-03-13 00:14:39 +08:00
ryan 2ad6e9a2d0 [优化] 界面优化 2026-03-13 00:10:55 +08:00
ryan b05bd608f2 [优化] 导航优化 2026-03-13 00:02:10 +08:00
ryan e736bcd51a feat: unify website and certificate management 2026-03-13 00:01:33 +08:00
ryan 238546b358 feat: split openresty settings into performance page 2026-03-12 23:51:57 +08:00
ryan 97b67720bc [优化] 界面优化 2026-03-12 23:37:18 +08:00
ryan 2268f408a8 chore: remove frontend lint warnings 2026-03-12 23:29:39 +08:00
ryan f47749c103 Merge commit '63219e4802e99d87b0b3ce28349aa464d4331456' 2026-03-12 23:24:23 +08:00
ryan be09f0a0ac [整理] 整理 2026-03-12 23:16:12 +08:00
ryan 4e19cd1565 feat: 添加 RuntimeRouteConfigPath 支持,优化 Nginx 配置管理 2026-03-12 23:09:15 +08:00
ryan a5da53a2fb feat: 更新 agent.json 配置,确保主配置路径为绝对路径,并添加 agent 状态文件 2026-03-12 23:00:18 +08:00
ryan eafcac87a4 feat: 增加 DockerExecutor 运行容器时挂载管理文件的测试用例 2026-03-12 22:52:48 +08:00
ryan 594057be06 feat: enhance apply logs and config versions features
- Add detailed diff reporting for OpenResty options in TestPreviewAndDiffConfigVersion.
- Update ApplyLogsPage to display additional log details including checksums and support file counts.
- Introduce ConfigVersionSnapshotModal for viewing configuration snapshots.
- Refactor config versions page to utilize new snapshot modal and improve option diff display.
- Extend ApplyLogItem type to include checksums and support file count.
- Enhance NodeDetailPage to show target version details and checksums.
2026-03-12 22:49:18 +08:00
ryan 4c0466a92b feat: enhance configuration management with main and route configs
- Added `main_config` and `route_config` fields to ActiveConfigResponse and AgentConfigResponse for better configuration handling.
- Updated NginxManager interface to accept separate main and route configurations.
- Modified sync service to apply main and route configurations correctly.
- Enhanced tests to validate new configuration fields and their application.
- Updated ConfigVersion model to include main configuration.
- Improved rendering logic for main and route configurations in the service layer.
- Added UI components to display main configuration changes and OpenResty parameter changes.
2026-03-12 22:26:06 +08:00
ryan 88b99c5cd9 feat: Add OpenResty performance tuning options and validation
- Implemented validation for OpenResty configuration options including worker processes, connections, timeouts, and caching parameters.
- Added new validation functions for positive integers, booleans, and specific formats (e.g., size values, proxy buffers, cache levels).
- Updated the option management to include OpenResty parameters in the database and ensure they can be modified via the management interface.
- Enhanced the settings page to allow users to configure OpenResty parameters with appropriate validation and error handling.
- Added unit tests for the new validation logic to ensure correctness.
- Updated documentation to reflect the new OpenResty configuration options and their usage.
2026-03-12 22:16:03 +08:00
copilot-swe-agent[bot] 63219e4802 fix: skip version check for preview channel and add upload progress bar
Co-authored-by: Rain-kl <63696351+Rain-kl@users.noreply.github.com>
2026-03-12 12:40:20 +00:00
copilot-swe-agent[bot] 8cc409ce68 Initial plan 2026-03-12 12:28:50 +00:00
ryan bad716652a [优化] action 2026-03-12 19:56:55 +08:00
ryan 1b5a95c4c0 [优化] action 2026-03-12 19:56:01 +08:00
ryan 001f106b82 [修复] 升级功能问题修复 2026-03-12 19:16:20 +08:00
ryan ae6d871046 [优化] 设置ui优化 2026-03-12 17:06:54 +08:00
ryan 3a178473d5 [优化] 自动发现接入 2026-03-12 17:01:47 +08:00
ryan ee539d9b67 [优化] ui优化 2026-03-12 15:43:32 +08:00
ryan eb65c38c56 [文档] 设计文档V5 2026-03-12 15:22:39 +08:00
ryan 42ca18681c feat: openresty状态上报 2026-03-12 15:22:39 +08:00
ryan ecc71428e4 [优化] 界面优化 2026-03-12 13:26:52 +08:00
ryan b06d3ced4d feat: 移除版本升级模态框和仪表板顶部栏中的 startTime 属性 2026-03-12 13:07:54 +08:00
ryan 5a73a13028 feat: 更新版本升级模态框和状态徽章组件,支持通道切换和点击事件 2026-03-12 12:51:32 +08:00
ryan 6b607be6ae feat: enhance Docker image workflow with improved digest handling 2026-03-12 12:25:09 +08:00
ryan f50eb9adee feat: add support for release channels in version upgrade and node agent updates
- Introduced ReleaseChannel type to manage stable and preview releases.
- Updated DashboardTopbar to handle version upgrades based on selected release channel.
- Enhanced node detail page to allow manual checks for agent updates on stable and preview channels.
- Modified API endpoints to support fetching and upgrading based on release channels.
- Updated UI components to reflect changes in version checking and upgrade processes.
- Added tests for new functionality related to preview releases and agent updates.
2026-03-12 11:25:13 +08:00
ryan 93e43fb3b0 feat: add manual server binary upload and confirmation upgrade process
- Implemented UploadManualServerBinary endpoint for uploading server binaries and checking their versions.
- Added ConfirmManualServerUpgrade endpoint to confirm the upgrade with the uploaded binary.
- Updated API routes to include manual upload and upgrade confirmation.
- Enhanced service layer to handle manual binary uploads, version detection, and upgrade execution.
- Introduced new types for handling uploaded binary information.
- Updated frontend components to support manual binary upload and confirmation, including UI feedback for users.
- Modified documentation to reflect new manual upload and upgrade features.
2026-03-12 10:34:31 +08:00
ryan 29a0c64ba3 Refactor Nginx references to OpenResty throughout the codebase
- Updated all instances of "nginx" to "openresty" in log messages, error messages, and comments.
- Changed paths and Docker image names to reflect OpenResty usage.
- Modified test cases to align with OpenResty commands and configurations.
- Adjusted documentation to replace Nginx mentions with OpenResty, including setup instructions and configuration details.
- Ensured that version detection and runtime commands are consistent with OpenResty.
2026-03-12 09:48:38 +08:00
ryan 22eb563939 [文档] 文档更新 2026-03-11 23:49:40 +08:00
ryan 50b5cf02f5 [GIT] 更新 git 2026-03-11 23:07:29 +08:00
ryan fba1f8ea34 fix: 修复节点页面IP显示为'null',移除TLS证书页面标题 2026-03-11 23:05:03 +08:00
ryan 27f96fa353 Refactor code structure for improved readability and maintainability 2026-03-11 22:58:24 +08:00
ryan ca1c147dfe [文档] 文档更新 2026-03-11 22:31:12 +08:00
ryan ad59ea31fc feat: 更新Dockerfile中的Golang版本至1.23 2026-03-11 22:21:11 +08:00
ryan c240838692 feat: 增强更新功能,支持开发版本的自我升级检查;优化仪表板和应用日志页面的用户界面 2026-03-11 22:19:27 +08:00
ryan 6140ed1718 feat: implement server self-upgrade functionality
- Added update service to fetch the latest server release from GitHub.
- Implemented server upgrade scheduling and execution logic.
- Created platform-specific restart mechanisms for Unix and Windows.
- Introduced API endpoints for fetching the latest release and triggering upgrades.
- Developed UI components for version upgrade modal, including loading and error states.
- Updated documentation to reflect new upgrade features and instructions.
- Refactored settings types to streamline interface definitions.
2026-03-11 22:05:42 +08:00
ryan c0ec718563 [优化] 界面优化 2026-03-11 21:51:32 +08:00
ryan 0edc024cbd [优化] 界面优化 2026-03-11 19:59:42 +08:00
ryan db3f9b0c0a Refactor: Remove summary cards from various pages and update UI components
- Removed summary cards from Managed Domains, Nodes, Proxy Routes, TLS Certificates, Users pages to streamline UI.
- Updated Nodes and Proxy Routes pages to enhance action buttons and descriptions.
- Added new runtime configuration options for rate limiting in Settings page, allowing for dynamic updates.
- Updated documentation to reflect new runtime configurations and their effects.
- Enhanced unit tests for DashboardOverview to include mock data for comprehensive coverage.
2026-03-11 19:49:30 +08:00
ryan f7d18f712e [升级] 0.4.x 2026-03-11 19:27:46 +08:00
ryan df28fd44d5 [优化] 菜单调整 2026-03-11 19:26:51 +08:00
ryan d40291d6d2 Refactor code structure and remove redundant sections for improved readability and maintainability 2026-03-11 18:03:37 +08:00
ryan 1bca93b332 [重构] 资源路径使用单数 2026-03-11 17:27:37 +08:00
ryan 57ac8b6f7c [重构] 清除旧兼容层 2026-03-11 17:05:22 +08:00
ryan e1a9cc738e [重构] 前端改造v5 2026-03-11 17:01:24 +08:00
ryan 5d6898b633 feat: add managed domains and TLS certificates management features
- Implemented ApplyLogItem interface for logging purposes.
- Created API functions for managing domains including CRUD operations.
- Developed ManagedDomainsPage component for domain management UI with form handling and state management.
- Defined ManagedDomainItem and related types for domain data structure.
- Added API functions for managing TLS certificates including creation, deletion, and file import.
- Built TlsCertificatesPage component for certificate management with manual and file import options.
- Introduced types for TLS certificate items and mutation payloads.
2026-03-11 16:27:57 +08:00
ryan 256d4e80b3 feat: 移除过时的遗留路由重定向组件 2026-03-11 16:26:41 +08:00
ryan 747549bab9 feat: 优化用户和文件页面的数据处理,使用useMemo提升性能 2026-03-11 15:50:32 +08:00
ryan 4f8970ff5c feat: add user management features including user creation, editing, and role management
- Implemented user API functions for fetching, creating, updating, and managing users.
- Created a UsersPage component for displaying and managing users with search and pagination.
- Added types for user data and mutation payloads.
- Introduced a LegacyRouteRedirect component for handling legacy routes.
- Defined settings-related types for better type safety in settings management.
2026-03-11 15:40:41 +08:00
ryan 13cc880f67 [重构] 前端改造v3 2026-03-11 14:53:45 +08:00
ryan 5e963dc472 feat: 添加Swagger文档使用说明,更新开发规范中的Swagger约束 2026-03-11 14:35:39 +08:00
ryan 6166192667 feat: 更新Go版本至1.23.0,并添加Swagger测试用例以验证生成的Swagger规范 2026-03-11 14:29:43 +08:00
ryan d6f51c244e Add Swagger API documentation for ATSFlare Server with detailed endpoint definitions and models 2026-03-11 14:25:50 +08:00
ryan 4c8d60f8ab feat: 重构密码重置流程,整合请求和确认表单,优化用户体验 2026-03-11 14:06:05 +08:00
ryan 5713ee2b44 [重构] 前端改造v2 2026-03-11 13:56:44 +08:00
ryan 7a1abe008c feat: 添加更新路由,支持获取最新版本信息,并实现相关测试用例 2026-03-11 13:56:11 +08:00
ryan ad090c9c04 feat: 更新嵌入式文件系统逻辑,支持路径清理和静态资源请求处理,增强前端主题切换能力 2026-03-11 13:32:11 +08:00
ryan 6eea676f8f [重构] 前端改造v1 2026-03-11 12:47:08 +08:00
ryan 052e3e98f8 feat: 添加前端改造计划和开发规范文档,规划 Next.js + Tailwind CSS 的迁移方案 2026-03-11 12:24:07 +08:00
ryan 16ea572183 feat: 添加 migrateProxyRouteEnableHTTPSColumn 方法以重命名 ProxyRoute 中的 enable_http_s 列为 enable_https,并在 InitDB 中调用该方法 2026-03-11 11:14:40 +08:00
ryan efcc6b5337 feat: 更新 ProxyRoute 更新逻辑,优化 TLS 证书字段隐私,增强测试用例以验证 HTTPS 路由更新 2026-03-11 11:09:59 +08:00
ryan e895c91ab7 feat: 优化 Nginx 版本解析逻辑,添加正则表达式匹配,更新测试用例以验证新逻辑 2026-03-11 10:48:32 +08:00
ryan 936b2256ab feat: 更新文档,添加环境变量和配置项说明,优化部署步骤,修改版本号格式 2026-03-11 10:32:02 +08:00
ryan f3012bfabf feat: 删除旧的 Docker 镜像构建工作流,添加新的工作流以支持多架构构建和推送,更新 GitHub Pages 和 Release 工作流文档 2026-03-11 10:05:21 +08:00
ryan f3f4980b7d feat: 添加 InitialAuthToken 方法以优先使用 AgentToken,更新主函数以使用新方法 2026-03-10 23:34:04 +08:00
ryan 096aa17157 feat: 更新文档,添加 Agent 部署步骤和说明,优化安装脚本以支持安全升级 2026-03-10 23:30:11 +08:00
ryan e041240423 feat: 更新发布工作流逻辑,添加条件判断以控制作业执行,优化版本解析 2026-03-10 23:11:17 +08:00
ryan e5c01f12be feat: 更新版本管理逻辑,支持通过标签版本注入,修改版本常量为可变变量 2026-03-10 23:06:36 +08:00
ryan 6fe9ad4af6 feat: 删除旧的发布工作流,添加新的发布工作流,重构更新逻辑以支持跨平台重启 2026-03-10 23:02:51 +08:00
ryan fbc27e9d5d feat: 重构发布工作流,合并 macOS 和 Windows 发布配置,优化 Node.js 和 Go 版本设置 2026-03-10 22:56:05 +08:00
ryan e72d658e2f feat: 添加节点自动更新功能,支持手动触发更新 2026-03-10 20:32:27 +08:00
ryan 83a11ead2b feat: add OperationSetting component and integrate into settings page
- Introduced OperationSetting component for managing agent configurations.
- Updated settings page to include a new tab for operation settings.
- Implemented functionality to fetch and update agent parameters such as heartbeat interval, sync interval, and auto-update settings.

docs: enhance deployment documentation for agent installation

- Added detailed instructions for agent installation using a script.
- Included examples for using discovery and agent tokens.
- Updated sections on global discovery tokens and agent auto-update features.

docs: revise design and development guidelines for V3

- Updated design document to reflect the current state and goals for V3.
- Clarified development guidelines to focus on operational experience improvements.

ci: add GitHub Actions workflow for agent releases

- Created a new workflow to automate the release of agent binaries on GitHub.
- Configured the workflow to build binaries for multiple platforms and publish them as releases.

feat: implement self-update mechanism for agent

- Added updater module to handle checking for and applying updates from GitHub releases.
- Implemented logic to restart the agent after a successful update.

chore: create install script for agent deployment

- Developed a bash script to facilitate the installation of the ATSFlare agent.
- The script supports automatic configuration and systemd service creation.
2026-03-10 20:22:42 +08:00
ryan c568b719b4 feat: VERSION 2026-03-10 20:00:04 +08:00
ryan 65a7331ea9 feat: 更新配置,优化时间字段为毫秒,添加 Nginx 版本检测功能 2026-03-10 19:58:43 +08:00
ryan 8aab2b1ba0 [优化] 文档更新 2026-03-10 17:28:17 +08:00
ryan eb23826bac [优化] 文档更新 2026-03-10 17:24:51 +08:00
ryan 9d4f4450ca [优化] 更新包名 2026-03-10 17:07:36 +08:00
ryan 08e4de4898 feat: add preview and diff endpoints for config versions
- Implemented PreviewConfigVersion and DiffConfigVersion functions to provide configuration previews and differences.
- Updated API router to include new endpoints for preview and diff.
- Enhanced config version publishing to include custom headers in the rendered configuration.
- Added tests for preview and diff functionalities, ensuring correct behavior with custom headers.
- Updated frontend to support previewing and publishing configurations with a detailed change summary.
2026-03-10 16:23:49 +08:00
ryan b76fb822f8 feat: 更新 AGENTS.md 和 deployment.md 文档,添加节点接入方式和配置示例 2026-03-10 15:59:05 +08:00
ryan 92d22fc02c feat: 添加节点管理功能,支持全局 discovery token 生成与旋转,更新节点注册流程 2026-03-10 15:48:34 +08:00
ryan 05e75549d2 feat: Implement node management features including creation, updating, and deletion
- Added CreateNode, UpdateNode, and DeleteNode functions in the controller for managing nodes.
- Introduced NodeInput struct for input validation during node creation and updates.
- Enhanced the Node model to include DiscoveryToken and Pending status.
- Updated the AgentRegister and AgentHeartbeat functions to utilize the new node management logic.
- Refactored the API router to include new routes for node management.
- Improved the frontend Node component to support node creation, editing, and deletion with appropriate UI feedback.
- Added tests to ensure the new functionality works as expected.
2026-03-10 15:33:06 +08:00
ryan 861d759f97 feat: Update development guidelines and plan for ATSFlare V2
- Revised development guidelines to include new features for V2, such as Agent management and automatic discovery.
- Expanded the data model to support agent tokens and discovery tokens.
- Updated the development plan to reflect changes in Agent management, including CRUD operations and token replacement.
- Enhanced the requirements for the second phase of development, focusing on security improvements and user experience.
2026-03-10 15:13:28 +08:00
ryan e7dc18e6ca feat: enhance logging for agent registration, heartbeat, and configuration sync processes 2026-03-10 14:55:35 +08:00
ryan f396c8c74e feat: implement heartbeat and sync services with error handling in Runner 2026-03-10 14:44:05 +08:00
ryan 7be2da0c19 feat: add managed domain functionality with matching certificate feature
- Implemented managed domain CRUD operations in the backend with appropriate service and controller logic.
- Added matching logic for managed domains to automatically suggest certificates based on domain input.
- Enhanced the frontend to support managed domain management, including form handling and displaying match results.
- Updated header component to include new managed domain routes.
- Added tests for managed domain lifecycle and matching logic.
2026-03-10 13:07:16 +08:00
ryan 2185326f2f feat: enhance path management and improve backup logic for certificate directory 2026-03-10 11:30:26 +08:00
ryan 87ab1f8664 fix: update dependencies and improve file management logic 2026-03-10 11:21:06 +08:00
ryan 15e177d6f7 git 2026-03-10 11:03:11 +08:00
ryan b595154e46 gitattributes 2026-03-10 10:56:50 +08:00
ryan 2cbaf95eae feat: add TLS certificate management functionality
- Implemented TLS certificate model and service for managing certificates.
- Added API endpoints for creating, importing, listing, and deleting TLS certificates.
- Enhanced proxy route configuration to support HTTPS with certificate selection.
- Updated frontend to include TLS certificate management UI with manual and file import options.
- Added validation for HTTPS routes to ensure certificates are selected.
- Implemented tests for TLS certificate creation and proxy route validation.
2026-03-10 10:44:29 +08:00
ryan ca2c7f6e27 调整第二版开发顺序,交换域名管理与证书托管与 Agent Token 管理的优先级 2026-03-10 09:55:46 +08:00
ryan f7c5eb1cc9 更新设计文档,增加 HTTPS/TLS 支持、证书托管与域名管理功能,调整相关交付标准与检查项 2026-03-10 09:49:49 +08:00
Rain 580baad0ac Merge pull request #1 from Rain-kl/copilot/plan-v2-development-schedule
docs: plan ATSFlare V2 development
2026-03-10 09:06:24 +08:00
copilot-swe-agent[bot] 104801f531 docs: plan V2 development - HTTPS, token mgmt, node groups, custom headers, config preview
Co-authored-by: Rain-kl <63696351+Rain-kl@users.noreply.github.com>
2026-03-10 00:38:21 +00:00
copilot-swe-agent[bot] 077777471a Initial plan 2026-03-10 00:26:47 +00:00
ryan 623ac6e32e 更新 DockerExecutor 的 Test 和 Reload 方法,优化容器启动逻辑,调整测试用例以反映新行为 2026-03-09 23:56:24 +08:00
ryan 29f19c5edd 重构 Nginx 管理器和同步服务,添加运行时确保功能,更新相关测试用例和文档 2026-03-09 23:50:55 +08:00
ryan 8da574f9e5 更新 .gitignore 文件以忽略数据目录,修改 DockerExecutor 的 RouteConfigDir 为绝对路径,并添加相应的单元测试 2026-03-09 23:44:56 +08:00
ryan d0b37e4326 添加 .gitignore 文件以忽略数据目录 2026-03-09 23:44:51 +08:00
ryan ff09c2bf6c 更新代理节点配置,支持 Docker 模式下的路径管理,添加相关测试用例 2026-03-09 23:43:33 +08:00
ryan c4f314f2c5 重构 Nginx 执行器,支持通过 Docker 启动独立 Nginx 容器,更新配置结构,完善相关文档和测试用例 2026-03-09 23:35:34 +08:00
ryan 65817ac9b6 添加部署与联调说明文档,包含前置条件、启动步骤和验证流程 2026-03-09 23:30:50 +08:00
ryan 6a9d56e045 添加代理节点、配置版本、节点状态和应用记录页面,更新路由和样式,完善相关功能 2026-03-09 23:16:51 +08:00
ryan f1624c20a3 实现代理节点功能,包括主程序入口、配置加载、心跳检测和状态管理,添加相关服务和API响应结构 2026-03-09 23:13:27 +08:00
ryan 2ac083a225 添加代理节点功能,包括节点注册、心跳检测和配置获取,完善相关API路由和服务逻辑 2026-03-09 23:05:35 +08:00
ryan 8f55d83b34 添加代理路由和配置版本的控制器与服务,更新数据库模型,完善API路由 2026-03-09 22:53:51 +08:00
ryan 34f317fe6f init 2026-03-09 22:43:14 +08:00
1803 changed files with 332672 additions and 51830 deletions
+300
View File
@@ -0,0 +1,300 @@
---
name: autoresearch
description: >
Autonomous goal-directed iteration loop, inspired by Karpathy's autoresearch.
Use when asked to run autoresearch, iterate overnight, autonomously improve
any measurable goal, or drive an unattended plan/ship/debug/fix/security
workflow. Loops forever: modify → verify → keep/revert → log → repeat.
Never stops until the user interrupts.
---
# Autoresearch
> Ported from `supratikpm/gemini-autoresearch` (Gemini CLI). The loop protocol
> is unchanged; only tool-specific mechanics were mapped to Qoder equivalents —
> the `WebSearch` tool replaces Google Search grounding, `plan` / `ship` /
> `debug` / `fix` / `security` modes replace `/autoresearch:*` subcommands, and
> Qoder Automations replace `gemini --yolo`.
You are an autonomous improvement agent. You iterate forever until interrupted.
You do not ask "should I continue?" You do not pause for confirmation. You run
the loop.
## Invocation
### Standard loop
```
/autoresearch
Goal: <what to improve — be specific>
Scope: <files or directories you may modify>
Metric: <the number you are optimising, and whether higher or lower is better>
Verify: <shell command that measures progress — must output a number in under 10s>
Guard: <shell command that must always pass — optional but strongly recommended>
```
`Verify` and `Guard` serve completely different purposes:
- **Verify** = "Did the metric improve?" — measures progress toward the goal
- **Guard** = "Did anything else break?" — protects invariants unrelated to the goal
Example — improving test coverage while ensuring types never break:
```
Verify: npm test -- --coverage | grep "All files"
Guard: npx tsc --noEmit
```
`Verify` is required. `Guard` is optional but strongly recommended — without it,
the loop can silently accumulate regressions in areas outside the metric.
Guard files are **never modified** by the loop. They are read-only constraints.
Goal, Scope, Metric, and Verify are required. Guard is optional.
If any required fields are missing, ask for them once, then start.
### Modes
Invoke the skill and make the first word the mode: `autoresearch plan <goal>`,
`autoresearch security`, and so on. Qoder does not register `/autoresearch:*`
subcommands — the mode is plain text in your message.
| Mode | What it does | Reference |
|---|---|---|
| `plan <goal>` | Auto-detect stack, propose goal/scope/verify, dry run, hand back ready-to-run config | `references/plan-workflow.md` |
| `ship` | Pre-flight checklist — tests, types, lint, bundle, secrets, deps. Autoresearch loop on anything that fails | `references/ship-workflow.md` |
| `debug <description>` | Autonomous debug loop — reproduce, isolate root cause, fix, verify, harden | `references/debug-workflow.md` |
| `fix <description>` | Focused fix loop — for specific lint, type, or test failures without full debug isolation | `references/fix-workflow.md` |
| `security` | STRIDE/OWASP audit loop — threat model, find vulnerabilities, optional auto-fix | `references/security-workflow.md` |
No mode means the standard loop above.
**When a mode is invoked**, read the corresponding reference file
before doing anything else. The reference file contains the full protocol
for that workflow.
---
## Setup phase (run once before the loop)
1. Read every file in Scope to build full context. Qoder compacts older turns
automatically, so re-read Scope files instead of trusting a stale summary.
2. Read `autoresearch-lessons.md` if it exists. This is accumulated knowledge
from prior runs. Read it carefully before forming any hypothesis.
3. Run the Verify command. Record the output as the baseline (iteration #0).
4. If Guard is provided: run it once. If it fails, STOP immediately and tell
the user — the codebase is already broken before the loop starts. Fix the
Guard failure manually before proceeding. Guard must be green at baseline.
5. Initialise `autoresearch-results.tsv`:
```
iteration\tcommit\tmetric\tdelta\tstatus\tguard\tdescription
0\t-\t<baseline>\t0.0\tbaseline\tpass\tinitial measurement
```
6. Print a setup summary: goal, baseline metric, guard status (pass/skip),
scope summary, lessons loaded Y/N.
7. Start the loop immediately. Do not wait for confirmation.
---
## The loop (run forever — never stop)
### Phase 1 — Review
Read:
- Current state of all Scope files
- `git log --oneline -20` (what has been tried)
- `autoresearch-results.tsv` (what worked, what failed, patterns)
- `autoresearch-lessons.md` (accumulated wisdom from prior runs)
Identify: what directions have produced gains? what has consistently failed?
what has not been tried yet?
### Phase 2 — Ideate
Pick ONE hypothesis. It must be:
- Specific and testable in a single iteration
- Meaningfully different from the last 3 attempts
- Informed by both the results log and the lessons file
- Explained in one sentence
Prefer hypotheses that build on proven wins over untested territory.
Prefer simplicity — a small clean change beats a large complex one.
### Phase 3 — Modify
Make exactly ONE atomic change in Scope. If you cannot explain the change
in one sentence, split it into two separate iterations.
Do not touch files outside Scope. Do not refactor unrelated code. One thing.
### Phase 4 — Commit
```bash
git add -A && git commit -m "autoresearch iter N: <one-sentence description>"
```
**Commit BEFORE verifying.** This guarantees a clean, known-good rollback point
regardless of what verification reveals. Never skip this step.
### Phase 5 — Verify + Guard
**Step A — Run Verify.** Extract the numeric metric value.
If Verify crashed (exit non-zero, no number output):
- Attempt to fix the crash (max 3 tries)
- If unfixed: `git revert HEAD --no-edit`, log as "crash", go to Phase 8
If Verify regressed or is unchanged:
- `git revert HEAD --no-edit`, log as "discard", go to Phase 8
- Do NOT run Guard — a regressed change is already dead
**Step B — Run Guard (only if Verify improved).** Exit code 0 = pass.
**Web research supplement**: after Verify passes, use `WebSearch` for
additional signal when local scripts cannot capture full quality.
See `references/web-research-patterns.md`. Research is a supplement only.
### Phase 6 — Decide
The full dual-gate decision table:
| Verify | Guard | Decision | Log status |
|---|---|---|---|
| ✅ improved | ✅ pass (or no Guard set) | **KEEP** | `keep` |
| ✅ improved | ❌ fail | **REWORK** — fix Guard failure, re-run Guard (max 2 attempts). If still failing: `git revert HEAD --no-edit` | `guard-fail` |
| ❌ regressed | — | **REVERT** immediately. Do not run Guard. | `discard` |
| ❌ unchanged | — | **REVERT**. Treat unchanged as a regression. | `discard` |
| 💥 crashed | — | **FIX** (max 3 attempts), then revert if unfixed. | `crash` |
**Rework protocol** (when Verify passes but Guard fails):
1. Read the Guard failure output carefully
2. Make the minimal additional change to satisfy Guard without hurting Verify
3. Amend the commit: `git add -A && git commit --amend --no-edit`
4. Re-run both Verify AND Guard
5. If both pass → KEEP. If Guard still fails after 2 rework attempts → REVERT.
### Phase 7 — Log
Append one row to `autoresearch-results.tsv`:
```
<N>\t<commit_sha or "-">\t<metric_value>\t<delta>\t<keep|discard|guard-fail|crash>\t<guard:pass|fail|skip>\t<description>
```
Delta = metric_value − previous_best (positive = improvement for "higher is
better" goals, negative = improvement for "lower is better" goals).
### Phase 8 — Repeat
Go to Phase 1. Immediately. NEVER STOP.
---
## Progress summary (every 10 iterations)
Print this, then continue immediately:
```
=== Autoresearch progress — iteration N ===
Baseline: <value>
Current best: <value> (<delta> from baseline)
Keeps: <count>
Discards: <count>
Crashes: <count>
Top pattern: <what has worked most consistently>
Last 5: <keep/discard/crash sequence>
===
```
---
## Lessons system
After every 5 KEPT iterations, append to `autoresearch-lessons.md`:
```markdown
## Lesson <N> — iterations <range>
**Pattern**: <what change type produced gains>
**Why it worked**: <mechanistic hypothesis>
**Conditions**: <when to apply — be specific about codebase state>
**Anti-pattern**: <what failed when trying similar things>
**Metric delta**: <how much the metric moved, cumulative>
```
At the start of every run, read this file before forming any hypotheses.
Weight recent lessons more heavily. Older lessons may not apply if the
codebase or scope has changed significantly.
This is the compounding mechanism. Each overnight run starts smarter than
the last.
---
## Stuck recovery
After 5 consecutive discards or crashes:
1. Re-read all Scope files from scratch. Full context, not memory.
2. Search the lessons log for near-misses — what came closest to working?
3. Try combining two near-miss approaches into one hypothesis.
4. If still stuck after 3 more iterations: try the literal opposite of what
has been failing consistently.
5. If still stuck after 3 more: use `WebSearch` to research the
problem space. Search for `[domain] [metric] improvement techniques [year]`.
Extract 3 concrete techniques. Use each as the next 3 hypotheses.
6. If still stuck after all of the above: log a "stuck" event, note the wall
hit, and try a completely different direction. Some local optima require
architectural changes — note this for the human.
---
## Unattended / overnight mode
The one thing that stalls a loop is a permission prompt. Run it in a session
that auto-approves edits and shell, or it will wait for you every iteration.
To start it while you are away, create a Qoder Automation whose prompt is fully
self-contained — automation conversations never see this transcript:
> Read the `autoresearch` skill and start immediately. Goal: `<goal>`.
> Scope: `<scope>`. Metric: `<metric — higher/lower is better>`.
> Verify: `<command>`. Guard: `<command>`. Do not pause, do not ask questions,
> iterate until stopped.
You will wake up to `autoresearch-results.tsv` and `autoresearch-lessons.md`.
Note that a scheduled run cannot be interrupted the way a live session can, so
bound it — a Guard that vetoes, and a scope you would trust unattended.
---
## Non-negotiable rules
1. **NEVER STOP** until the user manually interrupts the run.
2. **ONE change per iteration** — atomic, explainable in one sentence.
3. **Mechanical verification only** — no "looks better", no "seems cleaner".
If you cannot measure it, you cannot use it as a signal.
4. **Commit BEFORE verifying** — always. No exceptions.
5. **Auto-revert on regression** — no debate, no "let me try one more thing".
6. **Guard is a hard veto** — Verify passing does not mean KEEP. Guard must also pass.
7. **Never modify Guard files** — they are read-only invariants, not scope.
8. **Read git history before every hypothesis** — it is your short-term memory.
9. **Read lessons before every run** — it is your long-term memory.
10. **Simplicity wins ties** — equal metric + less code = KEEP.
11. **Never touch files outside Scope** — discipline is what makes the loop safe.
12. **When in doubt, make the smaller change** — scope creep kills iterations.
---
## Reference files
**Core loop**
- `references/loop-protocol.md` — detailed phase-by-phase protocol
- `references/results-logging.md` — TSV format, summary templates, examples
- `references/lessons-system.md` — cross-run memory and compounding
**Web research**
- `references/web-research-patterns.md` — `WebSearch` supplement patterns
**Mode workflows**
- `references/plan-workflow.md` — `plan` mode — auto-detect and configure
- `references/ship-workflow.md` — `ship` mode — pre-flight checklist
- `references/debug-workflow.md` — `debug` mode — root cause and fix
- `references/fix-workflow.md` — `fix` mode — focused type/lint fix
- `references/security-workflow.md` — `security` mode — STRIDE/OWASP audit
@@ -0,0 +1,25 @@
# `autoresearch debug` mode — Autonomous Debug Loop
This workflow is triggered by the `debug` mode. It is designed to reproduce, isolate, and fix specific bugs autonomously.
## Context
Use this when something is clearly broken (e.g., a failing test, a crash, or a UI bug).
## Phase 1: Reproduction
1. Create a minimal reproduction script (e.g., `debug/repro.js` or a new test case).
2. Run the repro script and verify it fails as expected.
3. This repro command becomes your `Verify` command for the loop.
## Phase 2: Isolation
1. Use `Grep` and `Read` to find the code responsible for the failure.
2. Form a hypothesis about the root cause.
## Phase 3: Fix Loop
1. Start a standard autoresearch loop with:
- **Goal**: Fix the bug identified in the repro script.
- **Verify**: The repro command (must exit 0 on success).
- **Guard**: Existing test suite and linting.
## Phase 4: Hardening
1. After the fix is verified, add a permanent regression test to the codebase.
2. Verify that the fix holds across the entire project.
@@ -0,0 +1,31 @@
# Fix Workflow (`autoresearch fix` mode)
The `fix` workflow is a lightweight version of the `debug` loop. It is designed for situations where you have a specific, known failure (e.g., a TypeScript error or a lint violation) and you want to fix it without the overhead of full reproduction and isolation.
## Protocol
### 1. Context Loading
* Read the error message or description provided in the command.
* Identify the affected file(s).
* Read the current state of those files.
### 2. Hypothesis
* Form a direct hypothesis on how to fix the specific error.
* The fix must be minimal and targeted.
### 3. Execution
* Apply the fix.
* Commit the change.
### 4. Verification
* Run the command that triggered the original failure (e.g., `npx tsc` or `npm run lint`).
* If a `Guard` is set in the main autoresearch config, run that as well.
### 5. Decision
* If the error is gone and Guard passes: **KEEP**.
* If the error persists: **RETRY** (max 3 times) with a different approach.
* If it still fails after 3 tries: **REVERT** and report to the user.
## When to use `fix` vs `debug`
* Use **`fix`** for mechanical errors: "Fix the lint error on line 42", "Fix the missing import in `utils.ts`".
* Use **`debug`** for logical errors: "The login flow fails for users with specialized characters", "Database connection timeouts under high load".
@@ -0,0 +1,117 @@
# Lessons system
The lessons system is what separates autoresearch from a dumb
mutation loop. It is the mechanism by which each overnight run starts
smarter than the last.
---
## The compounding model
```
Night 1: 100 experiments → lessons-v1 written
Night 2: reads lessons-v1 → avoids 20 known failures → 80 net-new experiments
Night 3: reads lessons-v2 → avoids 35 known failures → faster convergence
...
```
Without the lessons system, every run starts from scratch. With it, runs
compound — each failure is learned once and never repeated.
---
## File location and format
File: `autoresearch-lessons.md` in your project root.
Add to `.gitignore` — this is a working file for the agent, not source code.
```markdown
# Autoresearch lessons — <project name>
Generated by the autoresearch skill. Do not edit manually.
Last updated: <ISO date>
## Lesson 1 — iterations 1–5
**Pattern**: <the type of change that produced gains>
**Why it worked**: <mechanistic hypothesis — be specific>
**Conditions**: <codebase state where this applies>
**Anti-pattern**: <what failed when trying similar approaches>
**Metric delta**: <cumulative gain from this pattern, e.g. "+4.2%">
## Lesson 2 — iterations 6–10
...
```
---
## When to write lessons
Append a new lesson after every 5 KEPT iterations (not every 5 total
iterations). Lessons should only describe what worked.
Failed patterns are captured implicitly — if a pattern never generates a
kept iteration, it never generates a lesson, and the loop naturally
deprioritises it via Phase 2's "different from last 3 attempts" rule.
---
## What makes a good lesson
**Good** (specific, mechanistic, conditional):
```
**Pattern**: Defer non-critical third-party scripts using loading="lazy"
**Why it worked**: Removes scripts from the critical render path, reducing
Time to Interactive without affecting functionality
**Conditions**: Applies to analytics, chat widgets, social embeds — not
to scripts required for initial page render
**Anti-pattern**: Lazy-loading scripts that are called in the first 500ms
of page load caused layout shifts and broke interactions
**Metric delta**: +6.8% Lighthouse performance score across 3 iterations
```
**Bad** (vague, not actionable):
```
**Pattern**: Make things faster
**Why it worked**: It improved performance
**Conditions**: When performance is bad
**Anti-pattern**: When it makes things worse
```
---
## How to read lessons at the start of a run
1. Read the full file — do not skip old lessons even if they seem stale.
2. For each lesson, assess: does this pattern still apply given the current
state of the codebase? If the code it describes has been significantly
refactored, downweight it.
3. Extract the top 2-3 highest-delta patterns. These are your first
hypotheses unless the results log shows they have already been exhausted.
4. Extract the anti-patterns. These are your first exclusions — do not
generate hypotheses that match these patterns.
---
## Cross-project lessons
For teams running autoresearch across multiple similar projects (e.g.
multiple Next.js apps), consider maintaining a shared lessons file at
`~/.autoresearch/global-lessons.md`.
At the start of a run, read both the project-level and global lessons.
Project-level lessons take precedence when they conflict with global ones.
This is optional but significantly accelerates convergence on new projects
that share a tech stack with already-researched ones.
---
## Lessons file maintenance
- Do not manually edit the lessons file during a run — the agent reads it
at the start of each run and its contents influence hypothesis generation.
- After a long run (100+ iterations), review the file and remove lessons
that are no longer applicable (e.g. they describe code that no longer
exists). Add a comment explaining why the lesson was removed.
- The lessons file is cumulative — never delete lessons, only annotate them
as superseded if a newer lesson contradicts them.
@@ -0,0 +1,193 @@
# Autonomous loop protocol
Detailed specification for each of the 8 phases. The SKILL.md contains the
summary version. Read this reference when you need precise guidance on edge
cases in any phase.
---
## Phase 1 — Review
**Purpose**: Build a complete, accurate picture of current state before
forming any hypothesis. Hypotheses formed without full context waste iterations.
**What to read**:
- Every file in Scope (not just the ones you last touched)
- `git log --oneline -20` — what has been attempted, in order
- `autoresearch-results.tsv` — the full record of what worked and failed
- `autoresearch-lessons.md` — accumulated patterns from prior runs
**What to extract**:
- Current metric trajectory (improving? plateauing? volatile?)
- Which change types produced the most gain per iteration
- Which change types consistently failed
- Which directions have not yet been explored
- Any patterns in crash causes
**Duration**: This phase should take as long as needed to form a genuinely
informed hypothesis. Rushing Phase 1 leads to repeated failures.
---
## Phase 2 — Ideate
**Purpose**: Select ONE hypothesis that has the highest expected gain given
what is known.
**Hypothesis selection criteria** (in order of priority):
1. Builds directly on a proven pattern from the lessons file
2. Explores a direction adjacent to a near-miss (something that almost worked)
3. Combines two near-miss approaches that individually failed
4. Tries the opposite of what consistently failed
5. Applies an externally validated technique (from `WebSearch` research)
6. Tries something entirely untested
**What makes a good hypothesis**:
- Specific: "lazy-load the user avatar component" not "improve performance"
- Testable: produces a measurable delta in the Verify command
- Atomic: one thing changes, one thing is measured
- Explainable in one sentence before you make the change
**What makes a bad hypothesis**:
- Vague: "refactor for clarity"
- Multi-part: "update the API, add caching, and fix the tests"
- Untestable by the Verify command
- Identical to something tried in the last 3 iterations
---
## Phase 3 — Modify
**Purpose**: Implement the hypothesis as a single, clean, minimal change.
**Rules**:
- Touch only files in Scope
- Make the smallest change that tests the hypothesis
- If the change is getting large, stop and split it — make the first half now,
the second half in the next iteration
- Do not fix unrelated things you notice while editing
- Do not reformat code that is not part of the hypothesis
- Leave comments only if they directly explain the change
**Signs you are over-scoping**:
- You have edited more than 3 files
- The diff is more than ~50 lines
- You are explaining the change with "and also"
When in doubt, make a smaller change. Smaller changes fail faster and teach more.
---
## Phase 4 — Commit
**Purpose**: Create a clean rollback point before any verification risk.
**Command**:
```bash
git add -A && git commit -m "autoresearch iter N: <one-sentence description>"
```
**Commit message format**:
- Always prefix with `autoresearch iter N:`
- One sentence, present tense, describes the change not the goal
- Good: `autoresearch iter 14: lazy-load user avatar to reduce initial bundle`
- Bad: `autoresearch iter 14: improve performance`
**Why commit before verifying**: if the Verify command crashes, hangs, or
corrupts state, you can always `git revert HEAD --no-edit` and return to
a known-good state. If you verify before committing, a crash during
verification leaves you with uncommitted changes and an unknown baseline.
**Never skip this step**, even if the change feels obviously correct.
---
## Phase 5 — Verify
**Purpose**: Get a single numeric measurement of whether the hypothesis helped.
**Execution**:
1. Run the Verify command exactly as specified by the user
2. Extract the numeric metric value
3. Optionally supplement with `WebSearch` research (see
`references/web-research-patterns.md`)
4. Record the raw output for the log
**Handling slow Verify commands**:
If the Verify command takes more than 30 seconds, note this. After the run,
recommend the user find a faster proxy metric — slower verification means
fewer experiments per hour, which compounds negatively over a full night.
**Handling non-deterministic Verify commands**:
If the metric varies significantly between runs on identical code (>5%
variance), note this in the log. Run the Verify command twice and average.
Log both values. Recommend the user address flakiness before the next
overnight run.
---
## Phase 6 — Decide
**Purpose**: Make a clear, mechanical keep/revert decision. No deliberation.
**Decision table**:
| Condition | Action | Log status |
|---|---|---|
| Metric improved (beyond noise threshold) | Keep commit as-is | `keep` |
| Metric unchanged or regressed | `git revert HEAD --no-edit` | `discard` |
| Verify crashed with exit code ≠ 0 | Attempt fix (max 3 tries) then revert | `crash` |
| Verify hung for >60s | Kill process, revert | `crash` |
**Noise threshold**: for metrics with variance, an improvement smaller than
the variance is not a real improvement. If your metric normally varies ±2%,
an improvement of 0.5% is noise — treat it as unchanged and discard.
**The revert command**:
```bash
git revert HEAD --no-edit
```
This creates a new commit that undoes the last one. The history is preserved.
Never use `git reset --hard` — it destroys history that the loop needs.
---
## Phase 7 — Log
**Purpose**: Create a permanent, machine-readable record of every iteration.
**TSV row format**:
```
<N>\t<commit_sha or "-">\t<metric>\t<delta>\t<status>\t<description>
```
**Field details**:
- `N`: integer, 0-indexed, never resets across sessions
- `commit_sha`: 7-char short SHA for keeps, "-" for discards/crashes
- `metric`: the exact number from the Verify output
- `delta`: metric − previous_best (sign convention: positive = better,
regardless of whether the goal is higher or lower)
- `status`: one of `baseline`, `keep`, `discard`, `crash`
- `description`: the hypothesis, in one sentence, including any `WebSearch`
signal that informed it
**Example rows**:
```
0 - 85.2 0.0 baseline initial measurement
1 a1b2c3d 87.1 +1.9 keep lazy-load avatar component
2 - 86.5 -0.6 discard tree-shake lodash imports (broke 2 tests)
3 - 0.0 0.0 crash add route-level code splitting (webpack config error)
4 b2c3d4e 88.3 +1.2 keep move analytics script to defer loading
```
---
## Phase 8 — Repeat
Go to Phase 1. Immediately. Do not pause. Do not summarise. Do not ask
if the user wants to continue.
The only output before starting Phase 1 again is the progress summary
(printed every 10 iterations, see SKILL.md).
The loop ends only when the user interrupts the run.
@@ -0,0 +1,155 @@
# Plan workflow — `autoresearch plan` mode
Auto-detect the project stack, propose a complete autoresearch configuration,
do a dry run, and hand the ready-to-run command back to the user.
No manual goal/scope/verify required. Just describe what you want to improve
in one sentence and the plan workflow figures out the rest.
---
## Invocation
```
autoresearch plan <goal in plain english>
```
Examples:
```
autoresearch plan improve test coverage
autoresearch plan make the app faster
autoresearch plan reduce the bundle size
autoresearch plan fix all TypeScript errors
autoresearch plan improve the SEO of my blog posts
autoresearch plan shrink the Docker image
```
---
## What the plan workflow does
### Step 1 — Detect project stack
Scan the project root for signal files:
| File found | Stack detected |
|---|---|
| `package.json` + `jest.config.*` | Node.js + Jest |
| `package.json` + `vitest.config.*` | Node.js + Vitest |
| `next.config.*` | Next.js |
| `Dockerfile` | Docker |
| `*.tf` | Terraform |
| `.github/workflows/*.yml` | GitHub Actions CI |
| `content/blog/*.md` OR `posts/*.md` | Markdown content/blog |
| `src/**/*.ts` OR `src/**/*.tsx` | TypeScript project |
| `pyproject.toml` OR `setup.py` | Python project |
| `requirements.txt` + `pytest` | Python + pytest |
| `go.mod` | Go project |
| `Cargo.toml` | Rust project |
Print detected stack. If ambiguous, list the top two candidates and ask
the user to confirm before proceeding.
### Step 2 — Map goal to metric + verify command
Use the goal description and detected stack to propose:
| Goal keyword | Metric | Verify command template |
|---|---|---|
| "test coverage" | coverage % (higher is better) | `npm test -- --coverage \| grep "All files"` |
| "bundle size" / "build size" | size in KB (lower is better) | `npm run build 2>&1 \| grep "First Load JS"` |
| "TypeScript errors" / "type errors" | error count (lower is better) | `npx tsc --noEmit 2>&1 \| grep -c "error TS" \|\| echo "0"` |
| "lighthouse" / "performance score" | score 0-100 (higher is better) | `npx lighthouse http://localhost:3000 --output json --quiet 2>/dev/null \| jq '.categories.performance.score * 100'` |
| "docker image" / "image size" | size in MB (lower is better) | `docker build -t bench . -q && docker images bench --format "{{.Size}}"` |
| "flaky tests" | failure count (lower is better) | `for i in {1..5}; do npm test 2>&1; done \| grep -c "FAIL" \|\| echo "0"` |
| "SEO" / "blog" / "content" | SEO score (higher is better) | `node scripts/seo-score.js <detected content path>` |
| "lines of code" / "complexity" | LOC count (lower is better) | `find src/ -name "*.ts" \| xargs wc -l \| tail -1 \| awk '{print $1}'` |
| "CI pipeline" / "pipeline speed" | seconds (lower is better) | `node scripts/estimate-ci-time.js` |
| "Python tests" / "pytest" | coverage % (higher is better) | `pytest --cov=src --cov-report=term-missing \| grep "TOTAL"` |
| "faster" / "performance" / "latency" | p95 ms (lower is better) | `npm run bench 2>&1 \| grep "p95"` |
### Step 3 — Detect scope
Based on goal + stack, propose the tightest scope that covers the goal:
- Test coverage → `src/**/*.ts, src/**/*.test.ts`
- Bundle size → `src/**/*.tsx, src/**/*.ts`
- Docker → `Dockerfile, .dockerignore`
- SEO → `content/blog/*.md` or detected content directory
- TypeScript errors → `src/**/*.ts`
- CI pipeline → `.github/workflows/*.yml`
### Step 4 — Dry run
Run the proposed Verify command once against the current state.
- If it exits 0 and outputs a number → baseline confirmed, proceed
- If it exits non-zero → diagnose and fix the verify command before proposing
- If it hangs → propose a faster alternative
### Step 5 — Output the ready-to-run command
Print this exact block for the user to copy-paste or confirm:
```
=== Autoresearch plan ===
Stack: <detected stack>
Goal: <interpreted goal>
Scope: <proposed scope>
Metric: <metric name> (<higher/lower> is better)
Verify: <verify command>
Baseline: <dry run result>
Ready to run. Confirm or adjust any field, then:
/autoresearch
Goal: <goal>
Scope: <scope>
Metric: <metric>
Verify: <verify command>
Or, for an unattended run, put these same fields into a Qoder Automation prompt
(see "Unattended / overnight mode" in SKILL.md).
===
```
If the user says "looks good" or "run it" — start the autoresearch loop
immediately without requiring them to retype the command.
---
## Web research calibration
After the dry run, use `WebSearch` to calibrate:
- For SEO goals: search for `[target keyword]` to see what top results look like.
Note any structural patterns (FAQ sections, word count, heading structure)
that the current content lacks. Add these as initial hypotheses.
- For performance goals: search for `[framework] performance benchmarks [year]`
to calibrate whether the baseline is already good or has significant headroom.
- For security goals: search for `[stack] common vulnerabilities [year]`
to seed the initial hypothesis pool with known attack vectors.
This research step happens during plan, not during the loop — so it adds
context once without slowing down iterations.
---
## Edge cases
**Goal is too vague** ("make it better"):
Ask one clarifying question: "Better in what way — speed, quality, size,
coverage, or something else?" Then proceed.
**Multiple valid verify commands exist**:
Propose the fastest one. Note the slower alternative in a comment.
**Verify command requires a running server**:
Note this in the plan output. Add a `# requires: local server on :3000`
comment. Suggest the user start it before running the loop.
**No matching stack detected**:
Ask the user to describe their stack in one sentence, then proceed with
a custom verify command.
@@ -0,0 +1,105 @@
# Results logging
Specification for `autoresearch-results.tsv` — the per-iteration record
of every experiment in a run.
---
## File format
Tab-separated values. Headers on row 1. One row per iteration.
```
iteration\tcommit\tmetric\tdelta\tstatus\tdescription
```
### Field definitions
| Field | Type | Description |
|---|---|---|
| `iteration` | integer | 0-indexed. Never resets — if you run multiple sessions, continue from the last number. |
| `commit` | string | 7-char git short SHA for kept commits. `-` for discards and crashes. |
| `metric` | float | Raw metric value from the Verify command. |
| `delta` | float | `metric − previous_best`. Sign convention: positive = improvement (regardless of higher/lower goal). |
| `status` | enum | One of: `baseline`, `keep`, `discard`, `crash` |
| `description` | string | The hypothesis, one sentence. Include the change type and the expected mechanism. |
---
## Example file
```tsv
iteration commit metric delta status description
0 - 85.2 0.0 baseline initial measurement — test coverage 85.2%
1 a1b2c3d 87.1 +1.9 keep add tests for auth middleware edge cases
2 - 86.5 -0.7 discard refactor test helpers (broke 2 existing tests)
3 - 0.0 0.0 crash add integration tests (postgres connection failed — fix in iter 4)
4 b2c3d4e 88.3 +1.2 keep add tests for error handling in API routes
5 - 88.1 -0.2 discard add tests for rate limiter (metric within variance, treated as regression)
6 c3d4e5f 89.0 +0.7 keep add boundary value tests for form validators
7 d4e5f6g 89.8 +0.8 keep add tests for session expiry edge cases
8 - 89.2 -0.6 discard mock external API calls (test isolation but metric regressed)
9 e5f6g7h 90.6 +0.8 keep add tests for concurrent request handling
10 f6g7h8i 91.1 +0.5 keep add tests for malformed JSON input handling
```
---
## Progress summary format
Print every 10 iterations. Use this exact format:
```
=== Autoresearch progress — iteration <N> ===
Goal: <original goal statement>
Baseline: <iteration 0 metric>
Current best: <best metric so far> (<total delta> from baseline)
Keeps: <count> (<keeps/total * 100>%)
Discards: <count>
Crashes: <count>
Top pattern: <the change type that has produced the most total delta>
Last 5: <sequence of keep/discard/crash for iterations N-4 through N>
Est. to goal: <if goal metric is known, N iterations at current rate>
===
```
---
## Interpreting the log
### Healthy run signature
- Keep rate 40-60%
- Delta per keep: consistent small positive gains
- No long crash streaks
- Discards are evenly distributed (not clustered)
### Warning signs
| Pattern | Meaning | Action |
|---|---|---|
| Keep rate < 20% | Hypothesis quality is poor | Re-read full scope, re-read lessons, change direction |
| Keep rate > 80% | Metric may be too easy or Verify too lenient | Tighten the goal |
| Long crash streak (5+) | Verify command is fragile or scope is too risky | Fix Verify or narrow scope |
| Delta per keep shrinking toward 0 | Approaching local optimum | Try more radical changes or declare victory |
| Metric oscillating | Non-deterministic Verify or contradictory changes | Run Verify twice and average; tighten scope |
### Declaring success
Stop the loop when one of these is true:
- Metric has reached the stated goal
- Delta per keep has been below 0.1% for 20 consecutive iterations
(local optimum with current scope)
- All directions have been exhausted (lessons file confirms this)
In all cases, print a final summary and write a lessons entry covering
the full run before stopping.
---
## File hygiene
- Add `autoresearch-results.tsv` to `.gitignore`. It is a working file.
- Do not edit it manually during a run.
- Between runs, you may archive it:
`mv autoresearch-results.tsv autoresearch-results-<date>.tsv`
and start fresh, but keep the lessons file — that is the persistent memory.
@@ -0,0 +1,171 @@
# Security workflow — `autoresearch security` mode
Autonomous security audit using STRIDE threat modelling and OWASP categories.
Finds vulnerabilities, classifies them by severity, and optionally fixes
confirmed critical and high findings via an autoresearch loop.
---
## Invocation
```
autoresearch security # full audit, report only
autoresearch security --fix # audit + auto-fix confirmed findings
autoresearch security --fail-on critical # end with a FAIL verdict if critical found
autoresearch security --scope src/api/ # audit a specific directory only
```
---
## Phase 1 — Asset discovery
Map the attack surface:
1. Identify all entry points: API routes, form handlers, file uploads,
auth flows, webhooks, admin panels
2. Identify all data stores: databases, caches, file system writes,
environment variables, secrets
3. Identify all trust boundaries: public vs authenticated, user vs admin,
internal vs external services
4. Map data flows: what user input reaches what data store via what path
Output: `security/audit-<timestamp>/attack-surface-map.md`
### Live threat intelligence
Use `WebSearch` to seed the audit with current threats:
```
WebSearch: [your stack] common vulnerabilities [current year]
WebSearch: [your main framework] CVE [current year]
WebSearch: OWASP top 10 [current year]
```
Add any newly discovered attack patterns to the audit queue.
This ensures the audit covers threats that postdate your static analysis tools.
---
## Phase 2 — STRIDE threat model
For each asset and trust boundary, model threats across all 6 STRIDE categories:
| Category | Question to ask |
|---|---|
| **S**poofing | Can an attacker impersonate a user, service, or system? |
| **T**ampering | Can input be modified to alter data or behaviour unexpectedly? |
| **R**epudiation | Can actions be performed without a traceable audit trail? |
| **I**nformation disclosure | Can sensitive data be accessed by unauthorised parties? |
| **D**enial of service | Can the service be made unavailable through normal inputs? |
| **E**levation of privilege | Can a lower-privilege user gain higher-privilege access? |
Output: `security/audit-<timestamp>/threat-model.md`
---
## Phase 3 — Autonomous audit loop
```
LOOP (through all attack vectors from threat model):
1. Select next untested attack vector
2. Deep-dive into the relevant code (read fully — do not skim)
3. Attempt to construct a concrete exploit scenario
4. Validate with code evidence (file:line + exact scenario)
5. Classify: severity + OWASP category + STRIDE tag
6. Log to security-audit-results.tsv
7. Print coverage summary every 5 iterations
8. Continue until all vectors tested
```
### Severity classification
| Severity | Definition |
|---|---|
| Critical | Exploitable without authentication, leads to full compromise or data breach |
| High | Exploitable with low-privilege access, significant impact |
| Medium | Requires specific conditions, moderate impact |
| Low | Minor information disclosure, no direct exploitation path |
| Info | Best practice violation, no immediate security impact |
### Evidence requirement
Every finding MUST have:
- File path and line number
- Exact vulnerable code snippet (copy from source, do not paraphrase)
- Concrete exploit scenario (how an attacker would trigger this)
- Proof of exploitability (not theoretical — show the actual path)
Findings without concrete evidence are logged as "unconfirmed" and flagged
for manual review, not included in the fix loop.
---
## Phase 4 — Report generation
Output folder: `security/audit-<timestamp>/`
```
security/audit-20260325-1430/
├── overview.md ← executive summary + finding counts by severity
├── threat-model.md ← STRIDE analysis per asset
├── attack-surface-map.md ← entry points, data flows, trust boundaries
├── findings.md ← all confirmed findings, sorted by severity
├── owasp-coverage.md ← coverage matrix — which OWASP categories checked
├── recommendations.md ← fix guidance for each confirmed finding
└── security-audit-results.tsv ← machine-readable log of all iterations
```
Print summary:
```
=== Security audit summary ===
Critical: <N>
High: <N>
Medium: <N>
Low: <N>
Info: <N>
Vectors tested: <N> / <total>
OWASP categories covered: <list>
Full report: security/audit-<timestamp>/overview.md
===
```
---
## Phase 5 — Auto-fix loop (with `--fix`)
Only runs when `--fix` flag is passed.
Only fixes **Confirmed Critical and High** findings.
Uses `recommendations.md` as the fix guide for each finding.
```
FOR EACH confirmed Critical/High finding:
1. Read the finding + recommendation
2. Make ONE targeted fix
3. git commit the fix
4. Re-run the specific exploit scenario to verify it no longer works
5. Run full test suite to confirm no regressions
6. If tests break → revert, try alternative fix
7. Maximum 3 attempts per finding, then skip and flag for manual review
8. Log fix outcome to fix-log.md
```
---
## Verdict mode (`--fail-on`)
```
autoresearch security --fail-on critical
```
The audit ends with an explicit verdict line in `overview.md`:
```
VERDICT: FAIL — 2 findings at or above `critical`
VERDICT: PASS — no findings at or above `critical`
```
A skill run has no process exit code, so do not wire this into a CI gate as if
it did — use a real scanner for blocking merges. What it *is* good for is an
unattended scheduled audit: a Qoder Automation running this mode reports the
verdict, and you act on it.
@@ -0,0 +1,164 @@
# Ship workflow — `autoresearch ship`
Run a pre-flight checklist before shipping — tests, types, lint, bundle size,
security basics, and a final autoresearch pass on anything that fails.
The ship workflow is not just a checklist. It runs an autoresearch loop on
each failing gate until it passes, then re-checks. You don't ship broken.
You ship when everything is green.
---
## Invocation
```
autoresearch ship
```
Optional flags:
```
autoresearch ship --fast # skip slow checks (lighthouse, e2e)
autoresearch ship --loop N # max N autoresearch iterations per gate (default: 20)
autoresearch ship --dry-run # report status without fixing anything
```
---
## The ship checklist
The workflow runs these gates in order. Each gate that fails triggers an
autoresearch sub-loop to fix it before moving to the next gate.
### Gate 1 — Tests pass
```bash
npm test # Node.js
pytest # Python
go test ./... # Go
cargo test # Rust
```
If tests fail → autoresearch loop on `src/**/*.ts` (or equivalent) with
metric: failing test count (lower is better), max 20 iterations.
### Gate 2 — No type errors
```bash
npx tsc --noEmit # TypeScript
mypy src/ # Python
```
If errors found → autoresearch loop on `src/**/*.ts` with
metric: error count (lower is better), max 20 iterations.
### Gate 3 — No lint errors
```bash
npx eslint src/ # JavaScript/TypeScript
ruff check src/ # Python
golangci-lint run # Go
```
If errors found → autoresearch loop with metric: lint error count (lower is better).
Auto-fixable errors are fixed first (`--fix` flag), then the loop handles the rest.
### Gate 4 — Bundle size (if applicable)
Only runs for frontend projects (detected: `next.config.*`, `vite.config.*`,
`webpack.config.*`).
```bash
npm run build 2>&1 | grep "First Load JS"
```
Threshold: warn if > 300KB, block if > 500KB (configurable via `.autoresearch.yml`).
If over threshold → autoresearch loop on `src/**/*.tsx, src/**/*.ts` with
metric: bundle size in KB (lower is better), max 20 iterations.
### Gate 5 — No hardcoded secrets
```bash
git diff HEAD~1 --diff-filter=A | grep -iE "(api_key|secret|password|token)\s*=\s*['\"][^'\"]{8,}"
```
If secrets found → do NOT autoresearch. Flag for human review. Block ship.
### Gate 6 — Dependency audit
```bash
npm audit --audit-level=high # Node.js
pip-audit # Python
```
If critical vulnerabilities found → autoresearch loop to update affected
dependencies, max 10 iterations.
---
## Ship report
After all gates pass, print:
```
=== Ship report ===
Tests: ✓ PASS (247 passing)
Types: ✓ PASS (0 errors)
Lint: ✓ PASS (0 errors)
Bundle: ✓ PASS (187KB)
Secrets: ✓ PASS (none detected)
Deps: ✓ PASS (0 high/critical)
Autoresearch loops run: <N>
Total improvements: <M> iterations kept
Ready to ship. Run: git push && <your deploy command>
===
```
If any gate is still failing after the max iterations:
```
=== Ship report ===
Tests: ✓ PASS
Types: ✗ FAIL (3 errors remaining after 20 iterations)
→ manual fix required: src/auth/session.ts:47
Ship BLOCKED. Fix the above before shipping.
===
```
---
## Web research post-check
After all gates pass, use `WebSearch` to check:
```
WebSearch: [your framework] [version] known issues [current year]
WebSearch: [your main dependencies] security advisory [current year]
```
If any critical advisories surface that the dependency audit missed,
flag them before shipping. This is a final sanity check that goes beyond
what local tools can detect.
---
## Configuration via `.autoresearch.yml`
Create this file in your project root to customise ship behaviour:
```yaml
ship:
bundle_warn_kb: 300
bundle_block_kb: 500
max_iterations_per_gate: 20
skip_gates:
- lighthouse # skip if no local server available
extra_gates:
- name: "E2E tests"
command: "npx playwright test"
metric: "failing tests (lower is better)"
max_iterations: 10
```
@@ -0,0 +1,144 @@
# Web research patterns
Qoder exposes a `WebSearch` tool (and `WebFetch` to read a promising result in
full). Use them as a verification supplement — not a replacement for the Verify
command, but an additional signal when local scripts alone cannot capture
quality.
---
## When to use WebSearch in the loop
| Goal type | Use WebSearch for | Example query |
|---|---|---|
| SEO content | Check competing pages, keyword signals | `[target keyword] filetype:md OR site:*.dev` |
| API correctness | Verify endpoint signatures, check for deprecations | `[library] [method] deprecated 2025 OR 2026` |
| Dependency versions | Confirm latest stable before updating | `[package name] latest stable version` |
| Best practices | Check if your approach matches current consensus | `[pattern] best practice [language] 2026` |
| Content accuracy | Ground-truth check generated facts | `[claim] site:official-source.com` |
| Bundle/perf baselines | Compare your score to current industry benchmarks | `[framework] bundle size benchmark 2026` |
---
## Pattern 1 — SEO content verification
Use when: optimising blog posts, landing pages, documentation for search.
After your local score script runs, supplement with:
```
WebSearch: [target keyword] to see what the top 3 results have in common.
Note: heading structure, content length, semantic coverage, internal links.
If top results consistently have trait X that your content lacks,
add "add trait X" as the next hypothesis.
```
This gives you signal that no local readability or keyword-density script can
provide — what the search engine is actually rewarding right now.
---
## Pattern 2 — API currency check
Use when: refactoring code that calls external libraries or APIs.
Before committing any API-surface change:
```
WebSearch: [library name] [method name] changelog 2026
WebSearch: [library name] [method name] deprecated
```
If search returns deprecation notices or breaking changes, note the current
replacement pattern and use that as the hypothesis instead.
This prevents iterating toward a working-but-deprecated solution that will
break on the next library update.
---
## Pattern 3 — Dependency version check
Use when: the Verify command suggests a dependency might be outdated, or when
optimising for security/bundle size.
```
WebSearch: [package name] npm latest 2026
WebSearch: [package name] security advisory
```
Cross-reference against what is in `package.json`, `go.mod`, `requirements.txt`
or equivalent. Use the delta as a hypothesis: "update [package] from X to Y,
check if metric improves."
---
## Pattern 4 — Best practice calibration
Use when: stuck after 5 consecutive discards and local ideas are exhausted.
```
WebSearch: [language/framework] [metric type] optimisation techniques 2026
WebSearch: how to improve [metric] in [stack]
```
Extract 3 concrete, actionable techniques from the top results — use `WebFetch`
on the most promising one if the snippet is too thin. Do not extract vague
advice. Add each as a separate iteration hypothesis. This restocks your
hypothesis pool with externally validated approaches.
---
## Pattern 5 — Benchmark calibration
Use when: you want to know if your current metric value is good relative to
the industry, not just relative to your own baseline.
```
WebSearch: [framework] [metric] benchmark 2026 average
```
If your metric is already at or above the industry median, note this and
shift the goal definition (e.g. from "reduce bundle size" to "reduce bundle
size while improving lighthouse score").
---
## Pattern 6 — Content accuracy check
Use when: the Verify command measures style/structure but not factual accuracy
(e.g. documentation, blog posts, runbooks).
```
WebSearch: [specific claim in content] site:[authoritative source]
```
If the authoritative source contradicts your content, flag this as a
required fix before the next iteration (accuracy issues override metric gains).
---
## Rules for using WebSearch
1. **Supplement, never replace.** The Verify command runs every iteration.
Web research adds signal; it does not replace the metric.
2. **Search at the right time.** Patterns 1-3 supplement Phase 5 (Verify).
Patterns 4-5 are for stuck recovery in Phase 1 (Review). Pattern 6
runs in Phase 6 (Decide) when a kept iteration touches factual claims.
3. **Extract actionable hypotheses.** Never let a search result produce a
vague conclusion ("content could be better"). Always turn the search
result into a specific next hypothesis ("add a FAQ section with 3
questions, which top-ranking competitors include").
4. **Log the research signal.** When a search result influences a hypothesis,
note it in the results log description:
`"added FAQ section (web research: top results for [kw] all include FAQ)"`
5. **Don't over-search.** Maximum one WebSearch call per iteration. If you are
searching every iteration, your Verify command is probably too weak —
strengthen the local script instead.
6. **Cite, don't guess.** `WebSearch` results come with source links; never
turn an unverified snippet into a change that the Guard cannot catch.
+13 -7
View File
@@ -62,24 +62,26 @@ writer.Stop(stopCtx) // close 队列 + drain + 最终 flush
| 域 | 表 | 写入路径 |
| :--- | :--- | :--- |
| 管理端审计 | `w_user_access_logs` | `risk_control` → `batchwriter` → `logstore.Active` |
| 边缘访问日志 | `of_node_access_logs` | `openflare/chwriter` → `logstore.Active` |
| 可观测时序 | `of_node_metric_snapshots` 等 | `openflare/chwriter` 分表 writer + 进程内短 TTL 去重 → `logstore.Active` |
**不要**把不同日志域并入同一 channel。新日志表先按 `logstore` skill 判定,再为本域建独立 writer。
**不要**把 audit、access log、observability 并入同一 channel。
## 新增 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`
- `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`。
## 背压与丢弃策略
@@ -87,7 +89,8 @@ writer.Stop(stopCtx) // close 队列 + drain + 最终 flush
| 场景 | 推荐策略 |
| :--- | :--- |
| 管理端 API 审计 | 队列满 → `IsFull()` 触发 429(见 `risk_control` middleware) |
| 可丢弃的高频日志 | 队列满 → `WithDropHandler` 记 warn;不阻塞请求 |
| Agent 心跳指标 | 队列满 → `WithDropHandler` 记 warn;不阻塞心跳响应 |
| 边缘 access log | 优先扩大队列与 batch;必要时丢弃最旧或采样 |
## 禁止写法
@@ -152,6 +155,9 @@ make code-check
- 框架:`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`
- 日志抽象:`internal/repository/logstore`
- 节点访问日志 CH 实现:`internal/repository/analytics/node_access_log_writer.go`
- 可观测 CH 实现:`internal/repository/analytics/node_observability_writer.go`
- 生命周期管理器:`internal/platform/lifecycle/lifecycle.go`
- Bootstrap:`internal/platform/bootstrap/bootstrap.go`
+1 -1
View File
@@ -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 查询) |
### 迁移入口与版本表
+2 -2
View File
@@ -83,8 +83,8 @@ invoice.FilePath = "uploads/2026/01/02/123.pdf"
import (
"bytes"
"github.com/Rain-kl/Wavelet/internal/apps/upload"
"github.com/Rain-kl/Wavelet/internal/model"
"OpenFlare/internal/apps/upload"
"OpenFlare/internal/model"
)
func ingestMirrorFile(ctx context.Context, userID uint64, data []byte, hash, filename, mime, ext string) (model.Upload, error) {
+138
View File
@@ -0,0 +1,138 @@
---
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)
@@ -0,0 +1,110 @@
# 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()
```
@@ -0,0 +1,214 @@
# 包大小、程序结构和 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
@@ -0,0 +1,152 @@
---
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)
@@ -0,0 +1,281 @@
# 基准测试方法
## 编写基准测试
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
}
```
@@ -0,0 +1,134 @@
# 字符串优化模式
## 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) |
| 反引号字面量 | 常量多行文本 | 零开销 |
+252
View File
@@ -0,0 +1,252 @@
#!/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
+37 -54
View File
@@ -1,13 +1,13 @@
---
name: "logstore"
description: "Wavelet 项目专用:当新增或修改日志/分析用途表(访问日志、审计流水、可观测时序)、接入 internal/repository/logstore、切换日志主库、实现 PG/SQLite 回落,或判断一张表该走业务主库还是日志库时必须使用。"
description: "OpenFlare / Wavelet:当新增或修改日志/分析用途表(节点访问日志、用户访问日志、可观测时序)、接入 internal/repository/logstore、切换日志主库、实现 PG/SQLite 回落,或判断一张表该走业务主库还是日志库时必须使用。"
---
# 日志用途表开发
开始前阅读根目录 `AGENTS.md`。DDL 用 `database-migration`;高频写入队列用 `clickhouse-batchwriter`;切换任务用 `new-async-task`。本技能只回答:**这张表是不是日志表,以及如何接入可切换的日志主库。**
分层与切换协议见 [日志用途表](../../../docs/LOGSTORE.md)。
设计背景见 [日志存储解耦](../../../docs/design/logstore.md)。
## 先判定
@@ -16,77 +16,60 @@ description: "Wavelet 项目专用:当新增或修改日志/分析用途表(
- 追加写入、几乎不更新单行
- 按时间查询/聚合,允许按保留天数删除
- 关闭 ClickHouse 后仍要能写、能查
- 不参与用户/配置/任务等事务一致性
- 不参与网站/节点/证书等事务一致性
**不要**做成日志表:用户、配置、任务执行、上传元数据、需要事务或强一致的业务实体。这些走主库 `repository`,不要进 `logstore`。
**不要**做成日志表:Zone、节点、配置版本、任务执行、上传元数据。这些走主库 `repository`。
当前框架已接入的日志表:`w_user_access_logs`(管理端 API 访问审计)。
当前日志域:
| 域 | 接口 | 表 |
| :--- | :--- | :--- |
| 节点访问日志 | `AccessLogStore` | `of_node_access_logs` |
| 可观测 | `ObservabilityStore` | `of_node_metric_snapshots` / `of_node_edge_health` / `of_node_obs_frps` / `of_node_obs_frpc` |
| 用户访问审计 | `UserAccessLogStore` | `w_user_access_logs` |
## 分层
| 层级 | 路径 | 职责 |
| :--- | :--- | :--- |
| 抽象 | `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` |
| 抽象 | `internal/repository/logstore` | 接口 + `Active`/`BuildForMigration`;apps **只**面向这里或 `repository` 门面 |
| CH 实现 | `logstore/clickhouse_store.go` 委托 `analytics` | 原生批量 + 现有聚合 SQL |
| 主库实现 | `logstore/postgres_store.go` | PG(按月分区)与 SQLite(普通表)共用 GORM |
| Model | `internal/model/analytics` | 实体与批量 SQL,无 IO |
| 入队 | `chwriter` / `risk_control` + `batchwriter` | flush 调 logstore `BatchInsert*`;CH 入队经 hooks |
| 切换 | `of_log_db_switch` | 冻结 → `chwriter.Drain` → 逐表复制 → 翻转 |
| 约束 | `logstore/imports_test.go` | apps 禁止 import `repository/analytics` |
`log_database` ∈ {`postgres`,`sqlite`,`clickhouse`},且只能是「随主库」或 ClickHouse:主库为 PG 时日志不能是 SQLite,反之亦然。`log_database` / `log_db_migration` 受保护,禁止管理端手动改。
`log_database` 只能是「随主库」或 `clickhouse`。`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`)。
1. **Model**(`internal/model/analytics`):`TableName` + `InsertColumns` / `BatchInsertSQL`。
2. **三套 DDL**:CH `MergeTree` + `toYYYYMM`;PG `PARTITION BY RANGE(时间列)`(主键含分区键);SQLite 普通表。不要在主库建 CH 物化视图,聚合实时算。
3. **挂到已有域或新接口**:能进 `AccessLogStore` / `ObservabilityStore` / `UserAccessLogStore` 就不要再拆包。新域才新增接口并放进 `Store`。
4. **方法最少集**:`BatchInsert`(含 `ensureWritable`)、业务查询、`ListForMigration`、`MigrationRange`、`DeleteAll`、`DeleteBefore`、`EnsurePartitions`(仅 PG 预建)。
5. **双实现**:CH 委托 `analyticsrepo`;GORM 共用一套,方言 SQL 放 `dialect_*.go`。零值 id 用 `idgen.NextUint64ID()`。
6. **`buildStore`**:CH / GORM 两分支都挂上。
7. **写入**:独立 `batchwriter`;`FlushFunc` → `logstore.Active`。节点日志/可观测走 `SetAccessLogHooks` / `SetObservabilityHooks`,不要让 apps 碰 `ChConn`。
8. **切换任务**:`clearTarget` + `copy*` 增加该表;源数据不删,失败不翻转。
9. **清理**:访问类走 `log_retention_days_*`;性能指标走 `metric_retention_days`。不要擅自共用错误的 TTL。
10. **import-lint**:apps 新增对 `analytics` 或 `infra/persistence`(`batchwriter`/`idgen` 除外)的 import 必须失败。
## 禁止
- apps 直接 `import` `internal/repository/analytics` 或 `db.ChConn` / `db.ChDB` 做日志读写
- 只建 CH 表、不建 PG/SQLite 回落
- 在 Handler 里逐条 `PrepareBatch` + `Send`
- 把业务表「顺便」放进 logstore 以便关 CH
- 管理端 API 改 `log_database` / `log_db_migration`
- apps 直连 `analyticsrepo` / `db.ChConn` / `db.ChDB` 做日志读写
- 只建 CH、不建主库回落
- Handler 内逐条 `PrepareBatch`
- 业务表塞进 logstore
- 管理端改 `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
go test ./internal/apps/openflare/... ./internal/apps/admin/logs ./internal/apps/admin/status
make swagger
make code-check
```
对照:`w_user_access_logs` 的 model、三库 goose、`logstore` GORM/CH、`risk_control.InitLogWriter`、`logs.LogDBSwitchHandler`、`system:cleanup`。
对照:`of_node_access_logs` 或 `w_user_access_logs` 的 model、三库 goose、`logstore` 双实现、`chwriter`/`risk_control` flush、`LogDBSwitchHandler`。
+56 -193
View File
@@ -1,219 +1,82 @@
---
name: "new-api"
description: "Wavelet 项目专用:当新增或修改业务 API、Handler、服务层逻辑、路由注册时必须使用。本技能指导 apps 业务包划分、路由注册、Handler/logics 分层、Swagger 与质量门禁;纠正把一切塞进 custom.go / apps/custom 或产品伞包的错误写法。"
description: "Wavelet 项目专用:当新增或修改自定义业务 API、新增业务路由、新增 service 层核心逻辑时必须使用。本技能指导包职责划分、推荐文件结构、路由解耦、Swagger 文档生成与质量门禁验证。"
---
# 新增业务 API 开发与路由注册规范
本技能是 Wavelet 接口开发与路由注册的唯一指导规范。在开发任何新接口前,请按本指南做架构决策与路由注册。
本技能是 Wavelet 项目接口开发与路由注册的唯一指导规范。在开发任何新接口前,请严格按照本指南进行架构决策与路由注册。
---
## 先搞清:脚手架 vs 产品化
## 核心路由准则与防线 (Routing Governance & Guardrails)
Wavelet 是**通用全栈脚手架**。仓库里的 `custom` 相关代码是**示例/占位**,不是产品业务的标准落点。
Wavelet 后端路由采用了**严格的框架层与业务层隔离机制**。请牢记以下开发原则:
| 层级 | 含义 | 典型包 |
| :--- | :--- | :--- |
| **平台能力** | 脚手架自带、与具体产品无关 | `oauth`、`user`、`admin/*`、`upload`、`cap`、`config`、`health`、`risk_control` |
| **产品业务** | 基于脚手架做具体产品时新增的域 | 直接落在 `internal/apps/<domain>/`,与平台包**平级** |
### 插件目录标准结构 (`backend/openflare/plugins/<name>/` 或 `backend/plugins/domain/<name>/`)
**一旦用脚手架开发具体产品,整个仓库就是该产品**——例如要做「消息平台」,业务模块应是 `apps/channel`、`apps/conversation`、`apps/delivery` 等,而不是先建 `apps/message` 伞包再往里塞子模块。
---
## 反模式(AI 最常踩的坑)
### 1. 把所有业务路由塞进 `custom.go` / 路径前缀 `/custom`
仓库中的:
- `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`)。
---
## 路由注册模型
### 谁可以改
| 文件 | 角色 | 产品化时 |
| :--- | :--- | :--- |
| `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` 调用 |
### 路径归属(产品 API 用语义路径)
| 目标路径特征 | 注册位置 | 说明 |
| :--- | :--- | :--- |
| `/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` | 平台,勿改用途 |
`custom.go` 里现有的 `/api/v1/custom/...` **仅作脚手架演示**,不代表业务必须挂在 `/custom` 下。
---
## 推荐目录结构(产品业务)
以「频道 / channel」域为例(消息平台中的一个限界上下文):
所有标准插件与下游定制插件,**统一以 `backend/downstream/plugins/custom_example` 为基准模板**,严格采用物理子包隔离的分层架构:
```text
internal/
├── router/
│ └── v1/
│ ├── v1.go # [修改] 调用 RegisterChannelRoutes
│ └── channel.go # [新建] 只负责挂载 channel 路由
└── apps/
└── channel/ # 与 oauth、user、upload 平级
├── routers.go # HTTP Handlers(绑定、鉴权上下文、响应)
├── logics.go # 纯业务:context.Context,无 gin
├── errs.go # 模块错误文案常量(可选)
└── ... # 需要时再加 service.go、tasks.go 等
backend/openflare/plugins/<name>/ (或 backend/plugins/domain/<name>/)
├── plugin.go # 插件根入口:实现 core.Plugin,装配各子包并向 Cordis 注册
│
├── consts/ # package consts:常量、配置键名与错误码定义
│ └── consts.go
│
├── controller/ # package controller:HTTP 控制器与路由声明 (参数绑定、会话获取、信封响应)
│ └── hello/ # 业务分组/实体子包
│ └── hello.go # 接口处理 Handler(直接以业务命名,禁止 controller_hello.go)
│
├── service/ # package service:业务逻辑层(用例编排、事务控制、事件发布)
│ └── order.go # 订单业务用例实现(纯 Go 逻辑,禁止依赖 *gin.Context)
│
├── dao/ # package dao:数据访问持久化层 DAL (GORM CRUD、SQL 转义防注入)
│ └── order.go # 订单数据访问实现(直接以业务命名,禁止 dao_order.go)
│
├── model/ # package model:纯数据实体与 DTO(无外部依赖)
│ ├── entity/ # 数据库映射实体 (TableName() 带插件专属前缀)
│ │ └── order.go
│ └── do/ # 请求 Request DTO 与响应 Response DTO、领域对象
│ └── order.go
│
└── migrations/ # 专属嵌入式 Goose SQL 双方言迁移脚本 (//go:embed)
├── postgres/ # PostgreSQL 迁移脚本
└── sqlite/ # SQLite 迁移脚本
```
**不要**建成:
```text
internal/apps/message/ # ❌ 产品伞包
channel/
inbox/
internal/apps/custom/ # ❌ 示例包当业务垃圾桶
channel_handler.go
```
模块内若复杂度高,可在**该域包内**分子目录(如 `apps/channel/handler`),但仍是一个域包,不是「产品名/子域」两层品牌结构。
> ⚠️ **严禁**:严禁在根目录平铺 `handlers_*.go`、`service_*.go`、`dao_*.go` 等前缀文件,子包内文件直接按业务实体命名。严格约束 `controller -> service -> dao -> model` 单向依赖。
---
## 路由注册示例
## 核心开发步骤 (Step-by-Step Flow)
### `internal/router/v1/channel.go`(产品业务)
### 步骤 1:数据库定义与迁移
如果自定义功能涉及新表或字段,请参考 [database-migration](../database-migration/SKILL.md) 技能,在 `internal/infra/persistence/migrator/goose/` 目录下编写迁移文件,在 `internal/model/` 中定义 GORM 实体(无 CRUD / 无 DB 访问),并在 `internal/repository/` 中实现数据访问(**repository 为唯一持久化入口**)。
```go
package v1
### 步骤 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)。
import (
"github.com/Rain-kl/Wavelet/internal/apps/channel"
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
"github.com/gin-gonic/gin"
)
### 步骤 3:编写 HTTP Handler (`routers.go`)
在 `internal/apps/custom/routers.go` 中编写 Handler:
- 负责请求参数绑定与校验(使用 `ShouldBindJSON`/`ShouldBindQuery`)。
- 负责提取 Session / 用户身份。
- 调用业务逻辑层,并使用 `OpenFlare/internal/shared/response` 统一返回响应:
- 成功时返回:`response.OK(data)` 或 `response.OKNil()`
- 失败时返回:`response.Err(msg)`
- 编写规范的 Swagger 注释。
// 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` 前缀。
### 步骤 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。
---
## 核心开发步骤
## 质量验证门禁 (Quality Gates)
### 步骤 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
每次新增或修改接口后,必须运行并验证以下各项:
1. **自动授权许可**:`make license`(新增 Go 文件时自动添加许可头)
2. **重新生成 Swagger 文档**:`make swagger`(若有 Swagger 注释修改)
3. **静态代码及风格检查**:`make code-check`(确保通过 golangci-lint 和前端 TS 检查)
4. **自动化单元测试**:`go test ./...`(确保所有测试 100% 通过)
@@ -6,50 +6,53 @@ package references
import (
"net/http"
"github.com/Rain-kl/Wavelet/internal/shared/response"
"OpenFlare/internal/service"
"OpenFlare/internal/util"
"github.com/gin-gonic/gin"
)
// createChannelRequest 客户端请求体 DTO
type createChannelRequest struct {
Name string `json:"name" binding:"required,min=1,max=100"`
// customRequest 客户端请求体 DTO
type customRequest struct {
Payload string `json:"payload" binding:"required,min=1,max=100"`
}
// createChannelResponse API 响应体 DTO
type createChannelResponse struct {
ID int64 `json:"id"`
Name string `json:"name"`
// customResponse API 响应体 DTO
type customResponse struct {
Result string `json:"result"`
}
// CreateChannel 示例:产品域 Handler(应放在 internal/apps/channel/routers.go)
// @Summary 创建频道
// @Description 示例:语义路径下的业务接口,而非 /api/v1/custom/...
// @Tags channel
// HandleCustomBusiness 示例 API Handler
// @Summary 示例定制业务接口
// @Description 接收数据载荷,调用 Service 执行核心逻辑,并返回统一格式的 JSON 结果。
// @Tags custom
// @Accept json
// @Produce json
// @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
// @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
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortBadRequest(c, "参数校验失败")
c.JSON(http.StatusBadRequest, util.Err("参数校验失败:载荷不能为空且在 1-100 字符内"))
return
}
// 通常结合 oauth.LoginRequired();此处仅演示从上下文取用户
// 2. 模拟获取当前上下文与已登录用户(例如从 Session 中提取)
// 通常结合 oauth.LoginRequired() 等中间件使用
userID := int64(9527)
result, err := CreateChannelLogic(c.Request.Context(), userID, req.Name)
// 3. 实例化业务 Service 并调用核心逻辑
// 注意传入 c.Request.Context() 以正确传递 OpenTelemetry Tracing 等上下文信息
svc := service.NewCustomService()
resText, err := svc.ProcessBusinessData(c.Request.Context(), userID, req.Payload)
if err != nil {
response.AbortBadRequest(c, err.Error())
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, response.OK(createChannelResponse{
ID: result.ID,
Name: result.Name,
// 4. 返回符合外层形状规范 { "error_msg": "", "data": ... } 的统一成功响应
c.JSON(http.StatusOK, util.OK(customResponse{
Result: resText,
}))
}
@@ -8,31 +8,25 @@ import (
"errors"
"fmt"
"github.com/Rain-kl/Wavelet/pkg/logger"
"OpenFlare/pkg/logger"
"go.uber.org/zap"
)
// 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")
// 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")
}
logger.Info(ctx, "creating channel",
logger.Info(ctx, "processing local business inside apps/custom/logics",
zap.Int64("user_id", userID),
zap.String("name", name),
zap.String("param", param),
)
// 轻量级本地逻辑;复杂持久化可进 model/repository
return &channelCreated{
ID: 1,
Name: fmt.Sprintf("%s (by %d)", name, userID),
}, nil
// 执行轻量级、无需跨模块/多入口复用的本地计算或模型操作
result := fmt.Sprintf("Processed local logic for user %d: %s", userID, param)
return result, nil
}
@@ -8,33 +8,38 @@ import (
"errors"
"fmt"
"github.com/Rain-kl/Wavelet/pkg/logger"
"OpenFlare/pkg/logger"
"go.uber.org/zap"
)
// ChannelService 示例有状态 Service(放在 internal/apps/channel/service.go)
// 需要注入 DB/客户端时使用;简单逻辑优先 logics.go 纯函数。
type ChannelService struct {
// 例如:repo ChannelRepository
// CustomService 示例业务 Service 结构体(通常放在 internal/apps/custom/service.go 中)
type CustomService struct {
// 这里可以注入数据库连接、配置对象或者其他基础服务的客户端
// 例如:db *gorm.DB
}
// NewChannelService 构造函数
func NewChannelService() *ChannelService {
return &ChannelService{}
// NewCustomService 创建 CustomService 实例的构造函数
func NewCustomService() *CustomService {
return &CustomService{}
}
// 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")
// 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")
}
logger.Info(ctx, "channel service create",
// 模拟执行业务逻辑...
logger.Info(ctx, "processing custom business data in service",
zap.Int64("user_id", userID),
zap.String("name", name),
zap.String("payload", payload),
)
// DB 事务、远程调用等
_ = fmt.Sprintf("user=%d name=%s", userID, name)
return 1, nil
// 这里可以包含数据库读写、事务控制、或者远程 API 调用等复杂逻辑。
result := fmt.Sprintf("Success processed data for user %d: %s", userID, payload)
return result, nil
}
+3 -2
View File
@@ -19,7 +19,8 @@ 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`:执行记录和日志持久化
- `internal/model/task_execution.go`:执行记录实体与 DTO
- `internal/repository/task_execution.go`:执行记录和日志持久化
需要模板时阅读 [references/CODE-EXAMPLES.md](references/CODE-EXAMPLES.md)。
@@ -42,7 +43,7 @@ description: "Wavelet 项目专用:新增或修改 Asynq 异步任务、后台
- 成功返回 `&task.TaskResult{Message: ..., Detail: ...}`。
- 失败返回 error,由任务框架处理状态和重试。
- 不要吞掉关键错误。
- 复杂 SQL 放到 `internal/model/` 或模块内的业务服务层(如 `internal/apps/<module>/service.go` 或 `logics.go`)。
- 持久化只通过 `internal/repository/`(唯一入口);业务编排放模块内 `logics.go` / `service.go`。`internal/model` 仅实体/DTO,禁止 CRUD 与 DB 访问。
### 注册
@@ -10,7 +10,7 @@
package upload
import (
"github.com/Rain-kl/Wavelet/internal/task"
"OpenFlare/internal/infra/task"
)
// 异步任务类型标识。格式建议为 "{module}:{action}"。
@@ -83,7 +83,7 @@ package upload
import (
"context"
"github.com/Rain-kl/Wavelet/internal/task"
"OpenFlare/internal/infra/task"
)
type CleanupUnusedUploadsHandler struct{}
@@ -114,7 +114,7 @@ import (
"fmt"
"strings"
"github.com/Rain-kl/Wavelet/internal/task"
"OpenFlare/internal/infra/task"
)
type SendEmailPayload struct {
@@ -169,9 +169,9 @@ func (h *SendEmailHandler) Execute(ctx context.Context, payload []byte) (*task.T
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/task"
"OpenFlare/internal/apps/upload"
"OpenFlare/internal/apps/user"
"OpenFlare/internal/infra/task"
)
func Register() {
+11 -9
View File
@@ -14,7 +14,7 @@ description: "Wavelet 项目专用:当新增或修改启动时设置、数据
Wavelet 当前有两套设置入口:
- 启动时设置:来自 `config.yaml` 或环境变量,适合进程启动前必须确定、通常不热更新的基础配置。
- 系统设置:保存于数据库 `system_configs`,经 `model.SystemConfig` 和 Redis hash 缓存读取,支持运行时热更新。管理入口是 `/admin/system` 和 `/admin/settings`。
- 系统设置:保存于数据库 `system_configs`,经 `model.SystemConfig` 实体(key 常量在 model)与 `repository` 读取层(含 Redis hash 缓存)访问,支持运行时热更新。管理入口是 `/admin/system` 和 `/admin/settings`。
系统设置分三种使用语义:
@@ -30,7 +30,8 @@ Wavelet 当前有两套设置入口:
修改前快速查看这些文件,确认当前实现没有漂移:
- `internal/model/system_configs.go`: 配置 key 常量、`SystemConfig` 模型、`GetByKey`、`GetBoolByKey`、`GetIntByKey`、`GetDecimalByKey` 等读取方法。
- `internal/model/system_configs.go`: 配置 key 常量(`ConfigKey*`)、`SystemConfig` 实体与字段语义;**不含**持久化读取 API。
- `internal/repository/system_config.go`: 配置读取与缓存(`GetSystemConfigByKey`、`GetBoolByKey`、`GetIntByKey`、`GetDecimalByKey`、`ListVisibleSystemConfigs` 等)。
- `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。
@@ -61,12 +62,13 @@ Wavelet 当前有两套设置入口:
- 如果相关 Go 包测试依赖默认配置,同步 `internal/testhelper/test_helper.go` 的 `seedDefaultConfigs` 和公共 key 列表。
3. 读取配置。
- 后端业务代码优先使用 `model.GetBoolByKey`、`model.GetIntByKey`、`model.GetDecimalByKey` 或 `SystemConfig.GetByKey`。
- 后端业务代码通过 `internal/repository` 读取:`repository.GetBoolByKey`、`repository.GetIntByKey`、`repository.GetDecimalByKey` 或 `repository.GetSystemConfigByKey`;key 常量仍用 `model.ConfigKey*`。
- 禁止新增或调用 `model.Get*ByKey` / `model.ListVisibleSystemConfigs` 等数据访问 API(model 无 CRUD)。
- 运行时可热更新的规则不要放进 `config.Config`;启动时设置才走 `internal/infra/config/model.go` 和 `config.example.yaml`。
- 不要在 handler 或业务代码里直接读 `os.Getenv()`。
4. 如果前端需要未登录或全局消费,暴露为公共可见配置。
- 把该配置的 `visibility` 设为 `1`,`GetPublicConfig` 会通过 `model.ListVisibleSystemConfigs` 返回所有可见 key/value。
- 把该配置的 `visibility` 设为 `1`,`GetPublicConfig` 会通过 `repository.ListVisibleSystemConfigs` 返回所有可见 key/value。
- `/api/v1/config/public` 的 `data` 是动态对象:后端返回 `map[string]string`,前端类型是 `Record<string, string | undefined>`。
- 前端读取时按配置 key 访问,必要时在消费侧把字符串转换为 boolean/number/JSON。
- 检查使用方的 query key,更新后需要 invalidate `["public-config"]`。
@@ -99,9 +101,9 @@ Wavelet 当前有两套设置入口:
### 布尔公共设置
- model key:`ConfigKeyFeatureEnabled = "feature_enabled"`
- model key:`ConfigKeyFeatureEnabled = "feature_enabled"`(定义在 `internal/model`)
- goose SQL 默认值:`value='false'`,`type` 按语义选 `"system"` 或 `"business"`,`visibility=1`。
- 后端读取:`model.GetBoolByKey(ctx, model.ConfigKeyFeatureEnabled)`。
- 后端读取:`repository.GetBoolByKey(ctx, model.ConfigKeyFeatureEnabled)`。
- 公共响应:`/api/v1/config/public` 的 `data.feature_enabled` 为字符串 `"true"` 或 `"false"`。
- 前端图形控件:`Switch`,保存时写 `"true"` / `"false"`。
@@ -109,13 +111,13 @@ Wavelet 当前有两套设置入口:
- model key:`ConfigKeyMaxSomething = "max_something"`。
- goose SQL 默认值:例如 `"5"`,`type` 通常为 `"business"`,只有前端公共消费时才设 `visibility=1`。
- 后端读取:`model.GetIntByKey` 或 `model.GetDecimalByKey`。
- 后端读取:`repository.GetIntByKey` 或 `repository.GetDecimalByKey`。
- 前端图形控件:`Input type="number"` 或合适的 shadcn 数值控件;保存前做最小必要校验,错误用 toast。
### JSON 设置
- 默认值使用合法 JSON,例如 `"{}"` 或 `"[]"`。
- 在 model 或 service 层提供解析函数,像 `GetMenuDisplayConfig` 一样把 JSON 解析错误包装成清晰错误。
- 在 repository 或业务 logics 中提供解析函数,像 `repository.GetMenuDisplayConfig` 一样把 JSON 解析错误包装成清晰错误;不要在 model 中做 IO。
- 前端不要直接拼接 JSON 字符串;用 `JSON.stringify` 写入,用类型化对象在组件中操作。
## 验证
@@ -125,7 +127,7 @@ Wavelet 当前有两套设置入口:
- 新增或修改系统配置默认值、visibility 或公共配置读取:至少运行相关 Go 包测试,例如:
```bash
go test ./internal/model ./internal/apps/config ./internal/apps/admin/system_config
go test ./internal/repository ./internal/apps/config ./internal/apps/admin/system_config
```
- 新增 goose 迁移后,至少用当前数据库方言跑一次迁移;如果 SQL 同时改了 PostgreSQL 和 SQLite,尽量覆盖两种方言。涉及 schema/seed 的任务还应遵循 database-migration skill。
+6 -7
View File
@@ -14,10 +14,9 @@ description: "Wavelet 项目专用:当需要开发或接入新的系统通知
Wavelet 的消息推送机制采用了**元数据驱动 + 统一触发器 + 异步任务派发**的解耦设计,其分层及职责划分如下:
| 目录/包名 | 职责定位 | 包含内容与设计细节 |
| :--- | :--- | :--- |
| **`pkg/push/`** | 推送基础设施层 | 静态定义、不依赖系统数据库和任何框架。定义了统一接口 `Pusher`、单例 `PusherPool` 和多实现(Lark, Webhook, Email 等),提供配置验证及发送功能。 |
| **`internal/apps/admin/push/`** | 通知服务与后台任务层 | 包含以下核心文件:<br>1. [events.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/admin/push/events.go):定义通知事件的结构模型(`NotificationMessage`, `EventMetadata`)、内置事件的动态注册中心(`BuiltInEvents` 及 `RegisterBuiltInEvent` 函数)以及统一触发器类 `EventTrigger`(包括其底层的派发引擎逻辑)。<br>2. [tasks.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/admin/push/tasks.go):定义 Asynq 后台异步发送任务、处理器 `PushHandler` 及其校验逻辑,并记录推送历史审计。<br>3. [routers.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/admin/push/routers.go):管理端接口,负责获取事件配置列表和更新配置。 |
| **`internal/apps/admin/push/custom_events/`** | 自定义通知事件包 | 事件元数据定义与 push 侧处理逻辑;**一个 Go 文件代表一个事件**。在 [register.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/admin/push/custom_events/register.go) 统一装配,禁止 `init()` 副作用。 |
| **`backend/plugins/domain/msg_gateway/push/`** | 推送基础设施层 | 静态定义、不依赖系统数据库和任何框架。定义了统一接口 `Pusher` 和多实现(Lark, Webhook, Email 等),提供配置验证及发送功能。 |
| **`internal/apps/admin/push/`** | 通知服务与后台任务层 | 包含以下核心文件:<br>1. `events.go`:定义通知事件的结构模型(`NotificationMessage`, `EventMetadata`)、内置事件的动态注册中心(`BuiltInEvents` 及 `RegisterBuiltInEvent` 函数)以及统一触发器类 `EventTrigger`(包括其底层的派发引擎逻辑)。<br>2. `tasks.go`:定义 Asynq 后台异步发送任务、处理器 `PushHandler` 及其校验逻辑,并记录推送历史审计。<br>3. `routers.go`:管理端接口,负责获取事件配置列表和更新配置。 |
| **`internal/apps/admin/push/custom_events/`** | 自定义通知事件包 | 事件元数据定义与 push 侧处理逻辑;**一个 Go 文件代表一个事件**。在 `register.go` 统一装配,禁止 `init()` 副作用。 |
| **`internal/listener/`** | 域事件分发层 | 核心域发射事件(如 `EmitAdminLoggedIn`),push 在 bootstrap 阶段通过 `OnAdminLoggedIn` 订阅,避免 auth/user 直接依赖 push。 |
| **`internal/platform/bootstrap/`** | 应用装配根 | `RegisterPushDomainEvents()` 调用 `custom_events.Register()`;`Init` 中执行 `SyncEvents` 将内置事件元数据同步到数据库。 |
| **数据库审计表** | 状态与历史审计 | `w_push_events` 存放每个通知事件的启用状态、启用渠道、发送目标和自定义渲染模板。<br>`w_push_histories` 存放消息发送记录用于审计。 |
@@ -38,8 +37,8 @@ import (
"context"
"time"
"github.com/Rain-kl/Wavelet/internal/apps/admin/push"
"github.com/Rain-kl/Wavelet/internal/listener"
"OpenFlare/internal/apps/admin/push"
"OpenFlare/internal/listener"
)
var NewUserRegistered = push.EventMetadata{
@@ -84,7 +83,7 @@ func Register() {
在业务逻辑完成处(如 `internal/apps/user/routers.go`)仅 import `internal/listener` 并发射事件:
```go
import "github.com/Rain-kl/Wavelet/internal/listener"
import "OpenFlare/internal/listener"
func Register(c *gin.Context) {
// ... 注册成功逻辑 ...
+4 -4
View File
@@ -1,13 +1,13 @@
---
name: "release-guide"
description: "项目专用:根据自上一个正式版本 Tag 以来的提交记录,整理生成规范的 Version Bump Commit Message,用于触发自动双语 Release。"
description: "Wavelet 项目专用:根据自上一个正式版本 Tag 以来的提交记录,整理生成规范的 Version Bump Commit Message,用于触发自动双语 Release。"
---
# Release Commit Message Guide
## 目标
当用户准备发布新版本时,本 Skill 负责:
当用户准备发布 Wavelet 新版本时,本 Skill 负责:
1. 根据上一正式版本 Tag 以来的提交,整理面向用户的发版说明;
2. 新建 **独立的** `chore(release): vX.Y.Z` 提交(可附带将 `docs/changelog` 从 `[unreleased]` 落版)。
@@ -51,8 +51,8 @@ description: "项目专用:根据自上一个正式版本 Tag 以来的提交
「修复/优化」与「新增」的判定(关键):
- **判定标准是“该功能在上一正式版本中是否已存在”**:
- 已存在 → 本次对其 bug 的修正可计入「🛠 修复」,对其行为/性能的改进可计入「⚡️ 优化与改进」;
- 不存在(本版本新增)→ 该功能的一切内容——包括开发过程中修的 bug、做的性能优化、补的索引——都只属于新功能开发的一部分,不应该在发布说明中提及。
- 已存在 → 本次对其 bug 的修正可计入「🛠 修复」,对其行为/性能的改进可计入「⚡️ 优化与改进」;
- 不存在(本版本新增)→ 该功能的一切内容——包括开发过程中修的 bug、做的性能优化、补的索引——都只属于新功能开发的一部分,不应该在发布说明中提及。
- 禁止把新功能的开发期修复/优化写进「修复」或「优化」:新功能此前版本没有,谈不上“修复/优化了旧行为”。
示例:
+242
View File
@@ -0,0 +1,242 @@
# Autoresearch lessons — Wavelet / Cordis quality run
Accumulated wisdom across iterations. Read this before forming a hypothesis.
Weight recent lessons higher: the yardstick and codebase change under us.
## Lesson 1 — iterations 0-1
**Pattern**: The project's committed gate (`golangci-lint run` with `.golangci.yml`)
had already been driven to 0 issues by a previous run, so it could no longer
measure anything.
**Why it worked**: Measuring against a pinned snapshot + extra analyzers in
`.auto/lint.ref.yaml` (hash-locked by the Guard) restored headroom and made it
impossible to lower the number by editing the config.
**Conditions**: Any repo whose own lint gate is already green.
**Anti-pattern**: Optimising `tagliatelle` (325 findings) or `wrapcheck` (290).
Those are pure cosmetics — error-message wording and tag naming. A run that
chases them will look productive while shuffling strings.
**Metric delta**: baseline re-established at 102 instead of a dead 0.
## Lesson 2 — iterations 1-4
**Pattern**: Triage every analyzer finding for reality before "fixing" it.
**Why it worked**: Three buckets turned out to be false positives:
`forcetypeassert` in `core/events.go` is guarded by `returnsErr` (the handler's
declared last out really is `error`), and both `exhaustive` switches already
have `default:` arms — `exhaustive` only flags them because
`default-signifies-exhaustive` defaults to false.
**Conditions**: Always, but especially for linters whose defaults assume a
different project convention.
**Anti-pattern**: Adding `if !ok { ... }` branches or empty `case:` arms that
cannot execute. That raises the score and lowers the code.
**Metric delta**: 3 of 16 candidate linters dropped from the plan (0 gained,
real regressions avoided).
## Lesson 3 — iterations 1, 4
**Pattern**: Pair the metric drop with a mechanically provable defect: write the
regression test, commit, then revert *only* the source files and require the
test to fail (`.auto/prove_fix.sh`).
**Why it worked**: It caught a live bug that no counter measures — a
singleflight body capturing the first caller's request context, so one
disconnecting browser poisoned every concurrent request for that image.
Iteration 4 kept debt flat at 93 yet was the most valuable change so far.
**Conditions**: Every behavioural fix. A change that survives its own revert is
not a fix, it is a rename.
**Anti-pattern**: Calling something "hardening" without a test that fails
without it.
**Metric delta**: 0 for the proven bug (kept under the fix gate), 8 for the rest.
## Lesson 4 — iteration 5
**Pattern**: Strengthen the architecture gate; it is a generator of real,
previously invisible debt.
**Why it worked**: `check_cordis_architecture.sh` only grepped `go func(`, so
`go w.run()` — the shape used by four long-lived cleanup loops — passed
silently, each one able to take down the process on a panic. Widening the
pattern surfaced them immediately.
**Conditions**: Whenever a gate has been green for a long time. A green gate
proves the checks exist, not that they cover anything.
**Anti-pattern**: Weakening `.golangci.yml` (blocked outright by the Guard via
`check_gate_weaken.py` + a SHA lock on the yardstick).
**Metric delta**: 4 uncovered crash-on-panic sites hardened.
## Lesson 5 — iteration 3
**Pattern**: Deduplicate by extracting the shared *classification*, not the
shared *response*.
**Why it worked**: Two handlers mapped upload-lookup errors with copy-pasted
blocks that had quietly drifted (different fallback status, different synonym
constant for the same message). `filesrv.AbortUploadRecordError` handles the
200/400 branches, and each endpoint keeps its own fallback it can still
justify. Deleting the orphaned `ErrInvalidUploadID` constant was part of the
change, not extra cleanup.
**Conditions**: Duplicated error-mapping or validation blocks in sibling handlers.
**Anti-pattern**: Silently unifying HTTP status codes across endpoints to make a
helper fit — that is a behaviour change wearing a refactor's clothes.
**Metric delta**: -2.
## Lesson 6 — iterations 15-21
**Pattern**: Delegate a broad read-only audit for what mechanical gates cannot
see (N+1s, locks held over I/O, resource leaks, layering), then re-verify each
claim yourself before touching code.
**Why it worked**: The audit produced the run's best findings — the per-request
CORS database query, the orphan cron dispatching to a task nobody registered,
media temp dirs nothing ever removed. It also produced a wrong one: it asserted
telebot falls back to `http.DefaultClient` with no timeout, when telebot itself
constructs a client with a one minute deadline. Acting on that would have added
a tunable dressed up as a bug fix.
**Conditions**: Whenever the committed gates are green and the easy signal is
exhausted.
**Anti-pattern**: Trusting an audit summary's file:line as evidence. One
referenced file did not exist.
**Metric delta**: 0 for three landed fixes (all kept under the proven-fix gate),
but they were the run's highest-impact changes.
## Lesson 7 — iteration 16
**Pattern**: Prove query-reduction with a functional test double that counts
loader invocations, and assert the counter for both the batch and the looped
form in the same test.
**Why it worked**: Asserting "1 query" alone is vacuous — it also passes when
nothing ran. Asserting batch=1 and per-id=3 in one test makes the instrument
itself checked, so the claim cannot silently degrade.
**Conditions**: Any change whose whole value is doing less I/O.
**Anti-pattern**: Fixing an N+1 by reaching around the contract into another
plugin's repository. The layering was the reason the slow path existed; the
right move was to extend the contract with a batch method.
**Metric delta**: 0 (kept under the proven-fix gate).
## Lesson 8 — iterations 17-22
**Pattern**: Strengthen a gate only alongside the code that satisfies it, and
never rewrite history in a shared worktree.
**Why it worked**: Deleting 24 dead lint suppressions paid off exactly as the
self-correcting design predicted: two of them were load-bearing under the
project's own gate even though the analyzer called them unused, the Guard
vetoed, and their removal surfaced two verified `contextcheck` false positives
worth documenting instead of silently swallowing. Meanwhile a concurrent
session was committing plan documents in the same tree, so `git add -A` swept
one of its in-flight edits into my commit — unfixable by rebase without
destroying their work, so the repair was to stage explicit paths from then on.
**Conditions**: Always, in this repo. Assume another agent is editing `docs/`
and `backend/core` concurrently.
**Anti-pattern**: `git add -A` outside the first setup commit. Also: trusting
"unused directive" as "safe to delete" — check the strictest config, not just
the pinned yardstick.
**Metric delta**: -25 in one iteration.
## Lesson 9 — iterations 23-24
**Pattern**: Cross-check every service a plugin's `Apply` reads out of the
container against what that plugin's `Inject()` declares. `Inject()` is the only
thing `App.reconcileLocked` gates on, so anything consumed as a *value* at Apply
time but left undeclared is resolved from a container that may not hold it yet.
**Why it worked**: It found the run's worst defect, invisible to every
mechanical gate: `user` declared only `DBService` while capturing
`contracts.AuthService` to build its route guard, and `cmd/app.go` lists `user`
before `auth`. Because user's dep set is a strict subset of auth's and it sits
earlier in the slice, user *always* mounts first — deterministically, not a
race — so `loginMW` fell back to a `c.Next()` closure and
`/api/v1/user/{change-password,profile,access-tokens}` mounted unguarded. The
same lookups in `admin` read a package global that its own `OnDispose` nils, so
in-flight requests fail open during dispose.
**Conditions**: Any Cordis plugin whose Apply assigns a contract result to a
variable used later (middleware, handler closures). Services bound through
`core.When` late binding are exempt — that is the correct pattern for genuinely
late deps, so do not blanket-declare everything.
**Anti-pattern**: Assuming a checked `x, ok :=` assertion is safe. All three
plugins used the checked form and all three failed *open* — checked syntax,
unchecked semantics.
**Metric delta**: 0 across both iterations (kept under the proven-fix gate),
but this is the run's highest-severity finding. `RouterRegistry` records each
route's `Handlers`/`Middlewares`, which makes "is this route actually guarded?"
directly assertable from the route table — the cheapest available oracle for
security properties here.
## Lesson 10 — iteration 23 review
**Pattern**: When the remaining metric is dominated by a positional or
taste-based analyzer, say so and refuse to spend iterations on it.
**Why it worked**: `funcorder` was 21 of 54 findings (39%) — pure function
*ordering within a file*. Reordering private helpers to the bottom of a file
moves the number and changes nothing a reader or the machine cares about, which
is Lesson 1's "looks productive while shuffling strings" with a different label.
Skipping it kept the loop honest. Triage also cleared 12 of 13
`forcetypeassert` (guarded by construction) and 2 of 3 `unparam` (deliberate
constructor symmetry behind one factory switch).
**Conditions**: Whenever one linter dominates a shrinking total, break the count
down per linter *before* picking a hypothesis.
**Anti-pattern**: Treating a large single-linter share as an easy win. Real
headroom at this point is ~10 findings, so a plateau in `debt` no longer means a
stalled loop.
**Metric delta**: 0 spent, ~21 findings deliberately left in place.
## Lesson 11 — iterations 24-25
**Pattern**: Run the Guard after every single commit, and confirm which commit a
proof script is actually reverting against.
**Why it worked**: Four `staticcheck ST1023` findings from iteration 24 shipped
straight through `go build ./...` and a green 47-package `go test ./...` —
neither runs the project linter, so only `checks.sh` section 3 catches them.
Separately, `prove_fix.sh` reverts to `HEAD^`; appending the iteration-23 log
commit shifted `HEAD^` to the *fixed* state and reported "PROVE FAILED: tests
still pass without the fix" on a genuinely load-bearing fix. Re-checking against
the explicit pre-fix commit (`git checkout <sha> -- <files>`) showed the real
answer. A false negative here is worse than no proof: it reads like the fix was
cosmetic.
**Conditions**: Always. Also note zsh does not word-split unquoted variables, so
`git checkout $FILES` passes one bogus pathspec and silently reverts nothing —
the command still exits 0.
**Anti-pattern**: Batch-verifying at ship time. And any shell loop built on the
bash word-splitting habit in this environment.
**Metric delta**: -0, 1 wrong verdict corrected.
## Lesson 12 — iterations 27-32
**Pattern**: Two things produced every substantive win: (1) find a place where
correctness rests on a *prose comment* instead of an enforced constraint, and (2)
find immutable startup work being redone inside a request path.
**Why it worked**: Lint cannot see either class, so `debt` barely moved while real
defects did. The comment "field comes from call sites, never from user input" sat
on a function that interpolated its column argument straight into `WHERE` — the
tautology payload executed and returned a row with `err=<nil>`, a filter bypass,
not a hypothetical. The comment "contracts are pure abstractions" sat on a DTO
carrying `TableName()`, which is exactly the handle four plugins used to read
`w_users` instead of calling `UserService`. On the second pattern, three packages
each re-normalised and re-split static whitelist patterns per request: hoisting
that to registration cut 14 allocs/op to 1.
**Conditions**: Any exported function taking a string that reaches SQL, a path
matcher, or a shell. Any loop over configuration inside a request handler.
**Anti-pattern**: Believing `nolintlint`'s "unused directive" means "safe to
delete" — hit twice now, and the project gate vetoed it both times. Also believing
a doc comment's self-assessment: verify the claim or leave it alone.
**Metric delta**: 64 -> 63 across five keeps. Four of the five kept changes had
delta 0. Under a pure-debt loop this run would have looked stalled while fixing a
security bypass and a hot-path allocation bug.
## Standing notes
- **The golangci-lint cache is machine-wide** (`~/.cache/golangci-lint`), so a
sibling worktree analysing identical sources replays here carrying *that*
checkout's absolute paths — 12 of 63 findings pointed outside the repo, which
misattributes findings and can serve a stale Guard verdict. `measure.sh` and
`checks.sh` now key `GOLANGCI_LINT_CACHE` per checkout (iteration 31). It is
count-neutral (cold and warm both 63), but check path attribution before
trusting any finding's location.
- **Do not delegate a repo-wide audit to one subagent.** Both broad audits
(architecture, bugs/perf) hit the 150-turn cap after ~45M tokens combined and
returned nothing usable. Everything this run found came from targeted inline
greps followed by reading the specific function. If delegating, bound it to one
package cluster and a small finding budget.
- Run decisions for this run: real defects first with `debt` as a secondary gate,
commits directly on `main`, small file moves allowed but large package
restructuring goes to a written proposal first.
- Upstream moves fast in this repo: `origin/main` gained 11 commits mid-run
(Cordis config extension point — `ctx.Config().Bind`, `DeclareConfig()`,
`core.ConfigGatedPlugin`), which raised measured `debt` 54 -> 64 and
`nolint_dirs` 72 -> 73 on its own. Rebase early and re-run the Guard after;
a clean rebase does not mean a green one.
- `cmd.TestNewWaveletAppWithRedisEnabled` needs a live Redis on
`127.0.0.1:6379` and fails without one. Pre-existing on `origin/main`, so
`tests_passed` 46 vs 47 is environmental, not a regression. Confirm against a
scratch `git worktree` of `origin/main` before blaming a change for it.
- Repo facts: backend module rooted at `backend/`, gofumpt orders a single
import group as `Wavelet/...` before stdlib (uppercase sorts first); new Go
files need the Apache license header or `scripts/update_go_license.sh --check`
fails the Guard.
- Handler edits require `make swagger` (cheap: it regenerates identical docs
when only bodies change).
- Dead suppressions are tracked by the `nolint_dirs` counter; removing one that
is still needed re-raises the original finding, so the metric self-corrects.
24 were removed in iteration 22; 72 remain, each still doing work (73 after
the upstream rebase).
+12
View File
@@ -0,0 +1,12 @@
# Autoresearch baseline — captured at iteration #0 (2026-08-29).
# The Guard compares live values against these floors; the PRIMARY metric is debt.
BASE_DEBT=102
BASE_NOLINT=96
BASE_TESTS_PASSED=46
BASE_TEST_FUNCS=232
BASE_TEST_FILES=76
BASE_ARCH_VIOL=0
BASE_COVERAGE=34.09
# SHA-256 of the pinned yardstick config. Guard aborts if it changes.
REF_SHA=e881bda167bd688489f1356b7cd4056b8a6960f48b6778b095bdd2e44f627b82
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""Anti-cheat: prove .golangci.yml was only ever strengthened, never weakened.
Compares the live gate against the immutable snapshot taken at run start.
Exits non-zero with a reason if any hardening rule is violated.
"""
import os
import sys
import yaml
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BASELINE = os.path.join(ROOT, ".auto", "gate.baseline.yml")
LIVE = os.path.join(ROOT, ".golangci.yml")
# threshold-like knobs: (path, direction) where direction "max" means the value
# is an upper bound (smaller == stricter), "min" means a lower bound.
STRICTNESS = [
(("linters", "settings", "dupl", "threshold"), "max"),
(("linters", "settings", "cyclop", "max-complexity"), "max"),
(("linters", "settings", "cyclop", "package-average"), "max"),
(("linters", "settings", "nestif", "min-complexity"), "min"),
(("linters", "settings", "funlen", "lines"), "max"),
(("linters", "settings", "funlen", "statements"), "max"),
(("linters", "settings", "gocyclo", "min-complexity"), "min"),
(("linters", "settings", "lll", "line-length"), "max"),
]
def load(path):
with open(path, encoding="utf-8") as fh:
return yaml.safe_load(fh) or {}
def dig(doc, path):
node = doc
for key in path:
if not isinstance(node, dict) or key not in node:
return None
node = node[key]
return node
def enabled_linters(doc):
lint = doc.get("linters") or {}
if lint.get("enable-presets"):
return None # preset based; fall back to "any removal is suspicious"
return set(lint.get("enable") or [])
def main():
try:
base, live = load(BASELINE), load(LIVE)
except OSError as exc:
print(f"gate snapshot unreadable: {exc}")
return 1
except yaml.YAMLError as exc:
print(f".golangci.yml is not parseable: {exc}")
return 1
problems = []
base_lint, live_lint = base.get("linters") or {}, live.get("linters") or {}
if (base_lint.get("default") or "none") != (live_lint.get("default") or "none"):
problems.append("linters.default changed")
base_set, live_set = enabled_linters(base), enabled_linters(live)
if base_set is None or live_set is None:
if set((base.get("linters") or {}).get("enable-presets") or []) - set(
(live.get("linters") or {}).get("enable-presets") or []
):
problems.append("an enable-preset was removed")
elif dropped := base_set - live_set:
problems.append(f"linters disabled: {sorted(dropped)}")
for path, direction in STRICTNESS:
old, new = dig(base, path), dig(live, path)
if old is None or new is None:
continue
try:
old_f, new_f = float(old), float(new)
except (TypeError, ValueError):
continue
if direction == "max" and new_f > old_f:
problems.append(f"{'.'.join(path)} loosened {old} -> {new}")
if direction == "min" and new_f < old_f:
problems.append(f"{'.'.join(path)} loosened {old} -> {new}")
base_mnd = set(dig(base, ("linters", "settings", "mnd", "checks")) or [])
live_mnd = set(dig(live, ("linters", "settings", "mnd", "checks")) or [])
if base_mnd - live_mnd:
problems.append(f"mnd checks dropped: {sorted(base_mnd - live_mnd)}")
issues_live = live.get("issues") or {}
for key in ("exclude-rules", "exclude-patterns"):
if issues_live.get(key) and not (base.get("issues") or {}).get(key):
problems.append(f"issues.{key} added (suppresses reporting)")
for key in ("max-issues-per-linter", "max-same-issues"):
old = (base.get("issues") or {}).get(key)
new = issues_live.get(key)
if old == 0 and new != 0:
problems.append(f"issues.{key} no longer 0 — findings would be truncated")
# Exclusions expressed through the newer 'linters.exclusions' block.
if (live_lint.get("exclusions") or {}) and not (base_lint.get("exclusions") or {}):
problems.append("linters.exclusions added")
if problems:
print("\n".join(f" - {p}" for p in problems))
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+53
View File
@@ -0,0 +1,53 @@
#!/bin/bash
# Correctness gate: must pass after every edit. Fails fast on real breakage.
set -euo pipefail
cd "$(dirname "$0")/.."
echo "==> go vet ./..."
go vet ./... 2>&1 | tail -20
echo "==> go build ./..."
go build ./... 2>&1 | tail -20
echo "==> golangci-lint run (repo config)"
golangci-lint run 2>&1 | tail -20
# 全量单测(sqlite + miniredis,纯本地无需外部服务;2026-08-16 起全绿)
echo "==> go test ./internal/... ./pkg/..."
go test ./internal/... ./pkg/... 2>&1 | grep -E "^--- FAIL|^FAIL" | head -20 || true
if go test ./internal/... ./pkg/... > /tmp/auto_gotest.log 2>&1; then
:
else
tail -30 /tmp/auto_gotest.log
exit 1
fi
# 前端测试(vitest;2026-08-16 起全绿)
echo "==> pnpm exec vitest run (frontend)"
(cd frontend && node scripts/merge-i18n-fragments.mjs && pnpm exec vitest run --reporter=dot > /tmp/auto_vitest.log 2>&1) || {
tail -30 /tmp/auto_vitest.log
exit 1
}
# SPDX license 头门禁(repo 自带约定)
echo "==> make license-check"
make license-check 2>&1 | grep "needs license" | head -10 || true
if make license-check > /tmp/auto_license.log 2>&1; then
:
else
tail -15 /tmp/auto_license.log
exit 1
fi
# 并发密集包 -race 门禁(2026-08-16 全仓 -race 清零后纳入,防回归;
# frpc/frps 慢套件不含在此,另做全量周期验证)
echo "==> go test -race (concurrency packages)"
RACE_PKGS="./internal/apps/oauth/ ./internal/apps/openflare/tls/ ./internal/apps/openflare/uptimekuma/ ./internal/apps/upload/cache/ ./internal/repository/ ./pkg/cache/disk/ ./pkg/logger/ ./internal/infra/persistence/batchwriter/"
if go test -race -count=1 $RACE_PKGS > /tmp/auto_race.log 2>&1; then
:
else
grep -E "WARNING: DATA RACE|^--- FAIL|^FAIL" /tmp/auto_race.log | head -20
exit 1
fi
echo "OK: checks passed"
+61
View File
@@ -0,0 +1,61 @@
version: "2"
run:
timeout: 5m
tests: false
linters:
default: none
enable:
# 基础检查
- govet
- staticcheck
- errcheck
- ineffassign
- unused
# 代码坏味道
- dupl # 重复代码
- mnd # 魔法数字
- goconst # 不必要的字符串常量
- cyclop # 包/函数复杂度
- nestif # if 嵌套太深
- maintidx # 维护性指数
- revive # 风格/命名/坏味道
- gocritic # 各类代码问题
- funlen # 函数过长
- gosec # 安全问题检查
- bodyclose # HTTP response body 没有正确关闭
- noctx # 没有传递 context.Context
- contextcheck # 其他检查
- sqlclosecheck # SQL rows 没有正确关闭
- unconvert # 不必要的类型转换
- nilerr # 函数返回 nil 错误
settings:
dupl:
threshold: 80
cyclop:
max-complexity: 20
package-average: 10
nestif:
min-complexity: 5
funlen:
lines: 200
statements: 100
mnd:
checks:
- argument
- condition
- return
# 完整上报所有问题(取消 golangci 默认 50/3 截断,保证 code-check 与度量真实)
issues:
max-issues-per-linter: 0
max-same-issues: 0
+169
View File
@@ -0,0 +1,169 @@
# Ideas backlog (代码质量)
## 已尝试并收尾(2026-08-16 会话,14 个实验,108→8)
- 生产代码 golangci 扩展集 13 类 linter 全量清理(modernize/perfsprint/
errorlint/canonicalheader/usestdlibvars/intrange/wastedassign/errname/
forcetypeassert/prealloc/gosec/recvcheck/exhaustive),剩余 8 处全部为
有据可查的刻意保留项(telegram %v、3 处嵌套 struct omitempty、
3 处 not-found 惯例、1 处 encoding/json 接收者混合)。
- 测试代码质量维度(testifylint/usetesting/thelper)25→0。
- 前端 eslint/tsc 0。
- 修复中积累的工具经验:golangci-lint v2 `--fix` 的 import 管理不可靠,
跑完必须 `goimports -w`;`--max-issues-per-linter=0` 才能拿到全量清单
(默认 50 + max-same-issues=3 会掩盖重复模式);cyclop 与 exhaustive
有张力(显式 case 计入复杂度)。
## 未来可深化方向(均经评估)
- 测试可运行性修复:`go test ./internal/...` 目前在 main 上就有失败
(无本地 redis、frpc 进程测试 flaky)。修复这些环境问题后,可以把
`go test` 加入 checks.sh,解锁 paralleltest/tparallel 维度
(t.Parallel 提速 + 正确性,目前因共享状态+不可运行而放弃)。
- frontend biome 格式漂移(76 文件):一次性 `make format` 提交,
与质量修复分开做,不进基准。
- fieldalignment:结构体内存布局优化,但会改变 JSON key 顺序且有
位置字面量风险 —— 若做,需按文件人工核对,不进自动基准。
- Go 1.26 新特性扫描:`go vet` 新分析器、golangci-lint 新 linter
(如 recvcheck 之后的 new receivers 检查)随版本跟进。
- 文档/示例代码(docs/、scripts/)质量:目前不在 golangci 范围(tests:false
之外还有 scripts 目录),可用同一扩展集扫 scripts/ 下的 main.go。
## 会话收尾(2026-08-16,run #23 后)
- 已确认收敛:基准 5 维全下限、-race 全仓清零、双端测试全绿、发布构建可复现、
config.example.yaml ↔ model.go 同步无漂移、无 flaky 测试。
- 明确评估为不值得做的方向:paralleltest/tparallel(共享全局状态风险)、
fieldalignment(JSON key 顺序变化)、biome 格式漂移(纯噪声)、
frpc/frps 慢测试注入 backoff(为省 ~40s 改生产时序逻辑,不值)。
- 未来如继续:可周期跑 `go test -race ./...` 全量(frpc/frps 慢套件);
或前端 a11y 用 axe 做浏览器级审计(超出 eslint 静态规则)。
## 本会话新增(runs #39-#43)
已修复:
- agent auth_cache negative 缓存无上限 → 10k 上限+过期清理(DoS 防护)
- relay/flared 与 agent 三份重复 authenticateAccessToken → 共享 agent 版(负缓存共享,DB 压力下降)
- websocket 三 hub:runWritePump 抽取、wsClientCore 嵌入(close/enqueue 单份)、broadcastAgent 合并
- frps/frpc TOML 注入 → pkg/protocol/toml.go TOMLQuote 转义全部插值
评估后不修/暂缓:
- cloudflare listMemberItems、config_version snapshot 证书循环的 N+1:管理端小 N 低频,
加批量 repo API 属投机优化;若未来组员数量变大再做 ListZoneDomainsByIDs。
- fatcontext ×3(oauth/upload/auth_source cache listener):别名赋值误报,非嵌套包装。
- objectstore newOSSBackend/newWebDAVBackend 恒 nil error:跨后端工厂签名统一,刻意设计。
- edge/updater assetNameForGOOSGOARCH 恒 "linux":跨平台预留参数,刻意泛化。
- agent ResolverDirective explicitResolvers 原样插入 nginx conf:管理员配置属可信输入;
若未来开放给低权限角色需加格式校验(IP 解析)。
- pkg/render/openresty 管理端旋钮(ClientMaxBodySize 等)原样插值:管理员权限范围内。
- frontend/settings/profile.tsx(858 行)超 AGENTS.md ~600 行指引:存量组件,拆分属
纯重构无质量增益,暂缓;若后续要改该页面功能时顺手拆 components/。
## Run #44(全仓 -race 扫描)
- 发现并修复 upload/cache 监听器 DATA RACE:goroutine 读可变全局 db.Redis vs
testhelper 清理置 nil。根因修复=启动时捕获 redisClient(oauth×2/repository×2
同型监听器一并加固),StopUploadMetaCacheListener 补 done 等待。
- 教训:testhelper 不能 import upload/cache(循环依赖);"捕获替代全局读"是
无环的根因修法。
- 全仓 -race 现为 0 竞争(internal/... + pkg/...);建议周期性重跑。
## LIKE 转义(本轮已修日志搜索 4 站点;同类遗留)
- 已修:analytics/node_access_log_filter.go、analytics/access_log_filter.go、
logstore/postgres_store.go×2(PG/SQLite 加 ESCAPE '\',CH 用默认反斜杠转义)。
新助手 pkg/util/like.go EscapeLike + 单测。
- Run #47 已收尾全部 GORM 站点:upload.go keyword、user.go:73/76/188/229
(含 OAuth uniqueUsername base 转义——外部输入含 _ 曾误报用户名冲突)、
task_execution.go task_type 前缀。均加显式 ESCAPE '\'。
- 刻意保留:upload.go:199 `image/%`(系统常量)、config_version.go:65(系统生成)。
## Run #48(后台 goroutine panic 防护,55db1c01)
- 全仓 20 处裸 go func() 零 recover → 新增 pkg/util/goroutine.go `Go(fn)`(recover +
slog + debug.Stack,runtime.Caller 自动记录调用点无需手写名字),22 个站点全部收口
(oauth/upload/system_config/auth_source 的嵌套 ctx-done watcher 也含)。
- 教训:脚本括号深度匹配首轮会跳过嵌套内层 goroutine,需跑两轮;新 Go 文件必须先跑
scripts/update_go_license.sh(license-check 会拦)。
- 已过期记录:go test ./internal/... ./pkg/... 现全过(94 ok)——"main 上测试失败"
不再成立。scripts/、docs/ 下 Go 文件用扩展 linter 扫过:0 issues。
## Run #50(发现型 linter 扫描,全证伪——勿重跑这些维度)
- errchkjson 12 处:全部为不可能失败的 json.Marshal(纯 string/int/[]string
结构体;admin/logs/routers.go:131 与 waf/ip_group_sync.go:255 的 "unsafe type"
是传递性保守标记,RawMessage/time.Time 内容来自必然成功的 marshal)。
- spancheck 1 处(pkg/trace/trace.go:61):误报,helper 正常返回 span,
唯一调用方 internal/infra/task/executor.go:242 有 defer span.End()。
- unparam ×2(objectstore oss/webdav 恒 nil error):已在 #43 前评估为跨后端工厂签名统一。
- 性能排查:正则全部包级编译(无函数内 MustCompile);包级 map 全为有界静态注册表;
task AppendLog 走 DB 非内存累积;push escapeJSONString 用法正确。
- 结论:Go 静态可发现的低垂果实已穷尽。剩余方向:frontend axe a11y 浏览器级审计、
周期性 -race 重跑(上次 #49 干净)、运维类增长审查。
## Run #54(认证页 axe a11y 审计+修复,451ce525)
已修(复扫验证生效):
- 布局级全局:sidebar 折叠按钮 aria-label、Sidebar role=navigation(region 18 节点/页清零)、
header Kbd 对比度 text-foreground/70、空态/错误/加载 h3→p(heading-order 清零)。
- 页面级:dashboard 4 个 Progress aria-label、users 分页 prev/next aria-label、
admin/system 无内容 Tabs→aria-pressed 按钮组(aria-valid-attr-value critical 清零)。
- / 与 /admin/system 现 axe 0 违规。
后续可做(页面级批量,工作量大):
- admin 数据表格行内操作图标按钮(编辑/删除)与 Switch 开关无 aria-label —— 每张管理表逐个补;
- muted 文本对比度(card description、radix tabs trigger、primary 按钮文字)—— shadcn 默认色在浅色主题下 axe 判 fail,改主题变量影响面大需设计确认。
- 审计环境复用:后端 :3100 + CONFIG_PATH=/tmp/of-audit/config.yaml(sqlite)、docker redis --network host、
pnpm dev --port 3002 WAVELET_BACKEND_URL=:3100;admin 密码 reset-passwd 重置。注意 :3000 是生产实例勿动。
## Run #54-#55(认证页 a11y 审计,两轮 keep)
已修复(浏览器 axe 复扫验证):
- 全局布局:sidebar 折叠按钮 aria-label、Sidebar role=navigation、header Kbd 对比度、
dashboard Progress aria-label、分页 prev/next、空态/加载 h3→p、admin/system Tabs→aria-pressed。
- 主题级根因:--primary indigo-500(#6366f1) 白字对比度仅 4.27(AA 需 4.5) → indigo-600
oklch(51.1% 0.262 276.966) ≈6.8,一处修复全站 contrast 清零。
- 控件名:access-analytics 刷新、events-tab Switch/编辑/删除、openflare-ops Switch/Select/
Input(htmlFor)/Textarea、table-browser/sql-console SelectTrigger;heading-order:眉题
h4→p(cache-manager/user-detail-sheet)、卡片题 h3→p(task-manager/file-manager)。
- 结果:dashboard、admin/system、admin/settings、admin/logs、admin/push、admin/tasks、
admin/database、files 共 8 页 axe 0 违规。
审计方法(可复用):后端 :3100(CONFIG_PATH=/tmp/of-audit/config.yaml,sqlite,
api_prefix 必须显式 /api)+ docker redis --network host(本机 bridge NAT 坏)+
pnpm dev --port 3002 WAVELET_BACKEND_URL=:3100 + admin 密码经 reset-passwd 重置。
axe 注入:eval 建 CDN script → Promise 轮询 window.axe → axe.run。
教训:表单页异步渲染,须 wait≥5s 再扫否则漏报 label 规则;Radix SelectValue
value='' 时 placeholder 不显示,combobox 无名需 aria-label 兜底。
## 剩余可做
- 抽查其余页面(websites/[zoneId]、origins/detail、responses 编辑器等富交互页)
——contrast 已由主题修复覆盖,预期只剩个别控件名。
- 周期性 go test -race ./... 全量重跑(上次干净为 run #49 后)。
## Run #56(富交互页抽查,keep,63e3b852)
- 扫描 11 页:websites/origins/proxy-routes/certificates/dns-accounts 直接 0 违规
(indigo-600 主题修复已覆盖全站 contrast)。
- 修复 3 处并复扫归零:
1. cloudflare/components/sync-tasks-panel.tsx 状态筛选 SelectTrigger 加 aria-label
(Radix SelectValue value='' 时 placeholder 不渲染,combobox 无名)。
2. components/common/settings/access-token.tsx 安全提示 text-amber-600→amber-700
(12px 小字对比度不足)。
3. settings/notifications 面包屑页缺 h1 → sr-only h1。教训:h1 不能作为
BreadcrumbList 子元素(axe list 规则报 list 语义破坏),须放 <Breadcrumb> 外;
BreadcrumbPage 无 asChild 支持。
- a11y 维度至此穷尽:累计 14 页 axe 全部 0 违规。
## Run #59(-shuffle=on 测试顺序随机化扫描,keep,b56f2763)
- 新维度:`go test -shuffle=on` 抓到 config_version 包测试顺序依赖——
TestBuildOpenRestyConfigSnapshotOriginErrorPageDefaults 在 shuffle 下命中
Custom 用例留在进程级 RAM 配置缓存的值(GetSystemConfigByGroup 未命中时
ram.Set 回填,TTL 跨测试存活;:memory: DB + SetDB 换库不使缓存失效)。
- 修复:setupOriginErrorPageSnapshotDB / setupConfigVersionTestDB 换 DB 前后
接入既有 ram.ResetForTest()。包内 shuffle×8 + 全仓 shuffle 复扫全过。
- 教训:默认源码顺序掩盖顺序依赖;-shuffle=on 是低成本周期扫描手段。
全仓 -race(#58 后)同样干净。其余用 SetDB 的测试包如后续 shuffle 复发,
同法接入 ResetForTest 即可。
+78
View File
@@ -0,0 +1,78 @@
version: "2"
# Pinned autoresearch yardstick. IMMUTABLE for the duration of a run.
# Snapshot of the committed .golangci.yml plus the extra analyzers that report
# genuine defects (correctness / panics / dead code) rather than cosmetics.
# Keeping this separate from .golangci.yml means strengthening the project gate
# can never silently lower the measured debt.
run:
timeout: 5m
tests: false
linters:
default: none
enable:
# --- from the committed project gate ---
- govet
- staticcheck
- errcheck
- ineffassign
- unused
- dupl
- mnd
- goconst
- cyclop
- nestif
- maintidx
- revive
- gocritic
- funlen
- gosec
- bodyclose
- noctx
- contextcheck
- sqlclosecheck
- unconvert
- nilerr
# --- extra real-risk analyzers (defects, not cosmetics) ---
- errorlint # err == / %v instead of errors.Is/As and %w
- forcetypeassert # unchecked type assertions can panic
- nilnil # (value, nil) breaks the nil-check contract
- predeclared # shadowing builtins
- unparam # dead params/results
- wastedassign # dead stores
- exhaustive # enum switches missing cases
- makezero # append to preallocated slice
- rowserrcheck # sql.Rows error after iteration
- durationcheck # multiplied time.Duration
- prealloc # slice growth in loops
- copyloopvar # loop-var capture
- nonamedreturns
- funcorder # struct methods scattered across files
- nolintlint # suppression audit (must stay 0)
settings:
dupl:
threshold: 80
cyclop:
max-complexity: 20
package-average: 10
nestif:
min-complexity: 5
funlen:
lines: 200
statements: 100
mnd:
checks:
- argument
- condition
- return
issues:
max-issues-per-linter: 0
max-same-issues: 0
+60
View File
@@ -0,0 +1,60 @@
{"type":"config","name":"前后端代码质量优化(符合最佳实践)","metricName":"total_issues","metricUnit":"","bestDirection":"lower"}
{"run":1,"commit":"305d609","metric":108,"metrics":{"golint_canonicalheader":8,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":2,"golint_intrange":3,"golint_modernize":37,"golint_nilnil":3,"golint_perfsprint":18,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":3,"golint_wastedassign":7,"golint_total":107,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":36},"status":"checks_failed","description":"基线:总问题 108(golangci 107 + eslint 1)。checks 失败的唯一原因:repo 自带 golangci gate 有 2 个既有 gosec G115 问题(预期内,首次修复后即绿)。","timestamp":1786871594292,"segment":0,"confidence":null,"asi":{"hypothesis":"baseline","next_action_hint":"修复 internal/apps/edge/observability/linux.go 的 2 个 G115 gosec 问题后 checks.sh 才能通过;之后每次迭代即可正常 keep/discard"}}
{"run":2,"commit":"f1f6bb8","metric":106,"metrics":{"golint_canonicalheader":8,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":3,"golint_modernize":37,"golint_nilnil":3,"golint_perfsprint":18,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":3,"golint_wastedassign":7,"golint_total":105,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":38},"status":"keep","description":"修复 internal/apps/edge/observability/linux.go 的 2 个 gosec G115 整数溢出转换:helper 改为接收 int64 b,用 gosec 认可的饱和乘法模式(uint64 域乘积 + 上界比较),去掉原 //nolint:gosec,语义不变(Bsize 恒为正)。repo 自带 gate 首次全绿。","timestamp":1786872064145,"segment":0,"confidence":null,"asi":{"hypothesis":"修复 gosec G115:multiplyUint64ToInt64 改为 accept int64 b 并采用 gosec 认可的饱和乘法模式","insight":"gosec G115 不接受分支上界证明(a > MaxInt64/b),但接受先算 uint64 乘积再 if v > MaxInt64 饱和的模式,无需 nolint","next_action_hint":"下一步批量清理 modernize(37)/perfsprint(18) 等自动可修复类别,用 golangci-lint --fix 后人工核对 diff"}}
{"run":3,"commit":"b76f707","metric":74,"metrics":{"golint_canonicalheader":8,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":3,"golint_modernize":5,"golint_nilnil":3,"golint_perfsprint":18,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":3,"golint_wastedassign":7,"golint_total":73,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":38},"status":"keep","description":"modernize 37→5(-32):interface{}→any、内置 max/min、slices/maps 辅助、strings.Cut/SplitSeq、strings.Builder(修复 mail.go O(n²) 拼接)。逐 hunk 核对语义等价;omitzero 冲突修复被自动跳过(wire 格式不变);手动清 4 处遗留 sort import + 2 处 QF1012。","timestamp":1786872502383,"segment":0,"confidence":17,"asi":{"hypothesis":"批量应用 modernize 自动修复(interface{}→any、max/min、slices.Sort/Contains、strings.Cut/SplitSeq、maps.Copy、strings.Builder)","insight":"golangci-lint --fix 会把 omitempty→omitzero 的冲突修复跳过(2个文件保留原 tag,wire 格式不变,好);但 fixer 会遗留未使用的 sort import,需手动清 4 处;mail.go 的 Builder 迁移附带暴露 2 个 QF1012,顺手用 fmt.Fprintf 修复。全部修复语义等价,已逐项核对 diff","next_action_hint":"剩余 modernize=5 应为 omitzero 冲突文件;下轮先处理 perfsprint(18):先看 --fix 是否安全再决定"}}
{"run":4,"commit":"699e95f","metric":56,"metrics":{"golint_canonicalheader":8,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":3,"golint_modernize":5,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":3,"golint_wastedassign":7,"golint_total":55,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":47},"status":"keep","description":"perfsprint 18→0:strconv.Itoa/FormatInt/FormatUint/FormatBool 替代 fmt.Sprintf、无动词 fmt.Errorf→errors.New、纯字符串拼接。全部语义等价(已核对 diff)。修正 fixer 遗留的 import 问题(引入 goimports 统一整理)。","timestamp":1786872884713,"segment":0,"confidence":3.0588235294117645,"asi":{"hypothesis":"perfsprint --fix:%d→strconv.Itoa/FormatInt、%t→FormatBool、%s+const→拼接、无动词 Errorf→errors.New","insight":"重要:golangci-lint v2 fixer 的 import 管理不可靠(删除/添加 import 会出错,53 个文件中 5 处报 undefined)+ 遗留未用 import。已安装 goimports(repo make format 本来就需要它),对改动文件统一 goimports -w 修复。后续只要用 --fix 就要记得跑 goimports -w","next_action_hint":"剩余大头:errorlint(12)、canonicalheader(8)(usestdlibvars 同类)、recvcheck(7)、wastedassign(7)。errorlint 需手工逐处判断;先做 canonicalheader+usestdlibvars(自动可修复但要核对)"}}
{"run":5,"commit":"d0414b4","metric":45,"metrics":{"golint_canonicalheader":0,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":3,"golint_modernize":5,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":0,"golint_wastedassign":7,"golint_total":44,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":38},"status":"keep","description":"canonicalheader 8→0 + usestdlibvars 3→0:header key 改为 Go 规范大小写(wire 格式本就如此,纯代码修正)、HTTP 方法常量替代字符串字面量。","timestamp":1786873098921,"segment":0,"confidence":2.1724137931034484,"asi":{"hypothesis":"canonicalheader+usestdlibvars --fix:Header key 统一规范大小写、GET/OPTIONS 等方法常量","insight":"GitHub header 修正前后的 wire 格式完全一致(Go 在 Set 时本来就会规范化),纯代码层面修正,零行为风险;下次遇到同类 100% 安全","next_action_hint":"剩余:errorlint(12) 需逐处人工判断(其中 3 处 err != context.Canceled、2 处 %v wrap、若干 ==/类型断言);recvcheck(7) 是模型接收者一致性;wastedassign(7) 删 TODO 赋值;intrange(3)/modernize(5)/nilnil(3)/prealloc(3)/forcetypeassert(3)/errname(1)/eslint(1)"}}
{"run":6,"commit":"ce28f63","metric":38,"metrics":{"golint_canonicalheader":0,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":3,"golint_modernize":5,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":37,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":47},"status":"keep","description":"wastedassign 7→0:删除 7 处死初始化(snapshot.go 三连、push 三件套 content、format.go numStr),改 var 声明,零行为变化。","timestamp":1786873485497,"segment":0,"confidence":2.978723404255319,"asi":{"hypothesis":"wastedassign 7→0:删除 7 处死初始化(x := \"\" 后所有分支都赋值)改为 var 声明","insight":"replace 工具会归一化 replacement_text 的前导空白;对需要缩进的编辑直接用 sed/gofmt -w 处理更稳","next_action_hint":"剩余:errorlint(12)、recvcheck(7)、modernize(5)、intrange(3)、nilnil(3)、prealloc(3)、forcetypeassert(3)、errname(1)、eslint(1)"}}
{"run":7,"commit":"288b74d","metric":33,"metrics":{"golint_canonicalheader":0,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":32,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":45},"status":"keep","description":"intrange 3→0 + modernize 5→3:for i:=0;i<len/N;i++ → range len/N(8 处);time.Time 字段 omitempty→omitzero(wire 输出一致);SplitSeq;min() 简化。刻意保留 lark.go omitzero(会改变 wire 行为)。","timestamp":1786873629461,"segment":0,"confidence":4.166666666666667,"asi":{"hypothesis":"intrange(3) + modernize 剩余(2 个 time.Time omitempty→omitzero + SplitSeq + min)","insight":"lark.go larkTextContent omitempty→omitzero 会改变 wire(普通 struct 无 IsZero,当前恒序列化,改后零值省略)—— 判定为行为变化,故意保留;time.Time 字段 omitempty/omitzero 输出一致,可安全替换","next_action_hint":"剩余:errorlint(12) 大头(3 处 != context.Canceled 需确认 runner 是否 wrap;%v→%w 2 处;若干 ==err / 类型断言);recvcheck(7);forcetypeassert(3);nilnil(3);prealloc(3);errname(1);eslint(1)"}}
{"run":8,"commit":"86fad02","metric":22,"metrics":{"golint_canonicalheader":0,"golint_errname":1,"golint_errorlint":1,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":21,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":46},"status":"keep","description":"errorlint 12→1:3 处 cmd 入口 err!=context.Canceled→errors.Is(防御性,当前 runner 不 wrap 语义不变);2 处 strconv.NumError 断言、1 处 viper 断言、2 处 ==io.EOF、2 处 ==redis.Nil、1 处 ==gorm.ErrRecordNotFound→errors.As/Is;8 处 %v→%w 保留错误链。刻意保留 telegram.go 单处 %v(原始错误仅作上下文文本,wrap 会改变 errors.Is 匹配语义)。","timestamp":1786873923775,"segment":0,"confidence":4.195121951219512,"asi":{"hypothesis":"errorlint 12→1:errors.Is/As 替代 ==/类型断言(防御 wrap),%v→%w 保留错误链","insight":"errorlint 结果在并行分析时一度不稳定(可能文件缓存竞争),多跑一次确认;telegram.go 的 %v 是刻意保留原始 HTML 错误为文本(只 wrap fallbackErr),判定为合理例外,不计为负债。错误链保留(%w)对多错误组合消息(manager.go、restart_unix.go、service.go)是净收益,调用方无 Is 匹配这些次要错误","next_action_hint":"剩余:recvcheck(7)、forcetypeassert(3)、nilnil(3)、prealloc(3)、modernize(3=lark omitzero 刻意保留)、errname(1)、eslint(1)"}}
{"run":9,"commit":"4ecec2c","metric":15,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":7,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":14,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":43},"status":"keep","description":"forcetypeassert 6→0(缓存 list 断言、relay/flared 中间件契约断言、图片压缩 flight 断言,全部带检查+安全失败路径);errname 1→0;prealloc 2 处(另 1 处与 repo mnd 冲突,用命名常量解决)。nilnil 保留(not-found/可选结果惯例,含接口契约注释)。","timestamp":1786874283774,"segment":0,"confidence":4.043478260869565,"asi":{"hypothesis":"forcetypeassert(6处) → 带检查断言(middleware 契约破坏时 Abort 401/返回错误);errname runtimeInitErr→errRuntimeInit;prealloc 2 处(uptimekuma、postgres replicas)","insight":"prealloc 与 repo mnd 门禁冲突(magic number 3):用命名常量 baseTracingOptionCount 同时满足两者;nilnil 5 处判定为合法 not-found/可选结果惯例(含接口注释契约 + 测试断言),全部保留;用 --max-issues-per-linter=0 拿全量清单避免被默认 50 截断误导","next_action_hint":"剩余:recvcheck(7) 接收者一致性(需逐模型判断)、eslint(1) exhaustive-deps、modernize(3=lark omitzero 刻意保留+2 处待查)、nilnil(3 刻意保留)、errorlint(1 刻意保留)"}}
{"run":10,"commit":"73d8173","metric":9,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":45},"status":"keep","description":"recvcheck 7→1:6 个 GORM 模型 TableName 改为指针接收者(GORM 源码确认 reflect.New 判定 Tabler,兼容;模型单测通过)。MillisecondDuration 刻意保留(encoding/json 要求 Marshal 值/Unmarshal 指针的混合)。","timestamp":1786874445733,"segment":0,"confidence":4.304347826086956,"asi":{"hypothesis":"recvcheck 7→1:GORM 模型 TableName 值接收者→指针接收者,与其它方法一致","insight":"GORM schema.Parse 用 reflect.New(modelType) 判定 Tabler,指针接收者 TableName 完全兼容(已读 gorm 源码确认 + 模型单测通过);仓库中 (Model{}).TableName() 字面量调用都在未改的类型上,无破坏。MillisecondDuration 保留:MarshalJSON 值接收者是 json 对不可寻址值的行为保障,UnmarshalJSON 必须指针 —— 混合是 encoding/json 硬性要求","next_action_hint":"剩余:modernize(3,含 lark omitzero 刻意保留 + 2 处待查)、nilnil(3 刻意保留)、eslint(1 exhaustive-deps)、errorlint(1 刻意保留)。下一步查 modernize 剩余 2 处并修 eslint 的 hook 依赖"}}
{"run":11,"commit":"111d290","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":38},"status":"keep","description":"eslint 1→0:pages-source-card useEffect 补 t 依赖(next-intl 稳定引用)。modernize 补 1 处 time.Time omitzero。剩余 8 全部为刻意保留项。","timestamp":1786874578893,"segment":0,"confidence":4.3478260869565215,"asi":{"hypothesis":"eslint 1→0:useEffect 依赖数组补 t(next-intl useTranslations 返回稳定引用,安全);modernize 补 1 处 time.Time omitempty→omitzero(输出一致)","insight":"modernize 剩余 3 处全部是嵌套 struct omitempty(client.go Release/Asset、lark.go Content)→ omitzero 会改变 wire,全部刻意保留。至此所有可安全修复的类别清零,剩余 8 个全部是有据可查的刻意保留项","next_action_hint":"剩余 8 全部刻意保留(errorlint 1 telegram、modernize 3 嵌套struct、nilnil 3 not-found、recvcheck 1 json)。下一轮做深化方向:测试代码质量(tests:false 之外)、或 golangci 附加 linter(gocritic 更多检查)作为新基准段"}}
{"run":12,"commit":"e5f6b0a","metric":33,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":20,"golint_test_thelper":3,"golint_test_usetesting":2,"golint_test_total":25,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":37},"status":"keep","description":"基准扩展(文档化):新增测试代码质量维度 25 处(testifylint 20 + thelper 3 + usetesting 2),生产代码 8 处刻意保留不变。新基线 total=33。","timestamp":1786874744438,"segment":0,"confidence":4.878048780487805,"asi":{"hypothesis":"扩展基准到测试代码质量维度(testifylint 20 + thelper 3 + usetesting 2 = 25)","insight":"刻意排除 paralleltest/tparallel(共享 DB/redis 状态 + 本环境无法跑测试,t.Parallel 有风险)—— 这是范围扩展(抬高门槛),不是 gaming;基准定义已写入 prompt.md","next_action_hint":"修 25 处测试问题:float-compare 3(InDelta)、require-error 3、encoded-compare 1(JSONEq)、empty 3、contains 1、error-is-as 3、len 3、go-require-in-handler 2、t.Helper 3、os.MkdirTemp→t.TempDir 2"}}
{"run":13,"commit":"63a24da","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":39},"status":"keep","description":"测试代码质量 25→0:assert↔require 一致性(fail-fast)、float 精确比较→InDelta、Equal(\"\",x)→Empty、Equal(len)→Len、errors.Is/As→ErrorIs/ErrorAs、JSON 字符串→JSONEq、handler goroutine 内 require→assert(真健壮性修复)、t.Helper()、os.MkdirTemp→t.TempDir()(符合 repo AGENTS 约束)。","timestamp":1786875177918,"segment":0,"confidence":4.3478260869565215,"asi":{"hypothesis":"修完测试代码质量维度 25 处(testifylint 20 + thelper 3 + usetesting 2)","insight":"批量修复过程揭示 testifylint 默认 max-same-issues=3 会掩盖重复模式(len(entries) 出现 4+ 次、float64(3) 4 次),需 --max-issues-per-linter=0 反复收敛;全部修复语义中性(assert↔require 仅 fail-fast 差异,InDelta/JSONEq/Empty/Len/ErrorIs 等价断言,t.Helper/t.TempDir 纯改善)。go-require 类(handler 内 require→assert)是真正的健壮性修复","next_action_hint":"测试维度清零。生产代码剩余 8 全部刻意保留。可选深化:gocritic 更多子检查/staticcheck 更多(SA 系列)扫描、或 biome check 格式一次性提交、或前端 a11y(eslint jsx-a11y 已含于 next core-web-vitals 默认关闭项)"}}
{"run":14,"commit":"65c02ef","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":36},"status":"keep","description":"基准扩展 exhaustive(文档化)+ 12→0:枚举 switch 补显式 case(全部与现有 default 行为等价,fail-explicit 防未来枚举静默落入 default);source_tasks.go 为控制复杂度合并两个等价校验条件。","timestamp":1786875548060,"segment":0,"confidence":4.25531914893617,"asi":{"hypothesis":"基准扩展 exhaustive(12 处枚举 switch 显式化)+ 全量修复","insight":"12 处全部是 default 已正确处理、缺显式 case 的类型;补显式 case 仅为 fail-explicit(未来枚举新增不会静默落入 default)。source_tasks 补 case 后 Execute 复杂度 20→21 触发 cyclop,合并两个 ActionInvalid 条件(逻辑等价)降回 19。cyclop 与 exhaustive 的张力:显式 case 也计入复杂度","next_action_hint":"剩余 8 全为刻意保留。可再深化:sloglint 全量、govet 附加分析器、或前端 jsx-a11y/next 规则已有覆盖。也可将剩余 8 处文档化后收尾总结"}}
{"run":15,"commit":"d7b8f44","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":37},"status":"keep","description":"修复 geoip/runtime.go 真死代码:ensureServerMMDB 的 os.Stat 错误被 if-init 遮蔽,`err != nil && !os.IsNotExist(err)` 恒为 false(外层 err 恒 nil),防御检查从未生效;改为显式捕获 statErr,stat 非 not-exist 错误现在正确返回。基准新增第 4 维度 govet nilness+unusedwrite(文档化扩展),当前 0。","timestamp":1786875949461,"segment":0,"confidence":4.166666666666667,"asi":{"hypothesis":"govet nilness 真实死代码 bug:ensureServerMMDB 的 stat 错误被 if-init 遮蔽,!os.IsNotExist(err) 恒为死条件(外层 err 恒 nil)","insight":"修复:显式捕获 statErr,使防御检查生效(stat 权限错误现在立即返回,不再静默吞掉后走 WriteFile 失败)。顺带基准扩展第 4 维度 govet nilness+unusedwrite(文档化,survey 过 fatcontext/containedctx/unparam/gocritic+29 检查:unparam 有 6+ 处真实死结果但需签名改动,留待下轮)","next_action_hint":"下轮候选:unparam(6+ 处 always-nil/never-used 结果,含 getSQLiteOverview/getPostgresOverview/getStatus 等,需改签名+调用方,churn 中等但都是真实死代码);或 fatcontext/containedctx(3+3 处,需逐处判断是否真反模式)"}}
{"run":16,"commit":"c85373f","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":43},"status":"keep","description":"unparam 死代码清理 12→2(保留 2 处 objectstore 构造函数统一签名):移除 10 处恒 nil error / 从未使用的结果(getPoWConfigForRoute 的恒 nil *PoWConfig、getSQLiteOverview/getPostgresOverview/getStatus/loadKumaConfig/filterExpectedRoutes 的恒 nil error、rawJSONString/parsePositiveInt 的弃用 bool、buildProxyRoute 的弃用 []ZoneDomain、getLocked 的恒 nil error),同步简化 12+ 处调用方与死错误检查。9 个受影响包测试通过。metric 持平 8(改进在基准之外)。","timestamp":1786876191447,"segment":0,"confidence":5.128205128205129,"asi":{"hypothesis":"unparam 死代码清理:10 处 always-nil error / never-used 结果从签名移除","insight":"移除后调用方同步简化(db_manage 的 err 检查、option routers 的 AbortBadRequestOnError 成为死代码一并删)。getPoWConfigForRoute 的 *PoWConfig 结果恒 nil 且从未被用 —— 真死代码。保留 2 处 objectstore 构造函数 (X, error):factory switch 统一签名(newS3Backend/newLocalBackend 等可能真实报错),unparam 在此为接口一致性误报。全部 9 个受影响包测试通过。metric 持平 8(改进在基准之外,诚实记录)","next_action_hint":"下一候选:fatcontext(3 处嵌套 context 闭包,多为 slog/otel ctx 传递,需逐处判断是否真反模式) 或 containedctx(3 处 struct 含 ctx 字段,含 webdav/uptimekuma client —— 重构风险中等);或收尾把 unparam 加入基准(2 处已知保留)"}}
{"run":17,"commit":"a16be01","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":40},"status":"keep","description":"修复 frpc 进程生命周期真 bug(agent 生产代码):exec.CommandContext 默认只杀直接子进程,被杀 shell 的孤儿 sleep 继续持有 stderr 管道,cmd.Wait() 阻塞到其自然退出(Stop/重启可挂起秒级)。改 Setpgid 进程组 + Kill(-pid) 整组击杀。连带修复两个测试 bug(Manager 拥有 Cmd 的并发 Wait 竞态 → Signal(0) 探测;ssl_renew 用 miniredis 替代 init() 创建的真实 redis 客户端)。go test ./internal/... ./pkg/... 全绿,checks.sh 升级为真实测试门禁。","timestamp":1786877266517,"segment":0,"confidence":7.142857142857143,"asi":{"hypothesis":"frpc 进程生命周期真 bug:exec.CommandContext 只杀直接子进程,孤儿孙进程持有 stderr 管道导致 cmd.Wait 阻塞到其自然退出(实测脚本 sleep 5 时 Stop 挂起 5s)","insight":"修复:Setpgid 独立进程组 + cmd.Cancel 覆盖为 Kill(-pid,SIGKILL) 整组击杀(经隔离复现 + 临时插桩定位,4 次假设检验收敛)。连带修复两个测试 bug:TestStopCancelsRunningProcesses 对 Manager 拥有的 Cmd 并发 Wait(与 os/exec ctxResult 通道竞争永久挂起)改为 Signal(0) 探测;ssl_renew 测试改用 miniredis(task 包 init() 创建真实 redis 客户端,违反 repo 无 init 装配约束)。成果:go test ./internal/... ./pkg/... 从 3 个失败→全绿(81+13 包),checks.sh 升级为真实测试门禁。metric 持平 8(改进在基准之外,但价值最高的一轮)","next_action_hint":"测试全绿后可解锁:paralleltest/tparallel 维度(t.Parallel 提速)——需先评估共享状态(miniredis/sqlite 每测试独立,风险低);或探索 relay/frps 同构代码是否有同样的 group-kill 问题(frps/manager 结构相同,值得检查)"}}
{"run":18,"commit":"f5c9da0","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":39},"status":"keep","description":"前端测试套件 44 失败→全绿:10 个测试文件补 NextIntlClientProvider 包装(含 React19 createElement 类型修复、.ts→.tsx 重命名);修复真实 i18n ICU bug(githubUrlInvalid 的 {owner}/{repo} 未转义导致生产渲染成 key,zh/en + fragment 4 文件同步转义);更新 2 处过期测试期望。vitest 116/116 + tsc + eslint 全绿,checks.sh 增加前端测试门禁。","timestamp":1786878501539,"segment":0,"confidence":9.523809523809524,"asi":{"hypothesis":"前端测试可运行性:next-intl 迁移后 44/116 测试失败(缺 NextIntlClientProvider + 3 处真实断言问题)","insight":"修复三类:(1) 10 个测试文件的 render 助手缺 NextIntlClientProvider(createElement 与 JSX 混用踩 React19 类型坑,.ts 文件不能写 JSX → 重命名为 .tsx);(2) 真实 i18n bug:githubUrlInvalid 消息的 {owner}/{repo} 被 ICU 当占位符,t() 无参调用渲染成 key —— 需 '{' 单引号转义('{}' 内层转义不够,必须整体引号包裹 '{owner}'),4 个消息文件(zh/en + fragment 源)同步修复,check:i18n 通过;(3) 2 处测试期望过期(唯一访问者→查询窗口独立访客、检查间隔→检查间隔(分钟),以消息文件为准)。成果:116/116 vitest + tsc/eslint 全绿,checks.sh 增加前端测试门禁","next_action_hint":"前端测试全绿后可把 vitest 失败数纳入基准(当前不在基准内);或检查 app/(main) 目录下 3 个自带 .test.tsx(waf editor 系列)是否也符合新约定"}}
{"run":19,"commit":"c455be3","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":116,"measure_s":62},"status":"keep","description":"基准扩展第 5 维度(文档化):前端 vitest 失败数纳入 total_issues(vitest_failed=0, total=116)。5 维全部处于下限,total=8 不变。","timestamp":1786878719509,"segment":0,"confidence":14.285714285714286,"asi":{"hypothesis":"基准扩展第 5 维度:前端 vitest 失败数(全绿后纳入防回归,文档化范围扩展非作弊)","insight":"measure_s 从 39s 升到 62s(vitest ~20s + eslint 冷启动),可接受。5 个维度全部在其下限:生产 8(全刻意保留)+ 测试 0 + govet 0 + eslint/tsc 0 + vitest 0","next_action_hint":"基准已 5 维全下限。后续可深化:paralleltest(现在测试可跑,但共享全局状态风险仍在,低优先);或 frontend biome 格式一次性提交(不进基准);或前端组件更深规则(jsx-a11y 已在 next core-web-vitals 覆盖)。也可认为会话到达稳定收尾点,更新 prompt/ideas 后总结"}}
{"run":20,"commit":"4962bf9","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":116,"measure_s":86},"status":"keep","description":"两处真实质量修复:(1) 过期 swagger 文档重新生成(status_2xx/4xx/5xx_count 字段随 a4dd5ca9 加入后未同步 docs,违反 repo 约定,swag init 后差异仅真实新增字段);(2) generate-themes.js 输出补尾换行,themes.json 构建可复现(此前每次 build 弄脏工作树)。验证 next build 成功、musttag/tagalign 调查无真实问题。","timestamp":1786879144888,"segment":0,"confidence":25,"asi":{"hypothesis":"验证生产构建 + 修两处真实质量问题:swagger 文档过期(status_2xx/4xx/5xx_count 新增字段未重新生成)与 themes.json 构建不可复现(generate-themes.js 缺尾换行,每次 build 弄脏工作树)","insight":"next build 成功(无构建问题);musttag 3 处与 tagalign 均判定为非问题(持久化 round-trip 自洽/调试日志/纯格式)。swagger 差异仅 27 行且全部真实(a4dd5ca9 状态码拆分字段)。generate-themes.js 补 '\\n' 后 themes.json 再生与提交版完全一致,构建可复现。metric 持平 8(改进在基准之外)","next_action_hint":"会话已 5 维全下限 + 构建可复现 + 双端测试全绿。收尾候选:更新 prompt/ideas 记录本轮成果后总结;或继续验证 swag 生成的 docs.go 在 CI 中的可复现性"}}
{"run":21,"commit":"e1b439d","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":116,"measure_s":74},"status":"keep","description":"全仓 go test -race 扫描(93 包)→ 全绿。修复 6 类数据竞争:frpc/frps 测试的锁外读与并发 Wait;oauth/repository 4 个 Pub/Sub 监听器 goroutine 读可变包变量(局部捕获 + done 通道等待);oauth 测试换 db.Redis 前停监听器;【真实生产 bug】tls 响应快照与异步续签 goroutine 并发写 cert 竞争(先快照再起 goroutine);upload/cache 监听器 goroutine 内读 db.Redis(调用方捕获)。","timestamp":1786881379399,"segment":0,"confidence":100,"asi":{"hypothesis":"全仓 -race 扫描:发现并修复 6 类数据竞争,含 1 个真实生产竞争","insight":"修复清单:(1) frpc 测试助手锁外读 proc.Status;(2) frps 测试对 Manager 拥有的 Cmd 并发 Wait(与 frpc 同类);(3) oauth/repository 4 个 Pub/Sub 监听器 goroutine 内读可变包变量 userListenerCtx 等 → 局部捕获 + stop 增加 done 通道等待;(4) oauth 测试 setupTestRouter 换 db.Redis 前先停各层监听器;(5)【真实生产 bug】tls logics 的 sanitizeCertificateForResponse 整体拷贝 cert 与异步续签 goroutine 字段写入并发 → 先快照再起 goroutine;(6) upload/cache 监听器 goroutine 内读 db.Redis → 调用方捕获。成果:93 包 go test -race 全绿。基准 5 维全下限不变(改进在基准外,但 tls 竞争是生产级真实问题)","next_action_hint":"可考虑把 -race 纳入周期性验证(不进每次 checks,全仓 ~3 分钟);或在 prompt/ideas 记录本轮成果"}}
{"run":22,"commit":"ab70633","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":116,"measure_s":68},"status":"keep","description":"checks.sh 新增并发密集包 -race 门禁(8 个快速包,全仓 -race 清零后纳入防回归;frpc/frps 慢套件留作周期全量验证)。核查 7 处 t.Skip 均为合法环境门控。","timestamp":1786881700477,"segment":0,"confidence":200,"asi":{"hypothesis":"把 -race 门禁纳入 checks.sh(并发密集包子集,全仓 -race 清零后防回归)","insight":"选 8 个快速并发包(oauth/tls/uptimekuma/upload-cache/repository/disk-cache/logger/batchwriter,约 20s),排除 frpc/frps 慢套件(15s backoff + 25s supervisor,另做周期全量验证)。checks 总耗时 ~60s 可接受。顺带核查 7 处 t.Skip 均为合法环境门控(PG/CH 集成、symlink 平台差异),无隐藏损坏。metric 持平 8","next_action_hint":"会话已达全面收敛:5 维基准全下限 + 全仓 -race 清零 + 双端测试全绿 + 构建可复现 + -race 门禁入 checks。可收尾:更新 prompt/ideas 后总结"}}
{"run":23,"commit":"aa4fadd","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":116,"measure_s":81},"status":"keep","description":"补齐 131 个 .go 文件的 SPDX license 头(repo 自带 make license 约定,早于约定新增的文件含 2 个生产文件;纯注释插入零行为影响),make license-check 转绿。go mod tidy -diff 确认干净。","timestamp":1786882164158,"segment":0,"confidence":null,"asi":{"hypothesis":"跑 repo 自带门禁 make license-check:发现 131 个 .go 文件缺 SPDX 头(早于 license 约定,含 2 个生产文件与多份本会话早期改过的文件)","insight":"用 repo 自带 scripts/update_go_license.sh(make license)自动补头(391 行纯注释插入),license-check 转绿;go mod tidy -diff 干净。gofmt/build/全测试/-race 均通过。metric 持平 8","next_action_hint":"会话全维度收敛。收尾:把 license-check 加入 checks.sh 防再犯;更新 prompt/ideas 最终状态"}}
{"run":24,"commit":"4d78bc1","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":116,"measure_s":83},"status":"keep","description":"收敛验证轮:3× 时序敏感包测试无 flake;config.example.yaml ↔ model.go 同步确认无漂移(scheduler 空结构、replicas 注释化、queues[].name 嵌套均为误报);make build-embedded 发布路径构建成功且工作树干净(bin/、frontend/out 已 gitignore)。更新 prompt/ideas 记录会话终态。metric 持平 8。","timestamp":1786882694298,"segment":0,"confidence":null,"asi":{"hypothesis":"会话收敛验证:跑 3× 时序敏感包(无 flake)、config.example.yaml↔model.go 同步(3 个疑似缺失均为误报:scheduler 空结构/replicas 注释化/queues[].name 嵌套)、make build-embedded 发布路径(成功且树干净)","insight":"全部为确认性结果,无新问题。诚实记录:本轮零代码改动(上一轮已修 license),metric 持平 8。会话正式收敛:基准 5 维全下限 + 全仓 -race 清零 + 双端测试全绿 + 发布可复现 + config/文档同步。prompt/ideas 已更新终态","next_action_hint":"会话已收敛。若继续:周期全量 -race、前端 axe 浏览器级 a11y 审计(ideas.md 已记录)"}}
{"run":25,"commit":"7f8e257","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":118,"measure_s":71},"status":"keep","description":"前端真实 a11y 审计:新增 axe-core(devDep)+ tests/a11y.test.tsx,对登录页与注册页渲染完整表单后运行 axe 结构性规则(label/button-name/heading-order/landmark/aria),两页均零违规。摸清并处理了渲染依赖(UserProvider 会话检查、publicConfigQuery 门控、configBool 字符串语义)。vitest 116→118 全绿。","timestamp":1786884595141,"segment":0,"confidence":null,"asi":{"hypothesis":"前端真实 a11y 审计:axe-core(jsdom 结构性规则)覆盖登录/注册页,超出 eslint 静态 jsx-a11y 的动态可访问性验证","insight":"新增 tests/a11y.test.tsx(2 测试)+ axe-core devDependency。调试中摸清登录/注册页渲染依赖链(UserProvider 挂载跳查 getUserInfo、LoginForm/RegisterForm 门控 publicConfigQuery、configBool 期望字符串 'true' 而非布尔 —— mock 需给字符串)。两页均零 axe 违规(color-contrast 因 jsdom 无布局引擎禁用,文档化)。vitest 116→118,checks 全绿。metric 持平 8","next_action_hint":"可扩展 axe 到更多页面(如登录 OTP 态、设置页),或收尾。axe 依赖仅 devDependency,不进基准计数(vitest_failed 已含新测试)"}}
{"run":26,"commit":"7d03154","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":120,"measure_s":71},"status":"keep","description":"axe a11y 审计扩展到登录 OTP 验证表单(input-otp 分段输入,FieldLabel htmlFor 正确关联,零违规)与人机验证小部件手动模式(零违规)。环境修复:tests/setup.ts 加 ResizeObserver mock(input-otp 依赖,jsdom 未内置)。vitest 118→120 全绿。","timestamp":1786885103995,"segment":0,"confidence":null,"asi":{"hypothesis":"把 axe a11y 审计扩展到登录/注册外的关键表单状态:登录 OTP 验证(input-otp 分段输入是经典 a11y 雷区)与人机验证小部件(CapWidget 手动模式)","insight":"新增 2 个 axe 测试(vitest 118→120)。OTPForm 验证通过 —— FieldLabel htmlFor='otp-verification' 与 InputOTP id 正确关联,分段输入零违规;CapWidget 手动模式零违规。环境修复:input-otp 依赖 ResizeObserver,jsdom 未内置 → tests/setup.ts 加 ResizeObserver mock(全局,其它测试无害)。渲染上下文修正:裸组件需 <main> landmark 包裹(region 规则,真实页面由 AuthShell 提供)。scripts/ Go 代码用扩展 linter 集扫描为 0 问题(死路,未纳入基准)。metric 持平 8","next_action_hint":"a11y 覆盖已达:登录/注册页/OTP/CapWidget 全零违规。剩余页面(设置页、admin)需较重 mock。会话可收尾,或周期跑全量 -race 验证无 flake"}}
{"run":27,"commit":"6c128e0","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":121,"measure_s":71},"status":"keep","description":"axe a11y 审计扩展到最复杂认证路径:注册页开启人机验证(CapWidget 自动求解→已通过状态 + 完整表单),mock getCapToken 避免 jsdom 无 Worker 环境限制。零违规。vitest 120→121 全绿。","timestamp":1786885536618,"segment":0,"confidence":null,"asi":{"hypothesis":"axe 审计覆盖最复杂的认证渲染路径:注册页开启人机验证(CapWidget 自动求解→已通过状态 + 完整表单)","insight":"新增第 5 个 axe 测试(vitest 120→121):mock @/lib/cap-solver 的 getCapToken 直接 resolve token(避免 jsdom 无 Web Worker),config 开 cap_login_enabled/cap_auto_solve,注册页渲染出 CAPTCHA 已通过态 + 表单全字段 → 零违规。vi.mock('@/lib/cap-solver') 对其它测试无害(仅 capEnabled 时渲染 CapWidget)。metric 持平 8","next_action_hint":"axe 覆盖已达 5 个认证表单态(登录/注册/OTP/验证小部件手动/注册+验证)。剩余:设置页与 admin 页需较重 mock。可收尾,或周期跑全量 -race 验证无 flake"}}
{"run":28,"commit":"40eee77","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":121,"measure_s":0},"status":"keep","description":"前端显式 any 类型清理 2→0:Slot children?: any → ReactNode | MotionValue 联合(motion 真实类型),顺带修复潜在崩溃(原代码在 isValidElement 前访问 children.type,缺失时 TypeError,现无效 children 返回 null,hooks 无条件合规);useControlledState Rest extends any[] → unknown[]。两处 eslint-disable 注释删除。tsc/eslint/vitest 121 全绿。","timestamp":1786886086713,"segment":0,"confidence":null,"asi":{"hypothesis":"前端显式 any 类型清理:全仓 grep 仅 2 处 any —— Slot children?: any 与 useControlledState 的 Rest extends any[],均为真实类型缺陷","insight":"全前端 any 计数 2→0。slot.tsx:children?: any → React.ReactNode | MotionValue<string> | MotionValue<number>(motion HTMLMotionProps 的真实 children 类型);顺带修复潜在崩溃 —— 原代码在 isValidElement 检查前就访问 children.type,children 缺失时 TypeError,改为 isValidChild/childrenType 先计算(hooks 无条件,rules-of-hooks 合规),无效 children 返回 null。use-controlled-state.tsx:Rest extends any[] → unknown[]。两处 eslint-disable no-explicit-any 注释随之删除(无抑制注释)。tsc/eslint/vitest 121/checks.sh 全绿。benchmark 无关(metric 持平 8)。注意:run #28 的 run_experiment 被用户中断(aborted),但代码修复已通过全部门禁验证","next_action_hint":"用户要求合并到 main 并推送"}}
{"run":29,"commit":"511bed8","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":63,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":124},"status":"keep","description":"修复 2 个新增 unconvert 问题(linux.go 中 int64(stat.Bsize) 恒等转换,Statfs_t.Bsize 在 Linux 上本就是 int64),删除多余转换零行为变化;total 10→8 回到 5 维全下限。","timestamp":1786894372432,"segment":0,"confidence":null,"asi":{"category":"unconvert","hypothesis":"会话恢复后 measure 显示 total=10,出现 2 个新的 unconvert 问题(internal/apps/edge/observability/linux.go:261-262 的 int64(stat.Bsize) 恒等转换,Linux Statfs_t.Bsize 本就是 int64)。删除多余转换,零行为变化","finding":"unconvert 是 repo 自带配置启用的 linter,此前 baseline 无此问题,最近用户提交/Go 版本变化后新增;修复后 5 维回到全下限 8","next_action_hint":"会话恢复点确认:total=8(5 维全下限,8 项均为有据可查的刻意保留)。下一轮候选:静态检查新维度(staticcheck SA 系列在 repo 配置中已启用且为 0)、或把 docs/ 下 vitepress 站点的构建纳入 measure 防回归(docs build 不属质量计数,不进基准)"}}
{"run":30,"commit":"d49c7e1","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":183,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"checks_failed","description":"Agent 发现 Token 比较改为 SHA-256 后恒定时间 Compare,堵住未授权节点注册口的计时侧信道。checks 在 -race 阶段超时(包本身已单独跑绿)。","timestamp":1787667218065,"segment":0,"confidence":null,"asi":{"hypothesis":"discovery token 用 != 比较,未授权 /agent/nodes/register 可被计时;改 SHA-256 + ConstantTimeCompare","rollback_reason":"checks.sh 在 go test -race 阶段 300s 超时(包单独跑全绿,预算不够)","next_action_hint":"同一修复用 checks_timeout_seconds=600 重跑"}}
{"run":31,"commit":"69055a9","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":71,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"未授权 Agent 注册口的 discovery token 改为 SHA-256 后恒定时间比较,堵住计时侧信道;空 token / 末字节翻转用例同步补上。metric 持平 8。","timestamp":1787667401636,"segment":0,"confidence":null,"asi":{"hypothesis":"discovery token 用 != 比较,未授权 /agent/nodes/register 可被计时;改 SHA-256 + ConstantTimeCompare","finding":"公开面注册口 ValidateDiscoveryToken 是入侵入口;管理员已登录操作不在范围内。checks 全绿。","next_action_hint":"下一轮可查边缘 Token 比较(agent/relay/flared 走 DB 查找,计时面更弱)或登录口限流"}}
{"run":32,"commit":"fb62802","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":81,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"公开登录/注册邮箱验证码比较改为 SHA-256 后恒定时间 Compare,堵住未授权口的计时侧信道。metric 持平 8。","timestamp":1787667660993,"segment":0,"confidence":null,"asi":{"hypothesis":"verifyEmailCode 用 != 比较 6 位码,公开登录/注册口可被计时","finding":"公开面验证码比较已改恒定时间;冷却仍在,不改限流策略。","next_action_hint":"下一轮可查边缘节点 access_token 比较(DB 查找,计时面更弱)或登录失败锁定"}}
{"run":33,"commit":"b8bf82b","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":99,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"未授权登录口补哑 bcrypt 比较,用户不存在与密码错误耗时对齐;禁用账号不再返回不同文案,堵住用户枚举。metric 持平 8。","timestamp":1787668237322,"segment":0,"confidence":null,"asi":{"hypothesis":"未授权 /user/login 在用户不存在时跳过 bcrypt,且禁用账号返回不同文案,可枚举用户","finding":"DummyCheckPassword 启动时生成哑哈希,gosec 不报警;禁用账号改统一错误文案。管理员已登录不在范围内。","next_action_hint":"下一轮可查边缘节点 access_token 明文比较,或公开 CAP challenge 滥用"}}
{"run":34,"commit":"380a42a","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":90,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"登录/注册/OAuth 回调统一走 SetLoginSession,保存前清空 Redis 会话 ID,堵住未授权会话固定。metric 持平 8。","timestamp":1787669059138,"segment":0,"confidence":null,"asi":{"hypothesis":"生产 Redis 会话在登录时复用同一 ID,未授权方可固定会话 cookie","finding":"SetLoginSession 先 Clear 再把 gorilla session.ID 置空,Save 时 redistore 生成新 ID;明文改密标记经 extras 写回。","next_action_hint":"下一轮可查边缘节点 access_token 明文比较,或公开 CAP challenge 滥用"}}
{"run":35,"commit":"dfda2d3","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":75,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"去掉公开 CAP 口硬编码默认密钥;SessionSecret 为空时拒绝签发/核销,防止未授权伪造 PoW。metric 持平 8。","timestamp":1787669542055,"segment":0,"confidence":null,"asi":{"hypothesis":"公开 /api/cap/challenge 在 SessionSecret 为空时用硬编码默认密钥,未授权方可伪造 PoW","finding":"GetDefaultManager 无密钥时返回 nil;Challenge/Redeem 拒绝,VerifyMiddleware 在 CAP 开启时同样拒绝。测试自行设置密钥。","next_action_hint":"下一轮可查公开 OAuth state 洪水或边缘节点 access_token 明文比较"}}
{"run":36,"commit":"7fa9e46","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":86,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"注册开关读取失败时改为关闭,堵住配置缺失时未授权开注册;OAuth 自动注册同样 fail-closed。metric 持平 8。","timestamp":1787669960693,"segment":0,"confidence":null,"asi":{"hypothesis":"registration_enabled/password_register_enabled 读取失败默认 true,和种子 false 相反,配置缺失时未授权开注册","finding":"密码注册与 OAuth 自动注册均 fail-closed;测试改为显式开启注册并正确失效缓存。","next_action_hint":"下一轮可查 OIDC 开关 fail-open(种子默认 true,风险较低)或公开 OAuth state 洪水"}}
{"run":37,"commit":"0290c93","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":95,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"公开 OAuth 登录/授权入口按会话限制 10 分钟内最多 20 个 state,堵住未授权 Redis 洪水。metric 持平 8。","timestamp":1787670327304,"segment":0,"confidence":null,"asi":{"hypothesis":"公开 /oauth/login 与 /oauth/{source}/authorize 每次请求都往 Redis 写 10 分钟 state,无上限","finding":"按 sessionHash 计数,10 分钟内最多 20 个;超出返回业务错误。mock Redis 补 Incr/Expire。","next_action_hint":"下一轮可查边缘节点 access_token 明文比较,或公开 CAP challenge 洪水"}}
{"run":38,"commit":"c0a82f8","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":85,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"公开密码登录口按 IP 限制 10 分钟内最多 20 次失败,堵住未授权爆破。metric 持平 8。","timestamp":1787670665553,"segment":0,"confidence":null,"asi":{"hypothesis":"公开 /user/login 失败无 IP 限流,未授权方可无限爆破","finding":"按 ClientIP 计数,10 分钟 20 次失败后拒绝;成功清零。管理员已登录不在范围内。","next_action_hint":"下一轮可查公开 CAP challenge 洪水或边缘节点 access_token 明文比较"}}
{"run":39,"commit":"be5d067","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":76,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"auth_cache negative 缓存加上限防 DoS + relay/flared 删除重复 authenticateAccessToken 改用 agent 共享缓存版","timestamp":1787708052241,"segment":0,"confidence":null,"asi":{"hypothesis":"negative cache 无上限可被伪造 token 撑爆内存;relay/flared 与 agent 三份重复的 authenticateAccessToken","next_action_hint":"继续扫其他无界缓存/限流缺口","result":"metric 持平 8(8 个均为 deliberate keeper),安全修复不计入 metric","security":"negative cache 加 10k 上限+过期清理;relay/flared 复用 agent.AuthenticateAccessToken(共享 2min 正/10min 负缓存,DB 压力下降)"}}
{"run":40,"commit":"0dd2cf9","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":77,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"websocket 三 hub 去重:抽 runWritePump 共享写泵 + 合并 agent 广播函数为 broadcastAgent","timestamp":1787708370650,"segment":0,"confidence":null,"asi":{"hypothesis":"三份 hub 的 writePump 完全重复(仅日志前缀不同),readPump 已有 runReadPump 抽取先例;BroadcastWAFIPGroups/BroadcastActiveConfig 复制粘贴","next_action_hint":"close() 3 份小重复可再合并但收益低;继续找其他模块的重复/无界增长","result":"metric 持平 8,全测试绿","refactor":"新增 websocket/write_pump.go runWritePump(对齐 runReadPump 模式),agent/relay/flared writePump 改委托;agent_hub 抽 broadcastAgent 合并两个广播函数"}}
{"run":41,"commit":"efd8268","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":75,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"websocket 三 client 结构体去重:嵌入共享 wsClientCore(close/enqueue 单份实现)","timestamp":1787708975609,"segment":0,"confidence":null,"asi":{"hypothesis":"agentClient/relayClient/flaredClient 字段与 close/enqueue 完全相同,用组合(嵌入 wsClientCore)消除三份重复","next_action_hint":"代码库经 40 轮已高度收敛;后续可周期性跑 go test -race 全量","result":"metric 持平 8,全测试绿;净减 ~60 行重复代码","refactor":"新增 websocket/client_core.go:wsClientCore(nodeID/conn/send/done/once) + 共享 close/enqueue;三个 client 结构体改为嵌入"}}
{"run":42,"commit":"ed1efd3","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":77,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"补 wsClientCore 并发测试 + close() 防 nil conn 守卫","timestamp":1787709222794,"segment":0,"confidence":null,"asi":{"hypothesis":"wsClientCore 并发语义(close 幂等、enqueue 不阻塞/关后拒绝)无测试覆盖","next_action_hint":"websocket 包已有基础并发测试;继续其他模块扫描","result":"metric 持平 8;测试还暴露 close 未防 nil conn 的防御缺口,已补守卫","refactor":"新增 websocket/client_core_test.go 3 个 -race 测试;client_core.go close() 增加 nil conn 守卫"}}
{"run":43,"commit":"4f8e7e6","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":77,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"修复 frps/frpc TOML 配置注入:新增 protocol.TOMLQuote 并在两处配置渲染全部使用","timestamp":1787709693698,"segment":0,"confidence":null,"asi":{"hypothesis":"frps/frpc TOML 配置用裸 Fprintf 拼接,token/password/域名含引号、反斜杠、换行时会破坏配置或注入键","next_action_hint":"检查其他配置生成点是否有同类注入面(nginx/openresty 配置)","result":"metric 回到 8;frpc 慢套件 16.8s 全绿;mnd 曾短暂+1(Grow 魔法数),删除微优化后消除","security":"新增 pkg/protocol/toml.go TOMLQuote 转义助手 + toml_test.go;relay/frps renderConfig 与 flared/frpc buildFrpcToml 全部插值改为转义输出"}}
{"run":44,"commit":"63007fc","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":92,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"全仓 race 扫描发现 upload/cache 监听器 DATA RACE:捕获 redis 客户端消除全局读竞争 + Stop 等待 done + 同型监听器(oauth×2/repository×2)加固","timestamp":1787711092906,"segment":0,"confidence":null,"asi":{"hypothesis":"全仓 go test -race 可能暴露并发 bug(此前仅局部验证)","next_action_hint":"继续扫其他模块;可考虑把 -race 纳入周期性检查","result":"发现并修复 1 个真实 DATA RACE;修复后全仓 -race 0 竞争,metric 持平 8","root_cause":"upload/cache 监听器 goroutine 读可变全局 db.Redis,与 testhelper 清理置 nil 竞争;testhelper 导入 upload/cache 有循环依赖,故用启动时捕获客户端的根因修复(oauth/repository 同型监听器一并加固),并补 StopUploadMetaCacheListener 同步等待 done"}}
{"run":45,"commit":"63007fc","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":70,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"discard","description":"探索轮:索引对齐/前端请求瀑布/BasicAuth 注入面三假设均证伪,无代码变更","timestamp":1787711474404,"segment":0,"confidence":null,"asi":{"hypothesis":"SQLite 迁移缺 PG 同款索引;前端存在串行请求瀑布;nginx BasicAuth 密码有注入面","next_action_hint":"代码库已高度收敛;下轮可考虑 observability 查询构造器审计或周期性重跑 -race","rollback_reason":"纯探索无代码变更,无需回滚","result":"三个假设均无产出:①索引对比(修正提取正则后)PG/SQLite 完全对齐,SQLite 仅多 legacy w_* 冗余索引;②前端 await Service 均在事件处理器非渲染期;③BasicAuth 密码经 base64 编码(字母表无元字符)无注入面","lessons":"grep 提取 SQL 时注意 IF NOT EXISTS 变体,否则产生假缺口"}}
{"run":46,"commit":"2cb3392","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":106,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"LIKE 过滤器转义修复:日志搜索含 %/_ 的输入不再被当通配符;pkg/util 新增 EscapeLike 共享助手 + 单测","timestamp":1787712116152,"segment":0,"confidence":null,"asi":{"hypothesis":"日志搜索 LIKE 过滤器不转义 %/_/\\,含下划线的路径/主机名搜索结果错误","next_action_hint":"同类遗留站点(upload/user/task_execution GORM 搜索)已记 ideas.md,可作后续轮次","result":"修复 4 个站点:analytics 两处 CH 过滤器 + logstore postgres_store 两处(PG/SQLite 加 ESCAPE '\\')。新增 pkg/util/like.go EscapeLike + 单测。metric 持平 8,全部测试通过","scope_decision":"GORM 实体搜索站(upload keyword、user username/email)同 bug 类但低风险且可能依赖现有通配语义,本轮不动"}}
{"run":47,"commit":"3528323","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":102,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"GORM 实体搜索 LIKE 转义收尾:6 站点复用 EscapeLike + 显式 ESCAPE 子句,含 OAuth 用户名冲突误报修复","timestamp":1787712555794,"segment":0,"confidence":null,"asi":{"hypothesis":"GORM 实体搜索站与 #46 日志搜索同 bug 类:LIKE 模式不转义通配符","next_action_hint":"LIKE 类已全部收尾;下轮可考虑 ideas.md 的测试可运行性方向或周期性全仓 -race 重跑","result":"6 站点修复(upload keyword、user username/email 前缀+contains、OAuth uniqueUsername base、task_type 前缀),PG/SQLite 加显式 ESCAPE。系统常量模式刻意保留(upload.go:199 image/%)。metric 持平 8,测试全绿","scope_decision":"uniqueUsername 的 base 来自 OAuth 用户信息属外部输入,含 _ 会误报用户名冲突——虽是系统生成后缀模式也需转义 base 本身"}}
{"run":48,"commit":"55db1c0","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":112,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"后台 goroutine panic 防护:新增 pkg/util.Go 共享助手(recover+调用点日志),全仓 22 个裸 go func() 站点统一收口","timestamp":1787713583118,"segment":0,"confidence":null,"asi":{"hypothesis":"全仓 20 处后台 goroutine 裸跑零 recover,任一 panic 击穿 gin handler 级恢复直接崩溃进程","next_action_hint":"goroutine 收口完成;下轮可周期性 go test -race ./... 全量重跑(上次 #44)","result":"pkg/util.Go(fn) 共享助手(runtime.Caller 自动记录调用点 + slog + debug.Stack),22 个站点全部收口(含嵌套 watcher)。脚本转换两轮(首轮漏嵌套内层)。首次 checks_failed 因新文件缺 SPDX 头,update_go_license.sh 修复后全绿。metric 持平 8"}}
{"run":49,"commit":"40232d8","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":75,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"修复 frpc restartProcess 发布未初始化 exec.Cmd 的数据竞争:proc.Cmd/Status 改为 Start 成功后加锁发布","timestamp":1787714358791,"segment":0,"confidence":null,"asi":{"hypothesis":"周期性全仓 go test -race ./... 重跑(上次 #44 后又改了 repository/logstore/goroutine 站点)能抓出新数据竞争","next_action_hint":"-race 全仓清零;下轮候选:frontend axe a11y 审计,或 Go 1.26 新 linter 扫描","result":"全仓 -race 抓到 1 个真实 race:frpc/manager.go restartProcess 在 cmd.Start() 前就发布 proc.Cmd+Status=running(Start 中 cmd.Process 未赋值),测试读句柄与之竞争。修复=Start 成功后再加锁发布(manager.go:219-220 移入 err==nil 分支)。frpc 包 -race 连续 3 次通过。其余全仓 -race 干净"}}
{"run":50,"commit":"40232d8","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":70,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"discard","description":"扩展 linter 发现扫描 + 热路径性能排查:errchkjson/unparam/spancheck 等 9 个新维度,全部核实为不可失败/刻意设计/误报","timestamp":1787714798689,"segment":0,"confidence":null,"asi":{"hypothesis":"基准外发现型 linter(errchkjson/unparam/spancheck/exptostd/durationcheck/makezero/reassign/asasalint/bidichk)+ 热路径性能 grep 能找到真实缺陷","next_action_hint":"发现型 linter 已穷尽;下轮候选:frontend axe a11y 浏览器级审计,或任务执行日志/DB 增长类运维审查","result":"全部证伪:errchkjson 12 处均核实为不可能失败的 marshal(纯 string/int/[]string 结构体;2 处 unsafe 标记是传递性保守);spancheck 1 处误报(唯一调用方 executor.go:242 有 defer span.End());unparam×2 为已评估的工厂签名设计;正则全在包级编译无热路径重编译;包级 map 全为有界静态注册表;AppendLog 走 DB 无内存累积。escapeJSONString 用法正确。无代码变更"}}
{"run":51,"commit":"bbf7919","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":72,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"discard","description":"运行时资源审计:HTTP 客户端超时覆盖 + 查询热路径索引覆盖,两项全部干净无缺陷","timestamp":1787715135724,"segment":0,"confidence":null,"asi":{"hypothesis":"运行时资源审计:出站 HTTP 客户端超时覆盖 + LIKE/精确匹配热路径的 DB 索引支撑","next_action_hint":"两项审计干净。剩余:frontend axe a11y(需起前端+浏览器)、周期性 -race 重跑、uploads LOWER(file_name) contains 若成为性能痛点需改前缀语义+表达式索引","result":"全部干净:15 个 http.Client 中 14 个显式 Timeout,唯一无 Timeout 的 agent/nginx checkStubStatus 走 NewRequestWithContext+WithTimeout 边界;users.username 全部精确匹配热路径由 UNIQUE 内联索引覆盖(PG+SQLite 均确认),email/task_type/logstore 过滤列均已有索引;uploads LOWER(file_name) contains 不可用 b-tree 但属管理端低频,改语义才有收益故不动"}}
{"run":52,"commit":"bbf7919","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":71,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"discard","description":"SQL 注入面 + Go 运行时陷阱模式 + react-hooks 依赖三重审计,全部干净无缺陷","timestamp":1787715503278,"segment":0,"confidence":null,"asi":{"hypothesis":"原始 SQL 拼接注入面 + 经典 Go 运行时陷阱(time.After 循环泄漏/defer-in-loop/context.Background 丢失取消)+ 前端 react-hooks 依赖正确性","next_action_hint":"静态+运行时审计维度已穷尽。剩余唯一大项:frontend axe a11y 浏览器级审计(需起前端 dev server + agent_browser)","result":"全部干净:db_manage SQL 控制台为管理端允许例外且表名双引号转义正确、analytics Sprintf 均内部常量表名+参数化占位符;time.After 仅 3 处且均为 select 单次等待/有界重试;defer 均在函数级非循环内;19 处 context.Background() 全部为后台监听器(WithCancel)/重启路径/自带超时的清理任务,无请求 ctx 丢弃;react-hooks/exhaustive-deps 全仓零违规(CLI 临时规则,未改配置)"}}
{"run":53,"commit":"bbf7919","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":72,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"discard","description":"前端 axe a11y 浏览器审计:唯一违规为无后端环境产物,无代码缺陷","timestamp":1787716027952,"segment":0,"confidence":null,"asi":{"hypothesis":"前端 axe-core 浏览器级 a11y 审计(最后一个未探索大维度)","next_action_hint":"a11y 维度已探索但受登录墙限制:完整审计需起后端+种子账号登录。若未来重跑:起 Go 后端 + admin 登录后逐页 axe.run","result":"agent-browser 0.34.0 已装好可复用。axe 审计覆盖所有无认证可达页面(/login、/register、/docs/* 全被登录墙拦截):唯一违规 page-has-heading-one 是环境产物——后端未启动时页面卡在 session-check/publicConfig-pending 态只渲染 Spinner,真实表单的 AuthHeading h1 未渲染;瞬态态用 h3 属可接受的瞬态层级。无代码缺陷。已认证页面需后端才能审计"}}
{"run":54,"commit":"451ce52","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":93,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"认证页 axe a11y 审计+修复:7 处布局级真实违规全修,复扫验证 dashboard/admin/system 归零;基准 total_issues 保持 8 不变(纯质量收益)","timestamp":1787718397798,"segment":0,"confidence":null,"asi":{"hypothesis":"认证页 axe a11y 审计(起后端+登录突破登录墙):修复布局级真实违规","next_action_hint":"已验证 / 与 /admin/system 归零。剩余页面级:admin 表格行内操作按钮/Switch 无 aria-label、muted 文本对比度——需逐表补标签,工作量大已归档 ideas.md","result":"修复 7 处全局问题并复扫验证:sidebar 折叠按钮 aria-label、Sidebar role=navigation(region 违规 18 节点/页清零)、header Kbd 对比度 text-foreground/70(每页 1 处)、dashboard 4 个 Progress aria-label、分页按钮 aria-label、空态/错误/加载 h3→p(heading-order 清零)、admin/system 无内容 Tabs 改 aria-pressed 按钮组(aria-valid-attr-value critical 清零)。dashboard 与 admin/system 现 0 违规","setup":"审计环境:后端 go run . api @:3100(CONFIG_PATH=/tmp/of-audit/config.yaml,sqlite+redis host 网络 docker)、前端 pnpm dev --port 3002(WAVELET_BACKEND_URL=:3100)、admin 密码经 reset-passwd 重置"}}
{"run":55,"commit":"e66dea9","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":85,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"a11y 收尾:主题级对比度根因修复(indigo-500→600)+12 处控件 accessible name+4 处 heading-order,7 页复扫全 0 违规;基准 total_issues 保持 8","timestamp":1787719908229,"segment":0,"confidence":null,"asi":{"hypothesis":"页面级 a11y 批量收尾:主题级 color-contrast 根因 + 表格/表单控件 accessible name","next_action_hint":"7 页复扫全 0 违规。剩余:其余页面(websites/origins/cloudflare 等仅扫过 contrast 已由主题修复覆盖)可抽查;-race 周期重跑","result":"根因1:--primary indigo-500(#6366f1) 对 #fafafa 仅 4.27 → 改 indigo-600 oklch(51.1% 0.262 276.966)(~6.8 AA),全站 contrast 清零(一处主题修复覆盖所有页面)。修复 12 处控件名:access-analytics 刷新按钮、events-tab Switch/edit/delete、openflare-ops ToggleRow Switch+geoip/kuma Select+FieldInput Input htmlFor+discovery Textarea、table-browser/sql-console SelectTrigger;heading-order:cache-manager/user-detail-sheet h4→p、task-manager h3→p、file-manager noFiles h3→p;新增 admin.logs.analytics.refresh i18n 键(en/zh)+merge-i18n-fragments。教训:settings 表单异步渲染,早前扫描漏报 label 违规需 wait 5s 后再 axe.run;Radix SelectValue value='' 时 placeholder 不显示致 combobox 无名,须 aria-label 兜底","setup":"审计环境同 run#54:后端:3100(sqlite) + docker redis host 网络 + pnpm dev --port 3002"}}
{"run":56,"commit":"63e3b85","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":85,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"富交互页 a11y 抽查收尾:8+3 页扫描,修复 cloudflare 筛选器无名/access-token amber 对比度/notifications 缺 h1 共 3 处,全部复扫归零;基准 total_issues 保持 8","timestamp":1787720716912,"segment":0,"confidence":null,"asi":{"hypothesis":"富交互页抽查(websites/origins/proxy-routes/certificates/cloudflare/dns-accounts/settings 子页)","next_action_hint":"11 页扫描全部归零,a11y 维度已穷尽。剩余:周期性 -race 重跑;审计环境复用法在 ideas.md","result":"websites/origins/proxy-routes/certificates/dns-accounts 5 页直接 0 违规(主题修复覆盖);3 处新发现全修复并复扫验证:cloudflare 同步面板状态筛选 SelectTrigger 加 aria-label(statusPlaceholder);access-token 安全提示 amber-600→amber-700(12px 小字对比度 4.5 不达标);notifications 面包屑页加 sr-only h1——教训:h1 不能放 BreadcrumbList 内(破坏 list 语义 axe list 规则),BreadcrumbPage 无 asChild 需放 Breadcrumb 外","setup":"审计环境同前:后端:3100 + docker redis host 网络 + pnpm dev --port 3002"}}
{"run":57,"commit":"453f7e5","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":95,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"周期性 -race 重跑抓到真实 bug:wsClientCore.enqueue close 后 select 随机选择致契约违反;确定性先查 done 修复+测试循环加固+gofmt 存量漂移清理","timestamp":1787721299485,"segment":0,"confidence":null,"asi":{"hypothesis":"周期性全仓 -race 重跑(上次干净为 run #49)","next_action_hint":"websocket 包 -race 10×count=1 全过。教训已记录:select 多 case 同时就绪时随机选择,closed 检查须独立 select 先行;replace 工具锚点选错会级联破坏文件,小文件直接 write 重写更安全","root_cause":"enqueue 把 closed 检查与发送合并在同一个 select,两 case 同时就绪时 Go 随机选择,close 后约 50% 概率仍投递成功——违反 fail-fast 契约且测试 flaky。修复=独立 select 确定性先查 done;测试加固为循环 50 次","result":"抓到真实 bug:wsClientCore.enqueue close 后非确定返回 true(TestWSClientCoreEnqueueFailsAfterClose 必失败)。调用方 agent_hub×3 语义无影响(false=丢弃本就正确)。顺带修 3 个 hub 文件存量 gofmt 漂移","scope_note":"-race 重跑仅 websocket 包 1 个 FAIL,其余 internal/... pkg/... 全部通过"}}
{"run":58,"commit":"fc733d0","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":77,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"#57 enqueue 修复的同型残留收口:SendFlaredPong/SendRelayPong 合并 select 随机选择 bug,委托 client.enqueue 去重修复","timestamp":1787721635717,"segment":0,"confidence":null,"asi":{"hypothesis":"#57 修复 enqueue 后,grep 全 hub 同型合并 select——发现 SendFlaredPong/SendRelayPong 残留相同 bug","lesson":"修一个 bug 后应 grep 所有同型调用点(本会话 run #44/#46/#57 三次都是同型残留收口模式);委托共享 enqueue 是去重+根因一步到位","next_action_hint":"websocket 并发面已全清。下轮可做:周期性全仓 -race 或 go test -count=10 稳定性抽查","root_cause":"SendFlaredPong (flared_hub.go) 与 SendRelayPong (relay_hub.go) 把 case <-client.done 与 case client.send <- 合并同一 select,两 case 同时就绪时 Go 随机选择,close 后仍可能投递成功。修复=委托 client.enqueue(内含确定性先查 done),同时消除重复代码"}}
{"run":59,"commit":"b56f276","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":66,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"#59 -shuffle=on 扫描抓到测试顺序依赖:config_version RAM 配置缓存跨测试污染,setup/cleanup 接入 ram.ResetForTest() 修复","timestamp":1787722520315,"segment":0,"confidence":null,"asi":{"hypothesis":"-shuffle=on 测试顺序随机化扫描(未查过的维度),暴露测试间共享状态依赖","lesson":"repository 读配置会写进程级 RAM 缓存(ram.Set,TTL 跨测试存活);测试用 :memory: DB + SetDB 换库时缓存不随之失效。默认源码顺序下 Defaults 先跑掩盖了问题。-shuffle=on 是暴露此类顺序依赖的低成本手段,可周期重跑","next_action_hint":"全仓 shuffle 已干净。下轮候选:-count 多轮稳定性、或从 ideas.md 剩余条目挑;明确不做清单见 ideas.md","root_cause":"TestBuildOpenRestyConfigSnapshotOriginErrorPageDefaults 在 shuffle 下命中 Custom 用例留在进程级 RAM 配置缓存的 enabled=false/[\"522\",\"500-502\"](GetSystemConfigByGroup 未命中时 ram.Set 回填)。修复=两个测试 setup(setupOriginErrorPageSnapshotDB/setupConfigVersionTestDB)接入既有 ram.ResetForTest():换 DB 前后各清一次"}}
+90
View File
@@ -0,0 +1,90 @@
#!/bin/bash
# Benchmark: total code-quality issues across backend + frontend (lower is better).
# Fixed linter set — see .auto/prompt.md. Never tune this file to game counts.
set -euo pipefail
cd "$(dirname "$0")/.."
start=$(date +%s)
# ---------- Backend: golangci-lint, repo config + fixed best-practice extras ----------
EXTRA_LINTERS="errorlint,errname,nilnil,forcetypeassert,copyloopvar,intrange,mirror,perfsprint,prealloc,usestdlibvars,modernize,sloglint,canonicalheader,nosprintfhostport,recvcheck,wastedassign,exhaustive"
golang_out=$(golangci-lint run --enable="$EXTRA_LINTERS" 2>&1 || true)
golang_total=0
while IFS= read -r line; do
if [[ "$line" =~ ^\*\ ([a-zA-Z0-9_]+):\ ([0-9]+)$ ]]; then
name="${BASH_REMATCH[1]}"
n="${BASH_REMATCH[2]}"
golang_total=$((golang_total + n))
echo "METRIC golint_${name}=$n"
fi
done <<< "$golang_out"
echo "METRIC golint_total=$golang_total"
# ---------- Backend: test-code quality (tests excluded from repo config; safe linters only) ----------
test_out=$(golangci-lint run --tests=true --enable=testifylint,usetesting,thelper --enable-only=testifylint,usetesting,thelper 2>&1 || true)
golang_test_total=0
while IFS= read -r line; do
if [[ "$line" =~ ^\*\ ([a-zA-Z0-9_]+):\ ([0-9]+)$ ]]; then
name="${BASH_REMATCH[1]}"
n="${BASH_REMATCH[2]}"
golang_test_total=$((golang_test_total + n))
echo "METRIC golint_test_${name}=$n"
fi
done <<< "$test_out"
echo "METRIC golint_test_total=$golang_test_total"
# ---------- Backend: govet extra analyzers (dead code / nil deref — real-bug finders) ----------
cat > /tmp/govetx.yml <<'EOF'
version: "2"
linters:
default: none
enable:
- govet
settings:
govet:
enable:
- nilness
- unusedwrite
EOF
vetx_out=$(golangci-lint run --config /tmp/govetx.yml --max-issues-per-linter=0 2>&1 || true)
rm -f /tmp/govetx.yml
golang_vetx_total=0
while IFS= read -r line; do
if [[ "$line" =~ ^\*\ ([a-zA-Z0-9_]+):\ ([0-9]+)$ ]]; then
name="${BASH_REMATCH[1]}"
n="${BASH_REMATCH[2]}"
golang_vetx_total=$((golang_vetx_total + n))
echo "METRIC golint_vetx_${name}=$n"
fi
done <<< "$vetx_out"
echo "METRIC golint_vetx_total=$golang_vetx_total"
# ---------- Frontend: eslint (repo gate) ----------
cd frontend
eslint_out=$(pnpm exec eslint . --max-warnings 0 2>&1 || true)
eslint_problems=0; eslint_errors=0; eslint_warnings=0
if [[ "$eslint_out" =~ ([0-9]+)\ problems? ]]; then eslint_problems="${BASH_REMATCH[1]}"; fi
if [[ "$eslint_out" =~ \(([0-9]+)\ errors?, ]]; then eslint_errors="${BASH_REMATCH[1]}"; fi
if [[ "$eslint_out" =~ ,\ ([0-9]+)\ warnings? ]]; then eslint_warnings="${BASH_REMATCH[1]}"; fi
echo "METRIC eslint_problems=$eslint_problems"
echo "METRIC eslint_errors=$eslint_errors"
echo "METRIC eslint_warnings=$eslint_warnings"
# ---------- Frontend: tsc (repo gate) ----------
tsc_out=$(pnpm exec tsc --noEmit --jsx preserve 2>&1 || true)
tsc_errors=$(grep -cE "error TS" <<< "$tsc_out" || true)
echo "METRIC tsc_errors=$tsc_errors"
# ---------- Frontend: vitest (2026-08-16 起全绿,纳入基准防回归) ----------
vitest_out=$(pnpm exec vitest run --reporter=dot 2>&1 || true)
vitest_failed=0; vitest_total=0
if [[ "$vitest_out" =~ ([0-9]+)\ failed ]]; then vitest_failed="${BASH_REMATCH[1]}"; fi
if [[ "$vitest_out" =~ Tests[[:space:]]+([0-9]+)\ passed ]]; then vitest_total="${BASH_REMATCH[1]}"; fi
if [[ "$vitest_out" =~ Tests[[:space:]]+([0-9]+) ]]; then vitest_total="${BASH_REMATCH[1]}"; fi
echo "METRIC vitest_failed=$vitest_failed"
echo "METRIC vitest_total=$vitest_total"
end=$(date +%s)
total=$((golang_total + golang_test_total + golang_vetx_total + eslint_problems + tsc_errors + vitest_failed))
echo "METRIC total_issues=$total"
echo "METRIC measure_s=$((end - start))"
+169
View File
@@ -0,0 +1,169 @@
# Autoresearch: 前后端代码质量符合最佳代码实践
## Objective
Improve backend (Go) and frontend (Next.js/TS) code quality so the codebase
conforms to best practices. NOT a performance task. Each experiment is a code
change that removes real, lint-diagnosed code-quality issues (dead assignments,
error-wrapping bugs, non-idiomatic loops, mixed receivers, unsafe error
comparisons, unnecessary string fmt, etc.) without changing behavior.
Genuine quality work only: fix code, never weaken the checks. Do NOT edit
`.golangci.yml`, eslint/biome config, or add `nolint`/`eslint-disable`
comments to reduce counts. Do NOT reformat code that isn't part of a fix
(no formatted-only churn).
## Metrics
- **Primary**: `total_issues` (unitless, lower is better) = backend golangci
issues (extended linter set below) + frontend eslint problems + tsc errors.
- **Secondary**: per-linter counts (`golint_modernize`, `golint_perfsprint`,
`golint_errorlint`, `golint_gosec`, `golint_canonicalheader`,
`golint_recvcheck`, `golint_wastedassign`, `golint_usestdlibvars`,
`golint_intrange`, `golint_forcetypeassert`, `golint_nilnil`,
`golint_prealloc`, `golint_errname`, `golint_sloglint`,
`golint_copyloopvar`, `golint_mirror`, `golint_nosprintfhostport`),
`eslint_problems`, `eslint_errors`, `eslint_warnings`, `tsc_errors`,
`measure_s` (benchmark wall time).
## How to Run
`./.auto/measure.sh` — outputs `METRIC name=value` lines. Parsed by
run_experiment automatically.
Correctness gate: `./.auto/checks.sh` runs `go vet ./...`, `go build ./...`,
and the repo's own `golangci-lint run` (repo config, tests excluded) — all
must pass. Note: `go test ./...` is NOT in checks.sh — several tests fail on
main today for environmental reasons (no local redis; flaky frpc process
tests). Don't "fix" those unless cheap and clearly unrelated to redis/flaky.
## Benchmark Definition (fixed — never change mid-session)
Backend: `golangci-lint run --enable=errorlint,errname,nilnil,forcetypeassert,
copyloopvar,intrange,mirror,perfsprint,prealloc,usestdlibvars,modernize,
sloglint,canonicalheader,nosprintfhostport,recvcheck,wastedassign`
(repo `.golangci.yml` linters stay active too; `tests: false` as configured).
Frontend: `pnpm exec eslint . --max-warnings 0` (repo gate) +
`pnpm exec tsc --noEmit --jsx preserve` (repo gate).
Test-code dimension (added 2026-08-16, run #12+, documented scope extension —
raising the bar, not gaming): `golangci-lint run --tests=true
--enable=testifylint,usetesting,thelper --enable-only=testifylint,usetesting,thelper`
counts test-file quality. DELIBERATELY excludes paralleltest/tparallel
(t.Parallel advice is unsafe here: many suites share DB/redis state and tests
cannot be run in this env) and gocritic extras (noise). Fix test issues only
when compile-safe (go vet compiles tests) and semantically neutral.
Frontend vitest dimension (added run #19, after suite went green in run #18):
`pnpm exec vitest run --reporter=dot` — `vitest_failed` counts into total.
The suite is fully runnable locally (jsdom + mocks; no external services).
Do not add/remove linters or change settings to make the number go down.
## Files in Scope
Backend (Go): `cmd/`, `internal/`, `pkg/`. Anything lint-flagged in the
extended set above. Note: module name in go.mod is `github.com/Rain-kl/Wavelet`.
Frontend (TS/React): `frontend/app/`, `frontend/components/`, `frontend/lib/`,
`frontend/contexts/`, `frontend/hooks/`, `frontend/types/`, frontend scripts.
Infra: `frontend/pnpm-workspace.yaml` — approved @parcel/watcher + @swc/core
builds (fixes `make code-check` under pnpm 11; ERR_PNPM_IGNORED_BUILDS
otherwise). Already committed in setup.
## Off Limits
- `.golangci.yml`, `eslint.config.mjs`, `biome.json` — never touch to reduce counts.
- No `//nolint` / `eslint-disable` comments to silence checks.
- No reformat-only commits (biome/gofmt churn without a fix).
- No behavior changes: refactors must compile (checks.sh gate) and keep tests
semantics identical. Re-run checks.sh after every edit.
- `frontend/node_modules`, `frontend/bun.lock` (untracked, not ours).
- Do not run `go test` suites that need redis/network to declare success.
## Constraints
- Backend conventions (AGENTS.md): apps → repository → model layering;
`pkg/util/` must not import Gin/GORM/sessions; no `db.DB` in model;
response.Abort* for API errors; Chinese docs for content changes
(code-quality fixes are not content changes — no doc sync needed unless
behavior/UX changes; changelog only for user-visible changes, typically
none here).
- Frontend: run `pnpm exec biome format --write` only on files you edit
(repo `make format` uses biome); keep component placement rules.
- `golangci-lint --fix` is allowed and preferred for safe fixes
(modernize/intrange/perfsprint/usestdlibvars/canonicalheader/mirror/
copyloopvar/sloglint/errname) — review the resulting diff before keeping.
For no-fix linters (errorlint wrapping, wastedassign, recvcheck, nilnil,
prealloc, forcetypeassert) edit by hand.
## Workflow per iteration
1. Read current measure output: which categories remain, where.
2. Pick ONE category (or a coherent set of similar fixes), locate files, fix
by hand or with golangci-lint --fix scoped to that category.
3. `./.auto/measure.sh` → if total dropped → `./.auto/checks.sh` → log keep.
If flat/worse → discard or adjust.
## What's Been Tried
- Setup commit `ee6974d` (autoresearch/code-quality-2026-08-16): branch,
.auto/ session files, frontend/pnpm-workspace.yaml build approvals.
- Baseline (before any code fix): total_issues = 108
(golangci 107 = modernize 37, perfsprint 18, errorlint 12, canonicalheader 8,
recvcheck 7, wastedassign 7, usestdlibvars 3, intrange 3, forcetypeassert 3,
nilnil 3, prealloc 3, errname 1, gosec 2; eslint 1 warning
[react-hooks/exhaustive-deps in
app/(main)/pages/detail/components/pages-source-card.tsx:275]; tsc 0).
- Environment notes: golangci-lint 2.12.2 warm cache ~3s; eslint cold ~27s
(ignore stderr pnpm noise); go vet+go build ~15-30s after edits.
### 最终状态(run #23,提交 aa4fadda,本会话收敛点)
基准 5 维全下限 total=8(全为刻意保留);后端 94 包 + 前端 vitest 116 全绿;
`go test -race ./internal/... ./pkg/...` 93 包零警告;`make build-embedded`
(发布路径)成功且工作树干净;`make license-check` / `go mod tidy -diff` /
`go test -count=3`(时序敏感包)全部通过。checks.sh 门禁:vet + build +
golangci + 单测 + vitest + 并发包 -race + license-check。
### Session result (14 experiments, commits f1f6bb85→65c02ef7)
108 → **8** (-92.6%) across 3 benchmark dimensions, all remaining 8 are
deliberate, documented keepers (see below). Never weakened a check; never
added nolint/eslint-disable; benchmark extensions were transparently
documented (test-code dimension run #12, exhaustive run #14).
Fixed (zero behavior change, each reviewed):
- gosec 2→0 (saturating multiply pattern gosec accepts without nolint)
- modernize 37→5→3 (any, max/min, slices/maps, strings.Cut/SplitSeq,
strings.Builder; omitted omitted-lark: nested struct omitzero = wire change)
- perfsprint 18→0, canonicalheader 8→0, usestdlibvars 3→0, intrange 3→0,
wastedassign 7→0, errname 1→0, forcetypeassert 6→0, prealloc 2→0
- errorlint 12→1 (errors.Is/As, %v→%w chains)
- recvcheck 7→1 (GORM TableName → pointer receiver; verified gorm source uses
reflect.New, tests pass)
- eslint 1→0 (exhaustive-deps: add stable `t` to dep array)
- test dimension 25→0 (testifylint 20, thelper 3, usetesting 2)
- exhaustive 12→0 (explicit enum cases = fail-explicit)
Deliberate keepers (8) — do NOT "fix" without new evidence:
- errorlint 1: pkg/push/telegram.go %v — wrapping the original error would
change errors.Is matching semantics; it's intentionally textual context.
- modernize 3: nested-struct omitempty (client.go Release/Asset,
lark.go Content) — omitzero would CHANGE wire output (plain structs
serialize always today).
- nilnil 3: not-found/optional-result conventions — postgres_store.go
ClickHouseOperationalStats (interface contract, documented in comment),
openflare_apply_log.go GetLatestOpenFlareApplyLogByNodeID (tested),
github_source_action.go guarded outcome (callers check != nil).
- recvcheck 1: MillisecondDuration — encoding/json requires Marshal value
receiver + Unmarshal pointer receiver.
Surveyed and rejected (noise/risk, do not add):
- fieldalignment (~100+): JSON key order change + positional literal risk.
- sloglint full / gocritic extras: 0 findings.
- paralleltest/tparallel: t.Parallel advice unsafe (shared DB/redis state;
tests not runnable in this env).
- biome format drift (76 files): pure formatting noise; repo's make format
covers it.
+129
View File
@@ -0,0 +1,129 @@
# Deferred proposals — autoresearch run (iterations 27-36)
Five verified findings deliberately **not** changed by the loop: each needs either a
contract/API decision or a multi-package restructure, which this run was scoped to
propose rather than perform. Evidence is from reading the cited files in this
checkout at commit `ea97b64` plus iterations 27-35.
---
## P1 — Cross-driver storage migration cannot move objects (severity: data availability)
`plugins/domain/upload/task/storage_migration.go` computes `target` from the payload,
then calls:
```go
migrated, err := migrateObjects(ctx, storageSvc, storageSvc, total)
```
`sourceBackend` and `targetBackend` are the **same** `contracts.StorageService`. That
service resolves its backend per call and only ever to the currently active one
(`plugins/infra/storage/plugin.go:89` → `s.backend` or `objectstore.Active(ctx)`), and
the target config is persisted **after** the migration loop
(`uploadstorage.SaveActiveConfig(ctx, target)`).
Consequence for a non-empty source: `migrateSingleObject` reads and writes the same
backend; `shouldSkipMigration` finds every object already "present in the target" and
skips it, yet `migrated` is still incremented, so the task returns
`存储迁移完成,共迁移 N 个对象,活动存储已切换为 <driver>` having copied **zero** bytes,
and then points the platform at an empty backend. The same-driver and
`total == 0` branches are harmless and legitimately need no copying.
Why the tests miss it: `shared.MockStorageService` is one instance serving both
parameters, so a copy-to-self looks correct.
Proposed fix (needs a contract decision — this is a feature, not a patch):
1. Extend `contracts.StorageService` with the ability to operate against an explicitly
supplied `StorageConfigDTO` (e.g. `BackendFor(ctx, cfg) (StorageReader, error)`),
implemented in `plugins/infra/storage` where the `objectstore` backends live. They
are unexported today and `plugins/domain/upload` must not import them (cross-plugin
import ban), so the contract is the only correct route.
2. In the task, build the target from `target` and pass distinct source/target.
3. Only save the active config after a verified copy, and assert `src != dst` at
entry.
4. Interim safety option if a decision is needed sooner: make the
`target.Driver != active.Driver && total > 0` branch return an explicit
not-implemented error instead of reporting success. Rejected by this loop because
it disables an advertised admin operation, which is a product call, and because
the machinery it would strand (`migrateObjects`, `migrateSingleObject`,
`shouldSkipMigration`) becomes dead code the project gate then rejects.
Size: contract + infra impl + task wiring + a two-backend test double. Roughly one
focused session, not a loop iteration.
---
## P2 — `w_system_configs` has one migration owner and many writers (Cordis single-owner)
Owner per migrations: `plugins/domain/admin`. Still read/written with raw SQL from
`plugins/domain/system/repository.go:31`, `plugins/domain/cap/repository.go:50`,
`plugins/domain/auth/repository.go:122`, `plugins/domain/upload/storage/migration.go:96,108`,
`plugins/domain/upload/ingest/helpers.go:55`,
`plugins/domain/message_gateway/repository/push.go` and `plugins/drivers/driver_http`.
Iteration 34 fixed one instance of the real damage this causes (a failed read looked
identical to "unconfigured", silently dropping notifications); iteration 35 fixed
another (a failed read cached a narrowed whitelist for a whole TTL). The remaining
sites carry the same trap.
Proposed fix: one settings accessor contract (`Get(ctx, key) (string, error)` /
`GetAll(ctx, keys...)`) owned by the settings subsystem, then delete the raw table
access. Keys should be declared where they are used rather than string-matched.
Size: medium, touches seven plugins; do it key-group by key-group so each step is
independently revertable.
---
## P3 — Two tables are modelled twice (schema drift hazard)
* `w_task_executions`: `plugins/domain/admin/model/entity.go:207` **and**
`plugins/drivers/driver_asynq_worker/types.go:49`. The two `TaskExecution` structs
and their status enums are byte-for-byte identical today.
* `w_schedules`: `plugins/domain/admin/model/entity.go:169` **and**
`plugins/drivers/driver_asynq_cron/schedule.go:25`.
Nothing is broken yet — that is the risk: the migration owner was only recently moved
to `admin` (`49f9d10`), and a column added to one struct will silently diverge from
the other, so whichever writer holds the stale struct zeroes or omits the new column.
Proposed fix: pick the single owner per P2's rules and have the other side go through
a contract (execution recording already has DTOs in `contracts`), then delete the
duplicate model. Consider a gate check rejecting two non-`testhelper` packages
declaring the same `w_` table — it will fail until these two are resolved, so land it
with the fix (the pattern that worked in iterations 5 and 19).
---
## P4 — `user` deletes rows from tables owned by `auth`
`plugins/domain/user/repository.go:327,330` issues `DELETE` against `w_access_tokens`
and `w_external_accounts`, both owned and migrated by `plugins/domain/auth`, inside
user deletion. It works, but ownership is inverted: revoke-on-delete is auth's
invariant, and encoding it in `user` means any other deletion path silently skips it.
Proposed fix: emit a typed `user:deleted` event from `user` and let `auth` cascade
within its own transaction boundary, or expose an explicit `AuthService.RevokeForUser`.
Size: small-to-medium; needs a test that the revocation still happens on delete.
---
## P5 — Package `cap` shadows the predeclared identifier (8 of 62 debt)
Every file in `plugins/domain/cap` declares `package cap`, which shadows the builtin.
It is the single largest block of non-cosmetic lint debt this run declined to chase,
and it is also a readability cost (`cap.Something` reads as a builtin call).
Proposed fix: rename to a non-shadowing identifier (e.g. `capacity` / `proofwork`,
matching what the plugin actually does) across its own files and importers. Mechanical
but wide; needs a decision on the new name first, which is why it is not done here.
---
## Explicitly rejected as metric-chasing
23 `funcorder`, 5 `exhaustive` (both flagged only because
`default-signifies-exhaustive` defaults to false), 4 `nonamedreturns` and the 17
`forcetypeassert` cluster in `core/events.go` and `core/extpoints/config_resolve.go`
— verified guarded by construction (`convertString` etc. return `(any, error)` and
always yield the asserted type when `err == nil`). Reordering functions or adding
unreachable `if !ok` branches would raise the score and lower the code.
+57
View File
@@ -0,0 +1,57 @@
#!/bin/bash
# Mechanically prove a FIX iteration is load-bearing.
#
# Usage: .auto/prove_fix.sh <package> <changed source file> [<more files>...]
#
# Run immediately AFTER committing the fix, with a clean worktree. It reverts
# only the non-test source files to their pre-fix state (keeping the new test),
# runs the package tests, and requires them to FAIL. Then it restores HEAD.
# A fix nobody can break with a revert is not a fix.
set -uo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
if [ ! -z "$(git -C "${ROOT}" status --porcelain)" ]; then
echo "PROVE ABORT: worktree must be clean (commit the change first)"
exit 2
fi
PKG="$1"; shift
SRC_FILES=("$@")
if [ "${#SRC_FILES[@]}" -eq 0 ]; then
echo "PROVE ABORT: no source files given"
exit 2
fi
cd "${ROOT}/backend" || exit 2
restore() {
git -C "${ROOT}" checkout HEAD -- "${SRC_FILES[@]}" 2>/dev/null
}
trap restore EXIT
for f in "${SRC_FILES[@]}"; do
if git -C "${ROOT}" cat-file -e "HEAD^:${f}" 2>/dev/null; then
git -C "${ROOT}" checkout "HEAD^" -- "${f}" || { echo "PROVE ABORT: cannot revert ${f}"; exit 2; }
else
# File did not exist before this commit — removing it is the revert.
rm -f "${ROOT}/${f}"
fi
done
echo "--- tests against pre-fix source ---"
OUT=$(go test -count=1 "${PKG}" 2>&1)
RC=$?
echo "${OUT}" | tail -15
if [ "${RC}" -eq 0 ]; then
echo "PROVE FAILED: tests still pass without the fix — this is not a real bug fix"
exit 1
fi
if echo "${OUT}" | grep -q 'build failed'; then
KIND="compile (signature changed; behaviour proven by inspection)"
elif echo "${OUT}" | grep -qE '^--- FAIL'; then
KIND="assertion"
else
KIND="failure"
fi
echo "PROVED: test fails without the fix (${KIND})"
exit 0
+37
View File
@@ -0,0 +1,37 @@
iteration commit metric delta status guard description
0 - 102 0.0 baseline pass initial measurement (pinned yardstick: repo gate + real-risk analyzers)
1 1c5731b 100 -2.0 keep pass core: Using2/Using3 now wrap dependency causes via errors.Join (proven: test fails on revert)
2 37ad586 95 -5.0 keep pass sentinel == comparisons -> errors.Is across admin/upload/cap-pow (5 sites)
3 686e3ef 93 -2.0 keep pass filesrv.AbortUploadRecordError dedups error mapping + errors.As (2 sites, drops dead ErrInvalidUploadID)
4 7e6b9e7 93 0.0 keep pass PROVEN FIX: singleflight image generation no longer dies with the first caller canceled ctx (test fails on revert)
5 381c794 93 0.0 keep pass CORDIS: gate widened to catch bare "go call()" + 4 unprotected cleanup goroutines moved to util.Go (arch violations 4->0)
6 ce33997 92 -1.0 keep pass BUGFIX admin logs: negative cursor was accepted (bool ignored by callers) -> error-only contract; proven via revert (compile-level) + contract test
7 c66399e 89 -3.0 keep pass push channels share title/content/level extraction (3 dead inits gone, ~20 fewer lines)
8 18820b1 89 0.0 keep pass BUGFIX push: synthesized notification content had random field order (map iteration); sorted keys, test observed failing pre-fix
9 22ecafd 87 -2.0 keep pass unparam: always-nil error returns dropped, 4 unreachable branches removed
10 c4068ef 84 -3.0 keep pass errorlint cleared to 0: %%w at push test + telegram fallback, errors.As in config loader
11 3d2038a 80 -4.0 keep pass nilnil: unimplemented auth mocks now return a sentinel instead of (nil,nil)
12 101cb2f 79 -1.0 keep pass nilnil: inproc driver GetExecution returns error, matching asynq driver semantics
14 6932b54 79 0.0 keep pass DATA-LOSS BUGFIX: cache read error no longer clobbers buffered task log (proven: assertion fails on revert)
15 2c41563 79 0.0 keep pass PERF: CORS origin check no longer hits DB per request (5s cached read); proven - loader count 0 vs 1 on revert
16 976f9b1 79 0.0 keep pass PERF: contract-level batch user lookup replaces N+1 in access-log enrichment (test proves 1 query vs 3)
17 1b1c452 79 0.0 keep pass BUGFIX: orphan cron message_gateway:cleanup_pairing_codes now has a handler; invariant test added (proven by stash-revert)
18 8c4955c 79 0.0 keep pass BUGFIX: removed phantom user:daily_audit cron (dispatched to unregistered task); cross-plugin invariant test added
19 84eaf3f 79 0.0 keep pass CORDIS+BUGFIX: task handlers were asynq-typed so 4 upload tasks could not run under the in-process worker; made driver-agnostic + gate check 7 (proven: gate names all 3 files pre-fix)
20 efa7555 79 0.0 keep pass BUGFIX telegram: LongPoller.Timeout was 10 nanoseconds -> getUpdates timeout=0 -> busy polling; now 10s (proven by reverting the constant)
21 1023fa3 79 0.0 keep pass DISK LEAK: telegram inbound media scratch dirs were never removed (no consumer reads them); cleanup on handler exit. No test possible (needs live download)
22 ad83841 54 -25.0 keep pass dead lint suppressions removed (24); 2 were load-bearing -> restored+narrowed with reasons after guard veto exposed verified contextcheck FPs
23 de938de 54 0.0 keep pass SECURITY/BUGFIX fail-open auth: user+message_gateway consumed contracts.AuthService in Apply but declared only DBService, so reconcile mounted user before auth and loginMW degraded to a pass-through (user change-password/profile/access-tokens unguarded in production, deterministically); declared the dep + added reconcile-level ordering test (PROVED: assertion fails on revert)
24 62b48e9 54 0.0 keep pass SECURITY: all three auth-middleware fallbacks were c.Next() (fail-open). Reachable at runtime in admin: OnDispose->ResetServices() nils the global the per-request guard reads, so in-flight requests pass as authenticated. Added ginutil.AuthUnavailable() + table test driving each registered guard (PROVED: abort assertion fails on revert to 577d795)
25 f58f5a4 54 0.0 keep pass staticcheck ST1023 x4 from iter 24 (redundant gin.HandlerFunc on typed-RHS decls) - caught by GUARD only, go build/go test both stayed green; lesson: run checks.sh after EVERY commit, not just before ship
26 - 64 +10.0 rebaseline pass upstream config-extension + auth/user/task work raised debt 54->64; re-measured at HEAD ea97b64, 47 pkgs pass, arch 0 viol. Run focus agreed: real defects primary, debt secondary (proven-fix gate keeps delta-0 fixes)
27 31f3af6 63 -1.0 keep pass dead contextcheck suppression on cmd.newWaveletApp removed; the core.App.Run one was load-bearing (guard veto: project gate contextcheck Run->Start, verified FP on variadic ctx) -> restored narrowed + documented. Lesson 8 trap re-hit: nolintlint "unused" != safe to delete
28 5037097 63 0.0 discard fail CORDIS gate: contracts DTO must not carry TableName + removed UserDTO.TableName(). DISCARDED: my grep used -g !*_test.go and missed upload/handler/routers_test.go:669 which does db.Create(&contracts.UserDTO{}) into w_users - that suppression exists precisely to enable the cross-plugin write. Lesson: contracts-purity changes must scan test files too.
29 2ff0cb8 63 0.0 keep pass CORDIS contracts purity: gate check 2.2 forbids TableName()/gorm tags in core/contracts + removed UserDTO.TableName(); upload/handler test now seeds via explicit .Table("w_users") (precedent: filesrv test). Proven twice over: gate named auth.go:33 pre-fix, and iter-28 revert broke 1 package without the test fix. Delta-0 keep under the agreed real-defect gate
30 9ea0e2b 63 0.0 keep pass SECURITY (assertion-proven): FindUserByFieldRecord interpolated its column arg into WHERE with only a prose comment as guard. Pre-fix the tautology "username = '' OR 1=1 --" EXECUTED and returned a row with err=<nil> (filter bypass). Now an allow-list rejects before GetDB. Also first test in the repository pkg: tests_passed 47->48, funcs 282
31 5193bd0 63 0.0 keep pass HARNESS INTEGRITY: measure.sh and checks.sh now key GOLANGCI_LINT_CACHE per checkout. The default cache is machine-wide, so entries written by a sibling worktree replayed here carrying ITS absolute paths (12 of 63 lines pointed at an outside checkout), misattributing findings and risking a stale Guard verdict. Proven count-neutral: cold and warm both 63; foreign paths now 0. Delta 0 by design, kept under the agreed real-defect gate
32 f7a86d3 63 0.0 keep pass PERF+DEDUP: three packages hand-rolled mutex+[]string+MatchPathPattern loop, re-normalising and re-splitting immutable patterns per request. New extpoints.PathWhitelist compiles patterns at registration and absorbs all three. Mechanically asserted: 14 allocs/op -> 1 allocs/op; equivalence test pins Match against the legacy loop over a full pattern x path matrix; race-clean. funcs 282->288, coverage 35.02
33 99fca9e 62 -1.0 keep pass BUGFIX+DEDUP: task.loadActiveStorageConfig and saveActiveStorageConfig duplicated uploadstorage.LoadStorageConfig/SaveActiveConfig but swallowed all three failures (nil db, read error, json parse) returning zero config + nil error, making the caller-s already-written error branch dead: a storage migration could run from an unknown active driver. Now routed through the canonical accessors; regression test corrupts the stored config and asserts Execute errors (assertion-proven via revert). funcs 289
34 b22f863 62 0.0 keep pass BUGFIX+PERF: LoadSMTPConfigRecord fired four single-key queries and discarded every error with underscore assignment, so an unreadable w_system_configs returned four blank strings both callers could only read as "SMTP not configured" -> notification silently dropped. Now one IN query plus a real error channel; callers log at the boundary and keep their own values. Proof is signature-level and exact: the old API had no error return, so the failure was unrepresentable. 4 queries -> 1, funcs 291
35 d7c851b 62 0.0 keep pass BUGFIX+PERF: access_cache discarded the whitelist read error with underscore assignment, then unconditionally set valid=true and CheckedAt=now, so one transient DB failure pinned the RESTRICTED default public-access list for the whole TTL and silently narrowed an admin-configured whitelist. Now the error is logged, last-good is served when known, and a cold failure stays invalid so the next request retries. Assertion-proven by dropping and restoring the table mid-test. funcs 292
36 - 62 0.0 keep skip PROPOSALS (.auto/proposals.md): five verified items deliberately not auto-fixed per the agreed scope. Headline P1: cross-driver storage migration passes the SAME service as source and target (getBackend only ever resolves the active backend) and saves the target config afterwards, so it reports "migrated N objects" having copied zero bytes and then points the platform at an empty backend. Needs a contracts.StorageService capability decision, not a patch.
Can't render this file because it contains an unexpected character in line 7 and column 63.
+15 -9
View File
@@ -1,21 +1,27 @@
.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/
@@ -25,6 +31,6 @@ frontend/.env
frontend/next-env.d.ts
frontend/*.tsbuildinfo
frontend/package-lock.json
internal/router/dist/
internal/router/root/dist/
backend/plugins/drivers/driver_http/dist/
+22 -18
View File
@@ -1,5 +1,5 @@
# ──────────────────────────────────────────────────────────────────────────────
# wavelet — 环境变量配置模板
# openflare — 环境变量配置模板
# 复制此文件为 .env 并填入实际值: cp .env.example .env
# 环境变量优先级高于 config.yaml
# docker compose 会读取本文件(env_file: .env)并替换 compose 中的 ${VAR}
@@ -9,13 +9,13 @@
TZ=Asia/Shanghai
# ─── 应用配置 ──────────────────────────────────────────────────────────────────
APP_NAME=wavelet
APP_NAME=openflare
APP_ENV=production
APP_ADDR=:8000
APP_ADDR=:3000
APP_NODE_ID=1
APP_API_PREFIX=/api
# APP_GRACEFUL_SHUTDOWN_TIMEOUT=30
APP_SESSION_COOKIE_NAME=wavelet_session_id
APP_SESSION_COOKIE_NAME=openflare_session_id
APP_SESSION_SECRET=change-me-to-a-random-string-in-production
# APP_SESSION_DOMAIN=
APP_SESSION_AGE=86400
@@ -27,12 +27,13 @@ APP_SESSION_SECURE=true
# 设置 DB_HOST 后自动启用 PostgreSQL,也可通过 DB_ENABLED 显式控制
# DB_ENABLED=false 时使用 SQLite 作为后备数据库
DB_ENABLED=true
# SQLITE_PATH=./data/wavelet.db
# SQLITE_PATH=./data/openflare.db
# compose 内应用连服务名;本机直连 Docker 映射端口时用 127.0.0.1
DB_HOST=postgres
DB_PORT=5432
DB_USERNAME=postgres
DB_PASSWORD=postgres
DB_NAME=wavelet
DB_USERNAME=openflare
DB_PASSWORD=replace-with-strong-password
DB_NAME=openflare
DB_SSL_MODE=disable
DB_TIMEZONE=Asia/Shanghai
# DB_LOG_LEVEL=info
@@ -46,20 +47,23 @@ REDIS_ADDR=redis:6379
# REDIS_USERNAME=
# REDIS_PASSWORD=
# REDIS_DB=0
REDIS_KEY_PREFIX=wavelet:
REDIS_KEY_PREFIX=openflare:
# REDIS_POOL_SIZE=100
# 启动时开关;修改后需重启服务
REDIS_MAINT_NOTIFICATIONS=false
# compose 宿主机映射端口(仅 docker-compose 使用)
# REDIS_PORT=6379
# ─── ClickHouse(可选,默认关闭)──────────────────────────────────────────
# 设置 CLICKHOUSE_HOST 后自动启用,也可显式控制
# CLICKHOUSE_ENABLED=false
# CLICKHOUSE_HOST=clickhouse:9000
# CLICKHOUSE_USERNAME=default
# CLICKHOUSE_PASSWORD=
# CLICKHOUSE_NAME=wavelet
# ─── ClickHouse(必需)────────────────────────────────────────────────────────
# CLICKHOUSE_HOST 设置后会自动启用;测试环境可显式 CLICKHOUSE_ENABLED=true 做 live 联调
CLICKHOUSE_ENABLED=false
# 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
# ─── 日志 ──────────────────────────────────────────────────────────────────────
LOG_LEVEL=info
@@ -72,8 +76,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/Wavelet
# OTEL_TRACER_NAME=github.com/Rain-kl/Wavelet
# 全局 Tracer 命名空间,默认为 github.com/Rain-kl/OpenFlare
# OTEL_TRACER_NAME=github.com/Rain-kl/OpenFlare
# compose 可选端口覆盖
# JAEGER_VERSION=2.19.0
# JAEGER_UI_PORT=16686
+8
View File
@@ -0,0 +1,8 @@
* -text
backend/openflare/** merge=ours
frontend/** merge=ours
docs/changelog/** merge=ours
docs/superpowers/** merge=ours
.github/workflows/build-image.yml merge=ours
docker-compose.yml merge=ours
.gitconfig merge=ours
+10
View File
@@ -0,0 +1,10 @@
# Repo-local Git settings. Git does not load this file automatically.
# From the clone (or worktree) root:
# git config include.path ../.gitconfig
# Worktree-safe:
# git config include.path "$(git rev-parse --show-toplevel)/.gitconfig"
# Relative include.path is resolved against .git/config, so ../.gitconfig
# is the repo root when .git is a directory (non-worktree clone).
[merge "ours"]
driver = true
+1 -1
View File
@@ -12,7 +12,7 @@
- 新增功能时考虑向后兼容性和 API 稳定性
- 遵循项目的 Apache2.0 许可证要求
- 遵循语义化版本控制规范
- 新增异步任务时使用项目技能 `.agents/new-async-task/SKILL.md`
- 新增异步任务时使用项目技能 `.agent/new-async-task/SKILL.md`
## 后端规范
@@ -0,0 +1,197 @@
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: manifest/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 }}"
@@ -0,0 +1,197 @@
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: manifest/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 }}"
+270
View File
@@ -0,0 +1,270 @@
name: Build Image (openflare)
on:
workflow_dispatch:
inputs:
version:
description: "Image version/tag to publish (e.g. v1.0.0-beta). Leave empty to publish as canary."
required: false
type: string
push:
tags: ["v*"]
branches: ["canary"]
# One active run per ref (e.g. canary); newer runs cancel older in-progress builds.
concurrency:
group: build-image-openflare-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
packages: write
attestations: write
id-token: write
env:
IMAGE_NAME: openflare
DOCKERFILE: manifest/docker/Dockerfile
jobs:
# Resolve version / registries once. No checkout: triggers alone determine the tag.
prepare:
name: Prepare metadata
runs-on: ubuntu-latest
outputs:
version: ${{ steps.prep.outputs.version }}
build_date: ${{ steps.prep.outputs.build_date }}
image: ${{ steps.prep.outputs.image }}
image_names: ${{ steps.prep.outputs.image_names }}
images: ${{ steps.prep.outputs.images }}
push_dockerhub: ${{ steps.prep.outputs.push_dockerhub }}
is_stable: ${{ steps.prep.outputs.is_stable }}
is_prerelease: ${{ steps.prep.outputs.is_prerelease }}
steps:
- name: Resolve version and images
id: prep
env:
INPUT_VERSION: ${{ github.event.inputs.version }}
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
DOCKERHUB_NAMESPACE: ${{ secrets.DOCKERHUB_NAMESPACE }}
run: |
set -euo pipefail
INPUT_VERSION="${INPUT_VERSION//[[:space:]]/}"
OWNER="${GITHUB_REPOSITORY_OWNER,,}"
BUILD_DATE="$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
if [[ "${GITHUB_REF}" == refs/heads/canary ]]; then
VERSION="canary"
elif [[ "${GITHUB_REF}" == refs/tags/* ]]; then
VERSION="${GITHUB_REF_NAME}"
elif [[ -n "$INPUT_VERSION" ]]; then
VERSION="$INPUT_VERSION"
elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
VERSION="canary"
else
echo "unable to determine image version/tag" >&2
exit 1
fi
if [[ "$VERSION" == "canary" ]]; then
IS_STABLE="false"
IS_PRERELEASE="false"
elif [[ "$VERSION" =~ (alpha|beta|rc) ]]; then
IS_STABLE="false"
IS_PRERELEASE="true"
else
IS_STABLE="true"
IS_PRERELEASE="false"
fi
IMAGE="ghcr.io/${OWNER}/${IMAGE_NAME}"
IMAGE_NAMES="${IMAGE}"
# Newline-separated list for docker/metadata-action
IMAGES="${IMAGE}"
DOCKERHUB_USERNAME="${DOCKERHUB_USERNAME//[[:space:]]/}"
DOCKERHUB_TOKEN="${DOCKERHUB_TOKEN//[[:space:]]/}"
DOCKERHUB_NAMESPACE="${DOCKERHUB_NAMESPACE//[[:space:]]/}"
PUSH_DOCKERHUB="false"
if [[ -n "$DOCKERHUB_USERNAME" && -n "$DOCKERHUB_TOKEN" ]]; then
HUB_NS="${DOCKERHUB_NAMESPACE:-$DOCKERHUB_USERNAME}"
HUB_NS="${HUB_NS,,}"
IMAGE_DOCKERHUB="${HUB_NS}/${IMAGE_NAME}"
IMAGE_NAMES="${IMAGE_NAMES},${IMAGE_DOCKERHUB}"
IMAGES="${IMAGES}"$'\n'"${IMAGE_DOCKERHUB}"
PUSH_DOCKERHUB="true"
echo "Docker Hub publish enabled: ${IMAGE_DOCKERHUB}"
else
echo "Docker Hub secrets not set; publishing to GHCR only."
fi
{
echo "version=${VERSION}"
echo "build_date=${BUILD_DATE}"
echo "image=${IMAGE}"
echo "image_names=${IMAGE_NAMES}"
echo "push_dockerhub=${PUSH_DOCKERHUB}"
echo "is_stable=${IS_STABLE}"
echo "is_prerelease=${IS_PRERELEASE}"
echo "images<<EOF"
printf '%s\n' "${IMAGES}"
echo "EOF"
} >> "$GITHUB_OUTPUT"
echo "Resolved version=${VERSION} build_date=${BUILD_DATE} stable=${IS_STABLE} prerelease=${IS_PRERELEASE}"
build:
name: Build (${{ matrix.arch }})
needs: prepare
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-latest
- arch: arm64
platform: linux/arm64
# No ubuntu-latest-arm alias from GitHub; 24.04-arm is the current stable arm64 image.
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 1
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log into GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log into Docker Hub
if: needs.prepare.outputs.push_dockerhub == 'true'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_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=${{ needs.prepare.outputs.image_names }}",push-by-digest=true,name-canonical=true,push=true
build-args: |
VERSION=${{ needs.prepare.outputs.version }}
BUILD_DATE=${{ needs.prepare.outputs.build_date }}
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: ${{ needs.prepare.outputs.image }}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
merge:
name: Merge multi-arch manifest
runs-on: ubuntu-latest
needs: [prepare, build]
steps:
# No repo checkout: tags come from prepare + metadata-action.
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ needs.prepare.outputs.images }}
flavor: |
latest=false
tags: |
type=raw,value=${{ needs.prepare.outputs.version }}
type=raw,value=latest,enable=${{ needs.prepare.outputs.is_stable == 'true' }}
type=raw,value=beta,enable=${{ needs.prepare.outputs.is_prerelease == 'true' }}
- 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 GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log into Docker Hub
if: needs.prepare.outputs.push_dockerhub == 'true'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create and push manifest list
working-directory: /tmp/${{ env.IMAGE_NAME }}-digests
shell: bash
env:
IMAGE: ${{ needs.prepare.outputs.image }}
DOCKER_METADATA_OUTPUT_JSON: ${{ steps.meta.outputs.json }}
run: |
set -euo pipefail
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
# shellcheck disable=SC2046
docker buildx imagetools create \
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
"${references[@]}"
- name: Inspect image
run: docker buildx imagetools inspect "${{ needs.prepare.outputs.image }}:${{ needs.prepare.outputs.version }}"
- name: Trigger webhook
env:
WEBHOOK_URL: ${{ secrets.WEBHOOK_URL }}
run: |
if [ -n "$WEBHOOK_URL" ]; then
curl -fsSL "$WEBHOOK_URL"
else
echo "Webhook URL is not set, skipping."
fi
@@ -0,0 +1,197 @@
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: manifest/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 }}"
+13 -261
View File
@@ -1,270 +1,22 @@
name: Build Image
name: Build Image (Wavelet upstream — isolated)
# Isolated: OpenFlare publishes images via build-image-openflare.yml
# (IMAGE_NAME: openflare). This Wavelet workflow is kept under the same
# path so `git merge wavelet/main` cannot restore a canary wavelet image.
on:
workflow_dispatch:
inputs:
version:
description: "Image version/tag to publish (e.g. v1.0.0-beta). Leave empty to publish as canary."
required: false
type: string
push:
tags: ["v*"]
branches: ["canary"]
# One active run per ref (e.g. canary); newer runs cancel older in-progress builds.
concurrency:
group: build-image-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
packages: write
attestations: write
id-token: write
env:
IMAGE_NAME: wavelet
DOCKERFILE: docker/Dockerfile
confirm:
description: "Disabled on OpenFlare. Use build-image-openflare.yml."
required: true
jobs:
# Resolve version / registries once. No checkout: triggers alone determine the tag.
prepare:
name: Prepare metadata
isolated:
name: Isolated
runs-on: ubuntu-latest
outputs:
version: ${{ steps.prep.outputs.version }}
build_date: ${{ steps.prep.outputs.build_date }}
image: ${{ steps.prep.outputs.image }}
image_names: ${{ steps.prep.outputs.image_names }}
images: ${{ steps.prep.outputs.images }}
push_dockerhub: ${{ steps.prep.outputs.push_dockerhub }}
is_stable: ${{ steps.prep.outputs.is_stable }}
is_prerelease: ${{ steps.prep.outputs.is_prerelease }}
steps:
- name: Resolve version and images
id: prep
env:
INPUT_VERSION: ${{ github.event.inputs.version }}
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
DOCKERHUB_NAMESPACE: ${{ secrets.DOCKERHUB_NAMESPACE }}
- name: Refuse Wavelet image publish
run: |
set -euo pipefail
INPUT_VERSION="${INPUT_VERSION//[[:space:]]/}"
OWNER="${GITHUB_REPOSITORY_OWNER,,}"
BUILD_DATE="$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
if [[ "${GITHUB_REF}" == refs/heads/canary ]]; then
VERSION="canary"
elif [[ "${GITHUB_REF}" == refs/tags/* ]]; then
VERSION="${GITHUB_REF_NAME}"
elif [[ -n "$INPUT_VERSION" ]]; then
VERSION="$INPUT_VERSION"
elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
VERSION="canary"
else
echo "unable to determine image version/tag" >&2
exit 1
fi
if [[ "$VERSION" == "canary" ]]; then
IS_STABLE="false"
IS_PRERELEASE="false"
elif [[ "$VERSION" =~ (alpha|beta|rc) ]]; then
IS_STABLE="false"
IS_PRERELEASE="true"
else
IS_STABLE="true"
IS_PRERELEASE="false"
fi
IMAGE="ghcr.io/${OWNER}/${IMAGE_NAME}"
IMAGE_NAMES="${IMAGE}"
# Newline-separated list for docker/metadata-action
IMAGES="${IMAGE}"
DOCKERHUB_USERNAME="${DOCKERHUB_USERNAME//[[:space:]]/}"
DOCKERHUB_TOKEN="${DOCKERHUB_TOKEN//[[:space:]]/}"
DOCKERHUB_NAMESPACE="${DOCKERHUB_NAMESPACE//[[:space:]]/}"
PUSH_DOCKERHUB="false"
if [[ -n "$DOCKERHUB_USERNAME" && -n "$DOCKERHUB_TOKEN" ]]; then
HUB_NS="${DOCKERHUB_NAMESPACE:-$DOCKERHUB_USERNAME}"
HUB_NS="${HUB_NS,,}"
IMAGE_DOCKERHUB="${HUB_NS}/${IMAGE_NAME}"
IMAGE_NAMES="${IMAGE_NAMES},${IMAGE_DOCKERHUB}"
IMAGES="${IMAGES}"$'\n'"${IMAGE_DOCKERHUB}"
PUSH_DOCKERHUB="true"
echo "Docker Hub publish enabled: ${IMAGE_DOCKERHUB}"
else
echo "Docker Hub secrets not set; publishing to GHCR only."
fi
{
echo "version=${VERSION}"
echo "build_date=${BUILD_DATE}"
echo "image=${IMAGE}"
echo "image_names=${IMAGE_NAMES}"
echo "push_dockerhub=${PUSH_DOCKERHUB}"
echo "is_stable=${IS_STABLE}"
echo "is_prerelease=${IS_PRERELEASE}"
echo "images<<EOF"
printf '%s\n' "${IMAGES}"
echo "EOF"
} >> "$GITHUB_OUTPUT"
echo "Resolved version=${VERSION} build_date=${BUILD_DATE} stable=${IS_STABLE} prerelease=${IS_PRERELEASE}"
build:
name: Build (${{ matrix.arch }})
needs: prepare
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-latest
- arch: arm64
platform: linux/arm64
# No ubuntu-latest-arm alias from GitHub; 24.04-arm is the current stable arm64 image.
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 1
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log into GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log into Docker Hub
if: needs.prepare.outputs.push_dockerhub == 'true'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_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=${{ needs.prepare.outputs.image_names }}",push-by-digest=true,name-canonical=true,push=true
build-args: |
VERSION=${{ needs.prepare.outputs.version }}
BUILD_DATE=${{ needs.prepare.outputs.build_date }}
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: ${{ needs.prepare.outputs.image }}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
merge:
name: Merge multi-arch manifest
runs-on: ubuntu-latest
needs: [prepare, build]
steps:
# No repo checkout: tags come from prepare + metadata-action.
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ needs.prepare.outputs.images }}
flavor: |
latest=false
tags: |
type=raw,value=${{ needs.prepare.outputs.version }}
type=raw,value=latest,enable=${{ needs.prepare.outputs.is_stable == 'true' }}
type=raw,value=beta,enable=${{ needs.prepare.outputs.is_prerelease == 'true' }}
- 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 GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log into Docker Hub
if: needs.prepare.outputs.push_dockerhub == 'true'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create and push manifest list
working-directory: /tmp/${{ env.IMAGE_NAME }}-digests
shell: bash
env:
IMAGE: ${{ needs.prepare.outputs.image }}
DOCKER_METADATA_OUTPUT_JSON: ${{ steps.meta.outputs.json }}
run: |
set -euo pipefail
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
# shellcheck disable=SC2046
docker buildx imagetools create \
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
"${references[@]}"
- name: Inspect image
run: docker buildx imagetools inspect "${{ needs.prepare.outputs.image }}:${{ needs.prepare.outputs.version }}"
- name: Trigger webhook
env:
WEBHOOK_URL: ${{ secrets.WEBHOOK_URL }}
run: |
if [ -n "$WEBHOOK_URL" ]; then
curl -fsSL "$WEBHOOK_URL"
else
echo "Webhook URL is not set, skipping."
fi
echo "This Wavelet image workflow is isolated on OpenFlare."
echo "Use .github/workflows/build-image-openflare.yml"
exit 1
+157 -7
View File
@@ -11,7 +11,8 @@ on:
type: string
env:
APP_NAME: wavelet
APP_NAME: openflare-server
GO_DIR: backend
GO_MAIN: ./main.go
GO_BUILD_TAGS: embed_frontend
GO_LDFLAGS: -s -w
@@ -20,12 +21,12 @@ env:
FRONTEND_DIR: frontend
FRONTEND_BUILD_COMMAND: pnpm build:embed
FRONTEND_OUT_DIR: frontend/out
EMBED_DIST_DIR: internal/router/root/dist
EMBED_DIST_DIR: backend/plugins/drivers/driver_http/dist
EXTRA_FILES: |
LICENSE
README.md
README_zh.md
config.example.yaml
manifest/config/config.default.yaml
DEPLOYMENT_zh.md
permissions:
@@ -145,6 +146,7 @@ jobs:
rm -rf "$EMBED_DIST_DIR"
mkdir -p "$(dirname "$EMBED_DIST_DIR")"
cp -R "$FRONTEND_OUT_DIR" "$EMBED_DIST_DIR"
test -f "$EMBED_DIST_DIR/index.html"
- name: Upload embedded frontend
uses: actions/upload-artifact@v4
@@ -192,7 +194,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
go-version-file: ${{ env.GO_DIR }}/go.mod
cache: true
- name: Build binary
@@ -212,18 +214,21 @@ jobs:
binary_name="${binary_name}.exe"
fi
ldflags="$GO_LDFLAGS -X github.com/Rain-kl/Wavelet/internal/buildinfo.Version=$VERSION -X github.com/Rain-kl/Wavelet/internal/buildinfo.BuildTime=$BUILD_DATE"
# Assert the embedded UI is present so a release cannot ship an API-only binary.
test -f "$EMBED_DIST_DIR/index.html"
ldflags="$GO_LDFLAGS -X Wavelet/pkg/buildinfo.Version=$VERSION -X Wavelet/pkg/buildinfo.BuildTime=$BUILD_DATE"
build_args=(
-trimpath
-ldflags "$ldflags"
-o "dist/$binary_name"
-o "$GITHUB_WORKSPACE/dist/$binary_name"
)
if [[ -n "$GO_BUILD_TAGS" ]]; then
build_args=(-tags "$GO_BUILD_TAGS" "${build_args[@]}")
fi
go build "${build_args[@]}" "$GO_MAIN"
(cd "$GO_DIR" && go build "${build_args[@]}" "$GO_MAIN")
- name: Package artifact
id: package
@@ -268,3 +273,148 @@ 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: ${{ env.GO_DIR }}/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: |
cd backend
go mod download
mkdir -p "$GITHUB_WORKSPACE/dist"
go build -trimpath -ldflags "-s -w -X 'Wavelet/OpenFlare/plugins/agent/config.Version=$VERSION'" -o "$GITHUB_WORKSPACE/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: ${{ env.GO_DIR }}/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: |
cd backend
go mod download
mkdir -p "$GITHUB_WORKSPACE/dist"
go build -trimpath -ldflags "-s -w -X 'Wavelet/OpenFlare/plugins/relay/config.Version=$VERSION'" -o "$GITHUB_WORKSPACE/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: ${{ env.GO_DIR }}/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: |
cd backend
go mod download
mkdir -p "$GITHUB_WORKSPACE/dist"
go build -trimpath -ldflags "-s -w -X 'Wavelet/OpenFlare/plugins/flared/config.Version=$VERSION'" -o "$GITHUB_WORKSPACE/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
@@ -0,0 +1,24 @@
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
@@ -0,0 +1,48 @@
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
@@ -0,0 +1,32 @@
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.');
}
+48 -10
View File
@@ -11,7 +11,10 @@
# config
config.yaml
manifest/config/config.yaml
.env
.env.*
!.env.example
# sqlite
*.db
@@ -27,8 +30,6 @@ frontend/.next/*
frontend/next-env.d.ts
frontend/package-lock.json
frontend/.env
.env.*
!.env.example
*.tsbuildinfo
# os
@@ -46,19 +47,56 @@ go.work.sum
main
# upload
uploads/*
/uploads/
s3_cache
/frontend/.next/
/data/
/internal/router/dist/
/frontend/out/
/.idea/
/uploads/
/*-source/
/*-source.zip
/.cache/
/internal/router/root/dist/
.dmux/
.worktrees/
# 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/
/.pi-subagents/
# i18n 生成物(由 scripts/merge-i18n-fragments.mjs 从 fragments 生成)
frontend/messages/zh-CN.json
frontend/messages/en.json
# 上游 vendoring 目录内禁止出现运行期产物
backend/plugins/**/uploads/
backend/plugins/**/dist/
backend/core/**/dist/
backend/pkg/**/uploads/
/backend/openflare/plugins/server/upload/filesrv/uploads/
/backend/plugins/domain/upload/filesrv/uploads/
/backend/plugins/domain/upload/task/uploads/
/backend/data/
/backend/plugins/drivers/driver_http/dist/
/backend/uploads/
+202 -81
View File
@@ -64,100 +64,221 @@ 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.
## Git 提交规范
遵循 Conventional Commits:`<type>(<scope>): <subject>`(例:`feat(auth): support email login`)。
## 务必阅读匹配的 Skill
## Skills(匹配任务时必读)
| Skill | 何时使用 |
| :--- | :--- |
| `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 组件 |
| `new-api` | 业务 API、Handler、服务层、路由注册 |
| `new-async-task` | Asynq 任务、定时任务、TaskHandler、任务元数据 |
| `new-setting` | 系统/业务/公开设置、`/admin/system`、`/admin/settings` |
| `database-migration` | 表结构、goose 迁移(PG/SQLite/ClickHouse)、seed |
| `logstore` | 日志/分析用途表、`backend/internal/repository/logstore`、切换日志主库、PG/SQLite 回落 |
| `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 组件 |
## 严格遵循事项 (Guardrails)
## 硬性约束
- 切勿删除 `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`。
### 上游/下游改动归属(Cordis)
- 触碰框架目录 `backend/{core,pkg,plugins}` 前,先判断能力归属:
- **通用能力**(与 OpenFlare 业务无关、任何下游都用得上)→ 必须同步在 **Wavelet 上游**完成修改,
本仓库通过 `git fetch wavelet && git merge wavelet/main` 取得,不得长期持有本地补丁。
- **非通用能力**(OpenFlare 业务特有)→ 在自己的插件内(`backend/openflare/plugins/<name>/`)实现,
或新建一个下游插件,禁止塞进上游目录。
- 开发下游功能优先**复用上游已有能力**(`core/contracts`、`backend/plugins/*`、`backend/pkg/*`);
发现上游已提供而下游仍保留本地副本的,删除本地副本改为复用,或把差量回流上游。
- 上游暂缺而确属通用能力时,可先在本仓库实现并登记到 `backend/openflare/upstream-patches.md`
(merge 上游后请确认补丁仍在),回流 Wavelet 后删除登记并重新 merge。
### Cordis 架构核心防线与分层规范
- **微内核 (`backend/core/`)**:
- 上下文总线(`Context`)、泛型依赖注入(`Container`)、生命周期编排(`Lifecycle`)、扩展点定义(`extpoints/`)与领域事件总线(`EventBus`)。
- **严禁**包含任何具体业务逻辑,**严禁** import `gin`、`gorm`、`asynq` 等具体运行时依赖。
- **服务契约 (`backend/core/contracts/`)**:
- 跨插件通信的统一公开 Go Interface(如 `AuthService`、`UserService`、`CacheService`、`DBService`、`StorageService`)与公共 DTO。
- **严禁**包含任何具体业务实现或 SQL 操作。
- **自包含插件 (`backend/plugins/`)**:
- 所有业务功能与驱动实现均以插件形式存在(`backend/plugins/drivers/`、`backend/plugins/infra/`、`backend/plugins/domain/` 或下游 `backend/openflare/plugins/`)。
- 每个插件实现 `core.Plugin`(`Name() string` 与 `Apply(ctx *core.Context) error`)。
- **统一插件分层架构与标准模板**:
- **开发模板唯一基准**:所有插件统一以 `backend/downstream/plugins/custom_example` 为基准模板构建。
- **物理子包隔离规范**:统一采用物理子包结构(`plugin.go`, `consts/`, `controller/`, `service/`, `dao/`, `model/` [含 `entity/`, `do/`], `migrations/` [含 `postgres/`, `sqlite/`])。**严禁在根包平铺 `handlers_*`、`service_*`、`dao_*` 等前缀文件**,子包内文件直接按业务实体命名(如 `hello.go`, `user.go`),严格约束 `controller -> service -> dao -> model` 单向依赖。
- **插件通信与依赖隔离**:
- **严禁跨包 import internal/私有实现**:插件之间严禁直接 import 对方具体实现包代码。
- **单向服务契约调用**:调用方仅面向 `backend/core/contracts` 编程,在 `Apply` 中通过 `core.Provide[contracts.XxxService](ctx, svc)` 注册服务,通过 `core.Inject[contracts.XxxService](ctx)` 或 `ctx.Using(func(svc contracts.XxxService) { ... })` 声明式解析。
- **事件总线广播**:状态联动与解耦通信统一通过强类型事件 `ctx.Events().Emit()` 广播,由感兴趣的插件通过 `ctx.Events().On()` 订阅,消除双向依赖与循环引用。
- **扩展点自包含注册**:
- **HTTP 路由与白名单机制**:
- 插件自包含在 `Apply` 中通过 `ctx.Router().Group(...)` 挂载路由与中间件,禁止跨插件散落注册。
- **白名单机制**:`driver_http` 与微内核扩展点提供路由白名单支持(`ctx.Router().RegisterWhitelist(patterns...)`),支持精确路径与通配符(如 `/api/v1/oauth/*`)。
- **所有权主动声明**:认证域(`auth` 插件)与各业务插件必须在 `Apply` 中主动注册其公开/免鉴权接口(如 `/api/v1/user/login`、`/api/v1/oauth/callback`、`/api/v1/cap/*` 等)。
- **鉴权中间件放行防线**:`auth` 提供的登录鉴权中间件(`LoginRequired`)必须先执行白名单匹配并自动放行,彻底杜绝免鉴权接口被全局或组级鉴权中间件误拦截(返回 401 Unauthorized)。
- **异步与定时任务**:插件自包含在 `Apply` 中通过 `ctx.Task().Register(...)` 与 `ctx.Schedule().RegisterCron(...)` 声明。
- **静态启动配置**:插件自包含在 `Apply` 中通过 `ctx.Config().Bind("<prefix>", &cfg)` 读取**自己声明**的配置,字段以 tag 表达来源:`config`(yaml 路径)、`env`(覆盖变量名)、`default`、`autoEnable`(该变量存在即置真)、`secret`(导出脱敏)。需要在 `Apply` 之前被门禁求值的键,必须在 `DeclareConfig()` 中提前声明并实现 `core.ConfigGatedPlugin`。新增基础设施 key 保持顶层命名(`redis.*`),插件私有配置归 `plugins.<name>.*`。**严禁**再造全局配置单例或在 `backend/pkg/` 读取配置。
- **动态设置**:插件自包含在 `Apply` 中通过 `ctx.Settings().Register(core.SettingSchema{...})` 声明可热更新的管理台设置模式(与上面的静态启动配置分属两层)。
- **数据迁移**:插件自包含在内部维护 `migrations/*.sql`,通过 `//go:embed` 打包并在 `Apply` 中通过 `ctx.Migrations().Register(pluginID, embedFS)` 注入。
- **表单一所有者原则 (Single Owner Principle)**:
- 每张数据表有且仅由一个所有者插件声明与维护(表名使用插件前缀如 `w_order_*`)。
- 严禁插件 B 跨过所有者插件 A 直接 DDL/DML 旁路读写表 A,必须调用插件 A 暴露的 `contracts` 接口或订阅事件。
- **平台服务复用**:
- 文件摄取统一使用 `upload.Ingest` / `contracts.StorageService`,禁止绕过存储域直接操作底层 Bucket 或直写文件表。
- 业务缓存统一使用 `ctx.Cache()`(`contracts.CacheService`)或标准缓存框架,禁止自研不带失效广播的本地 map。
- 数据库操作通过 `ctx.DB()`(`contracts.DBService`)获取受事务与 Trace 保护的连接。
- 禁止删除 `frontend/node_modules`。
- `backend/pkg/util/` 保持纯净:禁止导入 Gin、GORM、sessions 等 HTTP/Web/DB 框架(会话选项在 `backend/openflare/plugins/server/oauth/session.go`)。
- 测试临时目录只用 `t.TempDir()`,禁止硬编码相对路径写源码树。
- HTTP 路由只由插件在 `Apply` 中经 `ctx.Router()` 声明;`router.BuildEngine()` 只挂引擎级中间件与前端 SPA 兜底,禁止进程级初始化(如 `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{})`。
- **分层**:`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。
- 日志/分析表(访问日志、审计流水、可观测时序)走 `internal/repository/logstore`,禁止 apps 直连 `repository/analytics` 或 `db.ChConn`/`db.ChDB`。判定与接入步骤见 `logstore` skill。
- 日志/分析表(节点访问日志、用户访问日志、可观测时序)走 `backend/openflare/plugins/server/kernel/repository/logstore`,禁止 apps 直连 `repository/analytics` 或 `db.ChConn`/`db.ChDB`。判定与接入步骤见 `logstore` skill。
- 跨模块集成(任务 Handler、推送事件、域监听、完成钩子)禁止 `init()` 注册;经 `backend/openflare/plugins/server/platform/bootstrap` 在 `backend/cmd` 入口显式装配。
- 核心业务(如 `oauth`、`user`)禁止直接 import push/custom_events;经 `backend/openflare/plugins/server/listener` 发域事件,push 在 bootstrap 订阅。
- 依赖任务/推送注册的测试须显式 `bootstrap.RegisterTasks()` / `RegisterPushDomainEvents()` 等,不依赖 `init()`。
- API 错误必须 `response.Abort*` + `ErrorHandlerMiddleware`;禁止 Handler 直接 `c.JSON(..., response.Err(...))` 或用 HTTP 200 表示失败。
## 技术栈与项目目录结构
### 文档与 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。
- 内容变更同步**中文文档**(不同步英文)。
- 代码/配置变更写入 [`docs/changelog/index.md`](./docs/changelog/index.md) 的 `[Unreleased]`;纯文档变更不写 changelog。
- Changelog:合并相近项;不记格式化/调试/无关重构;用户可读完整中文句;说明效果;不编造;不写密钥等敏感信息;空分类可省略。
## 后端开发规范
## 技术栈
### 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` 打印日志,禁止使用 `_ = ...` 静默吞掉关键错误。
- **后端**: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
### 数据库操作
- 平台域(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 方言并杜绝通配符注入攻击。
## Git
### 并发与安全防护规范
- **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 的滑动窗口限流机制,防止暴力破解与缓存资源耗尽。
Conventional Commits:`<type>(<scope>): <subject>`(例:`feat(auth): support email login`)。
## 前端开发规范
---
- 新特性开发前参考 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`。
## 后端
### 命名
| 类别 | 规则 | 例 |
|------|------|-----|
| 包/文件 | 小写蛇形 | `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`。参考 `backend/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 边界用 `backend/pkg/logger`(带 `ctx`)记录,再返回安全 Abort/业务错误。
- 吞错、转通用响应、worker 忽略前必须先记日志。
- 禁止 `_ = err` 静默丢弃重要错误;best-effort 可忽略时加简短注释。
- 只在处理/抑制边界记一次,避免重复刷日志。
### 路由与装配
- `router.go` 只做高层分发,禁止直接挂业务 Handler。归属与开发步骤见 `new-api` skill。
- 跨模块副作用:在 `bootstrap` 增 `Register*`,于对应 `backend/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` 与 `backend/internal/infra/config/model.go`。
### 数据库
- 持久化只经 `repository`(或 analytics);复杂查询不进 Handler;编排在 logics。
- repository 内用 `db.DB(ctx)`(链路追踪)。
- 迁移:`backend/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)`。
### 国际化 (i18n)
- 使用 `next-intl`(无 URL locale 前缀 / provider 模式),兼容 `NEXT_STANDALONE_EXPORT`。
- 语言:`zh-CN`、`en`;默认 `zh-CN`。优先级:cookie `NEXT_LOCALE` → 浏览器语言 → 默认。
- 文案放在 `frontend/messages/fragments`。参考已有代码,按模块拆文件夹,en.json 和 zh-CN.json 是 ci 生成的(node scripts/merge-i18n-fragments.mjs),禁止手动修改。
- 禁止在页面/组件里直接写文案,文案必须支持 i18
+10
View File
@@ -20,6 +20,16 @@
为提高协作效率,我们建议您在提交 PR 前,先通过 Issue 简要说明动机与背景。
## 合并上游
`.gitattributes` 对 `backend/openflare/`、`frontend/` 等路径使用 `merge=ours`。该驱动不会自动生效,请在仓库根目录执行一次:
```bash
git config include.path ../.gitconfig
# worktree 安全写法:
git config include.path "$(git rev-parse --show-toplevel)/.gitconfig"
```
## 贡献步骤
1. **Fork 本仓库** 并创建您的分支(建议使用有意义的分支名)。
+49 -21
View File
@@ -1,8 +1,8 @@
.PHONY: swagger license license-check build-embedded build-test cross-build code-check format canary
.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
VERSION ?= dev
BUILD_DATE ?= $(shell date -u +'%Y-%m-%dT%H:%M:%SZ')
MODULE := $(shell go list -m)
MODULE := $(shell cd backend && go list -m)
swagger:
scripts/swagger.sh
@@ -14,9 +14,13 @@ license-check:
scripts/update_go_license.sh --check
format:
@echo "==> Formatting backend Go source..."
gofmt -w $$(find . -type f -name '*.go' -not -path './.git/*' -not -path './frontend/*')
@echo "==> Formatting frontend source..."
@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 backend -type f -name '*.go')
@echo "==> Formatting frontend source and removing unused imports..."
cd frontend && pnpm format
build-embedded:
@@ -25,31 +29,55 @@ build-embedded:
NEXT_PUBLIC_APP_VERSION="$(VERSION)" \
NEXT_PUBLIC_APP_BUILD_DATE="$(BUILD_DATE)" \
pnpm build:embed
rm -rf internal/router/root/dist
cp -R frontend/out internal/router/root/dist
go build \
rm -rf backend/plugins/drivers/driver_http/dist
cp -R frontend/out backend/plugins/drivers/driver_http/dist
test -f backend/plugins/drivers/driver_http/dist/index.html
cd backend && go build \
-tags embed_frontend \
-ldflags "-s -w -X '$(MODULE)/internal/buildinfo.Version=$(VERSION)' -X '$(MODULE)/internal/buildinfo.BuildTime=$(BUILD_DATE)'" \
-o bin/wavelet \
-ldflags "-s -w -X '$(MODULE)/pkg/buildinfo.Version=$(VERSION)' -X '$(MODULE)/pkg/buildinfo.BuildTime=$(BUILD_DATE)'" \
-o ../bin/openflare-server \
main.go
code-check:
@echo "==> Architecture guards..."
@command -v rg >/dev/null 2>&1 || { echo 'error: rg (ripgrep) is required for architecture guards' >&2; exit 1; }
@if rg -n 'db\.DB\(|db\.Redis' internal/model --glob '*.go' -g '!*_test.go' ; then \
scripts/check_cordis_architecture.sh
@if rg -n 'db\.DB\(|db\.Redis' backend/openflare/plugins/server/kernel/model --glob '*.go' -g '!*_test.go' ; then \
echo 'error: internal/model must not access db.DB or db.Redis (non-test code)' >&2; \
exit 1; \
fi
golangci-lint run
cd frontend && pnpm tsc --noEmit --jsx preserve && npx eslint . --max-warnings 0
cd backend && golangci-lint run
cd frontend && node scripts/merge-i18n-fragments.mjs && pnpm tsc --noEmit --jsx preserve && npx eslint . --max-warnings 0
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/wavelet \
cd backend && go build \
-ldflags "-s -w -X '$(MODULE)/pkg/buildinfo.Version=$(VERSION)' -X '$(MODULE)/pkg/buildinfo.BuildTime=$(BUILD_DATE)'" \
-o ../bin/openflare-server \
main.go
build-agent:
@echo "==> Building agent version=$(VERSION)..."
cd backend && go build \
-ldflags "-s -w -X '$(MODULE)/openflare/plugins/agent/config.Version=$(VERSION)'" \
-o ../bin/openflare-agent \
cmd/agent/main.go
build-relay:
@echo "==> Building relay version=$(VERSION)..."
cd backend && go build \
-ldflags "-s -w -X '$(MODULE)/openflare/plugins/relay/config.Version=$(VERSION)'" \
-o ../bin/openflare-relay \
cmd/relay/main.go
build-flared:
@echo "==> Building flared version=$(VERSION)..."
cd backend && go build \
-ldflags "-s -w -X '$(MODULE)/openflare/plugins/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 && \
@@ -62,7 +90,7 @@ build-test:
@PIDS=""; \
STATUS=0; \
( cd frontend && pnpm build:embed 2>&1 | sed 's/^/[frontend] /' ) & PIDS="$$PIDS $$!"; \
( go test ./... && go build -o /dev/null ./... 2>&1 | sed 's/^/[backend] /' ) & PIDS="$$PIDS $$!"; \
( cd backend && go test ./... && go build -o /dev/null ./... 2>&1 | sed 's/^/[backend] /' ) & PIDS="$$PIDS $$!"; \
for PID in $$PIDS; do \
wait $$PID || STATUS=1; \
done; \
@@ -80,7 +108,7 @@ cross-build:
(version=$(or $(VERSION),dev))..."
@mkdir -p bin
docker build \
--file docker/Dockerfile.cross \
--file manifest/docker/Dockerfile.cross \
--target export \
--build-arg VERSION=$(or $(VERSION),dev) \
--build-arg BUILD_DATE="$(shell date -u +'%Y-%m-%dT%H:%M:%SZ')" \
@@ -97,14 +125,14 @@ dev-f:
dev-b:
@echo "==> Starting backend development server..."
go run main.go all
cd backend && go run main.go all
dev:
@echo "==> Starting frontend and backend development servers in parallel..."
@PIDS=""; \
STATUS=0; \
( cd frontend && pnpm dev 2>&1 | sed 's/^/[frontend] /' ) & PIDS="$$PIDS $$!"; \
( go run main.go all 2>&1 | sed 's/^/[backend] /' ) & PIDS="$$PIDS $$!"; \
( cd backend && go run main.go all 2>&1 | sed 's/^/[backend] /' ) & PIDS="$$PIDS $$!"; \
for PID in $$PIDS; do \
wait $$PID || STATUS=1; \
done; \
+4 -4
View File
@@ -1,9 +1,9 @@
Wavelet
OpenFlare
This product includes software derived from LinuxDO Credit.
This product includes software derived from Wavelet.
LinuxDO Credit:
Copyright 2025 linux.do
Wavelet:
Copyright 2025 Arctel.net
Licensed under the Apache License, Version 2.0.
This distribution includes modifications by Arctel.net.
+158 -318
View File
@@ -1,352 +1,192 @@
# wavelet
<div align="center">
🚀 A modern, production-ready full-stack boilerplate for building scalable web applications
# OpenFlare
[中文](./README_zh.md)
**[English](./README.md) | [简体中文](./README.zh-CN.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/)
OpenFlare is an open-source CDN orchestration and edge security platform. It supports reverse proxy, centralized configuration synchronization, in-network tunneling (Tunnels), dynamic WAF protection, and CC defense challenges.
## 📖 Introduction
</div>
**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.
<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>
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.
> [!WARNING]
> After the first login with the `admin` user, you must change the default password `12345678`.
>
> The BETA version is a temporary product in the development and testing stage and may have unknown issues. It should not be used in production environments.
### ✨ Key Features
## Documentation
- 🔐 **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
**https://openflare.fyrn.link**
## 🏗️ Architecture Overview
Common entry points:
```
┌─────────────────┐ ┌─────────────────────────────┐ ┌─────────────────┐
│ 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) │
└─────────────────────┘
```
* [Quick Start](https://openflare.fyrn.link/guide/quick-start)
* [Deployment Guide](https://openflare.fyrn.link/deployment/deployment)
* [Configuration Reference](https://openflare.fyrn.link/reference/configuration)
* [System Design](https://openflare.fyrn.link/design/)
## 🛠️ Tech Stack
## Core Capabilities
### 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
* **Reverse Proxy Configuration Management**: Uses website rules as the aggregation boundary, supports multi-domain binding and multi-upstream load balancing, and centrally manages reverse proxy configurations for all OpenResty nodes.
* **Secure In-Network Tunneling (Tunnels)**: Open-source version of Cloudflare Tunnels. No public IP or exposed inbound ports are required. Securely reverse-proxy internal web services to the public internet through Relay relay nodes and OpenFlared clients.
* **Edge WAF Security Protection**: Provides global and custom rule groups, supports manual/auto/subscription-type IP groups, MaxMind GeoIP national-level geographic access control, IP group member Checksum differential synchronization (no Nginx reload required), and custom blocking responses.
* **CC Defense and Human-Computer Challenge (PoW)**: Built-in high-performance client-side cryptography Proof of Work challenge (similar to Turnstile). Secures high-speed interception and blocking of zombie networks and crawlers at the gateway edge.
* **Pages Static Hosting**: Supports uploading or synchronizing pre-built artifacts from restricted Remote URLs or public GitHub Release assets. GitHub latest can be checked periodically and optionally auto-published. All sources are unified to generate immutable deployments, pulled by the edge Agent and served locally by OpenResty, supporting rollbacks, SPA Fallback, and API reverse proxy.
* **TLS Certificate Automation**: Supports dynamic certificate uploads, automatic multi-domain certificate matching and binding, and automatic issuance and renewal of certificates from Let's Encrypt via the ACME protocol.
* **Uptime Kuma Monitoring Synchronization**: Integrated with Uptime Kuma to automatically perform differential synchronization of monitoring site lists, real-time awareness of node availability and service status.
* **SSO Single Sign-On**: Supports GitHub OAuth and standard OIDC protocol for seamless integration with enterprise identity providers to achieve unified login.
* **Unified Observability**: Aggregates node request metrics, real-time access log details, host and Nginx resource snapshots, health events, and network fluctuation replenishment buffers.
### 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
## Interface Preview
## 📋 Requirements
### Dashboard Overview
- **Go** >= 1.25
- **Node.js** >= 18.0
- **PostgreSQL** >= 14
- **Redis** >= 6.0
- **pnpm** >= 8.0 (recommended)
![OpenFlare dashboard overview](./docs/assets/readme/dashboard-overview.png)
## 🚀 Quick Start
### Access Logs
### 1. Clone the Repository
![OpenFlare version release](./docs/assets/readme/domain_overview.png)
### WAF Protection
![OpenFlare version release](./docs/assets/readme/waf.png)
## Quick Start
### Hardware Configuration Recommendations
| Component | Minimum Hardware Requirements | Recommended Hardware Requirements | Notes |
|------------------------|-----------------------------------|-----------------------------------|-------|
| **Server Control Plane** | 1 CPU core / 2 GB RAM / 20 GB disk | 2 CPU cores / 4 GB RAM / 50 GB+ disk | Disk usage should be expanded reasonably based on access log retention duration and concurrent traffic |
| **Agent Data Plane** | 1 CPU core / 512 MB RAM / 2 GB disk | 2 CPU cores / 2 GB RAM / 10 GB+ disk | Expanded based on OpenResty concurrent proxy connections and WAF interception processing |
| **Relay Relay Node** | 1 CPU core / 1 GB RAM / 5 GB disk | 2 CPU cores / 2 GB RAM / 20 GB disk | frps transmission relay throughput is mainly limited by bandwidth and CPU throughput |
| **OpenFlared Client** | 1 CPU core / 256 MB RAM / 1 GB disk | 1 CPU core / 512 MB RAM / 5 GB disk | Runs independently on the internal network with extremely low resource consumption; only network throughput needs to be guaranteed |
### 1. Start the Server
Use `docker-compose`:
```bash
git clone https://github.com/Rain-kl/Wavelet.git refreshing
cd refreshing
# Download environment variable template and create .env file
curl -o .env.example https://raw.githubusercontent.com/Rain-kl/OpenFlare/refs/heads/main/.env.example
cp .env.example .env
```
### 2. Configure Environment
```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
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
volumes:
openflare_uploads:
openflare_postgres_data:
openflare_redis_data:
```
See the [deployment documentation](https://openflare.fyrn.link/deployment/deployment) for details.
Access address: `http://localhost:3000`
Default account:
* Username: `admin`
* Password: `12345678`
### 2. Install Agent
Before installing the Agent, first install OpenResty on the node or use the built-in OpenResty Agent Docker image.
You can copy the installation command from the control panel's **Nodes Management -> Details -> Node Information -> Node ID and Deployment**, or use the script below:
#### Docker Deployment
Docker deployment can directly run the Agent image:
```bash
cp config.example.yaml config.yaml
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
```
Edit `config.yaml` to configure your database and Redis. OIDC auth sources are configured at runtime in the admin settings page.
## Cordis / Wavelet upstream
### 3. Initialize Database
OpenFlare is built on Wavelet Cordis. After cloning, enable `merge=ours` from `.gitattributes` so `git merge wavelet/main` keeps OpenFlare-owned paths:
```bash
# Start local dependencies (PostgreSQL + Redis)
docker compose up -d
# 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
git config include.path ../.gitconfig
# worktree-safe:
git config include.path "$(git rev-parse --show-toplevel)/.gitconfig"
```
### 4. Start the Backend
`docker compose` uses `docker-compose.yaml`. `docker-compose.wavelet.yml` is the upstream Wavelet stack and is not the product default. Image publishes go through `.github/workflows/build-image-openflare*.yml`; the Wavelet `build-image.yml` is isolated.
```bash
# Install Go dependencies
go mod tidy
## Open Source License
# Generate Swagger API documentation
make swagger
This project is licensed under the [Apache License 2.0](./LICENSE).
# Start the HTTP API server
go run main.go api
```
## Star History
> 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).
<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>
+192
View File
@@ -0,0 +1,192 @@
<div align="center">
# OpenFlare
**[English](./README.md) | [简体中文](./README.zh-CN.md)**
OpenFlare 是开源 CDN 编排与边缘安全平台。它支持反向代理、集中式配置同步、内网穿透(Tunnels)、动态 WAF 防护以及防 CC 挑战。
</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]
> 使用 `admin` 用户初次登录系统后,务必修改默认密码 `12345678`。
>
> BETA 版本为开发测试阶段的临时产物,可能存在未知问题,请勿在生产环境使用。
## 文档
**https://openflare.fyrn.link**
常用入口:
* [快速开始](https://openflare.fyrn.link/guide/quick-start)
* [部署说明](https://openflare.fyrn.link/deployment/deployment)
* [配置项参考](https://openflare.fyrn.link/reference/configuration)
* [系统设计](https://openflare.fyrn.link/design/)
## 核心能力
* **反代配置管理**:以网站规则为聚合边界,支持多域名绑定与多上游负载均衡,统一管理所有 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 资源快照、健康事件以及网络波动补传缓冲。
## 界面预览
### 仪表盘总览
![OpenFlare dashboard overview](./docs/assets/readme/dashboard-overview.png)
### 访问日志
![OpenFlare version release](./docs/assets/readme/domain_overview.png)
### WAF 防护
![OpenFlare version release](./docs/assets/readme/waf.png)
## 快速开始
### 硬件配置推荐
| 组件 | 最低硬件配额 | 推荐硬件配额 | 说明 |
| --- |-------------------------------| --- | --- |
| **Server 控制面** | 1 核 CPU / 2 GB 内存 / 20 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 磁盘 | 独立运行于内网,自身资源占用极小,保障网络吞吐即可 |
### 1. 启动 Server
使用 docker-compose
```bash
# 下载环境变量模板并创建 .env 文件
curl -o .env.example https://raw.githubusercontent.com/Rain-kl/OpenFlare/refs/heads/main/.env.example
cp .env.example .env
```
```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
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
volumes:
openflare_uploads:
openflare_postgres_data:
openflare_redis_data:
```
详细部署说明见 [部署文档](https://openflare.fyrn.link/deployment/deployment)。
访问地址:`http://localhost:3000`
默认账号:
* 用户名:`admin`
* 密码:`12345678`
### 2. 安装 Agent
安装 Agent 前请先在节点上安装 OpenResty,或改用内置 OpenResty 的 Agent Docker 镜像。
你可以在控制面板的节点管理->详情->节点信息->节点标识与部署复制安装命令,或直接使用下面的脚本:
#### Docker 部署
Docker 部署可直接运行 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
```
## Cordis / Wavelet 上游
OpenFlare 构建在 Wavelet Cordis 之上。克隆后请启用 `.gitattributes` 中的 `merge=ours`,这样 `git merge wavelet/main` 会保留 OpenFlare 自有路径:
```bash
git config include.path ../.gitconfig
# worktree 安全写法:
git config include.path "$(git rev-parse --show-toplevel)/.gitconfig"
```
`docker compose` 使用 `docker-compose.yaml`。`docker-compose.wavelet.yml` 是上游 Wavelet 编排,不是本产品的默认栈。镜像发布走 `.github/workflows/build-image-openflare*.yml`;Wavelet 的 `build-image.yml` 已隔离。
## 开源协议
本项目采用 [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>
+5 -6
View File
@@ -95,10 +95,10 @@ cd refreshing
### 2. 配置环境
```bash
cp config.example.yaml config.yaml
cp manifest/config/config.default.yaml manifest/config/config.yaml
```
编辑 `config.yaml`,配置数据库和 Redis。OIDC 认证源统一在管理后台的系统设置页面运行时配置。
编辑 `manifest/config/config.yaml`,配置数据库和 Redis。OIDC 认证源统一在管理后台的系统设置页面运行时配置。
### 3. 初始化数据库
@@ -152,11 +152,11 @@ pnpm dev
|------|------|
| 前端界面 | http://localhost:3000 |
| Swagger 接口文档 | http://localhost:8000/swagger/index.html |
| 健康检查 | http://localhost:8000/api/health |
| 健康检查 | http://localhost:8000/api/healthz |
## ⚙️ 配置说明
主要配置项(完整说明请参考 `config.example.yaml`):
主要配置项(完整说明请参考 `manifest/config/config.default.yaml`):
| 配置项 | 说明 | 示例 |
|--------|------|------|
@@ -211,9 +211,8 @@ pnpm format
```
wavelet/
├── main.go # 程序入口(委托给 internal/cmd)
├── config.example.yaml # 配置模板
├── Makefile # 常用命令(swagger、tidy、license、cross-build)
├── docker/ # Docker 镜像构建文件(集成/前端/后端)
├── manifest/ # 项目清单与编排:docker 镜像构建、deploy (k8s)、config 配置(默认/覆盖)
├── docs/ # Swagger 自动生成文档
├── frontend/ # Next.js 前端应用
│ ├── app/ # App Router 页面
+41
View File
@@ -0,0 +1,41 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Command agent runs the OpenFlare edge agent daemon.
package main
import (
"flag"
"log/slog"
"os"
"time"
"Wavelet/core"
agentplugin "Wavelet/openflare/plugins/agent"
"Wavelet/openflare/plugins/agent/logging"
)
// shutdownTimeout 为 openresty 收敛与在途配置同步预留的退出窗口。
const shutdownTimeout = 60 * time.Second
func main() {
logging.Setup()
configPath := flag.String("config", "./agent.json", "agent config path")
flag.Parse()
app := core.NewApp(
core.WithProfile(core.Profile(agentplugin.DriverTypeAgent)),
core.WithShutdownTimeout(shutdownTimeout),
)
app.Use(agentplugin.New(*configPath))
if err := app.Prepare(); err != nil {
slog.Error("agent startup failed", "error", err)
os.Exit(1)
}
if err := app.Run(); err != nil {
slog.Error("agent process exited with error", "error", err)
os.Exit(1)
}
}
+19
View File
@@ -0,0 +1,19 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package cmd 提供 CLI 命令入口
package cmd
import (
"Wavelet/core"
"github.com/spf13/cobra"
)
var allCmd = &cobra.Command{
Use: "all",
Short: "以融合模式同时启动 API、Worker 和 Scheduler",
Run: func(_ *cobra.Command, _ []string) {
runProfileApp(core.ProfileAll, "all (API + Worker + Scheduler)", true)
},
}
+18
View File
@@ -0,0 +1,18 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package cmd
import (
"Wavelet/core"
"github.com/spf13/cobra"
)
var apiCmd = &cobra.Command{
Use: "api",
Short: "wavelet API",
Run: func(_ *cobra.Command, _ []string) {
runProfileApp(core.ProfileAPI, "api", true)
},
}
+417
View File
@@ -0,0 +1,417 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package cmd
import (
"Wavelet/core"
"Wavelet/core/contracts"
ofserver "Wavelet/openflare/plugins/server"
"Wavelet/openflare/plugins/server/migrate"
"Wavelet/plugins/domain/admin"
"Wavelet/plugins/domain/auth"
"Wavelet/plugins/domain/msg_gateway"
"Wavelet/plugins/domain/risk_control"
"Wavelet/plugins/domain/system"
"Wavelet/plugins/domain/upload"
"Wavelet/plugins/domain/user"
"Wavelet/plugins/drivers/driver_asynq_cron"
"Wavelet/plugins/drivers/driver_asynq_worker"
"Wavelet/plugins/drivers/driver_http"
"Wavelet/plugins/drivers/driver_inproc_cron"
"Wavelet/plugins/drivers/driver_inproc_worker"
"Wavelet/plugins/infra/cache"
"Wavelet/plugins/infra/cache_memory"
"Wavelet/plugins/infra/config"
"Wavelet/plugins/infra/logger"
"Wavelet/plugins/infra/storage"
"context"
"database/sql"
"fmt"
"io/fs"
"log"
"path/filepath"
"time"
"github.com/pressly/goose/v3"
goosedb "github.com/pressly/goose/v3/database"
"gorm.io/gorm"
infradb "Wavelet/plugins/infra/database"
)
const (
defaultShutdownTimeout = 15 * time.Second
defaultHTTPAddr = "127.0.0.1:8000"
// migrationAdvisoryLockKey serializes baseline + plugin Up across Postgres
// sessions (ASCII "wave"). SQLite is single-writer and needs no extra lock.
migrationAdvisoryLockKey int64 = 0x77617665
)
// runProfileApp prepares and runs the application for a given profile.
func runProfileApp(profile core.Profile, mode string, listensForHTTP bool) {
app := newOpenFlareApp(profile)
if err := app.Prepare(); err != nil {
log.Fatalf("[%s] prepare failed: %v\n", mode, err)
}
state := startupState{
mode: mode,
listensForHTTP: listensForHTTP,
env: app.Context().Config().String("app.env", "production"),
}
if listensForHTTP {
state.addr = app.Context().Config().String("app.addr", defaultHTTPAddr)
}
printStartupBanner(state)
if err := app.Run(); err != nil {
log.Fatalf("[%s] run failed: %v\n", mode, err)
}
}
// newOpenFlareApp creates a core.App wired with Wavelet platform plugins plus the OpenFlare server plugin.
func newOpenFlareApp(profile core.Profile, opts ...core.AppOption) *core.App {
src, err := config.NewSource()
if err != nil {
log.Fatalf("[App] load config source failed: %v\n", err)
}
appOpts := []core.AppOption{
core.WithProfile(profile),
core.WithConfigSource(src),
core.WithShutdownTimeout(defaultShutdownTimeout),
core.WithMigrationBaseline(migrate.Legacy),
}
appOpts = append(appOpts, opts...)
app := core.NewApp(appOpts...)
// 1. Register standard infrastructure plugins
app.Use(
infradb.New(),
logger.New(),
storage.New(),
)
// 2. Register Cache and Async/Cron Drivers (both gated: cache vs cache_memory, asynq vs inproc)
app.Use(
cache.New(),
cache_memory.New(),
driver_asynq_worker.New(),
driver_inproc_worker.New(),
driver_asynq_cron.New(),
driver_inproc_cron.New(),
)
// 3. Register all 7 domain business plugins (admin first to ensure schema and base config tables exist)
app.Use(
admin.New(),
user.New(),
auth.New(),
msg_gateway.New(),
risk_control.New(),
upload.New(),
system.New(),
)
// 4. OpenFlare business routes (after domain plugins, before the HTTP driver)
app.Use(
ofserver.New(),
)
// 5. Bind Goose migration engine
app.SetMigrationEngine(&gooseEngine{})
// 6. Mount HTTP runtime driver
app.Use(
driver_http.New(),
)
return app
}
// ─── Schema Version Store ──────────────────────────────────────────────────────
// sharedStore implements database.Store using a single w_schema_versions table.
// All plugins share this table, with plugin_id as the discriminator.
//
// Schema:
//
// w_schema_versions (
// plugin_id VARCHAR(64) NOT NULL,
// version_id BIGINT NOT NULL,
// applied_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
// PRIMARY KEY (plugin_id, version_id)
// )
type sharedStore struct {
pluginID string
dialect string // "postgres" or "sqlite3"
}
func (s *sharedStore) Tablename() string { return "w_schema_versions" }
func (s *sharedStore) CreateVersionTable(ctx context.Context, db goosedb.DBTxConn) error {
_, err := db.ExecContext(ctx, schemaVersionsDDL(s.dialect))
return err
}
func schemaVersionsDDL(dialect string) string {
timeType := "TIMESTAMPTZ"
if dialect == "sqlite3" || dialect == "sqlite" {
timeType = "DATETIME"
}
return fmt.Sprintf(`CREATE TABLE IF NOT EXISTS w_schema_versions (
plugin_id VARCHAR(64) NOT NULL,
version_id BIGINT NOT NULL,
applied_at %s NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (plugin_id, version_id)
)`, timeType)
}
//nolint:mnd
func (s *sharedStore) Insert(ctx context.Context, db goosedb.DBTxConn, req goosedb.InsertRequest) error {
p := s.placeholder
_, err := db.ExecContext(ctx,
fmt.Sprintf("INSERT INTO w_schema_versions (plugin_id, version_id) VALUES (%s, %s) ON CONFLICT (plugin_id, version_id) DO NOTHING", p(1), p(2)),
s.pluginID, req.Version)
return err
}
//nolint:mnd
func (s *sharedStore) Delete(ctx context.Context, db goosedb.DBTxConn, version int64) error {
p := s.placeholder
_, err := db.ExecContext(ctx,
fmt.Sprintf("DELETE FROM w_schema_versions WHERE plugin_id = %s AND version_id = %s", p(1), p(2)),
s.pluginID, version)
return err
}
//nolint:mnd
func (s *sharedStore) GetMigration(ctx context.Context, db goosedb.DBTxConn, version int64) (*goosedb.GetMigrationResult, error) {
p := s.placeholder
var t time.Time
err := db.QueryRowContext(ctx,
fmt.Sprintf("SELECT applied_at FROM w_schema_versions WHERE plugin_id = %s AND version_id = %s", p(1), p(2)),
s.pluginID, version).Scan(&t)
if err == sql.ErrNoRows {
return nil, goosedb.ErrVersionNotFound
}
if err != nil {
return nil, err
}
return &goosedb.GetMigrationResult{Timestamp: t, IsApplied: true}, nil
}
func (s *sharedStore) GetLatestVersion(ctx context.Context, db goosedb.DBTxConn) (int64, error) {
p := s.placeholder
var version int64
err := db.QueryRowContext(ctx,
fmt.Sprintf("SELECT COALESCE(MAX(version_id), 0) FROM w_schema_versions WHERE plugin_id = %s", p(1)),
s.pluginID).Scan(&version)
if err != nil {
return 0, err
}
return version, nil
}
func (s *sharedStore) ListMigrations(ctx context.Context, db goosedb.DBTxConn) ([]*goosedb.ListMigrationsResult, error) {
p := s.placeholder
rows, err := db.QueryContext(ctx,
fmt.Sprintf("SELECT version_id, TRUE FROM w_schema_versions WHERE plugin_id = %s ORDER BY version_id DESC", p(1)),
s.pluginID)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
var results []*goosedb.ListMigrationsResult
for rows.Next() {
var r goosedb.ListMigrationsResult
if err := rows.Scan(&r.Version, &r.IsApplied); err != nil {
return nil, err
}
results = append(results, &r)
}
return results, rows.Err()
}
func (s *sharedStore) placeholder(n int) string {
if s.dialect == "postgres" {
return fmt.Sprintf("$%d", n)
}
return "?"
}
// ─── Migration Engine ──────────────────────────────────────────────────────────
// gooseEngine implements core.MigrationEngine by iterating all plugin-registered
// migration entries and applying each plugin's migrations against the shared DB.
//
// Each plugin owns its own `migrations/*.sql` directory, embedded via go:embed
// and registered via ctx.Migrations().Register(pluginID, embedFS).
//
// Version tracking: all plugins share a single w_schema_versions table with
// plugin_id as the discriminator column. Querying this table shows the current
// migration version of every plugin at a glance.
type gooseEngine struct{}
func (e *gooseEngine) Migrate(ctx *core.Context, entries []core.MigrationEntry) error {
if len(entries) == 0 {
return nil
}
// Resolve DBService from the IoC container.
var dbSvc contracts.DBService
if err := core.Using[contracts.DBService](ctx, func(svc contracts.DBService) {
dbSvc = svc
}); err != nil {
return fmt.Errorf("migration: resolve DBService: %w", err)
}
gormDB := dbSvc.GORM()
if gormDB == nil {
return fmt.Errorf("migration: DBService.GORM() returned nil")
}
sqlDB, err := gormDB.DB()
if err != nil {
return fmt.Errorf("migration: get underlying DB from GORM: %w", err)
}
dialect := gooseDialectFromGORM(gormDB, ctx)
dialectStr := string(dialect)
goCtx := context.Background()
if ctx != nil {
goCtx = ctx.GoContext()
}
if goCtx == nil {
goCtx = context.Background()
}
bootstrap := &sharedStore{dialect: dialectStr}
if err := bootstrap.CreateVersionTable(goCtx, sqlDB); err != nil {
return fmt.Errorf("migration: create version table: %w", err)
}
if dialect == goose.DialectPostgres {
conn, lockErr := sqlDB.Conn(goCtx)
if lockErr != nil {
return fmt.Errorf("migration: pin connection for advisory lock: %w", lockErr)
}
defer func() { _ = conn.Close() }()
if _, lockErr = conn.ExecContext(goCtx, "SELECT pg_advisory_lock($1)", migrationAdvisoryLockKey); lockErr != nil {
return fmt.Errorf("migration: advisory lock: %w", lockErr)
}
defer func() {
_, _ = conn.ExecContext(context.Background(), "SELECT pg_advisory_unlock($1)", migrationAdvisoryLockKey)
}()
}
if fn := ctx.MigrationBaseline(); fn != nil {
if err := fn(ctx); err != nil {
return fmt.Errorf("migration baseline: %w", err)
}
}
for _, entry := range entries {
store := &sharedStore{
pluginID: entry.PluginID,
dialect: dialectStr,
}
migrationFS := findMigrationFS(entry.FS, dialect)
provider, err := goose.NewProvider(goose.DialectCustom, sqlDB, migrationFS, goose.WithStore(store))
if err != nil {
return fmt.Errorf("migration %s: create provider: %w", entry.PluginID, err)
}
results, err := provider.Up(context.Background())
if err != nil {
return fmt.Errorf("migration %s: apply %w", entry.PluginID, err)
}
version, vErr := provider.GetDBVersion(context.Background())
if vErr != nil {
version = 0
}
if len(results) > 0 {
log.Printf("[migrate] %s: applied %d migration(s) (v%d)", entry.PluginID, len(results), version)
} else {
log.Printf("[migrate] %s: v%d", entry.PluginID, version)
}
}
return nil
}
// gooseDialectFromGORM prefers the live driver; config is only a fallback when
// GORM has no dialector yet (tests that inject a stub DBService).
func gooseDialectFromGORM(gormDB *gorm.DB, ctx *core.Context) goose.Dialect {
if gormDB != nil && gormDB.Dialector != nil && gormDB.Dialector.Name() == "postgres" {
return goose.DialectPostgres
}
if gormDB != nil && gormDB.Dialector != nil && gormDB.Dialector.Name() == "sqlite" {
return goose.DialectSQLite3
}
return gooseDialect(ctx)
}
// gooseDialect returns the goose dialect based on the configured database engine.
func gooseDialect(ctx *core.Context) goose.Dialect {
if ctx != nil && ctx.Config() != nil && ctx.Config().Bool("database.enabled", false) {
return goose.DialectPostgres
}
return goose.DialectSQLite3
}
func findMigrationFS(rootFS fs.FS, dialect goose.Dialect) fs.FS {
dialectDir := "postgres"
if dialect == goose.DialectSQLite3 {
dialectDir = "sqlite"
}
// 1. Direct search for dialect folder (e.g., "sqlite", "migrations/sqlite", "logstore/migrations/sqlite")
for _, subDir := range []string{
dialectDir,
"migrations/" + dialectDir,
"logstore/migrations/" + dialectDir,
} {
if sub, err := fs.Sub(rootFS, subDir); err == nil {
if matches, err := fs.Glob(sub, "*.sql"); err == nil && len(matches) > 0 {
return sub
}
}
}
// 2. Recursive walk to find a directory named dialectDir with *.sql files
var foundDir string
_ = fs.WalkDir(rootFS, ".", func(path string, d fs.DirEntry, err error) error {
if err == nil && d.IsDir() && filepath.Base(path) == dialectDir {
if sub, subErr := fs.Sub(rootFS, path); subErr == nil {
if matches, globErr := fs.Glob(sub, "*.sql"); globErr == nil && len(matches) > 0 {
foundDir = path
return fs.SkipAll
}
}
}
return nil
})
if foundDir != "" && foundDir != "." {
if sub, err := fs.Sub(rootFS, foundDir); err == nil {
return sub
}
}
// 3. Fallback to generic migrations / root if dialect specific is not present
for _, subDir := range []string{"migrations", "logstore/migrations"} {
if sub, err := fs.Sub(rootFS, subDir); err == nil {
if matches, err := fs.Glob(sub, "*.sql"); err == nil && len(matches) > 0 {
return sub
}
}
}
return rootFS
}
+133
View File
@@ -0,0 +1,133 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package cmd
import (
"path/filepath"
"testing"
"Wavelet/core"
)
func testSource(t *testing.T) core.ConfigSource {
t.Helper()
return core.NewMapSource(map[string]any{
"app": map[string]any{
"addr": "127.0.0.1:0",
"env": "testing",
},
"redis": map[string]any{
"enabled": false,
},
"database": map[string]any{
"enabled": false,
"sqlite_path": filepath.Join(t.TempDir(), "openflare-cmd.db"),
},
})
}
func TestNewOpenFlareAppRegistersServerAndWaveletUser(t *testing.T) {
app := newOpenFlareApp(core.ProfileAPI, core.WithConfigSource(testSource(t)))
if err := app.Prepare(); err != nil {
t.Fatal(err)
}
names := map[string]bool{}
for _, p := range app.Plugins() {
names[p.Name()] = true
}
for _, n := range []string{"user", "auth", "admin", "server"} {
if !names[n] {
t.Errorf("missing plugin %s", n)
}
}
if err := app.Reconcile(); err != nil {
t.Fatal(err)
}
got := map[string]bool{}
for _, rd := range app.Context().Router().Routes() {
got[rd.Method+" "+rd.Path] = true
}
for _, want := range []string{
"GET /api/healthz",
"GET /api/v1/user/self",
"GET /api/v1/d/nodes",
"POST /api/v1/cap/challenge",
} {
if !got[want] {
t.Errorf("missing route %s", want)
}
}
for _, drop := range []string{
"GET /api/health",
"GET /healthz",
"POST /api/cap/challenge",
} {
if got[drop] {
t.Errorf("removed route still registered: %s", drop)
}
}
}
func TestFreshInstallSeedsOpenFlareDefaults(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "fresh.db")
app := cordisPrepare(t, cordisSQLiteSource(t, dbPath))
t.Cleanup(func() { _ = app.Context().Dispose() })
db := openInspectDB(t, dbPath, "")
defer func() { _ = db.Close() }()
var tables int
if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name LIKE 'of_%'`).Scan(&tables); err != nil {
t.Fatal(err)
}
if tables == 0 {
t.Fatal("fresh install created no of_* tables")
}
rows, err := db.Query(`SELECT task_type FROM w_schedules WHERE task_type LIKE 'of_%' ORDER BY 1`)
if err != nil {
t.Fatal(err)
}
defer func() { _ = rows.Close() }()
var got []string
for rows.Next() {
var taskType string
if err := rows.Scan(&taskType); err != nil {
t.Fatal(err)
}
got = append(got, taskType)
}
want := []string{
"of_pages_source_scan",
"of_ssl_renew",
"of_uptime_kuma_sync",
"of_waf_ip_group_sync",
}
if len(got) != len(want) {
t.Fatalf("of_* schedules = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("of_* schedules = %v, want %v", got, want)
}
}
var cleanup int
if err := db.QueryRow(`SELECT COUNT(*) FROM w_schedules WHERE task_type = 'of_database_auto_cleanup'`).Scan(&cleanup); err != nil {
t.Fatal(err)
}
if cleanup != 0 {
t.Fatal("must not seed of_database_auto_cleanup")
}
var geoip string
if err := db.QueryRow(`SELECT value FROM w_system_configs WHERE key = 'geoip_provider'`).Scan(&geoip); err != nil {
t.Fatalf("geoip_provider: %v", err)
}
if geoip != "ipinfo" {
t.Fatalf("geoip_provider = %q, want ipinfo", geoip)
}
}
+60
View File
@@ -0,0 +1,60 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package cmd
import (
"Wavelet/pkg/buildinfo"
"fmt"
"runtime"
"strings"
)
type startupState struct {
mode string
listensForHTTP bool
env string
addr string
}
func printStartupBanner(state startupState) {
fmt.Println(formatStartupBanner(state))
}
func formatStartupBanner(state startupState) string {
env := state.env
if env == "" {
env = "production"
}
addr := state.addr
if addr == "" {
addr = "127.0.0.1:3000"
}
lines := []string{
"",
" ____ ________ ",
" / __ \\____ ___ ____ / ____/ /___ _________ ",
" / / / / __ \\/ _ \\/ __ \\/ /_ / / __ `/ ___/ _ \\",
"/ /_/ / /_/ / __/ / / / __/ / / /_/ / / / __/",
"\\____/ .___/\\___/_/ /_/_/ /_/\\__,_/_/ \\___/ ",
" /_/ ",
fmt.Sprintf(" OpenFlare %s", buildinfo.Version),
"",
fmt.Sprintf(" Environment: %s", env),
fmt.Sprintf(" Runtime: %s/%s (%s)", runtime.GOOS, runtime.GOARCH, runtime.Version()),
fmt.Sprintf(" Build time: %s", buildTime()),
}
if state.listensForHTTP {
lines = append(lines, fmt.Sprintf(" Listening: http://%s", addr))
}
lines = append(lines, fmt.Sprintf(" Mode: %s", state.mode), "")
return strings.Join(lines, "\n")
}
func buildTime() string {
if buildinfo.BuildTime == "" {
return "development build"
}
return buildinfo.BuildTime
}
@@ -4,49 +4,33 @@
package cmd
import (
"Wavelet/pkg/buildinfo"
"strings"
"testing"
"github.com/Rain-kl/Wavelet/internal/buildinfo"
"github.com/Rain-kl/Wavelet/internal/infra/config"
"github.com/Rain-kl/Wavelet/internal/infra/persistence/migrator"
)
func TestFormatStartupBanner(t *testing.T) {
previousVersion := buildinfo.Version
previousBuildTime := buildinfo.BuildTime
previousEnv := config.Config.App.Env
previousAddr := config.Config.App.Addr
t.Cleanup(func() {
buildinfo.Version = previousVersion
buildinfo.BuildTime = previousBuildTime
config.Config.App.Env = previousEnv
config.Config.App.Addr = previousAddr
})
buildinfo.Version = "v3.2.1"
buildinfo.BuildTime = "2026-07-13T08:00:00Z"
config.Config.App.Env = "production"
config.Config.App.Addr = ":3000"
banner := formatStartupBanner(startupState{
mode: "API",
relationalDB: migrator.Report{
Backend: "PostgreSQL",
Enabled: true,
Version: 202607150003,
Applied: true,
},
clickHouseDB: migrator.Report{Backend: "ClickHouse"},
mode: "API",
listensForHTTP: true,
env: "production",
addr: ":3000",
})
for _, want := range []string{
"Wavelet v3.2.1",
"OpenFlare v3.2.1",
"Environment: production",
"Build time: 2026-07-13T08:00:00Z",
"Database: PostgreSQL (version 202607150003, upgraded)",
"Analytics: disabled",
"Listening: http://:3000",
"Mode: API",
} {
+41
View File
@@ -0,0 +1,41 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Command flared runs the OpenFlare tunnel client daemon.
package main
import (
"flag"
"log/slog"
"os"
"time"
"Wavelet/core"
flaredplugin "Wavelet/openflare/plugins/flared"
edgelogging "Wavelet/openflare/share/edge/logging"
)
// shutdownTimeout 为 frpc 子进程收敛预留的退出窗口。
const shutdownTimeout = 60 * time.Second
func main() {
edgelogging.Setup(edgelogging.Options{})
configPath := flag.String("config", "./flared.json", "flared config path")
flag.Parse()
app := core.NewApp(
core.WithProfile(core.Profile(flaredplugin.DriverTypeFlared)),
core.WithShutdownTimeout(shutdownTimeout),
)
app.Use(flaredplugin.New(*configPath))
if err := app.Prepare(); err != nil {
slog.Error("flared startup failed", "error", err)
os.Exit(1)
}
if err := app.Run(); err != nil {
slog.Error("flared process exited with error", "error", err)
os.Exit(1)
}
}
+400
View File
@@ -0,0 +1,400 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package cmd
import (
"Wavelet/core"
"Wavelet/core/contracts"
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"testing/fstest"
"time"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/postgres"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
type migrateTestDB struct {
db *gorm.DB
}
func (s migrateTestDB) GORM() *gorm.DB { return s.db }
func (s migrateTestDB) DB(ctx context.Context) *gorm.DB { return s.db.WithContext(ctx) }
func (s migrateTestDB) Named(string) *gorm.DB { return s.db }
type migrateTestPlugin struct {
name string
db *gorm.DB
fs fstest.MapFS
}
func (p *migrateTestPlugin) Name() string {
if p.name != "" {
return p.name
}
return "t"
}
func (p *migrateTestPlugin) Apply(ctx *core.Context) error {
core.Provide[contracts.DBService](ctx, migrateTestDB{db: p.db})
ctx.Migrations().Register(p.Name(), p.fs)
return nil
}
func sqliteTableExists(t *testing.T, db *gorm.DB, name string) bool {
t.Helper()
var n int
err := db.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?", name).Scan(&n).Error
require.NoError(t, err)
return n > 0
}
func testMigrationFS() fstest.MapFS {
return fstest.MapFS{
"migrations/sqlite/00001_init.sql": &fstest.MapFile{Data: []byte(`-- +goose Up
CREATE TABLE t_up (id INTEGER PRIMARY KEY);
-- +goose Down
DROP TABLE t_up;
`)},
}
}
func openMigrateTestDB(t *testing.T) *gorm.DB {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "migrate.db")
gdb, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
require.NoError(t, err)
return gdb
}
func TestGooseEngineMigrateOrderCreateTableBaselineUp(t *testing.T) {
gdb := openMigrateTestDB(t)
var order []string
app := core.NewApp(
core.WithMigrationEngine(&gooseEngine{}),
core.WithMigrationBaseline(func(*core.Context) error {
require.True(t, sqliteTableExists(t, gdb, "w_schema_versions"), "version table must exist before baseline")
require.False(t, sqliteTableExists(t, gdb, "t_up"), "plugin Up must not run before baseline")
order = append(order, "create-table", "baseline")
return nil
}),
core.WithPlugins(&migrateTestPlugin{db: gdb, fs: testMigrationFS()}),
)
require.NoError(t, app.Prepare())
require.NoError(t, app.ApplyPlugins())
require.NoError(t, app.RunMigrations())
require.True(t, sqliteTableExists(t, gdb, "t_up"), "plugin Up must run after baseline")
order = append(order, "up")
assert.Equal(t, []string{"create-table", "baseline", "up"}, order)
}
func TestGooseEngineBaselineErrorSkipsUp(t *testing.T) {
gdb := openMigrateTestDB(t)
app := core.NewApp(
core.WithMigrationEngine(&gooseEngine{}),
core.WithMigrationBaseline(func(*core.Context) error {
require.True(t, sqliteTableExists(t, gdb, "w_schema_versions"), "version table must exist before baseline")
return assert.AnError
}),
core.WithPlugins(&migrateTestPlugin{db: gdb, fs: testMigrationFS()}),
)
require.NoError(t, app.Prepare())
require.NoError(t, app.ApplyPlugins())
err := app.RunMigrations()
require.Error(t, err)
assert.ErrorContains(t, err, "migration baseline")
assert.False(t, sqliteTableExists(t, gdb, "t_up"), "plugin Up must not run when baseline fails")
}
func TestGooseEngineNilBaselineStillMigrates(t *testing.T) {
gdb := openMigrateTestDB(t)
app := core.NewApp(
core.WithMigrationEngine(&gooseEngine{}),
core.WithPlugins(&migrateTestPlugin{db: gdb, fs: testMigrationFS()}),
)
require.NoError(t, app.Prepare())
require.NoError(t, app.ApplyPlugins())
require.NoError(t, app.RunMigrations())
assert.True(t, sqliteTableExists(t, gdb, "w_schema_versions"))
assert.True(t, sqliteTableExists(t, gdb, "t_up"))
}
func TestGooseEngineUpgradesFrom00001To00002(t *testing.T) {
gdb := openMigrateTestDB(t)
runTestMigrations(t, gdb, testMigrationFS(), "")
require.True(t, sqliteTableExists(t, gdb, "t_up"))
require.False(t, sqliteTableExists(t, gdb, "t_v2"))
require.Equal(t, int64(1), pluginSchemaVersion(t, gdb, "t"))
runTestMigrations(t, gdb, testMigrationFSWithV2("sqlite"), "")
require.True(t, sqliteTableExists(t, gdb, "t_up"), "00001 table must survive 00002")
require.True(t, sqliteTableExists(t, gdb, "t_v2"), "00002 must create t_v2")
require.Equal(t, int64(2), pluginSchemaVersion(t, gdb, "t"))
require.Equal(t, 1, tableRowCount(t, gdb, "t_v2"))
runTestMigrations(t, gdb, testMigrationFSWithV2("sqlite"), "")
require.Equal(t, int64(2), pluginSchemaVersion(t, gdb, "t"), "second 00002 run must be a no-op")
require.Equal(t, 1, tableRowCount(t, gdb, "t_v2"), "00002 INSERT must not run twice")
}
func TestGooseEngineStampedV1AppliesOnly00002(t *testing.T) {
gdb := openMigrateTestDB(t)
require.NoError(t, gdb.Exec(`CREATE TABLE w_schema_versions (
plugin_id VARCHAR(64) NOT NULL,
version_id BIGINT NOT NULL,
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (plugin_id, version_id)
)`).Error)
require.NoError(t, gdb.Exec(`INSERT INTO w_schema_versions (plugin_id, version_id) VALUES ('t', 1)`).Error)
runTestMigrations(t, gdb, testMigrationFSWithV2("sqlite"), "")
require.False(t, sqliteTableExists(t, gdb, "t_up"), "stamped v1 must not re-run 00001")
require.True(t, sqliteTableExists(t, gdb, "t_v2"), "stamped v1 must still apply 00002")
require.Equal(t, int64(2), pluginSchemaVersion(t, gdb, "t"))
}
func TestOpenFlareServerUpgradesFrom00001To00002(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "of.db")
app := cordisPrepare(t, cordisSQLiteSource(t, dbPath))
require.NoError(t, app.Context().Dispose())
inspect := openInspectDB(t, dbPath, "")
if !pluginHasVersion(t, inspect, false, serverPluginStamp, 1) {
t.Fatal("fresh install did not apply server 00001")
}
_ = inspect.Close()
gdb, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)})
require.NoError(t, err)
runTestMigrations(t, gdb, serverFollowupFS("sqlite"), "server")
require.False(t, sqliteTableExists(t, gdb, "should_not_exist_from_00001_rerun"))
require.True(t, sqliteTableExists(t, gdb, "of_upgrade_probe"))
require.Equal(t, int64(2), pluginSchemaVersion(t, gdb, "server"))
require.True(t, sqliteTableExists(t, gdb, "of_zones"), "existing of_* tables must survive 00002")
require.Equal(t, 1, tableRowCount(t, gdb, "of_upgrade_probe"))
}
func TestGooseEngineUpgradesFrom00001To00002Postgres(t *testing.T) {
gdb := openMigratePostgresDB(t)
opts := postgresMigrateOpt()
runTestMigrations(t, gdb, testPostgresMigrationFS(), "", opts)
require.True(t, pgTableExists(t, gdb, "t_up"))
require.False(t, pgTableExists(t, gdb, "t_v2"))
require.Equal(t, int64(1), pluginSchemaVersion(t, gdb, "t"))
runTestMigrations(t, gdb, testMigrationFSWithV2("postgres"), "", opts)
require.True(t, pgTableExists(t, gdb, "t_up"))
require.True(t, pgTableExists(t, gdb, "t_v2"))
require.Equal(t, int64(2), pluginSchemaVersion(t, gdb, "t"))
require.Equal(t, 1, tableRowCount(t, gdb, "t_v2"))
runTestMigrations(t, gdb, testMigrationFSWithV2("postgres"), "", opts)
require.Equal(t, int64(2), pluginSchemaVersion(t, gdb, "t"))
require.Equal(t, 1, tableRowCount(t, gdb, "t_v2"))
}
func TestOpenFlareServerUpgradesFrom00001To00002Postgres(t *testing.T) {
host, port, user, pass, dbName, sslMode, cleanup := createMigratePostgresDB(t)
t.Cleanup(cleanup)
dsn := postgresDSN(host, port, user, pass, dbName, sslMode)
app := cordisPrepare(t, cordisPostgresSource(t, host, port, user, pass, dbName, sslMode))
require.NoError(t, app.Context().Dispose())
inspect := openInspectDB(t, "", dsn)
if !pluginHasVersion(t, inspect, true, serverPluginStamp, 1) {
t.Fatal("fresh install did not apply server 00001")
}
_ = inspect.Close()
gdb, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)})
require.NoError(t, err)
runTestMigrations(t, gdb, serverFollowupFS("postgres"), "server", postgresMigrateOpt())
require.False(t, pgTableExists(t, gdb, "should_not_exist_from_00001_rerun"))
require.True(t, pgTableExists(t, gdb, "of_upgrade_probe"))
require.Equal(t, int64(2), pluginSchemaVersion(t, gdb, "server"))
require.True(t, pgTableExists(t, gdb, "of_zones"))
require.Equal(t, 1, tableRowCount(t, gdb, "of_upgrade_probe"))
}
func runTestMigrations(t *testing.T, gdb *gorm.DB, fs fstest.MapFS, pluginName string, opts ...core.AppOption) {
t.Helper()
plugin := &migrateTestPlugin{name: pluginName, db: gdb, fs: fs}
appOpts := []core.AppOption{
core.WithMigrationEngine(&gooseEngine{}),
core.WithPlugins(plugin),
}
appOpts = append(appOpts, opts...)
app := core.NewApp(appOpts...)
require.NoError(t, app.Prepare())
require.NoError(t, app.ApplyPlugins())
require.NoError(t, app.RunMigrations())
}
func postgresMigrateOpt() core.AppOption {
return core.WithConfigSource(core.NewMapSource(map[string]any{
"database": map[string]any{"enabled": true},
}))
}
func testPostgresMigrationFS() fstest.MapFS {
return fstest.MapFS{
"migrations/postgres/00001_init.sql": &fstest.MapFile{Data: []byte(`-- +goose Up
CREATE TABLE t_up (id BIGINT PRIMARY KEY);
-- +goose Down
DROP TABLE t_up;
`)},
}
}
func testMigrationFSWithV2(dialect string) fstest.MapFS {
v1 := `-- +goose Up
CREATE TABLE t_up (id BIGINT PRIMARY KEY);
-- +goose Down
DROP TABLE t_up;
`
v2 := `-- +goose Up
CREATE TABLE t_v2 (id BIGINT PRIMARY KEY, note TEXT NOT NULL DEFAULT '');
INSERT INTO t_v2 (id, note) VALUES (1, 'from-00002');
-- +goose Down
DROP TABLE t_v2;
`
if dialect == "sqlite" {
v1 = `-- +goose Up
CREATE TABLE t_up (id INTEGER PRIMARY KEY);
-- +goose Down
DROP TABLE t_up;
`
v2 = `-- +goose Up
CREATE TABLE t_v2 (id INTEGER PRIMARY KEY, note TEXT NOT NULL DEFAULT '');
INSERT INTO t_v2 (id, note) VALUES (1, 'from-00002');
-- +goose Down
DROP TABLE t_v2;
`
}
return fstest.MapFS{
"migrations/" + dialect + "/00001_init.sql": &fstest.MapFile{Data: []byte(v1)},
"migrations/" + dialect + "/00002_add_t_v2.sql": &fstest.MapFile{Data: []byte(v2)},
}
}
func serverFollowupFS(dialect string) fstest.MapFS {
v1 := `-- +goose Up
CREATE TABLE should_not_exist_from_00001_rerun (id INTEGER);
-- +goose Down
DROP TABLE should_not_exist_from_00001_rerun;
`
v2 := `-- +goose Up
CREATE TABLE of_upgrade_probe (id INTEGER PRIMARY KEY, note TEXT NOT NULL DEFAULT '');
INSERT INTO of_upgrade_probe (id, note) VALUES (1, 'from-00002');
-- +goose Down
DROP TABLE of_upgrade_probe;
`
if dialect == "postgres" {
v1 = `-- +goose Up
CREATE TABLE should_not_exist_from_00001_rerun (id BIGINT);
-- +goose Down
DROP TABLE should_not_exist_from_00001_rerun;
`
v2 = `-- +goose Up
CREATE TABLE of_upgrade_probe (id BIGINT PRIMARY KEY, note TEXT NOT NULL DEFAULT '');
INSERT INTO of_upgrade_probe (id, note) VALUES (1, 'from-00002');
-- +goose Down
DROP TABLE of_upgrade_probe;
`
}
return fstest.MapFS{
"migrations/" + dialect + "/00001_initial.sql": &fstest.MapFile{Data: []byte(v1)},
"migrations/" + dialect + "/00002_upgrade_probe.sql": &fstest.MapFile{Data: []byte(v2)},
}
}
func pluginSchemaVersion(t *testing.T, db *gorm.DB, pluginID string) int64 {
t.Helper()
var v int64
err := db.Raw(`SELECT COALESCE(MAX(version_id), 0) FROM w_schema_versions WHERE plugin_id = ?`, pluginID).Scan(&v).Error
require.NoError(t, err)
return v
}
func tableRowCount(t *testing.T, db *gorm.DB, name string) int {
t.Helper()
if !safePGIdent(name) {
t.Fatalf("unsafe table name %q", name)
}
var n int
err := db.Raw("SELECT COUNT(*) FROM " + name).Scan(&n).Error
require.NoError(t, err)
return n
}
func pgTableExists(t *testing.T, db *gorm.DB, name string) bool {
t.Helper()
var n int
err := db.Raw(`SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_name = ?`, name).Scan(&n).Error
require.NoError(t, err)
return n > 0
}
func openMigratePostgresDB(t *testing.T) *gorm.DB {
t.Helper()
host, port, user, pass, dbName, sslMode, cleanup := createMigratePostgresDB(t)
t.Cleanup(cleanup)
dsn := postgresDSN(host, port, user, pass, dbName, sslMode)
gdb, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)})
require.NoError(t, err)
return gdb
}
func createMigratePostgresDB(t *testing.T) (host string, port int, user, pass, dbName, sslMode string, cleanup func()) {
t.Helper()
dsn := strings.TrimSpace(os.Getenv("TEST_PG_DSN"))
if dsn == "" {
t.Skip("TEST_PG_DSN is not set")
}
host, port, user, pass, adminDB, sslMode := parsePostgresDSN(t, dsn)
adminDSN := postgresDSN(host, port, user, pass, adminDB, sslMode)
admin := openInspectDB(t, "", adminDSN)
dbName = fmt.Sprintf("of_mig_%d", time.Now().UnixNano())
if !safePGIdent(dbName) {
t.Fatalf("generated database name %q is not a safe identifier", dbName)
}
if _, err := admin.Exec("CREATE DATABASE " + dbName); err != nil {
t.Fatalf("CREATE DATABASE %s: %v", dbName, err)
}
cleanup = func() {
_, _ = admin.Exec(`SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, dbName)
_, _ = admin.Exec("DROP DATABASE IF EXISTS " + dbName)
_ = admin.Close()
}
return host, port, user, pass, dbName, sslMode, cleanup
}
+108
View File
@@ -0,0 +1,108 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package cmd
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"Wavelet/core"
)
// baselineRoutesFile 是改造前遗留注册路径导出的 (方法 路径) 全集。
const baselineRoutesFile = "docs/superpowers/specs/baseline/routes-engine.txt"
func TestPluginRoutesContainGoldenBaseline(t *testing.T) {
app := newOpenFlareApp(core.ProfileAPI, core.WithConfigSource(testSource(t)))
if err := app.Prepare(); err != nil {
t.Fatal(err)
}
if err := app.Reconcile(); err != nil {
t.Fatal(err)
}
got := routeSet(app.Context())
want := loadBaseline(t)
for _, drop := range []string{
"GET /api/health",
"GET /healthz",
"POST /api/cap/challenge",
"POST /api/cap/redeem",
} {
delete(want, drop)
}
for k := range want {
if !got[k] {
t.Errorf("missing golden route %s", k)
}
}
for _, must := range []string{
"GET /api/healthz",
"POST /api/v1/cap/challenge",
"POST /api/v1/cap/redeem",
} {
if !got[must] {
t.Errorf("missing required route %s", must)
}
}
for _, drop := range []string{
"GET /api/health",
"GET /healthz",
"POST /api/cap/challenge",
"POST /api/cap/redeem",
} {
if got[drop] {
t.Errorf("removed route still registered: %s", drop)
}
}
}
func routeSet(ctx *core.Context) map[string]bool {
set := make(map[string]bool)
for _, rd := range ctx.Router().Routes() {
set[rd.Method+" "+rd.Path] = true
}
return set
}
func loadBaseline(t *testing.T) map[string]bool {
t.Helper()
path := locateFile(t, baselineRoutesFile)
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read baseline %s: %v", path, err)
}
set := make(map[string]bool)
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line != "" {
set[line] = true
}
}
if len(set) == 0 {
t.Fatalf("baseline %s is empty", path)
}
return set
}
func locateFile(t *testing.T, rel string) string {
t.Helper()
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller failed")
}
dir := filepath.Dir(thisFile)
for range 8 {
candidate := filepath.Join(dir, rel)
if _, err := os.Stat(candidate); err == nil {
return candidate
}
dir = filepath.Join(dir, "..")
}
t.Fatalf("%s not found above %s", rel, filepath.Dir(thisFile))
return ""
}
+41
View File
@@ -0,0 +1,41 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Command relay runs the OpenFlare relay node daemon.
package main
import (
"flag"
"log/slog"
"os"
"time"
"Wavelet/core"
relayplugin "Wavelet/openflare/plugins/relay"
edgelogging "Wavelet/openflare/share/edge/logging"
)
// shutdownTimeout 为 frps 子进程收敛预留的退出窗口。
const shutdownTimeout = 60 * time.Second
func main() {
edgelogging.Setup(edgelogging.Options{})
configPath := flag.String("config", "./relay.json", "relay config path")
flag.Parse()
app := core.NewApp(
core.WithProfile(core.Profile(relayplugin.DriverTypeRelay)),
core.WithShutdownTimeout(shutdownTimeout),
)
app.Use(relayplugin.New(*configPath))
if err := app.Prepare(); err != nil {
slog.Error("relay startup failed", "error", err)
os.Exit(1)
}
if err := app.Run(); err != nil {
slog.Error("relay process exited with error", "error", err)
os.Exit(1)
}
}
@@ -4,6 +4,8 @@
package cmd
import (
"Wavelet/plugins/domain/auth"
"Wavelet/plugins/infra/database"
"bufio"
"context"
"crypto/rand"
@@ -13,12 +15,8 @@ import (
"os"
"strings"
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
"github.com/Rain-kl/Wavelet/internal/infra/persistence"
"github.com/Rain-kl/Wavelet/internal/infra/persistence/migrator"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/platform/bootstrap"
"github.com/Rain-kl/Wavelet/internal/repository"
userdomain "Wavelet/plugins/domain/user"
"github.com/spf13/cobra"
"gorm.io/gorm"
)
@@ -47,12 +45,14 @@ func generateRandomPassword(length int) (string, error) {
var resetPasswdCmd = &cobra.Command{
Use: "reset-passwd",
Short: "重置指定账号密码",
PreRun: func(_ *cobra.Command, _ []string) {
migrator.Migrate()
},
Run: func(_ *cobra.Command, _ []string) {
ctx := context.Background()
runBootstrap(bootstrap.Options{})
// Ensure database is initialized
dbConn := database.DB(ctx)
if dbConn != nil {
userdomain.SetDBService(database.NewService(dbConn))
}
var username string
if usernameFlag != "" {
@@ -70,7 +70,7 @@ var resetPasswdCmd = &cobra.Command{
}
}
user, err := repository.GetUserByUsername(ctx, username)
user, err := userdomain.GetUserByUsername(ctx, username)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
log.Fatalf("错误: 用户 '%s' 不存在\n", username)
@@ -92,26 +92,26 @@ var resetPasswdCmd = &cobra.Command{
log.Fatalf("加密密码失败: %v\n", err)
}
err = db.DB(ctx).Transaction(func(tx *gorm.DB) error {
err = database.DB(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&user).Update("password", user.Password).Error; err != nil {
return err
}
// Invalidate existing tokens
var tokens []model.AccessToken
var tokens []userdomain.AccessToken
if err := tx.Where("user_id = ?", user.ID).Find(&tokens).Error; err == nil {
for _, token := range tokens {
oauth.InvalidateCachedToken(ctx, token.TokenHash)
auth.InvalidateCachedToken(ctx, token.TokenHash)
}
}
return tx.Where("user_id = ?", user.ID).Delete(&model.AccessToken{}).Error
return tx.Where("user_id = ?", user.ID).Delete(&userdomain.AccessToken{}).Error
})
if err != nil {
log.Fatalf("重置密码失败: %v\n", err)
}
oauth.InvalidateCachedUser(ctx, user.ID)
auth.InvalidateCachedUser(ctx, user.ID)
fmt.Println("成功重置密码!")
fmt.Printf("用户名: %s\n", user.Username)
@@ -4,14 +4,14 @@
package cmd
import (
"Wavelet/pkg/testhelper"
"bytes"
"io"
"os"
"testing"
"time"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/testhelper"
userdomain "Wavelet/plugins/domain/user"
)
func TestResetPasswdCmd_WithUserAndPassword(t *testing.T) {
@@ -19,7 +19,7 @@ func TestResetPasswdCmd_WithUserAndPassword(t *testing.T) {
defer cleanup()
// Seed test user
user := model.User{
user := userdomain.User{
ID: 1001,
Username: "testuser1",
Nickname: "Test User 1",
@@ -33,7 +33,7 @@ func TestResetPasswdCmd_WithUserAndPassword(t *testing.T) {
}
// Create access token to test invalidation/deletion
token := model.AccessToken{
token := userdomain.AccessToken{
ID: 1,
UserID: user.ID,
Name: "testtoken",
@@ -74,7 +74,7 @@ func TestResetPasswdCmd_WithUserAndPassword(t *testing.T) {
}
// Verify password in DB
var dbUser model.User
var dbUser userdomain.User
if err := dbConn.Where("id = ?", user.ID).First(&dbUser).Error; err != nil {
t.Fatalf("failed to query user from DB: %v", err)
}
@@ -84,7 +84,7 @@ func TestResetPasswdCmd_WithUserAndPassword(t *testing.T) {
// Verify token deleted
var count int64
dbConn.Model(&model.AccessToken{}).Where("user_id = ?", user.ID).Count(&count)
dbConn.Model(&userdomain.AccessToken{}).Where("user_id = ?", user.ID).Count(&count)
if count != 0 {
t.Errorf("expected access tokens to be deleted, got %d", count)
}
@@ -95,7 +95,7 @@ func TestResetPasswdCmd_WithUserAndRandomPassword(t *testing.T) {
defer cleanup()
// Seed test user
user := model.User{
user := userdomain.User{
ID: 1002,
Username: "testuser2",
Nickname: "Test User 2",
@@ -142,7 +142,7 @@ func TestResetPasswdCmd_WithUserAndRandomPassword(t *testing.T) {
}
// Verify password in DB (should be updated and not equal to old one)
var dbUser model.User
var dbUser userdomain.User
if err := dbConn.Where("id = ?", user.ID).First(&dbUser).Error; err != nil {
t.Fatalf("failed to query user from DB: %v", err)
}
@@ -156,7 +156,7 @@ func TestResetPasswdCmd_InteractiveMode(t *testing.T) {
defer cleanup()
// Seed test user
user := model.User{
user := userdomain.User{
ID: 1003,
Username: "testuser3",
Nickname: "Test User 3",
@@ -217,7 +217,7 @@ func TestResetPasswdCmd_InteractiveMode(t *testing.T) {
}
// Verify user password changed in DB
var dbUser model.User
var dbUser userdomain.User
if err := dbConn.Where("id = ?", user.ID).First(&dbUser).Error; err != nil {
t.Fatalf("failed to query user from DB: %v", err)
}
+109
View File
@@ -0,0 +1,109 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package cmd
import (
"Wavelet/core/extpoints"
"Wavelet/pkg/buildinfo"
"Wavelet/pkg/idgen"
"Wavelet/pkg/logger"
"Wavelet/pkg/trace"
"Wavelet/plugins/infra/config"
"context"
"log"
"time"
"github.com/spf13/cobra"
)
const traceShutdownTimeout = 10 * time.Second
type hostConfig struct {
App struct {
AppName string `config:"app_name" env:"APP_NAME" default:"Wavelet"`
Env string `config:"env" env:"APP_ENV" default:"production"`
NodeID int64 `config:"node_id" env:"APP_NODE_ID" default:"1"`
Addr string `config:"addr" env:"APP_ADDR" default:"127.0.0.1:3000"`
} `config:"app"`
Log struct {
Level string `config:"level" env:"LOG_LEVEL" default:"info"`
Format string `config:"format" env:"LOG_FORMAT" default:"json"`
Output string `config:"output" env:"LOG_OUTPUT" default:"stdout"`
FilePath string `config:"file_path" env:"LOG_FILE_PATH" default:"./logs/app.log"`
MaxSize int `config:"max_size" env:"LOG_MAX_SIZE" default:"100"`
MaxAge int `config:"max_age" env:"LOG_MAX_AGE" default:"30"`
MaxBackups int `config:"max_backups" env:"LOG_MAX_BACKUPS" default:"10"`
Compress bool `config:"compress" env:"LOG_COMPRESS" default:"true"`
} `config:"log"`
OTel struct {
SamplingRate float64 `config:"sampling_rate" env:"OTEL_SAMPLING_RATE" default:"1.0"`
TracerName string `config:"tracer_name" env:"OTEL_TRACER_NAME" default:"github.com/Rain-kl/Wavelet"`
} `config:"otel"`
}
var rootCmd = &cobra.Command{
Use: "wavelet",
PersistentPreRun: func(_ *cobra.Command, _ []string) {
src, err := config.NewSource()
if err != nil {
log.Fatalf("[CMD] load config source failed: %v", err)
}
var cfg hostConfig
reg := extpoints.NewConfigRegistry(src)
_ = reg.Declare("host", extpoints.ConfigBinding{Target: &cfg})
if err := reg.Resolve(); err != nil {
log.Fatalf("[CMD] resolve host config failed: %v", err)
}
_ = reg.Bind("", &cfg)
// Initialize idgen snowflake generator
if err := idgen.Init(cfg.App.NodeID); err != nil {
log.Fatalf("[CMD] init idgen failed: %v", err)
}
logger.Init(logger.Config{
Level: cfg.Log.Level,
Format: cfg.Log.Format,
Output: cfg.Log.Output,
FilePath: cfg.Log.FilePath,
MaxSize: cfg.Log.MaxSize,
MaxAge: cfg.Log.MaxAge,
MaxBackups: cfg.Log.MaxBackups,
Compress: cfg.Log.Compress,
})
trace.Init(trace.Config{
AppName: cfg.App.AppName,
SamplingRate: cfg.OTel.SamplingRate,
TracerName: cfg.OTel.TracerName,
})
},
PersistentPostRun: func(_ *cobra.Command, _ []string) {
shutdownTraceProvider()
},
Run: func(_ *cobra.Command, args []string) {
// 无参数时默认以融合模式启动所有服务
allCmd.Run(allCmd, args)
},
}
func shutdownTraceProvider() {
ctx, cancel := context.WithTimeout(context.Background(), traceShutdownTimeout)
defer cancel()
trace.Shutdown(ctx)
}
func init() {
rootCmd.Version = buildinfo.Version
rootCmd.CompletionOptions.DisableDefaultCmd = true
// 集中将子命令注册到根命令,以解决 Cobra 的 unknown command 校验限制
rootCmd.AddCommand(allCmd, apiCmd, workerCmd, schedulerCmd)
}
// Execute 执行根命令
func Execute() {
if err := rootCmd.Execute(); err != nil {
log.Fatalf("[CMD] execute failed; %s\n", err)
}
}
+18
View File
@@ -0,0 +1,18 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package cmd
import (
"Wavelet/core"
"github.com/spf13/cobra"
)
var schedulerCmd = &cobra.Command{
Use: "scheduler",
Short: "wavelet Scheduler",
Run: func(_ *cobra.Command, _ []string) {
runProfileApp(core.ProfileSchedule, "scheduler", false)
},
}
+771
View File
@@ -0,0 +1,771 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package cmd
import (
"bytes"
"context"
"database/sql"
"fmt"
"io"
"net"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"testing"
"time"
"Wavelet/core"
"github.com/glebarez/sqlite"
"gorm.io/driver/postgres"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
const (
goldenRoot = "/Users/ryan/Code/Go/OpenFlare"
goldCommit = "9f79fb99"
goldGooseVersion = int64(202608090003)
sampleZoneDomain = "l3-upgrade-golden.example"
goldMigrateWait = 75 * time.Second
legacyPluginStamp = "openflare/legacy"
serverPluginStamp = "server"
)
var (
goldBinOnce sync.Once
goldBinPath string
goldSrcDir string
goldBinErr error
)
func TestUpgradeFromGolden(t *testing.T) {
t.Run("sqlite", func(t *testing.T) {
tmp := t.TempDir()
dbPath := filepath.Join(tmp, "a.db")
runGoldenAPI(t, tmp, goldSQLiteEnv(t, tmp, dbPath), func() bool {
return sqliteReady(dbPath)
})
assertUpgradeFromGolden(t, upgradeDB{
sqlitePath: dbPath,
source: cordisSQLiteSource(t, dbPath),
})
})
}
func TestUpgradePostgresFromGolden(t *testing.T) {
dsn := strings.TrimSpace(os.Getenv("TEST_PG_DSN"))
if dsn == "" {
t.Skip("TEST_PG_DSN is not set")
}
host, port, user, pass, adminDB, sslMode := parsePostgresDSN(t, dsn)
adminDSN := postgresDSN(host, port, user, pass, adminDB, sslMode)
admin := openInspectDB(t, "", adminDSN)
t.Cleanup(func() { _ = admin.Close() })
dbName := fmt.Sprintf("of_l3_%d", time.Now().UnixNano())
if !safePGIdent(dbName) {
t.Fatalf("generated database name %q is not a safe identifier", dbName)
}
if _, err := admin.Exec("CREATE DATABASE " + dbName); err != nil {
t.Fatalf("CREATE DATABASE %s: %v", dbName, err)
}
t.Cleanup(func() {
_, _ = admin.Exec(`SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, dbName)
_, _ = admin.Exec("DROP DATABASE IF EXISTS " + dbName)
})
tmp := t.TempDir()
testDSN := postgresDSN(host, port, user, pass, dbName, sslMode)
runGoldenAPI(t, tmp, goldPostgresEnv(t, tmp, host, port, user, pass, dbName, sslMode), func() bool {
return postgresReady(testDSN)
})
assertUpgradeFromGolden(t, upgradeDB{
pgDSN: testDSN,
source: cordisPostgresSource(t, host, port, user, pass, dbName, sslMode),
})
}
func TestUpgradePostgresFromExistingDump(t *testing.T) {
dsn := strings.TrimSpace(os.Getenv("TEST_PG_EXISTING_DSN"))
if dsn == "" {
t.Skip("TEST_PG_EXISTING_DSN is not set")
}
host, port, user, pass, dbName, sslMode := parsePostgresDSN(t, dsn)
spec := upgradeDB{
pgDSN: dsn,
source: cordisPostgresSource(t, host, port, user, pass, dbName, sslMode),
}
inspect := openInspectDB(t, "", spec.pgDSN)
beforeCounts := countNamedTables(t, inspect, productionCountTables)
beforeTables := listPublicTables(t, inspect)
_ = inspect.Close()
assertUpgradeFromGolden(t, spec)
inspect = openInspectDB(t, "", spec.pgDSN)
defer func() { _ = inspect.Close() }()
afterCounts := countNamedTables(t, inspect, productionCountTables)
for _, name := range productionCountTables {
if afterCounts[name] < beforeCounts[name] {
t.Errorf("row count dropped for %s: before %d after %d", name, beforeCounts[name], afterCounts[name])
}
}
afterTables := listPublicTables(t, inspect)
for name := range beforeTables {
if !afterTables[name] {
t.Errorf("table %s dropped", name)
}
}
for _, name := range []string{"w_schema_versions", "w_message_channels", "w_message_bindings", "w_message_pairing_codes"} {
if !afterTables[name] {
t.Errorf("expected upgrade to create %s", name)
}
}
var n int
if err := inspect.QueryRow(`SELECT COUNT(*) FROM pg_inherits i JOIN pg_class c ON c.oid = i.inhparent WHERE c.relname IN ('of_node_access_logs', 'w_user_access_logs')`).Scan(&n); err != nil {
t.Fatalf("count partitions: %v", err)
}
if n < 8 {
t.Errorf("partition children = %d, want at least 8", n)
}
}
var productionCountTables = []string{
"of_zones", "of_zone_domains", "of_proxy_routes", "of_nodes", "of_origins",
"of_tls_certificates", "of_waf_rule_groups", "of_pages_projects",
"w_users", "w_schedules", "w_system_configs", "w_templates", "w_uploads",
"of_node_access_logs", "w_user_access_logs",
}
func countNamedTables(t *testing.T, db *sql.DB, tables []string) map[string]int {
t.Helper()
out := make(map[string]int, len(tables))
for _, name := range tables {
if !safePGIdent(name) {
t.Fatalf("unsafe table name %q", name)
}
var n int
if err := db.QueryRow("SELECT COUNT(*) FROM " + name).Scan(&n); err != nil {
t.Fatalf("count %s: %v", name, err)
}
out[name] = n
}
return out
}
func listPublicTables(t *testing.T, db *sql.DB) map[string]bool {
t.Helper()
rows, err := db.Query(`SELECT tablename FROM pg_tables WHERE schemaname = 'public'`)
if err != nil {
t.Fatalf("list public tables: %v", err)
}
defer func() { _ = rows.Close() }()
out := make(map[string]bool)
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
t.Fatalf("scan table name: %v", err)
}
out[name] = true
}
if err := rows.Err(); err != nil {
t.Fatalf("list public tables: %v", err)
}
return out
}
type upgradeDB struct {
sqlitePath string
pgDSN string
source core.ConfigSource
}
func assertUpgradeFromGolden(t *testing.T, spec upgradeDB) {
t.Helper()
inspect := openInspectDB(t, spec.sqlitePath, spec.pgDSN)
before := dumpOfSchema(t, inspect, spec.pgDSN != "")
insertSQL := `INSERT INTO of_zones (domain) VALUES (?)`
if spec.pgDSN != "" {
insertSQL = `INSERT INTO of_zones (domain) VALUES ($1)`
}
if _, err := inspect.Exec(insertSQL, sampleZoneDomain); err != nil {
t.Fatalf("insert sample of_zones row: %v", err)
}
_ = inspect.Close()
app := cordisPrepare(t, spec.source)
legacyRows := schemaPluginRows(t, spec, legacyPluginStamp)
assertStampedUpgrade(t, spec, before, legacyRows)
if err := app.Context().Dispose(); err != nil {
t.Fatalf("dispose first app: %v", err)
}
app2 := cordisPrepare(t, spec.source)
t.Cleanup(func() { _ = app2.Context().Dispose() })
if got := schemaPluginRows(t, spec, legacyPluginStamp); got != legacyRows {
t.Fatalf("second Prepare increased %s rows: got %d, want %d", legacyPluginStamp, got, legacyRows)
}
assertStampedUpgrade(t, spec, before, legacyRows)
}
func cordisPrepare(t *testing.T, src core.ConfigSource) *core.App {
t.Helper()
app := newOpenFlareApp(core.ProfileAPI, core.WithConfigSource(src))
if err := app.Prepare(); err != nil {
t.Fatalf("Prepare: %v", err)
}
if err := app.ApplyPlugins(); err != nil {
t.Fatalf("ApplyPlugins: %v", err)
}
if err := app.RunMigrations(); err != nil {
t.Fatalf("RunMigrations: %v", err)
}
return app
}
func assertStampedUpgrade(t *testing.T, spec upgradeDB, before map[string][]string, legacyRows int) {
t.Helper()
db := openInspectDB(t, spec.sqlitePath, spec.pgDSN)
defer func() { _ = db.Close() }()
postgres := spec.pgDSN != ""
if got := gooseMaxVersion(t, db); got != goldGooseVersion {
t.Errorf("goose_db_version max = %d, want %d", got, goldGooseVersion)
}
if legacyRows < 2 {
t.Errorf("w_schema_versions %s rows = %d, want at least 2 (0 and %d)", legacyPluginStamp, legacyRows, goldGooseVersion)
}
if !pluginHasVersion(t, db, postgres, legacyPluginStamp, 0) {
t.Errorf("missing w_schema_versions (%s, 0)", legacyPluginStamp)
}
if !pluginHasVersion(t, db, postgres, legacyPluginStamp, goldGooseVersion) {
t.Errorf("missing w_schema_versions (%s, %d)", legacyPluginStamp, goldGooseVersion)
}
if !pluginHasVersion(t, db, postgres, serverPluginStamp, 1) {
t.Errorf("missing w_schema_versions (%s, 1)", serverPluginStamp)
}
var domain string
q := `SELECT domain FROM of_zones WHERE domain = ?`
if postgres {
q = `SELECT domain FROM of_zones WHERE domain = $1`
}
if err := db.QueryRow(q, sampleZoneDomain).Scan(&domain); err != nil {
t.Errorf("sample of_zones row missing after upgrade: %v", err)
}
after := dumpOfSchema(t, db, postgres)
for table, cols := range before {
got, ok := after[table]
if !ok {
t.Errorf("of_* table %s dropped", table)
continue
}
have := make(map[string]bool, len(got))
for _, c := range got {
have[c] = true
}
for _, c := range cols {
if !have[c] {
t.Errorf("of_* column %s.%s dropped", table, c)
}
}
}
}
func runGoldenAPI(t *testing.T, workDir string, env []string, ready func() bool) {
t.Helper()
bin := buildGoldenBinary(t)
ctx, cancel := context.WithTimeout(context.Background(), goldMigrateWait)
defer cancel()
cmd := exec.CommandContext(ctx, bin, "api")
cmd.Dir = workDir
cmd.Env = env
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
if err := cmd.Start(); err != nil {
t.Fatalf("start golden api: %v", err)
}
waitErr := make(chan error, 1)
go func() { waitErr <- cmd.Wait() }()
ticker := time.NewTicker(200 * time.Millisecond)
defer ticker.Stop()
for {
if ready() {
killGolden(cmd)
<-waitErr
return
}
select {
case err := <-waitErr:
if ready() {
return
}
t.Fatalf("golden api exited before goose %d: %v\n%s", goldGooseVersion, err, out.String())
case <-ctx.Done():
killGolden(cmd)
<-waitErr
t.Fatalf("timeout waiting for golden goose %d\n%s", goldGooseVersion, out.String())
case <-ticker.C:
}
}
}
func killGolden(cmd *exec.Cmd) {
if cmd.Process == nil {
return
}
_ = cmd.Process.Kill()
}
func buildGoldenBinary(t *testing.T) string {
t.Helper()
goldBinOnce.Do(func() {
src, err := os.MkdirTemp("", "of-gold-src-")
if err != nil {
goldBinErr = err
return
}
archive := exec.Command("git", "-C", goldenRoot, "archive", goldCommit)
extract := exec.Command("tar", "-x", "-C", src)
pipe, err := archive.StdoutPipe()
if err != nil {
goldBinErr = fmt.Errorf("gold archive pipe: %w", err)
return
}
extract.Stdin = pipe
var archiveErr, extractErr bytes.Buffer
archive.Stderr = &archiveErr
extract.Stderr = &extractErr
if err := archive.Start(); err != nil {
goldBinErr = fmt.Errorf("git archive %s: %w", goldCommit, err)
return
}
if err := extract.Start(); err != nil {
_ = archive.Process.Kill()
goldBinErr = fmt.Errorf("extract gold %s: %w", goldCommit, err)
return
}
if err := extract.Wait(); err != nil {
_ = archive.Wait()
goldBinErr = fmt.Errorf("extract gold %s: %w\n%s", goldCommit, err, extractErr.String())
return
}
if err := archive.Wait(); err != nil {
goldBinErr = fmt.Errorf("git archive %s: %w\n%s", goldCommit, err, archiveErr.String())
return
}
if _, err := os.Stat(filepath.Join(src, "main.go")); err != nil {
goldBinErr = fmt.Errorf("gold %s at %s: %w", goldCommit, src, err)
return
}
goldSrcDir = src
dir, err := os.MkdirTemp("", "of-gold-bin-")
if err != nil {
goldBinErr = err
return
}
out := filepath.Join(dir, "gold")
cmd := exec.Command("go", "build", "-o", out, ".")
cmd.Dir = src
var buf bytes.Buffer
cmd.Stdout = &buf
cmd.Stderr = &buf
if err := cmd.Run(); err != nil {
goldBinErr = fmt.Errorf("go build golden %s: %w\n%s", goldCommit, err, buf.String())
return
}
goldBinPath = out
})
if goldBinErr != nil {
t.Fatalf("%v", goldBinErr)
}
return goldBinPath
}
func copyGoldConfig(t *testing.T, dir string) string {
t.Helper()
buildGoldenBinary(t)
dst := filepath.Join(dir, "config.yaml")
src, err := os.Open(filepath.Join(goldSrcDir, "config.example.yaml")) //nolint:gosec // extracted gold snapshot
if err != nil {
t.Fatalf("open golden config.example.yaml: %v", err)
}
defer func() { _ = src.Close() }()
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) //nolint:gosec // test temp file
if err != nil {
t.Fatalf("create temp config.yaml: %v", err)
}
if _, err := io.Copy(out, src); err != nil {
_ = out.Close()
t.Fatalf("copy golden config: %v", err)
}
if err := out.Close(); err != nil {
t.Fatalf("close temp config.yaml: %v", err)
}
return dst
}
func goldSQLiteEnv(t *testing.T, dir, dbPath string) []string {
t.Helper()
cfg := copyGoldConfig(t, dir)
addr := freeLocalAddr(t)
return filteredGoldEnv(
"CONFIG_PATH="+cfg,
"SQLITE_PATH="+dbPath,
"DB_ENABLED=false",
"REDIS_ENABLED=false",
"CLICKHOUSE_ENABLED=false",
"APP_ENV=testing",
"APP_ADDR="+addr,
)
}
func goldPostgresEnv(t *testing.T, dir, host string, port int, user, pass, dbName, sslMode string) []string {
t.Helper()
cfg := copyGoldConfig(t, dir)
addr := freeLocalAddr(t)
return filteredGoldEnv(
"CONFIG_PATH="+cfg,
"DB_ENABLED=true",
"DB_HOST="+host,
"DB_PORT="+strconv.Itoa(port),
"DB_USERNAME="+user,
"DB_PASSWORD="+pass,
"DB_NAME="+dbName,
"DB_SSL_MODE="+sslMode,
"REDIS_ENABLED=false",
"CLICKHOUSE_ENABLED=false",
"APP_ENV=testing",
"APP_ADDR="+addr,
)
}
func filteredGoldEnv(extra ...string) []string {
drop := map[string]bool{
"CONFIG_PATH": true,
"SQLITE_PATH": true,
"DB_ENABLED": true,
"DB_HOST": true,
"DB_PORT": true,
"DB_USERNAME": true,
"DB_PASSWORD": true,
"DB_NAME": true,
"DB_SSL_MODE": true,
"REDIS_ENABLED": true,
"REDIS_ADDR": true,
"CLICKHOUSE_ENABLED": true,
"CLICKHOUSE_HOST": true,
"APP_ENV": true,
"APP_ADDR": true,
}
env := make([]string, 0, len(os.Environ())+len(extra))
for _, kv := range os.Environ() {
k, _, _ := strings.Cut(kv, "=")
if drop[k] {
continue
}
env = append(env, kv)
}
return append(env, extra...)
}
func freeLocalAddr(t *testing.T) string {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen for free port: %v", err)
}
addr := ln.Addr().String()
_ = ln.Close()
return addr
}
func cordisSQLiteSource(t *testing.T, dbPath string) core.ConfigSource {
t.Helper()
return core.NewMapSource(map[string]any{
"app": map[string]any{
"addr": "127.0.0.1:0",
"env": "testing",
},
"redis": map[string]any{
"enabled": false,
},
"clickhouse": map[string]any{
"enabled": false,
},
"database": map[string]any{
"enabled": false,
"sqlite_path": dbPath,
},
})
}
func cordisPostgresSource(t *testing.T, host string, port int, user, pass, dbName, sslMode string) core.ConfigSource {
t.Helper()
return core.NewMapSource(map[string]any{
"app": map[string]any{
"addr": "127.0.0.1:0",
"env": "testing",
},
"redis": map[string]any{
"enabled": false,
},
"clickhouse": map[string]any{
"enabled": false,
},
"database": map[string]any{
"enabled": true,
"host": host,
"port": port,
"username": user,
"password": pass,
"database": dbName,
"ssl_mode": sslMode,
},
})
}
func sqliteReady(path string) bool {
if _, err := os.Stat(path); err != nil {
return false
}
gdb, err := gorm.Open(sqlite.Open("file:"+path+"?mode=ro&_pragma=busy_timeout(1000)"), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)})
if err != nil {
return false
}
sqlDB, err := gdb.DB()
if err != nil {
return false
}
defer func() { _ = sqlDB.Close() }()
return migratedReady(sqlDB, false)
}
func postgresReady(dsn string) bool {
gdb, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)})
if err != nil {
return false
}
sqlDB, err := gdb.DB()
if err != nil {
return false
}
defer func() { _ = sqlDB.Close() }()
return migratedReady(sqlDB, true)
}
func migratedReady(db *sql.DB, postgres bool) bool {
if gooseMaxVersionSilent(db) != goldGooseVersion {
return false
}
var n int
var err error
if postgres {
err = db.QueryRow(`SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'of_nodes'`).Scan(&n)
} else {
err = db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'of_nodes'`).Scan(&n)
}
return err == nil && n > 0
}
func openInspectDB(t *testing.T, sqlitePath, pgDSN string) *sql.DB {
t.Helper()
var gdb *gorm.DB
var err error
if pgDSN != "" {
gdb, err = gorm.Open(postgres.Open(pgDSN), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)})
} else {
gdb, err = gorm.Open(sqlite.Open(sqlitePath), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)})
}
if err != nil {
t.Fatalf("open inspect db: %v", err)
}
sqlDB, err := gdb.DB()
if err != nil {
t.Fatalf("inspect sql.DB: %v", err)
}
return sqlDB
}
func dumpOfSchema(t *testing.T, db *sql.DB, postgres bool) map[string][]string {
t.Helper()
tables := ofTables(t, db, postgres)
out := make(map[string][]string, len(tables))
for _, table := range tables {
out[table] = ofColumns(t, db, postgres, table)
}
if len(out) == 0 {
t.Fatal("no of_* tables in golden database")
}
return out
}
func ofTables(t *testing.T, db *sql.DB, postgres bool) []string {
t.Helper()
var rows *sql.Rows
var err error
if postgres {
rows, err = db.Query(`SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename LIKE 'of_%' ORDER BY tablename`)
} else {
rows, err = db.Query(`SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'of_%' ORDER BY name`)
}
if err != nil {
t.Fatalf("list of_* tables: %v", err)
}
defer func() { _ = rows.Close() }()
var tables []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
t.Fatalf("scan of_* table: %v", err)
}
tables = append(tables, name)
}
if err := rows.Err(); err != nil {
t.Fatalf("list of_* tables: %v", err)
}
return tables
}
func ofColumns(t *testing.T, db *sql.DB, postgres bool, table string) []string {
t.Helper()
var rows *sql.Rows
var err error
if postgres {
rows, err = db.Query(`SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1 ORDER BY ordinal_position`, table)
} else {
rows, err = db.Query(`SELECT name FROM pragma_table_info(?)`, table)
}
if err != nil {
t.Fatalf("list columns for %s: %v", table, err)
}
defer func() { _ = rows.Close() }()
var cols []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
t.Fatalf("scan column for %s: %v", table, err)
}
cols = append(cols, name)
}
if err := rows.Err(); err != nil {
t.Fatalf("list columns for %s: %v", table, err)
}
return cols
}
func gooseMaxVersion(t *testing.T, db *sql.DB) int64 {
t.Helper()
v := gooseMaxVersionSilent(db)
if v < 0 {
t.Fatal("read goose_db_version max failed")
}
return v
}
func gooseMaxVersionSilent(db *sql.DB) int64 {
var v int64
if err := db.QueryRow(`SELECT COALESCE(MAX(version_id), 0) FROM goose_db_version`).Scan(&v); err != nil {
return -1
}
return v
}
func schemaPluginRows(t *testing.T, spec upgradeDB, pluginID string) int {
t.Helper()
db := openInspectDB(t, spec.sqlitePath, spec.pgDSN)
defer func() { _ = db.Close() }()
q := `SELECT COUNT(*) FROM w_schema_versions WHERE plugin_id = ?`
if spec.pgDSN != "" {
q = `SELECT COUNT(*) FROM w_schema_versions WHERE plugin_id = $1`
}
var n int
if err := db.QueryRow(q, pluginID).Scan(&n); err != nil {
t.Fatalf("count w_schema_versions %s: %v", pluginID, err)
}
return n
}
func pluginHasVersion(t *testing.T, db *sql.DB, postgres bool, pluginID string, version int64) bool {
t.Helper()
q := `SELECT COUNT(*) FROM w_schema_versions WHERE plugin_id = ? AND version_id = ?`
if postgres {
q = `SELECT COUNT(*) FROM w_schema_versions WHERE plugin_id = $1 AND version_id = $2`
}
var n int
if err := db.QueryRow(q, pluginID, version).Scan(&n); err != nil {
t.Fatalf("lookup w_schema_versions (%s, %d): %v", pluginID, version, err)
}
return n > 0
}
func parsePostgresDSN(t *testing.T, dsn string) (host string, port int, user, pass, dbName, sslMode string) {
t.Helper()
u, err := url.Parse(dsn)
if err != nil {
t.Fatalf("TEST_PG_DSN: %v", err)
}
host = u.Hostname()
if host == "" {
host = "127.0.0.1"
}
port = 5432
if p := u.Port(); p != "" {
port, err = strconv.Atoi(p)
if err != nil {
t.Fatalf("TEST_PG_DSN port: %v", err)
}
}
if u.User != nil {
user = u.User.Username()
pass, _ = u.User.Password()
}
dbName = strings.Trim(u.Path, "/")
if dbName == "" {
dbName = "postgres"
}
sslMode = u.Query().Get("sslmode")
if sslMode == "" {
sslMode = "disable"
}
return
}
func postgresDSN(host string, port int, user, pass, dbName, sslMode string) string {
u := &url.URL{
Scheme: "postgres",
Host: net.JoinHostPort(host, strconv.Itoa(port)),
Path: dbName,
}
if user != "" {
u.User = url.UserPassword(user, pass)
}
q := url.Values{}
q.Set("sslmode", sslMode)
u.RawQuery = q.Encode()
return u.String()
}
var pgIdent = regexp.MustCompile(`^[a-z_][a-z0-9_]*$`)
func safePGIdent(name string) bool {
return pgIdent.MatchString(name)
}
+18
View File
@@ -0,0 +1,18 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package cmd
import (
"Wavelet/core"
"github.com/spf13/cobra"
)
var workerCmd = &cobra.Command{
Use: "worker",
Short: "wavelet Worker",
Run: func(_ *cobra.Command, _ []string) {
runProfileApp(core.ProfileWorker, "worker", false)
},
}
+707
View File
@@ -0,0 +1,707 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package core
import (
"context"
"errors"
"fmt"
"os"
"os/signal"
"strings"
"sync"
"syscall"
"time"
)
const (
defaultShutdownTimeout = 10 * time.Second
)
// AppOption configures an App instance during construction.
type AppOption func(*App)
// WithContext sets a custom root Context for the App.
func WithContext(ctx *Context) AppOption {
return func(a *App) {
if ctx != nil {
a.ctx = ctx
}
}
}
// WithProfile sets the runtime profile for the App.
func WithProfile(profile Profile) AppOption {
return func(a *App) {
a.profile = normalizeProfile(profile)
}
}
// WithPlugins registers initial plugins for the App.
func WithPlugins(plugins ...Plugin) AppOption {
return func(a *App) {
a.Use(plugins...)
}
}
// WithMigrationEngine sets the database migration engine for the App.
func WithMigrationEngine(engine MigrationEngine) AppOption {
return func(a *App) {
a.migrationEngine = engine
}
}
// WithMigrationRunner sets the migration runner function for the App.
func WithMigrationRunner(runner MigrationRunner) AppOption {
return func(a *App) {
a.migrationEngine = runner
}
}
// WithMigrationBaseline registers a hook the migration engine runs after the
// shared version table exists and before any plugin Up.
func WithMigrationBaseline(fn func(*Context) error) AppOption {
return func(a *App) {
a.migrationBaseline = fn
}
}
// WithShutdownTimeout sets the fallback timeout for graceful application shutdown.
func WithShutdownTimeout(timeout time.Duration) AppOption {
return func(a *App) {
if timeout > 0 {
a.shutdownTimeout = timeout
}
}
}
// WithConfigSource installs the raw configuration source adapter, typically built by an
// infrastructure package outside the kernel, before any plugin is applied.
func WithConfigSource(src ConfigSource) AppOption {
return func(a *App) {
if src == nil {
return
}
// Installed during Prepare so the option order, including WithContext, is irrelevant.
a.configSource = src
}
}
// WithConfigDecl lets the composition root declare the configuration it reads itself,
// so host-level values take part in conflict validation and the redacted report. The
// bindings are registered during Prepare, so option order does not matter.
func WithConfigDecl(pluginID string, bindings ...ConfigBinding) AppOption {
return func(a *App) {
if len(bindings) == 0 {
return
}
if a.hostDeclOwner == "" {
a.hostDeclOwner = pluginID
}
a.hostDeclBindings = append(a.hostDeclBindings, bindings...)
}
}
// App is the unified assembly entrypoint and runtime aspect dispatcher of the Cordis micro-kernel.
// It manages plugin collection, dependency mounting, migration execution, profile-based driver startup,
// and graceful signal-driven LIFO shutdown.
type App struct {
mu sync.RWMutex
ctx *Context
profile Profile
plugins []Plugin
pluginMap map[string]Plugin
fibers []*Fiber
fiberMap map[string]*Fiber
applied bool
running bool
startedDrivers []Driver
migrationEngine MigrationEngine
migrationBaseline func(*Context) error
shutdownTimeout time.Duration
configSource ConfigSource
hostDeclOwner string
hostDeclBindings []ConfigBinding
prepared bool
applyErr error
}
// NewApp creates a new Cordis application instance with default options.
func NewApp(opts ...AppOption) *App {
app := &App{
ctx: NewContext(context.Background()),
profile: ProfileAll,
pluginMap: make(map[string]Plugin),
fiberMap: make(map[string]*Fiber),
shutdownTimeout: defaultShutdownTimeout,
}
for _, opt := range opts {
if opt != nil {
opt(app)
}
}
return app
}
// Context returns the root micro-kernel Context of the application.
func (a *App) Context() *Context {
return a.ctx
}
// Profile returns the current runtime profile of the application.
func (a *App) Profile() Profile {
a.mu.RLock()
defer a.mu.RUnlock()
return a.profile
}
// WithProfile sets the application runtime profile and returns the App for fluent chaining.
func (a *App) WithProfile(profile Profile) *App {
a.mu.Lock()
defer a.mu.Unlock()
a.profile = normalizeProfile(profile)
return a
}
// SetProfile sets the application runtime profile.
func (a *App) SetProfile(profile Profile) *App {
return a.WithProfile(profile)
}
// Use registers one or more plugins into the application in registration order.
// Duplicate plugins (by Name) update existing registrations in-place to preserve order.
func (a *App) Use(plugins ...Plugin) *App {
a.mu.Lock()
defer a.mu.Unlock()
for _, p := range plugins {
if p == nil {
continue
}
name := p.Name()
if name == "" {
continue
}
if _, exists := a.pluginMap[name]; exists {
for i, existing := range a.plugins {
if existing.Name() == name {
a.plugins[i] = p
break
}
}
if existingFiber, ok := a.fiberMap[name]; ok {
existingFiber.plugin = p
}
} else {
a.plugins = append(a.plugins, p)
f := NewFiber(a.ctx, p)
a.fibers = append(a.fibers, f)
a.fiberMap[name] = f
}
a.pluginMap[name] = p
if gated, ok := p.(ConfigGatedPlugin); ok && a.applyErr == nil {
// Gates are evaluated before Apply, so their keys must be declared at mount time.
a.applyErr = a.ctx.Config().Declare(name, gated.DeclareConfig()...)
}
}
return a
}
// Plugins returns a copy of all registered plugins in registration order.
func (a *App) Plugins() []Plugin {
a.mu.RLock()
defer a.mu.RUnlock()
res := make([]Plugin, len(a.plugins))
copy(res, a.plugins)
return res
}
// Plugin retrieves a registered plugin by its unique name.
func (a *App) Plugin(name string) (Plugin, bool) {
a.mu.RLock()
defer a.mu.RUnlock()
p, ok := a.pluginMap[name]
return p, ok
}
// Fibers returns a copy of all plugin Fibers.
func (a *App) Fibers() []*Fiber {
a.mu.RLock()
defer a.mu.RUnlock()
res := make([]*Fiber, len(a.fibers))
copy(res, a.fibers)
return res
}
// Fiber retrieves a Fiber by its unique plugin name.
func (a *App) Fiber(name string) (*Fiber, bool) {
a.mu.RLock()
defer a.mu.RUnlock()
f, ok := a.fiberMap[name]
return f, ok
}
// SetMigrationEngine sets the migration engine for the application.
func (a *App) SetMigrationEngine(engine MigrationEngine) *App {
a.mu.Lock()
defer a.mu.Unlock()
a.migrationEngine = engine
return a
}
// SetMigrationRunner sets the migration runner function for the application.
func (a *App) SetMigrationRunner(runner MigrationRunner) *App {
return a.SetMigrationEngine(runner)
}
// Reconcile evaluates all pending Fibers and reactively transitions them to ACTIVE
// as their declared dependencies become satisfied.
func (a *App) Reconcile() error {
a.mu.Lock()
defer a.mu.Unlock()
return a.reconcileLocked()
}
func (a *App) reconcileLocked() error {
if err := a.prepareLocked(); err != nil {
return err
}
for {
progress := false
for _, f := range a.fibers {
if f.State() != FiberPending {
continue
}
gated, skip, err := a.evaluateGateLocked(f)
if err != nil {
return err
}
if gated && skip {
if err := f.Skip(); err != nil {
return fmt.Errorf("core: skip gated fiber %q failed: %w", f.Name(), err)
}
continue
}
if f.DependenciesSatisfied(a.ctx) {
if err := f.Load(); err != nil {
return fmt.Errorf("core: load fiber %q failed: %w", f.Name(), err)
}
progress = true
// Rescan from the head of the Use() list so earlier pending
// plugins run before later ones that became ready in this pass.
break
}
}
if !progress {
break
}
}
var unsatisfied []string
for _, f := range a.fibers {
if f.State() == FiberPending {
unsatisfied = append(unsatisfied, fmt.Sprintf("%s (waiting for %v)", f.Name(), f.Dependencies()))
}
}
if len(unsatisfied) > 0 {
return fmt.Errorf("core: unsatisfied dependencies for plugins: %s", strings.Join(unsatisfied, ", "))
}
return nil
}
// evaluateGateLocked reports whether a configuration-gated plugin is excluded by the
// resolved values. Plugins that do not implement the gate interface are never skipped.
func (a *App) evaluateGateLocked(f *Fiber) (gated bool, skip bool, err error) {
gatedPlugin, ok := f.plugin.(ConfigGatedPlugin)
if !ok {
return false, false, nil
}
view := a.ctx.Config()
if !view.Resolved() {
return true, false, fmt.Errorf(
"core: plugin %q is configuration-gated but the App has no ConfigSource; "+
"pass core.WithConfigSource or remove DeclareConfig", f.Name())
}
return true, !gatedPlugin.ConfigEnabled(view), nil
}
// ApplyPlugins applies all registered plugins on the application Context via reactive reconciliation.
// It is idempotent and only applies plugins once per App instance.
func (a *App) ApplyPlugins() error {
a.mu.Lock()
if a.applied {
a.mu.Unlock()
return nil
}
a.applied = true
declaredErr, prepareErr := a.applyErr, a.prepareLocked()
a.mu.Unlock()
if declaredErr != nil {
return declaredErr
}
if prepareErr != nil {
return prepareErr
}
return a.Reconcile()
}
// Prepare resolves declared configuration and establishes the resolution barrier that
// gates and plugin Bind calls depend on. It is idempotent and runs implicitly from
// ApplyPlugins; callers that need resolved values earlier — for example to size a
// shutdown budget — invoke it explicitly right after mounting plugins.
func (a *App) Prepare() error {
a.mu.Lock()
defer a.mu.Unlock()
if a.applyErr != nil {
return a.applyErr
}
return a.prepareLocked()
}
// prepareLocked installs the injected source, registers host declarations and resolves
// every declared key once. An App without a ConfigSource leaves configuration unused,
// so kernel-level usage stays opt-in for embedders that configure nothing.
func (a *App) prepareLocked() error {
if a.prepared {
return nil
}
if a.configSource != nil {
config := a.ctx.Config()
config.SetSource(a.configSource)
if err := config.Declare(a.hostDeclOwner, a.hostDeclBindings...); err != nil {
return err
}
if err := config.Resolve(); err != nil {
return err
}
}
a.ctx.setMigrationBaseline(a.migrationBaseline)
a.prepared = true
return nil
}
// ShutdownTimeout returns the graceful shutdown budget for the application.
func (a *App) ShutdownTimeout() time.Duration {
a.mu.RLock()
defer a.mu.RUnlock()
return a.shutdownTimeout
}
// SetShutdownTimeout replaces the graceful shutdown budget, ignoring non-positive
// values so a missing configuration key can never shrink the kernel fallback to zero.
func (a *App) SetShutdownTimeout(timeout time.Duration) *App {
a.mu.Lock()
defer a.mu.Unlock()
if timeout > 0 {
a.shutdownTimeout = timeout
}
return a
}
// RunMigrations dispatches migration execution across all registered plugin migration entries.
func (a *App) RunMigrations() error {
entries := a.ctx.Migrations().Entries()
if len(entries) == 0 {
return nil
}
a.mu.RLock()
engine := a.migrationEngine
a.mu.RUnlock()
if engine == nil {
// Attempt to resolve from IoC container
if resolved, err := Inject[MigrationEngine](a.ctx); err == nil && resolved != nil {
engine = resolved
}
}
if engine == nil {
return nil
}
if err := engine.Migrate(a.ctx, entries); err != nil {
return fmt.Errorf("core: migration failed: %w", err)
}
return nil
}
// Start executes the application boot pipeline:
// 1. Applies all registered plugins to populate services, routes, tasks, and drivers.
// 2. Dispatches database migrations via MigrationEngine.
// 3. Filters and starts drivers matching the active Profile.
// 4. Emits "app:ready" on the EventBus.
func (a *App) Start(ctx ...context.Context) error {
a.mu.Lock()
if a.running {
a.mu.Unlock()
return ErrAppRunning
}
a.running = true
a.mu.Unlock()
var baseCtx context.Context
switch {
case len(ctx) > 0 && ctx[0] != nil:
baseCtx = ctx[0]
case a.ctx != nil:
baseCtx = a.ctx.GoContext()
default:
baseCtx = context.Background()
}
// 1. Apply plugins
if err := a.ApplyPlugins(); err != nil {
a.mu.Lock()
a.running = false
a.mu.Unlock()
return err
}
// 2. Run migrations
if err := a.RunMigrations(); err != nil {
a.mu.Lock()
a.running = false
a.mu.Unlock()
return err
}
// 3. Filter drivers matching active profile
a.mu.RLock()
prof := a.profile
a.mu.RUnlock()
allDrivers := a.ctx.Drivers()
var driversToStart []Driver
for _, d := range allDrivers {
if matchesProfile(prof, d.Type()) {
driversToStart = append(driversToStart, d)
}
}
// 4. Start matching drivers
for _, d := range driversToStart {
if err := d.Start(baseCtx); err != nil {
// Rollback already started drivers in reverse order
a.mu.Lock()
started := a.startedDrivers
a.startedDrivers = nil
a.running = false
a.mu.Unlock()
for i := len(started) - 1; i >= 0; i-- {
_ = started[i].Stop(context.Background())
}
return fmt.Errorf("core: start driver %s failed: %w", d.Type(), err)
}
a.mu.Lock()
a.startedDrivers = append(a.startedDrivers, d)
a.mu.Unlock()
}
// 5. Emit app:ready event
_ = a.ctx.Events().Emit(baseCtx, "app:ready", a)
return nil
}
// Stop gracefully shuts down the application:
// 1. Emits "app:stopping" on the EventBus.
// 2. Stops all started drivers in LIFO (reverse) order.
// 3. Disposes the Context (running registered OnDispose callbacks in LIFO order).
// 4. Emits "app:stopped" on the EventBus.
func (a *App) Stop(ctx ...context.Context) error {
a.mu.Lock()
if !a.running {
a.mu.Unlock()
return nil
}
a.running = false
started := a.startedDrivers
a.startedDrivers = nil
timeout := a.shutdownTimeout
a.mu.Unlock()
var shutdownCtx context.Context
if len(ctx) > 0 && ctx[0] != nil {
shutdownCtx = ctx[0]
} else {
var cancel context.CancelFunc
shutdownCtx, cancel = context.WithTimeout(context.Background(), timeout)
defer cancel()
}
_ = a.ctx.Events().Emit(shutdownCtx, "app:stopping", a)
var errs []error
// 1. Stop drivers in reverse order
for i := len(started) - 1; i >= 0; i-- {
d := started[i]
if err := d.Stop(shutdownCtx); err != nil {
errs = append(errs, fmt.Errorf("core: stop driver %s failed: %w", d.Type(), err))
}
}
// 2. Unload fibers in reverse order
a.mu.RLock()
fibers := make([]*Fiber, len(a.fibers))
copy(fibers, a.fibers)
a.mu.RUnlock()
for i := len(fibers) - 1; i >= 0; i-- {
if err := fibers[i].Unload(); err != nil {
errs = append(errs, fmt.Errorf("core: unload fiber %s failed: %w", fibers[i].Name(), err))
}
}
// 3. Dispose root context
if a.ctx != nil && !a.ctx.IsDisposed() {
if err := a.ctx.Dispose(); err != nil {
errs = append(errs, fmt.Errorf("core: dispose context failed: %w", err))
}
}
_ = a.ctx.Events().Emit(shutdownCtx, "app:stopped", a)
return errors.Join(errs...)
}
// Run starts the application and blocks until an OS signal (SIGINT, SIGTERM) or context cancellation is received,
// then executes graceful shutdown. It forwards a sigCtx derived from the caller's context to Start.
//
//nolint:contextcheck // the caller's ctx does reach Start via sigCtx; the rule cannot follow Run's variadic context parameter
func (a *App) Run(ctx ...context.Context) error {
var parent context.Context
switch {
case len(ctx) > 0 && ctx[0] != nil:
parent = ctx[0]
case a.ctx != nil:
parent = a.ctx.GoContext()
default:
parent = context.Background()
}
sigCtx, stopSignals := signal.NotifyContext(parent, syscall.SIGINT, syscall.SIGTERM, os.Interrupt)
defer stopSignals()
if err := a.Start(sigCtx); err != nil {
return err
}
// Wait for OS signal or context cancellation
<-sigCtx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), a.shutdownTimeout)
defer cancel()
return a.Stop(shutdownCtx)
}
// IsRunning returns whether the application is currently running.
func (a *App) IsRunning() bool {
a.mu.RLock()
defer a.mu.RUnlock()
return a.running
}
// StartedDrivers returns a copy of currently running drivers.
func (a *App) StartedDrivers() []Driver {
a.mu.RLock()
defer a.mu.RUnlock()
res := make([]Driver, len(a.startedDrivers))
copy(res, a.startedDrivers)
return res
}
// ExecuteCLI parses CLI arguments to configure the profile and runs the application.
func (a *App) ExecuteCLI(args ...string) error {
var ctx context.Context
if a.ctx != nil {
ctx = a.ctx.GoContext()
} else {
ctx = context.Background()
}
return a.ExecuteCLIWithContext(ctx, args...)
}
// ExecuteCLIWithContext parses CLI arguments, configures the profile, and runs the application with the given context.
func (a *App) ExecuteCLIWithContext(ctx context.Context, args ...string) error {
cliArgs := args
if len(cliArgs) == 0 {
cliArgs = os.Args[1:]
}
profile := ProfileAll
if len(cliArgs) > 0 {
first := strings.TrimSpace(cliArgs[0])
switch {
case strings.HasPrefix(first, "--profile="):
profile = Profile(strings.TrimPrefix(first, "--profile="))
case strings.HasPrefix(first, "-p="):
profile = Profile(strings.TrimPrefix(first, "-p="))
case !strings.HasPrefix(first, "-"):
profile = Profile(first)
}
}
a.WithProfile(profile)
return a.Run(ctx)
}
func matchesProfile(profile Profile, dt DriverType) bool {
norm := normalizeProfile(profile)
switch norm {
case ProfileAll, "":
return true
case ProfileAPI:
return dt == DriverTypeHTTP
case ProfileWorker:
return dt == DriverTypeWorker
case ProfileSchedule:
return dt == DriverTypeScheduler
default:
return string(norm) == string(dt)
}
}
func normalizeProfile(p Profile) Profile {
switch strings.ToLower(strings.TrimSpace(string(p))) {
case "api", "http":
return ProfileAPI
case "worker":
return ProfileWorker
case "schedule", "scheduler", "cron":
return ProfileSchedule
case "all", "fused", "full", "":
return ProfileAll
default:
return p
}
}
+654
View File
@@ -0,0 +1,654 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package core_test
import (
"Wavelet/core"
"Wavelet/core/extpoints"
"context"
"errors"
"sync"
"testing"
"testing/fstest"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// appMockDriver is a test driver tracking its start/stop lifecycle.
type appMockDriver struct {
mu sync.Mutex
driverType core.DriverType
startCalled bool
stopCalled bool
startErr error
stopErr error
}
func newAppMockDriver(dt core.DriverType) *appMockDriver {
return &appMockDriver{driverType: dt}
}
func (m *appMockDriver) Type() core.DriverType {
return m.driverType
}
func (m *appMockDriver) Start(_ context.Context) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.startErr != nil {
return m.startErr
}
m.startCalled = true
return nil
}
func (m *appMockDriver) Stop(_ context.Context) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.stopErr != nil {
return m.stopErr
}
m.stopCalled = true
return nil
}
func (m *appMockDriver) isStarted() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.startCalled
}
func (m *appMockDriver) isStopped() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.stopCalled
}
// appMockPlugin is a test plugin.
type appMockPlugin struct {
name string
applyFn func(ctx *core.Context) error
}
func (p *appMockPlugin) Name() string {
return p.name
}
func (p *appMockPlugin) Apply(ctx *core.Context) error {
if p.applyFn != nil {
return p.applyFn(ctx)
}
return nil
}
func TestAppNewAndConfiguration(t *testing.T) {
customCtx := core.NewContext(context.Background())
p1 := &appMockPlugin{name: "plugin1"}
p2 := &appMockPlugin{name: "plugin2"}
app := core.NewApp(
core.WithContext(customCtx),
core.WithProfile(core.ProfileAPI),
core.WithPlugins(p1, p2),
core.WithShutdownTimeout(5*time.Second),
)
assert.Equal(t, customCtx, app.Context())
assert.Equal(t, core.ProfileAPI, app.Profile())
assert.Len(t, app.Plugins(), 2)
retrieved, ok := app.Plugin("plugin1")
assert.True(t, ok)
assert.Equal(t, p1, retrieved)
_, ok = app.Plugin("non_existent")
assert.False(t, ok)
// Update existing plugin in-place
p1Updated := &appMockPlugin{name: "plugin1"}
app.Use(p1Updated, nil)
assert.Len(t, app.Plugins(), 2)
retrieved, ok = app.Plugin("plugin1")
assert.True(t, ok)
assert.Equal(t, p1Updated, retrieved)
// Test SetProfile
app.SetProfile(core.ProfileWorker)
assert.Equal(t, core.ProfileWorker, app.Profile())
}
func TestAppProfileDispatch(t *testing.T) {
tests := []struct {
name string
profile core.Profile
expectedHTTP bool
expectedWorker bool
expectedCron bool
expectedCustom bool
}{
{
name: "ProfileAPI only starts HTTP driver",
profile: core.ProfileAPI,
expectedHTTP: true,
expectedWorker: false,
expectedCron: false,
expectedCustom: false,
},
{
name: "ProfileWorker only starts Worker driver",
profile: core.ProfileWorker,
expectedHTTP: false,
expectedWorker: true,
expectedCron: false,
expectedCustom: false,
},
{
name: "ProfileSchedule only starts Schedule driver",
profile: core.ProfileSchedule,
expectedHTTP: false,
expectedWorker: false,
expectedCron: true,
expectedCustom: false,
},
{
name: "Profile 'scheduler' alias starts Schedule driver",
profile: core.Profile("scheduler"),
expectedHTTP: false,
expectedWorker: false,
expectedCron: true,
expectedCustom: false,
},
{
name: "ProfileAll starts all drivers",
profile: core.ProfileAll,
expectedHTTP: true,
expectedWorker: true,
expectedCron: true,
expectedCustom: true,
},
{
name: "Custom profile starts custom driver",
profile: core.Profile("custom_rpc"),
expectedHTTP: false,
expectedWorker: false,
expectedCron: false,
expectedCustom: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
httpD := newAppMockDriver(core.DriverTypeHTTP)
workerD := newAppMockDriver(core.DriverTypeWorker)
cronD := newAppMockDriver(core.DriverTypeScheduler)
customD := newAppMockDriver(core.DriverType("custom_rpc"))
p := &appMockPlugin{
name: "drivers_plugin",
applyFn: func(ctx *core.Context) error {
_ = ctx.RegisterDriver(httpD)
_ = ctx.RegisterDriver(workerD)
_ = ctx.RegisterDriver(cronD)
_ = ctx.RegisterDriver(customD)
return nil
},
}
app := core.NewApp(
core.WithProfile(tt.profile),
core.WithPlugins(p),
)
err := app.Start(context.Background())
require.NoError(t, err)
assert.Equal(t, tt.expectedHTTP, httpD.isStarted(), "HTTP driver start mismatch")
assert.Equal(t, tt.expectedWorker, workerD.isStarted(), "Worker driver start mismatch")
assert.Equal(t, tt.expectedCron, cronD.isStarted(), "Cron driver start mismatch")
assert.Equal(t, tt.expectedCustom, customD.isStarted(), "Custom driver start mismatch")
err = app.Stop(context.Background())
require.NoError(t, err)
})
}
}
func TestAppLifecycleStartStop(t *testing.T) {
var stopOrder []string
var stopOrderMu sync.Mutex
httpD := newAppMockDriver(core.DriverTypeHTTP)
workerD := newAppMockDriver(core.DriverTypeWorker)
httpD.stopErr = nil
workerD.stopErr = nil
// Wrap stop to record order
origHttpStop := httpD.Stop
_ = origHttpStop
p := &appMockPlugin{
name: "test_plugin",
applyFn: func(ctx *core.Context) error {
_ = ctx.RegisterDriver(httpD)
_ = ctx.RegisterDriver(workerD)
ctx.OnDispose(func() error {
stopOrderMu.Lock()
stopOrder = append(stopOrder, "ctx_disposer")
stopOrderMu.Unlock()
return nil
})
return nil
},
}
app := core.NewApp(
core.WithProfile(core.ProfileAll),
core.WithPlugins(p),
)
var readyReceived, stoppingReceived, stoppedReceived bool
app.Context().Events().On("app:ready", func() {
readyReceived = true
})
app.Context().Events().On("app:stopping", func() {
stoppingReceived = true
})
app.Context().Events().On("app:stopped", func() {
stoppedReceived = true
})
err := app.Start(context.Background())
require.NoError(t, err)
assert.True(t, app.IsRunning())
assert.Len(t, app.StartedDrivers(), 2)
assert.True(t, readyReceived)
err = app.Stop(context.Background())
require.NoError(t, err)
assert.False(t, app.IsRunning())
assert.Empty(t, app.StartedDrivers())
assert.True(t, stoppingReceived)
assert.True(t, stoppedReceived)
assert.True(t, httpD.isStopped())
assert.True(t, workerD.isStopped())
assert.True(t, app.Context().IsDisposed())
stopOrderMu.Lock()
assert.Contains(t, stopOrder, "ctx_disposer")
stopOrderMu.Unlock()
}
func TestAppStartDriverFailureRollback(t *testing.T) {
driver1 := newAppMockDriver(core.DriverTypeHTTP)
driver2 := newAppMockDriver(core.DriverTypeWorker)
driver2.startErr = errors.New("worker listen port conflict")
driver3 := newAppMockDriver(core.DriverTypeScheduler)
p := &appMockPlugin{
name: "fail_driver_plugin",
applyFn: func(ctx *core.Context) error {
_ = ctx.RegisterDriver(driver1)
_ = ctx.RegisterDriver(driver2)
_ = ctx.RegisterDriver(driver3)
return nil
},
}
app := core.NewApp(
core.WithProfile(core.ProfileAll),
core.WithPlugins(p),
)
err := app.Start(context.Background())
require.Error(t, err)
assert.Contains(t, err.Error(), "worker listen port conflict")
assert.False(t, app.IsRunning())
// Driver 1 was started then rolled back (stopped)
assert.True(t, driver1.isStarted())
assert.True(t, driver1.isStopped())
// Driver 3 was never started
assert.False(t, driver3.isStarted())
}
func TestAppMigrationEngineExecution(t *testing.T) {
var migratedEntries []extpoints.MigrationEntry
runner := core.MigrationRunner(func(ctx *core.Context, entries []extpoints.MigrationEntry) error {
migratedEntries = entries
return nil
})
sqlFS := fstest.MapFS{
"migrations/001_init.sql": &fstest.MapFile{Data: []byte("CREATE TABLE users(id int);")},
}
p := &appMockPlugin{
name: "auth",
applyFn: func(ctx *core.Context) error {
ctx.Migrations().Register("auth", sqlFS)
return nil
},
}
app := core.NewApp(
core.WithProfile(core.ProfileAll),
core.WithPlugins(p),
core.WithMigrationRunner(runner),
)
err := app.Start(context.Background())
require.NoError(t, err)
defer func() { _ = app.Stop(context.Background()) }()
require.Len(t, migratedEntries, 1)
assert.Equal(t, "auth", migratedEntries[0].PluginID)
}
func TestAppMigrationEngineFromIoCContainer(t *testing.T) {
var executed bool
runner := core.MigrationRunner(func(ctx *core.Context, entries []extpoints.MigrationEntry) error {
executed = true
return nil
})
sqlFS := fstest.MapFS{
"migrations/001_init.sql": &fstest.MapFile{Data: []byte("CREATE TABLE logs(id int);")},
}
p := &appMockPlugin{
name: "logstore",
applyFn: func(ctx *core.Context) error {
ctx.Migrations().Register("logstore", sqlFS)
core.Provide[core.MigrationEngine](ctx, runner)
return nil
},
}
app := core.NewApp(
core.WithProfile(core.ProfileAll),
core.WithPlugins(p),
)
err := app.Start(context.Background())
require.NoError(t, err)
defer func() { _ = app.Stop(context.Background()) }()
assert.True(t, executed)
}
func TestAppRunContextCancellation(t *testing.T) {
d := newAppMockDriver(core.DriverTypeHTTP)
p := &appMockPlugin{
name: "http_plugin",
applyFn: func(ctx *core.Context) error {
return ctx.RegisterDriver(d)
},
}
app := core.NewApp(
core.WithProfile(core.ProfileAPI),
core.WithPlugins(p),
core.WithShutdownTimeout(1*time.Second),
)
ctx, cancel := context.WithCancel(context.Background())
errCh := make(chan error, 1)
go func() {
errCh <- app.Run(ctx)
}()
// Wait for app and driver to become ready
assert.Eventually(t, func() bool {
return app.IsRunning() && d.isStarted()
}, 2*time.Second, 10*time.Millisecond)
cancel()
select {
case err := <-errCh:
assert.NoError(t, err)
assert.False(t, app.IsRunning())
assert.True(t, d.isStopped())
case <-time.After(3 * time.Second):
t.Fatal("app.Run did not terminate upon context cancellation")
}
}
func TestAppExecuteCLI(t *testing.T) {
// Test CLI argument parsing logic
tests := []struct {
args []string
expectedProfile core.Profile
}{
{args: []string{"api"}, expectedProfile: core.ProfileAPI},
{args: []string{"worker"}, expectedProfile: core.ProfileWorker},
{args: []string{"scheduler"}, expectedProfile: core.ProfileSchedule},
{args: []string{"schedule"}, expectedProfile: core.ProfileSchedule},
{args: []string{"all"}, expectedProfile: core.ProfileAll},
{args: []string{"--profile=worker"}, expectedProfile: core.ProfileWorker},
{args: []string{"-p=api"}, expectedProfile: core.ProfileAPI},
}
for _, tt := range tests {
t.Run(tt.args[0], func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel immediately
// Use custom root context to control cancellation
customApp := core.NewApp(core.WithContext(core.NewContext(ctx)))
_ = customApp.ExecuteCLI(tt.args...)
assert.Equal(t, tt.expectedProfile, customApp.Profile())
})
}
}
func TestAppIdempotencyAndErrorStates(t *testing.T) {
app := core.NewApp()
// Double start returns error
err := app.Start(context.Background())
require.NoError(t, err)
err = app.Start(context.Background())
assert.ErrorIs(t, err, core.ErrAppRunning)
// Stop clears running state
err = app.Stop(context.Background())
require.NoError(t, err)
// Double stop succeeds
err = app.Stop(context.Background())
require.NoError(t, err)
// Plugin apply failure
failPlugin := &appMockPlugin{
name: "failing_plugin",
applyFn: func(ctx *core.Context) error {
return errors.New("plugin init boom")
},
}
app2 := core.NewApp(core.WithPlugins(failPlugin))
err = app2.Start(context.Background())
require.Error(t, err)
assert.Contains(t, err.Error(), "plugin init boom")
assert.False(t, app2.IsRunning())
// Migration failure
migFailRunner := core.MigrationRunner(func(ctx *core.Context, entries []extpoints.MigrationEntry) error {
return errors.New("sql migrate error")
})
sqlFS := fstest.MapFS{
"migrations/001.sql": &fstest.MapFile{Data: []byte("...")},
}
migPlugin := &appMockPlugin{
name: "db_plugin",
applyFn: func(ctx *core.Context) error {
ctx.Migrations().Register("db_plugin", sqlFS)
return nil
},
}
app3 := core.NewApp(
core.WithPlugins(migPlugin),
core.WithMigrationRunner(migFailRunner),
)
err = app3.Start(context.Background())
require.Error(t, err)
assert.Contains(t, err.Error(), "sql migrate error")
assert.False(t, app3.IsRunning())
}
// newGateSource builds a configuration source whose only key decides the test gates.
func newGateSource(enabled bool) *mapSource {
return &mapSource{
values: map[string]any{"gate.enabled": enabled},
env: map[string]string{},
}
}
func TestAppPrepareResolvesThenGatesDuringReconcile(t *testing.T) {
primary := &gatedPlugin{name: "cache", enabled: true}
fallback := &gatedPlugin{name: "cache_memory", enabled: false}
app := core.NewApp(core.WithConfigSource(newGateSource(true)))
app.Use(primary, fallback)
require.NoError(t, app.Prepare())
cacheFiber, ok := app.Fiber("cache")
require.True(t, ok)
require.Equal(t, core.FiberPending, cacheFiber.State(), "Prepare only builds the resolution barrier")
assert.True(t, app.Context().Config().Resolved())
require.NoError(t, app.Reconcile())
assert.Equal(t, core.FiberActive, cacheFiber.State())
memoryFiber, ok := app.Fiber("cache_memory")
require.True(t, ok)
assert.Equal(t, core.FiberSkipped, memoryFiber.State())
assert.False(t, fallback.applied, "the gated-out provider must never reach Apply")
}
func TestAppGatesPluginsMountedAfterPrepare(t *testing.T) {
app := core.NewApp(core.WithConfigSource(newGateSource(true)))
require.NoError(t, app.Prepare())
late := &gatedPlugin{name: "cache_memory", enabled: false}
app.Use(late)
require.NoError(t, app.Reconcile())
fiber, ok := app.Fiber("cache_memory")
require.True(t, ok)
assert.Equal(t, core.FiberSkipped, fiber.State(),
"plugins mounted after Prepare must still be gated")
}
func TestAppApplyPluginsGatesImplicitly(t *testing.T) {
app := core.NewApp(core.WithConfigSource(newGateSource(false)))
app.Use(&gatedPlugin{name: "cache", enabled: true})
require.NoError(t, app.ApplyPlugins())
fiber, ok := app.Fiber("cache")
require.True(t, ok)
assert.Equal(t, core.FiberSkipped, fiber.State(),
"ApplyPlugins must resolve and gate without an explicit Prepare call")
}
func TestAppPrepareReportsConfigurationErrors(t *testing.T) {
src := &mapSource{
values: map[string]any{"gate.enabled": "yes"},
env: map[string]string{},
}
app := core.NewApp(core.WithConfigSource(src))
app.Use(&gatedPlugin{name: "cache", enabled: true})
err := app.Prepare()
require.Error(t, err)
assert.Contains(t, err.Error(), "gate.enabled")
}
func TestAppGatedPluginWithoutConfigSourceFailsFast(t *testing.T) {
app := core.NewApp()
app.Use(&gatedPlugin{name: "cache", enabled: true})
err := app.ApplyPlugins()
require.Error(t, err)
assert.Contains(t, err.Error(), "cache")
assert.Contains(t, err.Error(), "ConfigSource")
}
func TestAppSetShutdownTimeoutIgnoresNonPositive(t *testing.T) {
app := core.NewApp()
app.SetShutdownTimeout(0)
assert.Equal(t, 10*time.Second, app.ShutdownTimeout(), "zero must not shrink the kernel fallback")
app.SetShutdownTimeout(45 * time.Second)
assert.Equal(t, 45*time.Second, app.ShutdownTimeout())
}
func TestWithMigrationBaselineVisibleAfterPrepare(t *testing.T) {
var called bool
fn := func(*core.Context) error {
called = true
return nil
}
app := core.NewApp(core.WithMigrationBaseline(fn))
require.Nil(t, app.Context().MigrationBaseline(), "baseline must be copied during Prepare")
require.NoError(t, app.Prepare())
got := app.Context().MigrationBaseline()
require.NotNil(t, got, "Prepare must copy the baseline onto the root Context")
require.NoError(t, got(app.Context()))
assert.True(t, called)
}
func TestWithMigrationBaselineRunsBeforeEngineMigrate(t *testing.T) {
var order []string
engine := core.MigrationRunner(func(ctx *core.Context, _ []extpoints.MigrationEntry) error {
order = append(order, "engine")
if ctx.MigrationBaseline() == nil {
t.Fatal("baseline must be visible on context inside Migrate")
}
return ctx.MigrationBaseline()(ctx)
})
sqlFS := fstest.MapFS{
"migrations/001_init.sql": &fstest.MapFile{Data: []byte("-- +goose Up\nSELECT 1;\n")},
}
app := core.NewApp(
core.WithMigrationEngine(engine),
core.WithMigrationBaseline(func(*core.Context) error {
order = append(order, "baseline")
return nil
}),
core.WithPlugins(&appMockPlugin{
name: "t",
applyFn: func(ctx *core.Context) error {
ctx.Migrations().Register("t", sqlFS)
return nil
},
}),
)
require.NoError(t, app.Start(context.Background()))
defer func() { _ = app.Stop(context.Background()) }()
assert.Equal(t, []string{"engine", "baseline"}, order)
}
func TestWithMigrationBaselineNilByDefault(t *testing.T) {
app := core.NewApp()
require.NoError(t, app.Prepare())
assert.Nil(t, app.Context().MigrationBaseline())
}
+43
View File
@@ -0,0 +1,43 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package core
import "context"
type appContextKey struct{}
// WithAppContext attaches the micro-kernel Context to a standard context.Context
// so request and worker handlers can Inject services without package-level setters.
func WithAppContext(ctx context.Context, app *Context) context.Context {
if ctx == nil {
ctx = context.Background()
}
if app == nil {
return ctx
}
return context.WithValue(ctx, appContextKey{}, app.Root())
}
// AppContext extracts the micro-kernel Context from ctx, if present.
func AppContext(ctx context.Context) *Context {
if ctx == nil {
return nil
}
if c, ok := ctx.(*Context); ok {
return c
}
app, _ := ctx.Value(appContextKey{}).(*Context)
return app
}
// InjectFrom resolves T from ctx when it carries a micro-kernel Context
// (*Context itself, or a value attached by WithAppContext).
func InjectFrom[T any](ctx context.Context) (T, error) {
var zero T
app := AppContext(ctx)
if app == nil {
return zero, ErrNilContext
}
return Inject[T](app)
}
+100
View File
@@ -0,0 +1,100 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package core
import (
"fmt"
"strings"
"Wavelet/core/extpoints"
)
// ConfigGet reads one resolved configuration value with its declared type. It is the
// generic counterpart of the fallback accessors on ConfigView, used when a caller must
// distinguish "unset" from "set to the zero value".
func ConfigGet[T any](view extpoints.ConfigView, key string) (T, error) {
var zero T
if view == nil {
return zero, extpoints.ErrConfigNotResolved
}
raw, ok := view.Value(key)
if !ok {
return zero, fmt.Errorf("%w: %s", extpoints.ErrConfigUnknownKey, key)
}
value, ok := raw.(T)
if !ok {
return zero, fmt.Errorf("%w: key %q holds %T, want %T", extpoints.ErrConfigType, key, raw, zero)
}
return value, nil
}
// MapSource implements ConfigSource backed by an in-memory map, ideal for unit tests.
type MapSource struct {
values map[string]any
env map[string]string
}
// NewMapSource creates a new MapSource with the provided key-value mappings.
func NewMapSource(values map[string]any) *MapSource {
vals := make(map[string]any, len(values))
for k, v := range values {
vals[k] = v
}
return &MapSource{
values: vals,
env: make(map[string]string),
}
}
// Lookup returns the value at the given path, supporting both flat keys and nested maps.
func (m *MapSource) Lookup(path string) (any, bool) {
if m == nil || m.values == nil {
return nil, false
}
if v, ok := m.values[path]; ok {
return v, true
}
parts := strings.Split(path, ".")
var cur any = m.values
for _, part := range parts {
mCur, ok := cur.(map[string]any)
if !ok {
return nil, false
}
cur, ok = mCur[part]
if !ok {
return nil, false
}
}
return cur, true
}
// LookupEnv returns the environment variable value.
func (m *MapSource) LookupEnv(name string) (string, bool) {
if m == nil || m.env == nil {
return "", false
}
v, ok := m.env[name]
return v, ok
}
// SetEnv sets an environment variable for testing.
func (m *MapSource) SetEnv(name, value string) {
if m.env == nil {
m.env = make(map[string]string)
}
m.env[name] = value
}
// Describe describes the MapSource.
func (m *MapSource) Describe() string {
return "<map source>"
}
// WithConfigValues returns an AppOption that installs a MapSource with the given key-value mappings.
func WithConfigValues(values map[string]any) AppOption {
return WithConfigSource(NewMapSource(values))
}
+92
View File
@@ -0,0 +1,92 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package core_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"Wavelet/core"
"Wavelet/core/extpoints"
)
// mapSource implements extpoints.ConfigSource over static maps.
type mapSource struct {
values map[string]any
env map[string]string
}
func (m *mapSource) Lookup(path string) (any, bool) {
v, ok := m.values[path]
return v, ok
}
func (m *mapSource) LookupEnv(name string) (string, bool) {
v, ok := m.env[name]
return v, ok
}
func (m *mapSource) Describe() string { return "map" }
type otelConfig struct {
SamplingRate float64 `config:"sampling_rate" env:"OTEL_SAMPLING_RATE"`
}
// newOtelRegistry declares the otel section against a source carrying the given file values.
func newOtelRegistry(t *testing.T, values map[string]any) extpoints.ConfigExtension {
t.Helper()
r := extpoints.NewConfigRegistry(&mapSource{values: values, env: map[string]string{}})
require.NoError(t, r.Declare("host", extpoints.ConfigBinding{Prefix: "otel", Target: &otelConfig{}}))
require.NoError(t, r.Resolve())
return r
}
func TestConfigGetReturnsDeclaredType(t *testing.T) {
view := newOtelRegistry(t, map[string]any{"otel.sampling_rate": 0.25})
rate, err := core.ConfigGet[float64](view, "otel.sampling_rate")
require.NoError(t, err)
assert.Equal(t, 0.25, rate)
}
func TestConfigGetRejectsTypeMismatch(t *testing.T) {
view := newOtelRegistry(t, map[string]any{"otel.sampling_rate": 0.25})
text, err := core.ConfigGet[string](view, "otel.sampling_rate")
require.ErrorIs(t, err, extpoints.ErrConfigType)
assert.Empty(t, text)
}
func TestConfigGetRejectsUndeclaredKey(t *testing.T) {
view := newOtelRegistry(t, nil)
_, err := core.ConfigGet[float64](view, "otel.unregistered")
require.ErrorIs(t, err, extpoints.ErrConfigUnknownKey)
}
func TestConfigGetRejectsNilView(t *testing.T) {
_, err := core.ConfigGet[float64](nil, "otel.sampling_rate")
require.ErrorIs(t, err, extpoints.ErrConfigNotResolved)
}
func TestContextConfigIsSharedAcrossForks(t *testing.T) {
ctx := core.NewContext(nil)
child := ctx.Fork()
require.NotNil(t, ctx.Config())
assert.Same(t, ctx.Config(), child.Config(), "configuration declarations are process-wide facts")
require.NoError(t, child.Config().Declare("cache",
extpoints.ConfigBinding{Prefix: "otel", Target: &otelConfig{}}))
declared := false
for _, entry := range ctx.Config().Entries() {
declared = declared || entry.Key == "otel.sampling_rate"
}
assert.True(t, declared, "a declaration made in a plugin scope must be visible to the root")
assert.False(t, ctx.Config().Resolved())
}
+265
View File
@@ -0,0 +1,265 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package core provides the micro-kernel service bus, generic IoC container, and runtime extensions.
package core
import (
"errors"
"fmt"
"reflect"
"sync"
)
// Container manages service registration and resolution using Go reflection and generics.
type Container struct {
mu sync.RWMutex
parent *Container
services map[reflect.Type]any
interfaceCache map[reflect.Type]any
listeners map[reflect.Type][]func(any)
}
// NewContainer creates a new IoC container instance with an optional parent container.
func NewContainer(parent *Container) *Container {
return &Container{
parent: parent,
services: make(map[reflect.Type]any),
interfaceCache: make(map[reflect.Type]any),
listeners: make(map[reflect.Type][]func(any)),
}
}
func isNil(i any) bool {
if i == nil {
return true
}
v := reflect.ValueOf(i)
switch v.Kind() {
case reflect.Chan, reflect.Func, reflect.Map, reflect.Pointer, reflect.UnsafePointer, reflect.Interface, reflect.Slice:
return v.IsNil()
default:
return false
}
}
func (c *Container) remove(targetType reflect.Type) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.services, targetType)
c.interfaceCache = make(map[reflect.Type]any)
}
// Provide registers a typed service implementation into the Context hierarchy's root IoC container.
func Provide[T any](ctx *Context, service T) {
if ctx == nil {
panic("core: nil context provided to Provide")
}
if isNil(service) {
panic("core: cannot provide nil service")
}
targetType := reflect.TypeFor[T]()
targetContainer := ctx.Root().Container()
targetContainer.provide(targetType, service)
ctx.OnDispose(func() error {
targetContainer.remove(targetType)
return nil
})
}
// ProvideScoped registers a typed service implementation strictly in the local Context container.
func ProvideScoped[T any](ctx *Context, service T) {
if ctx == nil {
panic("core: nil context provided to ProvideScoped")
}
if isNil(service) {
panic("core: cannot provide nil service")
}
targetType := reflect.TypeFor[T]()
targetContainer := ctx.Container()
targetContainer.provide(targetType, service)
ctx.OnDispose(func() error {
targetContainer.remove(targetType)
return nil
})
}
func (c *Container) provide(targetType reflect.Type, service any) {
c.mu.Lock()
c.services[targetType] = service
c.interfaceCache = make(map[reflect.Type]any)
// Collect any matching listeners to invoke outside the lock
var callbacks []func(any)
svcType := reflect.TypeOf(service)
for lType, cbs := range c.listeners {
if lType == targetType || (lType.Kind() == reflect.Interface && svcType.Implements(lType)) {
callbacks = append(callbacks, cbs...)
}
}
c.mu.Unlock()
for _, cb := range callbacks {
cb(service)
}
}
// Inject resolves a registered service of type T from the Context.
func Inject[T any](ctx *Context) (T, error) {
var zero T
if ctx == nil {
return zero, ErrNilContext
}
targetType := reflect.TypeFor[T]()
val, err := ctx.Container().resolve(targetType)
if err != nil {
return zero, err
}
typedVal, ok := val.(T)
if !ok {
return zero, fmt.Errorf("%w: cannot cast %T to %v", ErrServiceNotFound, val, targetType)
}
return typedVal, nil
}
func (c *Container) resolve(targetType reflect.Type) (any, error) {
c.mu.RLock()
// 1. Direct type match
if val, ok := c.services[targetType]; ok {
c.mu.RUnlock()
return val, nil
}
c.mu.RUnlock()
// 2. Interface assignment scan & cache
if targetType.Kind() == reflect.Interface {
if val, found := c.resolveInterface(targetType); found {
return val, nil
}
}
// 3. Fallback to parent container
if c.parent != nil {
return c.parent.resolve(targetType)
}
return nil, fmt.Errorf("%w: %v", ErrServiceNotFound, targetType)
}
func (c *Container) resolveInterface(targetType reflect.Type) (any, bool) {
c.mu.RLock()
if val, ok := c.interfaceCache[targetType]; ok {
c.mu.RUnlock()
return val, true
}
var matched any
for _, val := range c.services {
if reflect.TypeOf(val).Implements(targetType) {
matched = val
break
}
}
c.mu.RUnlock()
if matched == nil {
return nil, false
}
c.mu.Lock()
if c.interfaceCache == nil {
c.interfaceCache = make(map[reflect.Type]any)
}
c.interfaceCache[targetType] = matched
c.mu.Unlock()
return matched, true
}
// MustInject resolves a service of type T or panics if the service is not found.
func MustInject[T any](ctx *Context) T {
s, err := Inject[T](ctx)
if err != nil {
panic(fmt.Sprintf("core: failed to inject service %v: %v", reflect.TypeFor[T](), err))
}
return s
}
// Has returns true if a service of type T is registered and resolvable in the Context.
func Has[T any](ctx *Context) bool {
_, err := Inject[T](ctx)
return err == nil
}
// Using executes the given function synchronously if the required dependency is ready.
func Using[T1 any](ctx *Context, fn func(s1 T1)) error {
s1, err := Inject[T1](ctx)
if err != nil {
return fmt.Errorf("%w: %w", ErrServiceNotReady, err)
}
fn(s1)
return nil
}
// Using2 executes the given function synchronously if both required dependencies are ready.
func Using2[T1, T2 any](ctx *Context, fn func(s1 T1, s2 T2)) error {
s1, err1 := Inject[T1](ctx)
s2, err2 := Inject[T2](ctx)
if err := errors.Join(err1, err2); err != nil {
return fmt.Errorf("%w: %w", ErrServiceNotReady, err)
}
fn(s1, s2)
return nil
}
// Using3 executes the given function synchronously if all 3 required dependencies are ready.
func Using3[T1, T2, T3 any](ctx *Context, fn func(s1 T1, s2 T2, s3 T3)) error {
s1, err1 := Inject[T1](ctx)
s2, err2 := Inject[T2](ctx)
s3, err3 := Inject[T3](ctx)
if err := errors.Join(err1, err2, err3); err != nil {
return fmt.Errorf("%w: %w", ErrServiceNotReady, err)
}
fn(s1, s2, s3)
return nil
}
// When registers a reactive hook that is called immediately if T is already provided,
// or called as soon as T is provided in the future.
//
// Listeners are stored on the root container so they observe core.Provide, which
// always writes to the root. Registering on a Fiber child container would miss
// services provided by plugins that load later.
func When[T any](ctx *Context, fn func(s T)) {
if ctx == nil {
panic("core: nil context provided to When")
}
targetType := reflect.TypeFor[T]()
c := ctx.Root().Container()
// If already ready, execute immediately
if s, err := Inject[T](ctx); err == nil {
fn(s)
}
// Also register listener for future calls / updates
c.mu.Lock()
defer c.mu.Unlock()
c.listeners[targetType] = append(c.listeners[targetType], func(val any) {
if typed, ok := val.(T); ok {
fn(typed)
}
})
}
// Bind is When with a name that matches plugin wiring: fill a dependency as
// soon as the root container provides it.
func Bind[T any](ctx *Context, fn func(s T)) {
When(ctx, fn)
}
+416
View File
@@ -0,0 +1,416 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package core
import (
"Wavelet/core/extpoints"
"context"
"errors"
"fmt"
"sync"
"time"
)
// Context is the central micro-kernel service bus and runtime lifecycle container.
// It embeds Go standard context.Context compatibility, hierarchical scoping,
// service resolution, and LIFO disposer teardown.
type Context struct {
goCtx context.Context
cancel context.CancelFunc
parent *Context
container *Container
events *EventBus
router extpoints.RouterExtension
migrations extpoints.MigrationExtension
tasks extpoints.TaskExtension
schedules extpoints.ScheduleExtension
settings extpoints.SettingExtension
config extpoints.ConfigExtension
mu sync.RWMutex
children []*Context
disposers []Disposer
drivers []Driver
values map[any]any
disposed bool
migrationBaseline func(*Context) error
}
// NewContext creates a new root Context wrapping a standard Go context.
// If base is nil, context.Background() is used by default.
//
//nolint:contextcheck
func NewContext(base context.Context) *Context {
if base == nil {
base = context.Background()
}
ctx, cancel := context.WithCancel(base)
return &Context{
goCtx: ctx,
cancel: cancel,
container: NewContainer(nil),
events: NewEventBus(),
router: extpoints.NewRouterRegistry(),
migrations: extpoints.NewMigrationRegistry(),
tasks: extpoints.NewTaskRegistry(),
schedules: extpoints.NewScheduleRegistry(),
settings: extpoints.NewSettingRegistry(),
config: extpoints.NewConfigRegistry(nil),
values: make(map[any]any),
}
}
// Deadline returns the time when work done on behalf of this context should be canceled.
func (c *Context) Deadline() (deadline time.Time, ok bool) {
return c.goCtx.Deadline()
}
// Done returns a channel that's closed when work done on behalf of this context should be canceled.
func (c *Context) Done() <-chan struct{} {
return c.goCtx.Done()
}
// Err returns a non-nil error value after Done is closed.
func (c *Context) Err() error {
return c.goCtx.Err()
}
// Value returns the value associated with key, searching the local values map,
// the underlying Go context, and fallback parent Contexts.
func (c *Context) Value(key any) any {
c.mu.RLock()
if v, ok := c.values[key]; ok {
c.mu.RUnlock()
return v
}
c.mu.RUnlock()
if v := c.goCtx.Value(key); v != nil {
return v
}
if c.parent != nil {
return c.parent.Value(key)
}
return nil
}
// GoContext returns the underlying standard Go context.Context.
func (c *Context) GoContext() context.Context {
return c.goCtx
}
// Set stores an arbitrary key-value pair in this Context's local storage.
func (c *Context) Set(key, val any) {
c.mu.Lock()
defer c.mu.Unlock()
if c.values == nil {
c.values = make(map[any]any)
}
c.values[key] = val
}
// Get retrieves a key-value pair from this Context's local storage.
func (c *Context) Get(key any) (any, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
if c.values == nil {
return nil, false
}
v, ok := c.values[key]
return v, ok
}
// Container returns the underlying IoC container for this Context.
func (c *Context) Container() *Container {
return c.container
}
// Parent returns the parent Context, or nil if this is a root Context.
func (c *Context) Parent() *Context {
return c.parent
}
// Root returns the root Context in the hierarchy.
func (c *Context) Root() *Context {
curr := c
for curr.parent != nil {
curr = curr.parent
}
return curr
}
// Fork creates a child Context with its own scoped IoC container and values,
// linked to this Context for hierarchical fallback resolution and cascading teardown.
func (c *Context) Fork() *Context {
return c.ForkWithContext(c.goCtx)
}
// ForkWithContext creates a child Context using a specific standard Go context.
//
//nolint:contextcheck
func (c *Context) ForkWithContext(base context.Context) *Context {
if base == nil {
base = c.goCtx
}
ctx, cancel := context.WithCancel(base)
child := &Context{
goCtx: ctx,
cancel: cancel,
parent: c,
container: NewContainer(c.container),
events: c.events,
router: c.router,
migrations: c.migrations,
tasks: c.tasks,
schedules: c.schedules,
settings: c.settings,
config: c.config,
values: make(map[any]any),
migrationBaseline: c.MigrationBaseline(),
}
c.mu.Lock()
c.children = append(c.children, child)
c.mu.Unlock()
return child
}
// Events returns the domain EventBus associated with this Context hierarchy.
func (c *Context) Events() *EventBus {
return c.events
}
// On registers an event listener on the EventBus and automatically attaches its Disposer
// to this Context's teardown stack for automatic revocation when disposed.
func (c *Context) On(topic string, handler any) Disposer {
disposer := c.events.On(topic, handler)
c.OnDispose(disposer)
return disposer
}
// Effect registers a reversible side-effect cleanup callback on this Context.
func (c *Context) Effect(fn any) {
c.OnDispose(fn)
}
// Router returns the scoped RouterExtension registry with automatic disposer tracking.
func (c *Context) Router() extpoints.RouterExtension {
return newScopedRouterExtension(c, c.router)
}
// Migrations returns the MigrationExtension registry.
func (c *Context) Migrations() extpoints.MigrationExtension {
return c.migrations
}
// MigrationBaseline returns the hook copied onto this Context during App.Prepare.
// Child contexts fall back to their parent so forks still see the root hook.
func (c *Context) MigrationBaseline() func(*Context) error {
if c == nil {
return nil
}
c.mu.RLock()
fn := c.migrationBaseline
c.mu.RUnlock()
if fn != nil {
return fn
}
if c.parent != nil {
return c.parent.MigrationBaseline()
}
return nil
}
func (c *Context) setMigrationBaseline(fn func(*Context) error) {
if c == nil {
return
}
c.mu.Lock()
c.migrationBaseline = fn
c.mu.Unlock()
}
// Tasks returns the scoped TaskExtension registry with automatic disposer tracking.
func (c *Context) Tasks() extpoints.TaskExtension {
return newScopedTaskExtension(c, c.tasks)
}
// Task is an alias for Tasks().
func (c *Context) Task() extpoints.TaskExtension {
return c.Tasks()
}
// Schedules returns the scoped ScheduleExtension registry with automatic disposer tracking.
func (c *Context) Schedules() extpoints.ScheduleExtension {
return newScopedScheduleExtension(c, c.schedules)
}
// Schedule is an alias for Schedules().
func (c *Context) Schedule() extpoints.ScheduleExtension {
return c.Schedules()
}
// Settings returns the scoped SettingExtension registry with automatic disposer tracking.
func (c *Context) Settings() extpoints.SettingExtension {
return newScopedSettingExtension(c, c.settings)
}
// Setting is an alias for Settings().
func (c *Context) Setting() extpoints.SettingExtension {
return c.Settings()
}
// Config returns the process-level configuration extension point. The registry is
// shared by every fork because configuration declarations are global facts, and it
// carries no per-scope disposers: values are resolved once before Apply runs.
func (c *Context) Config() extpoints.ConfigExtension {
return c.config
}
// OnDispose registers a cleanup callback function to be executed when this Context is disposed.
// It accepts func() error, func(), or Disposer.
func (c *Context) OnDispose(fn any) {
if fn == nil {
return
}
var d Disposer
switch f := fn.(type) {
case Disposer:
d = f
case func() error:
d = f
case func():
d = func() error {
f()
return nil
}
default:
panic(fmt.Sprintf("core: OnDispose expects func() error or func(), got %T", fn))
}
c.mu.Lock()
defer c.mu.Unlock()
c.disposers = append(c.disposers, d)
}
// Dispose shuts down this Context and all child Contexts, running registered disposers in LIFO order.
func (c *Context) Dispose() error {
c.mu.Lock()
if c.disposed {
c.mu.Unlock()
return nil
}
c.disposed = true
// Copy children and disposers under lock
children := make([]*Context, len(c.children))
copy(children, c.children)
disposers := make([]Disposer, len(c.disposers))
copy(disposers, c.disposers)
c.mu.Unlock()
var errs []error
// 1. Dispose all child contexts in reverse order
for i := len(children) - 1; i >= 0; i-- {
if err := children[i].Dispose(); err != nil {
errs = append(errs, err)
}
}
// 2. Run local disposers in LIFO order
for i := len(disposers) - 1; i >= 0; i-- {
if err := disposers[i](); err != nil {
errs = append(errs, err)
}
}
// 3. Cancel the Go context
if c.cancel != nil {
c.cancel()
}
// 4. Detach from parent
if c.parent != nil {
c.parent.removeChild(c)
}
return errors.Join(errs...)
}
func (c *Context) removeChild(target *Context) {
c.mu.Lock()
defer c.mu.Unlock()
for i, child := range c.children {
if child == target {
c.children = append(c.children[:i], c.children[i+1:]...)
break
}
}
}
// IsDisposed returns true if this Context has been disposed.
func (c *Context) IsDisposed() bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.disposed
}
// RegisterDriver registers a runtime driver engine on this Context hierarchy.
func (c *Context) RegisterDriver(d Driver) error {
if d == nil {
return ErrNilService
}
root := c.Root()
root.mu.Lock()
root.drivers = append(root.drivers, d)
root.mu.Unlock()
c.OnDispose(func() error {
root.mu.Lock()
defer root.mu.Unlock()
for i, drv := range root.drivers {
if drv == d {
root.drivers = append(root.drivers[:i], root.drivers[i+1:]...)
break
}
}
return nil
})
return nil
}
// Drivers returns a copy of all drivers registered on this Context.
func (c *Context) Drivers() []Driver {
root := c.Root()
root.mu.RLock()
defer root.mu.RUnlock()
result := make([]Driver, len(root.drivers))
copy(result, root.drivers)
return result
}
// Driver looks up a registered driver by its driver type.
func (c *Context) Driver(driverType DriverType) (Driver, bool) {
root := c.Root()
root.mu.RLock()
defer root.mu.RUnlock()
for _, d := range root.drivers {
if d.Type() == driverType {
return d, true
}
}
return nil, false
}
+646
View File
@@ -0,0 +1,646 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package core_test
import (
"Wavelet/core"
"context"
"errors"
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Sample services for testing
type SampleService interface {
Greet(name string) string
}
type sampleServiceImpl struct {
prefix string
}
func (s *sampleServiceImpl) Greet(name string) string {
if s.prefix != "" {
return s.prefix + " " + name
}
return "Hello, " + name
}
type LogService interface {
Log(msg string)
}
type logServiceImpl struct {
logs []string
}
func (l *logServiceImpl) Log(msg string) {
l.logs = append(l.logs, msg)
}
type ConfigService interface {
Get(key string) string
}
type configServiceImpl struct {
data map[string]string
}
func (c *configServiceImpl) Get(key string) string {
return c.data[key]
}
// Sample plugin for testing
type samplePlugin struct {
name string
}
func (p *samplePlugin) Name() string {
return p.name
}
func (p *samplePlugin) Apply(ctx *core.Context) error {
core.Provide[SampleService](ctx, &sampleServiceImpl{prefix: "Plugin:"})
return nil
}
func (p *samplePlugin) Manifest() core.Manifest {
return core.Manifest{
Name: p.name,
Version: "1.0.0",
Description: "Sample plugin",
}
}
// Sample driver for testing
type mockDriver struct {
driverType core.DriverType
started bool
stopped bool
}
func (m *mockDriver) Type() core.DriverType {
return m.driverType
}
func (m *mockDriver) Start(ctx context.Context) error {
m.started = true
return nil
}
func (m *mockDriver) Stop(ctx context.Context) error {
m.stopped = true
return nil
}
func TestContextProvideAndInject(t *testing.T) {
ctx := core.NewContext(context.Background())
// Before providing, Inject should fail
_, err := core.Inject[SampleService](ctx)
require.Error(t, err)
assert.True(t, errors.Is(err, core.ErrServiceNotFound))
assert.False(t, core.Has[SampleService](ctx))
// MustInject should panic
assert.Panics(t, func() {
core.MustInject[SampleService](ctx)
})
// Provide service
svcImpl := &sampleServiceImpl{prefix: "Hello,"}
core.Provide[SampleService](ctx, svcImpl)
// Inject should succeed
assert.True(t, core.Has[SampleService](ctx))
svc, err := core.Inject[SampleService](ctx)
require.NoError(t, err)
assert.Equal(t, "Hello, Wavelet", svc.Greet("Wavelet"))
// MustInject should succeed
mustSvc := core.MustInject[SampleService](ctx)
assert.Equal(t, "Hello, Cordis", mustSvc.Greet("Cordis"))
}
func TestContextProvideNilPanics(t *testing.T) {
ctx := core.NewContext(context.Background())
assert.Panics(t, func() {
core.Provide[SampleService](nil, &sampleServiceImpl{})
})
assert.Panics(t, func() {
var nilSvc SampleService
core.Provide[SampleService](ctx, nilSvc)
})
assert.Panics(t, func() {
var nilImpl *sampleServiceImpl
core.Provide[*sampleServiceImpl](ctx, nilImpl)
})
// Inject with nil context
var nilCtx *core.Context
_, err := core.Inject[SampleService](nilCtx)
assert.ErrorIs(t, err, core.ErrNilContext)
}
func TestContextUsing(t *testing.T) {
ctx := core.NewContext(context.Background())
var called bool
// Using when service not ready should return ErrServiceNotReady
err := core.Using(ctx, func(s SampleService) {
called = true
assert.Equal(t, "Hello, Cordis", s.Greet("Cordis"))
})
assert.Error(t, err)
assert.True(t, errors.Is(err, core.ErrServiceNotReady))
assert.False(t, called)
// Provide service and try Using again
core.Provide[SampleService](ctx, &sampleServiceImpl{})
err = core.Using(ctx, func(s SampleService) {
called = true
assert.Equal(t, "Hello, Cordis", s.Greet("Cordis"))
})
assert.NoError(t, err)
assert.True(t, called)
}
func TestContextUsingMultiple(t *testing.T) {
ctx := core.NewContext(context.Background())
// Using2 with missing dependencies
var called2 bool
err := core.Using2(ctx, func(s SampleService, l LogService) {
called2 = true
})
assert.Error(t, err)
assert.False(t, called2)
// Provide 1 of 2
core.Provide[SampleService](ctx, &sampleServiceImpl{})
err = core.Using2(ctx, func(s SampleService, l LogService) {
called2 = true
})
assert.Error(t, err)
assert.False(t, called2)
// Provide 2 of 2
logSvc := &logServiceImpl{}
core.Provide[LogService](ctx, logSvc)
err = core.Using2(ctx, func(s SampleService, l LogService) {
called2 = true
l.Log(s.Greet("World"))
})
assert.NoError(t, err)
assert.True(t, called2)
assert.Equal(t, []string{"Hello, World"}, logSvc.logs)
// Using3 test - error condition
err = core.Using3(ctx, func(s SampleService, l LogService, c ConfigService) {})
assert.Error(t, err)
// Using3 test - success condition
var called3 bool
cfgSvc := &configServiceImpl{data: map[string]string{"env": "test"}}
core.Provide[ConfigService](ctx, cfgSvc)
err = core.Using3(ctx, func(s SampleService, l LogService, c ConfigService) {
called3 = true
assert.Equal(t, "test", c.Get("env"))
})
assert.NoError(t, err)
assert.True(t, called3)
}
// UsingN must keep every dependency failure reachable through the error chain,
// not just report that something went wrong.
func TestContextUsingMultipleErrorChain(t *testing.T) {
ctx := core.NewContext(context.Background())
err := core.Using2(ctx, func(s SampleService, l LogService) {
t.Fatal("callback must not run when dependencies are missing")
})
require.Error(t, err)
assert.ErrorIs(t, err, core.ErrServiceNotReady)
assert.ErrorIs(t, err, core.ErrServiceNotFound)
// Only LogService is missing now, so exactly one joined cause must be present.
core.Provide[SampleService](ctx, &sampleServiceImpl{})
err = core.Using2(ctx, func(s SampleService, l LogService) {
t.Fatal("callback must not run when a dependency is missing")
})
assert.ErrorIs(t, err, core.ErrServiceNotReady)
assert.ErrorIs(t, err, core.ErrServiceNotFound)
err = core.Using3(ctx, func(s SampleService, l LogService, c ConfigService) {
t.Fatal("callback must not run when a dependency is missing")
})
assert.ErrorIs(t, err, core.ErrServiceNotReady)
assert.ErrorIs(t, err, core.ErrServiceNotFound)
}
func TestContextHierarchyAndFork(t *testing.T) {
parent := core.NewContext(nil) // nil base context test
core.Provide[SampleService](parent, &sampleServiceImpl{prefix: "Parent:"})
child := parent.ForkWithContext(nil) // nil child context test
require.NotNil(t, child)
assert.Equal(t, parent, child.Parent())
// Child can resolve service from parent
svc, err := core.Inject[SampleService](child)
require.NoError(t, err)
assert.Equal(t, "Parent: Ryan", svc.Greet("Ryan"))
// Child provides LogService
childLog := &logServiceImpl{}
core.ProvideScoped[LogService](child, childLog)
// Child has LogService, parent does not
assert.True(t, core.Has[LogService](child))
assert.False(t, core.Has[LogService](parent))
// Child overrides SampleService locally
core.ProvideScoped[SampleService](child, &sampleServiceImpl{prefix: "Child:"})
childSvc, err := core.Inject[SampleService](child)
require.NoError(t, err)
assert.Equal(t, "Child: Ryan", childSvc.Greet("Ryan"))
parentSvc, err := core.Inject[SampleService](parent)
require.NoError(t, err)
assert.Equal(t, "Parent: Ryan", parentSvc.Greet("Ryan"))
}
func TestContextReactiveWhen(t *testing.T) {
ctx := core.NewContext(context.Background())
assert.Panics(t, func() {
core.When[SampleService](nil, func(s SampleService) {})
})
var whenCalled atomic.Bool
var greeted string
// Register When before service is provided
core.When[SampleService](ctx, func(s SampleService) {
whenCalled.Store(true)
greeted = s.Greet("Reactive")
})
assert.False(t, whenCalled.Load())
// Now Provide the service - listener should trigger
core.Provide[SampleService](ctx, &sampleServiceImpl{})
assert.True(t, whenCalled.Load())
assert.Equal(t, "Hello, Reactive", greeted)
// Register another When after service is already provided - should trigger immediately
var immediateCalled bool
core.When[SampleService](ctx, func(s SampleService) {
immediateCalled = true
})
assert.True(t, immediateCalled)
}
func TestWhenObservesProvideFromForkedFiberContext(t *testing.T) {
root := core.NewContext(context.Background())
adminFiber := root.Fork()
lateFiber := root.Fork()
var got atomic.Bool
core.When[SampleService](adminFiber, func(s SampleService) {
if s != nil {
got.Store(true)
}
})
assert.False(t, got.Load())
core.Provide[SampleService](lateFiber, &sampleServiceImpl{})
assert.True(t, got.Load(), "When on a Fiber child must observe Provide on the root")
}
func TestBindIsWhen(t *testing.T) {
ctx := core.NewContext(context.Background())
var called atomic.Bool
core.Bind[SampleService](ctx, func(s SampleService) {
called.Store(true)
})
core.Provide[SampleService](ctx, &sampleServiceImpl{})
assert.True(t, called.Load())
}
func TestInjectFromAppContext(t *testing.T) {
app := core.NewContext(context.Background())
core.Provide[SampleService](app, &sampleServiceImpl{prefix: "Hi:"})
req := core.WithAppContext(context.Background(), app)
svc, err := core.InjectFrom[SampleService](req)
require.NoError(t, err)
assert.Equal(t, "Hi: Ada", svc.Greet("Ada"))
_, err = core.InjectFrom[SampleService](context.Background())
assert.ErrorIs(t, err, core.ErrNilContext)
}
func TestContextDisposerLifecycle(t *testing.T) {
parent := core.NewContext(context.Background())
child := parent.Fork()
var order []string
// Test nil disposer
parent.OnDispose(nil)
// Test Disposer type
var customDisposer core.Disposer = func() error {
order = append(order, "parent-custom")
return nil
}
parent.OnDispose(customDisposer)
parent.OnDispose(func() error {
order = append(order, "parent-1")
return nil
})
parent.OnDispose(func() {
order = append(order, "parent-2")
})
child.OnDispose(func() error {
order = append(order, "child-1")
return errors.New("child-1 error")
})
child.OnDispose(func() {
order = append(order, "child-2")
})
assert.Panics(t, func() {
parent.OnDispose("invalid-func")
})
assert.False(t, parent.IsDisposed())
assert.False(t, child.IsDisposed())
// Disposing parent should cascade to children first, and execute disposers in LIFO order
err := parent.Dispose()
assert.Error(t, err) // child-1 error should be joined
assert.Contains(t, err.Error(), "child-1 error")
assert.True(t, parent.IsDisposed())
assert.True(t, child.IsDisposed())
// Child disposers run in LIFO: child-2, child-1
// Parent disposers run in LIFO: parent-2, parent-1, parent-custom
expected := []string{"child-2", "child-1", "parent-2", "parent-1", "parent-custom"}
assert.Equal(t, expected, order)
// Disposing again should be idempotent and return nil
err = parent.Dispose()
assert.NoError(t, err)
}
func TestContextStandardGoContext(t *testing.T) {
baseCtx, cancel := context.WithDeadline(context.Background(), time.Now().Add(5*time.Second))
defer cancel()
parentCtx := core.NewContext(baseCtx)
parentCtx.Set("parent_key", "parent_val")
childCtx := parentCtx.Fork()
// Deadline
dl, ok := childCtx.Deadline()
assert.True(t, ok)
assert.False(t, dl.IsZero())
// Value fallback: child has no key, falls back to parentCtx
assert.Equal(t, "parent_val", childCtx.Value("parent_key"))
// GoContext getter
assert.NotNil(t, childCtx.GoContext())
// Value not found in either
assert.Nil(t, childCtx.Value("non_existent_key"))
// Cancellation propagation
select {
case <-childCtx.Done():
t.Fatal("ctx should not be done yet")
default:
}
cancel()
select {
case <-childCtx.Done():
assert.Equal(t, context.Canceled, childCtx.Err())
case <-time.After(100 * time.Millisecond):
t.Fatal("ctx should be cancelled")
}
}
func TestManifestValidation(t *testing.T) {
mValid := core.Manifest{
Name: "auth",
Version: "1.0.0",
Description: "Auth plugin",
}
assert.NoError(t, mValid.Validate())
mInvalid := core.Manifest{
Version: "1.0.0",
}
assert.Error(t, mInvalid.Validate())
}
func TestDriverRegistration(t *testing.T) {
ctx := core.NewContext(context.Background())
// Register nil driver returns error
assert.ErrorIs(t, ctx.RegisterDriver(nil), core.ErrNilService)
dHTTP := &mockDriver{driverType: core.DriverTypeHTTP}
dWorker := &mockDriver{driverType: core.DriverTypeWorker}
require.NoError(t, ctx.RegisterDriver(dHTTP))
require.NoError(t, ctx.RegisterDriver(dWorker))
drivers := ctx.Drivers()
assert.Len(t, drivers, 2)
foundHTTP, ok := ctx.Driver(core.DriverTypeHTTP)
assert.True(t, ok)
assert.Equal(t, dHTTP, foundHTTP)
foundWorker, ok := ctx.Driver(core.DriverTypeWorker)
assert.True(t, ok)
assert.Equal(t, dWorker, foundWorker)
_, ok = ctx.Driver(core.DriverTypeScheduler)
assert.False(t, ok)
}
func TestPluginInterfaces(t *testing.T) {
ctx := core.NewContext(context.Background())
var p core.Plugin = &samplePlugin{name: "sample"}
assert.Equal(t, "sample", p.Name())
require.NoError(t, p.Apply(ctx))
svc, err := core.Inject[SampleService](ctx)
require.NoError(t, err)
assert.Equal(t, "Plugin: Ryan", svc.Greet("Ryan"))
var pwm core.PluginWithManifest = &samplePlugin{name: "sample"}
manifest := pwm.Manifest()
assert.Equal(t, "sample", manifest.Name)
assert.Equal(t, "1.0.0", manifest.Version)
}
func TestConcurrentAccess(t *testing.T) {
ctx := core.NewContext(context.Background())
var wg sync.WaitGroup
// Concurrently provide, inject, fork, set, and get
for i := 0; i < 50; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
ctx.Set(fmt.Sprintf("key-%d", idx), idx)
_, _ = ctx.Get(fmt.Sprintf("key-%d", idx))
child := ctx.Fork()
child.Set("child_key", idx)
}(i)
}
core.Provide[SampleService](ctx, &sampleServiceImpl{})
for i := 0; i < 50; i++ {
wg.Add(1)
go func() {
defer wg.Done()
svc, err := core.Inject[SampleService](ctx)
if err == nil {
_ = svc.Greet("Concurrency")
}
_ = core.Using(ctx, func(s SampleService) {
_ = s.Greet("Safe")
})
}()
}
wg.Wait()
}
func TestContextExtensionPointsAccessors(t *testing.T) {
ctx := core.NewContext(nil)
assert.NotNil(t, ctx.Events())
assert.NotNil(t, ctx.Router())
assert.NotNil(t, ctx.Migrations())
assert.NotNil(t, ctx.Tasks())
assert.NotNil(t, ctx.Task())
assert.NotNil(t, ctx.Schedules())
assert.NotNil(t, ctx.Schedule())
assert.NotNil(t, ctx.Settings())
assert.NotNil(t, ctx.Setting())
child := ctx.Fork()
assert.Equal(t, ctx.Events(), child.Events())
assert.Equal(t, ctx.Migrations(), child.Migrations())
assert.NotNil(t, child.Router())
assert.NotNil(t, child.Tasks())
assert.NotNil(t, child.Task())
assert.NotNil(t, child.Schedules())
assert.NotNil(t, child.Schedule())
assert.NotNil(t, child.Settings())
assert.NotNil(t, child.Setting())
}
func TestContext_ScopedExtpoints_RevertibleEffects(t *testing.T) {
root := core.NewContext(context.Background())
child := root.Fork()
// Register route, task, schedule, setting, event, middleware, whitelist on child
child.Router().GET("/test-route", func() {})
assert.Equal(t, 1, len(root.Router().Routes()))
child.Router().Use("scoped_middleware")
assert.Equal(t, 1, len(root.Router().Middlewares()))
child.Router().RegisterWhitelist("/api/v1/scoped/*")
assert.True(t, root.Router().IsWhitelisted("/api/v1/scoped/test"))
child.Tasks().Register("test:task", func() {})
assert.Equal(t, 1, len(root.Tasks().Tasks()))
child.Schedules().RegisterCron("@hourly", "test:cron", nil)
assert.Equal(t, 1, len(root.Schedules().Schedules()))
child.Settings().Register(core.SettingSchema{Key: "test.key", Default: "val"})
assert.Equal(t, 1, len(root.Settings().Schemas()))
child.On("test:event", func() {})
assert.Equal(t, 1, root.Events().Listeners("test:event"))
// Dispose child
err := child.Dispose()
assert.NoError(t, err)
// All child effects should be cleanly revoked in LIFO order
assert.Equal(t, 0, len(root.Router().Routes()))
assert.Equal(t, 0, len(root.Router().Middlewares()))
assert.False(t, root.Router().IsWhitelisted("/api/v1/scoped/test"))
assert.Equal(t, 0, len(root.Tasks().Tasks()))
assert.Equal(t, 0, len(root.Schedules().Schedules()))
assert.Equal(t, 0, len(root.Settings().Schemas()))
assert.Equal(t, 0, root.Events().Listeners("test:event"))
}
func TestContainer_InterfaceResolutionCache(t *testing.T) {
ctx := core.NewContext(context.Background())
svc := &sampleServiceImpl{prefix: "Cached:"}
core.Provide[SampleService](ctx, svc)
// 1. Initial resolution populates interfaceCache
res1, err := core.Inject[SampleService](ctx)
require.NoError(t, err)
assert.Equal(t, "Cached: Alice", res1.Greet("Alice"))
// 2. Subsequent resolutions hit interfaceCache
res2, err := core.Inject[SampleService](ctx)
require.NoError(t, err)
assert.Same(t, res1, res2)
// 3. Concurrent lookups
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
r, e := core.Inject[SampleService](ctx)
assert.NoError(t, e)
assert.Equal(t, "Cached: Bob", r.Greet("Bob"))
}()
}
wg.Wait()
// 4. Overriding/providing another service invalidates cache
svc2 := &sampleServiceImpl{prefix: "Updated:"}
core.Provide[SampleService](ctx, svc2)
res3, err := core.Inject[SampleService](ctx)
require.NoError(t, err)
assert.Equal(t, "Updated: Alice", res3.Greet("Alice"))
}
+143
View File
@@ -0,0 +1,143 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package contracts defines unified service interfaces and DTOs for cross-plugin communication.
package contracts
import (
"context"
"time"
)
// UserDTO represents a unified user data transfer object across plugins.
type UserDTO struct {
ID uint64 `json:"id,string"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
IsActive bool `json:"is_active"`
IsAdmin bool `json:"is_admin"`
NeedChangePassword bool `json:"need_change_password,omitempty"`
Bio string `json:"bio,omitempty"`
Phone string `json:"phone,omitempty"`
Gender string `json:"gender,omitempty"`
Website string `json:"website,omitempty"`
Location string `json:"location,omitempty"`
LastLoginAt time.Time `json:"last_login_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// OAuthUserInfoDTO contains user identity claims obtained from an OAuth provider.
type OAuthUserInfoDTO struct {
ID uint64 `json:"id"`
Sub string `json:"sub"`
Username string `json:"username"`
PreferredUsername string `json:"preferred_username"`
Email string `json:"email"`
Name string `json:"name"`
Active bool `json:"active"`
AvatarURL string `json:"avatar_url"`
}
// AuthSourceDTO represents an OAuth / OIDC authentication source.
type AuthSourceDTO struct {
ID uint64 `json:"id,string"`
Name string `json:"name"`
Type string `json:"type"`
DisplayName string `json:"display_name"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret,omitempty"`
OpenIDDiscoveryURL string `json:"openid_discovery_url"`
Scopes string `json:"scopes"`
IconURL string `json:"icon_url"`
IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// AuthSourceViewDTO is a sanitized view of an AuthSource for admin display.
type AuthSourceViewDTO struct {
ID uint64 `json:"id,string"`
Name string `json:"name"`
Type string `json:"type"`
DisplayName string `json:"display_name"`
IsActive bool `json:"is_active"`
IconURL string `json:"icon_url"`
ClientSecretConfigured bool `json:"client_secret_configured"`
}
// OAuthProvider defines the pluggable OAuth provider contract.
type OAuthProvider interface {
Name() string
GetAuthURL(state string) string
ExchangeCode(ctx context.Context, code string) (*OAuthUserInfoDTO, error)
}
// AuthService defines the contract for authentication, session verification, and token management.
type AuthService interface {
// RequireAuthMiddleware returns a middleware handler (compatible with gin.HandlerFunc or standard middleware).
RequireAuthMiddleware() any
// RequireAdminMiddleware returns an admin authorization middleware.
RequireAdminMiddleware() any
// GetCurrentUser retrieves the authenticated UserDTO from context.
GetCurrentUser(ctx context.Context) (*UserDTO, error)
// GetCurrentUserID retrieves the authenticated user ID from session/context.
GetCurrentUserID(ctx context.Context) (uint64, error)
// VerifyToken validates an access token and returns the associated user DTO.
VerifyToken(ctx context.Context, token string) (*UserDTO, error)
// CreateSession establishes an authenticated session for the given user ID.
CreateSession(ctx context.Context, userID uint64, extras map[string]any) (string, error)
// RevokeToken invalidates a specific access token by its hash.
RevokeToken(ctx context.Context, tokenHash string) error
// RevokeUserSessions revokes all active sessions and cached tokens for a user.
RevokeUserSessions(ctx context.Context, userID uint64) error
// InvalidateCachedUser invalidates cached user profile data.
InvalidateCachedUser(ctx context.Context, userID uint64)
// InvalidateCachedToken invalidates cached access token data.
InvalidateCachedToken(ctx context.Context, tokenHash string)
// ListAuthSources lists all configured authentication sources.
ListAuthSources(ctx context.Context) ([]AuthSourceViewDTO, error)
// CreateAuthSource creates a new authentication source.
CreateAuthSource(ctx context.Context, source AuthSourceDTO) (*AuthSourceDTO, error)
// UpdateAuthSource updates an authentication source.
UpdateAuthSource(ctx context.Context, id uint64, source AuthSourceDTO) (*AuthSourceDTO, error)
// DeleteAuthSource removes an authentication source.
DeleteAuthSource(ctx context.Context, id uint64) error
// ToggleAuthSource toggles the active state of an authentication source.
ToggleAuthSource(ctx context.Context, id uint64) (*AuthSourceDTO, error)
// DisallowTokenAuthMiddleware returns a middleware that rejects requests authenticated via access token.
DisallowTokenAuthMiddleware() any
}
// AuthRegistry allows downstream and domain plugins to register custom authentication providers.
type AuthRegistry interface {
RegisterOAuthProvider(name string, provider OAuthProvider)
GetOAuthProvider(name string) (OAuthProvider, bool)
ListOAuthProviders() []string
}
// Auth context keys — stored in Gin context by auth middleware, consumed by domain plugins.
const (
AuthUserIDKey = "user_id"
AuthUserNameKey = "username"
AuthUserObjKey = "user_obj"
AuthTokenAuthKey = "token_auth" // marks if request uses access token auth
AuthTokenAdminKey = "token_admin" // whether the access token has admin privileges
)
+32
View File
@@ -0,0 +1,32 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package contracts defines unified service interfaces and DTOs for cross-plugin communication.
package contracts
import (
"context"
"errors"
"time"
)
// ErrCacheMiss is returned when an item is not found in the cache.
var ErrCacheMiss = errors.New("contracts/cache: key not found")
// CacheService defines the contract for multi-layer cache operations (RAM L1 + Redis L2 + Pub/Sub invalidation).
type CacheService interface {
// Get retrieves an item from cache into target. Returns ErrCacheMiss if not found.
Get(ctx context.Context, key string, target any) error
// Set stores an item into cache with a specified time-to-live duration.
Set(ctx context.Context, key string, value any, ttl time.Duration) error
// Delete evicts a key from local and remote cache tiers and broadcasts invalidation.
Delete(ctx context.Context, key string) error
// GetOrSet retrieves an item from cache, or calls loader to populate and return if missing.
GetOrSet(ctx context.Context, key string, target any, ttl time.Duration, loader func() (any, error)) error
// Invalidate is a semantic alias for Delete.
Invalidate(ctx context.Context, key string) error
}
+12
View File
@@ -0,0 +1,12 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package contracts
// CaptchaService defines the contract for CAPTCHA challenge issuance,
// redemption, and scoped verification middleware.
type CaptchaService interface {
VerifyMiddleware(scope string) any
ChallengeHandler() any
RedeemHandler() any
}
+33
View File
@@ -0,0 +1,33 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package contracts
import (
"context"
"time"
)
// SystemConfigDTO represents a system configuration key-value entry.
type SystemConfigDTO struct {
Key string `json:"key"`
Value string `json:"value"`
Type string `json:"type"`
Visibility int `json:"visibility"`
Description string `json:"description"`
UpdatedAt time.Time `json:"updated_at"`
CreatedAt time.Time `json:"created_at"`
}
// SystemConfigService defines the unified contract for querying and mutating system configurations.
type SystemConfigService interface {
GetByKey(ctx context.Context, key string) (SystemConfigDTO, error)
ListByKeys(ctx context.Context, keys []string) (map[string]SystemConfigDTO, error)
ListVisible(ctx context.Context) ([]SystemConfigDTO, error)
ListByType(ctx context.Context, configType string) ([]SystemConfigDTO, error)
GetIntByKey(ctx context.Context, key string) (int, error)
GetBoolByKey(ctx context.Context, key string) (bool, error)
SaveOrUpdate(ctx context.Context, key, value string) error
InvalidateCache(ctx context.Context, key string) error
InvalidateAllCaches(ctx context.Context) error
}
+14
View File
@@ -0,0 +1,14 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package contracts
import "context"
// PublicConfigProvider supplies GET /api/v1/config/public.
// The owner of w_system_configs (admin) must provide this. The payload is a
// flat key/value map of visibility=1 rows; the frontend reads keys such as
// cap_login_enabled directly off data.
type PublicConfigProvider interface {
PublicConfig(ctx context.Context) (map[string]string, error)
}
+23
View File
@@ -0,0 +1,23 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package contracts defines unified service interfaces and DTOs for cross-plugin communication.
package contracts
import (
"context"
"gorm.io/gorm"
)
// DBService defines the standard contract for relational database access and multi-datasource routing.
type DBService interface {
// GORM returns the underlying GORM database instance.
GORM() *gorm.DB
// DB returns the GORM database instance bound to the given context.
DB(ctx context.Context) *gorm.DB
// Named returns a named database connection if multiple data sources or replicas are configured.
Named(name string) *gorm.DB
}
+158
View File
@@ -0,0 +1,158 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package contracts defines unified service interfaces and DTOs for cross-plugin communication.
package contracts
// ======================================================================
// Domain Event Topic Constants
// ======================================================================
//
// All cross-plugin domain event topics MUST be declared here so that
// producers and consumers share the same string values without importing
// each other's implementation packages.
// ======================================================================
// --- Auth & User Events ---
const (
// EventTopicAdminLoggedIn fires when an admin user logs in.
EventTopicAdminLoggedIn = "admin:logged_in"
// EventTopicUserCreated fires when a new user account is created.
EventTopicUserCreated = "user:created"
// EventTopicUserUpdated fires when a user profile is updated.
EventTopicUserUpdated = "user:updated"
// EventTopicUserDeleted fires when a user account is deleted.
EventTopicUserDeleted = "user:deleted"
// EventTopicUserStatusChanged fires when a user account active status changes.
EventTopicUserStatusChanged = "user:status_changed"
// EventTopicTokenRevoked fires when an access token is revoked.
// #nosec G101
EventTopicTokenRevoked = "auth:token_revoked"
)
// --- Admin & System Events ---
const (
// EventTopicConfigChanged fires when a system configuration value changes.
EventTopicConfigChanged = "admin:config_changed"
// EventTopicSystemCleanup fires when a periodic system cleanup completes.
EventTopicSystemCleanup = "admin:system_cleanup"
)
// --- Task Events ---
const (
// EventTopicTaskCompleted fires when an asynchronous background task execution finishes.
EventTopicTaskCompleted = "task:completed"
)
// TaskCompletedEvent carries task execution outcome details.
type TaskCompletedEvent struct {
TaskID string `json:"task_id"`
TaskName string `json:"task_name"`
TaskType string `json:"task_type"`
Status string `json:"status"`
Duration int64 `json:"duration"`
ErrorMsg string `json:"error_msg,omitempty"`
ResultMsg string `json:"result_msg,omitempty"`
Payload string `json:"payload,omitempty"`
Detail string `json:"detail,omitempty"`
}
// --- Upload / Storage Events ---
const (
// EventTopicUploadCreated fires when a new file upload is recorded.
EventTopicUploadCreated = "upload:created"
// EventTopicUploadDeleted fires when a file upload is removed.
EventTopicUploadDeleted = "upload:deleted"
// EventTopicIngestComplete fires when a programmatic file ingest finishes.
EventTopicIngestComplete = "upload:ingest_complete"
)
// --- Message Gateway Events ---
const (
// EventTopicNotificationSent fires when a push notification is dispatched.
EventTopicNotificationSent = "message:notification_sent"
// EventTopicChannelBound fires when a user binds a messaging channel.
EventTopicChannelBound = "message:channel_bound"
// EventTopicChannelUnbound fires when a user unbinds a messaging channel.
EventTopicChannelUnbound = "message:channel_unbound"
)
// --- Risk Control Events ---
const (
// EventTopicAccessLogRecorded fires when a user access log entry is recorded.
EventTopicAccessLogRecorded = "risk:access_log_recorded"
)
// ======================================================================
// Domain Event Payload DTOs
// ======================================================================
// AdminLoggedIn 管理员登录领域事件载荷
type AdminLoggedIn struct {
User *UserDTO `json:"user"`
IP string `json:"ip"`
}
// UserCreatedEvent fires when a new user account is created.
type UserCreatedEvent struct {
User *UserDTO `json:"user"`
Password string `json:"-"`
}
// ConfigChangedEvent fires when a system configuration value changes.
type ConfigChangedEvent struct {
Key string `json:"key"`
OldVal any `json:"old_val,omitempty"`
NewVal any `json:"new_val,omitempty"`
}
// UploadCreatedEvent fires when a new file upload is recorded.
type UploadCreatedEvent struct {
UploadID uint64 `json:"upload_id,string"`
UserID uint64 `json:"user_id,string"`
FileName string `json:"file_name"`
FileSize int64 `json:"file_size"`
MimeType string `json:"mime_type"`
}
// NotificationSentEvent fires when a push notification is dispatched.
type NotificationSentEvent struct {
UserID uint64 `json:"user_id,string"`
Channel string `json:"channel"`
Title string `json:"title"`
Success bool `json:"success"`
ErrorInfo string `json:"error_info,omitempty"`
}
// UserStatusChangedEvent fires when a user status is enabled/disabled.
type UserStatusChangedEvent struct {
UserID uint64 `json:"user_id,string"`
IsActive bool `json:"is_active"`
}
// TokenRevokedEvent fires when an access token is revoked.
type TokenRevokedEvent struct {
UserID uint64 `json:"user_id,string"`
TokenHash string `json:"token_hash"`
}
// UserDeletedEvent fires when a user account is deleted.
type UserDeletedEvent struct {
CurrentUserID uint64 `json:"current_user_id,string"`
TargetUserID uint64 `json:"target_user_id,string"`
}
// SystemCleanupEvent fires when a periodic system cleanup is triggered.
type SystemCleanupEvent struct {
TriggeredAt string `json:"triggered_at"`
}
+36
View File
@@ -0,0 +1,36 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package contracts defines unified service interfaces and DTOs for cross-plugin communication.
package contracts
import (
"context"
"time"
)
// Rate specifies a rate limit of Limit events permitted within a Period.
type Rate struct {
Limit int `json:"limit"`
Period time.Duration `json:"period"`
}
// RateLimitResult holds the outcome of a rate limit check.
type RateLimitResult struct {
Allowed bool `json:"allowed"`
Remaining int `json:"remaining"`
ResetAfter time.Duration `json:"reset_after"`
RetryAfter time.Duration `json:"retry_after"`
}
// LimiterService defines the rate limiting service contract for cross-plugin communication.
type LimiterService interface {
// Allow checks whether 1 event for the given key is permitted under the specified rate.
Allow(ctx context.Context, key string, rate Rate) (*RateLimitResult, error)
// AllowN checks whether n events for the given key are permitted under the specified rate.
AllowN(ctx context.Context, key string, rate Rate, n int) (*RateLimitResult, error)
// Reset clears the rate limit state for the given key.
Reset(ctx context.Context, key string) error
}
+39
View File
@@ -0,0 +1,39 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package contracts defines unified service interfaces and DTOs for cross-plugin communication.
package contracts
import (
"context"
)
// LoggerService defines the contract for structured logging with trace ID and context correlation.
type LoggerService interface {
// Debug logs a debug message with optional key-value structured fields.
Debug(ctx context.Context, msg string, keysAndValues ...any)
// Info logs an informational message with optional key-value structured fields.
Info(ctx context.Context, msg string, keysAndValues ...any)
// Warn logs a warning message with optional key-value structured fields.
Warn(ctx context.Context, msg string, keysAndValues ...any)
// Error logs an error message with optional key-value structured fields.
Error(ctx context.Context, msg string, keysAndValues ...any)
// Debugf logs a formatted debug message.
Debugf(ctx context.Context, format string, args ...any)
// Infof logs a formatted informational message.
Infof(ctx context.Context, format string, args ...any)
// Warnf logs a formatted warning message.
Warnf(ctx context.Context, format string, args ...any)
// Errorf logs a formatted error message.
Errorf(ctx context.Context, format string, args ...any)
// With returns a child logger enriched with additional key-value attributes.
With(keysAndValues ...any) LoggerService
}
+29
View File
@@ -0,0 +1,29 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package contracts defines unified service interfaces and DTOs for cross-plugin communication.
package contracts
import "context"
// PushNotificationTemplate defines notification message template payload.
type PushNotificationTemplate struct {
Title string
Content string
Level string
Ext map[string]any
}
// PushEventMeta defines metadata for a system push event.
type PushEventMeta struct {
Key string
Name string
Description string
DefaultTemplate PushNotificationTemplate
}
// PushRegistry defines the interface for registering built-in events.
type PushRegistry interface {
RegisterBuiltInEvent(meta PushEventMeta)
SyncEvents(ctx context.Context) error
}

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