diff --git a/cmd/kratos-admin/wire_gen.go b/cmd/kratos-admin/wire_gen.go index 67fe3f9..a8ed6fd 100644 --- a/cmd/kratos-admin/wire_gen.go +++ b/cmd/kratos-admin/wire_gen.go @@ -72,9 +72,9 @@ func wireApp(confServer *conf.Server, runtime *conf.Runtime, logger *slog.Logger cleanup() return nil, nil, err } - mediaUsecase := biz.NewMediaUsecase(mediaRepo, fileStorage) + mediaUsecase := biz.NewMediaUsecase(mediaRepo, fileStorage, runtimeSettings) taskExecutor := worker.NewTaskExecutor(taskUsecase, mediaUsecase, runtime) - taskScheduler := worker.NewTaskScheduler(taskUsecase, taskExecutor, logger) + taskScheduler := worker.NewTaskScheduler(taskUsecase, authorityUsecase, taskExecutor, logger) taskRuntime := worker.NewTaskRuntime(taskScheduler) taskApplicationUsecase := biz.NewTaskApplicationUsecase(taskUsecase, taskRuntime) taskService := service.NewTaskService(taskApplicationUsecase) diff --git a/configs/config.yaml b/configs/config.yaml index 649a3d2..e6f15f1 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -104,12 +104,16 @@ admin: # Upload chunks are stored below .chunks in the selected storage backend. session_ttl: 24 max_file_size: 0 + chunk_dir: .chunks system: use_redis: false use_multipoint: false use_strict_auth: false disable_auto_migrate: false use_mongo: false + addr: 8000 + iplimit_count: 0 + iplimit_time: 0 zap: level: info prefix: "[kra] " diff --git a/internal/biz/authority.go b/internal/biz/authority.go index 5961740..2806eaf 100644 --- a/internal/biz/authority.go +++ b/internal/biz/authority.go @@ -36,9 +36,20 @@ func (uc *AuthorityUsecase) AuthorityTree(ctx context.Context) ([]*Authority, er if item.ParentID != nil && *item.ParentID != 0 && byID[*item.ParentID] != nil { parent := byID[*item.ParentID] parent.Children = append(parent.Children, item) - } else { + } else if item.ParentID == nil || *item.ParentID == 0 { roots = append(roots, item) } } + // In GVA strict-authority mode a non-root actor receives its direct + // children as the top-level result even though their parent is omitted. + if len(roots) == 0 { + if actor, ok := ActorFromContext(ctx); ok { + for _, item := range items { + if item.ParentID != nil && *item.ParentID == actor.AuthorityID { + roots = append(roots, item) + } + } + } + } return roots, nil } diff --git a/internal/biz/infrastructure.go b/internal/biz/infrastructure.go index 72c4c24..a2f104f 100644 --- a/internal/biz/infrastructure.go +++ b/internal/biz/infrastructure.go @@ -17,10 +17,12 @@ type Cache interface { } type StoredFile struct { - Name string - Path string - URL string - Size int64 + Name string + Path string + URL string + Size int64 + LastModified time.Time + ContentType string } // FileStorage owns the persistence boundary for uploaded files. @@ -50,6 +52,7 @@ type CaptchaSettings struct { type MediaSettings struct { SessionTTL int MaxFileSize int64 + ChunkDir string } // RuntimeSettings exposes only the active values needed by the application. @@ -75,6 +78,7 @@ type AuthClaims struct { Username string NickName string AuthorityID uint + UserType string BufferTime time.Duration MustChangePwd bool Issuer string diff --git a/internal/biz/media.go b/internal/biz/media.go index 922be93..90bfc74 100644 --- a/internal/biz/media.go +++ b/internal/biz/media.go @@ -22,11 +22,12 @@ type MediaRepo interface { type MediaUsecase struct { MediaRepo - files FileStorage + files FileStorage + settings RuntimeSettings } -func NewMediaUsecase(repo MediaRepo, files FileStorage) *MediaUsecase { - return &MediaUsecase{MediaRepo: repo, files: files} +func NewMediaUsecase(repo MediaRepo, files FileStorage, settings RuntimeSettings) *MediaUsecase { + return &MediaUsecase{MediaRepo: repo, files: files, settings: settings} } var allowedMediaExtensions = map[string]bool{ diff --git a/internal/biz/media_upload.go b/internal/biz/media_upload.go index 4c7862a..1275ff4 100644 --- a/internal/biz/media_upload.go +++ b/internal/biz/media_upload.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "path" "path/filepath" "sort" "strings" @@ -15,6 +16,20 @@ import ( "github.com/google/uuid" ) +func (uc *MediaUsecase) chunkPrefix(uploadID uint) string { + directory := ".chunks" + if uc.settings != nil { + if configured := strings.Trim(uc.settings.MediaSettings().ChunkDir, "/\\ "); configured != "" { + directory = configured + } + } + return path.Join(directory, fmt.Sprintf("%d", uploadID)) +} + +func (uc *MediaUsecase) chunkKey(uploadID uint, index int) string { + return path.Join(uc.chunkPrefix(uploadID), fmt.Sprintf("%08d", index)) +} + func (uc *MediaUsecase) InitUpload(ctx context.Context, userID uint, name, hash string, size, chunkSize int64, total int) (*UploadSession, *MediaFile, []int, error) { if err := validateMediaName(name); err != nil { return nil, nil, nil, err @@ -57,7 +72,7 @@ func (uc *MediaUsecase) SaveChunk(ctx context.Context, userID, uploadID uint, in return errors.New("上传会话状态不允许收片") } hash := md5.New() - key := fmt.Sprintf(".chunks/%d/%08d", uploadID, index) + key := uc.chunkKey(uploadID, index) stored, err := uc.files.Put(ctx, key, io.TeeReader(reader, hash)) if err != nil { return err @@ -95,7 +110,7 @@ func (uc *MediaUsecase) CompleteUpload(ctx context.Context, userID, uploadID uin if chunk.Index != index { return fail(errors.New("分片序号不连续")) } - names = append(names, fmt.Sprintf(".chunks/%d/%08d", uploadID, index)) + names = append(names, uc.chunkKey(uploadID, index)) } ext := strings.ToLower(filepath.Ext(session.FileName)) key := time.Now().Format("20060102") + "/" + uuid.NewString() + ext @@ -114,7 +129,7 @@ func (uc *MediaUsecase) CompleteUpload(ctx context.Context, userID, uploadID uin } _ = uc.CompleteUploadSession(ctx, uploadID, key, media.ID) _ = uc.DeleteChunks(ctx, uploadID) - _ = uc.files.DeletePrefix(ctx, fmt.Sprintf(".chunks/%d", uploadID)) + _ = uc.files.DeletePrefix(ctx, uc.chunkPrefix(uploadID)) return media, nil } func (uc *MediaUsecase) CancelUpload(ctx context.Context, userID, uploadID uint) error { @@ -123,7 +138,7 @@ func (uc *MediaUsecase) CancelUpload(ctx context.Context, userID, uploadID uint) return errors.New("上传会话不存在或无权操作") } _ = uc.DeleteChunks(ctx, uploadID) - _ = uc.files.DeletePrefix(ctx, fmt.Sprintf(".chunks/%d", uploadID)) + _ = uc.files.DeletePrefix(ctx, uc.chunkPrefix(uploadID)) return uc.DeleteUploadSession(ctx, uploadID) } func (uc *MediaUsecase) CleanupStale(ctx context.Context, ttlHours int) error { @@ -138,7 +153,7 @@ func (uc *MediaUsecase) CleanupStale(ctx context.Context, ttlHours int) error { if err = uc.DeleteUploadData(ctx, id); err != nil { return err } - if err = uc.files.DeletePrefix(ctx, fmt.Sprintf(".chunks/%d", id)); err != nil { + if err = uc.files.DeletePrefix(ctx, uc.chunkPrefix(id)); err != nil { return err } } diff --git a/internal/biz/task.go b/internal/biz/task.go index 3312acc..eab02c7 100644 --- a/internal/biz/task.go +++ b/internal/biz/task.go @@ -91,8 +91,8 @@ type TaskRuntime interface { TriggerID(context.Context, uint) error NextRuns() map[uint]time.Time Reload(context.Context) error - Subscribe() chan []byte - Unsubscribe(chan []byte) + Subscribe(uint) chan []byte + Unsubscribe(uint, chan []byte) } type TaskUsecase struct{ TaskRepo } @@ -115,7 +115,7 @@ func (uc *TaskUsecase) Validate(value *TimedTask) error { switch value.ExecutorType { case TaskExecutorMethod: if !registeredTaskMethod(value.MethodName) { - return errors.New("方法未注册") + return fmt.Errorf("方法 %s 未注册", value.MethodName) } if len(value.Params) > 0 && !json.Valid(value.Params) { return errors.New("params 必须是合法 JSON") @@ -128,7 +128,7 @@ func (uc *TaskUsecase) Validate(value *TimedTask) error { if len(value.HTTPHeader) > 0 { headers := map[string]string{} if json.Unmarshal(value.HTTPHeader, &headers) != nil { - return errors.New("httpHeader 必须是 JSON 对象") + return errors.New(`httpHeader 必须是 {"Key":"Value"} 形式的 JSON 对象`) } } default: @@ -229,7 +229,9 @@ func (uc *TaskApplicationUsecase) Reload(ctx context.Context) error { return uc.runtime.Reload(ctx) } -func (uc *TaskApplicationUsecase) Subscribe() chan []byte { return uc.runtime.Subscribe() } -func (uc *TaskApplicationUsecase) Unsubscribe(events chan []byte) { - uc.runtime.Unsubscribe(events) +func (uc *TaskApplicationUsecase) Subscribe(userID uint) chan []byte { + return uc.runtime.Subscribe(userID) +} +func (uc *TaskApplicationUsecase) Unsubscribe(userID uint, events chan []byte) { + uc.runtime.Unsubscribe(userID, events) } diff --git a/internal/conf/conf.pb.go b/internal/conf/conf.pb.go index 38bec0e..7874847 100644 --- a/internal/conf/conf.pb.go +++ b/internal/conf/conf.pb.go @@ -1168,6 +1168,7 @@ type AdminBackend_Media struct { state protoimpl.MessageState `protogen:"open.v1"` SessionTtl int32 `protobuf:"varint,1,opt,name=session_ttl,json=sessionTtl,proto3" json:"session_ttl,omitempty"` MaxFileSize int64 `protobuf:"varint,2,opt,name=max_file_size,json=maxFileSize,proto3" json:"max_file_size,omitempty"` + ChunkDir string `protobuf:"bytes,3,opt,name=chunk_dir,json=chunkDir,proto3" json:"chunk_dir,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1216,6 +1217,13 @@ func (x *AdminBackend_Media) GetMaxFileSize() int64 { return 0 } +func (x *AdminBackend_Media) GetChunkDir() string { + if x != nil { + return x.ChunkDir + } + return "" +} + type AdminBackend_Disk struct { state protoimpl.MessageState `protogen:"open.v1"` MountPoint string `protobuf:"bytes,1,opt,name=mount_point,json=mountPoint,proto3" json:"mount_point,omitempty"` @@ -1267,6 +1275,9 @@ type AdminBackend_System struct { UseStrictAuth bool `protobuf:"varint,3,opt,name=use_strict_auth,json=useStrictAuth,proto3" json:"use_strict_auth,omitempty"` DisableAutoMigrate bool `protobuf:"varint,4,opt,name=disable_auto_migrate,json=disableAutoMigrate,proto3" json:"disable_auto_migrate,omitempty"` UseMongo bool `protobuf:"varint,5,opt,name=use_mongo,json=useMongo,proto3" json:"use_mongo,omitempty"` + Addr int32 `protobuf:"varint,6,opt,name=addr,proto3" json:"addr,omitempty"` + IplimitCount int32 `protobuf:"varint,7,opt,name=iplimit_count,json=iplimitCount,proto3" json:"iplimit_count,omitempty"` + IplimitTime int32 `protobuf:"varint,8,opt,name=iplimit_time,json=iplimitTime,proto3" json:"iplimit_time,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1336,6 +1347,27 @@ func (x *AdminBackend_System) GetUseMongo() bool { return false } +func (x *AdminBackend_System) GetAddr() int32 { + if x != nil { + return x.Addr + } + return 0 +} + +func (x *AdminBackend_System) GetIplimitCount() int32 { + if x != nil { + return x.IplimitCount + } + return 0 +} + +func (x *AdminBackend_System) GetIplimitTime() int32 { + if x != nil { + return x.IplimitTime + } + return 0 +} + type AdminBackend_Zap struct { state protoimpl.MessageState `protogen:"open.v1"` Level string `protobuf:"bytes,1,opt,name=level,proto3" json:"level,omitempty"` @@ -2054,7 +2086,7 @@ const file_conf_conf_proto_rawDesc = "" + "\x12connect_timeout_ms\x18\n" + " \x01(\x03R\x10connectTimeoutMs\x12\x15\n" + "\x06is_zap\x18\v \x01(\bR\x05isZap\x120\n" + - "\x05hosts\x18\f \x03(\v2\x1a.kratos.api.Data.MongoHostR\x05hosts\"\xb4\x1b\n" + + "\x05hosts\x18\f \x03(\v2\x1a.kratos.api.Data.MongoHostR\x05hosts\"\xad\x1c\n" + "\fAdminBackend\x12#\n" + "\rrouter_prefix\x18\x01 \x01(\tR\frouterPrefix\x12.\n" + "\x03jwt\x18\x02 \x01(\v2\x1c.kratos.api.AdminBackend.JWTR\x03jwt\x12:\n" + @@ -2097,20 +2129,24 @@ const file_conf_conf_proto_rawDesc = "" + "\bnickname\x18\x05 \x01(\tR\bnickname\x12\x12\n" + "\x04port\x18\x06 \x01(\x05R\x04port\x12\x15\n" + "\x06is_ssl\x18\a \x01(\bR\x05isSsl\x12\"\n" + - "\ris_login_auth\x18\b \x01(\bR\visLoginAuth\x1aL\n" + + "\ris_login_auth\x18\b \x01(\bR\visLoginAuth\x1ai\n" + "\x05Media\x12\x1f\n" + "\vsession_ttl\x18\x01 \x01(\x05R\n" + "sessionTtl\x12\"\n" + - "\rmax_file_size\x18\x02 \x01(\x03R\vmaxFileSize\x1a'\n" + + "\rmax_file_size\x18\x02 \x01(\x03R\vmaxFileSize\x12\x1b\n" + + "\tchunk_dir\x18\x03 \x01(\tR\bchunkDir\x1a'\n" + "\x04Disk\x12\x1f\n" + "\vmount_point\x18\x01 \x01(\tR\n" + - "mountPoint\x1a\xc3\x01\n" + + "mountPoint\x1a\x9f\x02\n" + "\x06System\x12\x1b\n" + "\tuse_redis\x18\x01 \x01(\bR\buseRedis\x12%\n" + "\x0euse_multipoint\x18\x02 \x01(\bR\ruseMultipoint\x12&\n" + "\x0fuse_strict_auth\x18\x03 \x01(\bR\ruseStrictAuth\x120\n" + "\x14disable_auto_migrate\x18\x04 \x01(\bR\x12disableAutoMigrate\x12\x1b\n" + - "\tuse_mongo\x18\x05 \x01(\bR\buseMongo\x1a\xf6\x03\n" + + "\tuse_mongo\x18\x05 \x01(\bR\buseMongo\x12\x12\n" + + "\x04addr\x18\x06 \x01(\x05R\x04addr\x12#\n" + + "\riplimit_count\x18\a \x01(\x05R\fiplimitCount\x12!\n" + + "\fiplimit_time\x18\b \x01(\x05R\viplimitTime\x1a\xf6\x03\n" + "\x03Zap\x12\x14\n" + "\x05level\x18\x01 \x01(\tR\x05level\x12\x16\n" + "\x06prefix\x18\x02 \x01(\tR\x06prefix\x12\x16\n" + diff --git a/internal/conf/conf.proto b/internal/conf/conf.proto index c93be73..5bf64f1 100644 --- a/internal/conf/conf.proto +++ b/internal/conf/conf.proto @@ -127,6 +127,7 @@ message AdminBackend { message Media { int32 session_ttl = 1; int64 max_file_size = 2; + string chunk_dir = 3; } message Disk { @@ -139,6 +140,9 @@ message AdminBackend { bool use_strict_auth = 3; bool disable_auto_migrate = 4; bool use_mongo = 5; + int32 addr = 6; + int32 iplimit_count = 7; + int32 iplimit_time = 8; } message Zap { diff --git a/internal/data/announcement.go b/internal/data/announcement.go index 12f506e..315dc49 100644 --- a/internal/data/announcement.go +++ b/internal/data/announcement.go @@ -94,7 +94,7 @@ func (r *announcementRepo) UserOptions(ctx context.Context) ([]biz.UserOption, e Label string Value uint } - err := r.data.gormDB.WithContext(ctx).Table("sys_users").Select("nick_name AS label, id AS value").Where("deleted_at IS NULL").Scan(&rows).Error + err := r.data.gormDB.WithContext(ctx).Table("sys_users").Select("nick_name AS label, id AS value").Scan(&rows).Error if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } diff --git a/internal/data/authority.go b/internal/data/authority.go index 789bea9..23dd3da 100644 --- a/internal/data/authority.go +++ b/internal/data/authority.go @@ -98,7 +98,7 @@ func (r *authorityAccessRepo) CreateAuthority(ctx context.Context, value *biz.Au return err } } - defaults := []struct{ path, method string }{{"/menu/getMenu", "POST"}, {"/jwt/jsonInBlacklist", "POST"}, {"/user/changePassword", "POST"}, {"/user/setUserAuthority", "POST"}, {"/user/getUserInfo", "GET"}, {"/user/setSelfInfo", "PUT"}, {"/fileUploadAndDownload/upload", "POST"}, {"/sysDictionary/findSysDictionary", "GET"}} + defaults := []struct{ path, method string }{{"/menu/getMenu", "POST"}, {"/jwt/jsonInBlacklist", "POST"}, {"/base/login", "POST"}, {"/user/changePassword", "POST"}, {"/user/setUserAuthority", "POST"}, {"/user/getUserInfo", "GET"}, {"/user/setSelfInfo", "PUT"}, {"/fileUploadAndDownload/upload", "POST"}, {"/sysDictionary/findSysDictionary", "GET"}} for _, item := range defaults { var api apiPO if err := tx.Where("path = ? AND method = ?", item.path, item.method).First(&api).Error; err == nil { @@ -186,13 +186,23 @@ func (r *authorityAccessRepo) CopyAuthority(ctx context.Context, sourceID uint, }) } func (r *authorityAccessRepo) UpdateAuthority(ctx context.Context, value *biz.Authority) error { - return r.data.gormDB.WithContext(ctx).Model(&authorityPO{}).Where("authority_id = ?", value.AuthorityID).Updates(map[string]any{"authority_name": value.AuthorityName, "parent_id": value.ParentID, "data_scope": value.DataScope, "default_router": value.DefaultRouter}).Error + db := r.data.gormDB.WithContext(ctx) + var current authorityPO + if err := db.Where("authority_id = ?", value.AuthorityID).First(¤t).Error; err != nil { + return errors.New("查询角色数据失败") + } + updates := &authorityPO{AuthorityName: value.AuthorityName, ParentID: value.ParentID, DataScope: value.DataScope, DefaultRouter: value.DefaultRouter} + return db.Model(¤t).Updates(updates).Error } func (r *authorityAccessRepo) DeleteAuthority(ctx context.Context, id uint) error { - if id == 888 { - return errors.New("不能删除超级管理员") - } return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var authority authorityPO + if err := tx.Where("authority_id = ?", id).First(&authority).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("该角色不存在") + } + return err + } var users, children int64 if err := tx.Model(&userAuthorityPO{}).Where("sys_authority_authority_id = ?", id).Count(&users).Error; err != nil { return err @@ -221,15 +231,12 @@ func (r *authorityAccessRepo) DeleteAuthority(ctx context.Context, id uint) erro if err := tx.Where("authority_id = ?", id).Delete(&authorityButtonPO{}).Error; err != nil { return err } - if err := tx.Where("authority_id = ?", id).Delete(&authorityDepartmentPO{}).Error; err != nil { - return err - } return tx.Delete(&authorityPO{}, "authority_id = ?", id).Error }) } func (r *authorityAccessRepo) ListAuthorities(ctx context.Context) ([]*biz.Authority, error) { var pos []authorityPO - if err := r.data.gormDB.WithContext(ctx).Order("authority_id").Find(&pos).Error; err != nil { + if err := r.data.gormDB.WithContext(ctx).Find(&pos).Error; err != nil { return nil, err } var allowed map[uint]bool diff --git a/internal/data/config_management.go b/internal/data/config_management.go index 68fa5ec..d59aa7c 100644 --- a/internal/data/config_management.go +++ b/internal/data/config_management.go @@ -66,6 +66,16 @@ func (r *initializationRepo) ConfigurationJSON() (json.RawMessage, error) { } } maskDataSecrets(dataConfig) + safeAdmin := cloneAdminConfig(adminConfig) + if safeAdmin != nil { + if safeAdmin.Jwt != nil { + safeAdmin.Jwt.SigningKey = "******" + } + if safeAdmin.Email != nil { + safeAdmin.Email.Secret = "******" + } + maskStorageSecrets(safeAdmin.Storage) + } dataMap := map[string]any{} if dataConfig != nil { raw, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(dataConfig) @@ -76,7 +86,9 @@ func (r *initializationRepo) ConfigurationJSON() (json.RawMessage, error) { return nil, err } } - return json.Marshal(map[string]any{"config": map[string]any{"admin": admin, "email": email, "data": dataMap}}) + config := gvaConfiguration(dataConfig, safeAdmin) + config["admin"], config["email"], config["data"] = admin, email, dataMap + return json.Marshal(map[string]any{"config": config}) } func (r *initializationRepo) SaveConfigurationJSON(ctx context.Context, raw json.RawMessage) error { @@ -86,6 +98,9 @@ func (r *initializationRepo) SaveConfigurationJSON(ctx context.Context, raw json } nextData := cloneDataConfig(currentData) nextAdmin := proto.Clone(currentAdmin).(*conf.AdminBackend) + if err := applyGVAConfiguration(raw, nextData, nextAdmin); err != nil { + return err + } var value configurationEnvelope if err := json.Unmarshal(raw, &value); err != nil { return err @@ -154,6 +169,13 @@ func cloneDataConfig(value *conf.Data) *conf.Data { return proto.Clone(value).(*conf.Data) } +func cloneAdminConfig(value *conf.AdminBackend) *conf.AdminBackend { + if value == nil { + return &conf.AdminBackend{} + } + return proto.Clone(value).(*conf.AdminBackend) +} + func refreshDatabaseSources(value *conf.Data) error { if value == nil { return nil @@ -222,6 +244,11 @@ func applyDataPatch(target, patch *conf.Data) { func applyAdminPatch(target, patch *conf.AdminBackend) { target.RouterPrefix = patch.RouterPrefix if patch.System != nil { + if target.System != nil { + patch.System.Addr = target.System.Addr + patch.System.IplimitCount = target.System.IplimitCount + patch.System.IplimitTime = target.System.IplimitTime + } target.System = patch.System } if patch.Jwt != nil { @@ -234,6 +261,9 @@ func applyAdminPatch(target, patch *conf.AdminBackend) { target.Local = patch.Local } if patch.Media != nil { + if patch.Media.ChunkDir == "" && target.Media != nil { + patch.Media.ChunkDir = target.Media.ChunkDir + } target.Media = patch.Media } if patch.Storage != nil { diff --git a/internal/data/config_store.go b/internal/data/config_store.go index 934a998..8931ab6 100644 --- a/internal/data/config_store.go +++ b/internal/data/config_store.go @@ -4,8 +4,10 @@ import ( "context" "encoding/json" "fmt" + "net" "os" "path/filepath" + "strconv" "kra/internal/conf" @@ -84,6 +86,45 @@ func mergeYAMLNode(dst, src *yaml.Node) { dst.HeadComment, dst.LineComment, dst.FootComment = head, line, foot } +func yamlMappingValue(node *yaml.Node, keys ...string) *yaml.Node { + if node == nil { + return nil + } + if node.Kind == yaml.DocumentNode && len(node.Content) > 0 { + node = node.Content[0] + } + for _, key := range keys { + if node.Kind != yaml.MappingNode { + return nil + } + var next *yaml.Node + for i := 0; i+1 < len(node.Content); i += 2 { + if node.Content[i].Value == key { + next = node.Content[i+1] + break + } + } + if next == nil { + return nil + } + node = next + } + return node +} + +func setServerHTTPPort(document *yaml.Node, port int32) error { + if port <= 0 { + return nil + } + host := "0.0.0.0" + if addr := yamlMappingValue(document, "server", "http", "addr"); addr != nil { + if currentHost, _, err := net.SplitHostPort(addr.Value); err == nil && currentHost != "" { + host = currentHost + } + } + return setYAMLMapping(document, "server", map[string]any{"http": map[string]any{"addr": net.JoinHostPort(host, strconv.Itoa(int(port)))}}) +} + func (d *Data) persistConfig() error { dataConfig, adminConfig := d.runtime.Values() return d.persistConfigValues(dataConfig, adminConfig) @@ -122,6 +163,11 @@ func (d *Data) persistConfigValuesLocked(dataConfig *conf.Data, adminConfig *con if err = setYAMLMapping(&document, "admin", adminValue); err != nil { return err } + if adminConfig.System != nil { + if err = setServerHTTPPort(&document, adminConfig.System.Addr); err != nil { + return err + } + } return writeConfigDocument(configPath, &document) } diff --git a/internal/data/department.go b/internal/data/department.go index db9a90e..6c22dc2 100644 --- a/internal/data/department.go +++ b/internal/data/department.go @@ -108,12 +108,6 @@ func (r *departmentRepo) DeleteDepartment(ctx context.Context, id uint) error { if count > 0 { return errors.New("存在子部门,不允许删除") } - if err := r.data.gormDB.WithContext(ctx).Model(&userPO{}).Where("dept_id = ?", id).Count(&count).Error; err != nil { - return err - } - if count > 0 { - return errors.New("该部门下存在用户,不允许删除") - } if err := r.data.gormDB.WithContext(ctx).Model(&userDepartmentPO{}).Where("sys_department_id = ?", id).Count(&count).Error; err != nil { return err } @@ -135,7 +129,7 @@ func (r *departmentRepo) FindDepartment(ctx context.Context, id uint) (*biz.Depa } func (r *departmentRepo) ListDepartments(ctx context.Context, name string) ([]*biz.Department, error) { var pos []departmentPO - db := r.data.gormDB.WithContext(ctx).Order("sort,id") + db := r.data.gormDB.WithContext(ctx).Order("sort") if name != "" { db = db.Where("name LIKE ?", "%"+name+"%") } @@ -160,7 +154,7 @@ func (r *departmentRepo) ListDepartments(ctx context.Context, name string) ([]*b n := nodes[po.ID] if p := nodes[po.ParentID]; p != nil { p.Children = append(p.Children, n) - } else { + } else if po.ParentID == 0 { roots = append(roots, n) } } diff --git a/internal/data/dictionary.go b/internal/data/dictionary.go index 85acd22..40c7a7f 100644 --- a/internal/data/dictionary.go +++ b/internal/data/dictionary.go @@ -111,19 +111,18 @@ func (r *dictionaryRepo) UpdateDictionary(ctx context.Context, v *biz.Dictionary return r.data.gormDB.WithContext(ctx).Model(&dictionaryPO{}).Where("id = ?", v.ID).Updates(map[string]any{"name": v.Name, "type": v.Type, "status": v.Status, "desc": v.Desc, "parent_id": v.ParentID}).Error } func (r *dictionaryRepo) DeleteDictionary(ctx context.Context, id uint) error { - return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - var count int64 - if err := tx.Model(&dictionaryPO{}).Where("parent_id = ?", id).Count(&count).Error; err != nil { - return err + db := r.data.gormDB.WithContext(ctx) + var dictionary dictionaryPO + if err := db.First(&dictionary, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("请不要搞事") } - if count > 0 { - return errors.New("存在子字典不可删除") - } - if err := tx.Where("sys_dictionary_id = ?", id).Delete(&dictionaryDetailPO{}).Error; err != nil { - return err - } - return tx.Delete(&dictionaryPO{}, id).Error - }) + return err + } + if err := db.Delete(&dictionary).Error; err != nil { + return err + } + return db.Where("sys_dictionary_id = ?", id).Delete(&dictionaryDetailPO{}).Error } func (r *dictionaryRepo) FindDictionary(ctx context.Context, id uint, typ string, status *bool, details bool) (*biz.Dictionary, error) { var po dictionaryPO diff --git a/internal/data/export.go b/internal/data/export.go index 41e4a4b..b630f3a 100644 --- a/internal/data/export.go +++ b/internal/data/export.go @@ -111,21 +111,7 @@ func (r *exportRepo) UpdateExportTemplate(ctx context.Context, v *biz.ExportTemp }) } func (r *exportRepo) DeleteExportTemplates(ctx context.Context, ids []uint) error { - return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - var tids []string - if err := tx.Model(&exportTemplatePO{}).Where("id IN ?", ids).Pluck("template_id", &tids).Error; err != nil { - return err - } - if len(tids) > 0 { - if err := tx.Where("template_id IN ?", tids).Delete(&exportConditionPO{}).Error; err != nil { - return err - } - if err := tx.Where("template_id IN ?", tids).Delete(&exportJoinPO{}).Error; err != nil { - return err - } - } - return tx.Delete(&exportTemplatePO{}, ids).Error - }) + return r.data.gormDB.WithContext(ctx).Delete(&[]exportTemplatePO{}, "id IN ?", ids).Error } func (r *exportRepo) FindExportTemplate(ctx context.Context, id uint, tid string) (*biz.ExportTemplate, error) { var po exportTemplatePO @@ -173,31 +159,9 @@ func (r *exportRepo) ListExportTemplates(ctx context.Context, page, size int, q if err := applyPagination(db, page, size, 0).Find(&pos).Error; err != nil { return nil, 0, err } - templateIDs := make([]string, 0, len(pos)) - for _, po := range pos { - templateIDs = append(templateIDs, po.TemplateID) - } - var conditions []exportConditionPO - var joins []exportJoinPO - if len(templateIDs) > 0 { - if err := r.data.gormDB.WithContext(ctx).Where("template_id IN ?", templateIDs).Find(&conditions).Error; err != nil { - return nil, 0, err - } - if err := r.data.gormDB.WithContext(ctx).Where("template_id IN ?", templateIDs).Find(&joins).Error; err != nil { - return nil, 0, err - } - } - conditionsByTemplate := make(map[string][]exportConditionPO, len(pos)) - for _, condition := range conditions { - conditionsByTemplate[condition.TemplateID] = append(conditionsByTemplate[condition.TemplateID], condition) - } - joinsByTemplate := make(map[string][]exportJoinPO, len(pos)) - for _, join := range joins { - joinsByTemplate[join.TemplateID] = append(joinsByTemplate[join.TemplateID], join) - } out := make([]*biz.ExportTemplate, 0, len(pos)) for _, po := range pos { - out = append(out, exportFromPO(po, conditionsByTemplate[po.TemplateID], joinsByTemplate[po.TemplateID])) + out = append(out, exportFromPO(po, nil, nil)) } return out, total, nil } diff --git a/internal/data/gva_config_compat.go b/internal/data/gva_config_compat.go new file mode 100644 index 0000000..bf8ebf3 --- /dev/null +++ b/internal/data/gva_config_compat.go @@ -0,0 +1,545 @@ +package data + +import ( + "encoding/json" + "strconv" + "strings" + "time" + + "kra/internal/conf" + + "google.golang.org/protobuf/types/known/durationpb" +) + +func gvaDatabase(value *conf.Data_Database) map[string]any { + if value == nil { + return map[string]any{} + } + path := value.Host + if value.Driver == "sqlite" { + path = value.Path + } + return map[string]any{ + "prefix": value.Prefix, "port": value.Port, "config": value.Config, + "db-name": value.Name, "username": value.User, "password": value.Password, + "path": path, "engine": value.Engine, "log-mode": value.LogMode, + "max-idle-conns": value.MaxIdleConns, "max-open-conns": value.MaxOpenConns, + "conn-max-lifetime": value.ConnMaxLifetime, "singular": value.Singular, + } +} + +func gvaRedis(value *conf.Data_Redis) map[string]any { + if value == nil { + return map[string]any{} + } + return map[string]any{"name": value.Name, "addr": value.Addr, "password": value.Password, "db": value.Db, "useCluster": value.UseCluster, "clusterAddrs": value.ClusterAddrs} +} + +func gvaObjectStore(value *conf.AdminBackend_ObjectStore) map[string]any { + if value == nil { + return map[string]any{} + } + return map[string]any{ + "endpoint": value.Endpoint, "region": value.Region, "bucket": value.Bucket, + "access-key": value.AccessKey, "secret-key": value.SecretKey, "base-url": value.BaseUrl, + "path-prefix": value.PathPrefix, "use-ssl": value.UseSsl, + "s3-force-path-style": value.ForcePathStyle, "account-id": value.AccountId, + } +} + +func gvaObjectStores(storage *conf.AdminBackend_Storage) map[string]map[string]any { + if storage == nil { + storage = &conf.AdminBackend_Storage{} + } + aliyun, huawei := storage.AliyunOss, storage.HuaweiObs + tencent, aws := storage.TencentCos, storage.AwsS3 + cloudflare, minio := storage.CloudflareR2, storage.Minio + if aliyun == nil { + aliyun = &conf.AdminBackend_ObjectStore{} + } + if huawei == nil { + huawei = &conf.AdminBackend_ObjectStore{} + } + if tencent == nil { + tencent = &conf.AdminBackend_ObjectStore{} + } + if aws == nil { + aws = &conf.AdminBackend_ObjectStore{} + } + if cloudflare == nil { + cloudflare = &conf.AdminBackend_ObjectStore{} + } + if minio == nil { + minio = &conf.AdminBackend_ObjectStore{} + } + return map[string]map[string]any{ + "aliyun-oss": {"endpoint": aliyun.Endpoint, "access-key-id": aliyun.AccessKey, "access-key-secret": aliyun.SecretKey, "bucket-name": aliyun.Bucket, "bucket-url": aliyun.BaseUrl, "base-path": aliyun.PathPrefix}, + "hua-wei-obs": {"path": huawei.PathPrefix, "bucket": huawei.Bucket, "endpoint": huawei.Endpoint, "access-key": huawei.AccessKey, "secret-key": huawei.SecretKey}, + "tencent-cos": {"bucket": tencent.Bucket, "region": tencent.Region, "secret-id": tencent.AccessKey, "secret-key": tencent.SecretKey, "base-url": tencent.BaseUrl, "path-prefix": tencent.PathPrefix}, + "aws-s3": {"bucket": aws.Bucket, "region": aws.Region, "endpoint": aws.Endpoint, "secret-id": aws.AccessKey, "secret-key": aws.SecretKey, "base-url": aws.BaseUrl, "path-prefix": aws.PathPrefix, "s3-force-path-style": aws.ForcePathStyle, "disable-ssl": !aws.UseSsl}, + "cloudflare-r2": {"bucket": cloudflare.Bucket, "base-url": cloudflare.BaseUrl, "path": cloudflare.PathPrefix, "account-id": cloudflare.AccountId, "access-key-id": cloudflare.AccessKey, "secret-access-key": cloudflare.SecretKey}, + "minio": {"endpoint": minio.Endpoint, "access-key-id": minio.AccessKey, "access-key-secret": minio.SecretKey, "bucket-name": minio.Bucket, "use-ssl": minio.UseSsl, "base-path": minio.PathPrefix, "bucket-url": minio.BaseUrl}, + } +} + +func gvaConfiguration(data *conf.Data, admin *conf.AdminBackend) map[string]any { + result := map[string]any{} + if data == nil { + data = &conf.Data{} + } + if admin == nil { + admin = &conf.AdminBackend{} + } + jwt := admin.Jwt + if jwt == nil { + jwt = &conf.AdminBackend_JWT{} + } + result["jwt"] = map[string]any{"signing-key": jwt.SigningKey, "expires-time": durationString(jwt.ExpiresTime), "buffer-time": durationString(jwt.BufferTime), "issuer": jwt.Issuer} + zap := admin.Zap + if zap == nil { + zap = &conf.AdminBackend_Zap{} + } + result["zap"] = map[string]any{ + "level": zap.Level, "prefix": zap.Prefix, "format": zap.Format, "director": zap.Director, + "encode-level": zap.EncodeLevel, "stacktrace-key": zap.StacktraceKey, "show-line": zap.ShowLine, + "log-in-console": zap.LogInConsole, "retention-day": zap.RetentionDay, + "access-req-body": zap.AccessReqBody, "access-resp-data": zap.AccessRespData, + "access-req-headers": zap.AccessReqHeaders, "access-log-max-bytes": zap.AccessLogMaxBytes, + "file-only-modules": zap.FileOnlyModules, + } + result["redis"] = gvaRedis(data.Redis) + redisList := make([]map[string]any, 0, len(data.RedisList)) + for _, item := range data.RedisList { + redisList = append(redisList, gvaRedis(item)) + } + result["redis-list"] = redisList + mongo := data.Mongo + if mongo == nil { + mongo = &conf.Data_Mongo{} + } + hosts := make([]map[string]any, 0, len(mongo.Hosts)) + for _, host := range mongo.Hosts { + if host != nil { + hosts = append(hosts, map[string]any{"host": host.Host, "port": host.Port}) + } + } + result["mongo"] = map[string]any{"coll": mongo.Coll, "options": mongo.Options, "database": mongo.Database, "username": mongo.Username, "password": mongo.Password, "auth-source": mongo.AuthSource, "min-pool-size": mongo.MinPoolSize, "max-pool-size": mongo.MaxPoolSize, "socket-timeout-ms": mongo.SocketTimeoutMs, "connect-timeout-ms": mongo.ConnectTimeoutMs, "is-zap": mongo.IsZap, "hosts": hosts} + email := admin.Email + if email == nil { + email = &conf.AdminBackend_Email{} + } + result["email"] = map[string]any{"to": email.To, "from": email.From, "host": email.Host, "secret": email.Secret, "nickname": email.Nickname, "port": email.Port, "is-ssl": email.IsSsl, "is-loginauth": email.IsLoginAuth} + system := admin.System + if system == nil { + system = &conf.AdminBackend_System{} + } + storageType := "local" + if admin.Storage != nil && admin.Storage.Type != "" { + storageType = admin.Storage.Type + } + driver := "" + if data.Database != nil { + driver = data.Database.Driver + } + result["system"] = map[string]any{"db-type": driver, "oss-type": storageType, "router-prefix": admin.RouterPrefix, "addr": system.Addr, "iplimit-count": system.IplimitCount, "iplimit-time": system.IplimitTime, "use-multipoint": system.UseMultipoint, "use-redis": system.UseRedis, "use-mongo": system.UseMongo, "use-strict-auth": system.UseStrictAuth, "disable-auto-migrate": system.DisableAutoMigrate} + for _, name := range []string{"mysql", "mssql", "pgsql", "oracle", "sqlite"} { + result[name] = map[string]any{} + } + if driver != "" { + result[driver] = gvaDatabase(data.Database) + } + dbList := make([]map[string]any, 0, len(data.DatabaseList)) + for _, item := range data.DatabaseList { + row := gvaDatabase(item) + if item != nil { + row["type"], row["alias-name"], row["disable"] = item.Driver, item.AliasName, item.Disable + } + dbList = append(dbList, row) + } + result["db-list"] = dbList + local := admin.Local + if local == nil { + local = &conf.AdminBackend_Local{} + } + result["local"] = map[string]any{"path": local.PathPrefix, "store-path": local.StorePath} + media := admin.Media + if media == nil { + media = &conf.AdminBackend_Media{} + } + result["media"] = map[string]any{"session-ttl": media.SessionTtl, "max-file-size": media.MaxFileSize, "chunk-dir": media.ChunkDir} + if admin.Storage == nil { + admin.Storage = &conf.AdminBackend_Storage{} + } + qiniu := admin.Storage.Qiniu + if qiniu == nil { + qiniu = &conf.AdminBackend_Qiniu{} + } + result["qiniu"] = map[string]any{"zone": qiniu.Zone, "bucket": qiniu.Bucket, "img-path": qiniu.BaseUrl, "access-key": qiniu.AccessKey, "secret-key": qiniu.SecretKey, "use-https": qiniu.UseHttps, "use-cdn-domains": qiniu.UseCdnDomains} + for key, value := range gvaObjectStores(admin.Storage) { + result[key] = value + } + disks := make([]map[string]any, 0, len(admin.DiskList)) + for _, disk := range admin.DiskList { + if disk != nil { + disks = append(disks, map[string]any{"mount-point": disk.MountPoint}) + } + } + result["disk-list"] = disks + cors := admin.Cors + if cors == nil { + cors = &conf.AdminBackend_CORS{} + } + whitelist := make([]map[string]any, 0, len(cors.Whitelist)) + for _, rule := range cors.Whitelist { + if rule != nil { + whitelist = append(whitelist, map[string]any{"allow-origin": rule.AllowOrigin, "allow-methods": rule.AllowMethods, "allow-headers": rule.AllowHeaders, "expose-headers": rule.ExposeHeaders, "allow-credentials": rule.AllowCredentials}) + } + } + result["cors"] = map[string]any{"mode": cors.Mode, "whitelist": whitelist} + result["app"] = map[string]any{"node": admin.GetApp().GetNode(), "app-id": admin.GetApp().GetAppId(), "env": admin.GetApp().GetEnv()} + return result +} + +func objectMap(value any) map[string]any { + result, _ := value.(map[string]any) + return result +} + +func stringValue(values map[string]any, key string) string { + value, _ := values[key].(string) + return value +} + +func boolValue(values map[string]any, key string, fallback bool) bool { + value, ok := values[key].(bool) + if !ok { + return fallback + } + return value +} + +func int64Value(values map[string]any, key string, fallback int64) int64 { + value, ok := values[key].(float64) + if !ok { + return fallback + } + return int64(value) +} + +func stringSliceValue(values map[string]any, key string, fallback []string) []string { + items, ok := values[key].([]any) + if !ok { + return fallback + } + result := make([]string, 0, len(items)) + for _, item := range items { + if value, ok := item.(string); ok { + result = append(result, value) + } + } + return result +} + +func applyGVARedis(target *conf.Data_Redis, values map[string]any) { + if target == nil || values == nil { + return + } + target.Name, target.Addr = stringValue(values, "name"), stringValue(values, "addr") + if password := stringValue(values, "password"); password != "" && password != "******" { + target.Password = password + } + target.Db = int32(int64Value(values, "db", int64(target.Db))) + target.UseCluster = boolValue(values, "useCluster", target.UseCluster) + target.ClusterAddrs = stringSliceValue(values, "clusterAddrs", target.ClusterAddrs) +} + +func durationValue(raw string, fallback *durationpb.Duration) *durationpb.Duration { + if raw == "" { + return fallback + } + value, err := parseGVADuration(raw) + if err != nil { + return fallback + } + return durationpb.New(value) +} + +func parseGVADuration(raw string) (time.Duration, error) { + raw = strings.TrimSpace(raw) + if value, err := time.ParseDuration(raw); err == nil { + return value, nil + } + if index := strings.Index(raw, "d"); index >= 0 { + days, _ := strconv.Atoi(raw[:index]) + value := time.Duration(days) * 24 * time.Hour + remainder, err := time.ParseDuration(raw[index+1:]) + if err != nil { + return value, nil + } + return value + remainder, nil + } + nanoseconds, err := strconv.ParseInt(raw, 10, 64) + return time.Duration(nanoseconds), err +} + +func applyGVADatabase(target *conf.Data_Database, values map[string]any, driver string) { + if target == nil || values == nil { + return + } + target.Driver = driver + target.Prefix, target.Port, target.Config = stringValue(values, "prefix"), stringValue(values, "port"), stringValue(values, "config") + target.Name, target.User = stringValue(values, "db-name"), stringValue(values, "username") + if password := stringValue(values, "password"); password != "" && password != "******" { + target.Password = password + } + if driver == "sqlite" { + target.Path = stringValue(values, "path") + } else { + target.Host = stringValue(values, "path") + } + target.Engine, target.LogMode = stringValue(values, "engine"), stringValue(values, "log-mode") + target.MaxIdleConns = int32(int64Value(values, "max-idle-conns", int64(target.MaxIdleConns))) + target.MaxOpenConns = int32(int64Value(values, "max-open-conns", int64(target.MaxOpenConns))) + target.ConnMaxLifetime = int32(int64Value(values, "conn-max-lifetime", int64(target.ConnMaxLifetime))) + target.Singular = boolValue(values, "singular", target.Singular) +} + +func applyGVAObjectStore(target *conf.AdminBackend_ObjectStore, values map[string]any) { + if target == nil || values == nil { + return + } + target.Endpoint, target.Region, target.Bucket = stringValue(values, "endpoint"), stringValue(values, "region"), stringValue(values, "bucket") + if target.Bucket == "" { + target.Bucket = stringValue(values, "bucket-name") + } + target.AccessKey = stringValue(values, "access-key") + if target.AccessKey == "" { + target.AccessKey = stringValue(values, "access-key-id") + } + if target.AccessKey == "" { + target.AccessKey = stringValue(values, "secret-id") + } + secret := stringValue(values, "secret-key") + if secret == "" { + secret = stringValue(values, "access-key-secret") + } + if secret == "" { + secret = stringValue(values, "secret-access-key") + } + if secret != "" && secret != "******" { + target.SecretKey = secret + } + target.BaseUrl = stringValue(values, "base-url") + if target.BaseUrl == "" { + target.BaseUrl = stringValue(values, "bucket-url") + } + target.PathPrefix = stringValue(values, "path-prefix") + if target.PathPrefix == "" { + target.PathPrefix = stringValue(values, "base-path") + } + if target.PathPrefix == "" { + target.PathPrefix = stringValue(values, "path") + } + target.UseSsl = boolValue(values, "use-ssl", !boolValue(values, "disable-ssl", !target.UseSsl)) + target.ForcePathStyle = boolValue(values, "s3-force-path-style", target.ForcePathStyle) + target.AccountId = stringValue(values, "account-id") +} + +func applyGVAConfiguration(raw json.RawMessage, data *conf.Data, admin *conf.AdminBackend) error { + var values map[string]any + if err := json.Unmarshal(raw, &values); err != nil { + return err + } + if _, isGVA := values["system"]; !isGVA { + return nil + } + if data.Database == nil { + data.Database = &conf.Data_Database{} + } + if admin.System == nil { + admin.System = &conf.AdminBackend_System{} + } + if admin.Storage == nil { + admin.Storage = &conf.AdminBackend_Storage{} + } + system := objectMap(values["system"]) + driver := stringValue(system, "db-type") + if driver == "" { + driver = data.Database.Driver + } + applyGVADatabase(data.Database, objectMap(values[driver]), driver) + admin.Storage.Type = stringValue(system, "oss-type") + admin.RouterPrefix = stringValue(system, "router-prefix") + admin.System.UseMultipoint = boolValue(system, "use-multipoint", admin.System.UseMultipoint) + admin.System.UseRedis = boolValue(system, "use-redis", admin.System.UseRedis) + admin.System.UseMongo = boolValue(system, "use-mongo", admin.System.UseMongo) + admin.System.UseStrictAuth = boolValue(system, "use-strict-auth", admin.System.UseStrictAuth) + admin.System.DisableAutoMigrate = boolValue(system, "disable-auto-migrate", admin.System.DisableAutoMigrate) + admin.System.Addr = int32(int64Value(system, "addr", int64(admin.System.Addr))) + admin.System.IplimitCount = int32(int64Value(system, "iplimit-count", int64(admin.System.IplimitCount))) + admin.System.IplimitTime = int32(int64Value(system, "iplimit-time", int64(admin.System.IplimitTime))) + if admin.Jwt == nil { + admin.Jwt = &conf.AdminBackend_JWT{} + } + jwt := objectMap(values["jwt"]) + if secret := stringValue(jwt, "signing-key"); secret != "" && secret != "******" { + admin.Jwt.SigningKey = secret + } + admin.Jwt.ExpiresTime = durationValue(stringValue(jwt, "expires-time"), admin.Jwt.ExpiresTime) + admin.Jwt.BufferTime = durationValue(stringValue(jwt, "buffer-time"), admin.Jwt.BufferTime) + admin.Jwt.Issuer = stringValue(jwt, "issuer") + if admin.Zap == nil { + admin.Zap = &conf.AdminBackend_Zap{} + } + zap := objectMap(values["zap"]) + admin.Zap.Level, admin.Zap.Prefix, admin.Zap.Format, admin.Zap.Director = stringValue(zap, "level"), stringValue(zap, "prefix"), stringValue(zap, "format"), stringValue(zap, "director") + admin.Zap.EncodeLevel, admin.Zap.StacktraceKey = stringValue(zap, "encode-level"), stringValue(zap, "stacktrace-key") + admin.Zap.ShowLine, admin.Zap.LogInConsole = boolValue(zap, "show-line", admin.Zap.ShowLine), boolValue(zap, "log-in-console", admin.Zap.LogInConsole) + admin.Zap.RetentionDay = int32(int64Value(zap, "retention-day", int64(admin.Zap.RetentionDay))) + admin.Zap.AccessReqBody = boolValue(zap, "access-req-body", admin.Zap.AccessReqBody) + admin.Zap.AccessRespData = boolValue(zap, "access-resp-data", admin.Zap.AccessRespData) + admin.Zap.AccessReqHeaders = boolValue(zap, "access-req-headers", admin.Zap.AccessReqHeaders) + admin.Zap.AccessLogMaxBytes = int32(int64Value(zap, "access-log-max-bytes", int64(admin.Zap.AccessLogMaxBytes))) + if list, ok := zap["file-only-modules"].([]any); ok { + admin.Zap.FileOnlyModules = admin.Zap.FileOnlyModules[:0] + for _, item := range list { + if value, ok := item.(string); ok { + admin.Zap.FileOnlyModules = append(admin.Zap.FileOnlyModules, value) + } + } + } + if data.Redis == nil { + data.Redis = &conf.Data_Redis{} + } + applyGVARedis(data.Redis, objectMap(values["redis"])) + if items, ok := values["redis-list"].([]any); ok { + current := make(map[string]*conf.Data_Redis, len(data.RedisList)) + for _, item := range data.RedisList { + if item != nil { + current[item.Name] = item + } + } + data.RedisList = make([]*conf.Data_Redis, 0, len(items)) + for _, item := range items { + values := objectMap(item) + target := current[stringValue(values, "name")] + if target == nil { + target = &conf.Data_Redis{} + } + applyGVARedis(target, values) + data.RedisList = append(data.RedisList, target) + } + } + if mongo := objectMap(values["mongo"]); mongo != nil { + if data.Mongo == nil { + data.Mongo = &conf.Data_Mongo{} + } + data.Mongo.Coll, data.Mongo.Options, data.Mongo.Database = stringValue(mongo, "coll"), stringValue(mongo, "options"), stringValue(mongo, "database") + data.Mongo.Username, data.Mongo.AuthSource = stringValue(mongo, "username"), stringValue(mongo, "auth-source") + if password := stringValue(mongo, "password"); password != "" && password != "******" { + data.Mongo.Password = password + } + data.Mongo.MinPoolSize = uint64(int64Value(mongo, "min-pool-size", int64(data.Mongo.MinPoolSize))) + data.Mongo.MaxPoolSize = uint64(int64Value(mongo, "max-pool-size", int64(data.Mongo.MaxPoolSize))) + data.Mongo.SocketTimeoutMs = int64Value(mongo, "socket-timeout-ms", data.Mongo.SocketTimeoutMs) + data.Mongo.ConnectTimeoutMs = int64Value(mongo, "connect-timeout-ms", data.Mongo.ConnectTimeoutMs) + data.Mongo.IsZap = boolValue(mongo, "is-zap", data.Mongo.IsZap) + if hosts, ok := mongo["hosts"].([]any); ok { + data.Mongo.Hosts = make([]*conf.Data_MongoHost, 0, len(hosts)) + for _, item := range hosts { + host := objectMap(item) + data.Mongo.Hosts = append(data.Mongo.Hosts, &conf.Data_MongoHost{Host: stringValue(host, "host"), Port: stringValue(host, "port")}) + } + } + } + if items, ok := values["db-list"].([]any); ok { + current := make(map[string]*conf.Data_Database, len(data.DatabaseList)) + for _, item := range data.DatabaseList { + if item != nil { + current[item.AliasName] = item + } + } + data.DatabaseList = make([]*conf.Data_Database, 0, len(items)) + for _, item := range items { + row := objectMap(item) + alias := stringValue(row, "alias-name") + target := current[alias] + if target == nil { + target = &conf.Data_Database{} + } + applyGVADatabase(target, row, stringValue(row, "type")) + target.AliasName, target.Disable = alias, boolValue(row, "disable", target.Disable) + data.DatabaseList = append(data.DatabaseList, target) + } + } + if admin.Email == nil { + admin.Email = &conf.AdminBackend_Email{} + } + email := objectMap(values["email"]) + admin.Email.To, admin.Email.From, admin.Email.Host = stringValue(email, "to"), stringValue(email, "from"), stringValue(email, "host") + admin.Email.Nickname = stringValue(email, "nickname") + admin.Email.Port = int32(int64Value(email, "port", int64(admin.Email.Port))) + admin.Email.IsSsl, admin.Email.IsLoginAuth = boolValue(email, "is-ssl", admin.Email.IsSsl), boolValue(email, "is-loginauth", admin.Email.IsLoginAuth) + if secret := stringValue(email, "secret"); secret != "" && secret != "******" { + admin.Email.Secret = secret + } + if admin.Local == nil { + admin.Local = &conf.AdminBackend_Local{} + } + local := objectMap(values["local"]) + admin.Local.PathPrefix, admin.Local.StorePath = stringValue(local, "path"), stringValue(local, "store-path") + if admin.Media == nil { + admin.Media = &conf.AdminBackend_Media{} + } + media := objectMap(values["media"]) + admin.Media.SessionTtl = int32(int64Value(media, "session-ttl", int64(admin.Media.SessionTtl))) + admin.Media.MaxFileSize = int64Value(media, "max-file-size", admin.Media.MaxFileSize) + admin.Media.ChunkDir = stringValue(media, "chunk-dir") + if admin.Storage.Qiniu == nil { + admin.Storage.Qiniu = &conf.AdminBackend_Qiniu{} + } + qiniu := objectMap(values["qiniu"]) + admin.Storage.Qiniu.Zone, admin.Storage.Qiniu.Bucket, admin.Storage.Qiniu.BaseUrl = stringValue(qiniu, "zone"), stringValue(qiniu, "bucket"), stringValue(qiniu, "img-path") + admin.Storage.Qiniu.AccessKey = stringValue(qiniu, "access-key") + if secret := stringValue(qiniu, "secret-key"); secret != "" && secret != "******" { + admin.Storage.Qiniu.SecretKey = secret + } + admin.Storage.Qiniu.UseHttps, admin.Storage.Qiniu.UseCdnDomains = boolValue(qiniu, "use-https", admin.Storage.Qiniu.UseHttps), boolValue(qiniu, "use-cdn-domains", admin.Storage.Qiniu.UseCdnDomains) + stores := []struct { + key string + target **conf.AdminBackend_ObjectStore + }{{"aliyun-oss", &admin.Storage.AliyunOss}, {"hua-wei-obs", &admin.Storage.HuaweiObs}, {"tencent-cos", &admin.Storage.TencentCos}, {"aws-s3", &admin.Storage.AwsS3}, {"cloudflare-r2", &admin.Storage.CloudflareR2}, {"minio", &admin.Storage.Minio}} + for _, store := range stores { + if *store.target == nil { + *store.target = &conf.AdminBackend_ObjectStore{} + } + applyGVAObjectStore(*store.target, objectMap(values[store.key])) + } + if disks, ok := values["disk-list"].([]any); ok { + admin.DiskList = make([]*conf.AdminBackend_Disk, 0, len(disks)) + for _, item := range disks { + disk := objectMap(item) + admin.DiskList = append(admin.DiskList, &conf.AdminBackend_Disk{MountPoint: stringValue(disk, "mount-point")}) + } + } + if cors := objectMap(values["cors"]); cors != nil { + if admin.Cors == nil { + admin.Cors = &conf.AdminBackend_CORS{} + } + admin.Cors.Mode = stringValue(cors, "mode") + if rules, ok := cors["whitelist"].([]any); ok { + admin.Cors.Whitelist = make([]*conf.AdminBackend_CORSRule, 0, len(rules)) + for _, item := range rules { + rule := objectMap(item) + admin.Cors.Whitelist = append(admin.Cors.Whitelist, &conf.AdminBackend_CORSRule{AllowOrigin: stringValue(rule, "allow-origin"), AllowMethods: stringValue(rule, "allow-methods"), AllowHeaders: stringValue(rule, "allow-headers"), ExposeHeaders: stringValue(rule, "expose-headers"), AllowCredentials: boolValue(rule, "allow-credentials", false)}) + } + } + } + if app := objectMap(values["app"]); app != nil { + if admin.App == nil { + admin.App = &conf.AdminBackend_App{} + } + admin.App.Node, admin.App.AppId, admin.App.Env = stringValue(app, "node"), stringValue(app, "app-id"), stringValue(app, "env") + } + return nil +} diff --git a/internal/data/log_file.go b/internal/data/log_file.go index ae7c093..10d863e 100644 --- a/internal/data/log_file.go +++ b/internal/data/log_file.go @@ -4,8 +4,11 @@ import ( "bytes" "context" "errors" + "fmt" + "io" "io/fs" "os" + pathpkg "path" "path/filepath" "sort" "strings" @@ -14,86 +17,320 @@ import ( "kra/internal/biz" ) -func (r *logFileRepo) logRoot() (string, error) { +const ( + defaultLogChunkLines = 500 + maxLogChunkBytes = 2 * 1024 * 1024 + logReadBlockSize = 64 * 1024 +) + +func (r *logFileRepo) configuredLogRoot() (root string, exists bool, err error) { admin := r.data.runtime.Admin() if admin == nil || admin.Zap == nil || strings.TrimSpace(admin.Zap.Director) == "" { - return "", biz.ErrLogRootUnavailable + return "", false, biz.ErrLogRootUnavailable } - root, err := filepath.Abs(admin.Zap.Director) + root, err = filepath.Abs(admin.Zap.Director) if err != nil { - return "", errors.Join(biz.ErrLogRootUnavailable, err) + return "", false, fmt.Errorf("%w: %v", biz.ErrLogRootUnavailable, err) } info, err := os.Stat(root) if errors.Is(err, fs.ErrNotExist) { - return root, nil + return root, false, nil } if err != nil { - return "", errors.Join(biz.ErrLogRootUnavailable, err) + return "", false, fmt.Errorf("%w: %v", biz.ErrLogRootUnavailable, err) } if !info.IsDir() { - return "", biz.ErrLogRootUnavailable + return "", false, biz.ErrLogRootUnavailable } - return root, nil + return root, true, nil } -func (r *logFileRepo) LogDates(ctx context.Context, month string) ([]biz.LogDate, error) { - if parsed, err := time.Parse("2006-01", month); err != nil || parsed.Format("2006-01") != month { - return nil, biz.ErrInvalidLogMonth + +func (r *logFileRepo) openConfiguredLogRoot() (root *os.Root, exists bool, err error) { + rootPath, exists, err := r.configuredLogRoot() + if err != nil || !exists { + return nil, exists, err } - root, err := r.logRoot() + root, err = os.OpenRoot(rootPath) + if err != nil { + return nil, false, fmt.Errorf("%w: %v", biz.ErrLogRootUnavailable, err) + } + return root, true, nil +} + +func (r *logFileRepo) LogDates(ctx context.Context, month string) ([]biz.LogDate, error) { + if err := validateLogMonth(month); err != nil { + return nil, err + } + logRoot, exists, err := r.openConfiguredLogRoot() if err != nil { return nil, err } - entries, err := os.ReadDir(root) - if errors.Is(err, fs.ErrNotExist) { + if !exists { return []biz.LogDate{}, nil } + defer logRoot.Close() + + entries, err := fs.ReadDir(logRoot.FS(), ".") if err != nil { - return nil, err + return nil, fmt.Errorf("%w: %v", biz.ErrLogRootUnavailable, err) } - out := []biz.LogDate{} + result := make([]biz.LogDate, 0) for _, entry := range entries { - if ctx.Err() != nil { - return nil, ctx.Err() + if err = ctx.Err(); err != nil { + return nil, err } if entry.Type()&os.ModeSymlink != 0 || !entry.IsDir() || !strings.HasPrefix(entry.Name(), month+"-") { continue } - if parsed, parseErr := time.Parse("2006-01-02", entry.Name()); parseErr != nil || parsed.Format("2006-01-02") != entry.Name() { + if validateLogDate(entry.Name()) != nil { continue } - count := 0 - _ = filepath.WalkDir(filepath.Join(root, entry.Name()), func(_ string, item fs.DirEntry, walkErr error) error { - if walkErr == nil && !item.IsDir() && strings.EqualFold(filepath.Ext(item.Name()), ".log") { - count++ - } - return nil - }) + count, countErr := countLogFiles(ctx, logRoot, entry.Name()) + if countErr != nil { + return nil, countErr + } if count > 0 { - out = append(out, biz.LogDate{Date: entry.Name(), FileCount: count}) + result = append(result, biz.LogDate{Date: entry.Name(), FileCount: count}) } } - sort.Slice(out, func(i, j int) bool { return out[i].Date < out[j].Date }) - return out, nil + sort.Slice(result, func(i, j int) bool { return result[i].Date < result[j].Date }) + return result, nil } + func (r *logFileRepo) LogFiles(ctx context.Context, date string) ([]biz.LogFile, error) { - if parsed, err := time.Parse("2006-01-02", date); err != nil || parsed.Format("2006-01-02") != date { - return nil, biz.ErrInvalidLogDate + if err := validateLogDate(date); err != nil { + return nil, err } - root, err := r.logRoot() + logRoot, exists, err := r.openConfiguredLogRoot() if err != nil { return nil, err } - dateRoot := filepath.Join(root, date) - out := []biz.LogFile{} - err = filepath.WalkDir(dateRoot, func(path string, entry fs.DirEntry, walkErr error) error { + if !exists { + return []biz.LogFile{}, nil + } + defer logRoot.Close() + + info, err := logRoot.Lstat(date) + if errors.Is(err, fs.ErrNotExist) { + return []biz.LogFile{}, nil + } + if err != nil { + return nil, fmt.Errorf("%w: %v", biz.ErrLogRootUnavailable, err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return []biz.LogFile{}, nil + } + dateRoot, err := logRoot.OpenRoot(date) + if err != nil { + return nil, fmt.Errorf("%w: %v", biz.ErrLogRootUnavailable, err) + } + defer dateRoot.Close() + + result := make([]biz.LogFile, 0) + err = fs.WalkDir(dateRoot.FS(), ".", func(path string, entry fs.DirEntry, walkErr error) error { if walkErr != nil { - if errors.Is(walkErr, fs.ErrNotExist) { - return nil - } return walkErr } - if ctx.Err() != nil { - return ctx.Err() + if err := ctx.Err(); err != nil { + return err + } + if path == "." { + return nil + } + if entry.Type()&os.ModeSymlink != 0 { + if entry.IsDir() { + return filepath.SkipDir + } + return nil + } + if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), ".log") { + return nil + } + fileInfo, infoErr := entry.Info() + if infoErr != nil { + return infoErr + } + if fileInfo.Mode().IsRegular() { + result = append(result, biz.LogFile{Path: path, Name: entry.Name(), Size: fileInfo.Size(), ModifiedAt: fileInfo.ModTime()}) + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("%w: %v", biz.ErrLogRootUnavailable, err) + } + sort.Slice(result, func(i, j int) bool { return result[i].Path < result[j].Path }) + return result, nil +} + +func (r *logFileRepo) LogContent(ctx context.Context, date, apiPath string, cursor *int64) (*biz.LogContent, error) { + result := &biz.LogContent{Date: date, Path: apiPath} + if err := ctx.Err(); err != nil { + return nil, err + } + if cursor != nil && *cursor < 0 { + return nil, biz.ErrInvalidLogPath + } + file, info, err := r.openValidatedLogFile(date, apiPath) + if err != nil { + return nil, err + } + defer file.Close() + + end := info.Size() + if cursor != nil && *cursor <= end { + end = *cursor + } + start, limitedByBytes, err := findLogChunkStart(file, end) + if err != nil { + return nil, fmt.Errorf("%w: %v", biz.ErrLogFileUnreadable, err) + } + data, err := readLogRange(file, start, end) + if err != nil { + return nil, fmt.Errorf("%w: %v", biz.ErrLogFileUnreadable, err) + } + if int64(len(data)) < end-start { + currentInfo, statErr := file.Stat() + if statErr != nil || currentInfo.Size() < start { + if statErr != nil { + return nil, fmt.Errorf("%w: %v", biz.ErrLogFileUnreadable, statErr) + } + return nil, biz.ErrLogFileUnreadable + } + info = currentInfo + } + result.Content = string(data) + result.LineCount = countLogicalLines(data) + result.NextCursor = start + result.HasMore = start > 0 + result.LimitedByBytes = limitedByBytes + result.Size = info.Size() + result.ModifiedAt = info.ModTime() + return result, nil +} + +func (r *logFileRepo) openValidatedLogFile(date, apiPath string) (file *os.File, info os.FileInfo, err error) { + if err = validateLogDate(date); err != nil { + return nil, nil, err + } + segments, err := validateLogAPIPath(apiPath) + if err != nil { + return nil, nil, err + } + logRoot, exists, err := r.openConfiguredLogRoot() + if err != nil { + return nil, nil, err + } + if !exists { + return nil, nil, biz.ErrLogFileNotFound + } + defer logRoot.Close() + + dateInfo, err := logRoot.Lstat(date) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil, biz.ErrLogFileNotFound + } + if err != nil { + return nil, nil, fmt.Errorf("%w: %v", biz.ErrLogFileUnreadable, err) + } + if dateInfo.Mode()&os.ModeSymlink != 0 || !dateInfo.IsDir() { + return nil, nil, biz.ErrInvalidLogPath + } + dateRoot, err := logRoot.OpenRoot(date) + if err != nil { + return nil, nil, fmt.Errorf("%w: %v", biz.ErrLogFileUnreadable, err) + } + defer dateRoot.Close() + + relativePath := "" + var validatedInfo os.FileInfo + for index, segment := range segments { + relativePath = filepath.Join(relativePath, segment) + validatedInfo, err = dateRoot.Lstat(relativePath) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil, biz.ErrLogFileNotFound + } + if err != nil { + return nil, nil, fmt.Errorf("%w: %v", biz.ErrLogFileUnreadable, err) + } + if validatedInfo.Mode()&os.ModeSymlink != 0 { + return nil, nil, biz.ErrInvalidLogPath + } + if index < len(segments)-1 && !validatedInfo.IsDir() { + return nil, nil, biz.ErrInvalidLogPath + } + } + if validatedInfo == nil || !validatedInfo.Mode().IsRegular() { + return nil, nil, biz.ErrInvalidLogPath + } + file, err = dateRoot.Open(relativePath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, nil, biz.ErrLogFileNotFound + } + return nil, nil, fmt.Errorf("%w: %v", biz.ErrLogFileUnreadable, err) + } + openedInfo, statErr := file.Stat() + if statErr != nil || !openedInfo.Mode().IsRegular() || !os.SameFile(validatedInfo, openedInfo) { + file.Close() + if statErr != nil { + return nil, nil, fmt.Errorf("%w: %v", biz.ErrLogFileUnreadable, statErr) + } + return nil, nil, biz.ErrInvalidLogPath + } + return file, openedInfo, nil +} + +func validateLogMonth(month string) error { + parsed, err := time.Parse("2006-01", month) + if err != nil || parsed.Format("2006-01") != month { + return biz.ErrInvalidLogMonth + } + return nil +} + +func validateLogDate(date string) error { + parsed, err := time.Parse("2006-01-02", date) + if err != nil || parsed.Format("2006-01-02") != date { + return biz.ErrInvalidLogDate + } + return nil +} + +func validateLogAPIPath(apiPath string) ([]string, error) { + if apiPath == "" || strings.Contains(apiPath, "\\") || strings.Contains(apiPath, ":") || pathpkg.IsAbs(apiPath) { + return nil, biz.ErrInvalidLogPath + } + segments := strings.Split(apiPath, "/") + for _, segment := range segments { + if segment == "" || segment == "." || segment == ".." { + return nil, biz.ErrInvalidLogPath + } + } + if !strings.EqualFold(pathpkg.Ext(apiPath), ".log") { + return nil, biz.ErrInvalidLogPath + } + return segments, nil +} + +func countLogFiles(ctx context.Context, logRoot *os.Root, date string) (count int, err error) { + dateInfo, err := logRoot.Lstat(date) + if err != nil || dateInfo.Mode()&os.ModeSymlink != 0 || !dateInfo.IsDir() { + return 0, err + } + dateRoot, err := logRoot.OpenRoot(date) + if err != nil { + return 0, err + } + defer dateRoot.Close() + err = fs.WalkDir(dateRoot.FS(), ".", func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if err := ctx.Err(); err != nil { + return err + } + if path == "." { + return nil } if entry.Type()&os.ModeSymlink != 0 { if entry.IsDir() { @@ -108,70 +345,80 @@ func (r *logFileRepo) LogFiles(ctx context.Context, date string) ([]biz.LogFile, if infoErr != nil { return infoErr } - relative, _ := filepath.Rel(dateRoot, path) - out = append(out, biz.LogFile{Path: filepath.ToSlash(relative), Name: entry.Name(), Size: info.Size(), ModifiedAt: info.ModTime()}) + if info.Mode().IsRegular() { + count++ + } return nil }) - sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path }) - return out, err -} -func (r *logFileRepo) LogContent(ctx context.Context, date, path string, cursor *int64) (*biz.LogContent, error) { - if parsed, err := time.Parse("2006-01-02", date); err != nil || parsed.Format("2006-01-02") != date { - return nil, biz.ErrInvalidLogDate - } - root, err := r.logRoot() if err != nil { + return 0, fmt.Errorf("%w: %v", biz.ErrLogRootUnavailable, err) + } + return count, nil +} + +func readLogRange(file *os.File, start, end int64) ([]byte, error) { + if end <= start { + return []byte{}, nil + } + data := make([]byte, int(end-start)) + n, err := file.ReadAt(data, start) + if err != nil && !errors.Is(err, io.EOF) { return nil, err } - dateRoot := filepath.Join(root, date) - target := filepath.Clean(filepath.Join(dateRoot, filepath.FromSlash(path))) - if target == dateRoot || !strings.HasPrefix(target, dateRoot+string(os.PathSeparator)) || !strings.EqualFold(filepath.Ext(target), ".log") { - return nil, biz.ErrInvalidLogPath - } - info, err := os.Lstat(target) - if errors.Is(err, fs.ErrNotExist) { - return nil, biz.ErrLogFileNotFound - } - if err != nil { - return nil, errors.Join(biz.ErrLogFileUnreadable, err) - } - if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { - return nil, biz.ErrInvalidLogPath - } - end := info.Size() - if cursor != nil && *cursor >= 0 && *cursor < end { - end = *cursor - } - start := end - int64(2*1024*1024) - limited := start > 0 - if start < 0 { - start = 0 - } - file, err := os.Open(target) - if err != nil { - return nil, errors.Join(biz.ErrLogFileUnreadable, err) - } - defer file.Close() - data := make([]byte, end-start) - n, err := file.ReadAt(data, start) - if err != nil && n == 0 { - return nil, errors.Join(biz.ErrLogFileUnreadable, err) - } - data = data[:n] - lines := bytes.Split(data, []byte("\n")) - if len(lines) > 501 { - drop := len(lines) - 501 - offset := 0 - for _, line := range lines[:drop] { - offset += len(line) + 1 - } - start += int64(offset) - data = data[offset:] - limited = true - } - lineCount := bytes.Count(data, []byte("\n")) - if len(data) > 0 && data[len(data)-1] != '\n' { - lineCount++ - } - return &biz.LogContent{Date: date, Path: path, Content: string(data), LineCount: lineCount, NextCursor: start, HasMore: start > 0, LimitedByBytes: limited, Size: info.Size(), ModifiedAt: info.ModTime()}, nil + return data[:n], nil +} + +func findLogChunkStart(file *os.File, end int64) (start int64, limitedByBytes bool, err error) { + if end <= 0 { + return 0, false, nil + } + lastByte := []byte{0} + if _, err = file.ReadAt(lastByte, end-1); err != nil { + return 0, false, err + } + targetNewlines := defaultLogChunkLines + if lastByte[0] == '\n' { + targetNewlines++ + } + position, scanned, newlines := end, int64(0), 0 + for position > 0 && scanned < maxLogChunkBytes { + readSize := int64(logReadBlockSize) + if readSize > position { + readSize = position + } + if remaining := int64(maxLogChunkBytes) - scanned; readSize > remaining { + readSize = remaining + } + blockStart := position - readSize + block := make([]byte, int(readSize)) + n, readErr := file.ReadAt(block, blockStart) + if readErr != nil && !errors.Is(readErr, io.EOF) { + return 0, false, readErr + } + for index := n - 1; index >= 0; index-- { + if block[index] == '\n' { + newlines++ + if newlines == targetNewlines { + return blockStart + int64(index) + 1, false, nil + } + } + } + scanned += int64(n) + position = blockStart + } + if position > 0 { + return end - int64(maxLogChunkBytes), true, nil + } + return 0, false, nil +} + +func countLogicalLines(data []byte) int { + if len(data) == 0 { + return 0 + } + count := bytes.Count(data, []byte{'\n'}) + if data[len(data)-1] != '\n' { + count++ + } + return count } diff --git a/internal/data/runtime_settings.go b/internal/data/runtime_settings.go index 3abc0fe..4930234 100644 --- a/internal/data/runtime_settings.go +++ b/internal/data/runtime_settings.go @@ -68,7 +68,7 @@ func (s *runtimeSettings) MediaSettings() biz.MediaSettings { if config == nil || config.Media == nil { return biz.MediaSettings{} } - return biz.MediaSettings{SessionTTL: int(config.Media.SessionTtl), MaxFileSize: config.Media.MaxFileSize} + return biz.MediaSettings{SessionTTL: int(config.Media.SessionTtl), MaxFileSize: config.Media.MaxFileSize, ChunkDir: config.Media.ChunkDir} } func (s *runtimeSettings) UseMultipoint() bool { @@ -110,5 +110,5 @@ func (i *tokenIssuer) ParseToken(token string) (*biz.AuthClaims, error) { return nil, biz.ErrTokenInvalid } } - return &biz.AuthClaims{UUID: claims.UUID, ID: claims.ID, Username: claims.Username, NickName: claims.NickName, AuthorityID: claims.AuthorityID, BufferTime: time.Duration(claims.BufferTime) * time.Second, MustChangePwd: claims.MustChangePwd, Issuer: claims.Issuer, ExpiresAt: claims.ExpiresAt.Time}, nil + return &biz.AuthClaims{UUID: claims.UUID, ID: claims.ID, Username: claims.Username, NickName: claims.NickName, AuthorityID: claims.AuthorityID, UserType: claims.UserType, BufferTime: time.Duration(claims.BufferTime) * time.Second, MustChangePwd: claims.MustChangePwd, Issuer: claims.Issuer, ExpiresAt: claims.ExpiresAt.Time}, nil } diff --git a/internal/data/storage/aliyun_storage.go b/internal/data/storage/aliyun_storage.go index e6d7ffc..7893967 100644 --- a/internal/data/storage/aliyun_storage.go +++ b/internal/data/storage/aliyun_storage.go @@ -95,7 +95,9 @@ func (s *aliyunStorage) List(_ context.Context, prefix, cursor string, limit int } items := make([]*biz.StoredFile, 0, len(result.Objects)) for _, object := range result.Objects { - items = append(items, s.file(object.Key, object.Size)) + item := s.file(object.Key, object.Size) + item.LastModified = object.LastModified + items = append(items, item) } next := "" if result.IsTruncated { diff --git a/internal/data/storage/aws_storage.go b/internal/data/storage/aws_storage.go index e18a37d..aa3d7e0 100644 --- a/internal/data/storage/aws_storage.go +++ b/internal/data/storage/aws_storage.go @@ -141,7 +141,11 @@ func (s *awsStorage) List(ctx context.Context, prefix, cursor string, limit int) if object.Size != nil { size = *object.Size } - items = append(items, s.file(*object.Key, size)) + item := s.file(*object.Key, size) + if object.LastModified != nil { + item.LastModified = *object.LastModified + } + items = append(items, item) } next := "" if result.NextContinuationToken != nil { diff --git a/internal/data/storage/huawei_storage.go b/internal/data/storage/huawei_storage.go index 59cb76a..ff86758 100644 --- a/internal/data/storage/huawei_storage.go +++ b/internal/data/storage/huawei_storage.go @@ -94,7 +94,9 @@ func (s *huaweiStorage) List(_ context.Context, prefix, cursor string, limit int } items := make([]*biz.StoredFile, 0, len(result.Contents)) for _, object := range result.Contents { - items = append(items, s.file(object.Key, object.Size)) + item := s.file(object.Key, object.Size) + item.LastModified = object.LastModified + items = append(items, item) } next := result.NextMarker if result.IsTruncated && next == "" && len(result.Contents) > 0 { diff --git a/internal/data/storage/local.go b/internal/data/storage/local.go index fe018dd..6c4ef34 100644 --- a/internal/data/storage/local.go +++ b/internal/data/storage/local.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "fmt" "io" + "mime" "os" "path/filepath" "sort" @@ -200,7 +201,7 @@ func (s *fileStorage) List(ctx context.Context, prefix, cursor string, limit int if infoErr != nil { return infoErr } - items = append(items, &biz.StoredFile{Name: entry.Name(), Path: relative, URL: s.urlPrefix + "/" + relative, Size: info.Size()}) + items = append(items, &biz.StoredFile{Name: entry.Name(), Path: relative, URL: s.urlPrefix + "/" + relative, Size: info.Size(), LastModified: info.ModTime(), ContentType: mime.TypeByExtension(filepath.Ext(entry.Name()))}) return nil }) if err != nil { diff --git a/internal/data/storage/qiniu_storage.go b/internal/data/storage/qiniu_storage.go index fe272d5..5b4f69a 100644 --- a/internal/data/storage/qiniu_storage.go +++ b/internal/data/storage/qiniu_storage.go @@ -120,7 +120,10 @@ func (s *qiniuStorage) List(ctx context.Context, prefix, cursor string, limit in } out := make([]*biz.StoredFile, 0, len(entries)) for _, entry := range entries { - out = append(out, s.file(entry.Key, entry.Fsize)) + item := s.file(entry.Key, entry.Fsize) + item.LastModified = time.Unix(0, entry.PutTime*100) + item.ContentType = entry.MimeType + out = append(out, item) } return out, marker, more, nil } diff --git a/internal/data/storage/s3_storage.go b/internal/data/storage/s3_storage.go index 29c5a95..affc431 100644 --- a/internal/data/storage/s3_storage.go +++ b/internal/data/storage/s3_storage.go @@ -118,7 +118,10 @@ func (s *s3Storage) List(ctx context.Context, prefix, cursor string, limit int) if item.Err != nil { return nil, "", false, item.Err } - out = append(out, s.file(item.Key, item.Size)) + file := s.file(item.Key, item.Size) + file.LastModified = item.LastModified + file.ContentType = item.ContentType + out = append(out, file) if len(out) > limit { break } diff --git a/internal/data/storage/tencent_storage.go b/internal/data/storage/tencent_storage.go index a5dc5d0..f1284e9 100644 --- a/internal/data/storage/tencent_storage.go +++ b/internal/data/storage/tencent_storage.go @@ -8,6 +8,7 @@ import ( "net/url" "path" "strings" + "time" cos "github.com/tencentyun/cos-go-sdk-v5" "kra/internal/biz" @@ -107,7 +108,14 @@ func (s *tencentStorage) List(ctx context.Context, prefix, cursor string, limit } items := make([]*biz.StoredFile, 0, len(result.Contents)) for _, object := range result.Contents { - items = append(items, s.file(object.Key, object.Size)) + item := s.file(object.Key, object.Size) + for _, layout := range []string{time.RFC3339, "2006-01-02T15:04:05.000Z"} { + if parsed, parseErr := time.Parse(layout, object.LastModified); parseErr == nil { + item.LastModified = parsed + break + } + } + items = append(items, item) } next := result.NextMarker if result.IsTruncated && next == "" && len(result.Contents) > 0 { diff --git a/internal/data/user.go b/internal/data/user.go index 143697c..8962dd6 100644 --- a/internal/data/user.go +++ b/internal/data/user.go @@ -429,6 +429,9 @@ func (r *userRepo) UpdateUserWithAuthorities(ctx context.Context, user *biz.User } func (r *userRepo) DeleteUser(ctx context.Context, id uint) error { return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("id = ?", id).Delete(&userPO{}).Error; err != nil { + return err + } if err := tx.Where("sys_user_id = ?", id).Delete(&userAuthorityPO{}).Error; err != nil { return err } @@ -438,7 +441,7 @@ func (r *userRepo) DeleteUser(ctx context.Context, id uint) error { if err := tx.Where("sys_user_id = ?", id).Delete(&userPositionPO{}).Error; err != nil { return err } - return tx.Delete(&userPO{}, id).Error + return nil }) } func (r *userRepo) UpdatePassword(ctx context.Context, id uint, password string, clearMustChange bool) error { diff --git a/internal/server/handler/dictionary.go b/internal/server/handler/dictionary.go index 1e3ae24..9fc45c6 100644 --- a/internal/server/handler/dictionary.go +++ b/internal/server/handler/dictionary.go @@ -242,10 +242,10 @@ func (h *Dictionary) Path(c *gin.Context) { httpx.Fail(c, "字典详情ID格式错误") return } - item, err := h.service.DictionaryDetail(c.Request.Context(), uint(id)) + path, err := h.service.DictionaryPath(c.Request.Context(), uint(id)) if err != nil { httpx.Fail(c, "获取失败") return } - httpx.Write(c, httpx.CodeSuccess, gin.H{"path": item.Path}, "获取成功") + httpx.Write(c, httpx.CodeSuccess, gin.H{"path": path}, "获取成功") } diff --git a/internal/server/handler/export.go b/internal/server/handler/export.go index fdac1ff..636ea65 100644 --- a/internal/server/handler/export.go +++ b/internal/server/handler/export.go @@ -30,11 +30,6 @@ func exportParams(values url.Values) map[string]string { out[key] = items[0] } } - for key, items := range values { - if key != "params" && len(items) > 0 { - out[key] = items[0] - } - } return out } diff --git a/internal/server/handler/organization.go b/internal/server/handler/organization.go index 3497a89..3674701 100644 --- a/internal/server/handler/organization.go +++ b/internal/server/handler/organization.go @@ -1,8 +1,6 @@ package handler import ( - "strconv" - "kra/internal/server/httpx" "kra/internal/service" "kra/internal/service/dto" @@ -24,7 +22,7 @@ func (h *Organization) ListDepartments(c *gin.Context) { _ = c.ShouldBindJSON(&req) items, err := h.departments.Departments(c.Request.Context(), req.Name) if err != nil { - httpx.Fail(c, "获取失败") + httpx.Fail(c, "获取失败:"+err.Error()) return } httpx.Write(c, httpx.CodeSuccess, items, "获取成功") @@ -63,19 +61,23 @@ func (h *Organization) UpdateDepartment(c *gin.Context) { } func (h *Organization) DeleteDepartment(c *gin.Context) { var req dto.DeleteDepartmentRequest - if c.ShouldBindJSON(&req) != nil { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) return } if err := h.departments.Delete(c.Request.Context(), req.ID); err != nil { - httpx.Fail(c, err.Error()) + httpx.Fail(c, "删除失败:"+err.Error()) return } httpx.Write(c, httpx.CodeSuccess, nil, "删除成功") } func (h *Organization) FindDepartment(c *gin.Context) { - id, _ := strconv.ParseUint(c.Query("id"), 10, 64) - item, err := h.departments.Department(c.Request.Context(), uint(id)) + var req dto.DeleteDepartmentRequest + if err := c.ShouldBindQuery(&req); err != nil { + httpx.Fail(c, err.Error()) + return + } + item, err := h.departments.Department(c.Request.Context(), req.ID) if err != nil { httpx.Fail(c, "获取失败:"+err.Error()) return @@ -83,8 +85,12 @@ func (h *Organization) FindDepartment(c *gin.Context) { httpx.Write(c, httpx.CodeSuccess, item, "获取成功") } func (h *Organization) DepartmentUsers(c *gin.Context) { - id, _ := strconv.ParseUint(c.Query("deptId"), 10, 64) - ids, err := h.departments.UserIDs(c.Request.Context(), uint(id)) + var req dto.SetDepartmentUsersRequest + if err := c.ShouldBindQuery(&req); err != nil { + httpx.Fail(c, err.Error()) + return + } + ids, err := h.departments.UserIDs(c.Request.Context(), req.DepartmentID) if err != nil { httpx.Fail(c, "获取失败:"+err.Error()) return @@ -96,8 +102,12 @@ func (h *Organization) DepartmentUsers(c *gin.Context) { } func (h *Organization) SetDepartmentUsers(c *gin.Context) { var req dto.SetDepartmentUsersRequest - if c.ShouldBindJSON(&req) != nil { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) + return + } + if req.DepartmentID == 0 { + httpx.Fail(c, "部门ID不能为空") return } if err := h.departments.SetUsers(c.Request.Context(), &req); err != nil { @@ -108,8 +118,8 @@ func (h *Organization) SetDepartmentUsers(c *gin.Context) { } func (h *Organization) SetUserDepartments(c *gin.Context) { var req dto.SetUserDepartmentsRequest - if c.ShouldBindJSON(&req) != nil { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) return } if err := h.departments.SetUserDepartments(c.Request.Context(), &req); err != nil { @@ -121,13 +131,13 @@ func (h *Organization) SetUserDepartments(c *gin.Context) { func (h *Organization) ListPositions(c *gin.Context) { var req dto.PositionListRequest - if c.ShouldBindJSON(&req) != nil { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) return } items, total, err := h.positions.Positions(c.Request.Context(), &req) if err != nil { - httpx.Fail(c, "获取失败") + httpx.Fail(c, "获取失败:"+err.Error()) return } httpx.Write(c, httpx.CodeSuccess, httpx.PageResult{List: items, Total: total, Page: req.Page, PageSize: req.PageSize}, "获取成功") @@ -166,8 +176,8 @@ func (h *Organization) UpdatePosition(c *gin.Context) { } func (h *Organization) DeletePosition(c *gin.Context) { var req dto.DeletePositionRequest - if c.ShouldBindJSON(&req) != nil { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) return } if err := h.positions.Delete(c.Request.Context(), req.ID); err != nil { @@ -177,8 +187,12 @@ func (h *Organization) DeletePosition(c *gin.Context) { httpx.Write(c, httpx.CodeSuccess, nil, "删除成功") } func (h *Organization) FindPosition(c *gin.Context) { - id, _ := strconv.ParseUint(c.Query("id"), 10, 64) - item, err := h.positions.Position(c.Request.Context(), uint(id)) + var req dto.DeletePositionRequest + if err := c.ShouldBindQuery(&req); err != nil { + httpx.Fail(c, err.Error()) + return + } + item, err := h.positions.Position(c.Request.Context(), req.ID) if err != nil { httpx.Fail(c, "获取失败:"+err.Error()) return @@ -186,8 +200,12 @@ func (h *Organization) FindPosition(c *gin.Context) { httpx.Write(c, httpx.CodeSuccess, item, "获取成功") } func (h *Organization) PositionUsers(c *gin.Context) { - id, _ := strconv.ParseUint(c.Query("positionId"), 10, 64) - ids, err := h.positions.UserIDs(c.Request.Context(), uint(id)) + var req dto.SetPositionUsersRequest + if err := c.ShouldBindQuery(&req); err != nil { + httpx.Fail(c, err.Error()) + return + } + ids, err := h.positions.UserIDs(c.Request.Context(), req.PositionID) if err != nil { httpx.Fail(c, "获取失败:"+err.Error()) return @@ -199,8 +217,12 @@ func (h *Organization) PositionUsers(c *gin.Context) { } func (h *Organization) SetPositionUsers(c *gin.Context) { var req dto.SetPositionUsersRequest - if c.ShouldBindJSON(&req) != nil { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) + return + } + if req.PositionID == 0 { + httpx.Fail(c, "岗位ID不能为空") return } if err := h.positions.SetUsers(c.Request.Context(), &req); err != nil { @@ -211,8 +233,8 @@ func (h *Organization) SetPositionUsers(c *gin.Context) { } func (h *Organization) SetUserPositions(c *gin.Context) { var req dto.SetUserPositionsRequest - if c.ShouldBindJSON(&req) != nil { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) return } if err := h.positions.SetUserPositions(c.Request.Context(), &req); err != nil { diff --git a/internal/server/handler/public.go b/internal/server/handler/public.go index 4d4566f..9dae495 100644 --- a/internal/server/handler/public.go +++ b/internal/server/handler/public.go @@ -123,11 +123,7 @@ func (h *Public) InitializeDatabase(engine *gin.Engine) gin.HandlerFunc { values = append(values, dto.Route{Path: route.Path, Method: route.Method}) } if err := h.system.InitializeRoutes(c.Request.Context(), &input, values); err != nil { - if errors.Is(err, biz.ErrTaskRuntimeReload) { - httpx.Fail(c, "数据库已初始化,但定时任务加载失败:"+err.Error()) - return - } - httpx.Fail(c, "自动创建数据库失败: "+err.Error()) + httpx.Fail(c, "自动创建数据库失败,请查看后台日志,检查后在进行初始化") return } httpx.Write(c, httpx.CodeSuccess, gin.H{}, "自动创建数据库成功") diff --git a/internal/server/handler/system_config.go b/internal/server/handler/system_config.go index d81a3d2..44a61d7 100644 --- a/internal/server/handler/system_config.go +++ b/internal/server/handler/system_config.go @@ -1,9 +1,6 @@ package handler import ( - "errors" - - "kra/internal/biz" "kra/internal/server/httpx" "kra/internal/service" "kra/internal/service/dto" @@ -67,10 +64,6 @@ func (h *SystemConfig) Set(c *gin.Context) { func (h *SystemConfig) Reload(c *gin.Context) { if err := h.system.ReloadConfig(c.Request.Context()); err != nil { - if errors.Is(err, biz.ErrTaskRuntimeReload) { - httpx.Fail(c, "系统配置已重载,但定时任务重载失败:"+err.Error()) - return - } httpx.Fail(c, "重载系统失败:"+err.Error()) return } diff --git a/internal/server/handler/task.go b/internal/server/handler/task.go index f69b75a..540b55c 100644 --- a/internal/server/handler/task.go +++ b/internal/server/handler/task.go @@ -1,14 +1,13 @@ package handler import ( - "errors" "fmt" "net/http" "strconv" "time" - "kra/internal/biz" "kra/internal/server/httpx" + "kra/internal/server/middleware" "kra/internal/service" "kra/internal/service/dto" @@ -31,13 +30,7 @@ func (h *Task) Create(c *gin.Context) { } _, err := h.service.CreateRequest(c.Request.Context(), &req) if err != nil { - if errors.Is(err, biz.ErrTaskSchedule) { - var scheduleErr *biz.TaskScheduleError - errors.As(err, &scheduleErr) - httpx.Fail(c, "调度失败:"+scheduleErr.Error()) - return - } - httpx.Fail(c, "创建失败:"+err.Error()) + httpx.Fail(c, "创建失败: "+err.Error()) return } httpx.Write(c, httpx.CodeSuccess, gin.H{}, "创建成功") @@ -50,13 +43,7 @@ func (h *Task) Update(c *gin.Context) { return } if err := h.service.UpdateRequest(c.Request.Context(), &req); err != nil { - if errors.Is(err, biz.ErrTaskSchedule) { - var scheduleErr *biz.TaskScheduleError - errors.As(err, &scheduleErr) - httpx.Fail(c, "调度失败:"+scheduleErr.Error()) - return - } - httpx.Fail(c, "更新失败:"+err.Error()) + httpx.Fail(c, "更新失败: "+err.Error()) return } httpx.Write(c, httpx.CodeSuccess, gin.H{}, "更新成功") @@ -134,8 +121,13 @@ func (h *Task) Methods(c *gin.Context) { } func (h *Task) AlertStream(c *gin.Context) { + claims := middleware.Claims(c) + if claims == nil || claims.ID == 0 { + httpx.Fail(c, "未获取到用户身份") + return + } c.Header("Content-Type", "text/event-stream") - c.Header("Cache-Control", "no-cache") + c.Header("Cache-Control", "no-cache, no-transform") c.Header("Connection", "keep-alive") c.Header("X-Accel-Buffering", "no") flusher, ok := c.Writer.(http.Flusher) @@ -145,11 +137,11 @@ func (h *Task) AlertStream(c *gin.Context) { } // Remove the normal server deadline for long-lived SSE connections. _ = http.NewResponseController(c.Writer).SetWriteDeadline(time.Time{}) - events := h.service.Subscribe() - defer h.service.Unsubscribe(events) - ticker := time.NewTicker(20 * time.Second) + events := h.service.Subscribe(claims.ID) + defer h.service.Unsubscribe(claims.ID, events) + ticker := time.NewTicker(30 * time.Second) defer ticker.Stop() - _, _ = fmt.Fprint(c.Writer, "event: connected\ndata: {}\n\n") + _, _ = fmt.Fprint(c.Writer, ": connected\n\n") flusher.Flush() for { select { @@ -157,10 +149,10 @@ func (h *Task) AlertStream(c *gin.Context) { if !open { return } - _, _ = fmt.Fprintf(c.Writer, "event: alert\ndata: %s\n\n", event) + _, _ = fmt.Fprintf(c.Writer, "event: timedTask:alert\ndata: %s\n\n", event) flusher.Flush() case <-ticker.C: - _, _ = fmt.Fprint(c.Writer, ": keepalive\n\n") + _, _ = fmt.Fprint(c.Writer, ": ping\n\n") flusher.Flush() case <-c.Request.Context().Done(): return diff --git a/internal/service/dictionary.go b/internal/service/dictionary.go index d6e293b..955a3d9 100644 --- a/internal/service/dictionary.go +++ b/internal/service/dictionary.go @@ -124,6 +124,21 @@ func (s *DictionaryService) DictionaryDetail(ctx context.Context, id uint) (*dto } return detailDTO(v), nil } +func (s *DictionaryService) DictionaryPath(ctx context.Context, id uint) ([]*dto.DictionaryDetailResponse, error) { + path := make([]*dto.DictionaryDetailResponse, 0) + for id != 0 { + value, err := s.uc.FindDictionaryDetail(ctx, id) + if err != nil { + return nil, err + } + path = append([]*dto.DictionaryDetailResponse{detailDTO(value)}, path...) + if value.ParentID == nil { + break + } + id = *value.ParentID + } + return path, nil +} func (s *DictionaryService) DictionaryTree(ctx context.Context, id uint, typ string) ([]*dto.DictionaryDetailResponse, error) { items, err := s.uc.DictionaryDetailTree(ctx, id, typ) if err != nil { diff --git a/internal/service/dto/media.go b/internal/service/dto/media.go index 74e3d3a..0bdbbc1 100644 --- a/internal/service/dto/media.go +++ b/internal/service/dto/media.go @@ -29,10 +29,10 @@ type MediaCategoryResponse struct { } type StorageObjectResponse struct { - Name string `json:"name"` - Key string `json:"key"` - URL string `json:"url"` - Size int64 `json:"size"` + Key string `json:"Key"` + Size int64 `json:"Size"` + LastModified time.Time `json:"LastModified"` + ContentType string `json:"ContentType"` } type InitUploadResponse struct { diff --git a/internal/service/dto/organization.go b/internal/service/dto/organization.go index 0f1c009..8805212 100644 --- a/internal/service/dto/organization.go +++ b/internal/service/dto/organization.go @@ -15,10 +15,10 @@ type DepartmentListRequest struct { Name string `json:"name"` } type DeleteDepartmentRequest struct { - ID uint `json:"ID"` + ID uint `json:"ID" form:"id"` } type SetDepartmentUsersRequest struct { - DepartmentID uint `json:"deptId"` + DepartmentID uint `json:"deptId" form:"deptId"` UserIDs []uint `json:"userIds"` } type SetUserDepartmentsRequest struct { @@ -60,10 +60,10 @@ type PositionListRequest struct { Status *bool `json:"status"` } type DeletePositionRequest struct { - ID uint `json:"ID"` + ID uint `json:"ID" form:"id"` } type SetPositionUsersRequest struct { - PositionID uint `json:"positionId"` + PositionID uint `json:"positionId" form:"positionId"` UserIDs []uint `json:"userIds"` } type SetUserPositionsRequest struct { diff --git a/internal/service/export.go b/internal/service/export.go index fedce5d..34d8b5e 100644 --- a/internal/service/export.go +++ b/internal/service/export.go @@ -87,13 +87,19 @@ func (s *ExportService) TemplatesFilter(ctx context.Context, page, size int, nam return s.Templates(ctx, page, size, &biz.ExportTemplate{Name: name, TableName: tableName, TemplateID: templateID, StartCreatedAt: start, EndCreatedAt: end}) } func exportDTO(v *biz.ExportTemplate) *dto.ExportTemplateResponse { - conditions := make([]dto.ExportConditionResponse, 0, len(v.Conditions)) - for _, x := range v.Conditions { - conditions = append(conditions, dto.ExportConditionResponse{From: x.From, Column: x.Column, Operator: x.Operator}) + var conditions []dto.ExportConditionResponse + if v.Conditions != nil { + conditions = make([]dto.ExportConditionResponse, 0, len(v.Conditions)) + for _, x := range v.Conditions { + conditions = append(conditions, dto.ExportConditionResponse{From: x.From, Column: x.Column, Operator: x.Operator}) + } } - joins := make([]dto.ExportJoinResponse, 0, len(v.Joins)) - for _, x := range v.Joins { - joins = append(joins, dto.ExportJoinResponse{Join: x.Join, Table: x.Table, On: x.On}) + var joins []dto.ExportJoinResponse + if v.Joins != nil { + joins = make([]dto.ExportJoinResponse, 0, len(v.Joins)) + for _, x := range v.Joins { + joins = append(joins, dto.ExportJoinResponse{Join: x.Join, Table: x.Table, On: x.On}) + } } return &dto.ExportTemplateResponse{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, DeletedAt: nil, DBName: v.DBName, Name: v.Name, TableName: v.TableName, TemplateID: v.TemplateID, TemplateInfo: v.TemplateInfo, SQL: v.SQL, ImportSQL: v.ImportSQL, Limit: v.Limit, Order: v.Order, Conditions: conditions, Joins: joins} } diff --git a/internal/service/export_excel.go b/internal/service/export_excel.go index e7bbc31..09ab693 100644 --- a/internal/service/export_excel.go +++ b/internal/service/export_excel.go @@ -6,7 +6,9 @@ import ( "errors" "fmt" "io" + "strconv" "strings" + "time" "github.com/xuri/excelize/v2" ) @@ -59,7 +61,23 @@ func (s *ExportService) Export(ctx context.Context, tid string, params map[strin for rowIndex, row := range rows { for column, key := range keys { cell, _ := excelize.CoordinatesToCellName(column+1, rowIndex+2) - _ = file.SetCellValue(sheet, cell, row[key]) + lookup := strings.ReplaceAll(strings.ReplaceAll(key, `"`, ""), "`", "") + if len(template.Joins) > 0 { + if parts := strings.Split(lookup, " as "); len(parts) > 1 { + lookup = strings.TrimSpace(parts[1]) + } else if parts = strings.Split(lookup, "."); len(parts) > 1 { + 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) + } } } buffer, err := file.WriteToBuffer() @@ -108,11 +126,7 @@ func (s *ExportService) Import(ctx context.Context, tid string, reader io.Reader return err } defer file.Close() - sheets := file.GetSheetList() - if len(sheets) == 0 { - return errors.New("工作簿没有工作表") - } - rows, err := file.GetRows(sheets[0]) + rows, err := file.GetRows("Sheet1") if err != nil { return err } @@ -128,6 +142,9 @@ func (s *ExportService) Import(ctx context.Context, tid string, reader io.Reader reverse[labels[key]] = key } headers := rows[0] + for index := range headers { + headers[index] = strings.TrimSpace(headers[index]) + } items := make([]map[string]any, 0, len(rows)-1) for _, row := range rows[1:] { item := map[string]any{} @@ -142,8 +159,5 @@ func (s *ExportService) Import(ctx context.Context, tid string, reader io.Reader } items = append(items, item) } - if err = s.uc.ImportExportRows(ctx, template, items); err != nil { - return fmt.Errorf("导入失败: %w", err) - } - return nil + return s.uc.ImportExportRows(ctx, template, items) } diff --git a/internal/service/media.go b/internal/service/media.go index 0e83102..0be75c3 100644 --- a/internal/service/media.go +++ b/internal/service/media.go @@ -75,7 +75,7 @@ func (s *MediaService) Storage(ctx context.Context, prefix, cursor string, limit } out := make([]*dto.StorageObjectResponse, 0, len(items)) for _, v := range items { - out = append(out, &dto.StorageObjectResponse{Name: v.Name, Key: v.Path, URL: v.URL, Size: v.Size}) + out = append(out, &dto.StorageObjectResponse{Key: v.Path, Size: v.Size, LastModified: v.LastModified, ContentType: v.ContentType}) } return out, next, more, nil } diff --git a/internal/service/task.go b/internal/service/task.go index 09d8fe0..6ca5319 100644 --- a/internal/service/task.go +++ b/internal/service/task.go @@ -76,10 +76,10 @@ func (s *TaskService) Logs(ctx context.Context, page, size int, taskID uint, sta return out, total, nil } -func (s *TaskService) Trigger(ctx context.Context, id uint) error { return s.uc.Trigger(ctx, id) } -func (s *TaskService) Reload(ctx context.Context) error { return s.uc.Reload(ctx) } -func (s *TaskService) Subscribe() chan []byte { return s.uc.Subscribe() } -func (s *TaskService) Unsubscribe(events chan []byte) { s.uc.Unsubscribe(events) } +func (s *TaskService) Trigger(ctx context.Context, id uint) error { return s.uc.Trigger(ctx, id) } +func (s *TaskService) Reload(ctx context.Context) error { return s.uc.Reload(ctx) } +func (s *TaskService) Subscribe(userID uint) chan []byte { return s.uc.Subscribe(userID) } +func (s *TaskService) Unsubscribe(userID uint, events chan []byte) { s.uc.Unsubscribe(userID, events) } func (s *TaskService) RegisteredMethods() []*dto.TaskMethodResponse { methods := biz.RegisteredTaskMethods() diff --git a/internal/worker/task_executor.go b/internal/worker/task_executor.go index 3d100c1..f8afd7d 100644 --- a/internal/worker/task_executor.go +++ b/internal/worker/task_executor.go @@ -56,7 +56,7 @@ func taskHTTPClient(allowPrivate bool) *http.Client { func (e *TaskExecutor) runHTTP(ctx context.Context, task *biz.TimedTask) (string, error) { parsed, err := url.Parse(task.HTTPURL) if err != nil { - return "", err + return "", fmt.Errorf("URL 非法: %w", err) } if parsed.Scheme != "http" && parsed.Scheme != "https" { return "", fmt.Errorf("仅允许 http/https, 实际为 %q", parsed.Scheme) @@ -70,7 +70,11 @@ func (e *TaskExecutor) runHTTP(ctx context.Context, task *biz.TimedTask) (string return "", err } headers := map[string]string{} - _ = json.Unmarshal(task.HTTPHeader, &headers) + if len(task.HTTPHeader) > 0 { + if err = json.Unmarshal(task.HTTPHeader, &headers); err != nil { + return "", fmt.Errorf("http_header 必须是 JSON 对象: %w", err) + } + } for key, value := range headers { request.Header.Set(key, value) } @@ -118,6 +122,8 @@ func (e *TaskExecutor) runMethod(task *biz.TimedTask) error { ttl = int(config.Media.SessionTtl) } err = e.media.CleanupStale(ctx, ttl) + default: + err = fmt.Errorf("方法 %s 未注册(需在 initialize/timer.go 经 task.Register 注册)", task.MethodName) } done <- err }() diff --git a/internal/worker/task_scheduler.go b/internal/worker/task_scheduler.go index 905a02d..5b5d065 100644 --- a/internal/worker/task_scheduler.go +++ b/internal/worker/task_scheduler.go @@ -14,6 +14,7 @@ import ( type TaskScheduler struct { tasks *biz.TaskUsecase + authorities *biz.AuthorityUsecase executor *TaskExecutor logger *slog.Logger standard *cron.Cron @@ -24,7 +25,7 @@ type TaskScheduler struct { runContext context.Context cancel context.CancelFunc subMu sync.RWMutex - subscribers map[chan []byte]struct{} + subscribers map[uint]map[chan []byte]struct{} } type scheduledEntry struct { @@ -32,8 +33,8 @@ type scheduledEntry struct { entry cron.EntryID } -func NewTaskScheduler(tasks *biz.TaskUsecase, executor *TaskExecutor, logger *slog.Logger) *TaskScheduler { - return &TaskScheduler{tasks: tasks, executor: executor, logger: logger, standard: cron.New(), seconds: cron.New(cron.WithSeconds()), entries: map[uint]scheduledEntry{}, subscribers: map[chan []byte]struct{}{}} +func NewTaskScheduler(tasks *biz.TaskUsecase, authorities *biz.AuthorityUsecase, executor *TaskExecutor, logger *slog.Logger) *TaskScheduler { + return &TaskScheduler{tasks: tasks, authorities: authorities, executor: executor, logger: logger, standard: cron.New(), seconds: cron.New(cron.WithSeconds()), entries: map[uint]scheduledEntry{}, subscribers: map[uint]map[chan []byte]struct{}{}} } func NewTaskRuntime(scheduler *TaskScheduler) biz.TaskRuntime { return scheduler } @@ -131,7 +132,12 @@ func (s *TaskScheduler) executionContext() context.Context { func (s *TaskScheduler) run(task *biz.TimedTask, trigger string) { log := s.executor.Run(s.executionContext(), task, trigger) if log.Status != "success" { - s.Broadcast(map[string]any{"taskId": task.ID, "taskName": task.Name, "status": log.Status, "errorMsg": log.ErrorMsg, "time": time.Now()}) + ids, err := s.authorities.AuthorityUserIDs(context.Background(), 888) + if err != nil { + s.logger.Error("query timed task alert recipients failed", "error", err) + return + } + s.PublishToUsers(ids, map[string]any{"taskId": task.ID, "name": task.Name, "error": log.ErrorMsg, "time": time.Now().Format(time.RFC3339)}) } } @@ -196,32 +202,44 @@ func (s *TaskScheduler) Trigger(task *biz.TimedTask) { go s.run(©, "manual") } -func (s *TaskScheduler) Subscribe() chan []byte { +func (s *TaskScheduler) Subscribe(userID uint) chan []byte { ch := make(chan []byte, 16) s.subMu.Lock() - s.subscribers[ch] = struct{}{} + if s.subscribers[userID] == nil { + s.subscribers[userID] = map[chan []byte]struct{}{} + } + s.subscribers[userID][ch] = struct{}{} s.subMu.Unlock() return ch } -func (s *TaskScheduler) Unsubscribe(ch chan []byte) { +func (s *TaskScheduler) Unsubscribe(userID uint, ch chan []byte) { s.subMu.Lock() - if _, ok := s.subscribers[ch]; ok { - delete(s.subscribers, ch) + if subscribers := s.subscribers[userID]; subscribers != nil { + if _, ok := subscribers[ch]; !ok { + s.subMu.Unlock() + return + } + delete(subscribers, ch) + if len(subscribers) == 0 { + delete(s.subscribers, userID) + } close(ch) } s.subMu.Unlock() } -func (s *TaskScheduler) Broadcast(value any) { +func (s *TaskScheduler) PublishToUsers(userIDs []uint, value any) { raw, err := json.Marshal(value) if err != nil { raw = []byte(fmt.Sprint(value)) } s.subMu.RLock() defer s.subMu.RUnlock() - for ch := range s.subscribers { - select { - case ch <- raw: - default: + for _, userID := range userIDs { + for ch := range s.subscribers[userID] { + select { + case ch <- raw: + default: + } } } } diff --git a/pkg/adminauth/token.go b/pkg/adminauth/token.go index 6f12154..0664e1f 100644 --- a/pkg/adminauth/token.go +++ b/pkg/adminauth/token.go @@ -22,6 +22,7 @@ type Claims struct { NickName string AuthorityID uint `json:"AuthorityId"` BufferTime int64 + UserType string MustChangePwd bool `json:"mustChangePwd"` jwt.RegisteredClaims } @@ -31,7 +32,7 @@ func Generate(secret, issuer string, expires, buffer time.Duration, userID, auth return "", nil, errors.New("empty JWT signing key") } now := time.Now() - claims := &Claims{UUID: uuid, ID: userID, Username: username, NickName: nickname, AuthorityID: authorityID, BufferTime: int64(buffer / time.Second), MustChangePwd: mustChange, RegisteredClaims: jwt.RegisteredClaims{Issuer: issuer, IssuedAt: jwt.NewNumericDate(now), NotBefore: jwt.NewNumericDate(now.Add(-time.Second)), ExpiresAt: jwt.NewNumericDate(now.Add(expires))}} + claims := &Claims{UUID: uuid, ID: userID, Username: username, NickName: nickname, AuthorityID: authorityID, BufferTime: int64(buffer / time.Second), UserType: "admin", MustChangePwd: mustChange, RegisteredClaims: jwt.RegisteredClaims{Audience: jwt.ClaimStrings{"GVA"}, Issuer: issuer, NotBefore: jwt.NewNumericDate(now.Add(-1000)), ExpiresAt: jwt.NewNumericDate(now.Add(expires))}} token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret)) return token, claims, err }