830 lines
29 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"
"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(&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,
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
}