package policyconfig import ( "context" "database/sql" "encoding/json" "errors" "math" "strconv" "strings" "time" "hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/integration/activityclient" activityv1 "hyapp.local/api/proto/activity/v1" ) const ( policyStatusActive = "active" policyStatusDisabled = "disabled" publishStatusDraft = "draft" publishStatusPublished = "published" publishStatusFailed = "failed" policyTaskRewardAssetCoin = "COIN" defaultPolicyTemplateCode = "first_google70000_coin_seller_92000_100000_v1" defaultPolicyTemplateVersion = "v1" ) type Service struct { adminDB *sql.DB walletDB *sql.DB userDB *sql.DB activity activityclient.Client } type listTemplatesQuery struct { Keyword string Status string Page int PageSize int } type listInstancesQuery struct { Keyword string Status string Page int PageSize int } type policyInstanceRow struct { InstanceID uint64 AppCode string InstanceCode string TemplateCode string TemplateVersion string RegionScope string Status string EffectiveFromMS int64 EffectiveToMS int64 } type compiledPolicy struct { HostPointRatioPPM int64 PointsPerUSD int64 WithdrawFeeBPS int64 TaskRewardAssetType string RuleJSON json.RawMessage } func NewService(adminDB *sql.DB, walletDB *sql.DB, userDB *sql.DB, activity activityclient.Client) *Service { return &Service{adminDB: adminDB, walletDB: walletDB, userDB: userDB, activity: activity} } func (s *Service) ListTemplates(ctx context.Context, query listTemplatesQuery) ([]templateDTO, int64, error) { if s == nil || s.adminDB == nil { return nil, 0, errors.New("admin database is not configured") } where, args := templateWhere(query) var total int64 if err := s.adminDB.QueryRowContext(ctx, `SELECT COUNT(*) FROM admin_policy_templates t JOIN admin_policy_template_versions v ON v.template_code = t.template_code AND v.template_version = t.template_version `+where, args...).Scan(&total); err != nil { return nil, 0, err } page, pageSize := normalizePage(query.Page, query.PageSize) args = append(args, pageSize, (page-1)*pageSize) rows, err := s.adminDB.QueryContext(ctx, ` SELECT t.template_id, v.version_id, t.template_code, v.template_version, t.name, v.status, COALESCE(CAST(v.rule_json AS CHAR), '{}'), t.description, v.created_at_ms, v.updated_at_ms FROM admin_policy_templates t JOIN admin_policy_template_versions v ON v.template_code = t.template_code AND v.template_version = t.template_version `+where+` ORDER BY t.updated_at_ms DESC, v.updated_at_ms DESC LIMIT ? OFFSET ?`, args...) if err != nil { return nil, 0, err } defer rows.Close() items := make([]templateDTO, 0) for rows.Next() { item, err := scanTemplate(rows) if err != nil { return nil, 0, err } items = append(items, item) } return items, total, rows.Err() } func (s *Service) CreateTemplate(ctx context.Context, actorID uint, req templateRequest) (templateDTO, error) { if s == nil || s.adminDB == nil { return templateDTO{}, errors.New("admin database is not configured") } templateCode := normalizeCode(req.TemplateCode) version := normalizeVersion(req.TemplateVersion) name := strings.TrimSpace(req.Name) status := normalizeStatus(req.Status) if templateCode == "" || version == "" || name == "" { return templateDTO{}, errors.New("template_code, template_version and name are required") } ruleJSON, err := normalizeRuleJSON(req.RuleJSON) if err != nil { return templateDTO{}, err } nowMS := time.Now().UTC().UnixMilli() tx, err := s.adminDB.BeginTx(ctx, nil) if err != nil { return templateDTO{}, err } defer func() { _ = tx.Rollback() }() if _, err := tx.ExecContext(ctx, ` INSERT INTO admin_policy_templates ( template_code, template_version, name, status, rule_json, description, created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, CAST(? AS JSON), ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE name = VALUES(name), status = VALUES(status), rule_json = VALUES(rule_json), description = VALUES(description), updated_by_admin_id = VALUES(updated_by_admin_id), updated_at_ms = VALUES(updated_at_ms)`, templateCode, version, name, status, string(ruleJSON), strings.TrimSpace(req.Description), actorID, actorID, nowMS, nowMS, ); err != nil { return templateDTO{}, err } if _, err := tx.ExecContext(ctx, ` INSERT INTO admin_policy_template_versions ( template_code, template_version, status, rule_json, created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, CAST(? AS JSON), ?, ?, ?, ?) ON DUPLICATE KEY UPDATE status = VALUES(status), rule_json = VALUES(rule_json), updated_by_admin_id = VALUES(updated_by_admin_id), updated_at_ms = VALUES(updated_at_ms)`, templateCode, version, status, string(ruleJSON), actorID, actorID, nowMS, nowMS, ); err != nil { return templateDTO{}, err } if err := tx.Commit(); err != nil { return templateDTO{}, err } return s.getTemplate(ctx, templateCode, version) } func (s *Service) ListInstances(ctx context.Context, appCode string, query listInstancesQuery) ([]instanceDTO, int64, error) { if s == nil || s.adminDB == nil { return nil, 0, errors.New("admin database is not configured") } appCode = appctx.Normalize(appCode) where, args := instanceWhere(appCode, query) var total int64 if err := s.adminDB.QueryRowContext(ctx, `SELECT COUNT(*) FROM admin_policy_instances `+where, args...).Scan(&total); err != nil { return nil, 0, err } page, pageSize := normalizePage(query.Page, query.PageSize) args = append(args, pageSize, (page-1)*pageSize) rows, err := s.adminDB.QueryContext(ctx, ` SELECT instance_id, app_code, instance_code, template_code, template_version, region_scope, status, effective_from_ms, effective_to_ms, publish_status, publish_error, last_published_at_ms, created_at_ms, updated_at_ms FROM admin_policy_instances `+where+` ORDER BY updated_at_ms DESC, instance_id DESC LIMIT ? OFFSET ?`, args...) if err != nil { return nil, 0, err } defer rows.Close() items := make([]instanceDTO, 0) for rows.Next() { item, err := scanInstanceDTO(rows) if err != nil { return nil, 0, err } items = append(items, item) } return items, total, rows.Err() } func (s *Service) CreateInstance(ctx context.Context, appCode string, actorID uint, req instanceRequest) (instanceDTO, error) { if s == nil || s.adminDB == nil { return instanceDTO{}, errors.New("admin database is not configured") } appCode = appctx.Normalize(appCode) instanceCode := normalizeCode(req.InstanceCode) templateCode := normalizeCode(req.TemplateCode) version := normalizeVersion(req.TemplateVersion) if instanceCode == "" || templateCode == "" || version == "" { return instanceDTO{}, errors.New("instance_code, template_code and template_version are required") } if _, err := s.getTemplate(ctx, templateCode, version); err != nil { return instanceDTO{}, err } status := normalizeStatus(req.Status) regionScope := normalizeRegionScope(req.RegionScope) if err := validateEffectiveRange(req.EffectiveFromMS, req.EffectiveToMS); err != nil { return instanceDTO{}, err } nowMS := time.Now().UTC().UnixMilli() effectiveFromMS := req.EffectiveFromMS if effectiveFromMS <= 0 { effectiveFromMS = nowMS } result, err := s.adminDB.ExecContext(ctx, ` INSERT INTO admin_policy_instances ( app_code, instance_code, template_code, template_version, region_scope, status, effective_from_ms, effective_to_ms, publish_status, created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, appCode, instanceCode, templateCode, version, regionScope, status, effectiveFromMS, req.EffectiveToMS, publishStatusDraft, actorID, actorID, nowMS, nowMS, ) if err != nil { return instanceDTO{}, err } id, err := result.LastInsertId() if err != nil { return instanceDTO{}, err } return s.getInstanceDTO(ctx, appCode, uint64(id)) } func (s *Service) UpdateInstanceStatus(ctx context.Context, appCode string, actorID uint, instanceID uint64, status string) (instanceDTO, error) { if s == nil || s.adminDB == nil { return instanceDTO{}, errors.New("admin database is not configured") } appCode = appctx.Normalize(appCode) status = normalizeStatus(status) nowMS := time.Now().UTC().UnixMilli() instance, err := s.getInstanceRow(ctx, appCode, instanceID) if err != nil { return instanceDTO{}, err } ruleJSON, err := s.getTemplateRuleJSONSnapshot(ctx, instance.TemplateCode, instance.TemplateVersion) if err != nil { return instanceDTO{}, err } compiled, err := compilePolicy(ruleJSON) if err != nil { return instanceDTO{}, err } result, err := s.adminDB.ExecContext(ctx, ` UPDATE admin_policy_instances SET status = ?, updated_by_admin_id = ?, updated_at_ms = ? WHERE app_code = ? AND instance_id = ?`, status, actorID, nowMS, appCode, instanceID, ) if err != nil { return instanceDTO{}, err } affected, err := result.RowsAffected() if err != nil { return instanceDTO{}, err } if affected == 0 { return instanceDTO{}, errors.New("policy instance not found") } instance.Status = status if err := s.syncWalletRuntimeStatus(ctx, instance, status, nowMS); err != nil { return instanceDTO{}, err } if err := s.publishActivityRuntime(ctx, instance, compiled, actorID, nowMS); err != nil { return instanceDTO{}, err } return s.getInstanceDTO(ctx, appCode, instanceID) } func (s *Service) PublishInstance(ctx context.Context, appCode string, actorID uint, instanceID uint64) (publishDTO, error) { if s == nil || s.adminDB == nil || s.walletDB == nil || s.userDB == nil || s.activity == nil { return publishDTO{}, errors.New("policy publish database is not configured") } appCode = appctx.Normalize(appCode) instance, err := s.getInstanceRow(ctx, appCode, instanceID) if err != nil { return publishDTO{}, err } if instance.Status != policyStatusActive { return publishDTO{}, errors.New("only active policy instance can be published") } ruleJSON, err := s.getTemplateRuleJSON(ctx, instance.TemplateCode, instance.TemplateVersion) if err != nil { return publishDTO{}, err } compiled, err := compilePolicy(ruleJSON) if err != nil { return publishDTO{}, err } regionIDs, err := s.resolveRegionScope(ctx, instance.AppCode, instance.RegionScope) if err != nil { return publishDTO{}, err } nowMS := time.Now().UTC().UnixMilli() targetCount := len(regionIDs) + 1 jobID, err := s.createPublishJob(ctx, instance, actorID, targetCount, nowMS) if err != nil { return publishDTO{}, err } if err := s.publishWalletRuntime(ctx, instance, compiled, regionIDs, actorID, nowMS); err != nil { _ = s.finishPublishJob(ctx, jobID, publishStatusFailed, targetCount, 0, targetCount, err.Error(), nowMS) _ = s.markInstancePublishFailed(ctx, instance.AppCode, instance.InstanceID, err.Error(), nowMS) return publishDTO{}, err } if err := s.publishActivityRuntime(ctx, instance, compiled, actorID, nowMS); err != nil { _ = s.finishPublishJob(ctx, jobID, publishStatusFailed, targetCount, len(regionIDs), 1, err.Error(), nowMS) _ = s.markInstancePublishFailed(ctx, instance.AppCode, instance.InstanceID, err.Error(), nowMS) return publishDTO{}, err } if err := s.insertPublishItems(ctx, jobID, instance.AppCode, regionIDs, true, nowMS); err != nil { _ = s.finishPublishJob(ctx, jobID, publishStatusFailed, targetCount, targetCount, 0, err.Error(), nowMS) _ = s.markInstancePublishFailed(ctx, instance.AppCode, instance.InstanceID, err.Error(), nowMS) return publishDTO{}, err } if err := s.finishPublishJob(ctx, jobID, "succeeded", targetCount, targetCount, 0, "", nowMS); err != nil { return publishDTO{}, err } if _, err := s.adminDB.ExecContext(ctx, ` UPDATE admin_policy_instances SET publish_status = ?, publish_error = '', last_published_at_ms = ?, updated_by_admin_id = ?, updated_at_ms = ? WHERE app_code = ? AND instance_id = ?`, publishStatusPublished, nowMS, actorID, nowMS, instance.AppCode, instance.InstanceID, ); err != nil { return publishDTO{}, err } return publishDTO{ JobID: jobID, InstanceID: instance.InstanceID, AppCode: instance.AppCode, InstanceCode: instance.InstanceCode, TemplateCode: instance.TemplateCode, TemplateVersion: instance.TemplateVersion, Status: publishStatusPublished, TargetCount: targetCount, PublishedRegionID: regionIDs, PublishedAtMS: nowMS, }, nil } func (s *Service) getTemplate(ctx context.Context, templateCode string, version string) (templateDTO, error) { row := s.adminDB.QueryRowContext(ctx, ` SELECT t.template_id, v.version_id, t.template_code, v.template_version, t.name, v.status, COALESCE(CAST(v.rule_json AS CHAR), '{}'), t.description, v.created_at_ms, v.updated_at_ms FROM admin_policy_templates t JOIN admin_policy_template_versions v ON v.template_code = t.template_code AND v.template_version = t.template_version WHERE t.template_code = ? AND v.template_version = ?`, templateCode, version, ) item, err := scanTemplate(row) if errors.Is(err, sql.ErrNoRows) { return templateDTO{}, errors.New("policy template version not found") } return item, err } func (s *Service) getTemplateRuleJSON(ctx context.Context, templateCode string, version string) (json.RawMessage, error) { var raw string if err := s.adminDB.QueryRowContext(ctx, ` SELECT COALESCE(CAST(rule_json AS CHAR), '{}') FROM admin_policy_template_versions WHERE template_code = ? AND template_version = ? AND status = ?`, templateCode, version, policyStatusActive, ).Scan(&raw); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, errors.New("active policy template version not found") } return nil, err } return normalizeRuleJSON(json.RawMessage(raw)) } func (s *Service) getTemplateRuleJSONSnapshot(ctx context.Context, templateCode string, version string) (json.RawMessage, error) { var raw string if err := s.adminDB.QueryRowContext(ctx, ` SELECT COALESCE(CAST(rule_json AS CHAR), '{}') FROM admin_policy_template_versions WHERE template_code = ? AND template_version = ?`, templateCode, version, ).Scan(&raw); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, errors.New("policy template version not found") } return nil, err } return normalizeRuleJSON(json.RawMessage(raw)) } func (s *Service) getInstanceDTO(ctx context.Context, appCode string, instanceID uint64) (instanceDTO, error) { row := s.adminDB.QueryRowContext(ctx, ` SELECT instance_id, app_code, instance_code, template_code, template_version, region_scope, status, effective_from_ms, effective_to_ms, publish_status, publish_error, last_published_at_ms, created_at_ms, updated_at_ms FROM admin_policy_instances WHERE app_code = ? AND instance_id = ?`, appCode, instanceID, ) item, err := scanInstanceDTO(row) if errors.Is(err, sql.ErrNoRows) { return instanceDTO{}, errors.New("policy instance not found") } return item, err } func (s *Service) getInstanceRow(ctx context.Context, appCode string, instanceID uint64) (policyInstanceRow, error) { row := s.adminDB.QueryRowContext(ctx, ` SELECT instance_id, app_code, instance_code, template_code, template_version, region_scope, status, effective_from_ms, effective_to_ms FROM admin_policy_instances WHERE app_code = ? AND instance_id = ?`, appCode, instanceID, ) var item policyInstanceRow err := row.Scan(&item.InstanceID, &item.AppCode, &item.InstanceCode, &item.TemplateCode, &item.TemplateVersion, &item.RegionScope, &item.Status, &item.EffectiveFromMS, &item.EffectiveToMS) if errors.Is(err, sql.ErrNoRows) { return policyInstanceRow{}, errors.New("policy instance not found") } return item, err } func (s *Service) resolveRegionScope(ctx context.Context, appCode string, scope string) ([]int64, error) { scope = normalizeRegionScope(scope) if strings.HasPrefix(scope, "region_id:") { value := strings.TrimPrefix(scope, "region_id:") regionID, err := strconv.ParseInt(value, 10, 64) if err != nil || regionID <= 0 { return nil, errors.New("region_scope is invalid") } return []int64{regionID}, nil } rows, err := s.userDB.QueryContext(ctx, ` SELECT region_id FROM regions WHERE app_code = ? AND status = 'active' ORDER BY sort_order ASC, region_id ASC`, appCode) if err != nil { return nil, err } defer rows.Close() regionIDs := []int64{0} for rows.Next() { var regionID int64 if err := rows.Scan(®ionID); err != nil { return nil, err } if regionID > 0 { regionIDs = append(regionIDs, regionID) } } if err := rows.Err(); err != nil { return nil, err } return regionIDs, nil } func (s *Service) createPublishJob(ctx context.Context, item policyInstanceRow, actorID uint, targetCount int, nowMS int64) (uint64, error) { result, err := s.adminDB.ExecContext(ctx, ` INSERT INTO admin_policy_publish_jobs ( instance_id, app_code, instance_code, template_code, template_version, status, target_count, operator_admin_id, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?)`, item.InstanceID, item.AppCode, item.InstanceCode, item.TemplateCode, item.TemplateVersion, targetCount, actorID, nowMS, nowMS, ) if err != nil { return 0, err } id, err := result.LastInsertId() return uint64(id), err } func (s *Service) publishWalletRuntime(ctx context.Context, item policyInstanceRow, compiled compiledPolicy, regionIDs []int64, actorID uint, nowMS int64) error { if err := ensureWalletPolicyRuntimeTable(ctx, s.walletDB); err != nil { return err } tx, err := s.walletDB.BeginTx(ctx, nil) if err != nil { return err } defer func() { _ = tx.Rollback() }() if _, err := tx.ExecContext(ctx, `DELETE FROM wallet_policy_instances WHERE app_code = ? AND instance_code = ?`, item.AppCode, item.InstanceCode); err != nil { return err } for _, regionID := range regionIDs { if _, err := tx.ExecContext(ctx, ` INSERT INTO wallet_policy_instances ( app_code, instance_code, template_code, template_version, region_id, status, effective_from_ms, effective_to_ms, host_point_asset_type, host_point_ratio_ppm, points_per_usd, withdraw_fee_bps, rule_json, published_by_admin_id, published_at_ms, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'POINT', ?, ?, ?, CAST(? AS JSON), ?, ?, ?, ?)`, item.AppCode, item.InstanceCode, item.TemplateCode, item.TemplateVersion, regionID, item.Status, item.EffectiveFromMS, item.EffectiveToMS, compiled.HostPointRatioPPM, compiled.PointsPerUSD, compiled.WithdrawFeeBPS, string(compiled.RuleJSON), actorID, nowMS, nowMS, nowMS, ); err != nil { return err } } return tx.Commit() } func (s *Service) syncWalletRuntimeStatus(ctx context.Context, item policyInstanceRow, status string, nowMS int64) error { if s.walletDB == nil { return nil } if err := ensureWalletPolicyRuntimeTable(ctx, s.walletDB); err != nil { return err } // 运行表是 owner service 的最终读取面;后台停用实例必须同步停用旧快照,不能只改 admin 状态。 _, err := s.walletDB.ExecContext(ctx, ` UPDATE wallet_policy_instances SET status = ?, updated_at_ms = ? WHERE app_code = ? AND instance_code = ?`, status, nowMS, item.AppCode, item.InstanceCode, ) return err } func (s *Service) publishActivityRuntime(ctx context.Context, item policyInstanceRow, compiled compiledPolicy, actorID uint, nowMS int64) error { if s.activity == nil { return nil } // activity 目前只需要 App 级任务奖励默认资产;具体任务定义仍由 activity-service 自己持久化和快照。 _, err := s.activity.PublishTaskRewardPolicy(ctx, &activityv1.PublishTaskRewardPolicyRequest{ Meta: &activityv1.RequestMeta{ Caller: "admin-server", AppCode: item.AppCode, SentAtMs: nowMS, }, InstanceCode: item.InstanceCode, TemplateCode: item.TemplateCode, TemplateVersion: item.TemplateVersion, Status: item.Status, RewardAssetType: compiled.TaskRewardAssetType, RuleJson: string(compiled.RuleJSON), OperatorAdminId: int64(actorID), PublishedAtMs: nowMS, }) return err } func ensureWalletPolicyRuntimeTable(ctx context.Context, db *sql.DB) error { _, err := db.ExecContext(ctx, ` CREATE TABLE IF NOT EXISTS wallet_policy_instances ( app_code VARCHAR(32) NOT NULL COMMENT '应用编码', instance_code VARCHAR(96) NOT NULL COMMENT '后台策略实例编码', template_code VARCHAR(96) NOT NULL COMMENT '模板编码快照', template_version VARCHAR(64) NOT NULL COMMENT '模板版本快照', region_id BIGINT NOT NULL DEFAULT 0 COMMENT '适用地区,0 表示 App 兜底', status VARCHAR(24) NOT NULL DEFAULT 'active' COMMENT 'active/disabled', effective_from_ms BIGINT NOT NULL DEFAULT 0 COMMENT 'UTC epoch ms', effective_to_ms BIGINT NOT NULL DEFAULT 0 COMMENT '0 表示长期有效', host_point_asset_type VARCHAR(32) NOT NULL DEFAULT 'POINT' COMMENT '主播收益积分资产', host_point_ratio_ppm BIGINT NOT NULL DEFAULT 700000 COMMENT '有效付费礼物转 POINT 比例,ppm', points_per_usd BIGINT NOT NULL DEFAULT 100000 COMMENT 'POINT/USD 展示换算比例', withdraw_fee_bps INT NOT NULL DEFAULT 500 COMMENT '提现手续费 bps', rule_json JSON NOT NULL COMMENT '完整政策快照', published_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0, published_at_ms BIGINT NOT NULL DEFAULT 0, created_at_ms BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL, PRIMARY KEY (app_code, instance_code, region_id), KEY idx_wallet_policy_active (app_code, region_id, status, effective_from_ms, effective_to_ms) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='钱包运行侧收益政策实例表'`) return err } func (s *Service) insertPublishItems(ctx context.Context, jobID uint64, appCode string, regionIDs []int64, activitySucceeded bool, nowMS int64) error { tx, err := s.adminDB.BeginTx(ctx, nil) if err != nil { return err } defer func() { _ = tx.Rollback() }() for _, regionID := range regionIDs { if _, err := tx.ExecContext(ctx, ` INSERT INTO admin_policy_publish_items ( job_id, owner_service, app_code, region_id, status, created_at_ms, updated_at_ms ) VALUES (?, 'wallet', ?, ?, 'succeeded', ?, ?)`, jobID, appCode, regionID, nowMS, nowMS, ); err != nil { return err } } activityStatus := "failed" if activitySucceeded { activityStatus = "succeeded" } if _, err := tx.ExecContext(ctx, ` INSERT INTO admin_policy_publish_items ( job_id, owner_service, app_code, region_id, status, created_at_ms, updated_at_ms ) VALUES (?, 'activity', ?, 0, ?, ?, ?)`, jobID, appCode, activityStatus, nowMS, nowMS, ); err != nil { return err } return tx.Commit() } func (s *Service) finishPublishJob(ctx context.Context, jobID uint64, status string, targetCount int, successCount int, failureCount int, errMsg string, nowMS int64) error { _, err := s.adminDB.ExecContext(ctx, ` UPDATE admin_policy_publish_jobs SET status = ?, target_count = ?, success_count = ?, failure_count = ?, error_message = ?, updated_at_ms = ? WHERE job_id = ?`, status, targetCount, successCount, failureCount, truncate(errMsg, 512), nowMS, jobID, ) return err } func (s *Service) markInstancePublishFailed(ctx context.Context, appCode string, instanceID uint64, errMsg string, nowMS int64) error { _, err := s.adminDB.ExecContext(ctx, ` UPDATE admin_policy_instances SET publish_status = ?, publish_error = ?, updated_at_ms = ? WHERE app_code = ? AND instance_id = ?`, publishStatusFailed, truncate(errMsg, 512), nowMS, appCode, instanceID, ) return err } func compilePolicy(raw json.RawMessage) (compiledPolicy, error) { var decoded map[string]any if err := json.Unmarshal(raw, &decoded); err != nil { return compiledPolicy{}, errors.New("rule_json is invalid") } pointsPerUSD := int64FromJSON(decoded["points_per_usd"], 100000) host, _ := decoded["host"].(map[string]any) hostRatioPercent := floatFromJSON(host["point_ratio_percent"], 70) withdrawFeeBPS := int64FromJSON(host["withdraw_fee_bps"], 500) ratioPPM := int64(math.Round(hostRatioPercent * 10000)) tasks, _ := decoded["tasks"].(map[string]any) taskRewardAssetType := strings.ToUpper(strings.TrimSpace(stringFromJSON(tasks["reward_asset_type"], policyTaskRewardAssetCoin))) if pointsPerUSD <= 0 || ratioPPM <= 0 || ratioPPM > 1000000 || withdrawFeeBPS < 0 || withdrawFeeBPS > 10000 { return compiledPolicy{}, errors.New("rule_json host point policy is invalid") } if taskRewardAssetType != policyTaskRewardAssetCoin && taskRewardAssetType != "POINT" { return compiledPolicy{}, errors.New("rule_json task reward asset_type is invalid") } return compiledPolicy{ HostPointRatioPPM: ratioPPM, PointsPerUSD: pointsPerUSD, WithdrawFeeBPS: withdrawFeeBPS, TaskRewardAssetType: taskRewardAssetType, RuleJSON: raw, }, nil } func scanTemplate(row interface{ Scan(dest ...any) error }) (templateDTO, error) { var item templateDTO var rule string err := row.Scan(&item.TemplateID, &item.VersionID, &item.TemplateCode, &item.TemplateVersion, &item.Name, &item.Status, &rule, &item.Description, &item.CreatedAtMS, &item.UpdatedAtMS) item.RuleJSON = json.RawMessage(rule) return item, err } func scanInstanceDTO(row interface{ Scan(dest ...any) error }) (instanceDTO, error) { var item instanceDTO err := row.Scan(&item.InstanceID, &item.AppCode, &item.InstanceCode, &item.TemplateCode, &item.TemplateVersion, &item.RegionScope, &item.Status, &item.EffectiveFromMS, &item.EffectiveToMS, &item.PublishStatus, &item.PublishError, &item.LastPublishedAtMS, &item.CreatedAtMS, &item.UpdatedAtMS) return item, err } func templateWhere(query listTemplatesQuery) (string, []any) { conditions := []string{"1 = 1"} args := make([]any, 0) if query.Status != "" { conditions = append(conditions, "v.status = ?") args = append(args, normalizeStatus(query.Status)) } if query.Keyword != "" { conditions = append(conditions, "(t.template_code LIKE ? OR t.name LIKE ?)") like := "%" + query.Keyword + "%" args = append(args, like, like) } return "WHERE " + strings.Join(conditions, " AND "), args } func instanceWhere(appCode string, query listInstancesQuery) (string, []any) { conditions := []string{"app_code = ?"} args := []any{appCode} if query.Status != "" { conditions = append(conditions, "status = ?") args = append(args, normalizeStatus(query.Status)) } if query.Keyword != "" { conditions = append(conditions, "(instance_code LIKE ? OR template_code LIKE ?)") like := "%" + query.Keyword + "%" args = append(args, like, like) } return "WHERE " + strings.Join(conditions, " AND "), args } func normalizePage(page int, pageSize int) (int, int) { if page <= 0 { page = 1 } if pageSize <= 0 { pageSize = 10 } if pageSize > 100 { pageSize = 100 } return page, pageSize } func normalizeCode(value string) string { value = strings.ToLower(strings.TrimSpace(value)) value = strings.ReplaceAll(value, " ", "_") return value } func normalizeVersion(value string) string { return strings.TrimSpace(value) } func normalizeStatus(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case "", "enabled", policyStatusActive: return policyStatusActive case "inactive", policyStatusDisabled: return policyStatusDisabled default: return strings.ToLower(strings.TrimSpace(value)) } } func normalizeRegionScope(value string) string { value = strings.TrimSpace(value) if value == "" { return "all_active_regions" } return value } func validateEffectiveRange(fromMS int64, toMS int64) error { if fromMS < 0 || toMS < 0 { return errors.New("effective time is invalid") } if toMS > 0 && fromMS > 0 && toMS <= fromMS { return errors.New("effective_to_ms must be greater than effective_from_ms") } return nil } func normalizeRuleJSON(raw json.RawMessage) (json.RawMessage, error) { if len(raw) == 0 { return nil, errors.New("rule_json is required") } var decoded map[string]any if err := json.Unmarshal(raw, &decoded); err != nil || decoded == nil { return nil, errors.New("rule_json must be a JSON object") } normalized, err := json.Marshal(decoded) if err != nil { return nil, err } return json.RawMessage(normalized), nil } func int64FromJSON(value any, fallback int64) int64 { switch typed := value.(type) { case float64: if typed > 0 { return int64(typed) } case json.Number: if parsed, err := typed.Int64(); err == nil && parsed > 0 { return parsed } case string: if parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64); err == nil && parsed > 0 { return parsed } } return fallback } func floatFromJSON(value any, fallback float64) float64 { switch typed := value.(type) { case float64: if typed > 0 { return typed } case json.Number: if parsed, err := typed.Float64(); err == nil && parsed > 0 { return parsed } case string: if parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64); err == nil && parsed > 0 { return parsed } } return fallback } func stringFromJSON(value any, fallback string) string { switch typed := value.(type) { case string: if strings.TrimSpace(typed) != "" { return typed } case json.Number: if strings.TrimSpace(typed.String()) != "" { return typed.String() } } return fallback } func truncate(value string, limit int) string { value = strings.TrimSpace(value) if len(value) <= limit { return value } return value[:limit] } func init() { _ = defaultPolicyTemplateCode _ = defaultPolicyTemplateVersion }