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:
zerosaturation 2026-07-23 01:11:54 +08:00
parent d026102a91
commit 878bd46399
27 changed files with 1821 additions and 473 deletions

View File

@ -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=

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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"

View 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" }

View 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
);

View File

@ -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

View File

@ -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=

View File

@ -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).

View File

@ -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) {

View File

@ -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)
}
}

View 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"))
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -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%')")

View File

@ -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))

View File

@ -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 或发送到消息队列
// }

View File

@ -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

View File

@ -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,
})

View 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)
}
}

View File

@ -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),

View File

@ -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)
}
}

View File

@ -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 {

View File

@ -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)
}
}