Compare commits

...
10 Commits
Author SHA1 Message Date
cao.wangrenbo 24430f1867 chore: internal/webui/dist 改为不追踪(构建产物),新增 AGENTS.md
- .gitignore 增加 internal/webui/dist/,构建产物不再纳入版本控制
  (与 web/dist 一致;go:embed 仍依赖磁盘产物,不依赖 git 跟踪)
- 从索引移除已跟踪的 internal/webui/dist(历史提交仍保留旧产物)
- 新增 AGENTS.md:项目目的、架构、容器构建流程(前端 node:20-alpine
  与 Go golang:1.22-alpine 均走 podman)、环境注意事项与编码约定
2026-08-30 17:30:16 +08:00
cao.wangrenbo 70c084e7e9 feat(web): 审计页支持按结果筛选错误日志
- AuditListParams 增加 result 参数并透传
- 审计页筛选工具栏增加'结果'下拉(成功/失败),选中即筛出对应事件
- 管理员选'失败'即可集中查看错误事件(如重名申请冲突)
2026-08-30 17:28:46 +08:00
cao.wangrenbo 4029936744 feat(web): 登录页外部用户登录优先
- tab 顺序调整:外部用户 OTP 登录置前,管理员登录置后
- 移除品牌区副标题'账号申请 · SSH 密钥 · 使用审计'
2026-08-30 17:28:24 +08:00
cao.wangrenbo ab3a874556 feat(web): 申请页改用中文姓名,前端转拼音提交
- ApplyView 的'姓名'字段(2~10 个汉字)替代自定义用户名
- 前端经 pinyin-pro(toneType:none + surname:head 处理姓氏多音字)转拼音作为用户名
- 提交前兜底校验拼音用户名长度(后端规则 [a-z][a-z0-9]{1,31})
- alert 文案改为实时预览 '通过后自动创建账号 ext_<username>'
- 新增依赖 pinyin-pro@3.29.3
2026-08-30 17:28:05 +08:00
cao.wangrenbo 7340a3dd48 feat(api): 审计查询支持按结果筛选
- AuditFilter 增加 Result 字段,Query 按 result 精确过滤
- GET /audit 接口解析 result 查询参数(success/failed)
2026-08-30 17:27:49 +08:00
cao.wangrenbo 13c21dfd7e build(webui): 按 podman 正常流程重建前端产物并嵌入 Go 二进制
- make web-build(ws-usernode-web-dev 容器)重建 web/dist → internal/webui/dist
- 清空旧 hash 产物,避免 go:embed 冗余
- make build-embed 产出 bin/usernode(22MB,含新主题前端)
2026-08-30 11:46:21 +08:00
cao.wangrenbo 472c66ebd8 feat(web): 深度定制 Element Plus 主题 — 靛蓝主色/暗色模式/侧栏折叠/面包屑/登录页重绘
- 新增 styles/theme.css:CSS 变量覆盖默认主题(主色 #4F46E5、圆角、阴影、页面背景),html.dark 暗色变量
- 新增 stores/theme.ts:暗色/亮色切换,localStorage 持久化
- main.ts 引入 element-plus 暗色 css-vars 与主题文件,挂载前初始化主题
- index.html 加 SVG favicon 与防暗色闪烁内联脚本
- AdminLayout:深色可折叠侧栏、header 面包屑 + 暗色切换按钮、页面切换过渡
- UserLayout:header 打磨 + 暗色切换按钮
- LoginView/ApplyView:统一品牌渐变背景与卡片,登录页 OTP 输入行内联发送按钮、登录按钮唯一通栏主按钮(修复按钮错位)
2026-08-30 11:35:44 +08:00
cao.wangrenbo b305d7b371 feat(M5): 前端完善 + 部署 — Vue3 全量页面、仪表盘统计与我的审计接口、systemd/sudoers/迁移脚本、部署文档 2026-08-30 10:50:46 +08:00
cao.wangrenbo c0e2ff975a feat(M4): 生命周期 + 审计 — 到期锁定/回收 cron、审计查询/CSV 导出/每日归档、settings 动态策略 2026-08-30 10:17:22 +08:00
cao.wangrenbo 1ea18490e0 feat(M3): 申请审批 + 邮件队列 — 公开申请、管理员审批自动建号、mail_logs 落库重试 2026-08-30 10:01:38 +08:00
65 changed files with 7647 additions and 264 deletions
+1
View File
@@ -8,6 +8,7 @@ coverage.out
web/node_modules/
web/dist/
web/.vite/
internal/webui/dist/
# 运行时产物
*.log
+110
View File
@@ -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 + GORMSQLite 开发 / 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/ CLIserve / 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 了 proxychainspodman 拉镜像会失败,需:`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`。
+1 -1
View File
@@ -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
View File
@@ -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)
+4
View File
@@ -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,超期审计先归档再清理
+102
View File
@@ -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" → TEXTAUTOINCREMENT 去掉(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 库"
+6
View File
@@ -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)。
+57
View File
@@ -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
View File
@@ -0,0 +1,85 @@
# API 文档(RESTful v1
统一前缀 `/api/v1`;会话基于 cookieHttpOnly/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 |
## 用户 /usersadmin
| 方法 | 路径 | 说明 |
|---|---|---|
| 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_keysSSH 立即失效) |
| POST | `/users/:id/enable` | 启用(按 DB 密钥恢复) |
| POST | `/users/:id/extend` | 延期。body: `{days(0=默认)}``{expire_at}`;已过期在回收期内可恢复 |
| DELETE | `/users/:id` | 删除并回收(系统账号+家目录+密钥,保留审计) |
| GET | `/users/:id/keys` | 用户密钥列表 |
## 我的 /meuser
| 方法 | 路径 | 说明 |
|---|---|---|
| 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 |
## 审计 /auditadmin
| 方法 | 路径 | 说明 |
|---|---|---|
| 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` 下载) |
## 系统设置 /settingsadmin
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/settings` | 全部设置项 `{items: [{key, value, overridden}]}`config 默认 + settings 覆盖) |
| PUT | `/settings` | 更新 `{key, value}`(白名单 key`policy.default_ttl` / `policy.recycle_period` / `policy.audit_retention`,值为时长) |
## 仪表盘 /adminadmin
| 方法 | 路径 | 说明 |
|---|---|---|
| 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
View File
@@ -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
```
> 代理环境:容器构建无法继承 proxychainsMakefile 通过 `--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 数据可迁移至 MySQLdeploy/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 文件;MySQLmysqldump |
+193 -4
View File
@@ -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)
}
}
+104
View File
@@ -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)
}
+103
View File
@@ -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
View File
@@ -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),
+108
View File
@@ -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"])
}
}
+57
View File
@@ -0,0 +1,57 @@
package api
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"ws_usernode/internal/service"
)
// SettingsHandler 系统设置接口(adminPLAN 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})
}
+30
View File
@@ -0,0 +1,30 @@
// 仪表盘统计接口(admin):GET /admin/statsPLAN §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
View File
@@ -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 —— 删除并回收(系统账号 + 家目录 + 密钥,保留审计)。
+10
View File
@@ -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
View File
@@ -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))
}
}
+3 -3
View File
@@ -1,6 +1,6 @@
// Package mail 提供邮件发送抽象。M1 提供基础直发(net/smtp + STARTTLS
// 发送队列/重试/失败记录(mail_logs)在 M3 完善;SMTP 未配置时退化为
// LogMailer(仅打印,不阻断业务——OTP 邮件失败不阻断 CLI 通道)。
// Package mail 提供邮件发送抽象。M1 提供基础直发(net/smtp + STARTTLS
// M3 引入 QueuedMailermail_logs 队列 + 失败重试,见 queue.go),
// SMTP 未配置时退化为 LogMailer(仅打印,不阻断业务——OTP 邮件失败不阻断 CLI 通道)。
package mail
import (
+84
View File
@@ -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
}
+130
View File
@@ -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=2Retry 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)
}
}
+2
View File
@@ -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
View File
@@ -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:embeddev 阶段由 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) {
+201
View File
@@ -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)
}
}
+164
View File
@@ -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)
}
// 默认 TTL90 天)生效
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
View File
@@ -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 BOMExcel 直接打开不乱码。
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 BOMExcel 识别中文
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
}
+1 -1
View File
@@ -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{}
+132
View File
@@ -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_keysSSH 立即失效)+ 状态置 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)
}
}
+299
View File
@@ -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)
}
}
+129
View File
@@ -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"}
}
+82 -9
View File
@@ -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_atdays<=0 用配置默认 TTL)。
// Extend 延长有效期:重设 expire_atdays<=0 用默认 TTLsettings 覆盖优先)。
// 已过期用户在回收期内可经此恢复(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)+ 家目录 + 密钥记录,
+2 -2
View File
@@ -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
View File
@@ -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 M0Go (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
File diff suppressed because one or more lines are too long
-13
View File
@@ -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>
+15
View File
@@ -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>
+2138
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -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
View File
@@ -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
View File
@@ -1,11 +1,31 @@
import axios from 'axios'
import axios, { AxiosError } from 'axios'
import { ElMessage } from 'element-plus'
// Axios 实例:baseURL 为 /api/v1Vite dev proxy 转发到 Go 后端;
// 生产由 go:embed 同源提供)。M1 起在此统一拦截 401 / 错误码
// Axios 实例:baseURL 为 /api/v1Vite dev proxy 转发到 Go 后端;生产 go:embed 同源)。
// withCredentialscookie 会话
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
+192
View File
@@ -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'))
}
+100
View File
@@ -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[]
}
+2 -1
View File
@@ -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
}
+212
View File
@@ -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>
+128
View File
@@ -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>
+13
View File
@@ -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
View File
@@ -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
+41
View File
@@ -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 }
})
+45
View File
@@ -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 }
})
+186
View File
@@ -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;
}
+69
View File
@@ -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
}
}
+126
View File
@@ -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>
-32
View File
@@ -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 M0Go (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>
+224
View File
@@ -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('验证码已发送(邮件或 CLIusernode 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>
+151
View File
@@ -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>
+162
View File
@@ -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>&nbsp;导出 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>
+164
View File
@@ -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>
+94
View File
@@ -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>
+405
View File
@@ -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>&nbsp;新建用户
</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>
+179
View File
@@ -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>&nbsp;上传公钥
</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>
+71
View File
@@ -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>
+78
View File
@@ -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>