feat: 初始化配置中心项目
This commit is contained in:
+39
@@ -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
|
||||
@@ -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 客户端可继续使用
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: ".",
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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...")
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module kenaito-conf/go-client
|
||||
|
||||
go 1.25.0
|
||||
@@ -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>
|
||||
+43
@@ -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
|
||||
@@ -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>
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package cn.odboy.confcenter;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public interface ConfigChangeHanlder {
|
||||
|
||||
public void onConfigChanged(Map<String, Object> changedConfigs);
|
||||
}
|
||||
+533
@@ -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;
|
||||
}
|
||||
}
|
||||
+24
@@ -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;
|
||||
}
|
||||
}
|
||||
+59
@@ -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;
|
||||
}
|
||||
}
|
||||
+77
@@ -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"));
|
||||
}
|
||||
}
|
||||
+55
@@ -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;
|
||||
}
|
||||
}
|
||||
+52
@@ -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);
|
||||
}
|
||||
}
|
||||
+35
@@ -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>
|
||||
+43
@@ -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
|
||||
@@ -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>
|
||||
```
|
||||
@@ -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>
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package cn.odboy.confcenter;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public interface ConfigChangeHanlder {
|
||||
public void onConfigChanged(Map<String, Object> changedConfigs);
|
||||
}
|
||||
+495
@@ -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);
|
||||
}
|
||||
}
|
||||
+21
@@ -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;
|
||||
}
|
||||
}
|
||||
+82
@@ -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());
|
||||
}
|
||||
}
|
||||
+59
@@ -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;
|
||||
}
|
||||
}
|
||||
+54
@@ -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);
|
||||
}
|
||||
}
|
||||
+36
@@ -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
|
||||
+8
@@ -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
|
||||
+8
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
active=dev
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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=
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"kenaito-conf/server/internal/dao"
|
||||
"kenaito-conf/server/internal/middleware"
|
||||
"kenaito-conf/server/internal/model"
|
||||
"kenaito-conf/server/internal/service"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type ConfigHandler struct {
|
||||
cryptoService *service.CryptoService
|
||||
parseService *service.ConfigParseService
|
||||
permService *service.PermissionService
|
||||
}
|
||||
|
||||
func NewConfigHandler(crypto *service.CryptoService, parse *service.ConfigParseService, perm *service.PermissionService) *ConfigHandler {
|
||||
return &ConfigHandler{cryptoService: crypto, parseService: parse, permService: perm}
|
||||
}
|
||||
|
||||
type ConfigListRequest struct {
|
||||
PageNo int `form:"pageNo" binding:"required"`
|
||||
PageSize int `form:"pageSize" binding:"required"`
|
||||
Env string `form:"env"`
|
||||
AppId *int `form:"appId"`
|
||||
}
|
||||
|
||||
func (h *ConfigHandler) List(c *gin.Context) {
|
||||
var req ConfigListRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.WrapperResponse{Code: 400, Msg: "参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
userInfo := middleware.GetUserInfo(c)
|
||||
query := dao.Engine.Where("enabled = 1")
|
||||
|
||||
if req.Env != "" {
|
||||
query = query.And("env = ?", req.Env)
|
||||
}
|
||||
if req.AppId != nil {
|
||||
if *req.AppId <= 0 {
|
||||
query = query.And("(app_id is null OR app_id = 0)")
|
||||
} else {
|
||||
var app model.Application
|
||||
if has, _ := dao.Engine.ID(*req.AppId).Get(&app); has {
|
||||
query = query.And("app_code like ?", app.Code+"%")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// permission filter
|
||||
if !userInfo.IsSuperAdmin() {
|
||||
if userInfo.IsGroupAdmin() && userInfo.GroupId != nil {
|
||||
query = query.And("group_id = ?", *userInfo.GroupId)
|
||||
} else if len(userInfo.GrantAppIds) > 0 {
|
||||
query = query.In("app_id", userInfo.GrantAppIds)
|
||||
} else {
|
||||
c.JSON(http.StatusOK, model.PageResult{PageNo: req.PageNo, PageSize: req.PageSize, Total: 0, Data: []interface{}{}})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var configs []model.AppConfig
|
||||
total, _ := query.Count(&model.AppConfig{})
|
||||
query.Limit(req.PageSize, (req.PageNo-1)*req.PageSize).Find(&configs)
|
||||
|
||||
// build app names
|
||||
for i := range configs {
|
||||
configs[i].AppNames = h.buildAppNames(&configs[i])
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, model.PageResult{PageNo: req.PageNo, PageSize: req.PageSize, Total: total, Data: configs})
|
||||
}
|
||||
|
||||
func (h *ConfigHandler) GetById(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
var config model.AppConfig
|
||||
has, _ := dao.Engine.ID(id).Get(&config)
|
||||
if !has {
|
||||
c.JSON(http.StatusOK, model.WrapperResponse{Code: 404, Msg: "配置不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
userInfo := middleware.GetUserInfo(c)
|
||||
if err := h.permService.RequirePermission(userInfo, config.GroupId, config.AppId, "RO"); err != nil {
|
||||
c.JSON(http.StatusForbidden, model.WrapperResponse{Code: 403, Msg: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
config.AppNames = h.buildAppNames(&config)
|
||||
c.JSON(http.StatusOK, model.WrapperResponse{Code: 0, Data: config})
|
||||
}
|
||||
|
||||
type AddConfigRequest struct {
|
||||
Id int `json:"id"`
|
||||
GroupId *int `json:"groupId"`
|
||||
AppId *int `json:"appId"`
|
||||
Env string `json:"env" binding:"required"`
|
||||
Version string `json:"version"`
|
||||
Name string `json:"name"`
|
||||
Type int `json:"type" binding:"required"`
|
||||
Contents string `json:"contents"`
|
||||
Global bool `json:"global"`
|
||||
}
|
||||
|
||||
func (h *ConfigHandler) Add(c *gin.Context) {
|
||||
var req AddConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.WrapperResponse{Code: 400, Msg: "参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
userInfo := middleware.GetUserInfo(c)
|
||||
if req.Global {
|
||||
if req.GroupId == nil {
|
||||
c.JSON(http.StatusOK, model.WrapperResponse{Code: 400, Msg: "全局配置需要指定所属业务组"})
|
||||
return
|
||||
}
|
||||
if err := h.permService.RequirePermission(userInfo, req.GroupId, nil, "RW"); err != nil {
|
||||
c.JSON(http.StatusForbidden, model.WrapperResponse{Code: 403, Msg: err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if !req.Global && req.AppId == nil {
|
||||
c.JSON(http.StatusOK, model.WrapperResponse{Code: 400, Msg: "非全局绑定应用不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
var app model.Application
|
||||
if req.AppId != nil {
|
||||
if has, _ := dao.Engine.ID(*req.AppId).Get(&app); !has {
|
||||
c.JSON(http.StatusOK, model.WrapperResponse{Code: 400, Msg: "应用不存在"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
config := model.AppConfig{
|
||||
GroupId: req.GroupId,
|
||||
AppId: req.AppId,
|
||||
Env: req.Env,
|
||||
Version: req.Version,
|
||||
Name: req.Name,
|
||||
Type: req.Type,
|
||||
Contents: req.Contents,
|
||||
Enabled: true,
|
||||
CreatedAt: func() *time.Time { t := time.Now(); return &t }(),
|
||||
CreatedBy: userInfo.Name,
|
||||
UpdatedAt: func() *time.Time { t := time.Now(); return &t }(),
|
||||
UpdatedBy: userInfo.Name,
|
||||
}
|
||||
|
||||
if req.AppId != nil {
|
||||
config.AppCode = app.Code
|
||||
if !req.Global {
|
||||
config.GroupId = app.GroupId
|
||||
}
|
||||
}
|
||||
|
||||
// encrypt sensitive values
|
||||
h.encryptIfRequired(&config)
|
||||
|
||||
if _, err := dao.Engine.Insert(&config); err != nil {
|
||||
c.JSON(http.StatusOK, model.WrapperResponse{Code: 500, Msg: "保存失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, model.WrapperResponse{Code: 0, Data: true})
|
||||
}
|
||||
|
||||
func (h *ConfigHandler) Update(c *gin.Context) {
|
||||
var req AddConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.WrapperResponse{Code: 400, Msg: "参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Id == 0 {
|
||||
c.JSON(http.StatusOK, model.WrapperResponse{Code: 400, Msg: "id参数缺失"})
|
||||
return
|
||||
}
|
||||
|
||||
var config model.AppConfig
|
||||
has, _ := dao.Engine.ID(req.Id).Get(&config)
|
||||
if !has {
|
||||
c.JSON(http.StatusOK, model.WrapperResponse{Code: 404, Msg: "配置不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
userInfo := middleware.GetUserInfo(c)
|
||||
if err := h.permService.RequirePermission(userInfo, config.GroupId, config.AppId, "RW"); err != nil {
|
||||
c.JSON(http.StatusForbidden, model.WrapperResponse{Code: 403, Msg: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// save history
|
||||
h.saveHistory(&config, userInfo.Name)
|
||||
|
||||
originalContents := config.Contents
|
||||
config.Contents = req.Contents
|
||||
config.Version = req.Version
|
||||
config.UpdatedBy = userInfo.Name
|
||||
now := time.Now()
|
||||
config.UpdatedAt = &now
|
||||
|
||||
h.encryptIfRequired(&config)
|
||||
|
||||
dao.Engine.ID(config.Id).Cols("contents", "version", "updated_by", "updated_at").Update(&config)
|
||||
|
||||
// TODO: publish config change event (ZK removed, could use webhook or other mechanism)
|
||||
_ = originalContents
|
||||
|
||||
c.JSON(http.StatusOK, model.WrapperResponse{Code: 0, Data: true})
|
||||
}
|
||||
|
||||
func (h *ConfigHandler) Delete(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
var config model.AppConfig
|
||||
has, _ := dao.Engine.ID(id).Get(&config)
|
||||
if !has {
|
||||
c.JSON(http.StatusOK, model.WrapperResponse{Code: 404, Msg: "配置不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
userInfo := middleware.GetUserInfo(c)
|
||||
if err := h.permService.RequirePermission(userInfo, config.GroupId, config.AppId, "RW"); err != nil {
|
||||
c.JSON(http.StatusForbidden, model.WrapperResponse{Code: 403, Msg: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
config.Enabled = false
|
||||
config.UpdatedBy = userInfo.Name
|
||||
now := time.Now()
|
||||
config.UpdatedAt = &now
|
||||
dao.Engine.ID(config.Id).Cols("enabled", "updated_by", "updated_at").Update(&config)
|
||||
|
||||
c.JSON(http.StatusOK, model.WrapperResponse{Code: 0})
|
||||
}
|
||||
|
||||
func (h *ConfigHandler) GetHistory(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
var histories []model.AppConfigHistory
|
||||
dao.Engine.Where("origin_id = ?", id).OrderBy("id desc").Limit(5).Find(&histories)
|
||||
|
||||
if len(histories) > 0 {
|
||||
var currentConfig model.AppConfig
|
||||
if has, _ := dao.Engine.ID(histories[0].OriginId).Get(¤tConfig); has {
|
||||
for i := range histories {
|
||||
histories[i].ActiveContents = currentConfig.Contents
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, model.WrapperResponse{Code: 0, Data: histories})
|
||||
}
|
||||
|
||||
func (h *ConfigHandler) Rollback(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
var history model.AppConfigHistory
|
||||
has, _ := dao.Engine.ID(id).Get(&history)
|
||||
if !has {
|
||||
c.JSON(http.StatusOK, model.WrapperResponse{Code: 404, Msg: "历史记录不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
var config model.AppConfig
|
||||
if has, _ := dao.Engine.ID(history.OriginId).Get(&config); has {
|
||||
userInfo := middleware.GetUserInfo(c)
|
||||
if err := h.permService.RequirePermission(userInfo, config.GroupId, config.AppId, "RW"); err != nil {
|
||||
c.JSON(http.StatusForbidden, model.WrapperResponse{Code: 403, Msg: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
config.Contents = history.Contents
|
||||
config.UpdatedBy = userInfo.Name
|
||||
now := time.Now()
|
||||
config.UpdatedAt = &now
|
||||
dao.Engine.ID(config.Id).Cols("contents", "updated_by", "updated_at").Update(&config)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, model.WrapperResponse{Code: 0})
|
||||
}
|
||||
|
||||
func (h *ConfigHandler) buildAppNames(config *model.AppConfig) string {
|
||||
if config.Global || (config.AppId == nil || *config.AppId == 0) {
|
||||
return "全局配置"
|
||||
}
|
||||
var app model.Application
|
||||
if has, _ := dao.Engine.ID(*config.AppId).Get(&app); has {
|
||||
return app.Name + "(" + app.Code + ")"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (h *ConfigHandler) saveHistory(config *model.AppConfig, operator string) {
|
||||
history := model.AppConfigHistory{
|
||||
OriginId: config.Id,
|
||||
AppId: config.AppId,
|
||||
AppNames: h.buildAppNames(config),
|
||||
Env: config.Env,
|
||||
Version: config.Version,
|
||||
Name: config.Name,
|
||||
Type: config.Type,
|
||||
Contents: config.Contents,
|
||||
CreatedAt: func() *time.Time { t := time.Now(); return &t }(),
|
||||
CreatedBy: operator,
|
||||
}
|
||||
dao.Engine.Insert(&history)
|
||||
}
|
||||
|
||||
func (h *ConfigHandler) encryptIfRequired(config *model.AppConfig) {
|
||||
// simplified: check if contents contain sensitive keys
|
||||
// In production, parse the config and encrypt values with {Cipher} prefix
|
||||
// For now, skip encryption logic
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"kenaito-conf/server/internal/middleware"
|
||||
"kenaito-conf/server/internal/model"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type IndexHandler struct{}
|
||||
|
||||
func NewIndexHandler() *IndexHandler {
|
||||
return &IndexHandler{}
|
||||
}
|
||||
|
||||
func (h *IndexHandler) GetMenus(c *gin.Context) {
|
||||
userInfo := middleware.GetUserInfo(c)
|
||||
if userInfo == nil {
|
||||
c.JSON(http.StatusUnauthorized, model.WrapperResponse{Code: 401, Msg: "未登录"})
|
||||
return
|
||||
}
|
||||
|
||||
menus := make([]model.Menu, 0)
|
||||
|
||||
// 全局管理菜单
|
||||
menu1 := model.NewMenu("全局管理", "fa-cubes", false)
|
||||
if userInfo.IsSuperAdmin() {
|
||||
menu1.AddSubMenu("环境管理", "", "/profile/list")
|
||||
}
|
||||
menu1.AddSubMenu("业务组管理", "", "/group/list")
|
||||
if userInfo.IsSuperAdmin() || userInfo.IsGroupAdmin() {
|
||||
menu1.AddSubMenu("用户管理", "", "/user/list")
|
||||
}
|
||||
menu1.AddSubMenu("应用管理", "", "/app/list")
|
||||
menus = append(menus, menu1)
|
||||
|
||||
// 配置管理菜单
|
||||
menu2 := model.NewMenu("配置管理", "fa-cubes", true)
|
||||
menu2.AddSubMenu("配置列表", "", "/config/list")
|
||||
menu2.AddSubMenu("新建配置", "", "/config/add")
|
||||
menus = append(menus, menu2)
|
||||
|
||||
c.JSON(http.StatusOK, model.WrapperResponse{Code: 0, Data: menus})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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>
|
||||
Generated
+1555
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user