1048 lines
43 KiB
Go
1048 lines
43 KiB
Go
package activitytemplate
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"net/url"
|
||
"regexp"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
"unicode/utf8"
|
||
|
||
"google.golang.org/grpc"
|
||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||
"hyapp/pkg/appcode"
|
||
"hyapp/pkg/xerr"
|
||
domain "hyapp/services/activity-service/internal/domain/activitytemplate"
|
||
)
|
||
|
||
const (
|
||
defaultLeaderboardSize = int32(100)
|
||
maxActivityDurationMS = int64((366 * 24 * time.Hour) / time.Millisecond)
|
||
maxDisplayConfigBytes = 64 * 1024
|
||
maxRankRewardRanges = 100
|
||
)
|
||
|
||
var (
|
||
templateCodePattern = regexp.MustCompile(`^[a-z][a-z0-9_-]{2,63}$`)
|
||
taskKeyPattern = regexp.MustCompile(`^[a-z][a-z0-9_-]{1,63}$`)
|
||
localePattern = regexp.MustCompile(`^[a-z]{2,3}(?:-[a-z0-9]{2,8})*$`)
|
||
assetKeyPattern = regexp.MustCompile(`^[a-z][a-z0-9_.-]{1,63}$`)
|
||
)
|
||
|
||
// Repository 是活动模版聚合的持久化边界。完整子配置、版本快照和状态必须由 activity-service 同库事务维护。
|
||
type Repository interface {
|
||
ListActivityTemplates(ctx context.Context, query domain.ListQuery) ([]domain.Summary, int64, error)
|
||
GetActivityTemplate(ctx context.Context, templateID string) (domain.Template, error)
|
||
CreateActivityTemplate(ctx context.Context, template domain.Template, operatorAdminID int64, nowMS int64) (domain.Template, error)
|
||
UpdateActivityTemplate(ctx context.Context, command domain.UpdateCommand, nowMS int64) (domain.Template, error)
|
||
SetActivityTemplateStatus(ctx context.Context, command domain.StatusCommand, nowMS int64) (domain.Template, error)
|
||
ListActivityTemplateVersions(ctx context.Context, templateID string, page int32, pageSize int32) ([]domain.Version, int64, error)
|
||
GetActivityTemplateVersion(ctx context.Context, templateID string, versionNo int64) (domain.Version, error)
|
||
}
|
||
|
||
// Service 拥有活动模版校验、乐观锁、发布状态机和克隆语义;HTTP/gRPC 层只做协议适配。
|
||
type Service struct {
|
||
repository Repository
|
||
catalog CatalogClient
|
||
regions RegionSource
|
||
runtime RuntimeRepository
|
||
runtimeWallet RuntimeWallet
|
||
rewardSnapshots RewardSnapshotWallet
|
||
notices NoticeService
|
||
now func() time.Time
|
||
}
|
||
|
||
// CatalogClient 是发布时确认礼物和奖励资源组仍由 owner service 激活的最小契约。
|
||
// 后台提交的名称、价格和 ID 都只是候选输入,不能代替 wallet-service 当前事实。
|
||
type CatalogClient interface {
|
||
ListGiftConfigs(ctx context.Context, req *walletv1.ListGiftConfigsRequest, opts ...grpc.CallOption) (*walletv1.ListGiftConfigsResponse, error)
|
||
GetResourceGroup(ctx context.Context, req *walletv1.GetResourceGroupRequest, opts ...grpc.CallOption) (*walletv1.GetResourceGroupResponse, error)
|
||
}
|
||
|
||
type RewardSnapshotWallet interface {
|
||
PinResourceGroupSnapshot(ctx context.Context, req *walletv1.PinResourceGroupSnapshotRequest, opts ...grpc.CallOption) (*walletv1.PinResourceGroupSnapshotResponse, error)
|
||
}
|
||
|
||
// RegionSource 复用 user-service 区域主数据适配器,发布时拒绝已停用或不存在的定向区域。
|
||
type RegionSource interface {
|
||
ListActiveRegionIDs(ctx context.Context) ([]int64, error)
|
||
}
|
||
|
||
func New(repository Repository, catalog CatalogClient, regions RegionSource) *Service {
|
||
service := &Service{repository: repository, catalog: catalog, regions: regions, now: time.Now}
|
||
// Production MySQL/wallet clients implement the runtime surfaces. Type assertions keep configuration-only
|
||
// fakes source-compatible while still failing closed when a runtime RPC is invoked without real dependencies.
|
||
service.runtime, _ = repository.(RuntimeRepository)
|
||
service.runtimeWallet, _ = catalog.(RuntimeWallet)
|
||
service.rewardSnapshots, _ = catalog.(RewardSnapshotWallet)
|
||
return service
|
||
}
|
||
|
||
func (s *Service) List(ctx context.Context, query domain.ListQuery) ([]domain.Summary, int64, error) {
|
||
if err := s.requireRepository(); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
query.Keyword = strings.TrimSpace(query.Keyword)
|
||
query.Status = normalizeStatus(query.Status)
|
||
query.NowMS = s.now().UTC().UnixMilli()
|
||
if !validListStatus(query.Status) {
|
||
return nil, 0, xerr.New(xerr.InvalidArgument, "status filter is invalid")
|
||
}
|
||
if query.StartMS > 0 && query.EndMS > 0 && query.EndMS <= query.StartMS {
|
||
return nil, 0, xerr.New(xerr.InvalidArgument, "time range must use [start_ms,end_ms)")
|
||
}
|
||
if query.StartMS < 0 || query.EndMS < 0 {
|
||
return nil, 0, xerr.New(xerr.InvalidArgument, "start_ms and end_ms must not be negative")
|
||
}
|
||
if query.RegionID != nil && *query.RegionID <= 0 {
|
||
return nil, 0, xerr.New(xerr.InvalidArgument, "region_id must be positive")
|
||
}
|
||
items, total, err := s.repository.ListActivityTemplates(ctx, query)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
for index := range items {
|
||
items[index].LifecycleStatus = lifecycleStatus(items[index].Status, items[index].StartMS, items[index].EndMS, query.NowMS)
|
||
}
|
||
return items, total, nil
|
||
}
|
||
|
||
func (s *Service) Get(ctx context.Context, templateID string) (domain.Template, error) {
|
||
if err := s.requireRepository(); err != nil {
|
||
return domain.Template{}, err
|
||
}
|
||
templateID = strings.TrimSpace(templateID)
|
||
if templateID == "" {
|
||
return domain.Template{}, xerr.New(xerr.InvalidArgument, "template_id is required")
|
||
}
|
||
item, err := s.repository.GetActivityTemplate(ctx, templateID)
|
||
if err != nil {
|
||
return domain.Template{}, err
|
||
}
|
||
item.LifecycleStatus = lifecycleStatus(item.Status, item.StartMS, item.EndMS, s.now().UTC().UnixMilli())
|
||
return item, nil
|
||
}
|
||
|
||
func (s *Service) Create(ctx context.Context, template domain.Template, operatorAdminID int64) (domain.Template, error) {
|
||
if err := s.requireRepository(); err != nil {
|
||
return domain.Template{}, err
|
||
}
|
||
if operatorAdminID <= 0 {
|
||
return domain.Template{}, xerr.New(xerr.InvalidArgument, "operator_admin_id is required")
|
||
}
|
||
template = normalizeTemplate(template)
|
||
// 新记录只能先落草稿。发布会额外执行完整校验、区间冲突检查和版本快照,不允许 create 绕过发布状态机。
|
||
template.Status = domain.StatusDraft
|
||
template.TemplateID = ""
|
||
template.Revision = 0
|
||
template.PublishedVersion = 0
|
||
nowMS := s.now().UTC().UnixMilli()
|
||
if issues := validateTemplate(template, false, nowMS); len(issues) > 0 {
|
||
return domain.Template{}, issueError(issues[0])
|
||
}
|
||
stored, err := s.repository.CreateActivityTemplate(ctx, template, operatorAdminID, nowMS)
|
||
if err != nil {
|
||
return domain.Template{}, err
|
||
}
|
||
stored.LifecycleStatus = lifecycleStatus(stored.Status, stored.StartMS, stored.EndMS, nowMS)
|
||
return stored, nil
|
||
}
|
||
|
||
func (s *Service) Update(ctx context.Context, command domain.UpdateCommand) (domain.Template, error) {
|
||
if err := s.requireRepository(); err != nil {
|
||
return domain.Template{}, err
|
||
}
|
||
command.Template = normalizeTemplate(command.Template)
|
||
command.Template.TemplateID = strings.TrimSpace(command.Template.TemplateID)
|
||
if command.Template.TemplateID == "" {
|
||
return domain.Template{}, xerr.New(xerr.InvalidArgument, "template_id is required")
|
||
}
|
||
if command.OperatorAdminID <= 0 {
|
||
return domain.Template{}, xerr.New(xerr.InvalidArgument, "operator_admin_id is required")
|
||
}
|
||
if command.ExpectedRevision <= 0 {
|
||
return domain.Template{}, xerr.New(xerr.InvalidArgument, "expected_revision is required")
|
||
}
|
||
current, err := s.repository.GetActivityTemplate(ctx, command.Template.TemplateID)
|
||
if err != nil {
|
||
return domain.Template{}, err
|
||
}
|
||
if current.Revision != command.ExpectedRevision {
|
||
return domain.Template{}, xerr.New(xerr.Conflict, "activity template revision changed")
|
||
}
|
||
// template_code 是 App deep link、运行版本和统计维度的长期业务身份;创建后改码会让已发布历史
|
||
// 与当前编辑态分裂。需要新编码时必须 clone 成新模版,由新 ID 重新走发布流程。
|
||
if command.Template.TemplateCode != current.TemplateCode {
|
||
return domain.Template{}, xerr.New(xerr.Conflict, "activity template_code is immutable; clone the template to use a new code")
|
||
}
|
||
if current.Status != domain.StatusDraft && current.Status != domain.StatusDisabled {
|
||
// 已发布配置必须先停用再编辑;这样 published_version 始终对应运行侧实际使用的不可变快照。
|
||
return domain.Template{}, xerr.New(xerr.Conflict, "published activity template must be disabled before editing")
|
||
}
|
||
command.Template.Status = current.Status
|
||
nowMS := s.now().UTC().UnixMilli()
|
||
if issues := validateTemplate(command.Template, false, nowMS); len(issues) > 0 {
|
||
return domain.Template{}, issueError(issues[0])
|
||
}
|
||
stored, err := s.repository.UpdateActivityTemplate(ctx, command, nowMS)
|
||
if err != nil {
|
||
return domain.Template{}, err
|
||
}
|
||
stored.LifecycleStatus = lifecycleStatus(stored.Status, stored.StartMS, stored.EndMS, nowMS)
|
||
return stored, nil
|
||
}
|
||
|
||
func (s *Service) SetStatus(ctx context.Context, command domain.StatusCommand) (domain.Template, error) {
|
||
if err := s.requireRepository(); err != nil {
|
||
return domain.Template{}, err
|
||
}
|
||
command.TemplateID = strings.TrimSpace(command.TemplateID)
|
||
command.Status = normalizeStatus(command.Status)
|
||
if command.TemplateID == "" || command.OperatorAdminID <= 0 || command.ExpectedRevision <= 0 {
|
||
return domain.Template{}, xerr.New(xerr.InvalidArgument, "template_id, operator_admin_id and expected_revision are required")
|
||
}
|
||
if command.Status != domain.StatusPublished && command.Status != domain.StatusDisabled {
|
||
return domain.Template{}, xerr.New(xerr.InvalidArgument, "status must be published or disabled")
|
||
}
|
||
current, err := s.repository.GetActivityTemplate(ctx, command.TemplateID)
|
||
if err != nil {
|
||
return domain.Template{}, err
|
||
}
|
||
if current.Revision != command.ExpectedRevision {
|
||
return domain.Template{}, xerr.New(xerr.Conflict, "activity template revision changed")
|
||
}
|
||
if current.Status == command.Status {
|
||
current.LifecycleStatus = lifecycleStatus(current.Status, current.StartMS, current.EndMS, s.now().UTC().UnixMilli())
|
||
return current, nil
|
||
}
|
||
nowMS := s.now().UTC().UnixMilli()
|
||
switch command.Status {
|
||
case domain.StatusPublished:
|
||
if current.Status != domain.StatusDraft && current.Status != domain.StatusDisabled {
|
||
return domain.Template{}, xerr.New(xerr.Conflict, "only draft or disabled activity template can be published")
|
||
}
|
||
if issues := validateTemplate(normalizeTemplate(current), true, nowMS); len(issues) > 0 {
|
||
return domain.Template{}, issueError(issues[0])
|
||
}
|
||
referenceIssues, err := s.validateReferences(ctx, current)
|
||
if err != nil {
|
||
return domain.Template{}, err
|
||
}
|
||
if len(referenceIssues) > 0 {
|
||
return domain.Template{}, issueError(referenceIssues[0])
|
||
}
|
||
if current.EndMS <= nowMS {
|
||
return domain.Template{}, xerr.New(xerr.Conflict, "ended activity template cannot be published")
|
||
}
|
||
rewardSnapshots, err := s.pinRewardSnapshots(ctx, current, current.PublishedVersion+1, command.OperatorAdminID)
|
||
if err != nil {
|
||
return domain.Template{}, err
|
||
}
|
||
command.RewardSnapshots = rewardSnapshots
|
||
case domain.StatusDisabled:
|
||
if current.Status != domain.StatusPublished {
|
||
return domain.Template{}, xerr.New(xerr.Conflict, "only published activity template can be disabled")
|
||
}
|
||
}
|
||
stored, err := s.repository.SetActivityTemplateStatus(ctx, command, nowMS)
|
||
if err != nil {
|
||
return domain.Template{}, err
|
||
}
|
||
stored.LifecycleStatus = lifecycleStatus(stored.Status, stored.StartMS, stored.EndMS, nowMS)
|
||
return stored, nil
|
||
}
|
||
|
||
func (s *Service) pinRewardSnapshots(ctx context.Context, item domain.Template, nextVersion int64, operatorAdminID int64) (map[int64]domain.RewardSnapshot, error) {
|
||
groupIDs := make(map[int64]struct{})
|
||
for _, task := range item.Tasks {
|
||
if task.RewardResourceGroupID > 0 {
|
||
groupIDs[task.RewardResourceGroupID] = struct{}{}
|
||
}
|
||
}
|
||
for _, reward := range item.RankRewards {
|
||
if reward.ResourceGroupID > 0 {
|
||
groupIDs[reward.ResourceGroupID] = struct{}{}
|
||
}
|
||
}
|
||
if len(groupIDs) == 0 {
|
||
return map[int64]domain.RewardSnapshot{}, nil
|
||
}
|
||
if s.rewardSnapshots == nil {
|
||
return nil, xerr.New(xerr.Unavailable, "wallet reward snapshot client is not configured")
|
||
}
|
||
ordered := make([]int64, 0, len(groupIDs))
|
||
for groupID := range groupIDs {
|
||
ordered = append(ordered, groupID)
|
||
}
|
||
sort.Slice(ordered, func(left, right int) bool { return ordered[left] < ordered[right] })
|
||
result := make(map[int64]domain.RewardSnapshot, len(ordered))
|
||
for _, groupID := range ordered {
|
||
groupResp, err := s.catalog.GetResourceGroup(ctx, &walletv1.GetResourceGroupRequest{
|
||
RequestId: "activity-template-pin-source:" + item.TemplateID + ":" + strconv.FormatInt(groupID, 10),
|
||
AppCode: appcode.FromContext(ctx), GroupId: groupID,
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
sourceUpdatedAtMS := groupResp.GetGroup().GetUpdatedAtMs()
|
||
sourceContentHash := strings.ToLower(strings.TrimSpace(groupResp.GetSourceContentHash()))
|
||
if groupResp.GetGroup() == nil || sourceUpdatedAtMS <= 0 || !validSHA256Hex(sourceContentHash) {
|
||
return nil, xerr.New(xerr.Conflict, "resource group source version is missing")
|
||
}
|
||
// Revision distinguishes a new edit after a failed cross-service publish attempt. Retrying the same
|
||
// revision reuses identical bytes; editing first produces a new pin even though published_version has
|
||
// not advanced yet.
|
||
pinIdentity := "activity-template:" + item.TemplateID + ":revision:" + strconv.FormatInt(item.Revision, 10) +
|
||
":v" + strconv.FormatInt(nextVersion, 10) + ":group:" + strconv.FormatInt(groupID, 10) +
|
||
":source:" + sourceContentHash
|
||
pinSum := sha256.Sum256([]byte(pinIdentity))
|
||
pinKey := "activity-template-pin:" + hex.EncodeToString(pinSum[:])
|
||
resp, err := s.rewardSnapshots.PinResourceGroupSnapshot(ctx, &walletv1.PinResourceGroupSnapshotRequest{
|
||
RequestId: pinKey, AppCode: appcode.FromContext(ctx), GroupId: groupID,
|
||
PinKey: pinKey, OperatorUserId: operatorAdminID, ExpectedGroupUpdatedAtMs: sourceUpdatedAtMS,
|
||
ExpectedSourceContentHash: sourceContentHash,
|
||
RequiredRegionIds: append([]int64(nil), item.RegionIDs...),
|
||
RequiredAllRegions: item.AllRegions,
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
snapshot := rewardSnapshotFromWallet(resp.GetSnapshot())
|
||
if snapshot.SnapshotID == "" || snapshot.SourceGroupID != groupID || len(snapshot.Items) == 0 ||
|
||
resp.GetSnapshot().GetSourceGroupUpdatedAtMs() != sourceUpdatedAtMS ||
|
||
resp.GetSnapshot().GetSourceContentHash() != sourceContentHash || snapshot.SnapshotHash != sourceContentHash {
|
||
return nil, xerr.New(xerr.Internal, "wallet returned an incomplete resource group snapshot")
|
||
}
|
||
result[groupID] = snapshot
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func validSHA256Hex(value string) bool {
|
||
if len(value) != sha256.Size*2 {
|
||
return false
|
||
}
|
||
decoded, err := hex.DecodeString(value)
|
||
return err == nil && len(decoded) == sha256.Size
|
||
}
|
||
|
||
func rewardSnapshotFromWallet(item *walletv1.ResourceGroupSnapshot) domain.RewardSnapshot {
|
||
if item == nil || item.GetGroup() == nil {
|
||
return domain.RewardSnapshot{}
|
||
}
|
||
out := domain.RewardSnapshot{
|
||
SourceGroupID: item.GetSourceGroupId(), SnapshotID: item.GetSnapshotId(), VersionNo: item.GetVersionNo(),
|
||
SnapshotHash: item.GetSnapshotHash(), Name: item.GetGroup().GetName(), Items: []domain.RewardItem{},
|
||
}
|
||
for _, value := range item.GetGroup().GetItems() {
|
||
reward := domain.RewardItem{
|
||
ItemType: value.GetItemType(), ResourceID: value.GetResourceId(), Quantity: value.GetQuantity(),
|
||
DurationMS: value.GetDurationMs(), WalletAssetType: value.GetWalletAssetType(),
|
||
WalletAssetAmount: value.GetWalletAssetAmount(), SortOrder: value.GetSortOrder(),
|
||
}
|
||
if value.GetResource() != nil {
|
||
reward.ResourceType = value.GetResource().GetResourceType()
|
||
reward.Name = value.GetResource().GetName()
|
||
reward.IconURL = value.GetResource().GetPreviewUrl()
|
||
if reward.IconURL == "" {
|
||
reward.IconURL = value.GetResource().GetAssetUrl()
|
||
}
|
||
} else if reward.WalletAssetType != "" {
|
||
reward.Name = strings.ToUpper(reward.WalletAssetType)
|
||
}
|
||
out.Items = append(out.Items, reward)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (s *Service) Delete(ctx context.Context, command domain.StatusCommand) (domain.Template, bool, error) {
|
||
if err := s.requireRepository(); err != nil {
|
||
return domain.Template{}, false, err
|
||
}
|
||
command.TemplateID = strings.TrimSpace(command.TemplateID)
|
||
command.Status = domain.StatusArchived
|
||
if command.TemplateID == "" || command.OperatorAdminID <= 0 || command.ExpectedRevision <= 0 {
|
||
return domain.Template{}, false, xerr.New(xerr.InvalidArgument, "template_id, operator_admin_id and expected_revision are required")
|
||
}
|
||
current, err := s.repository.GetActivityTemplate(ctx, command.TemplateID)
|
||
if err != nil {
|
||
return domain.Template{}, false, err
|
||
}
|
||
if current.Revision != command.ExpectedRevision {
|
||
return domain.Template{}, false, xerr.New(xerr.Conflict, "activity template revision changed")
|
||
}
|
||
if current.Status == domain.StatusArchived {
|
||
current.LifecycleStatus = domain.LifecycleArchived
|
||
return current, false, nil
|
||
}
|
||
nowMS := s.now().UTC().UnixMilli()
|
||
if current.Status == domain.StatusPublished && lifecycleStatus(current.Status, current.StartMS, current.EndMS, nowMS) != domain.LifecycleEnded {
|
||
// 正在生效或尚未开始的活动必须先停用,避免 DELETE 瞬间让客户端运行配置消失。
|
||
return domain.Template{}, false, xerr.New(xerr.Conflict, "scheduled or ongoing activity template must be disabled before archive")
|
||
}
|
||
stored, err := s.repository.SetActivityTemplateStatus(ctx, command, nowMS)
|
||
if err != nil {
|
||
return domain.Template{}, false, err
|
||
}
|
||
stored.LifecycleStatus = domain.LifecycleArchived
|
||
return stored, true, nil
|
||
}
|
||
|
||
func (s *Service) ListVersions(ctx context.Context, templateID string, page int32, pageSize int32) ([]domain.Version, int64, error) {
|
||
if err := s.requireRepository(); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
templateID = strings.TrimSpace(templateID)
|
||
if templateID == "" {
|
||
return nil, 0, xerr.New(xerr.InvalidArgument, "template_id is required")
|
||
}
|
||
items, total, err := s.repository.ListActivityTemplateVersions(ctx, templateID, page, pageSize)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
nowMS := s.now().UTC().UnixMilli()
|
||
for index := range items {
|
||
items[index].Snapshot.LifecycleStatus = RuntimeLifecycleStatus(
|
||
items[index].Snapshot, items[index].RuntimeFromMS, items[index].RuntimeToMS, nowMS,
|
||
)
|
||
}
|
||
return items, total, nil
|
||
}
|
||
|
||
func (s *Service) Clone(ctx context.Context, command domain.CloneCommand) (domain.Template, error) {
|
||
if err := s.requireRepository(); err != nil {
|
||
return domain.Template{}, err
|
||
}
|
||
command.SourceTemplateID = strings.TrimSpace(command.SourceTemplateID)
|
||
command.TemplateCode = strings.ToLower(strings.TrimSpace(command.TemplateCode))
|
||
command.Name = strings.TrimSpace(command.Name)
|
||
if command.SourceTemplateID == "" || command.OperatorAdminID <= 0 {
|
||
return domain.Template{}, xerr.New(xerr.InvalidArgument, "source_template_id and operator_admin_id are required")
|
||
}
|
||
if command.SourceVersion < 0 {
|
||
return domain.Template{}, xerr.New(xerr.InvalidArgument, "source_version must not be negative")
|
||
}
|
||
var source domain.Template
|
||
if command.SourceVersion > 0 {
|
||
version, err := s.repository.GetActivityTemplateVersion(ctx, command.SourceTemplateID, command.SourceVersion)
|
||
if err != nil {
|
||
return domain.Template{}, err
|
||
}
|
||
source = version.Snapshot
|
||
} else {
|
||
var err error
|
||
source, err = s.repository.GetActivityTemplate(ctx, command.SourceTemplateID)
|
||
if err != nil {
|
||
return domain.Template{}, err
|
||
}
|
||
}
|
||
// 克隆只复用内容,不复制排期和运行身份;清空时间能防止旧活动日期被误发布。
|
||
source.TemplateID = ""
|
||
source.TemplateCode = command.TemplateCode
|
||
source.Name = command.Name
|
||
source.Status = domain.StatusDraft
|
||
source.StartMS = 0
|
||
source.EndMS = 0
|
||
source.Revision = 0
|
||
source.PublishedVersion = 0
|
||
source.CreatedByAdminID = 0
|
||
source.UpdatedByAdminID = 0
|
||
source.PublishedByAdminID = 0
|
||
source.CreatedAtMS = 0
|
||
source.UpdatedAtMS = 0
|
||
source.PublishedAtMS = 0
|
||
source.ArchivedAtMS = 0
|
||
return s.Create(ctx, source, command.OperatorAdminID)
|
||
}
|
||
|
||
// Validate 返回完整字段问题集合,供编辑页一次性标出错误;写接口仍只返回首个错误以保持现有 gRPC 错误模型。
|
||
func (s *Service) Validate(ctx context.Context, template domain.Template, forPublish bool) ([]domain.ValidationIssue, error) {
|
||
template = normalizeTemplate(template)
|
||
issues := validateTemplate(template, forPublish, s.now().UTC().UnixMilli())
|
||
if !forPublish || len(issues) > 0 {
|
||
return issues, nil
|
||
}
|
||
referenceIssues, err := s.validateReferences(ctx, template)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return append(issues, referenceIssues...), nil
|
||
}
|
||
|
||
func (s *Service) requireRepository() error {
|
||
if s == nil || s.repository == nil {
|
||
return xerr.New(xerr.Unavailable, "activity template repository is not configured")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) validateReferences(ctx context.Context, item domain.Template) ([]domain.ValidationIssue, error) {
|
||
if s == nil || s.catalog == nil {
|
||
return nil, xerr.New(xerr.Unavailable, "wallet catalog client is not configured")
|
||
}
|
||
appCode := appcode.FromContext(ctx)
|
||
requestPrefix := "activity-template-validate:" + item.TemplateID + ":" + strings.TrimSpace(item.TemplateCode)
|
||
issues := make([]domain.ValidationIssue, 0)
|
||
if !item.AllRegions {
|
||
if s.regions == nil {
|
||
return nil, xerr.New(xerr.Unavailable, "region catalog client is not configured")
|
||
}
|
||
activeRegionIDs, err := s.regions.ListActiveRegionIDs(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
activeRegions := make(map[int64]struct{}, len(activeRegionIDs))
|
||
for _, regionID := range activeRegionIDs {
|
||
activeRegions[regionID] = struct{}{}
|
||
}
|
||
for _, regionID := range item.RegionIDs {
|
||
if _, active := activeRegions[regionID]; !active {
|
||
issues = append(issues, domain.ValidationIssue{Field: "region_ids", Code: "region_not_active", Message: "region " + strconv.FormatInt(regionID, 10) + " does not exist or is not active"})
|
||
}
|
||
}
|
||
}
|
||
for _, snapshot := range item.Gifts {
|
||
resp, err := s.catalog.ListGiftConfigs(ctx, &walletv1.ListGiftConfigsRequest{
|
||
RequestId: requestPrefix + ":gift:" + snapshot.GiftID,
|
||
AppCode: appCode,
|
||
Keyword: snapshot.GiftID,
|
||
Status: "active",
|
||
Page: 1,
|
||
PageSize: 500,
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var current *walletv1.GiftConfig
|
||
for _, candidate := range resp.GetGifts() {
|
||
if candidate.GetGiftId() == snapshot.GiftID {
|
||
current = candidate
|
||
break
|
||
}
|
||
}
|
||
if current == nil || current.GetResource() == nil || current.GetResource().GetStatus() != "active" {
|
||
issues = append(issues, domain.ValidationIssue{Field: "gifts.gift_id", Code: "gift_not_active", Message: "gift " + snapshot.GiftID + " does not exist or is not active"})
|
||
continue
|
||
}
|
||
// 礼物选择器优先展示 preview_url;未配置预览图时才回退资源原图。发布快照必须使用同一规则,
|
||
// 否则预览图与原图不同的正常礼物会被误判为旧快照。
|
||
ownerIconURL := current.GetResource().GetPreviewUrl()
|
||
if ownerIconURL == "" {
|
||
ownerIconURL = current.GetResource().GetAssetUrl()
|
||
}
|
||
// 发布快照必须与 owner 当前价格版本一致;只验证不自动改写,避免管理员在未察觉价格变化时发布旧展示值。
|
||
if snapshot.Name != current.GetName() || snapshot.IconURL != ownerIconURL || snapshot.CoinPrice != current.GetCoinPrice() || snapshot.PriceVersion != current.GetPriceVersion() {
|
||
issues = append(issues, domain.ValidationIssue{Field: "gifts", Code: "gift_snapshot_stale", Message: "gift " + snapshot.GiftID + " snapshot is stale; reselect the gift before publishing"})
|
||
}
|
||
// 礼物有效期必须覆盖完整活动区间。只检查“当前可用”会让未来活动在开场前或进行中失去计分礼物。
|
||
if (current.GetEffectiveFromMs() > 0 && current.GetEffectiveFromMs() > item.StartMS) ||
|
||
(current.GetEffectiveToMs() > 0 && current.GetEffectiveToMs() < item.EndMS) {
|
||
issues = append(issues, domain.ValidationIssue{Field: "gifts", Code: "gift_period_mismatch", Message: "gift " + snapshot.GiftID + " effective period does not cover the activity period"})
|
||
}
|
||
if !giftSupportsActivityScope(current.GetRegionIds(), item.AllRegions, item.RegionIDs) {
|
||
issues = append(issues, domain.ValidationIssue{Field: "gifts", Code: "gift_region_mismatch", Message: "gift " + snapshot.GiftID + " is not active for every activity region"})
|
||
}
|
||
}
|
||
|
||
groupIDs := map[int64]struct{}{}
|
||
for _, task := range item.Tasks {
|
||
if task.RewardResourceGroupID > 0 {
|
||
groupIDs[task.RewardResourceGroupID] = struct{}{}
|
||
}
|
||
}
|
||
for _, reward := range item.RankRewards {
|
||
if reward.ResourceGroupID > 0 {
|
||
groupIDs[reward.ResourceGroupID] = struct{}{}
|
||
}
|
||
}
|
||
for groupID := range groupIDs {
|
||
resp, err := s.catalog.GetResourceGroup(ctx, &walletv1.GetResourceGroupRequest{
|
||
RequestId: requestPrefix + ":resource-group:" + strconv.FormatInt(groupID, 10),
|
||
AppCode: appCode,
|
||
GroupId: groupID,
|
||
})
|
||
if err != nil {
|
||
if xerr.IsCode(err, xerr.NotFound) {
|
||
issues = append(issues, domain.ValidationIssue{Field: "resource_group_id", Code: "resource_group_not_found", Message: "resource group does not exist"})
|
||
continue
|
||
}
|
||
return nil, err
|
||
}
|
||
group := resp.GetGroup()
|
||
if group == nil || group.GetStatus() != "active" || len(group.GetItems()) == 0 || !validSHA256Hex(strings.ToLower(strings.TrimSpace(resp.GetSourceContentHash()))) {
|
||
issues = append(issues, domain.ValidationIssue{Field: "resource_group_id", Code: "resource_group_not_active", Message: "resource group must be active and contain at least one item"})
|
||
continue
|
||
}
|
||
for _, groupItem := range group.GetItems() {
|
||
if groupItem.GetItemType() == "wallet_asset" {
|
||
if strings.ToUpper(groupItem.GetWalletAssetType()) != "COIN" || groupItem.GetWalletAssetAmount() <= 0 || groupItem.GetResourceId() != 0 || groupItem.GetQuantity() != 1 || groupItem.GetDurationMs() != 0 {
|
||
issues = append(issues, domain.ValidationIssue{Field: "resource_group_id", Code: "resource_group_item_invalid", Message: "resource group contains an invalid wallet asset item"})
|
||
}
|
||
continue
|
||
}
|
||
resource := groupItem.GetResource()
|
||
if resource == nil || resource.GetStatus() != "active" || !resource.GetGrantable() || resource.GetResourceType() == "coin" || resource.GetResourceType() == "vip_trial_card" ||
|
||
groupItem.GetResourceId() <= 0 || groupItem.GetQuantity() <= 0 || groupItem.GetDurationMs() < 0 {
|
||
issues = append(issues, domain.ValidationIssue{Field: "resource_group_id", Code: "resource_group_item_invalid", Message: "resource group contains an inactive or unsupported resource"})
|
||
}
|
||
}
|
||
}
|
||
return deduplicateIssues(issues), nil
|
||
}
|
||
|
||
func giftSupportsActivityScope(giftRegionIDs []int64, allRegions bool, activityRegionIDs []int64) bool {
|
||
available := make(map[int64]struct{}, len(giftRegionIDs))
|
||
for _, regionID := range giftRegionIDs {
|
||
available[regionID] = struct{}{}
|
||
}
|
||
if _, global := available[0]; global {
|
||
return true
|
||
}
|
||
if allRegions {
|
||
return false
|
||
}
|
||
for _, regionID := range activityRegionIDs {
|
||
if _, ok := available[regionID]; !ok {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
func normalizeTemplate(item domain.Template) domain.Template {
|
||
item.TemplateID = strings.TrimSpace(item.TemplateID)
|
||
item.TemplateCode = strings.ToLower(strings.TrimSpace(item.TemplateCode))
|
||
item.Name = strings.TrimSpace(item.Name)
|
||
item.ActivityType = strings.ToLower(strings.TrimSpace(item.ActivityType))
|
||
if item.ActivityType == "" {
|
||
item.ActivityType = domain.ActivityTypeGiftChallenge
|
||
}
|
||
item.Status = normalizeStatus(item.Status)
|
||
if item.Status == "" {
|
||
item.Status = domain.StatusDraft
|
||
}
|
||
if item.DailyLeaderboardSize == 0 {
|
||
item.DailyLeaderboardSize = defaultLeaderboardSize
|
||
}
|
||
if item.TotalLeaderboardSize == 0 {
|
||
item.TotalLeaderboardSize = defaultLeaderboardSize
|
||
}
|
||
item.DisplayConfigJSON = strings.TrimSpace(item.DisplayConfigJSON)
|
||
if item.DisplayConfigJSON == "" {
|
||
item.DisplayConfigJSON = "{}"
|
||
}
|
||
|
||
regions := append([]int64(nil), item.RegionIDs...)
|
||
sort.Slice(regions, func(i, j int) bool { return regions[i] < regions[j] })
|
||
item.RegionIDs = regions
|
||
for index := range item.Locales {
|
||
item.Locales[index].Locale = normalizeLocale(item.Locales[index].Locale)
|
||
item.Locales[index].Title = strings.TrimSpace(item.Locales[index].Title)
|
||
item.Locales[index].Rules = strings.TrimSpace(item.Locales[index].Rules)
|
||
}
|
||
sort.SliceStable(item.Locales, func(i, j int) bool { return item.Locales[i].Locale < item.Locales[j].Locale })
|
||
for index := range item.Gifts {
|
||
item.Gifts[index].GiftID = strings.TrimSpace(item.Gifts[index].GiftID)
|
||
item.Gifts[index].Name = strings.TrimSpace(item.Gifts[index].Name)
|
||
item.Gifts[index].IconURL = strings.TrimSpace(item.Gifts[index].IconURL)
|
||
item.Gifts[index].PriceVersion = strings.TrimSpace(item.Gifts[index].PriceVersion)
|
||
}
|
||
sort.SliceStable(item.Gifts, func(i, j int) bool {
|
||
if item.Gifts[i].SortOrder == item.Gifts[j].SortOrder {
|
||
return item.Gifts[i].GiftID < item.Gifts[j].GiftID
|
||
}
|
||
return item.Gifts[i].SortOrder < item.Gifts[j].SortOrder
|
||
})
|
||
for index := range item.Tasks {
|
||
item.Tasks[index].TaskKey = strings.ToLower(strings.TrimSpace(item.Tasks[index].TaskKey))
|
||
item.Tasks[index].TaskType = strings.ToLower(strings.TrimSpace(item.Tasks[index].TaskType))
|
||
}
|
||
sort.SliceStable(item.Tasks, func(i, j int) bool {
|
||
if item.Tasks[i].SortOrder == item.Tasks[j].SortOrder {
|
||
return item.Tasks[i].TaskKey < item.Tasks[j].TaskKey
|
||
}
|
||
return item.Tasks[i].SortOrder < item.Tasks[j].SortOrder
|
||
})
|
||
for index := range item.RankRewards {
|
||
item.RankRewards[index].BoardType = strings.ToLower(strings.TrimSpace(item.RankRewards[index].BoardType))
|
||
}
|
||
sort.SliceStable(item.RankRewards, func(i, j int) bool {
|
||
if item.RankRewards[i].BoardType != item.RankRewards[j].BoardType {
|
||
return item.RankRewards[i].BoardType < item.RankRewards[j].BoardType
|
||
}
|
||
if item.RankRewards[i].RankFrom != item.RankRewards[j].RankFrom {
|
||
return item.RankRewards[i].RankFrom < item.RankRewards[j].RankFrom
|
||
}
|
||
return item.RankRewards[i].RankTo < item.RankRewards[j].RankTo
|
||
})
|
||
for index := range item.Assets {
|
||
item.Assets[index].AssetKey = strings.ToLower(strings.TrimSpace(item.Assets[index].AssetKey))
|
||
item.Assets[index].Locale = normalizeLocale(item.Assets[index].Locale)
|
||
if item.Assets[index].Locale == "" {
|
||
item.Assets[index].Locale = domain.SharedAssetLocale
|
||
}
|
||
item.Assets[index].URL = strings.TrimSpace(item.Assets[index].URL)
|
||
item.Assets[index].MediaType = strings.ToLower(strings.TrimSpace(item.Assets[index].MediaType))
|
||
}
|
||
sort.SliceStable(item.Assets, func(i, j int) bool {
|
||
if item.Assets[i].AssetKey != item.Assets[j].AssetKey {
|
||
return item.Assets[i].AssetKey < item.Assets[j].AssetKey
|
||
}
|
||
if item.Assets[i].Locale != item.Assets[j].Locale {
|
||
return item.Assets[i].Locale < item.Assets[j].Locale
|
||
}
|
||
return item.Assets[i].SortOrder < item.Assets[j].SortOrder
|
||
})
|
||
return item
|
||
}
|
||
|
||
func validateTemplate(item domain.Template, forPublish bool, nowMS int64) []domain.ValidationIssue {
|
||
issues := make([]domain.ValidationIssue, 0)
|
||
add := func(field string, code string, message string) {
|
||
issues = append(issues, domain.ValidationIssue{Field: field, Code: code, Message: message})
|
||
}
|
||
if !templateCodePattern.MatchString(item.TemplateCode) {
|
||
add("template_code", "invalid_format", "template_code must be 3-64 lowercase letters, numbers, underscore or hyphen")
|
||
}
|
||
if utf8.RuneCountInString(item.Name) == 0 || utf8.RuneCountInString(item.Name) > 128 {
|
||
add("name", "invalid_length", "name is required and must not exceed 128 characters")
|
||
}
|
||
if item.ActivityType != domain.ActivityTypeGiftChallenge {
|
||
add("activity_type", "unsupported", "activity_type is not supported")
|
||
}
|
||
if item.Status != domain.StatusDraft && item.Status != domain.StatusDisabled && item.Status != domain.StatusPublished {
|
||
add("status", "invalid", "status is invalid")
|
||
}
|
||
if item.StartMS != 0 || item.EndMS != 0 || forPublish {
|
||
if item.StartMS <= 0 || item.EndMS <= item.StartMS {
|
||
add("time_range", "invalid_range", "activity period must use [start_ms,end_ms)")
|
||
} else if item.EndMS-item.StartMS > maxActivityDurationMS {
|
||
add("time_range", "duration_too_long", "activity period must not exceed 366 days")
|
||
}
|
||
}
|
||
if forPublish && item.EndMS <= nowMS {
|
||
add("end_ms", "already_ended", "activity end_ms must be later than current UTC time")
|
||
}
|
||
|
||
seenRegions := map[int64]struct{}{}
|
||
for index, regionID := range item.RegionIDs {
|
||
if regionID <= 0 {
|
||
add("region_ids", "invalid_region", "region_ids must contain positive IDs")
|
||
}
|
||
if _, exists := seenRegions[regionID]; exists {
|
||
add("region_ids", "duplicate_region", "region_ids must not contain duplicates")
|
||
}
|
||
seenRegions[regionID] = struct{}{}
|
||
_ = index
|
||
}
|
||
if item.AllRegions && len(item.RegionIDs) > 0 {
|
||
add("region_ids", "scope_conflict", "all_regions cannot be combined with region_ids")
|
||
}
|
||
if forPublish && !item.AllRegions && len(item.RegionIDs) == 0 {
|
||
add("region_ids", "required", "at least one region is required when all_regions is false")
|
||
}
|
||
if len(item.RegionIDs) > 100 {
|
||
add("region_ids", "too_many", "region_ids must not exceed 100")
|
||
}
|
||
|
||
seenLocales := map[string]struct{}{}
|
||
hasEnglish := false
|
||
for _, locale := range item.Locales {
|
||
if len(locale.Locale) > 24 || !localePattern.MatchString(locale.Locale) {
|
||
add("locales", "invalid_locale", "locale must be a lowercase BCP-47 language tag")
|
||
}
|
||
if _, exists := seenLocales[locale.Locale]; exists {
|
||
add("locales", "duplicate_locale", "locale must be unique")
|
||
}
|
||
seenLocales[locale.Locale] = struct{}{}
|
||
if locale.Locale == "en" {
|
||
hasEnglish = true
|
||
}
|
||
if utf8.RuneCountInString(locale.Title) > 160 {
|
||
add("locales.title", "too_long", "localized title must not exceed 160 characters")
|
||
}
|
||
if len(locale.Rules) > 32*1024 {
|
||
add("locales.rules", "too_long", "localized rules must not exceed 32KB")
|
||
}
|
||
if forPublish && (locale.Title == "" || locale.Rules == "") {
|
||
add("locales", "content_required", "localized title and rules are required for publish")
|
||
}
|
||
}
|
||
if forPublish && !hasEnglish {
|
||
add("locales", "english_required", "English title and rules are required for publish")
|
||
}
|
||
if len(item.Locales) > 20 {
|
||
add("locales", "too_many", "locales must not exceed 20")
|
||
}
|
||
|
||
seenGifts := map[string]struct{}{}
|
||
for _, gift := range item.Gifts {
|
||
if gift.GiftID == "" {
|
||
add("gifts.gift_id", "required", "gift_id is required")
|
||
} else if len(gift.GiftID) > 96 {
|
||
add("gifts.gift_id", "too_long", "gift_id must not exceed 96 bytes")
|
||
}
|
||
if _, exists := seenGifts[gift.GiftID]; exists {
|
||
add("gifts", "duplicate_gift", "gift_id must be unique")
|
||
}
|
||
seenGifts[gift.GiftID] = struct{}{}
|
||
if gift.CoinPrice < 0 {
|
||
add("gifts.coin_price", "invalid", "coin_price must not be negative")
|
||
}
|
||
if utf8.RuneCountInString(gift.Name) > 128 || len(gift.IconURL) > 512 || len(gift.PriceVersion) > 64 {
|
||
add("gifts", "snapshot_too_long", "gift snapshot exceeds storage limits")
|
||
}
|
||
if gift.SortOrder < 0 {
|
||
add("gifts.sort_order", "invalid", "sort_order must not be negative")
|
||
}
|
||
if forPublish {
|
||
if gift.Name == "" || gift.IconURL == "" || gift.CoinPrice <= 0 || gift.PriceVersion == "" {
|
||
add("gifts", "snapshot_required", "published gifts require name, icon_url, positive coin_price and price_version snapshots")
|
||
} else if !validHTTPURL(gift.IconURL) {
|
||
add("gifts.icon_url", "invalid_url", "gift icon_url must be an absolute http or https URL")
|
||
}
|
||
}
|
||
}
|
||
if len(item.Gifts) > 20 {
|
||
add("gifts", "too_many", "gifts must not exceed 20")
|
||
} else if forPublish && len(item.Gifts) == 0 {
|
||
add("gifts", "required", "published activity requires at least one gift")
|
||
}
|
||
|
||
seenTasks := map[string]struct{}{}
|
||
dailyVisitTaskCount := 0
|
||
for _, task := range item.Tasks {
|
||
if !taskKeyPattern.MatchString(task.TaskKey) {
|
||
add("tasks.task_key", "invalid_format", "task_key format is invalid")
|
||
}
|
||
if _, exists := seenTasks[task.TaskKey]; exists {
|
||
add("tasks", "duplicate_task", "task_key must be unique")
|
||
}
|
||
seenTasks[task.TaskKey] = struct{}{}
|
||
if !validTaskType(task.TaskType) {
|
||
add("tasks.task_type", "unsupported", "task_type is not supported")
|
||
}
|
||
if task.TargetValue <= 0 {
|
||
add("tasks.target_value", "invalid", "target_value must be positive")
|
||
}
|
||
if task.TaskType == domain.TaskTypeDailyVisit && task.TargetValue != 1 {
|
||
add("tasks.target_value", "daily_visit_target", "daily_visit target_value must equal 1")
|
||
}
|
||
if task.TaskType == domain.TaskTypeDailyVisit {
|
||
dailyVisitTaskCount++
|
||
}
|
||
if task.SortOrder < 0 {
|
||
add("tasks.sort_order", "invalid", "sort_order must not be negative")
|
||
}
|
||
if forPublish && task.RewardResourceGroupID <= 0 {
|
||
add("tasks.reward_resource_group_id", "required", "task reward resource group is required for publish")
|
||
}
|
||
}
|
||
// daily_visit 的事实粒度固定为“用户 + 模版版本 + UTC 自然日”一次。允许配置多档会让同一次访问
|
||
// 同时生成多份入场奖励,既违背原型的单档语义,也容易被运营误配,因此 owner 层必须强制唯一。
|
||
if dailyVisitTaskCount > 1 {
|
||
add("tasks", "duplicate_daily_visit", "daily_visit task must not appear more than once")
|
||
}
|
||
if len(item.Tasks) > 30 {
|
||
add("tasks", "too_many", "tasks must not exceed 30")
|
||
} else if forPublish && len(item.Tasks) == 0 {
|
||
add("tasks", "required", "published activity requires at least one task")
|
||
}
|
||
|
||
if item.DailyLeaderboardSize < 3 || item.DailyLeaderboardSize > 1000 {
|
||
add("daily_leaderboard_size", "out_of_range", "daily_leaderboard_size must be between 3 and 1000")
|
||
}
|
||
if item.TotalLeaderboardSize < 3 || item.TotalLeaderboardSize > 1000 {
|
||
add("total_leaderboard_size", "out_of_range", "total_leaderboard_size must be between 3 and 1000")
|
||
}
|
||
for _, boardType := range []string{domain.BoardTypeDaily, domain.BoardTypeTotal} {
|
||
boardRewards := make([]domain.RankReward, 0)
|
||
for _, reward := range item.RankRewards {
|
||
if reward.BoardType == boardType {
|
||
boardRewards = append(boardRewards, reward)
|
||
}
|
||
}
|
||
sort.Slice(boardRewards, func(i, j int) bool { return boardRewards[i].RankFrom < boardRewards[j].RankFrom })
|
||
if forPublish && len(boardRewards) == 0 {
|
||
add("rank_rewards", "board_required", boardType+" leaderboard reward is required for publish")
|
||
}
|
||
previousTo := int32(0)
|
||
for _, reward := range boardRewards {
|
||
limit := item.DailyLeaderboardSize
|
||
if boardType == domain.BoardTypeTotal {
|
||
limit = item.TotalLeaderboardSize
|
||
}
|
||
if reward.RankFrom <= 0 || reward.RankTo < reward.RankFrom || reward.RankTo > limit {
|
||
add("rank_rewards", "invalid_range", "rank reward range is outside leaderboard size")
|
||
}
|
||
if previousTo > 0 && reward.RankFrom <= previousTo {
|
||
add("rank_rewards", "overlap", "rank reward ranges must not overlap")
|
||
}
|
||
if forPublish && reward.ResourceGroupID <= 0 {
|
||
add("rank_rewards.resource_group_id", "required", "rank reward resource group is required for publish")
|
||
}
|
||
previousTo = reward.RankTo
|
||
}
|
||
}
|
||
for _, reward := range item.RankRewards {
|
||
if reward.BoardType != domain.BoardTypeDaily && reward.BoardType != domain.BoardTypeTotal {
|
||
add("rank_rewards.board_type", "unsupported", "board_type must be daily or total")
|
||
}
|
||
if reward.SortOrder < 0 {
|
||
add("rank_rewards.sort_order", "invalid", "sort_order must not be negative")
|
||
}
|
||
}
|
||
if len(item.RankRewards) > maxRankRewardRanges {
|
||
add("rank_rewards", "too_many", "rank reward ranges must not exceed 100")
|
||
}
|
||
|
||
seenAssets := map[string]struct{}{}
|
||
for _, asset := range item.Assets {
|
||
if !assetKeyPattern.MatchString(asset.AssetKey) {
|
||
add("assets.asset_key", "invalid_format", "asset_key format is invalid")
|
||
}
|
||
if asset.Locale != domain.SharedAssetLocale && (len(asset.Locale) > 24 || !localePattern.MatchString(asset.Locale)) {
|
||
add("assets.locale", "invalid_locale", "asset locale must be * or a lowercase BCP-47 tag")
|
||
}
|
||
key := asset.AssetKey + "\x00" + asset.Locale
|
||
if _, exists := seenAssets[key]; exists {
|
||
add("assets", "duplicate_asset", "asset_key and locale combination must be unique")
|
||
}
|
||
seenAssets[key] = struct{}{}
|
||
if asset.URL == "" || len(asset.URL) > 1024 || !validHTTPURL(asset.URL) {
|
||
add("assets.url", "invalid_url", "asset URL must be an absolute http or https URL")
|
||
}
|
||
if !validMediaType(asset.MediaType) {
|
||
add("assets.media_type", "unsupported", "media_type must be image, webp, svga or pag")
|
||
}
|
||
if asset.SortOrder < 0 {
|
||
add("assets.sort_order", "invalid", "sort_order must not be negative")
|
||
}
|
||
}
|
||
if len(item.Assets) > 100 {
|
||
add("assets", "too_many", "assets must not exceed 100")
|
||
}
|
||
if len(item.DisplayConfigJSON) > maxDisplayConfigBytes {
|
||
add("display_config_json", "too_large", "display_config_json must not exceed 64KB")
|
||
} else {
|
||
var display map[string]any
|
||
if err := json.Unmarshal([]byte(item.DisplayConfigJSON), &display); err != nil || display == nil {
|
||
add("display_config_json", "invalid_json", "display_config_json must be a JSON object")
|
||
}
|
||
}
|
||
return deduplicateIssues(issues)
|
||
}
|
||
|
||
func lifecycleStatus(status string, startMS int64, endMS int64, nowMS int64) string {
|
||
switch status {
|
||
case domain.StatusDraft:
|
||
return domain.LifecycleDraft
|
||
case domain.StatusDisabled:
|
||
return domain.LifecycleDisabled
|
||
case domain.StatusArchived:
|
||
return domain.LifecycleArchived
|
||
case domain.StatusPublished:
|
||
// 边界与全局规则一致:[start_ms,end_ms),开始毫秒立即 ongoing,结束毫秒立即 ended。
|
||
if nowMS < startMS {
|
||
return domain.LifecycleScheduled
|
||
}
|
||
if nowMS >= endMS {
|
||
return domain.LifecycleEnded
|
||
}
|
||
return domain.LifecycleOngoing
|
||
default:
|
||
return status
|
||
}
|
||
}
|
||
|
||
// RuntimeLifecycleStatus derives presentation state from an immutable version's actual runtime window.
|
||
// A mid-flight disable remains disabled even after a newer version is published; planned start/end alone
|
||
// would otherwise make both v1 and v2 appear ongoing for the same wall-clock instant.
|
||
func RuntimeLifecycleStatus(item domain.Template, runtimeFromMS int64, runtimeToMS int64, nowMS int64) string {
|
||
switch item.Status {
|
||
case domain.StatusDraft:
|
||
return domain.LifecycleDraft
|
||
case domain.StatusArchived:
|
||
return domain.LifecycleArchived
|
||
}
|
||
if runtimeFromMS <= 0 {
|
||
// Compatibility for versions created before runtime windows were materialized. New publishes always
|
||
// join a positive runtime_from_ms, but old data must still produce a useful planned lifecycle.
|
||
runtimeFromMS = item.StartMS
|
||
}
|
||
if runtimeToMS > 0 && runtimeToMS < item.EndMS && nowMS >= runtimeToMS {
|
||
return domain.LifecycleDisabled
|
||
}
|
||
if nowMS < runtimeFromMS {
|
||
return domain.LifecycleScheduled
|
||
}
|
||
if nowMS >= item.EndMS || (runtimeToMS > 0 && nowMS >= runtimeToMS) {
|
||
return domain.LifecycleEnded
|
||
}
|
||
return domain.LifecycleOngoing
|
||
}
|
||
|
||
func normalizeStatus(value string) string {
|
||
return strings.ToLower(strings.TrimSpace(value))
|
||
}
|
||
|
||
func normalizeLocale(value string) string {
|
||
return strings.ToLower(strings.ReplaceAll(strings.TrimSpace(value), "_", "-"))
|
||
}
|
||
|
||
func validTaskType(value string) bool {
|
||
switch value {
|
||
case domain.TaskTypeDailyVisit, domain.TaskTypeGiftCount, domain.TaskTypeGiftCoinAmount:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func validMediaType(value string) bool {
|
||
switch value {
|
||
case "image", "webp", "svga", "pag":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func validListStatus(value string) bool {
|
||
switch value {
|
||
case "", domain.StatusDraft, domain.StatusPublished, domain.StatusDisabled, domain.StatusArchived,
|
||
domain.LifecycleScheduled, domain.LifecycleOngoing, domain.LifecycleEnded, "active":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func validHTTPURL(value string) bool {
|
||
parsed, err := url.ParseRequestURI(value)
|
||
return err == nil && parsed.Host != "" && (parsed.Scheme == "http" || parsed.Scheme == "https")
|
||
}
|
||
|
||
func issueError(issue domain.ValidationIssue) error {
|
||
return xerr.New(xerr.InvalidArgument, issue.Field+": "+issue.Message)
|
||
}
|
||
|
||
func deduplicateIssues(items []domain.ValidationIssue) []domain.ValidationIssue {
|
||
seen := map[string]struct{}{}
|
||
out := make([]domain.ValidationIssue, 0, len(items))
|
||
for _, item := range items {
|
||
key := item.Field + "\x00" + item.Code + "\x00" + item.Message
|
||
if _, exists := seen[key]; exists {
|
||
continue
|
||
}
|
||
seen[key] = struct{}{}
|
||
out = append(out, item)
|
||
}
|
||
return out
|
||
}
|