Files
affiliate_dash/backend/internal/service/rock_snapshot.go
T

209 lines
5.8 KiB
Go

package service
import (
"encoding/json"
"strings"
"time"
"affiliate_dash/internal/model"
"affiliate_dash/internal/pkg/timeutil"
)
func rockBindingChangedAt(ref *RockRefreshResponse) string {
if ref == nil {
return ""
}
if value := strings.TrimSpace(ref.Token.BindChangedAt); value != "" {
return value
}
return strings.TrimSpace(ref.Binding.BindTime)
}
// rockRawBindingAfterBaseline 只判断上游是否返回了本轮绑定事件,不参考残留账号。
func rockRawBindingAfterBaseline(token *model.RockToken, ref *RockRefreshResponse) (time.Time, bool) {
raw := rockBindingChangedAt(ref)
if raw == "" {
return time.Time{}, false
}
changedAt, ok := parseRockTime(raw)
if !ok {
return time.Time{}, false
}
baseline := rockClaimBaseline(token)
if baseline.IsZero() {
return time.Time{}, false
}
return changedAt, !changedAt.Before(baseline.Add(-time.Minute))
}
// rockCurrentCycleBinding 判断上游绑定事件是否属于当前分配周期。
func rockCurrentCycleBinding(token *model.RockToken, ref *RockRefreshResponse) (bool, time.Time) {
if token == nil || ref == nil {
return false, time.Time{}
}
changedAt, current := rockRawBindingAfterBaseline(token, ref)
if !current {
return false, time.Time{}
}
if rockBindingMatchesInitialSnapshot(token, ref) {
return false, time.Time{}
}
return true, changedAt
}
func rockBindingMatchesInitialSnapshot(token *model.RockToken, ref *RockRefreshResponse) bool {
snap, ok := bindingSnapshot(token)
if !ok {
return false
}
return sameRockBindingEvent(snap.BindChangedAt, rockBindingChangedAt(ref))
}
// sameRockBindingEvent 兼容上游同一事件的不同时间格式,避免仅因字符串格式差异
// 把分配时保存的历史绑定重新识别成本轮绑定。
func sameRockBindingEvent(snapshotAt, upstreamAt string) bool {
snapshotAt = strings.TrimSpace(snapshotAt)
upstreamAt = strings.TrimSpace(upstreamAt)
if snapshotAt == "" || upstreamAt == "" {
return false
}
snapshotTime, snapshotOK := parseRockTime(snapshotAt)
upstreamTime, upstreamOK := parseRockTime(upstreamAt)
if snapshotOK && upstreamOK {
diff := snapshotTime.Sub(upstreamTime)
if diff < 0 {
diff = -diff
}
return diff <= time.Second
}
return snapshotAt == upstreamAt
}
func bindingSnapshot(token *model.RockToken) (BindingSnapshot, bool) {
if token == nil || strings.TrimSpace(token.InitialBoundSnapshot) == "" {
return BindingSnapshot{}, false
}
var snap BindingSnapshot
if err := json.Unmarshal([]byte(token.InitialBoundSnapshot), &snap); err != nil {
return BindingSnapshot{}, false
}
snap.RoleID = strings.TrimSpace(snap.RoleID)
snap.BindChangedAt = strings.TrimSpace(snap.BindChangedAt)
return snap, true
}
func rockClaimBaseline(token *model.RockToken) time.Time {
if token == nil {
return time.Time{}
}
if token.UsingAt != nil && !token.UsingAt.IsZero() {
return token.UsingAt.UTC()
}
if token.ClaimedAt != nil && !token.ClaimedAt.IsZero() {
return token.ClaimedAt.UTC()
}
return time.Time{}
}
// parseRockTime 解析上游返回的时间串。
// 上游(0666.pro)返回的是不带偏移的北京时间,必须以业务时区解析,
// 不能依赖 time.Local——否则进程时区一变,绑定时序判断就会整体偏移 8 小时。
func parseRockTime(raw string) (time.Time, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Time{}, false
}
layouts := []string{
time.RFC3339Nano,
"2006-01-02T15:04:05.999999999",
"2006-01-02T15:04:05",
"2006-01-02 15:04:05",
}
for _, layout := range layouts {
if t, err := time.ParseInLocation(layout, raw, timeutil.Location()); err == nil {
return t.UTC(), true
}
}
return time.Time{}, false
}
type rockDeliveryEvidence struct {
Current bool
Ambiguous bool
At time.Time
FulfillID int64
Source string
}
// rockCurrentCycleDelivery 只认本轮 using_at 之后产生的兑换时间或发货流水。
// 明确早于本轮的历史记录忽略;状态为 redeemed 却没有可解析时间时按
// “已发货、数据待核对”保守处理,禁止释放。
func rockCurrentCycleDelivery(token *model.RockToken, ref *RockRefreshResponse) rockDeliveryEvidence {
if token == nil || ref == nil {
return rockDeliveryEvidence{}
}
baseline := rockClaimBaseline(token)
cutoff := baseline.Add(-time.Minute)
evidence := rockDeliveryEvidence{}
for _, item := range ref.DeliveryList {
at, ok := parseRockTime(item.Time)
if !ok {
continue
}
if baseline.IsZero() {
if !evidence.Ambiguous || at.After(evidence.At) {
evidence.Ambiguous = true
evidence.At = at
evidence.FulfillID = item.FulfillID
evidence.Source = "delivery_list"
}
continue
}
if at.Before(cutoff) {
continue
}
if !evidence.Current || at.After(evidence.At) ||
(at.Equal(evidence.At) && item.FulfillID > evidence.FulfillID) {
evidence.Current = true
evidence.Ambiguous = false
evidence.At = at
evidence.FulfillID = item.FulfillID
evidence.Source = "delivery_list"
}
}
rawRedeemedAt := strings.TrimSpace(ref.Token.RedeemedAt)
if rawRedeemedAt != "" {
if at, ok := parseRockTime(rawRedeemedAt); ok {
if baseline.IsZero() {
evidence.Ambiguous = true
if evidence.At.IsZero() || at.After(evidence.At) {
evidence.At = at
evidence.Source = "redeemed_at"
}
} else if !at.Before(cutoff) &&
(!evidence.Current || at.After(evidence.At)) {
evidence.Current = true
evidence.Ambiguous = false
evidence.At = at
evidence.Source = "redeemed_at"
}
} else {
evidence.Ambiguous = true
if evidence.Source == "" {
evidence.Source = "redeemed_at"
}
}
}
if strings.EqualFold(strings.TrimSpace(ref.Token.Status), model.RockStatusRedeemed) &&
rawRedeemedAt == "" && !evidence.Current {
evidence.Ambiguous = true
if evidence.Source == "" {
evidence.Source = "status"
}
}
return evidence
}