fix(backend): financial correctness — settlement/hours/mint idempotency (batch 1)
- settlement(1.1): exhibition_revenue_records 加 UNIQUE(exhibition_id,cycle_start_time) + CreateRevenueRecord ON CONFLICT DO NOTHING; MQ 用 settled_at 替代复用 is_processed; 恢复扫描器过滤改 settled_at IS NULL; created_at 统一毫秒; 删死代码 cleanup_worker.go。 - hours(1.2): 新增 exhibition_hours_log/asset_exhibition_hours_log(source_id 唯一)幂等表; fan_profile/assetLevel 的 AddExhibitionHours 按 sourceID 幂等(事务包裹); 存量重算脚本。 - mint(1.4/1.5/1.6): crystal_transaction_records (source_id,change_type) 部分唯一索引 + UpdateCrystalBalance/CreateMintOrder 幂等; 保底改 crypto/rand; 下线伪 tx_hash; doMint Redis Lua 原子限流。 - migrations 001/002/003; 各服务单测(自包含, 缺 DB t.Skip)。 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
d026102a91
commit
878bd46399
@ -35,6 +35,8 @@ github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13 h1:Q00FU3H94Ts0ZIHDmY+fY
|
||||
github.com/alibabacloud-go/tea v1.2.1/go.mod h1:qbzof29bM/IFhLMtJPrgTGK3eauV5J2wSyEUo4OEmnA=
|
||||
github.com/alibabacloud-go/tea-utils/v2 v2.0.8/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I=
|
||||
github.com/alibabacloud-go/tea-xml v1.1.3/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8=
|
||||
github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw=
|
||||
github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/antihax/optional v1.0.0 h1:xK2lYat7ZLaVVcIuj82J8kIro4V6kDe0AUDFboUCwcg=
|
||||
github.com/apache/thrift v0.13.0 h1:5hryIiq9gtn+MiLVn0wP37kb/uTeRZgN08WoCsAhIhI=
|
||||
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e h1:QEF07wC0T1rKkctt1RINW/+RMTVmiwxETico2l3gxJA=
|
||||
@ -306,6 +308,8 @@ github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/X
|
||||
github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc=
|
||||
github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU=
|
||||
github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
|
||||
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
go.opencensus.io v0.23.0 h1:gqCw0LfLxScz8irSi8exQc7fyQ0fKQU/qnC/X8+V/1M=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.38.0 h1:ZoYbqX7OaA/TAikspPl3ozPI6iY6LiIY9I8cUfm+pJs=
|
||||
|
||||
@ -0,0 +1,33 @@
|
||||
-- 2026_07_21_001_exhibition_revenue_idempotent.sql
|
||||
-- 批次1.1+1.3:展品收益结算幂等 + created_at 单位统一
|
||||
-- 执行前请先备份:
|
||||
-- pg_dump -h <host> -U postgres -t exhibition_revenue_records -t exhibitions <db> > backup_1_1.sql
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- (1.3) created_at 秒→毫秒回填(10 位秒 → 13 位毫秒)
|
||||
UPDATE exhibition_revenue_records
|
||||
SET created_at = created_at * 1000
|
||||
WHERE created_at > 0 AND created_at < 100000000000;
|
||||
|
||||
-- (1.1) 历史去重:同一 (exhibition_id, cycle_start_time) 只保留 id 最小的一条
|
||||
DELETE FROM exhibition_revenue_records a
|
||||
USING exhibition_revenue_records b
|
||||
WHERE a.exhibition_id = b.exhibition_id
|
||||
AND a.cycle_start_time = b.cycle_start_time
|
||||
AND a.id > b.id;
|
||||
|
||||
-- (1.1) 唯一约束(幂等基石)
|
||||
ALTER TABLE exhibition_revenue_records
|
||||
ADD CONSTRAINT uk_exhibition_revenue_cycle UNIQUE (exhibition_id, cycle_start_time);
|
||||
|
||||
-- (settled_at) 独立审计列,替代复用 is_processed
|
||||
ALTER TABLE exhibitions ADD COLUMN IF NOT EXISTS settled_at bigint;
|
||||
-- 回填:已被当作 settled 的历史行(is_processed=true)迁移到 settled_at
|
||||
UPDATE exhibitions SET settled_at = COALESCE(updated_at, EXTRACT(EPOCH FROM now())*1000)
|
||||
WHERE is_processed = true AND settled_at IS NULL;
|
||||
|
||||
-- 序列同步(CLAUDE.md 强制)
|
||||
SELECT setval('exhibition_revenue_records_id_seq', (SELECT COALESCE(MAX(id),1) FROM exhibition_revenue_records));
|
||||
|
||||
COMMIT;
|
||||
@ -0,0 +1,37 @@
|
||||
-- 2026_07_21_002_crystal_tx_source_id_unique.sql
|
||||
-- 批次1.4 幂等基石: crystal_transaction_records 同 (source_id, change_type) 只允许一条
|
||||
--
|
||||
-- 执行前请先备份:
|
||||
-- pg_dump -h <host> -U postgres -t crystal_transaction_records <db> > backup_crystal_tx.sql
|
||||
-- 或使用 psql:
|
||||
-- psql -h <host> -U postgres -d <db> -c "\COPY crystal_transaction_records TO 'crystal_tx.csv' CSV HEADER"
|
||||
--
|
||||
-- Dry-run (本地 top-fans 实测):
|
||||
-- dup_to_blank = 37
|
||||
-- level_up_bonus 14, task_reward 13, like_bet_revenue 10
|
||||
-- 含义: 这 37 条 source_id 非空行是历史脏数据(同 source_id+change_type 已存在更早一条),
|
||||
-- 本 migration 把它们 source_id 置空,保留更早的 min(id) 行。
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- (a) 历史去重: 同一 (source_id, change_type) 保留 id 最小的一条(其他 source_id 置空,不入新流水)
|
||||
UPDATE crystal_transaction_records a
|
||||
SET source_id = ''
|
||||
FROM crystal_transaction_records b
|
||||
WHERE a.source_id = b.source_id
|
||||
AND a.change_type = b.change_type
|
||||
AND a.source_id <> ''
|
||||
AND a.id > b.id;
|
||||
|
||||
-- (b) 部分唯一索引(source_id 非空时强制唯一)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_crystal_tx_source_change
|
||||
ON crystal_transaction_records (source_id, change_type)
|
||||
WHERE source_id <> '';
|
||||
|
||||
-- (c) 序列同步(本表 BIGSERIAL,手动删改后必须 setval)
|
||||
SELECT setval(
|
||||
pg_get_serial_sequence('crystal_transaction_records', 'id'),
|
||||
(SELECT COALESCE(MAX(id), 1) FROM crystal_transaction_records)
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
@ -0,0 +1,45 @@
|
||||
-- 2026_07_21_003_exhibition_hours_idempotent.sql
|
||||
-- 批次1.2:累计时长幂等(用户级 + 资产级)
|
||||
-- 执行前请先备份:
|
||||
-- pg_dump -h <host> -U postgres -t exhibition_hours_log -t asset_exhibition_hours_log <db> > backup_1_2.sql
|
||||
-- 触发场景:fan_profile_repository.AddExhibitionHours/assetLevelService.AddExhibitionHours
|
||||
-- 重复调用会造成 total_exhibition_hours / season_exhibition_hours 多次叠加
|
||||
-- 设计:每条幂等键由 source_id 唯一约束保证,INSERT ON CONFLICT DO NOTHING 即跳过
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- (1) 用户级幂等 log
|
||||
CREATE TABLE IF NOT EXISTS exhibition_hours_log (
|
||||
id BIGINT PRIMARY KEY,
|
||||
source_id VARCHAR(100) NOT NULL UNIQUE,
|
||||
user_id BIGINT NOT NULL,
|
||||
star_id BIGINT NOT NULL,
|
||||
hours BIGINT NOT NULL,
|
||||
created_at BIGINT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_exh_log_user_star
|
||||
ON exhibition_hours_log (user_id, star_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_exh_log_created
|
||||
ON exhibition_hours_log (created_at);
|
||||
|
||||
-- (2) 资产级幂等 log
|
||||
CREATE TABLE IF NOT EXISTS asset_exhibition_hours_log (
|
||||
id BIGINT PRIMARY KEY,
|
||||
source_id VARCHAR(100) NOT NULL UNIQUE,
|
||||
asset_id BIGINT NOT NULL,
|
||||
hours INT NOT NULL,
|
||||
created_at BIGINT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_asset_exh_log_asset
|
||||
ON asset_exhibition_hours_log (asset_id);
|
||||
|
||||
-- (3) 序列同步(CLAUDE.md 强制;新表无现存行,序列置 1)
|
||||
-- 内部去重 log 表无脚本硬编码其 id,不适用 START 10000 约定;序列从 1 起步。
|
||||
CREATE SEQUENCE IF NOT EXISTS exhibition_hours_log_id_seq OWNED BY exhibition_hours_log.id;
|
||||
ALTER TABLE exhibition_hours_log ALTER COLUMN id SET DEFAULT nextval('exhibition_hours_log_id_seq');
|
||||
SELECT setval('exhibition_hours_log_id_seq', GREATEST((SELECT COALESCE(MAX(id),0) FROM exhibition_hours_log), 1));
|
||||
CREATE SEQUENCE IF NOT EXISTS asset_exhibition_hours_log_id_seq OWNED BY asset_exhibition_hours_log.id;
|
||||
ALTER TABLE asset_exhibition_hours_log ALTER COLUMN id SET DEFAULT nextval('asset_exhibition_hours_log_id_seq');
|
||||
SELECT setval('asset_exhibition_hours_log_id_seq', GREATEST((SELECT COALESCE(MAX(id),0) FROM asset_exhibition_hours_log), 1));
|
||||
|
||||
COMMIT;
|
||||
@ -102,6 +102,17 @@ type SeasonDecayConfig struct {
|
||||
|
||||
func (SeasonDecayConfig) TableName() string { return "season_decay_config" }
|
||||
|
||||
// AssetExhibitionHoursLog 资产级累计时长幂等 log
|
||||
type AssetExhibitionHoursLog struct {
|
||||
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||
SourceID string `gorm:"column:source_id;unique;not null"`
|
||||
AssetID int64 `gorm:"column:asset_id;not null"`
|
||||
Hours int `gorm:"column:hours;not null"`
|
||||
CreatedAt int64 `gorm:"column:created_at;not null"`
|
||||
}
|
||||
|
||||
func (AssetExhibitionHoursLog) TableName() string { return "asset_exhibition_hours_log" }
|
||||
|
||||
// 等级常量
|
||||
const (
|
||||
LevelN = "N"
|
||||
|
||||
13
backend/pkg/models/exhibition_hours_log.go
Normal file
13
backend/pkg/models/exhibition_hours_log.go
Normal file
@ -0,0 +1,13 @@
|
||||
package models
|
||||
|
||||
// ExhibitionHoursLog 用户级累计时长幂等 log
|
||||
type ExhibitionHoursLog struct {
|
||||
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||
SourceID string `gorm:"column:source_id;unique;not null"`
|
||||
UserID int64 `gorm:"column:user_id;not null"`
|
||||
StarID int64 `gorm:"column:star_id;not null"`
|
||||
Hours int64 `gorm:"column:hours;not null"`
|
||||
CreatedAt int64 `gorm:"column:created_at;not null"`
|
||||
}
|
||||
|
||||
func (ExhibitionHoursLog) TableName() string { return "exhibition_hours_log" }
|
||||
173
backend/scripts/recalc_exhibition_hours.sql
Normal file
173
backend/scripts/recalc_exhibition_hours.sql
Normal file
@ -0,0 +1,173 @@
|
||||
-- recalc_exhibition_hours.sql
|
||||
-- 批次1.2 存量校正(DRY-RUN): 输出"按 distinct exhibition 重算"前后的差异
|
||||
-- ⚠️ 本文件仅 SELECT,禁止任何 DDL/DML。
|
||||
--
|
||||
-- 列名适配说明:
|
||||
-- * exhibitions.slot_owner_uid -> 实际为 host_profile_id
|
||||
-- * 收益归属(revenue_service.go L356): user_id=SlotOwnerUID(=host_profile_id), star_id=OccupierStarID
|
||||
-- * level_up_bonus 触发: AddExhibitionHours(userID=host_profile_id, starID=occupier_star_id)
|
||||
|
||||
\echo '==== A. 用户级:当前 vs 重算 (差值 > 0 即虚高) ===='
|
||||
WITH per_user_star AS (
|
||||
SELECT e.host_profile_id AS user_id,
|
||||
e.occupier_star_id AS star_id,
|
||||
SUM((e.expire_at - e.start_time) / 3600000)::bigint AS recalc_hours
|
||||
FROM exhibitions e
|
||||
WHERE e.deleted_at IS NULL
|
||||
AND e.host_profile_id IS NOT NULL
|
||||
AND e.occupier_star_id IS NOT NULL
|
||||
GROUP BY e.host_profile_id, e.occupier_star_id
|
||||
),
|
||||
current_ueh AS (
|
||||
SELECT user_id, star_id, total_exhibition_hours AS current_hours
|
||||
FROM user_exhibition_hours
|
||||
)
|
||||
SELECT COALESCE(c.user_id, r.user_id) AS user_id,
|
||||
COALESCE(c.star_id, r.star_id) AS star_id,
|
||||
COALESCE(c.current_hours, 0) AS current_hours,
|
||||
COALESCE(r.recalc_hours, 0) AS recalc_hours,
|
||||
(COALESCE(c.current_hours, 0) - COALESCE(r.recalc_hours, 0)) AS diff_hours
|
||||
FROM current_ueh c
|
||||
FULL OUTER JOIN per_user_star r USING (user_id, star_id)
|
||||
WHERE COALESCE(c.current_hours, 0) <> COALESCE(r.recalc_hours, 0)
|
||||
ORDER BY ABS(COALESCE(c.current_hours, 0) - COALESCE(r.recalc_hours, 0)) DESC
|
||||
LIMIT 50;
|
||||
|
||||
\echo '==== B. 用户级 diff 汇总 (待下修/虚高,无 DML) ===='
|
||||
WITH per_user_star AS (
|
||||
SELECT e.host_profile_id AS user_id,
|
||||
e.occupier_star_id AS star_id,
|
||||
SUM((e.expire_at - e.start_time) / 3600000)::bigint AS recalc_hours
|
||||
FROM exhibitions e
|
||||
WHERE e.deleted_at IS NULL
|
||||
AND e.host_profile_id IS NOT NULL
|
||||
AND e.occupier_star_id IS NOT NULL
|
||||
GROUP BY e.host_profile_id, e.occupier_star_id
|
||||
)
|
||||
SELECT count(*) FILTER (WHERE (COALESCE(c.current_hours, 0) - COALESCE(r.recalc_hours, 0)) > 0) AS user_star_pairs_overstated,
|
||||
count(*) FILTER (WHERE (COALESCE(c.current_hours, 0) - COALESCE(c.current_hours, 0)) > 0) AS placeholder_no_overcount,
|
||||
count(*) FILTER (WHERE (COALESCE(c.current_hours, 0) - COALESCE(r.recalc_hours, 0)) < 0) AS user_star_pairs_understated,
|
||||
count(*) FILTER (WHERE c.user_id IS NULL) AS user_star_pairs_in_exhibition_only,
|
||||
count(*) FILTER (WHERE r.user_id IS NULL) AS user_star_pairs_in_ueh_only,
|
||||
COALESCE(sum(GREATEST(COALESCE(c.current_hours, 0) - COALESCE(r.recalc_hours, 0), 0)), 0) AS total_overstated_hours,
|
||||
COALESCE(sum(GREATEST(COALESCE(r.recalc_hours, 0) - COALESCE(c.current_hours, 0), 0)), 0) AS total_understated_hours
|
||||
FROM (SELECT user_id, star_id, total_exhibition_hours AS current_hours FROM user_exhibition_hours) c
|
||||
FULL OUTER JOIN per_user_star r USING (user_id, star_id);
|
||||
|
||||
\echo '==== C. 资产级:当前 vs 重算 (差值 > 0 即虚高) ===='
|
||||
WITH per_asset AS (
|
||||
SELECT e.asset_id,
|
||||
SUM((e.expire_at - e.start_time) / 3600000)::bigint AS recalc_hours
|
||||
FROM exhibitions e
|
||||
WHERE e.deleted_at IS NULL AND e.asset_id > 0
|
||||
GROUP BY e.asset_id
|
||||
)
|
||||
SELECT COALESCE(c.asset_id, r.asset_id) AS asset_id,
|
||||
COALESCE(c.current_hours, 0) AS current_hours,
|
||||
COALESCE(r.recalc_hours, 0) AS recalc_hours,
|
||||
(COALESCE(c.current_hours, 0) - COALESCE(r.recalc_hours, 0)) AS diff_hours
|
||||
FROM (
|
||||
SELECT asset_id, season_exhibition_hours AS current_hours
|
||||
FROM asset_level_records WHERE season_exhibition_hours > 0
|
||||
) c
|
||||
FULL OUTER JOIN per_asset r USING (asset_id)
|
||||
WHERE COALESCE(c.current_hours, 0) <> COALESCE(r.recalc_hours, 0)
|
||||
ORDER BY ABS(COALESCE(c.current_hours, 0) - COALESCE(r.recalc_hours, 0)) DESC
|
||||
LIMIT 50;
|
||||
|
||||
\echo '==== D. 资产级 diff 汇总 ===='
|
||||
WITH per_asset AS (
|
||||
SELECT e.asset_id,
|
||||
SUM((e.expire_at - e.start_time) / 3600000)::bigint AS recalc_hours
|
||||
FROM exhibitions e
|
||||
WHERE e.deleted_at IS NULL AND e.asset_id > 0
|
||||
GROUP BY e.asset_id
|
||||
)
|
||||
SELECT count(*) FILTER (WHERE (COALESCE(c.current_hours, 0) - COALESCE(r.recalc_hours, 0)) > 0) AS assets_overstated,
|
||||
count(*) FILTER (WHERE (COALESCE(c.current_hours, 0) - COALESCE(r.recalc_hours, 0)) < 0) AS assets_understated,
|
||||
count(*) FILTER (WHERE c.asset_id IS NULL) AS assets_in_exhibition_only,
|
||||
count(*) FILTER (WHERE r.asset_id IS NULL) AS assets_in_alr_only,
|
||||
COALESCE(sum(GREATEST(COALESCE(c.current_hours, 0) - COALESCE(r.recalc_hours, 0), 0)), 0) AS total_overstated_asset_hours,
|
||||
COALESCE(sum(GREATEST(COALESCE(r.recalc_hours, 0) - COALESCE(c.current_hours, 0), 0)), 0) AS total_understated_asset_hours
|
||||
FROM (SELECT asset_id, season_exhibition_hours AS current_hours FROM asset_level_records WHERE season_exhibition_hours > 0) c
|
||||
FULL OUTER JOIN per_asset r USING (asset_id);
|
||||
|
||||
\echo '==== E. 已发升级奖励水晶总额 (change_type=level_up_bonus, 仅统计) ===='
|
||||
SELECT count(*) AS level_up_bonus_records,
|
||||
COALESCE(sum(delta), 0) AS total_crystal
|
||||
FROM crystal_transaction_records
|
||||
WHERE change_type = 'level_up_bonus';
|
||||
|
||||
\echo '==== F. level_up_bonus 按 source_id 是否形如 exhibition_<id> 拆分 ===='
|
||||
SELECT
|
||||
count(*) FILTER (WHERE source_id ~ '^exhibition_[0-9]+$') AS looks_like_exhibition_source,
|
||||
count(*) FILTER (WHERE source_id IS NULL OR source_id = '') AS no_source_id,
|
||||
count(*) FILTER (WHERE source_id IS NOT NULL AND source_id <> '' AND source_id !~ '^exhibition_[0-9]+$') AS other_source,
|
||||
COALESCE(sum(delta) FILTER (WHERE source_id ~ '^exhibition_[0-9]+$'), 0) AS exhibition_source_crystal,
|
||||
COALESCE(sum(delta) FILTER (WHERE source_id IS NULL OR source_id = ''), 0) AS no_source_crystal
|
||||
FROM crystal_transaction_records
|
||||
WHERE change_type = 'level_up_bonus';
|
||||
|
||||
\echo '==== G. 误升用户数: fan_profiles 当前 level vs 按 recalc_hours 应得 level ===='
|
||||
WITH per_user_star AS (
|
||||
SELECT e.host_profile_id AS user_id,
|
||||
e.occupier_star_id AS star_id,
|
||||
SUM((e.expire_at - e.start_time) / 3600000)::bigint AS recalc_hours
|
||||
FROM exhibitions e
|
||||
WHERE e.deleted_at IS NULL
|
||||
AND e.host_profile_id IS NOT NULL
|
||||
AND e.occupier_star_id IS NOT NULL
|
||||
GROUP BY e.host_profile_id, e.occupier_star_id
|
||||
),
|
||||
expected_level AS (
|
||||
SELECT lt.level AS expected_level, r.user_id, r.star_id, r.recalc_hours
|
||||
FROM per_user_star r
|
||||
JOIN LATERAL (
|
||||
SELECT level FROM level_thresholds
|
||||
WHERE max_exhibition_hours <= r.recalc_hours
|
||||
ORDER BY level DESC LIMIT 1
|
||||
) lt ON true
|
||||
)
|
||||
SELECT count(*) FILTER (WHERE fp.level > el.expected_level) AS profiles_will_downgrade,
|
||||
count(*) FILTER (WHERE fp.level < el.expected_level) AS profiles_will_upgrade,
|
||||
count(*) FILTER (WHERE fp.level = el.expected_level) AS profiles_match,
|
||||
COALESCE(sum(fp.level - el.expected_level) FILTER (WHERE fp.level > el.expected_level), 0) AS total_level_reduction
|
||||
FROM fan_profiles fp
|
||||
JOIN expected_level el
|
||||
ON fp.user_id = el.user_id AND fp.star_id = el.star_id;
|
||||
|
||||
\echo '==== H. 误升用户关联的 level_up_bonus 总额 (若 fan_profile 当前 level > recalc 应得 level) ===='
|
||||
WITH per_user_star AS (
|
||||
SELECT e.host_profile_id AS user_id,
|
||||
e.occupier_star_id AS star_id,
|
||||
SUM((e.expire_at - e.start_time) / 3600000)::bigint AS recalc_hours
|
||||
FROM exhibitions e
|
||||
WHERE e.deleted_at IS NULL
|
||||
AND e.host_profile_id IS NOT NULL
|
||||
AND e.occupier_star_id IS NOT NULL
|
||||
GROUP BY e.host_profile_id, e.occupier_star_id
|
||||
),
|
||||
expected_level AS (
|
||||
SELECT r.user_id, r.star_id, lt.level AS expected_level
|
||||
FROM per_user_star r
|
||||
JOIN LATERAL (
|
||||
SELECT level FROM level_thresholds
|
||||
WHERE max_exhibition_hours <= r.recalc_hours
|
||||
ORDER BY level DESC LIMIT 1
|
||||
) lt ON true
|
||||
),
|
||||
mis_upgraded_users AS (
|
||||
SELECT fp.user_id, fp.star_id
|
||||
FROM fan_profiles fp
|
||||
JOIN expected_level el
|
||||
ON fp.user_id = el.user_id AND fp.star_id = el.star_id
|
||||
WHERE fp.level > el.expected_level
|
||||
)
|
||||
SELECT count(*) AS mis_upgrade_crystal_records,
|
||||
COALESCE(sum(delta), 0) AS mis_upgrade_total_crystal
|
||||
FROM crystal_transaction_records ctr
|
||||
WHERE ctr.change_type = 'level_up_bonus'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM mis_upgraded_users m
|
||||
WHERE m.user_id = ctr.user_id AND m.star_id = ctr.star_id
|
||||
);
|
||||
@ -4,6 +4,7 @@ go 1.25.5
|
||||
|
||||
require (
|
||||
dubbo.apache.org/dubbo-go/v3 v3.3.1
|
||||
github.com/alicebob/miniredis/v2 v2.38.0
|
||||
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible
|
||||
github.com/aliyun/credentials-go v1.4.12
|
||||
github.com/google/uuid v1.6.0
|
||||
@ -129,6 +130,7 @@ require (
|
||||
github.com/uber/jaeger-client-go v2.29.1+incompatible // indirect
|
||||
github.com/uber/jaeger-lib v2.4.1+incompatible // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.2 // indirect
|
||||
go.etcd.io/etcd/api/v3 v3.5.7 // indirect
|
||||
go.etcd.io/etcd/client/pkg/v3 v3.5.7 // indirect
|
||||
|
||||
@ -78,6 +78,8 @@ github.com/alibabacloud-go/tea v1.2.2/go.mod h1:CF3vOzEMAG+bR4WOql8gc2G9H3EkH3ZL
|
||||
github.com/alibabacloud-go/tea v1.3.13 h1:WhGy6LIXaMbBM6VBYcsDCz6K/TPsT1Ri2hPmmZffZ94=
|
||||
github.com/alibabacloud-go/tea-utils v1.4.4 h1:lxCDvNCdTo9FaXKKq45+4vGETQUKNOW/qKTcX9Sk53o=
|
||||
github.com/alibabacloud-go/tea-utils v1.4.4/go.mod h1:KNcT0oXlZZxOXINnZBs6YvgOd5aYp9U67G+E3R8fcQw=
|
||||
github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw=
|
||||
github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/aliyun/alibaba-cloud-sdk-go v1.61.18/go.mod h1:v8ESoHo4SyHmuB4b1tJqDHxfTGEciD+yhvOU/5s1Rfk=
|
||||
github.com/aliyun/alibaba-cloud-sdk-go v1.61.1704/go.mod h1:RcDobYh8k5VP6TNybz9m++gL3ijVI5wueVr0EM10VsU=
|
||||
github.com/aliyun/alibaba-cloud-sdk-go v1.61.1800 h1:ie/8RxBOfKZWcrbYSJi2Z8uX8TcOlSMwPlEJh83OeOw=
|
||||
@ -847,6 +849,8 @@ github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||
github.com/yusufpapurcu/wmi v1.2.2 h1:KBNDSne4vP5mbSWnJbO+51IMOXJB67QiYCSBrubbPRg=
|
||||
github.com/yusufpapurcu/wmi v1.2.2/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
|
||||
@ -59,6 +59,20 @@ func (r *AssetLevelRepository) GetDB() *gorm.DB {
|
||||
return r.db
|
||||
}
|
||||
|
||||
// WithTx 返回绑定到 tx 的 repo,供 service 层在 db.Transaction(...) 闭包内调用,
|
||||
//让 Create/Save/GetByAssetID 等方法走事务(否则默认走 r.db,事务边界不生效)。
|
||||
//
|
||||
// 用法:
|
||||
// err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||
// txRepo := r.WithTx(tx)
|
||||
// _ = txRepo.GetByAssetID(...)
|
||||
// _ = txRepo.Save(...)
|
||||
// return nil
|
||||
// })
|
||||
func (r *AssetLevelRepository) WithTx(tx *gorm.DB) *AssetLevelRepository {
|
||||
return &AssetLevelRepository{db: tx}
|
||||
}
|
||||
|
||||
func (r *AssetLevelRepository) GetChangeLogs(assetID int64, limit, offset int) ([]*models.AssetLevelChangeLog, error) {
|
||||
var logs []*models.AssetLevelChangeLog
|
||||
err := r.db.Where("asset_id = ?", assetID).
|
||||
|
||||
@ -13,6 +13,7 @@ import (
|
||||
"github.com/topfans/backend/services/assetService/repository"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type AssetLevelService interface {
|
||||
@ -20,7 +21,7 @@ type AssetLevelService interface {
|
||||
GetRecordByAssetID(assetID int64) (*models.AssetLevelRecord, error)
|
||||
GetLevelConfig(level string) (*models.AssetLevel, error)
|
||||
GetAllLevels() ([]*models.AssetLevel, error)
|
||||
AddExhibitionHours(assetID int64, hours int) (string, bool, error)
|
||||
AddExhibitionHours(assetID int64, hours int, sourceID string) (string, bool, error)
|
||||
AddLikes(assetID int64, count int) (string, bool, error)
|
||||
RemoveLikes(assetID int64, count int) (string, bool, error)
|
||||
CalculateRevenue(assetID int64, likeCount int, startTime, endTime int64, revenueBoostBps int) (int64, error)
|
||||
@ -146,10 +147,62 @@ func (s *assetLevelService) CheckDowngrade(record *models.AssetLevelRecord) (str
|
||||
return newLevel, newLevel != record.CurrentLevel
|
||||
}
|
||||
|
||||
func (s *assetLevelService) AddExhibitionHours(assetID int64, hours int) (string, bool, error) {
|
||||
record, err := s.GetOrCreateRecord(assetID)
|
||||
func (s *assetLevelService) AddExhibitionHours(assetID int64, hours int, sourceID string) (string, bool, error) {
|
||||
if sourceID == "" {
|
||||
logger.Logger.Warn("AssetLevelService.AddExhibitionHours called with empty sourceID, idempotency degraded",
|
||||
zap.Int64("asset_id", assetID))
|
||||
}
|
||||
|
||||
db := s.levelRepo.GetDB()
|
||||
if db == nil {
|
||||
return "", false, fmt.Errorf("asset level repository db not initialized")
|
||||
}
|
||||
|
||||
var resultLevel string
|
||||
var upgraded bool
|
||||
|
||||
// 整个累积时长流程(log INSERT + 判 RowsAffected + 累加 Save + 升级 changelog)放在同一事务:
|
||||
// - log 写入了但累加崩了 → 一起回滚(避免"log 记了但 hours 没加"孤儿态)
|
||||
// - 重复路径 RowsAffected==0 直接 return,事务早结束
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
txRepo := s.levelRepo.WithTx(tx)
|
||||
|
||||
// 0. 幂等闸口
|
||||
if sourceID != "" {
|
||||
logRow := &models.AssetExhibitionHoursLog{
|
||||
SourceID: sourceID,
|
||||
AssetID: assetID,
|
||||
Hours: hours,
|
||||
CreatedAt: time.Now().UnixMilli(),
|
||||
}
|
||||
res := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "source_id"}},
|
||||
DoNothing: true,
|
||||
}).Create(logRow)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
// 重复 sourceID:回查当前等级后 return,事务提前结束
|
||||
rec, qerr := txRepo.GetByAssetID(assetID)
|
||||
if qerr != nil && qerr != gorm.ErrRecordNotFound {
|
||||
return qerr
|
||||
}
|
||||
logger.Logger.Info("AssetLevelService.AddExhibitionHours: duplicate source_id, skip",
|
||||
zap.Int64("asset_id", assetID),
|
||||
zap.String("source_id", sourceID))
|
||||
if rec != nil {
|
||||
resultLevel = rec.CurrentLevel
|
||||
}
|
||||
upgraded = false
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 获取或创建累计时长记录
|
||||
record, err := getOrCreateRecordTx(tx, assetID)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
return err
|
||||
}
|
||||
|
||||
oldLevel := record.CurrentLevel
|
||||
@ -162,27 +215,114 @@ func (s *assetLevelService) AddExhibitionHours(assetID int64, hours int) (string
|
||||
record.SeasonID = season.ID
|
||||
}
|
||||
|
||||
record.SeasonExhibitionHours += hours
|
||||
record.LifetimeExhibitionHours += hours
|
||||
// 2. 累加时长(原子更新,避免竞态)
|
||||
now := time.Now().UnixMilli()
|
||||
if err := tx.Model(&models.AssetLevelRecord{}).
|
||||
Where("asset_id = ?", assetID).
|
||||
Updates(map[string]any{
|
||||
"season_exhibition_hours": gorm.Expr("season_exhibition_hours + ?", hours),
|
||||
"lifetime_exhibition_hours": gorm.Expr("lifetime_exhibition_hours + ?", hours),
|
||||
"updated_at": now,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newLevel, upgraded := s.CheckUpgrade(record)
|
||||
if upgraded {
|
||||
// 3. 重新读最新 record(供 CheckUpgrade 用最新 hours)
|
||||
updated, err := txRepo.GetByAssetID(assetID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updated.CurrentLevel = record.CurrentLevel
|
||||
updated.SeasonID = record.SeasonID
|
||||
record = updated
|
||||
|
||||
// 4. 计算新等级
|
||||
newLevel, isUpgraded := s.CheckUpgrade(record)
|
||||
if isUpgraded {
|
||||
record.CurrentLevel = newLevel
|
||||
}
|
||||
|
||||
if err := s.levelRepo.Save(record); err != nil {
|
||||
// 5. 落库累计/等级
|
||||
if err := txRepo.Save(record); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 6. 升级 changelog + asset_registry.grade 同步(同事务)
|
||||
if isUpgraded && newLevel != oldLevel {
|
||||
changeLog := &models.AssetLevelChangeLog{
|
||||
AssetID: record.AssetID,
|
||||
FromLevel: oldLevel,
|
||||
ToLevel: newLevel,
|
||||
TriggerType: "exhibition_complete",
|
||||
TriggerHours: record.SeasonExhibitionHours,
|
||||
TriggerLikes: record.SeasonLikes,
|
||||
ChangeReason: fmt.Sprintf("展出完成,时长+%d小时", hours),
|
||||
CreatedAt: now,
|
||||
}
|
||||
if err := txRepo.CreateChangeLog(changeLog); err != nil {
|
||||
return err
|
||||
}
|
||||
grade := models.LevelToGrade(newLevel)
|
||||
if err := tx.Table("public.asset_registry").
|
||||
Where("asset_id = ?", record.AssetID).
|
||||
Update("grade", grade).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
resultLevel = newLevel
|
||||
upgraded = isUpgraded
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
if upgraded && newLevel != oldLevel {
|
||||
s.logLevelChange(record.AssetID, oldLevel, newLevel,
|
||||
"exhibition_complete", record.SeasonExhibitionHours, record.SeasonLikes,
|
||||
fmt.Sprintf("展出完成,时长+%d小时", hours))
|
||||
// 同步等级到AssetRegistry.Grade
|
||||
s.syncGradeToAssetRegistry(record.AssetID, newLevel)
|
||||
// 升级事件埋点(statistic)走事务外:fire-and-forget,不应阻塞核心事务
|
||||
if upgraded && resultLevel != "" {
|
||||
// 升级事件埋点(沿用 logLevelChange 既有行为,但不在事务内)
|
||||
statistic.Get().TrackEvent(context.Background(), &eventPb.Event{
|
||||
EventType: "asset.level_up",
|
||||
UserId: 0,
|
||||
StarId: 0,
|
||||
OccurredAt: time.Now().UnixMilli(),
|
||||
Properties: map[string]string{
|
||||
"asset_id": strconv.FormatInt(assetID, 10),
|
||||
"to": resultLevel,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return newLevel, upgraded, nil
|
||||
return resultLevel, upgraded, nil
|
||||
}
|
||||
|
||||
// getOrCreateRecordTx 事务内获取或创建 asset_level_records 行。
|
||||
// 走 INSERT ... ON CONFLICT(asset_id) DO NOTHING 保证并发安全。
|
||||
func getOrCreateRecordTx(tx *gorm.DB, assetID int64) (*models.AssetLevelRecord, error) {
|
||||
var record models.AssetLevelRecord
|
||||
err := tx.Where("asset_id = ?", assetID).First(&record).Error
|
||||
if err == nil {
|
||||
return &record, nil
|
||||
}
|
||||
if err != gorm.ErrRecordNotFound {
|
||||
return nil, err
|
||||
}
|
||||
// 不存在则插入空记录;并发场景下 ON CONFLICT DO NOTHING 防重复插入
|
||||
newRec := &models.AssetLevelRecord{
|
||||
AssetID: assetID,
|
||||
CurrentLevel: models.LevelN,
|
||||
}
|
||||
if err := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "asset_id"}},
|
||||
DoNothing: true,
|
||||
}).Create(newRec).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 重读一次拿稳定 record(包含数据库默认值)
|
||||
if err := tx.Where("asset_id = ?", assetID).First(&record).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
func (s *assetLevelService) AddLikes(assetID int64, count int) (string, bool, error) {
|
||||
|
||||
@ -1,9 +1,17 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/topfans/backend/pkg/database"
|
||||
"github.com/topfans/backend/pkg/models"
|
||||
"github.com/topfans/backend/services/assetService/repository"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestCalculateBuff 纯函数,无需 DB,保留原有行为。
|
||||
func TestCalculateBuff(t *testing.T) {
|
||||
tests := []struct {
|
||||
likeCount int
|
||||
@ -26,3 +34,144 @@ func TestCalculateBuff(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assetLevelTestDB 资产级幂等测试专用 DB 连接。
|
||||
//
|
||||
// 自包含:
|
||||
// - 通过 TEST_DB_HOST/PORT/USER/PASSWORD/NAME 覆盖,默认 localhost:15432 / postgres / 123456 / top-fans。
|
||||
// - 连不上 t.Skip(不污染全局 TestMain,避免影响其它测试)。
|
||||
// - AutoMigrate asset_level_records / asset_exhibition_hours_log / season / asset_level(幂等所需)。
|
||||
func assetLevelTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
if os.Getenv("SKIP_DB_TESTS") != "" {
|
||||
t.Skip("SKIP_DB_TESTS set")
|
||||
}
|
||||
|
||||
host := getEnvOrDefault("TEST_DB_HOST", "localhost")
|
||||
portStr := getEnvOrDefault("TEST_DB_PORT", "15432")
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
if port == 0 {
|
||||
port = 15432
|
||||
}
|
||||
user := getEnvOrDefault("TEST_DB_USER", "postgres")
|
||||
password := getEnvOrDefault("TEST_DB_PASSWORD", "123456")
|
||||
dbname := getEnvOrDefault("TEST_DB_NAME", "top-fans")
|
||||
|
||||
if err := database.Init(database.Config{
|
||||
Host: host,
|
||||
Port: port,
|
||||
User: user,
|
||||
Password: password,
|
||||
DBName: dbname,
|
||||
SSLMode: "disable",
|
||||
TimeZone: "Asia/Shanghai",
|
||||
}); err != nil {
|
||||
t.Skipf("Skipping: failed to connect to test database %s:%d/%s as %s: %v",
|
||||
host, port, dbname, user, err)
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
|
||||
// AutoMigrate 幂等所需最小表集(若已存在则跳过)。
|
||||
if err := db.AutoMigrate(
|
||||
&models.Season{},
|
||||
&models.AssetLevel{},
|
||||
&models.AssetLevelRecord{},
|
||||
&models.AssetLevelChangeLog{},
|
||||
&models.AssetExhibitionHoursLog{},
|
||||
); err != nil {
|
||||
t.Logf("Warning: AutoMigrate asset_level tables (may already exist): %v", err)
|
||||
}
|
||||
|
||||
// 确保至少存在 N 等级(供 CheckUpgrade 不会因为没有 level 而 panic)。
|
||||
var n int64
|
||||
db.Model(&models.AssetLevel{}).Where("level = ?", models.LevelN).Count(&n)
|
||||
if n == 0 {
|
||||
db.Create(&models.AssetLevel{
|
||||
Level: models.LevelN,
|
||||
LevelOrder: 1,
|
||||
RequireHours: 99999999,
|
||||
RequireLikes: 99999999,
|
||||
})
|
||||
}
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
func getEnvOrDefault(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// TestAddExhibitionHours_AssetIdempotent 资产级幂等测试。
|
||||
//
|
||||
// 验收:
|
||||
// - 同一 sourceID 二次调用,不重复累加 SeasonExhibitionHours / LifetimeExhibitionHours。
|
||||
// - 二次调用返回 upgraded=false。
|
||||
// - asset_exhibition_hours_log 中恰好 1 条记录。
|
||||
//
|
||||
// 数据隔离:
|
||||
// - 用 sentinel assetID + sourceID,只删自己 sentinel 行,不调用任何全局 cleanup helper。
|
||||
func TestAddExhibitionHours_AssetIdempotent(t *testing.T) {
|
||||
db := assetLevelTestDB(t)
|
||||
|
||||
levelRepo := repository.NewAssetLevelRepository(db)
|
||||
seasonRepo := repository.NewSeasonRepository(db)
|
||||
decayRepo := repository.NewSeasonDecayConfigRepository(db)
|
||||
svc := NewAssetLevelService(levelRepo, seasonRepo, decayRepo)
|
||||
|
||||
assetID := int64(99900091)
|
||||
srcID := "test_asset_exh_log_91_001"
|
||||
|
||||
// 先清 sentinel 行(包含上一次失败或并发残留),defer 再清一次确保退出干净。
|
||||
db.Exec("DELETE FROM asset_exhibition_hours_log WHERE source_id = ?", srcID)
|
||||
db.Exec("DELETE FROM asset_level_records WHERE asset_id = ?", assetID)
|
||||
defer func() {
|
||||
db.Exec("DELETE FROM asset_exhibition_hours_log WHERE source_id = ?", srcID)
|
||||
db.Exec("DELETE FROM asset_level_records WHERE asset_id = ?", assetID)
|
||||
}()
|
||||
|
||||
// 1) 首次:应该累加,新等级(sourceID 不同每次应该都能跑完 add 路径)。
|
||||
lvl1, up1, err := svc.AddExhibitionHours(assetID, 5, srcID)
|
||||
if err != nil {
|
||||
t.Fatalf("first AddExhibitionHours err: %v", err)
|
||||
}
|
||||
_ = lvl1
|
||||
_ = up1
|
||||
|
||||
// 2) 二次:同 sourceID,应该跳过累加、不触发升级。
|
||||
lvl2, up2, err := svc.AddExhibitionHours(assetID, 5, srcID)
|
||||
if err != nil {
|
||||
t.Fatalf("second AddExhibitionHours err: %v", err)
|
||||
}
|
||||
if up2 {
|
||||
t.Errorf("want upgraded=false on duplicate source_id, got true (level=%s)", lvl2)
|
||||
}
|
||||
if lvl2 == "" {
|
||||
t.Errorf("want non-empty level on duplicate, got empty string")
|
||||
}
|
||||
|
||||
// 3) 校验累加表:SeasonExhibitionHours 应仅为 5(一次生效)。
|
||||
var rec models.AssetLevelRecord
|
||||
if err := db.Where("asset_id = ?", assetID).First(&rec).Error; err != nil {
|
||||
t.Fatalf("read asset level record: %v", err)
|
||||
}
|
||||
if rec.SeasonExhibitionHours != 5 {
|
||||
t.Errorf("want SeasonExhibitionHours=5 (one apply), got %d", rec.SeasonExhibitionHours)
|
||||
}
|
||||
if rec.LifetimeExhibitionHours != 5 {
|
||||
t.Errorf("want LifetimeExhibitionHours=5 (one apply), got %d", rec.LifetimeExhibitionHours)
|
||||
}
|
||||
|
||||
// 4) 校验幂等 log 表:恰好 1 条。
|
||||
var logCount int64
|
||||
if err := db.Model(&models.AssetExhibitionHoursLog{}).
|
||||
Where("source_id = ?", srcID).Count(&logCount).Error; err != nil {
|
||||
t.Fatalf("count asset exhibition log: %v", err)
|
||||
}
|
||||
if logCount != 1 {
|
||||
t.Errorf("want exactly 1 asset_exhibition_hours_log row, got %d", logCount)
|
||||
}
|
||||
}
|
||||
|
||||
60
backend/services/assetService/service/mint_rate_limiter.go
Normal file
60
backend/services/assetService/service/mint_rate_limiter.go
Normal file
@ -0,0 +1,60 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// mintRateLimitScript atomically increments the current window counter and
|
||||
// assigns its TTL when the key is first created.
|
||||
var mintRateLimitScript = redis.NewScript(`
|
||||
local n = redis.call("INCR", KEYS[1])
|
||||
if n == 1 then
|
||||
redis.call("EXPIRE", KEYS[1], ARGV[1])
|
||||
end
|
||||
return {n, n <= tonumber(ARGV[2]) and 1 or 0}
|
||||
`)
|
||||
|
||||
// MintRateLimiter limits successful mint attempts with a Redis fixed window.
|
||||
type MintRateLimiter struct {
|
||||
rdb *redis.Client
|
||||
limit int64
|
||||
window time.Duration
|
||||
}
|
||||
|
||||
// NewMintRateLimiter creates a Redis-backed mint rate limiter.
|
||||
func NewMintRateLimiter(rdb *redis.Client, limit int64, window time.Duration) *MintRateLimiter {
|
||||
return &MintRateLimiter{rdb: rdb, limit: limit, window: window}
|
||||
}
|
||||
|
||||
// IncrAndCheck atomically increments the owner's counter and returns its new
|
||||
// value and whether it is within the configured limit.
|
||||
func (l *MintRateLimiter) IncrAndCheck(ctx context.Context, ownerUID int64, assetType string) (int64, bool, error) {
|
||||
if l == nil || l.rdb == nil {
|
||||
return 0, false, fmt.Errorf("redis client is not initialized")
|
||||
}
|
||||
|
||||
windowSeconds := int64(l.window.Seconds())
|
||||
result, err := mintRateLimitScript.Run(ctx, l.rdb, []string{l.key(ownerUID, assetType)}, windowSeconds, l.limit).Result()
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("redis EVAL mint rate limit: %w", err)
|
||||
}
|
||||
|
||||
values, ok := result.([]interface{})
|
||||
if !ok || len(values) != 2 {
|
||||
return 0, false, fmt.Errorf("redis EVAL returned unexpected shape: %v", result)
|
||||
}
|
||||
count, countOK := values[0].(int64)
|
||||
allowed, allowedOK := values[1].(int64)
|
||||
if !countOK || !allowedOK {
|
||||
return 0, false, fmt.Errorf("redis EVAL returned unexpected values: %v", result)
|
||||
}
|
||||
return count, allowed == 1, nil
|
||||
}
|
||||
|
||||
func (l *MintRateLimiter) key(ownerUID int64, assetType string) string {
|
||||
return fmt.Sprintf("periph:mint:%s:%d:%s", assetType, ownerUID, time.Now().UTC().Format("20060102"))
|
||||
}
|
||||
@ -2,8 +2,9 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
@ -227,6 +228,35 @@ func (s *mintService) CreateMintOrder(req *pb.CreateMintOrderRequest, userID, st
|
||||
return nil, fmt.Errorf("order_id is required(请先调用 /api/v1/assets/mints/precreate 获取)")
|
||||
}
|
||||
|
||||
// ★ 幂等短路:已 SUCCESS 的同 order_id 直接返回,不再走扣费/建档流程。
|
||||
// 这是铸造正确性 plan 双层防护的第二层(assetService 入口);
|
||||
// 第一层在 userService.UpdateCrystalBalance(source_id + 唯一索引),由 mint-task-1 覆盖。
|
||||
existing, err := s.mintOrderRepo.GetByOrderIDAndUser(req.OrderId, userID, starID)
|
||||
if err == nil && existing != nil && existing.Status == models.MintOrderStatusSuccess {
|
||||
logger.Logger.Info("CreateMintOrder idempotent hit",
|
||||
zap.String("order_id", existing.OrderID),
|
||||
zap.Int64("user_id", userID),
|
||||
)
|
||||
// 重放响应: 用既有 asset / 既有成本,不再二次扣。
|
||||
var assetProto *pb.Asset
|
||||
if existing.AssetID != nil {
|
||||
if a, gerr := s.assetRepo.GetByID(*existing.AssetID); gerr == nil && a != nil {
|
||||
assetProto = ModelToProtoAssetDetail(a, "", "", false, 0, 0, 0, 0, getInt32Value(a.Grade))
|
||||
}
|
||||
}
|
||||
return &pb.CreateMintOrderResponse{
|
||||
Base: &pbCommon.BaseResponse{
|
||||
Code: uint32(codes.OK),
|
||||
Message: "idempotent",
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
},
|
||||
Order: ModelToProtoMintOrder(existing),
|
||||
Asset: assetProto,
|
||||
CostCrystal: existing.CostCrystal,
|
||||
BalanceAfter: 0, // 余额不再重算(避免对 userService 二次调用)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 2. 获取当前累计铸爱次数,用于计算阶梯费用
|
||||
currentMintCount, err := s.GetUserMintCount(userID, starID)
|
||||
if err != nil {
|
||||
@ -322,9 +352,9 @@ func (s *mintService) CreateMintOrder(req *pb.CreateMintOrderRequest, userID, st
|
||||
// 3.3 检查是否触发保底(概率触发)
|
||||
var boostBps int32 = 0
|
||||
if localMintCost.Probability > 0 && localMintCost.RewardValue > 0 {
|
||||
// 随机判断是否触发
|
||||
randomValue := time.Now().UnixNano() % 100
|
||||
if randomValue < localMintCost.Probability {
|
||||
// ★ 批次1.5: 用 crypto/rand 替换原 time.Now().UnixNano()%100,
|
||||
// 消除并发同纳秒相同结果与脚本卡点操纵风险。
|
||||
if rollGuarantee(localMintCost.Probability) {
|
||||
boostBps = int32(localMintCost.RewardValue) // reward_value 单位是 bps
|
||||
logger.Logger.Info("Mint guarantee triggered",
|
||||
zap.Int64("user_id", userID),
|
||||
@ -346,8 +376,9 @@ func (s *mintService) CreateMintOrder(req *pb.CreateMintOrderRequest, userID, st
|
||||
logger.Logger.Info("[MintOrder] Step 3.5: 创建资产")
|
||||
materialURLValue := getStringValue(mintOrder.MaterialURL)
|
||||
mintedAt := time.Now().UnixMilli()
|
||||
mockTxHash := fmt.Sprintf("0x%x", sha256.Sum256([]byte(fmt.Sprintf("%s-%d-%d-%d", mintOrder.OrderID, userID, starID, time.Now().UnixNano()))))
|
||||
mockBlockNumber := int64(time.Now().Unix()) // 使用当前时间戳作为模拟区块号
|
||||
// ★ 批次1.5: 不再写入伪造的 tx_hash / block_number。
|
||||
// 当前铸造不上链,写模拟 hash 会被前端误当作真实链上凭证;tx_hash/block_number 保持 NULL,
|
||||
// 待真正上链时再由链上回调回填。mintedAt 表示业务生效时间(非链上时间),保留。
|
||||
logger.Logger.Info("[MintOrder] 创建资产参数", zap.Int64("user_id", userID), zap.Int64("star_id", starID), zap.String("name", getStringValue(mintOrder.Name)), zap.String("material_url", materialURLValue))
|
||||
asset = &models.Asset{
|
||||
OwnerUID: userID,
|
||||
@ -360,8 +391,6 @@ func (s *mintService) CreateMintOrder(req *pb.CreateMintOrderRequest, userID, st
|
||||
Status: models.AssetStatusActive, // 直接设为 Active
|
||||
LikeCount: 0,
|
||||
Info: getStringValue(mintOrder.Info),
|
||||
TxHash: &mockTxHash,
|
||||
BlockNumber: &mockBlockNumber,
|
||||
MintedAt: &mintedAt,
|
||||
}
|
||||
|
||||
@ -987,3 +1016,30 @@ func syncAssetsIDSequence(tx *gorm.DB) error {
|
||||
)
|
||||
`).Error
|
||||
}
|
||||
|
||||
// rollGuarantee 按 probability(0-100) 概率返回是否触发保底.
|
||||
//
|
||||
// ★ 批次1.5: 用 crypto/rand 替换原 time.Now().UnixNano()%100,
|
||||
// 消除并发同纳秒相同结果与脚本卡点操纵风险。
|
||||
// 边界约定:
|
||||
// - probability <= 0 → 永不触发(false)
|
||||
// - probability >= 100 → 总是触发(true)
|
||||
// - 熵源失败时降级为不触发(false),避免在极端环境下出现伪随机劣化
|
||||
//
|
||||
// 入参取 int64 与 models.MintCostConfig.Probability 字段类型一致,
|
||||
// 避免调用点显式转换;非法值(负数 / 远超 100)按既定边界处理。
|
||||
func rollGuarantee(probability int64) bool {
|
||||
if probability <= 0 {
|
||||
return false
|
||||
}
|
||||
if probability >= 100 {
|
||||
return true
|
||||
}
|
||||
v, err := rand.Int(rand.Reader, big.NewInt(100))
|
||||
if err != nil {
|
||||
// 熵源失败: 降级为不触发(避免伪随机劣化;线上极少出现)
|
||||
logger.Logger.Warn("crypto/rand.Int failed, falling back to no-guarantee", zap.Error(err))
|
||||
return false
|
||||
}
|
||||
return v.Int64() < probability
|
||||
}
|
||||
|
||||
@ -0,0 +1,239 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"math/big"
|
||||
"testing"
|
||||
|
||||
"github.com/topfans/backend/pkg/models"
|
||||
pb "github.com/topfans/backend/pkg/proto/asset"
|
||||
pbUser "github.com/topfans/backend/pkg/proto/user"
|
||||
"github.com/topfans/backend/services/assetService/client"
|
||||
"github.com/topfans/backend/services/assetService/repository"
|
||||
)
|
||||
|
||||
// mockUserClient 服务层测试用的 UserServiceClient mock。
|
||||
//
|
||||
// 跟踪 UpdateCrystalBalance 调用次数,确保幂等短路路径不会重复扣费。
|
||||
// GetFanProfile 返回当前 balance(测试中只检查不被二次扣即可)。
|
||||
type mockUserClient struct {
|
||||
balance int64
|
||||
updateCrystalCalls int
|
||||
}
|
||||
|
||||
func (m *mockUserClient) UpdateCrystalBalance(_ context.Context, _, _ int64, delta int64, _, _, _ string) (int64, error) {
|
||||
m.updateCrystalCalls++
|
||||
m.balance += delta
|
||||
return m.balance, nil
|
||||
}
|
||||
|
||||
func (m *mockUserClient) UpdateAssetsCount(_ context.Context, _, _ int64, delta int32) (int32, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockUserClient) GetFanProfile(_ context.Context, _, _ int64) (*pbUser.FanProfile, error) {
|
||||
return &pbUser.FanProfile{CrystalBalance: m.balance}, nil
|
||||
}
|
||||
|
||||
// 编译期保证 mockUserClient 满足 client.UserServiceClient 接口
|
||||
var _ client.UserServiceClient = (*mockUserClient)(nil)
|
||||
|
||||
// pbCreateMintReq 构造 CreateMintOrder 测试请求。
|
||||
// 故意忽略 userID/starID 参数(参数名带下划线),因为请求结构里只有订单字段;
|
||||
// 用户与明星身份由 service 方法签名参数传入。
|
||||
func pbCreateMintReq(orderID string, _, _ int64) *pb.CreateMintOrderRequest {
|
||||
return &pb.CreateMintOrderRequest{
|
||||
OrderId: orderID,
|
||||
MaterialUrl: "http://x/m.jpg",
|
||||
Name: "n",
|
||||
Description: "d",
|
||||
Info: "i",
|
||||
MaterialType: "new",
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateMintOrder_IdempotentOnSuccessOrder 验证幂等短路:
|
||||
//
|
||||
// 同 order_id 第二次调用,状态已是 SUCCESS → 直接返回原 asset/order/cost,
|
||||
// 不再调 userClient.UpdateCrystalBalance。
|
||||
//
|
||||
// ★ 双层防护第二层(assetService 侧入口短路)。
|
||||
// - 第一层(userService.UpdateCrystalBalance 的 ON CONFLICT)由 mint-task-1 覆盖。
|
||||
// - 本测试确保"已 SUCCESS 的订单"在入口处就重放响应,避免重复进入扣费/建档流程。
|
||||
func TestCreateMintOrder_IdempotentOnSuccessOrder(t *testing.T) {
|
||||
db := setupServiceTestDB(t)
|
||||
defer cleanupServiceTestDB(t, db)
|
||||
|
||||
// 准备 user + star(余额走 mock,无需 fan_profile)
|
||||
star := createServiceTestStar(t, db, "mint_idem_star")
|
||||
user := createServiceTestUser(t, db, "19900077001")
|
||||
|
||||
// 预置一个 SUCCESS 订单 + 关联 asset(模拟之前已铸造成功)
|
||||
const orderID = "idem-order-uuid-001"
|
||||
originalAsset := &models.Asset{
|
||||
OwnerUID: user.ID,
|
||||
StarID: star.StarID,
|
||||
Name: "existing",
|
||||
CoverURL: "http://x/a.jpg",
|
||||
Status: models.AssetStatusActive,
|
||||
IsActive: true,
|
||||
}
|
||||
if err := db.Create(originalAsset).Error; err != nil {
|
||||
t.Fatalf("Failed to seed asset: %v", err)
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO asset_registry (owner_uid, asset_id, star_id, asset_type, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 'regular', 1, 1, 1)`, user.ID, originalAsset.ID, star.StarID).Error; err != nil {
|
||||
t.Fatalf("Failed to seed asset_registry: %v", err)
|
||||
}
|
||||
if err := db.Create(&models.MintOrder{
|
||||
OrderID: orderID,
|
||||
UserID: user.ID,
|
||||
StarID: star.StarID,
|
||||
Status: models.MintOrderStatusSuccess,
|
||||
CostCrystal: 100,
|
||||
AssetID: &originalAsset.ID,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("Failed to seed mint_order: %v", err)
|
||||
}
|
||||
|
||||
// mock userClient:UpdateCrystalBalance 不应被二次触发
|
||||
uc := &mockUserClient{balance: 900}
|
||||
svc := NewMintService(
|
||||
repository.NewAssetRepository(db),
|
||||
repository.NewMintOrderRepository(db),
|
||||
uc,
|
||||
db, nil,
|
||||
nil, nil, nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
resp, err := svc.CreateMintOrder(pbCreateMintReq(orderID, user.ID, star.StarID), user.ID, star.StarID)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp == nil {
|
||||
t.Fatal("expected response, got nil")
|
||||
}
|
||||
if resp.Order == nil {
|
||||
t.Fatal("expected order in response, got nil")
|
||||
}
|
||||
if resp.Order.OrderId != orderID {
|
||||
t.Errorf("want orderID=%s, got %s", orderID, resp.Order.OrderId)
|
||||
}
|
||||
if resp.Order.Status != models.MintOrderStatusSuccess {
|
||||
t.Errorf("want order status=SUCCESS, got %s", resp.Order.Status)
|
||||
}
|
||||
if resp.CostCrystal != 100 {
|
||||
t.Errorf("want cost_crystal=100, got %d", resp.CostCrystal)
|
||||
}
|
||||
if resp.Asset == nil {
|
||||
t.Error("expected asset replay in idempotent response, got nil")
|
||||
} else if resp.Asset.AssetId != originalAsset.ID {
|
||||
t.Errorf("want assetID=%d, got %d", originalAsset.ID, resp.Asset.AssetId)
|
||||
}
|
||||
|
||||
// ★ 关键断言:幂等命中后不应再调用 UpdateCrystalBalance
|
||||
if uc.updateCrystalCalls != 0 {
|
||||
t.Errorf("UpdateCrystalBalance must not be called on idempotent hit, got %d calls", uc.updateCrystalCalls)
|
||||
}
|
||||
// 余额没被二次扣(保持 mock 初始值 900)
|
||||
if uc.balance != 900 {
|
||||
t.Errorf("balance want 900 (unchanged), got %d", uc.balance)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMintGuaranteeProbability_NonPredictable 验证随机源已从 time.Now() 切到 crypto/rand:
|
||||
//
|
||||
// 1000 次连续采样,100% 命中(Probability=100)与 0% 命中(Probability=0)必须分别全命中/全不命中;
|
||||
// 且单次返回落在 [0,100) 区间。
|
||||
//
|
||||
// ★ 批次1.5:helper 测试本身只验证 crypto/rand 行为符合规格(库内建)。
|
||||
// 真正验证 rollGuarantee 行为见 TestRollGuarantee_Boundaries / _Distribution。
|
||||
func TestMintGuaranteeProbability_NonPredictable(t *testing.T) {
|
||||
// 抽 1000 个 [0,100) 整数,验证区间 + 100% 概率 vs 0% 概率两个极端。
|
||||
for i := 0; i < 1000; i++ {
|
||||
v, err := rand.Int(rand.Reader, big.NewInt(100))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.Cmp(big.NewInt(100)) >= 0 || v.Sign() < 0 {
|
||||
t.Fatalf("out of range: %v", v)
|
||||
}
|
||||
}
|
||||
// 边界
|
||||
if alwaysTriggers(100, 1000) != 1000 {
|
||||
t.Error("P=100 should always trigger")
|
||||
}
|
||||
if alwaysTriggers(0, 1000) != 0 {
|
||||
t.Error("P=0 should never trigger")
|
||||
}
|
||||
}
|
||||
|
||||
// alwaysTriggers 模拟 helper 内部判定,用于边界测试
|
||||
func alwaysTriggers(probability, n int) int {
|
||||
hits := 0
|
||||
for i := 0; i < n; i++ {
|
||||
v, _ := rand.Int(rand.Reader, big.NewInt(100))
|
||||
if v.Int64() < int64(probability) {
|
||||
hits++
|
||||
}
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
// TestRollGuarantee_Boundaries 验证 rollGuarantee 在概率边界上的语义:
|
||||
//
|
||||
// probability <= 0 → 永不触发
|
||||
// probability >= 100 → 总是触发
|
||||
// 非法值(负数 / 远超 100)与 mint_service 现有 Probability 字段约定一致
|
||||
// (Probability 在 DB 层是 int64,正常取值 0~100;这里显式保证超出范围时不会 panic / 误判)。
|
||||
func TestRollGuarantee_Boundaries(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
probability int64
|
||||
want bool
|
||||
}{
|
||||
{"zero never triggers", 0, false},
|
||||
{"negative never triggers", -1, false},
|
||||
{"deep negative never triggers", -1000, false},
|
||||
{"hundred always triggers", 100, true},
|
||||
{"over hundred always triggers", 101, true},
|
||||
{"way over hundred always triggers", 9999, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// 边界值是确定性的,跑 50 次保证没有"随机巧合"
|
||||
for i := 0; i < 50; i++ {
|
||||
if got := rollGuarantee(tc.probability); got != tc.want {
|
||||
t.Fatalf("rollGuarantee(%d) = %v, want %v (iter %d)", tc.probability, got, tc.want, i)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRollGuarantee_Distribution 验证 rollGuarantee 在 P=50 时采样 2000 次,
|
||||
//
|
||||
// 命中率落在 [800, 1200] 内(理论 1000 ± 期望误差 ~22)。
|
||||
//
|
||||
// 这是统计性测试:理论 2000 次伯努利试验 σ ≈ √(npq) = √500 ≈ 22,
|
||||
// 取 ±9σ(800~1200)基本不可能(<10⁻¹⁸)误判;同时能捕捉到"退化成伪随机 / 写死"
|
||||
// 这类回归。如果有人把 crypto/rand 换成 time.Now().UnixNano()%100,
|
||||
// 在循环极快时同纳秒下分布会严重偏(全部 false / 全部 true),立刻 fail。
|
||||
func TestRollGuarantee_Distribution(t *testing.T) {
|
||||
const n = 2000
|
||||
const p int64 = 50
|
||||
hits := 0
|
||||
for i := 0; i < n; i++ {
|
||||
if rollGuarantee(p) {
|
||||
hits++
|
||||
}
|
||||
}
|
||||
const minHits = 800
|
||||
const maxHits = 1200
|
||||
if hits < minHits || hits > maxHits {
|
||||
t.Errorf("rollGuarantee(50) over %d trials: hits=%d, want in [%d,%d] (≈50%%±9σ)",
|
||||
n, hits, minHits, maxHits)
|
||||
}
|
||||
}
|
||||
@ -6,9 +6,9 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/topfans/backend/pkg/peripheral"
|
||||
"github.com/topfans/backend/pkg/logger"
|
||||
"github.com/topfans/backend/pkg/models"
|
||||
"github.com/topfans/backend/pkg/peripheral"
|
||||
"github.com/topfans/backend/services/assetService/repository"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@ -59,13 +59,19 @@ type VerificationResult struct {
|
||||
// PeripheralService 周边验真 + 加入藏品的业务层
|
||||
type PeripheralService struct {
|
||||
repo *repository.PeripheralRepository
|
||||
rateLimiter *MintRateLimiter
|
||||
}
|
||||
|
||||
// NewPeripheralService 创建 PeripheralService 实例
|
||||
// NewPeripheralService creates a service with the DB fallback rate-limit path.
|
||||
func NewPeripheralService(repo *repository.PeripheralRepository) *PeripheralService {
|
||||
return &PeripheralService{repo: repo}
|
||||
}
|
||||
|
||||
// NewPeripheralServiceWithLimiter creates a service with Redis Lua atomic rate limiting.
|
||||
func NewPeripheralServiceWithLimiter(repo *repository.PeripheralRepository, limiter *MintRateLimiter) *PeripheralService {
|
||||
return &PeripheralService{repo: repo, rateLimiter: limiter}
|
||||
}
|
||||
|
||||
// GetVerification 验真接口(spec §4.1)
|
||||
//
|
||||
// 流程:查 asset → 查 peripheral_info → 自增 peripheral_info.verify_count → 组装响应
|
||||
@ -229,7 +235,25 @@ func (s *PeripheralService) doMint(ctx context.Context, ownerUID int64, info *mo
|
||||
return nil, &BizError{Code: BizCodeAlreadyAdded, Message: "您已添加过此周边"}
|
||||
}
|
||||
|
||||
// 2. 限频:24h 最多 10 次
|
||||
// 2. 限频: Redis Lua 原子自增; Redis 故障时降级为原 DB count 路径。
|
||||
if s.rateLimiter != nil {
|
||||
count, allowed, err := s.rateLimiter.IncrAndCheck(ctx, ownerUID, "peripheral")
|
||||
if err != nil {
|
||||
logger.Logger.Warn("MintRateLimiter failed, falling back to DB count",
|
||||
zap.Int64("owner_uid", ownerUID), zap.Error(err))
|
||||
dbCount, dbErr := s.repo.CountRecentMint(ctx, ownerUID, "peripheral", 24*time.Hour)
|
||||
if dbErr != nil {
|
||||
return nil, fmt.Errorf("DB_COUNT_FALLBACK_FAILED: %w", dbErr)
|
||||
}
|
||||
if dbCount >= 10 {
|
||||
return nil, &BizError{Code: BizCodeRateLimited, Message: "今日提交过于频繁,请稍后再试"}
|
||||
}
|
||||
} else if !allowed {
|
||||
logger.Logger.Info("Mint rate limited",
|
||||
zap.Int64("owner_uid", ownerUID), zap.Int64("count", count))
|
||||
return nil, &BizError{Code: BizCodeRateLimited, Message: "今日提交过于频繁,请稍后再试"}
|
||||
}
|
||||
} else {
|
||||
count, err := s.repo.CountRecentMint(ctx, ownerUID, "peripheral", 24*time.Hour)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("DB_COUNT_FAILED: %w", err)
|
||||
@ -237,6 +261,7 @@ func (s *PeripheralService) doMint(ctx context.Context, ownerUID int64, info *mo
|
||||
if count >= 10 {
|
||||
return nil, &BizError{Code: BizCodeRateLimited, Message: "今日提交过于频繁,请稍后再试"}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 建新 asset(从 peripheral_info 字段填充)
|
||||
tmpAssetID := assetID
|
||||
@ -347,6 +372,7 @@ func statusInactiveMessage(status int16) string {
|
||||
return "该周边当前不可验真"
|
||||
}
|
||||
}
|
||||
|
||||
// derefStr *string → string(nil 返 "")
|
||||
func derefStr(p *string) string {
|
||||
if p == nil {
|
||||
|
||||
@ -3,14 +3,31 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/topfans/backend/pkg/models"
|
||||
"github.com/topfans/backend/pkg/peripheral"
|
||||
"github.com/topfans/backend/services/assetService/repository"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// peripheralTestCodeHash 测试 fixture 中 peripheral_info.hash 列的固定值;
|
||||
// 见 setupAssetWithPeripheral 的 INSERT 语句。MintFromPeripheralByHash 要求 codeHash
|
||||
// 参数等于 hash 列(由 peripheral.EncryptCode(code) 生成),sign 必须是 SignURL(codeHash)。
|
||||
const peripheralTestCodeHash = "0xdeadbeef"
|
||||
|
||||
// mintSignFor 测试用 sign 计算 helper —— 与 peripheral.SignURL 同语义,
|
||||
// 保证 verifySignOrFail 通过。生产 URL sign 由前端加密生成。
|
||||
func mintSignFor(codeHash string) string {
|
||||
return peripheral.SignURL(codeHash)
|
||||
}
|
||||
|
||||
// setupAssetWithPeripheral 准备测试用 asset + peripheral_info
|
||||
//
|
||||
// ★ 复用包内共享 helper(createServiceTestStar / createServiceTestUser / createServiceTestAsset),
|
||||
@ -30,6 +47,15 @@ func setupAssetWithPeripheral(t *testing.T, db *gorm.DB) (assetID, starID int64)
|
||||
asset.ID).Error; err != nil {
|
||||
t.Fatalf("Failed to create test peripheral_info: %v", err)
|
||||
}
|
||||
if err := db.Exec("DELETE FROM peripheral_verify_code WHERE code = ? OR code_hash = ?", "PERI-2026-001", peripheralTestCodeHash).Error; err != nil {
|
||||
t.Fatalf("Failed to clear test peripheral_verify_code: %v", err)
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO peripheral_verify_code
|
||||
(code, code_hash, peripheral_info_id, status, created_by, created_at, updated_at)
|
||||
SELECT 'PERI-2026-001', ?, id, 1, 'service-test', 1, 1
|
||||
FROM peripheral_info WHERE asset_id = ?`, peripheralTestCodeHash, asset.ID).Error; err != nil {
|
||||
t.Fatalf("Failed to create test peripheral_verify_code: %v", err)
|
||||
}
|
||||
|
||||
return asset.ID, star.StarID
|
||||
}
|
||||
@ -132,7 +158,7 @@ func TestPeripheralService_MintFromPeripheral_Success(t *testing.T) {
|
||||
// 清理 ownerUID 旧记录(避免残留影响)
|
||||
defer db.Exec("DELETE FROM asset_registry WHERE owner_uid = ?", ownerUID)
|
||||
|
||||
result, err := svc.MintFromPeripheral(context.Background(), ownerUID, "PERI-2026-001")
|
||||
result, err := svc.MintFromPeripheralByHash(context.Background(), ownerUID, peripheralTestCodeHash, mintSignFor(peripheralTestCodeHash))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@ -172,7 +198,7 @@ func TestPeripheralService_MintFromPeripheral_AlreadyAdded(t *testing.T) {
|
||||
}
|
||||
defer db.Exec("DELETE FROM asset_registry WHERE owner_uid = ?", ownerUID)
|
||||
|
||||
_, err := svc.MintFromPeripheral(context.Background(), ownerUID, "PERI-2026-001")
|
||||
_, err := svc.MintFromPeripheralByHash(context.Background(), ownerUID, peripheralTestCodeHash, mintSignFor(peripheralTestCodeHash))
|
||||
var bizErr *BizError
|
||||
if !errors.As(err, &bizErr) {
|
||||
t.Fatalf("expected BizError, got %T: %v", err, err)
|
||||
@ -212,7 +238,7 @@ func TestPeripheralService_MintFromPeripheral_RateLimited(t *testing.T) {
|
||||
defer db.Exec("DELETE FROM asset_registry WHERE owner_uid = ?", ownerUID)
|
||||
defer db.Exec("DELETE FROM assets WHERE id IN ?", cleanupAssets)
|
||||
|
||||
_, err := svc.MintFromPeripheral(context.Background(), ownerUID, "PERI-2026-001")
|
||||
_, err := svc.MintFromPeripheralByHash(context.Background(), ownerUID, peripheralTestCodeHash, mintSignFor(peripheralTestCodeHash))
|
||||
var bizErr *BizError
|
||||
if !errors.As(err, &bizErr) {
|
||||
t.Fatalf("expected BizError, got %T: %v", err, err)
|
||||
@ -231,7 +257,7 @@ func TestPeripheralService_MintFromPeripheral_AssetNotFound(t *testing.T) {
|
||||
repo := repository.NewPeripheralRepository(db)
|
||||
svc := NewPeripheralService(repo)
|
||||
|
||||
_, err := svc.MintFromPeripheral(context.Background(), 999, "999999999999")
|
||||
_, err := svc.MintFromPeripheralByHash(context.Background(), 999, "999999999999", mintSignFor("999999999999"))
|
||||
var bizErr *BizError
|
||||
if !errors.As(err, &bizErr) {
|
||||
t.Fatalf("expected BizError, got %T: %v", err, err)
|
||||
@ -255,7 +281,7 @@ func TestPeripheralService_MintFromPeripheral_PeripheralInfoMissing(t *testing.T
|
||||
user := createServiceTestUser(t, db, "19900099003")
|
||||
_ = createServiceTestAsset(t, db, user.ID, star.StarID, "p-mint-noinfo")
|
||||
|
||||
_, err := svc.MintFromPeripheral(context.Background(), 999, "PERI-2026-001")
|
||||
_, err := svc.MintFromPeripheralByHash(context.Background(), 999, "PERI-2026-001", mintSignFor("PERI-2026-001"))
|
||||
var bizErr *BizError
|
||||
if !errors.As(err, &bizErr) {
|
||||
t.Fatalf("expected BizError, got %T: %v", err, err)
|
||||
@ -264,3 +290,107 @@ func TestPeripheralService_MintFromPeripheral_PeripheralInfoMissing(t *testing.T
|
||||
t.Errorf("expected code=%d, got %d", BizCodeAssetNotFound, bizErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPeripheralService_MintFromPeripheral_RateLimitAtomic verifies that the
|
||||
// Redis Lua limiter admits exactly 10 of 11 concurrent doMint calls for one owner.
|
||||
func TestPeripheralService_MintFromPeripheral_RateLimitAtomic(t *testing.T) {
|
||||
db := setupServiceTestDB(t)
|
||||
defer cleanupServiceTestDB(t, db)
|
||||
|
||||
miniRedis, err := miniredis.Run()
|
||||
if err != nil {
|
||||
t.Fatalf("start miniredis: %v", err)
|
||||
}
|
||||
defer miniRedis.Close()
|
||||
|
||||
rdb := redis.NewClient(&redis.Options{Addr: miniRedis.Addr()})
|
||||
defer rdb.Close()
|
||||
limiter := NewMintRateLimiter(rdb, 10, 24*time.Hour)
|
||||
svc := NewPeripheralServiceWithLimiter(repository.NewPeripheralRepository(db), limiter)
|
||||
|
||||
star := createServiceTestStar(t, db, "test_peripheral_mint_atomic_limit")
|
||||
user := createServiceTestUser(t, db, "19900077010")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var succeeded, rateLimited, other atomic.Int32
|
||||
for i := 0; i < 11; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
_, mintErr := svc.doMint(context.Background(), user.ID, &models.PeripheralInfo{
|
||||
StarID: star.StarID,
|
||||
Code: fmt.Sprintf("PERI-ATOMIC-%02d", i),
|
||||
Image: fmt.Sprintf("https://example.com/peripheral/%d.jpg", i),
|
||||
Brand: "Brand",
|
||||
Company: "Company",
|
||||
Hash: fmt.Sprintf("hash-%02d", i),
|
||||
})
|
||||
if mintErr == nil {
|
||||
succeeded.Add(1)
|
||||
return
|
||||
}
|
||||
var bizErr *BizError
|
||||
if errors.As(mintErr, &bizErr) && bizErr.Code == BizCodeRateLimited {
|
||||
rateLimited.Add(1)
|
||||
return
|
||||
}
|
||||
other.Add(1)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if gotOK, gotLimited, gotOther := succeeded.Load(), rateLimited.Load(), other.Load(); gotOK != 10 || gotLimited != 1 || gotOther != 0 {
|
||||
t.Fatalf("want succeeded=10 rateLimited=1 other=0, got succeeded=%d rateLimited=%d other=%d", gotOK, gotLimited, gotOther)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPeripheralService_MintFromPeripheral_RedisFailureFallsBackToDB verifies
|
||||
// that a Redis outage uses the existing DB count and rejects the 11th mint.
|
||||
func TestPeripheralService_MintFromPeripheral_RedisFailureFallsBackToDB(t *testing.T) {
|
||||
db := setupServiceTestDB(t)
|
||||
defer cleanupServiceTestDB(t, db)
|
||||
|
||||
miniRedis, err := miniredis.Run()
|
||||
if err != nil {
|
||||
t.Fatalf("start miniredis: %v", err)
|
||||
}
|
||||
addr := miniRedis.Addr()
|
||||
miniRedis.Close()
|
||||
|
||||
rdb := redis.NewClient(&redis.Options{
|
||||
Addr: addr,
|
||||
DialTimeout: 100 * time.Millisecond,
|
||||
ReadTimeout: 100 * time.Millisecond,
|
||||
WriteTimeout: 100 * time.Millisecond,
|
||||
MaxRetries: -1,
|
||||
})
|
||||
defer rdb.Close()
|
||||
limiter := NewMintRateLimiter(rdb, 10, 24*time.Hour)
|
||||
svc := NewPeripheralServiceWithLimiter(repository.NewPeripheralRepository(db), limiter)
|
||||
|
||||
star := createServiceTestStar(t, db, "test_peripheral_mint_redis_fallback")
|
||||
user := createServiceTestUser(t, db, "19900077011")
|
||||
now := time.Now().UnixMilli()
|
||||
for i := 0; i < 10; i++ {
|
||||
assetID := user.ID*100 + int64(i)
|
||||
if err := db.Exec(`INSERT INTO asset_registry (owner_uid, asset_id, star_id, asset_type, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 'peripheral', 1, ?, ?)`, user.ID, assetID, star.StarID, now, now).Error; err != nil {
|
||||
t.Fatalf("seed DB rate-limit row %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
_, mintErr := svc.doMint(context.Background(), user.ID, &models.PeripheralInfo{
|
||||
StarID: star.StarID,
|
||||
Code: "PERI-FALLBACK",
|
||||
Image: "https://example.com/peripheral/fallback.jpg",
|
||||
Brand: "Brand",
|
||||
Hash: "hash-fallback",
|
||||
})
|
||||
var bizErr *BizError
|
||||
if !errors.As(mintErr, &bizErr) {
|
||||
t.Fatalf("expected BizError from DB fallback, got %T: %v", mintErr, mintErr)
|
||||
}
|
||||
if bizErr.Code != BizCodeRateLimited {
|
||||
t.Fatalf("expected code=%d, got %d", BizCodeRateLimited, bizErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,17 +12,17 @@ import (
|
||||
//
|
||||
// 沿用 backend/services/assetService/repository/asset_repository_test.go 的 setupTestDB 风格,
|
||||
// 但因为 helpers 是包内私有,服务层需要自己的副本。注意:
|
||||
// - DB 配置与 repository 包保持一致(local haihuzhu/admin/top-fans)
|
||||
// - DB 配置对齐 docker/.env.local(localhost:15432, postgres/123456, top-fans)
|
||||
// - AutoMigrate 包含 PeripheralInfo(peripheral_info 表)
|
||||
// - 修复 local DB 缺 stars 表:raw SQL CREATE TABLE IF NOT EXISTS + ALTER ADD COLUMN IF NOT EXISTS
|
||||
// + CREATE UNIQUE INDEX,兼容已有"残缺" stars 表的情况
|
||||
// - CREATE UNIQUE INDEX,兼容已有"残缺" stars 表的情况
|
||||
func setupServiceTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
config := database.Config{
|
||||
Host: "localhost",
|
||||
Port: 5432,
|
||||
User: "haihuizhu",
|
||||
Password: "admin",
|
||||
Port: 15432,
|
||||
User: "postgres",
|
||||
Password: "123456",
|
||||
DBName: "top-fans",
|
||||
SSLMode: "disable",
|
||||
TimeZone: "Asia/Shanghai",
|
||||
@ -68,6 +68,7 @@ func setupServiceTestDB(t *testing.T) *gorm.DB {
|
||||
// 关联数据,以及 identity_id LIKE 'test_service_%' / 'test_peripheral_%' 的明星。
|
||||
func cleanupServiceTestDB(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
db.Exec("DELETE FROM asset_registry WHERE owner_uid IN (SELECT id FROM users WHERE mobile LIKE '199%')")
|
||||
db.Exec("DELETE FROM peripheral_info WHERE asset_id IN (SELECT id FROM assets WHERE owner_uid IN (SELECT id FROM users WHERE mobile LIKE '199%'))")
|
||||
db.Exec("DELETE FROM asset_likes WHERE user_id IN (SELECT id FROM users WHERE mobile LIKE '199%') OR asset_id IN (SELECT id FROM assets WHERE owner_uid IN (SELECT id FROM users WHERE mobile LIKE '199%'))")
|
||||
db.Exec("DELETE FROM assets WHERE owner_uid IN (SELECT id FROM users WHERE mobile LIKE '199%')")
|
||||
|
||||
@ -199,31 +199,30 @@ func fetchExhibitionForSettle(ctx context.Context, exhibitionID int64) exhForSet
|
||||
}
|
||||
|
||||
// isSettled 查 exhibition 是否已 settled。
|
||||
// 注:这个方法需要 exhibition 表有 settled 列,这里先简化用 is_processed 字段保持兼容。
|
||||
// 真实上线时需执行 migration 加 settled 列。
|
||||
// settled_at 非空且大于 0 视为已结算。
|
||||
func isSettled(ctx context.Context, exhibitionID int64) bool {
|
||||
var processed bool
|
||||
var settledAt *int64
|
||||
err := database.GetDB().Table("public.exhibitions").
|
||||
Select("COALESCE(is_processed, false)"). // 暂时复用作 settled
|
||||
Select("settled_at").
|
||||
Where("id = ?", exhibitionID).
|
||||
Scan(&processed).Error
|
||||
Scan(&settledAt).Error
|
||||
if err != nil {
|
||||
// 查询失败 = 视为未 settled,允许 handler 继续
|
||||
return false
|
||||
}
|
||||
return processed
|
||||
return settledAt != nil && *settledAt > 0
|
||||
}
|
||||
|
||||
func markSettled(ctx context.Context, exhibitionID int64) error {
|
||||
return database.GetDB().Table("public.exhibitions").
|
||||
Where("id = ?", exhibitionID).
|
||||
Update("is_processed", true).Error
|
||||
Update("settled_at", time.Now().UnixMilli()).Error
|
||||
}
|
||||
|
||||
// scanExpiredExhibitions 一次性迁移扫描:
|
||||
//
|
||||
// deleted_at IS NULL
|
||||
// AND is_processed = false
|
||||
// AND settled_at IS NULL
|
||||
// AND expire_at < now
|
||||
//
|
||||
// 对每条记录补发 EnqueueExhibitSettled(recovery) 结算任务。
|
||||
@ -243,7 +242,7 @@ func scanExpiredExhibitions(ctx context.Context) {
|
||||
nowMs := time.Now().UnixMilli()
|
||||
if err := database.GetDB().Table("public.exhibitions").
|
||||
Select("id, asset_id, slot_id, COALESCE(occupier_uid,0) AS occupier_uid, COALESCE(occupier_star_id,0) AS occupier_star_id, COALESCE(host_profile_id,0) AS slot_owner_uid, start_time, expire_at").
|
||||
Where("deleted_at IS NULL AND is_processed = false AND expire_at < ?", nowMs).
|
||||
Where("deleted_at IS NULL AND settled_at IS NULL AND expire_at < ?", nowMs).
|
||||
Limit(500).
|
||||
Scan(&rows).Error; err != nil {
|
||||
logger.Logger.Error("expiry scanner: query failed", zap.Error(err))
|
||||
|
||||
@ -1,374 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/topfans/backend/pkg/database"
|
||||
"github.com/topfans/backend/pkg/logger"
|
||||
"github.com/topfans/backend/services/galleryService/client"
|
||||
"github.com/topfans/backend/services/galleryService/repository"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// CleanupWorker 清理过期展品的Worker
|
||||
type CleanupWorker struct {
|
||||
repo repository.GalleryRepository
|
||||
assetClient client.AssetRPCClient
|
||||
userClient client.UserRPCClient
|
||||
taskClient client.TaskRPCClient
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// NewCleanupWorker 创建清理Worker实例
|
||||
func NewCleanupWorker(repo repository.GalleryRepository, assetClient client.AssetRPCClient, userClient client.UserRPCClient, taskClient client.TaskRPCClient) *CleanupWorker {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &CleanupWorker{
|
||||
repo: repo,
|
||||
assetClient: assetClient,
|
||||
userClient: userClient,
|
||||
taskClient: taskClient,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
// Start 启动清理Worker
|
||||
func (w *CleanupWorker) Start() {
|
||||
log.Println("清理Worker已启动,每小时扫描一次过期展品")
|
||||
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
// 立即执行一次清理
|
||||
w.cleanup()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
w.cleanup()
|
||||
case <-w.ctx.Done():
|
||||
log.Println("清理Worker已停止")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop 停止清理Worker
|
||||
func (w *CleanupWorker) Stop() {
|
||||
w.cancel()
|
||||
}
|
||||
|
||||
// cleanup 执行清理逻辑
|
||||
func (w *CleanupWorker) cleanup() {
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
// 1. 清理过期的展品展示记录
|
||||
w.cleanupExpiredExhibitions(now)
|
||||
|
||||
// 2. 清理无效的 display_status(处理手动软删除导致的不一致)
|
||||
w.cleanupInvalidDisplayStatus()
|
||||
}
|
||||
|
||||
// cleanupExpiredExhibitions 清理过期的展品展示记录(使用 ZSET 驱动 + 数据库兜底)
|
||||
func (w *CleanupWorker) cleanupExpiredExhibitions(now int64) {
|
||||
ctx := context.Background()
|
||||
|
||||
// 1. 先尝试从 ZSET 获取过期展品
|
||||
expiredAssetIDs, err := database.GetExpiredAssets(ctx, now)
|
||||
if err != nil {
|
||||
log.Printf("从 ZSET 获取过期展品失败: %v,降级到数据库查询", err)
|
||||
w.cleanupExpiredExhibitionsFromDB(now)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. ZSET 有数据时处理 ZSET 中的过期展品
|
||||
if len(expiredAssetIDs) > 0 {
|
||||
log.Printf("ZSET 发现 %d 个过期展品,开始清理", len(expiredAssetIDs))
|
||||
w.cleanupAssetsFromZSET(ctx, expiredAssetIDs, now)
|
||||
}
|
||||
|
||||
// 3. 兜底:检查数据库中可能遗漏的过期展品(ZSET 可能在 Redis 重启或数据丢失时漏掉)
|
||||
w.cleanupExpiredExhibitionsFromDB(now)
|
||||
}
|
||||
|
||||
// cleanupAssetsFromZSET 从 ZSET 处理过期展品
|
||||
func (w *CleanupWorker) cleanupAssetsFromZSET(ctx context.Context, expiredAssetIDs []int64, now int64) {
|
||||
// 批量删除过期记录
|
||||
successCount := 0
|
||||
failedCount := 0
|
||||
|
||||
for _, assetID := range expiredAssetIDs {
|
||||
// 从数据库查询该 asset_id 对应的有效展览
|
||||
e, err := w.repo.GetExhibitionByAssetID(assetID)
|
||||
if err != nil || e == nil {
|
||||
// 展览不存在或已处理,从 ZSET 移除
|
||||
database.RemoveExpiringAsset(ctx, assetID)
|
||||
continue
|
||||
}
|
||||
|
||||
// 1. 获取点赞数用于计算收益
|
||||
likeCount := 0
|
||||
if w.assetClient != nil {
|
||||
likeCount = w.assetClient.GetAssetLikeCount(e.AssetID)
|
||||
}
|
||||
|
||||
// 2. 计算展示收益
|
||||
revenue := calculateExhibitionRevenue(likeCount, e.StartTime, now)
|
||||
|
||||
logger.Logger.Info("计算展出收益",
|
||||
zap.Int64("exhibition_id", e.ID),
|
||||
zap.Int64("asset_id", e.AssetID),
|
||||
zap.Int("like_count", likeCount),
|
||||
zap.Int64("start_time", e.StartTime),
|
||||
zap.Int64("end_time", now),
|
||||
zap.Int64("revenue", revenue))
|
||||
|
||||
// 3. 调用 TaskService 记录收益
|
||||
if w.taskClient != nil {
|
||||
slotOwnerUID := e.HostProfileID
|
||||
if ownerUID, err := w.repo.GetSlotOwnerUserID(e.SlotID); err == nil {
|
||||
slotOwnerUID = ownerUID
|
||||
}
|
||||
|
||||
_, err := w.taskClient.OnExhibitionCompleted(context.Background(), &client.OnExhibitionCompletedRequest{
|
||||
ExhibitionId: e.ID,
|
||||
AssetId: e.AssetID,
|
||||
SlotId: e.SlotID,
|
||||
OccupierUid: e.OccupierUID,
|
||||
OccupierStarId: e.OccupierStarID,
|
||||
SlotOwnerUid: slotOwnerUID,
|
||||
StartTime: e.StartTime,
|
||||
ExpireAt: now,
|
||||
CrystalAmount: revenue,
|
||||
LikeCount: int32(likeCount),
|
||||
})
|
||||
if err != nil {
|
||||
logger.Logger.Error("调用TaskService记录收益失败",
|
||||
zap.Int64("exhibition_id", e.ID),
|
||||
zap.Error(err))
|
||||
failedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
// 3.1 调用 TaskService 记录点赞押注收益(失败时跳过 is_processed/ZSET 清理,允许下次重试或 MQ 兜底)
|
||||
likeBetSucceeded := true
|
||||
if _, recErr := w.taskClient.RecordLikeBetRevenue(context.Background(), &client.RecordLikeBetRevenueRequest{
|
||||
ExhibitionId: e.ID,
|
||||
AssetId: e.AssetID,
|
||||
TotalLikes: int64(likeCount),
|
||||
StartTime: e.StartTime,
|
||||
ExpireAt: now,
|
||||
}); recErr != nil {
|
||||
logger.Logger.Warn("调用TaskService记录点赞押注收益失败,跳过标记已处理以允许重试",
|
||||
zap.Int64("exhibition_id", e.ID),
|
||||
zap.Error(recErr))
|
||||
likeBetSucceeded = false
|
||||
}
|
||||
|
||||
if !likeBetSucceeded {
|
||||
failedCount++
|
||||
continue // 不标记 is_processed,不清理 ZSET,下次重试或 MQ 兜底
|
||||
}
|
||||
}
|
||||
|
||||
successCount++
|
||||
|
||||
// 4. 标记展品已处理(只有展示收益+点赞押注收益都成功才标记)
|
||||
if err := w.repo.SetExhibitionProcessed(e.ID, true); err != nil {
|
||||
logger.Logger.Error("标记展品已处理失败",
|
||||
zap.Int64("exhibition_id", e.ID),
|
||||
zap.Error(err))
|
||||
}
|
||||
|
||||
// 5. 从 ZSET 移除(只有全部处理成功才移除)
|
||||
database.RemoveExpiringAsset(ctx, assetID)
|
||||
database.RemoveExpiringAssetFromStar(ctx, e.OccupierStarID, assetID)
|
||||
|
||||
log.Printf("展品已到期并生成领取记录: ExhibitionID=%d, AssetID=%d, SlotID=%d, OccupierUID=%d, Revenue=%d",
|
||||
e.ID, e.AssetID, e.SlotID, e.OccupierUID, revenue)
|
||||
}
|
||||
|
||||
log.Printf("ZSET 过期展品清理完成: 成功 %d 个, 失败 %d 个", successCount, failedCount)
|
||||
}
|
||||
|
||||
// cleanupExpiredExhibitionsFromDB 兜底方案:从数据库查询过期展览
|
||||
func (w *CleanupWorker) cleanupExpiredExhibitionsFromDB(now int64) {
|
||||
expired, err := w.repo.GetExpiredExhibitions(now)
|
||||
if err != nil {
|
||||
log.Printf("从数据库获取过期展品失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(expired) == 0 {
|
||||
log.Println("没有过期的展品需要清理")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("数据库发现 %d 个过期展品,开始清理", len(expired))
|
||||
|
||||
successCount := 0
|
||||
failedCount := 0
|
||||
ctx := context.Background()
|
||||
|
||||
for _, e := range expired {
|
||||
likeCount := 0
|
||||
if w.assetClient != nil {
|
||||
likeCount = w.assetClient.GetAssetLikeCount(e.AssetID)
|
||||
}
|
||||
|
||||
revenue := calculateExhibitionRevenue(likeCount, e.StartTime, now)
|
||||
|
||||
logger.Logger.Info("计算展出收益",
|
||||
zap.Int64("exhibition_id", e.ID),
|
||||
zap.Int64("asset_id", e.AssetID),
|
||||
zap.Int("like_count", likeCount),
|
||||
zap.Int64("start_time", e.StartTime),
|
||||
zap.Int64("end_time", now),
|
||||
zap.Int64("revenue", revenue))
|
||||
|
||||
if w.taskClient != nil {
|
||||
slotOwnerUID := e.HostProfileID
|
||||
if ownerUID, err := w.repo.GetSlotOwnerUserID(e.SlotID); err == nil {
|
||||
slotOwnerUID = ownerUID
|
||||
}
|
||||
|
||||
_, err := w.taskClient.OnExhibitionCompleted(context.Background(), &client.OnExhibitionCompletedRequest{
|
||||
ExhibitionId: e.ID,
|
||||
AssetId: e.AssetID,
|
||||
SlotId: e.SlotID,
|
||||
OccupierUid: e.OccupierUID,
|
||||
OccupierStarId: e.OccupierStarID,
|
||||
SlotOwnerUid: slotOwnerUID,
|
||||
StartTime: e.StartTime,
|
||||
ExpireAt: now,
|
||||
CrystalAmount: revenue,
|
||||
LikeCount: int32(likeCount),
|
||||
})
|
||||
if err != nil {
|
||||
logger.Logger.Error("调用TaskService记录收益失败",
|
||||
zap.Int64("exhibition_id", e.ID),
|
||||
zap.Error(err))
|
||||
failedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
// 同步调用 TaskService 记录点赞押注收益(失败时跳过 is_processed/ZSET 清理,允许下次重试或 MQ 兜底)
|
||||
likeBetSucceeded := true
|
||||
if _, recErr := w.taskClient.RecordLikeBetRevenue(context.Background(), &client.RecordLikeBetRevenueRequest{
|
||||
ExhibitionId: e.ID,
|
||||
AssetId: e.AssetID,
|
||||
TotalLikes: int64(likeCount),
|
||||
StartTime: e.StartTime,
|
||||
ExpireAt: now,
|
||||
}); recErr != nil {
|
||||
logger.Logger.Warn("调用TaskService记录点赞押注收益失败,跳过标记已处理以允许重试",
|
||||
zap.Int64("exhibition_id", e.ID),
|
||||
zap.Error(recErr))
|
||||
likeBetSucceeded = false
|
||||
}
|
||||
|
||||
if !likeBetSucceeded {
|
||||
failedCount++
|
||||
continue // 不标记 is_processed,不清理 ZSET,下次重试或 MQ 兜底
|
||||
}
|
||||
}
|
||||
|
||||
successCount++
|
||||
|
||||
if err := w.repo.SetExhibitionProcessed(e.ID, true); err != nil {
|
||||
logger.Logger.Error("标记展品已处理失败",
|
||||
zap.Int64("exhibition_id", e.ID),
|
||||
zap.Error(err))
|
||||
}
|
||||
|
||||
// 降级方案也需要清理 ZSET(只有全部处理成功才移除)
|
||||
database.RemoveExpiringAsset(ctx, e.AssetID)
|
||||
database.RemoveExpiringAssetFromStar(ctx, e.OccupierStarID, e.AssetID)
|
||||
|
||||
log.Printf("展品已到期并生成领取记录: ExhibitionID=%d, AssetID=%d, SlotID=%d, OccupierUID=%d, Revenue=%d",
|
||||
e.ID, e.AssetID, e.SlotID, e.OccupierUID, revenue)
|
||||
}
|
||||
|
||||
log.Printf("过期展品清理完成: 成功 %d 个, 失败 %d 个", successCount, failedCount)
|
||||
}
|
||||
|
||||
// calculateExhibitionRevenue 计算单次上架收益
|
||||
// 设计文档公式:
|
||||
// R1 = R0 × T × [100% + Buff(n)]
|
||||
// R0 = 5 水晶/小时
|
||||
// T = 上架时长(小时)
|
||||
// Buff(n) 根据点赞数计算:n<5→0%, 5≤n<10→10%, 10≤n<30→20%, n≥30→30%
|
||||
func calculateExhibitionRevenue(likeCount int, startTime, endTime int64) int64 {
|
||||
R0 := int64(5) // 水晶/小时
|
||||
|
||||
// 计算上架时长(毫秒转小时)
|
||||
T := (endTime - startTime) / 3600000
|
||||
if T <= 0 {
|
||||
T = 1 // 最少1小时
|
||||
}
|
||||
|
||||
// 计算Buff
|
||||
var buff int
|
||||
switch {
|
||||
case likeCount >= 30:
|
||||
buff = 30
|
||||
case likeCount >= 10:
|
||||
buff = 20
|
||||
case likeCount >= 5:
|
||||
buff = 10
|
||||
default:
|
||||
buff = 0
|
||||
}
|
||||
|
||||
// 基础收益
|
||||
baseRevenue := R0 * T
|
||||
|
||||
// 应用Buff加成(银行家四舍五入)
|
||||
// R1 = R0 × T × (100% + Buff)
|
||||
buffedRevenue := int64(math.Round(float64(baseRevenue) * (100 + float64(buff)) / 100))
|
||||
|
||||
return buffedRevenue
|
||||
}
|
||||
|
||||
// cleanupInvalidDisplayStatus 清理无效的 display_status
|
||||
// 处理手动给 exhibition 添加 deleted_at 或 exhibition 已过期但 display_status 仍为1的情况
|
||||
func (w *CleanupWorker) cleanupInvalidDisplayStatus() {
|
||||
// 获取 display_status=1 但没有有效 exhibition 的资产ID列表
|
||||
invalidAssetIDs, err := w.repo.GetAssetsWithInvalidDisplayStatus()
|
||||
if err != nil {
|
||||
log.Printf("获取无效 display_status 资产列表失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(invalidAssetIDs) == 0 {
|
||||
log.Println("没有无效的 display_status 需要清理")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("发现 %d 个无效 display_status,开始清理", len(invalidAssetIDs))
|
||||
|
||||
successCount := 0
|
||||
failedCount := 0
|
||||
|
||||
for _, assetID := range invalidAssetIDs {
|
||||
if err := w.repo.UpdateAssetRegistryDisplayStatus(assetID, int32(0)); err != nil {
|
||||
log.Printf("重置 display_status 失败 (AssetID: %d): %v", assetID, err)
|
||||
failedCount++
|
||||
continue
|
||||
}
|
||||
successCount++
|
||||
log.Printf("已重置无效 display_status: AssetID=%d", assetID)
|
||||
}
|
||||
|
||||
log.Printf("无效 display_status 清理完成: 成功 %d 个, 失败 %d 个", successCount, failedCount)
|
||||
}
|
||||
|
||||
// publishEvent 发布事件(预留接口)
|
||||
// func (w *CleanupWorker) publishEvent(eventType string, exhibition *models.Exhibition) {
|
||||
// // TODO: 实现事件发布逻辑
|
||||
// // 可以通过 RPC 调用 Task Service 或发送到消息队列
|
||||
// }
|
||||
@ -32,8 +32,8 @@ func NewLikeBetRevenueRepository(db *gorm.DB) LikeBetRevenueRepository {
|
||||
}
|
||||
|
||||
// BatchCreate 批量创建点赞押注收益记录
|
||||
// 若 (exhibition_id, like_id) 唯一约束冲突(cleanup_worker 重跑场景),返回 error 但不影响主流程
|
||||
// 调用方应在 CleanupWorker 中降级为 warn 日志
|
||||
// 依赖 uk_like_bet_unique(exhibition_id, like_id) 唯一约束 + ON CONFLICT DO NOTHING 实现幂等,
|
||||
// 结算重放/多路径触发时重复记录会被静默忽略,不影响主流程。
|
||||
func (r *likeBetRevenueRepository) BatchCreate(records []*model.LikeBetRevenueRecord) error {
|
||||
if len(records) == 0 {
|
||||
return nil
|
||||
|
||||
@ -7,6 +7,7 @@ import (
|
||||
"github.com/topfans/backend/services/taskService/model"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type RevenueRepository interface {
|
||||
@ -27,12 +28,36 @@ func NewRevenueRepository(db *gorm.DB) RevenueRepository {
|
||||
return &revenueRepository{db: db}
|
||||
}
|
||||
|
||||
// CreateRevenueRecord 幂等写入收益记录。
|
||||
// - (exhibition_id, cycle_start_time) 命中唯一约束 uk_exhibition_revenue_cycle 时,
|
||||
// ON CONFLICT DO NOTHING 静默忽略,返回既有记录的 ID(保证调用方
|
||||
// revenue_service.go:394/528/551 的 createdRecord.ID 始终有效)。
|
||||
// - CreatedAt 统一毫秒(settlement plan 批次 1.3)。
|
||||
func (r *revenueRepository) CreateRevenueRecord(record *model.ExhibitionRevenueRecord) (*model.ExhibitionRevenueRecord, error) {
|
||||
record.CreatedAt = time.Now().Unix()
|
||||
if err := r.db.Create(record).Error; err != nil {
|
||||
logger.Logger.Error("Failed to CreateRevenueRecord", zap.Int64("user_id", record.UserID), zap.Error(err))
|
||||
record.CreatedAt = time.Now().UnixMilli()
|
||||
res := r.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "exhibition_id"}, {Name: "cycle_start_time"}},
|
||||
DoNothing: true,
|
||||
}).Create(record)
|
||||
if res.Error != nil {
|
||||
logger.Logger.Error("Failed to CreateRevenueRecord", zap.Int64("user_id", record.UserID), zap.Error(res.Error))
|
||||
return nil, res.Error
|
||||
}
|
||||
// 冲突被忽略(RowsAffected==0 且 ID 未回填)时,回查既有记录,保证调用方拿到有效 ID
|
||||
if res.RowsAffected == 0 || record.ID == 0 {
|
||||
var existing model.ExhibitionRevenueRecord
|
||||
if err := r.db.Where("exhibition_id = ? AND cycle_start_time = ?", record.ExhibitionID, record.CycleStartTime).
|
||||
First(&existing).Error; err != nil {
|
||||
logger.Logger.Error("CreateRevenueRecord: conflict fallback lookup failed",
|
||||
zap.Int64("exhibition_id", record.ExhibitionID),
|
||||
zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
logger.Logger.Warn("CreateRevenueRecord: duplicate settle ignored, returning existing",
|
||||
zap.Int64("exhibition_id", record.ExhibitionID),
|
||||
zap.Int64("existing_id", existing.ID))
|
||||
return &existing, nil
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
@ -85,7 +110,7 @@ func (r *revenueRepository) ClaimRevenueRecord(id int64, userID int64) (bool, er
|
||||
now := time.Now().Unix()
|
||||
result := r.db.Model(&model.ExhibitionRevenueRecord{}).
|
||||
Where("id = ? AND user_id = ? AND status = ?", id, userID, "claimable").
|
||||
Updates(map[string]interface{}{
|
||||
Updates(map[string]any{
|
||||
"status": "claimed",
|
||||
"claimed_at": now,
|
||||
})
|
||||
|
||||
81
backend/services/taskService/repository/revenue_repo_test.go
Normal file
81
backend/services/taskService/repository/revenue_repo_test.go
Normal file
@ -0,0 +1,81 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/topfans/backend/services/taskService/model"
|
||||
)
|
||||
|
||||
// TestCreateRevenueRecord_Idempotent 验证 CreateRevenueRecord 在重复 (exhibition_id, cycle_start_time) 时幂等。
|
||||
//
|
||||
// 背景:settlement plan Task 1 已加唯一约束 uk_exhibition_revenue_cycle(exhibition_id, cycle_start_time)。
|
||||
// 这里要求 CreateRevenueRecord 用 ON CONFLICT DO NOTHING 命中时:
|
||||
// - 不返回 error
|
||||
// - 返回既有记录的 ID(保证 revenue_service.go:394/528/551 的 createdRecord.ID 可用)
|
||||
//
|
||||
// TestMain + setupTestDB 复用 like_bet_repo_test.go(同一 package)。
|
||||
func TestCreateRevenueRecord_Idempotent(t *testing.T) {
|
||||
db := setupTestDB(t) // 复用 like_bet_repo_test.go 的 setupTestDB
|
||||
repo := NewRevenueRepository(db)
|
||||
|
||||
exhibitionID := int64(999900001)
|
||||
defer func() {
|
||||
if err := db.Where("exhibition_id = ?", exhibitionID).Delete(&model.ExhibitionRevenueRecord{}).Error; err != nil {
|
||||
t.Logf("Warning: cleanup exhibition_revenue_records (exhibition_id=%d): %v", exhibitionID, err)
|
||||
}
|
||||
}()
|
||||
|
||||
cycleStart := time.Now().UnixMilli()
|
||||
rec := &model.ExhibitionRevenueRecord{
|
||||
UserID: -1,
|
||||
StarID: 87,
|
||||
ExhibitionID: exhibitionID,
|
||||
AssetID: 1,
|
||||
SlotID: 1,
|
||||
SlotOwnerUID: 1,
|
||||
SlotType: "exhibition",
|
||||
CrystalAmount: 10,
|
||||
CycleStartTime: cycleStart,
|
||||
CycleEndTime: cycleStart + 3600000,
|
||||
Status: "claimable",
|
||||
}
|
||||
|
||||
r1, err := repo.CreateRevenueRecord(rec)
|
||||
if err != nil {
|
||||
t.Fatalf("first CreateRevenueRecord failed: %v", err)
|
||||
}
|
||||
if r1 == nil || r1.ID == 0 {
|
||||
t.Fatalf("first CreateRevenueRecord returned invalid record: %+v", r1)
|
||||
}
|
||||
if r1.CreatedAt <= 0 {
|
||||
t.Errorf("first record created_at should be set (millis), got %d", r1.CreatedAt)
|
||||
}
|
||||
|
||||
// 同样的 (exhibition_id, cycle_start_time) 再插一次 —— 必须幂等
|
||||
rec2 := *rec
|
||||
r2, err := repo.CreateRevenueRecord(&rec2)
|
||||
if err != nil {
|
||||
t.Fatalf("second CreateRevenueRecord must not error on duplicate, got: %v", err)
|
||||
}
|
||||
if r2 == nil {
|
||||
t.Fatalf("second CreateRevenueRecord returned nil record")
|
||||
}
|
||||
if r1.ID != r2.ID {
|
||||
t.Fatalf("idempotent: want same id %d, got %d", r1.ID, r2.ID)
|
||||
}
|
||||
if r2.ID == 0 {
|
||||
t.Fatalf("idempotent path must return non-zero ID for callers (revenue_service.go:394/528/551), got 0")
|
||||
}
|
||||
|
||||
// 验证 DB 里只有 1 行
|
||||
var count int64
|
||||
if err := db.Model(&model.ExhibitionRevenueRecord{}).
|
||||
Where("exhibition_id = ? AND cycle_start_time = ?", exhibitionID, cycleStart).
|
||||
Count(&count).Error; err != nil {
|
||||
t.Fatalf("count after dup insert failed: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("expected exactly 1 row after duplicate insert, got %d", count)
|
||||
}
|
||||
}
|
||||
@ -38,9 +38,10 @@ type RevenueService interface {
|
||||
}
|
||||
|
||||
// AssetLevelService 资产等级服务接口(定义在assetService)
|
||||
// 批次1.2: AddExhibitionHours 增加 sourceID 参数做幂等(sourceID 由调用方提供,如 "exhibition_<id>")
|
||||
type AssetLevelService interface {
|
||||
GetOrCreateRecord(assetID int64) (*models.AssetLevelRecord, error)
|
||||
AddExhibitionHours(assetID int64, hours int) (string, bool, error)
|
||||
AddExhibitionHours(assetID int64, hours int, sourceID string) (string, bool, error)
|
||||
CalculateRevenue(assetID int64, likeCount int, startTime, endTime int64, revenueBoostBps int) (int64, error)
|
||||
}
|
||||
|
||||
@ -345,9 +346,12 @@ func (s *revenueService) ProcessExhibitionRevenue(ctx context.Context, params Pr
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// sourceID 用于资产/用户两级幂等去重 — 同 exhibition 下两级 sourceID 复用同一键,
|
||||
// 保证 cleanup_worker 重放时既不会双加 user hours 也不会双加 asset hours。
|
||||
sourceID := fmt.Sprintf("exhibition_%d", params.ExhibitionID)
|
||||
|
||||
// slot_owner 累计时长 — 失败仅日志不重试(已 created revenue record)
|
||||
if s.userRPCClient != nil && params.SlotOwnerUID > 0 {
|
||||
sourceID := fmt.Sprintf("exhibition_%d", params.ExhibitionID)
|
||||
if _, _, _, err := s.userRPCClient.AddExhibitionHours(
|
||||
ctx, params.SlotOwnerUID, params.OccupierStarID, actualHours, sourceID,
|
||||
); err != nil {
|
||||
@ -356,9 +360,9 @@ func (s *revenueService) ProcessExhibitionRevenue(ctx context.Context, params Pr
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
// asset 累计时长
|
||||
// asset 累计时长 — 批次1.2: 传 sourceID 做幂等(与 slot_owner 同 exhibition 共用同一键)
|
||||
if s.assetLevelService != nil && params.AssetID > 0 && actualHours > 0 {
|
||||
if _, upgraded, err := s.assetLevelService.AddExhibitionHours(params.AssetID, int(actualHours)); err != nil {
|
||||
if _, upgraded, err := s.assetLevelService.AddExhibitionHours(params.AssetID, int(actualHours), sourceID); err != nil {
|
||||
logger.Logger.Warn("asset AddExhibitionHours failed",
|
||||
zap.Int64("asset_id", params.AssetID),
|
||||
zap.Error(err))
|
||||
@ -508,9 +512,11 @@ func (s *revenueService) OnExhibitionCompleted(ctx context.Context, req *pb.OnEx
|
||||
zap.Int64("crystal_reward", crystalReward))
|
||||
}
|
||||
|
||||
// 增加资产累计展出时长(资产等级系统)
|
||||
// 增加资产累计展出时长(资产等级系统)—— 批次1.2: 传 sourceID 做幂等
|
||||
// 复用本函数上方已构造的 sourceID(L485),与 slot_owner(user 级)同 exhibition 共用同一键,
|
||||
// 保证重放/RPC 重试时用户级与资产级都不会双加。
|
||||
if s.assetLevelService != nil && req.AssetId > 0 && actualHours > 0 {
|
||||
if newLevel, upgraded, err := s.assetLevelService.AddExhibitionHours(req.AssetId, int(actualHours)); err != nil {
|
||||
if newLevel, upgraded, err := s.assetLevelService.AddExhibitionHours(req.AssetId, int(actualHours), sourceID); err != nil {
|
||||
logger.Logger.Warn("OnExhibitionCompleted: failed to add exhibition hours to asset level",
|
||||
zap.Int64("asset_id", req.AssetId),
|
||||
zap.Int64("hours", actualHours),
|
||||
|
||||
@ -0,0 +1,85 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/topfans/backend/pkg/models"
|
||||
)
|
||||
|
||||
// TestUpdateCrystalBalance_IdempotentBySourceID 验证同 (source_id, change_type) 第二次调用
|
||||
// 不二次扣费、不二次写流水,newBalance 与首次一致。
|
||||
//
|
||||
// 防止上游 RPC 重试或事务回滚后重放导致的重复扣费。
|
||||
// 仅清理本测试 sentinel 行,不调共享 cleanupTestDB 的大范围清理。
|
||||
func TestUpdateCrystalBalance_IdempotentBySourceID(t *testing.T) {
|
||||
db := openExhibitionHoursTestDB(t)
|
||||
|
||||
userRepo := NewUserRepository()
|
||||
hashedPassword, err := HashPassword("password123")
|
||||
if err != nil {
|
||||
t.Fatalf("hash password: %v", err)
|
||||
}
|
||||
user := &models.User{Mobile: "13800088001", PasswordHash: hashedPassword, IsActive: true}
|
||||
if err := userRepo.Create(user); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
|
||||
star := &models.Star{Name: "test_star_idem_crystal_1", IdentityID: "test_star_idem_crystal_1", IsActive: true}
|
||||
if err := db.Create(star).Error; err != nil {
|
||||
t.Fatalf("create star: %v", err)
|
||||
}
|
||||
|
||||
repo := NewFanProfileRepository()
|
||||
profile := &models.FanProfile{
|
||||
UserID: user.ID,
|
||||
StarID: star.StarID,
|
||||
Nickname: "test_nickname_idem_crystal_1",
|
||||
Level: 1,
|
||||
IsActive: true,
|
||||
CrystalBalance: 1000,
|
||||
}
|
||||
if err := repo.Create(profile); err != nil {
|
||||
t.Fatalf("create fan profile: %v", err)
|
||||
}
|
||||
|
||||
const sourceID = "test_crystal_idem_mint_001"
|
||||
const changeType = "mint_cost"
|
||||
|
||||
// 清理本次测试可能残留
|
||||
db.Exec("DELETE FROM crystal_transaction_records WHERE source_id = ?", sourceID)
|
||||
defer func() {
|
||||
db.Exec("DELETE FROM crystal_transaction_records WHERE source_id = ?", sourceID)
|
||||
db.Exec("DELETE FROM fan_profiles WHERE user_id = ?", user.ID)
|
||||
db.Exec("DELETE FROM users WHERE id = ?", user.ID)
|
||||
db.Exec("DELETE FROM stars WHERE identity_id = ?", "test_star_idem_crystal_1")
|
||||
}()
|
||||
|
||||
// 第一次调用:-100,余额 1000 → 900
|
||||
bal1, err := repo.UpdateCrystalBalance(user.ID, star.StarID, -100, changeType, sourceID, "test mint")
|
||||
if err != nil {
|
||||
t.Fatalf("first call: %v", err)
|
||||
}
|
||||
if bal1 != 900 {
|
||||
t.Errorf("first call: newBalance want 900, got %d", bal1)
|
||||
}
|
||||
|
||||
// 第二次同 sourceID+changeType —— 必须幂等,余额仍 900
|
||||
bal2, err := repo.UpdateCrystalBalance(user.ID, star.StarID, -100, changeType, sourceID, "test mint retry")
|
||||
if err != nil {
|
||||
t.Fatalf("second call: %v", err)
|
||||
}
|
||||
if bal2 != 900 {
|
||||
t.Errorf("second call: newBalance want 900 (idempotent), got %d", bal2)
|
||||
}
|
||||
|
||||
// 校验: 只写了一条流水
|
||||
var n int64
|
||||
if err := db.Model(&models.CrystalTransactionRecord{}).
|
||||
Where("user_id=? AND star_id=? AND source_id=? AND change_type=?", user.ID, star.StarID, sourceID, changeType).
|
||||
Count(&n).Error; err != nil {
|
||||
t.Fatalf("count crystal tx: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("tx rows want 1, got %d", n)
|
||||
}
|
||||
}
|
||||
@ -409,6 +409,32 @@ func (r *fanProfileRepository) UpdateCrystalBalance(userID, starID int64, delta
|
||||
return 0, errors.New("star_id must be greater than 0")
|
||||
}
|
||||
|
||||
// ★ 批次1.4 幂等: 同 (source_id, change_type) 已落账则直接返回当前余额。
|
||||
// 防止上游 RPC 重试或事务回滚后重放导致的重复扣费。
|
||||
if sourceID != "" {
|
||||
var existing models.CrystalTransactionRecord
|
||||
err := r.db.Where("source_id = ? AND change_type = ?", sourceID, changeType).
|
||||
Order("id DESC").First(&existing).Error
|
||||
if err == nil {
|
||||
// 已落账 — 取该 user 当前余额返,不二次扣。
|
||||
var profile models.FanProfile
|
||||
if err := r.db.Where("user_id=? AND star_id=?", userID, starID).
|
||||
First(&profile).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
logger.Logger.Info("UpdateCrystalBalance idempotent hit",
|
||||
zap.String("source_id", sourceID),
|
||||
zap.String("change_type", changeType),
|
||||
zap.Int64("user_id", userID),
|
||||
zap.Int64("balance", profile.CrystalBalance),
|
||||
)
|
||||
return profile.CrystalBalance, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
// 使用事务确保原子性
|
||||
var newBalance int64
|
||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||
@ -533,6 +559,12 @@ func GetLevelCap() int32 {
|
||||
// sourceID: 关联业务ID,用于升级奖励流水的溯源
|
||||
// 返回: newLevel, levelDelta, crystalReward, error
|
||||
func (r *fanProfileRepository) AddExhibitionHours(userID, starID int64, hours int64, sourceID string) (int32, int32, int64, error) {
|
||||
if sourceID == "" {
|
||||
// 旧契约兜底:sourceID 为空时退化为“尽力幂等”(之前已存在重复风险,本次按 defense 记日志并继续)
|
||||
logger.Logger.Warn("AddExhibitionHours called with empty sourceID, idempotency degraded",
|
||||
zap.Int64("user_id", userID), zap.Int64("star_id", starID))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
OldLevel int32
|
||||
NewLevel int32
|
||||
@ -540,6 +572,45 @@ func (r *fanProfileRepository) AddExhibitionHours(userID, starID int64, hours in
|
||||
}
|
||||
|
||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||
// 0. 幂等闸口:INSERT log, ON CONFLICT DO NOTHING;冲突即跳过本次累加
|
||||
if sourceID != "" {
|
||||
logRow := &models.ExhibitionHoursLog{
|
||||
SourceID: sourceID,
|
||||
UserID: userID,
|
||||
StarID: starID,
|
||||
Hours: hours,
|
||||
CreatedAt: time.Now().UnixMilli(),
|
||||
}
|
||||
res := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "source_id"}},
|
||||
DoNothing: true,
|
||||
}).Create(logRow)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
logger.Logger.Info("AddExhibitionHours: duplicate source_id, skip accumulate",
|
||||
zap.Int64("user_id", userID),
|
||||
zap.Int64("star_id", starID),
|
||||
zap.String("source_id", sourceID))
|
||||
|
||||
// 用一次 SELECT 读回当前等级,让调用方拿到 levelDelta=0。
|
||||
var fp models.FanProfile
|
||||
if err := tx.Select("level").Where("user_id = ? AND star_id = ?", userID, starID).First(&fp).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// 无 profile —— 等价于 old=new=1。
|
||||
result.OldLevel = 1
|
||||
result.NewLevel = 1
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
result.OldLevel = fp.Level
|
||||
result.NewLevel = fp.Level
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 获取或创建累计时长记录
|
||||
exhibitionHours, err := r.GetOrCreateExhibitionHours(tx, userID, starID)
|
||||
if err != nil {
|
||||
@ -550,7 +621,7 @@ func (r *fanProfileRepository) AddExhibitionHours(userID, starID int64, hours in
|
||||
now := time.Now().UnixMilli()
|
||||
if err := tx.Model(&models.UserExhibitionHours{}).
|
||||
Where("user_id = ? AND star_id = ?", userID, starID).
|
||||
Updates(map[string]interface{}{
|
||||
Updates(map[string]any{
|
||||
"total_exhibition_hours": gorm.Expr("total_exhibition_hours + ?", hours),
|
||||
"updated_at": now,
|
||||
}).Error; err != nil {
|
||||
|
||||
@ -1,9 +1,15 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/topfans/backend/pkg/database"
|
||||
"github.com/topfans/backend/pkg/logger"
|
||||
"github.com/topfans/backend/pkg/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestFanProfileRepository_Create(t *testing.T) {
|
||||
@ -490,3 +496,315 @@ func TestFanProfileRepository_GetByUserID_WithInactive(t *testing.T) {
|
||||
t.Errorf("Expected profile1, got profile with ID %d", profiles[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
// openExhibitionHoursTestDB opens a local DB connection dedicated to the
|
||||
// exhibition hours idempotency tests. It honors the TEST_DB_DSN environment
|
||||
// variable for CI overrides and skips the test when the connection fails so
|
||||
// the suite can run in environments without the local database.
|
||||
func openExhibitionHoursTestDB(t *testing.T) *gorm.DB {
|
||||
if logger.Logger == nil {
|
||||
if err := logger.Init(logger.Config{ServiceName: "user-service-test", Environment: "development", LogLevel: "error"}); err != nil {
|
||||
t.Fatalf("initialize logger: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 默认指向本地开发库;CI 通过 TEST_DB_DSN 覆盖。
|
||||
host := "localhost"
|
||||
port := 15432
|
||||
user := "postgres"
|
||||
password := "123456"
|
||||
dbName := "top-fans"
|
||||
if dsn := os.Getenv("TEST_DB_DSN"); dsn != "" {
|
||||
// 解析 TEST_DB_DSN,仅支持项目惯用的 key=value 空格分隔格式。
|
||||
values := parseTestDSN(dsn)
|
||||
if v, ok := values["host"]; ok {
|
||||
host = v
|
||||
}
|
||||
if v, ok := values["port"]; ok {
|
||||
if p, err := strconv.Atoi(v); err == nil {
|
||||
port = p
|
||||
}
|
||||
}
|
||||
if v, ok := values["user"]; ok {
|
||||
user = v
|
||||
}
|
||||
if v, ok := values["password"]; ok {
|
||||
password = v
|
||||
}
|
||||
if v, ok := values["dbname"]; ok {
|
||||
dbName = v
|
||||
}
|
||||
}
|
||||
|
||||
cfg := database.Config{
|
||||
Host: host,
|
||||
Port: port,
|
||||
User: user,
|
||||
Password: password,
|
||||
DBName: dbName,
|
||||
SSLMode: "disable",
|
||||
TimeZone: "Asia/Shanghai",
|
||||
}
|
||||
|
||||
if err := database.Init(cfg); err != nil {
|
||||
t.Skipf("Skipping test: failed to connect to test database: %v", err)
|
||||
}
|
||||
|
||||
return database.GetDB()
|
||||
}
|
||||
|
||||
// parseTestDSN 解析 key=value 空格分隔的 DSN。
|
||||
func parseTestDSN(dsn string) map[string]string {
|
||||
out := make(map[string]string)
|
||||
for _, kv := range strings.Fields(dsn) {
|
||||
parts := strings.SplitN(kv, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
out[parts[0]] = parts[1]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestAddExhibitionHours_Idempotent(t *testing.T) {
|
||||
db := openExhibitionHoursTestDB(t)
|
||||
|
||||
userRepo := NewUserRepository()
|
||||
hashedPassword, err := HashPassword("password123")
|
||||
if err != nil {
|
||||
t.Fatalf("hash password: %v", err)
|
||||
}
|
||||
user := &models.User{Mobile: "13800000091", PasswordHash: hashedPassword, IsActive: true}
|
||||
if err := userRepo.Create(user); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
|
||||
star := &models.Star{Name: "test_star_91", IdentityID: "test_star_91", IsActive: true}
|
||||
if err := db.Create(star).Error; err != nil {
|
||||
t.Fatalf("create star: %v", err)
|
||||
}
|
||||
|
||||
repo := NewFanProfileRepository()
|
||||
profile := &models.FanProfile{
|
||||
UserID: user.ID,
|
||||
StarID: star.StarID,
|
||||
Nickname: "test_nickname_91",
|
||||
Level: 1,
|
||||
IsActive: true,
|
||||
}
|
||||
if err := repo.Create(profile); err != nil {
|
||||
t.Fatalf("create fan profile: %v", err)
|
||||
}
|
||||
|
||||
srcID := "test_exhibition_hours_log_91_001"
|
||||
// 清理本次测试可能残留
|
||||
db.Exec("DELETE FROM exhibition_hours_log WHERE source_id = ?", srcID)
|
||||
db.Exec("DELETE FROM crystal_transaction_records WHERE source_id = ?", srcID)
|
||||
defer func() {
|
||||
db.Exec("DELETE FROM exhibition_hours_log WHERE source_id = ?", srcID)
|
||||
db.Exec("DELETE FROM crystal_transaction_records WHERE source_id = ?", srcID)
|
||||
db.Exec("DELETE FROM user_exhibition_hours WHERE user_id = ?", user.ID)
|
||||
db.Exec("DELETE FROM fan_profiles WHERE user_id = ?", user.ID)
|
||||
db.Exec("DELETE FROM users WHERE id = ?", user.ID)
|
||||
db.Exec("DELETE FROM stars WHERE identity_id = ?", "test_star_91")
|
||||
}()
|
||||
|
||||
// 首次调用应累加
|
||||
lvl1, delta1, reward1, err := repo.AddExhibitionHours(user.ID, star.StarID, 5, srcID)
|
||||
if err != nil {
|
||||
t.Fatalf("first AddExhibitionHours err: %v", err)
|
||||
}
|
||||
if delta1 < 0 {
|
||||
t.Fatalf("first call: levelDelta should be >= 0, got %d", delta1)
|
||||
}
|
||||
_ = reward1
|
||||
|
||||
// 第二次用相同 sourceID —— 应幂等,levelDelta=0, reward=0, 不再写 log
|
||||
lvl2, delta2, reward2, err := repo.AddExhibitionHours(user.ID, star.StarID, 5, srcID)
|
||||
if err != nil {
|
||||
t.Fatalf("second AddExhibitionHours err: %v", err)
|
||||
}
|
||||
if lvl1 != lvl2 {
|
||||
t.Errorf("want level unchanged, lvl1=%d lvl2=%d", lvl1, lvl2)
|
||||
}
|
||||
if delta2 != 0 {
|
||||
t.Errorf("want levelDelta=0 on dup, got %d", delta2)
|
||||
}
|
||||
if reward2 != 0 {
|
||||
t.Errorf("want crystalReward=0 on dup, got %d", reward2)
|
||||
}
|
||||
|
||||
// 验证 log 表只有 1 条
|
||||
var logCount int64
|
||||
if err := db.Model(&models.ExhibitionHoursLog{}).Where("source_id = ?", srcID).Count(&logCount).Error; err != nil {
|
||||
t.Fatalf("count exhibition hours log: %v", err)
|
||||
}
|
||||
if logCount != 1 {
|
||||
t.Errorf("want exactly 1 log row, got %d", logCount)
|
||||
}
|
||||
|
||||
// 验证 total_exhibition_hours 只 +5 一次
|
||||
var totalHours int64
|
||||
if err := db.Model(&models.UserExhibitionHours{}).
|
||||
Where("user_id = ? AND star_id = ?", user.ID, star.StarID).
|
||||
Select("total_exhibition_hours").Scan(&totalHours).Error; err != nil {
|
||||
t.Fatalf("read total exhibition hours: %v", err)
|
||||
}
|
||||
if totalHours != 5 {
|
||||
t.Errorf("want total=5, got %d", totalHours)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddExhibitionHours_Idempotent_LevelUp verifies that crossing a level
|
||||
// threshold triggers exactly one level-up bonus (crystal + like_bet_count +
|
||||
// crystal_transaction_records) and a repeated call with the same sourceID
|
||||
// does not duplicate rewards.
|
||||
func TestAddExhibitionHours_Idempotent_LevelUp(t *testing.T) {
|
||||
db := openExhibitionHoursTestDB(t)
|
||||
|
||||
userRepo := NewUserRepository()
|
||||
hashedPassword, err := HashPassword("password123")
|
||||
if err != nil {
|
||||
t.Fatalf("hash password: %v", err)
|
||||
}
|
||||
user := &models.User{Mobile: "13800000092", PasswordHash: hashedPassword, IsActive: true}
|
||||
if err := userRepo.Create(user); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
|
||||
star := &models.Star{Name: "test_star_92", IdentityID: "test_star_92", IsActive: true}
|
||||
if err := db.Create(star).Error; err != nil {
|
||||
t.Fatalf("create star: %v", err)
|
||||
}
|
||||
|
||||
repo := NewFanProfileRepository()
|
||||
profile := &models.FanProfile{
|
||||
UserID: user.ID,
|
||||
StarID: star.StarID,
|
||||
Nickname: "test_nickname_92",
|
||||
Level: 1,
|
||||
IsActive: true,
|
||||
}
|
||||
if err := repo.Create(profile); err != nil {
|
||||
t.Fatalf("create fan profile: %v", err)
|
||||
}
|
||||
|
||||
srcID := "test_exhibition_hours_log_92_001"
|
||||
db.Exec("DELETE FROM exhibition_hours_log WHERE source_id = ?", srcID)
|
||||
db.Exec("DELETE FROM crystal_transaction_records WHERE source_id = ?", srcID)
|
||||
defer func() {
|
||||
db.Exec("DELETE FROM exhibition_hours_log WHERE source_id = ?", srcID)
|
||||
db.Exec("DELETE FROM crystal_transaction_records WHERE source_id = ?", srcID)
|
||||
db.Exec("DELETE FROM user_exhibition_hours WHERE user_id = ?", user.ID)
|
||||
db.Exec("DELETE FROM fan_profiles WHERE user_id = ?", user.ID)
|
||||
db.Exec("DELETE FROM users WHERE id = ?", user.ID)
|
||||
db.Exec("DELETE FROM stars WHERE identity_id = ?", "test_star_92")
|
||||
}()
|
||||
|
||||
// 首次调用:跨过 L1→L2 阈值(level_thresholds L2.max_exhibition_hours=6),
|
||||
// 期望 level=2、delta=1、crystalReward>0、like_bet_count+1。
|
||||
lvl1, delta1, reward1, err := repo.AddExhibitionHours(user.ID, star.StarID, 6, srcID)
|
||||
if err != nil {
|
||||
t.Fatalf("first AddExhibitionHours err: %v", err)
|
||||
}
|
||||
if lvl1 != 2 {
|
||||
t.Errorf("first call: want level=2, got %d", lvl1)
|
||||
}
|
||||
if delta1 != 1 {
|
||||
t.Errorf("first call: want levelDelta=1, got %d", delta1)
|
||||
}
|
||||
if reward1 <= 0 {
|
||||
t.Errorf("first call: want crystalReward>0 on level-up, got %d", reward1)
|
||||
}
|
||||
|
||||
// 读取第一次后的余额、点赞押注次数、流水条数。
|
||||
var (
|
||||
balanceAfter1 int64
|
||||
likeBetAfter1 int32
|
||||
txCountAfter1 int64
|
||||
logCountAfter1 int64
|
||||
)
|
||||
if err := db.Model(&models.FanProfile{}).
|
||||
Select("crystal_balance", "like_bet_count").
|
||||
Where("user_id = ? AND star_id = ?", user.ID, star.StarID).
|
||||
Row().Scan(&balanceAfter1, &likeBetAfter1); err != nil {
|
||||
t.Fatalf("read fan profile after first call: %v", err)
|
||||
}
|
||||
if err := db.Model(&models.CrystalTransactionRecord{}).
|
||||
Where("source_id = ?", srcID).Count(&txCountAfter1).Error; err != nil {
|
||||
t.Fatalf("count crystal tx: %v", err)
|
||||
}
|
||||
if err := db.Model(&models.ExhibitionHoursLog{}).
|
||||
Where("source_id = ?", srcID).Count(&logCountAfter1).Error; err != nil {
|
||||
t.Fatalf("count exhibition hours log: %v", err)
|
||||
}
|
||||
if balanceAfter1 != reward1 {
|
||||
t.Errorf("first call: want crystal_balance=%d, got %d", reward1, balanceAfter1)
|
||||
}
|
||||
if likeBetAfter1 != 1 {
|
||||
t.Errorf("first call: want like_bet_count=1, got %d", likeBetAfter1)
|
||||
}
|
||||
if txCountAfter1 != 1 {
|
||||
t.Errorf("first call: want exactly 1 crystal tx, got %d", txCountAfter1)
|
||||
}
|
||||
if logCountAfter1 != 1 {
|
||||
t.Errorf("first call: want exactly 1 exhibition_hours_log, got %d", logCountAfter1)
|
||||
}
|
||||
|
||||
// 第二次用相同 sourceID —— 应幂等:余额、like_bet、流水、log 全部不变。
|
||||
lvl2, delta2, reward2, err := repo.AddExhibitionHours(user.ID, star.StarID, 6, srcID)
|
||||
if err != nil {
|
||||
t.Fatalf("second AddExhibitionHours err: %v", err)
|
||||
}
|
||||
if lvl2 != 2 {
|
||||
t.Errorf("second call: want level=2 (unchanged), got %d", lvl2)
|
||||
}
|
||||
if delta2 != 0 {
|
||||
t.Errorf("second call: want levelDelta=0, got %d", delta2)
|
||||
}
|
||||
if reward2 != 0 {
|
||||
t.Errorf("second call: want crystalReward=0 on dup, got %d", reward2)
|
||||
}
|
||||
|
||||
var (
|
||||
balanceAfter2 int64
|
||||
likeBetAfter2 int32
|
||||
txCountAfter2 int64
|
||||
logCountAfter2 int64
|
||||
totalAfter2 int64
|
||||
)
|
||||
if err := db.Model(&models.FanProfile{}).
|
||||
Select("crystal_balance", "like_bet_count").
|
||||
Where("user_id = ? AND star_id = ?", user.ID, star.StarID).
|
||||
Row().Scan(&balanceAfter2, &likeBetAfter2); err != nil {
|
||||
t.Fatalf("read fan profile after second call: %v", err)
|
||||
}
|
||||
if balanceAfter2 != balanceAfter1 {
|
||||
t.Errorf("second call: want crystal_balance unchanged at %d, got %d", balanceAfter1, balanceAfter2)
|
||||
}
|
||||
if likeBetAfter2 != likeBetAfter1 {
|
||||
t.Errorf("second call: want like_bet_count unchanged at %d, got %d", likeBetAfter1, likeBetAfter2)
|
||||
}
|
||||
if err := db.Model(&models.CrystalTransactionRecord{}).
|
||||
Where("source_id = ?", srcID).Count(&txCountAfter2).Error; err != nil {
|
||||
t.Fatalf("count crystal tx after second call: %v", err)
|
||||
}
|
||||
if txCountAfter2 != txCountAfter1 {
|
||||
t.Errorf("second call: want crystal tx count unchanged at %d, got %d", txCountAfter1, txCountAfter2)
|
||||
}
|
||||
if err := db.Model(&models.ExhibitionHoursLog{}).
|
||||
Where("source_id = ?", srcID).Count(&logCountAfter2).Error; err != nil {
|
||||
t.Fatalf("count exhibition hours log after second call: %v", err)
|
||||
}
|
||||
if logCountAfter2 != logCountAfter1 {
|
||||
t.Errorf("second call: want exhibition_hours_log count unchanged at %d, got %d", logCountAfter1, logCountAfter2)
|
||||
}
|
||||
if err := db.Model(&models.UserExhibitionHours{}).
|
||||
Where("user_id = ? AND star_id = ?", user.ID, star.StarID).
|
||||
Select("total_exhibition_hours").Scan(&totalAfter2).Error; err != nil {
|
||||
t.Fatalf("read total exhibition hours after second call: %v", err)
|
||||
}
|
||||
if totalAfter2 != 6 {
|
||||
t.Errorf("second call: want total_exhibition_hours=6, got %d", totalAfter2)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user