data_management_handler.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545
  1. package admin
  2. import (
  3. "context"
  4. "strconv"
  5. "strings"
  6. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  7. "github.com/Wei-Shaw/sub2api/internal/pkg/response"
  8. middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
  9. "github.com/Wei-Shaw/sub2api/internal/service"
  10. "github.com/gin-gonic/gin"
  11. )
  12. type DataManagementHandler struct {
  13. dataManagementService dataManagementService
  14. }
  15. func NewDataManagementHandler(dataManagementService *service.DataManagementService) *DataManagementHandler {
  16. return &DataManagementHandler{dataManagementService: dataManagementService}
  17. }
  18. type dataManagementService interface {
  19. GetConfig(ctx context.Context) (service.DataManagementConfig, error)
  20. UpdateConfig(ctx context.Context, cfg service.DataManagementConfig) (service.DataManagementConfig, error)
  21. ValidateS3(ctx context.Context, cfg service.DataManagementS3Config) (service.DataManagementTestS3Result, error)
  22. CreateBackupJob(ctx context.Context, input service.DataManagementCreateBackupJobInput) (service.DataManagementBackupJob, error)
  23. ListSourceProfiles(ctx context.Context, sourceType string) ([]service.DataManagementSourceProfile, error)
  24. CreateSourceProfile(ctx context.Context, input service.DataManagementCreateSourceProfileInput) (service.DataManagementSourceProfile, error)
  25. UpdateSourceProfile(ctx context.Context, input service.DataManagementUpdateSourceProfileInput) (service.DataManagementSourceProfile, error)
  26. DeleteSourceProfile(ctx context.Context, sourceType, profileID string) error
  27. SetActiveSourceProfile(ctx context.Context, sourceType, profileID string) (service.DataManagementSourceProfile, error)
  28. ListS3Profiles(ctx context.Context) ([]service.DataManagementS3Profile, error)
  29. CreateS3Profile(ctx context.Context, input service.DataManagementCreateS3ProfileInput) (service.DataManagementS3Profile, error)
  30. UpdateS3Profile(ctx context.Context, input service.DataManagementUpdateS3ProfileInput) (service.DataManagementS3Profile, error)
  31. DeleteS3Profile(ctx context.Context, profileID string) error
  32. SetActiveS3Profile(ctx context.Context, profileID string) (service.DataManagementS3Profile, error)
  33. ListBackupJobs(ctx context.Context, input service.DataManagementListBackupJobsInput) (service.DataManagementListBackupJobsResult, error)
  34. GetBackupJob(ctx context.Context, jobID string) (service.DataManagementBackupJob, error)
  35. EnsureAgentEnabled(ctx context.Context) error
  36. GetAgentHealth(ctx context.Context) service.DataManagementAgentHealth
  37. }
  38. type TestS3ConnectionRequest struct {
  39. Endpoint string `json:"endpoint"`
  40. Region string `json:"region" binding:"required"`
  41. Bucket string `json:"bucket" binding:"required"`
  42. AccessKeyID string `json:"access_key_id"`
  43. SecretAccessKey string `json:"secret_access_key"`
  44. Prefix string `json:"prefix"`
  45. ForcePathStyle bool `json:"force_path_style"`
  46. UseSSL bool `json:"use_ssl"`
  47. }
  48. type CreateBackupJobRequest struct {
  49. BackupType string `json:"backup_type" binding:"required,oneof=postgres redis full"`
  50. UploadToS3 bool `json:"upload_to_s3"`
  51. S3ProfileID string `json:"s3_profile_id"`
  52. PostgresID string `json:"postgres_profile_id"`
  53. RedisID string `json:"redis_profile_id"`
  54. IdempotencyKey string `json:"idempotency_key"`
  55. }
  56. type CreateSourceProfileRequest struct {
  57. ProfileID string `json:"profile_id" binding:"required"`
  58. Name string `json:"name" binding:"required"`
  59. Config service.DataManagementSourceConfig `json:"config" binding:"required"`
  60. SetActive bool `json:"set_active"`
  61. }
  62. type UpdateSourceProfileRequest struct {
  63. Name string `json:"name" binding:"required"`
  64. Config service.DataManagementSourceConfig `json:"config" binding:"required"`
  65. }
  66. type CreateS3ProfileRequest struct {
  67. ProfileID string `json:"profile_id" binding:"required"`
  68. Name string `json:"name" binding:"required"`
  69. Enabled bool `json:"enabled"`
  70. Endpoint string `json:"endpoint"`
  71. Region string `json:"region"`
  72. Bucket string `json:"bucket"`
  73. AccessKeyID string `json:"access_key_id"`
  74. SecretAccessKey string `json:"secret_access_key"`
  75. Prefix string `json:"prefix"`
  76. ForcePathStyle bool `json:"force_path_style"`
  77. UseSSL bool `json:"use_ssl"`
  78. SetActive bool `json:"set_active"`
  79. }
  80. type UpdateS3ProfileRequest struct {
  81. Name string `json:"name" binding:"required"`
  82. Enabled bool `json:"enabled"`
  83. Endpoint string `json:"endpoint"`
  84. Region string `json:"region"`
  85. Bucket string `json:"bucket"`
  86. AccessKeyID string `json:"access_key_id"`
  87. SecretAccessKey string `json:"secret_access_key"`
  88. Prefix string `json:"prefix"`
  89. ForcePathStyle bool `json:"force_path_style"`
  90. UseSSL bool `json:"use_ssl"`
  91. }
  92. func (h *DataManagementHandler) GetAgentHealth(c *gin.Context) {
  93. health := h.getAgentHealth(c)
  94. payload := gin.H{
  95. "enabled": health.Enabled,
  96. "reason": health.Reason,
  97. "socket_path": health.SocketPath,
  98. }
  99. if health.Agent != nil {
  100. payload["agent"] = gin.H{
  101. "status": health.Agent.Status,
  102. "version": health.Agent.Version,
  103. "uptime_seconds": health.Agent.UptimeSeconds,
  104. }
  105. }
  106. response.Success(c, payload)
  107. }
  108. func (h *DataManagementHandler) GetConfig(c *gin.Context) {
  109. if !h.requireAgentEnabled(c) {
  110. return
  111. }
  112. cfg, err := h.dataManagementService.GetConfig(c.Request.Context())
  113. if err != nil {
  114. response.ErrorFrom(c, err)
  115. return
  116. }
  117. response.Success(c, cfg)
  118. }
  119. func (h *DataManagementHandler) UpdateConfig(c *gin.Context) {
  120. var req service.DataManagementConfig
  121. if err := c.ShouldBindJSON(&req); err != nil {
  122. response.BadRequest(c, "Invalid request: "+err.Error())
  123. return
  124. }
  125. if !h.requireAgentEnabled(c) {
  126. return
  127. }
  128. cfg, err := h.dataManagementService.UpdateConfig(c.Request.Context(), req)
  129. if err != nil {
  130. response.ErrorFrom(c, err)
  131. return
  132. }
  133. response.Success(c, cfg)
  134. }
  135. func (h *DataManagementHandler) TestS3(c *gin.Context) {
  136. var req TestS3ConnectionRequest
  137. if err := c.ShouldBindJSON(&req); err != nil {
  138. response.BadRequest(c, "Invalid request: "+err.Error())
  139. return
  140. }
  141. if !h.requireAgentEnabled(c) {
  142. return
  143. }
  144. result, err := h.dataManagementService.ValidateS3(c.Request.Context(), service.DataManagementS3Config{
  145. Enabled: true,
  146. Endpoint: req.Endpoint,
  147. Region: req.Region,
  148. Bucket: req.Bucket,
  149. AccessKeyID: req.AccessKeyID,
  150. SecretAccessKey: req.SecretAccessKey,
  151. Prefix: req.Prefix,
  152. ForcePathStyle: req.ForcePathStyle,
  153. UseSSL: req.UseSSL,
  154. })
  155. if err != nil {
  156. response.ErrorFrom(c, err)
  157. return
  158. }
  159. response.Success(c, gin.H{"ok": result.OK, "message": result.Message})
  160. }
  161. func (h *DataManagementHandler) CreateBackupJob(c *gin.Context) {
  162. var req CreateBackupJobRequest
  163. if err := c.ShouldBindJSON(&req); err != nil {
  164. response.BadRequest(c, "Invalid request: "+err.Error())
  165. return
  166. }
  167. req.IdempotencyKey = normalizeBackupIdempotencyKey(c.GetHeader("X-Idempotency-Key"), req.IdempotencyKey)
  168. if !h.requireAgentEnabled(c) {
  169. return
  170. }
  171. triggeredBy := "admin:unknown"
  172. if subject, ok := middleware2.GetAuthSubjectFromContext(c); ok {
  173. triggeredBy = "admin:" + strconv.FormatInt(subject.UserID, 10)
  174. }
  175. job, err := h.dataManagementService.CreateBackupJob(c.Request.Context(), service.DataManagementCreateBackupJobInput{
  176. BackupType: req.BackupType,
  177. UploadToS3: req.UploadToS3,
  178. S3ProfileID: req.S3ProfileID,
  179. PostgresID: req.PostgresID,
  180. RedisID: req.RedisID,
  181. TriggeredBy: triggeredBy,
  182. IdempotencyKey: req.IdempotencyKey,
  183. })
  184. if err != nil {
  185. response.ErrorFrom(c, err)
  186. return
  187. }
  188. response.Success(c, gin.H{"job_id": job.JobID, "status": job.Status})
  189. }
  190. func (h *DataManagementHandler) ListSourceProfiles(c *gin.Context) {
  191. sourceType := strings.TrimSpace(c.Param("source_type"))
  192. if sourceType == "" {
  193. response.BadRequest(c, "Invalid source_type")
  194. return
  195. }
  196. if sourceType != "postgres" && sourceType != "redis" {
  197. response.BadRequest(c, "source_type must be postgres or redis")
  198. return
  199. }
  200. if !h.requireAgentEnabled(c) {
  201. return
  202. }
  203. items, err := h.dataManagementService.ListSourceProfiles(c.Request.Context(), sourceType)
  204. if err != nil {
  205. response.ErrorFrom(c, err)
  206. return
  207. }
  208. response.Success(c, gin.H{"items": items})
  209. }
  210. func (h *DataManagementHandler) CreateSourceProfile(c *gin.Context) {
  211. sourceType := strings.TrimSpace(c.Param("source_type"))
  212. if sourceType != "postgres" && sourceType != "redis" {
  213. response.BadRequest(c, "source_type must be postgres or redis")
  214. return
  215. }
  216. var req CreateSourceProfileRequest
  217. if err := c.ShouldBindJSON(&req); err != nil {
  218. response.BadRequest(c, "Invalid request: "+err.Error())
  219. return
  220. }
  221. if !h.requireAgentEnabled(c) {
  222. return
  223. }
  224. profile, err := h.dataManagementService.CreateSourceProfile(c.Request.Context(), service.DataManagementCreateSourceProfileInput{
  225. SourceType: sourceType,
  226. ProfileID: req.ProfileID,
  227. Name: req.Name,
  228. Config: req.Config,
  229. SetActive: req.SetActive,
  230. })
  231. if err != nil {
  232. response.ErrorFrom(c, err)
  233. return
  234. }
  235. response.Success(c, profile)
  236. }
  237. func (h *DataManagementHandler) UpdateSourceProfile(c *gin.Context) {
  238. sourceType := strings.TrimSpace(c.Param("source_type"))
  239. if sourceType != "postgres" && sourceType != "redis" {
  240. response.BadRequest(c, "source_type must be postgres or redis")
  241. return
  242. }
  243. profileID := strings.TrimSpace(c.Param("profile_id"))
  244. if profileID == "" {
  245. response.BadRequest(c, "Invalid profile_id")
  246. return
  247. }
  248. var req UpdateSourceProfileRequest
  249. if err := c.ShouldBindJSON(&req); err != nil {
  250. response.BadRequest(c, "Invalid request: "+err.Error())
  251. return
  252. }
  253. if !h.requireAgentEnabled(c) {
  254. return
  255. }
  256. profile, err := h.dataManagementService.UpdateSourceProfile(c.Request.Context(), service.DataManagementUpdateSourceProfileInput{
  257. SourceType: sourceType,
  258. ProfileID: profileID,
  259. Name: req.Name,
  260. Config: req.Config,
  261. })
  262. if err != nil {
  263. response.ErrorFrom(c, err)
  264. return
  265. }
  266. response.Success(c, profile)
  267. }
  268. func (h *DataManagementHandler) DeleteSourceProfile(c *gin.Context) {
  269. sourceType := strings.TrimSpace(c.Param("source_type"))
  270. if sourceType != "postgres" && sourceType != "redis" {
  271. response.BadRequest(c, "source_type must be postgres or redis")
  272. return
  273. }
  274. profileID := strings.TrimSpace(c.Param("profile_id"))
  275. if profileID == "" {
  276. response.BadRequest(c, "Invalid profile_id")
  277. return
  278. }
  279. if !h.requireAgentEnabled(c) {
  280. return
  281. }
  282. if err := h.dataManagementService.DeleteSourceProfile(c.Request.Context(), sourceType, profileID); err != nil {
  283. response.ErrorFrom(c, err)
  284. return
  285. }
  286. response.Success(c, gin.H{"deleted": true})
  287. }
  288. func (h *DataManagementHandler) SetActiveSourceProfile(c *gin.Context) {
  289. sourceType := strings.TrimSpace(c.Param("source_type"))
  290. if sourceType != "postgres" && sourceType != "redis" {
  291. response.BadRequest(c, "source_type must be postgres or redis")
  292. return
  293. }
  294. profileID := strings.TrimSpace(c.Param("profile_id"))
  295. if profileID == "" {
  296. response.BadRequest(c, "Invalid profile_id")
  297. return
  298. }
  299. if !h.requireAgentEnabled(c) {
  300. return
  301. }
  302. profile, err := h.dataManagementService.SetActiveSourceProfile(c.Request.Context(), sourceType, profileID)
  303. if err != nil {
  304. response.ErrorFrom(c, err)
  305. return
  306. }
  307. response.Success(c, profile)
  308. }
  309. func (h *DataManagementHandler) ListS3Profiles(c *gin.Context) {
  310. if !h.requireAgentEnabled(c) {
  311. return
  312. }
  313. items, err := h.dataManagementService.ListS3Profiles(c.Request.Context())
  314. if err != nil {
  315. response.ErrorFrom(c, err)
  316. return
  317. }
  318. response.Success(c, gin.H{"items": items})
  319. }
  320. func (h *DataManagementHandler) CreateS3Profile(c *gin.Context) {
  321. var req CreateS3ProfileRequest
  322. if err := c.ShouldBindJSON(&req); err != nil {
  323. response.BadRequest(c, "Invalid request: "+err.Error())
  324. return
  325. }
  326. if !h.requireAgentEnabled(c) {
  327. return
  328. }
  329. profile, err := h.dataManagementService.CreateS3Profile(c.Request.Context(), service.DataManagementCreateS3ProfileInput{
  330. ProfileID: req.ProfileID,
  331. Name: req.Name,
  332. SetActive: req.SetActive,
  333. S3: service.DataManagementS3Config{
  334. Enabled: req.Enabled,
  335. Endpoint: req.Endpoint,
  336. Region: req.Region,
  337. Bucket: req.Bucket,
  338. AccessKeyID: req.AccessKeyID,
  339. SecretAccessKey: req.SecretAccessKey,
  340. Prefix: req.Prefix,
  341. ForcePathStyle: req.ForcePathStyle,
  342. UseSSL: req.UseSSL,
  343. },
  344. })
  345. if err != nil {
  346. response.ErrorFrom(c, err)
  347. return
  348. }
  349. response.Success(c, profile)
  350. }
  351. func (h *DataManagementHandler) UpdateS3Profile(c *gin.Context) {
  352. var req UpdateS3ProfileRequest
  353. if err := c.ShouldBindJSON(&req); err != nil {
  354. response.BadRequest(c, "Invalid request: "+err.Error())
  355. return
  356. }
  357. profileID := strings.TrimSpace(c.Param("profile_id"))
  358. if profileID == "" {
  359. response.BadRequest(c, "Invalid profile_id")
  360. return
  361. }
  362. if !h.requireAgentEnabled(c) {
  363. return
  364. }
  365. profile, err := h.dataManagementService.UpdateS3Profile(c.Request.Context(), service.DataManagementUpdateS3ProfileInput{
  366. ProfileID: profileID,
  367. Name: req.Name,
  368. S3: service.DataManagementS3Config{
  369. Enabled: req.Enabled,
  370. Endpoint: req.Endpoint,
  371. Region: req.Region,
  372. Bucket: req.Bucket,
  373. AccessKeyID: req.AccessKeyID,
  374. SecretAccessKey: req.SecretAccessKey,
  375. Prefix: req.Prefix,
  376. ForcePathStyle: req.ForcePathStyle,
  377. UseSSL: req.UseSSL,
  378. },
  379. })
  380. if err != nil {
  381. response.ErrorFrom(c, err)
  382. return
  383. }
  384. response.Success(c, profile)
  385. }
  386. func (h *DataManagementHandler) DeleteS3Profile(c *gin.Context) {
  387. profileID := strings.TrimSpace(c.Param("profile_id"))
  388. if profileID == "" {
  389. response.BadRequest(c, "Invalid profile_id")
  390. return
  391. }
  392. if !h.requireAgentEnabled(c) {
  393. return
  394. }
  395. if err := h.dataManagementService.DeleteS3Profile(c.Request.Context(), profileID); err != nil {
  396. response.ErrorFrom(c, err)
  397. return
  398. }
  399. response.Success(c, gin.H{"deleted": true})
  400. }
  401. func (h *DataManagementHandler) SetActiveS3Profile(c *gin.Context) {
  402. profileID := strings.TrimSpace(c.Param("profile_id"))
  403. if profileID == "" {
  404. response.BadRequest(c, "Invalid profile_id")
  405. return
  406. }
  407. if !h.requireAgentEnabled(c) {
  408. return
  409. }
  410. profile, err := h.dataManagementService.SetActiveS3Profile(c.Request.Context(), profileID)
  411. if err != nil {
  412. response.ErrorFrom(c, err)
  413. return
  414. }
  415. response.Success(c, profile)
  416. }
  417. func (h *DataManagementHandler) ListBackupJobs(c *gin.Context) {
  418. if !h.requireAgentEnabled(c) {
  419. return
  420. }
  421. pageSize := int32(20)
  422. if raw := strings.TrimSpace(c.Query("page_size")); raw != "" {
  423. v, err := strconv.Atoi(raw)
  424. if err != nil || v <= 0 {
  425. response.BadRequest(c, "Invalid page_size")
  426. return
  427. }
  428. pageSize = int32(v)
  429. }
  430. result, err := h.dataManagementService.ListBackupJobs(c.Request.Context(), service.DataManagementListBackupJobsInput{
  431. PageSize: pageSize,
  432. PageToken: c.Query("page_token"),
  433. Status: c.Query("status"),
  434. BackupType: c.Query("backup_type"),
  435. })
  436. if err != nil {
  437. response.ErrorFrom(c, err)
  438. return
  439. }
  440. response.Success(c, result)
  441. }
  442. func (h *DataManagementHandler) GetBackupJob(c *gin.Context) {
  443. jobID := strings.TrimSpace(c.Param("job_id"))
  444. if jobID == "" {
  445. response.BadRequest(c, "Invalid backup job ID")
  446. return
  447. }
  448. if !h.requireAgentEnabled(c) {
  449. return
  450. }
  451. job, err := h.dataManagementService.GetBackupJob(c.Request.Context(), jobID)
  452. if err != nil {
  453. response.ErrorFrom(c, err)
  454. return
  455. }
  456. response.Success(c, job)
  457. }
  458. func (h *DataManagementHandler) requireAgentEnabled(c *gin.Context) bool {
  459. if h.dataManagementService == nil {
  460. err := infraerrors.ServiceUnavailable(
  461. service.DataManagementAgentUnavailableReason,
  462. "data management agent service is not configured",
  463. ).WithMetadata(map[string]string{"socket_path": service.DefaultDataManagementAgentSocketPath})
  464. response.ErrorFrom(c, err)
  465. return false
  466. }
  467. if err := h.dataManagementService.EnsureAgentEnabled(c.Request.Context()); err != nil {
  468. response.ErrorFrom(c, err)
  469. return false
  470. }
  471. return true
  472. }
  473. func (h *DataManagementHandler) getAgentHealth(c *gin.Context) service.DataManagementAgentHealth {
  474. if h.dataManagementService == nil {
  475. return service.DataManagementAgentHealth{
  476. Enabled: false,
  477. Reason: service.DataManagementAgentUnavailableReason,
  478. SocketPath: service.DefaultDataManagementAgentSocketPath,
  479. }
  480. }
  481. return h.dataManagementService.GetAgentHealth(c.Request.Context())
  482. }
  483. func normalizeBackupIdempotencyKey(headerValue, bodyValue string) string {
  484. headerKey := strings.TrimSpace(headerValue)
  485. if headerKey != "" {
  486. return headerKey
  487. }
  488. return strings.TrimSpace(bodyValue)
  489. }