diff --git a/.env-example b/.env-example index f429a07..245c9e1 100644 --- a/.env-example +++ b/.env-example @@ -21,3 +21,6 @@ DATABASE_PREFIX = # 缓存(config/cache.php) CACHE_DRIVER = file + +# 移动端接口鉴权(/api/v1/authToken) +AUTH_TOKEN_SECRET = 564d14asdasd113e46542asd6das1a2a diff --git a/app/api/controller/Account.php b/app/api/controller/Account.php index 0c16fbb..a4f3aa8 100644 --- a/app/api/controller/Account.php +++ b/app/api/controller/Account.php @@ -10,6 +10,7 @@ use app\common\facade\Token; use app\common\model\UserScoreLog; use app\common\model\UserMoneyLog; use app\common\controller\Frontend; +use app\common\facade\Token as TokenFacade; use support\validation\Validator; use support\validation\ValidationException; use Webman\Http\Request; @@ -20,6 +21,51 @@ class Account extends Frontend protected array $noNeedLogin = ['retrievePassword']; protected array $noNeedPermission = ['verification', 'changeBind']; + public function userProfile(Request $request): Response + { + $response = $this->initializeFrontend($request); + if ($response !== null) { + return $response; + } + + $authToken = trim((string) $request->header('auth-token', '')); + if ($authToken === '') { + return $this->mobileResult(1101, 'Missing auth-token'); + } + $tokenData = TokenFacade::get($authToken); + $type = $tokenData['type'] ?? ''; + $expireTime = $tokenData['expire_time'] ?? 0; + if ($type !== 'auth-token' || !is_numeric($expireTime) || $expireTime < time()) { + return $this->mobileResult(1101, 'auth-token is invalid or expired'); + } + + $user = $this->auth->getUser(); + $payload = [ + 'code' => 1, + 'message' => __('ok'), + 'data' => [ + 'id' => $user->id, + 'username' => $user->username, + 'head_image' => $user->avatar ?? '', + 'coin' => $user->coin, + 'current_streak' => $user->current_streak ?? 0, + 'channel_id' => $user->channel_id, + 'risk_flags' => $user->risk_flags ?? 0, + ], + ]; + return \response(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 200, ['Content-Type' => 'application/json']); + } + + private function mobileResult(int $code, string $message, array $data = []): Response + { + $payload = [ + 'code' => $code, + 'message' => __($message), + 'data' => $data, + ]; + return \response(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 200, ['Content-Type' => 'application/json']); + } + public function overview(Request $request): Response { $response = $this->initializeFrontend($request); diff --git a/app/api/controller/Auth.php b/app/api/controller/Auth.php new file mode 100644 index 0000000..82aa976 --- /dev/null +++ b/app/api/controller/Auth.php @@ -0,0 +1,138 @@ +initializeMobile($request); + if ($response !== null) { + return $response; + } + + $account = trim((string) $request->post('account', '')); + $accountType = trim((string) $request->post('account_type', '')); + $password = (string) $request->post('password', ''); + $inviteCode = trim((string) $request->post('invite_code', '')); + + if ($account === '' || $accountType === '' || $password === '') { + return $this->mobileError(1001, 'Missing parameters'); + } + if ($accountType !== 'phone' && $accountType !== 'email') { + return $this->mobileError(1003, 'Invalid parameter value'); + } + + $username = $account; + $mobile = ''; + $email = ''; + if ($accountType === 'phone') { + $mobile = $account; + } + if ($accountType === 'email') { + $email = $account; + } + + $extend = []; + if ($inviteCode !== '') { + $inviterAdmin = Db::name('admin')->field(['id', 'channel_id'])->where('invite_code', $inviteCode)->find(); + if (!$inviterAdmin) { + return $this->mobileError(2002, 'Invite code does not exist'); + } + $extend['register_invite_code'] = $inviteCode; + $extend['admin_id'] = $inviterAdmin['id']; + $extend['channel_id'] = $inviterAdmin['channel_id'] ?? null; + } + + $registered = $this->auth->register($username, $password, $mobile, $email, 1, $extend); + if (!$registered) { + return $this->mobileError(2000, (string) $this->auth->getError()); + } + + $loggedIn = $this->auth->login($username, $password, true); + if (!$loggedIn) { + return $this->mobileError(2000, 'Registered successfully but login failed'); + } + + $userInfo = $this->auth->getUserInfo(); + return $this->mobileSuccess([ + 'user_id' => $userInfo['id'] ?? null, + 'access_token' => $userInfo['token'] ?? '', + 'expires_in' => config('buildadmin.user_token_keep_time', 259200), + 'profile' => [ + 'username' => $userInfo['username'] ?? '', + 'coin' => $userInfo['coin'] ?? '0.0000', + 'channel_id' => $userInfo['channel_id'] ?? null, + ], + ]); + } + + public function userLogin(Request $request): Response + { + $response = $this->initializeMobile($request); + if ($response !== null) { + return $response; + } + + $account = trim((string) $request->post('account', '')); + $password = (string) $request->post('password', ''); + if ($account === '' || $password === '') { + return $this->mobileError(1001, 'Missing parameters'); + } + + $ok = $this->auth->login($account, $password, true); + if (!$ok) { + return $this->mobileError(1101, 'Incorrect account or password'); + } + $userInfo = $this->auth->getUserInfo(); + return $this->mobileSuccess([ + 'access_token' => $userInfo['token'] ?? '', + 'refresh_token' => $userInfo['refresh_token'] ?? '', + 'expires_in' => config('buildadmin.user_token_keep_time', 259200), + 'user' => [ + 'id' => $userInfo['id'] ?? null, + 'username' => $userInfo['username'] ?? '', + 'coin' => $userInfo['coin'] ?? '0.0000', + 'risk_flags' => $userInfo['risk_flags'] ?? 0, + ], + ]); + } + + public function tokenRefresh(Request $request): Response + { + $response = $this->initializeMobile($request); + if ($response !== null) { + return $response; + } + + $refreshToken = trim((string) $request->post('refresh_token', '')); + if ($refreshToken === '') { + return $this->mobileError(1001, 'Missing parameters'); + } + + $tokenData = Token::get($refreshToken); + if (!$tokenData || $tokenData['type'] !== UserAuth::TOKEN_TYPE . '-refresh' || $tokenData['expire_time'] < time()) { + return $this->mobileError(1101, 'Login status has expired'); + } + + $newToken = Random::uuid(); + Token::set($newToken, UserAuth::TOKEN_TYPE, $tokenData['user_id'], config('buildadmin.user_token_keep_time', 259200)); + return $this->mobileSuccess([ + 'access_token' => $newToken, + 'expires_in' => config('buildadmin.user_token_keep_time', 259200), + ]); + } +} + diff --git a/app/api/controller/Finance.php b/app/api/controller/Finance.php new file mode 100644 index 0000000..5a16cd1 --- /dev/null +++ b/app/api/controller/Finance.php @@ -0,0 +1,173 @@ +initializeMobile($request); + if ($response !== null) { + return $response; + } + $payAmountFiat = (string) $request->post('pay_amount_fiat', ''); + $fiatCurrency = trim((string) $request->post('fiat_currency', '')); + $channel = trim((string) $request->post('channel', '')); + $idempotencyKey = trim((string) $request->post('idempotency_key', '')); + if ($payAmountFiat === '' || $fiatCurrency === '' || $channel === '' || $idempotencyKey === '') { + return $this->mobileError(1001, 'Missing parameters'); + } + + $orderNo = 'DP' . date('YmdHis') . substr(str_replace('.', '', uniqid('', true)), -6); + $coinAmount = $payAmountFiat; + DepositOrder::create([ + 'order_no' => $orderNo, + 'user_id' => $this->auth->id, + 'fiat_currency' => $fiatCurrency, + 'fiat_amount' => $payAmountFiat, + 'fx_rate' => '1.00000000', + 'coin_amount' => $coinAmount, + 'gateway' => $channel, + 'status' => 0, + 'create_time' => time(), + 'update_time' => time(), + ]); + + return $this->mobileSuccess([ + 'order_no' => $orderNo, + 'coin_amount' => $coinAmount, + 'pay_url' => '', + 'status' => 'pending', + ]); + } + + public function depositDetail(Request $request): Response + { + $response = $this->initializeMobile($request); + if ($response !== null) { + return $response; + } + $orderNo = trim((string) $request->get('order_no', '')); + if ($orderNo === '') { + return $this->mobileError(1001, 'Missing parameters'); + } + $order = DepositOrder::where('order_no', $orderNo)->where('user_id', $this->auth->id)->find(); + if (!$order) { + return $this->mobileError(2003, 'Order does not exist'); + } + return $this->mobileSuccess([ + 'order_no' => $order->order_no, + 'status' => $this->mapDepositStatus($order->status), + 'coin_amount' => $order->coin_amount, + 'create_time' => $order->create_time, + 'finish_time' => $order->paid_at, + ]); + } + + public function withdrawCreate(Request $request): Response + { + $response = $this->initializeMobile($request); + if ($response !== null) { + return $response; + } + $withdrawCoin = (string) $request->post('withdraw_coin', ''); + $receiveAccount = trim((string) $request->post('receive_account', '')); + $receiveType = trim((string) $request->post('receive_type', '')); + $idempotencyKey = trim((string) $request->post('idempotency_key', '')); + if ($withdrawCoin === '' || $receiveAccount === '' || $receiveType === '' || $idempotencyKey === '') { + return $this->mobileError(1001, 'Missing parameters'); + } + $user = $this->auth->getUser(); + if (bccomp((string) $user->coin, $withdrawCoin, 4) < 0) { + return $this->mobileError(2001, 'Insufficient balance'); + } + + $orderNo = 'WD' . date('YmdHis') . substr(str_replace('.', '', uniqid('', true)), -6); + $feeCoin = bcmul($withdrawCoin, '0.005', 4); + $actualArrivalCoin = bcsub($withdrawCoin, $feeCoin, 4); + WithdrawOrder::create([ + 'order_no' => $orderNo, + 'user_id' => $user->id, + 'apply_amount' => $withdrawCoin, + 'fee_amount' => $feeCoin, + 'actual_amount' => $actualArrivalCoin, + 'fiat_currency' => '', + 'need_audit' => 1, + 'audit_status' => 0, + 'reject_reason' => '', + 'create_time' => time(), + 'update_time' => time(), + ]); + + return $this->mobileSuccess([ + 'order_no' => $orderNo, + 'status' => 'pending_review', + 'fee_coin' => $feeCoin, + 'actual_arrival_coin' => $actualArrivalCoin, + 'risk_review_required' => true, + ]); + } + + public function withdrawDetail(Request $request): Response + { + $response = $this->initializeMobile($request); + if ($response !== null) { + return $response; + } + $orderNo = trim((string) $request->get('order_no', '')); + if ($orderNo === '') { + return $this->mobileError(1001, 'Missing parameters'); + } + $order = WithdrawOrder::where('order_no', $orderNo)->where('user_id', $this->auth->id)->find(); + if (!$order) { + return $this->mobileError(2003, 'Order does not exist'); + } + return $this->mobileSuccess([ + 'order_no' => $order->order_no, + 'status' => $this->mapWithdrawStatus($order->audit_status), + 'withdraw_coin' => $order->apply_amount, + 'fee_coin' => $order->fee_amount, + 'reject_reason' => $order->reject_reason === '' ? null : $order->reject_reason, + 'create_time' => $order->create_time, + ]); + } + + private function mapDepositStatus($status): string + { + if ($this->intValue($status) === 1) { + return 'paid'; + } + if ($this->intValue($status) === 2 || $this->intValue($status) === 3) { + return 'failed'; + } + return 'pending'; + } + + private function mapWithdrawStatus($auditStatus): string + { + if ($this->intValue($auditStatus) === 1) { + return 'approved'; + } + if ($this->intValue($auditStatus) === 2) { + return 'rejected'; + } + return 'pending_review'; + } + + private function intValue($value): int + { + $result = filter_var($value, FILTER_VALIDATE_INT); + if ($result === false) { + return 0; + } + return $result; + } +} + diff --git a/app/api/controller/Game.php b/app/api/controller/Game.php new file mode 100644 index 0000000..4d016cc --- /dev/null +++ b/app/api/controller/Game.php @@ -0,0 +1,320 @@ +initializeMobile($request); + if ($response !== null) { + return $response; + } + + $period = GameRecord::order('id', 'desc')->find(); + $now = time(); + $startAt = $period ? $this->intValue($period->period_start_at) : $now; + $lockAt = $startAt + 20; + $openAt = $startAt + 22; + $countdown = $period ? max(0, ($startAt + 30) - $now) : 0; + + $dictionaryConfig = GameConfig::where('config_key', ZiHuaDictionary::CONFIG_KEY)->find(); + $dictionaryItems = ZiHuaDictionary::parseFromConfigValue($dictionaryConfig?->config_value ?? null); + $items = []; + foreach ($dictionaryItems as $row) { + $items[] = [ + 'number' => $row['no'], + 'name' => $row['name'], + 'category' => $row['category'], + 'icon' => '', + ]; + } + + $user = $this->auth->getUser(); + return $this->mobileSuccess([ + 'server_time' => $now, + 'period' => [ + 'period_no' => $period->period_no ?? '', + 'status' => $this->mapPeriodStatus($period->status ?? null), + 'countdown' => $countdown, + 'lock_at' => $lockAt, + 'open_at' => $openAt, + ], + 'bet_config' => [ + 'max_select_count' => $this->intValue($this->getConfigValue('max_select_count', '5')), + 'chips' => ['1.0000', '5.0000', '10.0000', '25.0000', '50.0000', '100.0000'], + 'single_number_max_bet' => $this->getConfigValue('single_number_max_bet', '500.0000'), + ], + 'dictionary' => $items, + 'user_snapshot' => [ + 'coin' => $user->coin, + 'current_streak' => $user->current_streak ?? 0, + ], + ]); + } + + public function dictionaryList(Request $request): Response + { + $response = $this->initializeMobile($request); + if ($response !== null) { + return $response; + } + $dictionaryConfig = GameConfig::where('config_key', ZiHuaDictionary::CONFIG_KEY)->find(); + $dictionaryItems = ZiHuaDictionary::parseFromConfigValue($dictionaryConfig?->config_value ?? null); + $items = []; + foreach ($dictionaryItems as $row) { + $items[] = [ + 'number' => $row['no'], + 'name' => $row['name'], + 'category' => $row['category'], + 'icon' => '', + ]; + } + return $this->mobileSuccess([ + 'version' => (string) ($dictionaryConfig->update_time ?? '1'), + 'items' => $items, + ]); + } + + public function periodHistory(Request $request): Response + { + $response = $this->initializeMobile($request); + if ($response !== null) { + return $response; + } + $limit = $this->intValue($request->get('limit', 30)); + if ($limit < 1) { + $limit = 30; + } + $list = GameRecord::whereNotNull('result_number')->order('id', 'desc')->limit($limit)->select(); + $rows = []; + foreach ($list as $item) { + $rows[] = [ + 'period_no' => $item->period_no, + 'result_number' => $item->result_number, + 'open_time' => $item->update_time, + ]; + } + return $this->mobileSuccess(['list' => $rows]); + } + + public function periodCurrent(Request $request): Response + { + $response = $this->initializeMobile($request); + if ($response !== null) { + return $response; + } + $period = GameRecord::order('id', 'desc')->find(); + if (!$period) { + return $this->mobileError(2002, 'Game period does not exist'); + } + $now = time(); + $startAt = $this->intValue($period->period_start_at); + return $this->mobileSuccess([ + 'period_id' => $period->id, + 'period_no' => $period->period_no, + 'status' => $this->mapPeriodStatus($period->status), + 'countdown' => max(0, ($startAt + 30) - $now), + 'bet_close_in' => max(0, ($startAt + 20) - $now), + 'result_number' => $period->result_number, + ]); + } + + public function betPlace(Request $request): Response + { + $response = $this->initializeMobile($request); + if ($response !== null) { + return $response; + } + $periodNo = trim((string) $request->post('period_no', '')); + $numbers = $request->post('numbers', []); + $betAmount = (string) $request->post('bet_amount', ''); + $idempotencyKey = trim((string) $request->post('idempotency_key', '')); + if ($periodNo === '' || !is_array($numbers) || $betAmount === '' || $idempotencyKey === '') { + return $this->mobileError(1001, 'Missing parameters'); + } + if (count($numbers) < 1) { + return $this->mobileError(1003, 'Invalid parameter value'); + } + + $period = GameRecord::where('period_no', $periodNo)->find(); + if (!$period) { + return $this->mobileError(2002, 'Game period does not exist'); + } + if ($this->intValue($period->status) !== 0) { + return $this->mobileError(3002, 'Betting is closed'); + } + + $user = $this->auth->getUser(); + $pickCount = count($numbers); + $totalAmount = bcmul($betAmount, (string) $pickCount, 4); + if (bccomp((string) $user->coin, $totalAmount, 4) < 0) { + return $this->mobileError(2001, 'Insufficient balance'); + } + + $exists = BetOrder::where('idempotency_key', $idempotencyKey)->find(); + if ($exists) { + return $this->mobileError(3003, 'Duplicate request'); + } + + Db::startTrans(); + try { + $before = (string) $user->coin; + $after = bcsub($before, $totalAmount, 4); + UserWalletRecord::create([ + 'user_id' => $user->id, + 'channel_id' => $user->channel_id, + 'biz_type' => 'bet', + 'direction' => 2, + 'amount' => $totalAmount, + 'balance_before' => $before, + 'balance_after' => $after, + 'ref_type' => 'bet_order', + 'remark' => '移动端下注', + 'create_time' => time(), + ]); + Db::name('user')->where('id', $user->id)->update(['coin' => $after, 'update_time' => time()]); + $orderNo = 'BO' . date('YmdHis') . substr(str_replace('.', '', uniqid('', true)), -6); + BetOrder::create([ + 'period_id' => $period->id, + 'period_no' => $period->period_no, + 'user_id' => $user->id, + 'channel_id' => $user->channel_id, + 'pick_numbers' => $numbers, + 'unit_amount' => $betAmount, + 'pick_count' => $pickCount, + 'total_amount' => $totalAmount, + 'streak_at_bet' => $user->current_streak ?? 0, + 'is_auto' => 0, + 'status' => 1, + 'idempotency_key' => $idempotencyKey, + 'create_time' => time(), + 'update_time' => time(), + ]); + Db::commit(); + } catch (\Throwable $e) { + Db::rollback(); + return $this->mobileError(5000, 'System is busy, please try again later', ['detail' => $e->getMessage()]); + } + + return $this->mobileSuccess([ + 'order_no' => $orderNo, + 'period_no' => $period->period_no, + 'status' => 'accepted', + 'locked_balance' => '0.0000', + 'balance_after' => $after, + 'current_streak' => $user->current_streak ?? 0, + ]); + } + + public function betRebet(Request $request): Response + { + $response = $this->initializeMobile($request); + if ($response !== null) { + return $response; + } + return $this->mobileError(3001, 'Current process does not allow this operation'); + } + + public function autoBetCreate(Request $request): Response + { + $response = $this->initializeMobile($request); + if ($response !== null) { + return $response; + } + return $this->mobileError(3001, 'Current process does not allow this operation'); + } + + public function autoBetStop(Request $request): Response + { + $response = $this->initializeMobile($request); + if ($response !== null) { + return $response; + } + return $this->mobileError(3001, 'Current process does not allow this operation'); + } + + public function betMyOrders(Request $request): Response + { + $response = $this->initializeMobile($request); + if ($response !== null) { + return $response; + } + $page = $this->intValue($request->get('page', 1)); + $pageSize = $this->intValue($request->get('page_size', 20)); + $paginate = BetOrder::where('user_id', $this->auth->id)->order('id', 'desc')->paginate([ + 'page' => $page, + 'list_rows' => $pageSize, + ]); + + $rows = []; + foreach ($paginate->items() as $item) { + $rows[] = [ + 'order_no' => (string) $item->id, + 'period_no' => $item->period_no, + 'numbers' => $item->pick_numbers ?? [], + 'bet_amount' => $item->unit_amount, + 'total_amount' => $item->total_amount, + 'result_number' => null, + 'win_amount' => $item->win_amount, + 'status' => (string) $item->status, + 'create_time' => $item->create_time, + ]; + } + + return $this->mobileSuccess([ + 'list' => $rows, + 'pagination' => [ + 'page' => $paginate->currentPage(), + 'page_size' => $paginate->listRows(), + 'total' => $paginate->total(), + ], + ]); + } + + private function mapPeriodStatus($status): string + { + if ($this->intValue($status) === 0) { + return 'betting'; + } + if ($this->intValue($status) === 1) { + return 'locked'; + } + if ($this->intValue($status) === 2 || $this->intValue($status) === 3) { + return 'settling'; + } + return 'finished'; + } + + private function getConfigValue(string $key, string $default): string + { + $value = GameConfig::where('config_key', $key)->value('config_value'); + if ($value === null || $value === '') { + return $default; + } + return (string) $value; + } + + private function intValue($value): int + { + $result = filter_var($value, FILTER_VALIDATE_INT); + if ($result === false) { + return 0; + } + return $result; + } +} + diff --git a/app/api/controller/MobileBase.php b/app/api/controller/MobileBase.php new file mode 100644 index 0000000..6ac0a36 --- /dev/null +++ b/app/api/controller/MobileBase.php @@ -0,0 +1,72 @@ +setRequest($request); + + $path = trim($request->path(), '/'); + $parts = explode('/', $path); + $action = $parts[array_key_last($parts)] ?? ''; + $needAuthToken = !action_in_arr($this->noNeedAuthToken, $action); + if ($needAuthToken) { + $authToken = trim((string) $request->header('auth-token', '')); + if ($authToken === '') { + return $this->mobileError(1101, 'Missing auth-token'); + } + $tokenData = Token::get($authToken); + $type = $tokenData['type'] ?? ''; + $expireTime = $tokenData['expire_time'] ?? 0; + if ($type !== 'auth-token' || !is_numeric($expireTime) || $expireTime < time()) { + return $this->mobileError(1101, 'auth-token is invalid or expired'); + } + } + + return $this->initializeFrontend($request); + } + + protected function mobileSuccess(array $data = [], string $message = 'ok'): Response + { + if ($message === '') { + $message = __('ok'); + } else { + $message = __($message); + } + $payload = [ + 'code' => 1, + 'message' => $message, + 'data' => $data, + ]; + return response(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 200, ['Content-Type' => 'application/json']); + } + + protected function mobileError(int $code, string $message, array $data = []): Response + { + $payload = [ + 'code' => $code, + 'message' => __($message), + 'data' => $data, + ]; + return response(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 200, ['Content-Type' => 'application/json']); + } +} + diff --git a/app/api/controller/Notice.php b/app/api/controller/Notice.php new file mode 100644 index 0000000..e4d3b4f --- /dev/null +++ b/app/api/controller/Notice.php @@ -0,0 +1,123 @@ +initializeMobile($request); + if ($response !== null) { + return $response; + } + $page = $this->intValue($request->get('page', 1), 1); + $pageSize = $this->intValue($request->get('page_size', 20), 20); + + $paginate = OperationNotice::where('status', 1)->order('id', 'desc')->paginate([ + 'page' => $page, + 'list_rows' => $pageSize, + ]); + + $noticeIds = []; + foreach ($paginate->items() as $row) { + $noticeIds[] = $row->id; + } + $readRows = []; + if ($noticeIds !== []) { + $readRows = UserNoticeRead::where('user_id', $this->auth->id)->whereIn('notice_id', $noticeIds)->column('notice_id'); + } + $readMap = array_flip($readRows); + + $list = []; + foreach ($paginate->items() as $row) { + $list[] = [ + 'notice_id' => $row->id, + 'title' => $row->title, + 'notice_type' => $this->intValue($row->notice_type, 0) === 1 ? 'popout' : 'silent', + 'is_read' => isset($readMap[$row->id]), + 'publish_time' => $row->publish_at, + ]; + } + + return $this->mobileSuccess(['list' => $list]); + } + + public function noticeDetail(Request $request): Response + { + $response = $this->initializeMobile($request); + if ($response !== null) { + return $response; + } + $id = $this->intValue($request->get('id', 0), 0); + if ($id < 1) { + return $this->mobileError(1001, 'Missing parameters'); + } + $notice = OperationNotice::where('id', $id)->where('status', 1)->find(); + if (!$notice) { + return $this->mobileError(2004, 'Notice does not exist'); + } + return $this->mobileSuccess([ + 'notice_id' => $notice->id, + 'title' => $notice->title, + 'content' => $notice->content, + 'notice_type' => $this->intValue($notice->notice_type, 0) === 1 ? 'popout' : 'silent', + 'must_confirm' => $this->intValue($notice->notice_type, 0) === 1, + 'publish_time' => $notice->publish_at, + ]); + } + + public function noticeConfirm(Request $request): Response + { + $response = $this->initializeMobile($request); + if ($response !== null) { + return $response; + } + $noticeId = $this->intValue($request->post('notice_id', 0), 0); + if ($noticeId < 1) { + return $this->mobileError(1001, 'Missing parameters'); + } + $notice = OperationNotice::where('id', $noticeId)->where('status', 1)->find(); + if (!$notice) { + return $this->mobileError(2004, 'Notice does not exist'); + } + + $exists = UserNoticeRead::where('user_id', $this->auth->id)->where('notice_id', $noticeId)->find(); + $now = time(); + if ($exists) { + $exists->save([ + 'confirmed' => 1, + 'read_at' => $now, + ]); + } else { + UserNoticeRead::create([ + 'user_id' => $this->auth->id, + 'notice_id' => $noticeId, + 'confirmed' => 1, + 'read_at' => $now, + 'create_time' => $now, + ]); + } + return $this->mobileSuccess([ + 'notice_id' => $noticeId, + 'confirmed' => true, + 'confirm_time' => $now, + ]); + } + + private function intValue($value, int $default): int + { + $result = filter_var($value, FILTER_VALIDATE_INT); + if ($result === false) { + return $default; + } + return $result; + } +} + diff --git a/app/api/controller/V1.php b/app/api/controller/V1.php new file mode 100644 index 0000000..ff401b7 --- /dev/null +++ b/app/api/controller/V1.php @@ -0,0 +1,85 @@ +initializeApi($request); + if ($responseInit !== null) { + return $responseInit; + } + + $secret = trim((string) $request->get('secret', '')); + $timestampRaw = $request->get('timestamp', ''); + $deviceId = trim((string) $request->get('device_id', '')); + $signature = trim((string) $request->get('signature', '')); + + if ($secret === '' || $timestampRaw === '' || $deviceId === '' || $signature === '') { + return $this->mobileResult(1001, 'Missing parameters'); + } + + $serverSecret = (string) env('AUTH_TOKEN_SECRET', ''); + if ($serverSecret === '' || !hash_equals($serverSecret, $secret)) { + return $this->mobileResult(1103, 'Invalid secret'); + } + + $timestamp = filter_var($timestampRaw, FILTER_VALIDATE_INT); + if ($timestamp === false) { + return $this->mobileResult(1002, 'Invalid parameter format'); + } + $now = time(); + $skew = abs($now - $timestamp); + if ($skew > 300) { + return $this->mobileResult(3001, 'Invalid timestamp'); + } + + $params = [ + 'device_id' => $deviceId, + 'secret' => $secret, + 'timestamp' => (string) $timestamp, + ]; + ksort($params); + $pairs = []; + foreach ($params as $k => $v) { + $pairs[] = $k . '=' . $v; + } + $plain = implode('&', $pairs); + $expected = strtoupper(md5($plain)); + if (!hash_equals($expected, $signature)) { + return $this->mobileResult(1103, 'Invalid signature'); + } + + $token = Random::uuid(); + $expire = 60 * 60 * 24; + Token::set($token, 'auth-token', 0, $expire); + + return $this->mobileResult(1, 'ok', [ + 'auth_token' => $token, + 'expires_in' => $expire, + 'server_time' => $now, + ]); + } + + private function mobileResult(int $code, string $message, array $data = []): Response + { + $payload = [ + 'code' => $code, + 'message' => __($message), + 'data' => $data, + ]; + return response(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 200, ['Content-Type' => 'application/json']); + } +} + diff --git a/app/api/controller/Wallet.php b/app/api/controller/Wallet.php new file mode 100644 index 0000000..afb90d3 --- /dev/null +++ b/app/api/controller/Wallet.php @@ -0,0 +1,80 @@ +initializeMobile($request); + if ($response !== null) { + return $response; + } + $user = $this->auth->getUser(); + return $this->mobileSuccess([ + 'coin_balance' => $user->coin, + 'frozen_balance' => '0.0000', + 'total_deposit_coin' => $user->total_deposit_coin ?? '0.0000', + 'total_valid_bet_coin' => $user->total_valid_bet_coin ?? '0.0000', + 'withdrawable_balance' => $user->coin, + ]); + } + + public function recordList(Request $request): Response + { + $response = $this->initializeMobile($request); + if ($response !== null) { + return $response; + } + $type = trim((string) $request->get('type', 'all')); + $page = $this->intValue($request->get('page', 1), 1); + $pageSize = $this->intValue($request->get('page_size', 20), 20); + + $query = UserWalletRecord::where('user_id', $this->auth->id)->order('id', 'desc'); + if ($type !== '' && $type !== 'all') { + $query->where('biz_type', $type); + } + $paginate = $query->paginate([ + 'page' => $page, + 'list_rows' => $pageSize, + ]); + $list = []; + foreach ($paginate->items() as $row) { + $list[] = [ + 'record_id' => $row->id, + 'biz_type' => $row->biz_type, + 'direction' => $row->direction, + 'amount' => $row->amount, + 'balance_before' => $row->balance_before, + 'balance_after' => $row->balance_after, + 'ref_type' => $row->ref_type, + 'ref_id' => $row->ref_id, + 'create_time' => $row->create_time, + ]; + } + return $this->mobileSuccess([ + 'list' => $list, + 'pagination' => [ + 'page' => $paginate->currentPage(), + 'page_size' => $paginate->listRows(), + 'total' => $paginate->total(), + ], + ]); + } + + private function intValue($value, int $default): int + { + $result = filter_var($value, FILTER_VALIDATE_INT); + if ($result === false) { + return $default; + } + return $result; + } +} + diff --git a/app/api/lang/en.php b/app/api/lang/en.php index 42619df..1449bc8 100644 --- a/app/api/lang/en.php +++ b/app/api/lang/en.php @@ -12,6 +12,27 @@ return [ 'Please login first' => 'Please login first!', 'You have no permission' => 'No permission to operate!', 'Captcha error' => 'Captcha error!', + 'ok' => 'ok', + 'Missing parameters' => 'Missing parameters', + 'Invalid parameter format' => 'Invalid parameter format', + 'Invalid parameter value' => 'Invalid parameter value', + 'Missing auth-token' => 'Missing auth-token', + 'auth-token is invalid or expired' => 'auth-token is invalid or expired', + 'Invalid secret' => 'Invalid secret', + 'Invalid signature' => 'Invalid signature', + 'Invalid timestamp' => 'Invalid timestamp', + 'Invite code does not exist' => 'Invite code does not exist', + 'Registered successfully but login failed' => 'Registered successfully but login failed', + 'Incorrect account or password' => 'Incorrect account or password', + 'Login status has expired' => 'Login status has expired', + 'Game period does not exist' => 'Game period does not exist', + 'Betting is closed' => 'Betting is closed', + 'Insufficient balance' => 'Insufficient balance', + 'Duplicate request' => 'Duplicate request', + 'System is busy, please try again later' => 'System is busy, please try again later', + 'Current process does not allow this operation' => 'Current process does not allow this operation', + 'Order does not exist' => 'Order does not exist', + 'Notice does not exist' => 'Notice does not exist', // Member center account 'Data updated successfully~' => 'Data updated successfully~', 'Password has been changed~' => 'Password has been changed~', diff --git a/app/api/lang/zh-cn.php b/app/api/lang/zh-cn.php index 0e01a89..29dffa7 100644 --- a/app/api/lang/zh-cn.php +++ b/app/api/lang/zh-cn.php @@ -44,6 +44,27 @@ return [ 'Parameter error' => '参数错误!', 'Token expiration' => '登录态过期,请重新登录!', 'Captcha error' => '验证码错误!', + 'ok' => '成功', + 'Missing parameters' => '参数缺失', + 'Invalid parameter format' => '参数格式错误', + 'Invalid parameter value' => '参数取值非法', + 'Missing auth-token' => '缺少 auth-token', + 'auth-token is invalid or expired' => 'auth-token 无效或已过期', + 'Invalid secret' => '密钥无效', + 'Invalid signature' => '签名错误', + 'Invalid timestamp' => '时间戳无效', + 'Invite code does not exist' => '邀请码不存在', + 'Registered successfully but login failed' => '注册成功但登录失败', + 'Incorrect account or password' => '账号或密码错误', + 'Login status has expired' => '登录状态已过期', + 'Game period does not exist' => '对局不存在', + 'Betting is closed' => '已封盘,禁止下注', + 'Insufficient balance' => '余额不足', + 'Duplicate request' => '重复请求(幂等冲突)', + 'System is busy, please try again later' => '系统繁忙,请稍后重试', + 'Current process does not allow this operation' => '当前流程不允许该操作', + 'Order does not exist' => '订单不存在', + 'Notice does not exist' => '公告不存在', // 会员中心 account 'Data updated successfully~' => '资料更新成功~', 'Password has been changed~' => '密码已修改~', diff --git a/app/common/middleware/LoadLangPack.php b/app/common/middleware/LoadLangPack.php index 8a3d29f..6e3e625 100644 --- a/app/common/middleware/LoadLangPack.php +++ b/app/common/middleware/LoadLangPack.php @@ -25,12 +25,20 @@ class LoadLangPack implements MiddlewareInterface protected function loadLang(Request $request): void { - // 优先从请求头 think-lang 获取前端选择的语言(与前端 axios 发送的 header 对应) - // 安装页等未发送 think-lang 时,回退到 Accept-Language 或配置默认值 - $headerLang = $request->header('think-lang'); + // 优先从请求头 lang / think-lang 获取前端选择的语言 + // 支持:lang=en / lang=zh / think-lang=en / think-lang=zh-cn + // 未发送时回退到 Accept-Language 或配置默认值 + $headerLang = $request->header('lang', ''); + if ($headerLang === '') { + $headerLang = $request->header('think-lang', ''); + } $allowLangList = config('lang.allow_lang_list', ['zh-cn', 'en']); - if ($headerLang && in_array(str_replace('_', '-', strtolower($headerLang)), $allowLangList)) { - $langSet = str_replace('_', '-', strtolower($headerLang)); + $normalizedHeaderLang = str_replace('_', '-', strtolower($headerLang)); + if ($normalizedHeaderLang === 'zh') { + $normalizedHeaderLang = 'zh-cn'; + } + if ($headerLang && in_array($normalizedHeaderLang, $allowLangList)) { + $langSet = $normalizedHeaderLang; } else { $acceptLang = $request->header('accept-language', ''); if (preg_match('/^zh[-_]?cn|^zh/i', $acceptLang)) { diff --git a/config/route.php b/config/route.php index 5e9c1c6..06d8e39 100644 --- a/config/route.php +++ b/config/route.php @@ -68,6 +68,9 @@ Route::get('/install/index', function () use ($installLockFileForInstall, $insta // api/index Route::get('/api/index/index', [\app\api\controller\Index::class, 'index']); +// api/v1 +Route::get('/api/v1/authToken', [\app\api\controller\V1::class, 'authToken']); + // api/user(GET 获取配置,POST 登录/注册) Route::add(['GET', 'POST'], '/api/user/checkIn', [\app\api\controller\User::class, 'checkIn']); Route::post('/api/user/logout', [\app\api\controller\User::class, 'logout']); @@ -108,6 +111,35 @@ Route::post('/api/account/retrievePassword', [\app\api\controller\Account::class // api/ems Route::post('/api/ems/send', [\app\api\controller\Ems::class, 'send']); +// ==================== 移动端游戏接口(36字花) ==================== +Route::post('/api/auth/userRegister', [\app\api\controller\Auth::class, 'userRegister']); +Route::post('/api/auth/userLogin', [\app\api\controller\Auth::class, 'userLogin']); +Route::post('/api/auth/tokenRefresh', [\app\api\controller\Auth::class, 'tokenRefresh']); + +Route::get('/api/account/userProfile', [\app\api\controller\Account::class, 'userProfile']); + +Route::get('/api/game/lobbyInit', [\app\api\controller\Game::class, 'lobbyInit']); +Route::get('/api/game/dictionaryList', [\app\api\controller\Game::class, 'dictionaryList']); +Route::get('/api/game/periodHistory', [\app\api\controller\Game::class, 'periodHistory']); +Route::get('/api/game/periodCurrent', [\app\api\controller\Game::class, 'periodCurrent']); +Route::post('/api/game/betPlace', [\app\api\controller\Game::class, 'betPlace']); +Route::post('/api/game/betRebet', [\app\api\controller\Game::class, 'betRebet']); +Route::post('/api/game/autoBetCreate', [\app\api\controller\Game::class, 'autoBetCreate']); +Route::post('/api/game/autoBetStop', [\app\api\controller\Game::class, 'autoBetStop']); +Route::get('/api/game/betMyOrders', [\app\api\controller\Game::class, 'betMyOrders']); + +Route::get('/api/wallet/balanceSummary', [\app\api\controller\Wallet::class, 'balanceSummary']); +Route::get('/api/wallet/recordList', [\app\api\controller\Wallet::class, 'recordList']); + +Route::post('/api/finance/depositCreate', [\app\api\controller\Finance::class, 'depositCreate']); +Route::get('/api/finance/depositDetail', [\app\api\controller\Finance::class, 'depositDetail']); +Route::post('/api/finance/withdrawCreate', [\app\api\controller\Finance::class, 'withdrawCreate']); +Route::get('/api/finance/withdrawDetail', [\app\api\controller\Finance::class, 'withdrawDetail']); + +Route::get('/api/notice/noticeList', [\app\api\controller\Notice::class, 'noticeList']); +Route::get('/api/notice/noticeDetail', [\app\api\controller\Notice::class, 'noticeDetail']); +Route::post('/api/notice/noticeConfirm', [\app\api\controller\Notice::class, 'noticeConfirm']); + // ==================== Admin 路由 ==================== // Admin 多为 JSON API,前端可能用 GET 传参查列表、POST 提交表单,使用 any 确保兼容 diff --git a/scripts/generate_auth_signature.php b/scripts/generate_auth_signature.php new file mode 100644 index 0000000..ef85abb --- /dev/null +++ b/scripts/generate_auth_signature.php @@ -0,0 +1,36 @@ + (string) $deviceId, + 'secret' => (string) $secret, + 'timestamp' => (string) $timestamp, +]; + +ksort($params); + +$pairs = []; +foreach ($params as $key => $value) { + $pairs[] = $key . '=' . $value; +} + +$plain = implode('&', $pairs); +$signature = strtoupper(md5($plain)); + +echo 'device_id: ' . $params['device_id'] . PHP_EOL; +echo 'secret: ' . $params['secret'] . PHP_EOL; +echo 'timestamp: ' . $params['timestamp'] . PHP_EOL; +echo 'signature: ' . $signature . PHP_EOL; +echo 'url: /api/v1/authToken?secret=' . rawurlencode($params['secret']) . '×tamp=' . rawurlencode($params['timestamp']) . '&device_id=' . rawurlencode($params['device_id']) . '&signature=' . rawurlencode($signature) . PHP_EOL; +