affiliate_handler.go 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291
  1. package admin
  2. import (
  3. "strconv"
  4. "strings"
  5. "time"
  6. "github.com/Wei-Shaw/sub2api/internal/pkg/response"
  7. "github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
  8. "github.com/Wei-Shaw/sub2api/internal/service"
  9. "github.com/gin-gonic/gin"
  10. )
  11. // AffiliateHandler handles admin affiliate (邀请返利) management:
  12. // listing users with custom settings, updating per-user invite codes
  13. // and exclusive rebate rates, and batch operations.
  14. type AffiliateHandler struct {
  15. affiliateService *service.AffiliateService
  16. adminService service.AdminService
  17. }
  18. // NewAffiliateHandler creates a new admin affiliate handler.
  19. func NewAffiliateHandler(affiliateService *service.AffiliateService, adminService service.AdminService) *AffiliateHandler {
  20. return &AffiliateHandler{
  21. affiliateService: affiliateService,
  22. adminService: adminService,
  23. }
  24. }
  25. // ListUsers returns paginated users with custom affiliate settings.
  26. // GET /api/v1/admin/affiliates/users
  27. func (h *AffiliateHandler) ListUsers(c *gin.Context) {
  28. page, pageSize := response.ParsePagination(c)
  29. search := c.Query("search")
  30. entries, total, err := h.affiliateService.AdminListCustomUsers(c.Request.Context(), service.AffiliateAdminFilter{
  31. Search: search,
  32. Page: page,
  33. PageSize: pageSize,
  34. })
  35. if err != nil {
  36. response.ErrorFrom(c, err)
  37. return
  38. }
  39. response.Paginated(c, entries, total, page, pageSize)
  40. }
  41. // UpdateUserSettings updates a user's affiliate settings.
  42. // PUT /api/v1/admin/affiliates/users/:user_id
  43. //
  44. // Both fields are optional and applied independently.
  45. type UpdateAffiliateUserRequest struct {
  46. AffCode *string `json:"aff_code"`
  47. AffRebateRatePercent *float64 `json:"aff_rebate_rate_percent"`
  48. // ClearRebateRate explicitly clears the per-user rate (sets it to NULL).
  49. // Used to disambiguate from "field not provided".
  50. ClearRebateRate bool `json:"clear_rebate_rate"`
  51. }
  52. func (h *AffiliateHandler) UpdateUserSettings(c *gin.Context) {
  53. userID, err := strconv.ParseInt(c.Param("user_id"), 10, 64)
  54. if err != nil || userID <= 0 {
  55. response.BadRequest(c, "Invalid user_id")
  56. return
  57. }
  58. var req UpdateAffiliateUserRequest
  59. if err := c.ShouldBindJSON(&req); err != nil {
  60. response.BadRequest(c, "Invalid request: "+err.Error())
  61. return
  62. }
  63. if req.AffCode != nil {
  64. if err := h.affiliateService.AdminUpdateUserAffCode(c.Request.Context(), userID, *req.AffCode); err != nil {
  65. response.ErrorFrom(c, err)
  66. return
  67. }
  68. }
  69. if req.ClearRebateRate {
  70. if err := h.affiliateService.AdminSetUserRebateRate(c.Request.Context(), userID, nil); err != nil {
  71. response.ErrorFrom(c, err)
  72. return
  73. }
  74. } else if req.AffRebateRatePercent != nil {
  75. if err := h.affiliateService.AdminSetUserRebateRate(c.Request.Context(), userID, req.AffRebateRatePercent); err != nil {
  76. response.ErrorFrom(c, err)
  77. return
  78. }
  79. }
  80. response.Success(c, gin.H{"user_id": userID})
  81. }
  82. // ClearUserSettings removes ALL of a user's custom affiliate settings — clears
  83. // the exclusive rebate rate AND regenerates the invite code as a new system
  84. // random one. Conceptually this "removes the user from the custom list".
  85. //
  86. // Both writes happen in this handler; failure of one leaves the other applied,
  87. // but the operation is idempotent so the admin can re-run it safely.
  88. // DELETE /api/v1/admin/affiliates/users/:user_id
  89. func (h *AffiliateHandler) ClearUserSettings(c *gin.Context) {
  90. userID, err := strconv.ParseInt(c.Param("user_id"), 10, 64)
  91. if err != nil || userID <= 0 {
  92. response.BadRequest(c, "Invalid user_id")
  93. return
  94. }
  95. if err := h.affiliateService.AdminSetUserRebateRate(c.Request.Context(), userID, nil); err != nil {
  96. response.ErrorFrom(c, err)
  97. return
  98. }
  99. if _, err := h.affiliateService.AdminResetUserAffCode(c.Request.Context(), userID); err != nil {
  100. response.ErrorFrom(c, err)
  101. return
  102. }
  103. response.Success(c, gin.H{"user_id": userID})
  104. }
  105. // BatchSetRate applies the same rebate rate (or clears it) to multiple users.
  106. //
  107. // Protocol: pass `clear: true` to clear rates (aff_rebate_rate_percent is
  108. // ignored). Otherwise aff_rebate_rate_percent is required and applied to
  109. // every user_id. The explicit `clear` flag exists because Go's JSON unmarshal
  110. // can't distinguish a missing field from `null`, and a silent clear from a
  111. // frontend that forgot to include the rate would be a footgun.
  112. //
  113. // POST /api/v1/admin/affiliates/users/batch-rate
  114. type BatchSetRateRequest struct {
  115. UserIDs []int64 `json:"user_ids" binding:"required"`
  116. AffRebateRatePercent *float64 `json:"aff_rebate_rate_percent"`
  117. Clear bool `json:"clear"`
  118. }
  119. func (h *AffiliateHandler) BatchSetRate(c *gin.Context) {
  120. var req BatchSetRateRequest
  121. if err := c.ShouldBindJSON(&req); err != nil {
  122. response.BadRequest(c, "Invalid request: "+err.Error())
  123. return
  124. }
  125. if len(req.UserIDs) == 0 {
  126. response.BadRequest(c, "user_ids cannot be empty")
  127. return
  128. }
  129. if !req.Clear && req.AffRebateRatePercent == nil {
  130. response.BadRequest(c, "aff_rebate_rate_percent is required unless clear=true")
  131. return
  132. }
  133. rate := req.AffRebateRatePercent
  134. if req.Clear {
  135. rate = nil
  136. }
  137. if err := h.affiliateService.AdminBatchSetUserRebateRate(c.Request.Context(), req.UserIDs, rate); err != nil {
  138. response.ErrorFrom(c, err)
  139. return
  140. }
  141. response.Success(c, gin.H{"affected": len(req.UserIDs)})
  142. }
  143. // AffiliateUserSummary is the minimal user shape returned by LookupUsers,
  144. // shared with the frontend's add-custom-user picker.
  145. type AffiliateUserSummary struct {
  146. ID int64 `json:"id"`
  147. Email string `json:"email"`
  148. Username string `json:"username"`
  149. }
  150. // LookupUsers searches users by email/username for the "add custom user" modal.
  151. // GET /api/v1/admin/affiliates/users/lookup?q=
  152. func (h *AffiliateHandler) LookupUsers(c *gin.Context) {
  153. keyword := c.Query("q")
  154. if keyword == "" {
  155. response.Success(c, []AffiliateUserSummary{})
  156. return
  157. }
  158. users, _, err := h.adminService.ListUsers(c.Request.Context(), 1, 20, service.UserListFilters{Search: keyword}, "email", "asc")
  159. if err != nil {
  160. response.ErrorFrom(c, err)
  161. return
  162. }
  163. result := make([]AffiliateUserSummary, len(users))
  164. for i, u := range users {
  165. result[i] = AffiliateUserSummary{ID: u.ID, Email: u.Email, Username: u.Username}
  166. }
  167. response.Success(c, result)
  168. }
  169. // GetUserOverview returns one user's affiliate overview.
  170. // GET /api/v1/admin/affiliates/users/:user_id/overview
  171. func (h *AffiliateHandler) GetUserOverview(c *gin.Context) {
  172. userID, err := strconv.ParseInt(c.Param("user_id"), 10, 64)
  173. if err != nil || userID <= 0 {
  174. response.BadRequest(c, "Invalid user_id")
  175. return
  176. }
  177. overview, err := h.affiliateService.AdminGetUserOverview(c.Request.Context(), userID)
  178. if err != nil {
  179. response.ErrorFrom(c, err)
  180. return
  181. }
  182. response.Success(c, overview)
  183. }
  184. // ListInviteRecords returns all inviter-invitee relationships.
  185. // GET /api/v1/admin/affiliates/invites
  186. func (h *AffiliateHandler) ListInviteRecords(c *gin.Context) {
  187. page, pageSize := response.ParsePagination(c)
  188. filter := parseAffiliateRecordFilter(c, page, pageSize)
  189. items, total, err := h.affiliateService.AdminListInviteRecords(c.Request.Context(), filter)
  190. if err != nil {
  191. response.ErrorFrom(c, err)
  192. return
  193. }
  194. response.Paginated(c, items, total, filter.Page, filter.PageSize)
  195. }
  196. // ListRebateRecords returns all order-level affiliate rebate records.
  197. // GET /api/v1/admin/affiliates/rebates
  198. func (h *AffiliateHandler) ListRebateRecords(c *gin.Context) {
  199. page, pageSize := response.ParsePagination(c)
  200. filter := parseAffiliateRecordFilter(c, page, pageSize)
  201. items, total, err := h.affiliateService.AdminListRebateRecords(c.Request.Context(), filter)
  202. if err != nil {
  203. response.ErrorFrom(c, err)
  204. return
  205. }
  206. response.Paginated(c, items, total, filter.Page, filter.PageSize)
  207. }
  208. // ListTransferRecords returns all affiliate quota-to-balance transfer records.
  209. // GET /api/v1/admin/affiliates/transfers
  210. func (h *AffiliateHandler) ListTransferRecords(c *gin.Context) {
  211. page, pageSize := response.ParsePagination(c)
  212. filter := parseAffiliateRecordFilter(c, page, pageSize)
  213. items, total, err := h.affiliateService.AdminListTransferRecords(c.Request.Context(), filter)
  214. if err != nil {
  215. response.ErrorFrom(c, err)
  216. return
  217. }
  218. response.Paginated(c, items, total, filter.Page, filter.PageSize)
  219. }
  220. func parseAffiliateRecordFilter(c *gin.Context, page, pageSize int) service.AffiliateRecordFilter {
  221. filter := service.AffiliateRecordFilter{
  222. Search: c.Query("search"),
  223. Page: page,
  224. PageSize: pageSize,
  225. SortBy: c.Query("sort_by"),
  226. SortDesc: c.Query("sort_order") != "asc",
  227. }
  228. if filter.PageSize > 100 {
  229. filter.PageSize = 100
  230. }
  231. userTZ := c.Query("timezone")
  232. if t := parseAffiliateRecordStartTime(c.Query("start_at"), userTZ); t != nil {
  233. filter.StartAt = t
  234. }
  235. if t := parseAffiliateRecordEndTime(c.Query("end_at"), userTZ); t != nil {
  236. filter.EndAt = t
  237. }
  238. return filter
  239. }
  240. func parseAffiliateRecordStartTime(raw string, userTZ string) *time.Time {
  241. raw = strings.TrimSpace(raw)
  242. if raw == "" {
  243. return nil
  244. }
  245. if parsed, err := time.Parse(time.RFC3339, raw); err == nil {
  246. return &parsed
  247. }
  248. if parsed, err := timezone.ParseInUserLocation("2006-01-02", raw, userTZ); err == nil {
  249. return &parsed
  250. }
  251. return nil
  252. }
  253. func parseAffiliateRecordEndTime(raw string, userTZ string) *time.Time {
  254. raw = strings.TrimSpace(raw)
  255. if raw == "" {
  256. return nil
  257. }
  258. if parsed, err := time.Parse(time.RFC3339, raw); err == nil {
  259. return &parsed
  260. }
  261. if parsed, err := timezone.ParseInUserLocation("2006-01-02", raw, userTZ); err == nil {
  262. end := parsed.AddDate(0, 0, 1).Add(-time.Nanosecond)
  263. return &end
  264. }
  265. return nil
  266. }