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 }