0.11.0: 旗舰版功能补齐(License/登录记录/会话管理/角色管理/门户定时任务/模型配额/输出脱敏/供应链扫描/记忆管理/AI助手/真实概览)

- 新增迁移 000031-000034(登录日志/角色/模型配额/记忆)
- 新增包: license/memory/modelquota/assistant,扫描引擎
- 全部功能后端+前端+端到端验证通过(25 包单测)
This commit is contained in:
2026-08-13 11:37:18 +08:00
parent 9501751792
commit c22669c31d
43 changed files with 3672 additions and 254 deletions
+57
View File
@@ -30,10 +30,14 @@ type Proxy struct {
circuits sync.Map
admission AdmissionController
tokenQuota TokenQuotaController
modelQuota ModelQuotaController
resilience ResiliencePolicy
audit AuditRecorder
policies *contentpolicy.Engine
pricing *pricing.Service
outputPolicies interface {
OutputRedact([]byte) ([]byte, bool)
}
}
type cachedProxy struct {
@@ -99,6 +103,11 @@ func (p *Proxy) SetTokenQuotaController(controller TokenQuotaController) {
p.tokenQuota = controller
}
// SetModelQuotaController 启用模型级 Token 配额(provider 解析后预留)。
func (p *Proxy) SetModelQuotaController(controller ModelQuotaController) {
p.modelQuota = controller
}
func (p *Proxy) SetResiliencePolicy(policy ResiliencePolicy) {
p.resilience = policy
p.transport.ResponseHeaderTimeout = policy.ResponseHeaderTimeout
@@ -106,6 +115,13 @@ func (p *Proxy) SetResiliencePolicy(policy ResiliencePolicy) {
func (p *Proxy) SetAuditRecorder(recorder AuditRecorder) { p.audit = recorder }
func (p *Proxy) SetContentPolicyEngine(engine *contentpolicy.Engine) { p.policies = engine }
// SetOutputPolicyEngine 启用输出侧脱敏(模型回答隐私拦截替换)。
func (p *Proxy) SetOutputPolicyEngine(engine interface {
OutputRedact([]byte) ([]byte, bool)
}) {
p.outputPolicies = engine
}
func (p *Proxy) SetPricingService(service *pricing.Service) { p.pricing = service }
func (p *Proxy) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
@@ -288,6 +304,30 @@ func (p *Proxy) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
request.Header.Del("X-Gateway-Provider")
writer.Header().Set("X-Gateway-Provider", resolved.Code)
span.setRoute(resolved.Code, writer.Header().Get("X-Gateway-Model"))
// 模型级配额(企业总配额,所有 Key 共享):在确定 provider 与 model 后
// 预留,与 API Key 级配额叠加;未配置配额或额度不足时按 429 处理。
if p.modelQuota != nil && usage != nil && modelPayload.model != "" {
modelQuota, quotaErr := p.modelQuota.Reserve(request.Context(), resolved.Code, modelPayload.model, estimateForReserve(p, request, usage), time.Now())
if quotaErr != nil {
writeOpenAIError(writer, http.StatusServiceUnavailable, "model_quota_unavailable", "model quota service is unavailable")
return
}
if modelQuota != nil {
if reservation, ok := modelQuota.(interface {
AllowedFlag() bool
RemainingTokens() int64
ResetTime() time.Time
}); ok {
if !reservation.AllowedFlag() {
writeOpenAIError(writer, http.StatusTooManyRequests, "insufficient_quota", "model token quota exceeded")
return
}
writer.Header().Set("X-ModelTokenLimit-Remaining", strconv.FormatInt(max(reservation.RemainingTokens(), 0), 10))
usage.modelController = p.modelQuota
usage.modelReservation = modelQuota
}
}
}
request.Body = span.captureBody(request.Body)
p.proxyFor(resolved).ServeHTTP(writer, request)
}
@@ -323,6 +363,11 @@ func (p *Proxy) proxyFor(resolved ResolvedAdapter) *httputil.ReverseProxy {
session.finish(TokenUsage{})
}
}
// 输出侧脱敏:模型回答隐私信息拦截替换(仅 2xx 且启用了输出策略时)。
if response.StatusCode >= http.StatusOK && response.StatusCode < http.StatusMultipleChoices && p.outputPolicies != nil {
response.Body = newOutputRedactReadCloser(response.Body, response.Header.Get("Content-Type"), p.outputPolicies)
response.Header.Set("X-Gateway-Output-Redacted", "true")
}
return nil
}
reverseProxy.ErrorHandler = func(writer http.ResponseWriter, request *http.Request, err error) {
@@ -351,6 +396,18 @@ func (p *Proxy) proxyFor(resolved ResolvedAdapter) *httputil.ReverseProxy {
return reverseProxy
}
// estimateForReserve 复用已有 usage session 的预留估算;不可用时回退 0。
func estimateForReserve(p *Proxy, request *http.Request, usage *usageSession) int64 {
if usage != nil && usage.reservation.Reserved > 0 {
return usage.reservation.Reserved
}
estimate, err := prepareTokenBudget(request, p.maxBody)
if err != nil {
return 0
}
return estimate
}
func (p *Proxy) authorized(request *http.Request) (apikey.Principal, error) {
presented := strings.TrimSpace(request.Header.Get("X-Gateway-API-Key"))
if presented == "" {