Compare commits
10
Commits
a5f501dba4
...
24430f1867
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24430f1867 | ||
|
|
70c084e7e9 | ||
|
|
4029936744 | ||
|
|
ab3a874556 | ||
|
|
7340a3dd48 | ||
|
|
13c21dfd7e | ||
|
|
472c66ebd8 | ||
|
|
b305d7b371 | ||
|
|
c0e2ff975a | ||
|
|
1ea18490e0 |
@@ -8,6 +8,7 @@ coverage.out
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
web/.vite/
|
||||
internal/webui/dist/
|
||||
|
||||
# 运行时产物
|
||||
*.log
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
# AGENTS.md — ws_usernode 项目指南
|
||||
|
||||
本文件供开发/编码 agent 快速上手:项目目的、架构、构建流程、环境注意事项与编码约定。
|
||||
详细设计见 `docs/PLAN.md`,接口见 `docs/API.md`,部署见 `docs/DEPLOY.md`。
|
||||
|
||||
## 项目目的
|
||||
|
||||
**服务器用户管理节点**:对服务器上的**外部用户**(非本机管理员)做集中化管理,交付形态为**单个 Go 二进制**(前端产物 `go:embed` 嵌入,单文件分发)。核心能力:
|
||||
|
||||
- 系统账号生命周期:创建、禁用、删除、过期与回收;统一 `ext_` 前缀、`external` 组、口令锁定(仅 SSH 密钥登录)
|
||||
- SSH 公钥自助管理:用户上传/重命名/吊销,实时同步 `authorized_keys`
|
||||
- 新账号申请 + 管理员审批流(通过后自动建号并邮件通知)
|
||||
- SMTP 邮件通知:OTP 登录验证码、审批结果、到期提醒、密码重置
|
||||
- 管理操作审计(append-only,保留 30 天 + 手动/每日定时归档)
|
||||
|
||||
部署形态:单机节点 + RESTful API v1;系统账号操作经 sudoers 白名单命令,为后期"控制面 + 多节点 agent"预留。
|
||||
|
||||
## 技术栈与目录
|
||||
|
||||
| 层 | 技术 |
|
||||
|---|---|
|
||||
| 后端 | Go ≥ 1.22 + Gin + GORM(SQLite 开发 / MySQL 生产)+ log/slog |
|
||||
| 前端 | Vue 3 + Vite + TypeScript + Element Plus + Pinia + Vue Router + Axios |
|
||||
| 构建 | podman 容器构建前端(node:20-alpine),Go 二进制 go:embed 嵌入 |
|
||||
| 配置 | TOML(`config.example.toml`),环境变量覆盖 `USERNODE_<SECTION>_<FIELD>` |
|
||||
|
||||
```
|
||||
cmd/usernode/ CLI:serve / migrate / admin / user / help
|
||||
internal/api/ HTTP handler + 审计 helper
|
||||
internal/service/ 业务逻辑(user/approval/audit/key/auth/settings/lifecycle)
|
||||
internal/model/ GORM 模型(User/Approval/AuditLog/MailLog/Session/Setting)
|
||||
internal/pkg/ 校验等公共工具(validate.go:用户名/邮箱规则)
|
||||
internal/auth/ OTP、验证码、会话、密码重置
|
||||
internal/system/ 系统账号操作层(useradd/usermod/...,sudoers 白名单)
|
||||
internal/webui/ go:embed 前端产物(dist/ 被 git 跟踪)
|
||||
internal/router/ 路由装配
|
||||
web/ 前端源码(Vue3)
|
||||
docs/ PLAN.md / API.md / DEPLOY.md
|
||||
deploy/ Containerfile(多阶段镜像)、sudoers、systemd 服务
|
||||
```
|
||||
|
||||
## 开发构建流程
|
||||
|
||||
**一切构建操作必须在 podman 容器内进行**,不要尝试在宿主机 install / build。
|
||||
|
||||
```bash
|
||||
# 一次性:构建前端 dev 镜像 + 初始化 node_modules volume
|
||||
make web-install
|
||||
|
||||
# 前端热开发(容器挂载源码,5173 端口,API 代理到 Go 后端 8080)
|
||||
make web-dev # 依赖宿主机 Go 后端已启动(make run)
|
||||
|
||||
# 标准构建(每次改前端后走这套):
|
||||
make web-build # 容器内 npm run build → web/dist → 复制到 internal/webui/dist
|
||||
make build-embed # 重新编译含前端产物的 Go 二进制 → bin/usernode
|
||||
|
||||
# 后端
|
||||
make build # 仅编译 Go(不含前端)
|
||||
make run # 本地运行(默认 config.toml)
|
||||
make test # go test ./...
|
||||
make vet
|
||||
make migrate # 数据库迁移
|
||||
make image # 多阶段容器镜像(deploy/Containerfile,内含 golang:1.22-alpine 构建阶段)
|
||||
make clean # 清 bin/ 和 web/dist
|
||||
```
|
||||
|
||||
### 关键细节
|
||||
|
||||
- **Go 容器构建方式**:项目标准做法是 `deploy/Containerfile` 多阶段构建(前端 node:20-alpine → Go golang:1.22-alpine → 运行镜像),`make image` 一次产出含前端与后端的镜像。本地开发若要单独产 Go 二进制,应**用与容器一致的 golang:1.22-alpine 环境**(`podman run --rm -v "$PWD":/src -w /src golang:1.22-alpine sh -c "CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/usernode ./cmd/usernode"`),避免宿主机 go 1.26.7 版本漂移。
|
||||
- **`make build-embed` 直接用宿主机 go 编译**(当前 1.26.7),与项目基线(go.mod `go 1.22`)不一致;仅在确认宿主机 go 版本与目标一致时使用,否则改走容器构建。
|
||||
- **Go 测试/静态检查可用宿主机 go**(`make test` / `make vet`,仅本地开发验证用;正式产物以容器构建为准)。
|
||||
|
||||
- **前端构建链**:`web/dist`(gitignore)→ `internal/webui/dist`(**git 跟踪,必须随改动提交**,否则 index.html 引用的新 hash 文件不入库)。
|
||||
- **重建前清空 `internal/webui/dist`**:`make web-build` 内是 `cp -r` 合并复制,不清旧 hash 文件会导致 `go:embed` 把新旧产物一起打进二进制。标准做法:`rm -rf internal/webui/dist && mkdir -p internal/webui/dist && make web-build`。
|
||||
- **新增 npm 依赖**(在容器内安装,同步宿主机 package.json / package-lock.json):
|
||||
```bash
|
||||
podman run --rm --network=host \
|
||||
-v "$PWD/web:/app" -v ws_usernode_node_modules:/app/node_modules \
|
||||
ws-usernode-web-dev sh -c "npm install <pkg> --save"
|
||||
```
|
||||
- **前端类型检查**(容器内):`podman run --rm --network=host -v "$PWD/web:/app" -v ws_usernode_node_modules:/app/node_modules ws-usernode-web-dev sh -c "npx vue-tsc --noEmit"`。
|
||||
- **代理环境**:容器构建无法继承宿主机 proxychains。Makefile 经 `--build-arg` 传 `HTTP_PROXY/HTTPS_PROXY/ALL_PROXY/NO_PROXY`。若宿主机 LD_PRELOAD 了 proxychains,podman 拉镜像会失败,需:`env -u LD_PRELOAD -u PROXYCHAINS_CONF_FILE make web-build`。
|
||||
- **git 跟踪约定**:`bin/`、`web/dist/`、`web/node_modules/`、`data/`、`config.toml` 均 gitignore;`internal/webui/dist/` 例外(跟踪)。
|
||||
|
||||
## 环境注意事项(本开发机)
|
||||
|
||||
- 环境可能存在代理(proxychains node 等),本地回环可能会被拒绝,尝试绕过。
|
||||
- 不要在本机装 Node 工具链,一律走 podman。
|
||||
- 正式 Go 产物请走 `golang:1.22-alpine` 容器(见"Go 容器构建方式"),测试/静态检查可用宿主机 go。
|
||||
- podman 有镜像 `localhost/ws-usernode-web-dev` 与 volume `ws_usernode_node_modules`(缓存依赖,勿删)。
|
||||
- 无法访问本地浏览器后端(browser-use 不可用),视觉验证交给用户。
|
||||
|
||||
## 编码约定与易错点
|
||||
|
||||
- **外部用户名校验**(`internal/pkg/validate.go`):申请/创建时传**不含前缀**的用户名,须匹配 `^[a-z][a-z0-9]{1,31}$`(小写开头、2~32 位);最终系统账号 = `user_prefix` + 用户名,总长 ≤ 32。
|
||||
- **申请页(ApplyView)已改为中文姓名**:前端校验 2~10 个汉字,用 `pinyin-pro`(`{ toneType:'none', type:'array', surname:'head' }`,surname 处理姓氏多音字如 曾→zeng、单→shan)转拼音作为用户名提交;alert 实时预览 `ext_<username>`。改这里时保持"姓名→拼音→提交"链路与 `surname:'head'` 选项。
|
||||
- **同名处理**:提交申请时后端 `usernameAvailable` 拦截"用户表已存在 + 同名待审批",返回 `ErrUsernameTaken` → HTTP 409;审批通过时复查用户表(防审批期间被手动创建)。前端 axios 拦截器统一弹出后端 error 文案,失败也进审计。
|
||||
- **审计 append-only**:业务代码只允许 `AuditService.Record` 写入(禁 Update/Delete);`Result` ∈ success/failed。`AuditFilter.Result` 支持按结果筛选(管理员"错误日志"= 审计页选"失败"),导出 CSV 仅带时间范围(有意为之,勿随意改)。
|
||||
- **审计清理**:`audit.archive_dir` 留空 = 不归档也不清理(防丢审计);生产配置目录后超期审计先归档 CSV 再删除。
|
||||
- **系统账号操作**:`system.dry_run=true` 时只打印命令;生产 `sudo=true` 走 sudoers 白名单(deploy/sudoers),节点以专有用户运行。
|
||||
- **邮件**:SMTP 留空则禁用邮件;OTP 双通道(邮件 + `usernode user otp --username ext_xxx`)同验证码、同 10 分钟有效期、同 60s 冷却。
|
||||
- **前端主题**:主题定制集中在 `web/src/styles/theme.css`(CSS 变量覆盖 Element Plus)+ `web/src/stores/theme.ts`(暗色切换,localStorage key `ws-usernode-theme`);`index.html` 有防暗色闪烁内联脚本。改主题优先改变量而非逐页样式。
|
||||
- **测试**:后端 `go test ./...`;前端无测试框架,靠 vue-tsc + 构建。
|
||||
|
||||
## 其他补充
|
||||
|
||||
- 版本号在 Makefile `VERSION`(当前 `0.3.0-m5`),编译期 `-X main.version` 注入。
|
||||
- 新模型必须注册进 `internal/model` 的模型列表以触发 AutoMigrate。
|
||||
- git 暂存时仅暂存已知改动,不要直接全部暂存。提交前必须和用户确认,即使在完全访问(YOLO)的模式下也需要先询问用户再提交。
|
||||
- 数据库迁移:开发可直接 `make migrate`;生产 MySQL 切换参考 `deploy/migrate-sqlite2mysql.sh`。
|
||||
@@ -13,7 +13,7 @@ NET_HOST := --network=host
|
||||
GO ?= go
|
||||
PODMAN ?= podman
|
||||
BIN := bin/usernode
|
||||
VERSION ?= 0.3.0-m2
|
||||
VERSION ?= 0.3.0-m5
|
||||
LDFLAGS := -s -w -X main.version=$(VERSION)
|
||||
GOFLAGS := -trimpath
|
||||
|
||||
|
||||
+11
-5
@@ -86,7 +86,8 @@ func cmdServe(args []string) error {
|
||||
adminSvc := service.NewAdminService(db)
|
||||
userSvc := service.NewUserService(db, sys, cfg)
|
||||
keySvc := service.NewKeyService(db, sys, cfg)
|
||||
auditSvc := service.NewAuditService(db)
|
||||
auditSvc := service.NewAuditService(db, log)
|
||||
settingsSvc := service.NewSettingService(db, cfg)
|
||||
|
||||
// 认证依赖:DB OTP/会话/重置令牌存储 + 内存图形验证码/登录限速器 + 邮件
|
||||
captchas := auth.NewMemoryCaptchaStore(cfg.Auth.CaptchaTTL)
|
||||
@@ -94,8 +95,13 @@ func cmdServe(args []string) error {
|
||||
sessions := auth.NewDBSessionStore(db)
|
||||
resets := auth.NewDBResetTokenStore(db)
|
||||
limiter := auth.NewRateLimiter(cfg.Auth.MaxLoginFailures, cfg.Auth.LockDuration)
|
||||
mailer := mail.New(cfg.SMTP, log)
|
||||
// M3:所有邮件经队列(mail_logs 落库 + 失败重试),OTP 双通道不受邮件故障影响
|
||||
mailer := mail.NewQueuedMailer(mail.New(cfg.SMTP, log), db, log)
|
||||
authSvc := service.NewAuthService(db, cfg, otps, captchas, sessions, resets, limiter, mailer, userSvc, adminSvc, auditSvc, log)
|
||||
approvalSvc := service.NewApprovalService(db, cfg, userSvc, mailer, log)
|
||||
// M4:设置覆盖默认 TTL(settings 表);生命周期每日维护
|
||||
userSvc.WithSettings(settingsSvc)
|
||||
lifecycleSvc := service.NewLifecycleService(db, cfg, sys, userSvc, settingsSvc, mailer, auditSvc, log)
|
||||
|
||||
// 启动前自动迁移(骨架阶段保证表结构就绪;M5 部署建议显式 migrate)
|
||||
if err := model.Migrate(db); err != nil {
|
||||
@@ -103,13 +109,13 @@ func cmdServe(args []string) error {
|
||||
}
|
||||
log.Info("db: migrated", "driver", cfg.Database.Driver, "dsn", cfg.Database.DSN)
|
||||
|
||||
// 定时任务(骨架:M1/M4 填充过期扫描/回收/审计归档)
|
||||
// 定时任务(M3:邮件重试;M4:过期扫描/回收/审计归档)
|
||||
sched := robfigcron.New()
|
||||
cron.New(db, log).Register(sched)
|
||||
cron.New(cfg, mailer, lifecycleSvc, auditSvc, settingsSvc, log).Register(sched)
|
||||
sched.Start()
|
||||
defer sched.Stop()
|
||||
|
||||
h := api.New(cfg, authSvc, userSvc, keySvc, auditSvc)
|
||||
h := api.New(cfg, authSvc, userSvc, keySvc, approvalSvc, auditSvc, settingsSvc)
|
||||
r := router.New(cfg, h, sessions, log)
|
||||
|
||||
srv := server.New(cfg.Server.Listen, r, log)
|
||||
|
||||
@@ -51,3 +51,7 @@ group = "external" # 外部用户统一组
|
||||
shell = "/bin/sh" # 默认 shell
|
||||
home_base = "/home" # 家目录基路径
|
||||
authorized_keys_dir = ".ssh" # authorized_keys 所在目录名
|
||||
|
||||
[audit]
|
||||
archive_dir = "" # 审计每日归档目录(M4):留空 = 不归档也不自动清理(防丢审计);
|
||||
# 生产建议如 /var/lib/usernode/audit_archive,超期审计先归档再清理
|
||||
|
||||
Executable
+102
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env bash
|
||||
# ws_usernode SQLite → MySQL 数据迁移脚本(PLAN 验收:SQLite 数据可迁移至 MySQL)。
|
||||
#
|
||||
# 原理:SQLite 导出 SQL(含表结构与数据)→ 简单改写为 MySQL 兼容语法 → 导入 MySQL。
|
||||
# 适用:从开发库(SQLite)迁移到生产库(MySQL)。
|
||||
#
|
||||
# 用法:
|
||||
# MYSQL_DSN='usernode:pass@tcp(127.0.0.1:3306)/usernode?charset=utf8mb4&parseTime=True&loc=UTC' \
|
||||
# ./deploy/migrate-sqlite2mysql.sh /path/to/usernode.db
|
||||
#
|
||||
# 前置:
|
||||
# - sqlite3、mysql 客户端已安装(Debian/Ubuntu: apt install sqlite3 default-mysql-client)
|
||||
# - MySQL 中已建库、建好应用账号(建议先用 usernode migrate --config 指向 MySQL 建表,
|
||||
# 再执行本脚本导数据;若表不存在,本脚本也可建表)
|
||||
# - 先停服务,避免迁移过程中写入
|
||||
#
|
||||
# 注意事项:
|
||||
# - 时间字段:SQLite 存的是 "2026-08-30 10:41:41.437309782+08:00" 这类文本,
|
||||
# MySQL DATETIME 不接受。本脚本用字符串函数提取前 19 位(秒级精度)。
|
||||
# 若你的库以 UTC 存储且无需时区换算,秒级精度足够(审计/会话/OTP 均为秒级业务)。
|
||||
# - BLOB/二进制字段:本表结构无二进制大字段,不处理。
|
||||
# - 迁移后建议校验行数并抽查,见脚本尾部。
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ $# -lt 1 ]; then
|
||||
echo "用法: $0 <sqlite.db> [MYSQL_DSN 或通过环境变量 MYSQL_DSN]"
|
||||
echo "示例: MYSQL_DSN='usernode:pass@tcp(127.0.0.1:3306)/usernode?charset=utf8mb4&parseTime=True&loc=UTC' $0 data/usernode.db"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SQLITE_DB="$1"
|
||||
MYSQL_DSN="${MYSQL_DSN:-}"
|
||||
|
||||
if [ -z "$MYSQL_DSN" ]; then
|
||||
echo "错误:未提供 MYSQL_DSN(环境变量或第二参数)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
command -v sqlite3 >/dev/null || { echo "缺少 sqlite3" >&2; exit 1; }
|
||||
command -v mysql >/dev/null || { echo "缺少 mysql 客户端" >&2; exit 1; }
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
SQL_DUMP="$TMP/dump.sql"
|
||||
|
||||
echo "==> 1/4 导出 SQLite 结构 + 数据: $SQLITE_DB"
|
||||
sqlite3 "$SQLITE_DB" .dump > "$SQL_DUMP"
|
||||
|
||||
# 排除 SQLite 内部表(sqlite_sequence 等)
|
||||
echo "==> 2/4 改写为 MySQL 兼容 SQL"
|
||||
awk '
|
||||
/^CREATE TABLE/ { table=1 }
|
||||
/^CREATE TABLE sqlite_sequence/ { skip=1; next }
|
||||
/^CREATE TABLE/ && $3 ~ /^sqlite_/ { skip=1; next }
|
||||
/^CREATE INDEX/ && $0 ~ /sqlite_autoindex/ { next }
|
||||
|
||||
# 建表语句改写:
|
||||
# "text" → TEXT;AUTOINCREMENT 去掉(MySQL 自增)
|
||||
# PRIMARY KEY (id) 保留;去掉多余引号
|
||||
/^CREATE TABLE/ {
|
||||
line = $0
|
||||
gsub(/"text"/, "TEXT", line)
|
||||
gsub(/AUTOINCREMENT/, "", line)
|
||||
# 去掉反引号内外的双引号(表名/列名)
|
||||
line = line
|
||||
print line
|
||||
next
|
||||
}
|
||||
|
||||
# 行数据:SQLite 的 INSERT INTO "table" VALUES(...) → MySQL 双引号仅用于字符串,表名不需要引号
|
||||
/^INSERT INTO/ {
|
||||
line = $0
|
||||
gsub(/INSERT INTO "/, "INSERT INTO `", line)
|
||||
gsub(/"\s*VALUES/, "` VALUES", line)
|
||||
# SQLite 布尔/整数原样;字符串内单引号已由 sqlite3 转义为 '',MySQL 兼容
|
||||
print line
|
||||
next
|
||||
}
|
||||
|
||||
{ print }
|
||||
' "$SQL_DUMP" > "$TMP/mysql.sql"
|
||||
|
||||
echo "==> 3/4 导入 MySQL"
|
||||
# mysql 客户端 DSN 形式为 mysql://user:pass@tcp(host:port)/db 或 user:pass@tcp(...)/db
|
||||
if [[ "$MYSQL_DSN" == mysql://* ]]; then
|
||||
MYSQL_DSN="${MYSQL_DSN#mysql://}"
|
||||
fi
|
||||
mysql --protocol=tcp "$MYSQL_DSN" < "$TMP/mysql.sql"
|
||||
|
||||
echo "==> 4/4 校验行数"
|
||||
for t in admin_users users ssh_keys approvals audit_logs sessions settings mail_logs otp_codes password_reset_tokens; do
|
||||
s=$(sqlite3 "$SQLITE_DB" "SELECT count(*) FROM $t;" 2>/dev/null || echo "?")
|
||||
m=$(mysql --protocol=tcp --batch --skip-column-names "$MYSQL_DSN" -e "SELECT count(*) FROM \`$t\`;" 2>/dev/null || echo "?")
|
||||
printf " %-24s sqlite=%-6s mysql=%s\n" "$t" "$s" "$m"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "迁移完成。建议:"
|
||||
echo " 1. 抽查关键表(users/sessions)内容与时间字段格式"
|
||||
echo " 2. 用指向 MySQL 的配置启动 usernode serve 验证"
|
||||
echo " 3. 确认无误后再处理旧 SQLite 库"
|
||||
@@ -25,3 +25,9 @@ usernode ALL=(root) NOPASSWD: /usr/sbin/useradd, /usr/sbin/usermod, \
|
||||
# [system]
|
||||
# sudo = true
|
||||
# dry_run = false
|
||||
#
|
||||
# 审计命令行为:如需记录 usernode 用户经 sudo 执行的所有命令,可追加
|
||||
# Defaults:usernode log_output
|
||||
# 并把输出目录设为 usernode 可写(如 /var/log/usernode/sudo),配合
|
||||
# journald 的 audit 日志作为系统账号操作的可追溯记录(PLAN §9)。
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# ws_usernode systemd 服务单元(生产部署)
|
||||
#
|
||||
# 安装:
|
||||
# 1. 构建单二进制:make build-embed(含前端产物)
|
||||
# 2. 安装二进制与配置:
|
||||
# sudo install -o usernode -g usernode -m 0755 bin/usernode /usr/local/bin/usernode
|
||||
# sudo install -o root -g root -m 0644 config.example.toml /etc/usernode/config.toml
|
||||
# (按需编辑 /etc/usernode/config.toml:监听地址、数据库、SMTP、system.sudo=true)
|
||||
# 3. 创建运行用户与数据目录:
|
||||
# sudo useradd -r -s /usr/sbin/nologin -d /var/lib/usernode usernode
|
||||
# sudo mkdir -p /var/lib/usernode && sudo chown usernode:usernode /var/lib/usernode
|
||||
# 4. 安装 sudoers 白名单(节点以专有用户经 sudo -n 执行系统账号命令,见 sudoers.example):
|
||||
# sudo cp deploy/sudoers.example /etc/sudoers.d/usernode && sudo visudo -c
|
||||
# 5. 安装本单元并启动:
|
||||
# sudo install -o root -g root -m 0644 deploy/usernode.service /etc/systemd/system/usernode.service
|
||||
# sudo systemctl daemon-reload
|
||||
# sudo systemctl enable --now usernode
|
||||
# 6. 初始化:migrate + 创建初始管理员
|
||||
# sudo -u usernode usernode migrate --config /etc/usernode/config.toml
|
||||
# sudo -u usernode usernode admin create --config /etc/usernode/config.toml \
|
||||
# --username root --password 'xxx' --email admin@example.com
|
||||
#
|
||||
# 说明:
|
||||
# - 以 usernode 专有用户运行;数据(SQLite / 审计归档)在 /var/lib/usernode。
|
||||
# - 生产必须配置 [system] sudo=true dry_run=false,配合 /etc/sudoers.d/usernode 白名单。
|
||||
# - 建议前置 nginx/caddy 反向代理启用 TLS;[server] listen 保持 127.0.0.1。
|
||||
|
||||
[Unit]
|
||||
Description=ws_usernode server user management node
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=usernode
|
||||
Group=usernode
|
||||
WorkingDirectory=/var/lib/usernode
|
||||
ExecStart=/usr/local/bin/usernode serve --config /etc/usernode/config.toml
|
||||
ExecReload=/bin/kill -HUP $MAINPID
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
# 安全加固
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/usernode /var/log/usernode /etc/usernode
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
# 日志走 journald
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
# API 文档(RESTful v1)
|
||||
|
||||
统一前缀 `/api/v1`;会话基于 cookie(HttpOnly/Secure/SameSite,`withCredentials`)。
|
||||
成功响应 `{"data": ...}`;失败响应 `{"error": "..."}`,HTTP 状态码语义化
|
||||
(400 参数/401 未登录/403 无权限/404 不存在/409 冲突/429 限速/500 内部)。
|
||||
|
||||
权限:`-` 公开,`admin` 管理员会话,`user` 外部用户会话。
|
||||
|
||||
## 认证 /auth
|
||||
|
||||
| 方法 | 路径 | 说明 | 权限 |
|
||||
|---|---|---|---|
|
||||
| GET | `/auth/captcha` | 图形验证码 `{captcha_id, image}`(base64 PNG data URI) | - |
|
||||
| POST | `/auth/otp/send` | 外部用户:用户名+图形验证码 → 发 OTP 邮件(60s 冷却)。body: `{username, captcha_id, captcha_code}` | - |
|
||||
| POST | `/auth/otp/login` | `{username, code}` → 建 cookie 会话 | - |
|
||||
| POST | `/auth/admin/login` | `{username, password}` → 建 cookie 会话 | - |
|
||||
| POST | `/auth/admin/forgot` | `{username}` → 发密码重置邮件(不存在也返回成功,防枚举) | - |
|
||||
| POST | `/auth/admin/reset` | `{token, new_password}` → 重置密码 | - |
|
||||
| POST | `/auth/logout` | 登出(删会话 + 清 cookie) | admin/user |
|
||||
| GET | `/auth/me` | 当前主体 `{user_type, id, username, email}` | admin/user |
|
||||
|
||||
## 用户 /users(admin)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/users?page=&page_size=&status=&supervisor=` | 列表(分页 `{total, items}`) |
|
||||
| POST | `/users` | 创建。body: `{username(不含ext_), email, supervisor, purpose, ttl_days(0=默认)}` → `{id, username, status, expire_at}` |
|
||||
| GET | `/users/:id` | 详情 |
|
||||
| PATCH | `/users/:id` | 更新(可空字段省略)。body: `{email?, supervisor?, purpose?}` |
|
||||
| POST | `/users/:id/disable` | 禁用(清空 authorized_keys,SSH 立即失效) |
|
||||
| POST | `/users/:id/enable` | 启用(按 DB 密钥恢复) |
|
||||
| POST | `/users/:id/extend` | 延期。body: `{days(0=默认)}` → `{expire_at}`;已过期在回收期内可恢复 |
|
||||
| DELETE | `/users/:id` | 删除并回收(系统账号+家目录+密钥,保留审计) |
|
||||
| GET | `/users/:id/keys` | 用户密钥列表 |
|
||||
|
||||
## 我的 /me(user)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/me/keys` | 我的密钥列表 |
|
||||
| POST | `/me/keys` | 上传公钥 `{name, public_key}`(格式/重复校验 + 同步 authorized_keys) |
|
||||
| PATCH | `/me/keys/:id` | 重命名 `{name}` |
|
||||
| DELETE | `/me/keys/:id` | 吊销(立即失效) |
|
||||
| GET | `/me/audit?page=&page_size=` | 我的审计痕迹(本人为操作者或对象) |
|
||||
|
||||
## 申请审批 /approvals
|
||||
|
||||
| 方法 | 路径 | 说明 | 权限 |
|
||||
|---|---|---|---|
|
||||
| POST | `/approvals` | 提交申请 `{username, email, supervisor, purpose}`(不含 ext_) | - |
|
||||
| GET | `/approvals?status=` | 列表(pending/approved/rejected) | admin |
|
||||
| POST | `/approvals/:id/review` | 审批 `{approve: bool, reason}`(拒绝必填理由) | admin |
|
||||
|
||||
## 审计 /audit(admin)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/audit?page=&page_size=&actor=&action=&resource_type=&resource_id=&since=&until=` | 查询(since/until 为 RFC3339) |
|
||||
| GET | `/audit/export?since=&until=` | CSV 导出(UTF-8 BOM,`Content-Disposition` 下载) |
|
||||
|
||||
## 系统设置 /settings(admin)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/settings` | 全部设置项 `{items: [{key, value, overridden}]}`(config 默认 + settings 覆盖) |
|
||||
| PUT | `/settings` | 更新 `{key, value}`(白名单 key:`policy.default_ttl` / `policy.recycle_period` / `policy.audit_retention`,值为时长) |
|
||||
|
||||
## 仪表盘 /admin(admin)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/admin/stats` | `{total, active, disabled, expired, expiring_soon[], pending_approvals}`(近 30 天待过期) |
|
||||
|
||||
## 其他
|
||||
|
||||
| 方法 | 路径 | 说明 | 权限 |
|
||||
|---|---|---|---|
|
||||
| GET | `/healthz` | 健康检查 `{status, version, uptime, go, timestamp}` | - |
|
||||
|
||||
## 审计动作一览
|
||||
|
||||
`auth.admin_login`、`auth.otp_send`、`auth.otp_login`、`auth.logout`、
|
||||
`user.create/update/disable/enable/extend/delete`、`key.create/rename/revoke`、
|
||||
`approval.submit/review`、`lifecycle.expire`(到期锁定)、`lifecycle.recycle`(回收删除)。
|
||||
`detail` 为 JSON 字符串;`result` ∈ `success|failed`。
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
# 部署指南(M5)
|
||||
|
||||
ws_usernode 交付形态:**单 Go 二进制**(前端已 go:embed 嵌入),配 systemd 服务 +
|
||||
sudoers 白名单。可选 podman 容器镜像。本文件覆盖完整生产部署步骤与验证。
|
||||
|
||||
---
|
||||
|
||||
## 1. 构建
|
||||
|
||||
前置:Go ≥ 1.22、podman(构建前端需要容器,宿主机 Node 版本过旧无法直接构建)。
|
||||
|
||||
```bash
|
||||
# 1) 构建前端并嵌入 go:embed(内部自动 podman 构建 dev 镜像 + npm install + vite build)
|
||||
make web-build
|
||||
|
||||
# 2) 编译含前端产物的单二进制
|
||||
make build-embed # 产物 bin/usernode
|
||||
```
|
||||
|
||||
> 代理环境:容器构建无法继承 proxychains,Makefile 通过 `--build-arg` 传递
|
||||
> `HTTP_PROXY/HTTPS_PROXY/ALL_PROXY/NO_PROXY`。若宿主机 LD_PRELOAD 了
|
||||
> proxychains(本环境如此),podman 拉取镜像会失败,需:
|
||||
> `env -u LD_PRELOAD -u PROXYCHAINS_CONF_FILE make web-build`
|
||||
|
||||
验证二进制:`./bin/usernode --help`;健康检查 `curl localhost:8080/healthz`。
|
||||
|
||||
---
|
||||
|
||||
## 2. systemd 部署(推荐)
|
||||
|
||||
### 2.1 运行用户与目录
|
||||
|
||||
```bash
|
||||
sudo useradd -r -s /usr/sbin/nologin -d /var/lib/usernode usernode
|
||||
sudo mkdir -p /var/lib/usernode /var/log/usernode /etc/usernode
|
||||
sudo chown usernode:usernode /var/lib/usernode /var/log/usernode
|
||||
```
|
||||
|
||||
### 2.2 安装二进制与配置
|
||||
|
||||
```bash
|
||||
sudo install -o usernode -g usernode -m 0755 bin/usernode /usr/local/bin/usernode
|
||||
sudo install -o root -g root -m 0644 config.example.toml /etc/usernode/config.toml
|
||||
sudoedit /etc/usernode/config.toml
|
||||
```
|
||||
|
||||
配置要点(生产):
|
||||
|
||||
```toml
|
||||
[app]
|
||||
env = "production"
|
||||
base_url = "https://usernode.example.com" # 对外地址(邮件重置链接)
|
||||
|
||||
[server]
|
||||
listen = "127.0.0.1:8080" # 置于反向代理后;或直接 0.0.0.0
|
||||
trusted_proxies = ["127.0.0.1", "::1"]
|
||||
|
||||
[database]
|
||||
driver = "mysql" # 生产 MySQL;小规模可 sqlite
|
||||
dsn = "usernode:pass@tcp(127.0.0.1:3306)/usernode?charset=utf8mb4&parseTime=True&loc=UTC"
|
||||
|
||||
[smtp]
|
||||
host = "smtp.example.com" # 邮件必配(OTP/审批/到期通知)
|
||||
port = 587
|
||||
username = "usernode"
|
||||
password = "..." # 或 USERNODE_SMTP_PASSWORD 环境变量
|
||||
from = "usernode@example.com"
|
||||
|
||||
[system]
|
||||
sudo = true # 必须 true:经 sudo -n 白名单执行
|
||||
dry_run = false # 必须 false
|
||||
|
||||
[audit]
|
||||
archive_dir = "/var/lib/usernode/audit_archive" # 审计每日归档;留空不清理
|
||||
```
|
||||
|
||||
### 2.3 sudoers 白名单
|
||||
|
||||
```bash
|
||||
sudo cp deploy/sudoers.example /etc/sudoers.d/usernode
|
||||
sudo visudo -c
|
||||
```
|
||||
|
||||
白名单仅允许 `useradd/usermod/userdel/passwd/mkdir/chmod/chown/install`
|
||||
(禁任意 shell),命令参数由程序内强校验。
|
||||
|
||||
### 2.4 初始化数据库与管理员
|
||||
|
||||
```bash
|
||||
sudo -u usernode usermode migrate --config /etc/usernode/config.toml # 建表(先于 admin create)
|
||||
sudo -u usernode usernode admin create --config /etc/usernode/config.toml \
|
||||
--username root --password '强密码' --email admin@example.com
|
||||
```
|
||||
|
||||
### 2.5 安装服务并启动
|
||||
|
||||
```bash
|
||||
sudo install -o root -g root -m 0644 deploy/usernode.service /etc/systemd/system/usernode.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now usernode
|
||||
sudo systemctl status usernode
|
||||
journalctl -u usernode -f # 结构化日志(slog)
|
||||
```
|
||||
|
||||
### 2.6 反向代理(TLS 可选但建议)
|
||||
|
||||
nginx 示例:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name usernode.example.com;
|
||||
# ssl_certificate / ssl_certificate_key ...
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. podman 容器(可选)
|
||||
|
||||
```bash
|
||||
make image # 多阶段构建:node 构建前端 → Go 编译 → alpine 运行镜像
|
||||
podman run -d --name usernode \
|
||||
-p 8080:8080 \
|
||||
-v usernode-data:/data \
|
||||
-v /etc/usernode:/etc/usernode:ro \
|
||||
-e USERNODE_SYSTEM_SUDO=true \
|
||||
-e USERNODE_SYSTEM_DRY_RUN=false \
|
||||
ws-usernode:0.3.0-m5
|
||||
```
|
||||
|
||||
> 容器内系统账号操作:容器以 usernode 用户运行,需在宿主机(或特权容器内)
|
||||
> 配置对应 sudoers;默认 `dry_run=true` 只打印计划命令,用于容器内演练。
|
||||
> 容器内用户目录/系统账号与宿主机共享时,请自行评估 sudo 与挂载边界。
|
||||
|
||||
---
|
||||
|
||||
## 4. SQLite → MySQL 迁移
|
||||
|
||||
```bash
|
||||
# 前置:MySQL 已建库、建账号;可先用指向 MySQL 的配置跑 usernode migrate 建表
|
||||
MYSQL_DSN='usernode:pass@tcp(127.0.0.1:3306)/usernode?charset=utf8mb4&parseTime=True&loc=UTC' \
|
||||
./deploy/migrate-sqlite2mysql.sh /path/to/usernode.db
|
||||
```
|
||||
|
||||
脚本会导出 SQLite(结构+数据)、改写为 MySQL 兼容语法并导入,最后逐表比对行数。
|
||||
详见脚本头部注释(含时间字段精度说明)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 验收清单(对照 PLAN M5 验收标准)
|
||||
|
||||
- [ ] 管理员创建用户 → 外部用户图形验证码 + OTP(邮件或 CLI)登录 → 上传公钥 → SSH 登录服务器
|
||||
- [ ] 吊销密钥立即失效;到期自动禁用并通知;回收期内可延期,超期自动回收(保留审计)
|
||||
- [ ] 所有管理操作可审计、可查询、可 CSV 导出;每日归档(配置 archive_dir 后)
|
||||
- [ ] SQLite 数据可迁移至 MySQL(deploy/migrate-sqlite2mysql.sh)
|
||||
- [ ] 单二进制部署(systemd + sudoers 白名单)通过
|
||||
- [ ] podman 容器构建通过
|
||||
|
||||
## 6. 运维速查
|
||||
|
||||
| 场景 | 操作 |
|
||||
|---|---|
|
||||
| 重置管理员密码 | `sudo -u usernode usernode admin reset-password --config /etc/usernode/config.toml --username root --password 新密码` |
|
||||
| 获取外部用户 OTP | `usernode user otp --config ... --username ext_xxx`(与邮件同码同效期) |
|
||||
| 查看审计归档 | `ls /var/lib/usernode/audit_archive/` |
|
||||
| 查看邮件失败记录 | 数据库 `mail_logs` 表 status='failed'(每 5 分钟自动重试) |
|
||||
| 手动触发维护 | 重启服务触发 `@daily` 维护;或等待 `@every 6h` 补扫 |
|
||||
| 备份 | SQLite:停服拷贝 db 文件;MySQL:mysqldump |
|
||||
+193
-4
@@ -97,18 +97,21 @@ func setupTestAppWithSys(t *testing.T, sys system.Manager) *testApp {
|
||||
adminSvc := service.NewAdminService(db)
|
||||
userSvc := service.NewUserService(db, sys, cfg)
|
||||
keySvc := service.NewKeyService(db, sys, cfg)
|
||||
auditSvc := service.NewAuditService(db)
|
||||
mailer := &recordingMailer{}
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
auditSvc := service.NewAuditService(db, log)
|
||||
settingsSvc := service.NewSettingService(db, cfg)
|
||||
userSvc.WithSettings(settingsSvc)
|
||||
|
||||
captchas := auth.NewMemoryCaptchaStore(cfg.Auth.CaptchaTTL)
|
||||
otps := auth.NewDBOTPStore(db, auth.DefaultMaxFailures, auth.DefaultFailureWin)
|
||||
sessions := auth.NewDBSessionStore(db)
|
||||
resets := auth.NewDBResetTokenStore(db)
|
||||
limiter := auth.NewRateLimiter(cfg.Auth.MaxLoginFailures, cfg.Auth.LockDuration)
|
||||
mailer := &recordingMailer{}
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
authSvc := service.NewAuthService(db, cfg, otps, captchas, sessions, resets, limiter, mailer, userSvc, adminSvc, auditSvc, log)
|
||||
approvalSvc := service.NewApprovalService(db, cfg, userSvc, mailer, log)
|
||||
|
||||
h := api.New(cfg, authSvc, userSvc, keySvc, auditSvc)
|
||||
h := api.New(cfg, authSvc, userSvc, keySvc, approvalSvc, auditSvc, settingsSvc)
|
||||
r := router.New(cfg, h, sessions, log)
|
||||
|
||||
if _, err := adminSvc.Create(context.Background(), "root", "Passw0rd", "root@example.com"); err != nil {
|
||||
@@ -480,3 +483,189 @@ func testSSHPubKey(t *testing.T) string {
|
||||
}
|
||||
return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPub)))
|
||||
}
|
||||
|
||||
func TestAPIApprovalFlow(t *testing.T) {
|
||||
app := setupTestApp(t)
|
||||
|
||||
// 公开提交申请(无需登录)
|
||||
w := app.doJSON(http.MethodPost, "/api/v1/approvals", map[string]any{
|
||||
"username": "zhaoliu", "email": "zl@example.com", "supervisor": "prof.wang", "purpose": "课程实验",
|
||||
})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("submit status = %d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
appr := decodeBody(t, w)["data"].(map[string]any)
|
||||
apprID := uint(appr["id"].(float64))
|
||||
if appr["status"] != "pending" {
|
||||
t.Fatalf("approval status = %v", appr["status"])
|
||||
}
|
||||
|
||||
// 同名待审批申请 → 409
|
||||
w = app.doJSON(http.MethodPost, "/api/v1/approvals", map[string]any{
|
||||
"username": "zhaoliu", "email": "zl2@example.com",
|
||||
})
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("duplicate submit status = %d, want 409", w.Code)
|
||||
}
|
||||
|
||||
// 未登录访问列表 → 401;外部用户访问 → 403
|
||||
w = app.doJSON(http.MethodGet, "/api/v1/approvals", nil)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauth list status = %d, want 401", w.Code)
|
||||
}
|
||||
|
||||
// 管理员登录 → 列表 1 条
|
||||
w = app.doJSON(http.MethodPost, "/api/v1/auth/admin/login", map[string]string{"username": "root", "password": "Passw0rd"})
|
||||
ck := sessionCookie(t, w)
|
||||
w = app.doJSON(http.MethodGet, "/api/v1/approvals?status=pending", nil, ck)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list status = %d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if items := decodeBody(t, w)["data"].(map[string]any)["items"].([]any); len(items) != 1 {
|
||||
t.Fatalf("pending items = %d, want 1", len(items))
|
||||
}
|
||||
|
||||
// 拒绝必须填理由 → 400
|
||||
w = app.doJSON(http.MethodPost, "/api/v1/approvals/"+itoa(apprID)+"/review", map[string]any{"approve": false}, ck)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("reject without reason status = %d, want 400", w.Code)
|
||||
}
|
||||
|
||||
// 通过 → 自动建号 + 通知邮件
|
||||
w = app.doJSON(http.MethodPost, "/api/v1/approvals/"+itoa(apprID)+"/review", map[string]any{"approve": true}, ck)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("approve status = %d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if status := decodeBody(t, w)["data"].(map[string]any)["status"]; status != "approved" {
|
||||
t.Fatalf("approved status = %v", status)
|
||||
}
|
||||
if app.mailer.lastTo != "zl@example.com" || !strings.Contains(app.mailer.lastBody, "ext_zhaoliu") {
|
||||
t.Fatalf("approval mail = %s / %s", app.mailer.lastTo, app.mailer.lastBody)
|
||||
}
|
||||
var u model.User
|
||||
if err := app.db.First(&u, "username = ?", "ext_zhaoliu").Error; err != nil {
|
||||
t.Fatalf("user not created after approve: %v", err)
|
||||
}
|
||||
if u.Status != model.UserStatusActive {
|
||||
t.Fatalf("created user status = %s", u.Status)
|
||||
}
|
||||
|
||||
// 重复审批 → 409
|
||||
w = app.doJSON(http.MethodPost, "/api/v1/approvals/"+itoa(apprID)+"/review", map[string]any{"approve": false, "reason": "x"}, ck)
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("re-review status = %d, want 409", w.Code)
|
||||
}
|
||||
|
||||
// 新申请 → 拒绝 + 理由 → 通知;被拒后可重新提交
|
||||
w = app.doJSON(http.MethodPost, "/api/v1/approvals", map[string]any{"username": "qianqi", "email": "qq@example.com"})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("second submit status = %d", w.Code)
|
||||
}
|
||||
apprID2 := uint(decodeBody(t, w)["data"].(map[string]any)["id"].(float64))
|
||||
w = app.doJSON(http.MethodPost, "/api/v1/approvals/"+itoa(apprID2)+"/review", map[string]any{"approve": false, "reason": "用途不明确"}, ck)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("reject status = %d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if status := decodeBody(t, w)["data"].(map[string]any)["status"]; status != "rejected" {
|
||||
t.Fatalf("rejected status = %v", status)
|
||||
}
|
||||
if app.mailer.lastTo != "qq@example.com" || !strings.Contains(app.mailer.lastBody, "用途不明确") {
|
||||
t.Fatalf("reject mail = %s / %s", app.mailer.lastTo, app.mailer.lastBody)
|
||||
}
|
||||
// 被拒后可重新提交同名申请
|
||||
w = app.doJSON(http.MethodPost, "/api/v1/approvals", map[string]any{"username": "qianqi", "email": "qq2@example.com"})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("resubmit status = %d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIAuditQueryExport(t *testing.T) {
|
||||
app := setupTestApp(t)
|
||||
// 登录后产生若干审计记录(登录本身即审计)
|
||||
w := app.doJSON(http.MethodPost, "/api/v1/auth/admin/login", map[string]string{"username": "root", "password": "Passw0rd"})
|
||||
ck := sessionCookie(t, w)
|
||||
|
||||
// 查询:action 筛选 + 分页
|
||||
w = app.doJSON(http.MethodGet, "/api/v1/audit?action=admin.login&page=1&page_size=10", nil, ck)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("audit list status = %d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
m := decodeBody(t, w)["data"].(map[string]any)
|
||||
if m["total"].(float64) < 1 {
|
||||
t.Fatalf("audit total = %v, want >= 1", m["total"])
|
||||
}
|
||||
if items := m["items"].([]any); len(items) == 0 {
|
||||
t.Fatal("audit items empty")
|
||||
}
|
||||
|
||||
// 非法时间参数 → 400
|
||||
w = app.doJSON(http.MethodGet, "/api/v1/audit?since=not-a-time", nil, ck)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("bad since status = %d, want 400", w.Code)
|
||||
}
|
||||
|
||||
// 未登录 / 外部用户 → 401 / 403
|
||||
w = app.doJSON(http.MethodGet, "/api/v1/audit", nil)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauth audit status = %d, want 401", w.Code)
|
||||
}
|
||||
|
||||
// CSV 导出
|
||||
w = app.doJSON(http.MethodGet, "/api/v1/audit/export", nil, ck)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("export status = %d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
body := w.Body.String()
|
||||
if !strings.HasPrefix(body, "\ufeffid,created_at,") {
|
||||
t.Fatalf("csv body = %q", body[:min(40, len(body))])
|
||||
}
|
||||
if !strings.Contains(body, "admin.login") {
|
||||
t.Fatalf("csv missing rows: %q", body[:min(200, len(body))])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPISettings(t *testing.T) {
|
||||
app := setupTestApp(t)
|
||||
w := app.doJSON(http.MethodPost, "/api/v1/auth/admin/login", map[string]string{"username": "root", "password": "Passw0rd"})
|
||||
ck := sessionCookie(t, w)
|
||||
|
||||
// 未登录 → 401
|
||||
w = app.doJSON(http.MethodGet, "/api/v1/settings", nil)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauth settings status = %d, want 401", w.Code)
|
||||
}
|
||||
|
||||
// 初始值(config 默认)
|
||||
w = app.doJSON(http.MethodGet, "/api/v1/settings", nil, ck)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("settings list status = %d", w.Code)
|
||||
}
|
||||
items := decodeBody(t, w)["data"].(map[string]any)["items"].([]any)
|
||||
if len(items) != 3 {
|
||||
t.Fatalf("settings items = %d, want 3", len(items))
|
||||
}
|
||||
|
||||
// 更新默认有效期
|
||||
w = app.doJSON(http.MethodPut, "/api/v1/settings", map[string]string{"key": "policy.default_ttl", "value": "720h"}, ck)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("settings put status = %d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
// 未知 key → 400
|
||||
w = app.doJSON(http.MethodPut, "/api/v1/settings", map[string]string{"key": "smtp.host", "value": "x"}, ck)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("unknown key status = %d, want 400", w.Code)
|
||||
}
|
||||
// 再次读取:default_ttl 已覆盖
|
||||
w = app.doJSON(http.MethodGet, "/api/v1/settings", nil, ck)
|
||||
items = decodeBody(t, w)["data"].(map[string]any)["items"].([]any)
|
||||
found := false
|
||||
for _, it := range items {
|
||||
item := it.(map[string]any)
|
||||
if item["key"] == "policy.default_ttl" && item["overridden"] == true && item["value"] == "720h" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("settings after put = %v", items)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"ws_usernode/internal/config"
|
||||
"ws_usernode/internal/model"
|
||||
"ws_usernode/internal/service"
|
||||
)
|
||||
|
||||
// ApprovalHandler 新账号申请与审批接口(PLAN F4):
|
||||
// 公开提交(POST /approvals)、管理员列表(GET /approvals)、审批(POST /approvals/:id/review)。
|
||||
type ApprovalHandler struct {
|
||||
svc *service.ApprovalService
|
||||
cfg *config.Config
|
||||
h *Handler // 访问审计 helper
|
||||
}
|
||||
|
||||
// ApprovalSubmitRequest 提交申请(用户名不含 ext_ 前缀)。
|
||||
type ApprovalSubmitRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Email string `json:"email" binding:"required"`
|
||||
Supervisor string `json:"supervisor"`
|
||||
Purpose string `json:"purpose"`
|
||||
}
|
||||
|
||||
// Submit POST /approvals —— 公开提交新账号申请。
|
||||
func (h *ApprovalHandler) Submit(c *gin.Context) {
|
||||
var req ApprovalSubmitRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
fail(c, http.StatusBadRequest, "请求参数不合法: "+err.Error())
|
||||
return
|
||||
}
|
||||
a, err := h.svc.Submit(c.Request.Context(), req.Username, req.Email, req.Supervisor, req.Purpose)
|
||||
if err != nil {
|
||||
h.h.audit(c, "approval.submit", "approval", "", map[string]any{"username": req.Username, "err": err.Error()}, model.ResultFailed)
|
||||
switch {
|
||||
case errors.Is(err, service.ErrUsernameTaken):
|
||||
fail(c, http.StatusConflict, err.Error())
|
||||
default:
|
||||
fail(c, http.StatusBadRequest, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
h.h.audit(c, "approval.submit", "approval", strconv.FormatUint(uint64(a.ID), 10), map[string]any{"username": a.UsernameRequested}, model.ResultSuccess)
|
||||
ok(c, a)
|
||||
}
|
||||
|
||||
// List GET /approvals —— 申请单列表(admin),按状态筛选。
|
||||
func (h *ApprovalHandler) List(c *gin.Context) {
|
||||
rows, err := h.svc.List(c.Request.Context(), c.Query("status"))
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"items": rows})
|
||||
}
|
||||
|
||||
// ApprovalReviewRequest 审批请求:通过 → approve=true;拒绝 → approve=false + 理由。
|
||||
type ApprovalReviewRequest struct {
|
||||
Approve bool `json:"approve"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// Review POST /approvals/:id/review —— 审批(通过 → 自动建号 / 拒绝 + 理由)。
|
||||
func (h *ApprovalHandler) Review(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, "无效的申请单 ID")
|
||||
return
|
||||
}
|
||||
var req ApprovalReviewRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
fail(c, http.StatusBadRequest, "请求参数不合法: "+err.Error())
|
||||
return
|
||||
}
|
||||
reviewerID := uint(0)
|
||||
if sess := sessionFrom(c); sess != nil {
|
||||
reviewerID = sess.RefID
|
||||
}
|
||||
a, err := h.svc.Review(c.Request.Context(), uint(id), req.Approve, reviewerID, req.Reason)
|
||||
if err != nil {
|
||||
h.h.audit(c, "approval.review", "approval", c.Param("id"), map[string]any{"approve": req.Approve, "err": err.Error()}, model.ResultFailed)
|
||||
switch {
|
||||
case errors.Is(err, service.ErrApprovalNotFound):
|
||||
fail(c, http.StatusNotFound, err.Error())
|
||||
case errors.Is(err, service.ErrApprovalReviewed):
|
||||
fail(c, http.StatusConflict, err.Error())
|
||||
case errors.Is(err, service.ErrReasonRequired):
|
||||
fail(c, http.StatusBadRequest, err.Error())
|
||||
case errors.Is(err, service.ErrUsernameTaken):
|
||||
fail(c, http.StatusConflict, err.Error())
|
||||
default:
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
h.h.audit(c, "approval.review", "approval", c.Param("id"), map[string]any{"approve": req.Approve, "reason": req.Reason, "status": a.Status}, model.ResultSuccess)
|
||||
ok(c, a)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"ws_usernode/internal/config"
|
||||
"ws_usernode/internal/service"
|
||||
)
|
||||
|
||||
// AuditHandler 审计接口(admin):查询 + 手动 CSV 导出(PLAN F6)。
|
||||
type AuditHandler struct {
|
||||
svc *service.AuditService
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
// NewAuditHandler 创建审计 handler。
|
||||
func NewAuditHandler(svc *service.AuditService, cfg *config.Config) *AuditHandler {
|
||||
return &AuditHandler{svc: svc, cfg: cfg}
|
||||
}
|
||||
|
||||
// parseTime 解析可选的时间参数(RFC3339)。
|
||||
func parseTime(v string) (*time.Time, error) {
|
||||
if v == "" {
|
||||
return nil, nil
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// List GET /audit —— 审计查询(操作者/动作/资源/时间范围/分页)。
|
||||
func (h *AuditHandler) List(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
since, err := parseTime(c.Query("since"))
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, "since 需为 RFC3339 时间")
|
||||
return
|
||||
}
|
||||
until, err := parseTime(c.Query("until"))
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, "until 需为 RFC3339 时间")
|
||||
return
|
||||
}
|
||||
rows, total, err := h.svc.Query(c.Request.Context(), service.AuditFilter{
|
||||
ActorName: c.Query("actor"),
|
||||
Action: c.Query("action"),
|
||||
ResourceType: c.Query("resource_type"),
|
||||
ResourceID: c.Query("resource_id"),
|
||||
Result: c.Query("result"),
|
||||
Since: since,
|
||||
Until: until,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
})
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"total": total, "items": rows})
|
||||
}
|
||||
|
||||
// Export GET /audit/export —— 手动 CSV 导出(可选时间范围)。
|
||||
func (h *AuditHandler) Export(c *gin.Context) {
|
||||
since, err := parseTime(c.Query("since"))
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, "since 需为 RFC3339 时间")
|
||||
return
|
||||
}
|
||||
until, err := parseTime(c.Query("until"))
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, "until 需为 RFC3339 时间")
|
||||
return
|
||||
}
|
||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Header("Content-Disposition", `attachment; filename="audit-`+time.Now().Format("2006-01-02")+`.csv"`)
|
||||
// 已开始写响应体后无法再返回 JSON 错误;查询失败在此吞掉(导出为管理操作,
|
||||
// 失败由审计归档兜底),正常路径写入 CSV。
|
||||
_ = h.svc.ExportCSV(c.Request.Context(), c.Writer, since, until)
|
||||
}
|
||||
|
||||
// MyAudit GET /me/audit —— 外部用户查看与自己相关的审计痕迹(PLAN §8 个人中心)。
|
||||
func (h *AuditHandler) MyAudit(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
sess := sessionFrom(c)
|
||||
if sess == nil {
|
||||
fail(c, http.StatusUnauthorized, "未登录")
|
||||
return
|
||||
}
|
||||
rows, total, err := h.svc.QueryByUser(c.Request.Context(), sess.RefID, page, pageSize)
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"total": total, "items": rows})
|
||||
}
|
||||
+13
-3
@@ -1,6 +1,8 @@
|
||||
// Package api 为 HTTP handler 层(RESTful v1)。
|
||||
// M1 覆盖认证(管理员/外部用户登录、会话、OTP)与用户管理 CRUD;
|
||||
// M2 覆盖 SSH 公钥管理(上传/重命名/吊销/列表)。
|
||||
// M2 覆盖 SSH 公钥管理(上传/重命名/吊销/列表);
|
||||
// M3 覆盖申请审批(公开提交 + 管理员审批 + 邮件通知);
|
||||
// M4 覆盖审计查询/导出与系统设置。
|
||||
package api
|
||||
|
||||
import (
|
||||
@@ -22,13 +24,17 @@ type Handler struct {
|
||||
Auth *AuthHandler
|
||||
User *UserHandler
|
||||
Key *KeyHandler
|
||||
Approval *ApprovalHandler
|
||||
Audit *AuditHandler
|
||||
Settings *SettingsHandler
|
||||
Stats *StatsHandler
|
||||
|
||||
authSvc *service.AuthService
|
||||
auditSvc *service.AuditService
|
||||
}
|
||||
|
||||
// New 创建 handler 集合。
|
||||
func New(cfg *config.Config, authSvc *service.AuthService, userSvc *service.UserService, keySvc *service.KeyService, auditSvc *service.AuditService) *Handler {
|
||||
func New(cfg *config.Config, authSvc *service.AuthService, userSvc *service.UserService, keySvc *service.KeyService, approvalSvc *service.ApprovalService, auditSvc *service.AuditService, settingsSvc *service.SettingService) *Handler {
|
||||
h := &Handler{
|
||||
Health: &HealthHandler{startedAt: time.Now()},
|
||||
authSvc: authSvc,
|
||||
@@ -37,6 +43,10 @@ func New(cfg *config.Config, authSvc *service.AuthService, userSvc *service.User
|
||||
h.Auth = &AuthHandler{svc: authSvc, cfg: cfg}
|
||||
h.User = &UserHandler{svc: userSvc, cfg: cfg, h: h}
|
||||
h.Key = &KeyHandler{svc: keySvc, cfg: cfg, h: h}
|
||||
h.Approval = &ApprovalHandler{svc: approvalSvc, cfg: cfg, h: h}
|
||||
h.Audit = NewAuditHandler(auditSvc, cfg)
|
||||
h.Settings = NewSettingsHandler(settingsSvc)
|
||||
h.Stats = NewStatsHandler(userSvc)
|
||||
return h
|
||||
}
|
||||
|
||||
@@ -63,7 +73,7 @@ type HealthHandler struct {
|
||||
func (h *HealthHandler) Healthz(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
"version": "0.3.0-m2",
|
||||
"version": "0.3.0-m5",
|
||||
"uptime": time.Since(h.startedAt).String(),
|
||||
"go": runtime.Version(),
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAPIStats 仪表盘统计:admin 登录后 /admin/stats 返回各状态数与待审批数。
|
||||
func TestAPIStats(t *testing.T) {
|
||||
app := setupTestApp(t)
|
||||
|
||||
w := app.doJSON(http.MethodGet, "/api/v1/admin/stats", nil)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauth stats status = %d, want 401", w.Code)
|
||||
}
|
||||
|
||||
w = app.doJSON(http.MethodPost, "/api/v1/auth/admin/login", map[string]string{"username": "root", "password": "Passw0rd"})
|
||||
ck := sessionCookie(t, w)
|
||||
|
||||
// 建 1 个用户 + 1 个待审批申请,stats 应反映
|
||||
w = app.doJSON(http.MethodPost, "/api/v1/users", map[string]any{
|
||||
"username": "m5stats", "email": "m5stats@example.com",
|
||||
"supervisor": "profA", "purpose": "test",
|
||||
}, ck)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("create user status = %d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
w = app.doJSON(http.MethodPost, "/api/v1/approvals", map[string]any{
|
||||
"username": "m5apply", "email": "m5apply@example.com",
|
||||
"supervisor": "profB", "purpose": "apply test",
|
||||
})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("submit approval status = %d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
w = app.doJSON(http.MethodGet, "/api/v1/admin/stats", nil, ck)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("stats status = %d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
m := decodeBody(t, w)
|
||||
data := m["data"].(map[string]any)
|
||||
if data["total"].(float64) != 1 {
|
||||
t.Fatalf("stats total = %v, want 1", data["total"])
|
||||
}
|
||||
if data["pending_approvals"].(float64) != 1 {
|
||||
t.Fatalf("pending_approvals = %v, want 1", data["pending_approvals"])
|
||||
}
|
||||
if _, ok := data["expiring_soon"]; !ok {
|
||||
t.Fatalf("stats missing expiring_soon: %v", data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPIMeAudit 外部用户"我的审计":登录后可查自己的审计痕迹,管理端接口拒绝 user 会话。
|
||||
func TestAPIMeAudit(t *testing.T) {
|
||||
app := setupTestApp(t)
|
||||
|
||||
// 建用户 + 通过 OTP 登录(复用 m3 流程)
|
||||
adminW := app.doJSON(http.MethodPost, "/api/v1/auth/admin/login", map[string]string{"username": "root", "password": "Passw0rd"})
|
||||
ack := sessionCookie(t, adminW)
|
||||
adminW = app.doJSON(http.MethodPost, "/api/v1/users", map[string]any{
|
||||
"username": "meaudit", "email": "meaudit@example.com",
|
||||
}, ack)
|
||||
if adminW.Code != http.StatusOK {
|
||||
t.Fatalf("create user status = %d, body=%s", adminW.Code, adminW.Body.String())
|
||||
}
|
||||
|
||||
// OTP 双通道:经 store 取验证码(模拟用户看到图形验证码)+ 登录
|
||||
|
||||
// OTP 双通道:经 store 取验证码(模拟用户看到图形验证码)+ 登录
|
||||
cap, err := app.captchas.New()
|
||||
if err != nil {
|
||||
t.Fatalf("captcha new: %v", err)
|
||||
}
|
||||
sendW := app.doJSON(http.MethodPost, "/api/v1/auth/otp/send", map[string]string{
|
||||
"username": "meaudit", "captcha_id": cap.ID, "captcha_code": cap.Text,
|
||||
})
|
||||
if sendW.Code != http.StatusOK {
|
||||
t.Fatalf("otp send status = %d, body=%s", sendW.Code, sendW.Body.String())
|
||||
}
|
||||
code, err := app.otps.Current(context.Background(), "ext_meaudit")
|
||||
if err != nil {
|
||||
t.Fatalf("get otp: %v", err)
|
||||
}
|
||||
loginW := app.doJSON(http.MethodPost, "/api/v1/auth/otp/login", map[string]string{"username": "meaudit", "code": code})
|
||||
if loginW.Code != http.StatusOK {
|
||||
t.Fatalf("otp login status = %d, body=%s", loginW.Code, loginW.Body.String())
|
||||
}
|
||||
uck := sessionCookie(t, loginW)
|
||||
|
||||
// 用户访问管理端审计 → 403
|
||||
w := app.doJSON(http.MethodGet, "/api/v1/audit", nil, uck)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("user access admin audit status = %d, want 403", w.Code)
|
||||
}
|
||||
|
||||
// 我的审计:登录成功等操作应可见
|
||||
w = app.doJSON(http.MethodGet, "/api/v1/me/audit", nil, uck)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("me/audit status = %d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
m := decodeBody(t, w)
|
||||
data := m["data"].(map[string]any)
|
||||
if data["total"].(float64) < 1 {
|
||||
t.Fatalf("me/audit total = %v, want >= 1", data["total"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"ws_usernode/internal/service"
|
||||
)
|
||||
|
||||
// SettingsHandler 系统设置接口(admin,PLAN F7)。
|
||||
type SettingsHandler struct {
|
||||
svc *service.SettingService
|
||||
}
|
||||
|
||||
// NewSettingsHandler 创建设置 handler。
|
||||
func NewSettingsHandler(svc *service.SettingService) *SettingsHandler {
|
||||
return &SettingsHandler{svc: svc}
|
||||
}
|
||||
|
||||
// List GET /settings —— 全部设置项(config 默认值 + settings 覆盖)。
|
||||
func (h *SettingsHandler) List(c *gin.Context) {
|
||||
items, err := h.svc.GetAll(c.Request.Context())
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
// SettingUpdateRequest 更新单个设置项。
|
||||
type SettingUpdateRequest struct {
|
||||
Key string `json:"key" binding:"required"`
|
||||
Value string `json:"value" binding:"required"`
|
||||
}
|
||||
|
||||
// Update PUT /settings —— 更新设置项(仅白名单 key,值为时长格式)。
|
||||
func (h *SettingsHandler) Update(c *gin.Context) {
|
||||
var req SettingUpdateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
fail(c, http.StatusBadRequest, "请求参数不合法: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.svc.Set(c.Request.Context(), req.Key, req.Value); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrSettingKeyUnknown):
|
||||
fail(c, http.StatusBadRequest, err.Error())
|
||||
case errors.Is(err, service.ErrSettingValueInvalid):
|
||||
fail(c, http.StatusBadRequest, err.Error())
|
||||
default:
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"status": "updated", "key": req.Key, "value": req.Value})
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// 仪表盘统计接口(admin):GET /admin/stats(PLAN §8 仪表盘数据)。
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"ws_usernode/internal/service"
|
||||
)
|
||||
|
||||
// StatsHandler 仪表盘统计。
|
||||
type StatsHandler struct {
|
||||
userSvc *service.UserService
|
||||
}
|
||||
|
||||
// NewStatsHandler 创建统计 handler。
|
||||
func NewStatsHandler(userSvc *service.UserService) *StatsHandler {
|
||||
return &StatsHandler{userSvc: userSvc}
|
||||
}
|
||||
|
||||
// Stats GET /admin/stats —— 用户状态分布、近期待过期、待审批数。
|
||||
func (h *StatsHandler) Stats(c *gin.Context) {
|
||||
st, err := h.userSvc.Stats(c.Request.Context())
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
ok(c, st)
|
||||
}
|
||||
+3
-10
@@ -179,21 +179,14 @@ func (h *UserHandler) Extend(c *gin.Context) {
|
||||
fail(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.svc.Extend(c.Request.Context(), uint(id), req.Days); err != nil {
|
||||
newExpire, err := h.svc.Extend(c.Request.Context(), uint(id), req.Days)
|
||||
if err != nil {
|
||||
h.h.audit(c, "user.extend", "user", c.Param("id"), map[string]any{"days": req.Days, "err": err.Error()}, model.ResultFailed)
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
h.h.audit(c, "user.extend", "user", c.Param("id"), map[string]any{"days": req.Days, "old_status": u.Status}, model.ResultSuccess)
|
||||
ok(c, gin.H{"expire_at": time.Now().Add(h.extendTTL(req.Days)).UTC()})
|
||||
}
|
||||
|
||||
// extendTTL 计算新的有效期(与 service 保持一致:days<=0 用默认)。
|
||||
func (h *UserHandler) extendTTL(days int) time.Duration {
|
||||
if days <= 0 {
|
||||
return h.cfg.Policy.DefaultTTL
|
||||
}
|
||||
return time.Duration(days) * 24 * time.Hour
|
||||
ok(c, gin.H{"expire_at": newExpire.UTC()})
|
||||
}
|
||||
|
||||
// Delete DELETE /users/:id —— 删除并回收(系统账号 + 家目录 + 密钥,保留审计)。
|
||||
|
||||
@@ -31,6 +31,7 @@ type Config struct {
|
||||
Auth AuthConfig `toml:"auth"`
|
||||
SMTP SMTPConfig `toml:"smtp"`
|
||||
System SystemConfig `toml:"system"`
|
||||
Audit AuditConfig `toml:"audit"`
|
||||
}
|
||||
|
||||
type AppConfig struct {
|
||||
@@ -90,6 +91,11 @@ type SystemConfig struct {
|
||||
AuthorizedKeysDir string `toml:"authorized_keys_dir"` // authorized_keys 所在目录(测试可覆盖)
|
||||
}
|
||||
|
||||
// AuditConfig 审计保留与归档配置。
|
||||
type AuditConfig struct {
|
||||
ArchiveDir string `toml:"archive_dir"` // 每日归档目录(空 = 不归档也不自动清理,防止丢审计)
|
||||
}
|
||||
|
||||
// Default 返回带开发环境默认值的配置,作为 config.example.toml 与未配置项的兜底。
|
||||
func Default() *Config {
|
||||
return &Config{
|
||||
@@ -114,6 +120,10 @@ func Default() *Config {
|
||||
CaptchaTTL: 5 * time.Minute,
|
||||
},
|
||||
SMTP: SMTPConfig{Port: 587},
|
||||
Audit: AuditConfig{
|
||||
// 开发默认不归档(避免在任意目录落文件);生产显式配置 archive_dir 启用归档。
|
||||
ArchiveDir: "",
|
||||
},
|
||||
System: SystemConfig{
|
||||
// 开发默认 dry-run:未配置 config 直接跑 serve 时只打印计划,避免误操作系统账号。
|
||||
// 生产必须显式 dry_run=false 且 sudo=true(见 deploy/sudoers.example)。
|
||||
|
||||
+64
-12
@@ -1,31 +1,83 @@
|
||||
// Package cron 提供定时任务:过期扫描、回收、审计归档。
|
||||
// M0 注册任务框架(任务函数在 M1/M4 接入),单实例部署说明见 PLAN §11。
|
||||
// Package cron 提供定时任务:邮件重试、过期扫描、回收、审计归档。
|
||||
// M0 注册任务框架;M3 接入邮件失败重试;M4 接入生命周期(到期锁定/回收)
|
||||
// 与审计归档;单实例部署说明见 PLAN §11。
|
||||
package cron
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"ws_usernode/internal/config"
|
||||
"ws_usernode/internal/mail"
|
||||
"ws_usernode/internal/service"
|
||||
)
|
||||
|
||||
// Jobs 聚合定时任务依赖与注册。
|
||||
type Jobs struct {
|
||||
db *gorm.DB
|
||||
cfg *config.Config
|
||||
mailer mail.Retryable
|
||||
lifecycle *service.LifecycleService
|
||||
audit *service.AuditService
|
||||
settings *service.SettingService
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New 创建定时任务集合。
|
||||
func New(db *gorm.DB, log *slog.Logger) *Jobs {
|
||||
return &Jobs{db: db, log: log}
|
||||
// mailer 为邮件队列(失败重试,M3);lifecycle 为账号生命周期(M4);
|
||||
// audit/settings 用于每日审计归档(M4)。
|
||||
func New(cfg *config.Config, mailer mail.Retryable, lifecycle *service.LifecycleService, audit *service.AuditService, settings *service.SettingService, log *slog.Logger) *Jobs {
|
||||
return &Jobs{cfg: cfg, mailer: mailer, lifecycle: lifecycle, audit: audit, settings: settings, log: log}
|
||||
}
|
||||
|
||||
// Register 将任务注册到 cron。骨架阶段注册空任务,M1/M4 填充实现。
|
||||
// Register 将任务注册到 cron。
|
||||
func (j *Jobs) Register(c *cron.Cron) {
|
||||
// 过期扫描(M4):每日扫描到期账号,锁系统账号 + 邮件通知
|
||||
// 回收任务(M4):超回收期账号自动回收(userdel + 保留审计)
|
||||
// 审计归档(M4):每日将过期审计记录导出归档后清理
|
||||
c.AddFunc("@daily", func() {
|
||||
j.log.Info("cron: daily maintenance tick (tasks to be implemented in M1/M4)")
|
||||
// 邮件失败重试(M3):每 5 分钟重试失败邮件(超出 mail.MaxRetries 停止)
|
||||
c.AddFunc("@every 5m", func() {
|
||||
if err := j.mailer.Retry(context.Background(), mail.MaxRetries); err != nil {
|
||||
j.log.Error("cron: mail retry failed", "err", err)
|
||||
}
|
||||
})
|
||||
|
||||
// 每日维护:过期扫描 → 回收 → 审计归档(M4)
|
||||
c.AddFunc("@daily", j.dailyMaintenance)
|
||||
|
||||
// 到期扫描额外每 6 小时跑一次:避免错过当日到期账号
|
||||
c.AddFunc("@every 6h", j.scanExpired)
|
||||
}
|
||||
|
||||
// dailyMaintenance 每日一次:到期扫描、超期回收、审计归档。
|
||||
func (j *Jobs) dailyMaintenance() {
|
||||
ctx := context.Background()
|
||||
j.scanExpired()
|
||||
if n, err := j.lifecycle.Recycle(ctx); err != nil {
|
||||
j.log.Error("cron: recycle failed", "err", err)
|
||||
} else if n > 0 {
|
||||
j.log.Info("cron: recycled accounts", "count", n)
|
||||
}
|
||||
j.archiveAudit()
|
||||
}
|
||||
|
||||
// scanExpired 到期扫描:锁定系统账号 + 邮件通知。
|
||||
func (j *Jobs) scanExpired() {
|
||||
ctx := context.Background()
|
||||
if n, err := j.lifecycle.ScanExpired(ctx); err != nil {
|
||||
j.log.Error("cron: expire scan failed", "err", err)
|
||||
} else if n > 0 {
|
||||
j.log.Info("cron: expired accounts locked", "count", n)
|
||||
}
|
||||
}
|
||||
|
||||
// archiveAudit 审计归档:将超过保留期的记录归档后清理(archive_dir 未配置时跳过)。
|
||||
func (j *Jobs) archiveAudit() {
|
||||
ctx := context.Background()
|
||||
retention := j.settings.AuditRetention(ctx)
|
||||
before := time.Now().Add(-retention)
|
||||
if n, err := j.audit.Archive(ctx, j.cfg.Audit.ArchiveDir, before); err != nil {
|
||||
j.log.Error("cron: audit archive failed", "err", err)
|
||||
} else if n > 0 {
|
||||
j.log.Info("cron: audit archived", "count", n, "before", before.Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Package mail 提供邮件发送抽象。M1 提供基础直发(net/smtp + STARTTLS),
|
||||
// 发送队列/重试/失败记录(mail_logs)在 M3 完善;SMTP 未配置时退化为
|
||||
// LogMailer(仅打印,不阻断业务——OTP 邮件失败不阻断 CLI 通道)。
|
||||
// Package mail 提供邮件发送抽象。M1 提供基础直发(net/smtp + STARTTLS);
|
||||
// M3 引入 QueuedMailer(mail_logs 队列 + 失败重试,见 queue.go),
|
||||
// SMTP 未配置时退化为 LogMailer(仅打印,不阻断业务——OTP 邮件失败不阻断 CLI 通道)。
|
||||
package mail
|
||||
|
||||
import (
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"ws_usernode/internal/model"
|
||||
)
|
||||
|
||||
// 邮件状态(mail_logs.status)。
|
||||
const (
|
||||
StatusPending = "pending" // 已入队,等待直发
|
||||
StatusSent = "sent" // 发送成功
|
||||
StatusFailed = "failed" // 发送失败,等待重试
|
||||
)
|
||||
|
||||
// MaxRetries 单封邮件最大重试次数;超出后停止重试,保留失败记录(PLAN F5)。
|
||||
const MaxRetries = 5
|
||||
|
||||
// Retryable 可由 cron 定时调用的邮件队列重试接口。
|
||||
type Retryable interface {
|
||||
Retry(ctx context.Context, limit int) error
|
||||
}
|
||||
|
||||
// QueuedMailer 邮件队列实现(PLAN F5):每封邮件先落 mail_logs 再同步尝试直发;
|
||||
// 失败记录 error 与 retry_count,由 cron 定时重试;成功标记 sent。
|
||||
// Send 恒返回 nil(除 DB 落库失败):邮件失败不阻断业务
|
||||
// (OTP 双通道、审批结果等,见 AuthService.UserOTPSend 的既有约定)。
|
||||
type QueuedMailer struct {
|
||||
base Mailer
|
||||
db *gorm.DB
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewQueuedMailer 创建邮件队列,包装底层直发实现(SMTP 或 LogMailer)。
|
||||
func NewQueuedMailer(base Mailer, db *gorm.DB, log *slog.Logger) *QueuedMailer {
|
||||
return &QueuedMailer{base: base, db: db, log: log}
|
||||
}
|
||||
|
||||
// Send 入队并尝试直发。返回 nil 表示已受理;实际送达与否见 mail_logs。
|
||||
func (m *QueuedMailer) Send(ctx context.Context, to, subject, body string) error {
|
||||
entry := model.MailLog{To: to, Subject: subject, Body: body, Status: StatusPending}
|
||||
if err := m.db.WithContext(ctx).Create(&entry).Error; err != nil {
|
||||
m.log.Error("mail: 记录 mail_log 失败", "err", err)
|
||||
return err
|
||||
}
|
||||
if err := m.base.Send(ctx, to, subject, body); err != nil {
|
||||
_ = m.db.WithContext(ctx).Model(&entry).Updates(map[string]any{
|
||||
"status": StatusFailed, "error": err.Error(), "retry_count": 1,
|
||||
}).Error
|
||||
m.log.Warn("mail: 直发失败,进入重试队列", "to", to, "subject", subject, "err", err)
|
||||
return nil // 非阻断
|
||||
}
|
||||
_ = m.db.WithContext(ctx).Model(&entry).Update("status", StatusSent).Error
|
||||
return nil
|
||||
}
|
||||
|
||||
// Retry 重试失败邮件(retry_count < limit,按原内容重发)。成功标记 sent,
|
||||
// 失败累加 retry_count 并刷新 error;达到上限后不再挑选,保留失败记录待人工处理。
|
||||
func (m *QueuedMailer) Retry(ctx context.Context, limit int) error {
|
||||
if limit <= 0 {
|
||||
return nil
|
||||
}
|
||||
var rows []model.MailLog
|
||||
if err := m.db.WithContext(ctx).
|
||||
Where("status = ? AND retry_count < ?", StatusFailed, limit).
|
||||
Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, r := range rows {
|
||||
if err := m.base.Send(ctx, r.To, r.Subject, r.Body); err != nil {
|
||||
_ = m.db.WithContext(ctx).Model(&model.MailLog{}).Where("id = ?", r.ID).Updates(map[string]any{
|
||||
"retry_count": r.RetryCount + 1, "error": err.Error(),
|
||||
}).Error
|
||||
m.log.Warn("mail: 重试仍失败", "id", r.ID, "to", r.To, "retry_count", r.RetryCount+1, "err", err)
|
||||
continue
|
||||
}
|
||||
_ = m.db.WithContext(ctx).Model(&model.MailLog{}).Where("id = ?", r.ID).Update("status", StatusSent).Error
|
||||
m.log.Info("mail: 重试成功", "id", r.ID, "to", r.To)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"ws_usernode/internal/model"
|
||||
)
|
||||
|
||||
// flakyMailer 前 n 次发送失败,之后成功;用于验证重试。
|
||||
type flakyMailer struct {
|
||||
failures int
|
||||
calls int
|
||||
}
|
||||
|
||||
var errFlaky = errors.New("mail: smtp down")
|
||||
|
||||
func (m *flakyMailer) Send(_ context.Context, _, _, _ string) error {
|
||||
m.calls++
|
||||
if m.calls <= m.failures {
|
||||
return errFlaky
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// recordingMailer 记录直发调用次数。
|
||||
type recordingMailer struct{ calls int }
|
||||
|
||||
func (m *recordingMailer) Send(context.Context, string, string, string) error {
|
||||
m.calls++
|
||||
return nil
|
||||
}
|
||||
|
||||
func testQueued(t *testing.T, base Mailer) (*QueuedMailer, *gorm.DB) {
|
||||
t.Helper()
|
||||
db, err := model.Open("sqlite", ":memory:", false)
|
||||
if err != nil {
|
||||
t.Fatalf("open test db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.MailLog{}); err != nil {
|
||||
t.Fatalf("migrate mail_logs: %v", err)
|
||||
}
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
return NewQueuedMailer(base, db, log), db
|
||||
}
|
||||
|
||||
func TestQueuedMailerSendSuccess(t *testing.T) {
|
||||
rec := &recordingMailer{}
|
||||
q, db := testQueued(t, rec)
|
||||
if err := q.Send(context.Background(), "a@example.com", "主题", "正文"); err != nil {
|
||||
t.Fatalf("send: %v", err)
|
||||
}
|
||||
if rec.calls != 1 {
|
||||
t.Fatalf("base send calls = %d, want 1", rec.calls)
|
||||
}
|
||||
var n int64
|
||||
if err := db.Model(&model.MailLog{}).Where("status = ?", StatusSent).Count(&n).Error; err != nil {
|
||||
t.Fatalf("count sent: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("sent = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueuedMailerFailureQueuedAndRetry(t *testing.T) {
|
||||
base := &flakyMailer{failures: 2}
|
||||
q, db := testQueued(t, base)
|
||||
|
||||
// 第一次发送失败:Send 仍返回 nil(不阻断业务),落 failed 记录
|
||||
if err := q.Send(context.Background(), "b@example.com", "s", "body"); err != nil {
|
||||
t.Fatalf("send returned error: %v", err)
|
||||
}
|
||||
if base.calls != 1 {
|
||||
t.Fatalf("base calls = %d, want 1", base.calls)
|
||||
}
|
||||
var row model.MailLog
|
||||
if err := db.First(&row, "status = ?", StatusFailed).Error; err != nil {
|
||||
t.Fatalf("failed row: %v", err)
|
||||
}
|
||||
if row.RetryCount != 1 || row.Error != errFlaky.Error() {
|
||||
t.Fatalf("failed row = %+v", row)
|
||||
}
|
||||
|
||||
// Retry 1:仍失败 → retry_count=2;Retry 2:成功 → sent
|
||||
if err := q.Retry(context.Background(), 5); err != nil {
|
||||
t.Fatalf("retry: %v", err)
|
||||
}
|
||||
if base.calls != 2 {
|
||||
t.Fatalf("calls after retry1 = %d, want 2", base.calls)
|
||||
}
|
||||
if err := q.Retry(context.Background(), 5); err != nil {
|
||||
t.Fatalf("retry2: %v", err)
|
||||
}
|
||||
if base.calls != 3 {
|
||||
t.Fatalf("calls after retry2 = %d, want 3", base.calls)
|
||||
}
|
||||
var n int64
|
||||
if err := db.Model(&model.MailLog{}).Where("status = ?", StatusSent).Count(&n).Error; err != nil {
|
||||
t.Fatalf("count sent: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("sent = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueuedMailerRetryHitsMax(t *testing.T) {
|
||||
base := &flakyMailer{failures: 100} // 永远失败
|
||||
q, db := testQueued(t, base)
|
||||
if err := q.Send(context.Background(), "c@example.com", "s", "body"); err != nil {
|
||||
t.Fatalf("send: %v", err)
|
||||
}
|
||||
// 达到上限后 Retry 不再挑选(retry_count 不再增长)
|
||||
for i := 0; i < 10; i++ {
|
||||
if err := q.Retry(context.Background(), MaxRetries); err != nil {
|
||||
t.Fatalf("retry: %v", err)
|
||||
}
|
||||
}
|
||||
var row model.MailLog
|
||||
if err := db.First(&row, "status = ?", StatusFailed).Error; err != nil {
|
||||
t.Fatalf("failed row: %v", err)
|
||||
}
|
||||
if row.RetryCount > MaxRetries {
|
||||
t.Fatalf("retry_count = %d, want <= %d", row.RetryCount, MaxRetries)
|
||||
}
|
||||
}
|
||||
@@ -130,10 +130,12 @@ type Setting struct {
|
||||
}
|
||||
|
||||
// MailLog 邮件发送记录(队列 + 重试 + 失败记录)。
|
||||
// Body 保存原文以便失败后按原内容重试(队列的载荷),仅用于重发,不对外展示。
|
||||
type MailLog struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
To string `gorm:"size:255;index;not null" json:"to"`
|
||||
Subject string `gorm:"size:255" json:"subject"`
|
||||
Body string `gorm:"type:text" json:"-"`
|
||||
Status string `gorm:"size:16;not null;default:pending;index" json:"status"`
|
||||
Error string `gorm:"type:text" json:"error"`
|
||||
RetryCount int `gorm:"not null;default:0" json:"retry_count"`
|
||||
|
||||
+28
-16
@@ -3,7 +3,6 @@ package router
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -64,16 +63,36 @@ func New(cfg *config.Config, h *api.Handler, sessions auth.SessionStore, log *sl
|
||||
me.POST("/keys", h.Key.Create)
|
||||
me.PATCH("/keys/:id", h.Key.Rename)
|
||||
me.DELETE("/keys/:id", h.Key.Revoke)
|
||||
me.GET("/audit", h.Audit.MyAudit) // 我的审计痕迹(个人中心)
|
||||
}
|
||||
|
||||
// 后续里程碑
|
||||
v1.POST("/approvals", notImplemented("提交申请(M3)"))
|
||||
v1.GET("/approvals", notImplemented("申请列表(M3)"))
|
||||
v1.POST("/approvals/:id/review", notImplemented("审批(M3)"))
|
||||
v1.GET("/audit", notImplemented("审计查询(M4)"))
|
||||
v1.GET("/audit/export", notImplemented("审计导出(M4)"))
|
||||
v1.GET("/settings", notImplemented("设置(M4)"))
|
||||
v1.PUT("/settings", notImplemented("设置(M4)"))
|
||||
// 申请审批(M3):提交公开;列表与审批需管理员会话
|
||||
v1.POST("/approvals", h.Approval.Submit)
|
||||
approvals := v1.Group("/approvals", sessionMiddleware(sessions), requireUserType(auth.SessionUserAdmin))
|
||||
{
|
||||
approvals.GET("", h.Approval.List)
|
||||
approvals.POST("/:id/review", h.Approval.Review)
|
||||
}
|
||||
|
||||
// 审计(M4):查询 + 手动 CSV 导出(admin)
|
||||
auditGrp := v1.Group("/audit", sessionMiddleware(sessions), requireUserType(auth.SessionUserAdmin))
|
||||
{
|
||||
auditGrp.GET("", h.Audit.List)
|
||||
auditGrp.GET("/export", h.Audit.Export)
|
||||
}
|
||||
|
||||
// 系统设置(M4):读写(admin)
|
||||
settings := v1.Group("/settings", sessionMiddleware(sessions), requireUserType(auth.SessionUserAdmin))
|
||||
{
|
||||
settings.GET("", h.Settings.List)
|
||||
settings.PUT("", h.Settings.Update)
|
||||
}
|
||||
|
||||
// 仪表盘统计(admin):用户状态分布 / 近期待过期 / 待审批数
|
||||
adminGrp := v1.Group("/admin", sessionMiddleware(sessions), requireUserType(auth.SessionUserAdmin))
|
||||
{
|
||||
adminGrp.GET("/stats", h.Stats.Stats)
|
||||
}
|
||||
}
|
||||
|
||||
// 前端静态资源(go:embed;dev 阶段由 Vite dev server 代理,见 Makefile dev)
|
||||
@@ -82,13 +101,6 @@ func New(cfg *config.Config, h *api.Handler, sessions auth.SessionStore, log *sl
|
||||
return r
|
||||
}
|
||||
|
||||
// notImplemented 返回 501 占位 handler,标注里程碑。
|
||||
func notImplemented(what string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": "接口 " + what + " 尚未实现"})
|
||||
}
|
||||
}
|
||||
|
||||
// requestLogger 以 slog 输出结构化请求日志。
|
||||
func requestLogger(log *slog.Logger) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"ws_usernode/internal/config"
|
||||
"ws_usernode/internal/mail"
|
||||
"ws_usernode/internal/model"
|
||||
"ws_usernode/internal/pkg"
|
||||
)
|
||||
|
||||
// 审批服务错误。
|
||||
var (
|
||||
ErrApprovalNotFound = errors.New("service: 申请单不存在")
|
||||
ErrApprovalReviewed = errors.New("service: 申请单已审批,不能重复操作")
|
||||
ErrUsernameTaken = errors.New("service: 用户名已被占用或已有待审批申请")
|
||||
ErrReasonRequired = errors.New("service: 拒绝申请必须填写理由")
|
||||
)
|
||||
|
||||
// ApprovalService 新账号申请与审批(PLAN F4):公开提交申请单;管理员审批
|
||||
// 通过 → 自动创建账号(复用 UserService.Create 的建号与回滚逻辑)并邮件通知,
|
||||
// 拒绝 → 记录理由并邮件通知;被拒后可重新提交(重复检查仅针对待审批单)。
|
||||
type ApprovalService struct {
|
||||
db *gorm.DB
|
||||
cfg *config.Config
|
||||
users *UserService
|
||||
mailer mail.Mailer
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewApprovalService 创建审批服务。
|
||||
func NewApprovalService(db *gorm.DB, cfg *config.Config, users *UserService, mailer mail.Mailer, log *slog.Logger) *ApprovalService {
|
||||
return &ApprovalService{db: db, cfg: cfg, users: users, mailer: mailer, log: log}
|
||||
}
|
||||
|
||||
// Submit 提交新账号申请(公开接口,无需登录)。校验用户名/邮箱格式,
|
||||
// 并拒绝已被占用(系统已存在用户)或已有待审批申请的同名申请。
|
||||
func (s *ApprovalService) Submit(ctx context.Context, username, email, supervisor, purpose string) (*model.Approval, error) {
|
||||
username = strings.TrimSpace(username)
|
||||
if err := pkg.ValidateUserName(username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := pkg.ValidateEmail(email); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.usernameAvailable(ctx, username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a := &model.Approval{
|
||||
UsernameRequested: username,
|
||||
Email: strings.TrimSpace(email),
|
||||
Supervisor: strings.TrimSpace(supervisor),
|
||||
Purpose: strings.TrimSpace(purpose),
|
||||
Status: model.StatusPending,
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Create(a).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// List 申请单列表(admin),可按状态筛选,最新在前。
|
||||
func (s *ApprovalService) List(ctx context.Context, status string) ([]model.Approval, error) {
|
||||
q := s.db.WithContext(ctx).Model(&model.Approval{})
|
||||
if status != "" {
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
var rows []model.Approval
|
||||
if err := q.Order("id DESC").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// Review 审批申请单。通过 → 自动创建账号(默认 TTL)并邮件通知申请人;
|
||||
// 拒绝 → 记录理由并邮件通知。已审批的单不可重复操作。
|
||||
func (s *ApprovalService) Review(ctx context.Context, id uint, approve bool, reviewerID uint, reason string) (*model.Approval, error) {
|
||||
var a model.Approval
|
||||
if err := s.db.WithContext(ctx).First(&a, "id = ?", id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrApprovalNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if a.Status != model.StatusPending {
|
||||
return nil, ErrApprovalReviewed
|
||||
}
|
||||
now := time.Now()
|
||||
if approve {
|
||||
// 通过前再次校验用户名:审批期间可能已被管理员手动创建(只查用户表,
|
||||
// 不查待审批单——同名待审批在提交时已拦截,当前单本身即该用户名的待审批单)。
|
||||
var n int64
|
||||
if err := s.db.WithContext(ctx).Model(&model.User{}).
|
||||
Where("username = ?", s.cfg.System.UserPrefix+a.UsernameRequested).Count(&n).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n > 0 {
|
||||
return nil, ErrUsernameTaken
|
||||
}
|
||||
u, err := s.users.Create(ctx, a.UsernameRequested, a.Email, a.Supervisor, a.Purpose, 0, reviewerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Model(&model.Approval{}).Where("id = ?", id).Updates(map[string]any{
|
||||
"status": model.StatusApproved, "reviewer_id": reviewerID, "reviewed_at": &now,
|
||||
}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.Status = model.StatusApproved
|
||||
a.ReviewerID = &reviewerID
|
||||
a.ReviewedAt = &now
|
||||
s.notifyApproved(ctx, &a, u)
|
||||
return &a, nil
|
||||
}
|
||||
reason = strings.TrimSpace(reason)
|
||||
if reason == "" {
|
||||
return nil, ErrReasonRequired
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Model(&model.Approval{}).Where("id = ?", id).Updates(map[string]any{
|
||||
"status": model.StatusRejected, "reviewer_id": reviewerID, "reviewed_at": &now, "reason": reason,
|
||||
}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.Status = model.StatusRejected
|
||||
a.ReviewerID = &reviewerID
|
||||
a.ReviewedAt = &now
|
||||
a.Reason = reason
|
||||
s.notifyRejected(ctx, &a)
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
// usernameAvailable 校验用户名未被现有用户占用且无同名待审批申请。
|
||||
func (s *ApprovalService) usernameAvailable(ctx context.Context, username string) error {
|
||||
full := s.cfg.System.UserPrefix + username
|
||||
var n int64
|
||||
if err := s.db.WithContext(ctx).Model(&model.User{}).Where("username = ?", full).Count(&n).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
return ErrUsernameTaken
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Model(&model.Approval{}).
|
||||
Where("username_requested = ? AND status = ?", username, model.StatusPending).Count(&n).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
return ErrUsernameTaken
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// notifyApproved 通知申请人账号已创建(审批结果 + 账号创建通知,PLAN F5)。
|
||||
// 邮件失败仅记日志,不阻断审批流程(经 QueuedMailer 时已落 mail_logs 可重试)。
|
||||
func (s *ApprovalService) notifyApproved(ctx context.Context, a *model.Approval, u *model.User) {
|
||||
expire := "长期"
|
||||
if u.ExpireAt != nil {
|
||||
expire = u.ExpireAt.Format("2006-01-02 15:04 MST")
|
||||
}
|
||||
body := fmt.Sprintf(`您的服务器账号申请已通过审批,账号已创建。
|
||||
|
||||
账号信息:
|
||||
用户名:%s
|
||||
有效期至:%s
|
||||
挂靠老师:%s
|
||||
用途:%s
|
||||
|
||||
使用说明:
|
||||
1. 系统账号口令已锁定,仅支持 SSH 公钥登录;
|
||||
2. 在 %s 使用图形验证码 + 邮件验证码登录后,进入"我的密钥"上传您的 SSH 公钥;
|
||||
3. 公钥同步后即可通过 SSH 登录服务器。
|
||||
`, u.Username, expire, a.Supervisor, a.Purpose, s.cfg.App.BaseURL)
|
||||
if err := s.mailer.Send(ctx, a.Email, "服务器账号申请通过", body); err != nil {
|
||||
s.log.Warn("approval: 通过邮件发送失败", "approval_id", a.ID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// notifyRejected 通知申请人申请被拒及理由(PLAN F4:拒绝后可重新提交)。
|
||||
func (s *ApprovalService) notifyRejected(ctx context.Context, a *model.Approval) {
|
||||
body := fmt.Sprintf(`您的服务器账号申请未通过审批。
|
||||
|
||||
申请信息:
|
||||
用户名:%s
|
||||
邮箱:%s
|
||||
挂靠老师:%s
|
||||
用途:%s
|
||||
|
||||
拒绝理由:%s
|
||||
|
||||
如需重新申请,可在修正信息后再次提交。
|
||||
`, a.UsernameRequested, a.Email, a.Supervisor, a.Purpose, a.Reason)
|
||||
if err := s.mailer.Send(ctx, a.Email, "服务器账号申请未通过", body); err != nil {
|
||||
s.log.Warn("approval: 拒绝邮件发送失败", "approval_id", a.ID, "err", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"ws_usernode/internal/model"
|
||||
)
|
||||
|
||||
// recordingMail 捕获邮件发送,用于断言审批通知。
|
||||
type recordingMail struct {
|
||||
lastTo string
|
||||
lastSubject string
|
||||
lastBody string
|
||||
}
|
||||
|
||||
func (m *recordingMail) Send(_ context.Context, to, subject, body string) error {
|
||||
m.lastTo = to
|
||||
m.lastSubject = subject
|
||||
m.lastBody = body
|
||||
return nil
|
||||
}
|
||||
|
||||
func testApprovalSvc(t *testing.T) (*ApprovalService, *UserService, *recordingMail) {
|
||||
t.Helper()
|
||||
db := testDB(t)
|
||||
sys := newFakeSys()
|
||||
users := NewUserService(db, sys, testConfig())
|
||||
mailer := &recordingMail{}
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
return NewApprovalService(db, testConfig(), users, mailer, log), users, mailer
|
||||
}
|
||||
|
||||
func TestApprovalServiceSubmitValidate(t *testing.T) {
|
||||
svc, _, _ := testApprovalSvc(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// 非法用户名 / 邮箱
|
||||
if _, err := svc.Submit(ctx, "Bad Name", "x@example.com", "", ""); err == nil {
|
||||
t.Fatal("invalid username should fail")
|
||||
}
|
||||
if _, err := svc.Submit(ctx, "okname", "not-an-email", "", ""); err == nil {
|
||||
t.Fatal("invalid email should fail")
|
||||
}
|
||||
|
||||
// 合法提交
|
||||
a, err := svc.Submit(ctx, "zhangsan", "zs@example.com", "prof.li", "科研")
|
||||
if err != nil {
|
||||
t.Fatalf("submit: %v", err)
|
||||
}
|
||||
if a.Status != model.StatusPending || a.UsernameRequested != "zhangsan" {
|
||||
t.Fatalf("approval = %+v", a)
|
||||
}
|
||||
|
||||
// 同名待审批 → 冲突
|
||||
if _, err := svc.Submit(ctx, "zhangsan", "z2@example.com", "", ""); !errors.Is(err, ErrUsernameTaken) {
|
||||
t.Fatalf("duplicate pending err = %v, want ErrUsernameTaken", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalServiceReviewApproveCreatesUser(t *testing.T) {
|
||||
svc, users, mailer := testApprovalSvc(t)
|
||||
ctx := context.Background()
|
||||
|
||||
a, err := svc.Submit(ctx, "lisi", "ls@example.com", "prof.wang", "毕设")
|
||||
if err != nil {
|
||||
t.Fatalf("submit: %v", err)
|
||||
}
|
||||
|
||||
// 拒绝必须填理由
|
||||
if _, err := svc.Review(ctx, a.ID, false, 1, " "); !errors.Is(err, ErrReasonRequired) {
|
||||
t.Fatalf("reject without reason err = %v, want ErrReasonRequired", err)
|
||||
}
|
||||
|
||||
// 通过 → 自动建号(系统账号 + DB 用户)+ 通知邮件
|
||||
got, err := svc.Review(ctx, a.ID, true, 1, "")
|
||||
if err != nil {
|
||||
t.Fatalf("approve: %v", err)
|
||||
}
|
||||
if got.Status != model.StatusApproved || got.ReviewerID == nil || *got.ReviewerID != 1 {
|
||||
t.Fatalf("approved = %+v", got)
|
||||
}
|
||||
u, err := users.GetByUsername(ctx, "ext_lisi")
|
||||
if err != nil {
|
||||
t.Fatalf("user not created: %v", err)
|
||||
}
|
||||
if u.Status != model.UserStatusActive {
|
||||
t.Fatalf("user status = %s", u.Status)
|
||||
}
|
||||
if mailer.lastTo != "ls@example.com" || !strings.Contains(mailer.lastBody, "ext_lisi") {
|
||||
t.Fatalf("approval mail = %s / %s", mailer.lastTo, mailer.lastBody)
|
||||
}
|
||||
// 默认 TTL(90 天)生效
|
||||
if u.ExpireAt == nil || time.Until(*u.ExpireAt) < 80*24*time.Hour {
|
||||
t.Fatalf("expire_at = %v", u.ExpireAt)
|
||||
}
|
||||
|
||||
// 重复审批 → 冲突
|
||||
if _, err := svc.Review(ctx, a.ID, false, 1, "x"); !errors.Is(err, ErrApprovalReviewed) {
|
||||
t.Fatalf("re-review err = %v, want ErrApprovalReviewed", err)
|
||||
}
|
||||
// 用户名已被占用(已建号)→ 新申请冲突
|
||||
if _, err := svc.Submit(ctx, "lisi", "x@example.com", "", ""); !errors.Is(err, ErrUsernameTaken) {
|
||||
t.Fatalf("resubmit taken err = %v, want ErrUsernameTaken", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalServiceRejectAndResubmit(t *testing.T) {
|
||||
svc, _, mailer := testApprovalSvc(t)
|
||||
ctx := context.Background()
|
||||
|
||||
a, err := svc.Submit(ctx, "wangwu", "ww@example.com", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("submit: %v", err)
|
||||
}
|
||||
got, err := svc.Review(ctx, a.ID, false, 1, "用途不明确")
|
||||
if err != nil {
|
||||
t.Fatalf("reject: %v", err)
|
||||
}
|
||||
if got.Status != model.StatusRejected || got.Reason != "用途不明确" {
|
||||
t.Fatalf("rejected = %+v", got)
|
||||
}
|
||||
if mailer.lastTo != "ww@example.com" || !strings.Contains(mailer.lastBody, "用途不明确") {
|
||||
t.Fatalf("reject mail = %s / %s", mailer.lastTo, mailer.lastBody)
|
||||
}
|
||||
|
||||
// 被拒后可重新提交同名申请
|
||||
if _, err := svc.Submit(ctx, "wangwu", "ww2@example.com", "", ""); err != nil {
|
||||
t.Fatalf("resubmit after reject: %v", err)
|
||||
}
|
||||
// 不存在 → ErrApprovalNotFound
|
||||
if _, err := svc.Review(ctx, 99999, true, 1, ""); !errors.Is(err, ErrApprovalNotFound) {
|
||||
t.Fatalf("missing review err = %v, want ErrApprovalNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalServiceList(t *testing.T) {
|
||||
svc, _, _ := testApprovalSvc(t)
|
||||
ctx := context.Background()
|
||||
for _, n := range []string{"a1", "a2", "a3"} {
|
||||
if _, err := svc.Submit(ctx, n, n+"@example.com", "", ""); err != nil {
|
||||
t.Fatalf("submit %s: %v", n, err)
|
||||
}
|
||||
}
|
||||
rows, err := svc.List(ctx, model.StatusPending)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(rows) != 3 {
|
||||
t.Fatalf("pending count = %d, want 3", len(rows))
|
||||
}
|
||||
// 最新在前
|
||||
if rows[0].UsernameRequested != "a3" {
|
||||
t.Fatalf("first = %s, want a3", rows[0].UsernameRequested)
|
||||
}
|
||||
if all, err := svc.List(ctx, ""); err != nil || len(all) != 3 {
|
||||
t.Fatalf("list all = %d, %v", len(all), err)
|
||||
}
|
||||
}
|
||||
+181
-9
@@ -2,9 +2,13 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -13,14 +17,15 @@ import (
|
||||
)
|
||||
|
||||
// AuditService 审计服务:append-only 记录(业务代码仅允许 INSERT),
|
||||
// 保留策略与 CSV 导出归档在 M4 完成,这里给出接口与最小实现。
|
||||
// 查询/CSV 导出/每日归档(PLAN F6)。
|
||||
type AuditService struct {
|
||||
db *gorm.DB
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewAuditService 创建审计服务。
|
||||
func NewAuditService(db *gorm.DB) *AuditService {
|
||||
return &AuditService{db: db}
|
||||
func NewAuditService(db *gorm.DB, log *slog.Logger) *AuditService {
|
||||
return &AuditService{db: db, log: log}
|
||||
}
|
||||
|
||||
// Record 记录一条管理操作审计。detail 为任意结构体,入库前 JSON 序列化。
|
||||
@@ -42,10 +47,177 @@ func (s *AuditService) Record(ctx context.Context, actorID uint, actorName, acti
|
||||
return s.db.WithContext(ctx).Create(&entry).Error
|
||||
}
|
||||
|
||||
// ExportCSV 导出审计为 CSV。M0 骨架:返回占位错误,M4 实现手动导出 + 每日归档。
|
||||
// AuditFilter 审计查询条件。
|
||||
type AuditFilter struct {
|
||||
ActorName string // 操作者模糊匹配
|
||||
Action string // 动作精确匹配
|
||||
ResourceType string // 资源类型(user / ssh_key / approval ...)
|
||||
ResourceID string // 资源 ID
|
||||
Result string // 结果(success / failed)
|
||||
Since *time.Time // 起始时间(含)
|
||||
Until *time.Time // 结束时间(含)
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
// Query 分页查询审计日志(最新在前)。
|
||||
func (s *AuditService) Query(ctx context.Context, f AuditFilter) ([]model.AuditLog, int64, error) {
|
||||
q := s.db.WithContext(ctx).Model(&model.AuditLog{})
|
||||
if f.ActorName != "" {
|
||||
q = q.Where("actor_name LIKE ?", "%"+f.ActorName+"%")
|
||||
}
|
||||
if f.Action != "" {
|
||||
q = q.Where("action = ?", f.Action)
|
||||
}
|
||||
if f.ResourceType != "" {
|
||||
q = q.Where("resource_type = ?", f.ResourceType)
|
||||
}
|
||||
if f.ResourceID != "" {
|
||||
q = q.Where("resource_id = ?", f.ResourceID)
|
||||
}
|
||||
if f.Result != "" {
|
||||
q = q.Where("result = ?", f.Result)
|
||||
}
|
||||
if f.Since != nil {
|
||||
q = q.Where("created_at >= ?", *f.Since)
|
||||
}
|
||||
if f.Until != nil {
|
||||
q = q.Where("created_at <= ?", *f.Until)
|
||||
}
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
page, size := f.Page, f.PageSize
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 {
|
||||
size = 20
|
||||
}
|
||||
if size > 200 {
|
||||
size = 200
|
||||
}
|
||||
var rows []model.AuditLog
|
||||
if err := q.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return rows, total, nil
|
||||
}
|
||||
|
||||
// QueryByUser 查询与指定用户相关的审计记录(个人中心"我的审计痕迹",PLAN §8)。
|
||||
// 匹配该用户作为操作者(actor_id 对用户会话)或作为操作对象(resource_type=user 且 resource_id 为数字 ID)。
|
||||
func (s *AuditService) QueryByUser(ctx context.Context, userID uint, page, pageSize int) ([]model.AuditLog, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = 20
|
||||
}
|
||||
if pageSize > 200 {
|
||||
pageSize = 200
|
||||
}
|
||||
q := s.db.WithContext(ctx).Model(&model.AuditLog{}).
|
||||
Where("actor_id = ? OR (resource_type = ? AND resource_id = ?)", userID, "user", strconv.FormatUint(uint64(userID), 10))
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var rows []model.AuditLog
|
||||
if err := q.Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return rows, total, nil
|
||||
}
|
||||
|
||||
// ExportCSV 导出审计为 CSV(手动导出接口)。since/until 为空则导出全量。
|
||||
// 输出带 UTF-8 BOM,Excel 直接打开不乱码。
|
||||
func (s *AuditService) ExportCSV(ctx context.Context, w io.Writer, since, until *time.Time) error {
|
||||
_ = w
|
||||
_ = since
|
||||
_ = until
|
||||
return errors.New("service: 审计 CSV 导出将在 M4 实现")
|
||||
q := s.db.WithContext(ctx).Model(&model.AuditLog{})
|
||||
if since != nil {
|
||||
q = q.Where("created_at >= ?", *since)
|
||||
}
|
||||
if until != nil {
|
||||
q = q.Where("created_at <= ?", *until)
|
||||
}
|
||||
var rows []model.AuditLog
|
||||
if err := q.Order("id ASC").Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return writeAuditCSV(w, rows)
|
||||
}
|
||||
|
||||
// Archive 每日归档:将 before 之前的审计记录导出到 dir 后删除(PLAN F6
|
||||
// "归档后可安全清理")。dir 为空时跳过并返回 0(防止未配置目录就丢审计)。
|
||||
// 写文件失败时不删除任何记录(归档成功是清理的前提)。
|
||||
func (s *AuditService) Archive(ctx context.Context, dir string, before time.Time) (int, error) {
|
||||
if dir == "" {
|
||||
s.log.Warn("audit: archive_dir 未配置,跳过归档与清理(防止丢审计)")
|
||||
return 0, nil
|
||||
}
|
||||
var rows []model.AuditLog
|
||||
if err := s.db.WithContext(ctx).Where("created_at < ?", before).Order("id ASC").Find(&rows).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// 按归档执行日期分文件,同日追加
|
||||
name := "audit-" + time.Now().Format("2006-01-02") + ".csv"
|
||||
f, err := os.OpenFile(filepath.Join(dir, name), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o640)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := writeAuditCSV(f, rows); err != nil {
|
||||
f.Close()
|
||||
return 0, err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// 归档成功后清理(append-only 约束:清理是运维动作,走 cron 每日任务)
|
||||
if err := s.db.WithContext(ctx).Where("created_at < ?", before).Delete(&model.AuditLog{}).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
s.log.Info("audit: archived", "file", name, "count", len(rows), "before", before.Format(time.RFC3339))
|
||||
return len(rows), nil
|
||||
}
|
||||
|
||||
// writeAuditCSV 以固定列序写审计记录(ExportCSV 与 Archive 共用)。
|
||||
func writeAuditCSV(w io.Writer, rows []model.AuditLog) error {
|
||||
// UTF-8 BOM:Excel 识别中文
|
||||
if _, err := w.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
|
||||
return err
|
||||
}
|
||||
cw := csv.NewWriter(w)
|
||||
header := []string{"id", "created_at", "actor_id", "actor_name", "action", "resource_type", "resource_id", "detail", "ip", "result"}
|
||||
if err := cw.Write(header); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, r := range rows {
|
||||
rec := []string{
|
||||
strconv.FormatUint(uint64(r.ID), 10),
|
||||
r.CreatedAt.Format(time.RFC3339),
|
||||
strconv.FormatUint(uint64(r.ActorID), 10),
|
||||
r.ActorName,
|
||||
r.Action,
|
||||
r.ResourceType,
|
||||
r.ResourceID,
|
||||
r.Detail,
|
||||
r.IP,
|
||||
r.Result,
|
||||
}
|
||||
if err := cw.Write(rec); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
cw.Flush()
|
||||
if err := cw.Error(); err != nil {
|
||||
return err
|
||||
}
|
||||
// 追加模式归档时,BOM 会在文件中部重复,无害(解析器按首列内容处理)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ func newTestAuthService(t *testing.T) (*AuthService, *fakeSys, *recordingMailer)
|
||||
sys := newFakeSys()
|
||||
userSvc := NewUserService(db, sys, testConfig())
|
||||
adminSvc := NewAdminService(db)
|
||||
auditSvc := NewAuditService(db)
|
||||
auditSvc := NewAuditService(db, testLogger())
|
||||
cfg := testConfig()
|
||||
|
||||
mailer := &recordingMailer{}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"ws_usernode/internal/config"
|
||||
"ws_usernode/internal/mail"
|
||||
"ws_usernode/internal/model"
|
||||
"ws_usernode/internal/system"
|
||||
)
|
||||
|
||||
// LifecycleService 账号生命周期维护(PLAN F2):
|
||||
// - 到期:锁定系统账号 + 清空 authorized_keys(SSH 立即失效)+ 状态置 expired + 邮件提醒;
|
||||
// - 回收:超过回收期仍未延期 → 删除系统账号与密钥(保留审计)+ 邮件通知。
|
||||
//
|
||||
// 由 cron 每日触发(单实例部署,PLAN §11)。
|
||||
type LifecycleService struct {
|
||||
db *gorm.DB
|
||||
cfg *config.Config
|
||||
sys system.Manager
|
||||
users *UserService
|
||||
settings *SettingService
|
||||
mailer mail.Mailer
|
||||
audit *AuditService
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewLifecycleService 创建生命周期服务。
|
||||
func NewLifecycleService(db *gorm.DB, cfg *config.Config, sys system.Manager, users *UserService, settings *SettingService, mailer mail.Mailer, audit *AuditService, log *slog.Logger) *LifecycleService {
|
||||
return &LifecycleService{db: db, cfg: cfg, sys: sys, users: users, settings: settings, mailer: mailer, audit: audit, log: log}
|
||||
}
|
||||
|
||||
// ScanExpired 扫描到期账号(expire_at < now 且状态非 disabled/expired):
|
||||
// 锁定系统账号 + 清空 authorized_keys + 状态置 expired + 邮件通知(到期提醒)。
|
||||
// 返回本次处理的账号数。
|
||||
func (s *LifecycleService) ScanExpired(ctx context.Context) (int, error) {
|
||||
now := time.Now()
|
||||
var rows []model.User
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("status = ? AND expire_at IS NOT NULL AND expire_at < ?", model.UserStatusActive, now).
|
||||
Find(&rows).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
processed := 0
|
||||
for i := range rows {
|
||||
u := &rows[i]
|
||||
if !s.systemAccountOK(u.Username) {
|
||||
s.log.Warn("lifecycle: 系统账号缺失,跳过锁定", "username", u.Username)
|
||||
continue
|
||||
}
|
||||
if err := s.sys.SetLock(ctx, u.Username, true); err != nil {
|
||||
s.log.Error("lifecycle: 锁定系统账号失败", "username", u.Username, "err", err)
|
||||
_ = s.audit.Record(ctx, 0, "system", "lifecycle.expire", "user", fmt.Sprint(u.ID), map[string]any{"err": err.Error()}, "cron", model.ResultFailed)
|
||||
continue
|
||||
}
|
||||
if err := s.sys.SyncAuthorizedKeys(ctx, u.Username, nil); err != nil {
|
||||
s.log.Error("lifecycle: 清空 authorized_keys 失败", "username", u.Username, "err", err)
|
||||
_ = s.audit.Record(ctx, 0, "system", "lifecycle.expire", "user", fmt.Sprint(u.ID), map[string]any{"err": err.Error()}, "cron", model.ResultFailed)
|
||||
continue
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", u.ID).Update("status", model.UserStatusExpired).Error; err != nil {
|
||||
s.log.Error("lifecycle: 更新状态失败", "username", u.Username, "err", err)
|
||||
continue
|
||||
}
|
||||
processed++
|
||||
s.notifyExpired(ctx, u)
|
||||
_ = s.audit.Record(ctx, 0, "system", "lifecycle.expire", "user", fmt.Sprint(u.ID), nil, "cron", model.ResultSuccess)
|
||||
}
|
||||
return processed, nil
|
||||
}
|
||||
|
||||
// Recycle 回收超期账号:expired 状态且过期时间超过回收期 → 删除系统账号与密钥
|
||||
// (保留审计)+ 邮件通知(回收提醒)。返回本次回收的账号数。
|
||||
func (s *LifecycleService) Recycle(ctx context.Context) (int, error) {
|
||||
period := s.settings.RecyclePeriod(ctx)
|
||||
cutoff := time.Now().Add(-period)
|
||||
var rows []model.User
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("status = ? AND expire_at IS NOT NULL AND expire_at < ?", model.UserStatusExpired, cutoff).
|
||||
Find(&rows).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
recycled := 0
|
||||
for i := range rows {
|
||||
u := &rows[i]
|
||||
if err := s.users.Delete(ctx, u.ID); err != nil {
|
||||
s.log.Error("lifecycle: 回收账号失败", "username", u.Username, "err", err)
|
||||
_ = s.audit.Record(ctx, 0, "system", "lifecycle.recycle", "user", fmt.Sprint(u.ID), map[string]any{"err": err.Error()}, "cron", model.ResultFailed)
|
||||
continue
|
||||
}
|
||||
recycled++
|
||||
s.notifyRecycled(ctx, u)
|
||||
_ = s.audit.Record(ctx, 0, "system", "lifecycle.recycle", "user", fmt.Sprint(u.ID), nil, "cron", model.ResultSuccess)
|
||||
}
|
||||
return recycled, nil
|
||||
}
|
||||
|
||||
// systemAccountOK 与 UserService 一致:dry-run 模式跳过真实检查。
|
||||
func (s *LifecycleService) systemAccountOK(username string) bool {
|
||||
if s.cfg.System.DryRun {
|
||||
return true
|
||||
}
|
||||
ok, err := s.sys.Exists(context.Background(), username)
|
||||
return err == nil && ok
|
||||
}
|
||||
|
||||
// notifyExpired 到期提醒:账号已到期,回收期内可延期恢复。
|
||||
func (s *LifecycleService) notifyExpired(ctx context.Context, u *model.User) {
|
||||
body := fmt.Sprintf(`您的服务器账号(%s)已到期,系统账号已被锁定,SSH 登录已不可用。
|
||||
|
||||
如需继续使用,请在回收期(%s)内联系管理员延期恢复;超过回收期账号将被自动回收。
|
||||
`, u.Username, s.settings.RecyclePeriod(ctx).String())
|
||||
if err := s.mailer.Send(ctx, u.Email, "服务器账号已到期", body); err != nil {
|
||||
s.log.Warn("lifecycle: 到期邮件发送失败", "username", u.Username, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// notifyRecycled 回收提醒:账号已超回收期被回收。
|
||||
func (s *LifecycleService) notifyRecycled(ctx context.Context, u *model.User) {
|
||||
body := fmt.Sprintf(`您的服务器账号(%s)已超过回收期且未办理延期,账号已被回收删除(系统账号与密钥均已清理)。
|
||||
|
||||
如需重新使用,请重新提交账号申请。
|
||||
`, u.Username)
|
||||
if err := s.mailer.Send(ctx, u.Email, "服务器账号已回收", body); err != nil {
|
||||
s.log.Warn("lifecycle: 回收邮件发送失败", "username", u.Username, "err", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"ws_usernode/internal/model"
|
||||
)
|
||||
|
||||
func TestSettingServiceCRUD(t *testing.T) {
|
||||
db := testDB(t)
|
||||
cfg := testConfig()
|
||||
svc := NewSettingService(db, cfg)
|
||||
ctx := context.Background()
|
||||
|
||||
// 初始值 = config 默认
|
||||
items, err := svc.GetAll(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("getall: %v", err)
|
||||
}
|
||||
if len(items) != 3 {
|
||||
t.Fatalf("items = %d, want 3", len(items))
|
||||
}
|
||||
for _, it := range items {
|
||||
if it.Overridden {
|
||||
t.Fatalf("initial item %s should not be overridden", it.Key)
|
||||
}
|
||||
}
|
||||
if d := svc.DefaultTTL(ctx); d != cfg.Policy.DefaultTTL {
|
||||
t.Fatalf("default ttl = %s, want %s", d, cfg.Policy.DefaultTTL)
|
||||
}
|
||||
|
||||
// 更新
|
||||
if err := svc.Set(ctx, "policy.default_ttl", "720h"); err != nil {
|
||||
t.Fatalf("set: %v", err)
|
||||
}
|
||||
if d := svc.DefaultTTL(ctx); d != 720*time.Hour {
|
||||
t.Fatalf("default ttl after set = %s, want 720h", d)
|
||||
}
|
||||
items, _ = svc.GetAll(ctx)
|
||||
for _, it := range items {
|
||||
if it.Key == "policy.default_ttl" {
|
||||
if !it.Overridden || it.Value != "720h" {
|
||||
t.Fatalf("item = %+v, want overridden 720h", it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 未知 key / 非法时长
|
||||
if err := svc.Set(ctx, "smtp.host", "x"); !errors.Is(err, ErrSettingKeyUnknown) {
|
||||
t.Fatalf("unknown key err = %v, want ErrSettingKeyUnknown", err)
|
||||
}
|
||||
if err := svc.Set(ctx, "policy.default_ttl", "not-a-duration"); !errors.Is(err, ErrSettingValueInvalid) {
|
||||
t.Fatalf("bad value err = %v, want ErrSettingValueInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingServiceRecycleRetention(t *testing.T) {
|
||||
db := testDB(t)
|
||||
cfg := testConfig()
|
||||
svc := NewSettingService(db, cfg)
|
||||
ctx := context.Background()
|
||||
// 未覆盖时回退 config
|
||||
if d := svc.RecyclePeriod(ctx); d != cfg.Policy.RecyclePeriod {
|
||||
t.Fatalf("recycle = %s", d)
|
||||
}
|
||||
if d := svc.AuditRetention(ctx); d != cfg.Policy.AuditRetention {
|
||||
t.Fatalf("retention = %s", d)
|
||||
}
|
||||
_ = svc.Set(ctx, "policy.recycle_period", "168h")
|
||||
_ = svc.Set(ctx, "policy.audit_retention", "720h")
|
||||
if d := svc.RecyclePeriod(ctx); d != 168*time.Hour {
|
||||
t.Fatalf("recycle after set = %s", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserServiceDefaultTTLFromSettings(t *testing.T) {
|
||||
db := testDB(t)
|
||||
sys := newFakeSys()
|
||||
cfg := testConfig()
|
||||
users := NewUserService(db, sys, cfg)
|
||||
settings := NewSettingService(db, cfg)
|
||||
users.WithSettings(settings)
|
||||
ctx := context.Background()
|
||||
|
||||
_ = settings.Set(ctx, "policy.default_ttl", "168h")
|
||||
u, err := users.Create(ctx, "ttluser", "ttl@example.com", "", "", 0, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if u.ExpireAt == nil {
|
||||
t.Fatal("expire_at nil")
|
||||
}
|
||||
if d := time.Until(*u.ExpireAt); d < 160*time.Hour || d > 176*time.Hour {
|
||||
t.Fatalf("expire in = %s, want ~168h", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditServiceQueryExport(t *testing.T) {
|
||||
db := testDB(t)
|
||||
svc := NewAuditService(db, testLogger())
|
||||
ctx := context.Background()
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
if err := svc.Record(ctx, 1, "root", "user.create", "user", "10", map[string]any{"n": i}, "127.0.0.1", model.ResultSuccess); err != nil {
|
||||
t.Fatalf("record: %v", err)
|
||||
}
|
||||
}
|
||||
if err := svc.Record(ctx, 2, "ext_x", "key.create", "ssh_key", "3", nil, "10.0.0.1", model.ResultSuccess); err != nil {
|
||||
t.Fatalf("record: %v", err)
|
||||
}
|
||||
|
||||
// 分页查询
|
||||
rows, total, err := svc.Query(ctx, AuditFilter{Action: "user.create", Page: 1, PageSize: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("query: %v", err)
|
||||
}
|
||||
if total != 5 || len(rows) != 2 {
|
||||
t.Fatalf("query total=%d len=%d, want 5/2", total, len(rows))
|
||||
}
|
||||
// 按 actor 筛选
|
||||
rows, total, _ = svc.Query(ctx, AuditFilter{ActorName: "ext_", Page: 1, PageSize: 10})
|
||||
if total != 1 {
|
||||
t.Fatalf("actor filter total = %d, want 1", total)
|
||||
}
|
||||
|
||||
// CSV 导出(含 BOM 与表头)
|
||||
var buf strings.Builder
|
||||
if err := svc.ExportCSV(ctx, &buf, nil, nil); err != nil {
|
||||
t.Fatalf("export: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.HasPrefix(out, "\ufeffid,created_at,") {
|
||||
t.Fatalf("csv missing header/BOM: %q", out[:40])
|
||||
}
|
||||
if !strings.Contains(out, "user.create") || !strings.Contains(out, "key.create") {
|
||||
t.Fatalf("csv missing rows: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditServiceArchive(t *testing.T) {
|
||||
db := testDB(t)
|
||||
svc := NewAuditService(db, testLogger())
|
||||
ctx := context.Background()
|
||||
dir := t.TempDir()
|
||||
|
||||
// 3 条旧记录 + 1 条新记录
|
||||
old := time.Now().Add(-10 * 24 * time.Hour)
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := db.Create(&model.AuditLog{CreatedAt: old, Action: "old", ActorName: "root", Result: model.ResultSuccess}).Error; err != nil {
|
||||
t.Fatalf("seed old: %v", err)
|
||||
}
|
||||
}
|
||||
if err := svc.Record(ctx, 1, "root", "fresh", "user", "1", nil, "ip", model.ResultSuccess); err != nil {
|
||||
t.Fatalf("seed fresh: %v", err)
|
||||
}
|
||||
|
||||
n, err := svc.Archive(ctx, dir, time.Now().Add(-5*24*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("archive: %v", err)
|
||||
}
|
||||
if n != 3 {
|
||||
t.Fatalf("archived = %d, want 3", n)
|
||||
}
|
||||
// 归档后旧记录已清理,新记录保留
|
||||
var count int64
|
||||
if err := db.Model(&model.AuditLog{}).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("remaining = %d, want 1", count)
|
||||
}
|
||||
// 归档文件存在且含旧记录
|
||||
files, _ := filepath.Glob(filepath.Join(dir, "audit-*.csv"))
|
||||
if len(files) != 1 {
|
||||
t.Fatalf("archive files = %v", files)
|
||||
}
|
||||
b, _ := os.ReadFile(files[0])
|
||||
if !strings.Contains(string(b), "old") {
|
||||
t.Fatalf("archive file missing rows: %q", string(b))
|
||||
}
|
||||
|
||||
// dir 为空:跳过且不清理
|
||||
n, err = svc.Archive(ctx, "", time.Now().Add(-5*24*time.Hour))
|
||||
if err != nil || n != 0 {
|
||||
t.Fatalf("empty dir archive = %d, %v, want 0/nil", n, err)
|
||||
}
|
||||
count = 0
|
||||
if err := db.Model(&model.AuditLog{}).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("remaining after empty-dir = %d, want 1(未配置目录不清理)", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLifecycleScanExpired(t *testing.T) {
|
||||
db := testDB(t)
|
||||
sys := newFakeSys()
|
||||
cfg := testConfig()
|
||||
users := NewUserService(db, sys, cfg)
|
||||
settings := NewSettingService(db, cfg)
|
||||
mailer := &recordingMail{}
|
||||
log := testLogger()
|
||||
svc := NewLifecycleService(db, cfg, sys, users, settings, mailer, NewAuditService(db, log), log)
|
||||
ctx := context.Background()
|
||||
|
||||
// 一个将过期、一个正常
|
||||
u1, err := users.Create(ctx, "expire1", "e1@example.com", "", "", 90*24*time.Hour, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
u2, err := users.Create(ctx, "expire2", "e2@example.com", "", "", 90*24*time.Hour, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
past := time.Now().Add(-time.Hour)
|
||||
if err := db.Model(&model.User{}).Where("id = ?", u1.ID).Update("expire_at", &past).Error; err != nil {
|
||||
t.Fatalf("force expire u1: %v", err)
|
||||
}
|
||||
|
||||
n, err := svc.ScanExpired(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("expired = %d, want 1", n)
|
||||
}
|
||||
if got, _ := users.GetByID(ctx, u1.ID); got.Status != model.UserStatusExpired {
|
||||
t.Fatalf("u1 status = %s, want expired", got.Status)
|
||||
}
|
||||
if got, _ := users.GetByID(ctx, u2.ID); got.Status != model.UserStatusActive {
|
||||
t.Fatalf("u2 status = %s, want active", got.Status)
|
||||
}
|
||||
if mailer.lastTo != "e1@example.com" || !strings.Contains(mailer.lastBody, "到期") {
|
||||
t.Fatalf("expire mail = %s / %s", mailer.lastTo, mailer.lastBody)
|
||||
}
|
||||
|
||||
// 幂等:再次扫描不重复处理(已 expired)
|
||||
if n, _ := svc.ScanExpired(ctx); n != 0 {
|
||||
t.Fatalf("rescan = %d, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLifecycleRecycle(t *testing.T) {
|
||||
db := testDB(t)
|
||||
sys := newFakeSys()
|
||||
cfg := testConfig()
|
||||
users := NewUserService(db, sys, cfg)
|
||||
settings := NewSettingService(db, cfg)
|
||||
mailer := &recordingMail{}
|
||||
log := testLogger()
|
||||
svc := NewLifecycleService(db, cfg, sys, users, settings, mailer, NewAuditService(db, log), log)
|
||||
ctx := context.Background()
|
||||
|
||||
// 已过期很久(超回收期)与刚过期(回收期内)
|
||||
u1, err := users.Create(ctx, "oldone", "o1@example.com", "", "", 90*24*time.Hour, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
u2, err := users.Create(ctx, "freshone", "f1@example.com", "", "", 90*24*time.Hour, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
longPast := time.Now().Add(-100 * 24 * time.Hour)
|
||||
justPast := time.Now().Add(-time.Hour)
|
||||
if err := db.Model(&model.User{}).Where("id = ?", u1.ID).Updates(map[string]any{"status": model.UserStatusExpired, "expire_at": &longPast}).Error; err != nil {
|
||||
t.Fatalf("force u1: %v", err)
|
||||
}
|
||||
if err := db.Model(&model.User{}).Where("id = ?", u2.ID).Updates(map[string]any{"status": model.UserStatusExpired, "expire_at": &justPast}).Error; err != nil {
|
||||
t.Fatalf("force u2: %v", err)
|
||||
}
|
||||
|
||||
n, err := svc.Recycle(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("recycle: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("recycled = %d, want 1", n)
|
||||
}
|
||||
// 超期账号已删除(DB + 系统账号),回收期内账号保留
|
||||
if _, err := users.GetByID(ctx, u1.ID); !errors.Is(err, ErrUserNotFound) {
|
||||
t.Fatalf("u1 err = %v, want ErrUserNotFound", err)
|
||||
}
|
||||
if sys.has("ext_oldone") {
|
||||
t.Fatal("u1 system account should be removed")
|
||||
}
|
||||
if _, err := users.GetByID(ctx, u2.ID); err != nil {
|
||||
t.Fatalf("u2 should remain: %v", err)
|
||||
}
|
||||
if mailer.lastTo != "o1@example.com" || !strings.Contains(mailer.lastBody, "回收") {
|
||||
t.Fatalf("recycle mail = %s / %s", mailer.lastTo, mailer.lastBody)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"ws_usernode/internal/config"
|
||||
"ws_usernode/internal/model"
|
||||
)
|
||||
|
||||
// 系统设置错误。
|
||||
var ErrSettingKeyUnknown = errors.New("service: 未知设置项")
|
||||
var ErrSettingValueInvalid = errors.New("service: 设置值不合法")
|
||||
|
||||
// 设置项 key 白名单(settings 表可覆盖 config 的策略字段,PLAN F7)。
|
||||
// 其余设置(SMTP、会话时长等)为启动时配置,不支持动态覆盖。
|
||||
var settingKeys = map[string]bool{
|
||||
"policy.default_ttl": true, // 新账号默认有效期(时长)
|
||||
"policy.recycle_period": true, // 到期后回收期(时长)
|
||||
"policy.audit_retention": true, // 审计保留时长(时长)
|
||||
}
|
||||
|
||||
// SettingItem 一个设置项的生效值。
|
||||
type SettingItem struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"` // 生效值(settings 覆盖优先,否则 config 默认)
|
||||
Overridden bool `json:"overridden"` // 是否被 settings 表覆盖
|
||||
}
|
||||
|
||||
// SettingService 系统设置读写(settings 表,config 提供默认值)。
|
||||
type SettingService struct {
|
||||
db *gorm.DB
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
// NewSettingService 创建设置服务。
|
||||
func NewSettingService(db *gorm.DB, cfg *config.Config) *SettingService {
|
||||
return &SettingService{db: db, cfg: cfg}
|
||||
}
|
||||
|
||||
// GetAll 返回全部设置项的生效值(未覆盖的展示 config 默认值)。
|
||||
func (s *SettingService) GetAll(ctx context.Context) ([]SettingItem, error) {
|
||||
var rows []model.Setting
|
||||
if err := s.db.WithContext(ctx).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
overridden := make(map[string]string, len(rows))
|
||||
for _, r := range rows {
|
||||
if settingKeys[r.Key] {
|
||||
overridden[r.Key] = r.Value
|
||||
}
|
||||
}
|
||||
items := make([]SettingItem, 0, len(settingKeys))
|
||||
for _, key := range sortedSettingKeys() {
|
||||
if v, ok := overridden[key]; ok {
|
||||
items = append(items, SettingItem{Key: key, Value: v, Overridden: true})
|
||||
} else {
|
||||
items = append(items, SettingItem{Key: key, Value: s.defaultValue(key), Overridden: false})
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// Set 更新一个设置项:校验 key 白名单与值格式(均为时长)。全部校验通过才写入。
|
||||
func (s *SettingService) Set(ctx context.Context, key, value string) error {
|
||||
if !settingKeys[key] {
|
||||
return fmt.Errorf("%w: %q", ErrSettingKeyUnknown, key)
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
if _, err := time.ParseDuration(value); err != nil {
|
||||
return fmt.Errorf("%w: %q(需为时长,如 2160h)", ErrSettingValueInvalid, value)
|
||||
}
|
||||
return s.db.WithContext(ctx).Save(&model.Setting{Key: key, Value: value}).Error
|
||||
}
|
||||
|
||||
// Duration 读取策略时长设置:settings 覆盖优先,无覆盖或解析失败用 fallback。
|
||||
func (s *SettingService) Duration(ctx context.Context, key string, fallback time.Duration) time.Duration {
|
||||
if !settingKeys[key] {
|
||||
return fallback
|
||||
}
|
||||
var row model.Setting
|
||||
err := s.db.WithContext(ctx).First(&row, "key = ?", key).Error
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
d, err := time.ParseDuration(row.Value)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// DefaultTTL 新账号默认有效期(settings 覆盖 config.policy.default_ttl)。
|
||||
func (s *SettingService) DefaultTTL(ctx context.Context) time.Duration {
|
||||
return s.Duration(ctx, "policy.default_ttl", s.cfg.Policy.DefaultTTL)
|
||||
}
|
||||
|
||||
// RecyclePeriod 到期后回收期。
|
||||
func (s *SettingService) RecyclePeriod(ctx context.Context) time.Duration {
|
||||
return s.Duration(ctx, "policy.recycle_period", s.cfg.Policy.RecyclePeriod)
|
||||
}
|
||||
|
||||
// AuditRetention 审计保留时长。
|
||||
func (s *SettingService) AuditRetention(ctx context.Context) time.Duration {
|
||||
return s.Duration(ctx, "policy.audit_retention", s.cfg.Policy.AuditRetention)
|
||||
}
|
||||
|
||||
// defaultValue 返回设置项的 config 默认值(保证遍历顺序稳定)。
|
||||
func (s *SettingService) defaultValue(key string) string {
|
||||
switch key {
|
||||
case "policy.default_ttl":
|
||||
return s.cfg.Policy.DefaultTTL.String()
|
||||
case "policy.recycle_period":
|
||||
return s.cfg.Policy.RecyclePeriod.String()
|
||||
case "policy.audit_retention":
|
||||
return s.cfg.Policy.AuditRetention.String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sortedSettingKeys() []string {
|
||||
// 固定顺序展示,避免 map 遍历随机
|
||||
return []string{"policy.default_ttl", "policy.recycle_period", "policy.audit_retention"}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ type UserService struct {
|
||||
db *gorm.DB
|
||||
sys system.Manager
|
||||
cfg *config.Config
|
||||
settings *SettingService // 可选:默认 TTL 等策略覆盖(settings 表)
|
||||
}
|
||||
|
||||
// NewUserService 创建用户服务。
|
||||
@@ -35,6 +36,20 @@ func NewUserService(db *gorm.DB, sys system.Manager, cfg *config.Config) *UserSe
|
||||
return &UserService{db: db, sys: sys, cfg: cfg}
|
||||
}
|
||||
|
||||
// WithSettings 注入设置服务(settings 表覆盖策略默认值,PLAN F7)。nil 安全。
|
||||
func (s *UserService) WithSettings(settings *SettingService) *UserService {
|
||||
s.settings = settings
|
||||
return s
|
||||
}
|
||||
|
||||
// effectiveDefaultTTL 新账号默认有效期:settings 覆盖优先,否则用配置默认。
|
||||
func (s *UserService) effectiveDefaultTTL(ctx context.Context) time.Duration {
|
||||
if s.settings != nil {
|
||||
return s.settings.DefaultTTL(ctx)
|
||||
}
|
||||
return s.cfg.Policy.DefaultTTL
|
||||
}
|
||||
|
||||
// GetByUsername 按用户名查询外部用户(含或不含 ext_ 前缀均可)。
|
||||
func (s *UserService) GetByUsername(ctx context.Context, username string) (*model.User, error) {
|
||||
full := normalizeName(username, s.cfg.System.UserPrefix)
|
||||
@@ -64,6 +79,7 @@ func (s *UserService) GetByID(ctx context.Context, id uint) (*model.User, error)
|
||||
type UserFilter struct {
|
||||
Status string // active / disabled / expired,空为全部
|
||||
Supervisor string // 挂靠老师模糊匹配
|
||||
ExpiresBefore *time.Time // 仅返回 expire_at 早于该时刻的用户(仪表盘"近期待过期"用)
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
@@ -77,6 +93,9 @@ func (s *UserService) List(ctx context.Context, f UserFilter) ([]model.User, int
|
||||
if f.Supervisor != "" {
|
||||
q = q.Where("supervisor LIKE ?", "%"+f.Supervisor+"%")
|
||||
}
|
||||
if f.ExpiresBefore != nil {
|
||||
q = q.Where("expire_at IS NOT NULL AND expire_at < ?", f.ExpiresBefore)
|
||||
}
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
@@ -98,6 +117,56 @@ func (s *UserService) List(ctx context.Context, f UserFilter) ([]model.User, int
|
||||
return users, total, nil
|
||||
}
|
||||
|
||||
// UserStats 仪表盘统计(PLAN §8):各状态用户数、近 30 天待过期用户、待审批申请数。
|
||||
type UserStats struct {
|
||||
Total int64 `json:"total"`
|
||||
Active int64 `json:"active"`
|
||||
Disabled int64 `json:"disabled"`
|
||||
Expired int64 `json:"expired"`
|
||||
ExpiringSoon []model.User `json:"expiring_soon"` // 30 天内到期且仍 active
|
||||
PendingApprovals int64 `json:"pending_approvals"`
|
||||
}
|
||||
|
||||
// Stats 汇总仪表盘统计。
|
||||
func (s *UserService) Stats(ctx context.Context) (*UserStats, error) {
|
||||
st := &UserStats{}
|
||||
q := s.db.WithContext(ctx).Model(&model.User{})
|
||||
if err := q.Count(&st.Total).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byStatus := map[string]int64{}
|
||||
var rows []struct {
|
||||
Status string
|
||||
C int64
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Model(&model.User{}).Select("status, count(*) as c").Group("status").Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rows {
|
||||
byStatus[r.Status] = r.C
|
||||
}
|
||||
st.Active = byStatus[model.UserStatusActive]
|
||||
st.Disabled = byStatus[model.UserStatusDisabled]
|
||||
st.Expired = byStatus[model.UserStatusExpired]
|
||||
|
||||
// 近 30 天待过期(active 且 expire_at 在 30 天内)
|
||||
window := time.Now().Add(30 * 24 * time.Hour)
|
||||
var soon []model.User
|
||||
if err := s.db.WithContext(ctx).Model(&model.User{}).
|
||||
Where("status = ? AND expire_at IS NOT NULL AND expire_at < ?", model.UserStatusActive, window).
|
||||
Order("expire_at ASC").Limit(10).Find(&soon).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st.ExpiringSoon = soon
|
||||
|
||||
var pending int64
|
||||
if err := s.db.WithContext(ctx).Model(&model.Approval{}).Where("status = ?", model.StatusPending).Count(&pending).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st.PendingApprovals = pending
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// Create 创建外部用户:DB 记录 + 系统账号(useradd + passwd -l)。
|
||||
// username 不含前缀;ttl 为有效期时长,0 表示用配置默认(90 天)。
|
||||
// 系统建号失败时回滚 DB 记录,保证两侧一致。
|
||||
@@ -118,7 +187,7 @@ func (s *UserService) Create(ctx context.Context, username, email, supervisor, p
|
||||
return nil, ErrUserExists
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = s.cfg.Policy.DefaultTTL
|
||||
ttl = s.effectiveDefaultTTL(ctx)
|
||||
}
|
||||
expireAt := time.Now().Add(ttl)
|
||||
u := &model.User{
|
||||
@@ -222,33 +291,37 @@ func (s *UserService) Enable(ctx context.Context, id uint) error {
|
||||
return s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Update("status", model.UserStatusActive).Error
|
||||
}
|
||||
|
||||
// Extend 延长有效期:重设 expire_at(days<=0 用配置默认 TTL)。
|
||||
// Extend 延长有效期:重设 expire_at(days<=0 用默认 TTL,settings 覆盖优先)。
|
||||
// 已过期用户在回收期内可经此恢复(PLAN §2.2),恢复后同步密钥。
|
||||
func (s *UserService) Extend(ctx context.Context, id uint, days int) error {
|
||||
// 返回新的过期时间,供 handler 响应。
|
||||
func (s *UserService) Extend(ctx context.Context, id uint, days int) (time.Time, error) {
|
||||
u, err := s.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
return time.Time{}, err
|
||||
}
|
||||
ttl := time.Duration(days) * 24 * time.Hour
|
||||
if days <= 0 {
|
||||
ttl = s.cfg.Policy.DefaultTTL
|
||||
ttl = s.effectiveDefaultTTL(ctx)
|
||||
}
|
||||
newExpire := time.Now().Add(ttl)
|
||||
updates := map[string]any{"expire_at": newExpire}
|
||||
if u.Status == model.UserStatusExpired {
|
||||
if !s.systemAccountOK(ctx, u.Username) {
|
||||
return ErrSystemAccountMissing
|
||||
return time.Time{}, ErrSystemAccountMissing
|
||||
}
|
||||
keys, err := activeUserKeys(s.db, ctx, u.ID, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
return time.Time{}, err
|
||||
}
|
||||
if err := s.sys.SyncAuthorizedKeys(ctx, u.Username, keys); err != nil {
|
||||
return err
|
||||
return time.Time{}, err
|
||||
}
|
||||
updates["status"] = model.UserStatusActive
|
||||
}
|
||||
return s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Updates(updates).Error
|
||||
if err := s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return newExpire, nil
|
||||
}
|
||||
|
||||
// Delete 删除并回收用户:删除系统账号(userdel -r)+ 家目录 + 密钥记录,
|
||||
|
||||
@@ -182,7 +182,7 @@ func TestUserServiceLifecycle(t *testing.T) {
|
||||
}
|
||||
|
||||
// 延期
|
||||
if err := svc.Extend(ctx, u.ID, 30); err != nil {
|
||||
if _, err := svc.Extend(ctx, u.ID, 30); err != nil {
|
||||
t.Fatalf("extend: %v", err)
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ func TestUserServiceEnableExpired(t *testing.T) {
|
||||
t.Fatalf("enable expired err = %v, want ErrUserExpired", err)
|
||||
}
|
||||
// 延期可恢复
|
||||
if err := svc.Extend(ctx, u.ID, 30); err != nil {
|
||||
if _, err := svc.Extend(ctx, u.ID, 30); err != nil {
|
||||
t.Fatalf("extend expired: %v", err)
|
||||
}
|
||||
if u2, _ := svc.GetByID(ctx, u.ID); u2.Status != model.UserStatusActive {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
import{d as i,o as _,c as f,w as o,r as m,a,b as v,e as l,f as n,g as s,t as w}from"./index-DU8VsiSb.js";const g=i({__name:"HomeView",setup(x){const t=m(null);return _(async()=>{try{const r=await fetch("/healthz");t.value=r.ok}catch{t.value=!1}}),(r,e)=>{const u=a("el-tag"),d=a("el-divider"),p=a("el-text"),c=a("el-card");return v(),f(c,{shadow:"never"},{header:o(()=>[...e[0]||(e[0]=[l("b",null,"工程骨架已就绪",-1)])]),default:o(()=>[e[3]||(e[3]=l("p",null,[n("ws_usernode M0:Go (Gin + GORM) 后端与 Vue3 前端脚手架已打通,API 前缀 "),l("code",null,"/api/v1"),n("。")],-1)),l("p",null,[e[1]||(e[1]=n(" 后端健康检查: ",-1)),s(u,{type:t.value===null?"info":t.value?"success":"danger"},{default:o(()=>[n(w(t.value===null?"检测中…":t.value?"正常":"不可达(请先启动 Go 后端)"),1)]),_:1},8,["type"])]),s(d),s(p,{type:"info"},{default:o(()=>[...e[2]||(e[2]=[n("登录 / 用户管理 / 密钥 / 审批 / 审计页面将在 M1 起逐步落地。",-1)])]),_:1})]),_:1})}}});export{g as default};
|
||||
File diff suppressed because one or more lines are too long
-92
File diff suppressed because one or more lines are too long
Vendored
-13
@@ -1,13 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ws_usernode 用户管理</title>
|
||||
<script type="module" crossorigin src="/assets/index-DU8VsiSb.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C0mNeYZw.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,7 +3,22 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%234f46e5'/%3E%3Crect x='7' y='7' width='18' height='13' rx='2' fill='none' stroke='white' stroke-width='2.4'/%3E%3Cline x1='9' y1='13' x2='23' y2='13' stroke='white' stroke-width='2'/%3E%3Cline x1='14' y1='20' x2='14' y2='24' stroke='white' stroke-width='2.4'/%3E%3Cline x1='18' y1='20' x2='18' y2='24' stroke='white' stroke-width='2.4'/%3E%3Cline x1='11' y1='24' x2='21' y2='24' stroke='white' stroke-width='2.4'/%3E%3C/svg%3E"
|
||||
/>
|
||||
<title>ws_usernode 用户管理</title>
|
||||
<script>
|
||||
// 在渲染前恢复主题,避免暗色模式闪白
|
||||
;(function () {
|
||||
try {
|
||||
var t = localStorage.getItem('ws-usernode-theme')
|
||||
if (t === 'dark' || (t !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark')
|
||||
}
|
||||
} catch (e) {}
|
||||
})()
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Generated
+2138
File diff suppressed because it is too large
Load Diff
@@ -10,9 +10,11 @@
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"axios": "^1.7.9",
|
||||
"element-plus": "^2.9.3",
|
||||
"pinia": "^2.3.1",
|
||||
"pinyin-pro": "^3.29.3",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
|
||||
+15
-22
@@ -1,33 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
// 骨架阶段根组件:展示项目名与健康检查状态。
|
||||
// M1 起替换为登录页 + 主布局(侧边导航、用户管理、审批、审计等页面)。
|
||||
import { onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
// 根组件:启动时探测会话(路由守卫会复用已加载状态)。
|
||||
const auth = useAuthStore()
|
||||
onMounted(() => {
|
||||
void auth.fetchMe()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-config-provider>
|
||||
<el-container class="app-shell">
|
||||
<el-header class="app-header">
|
||||
<span class="app-title">ws_usernode 服务器用户管理节点</span>
|
||||
</el-header>
|
||||
<el-main>
|
||||
<router-view />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-config-provider>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
}
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--el-border-color);
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
.app-title {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
<style>
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
background: var(--el-bg-color-page);
|
||||
}
|
||||
</style>
|
||||
|
||||
+24
-4
@@ -1,11 +1,31 @@
|
||||
import axios from 'axios'
|
||||
import axios, { AxiosError } from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
// Axios 实例:baseURL 为 /api/v1(Vite dev proxy 转发到 Go 后端;
|
||||
// 生产由 go:embed 同源提供)。M1 起在此统一拦截 401 / 错误码。
|
||||
// Axios 实例:baseURL 为 /api/v1(Vite dev proxy 转发到 Go 后端;生产 go:embed 同源)。
|
||||
// withCredentials:cookie 会话。
|
||||
const http = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
timeout: 15000,
|
||||
withCredentials: true, // cookie 会话
|
||||
withCredentials: true,
|
||||
})
|
||||
|
||||
// 响应拦截器:
|
||||
// - 成功响应统一解包 {data};
|
||||
// - 401 会话失效时清除本地状态并跳登录(登录/申请页自身除外,避免循环);
|
||||
// - 其余错误统一提示后端 error 文案。
|
||||
http.interceptors.response.use(
|
||||
(resp) => resp,
|
||||
(err: AxiosError<{ error?: string }>) => {
|
||||
const status = err.response?.status
|
||||
const msg = err.response?.data?.error || err.message || '请求失败'
|
||||
const path = window.location.pathname
|
||||
if (status === 401 && !path.startsWith('/login') && !path.startsWith('/apply')) {
|
||||
window.location.href = '/login'
|
||||
return Promise.reject(err)
|
||||
}
|
||||
ElMessage.error(msg)
|
||||
return Promise.reject(err)
|
||||
},
|
||||
)
|
||||
|
||||
export default http
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
// 后端 RESTful API v1 的统一封装:每个函数对应一个端点,返回解包后的 data。
|
||||
import http from './http'
|
||||
import type {
|
||||
Approval,
|
||||
AuditLog,
|
||||
Captcha,
|
||||
MeInfo,
|
||||
PageResult,
|
||||
SSHKey,
|
||||
SettingItem,
|
||||
User,
|
||||
UserStats,
|
||||
} from './types'
|
||||
|
||||
// 通用解包:{data} / {error}。
|
||||
async function unwrap<T>(p: Promise<{ data: { data: T } }>): Promise<T> {
|
||||
const resp = await p
|
||||
return resp.data.data
|
||||
}
|
||||
|
||||
// ---------- 认证 ----------
|
||||
|
||||
export function getCaptcha(): Promise<Captcha> {
|
||||
return unwrap(http.get('/auth/captcha'))
|
||||
}
|
||||
|
||||
export function otpSend(p: { username: string; captcha_id: string; captcha_code: string }): Promise<{ status: string }> {
|
||||
return unwrap(http.post('/auth/otp/send', p))
|
||||
}
|
||||
|
||||
export function otpLogin(p: { username: string; code: string }): Promise<{ session: string }> {
|
||||
return unwrap(http.post('/auth/otp/login', p))
|
||||
}
|
||||
|
||||
export function adminLogin(p: { username: string; password: string }): Promise<{ session: string }> {
|
||||
return unwrap(http.post('/auth/admin/login', p))
|
||||
}
|
||||
|
||||
export function adminForgot(p: { username: string }): Promise<{ status: string }> {
|
||||
return unwrap(http.post('/auth/admin/forgot', p))
|
||||
}
|
||||
|
||||
export function adminReset(p: { token: string; new_password: string }): Promise<{ status: string }> {
|
||||
return unwrap(http.post('/auth/admin/reset', p))
|
||||
}
|
||||
|
||||
export function logout(): Promise<{ status: string }> {
|
||||
return unwrap(http.post('/auth/logout'))
|
||||
}
|
||||
|
||||
export function me(): Promise<MeInfo> {
|
||||
return unwrap(http.get('/auth/me'))
|
||||
}
|
||||
|
||||
// ---------- 用户管理(admin) ----------
|
||||
|
||||
export interface UserListParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
status?: string
|
||||
supervisor?: string
|
||||
}
|
||||
|
||||
export function listUsers(params: UserListParams): Promise<PageResult<User>> {
|
||||
return unwrap(http.get('/users', { params }))
|
||||
}
|
||||
|
||||
export function createUser(p: {
|
||||
username: string
|
||||
email: string
|
||||
supervisor?: string
|
||||
purpose?: string
|
||||
ttl_days?: number
|
||||
}): Promise<{ id: number; username: string; status: string; expire_at: string | null }> {
|
||||
return unwrap(http.post('/users', p))
|
||||
}
|
||||
|
||||
export function getUser(id: number): Promise<User> {
|
||||
return unwrap(http.get(`/users/${id}`))
|
||||
}
|
||||
|
||||
export function updateUser(
|
||||
id: number,
|
||||
p: { email?: string; supervisor?: string; purpose?: string },
|
||||
): Promise<User> {
|
||||
return unwrap(http.patch(`/users/${id}`, p))
|
||||
}
|
||||
|
||||
export function disableUser(id: number): Promise<{ status: string }> {
|
||||
return unwrap(http.post(`/users/${id}/disable`))
|
||||
}
|
||||
|
||||
export function enableUser(id: number): Promise<{ status: string }> {
|
||||
return unwrap(http.post(`/users/${id}/enable`))
|
||||
}
|
||||
|
||||
export function extendUser(id: number, days: number): Promise<{ expire_at: string }> {
|
||||
return unwrap(http.post(`/users/${id}/extend`, { days }))
|
||||
}
|
||||
|
||||
export function deleteUser(id: number): Promise<{ status: string }> {
|
||||
return unwrap(http.delete(`/users/${id}`))
|
||||
}
|
||||
|
||||
export function listUserKeys(id: number): Promise<{ items: SSHKey[] }> {
|
||||
return unwrap(http.get(`/users/${id}/keys`))
|
||||
}
|
||||
|
||||
// ---------- 我的密钥(外部用户) ----------
|
||||
|
||||
export function listMyKeys(): Promise<{ items: SSHKey[] }> {
|
||||
return unwrap(http.get('/me/keys'))
|
||||
}
|
||||
|
||||
export function createKey(p: { name: string; public_key: string }): Promise<SSHKey> {
|
||||
return unwrap(http.post('/me/keys', p))
|
||||
}
|
||||
|
||||
export function renameKey(id: number, name: string): Promise<SSHKey> {
|
||||
return unwrap(http.patch(`/me/keys/${id}`, { name }))
|
||||
}
|
||||
|
||||
export function revokeKey(id: number): Promise<SSHKey> {
|
||||
return unwrap(http.delete(`/me/keys/${id}`))
|
||||
}
|
||||
|
||||
/** 我的审计痕迹(外部用户个人中心) */
|
||||
export function myAudit(page = 1, pageSize = 20): Promise<PageResult<AuditLog>> {
|
||||
return unwrap(http.get('/me/audit', { params: { page, page_size: pageSize } }))
|
||||
}
|
||||
|
||||
// ---------- 申请审批 ----------
|
||||
|
||||
export function submitApproval(p: {
|
||||
username: string
|
||||
email: string
|
||||
supervisor?: string
|
||||
purpose?: string
|
||||
}): Promise<Approval> {
|
||||
return unwrap(http.post('/approvals', p))
|
||||
}
|
||||
|
||||
export function listApprovals(status?: string): Promise<{ items: Approval[] }> {
|
||||
return unwrap(http.get('/approvals', { params: { status } }))
|
||||
}
|
||||
|
||||
export function reviewApproval(id: number, approve: boolean, reason: string): Promise<Approval> {
|
||||
return unwrap(http.post(`/approvals/${id}/review`, { approve, reason }))
|
||||
}
|
||||
|
||||
// ---------- 审计(admin) ----------
|
||||
|
||||
export interface AuditListParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
actor?: string
|
||||
action?: string
|
||||
resource_type?: string
|
||||
resource_id?: string
|
||||
result?: string
|
||||
since?: string
|
||||
until?: string
|
||||
}
|
||||
|
||||
export function listAudit(params: AuditListParams): Promise<PageResult<AuditLog>> {
|
||||
return unwrap(http.get('/audit', { params }))
|
||||
}
|
||||
|
||||
/** 审计 CSV 导出地址(浏览器直接导航下载,cookie 自动携带) */
|
||||
export function auditExportUrl(params: { since?: string; until?: string } = {}): string {
|
||||
const qs = new URLSearchParams()
|
||||
if (params.since) qs.set('since', params.since)
|
||||
if (params.until) qs.set('until', params.until)
|
||||
const s = qs.toString()
|
||||
return `/api/v1/audit/export${s ? `?${s}` : ''}`
|
||||
}
|
||||
|
||||
// ---------- 系统设置(admin) ----------
|
||||
|
||||
export function listSettings(): Promise<{ items: SettingItem[] }> {
|
||||
return unwrap(http.get('/settings'))
|
||||
}
|
||||
|
||||
export function updateSetting(key: string, value: string): Promise<{ status: string; key: string; value: string }> {
|
||||
return unwrap(http.put('/settings', { key, value }))
|
||||
}
|
||||
|
||||
// ---------- 仪表盘统计(admin) ----------
|
||||
|
||||
export function getStats(): Promise<UserStats> {
|
||||
return unwrap(http.get('/admin/stats'))
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// 与后端 RESTful API v1 对应的数据类型(字段对齐 internal/model 的 json tag)。
|
||||
|
||||
/** 外部用户 */
|
||||
export interface User {
|
||||
id: number
|
||||
username: string // 含 ext_ 前缀
|
||||
email: string
|
||||
supervisor: string
|
||||
purpose: string
|
||||
status: 'active' | 'disabled' | 'expired'
|
||||
expire_at: string | null
|
||||
shell: string
|
||||
created_by: number
|
||||
last_login_at: string | null
|
||||
recycled_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** SSH 公钥 */
|
||||
export interface SSHKey {
|
||||
id: number
|
||||
user_id: number
|
||||
name: string
|
||||
key_type: string
|
||||
public_key: string
|
||||
fingerprint: string
|
||||
status: 'active' | 'revoked'
|
||||
source: string
|
||||
created_by: number
|
||||
revoked_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 新账号申请单 */
|
||||
export interface Approval {
|
||||
id: number
|
||||
username_requested: string // 不含 ext_ 前缀
|
||||
email: string
|
||||
supervisor: string
|
||||
purpose: string
|
||||
status: 'pending' | 'approved' | 'rejected'
|
||||
reviewer_id: number | null
|
||||
reviewed_at: string | null
|
||||
reason: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 审计日志 */
|
||||
export interface AuditLog {
|
||||
id: number
|
||||
actor_id: number
|
||||
actor_name: string
|
||||
action: string
|
||||
resource_type: string
|
||||
resource_id: string
|
||||
detail: string // JSON 字符串
|
||||
ip: string
|
||||
result: 'success' | 'failed'
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** 系统设置项 */
|
||||
export interface SettingItem {
|
||||
key: string
|
||||
value: string
|
||||
overridden: boolean
|
||||
}
|
||||
|
||||
/** 当前会话主体 */
|
||||
export interface MeInfo {
|
||||
user_type: 'admin' | 'user'
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
}
|
||||
|
||||
/** 仪表盘统计 */
|
||||
export interface UserStats {
|
||||
total: number
|
||||
active: number
|
||||
disabled: number
|
||||
expired: number
|
||||
expiring_soon: User[]
|
||||
pending_approvals: number
|
||||
}
|
||||
|
||||
/** 图形验证码 */
|
||||
export interface Captcha {
|
||||
captcha_id: string
|
||||
image: string // data:image/png;base64,...
|
||||
}
|
||||
|
||||
/** 分页结果(后端统一 {total, items}) */
|
||||
export interface PageResult<T> {
|
||||
total: number
|
||||
items: T[]
|
||||
}
|
||||
Vendored
+2
-1
@@ -2,6 +2,7 @@
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, any>
|
||||
export default component
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const theme = useThemeStore()
|
||||
|
||||
const collapsed = ref(false)
|
||||
const activeMenu = computed(() => route.path)
|
||||
const username = computed(() => auth.me?.username ?? '')
|
||||
const email = computed(() => auth.me?.email ?? '')
|
||||
const pageTitle = computed(() => (route.meta.title as string) ?? '')
|
||||
|
||||
async function onLogout() {
|
||||
await auth.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-container class="admin-layout">
|
||||
<el-aside :width="collapsed ? '64px' : '220px'" class="aside">
|
||||
<div class="logo">
|
||||
<span class="logo-icon">
|
||||
<el-icon :size="20"><Monitor /></el-icon>
|
||||
</span>
|
||||
<span v-show="!collapsed" class="logo-text">用户管理节点</span>
|
||||
</div>
|
||||
<el-menu
|
||||
:default-active="activeMenu"
|
||||
:collapse="collapsed"
|
||||
:collapse-transition="false"
|
||||
router
|
||||
class="menu"
|
||||
background-color="transparent"
|
||||
text-color="rgba(255,255,255,0.72)"
|
||||
active-text-color="#ffffff"
|
||||
>
|
||||
<el-menu-item index="/admin/dashboard">
|
||||
<el-icon><Odometer /></el-icon><span>仪表盘</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/admin/users">
|
||||
<el-icon><User /></el-icon><span>用户管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/admin/approvals">
|
||||
<el-icon><Tickets /></el-icon><span>申请审批</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/admin/audit">
|
||||
<el-icon><Document /></el-icon><span>审计日志</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/admin/settings">
|
||||
<el-icon><Setting /></el-icon><span>系统设置</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
<el-container class="right">
|
||||
<el-header class="header">
|
||||
<div class="header-left">
|
||||
<el-button link class="icon-btn" @click="collapsed = !collapsed">
|
||||
<el-icon :size="18"><Expand v-if="collapsed" /><Fold v-else /></el-icon>
|
||||
</el-button>
|
||||
<el-breadcrumb separator="/">
|
||||
<el-breadcrumb-item :to="{ path: '/admin/dashboard' }">
|
||||
<el-icon class="crumb-home"><HomeFilled /></el-icon>
|
||||
<span>首页</span>
|
||||
</el-breadcrumb-item>
|
||||
<el-breadcrumb-item v-if="pageTitle && pageTitle !== '仪表盘'">{{ pageTitle }}</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-tooltip :content="theme.isDark ? '切换到亮色' : '切换到暗色'">
|
||||
<el-button link class="icon-btn" @click="theme.toggle()">
|
||||
<el-icon :size="17"><Sunny v-if="theme.isDark" /><Moon v-else /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-dropdown @command="onLogout">
|
||||
<span class="user-chip">
|
||||
<el-avatar :size="28" class="avatar">{{ username.slice(0, 1).toUpperCase() }}</el-avatar>
|
||||
<span>{{ username }}</span>
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item disabled>{{ email }}</el-dropdown-item>
|
||||
<el-dropdown-item divided command="logout">退出登录</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<el-main class="main">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-layout {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ------- 侧边栏 ------- */
|
||||
.aside {
|
||||
background: var(--app-sidebar-bg);
|
||||
border-right: none;
|
||||
transition: width 0.2s ease;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: var(--app-header-height);
|
||||
padding: 0 14px;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.logo-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 9px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
|
||||
}
|
||||
.logo-text {
|
||||
font-weight: 650;
|
||||
font-size: 15px;
|
||||
color: #fff;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.menu {
|
||||
border-right: none;
|
||||
flex: 1;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.aside :deep(.el-menu-item) {
|
||||
margin: 4px 10px;
|
||||
border-radius: 8px;
|
||||
--el-menu-item-height: 44px;
|
||||
}
|
||||
.aside :deep(.el-menu-item:hover) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.aside :deep(.el-menu-item.is-active) {
|
||||
background: var(--app-sidebar-bg-active);
|
||||
}
|
||||
.aside :deep(.el-menu-item.is-active .el-icon) {
|
||||
color: var(--app-sidebar-text-active);
|
||||
}
|
||||
|
||||
/* ------- 右侧容器 ------- */
|
||||
.right {
|
||||
min-width: 0;
|
||||
}
|
||||
.header {
|
||||
height: var(--app-header-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
background: var(--el-bg-color);
|
||||
border-bottom: 1px solid var(--el-border-color-light);
|
||||
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
.header-left,
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.icon-btn {
|
||||
color: var(--el-text-color-regular);
|
||||
padding: 4px;
|
||||
}
|
||||
.crumb-home {
|
||||
margin-right: 4px;
|
||||
vertical-align: -2px;
|
||||
}
|
||||
.user-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
.avatar {
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
color: #fff;
|
||||
}
|
||||
.main {
|
||||
padding: 20px;
|
||||
background: var(--el-bg-color-page);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,128 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const theme = useThemeStore()
|
||||
|
||||
const activeMenu = computed(() => route.path)
|
||||
const username = computed(() => auth.me?.username ?? '')
|
||||
|
||||
async function onLogout() {
|
||||
await auth.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-container class="user-layout">
|
||||
<el-header class="header">
|
||||
<span class="logo">
|
||||
<span class="logo-icon">
|
||||
<el-icon :size="18"><Monitor /></el-icon>
|
||||
</span>
|
||||
<span class="logo-text">服务器用户管理</span>
|
||||
</span>
|
||||
<el-menu :default-active="activeMenu" router mode="horizontal" :ellipsis="false" class="nav">
|
||||
<el-menu-item index="/me/profile">账号信息</el-menu-item>
|
||||
<el-menu-item index="/me/keys">我的密钥</el-menu-item>
|
||||
<el-menu-item index="/me/audit">我的审计</el-menu-item>
|
||||
</el-menu>
|
||||
<div class="header-right">
|
||||
<el-tooltip :content="theme.isDark ? '切换到亮色' : '切换到暗色'">
|
||||
<el-button link class="icon-btn" @click="theme.toggle()">
|
||||
<el-icon :size="17"><Sunny v-if="theme.isDark" /><Moon v-else /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-dropdown @command="onLogout">
|
||||
<span class="user-chip">
|
||||
<el-avatar :size="26" class="avatar">{{ username.slice(0, 1).toUpperCase() }}</el-avatar>
|
||||
<span>{{ username }}</span>
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="logout">退出登录</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</el-header>
|
||||
<el-main class="main">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.user-layout {
|
||||
min-height: 100vh;
|
||||
}
|
||||
.header {
|
||||
height: var(--app-header-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
background: var(--el-bg-color);
|
||||
border-bottom: 1px solid var(--el-border-color-light);
|
||||
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.logo-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 9px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
|
||||
}
|
||||
.logo-text {
|
||||
font-weight: 650;
|
||||
font-size: 15px;
|
||||
}
|
||||
.nav {
|
||||
flex: 1;
|
||||
border-bottom: none;
|
||||
}
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.icon-btn {
|
||||
color: var(--el-text-color-regular);
|
||||
padding: 4px;
|
||||
}
|
||||
.user-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
.avatar {
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
color: #fff;
|
||||
}
|
||||
.main {
|
||||
padding: 20px;
|
||||
background: var(--el-bg-color-page);
|
||||
}
|
||||
</style>
|
||||
@@ -1,14 +1,27 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import 'element-plus/dist/index.css'
|
||||
import 'element-plus/theme-chalk/dark/css-vars.css'
|
||||
import '@/styles/theme.css'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus, { locale: zhCn })
|
||||
|
||||
// 应用主题(暗色/亮色)需在挂载前生效,避免闪烁
|
||||
useThemeStore().init()
|
||||
|
||||
// 全局注册 Element Plus 图标
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
+120
-8
@@ -1,15 +1,127 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
// 骨架阶段:仅首页(占位)。M1 起增加 /login、/admin/*、/me 等路由并做鉴权守卫。
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
// 路由规划(PLAN §8):
|
||||
// - 公开:/login(登录)、/apply(新账号申请)
|
||||
// - admin:/admin/dashboard、/admin/users、/admin/approvals、/admin/audit、/admin/settings
|
||||
// - user:/me(个人中心:账号信息 + 我的密钥 + 我的审计)
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{ path: '/', redirect: '/login' },
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: () => import('@/views/HomeView.vue'),
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/views/LoginView.vue'),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: '/apply',
|
||||
name: 'apply',
|
||||
component: () => import('@/views/ApplyView.vue'),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
component: () => import('@/layouts/AdminLayout.vue'),
|
||||
meta: { requiresAuth: true, role: 'admin' },
|
||||
children: [
|
||||
{ path: '', redirect: '/admin/dashboard' },
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'admin-dashboard',
|
||||
component: () => import('@/views/admin/DashboardView.vue'),
|
||||
meta: { title: '仪表盘' },
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'admin-users',
|
||||
component: () => import('@/views/admin/UsersView.vue'),
|
||||
meta: { title: '用户管理' },
|
||||
},
|
||||
{
|
||||
path: 'approvals',
|
||||
name: 'admin-approvals',
|
||||
component: () => import('@/views/admin/ApprovalsView.vue'),
|
||||
meta: { title: '申请审批' },
|
||||
},
|
||||
{
|
||||
path: 'audit',
|
||||
name: 'admin-audit',
|
||||
component: () => import('@/views/admin/AuditView.vue'),
|
||||
meta: { title: '审计日志' },
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'admin-settings',
|
||||
component: () => import('@/views/admin/SettingsView.vue'),
|
||||
meta: { title: '系统设置' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/me',
|
||||
component: () => import('@/layouts/UserLayout.vue'),
|
||||
meta: { requiresAuth: true, role: 'user' },
|
||||
children: [
|
||||
{ path: '', redirect: '/me/profile' },
|
||||
{
|
||||
path: 'profile',
|
||||
name: 'me-profile',
|
||||
component: () => import('@/views/me/ProfileView.vue'),
|
||||
meta: { title: '账号信息' },
|
||||
},
|
||||
{
|
||||
path: 'keys',
|
||||
name: 'me-keys',
|
||||
component: () => import('@/views/me/KeysView.vue'),
|
||||
meta: { title: '我的密钥' },
|
||||
},
|
||||
{
|
||||
path: 'audit',
|
||||
name: 'me-audit',
|
||||
component: () => import('@/views/me/MyAuditView.vue'),
|
||||
meta: { title: '我的审计' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/login' },
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
// 全局守卫:登录页/申请页放行;受保护页面要求登录且角色匹配。
|
||||
router.beforeEach(async (to) => {
|
||||
const auth = useAuthStore()
|
||||
if (!auth.loaded) {
|
||||
await auth.fetchMe()
|
||||
}
|
||||
const me = auth.me
|
||||
|
||||
if (to.meta.public) {
|
||||
// 已登录再访问登录页 → 直接进对应首页
|
||||
if (me && to.name === 'login') {
|
||||
return me.user_type === 'admin' ? { name: 'admin-dashboard' } : { name: 'me-profile' }
|
||||
}
|
||||
return true
|
||||
}
|
||||
const needAuth = to.matched.some((r) => r.meta.requiresAuth)
|
||||
if (needAuth) {
|
||||
if (!me) {
|
||||
return { name: 'login' }
|
||||
}
|
||||
const role = to.meta.role as string | undefined
|
||||
if (role && me.user_type !== role) {
|
||||
return me.user_type === 'admin' ? { name: 'admin-dashboard' } : { name: 'me-profile' }
|
||||
}
|
||||
return true
|
||||
}
|
||||
// 其余路径
|
||||
if (me) {
|
||||
return me.user_type === 'admin' ? { name: 'admin-dashboard' } : { name: 'me-profile' }
|
||||
}
|
||||
return { name: 'login' }
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import * as api from '@/api'
|
||||
import type { MeInfo } from '@/api/types'
|
||||
|
||||
// 会话状态:基于 cookie 会话(withCredentials),本地仅缓存 /auth/me 结果。
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const me = ref<MeInfo | null>(null)
|
||||
const loaded = ref(false)
|
||||
|
||||
/** 拉取当前会话主体;未登录返回 null(401 由拦截器处理,这里不弹错)。 */
|
||||
async function fetchMe(): Promise<MeInfo | null> {
|
||||
try {
|
||||
me.value = await api.me()
|
||||
} catch {
|
||||
me.value = null
|
||||
} finally {
|
||||
loaded.value = true
|
||||
}
|
||||
return me.value
|
||||
}
|
||||
|
||||
function isAdmin(): boolean {
|
||||
return me.value?.user_type === 'admin'
|
||||
}
|
||||
|
||||
function isUser(): boolean {
|
||||
return me.value?.user_type === 'user'
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
try {
|
||||
await api.logout()
|
||||
} catch {
|
||||
// 会话已失效也视为登出成功
|
||||
}
|
||||
me.value = null
|
||||
}
|
||||
|
||||
return { me, loaded, fetchMe, isAdmin, isUser, logout }
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const STORAGE_KEY = 'ws-usernode-theme'
|
||||
|
||||
function systemPrefersDark(): boolean {
|
||||
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false
|
||||
}
|
||||
|
||||
/** 主题状态:暗色 / 亮色,持久化到 localStorage。 */
|
||||
export const useThemeStore = defineStore('theme', () => {
|
||||
const isDark = ref(false)
|
||||
|
||||
function apply() {
|
||||
document.documentElement.classList.toggle('dark', isDark.value)
|
||||
}
|
||||
|
||||
/** 应用初始化时调用:localStorage 优先,其次跟随系统偏好。 */
|
||||
function init() {
|
||||
let saved: string | null = null
|
||||
try {
|
||||
saved = localStorage.getItem(STORAGE_KEY)
|
||||
} catch {
|
||||
// 隐私模式下可能不可用,忽略
|
||||
}
|
||||
if (saved === 'dark' || saved === 'light') {
|
||||
isDark.value = saved === 'dark'
|
||||
} else {
|
||||
isDark.value = systemPrefersDark()
|
||||
}
|
||||
apply()
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
isDark.value = !isDark.value
|
||||
apply()
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, isDark.value ? 'dark' : 'light')
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
|
||||
return { isDark, init, toggle }
|
||||
})
|
||||
@@ -0,0 +1,186 @@
|
||||
/* ============================================================
|
||||
* 全局主题:通过 CSS 变量覆盖 Element Plus 默认主题
|
||||
* 亮色为默认;html.dark 下为暗色(配合 element-plus dark css-vars)
|
||||
* ============================================================ */
|
||||
|
||||
:root {
|
||||
/* 品牌主色:靛蓝(替代默认 #409EFF) */
|
||||
--el-color-primary: #4f46e5;
|
||||
--el-color-primary-light-3: #847eed;
|
||||
--el-color-primary-light-5: #a7a3f2;
|
||||
--el-color-primary-light-7: #cac8f7;
|
||||
--el-color-primary-light-8: #dcdafa;
|
||||
--el-color-primary-light-9: #edecfc;
|
||||
--el-color-primary-dark-2: #3f38b7;
|
||||
|
||||
/* 圆角 */
|
||||
--el-border-radius-base: 8px;
|
||||
--el-border-radius-small: 6px;
|
||||
|
||||
/* 页面背景:偏冷灰白 */
|
||||
--el-bg-color-page: #f4f5fa;
|
||||
|
||||
/* 阴影:更柔和 */
|
||||
--el-box-shadow-light: 0 4px 16px rgba(15, 23, 42, 0.06);
|
||||
--el-box-shadow: 0 10px 28px rgba(15, 23, 42, 0.1);
|
||||
|
||||
/* 字体栈 */
|
||||
--el-font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
|
||||
'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Arial, sans-serif;
|
||||
|
||||
/* 布局自定义变量 */
|
||||
--app-sidebar-bg: #1c2333;
|
||||
--app-sidebar-bg-active: rgba(99, 102, 241, 0.18);
|
||||
--app-sidebar-text: rgba(255, 255, 255, 0.72);
|
||||
--app-sidebar-text-active: #ffffff;
|
||||
--app-sidebar-logo-bg: rgba(99, 102, 241, 0.16);
|
||||
--app-header-height: 60px;
|
||||
}
|
||||
|
||||
/* ---------------- 暗色模式 ---------------- */
|
||||
html.dark {
|
||||
/* 主色在暗色下提亮一档,保证对比度 */
|
||||
--el-color-primary: #818cf8;
|
||||
--el-color-primary-light-3: #5a62ae;
|
||||
--el-color-primary-light-5: #41467c;
|
||||
--el-color-primary-light-7: #272a4a;
|
||||
--el-color-primary-light-8: #1a1c32;
|
||||
--el-color-primary-light-9: #14162b;
|
||||
--el-color-primary-dark-2: #6366f1;
|
||||
|
||||
--el-bg-color-page: #101218;
|
||||
--app-sidebar-bg: #161a24;
|
||||
--app-sidebar-logo-bg: rgba(129, 140, 248, 0.16);
|
||||
}
|
||||
|
||||
/* ---------------- 认证页(登录 / 申请)公共样式 ----------------
|
||||
* 深色品牌背景 + 光晕,卡片浮于其上。
|
||||
* 亮/暗模式下此背景保持一致,形成品牌页与内部页面的反差。
|
||||
*/
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(1200px 600px at 85% -10%, rgba(99, 102, 241, 0.28), transparent 60%),
|
||||
radial-gradient(900px 500px at -10% 110%, rgba(56, 189, 248, 0.16), transparent 55%),
|
||||
linear-gradient(135deg, #0f172a 0%, #1e293b 55%, #26224f 100%);
|
||||
}
|
||||
.auth-page::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 560px;
|
||||
height: 560px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(99, 102, 241, 0.22), transparent 70%);
|
||||
top: -180px;
|
||||
right: -120px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.auth-page::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 480px;
|
||||
height: 480px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(34, 211, 238, 0.14), transparent 70%);
|
||||
bottom: -160px;
|
||||
left: -100px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: 420px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 24px 60px rgba(2, 6, 23, 0.45);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
html.dark .auth-card {
|
||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.auth-card .el-card__body {
|
||||
padding: 28px 28px 24px;
|
||||
}
|
||||
|
||||
/* 品牌区:图标放渐变圆角块内 */
|
||||
.auth-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.auth-brand .brand-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
box-shadow: 0 8px 20px rgba(99, 102, 241, 0.35);
|
||||
}
|
||||
.auth-brand h2 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.auth-brand .sub {
|
||||
font-size: 12.5px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-top: -6px;
|
||||
}
|
||||
|
||||
.auth-foot {
|
||||
margin-top: 16px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
/* 表单提交按钮:通栏主按钮 */
|
||||
.auth-submit {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ---------------- 全局细节 ---------------- */
|
||||
/* 细滚动条 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(120, 130, 150, 0.35);
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(120, 130, 150, 0.55);
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* 文本选中颜色 */
|
||||
::selection {
|
||||
background: rgba(99, 102, 241, 0.25);
|
||||
}
|
||||
|
||||
/* 页面切换过渡(避免突兀) */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// 展示层工具:时间/状态格式化、审计动作中文映射。
|
||||
|
||||
export function fmtTime(v: string | null | undefined): string {
|
||||
if (!v) return '-'
|
||||
const d = new Date(v)
|
||||
if (Number.isNaN(d.getTime())) return v
|
||||
return d.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
export function fmtDate(v: string | null | undefined): string {
|
||||
if (!v) return '-'
|
||||
const d = new Date(v)
|
||||
if (Number.isNaN(d.getTime())) return v
|
||||
return d.toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
export function daysLeft(expireAt: string | null): number | null {
|
||||
if (!expireAt) return null
|
||||
const diff = new Date(expireAt).getTime() - Date.now()
|
||||
return Math.ceil(diff / 86400000)
|
||||
}
|
||||
|
||||
// 用户状态中文
|
||||
export const userStatusText: Record<string, string> = {
|
||||
active: '正常',
|
||||
disabled: '已禁用',
|
||||
expired: '已过期',
|
||||
}
|
||||
|
||||
// 申请单状态中文
|
||||
export const approvalStatusText: Record<string, string> = {
|
||||
pending: '待审批',
|
||||
approved: '已通过',
|
||||
rejected: '已拒绝',
|
||||
}
|
||||
|
||||
// 审计动作中文映射(未知动作回显原文)
|
||||
const actionTextMap: Record<string, string> = {
|
||||
'auth.admin_login': '管理员登录',
|
||||
'auth.otp_send': '发送 OTP',
|
||||
'auth.otp_login': 'OTP 登录',
|
||||
'auth.logout': '登出',
|
||||
'user.create': '创建用户',
|
||||
'user.update': '更新用户',
|
||||
'user.disable': '禁用用户',
|
||||
'user.enable': '启用用户',
|
||||
'user.extend': '延期用户',
|
||||
'user.delete': '删除用户',
|
||||
'key.create': '上传密钥',
|
||||
'key.rename': '重命名密钥',
|
||||
'key.revoke': '吊销密钥',
|
||||
'approval.submit': '提交申请',
|
||||
'approval.review': '审批申请',
|
||||
'lifecycle.expire': '到期锁定',
|
||||
'lifecycle.recycle': '回收删除',
|
||||
}
|
||||
|
||||
export function actionText(action: string): string {
|
||||
return actionTextMap[action] || action
|
||||
}
|
||||
|
||||
/** 解析审计 detail(JSON 字符串),解析失败返回原文 */
|
||||
export function parseDetail(detail: string): string {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(detail))
|
||||
} catch {
|
||||
return detail
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { pinyin } from 'pinyin-pro'
|
||||
import * as api from '@/api'
|
||||
|
||||
const router = useRouter()
|
||||
const submitting = ref(false)
|
||||
const form = reactive({
|
||||
realName: '', // 中文姓名,提交时转换为拼音作为用户名
|
||||
email: '',
|
||||
supervisor: '',
|
||||
purpose: '',
|
||||
})
|
||||
|
||||
// 前端校验:中文姓名、邮箱、挂靠老师、用途均在前端完成
|
||||
const rules = {
|
||||
realName: [
|
||||
{ required: true, message: '请输入中文姓名', trigger: 'blur' },
|
||||
{ pattern: /^[\u4e00-\u9fa5]{2,10}$/, message: '请输入 2~10 个汉字', trigger: 'blur' },
|
||||
],
|
||||
email: [
|
||||
{ required: true, message: '请输入邮箱', trigger: 'blur' },
|
||||
{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' },
|
||||
],
|
||||
supervisor: [{ required: true, message: '请输入挂靠老师', trigger: 'blur' }],
|
||||
purpose: [{ required: true, message: '请说明用途', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
const formRef = ref()
|
||||
|
||||
// 姓名 → 拼音(小写、无空格;首字按姓氏读音处理多音字)
|
||||
function nameToPinyin(name: string): string {
|
||||
const parts = pinyin(name, { toneType: 'none', type: 'array', surname: 'head' })
|
||||
return parts.join('')
|
||||
}
|
||||
|
||||
// 实时预览将创建的账号名;姓名非法或未输入时返回空
|
||||
const accountPreview = computed(() => {
|
||||
const n = form.realName.trim()
|
||||
if (!/^[\u4e00-\u9fa5]{2,10}$/.test(n)) return ''
|
||||
return `ext_${nameToPinyin(n)}`
|
||||
})
|
||||
|
||||
// 提交前的前端校验(姓名→拼音合法性兜底,防止姓名过长)
|
||||
function validUsername(uname: string): boolean {
|
||||
return /^[a-z][a-z0-9]{1,31}$/.test(uname)
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
const uname = nameToPinyin(form.realName.trim())
|
||||
if (!validUsername(uname)) {
|
||||
ElMessage.warning('姓名拼音过长,无法生成可用账号,请联系管理员')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await api.submitApproval({
|
||||
username: uname,
|
||||
email: form.email.trim(),
|
||||
supervisor: form.supervisor.trim(),
|
||||
purpose: form.purpose.trim(),
|
||||
})
|
||||
ElMessage.success('申请已提交,等待管理员审批(结果将邮件通知)')
|
||||
Object.assign(form, { realName: '', email: '', supervisor: '', purpose: '' })
|
||||
} catch {
|
||||
// 拦截器已提示(如拼音与已有申请重名)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auth-page">
|
||||
<el-card class="auth-card apply-card" shadow="never">
|
||||
<div class="auth-brand">
|
||||
<span class="brand-icon">
|
||||
<el-icon :size="26"><EditPen /></el-icon>
|
||||
</span>
|
||||
<h2>申请服务器账号</h2>
|
||||
<span class="sub">提交后由管理员审批,结果以邮件通知</span>
|
||||
</div>
|
||||
|
||||
<el-alert type="info" :closable="false" show-icon class="apply-tip">
|
||||
通过后自动创建账号 {{ accountPreview || 'ext_<username>' }}
|
||||
</el-alert>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px" @submit.prevent="onSubmit">
|
||||
<el-form-item label="姓名" prop="realName">
|
||||
<el-input v-model="form.realName" placeholder="输入中文姓名,如 张三" maxlength="10" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱" prop="email">
|
||||
<el-input v-model="form.email" placeholder="接收通知与 OTP 验证码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="挂靠老师" prop="supervisor">
|
||||
<el-input v-model="form.supervisor" />
|
||||
</el-form-item>
|
||||
<el-form-item label="用途" prop="purpose">
|
||||
<el-input v-model="form.purpose" type="textarea" :rows="3" placeholder="说明申请用途" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" class="auth-submit" :loading="submitting" @click="onSubmit">提交申请</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="auth-foot">
|
||||
<router-link to="/login">已有账号?返回登录</router-link>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.apply-card {
|
||||
width: 520px;
|
||||
}
|
||||
.apply-tip {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,32 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const backendOk = ref<boolean | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const resp = await fetch('/healthz')
|
||||
backendOk.value = resp.ok
|
||||
} catch {
|
||||
backendOk.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<b>工程骨架已就绪</b>
|
||||
</template>
|
||||
<p>ws_usernode M0:Go (Gin + GORM) 后端与 Vue3 前端脚手架已打通,API 前缀 <code>/api/v1</code>。</p>
|
||||
<p>
|
||||
后端健康检查:
|
||||
<el-tag :type="backendOk === null ? 'info' : backendOk ? 'success' : 'danger'">
|
||||
{{ backendOk === null ? '检测中…' : backendOk ? '正常' : '不可达(请先启动 Go 后端)' }}
|
||||
</el-tag>
|
||||
</p>
|
||||
<el-divider />
|
||||
<el-text type="info">登录 / 用户管理 / 密钥 / 审批 / 审计页面将在 M1 起逐步落地。</el-text>
|
||||
</el-card>
|
||||
</template>
|
||||
@@ -0,0 +1,224 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import * as api from '@/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { Captcha } from '@/api/types'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const tab = ref<'admin' | 'user'>('admin')
|
||||
const loading = ref(false) // 登录请求中
|
||||
const sendingOtp = ref(false) // 发送 OTP 中
|
||||
|
||||
// 管理员登录
|
||||
const adminForm = reactive({ username: '', password: '' })
|
||||
|
||||
// 外部用户 OTP 登录
|
||||
const userForm = reactive({ username: '', code: '', captcha_id: '', captcha_code: '' })
|
||||
const captcha = ref<Captcha | null>(null)
|
||||
const captchaLoading = ref(false)
|
||||
const cooldown = ref(0) // OTP 发送冷却倒计时(秒)
|
||||
let cooldownTimer: number | undefined
|
||||
|
||||
async function loadCaptcha() {
|
||||
captchaLoading.value = true
|
||||
try {
|
||||
captcha.value = await api.getCaptcha()
|
||||
} catch {
|
||||
captcha.value = null
|
||||
} finally {
|
||||
captchaLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startCooldown(secs: number) {
|
||||
cooldown.value = secs
|
||||
cooldownTimer = window.setInterval(() => {
|
||||
cooldown.value -= 1
|
||||
if (cooldown.value <= 0) {
|
||||
window.clearInterval(cooldownTimer)
|
||||
cooldown.value = 0
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
async function onAdminLogin() {
|
||||
if (!adminForm.username || !adminForm.password) {
|
||||
ElMessage.warning('请输入用户名和密码')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
await api.adminLogin(adminForm)
|
||||
await auth.fetchMe()
|
||||
router.push('/admin/dashboard')
|
||||
} catch {
|
||||
// 错误已由拦截器提示
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onSendOtp() {
|
||||
if (!userForm.username || !userForm.captcha_code) {
|
||||
ElMessage.warning('请输入用户名和图形验证码')
|
||||
return
|
||||
}
|
||||
if (!captcha.value) {
|
||||
await loadCaptcha()
|
||||
if (!captcha.value) return
|
||||
}
|
||||
sendingOtp.value = true
|
||||
try {
|
||||
await api.otpSend({
|
||||
username: userForm.username,
|
||||
captcha_id: captcha.value.captcha_id,
|
||||
captcha_code: userForm.captcha_code,
|
||||
})
|
||||
ElMessage.success('验证码已发送(邮件或 CLI:usernode user otp)')
|
||||
startCooldown(60)
|
||||
// 验证码一次性,重新生成
|
||||
userForm.captcha_code = ''
|
||||
await loadCaptcha()
|
||||
} catch {
|
||||
// 验证码错误等已提示;重新生成一次
|
||||
await loadCaptcha()
|
||||
} finally {
|
||||
sendingOtp.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onUserLogin() {
|
||||
if (!userForm.username || !userForm.code) {
|
||||
ElMessage.warning('请输入用户名和 OTP 验证码')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
await api.otpLogin({ username: userForm.username, code: userForm.code })
|
||||
await auth.fetchMe()
|
||||
router.push('/me/profile')
|
||||
} catch {
|
||||
// 已提示
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onTabChange() {
|
||||
if (tab.value === 'user' && !captcha.value) {
|
||||
loadCaptcha()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (tab.value === 'user') loadCaptcha()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auth-page">
|
||||
<el-card class="auth-card" shadow="never">
|
||||
<div class="auth-brand">
|
||||
<span class="brand-icon">
|
||||
<el-icon :size="26"><Monitor /></el-icon>
|
||||
</span>
|
||||
<h2>服务器用户管理节点</h2>
|
||||
</div>
|
||||
|
||||
<el-tabs v-model="tab" stretch @tab-change="onTabChange">
|
||||
|
||||
<!-- 外部用户 OTP 登录 -->
|
||||
<el-tab-pane label="外部用户登录" name="user">
|
||||
<el-form label-position="top" @submit.prevent="onUserLogin">
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="userForm.username" placeholder="用户名(可带或不带 ext_ 前缀)" autocomplete="username" />
|
||||
</el-form-item>
|
||||
<el-form-item label="图形验证码">
|
||||
<div class="inline-row">
|
||||
<el-input v-model="userForm.captcha_code" placeholder="4 位验证码" maxlength="4" @keyup.enter="onSendOtp" />
|
||||
<el-image
|
||||
v-if="captcha"
|
||||
:src="captcha.image"
|
||||
fit="contain"
|
||||
class="captcha-img"
|
||||
title="点击刷新"
|
||||
@click="loadCaptcha"
|
||||
/>
|
||||
<el-button v-else :loading="captchaLoading" @click="loadCaptcha">获取</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="登录验证码(OTP)">
|
||||
<div class="inline-row otp-row">
|
||||
<el-input
|
||||
v-model="userForm.code"
|
||||
placeholder="6 位 OTP(邮件或 CLI 获取)"
|
||||
maxlength="6"
|
||||
class="otp-input"
|
||||
@keyup.enter="onUserLogin"
|
||||
/>
|
||||
<el-button :loading="sendingOtp" :disabled="cooldown > 0" @click="onSendOtp">
|
||||
{{ cooldown > 0 ? `${cooldown}s 后重发` : '发送验证码' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-button type="primary" class="auth-submit" :loading="loading" @click="onUserLogin">登录</el-button>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 管理员登录 -->
|
||||
<el-tab-pane label="管理员登录" name="admin">
|
||||
<el-form label-position="top" @submit.prevent="onAdminLogin">
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="adminForm.username" placeholder="管理员用户名" autocomplete="username" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码">
|
||||
<el-input
|
||||
v-model="adminForm.password"
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
show-password
|
||||
autocomplete="current-password"
|
||||
@keyup.enter="onAdminLogin"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-button type="primary" class="auth-submit" :loading="loading" @click="onAdminLogin">登录</el-button>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<div class="auth-foot">
|
||||
<router-link to="/apply">还没有账号?申请新账号</router-link>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 输入框 + 相邻控件同行对齐 */
|
||||
.inline-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
.otp-row .otp-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.captcha-img {
|
||||
width: 110px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* tab 与品牌区的间距 */
|
||||
:deep(.el-tabs) {
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,151 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import * as api from '@/api'
|
||||
import type { Approval } from '@/api/types'
|
||||
import { approvalStatusText, fmtTime } from '@/utils/format'
|
||||
|
||||
const items = ref<Approval[]>([])
|
||||
const statusFilter = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const statusTag: Record<string, 'warning' | 'success' | 'danger'> = {
|
||||
pending: 'warning',
|
||||
approved: 'success',
|
||||
rejected: 'danger',
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await api.listApprovals(statusFilter.value || undefined)
|
||||
items.value = r.items
|
||||
} catch {
|
||||
// 已提示
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 拒绝对话框
|
||||
const rejectVisible = ref(false)
|
||||
const rejectTarget = ref<Approval | null>(null)
|
||||
const rejectForm = reactive({ reason: '' })
|
||||
const rejecting = ref(false)
|
||||
|
||||
function openReject(row: Approval) {
|
||||
rejectTarget.value = row
|
||||
rejectForm.reason = ''
|
||||
rejectVisible.value = true
|
||||
}
|
||||
|
||||
async function onApprove(row: Approval) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`通过后将为 ${row.username_requested} 自动创建账号(ext_${row.username_requested})并邮件通知,确认?`,
|
||||
'审批通过',
|
||||
{ type: 'warning', confirmButtonText: '确认通过', cancelButtonText: '取消' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.reviewApproval(row.id, true, '')
|
||||
ElMessage.success('已通过,账号创建中')
|
||||
load()
|
||||
} catch {
|
||||
// 已提示
|
||||
}
|
||||
}
|
||||
|
||||
async function onReject() {
|
||||
if (!rejectTarget.value) return
|
||||
if (!rejectForm.reason.trim()) {
|
||||
ElMessage.warning('拒绝时必须填写理由')
|
||||
return
|
||||
}
|
||||
rejecting.value = true
|
||||
try {
|
||||
await api.reviewApproval(rejectTarget.value.id, false, rejectForm.reason.trim())
|
||||
ElMessage.success('已拒绝')
|
||||
rejectVisible.value = false
|
||||
load()
|
||||
} catch {
|
||||
// 已提示
|
||||
} finally {
|
||||
rejecting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<el-card shadow="never">
|
||||
<div class="toolbar">
|
||||
<el-select v-model="statusFilter" placeholder="全部状态" clearable style="width: 150px" @change="load">
|
||||
<el-option label="待审批" value="pending" />
|
||||
<el-option label="已通过" value="approved" />
|
||||
<el-option label="已拒绝" value="rejected" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="load">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="items" stripe>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="username_requested" label="申请用户名" width="140">
|
||||
<template #default="{ row }">ext_{{ row.username_requested }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="email" label="邮箱" min-width="180" />
|
||||
<el-table-column prop="supervisor" label="挂靠老师" width="120" />
|
||||
<el-table-column prop="purpose" label="用途" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTag[row.status] ?? 'info'" size="small">{{ approvalStatusText[row.status] ?? row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="提交时间" width="170">
|
||||
<template #default="{ row }">{{ fmtTime(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="拒绝理由" min-width="140" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.reason || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.status === 'pending'">
|
||||
<el-button link type="success" @click="onApprove(row)">通过</el-button>
|
||||
<el-button link type="danger" @click="openReject(row)">拒绝</el-button>
|
||||
</template>
|
||||
<el-text v-else type="info" size="small">已处理</el-text>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="!loading && items.length === 0" description="暂无申请" />
|
||||
</el-card>
|
||||
|
||||
<!-- 拒绝对话框 -->
|
||||
<el-dialog v-model="rejectVisible" title="拒绝申请" width="440px">
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="用户名">
|
||||
<el-text>{{ rejectTarget?.username_requested }}</el-text>
|
||||
</el-form-item>
|
||||
<el-form-item label="理由" required>
|
||||
<el-input v-model="rejectForm.reason" type="textarea" :rows="3" placeholder="必填,将邮件通知申请人" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="rejectVisible = false">取消</el-button>
|
||||
<el-button type="danger" :loading="rejecting" @click="onReject">确认拒绝</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import * as api from '@/api'
|
||||
import type { AuditLog } from '@/api/types'
|
||||
import { actionText, fmtTime, parseDetail } from '@/utils/format'
|
||||
|
||||
const items = ref<AuditLog[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const loading = ref(false)
|
||||
|
||||
const filter = reactive({
|
||||
actor: '',
|
||||
action: '',
|
||||
resource_type: '',
|
||||
result: '',
|
||||
since: '',
|
||||
until: '',
|
||||
})
|
||||
|
||||
const resultTag: Record<string, 'success' | 'danger'> = { success: 'success', failed: 'danger' }
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await api.listAudit({
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
actor: filter.actor || undefined,
|
||||
action: filter.action || undefined,
|
||||
resource_type: filter.resource_type || undefined,
|
||||
result: filter.result || undefined,
|
||||
since: filter.since ? new Date(filter.since).toISOString() : undefined,
|
||||
until: filter.until ? new Date(filter.until).toISOString() : undefined,
|
||||
})
|
||||
items.value = r.items
|
||||
total.value = r.total
|
||||
} catch {
|
||||
// 已提示
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function onExport() {
|
||||
window.open(
|
||||
api.auditExportUrl({
|
||||
since: filter.since ? new Date(filter.since).toISOString() : undefined,
|
||||
until: filter.until ? new Date(filter.until).toISOString() : undefined,
|
||||
}),
|
||||
'_blank',
|
||||
)
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<el-card shadow="never">
|
||||
<!-- 筛选工具栏 -->
|
||||
<div class="toolbar">
|
||||
<el-input v-model="filter.actor" placeholder="操作者" clearable style="width: 150px" @keyup.enter="onSearch" />
|
||||
<el-input v-model="filter.action" placeholder="动作" clearable style="width: 160px" @keyup.enter="onSearch" />
|
||||
<el-select v-model="filter.resource_type" placeholder="资源类型" clearable style="width: 130px" @change="onSearch">
|
||||
<el-option label="用户" value="user" />
|
||||
<el-option label="密钥" value="ssh_key" />
|
||||
<el-option label="申请" value="approval" />
|
||||
</el-select>
|
||||
<el-select v-model="filter.result" placeholder="结果" clearable style="width: 110px" @change="onSearch">
|
||||
<el-option label="成功" value="success" />
|
||||
<el-option label="失败" value="failed" />
|
||||
</el-select>
|
||||
<el-date-picker
|
||||
v-model="filter.since"
|
||||
type="datetime"
|
||||
placeholder="起始时间"
|
||||
style="width: 190px"
|
||||
value-format="YYYY-MM-DDTHH:mm:ss"
|
||||
/>
|
||||
<el-date-picker
|
||||
v-model="filter.until"
|
||||
type="datetime"
|
||||
placeholder="结束时间"
|
||||
style="width: 190px"
|
||||
value-format="YYYY-MM-DDTHH:mm:ss"
|
||||
/>
|
||||
<el-button type="primary" @click="onSearch">查询</el-button>
|
||||
<div class="spacer" />
|
||||
<el-button type="success" @click="onExport">
|
||||
<el-icon><Download /></el-icon> 导出 CSV
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="items" stripe>
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column label="动作" min-width="130">
|
||||
<template #default="{ row }">{{ actionText(row.action) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="actor_name" label="操作者" width="120" />
|
||||
<el-table-column label="资源" min-width="130">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" type="info">{{ row.resource_type }}</el-tag>
|
||||
<span class="rid">{{ row.resource_id }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="详情" min-width="180" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ parseDetail(row.detail) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="结果" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="resultTag[row.result] ?? 'info'" size="small">
|
||||
{{ row.result === 'success' ? '成功' : '失败' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="ip" label="IP" width="130" />
|
||||
<el-table-column label="时间" width="170">
|
||||
<template #default="{ row }">{{ fmtTime(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
class="pager"
|
||||
@current-change="load"
|
||||
@size-change="load"
|
||||
/>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.pager {
|
||||
margin-top: 14px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.rid {
|
||||
margin-left: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,164 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import * as api from '@/api'
|
||||
import { listAudit } from '@/api'
|
||||
import type { AuditLog, UserStats } from '@/api/types'
|
||||
import { actionText, fmtTime, userStatusText } from '@/utils/format'
|
||||
|
||||
const router = useRouter()
|
||||
const stats = ref<UserStats | null>(null)
|
||||
const recentAudits = ref<AuditLog[]>([])
|
||||
const loading = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [s, a] = await Promise.all([api.getStats(), listAudit({ page: 1, page_size: 10 })])
|
||||
stats.value = s
|
||||
recentAudits.value = a.items
|
||||
} catch {
|
||||
// 拦截器已提示
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function goUsers(status?: string) {
|
||||
router.push(status ? { path: '/admin/users', query: { status } } : '/admin/users')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="4">
|
||||
<el-card shadow="never" class="stat-card" @click="goUsers()">
|
||||
<div class="stat-num">{{ stats?.total ?? '-' }}</div>
|
||||
<div class="stat-label">用户总数</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-card shadow="never" class="stat-card success" @click="goUsers('active')">
|
||||
<div class="stat-num">{{ stats?.active ?? '-' }}</div>
|
||||
<div class="stat-label">正常</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-card shadow="never" class="stat-card warning" @click="goUsers('disabled')">
|
||||
<div class="stat-num">{{ stats?.disabled ?? '-' }}</div>
|
||||
<div class="stat-label">已禁用</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-card shadow="never" class="stat-card danger" @click="goUsers('expired')">
|
||||
<div class="stat-num">{{ stats?.expired ?? '-' }}</div>
|
||||
<div class="stat-label">已过期</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-card shadow="never" class="stat-card primary" @click="router.push('/admin/approvals')">
|
||||
<div class="stat-num">{{ stats?.pending_approvals ?? '-' }}</div>
|
||||
<div class="stat-label">待审批申请</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-card shadow="never" class="stat-card info" @click="goUsers()">
|
||||
<div class="stat-num">{{ stats?.expiring_soon?.length ?? '-' }}</div>
|
||||
<div class="stat-label">30 天内到期</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="16" class="second-row">
|
||||
<el-col :span="12">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<b>近期待过期</b>
|
||||
<el-button link type="primary" @click="router.push('/admin/users')">全部用户</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="stats?.expiring_soon ?? []" size="small">
|
||||
<el-table-column prop="username" label="用户名" width="150" />
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'active' ? 'success' : 'info'" size="small">
|
||||
{{ userStatusText[row.status] ?? row.status }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="过期时间" min-width="160">
|
||||
<template #default="{ row }">{{ fmtTime(row.expire_at) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="!stats?.expiring_soon?.length" description="近 30 天无到期账号" :image-size="60" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<b>最近审计</b>
|
||||
<el-button link type="primary" @click="router.push('/admin/audit')">全部审计</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="recentAudits" size="small">
|
||||
<el-table-column label="动作" min-width="130">
|
||||
<template #default="{ row }">{{ actionText(row.action) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="actor_name" label="操作者" width="120" />
|
||||
<el-table-column label="结果" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.result === 'success' ? 'success' : 'danger'" size="small">
|
||||
{{ row.result === 'success' ? '成功' : '失败' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="时间" min-width="160">
|
||||
<template #default="{ row }">{{ fmtTime(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stat-card {
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
.stat-num {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.stat-label {
|
||||
margin-top: 4px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
.stat-card.success .stat-num {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
.stat-card.warning .stat-num {
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
.stat-card.danger .stat-num {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
.stat-card.primary .stat-num {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
.stat-card.info .stat-num {
|
||||
color: var(--el-color-info);
|
||||
}
|
||||
.second-row {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import * as api from '@/api'
|
||||
import type { SettingItem } from '@/api/types'
|
||||
|
||||
const items = ref<SettingItem[]>([])
|
||||
const loading = ref(false)
|
||||
const savingKey = ref('')
|
||||
|
||||
const descriptions: Record<string, { label: string; hint: string }> = {
|
||||
'policy.default_ttl': { label: '默认有效期', hint: '新账号默认有效期(如 2160h = 90 天)' },
|
||||
'policy.recycle_period': { label: '回收期', hint: '到期后保留时长,期内可延期恢复(如 720h = 30 天)' },
|
||||
'policy.audit_retention': { label: '审计保留', hint: '审计日志保留时长,超期先归档再清理(如 720h)' },
|
||||
}
|
||||
|
||||
const editableItems = computed(() =>
|
||||
items.value.filter((it) => it.key in descriptions).map((it) => ({ ...it, ...descriptions[it.key] })),
|
||||
)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await api.listSettings()
|
||||
items.value = r.items
|
||||
} catch {
|
||||
// 已提示
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onSave(row: { key: string; value: string }) {
|
||||
savingKey.value = row.key
|
||||
try {
|
||||
await api.updateSetting(row.key, row.value.trim())
|
||||
ElMessage.success('已保存并生效')
|
||||
await load()
|
||||
} catch {
|
||||
// 已提示
|
||||
} finally {
|
||||
savingKey.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<el-card v-loading="loading" shadow="never">
|
||||
<template #header><b>系统设置</b></template>
|
||||
<el-text type="info">以下策略通过 settings 表动态覆盖配置文件默认值,保存后立即生效。</el-text>
|
||||
<el-table :data="editableItems" stripe class="settings-table">
|
||||
<el-table-column label="配置项" width="140">
|
||||
<template #default="{ row }">{{ row.label }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Key" width="220">
|
||||
<template #default="{ row }"><code>{{ row.key }}</code></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="当前值">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.value" placeholder="时长格式,如 2160h">
|
||||
<template #suffix>
|
||||
<el-tag v-if="row.overridden" type="warning" size="small">已覆盖</el-tag>
|
||||
<el-tag v-else type="info" size="small">默认</el-tag>
|
||||
</template>
|
||||
</el-input>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="说明" min-width="240">
|
||||
<template #default="{ row }">{{ row.hint }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" size="small" :loading="savingKey === row.key" @click="onSave(row)">保存</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-alert type="info" :closable="false" class="notice">
|
||||
提示:SMTP、会话时长等参数在 <code>config.toml</code> 中配置(或环境变量覆盖),不在本页修改。
|
||||
</el-alert>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-table {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.notice {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,405 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import * as api from '@/api'
|
||||
import type { SSHKey, User } from '@/api/types'
|
||||
import { daysLeft, fmtTime, userStatusText } from '@/utils/format'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
// 列表与筛选
|
||||
const items = ref<User[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const filter = reactive({ status: '', supervisor: '', keyword: '' })
|
||||
const loading = ref(false)
|
||||
|
||||
const statusTag: Record<string, 'success' | 'info' | 'danger'> = {
|
||||
active: 'success',
|
||||
disabled: 'info',
|
||||
expired: 'danger',
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await api.listUsers({
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
status: filter.status || undefined,
|
||||
supervisor: filter.supervisor || undefined,
|
||||
})
|
||||
items.value = r.items
|
||||
total.value = r.total
|
||||
} catch {
|
||||
// 拦截器已提示
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function onPageChange(p: number) {
|
||||
page.value = p
|
||||
load()
|
||||
}
|
||||
|
||||
// 新建用户
|
||||
const createVisible = ref(false)
|
||||
const creating = ref(false)
|
||||
const createForm = reactive({
|
||||
username: '',
|
||||
email: '',
|
||||
supervisor: '',
|
||||
purpose: '',
|
||||
ttl_days: 0,
|
||||
})
|
||||
|
||||
async function onCreate() {
|
||||
if (!createForm.username || !createForm.email) {
|
||||
ElMessage.warning('用户名和邮箱必填')
|
||||
return
|
||||
}
|
||||
creating.value = true
|
||||
try {
|
||||
await api.createUser({
|
||||
username: createForm.username,
|
||||
email: createForm.email,
|
||||
supervisor: createForm.supervisor,
|
||||
purpose: createForm.purpose,
|
||||
ttl_days: createForm.ttl_days || undefined,
|
||||
})
|
||||
ElMessage.success('创建成功')
|
||||
createVisible.value = false
|
||||
Object.assign(createForm, { username: '', email: '', supervisor: '', purpose: '', ttl_days: 0 })
|
||||
load()
|
||||
} catch {
|
||||
// 已提示
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 详情抽屉(含密钥列表)
|
||||
const detailVisible = ref(false)
|
||||
const detail = ref<User | null>(null)
|
||||
const detailKeys = ref<SSHKey[]>([])
|
||||
const detailLoading = ref(false)
|
||||
|
||||
async function openDetail(row: User) {
|
||||
detail.value = row
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
try {
|
||||
const [u, k] = await Promise.all([api.getUser(row.id), api.listUserKeys(row.id)])
|
||||
detail.value = u
|
||||
detailKeys.value = k.items
|
||||
} catch {
|
||||
// 已提示
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑(邮箱/挂靠老师/用途)
|
||||
const editVisible = ref(false)
|
||||
const editing = ref(false)
|
||||
const editForm = reactive({ email: '', supervisor: '', purpose: '' })
|
||||
|
||||
function openEdit() {
|
||||
if (!detail.value) return
|
||||
editForm.email = detail.value.email
|
||||
editForm.supervisor = detail.value.supervisor
|
||||
editForm.purpose = detail.value.purpose
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
async function onEdit() {
|
||||
if (!detail.value) return
|
||||
editing.value = true
|
||||
try {
|
||||
await api.updateUser(detail.value.id, {
|
||||
email: editForm.email || undefined,
|
||||
supervisor: editForm.supervisor || undefined,
|
||||
purpose: editForm.purpose || undefined,
|
||||
})
|
||||
ElMessage.success('已更新')
|
||||
editVisible.value = false
|
||||
openDetail(detail.value) // 刷新详情
|
||||
load()
|
||||
} catch {
|
||||
// 已提示
|
||||
} finally {
|
||||
editing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 延期
|
||||
const extendVisible = ref(false)
|
||||
const extending = ref(false)
|
||||
const extendDays = ref(0)
|
||||
|
||||
function openExtend() {
|
||||
extendDays.value = 0
|
||||
extendVisible.value = true
|
||||
}
|
||||
|
||||
async function onExtend() {
|
||||
if (!detail.value) return
|
||||
extending.value = true
|
||||
try {
|
||||
const r = await api.extendUser(detail.value.id, extendDays.value)
|
||||
ElMessage.success(`已延期,新过期时间:${fmtTime(r.expire_at)}`)
|
||||
extendVisible.value = false
|
||||
openDetail(detail.value)
|
||||
load()
|
||||
} catch {
|
||||
// 已提示
|
||||
} finally {
|
||||
extending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 禁用 / 启用 / 删除
|
||||
async function onDisable() {
|
||||
if (!detail.value) return
|
||||
try {
|
||||
await api.disableUser(detail.value.id)
|
||||
ElMessage.success('已禁用(authorized_keys 已清空)')
|
||||
openDetail(detail.value)
|
||||
load()
|
||||
} catch {
|
||||
// 已提示
|
||||
}
|
||||
}
|
||||
|
||||
async function onEnable() {
|
||||
if (!detail.value) return
|
||||
try {
|
||||
await api.enableUser(detail.value.id)
|
||||
ElMessage.success('已启用')
|
||||
openDetail(detail.value)
|
||||
load()
|
||||
} catch {
|
||||
// 已提示
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
if (!detail.value) return
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`将删除用户 ${detail.value.username}(系统账号 + 家目录 + 密钥),审计记录保留。确认?`,
|
||||
'危险操作',
|
||||
{ type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.deleteUser(detail.value.id)
|
||||
ElMessage.success('已删除')
|
||||
detailVisible.value = false
|
||||
load()
|
||||
} catch {
|
||||
// 已提示
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (route.query.status) filter.status = String(route.query.status)
|
||||
load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<el-card shadow="never">
|
||||
<!-- 筛选工具栏 -->
|
||||
<div class="toolbar">
|
||||
<el-input v-model="filter.supervisor" placeholder="挂靠老师" clearable style="width: 180px" @keyup.enter="onSearch" />
|
||||
<el-select v-model="filter.status" placeholder="状态" clearable style="width: 130px" @change="onSearch">
|
||||
<el-option label="正常" value="active" />
|
||||
<el-option label="已禁用" value="disabled" />
|
||||
<el-option label="已过期" value="expired" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="onSearch">查询</el-button>
|
||||
<div class="spacer" />
|
||||
<el-button type="primary" @click="createVisible = true">
|
||||
<el-icon><Plus /></el-icon> 新建用户
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="items" stripe>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="username" label="用户名" min-width="140" />
|
||||
<el-table-column prop="email" label="邮箱" min-width="200" />
|
||||
<el-table-column prop="supervisor" label="挂靠老师" width="130" />
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTag[row.status] ?? 'info'" size="small">{{ userStatusText[row.status] ?? row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="过期时间" width="170">
|
||||
<template #default="{ row }">
|
||||
<span>{{ fmtTime(row.expire_at) }}</span>
|
||||
<el-tag v-if="row.status === 'active' && (daysLeft(row.expire_at) ?? 999) <= 30" type="warning" size="small" class="soon-tag">
|
||||
剩 {{ daysLeft(row.expire_at) }} 天
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
class="pager"
|
||||
@current-change="onPageChange"
|
||||
@size-change="onPageChange"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- 新建用户对话框 -->
|
||||
<el-dialog v-model="createVisible" title="新建外部用户" width="480px">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="用户名" required>
|
||||
<el-input v-model="createForm.username" placeholder="不含 ext_ 前缀(自动生成 ext_<name>)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱" required>
|
||||
<el-input v-model="createForm.email" placeholder="接收 OTP 与通知邮件" />
|
||||
</el-form-item>
|
||||
<el-form-item label="挂靠老师">
|
||||
<el-input v-model="createForm.supervisor" />
|
||||
</el-form-item>
|
||||
<el-form-item label="用途">
|
||||
<el-input v-model="createForm.purpose" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
<el-form-item label="有效期(天)">
|
||||
<el-input-number v-model="createForm.ttl_days" :min="0" :max="3650" />
|
||||
<div class="tip">留空/0 表示使用系统默认有效期</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="creating" @click="onCreate">创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 详情抽屉 -->
|
||||
<el-drawer v-model="detailVisible" :title="detail?.username ?? ''" size="560px" v-loading="detailLoading">
|
||||
<template v-if="detail">
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="用户名">{{ detail.username }}</el-descriptions-item>
|
||||
<el-descriptions-item label="邮箱">{{ detail.email }}</el-descriptions-item>
|
||||
<el-descriptions-item label="挂靠老师">{{ detail.supervisor || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用途">{{ detail.purpose || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="statusTag[detail.status] ?? 'info'" size="small">{{ userStatusText[detail.status] ?? detail.status }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="过期时间">{{ fmtTime(detail.expire_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Shell">{{ detail.shell }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最近登录">{{ fmtTime(detail.last_login_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ fmtTime(detail.created_at) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="actions">
|
||||
<el-button v-if="detail.status === 'active'" @click="onDisable">禁用</el-button>
|
||||
<el-button v-if="detail.status === 'disabled'" type="success" @click="onEnable">启用</el-button>
|
||||
<el-button v-if="detail.status === 'expired'" type="warning" @click="openExtend">延期恢复</el-button>
|
||||
<el-button type="primary" plain @click="openExtend">延期</el-button>
|
||||
<el-button @click="openEdit">编辑信息</el-button>
|
||||
<el-button type="danger" plain @click="onDelete">删除</el-button>
|
||||
</div>
|
||||
|
||||
<el-divider content-position="left">SSH 公钥({{ detailKeys.length }})</el-divider>
|
||||
<el-table :data="detailKeys" size="small">
|
||||
<el-table-column prop="name" label="名称" width="140" />
|
||||
<el-table-column prop="key_type" label="类型" width="120" />
|
||||
<el-table-column prop="fingerprint" label="指纹" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'active' ? 'success' : 'danger'" size="small">
|
||||
{{ row.status === 'active' ? '生效' : '已吊销' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
</el-drawer>
|
||||
|
||||
<!-- 编辑信息对话框 -->
|
||||
<el-dialog v-model="editVisible" title="编辑用户信息" width="460px">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="邮箱">
|
||||
<el-input v-model="editForm.email" />
|
||||
</el-form-item>
|
||||
<el-form-item label="挂靠老师">
|
||||
<el-input v-model="editForm.supervisor" />
|
||||
</el-form-item>
|
||||
<el-form-item label="用途">
|
||||
<el-input v-model="editForm.purpose" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="editing" @click="onEdit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 延期对话框 -->
|
||||
<el-dialog v-model="extendVisible" title="延长有效期" width="400px">
|
||||
<el-form label-width="120px">
|
||||
<el-form-item label="延长天数">
|
||||
<el-input-number v-model="extendDays" :min="0" :max="3650" />
|
||||
<div class="tip">留空/0 表示使用系统默认有效期</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="extendVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="extending" @click="onExtend">确认延期</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.pager {
|
||||
margin-top: 14px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.soon-tag {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.actions {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tip {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,179 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import * as api from '@/api'
|
||||
import type { SSHKey } from '@/api/types'
|
||||
import { fmtTime } from '@/utils/format'
|
||||
|
||||
const keys = ref<SSHKey[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const uploadVisible = ref(false)
|
||||
const uploading = ref(false)
|
||||
const uploadForm = reactive({ name: '', public_key: '' })
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await api.listMyKeys()
|
||||
keys.value = r.items
|
||||
} catch {
|
||||
// 已提示
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onUpload() {
|
||||
if (!uploadForm.name.trim()) {
|
||||
ElMessage.warning('请填写密钥名称')
|
||||
return
|
||||
}
|
||||
if (!uploadForm.public_key.trim()) {
|
||||
ElMessage.warning('请粘贴公钥内容')
|
||||
return
|
||||
}
|
||||
uploading.value = true
|
||||
try {
|
||||
await api.createKey({ name: uploadForm.name.trim(), public_key: uploadForm.public_key.trim() })
|
||||
ElMessage.success('上传成功,已同步到 authorized_keys')
|
||||
uploadVisible.value = false
|
||||
Object.assign(uploadForm, { name: '', public_key: '' })
|
||||
load()
|
||||
} catch {
|
||||
// 已提示(格式错误/重复/非 active 等)
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const renameVisible = ref(false)
|
||||
const renameTarget = ref<SSHKey | null>(null)
|
||||
const renameName = ref('')
|
||||
const renaming = ref(false)
|
||||
|
||||
function openRename(row: SSHKey) {
|
||||
renameTarget.value = row
|
||||
renameName.value = row.name
|
||||
renameVisible.value = true
|
||||
}
|
||||
|
||||
async function onRename() {
|
||||
if (!renameTarget.value || !renameName.value.trim()) return
|
||||
renaming.value = true
|
||||
try {
|
||||
await api.renameKey(renameTarget.value.id, renameName.value.trim())
|
||||
ElMessage.success('已重命名')
|
||||
renameVisible.value = false
|
||||
load()
|
||||
} catch {
|
||||
// 已提示
|
||||
} finally {
|
||||
renaming.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onRevoke(row: SSHKey) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`吊销密钥「${row.name}」(${row.fingerprint})后将立即从 authorized_keys 移除,SSH 登录立即失效。确认?`,
|
||||
'吊销密钥',
|
||||
{ type: 'warning', confirmButtonText: '确认吊销', cancelButtonText: '取消' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.revokeKey(row.id)
|
||||
ElMessage.success('已吊销')
|
||||
load()
|
||||
} catch {
|
||||
// 已提示
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<b>我的 SSH 公钥({{ keys.length }})</b>
|
||||
<el-button type="primary" size="small" @click="uploadVisible = true">
|
||||
<el-icon><Plus /></el-icon> 上传公钥
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table v-loading="loading" :data="keys" stripe>
|
||||
<el-table-column prop="name" label="名称" min-width="140" />
|
||||
<el-table-column prop="key_type" label="类型" width="120" />
|
||||
<el-table-column prop="fingerprint" label="指纹" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'active' ? 'success' : 'danger'" size="small">
|
||||
{{ row.status === 'active' ? '生效' : '已吊销' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="上传时间" width="170">
|
||||
<template #default="{ row }">{{ fmtTime(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.status === 'active'">
|
||||
<el-button link type="primary" @click="openRename(row)">重命名</el-button>
|
||||
<el-button link type="danger" @click="onRevoke(row)">吊销</el-button>
|
||||
</template>
|
||||
<el-text v-else type="info" size="small">已吊销</el-text>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="!loading && keys.length === 0" description="暂无密钥,上传公钥后即可 SSH 登录" />
|
||||
</el-card>
|
||||
|
||||
<!-- 上传公钥 -->
|
||||
<el-dialog v-model="uploadVisible" title="上传 SSH 公钥" width="520px">
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="名称" required>
|
||||
<el-input v-model="uploadForm.name" placeholder="如 我的笔记本" maxlength="64" />
|
||||
</el-form-item>
|
||||
<el-form-item label="公钥" required>
|
||||
<el-input
|
||||
v-model="uploadForm.public_key"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
placeholder="ssh-ed25519 AAAA... 或 ssh-rsa AAAA..."
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="uploadVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="uploading" @click="onUpload">上传</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 重命名 -->
|
||||
<el-dialog v-model="renameVisible" title="重命名密钥" width="400px">
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="名称">
|
||||
<el-input v-model="renameName" maxlength="64" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="renameVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="renaming" @click="onRename">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import * as api from '@/api'
|
||||
import type { AuditLog } from '@/api/types'
|
||||
import { actionText, fmtTime, parseDetail } from '@/utils/format'
|
||||
|
||||
const items = ref<AuditLog[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const loading = ref(false)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await api.myAudit(page.value, pageSize.value)
|
||||
items.value = r.items
|
||||
total.value = r.total
|
||||
} catch {
|
||||
// 已提示
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<el-card shadow="never">
|
||||
<template #header><b>我的审计痕迹</b></template>
|
||||
<el-table v-loading="loading" :data="items" stripe>
|
||||
<el-table-column label="动作" min-width="140">
|
||||
<template #default="{ row }">{{ actionText(row.action) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="详情" min-width="200" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ parseDetail(row.detail) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="结果" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.result === 'success' ? 'success' : 'danger'" size="small">
|
||||
{{ row.result === 'success' ? '成功' : '失败' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="ip" label="IP" width="130" />
|
||||
<el-table-column label="时间" width="170">
|
||||
<template #default="{ row }">{{ fmtTime(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
class="pager"
|
||||
@current-change="load"
|
||||
@size-change="load"
|
||||
/>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pager {
|
||||
margin-top: 14px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import * as api from '@/api'
|
||||
import type { User } from '@/api/types'
|
||||
import { daysLeft, fmtTime, userStatusText } from '@/utils/format'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const user = ref<User | null>(null)
|
||||
const loading = ref(true)
|
||||
|
||||
const username = computed(() => auth.me?.username ?? '')
|
||||
|
||||
const statusTag: Record<string, 'success' | 'info' | 'danger'> = {
|
||||
active: 'success',
|
||||
disabled: 'info',
|
||||
expired: 'danger',
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// 用户名含 ext_ 前缀,经 GetByUsername 语义查询详情
|
||||
const list = await api.listUsers({ page_size: 100 })
|
||||
const me = list.items.find((u) => u.username === username.value) ?? null
|
||||
user.value = me
|
||||
} catch {
|
||||
// 已提示
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading">
|
||||
<el-card shadow="never" class="profile-card">
|
||||
<template #header><b>账号信息</b></template>
|
||||
<template v-if="user">
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="用户名">{{ user.username }}</el-descriptions-item>
|
||||
<el-descriptions-item label="邮箱">{{ user.email }}</el-descriptions-item>
|
||||
<el-descriptions-item label="挂靠老师">{{ user.supervisor || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用途">{{ user.purpose || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="statusTag[user.status] ?? 'info'" size="small">{{ userStatusText[user.status] ?? user.status }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="过期时间">
|
||||
<span>{{ fmtTime(user.expire_at) }}</span>
|
||||
<el-tag v-if="user.status === 'active' && (daysLeft(user.expire_at) ?? 999) <= 30" type="warning" size="small" class="soon-tag">
|
||||
剩余 {{ daysLeft(user.expire_at) }} 天
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="最近登录">{{ fmtTime(user.last_login_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Shell">{{ user.shell }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-alert v-if="user.status === 'expired'" type="error" :closable="false" class="notice">
|
||||
账号已过期。请在回收期内联系管理员办理延期,否则将被自动回收删除。
|
||||
</el-alert>
|
||||
<el-alert type="info" :closable="false" class="notice">
|
||||
系统账号口令已锁定(仅支持 SSH 密钥登录)。请到「我的密钥」上传公钥后即可 SSH 登录。
|
||||
</el-alert>
|
||||
</template>
|
||||
<el-empty v-else description="未找到账号信息" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.profile-card {
|
||||
max-width: 640px;
|
||||
}
|
||||
.notice {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.soon-tag {
|
||||
margin-left: 8px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user