|
|
@@ -0,0 +1,176 @@
|
|
|
+<?php
|
|
|
+namespace app\api\controller {
|
|
|
+ class BaseApiController {
|
|
|
+ protected int $userId = 0;
|
|
|
+ protected string $lang = 'zh';
|
|
|
+ public $request;
|
|
|
+ public function __construct($userId = 0) { $this->userId = $userId; $this->request = \request(); }
|
|
|
+ protected function success($message = '', $data = []) { return ['code' => 1, 'data' => $data, 'msg' => $message]; }
|
|
|
+ protected function fail($message = '') { return ['code' => 0, 'msg' => $message]; }
|
|
|
+ }
|
|
|
+}
|
|
|
+namespace app\api\lists {
|
|
|
+ class BaseApiDataLists {
|
|
|
+ protected int $userId = 0;
|
|
|
+ protected string $lang = 'zh';
|
|
|
+ protected array $params = [];
|
|
|
+ protected array $searchWhere = [];
|
|
|
+ public int $limitOffset = 0;
|
|
|
+ public int $limitLength = 1;
|
|
|
+ public function __construct($userId = 0, $type = null) { $this->userId = $userId; $this->params = ['type' => $type]; }
|
|
|
+ public function setParams($params): self { $this->params = $params; return $this; }
|
|
|
+ }
|
|
|
+}
|
|
|
+namespace {
|
|
|
+ function request() { return $GLOBALS['activity_test_request']; }
|
|
|
+ function config($key, $default = null) { return $default; }
|
|
|
+ function env($key, $default = '') { return $key === 'app.bot_host' ? 'https://example.test' : $default; }
|
|
|
+ function ensureUrl($url, $host = '') { return $url; }
|
|
|
+ function json($value) { return $value; }
|
|
|
+ class TestRequest {
|
|
|
+ public array $query = [];
|
|
|
+ public array $postData = [];
|
|
|
+ public array $headers = [];
|
|
|
+ public function param($key, $default = null) { return $this->postData[$key] ?? $this->query[$key] ?? $default; }
|
|
|
+ public function get($key, $default = null) { return $this->query[$key] ?? $default; }
|
|
|
+ public function header($key, $default = null) { return $this->headers[strtolower($key)] ?? $default; }
|
|
|
+ public function post($key, $default = null, $filter = null) { $v = $this->postData[$key] ?? $default; return $filter ? $filter($v) : $v; }
|
|
|
+ }
|
|
|
+ $GLOBALS['activity_test_request'] = new TestRequest();
|
|
|
+ $repo = getenv('ACTIVITY_SITE_REPO') ?: dirname(__DIR__, 3) . '/melbet_sport-api';
|
|
|
+ $stage = getenv('ACTIVITY_SITE_STAGE') ?: $repo;
|
|
|
+ $loader = require $repo . '/vendor/autoload.php';
|
|
|
+ $loader->setPsr4('app\\', [$stage . '/app/', $repo . '/app/']);
|
|
|
+ $db = new \think\DbManager();
|
|
|
+ $db->setConfig(['default' => 'sqlite', 'connections' => ['sqlite' => ['type' => 'sqlite', 'database' => ':memory:', 'prefix' => '', 'fields_strict' => false]], 'auto_timestamp' => false]);
|
|
|
+ \think\Container::getInstance()->instance(\think\DbManager::class, $db);
|
|
|
+ \think\Container::getInstance()->instance('log', new \Psr\Log\NullLogger());
|
|
|
+ $tables = [
|
|
|
+ 'bot_users' => 'id INTEGER PRIMARY KEY,user_id TEXT,member_id TEXT,created_at TEXT,delete_time INTEGER,recharge_channel_group_id INTEGER',
|
|
|
+ 'bot_user_login' => 'id INTEGER PRIMARY KEY,user_id TEXT,status INTEGER,platform TEXT',
|
|
|
+ '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',
|
|
|
+ 'bot_recharge_channel_group' => 'id INTEGER PRIMARY KEY,activity_type TEXT',
|
|
|
+ '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,click_type TEXT DEFAULT "none",link TEXT,link_pc TEXT,link_h5 TEXT,click_position 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',
|
|
|
+ '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)',
|
|
|
+ 'bot_wallets' => 'id INTEGER PRIMARY KEY,member_id TEXT,available_balance TEXT,frozen_balance TEXT,yuebao TEXT,yuebao_invest TEXT',
|
|
|
+ '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',
|
|
|
+ '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',
|
|
|
+ ];
|
|
|
+ foreach ($tables as $table => $columns) { $db->execute('CREATE TABLE ' . $table . '(' . $columns . ')'); }
|
|
|
+ $assertions = 0;
|
|
|
+ function check($condition, $message) { global $assertions; ++$assertions; if (!$condition) { throw new \RuntimeException($message); } }
|
|
|
+ function row($table, $data) { \think\facade\Db::table($table)->insert($data); }
|
|
|
+ function activity($id, $override = []) {
|
|
|
+ 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));
|
|
|
+ }
|
|
|
+ row('bot_users', ['id' => 1, 'user_id' => '1001', 'member_id' => '2001', 'created_at' => '2026-10-01 12:00:00', 'recharge_channel_group_id' => 1]);
|
|
|
+ row('bot_recharge_channel_group', ['id' => 1, 'activity_type' => 'recharge,yuebao']);
|
|
|
+ activity(10, ['sort'=>10, 'type'=>6, 'quota_enabled'=>1, 'reward_quota'=>1, 'params'=>'{"daily_interest_rate":"1.00","number_of_consecutive_days_of_deposit":7}']);
|
|
|
+ row('bot_wallets', ['id' => 1, 'member_id' => '2001', 'available_balance' => '50.00', 'frozen_balance' => '0.00', 'yuebao' => '100.00', 'yuebao_invest' => '0.00']);
|
|
|
+ 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]);
|
|
|
+ $command = new \app\api\command\YuebaoInvest();
|
|
|
+ $invest = new \ReflectionMethod($command, 'invest');
|
|
|
+ $invest->invoke($command);
|
|
|
+ $wallet = \think\facade\Db::table('bot_wallets')->where('id', 1)->find();
|
|
|
+ check(bccomp($wallet['available_balance'], '151.00', 2) === 0, 'Automatic expiry credits principal and interest available');
|
|
|
+ check(bccomp($wallet['frozen_balance'], '0.00', 2) === 0, 'Automatic expiry leaves frozen balance unchanged');
|
|
|
+ check((int)\think\facade\Db::table('bot_activity_rewards')->where('id', 10)->value('rewarded_count') === 1, 'Automatic expiry records first distinct winner');
|
|
|
+ $invest->invoke($command);
|
|
|
+ check(\think\facade\Db::table('bot_yuebao_log')->count() === 1, 'Repeated expiry does not duplicate settlement');
|
|
|
+ row('bot_users', ['id' => 2, 'user_id' => '1002', 'member_id' => '2002', 'created_at' => '2026-10-01 12:00:00', 'recharge_channel_group_id' => 1]);
|
|
|
+ row('bot_wallets', ['id' => 2, 'member_id' => '2002', 'available_balance' => '50.00', 'frozen_balance' => '0.00', 'yuebao' => '100.00', 'yuebao_invest' => '0.00']);
|
|
|
+ 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]);
|
|
|
+ $invest->invoke($command);
|
|
|
+ $wallet = \think\facade\Db::table('bot_wallets')->where('id', 2)->find();
|
|
|
+ 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');
|
|
|
+ \think\facade\Db::table('bot_wallets')->where('id', 1)->update(['yuebao' => '100.00']);
|
|
|
+ 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]);
|
|
|
+ $invest->invoke($command);
|
|
|
+ $wallet = \think\facade\Db::table('bot_wallets')->where('id', 1)->find();
|
|
|
+ check(bccomp($wallet['available_balance'], '252.00', 2) === 0 && bccomp($wallet['frozen_balance'], '0.00', 2) === 0, 'Existing winner continues to receive saved-rate interest at full quota');
|
|
|
+ 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');
|
|
|
+ $offset = 12 - (int)gmdate('G');
|
|
|
+ date_default_timezone_set('Etc/GMT' . ($offset >= 0 ? '-' : '+') . abs($offset));
|
|
|
+ activity(20, ['quota_enabled' => 1, 'type' => 6, 'reward_quota' => 1, 'params' => '{"daily_interest_rate":"1.00","number_of_consecutive_days_of_deposit":7}']);
|
|
|
+ \think\facade\Db::table('bot_wallets')->where('id', 1)->update(['yuebao' => '100.00']);
|
|
|
+ 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]);
|
|
|
+ request()->postData = ['amount' => '100.00'];
|
|
|
+ \think\facade\Db::table('bot_wallets')->where('id', 1)->update(['frozen_balance'=>'7.00']);
|
|
|
+ $yuebao = new \app\api\controller\YuebaoController(1001);
|
|
|
+ $out = $yuebao->out();
|
|
|
+ $wallet = \think\facade\Db::table('bot_wallets')->where('id', 1)->find();
|
|
|
+ check($out['code'] === 1 && bccomp((string)$out['data']['interest'], '1.00', 2) === 0, 'Manual out awards interest and occupies first-user slot');
|
|
|
+ check(bccomp($wallet['available_balance'], '353.00', 2) === 0 && bccomp($wallet['frozen_balance'], '7.00', 2) === 0, 'Manual out credits principal and interest available and preserves historical frozen funds');
|
|
|
+ check((int)\think\facade\Db::table('bot_activity_rewards')->where('id', 20)->value('rewarded_count') === 1, 'Manual payout counts one distinct member');
|
|
|
+ check($yuebao->out()['code'] === 0, 'Repeated manual out cannot settle the same position twice');
|
|
|
+ \think\facade\Db::table('bot_wallets')->where('id', 2)->update(['yuebao' => '100.00']);
|
|
|
+ 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]);
|
|
|
+ check(bccomp((string)(new \app\api\controller\YuebaoController(1002))->out()['data']['interest'], '0', 2) === 0, 'Manual out denies other-member interest at full quota');
|
|
|
+ $wallet = \think\facade\Db::table('bot_wallets')->where('id', 2)->find();
|
|
|
+ check(bccomp($wallet['available_balance'], '250.00', 2) === 0 && bccomp($wallet['frozen_balance'], '0.00', 2) === 0, 'Manual quota denial preserves principal');
|
|
|
+ \think\facade\Db::table('bot_wallets')->where('id', 1)->update(['yuebao' => '100.00']);
|
|
|
+ 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]);
|
|
|
+ check(bccomp((string)$yuebao->out()['data']['interest'], '1.00', 2) === 0, 'Legacy unlinked position keeps saved rate without invented activity association');
|
|
|
+ check(\app\common\model\ActivityRewards::getYuebaoActivity('2001')['id'] === 10, 'Existing winner remains eligible for new positions when quota is full');
|
|
|
+ row('bot_users', ['id' => 3, 'user_id' => '1003', 'member_id' => '2003', 'created_at' => '2026-10-01 12:00:00', 'recharge_channel_group_id' => 1]);
|
|
|
+ row('bot_user_login', ['id' => 4, 'user_id' => '2003', 'status' => 1, 'platform' => 'iOS']);
|
|
|
+ row('bot_wallets', ['id' => 3, 'member_id' => '2003', 'available_balance' => '500.00', 'frozen_balance' => '0.00', 'yuebao' => '0.00', 'yuebao_invest' => '0.00']);
|
|
|
+ row('bot_balance_logs', ['member_id' => '2003', 'type' => 1, 'change_type' => '极速28投注', 'amount' => '-10000.00', 'created_at' => date('Y-m-d H:i:s')]);
|
|
|
+ $newMember = new \app\api\controller\YuebaoController(1003);
|
|
|
+ $beforeItems = \think\facade\Db::table('bot_yuebao_item')->count();
|
|
|
+ check($newMember->add()['code'] === 0, 'Different member cannot open linked position when all eligible configs have full quotas');
|
|
|
+ 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');
|
|
|
+ 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}']);
|
|
|
+ check($newMember->add()['code'] === 1, 'Qualified new position succeeds with user slots available');
|
|
|
+ check((int)\think\facade\Db::table('bot_yuebao_item')->order('id', 'desc')->value('activity_id') === 21, 'New position links selected activity for payout');
|
|
|
+ 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');
|
|
|
+ activity(22, ['quota_enabled' => 1, 'type' => 6, 'sort' => 9, 'reward_quota' => 3, 'params' => '{"daily_interest_rate":"1.00"}']);
|
|
|
+ \think\facade\Db::startTrans();
|
|
|
+ $locked = \app\common\service\ActivityQuotaService::lockActivities([22, 21, 22, null, 0, 999]);
|
|
|
+ check(array_keys($locked) === [21, 22], 'Quota locks use unique ascending IDs and exclude absent rows');
|
|
|
+ \think\facade\Db::commit();
|
|
|
+ foreach ([[4, '3001', [[50,22],[51,21]]], [5, '3002', [[60,21],[61,22]]]] as [$userPk, $userId, $items]) {
|
|
|
+ row('bot_users', ['id' => $userPk, 'user_id' => $userId, 'member_id' => $userId, 'created_at' => '2026-10-01 12:00:00', 'recharge_channel_group_id' => 1]);
|
|
|
+ row('bot_wallets', ['id' => $userPk, 'member_id' => $userId, 'available_balance' => '0.00', 'frozen_balance' => '0.00', 'yuebao' => '200.00', 'yuebao_invest' => '0.00']);
|
|
|
+ foreach ($items as [$itemId, $activityId]) {
|
|
|
+ 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]);
|
|
|
+ }
|
|
|
+ request()->postData = ['amount' => '200.00'];
|
|
|
+ $multi = (new \app\api\controller\YuebaoController((int)$userId))->out();
|
|
|
+ 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);
|
|
|
+ }
|
|
|
+ foreach ([21, 22] as $id) {
|
|
|
+ check((int)\think\facade\Db::table('bot_activity_rewards')->where('id', $id)->value('rewarded_count') === 2, 'Each activity tracks two distinct winning members: ' . $id);
|
|
|
+ }
|
|
|
+ activity(30, ['quota_enabled' => 1, 'type' => 6, 'reward_quota' => 1]);
|
|
|
+ row('bot_users', ['id' => 6, 'user_id' => '3003', 'member_id' => '3003', 'created_at' => '2026-10-01 12:00:00', 'recharge_channel_group_id' => 1]);
|
|
|
+ row('bot_wallets', ['id' => 6, 'member_id' => '3003', 'available_balance' => '0.00', 'frozen_balance' => '0.00', 'yuebao' => '100.00', 'yuebao_invest' => '0.00']);
|
|
|
+ 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]);
|
|
|
+ $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");
|
|
|
+ request()->postData = ['amount' => '100.00'];
|
|
|
+ $failureController = new \app\api\controller\YuebaoController(3003);
|
|
|
+ check($failureController->out()['code'] === 0, 'Actual settlement write failure rejects payout');
|
|
|
+ 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');
|
|
|
+ $failedWallet = \think\facade\Db::table('bot_wallets')->where('id', 6)->find();
|
|
|
+ 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');
|
|
|
+ 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');
|
|
|
+ $db->execute('DROP TRIGGER fail_yuebao_log');
|
|
|
+ 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');
|
|
|
+ row('bot_users', ['id'=>8, 'user_id'=>'4000', 'member_id'=>'4000']);
|
|
|
+ row('bot_wallets', ['id'=>8, 'member_id'=>'4000', 'available_balance'=>'0.1234567890', 'frozen_balance'=>'9.00', 'yuebao'=>'100', 'yuebao_invest'=>'0']);
|
|
|
+ row('bot_yuebao_item', ['id'=>80, 'user_id'=>'4000', 'principal'=>'100', 'surplus_principal'=>'100', 'daily_interest_rate'=>'1', 'expire_time'=>time()+3600, 'create_time'=>time()-86400]);
|
|
|
+ request()->postData = ['amount'=>'50.00'];
|
|
|
+ $partial = (new \app\api\controller\YuebaoController(4000))->out();
|
|
|
+ $partialWallet = \think\facade\Db::table('bot_wallets')->where('id',8)->find();
|
|
|
+ check($partial['code'] === 1 && bccomp($partialWallet['available_balance'],'50.6234567890',10) === 0, 'Partial payout credits principal plus interest without losing wallet precision');
|
|
|
+ check(bccomp($partialWallet['frozen_balance'],'9',10) === 0 && bccomp($partialWallet['yuebao'],'50',2) === 0, 'Partial payout preserves frozen funds and remaining principal');
|
|
|
+ $payoutLogs = \think\facade\Db::table('bot_balance_logs')->where('member_id','4000')->order('id')->select()->toArray();
|
|
|
+ check(count($payoutLogs) === 2 && bccomp($payoutLogs[0]['after_balance'],$payoutLogs[1]['before_balance'],10) === 0, 'Principal and interest ledger balances are continuous');
|
|
|
+ $logs = \think\facade\Db::table('bot_balance_logs')->where('member_id', '2001')->where('change_type', '余额宝利息')->select()->toArray();
|
|
|
+ check(count($logs) === 4, 'Exactly one interest credit per successful payout');
|
|
|
+ foreach ($logs as $log) {
|
|
|
+ check((int)$log['type'] === 1 && $log['frozen_status'] === null, 'Interest is available and never marked for unfreezing');
|
|
|
+ check(bccomp(bcsub($log['after_balance'], $log['before_balance'], 10), $log['amount'], 10) === 0, 'Interest ledger balances reconcile');
|
|
|
+ }
|
|
|
+ echo 'PASS ' . $assertions . " adapter assertions (actual ThinkORM + isolated in-memory SQLite; no live database)\n";
|
|
|
+}
|