feat(M5): 前端完善 + 部署 — Vue3 全量页面、仪表盘统计与我的审计接口、systemd/sudoers/迁移脚本、部署文档

This commit is contained in:
2026-08-30 10:50:46 +08:00
parent c0e2ff975a
commit b305d7b371
67 changed files with 3209 additions and 171 deletions
+17
View File
@@ -83,3 +83,20 @@ func (h *AuditHandler) Export(c *gin.Context) {
// 失败由审计归档兜底),正常路径写入 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})
}
+3 -1
View File
@@ -27,6 +27,7 @@ type Handler struct {
Approval *ApprovalHandler
Audit *AuditHandler
Settings *SettingsHandler
Stats *StatsHandler
authSvc *service.AuthService
auditSvc *service.AuditService
@@ -45,6 +46,7 @@ func New(cfg *config.Config, authSvc *service.AuthService, userSvc *service.User
h.Approval = &ApprovalHandler{svc: approvalSvc, cfg: cfg, h: h}
h.Audit = NewAuditHandler(auditSvc, cfg)
h.Settings = NewSettingsHandler(settingsSvc)
h.Stats = NewStatsHandler(userSvc)
return h
}
@@ -71,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-m4",
"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"])
}
}
+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)
}