227 lines
5.3 KiB
Go
227 lines
5.3 KiB
Go
package domain
|
||
|
||
import (
|
||
"strings"
|
||
"time"
|
||
|
||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||
)
|
||
|
||
const (
|
||
AnnouncementStatusDraft = "draft"
|
||
AnnouncementStatusActive = "active"
|
||
AnnouncementStatusArchived = "archived"
|
||
)
|
||
|
||
const (
|
||
AnnouncementConditionTypeSubscription = "subscription"
|
||
AnnouncementConditionTypeBalance = "balance"
|
||
)
|
||
|
||
const (
|
||
AnnouncementOperatorIn = "in"
|
||
AnnouncementOperatorGT = "gt"
|
||
AnnouncementOperatorGTE = "gte"
|
||
AnnouncementOperatorLT = "lt"
|
||
AnnouncementOperatorLTE = "lte"
|
||
AnnouncementOperatorEQ = "eq"
|
||
)
|
||
|
||
var (
|
||
ErrAnnouncementNotFound = infraerrors.NotFound("ANNOUNCEMENT_NOT_FOUND", "announcement not found")
|
||
ErrAnnouncementInvalidTarget = infraerrors.BadRequest("ANNOUNCEMENT_INVALID_TARGET", "invalid announcement targeting rules")
|
||
)
|
||
|
||
type AnnouncementTargeting struct {
|
||
// AnyOf 表示 OR:任意一个条件组满足即可展示。
|
||
AnyOf []AnnouncementConditionGroup `json:"any_of,omitempty"`
|
||
}
|
||
|
||
type AnnouncementConditionGroup struct {
|
||
// AllOf 表示 AND:组内所有条件都满足才算命中该组。
|
||
AllOf []AnnouncementCondition `json:"all_of,omitempty"`
|
||
}
|
||
|
||
type AnnouncementCondition struct {
|
||
// Type: subscription | balance
|
||
Type string `json:"type"`
|
||
|
||
// Operator:
|
||
// - subscription: in
|
||
// - balance: gt/gte/lt/lte/eq
|
||
Operator string `json:"operator"`
|
||
|
||
// subscription 条件:匹配的订阅套餐(group_id)
|
||
GroupIDs []int64 `json:"group_ids,omitempty"`
|
||
|
||
// balance 条件:比较阈值
|
||
Value float64 `json:"value,omitempty"`
|
||
}
|
||
|
||
func (t AnnouncementTargeting) Matches(balance float64, activeSubscriptionGroupIDs map[int64]struct{}) bool {
|
||
// 空规则:展示给所有用户
|
||
if len(t.AnyOf) == 0 {
|
||
return true
|
||
}
|
||
|
||
for _, group := range t.AnyOf {
|
||
if len(group.AllOf) == 0 {
|
||
// 空条件组不命中(避免 OR 中出现无条件 “全命中”)
|
||
continue
|
||
}
|
||
allMatched := true
|
||
for _, cond := range group.AllOf {
|
||
if !cond.Matches(balance, activeSubscriptionGroupIDs) {
|
||
allMatched = false
|
||
break
|
||
}
|
||
}
|
||
if allMatched {
|
||
return true
|
||
}
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
func (c AnnouncementCondition) Matches(balance float64, activeSubscriptionGroupIDs map[int64]struct{}) bool {
|
||
switch c.Type {
|
||
case AnnouncementConditionTypeSubscription:
|
||
if c.Operator != AnnouncementOperatorIn {
|
||
return false
|
||
}
|
||
if len(c.GroupIDs) == 0 {
|
||
return false
|
||
}
|
||
if len(activeSubscriptionGroupIDs) == 0 {
|
||
return false
|
||
}
|
||
for _, gid := range c.GroupIDs {
|
||
if _, ok := activeSubscriptionGroupIDs[gid]; ok {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
|
||
case AnnouncementConditionTypeBalance:
|
||
switch c.Operator {
|
||
case AnnouncementOperatorGT:
|
||
return balance > c.Value
|
||
case AnnouncementOperatorGTE:
|
||
return balance >= c.Value
|
||
case AnnouncementOperatorLT:
|
||
return balance < c.Value
|
||
case AnnouncementOperatorLTE:
|
||
return balance <= c.Value
|
||
case AnnouncementOperatorEQ:
|
||
return balance == c.Value
|
||
default:
|
||
return false
|
||
}
|
||
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func (t AnnouncementTargeting) NormalizeAndValidate() (AnnouncementTargeting, error) {
|
||
normalized := AnnouncementTargeting{AnyOf: make([]AnnouncementConditionGroup, 0, len(t.AnyOf))}
|
||
|
||
// 允许空 targeting(展示给所有用户)
|
||
if len(t.AnyOf) == 0 {
|
||
return normalized, nil
|
||
}
|
||
|
||
if len(t.AnyOf) > 50 {
|
||
return AnnouncementTargeting{}, ErrAnnouncementInvalidTarget
|
||
}
|
||
|
||
for _, g := range t.AnyOf {
|
||
if len(g.AllOf) == 0 {
|
||
return AnnouncementTargeting{}, ErrAnnouncementInvalidTarget
|
||
}
|
||
if len(g.AllOf) > 50 {
|
||
return AnnouncementTargeting{}, ErrAnnouncementInvalidTarget
|
||
}
|
||
|
||
group := AnnouncementConditionGroup{AllOf: make([]AnnouncementCondition, 0, len(g.AllOf))}
|
||
for _, c := range g.AllOf {
|
||
cond := AnnouncementCondition{
|
||
Type: strings.TrimSpace(c.Type),
|
||
Operator: strings.TrimSpace(c.Operator),
|
||
Value: c.Value,
|
||
}
|
||
for _, gid := range c.GroupIDs {
|
||
if gid <= 0 {
|
||
return AnnouncementTargeting{}, ErrAnnouncementInvalidTarget
|
||
}
|
||
cond.GroupIDs = append(cond.GroupIDs, gid)
|
||
}
|
||
|
||
if err := cond.validate(); err != nil {
|
||
return AnnouncementTargeting{}, err
|
||
}
|
||
group.AllOf = append(group.AllOf, cond)
|
||
}
|
||
|
||
normalized.AnyOf = append(normalized.AnyOf, group)
|
||
}
|
||
|
||
return normalized, nil
|
||
}
|
||
|
||
func (c AnnouncementCondition) validate() error {
|
||
switch c.Type {
|
||
case AnnouncementConditionTypeSubscription:
|
||
if c.Operator != AnnouncementOperatorIn {
|
||
return ErrAnnouncementInvalidTarget
|
||
}
|
||
if len(c.GroupIDs) == 0 {
|
||
return ErrAnnouncementInvalidTarget
|
||
}
|
||
return nil
|
||
|
||
case AnnouncementConditionTypeBalance:
|
||
switch c.Operator {
|
||
case AnnouncementOperatorGT, AnnouncementOperatorGTE, AnnouncementOperatorLT, AnnouncementOperatorLTE, AnnouncementOperatorEQ:
|
||
return nil
|
||
default:
|
||
return ErrAnnouncementInvalidTarget
|
||
}
|
||
|
||
default:
|
||
return ErrAnnouncementInvalidTarget
|
||
}
|
||
}
|
||
|
||
type Announcement struct {
|
||
ID int64
|
||
Title string
|
||
Content string
|
||
Status string
|
||
Targeting AnnouncementTargeting
|
||
StartsAt *time.Time
|
||
EndsAt *time.Time
|
||
CreatedBy *int64
|
||
UpdatedBy *int64
|
||
CreatedAt time.Time
|
||
UpdatedAt time.Time
|
||
}
|
||
|
||
func (a *Announcement) IsActiveAt(now time.Time) bool {
|
||
if a == nil {
|
||
return false
|
||
}
|
||
if a.Status != AnnouncementStatusActive {
|
||
return false
|
||
}
|
||
if a.StartsAt != nil && now.Before(*a.StartsAt) {
|
||
return false
|
||
}
|
||
if a.EndsAt != nil && !now.Before(*a.EndsAt) {
|
||
// ends_at 语义:到点即下线
|
||
return false
|
||
}
|
||
return true
|
||
}
|