353 lines
12 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 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
}