Merge branch 'main' into opencode/sunny-orchid

# Conflicts:
#	README.md
This commit is contained in:
RWDai
2026-05-14 20:22:36 +08:00
38 changed files with 363 additions and 789 deletions

View File

@@ -1,131 +0,0 @@
# syntax=docker/dockerfile:1
# Aether 运行镜像Rust gateway 直接服务 API + 前端静态文件(国内镜像源版本)
# 构建命令: docker build -f Dockerfile.app.local -t aether-app:latest .
# ==================== 前端构建 ====================
FROM node:22-slim AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm config set registry https://registry.npmmirror.com && npm ci
COPY frontend/ ./
RUN npm run build
# ==================== Rust gateway 构建 ====================
FROM rust:1.94.1-slim AS gateway-base
WORKDIR /build
# 本地镜像优先缩短构建时间,保留 release 语义,但改用更快的 thin LTO。
ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse \
CARGO_PROFILE_RELEASE_LTO=thin \
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list.d/debian.sources && \
apt-get update && apt-get install -y --no-install-recommends \
build-essential \
ca-certificates \
libssl-dev \
pkg-config \
perl
RUN --mount=type=cache,id=aether-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,id=aether-cargo-git,target=/usr/local/cargo/git,sharing=locked \
cargo install cargo-chef --locked
FROM gateway-base AS gateway-planner
COPY Cargo.toml Cargo.lock ./
COPY apps/ ./apps/
COPY crates/ ./crates/
RUN cargo chef prepare --recipe-path recipe.json
FROM gateway-base AS gateway-builder
COPY --from=gateway-planner /build/recipe.json ./recipe.json
RUN --mount=type=cache,id=aether-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,id=aether-cargo-git,target=/usr/local/cargo/git,sharing=locked \
--mount=type=cache,id=aether-cargo-target-local,target=/build/target,sharing=locked \
cargo chef cook --release --locked --package aether-gateway --bin aether-gateway --recipe-path recipe.json
COPY Cargo.toml Cargo.lock ./
COPY apps/ ./apps/
COPY crates/ ./crates/
RUN --mount=type=cache,id=aether-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,id=aether-cargo-git,target=/usr/local/cargo/git,sharing=locked \
--mount=type=cache,id=aether-cargo-target-local,target=/build/target,sharing=locked \
cargo build --release --locked -p aether-gateway && \
cp target/release/aether-gateway /tmp/aether-gateway
# ==================== 最小运行时打包 ====================
FROM gateway-builder AS runtime-prep
RUN set -eux; \
mkdir -p \
/runtime-root/app/data \
/runtime-root/app/logs \
/runtime-root/etc \
/runtime-root/etc/ssl \
/runtime-root/lib \
/runtime-root/lib64 \
/runtime-root/usr/local/bin; \
cp /tmp/aether-gateway /runtime-root/usr/local/bin/aether-gateway; \
: > /tmp/runtime-libs.txt; \
: > /tmp/runtime-scan-queue.txt; \
printf '%s\n' /tmp/aether-gateway >> /tmp/runtime-scan-queue.txt; \
while [ -s /tmp/runtime-scan-queue.txt ]; do \
current="$(head -n1 /tmp/runtime-scan-queue.txt)"; \
sed -i '1d' /tmp/runtime-scan-queue.txt; \
ldd "$current" | awk '/=>/ { print $3 } $1 ~ /^\// { print $1 }' | while read -r lib; do \
[ -n "$lib" ]; \
if ! grep -Fxq "$lib" /tmp/runtime-libs.txt; then \
printf '%s\n' "$lib" >> /tmp/runtime-libs.txt; \
printf '%s\n' "$lib" >> /tmp/runtime-scan-queue.txt; \
fi; \
done; \
done; \
sort -u /tmp/runtime-libs.txt -o /tmp/runtime-libs.txt; \
while read -r lib; do \
[ -n "$lib" ]; \
dest="/runtime-root$(dirname "$lib")"; \
mkdir -p "$dest"; \
cp -L "$lib" "$dest/"; \
done < /tmp/runtime-libs.txt; \
for lib in \
/lib/x86_64-linux-gnu/libnss_dns.so.2 \
/lib/x86_64-linux-gnu/libnss_files.so.2 \
/lib/x86_64-linux-gnu/libresolv.so.2; do \
if [ -f "$lib" ]; then \
dest="/runtime-root$(dirname "$lib")"; \
mkdir -p "$dest"; \
cp -L "$lib" "$dest/"; \
fi; \
done; \
cp -a /usr/lib/ssl /runtime-root/usr/lib/; \
cp -a /etc/ssl/certs /runtime-root/etc/ssl/; \
if [ -f /etc/ssl/openssl.cnf ]; then \
cp /etc/ssl/openssl.cnf /runtime-root/etc/ssl/openssl.cnf; \
fi; \
if [ -f /etc/nsswitch.conf ]; then \
cp /etc/nsswitch.conf /runtime-root/etc/nsswitch.conf; \
fi
# ==================== 运行时镜像 ====================
FROM scratch
# 复制 gateway 二进制
COPY --from=runtime-prep /runtime-root/ /
# 复制前端构建产物
COPY --from=frontend-builder /app/frontend/dist /srv/frontend
WORKDIR /app
ENV LANG=C.UTF-8 \
LC_ALL=C.UTF-8 \
RUST_LOG=aether_gateway=info \
APP_PORT=8084 \
AETHER_GATEWAY_STATIC_DIR=/srv/frontend
EXPOSE 8084
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD ["/usr/local/bin/aether-gateway", "--healthcheck"]
ENTRYPOINT ["/usr/local/bin/aether-gateway"]

224
README.md
View File

@@ -44,179 +44,26 @@ cd Aether
# 2. 配置环境变量 # 2. 配置环境变量
cp .env.example .env cp .env.example .env
./generate_keys.sh # 生成 JWT_SECRET_KEY / ENCRYPTION_KEY, 并填入 .env # 生成 JWT_SECRET_KEY / ENCRYPTION_KEY, 并填入 .env
# 编辑 .env 设置 ADMIN_PASSWORD
# 3. 首次部署 / 更新
docker compose pull && docker compose up -d
# 4. 默认会在 app 启动前自动执行挂起的 migration / backfill
# 如需手工控制,可在 .env 中设 AETHER_GATEWAY_AUTO_PREPARE_DATABASE=false
# 然后按需执行:
docker compose run --rm app --migrate
docker compose run --rm app --apply-backfills
# 5. 升级前备份 (可选)
docker compose exec postgres pg_dump -U postgres aether | gzip > backup_$(date +%Y%m%d_%H%M%S).sql.gz
```
### Docker Compose本地构建镜像
```bash
# 1. 克隆代码
git clone https://github.com/fawney19/Aether.git
cd Aether
# 2. 配置环境变量
cp .env.example .env
./generate_keys.sh # 生成 JWT_SECRET_KEY / ENCRYPTION_KEY, 并填入 .env
# 编辑 .env 设置 ADMIN_PASSWORD
# 3. 部署 / 更新(自动构建并启动)
git pull
./deploy.sh
```
### Docker Compose单容器 SQLite / solo
```bash
# 1. 克隆代码
git clone https://github.com/fawney19/Aether.git
cd Aether
# 2. 配置环境变量
cp .env.example .env
./generate_keys.sh ./generate_keys.sh
# 编辑 .env 设置 ADMIN_PASSWORD # 编辑 .env 设置 ADMIN_PASSWORD
# 3. 启动单容器 SQLite 版本 # 3. 首次部署 / 更新 (从以下数据库、内存策略任选其一)
docker compose -f docker-compose.solo.yml up -d # Postgres + Redis (适用于企业或多人使用)
docker compose pull && docker compose up -d
# 4. 升级前备份(可选) # 仅SQLite (适用于个人用户或朋友分享)
cp -a data/aether.db backup_$(date +%Y%m%d_%H%M%S).db docker compose -f docker-compose.sqlite.yml pull && docker compose -f docker-compose.sqlite.yml up -d
``` ```
### 一键安装(可选部署方式) ### 一键安装(可选部署方式 Linux: systemd; Mac: launchd
安装脚本先从 `aether-rust-pioneer` 分支下载,不依赖 GitHub Release 的 `latest` 脚本地址。运行后会先选择语言再选择版本和部署方式。Linux 单机 / 集群服务使用 systemdmacOS 单机 / 集群服务使用系统级 launchd脚本会按当前系统自动下载 `linux-*``macos-*` Release 压缩包。
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/fawney19/Aether/aether-rust-pioneer/install.sh | sudo bash curl -fsSL https://raw.githubusercontent.com/fawney19/Aether/main/install.sh | sudo bash
``` ```
运行后按提示输入语言、版本和部署方式。默认会安装最新正式版。需要提前测试时可用 `AETHER_CHANNEL=rc``AETHER_CHANNEL=beta`Docker Compose 模式默认使用 `latest` 镜像通道。固定安装某个 tag 时,版本选择选 `4`,再输入类似 `v0.7.0-rc.1` 的 tag。
二进制安装在下载 Release 压缩包前会询问是否使用下载加速源;选择使用时会先打印原始 GitHub URL再要求输入新的压缩包下载 URL。非交互式安装可用 `AETHER_RELEASE_ARCHIVE_URL` 指定压缩包 URL。
如果安装目录里已经有配置脚本会优先复用Docker Compose 保留已有 `.env`,二进制服务模式保留已有 `/etc/aether/aether-gateway.env`。只有首次生成新配置时才会提示输入管理员密码。
```text
请选择安装语言 / Choose installer language:
1) 中文
2) 英语 / English
请输入选项 / Enter choice [1]:
请选择 Aether 版本:
1) 最新正式版
2) 最新 RC 预发布版
3) 最新 Beta 预发布版
4) 指定 tag例如 v0.7.0-rc.1
请输入选项 [1]:
请选择 Aether 部署模式:
1) Docker Compose: 应用 + Postgres + Redis
2) 单机服务: systemd/launchd + SQLite + 进程内运行时
3) 集群节点服务: systemd/launchd + 共享数据库 + Redis
4) Docker Compose: 应用 + SQLite
请输入选项 [2]:
是否使用下载加速源?
1) 否,使用原始 GitHub 地址
2) 是,手动填写新的下载 URL
请输入选项 [1]:
```
安装后的常用命令Linux systemd
```bash
sudo systemctl status aether-gateway --no-pager
sudo journalctl -u aether-gateway -f
sudo systemctl restart aether-gateway
```
安装后的常用命令macOS launchd
```bash
sudo launchctl print system/com.aether.gateway
sudo launchctl kickstart -k system/com.aether.gateway
sudo launchctl bootout system /Library/LaunchDaemons/com.aether.gateway.plist
tail -f /var/log/aether/aether-gateway.out.log /var/log/aether/aether-gateway.err.log
```
macOS 原生安装使用系统级 LaunchDaemon默认以专用 `_aether` 服务账号运行;配置和密钥写入 `/etc/aether/aether-gateway.env`,数据和应用日志仍在 `/opt/aether`launchd stdout/stderr 在 `/var/log/aether`
默认单机数据和应用日志都在安装目录内:
```text
/opt/aether/data/aether.db
/opt/aether/logs
```
多节点不能使用 SQLite 或 `AETHER_RUNTIME_BACKEND=memory`。如果先只生成了多节点模板,编辑 `/etc/aether/aether-gateway.env` 后重跑安装脚本即可:
```env
AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY=multi-node
AETHER_GATEWAY_NODE_ROLE=frontdoor
DATABASE_URL=postgresql://...
REDIS_URL=redis://...
```
### 本地开发
```bash
# 启动依赖
docker compose -f docker-compose.build.yml up -d postgres redis
# 数据库迁移(仅在已有数据库引入新 migration 时需要)
./dev.sh --migrate
# 数据回填
./dev.sh --apply-backfills
# 后端
./dev.sh
# 前端
cd frontend && npm install && npm run dev
```
`./dev.sh` 现在只保留一种本地模式:
| 角色 | 本地地址 | 说明 |
|------|----------|------|
| Rust frontdoor | 默认 `http://localhost:8084` | `aether-gateway`,本地唯一公开入口;实际端口由 `APP_PORT` 控制 |
本地默认链路是:
```text
client -> rust frontdoor (aether-gateway) -> execution_runtime/provider transport
```
其中:
- `aether-gateway` 负责公开入口、健康检查、格式转换、本地执行 runtime以及当前已迁到 Rust 的 frontdoor/control/background 路径。
- `./dev.sh` 不再启动 Python 宿主;未下沉到 Rust 的 legacy 路由会直接失败。
- `./dev.sh --migrate` 会复用 `.env` 里的数据库配置,显式执行一次数据库迁移后退出。
- `./dev.sh` 默认把 `AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE` 设为 `rust-authoritative`,避免本地还依赖 Python sync report 语义。
- 空库首次启动会自动初始化到当前 baseline。
- `aether-gateway` 默认启动不会自动应用后续 schema migration如果数据库版本落后服务会拒绝启动并提示先执行 `aether-gateway --migrate`
- 仓库自带的 `docker-compose.yml``docker-compose.build.yml` 都已把 `AETHER_GATEWAY_AUTO_PREPARE_DATABASE` 设为默认开启,因此无论是预构建镜像部署还是 `./deploy.sh` / 本地构建 compose常规启动都会在监听端口前自动执行挂起的 migration 和 backfill。
## Aether Proxy (可选) ## Aether Proxy (可选)
Aether Proxy 是配套的正向代理节点,部署在海外 VPS 上,为墙内的 Aether 实例中转 API 流量。或者部署在其他服务器为指定的提供商、账号、Key使用不同的节点访问。支持 TUI 向导一键配置、systemd 服务管理、TLS 加密、DNS 缓存及连接池调优。 Aether Proxy 是配套的正向代理节点,部署在海外 VPS 上,为墙内的 Aether 实例中转 API 流量。
- Docker Compose 部署或下载预编译二进制直接运行 - Docker Compose 部署或下载预编译二进制直接运行
- 提供 macOS/Linux 与 Windows 一键脚本,自动下载最新 `proxy-v*` 制品并向现有 `aether-proxy.toml` 追加 `[[servers]]` - 提供 macOS/Linux 与 Windows 一键脚本,自动下载最新 `proxy-v*` 制品并向现有 `aether-proxy.toml` 追加 `[[servers]]`
@@ -230,63 +77,18 @@ Aether Proxy 是配套的正向代理节点,部署在海外 VPS 上,为墙
## 环境变量 ## 环境变量
部署建议:
- Docker Compose根目录 [`.env.example`](.env.example)
- systemd 二进制部署:使用根目录 `install.sh` 生成 `/etc/aether/aether-gateway.env`
当前主链路真正要关注的是这组变量:
- `APP_PORT``aether-gateway` 唯一监听端口,固定绑定 `0.0.0.0:${APP_PORT}` - `APP_PORT``aether-gateway` 唯一监听端口,固定绑定 `0.0.0.0:${APP_PORT}`
- `AETHER_DATABASE_DRIVER` / `AETHER_DATABASE_URL`:二进制单机部署可用 `sqlite`,例如 `sqlite:///opt/aether/data/aether.db` - `DATABASE_URL`数据库连接串SQLite 例如 `sqlite:///opt/aether/data/aether.db`Postgres 例如 `postgresql://postgres:aether@postgres:5432/aether`
- `DATABASE_URL` / `REDIS_URL`:共享后端连接串;多节点必须配置共享数据库和 Redis - `REDIS_URL`Redis 连接串;仅 Postgres + Redis 的 Docker Compose 部署需要配置
- `AETHER_RUNTIME_BACKEND=memory|redis`:运行时缓存/协调后端。单机 SQLite 默认用 `memory`,不会连接 Redis;显式设为 `redis` 或多节点部署才会把 `REDIS_URL` 注入运行时 Redis 后端 - `AETHER_RUNTIME_BACKEND=memory|redis`:运行时缓存/协调后端。SQLite 默认用 `memory`,不会连接 Redis
- `AETHER_GATEWAY_AUTO_PREPARE_DATABASE`:常规启动前自动执行挂起的 schema migration 和 backfill仓库自带的 `docker-compose.yml``docker-compose.build.yml` 默认开启 - `AETHER_GATEWAY_AUTO_PREPARE_DATABASE`:常规启动前自动执行挂起的 schema migration 和 backfill仓库自带的 `docker-compose.yml` 默认开启
- `JWT_SECRET_KEY` / `ENCRYPTION_KEY`:认证和敏感数据加密所需密钥 - `JWT_SECRET_KEY` / `ENCRYPTION_KEY`:认证和敏感数据加密所需密钥
- `API_KEY_PREFIX`:用户和管理员新建 API Key 时使用的前缀,默认 `sk` - `API_KEY_PREFIX`:用户和管理员新建 API Key 时使用的前缀,默认 `sk`
- `ADMIN_USERNAME` / `ADMIN_PASSWORD` / `ADMIN_EMAIL`:首次启动时自举首个本地管理员;`install.sh` 会提示输入管理员密码 - `ADMIN_USERNAME` / `ADMIN_PASSWORD` / `ADMIN_EMAIL`:首次启动时自举首个本地管理员;`install.sh` 会提示输入管理员密码
- `CORS_ORIGINS` / `CORS_ALLOW_CREDENTIALS`:前端跨域来源控制;如果要跨域带登录 Cookie`CORS_ORIGINS` 不能写 `*` - `CORS_ORIGINS` / `CORS_ALLOW_CREDENTIALS`:前端跨域来源控制;如果要跨域带登录 Cookie`CORS_ORIGINS` 不能写 `*`
- `AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY=single-node|multi-node`
- `AETHER_GATEWAY_NODE_ROLE=all|frontdoor|background`
- `RUST_LOG`Rust 日志过滤,例如 `aether_gateway=info``aether_gateway=debug,sqlx=warn` - `RUST_LOG`Rust 日志过滤,例如 `aether_gateway=info``aether_gateway=debug,sqlx=warn`
- Docker Compose 的 `DB_PASSWORD` / `REDIS_PASSWORD` 默认使用 `aether` - Docker Compose 的 `DB_PASSWORD` / `REDIS_PASSWORD` 默认使用 `aether`
systemd 的 `.env` 必须保持简单 `KEY=VALUE` 形式,不要写 `export``${VAR}` 或命令替换。
## Q&A
### Q: 如何开启/关闭请求体记录?
管理员在 **系统设置** 中配置日志记录的详细程度:
| 级别 | 记录内容 |
|------|----------|
| Base | 基本请求信息 |
| Headers | Base + 请求头 |
| Full | Headers + 请求体 |
### Q: 更新出问题如何回滚?
**有备份的情况(推荐):**
```bash
# Docker Compose:
# 1. 切回旧镜像 tag / digest
# 2. 恢复 Postgres 备份
# 3. 再启动 app
# systemd:
# 1. 把 /opt/aether/current 切回旧 release
# 2. systemctl restart aether-gateway
# 3. 如果升级包含数据库结构变更,再恢复 Postgres 备份
```
> 可以在升级前通过 `docker inspect ghcr.io/fawney19/aether:latest --format '{{index .RepoDigests 0}}'` 记录当前镜像 digest方便回滚时使用。
**没有备份的情况:**
当前不应该再依赖旧的 `alembic downgrade` 路线。空库首次启动会自动初始化;如果开启 `AETHER_GATEWAY_AUTO_PREPARE_DATABASE=true`(仓库自带的两份 compose 默认都如此),常规服务启动也会自动应用挂起的 migration/backfill。无论是否自动执行只要本次发布带来了不可逆的数据结构变化没有备份就不能保证安全回滚。因此升级前强烈建议先备份 `Postgres`
--- ---
## 许可证 ## 许可证

View File

@@ -153,7 +153,7 @@ pub(super) fn extract_trusted_admin_headers(
let user_role = header_value_str(headers, crate::constants::TRUSTED_ADMIN_USER_ROLE_HEADER)? let user_role = header_value_str(headers, crate::constants::TRUSTED_ADMIN_USER_ROLE_HEADER)?
.trim() .trim()
.to_string(); .to_string();
if !user_role.eq_ignore_ascii_case("admin") { if !crate::roles::can_access_admin_console(&user_role) {
return None; return None;
} }
let session_id = header_value_str(headers, crate::constants::TRUSTED_ADMIN_SESSION_ID_HEADER) let session_id = header_value_str(headers, crate::constants::TRUSTED_ADMIN_SESSION_ID_HEADER)
@@ -171,7 +171,7 @@ pub(super) fn extract_trusted_admin_headers(
Some(GatewayTrustedAdminHeaders { Some(GatewayTrustedAdminHeaders {
user_id, user_id,
user_role: "admin".to_string(), user_role,
session_id, session_id,
management_token_id, management_token_id,
}) })
@@ -563,6 +563,42 @@ mod tests {
); );
} }
#[test]
fn extracts_trusted_audit_admin_headers() {
let mut headers = http::HeaderMap::new();
headers.insert(
crate::constants::GATEWAY_HEADER,
"rust-phase3b".parse().unwrap(),
);
headers.insert(
crate::constants::TRUSTED_ADMIN_USER_ID_HEADER,
"audit-admin-1".parse().unwrap(),
);
headers.insert(
crate::constants::TRUSTED_ADMIN_USER_ROLE_HEADER,
"audit_admin".parse().unwrap(),
);
headers.insert(
crate::constants::TRUSTED_ADMIN_SESSION_ID_HEADER,
"sess-audit-1".parse().unwrap(),
);
let extracted = extract_request_credentials(
&headers,
&uri("/api/admin/endpoints/health/api-formats"),
"admin:endpoints_health",
);
assert_eq!(
extracted.trusted_admin_headers,
Some(GatewayTrustedAdminHeaders {
user_id: "audit-admin-1".to_string(),
user_role: "audit_admin".to_string(),
session_id: Some("sess-audit-1".to_string()),
management_token_id: None,
})
);
}
#[test] #[test]
fn ignores_trusted_admin_headers_without_gateway_marker() { fn ignores_trusted_admin_headers_without_gateway_marker() {
let mut headers = http::HeaderMap::new(); let mut headers = http::HeaderMap::new();

View File

@@ -273,7 +273,7 @@ async fn resolve_local_admin_principal(
if claims if claims
.get("role") .get("role")
.and_then(Value::as_str) .and_then(Value::as_str)
.is_some_and(|role| !role.eq_ignore_ascii_case("admin")) .is_some_and(|role| !crate::roles::can_access_admin_console(role))
{ {
return Ok(None); return Ok(None);
} }
@@ -300,7 +300,7 @@ async fn resolve_local_admin_principal_from_claims(
let Some(user) = state.find_user_auth_by_id(user_id).await? else { let Some(user) = state.find_user_auth_by_id(user_id).await? else {
return Ok(None); return Ok(None);
}; };
if !user.is_active || user.is_deleted || !user.role.eq_ignore_ascii_case("admin") { if !user.is_active || user.is_deleted || !crate::roles::can_access_admin_console(&user.role) {
return Ok(None); return Ok(None);
} }
@@ -329,7 +329,7 @@ async fn resolve_local_admin_principal_from_claims(
Ok(Some(GatewayAdminPrincipalContext { Ok(Some(GatewayAdminPrincipalContext {
user_id: user.id, user_id: user.id,
user_role: "admin".to_string(), user_role: user.role,
session_id: Some(session.id), session_id: Some(session.id),
management_token_id: None, management_token_id: None,
management_token_permissions: None, management_token_permissions: None,

View File

@@ -224,6 +224,19 @@ pub(crate) fn read_only_management_token_permissions() -> Vec<String> {
.collect() .collect()
} }
pub(crate) fn audit_admin_read_only_management_token_permissions() -> Vec<String> {
let mut permissions = read_only_management_token_permissions()
.into_iter()
.collect::<BTreeSet<_>>();
permissions.extend(
PERMISSION_GROUPS
.iter()
.filter(|group| !group.assignable)
.map(|group| permission_key(group.scope, "read").to_string()),
);
permissions.into_iter().collect()
}
pub(crate) fn normalize_assignable_management_token_permissions( pub(crate) fn normalize_assignable_management_token_permissions(
value: Option<&Value>, value: Option<&Value>,
) -> Result<Value, String> { ) -> Result<Value, String> {
@@ -615,6 +628,64 @@ mod tests {
.is_ok()); .is_ok());
} }
#[test]
fn read_only_permissions_allow_reads_and_reject_writes() {
let decision = GatewayControlDecision::synthetic(
"/api/admin/providers".to_string(),
Some("admin_proxy".to_string()),
Some("providers_manage".to_string()),
Some("create_provider".to_string()),
Some("admin:providers".to_string()),
);
let permissions = read_only_management_token_permissions();
assert!(validate_management_token_admin_route_permission(
&http::Method::GET,
&decision,
Some(&permissions),
)
.is_ok());
assert_eq!(
validate_management_token_admin_route_permission(
&http::Method::POST,
&decision,
Some(&permissions),
)
.expect_err("read-only permissions should reject writes")
.required_permission,
"admin:providers:write"
);
}
#[test]
fn audit_admin_read_only_permissions_allow_management_tokens_reads_and_reject_writes() {
let decision = GatewayControlDecision::synthetic(
"/api/admin/management-tokens".to_string(),
Some("admin_proxy".to_string()),
Some("management_tokens_manage".to_string()),
Some("list_tokens".to_string()),
Some("admin:management_tokens".to_string()),
);
let permissions = audit_admin_read_only_management_token_permissions();
assert!(validate_management_token_admin_route_permission(
&http::Method::GET,
&decision,
Some(&permissions),
)
.is_ok());
assert_eq!(
validate_management_token_admin_route_permission(
&http::Method::POST,
&decision,
Some(&permissions),
)
.expect_err("read-only permissions should reject management token writes")
.required_permission,
"admin:management_tokens:write"
);
}
fn extract_admin_route_scopes(source: &'static str) -> BTreeSet<&'static str> { fn extract_admin_route_scopes(source: &'static str) -> BTreeSet<&'static str> {
let mut scopes = BTreeSet::new(); let mut scopes = BTreeSet::new();
let mut remaining = source; let mut remaining = source;

View File

@@ -14,11 +14,13 @@ pub(crate) use auth::{
}; };
pub(crate) use execute::{allows_control_execute_emergency, maybe_execute_via_control}; pub(crate) use execute::{allows_control_execute_emergency, maybe_execute_via_control};
pub(crate) use management_token_permissions::{ pub(crate) use management_token_permissions::{
all_assignable_management_token_permissions, management_token_permission_catalog_payload, all_assignable_management_token_permissions,
management_token_permission_keys_from_value, management_token_permission_mode_and_summary, audit_admin_read_only_management_token_permissions,
management_token_permission_catalog_payload, management_token_permission_keys_from_value,
management_token_permission_mode_and_summary,
management_token_permissions_cover_all_assignable_permissions, management_token_permissions_cover_all_assignable_permissions,
management_token_required_permission, normalize_assignable_management_token_permissions, management_token_required_permission, normalize_assignable_management_token_permissions,
validate_management_token_admin_route_permission, read_only_management_token_permissions, validate_management_token_admin_route_permission,
}; };
pub(crate) use public::{resolve_public_request_context, GatewayPublicRequestContext}; pub(crate) use public::{resolve_public_request_context, GatewayPublicRequestContext};
#[cfg(test)] #[cfg(test)]

View File

@@ -503,7 +503,7 @@ fn normalize_selection_filters(
.map(|value| value.trim().to_ascii_lowercase()) .map(|value| value.trim().to_ascii_lowercase())
.filter(|value| !value.is_empty() && value != "all") .filter(|value| !value.is_empty() && value != "all")
{ {
Some(role) if matches!(role.as_str(), "user" | "admin") => Some(role), Some(role) if crate::roles::normalize_assignable_user_role(&role).is_some() => Some(role),
Some(_) => return Err("role 参数不合法".to_string()), Some(_) => return Err("role 参数不合法".to_string()),
None => None, None => None,
}; };

View File

@@ -225,16 +225,13 @@ pub(super) fn validate_admin_user_password(password: &str, policy: &str) -> Resu
} }
pub(super) fn normalize_admin_user_role(value: Option<&str>) -> Result<String, String> { pub(super) fn normalize_admin_user_role(value: Option<&str>) -> Result<String, String> {
match value let role = value
.map(str::trim) .map(str::trim)
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.unwrap_or("user") .unwrap_or("user");
.to_ascii_lowercase() match crate::roles::normalize_assignable_user_role(role) {
.as_str() Some(role) => Ok(role.to_string()),
{ None => Err("角色参数不合法".to_string()),
"user" => Ok("user".to_string()),
"admin" => Ok("admin".to_string()),
_ => Err("角色参数不合法".to_string()),
} }
} }

View File

@@ -2,6 +2,7 @@ use super::super::internal;
use crate::admin_api; use crate::admin_api;
use crate::audit::attach_admin_audit_event; use crate::audit::attach_admin_audit_event;
use crate::control::{ use crate::control::{
audit_admin_read_only_management_token_permissions,
validate_management_token_admin_route_permission, GatewayPublicRequestContext, validate_management_token_admin_route_permission, GatewayPublicRequestContext,
}; };
use crate::{AppState, GatewayError}; use crate::{AppState, GatewayError};
@@ -61,21 +62,32 @@ fn maybe_build_management_token_permission_denied_response(
) -> Option<Response<Body>> { ) -> Option<Response<Body>> {
let decision = request_context.control_decision.as_ref()?; let decision = request_context.control_decision.as_ref()?;
let admin_principal = decision.admin_principal.as_ref()?; let admin_principal = decision.admin_principal.as_ref()?;
let token_id = admin_principal.management_token_id.as_deref()?; let audit_admin_read_only_permissions;
let token_permissions = if crate::roles::can_write_admin_console(&admin_principal.user_role) {
admin_principal.management_token_permissions.as_deref()
} else {
audit_admin_read_only_permissions = audit_admin_read_only_management_token_permissions();
Some(audit_admin_read_only_permissions.as_slice())
};
let denied = validate_management_token_admin_route_permission( let denied = validate_management_token_admin_route_permission(
&request_context.request_method, &request_context.request_method,
decision, decision,
admin_principal.management_token_permissions.as_deref(), token_permissions,
) )
.err()?; .err()?;
let actor_id = admin_principal
.management_token_id
.as_deref()
.unwrap_or(admin_principal.user_id.as_str());
warn!( warn!(
trace_id = %request_context.trace_id, trace_id = %request_context.trace_id,
admin_management_token_id = %token_id, admin_actor_id = %actor_id,
admin_user_role = %admin_principal.user_role,
route_family = decision.route_family.as_deref().unwrap_or("unknown"), route_family = decision.route_family.as_deref().unwrap_or("unknown"),
route_kind = decision.route_kind.as_deref().unwrap_or("unknown"), route_kind = decision.route_kind.as_deref().unwrap_or("unknown"),
required_permission = %denied.required_permission, required_permission = %denied.required_permission,
"management token permission denied" "admin route permission denied"
); );
let mut response = ( let mut response = (
@@ -91,10 +103,10 @@ fn maybe_build_management_token_permission_denied_response(
.into_response(); .into_response();
attach_admin_audit_event( attach_admin_audit_event(
&mut response, &mut response,
"admin_management_token_permission_denied", "admin_route_permission_denied",
"permission_denied", "permission_denied",
"management_token_permission", "admin_route_permission",
token_id, actor_id,
); );
Some(response) Some(response)
} }

View File

@@ -228,7 +228,7 @@ async fn maybe_promote_management_token_admin_principal(
let Some(user) = state.find_user_auth_by_id(&token_with_user.user.id).await? else { let Some(user) = state.find_user_auth_by_id(&token_with_user.user.id).await? else {
return Ok(()); return Ok(());
}; };
if !user.is_active || user.is_deleted || !user.role.eq_ignore_ascii_case("admin") { if !user.is_active || user.is_deleted || !crate::roles::can_access_admin_console(&user.role) {
return Ok(()); return Ok(());
} }
let management_token_permissions = match management_token_permission_keys_from_value( let management_token_permissions = match management_token_permission_keys_from_value(

View File

@@ -56,6 +56,7 @@ mod provider_key_auth;
pub(crate) use aether_provider_transport as provider_transport; pub(crate) use aether_provider_transport as provider_transport;
mod rate_limit; mod rate_limit;
mod request_candidate_runtime; mod request_candidate_runtime;
mod roles;
mod router; mod router;
mod scheduler; mod scheduler;
mod state; mod state;

View File

@@ -0,0 +1,52 @@
pub(crate) const ROLE_USER: &str = "user";
pub(crate) const ROLE_ADMIN: &str = "admin";
pub(crate) const ROLE_AUDIT_ADMIN: &str = "audit_admin";
pub(crate) fn is_full_admin_role(role: &str) -> bool {
role.trim().eq_ignore_ascii_case(ROLE_ADMIN)
}
pub(crate) fn is_audit_admin_role(role: &str) -> bool {
role.trim().eq_ignore_ascii_case(ROLE_AUDIT_ADMIN)
}
pub(crate) fn can_access_admin_console(role: &str) -> bool {
is_full_admin_role(role) || is_audit_admin_role(role)
}
pub(crate) fn can_write_admin_console(role: &str) -> bool {
is_full_admin_role(role)
}
pub(crate) fn normalize_assignable_user_role(role: &str) -> Option<&'static str> {
match role.trim().to_ascii_lowercase().as_str() {
ROLE_USER => Some(ROLE_USER),
ROLE_ADMIN => Some(ROLE_ADMIN),
ROLE_AUDIT_ADMIN => Some(ROLE_AUDIT_ADMIN),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::{
can_access_admin_console, can_write_admin_console, normalize_assignable_user_role,
};
#[test]
fn audit_admin_can_access_but_not_write_admin_console() {
assert!(can_access_admin_console("audit_admin"));
assert!(!can_write_admin_console("audit_admin"));
}
#[test]
fn assignable_roles_include_audit_admin() {
assert_eq!(
normalize_assignable_user_role(" audit_admin "),
Some("audit_admin")
);
assert_eq!(normalize_assignable_user_role("admin"), Some("admin"));
assert_eq!(normalize_assignable_user_role("user"), Some("user"));
assert_eq!(normalize_assignable_user_role("owner"), None);
}
}

View File

@@ -66,7 +66,7 @@ impl AppState {
role: &str, role: &str,
) -> Result<Vec<String>, GatewayError> { ) -> Result<Vec<String>, GatewayError> {
let mut group_ids = normalized_user_group_ids(group_ids); let mut group_ids = normalized_user_group_ids(group_ids);
if role.trim().eq_ignore_ascii_case("admin") { if crate::roles::can_access_admin_console(role) {
if let Some(default_group_id) = self.configured_default_user_group_id().await? { if let Some(default_group_id) = self.configured_default_user_group_id().await? {
group_ids.remove(&default_group_id); group_ids.remove(&default_group_id);
} }

144
deploy.sh
View File

@@ -1,144 +0,0 @@
#!/bin/bash
# 智能部署脚本 - 自动检测代码变化并重建
#
# 用法:
# 部署/更新: ./deploy.sh
# 强制全部重建: ./deploy.sh --force
set -euo pipefail
cd "$(dirname "$0")"
# 兼容 docker-compose 和 docker compose
if command -v docker-compose &> /dev/null; then
DC="docker-compose -f docker-compose.build.yml"
USE_LEGACY_COMPOSE=true
else
DC="docker compose -f docker-compose.build.yml"
USE_LEGACY_COMPOSE=false
fi
compose_up() {
if [ "$USE_LEGACY_COMPOSE" = true ]; then
$DC up -d --no-build "$@"
else
$DC up -d --no-build --pull never "$@"
fi
}
# 缓存文件
CODE_HASH_FILE=".code-hash"
usage() {
cat <<'EOF'
Usage: ./deploy.sh [options]
Options:
--force, -f 强制重建并重启
-h, --help 显示帮助
EOF
}
FORCE_REBUILD_ALL=false
while [ $# -gt 0 ]; do
case "$1" in
--force|-f)
FORCE_REBUILD_ALL=true
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1"
usage
exit 1
;;
esac
done
# 计算代码文件的哈希值
calc_code_hash() {
{
cat Dockerfile.app.local 2>/dev/null
cat Cargo.toml Cargo.lock 2>/dev/null
find frontend/src -type f \( -name "*.vue" -o -name "*.ts" -o -name "*.tsx" -o -name "*.js" \) 2>/dev/null | sort | xargs cat 2>/dev/null
find apps -type f \( -name "*.rs" -o -name "Cargo.toml" \) 2>/dev/null | sort | xargs cat 2>/dev/null
find crates -type f \( -name "*.rs" -o -name "*.sql" -o -name "Cargo.toml" \) 2>/dev/null | sort | xargs cat 2>/dev/null
} | md5sum | cut -d' ' -f1
}
# 检查代码是否变化
check_code_changed() {
local current_hash=$(calc_code_hash)
if [ -f "$CODE_HASH_FILE" ]; then
local saved_hash=$(cat "$CODE_HASH_FILE")
if [ "$current_hash" = "$saved_hash" ]; then
return 1
fi
fi
return 0
}
save_code_hash() { calc_code_hash > "$CODE_HASH_FILE"; }
# 构建应用镜像
build_app() {
echo ">>> Building app image (rust gateway + frontend)..."
docker build --pull=false -f Dockerfile.app.local -t aether-app:latest .
save_code_hash
}
# 强制全部重建
if [ "$FORCE_REBUILD_ALL" = true ]; then
echo ">>> Force rebuilding everything..."
build_app
compose_up --force-recreate
docker image prune -f
echo ">>> Done!"
$DC ps
exit 0
fi
# 标记是否需要重启
NEED_RESTART=false
# 检查代码是否变化
if ! docker image inspect aether-app:latest >/dev/null 2>&1; then
echo ">>> App image not found, building..."
build_app
NEED_RESTART=true
elif check_code_changed; then
echo ">>> Code changed, rebuilding app image..."
build_app
NEED_RESTART=true
else
echo ">>> Code unchanged."
fi
# 检查容器是否在运行
CONTAINERS_RUNNING=true
if [ -z "$($DC ps -q 2>/dev/null)" ]; then
CONTAINERS_RUNNING=false
fi
# 有变化时重启,或容器未运行时启动
if [ "$NEED_RESTART" = true ]; then
echo ">>> Restarting services..."
compose_up
elif [ "$CONTAINERS_RUNNING" = false ]; then
echo ">>> Containers not running, starting services..."
compose_up
else
echo ">>> No changes detected, skipping restart."
fi
# 清理
docker image prune -f >/dev/null 2>&1 || true
echo ">>> Done!"
echo ">>> Note: empty databases auto-bootstrap on first start."
echo ">>> Note: docker compose now defaults to auto-running pending migrations/backfills on app startup."
echo ">>> Note: set AETHER_GATEWAY_AUTO_PREPARE_DATABASE=false if you want to keep manual rollout."
$DC ps

4
dev.sh
View File

@@ -95,13 +95,13 @@ dev_uses_redis_runtime() {
print_dev_infra_hint() { print_dev_infra_hint() {
echo "=> 本地开发依赖未就绪。" echo "=> 本地开发依赖未就绪。"
echo "=> 请先启动 Postgres / Redis:" echo "=> 请先启动 Postgres / Redis:"
echo "=> docker compose -f docker-compose.build.yml up -d postgres redis" echo "=> docker compose up -d postgres redis"
} }
print_postgres_hint() { print_postgres_hint() {
echo "=> PostgreSQL 未就绪。" echo "=> PostgreSQL 未就绪。"
echo "=> 请先启动 Postgres:" echo "=> 请先启动 Postgres:"
echo "=> docker compose -f docker-compose.build.yml up -d postgres" echo "=> docker compose up -d postgres"
} }
check_postgres_ready() { check_postgres_ready() {

View File

@@ -1,95 +0,0 @@
# Aether 部署配置 - 本地构建
# 使用方法:
# 启动服务: docker compose -f docker-compose.build.yml up -d --build
# 或使用: ./deploy.sh
services:
postgres:
image: postgres:15
container_name: aether-postgres
environment:
POSTGRES_DB: aether
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${DB_PASSWORD}
TZ: Asia/Shanghai
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "${DB_PORT:-5432}:5432"
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U postgres" ]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
redis:
image: redis:7-alpine
container_name: aether-redis
command: redis-server --appendonly yes --appendfsync everysec --save 60 1000 --requirepass ${REDIS_PASSWORD}
volumes:
- redis_data:/data
ports:
- "${REDIS_PORT:-6379}:6379"
healthcheck:
test: [ "CMD-SHELL", "redis-cli -a \"${REDIS_PASSWORD}\" ping | grep -q PONG" ]
interval: 5s
timeout: 3s
retries: 5
restart: unless-stopped
mysql:
image: mysql:8.0
container_name: aether-mysql
profiles:
- mysql
environment:
MYSQL_DATABASE: ${MYSQL_DATABASE:-aether}
MYSQL_USER: ${MYSQL_USER:-aether}
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-aether}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-aether_root}
TZ: Asia/Shanghai
volumes:
- mysql_data:/var/lib/mysql
ports:
- "${MYSQL_PORT:-3306}:3306"
healthcheck:
test:
[
"CMD-SHELL",
"mysqladmin ping -h 127.0.0.1 -u$${MYSQL_USER} -p$${MYSQL_PASSWORD} --silent"
]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
app:
build:
context: .
dockerfile: Dockerfile.app.local
image: aether-app:latest
container_name: aether-app
env_file:
- .env
environment:
DATABASE_URL: postgresql://postgres:${DB_PASSWORD}@postgres:5432/aether
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
TZ: Asia/Shanghai
APP_PORT: ${APP_PORT:-8084}
AETHER_GATEWAY_AUTO_PREPARE_DATABASE: ${AETHER_GATEWAY_AUTO_PREPARE_DATABASE:-true}
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
ports:
- "${APP_PORT:-8084}:${APP_PORT:-8084}"
volumes:
- ./logs:/app/logs
restart: unless-stopped
volumes:
postgres_data:
mysql_data:
redis_data:

View File

@@ -12,6 +12,8 @@ services:
TZ: Asia/Shanghai TZ: Asia/Shanghai
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
ports:
- "127.0.0.1:${DB_PORT:-5432}:5432"
command: postgres -c idle_in_transaction_session_timeout=30000 -c tcp_keepalives_idle=30 -c tcp_keepalives_interval=10 command: postgres -c idle_in_transaction_session_timeout=30000 -c tcp_keepalives_idle=30 -c tcp_keepalives_interval=10
healthcheck: healthcheck:
test: [ "CMD-SHELL", "pg_isready -U postgres" ] test: [ "CMD-SHELL", "pg_isready -U postgres" ]
@@ -26,6 +28,8 @@ services:
command: redis-server --appendonly yes --appendfsync everysec --save 60 1000 --requirepass ${REDIS_PASSWORD} command: redis-server --appendonly yes --appendfsync everysec --save 60 1000 --requirepass ${REDIS_PASSWORD}
volumes: volumes:
- redis_data:/data - redis_data:/data
ports:
- "127.0.0.1:${REDIS_PORT:-6379}:6379"
healthcheck: healthcheck:
test: [ "CMD-SHELL", "redis-cli -a \"${REDIS_PASSWORD}\" ping | grep -q PONG" ] test: [ "CMD-SHELL", "redis-cli -a \"${REDIS_PASSWORD}\" ping | grep -q PONG" ]
interval: 5s interval: 5s

View File

@@ -117,7 +117,7 @@ async function syncExternalAuthState(nextToken: string | null): Promise<void> {
return return
} }
if (router.currentRoute.value.path.startsWith('/admin') && user.role !== 'admin') { if (router.currentRoute.value.path.startsWith('/admin') && !authStore.canAccessAdmin) {
await router.replace('/dashboard') await router.replace('/dashboard')
} }
} }

View File

@@ -340,7 +340,7 @@ async function handleLogin() {
// 延迟一下让用户看到成功消息 // 延迟一下让用户看到成功消息
setTimeout(() => { setTimeout(() => {
// 根据用户角色跳转到不同的仪表盘 // 根据用户角色跳转到不同的仪表盘
const targetPath = authStore.user?.role === 'admin' ? '/admin/dashboard' : '/dashboard' const targetPath = authStore.canAccessAdmin ? '/admin/dashboard' : '/dashboard'
router.push(targetPath) router.push(targetPath)
}, 1000) }, 1000)
} else { } else {

View File

@@ -135,12 +135,15 @@
<SelectItem value="admin"> <SelectItem value="admin">
管理员 管理员
</SelectItem> </SelectItem>
<SelectItem value="audit_admin">
审计管理员
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div class="rounded-xl border border-amber-200/70 bg-amber-50/70 px-3 py-2.5 text-xs leading-relaxed text-amber-800 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-200"> <div class="rounded-xl border border-amber-200/70 bg-amber-50/70 px-3 py-2.5 text-xs leading-relaxed text-amber-800 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-200">
{{ targetRole === 'admin' ? '提示:设置为管理员会授予用户后台管理能力。' : '提示:设置为普通用户会移除目标用户的管理员权限。' }} {{ targetRole === 'admin' ? '提示:设置为管理员会授予用户完整后台管理能力。' : targetRole === 'audit_admin' ? '提示:设置为审计管理员会授予后台只读查看能力。' : '提示:设置为普通用户会移除目标用户的管理员权限。' }}
</div> </div>
</div> </div>

View File

@@ -80,6 +80,9 @@
<SelectItem value="admin"> <SelectItem value="admin">
管理员 管理员
</SelectItem> </SelectItem>
<SelectItem value="audit_admin">
审计管理员
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>

View File

@@ -66,7 +66,7 @@
</div> </div>
<div class="flex flex-col min-w-0"> <div class="flex flex-col min-w-0">
<span class="text-xs font-semibold leading-none truncate opacity-90 text-foreground">{{ authStore.user?.username }}</span> <span class="text-xs font-semibold leading-none truncate opacity-90 text-foreground">{{ authStore.user?.username }}</span>
<span class="text-[10px] opacity-50 leading-none mt-1.5 text-muted-foreground">{{ authStore.user?.role === 'admin' ? '管理员' : '用户' }}</span> <span class="text-[10px] opacity-50 leading-none mt-1.5 text-muted-foreground">{{ currentRoleLabel }}</span>
</div> </div>
</div> </div>
@@ -227,7 +227,7 @@
</div> </div>
<div class="flex flex-col min-w-0"> <div class="flex flex-col min-w-0">
<span class="text-sm font-semibold leading-none truncate text-[#191919] dark:text-white">{{ authStore.user?.username }}</span> <span class="text-sm font-semibold leading-none truncate text-[#191919] dark:text-white">{{ authStore.user?.username }}</span>
<span class="text-[10px] text-[#91918d] dark:text-muted-foreground leading-none mt-1">{{ authStore.user?.role === 'admin' ? '管理员' : '用户' }}</span> <span class="text-[10px] text-[#91918d] dark:text-muted-foreground leading-none mt-1">{{ currentRoleLabel }}</span>
</div> </div>
</div> </div>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
@@ -520,7 +520,7 @@ function showDebugVersionStatus(hasUpdate = true) {
// 检查更新 // 检查更新
async function checkForUpdate() { async function checkForUpdate() {
// 只有管理员才检查更新 // 只有管理员才检查更新
if (!isAdmin.value) return if (!authStore.canOperateAdmin) return
// 同一会话内只检查一次 // 同一会话内只检查一次
const sessionKey = 'aether_update_checked' const sessionKey = 'aether_update_checked'
@@ -567,7 +567,7 @@ onMounted(() => {
syncAuthNotice() syncAuthNotice()
// 管理员预加载模块状态(路由守卫会按需加载,这里提前加载以避免菜单闪烁) // 管理员预加载模块状态(路由守卫会按需加载,这里提前加载以避免菜单闪烁)
if (isAdmin.value && !moduleStore.loaded && !moduleStore.loading) { if (authStore.canAccessAdmin && !moduleStore.loaded && !moduleStore.loading) {
moduleStore.fetchModules() moduleStore.fetchModules()
} }
void loadVersionStatus() void loadVersionStatus()
@@ -707,7 +707,13 @@ const navigation = computed(() => {
} }
] ]
return authStore.user?.role === 'admin' ? adminNavigation : baseNavigation return authStore.canAccessAdmin ? adminNavigation : baseNavigation
})
const currentRoleLabel = computed(() => {
if (authStore.isAdmin) return '管理员'
if (authStore.isAuditAdmin) return '审计管理员'
return '用户'
}) })
// Breadcrumbs // Breadcrumbs

View File

@@ -12,8 +12,7 @@ export async function checkAdminAccess(
authStore: ReturnType<typeof useAuthStore>, authStore: ReturnType<typeof useAuthStore>,
moduleStore: ReturnType<typeof useModuleStore> moduleStore: ReturnType<typeof useModuleStore>
): Promise<string | null> { ): Promise<string | null> {
const isAdmin = authStore.user?.role === 'admin' if (!authStore.canAccessAdmin) {
if (!isAdmin) {
log.warn('Non-admin user attempted to access admin page, redirecting to user dashboard') log.warn('Non-admin user attempted to access admin page, redirecting to user dashboard')
return '/dashboard' return '/dashboard'
} }

View File

@@ -26,12 +26,11 @@ export function resolveHomeRedirect(
} }
// 已登录用户首次访问首页(非返回/刷新场景),根据角色跳转到对应仪表盘 // 已登录用户首次访问首页(非返回/刷新场景),根据角色跳转到对应仪表盘
const isAdmin = authStore.user?.role === 'admin'
const redirectPath = sessionStorage.getItem('redirectPath') const redirectPath = sessionStorage.getItem('redirectPath')
if (redirectPath && redirectPath !== '/') { if (redirectPath && redirectPath !== '/') {
sessionStorage.removeItem('redirectPath') sessionStorage.removeItem('redirectPath')
return redirectPath return redirectPath
} }
return isAdmin ? '/admin/dashboard' : '/dashboard' return authStore.canAccessAdmin ? '/admin/dashboard' : '/dashboard'
} }

View File

@@ -105,4 +105,21 @@ describe('auth store logout', () => {
expect(store.user).toBeNull() expect(store.user).toBeNull()
expect(store.token).toBeNull() expect(store.token).toBeNull()
}) })
it('separates admin access from admin operations for audit administrators', () => {
const store = useAuthStore()
store.user = {
id: 'audit-1',
username: 'auditor',
role: 'audit_admin',
is_active: true,
created_at: '2026-03-16T00:00:00Z',
}
expect(store.isAdmin).toBe(false)
expect(store.isAuditAdmin).toBe(true)
expect(store.canAccessAdmin).toBe(true)
expect(store.canOperateAdmin).toBe(false)
})
}) })

View File

@@ -32,6 +32,9 @@ export const useAuthStore = defineStore('auth', () => {
} }
} }
const isAdmin = computed(() => user.value?.role === 'admin') const isAdmin = computed(() => user.value?.role === 'admin')
const isAuditAdmin = computed(() => user.value?.role === 'audit_admin')
const canAccessAdmin = computed(() => isAdmin.value || isAuditAdmin.value)
const canOperateAdmin = computed(() => isAdmin.value)
async function login(email: string, password: string, authType: 'local' | 'ldap' = 'local') { async function login(email: string, password: string, authType: 'local' | 'ldap' = 'local') {
loading.value = true loading.value = true
@@ -114,6 +117,9 @@ export const useAuthStore = defineStore('auth', () => {
error, error,
isAuthenticated, isAuthenticated,
isAdmin, isAdmin,
isAuditAdmin,
canAccessAdmin,
canOperateAdmin,
login, login,
logout, logout,
applyExternalLogout, applyExternalLogout,

View File

@@ -414,7 +414,7 @@
使用记录 使用记录
</Button> </Button>
<Button <Button
v-if="canCancel(task.status)" v-if="authStore.canOperateAdmin && canCancel(task.status)"
variant="outline" variant="outline"
size="sm" size="sm"
class="h-7 text-xs text-red-500 border-red-200 hover:bg-red-50" class="h-7 text-xs text-red-500 border-red-200 hover:bg-red-50"
@@ -830,7 +830,7 @@
<!-- 操作按钮 --> <!-- 操作按钮 -->
<div <div
v-if="canCancel(selectedTask.status)" v-if="authStore.canOperateAdmin && canCancel(selectedTask.status)"
class="pt-4 border-t border-border/60" class="pt-4 border-t border-border/60"
> >
<Button <Button
@@ -902,7 +902,7 @@ import { useAuthStore } from '@/stores/auth'
import { log } from '@/utils/logger' import { log } from '@/utils/logger'
const authStore = useAuthStore() const authStore = useAuthStore()
const isAdmin = computed(() => authStore.user?.role === 'admin') const isAdmin = computed(() => authStore.canAccessAdmin)
const { toast } = useToast() const { toast } = useToast()
const { copyToClipboard } = useClipboard() const { copyToClipboard } = useClipboard()

View File

@@ -65,6 +65,9 @@
<SelectItem value="admin"> <SelectItem value="admin">
管理员 管理员
</SelectItem> </SelectItem>
<SelectItem value="audit_admin">
审计管理员
</SelectItem>
<SelectItem value="user"> <SelectItem value="user">
用户 用户
</SelectItem> </SelectItem>
@@ -148,6 +151,9 @@
<SelectItem value="admin"> <SelectItem value="admin">
管理员 管理员
</SelectItem> </SelectItem>
<SelectItem value="audit_admin">
审计管理员
</SelectItem>
<SelectItem value="user"> <SelectItem value="user">
普通用户 普通用户
</SelectItem> </SelectItem>
@@ -200,6 +206,7 @@
<!-- 新增用户按钮 --> <!-- 新增用户按钮 -->
<Button <Button
v-if="authStore.canOperateAdmin"
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-8 w-8" class="h-8 w-8"
@@ -211,6 +218,7 @@
<!-- 新增用户按钮 --> <!-- 新增用户按钮 -->
<Button <Button
v-if="authStore.canOperateAdmin"
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-8 w-8" class="h-8 w-8"
@@ -262,6 +270,7 @@
清空选择 清空选择
</Button> </Button>
<Button <Button
v-if="authStore.canOperateAdmin"
size="sm" size="sm"
class="h-7 px-3 text-[11px]" class="h-7 px-3 text-[11px]"
:disabled="(selectedCount === 0 && userGroups.length === 0) || usersStore.loading" :disabled="(selectedCount === 0 && userGroups.length === 0) || usersStore.loading"
@@ -362,10 +371,10 @@
{{ user.username }} {{ user.username }}
</div> </div>
<Badge <Badge
:variant="user.role === 'admin' ? 'default' : 'secondary'" :variant="userRoleBadgeVariant(user.role)"
class="h-5 px-1.5 py-0 text-[10px] font-medium flex-shrink-0" class="h-5 px-1.5 py-0 text-[10px] font-medium flex-shrink-0"
> >
{{ user.role === 'admin' ? '管理员' : '普通用户' }} {{ formatUserRole(user.role) }}
</Badge> </Badge>
</div> </div>
<div <div
@@ -468,6 +477,7 @@
<TableCell class="py-4"> <TableCell class="py-4">
<div class="flex justify-center gap-1"> <div class="flex justify-center gap-1">
<Button <Button
v-if="authStore.canOperateAdmin"
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-8 w-8" class="h-8 w-8"
@@ -477,6 +487,7 @@
<SquarePen class="h-4 w-4" /> <SquarePen class="h-4 w-4" />
</Button> </Button>
<Button <Button
v-if="authStore.canOperateAdmin"
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-8 w-8" class="h-8 w-8"
@@ -486,6 +497,7 @@
<DollarSign class="h-4 w-4" /> <DollarSign class="h-4 w-4" />
</Button> </Button>
<Button <Button
v-if="authStore.canOperateAdmin"
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-8 w-8" class="h-8 w-8"
@@ -495,6 +507,7 @@
<Key class="h-4 w-4" /> <Key class="h-4 w-4" />
</Button> </Button>
<Button <Button
v-if="authStore.canOperateAdmin"
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-8 w-8" class="h-8 w-8"
@@ -588,10 +601,10 @@
{{ user.username }} {{ user.username }}
</div> </div>
<Badge <Badge
:variant="user.role === 'admin' ? 'default' : 'secondary'" :variant="userRoleBadgeVariant(user.role)"
class="h-5 px-1.5 py-0 text-[10px] font-medium flex-shrink-0" class="h-5 px-1.5 py-0 text-[10px] font-medium flex-shrink-0"
> >
{{ user.role === 'admin' ? '管理员' : '普通用户' }} {{ formatUserRole(user.role) }}
</Badge> </Badge>
</div> </div>
<div <div
@@ -694,6 +707,7 @@
<div class="grid grid-cols-2 gap-2 pt-0.5"> <div class="grid grid-cols-2 gap-2 pt-0.5">
<Button <Button
v-if="authStore.canOperateAdmin"
variant="outline" variant="outline"
size="sm" size="sm"
class="h-8 text-xs" class="h-8 text-xs"
@@ -703,6 +717,7 @@
编辑 编辑
</Button> </Button>
<Button <Button
v-if="authStore.canOperateAdmin"
variant="outline" variant="outline"
size="sm" size="sm"
class="h-8 text-xs" class="h-8 text-xs"
@@ -712,6 +727,7 @@
资金 资金
</Button> </Button>
<Button <Button
v-if="authStore.canOperateAdmin"
variant="outline" variant="outline"
size="sm" size="sm"
class="h-8 text-xs" class="h-8 text-xs"
@@ -721,6 +737,7 @@
API Keys API Keys
</Button> </Button>
<Button <Button
v-if="authStore.canOperateAdmin"
variant="outline" variant="outline"
size="sm" size="sm"
class="h-8 text-xs" class="h-8 text-xs"
@@ -1236,6 +1253,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import { useUsersStore } from '@/stores/users' import { useUsersStore } from '@/stores/users'
import { useAuthStore } from '@/stores/auth'
import type { User, ApiKey, UserSession, UserBatchActionResponse, UserBatchSelectionFilters, UserGroup } from '@/api/users' import type { User, ApiKey, UserSession, UserBatchActionResponse, UserBatchSelectionFilters, UserGroup } from '@/api/users'
import { formatSessionMeta } from '@/types/session' import { formatSessionMeta } from '@/types/session'
import { adminWalletApi, type AdminWallet } from '@/api/admin-wallets' import { adminWalletApi, type AdminWallet } from '@/api/admin-wallets'
@@ -1310,6 +1328,7 @@ const { success, error } = useToast()
const { confirmDanger } = useConfirm() const { confirmDanger } = useConfirm()
const { copyToClipboard } = useClipboard() const { copyToClipboard } = useClipboard()
const usersStore = useUsersStore() const usersStore = useUsersStore()
const authStore = useAuthStore()
// 用户表单对话框状态 // 用户表单对话框状态
const showUserFormDialog = ref(false) const showUserFormDialog = ref(false)
@@ -1354,6 +1373,7 @@ const userGroups = ref<UserGroup[]>([])
const userRoleFilterOptions = [ const userRoleFilterOptions = [
{ value: 'all', label: '全部角色' }, { value: 'all', label: '全部角色' },
{ value: 'admin', label: '管理员' }, { value: 'admin', label: '管理员' },
{ value: 'audit_admin', label: '审计管理员' },
{ value: 'user', label: '普通用户' }, { value: 'user', label: '普通用户' },
] ]
const userStatusFilterOptions = [ const userStatusFilterOptions = [
@@ -1373,9 +1393,9 @@ const filteredUsers = computed(() => {
// 先排序:管理员优先,然后按创建时间倒序 // 先排序:管理员优先,然后按创建时间倒序
filtered.sort((a, b) => { filtered.sort((a, b) => {
// 管理员优先 const roleRank = (role: string) => role === 'admin' ? 0 : role === 'audit_admin' ? 1 : 2
if (a.role === 'admin' && b.role !== 'admin') return -1 const roleDiff = roleRank(a.role) - roleRank(b.role)
if (a.role !== 'admin' && b.role === 'admin') return 1 if (roleDiff !== 0) return roleDiff
// 同角色按创建时间倒序(新用户在前) // 同角色按创建时间倒序(新用户在前)
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime() return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
}) })
@@ -1437,7 +1457,7 @@ const batchSelectionFilters = computed<UserBatchSelectionFilters>(() => {
const filters: UserBatchSelectionFilters = {} const filters: UserBatchSelectionFilters = {}
const search = searchQuery.value.trim() const search = searchQuery.value.trim()
if (search) filters.search = search if (search) filters.search = search
if (filterRole.value === 'admin' || filterRole.value === 'user') filters.role = filterRole.value if (filterRole.value === 'admin' || filterRole.value === 'audit_admin' || filterRole.value === 'user') filters.role = filterRole.value
if (filterStatus.value === 'active') filters.is_active = true if (filterStatus.value === 'active') filters.is_active = true
if (filterStatus.value === 'inactive') filters.is_active = false if (filterStatus.value === 'inactive') filters.is_active = false
if (filterGroup.value !== 'all') filters.group_id = filterGroup.value if (filterGroup.value !== 'all') filters.group_id = filterGroup.value
@@ -1452,6 +1472,16 @@ watch([searchQuery, filterRole, filterStatus, filterGroup], () => {
watch(paginatedUsers, (users) => rememberBatchPageUsers(users), { immediate: true }) watch(paginatedUsers, (users) => rememberBatchPageUsers(users), { immediate: true })
function formatUserRole(role: string) {
if (role === 'admin') return '管理员'
if (role === 'audit_admin') return '审计管理员'
return '普通用户'
}
function userRoleBadgeVariant(role: string) {
return role === 'admin' ? 'default' : 'secondary'
}
onMounted(() => { onMounted(() => {
void refreshUsers({ preferCache: true }) void refreshUsers({ preferCache: true })
}) })

View File

@@ -109,7 +109,7 @@ onMounted(async () => {
success('登录成功') success('登录成功')
const redirectPath = consumeRedirectPath() const redirectPath = consumeRedirectPath()
const target = redirectPath || (authStore.user?.role === 'admin' ? '/admin/dashboard' : '/dashboard') const target = redirectPath || (authStore.canAccessAdmin ? '/admin/dashboard' : '/dashboard')
await router.replace(target) await router.replace(target)
}) })
</script> </script>

View File

@@ -498,7 +498,7 @@ const { copyToClipboard } = useClipboard()
const { siteName, siteSubtitle } = useSiteInfo() const { siteName, siteSubtitle } = useSiteInfo()
const dashboardPath = computed(() => const dashboardPath = computed(() =>
authStore.user?.role === 'admin' ? '/admin/dashboard' : '/dashboard' authStore.canAccessAdmin ? '/admin/dashboard' : '/dashboard'
) )
const baseUrl = computed(() => window.location.origin) const baseUrl = computed(() => window.location.origin)

View File

@@ -4,7 +4,6 @@ import {
Server, Server,
Key, Key,
Container, Container,
Code,
Shield, Shield,
Monitor, Monitor,
Check, Check,
@@ -44,38 +43,11 @@ const productionSteps = [
} }
] ]
const localBuildSteps = [
{
title: '克隆代码',
code: 'git clone https://github.com/fawney19/Aether.git\ncd Aether',
icon: Code
},
{
title: '配置环境变量',
note: '生成密钥并填入 .env',
code: 'cp .env.example .env\npython generate_keys.py',
icon: Key
},
{
title: '构建',
note: '自动构建、启动、迁移',
code: './deploy.sh',
icon: Container
},
{
title: '更新',
note: '需要拉取最新代码',
code: 'git pull origin main',
icon: Code,
optional: true
}
]
const developmentSteps = [ const developmentSteps = [
{ {
title: '启动依赖', title: '启动依赖',
note: 'PostgreSQL + Redis', note: 'PostgreSQL + Redis',
code: 'docker compose -f docker-compose.build.yml up -d postgres redis', code: 'docker compose up -d postgres redis',
icon: Container icon: Container
}, },
{ {
@@ -133,7 +105,6 @@ function copyStep(stepId: string, code: string) {
<button <button
v-for="(tab, idx) in [ v-for="(tab, idx) in [
{ icon: Container, label: 'Docker 预构建镜像' }, { icon: Container, label: 'Docker 预构建镜像' },
{ icon: Code, label: '本地代码构建' },
{ icon: Monitor, label: '本地开发' } { icon: Monitor, label: '本地开发' }
]" ]"
:key="idx" :key="idx"
@@ -200,58 +171,9 @@ function copyStep(stepId: string, code: string) {
</div> </div>
</div> </div>
<!-- 本地构建步骤 -->
<div
v-show="activeDeployTab === 1"
class="p-5 space-y-3"
>
<div
v-for="(step, idx) in localBuildSteps"
:key="idx"
class="group rounded-xl border border-[#e5e4df] dark:border-[rgba(227,224,211,0.12)] overflow-hidden transition-colors"
:class="step.optional ? 'border-dashed opacity-80' : ''"
>
<div class="flex items-center gap-3 px-4 py-3">
<span class="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 bg-[#cc785c] text-white">
{{ idx + 1 }}
</span>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-[#262624] dark:text-[#f1ead8]">{{ step.title }}</span>
<span
v-if="step.optional"
class="text-[10px] px-1.5 py-0.5 rounded-full bg-[#e5e4df] dark:bg-[rgba(227,224,211,0.12)] text-[#666663] dark:text-[#a3a094]"
>
更新时
</span>
</div>
<span
v-if="step.note"
class="text-xs text-[#91918d] dark:text-[#a3a094]/80"
>{{ step.note }}</span>
</div>
<button
class="flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs text-[#666663] dark:text-[#a3a094] hover:bg-[#f0f0eb] dark:hover:bg-[#3a3731] transition-colors shrink-0"
@click="copyStep(`build-${idx}`, step.code)"
>
<Check
v-if="copiedStep === `build-${idx}`"
class="h-3.5 w-3.5 text-green-500"
/>
<Copy
v-else
class="h-3.5 w-3.5"
/>
{{ copiedStep === `build-${idx}` ? '已复制' : '复制' }}
</button>
</div>
<pre class="px-4 pb-3 text-[13px] font-mono text-[#262624] dark:text-[#f1ead8] overflow-x-auto leading-relaxed border-t border-[#e5e4df]/50 dark:border-[rgba(227,224,211,0.06)] pt-3 mx-4 mb-1"><code>{{ step.code }}</code></pre>
</div>
</div>
<!-- 开发环境步骤 --> <!-- 开发环境步骤 -->
<div <div
v-show="activeDeployTab === 2" v-show="activeDeployTab === 1"
class="p-5 space-y-3" class="p-5 space-y-3"
> >
<div <div

View File

@@ -20,28 +20,11 @@ docker compose pull && docker compose up -d
docker compose exec postgres pg_dump -U postgres aether | gzip > backup_$(date +%Y%m%d_%H%M%S).sql.gz docker compose exec postgres pg_dump -U postgres aether | gzip > backup_$(date +%Y%m%d_%H%M%S).sql.gz
``` ```
### 2. 本地代码构建镜像 (Docker Compose) ### 2. 本地开发
```markdown
# 1. 克隆代码
git clone https://github.com/fawney19/Aether.git
cd Aether
# 2. 配置环境变量
cp .env.example .env
./generate_keys.sh # 生成密钥, 并将生成的密钥填入 .env
# 3. 构建(自动构建、启动、迁移)
./deploy.sh
# 4. 更新需要拉取最新代码
git pull origin main
```
### 3. 本地开发
依赖 Docker、uv、nodejs 依赖 Docker、uv、nodejs
```markdown ```markdown
# 启动数据库 # 启动数据库
docker compose -f docker-compose.build.yml up -d postgres redis docker compose up -d postgres redis
# 后端 # 后端
uv sync uv sync

View File

@@ -8,10 +8,10 @@
class="flex-1 min-w-0 flex flex-col" class="flex-1 min-w-0 flex flex-col"
> >
<Badge <Badge
:variant="authStore.user?.role === 'admin' ? 'default' : 'secondary'" :variant="authStore.isAdmin ? 'default' : 'secondary'"
class="uppercase tracking-[0.45em] mb-4 self-start" class="uppercase tracking-[0.45em] mb-4 self-start"
> >
{{ authStore.user?.role === 'admin' ? 'ADMIN MODE' : 'PERSONAL MODE' }} {{ dashboardModeLabel }}
</Badge> </Badge>
<!-- 主要统计卡片 --> <!-- 主要统计卡片 -->
@@ -915,7 +915,12 @@ function setupTimelineResizeObserver() {
announcementsTimelineObserver.observe(container) announcementsTimelineObserver.observe(container)
} }
const isAdmin = computed(() => authStore.user?.role === 'admin') const isAdmin = computed(() => authStore.canAccessAdmin)
const dashboardModeLabel = computed(() => {
if (authStore.isAdmin) return 'ADMIN MODE'
if (authStore.isAuditAdmin) return 'AUDIT MODE'
return 'PERSONAL MODE'
})
const statCardBorders = [ const statCardBorders = [
'border-book-cloth/30 dark:border-book-cloth/25', 'border-book-cloth/30 dark:border-book-cloth/25',

View File

@@ -50,15 +50,15 @@
> >
<UsageModelTable <UsageModelTable
:data="enhancedModelStats" :data="enhancedModelStats"
:is-admin="authStore.isAdmin" :is-admin="authStore.canAccessAdmin"
/> />
<UsageProviderTable <UsageProviderTable
:data="providerStats" :data="providerStats"
:is-admin="authStore.isAdmin" :is-admin="authStore.canAccessAdmin"
/> />
<UsageApiFormatTable <UsageApiFormatTable
:data="apiFormatStats" :data="apiFormatStats"
:is-admin="authStore.isAdmin" :is-admin="authStore.canAccessAdmin"
/> />
</div> </div>
<!-- 用户模型 + API格式2 --> <!-- 用户模型 + API格式2 -->
@@ -68,7 +68,7 @@
> >
<UsageModelTable <UsageModelTable
:data="enhancedModelStats" :data="enhancedModelStats"
:is-admin="authStore.isAdmin" :is-admin="authStore.canAccessAdmin"
/> />
<UsageApiFormatTable <UsageApiFormatTable
:data="apiFormatStats" :data="apiFormatStats"
@@ -81,7 +81,7 @@
<UsageRecordsTable <UsageRecordsTable
:records="displayRecords" :records="displayRecords"
:is-admin="isAdminPage" :is-admin="isAdminPage"
:show-actual-cost="authStore.isAdmin" :show-actual-cost="authStore.canAccessAdmin"
:loading="isLoadingRecords" :loading="isLoadingRecords"
:time-range="timeRange" :time-range="timeRange"
:filter-search="filterSearch" :filter-search="filterSearch"

View File

@@ -623,7 +623,7 @@ async function loadAnnouncements(page = 1) {
currentPage.value = page currentPage.value = page
try { try {
const response = await announcementApi.getAnnouncements({ const response = await announcementApi.getAnnouncements({
active_only: !isAdmin.value, // 管理员可以看到所有公告 active_only: !authStore.canAccessAdmin, // 管理员和审计管理员可以看到所有公告
limit: pageSize.value, limit: pageSize.value,
offset: (page - 1) * pageSize.value offset: (page - 1) * pageSize.value
}) })

View File

@@ -561,7 +561,7 @@
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-muted-foreground">角色</span> <span class="text-muted-foreground">角色</span>
<Badge :variant="profile?.role === 'admin' ? 'default' : 'secondary'"> <Badge :variant="profile?.role === 'admin' ? 'default' : 'secondary'">
{{ profile?.role === 'admin' ? '管理员' : '普通用户' }} {{ profileRoleLabel }}
</Badge> </Badge>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
@@ -682,6 +682,11 @@ const { setThemeMode } = useDarkMode()
const profile = ref<Profile | null>(null) const profile = ref<Profile | null>(null)
const userSessions = ref<UserSession[]>([]) const userSessions = ref<UserSession[]>([])
const profileRoleLabel = computed(() => {
if (profile.value?.role === 'admin') return '管理员'
if (profile.value?.role === 'audit_admin') return '审计管理员'
return '普通用户'
})
const profileForm = ref({ const profileForm = ref({
email: '', email: '',

View File

@@ -49,11 +49,10 @@ Usage: install.sh [options]
Install Aether Gateway. Install Aether Gateway.
Options: Options:
--mode MODE Deployment mode: compose, compose-solo, single, or cluster --mode MODE Deployment mode: compose, compose-sqlite, or single
compose: Docker Compose app + Postgres + Redis compose: Docker Compose app + Postgres + Redis
compose-solo: Docker Compose app + SQLite compose-sqlite: Docker Compose app + SQLite
single: system service with SQLite + in-process runtime single: system service with SQLite
cluster: system service connected to shared database + Redis
Linux services use systemd; macOS services use launchd Linux services use systemd; macOS services use launchd
--channel CHANNEL Release channel to resolve when --version is omitted: stable, latest, rc, or beta --channel CHANNEL Release channel to resolve when --version is omitted: stable, latest, rc, or beta
stable/latest resolves the latest stable tag (default) stable/latest resolves the latest stable tag (default)
@@ -81,7 +80,6 @@ Environment overrides:
AETHER_IMAGE_REPO, AETHER_APP_IMAGE AETHER_IMAGE_REPO, AETHER_APP_IMAGE
INSTALL_ROOT, AETHER_COMPOSE_DIR, CONFIG_DIR, SERVICE_USER, SERVICE_GROUP INSTALL_ROOT, AETHER_COMPOSE_DIR, CONFIG_DIR, SERVICE_USER, SERVICE_GROUP
ADMIN_PASSWORD (required for non-interactive first install when generating a new env) ADMIN_PASSWORD (required for non-interactive first install when generating a new env)
DATABASE_URL, REDIS_URL (required when generating a cluster env)
EOF EOF
} }
@@ -436,8 +434,8 @@ select_mode() {
MODE="compose" MODE="compose"
return return
;; ;;
compose-solo|solo|docker-solo|docker-solo-compose) compose-sqlite|sqlite-compose|compose-solo|solo|docker-solo|docker-solo-compose)
MODE="compose-solo" MODE="compose-sqlite"
return return
;; ;;
single|service|systemd|launchd|sqlite) single|service|systemd|launchd|sqlite)
@@ -445,56 +443,52 @@ select_mode() {
return return
;; ;;
cluster|multi|multi-node) cluster|multi|multi-node)
MODE="cluster" if ui_is_zh; then
return die "集群部署模式暂未开放;请先选择 compose、compose-sqlite 或 single"
else
die "cluster deployment mode is temporarily disabled; choose compose, compose-sqlite, or single"
fi
;; ;;
auto|"") auto|"")
;; ;;
*) *)
die "unsupported install mode: ${MODE}; expected compose, compose-solo, single, or cluster" die "unsupported install mode: ${MODE}; expected compose, compose-sqlite, or single"
;; ;;
esac esac
if interactive_tty_available; then if interactive_tty_available; then
local service_manager
service_manager="$(service_manager_name)"
if ui_is_zh; then if ui_is_zh; then
cat >/dev/tty <<EOF cat >/dev/tty <<EOF
请选择 Aether 部署模式: 请选择 Aether 部署模式:
1) Docker Compose: 应用 + Postgres + Redis 1) Docker Compose 应用: Postgres + Redis
2) 单机服务: ${service_manager} + SQLite + 进程内运行时 3) Docker Compose 应用: 仅SQLite
3) 集群节点服务: ${service_manager} + 共享数据库 + Redis 4) 系统服务: 仅SQLite
4) Docker Compose: 应用 + SQLite
请输入选项 [2]: 请输入选项 [4]:
EOF EOF
else else
cat >/dev/tty <<EOF cat >/dev/tty <<EOF
Choose Aether deployment mode: Choose Aether deployment mode:
1) Docker Compose: app + Postgres + Redis 1) Docker Compose app: Postgres + Redis
2) Single-node service: ${service_manager} + SQLite + in-process runtime 3) Docker Compose app: SQLite only
3) Cluster node service: ${service_manager} + shared database + Redis 4) System service: SQLite only
4) Docker Compose: app + SQLite
Enter choice [2]: Enter choice [4]:
EOF EOF
fi fi
local choice local choice
IFS= read -r choice </dev/tty || choice="" IFS= read -r choice </dev/tty || choice=""
case "${choice:-2}" in case "${choice:-4}" in
1) 1)
MODE="compose" MODE="compose"
;; ;;
2)
MODE="single"
;;
3) 3)
MODE="cluster" MODE="compose-sqlite"
;; ;;
4) 4)
MODE="compose-solo" MODE="single"
;; ;;
*) *)
if ui_is_zh; then if ui_is_zh; then
@@ -945,16 +939,13 @@ AETHER_LOG_RETENTION_DAYS=7
AETHER_LOG_MAX_FILES=30 AETHER_LOG_MAX_FILES=30
APP_PORT=${APP_PORT:-8084} APP_PORT=${APP_PORT:-8084}
AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY=single-node
AETHER_GATEWAY_NODE_ROLE=all
AETHER_GATEWAY_STATIC_DIR=${INSTALL_ROOT}/current/frontend AETHER_GATEWAY_STATIC_DIR=${INSTALL_ROOT}/current/frontend
AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE=rust-authoritative AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE=rust-authoritative
AETHER_GATEWAY_AUTO_PREPARE_DATABASE=true AETHER_GATEWAY_AUTO_PREPARE_DATABASE=true
AETHER_RUNTIME_BACKEND=memory AETHER_RUNTIME_BACKEND=memory
API_KEY_PREFIX=sk API_KEY_PREFIX=sk
AETHER_DATABASE_DRIVER=sqlite DATABASE_URL=sqlite://${INSTALL_ROOT}/data/aether.db
AETHER_DATABASE_URL=sqlite://${INSTALL_ROOT}/data/aether.db
JWT_SECRET_KEY=${jwt_key} JWT_SECRET_KEY=${jwt_key}
ENCRYPTION_KEY=${encryption_key} ENCRYPTION_KEY=${encryption_key}
@@ -1054,7 +1045,7 @@ generate_compose_env() {
replace_or_append_env "${output}" "AETHER_GATEWAY_AUTO_PREPARE_DATABASE" "true" replace_or_append_env "${output}" "AETHER_GATEWAY_AUTO_PREPARE_DATABASE" "true"
} }
generate_compose_solo_env() { generate_compose_sqlite_env() {
local output="$1" local output="$1"
local jwt_key encryption_key local jwt_key encryption_key
prompt_admin_password prompt_admin_password
@@ -1074,16 +1065,13 @@ AETHER_LOG_MAX_FILES=30
APP_IMAGE=$(compose_image) APP_IMAGE=$(compose_image)
APP_PORT=${APP_PORT:-8084} APP_PORT=${APP_PORT:-8084}
AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY=single-node
AETHER_GATEWAY_NODE_ROLE=all
AETHER_GATEWAY_STATIC_DIR=/srv/frontend AETHER_GATEWAY_STATIC_DIR=/srv/frontend
AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE=rust-authoritative AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE=rust-authoritative
AETHER_GATEWAY_AUTO_PREPARE_DATABASE=true AETHER_GATEWAY_AUTO_PREPARE_DATABASE=true
AETHER_RUNTIME_BACKEND=memory AETHER_RUNTIME_BACKEND=memory
API_KEY_PREFIX=sk API_KEY_PREFIX=sk
AETHER_DATABASE_DRIVER=sqlite DATABASE_URL=sqlite:///app/data/aether.db
AETHER_DATABASE_URL=sqlite:///app/data/aether.db
JWT_SECRET_KEY=${JWT_SECRET_KEY:-${jwt_key}} JWT_SECRET_KEY=${JWT_SECRET_KEY:-${jwt_key}}
ENCRYPTION_KEY=${ENCRYPTION_KEY:-${encryption_key}} ENCRYPTION_KEY=${ENCRYPTION_KEY:-${encryption_key}}
@@ -1242,7 +1230,7 @@ ensure_env_matches_requested_mode() {
[[ "${topology}" == "multi-node" ]] || die "existing env ${file} is ${topology}; set AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY=multi-node or use --mode single" [[ "${topology}" == "multi-node" ]] || die "existing env ${file} is ${topology}; set AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY=multi-node or use --mode single"
cluster_env_has_required_backends "${file}" || die "existing multi-node env ${file} must define DATABASE_URL and REDIS_URL" cluster_env_has_required_backends "${file}" || die "existing multi-node env ${file} must define DATABASE_URL and REDIS_URL"
elif [[ "${mode}" == "single" && "${topology}" == "multi-node" ]]; then elif [[ "${mode}" == "single" && "${topology}" == "multi-node" ]]; then
die "existing env ${file} is multi-node; use --mode cluster or edit the env file" die "existing env ${file} is multi-node; cluster mode is temporarily disabled, edit the env file"
fi fi
} }
@@ -1381,7 +1369,7 @@ validate_env_file() {
[[ -z "${video_task_store_path}" ]] || die "multi-node deployment must not set AETHER_GATEWAY_VIDEO_TASK_STORE_PATH" [[ -z "${video_task_store_path}" ]] || die "multi-node deployment must not set AETHER_GATEWAY_VIDEO_TASK_STORE_PATH"
else else
if [[ "${node_role}" != "all" ]]; then if [[ "${node_role}" != "all" ]]; then
warn "single-node deployment usually uses AETHER_GATEWAY_NODE_ROLE=all; split roles are only useful for cluster drills" warn "single-node deployment usually uses AETHER_GATEWAY_NODE_ROLE=all; split roles are not enabled by this installer"
fi fi
if [[ "${runtime_backend}" == "redis" && -z "${redis_url}" ]]; then if [[ "${runtime_backend}" == "redis" && -z "${redis_url}" ]]; then
die "AETHER_RUNTIME_BACKEND=redis requires REDIS_URL or AETHER_GATEWAY_DATA_REDIS_URL" die "AETHER_RUNTIME_BACKEND=redis requires REDIS_URL or AETHER_GATEWAY_DATA_REDIS_URL"
@@ -1488,11 +1476,11 @@ Generate a fresh key set any time:
EOF EOF
} }
install_compose_solo_mode() { install_compose_sqlite_mode() {
info "preparing Docker Compose solo deployment in ${COMPOSE_DIR}" info "preparing Docker Compose SQLite deployment in ${COMPOSE_DIR}"
install -d -m 0755 "${COMPOSE_DIR}" "${COMPOSE_DIR}/logs" "${COMPOSE_DIR}/data" install -d -m 0755 "${COMPOSE_DIR}" "${COMPOSE_DIR}/logs" "${COMPOSE_DIR}/data"
install_project_file "docker-compose.solo.yml" "${COMPOSE_DIR}/docker-compose.yml" "0644" install_project_file "docker-compose.sqlite.yml" "${COMPOSE_DIR}/docker-compose.yml" "0644"
install_project_file ".env.example" "${COMPOSE_DIR}/.env.example" "0644" install_project_file ".env.example" "${COMPOSE_DIR}/.env.example" "0644"
write_generate_keys_script "${COMPOSE_DIR}/generate_keys.sh" write_generate_keys_script "${COMPOSE_DIR}/generate_keys.sh"
@@ -1500,13 +1488,13 @@ install_compose_solo_mode() {
warn "keeping existing ${COMPOSE_DIR}/.env" warn "keeping existing ${COMPOSE_DIR}/.env"
else else
info "generating ${COMPOSE_DIR}/.env" info "generating ${COMPOSE_DIR}/.env"
generate_compose_solo_env "${COMPOSE_DIR}/.env" generate_compose_sqlite_env "${COMPOSE_DIR}/.env"
chmod 0600 "${COMPOSE_DIR}/.env" chmod 0600 "${COMPOSE_DIR}/.env"
fi fi
cat <<EOF cat <<EOF
Docker Compose solo files are ready: Docker Compose SQLite files are ready:
${COMPOSE_DIR}/docker-compose.yml ${COMPOSE_DIR}/docker-compose.yml
${COMPOSE_DIR}/.env ${COMPOSE_DIR}/.env
${COMPOSE_DIR}/.env.example ${COMPOSE_DIR}/.env.example
@@ -1516,6 +1504,7 @@ Docker Compose solo files are ready:
Next steps: Next steps:
cd ${COMPOSE_DIR} cd ${COMPOSE_DIR}
docker compose pull
docker compose up -d docker compose up -d
docker compose logs -f app docker compose logs -f app
@@ -1941,8 +1930,8 @@ main() {
if [[ "${MODE}" == "compose" ]]; then if [[ "${MODE}" == "compose" ]]; then
install_compose_mode install_compose_mode
elif [[ "${MODE}" == "compose-solo" ]]; then elif [[ "${MODE}" == "compose-sqlite" ]]; then
install_compose_solo_mode install_compose_sqlite_mode
else else
require_root require_root
require_service_manager require_service_manager