\n */\n public static function buildReleasePackages(int $leftAmountQf): array\n {\n $packages = [];\n $leftAmountQf = max($leftAmountQf, 0);\n\n foreach (self::RELEASE_TIER_STAGES as $stage) {\n $tierAmountQf = $stage['amount_qf'];\n $tierUnlockQf = $tierAmountQf - 1000;\n $tierCount = min($stage['max_count'], intdiv($leftAmountQf, $tierAmountQf));\n for ($i = 0; $i < $tierCount; $i++) {\n $packages[] = [\n 'amount_qf' => $tierAmountQf,\n 'unlock_recharge_qf' => $tierUnlockQf,\n ];\n $leftAmountQf -= $tierAmountQf;\n }\n }\n\n while ($leftAmountQf >= self::RELEASE_TIER_100_QF) {\n $packages[] = [\n 'amount_qf' => self::RELEASE_TIER_100_QF,\n 'unlock_recharge_qf' => self::RELEASE_TIER_100_QF - 1000,\n ];\n $leftAmountQf -= self::RELEASE_TIER_100_QF;\n }\n\n if ($leftAmountQf > 0) {\n $packages[] = [\n 'amount_qf' => $leftAmountQf,\n 'unlock_recharge_qf' => self::calcTailUnlockRechargeQf($leftAmountQf),\n ];\n }\n\n return $packages;\n }\n\n /**\n * @deprecated 旧均分规则,仅存量单测兼容;新定格请用 buildReleasePackages\n *\n * @return int[] 每档 amount_qf\n */\n public static function splitReleasePackageAmounts(int $leftAmount, int $packageAmount): array\n {\n $amounts = [];\n while ($leftAmount > 0) {\n $amounts[] = min($packageAmount, $leftAmount);\n $leftAmount -= $amounts[array_key_last($amounts)];\n }\n\n return $amounts;\n }"}},{"type":"tool_use","name":"StrReplace","input":{"path":"/Users/ray/Documents/project/www/slot/slot_console/app/api/logic/FreeCreditsLogic.php","old_string":" return Db::transaction(function () use ($config, $context, $frozenAmount, $firstCashAmount, $orderId, $firstStatus, $playerStatus) {\n $player = FreeCreditsPlayerModel::findByActivityAndUid($config->id, $context['uid']);\n if (is_null($player)) {\n $player = FreeCreditsPlayerModel::create([\n 'activity_id' => $config->id,\n 'uid' => $context['uid'],\n 'source' => $context['source'],\n 'home_withdraw_unlocked' => FreeCreditsPlayerModel::HOME_WITHDRAW_UNLOCKED,\n 'frozen_amount_qf' => $frozenAmount,\n 'first_cash_amount_qf' => $firstCashAmount,\n 'first_recharge_order_id' => $orderId,\n 'first_recharge_time' => date('Y-m-d H:i:s'),\n 'status' => $playerStatus,\n ]);\n } else {\n $player->save([\n 'home_withdraw_unlocked' => FreeCreditsPlayerModel::HOME_WITHDRAW_UNLOCKED,\n 'frozen_amount_qf' => $frozenAmount,\n 'first_cash_amount_qf' => $firstCashAmount,\n 'first_recharge_order_id' => $orderId,\n 'first_recharge_time' => date('Y-m-d H:i:s'),\n 'status' => $playerStatus,\n ]);\n }\n\n $this->createPackages($player, $firstCashAmount, max(0, $frozenAmount - $firstCashAmount), $firstStatus);\n\n return $player;\n });","new_string":" return Db::transaction(function () use (\n $config,\n $context,\n $frozenAmount,\n $firstCashAmount,\n $orderId,\n $firstStatus,\n $playerStatus,\n $totalRechargeAfterFreeze\n ) {\n $player = FreeCreditsPlayerModel::findByActivityAndUid($config->id, $context['uid']);\n $playerFields = [\n 'home_withdraw_unlocked' => FreeCreditsPlayerModel::HOME_WITHDRAW_UNLOCKED,\n 'frozen_amount_qf' => $frozenAmount,\n 'first_cash_amount_qf' => $firstCashAmount,\n 'first_recharge_order_id' => $orderId,\n 'first_recharge_time' => date('Y-m-d H:i:s'),\n 'status' => $playerStatus,\n 'recharge_baseline_qf' => $totalRechargeAfterFreeze,\n ];\n if (is_null($player)) {\n $player = FreeCreditsPlayerModel::create(array_merge([\n 'activity_id' => $config->id,\n 'uid' => $context['uid'],\n 'source' => $context['source'],\n ], $playerFields));\n } else {\n $player->save($playerFields);\n }\n\n $this->createPackages($player, $firstCashAmount, max(0, $frozenAmount - $firstCashAmount), $firstStatus);\n\n return $player;\n });"}},{"type":"tool_use","name":"StrReplace","input":{"path":"/Users/ray/Documents/project/www/slot/slot_console/app/api/logic/FreeCreditsLogic.php","old_string":" $packageAmount = $this->configAmountByActivityId($player->activity_id, 'package_amount', self::DEFAULT_PACKAGE_AMOUNT);\n $packageNo = 2;\n foreach (self::splitReleasePackageAmounts($leftAmount, $packageAmount) as $amount) {\n FreeCreditsPackageModel::create([\n 'player_id' => $player->id,\n 'activity_id' => $player->activity_id,\n 'uid' => $player->uid,\n 'package_no' => $packageNo,\n 'package_type' => FreeCreditsPackageModel::TYPE_RELEASE,\n 'amount_qf' => $amount,\n 'status' => FreeCreditsPackageModel::STATUS_LOCKED,\n ]);\n $packageNo++;\n }\n }","new_string":" $packageNo = 2;\n foreach (self::buildReleasePackages($leftAmount) as $releasePackage) {\n FreeCreditsPackageModel::create([\n 'player_id' => $player->id,\n 'activity_id' => $player->activity_id,\n 'uid' => $player->uid,\n 'package_no' => $packageNo,\n 'package_type' => FreeCreditsPackageModel::TYPE_RELEASE,\n 'amount_qf' => $releasePackage['amount_qf'],\n 'unlock_recharge_qf' => $releasePackage['unlock_recharge_qf'],\n 'status' => FreeCreditsPackageModel::STATUS_LOCKED,\n ]);\n $packageNo++;\n }\n }"}},{"type":"tool_use","name":"StrReplace","input":{"path":"/Users/ray/Documents/project/www/slot/slot_console/app/api/logic/FreeCreditsLogic.php","old_string":" private function advanceByRecharge(FreeCreditsPlayerModel $player, Model $config, int $totalRecharge, int $rechargeAmount): void\n {\n $firstPackage = FreeCreditsPackageModel::firstPackage($player->id);\n if (!is_null($firstPackage) && intval($firstPackage->status) === FreeCreditsPackageModel::STATUS_LOCKED) {\n $threshold = $this->configAmount($config, 'recharge_unlock_amount', self::DEFAULT_RECHARGE_UNLOCK);\n if ($totalRecharge >= $threshold) {\n $firstPackage->save([\n 'status' => FreeCreditsPackageModel::STATUS_READY,\n 'unlocked_time' => date('Y-m-d H:i:s'),\n ]);\n $player->save(['status' => FreeCreditsPlayerModel::STATUS_FIRST_CASH_READY]);\n }\n return;\n }\n\n if (is_null($firstPackage)) {\n return;\n }\n\n $minRecharge = $this->configAmount($config, 'subsequent_min_recharge', self::DEFAULT_SUBSEQUENT_MIN_RECHARGE);\n if ($rechargeAmount < $minRecharge) {\n return;\n }\n\n $maxUnlock = intval($this->configValue($config, 'max_unlock_per_recharge', self::DEFAULT_MAX_UNLOCK_PER_RECHARGE));\n for ($i = 0; $i < $maxUnlock; $i++) {\n $next = FreeCreditsPackageModel::nextLockedReleasePackage($player->id);\n if (is_null($next)) {\n break;\n }\n $next->save([\n 'status' => FreeCreditsPackageModel::STATUS_READY,\n 'unlocked_time' => date('Y-m-d H:i:s'),\n ]);\n }\n }","new_string":" private function advanceByRecharge(FreeCreditsPlayerModel $player, Model $config, int $totalRecharge, int $rechargeAmount): void\n {\n $firstPackage = FreeCreditsPackageModel::firstPackage($player->id);\n if (!is_null($firstPackage) && intval($firstPackage->status) === FreeCreditsPackageModel::STATUS_LOCKED) {\n $threshold = $this->configAmount($config, 'recharge_unlock_amount', self::DEFAULT_RECHARGE_UNLOCK);\n if ($totalRecharge >= $threshold) {\n $firstPackage->save([\n 'status' => FreeCreditsPackageModel::STATUS_READY,\n 'unlocked_time' => date('Y-m-d H:i:s'),\n ]);\n $player->save([\n 'status' => FreeCreditsPlayerModel::STATUS_FIRST_CASH_READY,\n 'recharge_baseline_qf' => $totalRecharge,\n ]);\n }\n\n return;\n }\n\n if (is_null($firstPackage)) {\n return;\n }\n\n $legacyMinRecharge = $this->configAmount($config, 'subsequent_min_recharge', self::DEFAULT_SUBSEQUENT_MIN_RECHARGE);\n $maxUnlock = intval($this->configValue($config, 'max_unlock_per_recharge', self::DEFAULT_MAX_UNLOCK_PER_RECHARGE));\n for ($i = 0; $i < $maxUnlock; $i++) {\n $nextReleasePackage = FreeCreditsPackageModel::nextLockedReleasePackage($player->id);\n if (is_null($nextReleasePackage)) {\n break;\n }\n\n if (!$this->tryUnlockReleasePackageByRecharge(\n $player,\n $nextReleasePackage,\n $totalRecharge,\n $rechargeAmount,\n $legacyMinRecharge\n )) {\n break;\n }\n }\n }\n\n /**\n * 按本档新增累计充值解锁后续 release 档;unlock_recharge_qf=0 时回退旧单笔门槛逻辑。\n */\n private function tryUnlockReleasePackageByRecharge(\n FreeCreditsPlayerModel $player,\n FreeCreditsPackageModel $releasePackage,\n int $totalRecharge,\n int $rechargeAmount,\n int $legacyMinRechargeQf\n ): bool {\n $unlockRechargeQf = intval($releasePackage->unlock_recharge_qf);\n if ($unlockRechargeQf <= 0) {\n if ($rechargeAmount < $legacyMinRechargeQf) {\n return false;\n }\n } else {\n $incrementalRechargeQf = $totalRecharge - intval($player->recharge_baseline_qf);\n if ($incrementalRechargeQf < $unlockRechargeQf) {\n return false;\n }\n }\n\n $releasePackage->save([\n 'status' => FreeCreditsPackageModel::STATUS_READY,\n 'unlocked_time' => date('Y-m-d H:i:s'),\n ]);\n $player->save(['recharge_baseline_qf' => $totalRecharge]);\n\n return true;\n }"}}]}}
{"role":"assistant","message":{"content":[{"type":"text","text":"[REDACTED]"},{"type":"tool_use","name":"Write","input":{"path":"/Users/ray/Documents/project/www/slot/slot_console/db/migrate_free_credits_release_tiers.sql","contents":"-- Free Credits:后续档瀑布拆分 + 每档新增累计充值解锁\n-- 已建表环境执行;新装见 install.sql\n\nALTER TABLE s_common.free_credits_package\n ADD COLUMN unlock_recharge_qf bigint unsigned NOT NULL DEFAULT 0\n COMMENT '解锁本档所需新增累计真实充值(千分位),自上一档解锁后起算'\n AFTER amount_qf;\n\nALTER TABLE s_common.free_credits_player\n ADD COLUMN recharge_baseline_qf bigint unsigned NOT NULL DEFAULT 0\n COMMENT '后续档计数起点:wallet.totalRecharge 快照(千分位)'\n AFTER first_cash_amount_qf;\n"}},{"type":"tool_use","name":"StrReplace","input":{"path":"/Users/ray/Documents/project/www/slot/slot_console/db/install.sql","old_string":" first_cash_amount_qf bigint unsigned default '0' not null comment '第一档免打码提现金额,千分位',\n first_recharge_order_id varchar(64) default '' not null comment '触发定格的首笔充值订单号',","new_string":" first_cash_amount_qf bigint unsigned default '0' not null comment '第一档免打码提现金额,千分位',\n recharge_baseline_qf bigint unsigned default '0' not null comment '后续档计数起点:wallet.totalRecharge快照,千分位',\n first_recharge_order_id varchar(64) default '' not null comment '触发定格的首笔充值订单号',"}},{"type":"tool_use","name":"StrReplace","input":{"path":"/Users/ray/Documents/project/www/slot/slot_console/db/install.sql","old_string":" amount_qf bigint unsigned default '0' not null comment '档位金额,千分位',\n status tinyint unsigned default '0' not null comment '档位状态 0锁定 1可操作 2处理中 3已完成 4失败 5风控拒绝',","new_string":" amount_qf bigint unsigned default '0' not null comment '档位金额,千分位',\n unlock_recharge_qf bigint unsigned default '0' not null comment '解锁本档所需新增累计真实充值,千分位',\n status tinyint unsigned default '0' not null comment '档位状态 0锁定 1可操作 2处理中 3已完成 4失败 5风控拒绝',"}},{"type":"tool_use","name":"StrReplace","input":{"path":"/Users/ray/Documents/project/www/slot/slot_console/app/model/common/FreeCreditsPlayerModel.php","old_string":" * @property int $first_cash_amount_qf 第一档金额,千分位\n * @property string $first_recharge_order_id","new_string":" * @property int $first_cash_amount_qf 第一档金额,千分位\n * @property int $recharge_baseline_qf 后续档新增累计充值计数起点(wallet.totalRecharge 快照),千分位\n * @property string $first_recharge_order_id"}},{"type":"tool_use","name":"StrReplace","input":{"path":"/Users/ray/Documents/project/www/slot/slot_console/app/model/common/FreeCreditsPackageModel.php","old_string":" * @property int $amount_qf 档位金额,千分位\n * @property int $status 见 STATUS_*","new_string":" * @property int $amount_qf 档位金额,千分位\n * @property int $unlock_recharge_qf 解锁本档所需新增累计充值,千分位;0 表示存量旧规则\n * @property int $status 见 STATUS_*"}},{"type":"tool_use","name":"TodoWrite","input":{"todos":[{"id":"split-algorithm","content":"实现 buildReleasePackages 瀑布拆分,替换 splitReleasePackageAmounts + createPackages","status":"completed"},{"id":"db-unlock-qf","content":"package 增 unlock_recharge_qf;player 增 recharge_baseline_qf;迁移脚本","status":"completed"},{"id":"advance-unlock","content":"advanceByRecharge 用 (totalR−baseline)≥unlock 逐档解锁并重置 baseline","status":"completed"},{"id":"admin-config","content":"废弃 package_amount/subsequent_min 后台必填;更新 ActivityValidate","status":"in_progress"}],"merge":true}},{"type":"tool_use","name":"Read","input":{"path":"/Users/ray/Documents/project/www/slot/backend/slot_admin/app/game/validate/ActivityValidate.php","offset":70,"limit":35}}]}}
{"role":"assistant","message":{"content":[{"type":"text","text":"[REDACTED]"},{"type":"tool_use","name":"Grep","input":{"pattern":"checkFreeCreditsExt","path":"/Users/ray/Documents/project/www/slot"}},{"type":"tool_use","name":"Read","input":{"path":"/Users/ray/Documents/project/www/slot/backend/slot_admin_vue/src/views/game/activity/edit.vue","offset":125,"limit":30}},{"type":"tool_use","name":"Read","input":{"path":"/Users/ray/Documents/project/www/slot/backend/slot_admin_vue/src/views/game/activity/index.vue","offset":88,"limit":15}}]}}
{"role":"assistant","message":{"content":[{"type":"text","text":"[REDACTED]"},{"type":"tool_use","name":"StrReplace","input":{"path":"/Users/ray/Documents/project/www/slot/backend/slot_admin/app/game/validate/ActivityValidate.php","old_string":" $required = [\n 'win_threshold_qf' => '首笔赢取门槛',\n 'recharge_unlock_amount_qf' => '充值解锁门槛',\n 'first_cash_amount_qf' => '免打码提现额',\n 'package_amount_qf' => '解锁拆分金额',\n 'subsequent_min_recharge_qf' => '后续解锁最小充值',\n 'max_unlock_per_recharge' => '每笔最多解锁档数',\n ];","new_string":" $required = [\n 'win_threshold_qf' => '首笔赢取门槛',\n 'recharge_unlock_amount_qf' => '充值解锁门槛',\n 'first_cash_amount_qf' => '免打码提现额',\n 'max_unlock_per_recharge' => '每笔最多解锁档数',\n ];\n $optionalNonNegative = [\n 'package_amount_qf' => '解锁拆分金额',\n 'subsequent_min_recharge_qf' => '后续解锁最小充值',\n ];"}},{"type":"tool_use","name":"StrReplace","input":{"path":"/Users/ray/Documents/project/www/slot/backend/slot_admin/app/game/validate/ActivityValidate.php","old_string":" foreach ($required as $key => $label) {\n if (!isset($extConfig[$key]) || $extConfig[$key] === '' || (int)$extConfig[$key] < 0) {\n throw new ValidateException(\"{$label}必须填写且为非负数\");\n }\n }","new_string":" foreach ($required as $key => $label) {\n if (!isset($extConfig[$key]) || $extConfig[$key] === '' || (int)$extConfig[$key] < 0) {\n throw new ValidateException(\"{$label}必须填写且为非负数\");\n }\n }\n foreach ($optionalNonNegative as $key => $label) {\n if (!isset($extConfig[$key]) || $extConfig[$key] === '') {\n continue;\n }\n if ((int)$extConfig[$key] < 0) {\n throw new ValidateException(\"{$label}必须为非负数\");\n }\n }"}},{"type":"tool_use","name":"StrReplace","input":{"path":"/Users/ray/Documents/project/www/slot/backend/slot_admin_vue/src/views/game/activity/edit.vue","old_string":" \n \n \n \n \n ","new_string":" \n \n \n \n \n "}},{"type":"tool_use","name":"StrReplace","input":{"path":"/Users/ray/Documents/project/www/slot/backend/slot_admin_vue/src/views/game/activity/index.vue","old_string":" 每档金额: ${{ formatExtAmount(record.ext_config.package_amount_qf, record.ext_config.package_amount) }}
\n 解锁最小充值: ${{ formatExtAmount(record.ext_config.subsequent_min_recharge_qf, record.ext_config.subsequent_min_recharge) }}
","new_string":" 分档规则: 10/20/30/50/100 瀑布(新定格)
\n \n 旧每档金额(废弃): ${{ formatExtAmount(record.ext_config.package_amount_qf, record.ext_config.package_amount) }}\n
"}}]}}
{"role":"assistant","message":{"content":[{"type":"text","text":"[REDACTED]"},{"type":"tool_use","name":"Write","input":{"path":"/Users/ray/Documents/project/www/slot/slot_console/tests/Unit/FreeCreditsLogicAmountTest.php","contents":"assertSame($expected, FreeCreditsLogic::calcFrozenAmount($balanceAfter, $recharge));\n }\n\n public static function frozenAmountProvider(): array\n {\n return [\n '首充前余额 100,入账 30' => [100, 30, 70],\n '入账大于余额' => [50, 80, 0],\n '入账为 0' => [100, 0, 100],\n ];\n }\n\n #[DataProvider('firstCashAmountProvider')]\n public function testCalcFirstCashAmount(int $frozen, int $configured, int $expected): void\n {\n $this->assertSame($expected, FreeCreditsLogic::calcFirstCashAmount($frozen, $configured));\n }\n\n public static function firstCashAmountProvider(): array\n {\n return [\n '定格小于配置仍取配置' => [15, 20, 20],\n '定格大于配置' => [100, 20, 20],\n '相等' => [50, 50, 50],\n '配置为 0 回退定格额' => [30, 0, 30],\n ];\n }\n\n public function testCalcTailUnlockRechargeQfFloorsToWholeDollar(): void\n {\n $this->assertSame(15000, FreeCreditsLogic::calcTailUnlockRechargeQf(15340));\n $this->assertSame(8000, FreeCreditsLogic::calcTailUnlockRechargeQf(8500));\n $this->assertSame(10000, FreeCreditsLogic::calcTailUnlockRechargeQf(10000));\n }\n\n #[DataProvider('buildReleasePackagesProvider')]\n public function testBuildReleasePackages(int $leftQf, array $expectedAmounts, array $expectedUnlocks): void\n {\n $packages = FreeCreditsLogic::buildReleasePackages($leftQf);\n $this->assertSame($expectedAmounts, array_column($packages, 'amount_qf'));\n $this->assertSame($expectedUnlocks, array_column($packages, 'unlock_recharge_qf'));\n $this->assertSame($leftQf, array_sum($expectedAmounts));\n }\n\n public static function buildReleasePackagesProvider(): array\n {\n return [\n '剩余 58500 → 3×10 + 20 + 尾档 8.5' => [\n 58500,\n [10000, 10000, 10000, 20000, 8500],\n [9000, 9000, 9000, 19000, 8000],\n ],\n '剩余 0 → 空' => [\n 0,\n [],\n [],\n ],\n '剩余 7 → 尾档' => [\n 7000,\n [7000],\n [7000],\n ],\n '尾档取整 unlock' => [\n 15340,\n [15340],\n [15000],\n ],\n '阶段 5 整百' => [\n 250000,\n array_merge(\n [10000, 10000, 10000],\n [20000, 20000, 20000, 20000, 20000],\n [30000, 30000, 30000, 30000, 30000, 30000, 30000, 30000, 30000, 30000],\n [50000, 50000, 50000, 50000, 50000, 50000, 50000, 50000, 50000, 50000],\n [100000, 100000]\n ),\n array_merge(\n array_fill(0, 3, 9000),\n array_fill(0, 5, 19000),\n array_fill(0, 10, 29000),\n array_fill(0, 10, 49000),\n [99000, 99000]\n ),\n ],\n ];\n }\n\n public function testBuildReleasePackagesSumEqualsLeft(): void\n {\n $leftQf = 73;\n $packages = FreeCreditsLogic::buildReleasePackages($leftQf);\n $this->assertSame($leftQf, array_sum(array_column($packages, 'amount_qf')));\n foreach ($packages as $package) {\n $this->assertGreaterThan(0, $package['amount_qf']);\n $this->assertGreaterThan(0, $package['unlock_recharge_qf']);\n $this->assertLessThanOrEqual($package['amount_qf'], $package['unlock_recharge_qf'] + 1000);\n }\n }\n\n /** @deprecated 旧均分规则保留兼容 */\n public function testSplitReleasePackageAmountsLegacy(): void\n {\n $this->assertSame([10, 10, 5], FreeCreditsLogic::splitReleasePackageAmounts(25, 10));\n }\n}\n"}},{"type":"tool_use","name":"Write","input":{"path":"/Users/ray/Documents/project/www/slot/slot_console/tests/Unit/FreeCreditsAdvanceUnlockTest.php","contents":"newPlayer(rechargeBaselineQf: 41000);\n $releasePackage = $this->newReleasePackage(unlockRechargeQf: 9000, status: FreeCreditsPackageModel::STATUS_LOCKED);\n\n $harness = $this->harnessWithPlayer($player, totalRechargeQf: 50000);\n $harness->advanceAfterRecharge(self::TEST_UID, 9000);\n\n $this->assertSame(FreeCreditsPackageModel::STATUS_READY, (int) $releasePackage->status);\n $this->assertSame(50000, (int) $player->recharge_baseline_qf);\n }\n\n public function testDoesNotUnlockWhenIncrementalBelowThreshold(): void\n {\n $player = $this->newPlayer(rechargeBaselineQf: 41000);\n $releasePackage = $this->newReleasePackage(unlockRechargeQf: 9000, status: FreeCreditsPackageModel::STATUS_LOCKED);\n\n $harness = $this->harnessWithPlayer($player, totalRechargeQf: 48000);\n $harness->advanceAfterRecharge(self::TEST_UID, 7000);\n\n $this->assertSame(FreeCreditsPackageModel::STATUS_LOCKED, (int) $releasePackage->status);\n $this->assertSame(41000, (int) $player->recharge_baseline_qf);\n }\n\n public function testSecondTierRequiresNewIncrementalAfterBaselineReset(): void\n {\n $player = $this->newPlayer(rechargeBaselineQf: 50000);\n $firstRelease = $this->newReleasePackage(id: 101, packageNo: 2, unlockRechargeQf: 9000, status: FreeCreditsPackageModel::STATUS_READY);\n $secondRelease = $this->newReleasePackage(id: 102, packageNo: 3, unlockRechargeQf: 9000, status: FreeCreditsPackageModel::STATUS_LOCKED);\n\n $harness = $this->harnessWithPlayer($player, totalRechargeQf: 59000, nextLockedPackage: $secondRelease);\n $harness->advanceAfterRecharge(self::TEST_UID, 9000);\n\n $this->assertSame(FreeCreditsPackageModel::STATUS_READY, (int) $secondRelease->status);\n $this->assertSame(59000, (int) $player->recharge_baseline_qf);\n $this->assertSame(FreeCreditsPackageModel::STATUS_READY, (int) $firstRelease->status);\n }\n\n public function testLegacyPackageUsesSingleRechargeMinWhenUnlockQfIsZero(): void\n {\n $player = $this->newPlayer(rechargeBaselineQf: 0);\n $legacyPackage = $this->newReleasePackage(unlockRechargeQf: 0, status: FreeCreditsPackageModel::STATUS_LOCKED);\n\n $harness = $this->harnessWithPlayer($player, totalRechargeQf: 20000);\n $harness->advanceAfterRecharge(self::TEST_UID, 10000);\n\n $this->assertSame(FreeCreditsPackageModel::STATUS_READY, (int) $legacyPackage->status);\n }\n\n public function testFirstCashUnlockResetsBaseline(): void\n {\n $player = $this->newPlayer(rechargeBaselineQf: 5000);\n $firstCash = $this->newFirstCashPackage(status: FreeCreditsPackageModel::STATUS_LOCKED);\n\n $harness = $this->harnessWithPlayer($player, totalRechargeQf: 50000);\n $harness->advanceAfterRecharge(self::TEST_UID, 45000);\n\n $this->assertSame(FreeCreditsPackageModel::STATUS_READY, (int) $firstCash->status);\n $this->assertSame(50000, (int) $player->recharge_baseline_qf);\n $this->assertSame(FreeCreditsPlayerModel::STATUS_FIRST_CASH_READY, (int) $player->status);\n }\n\n private function harnessWithPlayer(\n FreeCreditsPlayerModel $player,\n int $totalRechargeQf,\n ?FreeCreditsPackageModel $nextLockedPackage = null\n ): FreeCreditsLogicHarness {\n $wallet = RecordingWalletService::withWallet(new Wallet([\n 'balance' => 0,\n 'r' => $totalRechargeQf,\n ]));\n\n $harness = new class($player, $nextLockedPackage) extends FreeCreditsLogicHarness {\n public function __construct(\n private FreeCreditsPlayerModel $playerModel,\n private ?FreeCreditsPackageModel $nextLocked\n ) {\n }\n\n protected function findFreeCreditsPlayer(int $activityId, int $uid): ?FreeCreditsPlayerModel\n {\n return $this->playerModel;\n }\n\n protected function advanceByRecharge(\n FreeCreditsPlayerModel $player,\n \\think\\Model $config,\n int $totalRecharge,\n int $rechargeAmount\n ): void {\n $firstPackage = FreeCreditsPackageModel::firstPackage($player->id);\n if (!is_null($firstPackage) && (int) $firstPackage->status === FreeCreditsPackageModel::STATUS_LOCKED) {\n parent::advanceByRecharge($player, $config, $totalRecharge, $rechargeAmount);\n\n return;\n }\n\n $reflection = new \\ReflectionClass(FreeCreditsLogic::class);\n $method = $reflection->getMethod('tryUnlockReleasePackageByRecharge');\n $method->setAccessible(true);\n\n $logic = new FreeCreditsLogic();\n $config = RechargeGiftConfigModel::find(self::TEST_ACTIVITY_ID) ?? FreeCreditsConfigStub::freeCreditsConfig();\n $legacyMin = 10000;\n $target = $this->nextLocked ?? FreeCreditsPackageModel::nextLockedReleasePackage($player->id);\n if (!is_null($target)) {\n $method->invoke($logic, $player, $target, $totalRecharge, $rechargeAmount, $legacyMin);\n }\n }\n };\n\n $config = FreeCreditsConfigStub::freeCreditsConfig();\n $harness->inject(\n [\n 'uid' => self::TEST_UID,\n 'currency' => 'INR',\n 'source' => 'all',\n 'organization' => 1,\n 'model_id' => 1,\n ],\n $config,\n $wallet\n );\n $harness->injectPlayer($player);\n\n return $harness;\n }\n\n private function newPlayer(int $rechargeBaselineQf): FreeCreditsPlayerModel\n {\n $player = new FreeCreditsPlayerModel();\n $player->setAttr('id', 1);\n $player->setAttr('activity_id', self::TEST_ACTIVITY_ID);\n $player->setAttr('uid', self::TEST_UID);\n $player->setAttr('recharge_baseline_qf', $rechargeBaselineQf);\n $player->setAttr('status', FreeCreditsPlayerModel::STATUS_FIRST_CASH_DONE);\n\n return $player;\n }\n\n private function newFirstCashPackage(int $status): FreeCreditsPackageModel\n {\n $package = new FreeCreditsPackageModel();\n $package->setAttr('id', 100);\n $package->setAttr('player_id', 1);\n $package->setAttr('package_no', 1);\n $package->setAttr('package_type', FreeCreditsPackageModel::TYPE_FIRST_CASH);\n $package->setAttr('status', $status);\n\n return $package;\n }\n\n private function newReleasePackage(\n int $id = 200,\n int $packageNo = 2,\n int $unlockRechargeQf = 9000,\n int $status = FreeCreditsPackageModel::STATUS_LOCKED\n ): FreeCreditsPackageModel {\n $package = new FreeCreditsPackageModel();\n $package->setAttr('id', $id);\n $package->setAttr('player_id', 1);\n $package->setAttr('package_no', $packageNo);\n $package->setAttr('package_type', FreeCreditsPackageModel::TYPE_RELEASE);\n $package->setAttr('unlock_recharge_qf', $unlockRechargeQf);\n $package->setAttr('status', $status);\n\n return $package;\n }\n}\n"}}]}}
{"role":"assistant","message":{"content":[{"type":"text","text":"简化解锁单测,改为通过反射直接测试核心方法:\n\n[REDACTED]"},{"type":"tool_use","name":"Write","input":{"path":"/Users/ray/Documents/project/www/slot/slot_console/tests/Unit/FreeCreditsAdvanceUnlockTest.php","contents":"playerWithBaseline(41000);\n $package = $this->lockedReleasePackage(9000);\n\n $unlocked = $this->invokeTryUnlock($player, $package, totalRecharge: 50000, rechargeAmount: 9000);\n\n $this->assertTrue($unlocked);\n $this->assertSame(FreeCreditsPackageModel::STATUS_READY, (int) $package->status);\n $this->assertSame(50000, (int) $player->recharge_baseline_qf);\n }\n\n public function testDoesNotUnlockWhenIncrementalBelowThreshold(): void\n {\n $player = $this->playerWithBaseline(41000);\n $package = $this->lockedReleasePackage(9000);\n\n $unlocked = $this->invokeTryUnlock($player, $package, totalRecharge: 48000, rechargeAmount: 7000);\n\n $this->assertFalse($unlocked);\n $this->assertSame(FreeCreditsPackageModel::STATUS_LOCKED, (int) $package->status);\n $this->assertSame(41000, (int) $player->recharge_baseline_qf);\n }\n\n public function testTailTierUnlockUsesFlooredWholeDollarThreshold(): void\n {\n $player = $this->playerWithBaseline(100000);\n $package = $this->lockedReleasePackage(15000);\n\n $unlocked = $this->invokeTryUnlock($player, $package, totalRecharge: 115000, rechargeAmount: 15000);\n\n $this->assertTrue($unlocked);\n }\n\n public function testLegacyPackageUnlocksBySingleRechargeMin(): void\n {\n $player = $this->playerWithBaseline(0);\n $package = $this->lockedReleasePackage(0);\n\n $unlocked = $this->invokeTryUnlock($player, $package, totalRecharge: 20000, rechargeAmount: 10000, legacyMin: 10000);\n\n $this->assertTrue($unlocked);\n }\n\n public function testLegacyPackageDoesNotUnlockWhenSingleRechargeTooSmall(): void\n {\n $player = $this->playerWithBaseline(0);\n $package = $this->lockedReleasePackage(0);\n\n $unlocked = $this->invokeTryUnlock($player, $package, totalRecharge: 20000, rechargeAmount: 5000, legacyMin: 10000);\n\n $this->assertFalse($unlocked);\n }\n\n private function invokeTryUnlock(\n FreeCreditsPlayerModel $player,\n FreeCreditsPackageModel $package,\n int $totalRecharge,\n int $rechargeAmount,\n int $legacyMin = 10000\n ): bool {\n $logic = new FreeCreditsLogic();\n $method = new \\ReflectionMethod(FreeCreditsLogic::class, 'tryUnlockReleasePackageByRecharge');\n $method->setAccessible(true);\n\n return $method->invoke($logic, $player, $package, $totalRecharge, $rechargeAmount, $legacyMin);\n }\n\n private function playerWithBaseline(int $baselineQf): FreeCreditsPlayerModel\n {\n $player = new FreeCreditsPlayerModel();\n $player->setAttr('id', 1);\n $player->setAttr('recharge_baseline_qf', $baselineQf);\n\n return $player;\n }\n\n private function lockedReleasePackage(int $unlockRechargeQf): FreeCreditsPackageModel\n {\n $package = new FreeCreditsPackageModel();\n $package->setAttr('id', 2);\n $package->setAttr('unlock_recharge_qf', $unlockRechargeQf);\n $package->setAttr('status', FreeCreditsPackageModel::STATUS_LOCKED);\n\n return $package;\n }\n}\n"}}]}}
{"role":"assistant","message":{"content":[{"type":"text","text":"[REDACTED]"},{"type":"tool_use","name":"StrReplace","input":{"path":"/Users/ray/Documents/project/www/slot/slot_console/tests/Unit/FreeCreditsLogicAmountTest.php","old_string":" '阶段 5 整百' => [\n 250000,\n array_merge(\n [10000, 10000, 10000],\n [20000, 20000, 20000, 20000, 20000],\n [30000, 30000, 30000, 30000, 30000, 30000, 30000, 30000, 30000, 30000],\n [50000, 50000, 50000, 50000, 50000, 50000, 50000, 50000, 50000, 50000],\n [100000, 100000]\n ),\n array_merge(\n array_fill(0, 3, 9000),\n array_fill(0, 5, 19000),\n array_fill(0, 10, 29000),\n array_fill(0, 10, 49000),\n [99000, 99000]\n ),\n ],","new_string":" '阶段 3 仅 4 档 30 元' => [\n 250000,\n array_merge(\n [10000, 10000, 10000],\n [20000, 20000, 20000, 20000, 20000],\n [30000, 30000, 30000, 30000]\n ),\n array_merge(\n [9000, 9000, 9000],\n [19000, 19000, 19000, 19000, 19000],\n [29000, 29000, 29000, 29000]\n ),\n ],\n '阶段 5 整百两档' => [\n 200000,\n [100000, 100000],\n [99000, 99000],\n ],"}},{"type":"tool_use","name":"StrReplace","input":{"path":"/Users/ray/Documents/project/www/slot/slot_console/tests/Unit/FreeCreditsLogicAmountTest.php","old_string":" public function testBuildReleasePackagesSumEqualsLeft(): void\n {\n $leftQf = 73;\n $packages = FreeCreditsLogic::buildReleasePackages($leftQf);\n $this->assertSame($leftQf, array_sum(array_column($packages, 'amount_qf')));\n foreach ($packages as $package) {\n $this->assertGreaterThan(0, $package['amount_qf']);\n $this->assertGreaterThan(0, $package['unlock_recharge_qf']);\n $this->assertLessThanOrEqual($package['amount_qf'], $package['unlock_recharge_qf'] + 1000);\n }\n }","new_string":" public function testBuildReleasePackagesSumEqualsLeft(): void\n {\n $leftQf = 73000;\n $packages = FreeCreditsLogic::buildReleasePackages($leftQf);\n $this->assertSame($leftQf, array_sum(array_column($packages, 'amount_qf')));\n foreach ($packages as $package) {\n $this->assertGreaterThan(0, $package['amount_qf']);\n $this->assertLessThanOrEqual($package['amount_qf'], $package['unlock_recharge_qf'] + 1000);\n }\n }"}}]}}
{"role":"assistant","message":{"content":[{"type":"text","text":"[REDACTED]"},{"type":"tool_use","name":"StrReplace","input":{"path":"/Users/ray/Documents/project/www/slot/slot_console/tests/Unit/FreeCreditsLogicAmountTest.php","old_string":" '阶段 5 整百两档' => [\n 200000,\n [100000, 100000],\n [99000, 99000],\n ],","new_string":" '含整百档' => [\n 1130000,\n array_merge(\n array_fill(0, 3, 10000),\n array_fill(0, 5, 20000),\n array_fill(0, 10, 30000),\n array_fill(0, 10, 50000),\n [100000, 100000]\n ),\n array_merge(\n array_fill(0, 3, 9000),\n array_fill(0, 5, 19000),\n array_fill(0, 10, 29000),\n array_fill(0, 10, 49000),\n [99000, 99000]\n ),\n ],"}}]}}
{"role":"assistant","message":{"content":[{"type":"text","text":"[REDACTED]"},{"type":"tool_use","name":"Read","input":{"path":"/Users/ray/Documents/project/www/slot/docs/requirements/首充前免费余额定格与分档释放需求文档.md","offset":245,"limit":80}},{"type":"tool_use","name":"Read","input":{"path":"/Users/ray/Documents/project/www/slot/backend/slot_admin/app/game/model/common/FreeCreditsPackageModel.php","limit":25}}]}}
{"role":"assistant","message":{"content":[{"type":"text","text":"[REDACTED]"},{"type":"tool_use","name":"StrReplace","input":{"path":"/Users/ray/Documents/project/www/slot/docs/requirements/首充前免费余额定格与分档释放需求文档.md","old_string":"### 5.7 后续金额拆分规则\n\n第一档之后的剩余金额,按后台配置金额进行拆分。\n\n后台配置:\n\n| 配置项 | 默认值 | 说明 |\n|---|---:|---|\n| 解锁拆分金额 | `$10` | 后续每档释放金额 |\n\n拆分规则:\n\n```text\n剩余金额 = 定格金额 - 第一档金额\n后续每档金额 = min(解锁拆分金额, 剩余未拆分金额)\n```\n\n示例:\n\n定格金额 `$78.50`,第一档 `$20`,后续每档 `$10`。\n\n拆分结果:\n\n| 档位 | 金额 | 类型 |\n|---|---:|---|\n| 第 1 档 | `$20.00` | 免打码直接提现 |\n| 第 2 档 | `$10.00` | 后续解锁领取 |\n| 第 3 档 | `$10.00` | 后续解锁领取 |\n| 第 4 档 | `$10.00` | 后续解锁领取 |\n| 第 5 档 | `$10.00` | 后续解锁领取 |\n| 第 6 档 | `$10.00` | 后续解锁领取 |\n| 第 7 档 | `$8.50` | 后续解锁领取 |\n\n---\n\n### 5.8 后续档位解锁规则\n\n第一档完成释放后,剩余档位按顺序解锁。\n\n默认规则:\n\n> 用户每完成一笔符合条件的真实充值,解锁下一档 Free Credits。\n\n建议增加后台配置:\n\n| 配置项 | 默认值 | 说明 |\n|---|---:|---|\n| 后续解锁最小充值金额 | `$10` | 用户单笔充值达到该金额,才可解锁下一档 |\n| 每笔充值最多解锁档数 | `1` | 防止一笔充值解锁多个档位 |\n| 是否必须按顺序解锁 | 是 | 后一档必须等待前一档处理完成 |","new_string":"### 5.7 后续金额拆分规则\n\n第一档之后的剩余金额,按固定瀑布规则拆分(**不再**使用后台 `解锁拆分金额` 均分;该配置仅对存量旧档位兼容)。\n\n```text\n剩余金额 = 定格金额 - 第一档金额\n```\n\n按顺序消耗剩余金额:\n\n| 阶段 | 单档面额 | 本阶段最多档数 |\n|---|---:|---:|\n| 1 | `$10` | 3 |\n| 2 | `$20` | 5 |\n| 3 | `$30` | 10 |\n| 4 | `$50` | 10 |\n| 5 | `$100` | 直到剩余 < `$100` |\n| 6 | 尾档 | 剩余 < `$100` 的余额作为一整档 |\n\n尾档 `amount` 可带小数(如 `$8.50`),其余阶段均为整档面额。\n\n示例:\n\n定格金额 `$78.50`,第一档 `$20`,剩余 `$58.50`。\n\n| 档位 | 金额 | 类型 |\n|---|---:|---|\n| 第 1 档 | `$20.00` | 免打码直接提现 |\n| 第 2 档 | `$10.00` | 后续解锁领取 |\n| 第 3 档 | `$10.00` | 后续解锁领取 |\n| 第 4 档 | `$10.00` | 后续解锁领取 |\n| 第 5 档 | `$20.00` | 后续解锁领取 |\n| 第 6 档 | `$8.50` | 后续解锁领取(尾档) |\n\n---\n\n### 5.8 后续档位解锁规则\n\n第一档(免打码提现)仍按**终身**累计真实充值满后台门槛(默认 `$50`)解锁。\n\n第一档解锁后,后续 release 档按顺序解锁;**每一档单独计算「新增累计充值」**(自上一档解锁时的 `totalRecharge` 快照重新计数,非终身总额一刀切)。\n\n| 阶段面额 | 本档所需新增累计充值 |\n|---|---:|\n| `$10` | ≥ `$9` |\n| `$20` | ≥ `$19` |\n| `$30` | ≥ `$29` |\n| `$50` | ≥ `$49` |\n| `$100` | ≥ `$99` |\n| 尾档 | ≥ **面额按整元向下取整**(如面额 `$15.34` → 需新增累计 `$15`) |\n\n后台配置:\n\n| 配置项 | 默认值 | 说明 |\n|---|---:|---|\n| 每笔充值最多解锁档数 | `1` | 单次充值回调内最多连续解锁档数(每解锁一档会重置充值计数起点) |\n| 是否必须按顺序解锁 | 是 | 后一档必须等待前一档解锁 |\n\n存量用户(定格时 `unlock_recharge_qf=0` 的档位)仍可按旧规则:单笔充值 ≥ `后续解锁最小充值金额` 解锁下一档。"}},{"type":"tool_use","name":"Shell","input":{"command":"docker exec -w /app/www/slot/slot_console php82 ./vendor/bin/phpunit tests/Unit/FreeCreditsLogicAmountTest.php tests/Unit/FreeCreditsAdvanceUnlockTest.php 2>&1","description":"Run Free Credits unit tests in Docker"}}]}}
{"role":"assistant","message":{"content":[{"type":"text","text":"[REDACTED]"},{"type":"tool_use","name":"StrReplace","input":{"path":"/Users/ray/Documents/project/www/slot/slot_console/tests/Unit/FreeCreditsAdvanceUnlockTest.php","old_string":" private function playerWithBaseline(int $baselineQf): FreeCreditsPlayerModel\n {\n $player = new FreeCreditsPlayerModel();\n $player->setAttr('id', 1);\n $player->setAttr('recharge_baseline_qf', $baselineQf);\n\n return $player;\n }\n\n private function lockedReleasePackage(int $unlockRechargeQf): FreeCreditsPackageModel\n {\n $package = new FreeCreditsPackageModel();\n $package->setAttr('id', 2);\n $package->setAttr('unlock_recharge_qf', $unlockRechargeQf);\n $package->setAttr('status', FreeCreditsPackageModel::STATUS_LOCKED);\n\n return $package;\n }\n}","new_string":" private function playerWithBaseline(int $baselineQf): FreeCreditsPlayerModel\n {\n $player = new FreeCreditsPlayerModel();\n $player->exists(true);\n $player->setAttr('id', 1);\n $player->setAttr('recharge_baseline_qf', $baselineQf);\n\n return $player;\n }\n\n private function lockedReleasePackage(int $unlockRechargeQf): FreeCreditsPackageModel\n {\n $package = new FreeCreditsPackageModel();\n $package->exists(true);\n $package->setAttr('id', 2);\n $package->setAttr('unlock_recharge_qf', $unlockRechargeQf);\n $package->setAttr('status', FreeCreditsPackageModel::STATUS_LOCKED);\n\n return $package;\n }\n}"}},{"type":"tool_use","name":"StrReplace","input":{"path":"/Users/ray/Documents/project/www/slot/slot_console/tests/Unit/FreeCreditsLogicAmountTest.php","old_string":" '尾档取整 unlock' => [\n 15340,\n [15340],\n [15000],\n ],","new_string":" '小于 10 元先占一档 10 元再尾档' => [\n 15340,\n [10000, 5340],\n [9000, 5000],\n ],"}},{"type":"tool_use","name":"StrReplace","input":{"path":"/Users/ray/Documents/project/www/slot/slot_console/tests/Unit/FreeCreditsLogicAmountTest.php","old_string":" foreach ($packages as $package) {\n $this->assertGreaterThan(0, $package['amount_qf']);\n $this->assertLessThanOrEqual($package['amount_qf'], $package['unlock_recharge_qf'] + 1000);\n }","new_string":" foreach ($packages as $package) {\n $this->assertGreaterThan(0, $package['amount_qf']);\n $this->assertLessThanOrEqual($package['unlock_recharge_qf'], $package['amount_qf']);\n }"}},{"type":"tool_use","name":"Shell","input":{"command":"docker exec -w /app/www/slot/slot_console php82 ./vendor/bin/phpunit tests/Unit/FreeCreditsLogicAmountTest.php tests/Unit/FreeCreditsAdvanceUnlockTest.php 2>&1","description":"Re-run Free Credits unit tests"}}]}}
{"role":"assistant","message":{"content":[{"type":"text","text":"[REDACTED]"},{"type":"tool_use","name":"Shell","input":{"command":"docker exec -w /app/www/slot/slot_console php82 php -r '\nrequire \"vendor/autoload.php\";\n$pkgs = app\\api\\logic\\FreeCreditsLogic::buildReleasePackages(73000);\nforeach ($pkgs as $p) {\n if ($p[\"unlock_recharge_qf\"] > $p[\"amount_qf\"]) {\n echo json_encode($p), \"\\n\";\n }\n}\necho \"count=\", count($pkgs), \" sum=\", array_sum(array_column($pkgs,\"amount_qf\")), \"\\n\";\nprint_r($pkgs);\n'","description":"Debug buildReleasePackages(73000) output"}}]}}
{"role":"assistant","message":{"content":[{"type":"text","text":"[REDACTED]"},{"type":"tool_use","name":"Shell","input":{"command":"docker exec -w /app/www/slot/slot_console php82 ./vendor/bin/phpunit --filter testBuildReleasePackagesSumEqualsLeft 2>&1","description":"Run single sum test in isolation"}}]}}
{"role":"assistant","message":{"content":[{"type":"text","text":"[REDACTED]"},{"type":"tool_use","name":"StrReplace","input":{"path":"/Users/ray/Documents/project/www/slot/slot_console/tests/Unit/FreeCreditsLogicAmountTest.php","old_string":" $this->assertLessThanOrEqual($package['unlock_recharge_qf'], $package['amount_qf']);","new_string":" $this->assertLessThanOrEqual($package['amount_qf'], $package['unlock_recharge_qf']);"}},{"type":"tool_use","name":"Shell","input":{"command":"docker exec -w /app/www/slot/slot_console php82 ./vendor/bin/phpunit tests/Unit/FreeCreditsLogicAmountTest.php tests/Unit/FreeCreditsAdvanceUnlockTest.php 2>&1","description":"Run all Free Credits unit tests again"}}]}}
{"role":"assistant","message":{"content":[{"type":"text","text":"[REDACTED]"},{"type":"tool_use","name":"Shell","input":{"command":"~/.cursor/hooks/verify-slot-backend.sh 2>&1","description":"Run backend verification gate script"}},{"type":"tool_use","name":"TodoWrite","input":{"todos":[{"id":"admin-config","content":"废弃 package_amount/subsequent_min 后台必填;更新 ActivityValidate","status":"completed"},{"id":"tests-docs","content":"单测/集成测 + 需求文档 §5.7/5.8 更新","status":"completed"}],"merge":true}}]}}
{"role":"assistant","message":{"content":[{"type":"text","text":"已实现首充剩余定格的分档与解锁规则变更,摘要如下。\n\n## 核心逻辑(`slot_console`)\n\n**瀑布分档** — `FreeCreditsLogic::buildReleasePackages()` \n按 10×3 → 20×5 → 30×10 → 50×10 → 100 整档 → 尾档 拆分剩余金额;尾档 `unlock` 用 `calcTailUnlockRechargeQf()` 整元向下取整。\n\n**定格** — 写入各档 `unlock_recharge_qf`,并设置 `player.recharge_baseline_qf = 定格时 totalRecharge`。\n\n**解锁** — 后续档:`(totalR - baseline) >= unlock_recharge_qf` 后变 `ready`,并将 `baseline` 更新为当前 `totalR`;第一档仍用终身累计 ≥ `recharge_unlock_amount`;第一档解锁时也会重置 `baseline`。 \n`unlock_recharge_qf = 0` 的存量档仍走旧「单笔 ≥ subsequent_min」逻辑。\n\n## 数据库\n\n- 迁移:[`slot_console/db/migrate_free_credits_release_tiers.sql`](slot_console/db/migrate_free_credits_release_tiers.sql) \n- 新装:已更新 [`slot_console/db/install.sql`](slot_console/db/install.sql)\n\n**上线前请在 MySQL 执行迁移 SQL。**\n\n## 后台\n\n- [`ActivityValidate`](backend/slot_admin/app/game/validate/ActivityValidate.php):`package_amount` / `subsequent_min_recharge` 改为可选 \n- 活动编辑页标注上述两项已废弃\n\n## 测试与文档\n\n- 新增/更新单测:`FreeCreditsLogicAmountTest`、`FreeCreditsAdvanceUnlockTest`(21 tests OK) \n- 需求文档 §5.7 / §5.8 已更新\n\n### 示例(定格 $78.50,第一档 $20)\n\nRelease 档:`$10 + $10 + $10 + $20 + $8.50`;尾档解锁需**新增**累计 $8(非 $8.50)。\n\n```\n=== verify-slot-backend ===\nchanged: 8 file(s), 5 php\nPASS\n```\n\nPHPDoc: checked\n\n[REDACTED]"}]}}