353 lines
12 KiB
Go
353 lines
12 KiB
Go
package mysql
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
_ "github.com/go-sql-driver/mysql"
|
||
"hyapp/pkg/appcode"
|
||
"hyapp/pkg/xerr"
|
||
activitydomain "hyapp/services/activity-service/internal/domain/activity"
|
||
)
|
||
|
||
// Repository 是 activity-service 的 MySQL 存储入口。
|
||
type Repository struct {
|
||
db *sql.DB
|
||
}
|
||
|
||
// Open 创建 MySQL 连接池并做启动 ping。
|
||
func Open(ctx context.Context, dsn string) (*Repository, error) {
|
||
if strings.TrimSpace(dsn) == "" {
|
||
return nil, xerr.New(xerr.InvalidArgument, "mysql_dsn is required")
|
||
}
|
||
|
||
db, err := sql.Open("mysql", dsn)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 标准库默认不限制连接数,高并发下会打爆 MySQL max_connections,必须显式设上限。
|
||
db.SetMaxOpenConns(20)
|
||
db.SetMaxIdleConns(10)
|
||
db.SetConnMaxLifetime(30 * time.Minute)
|
||
|
||
if err := db.PingContext(ctx); err != nil {
|
||
_ = db.Close()
|
||
return nil, err
|
||
}
|
||
|
||
return &Repository{db: db}, nil
|
||
}
|
||
|
||
// Migrate performs narrow, idempotent local schema fixes for activity-service.
|
||
func (r *Repository) Migrate(ctx context.Context) error {
|
||
if r == nil || r.db == nil {
|
||
return xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||
}
|
||
if err := r.ensureActivityOutboxDeliveryColumns(ctx); err != nil {
|
||
return err
|
||
}
|
||
if err := r.ensureActivityOutboxDeliveryIndexes(ctx); err != nil {
|
||
return err
|
||
}
|
||
if err := r.ensureBroadcastOutboxIndexes(ctx); err != nil {
|
||
return err
|
||
}
|
||
if err := r.ensureWeeklyStarTables(ctx); err != nil {
|
||
return err
|
||
}
|
||
if err := r.ensureAgencyOpeningTables(ctx); err != nil {
|
||
return err
|
||
}
|
||
if err := r.ensureWheelTables(ctx); err != nil {
|
||
return err
|
||
}
|
||
if err := r.ensureRoomTurnoverRewardTables(ctx); err != nil {
|
||
return err
|
||
}
|
||
if err := r.ensureCumulativeRechargeRewardTables(ctx); err != nil {
|
||
return err
|
||
}
|
||
if err := r.ensureInviteActivityRewardTables(ctx); err != nil {
|
||
return err
|
||
}
|
||
if err := r.ensureCPWeeklyRankTables(ctx); err != nil {
|
||
return err
|
||
}
|
||
if err := r.ensureTaskDefinitionMetadataColumns(ctx); err != nil {
|
||
return err
|
||
}
|
||
if err := r.ensureTaskRewardPolicyTables(ctx); err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (r *Repository) ensureTaskRewardPolicyTables(ctx context.Context) error {
|
||
_, err := r.db.ExecContext(ctx, `
|
||
CREATE TABLE IF NOT EXISTS task_reward_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 '模板版本快照',
|
||
status VARCHAR(24) NOT NULL DEFAULT 'active' COMMENT 'active/disabled',
|
||
reward_asset_type VARCHAR(32) NOT NULL DEFAULT 'COIN' COMMENT '任务默认奖励资产:COIN/POINT',
|
||
rule_json JSON NOT NULL COMMENT '完整策略快照',
|
||
published_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '发布管理员 ID',
|
||
published_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '发布时间,UTC epoch ms',
|
||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||
PRIMARY KEY (app_code, instance_code),
|
||
KEY idx_task_reward_policy_active (app_code, status, published_at_ms)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务奖励运行侧策略实例表'`)
|
||
return err
|
||
}
|
||
|
||
func (r *Repository) ensureTaskDefinitionMetadataColumns(ctx context.Context) error {
|
||
// 本地和测试环境会走自动迁移,逐列检查避免旧库重复启动时报 duplicate column;线上仍可用 deploy/mysql/migrations 的显式 SQL。
|
||
additions := []struct {
|
||
column string
|
||
sql string
|
||
}{
|
||
{"audience_type", `ALTER TABLE task_definitions ADD COLUMN audience_type VARCHAR(32) NOT NULL DEFAULT 'all' COMMENT '适用人群:all/newbie' AFTER description`},
|
||
{"icon_key", `ALTER TABLE task_definitions ADD COLUMN icon_key VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'App 任务图标键' AFTER audience_type`},
|
||
{"icon_url", `ALTER TABLE task_definitions ADD COLUMN icon_url VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'App 任务图标 URL' AFTER icon_key`},
|
||
{"action_type", `ALTER TABLE task_definitions ADD COLUMN action_type VARCHAR(64) NOT NULL DEFAULT 'none' COMMENT 'App 跳转类型' AFTER icon_url`},
|
||
{"action_param", `ALTER TABLE task_definitions ADD COLUMN action_param VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'App 跳转参数' AFTER action_type`},
|
||
{"action_payload_json", `ALTER TABLE task_definitions ADD COLUMN action_payload_json JSON NULL COMMENT 'App 跳转扩展 JSON' AFTER action_param`},
|
||
{"dimension_filter_json", `ALTER TABLE task_definitions ADD COLUMN dimension_filter_json JSON NULL COMMENT '任务事件维度过滤 JSON' AFTER action_payload_json`},
|
||
{"reward_asset_type", `ALTER TABLE task_definitions ADD COLUMN reward_asset_type VARCHAR(32) NOT NULL DEFAULT 'COIN' COMMENT '奖励资产类型:COIN/POINT' AFTER reward_coin_amount`},
|
||
}
|
||
for _, addition := range additions {
|
||
exists, err := r.columnExists(ctx, "task_definitions", addition.column)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !exists {
|
||
// 任务定义新增的是展示、跳转和事件过滤元数据,默认值保证旧任务无需人工补字段即可继续查询和领取。
|
||
if _, err := r.db.ExecContext(ctx, addition.sql); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
progressAdditions := []struct {
|
||
column string
|
||
sql string
|
||
}{
|
||
{"reward_asset_type", `ALTER TABLE user_task_progress ADD COLUMN reward_asset_type VARCHAR(32) NOT NULL DEFAULT 'COIN' COMMENT '奖励资产类型快照:COIN/POINT' AFTER reward_coin_amount`},
|
||
}
|
||
for _, addition := range progressAdditions {
|
||
exists, err := r.columnExists(ctx, "user_task_progress", addition.column)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !exists {
|
||
if _, err := r.db.ExecContext(ctx, addition.sql); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
claimAdditions := []struct {
|
||
column string
|
||
sql string
|
||
}{
|
||
{"reward_asset_type", `ALTER TABLE task_reward_claims ADD COLUMN reward_asset_type VARCHAR(32) NOT NULL DEFAULT 'COIN' COMMENT '奖励资产类型快照:COIN/POINT' AFTER reward_coin_amount`},
|
||
}
|
||
for _, addition := range claimAdditions {
|
||
exists, err := r.columnExists(ctx, "task_reward_claims", addition.column)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !exists {
|
||
if _, err := r.db.ExecContext(ctx, addition.sql); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
eventAdditions := []struct {
|
||
column string
|
||
sql string
|
||
}{
|
||
{"metric_type", `ALTER TABLE task_event_consumption ADD COLUMN metric_type VARCHAR(64) NOT NULL DEFAULT '' COMMENT '任务指标类型' AFTER status`},
|
||
{"value_delta", `ALTER TABLE task_event_consumption ADD COLUMN value_delta BIGINT NOT NULL DEFAULT 0 COMMENT '本次事件进度增量' AFTER metric_type`},
|
||
{"dimensions_json", `ALTER TABLE task_event_consumption ADD COLUMN dimensions_json JSON NULL COMMENT '事件维度快照 JSON' AFTER value_delta`},
|
||
}
|
||
for _, addition := range eventAdditions {
|
||
exists, err := r.columnExists(ctx, "task_event_consumption", addition.column)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !exists {
|
||
// 事件消费表补充 metric/value/dimensions 快照,主要用于排查“事件到了但任务没涨”的过滤原因。
|
||
if _, err := r.db.ExecContext(ctx, addition.sql); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Close 释放 MySQL 连接池。
|
||
func (r *Repository) Close() error {
|
||
if r == nil || r.db == nil {
|
||
return nil
|
||
}
|
||
|
||
return r.db.Close()
|
||
}
|
||
|
||
// Ping 验证 MySQL 当前可用。
|
||
func (r *Repository) Ping(ctx context.Context) error {
|
||
if r == nil || r.db == nil {
|
||
return xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||
}
|
||
|
||
return r.db.PingContext(ctx)
|
||
}
|
||
|
||
func (r *Repository) ensureActivityOutboxDeliveryColumns(ctx context.Context) error {
|
||
additions := []struct {
|
||
name string
|
||
sql string
|
||
}{
|
||
{"locked_by", `ALTER TABLE activity_outbox ADD COLUMN locked_by VARCHAR(128) NOT NULL DEFAULT '' COMMENT '当前锁定 worker' AFTER next_retry_at_ms`},
|
||
{"lock_until_ms", `ALTER TABLE activity_outbox ADD COLUMN lock_until_ms BIGINT NOT NULL DEFAULT 0 COMMENT '锁过期时间,UTC epoch ms' AFTER locked_by`},
|
||
{"last_error", `ALTER TABLE activity_outbox ADD COLUMN last_error VARCHAR(512) NOT NULL DEFAULT '' COMMENT '最后一次失败原因' AFTER lock_until_ms`},
|
||
}
|
||
for _, addition := range additions {
|
||
exists, err := r.columnExists(ctx, "activity_outbox", addition.name)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !exists {
|
||
if _, err := r.db.ExecContext(ctx, addition.sql); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (r *Repository) ensureActivityOutboxDeliveryIndexes(ctx context.Context) error {
|
||
// activity_outbox 仍服务 activity-service 自己的异步事件;索引按 event_type 收敛,避免不同活动补偿 worker 互相扫全表。
|
||
additions := []struct {
|
||
name string
|
||
sql string
|
||
}{
|
||
{"idx_activity_outbox_event_retry", `CREATE INDEX idx_activity_outbox_event_retry ON activity_outbox (app_code, event_type, status, next_retry_at_ms, created_at_ms, outbox_id)`},
|
||
{"idx_activity_outbox_event_lock", `CREATE INDEX idx_activity_outbox_event_lock ON activity_outbox (app_code, event_type, status, lock_until_ms, created_at_ms, outbox_id)`},
|
||
{"idx_activity_outbox_retention", `CREATE INDEX idx_activity_outbox_retention ON activity_outbox (app_code, status, updated_at_ms, outbox_id)`},
|
||
}
|
||
for _, addition := range additions {
|
||
exists, err := r.indexExists(ctx, "activity_outbox", addition.name)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !exists {
|
||
if _, err := r.db.ExecContext(ctx, addition.sql); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (r *Repository) ensureBroadcastOutboxIndexes(ctx context.Context) error {
|
||
additions := []struct {
|
||
name string
|
||
statement string
|
||
}{
|
||
{"idx_im_broadcast_outbox_retention", `CREATE INDEX idx_im_broadcast_outbox_retention ON im_broadcast_outbox (app_code, status, updated_at_ms, event_id)`},
|
||
}
|
||
for _, addition := range additions {
|
||
exists, err := r.indexExists(ctx, "im_broadcast_outbox", addition.name)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !exists {
|
||
if _, err := r.db.ExecContext(ctx, addition.statement); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (r *Repository) columnExists(ctx context.Context, table string, column string) (bool, error) {
|
||
database, err := r.currentDatabase(ctx)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
var count int
|
||
if err := r.db.QueryRowContext(ctx, `
|
||
SELECT COUNT(*)
|
||
FROM information_schema.COLUMNS
|
||
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ?`,
|
||
database, table, column,
|
||
).Scan(&count); err != nil {
|
||
return false, err
|
||
}
|
||
return count > 0, nil
|
||
}
|
||
|
||
func (r *Repository) indexExists(ctx context.Context, table string, index string) (bool, error) {
|
||
database, err := r.currentDatabase(ctx)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
var count int
|
||
if err := r.db.QueryRowContext(ctx, `
|
||
SELECT COUNT(*)
|
||
FROM information_schema.STATISTICS
|
||
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND INDEX_NAME = ?`,
|
||
database, table, index,
|
||
).Scan(&count); err != nil {
|
||
return false, err
|
||
}
|
||
return count > 0, nil
|
||
}
|
||
|
||
func (r *Repository) currentDatabase(ctx context.Context) (string, error) {
|
||
var database sql.NullString
|
||
if err := r.db.QueryRowContext(ctx, `SELECT DATABASE()`).Scan(&database); err != nil {
|
||
return "", err
|
||
}
|
||
if !database.Valid || strings.TrimSpace(database.String) == "" {
|
||
return "", fmt.Errorf("mysql database is not selected")
|
||
}
|
||
return database.String, nil
|
||
}
|
||
|
||
// GetActivity 从 MySQL 活动配置表读取同步查询投影。
|
||
func (r *Repository) GetActivity(ctx context.Context, activityID string) (activitydomain.Activity, error) {
|
||
if r == nil || r.db == nil {
|
||
return activitydomain.Activity{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||
}
|
||
|
||
row := r.db.QueryRowContext(ctx,
|
||
`SELECT app_code, activity_id, status, updated_at_ms
|
||
FROM activities
|
||
WHERE app_code = ? AND activity_id = ?`,
|
||
appcode.FromContext(ctx),
|
||
activityID,
|
||
)
|
||
|
||
var activity activitydomain.Activity
|
||
var status string
|
||
if err := row.Scan(&activity.AppCode, &activity.ActivityID, &status, &activity.UpdatedAtMs); err != nil {
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
// 未配置活动时返回 NotFound,上层可决定是否降级为 inactive。
|
||
return activitydomain.Activity{}, xerr.New(xerr.NotFound, "activity not found")
|
||
}
|
||
|
||
return activitydomain.Activity{}, err
|
||
}
|
||
activity.Status = activitydomain.Status(status)
|
||
|
||
return activity, nil
|
||
}
|