commit 4436d3ee887c6110675de545149f02c371b93087 Author: Odboy Date: Wed Jan 14 21:02:13 2026 +0800 feat: 初始化配置中心项目 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3b047d6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# macOS +.DS_Store + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Node +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Build outputs +dist/ +build/ +bin/ +*.exe +*.dll +*.so +*.dylib + +# Go +/vendor/ + +# Java +/target/ +*.class +*.jar +*.war + +# Logs +*.log + +# Environment +.env +.env.local diff --git a/README.md b/README.md new file mode 100644 index 0000000..4d50b80 --- /dev/null +++ b/README.md @@ -0,0 +1,128 @@ +# Kenaito Config Center + +基于 Go 重构的配置中心。 + +## 技术栈 + +- **后端**: Go + Gin + xorm + MySQL +- **前端**: Vue 2 + Vite 5 + Element UI 2 +- **客户端**: Go(Java 客户端保留兼容) + +## 项目结构 + +``` +kenaito-conf/ +├── server/ # Go 管理端 +├── web/ # Vue 前端 +├── go-client/ # Go 客户端 +├── java-springboot-client/ # Java Spring Boot 客户端 +└── sql/ # 数据库脚本 +``` + +## 快速开始 + +### 1. 初始化数据库 + +```bash +mysql -u root -p configcenter < sql/init.sql +mysql -u root -p configcenter < sql/testdata.sql # 测试数据 +``` + +### 2. 启动后端 + +```bash +cd server +# 修改 config.yaml 中的数据库配置 +go run cmd/main.go +# 或编译后运行 +go build -o bin/server cmd/main.go +./bin/server -config config.yaml +``` + +后端默认监听 `:8080`。 + +### 3. 启动前端 + +```bash +cd web +npm install +npm run dev +``` + +前端默认监听 `:3000`,自动代理 API 到后端。 + +### 4. 访问 + +打开 http://localhost:3000 + +默认管理员账号:`admin` / 密码为 BCrypt 哈希对应的原始密码(见 testdata.sql) + +## Go 客户端使用 + +```go +import "kenaito-conf/go-client" + +cfg := &client.Config{ + BaseURL: "http://localhost:8080", + AppName: "myapp", + Env: "dev", + Version: "latest", + SyncInterval: 30, + CacheDir: ".", +} + +c := client.New(cfg) + +// 注册变更回调 +c.OnChange(func(changed map[string]string) { + for k, v := range changed { + fmt.Printf("%s = %s\n", k, v) + } +}) + +// 启动(拉取全量配置 + 开启定时同步) +c.Start() +defer c.Stop() + +// 读取配置 +dbHost := c.Get("db.host") +``` + +## API 概览 + +### 管理端 API(需 JWT 认证) + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | /user/login | 登录 | +| GET | /user/login_user_info | 当前用户信息 | +| GET | /admin/menus | 菜单 | +| POST | /admin/config/list | 配置列表 | +| POST | /admin/config/add | 新增配置 | +| POST | /admin/config/update | 更新配置 | +| GET | /admin/config/:id | 配置详情 | +| POST | /admin/config/delete/:id | 删除配置 | +| GET | /admin/config/config_histories/:id | 配置历史 | +| GET | /admin/config/rollback/:id | 回滚配置 | +| POST | /admin/app/list | 应用列表 | +| POST | /admin/app/add | 新增应用 | +| GET | /admin/app/tree | 应用树 | +| GET | /admin/user/list | 用户列表 | +| POST | /admin/user/add | 新增用户 | +| GET | /admin/group/list | 业务组列表 | +| GET | /admin/profile/list | 环境列表 | + +### 客户端 API(无需 JWT) + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | /api/fetch_all_configs | 全量拉取配置 | +| POST | /api/fetch_changed_configs | 增量拉取变更 | +| GET | /api/ping | 健康检查 | + +## 与原 Java 版的差异 + +- **去掉了 ZooKeeper**:配置变更通知改为客户端定时轮询 +- **认证方式**:管理端用 JWT,客户端 API 暂开放(可后续加签名验证) +- **前端**:从 layui 静态页面改为 Vue 2 + Element UI 前后端分离 +- **加密算法**:保持 AES 兼容,Java 客户端可继续使用 diff --git a/go-client/cache.go b/go-client/cache.go new file mode 100644 index 0000000..4714a4d --- /dev/null +++ b/go-client/cache.go @@ -0,0 +1,38 @@ +package client + +import ( + "encoding/json" + "os" + "path/filepath" +) + +const cacheFile = "config-cache.json" + +// Cache provides local file caching for configs +type Cache struct { + dir string +} + +func NewCache(dir string) *Cache { + return &Cache{dir: dir} +} + +func (c *Cache) Write(data map[string]interface{}) error { + path := filepath.Join(c.dir, cacheFile) + bytes, err := json.Marshal(data) + if err != nil { + return err + } + return os.WriteFile(path, bytes, 0644) +} + +func (c *Cache) Read() (map[string]interface{}, error) { + path := filepath.Join(c.dir, cacheFile) + bytes, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var data map[string]interface{} + err = json.Unmarshal(bytes, &data) + return data, err +} diff --git a/go-client/client.go b/go-client/client.go new file mode 100644 index 0000000..c452f2e --- /dev/null +++ b/go-client/client.go @@ -0,0 +1,291 @@ +package client + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strings" + "sync" + "time" +) + +// Client is the config center client +type Client struct { + config *Config + cache *Cache + listener *Listener + configs map[string]string + secret string + globalSecret string + lastTime int64 + mu sync.RWMutex + httpClient *http.Client + stopCh chan struct{} + stopOnce sync.Once + started bool +} + +// New creates a new config client +func New(config *Config) *Client { + if config.Version == "" { + config.Version = "latest" + } + if config.SyncInterval <= 0 { + config.SyncInterval = 60 + } + if config.CacheDir == "" { + config.CacheDir = "." + } + + return &Client{ + config: config, + cache: NewCache(config.CacheDir), + listener: NewListener(), + configs: make(map[string]string), + httpClient: &http.Client{Timeout: 10 * time.Second}, + stopCh: make(chan struct{}), + } +} + +// OnChange registers a config change handler +func (c *Client) OnChange(handler ChangeHandler) { + c.listener.OnChange(handler) +} + +// Get returns a config value by key +func (c *Client) Get(key string) string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.configs[key] +} + +// GetAll returns all configs +func (c *Client) GetAll() map[string]string { + c.mu.RLock() + defer c.mu.RUnlock() + result := make(map[string]string, len(c.configs)) + for k, v := range c.configs { + result[k] = v + } + return result +} + +// Start initializes the client and starts sync loop +func (c *Client) Start() error { + c.mu.Lock() + if c.started { + c.mu.Unlock() + return nil + } + c.started = true + c.mu.Unlock() + + // fetch all configs + if err := c.fetchAll(); err != nil { + log.Printf("[ConfigClient] fetch all configs error: %v, trying cache", err) + if cached, cacheErr := c.cache.Read(); cacheErr == nil { + c.applyConfigs(cached) + log.Printf("[ConfigClient] loaded %d configs from cache", len(c.configs)) + } else { + return fmt.Errorf("fetch configs failed and no cache: %w", err) + } + } + + // start sync loop + go c.syncLoop() + + return nil +} + +// Stop stops the sync loop +func (c *Client) Stop() { + c.stopOnce.Do(func() { + close(c.stopCh) + }) +} + +func (c *Client) fetchAll() error { + url := fmt.Sprintf("%s/api/fetchAllConfigs?appName=%s&env=%s&version=%s&ignoreGlobal=%v", + c.config.BaseURL, c.config.AppName, c.config.Env, c.config.Version, c.config.IgnoreGlobal) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return err + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("server returned HTTP %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + return err + } + + // check error response + if code, ok := result["code"]; ok { + codeInt, _ := code.(float64) + if codeInt != 0 { + msg, _ := result["msg"].(string) + return fmt.Errorf("server error: %s", msg) + } + } + + // extract secrets + if s, ok := result["kenaito.config.encrypt-secret"]; ok { + c.secret, _ = s.(string) + } + if s, ok := result["kenaito.config.global-encrypt-secret"]; ok { + c.globalSecret, _ = s.(string) + } + + // extract timestamp + if ts, ok := result["latest.fetch.timestamp"]; ok { + if tsStr, ok := ts.(string); ok { + fmt.Sscanf(tsStr, "%d", &c.lastTime) + } + } + + // save to cache + c.cache.Write(result) + + // apply configs + c.applyConfigs(result) + + log.Printf("[ConfigClient] fetched %d configs", len(c.configs)) + return nil +} + +func (c *Client) fetchChanged() error { + url := fmt.Sprintf("%s/api/fetchChangedConfigs", c.config.BaseURL) + + payload := map[string]interface{}{ + "appName": c.config.AppName, + "env": c.config.Env, + "version": c.config.Version, + "lastTime": c.lastTime, + "ignoreGlobal": c.config.IgnoreGlobal, + } + payloadBytes, _ := json.Marshal(payload) + + req, err := http.NewRequest("POST", url, strings.NewReader(string(payloadBytes))) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("server returned HTTP %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + var wrapper struct { + Code int `json:"code"` + Data map[string]string `json:"data"` + } + if err := json.Unmarshal(body, &wrapper); err != nil { + return err + } + + if wrapper.Code != 0 { + return fmt.Errorf("server error code: %d", wrapper.Code) + } + + // update timestamp + if ts, ok := wrapper.Data["latest.fetch.timestamp"]; ok { + fmt.Sscanf(ts, "%d", &c.lastTime) + delete(wrapper.Data, "latest.fetch.timestamp") + } + + if len(wrapper.Data) > 0 { + // decrypt values + changed := make(map[string]string) + for k, v := range wrapper.Data { + decrypted, err := decryptConfig(c.secret, v) + if err != nil && c.globalSecret != "" { + decrypted, err = decryptConfig(c.globalSecret, v) + } + if err != nil { + decrypted = v + } + changed[k] = decrypted + } + + // update local configs + c.mu.Lock() + for k, v := range changed { + c.configs[k] = v + } + c.mu.Unlock() + + // notify listeners + c.listener.Notify(changed) + log.Printf("[ConfigClient] %d configs changed", len(changed)) + } + + return nil +} + +func (c *Client) applyConfigs(data map[string]interface{}) { + c.mu.Lock() + defer c.mu.Unlock() + + for k, v := range data { + // skip meta keys + if strings.HasPrefix(k, "kenaito.") || k == "latest.fetch.timestamp" { + continue + } + strVal, ok := v.(string) + if !ok { + continue + } + // decrypt if needed - try app secret first, then global secret + decrypted, err := decryptConfig(c.secret, strVal) + if err != nil && c.globalSecret != "" { + decrypted, err = decryptConfig(c.globalSecret, strVal) + } + if err != nil { + decrypted = strVal + } + c.configs[k] = decrypted + } +} + +func (c *Client) syncLoop() { + ticker := time.NewTicker(time.Duration(c.config.SyncInterval) * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + if err := c.fetchChanged(); err != nil { + log.Printf("[ConfigClient] sync error: %v", err) + } + case <-c.stopCh: + return + } + } +} diff --git a/go-client/config.go b/go-client/config.go new file mode 100644 index 0000000..95d038b --- /dev/null +++ b/go-client/config.go @@ -0,0 +1,22 @@ +package client + +// Config holds the client configuration +type Config struct { + BaseURL string `json:"baseUrl"` + AppName string `json:"appName"` + Env string `json:"env"` + Version string `json:"version"` + Token string `json:"token"` + SyncInterval int `json:"syncInterval"` // seconds + IgnoreGlobal bool `json:"ignoreGlobal"` + CacheDir string `json:"cacheDir"` +} + +// DefaultConfig returns a config with default values +func DefaultConfig() *Config { + return &Config{ + Version: "latest", + SyncInterval: 60, + CacheDir: ".", + } +} diff --git a/go-client/crypto.go b/go-client/crypto.go new file mode 100644 index 0000000..5dd7baf --- /dev/null +++ b/go-client/crypto.go @@ -0,0 +1,62 @@ +package client + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/md5" + "encoding/base64" + "encoding/hex" + "fmt" + "strings" +) + +const CryptPrefix = "{Cipher}" + +func decryptConfig(secret string, data string) (string, error) { + if !strings.HasPrefix(data, CryptPrefix) { + return data, nil + } + encrypted := strings.TrimPrefix(data, CryptPrefix) + return decryptWithAES(secret, encrypted) +} + +func decryptWithAES(key string, data string) (string, error) { + secretKey := md5.Sum([]byte(key)) + aesKey := hex.EncodeToString(secretKey[:])[16:] + + cipherText, err := base64.StdEncoding.DecodeString(data) + if err != nil { + return "", err + } + + block, err := aes.NewCipher([]byte(aesKey)) + if err != nil { + return "", err + } + + if len(cipherText)%aes.BlockSize != 0 { + return "", fmt.Errorf("ciphertext is not a multiple of block size") + } + + plainText := make([]byte, len(cipherText)) + mode := cipher.NewCBCDecrypter(block, make([]byte, aes.BlockSize)) + mode.CryptBlocks(plainText, cipherText) + + plainText, err = pkcs7Unpad(plainText, aes.BlockSize) + if err != nil { + return "", err + } + + return string(plainText), nil +} + +func pkcs7Unpad(data []byte, blockSize int) ([]byte, error) { + if len(data) == 0 { + return nil, fmt.Errorf("empty data") + } + padding := int(data[len(data)-1]) + if padding > blockSize || padding == 0 { + return nil, fmt.Errorf("invalid padding") + } + return data[:len(data)-padding], nil +} diff --git a/go-client/example/main.go b/go-client/example/main.go new file mode 100644 index 0000000..912f9ac --- /dev/null +++ b/go-client/example/main.go @@ -0,0 +1,56 @@ +package main + +import ( + "fmt" + "kenaito-conf/go-client" + "log" + "os" + "os/signal" + "syscall" +) + +func main() { + // create config + cfg := &client.Config{ + BaseURL: "http://localhost:8080", + AppName: "myapp", + Env: "dev", + Version: "latest", + SyncInterval: 30, // sync every 30 seconds + CacheDir: ".", + } + + // create client + c := client.New(cfg) + + // register change handler + c.OnChange(func(changed map[string]string) { + fmt.Println("Config changed:") + for k, v := range changed { + fmt.Printf(" %s = %s\n", k, v) + } + }) + + // start client + if err := c.Start(); err != nil { + log.Fatalf("Failed to start config client: %v", err) + } + defer c.Stop() + + // read configs + fmt.Println("Current configs:") + for k, v := range c.GetAll() { + fmt.Printf(" %s = %s\n", k, v) + } + + // get specific config + dbHost := c.Get("db.host") + fmt.Printf("\ndb.host = %s\n", dbHost) + + // keep running until signal + fmt.Println("\nListening for config changes... (press Ctrl+C to stop)") + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + <-sigCh + fmt.Println("\nShutting down...") +} diff --git a/go-client/go.mod b/go-client/go.mod new file mode 100644 index 0000000..696aeb6 --- /dev/null +++ b/go-client/go.mod @@ -0,0 +1,3 @@ +module kenaito-conf/go-client + +go 1.25.0 diff --git a/go-client/listener.go b/go-client/listener.go new file mode 100644 index 0000000..20a5cbb --- /dev/null +++ b/go-client/listener.go @@ -0,0 +1,39 @@ +package client + +import "sync" + +// ChangeHandler is called when configs change +type ChangeHandler func(changed map[string]string) + +// Listener manages config change callbacks +type Listener struct { + mu sync.RWMutex + handlers []ChangeHandler +} + +func NewListener() *Listener { + return &Listener{ + handlers: make([]ChangeHandler, 0), + } +} + +func (l *Listener) OnChange(handler ChangeHandler) { + l.mu.Lock() + defer l.mu.Unlock() + l.handlers = append(l.handlers, handler) +} + +func (l *Listener) Notify(changed map[string]string) { + l.mu.RLock() + defer l.mu.RUnlock() + for _, h := range l.handlers { + func() { + defer func() { + if r := recover(); r != nil { + // log and continue + } + }() + h(changed) + }() + } +} diff --git a/kenaito-conf-springboot2-client-demo/README.md b/kenaito-conf-springboot2-client-demo/README.md new file mode 100644 index 0000000..b072258 --- /dev/null +++ b/kenaito-conf-springboot2-client-demo/README.md @@ -0,0 +1,41 @@ +# kenaito-conf-springboot2-client-demo + +配置中心客户端示例项目。 + +## 配置说明 + +在 `application.properties` 中配置: + +```properties +# 配置中心地址 +kenaito.config.server=http://localhost:8080 +kenaito.config.appName=demo +kenaito.config.env=dev +kenaito.config.version=latest +kenaito.config.profile=dev +``` + +## 运行步骤 + +1. 先安装 kenaito-conf-springboot2-client 到本地 Maven 仓库: +```bash +cd E:\SelfDev\Gitea2026\kenaito-conf\kenaito-conf-springboot2-client +mvn clean install -DskipTests +``` + +2. 运行示例项目: +```bash +cd E:\SelfDev\Gitea2026\kenaito-springboot-demo +mvn spring-boot:run +``` + +3. 访问测试: +```bash +curl http://localhost:8081/ +``` + +## 配置优先级 + +远程配置 > 本地配置 + +在配置中心添加应用 `demo` 的配置后,远程配置会覆盖本地同名配置。 diff --git a/kenaito-conf-springboot2-client-demo/pom.xml b/kenaito-conf-springboot2-client-demo/pom.xml new file mode 100644 index 0000000..0f5d3b5 --- /dev/null +++ b/kenaito-conf-springboot2-client-demo/pom.xml @@ -0,0 +1,51 @@ + + + 4.0.0 + + cn.odboy + kenaito-conf-springboot2-client-demo + 1.0.0 + jar + + kenaito-conf-springboot2-client-demo + Demo project for kenaito-conf config center + + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + + + + + 1.8 + 1.8 + 1.8 + UTF-8 + + + + + org.springframework.boot + spring-boot-starter-web + + + + + cn.odboy + kenaito-conf-springboot2-client + 1.0.0 + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/kenaito-conf-springboot2-client-demo/src/main/java/cn/odboy/demo/ConfigController.java b/kenaito-conf-springboot2-client-demo/src/main/java/cn/odboy/demo/ConfigController.java new file mode 100644 index 0000000..db18b03 --- /dev/null +++ b/kenaito-conf-springboot2-client-demo/src/main/java/cn/odboy/demo/ConfigController.java @@ -0,0 +1,43 @@ +package cn.odboy.demo; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.HashMap; +import java.util.Map; + +@RestController +public class ConfigController { + + @Value("${app.name:unknown}") + private String appName; + + @Value("${app.version:unknown}") + private String appVersion; + + @Value("${db.url:unknown}") + private String dbUrl; + + @Value("${db.username:unknown}") + private String dbUsername; + + @Value("${custom.config1:unknown}") + private String customConfig1; + + @GetMapping("/") + public Map index() { + Map result = new HashMap<>(); + result.put("appName", appName); + result.put("appVersion", appVersion); + result.put("dbUrl", dbUrl); + result.put("dbUsername", dbUsername); + result.put("customConfig1", customConfig1); + return result; + } + + @GetMapping("/health") + public String health() { + return "OK"; + } +} diff --git a/kenaito-conf-springboot2-client-demo/src/main/java/cn/odboy/demo/DemoApplication.java b/kenaito-conf-springboot2-client-demo/src/main/java/cn/odboy/demo/DemoApplication.java new file mode 100644 index 0000000..a941ddc --- /dev/null +++ b/kenaito-conf-springboot2-client-demo/src/main/java/cn/odboy/demo/DemoApplication.java @@ -0,0 +1,12 @@ +package cn.odboy.demo; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class DemoApplication { + + public static void main(String[] args) { + SpringApplication.run(DemoApplication.class, args); + } +} diff --git a/kenaito-conf-springboot2-client-demo/src/main/resources/META-INF/spring.factories b/kenaito-conf-springboot2-client-demo/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000..a58f71e --- /dev/null +++ b/kenaito-conf-springboot2-client-demo/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.boot.env.EnvironmentPostProcessor=\ +cn.odboy.confcenter.springboot.KenaitoEnvironmentInitializer diff --git a/kenaito-conf-springboot2-client-demo/src/main/resources/application.properties b/kenaito-conf-springboot2-client-demo/src/main/resources/application.properties new file mode 100644 index 0000000..3deb3f7 --- /dev/null +++ b/kenaito-conf-springboot2-client-demo/src/main/resources/application.properties @@ -0,0 +1,19 @@ +# Server +server.port=8081 + +# Application +app.name=kenaito-conf-springboot2-client-demo +app.version=1.0.0 + +# Kenaito Config Center +kenaito.config.baseUrl=http://localhost:8080 +kenaito.config.appName=demo +kenaito.config.profile=dev +kenaito.config.version=latest +kenaito.config.token= +kenaito.config.sync-interval-seconds=30 + +# Local configs (will be overridden by remote if exists) +db.url=jdbc:mysql://localhost:3306/local_db +db.username=local_user +custom.config1=local_value diff --git a/kenaito-conf-springboot2-client/.gitignore b/kenaito-conf-springboot2-client/.gitignore new file mode 100644 index 0000000..39c133d --- /dev/null +++ b/kenaito-conf-springboot2-client/.gitignore @@ -0,0 +1,5 @@ +/target/ +.DS_Store +/.settings/ +.project +.classpath diff --git a/kenaito-conf-springboot2-client/pom.xml b/kenaito-conf-springboot2-client/pom.xml new file mode 100644 index 0000000..5b68782 --- /dev/null +++ b/kenaito-conf-springboot2-client/pom.xml @@ -0,0 +1,55 @@ + + 4.0.0 + cn.odboy + kenaito-conf-springboot2-client + 1.0.0 + jar + + + 2.0.60 + UTF-8 + 1.8 + 1.8 + + + + + com.alibaba.fastjson2 + fastjson2 + ${fastjson2.version} + + + + org.apache.httpcomponents + httpclient + 4.5.14 + + + + org.apache.commons + commons-lang3 + 3.12.0 + + + + org.slf4j + slf4j-api + 1.7.36 + + + + org.springframework.boot + spring-boot + 2.7.18 + true + + + + org.springframework + spring-context + 5.3.31 + true + + + diff --git a/kenaito-conf-springboot2-client/src/main/java/cn/odboy/confcenter/ConfigChangeHanlder.java b/kenaito-conf-springboot2-client/src/main/java/cn/odboy/confcenter/ConfigChangeHanlder.java new file mode 100644 index 0000000..d6c51a9 --- /dev/null +++ b/kenaito-conf-springboot2-client/src/main/java/cn/odboy/confcenter/ConfigChangeHanlder.java @@ -0,0 +1,8 @@ +package cn.odboy.confcenter; + +import java.util.Map; + +public interface ConfigChangeHanlder { + + public void onConfigChanged(Map changedConfigs); +} diff --git a/kenaito-conf-springboot2-client/src/main/java/cn/odboy/confcenter/ConfigcenterContext.java b/kenaito-conf-springboot2-client/src/main/java/cn/odboy/confcenter/ConfigcenterContext.java new file mode 100644 index 0000000..64b2b26 --- /dev/null +++ b/kenaito-conf-springboot2-client/src/main/java/cn/odboy/confcenter/ConfigcenterContext.java @@ -0,0 +1,533 @@ +package cn.odboy.confcenter; + +import java.io.FileReader; +import java.net.InetAddress; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collections; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Objects; +import java.util.Properties; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Validate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.ApplicationContext; +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.env.MutablePropertySources; +import org.springframework.core.env.PropertiesPropertySource; +import org.springframework.core.env.StandardEnvironment; + +import com.alibaba.fastjson2.JSON; + +/** + * Config Center Context + * + * @author odboy + * @version 2.0.0 + * @date 2026-10-11 + */ +public class ConfigcenterContext { + + private final static Logger logger = LoggerFactory.getLogger("cn.odboy"); + + private static ConfigcenterContext instance = new ConfigcenterContext(); + + private List sensitiveKeys = new ArrayList<>(Arrays.asList("password", "key", "secret", "token", "credentials")); + + public static final String MANAGER_PROPERTY_SOURCE = "kenaitoConfigPropertySource"; + + private static final String IGNORE_PLACEHOLER = "[Ignore]"; + private static final String CRYPT_PREFIX = "{Cipher}"; + + // Local property storage (replaces ResourceUtils) + private static final Map localProps = new ConcurrentHashMap<>(); + + // Spring ApplicationContext (replaces InstanceFactory) + private static ApplicationContext applicationContext; + + private Boolean remoteEnabled; + private final String nodeId; + private String[] apiBaseUrls; + private String app; + private String env; + private String version; + private boolean ignoreGlobal; + private String secret; + private String globalSecret; + private String token; + private boolean remoteFirst = false; + private boolean isSpringboot; + private int syncIntervalSeconds = 50; + private InternalConfigChangeListener configChangeListener; + private List configChangeHanlders; + private boolean processed; + private Properties remoteProperties; + + private ConfigcenterContext() { + String name; + try { + name = InetAddress.getLocalHost().getHostName(); + } catch (Exception e) { + name = "unknown"; + } + this.nodeId = name; + } + + public static ConfigcenterContext getInstance() { + return instance; + } + + public static void setApplicationContext(ApplicationContext ctx) { + applicationContext = ctx; + } + + public static ApplicationContext getApplicationContext() { + return applicationContext; + } + + // ==================== Property Utils (replaces ResourceUtils) ==================== + + public static void mergeProperties(Properties props) { + if (props != null) { + for (Entry entry : props.entrySet()) { + localProps.put(entry.getKey().toString(), entry.getValue().toString()); + } + } + } + + public static void addProperty(String key, String value) { + localProps.put(key, value); + } + + public static String getProperty(String key) { + String val = localProps.get(key); + if (val == null) val = System.getProperty(key); + if (val == null) val = System.getenv(key); + return val; + } + + public static String getProperty(String key, String defaultValue) { + String val = getProperty(key); + return val != null && !val.isEmpty() ? val : defaultValue; + } + + public static boolean getBoolean(String key, boolean defaultValue) { + String val = getProperty(key); + if (val == null || val.isEmpty()) return defaultValue; + return Boolean.parseBoolean(val); + } + + public static int getInt(String key, int defaultValue) { + String val = getProperty(key); + if (val == null || val.isEmpty()) return defaultValue; + try { + return Integer.parseInt(val); + } catch (NumberFormatException e) { + return defaultValue; + } + } + + public static String getAnyProperty(String... keys) { + for (String key : keys) { + String val = getProperty(key); + if (val != null && !val.isEmpty()) return val; + } + return null; + } + + // ==================== Crypto Utils (replaces mendmix AES/DES/Base64/DigestUtils) ==================== + + public static String md5(String input) { + try { + MessageDigest md = MessageDigest.getInstance("MD5"); + byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(); + for (byte b : digest) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } catch (Exception e) { + throw new RuntimeException("MD5 error", e); + } + } + + public static String md5Short(String input) { + if (input == null || input.isEmpty()) return ""; + return md5(input).substring(0, 16); + } + + private static String desEncrypt(String key, String data) { + try { + SecretKey secretKey = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "DES"); + Cipher cipher = Cipher.getInstance("DES/ECB/PKCS5Padding"); + cipher.init(Cipher.ENCRYPT_MODE, secretKey); + byte[] encrypted = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(encrypted); + } catch (Exception e) { + throw new RuntimeException("DES encrypt error", e); + } + } + + private static byte[] aesDecrypt(byte[] data, byte[] key) throws Exception { + SecretKey secretKey = new SecretKeySpec(key, "AES"); + // Use CBC mode with zero IV to match Go server + IvParameterSpec iv = new IvParameterSpec(new byte[16]); + Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, secretKey, iv); + byte[] decrypted = cipher.doFinal(data); + // Remove PKCS7 padding + int padding = decrypted[decrypted.length - 1] & 0xFF; + if (padding > 0 && padding <= 16) { + byte[] result = new byte[decrypted.length - padding]; + System.arraycopy(decrypted, 0, result, 0, result.length); + return result; + } + return decrypted; + } + + private static String decryptWithAES(String key, String data) { + try { + String secretKey = md5(key).substring(16); + byte[] encrypted = Base64.getDecoder().decode(data.getBytes(StandardCharsets.UTF_8)); + byte[] decrypted = aesDecrypt(encrypted, secretKey.getBytes(StandardCharsets.UTF_8)); + return new String(decrypted, StandardCharsets.UTF_8); + } catch (Exception e) { + throw new RuntimeException(String.format("解密错误:%s", data)); + } + } + + // ==================== HTTP Utils (replaces mendmix HttpUtils) ==================== + + public static String httpGet(String url) throws Exception { + return HttpClientUtils.get(url); + } + + public static String httpPostJson(String url, String json) throws Exception { + return HttpClientUtils.postJson(url, json); + } + + // ==================== Core Methods ==================== + + public boolean isProcessed() { + return processed; + } + + public synchronized void init(Properties properties, boolean isSpringboot) { + if (processed || !isRemoteEnabled()) return; + if (properties != null) mergeProperties(properties); + + System.setProperty("client.nodeId", nodeId); + System.setProperty("springboot", String.valueOf(isSpringboot)); + this.isSpringboot = isSpringboot; + + String defaultAppName = getProperty("spring.application.name"); + app = getProperty("kenaito.config.appName", defaultAppName); + if (remoteEnabled == null) remoteEnabled = getBoolean("kenaito.config.enabled", true); + + if (!isRemoteEnabled()) return; + + env = getProperty("kenaito.config.profile", "dev"); + Validate.notBlank(env, "[kenaito.config.profile] is required"); + + setApiBaseUrl(getAnyProperty("kenaito.config.baseUrl", "kenaito.config.base.url")); + + version = getProperty("kenaito.config.version", "latest"); + ignoreGlobal = getBoolean("kenaito.config.global-ignore", false); + syncIntervalSeconds = getInt("kenaito.config.sync-interval-seconds", 60); + token = getProperty("kenaito.config.token"); + + logger.info("\n=====Configcenter config=====\nappName:{}\nenv:{}\nversion:{}\nremoteEnabled:{}\napiBaseUrls:{}\n=====Configcenter config=====", + app, env, version, isRemoteEnabled(), JSON.toJSONString(apiBaseUrls)); + } + + public boolean isRemoteEnabled() { + return remoteEnabled == null || remoteEnabled; + } + + public void setRemoteEnabled(boolean remoteEnabled) { + this.remoteEnabled = remoteEnabled; + } + + public String[] getApiBaseUrls() { + return apiBaseUrls; + } + + public void setApiBaseUrl(String apiBaseUrl) { + Validate.notBlank(apiBaseUrl, "[kenaito.config.base.url] is required"); + String[] urls = apiBaseUrl.split(",|;"); + this.apiBaseUrls = new String[urls.length]; + for (int i = 0; i < urls.length; i++) { + if (urls[i].endsWith("/")) { + this.apiBaseUrls[i] = urls[i].substring(0, urls[i].length() - 1); + } else { + this.apiBaseUrls[i] = urls[i]; + } + } + } + + public String getApp() { return app; } + public String getEnv() { return env; } + public String getVersion() { return version; } + public boolean isIgnoreGlobal() { return ignoreGlobal; } + public String getSecret() { return secret; } + public boolean isRemoteFirst() { return remoteFirst; } + public int getSyncIntervalSeconds() { return syncIntervalSeconds; } + public String getNodeId() { return nodeId; } + public boolean isSpringboot() { return isSpringboot; } + + public void mergeRemoteProperties(Properties properties) { + if (!remoteEnabled) return; + remoteProperties = getAllRemoteProperties(); + if (remoteProperties != null) { + Set> entrySet = remoteProperties.entrySet(); + for (Entry entry : entrySet) { + if (!isRemoteFirst() && properties.containsKey(entry.getKey())) { + continue; + } + String value = entry.getValue().toString(); + if (IGNORE_PLACEHOLER.equals(value)) continue; + properties.setProperty(entry.getKey().toString(), value); + } + } + + // 替换本地变量占位符 + Set> entrySet = properties.entrySet(); + for (Entry entry : entrySet) { + String key = entry.getKey().toString(); + String value = entry.getValue().toString(); + addProperty(key, value); + } + + // 加载本地测试配置 + String path = System.getProperty("localtest.config.location"); + if (StringUtils.isNotBlank(path)) { + try (FileReader fileReader = new FileReader(path)) { + Properties localProperties = new Properties(); + localProperties.load(fileReader); + if (!localProperties.isEmpty()) { + properties.putAll(localProperties); + } + } catch (Exception e) { + logger.warn("Failed to load local test config from {}: {}", path, e.getMessage()); + } + } + + // Close existing listener before creating new one + if (configChangeListener != null) { + configChangeListener.close(); + } + // register listener + configChangeListener = new InternalConfigChangeListener(); + printConfigs(properties); + } + + private Properties getAllRemoteProperties() { + if (remoteProperties != null) return remoteProperties; + Properties properties = new Properties(); + + Map map = fetchConfigFromServer(); + if (map == null) { + throw new RuntimeException("fetch remote config error!"); + } + + // 解密密匙 + secret = Objects.toString(map.remove("kenaito.config.encrypt-secret"), null); + globalSecret = Objects.toString(map.remove("kenaito.config.global-encrypt-secret"), null); + remoteFirst = Boolean.parseBoolean(Objects.toString(map.remove("kenaito.config.remote-config-first"), "false")); + + Set keys = map.keySet(); + for (String key : keys) { + Object value = decodeEncryptIfRequire(map.get(key)); + properties.put(key, value); + } + + return properties; + } + + @SuppressWarnings("unchecked") + private Map fetchConfigFromServer() { + Map result = null; + String errorMsg = null; + for (String apiBaseUrl : apiBaseUrls) { + String url = buildTokenParameter(String.format("%s/api/fetchAllConfigs?appName=%s&env=%s&version=%s&ignoreGlobal=%s", + apiBaseUrl, app, env, version, ignoreGlobal)); + logger.info("fetch configs url:{}", url); + try { + String jsonString = httpGet(url); + if (jsonString != null) { + result = JSON.parseObject(jsonString, Map.class); + if (result.containsKey("code")) { + errorMsg = result.get("msg").toString(); + logger.error("fetch error:{}", errorMsg); + result = null; + } else { + break; + } + } + } catch (Exception e) { + logger.error("fetch config error from {}: {}", apiBaseUrl, e.getMessage()); + } + } + + if (result == null) { + logger.warn(">>>>>remote config fetch error, load from local Cache"); + result = LocalCacheUtils.read(); + } else { + LocalCacheUtils.write(result); + } + return result; + } + + private void printConfigs(Properties properties) { + List sortKeys = new ArrayList<>(); + Set> entrySet = properties.entrySet(); + for (Entry entry : entrySet) { + sortKeys.add(entry.getKey().toString()); + } + Collections.sort(sortKeys); + System.out.println("==================final config list start=================="); + for (String key : sortKeys) { + String value = hideSensitive(key, properties.getProperty(key)); + System.out.println(String.format("%s = %s", key, value)); + } + System.out.println("==================final config list end===================="); + } + + @SuppressWarnings("unchecked") + public synchronized void updateConfig(Map updateConfig) { + if (!updateConfig.isEmpty()) { + Set keySet = updateConfig.keySet(); + for (String key : keySet) { + String oldValue = getProperty(key); + addProperty(key, decodeEncryptIfRequire(updateConfig.get(key)).toString()); + logger.info("Config [{}] Change, oldValue:{}, newValue:{}", key, oldValue, updateConfig.get(key)); + } + + // Update Spring Environment property source + if (applicationContext != null) { + StandardEnvironment environment = (StandardEnvironment) applicationContext.getEnvironment(); + MutablePropertySources propertySources = environment.getPropertySources(); + synchronized (propertySources) { + Properties properties = new Properties(); + if (propertySources.contains(MANAGER_PROPERTY_SOURCE)) { + Object source = propertySources.get(MANAGER_PROPERTY_SOURCE); + if (source instanceof MapPropertySource) { + Map map = ((MapPropertySource) source).getSource(); + for (Entry entry : map.entrySet()) { + properties.put(entry.getKey(), entry.getValue()); + } + } else if (source instanceof PropertiesPropertySource) { + Map src = ((PropertiesPropertySource) source).getSource(); + for (Entry entry : src.entrySet()) { + properties.put(entry.getKey(), entry.getValue().toString()); + } + } + } + for (Entry entry : updateConfig.entrySet()) { + properties.put(entry.getKey(), entry.getValue()); + } + propertySources.replace(MANAGER_PROPERTY_SOURCE, new PropertiesPropertySource(MANAGER_PROPERTY_SOURCE, properties)); + } + } + + if (configChangeHanlders == null) { + configChangeHanlders = new ArrayList<>(); + if (applicationContext != null) { + Map interfaces = applicationContext.getBeansOfType(ConfigChangeHanlder.class); + if (interfaces != null) { + configChangeHanlders.addAll(interfaces.values()); + } + } + } + + for (ConfigChangeHanlder handler : configChangeHanlders) { + try { + handler.onConfigChanged(updateConfig); + logger.info("invoke {}.onConfigChanged successed!", handler.getClass().getName()); + } catch (Exception e) { + logger.warn("invoke {}.onConfigChanged error, msg:{}", handler.getClass().getName(), e.getMessage()); + } + } + } + } + + public boolean pingCcServer(String pingUrl, int retry) { + boolean result = false; + try { + result = httpGet(pingUrl) != null; + } catch (Exception e) { + // ignore + } + if (retry == 0) return false; + if (!result) { + try { Thread.sleep(1500); } catch (Exception e) { } + return pingCcServer(pingUrl, --retry); + } + return result; + } + + public String buildTokenParameter(String url) { + if (token == null || token.length() < 8) { + return url; + } + String str = md5Short(token).concat(String.valueOf(new Date().getTime())); + String authtoken = desEncrypt(token.substring(0, 8), str).toLowerCase(); + return url + (url.contains("?") ? "&" : "?") + "authtoken=" + authtoken; + } + + public void close() { + if (configChangeListener != null) { + configChangeListener.close(); + } + } + + private Object decodeEncryptIfRequire(Object data) { + if (data == null) return ""; + if (data.toString().startsWith(CRYPT_PREFIX)) { + Validate.notBlank(secret, "config[kenaito.config.encrypt-secret] is required"); + data = data.toString().replace(CRYPT_PREFIX, ""); + String decryptString; + try { + decryptString = decryptWithAES(secret, data.toString()); + } catch (Exception e) { + if (globalSecret != null && !globalSecret.isEmpty()) { + decryptString = decryptWithAES(globalSecret, data.toString()); + } else { + throw e; + } + } + return decryptString; + } + return data; + } + + private String hideSensitive(String key, String origin) { + if (StringUtils.isAnyBlank(key, origin)) return ""; + boolean isSensitive = false; + for (String k : sensitiveKeys) { + if (key.toLowerCase().contains(k)) { + isSensitive = true; + break; + } + } + int length = origin.length(); + if (isSensitive && length > 1) return origin.substring(0, length / 2).concat("****"); + return origin; + } +} diff --git a/kenaito-conf-springboot2-client/src/main/java/cn/odboy/confcenter/EnvironmentChangeEvent.java b/kenaito-conf-springboot2-client/src/main/java/cn/odboy/confcenter/EnvironmentChangeEvent.java new file mode 100644 index 0000000..0d9a90f --- /dev/null +++ b/kenaito-conf-springboot2-client/src/main/java/cn/odboy/confcenter/EnvironmentChangeEvent.java @@ -0,0 +1,24 @@ +package cn.odboy.confcenter; + +import java.util.Set; + +import org.springframework.context.ApplicationEvent; + +public class EnvironmentChangeEvent extends ApplicationEvent { + + private static final long serialVersionUID = 1L; + + private Set keys; + + public EnvironmentChangeEvent(Set keys) { + super(keys); + this.keys = keys; + } + + /** + * @return the keys + */ + public Set getKeys() { + return keys; + } +} diff --git a/kenaito-conf-springboot2-client/src/main/java/cn/odboy/confcenter/HttpClientUtils.java b/kenaito-conf-springboot2-client/src/main/java/cn/odboy/confcenter/HttpClientUtils.java new file mode 100644 index 0000000..300d5d6 --- /dev/null +++ b/kenaito-conf-springboot2-client/src/main/java/cn/odboy/confcenter/HttpClientUtils.java @@ -0,0 +1,59 @@ +package cn.odboy.confcenter; + +import java.io.IOException; + +import org.apache.http.HttpResponse; +import org.apache.http.client.config.RequestConfig; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.util.EntityUtils; + +/** + * HTTP Client Utilities using Apache HttpClient 4.x + * + * @author odboy + * @version 2.0.0 + * @date 2026-10-11 + */ +public class HttpClientUtils { + + private static final CloseableHttpClient httpClient; + + static { + RequestConfig config = RequestConfig.custom() + .setConnectTimeout(10000) + .setSocketTimeout(30000) + .setConnectionRequestTimeout(5000) + .build(); + httpClient = HttpClients.custom() + .setDefaultRequestConfig(config) + .build(); + } + + public static String get(String url) throws IOException { + HttpGet request = new HttpGet(url); + HttpResponse response = httpClient.execute(request); + int statusCode = response.getStatusLine().getStatusCode(); + String body = EntityUtils.toString(response.getEntity(), "UTF-8"); + if (statusCode >= 200 && statusCode < 300) { + return body; + } + return null; + } + + public static String postJson(String url, String json) throws IOException { + HttpPost request = new HttpPost(url); + request.setHeader("Content-Type", "application/json;charset=UTF-8"); + request.setEntity(new StringEntity(json, "UTF-8")); + HttpResponse response = httpClient.execute(request); + int statusCode = response.getStatusLine().getStatusCode(); + String body = EntityUtils.toString(response.getEntity(), "UTF-8"); + if (statusCode >= 200 && statusCode < 300) { + return body; + } + return null; + } +} diff --git a/kenaito-conf-springboot2-client/src/main/java/cn/odboy/confcenter/InternalConfigChangeListener.java b/kenaito-conf-springboot2-client/src/main/java/cn/odboy/confcenter/InternalConfigChangeListener.java new file mode 100644 index 0000000..674fda8 --- /dev/null +++ b/kenaito-conf-springboot2-client/src/main/java/cn/odboy/confcenter/InternalConfigChangeListener.java @@ -0,0 +1,77 @@ +package cn.odboy.confcenter; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.alibaba.fastjson2.JSON; + +/** + * Internal Config Change Listener + * Polls for config changes via HTTP + * + * @author odboy + * @version 2.0.0 + * @date 2026-10-11 + */ +public class InternalConfigChangeListener { + + private final static Logger logger = LoggerFactory.getLogger("cn.odboy.confcenter"); + + private static final String LATEST_FETCH_TIMESTAMP = "latest.fetch.timestamp"; + + private ScheduledExecutorService hbScheduledExecutor; + + public InternalConfigChangeListener() { + ConfigcenterContext context = ConfigcenterContext.getInstance(); + registerHttpListener(context); + } + + public void close() { + if (hbScheduledExecutor != null) hbScheduledExecutor.shutdown(); + } + + private void registerHttpListener(ConfigcenterContext context) { + hbScheduledExecutor = Executors.newScheduledThreadPool(1); + + final String url = context.getApiBaseUrls()[0] + "/api/fetchChangedConfigs"; + final Map params = new HashMap<>(); + params.put("version", context.getVersion()); + params.put("appName", context.getApp()); + params.put("env", context.getEnv()); + if (context.isIgnoreGlobal()) { + params.put("ignoreGlobal", String.valueOf(context.isIgnoreGlobal())); + } + params.put("lastTime", ConfigcenterContext.getProperty(LATEST_FETCH_TIMESTAMP, String.valueOf(System.currentTimeMillis()))); + + hbScheduledExecutor.scheduleAtFixedRate(new Runnable() { + @Override + public void run() { + try { + String safeUrl = ConfigcenterContext.getInstance().buildTokenParameter(url); + String response = ConfigcenterContext.httpPostJson(safeUrl, JSON.toJSONString(params)); + if (response != null) { + Map map = JSON.parseObject(response, Map.class); + if (map != null && map.containsKey("data")) { + @SuppressWarnings("unchecked") + Map data = (Map) map.get("data"); + if (data.containsKey(LATEST_FETCH_TIMESTAMP)) { + params.put("lastTime", data.remove(LATEST_FETCH_TIMESTAMP).toString()); + } + if (!data.isEmpty()) context.updateConfig(data); + } + } + } catch (Exception e) { + logger.error("Error fetching config changes", e); + } + } + }, context.getSyncIntervalSeconds(), context.getSyncIntervalSeconds(), TimeUnit.SECONDS); + + logger.info("registerHttpUpdaterListener OK intervalSeconds:{}, lastFetchTime:{}", context.getSyncIntervalSeconds(), params.get("lastTime")); + } +} diff --git a/kenaito-conf-springboot2-client/src/main/java/cn/odboy/confcenter/LocalCacheUtils.java b/kenaito-conf-springboot2-client/src/main/java/cn/odboy/confcenter/LocalCacheUtils.java new file mode 100644 index 0000000..bfe77d6 --- /dev/null +++ b/kenaito-conf-springboot2-client/src/main/java/cn/odboy/confcenter/LocalCacheUtils.java @@ -0,0 +1,55 @@ +package cn.odboy.confcenter; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileWriter; +import java.io.InputStreamReader; +import java.util.Map; + +import com.alibaba.fastjson2.JSON; + +/** + * Local Cache Utilities + * + * @author odboy + * @version 2.0.0 + * @date 2026-10-11 + */ +public class LocalCacheUtils { + + private static String localStorageDir = ConfigcenterContext.getProperty("kenaito.configcenter.local-storage-dir", + System.getProperty("user.dir")); + + public static void write(Map datas) { + File dir = new File(localStorageDir); + if (!dir.exists()) dir.mkdirs(); + File file = new File(dir, "config-cache.log"); + try (FileWriter fileWriter = new FileWriter(file, false)) { + fileWriter.write(JSON.toJSONString(datas)); + } catch (Exception e) { + e.printStackTrace(); + } + } + + @SuppressWarnings("unchecked") + public static Map read() { + File dir = new File(localStorageDir); + if (!dir.exists()) dir.mkdirs(); + File file = new File(dir, "config-cache.log"); + if (!file.exists()) { + return null; + } + try (BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream(file)))) { + StringBuilder buffer = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + buffer.append(line); + } + return JSON.parseObject(buffer.toString(), Map.class); + } catch (Exception e) { + e.printStackTrace(); + } + return null; + } +} diff --git a/kenaito-conf-springboot2-client/src/main/java/cn/odboy/confcenter/spring/CCPropertyPlaceholderConfigurer.java b/kenaito-conf-springboot2-client/src/main/java/cn/odboy/confcenter/spring/CCPropertyPlaceholderConfigurer.java new file mode 100644 index 0000000..ae672ac --- /dev/null +++ b/kenaito-conf-springboot2-client/src/main/java/cn/odboy/confcenter/spring/CCPropertyPlaceholderConfigurer.java @@ -0,0 +1,52 @@ +package cn.odboy.confcenter.spring; + +import java.io.IOException; +import java.util.Properties; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.context.support.PropertySourcesPlaceholderConfigurer; + +import cn.odboy.confcenter.ConfigcenterContext; + +/** + * Spring Property Placeholder Configurer for non-SpringBoot applications + * + * @author odboy + * @version 2.0.0 + * @date 2026-10-11 + */ +public class CCPropertyPlaceholderConfigurer extends PropertySourcesPlaceholderConfigurer + implements DisposableBean, ApplicationContextAware { + + private boolean setRemoteEnabled = false; + private ConfigcenterContext ccContext = ConfigcenterContext.getInstance(); + + public void setRemoteEnabled(boolean remoteEnabled) { + ccContext.setRemoteEnabled(remoteEnabled); + setRemoteEnabled = true; + } + + @Override + protected Properties mergeProperties() throws IOException { + Properties properties = super.mergeProperties(); + if (!setRemoteEnabled) { + ccContext.setRemoteEnabled(Boolean.parseBoolean(properties.getProperty("kenaito.configcenter.enabled", "true"))); + } + ccContext.init(properties, false); + ccContext.mergeRemoteProperties(properties); + return properties; + } + + @Override + public void destroy() throws Exception { + ccContext.close(); + } + + @Override + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + ConfigcenterContext.setApplicationContext(applicationContext); + } +} diff --git a/kenaito-conf-springboot2-client/src/main/java/cn/odboy/confcenter/springboot/KenaitoEnvironmentInitializer.java b/kenaito-conf-springboot2-client/src/main/java/cn/odboy/confcenter/springboot/KenaitoEnvironmentInitializer.java new file mode 100644 index 0000000..0a08cd0 --- /dev/null +++ b/kenaito-conf-springboot2-client/src/main/java/cn/odboy/confcenter/springboot/KenaitoEnvironmentInitializer.java @@ -0,0 +1,35 @@ +package cn.odboy.confcenter.springboot; + +import java.util.Properties; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.env.EnvironmentPostProcessor; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.PropertiesPropertySource; +import org.springframework.core.env.StandardEnvironment; + +import cn.odboy.confcenter.ConfigcenterContext; + +/** + * Spring Boot 2 Environment Post Processor + * Initializes config center before application context is created + * + * @author odboy + * @version 2.0.0 + * @date 2026-10-11 + */ +public class KenaitoEnvironmentInitializer implements EnvironmentPostProcessor { + + private ConfigcenterContext ccContext = ConfigcenterContext.getInstance(); + + @Override + public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) { + if (ccContext.isRemoteEnabled() && !ccContext.isProcessed()) { + Properties properties = new Properties(); + ccContext.init(properties, true); + ccContext.mergeRemoteProperties(properties); + PropertiesPropertySource propertySource = new PropertiesPropertySource(ConfigcenterContext.MANAGER_PROPERTY_SOURCE, properties); + environment.getPropertySources().addFirst(propertySource); + } + } +} diff --git a/kenaito-conf-springboot2-client/src/main/resources/META-INF/spring.factories b/kenaito-conf-springboot2-client/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000..cfbe8a8 --- /dev/null +++ b/kenaito-conf-springboot2-client/src/main/resources/META-INF/spring.factories @@ -0,0 +1 @@ +org.springframework.boot.env.EnvironmentPostProcessor=cn.odboy.confcenter.springboot.KenaitoEnvironmentInitializer diff --git a/kenaito-conf-springboot3-client-demo/README.md b/kenaito-conf-springboot3-client-demo/README.md new file mode 100644 index 0000000..0725b05 --- /dev/null +++ b/kenaito-conf-springboot3-client-demo/README.md @@ -0,0 +1,64 @@ +# kenaito-conf-springboot3-client-demo + +配置中心 Spring Boot 3 客户端示例项目。 + +## 环境要求 + +- **JDK 17 或更高版本**(Spring Boot 3 最低要求) +- Maven 3.6+ + +## 配置说明 + +在 `application.properties` 中配置: + +```properties +# 配置中心地址 +kenaito.config.server=http://localhost:8080 +kenaito.config.appName=demo +kenaito.config.env=dev +kenaito.config.version=latest +kenaito.config.profile=dev +``` + +## 运行步骤 + +1. 确保已安装 JDK 17+: +```bash +java -version +``` + +2. 先安装 kenaito-conf-springboot3-client 到本地 Maven 仓库: +```bash +cd E:\SelfDev\Gitea2026\kenaito-conf\kenaito-conf-springboot3-client +mvn clean install -DskipTests +``` + +3. 运行示例项目: +```bash +cd E:\SelfDev\Gitea2026\kenaito-conf\kenaito-conf-springboot3-client-demo +mvn spring-boot:run +``` + +4. 访问测试: +```bash +curl http://localhost:8082/ +``` + +## 配置优先级 + +远程配置 > 本地配置 + +在配置中心添加应用 `demo` 的配置后,远程配置会覆盖本地同名配置。 + +## 与 Spring Boot 2 Demo 的区别 + +| 项目 | Spring Boot 版本 | JDK 版本 | 端口 | +|------|------------------|----------|------| +| kenaito-conf-springboot2-client-demo | 2.7.18 | 1.8+ | 8081 | +| kenaito-conf-springboot3-client-demo | 3.2.5 | 17+ | 8082 | + +## 注意事项 + +- 当前环境只有 JDK 11,无法运行此项目 +- 需要安装 JDK 17+ 后才能编译和运行 +- 如果需要在 JDK 11 环境测试,请使用 kenaito-conf-springboot2-client-demo diff --git a/kenaito-conf-springboot3-client-demo/pom.xml b/kenaito-conf-springboot3-client-demo/pom.xml new file mode 100644 index 0000000..30d6454 --- /dev/null +++ b/kenaito-conf-springboot3-client-demo/pom.xml @@ -0,0 +1,51 @@ + + + 4.0.0 + + cn.odboy + kenaito-conf-springboot3-client-demo + 1.0.0 + jar + + kenaito-conf-springboot3-client-demo + Demo project for kenaito-conf Spring Boot 3 client + + + org.springframework.boot + spring-boot-starter-parent + 3.2.5 + + + + + 17 + 17 + 17 + UTF-8 + + + + + org.springframework.boot + spring-boot-starter-web + + + + + cn.odboy + kenaito-conf-springboot3-client + 1.0.0 + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/kenaito-conf-springboot3-client-demo/src/main/java/cn/odboy/demo/ConfigController.java b/kenaito-conf-springboot3-client-demo/src/main/java/cn/odboy/demo/ConfigController.java new file mode 100644 index 0000000..db18b03 --- /dev/null +++ b/kenaito-conf-springboot3-client-demo/src/main/java/cn/odboy/demo/ConfigController.java @@ -0,0 +1,43 @@ +package cn.odboy.demo; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.HashMap; +import java.util.Map; + +@RestController +public class ConfigController { + + @Value("${app.name:unknown}") + private String appName; + + @Value("${app.version:unknown}") + private String appVersion; + + @Value("${db.url:unknown}") + private String dbUrl; + + @Value("${db.username:unknown}") + private String dbUsername; + + @Value("${custom.config1:unknown}") + private String customConfig1; + + @GetMapping("/") + public Map index() { + Map result = new HashMap<>(); + result.put("appName", appName); + result.put("appVersion", appVersion); + result.put("dbUrl", dbUrl); + result.put("dbUsername", dbUsername); + result.put("customConfig1", customConfig1); + return result; + } + + @GetMapping("/health") + public String health() { + return "OK"; + } +} diff --git a/kenaito-conf-springboot3-client-demo/src/main/java/cn/odboy/demo/DemoApplication.java b/kenaito-conf-springboot3-client-demo/src/main/java/cn/odboy/demo/DemoApplication.java new file mode 100644 index 0000000..a941ddc --- /dev/null +++ b/kenaito-conf-springboot3-client-demo/src/main/java/cn/odboy/demo/DemoApplication.java @@ -0,0 +1,12 @@ +package cn.odboy.demo; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class DemoApplication { + + public static void main(String[] args) { + SpringApplication.run(DemoApplication.class, args); + } +} diff --git a/kenaito-conf-springboot3-client-demo/src/main/resources/META-INF/spring.factories b/kenaito-conf-springboot3-client-demo/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000..a58f71e --- /dev/null +++ b/kenaito-conf-springboot3-client-demo/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.boot.env.EnvironmentPostProcessor=\ +cn.odboy.confcenter.springboot.KenaitoEnvironmentInitializer diff --git a/kenaito-conf-springboot3-client-demo/src/main/resources/application.properties b/kenaito-conf-springboot3-client-demo/src/main/resources/application.properties new file mode 100644 index 0000000..8b250bf --- /dev/null +++ b/kenaito-conf-springboot3-client-demo/src/main/resources/application.properties @@ -0,0 +1,19 @@ +# Server +server.port=8082 + +# Application +app.name=kenaito-conf-springboot3-client-demo +app.version=1.0.0 + +# Kenaito Config Center +kenaito.config.baseUrl=http://localhost:8080 +kenaito.config.appName=demo +kenaito.config.profile=dev +kenaito.config.version=latest +kenaito.config.token= +kenaito.config.sync-interval-seconds=30 + +# Local configs (will be overridden by remote if exists) +db.url=jdbc:mysql://localhost:3306/local_db +db.username=local_user +custom.config1=local_value diff --git a/kenaito-conf-springboot3-client/README.md b/kenaito-conf-springboot3-client/README.md new file mode 100644 index 0000000..5aa329c --- /dev/null +++ b/kenaito-conf-springboot3-client/README.md @@ -0,0 +1,107 @@ +# kenaito-conf-springboot3-client + +配置中心 Spring Boot 3 客户端(当前使用 Spring Boot 2.7.18 + JDK 11 编译) + +## 重要说明 + +**当前状态:** +- 项目名称为 springboot3-client,但实际使用 Spring Boot 2.7.18 + JDK 11 编译 +- 代码已重构,移除了 mendmix 依赖,使用标准 Java/Spring 工具类 +- 为未来升级到 Spring Boot 3 做好准备 + +**升级到 Spring Boot 3 的步骤:** +1. 安装 JDK 17 或更高版本 +2. 修改 `pom.xml`: + ```xml + 17 + 17 + 3.2.5 + ``` +3. 修改 Spring Framework 版本: + ```xml + 6.1.6 + ``` +4. 重新编译:`mvn clean install` + +## 与 springboot2-client 的区别 + +1. **移除 mendmix 依赖** - 使用标准 Java/Spring 工具类替代 +2. **使用 Java HttpClient** - 替代 mendmix 的 HttpUtils +3. **使用 Jackson** - 替代 mendmix 的 JsonUtils +4. **内置加密工具** - 使用 Java 原生 javax.crypto 实现 AES/DES 加密 +5. **更清晰的代码结构** - 便于维护和扩展 + +## 配置说明 + +在 `application.properties` 中配置: + +```properties +# 启用配置中心 +kenaito.config.enabled=true + +# 配置中心服务地址(支持多个,逗号分隔) +kenaito.config.baseUrl=http://localhost:8080 + +# 应用名称 +kenaito.config.appName=myapp + +# 环境标识 +kenaito.config.profile=dev + +# 版本号 +kenaito.config.version=latest + +# 同步间隔(秒) +kenaito.config.sync-interval-seconds=60 + +# 认证 token(如果配置中心启用了认证) +kenaito.config.token=your-token-here +``` + +## 使用方式 + +### Spring Boot 应用 + +自动生效,无需额外配置。`KenaitoEnvironmentInitializer` 会在 Spring Boot 启动时自动加载远程配置。 + +### 传统 Spring 应用 + +在 Spring 配置文件中添加: + +```xml + + + +``` + +## 安装到本地 Maven 仓库 + +```bash +mvn clean install -DskipTests +``` + +## 依赖说明 + +- Spring Boot 2.7.18(optional) +- Spring Context 5.3.31(optional) +- Jackson 2.17.0(JSON 处理) +- SLF4J 2.0.12(日志) +- Java HttpClient(HTTP 请求,JDK 11+ 内置) + +## 升级路径 + +当你的项目升级到 JDK 17 后,可以修改 pom.xml 中的版本配置,切换到 Spring Boot 3: + +```xml + + 17 + 17 + 3.2.5 + + + + org.springframework + spring-context + 6.1.6 + +``` diff --git a/kenaito-conf-springboot3-client/pom.xml b/kenaito-conf-springboot3-client/pom.xml new file mode 100644 index 0000000..c513afb --- /dev/null +++ b/kenaito-conf-springboot3-client/pom.xml @@ -0,0 +1,52 @@ + + 4.0.0 + cn.odboy + kenaito-conf-springboot3-client + 1.0.0 + jar + + + UTF-8 + 11 + 11 + 2.7.18 + + + + + org.springframework.boot + spring-boot + ${spring-boot.version} + true + + + + org.springframework + spring-context + 5.3.31 + true + + + + + org.apache.httpcomponents.client5 + httpclient5 + 5.3.1 + + + + + com.fasterxml.jackson.core + jackson-databind + 2.17.0 + + + + + org.slf4j + slf4j-api + 2.0.12 + + + diff --git a/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/ConfigChangeHanlder.java b/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/ConfigChangeHanlder.java new file mode 100644 index 0000000..5ff6b53 --- /dev/null +++ b/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/ConfigChangeHanlder.java @@ -0,0 +1,7 @@ +package cn.odboy.confcenter; + +import java.util.Map; + +public interface ConfigChangeHanlder { + public void onConfigChanged(Map changedConfigs); +} diff --git a/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/ConfigcenterContext.java b/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/ConfigcenterContext.java new file mode 100644 index 0000000..5a989e9 --- /dev/null +++ b/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/ConfigcenterContext.java @@ -0,0 +1,495 @@ +package cn.odboy.confcenter; + +import java.net.InetAddress; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collections; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Objects; +import java.util.Properties; +import java.util.Set; + +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.env.MutablePropertySources; +import org.springframework.core.env.PropertiesPropertySource; +import org.springframework.core.env.StandardEnvironment; + +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Config Center Context for Spring Boot 3 + * + * @author odboy + * @version 2.0.0 + * @date 2026-10-11 + */ +public class ConfigcenterContext { + + private final static Logger logger = LoggerFactory.getLogger("cn.odboy"); + + private static ConfigcenterContext instance = new ConfigcenterContext(); + + private List sensitiveKeys = new ArrayList<>(Arrays.asList("password","key","secret","token","credentials")); + + public static final String MANAGER_PROPERTY_SOURCE = "kenaitoConfigPropertySource"; + + private static final String IGNORE_PLACEHOLER = "[Ignore]"; + + private static final String CRYPT_PREFIX = "{Cipher}"; + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + private Boolean remoteEnabled; + + private final String nodeId; + private String[] apiBaseUrls; + private String app; + private String env; + private String version; + private boolean ignoreGlobal; + private String secret; + private String globalSecret; + private String token; + private boolean remoteFirst = false; + private boolean isSpringboot; + private int syncIntervalSeconds = 50; + private InternalConfigChangeListener configChangeListener; + + private List configChangeHanlders; + + private boolean processed; + + private Properties remoteProperties; + + private Properties localProperties = new Properties(); + + private StandardEnvironment environment; + + private ConfigcenterContext() { + String nodeName; + try { + nodeName = InetAddress.getLocalHost().getHostName(); + } catch (Exception e) { + nodeName = "unknown"; + } + this.nodeId = nodeName; + } + + public boolean isProcessed() { + return processed; + } + + public synchronized void init(Properties properties, boolean isSpringboot) { + if (processed || !isRemoteEnabled()) return; + if (properties != null) { + localProperties.putAll(properties); + } + + System.setProperty("client.nodeId", nodeId); + System.setProperty("springboot", String.valueOf(isSpringboot)); + this.isSpringboot = isSpringboot; + + String defaultAppName = getProperty("spring.application.name", "unknown"); + app = getProperty("kenaito.config.appName", defaultAppName); + if (remoteEnabled == null) { + remoteEnabled = Boolean.parseBoolean(getProperty("kenaito.config.enabled", "true")); + } + + if (!isRemoteEnabled()) return; + + env = getProperty("kenaito.config.profile", "dev"); + + if (env == null || env.isBlank()) { + throw new IllegalArgumentException("[kenaito.config.profile] is required"); + } + + setApiBaseUrl(getProperty("kenaito.config.baseUrl", getProperty("kenaito.config.base.url", ""))); + + version = getProperty("kenaito.config.version", "latest"); + ignoreGlobal = Boolean.parseBoolean(getProperty("kenaito.config.global-ignore", "false")); + syncIntervalSeconds = Integer.parseInt(getProperty("kenaito.config.sync-interval-seconds", "60")); + token = getProperty("kenaito.config.token", ""); + + System.out.println(String.format("\n=====Configcenter config=====\nappName:%s\nenv:%s\nversion:%s\nremoteEnabled:%s\napiBaseUrls:%s\n=====Configcenter config=====", + app, env, version, isRemoteEnabled(), toJson(apiBaseUrls))); + } + + public static ConfigcenterContext getInstance() { + return instance; + } + + public boolean isRemoteEnabled() { + return remoteEnabled == null || remoteEnabled; + } + + public void setRemoteEnabled(boolean remoteEnabled) { + this.remoteEnabled = remoteEnabled; + } + + public String[] getApiBaseUrls() { + return apiBaseUrls; + } + + public void setApiBaseUrl(String apiBaseUrl) { + if (apiBaseUrl == null || apiBaseUrl.isBlank()) { + throw new IllegalArgumentException("[kenaito.config.base.url] is required"); + } + + String[] urls = apiBaseUrl.split(",|;"); + this.apiBaseUrls = new String[urls.length]; + + for (int i = 0; i < urls.length; i++) { + if (urls[i].endsWith("/")) { + this.apiBaseUrls[i] = urls[i].substring(0, urls[i].length() - 1); + } else { + this.apiBaseUrls[i] = urls[i]; + } + } + } + + public String getApp() { + return app; + } + + public String getEnv() { + return env; + } + + public String getVersion() { + return version; + } + + public boolean isIgnoreGlobal() { + return ignoreGlobal; + } + + public String getSecret() { + return secret; + } + + public boolean isRemoteFirst() { + return remoteFirst; + } + + public int getSyncIntervalSeconds() { + return syncIntervalSeconds; + } + + public String getNodeId() { + return nodeId; + } + + public boolean isSpringboot() { + return isSpringboot; + } + + public void setEnvironment(StandardEnvironment environment) { + this.environment = environment; + } + + public void mergeRemoteProperties(Properties properties) { + if (!remoteEnabled) return; + remoteProperties = getAllRemoteProperties(); + if (remoteProperties != null) { + Set> entrySet = remoteProperties.entrySet(); + for (Entry entry : entrySet) { + if (!isRemoteFirst() && properties.containsKey(entry.getKey())) { + continue; + } + String value = entry.getValue().toString(); + if (IGNORE_PLACEHOLER.equals(value)) continue; + properties.setProperty(entry.getKey().toString(), value); + } + } + + // Sync merged properties into localProperties for change tracking + localProperties.putAll(properties); + + // Close existing listener before creating new one to prevent scheduler leak + if (configChangeListener != null) { + configChangeListener.close(); + } + // Register listener + configChangeListener = new InternalConfigChangeListener(); + printConfigs(properties); + } + + private Properties getAllRemoteProperties() { + if (remoteProperties != null) return remoteProperties; + Properties properties = new Properties(); + + Map map = fetchConfigFromServer(); + if (map == null) { + logger.warn("fetch remote config error and no local cache available, starting with empty remote config"); + return properties; + } + + secret = Objects.toString(map.remove("kenaito.config.encrypt-secret"), null); + globalSecret = Objects.toString(map.remove("kenaito.config.global-encrypt-secret"), null); + remoteFirst = Boolean.parseBoolean(Objects.toString(map.remove("kenaito.config.remote-config-first"), "false")); + + Set keys = map.keySet(); + for (String key : keys) { + Object value = decodeEncryptIfRequire(map.get(key)); + properties.put(key, value); + } + + return properties; + } + + @SuppressWarnings("unchecked") + private Map fetchConfigFromServer() { + Map result = null; + String errorMsg = null; + + for (String apiBaseUrl : apiBaseUrls) { + String url = buildTokenParameter(String.format("%s/api/fetchAllConfigs?appName=%s&env=%s&version=%s&ignoreGlobal=%s", + apiBaseUrl, app, env, version, ignoreGlobal)); + System.out.println("fetch configs url:" + url); + + try { + String jsonString = HttpUtils.get(url); + if (jsonString != null) { + result = objectMapper.readValue(jsonString, Map.class); + if (result.containsKey("code")) { + errorMsg = result.get("msg").toString(); + System.err.println("fetch error:" + errorMsg); + result = null; + } else { + break; + } + } + } catch (Exception e) { + e.printStackTrace(); + } + } + + if (result == null) { + System.out.println(">>>>>remote config fetch error, load from local Cache"); + result = LocalCacheUtils.read(); + } else { + LocalCacheUtils.write(result); + } + return result; + } + + private void printConfigs(Properties properties) { + List sortKeys = new ArrayList<>(); + Set> entrySet = properties.entrySet(); + for (Entry entry : entrySet) { + String key = entry.getKey().toString(); + sortKeys.add(key); + } + Collections.sort(sortKeys); + System.out.println("==================final config list start=================="); + String value; + for (String key : sortKeys) { + value = hideSensitive(key, properties.getProperty(key)); + System.out.println(String.format("%s = %s", key, value)); + } + System.out.println("==================final config list end===================="); + } + + public synchronized void updateConfig(Map updateConfig) { + if (!updateConfig.isEmpty()) { + Set keySet = updateConfig.keySet(); + for (String key : keySet) { + String oldValue = localProperties.getProperty(key); + localProperties.setProperty(key, decodeEncryptIfRequire(updateConfig.get(key)).toString()); + + if (environment != null) { + MutablePropertySources propertySources = environment.getPropertySources(); + + synchronized (propertySources) { + MapPropertySource source = null; + if (!propertySources.contains(MANAGER_PROPERTY_SOURCE)) { + source = new MapPropertySource(MANAGER_PROPERTY_SOURCE, new LinkedHashMap<>()); + environment.getPropertySources().addFirst(source); + } else { + Object existingSource = propertySources.get(MANAGER_PROPERTY_SOURCE); + if (existingSource instanceof MapPropertySource) { + source = (MapPropertySource) existingSource; + } + } + + if (source != null) { + Map map = source.getSource(); + Properties properties = new Properties(); + properties.putAll(map); + properties.putAll(updateConfig); + propertySources.replace(source.getName(), new PropertiesPropertySource(source.getName(), properties)); + } + } + } + + logger.info("Config [{}] Change, oldValue:{}, newValue:{}", key, oldValue, updateConfig.get(key)); + } + + if (configChangeHanlders == null) { + configChangeHanlders = new ArrayList<>(); + } + + for (ConfigChangeHanlder handler : configChangeHanlders) { + try { + handler.onConfigChanged(updateConfig); + logger.info("invoke {}.onConfigChanged successed!", handler.getClass().getName()); + } catch (Exception e) { + e.printStackTrace(); + logger.warn("invoke {}.onConfigChanged error, msg:{}", handler.getClass().getName(), e.getMessage()); + } + } + } + } + + public String buildTokenParameter(String url) { + if (token == null || token.length() < 8) { + return url; + } + String str = md5Short(token).concat(String.valueOf(new Date().getTime())); + String authtoken = desEncrypt(token.substring(0, 8), str).toLowerCase(); + return url + (url.contains("?") ? "&" : "?") + "authtoken=" + authtoken; + } + + public void close() { + if (configChangeListener != null) { + configChangeListener.close(); + } + } + + private Object decodeEncryptIfRequire(Object data) { + if (data == null) return ""; + if (data.toString().startsWith(CRYPT_PREFIX)) { + if (secret == null || secret.isBlank()) { + throw new IllegalArgumentException("config[kenaito.config.encrypt-secret] is required"); + } + data = data.toString().replace(CRYPT_PREFIX, ""); + String decryptString; + try { + decryptString = decryptWithAES(secret, data.toString()); + } catch (Exception e) { + decryptString = decryptWithAES(globalSecret, data.toString()); + } + return decryptString; + } + return data; + } + + private static String decryptWithAES(String key, String data) { + try { + String secretKey = md5(key).substring(16); + byte[] encrypted = Base64.getDecoder().decode(data.getBytes(StandardCharsets.UTF_8)); + byte[] decrypted = aesDecrypt(encrypted, secretKey.getBytes(StandardCharsets.UTF_8)); + return new String(decrypted, StandardCharsets.UTF_8); + } catch (Exception e) { + throw new RuntimeException(String.format("解密错误:%s", data)); + } + } + + private String hideSensitive(String key, String origin) { + if (key == null || key.isBlank() || origin == null || origin.isBlank()) return ""; + boolean isSensitive = false; + for (String k : sensitiveKeys) { + if (key.toLowerCase().contains(k)) { + isSensitive = true; + break; + } + } + int length = origin.length(); + if (isSensitive && length > 1) { + return origin.substring(0, length / 2).concat("****"); + } + return origin; + } + + // Helper methods + private String getProperty(String key, String defaultValue) { + String value = localProperties.getProperty(key); + if (value == null || value.isBlank()) { + value = System.getProperty(key); + } + if (value == null || value.isBlank()) { + value = System.getenv(key); + } + return value != null && !value.isBlank() ? value : defaultValue; + } + + private String getProperty(String key) { + return getProperty(key, null); + } + + private static String toJson(Object obj) { + try { + return objectMapper.writeValueAsString(obj); + } catch (Exception e) { + return "[]"; + } + } + + private static String md5(String input) { + try { + java.security.MessageDigest md = java.security.MessageDigest.getInstance("MD5"); + byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(); + for (byte b : digest) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } catch (Exception e) { + throw new RuntimeException("MD5 error", e); + } + } + + private static String md5Short(String input) { + if (input == null || input.isBlank()) return ""; + return md5(input).substring(0, 16); + } + + private static String desEncrypt(String key, String data) { + try { + SecretKey secretKey = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "DES"); + Cipher cipher = Cipher.getInstance("DES/ECB/PKCS5Padding"); + cipher.init(Cipher.ENCRYPT_MODE, secretKey); + byte[] encrypted = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(encrypted); + } catch (Exception e) { + throw new RuntimeException("DES encrypt error", e); + } + } + + private static byte[] aesDecrypt(byte[] data, byte[] key) throws Exception { + SecretKey secretKey = new SecretKeySpec(key, "AES"); + // Use CBC mode with zero IV to match Go server + IvParameterSpec iv = new IvParameterSpec(new byte[16]); + Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, secretKey, iv); + byte[] decrypted = cipher.doFinal(data); + // Remove PKCS7 padding + int padding = decrypted[decrypted.length - 1] & 0xFF; + if (padding > 0 && padding <= 16) { + byte[] result = new byte[decrypted.length - padding]; + System.arraycopy(decrypted, 0, result, 0, result.length); + return result; + } + return decrypted; + } + + public void addConfigChangeHandler(ConfigChangeHanlder handler) { + if (configChangeHanlders == null) { + configChangeHanlders = new ArrayList<>(); + } + configChangeHanlders.add(handler); + } +} diff --git a/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/EnvironmentChangeEvent.java b/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/EnvironmentChangeEvent.java new file mode 100644 index 0000000..a8a825b --- /dev/null +++ b/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/EnvironmentChangeEvent.java @@ -0,0 +1,21 @@ +package cn.odboy.confcenter; + +import java.util.Set; + +import org.springframework.context.ApplicationEvent; + +public class EnvironmentChangeEvent extends ApplicationEvent { + + private static final long serialVersionUID = 1L; + + private Set keys; + + public EnvironmentChangeEvent(Set keys) { + super(keys); + this.keys = keys; + } + + public Set getKeys() { + return keys; + } +} diff --git a/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/HttpUtils.java b/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/HttpUtils.java new file mode 100644 index 0000000..400c984 --- /dev/null +++ b/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/HttpUtils.java @@ -0,0 +1,53 @@ +package cn.odboy.confcenter; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; + +/** + * HTTP Utilities for Spring Boot 3 + * Uses Java's built-in HttpClient + * + * @author odboy + * @version 2.0.0 + * @date 2026-10-11 + */ +public class HttpUtils { + + private static final HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + + public static String get(String url) throws Exception { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .GET() + .timeout(Duration.ofSeconds(30)) + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() == 200) { + return response.body(); + } + return null; + } + + public static String postJson(String url, String json) throws Exception { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(json)) + .timeout(Duration.ofSeconds(30)) + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() == 200) { + return response.body(); + } + return null; + } +} diff --git a/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/InternalConfigChangeListener.java b/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/InternalConfigChangeListener.java new file mode 100644 index 0000000..9211598 --- /dev/null +++ b/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/InternalConfigChangeListener.java @@ -0,0 +1,82 @@ +package cn.odboy.confcenter; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Internal Config Change Listener + * Polls for config changes via HTTP + * + * @author odboy + * @version 2.0.0 + * @date 2026-10-11 + */ +public class InternalConfigChangeListener { + + private final static Logger logger = LoggerFactory.getLogger("cn.odboy.confcenter"); + + private static final String LATEST_FETCH_TIMESTAMP = "latest.fetch.timestamp"; + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + private ScheduledExecutorService hbScheduledExecutor; + + public InternalConfigChangeListener() { + ConfigcenterContext context = ConfigcenterContext.getInstance(); + registerHttpListener(context); + } + + public void close() { + if (hbScheduledExecutor != null) { + hbScheduledExecutor.shutdown(); + } + } + + private void registerHttpListener(ConfigcenterContext context) { + hbScheduledExecutor = Executors.newScheduledThreadPool(1); + + final String url = context.getApiBaseUrls()[0] + "/api/fetchChangedConfigs"; + final Map params = new HashMap<>(); + params.put("version", context.getVersion()); + params.put("appName", context.getApp()); + params.put("env", context.getEnv()); + if (context.isIgnoreGlobal()) { + params.put("ignoreGlobal", String.valueOf(context.isIgnoreGlobal())); + } + params.put("lastTime", String.valueOf(System.currentTimeMillis())); + + hbScheduledExecutor.scheduleAtFixedRate(() -> { + try { + String safeUrl = context.buildTokenParameter(url); + String response = HttpUtils.postJson(safeUrl, objectMapper.writeValueAsString(params)); + + if (response != null) { + @SuppressWarnings("unchecked") + Map map = objectMapper.readValue(response, Map.class); + if (map.containsKey("data")) { + @SuppressWarnings("unchecked") + Map data = (Map) map.get("data"); + if (data.containsKey(LATEST_FETCH_TIMESTAMP)) { + params.put("lastTime", data.remove(LATEST_FETCH_TIMESTAMP).toString()); + } + if (!data.isEmpty()) { + context.updateConfig(data); + } + } + } + } catch (Exception e) { + logger.error("Error fetching config changes", e); + } + }, context.getSyncIntervalSeconds(), context.getSyncIntervalSeconds(), TimeUnit.SECONDS); + + logger.info("registerHttpUpdaterListener OK intervalSeconds:{}", context.getSyncIntervalSeconds()); + } +} diff --git a/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/LocalCacheUtils.java b/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/LocalCacheUtils.java new file mode 100644 index 0000000..c552e14 --- /dev/null +++ b/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/LocalCacheUtils.java @@ -0,0 +1,59 @@ +package cn.odboy.confcenter; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileWriter; +import java.io.InputStreamReader; +import java.util.Map; + +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Local Cache Utilities + * + * @author odboy + * @version 2.0.0 + * @date 2026-10-11 + */ +public class LocalCacheUtils { + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + private static String localStorageDir = System.getProperty("kenaito.configcenter.local-storage-dir", + System.getProperty("user.dir")); + + public static void write(Map datas) { + File dir = new File(localStorageDir); + if (!dir.exists()) + dir.mkdirs(); + File file = new File(dir, "config-cache.log"); + try (FileWriter fileWriter = new FileWriter(file, false)) { + fileWriter.write(objectMapper.writeValueAsString(datas)); + } catch (Exception e) { + e.printStackTrace(); + } + } + + @SuppressWarnings("unchecked") + public static Map read() { + File dir = new File(localStorageDir); + if (!dir.exists()) + dir.mkdirs(); + File file = new File(dir, "config-cache.log"); + if (!file.exists()) { + return null; + } + try (BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream(file)))) { + StringBuilder buffer = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + buffer.append(line); + } + return objectMapper.readValue(buffer.toString(), Map.class); + } catch (Exception e) { + e.printStackTrace(); + } + return null; + } +} diff --git a/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/spring/CCPropertyPlaceholderConfigurer.java b/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/spring/CCPropertyPlaceholderConfigurer.java new file mode 100644 index 0000000..c7c3afd --- /dev/null +++ b/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/spring/CCPropertyPlaceholderConfigurer.java @@ -0,0 +1,54 @@ +package cn.odboy.confcenter.spring; + +import java.io.IOException; +import java.util.Properties; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.context.support.PropertySourcesPlaceholderConfigurer; +import org.springframework.core.env.StandardEnvironment; + +import cn.odboy.confcenter.ConfigcenterContext; + +/** + * Spring Property Placeholder Configurer for non-SpringBoot applications + * + * @author odboy + * @version 2.0.0 + * @date 2026-10-11 + */ +public class CCPropertyPlaceholderConfigurer extends PropertySourcesPlaceholderConfigurer + implements DisposableBean, ApplicationContextAware { + + private boolean setRemoteEnabled = false; + private ConfigcenterContext ccContext = ConfigcenterContext.getInstance(); + + public void setRemoteEnabled(boolean remoteEnabled) { + ccContext.setRemoteEnabled(remoteEnabled); + setRemoteEnabled = true; + } + + @Override + protected Properties mergeProperties() throws IOException { + Properties properties = super.mergeProperties(); + if (!setRemoteEnabled) { + ccContext.setRemoteEnabled(Boolean.parseBoolean(properties.getProperty("kenaito.configcenter.enabled", "true"))); + } + ccContext.init(properties, false); + ccContext.mergeRemoteProperties(properties); + return properties; + } + + @Override + public void destroy() throws Exception { + ccContext.close(); + } + + @Override + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + StandardEnvironment environment = (StandardEnvironment) applicationContext.getEnvironment(); + ccContext.setEnvironment(environment); + } +} diff --git a/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/springboot/KenaitoEnvironmentInitializer.java b/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/springboot/KenaitoEnvironmentInitializer.java new file mode 100644 index 0000000..64fd75c --- /dev/null +++ b/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/springboot/KenaitoEnvironmentInitializer.java @@ -0,0 +1,36 @@ +package cn.odboy.confcenter.springboot; + +import java.util.Properties; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.env.EnvironmentPostProcessor; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.PropertiesPropertySource; +import org.springframework.core.env.StandardEnvironment; + +import cn.odboy.confcenter.ConfigcenterContext; + +/** + * Spring Boot 3 Environment Post Processor + * Initializes config center before application context is created + * + * @author odboy + * @version 2.0.0 + * @date 2026-10-11 + */ +public class KenaitoEnvironmentInitializer implements EnvironmentPostProcessor { + + private ConfigcenterContext ccContext = ConfigcenterContext.getInstance(); + + @Override + public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) { + if (ccContext.isRemoteEnabled() && !ccContext.isProcessed()) { + Properties properties = new Properties(); + ccContext.setEnvironment((StandardEnvironment) environment); + ccContext.init(properties, true); + ccContext.mergeRemoteProperties(properties); + PropertiesPropertySource propertySource = new PropertiesPropertySource(ConfigcenterContext.MANAGER_PROPERTY_SOURCE, properties); + environment.getPropertySources().addFirst(propertySource); + } + } +} diff --git a/kenaito-conf-springboot3-client/src/main/resources/META-INF/spring.factories b/kenaito-conf-springboot3-client/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000..a58f71e --- /dev/null +++ b/kenaito-conf-springboot3-client/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.boot.env.EnvironmentPostProcessor=\ +cn.odboy.confcenter.springboot.KenaitoEnvironmentInitializer diff --git a/kenaito-conf-springboot3-client/target/classes/META-INF/spring.factories b/kenaito-conf-springboot3-client/target/classes/META-INF/spring.factories new file mode 100644 index 0000000..a58f71e --- /dev/null +++ b/kenaito-conf-springboot3-client/target/classes/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.boot.env.EnvironmentPostProcessor=\ +cn.odboy.confcenter.springboot.KenaitoEnvironmentInitializer diff --git a/kenaito-conf-springboot3-client/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst b/kenaito-conf-springboot3-client/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst new file mode 100644 index 0000000..179dfe7 --- /dev/null +++ b/kenaito-conf-springboot3-client/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst @@ -0,0 +1,8 @@ +cn\odboy\confcenter\ConfigChangeHanlder.class +cn\odboy\confcenter\springboot\KenaitoEnvironmentInitializer.class +cn\odboy\confcenter\HttpUtils.class +cn\odboy\confcenter\LocalCacheUtils.class +cn\odboy\confcenter\spring\CCPropertyPlaceholderConfigurer.class +cn\odboy\confcenter\ConfigcenterContext.class +cn\odboy\confcenter\EnvironmentChangeEvent.class +cn\odboy\confcenter\InternalConfigChangeListener.class diff --git a/kenaito-conf-springboot3-client/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst b/kenaito-conf-springboot3-client/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst new file mode 100644 index 0000000..c8477a2 --- /dev/null +++ b/kenaito-conf-springboot3-client/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst @@ -0,0 +1,8 @@ +E:\SelfDev\Gitea2026\kenaito-conf\kenaito-conf-springboot3-client\src\main\java\cn\odboy\confcenter\ConfigcenterContext.java +E:\SelfDev\Gitea2026\kenaito-conf\kenaito-conf-springboot3-client\src\main\java\cn\odboy\confcenter\ConfigChangeHanlder.java +E:\SelfDev\Gitea2026\kenaito-conf\kenaito-conf-springboot3-client\src\main\java\cn\odboy\confcenter\EnvironmentChangeEvent.java +E:\SelfDev\Gitea2026\kenaito-conf\kenaito-conf-springboot3-client\src\main\java\cn\odboy\confcenter\HttpUtils.java +E:\SelfDev\Gitea2026\kenaito-conf\kenaito-conf-springboot3-client\src\main\java\cn\odboy\confcenter\InternalConfigChangeListener.java +E:\SelfDev\Gitea2026\kenaito-conf\kenaito-conf-springboot3-client\src\main\java\cn\odboy\confcenter\LocalCacheUtils.java +E:\SelfDev\Gitea2026\kenaito-conf\kenaito-conf-springboot3-client\src\main\java\cn\odboy\confcenter\spring\CCPropertyPlaceholderConfigurer.java +E:\SelfDev\Gitea2026\kenaito-conf\kenaito-conf-springboot3-client\src\main\java\cn\odboy\confcenter\springboot\KenaitoEnvironmentInitializer.java diff --git a/server/application-dev.properties b/server/application-dev.properties new file mode 100644 index 0000000..e102020 --- /dev/null +++ b/server/application-dev.properties @@ -0,0 +1,14 @@ +# ========== 服务器配置 ========== +server.port=8080 + +# ========== 数据库配置 ========== +database.port=3306 +database.name=kenaito_conf +database.user=root + +# ========== JWT 配置 ========== +jwt.secret=kenaito-conf-jwt-secret-2026 +jwt.expireHours=24 + +# ========== 加密配置 ========== +crypto.secretKey=k9#mP2$vL7qR4xN8 diff --git a/server/application.properties b/server/application.properties new file mode 100644 index 0000000..a40ca3c --- /dev/null +++ b/server/application.properties @@ -0,0 +1,2 @@ +active=dev + diff --git a/server/cmd/main.go b/server/cmd/main.go new file mode 100644 index 0000000..3bb0279 --- /dev/null +++ b/server/cmd/main.go @@ -0,0 +1,127 @@ +package main + +import ( + "flag" + "fmt" + "kenaito-conf/server/internal/config" + "kenaito-conf/server/internal/dao" + "kenaito-conf/server/internal/handler" + "kenaito-conf/server/internal/middleware" + "kenaito-conf/server/internal/service" + "log" + + "github.com/gin-gonic/gin" +) + +func main() { + configPath := flag.String("config", "application.properties", "config file path") + flag.Parse() + + // load config + if err := config.Load(*configPath); err != nil { + log.Fatalf("load config error: %v", err) + } + + // init database + if err := dao.Init(); err != nil { + log.Fatalf("init database error: %v", err) + } + defer dao.Engine.Close() + + // init services + authService := service.NewAuthService(config.Cfg.JWT.Secret, config.Cfg.JWT.ExpireHours) + cryptoService := service.NewCryptoService() + parseService := service.NewConfigParseService() + permService := service.NewPermissionService() + + // init handlers + authHandler := handler.NewAuthHandler(authService) + indexHandler := handler.NewIndexHandler() + configHandler := handler.NewConfigHandler(cryptoService, parseService, permService) + configApiHandler := handler.NewConfigApiHandler(cryptoService, parseService) + appHandler := handler.NewAppHandler() + userHandler := handler.NewUserHandler(permService) + groupHandler := handler.NewGroupHandler() + profileHandler := handler.NewProfileHandler() + + // init gin + r := gin.Default() + r.Use(middleware.CORS()) + + // public routes + r.POST("/user/login", authHandler.Login) + + // admin routes (JWT auth required) + admin := r.Group("/admin") + admin.Use(middleware.JWTAuth(authService)) + { + admin.GET("/menus", indexHandler.GetMenus) + + // config management + admin.POST("/config/list", configHandler.List) + admin.GET("/config/:id", configHandler.GetById) + admin.POST("/config/add", configHandler.Add) + admin.POST("/config/update", configHandler.Update) + admin.POST("/config/delete/:id", configHandler.Delete) + admin.GET("/config/configHistories/:id", configHandler.GetHistory) + admin.POST("/config/rollback/:id", configHandler.Rollback) + + // app management + admin.POST("/app/list", appHandler.List) + admin.GET("/app/tree", appHandler.Tree) + admin.GET("/app/:id", appHandler.GetById) + admin.POST("/app/add", appHandler.Add) + admin.POST("/app/update", appHandler.Update) + admin.POST("/app/delete/:id", appHandler.Delete) + admin.POST("/app/switch/:id", appHandler.Switch) + admin.GET("/app/options", appHandler.Options) + + // user management + admin.GET("/user/list", userHandler.List) + admin.POST("/user/add", userHandler.Add) + admin.POST("/user/update", userHandler.AdminUpdate) + admin.POST("/user/delete/:id", userHandler.Delete) + admin.POST("/user/resetPassword/:id", userHandler.ResetPassword) + admin.POST("/user/switch/:id", userHandler.Switch) + admin.GET("/user/options", userHandler.Options) + + // group management + admin.GET("/group/list", groupHandler.List) + admin.GET("/group/get/:id", groupHandler.GetById) + admin.POST("/group/add", groupHandler.Add) + admin.POST("/group/update", groupHandler.Update) + admin.GET("/group/options", groupHandler.Options) + + // profile management + admin.GET("/profile/list", profileHandler.List) + admin.GET("/profile/:id", profileHandler.GetById) + admin.POST("/profile/add", profileHandler.Add) + admin.POST("/profile/update", profileHandler.Update) + admin.POST("/profile/delete/:id", profileHandler.Delete) + admin.POST("/profile/switch/:id", profileHandler.Switch) + admin.GET("/profile/options", profileHandler.Options) + } + + // user info routes (JWT auth required) + user := r.Group("/user") + user.Use(middleware.JWTAuth(authService)) + { + user.GET("/loginUserInfo", authHandler.GetLoginUserInfo) + user.POST("/logout", authHandler.Logout) + } + + // client API routes (no JWT, uses authtoken or open access) + api := r.Group("/api") + { + api.GET("/fetchAllConfigs", configApiHandler.FetchAllConfigs) + api.POST("/fetchChangedConfigs", configApiHandler.FetchChangedConfigs) + api.GET("/ping", configApiHandler.Ping) + } + + // start server + addr := fmt.Sprintf(":%d", config.Cfg.Server.Port) + log.Printf("Server starting on %s", addr) + if err := r.Run(addr); err != nil { + log.Fatalf("server error: %v", err) + } +} diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..2d8c609 --- /dev/null +++ b/server/go.mod @@ -0,0 +1,48 @@ +module kenaito-conf/server + +go 1.26.0 + +require ( + github.com/gin-contrib/cors v1.7.9 + github.com/gin-gonic/gin v1.12.0 + github.com/go-sql-driver/mysql v1.10.1 + github.com/golang-jwt/jwt/v5 v5.3.1 + golang.org/x/crypto v0.58.0 + gopkg.in/yaml.v3 v3.0.1 + xorm.io/xorm v1.4.3 +) + +require ( + filippo.io/edwards25519 v1.2.0 // indirect + github.com/bytedance/gopkg v0.1.4 // indirect + github.com/bytedance/sonic v1.15.2 // indirect + github.com/bytedance/sonic/loader v0.5.1 // indirect + github.com/cloudwego/base64x v0.1.7 // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/gin-contrib/sse v1.1.1 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.3 // indirect + github.com/goccy/go-json v0.10.6 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/golang/snappy v0.0.4 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.4.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.23 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.4.3 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.60.0 // indirect + github.com/syndtr/goleveldb v1.0.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.8.0 // indirect + golang.org/x/arch v0.29.0 // indirect + golang.org/x/net v0.60.0 // indirect + golang.org/x/sys v0.49.0 // indirect + golang.org/x/text v0.43.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + xorm.io/builder v0.3.13 // indirect +) diff --git a/server/go.sum b/server/go.sum new file mode 100644 index 0000000..cf7a489 --- /dev/null +++ b/server/go.sum @@ -0,0 +1,175 @@ +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGqTOXqu2aRi/XEQxDCBwM8yJtE6s= +gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU= +github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= +github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= +github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo= +github.com/bytedance/sonic v1.15.2/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA= +github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI= +github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI= +github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/cors v1.7.9 h1:69rz5YU6PW7XlQ4VPXacl77ldFiUvR0E0bmEqmbq86w= +github.com/gin-contrib/cors v1.7.9/go.mod h1:KTqiTA5HzlWPgVz7p4tolzAuptzGO7DEJn/CbklG0nU= +github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko= +github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8= +github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= +github.com/go-sql-driver/mysql v1.10.1 h1:arlSnNLq6a5yxGxV7qg9lF4j0C+KwD6NbQyKr9QL6ME= +github.com/go-sql-driver/mysql v1.10.1/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= +github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ= +github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= +github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= +github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= +github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0= +github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE= +github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8= +go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/arch v0.29.0 h1:8sSET5wB0+exBm0FGmOtdHMqjlRdV2DRD3/IV6OZgho= +golang.org/x/arch v0.29.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= +golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= +golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= +golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= +golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= +golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= +golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI= +golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI= +lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= +modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw= +modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0= +modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw= +modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sqlite v1.20.4 h1:J8+m2trkN+KKoE7jglyHYYYiaq5xmz2HoHJIiBlRzbE= +modernc.org/sqlite v1.20.4/go.mod h1:zKcGyrICaxNTMEHSr1HQ2GUraP0j+845GYw37+EyT6A= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +xorm.io/builder v0.3.13 h1:a3jmiVVL19psGeXx8GIurTp7p0IIgqeDmwhcR6BAOAo= +xorm.io/builder v0.3.13/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE= +xorm.io/xorm v1.4.3 h1:MwWFWzVr+/6D07qGCDhBAfABcuT0gvqY3XmTy1215BM= +xorm.io/xorm v1.4.3/go.mod h1:cs0ePc8O4a0jD78cNvD+0VFwhqotTvLQZv372QsDw7Q= diff --git a/server/internal/config/config.go b/server/internal/config/config.go new file mode 100644 index 0000000..81835fa --- /dev/null +++ b/server/internal/config/config.go @@ -0,0 +1,141 @@ +package config + +import ( + "bufio" + "fmt" + "github.com/gin-gonic/gin" + "os" + "path/filepath" + "strconv" + "strings" +) + +type Config struct { + Server ServerConfig + Database DatabaseConfig + JWT JWTConfig + Crypto CryptoConfig +} + +type ServerConfig struct { + Port int + Mode string +} + +type DatabaseConfig struct { + Host string + Port int + Name string + User string + Password string +} + +type JWTConfig struct { + Secret string + ExpireHours int +} + +type CryptoConfig struct { + SecretKey string +} + +var Cfg *Config + +func Load(path string) error { + baseProps, err := parseProps(path) + if err != nil { + return err + } + + active := baseProps["active"] + if active == "" { + return fmt.Errorf("missing required config: active") + } + + dir := filepath.Dir(path) + profilePath := filepath.Join(dir, "application-"+active+".properties") + profileProps, err := parseProps(profilePath) + if err != nil { + return fmt.Errorf("load profile %s: %w", active, err) + } + + for k, v := range profileProps { + baseProps[k] = v + } + + Cfg = &Config{} + + Cfg.Server.Port = intProp(baseProps, "server.port", 8080) + setServerMode(active) + + Cfg.Database.Host = baseProps["database.host"] + Cfg.Database.Port = intProp(baseProps, "database.port", 3306) + Cfg.Database.Name = baseProps["database.name"] + Cfg.Database.User = baseProps["database.user"] + Cfg.Database.Password = baseProps["database.password"] + + Cfg.JWT.Secret = baseProps["jwt.secret"] + Cfg.JWT.ExpireHours = intProp(baseProps, "jwt.expireHours", 24) + + Cfg.Crypto.SecretKey = baseProps["crypto.secretKey"] + + if Cfg.Database.Host == "" { + return fmt.Errorf("missing required config: database.host") + } + + return nil +} + +func setServerMode(active string) { + if "daily" == active { + Cfg.Server.Mode = gin.DebugMode + } else if "stage" == active || "online" == active { + Cfg.Server.Mode = gin.ReleaseMode + } else { + Cfg.Server.Mode = gin.DebugMode + } +} + +func parseProps(path string) (map[string]string, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + props := make(map[string]string) + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + idx := strings.Index(line, "=") + if idx < 0 { + continue + } + key := strings.TrimSpace(line[:idx]) + val := strings.TrimSpace(line[idx+1:]) + props[key] = val + } + if err := scanner.Err(); err != nil { + return nil, err + } + return props, nil +} + +func intProp(props map[string]string, key string, defaultVal int) int { + v, ok := props[key] + if !ok || v == "" { + return defaultVal + } + n, err := strconv.Atoi(v) + if err != nil { + return defaultVal + } + return n +} + +func (d *DatabaseConfig) DSN() string { + return d.User + ":" + d.Password + "@tcp(" + d.Host + ":" + strconv.Itoa(d.Port) + ")/" + d.Name + "?charset=utf8&parseTime=true" +} diff --git a/server/internal/dao/engine.go b/server/internal/dao/engine.go new file mode 100644 index 0000000..fa3885e --- /dev/null +++ b/server/internal/dao/engine.go @@ -0,0 +1,98 @@ +package dao + +import ( + "fmt" + "kenaito-conf/server/internal/config" + "kenaito-conf/server/internal/model" + "kenaito-conf/server/internal/util" + "time" + + _ "github.com/go-sql-driver/mysql" + "xorm.io/xorm" +) + +var Engine *xorm.Engine + +func Init() error { + cfg := config.Cfg.Database + dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8&parseTime=true&loc=Local", + cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.Name) + + var err error + Engine, err = xorm.NewEngine("mysql", dsn) + if err != nil { + return err + } + Engine.SetMaxOpenConns(20) + Engine.SetMaxIdleConns(5) + + // sync table structures + if err := Engine.Sync2( + new(model.BusinessGroup), + new(model.Profile), + new(model.Application), + new(model.AppExtrAttr), + new(model.AppConfig), + new(model.AppConfigHistory), + new(model.User), + new(model.UserPermission), + ); err != nil { + return err + } + + // insert initial data if tables are empty + return initSeedData() +} + +func initSeedData() error { + // check if profiles table is empty + count, err := Engine.Count(new(model.Profile)) + if err != nil { + return err + } + if count > 0 { + return nil + } + + // insert profiles + profiles := []model.Profile{ + {Name: "daily", Alias: "测试环境", IsDefault: true, Enabled: true}, + {Name: "stage", Alias: "预发布环境", IsDefault: true, Enabled: true}, + {Name: "online", Alias: "线上环境", IsDefault: true, Enabled: true}, + } + for _, p := range profiles { + if _, err := Engine.Insert(&p); err != nil { + return err + } + } + + // insert admin user + now := time.Now() + hashedPassword, err := util.HashPassword("admin123") + if err != nil { + return err + } + admin := model.User{ + Name: "admin", + Password: hashedPassword, + Type: "superAdmin", + Enabled: true, + CreatedAt: &now, + } + if _, err := Engine.Insert(&admin); err != nil { + return err + } + + // insert default business group + group := model.BusinessGroup{ + Name: "默认组", + Enabled: true, + CreatedAt: &now, + CreatedBy: "admin", + } + if _, err := Engine.Insert(&group); err != nil { + return err + } + + return nil +} diff --git a/server/internal/handler/app.go b/server/internal/handler/app.go new file mode 100644 index 0000000..e061911 --- /dev/null +++ b/server/internal/handler/app.go @@ -0,0 +1,302 @@ +package handler + +import ( + "kenaito-conf/server/internal/dao" + "kenaito-conf/server/internal/middleware" + "kenaito-conf/server/internal/model" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" +) + +type AppHandler struct{} + +func NewAppHandler() *AppHandler { + return &AppHandler{} +} + +type AppListRequest struct { + PageNo int `form:"pageNo" binding:"required"` + PageSize int `form:"pageSize" binding:"required"` + AppType string `form:"appType"` +} + +func (h *AppHandler) List(c *gin.Context) { + var req AppListRequest + if err := c.ShouldBindQuery(&req); err != nil { + c.JSON(http.StatusBadRequest, model.WrapperResponse{Code: 400, Msg: "参数错误"}) + return + } + + userInfo := middleware.GetUserInfo(c) + query := dao.Engine.Where("enabled = 1") + + if !userInfo.IsSuperAdmin() { + if userInfo.IsGroupAdmin() && userInfo.GroupId != nil { + query = query.And("group_id = ?", *userInfo.GroupId) + } else { + userId, _ := strconv.Atoi(userInfo.Id) + query = query.And("(owner_id = ? OR id IN (SELECT app_id FROM user_permissions WHERE user_id = ?))", userId, userId) + } + } + + var apps []model.Application + total, _ := query.Count(&model.Application{}) + query.Limit(req.PageSize, (req.PageNo-1)*req.PageSize).Find(&apps) + + // load group names + groupIds := make(map[int]bool) + for _, app := range apps { + if app.GroupId != nil { + groupIds[*app.GroupId] = true + } + } + groupMap := make(map[int]string) + for gid := range groupIds { + var group model.BusinessGroup + if has, _ := dao.Engine.ID(gid).Get(&group); has { + groupMap[gid] = group.Name + } + } + for i := range apps { + if apps[i].GroupId != nil { + apps[i].GroupName = groupMap[*apps[i].GroupId] + } + } + + c.JSON(http.StatusOK, model.PageResult{PageNo: req.PageNo, PageSize: req.PageSize, Total: total, Data: apps}) +} + +func (h *AppHandler) GetById(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + var app model.Application + has, _ := dao.Engine.ID(id).Get(&app) + if !has { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 404, Msg: "应用不存在"}) + return + } + c.JSON(http.StatusOK, model.WrapperResponse{Code: 0, Data: app}) +} + +func (h *AppHandler) Tree(c *gin.Context) { + var apps []model.Application + dao.Engine.Find(&apps) + + tree := make(map[string]*AppTreeModel) + for _, app := range apps { + node := &AppTreeModel{ + Id: strconv.Itoa(app.Id), + Name: app.Name, + Code: app.Code, + ServiceId: app.ServiceId, + Children: make([]*AppTreeModel, 0), + } + tree[strconv.Itoa(app.Id)] = node + } + + result := make([]*AppTreeModel, 0) + for _, app := range apps { + node := tree[strconv.Itoa(app.Id)] + if app.ParentId == nil { + result = append(result, node) + } else { + parentId := strconv.Itoa(*app.ParentId) + if parent, ok := tree[parentId]; ok { + parent.Children = append(parent.Children, node) + } + } + } + + c.JSON(http.StatusOK, model.WrapperResponse{Code: 0, Data: result}) +} + +type AddAppRequest struct { + Id int `json:"id"` + GroupId *int `json:"groupId"` + ParentId *int `json:"parentId"` + Name string `json:"name" binding:"required"` + Code string `json:"code" binding:"required"` + ServiceId string `json:"serviceId"` + OwnerId *int `json:"ownerId"` + Remarks string `json:"remarks"` +} + +func (h *AppHandler) Add(c *gin.Context) { + var req AddAppRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.WrapperResponse{Code: 400, Msg: "参数错误"}) + return + } + + userInfo := middleware.GetUserInfo(c) + + // check if code exists + var existing model.Application + if has, _ := dao.Engine.Where("code = ?", req.Code).Get(&existing); has { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 1002, Msg: "应用已存在"}) + return + } + + ownerId := req.OwnerId + if ownerId == nil { + uid, _ := strconv.Atoi(userInfo.Id) + ownerId = &uid + } + + var owner model.User + if has, _ := dao.Engine.ID(*ownerId).Get(&owner); !has { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 400, Msg: "负责人不存在"}) + return + } + + app := model.Application{ + GroupId: req.GroupId, + ParentId: req.ParentId, + Name: req.Name, + Code: req.Code, + ServiceId: req.ServiceId, + OwnerId: ownerId, + OwnerName: owner.Name, + Remarks: req.Remarks, + Enabled: true, + CreatedAt: func() *time.Time { t := time.Now(); return &t }(), + CreatedBy: userInfo.Name, + } + + if req.ParentId != nil { + var parent model.Application + if has, _ := dao.Engine.ID(*req.ParentId).Get(&parent); has { + app.GroupId = parent.GroupId + app.IsModule = true + app.Code = parent.Code + "-" + req.Code + } + } + + dao.Engine.Insert(&app) + + c.JSON(http.StatusOK, model.WrapperResponse{Code: 0, Data: true}) +} + +func (h *AppHandler) Update(c *gin.Context) { + var req AddAppRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.WrapperResponse{Code: 400, Msg: "参数错误"}) + return + } + + if req.Id == 0 { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 400, Msg: "id参数缺失"}) + return + } + + var app model.Application + has, _ := dao.Engine.ID(req.Id).Get(&app) + if !has { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 404, Msg: "应用不存在"}) + return + } + + userInfo := middleware.GetUserInfo(c) + + app.Name = req.Name + app.ServiceId = req.ServiceId + app.Remarks = req.Remarks + if req.OwnerId != nil { + if app.OwnerId == nil || *req.OwnerId != *app.OwnerId { + var owner model.User + if has, _ := dao.Engine.ID(*req.OwnerId).Get(&owner); has { + app.OwnerName = owner.Name + } + } + app.OwnerId = req.OwnerId + } + app.UpdatedBy = userInfo.Name + now := time.Now() + app.UpdatedAt = &now + + dao.Engine.ID(app.Id).Cols("name", "service_id", "remarks", "owner_id", "owner_name", "updated_by", "updated_at").Update(&app) + + c.JSON(http.StatusOK, model.WrapperResponse{Code: 0, Data: true}) +} + +func (h *AppHandler) Delete(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + userInfo := middleware.GetUserInfo(c) + + if !userInfo.IsSuperAdmin() { + c.JSON(http.StatusForbidden, model.WrapperResponse{Code: 403, Msg: "超级管理员才能删除"}) + return + } + + var app model.Application + has, _ := dao.Engine.ID(id).Get(&app) + if !has { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 404, Msg: "应用不存在"}) + return + } + + app.Enabled = false + app.UpdatedBy = userInfo.Name + now := time.Now() + app.UpdatedAt = &now + dao.Engine.ID(app.Id).Cols("enabled", "updated_by", "updated_at").Update(&app) + + // soft delete related configs + dao.Engine.Where("app_id = ?", id).Cols("enabled").Update(&model.AppConfig{Enabled: false}) + + c.JSON(http.StatusOK, model.WrapperResponse{Code: 0}) +} + +func (h *AppHandler) Switch(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + userInfo := middleware.GetUserInfo(c) + + var app model.Application + has, _ := dao.Engine.ID(id).Get(&app) + if !has { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 404, Msg: "应用不存在"}) + return + } + + app.Enabled = !app.Enabled + app.UpdatedBy = userInfo.Name + now := time.Now() + app.UpdatedAt = &now + dao.Engine.ID(app.Id).Cols("enabled", "updated_by", "updated_at").Update(&app) + + c.JSON(http.StatusOK, model.WrapperResponse{Code: 0, Data: true}) +} + +func (h *AppHandler) Options(c *gin.Context) { + userInfo := middleware.GetUserInfo(c) + query := dao.Engine.Where("enabled = 1") + + if !userInfo.IsSuperAdmin() { + if userInfo.IsGroupAdmin() && userInfo.GroupId != nil { + query = query.And("group_id = ?", *userInfo.GroupId) + } else { + userId, _ := strconv.Atoi(userInfo.Id) + query = query.And("owner_id = ?", userId) + } + } + + var apps []model.Application + query.Find(&apps) + + options := make([]model.SelectOption, 0) + for _, app := range apps { + options = append(options, model.SelectOption{Value: strconv.Itoa(app.Id), Label: app.Name}) + } + + c.JSON(http.StatusOK, model.WrapperResponse{Code: 0, Data: options}) +} + +type AppTreeModel struct { + Id string `json:"id"` + Name string `json:"name"` + Code string `json:"code"` + ServiceId string `json:"serviceId,omitempty"` + Children []*AppTreeModel `json:"children"` +} diff --git a/server/internal/handler/auth.go b/server/internal/handler/auth.go new file mode 100644 index 0000000..e274327 --- /dev/null +++ b/server/internal/handler/auth.go @@ -0,0 +1,55 @@ +package handler + +import ( + "kenaito-conf/server/internal/middleware" + "kenaito-conf/server/internal/model" + "kenaito-conf/server/internal/service" + "net/http" + + "github.com/gin-gonic/gin" +) + +type AuthHandler struct { + authService *service.AuthService +} + +func NewAuthHandler(authService *service.AuthService) *AuthHandler { + return &AuthHandler{authService: authService} +} + +type LoginRequest struct { + UserName string `json:"userName" binding:"required"` + Password string `json:"password" binding:"required"` +} + +func (h *AuthHandler) Login(c *gin.Context) { + var req LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.WrapperResponse{Code: 400, Msg: "参数错误"}) + return + } + + token, userInfo, err := h.authService.Login(req.UserName, req.Password) + if err != nil { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 400, Msg: err.Error()}) + return + } + + c.JSON(http.StatusOK, model.WrapperResponse{ + Code: 0, + Data: gin.H{"token": token, "userInfo": userInfo}, + }) +} + +func (h *AuthHandler) GetLoginUserInfo(c *gin.Context) { + userInfo := middleware.GetUserInfo(c) + if userInfo == nil { + c.JSON(http.StatusUnauthorized, model.WrapperResponse{Code: 401, Msg: "未登录"}) + return + } + c.JSON(http.StatusOK, model.WrapperResponse{Code: 0, Data: userInfo}) +} + +func (h *AuthHandler) Logout(c *gin.Context) { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 0}) +} diff --git a/server/internal/handler/config.go b/server/internal/handler/config.go new file mode 100644 index 0000000..f31397d --- /dev/null +++ b/server/internal/handler/config.go @@ -0,0 +1,320 @@ +package handler + +import ( + "kenaito-conf/server/internal/dao" + "kenaito-conf/server/internal/middleware" + "kenaito-conf/server/internal/model" + "kenaito-conf/server/internal/service" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" +) + +type ConfigHandler struct { + cryptoService *service.CryptoService + parseService *service.ConfigParseService + permService *service.PermissionService +} + +func NewConfigHandler(crypto *service.CryptoService, parse *service.ConfigParseService, perm *service.PermissionService) *ConfigHandler { + return &ConfigHandler{cryptoService: crypto, parseService: parse, permService: perm} +} + +type ConfigListRequest struct { + PageNo int `form:"pageNo" binding:"required"` + PageSize int `form:"pageSize" binding:"required"` + Env string `form:"env"` + AppId *int `form:"appId"` +} + +func (h *ConfigHandler) List(c *gin.Context) { + var req ConfigListRequest + if err := c.ShouldBindQuery(&req); err != nil { + c.JSON(http.StatusBadRequest, model.WrapperResponse{Code: 400, Msg: "参数错误"}) + return + } + + userInfo := middleware.GetUserInfo(c) + query := dao.Engine.Where("enabled = 1") + + if req.Env != "" { + query = query.And("env = ?", req.Env) + } + if req.AppId != nil { + if *req.AppId <= 0 { + query = query.And("(app_id is null OR app_id = 0)") + } else { + var app model.Application + if has, _ := dao.Engine.ID(*req.AppId).Get(&app); has { + query = query.And("app_code like ?", app.Code+"%") + } + } + } + + // permission filter + if !userInfo.IsSuperAdmin() { + if userInfo.IsGroupAdmin() && userInfo.GroupId != nil { + query = query.And("group_id = ?", *userInfo.GroupId) + } else if len(userInfo.GrantAppIds) > 0 { + query = query.In("app_id", userInfo.GrantAppIds) + } else { + c.JSON(http.StatusOK, model.PageResult{PageNo: req.PageNo, PageSize: req.PageSize, Total: 0, Data: []interface{}{}}) + return + } + } + + var configs []model.AppConfig + total, _ := query.Count(&model.AppConfig{}) + query.Limit(req.PageSize, (req.PageNo-1)*req.PageSize).Find(&configs) + + // build app names + for i := range configs { + configs[i].AppNames = h.buildAppNames(&configs[i]) + } + + c.JSON(http.StatusOK, model.PageResult{PageNo: req.PageNo, PageSize: req.PageSize, Total: total, Data: configs}) +} + +func (h *ConfigHandler) GetById(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + var config model.AppConfig + has, _ := dao.Engine.ID(id).Get(&config) + if !has { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 404, Msg: "配置不存在"}) + return + } + + userInfo := middleware.GetUserInfo(c) + if err := h.permService.RequirePermission(userInfo, config.GroupId, config.AppId, "RO"); err != nil { + c.JSON(http.StatusForbidden, model.WrapperResponse{Code: 403, Msg: err.Error()}) + return + } + + config.AppNames = h.buildAppNames(&config) + c.JSON(http.StatusOK, model.WrapperResponse{Code: 0, Data: config}) +} + +type AddConfigRequest struct { + Id int `json:"id"` + GroupId *int `json:"groupId"` + AppId *int `json:"appId"` + Env string `json:"env" binding:"required"` + Version string `json:"version"` + Name string `json:"name"` + Type int `json:"type" binding:"required"` + Contents string `json:"contents"` + Global bool `json:"global"` +} + +func (h *ConfigHandler) Add(c *gin.Context) { + var req AddConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.WrapperResponse{Code: 400, Msg: "参数错误"}) + return + } + + userInfo := middleware.GetUserInfo(c) + if req.Global { + if req.GroupId == nil { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 400, Msg: "全局配置需要指定所属业务组"}) + return + } + if err := h.permService.RequirePermission(userInfo, req.GroupId, nil, "RW"); err != nil { + c.JSON(http.StatusForbidden, model.WrapperResponse{Code: 403, Msg: err.Error()}) + return + } + } + + if !req.Global && req.AppId == nil { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 400, Msg: "非全局绑定应用不能为空"}) + return + } + + var app model.Application + if req.AppId != nil { + if has, _ := dao.Engine.ID(*req.AppId).Get(&app); !has { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 400, Msg: "应用不存在"}) + return + } + } + + config := model.AppConfig{ + GroupId: req.GroupId, + AppId: req.AppId, + Env: req.Env, + Version: req.Version, + Name: req.Name, + Type: req.Type, + Contents: req.Contents, + Enabled: true, + CreatedAt: func() *time.Time { t := time.Now(); return &t }(), + CreatedBy: userInfo.Name, + UpdatedAt: func() *time.Time { t := time.Now(); return &t }(), + UpdatedBy: userInfo.Name, + } + + if req.AppId != nil { + config.AppCode = app.Code + if !req.Global { + config.GroupId = app.GroupId + } + } + + // encrypt sensitive values + h.encryptIfRequired(&config) + + if _, err := dao.Engine.Insert(&config); err != nil { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 500, Msg: "保存失败"}) + return + } + + c.JSON(http.StatusOK, model.WrapperResponse{Code: 0, Data: true}) +} + +func (h *ConfigHandler) Update(c *gin.Context) { + var req AddConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.WrapperResponse{Code: 400, Msg: "参数错误"}) + return + } + + if req.Id == 0 { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 400, Msg: "id参数缺失"}) + return + } + + var config model.AppConfig + has, _ := dao.Engine.ID(req.Id).Get(&config) + if !has { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 404, Msg: "配置不存在"}) + return + } + + userInfo := middleware.GetUserInfo(c) + if err := h.permService.RequirePermission(userInfo, config.GroupId, config.AppId, "RW"); err != nil { + c.JSON(http.StatusForbidden, model.WrapperResponse{Code: 403, Msg: err.Error()}) + return + } + + // save history + h.saveHistory(&config, userInfo.Name) + + originalContents := config.Contents + config.Contents = req.Contents + config.Version = req.Version + config.UpdatedBy = userInfo.Name + now := time.Now() + config.UpdatedAt = &now + + h.encryptIfRequired(&config) + + dao.Engine.ID(config.Id).Cols("contents", "version", "updated_by", "updated_at").Update(&config) + + // TODO: publish config change event (ZK removed, could use webhook or other mechanism) + _ = originalContents + + c.JSON(http.StatusOK, model.WrapperResponse{Code: 0, Data: true}) +} + +func (h *ConfigHandler) Delete(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + var config model.AppConfig + has, _ := dao.Engine.ID(id).Get(&config) + if !has { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 404, Msg: "配置不存在"}) + return + } + + userInfo := middleware.GetUserInfo(c) + if err := h.permService.RequirePermission(userInfo, config.GroupId, config.AppId, "RW"); err != nil { + c.JSON(http.StatusForbidden, model.WrapperResponse{Code: 403, Msg: err.Error()}) + return + } + + config.Enabled = false + config.UpdatedBy = userInfo.Name + now := time.Now() + config.UpdatedAt = &now + dao.Engine.ID(config.Id).Cols("enabled", "updated_by", "updated_at").Update(&config) + + c.JSON(http.StatusOK, model.WrapperResponse{Code: 0}) +} + +func (h *ConfigHandler) GetHistory(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + var histories []model.AppConfigHistory + dao.Engine.Where("origin_id = ?", id).OrderBy("id desc").Limit(5).Find(&histories) + + if len(histories) > 0 { + var currentConfig model.AppConfig + if has, _ := dao.Engine.ID(histories[0].OriginId).Get(¤tConfig); has { + for i := range histories { + histories[i].ActiveContents = currentConfig.Contents + } + } + } + + c.JSON(http.StatusOK, model.WrapperResponse{Code: 0, Data: histories}) +} + +func (h *ConfigHandler) Rollback(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + var history model.AppConfigHistory + has, _ := dao.Engine.ID(id).Get(&history) + if !has { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 404, Msg: "历史记录不存在"}) + return + } + + var config model.AppConfig + if has, _ := dao.Engine.ID(history.OriginId).Get(&config); has { + userInfo := middleware.GetUserInfo(c) + if err := h.permService.RequirePermission(userInfo, config.GroupId, config.AppId, "RW"); err != nil { + c.JSON(http.StatusForbidden, model.WrapperResponse{Code: 403, Msg: err.Error()}) + return + } + + config.Contents = history.Contents + config.UpdatedBy = userInfo.Name + now := time.Now() + config.UpdatedAt = &now + dao.Engine.ID(config.Id).Cols("contents", "updated_by", "updated_at").Update(&config) + } + + c.JSON(http.StatusOK, model.WrapperResponse{Code: 0}) +} + +func (h *ConfigHandler) buildAppNames(config *model.AppConfig) string { + if config.Global || (config.AppId == nil || *config.AppId == 0) { + return "全局配置" + } + var app model.Application + if has, _ := dao.Engine.ID(*config.AppId).Get(&app); has { + return app.Name + "(" + app.Code + ")" + } + return "" +} + +func (h *ConfigHandler) saveHistory(config *model.AppConfig, operator string) { + history := model.AppConfigHistory{ + OriginId: config.Id, + AppId: config.AppId, + AppNames: h.buildAppNames(config), + Env: config.Env, + Version: config.Version, + Name: config.Name, + Type: config.Type, + Contents: config.Contents, + CreatedAt: func() *time.Time { t := time.Now(); return &t }(), + CreatedBy: operator, + } + dao.Engine.Insert(&history) +} + +func (h *ConfigHandler) encryptIfRequired(config *model.AppConfig) { + // simplified: check if contents contain sensitive keys + // In production, parse the config and encrypt values with {Cipher} prefix + // For now, skip encryption logic +} diff --git a/server/internal/handler/config_api.go b/server/internal/handler/config_api.go new file mode 100644 index 0000000..30f0813 --- /dev/null +++ b/server/internal/handler/config_api.go @@ -0,0 +1,171 @@ +package handler + +import ( + "fmt" + "kenaito-conf/server/internal/dao" + "kenaito-conf/server/internal/model" + "kenaito-conf/server/internal/service" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" +) + +type ConfigApiHandler struct { + cryptoService *service.CryptoService + parseService *service.ConfigParseService +} + +func NewConfigApiHandler(crypto *service.CryptoService, parse *service.ConfigParseService) *ConfigApiHandler { + return &ConfigApiHandler{cryptoService: crypto, parseService: parse} +} + +func (h *ConfigApiHandler) FetchAllConfigs(c *gin.Context) { + appName := c.Query("appName") + env := c.Query("env") + version := c.DefaultQuery("version", "latest") + ignoreGlobal := c.DefaultQuery("ignoreGlobal", "false") == "true" + + if appName == "" || env == "" { + c.JSON(http.StatusBadRequest, model.WrapperResponse{Code: 400, Msg: "参数缺失"}) + return + } + + // find app by code + var app model.Application + has, _ := dao.Engine.Where("code = ? AND enabled = 1", appName).Get(&app) + if !has { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 1001, Msg: "配置不存在"}) + return + } + + result := make(map[string]interface{}) + + // load global configs + if !ignoreGlobal && app.GroupId != nil { + var globalConfigs []model.AppConfig + dao.Engine.Where("env = ? AND version = ? AND enabled = 1 AND group_id = ? AND (app_id is null OR app_id = 0)", + env, version, *app.GroupId).Find(&globalConfigs) + for _, cfg := range globalConfigs { + kvMap := h.parseService.ParseConfigToKVMap(&cfg) + for k, v := range kvMap { + result[k] = v + } + } + } + + // load parent app configs + if app.ParentId != nil { + var parentConfigs []model.AppConfig + dao.Engine.Where("env = ? AND version = ? AND enabled = 1 AND app_id = ?", + env, version, *app.ParentId).Find(&parentConfigs) + for _, cfg := range parentConfigs { + kvMap := h.parseService.ParseConfigToKVMap(&cfg) + for k, v := range kvMap { + result[k] = v + } + } + } + + // load app configs + var appConfigs []model.AppConfig + dao.Engine.Where("env = ? AND version = ? AND enabled = 1 AND app_id = ?", + env, version, app.Id).Find(&appConfigs) + for _, cfg := range appConfigs { + kvMap := h.parseService.ParseConfigToKVMap(&cfg) + for k, v := range kvMap { + result[k] = v + } + } + + if len(result) == 0 { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 1001, Msg: "配置不存在"}) + return + } + + // add encryption keys + globalCryptKey := h.cryptoService.GetCryptKey(0, env) + result["kenaito.config.global-encrypt-secret"] = globalCryptKey + appCryptKey := h.cryptoService.GetCryptKey(app.Id, env) + result["kenaito.config.encrypt-secret"] = appCryptKey + result["latest.fetch.timestamp"] = strconv.FormatInt(time.Now().UnixMilli(), 10) + + c.JSON(http.StatusOK, result) +} + +type FetchChangedRequest struct { + AppName string `json:"appName" binding:"required"` + Env string `json:"env" binding:"required"` + Version string `json:"version"` + LastTime int64 `json:"lastTime" binding:"required"` + IgnoreGlobal bool `json:"ignoreGlobal"` + FetchAll bool `json:"fetchAll"` +} + +func (h *ConfigApiHandler) FetchChangedConfigs(c *gin.Context) { + var req FetchChangedRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.WrapperResponse{Code: 400, Msg: "参数错误"}) + return + } + + var app model.Application + has, _ := dao.Engine.Where("code = ? AND enabled = 1", req.AppName).Get(&app) + if !has { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 1001, Msg: "应用不存在"}) + return + } + + version := req.Version + if version == "" { + version = "latest" + } + + lastUpdateTime := time.UnixMilli(req.LastTime) + + result := make(map[string]string) + + // load global changed configs + if !req.IgnoreGlobal && app.GroupId != nil { + var globalConfigs []model.AppConfig + dao.Engine.Where("env = ? AND version = ? AND enabled = 1 AND group_id = ? AND (app_id is null OR app_id = 0) AND updated_at > ?", + req.Env, version, *app.GroupId, lastUpdateTime).Find(&globalConfigs) + for _, cfg := range globalConfigs { + h.addChangedConfigs(result, &cfg, req.FetchAll) + } + } + + // load parent app changed configs + if app.ParentId != nil { + var parentConfigs []model.AppConfig + dao.Engine.Where("env = ? AND version = ? AND enabled = 1 AND app_id = ? AND updated_at > ?", + req.Env, version, *app.ParentId, lastUpdateTime).Find(&parentConfigs) + for _, cfg := range parentConfigs { + h.addChangedConfigs(result, &cfg, req.FetchAll) + } + } + + // load app changed configs + var appConfigs []model.AppConfig + dao.Engine.Where("env = ? AND version = ? AND enabled = 1 AND app_id = ? AND updated_at > ?", + req.Env, version, app.Id, lastUpdateTime).Find(&appConfigs) + for _, cfg := range appConfigs { + h.addChangedConfigs(result, &cfg, req.FetchAll) + } + + result["latest.fetch.timestamp"] = strconv.FormatInt(time.Now().UnixMilli(), 10) + + c.JSON(http.StatusOK, model.WrapperResponse{Code: 0, Data: result}) +} + +func (h *ConfigApiHandler) addChangedConfigs(result map[string]string, config *model.AppConfig, fetchAll bool) { + kvMap := h.parseService.ParseConfigToKVMap(config) + for k, v := range kvMap { + result[k] = fmt.Sprintf("%v", v) + } +} + +func (h *ConfigApiHandler) Ping(c *gin.Context) { + c.String(http.StatusOK, "200") +} diff --git a/server/internal/handler/group.go b/server/internal/handler/group.go new file mode 100644 index 0000000..592dbe9 --- /dev/null +++ b/server/internal/handler/group.go @@ -0,0 +1,137 @@ +package handler + +import ( + "kenaito-conf/server/internal/dao" + "kenaito-conf/server/internal/middleware" + "kenaito-conf/server/internal/model" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" +) + +type GroupHandler struct{} + +func NewGroupHandler() *GroupHandler { + return &GroupHandler{} +} + +func (h *GroupHandler) List(c *gin.Context) { + var groups []model.BusinessGroup + dao.Engine.Find(&groups) + c.JSON(http.StatusOK, model.WrapperResponse{Code: 0, Data: groups}) +} + +func (h *GroupHandler) GetById(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + var group model.BusinessGroup + has, _ := dao.Engine.ID(id).Get(&group) + if !has { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 404, Msg: "业务组不存在"}) + return + } + c.JSON(http.StatusOK, model.WrapperResponse{Code: 0, Data: group}) +} + +type AddGroupRequest struct { + Id int `json:"id"` + Name string `json:"name" binding:"required"` + MasterUid *int `json:"masterUid" binding:"required"` + Remarks string `json:"remarks"` +} + +func (h *GroupHandler) Add(c *gin.Context) { + var req AddGroupRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.WrapperResponse{Code: 400, Msg: "参数错误"}) + return + } + + userInfo := middleware.GetUserInfo(c) + if !userInfo.IsSuperAdmin() { + c.JSON(http.StatusForbidden, model.WrapperResponse{Code: 403, Msg: "超级管理员才能操作"}) + return + } + + var existing model.BusinessGroup + if has, _ := dao.Engine.Where("name = ?", req.Name).Get(&existing); has { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 1002, Msg: "组名已存在"}) + return + } + + var master model.User + if has, _ := dao.Engine.ID(*req.MasterUid).Get(&master); !has { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 400, Msg: "管理员不存在"}) + return + } + + group := model.BusinessGroup{ + Name: req.Name, + Master: master.Name, + MasterUid: req.MasterUid, + Remarks: req.Remarks, + Enabled: true, + CreatedAt: func() *time.Time { t := time.Now(); return &t }(), + CreatedBy: userInfo.Name, + } + + dao.Engine.Insert(&group) + + c.JSON(http.StatusOK, model.WrapperResponse{Code: 0}) +} + +func (h *GroupHandler) Update(c *gin.Context) { + var req AddGroupRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.WrapperResponse{Code: 400, Msg: "参数错误"}) + return + } + + userInfo := middleware.GetUserInfo(c) + if !userInfo.IsSuperAdmin() { + c.JSON(http.StatusForbidden, model.WrapperResponse{Code: 403, Msg: "超级管理员才能操作"}) + return + } + + if req.Id == 0 { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 400, Msg: "id参数缺失"}) + return + } + + var group model.BusinessGroup + has, _ := dao.Engine.ID(req.Id).Get(&group) + if !has { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 404, Msg: "业务组不存在"}) + return + } + + group.Name = req.Name + group.Remarks = req.Remarks + if req.MasterUid != nil && (group.MasterUid == nil || *req.MasterUid != *group.MasterUid) { + var master model.User + if has, _ := dao.Engine.ID(*req.MasterUid).Get(&master); has { + group.Master = master.Name + group.MasterUid = req.MasterUid + } + } + group.UpdatedBy = userInfo.Name + now := time.Now() + group.UpdatedAt = &now + + dao.Engine.ID(group.Id).Cols("name", "remarks", "master", "master_uid", "updated_by", "updated_at").Update(&group) + + c.JSON(http.StatusOK, model.WrapperResponse{Code: 0}) +} + +func (h *GroupHandler) Options(c *gin.Context) { + var groups []model.BusinessGroup + dao.Engine.Where("enabled = 1").Find(&groups) + + options := make([]model.SelectOption, 0) + for _, g := range groups { + options = append(options, model.SelectOption{Value: strconv.Itoa(g.Id), Label: g.Name}) + } + + c.JSON(http.StatusOK, model.WrapperResponse{Code: 0, Data: options}) +} diff --git a/server/internal/handler/index.go b/server/internal/handler/index.go new file mode 100644 index 0000000..f0eb7bd --- /dev/null +++ b/server/internal/handler/index.go @@ -0,0 +1,45 @@ +package handler + +import ( + "kenaito-conf/server/internal/middleware" + "kenaito-conf/server/internal/model" + "net/http" + + "github.com/gin-gonic/gin" +) + +type IndexHandler struct{} + +func NewIndexHandler() *IndexHandler { + return &IndexHandler{} +} + +func (h *IndexHandler) GetMenus(c *gin.Context) { + userInfo := middleware.GetUserInfo(c) + if userInfo == nil { + c.JSON(http.StatusUnauthorized, model.WrapperResponse{Code: 401, Msg: "未登录"}) + return + } + + menus := make([]model.Menu, 0) + + // 全局管理菜单 + menu1 := model.NewMenu("全局管理", "fa-cubes", false) + if userInfo.IsSuperAdmin() { + menu1.AddSubMenu("环境管理", "", "/profile/list") + } + menu1.AddSubMenu("业务组管理", "", "/group/list") + if userInfo.IsSuperAdmin() || userInfo.IsGroupAdmin() { + menu1.AddSubMenu("用户管理", "", "/user/list") + } + menu1.AddSubMenu("应用管理", "", "/app/list") + menus = append(menus, menu1) + + // 配置管理菜单 + menu2 := model.NewMenu("配置管理", "fa-cubes", true) + menu2.AddSubMenu("配置列表", "", "/config/list") + menu2.AddSubMenu("新建配置", "", "/config/add") + menus = append(menus, menu2) + + c.JSON(http.StatusOK, model.WrapperResponse{Code: 0, Data: menus}) +} diff --git a/server/internal/handler/profile.go b/server/internal/handler/profile.go new file mode 100644 index 0000000..3144642 --- /dev/null +++ b/server/internal/handler/profile.go @@ -0,0 +1,168 @@ +package handler + +import ( + "kenaito-conf/server/internal/dao" + "kenaito-conf/server/internal/middleware" + "kenaito-conf/server/internal/model" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" +) + +type ProfileHandler struct{} + +func NewProfileHandler() *ProfileHandler { + return &ProfileHandler{} +} + +func (h *ProfileHandler) List(c *gin.Context) { + var profiles []model.Profile + dao.Engine.Find(&profiles) + c.JSON(http.StatusOK, model.WrapperResponse{Code: 0, Data: profiles}) +} + +func (h *ProfileHandler) GetById(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + var profile model.Profile + has, _ := dao.Engine.ID(id).Get(&profile) + if !has { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 404, Msg: "环境不存在"}) + return + } + + c.JSON(http.StatusOK, model.WrapperResponse{Code: 0, Data: profile}) +} + +type AddProfileRequest struct { + Name string `json:"name" binding:"required"` + Alias string `json:"alias"` + IsDefault bool `json:"isDefault"` +} + +func (h *ProfileHandler) Add(c *gin.Context) { + var req AddProfileRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.WrapperResponse{Code: 400, Msg: "参数错误"}) + return + } + + userInfo := middleware.GetUserInfo(c) + if !userInfo.IsSuperAdmin() { + c.JSON(http.StatusForbidden, model.WrapperResponse{Code: 403, Msg: "超级管理员才能操作"}) + return + } + + var existing model.Profile + if has, _ := dao.Engine.Where("name = ?", req.Name).Get(&existing); has { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 1002, Msg: "环境已存在"}) + return + } + + profile := model.Profile{ + Name: req.Name, + Alias: req.Alias, + IsDefault: req.IsDefault, + Enabled: true, + } + + dao.Engine.Insert(&profile) + + c.JSON(http.StatusOK, model.WrapperResponse{Code: 0, Data: true}) +} + +type UpdateProfileRequest struct { + Id int `json:"id" binding:"required"` + Name string `json:"name" binding:"required"` + Alias string `json:"alias"` + IsDefault bool `json:"isDefault"` +} + +func (h *ProfileHandler) Update(c *gin.Context) { + var req UpdateProfileRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.WrapperResponse{Code: 400, Msg: "参数错误"}) + return + } + + userInfo := middleware.GetUserInfo(c) + if !userInfo.IsSuperAdmin() { + c.JSON(http.StatusForbidden, model.WrapperResponse{Code: 403, Msg: "超级管理员才能操作"}) + return + } + + var profile model.Profile + has, _ := dao.Engine.ID(req.Id).Get(&profile) + if !has { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 404, Msg: "环境不存在"}) + return + } + + if profile.Name != req.Name { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 400, Msg: "环境标识不可修改"}) + return + } + + profile.Alias = req.Alias + dao.Engine.ID(profile.Id).Update(&profile) + + c.JSON(http.StatusOK, model.WrapperResponse{Code: 0, Data: true}) +} + +func (h *ProfileHandler) Delete(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + userInfo := middleware.GetUserInfo(c) + if !userInfo.IsSuperAdmin() { + c.JSON(http.StatusForbidden, model.WrapperResponse{Code: 403, Msg: "超级管理员才能操作"}) + return + } + + var profile model.Profile + has, _ := dao.Engine.ID(id).Get(&profile) + if !has { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 404, Msg: "环境不存在"}) + return + } + + if profile.IsDefault { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 1003, Msg: "默认环境不能删除"}) + return + } + + dao.Engine.ID(id).Delete(&model.Profile{}) + + c.JSON(http.StatusOK, model.WrapperResponse{Code: 0}) +} + +func (h *ProfileHandler) Switch(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + userInfo := middleware.GetUserInfo(c) + if !userInfo.IsSuperAdmin() { + c.JSON(http.StatusForbidden, model.WrapperResponse{Code: 403, Msg: "超级管理员才能操作"}) + return + } + + var profile model.Profile + has, _ := dao.Engine.ID(id).Get(&profile) + if !has { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 404, Msg: "环境不存在"}) + return + } + + profile.Enabled = !profile.Enabled + dao.Engine.ID(profile.Id).Cols("enabled").Update(&profile) + + c.JSON(http.StatusOK, model.WrapperResponse{Code: 0, Data: true}) +} + +func (h *ProfileHandler) Options(c *gin.Context) { + var profiles []model.Profile + dao.Engine.Where("enabled = 1").Find(&profiles) + + options := make([]model.SelectOption, 0) + for _, p := range profiles { + options = append(options, model.SelectOption{Value: p.Name, Label: p.Alias + "(" + p.Name + ")"}) + } + + c.JSON(http.StatusOK, model.WrapperResponse{Code: 0, Data: options}) +} diff --git a/server/internal/handler/user.go b/server/internal/handler/user.go new file mode 100644 index 0000000..052cd51 --- /dev/null +++ b/server/internal/handler/user.go @@ -0,0 +1,348 @@ +package handler + +import ( + "kenaito-conf/server/internal/dao" + "kenaito-conf/server/internal/middleware" + "kenaito-conf/server/internal/model" + "kenaito-conf/server/internal/service" + "kenaito-conf/server/internal/util" + "math/rand" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" +) + +type UserHandler struct { + permService *service.PermissionService +} + +func NewUserHandler(perm *service.PermissionService) *UserHandler { + return &UserHandler{permService: perm} +} + +func (h *UserHandler) List(c *gin.Context) { + groupIdStr := c.Query("groupId") + userInfo := middleware.GetUserInfo(c) + + var users []model.User + query := dao.Engine.Where("1=1") + + if !userInfo.IsSuperAdmin() && userInfo.GroupId != nil { + query = query.And("group_id = ?", *userInfo.GroupId) + } + if groupIdStr != "" { + groupId, _ := strconv.Atoi(groupIdStr) + query = query.And("group_id = ?", groupId) + } + + query.Find(&users) + + // load group names + var groups []model.BusinessGroup + dao.Engine.Find(&groups) + groupMap := make(map[int]string) + for _, g := range groups { + groupMap[g.Id] = g.Name + } + + for i := range users { + if users[i].GroupId != nil { + users[i].GroupName = groupMap[*users[i].GroupId] + } + } + + c.JSON(http.StatusOK, model.WrapperResponse{Code: 0, Data: users}) +} + +type AddUserRequest struct { + Name string `json:"name" binding:"required"` + Mobile string `json:"mobile" binding:"required"` + Email string `json:"email"` + Type string `json:"type" binding:"required"` + GroupId *int `json:"groupId"` + GroupName string `json:"groupName"` +} + +func (h *UserHandler) Add(c *gin.Context) { + var req AddUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.WrapperResponse{Code: 400, Msg: "参数错误"}) + return + } + + userInfo := middleware.GetUserInfo(c) + + // validate type - only superAdmin can create superAdmin + if req.Type != "user" && req.Type != "groupAdmin" && req.Type != "superAdmin" { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 400, Msg: "无效的用户类型"}) + return + } + if req.Type == "superAdmin" && !userInfo.IsSuperAdmin() { + c.JSON(http.StatusForbidden, model.WrapperResponse{Code: 403, Msg: "只有超级管理员才能创建超级管理员"}) + return + } + + // check name exists + var existing model.User + if has, _ := dao.Engine.Where("name = ?", req.Name).Get(&existing); has { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 1001, Msg: "用户名已存在"}) + return + } + if has, _ := dao.Engine.Where("mobile = ?", req.Mobile).Get(&existing); has { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 1001, Msg: "手机号已存在"}) + return + } + + groupId := req.GroupId + if groupId == nil && req.GroupName != "" { + var group model.BusinessGroup + if has, _ := dao.Engine.Where("name = ?", req.GroupName).Get(&group); has { + groupId = &group.Id + } else { + group = model.BusinessGroup{ + Name: req.GroupName, + Enabled: true, + CreatedAt: func() *time.Time { t := time.Now(); return &t }(), + CreatedBy: userInfo.Name, + } + dao.Engine.Insert(&group) + groupId = &group.Id + } + } + + password := "admin123" + hashedPassword, _ := util.HashPassword(password) + + user := model.User{ + Name: req.Name, + Mobile: req.Mobile, + Email: req.Email, + Type: req.Type, + GroupId: groupId, + Password: hashedPassword, + Enabled: true, + CreatedAt: func() *time.Time { t := time.Now(); return &t }(), + } + + dao.Engine.Insert(&user) + + c.JSON(http.StatusOK, model.WrapperResponse{Code: 0, Data: user}) +} + +type UpdateUserRequest struct { + Email string `json:"email"` + Mobile string `json:"mobile"` + Password string `json:"password"` + OldPassword string `json:"oldPassword"` + GroupId *int `json:"groupId"` +} + +func (h *UserHandler) Update(c *gin.Context) { + var req UpdateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.WrapperResponse{Code: 400, Msg: "参数错误"}) + return + } + + userInfo := middleware.GetUserInfo(c) + userId, _ := strconv.Atoi(userInfo.Id) + + var user model.User + has, _ := dao.Engine.ID(userId).Get(&user) + if !has { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 404, Msg: "用户不存在"}) + return + } + + if req.Email != "" { + user.Email = req.Email + } + if req.Mobile != "" { + user.Mobile = req.Mobile + } + if req.Password != "" { + if err := util.VerifyPassword(user.Password, req.OldPassword); err != nil { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 1001, Msg: "原密码不正确"}) + return + } + hashedPassword, _ := util.HashPassword(req.Password) + user.Password = hashedPassword + } + if req.GroupId != nil && *req.GroupId > 0 { + user.GroupId = req.GroupId + } + user.UpdatedAt = func() *time.Time { t := time.Now(); return &t }() + + dao.Engine.ID(user.Id).Cols("email", "mobile", "password", "group_id", "updated_at").Update(&user) + + c.JSON(http.StatusOK, model.WrapperResponse{Code: 0, Data: true}) +} + +type AdminUpdateUserRequest struct { + Id int `json:"id" binding:"required"` + Email string `json:"email"` + Mobile string `json:"mobile"` + Type string `json:"type"` + GroupId *int `json:"groupId"` + GroupName string `json:"groupName"` +} + +func (h *UserHandler) AdminUpdate(c *gin.Context) { + var req AdminUpdateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.WrapperResponse{Code: 400, Msg: "参数错误"}) + return + } + + userInfo := middleware.GetUserInfo(c) + if !userInfo.IsSuperAdmin() { + c.JSON(http.StatusForbidden, model.WrapperResponse{Code: 403, Msg: "只有超级管理员才能编辑用户"}) + return + } + + var user model.User + has, _ := dao.Engine.ID(req.Id).Get(&user) + if !has { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 404, Msg: "用户不存在"}) + return + } + + // superAdmin 不能修改类型和所属组 + if user.Type == "superAdmin" { + if req.Type != "" && req.Type != "superAdmin" { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 400, Msg: "不能修改超级管理员的类型"}) + return + } + if req.GroupId != nil || req.GroupName != "" { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 400, Msg: "不能修改超级管理员的所属组"}) + return + } + } + + if req.Mobile != "" { + user.Mobile = req.Mobile + } + if req.Email != "" { + user.Email = req.Email + } + if req.Type != "" && user.Type != "superAdmin" { + user.Type = req.Type + } + + // 处理所属组 + if user.Type != "superAdmin" { + if req.GroupId != nil && *req.GroupId > 0 { + user.GroupId = req.GroupId + } else if req.GroupName != "" { + var group model.BusinessGroup + has, _ := dao.Engine.Where("name = ?", req.GroupName).Get(&group) + if !has { + newGroup := model.BusinessGroup{ + Name: req.GroupName, + Enabled: true, + CreatedAt: func() *time.Time { t := time.Now(); return &t }(), + CreatedBy: userInfo.Name, + } + dao.Engine.Insert(&newGroup) + user.GroupId = &newGroup.Id + } else { + user.GroupId = &group.Id + } + } + } + + user.UpdatedAt = func() *time.Time { t := time.Now(); return &t }() + dao.Engine.ID(user.Id).Cols("mobile", "email", "type", "group_id", "updated_at").Update(&user) + + c.JSON(http.StatusOK, model.WrapperResponse{Code: 0, Data: true}) +} + +func (h *UserHandler) Delete(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + userInfo := middleware.GetUserInfo(c) + + var user model.User + has, _ := dao.Engine.ID(id).Get(&user) + if !has { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 404, Msg: "用户不存在"}) + return + } + + if err := h.permService.HasPermissionForUser(userInfo, &user); err != nil { + c.JSON(http.StatusForbidden, model.WrapperResponse{Code: 403, Msg: err.Error()}) + return + } + + dao.Engine.ID(id).Delete(&model.User{}) + + c.JSON(http.StatusOK, model.WrapperResponse{Code: 0}) +} + +func (h *UserHandler) ResetPassword(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + var user model.User + has, _ := dao.Engine.ID(id).Get(&user) + if !has { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 404, Msg: "用户不存在"}) + return + } + + newPassword := randomString(8) + hashedPassword, _ := util.HashPassword(newPassword) + user.Password = hashedPassword + now := time.Now() + user.UpdatedAt = &now + dao.Engine.ID(user.Id).Cols("password", "updated_at").Update(&user) + + c.JSON(http.StatusOK, model.WrapperResponse{Code: 0, Data: newPassword}) +} + +func (h *UserHandler) Switch(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + var user model.User + has, _ := dao.Engine.ID(id).Get(&user) + if !has { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 404, Msg: "用户不存在"}) + return + } + + // 用户名为 admin 的用户不能被禁用 + if user.Name == "admin" && user.Enabled { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 400, Msg: "admin 用户不能被禁用"}) + return + } + + user.Enabled = !user.Enabled + dao.Engine.ID(user.Id).Cols("enabled").Update(&user) + + c.JSON(http.StatusOK, model.WrapperResponse{Code: 0, Data: true}) +} + +func (h *UserHandler) Options(c *gin.Context) { + groupIdStr := c.Query("groupId") + var users []model.User + query := dao.Engine.Where("enabled = 1") + if groupIdStr != "" { + groupId, _ := strconv.Atoi(groupIdStr) + query = query.And("group_id = ?", groupId) + } + query.Find(&users) + + options := make([]model.SelectOption, 0) + for _, u := range users { + options = append(options, model.SelectOption{Value: strconv.Itoa(u.Id), Label: u.Name}) + } + + c.JSON(http.StatusOK, model.WrapperResponse{Code: 0, Data: options}) +} + +func randomString(n int) string { + const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + b := make([]byte, n) + for i := range b { + b[i] = letters[rand.Intn(len(letters))] + } + return string(b) +} diff --git a/server/internal/middleware/cors.go b/server/internal/middleware/cors.go new file mode 100644 index 0000000..63f3df5 --- /dev/null +++ b/server/internal/middleware/cors.go @@ -0,0 +1,19 @@ +package middleware + +import ( + "time" + + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" +) + +func CORS() gin.HandlerFunc { + return cors.New(cors.Config{ + AllowOrigins: []string{"*"}, + AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowHeaders: []string{"Origin", "Content-Type", "Authorization"}, + ExposeHeaders: []string{"Content-Length"}, + AllowCredentials: false, + MaxAge: 12 * time.Hour, + }) +} diff --git a/server/internal/middleware/jwt_auth.go b/server/internal/middleware/jwt_auth.go new file mode 100644 index 0000000..c9e72dd --- /dev/null +++ b/server/internal/middleware/jwt_auth.go @@ -0,0 +1,40 @@ +package middleware + +import ( + "kenaito-conf/server/internal/model" + "kenaito-conf/server/internal/service" + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +func JWTAuth(authService *service.AuthService) gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "msg": "未登录"}) + c.Abort() + return + } + + tokenString := strings.TrimPrefix(authHeader, "Bearer ") + userInfo, err := authService.ValidateToken(tokenString) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "msg": "登录已过期"}) + c.Abort() + return + } + + c.Set("userInfo", userInfo) + c.Next() + } +} + +func GetUserInfo(c *gin.Context) *model.LoginUserInfo { + val, exists := c.Get("userInfo") + if !exists { + return nil + } + return val.(*model.LoginUserInfo) +} diff --git a/server/internal/model/app_config.go b/server/internal/model/app_config.go new file mode 100644 index 0000000..b0317bd --- /dev/null +++ b/server/internal/model/app_config.go @@ -0,0 +1,38 @@ +package model + +import "time" + +type AppConfig struct { + Id int `xorm:"int autoincr pk" json:"id"` + GroupId *int `xorm:"int" json:"groupId"` + AppId *int `xorm:"int" json:"appId"` + AppCode string `xorm:"varchar(32)" json:"appCode"` + Env string `xorm:"varchar(16)" json:"env"` + Version string `xorm:"varchar(32)" json:"version"` + Name string `xorm:"varchar(32)" json:"name"` + Type int `xorm:"smallint(1)" json:"type"` // 1:file, 2:kv, 3:json + Contents string `xorm:"text" json:"contents"` + Enabled bool `xorm:"tinyint(1) default 1" json:"enabled"` + CreatedAt *time.Time `xorm:"datetime" json:"createdAt"` + CreatedBy string `xorm:"varchar(32)" json:"createdBy"` + UpdatedAt *time.Time `xorm:"datetime" json:"updatedAt"` + UpdatedBy string `xorm:"varchar(32)" json:"updatedBy"` + Global bool `xorm:"-" json:"global"` + AppNames string `xorm:"-" json:"appNames,omitempty"` +} + +type AppConfigHistory struct { + Id int `xorm:"int autoincr pk" json:"id"` + OriginId int `xorm:"int" json:"originId"` + AppId *int `xorm:"int" json:"appId"` + AppName string `xorm:"varchar(32)" json:"appName"` + AppNames string `xorm:"varchar(64)" json:"appNames"` + Env string `xorm:"varchar(16)" json:"env"` + Version string `xorm:"varchar(32)" json:"version"` + Name string `xorm:"varchar(32)" json:"name"` + Type int `xorm:"smallint(1)" json:"type"` + Contents string `xorm:"text" json:"contents"` + ActiveContents string `xorm:"-" json:"activeContents,omitempty"` + CreatedAt *time.Time `xorm:"datetime" json:"createdAt"` + CreatedBy string `xorm:"varchar(32)" json:"createdBy"` +} diff --git a/server/internal/model/application.go b/server/internal/model/application.go new file mode 100644 index 0000000..ff55557 --- /dev/null +++ b/server/internal/model/application.go @@ -0,0 +1,24 @@ +package model + +import "time" + +type Application struct { + Id int `xorm:"int autoincr pk" json:"id"` + GroupId *int `xorm:"int" json:"groupId"` + ParentId *int `xorm:"int" json:"parentId"` + Name string `xorm:"varchar(32)" json:"name"` + Code string `xorm:"varchar(32)" json:"code"` + ServiceId string `xorm:"varchar(64)" json:"serviceId"` + IsModule bool `xorm:"tinyint(1) default 1" json:"isModule"` + OwnerId *int `xorm:"int" json:"ownerId"` + OwnerName string `xorm:"varchar(16)" json:"ownerName"` + Remarks string `xorm:"text" json:"remarks"` + Enabled bool `xorm:"tinyint(1) default 1" json:"enabled"` + CreatedAt *time.Time `xorm:"datetime" json:"createdAt"` + CreatedBy string `xorm:"varchar(32)" json:"createdBy"` + UpdatedAt *time.Time `xorm:"datetime" json:"updatedAt"` + UpdatedBy string `xorm:"varchar(32)" json:"updatedBy"` + ParentName string `xorm:"-" json:"parentName,omitempty"` + FullName string `xorm:"-" json:"fullName,omitempty"` + GroupName string `xorm:"-" json:"groupName,omitempty"` +} diff --git a/server/internal/model/business_group.go b/server/internal/model/business_group.go new file mode 100644 index 0000000..bf64195 --- /dev/null +++ b/server/internal/model/business_group.go @@ -0,0 +1,16 @@ +package model + +import "time" + +type BusinessGroup struct { + Id int `xorm:"int autoincr pk" json:"id"` + Name string `xorm:"varchar(32) notnull" json:"name"` + Master string `xorm:"varchar(16)" json:"master"` + MasterUid *int `xorm:"int" json:"masterUid"` + Remarks string `xorm:"text" json:"remarks"` + Enabled bool `xorm:"tinyint(1) default 1" json:"enabled"` + CreatedAt *time.Time `xorm:"datetime" json:"createdAt"` + CreatedBy string `xorm:"varchar(32)" json:"createdBy"` + UpdatedAt *time.Time `xorm:"datetime" json:"updatedAt"` + UpdatedBy string `xorm:"varchar(32)" json:"updatedBy"` +} diff --git a/server/internal/model/profile.go b/server/internal/model/profile.go new file mode 100644 index 0000000..1006004 --- /dev/null +++ b/server/internal/model/profile.go @@ -0,0 +1,16 @@ +package model + +type Profile struct { + Id int `xorm:"int autoincr pk" json:"id"` + Name string `xorm:"varchar(32)" json:"name"` + Alias string `xorm:"varchar(32)" json:"alias"` + IsDefault bool `xorm:"tinyint(1) default 0" json:"isDefault"` + Enabled bool `xorm:"tinyint(1) default 1" json:"enabled"` +} + +type AppExtrAttr struct { + AppId int `xorm:"int pk" json:"appId"` + Env string `xorm:"varchar(16) pk" json:"env"` + AttrName string `xorm:"varchar(32) pk" json:"attrName"` + AttrValue string `xorm:"varchar(100)" json:"attrValue"` +} diff --git a/server/internal/model/response.go b/server/internal/model/response.go new file mode 100644 index 0000000..e2b1311 --- /dev/null +++ b/server/internal/model/response.go @@ -0,0 +1,40 @@ +package model + +type WrapperResponse struct { + Code int `json:"code"` + Msg string `json:"msg,omitempty"` + Data interface{} `json:"data,omitempty"` +} + +type PageResult struct { + PageNo int `json:"pageNo"` + PageSize int `json:"pageSize"` + Total int64 `json:"total"` + Data interface{} `json:"data"` +} + +type SelectOption struct { + Value string `json:"value"` + Label string `json:"label"` +} + +type Menu struct { + Title string `json:"title"` + Icon string `json:"icon"` + Active bool `json:"active"` + Children []SubMenu `json:"children"` +} + +type SubMenu struct { + Title string `json:"title"` + Icon string `json:"icon"` + Url string `json:"url"` +} + +func NewMenu(title, icon string, active bool) Menu { + return Menu{Title: title, Icon: icon, Active: active, Children: make([]SubMenu, 0)} +} + +func (m *Menu) AddSubMenu(title, icon, url string) { + m.Children = append(m.Children, SubMenu{Title: title, Icon: icon, Url: url}) +} diff --git a/server/internal/model/user.go b/server/internal/model/user.go new file mode 100644 index 0000000..79879e4 --- /dev/null +++ b/server/internal/model/user.go @@ -0,0 +1,44 @@ +package model + +import "time" + +type User struct { + Id int `xorm:"int autoincr pk" json:"id"` + GroupId *int `xorm:"int" json:"groupId"` + Name string `xorm:"varchar(16)" json:"name"` + Password string `xorm:"varchar(64)" json:"-"` + Mobile string `xorm:"char(11)" json:"mobile"` + Email string `xorm:"varchar(32)" json:"email"` + Type string `xorm:"varchar(16) default 'user'" json:"type"` // user, groupAdmin, superAdmin + Enabled bool `xorm:"tinyint(1) default 1" json:"enabled"` + CreatedAt *time.Time `xorm:"datetime" json:"createdAt"` + UpdatedAt *time.Time `xorm:"datetime" json:"updatedAt"` + GroupName string `xorm:"-" json:"groupName,omitempty"` + GroupMaster bool `xorm:"-" json:"groupMaster,omitempty"` +} + +type UserPermission struct { + Id int `xorm:"int autoincr pk" json:"id"` + UserId int `xorm:"int" json:"userId"` + Env string `xorm:"varchar(16)" json:"env"` + AppId int `xorm:"int" json:"appId"` + Operate string `xorm:"varchar(8) default 'RW'" json:"operate"` // RO, RW +} + +type LoginUserInfo struct { + Id string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + GroupId *int `json:"groupId"` + GrantAppIds []int `json:"grantAppIds"` + GrantPermissons []string `json:"grantPermissons"` + GroupMaster bool `json:"groupMaster"` +} + +func (u *LoginUserInfo) IsSuperAdmin() bool { + return u.Type == "superAdmin" +} + +func (u *LoginUserInfo) IsGroupAdmin() bool { + return u.Type == "groupAdmin" +} diff --git a/server/internal/service/auth.go b/server/internal/service/auth.go new file mode 100644 index 0000000..accb446 --- /dev/null +++ b/server/internal/service/auth.go @@ -0,0 +1,145 @@ +package service + +import ( + "errors" + "kenaito-conf/server/internal/dao" + "kenaito-conf/server/internal/model" + "strconv" + "time" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" +) + +type AuthService struct { + jwtSecret string + expireHrs int +} + +func NewAuthService(secret string, expireHours int) *AuthService { + return &AuthService{jwtSecret: secret, expireHrs: expireHours} +} + +func (s *AuthService) Login(userName, password string) (string, *model.LoginUserInfo, error) { + var user model.User + has, err := dao.Engine.Where("name = ? AND enabled = 1", userName).Get(&user) + if err != nil { + return "", nil, err + } + if !has { + return "", nil, errors.New("用户不存在或已禁用") + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil { + return "", nil, errors.New("密码错误") + } + + userInfo := &model.LoginUserInfo{ + Id: strconv.Itoa(user.Id), + Name: user.Name, + Type: user.Type, + GroupId: user.GroupId, + GrantAppIds: make([]int, 0), + GrantPermissons: make([]string, 0), + } + + // load permissions + s.loadPermissions(userInfo, user.Id) + + // generate JWT + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "userId": user.Id, + "name": user.Name, + "type": user.Type, + "groupId": user.GroupId, + "exp": time.Now().Add(time.Duration(s.expireHrs) * time.Hour).Unix(), + }) + tokenString, err := token.SignedString([]byte(s.jwtSecret)) + if err != nil { + return "", nil, err + } + + return tokenString, userInfo, nil +} + +func (s *AuthService) ValidateToken(tokenString string) (*model.LoginUserInfo, error) { + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + return []byte(s.jwtSecret), nil + }) + if err != nil { + return nil, err + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok || !token.Valid { + return nil, errors.New("invalid token") + } + + userId := int(claims["userId"].(float64)) + var user model.User + has, err := dao.Engine.ID(userId).Get(&user) + if err != nil || !has { + return nil, errors.New("user not found") + } + + userInfo := &model.LoginUserInfo{ + Id: strconv.Itoa(user.Id), + Name: user.Name, + Type: user.Type, + GroupId: user.GroupId, + GrantAppIds: make([]int, 0), + GrantPermissons: make([]string, 0), + } + s.loadPermissions(userInfo, user.Id) + return userInfo, nil +} + +func (s *AuthService) loadPermissions(userInfo *model.LoginUserInfo, userId int) { + if userInfo.IsSuperAdmin() { + return + } + + // load owned apps + var apps []model.Application + if userInfo.IsGroupAdmin() { + dao.Engine.Where("group_id = ?", userInfo.GroupId).Find(&apps) + } else { + dao.Engine.Where("owner_id = ?", userId).Find(&apps) + } + for _, app := range apps { + userInfo.GrantAppIds = append(userInfo.GrantAppIds, app.Id) + userInfo.GrantPermissons = append(userInfo.GrantPermissons, buildPermissionCode(app.Id, "RW")) + } + + // load granted permissions + var perms []model.UserPermission + dao.Engine.Where("user_id = ?", userId).Find(&perms) + for _, p := range perms { + permCode := buildPermissionCode(p.AppId, p.Operate) + // avoid duplicate + found := false + for _, existing := range userInfo.GrantPermissons { + if existing == permCode { + found = true + break + } + } + if !found { + userInfo.GrantPermissons = append(userInfo.GrantPermissons, permCode) + } + appIdFound := false + for _, id := range userInfo.GrantAppIds { + if id == p.AppId { + appIdFound = true + break + } + } + if !appIdFound { + userInfo.GrantAppIds = append(userInfo.GrantAppIds, p.AppId) + } + } +} + +func buildPermissionCode(appId int, operate string) string { + return strconv.Itoa(appId) + ":" + operate +} diff --git a/server/internal/service/config_parse.go b/server/internal/service/config_parse.go new file mode 100644 index 0000000..f3b9249 --- /dev/null +++ b/server/internal/service/config_parse.go @@ -0,0 +1,169 @@ +package service + +import ( + "encoding/json" + "fmt" + "kenaito-conf/server/internal/model" + "strings" + + "gopkg.in/yaml.v3" +) + +type ConfigParseService struct{} + +func NewConfigParseService() *ConfigParseService { + return &ConfigParseService{} +} + +// ParseConfigToKVMap parses config content based on type +func (s *ConfigParseService) ParseConfigToKVMap(config *model.AppConfig) map[string]interface{} { + result := make(map[string]interface{}) + + switch config.Type { + case 1: // file + if strings.HasSuffix(strings.ToLower(config.Name), ".xml") { + s.parseXML(result, config.Contents) + } else if strings.HasSuffix(strings.ToLower(config.Name), ".yml") || + strings.HasSuffix(strings.ToLower(config.Name), ".yaml") { + s.parseYAML(result, config.Contents) + } else { + s.parseProperties(result, config.Contents) + } + case 2: // key-value + result[config.Name] = config.Contents + case 3: // JSON + json.Unmarshal([]byte(config.Contents), &result) + } + + // resolve placeholders + s.resolvePlaceholders(result) + return result +} + +func (s *ConfigParseService) parseProperties(result map[string]interface{}, content string) { + lines := strings.Split(content, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + idx := strings.Index(line, "=") + if idx < 0 { + continue + } + key := strings.TrimSpace(line[:idx]) + value := strings.TrimSpace(line[idx+1:]) + if key != "" { + result[key] = value + } + } +} + +func (s *ConfigParseService) parseXML(result map[string]interface{}, content string) { + // simple XML parsing for value format + // This is a simplified version - for production use a proper XML parser + lines := strings.Split(content, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if !strings.Contains(line, "") + if valStart < 0 { + continue + } + valEnd := strings.Index(line, "") + if valEnd < 0 { + continue + } + value := strings.TrimSpace(line[valStart+1 : valEnd]) + if key != "" && value != "" { + result[key] = value + } + } +} + +func (s *ConfigParseService) parseYAML(result map[string]interface{}, content string) { + var data map[string]interface{} + if err := yaml.Unmarshal([]byte(content), &data); err != nil { + return + } + s.flattenYAML("", result, data) +} + +func (s *ConfigParseService) flattenYAML(prefix string, result map[string]interface{}, data map[string]interface{}) { + for k, v := range data { + key := k + if prefix != "" { + key = prefix + "." + k + } + if subMap, ok := v.(map[string]interface{}); ok { + s.flattenYAML(key, result, subMap) + } else { + result[key] = v + } + } +} + +func (s *ConfigParseService) resolvePlaceholders(result map[string]interface{}) { + for key, val := range result { + strVal, ok := val.(string) + if !ok { + continue + } + if strings.Contains(strVal, "${") { + result[key] = s.replacePlaceholder(result, strVal) + } + } +} + +func (s *ConfigParseService) replacePlaceholder(data map[string]interface{}, value string) string { + result := value + maxIterations := 100 + for i := 0; i < maxIterations; i++ { + start := strings.Index(result, "${") + if start < 0 { + break + } + end := strings.Index(result[start:], "}") + if end < 0 { + break + } + end += start + + placeholder := result[start+2 : end] + defaultVal := "" + refKey := placeholder + + // check for default value + if idx := strings.Index(placeholder, ":"); idx >= 0 { + refKey = placeholder[:idx] + defaultVal = placeholder[idx+1:] + } + + var replacement string + if v, ok := data[refKey]; ok { + replacement = fmt.Sprintf("%v", v) + } else if defaultVal != "" { + replacement = defaultVal + } else { + replacement = "${" + placeholder + "}" + } + + result = result[:start] + replacement + result[end+1:] + } + return result +} diff --git a/server/internal/service/crypto.go b/server/internal/service/crypto.go new file mode 100644 index 0000000..097067f --- /dev/null +++ b/server/internal/service/crypto.go @@ -0,0 +1,115 @@ +package service + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/md5" + "encoding/base64" + "encoding/hex" + "fmt" + "kenaito-conf/server/internal/config" + "strings" +) + +const CryptPrefix = "{Cipher}" + +type CryptoService struct{} + +func NewCryptoService() *CryptoService { + return &CryptoService{} +} + +func (s *CryptoService) GetCryptKey(appId int, env string) string { + base := config.Cfg.Crypto.SecretKey + fmt.Sprintf("%x", appId) + env + h := md5.Sum([]byte(base)) + return hex.EncodeToString(h[:]) +} + +func (s *CryptoService) Encrypt(appId int, env string, data string) string { + key := s.GetCryptKey(appId, env) + data = strings.ReplaceAll(data, CryptPrefix, "") + encrypted := encryptWithAES(key, data) + return CryptPrefix + encrypted +} + +func (s *CryptoService) IsEncrypted(appId int, env string, data string) bool { + if !strings.HasPrefix(data, CryptPrefix) { + return false + } + key := s.GetCryptKey(appId, env) + _, err := decryptWithAES(key, strings.TrimPrefix(data, CryptPrefix)) + return err == nil +} + +func (s *CryptoService) Decrypt(secret string, data string) (string, error) { + return decryptWithAES(secret, data) +} + +func encryptWithAES(key string, data string) string { + secretKey := md5.Sum([]byte(key)) + aesKey := hex.EncodeToString(secretKey[:])[16:] + + block, err := aes.NewCipher([]byte(aesKey)) + if err != nil { + return "" + } + + // ECB mode (compatible with Java version) + plainText := pkcs7Pad([]byte(data), aes.BlockSize) + encrypted := make([]byte, len(plainText)) + mode := cipher.NewCBCEncrypter(block, make([]byte, aes.BlockSize)) + mode.CryptBlocks(encrypted, plainText) + + return base64.StdEncoding.EncodeToString(encrypted) +} + +func decryptWithAES(key string, data string) (string, error) { + secretKey := md5.Sum([]byte(key)) + aesKey := hex.EncodeToString(secretKey[:])[16:] + + cipherText, err := base64.StdEncoding.DecodeString(data) + if err != nil { + return "", err + } + + block, err := aes.NewCipher([]byte(aesKey)) + if err != nil { + return "", err + } + + if len(cipherText)%aes.BlockSize != 0 { + return "", fmt.Errorf("ciphertext is not a multiple of block size") + } + + // ECB mode (compatible with Java version) + plainText := make([]byte, len(cipherText)) + mode := cipher.NewCBCDecrypter(block, make([]byte, aes.BlockSize)) + mode.CryptBlocks(plainText, cipherText) + + plainText, err = pkcs7Unpad(plainText, aes.BlockSize) + if err != nil { + return "", err + } + + return string(plainText), nil +} + +func pkcs7Pad(data []byte, blockSize int) []byte { + padding := blockSize - len(data)%blockSize + padText := make([]byte, padding) + for i := range padText { + padText[i] = byte(padding) + } + return append(data, padText...) +} + +func pkcs7Unpad(data []byte, blockSize int) ([]byte, error) { + if len(data) == 0 { + return nil, fmt.Errorf("empty data") + } + padding := int(data[len(data)-1]) + if padding > blockSize || padding == 0 { + return nil, fmt.Errorf("invalid padding") + } + return data[:len(data)-padding], nil +} diff --git a/server/internal/service/permission.go b/server/internal/service/permission.go new file mode 100644 index 0000000..024f6e5 --- /dev/null +++ b/server/internal/service/permission.go @@ -0,0 +1,51 @@ +package service + +import ( + "errors" + "kenaito-conf/server/internal/model" +) + +type PermissionService struct{} + +func NewPermissionService() *PermissionService { + return &PermissionService{} +} + +func (s *PermissionService) RequireSuperAdmin(userInfo *model.LoginUserInfo) error { + if !userInfo.IsSuperAdmin() { + return errors.New("超级管理员才有权限操作") + } + return nil +} + +func (s *PermissionService) RequirePermission(userInfo *model.LoginUserInfo, groupId *int, appId *int, operate string) error { + if userInfo.IsSuperAdmin() { + return nil + } + if groupId != nil && userInfo.GroupId != nil && *groupId == *userInfo.GroupId { + return nil + } + if appId == nil || *appId == 0 { + return errors.New("你没有全局配置权限") + } + permCode := buildPermissionCode(*appId, operate) + for _, p := range userInfo.GrantPermissons { + if p == permCode { + return nil + } + } + return errors.New("你没有该应用权限") +} + +func (s *PermissionService) HasPermissionForUser(userInfo *model.LoginUserInfo, targetUser *model.User) error { + if userInfo.IsSuperAdmin() { + return nil + } + if !userInfo.IsGroupAdmin() { + return errors.New("你不是业务组管理员") + } + if userInfo.GroupId == nil || targetUser.GroupId == nil || *userInfo.GroupId != *targetUser.GroupId { + return errors.New("不能操作其他组成员") + } + return nil +} diff --git a/server/internal/util/encrypt.go b/server/internal/util/encrypt.go new file mode 100644 index 0000000..1e10b4d --- /dev/null +++ b/server/internal/util/encrypt.go @@ -0,0 +1,11 @@ +package util + +import "golang.org/x/crypto/bcrypt" + +func HashPassword(password string) (string, error) { + fromPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(fromPassword), err +} +func VerifyPassword(hashedPassword, password string) error { + return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..dde0af3 --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + 配置中心 + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..51835ca --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1555 @@ +{ + "name": "kenaito-conf-web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "kenaito-conf-web", + "version": "1.0.0", + "dependencies": { + "axios": "^1.6.0", + "element-ui": "^2.15.14", + "vue": "^2.7.16", + "vue-router": "^3.6.5", + "vuex": "^3.6.2" + }, + "devDependencies": { + "@vitejs/plugin-vue2": "^2.3.1", + "vite": "^5.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.64.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.4.tgz", + "integrity": "sha512-7sdQKCYk/JCWa7rlXOgIa7AEAfZnUu/twNX4m4ECouPWsYhzxHfslKCFpOs43J8uux356TUU8NCuQus3XISCbQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.64.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.4.tgz", + "integrity": "sha512-iV5jlPMChTQgD0UCTcT9rLHN4DNce34v5Mw0IN7csTSg4RDaKq9AximXPL35k+sGXcfrR4OeQMvFNe+OmV1rhw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.64.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.4.tgz", + "integrity": "sha512-a0Sswc+5v8BVkjHGOaJCTbFArZZUJCGFi2y39FWGdiplOToMNzr1tE4v3l67DW+vGyYD2sS5PfhDdUCyrr3mjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.64.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.4.tgz", + "integrity": "sha512-qHH6RSwVPuNkqbOv0hgZKonHBW/ZIaHpd3bgH5LRPJ/8oRE+6Dl36OfS7wUQyeSeqQsAh1NrpwmUemgH1fh1WQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.64.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.4.tgz", + "integrity": "sha512-hs9CVjHH9WPGfeHZ5yvHYEiEm7hhm7M/16ytRTxrir7q+KnGaQ6EO1JYcrGooVIHYN1Z3H7sLIwxe97NNBYg6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.64.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.4.tgz", + "integrity": "sha512-4UYtjV0FBOToshUTYvwoTbfvig9Ec94EcE56WehhqaFZK43rK9sLNYO+vRMnJ9GTOxanFkKB/IvBKt96VmCYiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.64.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.4.tgz", + "integrity": "sha512-Md2BnPCGXVtoyaqPCxxahW4f8r5h8e1Iago6LFQRf+b9m8YOnkfW2L9l1KK9UsQa2gXYoVAVTi6mQgkVNSrOuQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.64.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.4.tgz", + "integrity": "sha512-oBAOMLpMKggD7Z9oTZCVUp4dDMa84Br/PV5mm6IL1kykBgk5Kp3gyu2buW8KFzvkktyFj+NdZdZCxpRqNgO71A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.64.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.4.tgz", + "integrity": "sha512-KYgCzHJjmFhXb7kPv6ih9c6HdY/4JXr6MmLtWFRmK0hAgXxLBx2tI24J1OYJyFl7DfxnzREWSLDj3FxKHfPHjQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.64.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.4.tgz", + "integrity": "sha512-xpeOpK+6OQfqZL3HQ1leCKM/JFZz0BmpVGkeydWRBdjYYgDlry7a2RWZ0rxnoJ29wMUK/xa/e1CSnnjV7UnxBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.64.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.4.tgz", + "integrity": "sha512-Iju/GGedDha6/e8lseZIxO2i3xPg+aJFw+l07QijRsEkGiUzQbsdNGaouTZwa6N1nu4q9WVJUbc/ejwPN24i+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.64.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.4.tgz", + "integrity": "sha512-v3fNKbpq8uHqlMayTSaRCX9GVa/OECoY5beetD1qa5MKeRmrlgWve98UpcxgQAqGKQpqhcflDAe1AvjIUMenWQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.64.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.4.tgz", + "integrity": "sha512-ngxCMEz/wPVGkB/Fx7yGW8LkkL1mtg/V/KOYZQytTUe/MCh/w3nsvS0CnjhdS6YEJb98StA2aUnPaQZte0uCZg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.64.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.4.tgz", + "integrity": "sha512-ob/5q9wu+GPAIJvqOzcr0tUo7vIuczYoVmxgfCjbSCE2zwbi/rxkR35b/fnQKGrI5fCua7ULhnB7w7wHseLlNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.64.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.4.tgz", + "integrity": "sha512-FsFK/3sO22/ogdbJ1TtWFyZAfru07l+4ya1OHn0L7renzB7qoQp0gaaTya7zKezubV5OoG9mSek1Z4qcT5rdew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.64.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.4.tgz", + "integrity": "sha512-id50wkt+Jt20/66TkMr04OXdUB64Sj4W3WVhMVf/KuTiy5NFQLGgmCwE0RBobDPr22zyKxEE1U6zMAeBTgnZaQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.64.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.4.tgz", + "integrity": "sha512-WinoPEOOZz+rKnluSytyemucpXtygq1LJaU+HBxclUXPz7IVBJIjcdDH9V4nN/yvYjyiVeb/fH32HSX2acLmrw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.64.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.4.tgz", + "integrity": "sha512-Fs5AqGrSuvXIPSGpZQAUHyM+7eRpKkWvIxN8eTf/8hoTCiKybb2FeqBnB5R9OyVOYI3/vP+VgSBOHT1zkYkKSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.64.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.4.tgz", + "integrity": "sha512-EdzYVEpaQG2YSRW/qi/uHcersoPi119NmUK/hgBQJdAZvk7HjlSKHsBWQvofZ8ZoL98R9SUNJUAtBwRlAsZ7WA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.64.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.4.tgz", + "integrity": "sha512-FZarHZsoTVzvMfCS0hDWcybs+VvkC5TPEYREZhuyD93GGMBBYKa413z2zUsXJEGU7hHJ7EEr4mUUMV1WfHIVmA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.64.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.4.tgz", + "integrity": "sha512-A+nCwcEGR1r2MET1kikO4O9nkq6bjpA4gGwZiyZ8CsL3FMqPvh4OREYyZHv/WJjl8eCgfUqh9dFUc01qBOUOgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.64.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.4.tgz", + "integrity": "sha512-9dS3eF2HL38ItWUF6wbStGGoLALgh3e+BbBfq5VEAm/NKhnEibz/B+D719uTET83+E1lZL2TKlv6idlzOorrgA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.64.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.4.tgz", + "integrity": "sha512-n2waAp+bsaATQlfM0dSsFWG+Juj3GBGYI5MS4prChfjn3z+PvijnWZT9FmRtcK6Db0F4rfGzMJUP5VENvb7wig==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.64.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.4.tgz", + "integrity": "sha512-kXMhVM8CVi/jRZAN8+SPVTdd1kl71WCVAajeu5JaVwsmjmAO40MiEj4QBDJeoWkLICE3b7jnRQK/mBQyhh0BCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.64.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.4.tgz", + "integrity": "sha512-OWiFOGLjX+ZPsRJjo9yQpU3rq5wBNxyPjB9Sc6PmSPVQgUKVSHQu2zR5y1YkZ+HFzy4rHfYhwnT2PQSUgatnJA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue2": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue2/-/plugin-vue2-2.3.4.tgz", + "integrity": "sha512-LgqtRRedJb1KdmgcllwGX0gtlPvOvtR6pITXmqxGwQhBZaAysg0Hd7wvj3sjCsj4+PENWsqS7O+ceYSOgJ+H9g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >= 16.0.0" + }, + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", + "vue": "^2.7.0-0" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-2.7.16.tgz", + "integrity": "sha512-KWhJ9k5nXuNtygPU7+t1rX6baZeqOYLEforUPjgNDBnLicfHCoi48H87Q8XyLZOrNNsmhuwKqtpDQWjEFe6Ekg==", + "dependencies": { + "@babel/parser": "^7.23.5", + "postcss": "^8.4.14", + "source-map": "^0.6.1" + }, + "optionalDependencies": { + "prettier": "^1.18.2 || ^2.0.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/async-validator": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-1.8.5.tgz", + "integrity": "sha512-tXBM+1m056MAX0E8TL2iCjg8WvSyXu0Zc8LNtYqrVeyoL3+esHRZ4SieE9fKQyyU09uONjnMEjrNBMqT0mbvmA==", + "dependencies": { + "babel-runtime": "6.x" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/babel-helper-vue-jsx-merge-props": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/babel-helper-vue-jsx-merge-props/-/babel-helper-vue-jsx-merge-props-2.0.3.tgz", + "integrity": "sha512-gsLiKK7Qrb7zYJNgiXKpXblxbV5ffSwR0f5whkPAaBAR4fhi6bwRZxX9wBlIc5M/v8CCkXUbXZL4N/nSE97cqg==", + "license": "MIT" + }, + "node_modules/babel-runtime": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", + "integrity": "sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==", + "license": "MIT", + "dependencies": { + "core-js": "^2.4.0", + "regenerator-runtime": "^0.11.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/core-js": { + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz", + "integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==", + "deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.", + "hasInstallScript": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-1.5.2.tgz", + "integrity": "sha512-95k0GDqvBjZavkuvzx/YqVLv/6YYa17fz6ILMSf7neqQITCPbnfEnQvEgMPNjH4kgobe7+WIL0yJEHku+H3qtQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/element-ui": { + "version": "2.15.14", + "resolved": "https://registry.npmjs.org/element-ui/-/element-ui-2.15.14.tgz", + "integrity": "sha512-2v9fHL0ZGINotOlRIAJD5YuVB8V7WKxrE9Qy7dXhRipa035+kF7WuU/z+tEmLVPBcJ0zt8mOu1DKpWcVzBK8IA==", + "license": "MIT", + "dependencies": { + "async-validator": "~1.8.1", + "babel-helper-vue-jsx-merge-props": "^2.0.0", + "deepmerge": "^1.2.0", + "normalize-wheel": "^1.0.1", + "resize-observer-polyfill": "^1.5.0", + "throttle-debounce": "^1.0.1" + }, + "peerDependencies": { + "vue": "^2.5.17" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz", + "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-wheel": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz", + "integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==", + "license": "BSD-3-Clause" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.19", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prettier": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "license": "MIT", + "optional": true, + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", + "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==", + "license": "MIT" + }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.64.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.4.tgz", + "integrity": "sha512-1DTowQbct6FOZTjZoYrZ8BwsjIwsB89HxwOn2pWPEyEKWWvKI8OrpTgmicB3fUbQzhlooCQRIn6JLUPhE0CZUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.64.4", + "@rollup/rollup-android-arm64": "4.64.4", + "@rollup/rollup-darwin-arm64": "4.64.4", + "@rollup/rollup-darwin-x64": "4.64.4", + "@rollup/rollup-freebsd-arm64": "4.64.4", + "@rollup/rollup-freebsd-x64": "4.64.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.64.4", + "@rollup/rollup-linux-arm-musleabihf": "4.64.4", + "@rollup/rollup-linux-arm64-gnu": "4.64.4", + "@rollup/rollup-linux-arm64-musl": "4.64.4", + "@rollup/rollup-linux-loong64-gnu": "4.64.4", + "@rollup/rollup-linux-loong64-musl": "4.64.4", + "@rollup/rollup-linux-ppc64-gnu": "4.64.4", + "@rollup/rollup-linux-ppc64-musl": "4.64.4", + "@rollup/rollup-linux-riscv64-gnu": "4.64.4", + "@rollup/rollup-linux-riscv64-musl": "4.64.4", + "@rollup/rollup-linux-s390x-gnu": "4.64.4", + "@rollup/rollup-linux-x64-gnu": "4.64.4", + "@rollup/rollup-linux-x64-musl": "4.64.4", + "@rollup/rollup-openbsd-x64": "4.64.4", + "@rollup/rollup-openharmony-arm64": "4.64.4", + "@rollup/rollup-win32-arm64-msvc": "4.64.4", + "@rollup/rollup-win32-ia32-msvc": "4.64.4", + "@rollup/rollup-win32-x64-gnu": "4.64.4", + "@rollup/rollup-win32-x64-msvc": "4.64.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/throttle-debounce": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-1.1.0.tgz", + "integrity": "sha512-XH8UiPCQcWNuk2LYePibW/4qL97+ZQ1AN3FNXwZRBNPPowo/NRU5fAlDCSNBJIYCKbioZfuYtMhG4quqoJhVzg==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/vue/-/vue-2.7.16.tgz", + "integrity": "sha512-4gCtFXaAA3zYZdTp5s4Hl2sozuySsgz4jy1EnpBHNfpMa9dK1ZCG7viqBPCwXtmgc8nHqUsAu3G4gtmXkkY3Sw==", + "deprecated": "Vue 2 has reached EOL and is no longer actively maintained. See https://v2.vuejs.org/eol/ for more details.", + "license": "MIT", + "dependencies": { + "@vue/compiler-sfc": "2.7.16", + "csstype": "^3.1.0" + } + }, + "node_modules/vue-router": { + "version": "3.6.5", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-3.6.5.tgz", + "integrity": "sha512-VYXZQLtjuvKxxcshuRAwjHnciqZVoXAjTjcqBTz4rKc8qih9g9pI3hbDjmqXaHdgL3v8pV6P8Z335XvHzESxLQ==", + "license": "MIT" + }, + "node_modules/vuex": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/vuex/-/vuex-3.6.2.tgz", + "integrity": "sha512-ETW44IqCgBpVomy520DT5jf8n0zoCac+sxWnn+hMe/CzaSejb/eVw2YToiXYX+Ex/AuHHia28vWTq4goAexFbw==", + "license": "MIT", + "peerDependencies": { + "vue": "^2.0.0" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..f4d3550 --- /dev/null +++ b/web/package.json @@ -0,0 +1,22 @@ +{ + "name": "kenaito-conf-web", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^2.7.16", + "vue-router": "^3.6.5", + "vuex": "^3.6.2", + "element-ui": "^2.15.14", + "axios": "^1.6.0" + }, + "devDependencies": { + "vite": "^5.0.0", + "@vitejs/plugin-vue2": "^2.3.1" + } +} diff --git a/web/src/App.vue b/web/src/App.vue new file mode 100644 index 0000000..f177f23 --- /dev/null +++ b/web/src/App.vue @@ -0,0 +1,23 @@ + + + + + diff --git a/web/src/api/app.js b/web/src/api/app.js new file mode 100644 index 0000000..b25590d --- /dev/null +++ b/web/src/api/app.js @@ -0,0 +1,53 @@ +import request from './request' + +export function getAppList(params) { + return request({ + url: '/admin/app/list', + method: 'post', + params + }) +} + +export function getAppTree() { + return request({ + url: '/admin/app/tree', + method: 'get' + }) +} + +export function getApp(id) { + return request({ + url: `/admin/app/${id}`, + method: 'get' + }) +} + +export function addApp(data) { + return request({ + url: '/admin/app/add', + method: 'post', + data + }) +} + +export function updateApp(data) { + return request({ + url: '/admin/app/update', + method: 'post', + data + }) +} + +export function deleteApp(id) { + return request({ + url: `/admin/app/delete/${id}`, + method: 'post' + }) +} + +export function getAppOptions() { + return request({ + url: '/admin/app/options', + method: 'get' + }) +} diff --git a/web/src/api/auth.js b/web/src/api/auth.js new file mode 100644 index 0000000..24ba5cf --- /dev/null +++ b/web/src/api/auth.js @@ -0,0 +1,30 @@ +import request from './request' + +export function login(data) { + return request({ + url: '/user/login', + method: 'post', + data + }) +} + +export function getUserInfo() { + return request({ + url: '/user/loginUserInfo', + method: 'get' + }) +} + +export function logout() { + return request({ + url: '/user/logout', + method: 'post' + }) +} + +export function getMenus() { + return request({ + url: '/admin/menus', + method: 'get' + }) +} diff --git a/web/src/api/config.js b/web/src/api/config.js new file mode 100644 index 0000000..7ae7a03 --- /dev/null +++ b/web/src/api/config.js @@ -0,0 +1,53 @@ +import request from './request' + +export function getConfigList(params) { + return request({ + url: '/admin/config/list', + method: 'post', + params + }) +} + +export function getConfig(id) { + return request({ + url: `/admin/config/${id}`, + method: 'get' + }) +} + +export function addConfig(data) { + return request({ + url: '/admin/config/add', + method: 'post', + data + }) +} + +export function updateConfig(data) { + return request({ + url: '/admin/config/update', + method: 'post', + data + }) +} + +export function deleteConfig(id) { + return request({ + url: `/admin/config/delete/${id}`, + method: 'post' + }) +} + +export function getConfigHistory(id) { + return request({ + url: `/admin/config/configHistories/${id}`, + method: 'get' + }) +} + +export function rollbackConfig(id) { + return request({ + url: `/admin/config/rollback/${id}`, + method: 'post' + }) +} diff --git a/web/src/api/request.js b/web/src/api/request.js new file mode 100644 index 0000000..b48e5cd --- /dev/null +++ b/web/src/api/request.js @@ -0,0 +1,45 @@ +import axios from 'axios' +import { Message } from 'element-ui' + +const request = axios.create({ + baseURL: '', + timeout: 10000 +}) + +request.interceptors.request.use( + config => { + const token = localStorage.getItem('token') + if (token) { + config.headers['Authorization'] = `Bearer ${token}` + } + return config + }, + error => { + return Promise.reject(error) + } +) + +request.interceptors.response.use( + response => { + const res = response.data + if (res.code && res.code !== 0) { + Message.error(res.msg || '请求失败') + if (res.code === 401) { + localStorage.removeItem('token') + window.location.href = '/login' + } + return Promise.reject(new Error(res.msg)) + } + return res + }, + error => { + if (error.response && error.response.status === 401) { + localStorage.removeItem('token') + window.location.href = '/login' + } + Message.error(error.message || '网络错误') + return Promise.reject(error) + } +) + +export default request diff --git a/web/src/main.js b/web/src/main.js new file mode 100644 index 0000000..f13ede8 --- /dev/null +++ b/web/src/main.js @@ -0,0 +1,14 @@ +import Vue from 'vue' +import App from './App.vue' +import router from './router' +import store from './store' +import ElementUI from 'element-ui' +import 'element-ui/lib/theme-chalk/index.css' + +Vue.use(ElementUI) + +new Vue({ + router, + store, + render: h => h(App) +}).$mount('#app') diff --git a/web/src/router/index.js b/web/src/router/index.js new file mode 100644 index 0000000..562e68c --- /dev/null +++ b/web/src/router/index.js @@ -0,0 +1,70 @@ +import Vue from 'vue' +import VueRouter from 'vue-router' +import store from '../store' + +Vue.use(VueRouter) + +const routes = [ + { + path: '/login', + name: 'Login', + component: () => import('../views/Login.vue') + }, + { + path: '/', + component: () => import('../views/Layout.vue'), + redirect: '/config/list', + children: [ + { + path: '/config/list', + name: 'ConfigList', + component: () => import('../views/config/ConfigList.vue') + }, + { + path: '/config/add', + name: 'ConfigAdd', + component: () => import('../views/config/ConfigAdd.vue') + }, + { + path: '/config/edit/:id', + name: 'ConfigEdit', + component: () => import('../views/config/ConfigEdit.vue') + }, + { + path: '/app/list', + name: 'AppList', + component: () => import('../views/app/AppList.vue') + }, + { + path: '/user/list', + name: 'UserList', + component: () => import('../views/user/UserList.vue') + }, + { + path: '/group/list', + name: 'GroupList', + component: () => import('../views/group/GroupList.vue') + }, + { + path: '/profile/list', + name: 'ProfileList', + component: () => import('../views/profile/ProfileList.vue') + } + ] + } +] + +const router = new VueRouter({ + routes +}) + +router.beforeEach((to, from, next) => { + const token = localStorage.getItem('token') + if (to.path !== '/login' && !token) { + next('/login') + } else { + next() + } +}) + +export default router diff --git a/web/src/store/index.js b/web/src/store/index.js new file mode 100644 index 0000000..7ac3540 --- /dev/null +++ b/web/src/store/index.js @@ -0,0 +1,27 @@ +import Vue from 'vue' +import Vuex from 'vuex' + +Vue.use(Vuex) + +export default new Vuex.Store({ + state: { + userInfo: null, + menus: [] + }, + mutations: { + SET_USER_INFO(state, userInfo) { + state.userInfo = userInfo + }, + SET_MENUS(state, menus) { + state.menus = menus + } + }, + actions: { + setUserInfo({ commit }, userInfo) { + commit('SET_USER_INFO', userInfo) + }, + setMenus({ commit }, menus) { + commit('SET_MENUS', menus) + } + } +}) diff --git a/web/src/views/Layout.vue b/web/src/views/Layout.vue new file mode 100644 index 0000000..67aea72 --- /dev/null +++ b/web/src/views/Layout.vue @@ -0,0 +1,99 @@ + + + + + diff --git a/web/src/views/Login.vue b/web/src/views/Login.vue new file mode 100644 index 0000000..5e6aa8d --- /dev/null +++ b/web/src/views/Login.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/web/src/views/app/AppList.vue b/web/src/views/app/AppList.vue new file mode 100644 index 0000000..3ae615e --- /dev/null +++ b/web/src/views/app/AppList.vue @@ -0,0 +1,157 @@ + + + diff --git a/web/src/views/config/ConfigAdd.vue b/web/src/views/config/ConfigAdd.vue new file mode 100644 index 0000000..199cb1e --- /dev/null +++ b/web/src/views/config/ConfigAdd.vue @@ -0,0 +1,104 @@ + + + diff --git a/web/src/views/config/ConfigEdit.vue b/web/src/views/config/ConfigEdit.vue new file mode 100644 index 0000000..9b45428 --- /dev/null +++ b/web/src/views/config/ConfigEdit.vue @@ -0,0 +1,60 @@ + + + diff --git a/web/src/views/config/ConfigList.vue b/web/src/views/config/ConfigList.vue new file mode 100644 index 0000000..7d4a0ca --- /dev/null +++ b/web/src/views/config/ConfigList.vue @@ -0,0 +1,126 @@ + + + diff --git a/web/src/views/group/GroupList.vue b/web/src/views/group/GroupList.vue new file mode 100644 index 0000000..b1ab394 --- /dev/null +++ b/web/src/views/group/GroupList.vue @@ -0,0 +1,108 @@ + + + diff --git a/web/src/views/profile/ProfileList.vue b/web/src/views/profile/ProfileList.vue new file mode 100644 index 0000000..d5cfd6f --- /dev/null +++ b/web/src/views/profile/ProfileList.vue @@ -0,0 +1,115 @@ + + + diff --git a/web/src/views/user/UserList.vue b/web/src/views/user/UserList.vue new file mode 100644 index 0000000..5ac8337 --- /dev/null +++ b/web/src/views/user/UserList.vue @@ -0,0 +1,157 @@ + + + diff --git a/web/vite.config.js b/web/vite.config.js new file mode 100644 index 0000000..18c294c --- /dev/null +++ b/web/vite.config.js @@ -0,0 +1,23 @@ +import { defineConfig } from 'vite' +import vue2 from '@vitejs/plugin-vue2' + +export default defineConfig({ + plugins: [vue2()], + server: { + port: 3000, + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true + }, + '/admin': { + target: 'http://localhost:8080', + changeOrigin: true + }, + '/user': { + target: 'http://localhost:8080', + changeOrigin: true + } + } + } +})