# zetl **Repository Path**: cpsoft13/zetl ## Basic Information - **Project Name**: zetl - **Description**: 微型数据仓库,包含简单的etl能力。 - **Primary Language**: Go - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2024-12-20 - **Last Updated**: 2026-10-22 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # zEtl 轻量级可视化 ETL 引擎 + BI 报表平台。纯 Go 单体(v1.0),Web 管理台可视化编排 数据管道,无需写 SQL 即可完成多源抽取、清洗、聚合与报表呈现。 English README: [README_EN.md](README_EN.md) --- ## 能力概览 | 能力 | 说明 | |---|---| | **数据源** | MySQL 为主,支持注册多个外部库(如企迈 POS、订货系统、物料中台) | | **19 类算子** | source / join / filter / compute / dim-map / mapping / aggregate / project / pivot / unpivot / lua / cast / split / dedup / rename / replace / trim / column_filter / null_replace | | **可视化规则编辑** | 拖拽式 DAG 编辑器,字段、算子、条件全部表单化 | | **数据集** | ETL 落地的表,可分组管理、字段元数据维护 | | **BI 报表** | 维度/度量/筛选/排序/聚合后过滤(HAVING)、计算字段、百分比格式 | | **定时调度** | cron 表达式调度,任务失败重试 + Webhook 告警 | | **语义增强** | 本体(实体/属性/关系)+ LLM 辅助字段语义标注 | | **独立运行** | 单机部署:仅需一个 MySQL,本地账号登录,不依赖任何外部身份平台(见「独立部署」) | 技术栈:Go 1.26(后端)· Vue3 + TypeScript + z-web-ui(前端)· MySQL(元数据与落地表) --- ## 架构 ``` ┌─────────────────────────────┐ 浏览器 ──HTTP──► │ web-backend (:8080) │ │ · REST API(118 个端点) │ │ · 静态资源托管 │ │ · 不执行 ETL │ └──────────────┬──────────────┘ │ 共享 MySQL(etl_* 表) ┌──────────────▼──────────────┐ │ data-processor │ │ · 轮询 etl_task 队列 │ │ · 执行规则 DAG │ │ · 落地写入目标表 │ └──────────────┬──────────────┘ │ keyset 分页抽取 ┌──────────────▼──────────────┐ │ 外部业务库(只读) │ │ 企迈 POS / 订货系统 / 中台 │ └─────────────────────────────┘ ``` **两个进程分工明确**:`web-backend` 只管 API 和静态页(重启不影响正在跑的任务), `data-processor` 只管执行任务。两者通过 MySQL 的 `etl_task` 表通信。 > ⚠️ **必须两个都起**。只起 web-backend 的话规则能保存、能手动触发,但**定时调度不会执行** > —— 任务会一直挂在 `pending` 状态。 --- ## 快速开始 ### 前置条件 - Go ≥ 1.26 - Node ≥ 20(构建前端) - MySQL ≥ 8.0(建议 8.0.36+) ### 1. 拉取代码 ```bash git clone git@gitee.com:cpsoft13/zetl.git cd zetl ``` ### 2. 准备数据库 ```sql CREATE DATABASE zetl DEFAULT CHARSET utf8mb4; -- 首次启动会自动建表;ZETL_AUTH_MODE=local 时还会创建初始管理员,无需手动导入 DDL ``` ### 3. 配置环境变量 ```bash cp deploy/env/zetl.env.example /etc/zetl/zetl.env chmod 600 /etc/zetl/zetl.env # 含数据库口令,必须收紧权限 vi /etc/zetl/zetl.env ``` 单机独立部署用 `deploy/env/zetl.standalone.env.example`(见「独立部署(单机通用 BI)」)。 关键项: | 变量 | 说明 | 必填 | |---|---|---| | `ZETL_DSN` | MySQL 连接串,**必须带 `parseTime=true`** | ✅ | | `ZETL_AUTH_MODE` | 身份模式:`unified`(默认,统一身份)/ `local`(本地账号,独立部署)。见「独立部署」 | | | `ZETL_ADMIN_PASSWORD` | 初始管理员密码(仅用户表为空时生效;`local` 模式建种子账号) | | | `ZETL_JWT_SECRET` | JWT 签名密钥,**生产必配**(见下方「认证」)。≥32 字节 | | | `ZETL_LISTEN` | web-backend 监听地址,默认 `:8080` | | | `ZETL_WEB_DIR` | 前端静态资源目录 | | | `ZETL_PRIMARY_KEYS` | 表主键映射,大表必配(见下方「性能」) | | | `ZETL_BATCH_SIZE` | 每批读写行数,默认 5000 | | | `ZETL_ALERT_WEBHOOK` | 任务最终失败的告警地址(飞书/钉钉),留空禁用 | | | `ZETL_TRUSTED_JWKS_URL` | 跨应用免登 RS256 主通道(zauth 公钥端点),见下方「跨应用免登」 | | | `ZETL_TRUSTED_JWT_SECRET` | 跨应用免登 legacy 通道密钥(= 旧 zWorker 的 `Auth.AccessSecret`,双签过渡),见下方「跨应用免登」 | | > 🔐 **不要用 root 账号连生产库**。建最小权限账号:数据表只读 + `etl_*` 表读写。 > ⚠️ **未配 `ZETL_JWT_SECRET` 时会退回随机密钥**(启动日志有告警):重启后所有 > 已签发 JWT 立即失效,且多实例之间互不认签。生产务必配置,且多实例共用同一个值。 ### 4. 构建 ```bash # 后端 cd src && go build -o ../bin/zetl-web-backend ./cmd/web-backend cd src && go build -o ../bin/zetl-data-processor ./cmd/data-processor # 前端 cd src/web && npm install && npm run build # 产物在 src/web/dist ``` ### 5. 启动 **macOS(launchd)**: ```bash cp ~/Library/LaunchAgents/com.zetl.*.plist ~/Library/LaunchAgents/ 2>/dev/null launchctl load ~/Library/LaunchAgents/com.zetl.webbackend.plist launchctl load ~/Library/LaunchAgents/com.zetl.dataprocessor.plist ``` **Linux(systemd)**:参见 `deploy/systemd/`,或直接: ```bash ./bin/zetl-web-backend & ./bin/zetl-data-processor & ``` ### 6. 访问 浏览器打开 `http://localhost:8080`,用初始管理员账号登录。 局域网访问:`http://<本机内网IP>:8080`(需放行防火墙)。 --- ## 核心概念 ### 规则(Rule) 一条规则 = 一个 **DAG** + 目标表。节点是算子,边是数据流向。 典型链路: ``` source(从数据源抽取) → compute(拼装关联键) → dim-map(查维表补属性,三级兜底) → aggregate(按维度聚合) → project(裁字段) → 落地目标表 ``` ### 数据源 在「数据源」页注册外部库。**大表必须在 `ZETL_PRIMARY_KEYS` 里配主键**, 否则回退 OFFSET 分页,数据量大时会拖垮源库。 配置格式:`表名=主键列`,逗号分隔。支持复合主键**只需填第一列**即可 (引擎会自动读取全部主键列做组合游标分页)。 #### 数据库类型(driver) 「数据源」表单第一项即 **数据库类型**,决定连接串格式与默认端口: | 类型 | 默认端口 | 连接串格式 | |---|---|---| | MySQL | 3306 | `user:pass@tcp(host:3306)/db?parseTime=true` | | ClickHouse | **9000** | `clickhouse://user:pass@host:9000/db?dial_timeout=10s` | - 切换类型时端口会跟随联动**(仅当当前端口等于旧类型的默认值时)**, 避免覆盖你手填的非标准端口。 - 老数据源**无需任何操作**:`etl_datasource.driver` 由幂等迁移自动补列, 存量行取默认值 `mysql`。 - ⚠️ **ClickHouse 只支持 native 协议(9000)**。填 `http://` / `https://` 连接串 (HTTP 传输 8123/8443)会被**明确拒绝**并提示改用 9000 —— 因为放行会拼出「native 协议打 HTTP 端口」的无效 DSN,症状只有一句「连接超时」。 - ⚠️ **ClickHouse 尚未做真实端到端验证**(开发环境无 CH 实例)。 方言层(DSN / 端口 / 凭据转义 / 驱动注册)与driver 迁移已测; 但**类型映射、upsert、日期函数等语义差异项未适配**,真实 CH 上仍会出错。 本期定位是「连接能建起来」,不是「全功能可用」。 ### HTTP 数据源(source 算子的 api 模式) `source` 节点可配`api` 字段从 HTTP API 拉取(issue IKKCTJ), 与 `datasetId`/`table`/`sql` **互斥**。典型场景:天气、行情、开放平台数据。 ```json { "operator": "source", "params": { "api": { "url": "https://archive-api.open-meteo.com/v1/archive", "params": { "latitude": "30.29", "longitude": "120.16", "start_date": "2026-08-25", "end_date": "2026-08-27", "daily": "temperature_2m_max,temperature_2m_min", "timezone": "Asia/Shanghai" }, "rowsPath": "daily", "fields": { "time": "d", "temperature_2m_max": "temp_max" }, "keepColumns": { "latitude": "lat", "longitude": "lon" } } } } ``` | 字段 | 说明 | |---|---| | `url` | 完整 URL(仅 http/https)。已带的 query 与 `params` 合并,后者覆盖 | | `params` | query 参数 | | `headers` | 请求头(预留 API key 场景) | | `rowsPath` | 平行数组所在对象的点分路径。**其下各数组必须等长**,不等长直接报错 | | `fields` | JSON 路径 → 输出列名(路径相对 `rowsPath` 对象),至少一条 | | `keepColumns` | 额外复制的标量列(路径相对**响应根对象**),如顶层坐标 | | `timeoutSeconds` | 单请求超时,默认 30 | | `maxRetries` | 失败重试次数,默认 3;429 会优先服从 `Retry-After` | 行为要点: - **一次请求取全量**,不分页、不支持断点续读。失败重试是整体重拉, **幂等性由落地表的唯一键保证** - 多城市一次请求即可(`latitude`/`longitude` 支持逗号分隔,响应是 JSON 数组), 不需要循环编排 - **成功判定是三重校验**(2xx + body 非空无 `error` + 行数 > 0)。 实测 open-meteo 缺参数会返回 `HTTP 200 + 空 body`,只看状态码会「成功 0 行」 - ⚠️ **`keepColumns` 必须取响应里的值,不能拿请求参数回填**。open-meteo 会把 坐标吸附到气象网格(请求 30.29 → 响应 30.263618),回填会得到与实际观测点 不符的假坐标 - ⚠️ **`writeMode` 必须是 `append`**。`overwrite` 会先 `DROP TABLE` 再建, 预建的复合主键会被丢弃 - ⚠️ **预建表必须包含全部输出列**。写入的列清单按字段映射**全量**拼, 漏一列会报 `1054 Unknown column` 整条任务失败 - ⚠️ **不预建表时 `append` 不幂等,每跑一次数据翻倍**(实测 22 → 44)。 自动建表产出的是**零索引**的表(列全 `NULL DEFAULT`、无任何 UNIQUE/PRIMARY), 而 `append` 的 DDL 只是 `CREATE TABLE IF NOT EXISTS` —— 幂等的那一半 来自目标表的唯一键,不是 `append` 自身的性质。**HTTP 源必然踩到**: 目标表总是第一次新建的 - ⚠️ **唯一键要覆盖全部标识维度**。以浙江 11 城实测:open-meteo 会把坐标吸附到 气象网格,**绍兴与舟山落到同一个网格点**(响应纬度同为 `29.982424`), 所以 `(lat, d)` 会把 22 行撞成 20 行 —— 正确做法是 **`(lat, lon, d)`** 多城落地的建表参考(11 城 × 每日): ```sql CREATE TABLE dwd_city_weather_day ( d DATE NOT NULL, lat DOUBLE NOT NULL, lon DOUBLE NOT NULL, temp_max DOUBLE, temp_min DOUBLE, precip DOUBLE, tz VARCHAR(64), PRIMARY KEY (lat, lon, d) -- ★ 必须含 lon:多城可能共用同一纬度网格 ) ENGINE=InnoDB; ``` 规则里对应 `"writeMode": "append"`(`overwrite` 会 DROP 掉上面的主键)。 ### 计算字段(computedFields) 报表里的派生指标。表达式引用**聚合结果的别名**(不带方括号): ```json { "name": "标准订销比", "expr": "sum_order_amount/sum_std_amount" } ``` ⚠️ **必须用别名式,不能写 `SUM([a])/SUM([b])`** —— 函数式表达式只有被 `measures` 引用(`computed: true`)才生效,单放 `computedFields` 不会渲染。 配合列格式 `{ "formatType": "percent", "decimalPlaces": 2 }` 即按百分比显示。 ### 聚合后过滤(HAVING) 「区间内无数据的门店不显示」这类需求用 `having`,它作用在**聚合之后**: ```json "having": [{ "expr": "sum_real+sum_std+sum_order", "op": ">", "value": 0 }] ``` `expr` 同样引用聚合别名,多个条件是 **OR 语义用加法表达**。 ### 相对日期筛选 报表筛选**一律用单条 `between`**,不要写 `>= @x` 和 `<= @y` 两条: ```json { "field": "report_date", "op": "between", "value": ["@last30d", "@today"] } ``` `@xxx` 是相对日期关键字。**两条式会被双重展开导致区间塌缩**成某一天, 表现为「报表打开无数据」。 ### 认证(双通道) 登录 `POST /api/auth/login` **同时**下发两种凭证: | 通道 | 载体 | 适用场景 | |---|---|---| | Cookie | `Set-Cookie: zetl_session`(HttpOnly) | 浏览器前端(默认) | | Bearer | 响应体 `token` 字段 | 钉钉容器、脚本、外部系统 | 调用带 Bearer 的接口: ```bash TOKEN=$(curl -s -X POST localhost:8080/api/auth/login \ -H 'Content-Type: application/json' \ -d '{"username":"admin","password":"..."}' | jq -r .token) curl -s localhost:8080/api/auth/me -H "Authorization: Bearer $TOKEN" ``` 两通道同时存在时**以 Bearer 优先**(它是显式意图)。 **令牌有效期 12 小时**,比 Cookie 会话(24 小时)短 —— Bearer 可能落在容器等 缓存不可控的环境里,缩短暴露窗口。 **改密码 / 改角色 / 删号会立即废掉已签发的 JWT**:`etl_user.token_version` 在变更时自增,校验时比对,版本不符即拒绝。这是为了在 JWT 无状态的前提下 保住「强制下线」能力,代价是每次 Bearer 请求多一次主键查询。 > 登出只清 Cookie 会话 —— Bearer 是自签名的,服务端无状态可删。 > 真正的登出语义由调用方丢弃令牌实现。这是 JWT 方案的固有特性。 **JWT 实现说明**:HS256 对称签名,用标准库 `crypto/hmac` 手写,不引入第三方依赖 (仓库为 vendor 模式)。算法固定 HS256 且**不接受 header 里的 alg**, 杜绝 `alg=none` 降级攻击。 ### 跨应用免登(与 zWorker / zauth 共享登录态) 账号体系已抽离为独立应用 **zauth**(2026-10-10,M4):token 由 zauth 用 RS256 私钥 签发,公钥经 JWKS 发布,zEtl 拉公钥验签(私钥不落本服务,加新应用无需分发 secret)。 旧 zWorker HS256 共享密钥通道保留为过渡(双签窗口,M5 下线)。 #### 配置 | 变量 | 默认 | 说明 | |---|---|---| | `ZETL_TRUSTED_JWKS_URL` | 空 | zauth 公钥端点,例 `http://zauth:8300/.well-known/jwks.json`。**推荐配置**;启动时预热,不可达直接起不来 | | `ZETL_TRUSTED_JWKS_ISSUER` | `zauth` | RS256 通道期望的 `iss` | | `ZETL_TRUSTED_AUD` | `zetl` | RS256 通道期望的 `aud`;token 缺失或不匹配一律拒绝 | | `ZETL_TRUSTED_JWT_SECRET` | 空 | legacy 通道(= 旧 zWorker 的 `Auth.AccessSecret`);双签窗口期保留 | | `ZETL_TRUSTED_JWT_ISSUER` | `zworker` | legacy 通道期望的 `iss` | | `ZETL_TRUSTED_ADMIN_ROLES` | `1` | 映射为本平台 admin 的外部 role 值,逗号分隔 | | `ZETL_TRUSTED_AUTO_CREATE` | `true` | 首次外部用户是否自动建号;设 `0` 则只认已映射用户 | > 两个通道都未配置时 `/api/auth/exchange` 恒返回 401,且**不回落随机密钥** —— > 回落会让「配错了」表现成「免登时灵时不灵」这类极难归因的故障。 > 两通道按 token header 的 alg 显式分发(白名单仅 RS256/HS256),密钥来源完全隔离, > 不存在「RS 公钥当 HMAC 密钥」的算法混淆面。 #### 跳转协议(M4 起推荐统一入口) 从任意应用进入 zEtl,统一走 zauth 的 authorize 协议: ``` https:///authorize?app=zetl&redirect=https:/// ``` zauth 统一登录页(钉钉内自动免登 / 浏览器扫码 / 账号密码)→ 302 回 `redirect#token=` → zEtl 既有落地逻辑原样接管。 zEtl 登录页在配置了 `VITE_ZAUTH_URL` 构建变量时也会展示「统一身份登录」入口。 直拼 token 的旧形态仍兼容(fragment 传递不变): ``` https:///#/sso?token= ``` 用 fragment(`#` 之后)而非 query(`?token=`)的原因:fragment **不会**随 HTTP 请求发给服务器,因此不进 nginx / 网关 / 负载均衡的访问日志;query 会进,且 `Referer` 头在部分跳转链里会把它带到第三方。 zEtl 前端启动时检测该 fragment → `POST /api/auth/exchange` 换本地登录态 → 立即用 `replaceState` 把 token 从地址栏抹掉(不给后退栈留记录)。 ```bash # 端点用法(脚本 / 服务间调用) curl -s -X POST https:///api/auth/exchange \ -H 'Content-Type: application/json' \ -d '{"token":""}' # 响应与 /api/auth/login 同构:Set-Cookie + {id, username, role, token, expiresAt} ``` #### 映射键统一(M4) 外部身份映射键 `ext_iss` 在 RS256 通道启用后统一记为 `zauth`(不再跟随 token 的 legacy iss):双签窗口内同一人无论拿哪种 token 进来,都映射到**同一个**本地账号。 启动时执行一次性幂等回填:`UPDATE etl_user SET ext_iss='zauth' WHERE ext_iss='zworker'`, 第二次运行 0 行、零副作用。 #### 设计要点 **外部 token 只在 exchange 端点可用一次,不能直接调 API。** 这是有意的边界:zWorker 的 claims 里没有 `token_version`(无撤销能力)、 没有 zEtl 用户主键(拿不到本地角色)、有效期 24 小时(比 zEtl 自己的 12 小时长)。 若允许外部 token 直接授权,等于把 zWorker 的授权语义整个漏进 zEtl, 任何一处 claims 映射错误都是权限绕过且难以归因。 **外部系统只决定「你是谁」,不能决定「你能做什么」。** 角色权威始终在本地 `etl_user`:外部 role 只在**首次建号**时决定初始角色,之后不参与判定。 代价是 zWorker 侧降级不会传导到 zEtl,需本地管理员手工调整 —— 这是刻意取舍,不是缺陷。 **角色映射**:`role ∈ ZETL_TRUSTED_ADMIN_ROLES` → 本平台 `admin`,其余一律 `readonly`。zWorker 的 `manager(2)` 在其体系内有业务管理权,但不等价于数据平台的 管理权,故默认不映射为 admin。 **自动建号的账号**:用户名为 `zw_<外部 userId>`(zWorker 的 JWT 里没有 username, 只能由 `userId` 派生),密码为随机不可登录值 —— 该账号只走外部免登通道进入。 **跨系统 id 撞号**:唯一键是 `(ext_iss, ext_sub)` 复合键,不是单列 `ext_sub`。 不同系统的用户 id 可能撞号,单列唯一会误判为「已映射」而把 A 系统的用户当成 B 系统的用户。实测确认:`('zworker','42')` 重复被拒(1062), `('other-system','42')` 允许共存。 **数据库迁移**:`etl_user` 的 `ext_iss` / `ext_sub` 两列与 `uk_ext_identity` 唯一键由服务启动时幂等补齐(`information_schema` 判定,见 `AuthStore.ensureExternalIdentity`)。老库自动升级,无需手工 DDL。 `schema_auth.sql` 已发布故不可补改 —— 新列一律走运行时 `ALTER`, 避免两份定义随时间漂移。 --- ## 性能与大表 ### 抽取分页 引擎用 **keyset 分页**(`WHERE pk > last ORDER BY pk LIMIT n`),不是 OFFSET。 - 主键索引缺失会退化为全表扫描,务必配主键 - 复合主键表(`(date, store_id)`)引擎自动用全部主键列做组合游标, **同一天的数据不会被截断** ### 批大小 `ZETL_BATCH_SIZE` 默认 5000。千万级表保持默认;内存吃紧可下调, 吞吐不够再上调。 ### 推送链路耗时参考 订销比 30 天全量回填(3 张事实表 + 汇总)约 16 分钟,其中瓶颈在源库 按年分区大表的聚合扫描。若源库是弱算力,建议按 10 天分片提交。 --- ## 目录结构 ``` ├── src/ Go 后端 + Vue 前端 │ ├── api/ REST API(118 端点) │ ├── engine/ DAG 执行引擎 │ │ ├── operators/ 19 类算子实现 │ │ └── rules/ 规则模型 │ ├── datasource/ 数据源接入 + keyset 分页读取 │ ├── scheduler/ cron 调度 + 任务队列 │ ├── llm/ LLM 接入(语义增强) │ ├── cmd/ 两个可执行入口 │ └── web/ 前端(Vue3 + z-web-ui) ├── deploy/ 部署资产(env 模板 / systemd / 脚本) ├── scripts/ 业务分析脚本(见 scripts/README.md) ├── dingxiao_etl/ 订销比 ETL 链路(案例参考) ├── sql/ 建表 DDL └── wiki/ 规划文档 ``` --- ## 常见问题 **Q:规则保存了但一直不执行?** 检查 `data-processor` 是否在跑(`ps aux | grep zetl-data-processor`)。 只有 web-backend 的话定时调度不会触发。 **Q:报表打开显示「无数据」,但后端 aggregate 接口返回 200?** 多半是相对日期筛选写法问题,见上文「相对日期筛选」。 **Q:任务失败但日志没细节?** 查 `etl_task` 表的 `error_message` 字段;也可在 Web 台「任务」页看。 **Q:抽取行数比源表少?** 多为复合主键表的分页问题。当前引擎已用组合游标修复;若仍复现, 确认该表在 `ZETL_PRIMARY_KEYS` 中的配置与实际主键一致。 **Q:如何验证数据正确?** 跑对账脚本做源库与落地表的逐日比对,例如订销比: ```bash python dingxiao_etl/reconcile_dingxiao.py 2026-09-01 2026-09-30 ``` 正常输出 `差异: rows=+0 sum=+0.00`。若 `Δstd` 偏离大,先回源库核对 对应日期源表本身是否完整——**不一定是 ETL 丢数**。 --- ## 业务脚本 `scripts/` 下是独立的业务分析脚本(销量预测、物料换算、基线对账), 不依赖 Web 服务,直接跑。**数据源凭据统一用根目录的 `ds_config`**: ```bash cp ds_config.template.py ds_config.local.py # 填入 password ``` `ds_config.local.py` 含真实凭据,已 gitignore。详见 [scripts/README.md](scripts/README.md)。 ## 独立部署(单机通用 BI) 本平台不依赖任何外部身份平台也能独立运行:**一个 MySQL + 两个进程**即可。 ### 一键启停(推荐) 仓库根的 `./start.sh` 是最短路径 —— 它按需构建(前端产物过期 / 二进制缺失或源码更新) 再启动两个进程,并在启动前把工具链、库连通性、端口归属一次查清: ```bash ./start.sh # 按需构建 + 重启 web + worker(旧进程先停掉)+ 健康检查 ./start.sh --start-only # 只启动:已在运行就跳过,不杀旧进程 ./start.sh status # pid / 端口 / 二进制 / 健康检查 ./start.sh stop # 先停 worker 再停 web,并校验端口释放 ./start.sh fg worker # 前台跑单个服务(看实时输出 / 会话内调试) ./start.sh doctor # 只自检不启动 ./start.sh build # 强制全量构建(含前端;--no-web 只建二进制) ``` `./start.sh` 默认是**重启**语义而不是「已在运行就跳过」:否则刚重建完二进制却看到 「已在运行 —— 跳过」,新旧二进制混跑,症状是「代码改了但行为没变」。要旧行为加 `--start-only`。旧进程若是**丢了 pid 文件的残留**(上次被强杀 / 机器重启 / `fg` 起的) 也会被自动接管 —— 判据是**可执行文件路径**必须等于 `run/bin/<目标二进制>`, 同机其它 zEtl 安装(如 `~/zetl/bin`)一律不碰。 它只是 `deploy/scripts/zetlctl.sh` 的薄封装,`init|start|stop|restart|status|logs` 原样透传。**为什么还要这一层**:zetlctl 的 start/restart 刻意不自动构建前端 (避免顶掉手动替换的包),代价是首次拉起或刚改完前端时页面是旧的而健康检查全绿 —— 属于静默失效,所以前端新鲜度判断放在 `start.sh`。 直接使用 `deploy/scripts/zetlctl.sh` 也完全可以(适合脚本化 / CI): ```bash deploy/scripts/zetlctl.sh init # 首次:生成 deploy/env/zetl.env(随机 JWT 密钥 + 自动挑空闲端口) $EDITOR deploy/env/zetl.env # 确认 ZETL_DSN 指向你的库(含口令,已 gitignore) deploy/scripts/zetlctl.sh start # 起 web-backend + data-processor,并做健康检查 deploy/scripts/zetlctl.sh status # pid / 端口 / 二进制 / 健康检查 / 身份模式 deploy/scripts/zetlctl.sh logs worker -f deploy/scripts/zetlctl.sh restart # 默认重建二进制后再重启 deploy/scripts/zetlctl.sh stop ``` 行为要点: - `start web` / `start worker` 可以只起一个(只看页面不起执行器时用 `start web`)。 - **源码比二进制新就自动重建**,避免「服务看着正常、跑的是旧二进制」。 - **已在运行不会重复拉起** —— 跑两份 `data-processor` 会导致定时任务重复提交(单实例约束)。 - 启动前**先查清端口归属**:被别的进程占用时打印对方的 pid / 可执行文件 / cwd 并拒绝启动; `stop` 后校验端口真的释放。`--force` 也**只终止 run 目录下本脚本自己的二进制**, 同机其它 zEtl 安装一律不碰。 - pid / 日志 / 本机构建的二进制都在 `run/`(已 gitignore)。 ### 手工启动(等价展开) ```bash # 1. 环境变量(最小集) export ZETL_DSN='root@tcp(127.0.0.1:3306)/zetl?parseTime=true' export ZETL_AUTH_MODE=local # ★ 独立部署的关键项:本地账号登录 export ZETL_ADMIN_PASSWORD='换成你自己的密码' # 首次启动创建 admin export ZETL_JWT_SECRET="$(openssl rand -hex 32)" export ZETL_WEB_DIR="$PWD/src/web/dist" # 2. 构建 cd src && go build -o ../bin/zetl-web-backend ./cmd/web-backend go build -o ../bin/zetl-data-processor ./cmd/data-processor cd web && npm install && npm run build && cd .. # 3. 启动(两个都要起,见「架构」) ../bin/zetl-web-backend & ../bin/zetl-data-processor & # 4. 打开 http://localhost:8080,用 admin / $ZETL_ADMIN_PASSWORD 登录 ``` 可直接用模板 `deploy/env/zetl.standalone.env.example`。 ### 关于 `ZETL_AUTH_MODE` | 值 | 形态 | 登录入口 | 账号归属 | |---|---|---|---| | `local` | 独立部署(单机通用 BI) | 登录页的本地账号表单 | 本地 `etl_user`(页内「用户管理」维护) | | `unified`(默认) | 家族部署(接入统一身份平台) | 统一身份门户跳转 | 统一身份平台,本平台只做映射与授权查询 | - 身份模式由**后端运行时决定**:前端启动时查询 `GET /api/auth/config`,再渲染对应的登录入口 —— 同一份 `dist` 两种部署通用,换模式不需要重新构建前端。 - `unified` 必须有可用的统一身份通道(`ZETL_TRUSTED_JWKS_URL` 或 `ZETL_TRUSTED_JWT_SECRET`);两者都没有时**启动即失败**并给出修法,而不是留下一个「启动成功但没有任何登录入口」的实例。 - 判定严格等值:只有字面 `local` 是本地模式,其余(含拼写变体与空值)一律按 `unified` —— 身份开关偏 fail-closed,手误不会把统一身份把关静默降级成本地账号可登。 ## 统一登录与权限(2026-10-13) 见 wiki/requirements/req-20261013-zauth-login-portal.md。默认 `ZETL_AUTH_MODE=unified`,前端构建配置 `VITE_ZAUTH_URL`,跳 `/portal?app=zetl&redirect=`。zauth 负责密码/钉钉登录及单、多应用分流。配置 `ZETL_ZAUTH_BASE_URL`、`ZETL_ZAUTH_SERVICE_TOKEN` 与 JWKS;统一模式拒绝本地登录和本地用户管理,不创建种子账号。每次 API 请求用 `ext_sub` 查询 zauth zetl 权限,撤权立即生效,当前应用权限含 `admin` 为管理员,其余已获授权为 readonly;支持自定义角色,不由数字 ID 推断权限。服务不可用失败关闭。`local` 是**独立部署形态**(见上一节),不是仅供隔离开发的开关。 子路径部署需为 Vite 配置对应 base,并在 zauth OAuth 应用 redirect 白名单登记该前端入口。登录入口(本地表单 / 门户跳转)与「用户管理」导航都由后端下发的身份模式决定,**不需要** `VITE_AUTH_MODE` 构建变量。