2026-07-22 13:55:55 +08:00

760 lines
28 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package policyconfig
import (
"context"
"database/sql"
"encoding/json"
"errors"
"strconv"
"strings"
"time"
mysqlDriver "github.com/go-sql-driver/mysql"
"hyapp-admin-server/internal/appctx"
)
const (
policyStatusActive = "active"
policyStatusDisabled = "disabled"
publishStatusDraft = "draft"
publishStatusPublished = "published"
publishStatusFailed = "failed"
)
type Service struct {
adminDB *sql.DB
walletDB *sql.DB
userDB *sql.DB
}
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 {
PointsPerUSD int64
MinimumPoints int64
WithdrawFeeBPS int64
RuleJSON json.RawMessage
}
func NewService(adminDB *sql.DB, walletDB *sql.DB, userDB *sql.DB) *Service {
return &Service{adminDB: adminDB, walletDB: walletDB, userDB: userDB}
}
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
}
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
}
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 {
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()
// 每日任务资产由 task definition 显式保存;通用政策只发布 wallet 目标,不能恢复 App 级任务默认值。
targetCount := len(regionIDs)
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.insertPublishItems(ctx, jobID, instance.AppCode, regionIDs, 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("POINT wallet policy 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 POINT wallet policy 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(&regionID); 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, agency_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, int64(0), int64(0),
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 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 0 COMMENT '兼容既有表结构;新礼物不读取',
agency_point_ratio_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '兼容既有表结构;新礼物不读取',
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='钱包运行侧政策实例表'`)
if err != nil {
return err
}
if _, err := db.ExecContext(ctx, `ALTER TABLE wallet_policy_instances ADD COLUMN agency_point_ratio_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '兼容既有表结构;新礼物不读取' AFTER host_point_ratio_ppm`); err != nil && !isDuplicateColumnError(err) {
return err
}
return nil
}
func isDuplicateColumnError(err error) bool {
var mysqlErr *mysqlDriver.MySQLError
return errors.As(err, &mysqlErr) && mysqlErr.Number == 1060
}
func (s *Service) insertPublishItems(ctx context.Context, jobID uint64, appCode string, regionIDs []int64, 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
}
}
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) {
normalized, err := normalizeRuleJSON(raw)
if err != nil {
return compiledPolicy{}, errors.New("rule_json is invalid")
}
var decoded map[string]any
if err := json.Unmarshal(normalized, &decoded); err != nil {
return compiledPolicy{}, errors.New("rule_json is invalid")
}
pointsPerUSD, err := requiredInt64FromJSON(decoded, "points_per_usd")
if err != nil || pointsPerUSD <= 0 {
return compiledPolicy{}, errors.New("rule_json points_per_usd must be a positive integer")
}
host, _ := decoded["host"].(map[string]any)
minimumPoints, err := requiredInt64FromJSON(host, "minimum_withdraw_points")
if err != nil || minimumPoints <= 0 {
return compiledPolicy{}, errors.New("rule_json host minimum_withdraw_points must be a positive integer")
}
withdrawFeeBPS, err := requiredInt64FromJSON(host, "withdraw_fee_bps")
if err != nil || withdrawFeeBPS < 0 || withdrawFeeBPS > 10_000 {
return compiledPolicy{}, errors.New("rule_json host withdraw_fee_bps must be an integer between 0 and 10000")
}
return compiledPolicy{
PointsPerUSD: pointsPerUSD,
MinimumPoints: minimumPoints,
WithdrawFeeBPS: withdrawFeeBPS,
RuleJSON: normalized,
}, nil
}
func scanTemplate(row interface{ Scan(dest ...any) error }) (templateDTO, error) {
var item templateDTO
var rule string
if err := row.Scan(&item.TemplateID, &item.VersionID, &item.TemplateCode, &item.TemplateVersion, &item.Name, &item.Status, &rule, &item.Description, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil {
return templateDTO{}, err
}
normalized, err := normalizeRuleJSON(json.RawMessage(rule))
if err != nil {
return templateDTO{}, err
}
item.RuleJSON = normalized
return item, nil
}
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")
}
// wallet owner 当前只消费 POINT/USD、最低提现 POINT 和提现手续费。保存、列表回显和发布都使用
// 同一白名单,确保旧模板或任意扩展 JSON 不能重新制造“后台保存成功但业务不生效”的幽灵配置。
normalizedRule := make(map[string]any, 2)
if pointsPerUSD, exists := decoded["points_per_usd"]; exists {
normalizedRule["points_per_usd"] = pointsPerUSD
}
if sourceHost, ok := decoded["host"].(map[string]any); ok {
host := make(map[string]any, 2)
if minimumPoints, exists := sourceHost["minimum_withdraw_points"]; exists {
host["minimum_withdraw_points"] = minimumPoints
}
if withdrawFeeBPS, exists := sourceHost["withdraw_fee_bps"]; exists {
host["withdraw_fee_bps"] = withdrawFeeBPS
}
if len(host) > 0 {
normalizedRule["host"] = host
}
}
normalized, err := json.Marshal(normalizedRule)
if err != nil {
return nil, err
}
return json.RawMessage(normalized), nil
}
func requiredInt64FromJSON(object map[string]any, key string) (int64, error) {
value, exists := object[key]
if !exists {
return 0, errors.New("required JSON value is missing")
}
switch typed := value.(type) {
case float64:
parsed := int64(typed)
if typed != float64(parsed) {
return 0, errors.New("JSON value is not an integer")
}
return parsed, nil
case json.Number:
return typed.Int64()
case string:
return strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
default:
return 0, errors.New("JSON value is not an integer")
}
}
func truncate(value string, limit int) string {
value = strings.TrimSpace(value)
if len(value) <= limit {
return value
}
return value[:limit]
}