优化结构

This commit is contained in:
Yvan 2026-08-20 20:39:09 +08:00
parent b88c220e20
commit 01b5c2f78a
73 changed files with 3171 additions and 794 deletions

2520
.gitignore vendored

File diff suppressed because it is too large Load Diff

View File

@ -11,6 +11,11 @@ import (
"kra/internal/biz"
"kra/internal/conf"
"kra/internal/data"
"kra/internal/data/payment"
"kra/internal/data/system"
"kra/internal/integration/cache"
"kra/internal/integration/email"
"kra/internal/integration/storage"
"kra/internal/server"
"kra/internal/server/handler"
"kra/internal/service"
@ -27,58 +32,57 @@ import (
// wireApp init kratos application.
func wireApp(confServer *conf.Server, runtime *conf.Runtime, logger *slog.Logger, reloadableLogger *logging.ReloadableLogger, string2 string) (*kratos.App, func(), error) {
dataData, cleanup, err := data.NewData(runtime, logger)
reloadable, err := storage.NewFileStorage(runtime)
if err != nil {
return nil, nil, err
}
authorityAccessRepo := data.NewAuthorityAccessRepo(dataData)
apiRepo := data.NewAPIRepo(dataData)
dataData, cleanup, err := data.NewData(runtime, logger, reloadable)
if err != nil {
return nil, nil, err
}
authorityAccessRepo := system.NewAuthorityAccessRepo(dataData)
apiRepo := system.NewAPIRepo(dataData)
accessControlUsecase := biz.NewAccessControlUsecase(authorityAccessRepo, apiRepo)
accessControlService := service.NewAccessControlService(accessControlUsecase)
authorityUsecase := biz.NewAuthorityUsecase(authorityAccessRepo)
authorityService := service.NewAuthorityService(authorityUsecase)
authority := handler.NewAuthority(authorityService)
menuRepo := data.NewMenuRepo(dataData)
menuRepo := system.NewMenuRepo(dataData)
menuUsecase := biz.NewMenuUsecase(menuRepo)
menuService := service.NewMenuService(menuUsecase)
menu := handler.NewMenu(menuService)
apiUsecase := biz.NewAPIUsecase(apiRepo)
runtimeSettings := data.NewRuntimeSettings(runtime)
runtimeSettings := system.NewRuntimeSettings(runtime)
apiService := service.NewAPIService(apiUsecase, runtimeSettings)
api := handler.NewAPI(apiService)
permissionRepo := data.NewPermissionRepo(dataData)
permissionRepo := system.NewPermissionRepo(dataData)
permissionUsecase := biz.NewPermissionUsecase(permissionRepo)
permissionService := service.NewPermissionService(permissionUsecase)
permission := handler.NewPermission(permissionService)
departmentRepo := data.NewDepartmentRepo(dataData)
departmentRepo := system.NewDepartmentRepo(dataData)
departmentUsecase := biz.NewDepartmentUsecase(departmentRepo)
departmentService := service.NewDepartmentService(departmentUsecase)
positionRepo := data.NewPositionRepo(dataData)
positionRepo := system.NewPositionRepo(dataData)
positionUsecase := biz.NewPositionUsecase(positionRepo)
positionService := service.NewPositionService(positionUsecase)
organization := handler.NewOrganization(departmentService, positionService)
announcementRepo := data.NewAnnouncementRepo(dataData)
announcementRepo := system.NewAnnouncementRepo(dataData)
announcementUsecase := biz.NewAnnouncementUsecase(announcementRepo)
announcementService := service.NewAnnouncementService(announcementUsecase)
announcement := handler.NewAnnouncement(announcementService)
emailRepo := data.NewEmailRepo(runtime)
emailRepo := email.NewEmailRepo(runtime)
emailUsecase := biz.NewEmailUsecase(emailRepo)
emailService := service.NewEmailService(emailUsecase)
email := handler.NewEmail(emailService)
paymentRepo := data.NewPaymentRepo(dataData)
paymentOrderRepo := data.NewPaymentOrderRepo(dataData)
handlerEmail := handler.NewEmail(emailService)
paymentRepo := payment.NewPaymentRepo(dataData)
paymentOrderRepo := payment.NewPaymentOrderRepo(dataData)
paymentUsecase := biz.NewPaymentUsecase(paymentRepo, paymentOrderRepo, logger)
paymentService := service.NewPaymentService(paymentUsecase)
payment := handler.NewPayment(paymentService)
taskRepo := data.NewTaskRepo(dataData)
handlerPayment := handler.NewPayment(paymentService)
taskRepo := system.NewTaskRepo(dataData)
taskUsecase := biz.NewTaskUsecase(taskRepo)
mediaRepo := data.NewMediaRepo(dataData)
fileStorage, err := data.NewFileStorage(dataData)
if err != nil {
cleanup()
return nil, nil, err
}
mediaUsecase := biz.NewMediaUsecase(mediaRepo, fileStorage, runtimeSettings)
mediaRepo := system.NewMediaRepo(dataData)
mediaUsecase := biz.NewMediaUsecase(mediaRepo, reloadable, runtimeSettings)
taskExecutor := worker.NewTaskExecutor(taskUsecase, mediaUsecase, runtime)
taskScheduler := worker.NewTaskScheduler(taskUsecase, authorityUsecase, taskExecutor, logger)
taskRuntime := worker.NewTaskRuntime(taskScheduler)
@ -87,46 +91,46 @@ func wireApp(confServer *conf.Server, runtime *conf.Runtime, logger *slog.Logger
task := handler.NewTask(taskService)
mediaService := service.NewMediaService(mediaUsecase, runtimeSettings)
media := handler.NewMedia(mediaService)
auditQueryRepo := data.NewAuditRepo(dataData)
auditQueryRepo := system.NewAuditRepo(dataData)
auditUsecase := biz.NewAuditUsecase(auditQueryRepo)
auditService := service.NewAuditService(auditUsecase)
auditRecordRepo := data.NewAuditRecorderRepo(dataData)
auditRecordRepo := system.NewAuditRecorderRepo(dataData)
auditRecorderUsecase := biz.NewAuditRecorderUsecase(auditRecordRepo)
auditRecorder := service.NewAuditRecorder(auditRecorderUsecase)
logFileRepo := data.NewLogFileRepo(dataData)
logFileRepo := system.NewLogFileRepo(dataData)
logViewerUsecase := biz.NewLogViewerUsecase(logFileRepo)
logViewerService := service.NewLogViewerService(logViewerUsecase)
audit := handler.NewAudit(auditService, auditRecorder, logViewerService, logger)
exportRepo := data.NewExportRepo(dataData)
exportRepo := system.NewExportRepo(dataData)
exportUsecase := biz.NewExportUsecase(exportRepo)
cache := data.NewCache(dataData)
exportService := service.NewExportService(exportUsecase, cache)
bizCache := cache.New(dataData)
exportService := service.NewExportService(exportUsecase, bizCache)
export := handler.NewExport(exportService)
versionRepo := data.NewVersionRepo(dataData)
versionRepo := system.NewVersionRepo(dataData)
versionUsecase := biz.NewVersionUsecase(versionRepo)
versionService := service.NewVersionService(versionUsecase)
version := handler.NewVersion(versionService)
dictionaryRepo := data.NewDictionaryRepo(dataData)
dictionaryRepo := system.NewDictionaryRepo(dataData)
dictionaryUsecase := biz.NewDictionaryUsecase(dictionaryRepo)
dictionaryService := service.NewDictionaryService(dictionaryUsecase)
dictionary := handler.NewDictionary(dictionaryService)
parameterRepo := data.NewParameterRepo(dataData)
parameterRepo := system.NewParameterRepo(dataData)
parameterUsecase := biz.NewParameterUsecase(parameterRepo)
parameterService := service.NewParameterService(parameterUsecase)
parameter := handler.NewParameter(parameterService)
apiTokenRepo := data.NewAPITokenRepo(dataData)
apiTokenRepo := system.NewAPITokenRepo(dataData)
tokenUsecase := biz.NewTokenUsecase(apiTokenRepo)
tokenIssuer := data.NewTokenIssuer(runtimeSettings)
tokenIssuer := system.NewTokenIssuer(runtimeSettings)
tokenService := service.NewTokenService(tokenUsecase, tokenIssuer)
apiToken := handler.NewAPIToken(tokenService)
initializationRepo := data.NewInitializationRepo(dataData)
systemConfigUsecase := biz.NewSystemConfigUsecase(initializationRepo, taskRuntime)
systemConfigService := service.NewSystemConfigService(systemConfigUsecase, runtimeSettings)
securityRepo := data.NewSecurityRepo(dataData)
securityUsecase := biz.NewSecurityUsecase(securityRepo, cache, runtimeSettings, tokenUsecase)
securityRepo := system.NewSecurityRepo(dataData)
securityUsecase := biz.NewSecurityUsecase(securityRepo, bizCache, runtimeSettings, tokenUsecase)
securityService := service.NewSecurityService(securityUsecase)
systemConfig := handler.NewSystemConfig(systemConfigService, securityService)
userRepo := data.NewUserRepo(dataData)
userRepo := system.NewUserRepo(dataData)
userUsecase := biz.NewUserUsecase(userRepo)
authenticationUsecase := biz.NewAuthenticationUsecase(userUsecase, securityUsecase, tokenIssuer, auditRecordRepo)
authService := service.NewAuthService(authenticationUsecase)
@ -135,7 +139,7 @@ func wireApp(confServer *conf.Server, runtime *conf.Runtime, logger *slog.Logger
user := handler.NewUser(userService, authService)
navigation := handler.NewNavigation(userService)
session := handler.NewSession(tokenService)
set := handler.NewSet(authority, menu, api, permission, organization, announcement, email, payment, task, media, audit, export, version, dictionary, parameter, apiToken, systemConfig, public, user, navigation, session)
set := handler.NewSet(authority, menu, api, permission, organization, announcement, handlerEmail, handlerPayment, task, media, audit, export, version, dictionary, parameter, apiToken, systemConfig, public, user, navigation, session)
engine := server.NewGinEngine(runtime, accessControlService, set, authService, securityService, auditRecorder, logger, string2)
httpServer := server.NewGinServer(confServer, engine)
app := newApp(logger, httpServer, taskScheduler, auditRecorder, reloadableLogger)

3
go.mod
View File

@ -14,8 +14,10 @@ require (
github.com/fsnotify/fsnotify v1.10.1
github.com/gin-gonic/gin v1.10.0
github.com/glebarez/sqlite v1.11.0
github.com/go-gormigrate/gormigrate/v2 v2.1.6
github.com/go-kratos/kratos/contrib/otel/v3 v3.0.0-20260617100506-4e232a3eff59
github.com/go-kratos/kratos/v3 v3.0.0
github.com/go-pay/crypto v0.0.1
github.com/go-pay/gopay v1.5.122
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
@ -95,7 +97,6 @@ require (
github.com/go-openapi/jsonreference v0.19.6 // indirect
github.com/go-openapi/spec v0.20.4 // indirect
github.com/go-openapi/swag v0.19.15 // indirect
github.com/go-pay/crypto v0.0.1 // indirect
github.com/go-pay/errgroup v0.0.3 // indirect
github.com/go-pay/smap v0.0.2 // indirect
github.com/go-pay/util v0.0.4 // indirect

4
go.sum
View File

@ -126,6 +126,8 @@ github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9g
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-gormigrate/gormigrate/v2 v2.1.6 h1:VtX+l1Stj2v5RGubVQk0LS/8EPGXR+ldcOyCmlmKoyg=
github.com/go-gormigrate/gormigrate/v2 v2.1.6/go.mod h1:PZpedQc4tWaxn6kvXicwhinh3L0seLpMc5ReKRX5id4=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-kratos/kratos/contrib/otel/v3 v3.0.0-20260617100506-4e232a3eff59 h1:FeDujUZF6a2pPS0RWGI52IXWPqVcGW1QP5vHDlAqLE8=
@ -209,6 +211,8 @@ github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=

View File

@ -387,6 +387,19 @@ func (uc *PaymentUsecase) Order(ctx context.Context, provider, tradeNo string) (
return uc.orders.FindPaymentOrder(ctx, provider, tradeNo)
}
func (uc *PaymentUsecase) Orders(ctx context.Context, page, pageSize int, filter PaymentOrderFilter) ([]*PaymentOrder, int64, error) {
if uc.orders == nil {
return nil, 0, errors.New("支付订单仓储未接入")
}
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 10
}
return uc.orders.ListPaymentOrders(ctx, page, pageSize, filter)
}
func (uc *PaymentUsecase) Create(ctx context.Context, req *PaymentRequest) (*PaymentResult, error) {
if req == nil || !validPaymentText(req.Provider, 64) || !validPaymentText(req.TradeNo, 128) || !validPaymentText(req.BusinessType, 64) || !validPaymentText(req.BusinessID, 128) {
return nil, errors.New("支付参数不完整")

View File

@ -111,9 +111,19 @@ type PaymentProviderUpdate struct {
CreatePayload json.RawMessage
}
type PaymentOrderFilter struct {
Provider string
TradeNo string
BusinessType string
BusinessID string
PaymentStatus string
RefundStatus string
}
type PaymentOrderRepo interface {
CreatePaymentOrder(context.Context, *PaymentOrder) (order *PaymentOrder, created bool, err error)
FindPaymentOrder(context.Context, string, string) (*PaymentOrder, error)
ListPaymentOrders(context.Context, int, int, PaymentOrderFilter) ([]*PaymentOrder, int64, error)
RecordPaymentCreate(context.Context, string, string, *PaymentProviderUpdate) (*PaymentOrder, error)
ApplyPaymentResult(context.Context, string, string, *PaymentProviderUpdate) (*PaymentOrder, error)
BeginPaymentFulfillment(context.Context, string, string, time.Duration) (order *PaymentOrder, token string, duplicate bool, err error)

View File

@ -48,6 +48,12 @@ func (r *paymentOrderRepoStub) FindPaymentOrder(context.Context, string, string)
}
return r.order, nil
}
func (r *paymentOrderRepoStub) ListPaymentOrders(context.Context, int, int, PaymentOrderFilter) ([]*PaymentOrder, int64, error) {
if r.order == nil {
return []*PaymentOrder{}, 0, nil
}
return []*PaymentOrder{r.order}, 1, nil
}
func (r *paymentOrderRepoStub) RecordPaymentCreate(_ context.Context, _, _ string, update *PaymentProviderUpdate) (*PaymentOrder, error) {
r.order.PaymentStatus = PaymentStatusPending
r.order.CreatePayload = append(r.order.CreatePayload[:0], update.CreatePayload...)

View File

@ -1 +1,28 @@
# Data
# Data Layer
`internal/data` is the persistence composition root. It owns the shared
database/runtime container (`Data`), database clients, configuration storage,
and the Wire `ProviderSet`. Business repositories live in submodules so a new
feature can be found by its domain instead of by scanning one large package.
## Modules
- `internal/data/system` contains the built-in administration domain: users,
authorities, APIs, permissions, menus, organization, dictionaries,
parameters, tokens, security, audit/logging, media, announcements, tasks,
versions, exports, bootstrap seeding, and system migrations.
- `internal/data/payment` contains payment configuration and payment-order
persistence. Provider SDK implementations are separate in
`internal/integration/payment`.
- `internal/data/migration` contains the version-table runner used by the
root migration coordinator.
The root package intentionally keeps only cross-cutting infrastructure:
database lifecycle/reloads, runtime configuration persistence, data-scope
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.
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`.

View File

@ -11,10 +11,30 @@ 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"
)
var ProviderSet = wire.NewSet(NewData, NewRuntimeSettings, NewTokenIssuer, NewUserRepo, NewInitializationRepo, NewAuthorityAccessRepo, NewAPIRepo, NewPermissionRepo, NewMenuRepo, NewDepartmentRepo, NewPositionRepo, NewDictionaryRepo, NewParameterRepo, NewAPITokenRepo, NewSecurityRepo, NewVersionRepo, NewExportRepo, NewAuditRepo, NewAuditRecorderRepo, NewLogFileRepo, NewTaskRepo, NewMediaRepo, NewAnnouncementRepo, NewEmailRepo, NewPaymentRepo, NewPaymentOrderRepo, NewCache, NewFileStorage)
var ProviderSet = wire.NewSet(
NewData,
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.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,
)
type Data struct {
initMu sync.Mutex
@ -24,13 +44,50 @@ type Data struct {
redis *reloadableRedis
mongo *reloadableMongo
runtime *conf.Runtime
storage *reloadableStorage
storage *storage.Reloadable
dbListMu sync.RWMutex
dbList map[string]*gorm.DB
appLogger *slog.Logger
auditLog *dataScopeAuditWriter
}
// DB exposes the active primary database to narrowly scoped data submodules.
func (d *Data) DB() *gorm.DB {
if d == nil || d.gormDB == nil {
return nil
}
return d.gormDB.DB()
}
// DatabaseReady reports whether the configured primary database has been
// initialized. It is intentionally small so system repositories do not depend
// on the full Data implementation.
func (d *Data) DatabaseReady() bool {
return d != nil && d.databaseReady.Load()
}
// Runtime exposes the immutable runtime configuration snapshot to data
// submodules that need system settings while keeping Data itself private.
func (d *Data) Runtime() *conf.Runtime {
if d == nil {
return nil
}
return d.runtime
}
// Database resolves the primary or a named database for repositories such as
// the system export module.
func (d *Data) Database(name string) (*gorm.DB, error) {
return d.database(name)
}
func (d *Data) RedisClient() redis.UniversalClient {
if d == nil || d.redis == nil {
return nil
}
return d.redis.load()
}
func (d *Data) logger() *slog.Logger {
if d != nil && d.appLogger != nil {
return d.appLogger
@ -87,7 +144,7 @@ func (d *Data) database(name string) (*gorm.DB, error) {
return db, nil
}
func NewData(runtime *conf.Runtime, appLogger *slog.Logger) (*Data, func(), error) {
func NewData(runtime *conf.Runtime, appLogger *slog.Logger, storageManager *storage.Reloadable) (*Data, func(), error) {
if appLogger == nil {
appLogger = slog.Default()
}
@ -101,7 +158,7 @@ func NewData(runtime *conf.Runtime, appLogger *slog.Logger) (*Data, func(), erro
// and /init/initdb remain available.
c.Database = &conf.Data_Database{}
}
d := &Data{runtime: runtime, appLogger: appLogger}
d := &Data{runtime: runtime, appLogger: appLogger, storage: storageManager}
usingFallback := !databaseConnectionConfigured(c.Database)
var db *gorm.DB
var err error
@ -154,12 +211,22 @@ func NewData(runtime *conf.Runtime, appLogger *slog.Logger) (*Data, func(), erro
admin.Storage = storageConfig
admin.Email = emailConfig
runtime.Replace(c, admin)
activeStorage, storageErr := storage.New(admin)
if storageErr != nil {
return nil, nil, fmt.Errorf("initialize storage: %w", storageErr)
}
if storageManager != nil {
storageManager.Replace(activeStorage)
}
if db.Migrator().HasTable(&integrationConfigPO{}) {
if removeErr := d.removeIntegrationConfigFromFile(); removeErr != nil {
appLogger.Warn("remove legacy integration configuration from file", "mod", "integration", "error", removeErr)
}
}
}
if storageManager == nil {
return nil, nil, fmt.Errorf("storage manager is nil")
}
useRedis := admin != nil && admin.System != nil && admin.System.UseRedis
d.redis = newReloadableRedis(openRedis(c.Redis, useRedis, appLogger))
useMongo := admin != nil && admin.System != nil && admin.System.UseMongo

View File

@ -20,6 +20,7 @@ import (
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
"kra/internal/conf"
"kra/pkg/gormkit"
)
var databaseNamePattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_$-]*$`)
@ -131,7 +132,7 @@ func gormConfig(config *conf.Data_Database, appLogger ...*slog.Logger) *gorm.Con
if len(appLogger) > 0 {
log = appLogger[0]
}
return &gorm.Config{Logger: newGormLogger(log, level), NamingStrategy: schema.NamingStrategy{TablePrefix: config.Prefix, SingularTable: config.Singular}}
return &gorm.Config{Logger: gormkit.NewLogger(log, level), NamingStrategy: schema.NamingStrategy{TablePrefix: config.Prefix, SingularTable: config.Singular}}
}
func openWithDriver(driver, dsn string, appLogger ...*slog.Logger) (*gorm.DB, error) {

View File

@ -1,65 +0,0 @@
package data
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"time"
"gorm.io/gorm/logger"
)
// gormLogger forwards GORM diagnostics through the application logger so SQL
// entries participate in the same daily/category files and error sink as the
// rest of the service. Slow queries use the administration contract's 200ms
// threshold.
type gormLogger struct {
logger *slog.Logger
slowThreshold time.Duration
level logger.LogLevel
}
func newGormLogger(log *slog.Logger, level logger.LogLevel) *gormLogger {
if log == nil {
log = slog.New(slog.NewTextHandler(io.Discard, nil))
}
return &gormLogger{logger: log, slowThreshold: 200 * time.Millisecond, level: level}
}
func (g *gormLogger) LogMode(level logger.LogLevel) logger.Interface {
next := *g
next.level = level
return &next
}
func (g *gormLogger) Info(ctx context.Context, message string, args ...any) {
g.logger.InfoContext(ctx, fmt.Sprintf(message, args...), "mod", "sql", "gorm_logger", true)
}
func (g *gormLogger) Warn(ctx context.Context, message string, args ...any) {
g.logger.WarnContext(ctx, fmt.Sprintf(message, args...), "mod", "sql", "gorm_logger", true)
}
func (g *gormLogger) Error(ctx context.Context, message string, args ...any) {
g.logger.ErrorContext(ctx, fmt.Sprintf(message, args...), "mod", "sql", "gorm_logger", true)
}
func (g *gormLogger) 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...)
}
}

View File

@ -8,6 +8,7 @@ import (
"testing"
"time"
"kra/pkg/gormkit"
"kra/pkg/logging"
"gorm.io/gorm/logger"
@ -16,7 +17,7 @@ import (
func TestGORMLoggerUsesApplicationCategories(t *testing.T) {
root := t.TempDir()
appLogger, cleanup := logging.NewZapLogger(root, "application.log", logging.Options{Level: "info", Format: "json"})
databaseLogger := newGormLogger(appLogger, logger.Info)
databaseLogger := gormkit.NewLogger(appLogger, logger.Info)
databaseLogger.Trace(context.Background(), time.Now(), func() (string, int64) { return "SELECT 1", 1 }, nil)
databaseLogger.Trace(context.Background(), time.Now(), func() (string, int64) { return "SELECT missing", 0 }, errors.New("database failure"))
cleanup()

View File

@ -1,48 +0,0 @@
package data
import (
"database/sql/driver"
"fmt"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)
// jsonPO selects a native or textual JSON storage type supported by each
// configured database while keeping one value shape in the data layer.
type jsonPO []byte
func (jsonPO) GormDataType() string { return "json" }
func (jsonPO) 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 jsonPO) Value() (driver.Value, error) {
if len(j) == 0 {
return nil, nil
}
return string(j), nil
}
func (j *jsonPO) 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
}

View File

@ -0,0 +1,40 @@
// 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
import (
"fmt"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
const TableName = "sys_schema_migrations"
type Step struct {
ID string
Migrate func(*gorm.DB) error
}
func Run(db *gorm.DB, steps []Step) error {
if db == nil {
return fmt.Errorf("database is nil")
}
migrations := make([]*gormigrate.Migration, 0, len(steps))
for _, step := range steps {
current := step
migrations = append(migrations, &gormigrate.Migration{ID: current.ID, Migrate: current.Migrate})
}
manager := gormigrate.New(db, &gormigrate.Options{
TableName: TableName,
IDColumnName: "id",
IDColumnSize: 255,
UseTransaction: false,
ValidateUnknownMigrations: true,
}, migrations)
if err := manager.Migrate(); err != nil {
return fmt.Errorf("apply database migrations: %w", err)
}
return nil
}

View File

@ -1,395 +1,28 @@
package data
import (
"fmt"
"strings"
"time"
"kra/internal/data/migration"
datapayment "kra/internal/data/payment"
datasystem "kra/internal/data/system"
"gorm.io/gorm"
)
// 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 {
if err := migrateLegacyIgnoreAPITable(db); err != nil {
return err
}
if err := migrateLegacyAuthorityDepartmentColumns(db); err != nil {
return err
}
if err := 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{},
&integrationConfigPO{},
&versionPO{}, &exportTemplatePO{}, &exportConditionPO{}, &exportJoinPO{},
&operationPO{}, &loginLogPO{}, &dataAccessLogPO{}, &errorRecordPO{},
&taskPO{}, &taskLogPO{}, &mediaPO{}, &categoryPO{}, &uploadSessionPO{}, &uploadChunkPO{},
&announcementPO{},
&paymentOrderPO{},
); err != nil {
return err
}
if err := ensurePaymentIntegrationConfigs(db); err != nil {
return err
}
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)
}
// 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)
return migration.Run(db, []migration.Step{
{ID: "202608200001_baseline", Migrate: func(db *gorm.DB) error {
if err := datasystem.LegacySchemaMigration(db); err != nil {
return 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})
return datapayment.Migrate(db)
}},
{ID: "202608200002_data_reconcile", Migrate: func(db *gorm.DB) error {
if err := datapayment.Reconcile(db); err != nil {
return err
}
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)
}
}
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
return datasystem.CurrentDataMigration(db)
}},
})
}
// 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
}

View File

@ -1,30 +0,0 @@
package data
import "gorm.io/gorm"
func paginationLimitOffset(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
}
func applyPagination(db *gorm.DB, page, size, maxSize int) *gorm.DB {
limit, offset := paginationLimitOffset(page, size, maxSize)
if limit == 0 {
return db
}
return db.Offset(offset).Limit(limit)
}
func applyRequiredPagination(db *gorm.DB, page, size, maxSize int) *gorm.DB {
limit, offset := paginationLimitOffset(page, size, maxSize)
return db.Offset(offset).Limit(limit)
}

View File

@ -0,0 +1,29 @@
package system
import "strings"
// IgnoredAPI describes an endpoint excluded from the generated root policy.
// It deliberately has no GORM tags so the system module can be reused by
// bootstrap and migration code without exposing data-layer PO types.
type IgnoredAPI struct {
Method string
Path string
}
func DefaultIgnoredAPIs(staticPath string) []IgnoredAPI {
staticRoute := "/" + strings.Trim(staticPath, "/") + "/*filepath"
return []IgnoredAPI{
{Method: "GET", Path: "/api/freshCasbin"},
{Method: "GET", Path: "/health"},
{Method: "GET", Path: "/swagger/*any"},
{Method: "GET", Path: staticRoute},
{Method: "HEAD", Path: staticRoute},
{Method: "POST", Path: "/system/reloadSystem"},
{Method: "POST", Path: "/base/login"},
{Method: "POST", Path: "/base/captcha"},
{Method: "POST", Path: "/init/initdb"},
{Method: "POST", Path: "/init/checkdb"},
{Method: "GET", Path: "/info/getInfoDataSource"},
{Method: "GET", Path: "/info/getInfoPublic"},
}
}

View File

@ -1,4 +1,6 @@
package data
// Package system contains data-layer implementations for system runtime
// settings, authentication tokens, and bootstrap definitions.
package system
import (
"errors"
@ -106,21 +108,12 @@ func (i *tokenIssuer) ReissueToken(source *biz.AuthClaims, authorityID uint) (*b
}
settings := i.settings.JWTSettings()
claims := &adminauth.Claims{
UUID: source.UUID,
ID: source.ID,
Username: source.Username,
NickName: source.NickName,
AuthorityID: authorityID,
BufferTime: int64(source.BufferTime / time.Second),
UserType: source.UserType,
MustChangePwd: source.MustChangePwd,
PasswordVersion: source.PasswordVersion,
UUID: source.UUID, ID: source.ID, Username: source.Username, NickName: source.NickName,
AuthorityID: authorityID, BufferTime: int64(source.BufferTime / time.Second), UserType: source.UserType,
MustChangePwd: source.MustChangePwd, PasswordVersion: source.PasswordVersion,
RegisteredClaims: jwt.RegisteredClaims{
Audience: jwt.ClaimStrings(append([]string(nil), source.Audience...)),
Issuer: source.Issuer,
IssuedAt: jwt.NewNumericDate(source.IssuedAt),
NotBefore: jwt.NewNumericDate(source.NotBefore),
ExpiresAt: jwt.NewNumericDate(source.ExpiresAt),
Audience: jwt.ClaimStrings(append([]string(nil), source.Audience...)), Issuer: source.Issuer,
IssuedAt: jwt.NewNumericDate(source.IssuedAt), NotBefore: jwt.NewNumericDate(source.NotBefore), ExpiresAt: jwt.NewNumericDate(source.ExpiresAt),
},
}
token, err := adminauth.Sign(settings.SigningKey, claims)
@ -146,8 +139,7 @@ func (i *tokenIssuer) ParseToken(token string) (*biz.AuthClaims, error) {
return nil, biz.ErrTokenInvalid
}
}
audience := make([]string, len(claims.Audience))
copy(audience, claims.Audience)
audience := append([]string(nil), claims.Audience...)
issuedAt := time.Time{}
if claims.IssuedAt != nil {
issuedAt = claims.IssuedAt.Time

View File

@ -1,4 +1,4 @@
package data
package system
import (
"context"
@ -10,11 +10,14 @@ import (
"gorm.io/gorm"
)
type securityRepo struct{ data *Data }
// DatabaseProvider is the smaller seam used by security settings, which also
// need to work before the primary database is initialized.
type DatabaseProvider interface {
DB() *gorm.DB
DatabaseReady() bool
}
func NewSecurityRepo(data *Data) biz.SecurityRepo { return &securityRepo{data: data} }
type securityConfigPO struct {
type SecurityConfigPO struct {
ID uint `gorm:"primaryKey"`
CreatedAt time.Time
UpdatedAt time.Time
@ -40,37 +43,46 @@ type securityConfigPO struct {
ForceNewUserChangePassword bool `gorm:"default:false"`
}
func (securityConfigPO) TableName() string { return "sys_security_config" }
func (SecurityConfigPO) TableName() string { return "sys_security_config" }
func defaultSecurityConfig() securityConfigPO {
return securityConfigPO{ID: 1, CaptchaTimeout: 3600, KeyLong: 6, ImgWidth: 240, ImgHeight: 80, PwdMinLength: 8, LimitWindow: 60, LimitCount: 30, LockThreshold: 5, LockDuration: 30, PwdExpireDays: 90}
func DefaultSecurityConfig() SecurityConfigPO {
return SecurityConfigPO{ID: 1, CaptchaTimeout: 3600, KeyLong: 6, ImgWidth: 240, ImgHeight: 80, PwdMinLength: 8, LimitWindow: 60, LimitCount: 30, LockThreshold: 5, LockDuration: 30, PwdExpireDays: 90}
}
func securityFromPO(v securityConfigPO) *biz.SecurityConfig {
type securityRepo struct{ data DatabaseProvider }
func NewSecurityRepo(data DatabaseProvider) biz.SecurityRepo { return &securityRepo{data: data} }
func securityFromPO(v SecurityConfigPO) *biz.SecurityConfig {
return &biz.SecurityConfig{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, CaptchaOpen: v.CaptchaOpen, CaptchaTimeout: v.CaptchaTimeout, KeyLong: v.KeyLong, ImgWidth: v.ImgWidth, ImgHeight: v.ImgHeight, PwdMinLength: v.PwdMinLength, PwdRequireUpper: v.PwdRequireUpper, PwdRequireLower: v.PwdRequireLower, PwdRequireDigit: v.PwdRequireDigit, PwdRequireSpecial: v.PwdRequireSpecial, LimitEnable: v.LimitEnable, LimitWindow: v.LimitWindow, LimitCount: v.LimitCount, LockEnable: v.LockEnable, LockThreshold: v.LockThreshold, LockDuration: v.LockDuration, PwdExpireEnable: v.PwdExpireEnable, PwdExpireDays: v.PwdExpireDays, ForceNewUserChangePassword: v.ForceNewUserChangePassword}
}
func securityToPO(v *biz.SecurityConfig) securityConfigPO {
return securityConfigPO{ID: 1, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, CaptchaOpen: v.CaptchaOpen, CaptchaTimeout: v.CaptchaTimeout, KeyLong: v.KeyLong, ImgWidth: v.ImgWidth, ImgHeight: v.ImgHeight, PwdMinLength: v.PwdMinLength, PwdRequireUpper: v.PwdRequireUpper, PwdRequireLower: v.PwdRequireLower, PwdRequireDigit: v.PwdRequireDigit, PwdRequireSpecial: v.PwdRequireSpecial, LimitEnable: v.LimitEnable, LimitWindow: v.LimitWindow, LimitCount: v.LimitCount, LockEnable: v.LockEnable, LockThreshold: v.LockThreshold, LockDuration: v.LockDuration, PwdExpireEnable: v.PwdExpireEnable, PwdExpireDays: v.PwdExpireDays, ForceNewUserChangePassword: v.ForceNewUserChangePassword}
func securityToPO(v *biz.SecurityConfig) SecurityConfigPO {
return SecurityConfigPO{ID: 1, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, CaptchaOpen: v.CaptchaOpen, CaptchaTimeout: v.CaptchaTimeout, KeyLong: v.KeyLong, ImgWidth: v.ImgWidth, ImgHeight: v.ImgHeight, PwdMinLength: v.PwdMinLength, PwdRequireUpper: v.PwdRequireUpper, PwdRequireLower: v.PwdRequireLower, PwdRequireDigit: v.PwdRequireDigit, PwdRequireSpecial: v.PwdRequireSpecial, LimitEnable: v.LimitEnable, LimitWindow: v.LimitWindow, LimitCount: v.LimitCount, LockEnable: v.LockEnable, LockThreshold: v.LockThreshold, LockDuration: v.LockDuration, PwdExpireEnable: v.PwdExpireEnable, PwdExpireDays: v.PwdExpireDays, ForceNewUserChangePassword: v.ForceNewUserChangePassword}
}
func (r *securityRepo) SecurityConfig(ctx context.Context) (*biz.SecurityConfig, error) {
if !r.data.databaseReady.Load() {
po := defaultSecurityConfig()
if !r.data.DatabaseReady() {
po := DefaultSecurityConfig()
po.ID = 0
return securityFromPO(po), errors.New("数据库未初始化")
}
var po securityConfigPO
err := r.data.gormDB.WithContext(ctx).First(&po, 1).Error
db := r.data.DB().WithContext(ctx)
var po SecurityConfigPO
err := db.First(&po, 1).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
po = defaultSecurityConfig()
err = r.data.gormDB.WithContext(ctx).Create(&po).Error
po = DefaultSecurityConfig()
err = db.Create(&po).Error
}
if err != nil {
return nil, err
}
return securityFromPO(po), nil
}
func (r *securityRepo) SaveSecurityConfig(ctx context.Context, v *biz.SecurityConfig) error {
po := securityToPO(v)
if err := r.data.gormDB.WithContext(ctx).Save(&po).Error; err != nil {
if err := r.data.DB().WithContext(ctx).Save(&po).Error; err != nil {
return err
}
v.ID, v.CreatedAt, v.UpdatedAt = po.ID, po.CreatedAt, po.UpdatedAt
@ -78,7 +90,5 @@ func (r *securityRepo) SaveSecurityConfig(ctx context.Context, v *biz.SecurityCo
}
func (r *securityRepo) BackfillPasswordUpdatedAt(ctx context.Context, at time.Time) error {
return r.data.gormDB.WithContext(ctx).Model(&userPO{}).
Where("password_updated_at IS NULL").
Update("password_updated_at", at).Error
return r.data.DB().WithContext(ctx).Table("sys_users").Where("password_updated_at IS NULL").Update("password_updated_at", at).Error
}

View File

@ -4,17 +4,15 @@ import (
"context"
"errors"
"fmt"
"strings"
"time"
"kra/internal/biz"
"kra/internal/conf"
datasystem "kra/internal/data/system"
"kra/internal/integration/storage"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"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() }
@ -31,7 +29,7 @@ func (r *initializationRepo) PersistAdminConfig(ctx context.Context, raw []byte)
next.Email = currentAdmin.Email
}
next.ConfigPath = currentAdmin.ConfigPath
candidateStorage, err := buildFileStorage(next)
candidateStorage, err := storage.New(next)
if err != nil {
return err
}
@ -48,7 +46,7 @@ func (r *initializationRepo) PersistAdminConfig(ctx context.Context, raw []byte)
// 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)
r.data.storage.Replace(candidateStorage)
}
return nil
}
@ -70,7 +68,7 @@ func (r *initializationRepo) PersistRuntimeConfig(ctx context.Context, dataRaw,
nextAdmin.Email = currentAdmin.Email
}
nextAdmin.ConfigPath = currentAdmin.ConfigPath
candidateStorage, err := buildFileStorage(nextAdmin)
candidateStorage, err := storage.New(nextAdmin)
if err != nil {
return err
}
@ -85,7 +83,7 @@ func (r *initializationRepo) PersistRuntimeConfig(ctx context.Context, dataRaw,
}
r.data.runtime.Replace(nextData, nextAdmin)
if r.data.storage != nil {
r.data.storage.replace(candidateStorage)
r.data.storage.Replace(candidateStorage)
}
return nil
}
@ -141,137 +139,7 @@ func (r *initializationRepo) Initialize(ctx context.Context, input *biz.Database
if err := migrateAll(db); err != nil {
return err
}
if err := db.Transaction(func(tx *gorm.DB) error {
rootParentID := uint(0)
authority := authorityPO{AuthorityID: 888, AuthorityName: "超级管理员", ParentID: &rootParentID, DataScope: 1, DefaultRouter: "dashboard"}
if err := tx.FirstOrCreate(&authority, authorityPO{AuthorityID: 888}).Error; err != nil {
return err
}
// The root role is seeded with parent_id=0. Older Kra databases used
// NULL, which makes the role disappear from the same root-only queries.
if err := tx.Model(&authorityPO{}).Where("authority_id = ? AND parent_id IS NULL", 888).Update("parent_id", 0).Error; err != nil {
return err
}
menus := defaultMenus()
for i := range menus {
if err := tx.Where("name = ?", menus[i].Name).FirstOrCreate(&menus[i]).Error; err != nil {
return err
}
}
var persisted []menuPO
if err := tx.Order("sort asc, id asc").Find(&persisted).Error; err != nil {
return err
}
nameID := make(map[string]uint, len(persisted))
for _, menu := range persisted {
nameID[menu.Name] = menu.ID
}
for i := range menus {
if menus[i].ActiveName == "" {
continue
}
if err := tx.Model(&menuPO{}).Where("name = ?", menus[i].Name).Updates(map[string]any{"parent_id": nameID[menus[i].ActiveName], "active_name": ""}).Error; err != nil {
return err
}
}
if err := tx.Where("sys_authority_authority_id = ?", 888).Delete(&authorityMenuPO{}).Error; err != nil {
return err
}
links := make([]authorityMenuPO, 0, len(persisted))
for _, menu := range persisted {
links = append(links, authorityMenuPO{SysAuthorityAuthorityID: 888, SysBaseMenuID: menu.ID})
}
if len(links) > 0 {
if err := tx.Create(&links).Error; err != nil {
return err
}
}
var count int64
if err := tx.Model(&userPO{}).Where("username = ?", "admin").Count(&count).Error; err != nil {
return err
}
if count == 0 {
hash, err := bcrypt.GenerateFromPassword([]byte(input.AdminPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
now := time.Now()
user := userPO{UUID: uuid.NewString(), Username: "admin", Password: string(hash), NickName: "超级管理员", AuthorityID: 888, Enable: 1, PasswordUpdatedAt: &now}
if err := tx.Create(&user).Error; err != nil {
return err
}
if err := tx.Create(&userAuthorityPO{SysUserID: user.ID, SysAuthorityAuthorityID: 888}).Error; err != nil {
return err
}
}
enabled := true
department := departmentPO{Name: "总公司", ParentID: 0, Ancestors: "0", Sort: 0, Status: &enabled}
if err := tx.Where("name = ?", department.Name).FirstOrCreate(&department).Error; err != nil {
return err
}
for _, position := range []positionPO{{Name: "总经理", Code: "CEO", Sort: 1, Status: &enabled}, {Name: "普通员工", Code: "STAFF", Sort: 2, Status: &enabled}} {
if err := tx.Where("code = ?", position.Code).FirstOrCreate(&position).Error; err != nil {
return err
}
}
security := defaultSecurityConfig()
if err := tx.FirstOrCreate(&security, securityConfigPO{ID: 1}).Error; err != nil {
return err
}
exportTemplate := exportTemplatePO{Name: "api", DBTableName: "sys_apis", TemplateID: "api", TemplateInfo: "{\n\"path\":\"路径\",\n\"method\":\"方法(大写)\",\n\"description\":\"方法介绍\",\n\"api_group\":\"方法分组\"\n}"}
if err := tx.Where("template_id = ?", exportTemplate.TemplateID).FirstOrCreate(&exportTemplate).Error; err != nil {
return err
}
for _, task := range []taskPO{{Name: "ClearDB", Description: "定时清理数据库过期日志(操作记录/JWT黑名单/定时任务执行日志)", Spec: "@daily", ExecutorType: "method", MethodName: "ClearDB", Enabled: true}, {Name: "CleanStaleUploads", Description: "定时清理过期大文件上传会话", Spec: "@hourly", ExecutorType: "method", MethodName: "CleanStaleUploads", Enabled: true}} {
if err := tx.Where("name = ?", task.Name).FirstOrCreate(&task).Error; err != nil {
return err
}
}
for _, item := range input.APIs {
if item == nil {
continue
}
po := apiPO{Path: item.Path, Method: strings.ToUpper(item.Method), Description: item.Description, APIGroup: item.APIGroup}
if err := tx.Where("path = ? AND method = ?", po.Path, po.Method).FirstOrCreate(&po).Error; err != nil {
return err
}
}
staticPath := "uploads/file"
if admin := r.data.runtime.Admin(); admin != nil && admin.Local != nil && strings.Trim(admin.Local.PathPrefix, "/") != "" {
staticPath = strings.Trim(admin.Local.PathPrefix, "/")
}
ignoredAPIs := defaultIgnoredAPIs(staticPath)
for _, ignored := range ignoredAPIs {
if err := tx.FirstOrCreate(&ignored, ignored).Error; err != nil {
return err
}
}
ignoreSet := make(map[string]struct{}, len(ignoredAPIs))
for _, ignored := range ignoredAPIs {
ignoreSet[ignored.Method+"\x00"+ignored.Path] = struct{}{}
}
var apiRows []apiPO
if err := tx.Find(&apiRows).Error; err != nil {
return err
}
for _, api := range apiRows {
if _, ignored := ignoreSet[api.Method+"\x00"+api.Path]; ignored {
continue
}
exists, err := policyExists(tx, 888, api.Path, api.Method)
if err != nil {
return err
}
if exists {
continue
}
rule := newPolicyRule(888, api.Path, api.Method)
if err := tx.Create(&rule).Error; err != nil {
return err
}
}
return nil
}); err != nil {
if err := datasystem.SeedSystem(ctx, db, input); err != nil {
return err
}
currentAdmin := r.data.runtime.Admin()
@ -310,41 +178,3 @@ func (r *initializationRepo) Initialize(ctx context.Context, input *biz.Database
activated = true
return nil
}
func defaultIgnoredAPIs(staticPath string) []ignoredAPIPO {
staticRoute := "/" + strings.Trim(staticPath, "/") + "/*filepath"
return []ignoredAPIPO{
{Method: "GET", Path: "/api/freshCasbin"}, {Method: "GET", Path: "/health"},
{Method: "GET", Path: "/swagger/*any"},
{Method: "GET", Path: staticRoute}, {Method: "HEAD", Path: staticRoute},
{Method: "POST", Path: "/system/reloadSystem"}, {Method: "POST", Path: "/base/login"},
{Method: "POST", Path: "/base/captcha"}, {Method: "POST", Path: "/init/initdb"},
{Method: "POST", Path: "/init/checkdb"}, {Method: "GET", Path: "/info/getInfoDataSource"},
{Method: "GET", Path: "/info/getInfoPublic"},
}
}
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}
}
child := func(parent, path, name, component, title, icon string, sort int) menuPO {
return menuPO{MenuLevel: 1, Path: path, Name: name, Component: component, Title: title, Icon: icon, Sort: sort, ActiveName: parent}
}
cachedChild := func(parent, path, name, component, title, icon string, sort int) menuPO {
value := child(parent, path, name, component, title, icon, sort)
value.KeepAlive = true
return value
}
return []menuPO{
{Path: "dashboard", Name: "dashboard", Component: "view/dashboard/index.vue", Title: "仪表盘", Icon: "odometer", Sort: 1},
root("permission", "permission", "权限管理", "perm-kra", 2), root("org", "org", "组织管理", "share", 3), root("systemConfig", "systemConfig", "系统设置", "config-kra", 4), root("monitor", "monitor", "运维监控", "monitor-kra", 5), root("media", "media", "媒体管理", "folder-opened", 6), root("extensions", "extensions", "扩展功能", "cherry", 10),
{Path: "person", Name: "person", Component: "view/person/person.vue", Title: "个人信息", Icon: "postcard", Hidden: true, Sort: 13},
child("permission", "authority", "authority", "view/superAdmin/authority/authority.vue", "角色管理", "role-kra", 1), cachedChild("permission", "menu", "menu", "view/superAdmin/menu/menu.vue", "菜单管理", "tickets", 2), cachedChild("permission", "api", "api", "view/superAdmin/api/api.vue", "api管理", "api-kra", 3), child("permission", "apiToken", "apiToken", "view/systemTools/apiToken/index.vue", "API Token", "key", 4),
child("org", "user", "user", "view/superAdmin/user/user.vue", "用户管理", "user", 1), child("org", "department", "department", "view/superAdmin/department/department.vue", "部门管理", "office-building", 2), child("org", "position", "position", "view/superAdmin/position/position.vue", "岗位管理", "postcard", 3),
child("systemConfig", "system", "system", "view/systemTools/system/system.vue", "配置文件", "config-file-kra", 1), child("systemConfig", "dictionary", "dictionary", "view/superAdmin/dictionary/sysDictionary.vue", "字典管理", "notebook", 2), child("systemConfig", "sysParams", "sysParams", "view/superAdmin/params/sysParams.vue", "参数管理", "set-up", 3), child("systemConfig", "security", "security", "view/system/security/index.vue", "安全配置", "security-kra", 4),
child("monitor", "operation", "operation", "view/superAdmin/operation/sysOperationRecord.vue", "操作历史", "document", 1), child("monitor", "loginLog", "loginLog", "view/systemTools/loginLog/index.vue", "登录日志", "clock", 2), child("monitor", "sysError", "sysError", "view/systemTools/sysError/sysError.vue", "错误日志", "error-kra", 3), child("monitor", "sysVersion", "sysVersion", "view/systemTools/version/version.vue", "版本管理", "version-kra", 4), child("monitor", "state", "state", "view/system/state.vue", "服务器状态", "server", 5), child("monitor", "dataAccessLog", "dataAccessLog", "view/superAdmin/dataAccessLog/dataAccessLog.vue", "数据权限审计", "warning", 6), child("monitor", "timedTask", "timedTask", "view/systemTools/timedTask/index.vue", "定时任务", "timer", 7), child("monitor", "logViewer", "logViewer", "view/systemTools/logViewer/index.vue", "文件日志", "document", 8),
child("media", "upload", "upload", "view/media/upload.vue", "媒体库(上传下载)", "upload", 1), child("media", "chunkUpload", "chunkUpload", "view/media/chunkUpload.vue", "大文件上传", "folder-add", 2),
child("extensions", "email", "email", "modules/email/view/index.vue", "邮件发送", "message", 4), child("extensions", "anInfo", "anInfo", "modules/announcement/view/info.vue", "公告管理", "bell", 5),
}
}

View File

@ -1,4 +1,4 @@
package data
package email
import (
"context"

View File

@ -44,6 +44,8 @@ var apiMetadata = map[string]apiMetadataValue{
"GET /menu/getMenuRoles": {group: "菜单", description: "获取菜单关联角色列表"},
"GET /position/findPosition": {group: "岗位", description: "根据ID获取岗位"},
"GET /position/getPositionUsers": {group: "岗位", description: "获取岗位成员ID列表"},
"GET /payment/configs": {group: "支付", description: "获取支付渠道配置"},
"GET /payment/orders": {group: "支付", description: "分页查询支付订单"},
"GET /securityConfig/getSecurityConfig": {group: "安全配置", description: "获取安全配置"},
"GET /sysDictionary/exportSysDictionary": {group: "系统字典", description: "导出字典JSON"},
"GET /sysDictionary/findSysDictionary": {group: "系统字典", description: "根据ID获取字典建议选择"},
@ -125,6 +127,8 @@ var apiMetadata = map[string]apiMetadataValue{
"POST /menu/getMenuList": {group: "菜单", description: "分页获取基础menu列表"},
"POST /menu/setMenuRoles": {group: "菜单", description: "全量覆盖菜单关联角色列表"},
"POST /menu/updateBaseMenu": {group: "菜单", description: "更新菜单"},
"POST /payment/config": {group: "支付", description: "保存支付渠道配置"},
"POST /payment/order": {group: "支付", description: "查询支付订单"},
"POST /position/createPosition": {group: "岗位", description: "创建岗位"},
"POST /position/getPositionList": {group: "岗位", description: "获取岗位列表"},
"POST /position/setPositionUsers": {group: "岗位", description: "设置岗位成员(反向分配)"},

View File

@ -56,7 +56,7 @@ func TestGinStartupLogsEveryRegisteredRoute(t *testing.T) {
if got, want := strings.Count(text, `"msg":"router registered"`), len(engine.Routes()); got != want {
t.Fatalf("registered route log count = %d, want %d", got, want)
}
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":185`) {
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":186`) {
t.Fatalf("startup route summary is missing: %s", text)
}
}
@ -257,6 +257,7 @@ GET /logViewer/dates
GET /logViewer/files
GET /menu/getMenuRoles
GET /payment/configs
GET /payment/orders
GET /position/findPosition
GET /position/getPositionUsers
GET /securityConfig/getSecurityConfig

View File

@ -47,6 +47,20 @@ func (h *Payment) Order(c *gin.Context) {
}
httpx.OKWithData(c, order)
}
func (h *Payment) Orders(c *gin.Context) {
var req dto.PaymentOrderListRequest
if err := c.ShouldBindQuery(&req); err != nil {
httpx.Fail(c, err.Error())
return
}
items, total, page, pageSize, err := h.service.Orders(c.Request.Context(), &req)
if err != nil {
httpx.Fail(c, err.Error())
return
}
httpx.Write(c, httpx.CodeSuccess, httpx.PageResult{List: items, Total: total, Page: page, PageSize: pageSize}, "获取成功")
}
func (h *Payment) Create(c *gin.Context) {
var req dto.PaymentRequest
if err := c.ShouldBindJSON(&req); err != nil {

View File

@ -9,6 +9,7 @@ func RegisterPayment(group, public *gin.RouterGroup, h *handler.Payment) {
payment := group.Group("/payment")
payment.GET("/configs", h.Configs)
payment.POST("/config", h.SaveConfig)
payment.GET("/orders", h.Orders)
payment.POST("/order", h.Order)
payment.POST("/create", h.Create)
payment.POST("/query", h.Query)

View File

@ -15,9 +15,20 @@ type PaymentConfigRequest struct {
// payment configuration endpoint. Values are already masked by the usecase's
// repository boundary.
type PaymentConfigResponse struct {
Provider string `json:"Provider"`
Enabled bool `json:"Enabled"`
Values json.RawMessage `json:"Values"`
Provider string `json:"provider"`
Enabled bool `json:"enabled"`
Config json.RawMessage `json:"config"`
}
type PaymentOrderListRequest struct {
Page int `form:"page"`
PageSize int `form:"pageSize"`
Provider string `form:"provider"`
TradeNo string `form:"tradeNo"`
BusinessType string `form:"businessType"`
BusinessID string `form:"businessId"`
PaymentStatus string `form:"paymentStatus"`
RefundStatus string `form:"refundStatus"`
}
type PaymentRequest struct {
@ -84,6 +95,7 @@ type PaymentResultResponse struct {
}
type PaymentOrderResponse struct {
ID uint64 `json:"ID"`
Provider string `json:"provider"`
TradeNo string `json:"tradeNo"`
ProviderTradeNo string `json:"providerTradeNo"`
@ -105,10 +117,13 @@ type PaymentOrderResponse struct {
PayerCurrency string `json:"payerCurrency,omitempty"`
AmountBreakdownKnown bool `json:"amountBreakdownKnown"`
PaymentStatus string `json:"paymentStatus"`
ProviderStatus string `json:"providerStatus"`
FulfillmentStatus string `json:"fulfillmentStatus"`
RefundStatus string `json:"refundStatus"`
RefundedAmount int64 `json:"refundedAmount"`
RefundRequestedAmount int64 `json:"refundRequestedAmount"`
RefundNo string `json:"refundNo,omitempty"`
LastError string `json:"lastError,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
PaidAt *time.Time `json:"paidAt,omitempty"`

View File

@ -21,7 +21,7 @@ func (s *PaymentService) Configs(ctx context.Context) ([]*dto.PaymentConfigRespo
if config == nil {
continue
}
result = append(result, &dto.PaymentConfigResponse{Provider: config.Provider, Enabled: config.Enabled, Values: config.Values})
result = append(result, &dto.PaymentConfigResponse{Provider: config.Provider, Enabled: config.Enabled, Config: config.Values})
}
return result, nil
}
@ -37,6 +37,7 @@ func (s *PaymentService) Order(ctx context.Context, provider, tradeNo string) (*
return nil, err
}
return &dto.PaymentOrderResponse{
ID: order.ID,
Provider: order.Provider, TradeNo: order.TradeNo, ProviderTradeNo: order.ProviderTradeNo,
BusinessType: order.BusinessType, BusinessID: order.BusinessID, Subject: order.Subject,
PaymentMode: order.PaymentMode, OriginalAmount: order.OriginalAmount,
@ -45,12 +46,60 @@ func (s *PaymentService) Order(ctx context.Context, provider, tradeNo string) (*
DiscountAmount: order.DiscountAmount, ProviderDiscountAmount: order.ProviderDiscountAmount,
MerchantDiscountAmount: order.MerchantDiscountAmount, SettlementAmount: order.SettlementAmount,
Currency: order.Currency, PayerCurrency: order.PayerCurrency, AmountBreakdownKnown: order.AmountBreakdownKnown,
PaymentStatus: order.PaymentStatus, FulfillmentStatus: order.FulfillmentStatus,
RefundStatus: order.RefundStatus, RefundedAmount: order.RefundedAmount, RefundNo: order.RefundNo,
PaymentStatus: order.PaymentStatus, ProviderStatus: order.ProviderStatus, FulfillmentStatus: order.FulfillmentStatus,
RefundStatus: order.RefundStatus, RefundedAmount: order.RefundedAmount, RefundRequestedAmount: order.RefundRequestedAmount, RefundNo: order.RefundNo, LastError: order.LastError,
CreatedAt: order.CreatedAt, UpdatedAt: order.UpdatedAt, PaidAt: order.PaidAt,
FulfilledAt: order.FulfilledAt, RefundedAt: order.RefundedAt,
}, nil
}
func (s *PaymentService) Orders(ctx context.Context, req *dto.PaymentOrderListRequest) ([]*dto.PaymentOrderResponse, int64, int, int, error) {
if req == nil {
return nil, 0, 1, 10, errors.New("支付订单列表请求为空")
}
page, pageSize := req.Page, req.PageSize
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 10
}
items, total, err := s.uc.Orders(ctx, page, pageSize, biz.PaymentOrderFilter{
Provider: req.Provider, TradeNo: req.TradeNo, BusinessType: req.BusinessType,
BusinessID: req.BusinessID, PaymentStatus: req.PaymentStatus, RefundStatus: req.RefundStatus,
})
if err != nil {
return nil, 0, page, pageSize, err
}
result := make([]*dto.PaymentOrderResponse, 0, len(items))
for _, item := range items {
converted, convertErr := paymentOrderResponse(item)
if convertErr != nil {
return nil, 0, page, pageSize, convertErr
}
result = append(result, converted)
}
return result, total, page, pageSize, nil
}
func paymentOrderResponse(order *biz.PaymentOrder) (*dto.PaymentOrderResponse, error) {
if order == nil {
return nil, errors.New("支付订单为空")
}
return &dto.PaymentOrderResponse{
ID: order.ID, Provider: order.Provider, TradeNo: order.TradeNo, ProviderTradeNo: order.ProviderTradeNo,
BusinessType: order.BusinessType, BusinessID: order.BusinessID, Subject: order.Subject,
PaymentMode: order.PaymentMode, OriginalAmount: order.OriginalAmount, Amount: order.Amount, PaidAmount: order.PaidAmount,
PayerPaidAmount: order.PayerPaidAmount, CashPaidAmount: order.CashPaidAmount, PointPaidAmount: order.PointPaidAmount,
DiscountAmount: order.DiscountAmount, ProviderDiscountAmount: order.ProviderDiscountAmount,
MerchantDiscountAmount: order.MerchantDiscountAmount, SettlementAmount: order.SettlementAmount,
Currency: order.Currency, PayerCurrency: order.PayerCurrency, AmountBreakdownKnown: order.AmountBreakdownKnown,
PaymentStatus: order.PaymentStatus, ProviderStatus: order.ProviderStatus, FulfillmentStatus: order.FulfillmentStatus,
RefundStatus: order.RefundStatus, RefundedAmount: order.RefundedAmount, RefundRequestedAmount: order.RefundRequestedAmount,
RefundNo: order.RefundNo, LastError: order.LastError, CreatedAt: order.CreatedAt, UpdatedAt: order.UpdatedAt,
PaidAt: order.PaidAt, FulfilledAt: order.FulfilledAt, RefundedAt: order.RefundedAt,
}, nil
}
func (s *PaymentService) Create(ctx context.Context, req *dto.PaymentRequest) (*dto.PaymentResultResponse, error) {
if req == nil {
return nil, errors.New("支付请求为空")

105
pkg/gormkit/logger.go Normal file
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...)
}
}

View File

@ -0,0 +1,37 @@
// Package pagination contains database-agnostic page calculations shared by
// repository implementations.
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)
}

21
web/src/api/payment.js Normal file
View File

@ -0,0 +1,21 @@
import service from '@/utils/request'
export const getPaymentConfigs = () => service({ url: '/payment/configs', method: 'get' })
export const savePaymentConfig = (data) => service({
url: '/payment/config',
method: 'post',
data
})
export const getPaymentOrders = (params) => service({
url: '/payment/orders',
method: 'get',
params
})
export const getPaymentOrder = (data) => service({
url: '/payment/order',
method: 'post',
data
})

View File

@ -0,0 +1,30 @@
<template>
<div class="kra-table-box">
<el-alert title="支付密钥只用于保存和调用渠道,列表接口会自动脱敏。提交 ****** 会保留原密钥。" type="warning" :closable="false" class="mb-4" />
<el-table :data="configs" row-key="provider" stripe>
<el-table-column prop="provider" label="渠道" width="160" />
<el-table-column label="状态" width="110"><template #default="scope"><el-switch v-model="scope.row.enabled" @change="save(scope.row)" /></template></el-table-column>
<el-table-column label="配置 JSON" min-width="520"><template #default="scope"><el-input v-model="scope.row.editor" type="textarea" :rows="4" spellcheck="false" /></template></el-table-column>
<el-table-column label="操作" width="110"><template #default="scope"><el-button type="primary" link icon="check" @click="save(scope.row)">保存</el-button></template></el-table-column>
</el-table>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { getPaymentConfigs, savePaymentConfig } from '@/api/payment'
const configs = ref([])
const load = async () => {
const res = await getPaymentConfigs()
if (res.code === 0) configs.value = (res.data || []).map((item) => ({ ...item, editor: JSON.stringify(item.config || {}, null, 2) }))
}
const save = async (row) => {
let config
try { config = JSON.parse(row.editor || '{}') } catch { ElMessage.error('配置必须是合法 JSON'); return }
const res = await savePaymentConfig({ provider: row.provider, enabled: row.enabled, config })
if (res.code === 0) { ElMessage.success('保存成功'); await load() }
}
load()
</script>

View File

@ -0,0 +1,55 @@
<template>
<div>
<div class="kra-search-box">
<el-form :inline="true" :model="searchInfo">
<el-form-item label="渠道"><el-input v-model="searchInfo.provider" placeholder="alipay / wechat-v3" clearable /></el-form-item>
<el-form-item label="商户订单号"><el-input v-model="searchInfo.tradeNo" clearable /></el-form-item>
<el-form-item label="业务类型"><el-input v-model="searchInfo.businessType" clearable /></el-form-item>
<el-form-item label="支付状态">
<el-select v-model="searchInfo.paymentStatus" clearable placeholder="全部">
<el-option label="待支付" value="pending" /><el-option label="已支付" value="paid" />
<el-option label="失败" value="failed" /><el-option label="已退款" value="refunded" />
</el-select>
</el-form-item>
<el-form-item><el-button type="primary" icon="search" @click="reload">查询</el-button><el-button icon="refresh" @click="reset">重置</el-button></el-form-item>
</el-form>
</div>
<div class="kra-table-box">
<el-table :data="rows" row-key="ID" stripe>
<el-table-column prop="tradeNo" label="商户订单号" min-width="190" show-overflow-tooltip />
<el-table-column prop="provider" label="渠道" width="120" />
<el-table-column prop="subject" label="商品/标题" min-width="180" show-overflow-tooltip />
<el-table-column label="金额" width="130"><template #default="scope">{{ scope.row.amount }} {{ scope.row.currency }}</template></el-table-column>
<el-table-column label="支付状态" width="110"><template #default="scope"><el-tag :type="statusType(scope.row.paymentStatus)">{{ statusText(scope.row.paymentStatus) }}</el-tag></template></el-table-column>
<el-table-column label="发货" width="110"><template #default="scope"><el-tag :type="scope.row.fulfillmentStatus === 'succeeded' ? 'success' : 'info'">{{ scope.row.fulfillmentStatus }}</el-tag></template></el-table-column>
<el-table-column label="退款" width="110"><template #default="scope">{{ scope.row.refundStatus }}</template></el-table-column>
<el-table-column prop="businessId" label="业务 ID" min-width="150" show-overflow-tooltip />
<el-table-column label="创建时间" width="180"><template #default="scope">{{ formatDate(scope.row.createdAt) }}</template></el-table-column>
</el-table>
<div class="kra-pagination"><el-pagination :current-page="page" :page-size="pageSize" :page-sizes="[10, 30, 50, 100]" :total="total" layout="total, sizes, prev, pager, next, jumper" @current-change="changePage" @size-change="changeSize" /></div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { getPaymentOrders } from '@/api/payment'
import { formatDate } from '@/utils/format'
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const rows = ref([])
const searchInfo = ref({})
const statusText = (status) => ({ initialized: '初始化', pending: '待支付', paid: '已支付', failed: '失败', closed: '已关闭', partially_refunded: '部分退款', refunded: '已退款' }[status] || status || '-')
const statusType = (status) => ({ paid: 'success', refunded: 'success', failed: 'danger', closed: 'info' }[status] || 'warning')
const load = async () => {
const res = await getPaymentOrders({ page: page.value, pageSize: pageSize.value, ...searchInfo.value })
if (res.code === 0) { rows.value = res.data.list || []; total.value = res.data.total || 0; page.value = res.data.page || page.value; pageSize.value = res.data.pageSize || pageSize.value }
}
const reload = () => { page.value = 1; load() }
const reset = () => { searchInfo.value = {}; reload() }
const changePage = (value) => { page.value = value; load() }
const changeSize = (value) => { pageSize.value = value; page.value = 1; load() }
load()
</script>