mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
Merge branch 'fawney19:main' into main
This commit is contained in:
@@ -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"]
|
||||
225
README.md
225
README.md
@@ -44,181 +44,27 @@ cd Aether
|
||||
|
||||
# 2. 配置环境变量
|
||||
cp .env.example .env
|
||||
./generate_keys.sh # 生成 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
|
||||
# 生成 JWT_SECRET_KEY / ENCRYPTION_KEY, 并填入 .env
|
||||
./generate_keys.sh
|
||||
# 编辑 .env 设置 ADMIN_PASSWORD
|
||||
|
||||
# 3. 启动单容器 SQLite 版本
|
||||
docker compose -f docker-compose.solo.yml up -d
|
||||
|
||||
# 4. 升级前备份(可选)
|
||||
cp -a data/aether.db backup_$(date +%Y%m%d_%H%M%S).db
|
||||
# 3. 首次部署 / 更新 (从以下数据库、内存策略任选其一)
|
||||
# Postgres + Redis (适用于企业或多人使用)
|
||||
docker compose pull && docker compose up -d
|
||||
# 仅SQLite (适用于个人用户或朋友分享)
|
||||
docker compose -f docker-compose.sqlite.yml pull && docker compose -f docker-compose.sqlite.yml up -d
|
||||
```
|
||||
|
||||
### 一键安装(可选部署方式)
|
||||
|
||||
安装脚本先从 `aether-rust-pioneer` 分支下载,不依赖 GitHub Release 的 `latest` 脚本地址。运行后会先选择语言,再选择版本和部署方式。Linux 单机 / 集群服务使用 systemd,macOS 单机 / 集群服务使用系统级 launchd;脚本会按当前系统自动下载 `linux-*` 或 `macos-*` Release 压缩包。
|
||||
### 一键安装(可选部署方式 Linux: systemd; Mac: launchd)
|
||||
|
||||
```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 是配套的正向代理节点,部署在海外 VPS 上,为墙内的 Aether 实例中转 API 流量。或者部署在其他服务器为指定的提供商、账号、Key使用不同的节点访问。支持 TUI 向导一键配置、systemd 服务管理、TLS 加密、DNS 缓存及连接池调优。
|
||||
Aether Proxy 是配套的正向代理节点,部署在海外 VPS 上,为墙内的 Aether 实例中转 API 流量。
|
||||
|
||||
- Docker Compose 部署或下载预编译二进制直接运行
|
||||
- 通过 `aether-proxy setup` 完成交互式配置,自动注册为系统服务
|
||||
- 详细文档见 [apps/aether-proxy/README.md](apps/aether-proxy/README.md)
|
||||
|
||||
@@ -229,63 +75,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}`
|
||||
- `AETHER_DATABASE_DRIVER` / `AETHER_DATABASE_URL`:二进制单机部署可用 `sqlite`,例如 `sqlite:///opt/aether/data/aether.db`
|
||||
- `DATABASE_URL` / `REDIS_URL`:共享后端连接串;多节点必须配置共享数据库和 Redis
|
||||
- `AETHER_RUNTIME_BACKEND=memory|redis`:运行时缓存/协调后端。单机 SQLite 默认用 `memory`,不会连接 Redis;显式设为 `redis` 或多节点部署才会把 `REDIS_URL` 注入运行时 Redis 后端
|
||||
- `AETHER_GATEWAY_AUTO_PREPARE_DATABASE`:常规启动前自动执行挂起的 schema migration 和 backfill;仓库自带的 `docker-compose.yml` 和 `docker-compose.build.yml` 默认开启
|
||||
- `DATABASE_URL`:数据库连接串;SQLite 例如 `sqlite:///opt/aether/data/aether.db`,Postgres 例如 `postgresql://postgres:aether@postgres:5432/aether`
|
||||
- `REDIS_URL`:Redis 连接串;仅 Postgres + Redis 的 Docker Compose 部署需要配置
|
||||
- `AETHER_RUNTIME_BACKEND=memory|redis`:运行时缓存/协调后端。SQLite 默认用 `memory`,不会连接 Redis
|
||||
- `AETHER_GATEWAY_AUTO_PREPARE_DATABASE`:常规启动前自动执行挂起的 schema migration 和 backfill;仓库自带的 `docker-compose.yml` 默认开启
|
||||
- `JWT_SECRET_KEY` / `ENCRYPTION_KEY`:认证和敏感数据加密所需密钥
|
||||
- `API_KEY_PREFIX`:用户和管理员新建 API Key 时使用的前缀,默认 `sk`
|
||||
- `ADMIN_USERNAME` / `ADMIN_PASSWORD` / `ADMIN_EMAIL`:首次启动时自举首个本地管理员;`install.sh` 会提示输入管理员密码
|
||||
- `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`
|
||||
- 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`。
|
||||
|
||||
---
|
||||
|
||||
## 许可证
|
||||
|
||||
@@ -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)?
|
||||
.trim()
|
||||
.to_string();
|
||||
if !user_role.eq_ignore_ascii_case("admin") {
|
||||
if !crate::roles::can_access_admin_console(&user_role) {
|
||||
return None;
|
||||
}
|
||||
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 {
|
||||
user_id,
|
||||
user_role: "admin".to_string(),
|
||||
user_role,
|
||||
session_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]
|
||||
fn ignores_trusted_admin_headers_without_gateway_marker() {
|
||||
let mut headers = http::HeaderMap::new();
|
||||
|
||||
@@ -273,7 +273,7 @@ async fn resolve_local_admin_principal(
|
||||
if claims
|
||||
.get("role")
|
||||
.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);
|
||||
}
|
||||
@@ -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 {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -329,7 +329,7 @@ async fn resolve_local_admin_principal_from_claims(
|
||||
|
||||
Ok(Some(GatewayAdminPrincipalContext {
|
||||
user_id: user.id,
|
||||
user_role: "admin".to_string(),
|
||||
user_role: user.role,
|
||||
session_id: Some(session.id),
|
||||
management_token_id: None,
|
||||
management_token_permissions: None,
|
||||
|
||||
@@ -224,6 +224,19 @@ pub(crate) fn read_only_management_token_permissions() -> Vec<String> {
|
||||
.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(
|
||||
value: Option<&Value>,
|
||||
) -> Result<Value, String> {
|
||||
@@ -615,6 +628,64 @@ mod tests {
|
||||
.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> {
|
||||
let mut scopes = BTreeSet::new();
|
||||
let mut remaining = source;
|
||||
|
||||
@@ -14,11 +14,13 @@ pub(crate) use auth::{
|
||||
};
|
||||
pub(crate) use execute::{allows_control_execute_emergency, maybe_execute_via_control};
|
||||
pub(crate) use management_token_permissions::{
|
||||
all_assignable_management_token_permissions, management_token_permission_catalog_payload,
|
||||
management_token_permission_keys_from_value, management_token_permission_mode_and_summary,
|
||||
all_assignable_management_token_permissions,
|
||||
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_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};
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -503,7 +503,7 @@ fn normalize_selection_filters(
|
||||
.map(|value| value.trim().to_ascii_lowercase())
|
||||
.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()),
|
||||
None => None,
|
||||
};
|
||||
|
||||
@@ -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> {
|
||||
match value
|
||||
let role = value
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("user")
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"user" => Ok("user".to_string()),
|
||||
"admin" => Ok("admin".to_string()),
|
||||
_ => Err("角色参数不合法".to_string()),
|
||||
.unwrap_or("user");
|
||||
match crate::roles::normalize_assignable_user_role(role) {
|
||||
Some(role) => Ok(role.to_string()),
|
||||
None => Err("角色参数不合法".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ use super::super::internal;
|
||||
use crate::admin_api;
|
||||
use crate::audit::attach_admin_audit_event;
|
||||
use crate::control::{
|
||||
audit_admin_read_only_management_token_permissions,
|
||||
validate_management_token_admin_route_permission, GatewayPublicRequestContext,
|
||||
};
|
||||
use crate::{AppState, GatewayError};
|
||||
@@ -61,21 +62,32 @@ fn maybe_build_management_token_permission_denied_response(
|
||||
) -> Option<Response<Body>> {
|
||||
let decision = request_context.control_decision.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(
|
||||
&request_context.request_method,
|
||||
decision,
|
||||
admin_principal.management_token_permissions.as_deref(),
|
||||
token_permissions,
|
||||
)
|
||||
.err()?;
|
||||
let actor_id = admin_principal
|
||||
.management_token_id
|
||||
.as_deref()
|
||||
.unwrap_or(admin_principal.user_id.as_str());
|
||||
|
||||
warn!(
|
||||
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_kind = decision.route_kind.as_deref().unwrap_or("unknown"),
|
||||
required_permission = %denied.required_permission,
|
||||
"management token permission denied"
|
||||
"admin route permission denied"
|
||||
);
|
||||
|
||||
let mut response = (
|
||||
@@ -91,10 +103,10 @@ fn maybe_build_management_token_permission_denied_response(
|
||||
.into_response();
|
||||
attach_admin_audit_event(
|
||||
&mut response,
|
||||
"admin_management_token_permission_denied",
|
||||
"admin_route_permission_denied",
|
||||
"permission_denied",
|
||||
"management_token_permission",
|
||||
token_id,
|
||||
"admin_route_permission",
|
||||
actor_id,
|
||||
);
|
||||
Some(response)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
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(());
|
||||
}
|
||||
let management_token_permissions = match management_token_permission_keys_from_value(
|
||||
|
||||
@@ -56,6 +56,7 @@ mod provider_key_auth;
|
||||
pub(crate) use aether_provider_transport as provider_transport;
|
||||
mod rate_limit;
|
||||
mod request_candidate_runtime;
|
||||
mod roles;
|
||||
mod router;
|
||||
mod scheduler;
|
||||
mod state;
|
||||
|
||||
52
apps/aether-gateway/src/roles.rs
Normal file
52
apps/aether-gateway/src/roles.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,7 @@ impl AppState {
|
||||
role: &str,
|
||||
) -> Result<Vec<String>, GatewayError> {
|
||||
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? {
|
||||
group_ids.remove(&default_group_id);
|
||||
}
|
||||
|
||||
144
deploy.sh
144
deploy.sh
@@ -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
4
dev.sh
@@ -95,13 +95,13 @@ dev_uses_redis_runtime() {
|
||||
print_dev_infra_hint() {
|
||||
echo "=> 本地开发依赖未就绪。"
|
||||
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() {
|
||||
echo "=> PostgreSQL 未就绪。"
|
||||
echo "=> 请先启动 Postgres:"
|
||||
echo "=> docker compose -f docker-compose.build.yml up -d postgres"
|
||||
echo "=> docker compose up -d postgres"
|
||||
}
|
||||
|
||||
check_postgres_ready() {
|
||||
|
||||
@@ -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:
|
||||
@@ -12,6 +12,8 @@ services:
|
||||
TZ: Asia/Shanghai
|
||||
volumes:
|
||||
- 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
|
||||
healthcheck:
|
||||
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}
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
ports:
|
||||
- "127.0.0.1:${REDIS_PORT:-6379}:6379"
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "redis-cli -a \"${REDIS_PASSWORD}\" ping | grep -q PONG" ]
|
||||
interval: 5s
|
||||
|
||||
@@ -117,7 +117,7 @@ async function syncExternalAuthState(nextToken: string | null): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
if (router.currentRoute.value.path.startsWith('/admin') && user.role !== 'admin') {
|
||||
if (router.currentRoute.value.path.startsWith('/admin') && !authStore.canAccessAdmin) {
|
||||
await router.replace('/dashboard')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,7 +340,7 @@ async function handleLogin() {
|
||||
// 延迟一下让用户看到成功消息
|
||||
setTimeout(() => {
|
||||
// 根据用户角色跳转到不同的仪表盘
|
||||
const targetPath = authStore.user?.role === 'admin' ? '/admin/dashboard' : '/dashboard'
|
||||
const targetPath = authStore.canAccessAdmin ? '/admin/dashboard' : '/dashboard'
|
||||
router.push(targetPath)
|
||||
}, 1000)
|
||||
} else {
|
||||
|
||||
@@ -135,12 +135,15 @@
|
||||
<SelectItem value="admin">
|
||||
管理员
|
||||
</SelectItem>
|
||||
<SelectItem value="audit_admin">
|
||||
审计管理员
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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">
|
||||
{{ targetRole === 'admin' ? '提示:设置为管理员会授予用户后台管理能力。' : '提示:设置为普通用户会移除目标用户的管理员权限。' }}
|
||||
{{ targetRole === 'admin' ? '提示:设置为管理员会授予用户完整后台管理能力。' : targetRole === 'audit_admin' ? '提示:设置为审计管理员会授予后台只读查看能力。' : '提示:设置为普通用户会移除目标用户的管理员权限。' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -80,6 +80,9 @@
|
||||
<SelectItem value="admin">
|
||||
管理员
|
||||
</SelectItem>
|
||||
<SelectItem value="audit_admin">
|
||||
审计管理员
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
</div>
|
||||
<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-[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>
|
||||
|
||||
@@ -227,7 +227,7 @@
|
||||
</div>
|
||||
<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-[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 class="flex items-center gap-1">
|
||||
@@ -520,7 +520,7 @@ function showDebugVersionStatus(hasUpdate = true) {
|
||||
// 检查更新
|
||||
async function checkForUpdate() {
|
||||
// 只有管理员才检查更新
|
||||
if (!isAdmin.value) return
|
||||
if (!authStore.canOperateAdmin) return
|
||||
|
||||
// 同一会话内只检查一次
|
||||
const sessionKey = 'aether_update_checked'
|
||||
@@ -567,7 +567,7 @@ onMounted(() => {
|
||||
syncAuthNotice()
|
||||
|
||||
// 管理员预加载模块状态(路由守卫会按需加载,这里提前加载以避免菜单闪烁)
|
||||
if (isAdmin.value && !moduleStore.loaded && !moduleStore.loading) {
|
||||
if (authStore.canAccessAdmin && !moduleStore.loaded && !moduleStore.loading) {
|
||||
moduleStore.fetchModules()
|
||||
}
|
||||
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
|
||||
|
||||
@@ -12,8 +12,7 @@ export async function checkAdminAccess(
|
||||
authStore: ReturnType<typeof useAuthStore>,
|
||||
moduleStore: ReturnType<typeof useModuleStore>
|
||||
): Promise<string | null> {
|
||||
const isAdmin = authStore.user?.role === 'admin'
|
||||
if (!isAdmin) {
|
||||
if (!authStore.canAccessAdmin) {
|
||||
log.warn('Non-admin user attempted to access admin page, redirecting to user dashboard')
|
||||
return '/dashboard'
|
||||
}
|
||||
|
||||
@@ -26,12 +26,11 @@ export function resolveHomeRedirect(
|
||||
}
|
||||
|
||||
// 已登录用户首次访问首页(非返回/刷新场景),根据角色跳转到对应仪表盘
|
||||
const isAdmin = authStore.user?.role === 'admin'
|
||||
const redirectPath = sessionStorage.getItem('redirectPath')
|
||||
if (redirectPath && redirectPath !== '/') {
|
||||
sessionStorage.removeItem('redirectPath')
|
||||
return redirectPath
|
||||
}
|
||||
|
||||
return isAdmin ? '/admin/dashboard' : '/dashboard'
|
||||
return authStore.canAccessAdmin ? '/admin/dashboard' : '/dashboard'
|
||||
}
|
||||
|
||||
@@ -105,4 +105,21 @@ describe('auth store logout', () => {
|
||||
expect(store.user).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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -32,6 +32,9 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
}
|
||||
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') {
|
||||
loading.value = true
|
||||
@@ -114,6 +117,9 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
error,
|
||||
isAuthenticated,
|
||||
isAdmin,
|
||||
isAuditAdmin,
|
||||
canAccessAdmin,
|
||||
canOperateAdmin,
|
||||
login,
|
||||
logout,
|
||||
applyExternalLogout,
|
||||
|
||||
@@ -414,7 +414,7 @@
|
||||
使用记录
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canCancel(task.status)"
|
||||
v-if="authStore.canOperateAdmin && canCancel(task.status)"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 text-xs text-red-500 border-red-200 hover:bg-red-50"
|
||||
@@ -830,7 +830,7 @@
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div
|
||||
v-if="canCancel(selectedTask.status)"
|
||||
v-if="authStore.canOperateAdmin && canCancel(selectedTask.status)"
|
||||
class="pt-4 border-t border-border/60"
|
||||
>
|
||||
<Button
|
||||
@@ -902,7 +902,7 @@ import { useAuthStore } from '@/stores/auth'
|
||||
import { log } from '@/utils/logger'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const isAdmin = computed(() => authStore.user?.role === 'admin')
|
||||
const isAdmin = computed(() => authStore.canAccessAdmin)
|
||||
const { toast } = useToast()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
|
||||
@@ -65,6 +65,9 @@
|
||||
<SelectItem value="admin">
|
||||
管理员
|
||||
</SelectItem>
|
||||
<SelectItem value="audit_admin">
|
||||
审计管理员
|
||||
</SelectItem>
|
||||
<SelectItem value="user">
|
||||
用户
|
||||
</SelectItem>
|
||||
@@ -148,6 +151,9 @@
|
||||
<SelectItem value="admin">
|
||||
管理员
|
||||
</SelectItem>
|
||||
<SelectItem value="audit_admin">
|
||||
审计管理员
|
||||
</SelectItem>
|
||||
<SelectItem value="user">
|
||||
普通用户
|
||||
</SelectItem>
|
||||
@@ -200,6 +206,7 @@
|
||||
|
||||
<!-- 新增用户按钮 -->
|
||||
<Button
|
||||
v-if="authStore.canOperateAdmin"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
@@ -211,6 +218,7 @@
|
||||
|
||||
<!-- 新增用户按钮 -->
|
||||
<Button
|
||||
v-if="authStore.canOperateAdmin"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
@@ -262,6 +270,7 @@
|
||||
清空选择
|
||||
</Button>
|
||||
<Button
|
||||
v-if="authStore.canOperateAdmin"
|
||||
size="sm"
|
||||
class="h-7 px-3 text-[11px]"
|
||||
:disabled="(selectedCount === 0 && userGroups.length === 0) || usersStore.loading"
|
||||
@@ -362,10 +371,10 @@
|
||||
{{ user.username }}
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
{{ user.role === 'admin' ? '管理员' : '普通用户' }}
|
||||
{{ formatUserRole(user.role) }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div
|
||||
@@ -468,6 +477,7 @@
|
||||
<TableCell class="py-4">
|
||||
<div class="flex justify-center gap-1">
|
||||
<Button
|
||||
v-if="authStore.canOperateAdmin"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
@@ -477,6 +487,7 @@
|
||||
<SquarePen class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="authStore.canOperateAdmin"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
@@ -486,6 +497,7 @@
|
||||
<DollarSign class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="authStore.canOperateAdmin"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
@@ -495,6 +507,7 @@
|
||||
<Key class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="authStore.canOperateAdmin"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
@@ -588,10 +601,10 @@
|
||||
{{ user.username }}
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
{{ user.role === 'admin' ? '管理员' : '普通用户' }}
|
||||
{{ formatUserRole(user.role) }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div
|
||||
@@ -694,6 +707,7 @@
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 pt-0.5">
|
||||
<Button
|
||||
v-if="authStore.canOperateAdmin"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8 text-xs"
|
||||
@@ -703,6 +717,7 @@
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
v-if="authStore.canOperateAdmin"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8 text-xs"
|
||||
@@ -712,6 +727,7 @@
|
||||
资金
|
||||
</Button>
|
||||
<Button
|
||||
v-if="authStore.canOperateAdmin"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8 text-xs"
|
||||
@@ -721,6 +737,7 @@
|
||||
API Keys
|
||||
</Button>
|
||||
<Button
|
||||
v-if="authStore.canOperateAdmin"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8 text-xs"
|
||||
@@ -1236,6 +1253,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useUsersStore } from '@/stores/users'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { User, ApiKey, UserSession, UserBatchActionResponse, UserBatchSelectionFilters, UserGroup } from '@/api/users'
|
||||
import { formatSessionMeta } from '@/types/session'
|
||||
import { adminWalletApi, type AdminWallet } from '@/api/admin-wallets'
|
||||
@@ -1310,6 +1328,7 @@ const { success, error } = useToast()
|
||||
const { confirmDanger } = useConfirm()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
const usersStore = useUsersStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 用户表单对话框状态
|
||||
const showUserFormDialog = ref(false)
|
||||
@@ -1354,6 +1373,7 @@ const userGroups = ref<UserGroup[]>([])
|
||||
const userRoleFilterOptions = [
|
||||
{ value: 'all', label: '全部角色' },
|
||||
{ value: 'admin', label: '管理员' },
|
||||
{ value: 'audit_admin', label: '审计管理员' },
|
||||
{ value: 'user', label: '普通用户' },
|
||||
]
|
||||
const userStatusFilterOptions = [
|
||||
@@ -1373,9 +1393,9 @@ const filteredUsers = computed(() => {
|
||||
|
||||
// 先排序:管理员优先,然后按创建时间倒序
|
||||
filtered.sort((a, b) => {
|
||||
// 管理员优先
|
||||
if (a.role === 'admin' && b.role !== 'admin') return -1
|
||||
if (a.role !== 'admin' && b.role === 'admin') return 1
|
||||
const roleRank = (role: string) => role === 'admin' ? 0 : role === 'audit_admin' ? 1 : 2
|
||||
const roleDiff = roleRank(a.role) - roleRank(b.role)
|
||||
if (roleDiff !== 0) return roleDiff
|
||||
// 同角色按创建时间倒序(新用户在前)
|
||||
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 search = searchQuery.value.trim()
|
||||
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 === 'inactive') filters.is_active = false
|
||||
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 })
|
||||
|
||||
function formatUserRole(role: string) {
|
||||
if (role === 'admin') return '管理员'
|
||||
if (role === 'audit_admin') return '审计管理员'
|
||||
return '普通用户'
|
||||
}
|
||||
|
||||
function userRoleBadgeVariant(role: string) {
|
||||
return role === 'admin' ? 'default' : 'secondary'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void refreshUsers({ preferCache: true })
|
||||
})
|
||||
|
||||
@@ -109,7 +109,7 @@ onMounted(async () => {
|
||||
success('登录成功')
|
||||
|
||||
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)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -498,7 +498,7 @@ const { copyToClipboard } = useClipboard()
|
||||
const { siteName, siteSubtitle } = useSiteInfo()
|
||||
|
||||
const dashboardPath = computed(() =>
|
||||
authStore.user?.role === 'admin' ? '/admin/dashboard' : '/dashboard'
|
||||
authStore.canAccessAdmin ? '/admin/dashboard' : '/dashboard'
|
||||
)
|
||||
const baseUrl = computed(() => window.location.origin)
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
Server,
|
||||
Key,
|
||||
Container,
|
||||
Code,
|
||||
Shield,
|
||||
Monitor,
|
||||
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 = [
|
||||
{
|
||||
title: '启动依赖',
|
||||
note: 'PostgreSQL + Redis',
|
||||
code: 'docker compose -f docker-compose.build.yml up -d postgres redis',
|
||||
code: 'docker compose up -d postgres redis',
|
||||
icon: Container
|
||||
},
|
||||
{
|
||||
@@ -133,7 +105,6 @@ function copyStep(stepId: string, code: string) {
|
||||
<button
|
||||
v-for="(tab, idx) in [
|
||||
{ icon: Container, label: 'Docker 预构建镜像' },
|
||||
{ icon: Code, label: '本地代码构建' },
|
||||
{ icon: Monitor, label: '本地开发' }
|
||||
]"
|
||||
:key="idx"
|
||||
@@ -200,58 +171,9 @@ function copyStep(stepId: string, code: string) {
|
||||
</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
|
||||
v-show="activeDeployTab === 2"
|
||||
v-show="activeDeployTab === 1"
|
||||
class="p-5 space-y-3"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
### 2. 本地代码构建镜像 (Docker Compose)
|
||||
```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. 本地开发
|
||||
### 2. 本地开发
|
||||
依赖 Docker、uv、nodejs
|
||||
```markdown
|
||||
# 启动数据库
|
||||
docker compose -f docker-compose.build.yml up -d postgres redis
|
||||
docker compose up -d postgres redis
|
||||
|
||||
# 后端
|
||||
uv sync
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
class="flex-1 min-w-0 flex flex-col"
|
||||
>
|
||||
<Badge
|
||||
:variant="authStore.user?.role === 'admin' ? 'default' : 'secondary'"
|
||||
:variant="authStore.isAdmin ? 'default' : 'secondary'"
|
||||
class="uppercase tracking-[0.45em] mb-4 self-start"
|
||||
>
|
||||
{{ authStore.user?.role === 'admin' ? 'ADMIN MODE' : 'PERSONAL MODE' }}
|
||||
{{ dashboardModeLabel }}
|
||||
</Badge>
|
||||
|
||||
<!-- 主要统计卡片 -->
|
||||
@@ -915,7 +915,12 @@ function setupTimelineResizeObserver() {
|
||||
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 = [
|
||||
'border-book-cloth/30 dark:border-book-cloth/25',
|
||||
|
||||
@@ -50,15 +50,15 @@
|
||||
>
|
||||
<UsageModelTable
|
||||
:data="enhancedModelStats"
|
||||
:is-admin="authStore.isAdmin"
|
||||
:is-admin="authStore.canAccessAdmin"
|
||||
/>
|
||||
<UsageProviderTable
|
||||
:data="providerStats"
|
||||
:is-admin="authStore.isAdmin"
|
||||
:is-admin="authStore.canAccessAdmin"
|
||||
/>
|
||||
<UsageApiFormatTable
|
||||
:data="apiFormatStats"
|
||||
:is-admin="authStore.isAdmin"
|
||||
:is-admin="authStore.canAccessAdmin"
|
||||
/>
|
||||
</div>
|
||||
<!-- 用户:模型 + API格式(2列) -->
|
||||
@@ -68,7 +68,7 @@
|
||||
>
|
||||
<UsageModelTable
|
||||
:data="enhancedModelStats"
|
||||
:is-admin="authStore.isAdmin"
|
||||
:is-admin="authStore.canAccessAdmin"
|
||||
/>
|
||||
<UsageApiFormatTable
|
||||
:data="apiFormatStats"
|
||||
@@ -81,7 +81,7 @@
|
||||
<UsageRecordsTable
|
||||
:records="displayRecords"
|
||||
:is-admin="isAdminPage"
|
||||
:show-actual-cost="authStore.isAdmin"
|
||||
:show-actual-cost="authStore.canAccessAdmin"
|
||||
:loading="isLoadingRecords"
|
||||
:time-range="timeRange"
|
||||
:filter-search="filterSearch"
|
||||
|
||||
@@ -623,7 +623,7 @@ async function loadAnnouncements(page = 1) {
|
||||
currentPage.value = page
|
||||
try {
|
||||
const response = await announcementApi.getAnnouncements({
|
||||
active_only: !isAdmin.value, // 管理员可以看到所有公告
|
||||
active_only: !authStore.canAccessAdmin, // 管理员和审计管理员可以看到所有公告
|
||||
limit: pageSize.value,
|
||||
offset: (page - 1) * pageSize.value
|
||||
})
|
||||
|
||||
@@ -561,7 +561,7 @@
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">角色</span>
|
||||
<Badge :variant="profile?.role === 'admin' ? 'default' : 'secondary'">
|
||||
{{ profile?.role === 'admin' ? '管理员' : '普通用户' }}
|
||||
{{ profileRoleLabel }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
@@ -682,6 +682,11 @@ const { setThemeMode } = useDarkMode()
|
||||
|
||||
const profile = ref<Profile | null>(null)
|
||||
const userSessions = ref<UserSession[]>([])
|
||||
const profileRoleLabel = computed(() => {
|
||||
if (profile.value?.role === 'admin') return '管理员'
|
||||
if (profile.value?.role === 'audit_admin') return '审计管理员'
|
||||
return '普通用户'
|
||||
})
|
||||
|
||||
const profileForm = ref({
|
||||
email: '',
|
||||
|
||||
81
install.sh
81
install.sh
@@ -49,11 +49,10 @@ Usage: install.sh [options]
|
||||
Install Aether Gateway.
|
||||
|
||||
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-solo: Docker Compose app + SQLite
|
||||
single: system service with SQLite + in-process runtime
|
||||
cluster: system service connected to shared database + Redis
|
||||
compose-sqlite: Docker Compose app + SQLite
|
||||
single: system service with SQLite
|
||||
Linux services use systemd; macOS services use launchd
|
||||
--channel CHANNEL Release channel to resolve when --version is omitted: stable, latest, rc, or beta
|
||||
stable/latest resolves the latest stable tag (default)
|
||||
@@ -81,7 +80,6 @@ Environment overrides:
|
||||
AETHER_IMAGE_REPO, AETHER_APP_IMAGE
|
||||
INSTALL_ROOT, AETHER_COMPOSE_DIR, CONFIG_DIR, SERVICE_USER, SERVICE_GROUP
|
||||
ADMIN_PASSWORD (required for non-interactive first install when generating a new env)
|
||||
DATABASE_URL, REDIS_URL (required when generating a cluster env)
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -436,8 +434,8 @@ select_mode() {
|
||||
MODE="compose"
|
||||
return
|
||||
;;
|
||||
compose-solo|solo|docker-solo|docker-solo-compose)
|
||||
MODE="compose-solo"
|
||||
compose-sqlite|sqlite-compose|compose-solo|solo|docker-solo|docker-solo-compose)
|
||||
MODE="compose-sqlite"
|
||||
return
|
||||
;;
|
||||
single|service|systemd|launchd|sqlite)
|
||||
@@ -445,56 +443,52 @@ select_mode() {
|
||||
return
|
||||
;;
|
||||
cluster|multi|multi-node)
|
||||
MODE="cluster"
|
||||
return
|
||||
if ui_is_zh; then
|
||||
die "集群部署模式暂未开放;请先选择 compose、compose-sqlite 或 single"
|
||||
else
|
||||
die "cluster deployment mode is temporarily disabled; choose compose, compose-sqlite, or single"
|
||||
fi
|
||||
;;
|
||||
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
|
||||
|
||||
if interactive_tty_available; then
|
||||
local service_manager
|
||||
service_manager="$(service_manager_name)"
|
||||
if ui_is_zh; then
|
||||
cat >/dev/tty <<EOF
|
||||
|
||||
请选择 Aether 部署模式:
|
||||
1) Docker Compose: 应用 + Postgres + Redis
|
||||
2) 单机服务: ${service_manager} + SQLite + 进程内运行时
|
||||
3) 集群节点服务: ${service_manager} + 共享数据库 + Redis
|
||||
4) Docker Compose: 应用 + SQLite
|
||||
1) Docker Compose 应用: Postgres + Redis
|
||||
3) Docker Compose 应用: 仅SQLite
|
||||
4) 系统服务: 仅SQLite
|
||||
|
||||
请输入选项 [2]:
|
||||
请输入选项 [4]:
|
||||
EOF
|
||||
else
|
||||
cat >/dev/tty <<EOF
|
||||
|
||||
Choose Aether deployment mode:
|
||||
1) Docker Compose: app + Postgres + Redis
|
||||
2) Single-node service: ${service_manager} + SQLite + in-process runtime
|
||||
3) Cluster node service: ${service_manager} + shared database + Redis
|
||||
4) Docker Compose: app + SQLite
|
||||
1) Docker Compose app: Postgres + Redis
|
||||
3) Docker Compose app: SQLite only
|
||||
4) System service: SQLite only
|
||||
|
||||
Enter choice [2]:
|
||||
Enter choice [4]:
|
||||
EOF
|
||||
fi
|
||||
local choice
|
||||
IFS= read -r choice </dev/tty || choice=""
|
||||
case "${choice:-2}" in
|
||||
case "${choice:-4}" in
|
||||
1)
|
||||
MODE="compose"
|
||||
;;
|
||||
2)
|
||||
MODE="single"
|
||||
;;
|
||||
3)
|
||||
MODE="cluster"
|
||||
MODE="compose-sqlite"
|
||||
;;
|
||||
4)
|
||||
MODE="compose-solo"
|
||||
MODE="single"
|
||||
;;
|
||||
*)
|
||||
if ui_is_zh; then
|
||||
@@ -945,16 +939,13 @@ AETHER_LOG_RETENTION_DAYS=7
|
||||
AETHER_LOG_MAX_FILES=30
|
||||
|
||||
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_VIDEO_TASK_TRUTH_SOURCE_MODE=rust-authoritative
|
||||
AETHER_GATEWAY_AUTO_PREPARE_DATABASE=true
|
||||
AETHER_RUNTIME_BACKEND=memory
|
||||
API_KEY_PREFIX=sk
|
||||
|
||||
AETHER_DATABASE_DRIVER=sqlite
|
||||
AETHER_DATABASE_URL=sqlite://${INSTALL_ROOT}/data/aether.db
|
||||
DATABASE_URL=sqlite://${INSTALL_ROOT}/data/aether.db
|
||||
|
||||
JWT_SECRET_KEY=${jwt_key}
|
||||
ENCRYPTION_KEY=${encryption_key}
|
||||
@@ -1054,7 +1045,7 @@ generate_compose_env() {
|
||||
replace_or_append_env "${output}" "AETHER_GATEWAY_AUTO_PREPARE_DATABASE" "true"
|
||||
}
|
||||
|
||||
generate_compose_solo_env() {
|
||||
generate_compose_sqlite_env() {
|
||||
local output="$1"
|
||||
local jwt_key encryption_key
|
||||
prompt_admin_password
|
||||
@@ -1074,16 +1065,13 @@ AETHER_LOG_MAX_FILES=30
|
||||
|
||||
APP_IMAGE=$(compose_image)
|
||||
APP_PORT=${APP_PORT:-8084}
|
||||
AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY=single-node
|
||||
AETHER_GATEWAY_NODE_ROLE=all
|
||||
AETHER_GATEWAY_STATIC_DIR=/srv/frontend
|
||||
AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE=rust-authoritative
|
||||
AETHER_GATEWAY_AUTO_PREPARE_DATABASE=true
|
||||
AETHER_RUNTIME_BACKEND=memory
|
||||
API_KEY_PREFIX=sk
|
||||
|
||||
AETHER_DATABASE_DRIVER=sqlite
|
||||
AETHER_DATABASE_URL=sqlite:///app/data/aether.db
|
||||
DATABASE_URL=sqlite:///app/data/aether.db
|
||||
|
||||
JWT_SECRET_KEY=${JWT_SECRET_KEY:-${jwt_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"
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
else
|
||||
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
|
||||
if [[ "${runtime_backend}" == "redis" && -z "${redis_url}" ]]; then
|
||||
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
|
||||
}
|
||||
|
||||
install_compose_solo_mode() {
|
||||
info "preparing Docker Compose solo deployment in ${COMPOSE_DIR}"
|
||||
install_compose_sqlite_mode() {
|
||||
info "preparing Docker Compose SQLite deployment in ${COMPOSE_DIR}"
|
||||
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"
|
||||
write_generate_keys_script "${COMPOSE_DIR}/generate_keys.sh"
|
||||
|
||||
@@ -1500,13 +1488,13 @@ install_compose_solo_mode() {
|
||||
warn "keeping existing ${COMPOSE_DIR}/.env"
|
||||
else
|
||||
info "generating ${COMPOSE_DIR}/.env"
|
||||
generate_compose_solo_env "${COMPOSE_DIR}/.env"
|
||||
generate_compose_sqlite_env "${COMPOSE_DIR}/.env"
|
||||
chmod 0600 "${COMPOSE_DIR}/.env"
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
|
||||
Docker Compose solo files are ready:
|
||||
Docker Compose SQLite files are ready:
|
||||
${COMPOSE_DIR}/docker-compose.yml
|
||||
${COMPOSE_DIR}/.env
|
||||
${COMPOSE_DIR}/.env.example
|
||||
@@ -1516,6 +1504,7 @@ Docker Compose solo files are ready:
|
||||
|
||||
Next steps:
|
||||
cd ${COMPOSE_DIR}
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
docker compose logs -f app
|
||||
|
||||
@@ -1941,8 +1930,8 @@ main() {
|
||||
|
||||
if [[ "${MODE}" == "compose" ]]; then
|
||||
install_compose_mode
|
||||
elif [[ "${MODE}" == "compose-solo" ]]; then
|
||||
install_compose_solo_mode
|
||||
elif [[ "${MODE}" == "compose-sqlite" ]]; then
|
||||
install_compose_sqlite_mode
|
||||
else
|
||||
require_root
|
||||
require_service_manager
|
||||
|
||||
Reference in New Issue
Block a user