Phase 1-3: AList stock deployment, NocoDB 7-table schema, W1/W2 n8n workflows + docs
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
# =============================================================================
|
||||
# AList Serve Resource — 环境变量模板
|
||||
# 复制为 .env 并填入真实值。.env 加入 .gitignore,绝不提交 Git(规格 §29/§55)。
|
||||
# =============================================================================
|
||||
|
||||
# --- 时区 ---
|
||||
TZ=Asia/Kuala_Lumpur
|
||||
|
||||
# --- AList ---
|
||||
# AList JWT 签名密钥(随机 32+ 字符字符串)。生产环境必须替换。
|
||||
JWT_SECRET=CHANGE_ME_random_32char_or_longer
|
||||
|
||||
# --- AList 内网地址(n8n / NocoDB webhook 访问 admin API 用)---
|
||||
# container_name 为 alist-serve-resource,走 bridge_hoelee 网络时内网地址如下:
|
||||
ALIST_URL=http://alist-serve-resource:5244
|
||||
|
||||
# --- AList Admin 凭证(登录 /api/auth/login 换取 token)---
|
||||
ALIST_ADMIN_USERNAME=admin
|
||||
ALIST_ADMIN_PASSWORD=CHANGE_ME_strong_admin_password
|
||||
# 可选:持久化 token 缓存(若不用每次 login,可后续由 n8n credential 保存)
|
||||
ALIST_API_TOKEN=
|
||||
|
||||
# --- NocoDB(业务授权 Source of Truth)---
|
||||
NOCODB_URL=https://nocodb.hoelee.com
|
||||
# 在 NocoDB 内创建 API token(Settings → API Tokens),不要用明文密码
|
||||
NOCODB_TOKEN=CHANGE_ME_nocodb_api_token
|
||||
|
||||
# --- n8n 加密密钥 ---
|
||||
# 生产环境固定一个强随机值;改动会导致已有 credentials 无法解密
|
||||
N8N_ENCRYPTION_KEY=CHANGE_ME_n8n_encryption_key
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
# Secrets — 绝不提交
|
||||
.env
|
||||
|
||||
# Docker / 运行时
|
||||
docker-compose.override.yml
|
||||
|
||||
# 本地临时文件
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,69 @@
|
||||
# AList Serve Resource — 客户文件权限与到期管理系统
|
||||
|
||||
在 Docker 上运行的客户数字文件交付系统:
|
||||
|
||||
- **AList** = 文件浏览/预览/下载入口(stock image,不 fork、不嵌 custom code)
|
||||
- **NocoDB** = 业务授权 Source of Truth(客户/产品/授权/到期)
|
||||
- **n8n** = 同步器(NocoDB ↔ AList 官方 Admin API)
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
Internet
|
||||
│
|
||||
▼
|
||||
DSM nginx (access.hoelee.com)
|
||||
│
|
||||
▼
|
||||
AList (stock, 只读挂载 NAS)
|
||||
▲
|
||||
│ 内网 Admin API(http://alist-serve-resource:5244)
|
||||
│
|
||||
n8n ──► NocoDB(授权 SOT)
|
||||
```
|
||||
|
||||
- 客户只经 AList 入口访问,默认严格 read-only(权限 bitmask `0` + Check path limit)。
|
||||
- 每个客户 = 1 个 AList User + 1 个专属 Role + 多个 path scopes,每个 scope 独立到期(规格 §8)。
|
||||
- 到期/禁用由 NocoDB + n8n 定时同步撤销,AList 不存业务授权数据。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
alist-serve-resource/
|
||||
├── docker-compose.yml # 仅 AList(stock)
|
||||
├── .env.example # 所有 secrets 模板(复制为 .env)
|
||||
├── .gitignore
|
||||
├── docs/
|
||||
│ ├── API_INTEGRATION.md # ★ AList API 源码级验证
|
||||
│ ├── INSTALL.md
|
||||
│ ├── CONFIGURATION.md
|
||||
│ ├── DATABASE_SCHEMA.md
|
||||
│ ├── N8N_WORKFLOWS.md
|
||||
│ ├── SECURITY.md
|
||||
│ ├── BACKUP_RESTORE.md
|
||||
│ ├── UPGRADE.md
|
||||
│ └── TROUBLESHOOTING.md
|
||||
├── n8n/workflows/ # n8n workflow JSON 导出
|
||||
└── scripts/ # backup/restore/healthcheck
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
见 [docs/INSTALL.md](docs/INSTALL.md)。
|
||||
|
||||
## 关键结论
|
||||
|
||||
规格文档 §11 担心的「Role scope 是否能用 API 管理」——**答案是能**(`v3.63.0` 源码级验证,
|
||||
完整 Role CRUD)。无需 fork、无需 integration service。详见
|
||||
[docs/API_INTEGRATION.md](docs/API_INTEGRATION.md)。
|
||||
|
||||
## 与旧 alist 的区别
|
||||
|
||||
| | 旧 alist | 新 alist-serve-resource |
|
||||
|---|---|---|
|
||||
| 镜像 | `xhofe/alist:v3.54.0` | `xhofe/alist:v3.63.0` |
|
||||
| custom.js/css 注入 | 有(custom made) | **无**(纯 stock) |
|
||||
| 入口链 | nginx-limit → traefik-update → alist | DSM nginx → alist(直连) |
|
||||
| 权限/到期 | 内嵌业务逻辑 | NocoDB + n8n + 官方 API |
|
||||
| 端口 | 5244/5245/5246 | 5247 |
|
||||
| 数据目录 | `/volume1/docker/alist` | `/volume1/docker/alist-serve-resource` |
|
||||
@@ -0,0 +1,61 @@
|
||||
# =============================================================================
|
||||
# AList Serve Resource — 客户文件交付系统(Phase 1:AList stock 部署)
|
||||
# =============================================================================
|
||||
# 这是替代旧自定义 alist(access.hoelee.com, xhofe/alist:v3.54.0 + custom.js)
|
||||
# 的全新 stock AList。业务权限逻辑由 NocoDB(SOT) + n8n(同步器) 通过 AList
|
||||
# 官方 Admin API 管理,绝不在 image 内嵌入 custom code(规格 §8/§55)。
|
||||
#
|
||||
# 官方 Role/User 管理 API 已源码级验证(v3.63.0):
|
||||
# GET/POST /api/admin/role/{list,get,create,update,delete}
|
||||
# GET/POST /api/admin/user/{list,get,create,update,delete,cancel_2fa}
|
||||
# role 字段 = role-ID 数组;read-only = permission bitmask 0;
|
||||
# Check path limit = bit 14 = 16384(见 docs/API_INTEGRATION.md)
|
||||
# =============================================================================
|
||||
|
||||
services:
|
||||
alist:
|
||||
# 固定版本 tag,不追 latest(规格 §48)。部署前确认 v3.63.0 为主要最新稳定版。
|
||||
image: xhofe/alist:v3.63.0
|
||||
container_name: alist-serve-resource
|
||||
restart: unless-stopped
|
||||
|
||||
# 仅暴露 DSM 内网端口。公网入口由 DSM nginx 反代 access.hoelee.com → 此端口。
|
||||
# 旧 alist 用 5244/5245/5246(nginx-limit/traefik-update 链),新服务独立端口避免冲突。
|
||||
ports:
|
||||
- "5247:5244" # host:5247 -> container:5244(AList 默认端口)
|
||||
|
||||
volumes:
|
||||
# AList 自身数据/数据库/config(独立目录,不碰旧 alist 的 /volume1/docker/alist)
|
||||
- /volume1/docker/alist-serve-resource/data:/opt/alist/data
|
||||
- /volume1/docker/alist-serve-resource/config:/opt/alist/config
|
||||
- /volume1/docker/alist-serve-resource/storage:/opt/alist/storage
|
||||
|
||||
# 只读挂载 NAS 真实文件目录 —— 客户只能读取,不能改动(规格 §15/§16)。
|
||||
# 第二层防护:即使 AList 权限配置出错,filesystem 层仍只读。
|
||||
- /volume1/Class:/mnt/Class:ro
|
||||
- /volume1/Book:/mnt/Book:ro
|
||||
- /volume1/Comic:/mnt/Comic:ro
|
||||
- /volume1/Template:/mnt/Template:ro
|
||||
|
||||
environment:
|
||||
- TZ=Asia/Kuala_Lumpur
|
||||
# 生产部署请从 .env 注入真实值,勿硬编码(规格 §29/§55)。见 .env.example。
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- JWT_EXPIRE=2h
|
||||
# 注意:不再注入 ALIST_CUSTOM_CSS / ALIST_CUSTOM_JS —— 全新 stock 配置。
|
||||
|
||||
# 健康检查:AList 无内建 /health,用 /ping 探测 HTTP 存活
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:5244/ping >/dev/null 2>&1 || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
|
||||
networks:
|
||||
- bridge_hoelee
|
||||
|
||||
networks:
|
||||
# 复用 DSM 上已存在的外部网络(与 n8n / nocodb 同网络,便于内网 API 互访)
|
||||
bridge_hoelee:
|
||||
external: true
|
||||
@@ -0,0 +1,259 @@
|
||||
# AList API 集成说明(源码级验证)
|
||||
|
||||
> 规格文档 §11/§47 要求:不猜 API,不发明 endpoint。本文所有端点均已从
|
||||
> AList 上游源码(`AlistGo/alist`,`v3.63.0`)验证,而非仅凭文档目录猜测。
|
||||
|
||||
## 1. 版本
|
||||
|
||||
- 目标版本:`xhofe/alist:v3.63.0`(2026-08 最新稳定 release)
|
||||
- Role-scoped path aggregation + Check path limit 自 v3.53.0 起支持(规格 §48 要求已满足)。
|
||||
|
||||
## 2. 认证
|
||||
|
||||
```
|
||||
POST /api/auth/login
|
||||
Body: { "username": "admin", "password": "***" }
|
||||
响应: { "code": 200, "data": { "token": "<JWT>" } }
|
||||
```
|
||||
|
||||
后续所有 Admin 请求带 header:
|
||||
|
||||
```
|
||||
Authorization: <token>
|
||||
```
|
||||
|
||||
## 3. 已验证的 Admin API
|
||||
|
||||
源码位置:`server/router.go`(`admin(g *gin.RouterGroup)`)、`server/handles/user.go`、`server/handles/role.go`。
|
||||
|
||||
### 3.1 User API
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|---|---|---|
|
||||
| `/api/admin/user/list` | GET | 分页列出用户(`?page=&per_page=`) |
|
||||
| `/api/admin/user/get` | GET | `?id=` 取单个用户 |
|
||||
| `/api/admin/user/create` | POST | 建用户 |
|
||||
| `/api/admin/user/update` | POST | 改用户 |
|
||||
| `/api/admin/user/delete` | POST | `?id=` 删用户 |
|
||||
| `/api/admin/user/cancel_2fa` | POST | 撤销某用户 2FA |
|
||||
|
||||
### 3.2 Role API(关键 —— 完整 CRUD 存在)
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|---|---|---|
|
||||
| `/api/admin/role/list` | GET | 分页列出 role |
|
||||
| `/api/admin/role/get` | GET | `?id=` 取单个 role |
|
||||
| `/api/admin/role/create` | POST | 建 role |
|
||||
| `/api/admin/role/update` | POST | 改 role(含 `permission_scopes`) |
|
||||
| `/api/admin/role/delete` | POST | `?id=` 删 role |
|
||||
|
||||
> ✅ 结论:Role scope 的增删改**可通过官方 API 完整实现**,无需 fork、无需
|
||||
> integration service(规格 §12 的 Option C 不需要走)。
|
||||
|
||||
## 4. 数据结构(源码 `internal/model/`)
|
||||
|
||||
### 4.1 Role
|
||||
|
||||
```go
|
||||
type Role struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"` // 唯一
|
||||
Description string `json:"description"`
|
||||
Default bool `json:"default"`
|
||||
PermissionScopes []PermissionEntry `json:"permission_scopes"`
|
||||
}
|
||||
|
||||
type PermissionEntry struct {
|
||||
Path string `json:"path"` // 路径前缀,如 "/Class/A/1"
|
||||
Permission int32 `json:"permission"` // bitmask
|
||||
}
|
||||
```
|
||||
|
||||
JSON 示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 5,
|
||||
"name": "customer-alice",
|
||||
"description": "Alice customer role",
|
||||
"default": false,
|
||||
"permission_scopes": [
|
||||
{ "path": "/Class/A/1", "permission": 0 },
|
||||
{ "path": "/Class/A/2", "permission": 0 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 User
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"` // create/update 时传入明文
|
||||
BasePath string `json:"base_path"`
|
||||
Role Roles `json:"role"` // role-ID 数组
|
||||
Permission int32 `json:"permission"` // bitmask
|
||||
Disabled bool `json:"disabled"`
|
||||
}
|
||||
```
|
||||
|
||||
JSON 示例(只读客户):
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 10,
|
||||
"username": "alice",
|
||||
"password": "***",
|
||||
"base_path": "/Class",
|
||||
"role": [5],
|
||||
"permission": 16384,
|
||||
"disabled": false
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Permission bitmask(源码 `internal/model/user.go`)
|
||||
|
||||
| bit | 含义 | 对应 |
|
||||
|---|---|---|
|
||||
| 0 | 查看隐藏文件 | 1 |
|
||||
| 1 | 免密访问 | 2 |
|
||||
| 2 | 离线下载任务 | 4 |
|
||||
| 3 | mkdir / upload | 8 |
|
||||
| 4 | rename | 16 |
|
||||
| 5 | move | 32 |
|
||||
| 6 | copy | 64 |
|
||||
| 7 | remove / delete | 128 |
|
||||
| 8 | WebDAV read | 256 |
|
||||
| 9 | WebDAV write | 512 |
|
||||
| 10 | FTP login/read | 1024 |
|
||||
| 11 | FTP write | 2048 |
|
||||
| 12 | 读压缩包 | 4096 |
|
||||
| 13 | 解压 | 8192 |
|
||||
| **14** | **Check path limit** | **16384** |
|
||||
| 15 | MCP read | 32768 |
|
||||
| 16 | MCP write | 65536 |
|
||||
|
||||
**本项目默认:**
|
||||
- `read-only` = bitmask **`0`**(不设 bit 3–7/9/11/13 等任何写权限)
|
||||
- `Check path limit ON` = 设 bit 14 → **`16384`**
|
||||
|
||||
即客户 user 的 `permission` 字段 = `16384`(只读 + 路径限制),所有写位为 0。
|
||||
|
||||
## 6. Role scope + Check path limit 的权限判定逻辑
|
||||
|
||||
源码 `JoinPath()`(`internal/model/user.go`)证实:
|
||||
|
||||
1. 用户 base path 只是登录后的默认落点,**不是 ACL**(规格 §2/§30)。
|
||||
2. 若用户 `CheckPathLimit()` 为真(bit 14 置位),请求路径必须落在该用户**任一 role
|
||||
的任一 permission scope 的 path 之内**(前缀匹配),否则返回 `PermissionDenied`。
|
||||
3. 因此「Alice 只能进 /Class/A/1 与 /Class/A/2,进不了 /Class/A/3 与 /Class/B」
|
||||
的正确实现 = Alice 专属 role 的 `permission_scopes` 只含这两个 path,且 user
|
||||
`permission` bit 14 置位。
|
||||
|
||||
## 7. 保护性约束(源码确认)
|
||||
|
||||
- `admin` / `guest` role 受保护:`update`(改 admin 名/默认属性)与 `delete` 会被
|
||||
`ErrChangeDefaultRole` 拒绝。客户 role 不受此限。
|
||||
- `CreateUser` 时若 `role` 空数组,会回填默认 role ID(`op.GetDefaultRoleID()`)。
|
||||
**因此建客户时必须显式传其专属 role ID,不能留空。**
|
||||
- `UpdateUser` 若试图把用户 role 改成 admin/guest role,会被拒绝。
|
||||
- 至少保留 1 个 enabled admin,否则禁用最后一个 admin 会失败。
|
||||
|
||||
## 8. 给 n8n 的实现要点(幂等,规格 §10/§28)
|
||||
|
||||
- 建用户:先 `GET /api/admin/user/list` 按 `username` 查重,存在则 `update`,否则 `create`。
|
||||
- 建 role:先 `GET /api/admin/role/list` 按 `name` 查重,存在则 `update`(重写 `permission_scopes`),否则 `create`。
|
||||
- 更新 scope:`POST /api/admin/role/update`,整体重写该 role 的 `permission_scopes`
|
||||
(desired state = NocoDB 当前有效 grants 的并集),天然幂等。
|
||||
- 到期撤销:从 desired scopes 里移除过期 path,再整体 `update` 该 role。
|
||||
- 禁用客户:`POST /api/admin/user/update` 设 `disabled: true`。
|
||||
- 删除客户:soft delete(NocoDB 侧 `status=deleted`),AList 侧禁用用户 + 清空 role scopes,不物理删除(保留审计)。
|
||||
|
||||
## 9. Phase 3 实测验证(2026-08-22,对真实 v3.63.0 实例)
|
||||
|
||||
> 以下为对 DSM 上运行中的 `alist-serve-resource`(v3.63.0,端口 5244)实际 curl 验证结果,
|
||||
> 修正/确认了上文的若干推断。
|
||||
|
||||
### 9.1 认证 —— 登录带 2FA 时的字段
|
||||
|
||||
源码 `server/handles/auth.go` 的 `LoginReq` 结构:
|
||||
|
||||
```go
|
||||
type LoginReq struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password"`
|
||||
OtpCode string `json:"otp_code"` // 2FA 开启时必填(TOTP 6 位)
|
||||
}
|
||||
```
|
||||
|
||||
- 账户开启 2FA(`otp_secret` 非空)时,单次 `POST /api/auth/login` 不带 `otp_code`
|
||||
直接返回 `{"code":402,"message":"Invalid 2FA code"}`。**是一步登录**,不是两步 challenge。
|
||||
- 登录成功返回:`{"code":200,"data":{"token":"<JWT>","device_key":"<md5>"}}`。
|
||||
- `GET /api/admin/user/list` 返回的员工 `password` 字段恒为 `""`(脱敏),`otp` 状态不在此端点,
|
||||
需 `GET /api/me` 才见 `otp: true/false`。
|
||||
|
||||
### 9.2 Role CRUD 实测返回值(修正 §3)
|
||||
|
||||
| 端点 | 实测返回 | 备注 |
|
||||
|---|---|---|
|
||||
| `role/list` | `{"code":200,"data":{"content":[...],"total":N}}` | `PageResp` 包裹,非顶层数组 |
|
||||
| `role/create` | `{"code":200,"data":null}` | **不返回新建 role 对象**;需再 `role/list` 按 `name` 取 id |
|
||||
| `role/update` | `{"code":200,"data":null}` | 成功即 null;`role/get?id=` 可回读确认 |
|
||||
| `role/get?id=` | `{"code":200,"data":{...}}` | 返回完整 role 含 `permission_scopes` |
|
||||
| `role/delete` | **`POST /api/admin/role/delete?id=4`(id 走 query param)** | 用 JSON body 传 id 会 `400 strconv.Atoi: parsing "": invalid syntax` |
|
||||
|
||||
- `role/delete` 与 `user/delete` **都必须 `?id=` query param**,不是 body。
|
||||
- `user/create`、`user/delete` 同样实测成功(返回 `data:null`)。
|
||||
|
||||
### 9.3 admin 账户(当前实例)
|
||||
|
||||
- 用户名 `hoelee`,角色 `admin`(role id 2,`permission 65535`,`permission_scopes` = `[{"/", 65535}]`)。
|
||||
- `guest` role(id 1)`disabled`,`permission 0`。
|
||||
- admin 登录曾开 2FA(已由用户关闭);本系统若需 n8n 无人值守,**建议 admin 账户保持 2FA 关闭
|
||||
或用独立 service token**——否则 n8n 每次登录都要算 TOTP。
|
||||
|
||||
### 9.4 网络可达性(关键部署事实)
|
||||
|
||||
- AList 容器名 `alist-serve-resource` 仅 Docker **网络内部**可解析。
|
||||
**DSM host shell 无法解析该名字**(`Could not resolve host`),须用 `localhost:5244`。
|
||||
- n8n 容器在 `bridge_hoelee` 网络内,访问 AList 用 **`http://alist-serve-resource:5244`**(✅)。
|
||||
- 旧 `alist` 容器(v3.54.0)现在映射 host 端口 5243,新容器占 5244,无冲突。
|
||||
|
||||
### 9.5 ⚠ JWT secret 不一致(待修复,否则 token 会失效)
|
||||
|
||||
- 容器 env `JWT_SECRET=<redacted>=`(.env 传入),但 AList 初始化时写入 `data/config.json`
|
||||
的 `jwt_secret` 是另一个值 `<redacted>`(首次启动自动生成)。
|
||||
- **env `JWT_SECRET` 只影响首次初始化**;服务运行后以 `config.json` 为准(当前生效的是
|
||||
`<redacted>`)。两者不一致是正常的(env 在初次 init 时可能被 config 覆盖)。
|
||||
- 影响:若将来重建数据目录(清空 `data/`),.env 里的 `JWT_SECRET` 会生效;否则一直用 config.json 上的值。
|
||||
**n8n 不关心这个**(每次 `login` 换 token),但需知悉「改 env 不会改已运行实例的 JWT」。
|
||||
- 若要统一:改 `data/config.json` 的 `jwt_secret` 并重启,或接受 config.json 为准。
|
||||
|
||||
### 9.6 n8n 集成结论(Phase 3 通过 ✅)
|
||||
|
||||
- admin 登录 token 可用,Role/User 全 CRUD 可用,scope 读写可用。
|
||||
- n8n workflow 实现要点(§8)全部成立,唯一补充:**delete 走 `?id=`**,**create/update 返回 null
|
||||
需回查**(用 `list` 按 `name` 定位 id)。
|
||||
|
||||
### 9.7 ⚠ `user/create` 空 role 会失败(Phase 4/W1 debug 实测,源码级确认)
|
||||
|
||||
- **症状**:`POST /api/admin/user/create` 传 `"role": []` 时返回
|
||||
`{"code":400,"message":"admin or guest user can not be created"}`,且与 username 无关(任意名字都失败)。
|
||||
- **根因**(`server/handles/user.go` `CreateUser`):
|
||||
1. `if len(req.Role) == 0 { req.Role = model.Roles{op.GetDefaultRoleID()} }` —— 空 role 被自动填为
|
||||
**默认 role(guest,id=1)**。
|
||||
2. 紧接着 `if req.IsAdmin() || req.IsGuest() { ... 400 ... }`,其中
|
||||
`IsGuest()` = `Role.Contains(GUEST)`(`GUEST=1`)、`IsAdmin()` = `Role.Contains(ADMIN)`(`ADMIN=2`)。
|
||||
role 现在是 `[1]`(guest)→ 判定为 guest → 拒绝。
|
||||
- **关键认知**:`IsGuest/IsAdmin` 判断的是 **role ID**,不是 username。错误信息里的 "admin or guest
|
||||
user" 会误导向「用户名保留了」,实际是「role 里含 guest/admin ID」。
|
||||
- **正确顺序(必须)**:
|
||||
1. `role/create`(name=`customer-<username>`,空 `permission_scopes`)→ 返回 200 但 `data:null`;
|
||||
2. `role/list` 按 name 找到新建 role 的自增 id(3、4、5…);
|
||||
3. `user/create` 传 `"role": [<该 role id>]`(**非空、非 1/2**)→ 成功,`base_path` 正确。
|
||||
- 实测:`role:[5]` 创建用户 `code 200`,`base_path:/Class`、`permission 16384` 均落地。
|
||||
- **适用**:W1 的「新建用户」路径必须先建 role;更新路径 `user/update` 里 `role:[]` 同样会触发
|
||||
`GetDefaultRoleID` 回填(见 `UpdateUser` 同款逻辑),若客户已有 role id 应显式传入。
|
||||
- 相关常量:`GENERAL=0, GUEST=1, ADMIN=2, NEWGENERAL=3`(`internal/model/user.go`);`permission`
|
||||
bitmask 定义同文件注释(bit 14 = "check path limit" = 16384)。
|
||||
@@ -0,0 +1,27 @@
|
||||
# 备份 / 恢复
|
||||
|
||||
> 重要区分(规格 §35):AList 的 data/config 与 NAS 真实文件 `/volume1/Class` 是两回事。
|
||||
> 备份 AList ≠ 备份客户文件。
|
||||
|
||||
## 需要备份
|
||||
|
||||
| 对象 | 说明 |
|
||||
|---|---|
|
||||
| AList data/config | `/volume1/docker/alist-serve-resource/data`(含数据库、用户、role) |
|
||||
| `.env` | 加密备份,不落 Git 明文 |
|
||||
| docker-compose.yml + docs | 已入 Git(gitea) |
|
||||
| NocoDB 数据库 | 现成 DB 已有自动备份(你说过不用重复做) |
|
||||
| n8n 数据 | 现有 n8n 已有备份策略 |
|
||||
|
||||
## 备份脚本占位
|
||||
|
||||
`scripts/backup.sh` / `restore.sh` 在 Phase 9 补全。当前 AList 数据目录可直接
|
||||
tar 备份:
|
||||
|
||||
```bash
|
||||
tar czf alist-serve-resource-data-$(date +%F).tar.gz -C /volume1/docker alist-serve-resource
|
||||
```
|
||||
|
||||
## 恢复
|
||||
|
||||
Phase 9 补充完整步骤(停止容器 → 还原目录 → 启动 → 跑 reconciliation)。
|
||||
@@ -0,0 +1,27 @@
|
||||
# 配置说明
|
||||
|
||||
## 环境变量
|
||||
|
||||
全部见 `.env.example`。核心变量:
|
||||
|
||||
| 变量 | 用途 |
|
||||
|---|---|
|
||||
| `JWT_SECRET` | AList 会话签名密钥(生产必换) |
|
||||
| `ALIST_URL` | n8n 访问 AList 的内网地址 |
|
||||
| `ALIST_ADMIN_USERNAME/PASSWORD` | admin 登录(换 token) |
|
||||
| `NOCODB_URL` / `NOCODB_TOKEN` | 业务 SOT |
|
||||
| `N8N_ENCRYPTION_KEY` | n8n credential 加密 |
|
||||
|
||||
## AList 关键配置
|
||||
|
||||
- `JWT_EXPIRE=2h`:session 有效期。
|
||||
- 不设 `ALIST_CUSTOM_CSS/JS`(stock)。
|
||||
- 端口映射 `5247:5244`(host → container)。
|
||||
|
||||
## NocoDB / n8n
|
||||
|
||||
- 复用现有运行中的 NocoDB(`https://nocodb.hoelee.com`)与 n8n(`https://auto.hoelee.com`),
|
||||
本 compose **不重复部署**它们。
|
||||
- 只需在 NocoDB 建表(见 DATABASE_SCHEMA.md)、在 n8n 建 workflow(见 N8N_WORKFLOWS.md)。
|
||||
|
||||
## 具体参数在实施 Phase 2/3 时补充。
|
||||
@@ -0,0 +1,152 @@
|
||||
# NocoDB 数据结构(Phase 2b — 重建,7 表最终模型)
|
||||
|
||||
> Source of Truth = NocoDB。AList 只做 enforcement,不存业务授权数据。
|
||||
> 本 schema 按《Hermes 调整指导》最终业务模型重建(2026-08-22)。
|
||||
> 通过 NocoDB API 自动建表,无需手动点 UI。
|
||||
|
||||
- **Base**: `AlistAccess`(id `p3lzkresswyowqg`),独立新 base,与旧 `accessDB` 隔离。
|
||||
- **MySQL source**: mysql-server 上的 `alistaccess` schema(由 NocoDB 自动管理)。
|
||||
- **API token**: workspace token(`nc_api_tokens` 表内 `n8n` token),`xc-token` header。
|
||||
|
||||
## 表 model_id(n8n v2 API 硬编码用)
|
||||
|
||||
| 表 | model_id |
|
||||
|---|---|
|
||||
| Customers | `maqb9zgvza2te3k` |
|
||||
| Products | `molyb3pxv81l75g` |
|
||||
| Resources | `m6cm7tkxzd8m18c` |
|
||||
| CustomerProducts | `mvyqbv8ao7u6bct` |
|
||||
| AccessGrants | `mxdx3yhfcyej3ao` |
|
||||
| AccessLogs | `ml12zcyzlehgi21` |
|
||||
| Purchases | `mwp12z9lcohhsuk` |
|
||||
|
||||
> ⚠ ID 是本次重建生成的。若再次删库重建,ID 会变,需同步更新 n8n 节点 + 本文档。
|
||||
|
||||
## 关系(LinkToAnotherRecord)
|
||||
|
||||
| 子表 | 字段 | 方向 | 指向 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| CustomerProducts | `customer` | bt | Customers | |
|
||||
| CustomerProducts | `product` | bt | Products | |
|
||||
| Products | `Resources` | **mm** | Resources | M2M link,列 id `c4ko4x8kwi16swg` |
|
||||
| Resources | `Products` | mm (reverse) | Products | 列 id `cfxi44n9ixkimqc` |
|
||||
| AccessGrants | `customer` | bt | Customers | |
|
||||
| AccessGrants | `resource` | bt | Resources | |
|
||||
| AccessLogs | `customer` | bt | Customers | |
|
||||
| Purchases | `customer` | bt | Customers | |
|
||||
| Purchases | `product` | bt | Products | |
|
||||
|
||||
> M2M 读写方式(已实测):`GET/POST /api/v2/tables/{products}/links/c4ko4x8kwi16swg/records/{productId}`,
|
||||
> POST body `{"Id": <resourceId>}`。反向用 Resources 的 `cfxi44n9ixkimqc`。
|
||||
|
||||
## 表结构
|
||||
|
||||
### 1. Customers
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| Id | number (PK) | |
|
||||
| username | SingleLineText | AList 登录名(唯一) |
|
||||
| password | SingleLineText | **plaintext**(规格 §2 已确认,勿 hash) |
|
||||
| display_name | SingleLineText | |
|
||||
| email | Email | |
|
||||
| notes | LongText | |
|
||||
| alist_user_id | Number | AList 用户 ID(n8n 回填) |
|
||||
| alist_role_id | Number | AList role ID(n8n 回填) |
|
||||
| status | SingleSelect | pending/active/suspended/expired/disabled/deleted |
|
||||
| sync_status | SingleSelect | pending/syncing/synced/error/unknown |
|
||||
| sync_required | Checkbox | 系统维护,管理员勿手改 |
|
||||
| last_sync | DateTime | |
|
||||
| last_sync_error | LongText | |
|
||||
|
||||
### 2. Products
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| Id | number (PK) | |
|
||||
| name | SingleLineText | 如 "Python Basic" |
|
||||
| description | LongText | |
|
||||
| default_duration_days | Number | 默认有效期天数 |
|
||||
| status | SingleSelect | active/inactive |
|
||||
| Resources | link (mm) | 该产品包含的 Resources |
|
||||
|
||||
### 3. Resources
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| Id | number (PK) | |
|
||||
| name | SingleLineText | 如 "Python 01" |
|
||||
| alist_path | SingleLineText | 真实 AList path,如 "/Class/Python/01" |
|
||||
| description | LongText | |
|
||||
| status | SingleSelect | active/inactive |
|
||||
| Products | link (mm, reverse) | |
|
||||
|
||||
### 4. CustomerProducts(核心新表)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| Id | number (PK) | |
|
||||
| customer | link bt → Customers | |
|
||||
| product | link bt → Products | |
|
||||
| start_at | Date | |
|
||||
| expires_at | Date | 实际到期(可 override default_duration) |
|
||||
| status | SingleSelect | scheduled/active/expired/revoked/suspended |
|
||||
| sync_status | SingleSelect | pending/syncing/synced/error/unknown |
|
||||
| sync_required | Checkbox | |
|
||||
| last_sync | DateTime | |
|
||||
| last_sync_error | LongText | |
|
||||
|
||||
### 5. AccessGrants(最终实际权限,n8n 计算维护)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| Id | number (PK) | |
|
||||
| customer | link bt → Customers | |
|
||||
| resource | link bt → Resources | |
|
||||
| permission | SingleSelect | read/readwrite |
|
||||
| start_at | Date | |
|
||||
| expires_at | Date | effective expiry(多来源取 max) |
|
||||
| status | SingleSelect | pending/active/revoked/expired |
|
||||
| source | SingleSelect | product/manual |
|
||||
| sync_status | SingleSelect | pending/syncing/synced/error/unknown |
|
||||
| sync_required | Checkbox | |
|
||||
| last_sync | DateTime | |
|
||||
| last_sync_error | LongText | |
|
||||
| revoked_at | DateTime | |
|
||||
| revoke_reason | LongText | |
|
||||
|
||||
### 6. AccessLogs(审计)
|
||||
| 字段 | 类型 |
|
||||
|---|---|
|
||||
| Id | number (PK) |
|
||||
| customer | link bt → Customers |
|
||||
| username | SingleLineText |
|
||||
| resource_path | SingleLineText |
|
||||
| file_path | SingleLineText |
|
||||
| action | SingleSelect (login/view/download/preview/logout) |
|
||||
| ip_address | SingleLineText |
|
||||
| user_agent | LongText |
|
||||
| result | SingleSelect (success/denied) |
|
||||
| bytes | Number |
|
||||
| timestamp | DateTime |
|
||||
|
||||
### 7. Purchases(纯记录,不参与权限)
|
||||
| 字段 | 类型 |
|
||||
|---|---|
|
||||
| Id | number (PK) |
|
||||
| customer | link bt → Customers |
|
||||
| product | link bt → Products |
|
||||
| purchase_date | Date |
|
||||
| amount | Decimal |
|
||||
| payment_method | SingleLineText |
|
||||
| order_id | SingleLineText |
|
||||
| notes | LongText |
|
||||
|
||||
## 建表 API recipe(实测 2026-08-22,复用见 nocodb-selfhosted skill)
|
||||
|
||||
- 建 base:`POST /api/v2/meta/bases/ {"title"}`
|
||||
- 建表(内联非 select 列):`POST /api/v2/meta/bases/{baseId}/tables {"title","table_name","type":"table","columns":[{"title","uidt"}]}`
|
||||
- 可用 uidt:`SingleLineText`,`LongText`,`Number`,`Decimal`,`Date`,`DateTime`,`Email`,`Checkbox`
|
||||
- SingleSelect 两段式:
|
||||
1. 建空列 `POST /api/v1/db/meta/tables/{tid}/columns {"title","uidt":"SingleSelect"}`
|
||||
2. 设选项 **必须同时传 `dtxp`**(逗号枚举串):
|
||||
`PATCH /api/v1/db/meta/columns/{colid} {"colOptions":{"options":[{"title","order","color":null}]},"dtxp":"'opt1','opt2'"}`
|
||||
⚠ 只传 `colOptions` 不传 `dtxp` 会静默失败(options 空)。
|
||||
- Link:`POST /api/v1/db/meta/tables/{childTid}/columns {"title","uidt":"LinkToAnotherRecord","parentId","childId","type":"bt"}`
|
||||
- M2M:`type":"mm"`,然后走 `/api/v2/tables/{t}/links/{colId}/records/{rowId}` 读写。
|
||||
- 删记录:`DELETE /api/v2/tables/{tid}/records` body `[{"Id":N},...]`(不是 `/records/{id}`)。
|
||||
@@ -0,0 +1,86 @@
|
||||
# 安装文档(Phase 1:AList stock 部署)
|
||||
|
||||
> 目标:在 DSM 上跑起全新的 stock AList,与旧 alist 并行,验证后再切换域名。
|
||||
|
||||
## 0. 前置条件(你需独立操作)
|
||||
|
||||
这些是你在 DSM / Portainer 上手动完成的部分,agent 无法代劳:
|
||||
|
||||
1. **确认 NAS 目录存在且就绪**:
|
||||
- `/volume1/Class`、`/volume1/Book`、`/volume1/Comic`、`/volume1/Template`
|
||||
- 若目录名/路径不同,改 `docker-compose.yml` 的 volumes。
|
||||
|
||||
2. **创建 AList 数据目录**(DSM File Station 或 SSH):
|
||||
```
|
||||
/volume1/docker/alist-serve-resource/data
|
||||
/volume1/docker/alist-serve-resource/config
|
||||
/volume1/docker/alist-serve-resource/storage
|
||||
```
|
||||
|
||||
3. **确认外部网络 `bridge_hoelee` 存在**:
|
||||
- 该网络由 n8n/nocodb 等其他 compose 创建(`external: true`)。
|
||||
- 若不存在,在 DSM 上建:`docker network create bridge_hoelee`(或 Portainer → Networks → Add)。
|
||||
|
||||
4. **准备 secrets**:
|
||||
- 复制 `.env.example` → `.env`,填入真实 `JWT_SECRET`、`ALIST_ADMIN_PASSWORD` 等。
|
||||
- 生成 JWT secret:`openssl rand -base64 32`
|
||||
|
||||
## 1. 部署(Portainer)
|
||||
|
||||
在 Portainer → Stacks → Add Stack:
|
||||
|
||||
- Name: `alist-serve-resource`
|
||||
- 方式:上传 `docker-compose.yml` + 配置环境变量(或把 `.env` 内容填入 Environment variables 区)。
|
||||
- 网络 `bridge_hoelee` 是 external,Portainer 会复用现有网络,不会新建。
|
||||
|
||||
> Portainer 与 `env_file`:Portainer Stack 使用内置 Environment variables 区而非 `.env` 文件。
|
||||
> 把 `.env` 里的键值对逐条填进 Portainer 的 Environment variables,或改用 Compose 里的
|
||||
> `${VAR}` 语法(Portainer 也支持 `env_file`,可上传 `.env` 作为 env_file)。
|
||||
|
||||
部署后验证:
|
||||
|
||||
```bash
|
||||
docker ps | grep alist-serve-resource
|
||||
docker logs alist-serve-resource
|
||||
```
|
||||
|
||||
## 2. 首次初始化 AList admin
|
||||
|
||||
1. 访问 `http://<DSM-IP>:5247`。
|
||||
2. 首次进入会引导设置 admin 账号密码(或 AList 会打印初始 admin 密码到日志:
|
||||
`grep -i admin docker logs alist-serve-resource`)。
|
||||
3. 登录后台,确认版本 v3.63.0。
|
||||
|
||||
## 3. 建立 Local Storage
|
||||
|
||||
AList 后台 → 存储 → 添加 → 驱动选「本地存储 / Local」:
|
||||
|
||||
```
|
||||
挂载路径: /Class → 根文件夹路径: /mnt/Class
|
||||
挂载路径: /Book → 根文件夹路径: /mnt/Book
|
||||
挂载路径: /Comic → 根文件夹路径: /mnt/Comic
|
||||
挂载路径: /Template → 根文件夹路径: /mnt/Template
|
||||
```
|
||||
|
||||
> 客户看到的逻辑路径是 `/Class/...`,实际 host 是 `/volume1/Class/...`(规格 §31)。
|
||||
> 客户永远看不到 `/volume1` 或 `/mnt`。
|
||||
|
||||
## 4. 验证健康检查
|
||||
|
||||
```bash
|
||||
docker inspect --format '{{.State.Health.Status}}' alist-serve-resource
|
||||
# 应输出 healthy
|
||||
```
|
||||
|
||||
## 5. 切换域名(后期,新旧并行验证通过后再做)
|
||||
|
||||
1. 旧 alist(`nginx-limit-alist`/`traefik-update-alist`/`alist`)保持运行。
|
||||
2. DSM nginx 将 `access.hoelee.com` 新指到 `http://<DSM-IP>:5247`。
|
||||
3. 验证客户能正常登录/浏览/下载。
|
||||
4. 确认无误后,停掉旧 alist 三个 container(先 `docker stop`,观察,再 `docker rm`)。
|
||||
|
||||
## 6. 后续 Phase
|
||||
|
||||
- Phase 2:NocoDB schema(`docs/DATABASE_SCHEMA.md`)
|
||||
- Phase 3:n8n + AList API 连接(`docs/N8N_WORKFLOWS.md`)
|
||||
- Phase 4–10:客户建档、权限同步、到期同步、审计、监控、备份、文档
|
||||
@@ -0,0 +1,54 @@
|
||||
# n8n Workflow 说明(Phase 3–6 — 待实施)
|
||||
|
||||
> n8n 运行在现有 auto.hoelee.com(`bridge_hoelee` 网络)。它是同步器,不是 SOT。
|
||||
|
||||
## 依赖的 AList API(已源码验证,见 API_INTEGRATION.md)
|
||||
|
||||
- 登录换 token:`POST http://alist-serve-resource:5244/api/auth/login`
|
||||
- User:`/api/admin/user/{list,get,create,update,delete,cancel_2fa}`
|
||||
- Role:`/api/admin/role/{list,get,create,update,delete}`
|
||||
|
||||
## 需要的 Workflow
|
||||
|
||||
### WF1 — Customer Create(trigger: Customers 新记录)
|
||||
1. 校验 customer。
|
||||
2. 查重 username → 建/改 AList user(`base_path=/Class`,`role=[专属role]`,`permission=16384`)。
|
||||
3. 建专属 role(`name=customer-<username>`,空 scopes)。
|
||||
4. 用 `/api/admin/user/update` 把 user 绑定到新 role(显式传 role ID,勿留空)。
|
||||
5. 回填 `alist_user_id` / `alist_role_id` 到 NocoDB。
|
||||
6. `sync_status=synced`。
|
||||
|
||||
### WF2 — AccessGrant Create/Update(trigger: AccessGrants 记录变更)
|
||||
1. 找 customer + resource path。
|
||||
2. 判断当前日期 vs start_at/expires_at。
|
||||
3. 若 active → 把该 path 并入该客专属 role 的 `permission_scopes`(permission=0)。
|
||||
4. 若非 active → 不加入。
|
||||
5. 整体 `post /api/admin/role/update` 重写 scopes(幂等)。
|
||||
6. 记录 `sync_status` / `last_sync_at` / `last_sync_error`。
|
||||
|
||||
### WF3 — Expiry Sync(schedule: every 5 min)
|
||||
1. 查所有 active/scheduled grants。
|
||||
2. 计算 desired scopes(NocoDB 有效 grants 并集)。
|
||||
3. 对比 AList 实际 scopes(`role/get`)。
|
||||
4. 移除过期 scope、加入新生效 scope。
|
||||
5. 若无剩余 active grant 且政策启用 → disable 该 customer(`user/update disabled=true`)。
|
||||
6. 记录结果;失败发通知。
|
||||
|
||||
### WF4 — Full Reconciliation(schedule: daily)
|
||||
- desired(NocoDB) vs actual(AList) 全量 diff,修复 AList,记录 reconciliation。
|
||||
|
||||
### WF5 — Customer Suspension(trigger: status=suspended)
|
||||
- `user/update disabled=true` + 清空 role scopes;恢复时重算。
|
||||
|
||||
### WF6 — Customer Delete(soft delete)
|
||||
- NocoDB `status=deleted`;AList 禁用用户 + 清空 scopes,不物理删除。
|
||||
|
||||
## 幂等规则(规格 §28)
|
||||
|
||||
所有操作 = get current → compare → apply diff。重跑不产生重复 user/role/scope。
|
||||
更新 scope 一律「整体重写该 role 的 `permission_scopes`」。
|
||||
|
||||
## 待定项(实施时确认)
|
||||
|
||||
- n8n 内网访问 AList 的 host 名(预期 `http://alist-serve-resource:5244`)。
|
||||
- 登录 token 缓存 / 刷新策略(JWT_EXPIRE=2h → 建议每次 workflow 起手先 login)。
|
||||
@@ -0,0 +1,93 @@
|
||||
# Project State (Handoff) — 2026-08-22
|
||||
|
||||
> 交接文档:压缩/新会话后,按此文档续做。细节见各 doc。
|
||||
|
||||
## 已完成 ✅
|
||||
|
||||
### 1. AList stock 部署(Phase 1)— 运行中
|
||||
- Container `alist-serve-resource` (xhofe/alist v3.63.0),DSM port **5244**,网络 `bridge_hoelee`
|
||||
- 挂载 `/volume1/Class` `/volume1/Book` `/volume1/Comic` `/volume1/Template`(只读 → /mnt/Class 等)
|
||||
- 旧 alist (v3.54.0) 仍运行在 5243,**暂不处理**
|
||||
- Admin: username `hoelee`,2FA 已关闭;compose 在项目根 `docker-compose.yml`
|
||||
- DSM 上从 host shell 访问内网容器要用 `localhost:5244`;n8n 容器内用 `http://alist-serve-resource:5244`
|
||||
|
||||
### 2. NocoDB schema(Phase 2 重建)— 7 表最终模型
|
||||
Base: **`AlistAccess`**(`p3lzkresswyowqg`),与旧 `accessDB` 完全隔离。表 + model_id + 关键点:
|
||||
| 表 | model_id | 备注 |
|
||||
|---|---|---|
|
||||
| Customers | maqb9zgvza2te3k | plaintext password + **password_updated_at** |
|
||||
| Products | molyb3pxv81l75g | default_duration_days;M2M→Resources |
|
||||
| Resources | m6cm7tkxzd8m18c | alist_path |
|
||||
| CustomerProducts | mvyqbv8ao7u6bct | 核心表(customer+product+expiry) |
|
||||
| AccessGrants | mxdx3yhfcyej3ao | **物化状态表,不是 trigger**(禁止 loop) |
|
||||
| AccessLogs | ml12zcyzlehgi21 | 审计 |
|
||||
| Purchases | mwp12z9lcohhsuk | 纯记录 |
|
||||
|
||||
- 建表脚本:`scripts/create_nocodb_schema.py`(幂等)
|
||||
- 表/字段/链接列 ID 全表:`docs/DATABASE_SCHEMA.md`(已更新为 7 表)
|
||||
- NocoDB API token(workspace 级,来自 nc_api_tokens 表 n8n 那行):在 `docs/API_INTEGRATION.md` / SETUP.md 有记(或查 NocoDB UI)
|
||||
|
||||
### 3. W1 客户开通 workflow — **已实测跑通** ✅
|
||||
- 文件:`n8n/W1-customer-provision.json`
|
||||
- 流程:Webhook → Extract customerId → **Load Env(step 2, 用 $env)** → Fetch 记录 → 判断 create/update → Create Role → Find Role Id → Create User(role:[roleId]) → Find User Id → Write Back
|
||||
- 关键:AList user/create 传 **空 role 数组会被拒**(自动填 guest→报 "admin or guest user can not be created")。必须先 Create Role 拿自增 id,再 Create User 带 `role:[id]`
|
||||
- 已 import n8n;用户曾在 UI 微调 step 2(见下方「n8n env 访问结论」模板)
|
||||
|
||||
### 4. W2 CustomerProduct 权限同步 — **已改好待测试** ⏳
|
||||
- 文件:`n8n/W2-customerproduct-sync.json`
|
||||
- 流程:Webhook → Extract customerProductId → Load Env → **Compute Effective Grants**(登录 AList→读 customer→读全部 active CP→读 Product 的 M2M Resources→**per-Resource MAX(expires_at)** 合并→读 AList role 现状→diff→算 newScopes) → AList Update Role Scopes → Write Back CP Sync Status
|
||||
- 已修:compute 节点 `***` 损坏行、所有 HTTP 表达式改引用 `$('Load Env').item.json.X`
|
||||
- ⚠️ 待用户 import + 测用例 2(1 Product 2 Resource)、用例 3(重叠取 max expiry)
|
||||
- **AccessGrants 表尚未在 W2 中被写入**(设计上它是物化结果,当前版本只更新 role scopes + CP sync 状态 —— 后续版本再物化进 AccessGrants?确认设计)
|
||||
|
||||
## 🔴 下一步(按序)
|
||||
1. **用户测试 W2**(import 覆盖 → 用例 2、3 → AList 前端实测客户登录看目录)→ 报错贴 execution 错误
|
||||
2. 验证通过后:**expiry scheduler**(每 5 分钟对账:全量 diff NocoDB↔AList,到期的移除 scope)—— 规格 §26/§37
|
||||
3. 其余 workflow(W3-W6):Product/Resource 变更 → 重算受影响 customer;手动 sync;日志;通知
|
||||
4. 本地 git 留档 → 验证稳定后推 Gitea(git.hoelee.com)
|
||||
|
||||
## 🐛 已沉淀的坑(勿再踩)
|
||||
- **AList**:
|
||||
- `user/create` 空 role 数组 → 400 "admin or guest"(源码 `req.Role==0 → 默认 guest` + `IsGuest()` 检查);先建 role 拿 id
|
||||
- `role/create`/`update` 返回 `data:null`;create 后要 **list 按 name 定位 id**
|
||||
- delete 用 `?id=` query param,不是 body
|
||||
- role/list 返回 `data.content[]` + `data.total`(PageResp)
|
||||
- GUEST=1 / ADMIN=2 / GENERAL=0;用户级 bit14=16384 = Check path limit
|
||||
- 源码位置:`internal/op/user.go` CreateUser / `server/handles/role.go` / `internal/model/roles.go`
|
||||
- **NocoDB**(`nocodb-selfhosted` skill 已更新):
|
||||
- SingleSelect 不能建表内联选项;要先建空列再 PATCH `{"colOptions":{options},"dtxp":"a,b,c"}`
|
||||
- Link 字段用 `type:"bt"`(belongs-to)建,`hm` 会把列名搞乱
|
||||
- base/table/column ID 全在 DATABASE_SCHEMA.md
|
||||
- **n8n env 访问(v1.123.72)**(memory 已记):
|
||||
- Code 节点内 `$env` ✅(**用户实测可用**,如 `$env.NOCODB_URL`)
|
||||
- HTTP 节点表达式字段里 `$env` / `process.env` ❌ 都不行
|
||||
- 模式:step 2 放 "Load Env" Code 节点输出全部 env **并透传 payload 字段**,下游 HTTP 用 `$('Load Env').item.json.VAR`
|
||||
- step 2 已验证模板:
|
||||
```js
|
||||
const prev = $input.item.json;
|
||||
return { json: {
|
||||
customerId: prev.customerId,
|
||||
eventType: prev.eventType,
|
||||
NOCODB_URL: $env.NOCODB_URL,
|
||||
NOCODB_TOKEN: $env.NOCODB_TOKEN,
|
||||
T_CUSTOMERS: $env.NOCODB_TABLE_CUSTOMERS,
|
||||
ALIST_URL: $env.ALIST_URL,
|
||||
ALIST_ADMIN_USERNAME: $env.ALIST_ADMIN_USERNAME,
|
||||
ALIST_ADMIN_PASSWORD: $env.ALIST_ADMIN_PASSWORD,
|
||||
ALIST_ADMIN_OTP: $env.ALIST_ADMIN_OTP || '',
|
||||
} };
|
||||
```
|
||||
|
||||
## 📁 项目结构
|
||||
```
|
||||
alist-serve-resource/
|
||||
├── docker-compose.yml / .env.example / .gitignore
|
||||
├── n8n/ W1-customer-provision.json · W2-customerproduct-sync.json · SETUP.md · W2-DESIGN.md · w2-compute.js
|
||||
├── scripts/ create_nocodb_schema.py · healthcheck.sh
|
||||
└── docs/ API_INTEGRATION.md · DATABASE_SCHEMA.md · INSTALL.md · CONFIGURATION.md · SECURITY.md · BACKUP_RESTORE.md · UPGRADE.md · TROUBLESHOOTING.md · PROJECT_STATE.md
|
||||
```
|
||||
|
||||
## 待确认的设计点
|
||||
1. W2 当前不写 AccessGrants 表 —— 是否在验证后补物化写入?(避免 AccessGrant→n8n loop 的前提下,由 W2 单向写)
|
||||
2. 密码:客户可自改 AList 密码 → NocoDB plaintext 会失真;当前不强制回写(用户已选方案 C)
|
||||
3. 对账/到期只靠 n8n 定时扫描(AList 无 expiry 概念)—— 已确认
|
||||
@@ -0,0 +1,40 @@
|
||||
# 安全要求(规格 §29/§52)
|
||||
|
||||
## Secrets 管理
|
||||
|
||||
- **绝不**把 token/密码写死在 `docker-compose.yml`,全部走 `.env`(已 `.gitignore`)。
|
||||
- AList admin token、NocoDB token、n8n 加密密钥都只在 `.env`。
|
||||
- `.env` 加密备份(见 BACKUP_RESTORE.md),不提交 Git。
|
||||
|
||||
## 双层防护(规格 §16/§52)
|
||||
|
||||
1. **应用层**:AList role scope + Check path limit + read-only bitmask。
|
||||
2. **文件系统层**:NAS 目录以 `:ro` 只读挂载。
|
||||
|
||||
即使 AList 权限配置出错,客户也无法通过 AList 改写 NAS 文件。
|
||||
|
||||
## 网络暴露
|
||||
|
||||
| 服务 | 公网 |
|
||||
|---|---|
|
||||
| AList 客户入口 | ✅ 经 DSM nginx(access.hoelee.com) |
|
||||
| AList Admin API | ❌ 仅内网(n8n 经 bridge_hoelee) |
|
||||
| NocoDB admin | ❌ 不直连公网 |
|
||||
| n8n editor | ❌ 不直连公网 |
|
||||
|
||||
## 权限默认值(客户 user)
|
||||
|
||||
- `permission` = `16384`(仅 Check path limit bit,所有写位=0)
|
||||
- 无 upload/mkdir/rename/move/copy/delete/webdav-write/ftp-write
|
||||
- 每客户专属 role,绝不共享(避免权限污染,规格 §7)
|
||||
|
||||
## 路径安全(规格 §32)
|
||||
|
||||
- 资源路径只允许 `/Class/...` 逻辑路径。
|
||||
- 拒绝 `../`、编码遍历、反斜杠、绝对 host 路径等。
|
||||
- NocoDB 中的 `alist_path` 需校验(n8n 侧或表单侧)。
|
||||
|
||||
## 强密码 / 2FA
|
||||
|
||||
- AList admin 强密码;支持 2FA 则启用(`/api/admin/user/cancel_2fa` 已确认存在,说明 2FA 可管理)。
|
||||
- 客户密码由你设定初值,首次交付后建议客户自行改。
|
||||
@@ -0,0 +1,4 @@
|
||||
# 故障排查
|
||||
|
||||
> Phase 10 补全。当前已覆盖常见问题见 UPGRADE.md 的排查表。
|
||||
> 实施过程中遇到的真实问题会回填到这里。
|
||||
@@ -0,0 +1,25 @@
|
||||
# 升级 / 故障排查
|
||||
|
||||
## 升级(规格 §36)
|
||||
|
||||
1. 备份 AList data/config。
|
||||
2. 读 release notes(github.com/AlistGo/alist/releases)。
|
||||
3. 固定新版本 tag(如 `v3.64.0`),改 `docker-compose.yml` 的 `image:`。
|
||||
4. Portainer → Recreate / `docker compose pull && up -d`。
|
||||
5. 跑 `scripts/healthcheck.sh`。
|
||||
6. 跑 n8n Full Reconciliation(WF4)。
|
||||
7. 验证 sample customer 权限。
|
||||
8. 异常则回滚 tag + 还原备份。
|
||||
|
||||
## 故障排查
|
||||
|
||||
| 症状 | 排查 |
|
||||
|---|---|
|
||||
| container unhealthy | `docker logs alist-serve-resource`;确认 `/ping` 返回 200 |
|
||||
| 挂载 MISS | 确认 NAS 目录名与 compose volumes 一致 |
|
||||
| 客户看不到路径 | 检查 role `permission_scopes`、user `permission` bit 14、storage mount path |
|
||||
| 客户能写文件 | 检查 user `permission` bitmask 是否误设 bit 3–7;确认挂载 `:ro` |
|
||||
| n8n 连不上 alist | 确认同 `bridge_hoelee` 网络、host 名 `alist-serve-resource` |
|
||||
| 到期未撤销 | 确认 WF3 每 5 min 触发;查 NocoDB `sync_status` |
|
||||
|
||||
详细排查步骤 Phase 10 补全。
|
||||
@@ -0,0 +1,70 @@
|
||||
# n8n Setup — Environment Variables
|
||||
|
||||
W1/W2 通过**一个 in-flow 的 "Load Env" Code 节点**(webhook 之后第 2 步)读取全部 env,
|
||||
下游所有节点引用 `$('Load Env').item.json.VAR`。secrets 不硬编码进 workflow。
|
||||
|
||||
## 读取方式(已实测确认)
|
||||
|
||||
- **Code 节点内**:`$env.VAR` 可用(例 `$env.NOCODB_URL`)。✔
|
||||
- **HTTP 节点表达式字段**(url / header / body expression):`$env` 和 `process.env` **都不可用**。✘
|
||||
- 所以用**一个 Code 节点 "Load Env"** 在 `$env` 里调取全部 env 输出到 `$json`,
|
||||
下游 HTTP 节点用 `{{ $('Load Env').item.json.VAR }}` 取值。
|
||||
|
||||
> 关键点:
|
||||
> - Load Env **必须接在 webhook flow 里(第 2 步)**,不在 flow 里的 Code 节点不会执行。
|
||||
> - Load Env **必须透传传入的字段**(`customerId` / `customerProductId`),否则下游丢数据。
|
||||
|
||||
### Load Env 模板(实测可用)
|
||||
|
||||
```js
|
||||
// Step 2: read ALL env via $env (works in Code node), and pass through
|
||||
// the webhook-derived fields so downstream keeps customerId.
|
||||
const prev = $input.item.json;
|
||||
return { json: {
|
||||
customerId: prev.customerId,
|
||||
eventType: prev.eventType,
|
||||
NOCODB_URL: $env.NOCODB_URL,
|
||||
NOCODB_TOKEN: $env.NOCODB_TOKEN,
|
||||
T_CUSTOMERS: $env.NOCODB_TABLE_CUSTOMERS,
|
||||
ALIST_URL: $env.ALIST_URL,
|
||||
ALIST_ADMIN_USERNAME: $env.ALIST_ADMIN_USERNAME,
|
||||
ALIST_ADMIN_PASSWORD: $env.ALIST_ADMIN_PASSWORD,
|
||||
ALIST_ADMIN_OTP: $env.ALIST_ADMIN_OTP || '',
|
||||
} };
|
||||
```
|
||||
|
||||
## 需要配置的 env(n8n 容器环境变量)
|
||||
|
||||
```
|
||||
NOCODB_URL=https://nocodb.hoelee.com
|
||||
NOCODB_TOKEN=<workspace token>
|
||||
NOCODB_TABLE_CUSTOMERS=maqb9zgvza2te3k
|
||||
NOCODB_TABLE_PRODUCTS=molyb3pxv81l75g
|
||||
NOCODB_TABLE_RESOURCES=m6cm7tkxzd8m18c
|
||||
NOCODB_TABLE_CUSTOMERPRODUCTS=mvyqbv8ao7u6bct
|
||||
NOCODB_TABLE_ACCESSGRANTS=mxdx3yhfcyej3ao
|
||||
NOCODB_LINK_PROD_RESOURCES=c4ko4x8kwi16swg
|
||||
ALIST_URL=http://alist-serve-resource:5244
|
||||
ALIST_ADMIN_USERNAME=hoelee
|
||||
ALIST_ADMIN_PASSWORD=<admin 密码>
|
||||
```
|
||||
|
||||
改完 env 必须**重建 n8n 容器**(不是 restart)。
|
||||
|
||||
## 导入步骤
|
||||
|
||||
1. n8n → Import from File → `W1-customer-provision.json` / `W2-customerproduct-sync.json`。
|
||||
2. Webhook 节点 → Copy Webhook URL。
|
||||
3. NocoDB → 对应表 → 建 Webhook(After Insert + After Update)粘 URL:
|
||||
- Customers 表 → W1;CustomerProducts 表 → W2。
|
||||
|
||||
## AList user 创建顺序(重要)
|
||||
|
||||
`user/create` 传空 `role` 会失败(`admin or guest user can not be created`,因 AList 把空 role
|
||||
自动填成 guest 后拒绝)。**必须先 `role/create` 建客户专属 role,再 `user/create` 传 `role:[新roleId]`。**
|
||||
详见 `docs/API_INTEGRATION.md` §9.7。
|
||||
|
||||
## 验证状态
|
||||
|
||||
- ✅ W1 已实测跑通(create user 自动化成功)。
|
||||
- W2 待测(同 Load Env 模式已套用)。
|
||||
@@ -0,0 +1,591 @@
|
||||
{
|
||||
"name": "W1 - Customer Provision (AList User + Role)",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "alist-customer-provision",
|
||||
"responseMode": "onReceived",
|
||||
"options": {}
|
||||
},
|
||||
"id": "trigger-webhook",
|
||||
"name": "NocoDB: Customer Webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"webhookId": "customer-provision"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "const body = $input.item.json.body ?? $input.item.json;\nconst data = body.data ?? {};\nconst rows = data.rows?.[0] ?? data.previous_rows?.[0] ?? data.rows_new?.[0] ?? data.rows_old?.[0] ?? {};\nconst id = rows.Id ?? data.data?.rows?.[0]?.Id;\nif (!id) throw new Error('no customer Id in webhook');\nreturn { json: { customerId: Number(id), eventType: body.type ?? '' } };\n"
|
||||
},
|
||||
"id": "extract-id",
|
||||
"name": "Extract Customer Record Id",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
220,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "// Step 2: read ALL env via process.env (works in Code node), and pass through\n// the webhook-derived fields so downstream keeps customerId.\nconst e = process.env;\nconst prev = $input.item.json;\nreturn { json: {\n customerId: prev.customerId,\n eventType: prev.eventType,\n NOCODB_URL: e.NOCODB_URL,\n NOCODB_TOKEN: e.NOCODB_TOKEN,\n T_CUSTOMERS: e.NOCODB_TABLE_CUSTOMERS,\n ALIST_URL: e.ALIST_URL,\n ALIST_ADMIN_USERNAME: e.ALIST_ADMIN_USERNAME,\n ALIST_ADMIN_PASSWORD: e.ALIST_ADMIN_PASSWORD,\n ALIST_ADMIN_OTP: e.ALIST_ADMIN_OTP || '',\n} };\n"
|
||||
},
|
||||
"id": "load-env",
|
||||
"name": "Load Env",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
220,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "GET",
|
||||
"url": "={{ $('Load Env').item.json.NOCODB_URL }}/api/v2/tables/{{ $('Load Env').item.json.T_CUSTOMERS }}/records/{{ $json.customerId }}",
|
||||
"authentication": "none",
|
||||
"sendHeaders": true,
|
||||
"headerParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "xc-token",
|
||||
"value": "{{ $('Load Env').item.json.NOCODB_TOKEN }}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"response": {
|
||||
"response": {
|
||||
"responseFormat": "json"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"id": "fetch-customer",
|
||||
"name": "Fetch Latest Customer Record",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [
|
||||
440,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "const cust = $input.item.json;\nconst username = cust.username; const password = cust.password;\nif (!username) throw new Error('Customer missing username');\nreturn { json: { customerId: cust.Id ?? cust.id, username, password, displayName: cust.display_name ?? username, basePath: cust.base_path ?? '/Class', alistUserId: cust.alist_user_id ?? null, alistRoleId: cust.alist_role_id ?? null, isCreate: !(cust.alist_user_id) } };\n"
|
||||
},
|
||||
"id": "prepare",
|
||||
"name": "Prepare Provision Payload",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
660,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "={{ $('Load Env').item.json.ALIST_URL }}/api/auth/login",
|
||||
"authentication": "none",
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify({ username: $('Load Env').item.json.ALIST_ADMIN_USERNAME, password: $('Load Env').item.json.ALIST_ADMIN_PASSWORD }) }}",
|
||||
"options": {
|
||||
"response": {
|
||||
"response": {
|
||||
"responseFormat": "json"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"id": "alist-login",
|
||||
"name": "AList Admin Login",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [
|
||||
880,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "const resp = $input.item.json;\nconst token = resp?.data?.token;\nif (!token) throw new Error('AList admin login failed: ' + JSON.stringify(resp));\nreturn { json: { token } };\n"
|
||||
},
|
||||
"id": "extract-token",
|
||||
"name": "Extract Admin Token",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
1100,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"conditions": {
|
||||
"options": {
|
||||
"caseSensitive": true,
|
||||
"leftValue": "",
|
||||
"typeName": "boolean",
|
||||
"ignoreCase": true
|
||||
},
|
||||
"conditions": [
|
||||
{
|
||||
"id": "cond-create",
|
||||
"leftValue": "={{ $('Prepare Provision Payload').item.json.isCreate }}",
|
||||
"rightValue": true,
|
||||
"operator": {
|
||||
"type": "boolean",
|
||||
"operation": "equals",
|
||||
"singleValue": true,
|
||||
"rightType": "boolean"
|
||||
}
|
||||
}
|
||||
],
|
||||
"combinator": "or"
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"id": "branch-create-update",
|
||||
"name": "Create vs Update",
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
1320,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "={{ $('Load Env').item.json.ALIST_URL }}/api/admin/role/create",
|
||||
"authentication": "none",
|
||||
"sendHeaders": true,
|
||||
"headerParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Authorization",
|
||||
"value": "={{ $('Extract Admin Token').item.json.token }}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify({ name: 'customer-' + $('Prepare Provision Payload').item.json.username, description: 'Auto role for ' + $('Prepare Provision Payload').item.json.username, default: false, permission_scopes: [] }) }}",
|
||||
"options": {
|
||||
"response": {
|
||||
"response": {
|
||||
"responseFormat": "json"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"id": "alist-create-role",
|
||||
"name": "AList Create Role",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [
|
||||
1540,
|
||||
-240
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "GET",
|
||||
"url": "={{ $('Load Env').item.json.ALIST_URL }}/api/admin/role/list?page=1&per_page=100",
|
||||
"authentication": "none",
|
||||
"sendHeaders": true,
|
||||
"headerParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Authorization",
|
||||
"value": "={{ $('Extract Admin Token').item.json.token }}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"response": {
|
||||
"response": {
|
||||
"responseFormat": "json"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"id": "alist-list-roles",
|
||||
"name": "AList List Roles (find id)",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [
|
||||
1760,
|
||||
-240
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "const roles = $input.item.json?.data?.content ?? [];\nconst target = 'customer-' + $('Prepare Provision Payload').item.json.username;\nconst r = roles.find(x => x.name === target);\nif (!r) throw new Error('Role not found: ' + target);\nreturn { json: { alistRoleId: r.id, roleName: target } };\n"
|
||||
},
|
||||
"id": "find-role-id",
|
||||
"name": "Find New Role Id",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
1980,
|
||||
-240
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "={{ $('Load Env').item.json.ALIST_URL }}/api/admin/user/create",
|
||||
"authentication": "none",
|
||||
"sendHeaders": true,
|
||||
"headerParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Authorization",
|
||||
"value": "={{ $('Extract Admin Token').item.json.token }}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify({ username: $('Prepare Provision Payload').item.json.username, password: $('Prepare Provision Payload').item.json.password, base_path: $('Prepare Provision Payload').item.json.basePath, role: [$('Find New Role Id').item.json.alistRoleId], permission: 16384 }) }}",
|
||||
"options": {
|
||||
"response": {
|
||||
"response": {
|
||||
"responseFormat": "json"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"id": "alist-create-user",
|
||||
"name": "AList Create User",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [
|
||||
2200,
|
||||
-240
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "GET",
|
||||
"url": "={{ $('Load Env').item.json.ALIST_URL }}/api/admin/user/list?page=1&per_page=100",
|
||||
"authentication": "none",
|
||||
"sendHeaders": true,
|
||||
"headerParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Authorization",
|
||||
"value": "={{ $('Extract Admin Token').item.json.token }}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"response": {
|
||||
"response": {
|
||||
"responseFormat": "json"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"id": "alist-list-users",
|
||||
"name": "AList List Users (find id)",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [
|
||||
2420,
|
||||
-240
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "const users = $input.item.json?.data?.content ?? [];\nconst target = $('Prepare Provision Payload').item.json.username;\nconst u = users.find(x => x.username === target);\nif (!u) throw new Error('Created user not found: ' + target);\nreturn { json: { alistUserId: u.id, username: target } };\n"
|
||||
},
|
||||
"id": "find-user-id",
|
||||
"name": "Find New User Id",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
2640,
|
||||
-240
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "PATCH",
|
||||
"url": "={{ $('Load Env').item.json.NOCODB_URL }}/api/v2/tables/{{ $('Load Env').item.json.T_CUSTOMERS }}/records",
|
||||
"authentication": "none",
|
||||
"sendHeaders": true,
|
||||
"headerParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "xc-token",
|
||||
"value": "{{ $('Load Env').item.json.NOCODB_TOKEN }}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify([{ Id: $('Prepare Provision Payload').item.json.customerId, alist_user_id: $('Find New User Id').item.json.alistUserId, alist_role_id: $('Find New Role Id').item.json.alistRoleId, sync_status: 'synced', last_sync: new Date().toISOString(), last_sync_error: null }]) }}",
|
||||
"options": {}
|
||||
},
|
||||
"id": "nocodb-writeback-create",
|
||||
"name": "Write Back (Created)",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [
|
||||
2860,
|
||||
-240
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "={{ $('Load Env').item.json.ALIST_URL }}/api/admin/user/update",
|
||||
"authentication": "none",
|
||||
"sendHeaders": true,
|
||||
"headerParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Authorization",
|
||||
"value": "={{ $('Extract Admin Token').item.json.token }}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify({ id: $('Prepare Provision Payload').item.json.alistUserId, username: $('Prepare Provision Payload').item.json.username, password: $('Prepare Provision Payload').item.json.password, base_path: $('Prepare Provision Payload').item.json.basePath, role: $('Prepare Provision Payload').item.json.alistRoleId ? [$('Prepare Provision Payload').item.json.alistRoleId] : [], permission: 16384 }) }}",
|
||||
"options": {
|
||||
"response": {
|
||||
"response": {
|
||||
"responseFormat": "json"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"id": "alist-update-user",
|
||||
"name": "AList Update User",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [
|
||||
1540,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "PATCH",
|
||||
"url": "={{ $('Load Env').item.json.NOCODB_URL }}/api/v2/tables/{{ $('Load Env').item.json.T_CUSTOMERS }}/records",
|
||||
"authentication": "none",
|
||||
"sendHeaders": true,
|
||||
"headerParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "xc-token",
|
||||
"value": "{{ $('Load Env').item.json.NOCODB_TOKEN }}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify([{ Id: $('Prepare Provision Payload').item.json.customerId, sync_status: 'synced', last_sync: new Date().toISOString(), last_sync_error: null }]) }}",
|
||||
"options": {}
|
||||
},
|
||||
"id": "nocodb-writeback-update",
|
||||
"name": "Write Back (Updated)",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [
|
||||
1760,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"NocoDB: Customer Webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Extract Customer Record Id",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Extract Customer Record Id": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Load Env",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Fetch Latest Customer Record": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Prepare Provision Payload",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Prepare Provision Payload": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "AList Admin Login",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"AList Admin Login": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Extract Admin Token",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Extract Admin Token": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Create vs Update",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Create vs Update": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "AList Create Role",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "AList Update User",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"AList Create Role": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "AList List Roles (find id)",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"AList List Roles (find id)": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Find New Role Id",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Find New Role Id": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "AList Create User",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"AList Create User": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "AList List Users (find id)",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"AList List Users (find id)": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Find New User Id",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Find New User Id": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Write Back (Created)",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Write Back (Created)": {
|
||||
"main": []
|
||||
},
|
||||
"AList Update User": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Write Back (Updated)",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Write Back (Updated)": {
|
||||
"main": []
|
||||
},
|
||||
"Load Env": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Fetch Latest Customer Record",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
# W2 — CustomerProduct 变更 → 同步 AList Role Scopes
|
||||
|
||||
事件驱动:NocoDB CustomerProducts 表 webhook → n8n webhook → 重取记录 → 全量重算该 customer 的 effective grants → diff AList role scopes → apply → 物化 AccessGrants + 回写 sync_status。
|
||||
|
||||
## 核心逻辑(desired-state,规格 §19/§37)
|
||||
|
||||
1. webhook 只带 record Id,n8n `GET /api/v2/tables/{CustomerProducts}/records/{Id}` 重取完整记录。
|
||||
2. 从记录解析 `customer` link(NocoDB link 字段返回关联行 Id + display value);
|
||||
若返回对象数组,取第一个的 `Id`。
|
||||
3. 拉取该 customer 所有 **status=active 且未过期** 的 CustomerProducts。
|
||||
4. 每个 CP → `product` link → Product;再走 Products 的 M2M link 列
|
||||
`c4ko4x8kwi16swg`(`GET /api/v2/tables/{Products}/links/c4ko4x8kwi16swg/records/{pid}`)
|
||||
取该 Product 的全部 Resources(含 `alist_path`)。
|
||||
5. 汇总:`alist_path -> MAX(expires_at)`(多 Product 重叠取最晚;规格 §18「取最大值」)。
|
||||
8. 取 customer 的 `alist_role_id` → `GET /api/admin/role/get?id=` 读当前 scopes。
|
||||
9. diff:desired paths vs 当前 scopes → 要加 / 要删。
|
||||
10. `POST /api/admin/role/update` 整体重写 `permission_scopes`(幂等)。
|
||||
11. 物化 AccessGrants:删掉该 customer 的 product 源 grants,重插 desired(manual 源保留不动)。
|
||||
12. 回写 CustomerProducts 各记录 `sync_status=synced` + `last_sync`。
|
||||
|
||||
## 关键 fact(已验证)
|
||||
- NocoDB link 字段在 v2 records GET 返回形如 `{"customer": {"Id": 5, "username": "..."}}` 或 `[{"Id":5}]`;
|
||||
Code 节点需兼容两种。
|
||||
- AList role create/update 返回 `data:null`;role/get 可回读 scopes。
|
||||
- `permission=0`(read-only bitmask);role scope 用 `{path, permission:0}`。
|
||||
- M2M link col id: Products.Resources = `c4ko4x8kwi16swg`。
|
||||
|
||||
## 首期不做
|
||||
- expiry 定时扫描/移除(W1/W2 验证后再加)
|
||||
- 密码 change-detection 的 password_updated_at 触发逻辑(W1 已留字段)
|
||||
@@ -0,0 +1,184 @@
|
||||
{
|
||||
"name": "W2 - CustomerProduct Sync (AList Role Scopes)",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "alist-cp-sync",
|
||||
"responseMode": "onReceived",
|
||||
"options": {}
|
||||
},
|
||||
"id": "trigger-webhook",
|
||||
"name": "NocoDB: CustomerProduct Webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"webhookId": "cp-sync"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "const body = $input.item.json.body ?? $input.item.json;\nconst data = body.data ?? {};\nconst rows = data.rows?.[0] ?? data.previous_rows?.[0] ?? data.rows_new?.[0] ?? data.rows_old?.[0] ?? {};\nconst id = rows.Id ?? data.data?.rows?.[0]?.Id;\nif (!id) throw new Error('no CP record Id in webhook');\nreturn { json: { customerProductId: Number(id), eventType: body.type ?? '' } };\n"
|
||||
},
|
||||
"id": "extract-id",
|
||||
"name": "Extract CP Record Id",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
220,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "// Step 2: read ALL env via $env (works in Code node), and pass through\n// the webhook-derived fields so downstream keeps customerProductId.\nconst prev = $input.item.json;\nreturn { json: {\n customerProductId: prev.customerProductId,\n eventType: prev.eventType,\n NOCODB_URL: $env.NOCODB_URL,\n NOCODB_TOKEN: $env.NOCODB_TOKEN,\n T_CUSTOMERPRODUCTS: $env.NOCODB_TABLE_CUSTOMERPRODUCTS,\n ALIST_URL: $env.ALIST_URL,\n ALIST_ADMIN_USERNAME: $env.ALIST_ADMIN_USERNAME,\n ALIST_ADMIN_PASSWORD: $env.ALIST_ADMIN_PASSWORD,\n ALIST_ADMIN_OTP: $env.ALIST_ADMIN_OTP || '',\n} };\n"
|
||||
},
|
||||
"id": "load-env",
|
||||
"name": "Load Env",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
220,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "// W2 core computation: effective desired grants + diff vs AList.\n// Uses this.helpers.httpRequest (n8n Code node) for all HTTP.\n// Config read via $env.\nconst NC = $env.NOCODB_URL;\nconst NC_TOKEN = $env.NOCODB_TOKEN;\nconst ALIST = $env.ALIST_URL;\nconst T_CUSTPROD = $env.NOCODB_TABLE_CUSTOMERPRODUCTS;\nconst T_PROD = $env.NOCODB_TABLE_PRODUCTS;\nconst T_CUST = $env.NOCODB_TABLE_CUSTOMERS;\nconst LNK_PROD_RES = $env.NOCODB_LINK_PROD_RESOURCES; // c4ko4x8kwi16swg\n\nasync function ncGet(path) {\n return await this.helpers.httpRequest({\n method: 'GET',\n url: NC + path,\n headers: { 'xc-token': NC_TOKEN },\n json: true,\n });\n}\nasync function alistReq(method, path, body, token) {\n return await this.helpers.httpRequest({\n method,\n url: ALIST + path,\n headers: token ? { Authorization: token } : {},\n body,\n json: true,\n });\n}\nfunction linkId(v) {\n if (v == null) return null;\n if (typeof v === 'object' && !Array.isArray(v)) return v.Id ?? v.id ?? null;\n if (Array.isArray(v)) return v[0]?.Id ?? v[0]?.id ?? null;\n return v;\n}\n\nconst custProdId = $json.customerProductId;\n\n// 1. re-fetch the CP record\nconst cp = await ncGet(`/api/v2/tables/${T_CUSTPROD}/records/${custProdId}`);\nconst customerId = linkId(cp.customer);\nif (!customerId) throw new Error('CP has no customer link');\n\n// 2. load all active CPs for this customer\nconst cps = await ncGet(`/api/v2/tables/${T_CUSTPROD}/records?where=(customer,eq,${customerId})&limit=1000`);\nconst active = (cps.list || []).filter(r => {\n if (r.status && r.status !== 'active') return false;\n return true;\n});\n\n// 3. build resource path -> max expires_at\nconst effective = {};\nfor (const c of active) {\n const pid = linkId(c.product);\n if (!pid) continue;\n const resList = await ncGet(`/api/v2/tables/${T_PROD}/links/${LNK_PROD_RES}/records/${pid}`);\n const exp = c.expires_at; // 'YYYY-MM-DD'\n for (const res of (resList.list || [])) {\n const path = res.alist_path;\n if (!path) continue;\n const cur = effective[path];\n if (!cur || !cur.expires || (exp && exp > cur.expires)) {\n effective[path] = { expires: exp || null, permission: 0 };\n }\n }\n}\n\n// 4. admin login\nconst login = await alistReq('POST', '/api/auth/login', {\n username: $env.ALIST_ADMIN_USERNAME,\n password: $env.ALIST_ADMIN_PASSWORD,\n}, null);\nconst token = login?.data?.token;\nif (!token) throw new Error('AList login failed');\n\n// customer's role id\nconst cust = await ncGet(`/api/v2/tables/${T_CUST}/records/${customerId}`);\nconst roleId = cust.alist_role_id;\n\nlet currentScopes = [];\nif (roleId) {\n const role = await alistReq('GET', `/api/admin/role/get?id=${roleId}`, null, token);\n currentScopes = role?.data?.permission_scopes ?? [];\n}\n\nconst desiredPaths = Object.keys(effective);\nconst currentPaths = currentScopes.map(s => s.path);\nconst toAdd = desiredPaths.filter(p => !currentPaths.includes(p));\nconst toRemove = currentPaths.filter(p => !desiredPaths.includes(p));\n\nlet roleName = null, roleDesc = null;\nif (roleId) {\n const role = await alistReq('GET', `/api/admin/role/get?id=${roleId}`, null, token);\n roleName = role?.data?.name;\n roleDesc = role?.data?.description;\n}\n\nreturn {\n json: {\n customerId,\n roleId,\n roleName,\n roleDesc,\n token,\n effective,\n desiredPaths,\n currentScopes,\n diff: { toAdd, toRemove },\n newScopes: desiredPaths.map(p => ({ path: p, permission: effective[p].permission })),\n },\n};\n"
|
||||
},
|
||||
"id": "compute",
|
||||
"name": "Compute Effective Grants",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
440,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "={{ $('Load Env').item.json.ALIST_URL }}/api/admin/role/update",
|
||||
"authentication": "none",
|
||||
"sendHeaders": true,
|
||||
"headerParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Authorization",
|
||||
"value": "={{ $('Compute Effective Grants').item.json.token }}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify({ id: $('Compute Effective Grants').item.json.roleId, name: $('Compute Effective Grants').item.json.roleName, description: $('Compute Effective Grants').item.json.roleDesc, permission_scopes: $('Compute Effective Grants').item.json.newScopes }) }}",
|
||||
"options": {
|
||||
"response": {
|
||||
"response": {
|
||||
"responseFormat": "json"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"id": "alist-apply-role",
|
||||
"name": "AList Update Role Scopes",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [
|
||||
660,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "PATCH",
|
||||
"url": "={{ $('Load Env').item.json.NOCODB_URL }}/api/v2/tables/{{ $('Load Env').item.json.T_CUSTOMERPRODUCTS }}/records",
|
||||
"authentication": "none",
|
||||
"sendHeaders": true,
|
||||
"headerParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "xc-token",
|
||||
"value": "{{ $('Load Env').item.json.NOCODB_TOKEN }}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify([{ Id: $('Extract CP Record Id').item.json.customerProductId, sync_status: 'synced', last_sync: new Date().toISOString(), last_sync_error: null }]) }}",
|
||||
"options": {}
|
||||
},
|
||||
"id": "nocodb-writeback",
|
||||
"name": "Write Back CP Sync Status",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [
|
||||
880,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"NocoDB: CustomerProduct Webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Extract CP Record Id",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Extract CP Record Id": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Load Env",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Compute Effective Grants": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "AList Update Role Scopes",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"AList Update Role Scopes": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Write Back CP Sync Status",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Load Env": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Compute Effective Grants",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
},
|
||||
"pinData": {}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// W2 core computation: effective desired grants + diff vs AList.
|
||||
// Uses this.helpers.httpRequest (n8n Code node) for all HTTP.
|
||||
// Config read via process.env (n8n $env proxy is unreliable for custom vars).
|
||||
const NC = process.env.NOCODB_URL;
|
||||
const NC_TOKEN = process.env.NOCODB_TOKEN;
|
||||
const ALIST = process.env.ALIST_URL;
|
||||
const T_CUSTPROD = process.env.NOCODB_TABLE_CUSTOMERPRODUCTS;
|
||||
const T_PROD = process.env.NOCODB_TABLE_PRODUCTS;
|
||||
const T_CUST = process.env.NOCODB_TABLE_CUSTOMERS;
|
||||
const LNK_PROD_RES = process.env.NOCODB_LINK_PROD_RESOURCES; // c4ko4x8kwi16swg
|
||||
|
||||
async function ncGet(path) {
|
||||
return await this.helpers.httpRequest({
|
||||
method: 'GET',
|
||||
url: NC + path,
|
||||
headers: { 'xc-token': NC_TOKEN },
|
||||
json: true,
|
||||
});
|
||||
}
|
||||
async function alistReq(method, path, body, token) {
|
||||
return await this.helpers.httpRequest({
|
||||
method,
|
||||
url: ALIST + path,
|
||||
headers: token ? { Authorization: token } : {},
|
||||
body,
|
||||
json: true,
|
||||
});
|
||||
}
|
||||
function linkId(v) {
|
||||
if (v == null) return null;
|
||||
if (typeof v === 'object' && !Array.isArray(v)) return v.Id ?? v.id ?? null;
|
||||
if (Array.isArray(v)) return v[0]?.Id ?? v[0]?.id ?? null;
|
||||
return v;
|
||||
}
|
||||
|
||||
const custProdId = $json.customerProductId;
|
||||
|
||||
// 1. re-fetch the CP record
|
||||
const cp = await ncGet(`/api/v2/tables/${T_CUSTPROD}/records/${custProdId}`);
|
||||
const customerId = linkId(cp.customer);
|
||||
if (!customerId) throw new Error('CP has no customer link');
|
||||
|
||||
// 2. load all active CPs for this customer
|
||||
const cps = await ncGet(`/api/v2/tables/${T_CUSTPROD}/records?where=(customer,eq,${customerId})&limit=1000`);
|
||||
const active = (cps.list || []).filter(r => {
|
||||
if (r.status && r.status !== 'active') return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// 3. build resource path -> max expires_at
|
||||
const effective = {};
|
||||
for (const c of active) {
|
||||
const pid = linkId(c.product);
|
||||
if (!pid) continue;
|
||||
const resList = await ncGet(`/api/v2/tables/${T_PROD}/links/${LNK_PROD_RES}/records/${pid}`);
|
||||
const exp = c.expires_at; // 'YYYY-MM-DD'
|
||||
for (const res of (resList.list || [])) {
|
||||
const path = res.alist_path;
|
||||
if (!path) continue;
|
||||
const cur = effective[path];
|
||||
if (!cur || !cur.expires || (exp && exp > cur.expires)) {
|
||||
effective[path] = { expires: exp || null, permission: 0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. admin login
|
||||
const login = await alistReq('POST', '/api/auth/login', {
|
||||
username: process.env.ALIST_ADMIN_USERNAME,
|
||||
password: process.env.ALIST_ADMIN_PASSWORD,
|
||||
}, null);
|
||||
const token = login?.data?.token;
|
||||
if (!token) throw new Error('AList login failed');
|
||||
|
||||
// customer's role id
|
||||
const cust = await ncGet(`/api/v2/tables/${T_CUST}/records/${customerId}`);
|
||||
const roleId = cust.alist_role_id;
|
||||
|
||||
let currentScopes = [];
|
||||
if (roleId) {
|
||||
const role = await alistReq('GET', `/api/admin/role/get?id=${roleId}`, null, token);
|
||||
currentScopes = role?.data?.permission_scopes ?? [];
|
||||
}
|
||||
|
||||
const desiredPaths = Object.keys(effective);
|
||||
const currentPaths = currentScopes.map(s => s.path);
|
||||
const toAdd = desiredPaths.filter(p => !currentPaths.includes(p));
|
||||
const toRemove = currentPaths.filter(p => !desiredPaths.includes(p));
|
||||
|
||||
let roleName = null, roleDesc = null;
|
||||
if (roleId) {
|
||||
const role = await alistReq('GET', `/api/admin/role/get?id=${roleId}`, null, token);
|
||||
roleName = role?.data?.name;
|
||||
roleDesc = role?.data?.description;
|
||||
}
|
||||
|
||||
return {
|
||||
json: {
|
||||
customerId,
|
||||
roleId,
|
||||
roleName,
|
||||
roleDesc,
|
||||
token,
|
||||
effective,
|
||||
desiredPaths,
|
||||
currentScopes,
|
||||
diff: { toAdd, toRemove },
|
||||
newScopes: desiredPaths.map(p => ({ path: p, permission: effective[p].permission })),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create the AlistAccess NocoDB base + 7-table schema (idempotent).
|
||||
|
||||
Verified against NocoDB 2026.08.1 on DSM. Run from any machine with SSH to DSM,
|
||||
or adapt to point NocoDB URL directly. Uses workspace XC-TOKEN.
|
||||
|
||||
Usage:
|
||||
python create_nocodb_schema.py
|
||||
|
||||
Env/config at top of file. Idempotent: delete + recreate base each run.
|
||||
"""
|
||||
import paramiko, json, base64
|
||||
|
||||
# ---- CONFIG ----
|
||||
SSH_HOST = "192.168.1.1"
|
||||
SSH_PORT = 9998
|
||||
SSH_USER = "hoelee"
|
||||
SSH_PASS = "CHANGE_ME_ssh_password"
|
||||
NOCODB_URL = "http://localhost:10380" # reached via SSH on DSM
|
||||
NOCODB_TOKEN = "CHANGE_ME_nocodb_token"
|
||||
# ----------------
|
||||
|
||||
|
||||
def make_ssh():
|
||||
c = paramiko.SSHClient()
|
||||
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
c.connect(SSH_HOST, port=SSH_PORT, username=SSH_USER, password=SSH_PASS, timeout=15)
|
||||
return c
|
||||
|
||||
|
||||
def main():
|
||||
ssh = make_ssh()
|
||||
def run(cmd):
|
||||
i, o, e = ssh.exec_command(cmd)
|
||||
return o.read().decode() + e.read().decode()
|
||||
|
||||
def curl(method, path, body=None):
|
||||
h = f'-H "xc-token: {NOCODB_TOKEN}"'
|
||||
d = ""
|
||||
if body is not None:
|
||||
b64 = base64.b64encode(json.dumps(body).encode()).decode()
|
||||
run(f"echo '{b64}' | base64 -d > /tmp/nc_payload.json")
|
||||
d = "--data-binary @/tmp/nc_payload.json -H 'Content-Type: application/json'"
|
||||
out = run(f'curl -s {h} -X {method} {d} "{NOCODB_URL}{path}"')
|
||||
try:
|
||||
return json.loads(out)
|
||||
except Exception:
|
||||
print("RAW:", out[:600]); return None
|
||||
|
||||
def jget(r, *keys, d=None):
|
||||
try:
|
||||
for k in keys:
|
||||
r = r[k]
|
||||
return r
|
||||
except Exception:
|
||||
return d
|
||||
|
||||
# 1. drop existing base if any (find by title)
|
||||
bases = curl("GET", "/api/v2/meta/bases/")
|
||||
for b in jget(bases, "list", d=[]) or []:
|
||||
if b.get("title") == "AlistAccess":
|
||||
curl("DELETE", f"/api/v2/meta/bases/{b['id']}")
|
||||
print("deleted old AlistAccess base", b["id"])
|
||||
|
||||
# 2. create base
|
||||
b = curl("POST", "/api/v2/meta/bases/", {"title": "AlistAccess"})
|
||||
base_id = b["id"]
|
||||
print("BASE:", base_id)
|
||||
|
||||
def create_table(title, columns):
|
||||
return curl("POST", f"/api/v2/meta/bases/{base_id}/tables",
|
||||
{"title": title, "table_name": title, "type": "table", "columns": columns})
|
||||
|
||||
def add_col(tid, title, uidt, **extra):
|
||||
body = {"title": title, "uidt": uidt}
|
||||
body.update(extra)
|
||||
return curl("POST", f"/api/v1/db/meta/tables/{tid}/columns", body)
|
||||
|
||||
def set_options(colid, opts):
|
||||
return curl("PATCH", f"/api/v1/db/meta/columns/{colid}",
|
||||
{"colOptions": {"options": [{"title": o, "order": i + 1, "color": None}
|
||||
for i, o in enumerate(opts)]},
|
||||
"dtxp": ",".join(f"'{o}'" for o in opts)})
|
||||
|
||||
def add_ss(tid, title, opts):
|
||||
r = add_col(tid, title, "SingleSelect")
|
||||
cid = jget(r, "id")
|
||||
set_options(cid, opts)
|
||||
return cid
|
||||
|
||||
def add_link(child_tid, title, parent_tid, type_="bt"):
|
||||
return add_col(child_tid, title, "LinkToAnotherRecord",
|
||||
parentId=parent_tid, childId=child_tid, type=type_)
|
||||
|
||||
# ---- tables ----
|
||||
cust = create_table("Customers", [
|
||||
{"title": "username", "uidt": "SingleLineText"},
|
||||
{"title": "password", "uidt": "SingleLineText"},
|
||||
{"title": "display_name", "uidt": "SingleLineText"},
|
||||
{"title": "email", "uidt": "Email"},
|
||||
{"title": "notes", "uidt": "LongText"},
|
||||
{"title": "alist_user_id", "uidt": "Number"},
|
||||
{"title": "alist_role_id", "uidt": "Number"},
|
||||
{"title": "last_sync", "uidt": "DateTime"},
|
||||
{"title": "last_sync_error", "uidt": "LongText"},
|
||||
]); cust = jget(cust, "id")
|
||||
add_ss(cust, "status", ["pending","active","suspended","expired","disabled","deleted"])
|
||||
add_ss(cust, "sync_status", ["pending","syncing","synced","error","unknown"])
|
||||
add_col(cust, "sync_required", "Checkbox")
|
||||
|
||||
prod = create_table("Products", [
|
||||
{"title": "name", "uidt": "SingleLineText"},
|
||||
{"title": "description", "uidt": "LongText"},
|
||||
{"title": "default_duration_days", "uidt": "Number"},
|
||||
]); prod = jget(prod, "id")
|
||||
add_ss(prod, "status", ["active","inactive"])
|
||||
|
||||
res = create_table("Resources", [
|
||||
{"title": "name", "uidt": "SingleLineText"},
|
||||
{"title": "alist_path", "uidt": "SingleLineText"},
|
||||
{"title": "description", "uidt": "LongText"},
|
||||
]); res = jget(res, "id")
|
||||
add_ss(res, "status", ["active","inactive"])
|
||||
|
||||
cp = create_table("CustomerProducts", [
|
||||
{"title": "start_at", "uidt": "Date"},
|
||||
{"title": "expires_at", "uidt": "Date"},
|
||||
{"title": "last_sync", "uidt": "DateTime"},
|
||||
{"title": "last_sync_error", "uidt": "LongText"},
|
||||
]); cp = jget(cp, "id")
|
||||
add_ss(cp, "status", ["scheduled","active","expired","revoked","suspended"])
|
||||
add_ss(cp, "sync_status", ["pending","syncing","synced","error","unknown"])
|
||||
add_col(cp, "sync_required", "Checkbox")
|
||||
|
||||
ag = create_table("AccessGrants", [
|
||||
{"title": "start_at", "uidt": "Date"},
|
||||
{"title": "expires_at", "uidt": "Date"},
|
||||
{"title": "revoked_at", "uidt": "DateTime"},
|
||||
{"title": "revoke_reason", "uidt": "LongText"},
|
||||
{"title": "last_sync", "uidt": "DateTime"},
|
||||
{"title": "last_sync_error", "uidt": "LongText"},
|
||||
]); ag = jget(ag, "id")
|
||||
add_ss(ag, "permission", ["read","readwrite"])
|
||||
add_ss(ag, "source", ["product","manual"])
|
||||
add_ss(ag, "status", ["pending","active","revoked","expired"])
|
||||
add_ss(ag, "sync_status", ["pending","syncing","synced","error","unknown"])
|
||||
add_col(ag, "sync_required", "Checkbox")
|
||||
|
||||
al = create_table("AccessLogs", [
|
||||
{"title": "username", "uidt": "SingleLineText"},
|
||||
{"title": "resource_path", "uidt": "SingleLineText"},
|
||||
{"title": "file_path", "uidt": "SingleLineText"},
|
||||
{"title": "ip_address", "uidt": "SingleLineText"},
|
||||
{"title": "user_agent", "uidt": "LongText"},
|
||||
{"title": "bytes", "uidt": "Number"},
|
||||
{"title": "timestamp", "uidt": "DateTime"},
|
||||
]); al = jget(al, "id")
|
||||
add_ss(al, "action", ["login","view","download","preview","logout"])
|
||||
add_ss(al, "result", ["success","denied"])
|
||||
|
||||
pur = create_table("Purchases", [
|
||||
{"title": "purchase_date", "uidt": "Date"},
|
||||
{"title": "amount", "uidt": "Decimal"},
|
||||
{"title": "payment_method", "uidt": "SingleLineText"},
|
||||
{"title": "order_id", "uidt": "SingleLineText"},
|
||||
{"title": "notes", "uidt": "LongText"},
|
||||
]); pur = jget(pur, "id")
|
||||
|
||||
# ---- links ----
|
||||
add_link(cp, "customer", cust)
|
||||
add_link(cp, "product", prod)
|
||||
add_link(prod, "Resources", res, type_="mm") # Products <-> Resources M2M
|
||||
add_link(ag, "customer", cust)
|
||||
add_link(ag, "resource", res)
|
||||
add_link(al, "customer", cust)
|
||||
add_link(pur, "customer", cust)
|
||||
add_link(pur, "product", prod)
|
||||
|
||||
ids = {"base": base_id, "Customers": cust, "Products": prod, "Resources": res,
|
||||
"CustomerProducts": cp, "AccessGrants": ag, "AccessLogs": al, "Purchases": pur}
|
||||
print("DONE. IDs:", json.dumps(ids, indent=2))
|
||||
# persist for later steps
|
||||
open("_nocodb_ids.json", "w").write(json.dumps(ids, indent=2))
|
||||
ssh.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/bin/bash
|
||||
# AList Serve Resource 健康检查
|
||||
# 用法: ./scripts/healthcheck.sh [ALIST_INTERNAL_URL]
|
||||
set -euo pipefail
|
||||
|
||||
ALIST_URL="${1:-http://localhost:5247}"
|
||||
|
||||
echo "== AList container =="
|
||||
docker inspect --format '状态: {{.State.Status}} 健康: {{.State.Health.Status}}' alist-serve-resource 2>/dev/null || echo "container 未运行"
|
||||
|
||||
echo "== HTTP 探活 =="
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' "${ALIST_URL}/ping" || echo "ERR")
|
||||
echo "GET /ping -> ${code} (期望 200)"
|
||||
|
||||
echo "== 挂载检查 =="
|
||||
docker exec alist-serve-resource sh -c 'for m in /mnt/Class /mnt/Book /mnt/Comic /mnt/Template; do \
|
||||
if [ -d "$m" ]; then echo "OK $m"; else echo "MISS $m"; fi; done' 2>/dev/null || echo "无法 exec(container 未运行或无 shell)"
|
||||
Reference in New Issue
Block a user