feat: improve datasource query audit workflows
This commit is contained in:
1 parent
74748e4914
commit
644981eef4
32 files changed
+780
-3061
No files matched your search
+2
-2
@@ -10,8 +10,8 @@ FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y ca-certificates sqlite3 curl && apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
# Install gh-ost for online DDL
|
||||
RUN GH_OST_VERSION=1.1.9 && \
|
||||
curl -fsSL "https://github.com/github/gh-ost/releases/download/v${GH_OST_VERSION}/gh-ost-binary-linux-amd64-*.tar.gz" \
|
||||
| tar xz -C /usr/local/bin gh-ost && chmod +x /usr/local/bin/gh-ost
|
||||
curl -fsSL -o /usr/local/bin/gh-ost "https://github.com/github/gh-ost/releases/download/v${GH_OST_VERSION}/gh-ost" && \
|
||||
chmod +x /usr/local/bin/gh-ost
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/opsxsql .
|
||||
COPY --from=build /app/cmd/server/seed.go /app/
|
||||
|
||||
@@ -117,7 +117,7 @@ func testPostgreSQL(ctx context.Context, host string, port int, user, pass, dbna
|
||||
dbname = "postgres"
|
||||
}
|
||||
dsn := fmt.Sprintf("postgres://%s:***@%s:%d/%s?sslmode=disable&connect_timeout=10",
|
||||
user, pass, host, port, dbname)
|
||||
user, host, port, dbname)
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
return false, fmt.Sprintf("Connection failed: %v", err)
|
||||
@@ -141,12 +141,12 @@ func testRedis(ctx context.Context, host string, port int, username, pass string
|
||||
redisUsername = ""
|
||||
}
|
||||
rdb := redis.NewClient(&redis.Options{
|
||||
Addr: addr,
|
||||
Username: redisUsername,
|
||||
Password: pass,
|
||||
DB: 0,
|
||||
DialTimeout: 10 * time.Second,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
Addr: addr,
|
||||
Username: redisUsername,
|
||||
Password: pass,
|
||||
DB: 0,
|
||||
DialTimeout: 10 * time.Second,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
})
|
||||
defer rdb.Close()
|
||||
|
||||
@@ -541,7 +541,7 @@ func getDBConnection(instance *model.Instance, dbName string) (*sql.DB, error) {
|
||||
dbName = "postgres"
|
||||
}
|
||||
dsn := fmt.Sprintf("postgres://%s:***@%s:%d/%s?sslmode=disable&connect_timeout=10",
|
||||
instance.Username, password, instance.Host, instance.Port, dbName)
|
||||
instance.Username, instance.Host, instance.Port, dbName)
|
||||
return sql.Open("pgx", dsn)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported database type: %s", instance.DBType)
|
||||
|
||||
@@ -14,10 +14,10 @@ import (
|
||||
// GET /api/v1/instances
|
||||
func ListInstances(c *gin.Context) {
|
||||
userID, _, role := middleware.GetCurrentUser(c)
|
||||
|
||||
|
||||
var instances []model.Instance
|
||||
query := model.DB.Order("id")
|
||||
|
||||
|
||||
// developer only sees instances in their groups
|
||||
if role == "developer" {
|
||||
var groupIDs []int
|
||||
@@ -33,12 +33,12 @@ func ListInstances(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if err := query.Find(&instances).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// mask passwords in response
|
||||
for i := range instances {
|
||||
instances[i].Password = ""
|
||||
@@ -86,8 +86,8 @@ func CreateInstance(c *gin.Context) {
|
||||
if result.Version != "" {
|
||||
updates["version"] = result.Version
|
||||
}
|
||||
if result.ClusterMode {
|
||||
updates["cluster_mode"] = true
|
||||
if instance.DBType == "redis" {
|
||||
updates["cluster_mode"] = result.ClusterMode
|
||||
}
|
||||
} else {
|
||||
updates["status"] = "error"
|
||||
@@ -108,13 +108,13 @@ func UpdateInstance(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
var input map[string]interface{}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// if password is empty or masked placeholder, don't update it
|
||||
if pw, ok := input["password"].(string); ok {
|
||||
pw = strings.TrimSpace(pw)
|
||||
@@ -128,12 +128,12 @@ func UpdateInstance(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if err := model.DB.Model(&instance).Updates(input).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
model.DB.First(&instance, id)
|
||||
instance.Password = "******"
|
||||
c.JSON(http.StatusOK, instance)
|
||||
@@ -157,15 +157,15 @@ func TestConnection(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
result := testDBConnectionFull(&instance)
|
||||
// Update version & cluster_mode in DB if detected
|
||||
updates := map[string]interface{}{}
|
||||
if result.Success && result.Version != "" {
|
||||
updates["version"] = result.Version
|
||||
}
|
||||
if result.ClusterMode {
|
||||
updates["cluster_mode"] = true
|
||||
if instance.DBType == "redis" {
|
||||
updates["cluster_mode"] = result.ClusterMode
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
model.DB.Model(&model.Instance{}).Where("id = ?", instance.ID).Updates(updates)
|
||||
@@ -181,16 +181,16 @@ func TestConnection(c *gin.Context) {
|
||||
func InstanceStats(c *gin.Context) {
|
||||
var total int64
|
||||
model.DB.Model(&model.Instance{}).Count(&total)
|
||||
|
||||
|
||||
type typeCount struct {
|
||||
DBType string `json:"db_type"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
var byType []typeCount
|
||||
model.DB.Model(&model.Instance{}).Select("db_type, count(*) as count").Group("db_type").Scan(&byType)
|
||||
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"total": total,
|
||||
"total": total,
|
||||
"by_type": byType,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,20 +7,20 @@ import (
|
||||
|
||||
"sql-platform/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"time"
|
||||
"context"
|
||||
"sql-platform/internal/middleware"
|
||||
"strings"
|
||||
"strconv"
|
||||
"sort"
|
||||
"encoding/json"
|
||||
"bytes"
|
||||
"regexp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/readpref"
|
||||
"regexp"
|
||||
"sort"
|
||||
"sql-platform/internal/middleware"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GET /api/v1/instances/:id/detail/databases
|
||||
@@ -523,7 +523,6 @@ func ExecuteQuery(c *gin.Context) {
|
||||
}
|
||||
// Read query limits from platform settings
|
||||
maxLimit := getSettingInt("query_limit_max", 1000)
|
||||
autoLimit := getSetting("query_auto_limit", "true") == "true"
|
||||
if input.Limit <= 0 {
|
||||
input.Limit = maxLimit
|
||||
if input.Limit > 1000 {
|
||||
@@ -547,7 +546,6 @@ func ExecuteQuery(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = autoLimit
|
||||
|
||||
var instance model.Instance
|
||||
if err := model.DB.First(&instance, input.InstanceID).Error; err != nil {
|
||||
@@ -619,6 +617,9 @@ func executeSQLQuery(instance *model.Instance, dbName string, sqlStr string, lim
|
||||
_ = ctx
|
||||
|
||||
upperSQL := strings.ToUpper(strings.TrimSpace(sqlStr))
|
||||
if ok, reason := isReadOnlySQL(sqlStr); !ok {
|
||||
return nil, fmt.Errorf("查询入口仅允许只读语句:%s。数据变更请提交工单", reason)
|
||||
}
|
||||
|
||||
// Count total (only when no user LIMIT and offset=0)
|
||||
total := 0
|
||||
@@ -643,7 +644,8 @@ func executeSQLQuery(instance *model.Instance, dbName string, sqlStr string, lim
|
||||
|
||||
// Inject LIMIT/OFFSET if not already present — only for SELECT statements
|
||||
// SHOW/DESC/EXPLAIN/CREATE/ALTER/DROP/etc do not support LIMIT syntax
|
||||
needsLimit := !hasLimit && strings.HasPrefix(upperSQL, "SELECT")
|
||||
autoLimit := getSetting("query_auto_limit", "true") == "true"
|
||||
needsLimit := autoLimit && !hasLimit && isLimitableReadSQL(upperSQL)
|
||||
if needsLimit {
|
||||
sqlStr = fmt.Sprintf("%s LIMIT %d OFFSET %d", sqlStr, limit, offset)
|
||||
}
|
||||
@@ -689,6 +691,82 @@ func executeSQLQuery(instance *model.Instance, dbName string, sqlStr string, lim
|
||||
}, nil
|
||||
}
|
||||
|
||||
func isReadOnlySQL(sqlStr string) (bool, string) {
|
||||
cleaned := strings.TrimSpace(stripLeadingSQLComments(sqlStr))
|
||||
if cleaned == "" {
|
||||
return false, "空语句"
|
||||
}
|
||||
if hasMultipleSQLStatements(cleaned) {
|
||||
return false, "不允许一次执行多条语句"
|
||||
}
|
||||
|
||||
upper := strings.ToUpper(cleaned)
|
||||
readPrefixes := []string{"SELECT", "SHOW", "DESCRIBE", "DESC", "EXPLAIN", "WITH"}
|
||||
allowed := false
|
||||
for _, prefix := range readPrefixes {
|
||||
if strings.HasPrefix(upper, prefix+" ") || upper == prefix {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
return false, "只允许 SELECT / SHOW / DESCRIBE / EXPLAIN"
|
||||
}
|
||||
|
||||
blockedPatterns := []string{
|
||||
" FOR UPDATE", " LOCK IN SHARE MODE", " INTO OUTFILE", " INTO DUMPFILE",
|
||||
"EXPLAIN ANALYZE", "DELETE ", "UPDATE ", "INSERT ", "REPLACE ",
|
||||
"CREATE ", "ALTER ", "DROP ", "TRUNCATE ", "RENAME ", "GRANT ", "REVOKE ",
|
||||
"CALL ", "DO ", "LOAD DATA", "COPY ",
|
||||
}
|
||||
for _, pattern := range blockedPatterns {
|
||||
if strings.Contains(upper, pattern) {
|
||||
return false, strings.TrimSpace(pattern)
|
||||
}
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
func isLimitableReadSQL(upperSQL string) bool {
|
||||
return strings.HasPrefix(upperSQL, "SELECT") || strings.HasPrefix(upperSQL, "WITH")
|
||||
}
|
||||
|
||||
func hasMultipleSQLStatements(sqlStr string) bool {
|
||||
trimmed := strings.TrimSpace(sqlStr)
|
||||
if strings.HasSuffix(trimmed, ";") {
|
||||
trimmed = strings.TrimSpace(strings.TrimSuffix(trimmed, ";"))
|
||||
}
|
||||
return strings.Contains(trimmed, ";")
|
||||
}
|
||||
|
||||
func stripLeadingSQLComments(sqlStr string) string {
|
||||
s := strings.TrimSpace(sqlStr)
|
||||
for {
|
||||
switch {
|
||||
case strings.HasPrefix(s, "--"):
|
||||
if idx := strings.Index(s, "\n"); idx >= 0 {
|
||||
s = strings.TrimSpace(s[idx+1:])
|
||||
continue
|
||||
}
|
||||
return ""
|
||||
case strings.HasPrefix(s, "#"):
|
||||
if idx := strings.Index(s, "\n"); idx >= 0 {
|
||||
s = strings.TrimSpace(s[idx+1:])
|
||||
continue
|
||||
}
|
||||
return ""
|
||||
case strings.HasPrefix(s, "/*"):
|
||||
if idx := strings.Index(s, "*/"); idx >= 0 {
|
||||
s = strings.TrimSpace(s[idx+2:])
|
||||
continue
|
||||
}
|
||||
return s
|
||||
default:
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseCommandParts splits a command string into parts, respecting quoted strings.
|
||||
// e.g. HGET "my key" "my field" => ["HGET", "my key", "my field"]
|
||||
func parseCommandParts(input string) []string {
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// Helper: get Redis client from instance (auto-detect standalone vs cluster)
|
||||
type redisClientWrapper struct {
|
||||
Client *redis.Client
|
||||
@@ -21,7 +22,6 @@ type redisClientWrapper struct {
|
||||
}
|
||||
|
||||
func (w *redisClientWrapper) Close() {
|
||||
// Always close the single-node Client (cluster uses single-node now)
|
||||
if w.Client != nil {
|
||||
w.Client.Close()
|
||||
}
|
||||
@@ -30,19 +30,24 @@ func (w *redisClientWrapper) Close() {
|
||||
}
|
||||
}
|
||||
|
||||
// Ping, Info, DBSize, Scan, Type, TTL, Get, LRange, SMembers, ZRangeWithScores, HGetAll,
|
||||
// SlowLogGet, ClientList, Do, Set, RPush, SAdd, HSet, Expire, Del — unified interface via wrapper methods
|
||||
// NOTE: All methods use Client (single-node). isCluster flag is only for ScanAllKeys behavior.
|
||||
|
||||
func (w *redisClientWrapper) Ping(ctx context.Context) *redis.StatusCmd {
|
||||
if w.isCluster && w.ClusterClient != nil {
|
||||
return w.ClusterClient.Ping(ctx)
|
||||
}
|
||||
return w.Client.Ping(ctx)
|
||||
}
|
||||
|
||||
func (w *redisClientWrapper) Info(ctx context.Context, sections ...string) *redis.StringCmd {
|
||||
if w.isCluster && w.ClusterClient != nil {
|
||||
return w.ClusterClient.Info(ctx, sections...)
|
||||
}
|
||||
return w.Client.Info(ctx, sections...)
|
||||
}
|
||||
|
||||
func (w *redisClientWrapper) DBSize(ctx context.Context) *redis.IntCmd {
|
||||
if w.isCluster && w.ClusterClient != nil {
|
||||
return w.ClusterClient.DBSize(ctx)
|
||||
}
|
||||
return w.Client.DBSize(ctx)
|
||||
}
|
||||
|
||||
@@ -50,9 +55,27 @@ func (w *redisClientWrapper) Scan(ctx context.Context, cursor uint64, match stri
|
||||
return w.Client.Scan(ctx, cursor, match, count)
|
||||
}
|
||||
|
||||
// ScanAllKeys scans keys on the connected node.
|
||||
// For clusters, this only scans keys on the single connected node (not all masters).
|
||||
func (w *redisClientWrapper) ScanAllKeys(ctx context.Context, match string, count int) ([]string, error) {
|
||||
if w.isCluster && w.ClusterClient != nil {
|
||||
var allKeys []string
|
||||
err := w.ClusterClient.ForEachMaster(ctx, func(ctx context.Context, shard *redis.Client) error {
|
||||
cursor := uint64(0)
|
||||
for {
|
||||
keys, nextCursor, err := shard.Scan(ctx, cursor, match, int64(count)).Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
allKeys = append(allKeys, keys...)
|
||||
cursor = nextCursor
|
||||
if cursor == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return allKeys, err
|
||||
}
|
||||
|
||||
var allKeys []string
|
||||
cursor := uint64(0)
|
||||
for {
|
||||
@@ -70,30 +93,51 @@ func (w *redisClientWrapper) ScanAllKeys(ctx context.Context, match string, coun
|
||||
}
|
||||
|
||||
func (w *redisClientWrapper) Type(ctx context.Context, key string) *redis.StatusCmd {
|
||||
if w.isCluster && w.ClusterClient != nil {
|
||||
return w.ClusterClient.Type(ctx, key)
|
||||
}
|
||||
return w.Client.Type(ctx, key)
|
||||
}
|
||||
|
||||
func (w *redisClientWrapper) TTL(ctx context.Context, key string) *redis.DurationCmd {
|
||||
if w.isCluster && w.ClusterClient != nil {
|
||||
return w.ClusterClient.TTL(ctx, key)
|
||||
}
|
||||
return w.Client.TTL(ctx, key)
|
||||
}
|
||||
|
||||
func (w *redisClientWrapper) Get(ctx context.Context, key string) *redis.StringCmd {
|
||||
if w.isCluster && w.ClusterClient != nil {
|
||||
return w.ClusterClient.Get(ctx, key)
|
||||
}
|
||||
return w.Client.Get(ctx, key)
|
||||
}
|
||||
|
||||
func (w *redisClientWrapper) LRange(ctx context.Context, key string, start, stop int64) *redis.StringSliceCmd {
|
||||
if w.isCluster && w.ClusterClient != nil {
|
||||
return w.ClusterClient.LRange(ctx, key, start, stop)
|
||||
}
|
||||
return w.Client.LRange(ctx, key, start, stop)
|
||||
}
|
||||
|
||||
func (w *redisClientWrapper) SMembers(ctx context.Context, key string) *redis.StringSliceCmd {
|
||||
if w.isCluster && w.ClusterClient != nil {
|
||||
return w.ClusterClient.SMembers(ctx, key)
|
||||
}
|
||||
return w.Client.SMembers(ctx, key)
|
||||
}
|
||||
|
||||
func (w *redisClientWrapper) ZRangeWithScores(ctx context.Context, key string, start, stop int64) *redis.ZSliceCmd {
|
||||
if w.isCluster && w.ClusterClient != nil {
|
||||
return w.ClusterClient.ZRangeWithScores(ctx, key, start, stop)
|
||||
}
|
||||
return w.Client.ZRangeWithScores(ctx, key, start, stop)
|
||||
}
|
||||
|
||||
func (w *redisClientWrapper) HGetAll(ctx context.Context, key string) *redis.MapStringStringCmd {
|
||||
if w.isCluster && w.ClusterClient != nil {
|
||||
return w.ClusterClient.HGetAll(ctx, key)
|
||||
}
|
||||
return w.Client.HGetAll(ctx, key)
|
||||
}
|
||||
|
||||
@@ -106,42 +150,72 @@ func (w *redisClientWrapper) ClientList(ctx context.Context) *redis.StringCmd {
|
||||
}
|
||||
|
||||
func (w *redisClientWrapper) Do(ctx context.Context, args ...interface{}) *redis.Cmd {
|
||||
if w.isCluster && w.ClusterClient != nil {
|
||||
return w.ClusterClient.Do(ctx, args...)
|
||||
}
|
||||
return w.Client.Do(ctx, args...)
|
||||
}
|
||||
|
||||
func (w *redisClientWrapper) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) *redis.StatusCmd {
|
||||
if w.isCluster && w.ClusterClient != nil {
|
||||
return w.ClusterClient.Set(ctx, key, value, expiration)
|
||||
}
|
||||
return w.Client.Set(ctx, key, value, expiration)
|
||||
}
|
||||
|
||||
func (w *redisClientWrapper) RPush(ctx context.Context, key string, values ...interface{}) *redis.IntCmd {
|
||||
if w.isCluster && w.ClusterClient != nil {
|
||||
return w.ClusterClient.RPush(ctx, key, values...)
|
||||
}
|
||||
return w.Client.RPush(ctx, key, values...)
|
||||
}
|
||||
|
||||
func (w *redisClientWrapper) SAdd(ctx context.Context, key string, members ...interface{}) *redis.IntCmd {
|
||||
if w.isCluster && w.ClusterClient != nil {
|
||||
return w.ClusterClient.SAdd(ctx, key, members...)
|
||||
}
|
||||
return w.Client.SAdd(ctx, key, members...)
|
||||
}
|
||||
|
||||
func (w *redisClientWrapper) HSet(ctx context.Context, key string, values ...interface{}) *redis.IntCmd {
|
||||
if w.isCluster && w.ClusterClient != nil {
|
||||
return w.ClusterClient.HSet(ctx, key, values...)
|
||||
}
|
||||
return w.Client.HSet(ctx, key, values...)
|
||||
}
|
||||
|
||||
func (w *redisClientWrapper) Expire(ctx context.Context, key string, expiration time.Duration) *redis.BoolCmd {
|
||||
if w.isCluster && w.ClusterClient != nil {
|
||||
return w.ClusterClient.Expire(ctx, key, expiration)
|
||||
}
|
||||
return w.Client.Expire(ctx, key, expiration)
|
||||
}
|
||||
|
||||
func (w *redisClientWrapper) Del(ctx context.Context, keys ...string) *redis.IntCmd {
|
||||
if w.isCluster && w.ClusterClient != nil {
|
||||
return w.ClusterClient.Del(ctx, keys...)
|
||||
}
|
||||
return w.Client.Del(ctx, keys...)
|
||||
}
|
||||
|
||||
func (w *redisClientWrapper) LLen(ctx context.Context, key string) *redis.IntCmd {
|
||||
if w.isCluster && w.ClusterClient != nil {
|
||||
return w.ClusterClient.LLen(ctx, key)
|
||||
}
|
||||
return w.Client.LLen(ctx, key)
|
||||
}
|
||||
|
||||
func (w *redisClientWrapper) SCard(ctx context.Context, key string) *redis.IntCmd {
|
||||
if w.isCluster && w.ClusterClient != nil {
|
||||
return w.ClusterClient.SCard(ctx, key)
|
||||
}
|
||||
return w.Client.SCard(ctx, key)
|
||||
}
|
||||
|
||||
func (w *redisClientWrapper) SScan(ctx context.Context, key string, cursor uint64, match string, count int64) *redis.ScanCmd {
|
||||
if w.isCluster && w.ClusterClient != nil {
|
||||
return w.ClusterClient.SScan(ctx, key, cursor, match, count)
|
||||
}
|
||||
return w.Client.SScan(ctx, key, cursor, match, count)
|
||||
}
|
||||
|
||||
@@ -162,10 +236,6 @@ func getRedisClient(instance *model.Instance, db int) (*redisClientWrapper, erro
|
||||
redisUsername = "" // go-redis treats empty Username as no ACL auth
|
||||
}
|
||||
|
||||
// Always use single-node connection for query execution.
|
||||
// ClusterClient does topology discovery (CLUSTER SLOTS) which fails when
|
||||
// internal cluster IPs are unreachable from the client (e.g., Docker networks).
|
||||
// Single-node mode works for all read-only commands on cluster nodes.
|
||||
client := redis.NewClient(&redis.Options{
|
||||
Addr: addr,
|
||||
Username: redisUsername,
|
||||
@@ -180,7 +250,48 @@ func getRedisClient(instance *model.Instance, db int) (*redisClientWrapper, erro
|
||||
|
||||
// Check cluster mode flag for wrapper behavior (ScanAllKeys needs to know)
|
||||
isCluster := instance.ClusterMode != nil && *instance.ClusterMode
|
||||
return &redisClientWrapper{Client: client, isCluster: isCluster}, nil
|
||||
wrapper := &redisClientWrapper{Client: client, isCluster: isCluster}
|
||||
if !isCluster {
|
||||
return wrapper, nil
|
||||
}
|
||||
|
||||
// Redis Cluster routes key commands by slot. Build the address list from
|
||||
// CLUSTER SLOTS, but replace advertised internal node IPs with the configured
|
||||
// host so Docker/NAT deployments can still reach the mapped node ports.
|
||||
addrs := []string{addr}
|
||||
if slots, err := client.ClusterSlots(ctx).Result(); err == nil && len(slots) > 0 {
|
||||
seen := map[string]bool{}
|
||||
addrs = addrs[:0]
|
||||
for _, slot := range slots {
|
||||
if len(slot.Nodes) == 0 {
|
||||
continue
|
||||
}
|
||||
nodeAddr := slot.Nodes[0].Addr
|
||||
if idx := strings.LastIndex(nodeAddr, ":"); idx >= 0 {
|
||||
nodeAddr = instance.Host + nodeAddr[idx:]
|
||||
}
|
||||
if !seen[nodeAddr] {
|
||||
seen[nodeAddr] = true
|
||||
addrs = append(addrs, nodeAddr)
|
||||
}
|
||||
}
|
||||
if len(addrs) == 0 {
|
||||
addrs = []string{addr}
|
||||
}
|
||||
}
|
||||
|
||||
clusterClient := redis.NewClusterClient(&redis.ClusterOptions{
|
||||
Addrs: addrs,
|
||||
Username: redisUsername,
|
||||
Password: password,
|
||||
})
|
||||
if err := clusterClient.Ping(ctx).Err(); err != nil {
|
||||
clusterClient.Close()
|
||||
client.Close()
|
||||
return nil, fmt.Errorf("Redis cluster connection failed: %v", err)
|
||||
}
|
||||
wrapper.ClusterClient = clusterClient
|
||||
return wrapper, nil
|
||||
}
|
||||
|
||||
// GET /api/v1/redis/:id/info
|
||||
@@ -265,11 +376,12 @@ func RedisDbsize(c *gin.Context) {
|
||||
func RedisScanKeys(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
dbParam := c.Query("db")
|
||||
pattern := c.DefaultQuery("pattern", "*")
|
||||
// Auto-append * if pattern has no wildcard (user-friendly fuzzy search)
|
||||
if !strings.Contains(pattern, "*") && !strings.Contains(pattern, "?") && !strings.Contains(pattern, "[") {
|
||||
pattern = pattern + "*"
|
||||
rawPattern := strings.TrimSpace(c.DefaultQuery("pattern", "*"))
|
||||
if rawPattern == "" {
|
||||
rawPattern = "*"
|
||||
}
|
||||
pattern := rawPattern
|
||||
hasWildcard := strings.ContainsAny(pattern, "*?[")
|
||||
count, _ := strconv.Atoi(c.DefaultQuery("count", "100"))
|
||||
|
||||
db := 0
|
||||
@@ -291,11 +403,6 @@ func RedisScanKeys(c *gin.Context) {
|
||||
defer client.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
allKeys, err := client.ScanAllKeys(ctx, pattern, count)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Get types for each key
|
||||
type KeyInfo struct {
|
||||
@@ -303,6 +410,37 @@ func RedisScanKeys(c *gin.Context) {
|
||||
Type string `json:"type"`
|
||||
TTL int64 `json:"ttl"`
|
||||
}
|
||||
|
||||
// Redis Cluster 精确 key 查询应优先走槽位路由直查。全量 SCAN 需要访问各 master,
|
||||
// 在 NAT/代理暴露单入口时容易受节点通告地址影响。
|
||||
if !hasWildcard && pattern != "*" {
|
||||
keyType, err := client.Type(ctx, pattern).Result()
|
||||
if err == nil && keyType != "none" {
|
||||
ttl, _ := client.TTL(ctx, pattern).Result()
|
||||
ttlMs := int64(-1)
|
||||
if ttl > 0 {
|
||||
ttlMs = ttl.Milliseconds()
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"keys": []KeyInfo{{Key: pattern, Type: keyType, TTL: ttlMs}},
|
||||
"cursor": 0,
|
||||
"total": 1,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-append * if pattern has no wildcard (user-friendly fuzzy search)
|
||||
if !hasWildcard {
|
||||
pattern = pattern + "*"
|
||||
}
|
||||
|
||||
allKeys, err := client.ScanAllKeys(ctx, pattern, count)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var keyInfos []KeyInfo
|
||||
for _, key := range allKeys {
|
||||
keyType, _ := client.Type(ctx, key).Result()
|
||||
@@ -498,7 +636,7 @@ func RedisExecuteCommand(c *gin.Context) {
|
||||
|
||||
ctx := context.Background()
|
||||
cmd := strings.ToUpper(input.Command)
|
||||
|
||||
|
||||
// Safe read-only commands
|
||||
readOnly := map[string]bool{
|
||||
"GET": true, "MGET": true, "HGET": true, "HGETALL": true,
|
||||
@@ -508,7 +646,7 @@ func RedisExecuteCommand(c *gin.Context) {
|
||||
"STRLEN": true, "LLEN": true, "SCARD": true, "ZCARD": true,
|
||||
"HLEN": true, "GETRANGE": true,
|
||||
}
|
||||
|
||||
|
||||
if !readOnly[cmd] {
|
||||
c.JSON(http.StatusForbidden, gin.H{"detail": "Only read-only commands allowed via query interface"})
|
||||
return
|
||||
@@ -547,10 +685,10 @@ func RedisCreateKey(c *gin.Context) {
|
||||
}
|
||||
|
||||
var input struct {
|
||||
Key string `json:"key" binding:"required"`
|
||||
Type string `json:"type" binding:"required"`
|
||||
Value interface{} `json:"value"`
|
||||
TTL int `json:"ttl"`
|
||||
Key string `json:"key" binding:"required"`
|
||||
Type string `json:"type" binding:"required"`
|
||||
Value interface{} `json:"value"`
|
||||
TTL int `json:"ttl"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
@@ -571,7 +709,7 @@ func RedisCreateKey(c *gin.Context) {
|
||||
defer client.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
|
||||
switch input.Type {
|
||||
case "string":
|
||||
client.Set(ctx, input.Key, input.Value, 0)
|
||||
|
||||
@@ -10,6 +10,14 @@ import (
|
||||
"sql-platform/internal/model"
|
||||
)
|
||||
|
||||
func stringPtr(value string) *string {
|
||||
return &value
|
||||
}
|
||||
|
||||
func boolPtr(value bool) *bool {
|
||||
return &value
|
||||
}
|
||||
|
||||
type AuthService struct {
|
||||
DB *gorm.DB
|
||||
}
|
||||
@@ -25,7 +33,7 @@ func (s *AuthService) Login(username, password string) (*model.User, string, err
|
||||
return nil, "", errors.New("invalid username or password")
|
||||
}
|
||||
|
||||
if !user.IsActive {
|
||||
if user.IsActive != nil && !*user.IsActive {
|
||||
return nil, "", errors.New("account is disabled")
|
||||
}
|
||||
|
||||
@@ -33,7 +41,7 @@ func (s *AuthService) Login(username, password string) (*model.User, string, err
|
||||
return nil, "", errors.New("invalid username or password")
|
||||
}
|
||||
|
||||
token, err := middleware.GenerateToken(user.ID, user.Username, user.Role)
|
||||
token, err := middleware.GenerateToken(&user)
|
||||
if err != nil {
|
||||
return nil, "", errors.New("failed to generate token")
|
||||
}
|
||||
@@ -67,11 +75,11 @@ func (s *AuthService) Register(username, displayName, email, phone, password, ro
|
||||
user := model.User{
|
||||
Username: username,
|
||||
DisplayName: displayName,
|
||||
Email: email,
|
||||
Phone: phone,
|
||||
Email: stringPtr(email),
|
||||
Phone: stringPtr(phone),
|
||||
HashedPassword: string(hashedPassword),
|
||||
Role: role,
|
||||
IsActive: true,
|
||||
IsActive: boolPtr(true),
|
||||
}
|
||||
|
||||
if err := s.DB.Create(&user).Error; err != nil {
|
||||
|
||||
@@ -19,14 +19,16 @@ func NewTicketService(db *gorm.DB) *TicketService {
|
||||
|
||||
// CreateTicket creates a new ticket
|
||||
func (s *TicketService) CreateTicket(ticket *model.Ticket) error {
|
||||
if ticket.TicketNo == "" {
|
||||
ticket.TicketNo = s.generateTicketNo()
|
||||
if ticket.TicketNo == nil || *ticket.TicketNo == "" {
|
||||
ticketNo := s.generateTicketNo()
|
||||
ticket.TicketNo = &ticketNo
|
||||
}
|
||||
if ticket.CreateTime.IsZero() {
|
||||
ticket.CreateTime = time.Now()
|
||||
}
|
||||
if ticket.Status == "" {
|
||||
ticket.Status = "pending"
|
||||
if ticket.Status == nil || *ticket.Status == "" {
|
||||
status := "pending"
|
||||
ticket.Status = &status
|
||||
}
|
||||
return s.DB.Create(ticket).Error
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user