redeem_handler.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
  1. package admin
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/csv"
  6. "errors"
  7. "fmt"
  8. "strconv"
  9. "strings"
  10. "time"
  11. "github.com/Wei-Shaw/sub2api/internal/handler/dto"
  12. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  13. "github.com/Wei-Shaw/sub2api/internal/pkg/response"
  14. "github.com/Wei-Shaw/sub2api/internal/service"
  15. "github.com/gin-gonic/gin"
  16. )
  17. // RedeemHandler handles admin redeem code management
  18. type RedeemHandler struct {
  19. adminService service.AdminService
  20. redeemService *service.RedeemService
  21. }
  22. // NewRedeemHandler creates a new admin redeem handler
  23. func NewRedeemHandler(adminService service.AdminService, redeemService *service.RedeemService) *RedeemHandler {
  24. return &RedeemHandler{
  25. adminService: adminService,
  26. redeemService: redeemService,
  27. }
  28. }
  29. // GenerateRedeemCodesRequest represents generate redeem codes request
  30. type GenerateRedeemCodesRequest struct {
  31. Count int `json:"count" binding:"required,min=1,max=100"`
  32. Type string `json:"type" binding:"required,oneof=balance concurrency subscription invitation"`
  33. Value float64 `json:"value"`
  34. GroupID *int64 `json:"group_id"` // 订阅类型必填
  35. ValidityDays int `json:"validity_days"` // 订阅类型使用,正数增加/负数退款扣减
  36. ExpiresAt *time.Time `json:"expires_at"`
  37. ExpiresInDays *int `json:"expires_in_days" binding:"omitempty,min=1,max=3650"`
  38. }
  39. // CreateAndRedeemCodeRequest represents creating a fixed code and redeeming it for a target user.
  40. // Type 为 omitempty 而非 required 是为了向后兼容旧版调用方(不传 type 时默认 balance)。
  41. type CreateAndRedeemCodeRequest struct {
  42. Code string `json:"code" binding:"required,min=3,max=128"`
  43. Type string `json:"type" binding:"omitempty,oneof=balance concurrency subscription invitation"` // 不传时默认 balance(向后兼容)
  44. Value float64 `json:"value" binding:"required"`
  45. UserID int64 `json:"user_id" binding:"required,gt=0"`
  46. GroupID *int64 `json:"group_id"` // subscription 类型必填
  47. ValidityDays int `json:"validity_days"` // subscription 类型:正数增加,负数退款扣减
  48. Notes string `json:"notes"`
  49. ExpiresAt *time.Time `json:"expires_at"`
  50. ExpiresInDays *int `json:"expires_in_days" binding:"omitempty,min=1,max=3650"`
  51. }
  52. func resolveRedeemCodeExpiresAt(expiresAt *time.Time, expiresInDays *int) (*time.Time, error) {
  53. if expiresAt != nil && expiresInDays != nil {
  54. return nil, infraerrors.BadRequest("REDEEM_CODE_EXPIRY_CONFLICT", "expires_at and expires_in_days cannot both be set")
  55. }
  56. now := time.Now().UTC()
  57. if expiresInDays != nil {
  58. if *expiresInDays <= 0 {
  59. return nil, infraerrors.BadRequest("REDEEM_CODE_EXPIRES_IN_DAYS_INVALID", "expires_in_days must be greater than zero")
  60. }
  61. expires := now.AddDate(0, 0, *expiresInDays)
  62. return &expires, nil
  63. }
  64. if expiresAt == nil {
  65. return nil, nil
  66. }
  67. expires := expiresAt.UTC()
  68. if !expires.After(now) {
  69. return nil, infraerrors.BadRequest("REDEEM_CODE_EXPIRES_AT_INVALID", "expires_at must be in the future")
  70. }
  71. return &expires, nil
  72. }
  73. // List handles listing all redeem codes with pagination
  74. // GET /api/v1/admin/redeem-codes
  75. func (h *RedeemHandler) List(c *gin.Context) {
  76. page, pageSize := response.ParsePagination(c)
  77. codeType := c.Query("type")
  78. status := c.Query("status")
  79. search := c.Query("search")
  80. sortBy := c.DefaultQuery("sort_by", "id")
  81. sortOrder := c.DefaultQuery("sort_order", "desc")
  82. // 标准化和验证 search 参数
  83. search = strings.TrimSpace(search)
  84. if len(search) > 100 {
  85. search = search[:100]
  86. }
  87. codes, total, err := h.adminService.ListRedeemCodes(c.Request.Context(), page, pageSize, codeType, status, search, sortBy, sortOrder)
  88. if err != nil {
  89. response.ErrorFrom(c, err)
  90. return
  91. }
  92. out := make([]dto.AdminRedeemCode, 0, len(codes))
  93. for i := range codes {
  94. out = append(out, *dto.RedeemCodeFromServiceAdmin(&codes[i]))
  95. }
  96. response.Paginated(c, out, total, page, pageSize)
  97. }
  98. // GetByID handles getting a redeem code by ID
  99. // GET /api/v1/admin/redeem-codes/:id
  100. func (h *RedeemHandler) GetByID(c *gin.Context) {
  101. codeID, err := strconv.ParseInt(c.Param("id"), 10, 64)
  102. if err != nil {
  103. response.BadRequest(c, "Invalid redeem code ID")
  104. return
  105. }
  106. code, err := h.adminService.GetRedeemCode(c.Request.Context(), codeID)
  107. if err != nil {
  108. response.ErrorFrom(c, err)
  109. return
  110. }
  111. response.Success(c, dto.RedeemCodeFromServiceAdmin(code))
  112. }
  113. // Generate handles generating new redeem codes
  114. // POST /api/v1/admin/redeem-codes/generate
  115. func (h *RedeemHandler) Generate(c *gin.Context) {
  116. var req GenerateRedeemCodesRequest
  117. if err := c.ShouldBindJSON(&req); err != nil {
  118. response.BadRequest(c, "Invalid request: "+err.Error())
  119. return
  120. }
  121. expiresAt, err := resolveRedeemCodeExpiresAt(req.ExpiresAt, req.ExpiresInDays)
  122. if err != nil {
  123. response.ErrorFrom(c, err)
  124. return
  125. }
  126. executeAdminIdempotentJSON(c, "admin.redeem_codes.generate", req, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) {
  127. codes, execErr := h.adminService.GenerateRedeemCodes(ctx, &service.GenerateRedeemCodesInput{
  128. Count: req.Count,
  129. Type: req.Type,
  130. Value: req.Value,
  131. GroupID: req.GroupID,
  132. ValidityDays: req.ValidityDays,
  133. ExpiresAt: expiresAt,
  134. })
  135. if execErr != nil {
  136. return nil, execErr
  137. }
  138. out := make([]dto.AdminRedeemCode, 0, len(codes))
  139. for i := range codes {
  140. out = append(out, *dto.RedeemCodeFromServiceAdmin(&codes[i]))
  141. }
  142. return out, nil
  143. })
  144. }
  145. // CreateAndRedeem creates a fixed redeem code and redeems it for a target user in one step.
  146. // POST /api/v1/admin/redeem-codes/create-and-redeem
  147. func (h *RedeemHandler) CreateAndRedeem(c *gin.Context) {
  148. if h.redeemService == nil {
  149. response.InternalError(c, "redeem service not configured")
  150. return
  151. }
  152. var req CreateAndRedeemCodeRequest
  153. if err := c.ShouldBindJSON(&req); err != nil {
  154. response.BadRequest(c, "Invalid request: "+err.Error())
  155. return
  156. }
  157. req.Code = strings.TrimSpace(req.Code)
  158. // 向后兼容:旧版调用方(如 Sub2ApiPay)不传 type 字段,默认当作 balance 充值处理。
  159. // 请勿删除此默认值逻辑,否则会导致旧版调用方 400 报错。
  160. if req.Type == "" {
  161. req.Type = "balance"
  162. }
  163. if req.Type == "subscription" {
  164. if req.GroupID == nil {
  165. response.BadRequest(c, "group_id is required for subscription type")
  166. return
  167. }
  168. if req.ValidityDays == 0 {
  169. response.BadRequest(c, "validity_days must not be zero for subscription type")
  170. return
  171. }
  172. }
  173. expiresAt, err := resolveRedeemCodeExpiresAt(req.ExpiresAt, req.ExpiresInDays)
  174. if err != nil {
  175. response.ErrorFrom(c, err)
  176. return
  177. }
  178. executeAdminIdempotentJSON(c, "admin.redeem_codes.create_and_redeem", req, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) {
  179. existing, err := h.redeemService.GetByCode(ctx, req.Code)
  180. if err == nil {
  181. return h.resolveCreateAndRedeemExisting(ctx, existing, req.UserID)
  182. }
  183. if !errors.Is(err, service.ErrRedeemCodeNotFound) {
  184. return nil, err
  185. }
  186. createErr := h.redeemService.CreateCode(ctx, &service.RedeemCode{
  187. Code: req.Code,
  188. Type: req.Type,
  189. Value: req.Value,
  190. Status: service.StatusUnused,
  191. Notes: req.Notes,
  192. GroupID: req.GroupID,
  193. ValidityDays: req.ValidityDays,
  194. ExpiresAt: expiresAt,
  195. })
  196. if createErr != nil {
  197. // Unique code race: if code now exists, use idempotent semantics by used_by.
  198. existingAfterCreateErr, getErr := h.redeemService.GetByCode(ctx, req.Code)
  199. if getErr == nil {
  200. return h.resolveCreateAndRedeemExisting(ctx, existingAfterCreateErr, req.UserID)
  201. }
  202. return nil, createErr
  203. }
  204. redeemed, redeemErr := h.redeemService.RedeemForAdminFulfillment(ctx, req.UserID, req.Code)
  205. if redeemErr != nil {
  206. return nil, redeemErr
  207. }
  208. return gin.H{"redeem_code": dto.RedeemCodeFromServiceAdmin(redeemed)}, nil
  209. })
  210. }
  211. func (h *RedeemHandler) resolveCreateAndRedeemExisting(ctx context.Context, existing *service.RedeemCode, userID int64) (any, error) {
  212. if existing == nil {
  213. return nil, infraerrors.Conflict("REDEEM_CODE_CONFLICT", "redeem code conflict")
  214. }
  215. // If previous run created the code but crashed before redeem, redeem it now.
  216. if existing.IsExpired() {
  217. return nil, service.ErrRedeemCodeExpired
  218. }
  219. if existing.CanUse() {
  220. redeemed, err := h.redeemService.RedeemForAdminFulfillment(ctx, userID, existing.Code)
  221. if err == nil {
  222. return gin.H{"redeem_code": dto.RedeemCodeFromServiceAdmin(redeemed)}, nil
  223. }
  224. if !errors.Is(err, service.ErrRedeemCodeUsed) {
  225. return nil, err
  226. }
  227. latest, getErr := h.redeemService.GetByCode(ctx, existing.Code)
  228. if getErr == nil {
  229. existing = latest
  230. }
  231. }
  232. if existing.UsedBy != nil && *existing.UsedBy == userID {
  233. return gin.H{"redeem_code": dto.RedeemCodeFromServiceAdmin(existing)}, nil
  234. }
  235. return nil, infraerrors.Conflict("REDEEM_CODE_CONFLICT", "redeem code already used by another user")
  236. }
  237. // Delete handles deleting a redeem code
  238. // DELETE /api/v1/admin/redeem-codes/:id
  239. func (h *RedeemHandler) Delete(c *gin.Context) {
  240. codeID, err := strconv.ParseInt(c.Param("id"), 10, 64)
  241. if err != nil {
  242. response.BadRequest(c, "Invalid redeem code ID")
  243. return
  244. }
  245. err = h.adminService.DeleteRedeemCode(c.Request.Context(), codeID)
  246. if err != nil {
  247. response.ErrorFrom(c, err)
  248. return
  249. }
  250. response.Success(c, gin.H{"message": "Redeem code deleted successfully"})
  251. }
  252. // BatchDelete handles batch deleting redeem codes
  253. // POST /api/v1/admin/redeem-codes/batch-delete
  254. func (h *RedeemHandler) BatchDelete(c *gin.Context) {
  255. var req struct {
  256. IDs []int64 `json:"ids" binding:"required,min=1"`
  257. }
  258. if err := c.ShouldBindJSON(&req); err != nil {
  259. response.BadRequest(c, "Invalid request: "+err.Error())
  260. return
  261. }
  262. deleted, err := h.adminService.BatchDeleteRedeemCodes(c.Request.Context(), req.IDs)
  263. if err != nil {
  264. response.ErrorFrom(c, err)
  265. return
  266. }
  267. response.Success(c, gin.H{
  268. "deleted": deleted,
  269. "message": "Redeem codes deleted successfully",
  270. })
  271. }
  272. // BatchUpdate handles batch updating redeem codes
  273. // POST /api/v1/admin/redeem-codes/batch-update
  274. func (h *RedeemHandler) BatchUpdate(c *gin.Context) {
  275. if h.redeemService == nil {
  276. response.InternalError(c, "redeem service not configured")
  277. return
  278. }
  279. var req dto.BatchUpdateRedeemCodesRequest
  280. if err := c.ShouldBindJSON(&req); err != nil {
  281. response.BadRequest(c, "Invalid request: "+err.Error())
  282. return
  283. }
  284. result, err := h.redeemService.BatchUpdate(c.Request.Context(), &service.RedeemCodeBatchUpdateInput{
  285. IDs: req.IDs,
  286. Fields: redeemBatchUpdateFieldsFromDTO(req.Fields),
  287. })
  288. if err != nil {
  289. response.ErrorFrom(c, err)
  290. return
  291. }
  292. response.Success(c, gin.H{
  293. "updated": result.Updated,
  294. "message": "Redeem codes updated successfully",
  295. })
  296. }
  297. func redeemBatchUpdateFieldsFromDTO(in dto.BatchUpdateRedeemCodeFields) service.RedeemCodeBatchUpdateFields {
  298. out := service.RedeemCodeBatchUpdateFields{
  299. Status: in.Status,
  300. Notes: in.Notes,
  301. Type: in.Type,
  302. Value: in.Value,
  303. }
  304. if in.ExpiresAt.Set {
  305. out.ExpiresAt = service.NullableTimeUpdate{Set: true, Value: in.ExpiresAt.Value}
  306. }
  307. if in.GroupID.Set {
  308. out.GroupID = service.NullableInt64Update{Set: true, Value: in.GroupID.Value}
  309. }
  310. return out
  311. }
  312. // Expire handles expiring a redeem code
  313. // POST /api/v1/admin/redeem-codes/:id/expire
  314. func (h *RedeemHandler) Expire(c *gin.Context) {
  315. codeID, err := strconv.ParseInt(c.Param("id"), 10, 64)
  316. if err != nil {
  317. response.BadRequest(c, "Invalid redeem code ID")
  318. return
  319. }
  320. code, err := h.adminService.ExpireRedeemCode(c.Request.Context(), codeID)
  321. if err != nil {
  322. response.ErrorFrom(c, err)
  323. return
  324. }
  325. response.Success(c, dto.RedeemCodeFromServiceAdmin(code))
  326. }
  327. // GetStats handles getting redeem code statistics
  328. // GET /api/v1/admin/redeem-codes/stats
  329. func (h *RedeemHandler) GetStats(c *gin.Context) {
  330. // Return mock data for now
  331. response.Success(c, gin.H{
  332. "total_codes": 0,
  333. "active_codes": 0,
  334. "used_codes": 0,
  335. "expired_codes": 0,
  336. "total_value_distributed": 0.0,
  337. "by_type": gin.H{
  338. "balance": 0,
  339. "concurrency": 0,
  340. "trial": 0,
  341. },
  342. })
  343. }
  344. // Export handles exporting redeem codes to CSV
  345. // GET /api/v1/admin/redeem-codes/export
  346. func (h *RedeemHandler) Export(c *gin.Context) {
  347. codeType := c.Query("type")
  348. status := c.Query("status")
  349. search := strings.TrimSpace(c.Query("search"))
  350. sortBy := c.DefaultQuery("sort_by", "id")
  351. sortOrder := c.DefaultQuery("sort_order", "desc")
  352. if len(search) > 100 {
  353. search = search[:100]
  354. }
  355. // Get all codes without pagination (use large page size)
  356. codes, _, err := h.adminService.ListRedeemCodes(c.Request.Context(), 1, 10000, codeType, status, search, sortBy, sortOrder)
  357. if err != nil {
  358. response.ErrorFrom(c, err)
  359. return
  360. }
  361. // Create CSV buffer
  362. var buf bytes.Buffer
  363. writer := csv.NewWriter(&buf)
  364. // Write header
  365. if err := writer.Write([]string{"id", "code", "type", "value", "status", "used_by", "used_by_email", "used_at", "expires_at", "created_at"}); err != nil {
  366. response.InternalError(c, "Failed to export redeem codes: "+err.Error())
  367. return
  368. }
  369. // Write data rows
  370. for _, code := range codes {
  371. usedBy := ""
  372. if code.UsedBy != nil {
  373. usedBy = fmt.Sprintf("%d", *code.UsedBy)
  374. }
  375. usedByEmail := ""
  376. if code.User != nil {
  377. usedByEmail = code.User.Email
  378. }
  379. usedAt := ""
  380. if code.UsedAt != nil {
  381. usedAt = code.UsedAt.Format("2006-01-02 15:04:05")
  382. }
  383. expiresAt := ""
  384. if code.ExpiresAt != nil {
  385. expiresAt = code.ExpiresAt.Format("2006-01-02 15:04:05")
  386. }
  387. if err := writer.Write([]string{
  388. fmt.Sprintf("%d", code.ID),
  389. code.Code,
  390. code.Type,
  391. fmt.Sprintf("%.2f", code.Value),
  392. code.Status,
  393. usedBy,
  394. usedByEmail,
  395. usedAt,
  396. expiresAt,
  397. code.CreatedAt.Format("2006-01-02 15:04:05"),
  398. }); err != nil {
  399. response.InternalError(c, "Failed to export redeem codes: "+err.Error())
  400. return
  401. }
  402. }
  403. writer.Flush()
  404. if err := writer.Error(); err != nil {
  405. response.InternalError(c, "Failed to export redeem codes: "+err.Error())
  406. return
  407. }
  408. c.Header("Content-Type", "text/csv")
  409. c.Header("Content-Disposition", "attachment; filename=redeem_codes.csv")
  410. c.Data(200, "text/csv", buf.Bytes())
  411. }