Files
creator-hub/internal/creator/logic.go
T
rogee eab193007c
douyin-release-gate / verify (push) Failing after 3m43s
refactor(accounts): 移除「授权状态」概念——账号收敛为启用/暂停单一状态机
产品已收敛为自有账号管理,撤销授权在 UI 无入口、状态恒为 authorized,属废弃语义:
- migration 1045:social_account DROP authorization_status/revoked_at/authorization_kind
- 删除 revoke API 路由与 RevokeAccount/disableAccount 状态机分支(PauseAccount 独立)
- accountRunnable/就绪判定/采集过滤/登录校验删除 authorization_status 检查
- EnvironmentContext/AccountProfile 契约删字段;前端删「已授权/已撤销」展示与 readiness 分支
- 测试同步:revoke 流程/409 用例删除,seed 语句去列;dev 库测试遗留 revoked 账号待 UI 删除
2026-09-30 14:18:34 +08:00

234 lines
7.2 KiB
Go

package creator
import (
"errors"
"math"
"strings"
"time"
)
var (
ErrInvalid = errors.New("invalid creator input")
ErrConflict = errors.New("creator resource conflicts with existing state")
ErrNotFound = errors.New("creator resource not found")
ErrUnavailable = errors.New("creator capability is unavailable")
ErrUncertain = errors.New("creator operation result is uncertain")
ErrSyncInProgress = errors.New("该竞品正在同步中,请稍后再试")
ErrSyncDisabled = errors.New("该竞品已停用,无法同步;启用后再试")
)
func ValidatePlatform(platform string) bool {
return platform == PlatformDouyin
}
const maxDurationSeconds = int64(math.MaxInt64 / int64(time.Second))
func ValidateSettings(input SettingsUpdate) error {
if input.LookbackDays <= 0 || int64(input.LookbackDays) > maxDurationSeconds/(24*60*60) ||
input.NewWorkIntervalSeconds <= 0 || input.NewWorkIntervalSeconds > maxDurationSeconds ||
input.MetricInitialIntervalSeconds <= 0 || input.MetricInitialIntervalSeconds > maxDurationSeconds ||
math.IsNaN(input.MetricMultiplier) || math.IsInf(input.MetricMultiplier, 0) || input.MetricMultiplier <= 1 ||
input.MetricMaxIntervalSeconds < input.MetricInitialIntervalSeconds || input.MetricMaxIntervalSeconds > maxDurationSeconds ||
input.MetricAgeSeconds <= 0 || input.MetricAgeSeconds > maxDurationSeconds {
return ErrInvalid
}
return nil
}
// NextMetricAt returns the next scheduled point strictly after now. A point
// exactly at now is considered covered by the current collection and is not
// returned again.
func NextMetricAt(publishedAt, now time.Time, initial, max time.Duration, multiplier float64, monitoringAge time.Duration) (time.Time, string) {
publishedAt, now = publishedAt.UTC(), now.UTC()
if publishedAt.IsZero() || !publishedAt.Before(now) || initial <= 0 || max < initial || math.IsNaN(multiplier) || math.IsInf(multiplier, 0) || multiplier <= 1 || monitoringAge <= 0 {
return time.Time{}, "published_at_pending_verification"
}
if !now.Before(publishedAt.Add(monitoringAge)) {
return time.Time{}, "monitoring_age_reached"
}
elapsed := now.Sub(publishedAt)
interval := initial
total := initial
for total <= elapsed {
candidate := float64(interval) * multiplier
var next time.Duration
if math.IsNaN(candidate) || math.IsInf(candidate, 0) || candidate < float64(interval) || candidate > float64(max) {
next = max
} else {
next = time.Duration(candidate)
if next < interval {
next = max
}
}
interval = next
if total > time.Duration(math.MaxInt64)-interval {
return time.Time{}, "monitoring_age_reached"
}
total += interval
}
candidate := publishedAt.Add(total)
if !candidate.Before(publishedAt.Add(monitoringAge)) {
return time.Time{}, "monitoring_age_reached"
}
return candidate, ""
}
func MetricSchedule(publishedAt time.Time, initial, max time.Duration, multiplier float64, count int) []time.Time {
if count <= 0 || publishedAt.IsZero() || initial <= 0 || max < initial || math.IsNaN(multiplier) || math.IsInf(multiplier, 0) || multiplier <= 1 {
return []time.Time{}
}
points := make([]time.Time, 0, count)
interval, total := initial, initial
for len(points) < count {
points = append(points, publishedAt.UTC().Add(total))
candidate := float64(interval) * multiplier
var next time.Duration
if math.IsNaN(candidate) || math.IsInf(candidate, 0) || candidate < float64(interval) || candidate > float64(max) {
next = max
} else {
next = time.Duration(candidate)
if next < interval {
next = max
}
}
interval = next
if total > time.Duration(math.MaxInt64)-interval {
break
}
total += interval
}
return points
}
// TrimKeyword removes only the edge whitespace allowed by the product rule.
// It intentionally does not normalize case, Unicode, full-width characters,
// or whitespace inside a keyword.
func TrimKeyword(value string) string { return strings.Trim(value, " \t\r\n") }
func NormalizeKeywords(values []string) ([]string, error) {
result := make([]string, len(values))
for index, value := range values {
result[index] = TrimKeyword(value)
if result[index] == "" {
return nil, ErrInvalid
}
}
return result, nil
}
func ValidateRuleInput(input LeadRuleInput) (LeadRuleInput, error) {
input.Name = strings.TrimSpace(input.Name)
input.Topic = strings.TrimSpace(input.Topic)
input.AIRequirement = strings.TrimSpace(input.AIRequirement)
input.SourceType = strings.TrimSpace(input.SourceType)
if input.SourceType == "" {
input.SourceType = "all"
}
if input.Name == "" || input.Topic == "" || input.AIRequirement == "" ||
(input.SourceType != "all" && input.SourceType != SourceOwned && input.SourceType != SourceCompetitor) {
return LeadRuleInput{}, ErrInvalid
}
var err error
input.IncludeKeywords, err = NormalizeKeywords(input.IncludeKeywords)
if err != nil || len(input.IncludeKeywords) == 0 {
return LeadRuleInput{}, ErrInvalid
}
input.ExcludeKeywords, err = NormalizeKeywords(input.ExcludeKeywords)
if err != nil {
return LeadRuleInput{}, ErrInvalid
}
return input, nil
}
func MatchKeywords(content string, include, exclude []string) (bool, []string) {
matched := make([]string, 0, len(include))
for _, keyword := range include {
if strings.Contains(content, keyword) {
matched = append(matched, keyword)
}
}
if len(matched) == 0 {
return false, matched
}
for _, keyword := range exclude {
if strings.Contains(content, keyword) {
return false, matched
}
}
return true, matched
}
func ValidEventType(eventType string) bool {
switch eventType {
case "comment", "like", "repost", "follow", "dm":
return true
default:
return false
}
}
func ValidMessageType(messageType string) bool {
switch messageType {
case MessageTypeText, MessageTypeImage, MessageTypeVoice, MessageTypeVideo, MessageTypeSticker, MessageTypeUnknown:
return true
default:
return false
}
}
func ValidAction(action string) bool {
switch action {
case ActionDM, ActionReplyComment, ActionLikeComment, ActionLikeWork, ActionFollow, ActionRepost:
return true
default:
return false
}
}
func ActionRequiresText(action string) bool {
return action == ActionDM || action == ActionReplyComment || action == ActionRepost
}
func validCooldownSeconds(seconds int64) bool {
return seconds > 0 && seconds <= maxDurationSeconds
}
func ActionTargetValid(action string, interactorUID, commentID, workID, targetType string) bool {
if interactorUID == "" {
return false
}
switch action {
case ActionDM, ActionFollow:
return true
case ActionReplyComment, ActionLikeComment:
return commentID != ""
case ActionLikeWork:
return workID != ""
case ActionRepost:
return workID != ""
default:
return false
}
}
func CanWrite(profile AccountProfile, automatic bool, action string) error {
if !ValidAction(action) {
return ErrInvalid
}
if profile.BusinessStatus != "normal" {
if automatic || profile.BusinessStatus == "banned" || profile.BusinessStatus == "deleted" ||
(action == ActionReplyComment || action == ActionDM || action == ActionRepost) {
return ErrConflict
}
}
if profile.LoginStatus != "logged_in" {
return ErrConflict
}
return nil
}
func InWindow(publishedAt, start, end time.Time) bool {
return !publishedAt.IsZero() && !publishedAt.Before(start.UTC()) && !publishedAt.After(end.UTC())
}