feat(memory): P1 长期记忆升级 —— 异步攒批 Consolidate + 软删 + importance/last_seen #1
+5
-4
@@ -165,11 +165,12 @@ RBAC 未做,暂以单管理员账号代理;概览口径必须是**系统级*
|
|||||||
- [ ] 用户管理接口:列举 / 禁用 / 改角色(现仅注册/登录/查我,handler/auth.go)| M
|
- [ ] 用户管理接口:列举 / 禁用 / 改角色(现仅注册/登录/查我,handler/auth.go)| M
|
||||||
- [ ] 多租户:Task/Eval/KB/Agent 加 tenant_id,owner_id 之上补租户隔离边界 + 租户表/关联表(store 全表,单租户假设)| L
|
- [ ] 多租户:Task/Eval/KB/Agent 加 tenant_id,owner_id 之上补租户隔离边界 + 租户表/关联表(store 全表,单租户假设)| L
|
||||||
|
|
||||||
### [ ] 🔴 T4.B 审计 / 可溯源 + 管理端聚合(推荐先做,内聚且喂管理端)
|
### [~] 🔴 T4.B 审计 / 可溯源 + 管理端聚合(推荐先做,内聚且喂管理端)
|
||||||
- [ ] `audit_log` 表 + 中间件:改模型/删模型/改密钥/激活 prompt 等敏感操作落库(handler/admin.go 全无留痕)| M
|
- [x] `GET /api/v1/admin/overview`(RequireAdmin) 系统级聚合 ✅ —— 全平台用户/KB/任务/评测/模型态/prompt 覆盖/健康;概览页已切过去(5调用→2),live 验证 users=7/kb 22/50 全平台口径。
|
||||||
|
- [x] `audit_log` 表 + 中间件 ✅ —— `store.AuditLog` + `middleware.Audit(db)` 挂管理组 + prompt 激活/撤销 + HITL 审批;只审计变更类(POST/PUT/DELETE/PATCH),best-effort 落库不拖垮主流程;`GET /admin/audit` 列表(倒序翻页)。live:PUT pricing / POST deactivate 留痕,GET 不记。
|
||||||
- [ ] 护栏拦截事件落库 `guardrail_event`(middleware/guardrail.go:29 现只打日志,无法复盘"谁触发多少次")| M
|
- [ ] 护栏拦截事件落库 `guardrail_event`(middleware/guardrail.go:29 现只打日志,无法复盘"谁触发多少次")| M
|
||||||
- [ ] HITL 审批决定落库(task_handler.go:172 现只发 NATS,无审批历史)| S
|
- [ ] HITL 审批决定明细落库(现审批已进 audit_log 但只有 who/when/status,无 approve/reject 决定与理由;可 enrich audit Detail 或单独表)| S
|
||||||
- [ ] `GET /api/v1/admin/overview`(RequireAdmin) 系统级聚合:用户/租户/全局任务/评测/模型态/prompt 覆盖(替代概览现借的个人 stats/overview,见 [T1.3])| M
|
- [ ] 前端:管理端加「审计流 / 安全事件」页或概览接 /admin/audit(把留痕可视化)| S
|
||||||
|
|
||||||
### [ ] T4.C 真实成本计费
|
### [ ] T4.C 真实成本计费
|
||||||
- [ ] token×单价 落库 `token_usage`(现仅 Redis 按天计数 TTL48h,task_handler.go:532)| L
|
- [ ] token×单价 落库 `token_usage`(现仅 Redis 按天计数 TTL48h,task_handler.go:532)| L
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -16,6 +17,34 @@ import (
|
|||||||
"github.com/sundynix/sundynix-shared/secrets"
|
"github.com/sundynix/sundynix-shared/secrets"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// AuditList: GET /api/v1/admin/audit?limit=&offset= —— 敏感操作审计流(倒序,供运维溯源)。
|
||||||
|
func (h *Handler) AuditList(c *gin.Context) {
|
||||||
|
limit, offset := 50, 0
|
||||||
|
if v := c.Query("limit"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
limit = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v := c.Query("offset"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
offset = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows, err := h.db.ListAudit(c.Request.Context(), limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]gin.H, 0, len(rows))
|
||||||
|
for _, a := range rows {
|
||||||
|
out = append(out, gin.H{
|
||||||
|
"id": a.ID, "actor": a.Actor, "action": a.Action, "route": a.Route,
|
||||||
|
"path": a.Path, "status": a.Status, "ip": a.IP, "detail": a.Detail, "at": a.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"logs": out})
|
||||||
|
}
|
||||||
|
|
||||||
// AdminOverview: GET /api/v1/admin/overview —— 管理端系统级聚合(控制塔口径)。
|
// AdminOverview: GET /api/v1/admin/overview —— 管理端系统级聚合(控制塔口径)。
|
||||||
// 区别于 stats/overview(桌面端个人工作台):这里一律系统级——全平台用户/任务/评测/
|
// 区别于 stats/overview(桌面端个人工作台):这里一律系统级——全平台用户/任务/评测/
|
||||||
// 模型配置态/提示词控制面态/服务健康。Task/Eval 表无 owner 即全量;用户/KB/Doc 走全局计数。
|
// 模型配置态/提示词控制面态/服务健康。Task/Eval 表无 owner 即全量;用户/KB/Doc 走全局计数。
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/sundynix/sundynix-gateway/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Audit 记录敏感操作留痕:仅对变更类请求(POST/PUT/DELETE)在收尾时 best-effort 落库。
|
||||||
|
// 挂在受保护/管理组上即可自动覆盖模型/密钥/prompt/审批等变更,无需在每个 handler 里手写。
|
||||||
|
// 写库用独立超时 context(不受请求取消影响),失败静默——审计不能拖垮主流程。
|
||||||
|
func Audit(db *store.Postgres) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
c.Next() // 先跑 handler,拿到最终状态码
|
||||||
|
|
||||||
|
switch c.Request.Method {
|
||||||
|
case "POST", "PUT", "DELETE", "PATCH":
|
||||||
|
default:
|
||||||
|
return // 只审计变更类操作
|
||||||
|
}
|
||||||
|
|
||||||
|
actor, _ := c.Get(CtxUserID)
|
||||||
|
uid, _ := actor.(string)
|
||||||
|
|
||||||
|
entry := &store.AuditLog{
|
||||||
|
Actor: uid,
|
||||||
|
Action: c.Request.Method,
|
||||||
|
Route: c.FullPath(),
|
||||||
|
Path: c.Request.URL.Path,
|
||||||
|
Status: c.Writer.Status(),
|
||||||
|
IP: c.ClientIP(),
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_ = db.AppendAudit(ctx, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,7 +52,7 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
|
|||||||
{
|
{
|
||||||
p.POST("/tasks", h.SubmitTask) // 解析 DSL 并 Publish 到 NATS(带已验证 uid)
|
p.POST("/tasks", h.SubmitTask) // 解析 DSL 并 Publish 到 NATS(带已验证 uid)
|
||||||
p.GET("/tasks/:id", h.TaskStatus) // 任务生命周期状态(UI 轮询 submitted/running/done/failed/timeout/waiting/rejected)
|
p.GET("/tasks/:id", h.TaskStatus) // 任务生命周期状态(UI 轮询 submitted/running/done/failed/timeout/waiting/rejected)
|
||||||
p.POST("/tasks/:id/approve", h.ApproveTask) // HITL 人工审批决定(批准/拒绝)
|
p.POST("/tasks/:id/approve", middleware.Audit(db), h.ApproveTask) // HITL 人工审批决定(批准/拒绝,审计)
|
||||||
p.GET("/tasks/:id/eval", h.TaskEval) // 自动化评测结果(综合/质量/忠实度/分级)
|
p.GET("/tasks/:id/eval", h.TaskEval) // 自动化评测结果(综合/质量/忠实度/分级)
|
||||||
p.PUT("/memory", h.SetMemory) // 偏好记忆登记(→ mcp-go memory_upsert)
|
p.PUT("/memory", h.SetMemory) // 偏好记忆登记(→ mcp-go memory_upsert)
|
||||||
p.GET("/memory", h.ListMemory) // 列出当前用户偏好(记忆面板)
|
p.GET("/memory", h.ListMemory) // 列出当前用户偏好(记忆面板)
|
||||||
@@ -69,8 +69,8 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
|
|||||||
// Prompt 控制面(平台级配置:建版本 → 激活 → 控制面热下发各服务)
|
// Prompt 控制面(平台级配置:建版本 → 激活 → 控制面热下发各服务)
|
||||||
p.GET("/prompts", h.PromptList) // 列出全部版本 + 可配键
|
p.GET("/prompts", h.PromptList) // 列出全部版本 + 可配键
|
||||||
p.POST("/prompts/version", h.PromptCreateVersion) // 建新版本(不自动激活)
|
p.POST("/prompts/version", h.PromptCreateVersion) // 建新版本(不自动激活)
|
||||||
p.POST("/prompts/activate", h.PromptActivate) // 激活某版本 → 广播热更新
|
p.POST("/prompts/activate", middleware.Audit(db), h.PromptActivate) // 激活某版本 → 广播热更新(审计)
|
||||||
p.POST("/prompts/deactivate", h.PromptDeactivate) // 撤销激活 → 回退代码默认(热)
|
p.POST("/prompts/deactivate", middleware.Audit(db), h.PromptDeactivate) // 撤销激活 → 回退代码默认(热,审计)
|
||||||
p.GET("/kb/links", h.KbLinks) // 某库双链
|
p.GET("/kb/links", h.KbLinks) // 某库双链
|
||||||
p.POST("/kb/note", h.KbSaveNote) // 新建/编辑笔记
|
p.POST("/kb/note", h.KbSaveNote) // 新建/编辑笔记
|
||||||
p.GET("/kb/graph", h.KbGraph) // 知识图谱三元组
|
p.GET("/kb/graph", h.KbGraph) // 知识图谱三元组
|
||||||
@@ -85,7 +85,7 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 运维控制面:LLM 模型配置(含 API 密钥管理)—— 必须管理员(RequireAdmin)。
|
// 运维控制面:LLM 模型配置(含 API 密钥管理)—— 必须管理员(RequireAdmin)。
|
||||||
admin := api.Group("/admin", middleware.RequireAdmin())
|
admin := api.Group("/admin", middleware.RequireAdmin(), middleware.Audit(db))
|
||||||
{
|
{
|
||||||
admin.GET("/models", h.ListModels)
|
admin.GET("/models", h.ListModels)
|
||||||
admin.POST("/models", h.SaveModel)
|
admin.POST("/models", h.SaveModel)
|
||||||
@@ -96,6 +96,7 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
|
|||||||
admin.PUT("/pricing", h.SavePricing) // 设置某模型输入/输出单价
|
admin.PUT("/pricing", h.SavePricing) // 设置某模型输入/输出单价
|
||||||
admin.GET("/status", h.AdminStatus) // 服务状态:基建/服务探活 + MCP 工具注册
|
admin.GET("/status", h.AdminStatus) // 服务状态:基建/服务探活 + MCP 工具注册
|
||||||
admin.GET("/overview", h.AdminOverview) // 系统级聚合:全平台用户/任务/评测/模型态/提示词态/健康
|
admin.GET("/overview", h.AdminOverview) // 系统级聚合:全平台用户/任务/评测/模型态/提示词态/健康
|
||||||
|
admin.GET("/audit", h.AuditList) // 敏感操作审计流(倒序,翻页)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return r
|
return r
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// AppendAudit 追加一条审计留痕(best-effort:审计失败不应影响主流程,调用方忽略返回)。
|
||||||
|
func (p *Postgres) AppendAudit(ctx context.Context, a *AuditLog) error {
|
||||||
|
if p.db == nil || a == nil {
|
||||||
|
return errStoreDisabled
|
||||||
|
}
|
||||||
|
return p.db.WithContext(ctx).Create(a).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAudit 倒序列出审计留痕(管理端审计流;limit 限流、offset 翻页)。
|
||||||
|
func (p *Postgres) ListAudit(ctx context.Context, limit, offset int) ([]AuditLog, error) {
|
||||||
|
if p.db == nil {
|
||||||
|
return nil, errStoreDisabled
|
||||||
|
}
|
||||||
|
if limit <= 0 || limit > 200 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
var out []AuditLog
|
||||||
|
err := p.db.WithContext(ctx).Order("created_at desc").Limit(limit).Offset(offset).Find(&out).Error
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
@@ -42,3 +42,18 @@ type Eval struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (Eval) TableName() string { return "sundynix_eval" }
|
func (Eval) TableName() string { return "sundynix_eval" }
|
||||||
|
|
||||||
|
// AuditLog 是一条敏感操作留痕(改模型/密钥/激活 prompt/审批 等)。
|
||||||
|
// 由 Audit 中间件在请求收尾时 best-effort 写入;只增不改,供运维溯源。
|
||||||
|
type AuditLog struct {
|
||||||
|
BaseModel
|
||||||
|
Actor string `gorm:"size:64;index"` // 操作者 uid(未登录/系统留空)
|
||||||
|
Action string `gorm:"size:8"` // HTTP 方法:POST / PUT / DELETE
|
||||||
|
Route string `gorm:"size:128"` // 路由模式,如 /api/v1/admin/models/:id
|
||||||
|
Path string `gorm:"size:256"` // 实际请求路径
|
||||||
|
Status int // HTTP 响应状态码
|
||||||
|
IP string `gorm:"size:64"`
|
||||||
|
Detail string `gorm:"type:text"` // 备注(可选,如目标名/关键参数)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (AuditLog) TableName() string { return "sundynix_audit_log" }
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ func OpenPostgres(dsn string) *Postgres {
|
|||||||
migrateLegacyIntIDs(db)
|
migrateLegacyIntIDs(db)
|
||||||
migrateDocLinkToID(db)
|
migrateDocLinkToID(db)
|
||||||
|
|
||||||
if err := db.AutoMigrate(&User{}, &Task{}, &Eval{}, &LLMModel{}, &KB{}, &Doc{}, &Agent{}, &DocLink{}, &Pricing{}, &Prompt{}); err != nil {
|
if err := db.AutoMigrate(&User{}, &Task{}, &Eval{}, &LLMModel{}, &KB{}, &Doc{}, &Agent{}, &DocLink{}, &Pricing{}, &Prompt{}, &AuditLog{}); err != nil {
|
||||||
log.Printf("[store] postgres AutoMigrate 失败,降级运行: %v", err)
|
log.Printf("[store] postgres AutoMigrate 失败,降级运行: %v", err)
|
||||||
return &Postgres{}
|
return &Postgres{}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user