redeem_code_repo.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446
  1. package repository
  2. import (
  3. "context"
  4. "strings"
  5. "time"
  6. dbent "github.com/Wei-Shaw/sub2api/ent"
  7. "github.com/Wei-Shaw/sub2api/ent/redeemcode"
  8. "github.com/Wei-Shaw/sub2api/ent/user"
  9. "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
  10. "github.com/Wei-Shaw/sub2api/internal/service"
  11. entsql "entgo.io/ent/dialect/sql"
  12. )
  13. type redeemCodeRepository struct {
  14. client *dbent.Client
  15. }
  16. func NewRedeemCodeRepository(client *dbent.Client) service.RedeemCodeRepository {
  17. return &redeemCodeRepository{client: client}
  18. }
  19. func (r *redeemCodeRepository) Create(ctx context.Context, code *service.RedeemCode) error {
  20. created, err := r.client.RedeemCode.Create().
  21. SetCode(code.Code).
  22. SetType(code.Type).
  23. SetValue(code.Value).
  24. SetStatus(code.Status).
  25. SetNotes(code.Notes).
  26. SetValidityDays(code.ValidityDays).
  27. SetNillableExpiresAt(code.ExpiresAt).
  28. SetNillableUsedBy(code.UsedBy).
  29. SetNillableUsedAt(code.UsedAt).
  30. SetNillableGroupID(code.GroupID).
  31. Save(ctx)
  32. if err == nil {
  33. code.ID = created.ID
  34. code.CreatedAt = created.CreatedAt
  35. }
  36. return err
  37. }
  38. func (r *redeemCodeRepository) CreateBatch(ctx context.Context, codes []service.RedeemCode) error {
  39. if len(codes) == 0 {
  40. return nil
  41. }
  42. builders := make([]*dbent.RedeemCodeCreate, 0, len(codes))
  43. for i := range codes {
  44. c := &codes[i]
  45. b := r.client.RedeemCode.Create().
  46. SetCode(c.Code).
  47. SetType(c.Type).
  48. SetValue(c.Value).
  49. SetStatus(c.Status).
  50. SetNotes(c.Notes).
  51. SetValidityDays(c.ValidityDays).
  52. SetNillableExpiresAt(c.ExpiresAt).
  53. SetNillableUsedBy(c.UsedBy).
  54. SetNillableUsedAt(c.UsedAt).
  55. SetNillableGroupID(c.GroupID)
  56. builders = append(builders, b)
  57. }
  58. return r.client.RedeemCode.CreateBulk(builders...).Exec(ctx)
  59. }
  60. func (r *redeemCodeRepository) GetByID(ctx context.Context, id int64) (*service.RedeemCode, error) {
  61. m, err := r.client.RedeemCode.Query().
  62. Where(redeemcode.IDEQ(id)).
  63. Only(ctx)
  64. if err != nil {
  65. if dbent.IsNotFound(err) {
  66. return nil, service.ErrRedeemCodeNotFound
  67. }
  68. return nil, err
  69. }
  70. return redeemCodeEntityToService(m), nil
  71. }
  72. func (r *redeemCodeRepository) GetByCode(ctx context.Context, code string) (*service.RedeemCode, error) {
  73. m, err := r.client.RedeemCode.Query().
  74. Where(redeemcode.CodeEQ(code)).
  75. Only(ctx)
  76. if err != nil {
  77. if dbent.IsNotFound(err) {
  78. return nil, service.ErrRedeemCodeNotFound
  79. }
  80. return nil, err
  81. }
  82. return redeemCodeEntityToService(m), nil
  83. }
  84. func (r *redeemCodeRepository) Delete(ctx context.Context, id int64) error {
  85. _, err := r.client.RedeemCode.Delete().Where(redeemcode.IDEQ(id)).Exec(ctx)
  86. return err
  87. }
  88. func (r *redeemCodeRepository) List(ctx context.Context, params pagination.PaginationParams) ([]service.RedeemCode, *pagination.PaginationResult, error) {
  89. return r.ListWithFilters(ctx, params, "", "", "")
  90. }
  91. func (r *redeemCodeRepository) ListWithFilters(ctx context.Context, params pagination.PaginationParams, codeType, status, search string) ([]service.RedeemCode, *pagination.PaginationResult, error) {
  92. q := r.client.RedeemCode.Query()
  93. if codeType != "" {
  94. q = q.Where(redeemcode.TypeEQ(codeType))
  95. }
  96. if status != "" {
  97. now := time.Now()
  98. switch status {
  99. case service.StatusExpired:
  100. q = q.Where(redeemcode.Or(
  101. redeemcode.StatusEQ(service.StatusExpired),
  102. redeemcode.And(
  103. redeemcode.StatusEQ(service.StatusUnused),
  104. redeemcode.ExpiresAtNotNil(),
  105. redeemcode.ExpiresAtLTE(now),
  106. ),
  107. ))
  108. case service.StatusUnused:
  109. q = q.Where(
  110. redeemcode.StatusEQ(service.StatusUnused),
  111. redeemcode.Or(
  112. redeemcode.ExpiresAtIsNil(),
  113. redeemcode.ExpiresAtGT(now),
  114. ),
  115. )
  116. default:
  117. q = q.Where(redeemcode.StatusEQ(status))
  118. }
  119. }
  120. if search != "" {
  121. q = q.Where(
  122. redeemcode.Or(
  123. redeemcode.CodeContainsFold(search),
  124. redeemcode.HasUserWith(user.EmailContainsFold(search)),
  125. ),
  126. )
  127. }
  128. total, err := q.Count(ctx)
  129. if err != nil {
  130. return nil, nil, err
  131. }
  132. codesQuery := q.
  133. WithUser().
  134. WithGroup().
  135. Offset(params.Offset()).
  136. Limit(params.Limit())
  137. for _, order := range redeemCodeListOrder(params) {
  138. codesQuery = codesQuery.Order(order)
  139. }
  140. codes, err := codesQuery.All(ctx)
  141. if err != nil {
  142. return nil, nil, err
  143. }
  144. outCodes := redeemCodeEntitiesToService(codes)
  145. return outCodes, paginationResultFromTotal(int64(total), params), nil
  146. }
  147. func redeemCodeListOrder(params pagination.PaginationParams) []func(*entsql.Selector) {
  148. sortBy := strings.ToLower(strings.TrimSpace(params.SortBy))
  149. sortOrder := params.NormalizedSortOrder(pagination.SortOrderDesc)
  150. var field string
  151. switch sortBy {
  152. case "type":
  153. field = redeemcode.FieldType
  154. case "value":
  155. field = redeemcode.FieldValue
  156. case "status":
  157. field = redeemcode.FieldStatus
  158. case "used_at":
  159. field = redeemcode.FieldUsedAt
  160. case "created_at":
  161. field = redeemcode.FieldCreatedAt
  162. case "expires_at":
  163. field = redeemcode.FieldExpiresAt
  164. case "code":
  165. field = redeemcode.FieldCode
  166. default:
  167. field = redeemcode.FieldID
  168. }
  169. if sortOrder == pagination.SortOrderAsc {
  170. return []func(*entsql.Selector){dbent.Asc(field), dbent.Asc(redeemcode.FieldID)}
  171. }
  172. return []func(*entsql.Selector){dbent.Desc(field), dbent.Desc(redeemcode.FieldID)}
  173. }
  174. func (r *redeemCodeRepository) Update(ctx context.Context, code *service.RedeemCode) error {
  175. up := r.client.RedeemCode.UpdateOneID(code.ID).
  176. SetCode(code.Code).
  177. SetType(code.Type).
  178. SetValue(code.Value).
  179. SetStatus(code.Status).
  180. SetNotes(code.Notes).
  181. SetValidityDays(code.ValidityDays)
  182. if code.UsedBy != nil {
  183. up.SetUsedBy(*code.UsedBy)
  184. } else {
  185. up.ClearUsedBy()
  186. }
  187. if code.UsedAt != nil {
  188. up.SetUsedAt(*code.UsedAt)
  189. } else {
  190. up.ClearUsedAt()
  191. }
  192. if code.GroupID != nil {
  193. up.SetGroupID(*code.GroupID)
  194. } else {
  195. up.ClearGroupID()
  196. }
  197. if code.ExpiresAt != nil {
  198. up.SetExpiresAt(*code.ExpiresAt)
  199. } else {
  200. up.ClearExpiresAt()
  201. }
  202. updated, err := up.Save(ctx)
  203. if err != nil {
  204. if dbent.IsNotFound(err) {
  205. return service.ErrRedeemCodeNotFound
  206. }
  207. return err
  208. }
  209. code.CreatedAt = updated.CreatedAt
  210. return nil
  211. }
  212. func (r *redeemCodeRepository) BatchUpdate(ctx context.Context, ids []int64, fields service.RedeemCodeBatchUpdateFields) (int64, error) {
  213. uniqueIDs := make([]int64, 0, len(ids))
  214. seen := make(map[int64]struct{}, len(ids))
  215. for _, id := range ids {
  216. if _, ok := seen[id]; ok {
  217. continue
  218. }
  219. seen[id] = struct{}{}
  220. uniqueIDs = append(uniqueIDs, id)
  221. }
  222. if len(uniqueIDs) == 0 {
  223. return 0, nil
  224. }
  225. if tx := dbent.TxFromContext(ctx); tx != nil {
  226. return r.batchUpdate(ctx, tx.Client(), uniqueIDs, fields)
  227. }
  228. tx, err := r.client.Tx(ctx)
  229. if err != nil {
  230. return 0, err
  231. }
  232. txCtx := dbent.NewTxContext(ctx, tx)
  233. defer func() { _ = tx.Rollback() }()
  234. updated, err := r.batchUpdate(txCtx, tx.Client(), uniqueIDs, fields)
  235. if err != nil {
  236. return 0, err
  237. }
  238. if err := tx.Commit(); err != nil {
  239. return 0, err
  240. }
  241. return updated, nil
  242. }
  243. func (r *redeemCodeRepository) batchUpdate(ctx context.Context, client *dbent.Client, ids []int64, fields service.RedeemCodeBatchUpdateFields) (int64, error) {
  244. existing, err := client.RedeemCode.Query().
  245. Where(redeemcode.IDIn(ids...)).
  246. All(ctx)
  247. if err != nil {
  248. return 0, err
  249. }
  250. if len(existing) != len(ids) {
  251. return 0, service.ErrRedeemCodeNotFound
  252. }
  253. if fields.TouchesUsedSensitiveFields() {
  254. for _, code := range existing {
  255. if code.Status == service.StatusUsed {
  256. return 0, service.ErrRedeemCodeUsed
  257. }
  258. }
  259. }
  260. up := client.RedeemCode.Update().Where(redeemcode.IDIn(ids...))
  261. if fields.Status != nil {
  262. up.SetStatus(*fields.Status)
  263. }
  264. if fields.Notes != nil {
  265. up.SetNotes(*fields.Notes)
  266. }
  267. if fields.ExpiresAt.Set {
  268. if fields.ExpiresAt.Value != nil {
  269. up.SetExpiresAt(*fields.ExpiresAt.Value)
  270. } else {
  271. up.ClearExpiresAt()
  272. }
  273. }
  274. if fields.GroupID.Set {
  275. if fields.GroupID.Value != nil {
  276. up.SetGroupID(*fields.GroupID.Value)
  277. } else {
  278. up.ClearGroupID()
  279. }
  280. }
  281. affected, err := up.Save(ctx)
  282. if err != nil {
  283. return 0, err
  284. }
  285. if affected != len(ids) {
  286. return 0, service.ErrRedeemCodeNotFound
  287. }
  288. return int64(affected), nil
  289. }
  290. func (r *redeemCodeRepository) Use(ctx context.Context, id, userID int64) error {
  291. now := time.Now()
  292. client := clientFromContext(ctx, r.client)
  293. affected, err := client.RedeemCode.Update().
  294. Where(redeemcode.IDEQ(id), redeemcode.StatusEQ(service.StatusUnused)).
  295. SetStatus(service.StatusUsed).
  296. SetUsedBy(userID).
  297. SetUsedAt(now).
  298. Save(ctx)
  299. if err != nil {
  300. return err
  301. }
  302. if affected == 0 {
  303. return service.ErrRedeemCodeUsed
  304. }
  305. return nil
  306. }
  307. func (r *redeemCodeRepository) ListByUser(ctx context.Context, userID int64, limit int) ([]service.RedeemCode, error) {
  308. if limit <= 0 {
  309. limit = 10
  310. }
  311. codes, err := r.client.RedeemCode.Query().
  312. Where(redeemcode.UsedByEQ(userID)).
  313. WithGroup().
  314. Order(dbent.Desc(redeemcode.FieldUsedAt)).
  315. Limit(limit).
  316. All(ctx)
  317. if err != nil {
  318. return nil, err
  319. }
  320. return redeemCodeEntitiesToService(codes), nil
  321. }
  322. // ListByUserPaginated returns paginated balance/concurrency history for a user.
  323. // Supports optional type filter (e.g. "balance", "admin_balance", "concurrency", "admin_concurrency", "subscription").
  324. func (r *redeemCodeRepository) ListByUserPaginated(ctx context.Context, userID int64, params pagination.PaginationParams, codeType string) ([]service.RedeemCode, *pagination.PaginationResult, error) {
  325. q := r.client.RedeemCode.Query().
  326. Where(redeemcode.UsedByEQ(userID))
  327. // Optional type filter
  328. if codeType != "" {
  329. q = q.Where(redeemcode.TypeEQ(codeType))
  330. }
  331. total, err := q.Count(ctx)
  332. if err != nil {
  333. return nil, nil, err
  334. }
  335. codes, err := q.
  336. WithGroup().
  337. Offset(params.Offset()).
  338. Limit(params.Limit()).
  339. Order(dbent.Desc(redeemcode.FieldUsedAt)).
  340. All(ctx)
  341. if err != nil {
  342. return nil, nil, err
  343. }
  344. return redeemCodeEntitiesToService(codes), paginationResultFromTotal(int64(total), params), nil
  345. }
  346. // SumPositiveBalanceByUser returns total recharged amount (sum of value > 0 where type is balance/admin_balance).
  347. func (r *redeemCodeRepository) SumPositiveBalanceByUser(ctx context.Context, userID int64) (float64, error) {
  348. var result []struct {
  349. Sum float64 `json:"sum"`
  350. }
  351. err := r.client.RedeemCode.Query().
  352. Where(
  353. redeemcode.UsedByEQ(userID),
  354. redeemcode.ValueGT(0),
  355. redeemcode.TypeIn("balance", "admin_balance"),
  356. ).
  357. Aggregate(dbent.As(dbent.Sum(redeemcode.FieldValue), "sum")).
  358. Scan(ctx, &result)
  359. if err != nil {
  360. return 0, err
  361. }
  362. if len(result) == 0 {
  363. return 0, nil
  364. }
  365. return result[0].Sum, nil
  366. }
  367. func redeemCodeEntityToService(m *dbent.RedeemCode) *service.RedeemCode {
  368. if m == nil {
  369. return nil
  370. }
  371. out := &service.RedeemCode{
  372. ID: m.ID,
  373. Code: m.Code,
  374. Type: m.Type,
  375. Value: m.Value,
  376. Status: m.Status,
  377. UsedBy: m.UsedBy,
  378. UsedAt: m.UsedAt,
  379. Notes: derefString(m.Notes),
  380. CreatedAt: m.CreatedAt,
  381. ExpiresAt: m.ExpiresAt,
  382. GroupID: m.GroupID,
  383. ValidityDays: m.ValidityDays,
  384. }
  385. if m.Edges.User != nil {
  386. out.User = userEntityToService(m.Edges.User)
  387. }
  388. if m.Edges.Group != nil {
  389. out.Group = groupEntityToService(m.Edges.Group)
  390. }
  391. return out
  392. }
  393. func redeemCodeEntitiesToService(models []*dbent.RedeemCode) []service.RedeemCode {
  394. out := make([]service.RedeemCode, 0, len(models))
  395. for i := range models {
  396. if s := redeemCodeEntityToService(models[i]); s != nil {
  397. out = append(out, *s)
  398. }
  399. }
  400. return out
  401. }