0.11.0: 旗舰版功能补齐(License/登录记录/会话管理/角色管理/门户定时任务/模型配额/输出脱敏/供应链扫描/记忆管理/AI助手/真实概览)
- 新增迁移 000031-000034(登录日志/角色/模型配额/记忆) - 新增包: license/memory/modelquota/assistant,扫描引擎 - 全部功能后端+前端+端到端验证通过(25 包单测)
This commit is contained in:
@@ -28,6 +28,12 @@ type Config struct {
|
||||
Embeddings Embeddings
|
||||
Inbox Inbox
|
||||
Scheduler Scheduler
|
||||
License License
|
||||
}
|
||||
|
||||
// License 配置 License 授权(LICENSE_FILE 指向签名文件;为空=社区版 Free)。
|
||||
type License struct {
|
||||
FilePath string
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
@@ -253,6 +259,9 @@ func Load() (Config, error) {
|
||||
BatchSize: intValue("SCHEDULER_BATCH_SIZE", 10),
|
||||
MaxAttempts: intValue("SCHEDULER_MAX_ATTEMPTS", 3),
|
||||
},
|
||||
License: License{
|
||||
FilePath: strings.TrimSpace(os.Getenv("LICENSE_FILE")),
|
||||
},
|
||||
}
|
||||
|
||||
return cfg, cfg.Validate()
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package license
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"aigateway.local/core/internal/identity"
|
||||
"aigateway.local/core/internal/platform/apiresponse"
|
||||
)
|
||||
|
||||
// HTTPHandler 提供管理端 License 查看与上传接口。
|
||||
type HTTPHandler struct {
|
||||
manager *Manager
|
||||
identity *identity.Service
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
func NewHTTPHandler(manager *Manager, identityService *identity.Service) *HTTPHandler {
|
||||
h := &HTTPHandler{manager: manager, identity: identityService, mux: http.NewServeMux()}
|
||||
h.mux.HandleFunc("GET /api/v1/admin/license", h.get)
|
||||
h.mux.HandleFunc("POST /api/v1/admin/license", h.upload)
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.mux.ServeHTTP(w, r) }
|
||||
|
||||
func (h *HTTPHandler) require(w http.ResponseWriter, r *http.Request, permission string) (identity.Account, bool) {
|
||||
account, err := h.identity.Authenticate(r.Context(), identity.KindAdmin, r.Header.Get("Authorization"))
|
||||
if err != nil {
|
||||
apiresponse.Error(w, http.StatusUnauthorized, "登录状态无效或已过期")
|
||||
return identity.Account{}, false
|
||||
}
|
||||
if !identity.HasPermission(account, permission) {
|
||||
apiresponse.Error(w, http.StatusForbidden, "缺少 License 管理权限")
|
||||
return identity.Account{}, false
|
||||
}
|
||||
return account, true
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) get(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.require(w, r, identity.PermissionSystemManage); !ok {
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, h.manager.Summary())
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) upload(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.require(w, r, identity.PermissionSystemManage); !ok {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
|
||||
decoder.DisallowUnknownFields()
|
||||
if decoder.Decode(&input) != nil || len(input.Content) > 256<<10 {
|
||||
apiresponse.Error(w, http.StatusBadRequest, "请求格式无效")
|
||||
return
|
||||
}
|
||||
if err := h.manager.Load([]byte(input.Content)); err != nil {
|
||||
apiresponse.Error(w, http.StatusBadRequest, FormatError(err))
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, h.manager.Summary())
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// Package license 实现平台 License 授权校验与账号数管控。
|
||||
//
|
||||
// License 是一个 JSON 文件(路径由 LICENSE_FILE 指定),内容为声明字段 +
|
||||
// HMAC-SHA256 签名(密钥由 CREDENTIAL_MASTER_KEY 派生)。未配置 LICENSE_FILE
|
||||
// 时按社区版 Free(3 账号)处理;管理员可通过管理端上传 License 热更新。
|
||||
package license
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Edition 是版本枚举;功能矩阵以 features 列表为准,edition 只做展示与
|
||||
// 默认能力集合。
|
||||
const (
|
||||
EditionFree = "free"
|
||||
EditionPro = "pro"
|
||||
EditionUltra = "ultra"
|
||||
DefaultAccountLimit = 3 // 未配置 License 时按社区版 Free
|
||||
)
|
||||
|
||||
var ErrInvalidLicense = errors.New("license 文件无效或签名不匹配")
|
||||
var ErrLicenseExpired = errors.New("license 已过期")
|
||||
|
||||
// Claims 是 License 的声明部分(不含签名)。
|
||||
type Claims struct {
|
||||
Edition string `json:"edition"`
|
||||
IssuedTo string `json:"issued_to"`
|
||||
MaxAccounts int `json:"max_accounts"` // 0 = 不限
|
||||
Features []string `json:"features"` // 额外授权特性名(预留)
|
||||
NotBefore string `json:"not_before"` // RFC3339
|
||||
NotAfter string `json:"not_after"` // RFC3339,空 = 永久
|
||||
}
|
||||
|
||||
// License 是完整的 License 文件内容。
|
||||
type License struct {
|
||||
Claims
|
||||
Signature string `json:"signature"` // base64(HMAC-SHA256(claimsCanonicalJSON, key))
|
||||
}
|
||||
|
||||
// Manager 持有当前 License 状态,支持热更新。
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
filePath string
|
||||
master []byte
|
||||
current License
|
||||
}
|
||||
|
||||
// NewManager 创建 License 管理器并从 filePath 加载(路径为空表示 Free 版)。
|
||||
func NewManager(filePath, masterKey string) (*Manager, error) {
|
||||
m := &Manager{filePath: filePath, master: deriveKey(masterKey)}
|
||||
if strings.TrimSpace(filePath) == "" {
|
||||
return m, nil // Free 版,无需文件
|
||||
}
|
||||
if err := m.Reload(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// deriveKey 从 master key 派生 License 签名密钥(独立用途域,不与凭据加密混用)。
|
||||
func deriveKey(masterKey string) []byte {
|
||||
sum := sha256.Sum256([]byte("aigateway-license-v1\x00" + masterKey))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
// Reload 重新读取并校验 License 文件。
|
||||
func (m *Manager) Reload() error {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
raw, err := os.ReadFile(m.filePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return ErrInvalidLicense
|
||||
}
|
||||
return err
|
||||
}
|
||||
lic, err := Parse(raw, m.master)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.current = lic
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse 解析并校验 License 内容(签名 + 有效期)。
|
||||
func Parse(raw []byte, key []byte) (License, error) {
|
||||
var lic License
|
||||
if err := json.Unmarshal(raw, &lic); err != nil {
|
||||
return License{}, ErrInvalidLicense
|
||||
}
|
||||
claims, err := json.Marshal(lic.Claims)
|
||||
if err != nil {
|
||||
return License{}, ErrInvalidLicense
|
||||
}
|
||||
mac := hmac.New(sha256.New, key)
|
||||
_, _ = mac.Write(claims)
|
||||
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||
if !hmac.Equal([]byte(expected), []byte(strings.TrimSpace(lic.Signature))) {
|
||||
return License{}, ErrInvalidLicense
|
||||
}
|
||||
now := time.Now()
|
||||
if lic.NotBefore != "" {
|
||||
if start, err := time.Parse(time.RFC3339, lic.NotBefore); err == nil && now.Before(start) {
|
||||
return License{}, ErrInvalidLicense
|
||||
}
|
||||
}
|
||||
if lic.NotAfter != "" {
|
||||
if end, err := time.Parse(time.RFC3339, lic.NotAfter); err == nil && now.After(end) {
|
||||
return License{}, ErrLicenseExpired
|
||||
}
|
||||
}
|
||||
edition := strings.ToLower(strings.TrimSpace(lic.Edition))
|
||||
switch edition {
|
||||
case EditionFree, EditionPro, EditionUltra, "":
|
||||
default:
|
||||
return License{}, ErrInvalidLicense
|
||||
}
|
||||
if lic.Edition == "" {
|
||||
lic.Edition = EditionFree
|
||||
}
|
||||
return lic, nil
|
||||
}
|
||||
|
||||
// Sign 生成 License(供本地签发工具/测试使用)。
|
||||
func Sign(claims Claims, masterKey string) (License, error) {
|
||||
claims.Edition = strings.ToLower(strings.TrimSpace(claims.Edition))
|
||||
claims.Features = normalize(claims.Features)
|
||||
raw, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
return License{}, err
|
||||
}
|
||||
mac := hmac.New(sha256.New, deriveKey(masterKey))
|
||||
_, _ = mac.Write(raw)
|
||||
return License{Claims: claims, Signature: base64.StdEncoding.EncodeToString(mac.Sum(nil))}, nil
|
||||
}
|
||||
|
||||
func normalize(values []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(values))
|
||||
for _, v := range values {
|
||||
v = strings.ToLower(strings.TrimSpace(v))
|
||||
if v != "" && !seen[v] {
|
||||
seen[v] = true
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Current 返回当前 License 快照。
|
||||
func (m *Manager) Current() License {
|
||||
if m == nil {
|
||||
return License{Claims: Claims{Edition: EditionFree}}
|
||||
}
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.current
|
||||
}
|
||||
|
||||
// AccountLimit 返回账号数上限;0 表示不限。
|
||||
func (m *Manager) AccountLimit() int {
|
||||
lic := m.Current()
|
||||
// 未配置 License 时 Edition 为空,同样按社区版 Free(3 账号)处理。
|
||||
if lic.Edition == "" || lic.Edition == EditionFree {
|
||||
if lic.MaxAccounts > 0 {
|
||||
return lic.MaxAccounts
|
||||
}
|
||||
return DefaultAccountLimit
|
||||
}
|
||||
return lic.MaxAccounts // pro/ultra 由 License 指定;0=不限
|
||||
}
|
||||
|
||||
// EditionName 返回可读版本名。
|
||||
func (m *Manager) EditionName() string {
|
||||
switch m.Current().Edition {
|
||||
case EditionPro:
|
||||
return "专业版 Pro"
|
||||
case EditionUltra:
|
||||
return "旗舰版 Ultra"
|
||||
default:
|
||||
return "社区版 Free"
|
||||
}
|
||||
}
|
||||
|
||||
// FilePath 返回 License 文件路径。
|
||||
func (m *Manager) FilePath() string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
return m.filePath
|
||||
}
|
||||
|
||||
// Load 原子替换 License 文件并热更新(管理端上传)。
|
||||
func (m *Manager) Load(raw []byte) error {
|
||||
if m == nil || m.filePath == "" {
|
||||
return errors.New("未配置 LICENSE_FILE,无法保存 License")
|
||||
}
|
||||
lic, err := Parse(raw, m.master)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := m.filePath + ".tmp"
|
||||
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, m.filePath); err != nil {
|
||||
return err
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.current = lic
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Summary 返回给管理端展示的信息。
|
||||
func (m *Manager) Summary() map[string]any {
|
||||
lic := m.Current()
|
||||
expires := lic.NotAfter
|
||||
if expires == "" {
|
||||
expires = "永久"
|
||||
}
|
||||
return map[string]any{
|
||||
"edition": lic.Edition,
|
||||
"edition_name": m.EditionName(),
|
||||
"issued_to": lic.IssuedTo,
|
||||
"max_accounts": m.AccountLimit(),
|
||||
"features": lic.Features,
|
||||
"expires": expires,
|
||||
"file": m.FilePath(),
|
||||
"licensed": m.FilePath() != "",
|
||||
}
|
||||
}
|
||||
|
||||
// 便捷格式化错误信息。
|
||||
func FormatError(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, ErrInvalidLicense):
|
||||
return "License 无效或签名不匹配"
|
||||
case errors.Is(err, ErrLicenseExpired):
|
||||
return "License 已过期"
|
||||
case err == nil:
|
||||
return ""
|
||||
default:
|
||||
return fmt.Sprintf("License 加载失败: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package license
|
||||
import "encoding/json"
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSignParseRoundTrip(t *testing.T) {
|
||||
key := "test-master-key-123"
|
||||
claims := Claims{Edition: EditionUltra, IssuedTo: "acme", MaxAccounts: 50,
|
||||
NotBefore: time.Now().Add(-time.Hour).Format(time.RFC3339),
|
||||
NotAfter: time.Now().Add(24 * time.Hour).Format(time.RFC3339)}
|
||||
lic, err := Sign(claims, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := json.Marshal(lic)
|
||||
parsed, err := Parse(raw, deriveKey(key))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if parsed.Edition != EditionUltra || parsed.MaxAccounts != 50 {
|
||||
t.Fatalf("bad claims: %+v", parsed.Claims)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRejectsBadSignature(t *testing.T) {
|
||||
lic, _ := Sign(Claims{Edition: EditionPro, MaxAccounts: 30}, "key-a")
|
||||
raw, _ := json.Marshal(lic)
|
||||
if _, err := Parse(raw, deriveKey("key-b")); err == nil {
|
||||
t.Fatal("expected signature failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRejectsExpired(t *testing.T) {
|
||||
claims := Claims{Edition: EditionPro, MaxAccounts: 30,
|
||||
NotAfter: time.Now().Add(-time.Hour).Format(time.RFC3339)}
|
||||
lic, _ := Sign(claims, "key")
|
||||
raw, _ := json.Marshal(lic)
|
||||
if _, err := Parse(raw, deriveKey("key")); err != ErrLicenseExpired {
|
||||
t.Fatalf("expected expired, got %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user