优化结构

This commit is contained in:
Yvan
2026-08-20 21:46:52 +08:00
parent 23bc29ce0f
commit 63dbb4cfbc
78 changed files with 767 additions and 1082 deletions
+25
View File
@@ -0,0 +1,25 @@
// Package adminsurface defines administration UI metadata contributed by
// business modules. The system data module owns how this metadata is stored.
package adminsurface
type Menu struct {
Name string
Path string
ParentName string
Component string
Title string
Icon string
Sort int
}
type API struct {
Path string
Method string
APIGroup string
Description string
}
type Surface struct {
Menus []Menu
APIs []API
}
+28 -3
View File
@@ -15,7 +15,11 @@ feature can be found by its domain instead of by scanning one large package.
persistence. Provider SDK implementations are separate in
`internal/integration/payment`.
- `internal/data/migration` contains the version-table runner used by the
root migration coordinator.
root migration coordinator. It stays under data because it executes and
records database schema steps.
- `internal/adminsurface` contains the small menu/API metadata contract used
when a business module contributes pages to the administration UI. It lives
outside `data` because the contract itself is not persistence code.
The root package intentionally keeps only cross-cutting infrastructure:
database lifecycle/reloads, runtime configuration persistence, data-scope
@@ -23,6 +27,27 @@ auditing, integration configuration storage, and migration orchestration.
Repositories depend on narrow module seams (`system.Provider` and
`payment.Provider`) rather than importing the root implementation details.
First-install and configuration-management orchestration lives in
`internal/initialize`. It implements `biz.InitializationRepo` through a narrow
backend interface that `*data.Data` satisfies. This keeps administrator/menu/API
seeding out of the database lifecycle package while retaining one migration
entry point.
Non-database adapters remain under `internal/integration` (storage, email,
payment SDKs, and cache), while reusable GORM and pagination helpers live in
`pkg/gormkit` and `pkg/pagination`.
payment SDKs, and cache). Their constructors are provided by
`internal/integration.ProviderSet`, separate from the data provider set.
GORM and pagination helpers live beside the data layer; stateless payment
parsing lives in `internal/utils/paymentutil`; admin JWT handling lives in
`internal/security/adminauth`.
## Migration and bootstrap
There is one migration execution entry point: `migrateAll` in the root data
package. The root owns the shared integration-configuration table and appends
the steps returned by `system.Migrations()` and `payment.Migrations()`.
Migrations create schemas and module-required fixed records only. Initial
business data is separate: `initialize.Repo` invokes `system.SeedSystem` only
from the explicit first-install flow to create the administrator, roles,
menus, APIs, departments, policies, and module-provided administration
surfaces.
+35
View File
@@ -0,0 +1,35 @@
package data
import (
"kra/internal/conf"
"google.golang.org/protobuf/proto"
)
func cloneAdminConfig(value *conf.AdminBackend) *conf.AdminBackend {
if value == nil {
return &conf.AdminBackend{}
}
return proto.Clone(value).(*conf.AdminBackend)
}
func maskStorageSecrets(storage *conf.AdminBackend_Storage) {
if storage == nil {
return
}
if storage.Qiniu != nil && storage.Qiniu.SecretKey != "" {
storage.Qiniu.SecretKey = "******"
}
for _, item := range []*conf.AdminBackend_ObjectStore{
storage.AliyunOss,
storage.HuaweiObs,
storage.TencentCos,
storage.AwsS3,
storage.CloudflareR2,
storage.Minio,
} {
if item != nil && item.SecretKey != "" {
item.SecretKey = "******"
}
}
}
+2 -7
View File
@@ -11,12 +11,9 @@ import (
"github.com/google/wire"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
"kra/internal/biz"
"kra/internal/conf"
datapayment "kra/internal/data/payment"
datasystem "kra/internal/data/system"
"kra/internal/integration/cache"
"kra/internal/integration/email"
"kra/internal/integration/storage"
)
@@ -25,15 +22,13 @@ var ProviderSet = wire.NewSet(
wire.Bind(new(datasystem.Provider), new(*Data)),
wire.Bind(new(datasystem.DatabaseProvider), new(*Data)),
wire.Bind(new(datapayment.Provider), new(*Data)),
wire.Bind(new(cache.RedisProvider), new(*Data)),
wire.Bind(new(biz.FileStorage), new(*storage.Reloadable)),
datasystem.NewRuntimeSettings,
datasystem.NewTokenIssuer,
datasystem.NewUserRepo, NewInitializationRepo, datasystem.NewAuthorityAccessRepo, datasystem.NewAPIRepo, datasystem.NewPermissionRepo,
datasystem.NewUserRepo, datasystem.NewAuthorityAccessRepo, datasystem.NewAPIRepo, datasystem.NewPermissionRepo,
datasystem.NewMenuRepo, datasystem.NewDepartmentRepo, datasystem.NewPositionRepo, datasystem.NewDictionaryRepo, datasystem.NewParameterRepo, datasystem.NewAPITokenRepo,
datasystem.NewSecurityRepo,
datasystem.NewVersionRepo, datasystem.NewExportRepo, datasystem.NewAuditRepo, datasystem.NewAuditRecorderRepo, datasystem.NewLogFileRepo, datasystem.NewTaskRepo,
datasystem.NewMediaRepo, datasystem.NewAnnouncementRepo, email.NewEmailRepo, datapayment.NewPaymentRepo, datapayment.NewPaymentOrderRepo, cache.New, storage.NewFileStorage,
datasystem.NewMediaRepo, datasystem.NewAnnouncementRepo, datapayment.NewPaymentRepo, datapayment.NewPaymentOrderRepo,
)
type Data struct {
+1 -1
View File
@@ -20,7 +20,7 @@ import (
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
"kra/internal/conf"
"kra/pkg/gormkit"
"kra/internal/data/gormkit"
)
var databaseNamePattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_$-]*$`)
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"testing"
"time"
"kra/pkg/gormkit"
"kra/pkg/logging"
"kra/internal/data/gormkit"
"kra/internal/logging"
"gorm.io/gorm/logger"
)
+5
View File
@@ -0,0 +1,5 @@
# GORM Helpers
This package owns storage-specific GORM types and logging integration used by
the data repositories. It is intentionally internal because its JSON value and
logger are persistence concerns, not general application utilities.
+105
View File
@@ -0,0 +1,105 @@
// Package gormkit contains reusable GORM integration helpers.
package gormkit
import (
"context"
"database/sql/driver"
"errors"
"fmt"
"io"
"log/slog"
"time"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
)
// JSON is a portable JSON value for GORM models. It selects a native JSON
// column where the driver supports one and a textual fallback elsewhere.
type JSON []byte
func (JSON) GormDataType() string { return "json" }
func (JSON) GormDBDataType(db *gorm.DB, _ *schema.Field) string {
switch db.Dialector.Name() {
case "mysql", "sqlite":
return "JSON"
case "postgres":
return "JSONB"
case "sqlserver":
return "NVARCHAR(MAX)"
case "oracle":
return "CLOB"
default:
return "TEXT"
}
}
func (j JSON) Value() (driver.Value, error) {
if len(j) == 0 {
return nil, nil
}
return string(j), nil
}
func (j *JSON) Scan(value any) error {
switch raw := value.(type) {
case nil:
*j = nil
case []byte:
*j = append((*j)[:0], raw...)
case string:
*j = append((*j)[:0], raw...)
default:
return fmt.Errorf("cannot scan JSON from %T", value)
}
return nil
}
// Logger forwards GORM diagnostics through an application slog logger.
type Logger struct {
logger *slog.Logger
slowThreshold time.Duration
level logger.LogLevel
}
func NewLogger(log *slog.Logger, level logger.LogLevel) *Logger {
if log == nil {
log = slog.New(slog.NewTextHandler(io.Discard, nil))
}
return &Logger{logger: log, slowThreshold: 200 * time.Millisecond, level: level}
}
func (g *Logger) LogMode(level logger.LogLevel) logger.Interface {
next := *g
next.level = level
return &next
}
func (g *Logger) Info(ctx context.Context, message string, args ...any) {
g.logger.InfoContext(ctx, fmt.Sprintf(message, args...), "mod", "sql", "gorm_logger", true)
}
func (g *Logger) Warn(ctx context.Context, message string, args ...any) {
g.logger.WarnContext(ctx, fmt.Sprintf(message, args...), "mod", "sql", "gorm_logger", true)
}
func (g *Logger) Error(ctx context.Context, message string, args ...any) {
g.logger.ErrorContext(ctx, fmt.Sprintf(message, args...), "mod", "sql", "gorm_logger", true)
}
func (g *Logger) Trace(ctx context.Context, begin time.Time, query func() (string, int64), queryErr error) {
if g.level <= logger.Silent {
return
}
elapsed := time.Since(begin)
sql, rows := query()
fields := []any{"mod", "sql", "gorm_logger", true, "sql", sql, "rows", rows, "elapsed_ms", elapsed.Milliseconds()}
switch {
case queryErr != nil && g.level >= logger.Error && !errors.Is(queryErr, logger.ErrRecordNotFound):
fields = append(fields, "error", queryErr)
g.logger.ErrorContext(ctx, "SQL 执行错误", fields...)
case elapsed > g.slowThreshold && g.level >= logger.Warn:
g.logger.WarnContext(ctx, "SQL 慢查询", fields...)
case g.level >= logger.Info:
g.logger.InfoContext(ctx, "SQL", fields...)
}
}
-9
View File
@@ -1,9 +0,0 @@
package data
import "kra/internal/biz"
type initializationRepo struct{ data *Data }
func NewInitializationRepo(data *Data) biz.InitializationRepo {
return &initializationRepo{data: data}
}
@@ -7,17 +7,58 @@ import (
"kra/internal/biz"
"kra/internal/conf"
datasystem "kra/internal/data/system"
"kra/internal/integration/storage"
"github.com/google/uuid"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
"gorm.io/gorm"
)
func (r *initializationRepo) PersistConfig(context.Context) error { return r.data.persistConfig() }
func (r *initializationRepo) PersistAdminConfig(ctx context.Context, raw []byte) error {
currentData, currentAdmin := r.data.runtime.Values()
func (d *Data) RuntimeValues() (*conf.Data, *conf.AdminBackend) { return d.runtime.Values() }
func (d *Data) RuntimeAdmin() *conf.AdminBackend { return d.runtime.Admin() }
func (d *Data) RefreshDatabaseSources(value *conf.Data) error { return refreshDatabaseSources(value) }
func refreshDatabaseSources(value *conf.Data) error {
if value == nil {
return nil
}
if err := refreshDatabaseSource(value.Database); err != nil {
return err
}
for _, database := range value.DatabaseList {
if database == nil || database.Disable {
continue
}
if err := refreshDatabaseSource(database); err != nil {
return err
}
}
return nil
}
func refreshDatabaseSource(database *conf.Data_Database) error {
if database == nil {
return nil
}
hasStructuredConfig := database.Host != "" || database.Port != "" || database.User != "" || database.Password != "" || database.Name != "" || database.Config != "" || database.Path != ""
if !hasStructuredConfig {
return nil
}
previousSource := database.Source
database.Source = ""
source, err := databaseDSN(database, "")
if err != nil {
database.Source = previousSource
return err
}
database.Source = source
return nil
}
func (d *Data) PersistConfig(context.Context) error { return d.persistConfig() }
func (d *Data) PersistAdminConfig(ctx context.Context, raw []byte) error {
currentData, currentAdmin := d.runtime.Values()
next := proto.Clone(currentAdmin).(*conf.AdminBackend)
if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(raw, next); err != nil {
return err
@@ -33,25 +74,25 @@ func (r *initializationRepo) PersistAdminConfig(ctx context.Context, raw []byte)
if err != nil {
return err
}
if err := r.data.persistStorageIntegrationConfig(ctx, next.Storage); err != nil {
if err := d.persistStorageIntegrationConfig(ctx, next.Storage); err != nil {
return err
}
if err := r.data.persistEmailIntegrationConfig(ctx, next.Email); err != nil {
if err := d.persistEmailIntegrationConfig(ctx, next.Email); err != nil {
return err
}
if err := r.data.persistConfigValues(currentData, next); err != nil {
if err := d.persistConfigValues(currentData, next); err != nil {
return err
}
// Writing through the management API updates the same in-memory values
// immediately; the file watcher remains the fallback for external edits.
r.data.runtime.Replace(currentData, next)
if r.data.storage != nil {
r.data.storage.Replace(candidateStorage)
d.runtime.Replace(currentData, next)
if d.storage != nil {
d.storage.Replace(candidateStorage)
}
return nil
}
func (r *initializationRepo) PersistRuntimeConfig(ctx context.Context, dataRaw, adminRaw []byte) error {
currentData, currentAdmin := r.data.runtime.Values()
func (d *Data) PersistRuntimeConfig(ctx context.Context, dataRaw, adminRaw []byte) error {
currentData, currentAdmin := d.runtime.Values()
nextData := proto.Clone(currentData).(*conf.Data)
nextAdmin := proto.Clone(currentAdmin).(*conf.AdminBackend)
options := protojson.UnmarshalOptions{DiscardUnknown: true}
@@ -72,32 +113,35 @@ func (r *initializationRepo) PersistRuntimeConfig(ctx context.Context, dataRaw,
if err != nil {
return err
}
if err := r.data.persistStorageIntegrationConfig(ctx, nextAdmin.Storage); err != nil {
if err := d.persistStorageIntegrationConfig(ctx, nextAdmin.Storage); err != nil {
return err
}
if err := r.data.persistEmailIntegrationConfig(ctx, nextAdmin.Email); err != nil {
if err := d.persistEmailIntegrationConfig(ctx, nextAdmin.Email); err != nil {
return err
}
if err := r.data.persistConfigValues(nextData, nextAdmin); err != nil {
if err := d.persistConfigValues(nextData, nextAdmin); err != nil {
return err
}
r.data.runtime.Replace(nextData, nextAdmin)
if r.data.storage != nil {
r.data.storage.Replace(candidateStorage)
d.runtime.Replace(nextData, nextAdmin)
if d.storage != nil {
d.storage.Replace(candidateStorage)
}
return nil
}
func (r *initializationRepo) ReloadConfig(ctx context.Context) error {
return r.data.reloadConfig(ctx)
func (d *Data) ReloadConfig(ctx context.Context) error {
return d.reloadConfig(ctx)
}
func (r *initializationRepo) IsInitialized(ctx context.Context) (bool, error) {
return r.data.databaseReady.Load(), nil
func (d *Data) IsInitialized(context.Context) (bool, error) {
return d.databaseReady.Load(), nil
}
func (r *initializationRepo) Initialize(ctx context.Context, input *biz.DatabaseConfig) error {
// InitializeDatabase opens and activates the configured database. The callback
// is the application-level first-install hook; data owns only lifecycle and
// schema migration, while initialize owns system seed orchestration.
func (d *Data) InitializeDatabase(ctx context.Context, input *biz.DatabaseConfig, seed func(context.Context, *gorm.DB) error) error {
config := &conf.Data_Database{}
if current := r.data.runtime.Data(); current != nil && current.Database != nil {
if current := d.runtime.Data(); current != nil && current.Database != nil {
config = proto.Clone(current.Database).(*conf.Data_Database)
}
config.Driver = input.Driver
@@ -114,16 +158,16 @@ func (r *initializationRepo) Initialize(ctx context.Context, input *biz.Database
return err
}
config.Source = source
r.data.initMu.Lock()
defer r.data.initMu.Unlock()
initialized, err := r.IsInitialized(ctx)
d.initMu.Lock()
defer d.initMu.Unlock()
initialized, err := d.IsInitialized(ctx)
if err != nil {
return err
}
if initialized {
return errors.New("数据库已初始化,无需重复初始化")
}
candidate, err := openDatabase(config, true, input.Template, r.data.logger())
candidate, err := openDatabase(config, true, input.Template, d.logger())
if err != nil {
return err
}
@@ -139,10 +183,12 @@ func (r *initializationRepo) Initialize(ctx context.Context, input *biz.Database
if err := migrateAll(db); err != nil {
return err
}
if err := datasystem.SeedSystem(ctx, db, input); err != nil {
return err
if seed != nil {
if err := seed(ctx, db); err != nil {
return err
}
}
currentAdmin := r.data.runtime.Admin()
currentAdmin := d.runtime.Admin()
var legacyStorage *conf.AdminBackend_Storage
if currentAdmin != nil {
legacyStorage = currentAdmin.Storage
@@ -160,11 +206,11 @@ func (r *initializationRepo) Initialize(ctx context.Context, input *biz.Database
return fmt.Errorf("initialize email integration configuration: %w", err)
}
signingKey := uuid.NewString()
if err := r.data.persistDatabaseConfig(config, signingKey); err != nil {
if err := d.persistDatabaseConfig(config, signingKey); err != nil {
return fmt.Errorf("persist database configuration: %w", err)
}
r.data.activateDatabase(candidate, config)
currentData, currentAdmin := r.data.runtime.Values()
d.activateDatabase(candidate, config)
currentData, currentAdmin := d.runtime.Values()
if currentAdmin == nil {
currentAdmin = &conf.AdminBackend{}
}
@@ -174,7 +220,7 @@ func (r *initializationRepo) Initialize(ctx context.Context, input *biz.Database
currentAdmin.Jwt.SigningKey = signingKey
currentAdmin.Storage = storageConfig
currentAdmin.Email = emailConfig
r.data.runtime.Replace(currentData, currentAdmin)
d.runtime.Replace(currentData, currentAdmin)
activated = true
return nil
}
@@ -35,9 +35,8 @@ func TestDatabaseConnectionConfigured(t *testing.T) {
func TestInitializationStateTracksRealDatabaseConnection(t *testing.T) {
data := &Data{}
repo := &initializationRepo{data: data}
initialized, err := repo.IsInitialized(context.Background())
initialized, err := data.IsInitialized(context.Background())
if err != nil {
t.Fatalf("IsInitialized() error = %v", err)
}
@@ -46,7 +45,7 @@ func TestInitializationStateTracksRealDatabaseConnection(t *testing.T) {
}
data.databaseReady.Store(true)
initialized, err = repo.IsInitialized(context.Background())
initialized, err = data.IsInitialized(context.Background())
if err != nil {
t.Fatalf("IsInitialized() error = %v", err)
}
+1 -2
View File
@@ -267,8 +267,7 @@ func TestPersistRuntimeConfigReplacesActiveStorage(t *testing.T) {
if err != nil {
t.Fatal(err)
}
repo := &initializationRepo{data: d}
if err = repo.PersistRuntimeConfig(context.Background(), dataRaw, adminRaw); err != nil {
if err = d.PersistRuntimeConfig(context.Background(), dataRaw, adminRaw); err != nil {
t.Fatal(err)
}
+3 -3
View File
@@ -1,6 +1,6 @@
// Package migration owns the application database migration runner. Schema
// steps remain in internal/data because they need that package's private POs;
// this package contains only the reusable versioning mechanism.
// Package migration owns the application database migration runner. Each data
// module declares its own steps; the root data package only orders and runs
// them.
package migration
import (
+9 -17
View File
@@ -11,21 +11,13 @@ import (
// migrateAll is the single data-layer migration entry point. Module-specific
// schema work stays with the module that owns its persistent objects.
func migrateAll(db *gorm.DB) error {
return migration.Run(db, []migration.Step{
{ID: "202608200001_baseline", Migrate: func(db *gorm.DB) error {
if err := datasystem.LegacySchemaMigration(db); err != nil {
return err
}
return datapayment.Migrate(db)
}},
{ID: "202608200002_data_reconcile", Migrate: func(db *gorm.DB) error {
if err := datapayment.Reconcile(db); err != nil {
return err
}
return datasystem.CurrentDataMigration(db)
}},
{ID: "202608200003_payment_admin_surface", Migrate: func(db *gorm.DB) error {
return datasystem.EnsureAdminSurface(db, datapayment.AdminSurface())
}},
})
steps := []migration.Step{{
ID: "202608200001_data_infrastructure",
Migrate: func(db *gorm.DB) error {
return db.AutoMigrate(&integrationConfigPO{})
},
}}
steps = append(steps, datasystem.Migrations()...)
steps = append(steps, datapayment.Migrations()...)
return migration.Run(db, steps)
}
+41
View File
@@ -0,0 +1,41 @@
package data
import (
"testing"
"kra/internal/data/migration"
)
func TestMigrateAllRunsModuleSchemasWithoutBootstrapSeed(t *testing.T) {
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
if err != nil {
t.Fatal(err)
}
if err = migrateAll(db); err != nil {
t.Fatal(err)
}
if err = migrateAll(db); err != nil {
t.Fatalf("second migration run: %v", err)
}
for _, table := range []string{"sys_integration_configs", "sys_users", "sys_base_menus", "pay_orders"} {
if !db.Migrator().HasTable(table) {
t.Fatalf("migration did not create %s", table)
}
}
var versions int64
if err = db.Table(migration.TableName).Count(&versions).Error; err != nil {
t.Fatal(err)
}
if versions != 4 {
t.Fatalf("migration versions = %d, want 4", versions)
}
for _, table := range []string{"sys_users", "sys_base_menus", "sys_apis"} {
var count int64
if err = db.Table(table).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("migration seeded %s: rows=%d", table, count)
}
}
}
+37
View File
@@ -0,0 +1,37 @@
// Package pagination contains GORM pagination helpers shared by repository
// implementations in the data layer.
package pagination
import "gorm.io/gorm"
// LimitOffset normalizes a page request and applies the optional maximum page
// size. A non-positive size means that no limit should be applied.
func LimitOffset(page, size, maxSize int) (limit, offset int) {
limit = size
if maxSize > 0 && size > maxSize {
limit = maxSize
}
if limit <= 0 {
return 0, 0
}
if page <= 0 {
page = 1
}
return limit, (page - 1) * limit
}
// Apply applies pagination only when the caller supplied a positive size.
func Apply(db *gorm.DB, page, size, maxSize int) *gorm.DB {
limit, offset := LimitOffset(page, size, maxSize)
if limit == 0 {
return db
}
return db.Offset(offset).Limit(limit)
}
// ApplyRequired always applies LIMIT/OFFSET, including LIMIT 0. This keeps
// the existing repository contract for APIs whose page size is mandatory.
func ApplyRequired(db *gorm.DB, page, size, maxSize int) *gorm.DB {
limit, offset := LimitOffset(page, size, maxSize)
return db.Offset(offset).Limit(limit)
}
+13 -13
View File
@@ -1,31 +1,31 @@
package payment
import (
"kra/internal/data/system"
"kra/internal/adminsurface"
"kra/internal/data/migration"
"gorm.io/gorm"
)
// Migrate creates the persistence owned by the payment module.
func Migrate(db *gorm.DB) error {
return db.AutoMigrate(&integrationConfigPO{}, &paymentOrderPO{})
}
// Reconcile seeds disabled configuration rows for all built-in providers.
func Reconcile(db *gorm.DB) error {
return ensurePaymentIntegrationConfigs(db)
func Migrations() []migration.Step {
return []migration.Step{
{ID: "202608200003_payment_schema", Migrate: func(db *gorm.DB) error {
return db.AutoMigrate(&paymentOrderPO{})
}},
{ID: "202608200004_payment_defaults", Migrate: ensurePaymentIntegrationConfigs},
}
}
// AdminSurface describes the payment-owned entries shown in the system
// administration UI. The system module persists these records because it owns
// the menu/API/policy tables.
func AdminSurface() system.AdminSurface {
return system.AdminSurface{
Menus: []system.AdminMenu{
func AdminSurface() adminsurface.Surface {
return adminsurface.Surface{
Menus: []adminsurface.Menu{
{Name: "paymentOrders", Path: "paymentOrders", ParentName: "extensions", Component: "view/systemTools/payment/orders.vue", Title: "支付订单", Icon: "wallet", Sort: 6},
{Name: "paymentConfig", Path: "paymentConfig", ParentName: "extensions", Component: "view/systemTools/payment/config.vue", Title: "支付配置", Icon: "credit-card", Sort: 7},
},
APIs: []system.AdminAPI{
APIs: []adminsurface.API{
{Path: "/payment/configs", Method: "GET", APIGroup: "支付", Description: "获取支付渠道配置"},
{Path: "/payment/config", Method: "POST", APIGroup: "支付", Description: "保存支付渠道配置"},
{Path: "/payment/orders", Method: "GET", APIGroup: "支付", Description: "分页查询支付订单"},
+3 -3
View File
@@ -1,6 +1,6 @@
package payment
import "kra/pkg/paymentkit"
import "kra/internal/utils/paymentutil"
func text(values map[string]any, key string) string {
value, _ := values[key].(string)
@@ -17,7 +17,7 @@ func firstAny(values map[string]any, keys ...string) string {
}
// configuredInt64 keeps repository-side validation independent from the
// provider adapter package while sharing the canonical paymentkit parser.
// provider adapter package while sharing the canonical payment parser.
func configuredInt64(values map[string]any, key string, fallback int64) int64 {
return paymentkit.ConfiguredInt64(values, key, fallback)
return paymentutil.ConfiguredInt64(values, key, fallback)
}
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"gorm.io/gorm"
"gorm.io/gorm/clause"
"kra/internal/biz"
"kra/pkg/pagination"
"kra/internal/data/pagination"
)
type paymentOrderPO struct {
+3 -10
View File
@@ -6,7 +6,6 @@ import (
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"kra/internal/biz"
)
type Data struct{ gormDB *reloadableDB }
@@ -49,19 +48,13 @@ func openIntegrationConfigTestDB(t *testing.T) *gorm.DB {
}
func migrateAll(db *gorm.DB) error {
if err := db.AutoMigrate(&integrationConfigPO{}, &paymentOrderPO{}); err != nil {
if err := db.AutoMigrate(&integrationConfigPO{}); err != nil {
return err
}
for _, provider := range biz.SupportedPaymentProviders {
var count int64
if err := db.Model(&integrationConfigPO{}).Where("kind = ? AND provider = ?", integrationKindPayment, provider).Count(&count).Error; err != nil {
for _, step := range Migrations() {
if err := step.Migrate(db); err != nil {
return err
}
if count == 0 {
if err := db.Create(&integrationConfigPO{Kind: integrationKindPayment, Provider: provider, Config: "{}"}).Error; err != nil {
return err
}
}
}
return nil
}
+3 -3
View File
@@ -6,8 +6,8 @@ import (
"time"
"kra/internal/biz"
"kra/pkg/gormkit"
"kra/pkg/pagination"
"kra/internal/data/gormkit"
"kra/internal/data/pagination"
"gorm.io/gorm"
)
@@ -23,7 +23,7 @@ type announcementPO struct {
Attachments gormkit.JSON
}
func (announcementPO) TableName() string { return "kra_announcements_info" }
func (announcementPO) TableName() string { return "sys_announcements" }
type announcementRepo struct{ data Provider }
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"time"
"kra/internal/biz"
"kra/pkg/pagination"
"kra/internal/data/pagination"
"gorm.io/gorm"
)
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"time"
"kra/internal/biz"
"kra/pkg/pagination"
"kra/internal/data/pagination"
"gorm.io/gorm"
)
-25
View File
@@ -10,31 +10,6 @@ type IgnoredAPI struct {
Path string
}
// AdminMenu describes a menu entry contributed by a module. ParentName is
// resolved by the system persistence layer so feature modules do not need to
// know the menu PO shape.
type AdminMenu struct {
Name string
Path string
ParentName string
Component string
Title string
Icon string
Sort int
}
type AdminAPI struct {
Path string
Method string
APIGroup string
Description string
}
type AdminSurface struct {
Menus []AdminMenu
APIs []AdminAPI
}
func DefaultIgnoredAPIs(staticPath string) []IgnoredAPI {
staticRoute := "/" + strings.Trim(staticPath, "/") + "/*filepath"
return []IgnoredAPI{
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"time"
"kra/internal/biz"
"kra/pkg/pagination"
"kra/internal/data/pagination"
"gorm.io/gorm"
)
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"time"
"kra/internal/biz"
"kra/pkg/pagination"
"kra/internal/data/pagination"
"gorm.io/gorm"
)
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"time"
"kra/internal/biz"
"kra/pkg/pagination"
"kra/internal/data/pagination"
"gorm.io/gorm"
)
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"time"
"kra/internal/biz"
"kra/pkg/pagination"
"kra/internal/data/pagination"
"gorm.io/gorm"
)
+21 -498
View File
@@ -1,507 +1,30 @@
package system
import (
"errors"
"fmt"
"strings"
"time"
"kra/internal/data/migration"
"gorm.io/gorm"
)
// legacySchemaMigration creates the complete schema and repairs legacy table
// shapes. It is intentionally called only by the one-time baseline migration.
func LegacySchemaMigration(db *gorm.DB) error {
if err := migrateLegacyIgnoreAPITable(db); err != nil {
return err
}
if err := migrateLegacyAuthorityDepartmentColumns(db); err != nil {
return err
}
return db.AutoMigrate(
&userPO{}, &authorityPO{}, &menuPO{}, &userAuthorityPO{}, &authorityMenuPO{}, &menuParameterPO{},
&apiPO{}, &ignoredAPIPO{}, &authorityAPIPO{}, &casbinRulePO{}, &menuButtonPO{}, &authorityButtonPO{},
&departmentPO{}, &positionPO{}, &userDepartmentPO{}, &userPositionPO{}, &authorityDepartmentPO{},
&dictionaryPO{}, &dictionaryDetailPO{}, &parameterPO{}, &apiTokenPO{}, &jwtBlacklistPO{}, &SecurityConfigPO{},
&versionPO{}, &exportTemplatePO{}, &exportConditionPO{}, &exportJoinPO{},
&operationPO{}, &loginLogPO{}, &dataAccessLogPO{}, &errorRecordPO{},
&taskPO{}, &taskLogPO{}, &mediaPO{}, &categoryPO{}, &uploadSessionPO{}, &uploadChunkPO{},
&announcementPO{},
)
}
// CurrentDataMigration contains idempotent system data and authorization
// reconciliation that belongs to an explicit gormigrate version.
func CurrentDataMigration(db *gorm.DB) error {
if err := migrateLegacyAuthorityAPIsToCasbinRules(db); err != nil {
return err
}
if err := normalizeErrorRecordStatuses(db); err != nil {
return err
}
if err := reconcileRootAuthorityAPIs(db); err != nil {
return err
}
return reconcileReferenceIndexes(db)
}
// EnsureAdminSurface upgrades an existing database with menu/API metadata
// contributed by a feature module. The operation is idempotent and safe to run
// before the root authority exists; bootstrap seeding links the full menu tree
// when the first administrator is created.
func EnsureAdminSurface(db *gorm.DB, surface AdminSurface) error {
clean := db.Session(&gorm.Session{NewDB: true})
return clean.Transaction(func(tx *gorm.DB) error {
menus := make([]menuPO, 0, len(surface.Menus))
for _, item := range surface.Menus {
parentID := uint(0)
if item.ParentName != "" {
var parent menuPO
if err := tx.Where("name = ?", item.ParentName).First(&parent).Error; err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
parent = menuPO{Path: item.ParentName, Name: item.ParentName, Component: "view/routerHolder.vue", Title: item.ParentName, Sort: item.Sort}
if err := tx.Create(&parent).Error; err != nil {
return err
}
}
parentID = parent.ID
}
menu := menuPO{MenuLevel: 1, ParentID: parentID, Path: item.Path, Name: item.Name, Component: item.Component, Title: item.Title, Icon: item.Icon, Sort: item.Sort}
if item.ParentName == "" {
menu.MenuLevel = 0
}
menus = append(menus, menu)
}
for _, item := range menus {
var current menuPO
err := tx.Where("name = ?", item.Name).First(&current).Error
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
if err := tx.Create(&item).Error; err != nil {
return err
}
case err != nil:
return err
default:
if err := tx.Model(&current).Updates(map[string]any{
"menu_level": item.MenuLevel, "parent_id": parent.ID, "path": item.Path,
"component": item.Component, "title": item.Title, "icon": item.Icon, "sort": item.Sort,
}).Error; err != nil {
return err
}
}
}
apis := make([]apiPO, 0, len(surface.APIs))
for _, item := range surface.APIs {
apis = append(apis, apiPO{Path: item.Path, Method: item.Method, APIGroup: item.APIGroup, Description: item.Description})
}
for _, item := range apis {
var current apiPO
err := tx.Where("path = ? AND method = ?", item.Path, item.Method).First(&current).Error
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
if err := tx.Create(&item).Error; err != nil {
return err
}
case err != nil:
return err
default:
if err := tx.Model(&current).Updates(map[string]any{"api_group": item.APIGroup, "description": item.Description}).Error; err != nil {
return err
}
}
}
var authorityCount int64
if err := tx.Model(&authorityPO{}).Where("authority_id = ?", 888).Count(&authorityCount).Error; err != nil {
return err
}
if authorityCount == 0 {
return nil
}
for _, item := range menus {
var current menuPO
if err := tx.Where("name = ?", item.Name).First(&current).Error; err != nil {
return err
}
var count int64
if err := tx.Model(&authorityMenuPO{}).Where("sys_authority_authority_id = ? AND sys_base_menu_id = ?", 888, current.ID).Count(&count).Error; err != nil {
return err
}
if count == 0 {
if err := tx.Create(&authorityMenuPO{SysAuthorityAuthorityID: 888, SysBaseMenuID: current.ID}).Error; err != nil {
return err
}
}
}
for _, item := range apis {
var current apiPO
if err := tx.Where("path = ? AND method = ?", item.Path, item.Method).First(&current).Error; err != nil {
return err
}
exists, err := policyExists(tx, 888, current.Path, current.Method)
if err != nil {
return err
}
if !exists {
if err := tx.Create(&casbinRulePO{Ptype: "p", V0: "888", V1: current.Path, V2: current.Method}).Error; err != nil {
return err
}
}
}
return nil
})
}
// migrateLegacyAuthorityDepartmentColumns preserves data created by early
// Kra builds, which used shortened join-column names and a composite primary
// key. The administration connection model has neither a primary key nor a
// uniqueness constraint, so rebuild the small table before AutoMigrate.
func migrateLegacyAuthorityDepartmentColumns(db *gorm.DB) error {
clean := db.Session(&gorm.Session{NewDB: true})
const (
table = "sys_authority_departments"
backup = "sys_authority_departments_kra_legacy"
)
// MySQL and Oracle auto-commit DDL. If a prior process stopped between the
// rename and cleanup steps, restore the untouched backup first and retry the
// migration from a known state.
if clean.Migrator().HasTable(backup) {
if clean.Migrator().HasTable(table) {
if err := clean.Migrator().DropTable(table); err != nil {
return fmt.Errorf("remove incomplete authority-department table: %w", err)
}
}
if err := clean.Migrator().RenameTable(backup, table); err != nil {
return fmt.Errorf("restore authority-department backup: %w", err)
}
}
if !clean.Migrator().HasTable(table) {
return nil
}
authorityColumn := "sys_authority_authority_id"
if !tableHasColumn(clean, table, authorityColumn) {
if !tableHasColumn(clean, table, "authority_id") {
return fmt.Errorf("authority-department table has no authority column")
}
authorityColumn = "authority_id"
}
departmentColumn := "sys_department_id"
if !tableHasColumn(clean, table, departmentColumn) {
if !tableHasColumn(clean, table, "department_id") {
return fmt.Errorf("authority-department table has no department column")
}
departmentColumn = "department_id"
}
hasPrimaryKey, err := tableHasPrimaryKey(clean, table)
if err != nil {
return err
}
if authorityColumn == "sys_authority_authority_id" && departmentColumn == "sys_department_id" && !hasPrimaryKey {
return nil
}
type relation struct {
AuthorityID uint `gorm:"column:authority_id"`
DepartmentID uint `gorm:"column:department_id"`
}
var rows []relation
selectColumns := authorityColumn + " AS authority_id, " + departmentColumn + " AS department_id"
if err := clean.Table(table).Select(selectColumns).Scan(&rows).Error; err != nil {
return fmt.Errorf("read legacy authority-department rows: %w", err)
}
rebuild := func(tx *gorm.DB) error {
if err := tx.Migrator().RenameTable(table, backup); err != nil {
return fmt.Errorf("rename legacy authority-department table: %w", err)
}
if err := tx.AutoMigrate(&authorityDepartmentPO{}); err != nil {
return fmt.Errorf("create authority-department table: %w", err)
}
if len(rows) > 0 {
items := make([]authorityDepartmentPO, 0, len(rows))
for _, row := range rows {
items = append(items, authorityDepartmentPO{AuthorityID: row.AuthorityID, DepartmentID: row.DepartmentID})
}
if err := tx.Create(&items).Error; err != nil {
return fmt.Errorf("copy authority-department rows: %w", err)
}
}
var count int64
if err := tx.Model(&authorityDepartmentPO{}).Count(&count).Error; err != nil {
return fmt.Errorf("verify authority-department rows: %w", err)
}
if count != int64(len(rows)) {
return fmt.Errorf("verify authority-department rows: got %d want %d", count, len(rows))
}
if err := tx.Migrator().DropTable(backup); err != nil {
return fmt.Errorf("drop legacy authority-department table: %w", err)
}
return nil
}
switch clean.Dialector.Name() {
case "mysql", "oracle":
if err := rebuild(clean); err != nil {
restoreErr := restoreAuthorityDepartmentBackup(clean, table, backup)
if restoreErr != nil {
return fmt.Errorf("%v; restore authority-department backup: %w", err, restoreErr)
}
return err
}
return nil
default:
return clean.Transaction(rebuild)
// Migrations returns schema steps owned by the built-in system module.
// Administrators, menus, APIs, and policies are created only by SeedSystem
// during the explicit first-install flow.
func Migrations() []migration.Step {
return []migration.Step{
{
ID: "202608200002_system_schema",
Migrate: func(db *gorm.DB) error {
return db.AutoMigrate(
&userPO{}, &authorityPO{}, &menuPO{}, &userAuthorityPO{}, &authorityMenuPO{}, &menuParameterPO{},
&apiPO{}, &ignoredAPIPO{}, &authorityAPIPO{}, &casbinRulePO{}, &menuButtonPO{}, &authorityButtonPO{},
&departmentPO{}, &positionPO{}, &userDepartmentPO{}, &userPositionPO{}, &authorityDepartmentPO{},
&dictionaryPO{}, &dictionaryDetailPO{}, &parameterPO{}, &apiTokenPO{}, &jwtBlacklistPO{}, &SecurityConfigPO{},
&versionPO{}, &exportTemplatePO{}, &exportConditionPO{}, &exportJoinPO{},
&operationPO{}, &loginLogPO{}, &dataAccessLogPO{}, &errorRecordPO{},
&taskPO{}, &taskLogPO{}, &mediaPO{}, &categoryPO{}, &uploadSessionPO{}, &uploadChunkPO{},
&announcementPO{},
)
},
},
}
}
func restoreAuthorityDepartmentBackup(db *gorm.DB, table, backup string) error {
if db.Migrator().HasTable(table) {
if err := db.Migrator().DropTable(table); err != nil {
return err
}
}
if db.Migrator().HasTable(backup) {
return db.Migrator().RenameTable(backup, table)
}
return nil
}
func tableHasPrimaryKey(db *gorm.DB, table string) (bool, error) {
columns, err := db.Migrator().ColumnTypes(table)
if err != nil {
return false, err
}
for _, column := range columns {
if primary, ok := column.PrimaryKey(); ok && primary {
return true, nil
}
}
return false, nil
}
// Older builds used a status label outside the administration page's supported
// state set, so normalize existing rows during migration.
func normalizeErrorRecordStatuses(db *gorm.DB) error {
return db.Session(&gorm.Session{NewDB: true}).Model(&errorRecordPO{}).Where("status = ?", "未解决").Update("status", "未处理").Error
}
// migrateLegacyAuthorityAPIsToCasbinRules upgrades the early Kra join-table
// representation to the independent Casbin policy table. Keep the legacy
// table in place for backwards compatibility, but make casbin_rule the sole
// live policy source. Existing policy rows are not duplicated.
func migrateLegacyAuthorityAPIsToCasbinRules(db *gorm.DB) error {
clean := db.Session(&gorm.Session{NewDB: true})
if !clean.Migrator().HasTable(&authorityAPIPO{}) || !clean.Migrator().HasTable(&casbinRulePO{}) {
return nil
}
type legacyPolicy struct {
AuthorityID uint
Path string
Method string
}
baseQuery := func() *gorm.DB {
return clean.Table("sys_authority_apis sa").
Select("sa.authority_id, a.path, a.method").
Joins("JOIN sys_apis a ON a.id = sa.api_id")
}
query := baseQuery()
// Early Kra schemas stored sys_apis without soft-delete timestamps. The
// legacy-policy migration must run before assuming that column exists;
// otherwise an upgrade from those schemas cannot start on MySQL.
if tableHasColumn(clean, "sys_apis", "deleted_at") {
query = query.Where("a.deleted_at IS NULL")
}
var rows []legacyPolicy
if err := query.Find(&rows).Error; err != nil {
// A few MySQL-compatible drivers report stale/incomplete metadata from
// INFORMATION_SCHEMA during startup. If the optional soft-delete column
// was reported present but the join still rejects it, retry using only
// columns shared by every legacy schema. This migration must never block
// startup of an older database solely because deleted_at is absent.
if strings.Contains(strings.ToLower(err.Error()), "unknown column") && strings.Contains(strings.ToLower(err.Error()), "deleted_at") {
if retryErr := baseQuery().Find(&rows).Error; retryErr != nil {
return retryErr
}
} else {
return err
}
}
for _, row := range rows {
exists, err := policyExists(clean, row.AuthorityID, row.Path, row.Method)
if err != nil {
return err
}
if exists {
continue
}
if err := clean.Create(&casbinRulePO{Ptype: "p", V0: fmt.Sprint(row.AuthorityID), V1: row.Path, V2: row.Method}).Error; err != nil {
return err
}
}
return nil
}
// tableHasColumn deliberately inspects the physical table rather than the
// model schema. Legacy databases may predate soft-delete columns even though
// the current PO includes gorm.DeletedAt. Metadata inspection failures are
// treated as "unknown" so callers use the portable query shape.
func tableHasColumn(db *gorm.DB, table, column string) bool {
columns, err := db.Migrator().ColumnTypes(table)
if err != nil {
return false
}
for _, item := range columns {
if strings.EqualFold(item.Name(), column) {
return true
}
}
return false
}
// migrateLegacyIgnoreAPITable upgrades the early Kra-only composite-key
// shape (path, method) to the compatible model shape (ID/timestamps/soft
// delete). AutoMigrate can add columns but cannot replace an existing
// composite primary key portably, so rebuild the small table once while
// preserving every existing ignore rule.
func migrateLegacyIgnoreAPITable(db *gorm.DB) error {
clean := db.Session(&gorm.Session{NewDB: true})
if !clean.Migrator().HasTable(&ignoredAPIPO{}) || clean.Migrator().HasColumn(&ignoredAPIPO{}, "id") {
return nil
}
legacyTable := fmt.Sprintf("sys_ignore_apis_legacy_%d", time.Now().UnixNano())
type legacyIgnoredAPI struct {
Path string
Method string
}
return clean.Transaction(func(tx *gorm.DB) error {
if err := tx.Migrator().RenameTable(ignoredAPIPO{}.TableName(), legacyTable); err != nil {
return fmt.Errorf("rename legacy ignore API table: %w", err)
}
if err := tx.AutoMigrate(&ignoredAPIPO{}); err != nil {
return fmt.Errorf("create compatible ignore API table: %w", err)
}
var rows []legacyIgnoredAPI
if err := tx.Table(legacyTable).Find(&rows).Error; err != nil {
return fmt.Errorf("read legacy ignore API rows: %w", err)
}
if len(rows) > 0 {
items := make([]ignoredAPIPO, 0, len(rows))
for _, row := range rows {
items = append(items, ignoredAPIPO{Path: row.Path, Method: row.Method})
}
if err := tx.Create(&items).Error; err != nil {
return fmt.Errorf("copy legacy ignore API rows: %w", err)
}
}
if err := tx.Migrator().DropTable(legacyTable); err != nil {
return fmt.Errorf("drop legacy ignore API table: %w", err)
}
return nil
})
}
// reconcileRootAuthorityAPIs is a one-time upgrade path from the former Kra
// implementation where authority 888 bypassed policy storage entirely. The compatible behavior
// grants its root role through persisted Casbin policies, so when a legacy
// database has the root role but no stored API links, materialize the same
// policy set and let normal authorization read it thereafter.
func reconcileRootAuthorityAPIs(db *gorm.DB) error {
clean := db.Session(&gorm.Session{NewDB: true})
var authorityCount int64
if err := clean.Session(&gorm.Session{NewDB: true}).Model(&authorityPO{}).Where("authority_id = ?", 888).Count(&authorityCount).Error; err != nil || authorityCount == 0 {
return err
}
var policyCount int64
if err := policyScope(clean).Where("v0 = ?", "888").Count(&policyCount).Error; err != nil || policyCount != 0 {
return err
}
var ignored []ignoredAPIPO
if err := clean.Session(&gorm.Session{NewDB: true}).Find(&ignored).Error; err != nil {
return err
}
ignoreSet := make(map[string]struct{}, len(ignored))
for _, item := range ignored {
ignoreSet[item.Method+"\x00"+item.Path] = struct{}{}
}
var apis []apiPO
if err := clean.Session(&gorm.Session{NewDB: true}).Find(&apis).Error; err != nil {
return err
}
rules := make([]casbinRulePO, 0, len(apis))
for _, api := range apis {
if _, ok := ignoreSet[api.Method+"\x00"+api.Path]; ok {
continue
}
rules = append(rules, newPolicyRule(888, api.Path, api.Method))
}
if len(rules) == 0 {
return nil
}
return clean.Session(&gorm.Session{NewDB: true}).Create(&rules).Error
}
// reconcileReferenceIndexes removes constraints created by older Kra builds
// that are not part of the administration data model. Business services own
// duplicate checks and their user-facing error messages.
func reconcileReferenceIndexes(db *gorm.DB) error {
clean := db.Session(&gorm.Session{NewDB: true})
obsolete := []struct {
model any
name string
}{
{&apiPO{}, "idx_api_path_method"},
{&dictionaryPO{}, "idx_sys_dictionaries_type"},
{&parameterPO{}, "idx_sys_params_key"},
{&apiTokenPO{}, "idx_sys_api_tokens_token"},
{&exportTemplatePO{}, "idx_sys_export_templates_template_id"},
}
for _, item := range obsolete {
migrator := clean.Session(&gorm.Session{NewDB: true}).Migrator()
if migrator.HasIndex(item.model, item.name) {
if err := migrator.DropIndex(item.model, item.name); err != nil {
return fmt.Errorf("drop obsolete index %s: %w", item.name, err)
}
}
}
for _, item := range []struct {
name string
field string
}{{"idx_sys_users_uuid", "UUID"}, {"idx_sys_users_username", "Username"}} {
unique, err := indexIsUnique(clean.Session(&gorm.Session{NewDB: true}), &userPO{}, item.name)
if err != nil {
return err
}
if !unique {
continue
}
migrator := clean.Session(&gorm.Session{NewDB: true}).Migrator()
if err = migrator.DropIndex(&userPO{}, item.name); err != nil {
return fmt.Errorf("drop legacy unique index %s: %w", item.name, err)
}
if err = migrator.CreateIndex(&userPO{}, item.field); err != nil {
return fmt.Errorf("create reference index %s: %w", item.name, err)
}
}
return nil
}
func indexIsUnique(db *gorm.DB, model any, name string) (bool, error) {
indexes, err := db.Migrator().GetIndexes(model)
if err != nil {
// Some third-party GORM drivers do not implement index inspection.
// Fresh schemas are already correct; skip only the legacy repair there.
return false, nil
}
for _, index := range indexes {
if index.Name() == name {
unique, known := index.Unique()
return known && unique, nil
}
}
return false, nil
}
+11 -245
View File
@@ -1,265 +1,31 @@
package system
import (
"testing"
import "testing"
"kra/internal/data/migration"
)
func TestMigrateAllUsesVersionTableAndIsIdempotent(t *testing.T) {
func TestMigrationsCreateOnlySystemSchema(t *testing.T) {
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
if err != nil {
t.Fatal(err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sqlDB.Close() })
if err = migrateAll(db); err != nil {
t.Fatalf("first migration: %v", err)
}
if err = migrateAll(db); err != nil {
t.Fatalf("second migration: %v", err)
}
if !db.Migrator().HasTable(migration.TableName) {
t.Fatalf("missing migration table %q", migration.TableName)
}
var count int64
if err = db.Table(migration.TableName).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 2 {
t.Fatalf("migration rows = %d, want 2", count)
}
}
func TestEnsurePaymentAdminSurfaceIsIdempotent(t *testing.T) {
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
if err != nil {
t.Fatal(err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sqlDB.Close() })
if err = db.AutoMigrate(&menuPO{}, &authorityMenuPO{}, &authorityPO{}, &apiPO{}, &casbinRulePO{}); err != nil {
t.Fatal(err)
}
rootParentID := uint(0)
if err = db.Create(&authorityPO{AuthorityID: 888, AuthorityName: "root", ParentID: &rootParentID}).Error; err != nil {
t.Fatal(err)
}
if err = db.Create(&menuPO{Name: "extensions", Path: "legacy-extensions", Title: "旧扩展"}).Error; err != nil {
t.Fatal(err)
}
if err = db.Create(&menuPO{Name: "paymentOrders", Component: "legacy.vue", Title: "旧支付订单"}).Error; err != nil {
t.Fatal(err)
}
if err = db.Create(&apiPO{Path: "/payment/configs", Method: "GET", APIGroup: "legacy"}).Error; err != nil {
t.Fatal(err)
}
for i := 0; i < 2; i++ {
if err = ensurePaymentAdminSurface(db); err != nil {
t.Fatalf("ensure payment admin surface pass %d: %v", i+1, err)
for _, table := range []string{"sys_users", "sys_base_menus", "sys_apis", "sys_departments", "sys_announcements"} {
if !db.Migrator().HasTable(table) {
t.Fatalf("system migration did not create %s", table)
}
}
var parent menuPO
if err = db.Where("name = ?", "extensions").First(&parent).Error; err != nil {
var users, menus, apis int64
if err = db.Model(&userPO{}).Count(&users).Error; err != nil {
t.Fatal(err)
}
for name, component := range map[string]string{
"paymentOrders": "view/systemTools/payment/orders.vue",
"paymentConfig": "view/systemTools/payment/config.vue",
} {
var menu menuPO
if err = db.Where("name = ?", name).First(&menu).Error; err != nil {
t.Fatal(err)
}
if menu.ParentID != parent.ID || menu.Component != component {
t.Fatalf("menu %s = %#v", name, menu)
}
var linkCount int64
if err = db.Model(&authorityMenuPO{}).Where("sys_authority_authority_id = ? AND sys_base_menu_id = ?", 888, menu.ID).Count(&linkCount).Error; err != nil {
t.Fatal(err)
}
if linkCount != 1 {
t.Fatalf("menu %s root links = %d, want 1", name, linkCount)
}
}
for _, item := range []struct{ method, path string }{
{"GET", "/payment/configs"}, {"POST", "/payment/config"},
{"GET", "/payment/orders"}, {"POST", "/payment/order"},
} {
var apiCount int64
if err = db.Model(&apiPO{}).Where("path = ? AND method = ?", item.path, item.method).Count(&apiCount).Error; err != nil {
t.Fatal(err)
}
if apiCount != 1 {
t.Fatalf("API %s %s rows = %d, want 1", item.method, item.path, apiCount)
}
var policyCount int64
if err = policyScope(db).Where("v0 = ? AND v1 = ? AND v2 = ?", "888", item.path, item.method).Count(&policyCount).Error; err != nil {
t.Fatal(err)
}
if policyCount != 1 {
t.Fatalf("policy %s %s rows = %d, want 1", item.method, item.path, policyCount)
}
}
}
func TestMigrateLegacyAuthorityAPIsWithoutDeletedAt(t *testing.T) {
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
if err != nil {
if err = db.Model(&menuPO{}).Count(&menus).Error; err != nil {
t.Fatal(err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sqlDB.Close() })
for _, statement := range []string{
`CREATE TABLE sys_apis (id integer primary key, path text, method text)`,
`CREATE TABLE sys_authority_apis (authority_id integer, api_id integer)`,
`INSERT INTO sys_apis (id, path, method) VALUES (1, '/legacy', 'GET')`,
`INSERT INTO sys_authority_apis (authority_id, api_id) VALUES (888, 1)`,
} {
if err = db.Exec(statement).Error; err != nil {
t.Fatal(err)
}
}
if err = db.AutoMigrate(&casbinRulePO{}, &errorRecordPO{}); err != nil {
t.Fatal(err)
}
legacyError := errorRecordPO{Status: "未解决"}
if err = db.Create(&legacyError).Error; err != nil {
t.Fatal(err)
}
if err = migrateLegacyAuthorityAPIsToCasbinRules(db); err != nil {
t.Fatalf("legacy migration failed without sys_apis.deleted_at: %v", err)
}
if err = normalizeErrorRecordStatuses(db); err != nil {
t.Fatalf("following migration inherited the legacy table alias: %v", err)
}
var count int64
if err = db.Model(&casbinRulePO{}).
Where("ptype = ? AND v0 = ? AND v1 = ? AND v2 = ?", "p", "888", "/legacy", "GET").
Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("migrated policy count = %d, want 1", count)
}
if err = db.First(&legacyError, legacyError.ID).Error; err != nil {
t.Fatal(err)
}
if legacyError.Status != "未处理" {
t.Fatalf("normalized status = %q, want 未处理", legacyError.Status)
}
}
func TestReconcileRootAuthorityAPIsUsesIndependentQueries(t *testing.T) {
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
if err != nil {
t.Fatal(err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sqlDB.Close() })
if err = db.AutoMigrate(&authorityPO{}, &apiPO{}, &ignoredAPIPO{}, &casbinRulePO{}); err != nil {
t.Fatal(err)
}
if err = db.Create(&authorityPO{AuthorityID: 888, AuthorityName: "root"}).Error; err != nil {
t.Fatal(err)
}
if err = db.Create(&apiPO{Path: "/allowed", Method: "GET"}).Error; err != nil {
t.Fatal(err)
}
if err = db.Create(&apiPO{Path: "/ignored", Method: "POST"}).Error; err != nil {
t.Fatal(err)
}
if err = db.Create(&ignoredAPIPO{Path: "/ignored", Method: "POST"}).Error; err != nil {
t.Fatal(err)
}
// Deliberately pass a handle carrying an unrelated model and predicate.
// Migration queries must not inherit either state.
dirty := db.Model(&authorityPO{}).Where("authority_id = ?", 999)
if err = reconcileRootAuthorityAPIs(dirty); err != nil {
t.Fatalf("reconcile root policies with dirty DB state: %v", err)
}
var rules []casbinRulePO
if err = db.Where("ptype = ? AND v0 = ?", "p", "888").Find(&rules).Error; err != nil {
t.Fatal(err)
}
if len(rules) != 1 || rules[0].V1 != "/allowed" || rules[0].V2 != "GET" {
t.Fatalf("root rules = %#v, want only GET /allowed", rules)
}
}
func TestMigrateLegacyAuthorityDepartmentColumns(t *testing.T) {
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
if err != nil {
t.Fatal(err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sqlDB.Close() })
for _, statement := range []string{
`CREATE TABLE sys_authority_departments (authority_id integer, department_id integer, PRIMARY KEY (authority_id, department_id))`,
`INSERT INTO sys_authority_departments (authority_id, department_id) VALUES (888, 7)`,
} {
if err = db.Exec(statement).Error; err != nil {
t.Fatal(err)
}
}
if err = migrateLegacyAuthorityDepartmentColumns(db); err != nil {
t.Fatalf("migrate legacy authority-department columns: %v", err)
}
if err = db.AutoMigrate(&authorityDepartmentPO{}); err != nil {
t.Fatal(err)
}
var relation authorityDepartmentPO
if err = db.First(&relation).Error; err != nil {
t.Fatal(err)
}
if relation.AuthorityID != 888 || relation.DepartmentID != 7 {
t.Fatalf("migrated relation = %#v", relation)
}
for _, column := range []string{"sys_authority_authority_id", "sys_department_id"} {
if !tableHasColumn(db, "sys_authority_departments", column) {
t.Fatalf("missing compatible column %s", column)
}
}
hasPrimaryKey, err := tableHasPrimaryKey(db, "sys_authority_departments")
if err != nil {
t.Fatal(err)
}
if hasPrimaryKey {
t.Fatal("authority-department table retained the legacy primary key")
}
if err = db.Create(&authorityDepartmentPO{AuthorityID: 888, DepartmentID: 7}).Error; err != nil {
t.Fatalf("duplicate administration relation was rejected: %v", err)
}
var count int64
if err = db.Model(&authorityDepartmentPO{}).Where("sys_authority_authority_id = ? AND sys_department_id = ?", 888, 7).Count(&count).Error; err != nil {
if err = db.Model(&apiPO{}).Count(&apis).Error; err != nil {
t.Fatal(err)
}
if count != 2 {
t.Fatalf("duplicate relation count = %d, want 2", count)
if users != 0 || menus != 0 || apis != 0 {
t.Fatalf("schema migration seeded business data: users=%d menus=%d apis=%d", users, menus, apis)
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"time"
"kra/internal/biz"
"kra/pkg/pagination"
"kra/internal/data/pagination"
"gorm.io/gorm"
)
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"time"
"kra/internal/biz"
"kra/pkg/pagination"
"kra/internal/data/pagination"
"gorm.io/gorm"
"gorm.io/gorm/clause"
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"kra/internal/biz"
"kra/internal/conf"
"kra/pkg/adminauth"
"kra/internal/security/adminauth"
jwt "github.com/golang-jwt/jwt/v5"
)
+48 -1
View File
@@ -5,6 +5,7 @@ import (
"strings"
"time"
"kra/internal/adminsurface"
"kra/internal/biz"
"github.com/google/uuid"
@@ -12,7 +13,7 @@ import (
"gorm.io/gorm"
)
func SeedSystem(ctx context.Context, db *gorm.DB, input *biz.DatabaseConfig) error {
func SeedSystem(ctx context.Context, db *gorm.DB, input *biz.DatabaseConfig, surfaces ...adminsurface.Surface) error {
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
rootParentID := uint(0)
authority := authorityPO{AuthorityID: 888, AuthorityName: "超级管理员", ParentID: &rootParentID, DataScope: 1, DefaultRouter: "dashboard"}
@@ -28,6 +29,11 @@ func SeedSystem(ctx context.Context, db *gorm.DB, input *biz.DatabaseConfig) err
return err
}
}
for _, surface := range surfaces {
if err := seedAdminSurface(tx, surface); err != nil {
return err
}
}
var persisted []menuPO
if err := tx.Order("sort asc, id asc").Find(&persisted).Error; err != nil {
return err
@@ -140,6 +146,47 @@ func SeedSystem(ctx context.Context, db *gorm.DB, input *biz.DatabaseConfig) err
})
}
func seedAdminSurface(tx *gorm.DB, surface adminsurface.Surface) error {
for _, item := range surface.Menus {
parentID := uint(0)
if item.ParentName != "" {
var parent menuPO
if err := tx.Where("name = ?", item.ParentName).First(&parent).Error; err != nil {
return err
}
parentID = parent.ID
}
menu := menuPO{
MenuLevel: 1,
ParentID: parentID,
Path: item.Path,
Name: item.Name,
Component: item.Component,
Title: item.Title,
Icon: item.Icon,
Sort: item.Sort,
}
if item.ParentName == "" {
menu.MenuLevel = 0
}
if err := tx.Where("name = ?", item.Name).FirstOrCreate(&menu).Error; err != nil {
return err
}
}
for _, item := range surface.APIs {
api := apiPO{
Path: item.Path,
Method: strings.ToUpper(item.Method),
APIGroup: item.APIGroup,
Description: item.Description,
}
if err := tx.Where("path = ? AND method = ?", api.Path, api.Method).FirstOrCreate(&api).Error; err != nil {
return err
}
}
return nil
}
func defaultMenus() []menuPO {
root := func(path, name, title, icon string, sort int) menuPO {
return menuPO{Path: path, Name: name, Component: "view/routerHolder.vue", Title: title, Icon: icon, Sort: sort}
+56
View File
@@ -0,0 +1,56 @@
package system
import (
"context"
"testing"
"kra/internal/adminsurface"
"kra/internal/biz"
)
func TestSeedSystemCreatesInitialDataAndModuleSurface(t *testing.T) {
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
if err != nil {
t.Fatal(err)
}
if err = migrateAll(db); err != nil {
t.Fatal(err)
}
surface := adminsurface.Surface{
Menus: []adminsurface.Menu{{Name: "orders", Path: "orders", ParentName: "extensions", Component: "view/orders.vue", Title: "订单", Sort: 6}},
APIs: []adminsurface.API{{Path: "/orders", Method: "GET", APIGroup: "订单", Description: "订单列表"}},
}
input := &biz.DatabaseConfig{AdminPassword: "admin-password", APIs: []*biz.API{{Path: "/healthz", Method: "GET", APIGroup: "系统"}}}
if err = SeedSystem(context.Background(), db, input, surface); err != nil {
t.Fatal(err)
}
var admin userPO
if err = db.Where("username = ?", "admin").First(&admin).Error; err != nil {
t.Fatal(err)
}
var menu menuPO
if err = db.Where("name = ?", "orders").First(&menu).Error; err != nil {
t.Fatal(err)
}
var parent menuPO
if err = db.Where("name = ?", "extensions").First(&parent).Error; err != nil {
t.Fatal(err)
}
if menu.ParentID != parent.ID {
t.Fatalf("module menu parent = %d, want %d", menu.ParentID, parent.ID)
}
var links int64
if err = db.Model(&authorityMenuPO{}).Where("sys_authority_authority_id = ? AND sys_base_menu_id = ?", 888, menu.ID).Count(&links).Error; err != nil {
t.Fatal(err)
}
if links != 1 {
t.Fatalf("root menu links = %d, want 1", links)
}
var policies int64
if err = policyScope(db).Where("v0 = ? AND v1 = ? AND v2 = ?", "888", "/orders", "GET").Count(&policies).Error; err != nil {
t.Fatal(err)
}
if policies != 1 {
t.Fatalf("root policies = %d, want 1", policies)
}
}
+2 -2
View File
@@ -3,8 +3,8 @@ package system
import (
"context"
"kra/internal/biz"
"kra/pkg/gormkit"
"kra/pkg/pagination"
"kra/internal/data/gormkit"
"kra/internal/data/pagination"
"time"
"gorm.io/gorm"
+6 -5
View File
@@ -8,7 +8,6 @@ import (
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"kra/internal/conf"
"kra/internal/data/migration"
)
// Data is a small in-package test harness. Production repositories depend on
@@ -69,8 +68,10 @@ func openWithDriver(driver, dsn string) (*gorm.DB, error) {
}
func migrateAll(db *gorm.DB) error {
return migration.Run(db, []migration.Step{
{ID: "test_baseline", Migrate: LegacySchemaMigration},
{ID: "test_reconcile", Migrate: CurrentDataMigration},
})
for _, step := range Migrations() {
if err := step.Migrate(db); err != nil {
return err
}
}
return nil
}
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"time"
"kra/internal/biz"
"kra/pkg/pagination"
"kra/internal/data/pagination"
"github.com/google/uuid"
"gorm.io/gorm"
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"gorm.io/gorm"
"kra/internal/biz"
"kra/pkg/pagination"
"kra/internal/data/pagination"
)
type versionPO struct {
+22
View File
@@ -0,0 +1,22 @@
# Initialization
`internal/initialize` owns application bootstrap orchestration. It implements
`biz.InitializationRepo`, coordinates the explicit first-install workflow, and
formats the compatible runtime configuration API.
The package depends on a narrow `Backend` interface. `*data.Data` implements
that interface and remains responsible for opening, migrating, activating,
reloading, and persisting database-backed runtime infrastructure.
The first-install order is:
1. `data` opens the candidate database.
2. The single root migration runner applies infrastructure, system, and
payment schema steps.
3. `initialize` invokes `system.SeedSystem` for administrator, menu, API,
department, policy, and module administration-surface records.
4. `data` persists configuration and activates the candidate clients.
Schema migration and business seed data are deliberately separate. Normal
startup and configuration reload may run migrations, but only the explicit
first-install endpoint runs seed data.
+24
View File
@@ -0,0 +1,24 @@
package initialize
import (
"context"
"kra/internal/biz"
"kra/internal/conf"
"gorm.io/gorm"
)
// Backend is the infrastructure boundary required by application
// initialization and runtime configuration management.
type Backend interface {
IsInitialized(context.Context) (bool, error)
InitializeDatabase(context.Context, *biz.DatabaseConfig, func(context.Context, *gorm.DB) error) error
PersistConfig(context.Context) error
PersistAdminConfig(context.Context, []byte) error
PersistRuntimeConfig(context.Context, []byte, []byte) error
ReloadConfig(context.Context) error
RuntimeValues() (*conf.Data, *conf.AdminBackend)
RuntimeAdmin() *conf.AdminBackend
RefreshDatabaseSources(*conf.Data) error
}
@@ -1,4 +1,4 @@
package data
package initialize
import (
"encoding/json"
@@ -1,11 +1,11 @@
package data
package initialize
import (
"context"
"encoding/json"
"strings"
"kra/internal/conf"
"kra/internal/utils/configutil"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
@@ -27,8 +27,8 @@ type configurationEnvelope struct {
} `json:"email"`
}
func (r *initializationRepo) ConfigurationJSON() (json.RawMessage, error) {
dataConfig, adminConfig := r.data.runtime.Values()
func (r *Repo) ConfigurationJSON() (json.RawMessage, error) {
dataConfig, adminConfig := r.backend.RuntimeValues()
admin := map[string]any{"routerPrefix": ""}
email := map[string]any{}
if adminConfig != nil {
@@ -121,8 +121,8 @@ func (r *initializationRepo) ConfigurationJSON() (json.RawMessage, error) {
return json.Marshal(map[string]any{"config": config})
}
func (r *initializationRepo) SaveConfigurationJSON(ctx context.Context, raw json.RawMessage) error {
currentData, currentAdmin := r.data.runtime.Values()
func (r *Repo) SaveConfigurationJSON(ctx context.Context, raw json.RawMessage) error {
currentData, currentAdmin := r.backend.RuntimeValues()
if currentAdmin == nil {
return nil
}
@@ -137,12 +137,12 @@ func (r *initializationRepo) SaveConfigurationJSON(ctx context.Context, raw json
}
options := protojson.UnmarshalOptions{DiscardUnknown: true}
if len(value.Data) > 0 && string(value.Data) != "null" {
if err := mergeConfigurationMessage(nextData, value.Data, options); err != nil {
if err := configutil.MergeProtoJSON(nextData, value.Data, options); err != nil {
return err
}
}
if len(value.Admin) > 0 && string(value.Admin) != "null" {
if err := mergeConfigurationMessage(nextAdmin, value.Admin, options); err != nil {
if err := configutil.MergeProtoJSON(nextAdmin, value.Admin, options); err != nil {
return err
}
}
@@ -159,7 +159,7 @@ func (r *initializationRepo) SaveConfigurationJSON(ctx context.Context, raw json
nextAdmin.Email.Secret = value.Email.Secret
}
}
if err := refreshDatabaseSources(nextData); err != nil {
if err := r.backend.RefreshDatabaseSources(nextData); err != nil {
return err
}
nextAdmin.ConfigPath = currentAdmin.ConfigPath
@@ -174,8 +174,8 @@ func (r *initializationRepo) SaveConfigurationJSON(ctx context.Context, raw json
return r.PersistRuntimeConfig(ctx, dataRaw, adminRaw)
}
func (r *initializationRepo) DiskMountPoints() []string {
config := r.data.runtime.Admin()
func (r *Repo) DiskMountPoints() []string {
config := r.backend.RuntimeAdmin()
if config == nil {
return nil
}
@@ -202,120 +202,6 @@ func cloneAdminConfig(value *conf.AdminBackend) *conf.AdminBackend {
return proto.Clone(value).(*conf.AdminBackend)
}
func refreshDatabaseSources(value *conf.Data) error {
if value == nil {
return nil
}
if err := refreshDatabaseSource(value.Database); err != nil {
return err
}
for _, database := range value.DatabaseList {
if database == nil || database.Disable {
continue
}
if err := refreshDatabaseSource(database); err != nil {
return err
}
}
return nil
}
func refreshDatabaseSource(database *conf.Data_Database) error {
if database == nil {
return nil
}
hasStructuredConfig := database.Host != "" || database.Port != "" || database.User != "" ||
database.Password != "" || database.Name != "" || database.Config != "" || database.Path != ""
if !hasStructuredConfig {
// A source-only configuration is an intentional escape hatch for custom
// driver DSNs; do not reinterpret it as the structured form.
return nil
}
previousSource := database.Source
database.Source = ""
source, err := databaseDSN(database, "")
if err != nil {
database.Source = previousSource
return err
}
database.Source = source
return nil
}
// mergeConfigurationMessage applies a JSON object as a partial update while
// retaining fields that were not sent by the caller. protojson.Unmarshal is
// intentionally not used directly here because its reset semantics would
// clear unrelated configuration sections when the management page submits a
// partial object.
func mergeConfigurationMessage(target proto.Message, patch json.RawMessage, options protojson.UnmarshalOptions) error {
currentRaw, err := protojson.MarshalOptions{UseProtoNames: false}.Marshal(target)
if err != nil {
return err
}
var current map[string]any
if err = json.Unmarshal(currentRaw, &current); err != nil {
return err
}
var incoming map[string]any
if err = json.Unmarshal(patch, &incoming); err != nil {
return err
}
incoming = normalizeJSONKeys(incoming).(map[string]any)
mergeJSONObjects(current, incoming)
merged, err := json.Marshal(current)
if err != nil {
return err
}
return options.Unmarshal(merged, target)
}
func normalizeJSONKeys(value any) any {
switch item := value.(type) {
case map[string]any:
result := make(map[string]any, len(item))
for key, nested := range item {
result[snakeToLowerCamel(key)] = normalizeJSONKeys(nested)
}
return result
case []any:
result := make([]any, len(item))
for index, nested := range item {
result[index] = normalizeJSONKeys(nested)
}
return result
default:
return value
}
}
func snakeToLowerCamel(value string) string {
if !strings.Contains(value, "_") {
return value
}
parts := strings.Split(value, "_")
result := parts[0]
for _, part := range parts[1:] {
if part == "" {
continue
}
result += strings.ToUpper(part[:1]) + part[1:]
}
return result
}
func mergeJSONObjects(target, patch map[string]any) {
for key, value := range patch {
incoming, isObject := value.(map[string]any)
if isObject {
if existing, ok := target[key].(map[string]any); ok {
mergeJSONObjects(existing, incoming)
continue
}
}
target[key] = value
}
}
func durationString(value *durationpb.Duration) string {
if value == nil {
return "0s"
+43
View File
@@ -0,0 +1,43 @@
package initialize
import (
"context"
"kra/internal/biz"
datapayment "kra/internal/data/payment"
datasystem "kra/internal/data/system"
"gorm.io/gorm"
)
type Repo struct{ backend Backend }
func NewRepo(backend Backend) biz.InitializationRepo {
return &Repo{backend: backend}
}
func (r *Repo) IsInitialized(ctx context.Context) (bool, error) {
return r.backend.IsInitialized(ctx)
}
func (r *Repo) Initialize(ctx context.Context, input *biz.DatabaseConfig) error {
return r.backend.InitializeDatabase(ctx, input, func(ctx context.Context, db *gorm.DB) error {
return datasystem.SeedSystem(ctx, db, input, datapayment.AdminSurface())
})
}
func (r *Repo) PersistConfig(ctx context.Context) error {
return r.backend.PersistConfig(ctx)
}
func (r *Repo) PersistAdminConfig(ctx context.Context, value []byte) error {
return r.backend.PersistAdminConfig(ctx, value)
}
func (r *Repo) PersistRuntimeConfig(ctx context.Context, data, admin []byte) error {
return r.backend.PersistRuntimeConfig(ctx, data, admin)
}
func (r *Repo) ReloadConfig(ctx context.Context) error {
return r.backend.ReloadConfig(ctx)
}
+5
View File
@@ -0,0 +1,5 @@
package initialize
import "github.com/google/wire"
var ProviderSet = wire.NewSet(NewRepo)
+20
View File
@@ -0,0 +1,20 @@
# Integrations
`internal/integration` contains adapters that talk to systems outside the
application process. They may open sockets, create SDK clients, keep reloadable
state, or translate provider-specific protocols into `biz` interfaces.
## Packages
- `cache`: Redis-backed cache with an in-memory fallback.
- `email`: SMTP email repository.
- `payment`: payment-channel SDKs and callback/signature handling.
- `storage`: local and object-storage implementations of `biz.FileStorage`.
These packages are not `utils`: they perform I/O and own external dependency
lifecycles. Their constructors are exposed through `ProviderSet`; `cmd` binds
the `cache.RedisProvider` implementation to the shared `data.Data` container.
Stateless protocol helpers that do not own clients live in
`internal/utils/paymentutil`. They are intentionally small and dependency
light, while provider adapters remain here.
+7 -7
View File
@@ -21,7 +21,7 @@ import (
"testing"
"kra/internal/biz"
"kra/pkg/paymentkit"
"kra/internal/utils/paymentutil"
gopayAlipay "github.com/go-pay/gopay/alipay"
gopayDouyin "github.com/go-pay/gopay/douyin"
@@ -177,7 +177,7 @@ func TestWechatV2GoPayCallbackVerificationAndSandboxAmount(t *testing.T) {
t.Errorf("read request: %v", err)
return
}
requestValues, err = paymentkit.XMLValues(body)
requestValues, err = paymentutil.XMLValues(body)
if err != nil {
t.Errorf("parse request: %v", err)
return
@@ -190,7 +190,7 @@ func TestWechatV2GoPayCallbackVerificationAndSandboxAmount(t *testing.T) {
}
response := map[string]string{"return_code": "SUCCESS", "return_msg": "OK", "result_code": "SUCCESS", "prepay_id": "prepay"}
response["sign"] = wechatV2Sign(response, key, "MD5")
_, _ = w.Write(paymentkit.XMLEncode(response))
_, _ = w.Write(paymentutil.XMLEncode(response))
}))
defer server.Close()
@@ -219,7 +219,7 @@ func TestWechatV2GoPayCallbackVerificationAndSandboxAmount(t *testing.T) {
if callbackValues["sign"] != originalSign {
t.Fatal("verification mutated source values")
}
callbackBody := paymentkit.XMLEncode(callbackValues)
callbackBody := paymentutil.XMLEncode(callbackValues)
callbackResult, err := (&wechatV2Adapter{}).Callback(context.Background(), &biz.PaymentCallback{Body: callbackBody}, map[string]any{
"app_id": "wx-test", "merchant_id": "mch-test", "mch_key": key,
})
@@ -243,7 +243,7 @@ func TestWechatV2MicropayUsesGoPaySDKAndValidatesIdentities(t *testing.T) {
t.Errorf("read request: %v", err)
return
}
requestValues, err = paymentkit.XMLValues(body)
requestValues, err = paymentutil.XMLValues(body)
if err != nil {
t.Errorf("parse request: %v", err)
return
@@ -263,7 +263,7 @@ func TestWechatV2MicropayUsesGoPaySDKAndValidatesIdentities(t *testing.T) {
"transaction_id": "WX-TRANSACTION-1", "total_fee": "123", "cash_fee": "123", "fee_type": "CNY",
}
response["sign"] = wechatV2Sign(response, key, "MD5")
_, _ = w.Write(paymentkit.XMLEncode(response))
_, _ = w.Write(paymentutil.XMLEncode(response))
}))
defer server.Close()
@@ -344,7 +344,7 @@ func TestWechatV2MicropayRejectsInvalidSuccessResponse(t *testing.T) {
}
tc.mutate(response)
response["sign"] = wechatV2Sign(response, key, "MD5")
_, _ = w.Write(paymentkit.XMLEncode(response))
_, _ = w.Write(paymentutil.XMLEncode(response))
}))
defer server.Close()
+3 -3
View File
@@ -12,7 +12,7 @@ import (
"testing"
"kra/internal/biz"
"kra/pkg/paymentkit"
"kra/internal/utils/paymentutil"
"github.com/go-pay/gopay"
gopayQQ "github.com/go-pay/gopay/qq"
@@ -55,7 +55,7 @@ func TestQQCreateKeepsClientPaymentDataOutOfDurableIdentities(t *testing.T) {
http.Error(w, "invalid XML", http.StatusBadRequest)
return
}
values, err := paymentkit.XMLValues(body)
values, err := paymentutil.XMLValues(body)
if err != nil {
t.Errorf("parse request XML: %v", err)
http.Error(w, "invalid XML", http.StatusBadRequest)
@@ -81,7 +81,7 @@ func TestQQCreateKeepsClientPaymentDataOutOfDurableIdentities(t *testing.T) {
for key, value := range response {
encoded[key] = fmt.Sprint(value)
}
_, _ = w.Write(paymentkit.XMLEncode(encoded))
_, _ = w.Write(paymentutil.XMLEncode(encoded))
}))
result, err := (&qqAdapter{}).Create(context.Background(), &biz.PaymentRequest{
+13 -13
View File
@@ -8,7 +8,7 @@ import (
"strings"
"kra/internal/biz"
"kra/pkg/paymentkit"
"kra/internal/utils/paymentutil"
)
func callbackFields(callback *biz.PaymentCallback) map[string]string {
@@ -70,49 +70,49 @@ func firstAny(values map[string]any, keys ...string) string {
}
// Compatibility shims keep channel adapters focused on protocol and
// persistence concerns while stateless parsing rules live in paymentkit.
// persistence concerns while stateless parsing rules live in paymentutil.
func normalizePaymentStatus(value, fallback string) string {
return paymentkit.NormalizeStatus(value, fallback)
return paymentutil.NormalizeStatus(value, fallback)
}
func parseIntegerAmount(value string) (int64, error) {
return paymentkit.ParseIntegerAmount(value)
return paymentutil.ParseIntegerAmount(value)
}
func parseDecimalAmount(value string, scale int64) (int64, error) {
return paymentkit.ParseDecimalAmount(value, scale)
return paymentutil.ParseDecimalAmount(value, scale)
}
func formatDecimalAmount(amount, scale int64) (string, error) {
return paymentkit.FormatDecimalAmount(amount, scale)
return paymentutil.FormatDecimalAmount(amount, scale)
}
func configuredInt64(values map[string]any, key string, fallback int64) int64 {
return paymentkit.ConfiguredInt64(values, key, fallback)
return paymentutil.ConfiguredInt64(values, key, fallback)
}
func jsonObject(raw []byte) map[string]any {
return paymentkit.JSONObject(raw)
return paymentutil.JSONObject(raw)
}
func nestedString(value any, keys ...string) string {
return paymentkit.NestedString(value, keys...)
return paymentutil.NestedString(value, keys...)
}
func valueAtPath(value any, path string) any {
return paymentkit.ValueAtPath(value, path)
return paymentutil.ValueAtPath(value, path)
}
func stringAtPath(value any, path string) string {
return paymentkit.StringAtPath(value, path)
return paymentutil.StringAtPath(value, path)
}
func configuredValues(values map[string]any, key string) []string {
return paymentkit.ConfiguredValues(values, key)
return paymentutil.ConfiguredValues(values, key)
}
func containsFold(values []string, value string) bool {
return paymentkit.ContainsFold(values, value)
return paymentutil.ContainsFold(values, value)
}
// parseConfiguredAmount reads a provider amount field using an explicit unit
+7 -7
View File
@@ -15,7 +15,7 @@ import (
"time"
"kra/internal/biz"
"kra/pkg/paymentkit"
"kra/internal/utils/paymentutil"
)
type vendorProfile string
@@ -244,18 +244,18 @@ func (a *vendorPaymentAdapter) signRequest(request *http.Request, payload map[st
digest := sha256.Sum256([]byte(appID + timestamp + nonceValue + string(raw) + secret))
request.Header.Set("Authorization", "OPEN-BODY-SIG AppId="+appID+", Timestamp="+timestamp+", Nonce="+nonceValue+", Signature="+hex.EncodeToString(digest[:]))
case vendorSFT:
request.Header.Set("X-SFT-Sign", paymentkit.MD5Canonical(payload, secret))
request.Header.Set("X-SFT-Sign", paymentutil.MD5Canonical(payload, secret))
case vendorWechatGame:
request.Header.Set("X-Wechat-Game-Sign", paymentkit.HMACSHA256Hex(raw, secret, false))
request.Header.Set("X-Wechat-Game-Sign", paymentutil.HMACSHA256Hex(raw, secret, false))
if token := text(c, "access_token"); token != "" {
query := request.URL.Query()
query.Set("access_token", token)
request.URL.RawQuery = query.Encode()
}
case vendorDouyinGame:
request.Header.Set("X-TT-Pay-Sign", paymentkit.MD5Canonical(payload, secret))
request.Header.Set("X-TT-Pay-Sign", paymentutil.MD5Canonical(payload, secret))
default:
request.Header.Set("X-Payment-Sign", paymentkit.HMACSHA256Hex(raw, secret, false))
request.Header.Set("X-Payment-Sign", paymentutil.HMACSHA256Hex(raw, secret, false))
}
}
func (a *vendorPaymentAdapter) verify(fields map[string]string, raw []byte, headers map[string]string, c map[string]any) error {
@@ -298,9 +298,9 @@ func (a *vendorPaymentAdapter) verify(fields map[string]string, raw []byte, head
values[key] = value
}
}
actual = paymentkit.MD5Canonical(values, secret)
actual = paymentutil.MD5Canonical(values, secret)
default:
actual = paymentkit.HMACSHA256Hex(raw, secret, false)
actual = paymentutil.HMACSHA256Hex(raw, secret, false)
}
if !hmac.Equal([]byte(strings.ToLower(expected)), []byte(strings.ToLower(actual))) {
return errors.New("支付回调签名校验失败")
+20
View File
@@ -0,0 +1,20 @@
// Package integration contains adapters for external systems and runtime
// clients. These constructors are kept out of the persistence provider set so
// the data package does not also act as the integration composition root.
package integration
import (
"kra/internal/biz"
"kra/internal/integration/cache"
"kra/internal/integration/email"
"kra/internal/integration/storage"
"github.com/google/wire"
)
var ProviderSet = wire.NewSet(
cache.New,
email.NewEmailRepo,
storage.NewFileStorage,
wire.Bind(new(biz.FileStorage), new(*storage.Reloadable)),
)
+56
View File
@@ -0,0 +1,56 @@
package storage
import (
"context"
"crypto/md5"
"encoding/hex"
"io"
)
// composeStreams concatenates objects into destination while calculating the
// MD5 of the exact bytes written. Provider-specific storage clients stay out
// of this package; callbacks keep this helper independent from biz and SDKs.
func composeStreams(
ctx context.Context,
names []string,
open func(context.Context, string) (io.ReadCloser, error),
put func(context.Context, string, io.Reader) error,
remove func(context.Context, string) error,
destination string,
) (string, error) {
reader, writer := io.Pipe()
hash := md5.New()
errCh := make(chan error, 1)
go func() {
defer writer.Close()
for _, name := range names {
if err := ctx.Err(); err != nil {
errCh <- err
return
}
file, err := open(ctx, name)
if err != nil {
errCh <- err
return
}
_, copyErr := io.Copy(io.MultiWriter(writer, hash), file)
_ = file.Close()
if copyErr != nil {
errCh <- copyErr
return
}
}
errCh <- nil
}()
putErr := put(ctx, destination, reader)
composeErr := <-errCh
if putErr != nil {
_ = remove(ctx, destination)
return "", putErr
}
if composeErr != nil {
_ = remove(ctx, destination)
return "", composeErr
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
@@ -0,0 +1,50 @@
package storage
import (
"context"
"crypto/md5"
"encoding/hex"
"errors"
"io"
"strings"
"testing"
)
func TestComposeStreams(t *testing.T) {
objects := map[string]string{"a": "hello ", "b": "world"}
var stored string
hash, err := composeStreams(context.Background(), []string{"a", "b"}, func(_ context.Context, name string) (io.ReadCloser, error) {
value, ok := objects[name]
if !ok {
return nil, errors.New("missing object")
}
return io.NopCloser(strings.NewReader(value)), nil
}, func(_ context.Context, _ string, reader io.Reader) error {
value, err := io.ReadAll(reader)
stored = string(value)
return err
}, func(context.Context, string) error { return nil }, "out")
if err != nil || stored != "hello world" {
t.Fatalf("compose = %q, %v", stored, err)
}
want := md5.Sum([]byte(stored))
if hash != hex.EncodeToString(want[:]) {
t.Fatalf("hash = %q", hash)
}
}
func TestComposeStreamsRemovesPartialDestination(t *testing.T) {
removed := false
_, err := composeStreams(context.Background(), []string{"missing"}, func(context.Context, string) (io.ReadCloser, error) {
return nil, errors.New("open failed")
}, func(_ context.Context, _ string, reader io.Reader) error {
_, _ = io.ReadAll(reader)
return nil
}, func(context.Context, string) error {
removed = true
return nil
}, "out")
if err == nil || !removed {
t.Fatalf("compose error = %v, removed = %v", err, removed)
}
}
+1 -2
View File
@@ -12,7 +12,6 @@ import (
"kra/internal/biz"
"kra/internal/conf"
"kra/pkg/osskit"
)
type fileStorage struct {
@@ -66,7 +65,7 @@ func New(config *conf.AdminBackend) (biz.FileStorage, error) {
func composeFiles(ctx context.Context, storage biz.FileStorage, names []string, destination string) (*biz.StoredFile, string, error) {
var stored *biz.StoredFile
hash, err := osskit.ComposeStreams(ctx, names, storage.Open, func(ctx context.Context, destination string, reader io.Reader) error {
hash, err := composeStreams(ctx, names, storage.Open, func(ctx context.Context, destination string, reader io.Reader) error {
var putErr error
stored, putErr = storage.Put(ctx, destination, reader)
return putErr
+67
View File
@@ -0,0 +1,67 @@
package logging
import (
"context"
"log/slog"
)
type contextFieldsKey struct{}
// ContextFields contains request metadata attached to logs emitted with a
// request context.
type ContextFields struct {
RequestID string
TraceID string
SpanID string
ParentSpanID string
DeviceID string
ClientIP string
HTTPMethod string
HTTPPath string
}
func WithContextFields(ctx context.Context, fields *ContextFields) context.Context {
return context.WithValue(ctx, contextFieldsKey{}, fields)
}
func ContextFieldsFrom(ctx context.Context) *ContextFields {
if ctx == nil {
return nil
}
fields, _ := ctx.Value(contextFieldsKey{}).(*ContextFields)
return fields
}
type contextHandler struct{ handler slog.Handler }
func (h *contextHandler) Enabled(ctx context.Context, level slog.Level) bool {
return h.handler.Enabled(ctx, level)
}
func (h *contextHandler) Handle(ctx context.Context, record slog.Record) error {
if fields := ContextFieldsFrom(ctx); fields != nil {
record.AddAttrs(
slog.String("request_id", fields.RequestID),
slog.String("trace_id", fields.TraceID),
slog.String("device_id", fields.DeviceID),
slog.String("client_ip", fields.ClientIP),
slog.String("http_method", fields.HTTPMethod),
slog.String("http_path", fields.HTTPPath),
)
if fields.SpanID != "" {
record.AddAttrs(slog.String("span_id", fields.SpanID))
}
if fields.ParentSpanID != "" {
record.AddAttrs(slog.String("parent_span_id", fields.ParentSpanID))
}
}
return h.handler.Handle(ctx, record)
}
func (h *contextHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &contextHandler{handler: h.handler.WithAttrs(attrs)}
}
func (h *contextHandler) WithGroup(name string) slog.Handler {
return &contextHandler{handler: h.handler.WithGroup(name)}
}
+88
View File
@@ -0,0 +1,88 @@
package logging
import (
"os"
"path/filepath"
"sync"
"time"
)
// DailyWriter writes log entries to <root>/<yyyy-mm-dd>/<name>. It keeps the
// file handle open for the active day and rotates on the first write after
// midnight, matching the directory layout consumed by the log viewer.
type DailyWriter struct {
mu sync.Mutex
root string
name string
date string
file *os.File
retentionDay int
}
func NewDailyWriter(root, name string, retentionDay int) *DailyWriter {
w := &DailyWriter{root: root, name: name, retentionDay: retentionDay}
w.removeExpired()
return w
}
func (w *DailyWriter) removeExpired() {
if w.retentionDay <= 0 {
return
}
entries, err := os.ReadDir(w.root)
if err != nil {
return
}
cutoff := time.Now().AddDate(0, 0, -w.retentionDay)
for _, entry := range entries {
if !entry.IsDir() {
continue
}
date, err := time.Parse("2006-01-02", entry.Name())
if err == nil && date.Before(cutoff) {
_ = os.RemoveAll(filepath.Join(w.root, entry.Name()))
}
}
}
func (w *DailyWriter) Write(value []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
date := time.Now().Format("2006-01-02")
if w.file == nil || w.date != date {
if w.file != nil {
_ = w.file.Close()
}
filePath := filepath.Join(w.root, date, filepath.Clean(w.name))
directory := filepath.Dir(filePath)
if err := os.MkdirAll(directory, 0o755); err != nil {
return 0, err
}
file, err := os.OpenFile(filePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return 0, err
}
w.file, w.date = file, date
}
return w.file.Write(value)
}
func (w *DailyWriter) Sync() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.file == nil {
return nil
}
return w.file.Sync()
}
func (w *DailyWriter) Close() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.file == nil {
return nil
}
err := w.file.Close()
w.file = nil
return err
}
+98
View File
@@ -0,0 +1,98 @@
package logging
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"regexp"
"strconv"
"strings"
)
type stackFrame struct {
File string
Line int
Func string
}
var stackFileLinePattern = regexp.MustCompile(`\s*(.+\.go):(\d+)\s*$`)
func finalApplicationCaller(stack string) (stackFrame, bool) {
if stack == "" {
return stackFrame{}, false
}
functionName := ""
for _, raw := range strings.Split(stack, "\n") {
line := strings.TrimSpace(raw)
if line == "" {
continue
}
matches := stackFileLinePattern.FindStringSubmatch(line)
if matches == nil {
functionName = line
continue
}
lineNumber, _ := strconv.Atoi(matches[2])
if skipStackFile(matches[1]) {
functionName = ""
continue
}
return stackFrame{File: matches[1], Line: lineNumber, Func: functionName}, true
}
return stackFrame{}, false
}
func skipStackFile(filename string) bool {
normalized := strings.ReplaceAll(filename, "\\", "/")
for _, marker := range []string{
"/go/pkg/mod/",
"/go.uber.org/",
"/gorm.io/",
"/internal/logging/",
"/internal/server/middleware/",
"/internal/server/router/",
} {
if strings.Contains(normalized, marker) {
return true
}
}
return strings.Contains(normalized, "/src/") &&
(strings.Contains(normalized, "/go/go") || strings.Contains(normalized, "/go/src/"))
}
func functionSourceAt(filename string, line int) (name, source string, startLine, endLine int, err error) {
content, err := os.ReadFile(filename)
if err != nil {
return "", "", 0, 0, fmt.Errorf("read file failed: %w", err)
}
files := token.NewFileSet()
parsed, err := parser.ParseFile(files, filename, content, parser.ParseComments)
if err != nil {
return "", "", 0, 0, fmt.Errorf("parse file failed: %w", err)
}
var target *ast.FuncDecl
ast.Inspect(parsed, func(node ast.Node) bool {
declaration, ok := node.(*ast.FuncDecl)
if !ok {
return true
}
start := files.Position(declaration.Pos()).Line
end := files.Position(declaration.End()).Line
if line >= start && line <= end {
target, startLine, endLine = declaration, start, end
return false
}
return true
})
if target == nil {
return "", "", 0, 0, fmt.Errorf("no function encloses line %d in %s", line, filename)
}
start := files.Position(target.Pos()).Offset
end := files.Position(target.End()).Offset
if start < 0 || end > len(content) || start >= end {
return "", "", 0, 0, fmt.Errorf("invalid offsets for function: start=%d end=%d len=%d", start, end, len(content))
}
return target.Name.Name, string(content[start:end]), startLine, endLine, nil
}
+469
View File
@@ -0,0 +1,469 @@
package logging
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/go-kratos/kratos/contrib/otel/v3/tracing"
kratoslog "github.com/go-kratos/kratos/v3/log"
"go.uber.org/zap"
"go.uber.org/zap/exp/zapslog"
"go.uber.org/zap/zapcore"
)
type Options struct {
Level, Format, EncodeLevel, Prefix, StacktraceKey string
LogInConsole, ShowLine bool
RetentionDay int
FileOnlyModules []string
}
// ErrorEntry is the storage-neutral representation of an Error-level log.
// Keeping it in internal/logging lets the log core report failures without taking a
// dependency on the application service or persistence layers.
type ErrorEntry struct {
Form, Info, Level, RequestID, TraceID string
}
// ErrorSink receives Error-level log entries. The sink must not log failures
// through the same logger, otherwise a storage failure could recurse forever.
type ErrorSink interface {
RecordLogError(context.Context, ErrorEntry) error
}
type ErrorSinkFunc func(context.Context, ErrorEntry) error
func (f ErrorSinkFunc) RecordLogError(ctx context.Context, entry ErrorEntry) error {
return f(ctx, entry)
}
type errorSinkState struct {
mu sync.RWMutex
sink ErrorSink
}
func (s *errorSinkState) record(entry ErrorEntry) {
if s == nil {
return
}
s.mu.RLock()
sink := s.sink
s.mu.RUnlock()
if sink != nil {
_ = sink.RecordLogError(context.Background(), entry)
}
}
type handlerOperation struct {
attrs []slog.Attr
group string
}
type reloadableHandlerState struct {
mu sync.RWMutex
handler slog.Handler
cleanup func()
}
type reloadableHandler struct {
state *reloadableHandlerState
ops []handlerOperation
}
func (h *reloadableHandler) resolved() slog.Handler {
current := h.state.handler
for _, operation := range h.ops {
if operation.group != "" {
current = current.WithGroup(operation.group)
} else {
current = current.WithAttrs(operation.attrs)
}
}
return current
}
func (h *reloadableHandler) Enabled(ctx context.Context, level slog.Level) bool {
h.state.mu.RLock()
defer h.state.mu.RUnlock()
return h.resolved().Enabled(ctx, level)
}
func (h *reloadableHandler) Handle(ctx context.Context, record slog.Record) error {
h.state.mu.RLock()
defer h.state.mu.RUnlock()
return h.resolved().Handle(ctx, record)
}
func (h *reloadableHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
next := append([]handlerOperation(nil), h.ops...)
next = append(next, handlerOperation{attrs: append([]slog.Attr(nil), attrs...)})
return &reloadableHandler{state: h.state, ops: next}
}
func (h *reloadableHandler) WithGroup(name string) slog.Handler {
next := append([]handlerOperation(nil), h.ops...)
next = append(next, handlerOperation{group: name})
return &reloadableHandler{state: h.state, ops: next}
}
type ReloadableLogger struct {
state *reloadableHandlerState
filename string
errorSink *errorSinkState
}
func (l *ReloadableLogger) Reload(root string, options Options) {
handler, cleanup := newZapHandler(root, l.filename, options, l.errorSink)
l.state.mu.Lock()
previous := l.state.cleanup
l.state.handler = handler
l.state.cleanup = cleanup
l.state.mu.Unlock()
if previous != nil {
previous()
}
}
// SetErrorSink changes the database/audit target while keeping the current
// logger and its hot-reload state intact.
func (l *ReloadableLogger) SetErrorSink(sink ErrorSink) {
if l == nil || l.errorSink == nil {
return
}
l.errorSink.mu.Lock()
l.errorSink.sink = sink
l.errorSink.mu.Unlock()
}
func (l *ReloadableLogger) Close() {
l.state.mu.Lock()
cleanup := l.state.cleanup
l.state.cleanup = nil
l.state.mu.Unlock()
if cleanup != nil {
cleanup()
}
}
type moduleFilterCore struct {
zapcore.Core
fileOnly map[string]struct{}
mod string
}
func (c *moduleFilterCore) With(fields []zapcore.Field) zapcore.Core {
mod := c.mod
if value := moduleField(fields); value != "" {
mod = value
}
return &moduleFilterCore{Core: c.Core.With(fields), fileOnly: c.fileOnly, mod: mod}
}
func (c *moduleFilterCore) Check(entry zapcore.Entry, checked *zapcore.CheckedEntry) *zapcore.CheckedEntry {
if c.Enabled(entry.Level) {
return checked.AddCore(entry, c)
}
return checked
}
func (c *moduleFilterCore) Write(entry zapcore.Entry, fields []zapcore.Field) error {
mod := c.mod
if value := moduleField(fields); value != "" {
mod = value
}
if _, excluded := c.fileOnly[mod]; excluded {
return nil
}
return c.Core.Write(entry, fields)
}
func moduleField(fields []zapcore.Field) string {
for _, field := range fields {
if field.Key == "mod" {
if field.String != "" {
return field.String
}
if field.Interface != nil {
return fmt.Sprint(field.Interface)
}
}
}
return ""
}
type routedFileState struct {
mu sync.Mutex
writers map[string]*DailyWriter
}
type routedFileCore struct {
base zapcore.Core
encoder zapcore.Encoder
level zapcore.LevelEnabler
root string
retentionDay int
state *routedFileState
fields []zapcore.Field
errorSink *errorSinkState
}
func (c *routedFileCore) Enabled(level zapcore.Level) bool { return c.level.Enabled(level) }
func (c *routedFileCore) With(fields []zapcore.Field) zapcore.Core {
inherited := append([]zapcore.Field(nil), c.fields...)
inherited = append(inherited, fields...)
return &routedFileCore{base: c.base.With(fields), encoder: c.encoder, level: c.level, root: c.root, retentionDay: c.retentionDay, state: c.state, fields: inherited, errorSink: c.errorSink}
}
func (c *routedFileCore) Check(entry zapcore.Entry, checked *zapcore.CheckedEntry) *zapcore.CheckedEntry {
if c.Enabled(entry.Level) {
return checked.AddCore(entry, c)
}
return checked
}
func (c *routedFileCore) Write(entry zapcore.Entry, fields []zapcore.Field) error {
baseErr := c.base.Write(entry, fields)
allFields := append([]zapcore.Field(nil), c.fields...)
allFields = append(allFields, fields...)
if entry.Level >= zapcore.ErrorLevel && !isGORMLoggerEntry(entry.Caller.File, allFields) {
c.errorSink.record(errorEntryFromZap(entry, allFields))
}
paths := routedLogPaths(moduleField(allFields), entry.Level)
if len(paths) == 0 {
return baseErr
}
buffer, err := c.encoder.Clone().EncodeEntry(entry, allFields)
if err != nil {
if baseErr != nil {
return baseErr
}
return err
}
defer buffer.Free()
for _, name := range paths {
writer := c.writer(name)
if _, writeErr := writer.Write(buffer.Bytes()); writeErr != nil && baseErr == nil {
baseErr = writeErr
}
}
return baseErr
}
func isGORMLoggerEntry(filename string, fields []zapcore.Field) bool {
for _, field := range fields {
if field.Key == "gorm_logger" {
return true
}
}
normalized := strings.ReplaceAll(filename, "\\", "/")
return strings.HasSuffix(normalized, "/gorm_logger_writer.go") ||
strings.HasSuffix(normalized, "/internal/data/gorm_logger.go")
}
func errorEntryFromZap(entry zapcore.Entry, fields []zapcore.Field) ErrorEntry {
requestID, traceID, errorText := "", "", ""
for _, field := range fields {
switch field.Key {
case "request_id":
if requestID == "" {
requestID = zapFieldString(field)
}
case "trace_id":
if traceID == "" {
traceID = zapFieldString(field)
}
case "error", "err":
if errorText == "" {
errorText = zapFieldString(field)
}
}
}
info := entry.Message
if errorText != "" {
info += " | 错误: " + errorText
}
if entry.Caller.File != "" {
info += fmt.Sprintf(" \n 源文件:%s:%d", entry.Caller.File, entry.Caller.Line)
}
if entry.Stack != "" {
info += " \n 调用栈:" + entry.Stack
if frame, ok := finalApplicationCaller(entry.Stack); ok {
functionName, source, startLine, endLine, err := functionSourceAt(frame.File, frame.Line)
if err == nil {
info += fmt.Sprintf(" \n 最终调用方法:%s:%d (%s lines %d-%d)\n----- 产生日志的方法代码如下 -----\n%s", frame.File, frame.Line, functionName, startLine, endLine, source)
} else {
info += fmt.Sprintf(" \n 最终调用方法:%s:%d (%s) | extract_err=%v", frame.File, frame.Line, functionName, err)
}
}
}
return ErrorEntry{Form: "后端", Info: info, Level: entry.Level.String(), RequestID: requestID, TraceID: traceID}
}
func zapFieldString(field zapcore.Field) string {
if field.String != "" {
return field.String
}
if field.Interface != nil {
return fmt.Sprint(field.Interface)
}
return ""
}
func (c *routedFileCore) Sync() error {
result := c.base.Sync()
c.state.mu.Lock()
defer c.state.mu.Unlock()
for _, writer := range c.state.writers {
if err := writer.Sync(); err != nil && result == nil {
result = err
}
}
return result
}
func (c *routedFileCore) writer(name string) *DailyWriter {
c.state.mu.Lock()
defer c.state.mu.Unlock()
writer := c.state.writers[name]
if writer == nil {
writer = NewDailyWriter(c.root, name, c.retentionDay)
c.state.writers[name] = writer
}
return writer
}
func (c *routedFileCore) Close() {
c.state.mu.Lock()
defer c.state.mu.Unlock()
for name, writer := range c.state.writers {
_ = writer.Close()
delete(c.state.writers, name)
}
}
func routedLogPaths(module string, level zapcore.Level) []string {
paths := make([]string, 0, 2)
if module = safeModuleName(module); module != "" {
switch module {
case "http":
paths = append(paths, filepath.Join("http", "access.log"))
case "timedTask":
paths = append(paths, filepath.Join("timedTask", "task.log"))
case "error":
paths = append(paths, filepath.Join("error", "error.log"))
default:
paths = append(paths, filepath.Join(module, "application.log"))
}
}
if level >= zapcore.ErrorLevel {
errorPath := filepath.Join("error", "error.log")
if len(paths) == 0 || paths[len(paths)-1] != errorPath {
paths = append(paths, errorPath)
}
}
return paths
}
func safeModuleName(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
return strings.Map(func(r rune) rune {
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' {
return r
}
return -1
}, value)
}
// NewZapLogger adapts a Zap core to the slog logger 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)
encoder := zap.NewProductionEncoderConfig()
encoder.EncodeTime = zapcore.RFC3339NanoTimeEncoder
if options.StacktraceKey != "" {
encoder.StacktraceKey = options.StacktraceKey
}
switch options.EncodeLevel {
case "CapitalLevelEncoder":
encoder.EncodeLevel = zapcore.CapitalLevelEncoder
case "CapitalColorLevelEncoder":
encoder.EncodeLevel = zapcore.CapitalColorLevelEncoder
case "LowercaseColorLevelEncoder":
encoder.EncodeLevel = zapcore.LowercaseColorLevelEncoder
default:
encoder.EncodeLevel = zapcore.LowercaseLevelEncoder
}
var outputEncoder zapcore.Encoder = zapcore.NewJSONEncoder(encoder)
if options.Format != "" && options.Format != "json" {
if options.Prefix != "" {
encoder.EncodeTime = func(value time.Time, output zapcore.PrimitiveArrayEncoder) {
output.AppendString(options.Prefix + value.Format("2006-01-02 15:04:05.000"))
}
}
outputEncoder = zapcore.NewConsoleEncoder(encoder)
}
level := zap.DebugLevel
// zapcore.Level.Set reports an error but does not make the intended
// configuration handling obvious here. Parse the configured value directly
// so debug/warn/error are actually applied after startup and hot reload.
if value := strings.TrimSpace(strings.ToLower(options.Level)); value != "" {
if err := level.UnmarshalText([]byte(value)); err != nil {
level = zap.DebugLevel
}
}
levelEnabler := zap.NewAtomicLevelAt(level)
fileCore := zapcore.NewCore(
outputEncoder.Clone(),
zapcore.AddSync(file),
levelEnabler,
)
core := zapcore.Core(fileCore)
if options.LogInConsole {
fileOnly := make(map[string]struct{}, len(options.FileOnlyModules))
for _, module := range options.FileOnlyModules {
fileOnly[module] = struct{}{}
}
consoleCore := zapcore.NewCore(outputEncoder.Clone(), zapcore.AddSync(os.Stdout), levelEnabler)
core = zapcore.NewTee(fileCore, &moduleFilterCore{Core: consoleCore, fileOnly: fileOnly})
}
routed := &routedFileCore{base: core, encoder: outputEncoder.Clone(), level: levelEnabler, root: root, retentionDay: options.RetentionDay, state: &routedFileState{writers: map[string]*DailyWriter{}}, errorSink: errorSink}
zapLogger := zap.New(routed)
handlerOptions := []zapslog.HandlerOption{zapslog.AddStacktraceAt(slog.LevelError)}
if options.ShowLine {
handlerOptions = append(handlerOptions, zapslog.WithCaller(true))
}
handler := zapslog.NewHandler(zapLogger.Core(), handlerOptions...)
cleanup := func() {
_ = zapLogger.Sync()
routed.Close()
_ = file.Close()
}
return handler, cleanup
}
// NewReloadableZapLogger keeps the slog/Kratos adapter stable while replacing
// the underlying Zap core when the runtime configuration changes.
func NewReloadableZapLogger(root, filename string, options Options, attrs ...any) (*slog.Logger, *ReloadableLogger) {
errorSink := &errorSinkState{}
baseHandler, cleanup := newZapHandler(root, filename, options, errorSink)
state := &reloadableHandlerState{handler: baseHandler, cleanup: cleanup}
control := &ReloadableLogger{state: state, filename: filename, errorSink: errorSink}
handler := &contextHandler{handler: &reloadableHandler{state: state}}
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
}
+119
View File
@@ -0,0 +1,119 @@
package logging
import (
"context"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
func TestZapHandlerHonorsConfiguredLevel(t *testing.T) {
root := t.TempDir()
handler, cleanup := newZapHandler(root, "application.log", Options{Level: "error", Format: "json"}, nil)
defer cleanup()
if handler.Enabled(context.Background(), slog.LevelInfo) {
t.Fatal("info should be disabled when level is error")
}
if !handler.Enabled(context.Background(), slog.LevelError) {
t.Fatal("error should be enabled when level is error")
}
}
func TestZapHandlerUsesDebugFallbackForInvalidLevel(t *testing.T) {
root := t.TempDir()
handler, cleanup := newZapHandler(root, "application.log", Options{Level: "not-a-level", Format: "json"}, nil)
defer cleanup()
if !handler.Enabled(context.Background(), slog.LevelDebug) {
t.Fatal("invalid levels should use the reference debug fallback")
}
}
func TestZapHandlerRoutesHTTPAndErrorLogs(t *testing.T) {
root := t.TempDir()
handler, cleanup := newZapHandler(root, "application.log", Options{Level: "info", Format: "json"}, nil)
logger := slog.New(handler)
logger.Info("request", "mod", "http", "request_id", "req-1")
logger.Error("failed", "mod", "users")
cleanup()
dateEntries, err := os.ReadDir(root)
if err != nil || len(dateEntries) != 1 {
t.Fatalf("expected one daily log directory, entries=%v err=%v", len(dateEntries), err)
}
date := dateEntries[0].Name()
paths := []string{
filepath.Join(root, date, "application.log"),
filepath.Join(root, date, "http", "access.log"),
filepath.Join(root, date, "users", "application.log"),
filepath.Join(root, date, "error", "error.log"),
}
for _, path := range paths {
if info, err := os.Stat(path); err != nil || info.Size() == 0 {
t.Fatalf("expected non-empty routed log %s: info=%v err=%v", path, info, err)
}
}
}
func TestZapHandlerRecordsEveryErrorThroughSink(t *testing.T) {
root := t.TempDir()
var entries []ErrorEntry
logger, control := NewReloadableZapLogger(root, "application.log", Options{Level: "info", Format: "json"})
defer control.Close()
control.SetErrorSink(ErrorSinkFunc(func(_ context.Context, entry ErrorEntry) error {
entries = append(entries, entry)
return nil
}))
logger.Error("task execution failed", "mod", "timedTask", "request_id", "request-1", "trace_id", "trace-1", "error", os.ErrPermission)
control.Reload(t.TempDir(), Options{Level: "info", Format: "json"})
logger.Error("reloaded logger failure", "mod", "system")
if len(entries) != 2 {
t.Fatalf("expected two error entries across reload, got %d", len(entries))
}
entry := entries[0]
if entry.Form != "后端" || entry.Level != "error" || entry.RequestID != "request-1" || entry.TraceID != "trace-1" || !strings.Contains(entry.Info, "task execution failed") || !strings.Contains(entry.Info, os.ErrPermission.Error()) {
t.Fatalf("unexpected error entry: %+v", entry)
}
}
func TestErrorEntryIncludesFinalApplicationSource(t *testing.T) {
filename := filepath.Join(t.TempDir(), "worker.go")
content := "package sample\n\nfunc execute() {\n\tprintln(\"failed\")\n}\n"
if err := os.WriteFile(filename, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
entry := errorEntryFromZap(zapcore.Entry{Message: "task failed", Level: zapcore.ErrorLevel, Stack: "kra/internal/worker.execute\n" + filename + ":4"}, nil)
if !strings.Contains(entry.Info, "最终调用方法:"+filename+":4 (execute lines 3-5)") || !strings.Contains(entry.Info, "func execute()") {
t.Fatalf("expected final caller source in error entry: %s", entry.Info)
}
}
func TestErrorSinkSkipsGORMBridge(t *testing.T) {
root := t.TempDir()
var entries []ErrorEntry
state := &errorSinkState{sink: ErrorSinkFunc(func(_ context.Context, entry ErrorEntry) error {
entries = append(entries, entry)
return nil
})}
base := zapcore.NewNopCore()
core := &routedFileCore{base: base, encoder: zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()), level: zapcore.ErrorLevel, root: root, state: &routedFileState{writers: map[string]*DailyWriter{}}, errorSink: state}
defer core.Close()
for _, filename := range []string{"/tmp/gorm_logger_writer.go", "/workspace/internal/data/gorm_logger.go"} {
if err := core.Write(zapcore.Entry{Level: zapcore.ErrorLevel, Message: "database failed", Caller: zapcore.EntryCaller{Defined: true, File: filename, Line: 10}}, nil); err != nil {
t.Fatal(err)
}
}
if err := core.Write(zapcore.Entry{Level: zapcore.ErrorLevel, Message: "database failed"}, []zapcore.Field{zap.Bool("gorm_logger", true)}); err != nil {
t.Fatal(err)
}
if len(entries) != 0 {
t.Fatalf("gorm bridge error must not recurse into sys_error: %+v", entries)
}
}
+6
View File
@@ -0,0 +1,6 @@
# Security
Security mechanisms used by the application live here. `adminauth` contains
the admin JWT claims, signing, parsing, and normalized token errors. It has no
HTTP or persistence dependency, but it is still application security code and
therefore does not belong in a generic utility package.
+77
View File
@@ -0,0 +1,77 @@
package adminauth
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
var (
ErrTokenExpired = errors.New("token expired")
ErrTokenMalformed = errors.New("token malformed")
ErrTokenSignatureInvalid = errors.New("token signature invalid")
ErrTokenNotValidYet = errors.New("token not valid yet")
ErrTokenInvalid = errors.New("token invalid")
)
type Claims struct {
UUID string
ID uint
Username string
NickName string
AuthorityID uint `json:"AuthorityId"`
BufferTime int64
UserType string
MustChangePwd bool `json:"mustChangePwd"`
PasswordVersion int64 `json:"passwordVersion,omitempty"`
jwt.RegisteredClaims
}
func Generate(secret, issuer string, expires, buffer time.Duration, userID, authorityID uint, uuid, username, nickname string, mustChange bool, passwordVersion int64) (string, *Claims, error) {
if secret == "" {
return "", nil, errors.New("empty JWT signing key")
}
now := time.Now()
claims := &Claims{UUID: uuid, ID: userID, Username: username, NickName: nickname, AuthorityID: authorityID, BufferTime: int64(buffer / time.Second), UserType: "admin", MustChangePwd: mustChange, PasswordVersion: passwordVersion, RegisteredClaims: jwt.RegisteredClaims{Audience: jwt.ClaimStrings{"KRA"}, Issuer: issuer, IssuedAt: jwt.NewNumericDate(now), NotBefore: jwt.NewNumericDate(now.Add(-1000)), ExpiresAt: jwt.NewNumericDate(now.Add(expires))}}
token, err := Sign(secret, claims)
return token, claims, err
}
func Sign(secret string, claims *Claims) (string, error) {
if secret == "" {
return "", errors.New("empty JWT signing key")
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret))
}
func Parse(tokenString, secret string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) {
if token.Method != jwt.SigningMethodHS256 {
return nil, errors.New("unexpected signing method")
}
return []byte(secret), nil
})
if err != nil {
switch {
case errors.Is(err, jwt.ErrTokenExpired):
return nil, ErrTokenExpired
case errors.Is(err, jwt.ErrTokenMalformed):
return nil, ErrTokenMalformed
case errors.Is(err, jwt.ErrTokenSignatureInvalid):
return nil, ErrTokenSignatureInvalid
case errors.Is(err, jwt.ErrTokenNotValidYet):
return nil, ErrTokenNotValidYet
default:
return nil, ErrTokenInvalid
}
}
if !token.Valid {
return nil, ErrTokenInvalid
}
claims, ok := token.Claims.(*Claims)
if !ok {
return nil, errors.New("token claims are invalid")
}
return claims, nil
}
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"encoding/hex"
"strings"
"kra/pkg/logging"
"kra/internal/logging"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
+11
View File
@@ -0,0 +1,11 @@
# Utilities
Utilities are pure or near-pure helpers with no database, network client,
runtime watcher, or business repository state.
- `configutil`: partial JSON/Proto merge helpers used by configuration APIs.
- `paymentutil`: payment amount, status, XML/JSON, and signing helpers shared
by payment adapters.
Do not place Redis, SMTP, object storage, payment SDK clients, or GORM repos in
this directory. Those belong to `internal/integration` or `internal/data`.
+78
View File
@@ -0,0 +1,78 @@
package configutil
import (
"encoding/json"
"strings"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
)
// MergeProtoJSON applies a JSON object as a partial protobuf update while
// retaining fields omitted by the caller.
func MergeProtoJSON(target proto.Message, patch json.RawMessage, options protojson.UnmarshalOptions) error {
currentRaw, err := protojson.MarshalOptions{UseProtoNames: false}.Marshal(target)
if err != nil {
return err
}
var current map[string]any
if err = json.Unmarshal(currentRaw, &current); err != nil {
return err
}
var incoming map[string]any
if err = json.Unmarshal(patch, &incoming); err != nil {
return err
}
incoming = normalizeJSONKeys(incoming).(map[string]any)
mergeJSONObjects(current, incoming)
merged, err := json.Marshal(current)
if err != nil {
return err
}
return options.Unmarshal(merged, target)
}
func normalizeJSONKeys(value any) any {
switch item := value.(type) {
case map[string]any:
result := make(map[string]any, len(item))
for key, nested := range item {
result[snakeToLowerCamel(key)] = normalizeJSONKeys(nested)
}
return result
case []any:
result := make([]any, len(item))
for index, nested := range item {
result[index] = normalizeJSONKeys(nested)
}
return result
default:
return value
}
}
func snakeToLowerCamel(value string) string {
if !strings.Contains(value, "_") {
return value
}
parts := strings.Split(value, "_")
result := parts[0]
for _, part := range parts[1:] {
if part != "" {
result += strings.ToUpper(part[:1]) + part[1:]
}
}
return result
}
func mergeJSONObjects(target, patch map[string]any) {
for key, value := range patch {
if incoming, ok := value.(map[string]any); ok {
if existing, exists := target[key].(map[string]any); exists {
mergeJSONObjects(existing, incoming)
continue
}
}
target[key] = value
}
}
+28
View File
@@ -0,0 +1,28 @@
package configutil
import (
"encoding/json"
"testing"
"kra/internal/conf"
"google.golang.org/protobuf/encoding/protojson"
)
func TestMergeProtoJSONPreservesOmittedFieldsAndNormalizesKeys(t *testing.T) {
target := &conf.AdminBackend{
RouterPrefix: "/api",
System: &conf.AdminBackend_System{UseRedis: true, IplimitCount: 3},
}
patch := json.RawMessage(`{"router_prefix":"/admin","system":{"iplimit_count":9}}`)
if err := MergeProtoJSON(target, patch, protojson.UnmarshalOptions{DiscardUnknown: true}); err != nil {
t.Fatal(err)
}
if target.RouterPrefix != "/admin" || target.System.IplimitCount != 9 {
t.Fatalf("patch was not applied: %#v", target)
}
if !target.System.UseRedis {
t.Fatal("an omitted nested field was cleared")
}
}
+5
View File
@@ -0,0 +1,5 @@
# Payment Utilities
Stateless payment protocol helpers live here: amount conversion, status
normalization, JSON/XML extraction, and request signing. Provider adapters and
their SDK clients remain under `internal/integration/payment`.
+97
View File
@@ -0,0 +1,97 @@
package paymentutil
import (
"errors"
"fmt"
"strconv"
"strings"
)
// ParseIntegerAmount parses an integer amount expressed in the smallest
// currency unit. Floating point is intentionally not involved.
func ParseIntegerAmount(value string) (int64, error) {
value = strings.TrimSpace(value)
if value == "" {
return 0, errors.New("支付金额为空")
}
return strconv.ParseInt(value, 10, 64)
}
// ParseDecimalAmount converts a major-unit decimal string to an integer amount
// using a power-of-ten scale, without using floating point.
func ParseDecimalAmount(value string, scale int64) (int64, error) {
value = strings.TrimSpace(value)
if value == "" || scale <= 0 {
return 0, errors.New("支付金额或换算比例无效")
}
if strings.HasPrefix(value, "-") {
return 0, errors.New("支付金额不能为负数")
}
parts := strings.Split(value, ".")
if len(parts) > 2 || parts[0] == "" {
return 0, fmt.Errorf("支付金额格式错误: %s", value)
}
digits, err := scaleDigits(scale)
if err != nil {
return 0, err
}
whole, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil {
return 0, err
}
fraction := ""
if len(parts) == 2 {
fraction = parts[1]
}
if len(fraction) > digits {
return 0, fmt.Errorf("支付金额精度超过 %d 位", digits)
}
fraction += strings.Repeat("0", digits-len(fraction))
fractionValue := int64(0)
if fraction != "" {
fractionValue, err = strconv.ParseInt(fraction, 10, 64)
if err != nil {
return 0, err
}
}
if whole > (int64(^uint64(0)>>1)-fractionValue)/scale {
return 0, errors.New("支付金额超出范围")
}
return whole*scale + fractionValue, nil
}
func FormatDecimalAmount(amount, scale int64) (string, error) {
if amount <= 0 || scale <= 0 {
return "", errors.New("支付金额或换算比例无效")
}
digits, err := scaleDigits(scale)
if err != nil {
return "", err
}
if digits == 0 {
return strconv.FormatInt(amount, 10), nil
}
return fmt.Sprintf("%d.%0*d", amount/scale, digits, amount%scale), nil
}
func scaleDigits(scale int64) (int, error) {
digits := 0
for n := scale; n > 1 && n%10 == 0; n /= 10 {
digits++
}
if scale <= 0 || scale != pow10(digits) {
return 0, errors.New("支付金额换算比例必须是 10 的幂")
}
return digits, nil
}
func pow10(digits int) int64 {
value := int64(1)
for i := 0; i < digits; i++ {
if value > (int64(^uint64(0)>>1))/10 {
return 0
}
value *= 10
}
return value
}
+25
View File
@@ -0,0 +1,25 @@
package paymentutil
import "testing"
func TestDecimalAmountUsesIntegerMath(t *testing.T) {
got, err := ParseDecimalAmount("12.34", 100)
if err != nil || got != 1234 {
t.Fatalf("ParseDecimalAmount = %d, %v", got, err)
}
formatted, err := FormatDecimalAmount(got, 100)
if err != nil || formatted != "12.34" {
t.Fatalf("FormatDecimalAmount = %q, %v", formatted, err)
}
}
func TestDecimalAmountRejectsUnsafeInput(t *testing.T) {
for _, value := range []string{"-1.00", "1.001", "1.2.3"} {
if _, err := ParseDecimalAmount(value, 100); err == nil {
t.Fatalf("ParseDecimalAmount(%q) accepted unsafe input", value)
}
}
if _, err := ParseDecimalAmount("1", 3); err == nil {
t.Fatal("non power-of-ten scale accepted")
}
}
+66
View File
@@ -0,0 +1,66 @@
package paymentutil
import (
"encoding/json"
"fmt"
"strings"
)
func JSONObject(raw []byte) map[string]any {
var value any
if json.Unmarshal(raw, &value) != nil {
return nil
}
object, _ := value.(map[string]any)
return object
}
func NestedString(value any, keys ...string) string {
found := nestedValue(value, keys...)
if found == nil {
return ""
}
return strings.TrimSpace(fmt.Sprint(found))
}
func StringAtPath(value any, path string) string {
found := ValueAtPath(value, path)
if found == nil {
return ""
}
return strings.TrimSpace(fmt.Sprint(found))
}
func ValueAtPath(value any, path string) any {
path = strings.TrimSpace(path)
if path == "" {
return nil
}
current := value
for _, part := range strings.Split(path, ".") {
object, ok := current.(map[string]any)
if !ok {
return nil
}
current = object[part]
}
return current
}
func nestedValue(value any, keys ...string) any {
object, ok := value.(map[string]any)
if !ok {
return nil
}
for _, key := range keys {
if found, exists := object[key]; exists && found != nil && fmt.Sprint(found) != "" {
return found
}
}
for _, nestedKey := range []string{"data", "result", "order", "transaction", "response"} {
if found := nestedValue(object[nestedKey], keys...); found != nil {
return found
}
}
return nil
}
+16
View File
@@ -0,0 +1,16 @@
package paymentutil
import "testing"
func TestNestedAndPathValues(t *testing.T) {
object := JSONObject([]byte(`{"data":{"status":"SUCCESS","amount":100}}`))
if got := NestedString(object, "status"); got != "SUCCESS" {
t.Fatalf("NestedString = %q", got)
}
if got := StringAtPath(object, "data.amount"); got != "100" {
t.Fatalf("StringAtPath = %q", got)
}
if got := StringAtPath(object, "data.missing"); got != "" {
t.Fatalf("missing path = %q", got)
}
}
+58
View File
@@ -0,0 +1,58 @@
package paymentutil
import (
"crypto/hmac"
"crypto/md5"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/url"
"sort"
"strings"
)
func WechatV2Sign(values map[string]string, key, signType string) string {
keys := make([]string, 0, len(values))
for k, v := range values {
if k != "sign" && v != "" {
keys = append(keys, k)
}
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, k+"="+values[k])
}
raw := strings.Join(parts, "&") + "&key=" + key
if strings.EqualFold(signType, "HMAC-SHA256") {
return HMACSHA256Hex([]byte(raw), key, true)
}
sum := md5.Sum([]byte(raw))
return strings.ToUpper(hex.EncodeToString(sum[:]))
}
func MD5Canonical(values map[string]any, secret string) string {
keys := make([]string, 0, len(values))
for key, value := range values {
if key != "sign" && value != nil && fmt.Sprint(value) != "" {
keys = append(keys, key)
}
}
sort.Strings(keys)
query := url.Values{}
for _, key := range keys {
query.Set(key, fmt.Sprint(values[key]))
}
sum := md5.Sum([]byte(query.Encode() + "&key=" + secret))
return strings.ToUpper(hex.EncodeToString(sum[:]))
}
func HMACSHA256Hex(raw []byte, secret string, upper bool) string {
h := hmac.New(sha256.New, []byte(secret))
_, _ = h.Write(raw)
result := hex.EncodeToString(h.Sum(nil))
if upper {
return strings.ToUpper(result)
}
return result
}
@@ -0,0 +1,12 @@
package paymentutil
import "testing"
func TestWechatV2SignIsStableAndExcludesSign(t *testing.T) {
values := map[string]string{"b": "2", "a": "1", "sign": "old"}
first := WechatV2Sign(values, "secret", "MD5")
values["sign"] = "different"
if second := WechatV2Sign(values, "secret", "MD5"); first != second {
t.Fatalf("signature changed when sign field changed: %q != %q", first, second)
}
}
+63
View File
@@ -0,0 +1,63 @@
package paymentutil
import (
"fmt"
"strings"
)
func NormalizeStatus(value, fallback string) string {
switch strings.ToUpper(strings.TrimSpace(value)) {
case "SUCCESS", "PAID", "TRADE_SUCCESS", "TRADE_FINISHED", "COMPLETED", "PAY_SUCCESS":
return "success"
case "WAIT_BUYER_PAY", "USERPAYING", "NOTPAY", "PROCESSING", "PENDING", "CREATED", "ACCEPT", "PAYING":
return "pending"
case "CLOSED", "TRADE_CLOSED", "CANCELLED", "CANCELED", "REVOKED", "REFUND", "REFUNDED", "FAILED", "FAIL", "PAYERROR":
return "failed"
default:
return fallback
}
}
func ConfiguredInt64(values map[string]any, key string, fallback int64) int64 {
value, ok := values[key]
if !ok {
return fallback
}
parsed, err := ParseIntegerAmount(strings.TrimSpace(toString(value)))
if err != nil || parsed <= 0 {
return fallback
}
return parsed
}
func ConfiguredValues(values map[string]any, key string) []string {
result := []string{}
for _, item := range strings.Split(toString(values[key]), ",") {
item = strings.ToUpper(strings.TrimSpace(item))
if item != "" {
result = append(result, item)
}
}
return result
}
func ContainsFold(values []string, value string) bool {
value = strings.ToUpper(strings.TrimSpace(value))
for _, item := range values {
if item == value {
return true
}
}
return false
}
func toString(value any) string {
if value == nil {
return ""
}
return strings.TrimSpace(strings.ReplaceAll(strings.TrimSpace(stringify(value)), "<nil>", ""))
}
func stringify(value any) string {
return fmt.Sprint(value)
}
+60
View File
@@ -0,0 +1,60 @@
package paymentutil
import (
"bytes"
"encoding/xml"
"io"
"sort"
"strings"
)
type xmlValue struct {
XMLName xml.Name
Value string `xml:",chardata"`
}
func XMLValues(raw []byte) (map[string]string, error) {
decoder := xml.NewDecoder(bytes.NewReader(raw))
result := map[string]string{}
for {
token, err := decoder.Token()
if err == io.EOF {
return result, nil
}
if err != nil {
return nil, err
}
start, ok := token.(xml.StartElement)
if !ok || start.Name.Local == "xml" {
continue
}
var value xmlValue
if err = decoder.DecodeElement(&value, &start); err != nil {
return nil, err
}
if value.Value != "" {
result[start.Name.Local] = value.Value
}
}
}
func XMLEncode(values map[string]string) []byte {
var b strings.Builder
b.WriteString("<xml>")
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
b.WriteString("<")
b.WriteString(key)
b.WriteString("><![CDATA[")
b.WriteString(values[key])
b.WriteString("]]></")
b.WriteString(key)
b.WriteString(">")
}
b.WriteString("</xml>")
return []byte(b.String())
}