优化结构
This commit is contained in:
parent
b88c220e20
commit
01b5c2f78a
File diff suppressed because it is too large
Load Diff
|
|
@ -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
3
go.mod
|
|
@ -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
4
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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("支付参数不完整")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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...)
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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...)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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{}, ¶meterPO{}, &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"},
|
||||
{¶meterPO{}, "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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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"},
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
package data
|
||||
package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
|
@ -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: "设置岗位成员(反向分配)"},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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("支付请求为空")
|
||||
|
|
|
|||
|
|
@ -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...)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue