优化结构

This commit is contained in:
Yvan
2026-08-24 21:50:33 +08:00
parent d3c20c578f
commit 969afee284
20 changed files with 497 additions and 244 deletions
+4 -2
View File
@@ -2,11 +2,13 @@ package handler
import "github.com/google/wire"
// ProviderSet wires the system HTTP handlers.
// ProviderSet wires the system HTTP handlers. Set itself is filled field by
// field so adding a handler only means adding its constructor here.
var ProviderSet = wire.NewSet(
NewAuthority, NewMenu, NewAPI, NewPermission, NewOrganization,
NewAnnouncement, NewEmail, NewPayment, NewTask, NewMedia, NewAudit,
NewExport, NewVersion, NewDictionary, NewParameter, NewAPIToken,
NewSystemConfig, NewPublic, NewUser, NewNavigation, NewSession, NewSet,
NewSystemConfig, NewPublic, NewUser, NewNavigation, NewSession,
NewIntegrationConfig,
wire.Struct(new(Set), "*"),
)
-4
View File
@@ -24,7 +24,3 @@ type Set struct {
Session *Session
IntegrationConfig *IntegrationConfig
}
func NewSet(authority *Authority, menu *Menu, api *API, permission *Permission, organization *Organization, announcement *Announcement, email *Email, payment *Payment, task *Task, media *Media, audit *Audit, export *Export, version *Version, dictionary *Dictionary, parameter *Parameter, apiToken *APIToken, systemConfig *SystemConfig, public *Public, user *User, navigation *Navigation, session *Session, integrationConfig *IntegrationConfig) *Set {
return &Set{Authority: authority, Menu: menu, API: api, Permission: permission, Organization: organization, Announcement: announcement, Email: email, Payment: payment, Task: task, Media: media, Audit: audit, Export: export, Version: version, Dictionary: dictionary, Parameter: parameter, APIToken: apiToken, SystemConfig: systemConfig, Public: public, User: user, Navigation: navigation, Session: session, IntegrationConfig: integrationConfig}
}
+22 -17
View File
@@ -142,17 +142,28 @@ func AccessLog(runtime *config.Store, logger *slog.Logger, version string) gin.H
"req_query", redactQuery(c.Request.URL.RawQuery),
}
}
if !paymentCallback && admin != nil && admin.Zap != nil && admin.Zap.AccessReqHeaders {
attributes = append(attributes, "req_headers", redactHeaders(c.Request.Header))
}
if !paymentCallback && admin != nil && admin.Zap != nil && admin.Zap.AccessReqBody {
attributes = append(attributes, "req_body", requestText)
}
if !paymentCallback && admin != nil && admin.Zap != nil && admin.Zap.AccessRespData {
attributes = append(attributes, "resp_data", responseText)
}
if !paymentCallback && privateErrors != "" {
attributes = append(attributes, "error_msg", privateErrors)
// A payment callback is logged through its own attribute set: neither the
// configurable request/response capture nor the private error text may leak
// provider payloads into the generic access log.
if !paymentCallback {
var zap *config.Zap
if admin != nil {
zap = admin.Zap
}
if zap != nil {
if zap.AccessReqHeaders {
attributes = append(attributes, "req_headers", redactHeaders(c.Request.Header))
}
if zap.AccessReqBody {
attributes = append(attributes, "req_body", requestText)
}
if zap.AccessRespData {
attributes = append(attributes, "resp_data", responseText)
}
}
if privateErrors != "" {
attributes = append(attributes, "error_msg", privateErrors)
}
}
logger.InfoContext(c.Request.Context(), "请求完成", attributes...)
}
@@ -173,12 +184,6 @@ func paymentCallbackProvider(path string) string {
return "unknown"
}
// isPaymentIntegrationConfigWrite is kept as a compatibility seam for
// middleware tests; policy ownership lives in routecatalog.
func isPaymentIntegrationConfigWrite(method, path string) bool {
return routecatalog.BodyPolicyFor(method, path) == routecatalog.BodyPolicyPaymentConfig
}
func paymentCallbackSummary(body []byte, contentType string) string {
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil || mediaType == "" {
+33 -16
View File
@@ -135,12 +135,24 @@ func operationRequestBody(raw []byte, contentType string, limit int) string {
return text
}
// operationSecretKeys holds the normalized JSON keys whose values never reach
// the operation record. Keys are compared after lowercasing and stripping
// separators, so "new_password" and "newPassword" both match "newpassword".
var operationSecretKeys = map[string]struct{}{
"password": {}, "newpassword": {}, "oldpassword": {}, "confirmpassword": {},
"passwd": {}, "pwd": {}, "token": {}, "accesstoken": {}, "refreshtoken": {},
"secret": {}, "clientsecret": {}, "apikey": {}, "privatekey": {}, "idcard": {},
"appkey": {}, "mchkey": {}, "apiv3key": {}, "clientcert": {}, "clientkey": {},
"platformcert": {}, "platformserialno": {}, "credentialcode": {}, "certfile": {},
"keyfile": {}, "publickey": {}, "rootcert": {}, "appcert": {}, "webhookid": {},
}
func maskOperationBody(value any) {
switch current := value.(type) {
case map[string]any:
for key, item := range current {
normalized := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(key, "_", ""), "-", ""))
if normalized == "password" || normalized == "newpassword" || normalized == "oldpassword" || normalized == "confirmpassword" || normalized == "passwd" || normalized == "pwd" || normalized == "token" || normalized == "accesstoken" || normalized == "refreshtoken" || normalized == "secret" || normalized == "clientsecret" || normalized == "apikey" || normalized == "privatekey" || normalized == "idcard" || normalized == "appkey" || normalized == "mchkey" || normalized == "apiv3key" || normalized == "clientcert" || normalized == "clientkey" || normalized == "platformcert" || normalized == "platformserialno" || normalized == "credentialcode" || normalized == "certfile" || normalized == "keyfile" || normalized == "publickey" || normalized == "rootcert" || normalized == "appcert" || normalized == "webhookid" {
if _, secret := operationSecretKeys[normalized]; secret {
current[key] = "***"
continue
}
@@ -153,21 +165,26 @@ func maskOperationBody(value any) {
}
}
func isDownloadResponse(c *gin.Context) bool {
header := c.Writer.Header()
return strings.Contains(header.Get("Pragma"), "public") ||
strings.Contains(header.Get("Expires"), "0") ||
strings.Contains(header.Get("Cache-Control"), "must-revalidate, post-check=0, pre-check=0") ||
strings.Contains(header.Get("Content-Type"), "application/force-download") ||
strings.Contains(header.Get("Content-Type"), "application/octet-stream") ||
strings.Contains(header.Get("Content-Type"), "application/vnd.ms-excel") ||
strings.Contains(header.Get("Content-Type"), "application/download") ||
strings.Contains(header.Get("Content-Disposition"), "attachment") ||
strings.Contains(header.Get("Content-Transfer-Encoding"), "binary")
// operationDownloadHeaders lists the response headers whose presence marks a
// download: the value is the substring that identifies it.
var operationDownloadHeaders = [...]struct{ header, marker string }{
{"Pragma", "public"},
{"Expires", "0"},
{"Cache-Control", "must-revalidate, post-check=0, pre-check=0"},
{"Content-Type", "application/force-download"},
{"Content-Type", "application/octet-stream"},
{"Content-Type", "application/vnd.ms-excel"},
{"Content-Type", "application/download"},
{"Content-Disposition", "attachment"},
{"Content-Transfer-Encoding", "binary"},
}
// recordsOperation remains as a small compatibility helper for tests and
// custom middleware chains that do not have a Gin context.
func recordsOperation(method, path string) bool {
return routecatalog.ShouldAudit(method, path)
func isDownloadResponse(c *gin.Context) bool {
header := c.Writer.Header()
for _, candidate := range operationDownloadHeaders {
if strings.Contains(header.Get(candidate.header), candidate.marker) {
return true
}
}
return false
}
+14 -6
View File
@@ -7,6 +7,7 @@ import (
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/sync/singleflight"
@@ -14,6 +15,10 @@ import (
const claimsKey = "admin_claims"
// tokenAuthTimeout bounds a shared token authentication flight. It replaces the
// leader request's own deadline, which followers must not inherit.
const tokenAuthTimeout = 10 * time.Second
var refreshTokens singleflight.Group
type TokenAuthenticator interface {
@@ -36,7 +41,7 @@ func AuthenticateWebSocket(c *gin.Context, auth TokenAuthenticator) bool {
}
func authenticate(c *gin.Context, auth TokenAuthenticator, allowQueryToken bool) bool {
token := requestToken(c, allowQueryToken)
token := RequestToken(c, allowQueryToken)
if token == "" {
NoAuth(c, "未登录或非法访问,请登录")
return false
@@ -45,8 +50,15 @@ func authenticate(c *gin.Context, auth TokenAuthenticator, allowQueryToken bool)
NoAuth(c, "认证服务不可用")
return false
}
// Followers of a singleflight flight must not inherit the leader's request
// context: if the leader's client disconnects mid-flight, its cancellation
// would surface as an auth failure for every follower and force-log-out
// unrelated sessions. Detach cancellation and bound the shared call with an
// explicit timeout instead.
value, err, _ := refreshTokens.Do(token, func() (any, error) {
return auth.AuthenticateToken(c.Request.Context(), token)
ctx, cancel := context.WithTimeout(context.WithoutCancel(c.Request.Context()), tokenAuthTimeout)
defer cancel()
return auth.AuthenticateToken(ctx, token)
})
if err != nil {
SetTokenCookie(c, "", -1)
@@ -93,10 +105,6 @@ func RequestToken(c *gin.Context, allowQueryToken bool) string {
return token
}
func requestToken(c *gin.Context, allowQueryToken bool) string {
return RequestToken(c, allowQueryToken)
}
func tokenErrorMessage(err error) string {
message := "无法处理此token"
switch {
@@ -4,6 +4,8 @@ import (
"encoding/json"
"strings"
"testing"
"kra/internal/routecatalog"
)
func TestPaymentIntegrationSecretsAreRedacted(t *testing.T) {
@@ -35,7 +37,7 @@ func TestPaymentOperationsAreAuditedWithRouterPrefix(t *testing.T) {
{method: "POST", path: "/api/payment/fulfill"},
{method: "POST", path: "/api/payment/providers/alipay/test"},
} {
if !recordsOperation(route.method, route.path) {
if !routecatalog.ShouldAudit(route.method, route.path) {
t.Fatalf("payment operation was not audited: %s %s", route.method, route.path)
}
}
@@ -47,10 +49,10 @@ func TestPaymentIntegrationConfigUsesRouteLevelSummary(t *testing.T) {
if strings.Contains(summary, "secret") || strings.Contains(summary, "certificate") {
t.Fatalf("payment configuration summary leaked payload: %s", summary)
}
if !isPaymentIntegrationConfigWrite("PUT", "/api/integration/configs/payment/saobei") {
if routecatalog.BodyPolicyFor("PUT", "/api/integration/configs/payment/saobei") != routecatalog.BodyPolicyPaymentConfig {
t.Fatal("payment configuration write route was not recognized")
}
if isPaymentIntegrationConfigWrite("PUT", "/api/integration/configs/mq/emqx") {
if routecatalog.BodyPolicyFor("PUT", "/api/integration/configs/mq/emqx") == routecatalog.BodyPolicyPaymentConfig {
t.Fatal("non-payment integration was treated as payment configuration")
}
}