feat(M5): 前端完善 + 部署 — Vue3 全量页面、仪表盘统计与我的审计接口、systemd/sudoers/迁移脚本、部署文档
This commit is contained in:
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAPIStats 仪表盘统计:admin 登录后 /admin/stats 返回各状态数与待审批数。
|
||||
func TestAPIStats(t *testing.T) {
|
||||
app := setupTestApp(t)
|
||||
|
||||
w := app.doJSON(http.MethodGet, "/api/v1/admin/stats", nil)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauth stats status = %d, want 401", w.Code)
|
||||
}
|
||||
|
||||
w = app.doJSON(http.MethodPost, "/api/v1/auth/admin/login", map[string]string{"username": "root", "password": "Passw0rd"})
|
||||
ck := sessionCookie(t, w)
|
||||
|
||||
// 建 1 个用户 + 1 个待审批申请,stats 应反映
|
||||
w = app.doJSON(http.MethodPost, "/api/v1/users", map[string]any{
|
||||
"username": "m5stats", "email": "m5stats@example.com",
|
||||
"supervisor": "profA", "purpose": "test",
|
||||
}, ck)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("create user status = %d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
w = app.doJSON(http.MethodPost, "/api/v1/approvals", map[string]any{
|
||||
"username": "m5apply", "email": "m5apply@example.com",
|
||||
"supervisor": "profB", "purpose": "apply test",
|
||||
})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("submit approval status = %d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
w = app.doJSON(http.MethodGet, "/api/v1/admin/stats", nil, ck)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("stats status = %d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
m := decodeBody(t, w)
|
||||
data := m["data"].(map[string]any)
|
||||
if data["total"].(float64) != 1 {
|
||||
t.Fatalf("stats total = %v, want 1", data["total"])
|
||||
}
|
||||
if data["pending_approvals"].(float64) != 1 {
|
||||
t.Fatalf("pending_approvals = %v, want 1", data["pending_approvals"])
|
||||
}
|
||||
if _, ok := data["expiring_soon"]; !ok {
|
||||
t.Fatalf("stats missing expiring_soon: %v", data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPIMeAudit 外部用户"我的审计":登录后可查自己的审计痕迹,管理端接口拒绝 user 会话。
|
||||
func TestAPIMeAudit(t *testing.T) {
|
||||
app := setupTestApp(t)
|
||||
|
||||
// 建用户 + 通过 OTP 登录(复用 m3 流程)
|
||||
adminW := app.doJSON(http.MethodPost, "/api/v1/auth/admin/login", map[string]string{"username": "root", "password": "Passw0rd"})
|
||||
ack := sessionCookie(t, adminW)
|
||||
adminW = app.doJSON(http.MethodPost, "/api/v1/users", map[string]any{
|
||||
"username": "meaudit", "email": "meaudit@example.com",
|
||||
}, ack)
|
||||
if adminW.Code != http.StatusOK {
|
||||
t.Fatalf("create user status = %d, body=%s", adminW.Code, adminW.Body.String())
|
||||
}
|
||||
|
||||
// OTP 双通道:经 store 取验证码(模拟用户看到图形验证码)+ 登录
|
||||
|
||||
// OTP 双通道:经 store 取验证码(模拟用户看到图形验证码)+ 登录
|
||||
cap, err := app.captchas.New()
|
||||
if err != nil {
|
||||
t.Fatalf("captcha new: %v", err)
|
||||
}
|
||||
sendW := app.doJSON(http.MethodPost, "/api/v1/auth/otp/send", map[string]string{
|
||||
"username": "meaudit", "captcha_id": cap.ID, "captcha_code": cap.Text,
|
||||
})
|
||||
if sendW.Code != http.StatusOK {
|
||||
t.Fatalf("otp send status = %d, body=%s", sendW.Code, sendW.Body.String())
|
||||
}
|
||||
code, err := app.otps.Current(context.Background(), "ext_meaudit")
|
||||
if err != nil {
|
||||
t.Fatalf("get otp: %v", err)
|
||||
}
|
||||
loginW := app.doJSON(http.MethodPost, "/api/v1/auth/otp/login", map[string]string{"username": "meaudit", "code": code})
|
||||
if loginW.Code != http.StatusOK {
|
||||
t.Fatalf("otp login status = %d, body=%s", loginW.Code, loginW.Body.String())
|
||||
}
|
||||
uck := sessionCookie(t, loginW)
|
||||
|
||||
// 用户访问管理端审计 → 403
|
||||
w := app.doJSON(http.MethodGet, "/api/v1/audit", nil, uck)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("user access admin audit status = %d, want 403", w.Code)
|
||||
}
|
||||
|
||||
// 我的审计:登录成功等操作应可见
|
||||
w = app.doJSON(http.MethodGet, "/api/v1/me/audit", nil, uck)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("me/audit status = %d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
m := decodeBody(t, w)
|
||||
data := m["data"].(map[string]any)
|
||||
if data["total"].(float64) < 1 {
|
||||
t.Fatalf("me/audit total = %v, want >= 1", data["total"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// 仪表盘统计接口(admin):GET /admin/stats(PLAN §8 仪表盘数据)。
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"ws_usernode/internal/service"
|
||||
)
|
||||
|
||||
// StatsHandler 仪表盘统计。
|
||||
type StatsHandler struct {
|
||||
userSvc *service.UserService
|
||||
}
|
||||
|
||||
// NewStatsHandler 创建统计 handler。
|
||||
func NewStatsHandler(userSvc *service.UserService) *StatsHandler {
|
||||
return &StatsHandler{userSvc: userSvc}
|
||||
}
|
||||
|
||||
// Stats GET /admin/stats —— 用户状态分布、近期待过期、待审批数。
|
||||
func (h *StatsHandler) Stats(c *gin.Context) {
|
||||
st, err := h.userSvc.Stats(c.Request.Context())
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
ok(c, st)
|
||||
}
|
||||
Reference in New Issue
Block a user