优化结构

This commit is contained in:
Yvan
2026-08-27 22:33:43 +08:00
parent 5052835fcb
commit e407d3ac10
86 changed files with 3151 additions and 726 deletions
-4
View File
@@ -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
View File
@@ -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
}
+2 -8
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
package provider
import "gorm.io/gorm"
type Database interface {
DB() *gorm.DB
DatabaseReady() bool
}
+5 -1
View File
@@ -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 {
+5 -1
View File
@@ -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]
+6 -7
View File
@@ -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
}
+27 -6
View File
@@ -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
View File
@@ -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 {
+3 -3
View File
@@ -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)
}
}
+4 -3
View File
@@ -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(&parameters).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
}
+7 -7
View File
@@ -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
+2 -4
View File
@@ -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(&parameterPO{}).Where("id = ?", v.ID).Updates(&parameterPO{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(&parameterPO{}).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 {
+5 -4
View File
@@ -7,7 +7,7 @@ import (
"time"
)
func TestParameterRepositoryPreservesModelMetadata(t *testing.T) {
func TestParameterRepositoryOwnsModelMetadata(t *testing.T) {
data := newTransactionTestData(t)
repo := &parameterRepo{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)
}
}
+3 -7
View File
@@ -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)
}
}
+28 -19
View File
@@ -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")
}
}
-6
View File
@@ -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) == "" {
+2 -5
View File
@@ -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