@@ -4,38 +4,57 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"ymhut-box/server/unified-management/internal/config"
|
||||
"ymhut-box/server/unified-management/internal/db"
|
||||
"ymhut-box/server/unified-management/internal/health"
|
||||
feedbackmail "ymhut-box/server/unified-management/internal/mail"
|
||||
)
|
||||
|
||||
func (r *router) handleAdminDatabase(w http.ResponseWriter, req *http.Request) {
|
||||
path := cleanPath(req.URL.Path)
|
||||
switch {
|
||||
case req.Method == http.MethodGet && path == "/api/admin/database/config":
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "config": config.SafeDatabase(r.cfg.BaseDir, r.cfg.Database)})
|
||||
case req.Method == http.MethodGet && path == "/api/admin/database/status":
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "database": r.store.Status()})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "database": r.store.Status(), "config": config.SafeDatabase(r.cfg.BaseDir, r.cfg.Database)})
|
||||
case req.Method == http.MethodPost && path == "/api/admin/database/test":
|
||||
var body config.DatabaseConfig
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
body, err := decodeAdminDatabaseConfig(req, r.cfg.BaseDir, r.cfg.Database, true)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
||||
return
|
||||
}
|
||||
if body.Provider == "" {
|
||||
body.Provider = r.cfg.Database.Provider
|
||||
}
|
||||
if body.SQLitePath == "" {
|
||||
body.SQLitePath = r.cfg.Database.SQLitePath
|
||||
}
|
||||
if body.MySQLDSN == "" {
|
||||
body.MySQLDSN = r.cfg.Database.MySQLDSN
|
||||
}
|
||||
if err := db.TestDatabase(body); err != nil {
|
||||
writeError(w, http.StatusBadGateway, "DATABASE_TEST_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "config": config.SafeDatabase(r.cfg.BaseDir, body)})
|
||||
case req.Method == http.MethodPost && path == "/api/admin/database/save":
|
||||
body, err := decodeAdminDatabaseConfig(req, r.cfg.BaseDir, r.cfg.Database, true)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
||||
return
|
||||
}
|
||||
if err := db.TestDatabase(body); err != nil {
|
||||
writeError(w, http.StatusBadGateway, "DATABASE_TEST_FAILED", err)
|
||||
return
|
||||
}
|
||||
next := *r.cfg
|
||||
next.Database = body
|
||||
if err := config.Save(&next); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "DATABASE_SAVE_FAILED", err)
|
||||
return
|
||||
}
|
||||
r.cfg.Database = next.Database
|
||||
if err := r.store.ReconfigureDatabase(r.cfg); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "DATABASE_SAVE_FAILED", err)
|
||||
return
|
||||
}
|
||||
_ = r.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "system.database.saved", Target: body.Provider, Message: "数据库配置已保存并热切换", IP: req.RemoteAddr, UserAgent: req.UserAgent()})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "database": r.store.Status(), "config": config.SafeDatabase(r.cfg.BaseDir, r.cfg.Database)})
|
||||
case req.Method == http.MethodPost && path == "/api/admin/database/import-sqlite":
|
||||
result, err := r.store.ImportSQLiteToRemote()
|
||||
if err != nil {
|
||||
@@ -55,6 +74,52 @@ func (r *router) handleAdminDatabase(w http.ResponseWriter, req *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
type adminDatabaseRequest struct {
|
||||
Provider string `json:"provider"`
|
||||
SQLitePath string `json:"sqlite_path"`
|
||||
SQLitePathAlt string `json:"sqlitePath"`
|
||||
MySQLDSN string `json:"mysql_dsn"`
|
||||
MySQLDSNAlt string `json:"mysqlDsn"`
|
||||
MySQLHost string `json:"mysql_host"`
|
||||
MySQLHostAlt string `json:"mysqlHost"`
|
||||
MySQLPort int `json:"mysql_port"`
|
||||
MySQLPortAlt int `json:"mysqlPort"`
|
||||
MySQLDatabase string `json:"mysql_database"`
|
||||
MySQLDBAlt string `json:"mysqlDatabase"`
|
||||
MySQLUser string `json:"mysql_user"`
|
||||
MySQLUserAlt string `json:"mysqlUser"`
|
||||
MySQLPassword string `json:"mysql_password"`
|
||||
MySQLPassAlt string `json:"mysqlPassword"`
|
||||
MySQL config.MySQLInput `json:"mysql"`
|
||||
}
|
||||
|
||||
func decodeAdminDatabaseConfig(req *http.Request, baseDir string, current config.DatabaseConfig, keepPassword bool) (config.DatabaseConfig, error) {
|
||||
var body adminDatabaseRequest
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
return config.DatabaseConfig{}, err
|
||||
}
|
||||
incoming := config.DatabaseConfig{
|
||||
Provider: body.Provider,
|
||||
SQLitePath: firstNonEmpty(body.SQLitePath, body.SQLitePathAlt),
|
||||
MySQLDSN: firstNonEmpty(body.MySQLDSN, body.MySQLDSNAlt),
|
||||
MySQLHost: firstNonEmpty(body.MySQLHost, body.MySQLHostAlt, body.MySQL.Host),
|
||||
MySQLPort: firstPositive(body.MySQLPort, body.MySQLPortAlt, body.MySQL.Port),
|
||||
MySQLDatabase: firstNonEmpty(body.MySQLDatabase, body.MySQLDBAlt, body.MySQL.Database),
|
||||
MySQLUser: firstNonEmpty(body.MySQLUser, body.MySQLUserAlt, body.MySQL.Username),
|
||||
MySQLPassword: firstNonEmpty(body.MySQLPassword, body.MySQLPassAlt, body.MySQL.Password),
|
||||
}
|
||||
return config.NormalizeDatabase(baseDir, current, incoming, keepPassword)
|
||||
}
|
||||
|
||||
func firstPositive(values ...int) int {
|
||||
for _, value := range values {
|
||||
if value > 0 {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (r *router) handleAdminDashboard(w http.ResponseWriter, req *http.Request) {
|
||||
path := cleanPath(req.URL.Path)
|
||||
if req.Method != http.MethodGet || path != "/api/admin/dashboard/overview" {
|
||||
@@ -141,12 +206,26 @@ func (r *router) handleAdminSystem(w http.ResponseWriter, req *http.Request) {
|
||||
case "/api/admin/system/health":
|
||||
writeJSON(w, http.StatusOK, health.Snapshot(r.cfg, r.store))
|
||||
case "/api/admin/system/audit":
|
||||
items, err := r.store.ListAuditLogs(100)
|
||||
page, err := r.store.ListAuditLogsPage(db.AuditFilters{
|
||||
Page: queryInt(req, "page", 1),
|
||||
PerPage: queryInt(req, "perPage", 35),
|
||||
Type: req.URL.Query().Get("type"),
|
||||
Target: req.URL.Query().Get("target"),
|
||||
Query: req.URL.Query().Get("q"),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "AUDIT_FAILED", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": items})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": page.Items, "page": page})
|
||||
case "/api/admin/system/mail/config":
|
||||
r.handleMailConfig(w, req)
|
||||
case "/api/admin/system/mail/test":
|
||||
r.handleMailTest(w, req)
|
||||
case "/api/admin/system/branding":
|
||||
r.handleBranding(w, req)
|
||||
case "/api/admin/system/migration":
|
||||
r.handleMigrationStatus(w, req)
|
||||
case "/api/admin/system/database/sync":
|
||||
if req.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("POST required"))
|
||||
@@ -162,3 +241,188 @@ func (r *router) handleAdminSystem(w http.ResponseWriter, req *http.Request) {
|
||||
http.NotFound(w, req)
|
||||
}
|
||||
}
|
||||
|
||||
func queryInt(req *http.Request, key string, fallback int) int {
|
||||
value, err := strconv.Atoi(req.URL.Query().Get(key))
|
||||
if err != nil || value <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func (r *router) handleMigrationStatus(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("GET required"))
|
||||
return
|
||||
}
|
||||
status := r.store.Status()
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"migration": map[string]any{
|
||||
"strategy": "database_first_with_file_assets",
|
||||
"databaseCovers": []string{
|
||||
"系统设置与品牌",
|
||||
"管理员与会话元数据",
|
||||
"反馈工单、附件元数据与邮件记录",
|
||||
"来源目录、客户端接口与健康记录",
|
||||
"发布元数据、版本公告与兼容 JSON 修订",
|
||||
"审计日志、旧项目同步记录与数据库同步状态",
|
||||
},
|
||||
"fileAssets": []map[string]string{
|
||||
{"name": "downloads", "path": r.cfg.DownloadsDir, "description": "发布包和下载文件"},
|
||||
{"name": "update public", "path": r.cfg.UpdatePublicDir, "description": "旧客户端兼容 JSON 生成物"},
|
||||
{"name": "feedback packages", "path": filepath.Join(r.cfg.StorageDir, "feedback-packages"), "description": "反馈附件包"},
|
||||
},
|
||||
"sqlitePath": r.store.Path(),
|
||||
"mysql": config.SafeDatabase(r.cfg.BaseDir, r.cfg.Database),
|
||||
"lastSyncAt": status.LastSyncAt,
|
||||
"lastSyncError": status.LastSyncError,
|
||||
"activeProvider": status.ActiveProvider,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (r *router) handleBranding(w http.ResponseWriter, req *http.Request) {
|
||||
switch req.Method {
|
||||
case http.MethodGet:
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "branding": config.SafeBranding(r.effectiveBranding())})
|
||||
case http.MethodPost:
|
||||
var body struct {
|
||||
SiteIconURL string `json:"siteIconUrl"`
|
||||
SiteIconURLSnake string `json:"site_icon_url"`
|
||||
DeveloperAvatarURL string `json:"developerAvatarUrl"`
|
||||
DeveloperAvatarAlt string `json:"developer_avatar_url"`
|
||||
DeveloperName string `json:"developerName"`
|
||||
DeveloperNameSnake string `json:"developer_name"`
|
||||
FeedbackEmail string `json:"feedbackEmail"`
|
||||
FeedbackEmailSnake string `json:"feedback_email"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
||||
return
|
||||
}
|
||||
next := config.BrandingConfig{
|
||||
SiteIconURL: firstNonEmpty(body.SiteIconURL, body.SiteIconURLSnake),
|
||||
DeveloperAvatarURL: firstNonEmpty(body.DeveloperAvatarURL, body.DeveloperAvatarAlt),
|
||||
DeveloperName: firstNonEmpty(body.DeveloperName, body.DeveloperNameSnake),
|
||||
FeedbackEmail: firstNonEmpty(body.FeedbackEmail, body.FeedbackEmailSnake),
|
||||
}
|
||||
if err := r.saveBranding(next); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "BRANDING_SAVE_FAILED", err)
|
||||
return
|
||||
}
|
||||
_ = r.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "system.branding.saved", Target: r.cfg.Branding.DeveloperName, Message: "站点品牌信息已保存", IP: req.RemoteAddr, UserAgent: req.UserAgent()})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "branding": config.SafeBranding(r.effectiveBranding())})
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("GET or POST required"))
|
||||
}
|
||||
}
|
||||
|
||||
func (r *router) handleMailConfig(w http.ResponseWriter, req *http.Request) {
|
||||
switch req.Method {
|
||||
case http.MethodGet:
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "config": feedbackmail.SafeConfig(r.cfg.Mail)})
|
||||
case http.MethodPost:
|
||||
nextMail, err := decodeMailConfig(req, r.cfg.Mail)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
||||
return
|
||||
}
|
||||
next := *r.cfg
|
||||
next.Mail = nextMail
|
||||
if err := config.Save(&next); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "MAIL_CONFIG_FAILED", err)
|
||||
return
|
||||
}
|
||||
r.cfg.Mail = next.Mail
|
||||
_ = r.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "system.mail.saved", Target: nextMail.Host, Message: "邮件通知配置已保存", IP: req.RemoteAddr, UserAgent: req.UserAgent()})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "config": feedbackmail.SafeConfig(r.cfg.Mail)})
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("GET or POST required"))
|
||||
}
|
||||
}
|
||||
|
||||
func (r *router) handleMailTest(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("POST required"))
|
||||
return
|
||||
}
|
||||
message, err := feedbackmail.BuildTestMessage(r.cfg)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "MAIL_TEST_FAILED", err)
|
||||
return
|
||||
}
|
||||
if err := feedbackmail.Send(r.cfg, message); err != nil {
|
||||
writeError(w, http.StatusBadGateway, "MAIL_TEST_FAILED", err)
|
||||
return
|
||||
}
|
||||
_ = r.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "system.mail.test", Target: message.To, Message: "测试邮件已发送", IP: req.RemoteAddr, UserAgent: req.UserAgent()})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
type mailConfigRequest struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Secure string `json:"secure"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
FromAddress string `json:"from_address"`
|
||||
FromAddressAlt string `json:"fromAddress"`
|
||||
FromName string `json:"from_name"`
|
||||
FromNameAlt string `json:"fromName"`
|
||||
DeveloperAddress string `json:"developer_address"`
|
||||
DeveloperAlt string `json:"developerAddress"`
|
||||
TimeoutSeconds int `json:"timeout_seconds"`
|
||||
TimeoutAlt int `json:"timeoutSeconds"`
|
||||
}
|
||||
|
||||
func decodeMailConfig(req *http.Request, current config.MailConfig) (config.MailConfig, error) {
|
||||
var body mailConfigRequest
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
return config.MailConfig{}, err
|
||||
}
|
||||
next := current
|
||||
if body.Host != "" {
|
||||
next.Host = body.Host
|
||||
}
|
||||
if body.Port > 0 {
|
||||
next.Port = body.Port
|
||||
}
|
||||
if body.Secure != "" {
|
||||
next.Secure = body.Secure
|
||||
}
|
||||
if body.Username != "" {
|
||||
next.Username = body.Username
|
||||
}
|
||||
if body.Password != "" {
|
||||
next.Password = body.Password
|
||||
}
|
||||
if value := firstNonEmpty(body.FromAddress, body.FromAddressAlt); value != "" {
|
||||
next.FromAddress = value
|
||||
}
|
||||
if value := firstNonEmpty(body.FromName, body.FromNameAlt); value != "" {
|
||||
next.FromName = value
|
||||
}
|
||||
if value := firstNonEmpty(body.DeveloperAddress, body.DeveloperAlt); value != "" {
|
||||
next.DeveloperAddress = value
|
||||
}
|
||||
if timeout := firstPositive(body.TimeoutSeconds, body.TimeoutAlt); timeout > 0 {
|
||||
next.TimeoutSeconds = timeout
|
||||
}
|
||||
if next.Port <= 0 {
|
||||
next.Port = 465
|
||||
}
|
||||
if next.Secure == "" {
|
||||
next.Secure = "ssl"
|
||||
}
|
||||
if next.FromName == "" {
|
||||
next.FromName = "YMhut Box Feedback"
|
||||
}
|
||||
if next.FromAddress == "" {
|
||||
next.FromAddress = next.Username
|
||||
}
|
||||
if next.TimeoutSeconds <= 0 {
|
||||
next.TimeoutSeconds = 20
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user