package web 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(), "config": config.SafeDatabase(r.cfg.BaseDir, r.cfg.Database)}) case req.Method == http.MethodPost && path == "/api/admin/database/test": 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 } 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 { writeError(w, http.StatusBadGateway, "DATABASE_IMPORT_FAILED", err) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true, "result": result}) case req.Method == http.MethodPost && path == "/api/admin/database/sync": result, err := r.store.SyncNow() if err != nil { writeError(w, http.StatusBadGateway, "DATABASE_SYNC_FAILED", err) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true, "result": result}) default: http.NotFound(w, req) } } 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" { http.NotFound(w, req) return } overview, err := r.store.DashboardOverview(80) if err != nil { writeError(w, http.StatusInternalServerError, "DASHBOARD_FAILED", err) return } overview["health"] = health.Snapshot(r.cfg, r.store) writeJSON(w, http.StatusOK, overview) } func (r *router) handleAdminSync(w http.ResponseWriter, req *http.Request) { if r.syncer == nil { writeError(w, http.StatusNotFound, "SYNC_DISABLED", errors.New("legacy sync service is not configured")) return } path := cleanPath(req.URL.Path) switch { case req.Method == http.MethodGet && path == "/api/admin/sync/legacy/preview": writeJSON(w, http.StatusOK, r.syncer.Preview(req.Context())) case req.Method == http.MethodPost && path == "/api/admin/sync/legacy/run": writeJSON(w, http.StatusOK, r.syncer.Run(req.Context())) default: http.NotFound(w, req) } } func (r *router) handleAdminEndpoints(w http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet { http.NotFound(w, req) return } items, err := r.sources.Endpoints(true) if err != nil { writeError(w, http.StatusInternalServerError, "ENDPOINTS_FAILED", err) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": items}) } func (r *router) handleAdminEvents(w http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet { writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("GET required")) return } flusher, ok := w.(http.Flusher) if !ok { writeError(w, http.StatusInternalServerError, "SSE_UNSUPPORTED", errors.New("streaming is not supported")) return } w.Header().Set("Content-Type", "text/event-stream; charset=utf-8") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") events, unsubscribe := r.sources.SubscribeEvents() defer unsubscribe() ticker := time.NewTicker(15 * time.Second) defer ticker.Stop() writeSSE(w, "ready", map[string]any{"ok": true, "time": time.Now().UTC().Format(time.RFC3339)}) flusher.Flush() for { select { case event, ok := <-events: if !ok { return } writeSSE(w, event.Type, event.Data) flusher.Flush() case <-ticker.C: writeSSE(w, "heartbeat", map[string]any{"time": time.Now().UTC().Format(time.RFC3339)}) flusher.Flush() case <-req.Context().Done(): return } } } func (r *router) handleAdminSystem(w http.ResponseWriter, req *http.Request) { path := cleanPath(req.URL.Path) switch path { case "/api/admin/system/health": writeJSON(w, http.StatusOK, health.Snapshot(r.cfg, r.store)) case "/api/admin/system/audit": 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": 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")) return } result, err := r.store.ImportSQLiteToRemote() if err != nil { writeError(w, http.StatusBadRequest, "SYNC_FAILED", err) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true, "result": result, "finishedAt": result.FinishedAt}) default: 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 }