优化结构
This commit is contained in:
@@ -62,6 +62,10 @@ var genericPaymentFields = []IntegrationConfigField{
|
||||
integrationField("create_url", "下单接口", true, false, "url"),
|
||||
integrationField("query_url", "查单接口", true, false, "url"),
|
||||
integrationField("refund_url", "退款接口", true, false, "url"),
|
||||
integrationField("refund_status_field", "退款状态字段", false, false, "text"),
|
||||
integrationField("refund_success_values", "退款受理状态值", false, false, "text"),
|
||||
integrationField("refund_failure_values", "退款拒绝状态值", false, false, "text"),
|
||||
integrationField("refund_status_field", "退款状态字段", false, false, "text"),
|
||||
integrationField("app_key", "签名密钥", true, true, "password"),
|
||||
integrationField("query_status_field", "查单状态字段", true, false, "text"),
|
||||
integrationField("query_success_values", "查单成功值", true, false, "text"),
|
||||
|
||||
@@ -37,7 +37,11 @@ type LoginLog struct {
|
||||
ErrorMessage, Agent string
|
||||
UserID uint
|
||||
User *User
|
||||
FilterByStatus bool
|
||||
}
|
||||
|
||||
type LoginLogFilter struct {
|
||||
Username string
|
||||
Status *bool
|
||||
}
|
||||
|
||||
type DataAccessLog struct {
|
||||
@@ -101,7 +105,7 @@ type AuditQueryRepo interface {
|
||||
ListOperations(context.Context, int, int, *OperationRecord) ([]*OperationRecord, int64, error)
|
||||
FindOperation(context.Context, uint) (*OperationRecord, error)
|
||||
DeleteOperations(context.Context, []int) error
|
||||
ListLogins(context.Context, int, int, *LoginLog) ([]*LoginLog, int64, error)
|
||||
ListLogins(context.Context, int, int, *LoginLogFilter) ([]*LoginLog, int64, error)
|
||||
FindLogin(context.Context, uint) (*LoginLog, error)
|
||||
DeleteLogins(context.Context, []int) error
|
||||
ListDataAccess(context.Context, int, int, *DataAccessLog) ([]*DataAccessLog, int64, error)
|
||||
|
||||
@@ -8,6 +8,7 @@ var (
|
||||
ErrUserNotFound = errors.NotFound("USER_NOT_FOUND", "user not found")
|
||||
// ErrInvalidCredentials is returned for any failed login, regardless of
|
||||
// whether the user exists, to avoid leaking which accounts are registered.
|
||||
ErrInvalidCredentials = errors.Unauthorized("INVALID_CREDENTIALS", "invalid credentials")
|
||||
ErrUserDisabled = errors.Forbidden("USER_DISABLED", "user disabled")
|
||||
ErrInvalidCredentials = errors.Unauthorized("INVALID_CREDENTIALS", "invalid credentials")
|
||||
ErrAuthoritiesRequired = errors.BadRequest("AUTHORITIES_REQUIRED", "at least one authority is required")
|
||||
ErrUserDisabled = errors.Forbidden("USER_DISABLED", "user disabled")
|
||||
)
|
||||
|
||||
@@ -2,9 +2,21 @@ package system
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
exportIdentifierPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$`)
|
||||
exportParameterPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||
exportColumnPattern = regexp.MustCompile(`(?i)^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?(\s+AS\s+[A-Za-z_][A-Za-z0-9_]*)?$`)
|
||||
exportJoinOnPattern = regexp.MustCompile(`(?i)^[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*\s*=\s*[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*(\s+AND\s+[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*\s*=\s*[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*)*$`)
|
||||
)
|
||||
|
||||
type ExportCondition struct {
|
||||
ID uint
|
||||
CreatedAt, UpdatedAt time.Time
|
||||
@@ -43,3 +55,81 @@ type ExportRepo interface {
|
||||
type ExportUsecase struct{ ExportRepo }
|
||||
|
||||
func NewExportUsecase(repo ExportRepo) *ExportUsecase { return &ExportUsecase{ExportRepo: repo} }
|
||||
|
||||
func ValidateExportTemplate(value *ExportTemplate) error {
|
||||
if value == nil {
|
||||
return errors.New("导出模板为空")
|
||||
}
|
||||
if strings.TrimSpace(value.Name) == "" || strings.TrimSpace(value.TemplateID) == "" {
|
||||
return errors.New("导出模板名称和模板 ID 不能为空")
|
||||
}
|
||||
if !exportIdentifierPattern.MatchString(strings.TrimSpace(value.TableName)) {
|
||||
return errors.New("导出模板表名不合法")
|
||||
}
|
||||
if strings.TrimSpace(value.DBName) != "" && !exportIdentifierPattern.MatchString(strings.TrimSpace(value.DBName)) {
|
||||
return errors.New("导出模板数据库别名不合法")
|
||||
}
|
||||
if strings.TrimSpace(value.SQL) != "" || strings.TrimSpace(value.ImportSQL) != "" {
|
||||
return errors.New("导出模板不允许自定义 SQL")
|
||||
}
|
||||
var columns map[string]string
|
||||
if err := json.Unmarshal([]byte(value.TemplateInfo), &columns); err != nil {
|
||||
return fmt.Errorf("导出模板列定义无效: %w", err)
|
||||
}
|
||||
if len(columns) == 0 {
|
||||
return errors.New("导出模板列为空")
|
||||
}
|
||||
for column := range columns {
|
||||
if !exportColumnPattern.MatchString(strings.TrimSpace(column)) {
|
||||
return fmt.Errorf("导出模板列不合法: %s", column)
|
||||
}
|
||||
}
|
||||
for _, join := range value.Joins {
|
||||
kind := strings.ToUpper(strings.Join(strings.Fields(join.Join), " "))
|
||||
switch kind {
|
||||
case "JOIN", "INNER JOIN", "LEFT JOIN", "RIGHT JOIN":
|
||||
default:
|
||||
return fmt.Errorf("导出模板 Join 类型不合法: %s", join.Join)
|
||||
}
|
||||
if !exportIdentifierPattern.MatchString(strings.TrimSpace(join.Table)) || !exportJoinOnPattern.MatchString(strings.TrimSpace(join.On)) {
|
||||
return errors.New("导出模板 Join 表或条件不合法")
|
||||
}
|
||||
}
|
||||
for _, condition := range value.Conditions {
|
||||
if !exportParameterPattern.MatchString(strings.TrimSpace(condition.From)) || !exportIdentifierPattern.MatchString(strings.TrimSpace(condition.Column)) {
|
||||
return errors.New("导出模板筛选字段不合法")
|
||||
}
|
||||
switch strings.ToUpper(strings.Join(strings.Fields(condition.Operator), " ")) {
|
||||
case "=", "!=", "<>", ">", ">=", "<", "<=", "LIKE", "IN", "NOT IN", "BETWEEN":
|
||||
default:
|
||||
return fmt.Errorf("导出模板筛选运算符不合法: %s", condition.Operator)
|
||||
}
|
||||
}
|
||||
if value.Limit != nil && (*value.Limit < 1 || *value.Limit > 100000) {
|
||||
return errors.New("导出模板行数限制必须在 1 到 100000 之间")
|
||||
}
|
||||
if order := strings.TrimSpace(value.Order); order != "" {
|
||||
parts := strings.Fields(order)
|
||||
if len(parts) > 2 || !exportIdentifierPattern.MatchString(parts[0]) {
|
||||
return errors.New("导出模板排序不合法")
|
||||
}
|
||||
if len(parts) == 2 && !strings.EqualFold(parts[1], "asc") && !strings.EqualFold(parts[1], "desc") {
|
||||
return errors.New("导出模板排序方向不合法")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (uc *ExportUsecase) CreateExportTemplate(ctx context.Context, value *ExportTemplate) error {
|
||||
if err := ValidateExportTemplate(value); err != nil {
|
||||
return err
|
||||
}
|
||||
return uc.ExportRepo.CreateExportTemplate(ctx, value)
|
||||
}
|
||||
|
||||
func (uc *ExportUsecase) UpdateExportTemplate(ctx context.Context, value *ExportTemplate) error {
|
||||
if err := ValidateExportTemplate(value); err != nil {
|
||||
return err
|
||||
}
|
||||
return uc.ExportRepo.UpdateExportTemplate(ctx, value)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func validExportTemplateForTest() *ExportTemplate {
|
||||
return &ExportTemplate{Name: "users", TemplateID: "users", TableName: "sys_users", TemplateInfo: `{"id":"ID","username":"用户名"}`}
|
||||
}
|
||||
|
||||
func TestValidateExportTemplateRejectsCustomSQL(t *testing.T) {
|
||||
value := validExportTemplateForTest()
|
||||
value.SQL = "SELECT * FROM sys_users"
|
||||
if err := ValidateExportTemplate(value); err == nil || !strings.Contains(err.Error(), "不允许自定义 SQL") {
|
||||
t.Fatalf("ValidateExportTemplate() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateExportTemplateRejectsUnsafeJoinAndCondition(t *testing.T) {
|
||||
value := validExportTemplateForTest()
|
||||
value.Joins = []ExportJoin{{Join: "LEFT JOIN", Table: "sys_roles", On: "1=1 OR 1=1"}}
|
||||
if err := ValidateExportTemplate(value); err == nil {
|
||||
t.Fatal("ValidateExportTemplate accepted unsafe join")
|
||||
}
|
||||
}
|
||||
@@ -99,13 +99,6 @@ func (uc *SecurityUsecase) Current(ctx context.Context) (*SecurityConfig, error)
|
||||
uc.mu.RUnlock()
|
||||
value, err := uc.repo.SecurityConfig(ctx)
|
||||
if err != nil {
|
||||
// Return the default value together with the database-not-ready
|
||||
// error. Callers such as login/runtime policy consumers intentionally
|
||||
// ignore the error and continue with that default, while the HTTP
|
||||
// settings endpoint still reports the failure.
|
||||
if value != nil {
|
||||
return value, err
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
uc.mu.Lock()
|
||||
|
||||
@@ -155,7 +155,7 @@ func (uc *UserUsecase) Authorities(ctx context.Context) ([]*Authority, error) {
|
||||
}
|
||||
func (uc *UserUsecase) SetUserAuthorities(ctx context.Context, id uint, authorityIDs []uint) error {
|
||||
if len(authorityIDs) == 0 {
|
||||
return ErrInvalidCredentials
|
||||
return ErrAuthoritiesRequired
|
||||
}
|
||||
return uc.repo.SetUserAuthorities(ctx, id, authorityIDs)
|
||||
}
|
||||
|
||||
@@ -52,3 +52,10 @@ func TestLoginRejectsMissingUserWithoutPanic(t *testing.T) {
|
||||
t.Fatalf("Login() = %#v, %v; want nil, ErrInvalidCredentials", user, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetUserAuthoritiesRejectsEmptyListAsBadRequest(t *testing.T) {
|
||||
err := NewUserUsecase(&loginUserRepo{}).SetUserAuthorities(context.Background(), 1, nil)
|
||||
if !errors.Is(err, ErrAuthoritiesRequired) || errors.Is(err, ErrInvalidCredentials) {
|
||||
t.Fatalf("SetUserAuthorities() error = %v, want ErrAuthoritiesRequired", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,6 @@ type Config struct {
|
||||
Admin *Admin `mapstructure:"admin" yaml:"admin" json:"admin"`
|
||||
}
|
||||
|
||||
type Bootstrap = Config
|
||||
|
||||
type Server struct {
|
||||
HTTP *ServerHTTP `mapstructure:"http" yaml:"http" json:"http"`
|
||||
}
|
||||
|
||||
@@ -31,10 +31,6 @@ func (d *Data) persistConfig() error {
|
||||
func (d *Data) persistConfigValues(dataConfig *config.Data, adminConfig *config.Admin) error {
|
||||
d.configMu.Lock()
|
||||
defer d.configMu.Unlock()
|
||||
return d.persistConfigValuesLocked(dataConfig, adminConfig)
|
||||
}
|
||||
|
||||
func (d *Data) persistConfigValuesLocked(dataConfig *config.Data, adminConfig *config.Admin) error {
|
||||
if adminConfig == nil || adminConfig.ConfigPath == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
+4
-56
@@ -122,55 +122,6 @@ func (d *Data) RedisClient() redis.UniversalClient {
|
||||
return d.redis.load()
|
||||
}
|
||||
|
||||
// MongoClient returns the currently active Mongo client. The client remains
|
||||
// owned by Data and is only exposed here for framework-level resource wiring.
|
||||
func (d *Data) MongoClient() *mongo.Client {
|
||||
if d == nil || d.mongo == nil {
|
||||
return nil
|
||||
}
|
||||
return d.mongo.load()
|
||||
}
|
||||
|
||||
// NamedDatabases returns a copy of configured secondary database handles for
|
||||
// the process-wide resource registry. Data retains lifecycle ownership.
|
||||
func (d *Data) NamedDatabases() map[string]*gorm.DB {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
d.dbListMu.RLock()
|
||||
defer d.dbListMu.RUnlock()
|
||||
if len(d.dbList) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make(map[string]*gorm.DB, len(d.dbList))
|
||||
for name, db := range d.dbList {
|
||||
if name != "" && db != nil {
|
||||
result[name] = db
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// NamedRedisClients returns a copy of configured secondary Redis handles for
|
||||
// the process-wide resource registry. Data retains lifecycle ownership.
|
||||
func (d *Data) NamedRedisClients() map[string]redis.UniversalClient {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
d.redisListMu.RLock()
|
||||
defer d.redisListMu.RUnlock()
|
||||
if len(d.redisList) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make(map[string]redis.UniversalClient, len(d.redisList))
|
||||
for name, client := range d.redisList {
|
||||
if name != "" && client != nil {
|
||||
result[name] = client
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (d *Data) logger() *slog.Logger {
|
||||
if d != nil && d.appLogger != nil {
|
||||
return d.appLogger
|
||||
@@ -262,12 +213,9 @@ func NewData(runtime *config.Store, appLogger *slog.Logger, storageManager *stor
|
||||
}
|
||||
})
|
||||
}
|
||||
initialized := false
|
||||
defer func() {
|
||||
if !initialized {
|
||||
cleanup()
|
||||
}
|
||||
}()
|
||||
var undo rollback
|
||||
undo.add(cleanup)
|
||||
defer undo.run()
|
||||
usingFallback := !databaseConnectionConfigured(c.Database)
|
||||
var db *gorm.DB
|
||||
var err error
|
||||
@@ -348,7 +296,7 @@ func NewData(runtime *config.Store, appLogger *slog.Logger, storageManager *stor
|
||||
mongoClient = nil
|
||||
}
|
||||
d.mongo = newReloadable(mongoClient, closeMongoClient)
|
||||
initialized = true
|
||||
undo.commit()
|
||||
return d, cleanup, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -3,17 +3,11 @@ package payment
|
||||
import "kra/pkg/paymentkit"
|
||||
|
||||
func text(values map[string]any, key string) string {
|
||||
value, _ := values[key].(string)
|
||||
return value
|
||||
return paymentutil.Text(values, key)
|
||||
}
|
||||
|
||||
func firstAny(values map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value, ok := values[key].(string); ok && value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
return paymentutil.FirstText(values, keys...)
|
||||
}
|
||||
|
||||
// configuredInt64 keeps repository-side validation independent from the
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package provider
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
type Database interface {
|
||||
DB() *gorm.DB
|
||||
DatabaseReady() bool
|
||||
}
|
||||
@@ -96,7 +96,11 @@ func (r *apiTokenRepo) ListAPITokens(ctx context.Context, page, size int, userID
|
||||
}
|
||||
users := make(map[uint]*system.User, len(userPOs))
|
||||
for i := range userPOs {
|
||||
users[userPOs[i].ID] = baseBizUser(&userPOs[i])
|
||||
user, err := baseBizUser(&userPOs[i])
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
users[userPOs[i].ID] = user
|
||||
}
|
||||
out := make([]*system.APIToken, 0, len(pos))
|
||||
for _, po := range pos {
|
||||
|
||||
@@ -66,7 +66,11 @@ func (r *departmentRepo) attachDepartmentLeaders(ctx context.Context, department
|
||||
}
|
||||
leaderByID := make(map[uint]*system.User, len(leaders))
|
||||
for i := range leaders {
|
||||
leaderByID[leaders[i].ID] = baseBizUser(&leaders[i])
|
||||
user, err := baseBizUser(&leaders[i])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
leaderByID[leaders[i].ID] = user
|
||||
}
|
||||
for _, department := range departments {
|
||||
department.Leader = leaderByID[department.LeaderID]
|
||||
|
||||
@@ -37,16 +37,15 @@ func (r *auditRecorderRepo) CreateError(ctx context.Context, v *system.ErrorReco
|
||||
if v.Status == "" {
|
||||
v.Status = "未处理"
|
||||
}
|
||||
return r.data.DB().WithContext(ctx).Create(&errorRecordPO{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, Form: v.Form, Info: v.Info, Level: v.Level, RequestID: v.RequestID, TraceID: v.TraceID, Solution: v.Solution, Status: v.Status}).Error
|
||||
po := errorRecordPO{Form: v.Form, Info: v.Info, Level: v.Level, RequestID: v.RequestID, TraceID: v.TraceID, Solution: v.Solution, Status: v.Status}
|
||||
if err := r.data.DB().WithContext(ctx).Create(&po).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
v.ID, v.CreatedAt, v.UpdatedAt = po.ID, po.CreatedAt, po.UpdatedAt
|
||||
return nil
|
||||
}
|
||||
func (r *auditQueryRepo) UpdateError(ctx context.Context, v *system.ErrorRecord) error {
|
||||
updates := make(map[string]any, 9)
|
||||
if v.ID != 0 {
|
||||
updates["id"] = v.ID
|
||||
}
|
||||
if !v.CreatedAt.IsZero() {
|
||||
updates["created_at"] = v.CreatedAt
|
||||
}
|
||||
if v.Form != nil {
|
||||
updates["form"] = v.Form
|
||||
}
|
||||
|
||||
@@ -45,7 +45,27 @@ func TestCreateErrorBeforeDatabaseInitializationIsNoop(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateErrorPreservesNullableFieldsAndDefaultStatus(t *testing.T) {
|
||||
func TestCreateErrorOwnsServerIdentityAndTimestamps(t *testing.T) {
|
||||
recorder, query := newErrorRecordRepos(t)
|
||||
createdAt := time.Date(2025, time.March, 4, 5, 6, 7, 0, time.UTC)
|
||||
form := "前端"
|
||||
value := &system.ErrorRecord{ID: 41, CreatedAt: createdAt, Form: &form, Level: "error"}
|
||||
if err := recorder.CreateError(context.Background(), value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value.ID == 41 || value.CreatedAt.IsZero() || value.CreatedAt.Equal(createdAt) {
|
||||
t.Fatalf("created metadata = %+v", value)
|
||||
}
|
||||
stored, err := query.FindError(context.Background(), value.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.ID != value.ID || stored.CreatedAt.IsZero() || stored.CreatedAt.Equal(createdAt) {
|
||||
t.Fatalf("stored metadata = %+v", stored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateErrorGeneratesMetadataAndPreservesNullableFields(t *testing.T) {
|
||||
recorder, query := newErrorRecordRepos(t)
|
||||
createdAt := time.Date(2025, time.March, 4, 5, 6, 7, 0, time.UTC)
|
||||
form := "前端"
|
||||
@@ -58,7 +78,7 @@ func TestCreateErrorPreservesNullableFieldsAndDefaultStatus(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := query.FindError(context.Background(), 41)
|
||||
got, err := query.FindError(context.Background(), 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -71,8 +91,8 @@ func TestCreateErrorPreservesNullableFieldsAndDefaultStatus(t *testing.T) {
|
||||
if got.Status != "未处理" {
|
||||
t.Fatalf("status = %q, want 未处理", got.Status)
|
||||
}
|
||||
if !got.CreatedAt.Equal(createdAt) {
|
||||
t.Fatalf("created_at = %v, want %v", got.CreatedAt, createdAt)
|
||||
if got.ID == 41 || got.CreatedAt.IsZero() || got.CreatedAt.Equal(createdAt) {
|
||||
t.Fatalf("server metadata = ID %d, created_at %v", got.ID, got.CreatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +128,7 @@ func TestUpdateErrorDistinguishesOmittedAndExplicitEmptyFields(t *testing.T) {
|
||||
t.Fatalf("omitted fields changed: info=%#v solution=%#v", got.Info, got.Solution)
|
||||
}
|
||||
|
||||
originalCreatedAt := got.CreatedAt
|
||||
changedCreatedAt := time.Date(2024, time.January, 2, 3, 4, 5, 0, time.UTC)
|
||||
if err = query.UpdateError(context.Background(), &system.ErrorRecord{
|
||||
ID: id,
|
||||
@@ -133,7 +154,7 @@ func TestUpdateErrorDistinguishesOmittedAndExplicitEmptyFields(t *testing.T) {
|
||||
if got.RequestID != "request-new" || got.TraceID != "trace-new" {
|
||||
t.Fatalf("trace fields = (%q, %q), want updated values", got.RequestID, got.TraceID)
|
||||
}
|
||||
if !got.CreatedAt.Equal(changedCreatedAt) {
|
||||
t.Fatalf("created_at = %v, want %v", got.CreatedAt, changedCreatedAt)
|
||||
if !got.CreatedAt.Equal(originalCreatedAt) || got.CreatedAt.Equal(changedCreatedAt) {
|
||||
t.Fatalf("created_at = %v, want unchanged %v", got.CreatedAt, originalCreatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
+85
-183
@@ -11,7 +11,6 @@ import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type exportTemplatePO struct {
|
||||
@@ -78,9 +77,9 @@ func exportFromPO(po exportTemplatePO, conditions []exportConditionPO, joins []e
|
||||
return v
|
||||
}
|
||||
func exportToPO(v *system.ExportTemplate) exportTemplatePO {
|
||||
return exportTemplatePO{ID: v.ID, DBName: v.DBName, Name: v.Name, DBTableName: v.TableName, TemplateID: v.TemplateID, TemplateInfo: v.TemplateInfo, SQL: v.SQL, ImportSQL: v.ImportSQL, Limit: v.Limit, Order: v.Order}
|
||||
return exportTemplatePO{DBName: v.DBName, Name: v.Name, DBTableName: v.TableName, TemplateID: v.TemplateID, TemplateInfo: v.TemplateInfo, SQL: v.SQL, ImportSQL: v.ImportSQL, Limit: v.Limit, Order: v.Order}
|
||||
}
|
||||
func (r *exportRepo) saveRelations(tx *gorm.DB, v *system.ExportTemplate, resetIDs, replace, forceTemplateID bool) error {
|
||||
func (r *exportRepo) saveRelations(tx *gorm.DB, v *system.ExportTemplate, replace bool) error {
|
||||
if replace {
|
||||
if err := tx.Where("template_id = ?", v.TemplateID).Delete(&exportConditionPO{}).Error; err != nil {
|
||||
return err
|
||||
@@ -91,50 +90,19 @@ func (r *exportRepo) saveRelations(tx *gorm.DB, v *system.ExportTemplate, resetI
|
||||
}
|
||||
conditions := make([]exportConditionPO, 0, len(v.Conditions))
|
||||
for _, x := range v.Conditions {
|
||||
id := x.ID
|
||||
if resetIDs {
|
||||
id = 0
|
||||
}
|
||||
templateID := x.TemplateID
|
||||
// GORM's has-many association on Create always writes the parent's
|
||||
// TemplateID into the child foreign key (references:TemplateID).
|
||||
// Preserve that observable behavior for creates; updates intentionally
|
||||
// keep the request's value after the compatible update deletes and
|
||||
// recreates relations.
|
||||
if forceTemplateID || templateID == "" {
|
||||
templateID = v.TemplateID
|
||||
}
|
||||
conditions = append(conditions, exportConditionPO{ID: id, CreatedAt: x.CreatedAt, UpdatedAt: x.UpdatedAt, TemplateID: templateID, From: x.From, Column: x.Column, Operator: x.Operator})
|
||||
conditions = append(conditions, exportConditionPO{TemplateID: v.TemplateID, From: x.From, Column: x.Column, Operator: x.Operator})
|
||||
}
|
||||
if len(conditions) > 0 {
|
||||
create := tx
|
||||
if !resetIDs {
|
||||
// GORM saves Create associations with ON CONFLICT DO NOTHING.
|
||||
// Preserve that behavior for copied templates carrying relation IDs.
|
||||
create = create.Clauses(clause.OnConflict{DoNothing: true})
|
||||
}
|
||||
if err := create.Create(&conditions).Error; err != nil {
|
||||
if err := tx.Create(&conditions).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
joins := make([]exportJoinPO, 0, len(v.Joins))
|
||||
for _, x := range v.Joins {
|
||||
id := x.ID
|
||||
if resetIDs {
|
||||
id = 0
|
||||
}
|
||||
templateID := x.TemplateID
|
||||
if forceTemplateID || templateID == "" {
|
||||
templateID = v.TemplateID
|
||||
}
|
||||
joins = append(joins, exportJoinPO{ID: id, CreatedAt: x.CreatedAt, UpdatedAt: x.UpdatedAt, TemplateID: templateID, Join: x.Join, Table: x.Table, On: x.On})
|
||||
joins = append(joins, exportJoinPO{TemplateID: v.TemplateID, Join: x.Join, Table: x.Table, On: x.On})
|
||||
}
|
||||
if len(joins) > 0 {
|
||||
create := tx
|
||||
if !resetIDs {
|
||||
create = create.Clauses(clause.OnConflict{DoNothing: true})
|
||||
}
|
||||
return create.Create(&joins).Error
|
||||
return tx.Create(&joins).Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -145,7 +113,7 @@ func (r *exportRepo) CreateExportTemplate(ctx context.Context, v *system.ExportT
|
||||
return err
|
||||
}
|
||||
v.ID = po.ID
|
||||
return r.saveRelations(tx, v, false, false, true)
|
||||
return r.saveRelations(tx, v, false)
|
||||
})
|
||||
}
|
||||
func (r *exportRepo) UpdateExportTemplate(ctx context.Context, v *system.ExportTemplate) error {
|
||||
@@ -154,7 +122,7 @@ func (r *exportRepo) UpdateExportTemplate(ctx context.Context, v *system.ExportT
|
||||
if err := tx.Model(&exportTemplatePO{}).Where("id = ?", v.ID).Updates(&po).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return r.saveRelations(tx, v, true, true, false)
|
||||
return r.saveRelations(tx, v, true)
|
||||
})
|
||||
}
|
||||
func (r *exportRepo) DeleteExportTemplates(ctx context.Context, ids []uint) error {
|
||||
@@ -219,49 +187,70 @@ func (r *exportRepo) ListExportTemplates(ctx context.Context, page, size int, q
|
||||
}
|
||||
|
||||
func (r *exportRepo) QueryExport(ctx context.Context, t *system.ExportTemplate, params map[string]string) ([]map[string]any, string, error) {
|
||||
selected, err := r.data.Database(t.DBName)
|
||||
query, err := r.buildExportQuery(ctx, t, params)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
db := selected.WithContext(ctx)
|
||||
var rows []map[string]any
|
||||
if t.SQL != "" {
|
||||
args := map[string]any{}
|
||||
for k, v := range params {
|
||||
args[k] = v
|
||||
}
|
||||
err := db.Raw(t.SQL, args).Scan(&rows).Error
|
||||
return rows, t.SQL, err
|
||||
}
|
||||
preview := query.ToSQL(func(tx *gorm.DB) *gorm.DB { return tx.Find(&[]map[string]any{}) })
|
||||
err = query.Find(&rows).Error
|
||||
return rows, preview, err
|
||||
}
|
||||
|
||||
func exportColumns(raw string) ([]string, error) {
|
||||
columns := make([]string, 0)
|
||||
if err := json.Unmarshal([]byte(t.TemplateInfo), &map[string]string{}); err != nil {
|
||||
return nil, "", err
|
||||
decoder := json.NewDecoder(strings.NewReader(raw))
|
||||
if token, err := decoder.Token(); err != nil || token != json.Delim('{') {
|
||||
return nil, errors.New("导出模板列定义必须是 JSON 对象")
|
||||
}
|
||||
decoder := json.NewDecoder(strings.NewReader(t.TemplateInfo))
|
||||
_, _ = decoder.Token()
|
||||
for decoder.More() {
|
||||
key, _ := decoder.Token()
|
||||
columns = append(columns, key.(string))
|
||||
key, err := decoder.Token()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
column, ok := key.(string)
|
||||
if !ok {
|
||||
return nil, errors.New("导出模板列名无效")
|
||||
}
|
||||
columns = append(columns, column)
|
||||
var ignored any
|
||||
_ = decoder.Decode(&ignored)
|
||||
if err = decoder.Decode(&ignored); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if len(columns) == 0 {
|
||||
return nil, "", errors.New("模板列为空")
|
||||
return nil, errors.New("模板列为空")
|
||||
}
|
||||
return columns, nil
|
||||
}
|
||||
|
||||
func (r *exportRepo) buildExportQuery(ctx context.Context, t *system.ExportTemplate, params map[string]string) (*gorm.DB, error) {
|
||||
if err := system.ValidateExportTemplate(t); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
selected, err := r.data.Database(t.DBName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db := selected.WithContext(ctx)
|
||||
columns, err := exportColumns(t.TemplateInfo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query := db.Table(t.TableName).Select(strings.Join(columns, ","))
|
||||
for _, j := range t.Joins {
|
||||
query = query.Joins(j.Join + " " + j.Table + " ON " + j.On)
|
||||
query = query.Joins(strings.ToUpper(strings.Join(strings.Fields(j.Join), " ")) + " " + j.Table + " ON " + j.On)
|
||||
}
|
||||
if params["filterDeleted"] == "true" {
|
||||
query = query.Where(fmt.Sprintf("%s.deleted_at IS NULL", t.TableName))
|
||||
query = query.Where(t.TableName + ".deleted_at IS NULL")
|
||||
for _, join := range t.Joins {
|
||||
if db.Migrator().HasColumn(join.Table, "deleted_at") {
|
||||
query = query.Where(fmt.Sprintf("%s.deleted_at IS NULL", join.Table))
|
||||
query = query.Where(join.Table + ".deleted_at IS NULL")
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, condition := range t.Conditions {
|
||||
operator := condition.Operator
|
||||
operator := strings.ToUpper(strings.Join(strings.Fields(condition.Operator), " "))
|
||||
value := params[condition.From]
|
||||
switch operator {
|
||||
case "LIKE":
|
||||
@@ -270,7 +259,11 @@ func (r *exportRepo) QueryExport(ctx context.Context, t *system.ExportTemplate,
|
||||
}
|
||||
case "IN", "NOT IN":
|
||||
if value != "" {
|
||||
query = query.Where(condition.Column+" "+operator+" (?)", value)
|
||||
values := strings.Split(value, ",")
|
||||
for index := range values {
|
||||
values[index] = strings.TrimSpace(values[index])
|
||||
}
|
||||
query = query.Where(condition.Column+" "+operator+" ?", values)
|
||||
}
|
||||
case "BETWEEN":
|
||||
start, end := params["start"+condition.From], params["end"+condition.From]
|
||||
@@ -285,7 +278,10 @@ func (r *exportRepo) QueryExport(ctx context.Context, t *system.ExportTemplate,
|
||||
}
|
||||
limit := 0
|
||||
if raw := params["limit"]; raw != "" {
|
||||
limit, _ = strconv.Atoi(raw)
|
||||
limit, err = strconv.Atoi(raw)
|
||||
if err != nil || limit < 0 || limit > 100000 {
|
||||
return nil, errors.New("导出行数限制不合法")
|
||||
}
|
||||
} else if t.Limit != nil {
|
||||
limit = *t.Limit
|
||||
}
|
||||
@@ -293,139 +289,54 @@ func (r *exportRepo) QueryExport(ctx context.Context, t *system.ExportTemplate,
|
||||
query = query.Limit(limit)
|
||||
}
|
||||
if raw := params["offset"]; raw != "" {
|
||||
if offset, parseErr := strconv.Atoi(raw); parseErr == nil {
|
||||
query = query.Offset(offset)
|
||||
offset, parseErr := strconv.Atoi(raw)
|
||||
if parseErr != nil || offset < 0 {
|
||||
return nil, errors.New("导出偏移量不合法")
|
||||
}
|
||||
query = query.Offset(offset)
|
||||
}
|
||||
order := params["order"]
|
||||
if order == "" {
|
||||
order = t.Order
|
||||
}
|
||||
if order != "" {
|
||||
parts := strings.Split(order, " ")
|
||||
parts := strings.Fields(order)
|
||||
if len(parts) > 2 {
|
||||
return nil, errors.New("导出排序不合法")
|
||||
}
|
||||
fields := map[string]bool{}
|
||||
columnTypes, columnErr := db.Migrator().ColumnTypes(t.TableName)
|
||||
if columnErr != nil {
|
||||
return nil, "", columnErr
|
||||
return nil, columnErr
|
||||
}
|
||||
for _, column := range columnTypes {
|
||||
fields[column.Name()] = true
|
||||
}
|
||||
if !fields[parts[0]] {
|
||||
return nil, "", fmt.Errorf("order by %s is not in the fields", order)
|
||||
columnName := parts[0]
|
||||
if dot := strings.LastIndex(columnName, "."); dot >= 0 {
|
||||
columnName = columnName[dot+1:]
|
||||
}
|
||||
if !fields[columnName] {
|
||||
return nil, fmt.Errorf("order by %s is not in the fields", order)
|
||||
}
|
||||
orderSQL := parts[0]
|
||||
if len(parts) > 1 {
|
||||
if parts[1] != "asc" && parts[1] != "desc" {
|
||||
return nil, "", fmt.Errorf("order by %s is not secure", order)
|
||||
if !strings.EqualFold(parts[1], "asc") && !strings.EqualFold(parts[1], "desc") {
|
||||
return nil, fmt.Errorf("order by %s is not secure", order)
|
||||
}
|
||||
orderSQL += " " + parts[1]
|
||||
orderSQL += " " + strings.ToUpper(parts[1])
|
||||
}
|
||||
query = query.Order(orderSQL)
|
||||
}
|
||||
preview := query.ToSQL(func(tx *gorm.DB) *gorm.DB { return tx.Find(&[]map[string]any{}) })
|
||||
err = query.Find(&rows).Error
|
||||
return rows, preview, err
|
||||
return query, nil
|
||||
}
|
||||
|
||||
func (r *exportRepo) PreviewExport(ctx context.Context, t *system.ExportTemplate, params map[string]string) (string, error) {
|
||||
columns := make([]string, 0)
|
||||
decoder := json.NewDecoder(strings.NewReader(t.TemplateInfo))
|
||||
if token, err := decoder.Token(); err != nil || token != json.Delim('{') {
|
||||
query, err := r.buildExportQuery(ctx, t, params)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for decoder.More() {
|
||||
key, err := decoder.Token()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
columns = append(columns, key.(string))
|
||||
var ignored any
|
||||
if err = decoder.Decode(&ignored); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
var builder strings.Builder
|
||||
builder.WriteString("SELECT ")
|
||||
builder.WriteString(strings.Join(columns, ", "))
|
||||
builder.WriteString(" FROM ")
|
||||
builder.WriteString(t.TableName)
|
||||
for _, join := range t.Joins {
|
||||
builder.WriteString(" " + join.Join + " " + join.Table + " ON " + join.On)
|
||||
}
|
||||
wheres := make([]string, 0)
|
||||
if params["filterDeleted"] == "true" {
|
||||
wheres = append(wheres, fmt.Sprintf("%s.deleted_at IS NULL", t.TableName))
|
||||
selected, err := r.data.Database(t.DBName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
db := selected.WithContext(ctx)
|
||||
for _, join := range t.Joins {
|
||||
if db.Migrator().HasColumn(join.Table, "deleted_at") {
|
||||
wheres = append(wheres, fmt.Sprintf("%s.deleted_at IS NULL", join.Table))
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, condition := range t.Conditions {
|
||||
op, column, value := strings.ToUpper(strings.TrimSpace(condition.Operator)), strings.TrimSpace(condition.Column), params[condition.From]
|
||||
switch op {
|
||||
case "BETWEEN":
|
||||
start, end := params["start"+condition.From], params["end"+condition.From]
|
||||
if start != "" && end != "" {
|
||||
wheres = append(wheres, fmt.Sprintf("%s BETWEEN '%s' AND '%s'", column, start, end))
|
||||
} else {
|
||||
wheres = append(wheres, fmt.Sprintf("%s BETWEEN {start%s} AND {end%s}", column, condition.From, condition.From))
|
||||
}
|
||||
case "IN", "NOT IN":
|
||||
if value != "" {
|
||||
parts := strings.Split(value, ",")
|
||||
for index := range parts {
|
||||
parts[index] = strings.TrimSpace(parts[index])
|
||||
}
|
||||
wheres = append(wheres, fmt.Sprintf("%s %s ('%s')", column, op, strings.Join(parts, "','")))
|
||||
} else {
|
||||
wheres = append(wheres, fmt.Sprintf("%s %s ({%s})", column, op, condition.From))
|
||||
}
|
||||
case "LIKE":
|
||||
if value != "" {
|
||||
wheres = append(wheres, fmt.Sprintf("%s LIKE '%%%s%%'", column, value))
|
||||
} else {
|
||||
wheres = append(wheres, fmt.Sprintf("%s LIKE {%%%s%%}", column, condition.From))
|
||||
}
|
||||
default:
|
||||
if value != "" {
|
||||
wheres = append(wheres, fmt.Sprintf("%s %s '%s'", column, op, value))
|
||||
} else {
|
||||
wheres = append(wheres, fmt.Sprintf("%s %s {%s}", column, op, condition.From))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(wheres) > 0 {
|
||||
builder.WriteString(" WHERE " + strings.Join(wheres, " AND "))
|
||||
}
|
||||
order := params["order"]
|
||||
if order == "" {
|
||||
order = t.Order
|
||||
}
|
||||
if order != "" {
|
||||
builder.WriteString(" ORDER BY " + order)
|
||||
}
|
||||
limitRaw, offsetRaw := params["limit"], params["offset"]
|
||||
if limitRaw == "" && t.Limit != nil && *t.Limit != 0 {
|
||||
limitRaw = strconv.Itoa(*t.Limit)
|
||||
}
|
||||
limit, _ := strconv.Atoi(limitRaw)
|
||||
offset, _ := strconv.Atoi(offsetRaw)
|
||||
if limit > 0 {
|
||||
builder.WriteString(" LIMIT " + strconv.Itoa(limit))
|
||||
if offset > 0 {
|
||||
builder.WriteString(" OFFSET " + strconv.Itoa(offset))
|
||||
}
|
||||
} else if offset > 0 {
|
||||
builder.WriteString(" OFFSET " + strconv.Itoa(offset))
|
||||
}
|
||||
return builder.String(), nil
|
||||
return query.ToSQL(func(tx *gorm.DB) *gorm.DB { return tx.Find(&[]map[string]any{}) }), nil
|
||||
}
|
||||
func parseTemplateColumns(raw string) map[string]string {
|
||||
out := map[string]string{}
|
||||
@@ -439,23 +350,14 @@ func parseTemplateColumns(raw string) map[string]string {
|
||||
return out
|
||||
}
|
||||
func (r *exportRepo) ImportExportRows(ctx context.Context, t *system.ExportTemplate, rows []map[string]any) error {
|
||||
// ImportSQL is checked verbatim. In particular, a whitespace-only value
|
||||
// is still treated as custom SQL and is allowed to return the driver's
|
||||
// native error instead of silently falling back to GORM insertion.
|
||||
sql := t.ImportSQL
|
||||
if err := system.ValidateExportTemplate(t); err != nil {
|
||||
return err
|
||||
}
|
||||
selected, err := r.data.Database(t.DBName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return selected.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if sql != "" {
|
||||
for _, row := range rows {
|
||||
if err := tx.Exec(sql, row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
needCreated := tx.Migrator().HasColumn(t.TableName, "created_at")
|
||||
needUpdated := tx.Migrator().HasColumn(t.TableName, "updated_at")
|
||||
for _, row := range rows {
|
||||
|
||||
@@ -34,14 +34,14 @@ func (r *auditRecorderRepo) RecordLogin(ctx context.Context, v *system.LoginLog)
|
||||
func loginFromPO(v loginLogPO) *system.LoginLog {
|
||||
return &system.LoginLog{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, Username: v.Username, IP: v.IP, Status: v.Status, ErrorMessage: v.ErrorMessage, Agent: v.Agent, UserID: v.UserID}
|
||||
}
|
||||
func (r *auditQueryRepo) ListLogins(ctx context.Context, page, size int, q *system.LoginLog) ([]*system.LoginLog, int64, error) {
|
||||
func (r *auditQueryRepo) ListLogins(ctx context.Context, page, size int, q *system.LoginLogFilter) ([]*system.LoginLog, int64, error) {
|
||||
db := r.data.DB().WithContext(ctx).Model(&loginLogPO{})
|
||||
if q != nil {
|
||||
if q.Username != "" {
|
||||
db = db.Where("username LIKE ?", "%"+q.Username+"%")
|
||||
}
|
||||
if q.FilterByStatus {
|
||||
db = db.Where("status = ?", q.Status)
|
||||
if q.Status != nil {
|
||||
db = db.Where("status = ?", *q.Status)
|
||||
}
|
||||
}
|
||||
var total int64
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
bizsystem "kra/internal/biz/system"
|
||||
)
|
||||
|
||||
func TestListLoginsCanFilterFailedStatus(t *testing.T) {
|
||||
data := newTransactionTestData(t)
|
||||
ctx := context.Background()
|
||||
if err := data.gormDB.WithContext(ctx).Create(&[]loginLogPO{
|
||||
{Username: "ok", Status: true},
|
||||
{Username: "failed", Status: false},
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status := false
|
||||
items, total, err := (&auditQueryRepo{data: data}).ListLogins(ctx, 1, 20, &bizsystem.LoginLogFilter{Status: &status})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 1 || len(items) != 1 || items[0].Status {
|
||||
t.Fatalf("failed login filter = total %d items %#v", total, items)
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ func menuFromPO(po menuPO) *system.Menu {
|
||||
}
|
||||
|
||||
func menuToPO(v *system.Menu) menuPO {
|
||||
return menuPO{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, ParentID: v.ParentID, Path: v.Path, Name: v.Name, Hidden: v.Hidden, Component: v.Component, Sort: v.Sort, ActiveName: v.ActiveName, KeepAlive: v.KeepAlive, DefaultMenu: v.DefaultMenu, Title: v.Title, Icon: v.Icon, CloseTab: v.CloseTab, TransitionType: v.TransitionType}
|
||||
return menuPO{ID: v.ID, ParentID: v.ParentID, Path: v.Path, Name: v.Name, Hidden: v.Hidden, Component: v.Component, Sort: v.Sort, ActiveName: v.ActiveName, KeepAlive: v.KeepAlive, DefaultMenu: v.DefaultMenu, Title: v.Title, Icon: v.Icon, CloseTab: v.CloseTab, TransitionType: v.TransitionType}
|
||||
}
|
||||
|
||||
func replaceMenuRelations(tx *gorm.DB, menu *system.Menu) error {
|
||||
@@ -39,7 +39,7 @@ func replaceMenuRelations(tx *gorm.DB, menu *system.Menu) error {
|
||||
func createMenuRelations(tx *gorm.DB, menu *system.Menu) error {
|
||||
parameters := make([]menuParameterPO, 0, len(menu.Parameters))
|
||||
for _, parameter := range menu.Parameters {
|
||||
parameters = append(parameters, menuParameterPO{ID: parameter.ID, CreatedAt: parameter.CreatedAt, UpdatedAt: parameter.UpdatedAt, MenuID: menu.ID, Type: parameter.Type, Key: parameter.Key, Value: parameter.Value})
|
||||
parameters = append(parameters, menuParameterPO{ID: parameter.ID, MenuID: menu.ID, Type: parameter.Type, Key: parameter.Key, Value: parameter.Value})
|
||||
}
|
||||
if len(parameters) > 0 {
|
||||
if err := tx.Create(¶meters).Error; err != nil {
|
||||
@@ -48,7 +48,7 @@ func createMenuRelations(tx *gorm.DB, menu *system.Menu) error {
|
||||
}
|
||||
buttons := make([]menuButtonPO, 0, len(menu.Buttons))
|
||||
for _, button := range menu.Buttons {
|
||||
buttons = append(buttons, menuButtonPO{ID: button.ID, CreatedAt: button.CreatedAt, UpdatedAt: button.UpdatedAt, Name: button.Name, Description: button.Description, MenuID: menu.ID})
|
||||
buttons = append(buttons, menuButtonPO{ID: button.ID, Name: button.Name, Description: button.Description, MenuID: menu.ID})
|
||||
}
|
||||
if len(buttons) > 0 {
|
||||
return tx.Create(&buttons).Error
|
||||
@@ -89,6 +89,7 @@ func (r *menuRepo) Create(ctx context.Context, v *system.Menu) error {
|
||||
}
|
||||
}
|
||||
po := menuToPO(v)
|
||||
po.ID = 0
|
||||
if err := tx.Create(&po).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ func newMenuTestData(t *testing.T) *Data {
|
||||
return data
|
||||
}
|
||||
|
||||
func TestMenuCreatePreservesSubmittedTimestamps(t *testing.T) {
|
||||
func TestMenuCreateGeneratesServerTimestamps(t *testing.T) {
|
||||
data := newMenuTestData(t)
|
||||
ctx := context.Background()
|
||||
createdAt := time.Date(2025, time.January, 2, 3, 4, 5, 0, time.UTC)
|
||||
@@ -40,8 +40,8 @@ func TestMenuCreatePreservesSubmittedTimestamps(t *testing.T) {
|
||||
if err := data.gormDB.WithContext(ctx).First(&stored, menu.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !stored.CreatedAt.Equal(createdAt) || !stored.UpdatedAt.Equal(updatedAt) {
|
||||
t.Fatalf("stored timestamps = (%v, %v), want (%v, %v)", stored.CreatedAt, stored.UpdatedAt, createdAt, updatedAt)
|
||||
if stored.CreatedAt.IsZero() || stored.UpdatedAt.IsZero() || stored.CreatedAt.Equal(createdAt) || stored.UpdatedAt.Equal(updatedAt) {
|
||||
t.Fatalf("stored timestamps = (%v, %v), want server-generated values", stored.CreatedAt, stored.UpdatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,8 +114,8 @@ func TestMenuUpdateRebuildsRelationsWithoutChangingRequestedIdentity(t *testing.
|
||||
if parameter.MenuID != 10 || parameter.Type != "params" || parameter.Key != "new" || parameter.Value != "value" {
|
||||
t.Fatalf("parameter = %+v", parameter)
|
||||
}
|
||||
if !parameter.CreatedAt.Equal(createdAt) || !parameter.UpdatedAt.Equal(updatedAt) {
|
||||
t.Fatalf("parameter timestamps = (%v, %v), want (%v, %v)", parameter.CreatedAt, parameter.UpdatedAt, createdAt, updatedAt)
|
||||
if parameter.CreatedAt.Equal(createdAt) || parameter.UpdatedAt.Equal(updatedAt) {
|
||||
t.Fatalf("parameter timestamps = (%v, %v), want server-generated values", parameter.CreatedAt, parameter.UpdatedAt)
|
||||
}
|
||||
|
||||
var button menuButtonPO
|
||||
@@ -125,8 +125,8 @@ func TestMenuUpdateRebuildsRelationsWithoutChangingRequestedIdentity(t *testing.
|
||||
if button.MenuID != 10 || button.Name != "edit" || button.Description != "new" {
|
||||
t.Fatalf("button = %+v", button)
|
||||
}
|
||||
if !button.CreatedAt.Equal(createdAt) || !button.UpdatedAt.Equal(updatedAt) {
|
||||
t.Fatalf("button timestamps = (%v, %v), want (%v, %v)", button.CreatedAt, button.UpdatedAt, createdAt, updatedAt)
|
||||
if button.CreatedAt.Equal(createdAt) || button.UpdatedAt.Equal(updatedAt) {
|
||||
t.Fatalf("button timestamps = (%v, %v), want server-generated values", button.CreatedAt, button.UpdatedAt)
|
||||
}
|
||||
|
||||
var links int64
|
||||
|
||||
@@ -29,7 +29,7 @@ type parameterPO struct {
|
||||
func (parameterPO) TableName() string { return "sys_params" }
|
||||
|
||||
func (r *parameterRepo) CreateParameter(ctx context.Context, v *system.SystemParameter) error {
|
||||
po := parameterPO{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, Name: v.Name, Key: v.Key, Value: v.Value, Desc: v.Desc}
|
||||
po := parameterPO{Name: v.Name, Key: v.Key, Value: v.Value, Desc: v.Desc}
|
||||
if err := r.data.DB().WithContext(ctx).Create(&po).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -37,9 +37,7 @@ func (r *parameterRepo) CreateParameter(ctx context.Context, v *system.SystemPar
|
||||
return nil
|
||||
}
|
||||
func (r *parameterRepo) UpdateParameter(ctx context.Context, v *system.SystemParameter) error {
|
||||
// The compatible update uses a struct, so zero-value optional fields (notably desc)
|
||||
// are intentionally ignored rather than clearing an existing value.
|
||||
return r.data.DB().WithContext(ctx).Model(¶meterPO{}).Where("id = ?", v.ID).Updates(¶meterPO{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, Name: v.Name, Key: v.Key, Value: v.Value, Desc: v.Desc}).Error
|
||||
return r.data.DB().WithContext(ctx).Model(¶meterPO{}).Where("id = ?", v.ID).Updates(map[string]any{"name": v.Name, "key": v.Key, "value": v.Value, "desc": v.Desc}).Error
|
||||
}
|
||||
func (r *parameterRepo) DeleteParameters(ctx context.Context, ids []string) error {
|
||||
if len(ids) == 1 {
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParameterRepositoryPreservesModelMetadata(t *testing.T) {
|
||||
func TestParameterRepositoryOwnsModelMetadata(t *testing.T) {
|
||||
data := newTransactionTestData(t)
|
||||
repo := ¶meterRepo{data: data}
|
||||
createdAt := time.Date(2025, time.January, 2, 3, 4, 5, 0, time.Local)
|
||||
@@ -16,10 +16,11 @@ func TestParameterRepositoryPreservesModelMetadata(t *testing.T) {
|
||||
if err := repo.CreateParameter(context.Background(), value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value.ID != 41 || !value.CreatedAt.Equal(createdAt) {
|
||||
if value.ID == 0 || value.ID == 41 || value.CreatedAt.IsZero() || value.CreatedAt.Equal(createdAt) {
|
||||
t.Fatalf("created parameter metadata = ID %d, CreatedAt %v", value.ID, value.CreatedAt)
|
||||
}
|
||||
|
||||
originalCreatedAt := value.CreatedAt
|
||||
nextCreatedAt := createdAt.Add(time.Hour)
|
||||
value.CreatedAt = nextCreatedAt
|
||||
value.Name = "updated"
|
||||
@@ -27,10 +28,10 @@ func TestParameterRepositoryPreservesModelMetadata(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var persisted parameterPO
|
||||
if err := data.gormDB.WithContext(context.Background()).First(&persisted, 41).Error; err != nil {
|
||||
if err := data.gormDB.WithContext(context.Background()).First(&persisted, value.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if persisted.Name != "updated" || !persisted.CreatedAt.Equal(nextCreatedAt) {
|
||||
if persisted.Name != "updated" || !persisted.CreatedAt.Equal(originalCreatedAt) || persisted.CreatedAt.Equal(nextCreatedAt) {
|
||||
t.Fatalf("updated parameter = %+v", persisted)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"kra/internal/biz/system"
|
||||
dataprovider "kra/internal/data/provider"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -11,10 +12,7 @@ import (
|
||||
|
||||
// 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
|
||||
}
|
||||
type DatabaseProvider = dataprovider.Database
|
||||
|
||||
type SecurityConfigPO struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
@@ -62,9 +60,7 @@ func securityToPO(v *system.SecurityConfig) SecurityConfigPO {
|
||||
|
||||
func (r *securityRepo) SecurityConfig(ctx context.Context) (*system.SecurityConfig, error) {
|
||||
if !r.data.DatabaseReady() {
|
||||
po := DefaultSecurityConfig()
|
||||
po.ID = 0
|
||||
return securityFromPO(po), errors.New("数据库未初始化")
|
||||
return nil, errors.New("数据库未初始化")
|
||||
}
|
||||
db := r.data.DB().WithContext(ctx)
|
||||
var po SecurityConfigPO
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSecurityConfigReturnsNoValueWhenDatabaseIsUnready(t *testing.T) {
|
||||
data := newTransactionTestData(t)
|
||||
data.databaseReady.Store(false)
|
||||
value, err := (&securityRepo{data: data}).SecurityConfig(context.Background())
|
||||
if err == nil || value != nil {
|
||||
t.Fatalf("SecurityConfig() = %#v, %v; want nil and error", value, err)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"kra/internal/biz/system"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -62,9 +63,9 @@ func (r *userRepo) loadUser(ctx context.Context, po *userPO) (*system.User, erro
|
||||
for _, item := range authorityPOs {
|
||||
authorities = append(authorities, toBizAuthority(item))
|
||||
}
|
||||
setting := map[string]any(nil)
|
||||
if po.OriginSetting != "" {
|
||||
_ = json.Unmarshal([]byte(po.OriginSetting), &setting)
|
||||
setting, err := parseOriginSetting(po.OriginSetting)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var departmentPOs []departmentPO
|
||||
if err := r.data.DB().WithContext(ctx).Table("sys_departments").Joins("JOIN sys_user_departments ON sys_user_departments.sys_department_id = sys_departments.id").Where("sys_user_departments.sys_user_id = ? AND sys_departments.deleted_at IS NULL", po.ID).Find(&departmentPOs).Error; err != nil {
|
||||
@@ -104,12 +105,23 @@ func toBizAuthority(po authorityPO) system.Authority {
|
||||
return system.Authority{CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, DeletedAt: deletedAt, AuthorityID: po.AuthorityID, AuthorityName: po.AuthorityName, ParentID: po.ParentID, DataScope: po.DataScope, DefaultRouter: po.DefaultRouter}
|
||||
}
|
||||
|
||||
func baseBizUser(po *userPO) *system.User {
|
||||
func parseOriginSetting(raw string) (map[string]any, error) {
|
||||
setting := map[string]any(nil)
|
||||
if po.OriginSetting != "" {
|
||||
_ = json.Unmarshal([]byte(po.OriginSetting), &setting)
|
||||
if raw == "" {
|
||||
return setting, nil
|
||||
}
|
||||
return &system.User{ID: po.ID, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, UUID: po.UUID, Username: po.Username, Password: po.Password, NickName: po.NickName, HeaderImg: po.HeaderImg, AuthorityID: po.AuthorityID, DeptID: po.DeptID, Phone: po.Phone, Email: po.Email, Enable: po.Enable, OriginSetting: setting, MustChangePassword: po.MustChangePassword, PasswordUpdatedAt: po.PasswordUpdatedAt}
|
||||
if err := json.Unmarshal([]byte(raw), &setting); err != nil {
|
||||
return nil, fmt.Errorf("解析用户设置: %w", err)
|
||||
}
|
||||
return setting, nil
|
||||
}
|
||||
|
||||
func baseBizUser(po *userPO) (*system.User, error) {
|
||||
setting, err := parseOriginSetting(po.OriginSetting)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &system.User{ID: po.ID, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, UUID: po.UUID, Username: po.Username, Password: po.Password, NickName: po.NickName, HeaderImg: po.HeaderImg, AuthorityID: po.AuthorityID, DeptID: po.DeptID, Phone: po.Phone, Email: po.Email, Enable: po.Enable, OriginSetting: setting, MustChangePassword: po.MustChangePassword, PasswordUpdatedAt: po.PasswordUpdatedAt}, nil
|
||||
}
|
||||
|
||||
func (r *userRepo) loadUsers(ctx context.Context, pos []userPO) ([]*system.User, error) {
|
||||
@@ -189,7 +201,10 @@ func (r *userRepo) loadUsers(ctx context.Context, pos []userPO) ([]*system.User,
|
||||
users := make([]*system.User, 0, len(pos))
|
||||
for i := range pos {
|
||||
po := &pos[i]
|
||||
user := baseBizUser(po)
|
||||
user, err := baseBizUser(po)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user.Authority = toBizAuthority(authorityByID[po.AuthorityID])
|
||||
user.Authorities = make([]system.Authority, 0, len(authorityByUser[po.ID]))
|
||||
for _, id := range authorityByUser[po.ID] {
|
||||
@@ -376,7 +391,10 @@ func (r *userRepo) CreateUserWithAuthorities(ctx context.Context, user *system.U
|
||||
// query. The reference handler returns the request's authority IDs and
|
||||
// leaves related authority/department records at their zero values; keep
|
||||
// that response contract while retaining the persisted timestamps/UUID.
|
||||
created := baseBizUser(&po)
|
||||
created, err := baseBizUser(&po)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
created.Authorities = createdAuthorities
|
||||
return created, nil
|
||||
}
|
||||
@@ -521,16 +539,7 @@ func (r *userRepo) UpdatePassword(ctx context.Context, id uint, password string,
|
||||
return r.data.DB().WithContext(ctx).Model(&userPO{}).Where("id = ?", id).Updates(updates).Error
|
||||
}
|
||||
func (r *userRepo) ListAuthorities(ctx context.Context) ([]*system.Authority, error) {
|
||||
var pos []authorityPO
|
||||
if err := r.data.DB().WithContext(ctx).Order("authority_id").Find(&pos).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]*system.Authority, 0, len(pos))
|
||||
for _, po := range pos {
|
||||
a := toBizAuthority(po)
|
||||
result = append(result, &a)
|
||||
}
|
||||
return result, nil
|
||||
return (&authorityAccessRepo{data: r.data}).ListAuthorities(ctx)
|
||||
}
|
||||
|
||||
func (r *userRepo) SetUserAuthorities(ctx context.Context, id uint, authorityIDs []uint) error {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFindUserRejectsCorruptOriginSetting(t *testing.T) {
|
||||
data := newTransactionTestData(t)
|
||||
if err := data.gormDB.WithContext(context.Background()).Create(&userPO{Username: "broken-setting", Password: "hash", AuthorityID: 888, OriginSetting: "{"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := (&userRepo{data: data}).FindUserByID(context.Background(), 1); err == nil {
|
||||
t.Fatal("FindUserByID accepted corrupt OriginSetting")
|
||||
}
|
||||
}
|
||||
@@ -25,12 +25,6 @@ func SeedDefaults(ctx context.Context, db *gorm.DB, defaults []platformmodule.Ti
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
if len(defaults) == 0 {
|
||||
defaults = []platformmodule.TimedTask{
|
||||
{Name: "ClearDB", Description: "定时清理数据库过期日志", Spec: "@daily", MethodName: "ClearDB", Enabled: true},
|
||||
{Name: "CleanStaleUploads", Description: "定时清理过期大文件上传会话", Spec: "@hourly", MethodName: "CleanStaleUploads", Enabled: true},
|
||||
}
|
||||
}
|
||||
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
for _, item := range defaults {
|
||||
if strings.TrimSpace(item.Name) == "" {
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
package task
|
||||
|
||||
import "gorm.io/gorm"
|
||||
import dataprovider "kra/internal/data/provider"
|
||||
|
||||
// Provider is the minimal database seam required by the task tables.
|
||||
// Task persistence must not depend on the full data runtime or system repos.
|
||||
type Provider interface {
|
||||
DB() *gorm.DB
|
||||
DatabaseReady() bool
|
||||
}
|
||||
type Provider = dataprovider.Database
|
||||
|
||||
Vendored
+15
-5
@@ -89,15 +89,23 @@ func (s *Store) Delete(ctx context.Context, key string) error {
|
||||
|
||||
func (s *Store) Increment(ctx context.Context, key string, expiration time.Duration) (int64, error) {
|
||||
if client := s.client(); client != nil {
|
||||
if expiration > 0 {
|
||||
expirationMS := expiration.Milliseconds()
|
||||
if expirationMS < 1 {
|
||||
expirationMS = 1
|
||||
}
|
||||
const incrementWithTTL = `
|
||||
local value = redis.call('INCR', KEYS[1])
|
||||
if redis.call('PTTL', KEYS[1]) < 0 then
|
||||
redis.call('PEXPIRE', KEYS[1], ARGV[1])
|
||||
end
|
||||
return value`
|
||||
return client.Eval(ctx, incrementWithTTL, []string{key}, expirationMS).Int64()
|
||||
}
|
||||
value, err := client.Incr(ctx, key).Result()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if value == 1 && expiration > 0 {
|
||||
if err := client.Expire(ctx, key, expiration).Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
@@ -120,6 +128,8 @@ func (s *Store) Increment(ctx context.Context, key string, expiration time.Durat
|
||||
} else {
|
||||
entry.expiresAt = time.Time{}
|
||||
}
|
||||
} else if entry.expiresAt.IsZero() && expiration > 0 {
|
||||
entry.expiresAt = time.Now().Add(expiration)
|
||||
}
|
||||
s.memory[key] = entry
|
||||
return value, nil
|
||||
|
||||
+15
@@ -40,3 +40,18 @@ func TestStoreIncrementRecreatesExpiredKeyWithoutStaleExpiry(t *testing.T) {
|
||||
t.Fatalf("Get() after recreation = %q, %v, %v", value, ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreIncrementAddsTTLToExistingPersistentCounter(t *testing.T) {
|
||||
store := New(nil)
|
||||
ctx := context.Background()
|
||||
if err := store.Set(ctx, "counter", "5", 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value, err := store.Increment(ctx, "counter", time.Millisecond); err != nil || value != 6 {
|
||||
t.Fatalf("Increment() = %d, %v", value, err)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
if _, ok, err := store.Get(ctx, "counter"); err != nil || ok {
|
||||
t.Fatalf("persistent counter did not expire: ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/asn1"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -20,12 +25,16 @@ const (
|
||||
var (
|
||||
appleJWSLeafExtensionOID = asn1.ObjectIdentifier{1, 2, 840, 113635, 100, 6, 11, 1}
|
||||
appleJWSIntermediateExtensionOID = asn1.ObjectIdentifier{1, 2, 840, 113635, 100, 6, 2, 1}
|
||||
appleTrustedRootFingerprints = map[string]struct{}{
|
||||
"63343abfb89a6a03ebb57e9b3f5fa7be7c4f5c756f3017b3a8c488c3653e9179": {},
|
||||
}
|
||||
)
|
||||
|
||||
// validateAppleJWSChain binds the leaf certificate used by GoPay for JWS
|
||||
// signature verification to the intermediate and root certificates that
|
||||
// GoPay validates against its embedded Apple trust anchor.
|
||||
func validateAppleJWSChain(signedPayload string) error {
|
||||
return validateAppleJWSChainWithRoots(signedPayload, appleTrustedRootFingerprints)
|
||||
}
|
||||
|
||||
func validateAppleJWSChainWithRoots(signedPayload string, trustedRoots map[string]struct{}) error {
|
||||
headerSegment, payloadSegment, signatureSegment, err := splitAppleJWS(signedPayload)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -73,6 +82,10 @@ func validateAppleJWSChain(signedPayload string) error {
|
||||
}
|
||||
certificates = append(certificates, certificate)
|
||||
}
|
||||
rootFingerprint := sha256.Sum256(certificates[2].Raw)
|
||||
if _, trusted := trustedRoots[hex.EncodeToString(rootFingerprint[:])]; !trusted {
|
||||
return errors.New("Apple JWS 根证书不受信任")
|
||||
}
|
||||
if err = certificates[0].CheckSignatureFrom(certificates[1]); err != nil {
|
||||
return fmt.Errorf("Apple JWS 叶子证书不属于声明的证书链: %w", err)
|
||||
}
|
||||
@@ -101,19 +114,20 @@ func validateAppleJWSChain(signedPayload string) error {
|
||||
return errors.New("Apple JWS 中间证书缺少 Apple 签名扩展")
|
||||
}
|
||||
|
||||
if hasVerificationTime {
|
||||
roots := x509.NewCertPool()
|
||||
roots.AddCert(certificates[2])
|
||||
intermediates := x509.NewCertPool()
|
||||
intermediates.AddCert(certificates[1])
|
||||
if _, err = certificates[0].Verify(x509.VerifyOptions{
|
||||
Roots: roots,
|
||||
Intermediates: intermediates,
|
||||
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
|
||||
CurrentTime: verificationTime,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("验证 Apple JWS 证书链: %w", err)
|
||||
}
|
||||
if !hasVerificationTime {
|
||||
verificationTime = time.Now()
|
||||
}
|
||||
roots := x509.NewCertPool()
|
||||
roots.AddCert(certificates[2])
|
||||
intermediates := x509.NewCertPool()
|
||||
intermediates.AddCert(certificates[1])
|
||||
if _, err = certificates[0].Verify(x509.VerifyOptions{
|
||||
Roots: roots,
|
||||
Intermediates: intermediates,
|
||||
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
|
||||
CurrentTime: verificationTime,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("验证 Apple JWS 证书链: %w", err)
|
||||
}
|
||||
if certificates[0].IsCA {
|
||||
return errors.New("Apple JWS 叶子证书不能是 CA")
|
||||
@@ -121,6 +135,18 @@ func validateAppleJWSChain(signedPayload string) error {
|
||||
if certificates[0].KeyUsage != 0 && certificates[0].KeyUsage&x509.KeyUsageDigitalSignature == 0 {
|
||||
return errors.New("Apple JWS 叶子证书不允许数字签名")
|
||||
}
|
||||
publicKey, ok := certificates[0].PublicKey.(*ecdsa.PublicKey)
|
||||
if !ok || publicKey.Curve != elliptic.P256() {
|
||||
return errors.New("Apple JWS 叶子证书不是 P-256 ECDSA 公钥")
|
||||
}
|
||||
signature, err := base64.RawURLEncoding.DecodeString(signatureSegment)
|
||||
if err != nil || len(signature) != 64 {
|
||||
return errors.New("Apple JWS ES256 签名格式无效")
|
||||
}
|
||||
digest := sha256.Sum256([]byte(headerSegment + "." + payloadSegment))
|
||||
if !ecdsa.Verify(publicKey, digest[:], new(big.Int).SetBytes(signature[:32]), new(big.Int).SetBytes(signature[32:])) {
|
||||
return errors.New("Apple JWS 签名校验失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,12 @@ import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/asn1"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"math/big"
|
||||
"testing"
|
||||
@@ -16,7 +18,8 @@ import (
|
||||
|
||||
func TestValidateAppleJWSChain(t *testing.T) {
|
||||
chain := testAppleCertificateChain(t)
|
||||
if err := validateAppleJWSChain(testAppleJWS(t, "ES256", chain, time.Now())); err != nil {
|
||||
jws := testAppleJWS(t, "ES256", chain, time.Now())
|
||||
if err := validateAppleJWSChainWithRoots(jws, testAppleTrustedRoots(t, chain)); err != nil {
|
||||
t.Fatalf("validateAppleJWSChain() error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -25,17 +28,30 @@ func TestValidateAppleJWSChainRejectsUnrelatedLeaf(t *testing.T) {
|
||||
chain := testAppleCertificateChain(t)
|
||||
attackerChain := testAppleCertificateChain(t)
|
||||
chain[0] = attackerChain[0]
|
||||
if err := validateAppleJWSChain(testAppleJWS(t, "ES256", chain, time.Now())); err == nil {
|
||||
if err := validateAppleJWSChainWithRoots(testAppleJWS(t, "ES256", chain, time.Now()), testAppleTrustedRoots(t, chain)); err == nil {
|
||||
t.Fatal("validateAppleJWSChain() accepted a leaf outside the declared chain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAppleJWSChainRejectsInvalidSignature(t *testing.T) {
|
||||
chain := testAppleCertificateChain(t)
|
||||
jws := testAppleJWS(t, "ES256", chain, time.Now())
|
||||
header, payload, _, err := splitAppleJWS(jws)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tampered := header + "." + payload + "." + base64.RawURLEncoding.EncodeToString(make([]byte, 64))
|
||||
if err = validateAppleJWSChainWithRoots(tampered, testAppleTrustedRoots(t, chain)); err == nil {
|
||||
t.Fatal("validateAppleJWSChain accepted an invalid ES256 signature")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAppleJWSChainRejectsUnexpectedAlgorithmAndShape(t *testing.T) {
|
||||
chain := testAppleCertificateChain(t)
|
||||
if err := validateAppleJWSChain(testAppleJWS(t, "ES384", chain, time.Now())); err == nil {
|
||||
if err := validateAppleJWSChainWithRoots(testAppleJWS(t, "ES384", chain, time.Now()), testAppleTrustedRoots(t, chain)); err == nil {
|
||||
t.Fatal("validateAppleJWSChain() accepted a non-ES256 algorithm")
|
||||
}
|
||||
if err := validateAppleJWSChain(testAppleJWS(t, "ES256", chain[:2], time.Now())); err == nil {
|
||||
if err := validateAppleJWSChainWithRoots(testAppleJWS(t, "ES256", chain[:2], time.Now()), nil); err == nil {
|
||||
t.Fatal("validateAppleJWSChain() accepted an incomplete x5c chain")
|
||||
}
|
||||
}
|
||||
@@ -43,14 +59,14 @@ func TestValidateAppleJWSChainRejectsUnexpectedAlgorithmAndShape(t *testing.T) {
|
||||
func TestValidateAppleJWSChainRejectsAdditionalCertificates(t *testing.T) {
|
||||
chain := testAppleCertificateChain(t)
|
||||
chain = append(chain, chain[2])
|
||||
if err := validateAppleJWSChain(testAppleJWS(t, "ES256", chain, time.Now())); err == nil {
|
||||
if err := validateAppleJWSChainWithRoots(testAppleJWS(t, "ES256", chain, time.Now()), testAppleTrustedRoots(t, chain[:3])); err == nil {
|
||||
t.Fatal("validateAppleJWSChain() accepted additional x5c certificates")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAppleJWSChainRejectsNonAppleSigningCertificates(t *testing.T) {
|
||||
chain := testAppleCertificateChainWithoutAppleExtensions(t)
|
||||
if err := validateAppleJWSChain(testAppleJWS(t, "ES256", chain, time.Now())); err == nil {
|
||||
if err := validateAppleJWSChainWithRoots(testAppleJWS(t, "ES256", chain, time.Now()), testAppleTrustedRoots(t, chain)); err == nil {
|
||||
t.Fatal("validateAppleJWSChain() accepted certificates without Apple signing extensions")
|
||||
}
|
||||
}
|
||||
@@ -58,7 +74,7 @@ func TestValidateAppleJWSChainRejectsNonAppleSigningCertificates(t *testing.T) {
|
||||
func TestValidateAppleJWSChainUsesSignedDateForHistoricalPayload(t *testing.T) {
|
||||
signedAt := time.Now().AddDate(-2, 0, 0)
|
||||
chain := testAppleCertificateChainAt(t, signedAt, true)
|
||||
if err := validateAppleJWSChain(testAppleJWS(t, "ES256", chain, signedAt)); err != nil {
|
||||
if err := validateAppleJWSChainWithRoots(testAppleJWS(t, "ES256", chain, signedAt), testAppleTrustedRoots(t, chain)); err != nil {
|
||||
t.Fatalf("validateAppleJWSChain() rejected historical payload: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -78,17 +94,17 @@ func TestValidateAppleJWSChainUsesNestedSignedDateForNotification(t *testing.T)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outer := base64.RawURLEncoding.EncodeToString(header) + "." + base64.RawURLEncoding.EncodeToString(payload) + ".signature"
|
||||
if err := validateAppleJWSChain(outer); err != nil {
|
||||
outer := testAppleSignedJWS(t, header, payload, chain)
|
||||
if err := validateAppleJWSChainWithRoots(outer, testAppleTrustedRoots(t, chain)); err != nil {
|
||||
t.Fatalf("validateAppleJWSChain() rejected historical notification: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAppleJWSChainAcceptsExpiredChainWithoutSignedDate(t *testing.T) {
|
||||
func TestValidateAppleJWSChainRejectsExpiredChainWithoutSignedDate(t *testing.T) {
|
||||
signedAt := time.Now().AddDate(-2, 0, 0)
|
||||
chain := testAppleCertificateChainAt(t, signedAt, true)
|
||||
if err := validateAppleJWSChain(testAppleJWSWithoutDate(t, chain)); err != nil {
|
||||
t.Fatalf("validateAppleJWSChain() rejected an expired chain without signedDate: %v", err)
|
||||
if err := validateAppleJWSChainWithRoots(testAppleJWSWithoutDate(t, chain), testAppleTrustedRoots(t, chain)); err == nil {
|
||||
t.Fatal("validateAppleJWSChain() accepted an expired chain without signedDate")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +112,7 @@ func TestValidateAppleJWSChainRejectsBrokenChainWithoutSignedDate(t *testing.T)
|
||||
chain := testAppleCertificateChain(t)
|
||||
attackerChain := testAppleCertificateChain(t)
|
||||
chain[0] = attackerChain[0]
|
||||
if err := validateAppleJWSChain(testAppleJWSWithoutDate(t, chain)); err == nil {
|
||||
if err := validateAppleJWSChainWithRoots(testAppleJWSWithoutDate(t, chain), testAppleTrustedRoots(t, chain)); err == nil {
|
||||
t.Fatal("validateAppleJWSChain() accepted a broken chain without signedDate")
|
||||
}
|
||||
}
|
||||
@@ -169,11 +185,13 @@ func testAppleCertificateChainAt(t *testing.T, validAt time.Time, includeAppleEx
|
||||
}}
|
||||
}
|
||||
leafDER := testAppleCertificate(t, leafTemplate, intermediate, &leafKey.PublicKey, intermediateKey)
|
||||
return []string{
|
||||
chain := []string{
|
||||
base64.StdEncoding.EncodeToString(leafDER),
|
||||
base64.StdEncoding.EncodeToString(intermediateDER),
|
||||
base64.StdEncoding.EncodeToString(rootDER),
|
||||
}
|
||||
testAppleLeafKeys[chain[0]] = leafKey
|
||||
return chain
|
||||
}
|
||||
|
||||
func testAppleECDSAKey(t *testing.T) *ecdsa.PrivateKey {
|
||||
@@ -204,7 +222,7 @@ func testAppleJWS(t *testing.T, algorithm string, chain []string, signedAt time.
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(header) + "." + base64.RawURLEncoding.EncodeToString(payload) + ".signature"
|
||||
return testAppleSignedJWS(t, header, payload, chain)
|
||||
}
|
||||
|
||||
func testAppleJWSWithoutDate(t *testing.T, chain []string) string {
|
||||
@@ -217,5 +235,36 @@ func testAppleJWSWithoutDate(t *testing.T, chain []string) string {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(header) + "." + base64.RawURLEncoding.EncodeToString(payload) + ".signature"
|
||||
return testAppleSignedJWS(t, header, payload, chain)
|
||||
}
|
||||
|
||||
var testAppleLeafKeys = map[string]*ecdsa.PrivateKey{}
|
||||
|
||||
func testAppleSignedJWS(t *testing.T, header, payload []byte, chain []string) string {
|
||||
t.Helper()
|
||||
headerSegment := base64.RawURLEncoding.EncodeToString(header)
|
||||
payloadSegment := base64.RawURLEncoding.EncodeToString(payload)
|
||||
key := testAppleLeafKeys[chain[0]]
|
||||
if key == nil {
|
||||
t.Fatal("missing test Apple leaf key")
|
||||
}
|
||||
digest := sha256.Sum256([]byte(headerSegment + "." + payloadSegment))
|
||||
r, s, err := ecdsa.Sign(rand.Reader, key, digest[:])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
signature := make([]byte, 64)
|
||||
r.FillBytes(signature[:32])
|
||||
s.FillBytes(signature[32:])
|
||||
return headerSegment + "." + payloadSegment + "." + base64.RawURLEncoding.EncodeToString(signature)
|
||||
}
|
||||
|
||||
func testAppleTrustedRoots(t *testing.T, chain []string) map[string]struct{} {
|
||||
t.Helper()
|
||||
rootDER, err := base64.StdEncoding.DecodeString(chain[2])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fingerprint := sha256.Sum256(rootDER)
|
||||
return map[string]struct{}{hex.EncodeToString(fingerprint[:]): {}}
|
||||
}
|
||||
|
||||
@@ -56,17 +56,11 @@ func first(values map[string]string, keys ...string) string {
|
||||
}
|
||||
|
||||
func text(values map[string]any, key string) string {
|
||||
value, _ := values[key].(string)
|
||||
return value
|
||||
return paymentutil.Text(values, key)
|
||||
}
|
||||
|
||||
func firstAny(values map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value, ok := values[key].(string); ok && value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
return paymentutil.FirstText(values, keys...)
|
||||
}
|
||||
|
||||
// Compatibility shims keep channel adapters focused on protocol and
|
||||
|
||||
@@ -133,6 +133,35 @@ func (a *vendorPaymentAdapter) call(ctx context.Context, endpointKey string, pay
|
||||
}
|
||||
result := &bizpayment.PaymentResult{Provider: a.provider, Status: "created", TradeNo: tradeNo, Payload: ensureJSON(body)}
|
||||
if endpointKey != "query_url" {
|
||||
if object := jsonObject(body); object != nil {
|
||||
operation := strings.TrimSuffix(endpointKey, "_url")
|
||||
statusField := text(c, operation+"_status_field")
|
||||
if statusField == "" {
|
||||
statusField = text(c, "query_status_field")
|
||||
}
|
||||
state := ""
|
||||
if statusField != "" {
|
||||
state = stringAtPath(object, statusField)
|
||||
}
|
||||
if state == "" {
|
||||
for _, key := range []string{"status", "trade_status", "order_status", "pay_status", "refund_status"} {
|
||||
if value := stringAtPath(object, key); value != "" {
|
||||
state = value
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if state != "" {
|
||||
switch {
|
||||
case containsFold(configuredValues(c, operation+"_failure_values"), state):
|
||||
result.Status = "failed"
|
||||
case containsFold(configuredValues(c, operation+"_success_values"), state):
|
||||
result.Status = "created"
|
||||
default:
|
||||
result.Status = normalizePaymentStatus(state, "created")
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
object := jsonObject(body)
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
bizpayment "kra/internal/biz/payment"
|
||||
)
|
||||
|
||||
func TestChinaumsCallbackAuthorizationVerification(t *testing.T) {
|
||||
@@ -31,3 +36,23 @@ func TestChinaumsCallbackAuthorizationVerification(t *testing.T) {
|
||||
t.Fatalf("mismatched Chinaums AppId error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVendorRefundMapsBusinessFailureStatus(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"status":"FAIL","message":"rejected"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
adapter := &vendorPaymentAdapter{provider: "vendor", profile: vendorSFT}
|
||||
result, err := adapter.Refund(context.Background(), &bizpayment.PaymentRefundRequest{TradeNo: "trade-1", RefundNo: "refund-1", Amount: 100, TotalAmount: 100, Currency: "CNY"}, map[string]any{
|
||||
"refund_url": server.URL,
|
||||
"app_key": "secret",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Refund() error = %v", err)
|
||||
}
|
||||
if result == nil || result.Status != "failed" {
|
||||
t.Fatalf("Refund() result = %#v, want failed status", result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,8 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
dataintegration "kra/internal/data/integration"
|
||||
"kra/internal/routecatalog"
|
||||
"kra/internal/modules/surface"
|
||||
"kra/pkg/module"
|
||||
)
|
||||
|
||||
@@ -16,18 +14,7 @@ func Definition() module.Definition {
|
||||
Migrations: dataintegration.Migrations(),
|
||||
Surface: module.Surface{
|
||||
Menus: []module.Menu{{Name: "integrationConfig", Path: "integrationConfig", ParentName: "extensions", Component: "view/systemTools/integration/config.vue", Title: "通信集成", Icon: "connection", Sort: 8}},
|
||||
APIs: integrationAPIs(),
|
||||
APIs: surface.APIsForPrefix("/integration/configs", 5),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func integrationAPIs() []module.API {
|
||||
items := make([]module.API, 0, 5)
|
||||
for _, descriptor := range routecatalog.Descriptors() {
|
||||
if descriptor.Public || !strings.HasPrefix(descriptor.Path, "/integration/configs") || descriptor.Description == "" {
|
||||
continue
|
||||
}
|
||||
items = append(items, module.API{Path: descriptor.Path, Method: descriptor.Method, Group: descriptor.Group, Description: descriptor.Description})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
@@ -3,10 +3,8 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
datapayment "kra/internal/data/payment"
|
||||
"kra/internal/routecatalog"
|
||||
"kra/internal/modules/surface"
|
||||
"kra/pkg/module"
|
||||
)
|
||||
|
||||
@@ -20,18 +18,7 @@ func Definition() module.Definition {
|
||||
{Name: "paymentOrders", Path: "paymentOrders", ParentName: "extensions", Component: "view/systemTools/payment/orders.vue", Title: "支付订单", Icon: "wallet", Sort: 6},
|
||||
{Name: "paymentConfig", Path: "paymentConfig", ParentName: "extensions", Component: "view/systemTools/payment/config.vue", Title: "支付配置", Icon: "credit-card", Sort: 7},
|
||||
},
|
||||
APIs: paymentAPIs(),
|
||||
APIs: surface.APIsForPrefix("/payment", 10),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func paymentAPIs() []module.API {
|
||||
items := make([]module.API, 0, 10)
|
||||
for _, descriptor := range routecatalog.Descriptors() {
|
||||
if descriptor.Public || !strings.HasPrefix(descriptor.Path, "/payment") || descriptor.Description == "" {
|
||||
continue
|
||||
}
|
||||
items = append(items, module.API{Path: descriptor.Path, Method: descriptor.Method, Group: descriptor.Group, Description: descriptor.Description})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package surface
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"kra/internal/routecatalog"
|
||||
"kra/pkg/module"
|
||||
)
|
||||
|
||||
func APIsForPrefix(prefix string, capacity int) []module.API {
|
||||
items := make([]module.API, 0, capacity)
|
||||
for _, descriptor := range routecatalog.Descriptors() {
|
||||
if descriptor.Public || !strings.HasPrefix(descriptor.Path, prefix) || descriptor.Description == "" {
|
||||
continue
|
||||
}
|
||||
items = append(items, module.API{Path: descriptor.Path, Method: descriptor.Method, Group: descriptor.Group, Description: descriptor.Description})
|
||||
}
|
||||
return items
|
||||
}
|
||||
@@ -15,12 +15,17 @@ func NewAnnouncement(service *service.AnnouncementService) *Announcement {
|
||||
}
|
||||
|
||||
func (h *Announcement) Create(c *gin.Context) {
|
||||
claims := Claims(c)
|
||||
if claims == nil || claims.ID == 0 {
|
||||
NoAuth(c, "未登录或非法访问")
|
||||
return
|
||||
}
|
||||
var req dto.AnnouncementRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
Fail(c, err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.service.Create(c.Request.Context(), &req); err != nil {
|
||||
if err := h.service.Create(c.Request.Context(), claims.ID, &req); err != nil {
|
||||
Fail(c, "创建失败")
|
||||
return
|
||||
}
|
||||
@@ -44,12 +49,17 @@ func (h *Announcement) DeleteByIDs(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *Announcement) Update(c *gin.Context) {
|
||||
claims := Claims(c)
|
||||
if claims == nil || claims.ID == 0 {
|
||||
NoAuth(c, "未登录或非法访问")
|
||||
return
|
||||
}
|
||||
var req dto.AnnouncementRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
Fail(c, err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.service.Update(c.Request.Context(), &req); err != nil {
|
||||
if err := h.service.Update(c.Request.Context(), claims.ID, &req); err != nil {
|
||||
Fail(c, "更新失败")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -139,12 +139,17 @@ func (h *Media) Rename(c *gin.Context) {
|
||||
Write(c, CodeSuccess, gin.H{}, "编辑成功")
|
||||
}
|
||||
func (h *Media) ImportURLs(c *gin.Context) {
|
||||
claims := Claims(c)
|
||||
if claims == nil || claims.ID == 0 {
|
||||
NoAuth(c, "未登录或非法访问")
|
||||
return
|
||||
}
|
||||
var req []dto.ImportMediaRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
Fail(c, err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.service.ImportURLRequests(c.Request.Context(), req); err != nil {
|
||||
if err := h.service.ImportURLRequests(c.Request.Context(), claims.ID, req); err != nil {
|
||||
Fail(c, "导入URL失败")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"kra/internal/service"
|
||||
"kra/internal/service/dto"
|
||||
|
||||
@@ -11,6 +12,27 @@ type Menu struct{ service *service.MenuService }
|
||||
|
||||
func NewMenu(service *service.MenuService) *Menu { return &Menu{service: service} }
|
||||
|
||||
func validateMenuRequest(req *dto.MenuRequest) error {
|
||||
if req == nil {
|
||||
return errors.New("菜单参数不能为空")
|
||||
}
|
||||
checks := []struct {
|
||||
value string
|
||||
field string
|
||||
}{
|
||||
{req.Path, "Path"}, {req.Name, "Name"}, {req.Component, "Component"}, {req.Meta.Title, "Title"},
|
||||
}
|
||||
for _, check := range checks {
|
||||
if check.value == "" {
|
||||
return errors.New(check.field + "值不能为空")
|
||||
}
|
||||
}
|
||||
if req.Sort < 0 {
|
||||
return errors.New("Sort长度或值不在合法范围,ge=0")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Menu) List(c *gin.Context) {
|
||||
items, err := h.service.Tree(c.Request.Context())
|
||||
if err != nil {
|
||||
@@ -35,24 +57,8 @@ func (h *Menu) Create(c *gin.Context) {
|
||||
Fail(c, err.Error())
|
||||
return
|
||||
}
|
||||
if req.Path == "" {
|
||||
Fail(c, "Path值不能为空")
|
||||
return
|
||||
}
|
||||
if req.Name == "" {
|
||||
Fail(c, "Name值不能为空")
|
||||
return
|
||||
}
|
||||
if req.Component == "" {
|
||||
Fail(c, "Component值不能为空")
|
||||
return
|
||||
}
|
||||
if req.Sort < 0 {
|
||||
Fail(c, "Sort长度或值不在合法范围,ge=0")
|
||||
return
|
||||
}
|
||||
if req.Meta.Title == "" {
|
||||
Fail(c, "Title值不能为空")
|
||||
if err := validateMenuRequest(&req); err != nil {
|
||||
Fail(c, err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.service.Create(c.Request.Context(), &req); err != nil {
|
||||
@@ -68,24 +74,8 @@ func (h *Menu) Update(c *gin.Context) {
|
||||
Fail(c, err.Error())
|
||||
return
|
||||
}
|
||||
if req.Path == "" {
|
||||
Fail(c, "Path值不能为空")
|
||||
return
|
||||
}
|
||||
if req.Name == "" {
|
||||
Fail(c, "Name值不能为空")
|
||||
return
|
||||
}
|
||||
if req.Component == "" {
|
||||
Fail(c, "Component值不能为空")
|
||||
return
|
||||
}
|
||||
if req.Sort < 0 {
|
||||
Fail(c, "Sort长度或值不在合法范围,ge=0")
|
||||
return
|
||||
}
|
||||
if req.Meta.Title == "" {
|
||||
Fail(c, "Title值不能为空")
|
||||
if err := validateMenuRequest(&req); err != nil {
|
||||
Fail(c, err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.service.Update(c.Request.Context(), &req); err != nil {
|
||||
|
||||
@@ -165,7 +165,9 @@ func (h *Payment) Callback(c *gin.Context) {
|
||||
}
|
||||
ack, err := h.service.Callback(c.Request.Context(), &dto.PaymentCallbackRequest{Provider: c.Param("provider"), Headers: headers, Body: raw, Query: query})
|
||||
writePaymentCallbackAck(c, ack)
|
||||
_ = err
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func writePaymentCallbackAck(c *gin.Context, ack dto.PaymentCallbackAck) {
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -75,8 +74,6 @@ func OperationAudit(runtime *config.Store, recorder *service.AuditRecorder) gin.
|
||||
userID := uint(0)
|
||||
if claims := Claims(c); claims != nil {
|
||||
userID = claims.ID
|
||||
} else if value, err := strconv.ParseUint(c.GetHeader("x-user-id"), 10, 64); err == nil {
|
||||
userID = uint(value)
|
||||
}
|
||||
requestID, _ := c.Get("request_id")
|
||||
status := c.Writer.Status()
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
defaultCORSHeaders = "Content-Type,AccessToken,X-CSRF-Token, Authorization, Token,X-Token,X-User-Id"
|
||||
defaultCORSHeaders = "Content-Type,AccessToken,X-CSRF-Token, Authorization, Token,X-Token"
|
||||
defaultCORSMethods = "POST, GET, OPTIONS,DELETE,PUT"
|
||||
defaultCORSExpose = "Content-Length, Access-Control-Allow-Origin, Access-Control-Allow-Headers, Content-Type, New-Token, New-Expires-At"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultCORSHeadersDoNotAllowUserIdentityHeader(t *testing.T) {
|
||||
if strings.Contains(strings.ToLower(defaultCORSHeaders), "x-user-id") {
|
||||
t.Fatal("default CORS headers still allow the forgeable x-user-id header")
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,7 @@ func failureLogModule(path string) string {
|
||||
}
|
||||
|
||||
func expectedClientFailure(message string) bool {
|
||||
for _, value := range []string{"参数错误", "请输入用户名和密码", "验证码错误", "用户名不存在或者密码错误", "用户被禁止登录", "账号已锁定", "权限不足", "密码已过期", "未登录", "token", "令牌失效"} {
|
||||
for _, value := range []string{"参数错误", "请输入用户名和密码", "验证码错误", "用户名不存在或者密码错误", "用户被禁止登录", "账号已锁定", "请求太过频繁", "权限不足", "密码已过期", "未登录", "token", "令牌失效"} {
|
||||
if strings.Contains(message, value) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ type AnnouncementRequest struct {
|
||||
ID uint `json:"ID"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
UserID *int `json:"userID"`
|
||||
Attachments json.RawMessage `json:"attachments"`
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ type LoginLogSearchRequest struct {
|
||||
Page int `form:"page"`
|
||||
PageSize int `form:"pageSize"`
|
||||
Username string `form:"username"`
|
||||
Status bool `form:"status"`
|
||||
Status *bool `form:"status"`
|
||||
}
|
||||
|
||||
type AuditIDQuery struct {
|
||||
@@ -73,16 +73,14 @@ type ErrorRecordRequest struct {
|
||||
// fields preserve the distinction between an omitted value and an explicitly
|
||||
// supplied empty string, which is required by the update contract.
|
||||
type ErrorRecordMutationRequest struct {
|
||||
ID uint `json:"ID"`
|
||||
CreatedAt time.Time `json:"CreatedAt"`
|
||||
UpdatedAt time.Time `json:"UpdatedAt"`
|
||||
Form *string `json:"form" binding:"required"`
|
||||
Info *string `json:"info"`
|
||||
Level string `json:"level"`
|
||||
RequestID string `json:"request_id"`
|
||||
TraceID string `json:"trace_id"`
|
||||
Solution *string `json:"solution"`
|
||||
Status string `json:"status"`
|
||||
ID uint `json:"ID"`
|
||||
Form *string `json:"form" binding:"required"`
|
||||
Info *string `json:"info"`
|
||||
Level string `json:"level"`
|
||||
RequestID string `json:"request_id"`
|
||||
TraceID string `json:"trace_id"`
|
||||
Solution *string `json:"solution"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type OperationRecordResponse struct {
|
||||
|
||||
@@ -3,22 +3,18 @@ package dto
|
||||
import "time"
|
||||
|
||||
type ExportConditionRequest struct {
|
||||
ID uint `json:"ID"`
|
||||
CreatedAt time.Time `json:"CreatedAt"`
|
||||
UpdatedAt time.Time `json:"UpdatedAt"`
|
||||
TemplateID string `json:"templateID"`
|
||||
From string `json:"from"`
|
||||
Column string `json:"column"`
|
||||
Operator string `json:"operator"`
|
||||
ID uint `json:"ID"`
|
||||
TemplateID string `json:"templateID"`
|
||||
From string `json:"from"`
|
||||
Column string `json:"column"`
|
||||
Operator string `json:"operator"`
|
||||
}
|
||||
type ExportJoinRequest struct {
|
||||
ID uint `json:"ID"`
|
||||
CreatedAt time.Time `json:"CreatedAt"`
|
||||
UpdatedAt time.Time `json:"UpdatedAt"`
|
||||
TemplateID string `json:"templateID"`
|
||||
Join string `json:"joins"`
|
||||
Table string `json:"table"`
|
||||
On string `json:"on"`
|
||||
ID uint `json:"ID"`
|
||||
TemplateID string `json:"templateID"`
|
||||
Join string `json:"joins"`
|
||||
Table string `json:"table"`
|
||||
On string `json:"on"`
|
||||
}
|
||||
type ExportTemplateRequest struct {
|
||||
ID uint `json:"ID"`
|
||||
|
||||
@@ -77,7 +77,6 @@ type ImportMediaRequest struct {
|
||||
Size int64 `json:"size"`
|
||||
Mime string `json:"mime"`
|
||||
MD5 string `json:"md5"`
|
||||
UserID uint `json:"userId"`
|
||||
}
|
||||
type StorageListRequest struct {
|
||||
Prefix string `json:"prefix"`
|
||||
|
||||
@@ -13,27 +13,21 @@ type MenuMetaRequest struct {
|
||||
}
|
||||
|
||||
type MenuButtonRequest struct {
|
||||
CreatedAt time.Time `json:"CreatedAt"`
|
||||
UpdatedAt time.Time `json:"UpdatedAt"`
|
||||
ID uint `json:"ID"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"desc"`
|
||||
SysBaseMenuID uint `json:"sysBaseMenuID"`
|
||||
ID uint `json:"ID"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"desc"`
|
||||
SysBaseMenuID uint `json:"sysBaseMenuID"`
|
||||
}
|
||||
|
||||
type MenuParameterRequest struct {
|
||||
CreatedAt time.Time `json:"CreatedAt"`
|
||||
UpdatedAt time.Time `json:"UpdatedAt"`
|
||||
ID uint `json:"ID"`
|
||||
SysBaseMenuID uint `json:"SysBaseMenuID"`
|
||||
Type string `json:"type"`
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
ID uint `json:"ID"`
|
||||
SysBaseMenuID uint `json:"SysBaseMenuID"`
|
||||
Type string `json:"type"`
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type MenuRequest struct {
|
||||
CreatedAt time.Time `json:"CreatedAt"`
|
||||
UpdatedAt time.Time `json:"UpdatedAt"`
|
||||
ID uint `json:"ID"`
|
||||
ParentID uint `json:"parentId"`
|
||||
Path string `json:"path"`
|
||||
|
||||
@@ -40,13 +40,11 @@ type DictionaryDetailListRequest struct {
|
||||
Level *int
|
||||
}
|
||||
type SystemParameterRequest struct {
|
||||
ID uint `json:"ID" form:"ID"`
|
||||
CreatedAt time.Time `json:"CreatedAt" form:"CreatedAt"`
|
||||
UpdatedAt time.Time `json:"UpdatedAt" form:"UpdatedAt"`
|
||||
Name string `json:"name" form:"name" binding:"required"`
|
||||
Key string `json:"key" form:"key" binding:"required"`
|
||||
Value string `json:"value" form:"value" binding:"required"`
|
||||
Description string `json:"desc" form:"desc"`
|
||||
ID uint `json:"ID" form:"ID"`
|
||||
Name string `json:"name" form:"name" binding:"required"`
|
||||
Key string `json:"key" form:"key" binding:"required"`
|
||||
Value string `json:"value" form:"value" binding:"required"`
|
||||
Description string `json:"desc" form:"desc"`
|
||||
}
|
||||
|
||||
type SystemParameterSearchRequest struct {
|
||||
|
||||
@@ -19,16 +19,17 @@ func announcementDTO(item *system.Announcement) *dto.AnnouncementResponse {
|
||||
return &dto.AnnouncementResponse{ID: item.ID, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, DeletedAt: nil, Title: item.Title, Content: item.Content, UserID: item.UserID, Attachments: json.RawMessage(item.Attachments)}
|
||||
}
|
||||
|
||||
func announcementDO(in *dto.AnnouncementRequest) *system.Announcement {
|
||||
return &system.Announcement{ID: in.ID, Title: in.Title, Content: in.Content, UserID: in.UserID, Attachments: in.Attachments}
|
||||
func announcementDO(in *dto.AnnouncementRequest, userID uint) *system.Announcement {
|
||||
authorID := int(userID)
|
||||
return &system.Announcement{ID: in.ID, Title: in.Title, Content: in.Content, UserID: &authorID, Attachments: in.Attachments}
|
||||
}
|
||||
|
||||
func (s *AnnouncementService) Create(ctx context.Context, in *dto.AnnouncementRequest) error {
|
||||
return s.uc.Create(ctx, announcementDO(in))
|
||||
func (s *AnnouncementService) Create(ctx context.Context, userID uint, in *dto.AnnouncementRequest) error {
|
||||
return s.uc.Create(ctx, announcementDO(in, userID))
|
||||
}
|
||||
|
||||
func (s *AnnouncementService) Update(ctx context.Context, in *dto.AnnouncementRequest) error {
|
||||
return s.uc.Update(ctx, announcementDO(in))
|
||||
func (s *AnnouncementService) Update(ctx context.Context, userID uint, in *dto.AnnouncementRequest) error {
|
||||
return s.uc.Update(ctx, announcementDO(in, userID))
|
||||
}
|
||||
|
||||
func (s *AnnouncementService) Delete(ctx context.Context, id string) error {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"kra/internal/service/dto"
|
||||
)
|
||||
|
||||
func TestAnnouncementDomainUsesAuthenticatedUser(t *testing.T) {
|
||||
value := announcementDO(&dto.AnnouncementRequest{Title: "notice"}, 42)
|
||||
if value.UserID == nil || *value.UserID != 42 {
|
||||
t.Fatalf("announcement user = %#v, want 42", value.UserID)
|
||||
}
|
||||
}
|
||||
@@ -56,8 +56,8 @@ func (s *AuditService) DeleteOperations(ctx context.Context, ids []int) error {
|
||||
func (s *AuditRecorder) RecordLogin(ctx context.Context, v *system.LoginLog) error {
|
||||
return s.uc.RecordLogin(ctx, v)
|
||||
}
|
||||
func (s *AuditService) LoginsFilter(ctx context.Context, page, size int, username string, status bool) ([]*dto.LoginLogResponse, int64, error) {
|
||||
return s.Logins(ctx, page, size, &system.LoginLog{Username: username, Status: status, FilterByStatus: status})
|
||||
func (s *AuditService) LoginsFilter(ctx context.Context, page, size int, username string, status *bool) ([]*dto.LoginLogResponse, int64, error) {
|
||||
return s.Logins(ctx, page, size, &system.LoginLogFilter{Username: username, Status: status})
|
||||
}
|
||||
func loginDTO(v *system.LoginLog) *dto.LoginLogResponse {
|
||||
var user any = map[string]any{}
|
||||
@@ -66,7 +66,7 @@ func loginDTO(v *system.LoginLog) *dto.LoginLogResponse {
|
||||
}
|
||||
return &dto.LoginLogResponse{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, DeletedAt: nil, Username: v.Username, IP: v.IP, Status: v.Status, ErrorMessage: v.ErrorMessage, Agent: v.Agent, UserID: v.UserID, User: user}
|
||||
}
|
||||
func (s *AuditService) Logins(ctx context.Context, page, size int, q *system.LoginLog) ([]*dto.LoginLogResponse, int64, error) {
|
||||
func (s *AuditService) Logins(ctx context.Context, page, size int, q *system.LoginLogFilter) ([]*dto.LoginLogResponse, int64, error) {
|
||||
items, total, err := s.uc.ListLogins(ctx, page, size, q)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
|
||||
@@ -24,13 +24,15 @@ func recordedErrorDomain(value *dto.ErrorRecordRequest) *system.ErrorRecord {
|
||||
return result
|
||||
}
|
||||
func mutatedErrorDomain(value *dto.ErrorRecordMutationRequest) *system.ErrorRecord {
|
||||
return &system.ErrorRecord{ID: value.ID, CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, Form: value.Form, Info: value.Info, Level: value.Level, RequestID: value.RequestID, TraceID: value.TraceID, Solution: value.Solution, Status: value.Status}
|
||||
return &system.ErrorRecord{ID: value.ID, Form: value.Form, Info: value.Info, Level: value.Level, RequestID: value.RequestID, TraceID: value.TraceID, Solution: value.Solution, Status: value.Status}
|
||||
}
|
||||
func (s *AuditRecorder) CreateErrorRequest(ctx context.Context, req *dto.ErrorRecordRequest) error {
|
||||
return s.CreateError(ctx, recordedErrorDomain(req))
|
||||
}
|
||||
func (s *AuditRecorder) CreateErrorMutationRequest(ctx context.Context, req *dto.ErrorRecordMutationRequest) error {
|
||||
return s.CreateError(ctx, mutatedErrorDomain(req))
|
||||
value := mutatedErrorDomain(req)
|
||||
value.ID = 0
|
||||
return s.CreateError(ctx, value)
|
||||
}
|
||||
func (s *AuditService) UpdateErrorRequest(ctx context.Context, req *dto.ErrorRecordMutationRequest) error {
|
||||
return s.UpdateError(ctx, mutatedErrorDomain(req))
|
||||
|
||||
@@ -70,10 +70,10 @@ func (s *ExportService) ConsumeToken(ctx context.Context, token string, expectBl
|
||||
func exportTemplateDomain(value *dto.ExportTemplateRequest) *system.ExportTemplate {
|
||||
out := &system.ExportTemplate{ID: value.ID, DBName: value.DBName, Name: value.Name, TableName: value.TableName, TemplateID: value.TemplateID, TemplateInfo: value.TemplateInfo, SQL: value.SQL, ImportSQL: value.ImportSQL, Limit: value.Limit, Order: value.Order}
|
||||
for _, item := range value.Conditions {
|
||||
out.Conditions = append(out.Conditions, system.ExportCondition{ID: item.ID, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, TemplateID: item.TemplateID, From: item.From, Column: item.Column, Operator: item.Operator})
|
||||
out.Conditions = append(out.Conditions, system.ExportCondition{ID: item.ID, TemplateID: item.TemplateID, From: item.From, Column: item.Column, Operator: item.Operator})
|
||||
}
|
||||
for _, item := range value.Joins {
|
||||
out.Joins = append(out.Joins, system.ExportJoin{ID: item.ID, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, TemplateID: item.TemplateID, Join: item.Join, Table: item.Table, On: item.On})
|
||||
out.Joins = append(out.Joins, system.ExportJoin{ID: item.ID, TemplateID: item.TemplateID, Join: item.Join, Table: item.Table, On: item.On})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -4,9 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -69,15 +67,7 @@ func (s *ExportService) Export(ctx context.Context, tid string, params map[strin
|
||||
lookup = parts[1]
|
||||
}
|
||||
}
|
||||
value := fmt.Sprintf("%v", row[lookup])
|
||||
if timestamp, ok := row[lookup].(time.Time); ok {
|
||||
value = timestamp.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
if number, parseErr := strconv.ParseFloat(value, 64); parseErr == nil {
|
||||
_ = file.SetCellValue(sheet, cell, number)
|
||||
} else {
|
||||
_ = file.SetCellValue(sheet, cell, value)
|
||||
}
|
||||
_ = file.SetCellValue(sheet, cell, exportCellValue(row[lookup]))
|
||||
}
|
||||
}
|
||||
buffer, err := file.WriteToBuffer()
|
||||
@@ -87,6 +77,19 @@ func (s *ExportService) Export(ctx context.Context, tid string, params map[strin
|
||||
}
|
||||
return buffer.Bytes(), template.Name + ".xlsx", nil
|
||||
}
|
||||
|
||||
func exportCellValue(value any) any {
|
||||
switch typed := value.(type) {
|
||||
case time.Time:
|
||||
return typed.Format("2006-01-02 15:04:05")
|
||||
case []byte:
|
||||
return string(typed)
|
||||
case string:
|
||||
return typed
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
func (s *ExportService) ExportBlankTemplate(ctx context.Context, tid string) ([]byte, string, error) {
|
||||
template, err := s.uc.FindExportTemplate(ctx, 0, tid)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package system
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExportCellValuePreservesNumericText(t *testing.T) {
|
||||
value, ok := exportCellValue("007").(string)
|
||||
if !ok || value != "007" {
|
||||
t.Fatalf("exportCellValue() = %#v, want string 007", exportCellValue("007"))
|
||||
}
|
||||
}
|
||||
@@ -72,10 +72,10 @@ func (s *MediaService) Rename(ctx context.Context, id uint, name string) error {
|
||||
func (s *MediaService) ImportURLs(ctx context.Context, items []*system.MediaFile) error {
|
||||
return s.uc.CreateMediaBatch(ctx, items)
|
||||
}
|
||||
func (s *MediaService) ImportURLRequests(ctx context.Context, values []dto.ImportMediaRequest) error {
|
||||
func (s *MediaService) ImportURLRequests(ctx context.Context, userID uint, values []dto.ImportMediaRequest) error {
|
||||
items := make([]*system.MediaFile, 0, len(values))
|
||||
for _, value := range values {
|
||||
items = append(items, &system.MediaFile{Name: value.Name, CategoryID: value.ClassID, URL: value.URL, Tag: value.Tag, Key: value.Key, Size: value.Size, Mime: value.Mime, MD5: value.MD5, UserID: value.UserID})
|
||||
items = append(items, &system.MediaFile{Name: value.Name, CategoryID: value.ClassID, URL: value.URL, Tag: value.Tag, Key: value.Key, Size: value.Size, Mime: value.Mime, MD5: value.MD5, UserID: userID})
|
||||
}
|
||||
return s.ImportURLs(ctx, items)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
bizsystem "kra/internal/biz/system"
|
||||
"kra/internal/service/dto"
|
||||
)
|
||||
|
||||
type mediaImportRepo struct {
|
||||
bizsystem.MediaRepo
|
||||
items []*bizsystem.MediaFile
|
||||
}
|
||||
|
||||
func (r *mediaImportRepo) CreateMediaBatch(_ context.Context, items []*bizsystem.MediaFile) error {
|
||||
r.items = items
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestImportURLRequestsUsesAuthenticatedUser(t *testing.T) {
|
||||
repo := &mediaImportRepo{}
|
||||
service := NewMediaService(bizsystem.NewMediaUsecase(repo, nil, nil), nil)
|
||||
if err := service.ImportURLRequests(context.Background(), 42, []dto.ImportMediaRequest{{Name: "image", URL: "https://example.test/image.png"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(repo.items) != 1 || repo.items[0].UserID != 42 {
|
||||
t.Fatalf("imported media = %#v, want authenticated user 42", repo.items)
|
||||
}
|
||||
}
|
||||
@@ -75,14 +75,14 @@ func (s *MenuService) SetRoles(ctx context.Context, req *dto.SetMenuRolesRequest
|
||||
}
|
||||
|
||||
func menuDomain(v *dto.MenuRequest) *system.Menu {
|
||||
menu := &system.Menu{CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, ID: v.ID, ParentID: v.ParentID, Path: v.Path, Name: v.Name, Hidden: v.Hidden, Component: v.Component, Sort: v.Sort, ActiveName: v.Meta.ActiveName, KeepAlive: v.Meta.KeepAlive, DefaultMenu: v.Meta.DefaultMenu, Title: v.Meta.Title, Icon: v.Meta.Icon, CloseTab: v.Meta.CloseTab, TransitionType: v.Meta.TransitionType}
|
||||
menu := &system.Menu{ID: v.ID, ParentID: v.ParentID, Path: v.Path, Name: v.Name, Hidden: v.Hidden, Component: v.Component, Sort: v.Sort, ActiveName: v.Meta.ActiveName, KeepAlive: v.Meta.KeepAlive, DefaultMenu: v.Meta.DefaultMenu, Title: v.Meta.Title, Icon: v.Meta.Icon, CloseTab: v.Meta.CloseTab, TransitionType: v.Meta.TransitionType}
|
||||
menu.Buttons = make([]*system.MenuButton, 0, len(v.MenuButtons))
|
||||
for _, button := range v.MenuButtons {
|
||||
menu.Buttons = append(menu.Buttons, &system.MenuButton{CreatedAt: button.CreatedAt, UpdatedAt: button.UpdatedAt, ID: button.ID, Name: button.Name, Description: button.Description, MenuID: v.ID})
|
||||
menu.Buttons = append(menu.Buttons, &system.MenuButton{ID: button.ID, Name: button.Name, Description: button.Description, MenuID: v.ID})
|
||||
}
|
||||
menu.Parameters = make([]*system.MenuParameter, 0, len(v.Parameters))
|
||||
for _, parameter := range v.Parameters {
|
||||
menu.Parameters = append(menu.Parameters, &system.MenuParameter{CreatedAt: parameter.CreatedAt, UpdatedAt: parameter.UpdatedAt, ID: parameter.ID, MenuID: v.ID, Type: parameter.Type, Key: parameter.Key, Value: parameter.Value})
|
||||
menu.Parameters = append(menu.Parameters, &system.MenuParameter{ID: parameter.ID, MenuID: v.ID, Type: parameter.Type, Key: parameter.Key, Value: parameter.Value})
|
||||
}
|
||||
return menu
|
||||
}
|
||||
|
||||
@@ -4,32 +4,27 @@ import (
|
||||
"encoding/json"
|
||||
"kra/internal/biz/system"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"kra/internal/service/dto"
|
||||
)
|
||||
|
||||
func TestMenuDomainPreservesRelationIdentity(t *testing.T) {
|
||||
createdAt := time.Date(2025, time.March, 4, 5, 6, 7, 0, time.UTC)
|
||||
updatedAt := time.Date(2025, time.April, 5, 6, 7, 8, 0, time.UTC)
|
||||
menu := menuDomain(&dto.MenuRequest{
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
ID: 10,
|
||||
ID: 10,
|
||||
MenuButtons: []dto.MenuButtonRequest{{
|
||||
CreatedAt: createdAt, UpdatedAt: updatedAt, ID: 31, Name: "edit", Description: "编辑",
|
||||
ID: 31, Name: "edit", Description: "编辑",
|
||||
}},
|
||||
Parameters: []dto.MenuParameterRequest{{
|
||||
CreatedAt: createdAt, UpdatedAt: updatedAt, ID: 21, Type: "query", Key: "id", Value: "1",
|
||||
ID: 21, Type: "query", Key: "id", Value: "1",
|
||||
}},
|
||||
})
|
||||
if !menu.CreatedAt.Equal(createdAt) || !menu.UpdatedAt.Equal(updatedAt) {
|
||||
t.Fatalf("menu timestamps = (%v, %v), want (%v, %v)", menu.CreatedAt, menu.UpdatedAt, createdAt, updatedAt)
|
||||
if !menu.CreatedAt.IsZero() || !menu.UpdatedAt.IsZero() {
|
||||
t.Fatalf("menu timestamps = (%v, %v), want zero before persistence", menu.CreatedAt, menu.UpdatedAt)
|
||||
}
|
||||
if len(menu.Buttons) != 1 || menu.Buttons[0].ID != 31 || menu.Buttons[0].MenuID != 10 || !menu.Buttons[0].CreatedAt.Equal(createdAt) || !menu.Buttons[0].UpdatedAt.Equal(updatedAt) {
|
||||
if len(menu.Buttons) != 1 || menu.Buttons[0].ID != 31 || menu.Buttons[0].MenuID != 10 || !menu.Buttons[0].CreatedAt.IsZero() || !menu.Buttons[0].UpdatedAt.IsZero() {
|
||||
t.Fatalf("button conversion = %+v", menu.Buttons)
|
||||
}
|
||||
if len(menu.Parameters) != 1 || menu.Parameters[0].ID != 21 || menu.Parameters[0].MenuID != 10 || !menu.Parameters[0].CreatedAt.Equal(createdAt) || !menu.Parameters[0].UpdatedAt.Equal(updatedAt) {
|
||||
if len(menu.Parameters) != 1 || menu.Parameters[0].ID != 21 || menu.Parameters[0].MenuID != 10 || !menu.Parameters[0].CreatedAt.IsZero() || !menu.Parameters[0].UpdatedAt.IsZero() {
|
||||
t.Fatalf("parameter conversion = %+v", menu.Parameters)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ func NewParameterService(uc *system.ParameterUsecase) *ParameterService {
|
||||
}
|
||||
|
||||
func parameterDomain(value *dto.SystemParameterRequest) *system.SystemParameter {
|
||||
return &system.SystemParameter{ID: value.ID, CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, Name: value.Name, Key: value.Key, Value: value.Value, Desc: value.Description}
|
||||
return &system.SystemParameter{ID: value.ID, Name: value.Name, Key: value.Key, Value: value.Value, Desc: value.Description}
|
||||
}
|
||||
func (s *ParameterService) CreateParameterRequest(ctx context.Context, req *dto.SystemParameterRequest) error {
|
||||
return s.CreateParameter(ctx, parameterDomain(req))
|
||||
|
||||
@@ -55,6 +55,13 @@ func (s *SecurityService) ValidatePassword(ctx context.Context, password string)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.ValidatePasswordWithConfig(cfg, password)
|
||||
}
|
||||
|
||||
func (s *SecurityService) ValidatePasswordWithConfig(cfg *system.SecurityConfig, password string) error {
|
||||
if cfg == nil {
|
||||
return errors.New("安全配置不可用")
|
||||
}
|
||||
if err := s.uc.ValidatePassword(cfg, password); err != nil {
|
||||
return &PasswordPolicyError{Err: err}
|
||||
}
|
||||
|
||||
@@ -29,10 +29,13 @@ func (s *UserService) ListUsersRequest(ctx context.Context, value *dto.UserListR
|
||||
}
|
||||
|
||||
func (s *UserService) CreateUserRequest(ctx context.Context, value *dto.UserRequest) (*dto.UserResponse, error) {
|
||||
if err := s.settings.ValidatePassword(ctx, value.Password); err != nil {
|
||||
security, err := s.settings.CurrentSecurity(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.settings.ValidatePasswordWithConfig(security, value.Password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
security, _ := s.settings.CurrentSecurity(ctx)
|
||||
mustChange := security != nil && security.ForceNewUserChangePassword
|
||||
user, err := s.uc.CreateUser(ctx, &system.User{Username: value.Username, Password: value.Password, NickName: value.NickName, HeaderImg: value.HeaderImg, AuthorityID: value.AuthorityID, Phone: value.Phone, Email: value.Email, Enable: value.Enable, MustChangePassword: mustChange}, value.AuthorityIDs)
|
||||
if err != nil {
|
||||
|
||||
@@ -256,12 +256,17 @@ func (s *VersionService) ImportRequest(ctx context.Context, req *dto.ImportVersi
|
||||
if err := s.Import(ctx, bundle); err != nil {
|
||||
return err
|
||||
}
|
||||
raw, _ := json.Marshal(req)
|
||||
_ = s.uc.CreateVersion(ctx, &system.Version{
|
||||
raw, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return &system.VersionStageError{Stage: system.VersionStageJSON, Err: err}
|
||||
}
|
||||
if err = s.uc.CreateVersion(ctx, &system.Version{
|
||||
Name: versionPointer(req.Version.Name),
|
||||
Code: versionPointer(req.Version.Code + "_imported_" + time.Now().Format("20060102150405")),
|
||||
Description: versionPointer("导入版本: " + req.Version.Description),
|
||||
Data: versionPointer(string(raw)),
|
||||
})
|
||||
}); err != nil {
|
||||
return &system.VersionStageError{Stage: system.VersionStageSave, Err: err}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
bizsystem "kra/internal/biz/system"
|
||||
"kra/internal/service/dto"
|
||||
)
|
||||
|
||||
type versionImportRepo struct {
|
||||
bizsystem.VersionRepo
|
||||
saveErr error
|
||||
}
|
||||
|
||||
func (r *versionImportRepo) ImportVersionBundle(context.Context, *bizsystem.VersionBundle) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *versionImportRepo) CreateVersion(context.Context, *bizsystem.Version) error {
|
||||
return r.saveErr
|
||||
}
|
||||
|
||||
func TestImportRequestReturnsVersionAuditSaveFailure(t *testing.T) {
|
||||
repo := &versionImportRepo{saveErr: errors.New("database unavailable")}
|
||||
service := NewVersionService(bizsystem.NewVersionUsecase(repo))
|
||||
request := &dto.ImportVersionRequest{}
|
||||
request.Version.Name = "release"
|
||||
request.Version.Code = "v1"
|
||||
err := service.ImportRequest(context.Background(), request)
|
||||
var stageErr *bizsystem.VersionStageError
|
||||
if !errors.As(err, &stageErr) || stageErr.Stage != bizsystem.VersionStageSave {
|
||||
t.Fatalf("ImportRequest() error = %v, want save stage error", err)
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,8 @@ func TestReloadPreservesExistingScheduleOnPreparationFailure(t *testing.T) {
|
||||
repo := &workerTaskRepo{}
|
||||
scheduler := newTestTaskScheduler(repo)
|
||||
old := &taskbiz.TimedTask{ID: 1, Name: "old", Spec: "0 0 * * *", Enabled: true}
|
||||
if err := scheduler.Schedule(old); err != nil {
|
||||
repo.items = []*taskbiz.TimedTask{old}
|
||||
if err := scheduler.ScheduleID(context.Background(), old.ID); err != nil {
|
||||
t.Fatalf("schedule old task: %v", err)
|
||||
}
|
||||
|
||||
@@ -116,16 +117,17 @@ func assertOnlyScheduledTask(t *testing.T, scheduler *TaskScheduler, id uint) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentScheduleKeepsSingleEntry(t *testing.T) {
|
||||
scheduler := newTestTaskScheduler(&workerTaskRepo{})
|
||||
func TestConcurrentScheduleIDKeepsSingleEntry(t *testing.T) {
|
||||
repo := &workerTaskRepo{items: []*taskbiz.TimedTask{{ID: 7, Name: "same", Spec: "0 0 * * *", Enabled: true}}}
|
||||
scheduler := newTestTaskScheduler(repo)
|
||||
const workers = 32
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := scheduler.Schedule(&taskbiz.TimedTask{ID: 7, Name: "same", Spec: "0 0 * * *", Enabled: true}); err != nil {
|
||||
t.Errorf("Schedule() error = %v", err)
|
||||
if err := scheduler.ScheduleID(context.Background(), 7); err != nil {
|
||||
t.Errorf("ScheduleID() error = %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -325,34 +325,6 @@ func (s *TaskScheduler) run(ctx context.Context, task *taskbiz.TimedTask, trigge
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TaskScheduler) Schedule(task *taskbiz.TimedTask) error {
|
||||
if task == nil {
|
||||
return errors.New("定时任务不能为空")
|
||||
}
|
||||
var schedule cron.Schedule
|
||||
var err error
|
||||
if task.Enabled {
|
||||
schedule, err = parseTaskSchedule(task)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.runMu.Lock()
|
||||
stopping := s.stopping
|
||||
s.runMu.Unlock()
|
||||
if stopping {
|
||||
return errors.New("任务调度器正在停止")
|
||||
}
|
||||
s.removeLocked(task.ID)
|
||||
if !task.Enabled {
|
||||
return nil
|
||||
}
|
||||
s.scheduleLocked(cloneTimedTask(task), schedule)
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseTaskSchedule(task *taskbiz.TimedTask) (cron.Schedule, error) {
|
||||
if task.WithSeconds {
|
||||
return cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor).Parse(task.Spec)
|
||||
|
||||
Reference in New Issue
Block a user