affiliate_service.go 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624
  1. package service
  2. import (
  3. "context"
  4. "errors"
  5. "math"
  6. "strings"
  7. "time"
  8. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  9. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  10. )
  11. var (
  12. ErrAffiliateProfileNotFound = infraerrors.NotFound("AFFILIATE_PROFILE_NOT_FOUND", "affiliate profile not found")
  13. ErrAffiliateCodeInvalid = infraerrors.BadRequest("AFFILIATE_CODE_INVALID", "invalid affiliate code")
  14. ErrAffiliateCodeTaken = infraerrors.Conflict("AFFILIATE_CODE_TAKEN", "affiliate code already in use")
  15. ErrAffiliateAlreadyBound = infraerrors.Conflict("AFFILIATE_ALREADY_BOUND", "affiliate inviter already bound")
  16. ErrAffiliateQuotaEmpty = infraerrors.BadRequest("AFFILIATE_QUOTA_EMPTY", "no affiliate quota available to transfer")
  17. )
  18. const (
  19. affiliateInviteesLimit = 100
  20. // AffiliateCodeMinLength / AffiliateCodeMaxLength bound both system-generated
  21. // 12-char codes and admin-customized codes (e.g. "VIP2026").
  22. AffiliateCodeMinLength = 4
  23. AffiliateCodeMaxLength = 32
  24. )
  25. // affiliateCodeValidChar accepts uppercase letters, digits, underscore and dash.
  26. // All input passes through strings.ToUpper before validation, so lowercase from
  27. // users is normalized — admins may supply mixed case in their UI.
  28. var affiliateCodeValidChar = func() [256]bool {
  29. var tbl [256]bool
  30. for c := byte('A'); c <= 'Z'; c++ {
  31. tbl[c] = true
  32. }
  33. for c := byte('0'); c <= '9'; c++ {
  34. tbl[c] = true
  35. }
  36. tbl['_'] = true
  37. tbl['-'] = true
  38. return tbl
  39. }()
  40. // isValidAffiliateCodeFormat validates code format for both binding (user input)
  41. // and admin updates. Caller is expected to upper-case the input first.
  42. func isValidAffiliateCodeFormat(code string) bool {
  43. if len(code) < AffiliateCodeMinLength || len(code) > AffiliateCodeMaxLength {
  44. return false
  45. }
  46. for i := 0; i < len(code); i++ {
  47. if !affiliateCodeValidChar[code[i]] {
  48. return false
  49. }
  50. }
  51. return true
  52. }
  53. type AffiliateSummary struct {
  54. UserID int64 `json:"user_id"`
  55. AffCode string `json:"aff_code"`
  56. AffCodeCustom bool `json:"aff_code_custom"`
  57. AffRebateRatePercent *float64 `json:"aff_rebate_rate_percent,omitempty"`
  58. InviterID *int64 `json:"inviter_id,omitempty"`
  59. AffCount int `json:"aff_count"`
  60. AffQuota float64 `json:"aff_quota"`
  61. AffFrozenQuota float64 `json:"aff_frozen_quota"`
  62. AffHistoryQuota float64 `json:"aff_history_quota"`
  63. CreatedAt time.Time `json:"created_at"`
  64. UpdatedAt time.Time `json:"updated_at"`
  65. }
  66. type AffiliateInvitee struct {
  67. UserID int64 `json:"user_id"`
  68. Email string `json:"email"`
  69. Username string `json:"username"`
  70. CreatedAt *time.Time `json:"created_at,omitempty"`
  71. TotalRebate float64 `json:"total_rebate"`
  72. }
  73. type AffiliateDetail struct {
  74. UserID int64 `json:"user_id"`
  75. AffCode string `json:"aff_code"`
  76. InviterID *int64 `json:"inviter_id,omitempty"`
  77. AffCount int `json:"aff_count"`
  78. AffQuota float64 `json:"aff_quota"`
  79. AffFrozenQuota float64 `json:"aff_frozen_quota"`
  80. AffHistoryQuota float64 `json:"aff_history_quota"`
  81. // EffectiveRebateRatePercent 是当前用户作为邀请人时实际生效的返利比例:
  82. // 优先用户自己的专属比例(aff_rebate_rate_percent),否则回退到全局比例。
  83. // 用于在用户的 /affiliate 页面直观展示「分享后能拿到多少」。
  84. EffectiveRebateRatePercent float64 `json:"effective_rebate_rate_percent"`
  85. Invitees []AffiliateInvitee `json:"invitees"`
  86. }
  87. type AffiliateRepository interface {
  88. EnsureUserAffiliate(ctx context.Context, userID int64) (*AffiliateSummary, error)
  89. GetAffiliateByCode(ctx context.Context, code string) (*AffiliateSummary, error)
  90. BindInviter(ctx context.Context, userID, inviterID int64) (bool, error)
  91. AccrueQuota(ctx context.Context, inviterID, inviteeUserID int64, amount float64, freezeHours int, sourceOrderID *int64) (bool, error)
  92. GetAccruedRebateFromInvitee(ctx context.Context, inviterID, inviteeUserID int64) (float64, error)
  93. ThawFrozenQuota(ctx context.Context, userID int64) (float64, error)
  94. TransferQuotaToBalance(ctx context.Context, userID int64) (float64, float64, error)
  95. ListInvitees(ctx context.Context, inviterID int64, limit int) ([]AffiliateInvitee, error)
  96. // 管理端:用户级专属配置
  97. UpdateUserAffCode(ctx context.Context, userID int64, newCode string) error
  98. ResetUserAffCode(ctx context.Context, userID int64) (string, error)
  99. SetUserRebateRate(ctx context.Context, userID int64, ratePercent *float64) error
  100. BatchSetUserRebateRate(ctx context.Context, userIDs []int64, ratePercent *float64) error
  101. ListUsersWithCustomSettings(ctx context.Context, filter AffiliateAdminFilter) ([]AffiliateAdminEntry, int64, error)
  102. ListAffiliateInviteRecords(ctx context.Context, filter AffiliateRecordFilter) ([]AffiliateInviteRecord, int64, error)
  103. ListAffiliateRebateRecords(ctx context.Context, filter AffiliateRecordFilter) ([]AffiliateRebateRecord, int64, error)
  104. ListAffiliateTransferRecords(ctx context.Context, filter AffiliateRecordFilter) ([]AffiliateTransferRecord, int64, error)
  105. GetAffiliateUserOverview(ctx context.Context, userID int64) (*AffiliateUserOverview, error)
  106. }
  107. // AffiliateAdminFilter 列表筛选条件
  108. type AffiliateAdminFilter struct {
  109. Search string
  110. Page int
  111. PageSize int
  112. }
  113. // AffiliateAdminEntry 专属用户列表条目
  114. type AffiliateAdminEntry struct {
  115. UserID int64 `json:"user_id"`
  116. Email string `json:"email"`
  117. Username string `json:"username"`
  118. AffCode string `json:"aff_code"`
  119. AffCodeCustom bool `json:"aff_code_custom"`
  120. AffRebateRatePercent *float64 `json:"aff_rebate_rate_percent,omitempty"`
  121. AffCount int `json:"aff_count"`
  122. }
  123. type AffiliateRecordFilter struct {
  124. Search string
  125. Page int
  126. PageSize int
  127. StartAt *time.Time
  128. EndAt *time.Time
  129. SortBy string
  130. SortDesc bool
  131. }
  132. type AffiliateInviteRecord struct {
  133. InviterID int64 `json:"inviter_id"`
  134. InviterEmail string `json:"inviter_email"`
  135. InviterUsername string `json:"inviter_username"`
  136. InviteeID int64 `json:"invitee_id"`
  137. InviteeEmail string `json:"invitee_email"`
  138. InviteeUsername string `json:"invitee_username"`
  139. AffCode string `json:"aff_code"`
  140. TotalRebate float64 `json:"total_rebate"`
  141. CreatedAt time.Time `json:"created_at"`
  142. }
  143. type AffiliateRebateRecord struct {
  144. OrderID int64 `json:"order_id"`
  145. OutTradeNo string `json:"out_trade_no"`
  146. InviterID int64 `json:"inviter_id"`
  147. InviterEmail string `json:"inviter_email"`
  148. InviterUsername string `json:"inviter_username"`
  149. InviteeID int64 `json:"invitee_id"`
  150. InviteeEmail string `json:"invitee_email"`
  151. InviteeUsername string `json:"invitee_username"`
  152. OrderAmount float64 `json:"order_amount"`
  153. PayAmount float64 `json:"pay_amount"`
  154. RebateAmount float64 `json:"rebate_amount"`
  155. PaymentType string `json:"payment_type"`
  156. OrderStatus string `json:"order_status"`
  157. CreatedAt time.Time `json:"created_at"`
  158. }
  159. type AffiliateTransferRecord struct {
  160. LedgerID int64 `json:"ledger_id"`
  161. UserID int64 `json:"user_id"`
  162. UserEmail string `json:"user_email"`
  163. Username string `json:"username"`
  164. Amount float64 `json:"amount"`
  165. BalanceAfter *float64 `json:"balance_after,omitempty"`
  166. AvailableQuotaAfter *float64 `json:"available_quota_after,omitempty"`
  167. FrozenQuotaAfter *float64 `json:"frozen_quota_after,omitempty"`
  168. HistoryQuotaAfter *float64 `json:"history_quota_after,omitempty"`
  169. SnapshotAvailable bool `json:"snapshot_available"`
  170. CurrentBalance float64 `json:"-"`
  171. RemainingQuota float64 `json:"-"`
  172. FrozenQuota float64 `json:"-"`
  173. HistoryQuota float64 `json:"-"`
  174. CreatedAt time.Time `json:"created_at"`
  175. }
  176. type AffiliateUserOverview struct {
  177. UserID int64 `json:"user_id"`
  178. Email string `json:"email"`
  179. Username string `json:"username"`
  180. AffCode string `json:"aff_code"`
  181. RebateRatePercent float64 `json:"rebate_rate_percent"`
  182. RebateRateCustom bool `json:"-"`
  183. InvitedCount int `json:"invited_count"`
  184. RebatedInviteeCount int `json:"rebated_invitee_count"`
  185. AvailableQuota float64 `json:"available_quota"`
  186. HistoryQuota float64 `json:"history_quota"`
  187. }
  188. type AffiliateService struct {
  189. repo AffiliateRepository
  190. settingService *SettingService
  191. authCacheInvalidator APIKeyAuthCacheInvalidator
  192. billingCacheService *BillingCacheService
  193. }
  194. func NewAffiliateService(repo AffiliateRepository, settingService *SettingService, authCacheInvalidator APIKeyAuthCacheInvalidator, billingCacheService *BillingCacheService) *AffiliateService {
  195. return &AffiliateService{
  196. repo: repo,
  197. settingService: settingService,
  198. authCacheInvalidator: authCacheInvalidator,
  199. billingCacheService: billingCacheService,
  200. }
  201. }
  202. // IsEnabled reports whether the affiliate (邀请返利) feature is turned on.
  203. func (s *AffiliateService) IsEnabled(ctx context.Context) bool {
  204. if s == nil || s.settingService == nil {
  205. return AffiliateEnabledDefault
  206. }
  207. return s.settingService.IsAffiliateEnabled(ctx)
  208. }
  209. func (s *AffiliateService) EnsureUserAffiliate(ctx context.Context, userID int64) (*AffiliateSummary, error) {
  210. if userID <= 0 {
  211. return nil, infraerrors.BadRequest("INVALID_USER", "invalid user")
  212. }
  213. if s == nil || s.repo == nil {
  214. return nil, infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
  215. }
  216. return s.repo.EnsureUserAffiliate(ctx, userID)
  217. }
  218. func (s *AffiliateService) GetAffiliateDetail(ctx context.Context, userID int64) (*AffiliateDetail, error) {
  219. // Lazy thaw: move any matured frozen quota to available before reading.
  220. if s != nil && s.repo != nil {
  221. // best-effort: thaw failure is non-fatal
  222. _, _ = s.repo.ThawFrozenQuota(ctx, userID)
  223. }
  224. summary, err := s.EnsureUserAffiliate(ctx, userID)
  225. if err != nil {
  226. return nil, err
  227. }
  228. invitees, err := s.listInvitees(ctx, userID)
  229. if err != nil {
  230. return nil, err
  231. }
  232. return &AffiliateDetail{
  233. UserID: summary.UserID,
  234. AffCode: summary.AffCode,
  235. InviterID: summary.InviterID,
  236. AffCount: summary.AffCount,
  237. AffQuota: summary.AffQuota,
  238. AffFrozenQuota: summary.AffFrozenQuota,
  239. AffHistoryQuota: summary.AffHistoryQuota,
  240. EffectiveRebateRatePercent: s.resolveRebateRatePercent(ctx, summary),
  241. Invitees: invitees,
  242. }, nil
  243. }
  244. func (s *AffiliateService) BindInviterByCode(ctx context.Context, userID int64, rawCode string) error {
  245. code := strings.ToUpper(strings.TrimSpace(rawCode))
  246. if code == "" {
  247. return nil
  248. }
  249. if s == nil || s.repo == nil {
  250. return infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
  251. }
  252. // 总开关关闭时,注册阶段静默忽略 aff 参数(不报错,避免阻断注册流程)
  253. if !s.IsEnabled(ctx) {
  254. return nil
  255. }
  256. if !isValidAffiliateCodeFormat(code) {
  257. return ErrAffiliateCodeInvalid
  258. }
  259. selfSummary, err := s.repo.EnsureUserAffiliate(ctx, userID)
  260. if err != nil {
  261. return err
  262. }
  263. if selfSummary.InviterID != nil {
  264. return nil
  265. }
  266. inviterSummary, err := s.repo.GetAffiliateByCode(ctx, code)
  267. if err != nil {
  268. if errors.Is(err, ErrAffiliateProfileNotFound) {
  269. return ErrAffiliateCodeInvalid
  270. }
  271. return err
  272. }
  273. if inviterSummary == nil || inviterSummary.UserID <= 0 || inviterSummary.UserID == userID {
  274. return ErrAffiliateCodeInvalid
  275. }
  276. bound, err := s.repo.BindInviter(ctx, userID, inviterSummary.UserID)
  277. if err != nil {
  278. return err
  279. }
  280. if !bound {
  281. return ErrAffiliateAlreadyBound
  282. }
  283. return nil
  284. }
  285. func (s *AffiliateService) AccrueInviteRebate(ctx context.Context, inviteeUserID int64, baseRechargeAmount float64) (float64, error) {
  286. return s.AccrueInviteRebateForOrder(ctx, inviteeUserID, baseRechargeAmount, nil)
  287. }
  288. func (s *AffiliateService) AccrueInviteRebateForOrder(ctx context.Context, inviteeUserID int64, baseRechargeAmount float64, sourceOrderID *int64) (float64, error) {
  289. if s == nil || s.repo == nil {
  290. return 0, nil
  291. }
  292. if inviteeUserID <= 0 || baseRechargeAmount <= 0 || math.IsNaN(baseRechargeAmount) || math.IsInf(baseRechargeAmount, 0) {
  293. return 0, nil
  294. }
  295. // 总开关关闭时,新充值不再产生返利
  296. if !s.IsEnabled(ctx) {
  297. return 0, nil
  298. }
  299. inviteeSummary, err := s.repo.EnsureUserAffiliate(ctx, inviteeUserID)
  300. if err != nil {
  301. return 0, err
  302. }
  303. if inviteeSummary.InviterID == nil || *inviteeSummary.InviterID <= 0 {
  304. return 0, nil
  305. }
  306. // 加载邀请人 profile,优先使用专属比例(覆盖全局)
  307. inviterSummary, err := s.repo.EnsureUserAffiliate(ctx, *inviteeSummary.InviterID)
  308. if err != nil {
  309. return 0, err
  310. }
  311. // 有效期检查:超过返利有效期后不再产生返利
  312. if s.settingService != nil {
  313. if durationDays := s.settingService.GetAffiliateRebateDurationDays(ctx); durationDays > 0 {
  314. if time.Now().After(inviteeSummary.CreatedAt.AddDate(0, 0, durationDays)) {
  315. return 0, nil
  316. }
  317. }
  318. }
  319. rebateRatePercent := s.resolveRebateRatePercent(ctx, inviterSummary)
  320. rebate := roundTo(baseRechargeAmount*(rebateRatePercent/100), 8)
  321. if rebate <= 0 {
  322. return 0, nil
  323. }
  324. // 单人上限检查:精确截断到剩余额度
  325. if s.settingService != nil {
  326. if perInviteeCap := s.settingService.GetAffiliateRebatePerInviteeCap(ctx); perInviteeCap > 0 {
  327. existing, err := s.repo.GetAccruedRebateFromInvitee(ctx, *inviteeSummary.InviterID, inviteeUserID)
  328. if err != nil {
  329. return 0, err
  330. }
  331. if existing >= perInviteeCap {
  332. return 0, nil
  333. }
  334. if remaining := perInviteeCap - existing; rebate > remaining {
  335. rebate = roundTo(remaining, 8)
  336. }
  337. }
  338. }
  339. var freezeHours int
  340. if s.settingService != nil {
  341. freezeHours = s.settingService.GetAffiliateRebateFreezeHours(ctx)
  342. }
  343. applied, err := s.repo.AccrueQuota(ctx, *inviteeSummary.InviterID, inviteeUserID, rebate, freezeHours, sourceOrderID)
  344. if err != nil {
  345. return 0, err
  346. }
  347. if !applied {
  348. return 0, nil
  349. }
  350. return rebate, nil
  351. }
  352. // resolveRebateRatePercent returns the inviter's exclusive rate when set,
  353. // otherwise the global setting value (clamped to [Min, Max]).
  354. func (s *AffiliateService) resolveRebateRatePercent(ctx context.Context, inviter *AffiliateSummary) float64 {
  355. if inviter != nil && inviter.AffRebateRatePercent != nil {
  356. v := *inviter.AffRebateRatePercent
  357. if math.IsNaN(v) || math.IsInf(v, 0) {
  358. return s.globalRebateRatePercent(ctx)
  359. }
  360. return clampAffiliateRebateRate(v)
  361. }
  362. return s.globalRebateRatePercent(ctx)
  363. }
  364. // globalRebateRatePercent reads the system-wide rebate rate via SettingService,
  365. // returning the documented default when SettingService is unavailable.
  366. func (s *AffiliateService) globalRebateRatePercent(ctx context.Context) float64 {
  367. if s == nil || s.settingService == nil {
  368. return AffiliateRebateRateDefault
  369. }
  370. return s.settingService.GetAffiliateRebateRatePercent(ctx)
  371. }
  372. func (s *AffiliateService) TransferAffiliateQuota(ctx context.Context, userID int64) (float64, float64, error) {
  373. if s == nil || s.repo == nil {
  374. return 0, 0, infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
  375. }
  376. transferred, balance, err := s.repo.TransferQuotaToBalance(ctx, userID)
  377. if err != nil {
  378. return 0, 0, err
  379. }
  380. if transferred > 0 {
  381. s.invalidateAffiliateCaches(ctx, userID)
  382. }
  383. return transferred, balance, nil
  384. }
  385. func (s *AffiliateService) listInvitees(ctx context.Context, inviterID int64) ([]AffiliateInvitee, error) {
  386. if s == nil || s.repo == nil {
  387. return nil, infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
  388. }
  389. invitees, err := s.repo.ListInvitees(ctx, inviterID, affiliateInviteesLimit)
  390. if err != nil {
  391. return nil, err
  392. }
  393. for i := range invitees {
  394. invitees[i].Email = maskEmail(invitees[i].Email)
  395. }
  396. return invitees, nil
  397. }
  398. func roundTo(v float64, scale int) float64 {
  399. factor := math.Pow10(scale)
  400. return math.Round(v*factor) / factor
  401. }
  402. func maskEmail(email string) string {
  403. email = strings.TrimSpace(email)
  404. if email == "" {
  405. return ""
  406. }
  407. at := strings.Index(email, "@")
  408. if at <= 0 || at >= len(email)-1 {
  409. return "***"
  410. }
  411. local := email[:at]
  412. domain := email[at+1:]
  413. dot := strings.LastIndex(domain, ".")
  414. maskedLocal := maskSegment(local)
  415. if dot <= 0 || dot >= len(domain)-1 {
  416. return maskedLocal + "@" + maskSegment(domain)
  417. }
  418. domainName := domain[:dot]
  419. tld := domain[dot:]
  420. return maskedLocal + "@" + maskSegment(domainName) + tld
  421. }
  422. func maskSegment(s string) string {
  423. r := []rune(s)
  424. if len(r) == 0 {
  425. return "***"
  426. }
  427. if len(r) == 1 {
  428. return string(r[0]) + "***"
  429. }
  430. return string(r[0]) + "***"
  431. }
  432. func (s *AffiliateService) invalidateAffiliateCaches(ctx context.Context, userID int64) {
  433. if s.authCacheInvalidator != nil {
  434. s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
  435. }
  436. if s.billingCacheService != nil {
  437. if err := s.billingCacheService.InvalidateUserBalance(ctx, userID); err != nil {
  438. logger.LegacyPrintf("service.affiliate", "[Affiliate] Failed to invalidate billing cache for user %d: %v", userID, err)
  439. }
  440. }
  441. }
  442. // =========================
  443. // Admin: 专属配置管理
  444. // =========================
  445. // validateExclusiveRate ensures a per-user override is finite and within
  446. // [Min, Max]. nil is always valid (means "clear / fall back to global").
  447. func validateExclusiveRate(ratePercent *float64) error {
  448. if ratePercent == nil {
  449. return nil
  450. }
  451. v := *ratePercent
  452. if math.IsNaN(v) || math.IsInf(v, 0) {
  453. return infraerrors.BadRequest("INVALID_RATE", "invalid rebate rate")
  454. }
  455. if v < AffiliateRebateRateMin || v > AffiliateRebateRateMax {
  456. return infraerrors.BadRequest("INVALID_RATE", "rebate rate out of range")
  457. }
  458. return nil
  459. }
  460. // AdminUpdateUserAffCode 管理员改写用户的邀请码(专属邀请码)。
  461. func (s *AffiliateService) AdminUpdateUserAffCode(ctx context.Context, userID int64, rawCode string) error {
  462. if s == nil || s.repo == nil {
  463. return infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
  464. }
  465. code := strings.ToUpper(strings.TrimSpace(rawCode))
  466. if !isValidAffiliateCodeFormat(code) {
  467. return ErrAffiliateCodeInvalid
  468. }
  469. return s.repo.UpdateUserAffCode(ctx, userID, code)
  470. }
  471. // AdminResetUserAffCode 重置用户邀请码为系统随机码。
  472. func (s *AffiliateService) AdminResetUserAffCode(ctx context.Context, userID int64) (string, error) {
  473. if s == nil || s.repo == nil {
  474. return "", infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
  475. }
  476. return s.repo.ResetUserAffCode(ctx, userID)
  477. }
  478. // AdminSetUserRebateRate 设置/清除用户专属返利比例。ratePercent==nil 表示清除。
  479. func (s *AffiliateService) AdminSetUserRebateRate(ctx context.Context, userID int64, ratePercent *float64) error {
  480. if s == nil || s.repo == nil {
  481. return infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
  482. }
  483. if err := validateExclusiveRate(ratePercent); err != nil {
  484. return err
  485. }
  486. return s.repo.SetUserRebateRate(ctx, userID, ratePercent)
  487. }
  488. // AdminBatchSetUserRebateRate 批量设置/清除用户专属返利比例。
  489. func (s *AffiliateService) AdminBatchSetUserRebateRate(ctx context.Context, userIDs []int64, ratePercent *float64) error {
  490. if s == nil || s.repo == nil {
  491. return infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
  492. }
  493. if err := validateExclusiveRate(ratePercent); err != nil {
  494. return err
  495. }
  496. cleaned := make([]int64, 0, len(userIDs))
  497. for _, uid := range userIDs {
  498. if uid > 0 {
  499. cleaned = append(cleaned, uid)
  500. }
  501. }
  502. if len(cleaned) == 0 {
  503. return nil
  504. }
  505. return s.repo.BatchSetUserRebateRate(ctx, cleaned, ratePercent)
  506. }
  507. // AdminListCustomUsers 列出有专属配置的用户。
  508. func (s *AffiliateService) AdminListCustomUsers(ctx context.Context, filter AffiliateAdminFilter) ([]AffiliateAdminEntry, int64, error) {
  509. if s == nil || s.repo == nil {
  510. return nil, 0, infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
  511. }
  512. return s.repo.ListUsersWithCustomSettings(ctx, filter)
  513. }
  514. func (s *AffiliateService) AdminListInviteRecords(ctx context.Context, filter AffiliateRecordFilter) ([]AffiliateInviteRecord, int64, error) {
  515. if s == nil || s.repo == nil {
  516. return nil, 0, infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
  517. }
  518. return s.repo.ListAffiliateInviteRecords(ctx, normalizeAffiliateRecordFilter(filter))
  519. }
  520. func (s *AffiliateService) AdminListRebateRecords(ctx context.Context, filter AffiliateRecordFilter) ([]AffiliateRebateRecord, int64, error) {
  521. if s == nil || s.repo == nil {
  522. return nil, 0, infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
  523. }
  524. return s.repo.ListAffiliateRebateRecords(ctx, normalizeAffiliateRecordFilter(filter))
  525. }
  526. func (s *AffiliateService) AdminListTransferRecords(ctx context.Context, filter AffiliateRecordFilter) ([]AffiliateTransferRecord, int64, error) {
  527. if s == nil || s.repo == nil {
  528. return nil, 0, infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
  529. }
  530. return s.repo.ListAffiliateTransferRecords(ctx, normalizeAffiliateRecordFilter(filter))
  531. }
  532. func (s *AffiliateService) AdminGetUserOverview(ctx context.Context, userID int64) (*AffiliateUserOverview, error) {
  533. if userID <= 0 {
  534. return nil, infraerrors.BadRequest("INVALID_USER", "invalid user")
  535. }
  536. if s == nil || s.repo == nil {
  537. return nil, infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
  538. }
  539. overview, err := s.repo.GetAffiliateUserOverview(ctx, userID)
  540. if err != nil {
  541. return nil, err
  542. }
  543. if overview != nil {
  544. if !overview.RebateRateCustom {
  545. overview.RebateRatePercent = s.globalRebateRatePercent(ctx)
  546. }
  547. overview.RebateRatePercent = clampAffiliateRebateRate(overview.RebateRatePercent)
  548. }
  549. return overview, nil
  550. }
  551. func normalizeAffiliateRecordFilter(filter AffiliateRecordFilter) AffiliateRecordFilter {
  552. if filter.Page <= 0 {
  553. filter.Page = 1
  554. }
  555. if filter.PageSize <= 0 {
  556. filter.PageSize = 20
  557. }
  558. if filter.PageSize > 100 {
  559. filter.PageSize = 100
  560. }
  561. filter.Search = strings.TrimSpace(filter.Search)
  562. filter.SortBy = strings.TrimSpace(filter.SortBy)
  563. return filter
  564. }