announcement_service.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415
  1. package service
  2. import (
  3. "context"
  4. "fmt"
  5. "sort"
  6. "strings"
  7. "time"
  8. "github.com/Wei-Shaw/sub2api/internal/domain"
  9. "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
  10. )
  11. type AnnouncementService struct {
  12. announcementRepo AnnouncementRepository
  13. readRepo AnnouncementReadRepository
  14. userRepo UserRepository
  15. userSubRepo UserSubscriptionRepository
  16. }
  17. func NewAnnouncementService(
  18. announcementRepo AnnouncementRepository,
  19. readRepo AnnouncementReadRepository,
  20. userRepo UserRepository,
  21. userSubRepo UserSubscriptionRepository,
  22. ) *AnnouncementService {
  23. return &AnnouncementService{
  24. announcementRepo: announcementRepo,
  25. readRepo: readRepo,
  26. userRepo: userRepo,
  27. userSubRepo: userSubRepo,
  28. }
  29. }
  30. type CreateAnnouncementInput struct {
  31. Title string
  32. Content string
  33. Status string
  34. NotifyMode string
  35. Targeting AnnouncementTargeting
  36. StartsAt *time.Time
  37. EndsAt *time.Time
  38. ActorID *int64 // 管理员用户ID
  39. }
  40. type UpdateAnnouncementInput struct {
  41. Title *string
  42. Content *string
  43. Status *string
  44. NotifyMode *string
  45. Targeting *AnnouncementTargeting
  46. StartsAt **time.Time
  47. EndsAt **time.Time
  48. ActorID *int64 // 管理员用户ID
  49. }
  50. type UserAnnouncement struct {
  51. Announcement Announcement
  52. ReadAt *time.Time
  53. }
  54. type AnnouncementUserReadStatus struct {
  55. UserID int64 `json:"user_id"`
  56. Email string `json:"email"`
  57. Username string `json:"username"`
  58. Balance float64 `json:"balance"`
  59. Eligible bool `json:"eligible"`
  60. ReadAt *time.Time `json:"read_at,omitempty"`
  61. }
  62. func (s *AnnouncementService) Create(ctx context.Context, input *CreateAnnouncementInput) (*Announcement, error) {
  63. if input == nil {
  64. return nil, ErrAnnouncementNilInput
  65. }
  66. if !isJSONTimeInRange(input.StartsAt) || !isJSONTimeInRange(input.EndsAt) {
  67. return nil, ErrAnnouncementInvalidSchedule
  68. }
  69. title := strings.TrimSpace(input.Title)
  70. content := strings.TrimSpace(input.Content)
  71. if title == "" || len(title) > 200 {
  72. return nil, ErrAnnouncementInvalidTitle
  73. }
  74. if content == "" {
  75. return nil, ErrAnnouncementContentRequired
  76. }
  77. status := strings.TrimSpace(input.Status)
  78. if status == "" {
  79. status = AnnouncementStatusDraft
  80. }
  81. if !isValidAnnouncementStatus(status) {
  82. return nil, ErrAnnouncementInvalidStatus
  83. }
  84. targeting, err := domain.AnnouncementTargeting(input.Targeting).NormalizeAndValidate()
  85. if err != nil {
  86. return nil, err
  87. }
  88. notifyMode := strings.TrimSpace(input.NotifyMode)
  89. if notifyMode == "" {
  90. notifyMode = AnnouncementNotifyModeSilent
  91. }
  92. if !isValidAnnouncementNotifyMode(notifyMode) {
  93. return nil, ErrAnnouncementInvalidNotifyMode
  94. }
  95. if input.StartsAt != nil && input.EndsAt != nil {
  96. if !input.StartsAt.Before(*input.EndsAt) {
  97. return nil, ErrAnnouncementInvalidSchedule
  98. }
  99. }
  100. a := &Announcement{
  101. Title: title,
  102. Content: content,
  103. Status: status,
  104. NotifyMode: notifyMode,
  105. Targeting: targeting,
  106. StartsAt: input.StartsAt,
  107. EndsAt: input.EndsAt,
  108. }
  109. if input.ActorID != nil && *input.ActorID > 0 {
  110. a.CreatedBy = input.ActorID
  111. a.UpdatedBy = input.ActorID
  112. }
  113. if err := s.announcementRepo.Create(ctx, a); err != nil {
  114. return nil, fmt.Errorf("create announcement: %w", err)
  115. }
  116. return a, nil
  117. }
  118. func (s *AnnouncementService) Update(ctx context.Context, id int64, input *UpdateAnnouncementInput) (*Announcement, error) {
  119. if input == nil {
  120. return nil, ErrAnnouncementNilInput
  121. }
  122. if (input.StartsAt != nil && !isJSONTimeInRange(*input.StartsAt)) ||
  123. (input.EndsAt != nil && !isJSONTimeInRange(*input.EndsAt)) {
  124. return nil, ErrAnnouncementInvalidSchedule
  125. }
  126. a, err := s.announcementRepo.GetByID(ctx, id)
  127. if err != nil {
  128. return nil, err
  129. }
  130. if input.Title != nil {
  131. title := strings.TrimSpace(*input.Title)
  132. if title == "" || len(title) > 200 {
  133. return nil, ErrAnnouncementInvalidTitle
  134. }
  135. a.Title = title
  136. }
  137. if input.Content != nil {
  138. content := strings.TrimSpace(*input.Content)
  139. if content == "" {
  140. return nil, ErrAnnouncementContentRequired
  141. }
  142. a.Content = content
  143. }
  144. if input.Status != nil {
  145. status := strings.TrimSpace(*input.Status)
  146. if !isValidAnnouncementStatus(status) {
  147. return nil, ErrAnnouncementInvalidStatus
  148. }
  149. a.Status = status
  150. }
  151. if input.NotifyMode != nil {
  152. notifyMode := strings.TrimSpace(*input.NotifyMode)
  153. if !isValidAnnouncementNotifyMode(notifyMode) {
  154. return nil, ErrAnnouncementInvalidNotifyMode
  155. }
  156. a.NotifyMode = notifyMode
  157. }
  158. if input.Targeting != nil {
  159. targeting, err := domain.AnnouncementTargeting(*input.Targeting).NormalizeAndValidate()
  160. if err != nil {
  161. return nil, err
  162. }
  163. a.Targeting = targeting
  164. }
  165. if input.StartsAt != nil {
  166. a.StartsAt = *input.StartsAt
  167. }
  168. if input.EndsAt != nil {
  169. a.EndsAt = *input.EndsAt
  170. }
  171. if a.StartsAt != nil && a.EndsAt != nil {
  172. if !a.StartsAt.Before(*a.EndsAt) {
  173. return nil, ErrAnnouncementInvalidSchedule
  174. }
  175. }
  176. if input.ActorID != nil && *input.ActorID > 0 {
  177. a.UpdatedBy = input.ActorID
  178. }
  179. if err := s.announcementRepo.Update(ctx, a); err != nil {
  180. return nil, fmt.Errorf("update announcement: %w", err)
  181. }
  182. return a, nil
  183. }
  184. func (s *AnnouncementService) Delete(ctx context.Context, id int64) error {
  185. if err := s.announcementRepo.Delete(ctx, id); err != nil {
  186. return fmt.Errorf("delete announcement: %w", err)
  187. }
  188. return nil
  189. }
  190. func (s *AnnouncementService) GetByID(ctx context.Context, id int64) (*Announcement, error) {
  191. return s.announcementRepo.GetByID(ctx, id)
  192. }
  193. func (s *AnnouncementService) List(ctx context.Context, params pagination.PaginationParams, filters AnnouncementListFilters) ([]Announcement, *pagination.PaginationResult, error) {
  194. return s.announcementRepo.List(ctx, params, filters)
  195. }
  196. func (s *AnnouncementService) ListForUser(ctx context.Context, userID int64, unreadOnly bool) ([]UserAnnouncement, error) {
  197. user, err := s.userRepo.GetByID(ctx, userID)
  198. if err != nil {
  199. return nil, fmt.Errorf("get user: %w", err)
  200. }
  201. activeSubs, err := s.userSubRepo.ListActiveByUserID(ctx, userID)
  202. if err != nil {
  203. return nil, fmt.Errorf("list active subscriptions: %w", err)
  204. }
  205. activeGroupIDs := make(map[int64]struct{}, len(activeSubs))
  206. for i := range activeSubs {
  207. activeGroupIDs[activeSubs[i].GroupID] = struct{}{}
  208. }
  209. now := time.Now()
  210. anns, err := s.announcementRepo.ListActive(ctx, now)
  211. if err != nil {
  212. return nil, fmt.Errorf("list active announcements: %w", err)
  213. }
  214. visible := make([]Announcement, 0, len(anns))
  215. ids := make([]int64, 0, len(anns))
  216. for i := range anns {
  217. a := anns[i]
  218. if !a.IsActiveAt(now) {
  219. continue
  220. }
  221. if !a.Targeting.Matches(user.Balance, activeGroupIDs) {
  222. continue
  223. }
  224. visible = append(visible, a)
  225. ids = append(ids, a.ID)
  226. }
  227. if len(visible) == 0 {
  228. return []UserAnnouncement{}, nil
  229. }
  230. readMap, err := s.readRepo.GetReadMapByUser(ctx, userID, ids)
  231. if err != nil {
  232. return nil, fmt.Errorf("get read map: %w", err)
  233. }
  234. out := make([]UserAnnouncement, 0, len(visible))
  235. for i := range visible {
  236. a := visible[i]
  237. readAt, ok := readMap[a.ID]
  238. if unreadOnly && ok {
  239. continue
  240. }
  241. var ptr *time.Time
  242. if ok {
  243. t := readAt
  244. ptr = &t
  245. }
  246. out = append(out, UserAnnouncement{
  247. Announcement: a,
  248. ReadAt: ptr,
  249. })
  250. }
  251. // 未读优先、同状态按创建时间倒序
  252. sort.Slice(out, func(i, j int) bool {
  253. ai, aj := out[i], out[j]
  254. if (ai.ReadAt == nil) != (aj.ReadAt == nil) {
  255. return ai.ReadAt == nil
  256. }
  257. return ai.Announcement.ID > aj.Announcement.ID
  258. })
  259. return out, nil
  260. }
  261. func (s *AnnouncementService) MarkRead(ctx context.Context, userID, announcementID int64) error {
  262. // 安全:仅允许标记当前用户“可见”的公告
  263. user, err := s.userRepo.GetByID(ctx, userID)
  264. if err != nil {
  265. return fmt.Errorf("get user: %w", err)
  266. }
  267. a, err := s.announcementRepo.GetByID(ctx, announcementID)
  268. if err != nil {
  269. return err
  270. }
  271. now := time.Now()
  272. if !a.IsActiveAt(now) {
  273. return ErrAnnouncementNotFound
  274. }
  275. activeSubs, err := s.userSubRepo.ListActiveByUserID(ctx, userID)
  276. if err != nil {
  277. return fmt.Errorf("list active subscriptions: %w", err)
  278. }
  279. activeGroupIDs := make(map[int64]struct{}, len(activeSubs))
  280. for i := range activeSubs {
  281. activeGroupIDs[activeSubs[i].GroupID] = struct{}{}
  282. }
  283. if !a.Targeting.Matches(user.Balance, activeGroupIDs) {
  284. return ErrAnnouncementNotFound
  285. }
  286. if err := s.readRepo.MarkRead(ctx, announcementID, userID, now); err != nil {
  287. return fmt.Errorf("mark read: %w", err)
  288. }
  289. return nil
  290. }
  291. func (s *AnnouncementService) ListUserReadStatus(
  292. ctx context.Context,
  293. announcementID int64,
  294. params pagination.PaginationParams,
  295. search string,
  296. ) ([]AnnouncementUserReadStatus, *pagination.PaginationResult, error) {
  297. ann, err := s.announcementRepo.GetByID(ctx, announcementID)
  298. if err != nil {
  299. return nil, nil, err
  300. }
  301. filters := UserListFilters{
  302. Search: strings.TrimSpace(search),
  303. }
  304. users, page, err := s.userRepo.ListWithFilters(ctx, params, filters)
  305. if err != nil {
  306. return nil, nil, fmt.Errorf("list users: %w", err)
  307. }
  308. userIDs := make([]int64, 0, len(users))
  309. for i := range users {
  310. userIDs = append(userIDs, users[i].ID)
  311. }
  312. readMap, err := s.readRepo.GetReadMapByUsers(ctx, announcementID, userIDs)
  313. if err != nil {
  314. return nil, nil, fmt.Errorf("get read map: %w", err)
  315. }
  316. out := make([]AnnouncementUserReadStatus, 0, len(users))
  317. for i := range users {
  318. u := users[i]
  319. subs, err := s.userSubRepo.ListActiveByUserID(ctx, u.ID)
  320. if err != nil {
  321. return nil, nil, fmt.Errorf("list active subscriptions: %w", err)
  322. }
  323. activeGroupIDs := make(map[int64]struct{}, len(subs))
  324. for j := range subs {
  325. activeGroupIDs[subs[j].GroupID] = struct{}{}
  326. }
  327. readAt, ok := readMap[u.ID]
  328. var ptr *time.Time
  329. if ok {
  330. t := readAt
  331. ptr = &t
  332. }
  333. out = append(out, AnnouncementUserReadStatus{
  334. UserID: u.ID,
  335. Email: u.Email,
  336. Username: u.Username,
  337. Balance: u.Balance,
  338. Eligible: domain.AnnouncementTargeting(ann.Targeting).Matches(u.Balance, activeGroupIDs),
  339. ReadAt: ptr,
  340. })
  341. }
  342. return out, page, nil
  343. }
  344. func isValidAnnouncementStatus(status string) bool {
  345. switch status {
  346. case AnnouncementStatusDraft, AnnouncementStatusActive, AnnouncementStatusArchived:
  347. return true
  348. default:
  349. return false
  350. }
  351. }
  352. func isValidAnnouncementNotifyMode(mode string) bool {
  353. switch mode {
  354. case AnnouncementNotifyModeSilent, AnnouncementNotifyModePopup:
  355. return true
  356. default:
  357. return false
  358. }
  359. }