feat: 初始化配置中心项目

This commit is contained in:
2026-01-14 21:02:13 +08:00
commit 4436d3ee88
95 changed files with 8736 additions and 0 deletions
+39
View File
@@ -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
+128
View File
@@ -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 客户端可继续使用
+38
View File
@@ -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
}
+291
View File
@@ -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
}
}
}
+22
View File
@@ -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: ".",
}
}
+62
View File
@@ -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
}
+56
View File
@@ -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...")
}
+3
View File
@@ -0,0 +1,3 @@
module kenaito-conf/go-client
go 1.25.0
+39
View File
@@ -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)
}()
}
}
@@ -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` 的配置后,远程配置会覆盖本地同名配置。
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>cn.odboy</groupId>
<artifactId>kenaito-conf-springboot2-client-demo</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>kenaito-conf-springboot2-client-demo</name>
<description>Demo project for kenaito-conf config center</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
<relativePath/>
</parent>
<properties>
<java.version>1.8</java.version>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- kenaito-conf springboot client -->
<dependency>
<groupId>cn.odboy</groupId>
<artifactId>kenaito-conf-springboot2-client</artifactId>
<version>1.0.0</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
@@ -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<String, Object> index() {
Map<String, Object> 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";
}
}
@@ -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);
}
}
@@ -0,0 +1,2 @@
org.springframework.boot.env.EnvironmentPostProcessor=\
cn.odboy.confcenter.springboot.KenaitoEnvironmentInitializer
@@ -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
@@ -0,0 +1,5 @@
/target/
.DS_Store
/.settings/
.project
.classpath
+55
View File
@@ -0,0 +1,55 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>cn.odboy</groupId>
<artifactId>kenaito-conf-springboot2-client</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<properties>
<fastjson2.version>2.0.60</fastjson2.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>${fastjson2.version}</version>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.14</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.12.0</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>1.7.36</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot</artifactId>
<version>2.7.18</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>5.3.31</version>
<optional>true</optional>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,8 @@
package cn.odboy.confcenter;
import java.util.Map;
public interface ConfigChangeHanlder {
public void onConfigChanged(Map<String, Object> changedConfigs);
}
@@ -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<String> 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<String, String> 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<ConfigChangeHanlder> 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<Object, Object> 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<Entry<Object, Object>> entrySet = remoteProperties.entrySet();
for (Entry<Object, Object> 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<Entry<Object, Object>> entrySet = properties.entrySet();
for (Entry<Object, Object> 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<String, Object> 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<String> keys = map.keySet();
for (String key : keys) {
Object value = decodeEncryptIfRequire(map.get(key));
properties.put(key, value);
}
return properties;
}
@SuppressWarnings("unchecked")
private Map<String, Object> fetchConfigFromServer() {
Map<String, Object> 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<String> sortKeys = new ArrayList<>();
Set<Entry<Object, Object>> entrySet = properties.entrySet();
for (Entry<Object, Object> 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<String, Object> updateConfig) {
if (!updateConfig.isEmpty()) {
Set<String> 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<String, Object> map = ((MapPropertySource) source).getSource();
for (Entry<String, Object> entry : map.entrySet()) {
properties.put(entry.getKey(), entry.getValue());
}
} else if (source instanceof PropertiesPropertySource) {
Map<String, Object> src = ((PropertiesPropertySource) source).getSource();
for (Entry<String, Object> entry : src.entrySet()) {
properties.put(entry.getKey(), entry.getValue().toString());
}
}
}
for (Entry<String, Object> 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<String, ConfigChangeHanlder> 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;
}
}
@@ -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<String> keys;
public EnvironmentChangeEvent(Set<String> keys) {
super(keys);
this.keys = keys;
}
/**
* @return the keys
*/
public Set<String> getKeys() {
return keys;
}
}
@@ -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;
}
}
@@ -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<String, String> 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<String, Object> map = JSON.parseObject(response, Map.class);
if (map != null && map.containsKey("data")) {
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) 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"));
}
}
@@ -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<String, Object> 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<String, Object> 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;
}
}
@@ -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);
}
}
@@ -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);
}
}
}
@@ -0,0 +1 @@
org.springframework.boot.env.EnvironmentPostProcessor=cn.odboy.confcenter.springboot.KenaitoEnvironmentInitializer
@@ -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
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>cn.odboy</groupId>
<artifactId>kenaito-conf-springboot3-client-demo</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>kenaito-conf-springboot3-client-demo</name>
<description>Demo project for kenaito-conf Spring Boot 3 client</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
<relativePath/>
</parent>
<properties>
<java.version>17</java.version>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- kenaito-conf springboot3 client -->
<dependency>
<groupId>cn.odboy</groupId>
<artifactId>kenaito-conf-springboot3-client</artifactId>
<version>1.0.0</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
@@ -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<String, Object> index() {
Map<String, Object> 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";
}
}
@@ -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);
}
}
@@ -0,0 +1,2 @@
org.springframework.boot.env.EnvironmentPostProcessor=\
cn.odboy.confcenter.springboot.KenaitoEnvironmentInitializer
@@ -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
+107
View File
@@ -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
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<spring-boot.version>3.2.5</spring-boot.version>
```
3. 修改 Spring Framework 版本:
```xml
<version>6.1.6</version>
```
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
<bean class="cn.odboy.confcenter.spring.CCPropertyPlaceholderConfigurer">
<property name="remoteEnabled" value="true"/>
</bean>
```
## 安装到本地 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
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<spring-boot.version>3.2.5</spring-boot.version>
</properties>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>6.1.6</version>
</dependency>
```
+52
View File
@@ -0,0 +1,52 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>cn.odboy</groupId>
<artifactId>kenaito-conf-springboot3-client</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<spring-boot.version>2.7.18</spring-boot.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot</artifactId>
<version>${spring-boot.version}</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>5.3.31</version>
<optional>true</optional>
</dependency>
<!-- HTTP Client -->
<dependency>
<groupId>org.apache.httpcomponents.client5</groupId>
<artifactId>httpclient5</artifactId>
<version>5.3.1</version>
</dependency>
<!-- JSON Processing -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.0</version>
</dependency>
<!-- Logging -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.12</version>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,7 @@
package cn.odboy.confcenter;
import java.util.Map;
public interface ConfigChangeHanlder {
public void onConfigChanged(Map<String, Object> changedConfigs);
}
@@ -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<String> 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<ConfigChangeHanlder> 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<Entry<Object, Object>> entrySet = remoteProperties.entrySet();
for (Entry<Object, Object> 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<String, Object> 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<String> keys = map.keySet();
for (String key : keys) {
Object value = decodeEncryptIfRequire(map.get(key));
properties.put(key, value);
}
return properties;
}
@SuppressWarnings("unchecked")
private Map<String, Object> fetchConfigFromServer() {
Map<String, Object> 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<String> sortKeys = new ArrayList<>();
Set<Entry<Object, Object>> entrySet = properties.entrySet();
for (Entry<Object, Object> 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<String, Object> updateConfig) {
if (!updateConfig.isEmpty()) {
Set<String> 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<String, Object> 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);
}
}
@@ -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<String> keys;
public EnvironmentChangeEvent(Set<String> keys) {
super(keys);
this.keys = keys;
}
public Set<String> getKeys() {
return keys;
}
}
@@ -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<String> 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<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
return response.body();
}
return null;
}
}
@@ -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<String, String> 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<String, Object> map = objectMapper.readValue(response, Map.class);
if (map.containsKey("data")) {
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) 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());
}
}
@@ -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<String, Object> 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<String, Object> 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;
}
}
@@ -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);
}
}
@@ -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);
}
}
}
@@ -0,0 +1,2 @@
org.springframework.boot.env.EnvironmentPostProcessor=\
cn.odboy.confcenter.springboot.KenaitoEnvironmentInitializer
@@ -0,0 +1,2 @@
org.springframework.boot.env.EnvironmentPostProcessor=\
cn.odboy.confcenter.springboot.KenaitoEnvironmentInitializer
@@ -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
@@ -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
+14
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
active=dev
+127
View File
@@ -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)
}
}
+48
View File
@@ -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
)
+175
View File
@@ -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=
+141
View File
@@ -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"
}
+98
View File
@@ -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
}
+302
View File
@@ -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"`
}
+55
View File
@@ -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})
}
+320
View File
@@ -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(&currentConfig); 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
}
+171
View File
@@ -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")
}
+137
View File
@@ -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})
}
+45
View File
@@ -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("环境管理", "&#xe61d;", "/profile/list")
}
menu1.AddSubMenu("业务组管理", "&#xe613;", "/group/list")
if userInfo.IsSuperAdmin() || userInfo.IsGroupAdmin() {
menu1.AddSubMenu("用户管理", "&#xe612;", "/user/list")
}
menu1.AddSubMenu("应用管理", "&#xe63b;", "/app/list")
menus = append(menus, menu1)
// 配置管理菜单
menu2 := model.NewMenu("配置管理", "fa-cubes", true)
menu2.AddSubMenu("配置列表", "&#xe60a;", "/config/list")
menu2.AddSubMenu("新建配置", "&#xe63c;", "/config/add")
menus = append(menus, menu2)
c.JSON(http.StatusOK, model.WrapperResponse{Code: 0, Data: menus})
}
+168
View File
@@ -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})
}
+348
View File
@@ -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)
}
+19
View File
@@ -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,
})
}
+40
View File
@@ -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)
}
+38
View File
@@ -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"`
}
+24
View File
@@ -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"`
}
+16
View File
@@ -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"`
}
+16
View File
@@ -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"`
}
+40
View File
@@ -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})
}
+44
View File
@@ -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"
}
+145
View File
@@ -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
}
+169
View File
@@ -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 <entry key="...">value</entry> 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, "<entry") {
continue
}
// extract key
keyStart := strings.Index(line, `key="`)
if keyStart < 0 {
continue
}
keyStart += 5
keyEnd := strings.Index(line[keyStart:], `"`)
if keyEnd < 0 {
continue
}
key := line[keyStart : keyStart+keyEnd]
// extract value
valStart := strings.Index(line, ">")
if valStart < 0 {
continue
}
valEnd := strings.Index(line, "</entry>")
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
}
+115
View File
@@ -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
}
+51
View File
@@ -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
}
+11
View File
@@ -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))
}
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>配置中心</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1555
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -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"
}
}
+23
View File
@@ -0,0 +1,23 @@
<template>
<div id="app">
<router-view />
</div>
</template>
<script>
export default {
name: 'App'
}
</script>
<style>
html, body {
margin: 0;
padding: 0;
height: 100%;
}
#app {
height: 100%;
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
}
</style>
+53
View File
@@ -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'
})
}
+30
View File
@@ -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'
})
}
+53
View File
@@ -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'
})
}
+45
View File
@@ -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
+14
View File
@@ -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')
+70
View File
@@ -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
+27
View File
@@ -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)
}
}
})
+99
View File
@@ -0,0 +1,99 @@
<template>
<el-container style="height: 100vh">
<el-aside width="200px" style="background: #304156">
<div class="logo">配置中心</div>
<el-menu
:default-active="activeMenu"
background-color="#304156"
text-color="#bfcbd9"
active-text-color="#409EFF"
router
>
<template v-for="menu in menus">
<el-submenu v-if="menu.children && menu.children.length > 1" :index="menu.title" :key="menu.title">
<template slot="title">
<i :class="menu.icon"></i>
<span>{{ menu.title }}</span>
</template>
<el-menu-item v-for="child in menu.children" :key="child.url" :index="child.url">
{{ child.title }}
</el-menu-item>
</el-submenu>
<el-menu-item v-else-if="menu.children && menu.children.length === 1" :index="menu.children[0].url" :key="menu.children[0].url">
<i :class="menu.icon"></i>
<span>{{ menu.children[0].title }}</span>
</el-menu-item>
</template>
</el-menu>
</el-aside>
<el-container>
<el-header style="background: #fff; display: flex; align-items: center; justify-content: space-between; box-shadow: 0 1px 4px rgba(0,21,41,.08)">
<span style="font-size: 16px; font-weight: bold">欢迎使用配置中心</span>
<div>
<span style="margin-right: 15px">{{ userInfo ? userInfo.name : '' }}</span>
<el-button type="text" @click="handleLogout">退出</el-button>
</div>
</el-header>
<el-main style="background: #f0f2f5; padding: 20px">
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<script>
import { getMenus, getUserInfo, logout } from '../api/auth'
import { mapState } from 'vuex'
export default {
computed: {
...mapState(['userInfo', 'menus']),
activeMenu() {
return this.$route.path
}
},
created() {
this.loadMenus()
},
methods: {
async loadMenus() {
if (this.menus.length === 0) {
try {
const res = await getMenus()
this.$store.dispatch('setMenus', res.data)
} catch (e) {}
}
if (!this.userInfo) {
try {
const res = await getUserInfo()
this.$store.dispatch('setUserInfo', res.data)
} catch (e) {}
}
},
async handleLogout() {
try {
await logout()
} catch (e) {}
localStorage.removeItem('token')
this.$store.dispatch('setUserInfo', null)
this.$store.dispatch('setMenus', [])
this.$router.push('/login')
}
}
}
</script>
<style scoped>
.logo {
height: 60px;
line-height: 60px;
text-align: center;
color: #fff;
font-size: 18px;
font-weight: bold;
background: #263445;
}
.el-menu {
border-right: none;
}
</style>
+77
View File
@@ -0,0 +1,77 @@
<template>
<div class="login-container">
<el-card class="login-card">
<h2 class="login-title">配置中心</h2>
<el-form :model="form" :rules="rules" ref="form" label-width="0">
<el-form-item prop="userName">
<el-input v-model="form.userName" placeholder="用户名" prefix-icon="el-icon-user"></el-input>
</el-form-item>
<el-form-item prop="password">
<el-input v-model="form.password" type="password" placeholder="密码" prefix-icon="el-icon-lock" @keyup.enter.native="handleLogin"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleLogin" :loading="loading" style="width: 100%">登录</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script>
import { login, getUserInfo, getMenus } from '../api/auth'
export default {
data() {
return {
form: {
userName: '',
password: ''
},
rules: {
userName: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
},
loading: false
}
},
methods: {
async handleLogin() {
this.$refs.form.validate(async (valid) => {
if (!valid) return
this.loading = true
try {
const res = await login(this.form)
localStorage.setItem('token', res.data.token)
this.$store.dispatch('setUserInfo', res.data.userInfo)
const menuRes = await getMenus()
this.$store.dispatch('setMenus', menuRes.data)
this.$router.push('/')
} catch (e) {
// error handled by interceptor
} finally {
this.loading = false
}
})
}
}
}
</script>
<style scoped>
.login-container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background: #2d3a4b;
}
.login-card {
width: 400px;
padding: 20px;
}
.login-title {
text-align: center;
color: #303133;
margin-bottom: 30px;
}
</style>
+157
View File
@@ -0,0 +1,157 @@
<template>
<el-card>
<div slot="header" style="display: flex; justify-content: space-between; align-items: center">
<span>应用管理</span>
<el-button type="primary" @click="showAdd">新建应用</el-button>
</div>
<el-table :data="list" v-loading="loading" border stripe>
<el-table-column prop="id" label="ID" width="60"></el-table-column>
<el-table-column prop="name" label="应用名称"></el-table-column>
<el-table-column prop="code" label="应用编码"></el-table-column>
<el-table-column prop="groupName" label="所属业务组" width="120"></el-table-column>
<el-table-column prop="ownerName" label="负责人" width="100"></el-table-column>
<el-table-column prop="enabled" label="状态" width="100">
<template slot-scope="scope">
<el-switch v-model="scope.row.enabled" @change="handleSwitch(scope.row)"></el-switch>
</template>
</el-table-column>
<el-table-column label="操作" width="150">
<template slot-scope="scope">
<el-button type="text" @click="showEdit(scope.row)">编辑</el-button>
<el-button type="text" style="color: #F56C6C" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
style="margin-top: 15px; text-align: right"
@current-change="handlePageChange"
:current-page="query.pageNo"
:page-size="query.pageSize"
:total="total"
layout="total, prev, pager, next"
></el-pagination>
<el-dialog :title="dialogTitle" :visible.sync="dialogVisible" width="500px">
<el-form :model="form" :rules="rules" ref="form" label-width="100px">
<el-form-item label="应用名称" prop="name">
<el-input v-model="form.name"></el-input>
</el-form-item>
<el-form-item label="应用编码" prop="code">
<el-input v-model="form.code" :disabled="!!form.id"></el-input>
</el-form-item>
<el-form-item label="所属业务组">
<el-select v-model="form.groupId" placeholder="请选择">
<el-option v-for="g in groups" :key="g.value" :label="g.label" :value="parseInt(g.value)"></el-option>
</el-select>
</el-form-item>
<el-form-item label="负责人">
<el-select v-model="form.ownerId" placeholder="请选择">
<el-option v-for="u in users" :key="u.value" :label="u.label" :value="parseInt(u.value)"></el-option>
</el-select>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remarks" type="textarea"></el-input>
</el-form-item>
</el-form>
<span slot="footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</span>
</el-dialog>
</el-card>
</template>
<script>
import { getAppList, addApp, updateApp, deleteApp } from '../../api/app'
import request from '../../api/request'
export default {
data() {
return {
list: [],
loading: false,
total: 0,
query: { pageNo: 1, pageSize: 20 },
dialogVisible: false,
dialogTitle: '',
form: {},
rules: {
name: [{ required: true, message: '请输入应用名称', trigger: 'blur' }],
code: [{ required: true, message: '请输入应用编码', trigger: 'blur' }]
},
groups: [],
users: []
}
},
created() {
this.loadData()
this.loadOptions()
},
methods: {
async loadData() {
this.loading = true
try {
const res = await getAppList(this.query)
this.list = res.data || []
this.total = res.total || 0
} finally {
this.loading = false
}
},
async loadOptions() {
try {
const [groupRes, userRes] = await Promise.all([
request({ url: '/admin/group/options', method: 'get' }),
request({ url: '/admin/user/options', method: 'get' })
])
this.groups = groupRes.data || []
this.users = userRes.data || []
} catch (e) {}
},
showAdd() {
this.dialogTitle = '新建应用'
this.form = { name: '', code: '', groupId: null, ownerId: null, remarks: '' }
this.dialogVisible = true
},
showEdit(row) {
this.dialogTitle = '编辑应用'
this.form = { ...row }
this.dialogVisible = true
},
handleSubmit() {
this.$refs.form.validate(async (valid) => {
if (!valid) return
try {
if (this.form.id) {
await updateApp(this.form)
} else {
await addApp(this.form)
}
this.$message.success('保存成功')
this.dialogVisible = false
this.loadData()
} catch (e) {}
})
},
async handleDelete(row) {
try {
await this.$confirm('确定删除此应用?')
await deleteApp(row.id)
this.$message.success('删除成功')
this.loadData()
} catch (e) {}
},
async handleSwitch(row) {
try {
await request({ url: `/admin/app/switch/${row.id}`, method: 'post' })
} catch (e) {
row.enabled = !row.enabled
}
},
handlePageChange(page) {
this.query.pageNo = page
this.loadData()
}
}
}
</script>
+104
View File
@@ -0,0 +1,104 @@
<template>
<el-card>
<div slot="header">新建配置</div>
<el-form :model="form" :rules="rules" ref="form" label-width="100px">
<el-form-item label="配置类型">
<el-radio-group v-model="form.global">
<el-radio :label="true">全局配置</el-radio>
<el-radio :label="false">应用配置</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="所属业务组" v-if="form.global" prop="groupId">
<el-select v-model="form.groupId" placeholder="请选择">
<el-option v-for="g in groups" :key="g.value" :label="g.label" :value="parseInt(g.value)"></el-option>
</el-select>
</el-form-item>
<el-form-item label="所属应用" v-if="!form.global" prop="appId">
<el-select v-model="form.appId" placeholder="请选择">
<el-option v-for="a in apps" :key="a.value" :label="a.label" :value="parseInt(a.value)"></el-option>
</el-select>
</el-form-item>
<el-form-item label="环境" prop="env">
<el-select v-model="form.env" placeholder="请选择">
<el-option v-for="p in profiles" :key="p.value" :label="p.label" :value="p.value"></el-option>
</el-select>
</el-form-item>
<el-form-item label="版本">
<el-input v-model="form.version" placeholder="latest"></el-input>
</el-form-item>
<el-form-item label="配置格式">
<el-select v-model="form.type" placeholder="请选择">
<el-option :value="2" label="配置项 (key=value)"></el-option>
<el-option :value="1" label="文件 (properties/xml/yaml)"></el-option>
<el-option :value="3" label="JSON"></el-option>
</el-select>
</el-form-item>
<el-form-item label="配置名称" v-if="form.type === 2 || form.type === 1" prop="name">
<el-input v-model="form.name" :placeholder="form.type === 2 ? '如: db.host' : '如: application.properties'"></el-input>
</el-form-item>
<el-form-item label="配置内容" prop="contents">
<el-input v-model="form.contents" type="textarea" :rows="15" placeholder="请输入配置内容"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSubmit">保存</el-button>
<el-button @click="$router.back()">取消</el-button>
</el-form-item>
</el-form>
</el-card>
</template>
<script>
import { addConfig } from '../../api/config'
import request from '../../api/request'
export default {
data() {
return {
form: {
global: false,
groupId: null,
appId: null,
env: '',
version: 'latest',
type: 2,
name: '',
contents: ''
},
rules: {
env: [{ required: true, message: '请选择环境', trigger: 'change' }],
contents: [{ required: true, message: '请输入配置内容', trigger: 'blur' }]
},
groups: [],
apps: [],
profiles: []
}
},
created() {
this.loadOptions()
},
methods: {
async loadOptions() {
try {
const [groupRes, appRes, profileRes] = await Promise.all([
request({ url: '/admin/group/options', method: 'get' }),
request({ url: '/admin/app/options', method: 'get' }),
request({ url: '/admin/profile/options', method: 'get' })
])
this.groups = groupRes.data || []
this.apps = appRes.data || []
this.profiles = profileRes.data || []
} catch (e) {}
},
handleSubmit() {
this.$refs.form.validate(async (valid) => {
if (!valid) return
try {
await addConfig(this.form)
this.$message.success('保存成功')
this.$router.push('/config/list')
} catch (e) {}
})
}
}
}
</script>
+60
View File
@@ -0,0 +1,60 @@
<template>
<el-card>
<div slot="header">编辑配置</div>
<el-form :model="form" ref="form" label-width="100px" v-loading="loading">
<el-form-item label="配置名称">
<el-input v-model="form.name" disabled></el-input>
</el-form-item>
<el-form-item label="环境">
<el-input v-model="form.env" disabled></el-input>
</el-form-item>
<el-form-item label="所属应用">
<el-input :value="form.appNames" disabled></el-input>
</el-form-item>
<el-form-item label="版本">
<el-input v-model="form.version"></el-input>
</el-form-item>
<el-form-item label="配置内容">
<el-input v-model="form.contents" type="textarea" :rows="20"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSubmit">保存</el-button>
<el-button @click="$router.back()">取消</el-button>
</el-form-item>
</el-form>
</el-card>
</template>
<script>
import { getConfig, updateConfig } from '../../api/config'
export default {
data() {
return {
form: {},
loading: false
}
},
created() {
this.loadConfig()
},
methods: {
async loadConfig() {
this.loading = true
try {
const res = await getConfig(this.$route.params.id)
this.form = res.data
} finally {
this.loading = false
}
},
async handleSubmit() {
try {
await updateConfig(this.form)
this.$message.success('保存成功')
this.$router.push('/config/list')
} catch (e) {}
}
}
}
</script>
+126
View File
@@ -0,0 +1,126 @@
<template>
<el-card>
<div slot="header" style="display: flex; justify-content: space-between; align-items: center">
<span>配置列表</span>
<div>
<el-select v-model="query.env" placeholder="环境" clearable style="width: 120px; margin-right: 10px" @change="loadData">
<el-option v-for="p in profiles" :key="p.value" :label="p.label" :value="p.value"></el-option>
</el-select>
<el-button type="primary" @click="$router.push('/config/add')">新建配置</el-button>
</div>
</div>
<el-table :data="list" v-loading="loading" border stripe>
<el-table-column prop="id" label="ID" width="60"></el-table-column>
<el-table-column prop="name" label="配置名"></el-table-column>
<el-table-column prop="env" label="环境" width="80"></el-table-column>
<el-table-column prop="appNames" label="所属应用"></el-table-column>
<el-table-column prop="type" label="类型" width="80">
<template slot-scope="scope">
<span v-if="scope.row.type === 1">文件</span>
<span v-else-if="scope.row.type === 2">配置项</span>
<span v-else-if="scope.row.type === 3">JSON</span>
</template>
</el-table-column>
<el-table-column prop="version" label="版本" width="100"></el-table-column>
<el-table-column label="操作" width="200">
<template slot-scope="scope">
<el-button type="text" @click="$router.push(`/config/edit/${scope.row.id}`)">编辑</el-button>
<el-button type="text" @click="showHistory(scope.row)">历史</el-button>
<el-button type="text" style="color: #F56C6C" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
style="margin-top: 15px; text-align: right"
@current-change="handlePageChange"
:current-page="query.pageNo"
:page-size="query.pageSize"
:total="total"
layout="total, prev, pager, next"
></el-pagination>
<el-dialog title="配置历史" :visible.sync="historyVisible" width="700px">
<el-table :data="historyList" border stripe>
<el-table-column prop="id" label="ID" width="60"></el-table-column>
<el-table-column prop="createdBy" label="操作人" width="100"></el-table-column>
<el-table-column prop="createdAt" label="操作时间" width="180"></el-table-column>
<el-table-column label="操作">
<template slot-scope="scope">
<el-button type="text" @click="handleRollback(scope.row)">回滚到此版本</el-button>
</template>
</el-table-column>
</el-table>
</el-dialog>
</el-card>
</template>
<script>
import { getConfigList, deleteConfig, getConfigHistory, rollbackConfig } from '../../api/config'
import request from '../../api/request'
export default {
data() {
return {
list: [],
loading: false,
total: 0,
query: { pageNo: 1, pageSize: 20, env: '' },
profiles: [],
historyVisible: false,
historyList: [],
currentConfig: null
}
},
created() {
this.loadData()
this.loadProfiles()
},
methods: {
async loadData() {
this.loading = true
try {
const res = await getConfigList(this.query)
this.list = res.data || []
this.total = res.total || 0
} finally {
this.loading = false
}
},
async loadProfiles() {
try {
const res = await request({ url: '/admin/profile/options', method: 'get' })
this.profiles = res.data || []
} catch (e) {}
},
handlePageChange(page) {
this.query.pageNo = page
this.loadData()
},
async handleDelete(row) {
try {
await this.$confirm('确定删除此配置?')
await deleteConfig(row.id)
this.$message.success('删除成功')
this.loadData()
} catch (e) {}
},
async showHistory(row) {
this.currentConfig = row
try {
const res = await getConfigHistory(row.id)
this.historyList = res.data || []
this.historyVisible = true
} catch (e) {}
},
async handleRollback(row) {
try {
await this.$confirm('确定回滚到此版本?')
await rollbackConfig(row.id)
this.$message.success('回滚成功')
this.historyVisible = false
this.loadData()
} catch (e) {}
}
}
}
</script>
+108
View File
@@ -0,0 +1,108 @@
<template>
<el-card>
<div slot="header" style="display: flex; justify-content: space-between; align-items: center">
<span>业务组管理</span>
<el-button type="primary" @click="showAdd">新建业务组</el-button>
</div>
<el-table :data="list" v-loading="loading" border stripe>
<el-table-column prop="id" label="ID" width="60"></el-table-column>
<el-table-column prop="name" label="组名称"></el-table-column>
<el-table-column prop="master" label="负责人"></el-table-column>
<el-table-column prop="remarks" label="备注"></el-table-column>
<el-table-column prop="enabled" label="状态" width="80">
<template slot-scope="scope">
<el-tag :type="scope.row.enabled ? 'success' : 'info'">{{ scope.row.enabled ? '启用' : '禁用' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="100">
<template slot-scope="scope">
<el-button type="text" @click="showEdit(scope.row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog :title="dialogTitle" :visible.sync="dialogVisible" width="500px">
<el-form :model="form" :rules="rules" ref="form" label-width="80px">
<el-form-item label="组名称" prop="name">
<el-input v-model="form.name"></el-input>
</el-form-item>
<el-form-item label="负责人" prop="masterUid">
<el-select v-model="form.masterUid" placeholder="请选择">
<el-option v-for="u in users" :key="u.value" :label="u.label" :value="parseInt(u.value)"></el-option>
</el-select>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remarks" type="textarea"></el-input>
</el-form-item>
</el-form>
<span slot="footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</span>
</el-dialog>
</el-card>
</template>
<script>
import request from '../../api/request'
export default {
data() {
return {
list: [],
loading: false,
dialogVisible: false,
dialogTitle: '',
form: {},
rules: {
name: [{ required: true, message: '请输入组名称', trigger: 'blur' }],
masterUid: [{ required: true, message: '请选择负责人', trigger: 'change' }]
},
users: []
}
},
created() {
this.loadData()
this.loadUsers()
},
methods: {
async loadData() {
this.loading = true
try {
const res = await request({ url: '/admin/group/list', method: 'get' })
this.list = res.data || []
} finally {
this.loading = false
}
},
async loadUsers() {
try {
const res = await request({ url: '/admin/user/options', method: 'get' })
this.users = res.data || []
} catch (e) {}
},
showAdd() {
this.dialogTitle = '新建业务组'
this.form = { name: '', masterUid: null, remarks: '' }
this.dialogVisible = true
},
showEdit(row) {
this.dialogTitle = '编辑业务组'
this.form = { ...row }
this.dialogVisible = true
},
handleSubmit() {
this.$refs.form.validate(async (valid) => {
if (!valid) return
try {
const url = this.form.id ? '/admin/group/update' : '/admin/group/add'
await request({ url, method: 'post', data: this.form })
this.$message.success('保存成功')
this.dialogVisible = false
this.loadData()
} catch (e) {}
})
}
}
}
</script>
+115
View File
@@ -0,0 +1,115 @@
<template>
<el-card>
<div slot="header" style="display: flex; justify-content: space-between; align-items: center">
<span>环境管理</span>
<el-button type="primary" @click="showAdd">新建环境</el-button>
</div>
<el-table :data="list" v-loading="loading" border stripe>
<el-table-column prop="id" label="ID" width="60"></el-table-column>
<el-table-column prop="name" label="环境标识"></el-table-column>
<el-table-column prop="alias" label="环境名称"></el-table-column>
<el-table-column prop="isDefault" label="默认" width="80">
<template slot-scope="scope">
<el-tag v-if="scope.row.isDefault" type="success">是</el-tag>
<span v-else>否</span>
</template>
</el-table-column>
<el-table-column prop="enabled" label="状态" width="100">
<template slot-scope="scope">
<el-switch v-model="scope.row.enabled" @change="handleSwitch(scope.row)"></el-switch>
</template>
</el-table-column>
<el-table-column label="操作" width="180">
<template slot-scope="scope">
<el-button type="text" @click="showEdit(scope.row)">编辑</el-button>
<el-button type="text" style="color: #F56C6C" @click="handleDelete(scope.row)" v-if="!scope.row.isDefault">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog :title="dialogTitle" :visible.sync="dialogVisible" width="500px">
<el-form :model="form" :rules="rules" ref="form" label-width="80px">
<el-form-item label="环境标识" prop="name">
<el-input v-model="form.name" placeholder="如: daily, stage, online" :disabled="!!form.id"></el-input>
</el-form-item>
<el-form-item label="环境名称">
<el-input v-model="form.alias" placeholder="如: 测试环境"></el-input>
</el-form-item>
</el-form>
<span slot="footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</span>
</el-dialog>
</el-card>
</template>
<script>
import request from '../../api/request'
export default {
data() {
return {
list: [],
loading: false,
dialogVisible: false,
dialogTitle: '',
form: {},
rules: {
name: [{ required: true, message: '请输入环境标识', trigger: 'blur' }]
}
}
},
created() {
this.loadData()
},
methods: {
async loadData() {
this.loading = true
try {
const res = await request({ url: '/admin/profile/list', method: 'get' })
this.list = res.data || []
} finally {
this.loading = false
}
},
showAdd() {
this.dialogTitle = '新建环境'
this.form = { name: '', alias: '' }
this.dialogVisible = true
},
showEdit(row) {
this.dialogTitle = '编辑环境'
this.form = { id: row.id, name: row.name, alias: row.alias }
this.dialogVisible = true
},
handleSubmit() {
this.$refs.form.validate(async (valid) => {
if (!valid) return
try {
const url = this.form.id ? '/admin/profile/update' : '/admin/profile/add'
await request({ url, method: 'post', data: this.form })
this.$message.success('保存成功')
this.dialogVisible = false
this.loadData()
} catch (e) {}
})
},
async handleSwitch(row) {
try {
await request({ url: `/admin/profile/switch/${row.id}`, method: 'post' })
} catch (e) {
row.enabled = !row.enabled
}
},
async handleDelete(row) {
try {
await this.$confirm('确定删除此环境?')
await request({ url: `/admin/profile/delete/${row.id}`, method: 'post' })
this.$message.success('删除成功')
this.loadData()
} catch (e) {}
}
}
}
</script>
+157
View File
@@ -0,0 +1,157 @@
<template>
<el-card>
<div slot="header" style="display: flex; justify-content: space-between; align-items: center">
<span>用户管理</span>
<el-button type="primary" @click="showAdd">新建用户</el-button>
</div>
<el-table :data="list" v-loading="loading" border stripe>
<el-table-column prop="id" label="ID" width="60"></el-table-column>
<el-table-column prop="name" label="用户名"></el-table-column>
<el-table-column prop="mobile" label="手机号"></el-table-column>
<el-table-column prop="email" label="邮箱"></el-table-column>
<el-table-column prop="type" label="类型" width="100">
<template slot-scope="scope">
<el-tag v-if="scope.row.type === 'superAdmin'" type="danger">超级管理员</el-tag>
<el-tag v-else-if="scope.row.type === 'groupAdmin'" type="warning">组管理员</el-tag>
<el-tag v-else>普通用户</el-tag>
</template>
</el-table-column>
<el-table-column prop="groupName" label="所属组"></el-table-column>
<el-table-column prop="enabled" label="状态" width="100">
<template slot-scope="scope">
<el-switch
v-model="scope.row.enabled"
@change="handleSwitch(scope.row)"
:disabled="scope.row.name === 'admin'">
</el-switch>
</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template slot-scope="scope">
<el-button type="text" @click="showEdit(scope.row)">编辑</el-button>
<el-button type="text" @click="handleResetPwd(scope.row)">重置密码</el-button>
<el-button type="text" style="color: #F56C6C" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog :title="dialogTitle" :visible.sync="dialogVisible" width="500px">
<el-form :model="form" :rules="rules" ref="form" label-width="80px">
<el-form-item label="用户名" prop="name">
<el-input v-model="form.name" :disabled="!!form.id"></el-input>
</el-form-item>
<el-form-item label="手机号" prop="mobile">
<el-input v-model="form.mobile"></el-input>
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="form.email"></el-input>
</el-form-item>
<el-form-item label="类型" prop="type">
<el-select v-model="form.type" :disabled="form.id && form.type === 'superAdmin'">
<el-option value="user" label="普通用户"></el-option>
<el-option value="groupAdmin" label="组管理员"></el-option>
<el-option value="superAdmin" label="超级管理员"></el-option>
</el-select>
</el-form-item>
<el-form-item label="所属组" v-if="form.type !== 'superAdmin'">
<el-input v-model="form.groupName" placeholder="输入组名(不存在会自动创建)" :disabled="form.id && form.isSuperAdmin"></el-input>
</el-form-item>
</el-form>
<span slot="footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</span>
</el-dialog>
</el-card>
</template>
<script>
import request from '../../api/request'
export default {
data() {
return {
list: [],
loading: false,
dialogVisible: false,
dialogTitle: '',
form: {},
rules: {
name: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
mobile: [{ required: true, message: '请输入手机号', trigger: 'blur' }],
type: [{ required: true, message: '请选择类型', trigger: 'change' }]
}
}
},
created() {
this.loadData()
},
methods: {
async loadData() {
this.loading = true
try {
const res = await request({ url: '/admin/user/list', method: 'get' })
this.list = res.data || []
} finally {
this.loading = false
}
},
showAdd() {
this.dialogTitle = '新建用户'
this.form = { name: '', mobile: '', email: '', type: 'user', groupName: '' }
this.dialogVisible = true
},
showEdit(row) {
this.dialogTitle = '编辑用户'
this.form = {
id: row.id,
name: row.name,
mobile: row.mobile,
email: row.email,
type: row.type,
groupName: row.groupName || '',
isSuperAdmin: row.type === 'superAdmin'
}
this.dialogVisible = true
},
handleSubmit() {
this.$refs.form.validate(async (valid) => {
if (!valid) return
try {
const url = this.form.id ? '/admin/user/update' : '/admin/user/add'
await request({ url, method: 'post', data: this.form })
if (this.form.id) {
this.$message.success('编辑成功')
} else {
this.$message.success('创建成功,默认密码:admin123')
}
this.dialogVisible = false
this.loadData()
} catch (e) {}
})
},
async handleSwitch(row) {
try {
await request({ url: `/admin/user/switch/${row.id}`, method: 'post' })
this.$message.success('操作成功')
this.loadData()
} catch (e) {}
},
async handleResetPwd(row) {
try {
await this.$confirm('确定重置密码?')
const res = await request({ url: `/admin/user/resetPassword/${row.id}`, method: 'post' })
this.$message.success(`新密码: ${res.data}`)
} catch (e) {}
},
async handleDelete(row) {
try {
await this.$confirm('确定删除此用户?')
await request({ url: `/admin/user/delete/${row.id}`, method: 'post' })
this.$message.success('删除成功')
this.loadData()
} catch (e) {}
}
}
}
</script>
+23
View File
@@ -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
}
}
}
})