优化结构

This commit is contained in:
Yvan
2026-08-28 11:51:37 +08:00
parent 5b04998731
commit 33ba777137
25 changed files with 163 additions and 125 deletions
+2 -2
View File
@@ -46,7 +46,7 @@ func waitForDataScopeAuditCount(t *testing.T, db *gorm.DB, want int64) {
func TestDataScopeAuditWriterFlushesBatch(t *testing.T) {
db := openDataScopeAuditTestDB(t, "data-scope-audit-batch")
d := &Data{gormDB: newReloadableDB(db, nil)}
d := &Data{gormDB: newReloadableDB(db)}
w := newDataScopeAuditWriterWithOptions(d, slog.New(slog.NewTextHandler(io.Discard, nil)), 8, 2, time.Hour)
d.auditLog = w
t.Cleanup(w.Close)
@@ -59,7 +59,7 @@ func TestDataScopeAuditWriterFlushesBatch(t *testing.T) {
func TestDataScopeAuditWriterUsesReloadedDatabase(t *testing.T) {
first := openDataScopeAuditTestDB(t, "data-scope-audit-first")
second := openDataScopeAuditTestDB(t, "data-scope-audit-second")
d := &Data{gormDB: newReloadableDB(first, nil)}
d := &Data{gormDB: newReloadableDB(first)}
w := newDataScopeAuditWriterWithOptions(d, slog.New(slog.NewTextHandler(io.Discard, nil)), 8, 100, 10*time.Millisecond)
d.auditLog = w
t.Cleanup(w.Close)
+2 -2
View File
@@ -16,11 +16,11 @@ import (
func TestGORMLoggerUsesApplicationCategories(t *testing.T) {
root := t.TempDir()
appLogger, cleanup := logging.NewZapLogger(root, "application.log", logging.Options{Level: "info", Format: "json"})
appLogger, control := logging.NewReloadableZapLogger(root, "application.log", logging.Options{Level: "info", Format: "json"})
databaseLogger := gormkit.NewLogger(appLogger, logger.Info)
databaseLogger.Trace(context.Background(), time.Now(), func() (string, int64) { return "SELECT 1", 1 }, nil)
databaseLogger.Trace(context.Background(), time.Now(), func() (string, int64) { return "SELECT missing", 0 }, errors.New("database failure"))
cleanup()
control.Close()
date := time.Now().Format("2006-01-02")
for _, name := range []string{"application.log", filepath.Join("sql", "application.log"), filepath.Join("error", "error.log")} {
+1 -1
View File
@@ -247,7 +247,7 @@ func TestPersistRuntimeConfigReplacesActiveStorage(t *testing.T) {
}
d := &Data{
runtime: config.NewStore(&config.Config{Data: &config.Data{}, Admin: currentAdmin}),
gormDB: newReloadableDB(db, nil),
gormDB: newReloadableDB(db),
storage: currentStorage,
}
d.databaseReady.Store(true)
+1 -1
View File
@@ -165,7 +165,7 @@ func (r *rollback) run() {
// newReloadableDB owns only the hot-swappable database handle. Domain-specific
// callbacks are attached by Data when a pool becomes active.
func newReloadableDB(db *gorm.DB, _ ...dataScopeAuditEnqueue) *reloadable[*gorm.DB] {
func newReloadableDB(db *gorm.DB) *reloadable[*gorm.DB] {
return newReloadable(db, closeGormDB)
}
+2 -2
View File
@@ -71,7 +71,7 @@ func (r *announcementRepo) List(ctx context.Context, filter system.AnnouncementF
if filter.StartCreatedAt != nil && filter.EndCreatedAt != nil {
db = db.Where("created_at BETWEEN ? AND ?", filter.StartCreatedAt, filter.EndCreatedAt)
}
return listRows(db.Order("id desc"), filter.Page, filter.PageSize, filter.PageSize > 0, func(po announcementPO) *system.Announcement {
return listRows(db.Order("id desc"), filter.Page, filter.PageSize, rowQueryOptions{Paginate: filter.PageSize > 0}, func(po announcementPO) *system.Announcement {
return announcementToBiz(po)
})
}
@@ -83,7 +83,7 @@ func (r *announcementRepo) UserOptions(ctx context.Context) ([]system.UserOption
}
// The generated data-source endpoint is best effort: return collected
// options even when the underlying scan reports an error.
_ = r.data.DB().WithContext(ctx).Table("sys_users").Select("nick_name AS label, id AS value").Scan(&rows).Error
_ = r.data.DB().WithContext(ctx).Model(&userPO{}).Select("nick_name AS label, id AS value").Scan(&rows).Error
items := make([]system.UserOption, 0, len(rows))
for _, row := range rows {
items = append(items, system.UserOption{Label: row.Label, Value: row.Value})
+2 -5
View File
@@ -249,11 +249,8 @@ func (r *authorityAccessRepo) CopyAuthority(ctx context.Context, sourceID uint,
}
value.CreatedAt, value.UpdatedAt = po.CreatedAt, po.UpdatedAt
value.DataScope, value.DefaultRouter = po.DataScope, po.DefaultRouter
copyLinks := func(table string, destination any, columns map[string]any) error {
return tx.Table(table).Where(columns).Find(destination).Error
}
var menus []authorityMenuPO
if err := copyLinks("sys_authority_menus", &menus, map[string]any{"sys_authority_authority_id": sourceID}); err != nil {
if err := tx.Model(&authorityMenuPO{}).Where("sys_authority_authority_id = ?", sourceID).Find(&menus).Error; err != nil {
return err
}
if strict && len(menus) > 0 {
@@ -316,7 +313,7 @@ func (r *authorityAccessRepo) CopyAuthority(ctx context.Context, sourceID uint,
}
}
var buttons []authorityButtonPO
if err := copyLinks("sys_authority_btns", &buttons, map[string]any{"authority_id": sourceID}); err != nil {
if err := tx.Model(&authorityButtonPO{}).Where("authority_id = ?", sourceID).Find(&buttons).Error; err != nil {
return err
}
if strict && len(buttons) > 0 {
+1 -1
View File
@@ -35,7 +35,7 @@ func (r *auditQueryRepo) ListDataAccess(ctx context.Context, page, size int, q *
db = db.Where("target_table LIKE ?", "%"+q.TargetTable+"%")
}
}
pos, total, err := queryRows[DataAccessLogPO](db.Order("id desc"), page, size, true, true)
pos, total, err := queryRows[DataAccessLogPO](db.Order("id desc"), page, size, rowQueryOptions{Paginate: true, Required: true})
if err != nil {
return nil, 0, err
}
+1 -1
View File
@@ -90,7 +90,7 @@ func (r *auditQueryRepo) ListErrors(ctx context.Context, page, size int, q *syst
db = db.Where("info LIKE ?", "%"+*q.Info+"%")
}
}
return listRows(db.Order("created_at desc"), page, size, true, func(po errorRecordPO) *system.ErrorRecord {
return listRows(db.Order("created_at desc"), page, size, rowQueryOptions{Paginate: true}, func(po errorRecordPO) *system.ErrorRecord {
return errorFromPO(po)
})
}
+10 -5
View File
@@ -10,13 +10,18 @@ import (
// count the filtered query, apply the bounded page, load POs, and convert them
// at the data/biz boundary. Domain-specific filters and ordering stay in each
// repository.
func queryRows[PO any](db *gorm.DB, page, size int, paginate, required bool) ([]PO, int64, error) {
type rowQueryOptions struct {
Paginate bool
Required bool
}
func queryRows[PO any](db *gorm.DB, page, size int, options rowQueryOptions) ([]PO, int64, error) {
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
if paginate {
if required {
if options.Paginate {
if options.Required {
db = pagination.ApplyRequired(db, page, size, 100)
} else {
db = pagination.Apply(db, page, size, 100)
@@ -29,8 +34,8 @@ func queryRows[PO any](db *gorm.DB, page, size int, paginate, required bool) ([]
return pos, total, nil
}
func listRows[PO any, DO any](db *gorm.DB, page, size int, paginate bool, convert func(PO) *DO) ([]*DO, int64, error) {
pos, total, err := queryRows[PO](db, page, size, paginate, false)
func listRows[PO any, DO any](db *gorm.DB, page, size int, options rowQueryOptions, convert func(PO) *DO) ([]*DO, int64, error) {
pos, total, err := queryRows[PO](db, page, size, options)
if err != nil {
return nil, 0, err
}
+1 -1
View File
@@ -51,7 +51,7 @@ func (r *auditQueryRepo) ListLogins(ctx context.Context, page, size int, q *syst
db = db.Where("created_at <= ?", *q.EndCreatedAt)
}
}
pos, total, err := queryRows[loginLogPO](db.Order("id desc"), page, size, true, true)
pos, total, err := queryRows[loginLogPO](db.Order("id desc"), page, size, rowQueryOptions{Paginate: true, Required: true})
if err != nil {
return nil, 0, err
}
+1 -1
View File
@@ -47,7 +47,7 @@ func (r *auditQueryRepo) ListOperations(ctx context.Context, page, size int, q *
db = db.Where("status = ?", q.Status)
}
}
pos, total, err := queryRows[operationPO](db.Order("id desc"), page, size, true, true)
pos, total, err := queryRows[operationPO](db.Order("id desc"), page, size, rowQueryOptions{Paginate: true, Required: true})
if err != nil {
return nil, 0, err
}
+1 -1
View File
@@ -70,7 +70,7 @@ func (r *parameterRepo) ListParameters(ctx context.Context, page, size int, q *s
db = db.Where(clause.Like{Column: clause.Column{Name: "key"}, Value: "%" + q.Key + "%"})
}
}
return listRows(db.Order("id desc"), page, size, true, func(po parameterPO) *system.SystemParameter {
return listRows(db.Order("id desc"), page, size, rowQueryOptions{Paginate: true}, func(po parameterPO) *system.SystemParameter {
return parameterFromPO(po)
})
}
+1 -1
View File
@@ -85,5 +85,5 @@ func (r *securityRepo) SaveSecurityConfig(ctx context.Context, v *system.Securit
}
func (r *securityRepo) BackfillPasswordUpdatedAt(ctx context.Context, at time.Time) error {
return r.data.DB().WithContext(ctx).Table("sys_users").Where("password_updated_at IS NULL").Update("password_updated_at", at).Error
return r.data.DB().WithContext(ctx).Model(&userPO{}).Where("password_updated_at IS NULL").Update("password_updated_at", at).Error
}
+5 -5
View File
@@ -56,7 +56,7 @@ func (r *userRepo) loadUser(ctx context.Context, po *userPO) (*system.User, erro
return nil, err
}
var authorityPOs []authorityPO
if err := r.data.DB().WithContext(ctx).Table("sys_authorities").Joins("JOIN sys_user_authority ON sys_user_authority.sys_authority_authority_id = sys_authorities.authority_id").Where("sys_user_authority.sys_user_id = ? AND sys_authorities.deleted_at IS NULL", po.ID).Find(&authorityPOs).Error; err != nil {
if err := r.data.DB().WithContext(ctx).Model(&authorityPO{}).Joins("JOIN sys_user_authority ON sys_user_authority.sys_authority_authority_id = sys_authorities.authority_id").Where("sys_user_authority.sys_user_id = ? AND sys_authorities.deleted_at IS NULL", po.ID).Find(&authorityPOs).Error; err != nil {
return nil, err
}
authorities := make([]system.Authority, 0, len(authorityPOs))
@@ -68,7 +68,7 @@ func (r *userRepo) loadUser(ctx context.Context, po *userPO) (*system.User, erro
return nil, err
}
var departmentPOs []departmentPO
if err := r.data.DB().WithContext(ctx).Table("sys_departments").Joins("JOIN sys_user_departments ON sys_user_departments.sys_department_id = sys_departments.id").Where("sys_user_departments.sys_user_id = ? AND sys_departments.deleted_at IS NULL", po.ID).Find(&departmentPOs).Error; err != nil {
if err := r.data.DB().WithContext(ctx).Model(&departmentPO{}).Joins("JOIN sys_user_departments ON sys_user_departments.sys_department_id = sys_departments.id").Where("sys_user_departments.sys_user_id = ? AND sys_departments.deleted_at IS NULL", po.ID).Find(&departmentPOs).Error; err != nil {
return nil, err
}
departments := make([]system.Department, 0, len(departmentPOs))
@@ -86,7 +86,7 @@ func (r *userRepo) loadUser(ctx context.Context, po *userPO) (*system.User, erro
}
}
var positionPOs []positionPO
if err := r.data.DB().WithContext(ctx).Table("sys_positions").Joins("JOIN sys_user_positions ON sys_user_positions.sys_position_id = sys_positions.id").Where("sys_user_positions.sys_user_id = ? AND sys_positions.deleted_at IS NULL", po.ID).Find(&positionPOs).Error; err != nil {
if err := r.data.DB().WithContext(ctx).Model(&positionPO{}).Joins("JOIN sys_user_positions ON sys_user_positions.sys_position_id = sys_positions.id").Where("sys_user_positions.sys_user_id = ? AND sys_positions.deleted_at IS NULL", po.ID).Find(&positionPOs).Error; err != nil {
return nil, err
}
positions := make([]system.Position, 0, len(positionPOs))
@@ -238,7 +238,7 @@ func (r *userRepo) HasAuthorityMenu(ctx context.Context, authorityID uint, name
func (r *userRepo) MenusByAuthority(ctx context.Context, authorityID uint) ([]*system.Menu, error) {
var pos []menuPO
err := r.data.DB().WithContext(ctx).Table("sys_base_menus").
err := r.data.DB().WithContext(ctx).Model(&menuPO{}).
Joins("JOIN sys_authority_menus ON sys_authority_menus.sys_base_menu_id = sys_base_menus.id").
Where("sys_authority_menus.sys_authority_authority_id = ? AND sys_base_menus.deleted_at IS NULL", authorityID).
Order("sys_base_menus.sort").Scan(&pos).Error
@@ -266,7 +266,7 @@ func (r *userRepo) MenusByAuthority(ctx context.Context, authorityID uint) ([]*s
menu.Parameters = append(menu.Parameters, menuParameterFromPO(parameter))
}
var buttons []menuButtonPO
if err := r.data.DB().WithContext(ctx).Table("sys_base_menu_btns").Joins("JOIN sys_authority_btns ON sys_authority_btns.sys_base_menu_btn_id = sys_base_menu_btns.id").Where("sys_authority_btns.authority_id = ? AND sys_authority_btns.sys_menu_id IN ?", authorityID, menuIDs).Find(&buttons).Error; err != nil {
if err := r.data.DB().WithContext(ctx).Model(&menuButtonPO{}).Joins("JOIN sys_authority_btns ON sys_authority_btns.sys_base_menu_btn_id = sys_base_menu_btns.id").Where("sys_authority_btns.authority_id = ? AND sys_authority_btns.sys_menu_id IN ?", authorityID, menuIDs).Find(&buttons).Error; err != nil {
return nil, err
}
for _, button := range buttons {
+22 -26
View File
@@ -6,42 +6,42 @@ import (
"strings"
)
// Adapter is the provider boundary used by the payment repository. Provider
// SDK types stay in this package and are normalized to biz/payment results.
type Adapter = bizpayment.PaymentAdapter
type Factory struct{}
func NewFactory() *Factory { return &Factory{} }
type adapterFactory func() Adapter
type adapterFactory func() bizpayment.PaymentAdapter
// adapterFactories is the single payment integration registration point.
// Keep constructors zero-state: provider configuration belongs to each call,
// so an adapter can never accidentally retain secrets or order data.
var adapterFactories = map[string]adapterFactory{
bizpayment.PaymentAlipay: func() Adapter { return &alipayAdapter{} },
bizpayment.PaymentAlipayV3: func() Adapter { return &alipayV3Adapter{} },
bizpayment.PaymentWechatV2: func() Adapter { return &wechatV2Adapter{} },
bizpayment.PaymentWechatV3: func() Adapter { return &wechatV3Adapter{} },
bizpayment.PaymentApple: func() Adapter { return &appleAdapter{} },
bizpayment.PaymentDouyin: func() Adapter { return &douyinAdapter{} },
bizpayment.PaymentQQ: func() Adapter { return &qqAdapter{} },
bizpayment.PaymentAllinPay: func() Adapter { return &allinpayAdapter{} },
bizpayment.PaymentLakala: func() Adapter { return &lakalaAdapter{} },
bizpayment.PaymentPayPal: func() Adapter { return &paypalAdapter{} },
bizpayment.PaymentSaobei: func() Adapter { return &saobeiAdapter{} },
bizpayment.PaymentChinaums: func() Adapter { return newVendorAdapter(bizpayment.PaymentChinaums, vendorChinaums) },
bizpayment.PaymentSFT: func() Adapter { return newVendorAdapter(bizpayment.PaymentSFT, vendorSFT) },
bizpayment.PaymentSuperPay: func() Adapter { return newVendorAdapter(bizpayment.PaymentSuperPay, vendorSupperPay) },
bizpayment.PaymentWechatGame: func() Adapter { return newVendorAdapter(bizpayment.PaymentWechatGame, vendorWechatGame) },
bizpayment.PaymentDouyinGame: func() Adapter { return newVendorAdapter(bizpayment.PaymentDouyinGame, vendorDouyinGame) },
bizpayment.PaymentAlipay: func() bizpayment.PaymentAdapter { return &alipayAdapter{} },
bizpayment.PaymentAlipayV3: func() bizpayment.PaymentAdapter { return &alipayV3Adapter{} },
bizpayment.PaymentWechatV2: func() bizpayment.PaymentAdapter { return &wechatV2Adapter{} },
bizpayment.PaymentWechatV3: func() bizpayment.PaymentAdapter { return &wechatV3Adapter{} },
bizpayment.PaymentApple: func() bizpayment.PaymentAdapter { return &appleAdapter{} },
bizpayment.PaymentDouyin: func() bizpayment.PaymentAdapter { return &douyinAdapter{} },
bizpayment.PaymentQQ: func() bizpayment.PaymentAdapter { return &qqAdapter{} },
bizpayment.PaymentAllinPay: func() bizpayment.PaymentAdapter { return &allinpayAdapter{} },
bizpayment.PaymentLakala: func() bizpayment.PaymentAdapter { return &lakalaAdapter{} },
bizpayment.PaymentPayPal: func() bizpayment.PaymentAdapter { return &paypalAdapter{} },
bizpayment.PaymentSaobei: func() bizpayment.PaymentAdapter { return &saobeiAdapter{} },
bizpayment.PaymentChinaums: func() bizpayment.PaymentAdapter { return newVendorAdapter(bizpayment.PaymentChinaums, vendorChinaums) },
bizpayment.PaymentSFT: func() bizpayment.PaymentAdapter { return newVendorAdapter(bizpayment.PaymentSFT, vendorSFT) },
bizpayment.PaymentSuperPay: func() bizpayment.PaymentAdapter { return newVendorAdapter(bizpayment.PaymentSuperPay, vendorSupperPay) },
bizpayment.PaymentWechatGame: func() bizpayment.PaymentAdapter {
return newVendorAdapter(bizpayment.PaymentWechatGame, vendorWechatGame)
},
bizpayment.PaymentDouyinGame: func() bizpayment.PaymentAdapter {
return newVendorAdapter(bizpayment.PaymentDouyinGame, vendorDouyinGame)
},
}
// New constructs the SDK-backed adapter for a configured provider. Provider
// identifiers are normalized at this I/O boundary so direct callers get the
// same behavior as the configuration and business layers.
func New(provider string) (Adapter, error) {
func (*Factory) New(provider string) (bizpayment.PaymentAdapter, error) {
provider = strings.ToLower(strings.TrimSpace(provider))
factory, ok := adapterFactories[provider]
if !ok {
@@ -49,7 +49,3 @@ func New(provider string) (Adapter, error) {
}
return factory(), nil
}
func (*Factory) New(provider string) (bizpayment.PaymentAdapter, error) {
return New(provider)
}
+5 -3
View File
@@ -6,9 +6,10 @@ import (
)
func TestEverySupportedProviderHasAdapter(t *testing.T) {
factory := NewFactory()
for _, provider := range bizpayment.SupportedPaymentProviders {
t.Run(provider, func(t *testing.T) {
adapter, err := New(provider)
adapter, err := factory.New(provider)
if err != nil {
t.Fatalf("New(%q): %v", provider, err)
}
@@ -20,8 +21,9 @@ func TestEverySupportedProviderHasAdapter(t *testing.T) {
}
func TestNewNormalizesProviderIdentifier(t *testing.T) {
factory := NewFactory()
for _, provider := range bizpayment.SupportedPaymentProviders {
adapter, err := New(" " + provider + " ")
adapter, err := factory.New(" " + provider + " ")
if err != nil {
t.Fatalf("New(%q): %v", provider, err)
}
@@ -32,7 +34,7 @@ func TestNewNormalizesProviderIdentifier(t *testing.T) {
}
func TestNewRejectsUnknownProvider(t *testing.T) {
if _, err := New("unknown-provider"); err == nil {
if _, err := NewFactory().New("unknown-provider"); err == nil {
t.Fatal("unknown provider unexpectedly accepted")
}
}
+1 -1
View File
@@ -33,7 +33,7 @@ type vendorPaymentAdapter struct {
profile vendorProfile
}
func newVendorAdapter(provider string, profile vendorProfile) Adapter {
func newVendorAdapter(provider string, profile vendorProfile) bizpayment.PaymentAdapter {
return &vendorPaymentAdapter{provider: provider, profile: profile}
}
+3
View File
@@ -50,6 +50,9 @@ func (w *DailyWriter) Write(value []byte) (int, error) {
defer w.mu.Unlock()
date := time.Now().Format("2006-01-02")
if w.file == nil || w.date != date {
// Retention is enforced again when a long-lived writer rolls to a new day.
// Running this only on rotation avoids a directory scan for every write.
w.removeExpired()
if w.file != nil {
_ = w.file.Close()
}
+26
View File
@@ -0,0 +1,26 @@
package logging
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestDailyWriterCleansExpiredDirectoriesOnRotation(t *testing.T) {
root := t.TempDir()
writer := NewDailyWriter(root, "application.log", 1)
oldDate := time.Now().AddDate(0, 0, -3).Format("2006-01-02")
oldDir := filepath.Join(root, oldDate)
if err := os.MkdirAll(oldDir, 0o755); err != nil {
t.Fatal(err)
}
writer.date = "2000-01-01"
if _, err := writer.Write([]byte("rotated\n")); err != nil {
t.Fatal(err)
}
_ = writer.Close()
if _, err := os.Stat(oldDir); !os.IsNotExist(err) {
t.Fatalf("expired directory still exists: err=%v", err)
}
}
+1 -6
View File
@@ -460,7 +460,7 @@ func safeModuleName(value string) string {
}, value)
}
// NewZapLogger adapts a Zap core to the slog logger used by Kratos v3.
// newZapHandler adapts a Zap core to the slog handler used by Kratos v3.
// The file layout remains compatible with the administration log viewer.
func newZapHandler(root, filename string, options Options, errorSink *errorSinkState) (slog.Handler, func()) {
file := NewDailyWriter(root, filename, options.RetentionDay)
@@ -538,8 +538,3 @@ func NewReloadableZapLogger(root, filename string, options Options, attrs ...any
logger := kratoslog.NewLogger(handler, kratoslog.WithExtractor(tracing.TraceAttrs)).With(attrs...)
return logger, control
}
func NewZapLogger(root, filename string, options Options, attrs ...any) (*slog.Logger, func()) {
logger, control := NewReloadableZapLogger(root, filename, options, attrs...)
return logger, control.Close
}
+5 -3
View File
@@ -14,7 +14,7 @@ import (
const (
CodeSuccess = httpx.CodeSuccess
CodeError = httpx.CodeError
CodePasswordChangeRequired = httpx.CodePasswordChangeRequired
CodePasswordChangeRequired = middleware.CodePasswordChangeRequired
)
type Response = httpx.Response
@@ -25,5 +25,7 @@ func OK(c *gin.Context) { httpx.OK(c) }
func OKWithData(c *gin.Context, data any) { httpx.OKWithData(c, data) }
func Fail(c *gin.Context, message string) { httpx.Fail(c, message) }
func NoAuth(c *gin.Context, message string) { httpx.NoAuth(c, message) }
func SetTokenCookie(c *gin.Context, value string, maxAge int) { httpx.SetTokenCookie(c, value, maxAge) }
func Claims(c *gin.Context) *system.AuthClaims { return middleware.Claims(c) }
func SetTokenCookie(c *gin.Context, value string, maxAge int) {
httpx.SetCookie(c, "x-token", value, maxAge)
}
func Claims(c *gin.Context) *system.AuthClaims { return middleware.Claims(c) }
+6 -6
View File
@@ -12,9 +12,8 @@ import (
)
const (
CodeSuccess = 0
CodeError = 7
CodePasswordChangeRequired = 10001
CodeSuccess = 0
CodeError = 7
)
type Response struct {
@@ -55,12 +54,13 @@ func requestUsesHTTPS(request *http.Request) bool {
return strings.EqualFold(strings.TrimSpace(forwarded), "https")
}
// SetTokenCookie is the shared transport-level cookie policy for the admin API.
func SetTokenCookie(c *gin.Context, value string, maxAge int) {
// SetCookie applies the transport-level cookie policy without knowing the
// application's cookie names or authentication semantics.
func SetCookie(c *gin.Context, name, value string, maxAge int) {
if c == nil {
return
}
cookie := &http.Cookie{Name: "x-token", Value: value, Path: "/", MaxAge: maxAge, Secure: requestUsesHTTPS(c.Request), HttpOnly: true, SameSite: http.SameSiteStrictMode}
cookie := &http.Cookie{Name: name, Value: value, Path: "/", MaxAge: maxAge, Secure: requestUsesHTTPS(c.Request), HttpOnly: true, SameSite: http.SameSiteStrictMode}
if maxAge < 0 {
cookie.Expires = time.Unix(1, 0)
}
+13
View File
@@ -2,6 +2,7 @@ package httpx
import (
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
@@ -31,3 +32,15 @@ func TestResponseHelpers(t *testing.T) {
}
}
}
func TestSetCookieUsesRequestedName(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.GET("/cookie", func(c *gin.Context) { SetCookie(c, "example", "value", 60) })
req := httptest.NewRequest("GET", "/cookie", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if got := w.Header().Get("Set-Cookie"); !strings.HasPrefix(got, "example=") {
t.Fatalf("Set-Cookie = %q", got)
}
}
+6 -4
View File
@@ -16,6 +16,8 @@ import (
const claimsKey = "admin_claims"
const CodePasswordChangeRequired = 10001
// 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
@@ -62,20 +64,20 @@ func authenticate(c *gin.Context, auth TokenAuthenticator, allowQueryToken bool)
return auth.AuthenticateToken(ctx, token)
})
if err != nil {
httpx.SetTokenCookie(c, "", -1)
httpx.SetCookie(c, "x-token", "", -1)
httpx.NoAuth(c, tokenErrorMessage(err))
return false
}
authentication, ok := value.(*system.TokenAuthentication)
if !ok || authentication == nil || authentication.Claims == nil {
httpx.SetTokenCookie(c, "", -1)
httpx.SetCookie(c, "x-token", "", -1)
httpx.NoAuth(c, "无法处理此token")
return false
}
if authentication.Refreshed != nil {
c.Header("new-token", authentication.Refreshed.Value)
c.Header("new-expires-at", strconv.FormatInt(authentication.Refreshed.ExpiresAt.Unix(), 10))
httpx.SetTokenCookie(c, authentication.Refreshed.Value, int(authentication.Refreshed.TTL.Seconds()))
httpx.SetCookie(c, "x-token", authentication.Refreshed.Value, int(authentication.Refreshed.TTL.Seconds()))
}
c.Set(claimsKey, authentication.Claims)
return true
@@ -141,6 +143,6 @@ func MustChangePassword() gin.HandlerFunc {
c.Next()
return
}
c.AbortWithStatusJSON(http.StatusConflict, httpx.Response{Code: httpx.CodePasswordChangeRequired, Data: gin.H{"needChangePassword": true}, Msg: "密码已过期,请先修改密码"})
c.AbortWithStatusJSON(http.StatusConflict, httpx.Response{Code: CodePasswordChangeRequired, Data: gin.H{"needChangePassword": true}, Msg: "密码已过期,请先修改密码"})
}
}