activity_site_eligibility.php 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. <?php
  2. namespace app\api\controller {
  3. class BaseApiController {
  4. protected int $userId = 0;
  5. protected string $lang = 'zh';
  6. public $request;
  7. public function __construct($userId = 0) { $this->userId = $userId; $this->request = \request(); }
  8. protected function success($message = '', $data = []) { return ['code' => 1, 'data' => $data, 'msg' => $message]; }
  9. protected function fail($message = '') { return ['code' => 0, 'msg' => $message]; }
  10. }
  11. }
  12. namespace app\api\lists {
  13. class BaseApiDataLists {
  14. protected int $userId = 0;
  15. protected string $lang = 'zh';
  16. protected array $params = [];
  17. protected array $searchWhere = [];
  18. public int $limitOffset = 0;
  19. public int $limitLength = 1;
  20. public function __construct($userId = 0, $type = null) { $this->userId = $userId; $this->params = ['type' => $type]; }
  21. public function setParams($params): self { $this->params = $params; return $this; }
  22. }
  23. }
  24. namespace {
  25. function request() { return $GLOBALS['activity_test_request']; }
  26. function env($key, $default = '') { return $default; }
  27. function ensureUrl($url, $host = '') { return $url; }
  28. function json($value) { return $value; }
  29. class TestRequest {
  30. public array $query = [];
  31. public array $postData = [];
  32. public array $headers = [];
  33. public function get($key, $default = null) { return $this->query[$key] ?? $default; }
  34. public function header($key, $default = null) { return $this->headers[strtolower($key)] ?? $default; }
  35. public function post($key, $default = null, $filter = null) { $v = $this->postData[$key] ?? $default; return $filter ? $filter($v) : $v; }
  36. }
  37. $GLOBALS['activity_test_request'] = new TestRequest();
  38. $repo = getenv('ACTIVITY_SITE_REPO') ?: dirname(__DIR__, 3) . '/melbet_sport-api';
  39. $stage = getenv('ACTIVITY_SITE_STAGE') ?: $repo;
  40. $loader = require $repo . '/vendor/autoload.php';
  41. $loader->setPsr4('app\\', [$stage . '/app/', $repo . '/app/']);
  42. $db = new \think\DbManager();
  43. $db->setConfig(['default' => 'sqlite', 'connections' => ['sqlite' => ['type' => 'sqlite', 'database' => ':memory:', 'prefix' => '', 'fields_strict' => false]], 'auto_timestamp' => false]);
  44. \think\Container::getInstance()->instance(\think\DbManager::class, $db);
  45. $tables = [
  46. 'bot_users' => 'id INTEGER PRIMARY KEY,user_id TEXT,member_id TEXT,created_at TEXT,delete_time INTEGER,recharge_channel_group_id INTEGER',
  47. 'bot_user_login' => 'id INTEGER PRIMARY KEY,user_id TEXT,status INTEGER,platform TEXT',
  48. 'bot_balance_logs' => 'id INTEGER PRIMARY KEY,member_id TEXT,type INTEGER,change_type TEXT,amount TEXT,created_at TEXT,updated_at TEXT,remark TEXT,before_balance TEXT,after_balance TEXT,related_id INTEGER,frozen_status INTEGER',
  49. 'bot_recharge_channel_group' => 'id INTEGER PRIMARY KEY,activity_type TEXT',
  50. 'bot_activity_rewards' => 'id INTEGER PRIMARY KEY,title TEXT,sub_title TEXT,start_time INTEGER,end_time INTEGER,detail_image TEXT,type INTEGER,lang TEXT,game_category TEXT,pc_image TEXT,detail_banner TEXT,pc_banner TEXT,publish_platforms TEXT,budget TEXT,budget_used TEXT DEFAULT "0",quota_enabled INTEGER DEFAULT 0,reward_quota INTEGER,rewarded_count INTEGER DEFAULT 0,sort INTEGER DEFAULT 0,register_time_limited INTEGER,register_start_time TEXT,register_end_time TEXT,register_platforms TEXT,deposit_limited INTEGER,deposit_min TEXT,deposit_max TEXT,show_ineligible INTEGER DEFAULT 1,status INTEGER,params TEXT,created_at TEXT,updated_at TEXT',
  51. 'bot_activity_reward_recipients' => 'id INTEGER PRIMARY KEY,activity_id INTEGER NOT NULL,member_id TEXT NOT NULL,created_at TEXT,UNIQUE(activity_id,member_id)',
  52. 'bot_wallets' => 'id INTEGER PRIMARY KEY,member_id TEXT,available_balance TEXT,frozen_balance TEXT,yuebao TEXT,yuebao_invest TEXT',
  53. 'bot_yuebao_item' => 'id INTEGER PRIMARY KEY,user_id TEXT,activity_id INTEGER,principal TEXT,surplus_principal TEXT,status INTEGER DEFAULT 1,daily_interest_rate TEXT,expire_time INTEGER,create_time INTEGER DEFAULT 0,update_time INTEGER',
  54. 'bot_yuebao_log' => 'id INTEGER PRIMARY KEY,user_id TEXT,item_id INTEGER,amount TEXT,type INTEGER,interest TEXT,hold_hours TEXT,create_time INTEGER,update_time INTEGER',
  55. ];
  56. foreach ($tables as $table => $columns) { $db->execute('CREATE TABLE ' . $table . '(' . $columns . ')'); }
  57. $assertions = 0;
  58. function check($condition, $message) { global $assertions; ++$assertions; if (!$condition) { throw new \RuntimeException($message); } }
  59. function row($table, $data) { \think\facade\Db::table($table)->insert($data); }
  60. function activity($id, $override = []) {
  61. row('bot_activity_rewards', array_merge(['id' => $id, 'title' => 'Activity ' . $id, 'status' => 1, 'type' => 5, 'lang' => 'zh', 'sort' => 0, 'start_time' => time() - 100, 'end_time' => time() + 10000, 'show_ineligible' => 1, 'budget_used' => '0.00', 'detail_image' => '', 'pc_image' => '', 'detail_banner' => '', 'pc_banner' => ''], $override));
  62. }
  63. row('bot_users', ['id' => 1, 'user_id' => '1001', 'member_id' => '2001', 'created_at' => '2026-10-01 12:00:00', 'recharge_channel_group_id' => 1]);
  64. row('bot_user_login', ['id' => 1, 'user_id' => '2001', 'status' => 0, 'platform' => 'PC']);
  65. row('bot_user_login', ['id' => 2, 'user_id' => '2001', 'status' => 1, 'platform' => 'iOS']);
  66. row('bot_user_login', ['id' => 3, 'user_id' => '2001', 'status' => 1, 'platform' => 'PC']);
  67. row('bot_recharge_channel_group', ['id' => 1, 'activity_type' => 'recharge,yuebao']);
  68. row('bot_balance_logs', ['member_id' => '2001', 'type' => 1, 'change_type' => '充值', 'amount' => '99.99']);
  69. row('bot_balance_logs', ['member_id' => '2001', 'type' => 1, 'change_type' => '三方充值', 'amount' => '100.00']);
  70. row('bot_balance_logs', ['member_id' => '2001', 'type' => 2, 'change_type' => '充值', 'amount' => '999.00']);
  71. row('bot_balance_logs', ['member_id' => '2001', 'type' => 1, 'change_type' => '即充即送', 'amount' => '999.00']);
  72. $service = new \app\common\service\MemberActivityService(1001, 'PC');
  73. check($service->memberId() === '2001', 'User ID must map to member ID');
  74. $context = $service->context();
  75. check($context['registered_at'] === '2026-10-01 12:00:00', 'Raw member registration time');
  76. check($context['register_platform'] === 'APP', 'First successful login must determine registration platform');
  77. check(bccomp($context['deposit_total'], '199.99', 2) === 0, 'Lifetime deposit excludes bonuses and frozen balance');
  78. $queryCount = $db->getQueryTimes();
  79. $service->context();
  80. check($db->getQueryTimes() === $queryCount, 'Member context is cached');
  81. activity(1, ['sort' => 4, 'deposit_limited' => 1, 'deposit_min' => '199.99', 'deposit_max' => '199.99', 'register_platforms' => '["APP"]']);
  82. activity(2, ['sort' => 9, 'publish_platforms' => '["H5"]']);
  83. activity(3, ['sort' => 8, 'show_ineligible' => 0, 'deposit_limited' => 1, 'deposit_min' => '300', 'deposit_max' => '400']);
  84. activity(4, ['sort' => 7, 'show_ineligible' => 1, 'register_platforms' => '["PC"]', 'game_category' => 'third:2']);
  85. activity(5, ['sort' => 10, 'status' => 0]);
  86. activity(6, ['type' => 2, 'sort' => 100, 'publish_platforms' => '["H5"]', 'show_ineligible' => 0, 'register_platforms' => '["PC"]']);
  87. request()->query = ['platform' => 'PC'];
  88. $lists = new \app\api\lists\ActivityRewardsLists(1001);
  89. check($lists->count() === 3, 'Visible count is before pagination');
  90. $queryCount = $db->getQueryTimes();
  91. $page = $lists->lists();
  92. check(count($page) === 1 && $page[0]['id'] === 4 && $page[0]['is_eligible'] === false, 'Visible ineligible activity sorted first');
  93. check($db->getQueryTimes() === $queryCount, 'List and count share same visible rows');
  94. $lists->limitOffset = 1;
  95. check($lists->lists()[0]['id'] === 1, 'Filtered-out rows do not create pagination gaps');
  96. $lists->limitOffset = 2;
  97. check($lists->lists()[0]['id'] === 6, 'Football ignores new fields and has effective sort zero');
  98. $controller = new \app\api\controller\IndexController(1001);
  99. foreach ([2, 3, 5] as $id) { request()->query['id'] = $id; check($controller->activityInfo()['code'] === 0, 'Hidden ID detail must be denied: ' . $id); }
  100. request()->query['id'] = 4;
  101. check($controller->activityInfo()['data']['is_eligible'] === false, 'Visible ineligible detail exposes eligibility flag');
  102. $gameNames = ['third:1' => '真人', 'third:2' => '电子', 'third:3' => '彩票', 'third:4' => '体育',
  103. 'third:5' => '电竞', 'third:6' => '捕鱼', 'third:7' => '棋牌', 'self:sport' => 'F1体育', 'self:lottery' => 'F1彩票', 'other' => '其他'];
  104. $originalActivity = \think\facade\Db::table('bot_activity_rewards')->where('id', 4)->find();
  105. $fundsBefore = \think\facade\Db::table('bot_balance_logs')->order('id')->select()->toArray();
  106. foreach ($gameNames as $category => $name) {
  107. \think\facade\Db::table('bot_activity_rewards')->where('id', 4)->update(['game_category' => $category]);
  108. $catalogList = new \app\api\lists\ActivityRewardsLists(1001);
  109. $catalogList->limitLength = 100;
  110. $listed = array_column($catalogList->lists(), null, 'id')[4];
  111. $detail = $controller->activityInfo();
  112. check($detail['code'] === 1, 'Visible catalog detail succeeds: ' . $category);
  113. foreach ([$listed, $detail['data']] as $item) {
  114. check($item['type'] === 5 && $item['type_name'] === 'USDT使用教学', 'Activity type value and name are returned together');
  115. check($item['game_category'] === $category && $item['game_category_name'] === $name, 'Category value/name are returned on list and detail: ' . $category);
  116. check($item['is_eligible'] === false, 'Catalog enrichment preserves member eligibility');
  117. }
  118. check($catalogList->count() === 3, 'Category names do not change visibility or pagination totals');
  119. }
  120. \think\facade\Db::table('bot_activity_rewards')->where('id', 4)->update(['game_category' => null]);
  121. $uncategorized = $controller->activityInfo()['data'];
  122. check($uncategorized['game_category'] === null && $uncategorized['game_category_name'] === null, 'Unassigned category remains null, not other');
  123. $defaultList = new \app\api\lists\ActivityRewardsLists(1001);
  124. $defaultList->limitLength = 100;
  125. $defaultRows = array_column($defaultList->lists(), null, 'id');
  126. check($defaultRows[4]['game_category'] === null && $defaultRows[4]['game_category_name'] === null, 'Unassigned list category is explicit null');
  127. check($defaultRows[6]['type'] === 2 && $defaultRows[6]['type_name'] === '普通活动', 'Former football type displays the normal activity name without changing its ID');
  128. foreach ([2 => '普通活动', 3 => '即充即送', 4 => '老用户召集令', 5 => 'USDT使用教学', 6 => '余额宝'] as $type => $name) {
  129. $named = \app\common\service\ActivityCatalog::decorate(['type' => $type, 'game_category' => 'other']);
  130. check($named['type'] === $type && $named['type_name'] === $name && $named['game_category_name'] === '其他', 'Canonical activity label: ' . $type);
  131. }
  132. $legacyType = \app\common\service\ActivityCatalog::decorate(['type' => 1]);
  133. check($legacyType['type'] === 1 && $legacyType['type_name'] === null && $legacyType['game_category'] === null, 'Legacy type one is preserved without inventing a name');
  134. $unknownType = \app\common\service\ActivityCatalog::decorate(['type' => 99, 'game_category' => 'legacy:unknown']);
  135. check($unknownType['type'] === 99 && $unknownType['type_name'] === null && $unknownType['game_category'] === 'legacy:unknown' && $unknownType['game_category_name'] === null, 'Unknown values remain unchanged without guessing another category');
  136. \think\facade\Db::table('bot_activity_rewards')->where('id', 4)->update(['game_category' => 'third:2']);
  137. check(\think\facade\Db::table('bot_activity_rewards')->where('id', 4)->find() === $originalActivity, 'List/detail metadata reads do not modify the activity');
  138. check(\think\facade\Db::table('bot_balance_logs')->order('id')->select()->toArray() === $fundsBefore, 'List/detail metadata reads never create financial entries');
  139. activity(7, ['type' => 4]);
  140. request()->query['id'] = 7;
  141. check($controller->activityInfo()['code'] === 0, 'Detail enforces recharge group without type query');
  142. check((new \app\api\lists\ActivityRewardsLists(1001))->count() === 3, 'Omitted type list applies same disabled old-user group as detail');
  143. check((new \app\api\lists\ActivityRewardsLists(1001, 4))->count() === 0, 'Explicit old-user type cannot bypass disabled group');
  144. request()->query = [];
  145. check((new \app\common\service\MemberActivityService(1001))->visibleActivity(['type' => 5, 'publish_platforms' => '["PC"]']) === null, 'Limited publication fails closed without explicit platform');
  146. activity(8, ['type' => 6]);
  147. \think\facade\Db::table('bot_recharge_channel_group')->where('id', 1)->update(['activity_type' => 'recharge']);
  148. check((new \app\api\lists\ActivityRewardsLists(1001, 6))->count() === 0, 'Explicit Yuebao type cannot bypass disabled group');
  149. request()->query = ['id' => 8];
  150. check($controller->activityInfo()['code'] === 0, 'Yuebao detail matches group-disabled list');
  151. \think\facade\Db::table('bot_recharge_channel_group')->where('id', 1)->update(['activity_type' => 'recharge,yuebao']);
  152. \think\facade\Db::table('bot_activity_rewards')->where('id', 8)->delete();
  153. $guest = new \app\common\service\MemberActivityService(0, 'PC');
  154. check($guest->visibleActivity(['type' => 5, 'show_ineligible' => 0]) === null, 'Guests cannot see hidden ineligible activities');
  155. check($guest->visibleActivity(['type' => 2, 'show_ineligible' => 0])['is_eligible'], 'Football ignores guest eligibility');
  156. activity(10, ['quota_enabled' => 1, 'type' => 6, 'sort' => 3, 'reward_quota' => 1, 'params' => '{"daily_interest_rate":"1.00","number_of_consecutive_days_of_deposit":7}']);
  157. activity(11, ['quota_enabled' => 1, 'type' => 6, 'sort' => 4, 'register_platforms' => '["PC"]']);
  158. check(\app\common\model\ActivityRewards::getYuebaoActivity('2001')['id'] === 10, 'Yuebao skips higher-sorted ineligible activity');
  159. check(\think\facade\Db::table('bot_activity_reward_recipients')->count() === 0, 'Eligibility and config reads do not occupy a winner slot');
  160. \think\facade\Db::startTrans();
  161. check(\app\common\service\ActivityQuotaService::consume(10, '2001', '100.00'), 'First positive reward occupies one distinct-user slot');
  162. $recipientCurrentReads = 0;
  163. $db->event('before_find', function ($query) use (&$recipientCurrentReads) {
  164. if ($query->getTable() === 'bot_activity_reward_recipients' && $query->getOptions('lock')) {
  165. ++$recipientCurrentReads;
  166. }
  167. });
  168. $fullQuota = \think\facade\Db::table('bot_activity_rewards')->where('id', 10)->lock(true)->find();
  169. check(\app\common\service\ActivityQuotaService::canAward($fullQuota, '2001', true), 'Current-read availability recognizes recorded winner at full quota');
  170. check($recipientCurrentReads === 1, 'Locked availability reads recipient with row-lock query');
  171. $beforeRepeatReads = $recipientCurrentReads;
  172. check(\app\common\service\ActivityQuotaService::consume(10, '2001', '999.00'), 'Same winner can receive another reward when the quota is full');
  173. check($recipientCurrentReads === $beforeRepeatReads + 1, 'Repeated consume uses exactly one current recipient read after activity lock');
  174. check(!\app\common\service\ActivityQuotaService::consume(10, '2002', '0.01'), 'A different member cannot win after the user quota is full');
  175. check((int)\think\facade\Db::table('bot_activity_rewards')->where('id', 10)->value('rewarded_count') === 1, 'Repeated same-member rewards count once independent of monetary amount');
  176. \think\facade\Db::rollback();
  177. check((int)\think\facade\Db::table('bot_activity_rewards')->where('id', 10)->value('rewarded_count') === 0 && \think\facade\Db::table('bot_activity_reward_recipients')->count() === 0, 'Recipient and awarded-user counter roll back together');
  178. activity(12, ['quota_enabled' => 1, 'reward_quota' => 0]);
  179. \think\facade\Db::startTrans();
  180. check(\app\common\service\ActivityQuotaService::consume(12, '2001', '0.00'), 'Zero reward does not need or occupy a winner slot');
  181. check(!\app\common\service\ActivityQuotaService::consume(12, '2001', '1.00'), 'Zero quota blocks first positive reward');
  182. check(!\app\common\service\ActivityQuotaService::consume(12, '2001', '-1.00'), 'Negative reward does not occupy a winner slot');
  183. \think\facade\Db::commit();
  184. check(\think\facade\Db::table('bot_activity_reward_recipients')->where('activity_id', 12)->count() === 0, 'No recipient records for zero or denied rewards');
  185. activity(13, ['quota_enabled' => 1, 'reward_quota' => 10]);
  186. \think\facade\Db::startTrans();
  187. for ($i = 1; $i <= 10; ++$i) {
  188. check(\app\common\service\ActivityQuotaService::consume(13, 'winner-' . $i, (string)$i . '.00'), 'Distinct winner slot ' . $i . ' is allowed');
  189. }
  190. check(!\app\common\service\ActivityQuotaService::consume(13, 'winner-11', '1.00'), 'The eleventh distinct member cannot receive a quota-ten reward');
  191. check(\app\common\service\ActivityQuotaService::consume(13, 'winner-1', '100000.00'), 'Existing winner remains allowed at ten distinct recipients');
  192. \think\facade\Db::commit();
  193. check((int)\think\facade\Db::table('bot_activity_rewards')->where('id', 13)->value('rewarded_count') === 10 && \think\facade\Db::table('bot_activity_reward_recipients')->where('activity_id', 13)->count() === 10, 'Quota ten counts exactly ten unique winners');
  194. activity(14, ['quota_enabled' => 1, 'reward_quota' => null, 'budget' => '0.00', 'budget_used' => '999999.00']);
  195. \think\facade\Db::startTrans();
  196. check(\app\common\service\ActivityQuotaService::consume(14, '2001', '99999.00'), 'Unlimited user quota ignores historical monetary budget');
  197. \think\facade\Db::commit();
  198. $public = $service->visibleActivity(\think\facade\Db::table('bot_activity_rewards')->where('id', 14)->find());
  199. check(array_key_exists('reward_quota', $public) && array_key_exists('rewarded_count', $public) && (int)$public['quota_enabled'] === 1, 'New activity response exposes quota mode and distinct-winner metadata');
  200. row('bot_wallets', ['id' => 1, 'member_id' => '2001', 'available_balance' => '50.00', 'frozen_balance' => '0.00', 'yuebao' => '100.00', 'yuebao_invest' => '0.00']);
  201. row('bot_yuebao_item', ['id' => 1, 'user_id' => '1001', 'activity_id' => 10, 'principal' => '100.00', 'surplus_principal' => '100.00', 'daily_interest_rate' => '1.00', 'expire_time' => time() - 1, 'create_time' => time() - 86400]);
  202. $command = new \app\api\command\YuebaoInvest();
  203. $invest = new \ReflectionMethod($command, 'invest');
  204. $invest->invoke($command);
  205. $wallet = \think\facade\Db::table('bot_wallets')->where('id', 1)->find();
  206. check(bccomp($wallet['available_balance'], '150.00', 2) === 0, 'Automatic expiry returns principal');
  207. check(bccomp($wallet['frozen_balance'], '1.00', 2) === 0, 'Automatic expiry awards interest once');
  208. check((int)\think\facade\Db::table('bot_activity_rewards')->where('id', 10)->value('rewarded_count') === 1, 'Automatic expiry records first distinct winner');
  209. $invest->invoke($command);
  210. check(\think\facade\Db::table('bot_yuebao_log')->count() === 1, 'Repeated expiry does not duplicate settlement');
  211. row('bot_users', ['id' => 2, 'user_id' => '1002', 'member_id' => '2002', 'created_at' => '2026-10-01 12:00:00', 'recharge_channel_group_id' => 1]);
  212. row('bot_wallets', ['id' => 2, 'member_id' => '2002', 'available_balance' => '50.00', 'frozen_balance' => '0.00', 'yuebao' => '100.00', 'yuebao_invest' => '0.00']);
  213. row('bot_yuebao_item', ['id' => 2, 'user_id' => '1002', 'activity_id' => 10, 'principal' => '100.00', 'surplus_principal' => '100.00', 'daily_interest_rate' => '1.00', 'expire_time' => time() - 1, 'create_time' => time() - 86400]);
  214. $invest->invoke($command);
  215. $wallet = \think\facade\Db::table('bot_wallets')->where('id', 2)->find();
  216. check(bccomp($wallet['available_balance'], '150.00', 2) === 0 && bccomp($wallet['frozen_balance'], '0.00', 2) === 0, 'Full winner quota returns other member principal without interest');
  217. \think\facade\Db::table('bot_wallets')->where('id', 1)->update(['yuebao' => '100.00']);
  218. row('bot_yuebao_item', ['id' => 3, 'user_id' => '1001', 'activity_id' => 10, 'principal' => '100.00', 'surplus_principal' => '100.00', 'daily_interest_rate' => '1.00', 'expire_time' => time() - 1, 'create_time' => time() - 86400]);
  219. $invest->invoke($command);
  220. $wallet = \think\facade\Db::table('bot_wallets')->where('id', 1)->find();
  221. check(bccomp($wallet['available_balance'], '250.00', 2) === 0 && bccomp($wallet['frozen_balance'], '2.00', 2) === 0, 'Existing winner continues to receive saved-rate interest at full quota');
  222. check((int)\think\facade\Db::table('bot_activity_rewards')->where('id', 10)->value('rewarded_count') === 1, 'Second position of same member does not add another winner');
  223. $offset = 12 - (int)gmdate('G');
  224. date_default_timezone_set('Etc/GMT' . ($offset >= 0 ? '-' : '+') . abs($offset));
  225. activity(20, ['quota_enabled' => 1, 'type' => 6, 'reward_quota' => 1, 'params' => '{"daily_interest_rate":"1.00","number_of_consecutive_days_of_deposit":7}']);
  226. \think\facade\Db::table('bot_wallets')->where('id', 1)->update(['yuebao' => '100.00']);
  227. row('bot_yuebao_item', ['id' => 4, 'user_id' => '1001', 'activity_id' => 20, 'principal' => '100.00', 'surplus_principal' => '100.00', 'daily_interest_rate' => '1.00', 'expire_time' => time() + 100, 'create_time' => time() - 86400]);
  228. request()->postData = ['amount' => '100.00'];
  229. $yuebao = new \app\api\controller\YuebaoController(1001);
  230. $out = $yuebao->out();
  231. $wallet = \think\facade\Db::table('bot_wallets')->where('id', 1)->find();
  232. check($out['code'] === 1 && bccomp((string)$out['data']['interest'], '1.00', 2) === 0, 'Manual out awards interest and occupies first-user slot');
  233. check(bccomp($wallet['available_balance'], '350.00', 2) === 0 && bccomp($wallet['frozen_balance'], '3.00', 2) === 0, 'Manual out credits principal available and interest frozen once');
  234. check((int)\think\facade\Db::table('bot_activity_rewards')->where('id', 20)->value('rewarded_count') === 1, 'Manual payout counts one distinct member');
  235. check($yuebao->out()['code'] === 0, 'Repeated manual out cannot settle the same position twice');
  236. \think\facade\Db::table('bot_wallets')->where('id', 2)->update(['yuebao' => '100.00']);
  237. row('bot_yuebao_item', ['id' => 5, 'user_id' => '1002', 'activity_id' => 20, 'principal' => '100.00', 'surplus_principal' => '100.00', 'daily_interest_rate' => '1.00', 'expire_time' => time() + 100, 'create_time' => time() - 86400]);
  238. check(bccomp((string)(new \app\api\controller\YuebaoController(1002))->out()['data']['interest'], '0', 2) === 0, 'Manual out denies other-member interest at full quota');
  239. $wallet = \think\facade\Db::table('bot_wallets')->where('id', 2)->find();
  240. check(bccomp($wallet['available_balance'], '250.00', 2) === 0 && bccomp($wallet['frozen_balance'], '0.00', 2) === 0, 'Manual quota denial preserves principal');
  241. \think\facade\Db::table('bot_wallets')->where('id', 1)->update(['yuebao' => '100.00']);
  242. row('bot_yuebao_item', ['id' => 6, 'user_id' => '1001', 'activity_id' => null, 'principal' => '100.00', 'surplus_principal' => '100.00', 'daily_interest_rate' => '1.00', 'expire_time' => time() + 100, 'create_time' => time() - 86400]);
  243. check(bccomp((string)$yuebao->out()['data']['interest'], '1.00', 2) === 0, 'Legacy unlinked position keeps saved rate without invented activity association');
  244. check(\app\common\model\ActivityRewards::getYuebaoActivity('2001')['id'] === 10, 'Existing winner remains eligible for new positions when quota is full');
  245. row('bot_users', ['id' => 3, 'user_id' => '1003', 'member_id' => '2003', 'created_at' => '2026-10-01 12:00:00', 'recharge_channel_group_id' => 1]);
  246. row('bot_user_login', ['id' => 4, 'user_id' => '2003', 'status' => 1, 'platform' => 'iOS']);
  247. row('bot_wallets', ['id' => 3, 'member_id' => '2003', 'available_balance' => '500.00', 'frozen_balance' => '0.00', 'yuebao' => '0.00', 'yuebao_invest' => '0.00']);
  248. row('bot_balance_logs', ['member_id' => '2003', 'type' => 1, 'change_type' => '极速28投注', 'amount' => '-10000.00', 'created_at' => date('Y-m-d H:i:s')]);
  249. $newMember = new \app\api\controller\YuebaoController(1003);
  250. $beforeItems = \think\facade\Db::table('bot_yuebao_item')->count();
  251. check($newMember->add()['code'] === 0, 'Different member cannot open linked position when all eligible configs have full quotas');
  252. check(bccomp((string)\think\facade\Db::table('bot_wallets')->where('id', 3)->value('available_balance'), '500.00', 2) === 0 && \think\facade\Db::table('bot_yuebao_item')->count() === $beforeItems, 'Denied new position leaves wallet and positions unchanged');
  253. activity(21, ['quota_enabled' => 1, 'type' => 6, 'sort' => 8, 'reward_quota' => 3, 'params' => '{"daily_interest_rate":"1.00","number_of_consecutive_days_of_deposit":7}']);
  254. check($newMember->add()['code'] === 1, 'Qualified new position succeeds with user slots available');
  255. check((int)\think\facade\Db::table('bot_yuebao_item')->order('id', 'desc')->value('activity_id') === 21, 'New position links selected activity for payout');
  256. check((int)\think\facade\Db::table('bot_activity_rewards')->where('id', 21)->value('rewarded_count') === 0 && \think\facade\Db::table('bot_activity_reward_recipients')->where('activity_id', 21)->count() === 0, 'Principal transfer and position creation do not consume a winner slot');
  257. activity(22, ['quota_enabled' => 1, 'type' => 6, 'sort' => 9, 'reward_quota' => 3, 'params' => '{"daily_interest_rate":"1.00"}']);
  258. \think\facade\Db::startTrans();
  259. $locked = \app\common\service\ActivityQuotaService::lockActivities([22, 21, 22, null, 0, 999]);
  260. check(array_keys($locked) === [21, 22], 'Quota locks use unique ascending IDs and exclude absent rows');
  261. \think\facade\Db::commit();
  262. foreach ([[4, '3001', [[50,22],[51,21]]], [5, '3002', [[60,21],[61,22]]]] as [$userPk, $userId, $items]) {
  263. row('bot_users', ['id' => $userPk, 'user_id' => $userId, 'member_id' => $userId, 'created_at' => '2026-10-01 12:00:00', 'recharge_channel_group_id' => 1]);
  264. row('bot_wallets', ['id' => $userPk, 'member_id' => $userId, 'available_balance' => '0.00', 'frozen_balance' => '0.00', 'yuebao' => '200.00', 'yuebao_invest' => '0.00']);
  265. foreach ($items as [$itemId, $activityId]) {
  266. row('bot_yuebao_item', ['id' => $itemId, 'user_id' => $userId, 'activity_id' => $activityId, 'principal' => '100.00', 'surplus_principal' => '100.00', 'daily_interest_rate' => '1.00', 'expire_time' => time() + 100, 'create_time' => time() - 86400]);
  267. }
  268. request()->postData = ['amount' => '200.00'];
  269. $multi = (new \app\api\controller\YuebaoController((int)$userId))->out();
  270. check($multi['code'] === 1 && bccomp((string)$multi['data']['interest'], '2.00', 2) === 0, 'Two linked quota payouts succeed independently of FIFO activity order for ' . $userId);
  271. }
  272. foreach ([21, 22] as $id) {
  273. check((int)\think\facade\Db::table('bot_activity_rewards')->where('id', $id)->value('rewarded_count') === 2, 'Each activity tracks two distinct winning members: ' . $id);
  274. }
  275. activity(23, ['quota_enabled' => 1, 'type' => 6, 'sort' => 100, 'reward_quota' => 1, 'params' => '{"daily_interest_rate":"999.00"}']);
  276. $invalidateCandidates = true;
  277. $db->event('before_find', function ($query) use (&$invalidateCandidates) {
  278. if ($invalidateCandidates && $query->getTable() === 'bot_activity_rewards' && $query->getOptions('lock')) {
  279. $invalidateCandidates = false;
  280. \think\facade\Db::table('bot_activity_rewards')->where('id', 23)->delete();
  281. \think\facade\Db::table('bot_activity_rewards')->where('id', 22)->update(['status' => 0]);
  282. }
  283. });
  284. \think\facade\Db::startTrans();
  285. $fresh = \app\common\model\ActivityRewards::getYuebaoActivity('2001');
  286. \think\facade\Db::commit();
  287. check($fresh['id'] === 21 && (string)$fresh['daily_interest_rate'] === '1.00', 'New position config excludes deleted or disabled snapshots after quota locks');
  288. activity(30, ['quota_enabled' => 1, 'type' => 6, 'reward_quota' => 1]);
  289. row('bot_users', ['id' => 6, 'user_id' => '3003', 'member_id' => '3003', 'created_at' => '2026-10-01 12:00:00', 'recharge_channel_group_id' => 1]);
  290. row('bot_wallets', ['id' => 6, 'member_id' => '3003', 'available_balance' => '0.00', 'frozen_balance' => '0.00', 'yuebao' => '100.00', 'yuebao_invest' => '0.00']);
  291. row('bot_yuebao_item', ['id' => 70, 'user_id' => '3003', 'activity_id' => 30, 'principal' => '100.00', 'surplus_principal' => '100.00', 'daily_interest_rate' => '1.00', 'expire_time' => time() + 100, 'create_time' => time() - 86400]);
  292. $db->execute("CREATE TRIGGER fail_yuebao_log BEFORE INSERT ON bot_yuebao_log WHEN NEW.item_id = 70 BEGIN SELECT RAISE(ABORT, 'simulated payout write failure'); END");
  293. request()->postData = ['amount' => '100.00'];
  294. $failureController = new \app\api\controller\YuebaoController(3003);
  295. check($failureController->out()['code'] === 0, 'Actual settlement write failure rejects payout');
  296. check((int)\think\facade\Db::table('bot_activity_rewards')->where('id', 30)->value('rewarded_count') === 0 && \think\facade\Db::table('bot_activity_reward_recipients')->where('activity_id', 30)->count() === 0, 'Failed payout rolls back recipient and distinct-winner slot');
  297. $failedWallet = \think\facade\Db::table('bot_wallets')->where('id', 6)->find();
  298. check(bccomp($failedWallet['yuebao'], '100.00', 2) === 0 && bccomp($failedWallet['available_balance'], '0.00', 2) === 0 && bccomp($failedWallet['frozen_balance'], '0.00', 2) === 0, 'Failed payout leaves original principal and balances intact');
  299. check((int)\think\facade\Db::table('bot_yuebao_item')->where('id', 70)->value('status') === 1 && bccomp((string)\think\facade\Db::table('bot_yuebao_item')->where('id', 70)->value('surplus_principal'), '100.00', 2) === 0, 'Failed payout keeps the position unsettled');
  300. $db->execute('DROP TRIGGER fail_yuebao_log');
  301. check($failureController->out()['code'] === 1 && (int)\think\facade\Db::table('bot_activity_rewards')->where('id', 30)->value('rewarded_count') === 1, 'Retried successful payout occupies exactly one winner slot');
  302. activity(31, ['quota_enabled' => 0, 'budget' => '10.00', 'budget_used' => '0.00', 'reward_quota' => 0]);
  303. \think\facade\Db::startTrans();
  304. check(\app\common\service\ActivityQuotaService::consume(31, '2001', '6.00'), 'Legacy activity still accepts six under monetary budget ten');
  305. check(!\app\common\service\ActivityQuotaService::consume(31, '2002', '5.00'), 'Legacy activity denies five when only four money units remain');
  306. check(!\app\common\service\ActivityQuotaService::consume(31, '2001', '5.00'), 'Legacy monetary cap applies to repeat member exactly as before');
  307. check(bccomp((string)\think\facade\Db::table('bot_activity_rewards')->where('id', 31)->value('budget_used'), '6.00', 2) === 0, 'Denied legacy payout preserves consumed monetary budget');
  308. check(\app\common\service\ActivityQuotaService::consume(31, '2001', '4.00'), 'Legacy activity accepts exact remaining monetary budget');
  309. \think\facade\Db::commit();
  310. $legacyData = \think\facade\Db::table('bot_activity_rewards')->where('id', 31)->find();
  311. check(!\app\common\service\ActivityQuotaService::canAward($legacyData, '2001'), 'Legacy exhausted monetary budget remains exhausted');
  312. check((int)$legacyData['rewarded_count'] === 0 && \think\facade\Db::table('bot_activity_reward_recipients')->where('activity_id', 31)->count() === 0, 'Legacy payouts never write distinct-user recipients or new count');
  313. $legacyPublic = $service->visibleActivity($legacyData);
  314. check(array_key_exists('budget', $legacyPublic) && array_key_exists('budget_used', $legacyPublic) && bccomp((string)$legacyPublic['budget'], '10.00', 2) === 0 && bccomp((string)$legacyPublic['budget_used'], '10.00', 2) === 0 && (int)$legacyPublic['quota_enabled'] === 0, 'Legacy activity response preserves original monetary fields and marks legacy mode');
  315. activity(32, ['quota_enabled' => 0, 'budget' => null, 'reward_quota' => 0]);
  316. \think\facade\Db::startTrans();
  317. check(\app\common\service\ActivityQuotaService::consume(32, '2002', '1000.00'), 'Legacy unlimited monetary budget ignores new zero-user quota');
  318. \think\facade\Db::commit();
  319. check(bccomp((string)\think\facade\Db::table('bot_activity_rewards')->where('id', 32)->value('budget_used'), '1000.00', 2) === 0 && \think\facade\Db::table('bot_activity_reward_recipients')->where('activity_id', 32)->count() === 0, 'Legacy unlimited payouts retain money usage audit and do not count quota winners');
  320. activity(33, ['type' => 2, 'quota_enabled' => 1, 'reward_quota' => 0, 'budget' => '0.00']);
  321. $footballQuota = \think\facade\Db::table('bot_activity_rewards')->where('id', 33)->find();
  322. check(\app\common\service\ActivityQuotaService::canAward($footballQuota, null), 'Football bypasses both modes even with new immutable marker enabled');
  323. \think\facade\Db::startTrans();
  324. check(\app\common\service\ActivityQuotaService::consume(33, '2001', '1000.00'), 'Football bypasses zero quota and zero monetary budget');
  325. \think\facade\Db::commit();
  326. check((int)\think\facade\Db::table('bot_activity_rewards')->where('id', 33)->value('rewarded_count') === 0 && \think\facade\Db::table('bot_activity_reward_recipients')->where('activity_id', 33)->count() === 0, 'Football never registers or consumes quota winners');
  327. echo 'PASS ' . $assertions . " adapter assertions (actual ThinkORM + isolated in-memory SQLite; no live database)\n";
  328. }