Compare commits

...

61 Commits

Author SHA1 Message Date
545b8d9a1c 1.修复谷歌验证报错
2.修复商城配置为小数点后3位
2026-08-06 11:16:30 +08:00
239f5f4444 优化登录页面原地TP的问题 2026-07-27 17:10:22 +08:00
3fc91cd2d3 移除商品兑换需要流水 2026-07-27 16:52:07 +08:00
278532cdad 修复登录报错 2026-07-27 16:30:34 +08:00
abfa8e0cae 优化 2026-07-27 16:19:31 +08:00
f91eb186ca 更新增加统计【活动期间输赢总数据】 2026-07-27 15:41:31 +08:00
f2543b9aa4 更新接口不通过user_id绑定数据 2026-07-27 14:24:53 +08:00
778cdc01da 优化积分修改时的备注写入适配多语言 2026-07-27 12:34:34 +08:00
881581a8f0 优化积分修改时的备注写入 2026-07-27 12:30:23 +08:00
c4ec7828bd 移除地区的校验,不需要填地区 2026-07-27 12:18:03 +08:00
25f2ba4c03 新增订单查询接口/api/v1/mall/order 2026-07-27 11:39:45 +08:00
d054908b30 统一前端的访问地址格式为/#/ 2026-07-27 10:21:35 +08:00
e3d4ec4624 优化每日推送和积分转化多语言样式 2026-07-24 12:01:41 +08:00
4bad91b8a3 优化每日推送和积分转化逻辑 2026-07-24 11:12:29 +08:00
b2b4fd4fbc 更新每日推送数据的保存 2026-07-22 11:00:42 +08:00
140a068b88 增加积分调整日志 2026-07-21 18:59:45 +08:00
4fbc4500fd 优化增加第三方【每日推送】原始数据记录日志.log 2026-07-21 16:44:39 +08:00
2fbc07329e 增加每日推送的导出和排序 2026-07-21 15:49:29 +08:00
5aad851108 增加每日推送的导出和排序 2026-07-21 15:46:01 +08:00
a0056ff15b 在环境文件中增加谷歌验证器开关 2026-07-21 15:15:11 +08:00
e40de47b8b 1.优化后台菜单页面 2026-06-24 17:00:46 +08:00
58b22cfb64 1.优化后台菜单页面 2026-06-24 16:55:04 +08:00
ceb4386440 1.优化谷歌验证器-前端不显示验证器按钮 2026-06-24 16:49:34 +08:00
21def75ef1 1.优化谷歌验证器 2026-06-24 16:36:30 +08:00
3e88a5dd91 1.增加谷歌验证器 2026-06-24 16:25:31 +08:00
5187330c38 1.修复上传漏洞和SQL注入漏洞-增强登录验证功能 2026-06-24 11:47:27 +08:00
546d350f3a 1.修复上传漏洞和SQL注入漏洞-修复服务端无法启动的问题 2026-06-24 10:24:59 +08:00
60f30d8381 1.修复上传漏洞和SQL注入漏洞-移除安装模块所有的代码 2026-06-24 10:16:11 +08:00
aa5bda04ad 1.修复上传漏洞和SQL注入漏洞 2026-06-24 09:59:58 +08:00
8c5b1d212a 优化登录状态错误时跳转页面到admin/login 2026-05-11 15:19:33 +08:00
2f8c4f4ced 优化推送统一订单信息-playxId修改为用户名 2026-05-07 14:20:45 +08:00
afa3750035 修改visual_name=>Reward Mall 2026-05-07 12:09:30 +08:00
234c607db0 1.优化统一订单推送报错记录日志 2026-05-06 17:22:11 +08:00
6883e74cf5 1.优化统一订单推送报错记录日志 2026-05-06 17:15:47 +08:00
ddd533f436 1.优化商城参数配置菜单-以及配置生效问题 2026-05-06 15:50:42 +08:00
ec499dce0f 1.新增商城参数配置菜单-管理兑换比例 2026-05-06 15:21:59 +08:00
c2c2baeaec 优化测试verifyToken接口脚本 2026-05-06 11:05:23 +08:00
d44ce36286 优化测试verifyToken接口脚本 2026-05-06 10:59:49 +08:00
9ce351a0c7 优化测试verifyToken接口脚本 2026-05-06 10:56:25 +08:00
7acdc414fc 优化测试verifyToken接口脚本 2026-05-06 10:51:57 +08:00
907f7cb315 新增测试verifyToken接口脚本 2026-05-06 10:48:53 +08:00
713170308b 接口新增记录日志 2026-05-06 10:48:26 +08:00
438580d72c 优化Token验证接口 2026-05-06 10:25:56 +08:00
04680408e6 优化Token验证接口 2026-05-06 10:18:26 +08:00
05f2d6f084 1.重新设置Token验证接口 2026-05-06 10:03:17 +08:00
29ab883f4e 1.重新设置Token验证接口 2026-05-06 09:49:39 +08:00
ccdb58ea1d 1.优化相关文档
2.重新设置Token验证接口
2026-04-30 15:18:06 +08:00
487016e037 1.远程验证token有效性 2026-04-30 14:39:25 +08:00
d9b2ebafa9 1.远程验证token有效性 2026-04-30 14:35:51 +08:00
cf6aa7e9e9 1.远程验证token有效性 2026-04-30 14:25:36 +08:00
8017d1819a 优化验证token接口-暂时不验证token有效性 2026-04-23 09:49:58 +08:00
3b687489a0 优化验证token接口-暂时不验证token有效性 2026-04-23 09:43:10 +08:00
0a1109c109 优化验证token接口-临时验证 2026-04-23 09:26:33 +08:00
eb3bdaa005 优化每日推送创建(更新)用户资产 2026-04-23 09:17:48 +08:00
7af40bdd1f 优化每日推送创建(更新)用户资产 2026-04-22 16:59:42 +08:00
ca0a9e75e0 数据库备份 2026-04-21 16:00:02 +08:00
7b9187fb62 1.新增马来语ms,修改有商品管理和所有接口返回 2026-04-21 14:30:15 +08:00
3ac825f15d 1.优化后台页面样式
2.优化统一订单中红利的状态和失败原因
3.移除项目中冗余代码和字段
2026-04-21 11:59:15 +08:00
1c900e7132 1.修复index.vue表格翻译错误
2.限制统一订单类型审核
3.统一订单手动推送报错PlayX 接口未配置
2026-04-14 18:55:31 +08:00
4cf84ca083 对接文档-PlayX 调用积分商城接口说明 2026-04-09 15:08:37 +08:00
9f6358a3f2 新增积分流水接口 2026-04-09 15:08:19 +08:00
190 changed files with 9453 additions and 8826 deletions

View File

@@ -7,6 +7,10 @@ APP_DEFAULT_TIMEZONE = Asia/Shanghai
# 语言
LANG_DEFAULT_LANG = zh-cn
# 管理员谷歌验证器TOTP1=开启(登录需验证/绑定0=关闭
ADMIN_TOTP_ENABLE=1
# Database
DATABASE_TYPE = mysql
DATABASE_HOSTNAME = 127.0.0.1
@@ -18,20 +22,25 @@ DATABASE_CHARSET = utf8mb4
DATABASE_PREFIX =
# PlayX 配置
# 提现折算:积分 -> 现金(如 10 分 = 1 元,则 points_to_cash_ratio=0.1
PLAYX_POINTS_TO_CASH_RATIO=0.1
# 返还比例:新增保障金 = ABS(yesterday_win_loss_net) * return_ratio仅亏损时
PLAYX_RETURN_RATIO=0.1
# 解锁比例:今日可领取上限 = yesterday_total_deposit * unlock_ratio
PLAYX_UNLOCK_RATIO=0.1
# 积分折算、昨日净输赢/终身总输赢兑换比例等均在后台「积分商城 → 商城参数配置」(mall_business_config),不再使用下方 env 比例项
# Daily Push 签名校验密钥HMAC建议从部署系统注入避免写入代码/仓库)
PLAYX_DAILY_PUSH_SECRET=
# 第三方每日推送原始日志保留天数runtime/logs/daily_push_raw默认 30
PLAYX_DAILY_PUSH_RAW_LOG_DAYS=30
# 合作方回调 JWT 验签密钥HS256与对端私发密钥一致与上一项可同时配置则两种均需通过
PLAYX_PARTNER_JWT_SECRET=
# Agent authtoken/api/v1/authTokenJWT 签名密钥;留空则使用下方 buildadmin.token.key
AGENT_AUTH_JWT_SECRET=
# token 会话缓存过期时间(秒)
PLAYX_SESSION_EXPIRE_SECONDS=3600
# verifyToken 是否仅本地联调true=不调 PlayX用下方默认用户签发 sessionfalse=走 PLAYX_TOKEN_VERIFY_URL
PLAYX_VERIFY_TOKEN_LOCAL_ONLY=false
# verifyToken完整 https URL 时 Body merchant_code+request_date+request_id+token、签名明文同序相对路径时拼基址+商户四字段
PLAYX_TOKEN_VERIFY_URL=https://callback-mallsys.superior3.net/callback/api/mallsys/plx/auth/verify-token
# verifyToken 仅本地联调时的默认用户ID
PLAYX_VERIFY_TOKEN_LOCAL_DEFAULT_USER_ID=testmyr
# verifyToken 仅本地联调时的默认用户名
PLAYX_VERIFY_TOKEN_LOCAL_DEFAULT_USERNAME=yangyang123
# PlayX API商城调用 PlayX 时使用)
PLAYX_API_BASE_URL=
PLAYX_API_SECRET_KEY=

1
.gitignore vendored
View File

@@ -16,6 +16,7 @@ yarn.lock
/nbproject
/runtime/*
/install
/public/install
node_modules
dist
dist-ssr

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace app\admin\controller;
use ba\Terminal;
use ba\TableManager;
use support\think\Db;
use app\admin\model\AdminLog;
@@ -17,7 +16,6 @@ use support\Response;
class Ajax extends Backend
{
protected array $noNeedPermission = ['*'];
protected array $noNeedLogin = ['terminal'];
public function upload(Request $request): Response
{
@@ -167,18 +165,6 @@ class Ajax extends Backend
]);
}
public function changeTerminalConfig(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
AdminLog::instance($request)->setTitle(__('Change terminal config'));
if (Terminal::changeTerminalConfig()) {
return $this->success();
}
return $this->error(__('Failed to modify the terminal configuration. Please modify the configuration file manually:%s', ['/config/terminal.php']));
}
public function clearCache(Request $request): Response
{
$response = $this->initializeBackend($request);
@@ -194,13 +180,4 @@ class Ajax extends Backend
event_trigger('cacheClearAfter');
return $this->success(__('Cache cleaned~'));
}
public function terminal(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
(new Terminal())->exec();
return $this->success();
}
}

View File

@@ -6,8 +6,10 @@ namespace app\admin\controller;
use ba\ClickCaptcha;
use ba\Random;
use app\admin\model\Admin;
use app\common\facade\Token;
use app\admin\model\AdminLog;
use app\common\library\AdminTotp;
use app\common\controller\Backend;
use support\validation\Validator;
use support\validation\ValidationException;
@@ -16,7 +18,7 @@ use support\Response;
class Index extends Backend
{
protected array $noNeedLogin = ['logout', 'login'];
protected array $noNeedLogin = ['logout', 'login', 'totpVerify', 'totpBindInit', 'totpBindConfirm'];
protected array $noNeedPermission = ['index'];
public function index(Request $request): Response
@@ -46,14 +48,11 @@ class Index extends Backend
'siteName' => get_sys_config('site_name'),
'version' => get_sys_config('version'),
'apiUrl' => $apiUrl,
'upload' => keys_to_camel_case(get_upload_config($request), ['max_size', 'save_name', 'allowed_suffixes', 'allowed_mime_types']),
'upload' => keys_to_camel_case(get_upload_config($request), ['max_size', 'save_name', 'allowed_suffixes', 'allowed_mime_types', 'forbidden_suffixes']),
'cdnUrl' => full_url(),
'cdnUrlParams' => config('buildadmin.cdn_url_params'),
'totpEnable' => AdminTotp::isEnabled(),
],
'terminal' => [
'phpDevelopmentServer' => str_contains($_SERVER['SERVER_SOFTWARE'] ?? '', 'Development Server'),
'npmPackageManager' => config('terminal.npm_package_manager'),
]
]);
}
@@ -106,33 +105,185 @@ class Index extends Backend
AdminLog::instance($request)->setTitle(__('Login'));
$res = $this->auth->login($username, $password, (bool) $keep);
if ($res === true) {
$userInfo = $this->auth->getInfo();
$adminId = $this->auth->id;
$keepTime = (int) config('buildadmin.admin_token_keep_time', 86400 * 3);
// 兜底:若 getInfo 未返回 token在控制器层生成并入库login 成功时必有 adminId
if (empty($userInfo['token']) && $adminId) {
$userInfo['token'] = Random::uuid();
Token::set($userInfo['token'], \app\admin\library\Auth::TOKEN_TYPE, $adminId, $keepTime);
}
if (empty($userInfo['refresh_token']) && $keep && $adminId) {
$userInfo['refresh_token'] = Random::uuid();
Token::set($userInfo['refresh_token'], \app\admin\library\Auth::TOKEN_TYPE . '-refresh', $adminId, 2592000);
}
return $this->success(__('Login succeeded!'), [
'userInfo' => $userInfo
]);
}
if (!$this->auth->verifyCredentials($username, $password)) {
$msg = $this->auth->getError();
return $this->error($msg ?: __('Incorrect user name or password!'));
}
return $this->success('', [
'captcha' => $captchaSwitch
if (!AdminTotp::isEnabled()) {
if (!$this->auth->finalizeLogin((bool) $keep)) {
return $this->error($this->auth->getError() ?: __('Login failed'));
}
return $this->buildLoginSuccessResponse((bool) $keep);
}
if ($this->auth->hasTotpBound()) {
$tempToken = AdminTotp::createPendingToken($this->auth->id, AdminTotp::TOKEN_TYPE_VERIFY);
return $this->success(__('Please enter Google Authenticator code'), [
'type' => $this->auth::NEED_TOTP,
'tempToken' => $tempToken,
'username' => $this->auth->username,
]);
}
$tempToken = AdminTotp::createPendingToken($this->auth->id, AdminTotp::TOKEN_TYPE_BIND);
return $this->success(__('Please bind Google Authenticator'), [
'type' => $this->auth::NEED_BIND_TOTP,
'tempToken' => $tempToken,
'username' => $this->auth->username,
]);
}
return $this->success('', [
'captcha' => $captchaSwitch,
'totpEnable' => AdminTotp::isEnabled(),
]);
}
public function totpBindInit(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
if (!AdminTotp::isEnabled()) {
return $this->error(__('Google Authenticator is disabled'));
}
if ($request->method() !== 'POST') {
return $this->error(__('Method not allowed'), [], 0, ['statusCode' => 405]);
}
$tempToken = (string) $request->post('tempToken', '');
$adminId = AdminTotp::resolvePendingToken($tempToken, AdminTotp::TOKEN_TYPE_BIND);
if ($adminId <= 0) {
return $this->error(__('TOTP session expired, please login again'));
}
if (!$this->auth->loadAdminById($adminId)) {
return $this->error($this->auth->getError());
}
if ($this->auth->hasTotpBound()) {
return $this->error(__('Google Authenticator already bound'));
}
$secret = AdminTotp::generateSecret();
$label = $this->auth->username;
$qrCode = AdminTotp::getQrDataUri($label, $secret);
return $this->success('', [
'secret' => $secret,
'qrCode' => $qrCode,
'username' => $label,
]);
}
public function totpBindConfirm(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
if (!AdminTotp::isEnabled()) {
return $this->error(__('Google Authenticator is disabled'));
}
if ($request->method() !== 'POST') {
return $this->error(__('Method not allowed'), [], 0, ['statusCode' => 405]);
}
$tempToken = (string) $request->post('tempToken', '');
$secret = (string) $request->post('secret', '');
$code = (string) $request->post('code', '');
$keep = (bool) $request->post('keep');
if ($tempToken === '' || $secret === '' || $code === '') {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$adminId = AdminTotp::resolvePendingToken($tempToken, AdminTotp::TOKEN_TYPE_BIND);
if ($adminId <= 0) {
return $this->error(__('TOTP session expired, please login again'));
}
if (!AdminTotp::verifyCode($secret, $code)) {
return $this->error(__('Google Authenticator code error'));
}
if (!$this->auth->loadAdminById($adminId)) {
return $this->error($this->auth->getError());
}
if ($this->auth->hasTotpBound()) {
AdminTotp::deletePendingToken($tempToken);
return $this->error(__('Google Authenticator already bound'));
}
$encrypted = AdminTotp::encryptSecret($secret);
if ($encrypted === '') {
return $this->error(__('Google Authenticator bind failed'));
}
Admin::where('id', $adminId)->update([
'totp_secret' => $encrypted,
'totp_bind_time' => time(),
]);
AdminTotp::deletePendingToken($tempToken);
if (!$this->auth->finalizeLogin($keep)) {
return $this->error($this->auth->getError() ?: __('Google Authenticator bind failed'));
}
return $this->buildLoginSuccessResponse($keep, __('Google Authenticator bound successfully'));
}
public function totpVerify(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
if (!AdminTotp::isEnabled()) {
return $this->error(__('Google Authenticator is disabled'));
}
if ($request->method() !== 'POST') {
return $this->error(__('Method not allowed'), [], 0, ['statusCode' => 405]);
}
$tempToken = (string) $request->post('tempToken', '');
$code = (string) $request->post('code', '');
$keep = (bool) $request->post('keep');
if ($tempToken === '' || $code === '') {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$adminId = AdminTotp::resolvePendingToken($tempToken, AdminTotp::TOKEN_TYPE_VERIFY);
if ($adminId <= 0) {
return $this->error(__('TOTP session expired, please login again'));
}
if (!$this->auth->loadAdminById($adminId)) {
return $this->error($this->auth->getError());
}
if (!$this->auth->hasTotpBound()) {
AdminTotp::deletePendingToken($tempToken);
return $this->error(__('Google Authenticator not bound'));
}
$encrypted = $this->auth->getAdmin()->getData('totp_secret');
if (!AdminTotp::verifyStoredCode($encrypted, $code)) {
$this->auth->loginFailed();
return $this->error(__('Google Authenticator code error'));
}
AdminTotp::deletePendingToken($tempToken);
if (!$this->auth->finalizeLogin($keep)) {
return $this->error($this->auth->getError() ?: __('Login failed'));
}
return $this->buildLoginSuccessResponse($keep);
}
public function logout(Request $request): Response
{
$response = $this->initializeBackend($request);
@@ -148,4 +299,22 @@ class Index extends Backend
}
return $this->error(__('Method not allowed'), [], 0, ['statusCode' => 405]);
}
private function buildLoginSuccessResponse(bool $keep, ?string $message = null): Response
{
$userInfo = $this->auth->getInfo();
$adminId = $this->auth->id;
$keepTime = (int) config('buildadmin.admin_token_keep_time', 86400 * 3);
if (empty($userInfo['token']) && $adminId) {
$userInfo['token'] = Random::uuid();
Token::set($userInfo['token'], \app\admin\library\Auth::TOKEN_TYPE, $adminId, $keepTime);
}
if (empty($userInfo['refresh_token']) && $keep && $adminId) {
$userInfo['refresh_token'] = Random::uuid();
Token::set($userInfo['refresh_token'], \app\admin\library\Auth::TOKEN_TYPE . '-refresh', $adminId, 2592000);
}
return $this->success($message ?: __('Login succeeded!'), [
'userInfo' => $userInfo,
]);
}
}

View File

@@ -1,147 +0,0 @@
<?php
declare(strict_types=1);
namespace app\admin\controller;
use ba\Exception as BaException;
use app\admin\model\AdminLog;
use app\admin\library\module\Server;
use app\admin\library\module\Manage;
use app\common\controller\Backend;
use Webman\Http\Request;
use support\Response;
class Module extends Backend
{
protected array $noNeedPermission = ['state', 'dependentInstallComplete'];
public function index(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
return $this->success('', [
'installed' => Server::installedList(root_path() . 'modules' . DIRECTORY_SEPARATOR),
'sysVersion' => config('buildadmin.version'),
'nuxtVersion' => Server::getNuxtVersion(),
]);
}
public function state(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$uid = $request->get('uid', '');
if (!$uid) {
return $this->error(__('Parameter error'));
}
return $this->success('', [
'state' => Manage::instance($uid)->getInstallState()
]);
}
public function install(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
AdminLog::instance($request)->setTitle(__('Install module'));
$uid = $request->get('uid', $request->post('uid', ''));
$update = filter_var($request->get('update', $request->post('update', false)), FILTER_VALIDATE_BOOLEAN);
if (!$uid) {
return $this->error(__('Parameter error'));
}
$res = [];
try {
$res = Manage::instance($uid)->install($update);
} catch (BaException $e) {
return $this->error(__($e->getMessage()), $e->getData(), $e->getCode());
} catch (\Throwable $e) {
return $this->error(__($e->getMessage()));
}
return $this->success('', ['data' => $res]);
}
public function dependentInstallComplete(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$uid = $request->get('uid', '');
if (!$uid) {
return $this->error(__('Parameter error'));
}
try {
Manage::instance($uid)->dependentInstallComplete('all');
} catch (BaException $e) {
return $this->error(__($e->getMessage()), $e->getData(), $e->getCode());
} catch (\Throwable $e) {
return $this->error(__($e->getMessage()));
}
return $this->success();
}
public function changeState(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
AdminLog::instance($request)->setTitle(__('Change module state'));
$uid = $request->post('uid', '');
$state = filter_var($request->post('state', false), FILTER_VALIDATE_BOOLEAN);
if (!$uid) {
return $this->error(__('Parameter error'));
}
$info = [];
try {
$info = Manage::instance($uid)->changeState($state);
} catch (BaException $e) {
return $this->error(__($e->getMessage()), $e->getData(), $e->getCode());
} catch (\Throwable $e) {
return $this->error(__($e->getMessage()));
}
return $this->success('', ['info' => $info]);
}
public function uninstall(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
AdminLog::instance($request)->setTitle(__('Uninstall module'));
$uid = $request->post('uid', '');
if (!$uid) {
return $this->error(__('Parameter error'));
}
try {
Manage::instance($uid)->uninstall();
} catch (BaException $e) {
return $this->error(__($e->getMessage()), $e->getData(), $e->getCode());
} catch (\Throwable $e) {
return $this->error(__($e->getMessage()));
}
return $this->success();
}
public function upload(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
AdminLog::instance($request)->setTitle(__('Upload module'));
$file = $request->file('file');
if (!$file) {
return $this->error(__('Parameter error'));
}
try {
$res = Manage::uploadFromRequest($request);
} catch (BaException $e) {
return $this->error(__($e->getMessage()), $e->getData(), $e->getCode());
} catch (\Throwable $e) {
return $this->error(__($e->getMessage()));
}
return $this->success('', $res);
}
}

View File

@@ -8,6 +8,8 @@ use Throwable;
use support\think\Db;
use support\validation\Validator;
use support\validation\ValidationException;
use app\common\facade\Token;
use app\admin\model\AdminLog;
use app\common\controller\Backend;
use app\admin\model\Admin as AdminModel;
use support\Response;
@@ -17,7 +19,7 @@ class Admin extends Backend
{
protected ?object $model = null;
protected array|string $preExcludeFields = ['create_time', 'update_time', 'password', 'salt', 'login_failure', 'last_login_time', 'last_login_ip', 'agent_id', 'agent_api_secret', 'channel_id'];
protected array|string $preExcludeFields = ['create_time', 'update_time', 'password', 'salt', 'login_failure', 'last_login_time', 'last_login_ip', 'channel_id', 'totp_secret', 'totp_bind_time'];
protected array|string $quickSearchField = ['username', 'nickname'];
@@ -98,15 +100,6 @@ class Admin extends Backend
$this->model->startTrans();
try {
$result = $this->model->save($data);
if ($result !== false) {
$agentId = strtolower(md5($this->model->username . $this->model->id));
$agentSecret = strtoupper(md5($this->model->username . $this->model->id));
// 使用原生 SQL避免 ThinkORM 按当前表结构校验字段时因未迁移缺少 agent_api_secret 列而报错
Db::execute(
'UPDATE `admin` SET `agent_id` = ?, `agent_api_secret` = ? WHERE `id` = ?',
[$agentId, $agentSecret, $this->model->id]
);
}
if (!empty($data['group_arr'])) {
$groupAccess = [];
foreach ($data['group_arr'] as $datum) {
@@ -233,6 +226,54 @@ class Admin extends Backend
]);
}
public function resetTotp(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
if (!\app\common\library\AdminTotp::isEnabled()) {
return $this->error(__('Google Authenticator is disabled'));
}
if ($request->method() !== 'POST') {
return $this->error(__('Method not allowed'), [], 0, ['statusCode' => 405]);
}
if (!$this->auth->isSuperAdmin()) {
return $this->error(__('You have no permission'));
}
$id = (int) ($request->post('id') ?? 0);
if ($id <= 0) {
return $this->error(__('Parameter error'));
}
if ($id === $this->auth->id) {
return $this->error(__('Cannot reset your own authenticator, please modify in database'));
}
$row = $this->model->find($id);
if (!$row) {
return $this->error(__('Record not found'));
}
$dataLimitAdminIds = $this->getDataLimitAdminIds();
if ($dataLimitAdminIds && !in_array($row[$this->dataLimitField], $dataLimitAdminIds)) {
return $this->error(__('You have no permission'));
}
AdminLog::instance($request)->setTitle(__('Reset Google Authenticator'));
$row->save([
'totp_secret' => '',
'totp_bind_time' => null,
]);
Token::clear(\app\admin\library\Auth::TOKEN_TYPE, $id);
Token::clear(\app\admin\library\Auth::TOKEN_TYPE . '-refresh', $id);
return $this->success(__('Google Authenticator reset successfully'));
}
public function del(Request $request): Response
{
$response = $this->initializeBackend($request);

View File

@@ -30,7 +30,6 @@ class Crud extends Backend
protected string $webTranslate = '';
protected array $langTsData = [];
protected array $dtStringToArray = ['checkbox', 'selects', 'remoteSelects', 'city', 'images', 'files'];
protected array $noNeedLogin = ['getFileData'];
protected array $noNeedPermission = ['logStart', 'getFileData', 'parseFieldData', 'generateCheck', 'uploadCompleted'];
protected function initController(Request $request): ?Response

View File

@@ -4,6 +4,7 @@ namespace app\admin\controller\mall;
namespace app\admin\controller\mall;
use Throwable;
@@ -26,7 +27,7 @@ class Address extends Backend
/**
* MallAddress模型对象
$this->model = new \app\common\model\MallAddress();
* @var object|null
* @phpstan-var \app\common\model\MallAddress|null
@@ -45,11 +46,6 @@ class Address extends Backend
/**
* 1. withJoin 不可使用 alias 方法设置表别名,别名将自动使用关联模型名称(小写下划线命名规则)
* 2. 以下的别名设置了主表别名,同时便于拼接查询参数等
* 3. paginate 数据集可使用链式操作 each(function($item, $key) {}) 遍历处理
*/
protected string|array $quickSearchField = ['id'];
@@ -67,8 +63,4 @@ class Address extends Backend
/**
/**
* 若需重写查看、编辑、删除等方法,请复制 @see \app\admin\library\traits\Backend 中对应的方法至此进行重写
*/

View File

@@ -3,6 +3,8 @@
namespace app\admin\controller\mall;
use Throwable;
use app\common\library\MallDailyPushBackfill;
use app\common\library\MallDailyPushExport;
use app\common\controller\Backend;
use support\Response;
use Webman\Http\Request;
@@ -28,12 +30,19 @@ class DailyPush extends Backend
'date',
'username',
'yesterday_win_loss_net',
'converted_points',
'yesterday_total_deposit',
'lifetime_total_deposit',
'lifetime_total_withdraw',
'lifetime_win_loss_net',
'create_time',
];
/**
* exportCount 与 export 共用导出权限
*/
protected array $noNeedPermission = ['exportCount'];
public function initialize(): void
{
parent::initialize();
@@ -57,5 +66,122 @@ class DailyPush extends Backend
return $this->_index();
}
/**
* 导出筛选结果条数预览
*/
public function exportCount(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if (!$this->auth->check('mall/dailyPush/export')) {
return $this->error(__('You have no permission'), [], 401);
}
try {
[$where, $alias] = $this->buildExportQueryParts();
$count = $this->model->alias($alias)->where($where)->count();
$exporter = new MallDailyPushExport();
return $this->success('', [
'count' => $count,
'max_limit' => $exporter->getMaxExportLimit(),
]);
} catch (Throwable $e) {
return $this->error($e->getMessage());
}
}
/**
* 导出 ExcelXLSX 流式写入)
*/
public function export(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
$fieldsRaw = $request->post('fields', $request->get('fields', []));
$fields = is_array($fieldsRaw) ? $fieldsRaw : explode(',', strval($fieldsRaw));
$limitRaw = strval($request->post('export_limit', $request->get('export_limit', '0')));
if (!is_numeric($limitRaw)) {
return $this->error(__('Parameter error'));
}
$limit = intval($limitRaw);
if ($limit < 0) {
return $this->error(__('Parameter error'));
}
try {
[$where, $alias, $order] = $this->buildExportQueryParts();
$lang = strval($request->header('think-lang', 'zh-cn'));
$exporter = new MallDailyPushExport();
return $exporter->export($this->model, $where, $alias, $order, $fields, $limit, $lang);
} catch (Throwable $e) {
return $this->error($e->getMessage());
}
}
/**
* @return array{0: array, 1: array, 2: array}
*/
private function buildExportQueryParts(): array
{
[$where, $alias, , $order] = $this->queryBuilder();
return [$where, $alias, $order];
}
/**
* 按历史 mall_daily_push 记录回补用户信息
*/
public function backfillUsers(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
$dateFrom = trim(strval($request->post('date_from', $request->get('date_from', ''))));
$dateTo = trim(strval($request->post('date_to', $request->get('date_to', ''))));
$limitRaw = strval($request->post('limit', $request->get('limit', '0')));
$dryRunRaw = strval($request->post('dry_run', $request->get('dry_run', '0')));
if ($dateFrom !== '' && !$this->isValidDate($dateFrom)) {
return $this->error('date_from 格式错误,需为 YYYY-MM-DD');
}
if ($dateTo !== '' && !$this->isValidDate($dateTo)) {
return $this->error('date_to 格式错误,需为 YYYY-MM-DD');
}
if (!is_numeric($limitRaw)) {
return $this->error('limit 必须是数字');
}
$limit = intval($limitRaw);
if ($limit < 0) {
return $this->error('limit 不能小于 0');
}
$dryRun = in_array(strtolower($dryRunRaw), ['1', 'true', 'yes', 'on'], true);
$service = new MallDailyPushBackfill();
$result = $service->backfill(
$dateFrom === '' ? null : $dateFrom,
$dateTo === '' ? null : $dateTo,
$limit,
$dryRun
);
return $this->success('', $result);
}
private function isValidDate(string $value): bool
{
$dt = \DateTime::createFromFormat('Y-m-d', $value);
if (!$dt) {
return false;
}
return $dt->format('Y-m-d') === $value;
}
}

View File

@@ -87,4 +87,135 @@ class Item extends Backend
'remark' => get_route_remark(),
]);
}
/**
* 兼容不同前端键名,避免英文字段被漏存。
* - 优先 snake_case: title_en / description_en
* - 回落 camelCase: titleEn / descriptionEn
*/
private function normalizeLangFields(array $data): array
{
$data['title_en'] = isset($data['title_en'])
? strval($data['title_en'])
: strval($data['titleEn'] ?? '');
$data['description_en'] = isset($data['description_en'])
? strval($data['description_en'])
: strval($data['descriptionEn'] ?? '');
$data['title_ms'] = isset($data['title_ms'])
? strval($data['title_ms'])
: strval($data['titleMs'] ?? '');
$data['description_ms'] = isset($data['description_ms'])
? strval($data['description_ms'])
: strval($data['descriptionMs'] ?? '');
unset($data['titleEn'], $data['descriptionEn'], $data['titleMs'], $data['descriptionMs']);
return $data;
}
/**
* 重写新增:显式处理中英马三语字段。
*/
protected function _add(): Response
{
if ($this->request && $this->request->method() === 'POST') {
$data = $this->request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->applyInputFilter($data);
$data = $this->normalizeLangFields($data);
$data = $this->excludeFields($data);
if ($this->dataLimit && $this->dataLimitFieldAutoFill) {
$data[$this->dataLimitField] = $this->auth->id;
}
if ($this->autoFillAdminId && $this->dataLimitField === 'admin_id') {
$data['admin_id'] = $this->auth->id;
}
$result = false;
$this->model->startTrans();
try {
if ($this->modelValidate) {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validate)) {
$validate = new $validate();
if ($this->modelSceneValidate) {
$validate->scene('add');
}
$validate->check($data);
}
}
$result = $this->model->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
if ($result !== false) {
return $this->success(__('Added successfully'));
}
return $this->error(__('No rows were added'));
}
return $this->error(__('Parameter error'));
}
/**
* 重写编辑:显式处理中英马三语字段。
*/
protected function _edit(): Response
{
$pk = $this->model->getPk();
$id = $this->request ? ($this->request->post($pk) ?? $this->request->get($pk)) : null;
$row = $this->model->find($id);
if (!$row) {
return $this->error(__('Record not found'));
}
$dataLimitAdminIds = $this->getDataLimitAdminIds();
if ($dataLimitAdminIds && !in_array($row[$this->dataLimitField], $dataLimitAdminIds)) {
return $this->error(__('You have no permission'));
}
if ($this->request && $this->request->method() === 'POST') {
$data = $this->request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->applyInputFilter($data);
$data = $this->normalizeLangFields($data);
$data = $this->excludeFields($data);
$result = false;
$this->model->startTrans();
try {
if ($this->modelValidate) {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validate)) {
$validate = new $validate();
if ($this->modelSceneValidate) {
$validate->scene('edit');
}
$data[$pk] = $row[$pk];
$validate->check($data);
}
}
$result = $row->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
if ($result !== false) {
return $this->success(__('Update successful'));
}
return $this->error(__('No rows updated'));
}
return $this->success('', [
'row' => $row
]);
}
}

View File

@@ -26,7 +26,7 @@ class Order extends Backend
protected array $withJoinTable = ['mallItem'];
protected string|array $quickSearchField = ['user_id', 'external_transaction_id', 'playx_transaction_id'];
protected string|array $quickSearchField = ['user_id', 'external_transaction_id'];
protected string|array $indexField = [
'id',
@@ -36,9 +36,7 @@ class Order extends Backend
'mall_item_id',
'points_cost',
'amount',
'multiplier',
'external_transaction_id',
'playx_transaction_id',
'grant_status',
'fail_reason',
'reject_reason',
@@ -75,10 +73,18 @@ class Order extends Backend
[$where, $alias, $limit, $order] = $this->queryBuilder();
$res = $this->model
->with(['mallItem' => function ($query) {
->with([
'mallItem' => function ($query) {
$query->field('id,title');
}])
->visible(['mallItem' => ['title']])
},
'mallUserAsset' => function ($query) {
$query->field('playx_user_id,username');
},
])
->visible([
'mallItem' => ['title'],
'mallUserAsset' => ['username'],
])
->alias($alias)
->where($where)
->order($order)
@@ -166,7 +172,7 @@ class Order extends Backend
if ($order->status !== MallOrder::STATUS_PENDING) {
return $this->error(__('Order status must be PENDING'));
}
if ($order->type === MallOrder::TYPE_PHYSICAL) {
if ($order->type !== MallOrder::TYPE_WITHDRAW) {
return $this->error(__('Order type not supported'));
}
@@ -214,6 +220,9 @@ class Order extends Backend
if ($order->status !== MallOrder::STATUS_PENDING) {
return $this->error(__('Order status must be PENDING'));
}
if (!in_array($order->type, [MallOrder::TYPE_PHYSICAL, MallOrder::TYPE_WITHDRAW], true)) {
return $this->error(__('Order type not supported'));
}
if ($order->type === MallOrder::TYPE_PHYSICAL && $rejectReason === '') {
return $this->error(__('Missing required fields'));
@@ -291,14 +300,14 @@ class Order extends Backend
return $this->error(__('Current grant status cannot be manually pushed'));
}
if (strval(config('playx.api.base_url', '')) === '') {
if (strval(config('playx.angpow_import.base_url', '')) === '') {
return $this->error(__('PlayX API not configured'));
}
$result = MallBonusGrantPush::push($order);
if ($result['ok']) {
$order->grant_status = MallOrder::GRANT_ACCEPTED;
$order->playx_transaction_id = $result['playx_transaction_id'];
$order->status = MallOrder::STATUS_COMPLETED;
$order->fail_reason = null;
$order->update_time = time();
$order->save();

View File

@@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
namespace app\admin\controller\mall;
use app\common\controller\Backend;
use app\common\library\MallPlayxRatios;
use app\common\library\MallPointsConversion;
use app\common\model\MallBusinessConfig;
use support\Response;
use support\think\Db;
use Webman\Http\Request;
/**
* 积分商城参数PlayX 比例等,读写 mall_business_config
*/
class PlayxConfig extends Backend
{
public function index(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
$row = MallBusinessConfig::ensureRow();
MallPlayxRatios::syncFromRow($row);
$payload = $row->toArray();
$payload['active_mall_open_date'] = MallPointsConversion::resolveActiveMallOpenDate($row);
$payload['active_event_end_date'] = MallPointsConversion::resolveActiveEventEndDate($row);
return $this->success('', [
'row' => $payload,
'remark' => get_route_remark(),
]);
}
public function save(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
$data = $request->post();
if (empty($data) || !is_array($data)) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$return = $data['return_ratio'] ?? null;
$unlock = $data['unlock_ratio'] ?? null;
if (!is_numeric($return) || !is_numeric($unlock)) {
return $this->error(__('Parameter error'));
}
$returnF = floatval($return);
$unlockF = floatval($unlock);
if ($returnF < 0 || $unlockF < 0) {
return $this->error(__('Parameter error'));
}
$cashF = null;
if (array_key_exists('points_to_cash_ratio', $data) && $data['points_to_cash_ratio'] !== null && $data['points_to_cash_ratio'] !== '') {
if (!is_numeric($data['points_to_cash_ratio'])) {
return $this->error(__('Parameter error'));
}
$cashF = floatval($data['points_to_cash_ratio']);
if ($cashF < 0) {
return $this->error(__('Parameter error'));
}
}
$dailyEnabled = !empty($data['daily_points_enabled']) ? 1 : 0;
$lifetimeEnabled = !empty($data['lifetime_points_enabled']) ? 1 : 0;
$lifetimeRatio = $data['lifetime_points_ratio'] ?? null;
if (!is_numeric($lifetimeRatio)) {
return $this->error(__('Parameter error'));
}
$lifetimeRatioF = floatval($lifetimeRatio);
if ($lifetimeRatioF < 0) {
return $this->error(__('Parameter error'));
}
$mallOpenDate = MallPointsConversion::normalizeDate($data['mall_open_date'] ?? null);
$mallEventEndDate = MallPointsConversion::normalizeDate($data['mall_event_end_date'] ?? null);
if ($mallOpenDate !== null && $mallEventEndDate !== null && $mallEventEndDate < $mallOpenDate) {
return $this->error(__('Event end date cannot be earlier than start date'));
}
$row = MallBusinessConfig::order('id', 'asc')->find();
$now = time();
if (!$row) {
$effectiveOn = $mallOpenDate !== null ? MallPointsConversion::nextCalendarDate() : null;
$cashPersist = $cashF ?? MallBusinessConfig::DEFAULT_POINTS_TO_CASH_RATIO;
MallBusinessConfig::create([
'return_ratio' => $returnF,
'unlock_ratio' => $unlockF,
'points_to_cash_ratio' => $cashPersist,
'daily_points_enabled' => $dailyEnabled,
'lifetime_points_enabled' => $lifetimeEnabled,
'lifetime_points_ratio' => $lifetimeRatioF,
'mall_open_date' => $mallOpenDate,
'mall_open_date_previous' => null,
'mall_open_date_effective_on' => $effectiveOn,
'mall_event_end_date' => $mallEventEndDate,
'mall_event_end_date_previous' => null,
'mall_event_end_date_effective_on' => MallPointsConversion::nextCalendarDate(),
'create_time' => $now,
'update_time' => $now,
]);
} else {
$previousOpen = MallPointsConversion::resolveActiveMallOpenDate($row);
$currentPending = MallPointsConversion::normalizeDate($row->mall_open_date ?? null);
$openDateChanged = $mallOpenDate !== $currentPending;
$previousEnd = MallPointsConversion::resolveActiveEventEndDate($row);
$currentPendingEnd = MallPointsConversion::normalizeDate($row->mall_event_end_date ?? null);
$endDateChanged = $mallEventEndDate !== $currentPendingEnd;
$row->return_ratio = $returnF;
$row->unlock_ratio = $unlockF;
if ($cashF !== null) {
$row->points_to_cash_ratio = $cashF;
}
$row->daily_points_enabled = $dailyEnabled;
$row->lifetime_points_enabled = $lifetimeEnabled;
$row->lifetime_points_ratio = $lifetimeRatioF;
if ($openDateChanged) {
$row->mall_open_date_previous = $previousOpen;
$row->mall_open_date = $mallOpenDate;
$row->mall_open_date_effective_on = $mallOpenDate !== null ? MallPointsConversion::nextCalendarDate() : null;
}
if ($endDateChanged) {
$row->mall_event_end_date_previous = $previousEnd;
$row->mall_event_end_date = $mallEventEndDate;
$row->mall_event_end_date_effective_on = MallPointsConversion::nextCalendarDate();
}
$row->save();
}
$fresh = MallBusinessConfig::order('id', 'asc')->find();
if ($fresh) {
MallPlayxRatios::syncFromRow($fresh);
}
return $this->success(__('The current page configuration item was updated successfully'));
}
/**
* 一键按最新终身总输赢与比例折算积分(重复执行会先扣旧比例再加新比例)
*/
public function calculateLifetimePoints(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
$row = MallBusinessConfig::order('id', 'asc')->find();
if (!$row) {
return $this->error(__('Parameter error'));
}
Db::startTrans();
try {
$stats = MallPointsConversion::recalculateAllLifetimePoints($row);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
return $this->error(__($e->getMessage()));
}
return $this->success(
sprintf(
__('Lifetime points calculation completed'),
$stats['processed'],
$stats['updated'],
$stats['skipped']
),
$stats
);
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace app\admin\controller\mall;
use app\admin\model\MallPointsLog;
use app\common\controller\Backend;
use app\common\model\MallUserAsset;
use support\Response;
use Webman\Http\Request;
class PointsLog extends Backend
{
protected ?object $model = null;
protected array $withJoinTable = ['userAsset', 'admin'];
protected array|string $preExcludeFields = ['id', 'before', 'after', 'admin_id', 'create_time'];
protected array|string $quickSearchField = [
'userAsset.playx_user_id',
'userAsset.username',
'userAsset.phone',
];
protected bool $autoFillAdminId = true;
protected function initController(Request $request): ?Response
{
$this->model = new MallPointsLog();
return null;
}
public function add(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if ($request->method() === 'POST') {
return $this->_add();
}
$userAssetId = $request->get('userAssetId', $request->post('userAssetId', 0));
$userAsset = MallUserAsset::where('id', $userAssetId)->find();
if (!$userAsset) {
return $this->error(__('User asset not found'));
}
return $this->success('', ['userAsset' => $userAsset]);
}
public function edit(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
return $this->error(__('Points log cannot be modified'));
}
public function del(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
return $this->error(__('Points log cannot be deleted'));
}
}

View File

@@ -17,7 +17,14 @@ class UserAsset extends Backend
*/
protected ?object $model = null;
protected array|string $preExcludeFields = ['id', 'create_time', 'update_time'];
protected array|string $preExcludeFields = [
'id',
'locked_points',
'available_points',
'today_claimed',
'create_time',
'update_time',
];
protected string|array $quickSearchField = [
'playx_user_id',
@@ -35,6 +42,9 @@ class UserAsset extends Backend
'today_limit',
'today_claimed',
'today_limit_date',
'points_claim_cap',
'total_points_claimed',
'event_period_win_loss_net',
'create_time',
'update_time',
];

View File

@@ -13,8 +13,8 @@ class AdminInfo extends Backend
{
protected ?object $model = null;
protected array|string $preExcludeFields = ['username', 'last_login_time', 'password', 'salt', 'status', 'channel_id', 'agent_id', 'agent_api_secret'];
protected array $authAllowFields = ['id', 'username', 'nickname', 'avatar', 'email', 'mobile', 'motto', 'last_login_time'];
protected array|string $preExcludeFields = ['username', 'last_login_time', 'password', 'salt', 'status', 'channel_id', 'totp_secret', 'totp_bind_time'];
protected array $authAllowFields = ['id', 'username', 'nickname', 'avatar', 'email', 'mobile', 'motto', 'last_login_time', 'totp_bound'];
protected function initController(Request $request): ?Response
{

View File

@@ -27,9 +27,14 @@ return [
'Added successfully' => 'Added successfully!',
'Deleted successfully' => 'Deleted successfully!',
'Parameter error' => 'Parameter error!',
'Event end date cannot be earlier than start date' => 'Event end date cannot be earlier than start date',
'Lifetime points source is disabled' => 'Lifetime points source is disabled',
'Operation completed' => 'Operation completed!',
'Lifetime points calculation completed' => 'Lifetime conversion done: %d processed, %d updated, %d skipped',
'File uploaded successfully' => 'File uploaded successfully',
'No files were uploaded' => 'No files were uploaded',
'The uploaded file format is not allowed' => 'The uploaded file format is no allowance.',
'The uploaded file contains prohibited content' => 'The uploaded file contains prohibited content',
'The uploaded image file is not a valid image' => 'The uploaded image file is not a valid image',
'The uploaded file is too large (%sMiB), Maximum file size:%sMiB' => 'The uploaded file is too large (%sMiB), maximum file size:%sMiB',
'No files have been uploaded or the file size exceeds the upload limit of the server' => 'No files have been uploaded or the file size exceeds the server upload limit.',
@@ -97,7 +102,29 @@ return [
'Group Name Arr' => 'Group Name Arr',
'Push succeeded' => 'Push succeeded',
'Manual push failed' => 'Manual push failed',
'PlayX API not configured' => 'PlayX API not configured',
'playX API not configured' => 'playX API not configured',
'Current grant status cannot be manually pushed' => 'Current grant status cannot be manually pushed',
'Order status must be PENDING' => 'Order status must be PENDING',
'Missing required fields' => 'Missing required fields',
'Order type not PHYSICAL' => 'Order type is not physical goods',
'Order type not supported' => 'Order type not supported',
'Only BONUS can retry' => 'Only bonus orders can retry push',
'Shipped successfully' => 'Shipped successfully',
'Approved successfully' => 'Approved successfully',
'Rejected successfully' => 'Rejected successfully',
'Success' => 'success',
'Failed' => 'failed',
'Please enter Google Authenticator code' => 'Please enter Google Authenticator code',
'Please bind Google Authenticator' => 'Please bind Google Authenticator',
'TOTP session expired, please login again' => 'Verification session expired, please login again',
'Google Authenticator already bound' => 'Google Authenticator already bound',
'Google Authenticator code error' => 'Google Authenticator code error',
'Google Authenticator bind failed' => 'Google Authenticator bind failed',
'Google Authenticator bound successfully' => 'Google Authenticator bound successfully',
'Google Authenticator not bound' => 'Google Authenticator not bound',
'Reset Google Authenticator' => 'Reset Google Authenticator',
'Google Authenticator reset successfully' => 'Google Authenticator reset. The admin must re-bind on next login',
'Google Authenticator is disabled' => 'Google Authenticator is disabled',
'Cannot reset your own authenticator, please modify in database' => 'Cannot reset your own authenticator. Super admin recovery requires database change',
'Login failed' => 'Login failed',
];

View File

@@ -0,0 +1,13 @@
<?php
return [
'User asset' => 'User asset',
'Points adjustment' => 'Points adjustment',
'User asset not found' => 'User asset not found',
'Points adjustment must be a non-zero integer' => 'Points adjustment must be a non-zero integer',
'Insufficient available points' => 'Insufficient available points',
'Administrator added %s points' => 'Administrator added %s points',
'Administrator deducted %s points' => 'Administrator deducted %s points',
'Points log cannot be modified' => 'Points log cannot be modified',
'Points log cannot be deleted' => 'Points log cannot be deleted',
];

View File

@@ -27,10 +27,15 @@ return [
'Added successfully' => '添加成功!',
'Deleted successfully' => '删除成功!',
'Parameter error' => '参数错误!',
'Event end date cannot be earlier than start date' => '活动结束日不能早于开始日',
'Lifetime points source is disabled' => '终身总输赢积分来源未启用',
'Operation completed' => '操作完成!',
'Lifetime points calculation completed' => '终身总输赢积分折算完成:共处理 %d 条,更新 %d 条,跳过 %d 条',
'Please use the %s field to sort before operating' => '请使用 %s 字段排序后再操作',
'File uploaded successfully' => '文件上传成功!',
'No files were uploaded' => '没有文件被上传',
'The uploaded file format is not allowed' => '上传的文件格式未被允许',
'The uploaded file contains prohibited content' => '上传的文件包含禁止的内容',
'The uploaded image file is not a valid image' => '上传的图片文件不是有效的图像',
'The uploaded file is too large (%sMiB), Maximum file size:%sMiB' => '上传的文件太大(%sM),最大文件大小:%sM',
'No files have been uploaded or the file size exceeds the upload limit of the server' => '没有文件被上传或文件大小超出服务器上传限制!',
@@ -116,7 +121,29 @@ return [
'Group Name Arr' => '分组名称数组',
'Push succeeded' => '推送成功',
'Manual push failed' => '手动推送失败',
'PlayX API not configured' => 'PlayX 接口未配置',
'playX API not configured' => 'playX 接口未配置',
'Current grant status cannot be manually pushed' => '当前发放状态不可手动推送',
'Order status must be PENDING' => '订单状态须为处理中',
'Missing required fields' => '缺少必填项',
'Order type not PHYSICAL' => '订单类型不是实物',
'Order type not supported' => '订单类型不支持',
'Only BONUS can retry' => '仅红利订单可重试推送',
'Shipped successfully' => '发货成功',
'Approved successfully' => '审核通过',
'Rejected successfully' => '驳回成功',
'Success' => '成功',
'Failed' => '失败',
'Please enter Google Authenticator code' => '请输入谷歌验证器验证码',
'Please bind Google Authenticator' => '请绑定谷歌验证器',
'TOTP session expired, please login again' => '验证会话已过期,请重新登录',
'Google Authenticator already bound' => '谷歌验证器已绑定',
'Google Authenticator code error' => '谷歌验证器验证码错误',
'Google Authenticator bind failed' => '谷歌验证器绑定失败',
'Google Authenticator bound successfully' => '谷歌验证器绑定成功',
'Google Authenticator not bound' => '谷歌验证器未绑定',
'Reset Google Authenticator' => '重置谷歌验证器',
'Google Authenticator reset successfully' => '谷歌验证器已重置,该管理员下次登录需重新绑定',
'Google Authenticator is disabled' => '谷歌验证器功能已关闭',
'Cannot reset your own authenticator, please modify in database' => '不能重置自己的验证器,超管丢失验证器请在数据库中修改',
'Login failed' => '登录失败',
];

View File

@@ -0,0 +1,13 @@
<?php
return [
'User asset' => '用户资产',
'Points adjustment' => '调整积分',
'User asset not found' => '用户资产不存在',
'Points adjustment must be a non-zero integer' => '调整积分必须为非零整数',
'Insufficient available points' => '可用积分不足',
'Administrator added %s points' => '管理员增加积分 %s',
'Administrator deducted %s points' => '管理员扣除积分 %s',
'Points log cannot be modified' => '积分流水不允许修改',
'Points log cannot be deleted' => '积分流水不允许删除',
];

View File

@@ -1,29 +0,0 @@
<?php
return [
'Order not found' => '订单找不到啦!',
'Module already exists' => '模块已存在!',
'package download failed' => '包下载失败!',
'package check failed' => '包检查失败!',
'No permission to write temporary files' => '没有权限写入临时文件!',
'Zip file not found' => '找不到压缩包文件',
'Unable to open the zip file' => '无法打开压缩包文件',
'Unable to extract ZIP file' => '无法提取ZIP文件',
'Unable to package zip file' => '无法打包zip文件',
'Basic configuration of the Module is incomplete' => '模块基础配置不完整',
'Module package file does not exist' => '模块包文件不存在',
'Module file conflicts' => '模块文件存在冲突,请手动处理!',
'Configuration file has no write permission' => '配置文件无写入权限',
'The current state of the module cannot be set to disabled' => '模块当前状态无法设定为禁用',
'The current state of the module cannot be set to enabled' => '模块当前状态无法设定为启用',
'Module file updated' => '模块文件有更新',
'Please disable the module first' => '请先禁用模块',
'Please disable the module before updating' => '更新前请先禁用模块',
'The directory required by the module is occupied' => '模块所需目录已被占用',
'Install module' => '安装模块',
'Unload module' => '卸载模块',
'Update module' => '更新模块',
'Change module state' => '改变模块状态',
'Upload install module' => '上传安装模块',
'Please login to the official website account first' => '请先使用BuildAdmin官网账户登录到模块市场~',
'composer config %s conflict' => 'composer 配置项 %s 存在冲突',
];

View File

@@ -24,6 +24,8 @@ class Auth extends \ba\Auth
public const LOGIN_RESPONSE_CODE = 303;
public const NEED_LOGIN = 'need login';
public const LOGGED_IN = 'logged in';
public const NEED_TOTP = 'need_totp';
public const NEED_BIND_TOTP = 'need_bind_totp';
public const TOKEN_TYPE = 'admin';
protected bool $loginEd = false;
@@ -90,6 +92,21 @@ class Auth extends \ba\Auth
}
public function login(string $username, string $password, bool $keep = false): bool
{
if (!$this->verifyCredentials($username, $password)) {
return false;
}
if (config('buildadmin.admin_sso')) {
Token::clear(self::TOKEN_TYPE, $this->model->id);
Token::clear(self::TOKEN_TYPE . '-refresh', $this->model->id);
}
if ($keep) {
$this->setRefreshToken($this->refreshTokenKeepTime);
}
return $this->loginSuccessful();
}
public function verifyCredentials(string $username, string $password): bool
{
$this->model = Admin::where('username', $username)->find();
if (!$this->model) {
@@ -124,18 +141,44 @@ class Auth extends \ba\Auth
return false;
}
return true;
}
public function hasTotpBound(): bool
{
if (!$this->model) {
return false;
}
return \app\common\library\AdminTotp::isBound($this->model->getData('totp_secret'));
}
public function loadAdminById(int $adminId): bool
{
$this->model = Admin::where('id', $adminId)->find();
if (!$this->model) {
$this->setError('Account not exist');
return false;
}
if ($this->model->status === 'disable') {
$this->setError('Account disabled');
return false;
}
return true;
}
public function finalizeLogin(bool $keep = false): bool
{
if (!$this->model) {
return false;
}
if (config('buildadmin.admin_sso')) {
Token::clear(self::TOKEN_TYPE, $this->model->id);
Token::clear(self::TOKEN_TYPE . '-refresh', $this->model->id);
}
if ($keep) {
$this->setRefreshToken($this->refreshTokenKeepTime);
}
if (!$this->loginSuccessful()) {
return false;
}
return true;
return $this->loginSuccessful();
}
public function setRefreshToken(int $keepTime = 0): void

View File

@@ -1,900 +0,0 @@
<?php
declare(strict_types=1);
namespace app\admin\library\module;
use Throwable;
use ba\Version;
use ba\Depends;
use ba\Exception;
use ba\Filesystem;
use FilesystemIterator;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use Webman\Http\Request;
/**
* 模块管理类Webman 迁移版)
*/
class Manage
{
public const UNINSTALLED = 0;
public const INSTALLED = 1;
public const WAIT_INSTALL = 2;
public const CONFLICT_PENDING = 3;
public const DEPENDENT_WAIT_INSTALL = 4;
public const DIRECTORY_OCCUPIED = 5;
public const DISABLE = 6;
protected static ?Manage $instance = null;
protected string $installDir;
protected string $backupsDir;
protected string $uid;
protected string $modulesDir;
public static function instance(string $uid = ''): Manage
{
if (self::$instance === null) {
self::$instance = new static($uid);
}
return self::$instance->setModuleUid($uid);
}
public function __construct(string $uid)
{
$this->installDir = root_path() . 'modules' . DIRECTORY_SEPARATOR;
$this->backupsDir = $this->installDir . 'backups' . DIRECTORY_SEPARATOR;
if (!is_dir($this->installDir)) {
mkdir($this->installDir, 0755, true);
}
if (!is_dir($this->backupsDir)) {
mkdir($this->backupsDir, 0755, true);
}
if ($uid) {
$this->setModuleUid($uid);
} else {
$this->uid = '';
$this->modulesDir = $this->installDir;
}
}
public function getInstallState(): int
{
if (!is_dir($this->modulesDir)) {
return self::UNINSTALLED;
}
$info = $this->getInfo();
if ($info && isset($info['state'])) {
return $info['state'];
}
return Filesystem::dirIsEmpty($this->modulesDir) ? self::UNINSTALLED : self::DIRECTORY_OCCUPIED;
}
/**
* 从 Webman Request 上传安装(适配 Multipart 上传)
* @return array 模块基本信息
* @throws Throwable
*/
public static function uploadFromRequest(Request $request): array
{
$file = $request->file('file');
if (!$file) {
throw new Exception('Parameter error');
}
$token = $request->post('token', $request->get('token', ''));
if (!$token) {
throw new Exception('Please login to the official website account first');
}
$uploadDir = root_path() . 'public' . DIRECTORY_SEPARATOR . 'storage' . DIRECTORY_SEPARATOR . 'upload' . DIRECTORY_SEPARATOR;
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
$saveName = 'temp' . DIRECTORY_SEPARATOR . date('YmdHis') . '_' . ($file->getUploadName() ?? 'module.zip');
$savePath = $uploadDir . $saveName;
$saveDir = dirname($savePath);
if (!is_dir($saveDir)) {
mkdir($saveDir, 0755, true);
}
$file->move($savePath);
$relativePath = 'storage/upload/' . str_replace(DIRECTORY_SEPARATOR, '/', $saveName);
try {
return self::instance('')->doUpload($token, $relativePath);
} finally {
if (is_file($savePath)) {
@unlink($savePath);
}
}
}
/**
* 下载模块文件
* @throws Throwable
*/
public function download(): string
{
$req = function_exists('request') ? request() : null;
$token = $req ? ($req->post('token', $req->get('token', ''))) : '';
$version = $req ? ($req->post('version', $req->get('version', ''))) : '';
$orderId = $req ? ($req->post('orderId', $req->get('orderId', 0))) : 0;
if (!$orderId) {
throw new Exception('Order not found');
}
$zipFile = Server::download($this->uid, $this->installDir, [
'version' => $version,
'orderId' => $orderId,
'nuxtVersion' => Server::getNuxtVersion(),
'sysVersion' => config('buildadmin.version', ''),
'installed' => Server::getInstalledIds($this->installDir),
'ba-user-token' => $token,
]);
Filesystem::delDir($this->modulesDir);
Filesystem::unzip($zipFile);
@unlink($zipFile);
$this->checkPackage();
$this->setInfo([
'state' => self::WAIT_INSTALL,
]);
return $zipFile;
}
/**
* 上传安装token + 文件相对路径)
* @throws Throwable
*/
public function doUpload(string $token, string $file): array
{
$file = Filesystem::fsFit(root_path() . 'public' . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $file));
if (!is_file($file)) {
throw new Exception('Zip file not found');
}
$copyTo = $this->installDir . 'uploadTemp' . date('YmdHis') . '.zip';
copy($file, $copyTo);
$copyToDir = Filesystem::unzip($copyTo);
$copyToDir .= DIRECTORY_SEPARATOR;
@unlink($file);
@unlink($copyTo);
$info = Server::getIni($copyToDir);
if (empty($info['uid'])) {
Filesystem::delDir($copyToDir);
throw new Exception('Basic configuration of the Module is incomplete');
}
$this->setModuleUid($info['uid']);
$upgrade = false;
if (is_dir($this->modulesDir)) {
$oldInfo = $this->getInfo();
if ($oldInfo && !empty($oldInfo['uid'])) {
$versions = explode('.', $oldInfo['version'] ?? '0.0.0');
if (isset($versions[2])) {
$versions[2]++;
}
$nextVersion = implode('.', $versions);
$upgrade = Version::compare($nextVersion, $info['version'] ?? '');
if ($upgrade) {
if (!in_array($oldInfo['state'], [self::UNINSTALLED, self::WAIT_INSTALL, self::DISABLE])) {
Filesystem::delDir($copyToDir);
throw new Exception('Please disable the module before updating');
}
} else {
Filesystem::delDir($copyToDir);
throw new Exception('Module already exists');
}
}
if (!Filesystem::dirIsEmpty($this->modulesDir) && !$upgrade) {
Filesystem::delDir($copyToDir);
throw new Exception('The directory required by the module is occupied');
}
}
try {
Server::installPreCheck([
'uid' => $info['uid'],
'version' => $info['version'] ?? '',
'sysVersion' => config('buildadmin.version', ''),
'nuxtVersion' => Server::getNuxtVersion(),
'moduleVersion' => $info['version'] ?? '',
'ba-user-token' => $token,
'installed' => Server::getInstalledIds($this->installDir),
'server' => 1,
]);
} catch (Throwable $e) {
Filesystem::delDir($copyToDir);
throw $e;
}
$newInfo = ['state' => self::WAIT_INSTALL];
if ($upgrade) {
$info['update'] = 1;
Filesystem::delDir($this->modulesDir);
}
rename($copyToDir, $this->modulesDir);
$this->checkPackage();
$this->setInfo($newInfo);
return $info;
}
/**
* 安装模块
* @throws Throwable
*/
public function install(bool $update): array
{
$state = $this->getInstallState();
if ($update) {
if (!in_array($state, [self::UNINSTALLED, self::WAIT_INSTALL, self::DISABLE])) {
throw new Exception('Please disable the module before updating');
}
if ($state == self::UNINSTALLED || $state != self::WAIT_INSTALL) {
$this->download();
}
} else {
if ($state == self::INSTALLED || $state == self::DIRECTORY_OCCUPIED || $state == self::DISABLE) {
throw new Exception('Module already exists');
}
if ($state == self::UNINSTALLED) {
$this->download();
}
}
Server::importSql($this->modulesDir);
$info = $this->getInfo();
if ($update) {
$info['update'] = 1;
Server::execEvent($this->uid, 'update');
}
$req = function_exists('request') ? request() : null;
$extend = $req ? ($req->post('extend') ?? []) : [];
if (!isset($extend['conflictHandle'])) {
Server::execEvent($this->uid, 'install');
}
$this->enable('install');
return $info;
}
/**
* 卸载
* @throws Throwable
*/
public function uninstall(): void
{
$info = $this->getInfo();
if (($info['state'] ?? 0) != self::DISABLE) {
throw new Exception('Please disable the module first', 0, [
'uid' => $this->uid,
]);
}
Server::execEvent($this->uid, 'uninstall');
Filesystem::delDir($this->modulesDir);
}
/**
* 修改模块状态
* @throws Throwable
*/
public function changeState(bool $state): array
{
$info = $this->getInfo();
if (!$state) {
$canDisable = [
self::INSTALLED,
self::CONFLICT_PENDING,
self::DEPENDENT_WAIT_INSTALL,
];
if (!in_array($info['state'] ?? 0, $canDisable)) {
throw new Exception('The current state of the module cannot be set to disabled', 0, [
'uid' => $this->uid,
'state' => $info['state'] ?? 0,
]);
}
return $this->disable();
}
if (($info['state'] ?? 0) != self::DISABLE) {
throw new Exception('The current state of the module cannot be set to enabled', 0, [
'uid' => $this->uid,
'state' => $info['state'] ?? 0,
]);
}
$this->setInfo([
'state' => self::WAIT_INSTALL,
]);
return $info;
}
/**
* 启用
* @throws Throwable
*/
public function enable(string $trigger): void
{
Server::installWebBootstrap($this->uid, $this->modulesDir);
Server::createRuntime($this->modulesDir);
$this->conflictHandle($trigger);
Server::execEvent($this->uid, 'enable');
$this->dependUpdateHandle();
}
/**
* 禁用
* @throws Throwable
*/
public function disable(): array
{
$req = function_exists('request') ? request() : null;
$update = $req ? filter_var($req->post('update', false), FILTER_VALIDATE_BOOLEAN) : false;
$confirmConflict = $req ? filter_var($req->post('confirmConflict', false), FILTER_VALIDATE_BOOLEAN) : false;
$dependConflictSolution = $req ? ($req->post('dependConflictSolution') ?? []) : [];
$info = $this->getInfo();
$zipFile = $this->backupsDir . $this->uid . '-install.zip';
$zipDir = false;
if (is_file($zipFile)) {
try {
$zipDir = $this->backupsDir . $this->uid . '-install' . DIRECTORY_SEPARATOR;
Filesystem::unzip($zipFile, $zipDir);
} catch (Exception) {
// skip
}
}
$conflictFile = Server::getFileList($this->modulesDir, true);
$dependConflict = $this->disableDependCheck();
if (($conflictFile || !self::isEmptyArray($dependConflict)) && !$confirmConflict) {
$dependConflictTemp = [];
foreach ($dependConflict as $env => $item) {
foreach ($item as $depend => $v) {
$dependConflictTemp[] = [
'env' => $env,
'depend' => $depend,
'dependTitle' => $depend . ' ' . $v,
'solution' => 'delete',
];
}
}
throw new Exception('Module file updated', -1, [
'uid' => $this->uid,
'conflictFile' => $conflictFile,
'dependConflict' => $dependConflictTemp,
]);
}
Server::execEvent($this->uid, 'disable', ['update' => $update]);
$delNpmDepend = false;
$delNuxtNpmDepend = false;
$delComposerDepend = false;
foreach ($dependConflictSolution as $env => $depends) {
if (!$depends) continue;
if ($env == 'require' || $env == 'require-dev') {
$delComposerDepend = true;
} elseif ($env == 'dependencies' || $env == 'devDependencies') {
$delNpmDepend = true;
} elseif ($env == 'nuxtDependencies' || $env == 'nuxtDevDependencies') {
$delNuxtNpmDepend = true;
}
}
$dependJsonFiles = [
'composer' => 'composer.json',
'webPackage' => 'web' . DIRECTORY_SEPARATOR . 'package.json',
'webNuxtPackage' => 'web-nuxt' . DIRECTORY_SEPARATOR . 'package.json',
];
$dependWaitInstall = [];
if ($delComposerDepend) {
$conflictFile[] = $dependJsonFiles['composer'];
$dependWaitInstall[] = [
'pm' => false,
'command' => 'composer.update',
'type' => 'composer_dependent_wait_install',
];
}
if ($delNpmDepend) {
$conflictFile[] = $dependJsonFiles['webPackage'];
$dependWaitInstall[] = [
'pm' => true,
'command' => 'web-install',
'type' => 'npm_dependent_wait_install',
];
}
if ($delNuxtNpmDepend) {
$conflictFile[] = $dependJsonFiles['webNuxtPackage'];
$dependWaitInstall[] = [
'pm' => true,
'command' => 'nuxt-install',
'type' => 'nuxt_npm_dependent_wait_install',
];
}
if ($conflictFile) {
$overwriteDir = Server::getOverwriteDir();
foreach ($conflictFile as $key => $item) {
$paths = explode(DIRECTORY_SEPARATOR, $item);
if (in_array($paths[0], $overwriteDir) || in_array($item, $dependJsonFiles)) {
$conflictFile[$key] = $item;
} else {
$conflictFile[$key] = Filesystem::fsFit(str_replace(root_path(), '', $this->modulesDir . $item));
}
if (!is_file(root_path() . $conflictFile[$key])) {
unset($conflictFile[$key]);
}
}
$backupsZip = $this->backupsDir . $this->uid . '-disable-' . date('YmdHis') . '.zip';
Filesystem::zip($conflictFile, $backupsZip);
}
$serverDepend = new Depends(root_path() . 'composer.json', 'composer');
$webDep = new Depends(root_path() . 'web' . DIRECTORY_SEPARATOR . 'package.json');
$webNuxtDep = new Depends(root_path() . 'web-nuxt' . DIRECTORY_SEPARATOR . 'package.json');
foreach ($dependConflictSolution as $env => $depends) {
if (!$depends) continue;
$dev = stripos($env, 'dev') !== false;
if ($env == 'require' || $env == 'require-dev') {
$serverDepend->removeDepends($depends, $dev);
} elseif ($env == 'dependencies' || $env == 'devDependencies') {
$webDep->removeDepends($depends, $dev);
} elseif ($env == 'nuxtDependencies' || $env == 'nuxtDevDependencies') {
$webNuxtDep->removeDepends($depends, $dev);
}
}
$composerConfig = Server::getConfig($this->modulesDir, 'composerConfig');
if ($composerConfig) {
$serverDepend->removeComposerConfig($composerConfig);
}
$protectedFiles = Server::getConfig($this->modulesDir, 'protectedFiles');
foreach ($protectedFiles as &$protectedFile) {
$protectedFile = Filesystem::fsFit(root_path() . $protectedFile);
}
$moduleFile = Server::getFileList($this->modulesDir);
foreach ($moduleFile as &$file) {
$moduleFilePath = Filesystem::fsFit($this->modulesDir . $file);
$file = Filesystem::fsFit(root_path() . $file);
if (!file_exists($file)) continue;
if (!file_exists($moduleFilePath)) {
if (!is_dir(dirname($moduleFilePath))) {
mkdir(dirname($moduleFilePath), 0755, true);
}
copy($file, $moduleFilePath);
}
if (in_array($file, $protectedFiles)) {
continue;
}
if (file_exists($file)) {
unlink($file);
}
Filesystem::delEmptyDir(dirname($file));
}
if ($zipDir) {
$unrecoverableFiles = [
Filesystem::fsFit(root_path() . 'composer.json'),
Filesystem::fsFit(root_path() . 'web/package.json'),
Filesystem::fsFit(root_path() . 'web-nuxt/package.json'),
];
foreach (
new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($zipDir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
) as $item
) {
$backupsFile = Filesystem::fsFit(root_path() . str_replace($zipDir, '', $item->getPathname()));
if (in_array($backupsFile, $moduleFile) && !in_array($backupsFile, $protectedFiles)) {
continue;
}
if ($item->isDir()) {
if (!is_dir($backupsFile)) {
mkdir($backupsFile, 0755, true);
}
} elseif (!in_array($backupsFile, $unrecoverableFiles)) {
copy($item->getPathname(), $backupsFile);
}
}
}
if ($zipDir && is_dir($zipDir)) {
Filesystem::delDir($zipDir);
}
Server::uninstallWebBootstrap($this->uid);
$this->setInfo([
'state' => self::DISABLE,
]);
if ($update) {
throw new Exception('update', -3, [
'uid' => $this->uid,
]);
}
if (!empty($dependWaitInstall)) {
throw new Exception('dependent wait install', -2, [
'uid' => $this->uid,
'wait_install' => $dependWaitInstall,
]);
}
return $info;
}
/**
* 处理依赖和文件冲突
* @throws Throwable
*/
public function conflictHandle(string $trigger): bool
{
$info = $this->getInfo();
if (!in_array($info['state'] ?? 0, [self::WAIT_INSTALL, self::CONFLICT_PENDING])) {
return false;
}
$fileConflict = Server::getFileList($this->modulesDir, true);
$dependConflict = Server::dependConflictCheck($this->modulesDir);
$installFiles = Server::getFileList($this->modulesDir);
$depends = Server::getDepend($this->modulesDir);
$coverFiles = [];
$discardFiles = [];
$serverDep = new Depends(root_path() . 'composer.json', 'composer');
$webDep = new Depends(root_path() . 'web' . DIRECTORY_SEPARATOR . 'package.json');
$webNuxtDep = new Depends(root_path() . 'web-nuxt' . DIRECTORY_SEPARATOR . 'package.json');
$req = function_exists('request') ? request() : null;
$extend = $req ? ($req->post('extend') ?? []) : [];
if ($fileConflict || !self::isEmptyArray($dependConflict)) {
if (!$extend) {
$fileConflictTemp = [];
foreach ($fileConflict as $key => $item) {
$fileConflictTemp[$key] = [
'newFile' => $this->uid . DIRECTORY_SEPARATOR . $item,
'oldFile' => $item,
'solution' => 'cover',
];
}
$dependConflictTemp = [];
foreach ($dependConflict as $env => $item) {
$dev = stripos($env, 'dev') !== false;
foreach ($item as $depend => $v) {
$oldDepend = '';
if (in_array($env, ['require', 'require-dev'])) {
$oldDepend = $depend . ' ' . $serverDep->hasDepend($depend, $dev);
} elseif (in_array($env, ['dependencies', 'devDependencies'])) {
$oldDepend = $depend . ' ' . $webDep->hasDepend($depend, $dev);
} elseif (in_array($env, ['nuxtDependencies', 'nuxtDevDependencies'])) {
$oldDepend = $depend . ' ' . $webNuxtDep->hasDepend($depend, $dev);
}
$dependConflictTemp[] = [
'env' => $env,
'newDepend' => $depend . ' ' . $v,
'oldDepend' => $oldDepend,
'depend' => $depend,
'solution' => 'cover',
];
}
}
$this->setInfo([
'state' => self::CONFLICT_PENDING,
]);
throw new Exception('Module file conflicts', -1, [
'fileConflict' => $fileConflictTemp,
'dependConflict' => $dependConflictTemp,
'uid' => $this->uid,
'state' => self::CONFLICT_PENDING,
]);
}
if ($fileConflict && isset($extend['fileConflict'])) {
foreach ($installFiles as $ikey => $installFile) {
if (isset($extend['fileConflict'][$installFile])) {
if ($extend['fileConflict'][$installFile] == 'discard') {
$discardFiles[] = $installFile;
unset($installFiles[$ikey]);
} else {
$coverFiles[] = $installFile;
}
}
}
}
if (!self::isEmptyArray($dependConflict) && isset($extend['dependConflict'])) {
foreach ($depends as $fKey => $fItem) {
foreach ($fItem as $cKey => $cItem) {
if (isset($extend['dependConflict'][$fKey][$cKey])) {
if ($extend['dependConflict'][$fKey][$cKey] == 'discard') {
unset($depends[$fKey][$cKey]);
}
}
}
}
}
}
if ($depends) {
foreach ($depends as $key => $item) {
if (!$item) continue;
if ($key == 'require' || $key == 'require-dev') {
$coverFiles[] = 'composer.json';
continue;
}
if ($key == 'dependencies' || $key == 'devDependencies') {
$coverFiles[] = 'web' . DIRECTORY_SEPARATOR . 'package.json';
}
if ($key == 'nuxtDependencies' || $key == 'nuxtDevDependencies') {
$coverFiles[] = 'web-nuxt' . DIRECTORY_SEPARATOR . 'package.json';
}
}
}
if ($coverFiles) {
$backupsZip = $trigger == 'install' ? $this->backupsDir . $this->uid . '-install.zip' : $this->backupsDir . $this->uid . '-cover-' . date('YmdHis') . '.zip';
Filesystem::zip($coverFiles, $backupsZip);
}
if ($depends) {
$npm = false;
$composer = false;
$nuxtNpm = false;
$composerConfig = Server::getConfig($this->modulesDir, 'composerConfig');
if ($composerConfig) {
$serverDep->setComposerConfig($composerConfig);
}
foreach ($depends as $key => $item) {
if (!$item) continue;
if ($key == 'require') {
$composer = true;
$serverDep->addDepends($item, false, true);
} elseif ($key == 'require-dev') {
$composer = true;
$serverDep->addDepends($item, true, true);
} elseif ($key == 'dependencies') {
$npm = true;
$webDep->addDepends($item, false, true);
} elseif ($key == 'devDependencies') {
$npm = true;
$webDep->addDepends($item, true, true);
} elseif ($key == 'nuxtDependencies') {
$nuxtNpm = true;
$webNuxtDep->addDepends($item, false, true);
} elseif ($key == 'nuxtDevDependencies') {
$nuxtNpm = true;
$webNuxtDep->addDepends($item, true, true);
}
}
if ($npm) {
$info['npm_dependent_wait_install'] = 1;
$info['state'] = self::DEPENDENT_WAIT_INSTALL;
}
if ($composer) {
$info['composer_dependent_wait_install'] = 1;
$info['state'] = self::DEPENDENT_WAIT_INSTALL;
}
if ($nuxtNpm) {
$info['nuxt_npm_dependent_wait_install'] = 1;
$info['state'] = self::DEPENDENT_WAIT_INSTALL;
}
$info = $info ?? $this->getInfo();
if (($info['state'] ?? 0) != self::DEPENDENT_WAIT_INSTALL) {
$this->setInfo(['state' => self::INSTALLED]);
} else {
$this->setInfo([], $info);
}
} else {
$this->setInfo(['state' => self::INSTALLED]);
}
$overwriteDir = Server::getOverwriteDir();
foreach ($overwriteDir as $dirItem) {
$baseDir = $this->modulesDir . $dirItem;
$destDir = root_path() . $dirItem;
if (!is_dir($baseDir)) continue;
foreach (
new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($baseDir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
) as $item
) {
$destDirItem = Filesystem::fsFit($destDir . DIRECTORY_SEPARATOR . str_replace($baseDir, '', $item->getPathname()));
if ($item->isDir()) {
Filesystem::mkdir($destDirItem);
} elseif (!in_array(str_replace(root_path(), '', $destDirItem), $discardFiles)) {
Filesystem::mkdir(dirname($destDirItem));
copy($item->getPathname(), $destDirItem);
}
}
if (config('buildadmin.module_pure_install', false)) {
Filesystem::delDir($baseDir);
}
}
return true;
}
/**
* 依赖升级处理
* @throws Throwable
*/
public function dependUpdateHandle(): void
{
$info = $this->getInfo();
if (($info['state'] ?? 0) == self::DEPENDENT_WAIT_INSTALL) {
$waitInstall = [];
if (isset($info['composer_dependent_wait_install'])) {
$waitInstall[] = 'composer_dependent_wait_install';
}
if (isset($info['npm_dependent_wait_install'])) {
$waitInstall[] = 'npm_dependent_wait_install';
}
if (isset($info['nuxt_npm_dependent_wait_install'])) {
$waitInstall[] = 'nuxt_npm_dependent_wait_install';
}
if ($waitInstall) {
throw new Exception('dependent wait install', -2, [
'uid' => $this->uid,
'state' => self::DEPENDENT_WAIT_INSTALL,
'wait_install' => $waitInstall,
]);
} else {
$this->setInfo(['state' => self::INSTALLED]);
}
}
}
/**
* 依赖安装完成标记
* @throws Throwable
*/
public function dependentInstallComplete(string $type): void
{
$info = $this->getInfo();
if (($info['state'] ?? 0) == self::DEPENDENT_WAIT_INSTALL) {
if ($type == 'npm') {
unset($info['npm_dependent_wait_install']);
}
if ($type == 'nuxt_npm') {
unset($info['nuxt_npm_dependent_wait_install']);
}
if ($type == 'composer') {
unset($info['composer_dependent_wait_install']);
}
if ($type == 'all') {
unset($info['npm_dependent_wait_install'], $info['composer_dependent_wait_install'], $info['nuxt_npm_dependent_wait_install']);
}
if (!isset($info['npm_dependent_wait_install']) && !isset($info['composer_dependent_wait_install']) && !isset($info['nuxt_npm_dependent_wait_install'])) {
$info['state'] = self::INSTALLED;
}
$this->setInfo([], $info);
}
}
public function disableDependCheck(): array
{
$depend = Server::getDepend($this->modulesDir);
if (!$depend) return [];
$serverDep = new Depends(root_path() . 'composer.json', 'composer');
$webDep = new Depends(root_path() . 'web' . DIRECTORY_SEPARATOR . 'package.json');
$webNuxtDep = new Depends(root_path() . 'web-nuxt' . DIRECTORY_SEPARATOR . 'package.json');
foreach ($depend as $key => $depends) {
$dev = stripos($key, 'dev') !== false;
if ($key == 'require' || $key == 'require-dev') {
foreach ($depends as $dependKey => $dependItem) {
if (!$serverDep->hasDepend($dependKey, $dev)) {
unset($depends[$dependKey]);
}
}
$depend[$key] = $depends;
} elseif ($key == 'dependencies' || $key == 'devDependencies') {
foreach ($depends as $dependKey => $dependItem) {
if (!$webDep->hasDepend($dependKey, $dev)) {
unset($depends[$dependKey]);
}
}
$depend[$key] = $depends;
} elseif ($key == 'nuxtDependencies' || $key == 'nuxtDevDependencies') {
foreach ($depends as $dependKey => $dependItem) {
if (!$webNuxtDep->hasDepend($dependKey, $dev)) {
unset($depends[$dependKey]);
}
}
$depend[$key] = $depends;
}
}
return $depend;
}
/**
* 检查包是否完整
* @throws Throwable
*/
public function checkPackage(): bool
{
if (!is_dir($this->modulesDir)) {
throw new Exception('Module package file does not exist');
}
$info = $this->getInfo();
$infoKeys = ['uid', 'title', 'intro', 'author', 'version', 'state'];
foreach ($infoKeys as $value) {
if (!array_key_exists($value, $info)) {
Filesystem::delDir($this->modulesDir);
throw new Exception('Basic configuration of the Module is incomplete');
}
}
return true;
}
public function getInfo(): array
{
return Server::getIni($this->modulesDir);
}
/**
* @throws Throwable
*/
public function setInfo(array $kv = [], array $arr = []): bool
{
if ($kv) {
$info = $this->getInfo();
foreach ($kv as $k => $v) {
$info[$k] = $v;
}
return Server::setIni($this->modulesDir, $info);
}
if ($arr) {
return Server::setIni($this->modulesDir, $arr);
}
throw new Exception('Parameter error');
}
public static function isEmptyArray($arr): bool
{
foreach ($arr as $item) {
if (is_array($item)) {
if (!self::isEmptyArray($item)) return false;
} elseif ($item) {
return false;
}
}
return true;
}
public function setModuleUid(string $uid): static
{
$this->uid = $uid;
$this->modulesDir = $this->installDir . $uid . DIRECTORY_SEPARATOR;
return $this;
}
}

View File

@@ -1,551 +0,0 @@
<?php
declare(strict_types=1);
namespace app\admin\library\module;
use Throwable;
use ba\Depends;
use ba\Exception;
use ba\Filesystem;
use support\think\Db;
use FilesystemIterator;
use RecursiveIteratorIterator;
use RecursiveDirectoryIterator;
use think\db\exception\PDOException;
use app\admin\library\crud\Helper;
use GuzzleHttp\Exception\TransferException;
/**
* 模块服务类Webman 迁移版)
*/
class Server
{
private static string $apiBaseUrl = '/api/v7.store/';
/**
* 下载
* @throws Throwable
*/
public static function download(string $uid, string $dir, array $extend = []): string
{
$tmpFile = $dir . $uid . ".zip";
try {
$client = get_ba_client();
$response = $client->get(self::$apiBaseUrl . 'download', ['query' => array_merge(['uid' => $uid, 'server' => 1], $extend)]);
$body = $response->getBody();
$content = $body->getContents();
if ($content == '' || stripos($content, '<title>系统发生错误</title>') !== false) {
throw new Exception('package download failed', 0);
}
if (str_starts_with($content, '{')) {
$json = (array)json_decode($content, true);
throw new Exception($json['msg'], $json['code'], $json['data'] ?? []);
}
} catch (TransferException $e) {
throw new Exception('package download failed', 0, ['msg' => $e->getMessage()]);
}
if ($write = fopen($tmpFile, 'w')) {
fwrite($write, $content);
fclose($write);
return $tmpFile;
}
throw new Exception("No permission to write temporary files");
}
/**
* 安装预检
* @throws Throwable
*/
public static function installPreCheck(array $query = []): bool
{
try {
$client = get_ba_client();
$response = $client->get(self::$apiBaseUrl . 'preCheck', ['query' => $query]);
$body = $response->getBody();
$statusCode = $response->getStatusCode();
$content = $body->getContents();
if ($content == '' || stripos($content, '<title>系统发生错误</title>') !== false || $statusCode != 200) {
return true;
}
if (str_starts_with($content, '{')) {
$json = json_decode($content, true);
if ($json && $json['code'] == 0) {
throw new Exception($json['msg'], $json['code'], $json['data'] ?? []);
}
}
} catch (TransferException $e) {
throw new Exception('package check failed', 0, ['msg' => $e->getMessage()]);
}
return true;
}
public static function getConfig(string $dir, $key = ''): array
{
$configFile = $dir . 'config.json';
if (!is_dir($dir) || !is_file($configFile)) {
return [];
}
$configContent = @file_get_contents($configFile);
$configContent = json_decode($configContent, true);
if (!$configContent) {
return [];
}
if ($key) {
return $configContent[$key] ?? [];
}
return $configContent;
}
public static function getDepend(string $dir, string $key = ''): array
{
if ($key) {
return self::getConfig($dir, $key);
}
$configContent = self::getConfig($dir);
$dependKey = ['require', 'require-dev', 'dependencies', 'devDependencies', 'nuxtDependencies', 'nuxtDevDependencies'];
$dependArray = [];
foreach ($dependKey as $item) {
if (array_key_exists($item, $configContent) && $configContent[$item]) {
$dependArray[$item] = $configContent[$item];
}
}
return $dependArray;
}
/**
* 依赖冲突检查
* @throws Throwable
*/
public static function dependConflictCheck(string $dir): array
{
$depend = self::getDepend($dir);
$serverDep = new Depends(root_path() . 'composer.json', 'composer');
$webDep = new Depends(root_path() . 'web' . DIRECTORY_SEPARATOR . 'package.json');
$webNuxtDep = new Depends(root_path() . 'web-nuxt' . DIRECTORY_SEPARATOR . 'package.json');
$sysDepend = [
'require' => $serverDep->getDepends(),
'require-dev' => $serverDep->getDepends(true),
'dependencies' => $webDep->getDepends(),
'devDependencies' => $webDep->getDepends(true),
'nuxtDependencies' => $webNuxtDep->getDepends(),
'nuxtDevDependencies' => $webNuxtDep->getDepends(true),
];
$conflict = [];
foreach ($depend as $key => $item) {
$conflict[$key] = array_uintersect_assoc($item, $sysDepend[$key] ?? [], function ($a, $b) {
return $a == $b ? -1 : 0;
});
}
return $conflict;
}
/**
* 获取模块[冲突]文件列表
*/
public static function getFileList(string $dir, bool $onlyConflict = false): array
{
if (!is_dir($dir)) {
return [];
}
$fileList = [];
$overwriteDir = self::getOverwriteDir();
$moduleFileList = self::getRuntime($dir, 'files');
if ($moduleFileList) {
if ($onlyConflict) {
$excludeFile = ['info.ini'];
foreach ($moduleFileList as $file) {
$path = Filesystem::fsFit(str_replace($dir, '', $file['path']));
$paths = explode(DIRECTORY_SEPARATOR, $path);
$overwriteFile = in_array($paths[0], $overwriteDir) ? root_path() . $path : $dir . $path;
if (is_file($overwriteFile) && !in_array($path, $excludeFile) && (filesize($overwriteFile) != $file['size'] || md5_file($overwriteFile) != $file['md5'])) {
$fileList[] = $path;
}
}
} else {
foreach ($overwriteDir as $item) {
$baseDir = $dir . $item;
foreach ($moduleFileList as $file) {
if (!str_starts_with($file['path'], $baseDir)) continue;
$fileList[] = Filesystem::fsFit(str_replace($dir, '', $file['path']));
}
}
}
return $fileList;
}
foreach ($overwriteDir as $item) {
$baseDir = $dir . $item;
if (!is_dir($baseDir)) {
continue;
}
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($baseDir, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($files as $file) {
if ($file->isFile()) {
$filePath = $file->getPathName();
$path = str_replace($dir, '', $filePath);
$path = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $path);
if ($onlyConflict) {
$overwriteFile = root_path() . $path;
if (is_file($overwriteFile) && (filesize($overwriteFile) != filesize($filePath) || md5_file($overwriteFile) != md5_file($filePath))) {
$fileList[] = $path;
}
} else {
$fileList[] = $path;
}
}
}
}
return $fileList;
}
public static function getOverwriteDir(): array
{
return [
'app',
'config',
'database',
'extend',
'modules',
'public',
'vendor',
'web',
'web-nuxt',
];
}
public static function importSql(string $dir): bool
{
$sqlFile = $dir . 'install.sql';
$tempLine = '';
$prefix = config('thinkorm.connections.mysql.prefix', config('database.connections.mysql.prefix', ''));
if (is_file($sqlFile)) {
$lines = file($sqlFile);
foreach ($lines as $line) {
if (str_starts_with($line, '--') || $line == '' || str_starts_with($line, '/*')) {
continue;
}
$tempLine .= $line;
if (str_ends_with(trim($line), ';')) {
$tempLine = str_ireplace('__PREFIX__', $prefix, $tempLine);
$tempLine = str_ireplace('INSERT INTO ', 'INSERT IGNORE INTO ', $tempLine);
try {
Db::execute($tempLine);
} catch (PDOException) {
// ignore
}
$tempLine = '';
}
}
}
return true;
}
public static function installedList(string $dir): array
{
if (!is_dir($dir)) {
return [];
}
$installedDir = scandir($dir);
$installedList = [];
foreach ($installedDir as $item) {
if ($item === '.' or $item === '..' || is_file($dir . $item)) {
continue;
}
$tempDir = $dir . $item . DIRECTORY_SEPARATOR;
if (!is_dir($tempDir)) {
continue;
}
$info = self::getIni($tempDir);
if (!isset($info['uid'])) {
continue;
}
$installedList[] = $info;
}
return $installedList;
}
public static function getInstalledIds(string $dir): array
{
$installedIds = [];
$installed = self::installedList($dir);
foreach ($installed as $item) {
$installedIds[] = $item['uid'];
}
return $installedIds;
}
public static function getIni(string $dir): array
{
$infoFile = $dir . 'info.ini';
$info = [];
if (is_file($infoFile)) {
$info = parse_ini_file($infoFile, true, INI_SCANNER_TYPED) ?: [];
if (!$info) return [];
}
return $info;
}
/**
* @throws Throwable
*/
public static function setIni(string $dir, array $arr): bool
{
$infoFile = $dir . 'info.ini';
$ini = [];
foreach ($arr as $key => $val) {
if (is_array($val)) {
$ini[] = "[$key]";
foreach ($val as $ikey => $ival) {
$ini[] = "$ikey = $ival";
}
} else {
$ini[] = "$key = $val";
}
}
if (!file_put_contents($infoFile, implode("\n", $ini) . "\n", LOCK_EX)) {
throw new Exception("Configuration file has no write permission");
}
return true;
}
public static function getClass(string $uid, string $type = 'event', ?string $class = null): string
{
$name = parse_name($uid);
if (!is_null($class) && strpos($class, '.')) {
$class = explode('.', $class);
$class[count($class) - 1] = parse_name(end($class), 1);
$class = implode('\\', $class);
} else {
$class = parse_name(is_null($class) ? $name : $class, 1);
}
$namespace = match ($type) {
'controller' => '\\modules\\' . $name . '\\controller\\' . $class,
default => '\\modules\\' . $name . '\\' . $class,
};
return class_exists($namespace) ? $namespace : '';
}
public static function execEvent(string $uid, string $event, array $params = []): void
{
$eventClass = self::getClass($uid);
if (class_exists($eventClass)) {
$handle = new $eventClass();
if (method_exists($eventClass, $event)) {
$handle->$event($params);
}
}
}
public static function analysisWebBootstrap(string $uid, string $dir): array
{
$bootstrapFile = $dir . 'webBootstrap.stub';
if (!file_exists($bootstrapFile)) return [];
$bootstrapContent = file_get_contents($bootstrapFile);
$pregArr = [
'mainTsImport' => '/#main.ts import code start#([\s\S]*?)#main.ts import code end#/i',
'mainTsStart' => '/#main.ts start code start#([\s\S]*?)#main.ts start code end#/i',
'appVueImport' => '/#App.vue import code start#([\s\S]*?)#App.vue import code end#/i',
'appVueOnMounted' => '/#App.vue onMounted code start#([\s\S]*?)#App.vue onMounted code end#/i',
'nuxtAppVueImport' => '/#web-nuxt\/app.vue import code start#([\s\S]*?)#web-nuxt\/app.vue import code end#/i',
'nuxtAppVueStart' => '/#web-nuxt\/app.vue start code start#([\s\S]*?)#web-nuxt\/app.vue start code end#/i',
];
$codeStrArr = [];
foreach ($pregArr as $key => $item) {
preg_match($item, $bootstrapContent, $matches);
if (isset($matches[1]) && $matches[1]) {
$mainImportCodeArr = array_filter(preg_split('/\r\n|\r|\n/', $matches[1]));
if ($mainImportCodeArr) {
$codeStrArr[$key] = "\n";
if (count($mainImportCodeArr) == 1) {
foreach ($mainImportCodeArr as $codeItem) {
$codeStrArr[$key] .= $codeItem . self::buildMarkStr('module-line-mark', $uid, $key);
}
} else {
$codeStrArr[$key] .= self::buildMarkStr('module-multi-line-mark-start', $uid, $key);
foreach ($mainImportCodeArr as $codeItem) {
$codeStrArr[$key] .= $codeItem . "\n";
}
$codeStrArr[$key] .= self::buildMarkStr('module-multi-line-mark-end', $uid, $key);
}
}
}
unset($matches);
}
return $codeStrArr;
}
public static function installWebBootstrap(string $uid, string $dir): void
{
$bootstrapCode = self::analysisWebBootstrap($uid, $dir);
if (!$bootstrapCode) {
return;
}
$webPath = root_path() . 'web' . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR;
$webNuxtPath = root_path() . 'web-nuxt' . DIRECTORY_SEPARATOR;
$filePaths = [
'mainTsImport' => $webPath . 'main.ts',
'mainTsStart' => $webPath . 'main.ts',
'appVueImport' => $webPath . 'App.vue',
'appVueOnMounted' => $webPath . 'App.vue',
'nuxtAppVueImport' => $webNuxtPath . 'app.vue',
'nuxtAppVueStart' => $webNuxtPath . 'app.vue',
];
$marks = [
'mainTsImport' => self::buildMarkStr('import-root-mark'),
'mainTsStart' => self::buildMarkStr('start-root-mark'),
'appVueImport' => self::buildMarkStr('import-root-mark'),
'appVueOnMounted' => self::buildMarkStr('onMounted-root-mark'),
'nuxtAppVueImport' => self::buildMarkStr('import-root-mark'),
'nuxtAppVueStart' => self::buildMarkStr('start-root-mark'),
];
foreach ($bootstrapCode as $key => $item) {
if ($item && isset($marks[$key]) && isset($filePaths[$key]) && is_file($filePaths[$key])) {
$content = file_get_contents($filePaths[$key]);
$markPos = stripos($content, $marks[$key]);
if ($markPos && strripos($content, self::buildMarkStr('module-line-mark', $uid, $key)) === false && strripos($content, self::buildMarkStr('module-multi-line-mark-start', $uid, $key)) === false) {
$content = substr_replace($content, $item, $markPos + strlen($marks[$key]), 0);
file_put_contents($filePaths[$key], $content);
}
}
}
}
public static function uninstallWebBootstrap(string $uid): void
{
$webPath = root_path() . 'web' . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR;
$webNuxtPath = root_path() . 'web-nuxt' . DIRECTORY_SEPARATOR;
$filePaths = [
'mainTsImport' => $webPath . 'main.ts',
'mainTsStart' => $webPath . 'main.ts',
'appVueImport' => $webPath . 'App.vue',
'appVueOnMounted' => $webPath . 'App.vue',
'nuxtAppVueImport' => $webNuxtPath . 'app.vue',
'nuxtAppVueStart' => $webNuxtPath . 'app.vue',
];
$marksKey = [
'mainTsImport',
'mainTsStart',
'appVueImport',
'appVueOnMounted',
'nuxtAppVueImport',
'nuxtAppVueStart',
];
foreach ($marksKey as $item) {
if (!isset($filePaths[$item]) || !is_file($filePaths[$item])) {
continue;
}
$content = file_get_contents($filePaths[$item]);
$moduleLineMark = self::buildMarkStr('module-line-mark', $uid, $item);
$moduleMultiLineMarkStart = self::buildMarkStr('module-multi-line-mark-start', $uid, $item);
$moduleMultiLineMarkEnd = self::buildMarkStr('module-multi-line-mark-end', $uid, $item);
$moduleLineMarkPos = strripos($content, $moduleLineMark);
if ($moduleLineMarkPos !== false) {
$delStartTemp = explode($moduleLineMark, $content);
$delStartPos = strripos(rtrim($delStartTemp[0], "\n"), "\n");
$delEndPos = stripos($content, "\n", $moduleLineMarkPos);
$content = substr_replace($content, '', $delStartPos, $delEndPos - $delStartPos);
}
$moduleMultiLineMarkStartPos = stripos($content, $moduleMultiLineMarkStart);
if ($moduleMultiLineMarkStartPos !== false) {
$moduleMultiLineMarkStartPos--;
$moduleMultiLineMarkEndPos = stripos($content, $moduleMultiLineMarkEnd);
$delLang = ($moduleMultiLineMarkEndPos + strlen($moduleMultiLineMarkEnd)) - $moduleMultiLineMarkStartPos;
$content = substr_replace($content, '', $moduleMultiLineMarkStartPos, $delLang);
}
if (($moduleLineMarkPos ?? false) !== false || ($moduleMultiLineMarkStartPos ?? false) !== false) {
file_put_contents($filePaths[$item], $content);
}
}
}
public static function buildMarkStr(string $type, string $uid = '', string $extend = ''): string
{
$nonTabKeys = ['mti', 'avi', 'navi', 'navs'];
$extend = match ($extend) {
'mainTsImport' => 'mti',
'mainTsStart' => 'mts',
'appVueImport' => 'avi',
'appVueOnMounted' => 'avo',
'nuxtAppVueImport' => 'navi',
'nuxtAppVueStart' => 'navs',
default => '',
};
return match ($type) {
'import-root-mark' => '// modules import mark, Please do not remove.',
'start-root-mark' => '// modules start mark, Please do not remove.',
'onMounted-root-mark' => '// Modules onMounted mark, Please do not remove.',
'module-line-mark' => ' // Code from module \'' . $uid . "'" . ($extend ? "($extend)" : ''),
'module-multi-line-mark-start' => (in_array($extend, $nonTabKeys) ? '' : Helper::tab()) . "// Code from module '$uid' start" . ($extend ? "($extend)" : '') . "\n",
'module-multi-line-mark-end' => (in_array($extend, $nonTabKeys) ? '' : Helper::tab()) . "// Code from module '$uid' end",
default => '',
};
}
public static function getNuxtVersion(): mixed
{
$nuxtPackageJsonPath = Filesystem::fsFit(root_path() . 'web-nuxt/package.json');
if (is_file($nuxtPackageJsonPath)) {
$nuxtPackageJson = file_get_contents($nuxtPackageJsonPath);
$nuxtPackageJson = json_decode($nuxtPackageJson, true);
if ($nuxtPackageJson && isset($nuxtPackageJson['version'])) {
return $nuxtPackageJson['version'];
}
}
return false;
}
public static function createRuntime(string $dir): void
{
$runtimeFilePath = $dir . '.runtime';
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir), RecursiveIteratorIterator::LEAVES_ONLY
);
$filePaths = [];
foreach ($files as $file) {
if (!$file->isDir()) {
$pathName = $file->getPathName();
if ($pathName == $runtimeFilePath) continue;
$filePaths[] = [
'path' => Filesystem::fsFit($pathName),
'size' => filesize($pathName),
'md5' => md5_file($pathName),
];
}
}
file_put_contents($runtimeFilePath, json_encode([
'files' => $filePaths,
'pure' => config('buildadmin.module_pure_install', false),
]));
}
public static function getRuntime(string $dir, string $key = ''): mixed
{
$runtimeFilePath = $dir . '.runtime';
$runtimeContent = @file_get_contents($runtimeFilePath);
$runtimeContentArr = json_decode($runtimeContent, true);
if (!$runtimeContentArr) return [];
if ($key) {
return $runtimeContentArr[$key] ?? [];
}
return $runtimeContentArr;
}
}

View File

@@ -21,8 +21,8 @@ use support\think\Db;
* @property string $password 密码密文
* @property string $salt 密码盐
* @property string $status 状态:enable=启用,disable=禁用
* @property string $agent_id 代理 IDAPI 鉴权)
* @property string $agent_api_secret Agent API 密钥
* @property string $totp_secret TOTP密钥(加密)
* @property int $totp_bind_time TOTP绑定时间
*/
class Admin extends Model
{
@@ -45,8 +45,18 @@ class Admin extends Model
protected array $append = [
'group_arr',
'group_name_arr',
'totp_bound',
];
protected array $hidden = [
'totp_secret',
];
public function getTotpBoundAttr($value, $row): bool
{
return \app\common\library\AdminTotp::isBound($row['totp_secret'] ?? '');
}
public function getGroupArrAttr($value, $row): array
{
return Db::name('admin_group_access')

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace app\admin\model;
use app\common\model\MallUserAsset;
use think\model\relation\BelongsTo;
class MallPointsLog extends \app\common\model\MallPointsLog
{
public static function onBeforeInsert($model): void
{
$userAssetId = filter_var($model->user_asset_id, FILTER_VALIDATE_INT);
if ($userAssetId === false || $userAssetId <= 0) {
throw new \InvalidArgumentException(__('User asset not found'));
}
$scoreValue = filter_var($model->score_value, FILTER_VALIDATE_INT);
if ($scoreValue === false || $scoreValue === 0) {
throw new \InvalidArgumentException(__('Points adjustment must be a non-zero integer'));
}
$userAsset = MallUserAsset::where('id', $userAssetId)->lock(true)->find();
if (!$userAsset) {
throw new \RuntimeException(__('User asset not found'));
}
$before = $userAsset->available_points;
$after = $before + $scoreValue;
if ($after < 0) {
throw new \RuntimeException(__('Insufficient available points'));
}
$model->user_asset_id = $userAssetId;
$model->score_value = $scoreValue;
$model->before = $before;
$model->after = $after;
$pointsAbs = abs($scoreValue);
$memo = trim(strval($model->getData('memo') ?? ''));
if ($memo === '') {
$memo = $scoreValue > 0
? sprintf(__('Administrator added %s points'), $pointsAbs)
: sprintf(__('Administrator deducted %s points'), $pointsAbs);
} else {
if (function_exists('mb_substr')) {
$memo = mb_substr($memo, 0, 255);
} else {
$memo = substr($memo, 0, 255);
}
}
$model->memo = $memo;
$userAsset->available_points = $after;
$userAsset->save();
}
public static function onBeforeDelete(): bool
{
return false;
}
public function userAsset(): BelongsTo
{
return $this->belongsTo(MallUserAsset::class, 'user_asset_id');
}
public function admin(): BelongsTo
{
return $this->belongsTo(Admin::class, 'admin_id');
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace app\admin\validate;
use think\Validate;
class MallPointsLog extends Validate
{
protected $failException = true;
protected $rule = [
'user_asset_id' => 'require|integer|gt:0',
'score_value' => 'require|integer|notIn:0',
];
protected $scene = [
'add' => ['user_asset_id', 'score_value'],
];
public function __construct()
{
$this->field = [
'user_asset_id' => __('User asset'),
'score_value' => __('Points adjustment'),
];
parent::__construct();
}
}

View File

@@ -58,7 +58,7 @@ class Index extends Frontend
'siteName' => get_sys_config('site_name'),
'version' => get_sys_config('version'),
'cdnUrl' => full_url(),
'upload' => keys_to_camel_case(get_upload_config($request), ['max_size', 'save_name', 'allowed_suffixes', 'allowed_mime_types']),
'upload' => keys_to_camel_case(get_upload_config($request), ['max_size', 'save_name', 'allowed_suffixes', 'allowed_mime_types', 'forbidden_suffixes']),
'recordNumber' => get_sys_config('record_number'),
'cdnUrlParams' => config('buildadmin.cdn_url_params'),
],

View File

@@ -1,768 +0,0 @@
<?php
declare(strict_types=1);
namespace app\api\controller;
use Throwable;
use ba\Random;
use ba\Version;
use ba\Terminal;
use ba\Filesystem;
use app\common\controller\Api;
use app\admin\model\Admin as AdminModel;
use app\admin\model\User as UserModel;
use support\Response;
use Webman\Http\Request;
use Phinx\Config\Config as PhinxConfig;
use Phinx\Migration\Manager as PhinxManager;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
/**
* 安装控制器
*/
class Install extends Api
{
public const X64 = 'x64';
public const X86 = 'x86';
protected bool $useSystemSettings = false;
/**
* 环境检查状态
*/
static string $ok = 'ok';
static string $fail = 'fail';
static string $warn = 'warn';
/**
* 安装锁文件名称
*/
static string $lockFileName = 'install.lock';
/**
* 配置文件
*/
static string $dbConfigFileName = 'thinkorm.php';
static string $buildConfigFileName = 'buildadmin.php';
/**
* 自动构建的前端文件的 outDir 相对于根目录
*/
static string $distDir = 'web' . DIRECTORY_SEPARATOR . 'dist';
/**
* 需要的依赖版本
*/
static array $needDependentVersion = [
'php' => '8.2.0',
'npm' => '9.8.1',
'cnpm' => '7.1.0',
'node' => '20.14.0',
'yarn' => '1.2.0',
'pnpm' => '6.32.13',
];
/**
* 安装完成标记
*/
static string $InstallationCompletionMark = 'install-end';
/**
* 命令执行窗口exec 为 SSE 长连接,不会返回)
* @throws Throwable
*/
public function terminal(Request $request): Response
{
$this->setRequest($request);
if ($this->isInstallComplete()) {
return $this->error(__('The system has completed installation. If you need to reinstall, please delete the %file% file first', ['%file%' => 'public/' . self::$lockFileName]));
}
(new Terminal())->exec(false);
return $this->success(); // unreachable: exec() blocks with SSE stream
}
public function changePackageManager(Request $request): Response
{
$this->setRequest($request);
if ($this->isInstallComplete()) {
return $this->error(__('The system has completed installation. If you need to reinstall, please delete the %file% file first', ['%file%' => 'public/' . self::$lockFileName]));
}
$newPackageManager = $request->post('manager', config('terminal.npm_package_manager'));
if (Terminal::changeTerminalConfig()) {
return $this->success('', [
'manager' => $newPackageManager
]);
}
return $this->error(__('Failed to switch package manager. Please modify the configuration file manually:%s', ['config/terminal.php']));
}
/**
* 环境基础检查
*/
public function envBaseCheck(Request $request): Response
{
$this->setRequest($request);
if ($this->isInstallComplete()) {
return $this->error(__('The system has completed installation. If you need to reinstall, please delete the %file% file first', ['%file%' => 'public/' . self::$lockFileName]), []);
}
if (($_ENV['DATABASE_TYPE'] ?? getenv('DATABASE_TYPE'))) {
return $this->error(__('The .env file with database configuration was detected. Please clean up and try again!'));
}
// php版本-start
$phpVersion = phpversion();
$phpBit = PHP_INT_SIZE == 8 ? self::X64 : self::X86;
$phpVersionCompare = Version::compare(self::$needDependentVersion['php'], $phpVersion);
if (!$phpVersionCompare) {
$phpVersionLink = [
[
'name' => __('need') . ' >= ' . self::$needDependentVersion['php'],
'type' => 'text'
],
[
'name' => __('How to solve?'),
'title' => __('Click to see how to solve it'),
'type' => 'faq',
'url' => 'https://doc.buildadmin.com/guide/install/preparePHP.html'
]
];
} elseif ($phpBit != self::X64) {
$phpVersionLink = [
[
'name' => __('need') . ' x64 PHP',
'type' => 'text'
],
[
'name' => __('How to solve?'),
'title' => __('Click to see how to solve it'),
'type' => 'faq',
'url' => 'https://doc.buildadmin.com/guide/install/preparePHP.html'
]
];
}
// php版本-end
// 配置文件-start分别检测目录和文件便于定位问题
$configDir = rtrim(config_path(), '/\\');
$dbConfigFile = $configDir . DIRECTORY_SEPARATOR . self::$dbConfigFileName;
$configDirWritable = Filesystem::pathIsWritable($configDir);
$dbConfigWritable = Filesystem::pathIsWritable($dbConfigFile);
$configIsWritable = $configDirWritable && $dbConfigWritable;
if (!$configIsWritable) {
$configIsWritableLink = [
[
'name' => __('View reason'),
'title' => __('Click to view the reason'),
'type' => 'faq',
'url' => 'https://doc.buildadmin.com/guide/install/dirNoPermission.html'
]
];
}
// 配置文件-end
// public-start
$publicIsWritable = Filesystem::pathIsWritable(public_path());
if (!$publicIsWritable) {
$publicIsWritableLink = [
[
'name' => __('View reason'),
'title' => __('Click to view the reason'),
'type' => 'faq',
'url' => 'https://doc.buildadmin.com/guide/install/dirNoPermission.html'
]
];
}
// public-end
// PDO-start
$phpPdo = extension_loaded("PDO") && extension_loaded('pdo_mysql');
if (!$phpPdo) {
$phpPdoLink = [
[
'name' => __('PDO extensions need to be installed'),
'type' => 'text'
],
[
'name' => __('How to solve?'),
'title' => __('Click to see how to solve it'),
'type' => 'faq',
'url' => 'https://doc.buildadmin.com/guide/install/missingExtension.html'
]
];
}
// PDO-end
// GD2和freeType-start
$phpGd2 = extension_loaded('gd') && function_exists('imagettftext');
if (!$phpGd2) {
$phpGd2Link = [
[
'name' => __('The gd extension and freeType library need to be installed'),
'type' => 'text'
],
[
'name' => __('How to solve?'),
'title' => __('Click to see how to solve it'),
'type' => 'faq',
'url' => 'https://doc.buildadmin.com/guide/install/gdFail.html'
]
];
}
// GD2和freeType-end
// proc_open
$phpProc = function_exists('proc_open') && function_exists('proc_close') && function_exists('proc_get_status');
if (!$phpProc) {
$phpProcLink = [
[
'name' => __('View reason'),
'title' => __('proc_open or proc_close functions in PHP Ini is disabled'),
'type' => 'faq',
'url' => 'https://doc.buildadmin.com/guide/install/disablement.html'
],
[
'name' => __('How to modify'),
'title' => __('Click to view how to modify'),
'type' => 'faq',
'url' => 'https://doc.buildadmin.com/guide/install/disablement.html'
],
[
'name' => __('Security assurance?'),
'title' => __('Using the installation service correctly will not cause any potential security problems. Click to view the details'),
'type' => 'faq',
'url' => 'https://doc.buildadmin.com/guide/install/senior.html'
],
];
}
// proc_open-end
return $this->success('', [
'php_version' => [
'describe' => $phpVersion . " ($phpBit)",
'state' => $phpVersionCompare && $phpBit == self::X64 ? self::$ok : self::$fail,
'link' => $phpVersionLink ?? [],
],
'config_is_writable' => [
'describe' => $configIsWritable
? self::writableStateDescribe(true)
: (self::writableStateDescribe(false) . ' [' . $configDir . ']'),
'state' => $configIsWritable ? self::$ok : self::$fail,
'link' => $configIsWritableLink ?? []
],
'public_is_writable' => [
'describe' => self::writableStateDescribe($publicIsWritable),
'state' => $publicIsWritable ? self::$ok : self::$fail,
'link' => $publicIsWritableLink ?? []
],
'php_pdo' => [
'describe' => $phpPdo ? __('already installed') : __('Not installed'),
'state' => $phpPdo ? self::$ok : self::$fail,
'link' => $phpPdoLink ?? []
],
'php_gd2' => [
'describe' => $phpGd2 ? __('already installed') : __('Not installed'),
'state' => $phpGd2 ? self::$ok : self::$fail,
'link' => $phpGd2Link ?? []
],
'php_proc' => [
'describe' => $phpProc ? __('Allow execution') : __('disabled'),
'state' => $phpProc ? self::$ok : self::$warn,
'link' => $phpProcLink ?? []
],
]);
}
/**
* npm环境检查
*/
public function envNpmCheck(Request $request): Response
{
$this->setRequest($request);
if ($this->isInstallComplete()) {
return $this->error('', [], 2);
}
$packageManager = $request->post('manager', 'none');
// npm
$npmVersion = Version::getVersion('npm');
$npmVersionCompare = Version::compare(self::$needDependentVersion['npm'], $npmVersion);
if (!$npmVersionCompare || !$npmVersion) {
$npmVersionLink = [
[
'name' => __('need') . ' >= ' . self::$needDependentVersion['npm'],
'type' => 'text'
],
[
'name' => __('How to solve?'),
'title' => __('Click to see how to solve it'),
'type' => 'faq',
'url' => 'https://doc.buildadmin.com/guide/install/prepareNpm.html'
]
];
}
// 包管理器
$pmVersionLink = [];
$pmVersion = __('nothing');
$pmVersionCompare = false;
if (in_array($packageManager, ['npm', 'cnpm', 'pnpm', 'yarn'])) {
$pmVersion = Version::getVersion($packageManager);
$pmVersionCompare = Version::compare(self::$needDependentVersion[$packageManager], $pmVersion);
if (!$pmVersion) {
$pmVersionLink[] = [
'name' => __('need') . ' >= ' . self::$needDependentVersion[$packageManager],
'type' => 'text'
];
if ($npmVersionCompare) {
$pmVersionLink[] = [
'name' => __('Click Install %s', [$packageManager]),
'title' => '',
'type' => 'install-package-manager'
];
} else {
$pmVersionLink[] = [
'name' => __('Please install NPM first'),
'type' => 'text'
];
}
} elseif (!$pmVersionCompare) {
$pmVersionLink[] = [
'name' => __('need') . ' >= ' . self::$needDependentVersion[$packageManager],
'type' => 'text'
];
$pmVersionLink[] = [
'name' => __('Please upgrade %s version', [$packageManager]),
'type' => 'text'
];
}
} elseif ($packageManager == 'ni') {
$pmVersionCompare = true;
}
// nodejs
$nodejsVersion = Version::getVersion('node');
$nodejsVersionCompare = Version::compare(self::$needDependentVersion['node'], $nodejsVersion);
if (!$nodejsVersionCompare || !$nodejsVersion) {
$nodejsVersionLink = [
[
'name' => __('need') . ' >= ' . self::$needDependentVersion['node'],
'type' => 'text'
],
[
'name' => __('How to solve?'),
'title' => __('Click to see how to solve it'),
'type' => 'faq',
'url' => 'https://doc.buildadmin.com/guide/install/prepareNodeJs.html'
]
];
}
return $this->success('', [
'npm_version' => [
'describe' => $npmVersion ?: __('Acquisition failed'),
'state' => $npmVersionCompare ? self::$ok : self::$warn,
'link' => $npmVersionLink ?? [],
],
'nodejs_version' => [
'describe' => $nodejsVersion ?: __('Acquisition failed'),
'state' => $nodejsVersionCompare ? self::$ok : self::$warn,
'link' => $nodejsVersionLink ?? []
],
'npm_package_manager' => [
'describe' => $pmVersion ?: __('Acquisition failed'),
'state' => $pmVersionCompare ? self::$ok : self::$warn,
'link' => $pmVersionLink ?? [],
]
]);
}
/**
* 测试数据库连接
*/
public function testDatabase(Request $request): Response
{
$this->setRequest($request);
$database = [
'hostname' => $request->post('hostname'),
'username' => $request->post('username'),
'password' => $request->post('password'),
'hostport' => $request->post('hostport'),
'database' => '',
];
$conn = $this->connectDb($database);
if ($conn['code'] == 0) {
return $this->error($conn['msg']);
}
return $this->success('', [
'databases' => $conn['databases']
]);
}
/**
* 系统基础配置
* post请求=开始安装
*/
public function baseConfig(Request $request): Response
{
$this->setRequest($request);
if ($this->isInstallComplete()) {
return $this->error(__('The system has completed installation. If you need to reinstall, please delete the %file% file first', ['%file%' => 'public/' . self::$lockFileName]));
}
$envOk = $this->commandExecutionCheck();
$rootPath = str_replace('\\', '/', root_path());
$migrateCommand = 'php vendor/bin/phinx migrate';
if ($request->isGet()) {
return $this->success('', [
'rootPath' => $rootPath,
'executionWebCommand' => $envOk,
'migrateCommand' => $migrateCommand,
]);
}
$connectData = $databaseParam = $request->only(['hostname', 'username', 'password', 'hostport', 'database', 'prefix']);
// 数据库配置测试
$connectData['database'] = '';
$connect = $this->connectDb($connectData, true);
if ($connect['code'] == 0) {
return $this->error($connect['msg']);
}
// 建立数据库
if (!in_array($databaseParam['database'], $connect['databases'])) {
$sql = "CREATE DATABASE IF NOT EXISTS `{$databaseParam['database']}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci";
$connect['pdo']->exec($sql);
}
// 写入数据库配置文件thinkorm.php 使用 $env('database.xxx', 'default') 格式)
$dbConfigFile = config_path(self::$dbConfigFileName);
$dbConfigContent = @file_get_contents($dbConfigFile);
if ($dbConfigContent === false || $dbConfigContent === '') {
return $this->error(__('File has no write permission:%s', ['config/' . self::$dbConfigFileName]));
}
$callback = function ($matches) use ($databaseParam) {
$key = $matches[1];
$value = (string) ($databaseParam[$key] ?? '');
return "\$env('database.{$key}', '" . addslashes($value) . "')";
};
$dbConfigText = preg_replace_callback("/\\\$env\('database\.(hostname|database|username|password|hostport|prefix)',\s*'[^']*'\)/", $callback, $dbConfigContent);
$result = @file_put_contents($dbConfigFile, $dbConfigText);
if (!$result) {
return $this->error(__('File has no write permission:%s', ['config/' . self::$dbConfigFileName]));
}
// 写入 .env 和 .env-example仅使用 Dotenv 可解析的 DATABASE_XXX 格式,避免 [DATABASE] 导致解析失败)
$databaseBlock = "\n# Database\n"
. 'DATABASE_TYPE = mysql' . "\n"
. 'DATABASE_HOSTNAME = ' . $databaseParam['hostname'] . "\n"
. 'DATABASE_DATABASE = ' . $databaseParam['database'] . "\n"
. 'DATABASE_USERNAME = ' . $databaseParam['username'] . "\n"
. 'DATABASE_PASSWORD = ' . $databaseParam['password'] . "\n"
. 'DATABASE_HOSTPORT = ' . $databaseParam['hostport'] . "\n"
. 'DATABASE_CHARSET = utf8mb4' . "\n"
. 'DATABASE_PREFIX = ' . ($databaseParam['prefix'] ?? '') . "\n";
foreach (['.env', '.env-example'] as $envName) {
$envFile = root_path() . $envName;
$envFileContent = is_file($envFile) ? @file_get_contents($envFile) : '';
if ($envFileContent !== false) {
$cutPos = strlen($envFileContent);
foreach (['[DATABASE]', "\n# Database\n", "\n# 数据库", "\nDATABASE_DRIVER", "\nDATABASE_TYPE"] as $marker) {
$pos = stripos($envFileContent, $marker);
if ($pos !== false && $pos < $cutPos) {
$cutPos = $pos;
}
}
$envFileContent = rtrim(substr($envFileContent, 0, $cutPos)) . $databaseBlock;
$result = @file_put_contents($envFile, $envFileContent);
if (!$result && is_file($envFile)) {
return $this->error(__('File has no write permission:%s', ['%s' => $envName]));
}
}
}
// 设置新的Token随机密钥key
$oldTokenKey = config('buildadmin.token.key');
$newTokenKey = Random::build('alnum', 32);
$buildConfigFile = config_path(self::$buildConfigFileName);
$buildConfigContent = @file_get_contents($buildConfigFile);
if ($buildConfigContent === false || $buildConfigContent === '') {
return $this->error(__('File has no write permission:%s', ['config/' . self::$buildConfigFileName]));
}
$buildConfigContent = preg_replace("/'key'(\s+)=>(\s+)'$oldTokenKey'/", "'key'\$1=>\$2'$newTokenKey'", $buildConfigContent);
$result = @file_put_contents($buildConfigFile, $buildConfigContent);
if (!$result) {
return $this->error(__('File has no write permission:%s', ['config/' . self::$buildConfigFileName]));
}
// 建立安装锁文件
$result = @file_put_contents(public_path(self::$lockFileName), date('Y-m-d H:i:s'));
if (!$result) {
return $this->error(__('File has no write permission:%s', ['public/' . self::$lockFileName]));
}
// 自动执行数据库迁移(无需手动运行 phinx 命令)
$migrateResult = $this->runPhinxMigrate($databaseParam);
if ($migrateResult !== true) {
return $this->error($migrateResult);
}
return $this->success('', [
'rootPath' => $rootPath,
'executionWebCommand' => $envOk,
'migrateCommand' => $migrateCommand,
'migrationCompleted' => true,
]);
}
/**
* 程序化执行 Phinx 数据库迁移
* @param array $databaseParam 数据库连接参数
* @return true|string 成功返回 true失败返回错误信息
*/
private function runPhinxMigrate(array $databaseParam): true|string
{
try {
$baseDir = root_path();
$phinxConfigPath = $baseDir . 'phinx.php';
if (!is_file($phinxConfigPath)) {
return __('Failed to install SQL execution:%msg%', ['%msg%' => 'phinx.php not found']);
}
// 临时设置环境变量,供 phinx 读取数据库配置
$_ENV['DATABASE_HOSTNAME'] = $databaseParam['hostname'] ?? '127.0.0.1';
$_ENV['DATABASE_DATABASE'] = $databaseParam['database'] ?? '';
$_ENV['DATABASE_USERNAME'] = $databaseParam['username'] ?? 'root';
$_ENV['DATABASE_PASSWORD'] = $databaseParam['password'] ?? '';
$_ENV['DATABASE_HOSTPORT'] = $databaseParam['hostport'] ?? '3306';
$_ENV['DATABASE_PREFIX'] = $databaseParam['prefix'] ?? '';
putenv('DATABASE_HOSTNAME=' . $_ENV['DATABASE_HOSTNAME']);
putenv('DATABASE_DATABASE=' . $_ENV['DATABASE_DATABASE']);
putenv('DATABASE_USERNAME=' . $_ENV['DATABASE_USERNAME']);
putenv('DATABASE_PASSWORD=' . $_ENV['DATABASE_PASSWORD']);
putenv('DATABASE_HOSTPORT=' . $_ENV['DATABASE_HOSTPORT']);
putenv('DATABASE_PREFIX=' . $_ENV['DATABASE_PREFIX']);
$config = PhinxConfig::fromPhp($phinxConfigPath);
$input = new ArrayInput([]);
$output = new NullOutput();
$manager = new PhinxManager($config, $input, $output);
$environment = $config->getDefaultEnvironment();
$manager->migrate($environment);
return true;
} catch (Throwable $e) {
$msg = $e->getMessage();
if ($e->getPrevious()) {
$msg .= ' | ' . $e->getPrevious()->getMessage();
}
return __('Failed to install SQL execution:%msg%', ['%msg%' => $msg]);
}
}
protected function isInstallComplete(): bool
{
if (is_file(public_path(self::$lockFileName))) {
$contents = @file_get_contents(public_path(self::$lockFileName));
if ($contents == self::$InstallationCompletionMark) {
return true;
}
}
return false;
}
/**
* 标记命令执行完毕
* @throws Throwable
*/
public function commandExecComplete(Request $request): Response
{
$this->setRequest($request);
if ($this->isInstallComplete()) {
return $this->error(__('The system has completed installation. If you need to reinstall, please delete the %file% file first', ['%file%' => 'public/' . self::$lockFileName]));
}
$param = $request->only(['type', 'adminname', 'adminpassword', 'sitename']);
if ($param['type'] == 'web') {
$result = @file_put_contents(public_path(self::$lockFileName), self::$InstallationCompletionMark);
if (!$result) {
return $this->error(__('File has no write permission:%s', ['public/' . self::$lockFileName]));
}
} else {
// 管理员配置入库
$adminModel = new AdminModel();
$defaultAdmin = $adminModel->where('username', 'admin')->find();
$defaultAdmin->username = $param['adminname'];
$defaultAdmin->nickname = ucfirst($param['adminname']);
$defaultAdmin->save();
if (isset($param['adminpassword']) && $param['adminpassword']) {
$adminModel->resetPassword($defaultAdmin->id, $param['adminpassword']);
}
// 默认用户密码修改
$user = new UserModel();
$user->resetPassword(1, Random::build());
// 修改站点名称
if (class_exists(\app\admin\model\Config::class)) {
\app\admin\model\Config::where('name', 'site_name')->update([
'value' => $param['sitename']
]);
}
}
return $this->success();
}
/**
* 获取命令执行检查的结果
* @return bool 是否拥有执行命令的条件
*/
private function commandExecutionCheck(): bool
{
$pm = config('terminal.npm_package_manager');
if ($pm == 'none') {
return false;
}
$check['phpPopen'] = function_exists('proc_open') && function_exists('proc_close');
$check['npmVersionCompare'] = Version::compare(self::$needDependentVersion['npm'], Version::getVersion('npm'));
$check['pmVersionCompare'] = Version::compare(self::$needDependentVersion[$pm], Version::getVersion($pm));
$check['nodejsVersionCompare'] = Version::compare(self::$needDependentVersion['node'], Version::getVersion('node'));
$envOk = true;
foreach ($check as $value) {
if (!$value) {
$envOk = false;
break;
}
}
return $envOk;
}
/**
* 获取安装完成后的访问地址(根据请求来源区分 API 与前端开发模式)
* - 通过 API 访问8787index.html#/admin、index.html#/
* - 通过前端开发服务访问1818/#/admin、/#/
*/
public function accessUrls(Request $request): Response
{
$this->setRequest($request);
$host = $request->header('host', '127.0.0.1:8787');
$port = '8787';
if (str_contains($host, ':')) {
$port = substr($host, strrpos($host, ':') + 1);
}
$scheme = $request->header('x-forwarded-proto', 'http');
$base = rtrim($scheme . '://' . $host, '/');
if ($port === '1818') {
$adminUrl = $base . '/#/admin';
$frontUrl = $base . '/#/';
} else {
$adminUrl = $base . '/index.html#/admin';
$frontUrl = $base . '/index.html#/';
}
return $this->success('', [
'adminUrl' => $adminUrl,
'frontUrl' => $frontUrl,
]);
}
/**
* 安装指引
*/
public function manualInstall(Request $request): Response
{
$this->setRequest($request);
return $this->success('', [
'webPath' => str_replace('\\', '/', root_path() . 'web')
]);
}
public function mvDist(Request $request): Response
{
$this->setRequest($request);
if (!is_file(root_path() . self::$distDir . DIRECTORY_SEPARATOR . 'index.html')) {
return $this->error(__('No built front-end file found, please rebuild manually!'));
}
if (Terminal::mvDist()) {
return $this->success();
}
return $this->error(__('Failed to move the front-end file, please move it manually!'));
}
/**
* 目录是否可写
* @param $writable
* @return string
*/
private static function writableStateDescribe($writable): string
{
return $writable ? __('Writable') : __('No write permission');
}
/**
* 数据库连接-获取数据表列表(使用 raw PDO
* @param array $database hostname, hostport, username, password, database
* @param bool $returnPdo
* @return array
*/
private function connectDb(array $database, bool $returnPdo = false): array
{
$host = $database['hostname'] ?? '127.0.0.1';
$port = $database['hostport'] ?? '3306';
$user = $database['username'] ?? '';
$pass = $database['password'] ?? '';
$db = $database['database'] ?? '';
$dsn = "mysql:host={$host};port={$port};charset=utf8mb4";
if ($db) {
$dsn .= ";dbname={$db}";
}
try {
$pdo = new \PDO($dsn, $user, $pass, [
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
\PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => true,
]);
$pdo->query("SELECT 1")->fetchAll(\PDO::FETCH_ASSOC);
} catch (\PDOException $e) {
$errorMsg = mb_convert_encoding($e->getMessage() ?: 'unknown', 'UTF-8', 'UTF-8,GBK,GB2312,BIG5');
$template = __('Database connection failed:%s');
return [
'code' => 0,
'msg' => strpos($template, '%s') !== false ? sprintf($template, $errorMsg) : $template . $errorMsg,
];
}
$databases = [];
$databasesExclude = ['information_schema', 'mysql', 'performance_schema', 'sys'];
$stmt = $pdo->query("SHOW DATABASES");
$rows = $stmt->fetchAll(\PDO::FETCH_ASSOC);
$stmt->closeCursor();
foreach ($rows as $row) {
$dbName = $row['Database'] ?? $row['database'] ?? '';
if ($dbName && !in_array($dbName, $databasesExclude)) {
$databases[] = $dbName;
}
}
return [
'code' => 1,
'msg' => '',
'databases' => $databases,
'pdo' => $returnPdo ? $pdo : null,
];
}
}

View File

@@ -9,9 +9,7 @@ use Throwable;
use app\common\controller\Api;
use app\common\facade\Token;
use app\common\library\Auth as UserAuth;
use app\common\library\AgentJwt;
use app\common\model\MallUserAsset;
use app\admin\model\Admin;
use Webman\Http\Request;
use support\Response;
@@ -20,83 +18,12 @@ use support\Response;
*/
class Auth extends Api
{
/**
* Agent Token 类型
*/
public const TOKEN_TYPE = 'agent';
/**
* 时间戳有效范围(秒),防止重放攻击
*/
protected int $timeTolerance = 300;
/**
* 临时登录 token 有效期(秒)
*/
protected int $tempTokenExpire = 86400;
/**
* 获取鉴权 TokenGET 请求)
* 参数仅从 Query 读取signature、secret、agent_id、time
* 返回authtoken失败返回 code=0 及失败信息
*/
public function authToken(Request $request): Response
{
$response = $this->initializeApi($request);
if ($response !== null) {
return $response;
}
$signature = $request->get('signature', '');
$secret = $request->get('secret', '');
$agentId = $request->get('agent_id', '');
$time = $request->get('time', '');
if ($signature === '' || $secret === '' || $agentId === '' || $time === '') {
return $this->error(__('Parameter signature/secret/agent_id/time can not be empty'));
}
$timestamp = intval($time);
if ($timestamp <= 0) {
return $this->error(__('Invalid timestamp'));
}
$now = time();
if ($timestamp < $now - $this->timeTolerance || $timestamp > $now + $this->timeTolerance) {
return $this->error(__('Timestamp expired'));
}
$admin = Admin::where('agent_id', $agentId)->find();
if (!$admin) {
return $this->error(__('Agent not found'));
}
$apiSecret = strval($admin->agent_api_secret ?? '');
if ($apiSecret === '') {
return $this->error(__('Agent not found'));
}
if ($apiSecret !== $secret) {
return $this->error(__('Invalid agent or secret'));
}
$expectedSignature = strtoupper(md5($agentId . $secret . $time));
if (!hash_equals($expectedSignature, $signature)) {
return $this->error(__('Invalid signature'));
}
$expire = intval(config('buildadmin.agent_auth.token_expire', 86400));
$payload = [
'agent_id' => $agentId,
'admin_id' => $admin->id,
];
$authtoken = AgentJwt::encode($payload, $expire);
return $this->success('', [
'authtoken' => $authtoken,
]);
}
/**
* H5 临时登录GET/POST
* 参数username
@@ -109,7 +36,7 @@ class Auth extends Api
return $response;
}
$enabled = config('buildadmin.agent_auth.temp_login_enable', false);
$enabled = config('buildadmin.temp_login.enable', false);
if (!$enabled) {
return $this->error(__('Temp login is disabled'));
}
@@ -145,7 +72,7 @@ class Auth extends Api
$token = Random::uuid();
$refreshToken = Random::uuid();
$expire = config('buildadmin.agent_auth.temp_login_expire', $this->tempTokenExpire);
$expire = config('buildadmin.temp_login.expire', $this->tempTokenExpire);
$assetId = intval($asset->getKey());
Token::set($token, UserAuth::TOKEN_TYPE_MALL_USER, $assetId, $expire);
Token::set($refreshToken, UserAuth::TOKEN_TYPE_MALL_USER . '-refresh', $assetId, 2592000);

View File

@@ -14,6 +14,9 @@ use app\common\model\MallDailyPush;
use app\common\model\MallSession;
use app\common\model\MallOrder;
use app\common\model\MallUserAsset;
use app\common\library\MallPlayxRatios;
use app\common\library\MallPointsConversion;
use app\common\library\MallDailyPushRawLogger;
use app\common\model\MallAddress;
use support\think\Db;
use Webman\Http\Request;
@@ -25,7 +28,7 @@ use support\Response;
class Playx extends Api
{
/**
* 从请求解析 mall_user_asset.idmuser token、session、user_id 均指向资产表主键或 playx_user_id
* 从请求解析 mall_user_asset.idmuser token 或 mall_session
*/
private function resolvePlayxAssetIdFromRequest(Request $request): ?int
{
@@ -55,19 +58,6 @@ class Playx extends Api
return $this->resolveAssetIdByToken($token);
}
$userId = strval($request->post('user_id', $request->get('user_id', '')));
if ($userId === '') {
return null;
}
if (ctype_digit($userId)) {
return intval($userId);
}
$asset = MallUserAsset::where('playx_user_id', $userId)->find();
if ($asset) {
return intval($asset->getKey());
}
return null;
}
@@ -87,22 +77,15 @@ class Playx extends Api
return null;
}
private function buildTempPhone(): ?string
{
for ($i = 0; $i < 8; $i++) {
$candidate = '13' . str_pad(strval(mt_rand(0, 999999999)), 9, '0', STR_PAD_LEFT);
if (!MallUserAsset::where('phone', $candidate)->find()) {
return $candidate;
}
}
return null;
}
private function ensureAssetForPlayx(string $playxUserId, string $username): ?MallUserAsset
private function ensureAssetForPlayx(string $playxUserId, string $username, string $phone = ''): ?MallUserAsset
{
$phone = trim($phone);
$asset = MallUserAsset::where('playx_user_id', $playxUserId)->find();
if ($asset) {
if ($phone !== '' && trim(strval($asset->phone ?? '')) === '') {
$asset->phone = $phone;
$asset->save();
}
return $asset;
}
@@ -113,15 +96,14 @@ class Playx extends Api
$byName = MallUserAsset::where('username', $effectiveUsername)->find();
if ($byName) {
$byName->playx_user_id = $playxUserId;
if ($phone !== '' && trim(strval($byName->phone ?? '')) === '') {
$byName->phone = $phone;
}
$byName->save();
return $byName;
}
$phone = $this->buildTempPhone();
if ($phone === null) {
return null;
}
$pwd = hash_password(Random::build('alnum', 16));
$now = time();
@@ -146,6 +128,72 @@ class Playx extends Api
return MallUserAsset::where('id', $assetId)->find();
}
/**
* 按每日推送记录同步用户资产主信息applyAssetDelta=true 时才落资产增量。
*/
/**
* @return array<string, mixed>
*/
private function getPointsConversionConfig(): array
{
return MallPointsConversion::configForConversion();
}
private function dailyPushSettlementDay(): string
{
return date('Y-m-d');
}
private function syncAssetByDailyPush(
string $playxUserId,
string $username,
string $date,
int $convertedPoints,
int $todayLimit,
bool $applyAssetDelta,
string $phone = '',
?array $pointsConfig = null,
?float $yesterdayWinLossNet = null,
?float $previousYesterdayWinLossNet = null
): ?MallUserAsset
{
$asset = $this->ensureAssetForPlayx($playxUserId, $username, $phone);
if (!$asset) {
return null;
}
$asset->playx_user_id = $playxUserId;
$uname = trim($username);
if ($uname !== '') {
$asset->username = $uname;
}
if ($applyAssetDelta) {
if ($asset->today_limit_date !== $date) {
$asset->today_claimed = 0;
$asset->today_limit_date = $date;
}
if ($convertedPoints !== 0) {
MallPointsConversion::applyDailyConversionDelta($asset, $convertedPoints);
}
$asset->today_limit = $todayLimit;
if (is_array($pointsConfig) && $yesterdayWinLossNet !== null) {
MallPointsConversion::applyEventPeriodWinLossForDailyPush(
$asset,
$date,
$yesterdayWinLossNet,
$pointsConfig,
$this->dailyPushSettlementDay(),
$previousYesterdayWinLossNet
);
}
}
$asset->save();
return $asset;
}
/**
* Daily Push API - PlayX 调用商城接收 T+1 数据
* POST /api/v1/mall/dailyPush
@@ -157,13 +205,15 @@ class Playx extends Api
return $response;
}
$rawBody = $request->rawBody();
$body = $request->post();
if (empty($body)) {
$raw = $request->rawBody();
if ($raw) {
$body = json_decode($raw, true) ?? [];
if (empty($body) && $rawBody !== '') {
$body = json_decode($rawBody, true) ?? [];
}
if ($rawBody === '') {
$rawBody = json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
MallDailyPushRawLogger::log($request, $rawBody);
$secret = config('playx.daily_push_secret', '');
if ($secret !== '') {
@@ -181,7 +231,7 @@ class Playx extends Api
}
// ===== 新版批量上报格式 =====
// 兼容你们截图:{ report_date, member:[{member_id, login, lty_deposit, lty_withdrawal, yesterday_total_w, yesterday_total_deposit}, ...] }
// 当前字段为 ltv_depositltv_withdrawalyesterday_total_wl同时兼容早期错误字段名。
if (isset($body['report_date']) && isset($body['member']) && is_array($body['member'])) {
$reportDate = $body['report_date'];
$date = '';
@@ -201,8 +251,8 @@ class Playx extends Api
$requestId = 'report_' . $date;
}
$returnRatio = config('playx.return_ratio', 0.1);
$unlockRatio = config('playx.unlock_ratio', 0.1);
$pointsConfig = $this->getPointsConversionConfig();
$settlementDay = $this->dailyPushSettlementDay();
$results = [];
$allDeduped = true;
@@ -214,13 +264,22 @@ class Playx extends Api
}
$username = strval($m['login'] ?? '');
$yesterdayWinLossNet = $m['yesterday_total_w'] ?? 0;
$phone = trim(strval($m['phone'] ?? ($m['mobile'] ?? '')));
$yesterdayWinLossNet = $m['yesterday_total_wl'] ?? ($m['yesterday_total_w'] ?? 0);
$yesterdayTotalDeposit = $m['yesterday_total_deposit'] ?? 0;
$lifetimeTotalDeposit = $m['lty_deposit'] ?? 0;
$lifetimeTotalWithdraw = $m['lty_withdrawal'] ?? 0;
$lifetimeTotalDeposit = $m['ltv_deposit'] ?? ($m['lty_deposit'] ?? 0);
$lifetimeTotalWithdraw = $m['ltv_withdrawal'] ?? ($m['lty_withdrawal'] ?? 0);
$lifetimeWinLossNet = MallPointsConversion::parseLifetimeWinLossFromMember($m);
$exists = MallDailyPush::where('user_id', $playxUserId)->where('date', $date)->find();
if ($exists) {
$convertedPoints = MallPointsConversion::dailyConvertedPoints($date, floatval($yesterdayWinLossNet), $pointsConfig, $settlementDay);
$todayLimit = MallPointsConversion::dailyPushTodayLimit($date, floatval($yesterdayTotalDeposit), $pointsConfig, $settlementDay);
$asset = $this->syncAssetByDailyPush($playxUserId, $username, $date, $convertedPoints, $todayLimit, false, $phone);
if (!$asset) {
return $this->error(__('Failed to ensure PlayX user asset'));
}
$results[] = [
'user_id' => $playxUserId,
'deduped' => true,
@@ -232,7 +291,7 @@ class Playx extends Api
Db::startTrans();
try {
MallDailyPush::create([
$pushData = [
'user_id' => $playxUserId,
'date' => $date,
'username' => $username,
@@ -241,34 +300,21 @@ class Playx extends Api
'lifetime_total_deposit' => $lifetimeTotalDeposit,
'lifetime_total_withdraw' => $lifetimeTotalWithdraw,
'create_time' => time(),
]);
$newLocked = 0;
if ($yesterdayWinLossNet < 0) {
$newLocked = intval(round(abs(floatval($yesterdayWinLossNet)) * $returnRatio));
];
$convertedPoints = MallPointsConversion::dailyConvertedPoints($date, floatval($yesterdayWinLossNet), $pointsConfig, $settlementDay);
$pushData['converted_points'] = $convertedPoints;
if ($lifetimeWinLossNet !== null) {
$pushData['lifetime_win_loss_net'] = $lifetimeWinLossNet;
}
$todayLimit = intval(round(floatval($yesterdayTotalDeposit) * $unlockRatio));
MallDailyPush::create($pushData);
$asset = $this->ensureAssetForPlayx($playxUserId, $username);
$todayLimit = MallPointsConversion::dailyPushTodayLimit($date, floatval($yesterdayTotalDeposit), $pointsConfig, $settlementDay);
$asset = $this->syncAssetByDailyPush($playxUserId, $username, $date, $convertedPoints, $todayLimit, true, $phone, $pointsConfig, floatval($yesterdayWinLossNet));
if (!$asset) {
throw new \RuntimeException(__('Failed to ensure PlayX user asset'));
}
if ($asset->today_limit_date !== $date) {
$asset->today_claimed = 0;
$asset->today_limit_date = $date;
}
$asset->locked_points = intval($asset->locked_points ?? 0) + $newLocked;
$asset->today_limit = $todayLimit;
$asset->playx_user_id = $playxUserId;
$uname = trim($username);
if ($uname !== '') {
$asset->username = $uname;
}
$asset->save();
Db::commit();
$results[] = [
@@ -297,6 +343,7 @@ class Playx extends Api
$requestId = $body['request_id'] ?? '';
$date = $body['date'] ?? '';
$playxUserId = strval($body['user_id'] ?? '');
$phone = trim(strval($body['phone'] ?? ($body['mobile'] ?? '')));
$yesterdayWinLossNet = $body['yesterday_win_loss_net'] ?? 0;
$yesterdayTotalDeposit = $body['yesterday_total_deposit'] ?? 0;
@@ -306,6 +353,23 @@ class Playx extends Api
$exists = MallDailyPush::where('user_id', $playxUserId)->where('date', $date)->find();
if ($exists) {
$pointsConfig = $this->getPointsConversionConfig();
$settlementDay = $this->dailyPushSettlementDay();
$convertedPoints = MallPointsConversion::dailyConvertedPoints($date, floatval($yesterdayWinLossNet), $pointsConfig, $settlementDay);
$todayLimit = MallPointsConversion::dailyPushTodayLimit($date, floatval($yesterdayTotalDeposit), $pointsConfig, $settlementDay);
$asset = $this->syncAssetByDailyPush(
$playxUserId,
strval($body['username'] ?? ''),
$date,
$convertedPoints,
$todayLimit,
false,
$phone
);
if (!$asset) {
return $this->error(__('Failed to ensure PlayX user asset'));
}
return $this->success('', [
'request_id' => $requestId,
'accepted' => true,
@@ -316,7 +380,13 @@ class Playx extends Api
Db::startTrans();
try {
MallDailyPush::create([
$pointsConfig = $this->getPointsConversionConfig();
$settlementDay = $this->dailyPushSettlementDay();
$lifetimeWinLossNet = null;
if (isset($body['lifetime_win_loss_net']) && is_numeric($body['lifetime_win_loss_net'])) {
$lifetimeWinLossNet = floatval($body['lifetime_win_loss_net']);
}
$pushData = [
'user_id' => $playxUserId,
'date' => $date,
'username' => $body['username'] ?? '',
@@ -325,36 +395,31 @@ class Playx extends Api
'lifetime_total_deposit' => $body['lifetime_total_deposit'] ?? 0,
'lifetime_total_withdraw' => $body['lifetime_total_withdraw'] ?? 0,
'create_time' => time(),
]);
$newLocked = 0;
$returnRatio = config('playx.return_ratio', 0.1);
$unlockRatio = config('playx.unlock_ratio', 0.1);
if ($yesterdayWinLossNet < 0) {
$newLocked = intval(round(abs(floatval($yesterdayWinLossNet)) * $returnRatio));
];
$convertedPoints = MallPointsConversion::dailyConvertedPoints($date, floatval($yesterdayWinLossNet), $pointsConfig, $settlementDay);
$pushData['converted_points'] = $convertedPoints;
if ($lifetimeWinLossNet !== null) {
$pushData['lifetime_win_loss_net'] = $lifetimeWinLossNet;
}
$todayLimit = intval(round(floatval($yesterdayTotalDeposit) * $unlockRatio));
MallDailyPush::create($pushData);
$asset = $this->ensureAssetForPlayx($playxUserId, strval($body['username'] ?? ''));
$todayLimit = MallPointsConversion::dailyPushTodayLimit($date, floatval($yesterdayTotalDeposit), $pointsConfig, $settlementDay);
$asset = $this->syncAssetByDailyPush(
$playxUserId,
strval($body['username'] ?? ''),
$date,
$convertedPoints,
$todayLimit,
true,
$phone,
$pointsConfig,
floatval($yesterdayWinLossNet)
);
if (!$asset) {
throw new \RuntimeException(__('Failed to ensure PlayX user asset'));
}
if ($asset->today_limit_date !== $date) {
$asset->today_claimed = 0;
$asset->today_limit_date = $date;
}
$asset->locked_points = intval($asset->locked_points ?? 0) + $newLocked;
$asset->today_limit = $todayLimit;
$asset->playx_user_id = $playxUserId;
$uname = trim(strval($body['username'] ?? ''));
if ($uname !== '') {
$asset->username = $uname;
}
$asset->save();
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
@@ -370,8 +435,8 @@ class Playx extends Api
}
/**
* Token 验证 - POST /api/v1/playx/verify-token
* 配置 playx.verify_token_local_only=true 时仅本地校验 token不请求 PlayX)。
* Token 验证 - POST /api/v1/mall/verifyToken
* 配置 playx.verify_token_local_only=true 时:不向 PlayX 请求,按 .env 默认用户签发 session见 verify_token_local_default_*)。
*/
public function verifyToken(Request $request): Response
{
@@ -380,43 +445,141 @@ class Playx extends Api
return $response;
}
if (config('playx.verify_token_local_only', false)) {
return $this->verifyTokenLocalOpen($request);
}
$token = strval($request->post('token', $request->post('session', $request->get('token', ''))));
if ($token === '') {
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
}
if (config('playx.verify_token_local_only', false)) {
return $this->verifyTokenLocal($token);
}
$baseUrl = strval(config('playx.angpow_import.base_url', ''));
$verifyUrlRaw = strval(config('playx.api.token_verify_url', '/api/v1/auth/verify-token'));
$verifyUrlTrimmed = trim($verifyUrlRaw);
$isAbsoluteVerifyUrl = str_starts_with($verifyUrlTrimmed, 'http://')
|| str_starts_with($verifyUrlTrimmed, 'https://');
$baseUrl = config('playx.api.base_url', '');
$verifyUrl = config('playx.api.token_verify_url', '/api/v1/auth/verify-token');
if ($isAbsoluteVerifyUrl) {
$targetVerifyUrl = $verifyUrlTrimmed;
} else {
$verifyPath = ltrim($verifyUrlTrimmed, '/');
if ($verifyPath === '') {
return $this->error(__('PlayX API not configured'));
}
if ($baseUrl === '') {
return $this->error(__('PlayX API not configured'));
}
$targetVerifyUrl = rtrim($baseUrl, '/') . '/' . $verifyPath;
}
try {
$client = new \GuzzleHttp\Client([
'base_uri' => rtrim($baseUrl, '/') . '/',
$requestId = 'mall_' . uniqid();
$clientOptions = [
'timeout' => 10,
]);
$res = $client->post($verifyUrl, [
'json' => [
'request_id' => 'mall_' . uniqid(),
'http_errors' => false,
];
if (!$isAbsoluteVerifyUrl) {
$clientOptions['base_uri'] = rtrim($baseUrl, '/') . '/';
}
$client = new \GuzzleHttp\Client($clientOptions);
if ($isAbsoluteVerifyUrl) {
$merchantCode = strval(config('playx.angpow_import.merchant_code', ''));
$authKey = strval(config('playx.angpow_import.auth_key', ''));
if ($merchantCode === '' || $authKey === '') {
return $this->error(__('PlayX API not configured'));
}
// 回调网关要求 Body 含 merchant_code、request_dateUnix 秒);签名字符串与 Body 参与签名字段一致HMAC 同 angpow
$requestDate = strval(time());
$signatureInput = 'merchant_code=' . $merchantCode
. '&request_date=' . $requestDate
. '&request_id=' . $requestId
. '&token=' . $token;
$signature = $this->buildPlayxTokenVerifySignature($signatureInput, $authKey);
if ($signature === null) {
return $this->error(__('Invalid signature'), null, 0, ['statusCode' => 500]);
}
$headers = [
'Content-Type' => 'application/json',
'X-Request-Signature' => $signature,
];
$payload = [
'merchant_code' => $merchantCode,
'request_date' => $requestDate,
'request_id' => $requestId,
'token' => $token,
],
];
$res = $client->post($targetVerifyUrl, [
'headers' => $headers,
'json' => $payload,
]);
$code = $res->getStatusCode();
} else {
$merchantCode = strval(config('playx.angpow_import.merchant_code', ''));
$authKey = strval(config('playx.angpow_import.auth_key', ''));
if ($merchantCode === '' || $authKey === '') {
return $this->error(__('PlayX API not configured'));
}
$requestDate = strval(time());
$signatureInput = 'merchant_code=' . $merchantCode
. '&request_date=' . $requestDate
. '&request_id=' . $requestId
. '&token=' . $token;
$signature = $this->buildPlayxTokenVerifySignature($signatureInput, $authKey);
if ($signature === null) {
return $this->error(__('Invalid signature'), null, 0, ['statusCode' => 500]);
}
$headers = [
'Content-Type' => 'application/json',
'X-Request-Signature' => $signature,
'X-Signature' => $signature,
'X-Request-Date' => $requestDate,
'X-Request-ID' => $requestId,
];
$payload = [
'merchant_code' => $merchantCode,
'request_date' => $requestDate,
'request_id' => $requestId,
'token' => $token,
];
$verifyPath = ltrim($verifyUrlTrimmed, '/');
$res = $client->post($verifyPath, [
'headers' => $headers,
'json' => $payload,
]);
}
$data = json_decode(strval($res->getBody()), true);
$code = $res->getStatusCode();
if ($code !== 200 || empty($data['user_id'])) {
$remoteMsg = $data['message'] ?? '';
$msg = is_string($remoteMsg) && $remoteMsg !== '' ? $remoteMsg : __('Token expiration');
$remoteMsg = '';
if (is_array($data)) {
$candidateMsg = $data['message'] ?? ($data['msg'] ?? ($data['error'] ?? ''));
if (is_string($candidateMsg) && $candidateMsg !== '') {
$remoteMsg = $candidateMsg;
}
}
if ($remoteMsg === '') {
$bodyText = strval($res->getBody());
if ($bodyText !== '') {
$remoteMsg = mb_substr($bodyText, 0, 300);
}
}
if ($remoteMsg === '' || str_contains(strtolower($remoteMsg), '<html')) {
$remoteMsg = __('PlayX verify upstream failed', [strval($code), $targetVerifyUrl]);
}
$msg = $remoteMsg !== '' ? $remoteMsg : __('Token expiration');
return $this->error($msg, null, 0, ['statusCode' => 401]);
}
$userId = strval($data['user_id']);
$username = strval($data['username'] ?? '');
$phone = trim(strval($data['phone'] ?? ($data['mobile'] ?? '')));
$expireAt = time() + intval(config('playx.session_expire_seconds', 3600));
if (!empty($data['token_expire_at'])) {
@@ -426,6 +589,11 @@ class Playx extends Api
}
}
$asset = $this->ensureAssetForPlayx($userId, $username, $phone);
if ($asset === null) {
return $this->error(__('Failed to ensure PlayX user asset'));
}
$sessionId = bin2hex(random_bytes(16));
MallSession::create([
'session_id' => $sessionId,
@@ -448,55 +616,81 @@ class Playx extends Api
}
/**
* 本地校验 temLogin 等写入的商城 token类型 muser写入 mall_session
* 本地联调:不调用 PlayX verify-token身份取自 PLAYX_VERIFY_TOKEN_LOCAL_DEFAULT_*,请求 token 仅作占位(有则通过、无则也通过)。
*/
private function verifyTokenLocal(string $token): Response
private function verifyTokenLocalOpen(Request $request): Response
{
$tokenData = Token::get($token);
if (empty($tokenData) || (isset($tokenData['expire_time']) && intval($tokenData['expire_time']) <= time())) {
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
}
$tokenType = strval($tokenData['type'] ?? '');
if ($tokenType !== UserAuth::TOKEN_TYPE_MALL_USER) {
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
}
$assetId = intval($tokenData['user_id'] ?? 0);
if ($assetId <= 0) {
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
}
$asset = MallUserAsset::where('id', $assetId)->find();
if (!$asset) {
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
}
$playxUserId = strval($asset->playx_user_id ?? '');
$playxUserId = trim(strval(config('playx.verify_token_local_default_user_id', '')));
$username = trim(strval(config('playx.verify_token_local_default_username', '')));
if ($playxUserId === '') {
$playxUserId = strval($assetId);
return $this->error(__('PlayX verify token local default user not configured'));
}
if ($username === '') {
$username = $playxUserId;
}
$asset = $this->ensureAssetForPlayx($playxUserId, $username);
if ($asset === null) {
return $this->error(__('Failed to ensure PlayX user asset'));
}
$expireAt = time() + intval(config('playx.session_expire_seconds', 3600));
$sessionId = bin2hex(random_bytes(16));
$now = time();
MallSession::create([
'session_id' => $sessionId,
'user_id' => $playxUserId,
'username' => strval($asset->username ?? ''),
'username' => $username,
'expire_time' => $expireAt,
'create_time' => time(),
'update_time' => time(),
'create_time' => $now,
'update_time' => $now,
]);
return $this->success('', [
return $this->success('Success', [
'session_id' => $sessionId,
'user_id' => $playxUserId,
'username' => strval($asset->username ?? ''),
'username' => $username,
'token_expire_at' => date('c', $expireAt),
]);
}
/**
* 生成 token verify 所需签名Base64(HMAC-SHA1(canonical, key))
*/
private function buildPlayxTokenVerifySignature(string $input, string $authKey): ?string
{
$keyBytes = null;
$maybeBase64 = base64_decode($authKey, true);
if (is_string($maybeBase64) && $maybeBase64 !== '') {
$keyBytes = $maybeBase64;
}
if ($keyBytes === null) {
$isHex = ctype_xdigit($authKey) && (strlen($authKey) % 2 === 0);
if ($isHex) {
$hex = hex2bin($authKey);
if (is_string($hex) && $hex !== '') {
$keyBytes = $hex;
}
}
}
if ($keyBytes === null) {
$keyBytes = $authKey;
}
$raw = hash_hmac('sha1', $input, $keyBytes, true);
if (!is_string($raw) || $raw === '') {
return null;
}
return base64_encode($raw);
}
/**
* 用户资产
* GET /api/v1/playx/assets?user_id=xxx
* GET /api/v1/mall/assets
*/
public function assets(Request $request): Response
{
@@ -521,7 +715,7 @@ class Playx extends Api
]);
}
$ratio = config('playx.points_to_cash_ratio', 0.1);
$ratio = MallPlayxRatios::get()['points_to_cash_ratio'];
$withdrawableCash = round($asset->available_points * $ratio, 2);
return $this->success('', [
@@ -571,11 +765,12 @@ class Playx extends Api
}
$remain = $asset->today_limit - $asset->today_claimed;
if ($asset->locked_points <= 0 || $remain <= 0) {
$lifetimeRemain = MallPointsConversion::remainingClaimable($asset);
if ($asset->locked_points <= 0 || $remain <= 0 || $lifetimeRemain <= 0) {
return $this->error(__('No points to claim or limit reached'));
}
$canClaim = min($asset->locked_points, $remain);
$canClaim = min($asset->locked_points, $remain, $lifetimeRemain);
Db::startTrans();
try {
@@ -589,6 +784,7 @@ class Playx extends Api
$asset->locked_points -= $canClaim;
$asset->available_points += $canClaim;
$asset->today_claimed += $canClaim;
$asset->total_points_claimed = max(0, intval($asset->total_points_claimed ?? 0) + $canClaim);
$asset->save();
Db::commit();
@@ -620,7 +816,12 @@ class Playx extends Api
}
$list = $query->order('sort', 'asc')->select();
return $this->success('', ['list' => $list->toArray()]);
$out = [];
foreach ($list->toArray() as $row) {
$out[] = $this->applyMallItemLocaleForApi($row);
}
return $this->success('', ['list' => $out]);
}
/**
@@ -652,7 +853,7 @@ class Playx extends Api
/**
* 订单列表
* GET /api/v1/playx/orders?user_id=xxx
* GET /api/v1/mall/orders
*/
public function orders(Request $request): Response
{
@@ -676,7 +877,307 @@ class Playx extends Api
->limit(100)
->select();
return $this->success('', ['list' => $list->toArray()]);
$arr = $list->toArray();
foreach ($arr as &$orderRow) {
if (isset($orderRow['mallItem']) && is_array($orderRow['mallItem'])) {
$orderRow['mallItem'] = $this->applyMallItemLocaleForApi($orderRow['mallItem']);
}
}
unset($orderRow);
return $this->success('', ['list' => $arr]);
}
/**
* 按订单 ID 查询订单(仅当前登录用户本人的订单)
* GET /api/v1/mall/order?order_id=xxx
*
* 鉴权token / session_id同 assets/orders
*/
public function order(Request $request): Response
{
$response = $this->initializeApi($request);
if ($response !== null) {
return $response;
}
$assetId = $this->resolvePlayxAssetIdFromRequest($request);
if ($assetId === null) {
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
}
$asset = $this->getAssetById($assetId);
if (!$asset || strval($asset->playx_user_id ?? '') === '') {
return $this->error(__('Record not found'));
}
$orderId = intval($request->get('order_id', $request->post('order_id', 0)));
if ($orderId <= 0) {
$orderId = intval($request->get('id', $request->post('id', 0)));
}
if ($orderId <= 0) {
return $this->error(__('Missing required fields'));
}
$playxUserId = strval($asset->playx_user_id);
$order = MallOrder::where('id', $orderId)
->where('user_id', $playxUserId)
->with(['mallItem'])
->find();
if (!$order) {
return $this->error(__('Record not found'));
}
$row = $order->toArray();
if (isset($row['mallItem']) && is_array($row['mallItem'])) {
$row['mallItem'] = $this->applyMallItemLocaleForApi($row['mallItem']);
}
return $this->success('', [
'order_id' => $order->id,
'status' => $order->status,
'order' => $row,
]);
}
/**
* 积分流水(领取/兑换/退回/管理员调整)
* GET /api/v1/mall/pointsLogs
*
* 鉴权token / session_id同 assets/orders
*
* 参数:
* - limit: 每页条数(默认 20最大 100
* - cursor: 游标(上一页返回的 next_cursor
* - direction: IN | OUT可选不传返回全部
*/
public function pointsLogs(Request $request): Response
{
$response = $this->initializeApi($request);
if ($response !== null) {
return $response;
}
$assetId = $this->resolvePlayxAssetIdFromRequest($request);
if ($assetId === null) {
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
}
$asset = $this->getAssetById($assetId);
if (!$asset || ($asset->playx_user_id ?? '') === '') {
return $this->success('', [
'list' => [],
'next_cursor' => null,
]);
}
$playxUserId = $asset->playx_user_id;
$limit = $request->get('limit', $request->post('limit', '20'));
if (!is_string($limit) || $limit === '' || !ctype_digit($limit) || $limit === '0') {
$limit = '20';
}
if (strlen($limit) > 3 || $limit > '100') {
$limit = '100';
}
$cursor = $request->get('cursor', $request->post('cursor', ''));
if (!is_string($cursor)) {
$cursor = '';
}
$direction = $request->get('direction', $request->post('direction', ''));
$direction = is_string($direction) ? strtoupper(trim($direction)) : '';
if ($direction !== '' && $direction !== 'IN' && $direction !== 'OUT') {
$direction = '';
}
$sql = <<<SQL
SELECT
t.biz_type,
t.direction,
t.points,
t.ts,
t.ref_id,
t.order_no,
t.order_status,
t.item_id,
t.item_title,
t.item_type,
t.item_score,
t.item_amount,
t.item_category,
t.item_category_title,
t.sort_key
FROM (
SELECT
'CLAIM' AS biz_type,
'IN' AS direction,
cl.claimed_amount AS points,
cl.create_time AS ts,
cl.claim_request_id AS ref_id,
'' AS order_no,
'' AS order_status,
0 AS item_id,
'' AS item_title,
0 AS item_type,
0 AS item_score,
0 AS item_amount,
'' AS item_category,
'' AS item_category_title,
CONCAT(LPAD(cl.create_time, 10, '0'), '_1_', LPAD(cl.id, 10, '0')) AS sort_key
FROM mall_claim_log cl
WHERE cl.user_id = :user_id_claim
UNION ALL
SELECT
CONCAT('REDEEM_', o.type) AS biz_type,
'OUT' AS direction,
o.points_cost AS points,
o.create_time AS ts,
o.external_transaction_id AS ref_id,
o.external_transaction_id AS order_no,
o.status AS order_status,
o.mall_item_id AS item_id,
COALESCE(i.title, '') AS item_title,
COALESCE(i.type, 0) AS item_type,
COALESCE(i.score, 0) AS item_score,
COALESCE(i.amount, 0) AS item_amount,
COALESCE(i.category, '') AS item_category,
COALESCE(i.category_title, '') AS item_category_title,
CONCAT(LPAD(o.create_time, 10, '0'), '_2_', LPAD(o.id, 10, '0')) AS sort_key
FROM mall_order o
LEFT JOIN mall_item i ON i.id = o.mall_item_id
WHERE o.user_id = :user_id_redeem AND o.points_cost > 0
UNION ALL
SELECT
'REFUND' AS biz_type,
'IN' AS direction,
o.points_cost AS points,
o.update_time AS ts,
o.external_transaction_id AS ref_id,
o.external_transaction_id AS order_no,
o.status AS order_status,
o.mall_item_id AS item_id,
COALESCE(i.title, '') AS item_title,
COALESCE(i.type, 0) AS item_type,
COALESCE(i.score, 0) AS item_score,
COALESCE(i.amount, 0) AS item_amount,
COALESCE(i.category, '') AS item_category,
COALESCE(i.category_title, '') AS item_category_title,
CONCAT(LPAD(o.update_time, 10, '0'), '_3_', LPAD(o.id, 10, '0')) AS sort_key
FROM mall_order o
LEFT JOIN mall_item i ON i.id = o.mall_item_id
WHERE o.user_id = :user_id_refund AND o.status = 'REJECTED' AND o.points_cost > 0 AND o.update_time IS NOT NULL
UNION ALL
SELECT
'ADMIN_ADJUST' AS biz_type,
CASE WHEN pl.score_value > 0 THEN 'IN' ELSE 'OUT' END AS direction,
ABS(pl.score_value) AS points,
pl.create_time AS ts,
pl.id AS ref_id,
'' AS order_no,
'' AS order_status,
0 AS item_id,
'' AS item_title,
0 AS item_type,
0 AS item_score,
0 AS item_amount,
'' AS item_category,
'' AS item_category_title,
CONCAT(LPAD(pl.create_time, 10, '0'), '_4_', LPAD(pl.id, 10, '0')) AS sort_key
FROM mall_points_log pl
WHERE pl.user_asset_id = :user_asset_id_adjust
) t
WHERE 1=1
SQL;
$params = [
'user_id_claim' => $playxUserId,
'user_id_redeem' => $playxUserId,
'user_id_refund' => $playxUserId,
'user_asset_id_adjust' => $assetId,
];
if ($cursor !== '') {
$sql .= "\n AND t.sort_key < :cursor";
$params['cursor'] = $cursor;
}
if ($direction !== '') {
$sql .= "\n AND t.direction = :direction";
$params['direction'] = $direction;
}
$sql .= "\nORDER BY t.sort_key DESC";
$sql .= "\nLIMIT " . $limit;
try {
$rows = Db::query($sql, $params);
} catch (\Throwable $e) {
return $this->error($e->getMessage(), null, 0, ['statusCode' => 500]);
}
$list = [];
$nextCursor = null;
if (is_array($rows)) {
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
$memo = '';
if (($row['biz_type'] ?? '') === 'ADMIN_ADJUST') {
$memoKey = ($row['direction'] ?? '') === 'OUT'
? 'Administrator deducted %s points'
: 'Administrator added %s points';
$memo = sprintf(__($memoKey), $row['points'] ?? 0);
}
$list[] = [
'biz_type' => $row['biz_type'] ?? '',
'direction' => $row['direction'] ?? '',
'points' => $row['points'] ?? 0,
'memo' => $memo,
'ts' => $row['ts'] ?? null,
'ref_id' => $row['ref_id'] ?? '',
'order_no' => $row['order_no'] ?? '',
'order_status' => $row['order_status'] ?? '',
'item_id' => $row['item_id'] ?? 0,
'item_title' => $row['item_title'] ?? '',
'item_type' => $row['item_type'] ?? 0,
'item_score' => $row['item_score'] ?? 0,
'mallItem' => ($row['item_id'] ?? 0) ? [
'id' => $row['item_id'] ?? 0,
'title' => $row['item_title'] ?? '',
'type' => $row['item_type'] ?? 0,
'score' => $row['item_score'] ?? 0,
'amount' => $row['item_amount'] ?? 0,
'category' => $row['item_category'] ?? '',
'category_title' => $row['item_category_title'] ?? '',
] : null,
'cursor' => $row['sort_key'] ?? '',
];
$nextCursor = $row['sort_key'] ?? $nextCursor;
}
}
$itemsMap = $this->buildMallItemLocaleMapForPointsLog($list);
foreach ($list as &$row) {
$id = (int)($row['item_id'] ?? 0);
if ($id > 0 && isset($itemsMap[$id])) {
$row['item_title'] = $itemsMap[$id]['title'];
if (isset($row['mallItem']) && is_array($row['mallItem'])) {
$row['mallItem']['title'] = $itemsMap[$id]['title'];
}
}
}
unset($row);
return $this->success('', [
'list' => $list,
'next_cursor' => $nextCursor,
]);
}
/**
@@ -721,11 +1222,10 @@ class Playx extends Api
$phone = trim(strval($request->post('phone', '')));
$receiverName = trim(strval($request->post('receiver_name', '')));
$region = $request->post('region', '');
$detailAddress = trim(strval($request->post('detail_address', '')));
$defaultSetting = strval($request->post('default_setting', '0')) === '1' ? 1 : 0;
if ($phone === '' || $receiverName === '' || $detailAddress === '' || $region === '' || $region === null) {
if ($phone === '' || $receiverName === '' || $detailAddress === '') {
return $this->error(__('Missing required fields'));
}
@@ -739,7 +1239,6 @@ class Playx extends Api
'playx_user_asset_id' => $assetId,
'receiver_name' => $receiverName,
'phone' => $phone,
'region' => $region,
'detail_address' => $detailAddress,
'default_setting' => $defaultSetting,
'create_time' => time(),
@@ -790,9 +1289,6 @@ class Playx extends Api
if ($request->post('receiver_name', null) !== null) {
$updates['receiver_name'] = trim(strval($request->post('receiver_name', '')));
}
if ($request->post('region', null) !== null) {
$updates['region'] = $request->post('region', '');
}
if ($request->post('detail_address', null) !== null) {
$updates['detail_address'] = trim(strval($request->post('detail_address', '')));
}
@@ -881,7 +1377,7 @@ class Playx extends Api
'withdrawable_cash' => 0,
];
}
$ratio = config('playx.points_to_cash_ratio', 0.1);
$ratio = MallPlayxRatios::get()['points_to_cash_ratio'];
return [
'locked_points' => $asset->locked_points,
'available_points' => $asset->available_points,
@@ -918,10 +1414,6 @@ class Playx extends Api
}
$playxUserId = strval($asset->playx_user_id);
$multiplier = intval($item->multiplier ?? 0);
if ($multiplier <= 0) {
$multiplier = 1;
}
$amount = floatval($item->amount ?? 0);
Db::startTrans();
@@ -937,7 +1429,6 @@ class Playx extends Api
'mall_item_id' => $item->id,
'points_cost' => $item->score,
'amount' => $amount,
'multiplier' => $multiplier,
'external_transaction_id' => $orderNo,
'grant_status' => MallOrder::GRANT_NOT_SENT,
'create_time' => time(),
@@ -1064,10 +1555,6 @@ class Playx extends Api
}
$playxUserId = strval($asset->playx_user_id);
$multiplier = intval($item->multiplier ?? 0);
if ($multiplier <= 0) {
$multiplier = 1;
}
$amount = floatval($item->amount ?? 0);
Db::startTrans();
@@ -1083,7 +1570,6 @@ class Playx extends Api
'mall_item_id' => $item->id,
'points_cost' => $item->score,
'amount' => $amount,
'multiplier' => $multiplier,
'external_transaction_id' => $orderNo,
'grant_status' => MallOrder::GRANT_NOT_APPLICABLE,
'create_time' => time(),
@@ -1102,6 +1588,75 @@ class Playx extends Api
]);
}
private function getApiLocale(): string
{
if (function_exists('locale')) {
$l = locale();
if (is_string($l) && $l !== '') {
return $l;
}
}
return 'zh-cn';
}
/**
* @param array<string,mixed> $item MallItem 行(可含 title_en/title_ms 与 description_en/description_ms
* @return array<string,mixed>
*/
private function applyMallItemLocaleForApi(array $item): array
{
$lang = $this->getApiLocale();
if ($lang === 'en') {
$te = trim((string)($item['title_en'] ?? ''));
$de = trim((string)($item['description_en'] ?? ''));
if ($te !== '') {
$item['title'] = $te;
}
if ($de !== '') {
$item['description'] = $de;
}
} elseif ($lang === 'ms') {
$tm = trim((string)($item['title_ms'] ?? ''));
$dm = trim((string)($item['description_ms'] ?? ''));
if ($tm !== '') {
$item['title'] = $tm;
}
if ($dm !== '') {
$item['description'] = $dm;
}
}
unset($item['title_en'], $item['description_en'], $item['title_ms'], $item['description_ms']);
return $item;
}
/**
* @param list<array<string,mixed>> $list pointsLogs 已组装的列表
* @return array<int,array<string,mixed>> id => item 行(已本地化并去掉 *_ms
*/
private function buildMallItemLocaleMapForPointsLog(array $list): array
{
$ids = [];
foreach ($list as $row) {
$id = (int)($row['item_id'] ?? 0);
if ($id > 0) {
$ids[$id] = true;
}
}
if ($ids === []) {
return [];
}
$idList = array_keys($ids);
$map = [];
$models = MallItem::whereIn('id', $idList)->select();
foreach ($models as $model) {
$map[intval($model->id)] = $this->applyMallItemLocaleForApi($model->toArray());
}
return $map;
}
private function callPlayxBonusGrant(MallOrder $order, MallItem $item, string $userId): void
{
$baseUrl = rtrim(config('playx.api.base_url', ''), '/');
@@ -1121,12 +1676,10 @@ class Playx extends Api
'rewardName' => $item->title ?? '',
'category' => $item->category ?? 'daily',
'categoryTitle' => $item->category_title ?? '',
'multiplier' => $order->multiplier,
],
]);
$data = json_decode(strval($res->getBody()), true);
if ($res->getStatusCode() === 200 && ($data['status'] ?? '') === 'accepted') {
$order->playx_transaction_id = $data['playx_transaction_id'] ?? '';
$order->grant_status = MallOrder::GRANT_ACCEPTED;
$order->save();
} else {

View File

@@ -1,37 +1,68 @@
<?php
/**
* API root language pack (English). Keys must match zh-cn.php / ms.php.
*/
return [
'%d second%s ago' => '%d second%s ago',
'%d minute%s ago' => '%d minute%s ago',
'%d hour%s ago' => '%d hour%s ago',
'%d day%s ago' => '%d day%s ago',
'%d week%s ago' => '%d week%s ago',
'%d month%s ago' => '%d month%s ago',
'%d year%s ago' => '%d year%s ago',
'%d second%s after' => '%d second%s later',
'%d minute%s after' => '%d minute%s later',
'%d hour%s after' => '%d hour%s later',
'%d day%s after' => '%d day%s later',
'%d week%s after' => '%d week%s later',
'%d month%s after' => '%d month%s later',
'%d year%s after' => '%d year%s later',
'File uploaded successfully' => 'File uploaded successfully!',
'No files were uploaded' => 'No files were uploaded',
'The uploaded file format is not allowed' => 'The uploaded file format is not allowed',
'The uploaded file contains prohibited content' => 'The uploaded file contains prohibited content',
'The uploaded image file is not a valid image' => 'The uploaded image is not valid',
'The uploaded file is too large (%sMiB), Maximum file size:%sMiB' => 'The file is too large (%sMiB), maximum: %sMiB',
'No files have been uploaded or the file size exceeds the upload limit of the server' => 'No file uploaded or size exceeds server limit',
'Topic format error' => 'Storage topic format error',
'Driver %s not supported' => 'Driver not supported: %s',
'Username' => 'Username',
'Email' => 'Email',
'Mobile' => 'Mobile',
'Password' => 'Password',
'Login expired, please login again.' => 'Login expired, please login again.',
'Account not exist' => 'Account does not exist',
'Account disabled' => 'Account is disabled',
'Token login failed' => 'Token login failed',
'Please try again after 1 day' => 'The number of failed login attempts has exceeded the limit, please try again after 24 hours.',
'Please try again after 1 day' => 'Too many failed attempts, please try again after 24 hours',
'Password is incorrect' => 'Incorrect password',
'You are not logged in' => 'You are not logged in.',
'You are not logged in' => 'You are not logged in',
'Unknown operation' => 'Unknown operation',
'No action available, please contact the administrator~' => 'There is no action available, please contact the administrator~',
'Please login first' => 'Please login first',
'You have no permission' => 'No permission to operate',
'Captcha error' => 'Captcha error!',
'No action available, please contact the administrator~' => 'No action available, please contact the administrator~',
'Please login first' => 'Please login first!',
'You have no permission' => 'No permission!',
'Parameter error' => 'Parameter error!',
'Parameter %s can not be empty' => 'Parameter %s cannot be empty',
'Parameter signature/secret/agent_id/time can not be empty' => 'Parameter signature/secret/agent_id/time can not be empty',
'Invalid timestamp' => 'Invalid timestamp',
'Timestamp expired' => 'Timestamp expired',
'Invalid agent or secret' => 'Invalid agent or secret',
'Invalid signature' => 'Invalid signature',
'Agent not found' => 'Agent not found',
'Temp login is disabled' => 'Temp login is disabled',
'Failed to create temp account' => 'Failed to allocate a unique phone number, please retry later',
'Temp login is disabled' => 'Temporary login is disabled',
'Failed to create temp account' => 'Failed to create temporary account, please retry',
'Parameter username can not be empty' => 'Parameter username cannot be empty',
'Token expiration' => 'Session expired, please login again.',
// Member center account
'Data updated successfully~' => 'Data updated successfully~',
'Captcha error' => 'Captcha error!',
'Data updated successfully~' => 'Profile updated successfully~',
'Password has been changed~' => 'Password has been changed~',
'Password has been changed, please login again~' => 'Password has been changed, please login again~',
'Password has been changed, please login again~' => 'Password changed, please login again~',
'already exists' => 'already exists',
'nicknameChsDash' => 'Usernames can only be Chinese characters, letters, numbers, underscores_ and dashes-.',
// PlayX API v1 /api/v1/*
'nicknameChsDash' => 'Username may only contain letters, numbers, underscores and dashes',
'Invalid token' => 'Invalid or expired token',
'PlayX API not configured' => 'PlayX API is not configured',
'PlayX verify token local default user not configured' => 'Local verify user is not configured; set PLAYX_VERIFY_TOKEN_LOCAL_DEFAULT_USER_ID',
'PlayX verify upstream failed' => 'Upstream token verification failed (HTTP %s): %s',
'Duplicate input' => 'Duplicate submission',
'Ok' => 'OK',
'Failed to map playx user to mall user' => 'Failed to map PlayX user to mall user',
@@ -41,17 +72,20 @@ return [
'Daily push signature missing or incomplete' => 'Daily push signature missing or incomplete',
'Daily push signature verification failed' => 'Daily push signature verification failed',
'Failed to ensure PlayX user asset' => 'Failed to ensure PlayX user asset',
'claim_request_id and user_id/session_id required' => 'claim_request_id and user_id/session_id/token are required',
'claim_request_id and user_id/session_id required' => 'claim_request_id and session_id/token are required',
'User asset not found' => 'User asset not found',
'No points to claim or limit reached' => 'No points to claim or daily limit reached',
'Claim success' => 'Claim successful',
'item_id and user_id/session_id required' => 'item_id and user_id/session_id/token are required',
'item_id and user_id/session_id required' => 'item_id and session_id/token are required',
'Item not found or not available' => 'Item not found or not available',
'Insufficient points' => 'Insufficient points',
'Administrator added %s points' => 'Administrator added %s points',
'Administrator deducted %s points' => 'Administrator deducted %s points',
'Redeem submitted, please wait about 10 minutes' => 'Redeem submitted, please wait about 10 minutes',
'Missing required fields' => 'Missing required fields',
'Shipping address not found' => 'Shipping address not found',
'Out of stock' => 'Out of stock',
'Record not found' => 'Record not found',
'Shipping address not found' => 'Shipping address not found',
'Redeem success' => 'Redeem successful',
'Withdraw submitted, please wait about 10 minutes' => 'Withdrawal submitted, please wait about 10 minutes',
];

View File

@@ -1,44 +0,0 @@
<?php
return [
'Install the controller' => 'Install the controller',
'need' => 'Need',
'Click to see how to solve it' => 'Click to see how to solve.',
'Please check the config directory permissions' => 'Please check the Config directory permissions',
'Please check the public directory permissions' => 'Please check the Public directory permissions',
'open' => 'Open',
'close' => 'Close',
'The installation can continue, and some operations need to be completed manually' => 'You can continue to install, and some operations need to be completed manually ',
'Allow execution' => 'Allow execution',
'disabled' => 'Disabled',
'Allow operation' => 'Allow operation',
'Acquisition failed' => 'Access failed',
'Click Install %s' => 'Click Install %s',
'Writable' => 'Writable',
'No write permission' => 'No write permissions',
'already installed' => 'Installed',
'Not installed' => 'Not installed',
'File has no write permission:%s' => 'File has no write permission:%s',
'The system has completed installation. If you need to reinstall, please delete the %file% file first' => 'The system has been installed. If you need to reinstall, please delete the %file% file first.',
'Database connection failed:%s' => 'Database connection failure%s',
'Failed to install SQL execution:%msg%' => 'Installation SQL execution failed: %msg%',
'unknown' => 'Unknown',
'Database does not exist' => 'Database does not exist!',
'No built front-end file found, please rebuild manually!' => 'No built front-end file found, please rebuild manually.',
'Failed to move the front-end file, please move it manually!' => 'Failed to move the front-end file, please move manually',
'How to solve?' => 'How to solve?',
'View reason' => 'View reasons',
'Click to view the reason' => 'Click to see the reason',
'PDO extensions need to be installed' => 'pdo_mysql extensions need to be installed.',
'proc_open or proc_close functions in PHP Ini is disabled' => 'proc_open and proc_close functions in PHP.Ini is disabled.',
'How to modify' => 'How to modify?',
'Click to view how to modify' => 'Click to see how to modify.',
'Security assurance?' => 'Security assurance?',
'Using the installation service correctly will not cause any potential security problems. Click to view the details' => 'The correct use of the installation service will not cause any potential security issues. Click to view the details.',
'Please install NPM first' => 'Please install NPM first.',
'Installation error:%s' => 'Installation error%s',
'Failed to switch package manager. Please modify the configuration file manually:%s' => 'Package manager switch failed, please modify the configuration file manually%s.',
'Please upgrade %s version' => 'Please upgrade the %s version',
'nothing' => 'Nothing',
'The gd extension and freeType library need to be installed' => 'The gd2 extension and freeType library need to be installed',
'The .env file with database configuration was detected. Please clean up and try again!' => 'The .env file with database configuration was detected. Please clean up and try again!',
];

92
app/api/lang/ms.php Normal file
View File

@@ -0,0 +1,92 @@
<?php
/**
* API 根级语言包(马来语 Bahasa Malaysia
* 键名与 zh-cn.php / en.php 保持一致,供 __() 使用。
*/
return [
'%d second%s ago' => '%d saat yang lalu',
'%d minute%s ago' => '%d minit yang lalu',
'%d hour%s ago' => '%d jam yang lalu',
'%d day%s ago' => '%d hari yang lalu',
'%d week%s ago' => '%d minggu yang lalu',
'%d month%s ago' => '%d bulan yang lalu',
'%d year%s ago' => '%d tahun yang lalu',
'%d second%s after' => '%d saat lagi',
'%d minute%s after' => '%d minit lagi',
'%d hour%s after' => '%d jam lagi',
'%d day%s after' => '%d hari lagi',
'%d week%s after' => '%d minggu lagi',
'%d month%s after' => '%d bulan lagi',
'%d year%s after' => '%d tahun lagi',
'File uploaded successfully' => 'Fail berjaya dimuat naik!',
'No files were uploaded' => 'Tiada fail dimuat naik',
'The uploaded file format is not allowed' => 'Format fail yang dimuat naik tidak dibenarkan',
'The uploaded file contains prohibited content' => 'Fail yang dimuat naik mengandungi kandungan yang dilarang',
'The uploaded image file is not a valid image' => 'Fail imej yang dimuat naik tidak sah',
'The uploaded file is too large (%sMiB), Maximum file size:%sMiB' => 'Fail terlalu besar (%sM), saiz maksimum: %sM',
'No files have been uploaded or the file size exceeds the upload limit of the server' => 'Tiada fail dimuat naik atau saiz melebihi had pelayan',
'Topic format error' => 'Ralat format topik storan',
'Driver %s not supported' => 'Pemandu tidak disokong: %s',
'Username' => 'Nama pengguna',
'Email' => 'E-mel',
'Mobile' => 'Telefon bimbit',
'Password' => 'Kata laluan',
'Login expired, please login again.' => 'Log masuk tamat tempoh, sila log masuk semula.',
'Account not exist' => 'Akaun tidak wujud',
'Account disabled' => 'Akaun dilumpuhkan',
'Token login failed' => 'Log masuk token gagal',
'Please try again after 1 day' => 'Percubaan gagal terlalu kerap, sila cuba semula selepas 24 jam',
'Password is incorrect' => 'Kata laluan tidak betul',
'You are not logged in' => 'Anda belum log masuk',
'Unknown operation' => 'Operasi tidak diketahui',
'No action available, please contact the administrator~' => 'Tiada tindakan tersedia, sila hubungi pentadbir.',
'Please login first' => 'Sila log masuk dahulu!',
'You have no permission' => 'Tiada kebenaran!',
'Parameter error' => 'Ralat parameter!',
'Parameter %s can not be empty' => 'Parameter %s tidak boleh kosong',
'Invalid timestamp' => 'Cap masa tidak sah',
'Timestamp expired' => 'Cap masa tamat tempoh',
'Invalid agent or secret' => 'Ejen atau rahsia tidak sah',
'Invalid signature' => 'Tandatangan tidak sah',
'Agent not found' => 'Ejen tidak dijumpai',
'Temp login is disabled' => 'Log masuk sementara dilumpuhkan',
'Failed to create temp account' => 'Gagal mencipta akaun sementara, sila cuba lagi',
'Parameter username can not be empty' => 'Parameter username tidak boleh kosong',
'Token expiration' => 'Sesi tamat, sila log masuk semula!',
'Captcha error' => 'Ralat captcha!',
'Data updated successfully~' => 'Data berjaya dikemas kini~',
'Password has been changed~' => 'Kata laluan telah ditukar~',
'Password has been changed, please login again~' => 'Kata laluan ditukar, sila log masuk semula~',
'already exists' => 'sudah wujud',
'nicknameChsDash' => 'Nama pengguna hanya huruf, nombor, garis bawah dan sempang',
'Invalid token' => 'Token tidak sah atau tamat tempoh',
'PlayX API not configured' => 'API PlayX tidak dikonfigurasi',
'PlayX verify token local default user not configured' => 'Pengguna verifikasi tempatan tidak dikonfigurasi; tetapkan PLAYX_VERIFY_TOKEN_LOCAL_DEFAULT_USER_ID',
'PlayX verify upstream failed' => 'Pengesahan token hulu gagal (HTTP %s): %s',
'Duplicate input' => 'Penghantaran pendua',
'Ok' => 'OK',
'Failed to map playx user to mall user' => 'Gagal memetakan pengguna PlayX ke pengguna mall',
'Missing required fields: request_id, date, user_id' => 'Medan wajib tiada: request_id, date, user_id',
'Missing required fields: report_date, member' => 'Medan wajib tiada: report_date, member',
'Missing required fields: member_id' => 'Medan wajib tiada: member_id',
'Daily push signature missing or incomplete' => 'Tandatangan push harian tiada atau tidak lengkap',
'Daily push signature verification failed' => 'Pengesahan tandatangan push harian gagal',
'Failed to ensure PlayX user asset' => 'Gagal mencipta/memeta aset pengguna',
'claim_request_id and user_id/session_id required' => 'claim_request_id dan session_id/token diperlukan',
'User asset not found' => 'Aset pengguna tidak dijumpai',
'No points to claim or limit reached' => 'Tiada mata untuk dituntut atau had harian dicapai',
'Claim success' => 'Tuntutan berjaya',
'item_id and user_id/session_id required' => 'item_id dan session_id/token diperlukan',
'Item not found or not available' => 'Item tidak dijumpai atau tidak tersedia',
'Insufficient points' => 'Mata tidak mencukupi',
'Administrator added %s points' => 'Pentadbir menambah %s mata',
'Administrator deducted %s points' => 'Pentadbir menolak %s mata',
'Redeem submitted, please wait about 10 minutes' => 'Penebusan dihantar, sila tunggu kira-kira 10 minit',
'Missing required fields' => 'Medan wajib tiada',
'Out of stock' => 'Stok tidak mencukupi',
'Record not found' => 'Rekod tidak dijumpai',
'Shipping address not found' => 'Alamat penghantaran tidak dijumpai',
'Redeem success' => 'Penebusan berjaya',
'Withdraw submitted, please wait about 10 minutes' => 'Permohonan pengeluaran dihantar, sila tunggu kira-kira 10 minit',
];

View File

@@ -20,6 +20,7 @@ return [
'File uploaded successfully' => '文件上传成功!',
'No files were uploaded' => '没有文件被上传',
'The uploaded file format is not allowed' => '上传的文件格式未被允许',
'The uploaded file contains prohibited content' => '上传的文件包含禁止的内容',
'The uploaded image file is not a valid image' => '上传的图片文件不是有效的图像',
'The uploaded file is too large (%sMiB), Maximum file size:%sMiB' => '上传的文件太大(%sM),最大文件大小:%sM',
'No files have been uploaded or the file size exceeds the upload limit of the server' => '没有文件被上传或文件大小超出服务器上传限制!',
@@ -43,7 +44,6 @@ return [
'You have no permission' => '没有权限操作!',
'Parameter error' => '参数错误!',
'Parameter %s can not be empty' => '参数%s不能为空',
'Parameter signature/secret/agent_id/time can not be empty' => '参数 signature/secret/agent_id/time 不能为空',
'Invalid timestamp' => '无效的时间戳',
'Timestamp expired' => '时间戳已过期',
'Invalid agent or secret' => '代理或密钥无效',
@@ -63,6 +63,8 @@ return [
// PlayX API v1 /api/v1/*
'Invalid token' => '令牌无效或已过期',
'PlayX API not configured' => '未配置 PlayX 接口地址',
'PlayX verify token local default user not configured' => '未配置本地联调用户,请设置 PLAYX_VERIFY_TOKEN_LOCAL_DEFAULT_USER_ID',
'PlayX verify upstream failed' => '上游 Token 校验失败HTTP %s%s',
'Duplicate input' => '重复提交',
'Ok' => '成功',
'Failed to map playx user to mall user' => '无法将 PlayX 用户关联到商城用户',
@@ -72,13 +74,15 @@ return [
'Daily push signature missing or incomplete' => '签名缺失或不完整',
'Daily push signature verification failed' => '签名校验失败',
'Failed to ensure PlayX user asset' => '创建/映射用户资产失败',
'claim_request_id and user_id/session_id required' => '缺少 claim_request_id或未提供有效的 user_id/session_id/token',
'claim_request_id and user_id/session_id required' => '缺少 claim_request_id或未提供有效的 session_id/token',
'User asset not found' => '未找到用户资产',
'No points to claim or limit reached' => '暂无可领取积分或已达今日上限',
'Claim success' => '领取成功',
'item_id and user_id/session_id required' => '缺少 item_id或未提供有效的 user_id/session_id/token',
'item_id and user_id/session_id required' => '缺少 item_id或未提供有效的 session_id/token',
'Item not found or not available' => '商品不存在或已下架',
'Insufficient points' => '积分不足',
'Administrator added %s points' => '管理员增加积分 %s',
'Administrator deducted %s points' => '管理员扣除积分 %s',
'Redeem submitted, please wait about 10 minutes' => '兑换已提交,请等待约 10 分钟',
'Missing required fields' => '缺少必填字段',
'Out of stock' => '库存不足',

View File

@@ -1,44 +0,0 @@
<?php
return [
'Install the controller' => '安装控制器',
'need' => '需要',
'Click to see how to solve it' => '点击查看如何解决',
'Please check the config directory permissions' => '请检查 config 目录权限',
'Please check the public directory permissions' => '请检查 public 目录权限',
'open' => '开启',
'close' => '关闭',
'The installation can continue, and some operations need to be completed manually' => '可以继续安装,部分操作需手动完成',
'Allow execution' => '允许执行',
'disabled' => '已禁用',
'Allow operation' => '允许操作',
'Acquisition failed' => '获取失败',
'Click Install %s' => '点击安装%s',
'Writable' => '可写',
'No write permission' => '无写权限',
'already installed' => '已安装',
'Not installed' => '未安装',
'File has no write permission:%s' => '文件无写入权限:%s',
'The system has completed installation. If you need to reinstall, please delete the %file% file first' => '系统已完成安装。如果需要重新安装,请先删除 %file% 文件',
'Database connection failed:%s' => '数据库连接失败:%s',
'Failed to install SQL execution:%msg%' => '安装SQL执行失败%msg%',
'unknown' => '未知',
'Database does not exist' => '数据库不存在!',
'No built front-end file found, please rebuild manually!' => '没有找到构建好的前端文件,请手动重新构建!',
'Failed to move the front-end file, please move it manually!' => '移动前端文件失败,请手动移动!',
'How to solve?' => '如何解决?',
'View reason' => '查看原因',
'Click to view the reason' => '点击查看原因',
'PDO extensions need to be installed' => '需要安装 pdo_mysql 扩展',
'proc_open or proc_close functions in PHP Ini is disabled' => 'proc_open和proc_close函数在php.ini中被禁用掉了',
'How to modify' => '如何修改',
'Click to view how to modify' => '点击查看如何修改',
'Security assurance?' => '安全保证?',
'Using the installation service correctly will not cause any potential security problems. Click to view the details' => '安装服务使用正确不会造成任何潜在安全问题,点击查看详情',
'Please install NPM first' => '请先安装npm',
'Installation error:%s' => '安装出错:%s',
'Failed to switch package manager. Please modify the configuration file manually:%s' => '包管理器切换失败,请手动修改配置文件:%s',
'Please upgrade %s version' => '请升级%s版本',
'nothing' => '无',
'The gd extension and freeType library need to be installed' => '需要gd2扩展和freeType库',
'The .env file with database configuration was detected. Please clean up and try again!' => '检测到带有数据库配置的 .env 文件。请清理后再试一次!',
];

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace app\command;
use app\common\library\MallDailyPushBackfill;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand('mall:daily-push:backfill-users', '根据历史每日推送回补用户信息')]
class MallDailyPushBackfillUsers extends Command
{
protected function configure(): void
{
$this->addOption('from', null, InputOption::VALUE_OPTIONAL, '开始日期(含),格式 YYYY-MM-DD');
$this->addOption('to', null, InputOption::VALUE_OPTIONAL, '结束日期(含),格式 YYYY-MM-DD');
$this->addOption('limit', null, InputOption::VALUE_OPTIONAL, '最多处理用户数(按 user_id 去重后)', '0');
$this->addOption('dry-run', null, InputOption::VALUE_NONE, '仅预览,不落库');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$dateFrom = strval($input->getOption('from') ?? '');
$dateTo = strval($input->getOption('to') ?? '');
$limitRaw = strval($input->getOption('limit') ?? '0');
$dryRun = boolval($input->getOption('dry-run'));
if ($dateFrom !== '' && !$this->isValidDate($dateFrom)) {
$output->writeln('<error>参数 --from 日期格式错误,需为 YYYY-MM-DD</error>');
return self::FAILURE;
}
if ($dateTo !== '' && !$this->isValidDate($dateTo)) {
$output->writeln('<error>参数 --to 日期格式错误,需为 YYYY-MM-DD</error>');
return self::FAILURE;
}
if (!is_numeric($limitRaw)) {
$output->writeln('<error>参数 --limit 必须是数字</error>');
return self::FAILURE;
}
$limit = intval($limitRaw);
if ($limit < 0) {
$output->writeln('<error>参数 --limit 不能小于 0</error>');
return self::FAILURE;
}
$service = new MallDailyPushBackfill();
$result = $service->backfill(
$dateFrom === '' ? null : $dateFrom,
$dateTo === '' ? null : $dateTo,
$limit,
$dryRun
);
$output->writeln('<info>DailyPush 用户回补完成</info>');
$output->writeln('dry_run: ' . ($result['dry_run'] ? 'true' : 'false'));
$output->writeln('scanned_rows: ' . strval($result['scanned_rows']));
$output->writeln('target_users: ' . strval($result['target_users']));
$output->writeln('processed_users: ' . strval($result['processed_users']));
$output->writeln('created_users: ' . strval($result['created_users']));
$output->writeln('updated_users: ' . strval($result['updated_users']));
$output->writeln('unchanged_users: ' . strval($result['unchanged_users']));
$output->writeln('failed_users: ' . strval($result['failed_users']));
$errors = $result['errors'];
if (is_array($errors) && !empty($errors)) {
$output->writeln('<comment>错误明细:</comment>');
foreach ($errors as $err) {
$uid = strval($err['user_id'] ?? '');
$msg = strval($err['message'] ?? '');
$output->writeln("- user_id={$uid}, message={$msg}");
}
}
return self::SUCCESS;
}
private function isValidDate(string $value): bool
{
$dt = \DateTime::createFromFormat('Y-m-d', $value);
if (!$dt) {
return false;
}
return $dt->format('Y-m-d') === $value;
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace app\command;
use app\common\library\MallDailyPushLogReplay;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Throwable;
#[AsCommand('mall:daily-push:replay-log', '从原始日志幂等回放每日推送数据')]
class MallDailyPushReplayLog extends Command
{
protected function configure(): void
{
$this->addOption('file', null, InputOption::VALUE_REQUIRED, 'daily_push_*.log 文件路径');
$this->addOption('dry-run', null, InputOption::VALUE_NONE, '仅预检,不修改数据库');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$file = trim(strval($input->getOption('file') ?? ''));
if ($file === '') {
$output->writeln('<error>必须提供 --file 日志文件路径</error>');
return self::FAILURE;
}
$dryRun = boolval($input->getOption('dry-run'));
try {
$result = (new MallDailyPushLogReplay())->replay($file, $dryRun);
} catch (Throwable $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
return self::FAILURE;
}
$output->writeln('<info>DailyPush 日志回放' . ($dryRun ? '预检' : '完成') . '</info>');
foreach ([
'dry_run',
'log_file',
'backup_file',
'lines',
'members',
'unique_members',
'daily_created',
'daily_updated',
'daily_unchanged',
'assets_created',
'assets_updated',
'locked_points_delta',
'invalid_lines',
'failed_members',
] as $key) {
$value = $result[$key] ?? '';
if (is_bool($value)) {
$value = $value ? 'true' : 'false';
}
$output->writeln($key . ': ' . strval($value));
}
$errors = $result['errors'] ?? [];
if (is_array($errors) && $errors !== []) {
$output->writeln('<comment>错误明细(最多显示 100 条):</comment>');
foreach ($errors as $error) {
$output->writeln('- ' . strval($error));
}
}
return intval($result['failed_members'] ?? 0) > 0 || intval($result['invalid_lines'] ?? 0) > 0
? self::FAILURE
: self::SUCCESS;
}
}

View File

@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace app\common\library;
use ba\Random;
use app\common\facade\Token;
use RobThree\Auth\TwoFactorAuth;
use RobThree\Auth\Providers\Qr\BaconQrCodeProvider;
/**
* 管理员谷歌验证器TOTP
*/
class AdminTotp
{
public const TOKEN_TYPE_BIND = 'admin-totp-bind';
public const TOKEN_TYPE_VERIFY = 'admin-totp-verify';
public const TOKEN_EXPIRE = 300;
private static ?TwoFactorAuth $tfa = null;
public static function isEnabled(): bool
{
return (bool) config('buildadmin.admin_totp_enable', true);
}
private static function tfa(): TwoFactorAuth
{
if (self::$tfa === null) {
$issuer = (string) get_sys_config('site_name');
if ($issuer === '') {
$issuer = 'BuildAdmin';
}
// 使用 SVG 格式,避免依赖 imagick 扩展PNG 默认需要 ImagickImageBackEnd
self::$tfa = new TwoFactorAuth(new BaconQrCodeProvider(format: 'svg'), $issuer);
}
return self::$tfa;
}
public static function isBound(?string $encryptedSecret): bool
{
return is_string($encryptedSecret) && $encryptedSecret !== '';
}
public static function generateSecret(): string
{
return self::tfa()->createSecret();
}
public static function getQrDataUri(string $label, string $secret): string
{
return self::tfa()->getQRCodeImageAsDataUri($label, $secret);
}
public static function verifyCode(string $plainSecret, string $code, int $discrepancy = 2): bool
{
$code = trim($code);
if (!preg_match('/^\d{6}$/', $code)) {
return false;
}
return self::tfa()->verifyCode($plainSecret, $code, $discrepancy);
}
public static function encryptSecret(string $plainSecret): string
{
$key = substr(hash('sha256', (string) config('buildadmin.token.key')), 0, 32);
$iv = random_bytes(16);
$encrypted = openssl_encrypt($plainSecret, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
if ($encrypted === false) {
return '';
}
return base64_encode($iv . $encrypted);
}
public static function decryptSecret(string $encryptedSecret): string
{
if ($encryptedSecret === '') {
return '';
}
$raw = base64_decode($encryptedSecret, true);
if ($raw === false || strlen($raw) < 17) {
return '';
}
$iv = substr($raw, 0, 16);
$encrypted = substr($raw, 16);
$key = substr(hash('sha256', (string) config('buildadmin.token.key')), 0, 32);
$plain = openssl_decrypt($encrypted, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
return $plain === false ? '' : $plain;
}
public static function verifyStoredCode(string $encryptedSecret, string $code): bool
{
$plain = self::decryptSecret($encryptedSecret);
if ($plain === '') {
return false;
}
return self::verifyCode($plain, $code);
}
public static function createPendingToken(int $adminId, string $type): string
{
$token = Random::uuid();
Token::set($token, $type, $adminId, self::TOKEN_EXPIRE);
return $token;
}
public static function resolvePendingToken(string $token, string $type): int
{
$token = trim($token);
if ($token === '') {
return 0;
}
$data = Token::get($token);
if (!$data || ($data['type'] ?? '') !== $type) {
return 0;
}
Token::tokenExpirationCheck($data);
return (int) ($data['user_id'] ?? 0);
}
public static function deletePendingToken(string $token): void
{
if ($token !== '') {
Token::delete($token);
}
}
}

View File

@@ -1,67 +0,0 @@
<?php
declare(strict_types=1);
namespace app\common\library;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Firebase\JWT\ExpiredException;
use Firebase\JWT\SignatureInvalidException;
/**
* Agent 鉴权 JWT 工具
*/
class AgentJwt
{
public const ALG = 'HS256';
/**
* 生成 JWT authtoken
* @param array $payload agent_id、admin_id 等
* @param int $expire 有效期(秒)
*/
public static function encode(array $payload, int $expire = 86400): string
{
$now = time();
$payload['iat'] = $now;
$payload['exp'] = $now + $expire;
$secret = self::getSecret();
return JWT::encode($payload, $secret, self::ALG);
}
/**
* 解析并验证 JWT返回 payload
* @return array payload失败返回空数组
*/
public static function decode(string $token): array
{
if ($token === '') {
return [];
}
try {
$secret = self::getSecret();
$decoded = JWT::decode($token, new Key($secret, self::ALG));
return (array) $decoded;
} catch (ExpiredException|SignatureInvalidException|\Throwable) {
return [];
}
}
/**
* 验证 JWT 是否有效
*/
public static function verify(string $token): bool
{
return !empty(self::decode($token));
}
private static function getSecret(): string
{
$secret = config('buildadmin.agent_auth.jwt_secret', '');
if ($secret === '') {
$secret = config('buildadmin.token.key', '');
}
return $secret;
}
}

View File

@@ -6,7 +6,9 @@ namespace app\common\library;
use app\common\model\MallItem;
use app\common\model\MallOrder;
use app\common\model\MallUserAsset;
use GuzzleHttp\Client;
use support\Log;
/**
* 红利订单调用 PlayX bonus/grant与定时任务、后台手动推送共用
@@ -14,72 +16,225 @@ use GuzzleHttp\Client;
final class MallBonusGrantPush
{
/**
* @return array{ok: bool, message: string, playx_transaction_id: string}
* @return array{ok: bool, message: string}
*/
public static function push(MallOrder $order): array
{
$baseUrl = rtrim(strval(config('playx.api.base_url', '')), '/');
if ($baseUrl === '') {
$conf = config('playx.angpow_import');
if (!is_array($conf)) {
return [
'ok' => false,
'message' => 'PlayX base_url not configured',
'playx_transaction_id' => '',
'message' => 'playX angpow_import not configured',
];
}
$baseUrl = rtrim(strval($conf['base_url'] ?? ''), '/');
$path = strval($conf['path'] ?? '');
$merchantCode = strval($conf['merchant_code'] ?? '');
$authKey = strval($conf['auth_key'] ?? '');
if ($baseUrl === '' || $path === '' || $merchantCode === '' || $authKey === '') {
return [
'ok' => false,
'message' => 'playX Angpow Import API not configured',
];
}
$path = strval(config('playx.api.bonus_grant_url', '/api/v1/bonus/grant'));
$url = $baseUrl . $path;
$item = MallItem::where('id', $order->mall_item_id)->find();
$rewardName = $item ? strval($item->title) : '';
$category = $item ? strval($item->category) : 'daily';
$categoryTitle = $item ? strval($item->category_title) : '';
$multiplier = intval($order->multiplier ?? 0);
if ($multiplier <= 0) {
$multiplier = 1;
$asset = MallUserAsset::where('playx_user_id', $order->user_id)->find();
if (!$asset || !is_string($asset->playx_user_id ?? null) || strval($asset->playx_user_id) === '') {
return [
'ok' => false,
'message' => 'User asset not found',
];
}
$memberLogin = trim(strval($asset->username ?? ''));
if ($memberLogin === '') {
return [
'ok' => false,
'message' => 'User username empty',
];
}
$item = MallItem::where('id', $order->mall_item_id)->find();
if (!$item) {
return [
'ok' => false,
'message' => 'Item not found',
];
}
$reportDate = strval(time());
$signatureInput = 'merchant_code=' . $merchantCode . '&report_date=' . $reportDate;
$signature = self::buildSignature($signatureInput, $authKey);
if ($signature === null) {
return [
'ok' => false,
'message' => 'Build signature failed',
];
}
$start = gmdate('Y-m-d\TH:i:s\Z', strtotime(strval($order->start_time)));
$end = gmdate('Y-m-d\TH:i:s\Z', strtotime(strval($order->end_time)));
$payload = [
'merchant_code' => $merchantCode,
'report_date' => $reportDate,
'angpow' => [
[
'member_login' => $memberLogin,
'start_time' => $start,
'end_time' => $end,
'amount' => $order->amount,
'reward_name' => strval($item->title ?? ''),
'description' => strval($item->description ?? ''),
'member_inbox_message' => 'Congratulations! You received an angpow.',
'category' => strval($item->category ?? ''),
'category_title' => strval($item->category_title ?? ''),
],
],
'currency_visual' => [
[
'currency' => strval($conf['currency'] ?? 'MYR'),
'visual_name' => strval($conf['visual_name'] ?? 'Angpow'),
],
],
];
$client = new Client([
'timeout' => 20,
'http_errors' => false,
]);
$requestId = 'mall_bonus_' . uniqid();
try {
$res = $client->post($url, [
'json' => [
'request_id' => $requestId,
'externalTransactionId' => $order->external_transaction_id,
'user_id' => $order->user_id,
'amount' => $order->amount,
'rewardName' => $rewardName,
'category' => $category,
'categoryTitle' => $categoryTitle,
'multiplier' => $multiplier,
'headers' => [
'Content-Type' => 'application/json',
'X-Request-Signature' => $signature,
],
'json' => $payload,
]);
$data = json_decode(strval($res->getBody()), true) ?? [];
if ($res->getStatusCode() === 200 && ($data['status'] ?? '') === 'accepted') {
$httpStatus = 0;
if (is_object($res) && method_exists($res, 'getStatusCode')) {
$sc = $res->getStatusCode();
if (is_int($sc)) {
$httpStatus = $sc;
}
}
$rawBody = strval($res->getBody());
$data = json_decode($rawBody, true);
if (!is_array($data)) {
$jsonDetail = 'root not JSON object';
if (json_last_error() !== JSON_ERROR_NONE) {
$jem = json_last_error_msg();
$jsonDetail = is_string($jem) && $jem !== '' ? $jem : 'JSON parse error';
}
Log::error(
'[MallBonusGrantPush] response not a JSON object | order_id=' . $order->id
. ' | http=' . $httpStatus . ' | ' . $jsonDetail . ' | body=' . self::truncateForLog($rawBody)
);
return [
'ok' => false,
'message' => 'Invalid response',
];
}
if (($data['code'] ?? null) === '0' || ($data['code'] ?? null) === 0) {
return [
'ok' => true,
'message' => '',
'playx_transaction_id' => strval($data['playx_transaction_id'] ?? ''),
];
}
$remoteMsg = $data['message'] ?? $data['msg'] ?? '';
if (!is_string($remoteMsg)) {
$remoteMsg = '';
}
if ($remoteMsg === '') {
$remoteMsg = 'playX angpow import not accepted';
}
$flags = JSON_UNESCAPED_UNICODE;
if (defined('JSON_INVALID_UTF8_SUBSTITUTE')) {
$flags = $flags | JSON_INVALID_UTF8_SUBSTITUTE;
}
$encoded = json_encode($data, $flags);
if (!is_string($encoded)) {
$encoded = $rawBody;
}
Log::error(
'[MallBonusGrantPush] angpow import rejected | order_id=' . $order->id
. ' | http=' . $httpStatus . ' | parsed=' . self::truncateForLog($encoded)
);
return [
'ok' => false,
'message' => strval($data['message'] ?? 'PlayX bonus grant not accepted'),
'playx_transaction_id' => '',
'message' => $remoteMsg,
];
} catch (\Throwable $e) {
Log::error('[MallBonusGrantPush] request exception | order_id=' . $order->id . ' | ' . $e->getMessage());
return [
'ok' => false,
'message' => $e->getMessage(),
'playx_transaction_id' => '',
];
}
}
private static function truncateForLog(string $text, int $maxLen = 8000): string
{
$s = trim($text);
if ($s === '') {
return '';
}
$oneLine = preg_replace('/\s+/', ' ', $s);
$snippet = is_string($oneLine) && $oneLine !== '' ? $oneLine : $s;
if (function_exists('mb_strlen') && function_exists('mb_substr')) {
if (mb_strlen($snippet, 'UTF-8') > $maxLen) {
return mb_substr($snippet, 0, $maxLen, 'UTF-8') . '…';
}
return $snippet;
}
if (strlen($snippet) > $maxLen) {
return substr($snippet, 0, $maxLen) . '…';
}
return $snippet;
}
private static function buildSignature(string $input, string $authKey): ?string
{
$keyBytes = null;
$maybeBase64 = base64_decode($authKey, true);
if ($maybeBase64 !== false && $maybeBase64 !== '') {
$keyBytes = $maybeBase64;
}
if ($keyBytes === null) {
$isHex = ctype_xdigit($authKey) && (strlen($authKey) % 2 === 0);
if ($isHex) {
$hex = hex2bin($authKey);
if ($hex !== false && $hex !== '') {
$keyBytes = $hex;
}
}
}
if ($keyBytes === null) {
$keyBytes = $authKey;
}
$raw = hash_hmac('sha1', $input, $keyBytes, true);
if (!is_string($raw) || $raw === '') {
return null;
}
return base64_encode($raw);
}
}

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace app\common\library;
use app\common\model\MallDailyPush;
use app\common\model\MallUserAsset;
use ba\Random;
use Throwable;
/**
* 基于历史每日推送记录回补/同步用户资产主信息。
*/
class MallDailyPushBackfill
{
/**
* @return array<string, mixed>
*/
public function backfill(?string $dateFrom, ?string $dateTo, int $limit = 0, bool $dryRun = false): array
{
$query = MallDailyPush::order('create_time', 'desc')->order('id', 'desc');
if ($dateFrom !== null && $dateFrom !== '') {
$query->where('date', '>=', $dateFrom);
}
if ($dateTo !== null && $dateTo !== '') {
$query->where('date', '<=', $dateTo);
}
$rows = $query->select();
$seenUserIds = [];
$stats = [
'dry_run' => $dryRun,
'date_from' => $dateFrom,
'date_to' => $dateTo,
'limit' => $limit,
'scanned_rows' => 0,
'target_users' => 0,
'processed_users' => 0,
'created_users' => 0,
'updated_users' => 0,
'unchanged_users' => 0,
'failed_users' => 0,
'errors' => [],
];
foreach ($rows as $row) {
$stats['scanned_rows']++;
$playxUserId = trim(strval($row->user_id ?? ''));
if ($playxUserId === '' || isset($seenUserIds[$playxUserId])) {
continue;
}
$seenUserIds[$playxUserId] = true;
$stats['target_users']++;
if ($limit > 0 && $stats['target_users'] > $limit) {
break;
}
$username = trim(strval($row->username ?? ''));
try {
$result = $this->ensureAssetForPlayx($playxUserId, $username, $dryRun);
$stats['processed_users']++;
if ($result['created']) {
$stats['created_users']++;
} elseif ($result['updated']) {
$stats['updated_users']++;
} else {
$stats['unchanged_users']++;
}
} catch (Throwable $e) {
$stats['failed_users']++;
$stats['errors'][] = [
'user_id' => $playxUserId,
'message' => $e->getMessage(),
];
}
}
return $stats;
}
/**
* @return array{created:bool,updated:bool}
*/
private function ensureAssetForPlayx(string $playxUserId, string $username, bool $dryRun): array
{
$asset = MallUserAsset::where('playx_user_id', $playxUserId)->find();
if ($asset) {
$updated = false;
$uname = trim($username);
if ($uname !== '' && strval($asset->username ?? '') !== $uname) {
$asset->username = $uname;
$updated = true;
}
if ($updated && !$dryRun) {
$asset->save();
}
return ['created' => false, 'updated' => $updated];
}
$effectiveUsername = trim($username);
if ($effectiveUsername === '') {
$effectiveUsername = 'playx_' . $playxUserId;
}
$byName = MallUserAsset::where('username', $effectiveUsername)->find();
if ($byName) {
$updated = strval($byName->playx_user_id ?? '') !== $playxUserId;
if ($updated && !$dryRun) {
$byName->playx_user_id = $playxUserId;
$byName->save();
}
return ['created' => false, 'updated' => $updated];
}
if ($dryRun) {
return ['created' => true, 'updated' => false];
}
$pwd = hash_password(Random::build('alnum', 16));
$now = time();
$created = MallUserAsset::create([
'playx_user_id' => $playxUserId,
'username' => $effectiveUsername,
'phone' => '',
'password' => $pwd,
'admin_id' => 0,
'locked_points' => 0,
'available_points' => 0,
'today_limit' => 0,
'today_claimed' => 0,
'today_limit_date' => null,
'create_time' => $now,
'update_time' => $now,
]);
if (!$created) {
throw new \RuntimeException('Failed to create mall_user_asset');
}
return ['created' => true, 'updated' => false];
}
}

View File

@@ -0,0 +1,224 @@
<?php
declare(strict_types=1);
namespace app\common\library;
use app\common\model\MallDailyPush;
use OpenSpout\Common\Entity\Row;
use OpenSpout\Writer\XLSX\Options;
use OpenSpout\Writer\XLSX\Writer;
use support\Response;
use Throwable;
/**
* 每日推送数据导出OpenSpout 流式 XLSX
*/
class MallDailyPushExport
{
/** 0 表示不限制条数,导出全部匹配数据 */
public const UNLIMITED_EXPORT = 0;
public const CHUNK_SIZE = 2000;
/**
* @var array<string, string>
*/
private array $fieldLabels = [
'id' => 'ID',
'user_id' => 'playX-ID',
'date' => '业务日期',
'username' => '用户名',
'yesterday_win_loss_net' => '昨日净输赢',
'converted_points' => '转换积分',
'yesterday_total_deposit' => '昨日总充值',
'lifetime_total_deposit' => '终身总充值',
'lifetime_total_withdraw' => '终身总提现',
'lifetime_win_loss_net' => '终身总输赢',
'create_time' => '创建时间',
];
/**
* @var array<string, string>
*/
private array $fieldLabelsEn = [
'id' => 'ID',
'user_id' => 'PlayX user ID',
'date' => 'Business date',
'username' => 'Username',
'yesterday_win_loss_net' => 'Yesterday net win/loss',
'converted_points' => 'Converted points',
'yesterday_total_deposit' => 'Yesterday total deposit',
'lifetime_total_deposit' => 'Lifetime total deposit',
'lifetime_total_withdraw' => 'Lifetime total withdraw',
'lifetime_win_loss_net' => 'Lifetime net win/loss',
'create_time' => 'Created at',
];
/**
* @param array<int, string> $fields
*/
public function export(
MallDailyPush $model,
array $where,
array $alias,
array $order,
array $fields,
int $limit,
string $lang = 'zh-cn'
): Response {
$fields = $this->normalizeFields($fields);
if ($fields === []) {
throw new \InvalidArgumentException(__('Parameter error'));
}
if ($limit < 0) {
throw new \InvalidArgumentException(__('Parameter error'));
}
@set_time_limit(0);
$exportDir = runtime_path('export');
if (!is_dir($exportDir) && !mkdir($exportDir, 0755, true) && !is_dir($exportDir)) {
throw new \RuntimeException('Failed to create export directory');
}
$filepath = $exportDir . DIRECTORY_SEPARATOR . 'daily_push_' . date('YmdHis') . '_' . bin2hex(random_bytes(4)) . '.xlsx';
$labels = $this->resolveFieldLabels($lang);
$options = new Options();
$options->setTempFolder($exportDir);
$writer = new Writer($options);
try {
$writer->openToFile($filepath);
$writer->addRow(Row::fromValues($this->buildHeaderRow($fields, $labels)));
$exported = 0;
$page = 1;
$unlimited = $limit === self::UNLIMITED_EXPORT;
while ($unlimited || $exported < $limit) {
$batchSize = $unlimited ? self::CHUNK_SIZE : min(self::CHUNK_SIZE, $limit - $exported);
$rows = $model
->field($fields)
->alias($alias)
->where($where)
->order($order)
->page($page, $batchSize)
->select();
if ($rows->isEmpty()) {
break;
}
foreach ($rows as $row) {
$writer->addRow(Row::fromValues($this->formatRow($row->toArray(), $fields)));
$exported++;
if (!$unlimited && $exported >= $limit) {
break;
}
}
if ($rows->count() < $batchSize) {
break;
}
$page++;
}
$writer->close();
} catch (Throwable $e) {
try {
$writer->close();
} catch (Throwable) {
// ignore
}
if (is_file($filepath)) {
@unlink($filepath);
}
throw $e;
}
register_shutdown_function(static function () use ($filepath): void {
if (is_file($filepath)) {
@unlink($filepath);
}
});
$filename = 'daily_push_' . date('YmdHis') . '.xlsx';
return (new Response())->file($filepath, $filename);
}
/**
* @param array<int, string> $fields
* @return array<int, string>
*/
public function normalizeFields(array $fields): array
{
$allowed = array_keys($this->fieldLabels);
$normalized = [];
foreach ($fields as $field) {
$field = trim(strval($field));
if ($field !== '' && in_array($field, $allowed, true) && !in_array($field, $normalized, true)) {
$normalized[] = $field;
}
}
return $normalized;
}
/**
* @return array<int, string>
*/
public function getDefaultFields(): array
{
return array_keys($this->fieldLabels);
}
public function getMaxExportLimit(): int
{
return self::UNLIMITED_EXPORT;
}
/**
* @param array<int, string> $fields
* @param array<string, string> $labels
* @return array<int, string>
*/
private function buildHeaderRow(array $fields, array $labels): array
{
$header = [];
foreach ($fields as $field) {
$header[] = $labels[$field] ?? $field;
}
return $header;
}
/**
* @param array<string, mixed> $row
* @param array<int, string> $fields
* @return array<int, string>
*/
private function formatRow(array $row, array $fields): array
{
$line = [];
foreach ($fields as $field) {
$value = $row[$field] ?? '';
if ($field === 'create_time' && $value !== '' && $value !== null) {
$timestamp = is_numeric($value) ? intval($value) : strtotime(strval($value));
$line[] = $timestamp > 0 ? date('Y-m-d H:i:s', $timestamp) : '';
continue;
}
$line[] = $value === null ? '' : strval($value);
}
return $line;
}
/**
* @return array<string, string>
*/
private function resolveFieldLabels(string $lang): array
{
$lang = strtolower(str_replace('_', '-', $lang));
return $lang === 'en' ? $this->fieldLabelsEn : $this->fieldLabels;
}
}

View File

@@ -0,0 +1,515 @@
<?php
declare(strict_types=1);
namespace app\common\library;
use app\common\model\MallBusinessConfig;
use app\common\model\MallDailyPush;
use app\common\model\MallUserAsset;
use ba\Random;
use RuntimeException;
use support\think\Db;
use Throwable;
/**
* 将 daily_push_*.log 中保存的第三方原始推送幂等回放到业务表。
*/
class MallDailyPushLogReplay
{
private const QUERY_CHUNK_SIZE = 1000;
/**
* @return array<string, mixed>
*/
public function replay(string $logFile, bool $dryRun = false): array
{
$stats = $this->newStats($logFile, $dryRun);
$records = $this->parseRecords($logFile, $stats);
$stats['unique_members'] = count($records);
[$dailyMap, $assetByPlayxId, $assetByUsername] = $this->preloadExistingData($records);
$configRow = MallBusinessConfig::ensureRow();
$pointsConfig = MallPointsConversion::configFromRow($configRow);
$unlockRatio = floatval($pointsConfig['unlock_ratio'] ?? 0);
[$backupHandle, $backupPath] = $this->openBackup($dryRun);
$stats['backup_file'] = $backupPath;
try {
foreach ($records as $key => $record) {
try {
$daily = $dailyMap[$key] ?? null;
$asset = $assetByPlayxId[$record['user_id']]
?? ($record['username'] !== '' ? ($assetByUsername[$record['username']] ?? null) : null);
$result = $this->replayRecord(
$record,
$daily,
$asset,
$pointsConfig,
$unlockRatio,
$dryRun,
$backupHandle,
$stats
);
if ($result['daily']) {
$dailyMap[$key] = $result['daily'];
}
if ($result['asset']) {
$assetByPlayxId[$record['user_id']] = $result['asset'];
if ($record['username'] !== '') {
$assetByUsername[$record['username']] = $result['asset'];
}
}
} catch (Throwable $e) {
$stats['failed_members']++;
$this->appendError($stats, 'user_id=' . $record['user_id'] . '' . $e->getMessage());
}
}
} finally {
if (is_resource($backupHandle)) {
fclose($backupHandle);
}
}
return $stats;
}
/**
* @return array<string, mixed>
*/
private function newStats(string $logFile, bool $dryRun): array
{
return [
'dry_run' => $dryRun,
'log_file' => $logFile,
'backup_file' => '',
'lines' => 0,
'members' => 0,
'unique_members' => 0,
'daily_created' => 0,
'daily_updated' => 0,
'daily_unchanged' => 0,
'assets_created' => 0,
'assets_updated' => 0,
'locked_points_delta' => 0,
'invalid_lines' => 0,
'failed_members' => 0,
'errors' => [],
];
}
/**
* @param array<string, mixed> $stats
* @return array<string, array<string, mixed>>
*/
private function parseRecords(string $logFile, array &$stats): array
{
if (!is_file($logFile) || !is_readable($logFile)) {
throw new RuntimeException('日志文件不存在或不可读:' . $logFile);
}
$handle = fopen($logFile, 'rb');
if ($handle === false) {
throw new RuntimeException('无法打开日志文件:' . $logFile);
}
$records = [];
try {
while (($line = fgets($handle)) !== false) {
$line = trim($line);
if ($line === '') {
continue;
}
$stats['lines']++;
$entry = json_decode($line, true);
$raw = is_array($entry) ? strval($entry['raw'] ?? '') : '';
$payload = $raw !== '' ? json_decode($raw, true) : null;
if (!is_array($payload) || !isset($payload['report_date'], $payload['member']) || !is_array($payload['member'])) {
$stats['invalid_lines']++;
$this->appendError($stats, '第 ' . strval($stats['lines']) . ' 行格式无效');
continue;
}
$date = $this->resolveDate($payload['report_date']);
if ($date === '') {
$stats['invalid_lines']++;
$this->appendError($stats, '第 ' . strval($stats['lines']) . ' 行 report_date 无效');
continue;
}
$entryTime = strtotime(strval($entry['time'] ?? ''));
$createTime = $entryTime === false ? time() : $entryTime;
foreach ($payload['member'] as $member) {
$stats['members']++;
if (!is_array($member)) {
$stats['failed_members']++;
continue;
}
$userId = trim(strval($member['member_id'] ?? ''));
if ($userId === '') {
$stats['failed_members']++;
$this->appendError($stats, '第 ' . strval($stats['lines']) . ' 行存在空 member_id');
continue;
}
$key = $date . '|' . $userId;
$records[$key] = [
'user_id' => $userId,
'date' => $date,
'username' => trim(strval($member['login'] ?? '')),
'phone' => trim(strval($member['phone'] ?? ($member['mobile'] ?? ''))),
'yesterday_win_loss_net' => $this->numericValue($member, ['yesterday_total_wl', 'yesterday_total_w']),
'yesterday_total_deposit' => $this->numericValue($member, ['yesterday_total_deposit']),
'lifetime_total_deposit' => $this->numericValue($member, ['ltv_deposit', 'lty_deposit']),
'lifetime_total_withdraw' => $this->numericValue($member, ['ltv_withdrawal', 'lty_withdrawal']),
'create_time' => $createTime,
];
}
}
} finally {
fclose($handle);
}
return $records;
}
/**
* @param array<string, array<string, mixed>> $records
* @return array{0:array<string,MallDailyPush>,1:array<string,MallUserAsset>,2:array<string,MallUserAsset>}
*/
private function preloadExistingData(array $records): array
{
$idsByDate = [];
$userIds = [];
$usernames = [];
foreach ($records as $record) {
$date = $record['date'];
$userId = $record['user_id'];
$idsByDate[$date][$userId] = true;
$userIds[$userId] = true;
if ($record['username'] !== '') {
$usernames[$record['username']] = true;
}
}
$dailyMap = [];
foreach ($idsByDate as $date => $ids) {
foreach (array_chunk(array_keys($ids), self::QUERY_CHUNK_SIZE) as $idChunk) {
$rows = MallDailyPush::where('date', $date)->whereIn('user_id', $idChunk)->select();
foreach ($rows as $row) {
/** @var MallDailyPush $row */
$dailyMap[$date . '|' . strval($row->user_id)] = $row;
}
}
}
$assetByPlayxId = [];
foreach (array_chunk(array_keys($userIds), self::QUERY_CHUNK_SIZE) as $idChunk) {
$rows = MallUserAsset::whereIn('playx_user_id', $idChunk)->select();
foreach ($rows as $row) {
$assetByPlayxId[strval($row->playx_user_id)] = $row;
}
}
$assetByUsername = [];
foreach (array_chunk(array_keys($usernames), self::QUERY_CHUNK_SIZE) as $usernameChunk) {
$rows = MallUserAsset::whereIn('username', $usernameChunk)->select();
foreach ($rows as $row) {
$assetByUsername[strval($row->username)] = $row;
}
}
return [$dailyMap, $assetByPlayxId, $assetByUsername];
}
/**
* @return array{0:mixed,1:string}
*/
private function openBackup(bool $dryRun): array
{
if ($dryRun) {
return [null, ''];
}
$backupDir = runtime_path('backup/daily_push_replay');
if (!is_dir($backupDir) && !mkdir($backupDir, 0755, true) && !is_dir($backupDir)) {
throw new RuntimeException('无法创建回放备份目录');
}
$path = $backupDir . DIRECTORY_SEPARATOR . 'before_replay_' . date('Ymd_His') . '.jsonl';
$handle = fopen($path, 'wb');
if ($handle === false) {
throw new RuntimeException('无法创建回放备份文件');
}
return [$handle, $path];
}
/**
* @param array<string, mixed> $record
* @param resource|null $backupHandle
* @param array<string, mixed> $stats
* @return array{daily:?MallDailyPush,asset:?MallUserAsset}
*/
private function replayRecord(
array $record,
?MallDailyPush $daily,
?MallUserAsset $asset,
array $pointsConfig,
float $unlockRatio,
bool $dryRun,
$backupHandle,
array &$stats
): array {
$oldWinLossNet = $daily ? floatval($daily->yesterday_win_loss_net ?? 0) : 0.0;
$newLocked = MallPointsConversion::dailyConvertedPoints(
$record['date'],
floatval($record['yesterday_win_loss_net']),
$pointsConfig,
MallPointsConversion::nextCalendarDate($record['date'])
);
$oldLocked = $daily
? MallPointsConversion::dailyConvertedPoints(
$record['date'],
$oldWinLossNet,
$pointsConfig,
MallPointsConversion::nextCalendarDate($record['date'])
)
: 0;
$lockedDelta = $newLocked - $oldLocked;
$todayLimit = MallPointsConversion::dailyPushTodayLimit(
$record['date'],
floatval($record['yesterday_total_deposit']),
$pointsConfig,
MallPointsConversion::nextCalendarDate($record['date'])
);
$dailyData = [
'user_id' => $record['user_id'],
'date' => $record['date'],
'username' => $record['username'],
'yesterday_win_loss_net' => $record['yesterday_win_loss_net'],
'converted_points' => $newLocked,
'yesterday_total_deposit' => $record['yesterday_total_deposit'],
'lifetime_total_deposit' => $record['lifetime_total_deposit'],
'lifetime_total_withdraw' => $record['lifetime_total_withdraw'],
];
$dailyChanged = !$daily || $this->dailyHasChanges($daily, $dailyData);
$this->countDailyChange($stats, $daily, $dailyChanged);
$assetWillCreate = !$asset;
$assetWillUpdate = $assetWillCreate
|| trim(strval($asset->playx_user_id ?? '')) !== $record['user_id']
|| $lockedDelta !== 0
|| ($record['username'] !== '' && trim(strval($asset->username ?? '')) !== $record['username'])
|| ($record['phone'] !== '' && trim(strval($asset->phone ?? '')) === '')
|| $this->shouldUpdateDailyLimit($asset, $record['date'], $todayLimit);
if ($assetWillCreate) {
$stats['assets_created']++;
} elseif ($assetWillUpdate) {
$stats['assets_updated']++;
}
$stats['locked_points_delta'] += $lockedDelta;
if ($dryRun) {
return ['daily' => $daily, 'asset' => $asset];
}
if (!$dailyChanged && !$assetWillUpdate) {
return ['daily' => $daily, 'asset' => $asset];
}
$this->writeBackup($backupHandle, $record['date'] . '|' . $record['user_id'], $daily, $asset);
Db::startTrans();
try {
if ($daily) {
if ($dailyChanged) {
$daily->save($dailyData);
}
} else {
$dailyData['create_time'] = $record['create_time'];
$daily = MallDailyPush::create($dailyData);
}
if (!$asset) {
$effectiveUsername = $record['username'] !== '' ? $record['username'] : 'playx_' . $record['user_id'];
$asset = MallUserAsset::create([
'playx_user_id' => $record['user_id'],
'username' => $effectiveUsername,
'phone' => $record['phone'],
'password' => hash_password(Random::build('alnum', 16)),
'admin_id' => 0,
'locked_points' => 0,
'available_points' => 0,
'today_limit' => 0,
'today_claimed' => 0,
'today_limit_date' => null,
'points_claim_cap' => 0,
'total_points_claimed' => 0,
'lifetime_converted_points' => 0,
'daily_converted_points' => 0,
'create_time' => $record['create_time'],
'update_time' => time(),
]);
if (!$asset) {
throw new RuntimeException('创建用户资产失败');
}
/** @var MallUserAsset $asset */
} elseif ($assetWillUpdate) {
$asset->playx_user_id = $record['user_id'];
if ($record['username'] !== '') {
$asset->username = $record['username'];
}
if ($record['phone'] !== '' && trim(strval($asset->phone ?? '')) === '') {
$asset->phone = $record['phone'];
}
}
if ($assetWillUpdate) {
if ($lockedDelta !== 0) {
MallPointsConversion::applyDailyConversionDelta($asset, $lockedDelta);
}
$this->applyDailyLimit($asset, $record['date'], $todayLimit);
$settlementDay = MallPointsConversion::nextCalendarDate($record['date']);
MallPointsConversion::applyEventPeriodWinLossForDailyPush(
$asset,
$record['date'],
floatval($record['yesterday_win_loss_net']),
$pointsConfig,
$settlementDay,
$daily ? $oldWinLossNet : null
);
$asset->save();
}
Db::commit();
} catch (Throwable $e) {
Db::rollback();
throw $e;
}
return ['daily' => $daily, 'asset' => $asset];
}
/**
* @param array<string, mixed> $stats
*/
private function countDailyChange(array &$stats, ?MallDailyPush $daily, bool $changed): void
{
if (!$daily) {
$stats['daily_created']++;
} elseif ($changed) {
$stats['daily_updated']++;
} else {
$stats['daily_unchanged']++;
}
}
/**
* @param array<string, mixed> $member
* @param array<int, string> $keys
*/
private function numericValue(array $member, array $keys): float
{
foreach ($keys as $key) {
if (array_key_exists($key, $member) && is_numeric($member[$key])) {
return round(floatval($member[$key]), 2);
}
}
return 0.0;
}
private function resolveDate(mixed $reportDate): string
{
if (is_numeric($reportDate)) {
$timestamp = intval($reportDate);
return $timestamp > 0 ? date('Y-m-d', $timestamp) : '';
}
$date = trim(strval($reportDate));
$parsed = \DateTime::createFromFormat('Y-m-d', $date);
return $parsed && $parsed->format('Y-m-d') === $date ? $date : '';
}
private function lockedContribution(float $winLossNet, float $returnRatio): int
{
return $winLossNet < 0 ? intval(round(abs($winLossNet) * $returnRatio)) : 0;
}
/**
* @param array<string, mixed> $dailyData
*/
private function dailyHasChanges(MallDailyPush $daily, array $dailyData): bool
{
if (trim(strval($daily->username ?? '')) !== $dailyData['username']) {
return true;
}
foreach ([
'yesterday_win_loss_net',
'converted_points',
'yesterday_total_deposit',
'lifetime_total_deposit',
'lifetime_total_withdraw',
] as $field) {
if (abs(floatval($daily->$field ?? 0) - floatval($dailyData[$field])) > 0.000001) {
return true;
}
}
return false;
}
private function shouldUpdateDailyLimit(?MallUserAsset $asset, string $date, int $todayLimit): bool
{
if (!$asset) {
return true;
}
$currentDate = trim(strval($asset->today_limit_date ?? ''));
if ($currentDate !== '' && $currentDate > $date) {
return false;
}
return $currentDate !== $date || intval($asset->today_limit ?? 0) !== $todayLimit;
}
private function applyDailyLimit(MallUserAsset $asset, string $date, int $todayLimit): void
{
$currentDate = trim(strval($asset->today_limit_date ?? ''));
if ($currentDate !== '' && $currentDate > $date) {
return;
}
if ($currentDate !== $date) {
$asset->today_claimed = 0;
$asset->today_limit_date = $date;
}
$asset->today_limit = $todayLimit;
}
/**
* @param resource|null $handle
*/
private function writeBackup($handle, string $key, ?MallDailyPush $daily, ?MallUserAsset $asset): void
{
if (!is_resource($handle)) {
return;
}
$assetData = null;
if ($asset) {
$assetData = [
'id' => $asset->id,
'playx_user_id' => $asset->playx_user_id,
'username' => $asset->username,
'phone' => $asset->phone,
'locked_points' => $asset->locked_points,
'today_limit' => $asset->today_limit,
'today_claimed' => $asset->today_claimed,
'today_limit_date' => $asset->today_limit_date,
'update_time' => $asset->update_time,
];
}
fwrite($handle, json_encode([
'key' => $key,
'daily_push' => $daily ? $daily->toArray() : null,
'user_asset' => $assetData,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL);
}
/**
* @param array<string, mixed> $stats
*/
private function appendError(array &$stats, string $message): void
{
if (count($stats['errors']) < 100) {
$stats['errors'][] = $message;
}
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace app\common\library;
use Webman\Http\Request;
/**
* 第三方每日推送原始数据日志(按天写入,默认保留 30 天)
*/
class MallDailyPushRawLogger
{
public const DEFAULT_RETENTION_DAYS = 30;
public static function log(Request $request, string $rawBody): void
{
$rawBody = trim($rawBody);
if ($rawBody === '') {
return;
}
$dir = runtime_path('logs/daily_push_raw');
if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
return;
}
$entry = [
'time' => date('Y-m-d H:i:s'),
'ip' => $request->getRealIp(),
'method' => $request->method(),
'path' => $request->path(),
'headers' => [
'X-Request-Id' => strval($request->header('X-Request-Id', '')),
'X-Timestamp' => strval($request->header('X-Timestamp', '')),
'Content-Type' => strval($request->header('Content-Type', '')),
],
'raw' => $rawBody,
];
$logFile = $dir . DIRECTORY_SEPARATOR . 'daily_push_' . date('Y-m-d') . '.log';
file_put_contents(
$logFile,
json_encode($entry, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL,
FILE_APPEND | LOCK_EX
);
self::cleanup($dir);
}
private static function cleanup(string $dir): void
{
$retentionDays = intval(config('playx.daily_push_raw_log_days', self::DEFAULT_RETENTION_DAYS));
if ($retentionDays <= 0) {
return;
}
$expireBefore = time() - ($retentionDays * 86400);
$files = glob($dir . DIRECTORY_SEPARATOR . 'daily_push_*.log');
if ($files === false) {
return;
}
foreach ($files as $file) {
if (is_file($file) && filemtime($file) < $expireBefore) {
@unlink($file);
}
}
}
}

View File

@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace app\common\library;
use app\common\model\MallBusinessConfig;
use support\Redis as RedisSupport;
use Throwable;
/**
* PlayX 与积分商城相关的比例配置:优先读 Redis未命中则读 mall_business_config 并回写 Redis。
*/
final class MallPlayxRatios
{
public const REDIS_KEY = 'mall:business_config:playx_ratios';
/**
* @return array{return_ratio: float, unlock_ratio: float, points_to_cash_ratio: float}
*/
public static function get(): array
{
$conn = self::redisConnection();
if ($conn !== null) {
$cached = self::readFromRedis($conn);
if ($cached !== null) {
return $cached;
}
}
return self::loadFromDatabaseAndWriteRedis($conn);
}
/**
* @param array{return_ratio?: mixed, unlock_ratio?: mixed, points_to_cash_ratio?: mixed} $ratios
*/
public static function syncToRedis(array $ratios): void
{
$payload = [
'return_ratio' => self::toFloat($ratios['return_ratio'] ?? 0),
'unlock_ratio' => self::toFloat($ratios['unlock_ratio'] ?? 0),
'points_to_cash_ratio' => self::toFloat($ratios['points_to_cash_ratio'] ?? 0),
];
self::writeRedis(self::redisConnection(), $payload);
}
public static function syncFromRow(MallBusinessConfig $row): void
{
self::syncToRedis([
'return_ratio' => $row->return_ratio,
'unlock_ratio' => $row->unlock_ratio,
'points_to_cash_ratio' => $row->points_to_cash_ratio,
]);
}
public static function forget(): void
{
$conn = self::redisConnection();
if ($conn === null) {
return;
}
try {
$conn->del(self::REDIS_KEY);
} catch (Throwable) {
}
}
private static function redisConnection(): ?object
{
$cfg = config('redis');
if (!is_array($cfg) || empty($cfg)) {
return null;
}
try {
return RedisSupport::connection('default');
} catch (Throwable) {
return null;
}
}
/**
* @return array{return_ratio: float, unlock_ratio: float, points_to_cash_ratio: float}|null
*/
private static function readFromRedis(object $conn): ?array
{
try {
$raw = $conn->get(self::REDIS_KEY);
} catch (Throwable) {
return null;
}
if (!is_string($raw) || $raw === '') {
return null;
}
$data = json_decode($raw, true);
if (!is_array($data)) {
return null;
}
if (!isset($data['return_ratio'], $data['unlock_ratio'], $data['points_to_cash_ratio'])) {
return null;
}
return [
'return_ratio' => self::toFloat($data['return_ratio']),
'unlock_ratio' => self::toFloat($data['unlock_ratio']),
'points_to_cash_ratio' => self::toFloat($data['points_to_cash_ratio']),
];
}
/**
* @return array{return_ratio: float, unlock_ratio: float, points_to_cash_ratio: float}
*/
private static function loadFromDatabaseAndWriteRedis(?object $conn): array
{
$row = MallBusinessConfig::order('id', 'asc')->find();
if ($row) {
$out = [
'return_ratio' => self::toFloat($row->return_ratio),
'unlock_ratio' => self::toFloat($row->unlock_ratio),
'points_to_cash_ratio' => self::toFloat($row->points_to_cash_ratio),
];
self::writeRedis($conn, $out);
return $out;
}
$out = [
'return_ratio' => MallBusinessConfig::DEFAULT_RETURN_RATIO,
'unlock_ratio' => MallBusinessConfig::DEFAULT_UNLOCK_RATIO,
'points_to_cash_ratio' => MallBusinessConfig::DEFAULT_POINTS_TO_CASH_RATIO,
];
self::writeRedis($conn, $out);
return $out;
}
/**
* @param array{return_ratio: float, unlock_ratio: float, points_to_cash_ratio: float} $payload
*/
private static function writeRedis(?object $conn, array $payload): void
{
if ($conn === null) {
return;
}
try {
$json = json_encode($payload, JSON_THROW_ON_ERROR);
$conn->set(self::REDIS_KEY, $json);
} catch (Throwable) {
}
}
private static function toFloat(mixed $v): float
{
if (is_numeric($v)) {
return floatval($v);
}
return 0.0;
}
}

View File

@@ -0,0 +1,377 @@
<?php
declare(strict_types=1);
namespace app\common\library;
use app\common\model\MallBusinessConfig;
use app\common\model\MallDailyPush;
use app\common\model\MallUserAsset;
use support\think\Db;
/**
* 积分来源:昨日净输赢 / 终身总输赢 折算与领取上限
*/
final class MallPointsConversion
{
/**
* @return array{
* return_ratio: float,
* unlock_ratio: float,
* points_to_cash_ratio: float,
* daily_points_enabled: bool,
* lifetime_points_enabled: bool,
* lifetime_points_ratio: float,
* mall_open_date: ?string,
* mall_open_date_previous: ?string,
* mall_open_date_effective_on: ?string,
* active_mall_open_date: ?string,
* mall_event_end_date: ?string,
* mall_event_end_date_previous: ?string,
* mall_event_end_date_effective_on: ?string,
* active_event_end_date: ?string
* }
*/
public static function configFromRow(MallBusinessConfig $row): array
{
return [
'return_ratio' => floatval($row->return_ratio ?? 0),
'unlock_ratio' => floatval($row->unlock_ratio ?? 0),
'points_to_cash_ratio' => floatval($row->points_to_cash_ratio ?? 0),
'daily_points_enabled' => intval($row->daily_points_enabled ?? 1) === 1,
'lifetime_points_enabled' => intval($row->lifetime_points_enabled ?? 0) === 1,
'lifetime_points_ratio' => floatval($row->lifetime_points_ratio ?? 0),
'mall_open_date' => self::normalizeDate($row->mall_open_date ?? null),
'mall_open_date_previous' => self::normalizeDate($row->mall_open_date_previous ?? null),
'mall_open_date_effective_on' => self::normalizeDate($row->mall_open_date_effective_on ?? null),
'active_mall_open_date' => self::resolveActiveMallOpenDate($row),
'mall_event_end_date' => self::normalizeDate($row->mall_event_end_date ?? null),
'mall_event_end_date_previous' => self::normalizeDate($row->mall_event_end_date_previous ?? null),
'mall_event_end_date_effective_on' => self::normalizeDate($row->mall_event_end_date_effective_on ?? null),
'active_event_end_date' => self::resolveActiveEventEndDate($row),
];
}
public static function lossToPoints(float $winLossNet, float $ratio): int
{
if ($winLossNet >= 0 || $ratio <= 0) {
return 0;
}
return self::lossIntegerToPoints($winLossNet, $ratio);
}
/**
* 仅统计输钱:取净输赢绝对值的整数部分,再按比例折算为正整数积分
*/
public static function lossIntegerToPoints(float $winLossNet, float $ratio): int
{
if ($winLossNet >= 0 || $ratio <= 0) {
return 0;
}
$lossInteger = intval(floor(abs($winLossNet)));
if ($lossInteger <= 0) {
return 0;
}
return intval(round($lossInteger * $ratio));
}
/**
* 每日推送:昨日净输赢折算积分(受开关、商城开放日约束)
*
* @param array<string, mixed> $config
*/
public static function dailyConvertedPoints(
string $businessDate,
float $yesterdayWinLossNet,
array $config,
?string $settlementCalendarDate = null
): int {
if (empty($config['daily_points_enabled'])) {
return 0;
}
if (!self::isDailyPushSettlementAllowed($businessDate, $config, $settlementCalendarDate)) {
return 0;
}
$ratio = floatval($config['return_ratio'] ?? 0);
return self::lossIntegerToPoints($yesterdayWinLossNet, $ratio);
}
/**
* 每日推送是否允许折算昨日净输赢、昨日充值解锁上限(活动开始/结束日规则)
*/
public static function isDailyPushSettlementAllowed(
string $businessDate,
array $config,
?string $settlementCalendarDate = null
): bool {
$settlementDay = self::normalizeDate($settlementCalendarDate ?? date('Y-m-d')) ?? date('Y-m-d');
$start = self::normalizeDate($config['active_mall_open_date'] ?? null);
if ($start !== null && $businessDate < $start) {
return false;
}
$end = self::normalizeDate($config['active_event_end_date'] ?? null);
if ($end === null) {
return true;
}
if ($businessDate > $end) {
return false;
}
$endPlusOne = self::nextCalendarDate($end);
if ($settlementDay === $endPlusOne && $businessDate >= $end) {
return false;
}
if ($settlementDay > $endPlusOne) {
return false;
}
return true;
}
/**
* 活动开放期内按推送累加净输赢;关闭期间不累加,再开启后接着累加(不重置)。
*
* @param float|null $previousWinLossNet 更新已有每日推送时传旧「昨日净输赢」;新建推送传 null
*/
public static function applyEventPeriodWinLossForDailyPush(
MallUserAsset $asset,
string $businessDate,
float $yesterdayWinLossNet,
array $config,
?string $settlementCalendarDate = null,
?float $previousWinLossNet = null
): void {
if (!self::isDailyPushSettlementAllowed($businessDate, $config, $settlementCalendarDate)) {
return;
}
$current = floatval($asset->event_period_win_loss_net ?? 0);
if ($previousWinLossNet === null) {
$asset->event_period_win_loss_net = round($current + $yesterdayWinLossNet, 2);
return;
}
$delta = $yesterdayWinLossNet - $previousWinLossNet;
if ($delta === 0.0) {
return;
}
$asset->event_period_win_loss_net = round($current + $delta, 2);
}
public static function dailyPushTodayLimit(
string $businessDate,
float $yesterdayTotalDeposit,
array $config,
?string $settlementCalendarDate = null
): int {
if (!self::isDailyPushSettlementAllowed($businessDate, $config, $settlementCalendarDate)) {
return 0;
}
$unlockRatio = floatval($config['unlock_ratio'] ?? 0);
return intval(round($yesterdayTotalDeposit * $unlockRatio));
}
/**
* @param array<string, mixed> $config
* @deprecated 使用 dailyConvertedPoints
*/
public static function dailyLockedIncrement(string $businessDate, float $yesterdayWinLossNet, array $config): int
{
return self::dailyConvertedPoints($businessDate, $yesterdayWinLossNet, $config);
}
/**
* @return array<string, mixed>
*/
public static function configForConversion(): array
{
return self::configFromRow(MallBusinessConfig::ensureRow());
}
public static function syncClaimCap(MallUserAsset $asset): void
{
$lifetime = max(0, intval($asset->lifetime_converted_points ?? 0));
$daily = max(0, intval($asset->daily_converted_points ?? 0));
$asset->points_claim_cap = $lifetime + $daily;
}
public static function applyDailyConversionDelta(MallUserAsset $asset, int $delta): void
{
if ($delta === 0) {
return;
}
$asset->daily_converted_points = max(0, intval($asset->daily_converted_points ?? 0) + $delta);
self::syncClaimCap($asset);
$newLocked = intval($asset->locked_points ?? 0) + $delta;
$asset->locked_points = max(0, $newLocked);
}
public static function applyLifetimeConversion(MallUserAsset $asset, int $targetLifetimePoints): void
{
$current = max(0, intval($asset->lifetime_converted_points ?? 0));
$delta = $targetLifetimePoints - $current;
if ($delta === 0) {
return;
}
$asset->lifetime_converted_points = $targetLifetimePoints;
self::syncClaimCap($asset);
$newLocked = intval($asset->locked_points ?? 0) + $delta;
$asset->locked_points = max(0, $newLocked);
}
/**
* @return array{processed: int, updated: int, skipped: int}
*/
public static function recalculateAllLifetimePoints(MallBusinessConfig $row): array
{
if (intval($row->lifetime_points_enabled ?? 0) !== 1) {
throw new \RuntimeException('Lifetime points source is disabled');
}
$config = self::configFromRow($row);
$ratio = floatval($config['lifetime_points_ratio'] ?? 0);
if ($ratio < 0) {
throw new \RuntimeException('Parameter error');
}
$stats = ['processed' => 0, 'updated' => 0, 'skipped' => 0];
$rows = Db::query(
'SELECT d.* FROM mall_daily_push d INNER JOIN (
SELECT user_id, MAX(`date`) AS max_date FROM mall_daily_push GROUP BY user_id
) latest ON d.user_id = latest.user_id AND d.date = latest.max_date'
);
foreach ($rows as $pushRow) {
$stats['processed']++;
$playxUserId = trim(strval($pushRow['user_id'] ?? ''));
if ($playxUserId === '') {
$stats['skipped']++;
continue;
}
$lifetimeWl = $pushRow['lifetime_win_loss_net'] ?? null;
if ($lifetimeWl === null || $lifetimeWl === '') {
$stats['skipped']++;
continue;
}
$target = self::lossToPoints(floatval($lifetimeWl), $ratio);
$asset = MallUserAsset::where('playx_user_id', $playxUserId)->lock(true)->find();
if (!$asset) {
$stats['skipped']++;
continue;
}
$before = intval($asset->lifetime_converted_points ?? 0);
if ($before === $target) {
$stats['skipped']++;
continue;
}
self::applyLifetimeConversion($asset, $target);
$asset->save();
$stats['updated']++;
}
return $stats;
}
public static function remainingClaimable(MallUserAsset $asset): int
{
$cap = max(0, intval($asset->points_claim_cap ?? 0));
$claimed = max(0, intval($asset->total_points_claimed ?? 0));
return max(0, $cap - $claimed);
}
public static function resolveActiveMallOpenDate(MallBusinessConfig $row): ?string
{
$pending = self::normalizeDate($row->mall_open_date ?? null);
$previous = self::normalizeDate($row->mall_open_date_previous ?? null);
$effectiveOn = self::normalizeDate($row->mall_open_date_effective_on ?? null);
$today = date('Y-m-d');
if ($pending === null) {
return $previous;
}
if ($effectiveOn === null || $today >= $effectiveOn) {
return $pending;
}
return $previous;
}
public static function resolveActiveEventEndDate(MallBusinessConfig $row): ?string
{
$pending = self::normalizeDate($row->mall_event_end_date ?? null);
$previous = self::normalizeDate($row->mall_event_end_date_previous ?? null);
$effectiveOn = self::normalizeDate($row->mall_event_end_date_effective_on ?? null);
$today = date('Y-m-d');
if ($pending === null) {
if ($effectiveOn !== null && $today >= $effectiveOn) {
return null;
}
return $previous;
}
if ($effectiveOn === null || $today >= $effectiveOn) {
return $pending;
}
return $previous;
}
public static function normalizeDate(mixed $value): ?string
{
if ($value === null || $value === '') {
return null;
}
$str = trim(strval($value));
if ($str === '') {
return null;
}
if (strlen($str) >= 10) {
$str = substr($str, 0, 10);
}
$parsed = \DateTime::createFromFormat('Y-m-d', $str);
return $parsed && $parsed->format('Y-m-d') === $str ? $str : null;
}
public static function nextCalendarDate(string $fromDate = ''): string
{
$base = $fromDate !== '' ? $fromDate : date('Y-m-d');
$dt = \DateTime::createFromFormat('Y-m-d', $base);
if (!$dt) {
$dt = new \DateTime();
}
$dt->modify('+1 day');
return $dt->format('Y-m-d');
}
public static function parseLifetimeWinLossFromMember(array $member): ?float
{
if (!array_key_exists('ltv_wl', $member) && !array_key_exists('lifetime_total_wl', $member) && !array_key_exists('ltv_total_wl', $member)) {
return null;
}
$raw = $member['ltv_wl'] ?? ($member['lifetime_total_wl'] ?? ($member['ltv_total_wl'] ?? null));
if ($raw === null || $raw === '') {
return null;
}
if (!is_numeric($raw)) {
return null;
}
return floatval($raw);
}
}

View File

@@ -59,6 +59,13 @@ class Upload
public function setDriver(string $driver): self
{
if (!preg_match('/^[a-zA-Z0-9_-]+$/', $driver)) {
throw new InvalidArgumentException(__('Driver %s not supported', [$driver]));
}
$allowed = $this->checkConfig($this->config['allowed_drivers'] ?? 'local');
if ($allowed && !in_array(strtolower($driver), $allowed)) {
throw new InvalidArgumentException(__('Driver %s not supported', [$driver]));
}
$this->driver['name'] = $driver;
return $this;
}
@@ -79,12 +86,17 @@ class Upload
protected function resolveDriverClass(string $driver): string|false
{
if ($this->driver['namespace'] || str_contains($driver, '\\')) {
$class = str_contains($driver, '\\') ? $driver : $this->driver['namespace'] . $this->studly($driver);
if (!preg_match('/^[a-zA-Z0-9_-]+$/', $driver)) {
return false;
}
$allowed = $this->checkConfig($this->config['allowed_drivers'] ?? 'local');
if ($allowed && !in_array(strtolower($driver), $allowed)) {
return false;
}
$class = $this->driver['namespace'] . $this->studly($driver);
if (class_exists($class)) {
return $class;
}
}
return false;
}
@@ -181,6 +193,10 @@ class Upload
throw new RuntimeException(__('The uploaded file format is not allowed'));
}
$this->assertSafeFileName($this->fileInfo['name']);
$this->assertNoMaliciousContent();
$this->assertImageMime();
if (!preg_match('/^[a-zA-Z0-9_-]+$/', $this->topic)) {
throw new RuntimeException(__('Topic format error'));
}
@@ -257,4 +273,95 @@ class Upload
}
return $configItem ? explode(',', strtolower((string)$configItem)) : [];
}
protected function getForbiddenSuffixes(): array
{
return $this->checkConfig($this->config['forbidden_suffixes'] ?? '');
}
protected function assertSafeFileName(string $fileName): void
{
$forbidden = $this->getForbiddenSuffixes();
if (!$forbidden) {
return;
}
$parts = explode('.', strtolower($fileName));
if (count($parts) < 2) {
return;
}
foreach ($parts as $index => $part) {
if ($part === '') {
throw new RuntimeException(__('The uploaded file format is not allowed'));
}
if ($index === count($parts) - 1) {
continue;
}
if (in_array($part, $forbidden)) {
throw new RuntimeException(__('The uploaded file format is not allowed'));
}
}
$suffix = $parts[count($parts) - 1];
if (in_array($suffix, $forbidden)) {
throw new RuntimeException(__('The uploaded file format is not allowed'));
}
}
protected function assertNoMaliciousContent(): void
{
$path = $this->file->getPathname();
if (!is_file($path) || !is_readable($path)) {
return;
}
$handle = fopen($path, 'rb');
if (!$handle) {
throw new RuntimeException(__('The uploaded file format is not allowed'));
}
$chunk = fread($handle, 8192);
fclose($handle);
if ($chunk === false || $chunk === '') {
return;
}
$patterns = [
'/<\?php/i',
'/<\?=/i',
'/<\?(?!xml)/i',
'/<%[@=]?/i',
'/<script\b/i',
'/\b(eval|assert|shell_exec|system|passthru|proc_open|popen|base64_decode)\s*\(/i',
];
foreach ($patterns as $pattern) {
if (preg_match($pattern, $chunk)) {
throw new RuntimeException(__('The uploaded file contains prohibited content'));
}
}
}
protected function assertImageMime(): void
{
$imageSuffixes = ['gif', 'jpg', 'jpeg', 'bmp', 'png', 'webp'];
if (!in_array($this->fileInfo['suffix'], $imageSuffixes, true)) {
return;
}
$path = $this->file->getPathname();
if (!is_file($path)) {
throw new RuntimeException(__('The uploaded image file is not a valid image'));
}
if (function_exists('finfo_open')) {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
if ($finfo) {
$detected = finfo_file($finfo, $path);
finfo_close($finfo);
if (!$detected || !str_starts_with(strtolower($detected), 'image/')) {
throw new RuntimeException(__('The uploaded image file is not a valid image'));
}
}
}
}
}

View File

@@ -12,7 +12,7 @@ use Webman\Http\Response;
* 加载控制器语言包中间件Webman 迁移版,等价 ThinkPHP LoadLangPack
* 根据当前路由加载对应控制器的语言包到 Translator
*
* 对外 api/优先请求头 langzh / zh-cn → 中文包 zh-cnen → 英文包),未传则 think-lang再默认 zh-cn不根据浏览器 Accept-Language
* 对外 api/语言优先级为 请求头 lang → GET/POST 参数 lang支持 zh/ZH、en/EN 等)→ think-lang再默认 zh-cn不根据浏览器 Accept-Language
* admin/think-lang → Accept-Language → 配置默认
*/
class LoadLangPack implements MiddlewareInterface
@@ -45,6 +45,16 @@ class LoadLangPack implements MiddlewareInterface
if ($langHeader !== '') {
$langSet = $this->normalizeLangHeader($langHeader, $allowLangList);
}
if ($langSet === null) {
$langParam = strval($request->get('lang', ''));
if ($langParam === '') {
$langParam = strval($request->post('lang', ''));
}
$langParam = trim($langParam);
if ($langParam !== '') {
$langSet = $this->normalizeLangHeader($langParam, $allowLangList);
}
}
}
// 与后台 Vue 一致的 think-lang对外 API 在 lang 未设置时仍可生效)
@@ -138,6 +148,12 @@ class LoadLangPack implements MiddlewareInterface
if (str_starts_with($s, 'en')) {
return in_array('en', $allowLangList, true) ? 'en' : null;
}
if ($s === 'ms' || str_starts_with($s, 'ms-')) {
return in_array('ms', $allowLangList, true) ? 'ms' : null;
}
if ($s === 'may') {
return in_array('ms', $allowLangList, true) ? 'ms' : null;
}
if ($s === 'zh' || str_starts_with($s, 'zh-')) {
return in_array('zh-cn', $allowLangList, true) ? 'zh-cn' : null;
}

View File

@@ -18,43 +18,28 @@ class MallAddress extends Model
// 自动写入时间戳字段
protected $autoWriteTimestamp = true;
// 追加属性
protected $append = [
'region_text',
];
/** 地区字段已废弃,接口与后台不再使用 */
protected array $hidden = ['region'];
public function getregionAttr($value): array
protected static function onBeforeInsert($model): void
{
if ($value === '' || $value === null) return [];
if (!is_array($value)) {
return explode(',', $value);
$model->set('region', '');
}
return $value;
protected static function onBeforeUpdate($model): void
{
$model->set('region', '');
}
public function getregionAttr($value): string
{
return trim(strval($value ?? ''));
}
public function setregionAttr($value): string
{
return is_array($value) ? implode(',', $value) : $value;
}
public function getregionTextAttr($value, $row): string
{
if ($row['region'] === '' || $row['region'] === null) return '';
$region = $row['region'];
$ids = $region;
if (!is_array($ids)) {
$ids = explode(',', (string) $ids);
}
$ids = array_values(array_filter(array_map('trim', $ids), static function ($s) {
return $s !== '';
}));
if (empty($ids)) {
return '';
}
$cityNames = \support\think\Db::name('area')->whereIn('id', $ids)->column('name');
return $cityNames ? implode(',', $cityNames) : '';
}
public function playxUserAsset(): \think\model\relation\BelongsTo
{
@@ -68,19 +53,10 @@ class MallAddress extends Model
*/
public static function snapshotForPhysicalOrder(self $addr): array
{
$regionText = $addr->region_text ?? '';
$parts = array_filter([
trim($regionText),
trim($addr->detail_address ?? ''),
], static function ($s) {
return $s !== '';
});
$receiverAddress = implode(' ', $parts);
return [
'receiver_name' => trim($addr->receiver_name ?? ''),
'receiver_phone' => trim($addr->phone ?? ''),
'receiver_address' => $receiverAddress,
'receiver_address' => trim($addr->detail_address ?? ''),
];
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace app\common\model;
use support\think\Model;
/**
* 积分商城业务配置(含 PlayX 比例等)
*/
class MallBusinessConfig extends Model
{
protected string $name = 'mall_business_config';
protected bool $autoWriteTimestamp = true;
public const DEFAULT_RETURN_RATIO = 0.1;
public const DEFAULT_UNLOCK_RATIO = 0.1;
public const DEFAULT_POINTS_TO_CASH_RATIO = 0.1;
public const DEFAULT_LIFETIME_POINTS_RATIO = 0.1;
protected array $type = [
'create_time' => 'integer',
'update_time' => 'integer',
'return_ratio' => 'float',
'unlock_ratio' => 'float',
'points_to_cash_ratio' => 'float',
'daily_points_enabled' => 'integer',
'lifetime_points_enabled' => 'integer',
'lifetime_points_ratio' => 'float',
'mall_open_date' => 'string',
'mall_open_date_previous' => 'string',
'mall_open_date_effective_on' => 'string',
'mall_event_end_date' => 'string',
'mall_event_end_date_previous' => 'string',
'mall_event_end_date_effective_on' => 'string',
];
/**
* 保证存在一条商城参数配置(比例仅来自后台 mall_business_config不再读 .env
*/
public static function ensureRow(): self
{
$row = self::order('id', 'asc')->find();
if ($row) {
return $row;
}
$now = time();
$created = self::create([
'return_ratio' => self::DEFAULT_RETURN_RATIO,
'unlock_ratio' => self::DEFAULT_UNLOCK_RATIO,
'points_to_cash_ratio' => self::DEFAULT_POINTS_TO_CASH_RATIO,
'daily_points_enabled' => 1,
'lifetime_points_enabled' => 0,
'lifetime_points_ratio' => self::DEFAULT_LIFETIME_POINTS_RATIO,
'create_time' => $now,
'update_time' => $now,
]);
if (!$created) {
throw new \RuntimeException('Failed to create mall_business_config');
}
return $created;
}
}

View File

@@ -15,9 +15,11 @@ class MallDailyPush extends Model
protected array $type = [
'yesterday_win_loss_net' => 'float',
'converted_points' => 'integer',
'yesterday_total_deposit' => 'float',
'lifetime_total_deposit' => 'float',
'lifetime_total_withdraw' => 'float',
'lifetime_win_loss_net' => 'float',
'create_time' => 'integer',
];
}

View File

@@ -7,6 +7,13 @@ use support\think\Model;
/**
* MallItem
* type: 1=BONUS(红利), 2=PHYSICAL(实物), 3=WITHDRAW(提现档位)
*
* @property string $title
* @property string $description
* @property string $title_en
* @property string|null $description_en
* @property string $title_ms
* @property string|null $description_ms
*/
class MallItem extends Model
{
@@ -22,7 +29,6 @@ class MallItem extends Model
'create_time' => 'integer',
'update_time' => 'integer',
'amount' => 'float',
'multiplier' => 'integer',
];
public function admin(): \think\model\relation\BelongsTo

View File

@@ -16,9 +16,7 @@ use support\think\Model;
* @property int $mall_item_id
* @property int $points_cost
* @property float $amount
* @property int $multiplier
* @property string $external_transaction_id
* @property string $playx_transaction_id
* @property string $grant_status
* @property string|null $fail_reason
* @property int $retry_count
@@ -59,7 +57,6 @@ class MallOrder extends Model
'update_time' => 'integer',
'points_cost' => 'integer',
'amount' => 'float',
'multiplier' => 'integer',
'retry_count' => 'integer',
'mall_address_id' => 'integer',
];
@@ -73,5 +70,13 @@ class MallOrder extends Model
{
return $this->belongsTo(MallAddress::class, 'mall_address_id', 'id');
}
/**
* 订单 user_id 存 playX 侧用户标识字符串,与 mall_user_asset.playx_user_id 对齐。
*/
public function mallUserAsset(): \think\model\relation\BelongsTo
{
return $this->belongsTo(MallUserAsset::class, 'user_id', 'playx_user_id');
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace app\common\model;
use app\common\model\traits\TimestampInteger;
use support\think\Model;
class MallPointsLog extends Model
{
use TimestampInteger;
protected string $table = 'mall_points_log';
protected string $pk = 'id';
protected bool $autoWriteTimestamp = true;
protected bool $updateTime = false;
/**
* 展示备注:兼容历史数据未替换的 %s 占位符
*/
public function getMemoAttr($value, $row): string
{
$memo = strval($value ?? '');
if ($memo === '' || !str_contains($memo, '%s')) {
return $memo;
}
$points = abs(intval(is_array($row) ? ($row['score_value'] ?? 0) : ($row->score_value ?? 0)));
return sprintf($memo, $points);
}
}

View File

@@ -24,6 +24,11 @@ class MallUserAsset extends Model
'today_limit' => 'integer',
'today_claimed' => 'integer',
'admin_id' => 'integer',
'points_claim_cap' => 'integer',
'total_points_claimed' => 'integer',
'lifetime_converted_points' => 'integer',
'daily_converted_points' => 'integer',
'event_period_win_loss_net' => 'float',
];
/**
@@ -62,6 +67,10 @@ class MallUserAsset extends Model
'today_limit' => 0,
'today_claimed' => 0,
'today_limit_date' => null,
'points_claim_cap' => 0,
'total_points_claimed' => 0,
'lifetime_converted_points' => 0,
'daily_converted_points' => 0,
'create_time' => $now,
'update_time' => $now,
]);

View File

@@ -165,18 +165,6 @@ if (!function_exists('get_auth_token')) {
}
}
if (!function_exists('get_agent_jwt_payload')) {
/**
* 解析 Agent JWT authtoken返回 payloadagent_id、admin_id 等)
* @param string $token authtoken
* @return array 成功返回 payload失败返回空数组
*/
function get_agent_jwt_payload(string $token): array
{
return \app\common\library\AgentJwt::decode($token);
}
}
if (!function_exists('get_controller_path')) {
/**
* 从 Request 或路由获取控制器路径(等价于 ThinkPHP controllerPath
@@ -648,8 +636,9 @@ if (!function_exists('get_account_verification_type')) {
if ($configured) {
$types[] = 'email';
}
if (class_exists(\app\admin\library\module\Server::class)) {
$sms = \app\admin\library\module\Server::getIni(\ba\Filesystem::fsFit(root_path() . 'modules/sms/'));
$smsIni = \ba\Filesystem::fsFit(root_path() . 'modules/sms/info.ini');
if (is_file($smsIni)) {
$sms = parse_ini_file($smsIni, true, INI_SCANNER_TYPED) ?: [];
if ($sms && ($sms['state'] ?? 0) == 1) {
$types[] = 'mobile';
}

View File

@@ -6,6 +6,7 @@ use app\common\model\MallItem;
use app\common\model\MallOrder;
use app\common\model\MallUserAsset;
use GuzzleHttp\Client;
use support\Log;
use Workerman\Timer;
use Workerman\Worker;
@@ -22,18 +23,17 @@ class AngpowImportJobs
private const BATCH_LIMIT = 100;
private const MAX_RETRY = 3;
protected Client $http;
protected ?Client $http = null;
public function __construct()
public function onWorkerStart(Worker $worker): void
{
// 确保定时任务只在一个 worker 上运行
if (!Worker::getAllWorkers()) {
return;
$this->http = new Client($this->buildGuzzleOptions());
Timer::add(self::TIMER_SECONDS, [$this, 'pushPendingOrders']);
}
$this->http = new Client($this->buildGuzzleOptions());
Timer::add(self::TIMER_SECONDS, [$this, 'pushPendingOrders']);
protected function getHttp(): ?Client
{
return $this->http;
}
/**
@@ -69,6 +69,11 @@ class AngpowImportJobs
public function pushPendingOrders(): void
{
$http = $this->getHttp();
if ($http === null) {
return;
}
$conf = config('playx.angpow_import');
if (!is_array($conf)) {
return;
@@ -124,13 +129,17 @@ class AngpowImportJobs
if (!$order instanceof MallOrder) {
continue;
}
$row = $this->buildAngpowRow($order);
if ($row === null) {
$rowResult = $this->buildAngpowRow($order);
if ($rowResult === null) {
// 构造失败:直接标为可重试失败
$this->markFailedAttempt($order, 'Build payload failed');
continue;
}
$payload['angpow'][] = $row;
if (is_string($rowResult)) {
$this->markFailedAttempt($order, $rowResult);
continue;
}
$payload['angpow'][] = $rowResult;
$orderIds[] = $order->id;
}
@@ -160,7 +169,7 @@ class AngpowImportJobs
$res = null;
$body = '';
try {
$res = $this->http->post($url, [
$res = $http->post($url, [
'headers' => [
'Content-Type' => 'application/json',
'X-Request-Signature' => $signature,
@@ -181,6 +190,7 @@ class AngpowImportJobs
$data = json_decode($body, true);
if (!is_array($data)) {
Log::error($this->formatAngpowInvalidJsonBodyForLog($res, $body, $orderIds));
foreach ($orders as $order) {
if (!$order instanceof MallOrder) {
continue;
@@ -198,13 +208,16 @@ class AngpowImportJobs
if ($code === '0' || $code === 0) {
MallOrder::whereIn('id', $orderIds)->update([
'grant_status' => MallOrder::GRANT_ACCEPTED,
'status' => MallOrder::STATUS_COMPLETED,
'fail_reason' => null,
'update_time' => time(),
]);
return;
}
// 失败:整批视为失败(对方未提供逐条返回)
// 失败:整批视为失败(对方未提供逐条返回);解析后的 JSON 仅写入项目日志
$this->logAngpowPushRejectedParsedBody($orderIds, $data);
foreach ($orders as $order) {
if (!$order instanceof MallOrder) {
continue;
@@ -213,7 +226,10 @@ class AngpowImportJobs
}
}
private function buildAngpowRow(MallOrder $order): ?array
/**
* @return array<string, mixed>|string|null 成功返回行数组;用户名缺失返回错误文案字符串;其它构造失败返回 null
*/
private function buildAngpowRow(MallOrder $order): array|string|null
{
$asset = MallUserAsset::where('playx_user_id', $order->user_id)->find();
if (!$asset) {
@@ -228,6 +244,11 @@ class AngpowImportJobs
return null;
}
$memberLogin = trim(strval($asset->username ?? ''));
if ($memberLogin === '') {
return 'User username empty';
}
$item = null;
if ($order->mallItem) {
$item = $order->mallItem;
@@ -247,11 +268,11 @@ class AngpowImportJobs
// }
// }
$start = gmdate('Y-m-d H:i:s', strtotime($order->start_time));
$end = gmdate('Y-m-d H:i:s', strtotime($order->end_time));
$start = gmdate('Y-m-d\TH:i:s\Z', strtotime($order->start_time));
$end = gmdate('Y-m-d\TH:i:s\Z', strtotime($order->end_time));
return [
'member_login' => strval($asset->playx_user_id),
'member_login' => $memberLogin,
'start_time' => $start,
'end_time' => $end,
'amount' => $order->amount,
@@ -260,11 +281,102 @@ class AngpowImportJobs
'member_inbox_message' => 'Congratulations! You received an angpow.',
'category' => strval($item->category ?? ''),
'category_title' => strval($item->category_title ?? ''),
'one_time_turnover' => 'yes',
'multiplier' => $order->multiplier,
];
}
/**
* 对方返回体无法解析为「根级 JSON 对象」时,生成写入项目日志的完整说明(不落库订单 fail_reason
*
* @param list<int|string> $orderIds
* @param mixed $response Guzzle 响应或其它
*/
private function formatAngpowInvalidJsonBodyForLog(mixed $response, string $rawBody, array $orderIds): string
{
$httpPart = 'HTTP unknown';
if (is_object($response) && method_exists($response, 'getStatusCode')) {
$code = $response->getStatusCode();
if (is_int($code)) {
$httpPart = 'HTTP ' . $code;
}
}
$trimmed = trim($rawBody);
if ($trimmed === '') {
$detail = 'empty body';
} elseif (json_last_error() !== JSON_ERROR_NONE) {
$msg = json_last_error_msg();
$detail = is_string($msg) && $msg !== '' ? $msg : 'JSON parse error';
} else {
$detail = 'root is not a JSON object';
}
$idPart = implode(',', $orderIds);
$snippet = '';
if ($trimmed !== '') {
$oneLine = preg_replace('/\s+/', ' ', $trimmed);
$snippet = is_string($oneLine) && $oneLine !== '' ? $oneLine : $trimmed;
$maxLen = 8000;
if (function_exists('mb_strlen') && function_exists('mb_substr')) {
if (mb_strlen($snippet, 'UTF-8') > $maxLen) {
$snippet = mb_substr($snippet, 0, $maxLen, 'UTF-8') . '…';
}
} elseif (strlen($snippet) > $maxLen) {
$snippet = substr($snippet, 0, $maxLen) . '…';
}
}
$head = '[AngpowImport] response not a JSON object | order_ids=' . $idPart . ' | ' . $httpPart . ' | ' . $detail;
if ($snippet === '') {
return $head;
}
return $head . ' | body=' . $snippet;
}
/**
* 可解析 JSON 但业务失败时,将解析结果写入项目日志(订单仍只记 message 等业务文案)。
*
* @param list<int|string> $orderIds
* @param array<mixed> $parsed
*/
private function logAngpowPushRejectedParsedBody(array $orderIds, array $parsed): void
{
$flags = JSON_UNESCAPED_UNICODE;
if (defined('JSON_INVALID_UTF8_SUBSTITUTE')) {
$flags = $flags | JSON_INVALID_UTF8_SUBSTITUTE;
}
$encoded = json_encode($parsed, $flags);
if (!is_string($encoded)) {
$encoded = 'json_encode failed';
}
$maxLen = 8000;
if (function_exists('mb_strlen') && function_exists('mb_substr')) {
if (mb_strlen($encoded, 'UTF-8') > $maxLen) {
$encoded = mb_substr($encoded, 0, $maxLen, 'UTF-8') . '…';
}
} elseif (strlen($encoded) > $maxLen) {
$encoded = substr($encoded, 0, $maxLen) . '…';
}
Log::error('[AngpowImport] push rejected (parsed JSON) | order_ids=' . implode(',', $orderIds) . ' | body=' . $encoded);
}
/**
* 单条失败原因压成一行,避免异常信息自带换行导致与 attempt 分段混在一起。
*/
private function normalizeReasonLine(string $reason): string
{
$s = trim($reason);
if ($s === '') {
return '';
}
$s = str_replace(["\r\n", "\r", "\n"], ' ', $s);
$replaced = preg_replace('/\s+/', ' ', $s);
return is_string($replaced) && $replaced !== '' ? $replaced : $s;
}
private function markFailedAttempt(MallOrder $order, string $reason): void
{
// retry_count 在“准备发送”阶段已 +1此处用当前 retry_count 作为 attempt 编号
@@ -277,7 +389,7 @@ class AngpowImportJobs
$prev = $order->fail_reason;
$prefix = 'attempt ' . $attempt . ': ';
$line = $prefix . $reason;
$line = $prefix . $this->normalizeReasonLine($reason);
$newReason = $line;
if (is_string($prev) && $prev !== '') {
$newReason = $prev . "\n" . $line;

View File

@@ -16,15 +16,10 @@ use Workerman\Worker;
*/
class PlayxJobs
{
protected Client $http;
protected ?Client $http = null;
public function __construct()
public function onWorkerStart(Worker $worker): void
{
// 确保定时任务只在一个 worker 上运行
if (!Worker::getAllWorkers()) {
return;
}
$this->http = new Client([
'timeout' => 20,
'http_errors' => false,
@@ -34,11 +29,21 @@ class PlayxJobs
Timer::add(60, [$this, 'retryFailedGrants']);
}
protected function getHttp(): ?Client
{
return $this->http;
}
/**
* 轮询:已 accepted 的订单,查询终态
*/
public function pollTransactionStatus(): void
{
$http = $this->getHttp();
if ($http === null) {
return;
}
$baseUrl = strval(config('playx.api.base_url', ''));
if ($baseUrl === '') {
return;
@@ -57,7 +62,7 @@ class PlayxJobs
foreach ($list as $order) {
/** @var MallOrder $order */
try {
$res = $this->http->get($url, [
$res = $http->get($url, [
'query' => [
'externalTransactionId' => $order->external_transaction_id,
],
@@ -171,7 +176,8 @@ class PlayxJobs
$result = MallBonusGrantPush::push($order);
if ($result['ok']) {
$order->grant_status = MallOrder::GRANT_ACCEPTED;
$order->playx_transaction_id = $result['playx_transaction_id'];
$order->status = MallOrder::STATUS_COMPLETED;
$order->update_time = time();
$order->save();
return;

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace app\support;
use support\Log;
use support\Response;
use Webman\Http\Request as WebmanRequest;
@@ -76,6 +77,13 @@ abstract class BaseController
*/
protected function result(string $msg, mixed $data = null, int $code = 0, array $header = []): Response
{
if (trim($msg) === '') {
$msg = $code === 1 ? __('Success') : __('Failed');
if (trim($msg) === '') {
$msg = $code === 1 ? 'success' : 'failed';
}
}
$body = [
'code' => $code,
'msg' => $msg,
@@ -86,6 +94,20 @@ abstract class BaseController
$statusCode = $header['statusCode'] ?? 200;
unset($header['statusCode']);
if ($code !== 1) {
$req = $this->request;
$method = $req ? $req->method() : '';
$path = $req ? trim($req->path(), '/') : '';
$ip = $req ? strval($req->getRealIp()) : '';
$msgForLog = $msg;
if (function_exists('mb_strlen') && function_exists('mb_substr') && mb_strlen($msgForLog) > 2000) {
$msgForLog = mb_substr($msgForLog, 0, 2000) . '...';
} elseif (strlen($msgForLog) > 2000) {
$msgForLog = substr($msgForLog, 0, 2000) . '...';
}
Log::error('[API] ' . $method . ' /' . $path . ' | business_code=' . strval($code) . ' http=' . strval($statusCode) . ' ip=' . $ip . ' msg=' . $msgForLog);
}
$headers = array_merge(['Content-Type' => 'application/json'], $header);
$options = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
if (defined('JSON_INVALID_UTF8_SUBSTITUTE')) {

View File

@@ -41,7 +41,10 @@
"voku/anti-xss": "^4.1",
"topthink/think-validate": "^3.0",
"firebase/php-jwt": "^7.0",
"guzzlehttp/guzzle": "^7.10"
"guzzlehttp/guzzle": "^7.10",
"robthree/twofactorauth": "^3.0",
"bacon/bacon-qr-code": "^3.1",
"openspout/openspout": "^4.28"
},
"suggest": {
"ext-event": "For better performance. "

View File

@@ -10,6 +10,8 @@ return [
'user_login_captcha' => false,
// 是否开启管理员登录验证码
'admin_login_captcha' => false,
// 是否开启管理员谷歌验证器TOTP由 .env ADMIN_TOTP_ENABLE 控制
'admin_totp_enable' => filter_var(env('ADMIN_TOTP_ENABLE', '1'), FILTER_VALIDATE_BOOLEAN),
// 会员登录失败可重试次数,false则无限
'user_login_retry' => 10,
// 管理员登录失败可重试次数,false则无限
@@ -81,20 +83,12 @@ return [
'cdn_url' => '',
// 内容分发网络URL参数将自动添加 `?`,之后拼接到 cdn_url 的结尾(例如 `imageMogr2/format/heif`
'cdn_url_params' => '',
// 代理鉴权配置(/api/v1/authToken
'agent_auth' => [
// agent_id => secret 映射
'agents' => [
// 'agent_001' => 'your_secret_key',
],
// JWT 签名密钥(留空则使用 token.key建议 AGENT_AUTH_JWT_SECRET 注入
'jwt_secret' => strval(env('AGENT_AUTH_JWT_SECRET', '')),
// 是否启用 H5 临时登录接口 /api/v1/temLogin
'temp_login_enable' => true,
// Token 有效期(秒),默认 24 小时
'token_expire' => 86400,
// H5 临时登录配置(/api/v1/temLogin
'temp_login' => [
// 是否启用 H5 临时登录接口
'enable' => true,
// 临时登录 token 有效期(秒),默认 1 天
'temp_login_expire' => 86400,
'expire' => 86400,
],
// 版本号
'version' => 'v2.3.6',

View File

@@ -4,5 +4,5 @@
*/
return [
'default_lang' => env('LANG_DEFAULT_LANG', 'zh-cn'),
'allow_lang_list' => ['zh-cn', 'en'],
'allow_lang_list' => ['zh-cn', 'en', 'ms'],
];

View File

@@ -4,14 +4,10 @@
* PlayX 积分商城对接配置
*/
return [
// 返还比例:新增保障金 = ABS(yesterday_win_loss_net) * 返还比例(仅亏损时)
'return_ratio' => floatval(env('PLAYX_RETURN_RATIO', '0.1')),
// 解锁比例:今日可领取上限 = yesterday_total_deposit * 解锁比例
'unlock_ratio' => floatval(env('PLAYX_UNLOCK_RATIO', '0.1')),
// 提现折算:积分 → 现金(如 10 分 = 1 元)
'points_to_cash_ratio' => floatval(env('PLAYX_POINTS_TO_CASH_RATIO', '0.1')),
// Daily Push 签名校验PlayX 调用商城时使用)
'daily_push_secret' => strval(env('PLAYX_DAILY_PUSH_SECRET', '')),
/** 第三方每日推送原始日志保留天数runtime/logs/daily_push_raw */
'daily_push_raw_log_days' => intval(env('PLAYX_DAILY_PUSH_RAW_LOG_DAYS', '30')),
/**
* 合作方 JWT 验签密钥HS256。非空时dailyPush 等回调需带 Authorization: Bearer
* 仅写入部署环境变量,勿提交仓库。
@@ -20,15 +16,21 @@ return [
// token 会话缓存过期时间(秒)
'session_expire_seconds' => intval(env('PLAYX_SESSION_EXPIRE_SECONDS', '3600')),
/**
* 为 true 时:/api/v1/playx/verify-token 仅本地校验(查 token 表 + mall_playx_user_asset不请求 PlayX
* 联调/无 PlayX 环境可开;上线对接 PlayX 后请设为 false 并配置 api.base_url
* 为 true 时verifyToken 不向 PlayX 请求;不校验请求 token 真伪,按环境变量默认用户签发 mall_session
* 用户标识见 PLAYX_VERIFY_TOKEN_LOCAL_DEFAULT_USER_ID / PLAYX_VERIFY_TOKEN_LOCAL_DEFAULT_USERNAME
* 生产对接 playX 时请设为 false 并配置 PLAYX_TOKEN_VERIFY_URL。
*/
'verify_token_local_only' => filter_var(env('PLAYX_VERIFY_TOKEN_LOCAL_ONLY', '1'), FILTER_VALIDATE_BOOLEAN),
'verify_token_local_only' => filter_var(env('PLAYX_VERIFY_TOKEN_LOCAL_ONLY', '0'), FILTER_VALIDATE_BOOLEAN),
/** verify_token_local_only 为 true 时写入 mall_session 的 playx 用户标识 */
'verify_token_local_default_user_id' => strval(env('PLAYX_VERIFY_TOKEN_LOCAL_DEFAULT_USER_ID', 'testmyr')),
/** verify_token_local_only 为 true 时写入 mall_session 的展示用户名 */
'verify_token_local_default_username' => strval(env('PLAYX_VERIFY_TOKEN_LOCAL_DEFAULT_USERNAME', 'yangyang123')),
// PlayX API 配置(商城调用 PlayX 时使用)
'api' => [
'base_url' => strval(env('PLAYX_API_BASE_URL', '')),
'secret_key' => strval(env('PLAYX_API_SECRET_KEY', '')),
'token_verify_url' => '/api/v1/auth/verify-token',
// 完整 https URL回调 Body merchant_code+request_date+request_id+token签名同序HMAC 同 angpow相对路径拼基址 + 商户体
'token_verify_url' => strval(env('PLAYX_TOKEN_VERIFY_URL', '/api/v1/auth/verify-token')),
'bonus_grant_url' => '/api/v1/bonus/grant',
'balance_credit_url' => '/api/v1/balance/credit',
'transaction_status_url' => '/api/v1/transaction/status',
@@ -49,6 +51,6 @@ return [
'verify_ssl' => filter_var(env('PLAYX_ANGPOW_IMPORT_VERIFY_SSL', '1'), FILTER_VALIDATE_BOOLEAN),
// 固定货币展示映射
'currency' => 'MYR',
'visual_name' => 'Angpow',
'visual_name' => 'Reward Mall',
],
];

View File

@@ -16,14 +16,20 @@ use support\Log;
use support\Request;
use app\process\Http;
use app\process\AngpowImportJobs;
use app\process\PlayxJobs;
global $argv;
return [
$workerCount = env('WEBMAN_WORKER_COUNT', '');
if ($workerCount === '' || $workerCount === null) {
$workerCount = max(4, cpu_count() * 4);
}
$process = [
'webman' => [
'handler' => Http::class,
'listen' => 'http://0.0.0.0:6969',
'count' => cpu_count() * 4,
'count' => (int) $workerCount,
'user' => '',
'group' => '',
'reusePort' => false,
@@ -36,12 +42,10 @@ return [
'publicPath' => public_path()
]
],
// File update detection and automatic reload
'monitor' => [
'handler' => app\process\Monitor::class,
'reloadable' => false,
'constructor' => [
// Monitor these directories
'monitorDir' => array_merge([
app_path(),
config_path(),
@@ -49,26 +53,28 @@ return [
base_path() . '/support',
base_path() . '/resource',
base_path() . '/.env',
], glob(base_path() . '/plugin/*/app'), glob(base_path() . '/plugin/*/config'), glob(base_path() . '/plugin/*/api')),
// Files with these suffixes will be monitored
], glob(base_path() . '/plugin/*/app') ?: [], glob(base_path() . '/plugin/*/config') ?: [], glob(base_path() . '/plugin/*/api') ?: []),
'monitorExtensions' => [
'php', 'html', 'htm', 'env'
],
'options' => [
'enable_file_monitor' => !in_array('-d', $argv) && DIRECTORY_SEPARATOR === '/',
'enable_file_monitor' => !in_array('-d', $argv ?? []) && DIRECTORY_SEPARATOR === '/',
'enable_memory_monitor' => DIRECTORY_SEPARATOR === '/',
]
]
]
,
// PlayX 闭环任务:轮询交易终态/失败重试
'playx_jobs' => [
'handler' => app\process\PlayxJobs::class,
'reloadable' => false,
],
// Angpow 导入推送任务:订单兑换后推送到对方平台
'angpow_import_jobs' => [
'handler' => AngpowImportJobs::class,
'reloadable' => false,
],
];
$disableBackgroundJobs = filter_var(env('PLAYX_DISABLE_BACKGROUND_JOBS', '0'), FILTER_VALIDATE_BOOLEAN);
if (!$disableBackgroundJobs) {
$process['playx_jobs'] = [
'handler' => PlayxJobs::class,
'reloadable' => false,
];
$process['angpow_import_jobs'] = [
'handler' => AngpowImportJobs::class,
'reloadable' => false,
];
}
return $process;

View File

@@ -8,61 +8,18 @@
use Webman\Route;
use support\Response;
// ==================== 未安装时根路径重定向(迁移自 public/index.php ====================
// 当 install.lock 不存在或未完成安装时,访问 / 或 /index.html 重定向到安装页
$installLockFile = public_path('install.lock');
$installCompleteMark = 'install-end';
$installPageFile = public_path('install/index.html');
Route::get('/', function () use ($installLockFile, $installCompleteMark, $installPageFile) {
$needRedirect = is_file($installPageFile)
&& (!is_file($installLockFile) || @file_get_contents($installLockFile) !== $installCompleteMark);
if ($needRedirect) {
return new Response(302, ['Location' => '/install/']);
}
// ==================== 根路径 ====================
Route::get('/', function () {
if (is_file(public_path('index.html'))) {
return new Response(302, ['Location' => '/index.html']);
}
return new Response(404, [], 'Not Found');
});
Route::get('/index.html', function () use ($installLockFile, $installCompleteMark, $installPageFile) {
$needRedirect = is_file($installPageFile)
&& (!is_file($installLockFile) || @file_get_contents($installLockFile) !== $installCompleteMark);
if ($needRedirect) {
return new Response(302, ['Location' => '/install/']);
}
Route::get('/index.html', function () {
$file = public_path('index.html');
return is_file($file) ? (new Response())->file($file) : new Response(404, [], 'Not Found');
});
// ==================== 安装向导(静态页) ====================
// 已安装时访问 /install 重定向到应用,访问提示仅在终端显示
$installLockFileForInstall = public_path('install.lock');
$installCompleteMarkForInstall = 'install-end';
Route::get('/install', function () use ($installLockFileForInstall, $installCompleteMarkForInstall) {
$installed = is_file($installLockFileForInstall) && @file_get_contents($installLockFileForInstall) === $installCompleteMarkForInstall;
if ($installed && is_file(public_path('index.html'))) {
return new Response(302, ['Location' => '/index.html']);
}
$file = public_path('install/index.html');
return is_file($file) ? (new Response())->file($file) : new Response(404, [], 'Install page not found');
});
Route::get('/install/', function () use ($installLockFileForInstall, $installCompleteMarkForInstall) {
$installed = is_file($installLockFileForInstall) && @file_get_contents($installLockFileForInstall) === $installCompleteMarkForInstall;
if ($installed && is_file(public_path('index.html'))) {
return new Response(302, ['Location' => '/index.html']);
}
$file = public_path('install/index.html');
return is_file($file) ? (new Response())->file($file) : new Response(404, [], 'Install page not found');
});
Route::get('/install/index', function () use ($installLockFileForInstall, $installCompleteMarkForInstall) {
$installed = is_file($installLockFileForInstall) && @file_get_contents($installLockFileForInstall) === $installCompleteMarkForInstall;
if ($installed && is_file(public_path('index.html'))) {
return new Response(302, ['Location' => '/index.html']);
}
$file = public_path('install/index.html');
return is_file($file) ? (new Response())->file($file) : new Response(404, [], 'Install page not found');
});
// ==================== API 路由 ====================
// api/index
@@ -72,20 +29,7 @@ Route::get('/api/index/index', [\app\api\controller\Index::class, 'index']);
Route::add(['GET', 'POST'], '/api/user/checkIn', [\app\api\controller\User::class, 'checkIn']);
Route::post('/api/user/logout', [\app\api\controller\User::class, 'logout']);
// api/install安装流程多为 POST
Route::add(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD'], '/api/install/terminal', [\app\api\controller\Install::class, 'terminal']);
Route::post('/api/install/changePackageManager', [\app\api\controller\Install::class, 'changePackageManager']);
Route::get('/api/install/envBaseCheck', [\app\api\controller\Install::class, 'envBaseCheck']);
Route::add(['GET', 'POST'], '/api/install/envNpmCheck', [\app\api\controller\Install::class, 'envNpmCheck']);
Route::post('/api/install/testDatabase', [\app\api\controller\Install::class, 'testDatabase']);
Route::add(['GET', 'POST'], '/api/install/baseConfig', [\app\api\controller\Install::class, 'baseConfig']);
Route::get('/api/install/accessUrls', [\app\api\controller\Install::class, 'accessUrls']);
Route::post('/api/install/commandExecComplete', [\app\api\controller\Install::class, 'commandExecComplete']);
Route::post('/api/install/manualInstall', [\app\api\controller\Install::class, 'manualInstall']);
Route::post('/api/install/mvDist', [\app\api\controller\Install::class, 'mvDist']);
// api/common
Route::get('/api/common/captcha', [\app\api\controller\Common::class, 'captcha']);
Route::get('/api/common/clickCaptcha', [\app\api\controller\Common::class, 'clickCaptcha']);
Route::post('/api/common/checkClickCaptcha', [\app\api\controller\Common::class, 'checkClickCaptcha']);
Route::post('/api/common/refreshToken', [\app\api\controller\Common::class, 'refreshToken']);
@@ -109,7 +53,6 @@ Route::post('/api/account/retrievePassword', [\app\api\controller\Account::class
Route::post('/api/ems/send', [\app\api\controller\Ems::class, 'send']);
// api/v1 鉴权
Route::get('/api/v1/authToken', [\app\api\controller\v1\Auth::class, 'authToken']);
Route::get('/api/v1/temLogin', [\app\api\controller\v1\Auth::class, 'temLogin']);
// api/v1 PlayX 积分商城
@@ -122,6 +65,8 @@ Route::post('/api/v1/mall/bonusRedeem', [\app\api\controller\v1\Playx::class, 'b
Route::post('/api/v1/mall/physicalRedeem', [\app\api\controller\v1\Playx::class, 'physicalRedeem']);
Route::post('/api/v1/mall/withdrawApply', [\app\api\controller\v1\Playx::class, 'withdrawApply']);
Route::get('/api/v1/mall/orders', [\app\api\controller\v1\Playx::class, 'orders']);
Route::get('/api/v1/mall/order', [\app\api\controller\v1\Playx::class, 'order']);
Route::get('/api/v1/mall/pointsLogs', [\app\api\controller\v1\Playx::class, 'pointsLogs']);
Route::get('/api/v1/mall/addressList', [\app\api\controller\v1\Playx::class, 'addressList']);
Route::post('/api/v1/mall/addressAdd', [\app\api\controller\v1\Playx::class, 'addressAdd']);
Route::post('/api/v1/mall/addressEdit', [\app\api\controller\v1\Playx::class, 'addressEdit']);
@@ -135,27 +80,24 @@ Route::get('/admin/index/index', [\app\admin\controller\Index::class, 'index']);
Route::get('/admin/index/login', [\app\admin\controller\Index::class, 'login']);
Route::post('/admin/index/login', [\app\admin\controller\Index::class, 'login']);
Route::post('/admin/index/logout', [\app\admin\controller\Index::class, 'logout']);
Route::post('/admin/index/totpVerify', [\app\admin\controller\Index::class, 'totpVerify']);
Route::post('/admin/index/totpBindInit', [\app\admin\controller\Index::class, 'totpBindInit']);
Route::post('/admin/index/totpBindConfirm', [\app\admin\controller\Index::class, 'totpBindConfirm']);
// 兼容前端请求 /admin/Index/*(首字母大写)
Route::get('/admin/Index/index', [\app\admin\controller\Index::class, 'index']);
Route::get('/admin/Index/login', [\app\admin\controller\Index::class, 'login']);
Route::post('/admin/Index/login', [\app\admin\controller\Index::class, 'login']);
Route::post('/admin/Index/logout', [\app\admin\controller\Index::class, 'logout']);
Route::post('/admin/Index/totpVerify', [\app\admin\controller\Index::class, 'totpVerify']);
Route::post('/admin/Index/totpBindInit', [\app\admin\controller\Index::class, 'totpBindInit']);
Route::post('/admin/Index/totpBindConfirm', [\app\admin\controller\Index::class, 'totpBindConfirm']);
// admin/dashboard
Route::get('/admin/dashboard/index', [\app\admin\controller\Dashboard::class, 'index']);
// 兼容前端请求 /admin/Dashboard/*
Route::get('/admin/Dashboard/index', [\app\admin\controller\Dashboard::class, 'index']);
// admin/module
Route::get('/admin/module/index', [\app\admin\controller\Module::class, 'index']);
Route::get('/admin/module/state', [\app\admin\controller\Module::class, 'state']);
Route::post('/admin/module/install', [\app\admin\controller\Module::class, 'install']);
Route::post('/admin/module/dependentInstallComplete', [\app\admin\controller\Module::class, 'dependentInstallComplete']);
Route::post('/admin/module/changeState', [\app\admin\controller\Module::class, 'changeState']);
Route::post('/admin/module/uninstall', [\app\admin\controller\Module::class, 'uninstall']);
Route::post('/admin/module/upload', [\app\admin\controller\Module::class, 'upload']);
// admin/ajax
Route::post('/admin/ajax/upload', [\app\admin\controller\Ajax::class, 'upload']);
Route::get('/admin/ajax/area', [\app\admin\controller\Ajax::class, 'area']);
@@ -164,15 +106,14 @@ Route::get('/admin/ajax/getDatabaseConnectionList', [\app\admin\controller\Ajax:
Route::get('/admin/ajax/getTablePk', [\app\admin\controller\Ajax::class, 'getTablePk']);
Route::get('/admin/ajax/getTableList', [\app\admin\controller\Ajax::class, 'getTableList']);
Route::get('/admin/ajax/getTableFieldList', [\app\admin\controller\Ajax::class, 'getTableFieldList']);
Route::post('/admin/ajax/changeTerminalConfig', [\app\admin\controller\Ajax::class, 'changeTerminalConfig']);
Route::post('/admin/ajax/clearCache', [\app\admin\controller\Ajax::class, 'clearCache']);
Route::add(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD'], '/admin/ajax/terminal', [\app\admin\controller\Ajax::class, 'terminal']);
// admin/auth/admin
Route::get('/admin/auth/admin/index', [\app\admin\controller\auth\Admin::class, 'index']);
Route::post('/admin/auth/admin/add', [\app\admin\controller\auth\Admin::class, 'add']);
Route::post('/admin/auth/admin/edit', [\app\admin\controller\auth\Admin::class, 'edit']);
Route::post('/admin/auth/admin/del', [\app\admin\controller\auth\Admin::class, 'del']);
Route::post('/admin/auth/admin/resetTotp', [\app\admin\controller\auth\Admin::class, 'resetTotp']);
// admin/auth/group
Route::get('/admin/auth/group/index', [\app\admin\controller\auth\Group::class, 'index']);
@@ -214,6 +155,13 @@ Route::post('/admin/user/scoreLog/add', [\app\admin\controller\user\ScoreLog::cl
// admin/user/moneyLog
Route::post('/admin/user/moneyLog/add', [\app\admin\controller\user\MoneyLog::class, 'add']);
// admin/mall/dailyPush
Route::post('/admin/mall/dailyPush/backfillUsers', [\app\admin\controller\mall\DailyPush::class, 'backfillUsers']);
// admin/mall/playxConfig积分商城参数
Route::get('/admin/mall.PlayxConfig/index', [\app\admin\controller\mall\PlayxConfig::class, 'index']);
Route::post('/admin/mall.PlayxConfig/save', [\app\admin\controller\mall\PlayxConfig::class, 'save']);
// admin/routine/config
Route::get('/admin/routine/config/index', [\app\admin\controller\routine\Config::class, 'index']);
Route::post('/admin/routine/config/edit', [\app\admin\controller\routine\Config::class, 'edit']);

View File

@@ -1,83 +0,0 @@
<?php
// +----------------------------------------------------------------------
// | BuildAdmin - WEB终端配置
// | Webman 迁移migrate 命令需根据实际迁移工具调整
// +----------------------------------------------------------------------
return [
'npm_package_manager' => 'pnpm',
'commands' => [
'migrate' => [
'run' => [
'cwd' => '',
'command' => 'php vendor/bin/phinx migrate',
'notes' => 'Start the database migration'
],
'rollback' => ['cwd' => '', 'command' => 'php vendor/bin/phinx rollback'],
'breakpoint' => ['cwd' => '', 'command' => 'php vendor/bin/phinx breakpoint'],
],
'install' => [
'cnpm' => 'npm install cnpm -g --registry=https://registry.npmmirror.com',
'yarn' => 'npm install -g yarn',
'pnpm' => 'npm install -g pnpm',
'ni' => 'npm install -g @antfu/ni',
],
'version' => [
'npm' => 'npm -v',
'cnpm' => 'cnpm -v',
'yarn' => 'yarn -v',
'pnpm' => 'pnpm -v',
'node' => 'node -v',
],
'test' => [
'npm' => ['cwd' => 'public/npm-install-test', 'command' => 'npm install'],
'cnpm' => ['cwd' => 'public/npm-install-test', 'command' => 'cnpm install'],
'yarn' => ['cwd' => 'public/npm-install-test', 'command' => 'yarn install'],
'pnpm' => ['cwd' => 'public/npm-install-test', 'command' => 'pnpm install'],
'ni' => ['cwd' => 'public/npm-install-test', 'command' => 'ni install'],
],
'web-install' => [
'npm' => ['cwd' => 'web', 'command' => 'npm install'],
'cnpm' => ['cwd' => 'web', 'command' => 'cnpm install'],
'yarn' => ['cwd' => 'web', 'command' => 'yarn install'],
'pnpm' => ['cwd' => 'web', 'command' => 'pnpm install'],
'ni' => ['cwd' => 'web', 'command' => 'ni install'],
],
'nuxt-install' => [
'npm' => ['cwd' => 'web-nuxt', 'command' => 'npm install'],
'cnpm' => ['cwd' => 'web-nuxt', 'command' => 'cnpm install'],
'yarn' => ['cwd' => 'web-nuxt', 'command' => 'yarn install'],
'pnpm' => ['cwd' => 'web-nuxt', 'command' => 'pnpm install'],
'ni' => ['cwd' => 'web-nuxt', 'command' => 'ni install'],
],
'web-build' => [
'npm' => ['cwd' => 'web', 'command' => 'npm run build', 'notes' => 'Start executing the build command of the web project'],
'cnpm' => ['cwd' => 'web', 'command' => 'cnpm run build', 'notes' => 'Start executing the build command of the web project'],
'yarn' => ['cwd' => 'web', 'command' => 'yarn run build', 'notes' => 'Start executing the build command of the web project'],
'pnpm' => ['cwd' => 'web', 'command' => 'pnpm run build', 'notes' => 'Start executing the build command of the web project'],
'ni' => ['cwd' => 'web', 'command' => 'nr build', 'notes' => 'Start executing the build command of the web project'],
],
'set-npm-registry' => [
'npm' => 'npm config set registry https://registry.npmjs.org/ && npm config get registry',
'taobao' => 'npm config set registry https://registry.npmmirror.com/ && npm config get registry',
'tencent' => 'npm config set registry https://mirrors.cloud.tencent.com/npm/ && npm config get registry'
],
'set-composer-registry' => [
'composer' => 'composer config --unset repos.packagist',
'aliyun' => 'composer config -g repos.packagist composer https://mirrors.aliyun.com/composer/',
'tencent' => 'composer config -g repos.packagist composer https://mirrors.cloud.tencent.com/composer/',
'huawei' => 'composer config -g repos.packagist composer https://mirrors.huaweicloud.com/repository/php/',
'kkame' => 'composer config -g repos.packagist composer https://packagist.kr',
],
'npx' => [
'prettier' => ['cwd' => 'web', 'command' => 'npx prettier --write %s', 'notes' => 'Start formatting the web project code'],
],
'composer' => [
'update' => ['cwd' => '', 'command' => 'composer update --no-interaction', 'notes' => 'Start installing the composer dependencies']
],
'ping' => [
'baidu' => 'ping baidu.com',
'localhost' => 'ping 127.0.0.1 -n 6',
]
],
];

View File

@@ -16,6 +16,10 @@ return [
* 2. 千万不要允许 php,php5,.htaccess,.user.ini 等可执行或配置文件
* 3. 允许 pdf,ppt,docx 等可能含有脚本的文件时,请先从服务器配置此类文件直接下载而不是预览
*/
'allowed_suffixes' => 'jpg,png,bmp,jpeg,gif,webp,zip,rar,wav,mp4,mp3',
'allowed_suffixes' => 'jpg,png,bmp,jpeg,gif,webp,wav,mp4,mp3',
'allowed_mime_types' => [],
// 禁止上传的后缀(含双后缀检测,如 shell.php.jpg
'forbidden_suffixes' => 'php,php3,php4,php5,php7,php8,phtml,phar,htaccess,htpasswd,user.ini,asp,aspx,jsp,jspx,cgi,pl,sh,bat,cmd,exe,dll,vbs,ps1,com,shtml',
// 允许使用的上传驱动(禁止通过 driver 参数加载任意类)
'allowed_drivers' => 'local',
];

File diff suppressed because one or more lines are too long

View File

@@ -10,7 +10,7 @@
### 1.1 流程 AH5 临时登录(推荐)
适用场景H5 只需要“用户名级别”的轻量登录,不依赖 PlayX 的 token。
适用场景H5 只需要“用户名级别”的轻量登录,不依赖 playX 的 token。
1. H5 调用 `GET/POST /api/v1/temLogin?username=xxx` 获取 **商城 token**(类型 `muser`
2. H5 后续请求统一携带该 token推荐放在 Header`token: <muser_token>`,也可用参数 `token`
@@ -19,12 +19,13 @@
- `POST /api/v1/mall/claim` 领取积分(幂等)
- `GET /api/v1/mall/items` 获取商品
- `POST /api/v1/mall/bonusRedeem` / `physicalRedeem` / `withdrawApply` 提交兑换/提现
- `GET /api/v1/mall/orders` 查询订单
- `GET /api/v1/mall/orders` 查询订单列表
- `GET /api/v1/mall/order` 按订单 ID 查询单笔订单(含状态)
- `GET/POST /api/v1/mall/address*` 管理地址addressList/addressAdd/addressEdit/addressDelete
### 1.2 流程 BPlayX token 换取 session兼容
### 1.2 流程 BplayX token 换取 session兼容
适用场景H5 已经拿到了 PlayX 下发的 token希望换取商城侧 `session_id`
适用场景H5 已经拿到了 playX 下发的 token希望换取商城侧 `session_id`
1. H5 调用 `POST /api/v1/mall/verifyToken`(传 `token``session`
2. 服务端返回 `data.session_id`
@@ -34,16 +35,15 @@
## 2. 身份与鉴权(重要)
以下接口都会通过服务端逻辑解析当前资产主体”,优先级如下
以下接口通过服务端解析当前资产主体」,**仅**支持
1. **`session_id`**GET/POST对应表 `mall_session`,未过期则可映射到资产主体
2. **`token`**GET/POST 或 Header):支持会员 token 或 `muser` tokenH5 临时登录签发)
3. **`user_id`**GET/POST
- 纯数字:视为 `mall_user_asset.id`
- 非纯数字:按 `mall_user_asset.playx_user_id` 查找
2. **`token`**GET/POST 或 Header `token` / `ba-token`):会员 token 或 H5 临时登录签发的 **`muser`** token
不再支持单独传 `user_id` 作为鉴权参数(避免越权)。
推荐做法:
- H5 统一**Header `token`**值为 `muser` token),避免 URL 泄露与参数歧义
- H5 统一使**Header `token`**`muser` 或 playX 换 session 后的凭证),避免 URL 泄露。
---
@@ -59,7 +59,7 @@
成功返回 `data.userInfo`
- `id`:资产主键(`mall_user_asset.id`
- `username`:用户名
- `playx_user_id`:映射的 PlayX 用户标识(字符串)
- `playx_user_id`:映射的 playX 用户标识(字符串)
- `token`**muser token**(后续请求使用)
- `refresh_token`:刷新 token当前前端未强依赖可不接
- `expires_in`:秒
@@ -76,7 +76,7 @@ curl "https://{域名}/api/v1/temLogin?username=test001"
**GET** ` /api/v1/mall/assets `
鉴权:携带 `token``session_id``user_id`
鉴权:携带 `token``session_id`
成功返回 `data`
- `locked_points`:待领取积分
@@ -93,7 +93,7 @@ curl "https://{域名}/api/v1/temLogin?username=test001"
参数:
- `claim_request_id`:必填,幂等键(建议:`{业务前缀}_{assetId}_{毫秒时间戳}`
- 身份参数:`token` / `session_id` / `user_id` 三选一(推荐 `token`
- 身份参数:`token` / `session_id`(推荐 Header `token`
说明:
- 同一个 `claim_request_id` 重复提交会直接返回成功(不会重复入账)
@@ -116,7 +116,7 @@ curl "https://{域名}/api/v1/temLogin?username=test001"
参数:
- `item_id`:必填
- 身份参数:`token` / `session_id` / `user_id`
- 身份参数:`token` / `session_id`
返回:
- `data.order_id`
@@ -131,7 +131,7 @@ curl "https://{域名}/api/v1/temLogin?username=test001"
参数:
- `item_id`:必填
- `address_id`:必填,收货地址主键(`mall_address.id`,须为当前用户资产下地址)
- 身份参数:`token` / `session_id` / `user_id`
- 身份参数:`token` / `session_id`
说明:服务端会将该地址在下单时刻的 **收货人 / 电话 / 完整地址** 写入订单字段(快照),并写入 `mall_order.mall_address_id` 关联所选地址。
@@ -143,7 +143,7 @@ curl "https://{域名}/api/v1/temLogin?username=test001"
参数:
- `item_id`:必填
- 身份参数:`token` / `session_id` / `user_id`
- 身份参数:`token` / `session_id`
---
@@ -151,7 +151,7 @@ curl "https://{域名}/api/v1/temLogin?username=test001"
**GET** ` /api/v1/mall/orders `
鉴权:`token` / `session_id` / `user_id`
鉴权:`token` / `session_id`
说明:
- 返回最多 100 条
@@ -159,6 +159,74 @@ curl "https://{域名}/api/v1/temLogin?username=test001"
---
### 3.8.1 单笔订单查询(按 ID
**GET** ` /api/v1/mall/order `
鉴权:`token` / `session_id`(同订单列表)
参数Query
- `order_id`:必填,商城订单主键 `mall_order.id`(兼容参数名 `id`
说明:
- 仅能查询当前鉴权用户本人名下的订单;订单不存在或不属于当前用户时返回「记录不存在」,不泄露他人订单信息。
返回(成功 `data`
- `order_id`:订单 ID
- `status`:订单状态(`PENDING` / `COMPLETED` / `SHIPPED` / `REJECTED`
- `order`:订单详情(字段与订单列表单条一致,含 `reject_reason``grant_status``mallItem` 等)
示例:
```bash
curl -G "https://{域名}/api/v1/mall/order" \
-H "token: <muser_token>" \
--data-urlencode "order_id=123"
```
---
### 3.9 积分流水(领取/兑换/退回)
**GET** ` /api/v1/mall/pointsLogs `
鉴权:携带 `token``session_id`
参数Query
- `limit`:可选,每页条数(默认 20最大 100
- `cursor`:可选,游标(上一页响应返回的 `next_cursor`,传入后获取下一页)
- `direction`:可选,`IN`(入账)/`OUT`(扣减),不传返回全部
返回(成功 `data`
- `list[]`:流水数组(按时间倒序)
- `biz_type``CLAIM`(领取入账)/ `REDEEM_BONUS|REDEEM_PHYSICAL|REDEEM_WITHDRAW`(兑换扣分)/ `REFUND`(订单驳回或终态失败退回)
- `direction``IN` / `OUT`
- `points`:积分变动值(正数,方向由 `direction` 表示)
- `ts`时间戳Unix 秒)
- `ref_id`:关联业务号(领取为 `claim_request_id`;订单为 `external_transaction_id`
- `order_no`:订单号(非订单类为空)
- `order_status`:订单状态(非订单类为空)
- `mallItem`:商品信息(非订单类为 null
- `id`商品ID
- `title`:商品名
- `type`:商品类型(`1=BONUS 2=PHYSICAL 3=WITHDRAW`
- `score`:所需积分
- `amount`:现金面值(红利/提现档位)
- `multiplier`:流水倍数
- `category`:红利业务类别
- `category_title`:类别展示名
- `item_id/item_title/item_type/item_score`:兼容字段(建议优先使用 `mallItem`
- `cursor`:当前记录游标(用于分页)
- `next_cursor`:下一页游标(为本页最后一条的 `cursor`;若本页为空则为 null
示例:
```bash
curl -G "https://{域名}/api/v1/mall/pointsLogs" \
-H "token: <muser_token>" \
--data-urlencode "limit=20"
```
## 4. 地址管理H5
> 地址与资产主体通过 `playx_user_asset_id` 关联(即 `mall_user_asset.id`)。
@@ -169,7 +237,7 @@ curl "https://{域名}/api/v1/temLogin?username=test001"
### 4.2 新增地址
**POST** ` /api/v1/mall/addressAdd `
Body 含 `receiver_name`(收货人,建议填写;实物兑换下单快照需要非空的收货人、电话与拼接后的完整地址)
Body 含 `receiver_name``phone``detail_address`(完整收货地址,必填);实物兑换下单快照使用上述字段
### 4.3 编辑地址
**POST** ` /api/v1/mall/addressEdit `
@@ -189,12 +257,23 @@ Body 含 `receiver_name`(收货人,建议填写;实物兑换下单快照
- `token`
- `session`
可选 **`lang`**`zh` / `ZH` / `zh-cn` 返回中文 **`msg`**(默认中文),`en` / `EN` 返回英文可通过请求头、Query 或与本接口 Body 同传的表单字段传入。
建议 **`Content-Type: application/json`**Body 示例:`{"token":"..."}`。使用 `multipart/form-data` 时同样只传 `token`(及可选 `lang`)即可。
成功返回:
- `data.session_id`
- `data.user_id`
- `data.username`
- `data.token_expire_at`
**本地联调(不请求 PlayX**:在 `.env` 中设置 `PLAYX_VERIFY_TOKEN_LOCAL_ONLY=true`,并配置:
- `PLAYX_VERIFY_TOKEN_LOCAL_DEFAULT_USER_ID`:写入 `mall_session.user_id` / 资产 `playx_user_id`
- `PLAYX_VERIFY_TOKEN_LOCAL_DEFAULT_USERNAME`:写入 `mall_session.username` 与资产展示名
此时任意(或空)`token` 均视为验证通过,返回的 `user_id` / `username` 以上述环境变量为准。生产环境请保持 `PLAYX_VERIFY_TOKEN_LOCAL_ONLY=false` 并配置 `PLAYX_TOKEN_VERIFY_URL`
---
## 6. 常见错误与排查

View File

@@ -1,17 +1,17 @@
## 0. 交付说明(给 PlayX
## 0. 交付说明(给 playX
- **交付物**:本文件(接口清单 + 业务流程 + 联调验收清单)。
- **建议联调顺序**Token 验证(远程 PlayX 或本地 `verify_token_local_only`)→ 每日推送 → 领取 → 红利发放 → 提现入账 → 实物后台处理。
- **约定**:接口 URL、字段最终表、签名细节以 PlayX 提供的最终口径为准;本文档负责把流程、幂等、重试与最小字段集合先对齐。
- **建议联调顺序**Token 验证(远程 playX 或本地 `verify_token_local_only`)→ 每日推送 → 领取 → 红利发放 → 提现入账 → 实物后台处理。
- **约定**:接口 URL、字段最终表、签名细节以 playX 提供的最终口径为准;本文档负责把流程、幂等、重试与最小字段集合先对齐。
## 1. 文档目的与范围
本文档用于 PlayX 与积分商城Points Mall联调对接。范围仅包含
本文档用于 playX 与积分商城Points Mall联调对接。范围仅包含
- 前端:PlayX 以内嵌 Iframe 打开商城 H5使用 postMessage 传递 token/session。
- 后端:商城后端独立部署;与 PlayX 后端通过 REST API 通讯。
- 数据同步:仅 PlayX 每日 Cron 推送T+1玩家数据到商城用于计算“待领取积分/今日可领取上限”。
- 发放方式:商城在红利兑换/提现(回平台余额)下单后,直接调用 PlayX API 发放/入账;PlayX 侧每 10 分钟 Cron 执行 5.9 adjustment/最终入账。
- 前端:playX 以内嵌 Iframe 打开商城 H5使用 postMessage 传递 token/session。
- 后端:商城后端独立部署;与 playX 后端通过 REST API 通讯。
- 数据同步:仅 playX 每日 Cron 推送T+1玩家数据到商城用于计算“待领取积分/今日可领取上限”。
- 发放方式:商城在红利兑换/提现(回平台余额)下单后,直接调用 playX API 发放/入账;playX 侧每 10 分钟 Cron 执行 5.9 adjustment/最终入账。
不在本文档范围内:
@@ -22,22 +22,22 @@
```mermaid
flowchart LR
PlayXFrontend["PlayXFrontend"] -->|"postMessage(token/session)"| MallFrontend["MallFrontend(Iframe)"]
playXFrontend["playXFrontend"] -->|"postMessage(token/session)"| MallFrontend["MallFrontend(Iframe)"]
MallFrontend -->|"API(商城后端)"| MallBackend["MallBackend"]
MallBackend -->|"TokenVerificationAPI"| PlayXBackend["PlayXBackend"]
PlayXBackend -->|"DailyPushAPI(T+1)"| MallBackend
MallBackend -->|"BonusGrantAPI/BalanceCreditAPI"| PlayXBackend
MallBackend -->|"TokenVerificationAPI"| playXBackend["playXBackend"]
playXBackend -->|"DailyPushAPI(T+1)"| MallBackend
MallBackend -->|"BonusGrantAPI/BalanceCreditAPI"| playXBackend
```
> 当 **`playx.verify_token_local_only=true`** 时「Token 验证」一步在商城内完成,**不经过** `PlayXBackend` 的 Token Verification API详见 **§4.1**。
> 当 **`playX.verify_token_local_only=true`** 时「Token 验证」一步在商城内完成,**不经过** `playXBackend` 的 Token Verification API详见 **§4.1**。
## 3. 关键业务对象与状态机
### 3.1 资产口径(最小集合)
- **LockedPoints待领取积分**:由 PlayX 每日推送的“昨日输赢净额”在商城端按规则计算得到,未领取前不可消费。
- **LockedPoints待领取积分**:由 playX 每日推送的“昨日输赢净额”在商城端按规则计算得到,未领取前不可消费。
- **AvailablePoints可用积分**:领取后可用于兑换/提现的积分余额。
- **TodayLimit今日可领取上限**:由 PlayX 每日推送的“昨日总存款”按规则计算得到。
- **TodayLimit今日可领取上限**:由 playX 每日推送的“昨日总存款”按规则计算得到。
- **TodayClaimed今日已领取**:当日累计领取量(用于进度条与上限控制)。
### 3.2 订单类型
@@ -57,48 +57,51 @@ flowchart LR
### 4.1 登录鉴权Iframe + token
> **接口与字段细节**以代码为准,完整说明见同目录《PlayX-接口文档.md》§3 H5、§3.2 `temLogin`、§3.3 `verify-token`)。
> **接口与字段细节**以代码为准,完整说明见同目录《playX-接口文档.md》§3 H5、§3.2 `temLogin`、§3.3 `verifyToken`)。
#### 4.1.1 身份与数据模型(商城侧)
- **商城用户**:表 `mall_user`H5 临时登录、后台创建等均落此表)。
- **PlayX 资产扩展**:表 `mall_playx_user_asset`,与 `mall_user` **一对一**`mall_user_id``playx_user_id` 均唯一)。
- **业务侧用户标识**:对外接口中的 `user_id`(字符串)在多数场景下即 **`playx_user_id`**PlayX 玩家 ID
- 若用户仅通过商城 **临时登录** 进入、尚无 PlayX 正式 ID商城会生成占位 ID形如 **`mall_{mall_user.id}`**,与每日推送中的真实 `user_id` 区分(避免与纯数字 ID 混淆)。
- **H5 调业务接口时**:服务端内部统一解析为 **`mall_user.id`**,再查资产与订单(解析规则见《PlayX-接口文档》§3.1)。
- **playX 资产扩展**:表 `mall_playx_user_asset`,与 `mall_user` **一对一**`mall_user_id``playx_user_id` 均唯一)。
- **业务侧用户标识**:对外接口中的 `user_id`(字符串)在多数场景下即 **`playx_user_id`**playX 玩家 ID
- 若用户仅通过商城 **临时登录** 进入、尚无 playX 正式 ID商城会生成占位 ID形如 **`mall_{mall_user.id}`**,与每日推送中的真实 `user_id` 区分(避免与纯数字 ID 混淆)。
- **H5 调业务接口时**:服务端内部统一解析为 **`mall_user.id`**,再查资产与订单(解析规则见《playX-接口文档》§3.1)。
#### 4.1.2 模式 A联调 PlayX生产/预发,远程校验 token
#### 4.1.2 模式 A联调 playX生产/预发,远程校验 token
1. 用户在 PlayX 内打开积分商城入口iframe
2. PlayX 前端通过 postMessage 将 **PlayX 下发的 token**(及必要上下文)传给商城 H5。
3. 商城 H5 调用商城后端 **`POST /api/v1/playx/verify-token`**,由商城向 PlayX 的 **Token Verification API**`playx.api.base_url` + `playx.api.token_verify_url`发起校验。
4. **前提**:配置 **`playx.verify_token_local_only = false`**,且 **`playx.api.base_url`** 已配置为可访问的 PlayX 基地址。
5. PlayX 返回 **`user_id``username`**(及可选会话过期时间等)
1. 用户在 playX 内打开积分商城入口iframe
2. playX 前端通过 postMessage 将 **playX 下发的 token**(及必要上下文)传给商城 H5。
3. 商城 H5 调用商城后端 **`POST /api/v1/mall/verifyToken`**(前端只传 `token`),由商城后端向 **Token Verification API** 发起校验。
4. **前提**:配置 **`playX.verify_token_local_only = false`**,且 **`PLAYX_TOKEN_VERIFY_URL`** 已配置:
- **完整 `https://...` URL**(如回调):商城 `POST` 该地址JSON Body 含 **`merchant_code``request_date`Unix 秒)、`request_id``token`****`X-Request-Signature`** 与 angpow-imports **同源算法**(密钥 **`PLAYX_ANGPOW_IMPORT_AUTH_KEY`**),签名字符串为 **`merchant_code=...&request_date=...&request_id=...&token=...`**;无需 `PLAYX_ANGPOW_IMPORT_BASE_URL`
- **相对路径**(如 `/api/v1/auth/verify-token`):需同时配置 **`PLAYX_ANGPOW_IMPORT_BASE_URL`**Body 使用 **`request_date`**,并按商户约定附带签名等。
5. playX 返回 **`user_id``username`**(及可选会话过期时间等)。
6. 商城写入 **`mall_playx_session`**`session_id` + 上述 `user_id`/`username` + 过期时间),后续 H5 可用 **`session_id`** 或 **`token`(商城临时 token见模式 B** 调用资产/领取等接口。
幂等与安全:
- H5 **不要**把 PlayX 的 `user_id` 当作唯一可信凭据直传下单;**以 token 换 session** 或由商城签发 token 的流程为准。
- PlayX 侧 Token Verification API 的鉴权/签名若有按双方约定可参考《PlayX-接口文档》§2.1
- H5 **不要**把 playX 的 `user_id` 当作唯一可信凭据直传下单;**以 token 换 session** 或由商城签发 token 的流程为准。
- `PLAYX_TOKEN_VERIFY_URL` 为**完整 https URL**Body 含 **`merchant_code``request_date``request_id``token`**`X-Request-Signature` 为 angpow 同源 HMAC明文 **`merchant_code=...&request_date=...&request_id=...&token=...`**H5 不参与签名
- 若为**相对路径 + 基地址**商户网关:鉴权/签名由商城后端完成(`merchant_code/request_date/request_id` + `X-Request-Signature`H5 不参与签名计算。
#### 4.1.3 模式 B本地 / 无 PlayX 环境(商城自校验,不请求 PlayX
#### 4.1.3 模式 B本地 / 无 playX 环境(商城自校验,不请求 playX
用于开发、联调前自测、或 PlayX 接口未就绪时:
用于开发、联调前自测、或 playX 接口未就绪时:
1. 配置 **`playx.verify_token_local_only = true`**(环境变量 **`PLAYX_VERIFY_TOKEN_LOCAL_ONLY`**,默认可为开启,以项目 `config/playx.php` 为准)。
2. 此时 **`/api/v1/playx/verify-token` 不会访问 PlayX**,仅在商城内校验 **商城临时 token**token 表类型 **`muser`**,由下方 `temLogin` 签发)。
1. 配置 **`playX.verify_token_local_only = true`**(环境变量 **`PLAYX_VERIFY_TOKEN_LOCAL_ONLY`**,默认可为开启,以项目 `config/playx.php` 为准)。
2. 此时 **`/api/v1/mall/verifyToken` 不会访问 playX**,仅在商城内校验 **商城临时 token**token 表类型 **`muser`**,由下方 `temLogin` 签发)。
3. 调用 **`GET/POST /api/v1/temLogin?username=...`**(需 **`buildadmin.agent_auth.temp_login_enable = true`**):不存在则创建 **`mall_user`**,并保证存在 **`mall_playx_user_asset`**(含 `playx_user_id`,默认 **`mall_{id}`**),返回 **`userInfo.token`**、**`playx_user_id`**、**`expires_in`** 等。
4. 再用该 token 调用 **`verify-token`** 可得到 **`session_id`**,与模式 A 一样供后续接口使用;或直接带 **`token` / `ba-token`** 调资产等接口(见《PlayX-接口文档》§3.1)。
4. 再用该 token 调用 **`verifyToken`** 可得到 **`session_id`**,与模式 A 一样供后续接口使用;或直接带 **`token` / `ba-token`** 调资产等接口(见《playX-接口文档》§3.1)。
#### 4.1.4 会话续期与前端约定
- **会话续期**:玩家停留时间较长时,若商城 API 返回 token/session 失效(如 401H5 可通过 postMessage 请 PlayX 父页面 **重新派发 PlayX token**(模式 A模式 B 下可重新 **`temLogin`** 或走 **`/api/common/refreshToken`**`muser-refresh`)换取新 access token。
- 具体错误码与 Header`ba-token`)以前端与《PlayX-接口文档》为准。
- **会话续期**:玩家停留时间较长时,若商城 API 返回 token/session 失效(如 401H5 可通过 postMessage 请 playX 父页面 **重新派发 playX token**(模式 A模式 B 下可重新 **`temLogin`** 或走 **`/api/common/refreshToken`**`muser-refresh`)换取新 access token。
- 具体错误码与 Header`ba-token`)以前端与《playX-接口文档》为准。
### 4.2 每日 T+1 入池(PlayX → 商城)
### 4.2 每日 T+1 入池(playX → 商城)
1. PlayX 在每日固定时间向商城调用 **Daily Push API**,推送昨日玩家数据。(**注:请确认并约定好 `date` 字段对应的具体时区边界,如以 UTC+8 为准**)。
2. 商城按 `user_id + date` 幂等去重入库。由于不支持通过重复推送做数据修正,**若 PlayX 发现个别账单算错了,请联系商城运营在后台进行人工调账处理**,勿重复推送。
1. playX 在每日固定时间向商城调用 **Daily Push API**,推送昨日玩家数据。(**注:请确认并约定好 `date` 字段对应的具体时区边界,如以 UTC+8 为准**)。
2. 商城按 `user_id + date` 幂等去重入库。由于不支持通过重复推送做数据修正,**若 playX 发现个别账单算错了,请联系商城运营在后台进行人工调账处理**,勿重复推送。
3. 商城计算:
- 新增保障金(待领取积分增量)
- 今日可领取上限
@@ -118,19 +121,19 @@ flowchart LR
- 领取操作建议使用 `claim_request_id`(由前端生成或后端生成返回)实现幂等,避免重复点击导致重复领取。
### 4.4 红利兑换(商城 → PlayX 发放)
### 4.4 红利兑换(商城 → playX 发放)
1. 会员在“红利”商品点击兑换并确认(**为避免客诉,商城前端会提示会员:红利发放预计在此后约 10 分钟内入账,请耐心等待**)。
2. 商城创建 BONUS 订单PENDING并校验/扣减可用积分(原子扣减)。
3. 商城调用 PlayX **Bonus Grant API**,传递红利发放信息(字段见 5.3)。
4.PlayX API 返回初步排队接收成功HTTP 200 且 `status="accepted"`
- 商城订单保持 PENDING等待 PlayX 侧 10 分钟 Cron 最终发放/入账)。
3. 商城调用 playX **Bonus Grant API**,传递红利发放信息(字段见 5.3)。
4.playX API 返回初步排队接收成功HTTP 200 且 `status="accepted"`
- 商城订单保持 PENDING等待 playX 侧 10 分钟 Cron 最终发放/入账)。
- 记录 `playx_transaction_id`(或外部流水号)用于后续追踪。
- **商城后端将通过调用 PlayX 的 “交易状态查询 API”见 5.5)来轮询获取最终结果**,最终确认为成功后,商城订单才会流转闭环为 COMPLETED。
5.PlayX API 返回失败:
- **商城后端将通过调用 playX 的 “交易状态查询 API”见 5.5)来轮询获取最终结果**,最终确认为成功后,商城订单才会流转闭环为 COMPLETED。
5.playX API 返回失败:
- 订单保持 PENDING并记录失败原因与下一次可重试时间
- 支持后台“手动重试”(见 6.3
- 若经过 N 次重试仍失败或确认 PlayX 侧不可达成:订单转 REJECTED 并退回积分(见 6.2
- 若经过 N 次重试仍失败或确认 playX 侧不可达成:订单转 REJECTED 并退回积分(见 6.2
### 4.5 实物兑换(商城后台人工处理)
@@ -140,26 +143,26 @@ flowchart LR
- 发货:录入物流公司与单号 → 状态 SHIPPED
- 驳回:录入原因 → 状态 REJECTED → 自动退回积分
### 4.6 提现回平台余额(商城 → PlayX 入账)
### 4.6 提现回平台余额(商城 → playX 入账)
1. 会员在“提现到平台余额”商品点击提现并确认(**前端同样需向用户提示约 10 分钟入账预期**)。
2. 商城创建 WITHDRAW 订单PENDING并原子扣减可用积分。
3. 商城调用 PlayX **Balance Credit API**(或同一发放接口的提现模式),传入入账信息。
4.PlayX API 返回初步排队接收成功HTTP 200 且 `status="accepted"`
- 商城订单保持 PENDING等待 PlayX 侧 10 分钟 Cron 最终入账)。
3. 商城调用 playX **Balance Credit API**(或同一发放接口的提现模式),传入入账信息。
4.playX API 返回初步排队接收成功HTTP 200 且 `status="accepted"`
- 商城订单保持 PENDING等待 playX 侧 10 分钟 Cron 最终入账)。
- 记录 `playx_transaction_id`(或外部流水号)用于后续追踪。
- **商城后端通过「交易状态查询 API」见 5.5)轮询获取终态**,确认成功后订单才流转为 COMPLETED。
5.PlayX API 返回失败(非 200 或 `status``accepted`):失败处理同 4.4。
5.playX API 返回失败(非 200 或 `status``accepted`):失败处理同 4.4。
## 5. 接口清单(按调用方向)
> 说明:以下为接口“结构与字段清单”。具体 URL、Header、签名算法、错误码需 PlayX 提供或双方确认后固化。
> 说明:以下为接口“结构与字段清单”。具体 URL、Header、签名算法、错误码需 playX 提供或双方确认后固化。
### 5.1 PlayX → MallDaily Push API每日推送
### 5.1 playX → MallDaily Push API每日推送
- **目的**:推送昨日玩家数据,用于 T+1 计算入池与领取上限。
- **幂等键**`user_id + date`date 建议为 PlayX 业务日)
- **Method/Path建议**`POST /api/v1/playx/daily-push`
- **幂等键**`user_id + date`date 建议为 playX 业务日)
- **Method/Path建议**`POST /api/v1/mall/dailyPush`
请求字段说明(最小集合,来自现有资料):
@@ -167,7 +170,7 @@ flowchart LR
| --- | --- | --- | --- |
| `request_id` | String | 是 | 请求的唯一网关流水号,商城端用于日志追踪和外层防重。 |
| `date` | String | 是 | 数据归属的业务日期(如 `"2026-03-18"`),用于限定该批数据的生效周期。 |
| `user_id` | String | 是 | 玩家在 PlayX 的唯一标识 ID在此商城体系中以此作为核心绑定主键。 |
| `user_id` | String | 是 | 玩家在 playX 的唯一标识 ID在此商城体系中以此作为核心绑定主键。 |
| `username` | String | 否 | 玩家展示名,仅用于后台日志人工可读性或冗余展示,不作业务主键。 |
| `lifetime_total_deposit` | Decimal | 否 | 玩家历史总充值(如有需要用于玩家 VIP 分层,当前传值保留即可)。 |
| `lifetime_total_withdraw` | Decimal | 否 | 玩家历史总提现(储备字段)。 |
@@ -209,22 +212,58 @@ flowchart LR
}
```
### 5.2 Mall → PlayXToken Verification API
### 5.2 Mall → playXToken Verification API
- **目的**:商城后端校验 token/session,获取可信 `user_id``username`
- **Method/Path示例占位**`POST /api/v1/auth/verify-token`
- **目的**:商城后端校验 token获取可信 `user_id``username`
- **Method**`POST`
- **Content-Type**`application/json`
请求字段说明(建议):
#### 5.2.1 回调网关(推荐当前联调)
- **完整 URL**:由环境变量 **`PLAYX_TOKEN_VERIFY_URL`** 填完整地址,例如:
`https://callback-mallsys.superior3.net/callback/api/mallsys/plx/auth/verify-token`
- **请求 Header**:与 **angpow-imports** 相同风格,仅 **`Content-Type: application/json`**、**`X-Request-Signature`**`Base64(HMAC-SHA1(canonical, PLAYX_ANGPOW_IMPORT_AUTH_KEY))`,密钥解析与 angpow 一致hex / base64 / 明文)。
- **签名明文 canonical**`merchant_code={merchant_code}&request_date={request_date}&request_id={request_id}&token={token}``request_date` 为 Unix 秒字符串,与 Body 一致)。
- **请求 Body**(回调网关实际校验;在文档 `request_id`+`token` 基础上增加对端要求的 **`merchant_code``request_date`**
| 字段名 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `request_id` | String | 是 | 商城系统生成的唯一请求流水号。 |
| `token``session` | String | 是 | 从带有商城的 Iframe `postMessage` 接收到的用户加密登录散列或临时会话凭证。 |
| `merchant_code` | String | 是 | `PLAYX_ANGPOW_MERCHANT_CODE` 一致。 |
| `request_date` | String | 是 | Unix 秒时间戳字符串,须与参与签名一致。 |
| `request_id` | String | 是 | 商城生成的请求追踪号。 |
| `token` | String | 是 | 前端传入的 playX 临时凭证。 |
请求示例:
```json
{
"merchant_code": "plx",
"request_date": "1700000000",
"request_id": "mall_20260319_9f1b6d",
"token": "eyJhbGciOi..."
}
```
#### 5.2.2 商户网关(相对路径 + HMAC
- **Base URL**`PLAYX_ANGPOW_IMPORT_BASE_URL`
- **Path**`PLAYX_TOKEN_VERIFY_URL`(相对路径,如 `/api/v1/auth/verify-token`
- **完整 URL**`{PLAYX_ANGPOW_IMPORT_BASE_URL}{PLAYX_TOKEN_VERIFY_URL}`
- **签名 Header**`X-Request-Signature``Base64(HMAC-SHA1(canonical, key))`
- **签名明文 canonical**`merchant_code={merchant_code}&request_date={request_date}&request_id={request_id}&token={token}`
- **request_date**Unix 时间戳字符串(秒)
| 字段名 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `merchant_code` | String | 是 | 商户编码。 |
| `request_date` | String | 是 | 请求时间戳(秒)。 |
| `request_id` | String | 是 | 商城系统生成的唯一请求流水号。 |
| `token` | String | 是 | 用户登录凭证。 |
```json
{
"merchant_code": "plx",
"request_date": "1700000000",
"request_id": "mall_20260319_9f1b6d",
"token": "eyJhbGciOi..."
}
@@ -235,7 +274,7 @@ flowchart LR
| 字段名 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `request_id` | String | 是 | 透传请求时的 `request_id`。 |
| `user_id` | String | 是 | 该凭证解密后对应的、在 PlayX 平台具有唯一性的玩家专属 ID。 |
| `user_id` | String | 是 | 该凭证解密后对应的、在 playX 平台具有唯一性的玩家专属 ID。 |
| `username` | String | 否 | 该玩家显示名用于加载商城的界面头部“欢迎xxx”渲染。 |
| `token_expire_at` | String | 否 | Token 的物理过期时间(如 ISO8601用于商城前端预判是否到了需要执行无感续期重置的底线时间。 |
@@ -250,26 +289,26 @@ flowchart LR
}
```
### 5.3 Mall → PlayXBonus Grant API红利发放
### 5.3 Mall → playXBonus Grant API红利发放
来自 PlayX 现有字段清单(待 PlayX 确认最终口径):
来自 playX 现有字段清单(待 playX 确认最终口径):
请求字段说明(待 PlayX 确认最终口径):
请求字段说明(待 playX 确认最终口径):
| 字段名 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `request_id` | String | 是 | 商城发起的 HTTP 请求流水号(纯用于网关层)。 |
| `externalTransactionId` | String | 是 | **核心防重键:强制要求 PlayX 凭此字段做完全的幂等拦截**。这是商城侧派发红利的唯一本地订单号(如 `"BONUS_ORD001"`)。 |
| `user_id` | String | 是 | 要派发红利的玩家在 PlayX 的基础 ID这需对齐每日推送。 |
| `memberLogin` | String | 否 | 玩家登录名(若当前 PlayX 核心接口必须传登录名,则商城会补充;若以 `user_id` 为准,此项可废弃)。 |
| `externalTransactionId` | String | 是 | **核心防重键:强制要求 playX 凭此字段做完全的幂等拦截**。这是商城侧派发红利的唯一本地订单号(如 `"BONUS_ORD001"`)。 |
| `user_id` | String | 是 | 要派发红利的玩家在 playX 的基础 ID这需对齐每日推送。 |
| `memberLogin` | String | 否 | 玩家登录名(若当前 playX 核心接口必须传登录名,则商城会补充;若以 `user_id` 为准,此项可废弃)。 |
| `amount` | Decimal | 是 | 实际加给玩家游戏余额或红利钱包的具体现金数字。 |
| `rewardName` | String | 否 | 商城中对应的该红利商品名称,用于让用户后续在 PlayX 流水里看懂这笔钱从何而来。 |
| `rewardName` | String | 否 | 商城中对应的该红利商品名称,用于让用户后续在 playX 流水里看懂这笔钱从何而来。 |
| `description` | String | 否 | 系统行为备注说明(如 `"PointsMall bonus"`)。 |
| `memberInboxMessage` | String | 否 | 是否需借调此时机向玩家发送站内站群信内容提示。 |
| `category` | String | 是 | 标明该红利在游戏侧的所属业务类别的枚举代码(如 `daily`)。 |
| `categoryTitle` | String | 否 | 该红利业务类别的中文展示名称。 |
| `multiplier` (或 `turnover`) | Int | 是 | 款项入账后,玩家需完成的打码流水约束倍数(如 1 倍或 5 倍)。 |
| `startTime` / `endTime` | String | 否 | 红利生效时间窗口(起止时间,视 PlayX 规则传参)。 |
| `startTime` / `endTime` | String | 否 | 红利生效时间窗口(起止时间,视 playX 规则传参)。 |
请求示例:
@@ -296,7 +335,7 @@ flowchart LR
| 字段名 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `request_id` | String | 是 | 透传请求号。 |
| `playx_transaction_id` | String | 否 | PlayX 内部初创的接收入列单号或派发流水号,商城会将其归档以备应对极端客诉争议寻找记录用。 |
| `playx_transaction_id` | String | 否 | playX 内部初创的接收入列单号或派发流水号,商城会将其归档以备应对极端客诉争议寻找记录用。 |
| `status` | String | 是 | 核心状态枚举。若为 `accepted`,表示请求成功列入 10 分钟 Cron商城中止重试其他值皆触发商城的补偿拦截网。 |
| `message` | String | 否 | 对入列状态的额外提示信息内容。 |
@@ -311,7 +350,7 @@ flowchart LR
}
```
### 5.4 Mall → PlayXBalance Credit API提现回平台余额
### 5.4 Mall → playXBalance Credit API提现回平台余额
字段建议与 5.3 保持结构一致,至少包含:
@@ -323,7 +362,7 @@ flowchart LR
| `externalTransactionId` | String | 是 | **提现唯一单号,提现接口也需要基于此值做绝对拦截幂等功能**。 |
| `user_id` | String | 是 | 申请提现的玩家 ID。 |
| `memberLogin` | String | 否 | 玩家名(视老接口历史包袱兼容)。 |
| `amount` | Decimal | 是 | 本次提现要充入 PlayX 主游戏平台真金余额池的具体现金。 |
| `amount` | Decimal | 是 | 本次提现要充入 playX 主游戏平台真金余额池的具体现金。 |
| `multiplier` (或 `turnover_rule`) | Int | 是 | 本真金提现入账后的硬性流水锁定要求倍数限制。 |
| `description` | String | 否 | 日志源记录说明(如 `"PointsMall withdraw"`)。 |
@@ -337,7 +376,7 @@ flowchart LR
"memberLogin": "demo_user_01",
"amount": 100.0,
"multiplier": 1,
"description": "PointsMall withdraw to PlayX balance"
"description": "PointsMall withdraw to playX balance"
}
```
@@ -352,11 +391,11 @@ flowchart LR
}
```
### 5.5 Mall → PlayXTransaction Status Query API交易终态查询
### 5.5 Mall → playXTransaction Status Query API交易终态查询
- **目的**:红利/提现申请经 PlayX 接收后(即返回 `accepted` 后)可能处于排队发放下款状态(如 10 分钟 Cron。商城将通过此接口查询最终业务结果用于闭环商城自身的 PENDING 订单。
- **目的**:红利/提现申请经 playX 接收后(即返回 `accepted` 后)可能处于排队发放下款状态(如 10 分钟 Cron。商城将通过此接口查询最终业务结果用于闭环商城自身的 PENDING 订单。
- **Method/Path预留**`GET /api/v1/transaction/status`
- **传参方式**:使用 **Query String** 传递查询主键(若 PlayX 更倾向 POST可改为 `POST` + JSON body但需在联调前双方定稿一种即可
- **传参方式**:使用 **Query String** 传递查询主键(若 playX 更倾向 POST可改为 `POST` + JSON body但需在联调前双方定稿一种即可
示例:
@@ -378,21 +417,21 @@ flowchart LR
| --- | --- | --- | --- |
| `status` | String | 是 | 该笔资产调拨定时任务执行的彻底终态。只有两种预期终点:**`COMPLETED`**(入账成功) 或 **`FAILED`**(发放彻底阻断:如平台风控/未通过规则/账号封禁)。如果返回 `PENDING` 表示该 10 分钟 Cron 仍然没碰这笔单。 |
| `amount` | Decimal | 否 | 最后实际结算派发的精准明细金额数。 |
| `message` | String | 否 | 若拦截至 `FAILED` 终态,该字段负责说明 PlayX 端驳回的业务层原因,便于商城后端登记审计并自动回退积分。 |
| `message` | String | 否 | 若拦截至 `FAILED` 终态,该字段负责说明 playX 端驳回的业务层原因,便于商城后端登记审计并自动回退积分。 |
## 6. 一致性、幂等与退回规则
### 6.1 幂等原则
- **每日推送**:以 `user_id + date` 去重,重复推送不得导致重复入池。
- **兑换/提现交易**:以 `externalTransactionId` 幂等(商城生成并传给 PlayX
- **兑换/提现交易**:以 `externalTransactionId` 幂等(商城生成并传给 playX
- **领取**:以 `claim_request_id` 幂等,避免重复领取。
### 6.2 退积分规则(建议统一)
- **红利/提现**
- PlayX API 调用失败:订单保持 PENDING进入可重试队列不立即退积分避免“退了但 PlayX 已受理/最终入账”的不一致)。
- 当订单被判定为“最终失败”(例如超过最大重试次数或 PlayX 返回不可恢复错误)时:订单转 REJECTED退回积分并记录原因。
- playX API 调用失败:订单保持 PENDING进入可重试队列不立即退积分避免“退了但 playX 已受理/最终入账”的不一致)。
- 当订单被判定为“最终失败”(例如超过最大重试次数或 playX 返回不可恢复错误)时:订单转 REJECTED退回积分并记录原因。
- **实物**
- 驳回必须退回积分,并记录 `reject_reason`
@@ -400,12 +439,12 @@ flowchart LR
- 仅允许对 “尚未收到 `status = \"accepted\"` 响应、且可以确认未成功发放/未入账” 的订单发起重试。
- 每次重试必须生成并记录 `retry_request_id` 与操作者审计日志。
- **强制防重约定****PlayX 必须根据 `externalTransactionId` 提供严格的幂等拦截能力!**由于网络请求存在“Read Timeout读超时”的黑盒场景PlayX 实际已处理但响应由于网络中断未抵达商城,商城将会发起重试保护。如果 PlayX 不去拦截此重发单号,将必然出现给用户发双份钱的高危资损事故。
- **强制防重约定****playX 必须根据 `externalTransactionId` 提供严格的幂等拦截能力!**由于网络请求存在“Read Timeout读超时”的黑盒场景playX 实际已处理但响应由于网络中断未抵达商城,商城将会发起重试保护。如果 playX 不去拦截此重发单号,将必然出现给用户发双份钱的高危资损事故。
接收成功与终态闭环判定(关键约定):
- **第一步(接收排队)**:本系统调用发放类 API仅当收到 HTTP 200 且 `status = "accepted"` 时,视为 PlayX 已接收入队成功,此时商城**绝不再对发放接口发起新的成功路径请求**。若返回非 200、或响应超时、或未能解析出明确 `accepted`:商城可对**同一** `externalTransactionId` 进行有限次重试;**PlayX 须对该单号严格幂等**——重复请求不得产生第二笔发放,且应返回与首次受理一致或可识别的幂等结果(如再次返回 `accepted` 或明确 `DUPLICATE_REQUEST` 等,由双方约定响应形态)。
- **第二步(确认终态闭环)**:针对已入队返回 accepted 的订单,商城将调用**“交易终态查询 API”5.5**验证 PlayX 后台的最终发放结果实现闭环更新。
- **第一步(接收排队)**:本系统调用发放类 API仅当收到 HTTP 200 且 `status = "accepted"` 时,视为 playX 已接收入队成功,此时商城**绝不再对发放接口发起新的成功路径请求**。若返回非 200、或响应超时、或未能解析出明确 `accepted`:商城可对**同一** `externalTransactionId` 进行有限次重试;**playX 须对该单号严格幂等**——重复请求不得产生第二笔发放,且应返回与首次受理一致或可识别的幂等结果(如再次返回 `accepted` 或明确 `DUPLICATE_REQUEST` 等,由双方约定响应形态)。
- **第二步(确认终态闭环)**:针对已入队返回 accepted 的订单,商城将调用**“交易终态查询 API”5.5**验证 playX 后台的最终发放结果实现闭环更新。
默认重试策略(建议):
@@ -415,7 +454,7 @@ flowchart LR
## 7. 安全要求Shared Secret Key
建议所有 PlayX ↔ Mall 的后端调用统一:
建议所有 playX ↔ Mall 的后端调用统一:
- Header
- `X-Request-Id`
@@ -431,7 +470,7 @@ flowchart LR
其中:
- `PATH` 不含域名与 querystring例如 `/api/v1/playx/daily-push`)。
- `PATH` 不含域名与 querystring例如 `/api/v1/mall/dailyPush`)。
- `REQUEST_BODY_JSON` 使用原始 request body不做 key 排序时需双方约定序列化方式更推荐双方统一为“key 排序后的紧凑 JSON”
## 8. 错误码与可观测(建议)
@@ -459,7 +498,7 @@ flowchart LR
### 8.1 幂等:同一 `externalTransactionId` 重复调用Bonus Grant / Balance Credit
PlayX 须保证:**同一** `externalTransactionId` 无论被调用多少次,**资金侧最多只入账一次**。商城在「读超时重试」或联调压测时会重复提交同一单号,响应须符合以下 **两种约定之一**(联调前择一写死,避免双方解析不一致)。
playX 须保证:**同一** `externalTransactionId` 无论被调用多少次,**资金侧最多只入账一次**。商城在「读超时重试」或联调压测时会重复提交同一单号,响应须符合以下 **两种约定之一**(联调前择一写死,避免双方解析不一致)。
**模式 A推荐再次请求仍返回 HTTP 200且与首次受理语义一致**
@@ -475,7 +514,7 @@ PlayX 须保证:**同一** `externalTransactionId` 无论被调用多少次,
}
```
**模式 B显式重复错误码HTTP 状态可与 PlayX 规范一致,如 200 或 409联调前约定**
**模式 B显式重复错误码HTTP 状态可与 playX 规范一致,如 200 或 409联调前约定**
- `code``DUPLICATE_REQUEST`(或双方统一的幂等冲突码),`retryable``false`,提示商城勿再重试发放接口、改查 5.5 终态。
@@ -515,9 +554,9 @@ PlayX 须保证:**同一** `externalTransactionId` 无论被调用多少次,
### 9.4 红利/提现
- 发放接口HTTP 200 且 `status="accepted"` 后,订单 PENDING记录 `playx_transaction_id`**不再对发放接口重放**(终态靠 5.5)。
- 发放接口:非 200 / 超时 / 非 `accepted`:失败原因落库,可自动或人工重试;**PlayX 对同一 `externalTransactionId` 须严格幂等**。
- 发放接口:非 200 / 超时 / 非 `accepted`:失败原因落库,可自动或人工重试;**playX 对同一 `externalTransactionId` 须严格幂等**。
- **交易终态查询5.5**:按 `externalTransactionId` 查询,验证返回 `COMPLETED` / `FAILED` / `PENDING`;长时间 `PENDING` 走告警与人工。
- 幂等联调:同一 `externalTransactionId` 连续发送 2 次,PlayX 侧**不得重复入账**,第二次响应须符合双方约定的幂等语义。
- 幂等联调:同一 `externalTransactionId` 连续发送 2 次,playX 侧**不得重复入账**,第二次响应须符合双方约定的幂等语义。
### 9.5 实物
@@ -525,7 +564,7 @@ PlayX 须保证:**同一** `externalTransactionId` 无论被调用多少次,
- 发货录入物流单号
- 驳回退积分并展示原因
## 10. 需要 PlayX 提供/确认的信息清单(用于联调收口)
## 10. 需要 playX 提供/确认的信息清单(用于联调收口)
- **Token Verification API**URL、请求/响应字段、错误码、token 有效期/刷新策略、是否支持 session。
- **Daily Push API**推送时间点、时区、date 口径(业务日还是自然日)、失败重发策略、字段定义(特别是 `yesterday_win_loss_net` 的扣项范围)。

View File

@@ -1,15 +1,18 @@
# PlayX 接口文档(按调用方向拆分)
# playX 接口文档(按调用方向拆分)
说明:本文档严格依据当前代码 `app/api/controller/v1/Playx.php``app/api/controller/v1/Auth.php`(临时登录)、`config/playx.php` 与定时任务 `app/process/PlayxJobs.php` 整理。
三类接口分别为
- `积分商城 -> PlayX`PlayX 调用商城)
- `PlayX -> 积分商城`(商城调用 PlayX
- `积分商城 -> H5`H5 调用商城)
按调用方向分为三类(避免与历史章节标题混淆)
| 方向 | 含义 | 本文位置 |
|------|------|----------|
| **playX → 积分商城** | playX或上游批处理**主动 HTTP 调用商城**开放接口 | **§1**(如 Daily Push |
| **积分商城 → playX** | 商城 Worker / 后台 **主动 HTTP 调用 playX / Cash Market** 提供的接口 | **不展开于本文**;交付 playX 的说明见 **`docs/playX-接口待完善清单.md`** |
| **积分商城 → H5** | H5 / 内嵌页 **调用商城** 的会员与积分业务接口 | **§3** |
---
## 1. 积分商城 -> PlayXPlayX 调用商城
## 1. playX → 积分商城(外部系统调用商城开放接口
### 1.1 Daily Push API
* 方法:`POST`
@@ -19,7 +22,7 @@
- `lang`: `zh`/`zh-cn` 返回中文(默认);`en` 返回英文
#### Header签名校验HMAC 必填)
`playx.daily_push_secret` 配置非空时需要携带HMAC
`playX.daily_push_secret` 配置非空时需要携带HMAC
- `X-Request-Id`:请求 ID
- `X-Timestamp`:时间戳
- `X-Signature`签名HMAC_SHA256
@@ -36,8 +39,8 @@
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `request_id` | string | 是 | 外部推送请求号(原样返回) |
| `date` | string(YYYY-MM-DD) | 是 | 业务日期(入库到 `mall_playx_daily_push.date` |
| `user_id` | string | 是 | PlayX 用户 ID用于幂等入库 `mall_playx_daily_push.user_id` 等;服务端会映射/创建 `mall_user` `mall_playx_user_asset` |
| `date` | string(YYYY-MM-DD) | 是 | 业务日期(入库到 `mall_daily_push.date` |
| `user_id` | string | 是 | playX 用户 ID用于幂等入库 `mall_daily_push.user_id` 等;服务端会`user_id`/`username` **确保存在** `mall_user_asset` 资产行 |
| `username` | string | 否 | 展示冗余(同步到商城用户侧逻辑时使用) |
| `yesterday_win_loss_net` | number | 否 | 昨日净输赢(仅当 `< 0` 时计算新增保障金) |
| `yesterday_total_deposit` | number | 否 | 昨日总充值(用于计算今日可领取上限) |
@@ -53,9 +56,9 @@
{
"member_id": "123456",
"login": "john",
"lty_deposit": 15230.75,
"lty_withdrawal": 12400.50,
"yesterday_total_w": -320.25,
"ltv_deposit": 15230.75,
"ltv_withdrawal": 12400.50,
"yesterday_total_wl": -320.25,
"yesterday_total_deposit": 500.00
}
]
@@ -66,10 +69,10 @@
- `report_date` -> `date`(若为 Unix 秒则转为 `YYYY-MM-DD`
- `member[].member_id` -> `user_id`
- `member[].login` -> `username`
- `member[].yesterday_total_w` -> `yesterday_win_loss_net`
- `member[].yesterday_total_wl` -> `yesterday_win_loss_net`
- `member[].yesterday_total_deposit` -> `yesterday_total_deposit`
- `member[].lty_deposit` -> `lifetime_total_deposit`
- `member[].lty_withdrawal` -> `lifetime_total_withdraw`
- `member[].ltv_deposit` -> `lifetime_total_deposit`
- `member[].ltv_withdrawal` -> `lifetime_total_withdraw`
返回补充:
- 批量模式会在 `data` 里增加 `results[]`,每个成员一条结果(是否 `deduped`)。
@@ -144,9 +147,9 @@ curl -X POST 'http://localhost:1818/api/v1/mall/dailyPush' \
{
"member_id": "123456",
"login": "john",
"lty_deposit": 15230.75,
"lty_withdrawal": 12400.50,
"yesterday_total_w": -320.25,
"ltv_deposit": 15230.75,
"ltv_withdrawal": 12400.50,
"yesterday_total_wl": -320.25,
"yesterday_total_deposit": 500.00
}
]
@@ -178,188 +181,39 @@ curl -X POST 'http://localhost:1818/api/v1/mall/dailyPush' \
---
## 2. PlayX -> 积分商城(商城调用 PlayX
## 2. 积分商城 → playX贵方需提供的 HTTP 接口
> 下面这些接口由 PlayX 提供。商城侧仅按“请求参数 + 期望返回判定条件”发起调用与处理结果
> **说明**H5 调商城的 **`/api/v1/mall/verifyToken`** 在配置 **`playx.verify_token_local_only=true`**(默认)时**不会请求**本节接口,而是在商城内校验 `muser` token远程对接 PlayX 时见 **3.3** 与下文 **2.1**。
商城在验 Token、红利发放、交易轮询、Angpow 导入等场景会 **主动请求 playX / Cash Market**
**完整 URL、请求/响应字段、成功判定、与 Angpush 双路径关系、联调待办** 已单独整理,便于 **直接转发给 playX 平台**
### 2.1 Token Verification APIPlayX 侧实现,远程验证时使用)
* 方法:`POST`
* URL`${playx.api.base_url}${playx.api.token_verify_url}`
* 默认:`/api/v1/auth/verify-token`
- **`docs/playX-接口待完善清单.md`**
#### 请求 Body商城侧发送
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `request_id` | string | 是 | 形如 `mall_{uniqid}` |
| `token` | string | 是 | 前端传入的 PlayX token |
#### 返回(期望)
商城侧校验:
* HTTP 状态码必须为 `200`
* 且响应体中必须包含 `user_id`
期望字段(示例):
| 字段 | 类型 | 说明 |
|------|------|------|
| `user_id` | string | 必选 |
| `username` | string | 可选 |
| `token_expire_at` | string | 可选(能被 `strtotime` 解析) |
示例(成功):
```json
{
"user_id": "U123",
"username": "demo_user",
"token_expire_at": "2026-04-01T12:00:00Z"
}
```
示例(失败):
```json
{
"message": "invalid token"
}
```
本文 **§1** 仅描述「谁调用商城」;**§3** 描述「H5 调用商城」。
---
### 2.2 Bonus Grant API
* 方法:`POST`
* URL`${playx.api.base_url}${playx.api.bonus_grant_url}`
* 默认:`/api/v1/bonus/grant`
#### 请求 Body商城侧发送
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `request_id` | string | 是 | 形如 `mall_bonus_{uniqid}` |
| `externalTransactionId` | string | 是 | `MallPlayxOrder.external_transaction_id` |
| `user_id` | string | 是 | PlayX 用户 ID |
| `amount` | number | 是 | `MallPlayxOrder.amount` |
| `rewardName` | string | 是 | `mall_item.title` |
| `category` | string | 是 | `mall_item.category`(默认 `daily` |
| `categoryTitle` | string | 是 | `mall_item.category_title` |
| `multiplier` | int | 是 | `MallPlayxOrder.multiplier` |
#### 返回(期望)
商城侧判定:
* HTTP 状态码 `200`
*`data.status === "accepted"`
成功时读取:
* `data.playx_transaction_id`
失败时读取:
* `data.message` 写入订单 `fail_reason`
示例accepted
```json
{
"status": "accepted",
"playx_transaction_id": "PX_TX_001"
}
```
示例rejected
```json
{
"status": "rejected",
"message": "insufficient balance"
}
```
---
### 2.3 Balance Credit API
* 方法:`POST`
* URL`${playx.api.base_url}${playx.api.balance_credit_url}`
* 默认:`/api/v1/balance/credit`
#### 请求 Body商城侧发送
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `request_id` | string | 是 | 形如 `mall_withdraw_{uniqid}` |
| `externalTransactionId` | string | 是 | `MallPlayxOrder.external_transaction_id` |
| `user_id` | string | 是 | PlayX 用户 ID |
| `amount` | number | 是 | `MallPlayxOrder.amount` |
| `multiplier` | int | 是 | `MallPlayxOrder.multiplier` |
#### 返回(期望)
与 Bonus Grant 一致:
* `data.status === "accepted"` -> 读取 `playx_transaction_id`
* 否则 -> 读取 `message` 写入 `fail_reason`
示例accepted
```json
{
"status": "accepted",
"playx_transaction_id": "PX_TX_002"
}
```
示例rejected
```json
{
"status": "rejected",
"message": "insufficient balance"
}
```
---
### 2.4 Transaction Status Query API交易终态查询
* 方法:`GET`
* URL`${playx.api.base_url}${playx.api.transaction_status_url}`
* 默认:`/api/v1/transaction/status`
#### Query 参数
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `externalTransactionId` | string | 是 | 订单幂等键 `external_transaction_id` |
#### 返回(期望)
定时任务读取 `data.status`
* `COMPLETED`:商城将订单 `status` 更新为 `COMPLETED`
* `FAILED``REJECTED`:商城将订单 `status=REJECTED``grant_status=FAILED_FINAL`,并退回积分
* 失败信息取 `data.message` 写入订单 `fail_reason`
示例completed
```json
{ "status": "COMPLETED" }
```
示例failed
```json
{ "status": "FAILED", "message": "grant rejected by PlayX" }
```
---
## 3. 积分商城 -> H5服务端提供给 H5 的接口)
## 3. 积分商城 → H5服务端提供给 H5 的接口)
### 3.0 数据模型说明(与代码一致)
* **商城用户**`mall_user`(主键 `id`)。
* **PlayX 资产扩展**`mall_playx_user_asset`,与 `mall_user` **一对一**`mall_user_id` 唯一,`playx_user_id` 一)。
* **对外业务 ID**:接口里返回或订单里使用的 `user_id` 字符串多为 **PlayX 侧用户 ID**`playx_user_id`H5 临时登录场景若尚无真实 PlayX ID会生成形如 **`mall_{mall_user.id}`** 的占位 ID`temLogin`)。
* **服务端内部**`Playx` 控制器内部用 **`mall_user.id`**整型)解析资产;`session_id` / `token` / `user_id` 会映射到该 `mall_user`
* **积分商城用户资产主表**`mall_user_asset`(账号、积分、`playx_user_id`H5 临时登录 `temLogin` 直接创建/复用该表行,**不依赖**独立会员 `user`)。
* **会话缓存**`mall_session`(字段含 `session_id``user_id`(此处存 **playX 侧用户标识字符串**,与 `mall_user_asset.playx_user_id`致)、`expire_time`)。
* **统一订单**`mall_order`(红利/实物/提现订单;`user_id` 字段为 **`playx_user_id` 字符串**)。
* **对外业务 ID**:订单与推送中的 `user_id` 多为 **playX 用户 ID**`playx_user_id`)。临时登录场景下,资产表会生成占位 ID形如 **`mall_{mall_user_asset.id}`**`MallUserAsset::ensureForUsername`
### 3.1 鉴权解析规则(`resolveMallUserIdFromRequest`
### 3.1 鉴权解析规则(`resolvePlayxAssetIdFromRequest`
以下接口在服务端最终都会解析出 **商城用户 `mall_user.id`**,再按该用户查询 `mall_playx_user_asset`
以下接口在服务端最终都会解析出 **`mall_user_asset.id`(整型,资产表主键)**,再按该 ID 加载资产与关联数据
优先级(由高到低):
1. **`session_id`**`post` 优先,`get` 兼容)
*`mall_playx_session` 中存在且未过期:用会话里的 `user_id``playx_user_id`)在 `mall_playx_user_asset` 反查 `mall_user_id`
*`mall_session` 中存在且未过期:用会话里的 `user_id``playx_user_id` 字符串)在 `mall_user_asset` `playx_user_id` 查找,得到资产主键
* 若会话无效:兼容把 `session_id` 参数误当作 **商城 token** 再试一次UUID 形态 token
2. **`token`**`post` / `get` 或请求头 **`ba-token`** / **`token`**
* 校验 `token` 表:类型为会员 `user` 或商城临时 **`muser`**`mall_user` 登录),未过期`user_id` 字段**`mall_user.id`**。
3. **`user_id`**`post` / `get` 兼容)
* **纯数字**:视为 **`mall_user.id`**。
* **非纯数字**:视为 **`playx_user_id`**,在 `mall_playx_user_asset` 查找对应 `mall_user_id`
* 校验 `token` 表:类型为会员 `user` 或商城临时 **`muser`**未过期时,`user_id` 字段为 **`mall_user_asset.id`**`muser`)或会员体系约定 ID`user`
> 注意:请求参数的取值方式是 `post()` 优先,`get()` 兼容(即同字段既可传 post 也可传 get
> 注意:请求参数的取值方式是 `post()` 优先,`get()` 兼容。不再支持单独传 `user_id` 作为鉴权
---
@@ -373,19 +227,18 @@ curl -X POST 'http://localhost:1818/api/v1/mall/dailyPush' \
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `username` | string | 是 | 商城用户名(唯一);不存在则自动创建 `mall_user` |
| `username` | string | 是 | 登录名(唯一);不存在则自动创建 `mall_user_asset` |
#### 行为说明
* `mall_user` 不存在:创建用户(随机占位手机号、随机密码等,与后台「商城用户」一致)。
* **无论是否新用户**:保证存在 **`mall_playx_user_asset`** 一条记录(`MallPlayxUserAsset::ensureForMallUser``playx_user_id` 默认 **`mall_{mall_user.id}`**(与 PlayX 真实 ID 冲突概率低)
* 签发 **商城 token**(类型 **`muser`**,非会员表 `user`),并签发 `muser-refresh` 刷新令牌。
*用户名不存在:`MallUserAsset::ensureForUsername` 创建资产行(随机密码等),并将 `playx_user_id` 更新为 **`mall_{id}`** 形式(与真实 playX ID 区分)。
* 签发 **商城 token**(类型 **`muser`**`token` 表内 `user_id` = **`mall_user_asset.id`**),并签发 `muser-refresh` 刷新令牌
#### 返回(成功 data.userInfo
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | int | `mall_user.id` |
| `id` | int | **`mall_user_asset.id`** |
| `username` | string | 用户名 |
| `nickname` | string | 同 `username` |
| `playx_user_id` | string | 资产表中的 `playx_user_id`(如 `mall_12` |
@@ -408,16 +261,16 @@ curl -G 'http://localhost:1818/api/v1/temLogin' --data-urlencode 'username=demo_
* 方法:`POST`(推荐 `GET``token` 亦可)
* 路径:`/api/v1/mall/verifyToken`
#### 配置:本地验证 vs 远程 PlayX
#### 配置:本地验证 vs 远程 playX
* 配置项:`config/playx.php`**`verify_token_local_only`**(环境变量 **`PLAYX_VERIFY_TOKEN_LOCAL_ONLY`**,未设置时默认为 **`1` / 开启本地验证)。
* **`verify_token_local_only = true`(默认)**
* **不请求** PlayX HTTP。
* 仅接受商城临时登录 token类型 **`muser`**),校验 `token` 表后,根据 `mall_user``mall_playx_user_asset` 写入 `mall_playx_session`
* 返回的 `data.user_id`**`playx_user_id`**资产记录时回退为 `mall_user.id` 字符串,一般 temLogin 后已有资产)。
* **`verify_token_local_only = false`**(生产对接 PlayX
* 需配置 **`playx.api.base_url`**,由商城向 PlayX 发起 `POST` 校验(见下文「远程模式」)。
* 若未配置 `base_url`,返回 `PlayX API not configured`
* **不请求** playX HTTP。
* 仅接受商城临时登录 token类型 **`muser`**),校验 `token` 表后写入 **`mall_session`**
* 返回的 `data.user_id`**`playx_user_id`**资产表一致)。
* **`verify_token_local_only = false`**(生产对接 playX
* 需配置 **`PLAYX_TOKEN_VERIFY_URL`**(完整 `https` 回调或相对路径)、**`PLAYX_ANGPOW_MERCHANT_CODE`**、**`PLAYX_ANGPOW_IMPORT_AUTH_KEY`** 等(见 `docs/PlayX-对接文档(积分商城).md` §5.2)。
* 相对路径时尚需 **`PLAYX_ANGPOW_IMPORT_BASE_URL`**;未配置则返回未配置类错误
#### 请求参数
@@ -425,19 +278,21 @@ curl -G 'http://localhost:1818/api/v1/temLogin' --data-urlencode 'username=demo_
* `token`Body 优先;`session` 兼容字段Query 也可传 `token`
可选 **`lang`**`zh` / `ZH` / `zh-cn` 中文 **`msg`**(默认),`en` / `EN` 英文可通过请求头、Query 或与 Body 同传的字段指定。
#### 返回(成功 data
| 字段 | 类型 | 说明 |
|------|------|------|
| `session_id` | string | 写入 `mall_playx_session` |
| `user_id` | string | PlayX 用户 ID`playx_user_id`,会话内与订单/推送一致) |
| `session_id` | string | 写入 `mall_session` |
| `user_id` | string | playX 用户 ID`playx_user_id`,会话内与订单/推送一致) |
| `username` | string | 用户名 |
| `token_expire_at` | string | ISO 字符串(服务端 `date('c', expireAt)` |
失败:
* token 为空HTTP 401msg=`INVALID_TOKEN`
* 远程模式且 PlayX 未配置:`msg=PlayX API not configured`
* 远程模式且 playX 未配置:`msg=playX API not configured`
#### 示例(本地验证)
@@ -451,7 +306,7 @@ curl -X POST 'http://localhost:1818/api/v1/mall/verifyToken' \
### 3.x 收货地址(`mall_address`
> 下面接口用于 H5 维护收货地址。鉴权同本章其他接口:携带 `session_id` 或 `token` 或 `user_id`
> 下面接口用于 H5 维护收货地址。鉴权同本章其他接口:携带 `session_id` 或 `token`。
#### 3.x.1 地址列表
* 方法:`GET`
@@ -468,8 +323,7 @@ Body
|------|------|------|
| `receiver_name` | 是 | 收货人 |
| `phone` | 是 | 电话 |
| `region` | 是 | 地区(数组或逗号分隔字符串) |
| `detail_address` | 是 | 详细地址 |
| `detail_address` | 是 | 完整收货地址 |
| `default_setting` | 否 | `1` 设为默认地址 |
#### 3.x.3 修改地址(含设为默认)
@@ -484,37 +338,6 @@ Body`id` 必填,其余字段按需传入更新。
Body`id` 必填。若删除默认地址,服务端会自动挑选一条剩余地址设为默认(如存在)。
#### 远程模式(`verify_token_local_only=false` + 已配置 `base_url`
商城侧请求 URL`${playx.api.base_url}${playx.api.token_verify_url}`(默认路径 `/api/v1/auth/verify-token`)。
#### 请求 Body商城侧发送——仅远程模式
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `request_id` | string | 是 | 形如 `mall_{uniqid}` |
| `token` | string | 是 | 前端传入的 PlayX token |
#### 返回(期望)——仅远程模式
* HTTP 状态码必须为 `200`
* 且响应体中必须包含 `user_id`
响应(成功示例):
```json
{
"code": 1,
"msg": "",
"data": {
"session_id": "7b1c....",
"user_id": "U123",
"username": "demo_user",
"token_expire_at": "2026-04-01T12:00:00+00:00"
}
}
```
---
### 3.4 用户资产Assets
@@ -527,7 +350,6 @@ Body`id` 必填。若删除默认地址,服务端会自动挑选一条剩
* `session_id`
* `token`(或请求头 `ba-token` / `token`
* `user_id`(纯数字为 `mall_user.id`,否则为 `playx_user_id`
#### 返回(成功 data
若未找到资产:返回 0。
@@ -571,7 +393,7 @@ curl -G 'http://localhost:1818/api/v1/mall/assets' --data-urlencode 'token=上
* `claim_request_id`幂等键string唯一
鉴权:同 **3.1**`session_id` / `token` / `user_id`
鉴权:同 **3.1**`session_id` / `token`
#### 返回(成功 data
`用户资产` 返回字段一致(资产快照)。
@@ -671,7 +493,7 @@ curl -G 'http://localhost:1818/api/v1/mall/items' --data-urlencode 'type=WITHDRA
#### 请求 Body
必填:
* `item_id`:商品 ID要求 `mall_item.type=BONUS``status=1`
鉴权:同 **3.1**`session_id` / `token` / `user_id`
鉴权:同 **3.1**`session_id` / `token`
#### 返回(成功)
* `msg``Redeem submitted, please wait about 10 minutes`
@@ -709,7 +531,7 @@ curl -X POST 'http://localhost:1818/api/v1/mall/bonusRedeem' \
必填:
* `item_id`:商品 ID要求 `mall_item.type=PHYSICAL``status=1`
* `address_id`:收货地址 ID`mall_address.id`,须属于当前用户资产;下单时写入 `mall_order.mall_address_id`,并将该地址快照写入 `receiver_name` / `receiver_phone` / `receiver_address`
鉴权:同 **3.1**`session_id` / `token` / `user_id`
鉴权:同 **3.1**`session_id` / `token`
#### 返回(成功)
* `msg``Redeem success`
@@ -743,7 +565,7 @@ curl -X POST 'http://localhost:1818/api/v1/mall/physicalRedeem' \
#### 请求 Body
必填:
* `item_id`:商品 ID要求 `mall_item.type=WITHDRAW``status=1`
鉴权:同 **3.1**`session_id` / `token` / `user_id`
鉴权:同 **3.1**`session_id` / `token`
#### 返回(成功)
* `msg``Withdraw submitted, please wait about 10 minutes`
@@ -779,12 +601,12 @@ curl -X POST 'http://localhost:1818/api/v1/mall/withdrawApply' \
#### 请求参数(鉴权)
**3.1**`session_id` / `token` / `user_id`)。
**3.1**`session_id` / `token`)。
#### 返回(成功 data
* `list`:订单列表(最多 100 条),并包含关联的 `mallItem`(关系对象)
* 列表项中的 `user_id`**PlayX 侧 `playx_user_id`**(字符串),与 `mall_playx_order.user_id` 一致
* 列表项中的 `user_id`**playX 侧 `playx_user_id`**(字符串),与 `mall_order.user_id` 一致
#### 示例
请求:
@@ -823,6 +645,64 @@ curl -G 'http://localhost:1818/api/v1/mall/orders' --data-urlencode 'token=上
---
### 3.11 同步额度(可选
### 3.11 积分流水Points Logs
* 方法:`GET`
* 路径:`/api/v1/mall/pointsLogs`
#### 请求参数(鉴权)
**3.1**`session_id` / `token`)。
#### Query 参数
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `limit` | int | 否 | 每页条数,默认 20最大 100 |
| `cursor` | string | 否 | 游标(上一页返回 `next_cursor` |
| `direction` | string | 否 | `IN` 仅入账 / `OUT` 仅扣减;不传返回全部 |
#### 返回(成功 data
| 字段 | 类型 | 说明 |
|------|------|------|
| `data.list` | array | 流水数组(时间倒序) |
| `data.next_cursor` | string\|null | 下一页游标(本页最后一条记录的游标) |
`list` 每一项字段:
| 字段 | 类型 | 说明 |
|------|------|------|
| `biz_type` | string | `CLAIM`/`REDEEM_BONUS`/`REDEEM_PHYSICAL`/`REDEEM_WITHDRAW`/`REFUND` |
| `direction` | string | `IN`/`OUT` |
| `points` | int | 积分变动值(正数,方向由 `direction` 表示) |
| `ts` | int | Unix 秒 |
| `ref_id` | string | 领取为 `claim_request_id`;订单为 `external_transaction_id` |
| `order_no` | string | 订单号(非订单类为空) |
| `order_status` | string | 订单状态(非订单类为空) |
| `mallItem` | object\|null | 商品信息(非订单类为 null |
| `item_id` | int | 兼容字段商品ID非订单类为 0 |
| `item_title` | string | 兼容字段:商品标题(非订单类为空) |
| `item_type` | int | 兼容字段:商品类型(非订单类为 0`1=BONUS 2=PHYSICAL 3=WITHDRAW` |
| `item_score` | int | 兼容字段:商品所需积分(非订单类为 0 |
| `cursor` | string | 当前记录游标 |
`mallItem` 字段(非隐私):
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | int | `mall_item.id` |
| `title` | string | 商品名 |
| `type` | int | 商品类型 |
| `score` | int | 所需积分 |
| `amount` | number | 现金面值 |
| `multiplier` | int | 流水倍数 |
| `category` | string | 红利业务类别 |
| `category_title` | string | 类别展示名 |
#### 示例
```bash
curl -G 'http://localhost:1818/api/v1/mall/pointsLogs' \
--data-urlencode 'token=上一步temLogin返回的token' \
--data-urlencode 'limit=20'
```
---
### 3.12 同步额度(可选)
当前代码未实现并未注册路由:`/api/v1/mall/syncLimit`

View File

@@ -1,6 +1,6 @@
# PlayX 调用积分商城接口说明
# playX 调用积分商城接口说明
本文档描述 **PlayX 平台(或 PlayX 侧脚本/服务)如何调用积分商城已开放的 HTTP 接口**:基础约定、推荐流程、鉴权方式、请求参数与返回结构。
本文档描述 **playX 平台(或 playX 侧脚本/服务)如何调用积分商城已开放的 HTTP 接口**:基础约定、推荐流程、鉴权方式、请求参数与返回结构。
实现依据:`config/route.php``app/api/controller/v1/Playx.php``config/playx.php`
@@ -68,13 +68,13 @@
## 2. 使用流程(推荐)
### 2.1 PlayX 服务端 → 商城:每日数据推送(主流程)
### 2.1 playX 服务端 → 商城:每日数据推送(主流程)
适用于 T+1 等业务数据由 **PlayX 服务端**主动推送到积分商城。
适用于 T+1 等业务数据由 **playX 服务端**主动推送到积分商城。
```mermaid
sequenceDiagram
participant PX as PlayX 服务端
participant PX as playX 服务端
participant M as 积分商城
Note over PX,M: 按约定配置 HMAC 密钥
@@ -88,24 +88,20 @@ sequenceDiagram
### 2.2 用户侧H5 / 内嵌页)→ 商城:会话与业务接口
以下接口多由 **用户在浏览器内**打开积分商城 H5 后调用,通过 **`session_id`**(先调 `verifyToken` 获取)或 **`token`**(商城 `muser` 类 token标识用户**不一定由 PlayX 后端直接调用**
以下接口多由 **用户在浏览器内**打开积分商城 H5 后调用,通过 **`session_id`**(先调 `verifyToken` 获取)或 **`token`**(商城 `muser` 类 token标识用户**不一定由 playX 后端直接调用**
- `POST /api/v1/mall/verifyToken`:用 PlayX token 换商城 `session_id`
- `POST /api/v1/mall/verifyToken`:用 playX token 换商城 `session_id`
- `GET /api/v1/mall/assets`:查询资产
- `POST /api/v1/mall/claim`:领取积分
- `GET /api/v1/mall/items`:商品列表
- `POST /api/v1/mall/bonusRedeem` / `physicalRedeem` / `withdrawApply`:兑换与提现申请
- `GET /api/v1/mall/orders`:订单列表
PlayX 后端需要代替用户调用上述接口,须同样携带有效的 `session_id``token`,并遵守同一用户身份规则(见 **§4 身份说明**)。
### 2.3 代理鉴权(非 PlayX 通用)
`GET /api/v1/authToken` 为 **渠道/代理**签名换 JWT`authtoken`),与 PlayX 用户体系不同一般不在本文「PlayX 平台对接」主流程中展开;需要时由运营向商城索取单独说明。
playX 后端需要代替用户调用上述接口,须同样携带有效的 `session_id``token`,并遵守同一用户身份规则(见 **§4 身份说明**)。
---
## 3. PlayX 服务端推送Daily Push
## 3. playX 服务端推送Daily Push
### 3.1 概要
@@ -157,7 +153,7 @@ expected = HMAC_SHA256( canonical , PLAYX_DAILY_PUSH_SECRET )
|------|------|------|------|
| `request_id` | string | 是 | 本次推送请求号;响应中原样返回 |
| `date` | string | 是 | 业务日期,格式 `YYYY-MM-DD` |
| `user_id` | string | 是 | PlayX 用户 ID幂等键之一 |
| `user_id` | string | 是 | playX 用户 ID幂等键之一 |
| `username` | string | 否 | 展示名;用于同步/创建商城侧用户资产展示信息 |
| `yesterday_win_loss_net` | number | 否 | 昨日净输赢;**小于 0** 时按配置比例计入待领取保障金(`locked_points` |
| `yesterday_total_deposit` | number | 否 | 昨日总充值;用于计算当日可领取上限等 |
@@ -168,21 +164,21 @@ expected = HMAC_SHA256( canonical , PLAYX_DAILY_PUSH_SECRET )
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `report_date` | string/number | 是 | 报表日期;可以为 Unix 秒时间戳(如 `1700000000`)或 `YYYY-MM-DD` |
| `member` | array | 是 | 成员列表,每个成员包含一名 PlayX 用户数据 |
| `member` | array | 是 | 成员列表,每个成员包含一名 playX 用户数据 |
成员元素字段:
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `member_id` | string | 是 | PlayX 用户 ID幂等键之一 |
| `member_id` | string | 是 | playX 用户 ID幂等键之一 |
| `login` | string | 否 | 用户展示名 |
| `yesterday_total_w` | number | 否 | 昨日净输赢;小于 0 才会累加到 `locked_points` |
| `yesterday_total_wl` | number | 否 | 昨日净输赢;小于 0 才会累加到 `locked_points` |
| `yesterday_total_deposit` | number | 否 | 昨日总充值;用于计算 `today_limit` |
| `lty_deposit` | number | 否 | 历史总充值(冗余入库) |
| `lty_withdrawal` | number | 否 | 历史总提现(冗余入库) |
| `ltv_deposit` | number | 否 | 历史总充值(冗余入库) |
| `ltv_withdrawal` | number | 否 | 历史总提现(冗余入库) |
#### Body 填写要求(批量模式)
- **必须有**`report_date``member`(数组且至少 1 个元素)、`member[].member_id`
- **允许缺省**:成员的 `login/yesterday_total_w/yesterday_total_deposit/lty_deposit/lty_withdrawal`;缺省时按 `0` 或空字符串处理。
- **允许缺省**:成员的 `login/yesterday_total_wl/yesterday_total_deposit/ltv_deposit/ltv_withdrawal`;缺省时按 `0` 或空字符串处理。
- **日期**`report_date` 传 Unix 秒会自动转换成 `YYYY-MM-DD`;如果直接传 `YYYY-MM-DD` 也支持。
### 3.4 幂等
@@ -226,9 +222,9 @@ curl -X POST 'https://{商城域名}/api/v1/mall/dailyPush' \
{
"member_id": "123456",
"login": "john",
"lty_deposit": 15230.75,
"lty_withdrawal": 12400.50,
"yesterday_total_w": -320.25,
"ltv_deposit": 15230.75,
"ltv_withdrawal": 12400.50,
"yesterday_total_wl": -320.25,
"yesterday_total_deposit": 500.00
}
]
@@ -275,159 +271,368 @@ curl -X POST 'https://{商城域名}/api/v1/mall/dailyPush' \
---
## 5. 其他接口一览(摘要
## 5. 其他接口一览(前端联调版
> 下列均为 **BuildAdmin 通用 `code/msg/time/data` 结构**;成功时 `code=1`。
> 下列接口统一返回 `code/msg/time/data`;成功通常为 `code=1`。
> 除 `verifyToken` 外,其余用户接口均需携带 `session_id` 或 `token`(见 §4
### 5.0 `POST /api/v1/mall/dailyPush`(后端对后端)
**用途**playX 按日推送用户资产基础数据到商城(前端一般不直接调用)。
**请求示例:**
```json
{
"request_id": "report_20260430",
"rows": [
{
"user_id": "U10001",
"username": "demo_user",
"yesterday_total_deposit": 1000,
"yesterday_win_loss_net": -500
}
]
}
```
### 5.1 `POST /api/v1/mall/verifyToken`
**用途**:把 playX token 换成商城 `session_id`
用于将 **PlayX token**(或本地联调 token**商城 `session_id`**
**语言 `lang`(响应 `msg` 等多语言)**
- **默认中文**`zh-cn`)。
- 可通过 **请求头 `lang`**、**Query `lang`** 或 **表单/JSON 字段 `lang`** 指定:`zh` / `ZH` / `zh-cn` → 中文,`en` / `EN` → 英文(与 `LoadLangPack` 规则一致)。
| 参数位置 | 名称 | 说明 |
|----------|------|------|
| POST/GET | `token``session` | PlayX 或商城 token |
**前端入参约定(重要)**
- 调用本接口时 **仅需 `token`**(或兼容 `session`**不要**传 `request_id` / `request_date` / 商户字段(均由商城后端生成后请求上游)。
- 建议 **`Content-Type: application/json`**Body`{"token":"..."}`。若使用 **form-data**(如图二),请同样只传 `token`(及可选 `lang`);避免缺少 `token` 键名。
- 商城请求上游回调地址时Body 含 **`merchant_code``request_date`(秒)、`request_id``token`**`X-Request-Signature` 与 angpow-imports **同源算法**canonical 为 **`merchant_code=...&request_date=...&request_id=...&token=...`**。
**说明**`playx.verify_token_local_only=true`(默认),商城**仅本地校验** token不请求 PlayX 远程接口;远程模式需配置 `PLAYX_API_BASE_URL` 等。
**请求示例**
```json
{
"token": "eyJhbGciOi..."
}
```
**成功 `data` 示例:**
带英文响应示例Query
| 字段 | 说明 |
|------|------|
| `session_id` | 后续接口可带此字段 |
| `user_id` | PlayX 用户 ID 或映射后的标识 |
| `username` | 用户名 |
| `token_expire_at` | ISO8601 过期时间 |
```http
POST /api/v1/mall/verifyToken?lang=en
Content-Type: application/json
---
{"token":"eyJhbGciOi..."}
```
**成功响应示例:**
```json
{
"code": 1,
"msg": "Success",
"time": 1777533000,
"data": {
"session_id": "fc7f3e3f0d0f4cb29f66e4c8fbab4f66",
"user_id": "U10001",
"username": "demo_user",
"token_expire_at": "2026-04-30T16:20:00+08:00"
}
}
```
### 5.2 `GET /api/v1/mall/assets`
**用途**:查询当前用户积分资产。
查询积分资产;需 **§4** 身份。
**请求示例:**
```http
GET /api/v1/mall/assets?session_id=fc7f3e3f0d0f4cb29f66e4c8fbab4f66
```
**成功 `data`**
| 字段 | 说明 |
|------|------|
| `locked_points` | 待领取积分 |
| `available_points` | 可用积分 |
| `today_limit` | 今日可领取上限 |
| `today_claimed` | 今日已领取 |
| `withdrawable_cash` | 可提现现金(由积分×配置比例换算,保留小数) |
---
**成功响应示例**
```json
{
"code": 1,
"msg": "Success",
"time": 1777533000,
"data": {
"locked_points": 120,
"available_points": 350,
"today_limit": 200,
"today_claimed": 80,
"withdrawable_cash": 35
}
}
```
### 5.3 `POST /api/v1/mall/claim`
**用途**:领取积分(幂等)。
领取积分;需 **§4** 身份。
**请求示例:**
```json
{
"session_id": "fc7f3e3f0d0f4cb29f66e4c8fbab4f66",
"claim_request_id": "claim_20260430_0001"
}
```
| 参数 | 必填 | 说明 |
|------|------|------|
| `claim_request_id` | 是 | 幂等请求号 |
**成功 `data`** 与资产结构一致(含 `locked_points``available_points``today_limit``today_claimed``withdrawable_cash` 等)。
---
**成功响应示例:**
```json
{
"code": 1,
"msg": "Success",
"time": 1777533000,
"data": {
"locked_points": 60,
"available_points": 410,
"today_limit": 200,
"today_claimed": 140,
"withdrawable_cash": 41
}
}
```
### 5.4 `GET /api/v1/mall/items`
**用途**:获取商城商品列表(可按类型筛选)。
商品列表。
**请求示例:**
```http
GET /api/v1/mall/items?type=BONUS
```
| 参数 | 必填 | 说明 |
|------|------|------|
| `type` | 否 | `BONUS` / `PHYSICAL` / `WITHDRAW`,筛选类型 |
**成功 `data`** `{ "list": [ ... ] }`
---
**成功响应示例:**
```json
{
"code": 1,
"msg": "Success",
"time": 1777533000,
"data": {
"list": [
{
"id": 101,
"title": "10 MYR Bonus",
"type": "BONUS",
"points_cost": 1000
}
]
}
}
```
### 5.5 `POST /api/v1/mall/bonusRedeem`
**用途**:兑换红利商品。
红利兑换;需 **§4** 身份。
**请求示例:**
```json
{
"session_id": "fc7f3e3f0d0f4cb29f66e4c8fbab4f66",
"item_id": 101
}
```
| 参数 | 必填 | 说明 |
|------|------|------|
| `item_id` | 是 | 商品 ID |
**成功 `data`**`order_id``status`(如 `PENDING`)等。
---
**成功响应示例:**
```json
{
"code": 1,
"msg": "Success",
"time": 1777533000,
"data": {
"order_id": "ORD202604300001",
"status": "PENDING"
}
}
```
### 5.6 `POST /api/v1/mall/physicalRedeem`
**用途**:兑换实物商品(需要地址)。
实物兑换;需 **§4** 身份。
**请求示例:**
```json
{
"session_id": "fc7f3e3f0d0f4cb29f66e4c8fbab4f66",
"item_id": 202,
"address_id": 12
}
```
| 参数 | 必填 | 说明 |
|------|------|------|
| `item_id` | 是 | 实物商品 ID |
| `address_id` | 是 | `mall_address.id`(当前用户下地址);订单保存 `mall_address_id` 与地址快照 |
---
**成功响应示例:**
```json
{
"code": 1,
"msg": "Success",
"time": 1777533000,
"data": {
"order_id": "ORD202604300002",
"status": "PENDING"
}
}
```
### 5.7 `POST /api/v1/mall/withdrawApply`
**用途**:发起提现档位兑换申请。
提现类兑换申请;需 **§4** 身份。
**请求示例:**
```json
{
"session_id": "fc7f3e3f0d0f4cb29f66e4c8fbab4f66",
"item_id": 303
}
```
| 参数 | 必填 | 说明 |
|------|------|------|
| `item_id` | 是 | 提现档位商品 ID |
---
**成功响应示例:**
```json
{
"code": 1,
"msg": "Success",
"time": 1777533000,
"data": {
"order_id": "ORD202604300003",
"status": "PENDING"
}
}
```
### 5.8 `GET /api/v1/mall/orders`
**用途**:查询当前用户订单列表。
订单列表;需 **§4** 身份。
**请求示例:**
```http
GET /api/v1/mall/orders?session_id=fc7f3e3f0d0f4cb29f66e4c8fbab4f66
```
**成功 `data`** `{ "list": [ ... ] }`(含关联商品等,以实际返回为准)。
**成功响应示例:**
```json
{
"code": 1,
"msg": "Success",
"time": 1777533000,
"data": {
"list": [
{
"order_id": "ORD202604300001",
"status": "PENDING",
"item_title": "10 MYR Bonus"
}
]
}
}
```
---
### 5.9 `GET /api/v1/mall/pointsLogs`
**用途**:查询积分变动日志。
### 5.9 收货地址(`mall_address`
**请求示例:**
```http
GET /api/v1/mall/pointsLogs?session_id=fc7f3e3f0d0f4cb29f66e4c8fbab4f66
```
> 下列接口均需携带 **§4 身份参数**`session_id` / `token` / `user_id` 之一)。
**成功响应示例:**
```json
{
"code": 1,
"msg": "Success",
"time": 1777533000,
"data": {
"list": [
{
"id": 9001,
"change_points": 50,
"type": "CLAIM",
"remark": "daily claim"
}
]
}
}
```
#### 5.9.1 获取收货地址列表
- **方法**`GET`
- **路径**`/api/v1/mall/addressList`
### 5.10 收货地址(`mall_address`
**用途**:用户收货地址 CRUD。
返回 `data.list`:地址数组(按 `default_setting` 优先,其次 id 倒序)。
#### 5.10.1 `GET /api/v1/mall/addressList`
**请求示例:**
```http
GET /api/v1/mall/addressList?session_id=fc7f3e3f0d0f4cb29f66e4c8fbab4f66
```
#### 5.9.2 添加收货地址
- **方法**`POST`
- **路径**`/api/v1/mall/addressAdd`
**成功响应示例:**
```json
{
"code": 1,
"msg": "Success",
"time": 1777533000,
"data": {
"list": [
{
"id": 12,
"receiver_name": "Tom",
"phone": "0123456789",
"detail_address": "KLCC",
"default_setting": 1
}
]
}
}
```
Body表单或 JSON 均可,建议 JSON
| 字段 | 必填 | 说明 |
|------|------|------|
| `receiver_name` | 是 | 收货人 |
| `phone` | 是 | 联系电话 |
| `region` | 是 | 地区(可传数组或逗号分隔字符串) |
| `detail_address` | 是 | 详细地址(短文本) |
| `default_setting` | 否 | `1` 设为默认地址;`0` 或不传为非默认 |
#### 5.10.2 `POST /api/v1/mall/addressAdd`
**请求示例:**
```json
{
"session_id": "fc7f3e3f0d0f4cb29f66e4c8fbab4f66",
"receiver_name": "Tom",
"phone": "0123456789",
"detail_address": "Tower A, 8F, Kuala Lumpur",
"default_setting": 1
}
```
成功返回:`data.id` 为新地址 id。
**成功响应示例:**
```json
{
"code": 1,
"msg": "Success",
"time": 1777533000,
"data": {
"id": 13
}
}
```
#### 5.9.3 修改收货地址(含设置默认)
- **方法**`POST`
- **路径**`/api/v1/mall/addressEdit`
#### 5.10.3 `POST /api/v1/mall/addressEdit`
**请求示例:**
```json
{
"session_id": "fc7f3e3f0d0f4cb29f66e4c8fbab4f66",
"id": 13,
"detail_address": "Tower B, 10F",
"default_setting": 1
}
```
Body
| 字段 | 必填 | 说明 |
|------|------|------|
| `id` | 是 | 地址 id |
| `receiver_name/phone/region/detail_address/default_setting` | 否 | 需要修改的字段(只更新传入项) |
**成功响应示例:**
```json
{
"code": 1,
"msg": "Success",
"time": 1777533000,
"data": null
}
```
`default_setting=1`:会自动把该用户其他地址的 `default_setting` 置为 0。
#### 5.10.4 `POST /api/v1/mall/addressDelete`
**请求示例:**
```json
{
"session_id": "fc7f3e3f0d0f4cb29f66e4c8fbab4f66",
"id": 13
}
```
#### 5.9.4 删除收货地址
- **方法**`POST`
- **路径**`/api/v1/mall/addressDelete`
Body
| 字段 | 必填 | 说明 |
|------|------|------|
| `id` | 是 | 地址 id |
若删除的是默认地址:服务端会将剩余地址里 id 最大的一条自动设为默认(若存在)。
**成功响应示例:**
```json
{
"code": 1,
"msg": "Success",
"time": 1777533000,
"data": null
}
```
---
@@ -436,8 +641,11 @@ Body
| 环境变量 / 配置 | 作用 |
|-----------------|------|
| `PLAYX_DAILY_PUSH_SECRET` | 非空则 Daily Push 必须带合法 HMAC 头 |
| `PLAYX_VERIFY_TOKEN_LOCAL_ONLY` | 为 true 时 verifyToken 不请求 PlayX 远程 |
| `PLAYX_API_BASE_URL` | 商城调用 PlayX 接口时使用与「PlayX 调商城」方向相反) |
| `PLAYX_VERIFY_TOKEN_LOCAL_ONLY` | 为 true 时 verifyToken 不请求 playX 远程 |
| `PLAYX_ANGPOW_IMPORT_BASE_URL` | Angpow 推送等接口基地址;**相对路径** `verify-token` 时亦作其基地址 |
| `PLAYX_TOKEN_VERIFY_URL` | **完整 `https://` URL**:回调 Body `merchant_code`+`request_date`+`request_id`+`token`,签名明文同序;**相对路径**:拼基地址 + 商户四字段 |
| `PLAYX_ANGPOW_MERCHANT_CODE` | **回调与相对路径** `verify-token` 均需 |
| `PLAYX_ANGPOW_IMPORT_AUTH_KEY` | 回调与相对路径的 HMAC 密钥(与 angpow-imports 相同) |
---

BIN
docs/import-angpow-api.pdf Normal file

Binary file not shown.

View File

@@ -1,20 +1,20 @@
# 积分商城 PlayX 对接实施方案
# 积分商城 playX 对接实施方案
> 基于《积分商城-内部对接与流程说明.md》和《PlayX-对接文档(积分商城).md》整理结合当前项目结构给出具体落地方案。
> 基于《积分商城-内部对接与流程说明.md》和《playX-对接文档(积分商城).md》整理结合当前项目结构给出具体落地方案。
---
## 一、接口创建
### 1.1 商城需对外提供的接口(PlayX 调用商城)
### 1.1 商城需对外提供的接口(playX 调用商城)
#### Daily Push API
接收 PlayX 每日 T+1 数据推送。
接收 playX 每日 T+1 数据推送。
* 方法:`POST`
* 路径:`/api/v1/mall/dailyPush`
##### 请求Header
当配置了 `playx.daily_push_secret`Daily Push 签名校验)时,需要携带:
当配置了 `playX.daily_push_secret`Daily Push 签名校验)时,需要携带:
* `X-Request-Id`:请求 ID
* `X-Timestamp`:时间戳
* `X-Signature`签名HMAC_SHA256
@@ -28,7 +28,7 @@
|------|------|------|------|
| `request_id` | string | 是 | 外部推送请求号(原样返回) |
| `date` | string(YYYY-MM-DD) | 是 | 业务日期(入库到 `mall_playx_daily_push.date` |
| `user_id` | string | 是 | PlayX 用户 ID幂等键组成部分 |
| `user_id` | string | 是 | playX 用户 ID幂等键组成部分 |
| `username` | string | 否 | 展示冗余 |
| `yesterday_win_loss_net` | number | 否 | 昨日净输赢(仅当 `< 0` 时计算新增保障金) |
| `yesterday_total_deposit` | number | 否 | 昨日总充值(用于计算今日可领取上限) |
@@ -81,18 +81,18 @@ curl -X POST 'http://localhost:1818/api/v1/mall/dailyPush' \
---
### 1.2 商城需调用的 PlayX 接口(外部,由 PlayX 提供)
以下为商城侧调用(由 PlayX 提供)。
### 1.2 商城需调用的 playX 接口(外部,由 playX 提供)
以下为商城侧调用(由 playX 提供)。
#### Token Verification API
* 方法:`POST`
* URL`${playx.api.base_url}${playx.api.token_verify_url}`(默认 `/api/v1/auth/verify-token`
* URL`${playX.api.base_url}${playX.api.token_verify_url}`(默认 `/api/v1/auth/verify-token`
##### 请求 Body
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `request_id` | string | 是 | 服务端生成,如 `mall_{uniqid}` |
| `token` | string | 是 | 前端传入的 PlayX token |
| `token` | string | 是 | 前端传入的 playX token |
##### 返回(期望)
HTTP 状态码 `200`,响应体需包含:
@@ -118,14 +118,14 @@ HTTP 状态码 `200`,响应体需包含:
#### Bonus Grant API
* 方法:`POST`
* URL`${playx.api.base_url}${playx.api.bonus_grant_url}`(默认 `/api/v1/bonus/grant`
* URL`${playX.api.base_url}${playX.api.bonus_grant_url}`(默认 `/api/v1/bonus/grant`
##### 请求 Body
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `request_id` | string | 是 | 如 `mall_bonus_{uniqid}` |
| `externalTransactionId` | string | 是 | 订单幂等键:`external_transaction_id` |
| `user_id` | string | 是 | PlayX 用户 ID |
| `user_id` | string | 是 | playX 用户 ID |
| `amount` | number | 是 | 订单金额:`MallPlayxOrder.amount` |
| `rewardName` | string | 是 | `mall_item.title` |
| `category` | string | 是 | `mall_item.category`,默认 `daily` |
@@ -169,14 +169,14 @@ HTTP 状态码 `200`,响应体需包含:
#### Balance Credit API
* 方法:`POST`
* URL`${playx.api.base_url}${playx.api.balance_credit_url}`(默认 `/api/v1/balance/credit`
* URL`${playX.api.base_url}${playX.api.balance_credit_url}`(默认 `/api/v1/balance/credit`
##### 请求 Body
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `request_id` | string | 是 | 如 `mall_withdraw_{uniqid}` |
| `externalTransactionId` | string | 是 | 订单幂等键:`external_transaction_id` |
| `user_id` | string | 是 | PlayX 用户 ID |
| `user_id` | string | 是 | playX 用户 ID |
| `amount` | number | 是 | 订单金额:`MallPlayxOrder.amount` |
| `multiplier` | int | 是 | `MallPlayxOrder.multiplier` |
@@ -215,14 +215,14 @@ HTTP 状态码 `200`,响应体需包含:
#### Transaction Status Query API交易终态查询
* 方法:`GET`
* URL`${playx.api.base_url}${playx.api.transaction_status_url}`(默认 `/api/v1/transaction/status`
* URL`${playX.api.base_url}${playX.api.transaction_status_url}`(默认 `/api/v1/transaction/status`
##### Query
* `externalTransactionId`:订单幂等键
##### 示例(请求)
```bash
curl -G '${playx.api.base_url}/api/v1/transaction/status' \
curl -G '${playX.api.base_url}/api/v1/transaction/status' \
--data-urlencode 'externalTransactionId=BONUS_ORD2026....'
```
@@ -238,7 +238,7 @@ curl -G '${playx.api.base_url}/api/v1/transaction/status' \
示例failed
```json
{ "status": "FAILED", "message": "grant rejected by PlayX" }
{ "status": "FAILED", "message": "grant rejected by playX" }
```
---
@@ -499,7 +499,7 @@ curl -G 'http://localhost:1818/api/v1/mall/orders' --data-urlencode 'session_id=
#### 同步额度
当前代码未实现并未注册路由:`/api/v1/mall/syncLimit`
如需补齐,请在接口设计阶段新增对应实现与 PlayX API 对接。
如需补齐,请在接口设计阶段新增对应实现与 playX API 对接。
---
@@ -526,7 +526,7 @@ curl -G 'http://localhost:1818/api/v1/mall/orders' --data-urlencode 'session_id=
- 发货:录入物流公司、单号 → `SHIPPED`
- 驳回:录入驳回原因 → `REJECTED`,自动退回积分
- **红利/提现订单**
- 展示 `external_transaction_id``playx_transaction_id`推送playx
- 展示 `external_transaction_id`、推送 playX 状态
- 手动重试:仅对 `FAILED_RETRYABLE` 状态,记录 `retry_request_id`、操作者、原因
### 2.3 用户资产与人工调账
@@ -546,7 +546,7 @@ curl -G 'http://localhost:1818/api/v1/mall/orders' --data-urlencode 'session_id=
**方案 A改造 mall_player**
-`user_id` 作为主键(PlayX 的 user_id或与现有 id 并存
-`user_id` 作为主键(playX 的 user_id或与现有 id 并存
- 新增字段:
- `locked_points`(待领取积分)
- `available_points`(可用积分)
@@ -556,7 +556,7 @@ curl -G 'http://localhost:1818/api/v1/mall/orders' --data-urlencode 'session_id=
**方案 B新建 mall_playx_user_asset**
- `user_id`PlayX 用户 ID主键或唯一
- `user_id`playX 用户 ID主键或唯一
- `username`(冗余展示)
- `locked_points``available_points``today_limit``today_claimed``today_limit_date`
- `create_time``update_time`
@@ -613,8 +613,7 @@ curl -G 'http://localhost:1818/api/v1/mall/orders' --data-urlencode 'session_id=
| points_cost | int | 消耗积分 |
| amount | decimal(15,2) | 现金面值(红利/提现) |
| multiplier | int | 流水倍数 |
| external_transaction_id | varchar(64) | 订单号 |
| playx_transaction_id | varchar(64) | PlayX 流水号 |
| external_transaction_id | varchar(64) | 订单号(商城侧幂等/对账主键Angpow 流程不单独落库 playX 内部流水号) |
| grant_status | enum | NOT_SENT / SENT_PENDING / ACCEPTED / FAILED_RETRYABLE / FAILED_FINAL |
| fail_reason | text | 失败原因 |
| retry_count | int | 重试次数 |
@@ -677,7 +676,7 @@ curl -G 'http://localhost:1818/api/v1/mall/orders' --data-urlencode 'session_id=
## 六、待确认事项
- PlayX 提供的 **Token Verification API**、**Bonus Grant API**、**Balance Credit API**、**交易终态查询 API** 的 URL、鉴权方式、字段最终表
- playX 提供的 **Token Verification API**、**Bonus Grant API**、**Balance Credit API**、**交易终态查询 API** 的 URL、鉴权方式、字段最终表
- `date` 的时区定义(如 UTC+8
- 返还比例、解锁比例、提现折算的具体数值
- 是否启用「同步额度」功能(需 PlayX 提供对应 API
- 是否启用「同步额度」功能(需 playX 提供对应 API

View File

@@ -2,35 +2,35 @@
本文件为 **内部使用** 的完整说明,用于:
- 梳理积分商城与 PlayX 之间的 **全量业务流程**(含当前选型与备选方案)。
- 梳理积分商城与 playX 之间的 **全量业务流程**(含当前选型与备选方案)。
- 统一后台实现口径(资产计算、订单状态机、幂等、重试、对账)。
- 为后续版本扩展(实时 webhook、同步按钮、PlayX 拉取模式)预留空间。
- 为后续版本扩展(实时 webhook、同步按钮、playX 拉取模式)预留空间。
对外给 PlayX 的精简版请参考:`PlayX-对接文档(积分商城).md`
对外给 playX 的精简版请参考:`playX-对接文档(积分商城).md`
## 2. 当前选型概览V1.0
- **主键标识**`user_id`(贯穿每日推送、资产、订单、发放接口)。
- **集成方式**
- 前端:商城 H5 以 Iframe 嵌入 PlayXpostMessage 传 token/session。
- 后端:商城独立服务,通过 REST API 与 PlayX 通讯。
- 前端:商城 H5 以 Iframe 嵌入 playXpostMessage 传 token/session。
- 后端:商城独立服务,通过 REST API 与 playX 通讯。
- **数据来源**
- 资产池:仅使用 **每日 Cron 推送T+1** 的历史输赢与充值数据。
- 不引入实时充值/流水 webhook 作为资产来源。
- **发放模式**
- 商城在红利/提现下单后,**直接调用 PlayX 发放接口**Bonus Grant / Balance Credit
- PlayX 侧每 10 分钟 Cron 执行 5.9 adjustment / 最终入账。
- 商城在红利/提现下单后,**直接调用 playX 发放接口**Bonus Grant / Balance Credit
- playX 侧每 10 分钟 Cron 执行 5.9 adjustment / 最终入账。
- **幂等责任分工(方案 A与对外文档一致**
- **PlayX**:对 Bonus Grant / Balance Credit 按 **`externalTransactionId` 严格幂等**——同一单号重复请求不得产生第二笔发放;须返回与首次受理一致或可识别的幂等结果(具体 HTTP 体字段由联调约定)。
- **商城**:为每笔红利/提现生成**全局唯一** `externalTransactionId`;收到 HTTP 200 且 `status="accepted"` 后**不再向发放接口重放**;对网络超时等“未知是否受理”场景,可在有限次内重试**同一** `externalTransactionId`,依赖 PlayX 幂等保证不双发(见第 6 章)。
- **playX**:对 Bonus Grant / Balance Credit 按 **`externalTransactionId` 严格幂等**——同一单号重复请求不得产生第二笔发放;须返回与首次受理一致或可识别的幂等结果(具体 HTTP 体字段由联调约定)。
- **商城**:为每笔红利/提现生成**全局唯一** `externalTransactionId`;收到 HTTP 200 且 `status="accepted"` 后**不再向发放接口重放**;对网络超时等“未知是否受理”场景,可在有限次内重试**同一** `externalTransactionId`,依赖 playX 幂等保证不双发(见第 6 章)。
## 3. 角色、系统与对象
### 3.1 角色
- **会员**:在 PlayX 内通过 Iframe 使用积分商城。
- **会员**:在 playX 内通过 Iframe 使用积分商城。
- **运营/客服**:使用商城后台管理商品、订单、调账。
- **PlayX 平台**:数据源与权益发放执行方。
- **playX 平台**:数据源与权益发放执行方。
### 3.2 主要对象与前端看板展示映射
@@ -38,13 +38,13 @@
- 前端展示专用虚拟字段由可用积分按固定比例折算10 分 = 1 元),用于给玩家呈现直观价值,非底层独立资产。
- **待领取积分 (LockedPoints)**
- **定义**:基于昨日玩家亏损转化来的“保障池”,未领取前不可消费。
- **数据源**PlayX 每日推送的 `Yesterday Player Win loss`(取绝对值 × 返还比例)。
- **数据源**playX 每日推送的 `Yesterday Player Win loss`(取绝对值 × 返还比例)。
- **可用积分 (AvailablePoints)**
- **定义**:玩家当前拥有的、可立即抵扣兑换和提现的真实积分资产。
- **交互消耗**:所有兑换、提现操作均扣减该字段。
- **今日可领取上限 (TodayLimit)**
- **定义**:限制玩家当日最多能“挽回”多少积分。
- **数据源**PlayX 每日推送的 `Yesterday Total Deposit`(昨日总充值 × 解锁比例)。
- **数据源**playX 每日推送的 `Yesterday Total Deposit`(昨日总充值 × 解锁比例)。
- **今日已领取 (TodayClaimed)**
- **定义**:记录玩家当日累计已领取的积分规模,辅助校验上限,每日重置。
- **订单 (Order)**
@@ -55,11 +55,11 @@
### 4.1 登录与会话建立
1. 会员在 PlayX 点击“积分商城”入口。
1. 会员在 playX 点击“积分商城”入口。
2. 父页面加载 Iframe商城前端进入“连接中/鉴权中”态。
3. PlayX 前端通过 postMessage 发送:
3. playX 前端通过 postMessage 发送:
- `token` 或 session 标识。
4. 商城前端将 token 传给后端,后端调用 PlayX 的 **Token Verification API** 验证身份:
4. 商城前端将 token 传给后端,后端调用 playX 的 **Token Verification API** 验证身份:
- **请求参数**
- `token`:接收到的用户会话凭证。
- `request_id`:系统发起的鉴权校验流水号。
@@ -72,11 +72,11 @@
**安全与会话续期要点**
- **安全拦截**:不信任前端传入的 `user_id`,必须通过 Token Verification API 获取可信 `user_id`
- **Token 续期**:如果用户在商城停留过久导致 Token 过期(接口返回 `401 / INVALID_TOKEN`),商城前端需通过 `postMessage` 通知 PlayX 父页面重新派发新 Token 以实现静默续期;若无法自动续期,则需弹窗引导用户重新进入商城。
- **Token 续期**:如果用户在商城停留过久导致 Token 过期(接口返回 `401 / INVALID_TOKEN`),商城前端需通过 `postMessage` 通知 playX 父页面重新派发新 Token 以实现静默续期;若无法自动续期,则需弹窗引导用户重新进入商城。
### 4.2 每日 T+1 数据推送与资产入池
数据来源:PlayX 每日 Cron。
数据来源:playX 每日 Cron。
- **交互字段说明T+1 核心输入)**
- `date`:归属业务定日(用于限定该批数据的生效周期)。
@@ -87,7 +87,7 @@
商城处理逻辑:
1.`user_id + date` 幂等入库,避免重复处理。(**注:需与 PlayX 明确 `date` 的时区定义,如 UTC+8 等**)。
1.`user_id + date` 幂等入库,避免重复处理。(**注:需与 playX 明确 `date` 的时区定义,如 UTC+8 等**)。
2. 根据业务规则计算:
- `新增保障金 = ABS(yesterday_win_loss_net) * 返还比例`(仅当 yesterday_win_loss_net < 0 时产生)。
- `今日可领取上限 = yesterday_total_deposit * 解锁比例`。**注意:今日上限每日独立计算,不结转至次日。**
@@ -95,7 +95,7 @@
**数据修正机制**
- T+1 推送不支持覆盖更新(冲正)。如果 PlayX 上游数据算错导致推送有误,商城在二次推送时会触发去重拦截(`deduped`)。此类异常数据统一由**商城后台人工调账**处理。
- T+1 推送不支持覆盖更新(冲正)。如果 playX 上游数据算错导致推送有误,商城在二次推送时会触发去重拦截(`deduped`)。此类异常数据统一由**商城后台人工调账**处理。
### 4.3 领取流程(从待挽回转化为可用)
@@ -137,18 +137,18 @@
- 商品信息 & 消耗积分
5. 原子扣减 `AvailablePoints`
6. 生成 `externalTransactionId`(例如 `BONUS_ORD{订单号}`)。
7. 调用 PlayX Bonus Grant API核心透传字段详见外部对接文档
7. 调用 playX Bonus Grant API核心透传字段详见外部对接文档
- `externalTransactionId`:本单派发流水号(**防重**:用于要求下游强制幂等拦截)。
- `user_id`:玩家标示。
- `amount`:红利发放切实的现金面值。
- `multiplier`:这笔款项后续提款的流水约束倍数。
- `rewardName`:展示给玩家此笔红利来源名称。
- `category`:便于平台统计对账的红利业务大类。
8. PlayX 返回:
8. playX 返回:
- 若 HTTP 200 且 `status = "accepted"`
- 记录 `playx_transaction_id`
- 订单保持 PENDING等待对方系统最终发放
- **发起请求动作不再重试**。但商城侧需定时调用 PlayX 提供的 **交易状态查询 API** 轮询确认该订单最终结果,成功则转 `COMPLETED`,失败则转 `REJECTED` 并退分。
- **发起请求动作不再重试**。但商城侧需定时调用 playX 提供的 **交易状态查询 API** 轮询确认该订单最终结果,成功则转 `COMPLETED`,失败则转 `REJECTED` 并退分。
- 否则:
- 记录失败原因。
- 进入“可重试队列”(自动/人工重试,见第 6 章)。
@@ -160,12 +160,12 @@
- 校验库存/积分。
- 创建 PENDING 订单,扣减 `AvailablePoints`
3. 后台处理:
- 发货:录入物流公司与单号,状态 → SHIPPED。**(可选:调用 PlayX Inbox API 给用户发送发货通知站内信)**。
- 驳回:录入驳回原因,状态 → REJECTED并退回积分。**(可选:调用 PlayX Inbox API 告知用户驳回原因)**。
- 发货:录入物流公司与单号,状态 → SHIPPED。**(可选:调用 playX Inbox API 给用户发送发货通知站内信)**。
- 驳回:录入驳回原因,状态 → REJECTED并退回积分。**(可选:调用 playX Inbox API 告知用户驳回原因)**。
### 4.6 提现回平台余额WITHDRAW 操作逻辑)
本流程旨在将兑换所得的虚拟积分,按照规定“提现”为充入 PlayX 平台账户的真实金额。
本流程旨在将兑换所得的虚拟积分,按照规定“提现”为充入 playX 平台账户的真实金额。
1. **前端选择与展示**
- 会员在首页或“提现到平台”类别列表中选择特定提现档位(如:提现 100 元,需要 1000 积分1倍流水要求
@@ -178,19 +178,19 @@
- 原子扣减数据库内该会员的 `AvailablePoints`
- 创建 WITHDRAW 暂挂订单(状态 `PENDING`),记录该单提现对应的 `amount``multiplier`
- 生成外部交易单号 `externalTransactionId`(如 `WITHDRAW_ORD{订单号}`)。
4. **调用 PlayX API 发放(核心参数解析)**
- 商城发包调用 PlayX 的 Balance Credit API
4. **调用 playX API 发放(核心参数解析)**
- 商城发包调用 playX 的 Balance Credit API
- `externalTransactionId`:本提现申请的订单号(**提现防重唯一拦截键**)。
- `user_id`:发起提现的玩家 ID。
- `amount`:要充入平台余额池的真金面值。
- `multiplier/turnover_rule`:该笔提现资金入账后锁定的打码流水倍数条件。
5. **异步等待终态与 UI 回显**
- 收到 API `accepted` 响应后,商城将不间断返回前端“提交成功,预计 10 分钟内处理”。
- 商城内部保持该订单为 PENDING并进入定时轮询状态监控 PlayX 10 分钟 Cron 执行后的“最终业务发货结果”,闭环完成后才转入 COMPLETED 或对失败按规则退分。
- 商城内部保持该订单为 PENDING并进入定时轮询状态监控 playX 10 分钟 Cron 执行后的“最终业务发货结果”,闭环完成后才转入 COMPLETED 或对失败按规则退分。
## 5. 扩展与备选方案(暂不对外)
本章为 **扩展设计/备选方案**,当前版本不对 PlayX 提出实现要求,只在内部保留。
本章为 **扩展设计/备选方案**,当前版本不对 playX 提出实现要求,只在内部保留。
### 5.1 实时充值 webhook备选
@@ -198,7 +198,7 @@
示例设计:
- PlayX 在充值成功后,调用商城的充值 webhook
- playX 在充值成功后,调用商城的充值 webhook
- 字段:`user_id``amount``transaction_id``occurred_at`
- 商城:
-`transaction_id` 幂等入库。
@@ -210,11 +210,11 @@
### 5.2 外部积分来源 webhook任务/轮盘)
用途:把 PlayX 任务、幸运轮盘等活动产出的积分汇总到商城。
用途:把 playX 任务、幸运轮盘等活动产出的积分汇总到商城。
示例设计:
- PlayX 调用 webhook
- playX 调用 webhook
- 字段:`user_id``points``source``transaction_id`
- 商城:
-`transaction_id` 幂等增加 `AvailablePoints` 或某个“活动积分池”。
@@ -235,20 +235,20 @@
**后端业务逻辑选型推荐**
- **方案 A建议商城做拉取请求**
- 会员点击同步按钮,商城后端拦截并向 PlayX 系统调用一个**“查询今日实时余额/重算存款 API”**。
- 会员点击同步按钮,商城后端拦截并向 playX 系统调用一个**“查询今日实时余额/重算存款 API”**。
- 获取到最新存款后,累加或覆盖当前的 `TodayLimit`
- **方案 BPlayX 控制权)**
- 点击后,通过 iframe 的 `postMessage` 向父级 PlayX 窗口发送同步指令。
- PlayX 在自身域内统计今日所有游戏存款流水进行汇总归集(甚至一键转账入主钱包的操作)。
- 处理完成后 PlayX 主动请求积分商城的`更新 webhook`来给 `TodayLimit` 加额,最后前端获取成功事件刷新。
- **方案 BplayX 控制权)**
- 点击后,通过 iframe 的 `postMessage` 向父级 playX 窗口发送同步指令。
- playX 在自身域内统计今日所有游戏存款流水进行汇总归集(甚至一键转账入主钱包的操作)。
- 处理完成后 playX 主动请求积分商城的`更新 webhook`来给 `TodayLimit` 加额,最后前端获取成功事件刷新。
- **当前定案落地方向**
- 根据原型要求,本功能**必须落地**。推荐使用方案 A。商城侧需准备一个接受通知的 APIPlayX 需要支持实时提供玩家当日总存款的只读 API以供点击拉取。对于 V1.0 对外文档里若不打算实现,需与 PlayX 进一步交涉决定是否阉割掉此按钮。
- 根据原型要求,本功能**必须落地**。推荐使用方案 A。商城侧需准备一个接受通知的 APIplayX 需要支持实时提供玩家当日总存款的只读 API以供点击拉取。对于 V1.0 对外文档里若不打算实现,需与 playX 进一步交涉决定是否阉割掉此按钮。
### 5.4 发放模式备选:PlayX 定时拉取
### 5.4 发放模式备选:playX 定时拉取
备选方案:
-PlayX 每 10 分钟调用商城查询接口,拉取待发放订单列表,然后自行发放。
-playX 每 10 分钟调用商城查询接口,拉取待发放订单列表,然后自行发放。
需要新增:
@@ -257,7 +257,7 @@
当前选型:
- 考虑到复杂度与 PlayX 当前发放系统形态,最终选择 **商城主动调用 PlayX 发放 API** 的模式,拉取模式仅保留在内部文档中作为备选。
- 考虑到复杂度与 playX 当前发放系统形态,最终选择 **商城主动调用 playX 发放 API** 的模式,拉取模式仅保留在内部文档中作为备选。
## 6. 幂等、重试与状态机(内部实现口径)
@@ -270,25 +270,25 @@
### 6.2 发放请求状态机(商城内部)
针对每个订单BONUS/WITHDRAW在商城内部维护发放子状态,例如:
针对每个订单BONUS/WITHDRAW在商城内部维护推送playX状态,例如:
- `NOT_SENT`:未发送给 PlayX。
- `SENT_PENDING`:已发送,等待 PlayX 响应。
- `NOT_SENT`:未发送给 playX。
- `SENT_PENDING`:已发送,等待 playX 响应。
- `ACCEPTED`:收到 HTTP 200 且 `status = "accepted"`
- `FAILED_RETRYABLE`:失败且可重试(如超时、上游错误)。
- `FAILED_FINAL`:最终失败(达到重试上限或 PlayX 返回不可恢复错误)。
- `FAILED_FINAL`:最终失败(达到重试上限或 playX 返回不可恢复错误)。
关键规则:
- 只有在 `NOT_SENT``FAILED_RETRYABLE` 状态下才允许“再次发送”。
- 一旦进入 `ACCEPTED`,不得再发请求(自动或人工)。
- 订单业务状态PENDING/COMPLETED/REJECTED发放子状态之间要有清晰映射:
- 订单业务状态PENDING/COMPLETED/REJECTED推送playX状态之间要有清晰映射:
- `ACCEPTED` + 对账确认成功 → `COMPLETED`
- `FAILED_FINAL``REJECTED`(并退积分)。
### 6.3 重试策略(内部)与防重底线
- **前提**PlayX 已承诺按 **`externalTransactionId` 严格幂等**(方案 A。在此前提下读超时后使用**同一** `externalTransactionId` 的有限次自动重试是安全的。
- **前提**playX 已承诺按 **`externalTransactionId` 严格幂等**(方案 A。在此前提下读超时后使用**同一** `externalTransactionId` 的有限次自动重试是安全的。
- 自动重试:
- 仅针对网络错误、Read Timeout读超时`PLAYX_UPSTREAM_ERROR` 等,且**尚未收到** HTTP 200 + `status="accepted"`
- 建议间隔1min / 5min / 15min最多 3 次;**每次重试必须使用原订单的同一** `externalTransactionId`,不得生成新单号冒充新单。
@@ -303,16 +303,16 @@
- 对账来源:
- 商城订单表(含 externalTransactionId、playx_transaction_id
- PlayX 提供的对账/流水查询(如有)。
- playX 提供的对账/流水查询(如有)。
- 常见问题场景:
- 商城显示 REJECTED 但会员反馈已收到红利:需检查是否在“发放后回滚积分”链路出错。
- 商城显示 PENDING 时间过长:需排查 PlayX 侧 10 分钟 Cron 是否正常。
- 商城显示 PENDING 时间过长:需排查 playX 侧 10 分钟 Cron 是否正常。
## 8. 与对外文档的关系
- 本文档:覆盖“能想到的所有对接与流程设计”,供产品、后端、运营、商务内部统一认识。
- `PlayX-对接文档(积分商城).md`
- 仅暴露 PlayX V1.0 必须提供/实现的部分。
- `playX-对接文档(积分商城).md`
- 仅暴露 playX V1.0 必须提供/实现的部分。
- 承诺最小闭环,不在主文中提及实时 webhook、同步按钮、拉取模式。
- **同步额度等能力**若产品仍要落地,以本文 **5.3** 与商务/PlayX 结论为准;对外文档不写不代表产品一定不做。
- **同步额度等能力**若产品仍要落地,以本文 **5.3** 与商务/playX 结论为准;对外文档不写不代表产品一定不做。

View File

@@ -1,421 +0,0 @@
<?php
namespace ba;
use Throwable;
use support\Response;
use app\admin\library\Auth;
use app\common\library\token\TokenExpirationException;
use Workerman\Protocols\Http\ServerSentEvents;
use Workerman\Protocols\Http\Response as WorkermanResponse;
/**
* WEB 终端SSE 命令执行)
* 适配 Webman使用 request()->connection、config()
*/
class Terminal
{
protected static ?Terminal $instance = null;
protected string $commandKey = '';
protected array $descriptorsPec = [];
protected $process = false;
protected array $pipes = [];
protected int $procStatusMark = 0;
protected array $procStatusData = [];
protected string $uuid = '';
protected string $extend = '';
protected string $outputFile = '';
protected string $outputContent = '';
protected static string $distDir = 'web' . DIRECTORY_SEPARATOR . 'dist';
protected array $flag = [
'link-success' => 'command-link-success',
'exec-success' => 'command-exec-success',
'exec-completed' => 'command-exec-completed',
'exec-error' => 'command-exec-error',
];
public static function instance(): Terminal
{
if (is_null(self::$instance)) {
self::$instance = new static();
}
return self::$instance;
}
public function __construct()
{
$request = request();
$this->uuid = $request ? $request->param('uuid', '') : '';
$this->extend = $request ? $request->param('extend', '') : '';
$outputDir = root_path() . 'runtime' . DIRECTORY_SEPARATOR . 'terminal';
$this->outputFile = $outputDir . DIRECTORY_SEPARATOR . 'exec.log';
if (!is_dir($outputDir)) {
mkdir($outputDir, 0755, true);
}
file_put_contents($this->outputFile, '');
$this->descriptorsPec = [0 => ['pipe', 'r'], 1 => ['file', $this->outputFile, 'w'], 2 => ['file', $this->outputFile, 'w']];
}
public static function getCommand(string $key): bool|array
{
if (!$key) {
return false;
}
$commands = config('terminal.commands', []);
if (stripos($key, '.')) {
$key = explode('.', $key);
if (!array_key_exists($key[0], $commands) || !is_array($commands[$key[0]]) || !array_key_exists($key[1], $commands[$key[0]])) {
return false;
}
$command = $commands[$key[0]][$key[1]];
} else {
if (!array_key_exists($key, $commands)) {
return false;
}
$command = $commands[$key];
}
if (!is_array($command)) {
$command = [
'cwd' => root_path(),
'command' => $command,
];
} else {
$cwd = $command['cwd'] ?? '';
$root = rtrim(root_path(), DIRECTORY_SEPARATOR . '/');
$command['cwd'] = $root . DIRECTORY_SEPARATOR . ltrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $cwd), DIRECTORY_SEPARATOR . '/');
}
$request = request();
if ($request && str_contains($command['command'], '%')) {
$args = $request->param('extend', '');
$args = explode('~~', $args);
$args = array_map('escapeshellarg', $args);
array_unshift($args, $command['command']);
$command['command'] = call_user_func_array('sprintf', $args);
}
$command['cwd'] = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $command['cwd']);
if (DIRECTORY_SEPARATOR === '\\') {
$command['command'] = 'cmd /c "cd /d ' . str_replace('"', '""', $command['cwd']) . ' && ' . $command['command'] . '"';
$command['cwd'] = root_path();
}
return $command;
}
public function exec(bool $authentication = true): void
{
$this->sendHeader();
while (ob_get_level()) {
ob_end_clean();
}
if (!ob_get_level()) ob_start();
$request = request();
$this->commandKey = $request ? $request->param('command', '') : '';
$command = self::getCommand($this->commandKey);
if (!$command) {
$this->execError('The command was not allowed to be executed', true);
}
if ($authentication) {
try {
$token = get_auth_token();
$auth = Auth::instance();
$auth->init($token);
if (!$auth->isLogin() || !$auth->isSuperAdmin()) {
$this->execError("You are not super administrator or not logged in", true);
}
} catch (TokenExpirationException) {
$this->execError(__('Token expiration'));
}
}
$this->beforeExecution();
$this->outputFlag('link-success');
if (!empty($command['notes'])) {
$this->output('> ' . __($command['notes']), false);
}
$this->output('> ' . $command['command'], false);
$procEnv = $this->getProcEnv();
$this->process = proc_open($command['command'], $this->descriptorsPec, $this->pipes, $command['cwd'], $procEnv);
if (!is_resource($this->process)) {
$this->execError('Failed to execute', true);
}
if (isset($this->pipes[0])) {
fclose($this->pipes[0]);
unset($this->pipes[0]);
}
$lastHeartbeat = time();
$heartbeatInterval = 15;
while ($this->getProcStatus()) {
$contents = file_get_contents($this->outputFile);
if (strlen($contents) && $this->outputContent != $contents) {
$newOutput = str_replace($this->outputContent, '', $contents);
$this->checkOutput($contents, $newOutput);
if (strlen(trim($newOutput)) > 0) {
$this->output($newOutput);
$this->outputContent = $contents;
$lastHeartbeat = time();
}
}
if ($this->procStatusMark === 1 && (time() - $lastHeartbeat) >= $heartbeatInterval) {
$this->sendHeartbeat();
$lastHeartbeat = time();
}
if ($this->procStatusMark === 2) {
$this->output('exitCode: ' . $this->procStatusData['exitcode']);
if ($this->procStatusData['exitcode'] === 0) {
if ($this->successCallback()) {
$this->outputFlag('exec-success');
} else {
$this->output('Error: Command execution succeeded, but callback execution failed');
$this->outputFlag('exec-error');
}
} else {
$this->outputFlag('exec-error');
}
}
usleep(500000);
}
foreach ($this->pipes as $pipe) {
fclose($pipe);
}
proc_close($this->process);
$this->outputFlag('exec-completed');
}
protected function getProcEnv(): ?array
{
$env = getenv();
if (!is_array($env)) {
return null;
}
$env['CI'] = '1';
$env['FORCE_COLOR'] = '0';
return $env;
}
public function getProcStatus(): bool
{
$this->procStatusData = proc_get_status($this->process);
if ($this->procStatusData['running']) {
$this->procStatusMark = 1;
return true;
} elseif ($this->procStatusMark === 1) {
$this->procStatusMark = 2;
return true;
} else {
return false;
}
}
public function output(string $data, bool $callback = true): void
{
$data = self::outputFilter($data);
$data = [
'data' => $data,
'uuid' => $this->uuid,
'extend' => $this->extend,
'key' => $this->commandKey,
];
$data = json_encode($data, JSON_UNESCAPED_UNICODE);
if ($data) {
$this->finalOutput($data);
if ($callback) $this->outputCallback($data);
@ob_flush();
}
}
public function checkOutput(string $outputs, string $rowOutput): void
{
if (str_contains($rowOutput, '(Y/n)')) {
$this->execError('Interactive output detected, please manually execute the command to confirm the situation.', true);
}
}
public function outputFlag(string $flag): void
{
$this->output($this->flag[$flag], false);
}
protected function sendHeartbeat(): void
{
$request = request();
$connection = $request && isset($request->connection) ? $request->connection : null;
if ($connection) {
$connection->send(new ServerSentEvents(['' => 'keepalive']));
}
@ob_flush();
}
public function outputCallback($data): void
{
}
public function successCallback(): bool
{
if (stripos($this->commandKey, '.')) {
$commandKeyArr = explode('.', $this->commandKey);
$commandPKey = $commandKeyArr[0] ?? '';
} else {
$commandPKey = $this->commandKey;
}
if ($commandPKey == 'web-build') {
if (!self::mvDist()) {
$this->output('Build succeeded, but move file failed. Please operate manually.');
return false;
}
} elseif ($commandPKey == 'web-install' && $this->extend && class_exists(\app\admin\library\module\Manage::class)) {
[$type, $value] = explode(':', $this->extend);
if ($type == 'module-install' && $value) {
\app\admin\library\module\Manage::instance($value)->dependentInstallComplete('npm');
}
} elseif ($commandPKey == 'composer' && $this->extend && class_exists(\app\admin\library\module\Manage::class)) {
[$type, $value] = explode(':', $this->extend);
if ($type == 'module-install' && $value) {
\app\admin\library\module\Manage::instance($value)->dependentInstallComplete('composer');
}
} elseif ($commandPKey == 'nuxt-install' && $this->extend && class_exists(\app\admin\library\module\Manage::class)) {
[$type, $value] = explode(':', $this->extend);
if ($type == 'module-install' && $value) {
\app\admin\library\module\Manage::instance($value)->dependentInstallComplete('nuxt_npm');
}
}
return true;
}
public function beforeExecution(): void
{
if ($this->commandKey == 'test.pnpm') {
@unlink(root_path() . 'public' . DIRECTORY_SEPARATOR . 'npm-install-test' . DIRECTORY_SEPARATOR . 'pnpm-lock.yaml');
} elseif ($this->commandKey == 'web-install.pnpm') {
@unlink(root_path() . 'web' . DIRECTORY_SEPARATOR . 'pnpm-lock.yaml');
}
}
public static function outputFilter($str): string
{
$str = trim($str);
$preg = '/\x1b\[(.*?)m/i';
$str = preg_replace($preg, '', $str);
$str = str_replace(["\r\n", "\r", "\n"], "\n", $str);
return mb_convert_encoding($str, 'UTF-8', 'UTF-8,GBK,GB2312,BIG5');
}
public function execError($error, $break = false): void
{
$this->output('Error:' . $error);
$this->outputFlag('exec-error');
if ($break) $this->break();
}
public function break(): void
{
throw new TerminalBreakException();
}
public static function getOutputFromProc($commandKey): bool|string
{
if (!function_exists('proc_open') || !function_exists('proc_close')) {
return false;
}
$command = self::getCommand($commandKey);
if (!$command) {
return false;
}
$descriptorsPec = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$process = proc_open($command['command'], $descriptorsPec, $pipes, null, null);
if (is_resource($process)) {
$info = stream_get_contents($pipes[1]);
$info .= stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($process);
return self::outputFilter($info);
}
return '';
}
public static function mvDist(): bool
{
$distPath = root_path() . self::$distDir . DIRECTORY_SEPARATOR;
$indexHtmlPath = $distPath . 'index.html';
$assetsPath = $distPath . 'assets';
if (!file_exists($indexHtmlPath) || !file_exists($assetsPath)) {
return false;
}
$toIndexHtmlPath = root_path() . 'public' . DIRECTORY_SEPARATOR . 'index.html';
$toAssetsPath = root_path() . 'public' . DIRECTORY_SEPARATOR . 'assets';
@unlink($toIndexHtmlPath);
Filesystem::delDir($toAssetsPath);
if (rename($indexHtmlPath, $toIndexHtmlPath) && rename($assetsPath, $toAssetsPath)) {
Filesystem::delDir($distPath);
return true;
} else {
return false;
}
}
public static function changeTerminalConfig($config = []): bool
{
$request = request();
$oldPackageManager = config('terminal.npm_package_manager');
$newPackageManager = $request ? $request->post('manager', $config['manager'] ?? $oldPackageManager) : $oldPackageManager;
if ($oldPackageManager == $newPackageManager) {
return true;
}
$buildConfigFile = config_path('terminal.php');
$buildConfigContent = @file_get_contents($buildConfigFile);
$buildConfigContent = preg_replace("/'npm_package_manager'(\s+)=>(\s+)'$oldPackageManager'/", "'npm_package_manager'\$1=>\$2'$newPackageManager'", $buildConfigContent);
$result = @file_put_contents($buildConfigFile, $buildConfigContent);
return (bool)$result;
}
public function finalOutput(string $data): void
{
$request = request();
$connection = $request && isset($request->connection) ? $request->connection : null;
if ($connection) {
$connection->send(new ServerSentEvents(['event' => 'message', 'data' => $data]));
} else {
echo 'data: ' . $data . "\n\n";
}
}
public function sendHeader(): void
{
$request = request();
$headers = array_merge($request->allowCrossDomainHeaders ?? [], [
'X-Accel-Buffering' => 'no',
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache',
]);
$connection = $request && isset($request->connection) ? $request->connection : null;
if ($connection) {
$connection->send(new WorkermanResponse(200, $headers, "\r\n"));
} else {
foreach ($headers as $name => $val) {
header($name . (!is_null($val) ? ':' . $val : ''));
}
}
}
}

View File

@@ -1,10 +0,0 @@
<?php
namespace ba;
/**
* 终端中断异常(用于 break() 停止 SSE 流)
*/
class TerminalBreakException extends \Exception
{
}

View File

@@ -7,6 +7,13 @@ namespace ba;
*/
class Version
{
private const VERSION_COMMANDS = [
'npm' => 'npm -v',
'cnpm' => 'cnpm -v',
'yarn' => 'yarn -v',
'pnpm' => 'pnpm -v',
'node' => 'node -v',
];
/**
* 比较两个版本号
@@ -84,14 +91,13 @@ class Version
*/
public static function getCnpmVersion(): string
{
$execOut = Terminal::getOutputFromProc('version.cnpm');
$execOut = self::getOutputFromProc('cnpm');
if ($execOut) {
$preg = '/cnpm@(.+?) \(/is';
preg_match($preg, $execOut, $result);
return $result[1] ?? '';
} else {
return '';
}
return '';
}
/**
@@ -103,9 +109,16 @@ class Version
{
if ($name == 'cnpm') {
return self::getCnpmVersion();
} elseif (in_array($name, ['npm', 'yarn', 'pnpm', 'node'])) {
$execOut = Terminal::getOutputFromProc('version.' . $name);
if ($execOut) {
}
if (!in_array($name, ['npm', 'yarn', 'pnpm', 'node'])) {
return '';
}
$execOut = self::getOutputFromProc($name);
if (!$execOut) {
return '';
}
if (strripos($execOut, 'npm WARN') !== false) {
$preg = '/\d+(\.\d+){0,2}/';
preg_match($preg, $execOut, $matches);
@@ -113,16 +126,52 @@ class Version
return $matches[0];
}
}
$execOut = preg_split('/\r\n|\r|\n/', $execOut);
for ($i = 0; $i < 2; $i++) {
if (isset($execOut[$i]) && self::checkDigitalVersion($execOut[$i])) {
return $execOut[$i];
}
}
} else {
return '';
}
private static function getOutputFromProc(string $name): bool|string
{
if (!function_exists('proc_open') || !function_exists('proc_close')) {
return false;
}
$command = self::VERSION_COMMANDS[$name] ?? '';
if (!$command) {
return false;
}
if (DIRECTORY_SEPARATOR === '\\') {
$command = 'cmd /c "' . $command . '"';
}
$descriptorsPec = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$process = proc_open($command, $descriptorsPec, $pipes, root_path(), null);
if (!is_resource($process)) {
return '';
}
$info = stream_get_contents($pipes[1]);
$info .= stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($process);
return self::outputFilter($info);
}
private static function outputFilter($str): string
{
$str = trim($str);
$preg = '/\x1b\[(.*?)m/i';
$str = preg_replace($preg, '', $str);
$str = str_replace(["\r\n", "\r", "\n"], "\n", $str);
return mb_convert_encoding($str, 'UTF-8', 'UTF-8,GBK,GB2312,BIG5');
}
}

View File

@@ -1,12 +0,0 @@
{
"name": "npm-install-test",
"private": true,
"version": "0.0.1",
"scripts": {
"dev": "vite"
},
"dependencies": {
"vue": "^3.2.25"
},
"devDependencies": {}
}

103
scripts/check_start.php Normal file
View File

@@ -0,0 +1,103 @@
#!/usr/bin/env php
<?php
/**
* 启动前自检:在项目根目录执行 php scripts/check_start.php
*/
declare(strict_types=1);
chdir(__DIR__ . '/..');
require_once __DIR__ . '/../vendor/autoload.php';
$errors = [];
$isLinux = DIRECTORY_SEPARATOR === '/';
if ($isLinux) {
foreach (['pcntl', 'posix'] as $ext) {
if (!extension_loaded($ext)) {
$errors[] = "缺少 PHP 扩展: {$ext}Workerman 在 Linux 上必需)";
}
}
}
foreach (['pdo', 'pdo_mysql', 'json'] as $ext) {
if (!extension_loaded($ext)) {
$errors[] = "缺少 PHP 扩展: {$ext}";
}
}
if (!function_exists('cpu_count')) {
$errors[] = '函数 cpu_count() 不存在,请确认 vendor 已完整安装composer install';
}
$runtime = base_path() . '/runtime';
$logs = $runtime . '/logs';
foreach ([$runtime, $logs] as $dir) {
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) {
$errors[] = "无法创建目录: {$dir}";
} elseif (!is_writable($dir)) {
$errors[] = "目录不可写: {$dir}(请 chown/chmod 给运行 PHP 的用户)";
}
}
$pidPath = runtime_path() . '/webman.pid';
if (is_file($pidPath) && !is_writable($pidPath) && !is_writable(dirname($pidPath))) {
$errors[] = "PID 文件目录不可写: {$pidPath}";
}
try {
require base_path() . '/support/bootstrap.php';
} catch (Throwable $e) {
$errors[] = 'bootstrap 失败: ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine();
}
if (empty($errors)) {
foreach (config('process', []) as $name => $proc) {
$handler = $proc['handler'] ?? '';
if (is_string($handler) && $handler !== '' && !class_exists($handler)) {
$errors[] = "进程 [{$name}] 处理器不存在: {$handler}";
}
}
}
if (empty($errors)) {
try {
support\think\Db::execute('SELECT 1');
} catch (Throwable $e) {
$errors[] = '数据库连接失败: ' . $e->getMessage();
}
}
$listen = config('process.webman.listen', '');
if ($listen !== '' && empty($errors)) {
$parsed = parse_url($listen);
$host = $parsed['host'] ?? '0.0.0.0';
$port = $parsed['port'] ?? 80;
$bindHost = $host === '0.0.0.0' ? '127.0.0.1' : $host;
$sock = @stream_socket_server("tcp://{$bindHost}:{$port}", $errno, $errstr);
if ($sock === false) {
$errors[] = "端口 {$port} 无法监听: [{$errno}] {$errstr}";
$errors[] = "可执行: ss -lntp | grep {$port} 查看是否被占用";
} else {
fclose($sock);
}
}
if ($errors) {
echo "=== 启动自检失败 ===\n";
foreach ($errors as $i => $msg) {
echo ($i + 1) . '. ' . $msg . "\n";
}
echo "\n下一步:\n";
echo " php start.php start # 前台启动看完整报错\n";
echo " tail -n 80 runtime/logs/workerman.log\n";
echo " tail -n 80 runtime/logs/stdout.log\n";
exit(1);
}
echo "=== 启动自检通过 ===\n";
echo "监听: {$listen}\n";
echo "Worker 数: " . (config('process.webman.count') ?? '?') . "\n";
echo "路由数: " . count(Webman\Route::getRoutes()) . "\n";
echo "\n请执行: php start.php start\n";
echo "确认无误后再: php start.php start -d\n";
exit(0);

View File

@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
/**
* 命令行模拟商城服务端调用 PLAYX_TOKEN_VERIFY_URL回调 verify-token
*
* 用法(在项目根目录执行):
* php scripts/playx-verify-token-callback-test.php "<playx_token>"
*
* 可选环境变量(未设置则从项目根 .env 读取):
* PLAYX_TOKEN_VERIFY_URL 默认 https://callback-mallsys.superior3.net/callback/api/mallsys/plx/auth/verify-token
* PLAYX_ANGPOW_MERCHANT_CODE 回调 Body merchant_code必填
* PLAYX_ANGPOW_IMPORT_AUTH_KEY 与 angpow-imports 相同的 HMAC 密钥
*/
$root = dirname(__DIR__);
function loadDotEnv(string $path): void
{
if (!is_file($path)) {
return;
}
foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
$line = trim($line);
if ($line === '' || str_starts_with($line, '#')) {
continue;
}
if (!str_contains($line, '=')) {
continue;
}
$pos = strpos($line, '=');
$name = trim(substr($line, 0, $pos));
$value = trim(substr($line, $pos + 1));
if ($name !== '') {
$_ENV[$name] = $value;
putenv($name . '=' . $value);
}
}
}
function resolveKeyBytes(string $authKey): string
{
$maybeBase64 = base64_decode($authKey, true);
if (is_string($maybeBase64) && $maybeBase64 !== '') {
return $maybeBase64;
}
$isHex = ctype_xdigit($authKey) && (strlen($authKey) % 2 === 0);
if ($isHex) {
$hex = hex2bin($authKey);
if (is_string($hex) && $hex !== '') {
return $hex;
}
}
return $authKey;
}
function buildSignature(string $input, string $authKey): string
{
$raw = hash_hmac('sha1', $input, resolveKeyBytes($authKey), true);
return base64_encode($raw);
}
loadDotEnv($root . DIRECTORY_SEPARATOR . '.env');
$token = $argv[1] ?? '';
if ($token === '') {
fwrite(STDERR, "用法: php scripts/playx-verify-token-callback-test.php \"<playx_token>\"\n");
exit(1);
}
$url = strval($_ENV['PLAYX_TOKEN_VERIFY_URL'] ?? getenv('PLAYX_TOKEN_VERIFY_URL') ?: '');
if ($url === '') {
$url = 'https://callback-mallsys.superior3.net/callback/api/mallsys/plx/auth/verify-token';
}
$merchantCode = strval($_ENV['PLAYX_ANGPOW_MERCHANT_CODE'] ?? getenv('PLAYX_ANGPOW_MERCHANT_CODE') ?: '');
$authKey = strval($_ENV['PLAYX_ANGPOW_IMPORT_AUTH_KEY'] ?? getenv('PLAYX_ANGPOW_IMPORT_AUTH_KEY') ?: '');
if ($merchantCode === '') {
fwrite(STDERR, "缺少 PLAYX_ANGPOW_MERCHANT_CODE请在 .env 配置或导出环境变量)\n");
exit(1);
}
if ($authKey === '') {
fwrite(STDERR, "缺少 PLAYX_ANGPOW_IMPORT_AUTH_KEY请在 .env 配置或导出环境变量)\n");
exit(1);
}
$requestId = 'mall_cli_' . uniqid();
$requestDate = strval(time());
$canonical = 'merchant_code=' . $merchantCode
. '&request_date=' . $requestDate
. '&request_id=' . $requestId
. '&token=' . $token;
$signature = buildSignature($canonical, $authKey);
$payload = json_encode([
'merchant_code' => $merchantCode,
'request_date' => $requestDate,
'request_id' => $requestId,
'token' => $token,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($payload === false) {
fwrite(STDERR, "JSON 编码失败\n");
exit(1);
}
echo "--- 等价 curlLinux / macOS / Git Bash---\n";
echo "curl -sS -X POST '" . $url . "' \\\n";
echo " -H 'Content-Type: application/json' \\\n";
echo " -H 'X-Request-Signature: " . $signature . "' \\\n";
echo " -d '" . $payload . "'\n\n";
echo "--- 本机直接请求PHP stream---\n";
echo "request_id={$requestId}\n";
echo "request_date={$requestDate}\n";
echo "canonical={$canonical}\n\n";
$body = $payload;
$ctx = stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\nX-Request-Signature: {$signature}\r\n",
'content' => $body,
'timeout' => 15,
// 4xx/5xx 仍返回响应体,便于查看对端错误说明(否则 file_get_contents 直接 false
'ignore_errors' => true,
],
'ssl' => [
'verify_peer' => true,
'verify_peer_name' => true,
],
]);
$result = @file_get_contents($url, false, $ctx);
if ($result === false) {
$err = error_get_last();
fwrite(STDERR, "请求失败: " . ($err['message'] ?? 'unknown') . "\n");
exit(1);
}
$statusLine = isset($http_response_header[0]) ? $http_response_header[0] : '';
echo "--- HTTP 响应行 ---\n";
echo $statusLine . "\n";
if (isset($http_response_header[1])) {
echo "--- 响应头(节选)---\n";
$max = min(12, count($http_response_header));
for ($i = 1; $i < $max; $i++) {
echo $http_response_header[$i] . "\n";
}
}
echo "--- 响应体 ---\n";
echo $result . "\n";
if (!preg_match('/\b200\b/', $statusLine)) {
fwrite(STDERR, "\n提示:非 2xx 时请根据响应体与对端核对签名字符串、Body 字段是否与回调网关约定一致。\n");
exit(2);
}

View File

@@ -11,6 +11,20 @@
</head>
<body>
<div id="app"></div>
<script>
;(function () {
var path = window.location.pathname
if (path !== '/' && path !== '') return
var hash = window.location.hash
if (!hash) {
window.location.replace(path + '#/admin/login')
return
}
if (hash.indexOf('#/') === 0 && (window.location.search === '?' || window.location.href.indexOf('/?#/') !== -1)) {
window.history.replaceState(null, '', path + hash)
}
})()
</script>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,14 @@
import createAxios from '/@/utils/axios'
export function resetTotp(id: number) {
return createAxios(
{
url: '/admin/auth.Admin/resetTotp',
method: 'post',
data: { id },
},
{
showSuccessMessage: true,
}
)
}

View File

@@ -12,12 +12,58 @@ export function index() {
})
}
export function login(method: 'get' | 'post', params: object = {}) {
return createAxios({
export function login(method: 'get' | 'post', params: object = {}, axiosConfig: Record<string, unknown> = {}) {
return createAxios(
{
url: url + 'login',
data: params,
method: method,
})
...axiosConfig,
},
{
cancelDuplicateRequest: false,
}
)
}
export function totpVerify(params: object = {}) {
return createAxios(
{
url: url + 'totpVerify',
data: params,
method: 'post',
},
{
cancelDuplicateRequest: false,
}
)
}
export function totpBindInit(params: object = {}) {
return createAxios(
{
url: url + 'totpBindInit',
data: params,
method: 'post',
},
{
cancelDuplicateRequest: false,
}
)
}
export function totpBindConfirm(params: object = {}) {
return createAxios(
{
url: url + 'totpBindConfirm',
data: params,
method: 'post',
},
{
cancelDuplicateRequest: false,
showSuccessMessage: true,
}
)
}
export function logout() {

View File

@@ -0,0 +1,36 @@
import createAxios from '/@/utils/axios'
export const url = '/admin/mall.PlayxConfig/'
export const actionUrl = new Map([
['index', url + 'index'],
['save', url + 'save'],
['calculateLifetimePoints', url + 'calculateLifetimePoints'],
])
export function index() {
return createAxios({
url: actionUrl.get('index'),
method: 'get',
})
}
export function save(data: anyObj) {
return createAxios(
{
url: actionUrl.get('save'),
method: 'post',
data,
},
{
showSuccessMessage: true,
}
)
}
export function calculateLifetimePoints() {
return createAxios({
url: actionUrl.get('calculateLifetimePoints'),
method: 'post',
})
}

View File

@@ -0,0 +1,13 @@
import createAxios from '/@/utils/axios'
export const url = '/admin/mall.PointsLog/'
export function getUserAsset(userAssetId: string | number) {
return createAxios({
url: url + 'add',
method: 'get',
params: {
userAssetId,
},
})
}

View File

@@ -1,190 +0,0 @@
import { useBaAccount } from '/@/stores/baAccount'
import { useSiteConfig } from '/@/stores/siteConfig'
import createAxios from '/@/utils/axios'
const storeUrl = '/api/v7.store/'
const moduleControllerUrl = '/admin/module/'
export function index(params: anyObj = {}) {
return createAxios({
url: moduleControllerUrl + 'index',
method: 'get',
params: params,
})
}
export function modules(params: anyObj = {}) {
const siteConfig = useSiteConfig()
return createAxios({
url: siteConfig.apiUrl + storeUrl + 'modules',
method: 'get',
params: params,
})
}
export function info(params: anyObj) {
const baAccount = useBaAccount()
const siteConfig = useSiteConfig()
return createAxios(
{
url: siteConfig.apiUrl + storeUrl + 'info',
method: 'get',
params: params,
},
{
anotherToken: baAccount.getToken('auth'),
}
)
}
export function createOrder(params: object = {}) {
const baAccount = useBaAccount()
const siteConfig = useSiteConfig()
return createAxios(
{
url: siteConfig.apiUrl + storeUrl + 'order',
method: 'post',
params: params,
},
{
anotherToken: baAccount.getToken('auth'),
}
)
}
export function payOrder(orderId: number, payType: string) {
const baAccount = useBaAccount()
const siteConfig = useSiteConfig()
return createAxios(
{
url: siteConfig.apiUrl + storeUrl + 'pay',
method: 'post',
params: {
order_id: orderId,
pay_type: payType,
},
},
{
anotherToken: baAccount.getToken('auth'),
showSuccessMessage: true,
}
)
}
export function payCheck(sn: string) {
const baAccount = useBaAccount()
const siteConfig = useSiteConfig()
return createAxios(
{
url: siteConfig.apiUrl + '/api/pay/check',
method: 'get',
params: {
sn: sn,
},
},
{
anotherToken: baAccount.getToken('auth'),
showCodeMessage: false,
}
)
}
/**
* 获取模块的可安装版本列表
*/
export function preDownload(data: anyObj) {
const baAccount = useBaAccount()
const siteConfig = useSiteConfig()
return createAxios(
{
url: siteConfig.apiUrl + storeUrl + 'preDownload',
method: 'POST',
data,
},
{
anotherToken: baAccount.getToken('auth'),
}
)
}
export function getInstallState(uid: string) {
return createAxios({
url: moduleControllerUrl + 'state',
method: 'get',
params: {
uid: uid,
},
})
}
export function postInstallModule(uid: string, orderId: number, version: string, update: boolean, extend: anyObj = {}) {
const baAccount = useBaAccount()
return createAxios(
{
url: moduleControllerUrl + 'install',
method: 'POST',
data: {
uid,
update,
version,
orderId,
token: baAccount.getToken('auth'),
extend,
},
timeout: 3000 * 10,
},
{
showCodeMessage: false,
}
)
}
export function postUninstall(uid: string) {
return createAxios(
{
url: moduleControllerUrl + 'uninstall',
method: 'post',
params: {
uid: uid,
},
},
{
showSuccessMessage: true,
}
)
}
export function changeState(params: anyObj) {
return createAxios(
{
url: moduleControllerUrl + 'changeState',
method: 'post',
data: params,
},
{
showCodeMessage: false,
}
)
}
export function dependentInstallComplete(uid: string) {
return createAxios({
url: moduleControllerUrl + 'dependentInstallComplete',
method: 'post',
params: {
uid: uid,
},
})
}
export function upload(file: string) {
const baAccount = useBaAccount()
return createAxios({
url: moduleControllerUrl + 'upload',
method: 'post',
params: {
file: file,
token: baAccount.getToken('auth'),
},
})
}

View File

@@ -24,8 +24,6 @@ export const getTablePkUrl = '/admin/ajax/getTablePk'
export const getTableListUrl = '/admin/ajax/getTableList'
export const getTableFieldListUrl = '/admin/ajax/getTableFieldList'
export const getDatabaseConnectionListUrl = '/admin/ajax/getDatabaseConnectionList'
export const terminalUrl = adminBaseRoutePath + '/ajax/terminal'
export const changeTerminalConfigUrl = '/admin/ajax/changeTerminalConfig'
export const clearCacheUrl = '/admin/ajax/clearCache'
// 公共
@@ -177,27 +175,6 @@ export function postClearCache(type: string) {
)
}
/**
* 构建命令执行窗口url
*/
export function buildTerminalUrl(commandKey: string, uuid: string, extend: string) {
const adminInfo = useAdminInfo()
return (
getUrl() + terminalUrl + '?command=' + commandKey + '&uuid=' + uuid + '&extend=' + extend + '&batoken=' + adminInfo.getToken() + '&server=1'
)
}
/**
* 请求修改终端配置
*/
export function postChangeTerminalConfig(data: { manager?: string; port?: string }) {
return createAxios({
url: changeTerminalConfigUrl,
method: 'POST',
data: data,
})
}
/**
* 远程下拉框数据获取
*/

View File

@@ -0,0 +1,33 @@
<template>
<el-alert class="ba-table-alert mall-page-intro" type="info" show-icon :closable="false">
<template #title>{{ title }}</template>
<div class="mall-page-intro__desc">{{ desc }}</div>
</el-alert>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const props = defineProps<{
/** 与 mall.pageIntro.<pageKey> 对应,如 dailyPush、userAsset */
pageKey: string
}>()
const { t } = useI18n()
const title = computed(() => t(`mall.pageIntro.${props.pageKey}.title`))
const desc = computed(() => t(`mall.pageIntro.${props.pageKey}.desc`))
</script>
<style scoped lang="scss">
.mall-page-intro {
margin-bottom: 12px;
}
.mall-page-intro__desc {
margin: 0;
line-height: 1.6;
color: var(--el-text-color-regular);
white-space: pre-line;
}
</style>

View File

@@ -45,7 +45,7 @@
<!-- 带确认框的按钮 -->
<el-popconfirm
v-if="btn.render == 'confirmButton' && ((btn.name == 'delete' && baTable.auth('del')) || btn.name != 'delete')"
v-if="btn.render == 'confirmButton' && isConfirmButtonVisible(btn)"
:disabled="btn.disabled && btn.disabled(row, field)"
v-bind="invokeTableContextDataFun(btn.popconfirm, { row, field, cellValue: btn, column, index })"
@confirm="onButtonClick(btn)"
@@ -108,6 +108,7 @@ import { inject } from 'vue'
import { useI18n } from 'vue-i18n'
import { invokeTableContextDataFun } from '/@/components/table/index'
import type baTableClass from '/@/utils/baTable'
import { useAdminInfo } from '/@/stores/adminInfo'
interface Props {
row: TableRow
@@ -119,6 +120,7 @@ interface Props {
const { t, te } = useI18n()
const props = defineProps<Props>()
const baTable = inject('baTable') as baTableClass
const adminInfo = useAdminInfo()
const onButtonClick = (btn: OptButton) => {
if (typeof btn.click === 'function') {
@@ -128,6 +130,16 @@ const onButtonClick = (btn: OptButton) => {
baTable.onTableAction(btn.name as BaTableActionEventName, props)
}
const isConfirmButtonVisible = (btn: OptButton) => {
if (btn.name === 'delete') {
return baTable.auth('del')
}
if (btn.name === 'resetTotp') {
return adminInfo.super || baTable.auth('resetTotp')
}
return true
}
const getTranslation = (key?: string) => {
if (!key) return ''
return te(key) ? t(key) : key

View File

@@ -0,0 +1,75 @@
<template>
<div class="mall-order-fail-reason-table-cell">
<template v-if="fullText !== ''">
<el-tooltip placement="top" effect="dark" popper-class="mall-order-reason-tooltip" :enterable="true">
<template #content>
<div class="mall-order-fail-reason-tooltip-body">{{ fullText }}</div>
</template>
<span class="mall-order-fail-reason-ellipsis">{{ displayText }}</span>
</el-tooltip>
</template>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { TableColumnCtx } from 'element-plus'
import { getCellValue } from '/@/components/table/index'
interface Props {
row: TableRow
field: TableColumn
column: TableColumnCtx<TableRow>
index: number
}
const props = defineProps<Props>()
/** 与后台换行一致;无换行时在 attempt N: 前补行首,便于阅读 */
function normalizeFailReasonText(raw: unknown): string {
if (raw === null || raw === undefined) {
return ''
}
let t = String(raw)
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
t = t.replace(/([^\n])(attempt\s+\d+:)/gi, '$1\n$2')
return t
}
const rawCell = computed(() => getCellValue(props.row, props.field, props.column, props.index))
const fullText = computed(() => normalizeFailReasonText(rawCell.value))
const displayText = computed(() => fullText.value)
</script>
<style scoped lang="scss">
.mall-order-fail-reason-table-cell {
width: 100%;
min-width: 0;
}
.mall-order-fail-reason-ellipsis {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: bottom;
}
</style>
<style lang="scss">
.el-popper.mall-order-reason-tooltip {
max-width: min(560px, 90vw);
box-sizing: border-box;
}
.mall-order-reason-tooltip .mall-order-fail-reason-tooltip-body {
white-space: pre-wrap;
word-break: break-word;
line-height: 1.5;
text-align: left;
}
</style>

View File

@@ -37,6 +37,8 @@ export const getCellValue = (row: TableRow, field: TableColumn, column: TableCol
return cellValue
}
type DefaultOptButType = 'weigh-sort' | 'edit' | 'delete'
/*
* 默认按钮组
*/
@@ -138,4 +140,27 @@ export const invokeTableContextDataFun = <T>(
return fun
}
type DefaultOptButType = 'weigh-sort' | 'edit' | 'delete'
/** 操作列单个按钮占位宽度px */
export const OPERATE_BUTTON_WIDTH = 40
/**
* 根据按钮数量计算操作列宽度
*/
export const getButtonsColumnWidth = (buttons?: OptButton[]): number => {
const count = buttons?.length ?? 1
return count * OPERATE_BUTTON_WIDTH
}
/**
* 统一操作列宽度:按钮数 × OPERATE_BUTTON_WIDTH
*/
export const normalizeButtonsColumn = (column: TableColumn): TableColumn => {
if (column.render !== 'buttons') {
return column
}
return {
...column,
fixed: 'right',
width: getButtonsColumnWidth(column.buttons),
}
}

View File

@@ -27,7 +27,7 @@
<el-table-column
v-else
:key="key + '-column'"
v-bind="item"
v-bind="getColumnBindProps(item)"
:column-key="(item['columnKey'] ? item['columnKey'] : `table-column-${item.prop}`) || shortUuid()"
>
<!-- ./fieldRender/ 文件夹内的每个组件为一种字段渲染器组件名称为渲染器名称 -->
@@ -68,8 +68,11 @@ import type { Component } from 'vue'
import { computed, inject, nextTick, useTemplateRef } from 'vue'
import { useConfig } from '/@/stores/config'
import type baTableClass from '/@/utils/baTable'
import { normalizeButtonsColumn } from '/@/components/table/index'
import { shortUuid } from '/@/utils/random'
const getColumnBindProps = (item: TableColumn) => normalizeButtonsColumn(item)
const config = useConfig()
const tableRef = useTemplateRef('tableRef')
const baTable = inject('baTable') as baTableClass

View File

@@ -1,439 +0,0 @@
<template>
<div>
<el-dialog v-model="terminal.state.show" :title="t('terminal.Terminal')" class="ba-terminal-dialog main-dialog">
<el-scrollbar ref="terminalScrollbarRef" :max-height="500" class="terminal-scrollbar">
<el-alert
class="terminal-warning-alert"
v-if="!terminal.state.phpDevelopmentServer"
:title="t('terminal.The current terminal is not running under the installation service, and some commands may not be executed')"
type="error"
/>
<el-timeline class="terminal-timeline" v-if="terminal.state.taskList.length">
<el-timeline-item
v-for="(item, idx) in terminal.state.taskList"
:key="idx"
class="task-item"
:class="'task-status-' + item.status"
:type="getTaskStatus(item.status)['statusType']"
center
:timestamp="item.createTime"
placement="top"
>
<el-card>
<div>
<el-tag :type="getTaskStatus(item.status)['statusType']">{{ getTaskStatus(item.status)['statusText'] }}</el-tag>
<el-tag
class="block-on-failure-tag"
v-if="(item.status == taskStatus.Failed || item.status == taskStatus.Unknown) && item.blockOnFailure"
type="warning"
>
{{ t('terminal.Failure to execute this command will block the execution of the queue') }}
</el-tag>
<el-tag
class="block-on-failure-tag"
v-if="item.status == taskStatus.Executing || item.status == taskStatus.Connecting"
type="danger"
>
{{ t('terminal.Do not refresh the browser') }}
</el-tag>
<span class="command">{{ item.command }}</span>
<div class="task-opt">
<el-button
:title="t('Retry')"
v-if="item.status == taskStatus.Failed || item.status == taskStatus.Unknown"
size="small"
v-blur
type="warning"
icon="el-icon-RefreshRight"
circle
@click="terminal.retryTask(idx)"
/>
<el-button
@click="terminal.delTask(idx)"
:title="t('Delete')"
size="small"
v-blur
type="danger"
icon="el-icon-Delete"
circle
/>
</div>
</div>
<template v-if="item.status != taskStatus.Waiting">
<div
v-if="item.status != taskStatus.Connecting && item.status != taskStatus.Executing"
@click="terminal.setTaskShowMessage(idx)"
class="toggle-message-display"
>
<span>{{ t('terminal.Command run log') }}</span>
<Icon :name="item.showMessage ? 'el-icon-ArrowUp' : 'el-icon-ArrowDown'" size="16" color="#909399" />
</div>
<div
v-if="
item.status == taskStatus.Connecting ||
item.status == taskStatus.Executing ||
(item.status > taskStatus.Executing && item.showMessage)
"
class="exec-message"
:class="'exec-message-' + item.uuid"
>
<pre v-for="(msg, index) in item.message" :key="index" class="message-item">{{ msg }}</pre>
</div>
</template>
</el-card>
</el-timeline-item>
</el-timeline>
<el-empty v-else :image-size="80" :description="t('terminal.No mission yet')" />
</el-scrollbar>
<div class="terminal-buttons">
<el-button class="terminal-menu-item" icon="el-icon-MagicStick" v-blur @click="addTerminalTask('test', true, false)">
{{ t('terminal.Test command') }}
</el-button>
<el-dropdown class="terminal-menu-item">
<el-button icon="el-icon-Download" v-blur>
{{ t('terminal.Install dependent packages') }}
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="addTerminalTask('web-install', true)" v-if="terminal.state.packageManager != 'none'">
{{ terminal.state.packageManager }} run install
</el-dropdown-item>
<el-dropdown-item @click="addTerminalTask('composer.update', false)">composer update</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button class="terminal-menu-item" icon="el-icon-Sell" v-blur @click="webBuild()">{{ t('terminal.Republish') }}</el-button>
<el-button class="terminal-menu-item" icon="el-icon-Delete" v-blur @click="terminal.clearSuccessTask()">
{{ t('terminal.Clean up task list') }}
</el-button>
<el-button class="terminal-menu-item" icon="el-icon-Tools" v-blur @click="terminal.toggleConfigDialog()">
{{ t('terminal.Terminal settings') }}
</el-button>
</div>
</el-dialog>
<el-dialog
@close="terminal.toggleConfigDialog(false)"
:model-value="terminal.state.showConfig"
class="ba-terminal-dialog"
:title="t('terminal.Terminal settings')"
>
<el-form label-position="left" label-width="140">
<FormItem
:label="'NPM ' + t('terminal.Source')"
:model-value="terminal.state.npmRegistry"
:key="terminal.state.npmRegistry"
v-loading="state.registryLoading && state.registryLoadingType == 'npm'"
type="select"
:input-attr="{
border: true,
content: getSourceContent('npm'),
teleported: false,
onChange: (val: string) => changeRegistry(val, 'npm'),
}"
/>
<FormItem
:label="'Composer ' + t('terminal.Source')"
:model-value="terminal.state.composerRegistry"
:key="terminal.state.composerRegistry"
v-loading="state.registryLoading && state.registryLoadingType == 'composer'"
type="select"
:input-attr="{
border: true,
content: getSourceContent('composer'),
teleported: false,
onChange: (val: string) => changeRegistry(val, 'composer'),
}"
/>
<FormItem
:label="t('terminal.NPM package manager')"
:model-value="terminal.state.packageManager"
v-loading="state.packageManagerLoading"
type="select"
:input-attr="{
border: true,
content: { npm: 'NPM', cnpm: 'CNPM', pnpm: 'PNPM', yarn: 'YARN', ni: 'NI', none: t('terminal.Manual execution') },
teleported: false,
onChange: (val: string) => changePackageManager(val),
}"
:tip="t('terminal.NPM package manager tip')"
/>
<FormItem
:label="t('terminal.Clear successful task')"
:model-value="terminal.state.automaticCleanupTask"
type="radio"
:input-attr="{
border: true,
content: { '0': t('Disable'), '1': t('Enable') },
onChange: terminal.changeAutomaticCleanupTask,
}"
:tip="t('terminal.Clear successful task tip')"
/>
</el-form>
<div class="config-buttons">
<el-button @click="terminal.toggleConfigDialog(false)">{{ t('terminal.Back to terminal') }}</el-button>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import type { TimelineItemProps } from 'element-plus'
import { ElMessageBox, ElScrollbar } from 'element-plus'
import { nextTick, onMounted, reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { postChangeTerminalConfig } from '/@/api/common'
import FormItem from '/@/components/formItem/index.vue'
import { taskStatus } from '/@/stores/constant/terminalTaskStatus'
import { useTerminal } from '/@/stores/terminal'
import { changeListenDirtyFileSwitch } from '/@/utils/vite'
type SourceType = 'npm' | 'composer'
const { t } = useI18n()
const terminal = useTerminal()
const terminalScrollbarRef = useTemplateRef('terminalScrollbarRef')
const state = reactive({
registryLoading: false,
registryLoadingType: 'npm',
packageManagerLoading: false,
})
const getTaskStatus = (status: number) => {
let statusText = t('terminal.unknown')
let statusType: TimelineItemProps['type'] = 'info'
switch (status) {
case taskStatus.Waiting:
statusText = t('terminal.Waiting for execution')
statusType = 'info'
break
case taskStatus.Connecting:
statusText = t('terminal.Connecting')
statusType = 'warning'
break
case taskStatus.Executing:
statusText = t('terminal.Executing')
statusType = 'warning'
break
case taskStatus.Success:
statusText = t('terminal.Successful execution')
statusType = 'success'
break
case taskStatus.Failed:
statusText = t('terminal.Execution failed')
statusType = 'danger'
break
case taskStatus.Unknown:
statusText = t('terminal.Unknown execution result')
statusType = 'danger'
break
}
return {
statusText: statusText,
statusType: statusType,
}
}
const addTerminalTask = (command: string, pm: boolean, blockOnFailure = true, extend = '', callback: Function = () => {}) => {
if (pm) {
terminal.addTaskPM(command, blockOnFailure, extend, callback)
} else {
terminal.addTask(command, blockOnFailure, extend, callback)
}
// 任务列表滚动条滚动到底部
nextTick(() => {
if (terminalScrollbarRef.value && terminalScrollbarRef.value.wrapRef) {
terminalScrollbarRef.value.setScrollTop(terminalScrollbarRef.value.wrapRef.scrollHeight)
}
})
}
const webBuild = () => {
ElMessageBox.confirm(t('terminal.Are you sure you want to republish?'), t('Reminder'), {
confirmButtonText: t('Confirm'),
cancelButtonText: t('Cancel'),
type: 'warning',
}).then(() => {
changeListenDirtyFileSwitch(false)
addTerminalTask('web-build', true, true, '', () => {
changeListenDirtyFileSwitch(true)
})
})
}
const changePackageManager = (val: string) => {
state.packageManagerLoading = true
postChangeTerminalConfig({ manager: val })
.then((res) => {
if (res.code == 1) {
terminal.changePackageManager(val)
}
})
.finally(() => {
state.packageManagerLoading = false
})
}
const changeRegistry = (val: string, type: SourceType) => {
const oldVal = type == 'npm' ? terminal.state.npmRegistry : terminal.state.composerRegistry
terminal.changeRegistry(val, type)
state.registryLoading = true
state.registryLoadingType = type
terminal.addTask(`set-${type}-registry.${val}`, false, '', (res: taskStatus) => {
state.registryLoading = false
if (res == taskStatus.Failed || res == taskStatus.Unknown) {
ElMessageBox.confirm(t('terminal.Failed to modify the source command, Please try again manually'), t('Reminder'), {
confirmButtonText: t('Confirm'),
showCancelButton: false,
type: 'warning',
}).then(() => {
terminal.changeRegistry(oldVal, type)
})
}
})
}
const getSourceContent = (type: SourceType) => {
let content: anyObj = {}
if (type == 'npm') {
content = { npm: 'npm', taobao: 'taobao', tencent: 'tencent' }
} else if (type == 'composer') {
content = {
composer: 'composer',
huawei: 'huawei',
aliyun: 'aliyun',
tencent: 'tencent',
kkame: 'kkame',
}
}
// 如果值为 unknown则 unknown 选项
if (terminal.state[type == 'npm' ? 'npmRegistry' : 'composerRegistry'] == 'unknown') {
content.unknown = t('Unknown')
}
return content
}
onMounted(() => {
terminal.init()
})
</script>
<style scoped lang="scss">
.terminal-warning-alert {
margin: 0 0 20px 0;
}
.terminal-timeline {
padding: 0 15px;
}
.command {
font-size: var(--el-font-size-large);
font-weight: bold;
margin-left: 10px;
}
.exec-message {
color: var(--ba-bg-color-overlay);
font-size: 12px;
line-height: 16px;
padding: 6px;
background-color: #424251;
margin-top: 10px;
min-height: 30px;
max-height: 200px;
overflow: auto;
&::-webkit-scrollbar {
width: 5px;
height: 5px;
}
&::-webkit-scrollbar-thumb {
background: #c8c9cc;
border-radius: 4px;
box-shadow: none;
-webkit-box-shadow: none;
}
&::-webkit-scrollbar-track {
background: var(--ba-bg-color);
}
&:hover {
&::-webkit-scrollbar-thumb:hover {
background: #909399;
}
}
}
@supports not (selector(::-webkit-scrollbar)) {
.exec-message {
scrollbar-width: thin;
scrollbar-color: #c8c9cc #eaeaea;
}
}
.toggle-message-display {
padding-top: 10px;
font-size: 13px;
color: var(--el-text-color-secondary);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.task-opt {
display: none;
float: right;
}
.task-item.task-status-0:hover,
.task-item.task-status-3:hover,
.task-item.task-status-4:hover,
.task-item.task-status-5:hover {
.task-opt {
display: inline;
}
}
.block-on-failure-tag {
margin-left: 10px;
}
.terminal-menu-item {
margin-bottom: 12px;
}
.terminal-menu-item + .terminal-menu-item {
margin-left: 12px;
margin-bottom: 12px;
}
.terminal-buttons {
display: block;
width: fit-content;
margin: 0 auto;
padding-top: 12px;
}
.config-buttons {
display: flex;
align-items: center;
justify-content: flex-end;
padding-top: 20px;
padding-right: 20px;
}
:deep(.main-dialog) {
--el-dialog-padding-primary: 16px 16px 0 16px;
.el-dialog__body {
margin-top: 16px;
}
}
:deep(.ba-terminal-dialog) {
--el-dialog-width: 46% !important;
.el-loading-spinner {
--el-loading-spinner-size: 20px;
}
}
@media screen and (max-width: 768px) {
:deep(.ba-terminal-dialog) {
--el-dialog-width: 80% !important;
}
}
@media screen and (max-width: 540px) {
:deep(.ba-terminal-dialog) {
--el-dialog-width: 94% !important;
}
}
</style>

View File

@@ -7,8 +7,11 @@ import { adminBaseRoutePath } from '/@/router/static/adminBase'
*/
export default {
'/': ['./frontend/${lang}/index.ts'],
[adminBaseRoutePath + '/moduleStore']: ['./backend/${lang}/module.ts'],
[adminBaseRoutePath + '/user/rule']: ['./backend/${lang}/auth/rule.ts'],
[adminBaseRoutePath + '/user/scoreLog']: ['./backend/${lang}/user/moneyLog.ts'],
[adminBaseRoutePath + '/crud/crud']: ['./backend/${lang}/crud/log.ts', './backend/${lang}/crud/state.ts'],
[adminBaseRoutePath + '/mall/item']: ['./backend/${lang}/mall/item.ts'],
[adminBaseRoutePath + '/mall/item/index']: ['./backend/${lang}/mall/item.ts'],
[adminBaseRoutePath + '/mall.Item']: ['./backend/${lang}/mall/item.ts'],
[adminBaseRoutePath + '/mall.Item/index']: ['./backend/${lang}/mall/item.ts'],
}

View File

@@ -7,8 +7,13 @@ export default {
mobile: 'Mobile Number',
'Last login': 'Last login',
Password: 'Password',
agent_id: 'agent',
'Please leave blank if not modified': 'Please leave blank if you do not modify.',
'Personal signature': 'Personal Signature',
'Administrator login': 'Administrator Login Name',
'Google Authenticator': 'Google Authenticator',
Bound: 'Bound',
'Not bound': 'Not bound',
'Reset authenticator': 'Reset authenticator',
'Reset authenticator confirm': 'Reset this admin\'s Google Authenticator? They must re-bind on next login.',
'Pending bind tip': 'Not bound yet. This admin must complete binding on next login.',
}

View File

@@ -3,4 +3,15 @@ export default {
'Please input a password': 'Please enter your password',
'Hold session': 'Keep the session',
'Sign in': 'Sign in',
'Google Authenticator code': 'Google Authenticator code',
'Please enter the 6-digit code': 'Please enter the 6-digit code',
'Verify and sign in': 'Verify and sign in',
'Bind Google Authenticator': 'Bind Google Authenticator',
'Scan QR code with Google Authenticator': 'Scan the QR code with Google Authenticator',
'Or enter secret manually': 'Or enter the secret manually',
'Confirm bind': 'Confirm bind',
'Back to login': 'Back to login',
'Binding...': 'Binding...',
'Verifying...': 'Verifying...',
'Login failed, please try again': 'Sign-in could not be completed. Please try again or contact support.',
}

View File

@@ -1,10 +1,9 @@
export default {
id: 'id',
playx_user_asset_id: 'PlayX user asset',
playx_user_asset_id: 'playX user asset',
playxuserasset__username: 'username',
receiver_name: 'receiver name',
phone: 'phone',
region: 'region',
detail_address: 'detail_address',
default_setting: 'Default address',
'default_setting 0': '--',

View File

@@ -1,13 +1,28 @@
export default {
id: 'id',
user_id: 'user_id',
date: 'date',
username: 'username',
yesterday_win_loss_net: 'yesterday_win_loss_net',
yesterday_total_deposit: 'yesterday_total_deposit',
lifetime_total_deposit: 'lifetime_total_deposit',
lifetime_total_withdraw: 'lifetime_total_withdraw',
create_time: 'create_time',
'quick Search Fields': 'id',
id: 'ID',
user_id: 'PlayX user ID',
date: 'Business date',
username: 'Username',
yesterday_win_loss_net: 'Yesterday net win/loss',
converted_points: 'Converted points',
yesterday_total_deposit: 'Yesterday total deposit',
lifetime_total_deposit: 'Lifetime total deposit',
lifetime_total_withdraw: 'Lifetime total withdraw',
lifetime_win_loss_net: 'Lifetime net win/loss',
create_time: 'Created at',
'quick Search Fields': 'ID',
export_excel: 'Export Excel',
export_title: 'Export daily push data',
export_tip: 'Exports filtered and sorted data as Excel (.xlsx). The server writes in batches and supports exporting all matched records.',
export_fields: 'Export fields',
export_select_all: 'Select all',
export_clear_all: 'Clear all',
export_limit: 'Export limit (0 = all matched)',
export_all_matched: 'All matched',
export_matched_count: '{count} record(s) match the current filters',
export_actual_count: 'will export {count} record(s)',
export_large_warning: 'Large export may take a while. Please keep this page open.',
export_confirm: 'Start export',
export_success: 'Successfully exported {count} record(s)',
export_failed: 'Export failed, please try again later',
}

View File

@@ -1,23 +1,35 @@
export default {
id: 'id',
title: 'title',
description: 'description',
remark: 'remark',
score: 'score',
type: 'type',
'type 1': 'type 1',
'type 2': 'type 2',
'type 3': 'type 3',
amount: 'amount',
multiplier: 'multiplier',
category: 'category',
category_title: 'category_title',
admin_id: 'admin_id',
admin__username: 'username',
image: 'show image',
stock: 'stock',
sort: 'sort',
create_time: 'create_time',
update_time: 'update_time',
'quick Search Fields': 'id',
id: 'ID',
title: 'Title',
description: 'Description',
title_en: 'Title (English)',
description_en: 'Description (English)',
title_ms: 'Title (Malay)',
description_ms: 'Description (Malay)',
remark: 'Remark',
score: 'Points',
score_tip: 'Available points deducted when the user redeems this item.',
type: 'Type',
'type 1': 'Bonus',
'type 2': 'Physical',
'type 3': 'Withdraw',
amount: 'Cash amount',
amount_tip: 'Cash granted on redemption (stored on the order). You may use the suggested value below (points × mall reference ratio) or enter your own.',
amount_suggested: 'Suggested cash amount: {amount} ({score} points × ratio {ratio})',
amount_apply_suggested: 'Use suggested amount',
amount_ratio_missing: 'Mall points-to-cash reference ratio is unavailable; enter cash amount manually.',
points_to_cash_ratio_label: 'Reference ratio',
category: 'Category',
category_title: 'Category title',
admin_id: 'Created by',
admin__username: 'Created by',
image: 'Image',
stock: 'Stock',
sort: 'Sort',
status: 'Status',
'status 0': 'Disabled',
'status 1': 'Enabled',
create_time: 'Created at',
update_time: 'Updated at',
'quick Search Fields': 'ID',
}

View File

@@ -3,7 +3,7 @@ export default {
manual_retry: 'Retry grant',
retry_confirm: 'Queue this order for grant retry?',
id: 'Order ID',
user_id: 'User ID',
user_id: 'Username',
type: 'Type',
'type BONUS': 'Bonus',
'type PHYSICAL': 'Physical',
@@ -17,9 +17,7 @@ export default {
mallitem__title: 'Product title',
points_cost: 'Points spent',
amount: 'Cash amount',
multiplier: 'Turnover multiplier',
external_transaction_id: 'Order number',
playx_transaction_id: 'PlayX transaction ID',
grant_status: 'Grant status',
'grant_status NOT_SENT': 'Not sent',
'grant_status SENT_PENDING': 'Sent (queued)',

View File

@@ -0,0 +1,62 @@
export default {
userAsset: {
title: 'About this page',
desc: 'Manage mall user asset records, including PlayX binding and points fields used for claims and redemptions.',
},
address: {
title: 'About this page',
desc: 'View and manage user shipping addresses for physical orders.',
},
order: {
title: 'About this page',
desc: 'Central list of mall orders (approval, shipping, points changes) with search and admin actions.',
},
dailyPush: {
title: 'About this page',
desc: 'Daily T+1 snapshots from the partner; each row stores converted points per mall settings and syncs user assets.',
},
claimLog: {
title: 'About this page',
desc: 'History of users moving points from locked/pending to available, filterable by time and user.',
},
pointsLog: {
title: 'About this page',
desc: 'Records administrator additions and deductions to available points. Entries can only be added and viewed.',
},
item: {
title: 'About this page',
desc: 'Manage catalog items, stock, and multilingual display text for the points mall.',
},
pintsOrder: {
title: 'About this page',
desc: 'Orders paid with points—use for redemption and fulfillment checks.',
},
redemptionOrder: {
title: 'About this page',
desc: 'Redemption-style orders (e.g. bonus payouts) for issuance and reconciliation.',
},
playxCenter: {
title: 'About this page',
desc: 'PlayX hub: orders, daily push, claim logs, and user assets in one place for integration and troubleshooting.',
},
playxOrder: {
title: 'About this page',
desc: 'PlayX-facing order list for cross-checking with the mall.',
},
playxDailyPush: {
title: 'About this page',
desc: 'PlayX daily push rows in admin (compare with the standalone Daily push menu when sources align).',
},
playxClaimLog: {
title: 'About this page',
desc: 'PlayX users claim history on the mall side for amount and frequency checks.',
},
playxUserAsset: {
title: 'About this page',
desc: 'PlayX user assets in list form—cross-check with the main User assets screen when needed.',
},
playxConfig: {
title: 'About this page',
desc: 'General tab first: daily claim cap ratio. Event tab: dates, point sources, lifetime batch job. Product points and cash face value are set per item in Product management.',
},
}

View File

@@ -1,7 +1,7 @@
export default {
id: 'id',
order: 'order',
playx_user_asset_id: 'PlayX user asset',
playx_user_asset_id: 'playX user asset',
playxuserasset__username: 'username',
type: 'type',
'type 1': 'type 1',

View File

@@ -1,5 +1,5 @@
export default {
title: 'PlayX Integration Center',
title: 'playX Integration Center',
desc: 'Manage orders, daily push, claim logs and user assets in one place.',
orders: 'Orders',
dailyPush: 'Daily Push',

View File

@@ -0,0 +1,31 @@
export default {
tab_regular: 'General settings',
tab_event: 'Event settings',
section_event_period: 'Event period',
section_daily_source: 'Source 1: yesterday net win/loss',
daily_points_enabled: 'Enable daily loss to points',
daily_points_enabled_tip: 'Convert yesterday net win/loss from daily push to locked points; only losses (negative net) count.',
return_ratio: 'Yesterday loss conversion ratio',
return_ratio_tip: 'On daily push: converted points = floor(|yesterday net|) × ratio (losses only). Requires event window and enabled source.',
section_lifetime_source: 'Source 2: lifetime net win/loss',
lifetime_points_enabled: 'Enable lifetime loss to points',
lifetime_points_enabled_tip: 'Uses the latest daily push row per member; only losses. Skips when lifetime field is missing.',
lifetime_points_ratio: 'Lifetime loss conversion ratio',
lifetime_points_ratio_tip: 'One-click job: locked points from |lifetime net| × ratio. Re-run reverses prior lifetime conversion then applies the new ratio.',
calculate_lifetime_btn: 'Calculate lifetime loss points',
calculate_lifetime_tip: 'Batch update from latest daily push lifetime field and ratio above.',
calculate_lifetime_confirm: 'Recalculate all members with the current lifetime ratio (reverses previous lifetime conversion first). Continue?',
calculate_lifetime_success: 'Lifetime conversion done: {processed} processed, {updated} updated, {skipped} skipped',
section_mall_open: 'Mall open date',
mall_open_date: 'Event start date',
mall_open_date_tip: 'Takes effect the next calendar day. From then, daily push rows with business date on/after start date convert yesterday loss.',
active_mall_open_date: 'Active start date',
mall_event_end_date: 'Event end date',
mall_event_end_date_tip: 'Takes effect the next day; leave empty for no end. On the day after end, rows for the end business date no longer convert points or unlock cap; later business dates are excluded too.',
active_event_end_date: 'Active end date',
event_end_before_start: 'End date cannot be earlier than start date',
unlock_ratio: 'Daily claim cap ratio',
unlock_ratio_tip: 'Daily push: today claim cap = yesterday total deposit × this ratio (within event window).',
points_to_cash_ratio: 'Points-to-cash reference ratio',
points_to_cash_ratio_tip: 'Asset API withdrawable_cash = available points × this ratio. Suggested cash face value on bonus/withdraw items = redemption points × this ratio (editable per item).',
}

View File

@@ -1,12 +1,14 @@
export default {
id: 'id',
user_id: 'user_id',
date: 'date',
username: 'username',
yesterday_win_loss_net: 'yesterday_win_loss_net',
yesterday_total_deposit: 'yesterday_total_deposit',
lifetime_total_deposit: 'lifetime_total_deposit',
lifetime_total_withdraw: 'lifetime_total_withdraw',
create_time: 'create_time',
'quick Search Fields': 'id',
id: 'ID',
user_id: 'PlayX user ID',
date: 'Business date',
username: 'Username',
yesterday_win_loss_net: 'Yesterday net win/loss',
converted_points: 'Converted points',
yesterday_total_deposit: 'Yesterday total deposit',
lifetime_total_deposit: 'Lifetime total deposit',
lifetime_total_withdraw: 'Lifetime total withdraw',
lifetime_win_loss_net: 'Lifetime net win/loss',
create_time: 'Created at',
'quick Search Fields': 'ID',
}

View File

@@ -14,9 +14,7 @@ export default {
mallitem__title: 'title',
points_cost: 'points_cost',
amount: 'amount',
multiplier: 'multiplier',
external_transaction_id: 'external_transaction_id',
playx_transaction_id: 'playx_transaction_id',
grant_status: 'grant_status',
'grant_status NOT_SENT': 'NOT_SENT',
'grant_status SENT_PENDING': 'SENT_PENDING',
@@ -34,4 +32,11 @@ export default {
create_time: 'create_time',
update_time: 'update_time',
'quick Search Fields': 'ID',
manual_retry: 'Retry grant',
retry_confirm: 'Queue this order for grant retry?',
ship: 'Ship',
ship_shipping_no_prompt: 'Enter tracking number',
ship_shipping_company_prompt: 'Enter carrier',
reject: 'Reject',
reject_reason_prompt: 'Enter rejection reason',
}

View File

@@ -2,7 +2,7 @@ export default {
id: 'id',
username: 'username',
phone: 'phone',
playx_user_id: 'playx_user_id',
playx_user_id: 'playX_user_id',
locked_points: 'locked_points',
available_points: 'available_points',
today_limit: 'today_limit',
@@ -10,5 +10,5 @@ export default {
today_limit_date: 'today_limit_date',
create_time: 'create_time',
update_time: 'update_time',
'quick Search Fields': 'id, playx_user_id, username, phone',
'quick Search Fields': 'id, playX_user_id, username, phone',
}

View File

@@ -0,0 +1,20 @@
export default {
id: 'ID',
user_asset: 'User',
user_asset_id: 'User asset ID',
playx_user_id: 'PlayX ID',
username: 'Username',
available_points: 'Available points',
current_points: 'Current points',
score_value: 'Points adjustment',
before: 'Before',
after: 'After',
memo: 'Remark',
admin: 'Administrator',
create_time: 'Created at',
quick_search_fields: 'PlayX ID, username, phone',
non_zero_integer_required: 'Please enter a non-zero integer',
insufficient_available_points: 'Insufficient available points',
admin_add_memo: 'Administrator added {value} points',
admin_deduct_memo: 'Administrator deducted {value} points',
}

View File

@@ -1,7 +1,7 @@
export default {
id: 'id',
order: 'order',
playx_user_asset_id: 'PlayX user asset',
playx_user_asset_id: 'playX user asset',
playxuserasset__username: 'username',
status: 'status',
'status 0': 'status 0',

View File

@@ -1,15 +1,20 @@
export default {
id: 'id',
username: 'username',
phone: 'phone',
playx_user_id: 'playx_user_id',
locked_points: 'locked_points',
available_points: 'available_points',
today_limit: 'today_limit',
today_claimed: 'today_claimed',
today_limit_date: 'today_limit_date',
create_time: 'create_time',
update_time: 'update_time',
'quick Search Fields': 'id, playx_user_id, username, phone',
id: 'ID',
username: 'Username',
phone: 'Phone',
playx_user_id: 'PlayX user ID',
locked_points: 'Locked points',
available_points: 'Available points',
adjust_available_points: 'Adjust points',
today_limit: 'Daily claim cap',
today_limit_tip: 'Usually set by daily push: yesterday total deposit × unlock ratio in mall settings; 0 when the event is closed or settlement is not allowed. See “Daily cap date” for the business day. Manual edits here may be overwritten on the next successful push.',
today_claimed: 'Claimed today',
today_limit_date: 'Daily cap date',
points_claim_cap: 'Lifetime claim cap',
total_points_claimed: 'Total points claimed',
event_period_win_loss_net: 'Event period cumulative net W/L',
event_period_win_loss_net_tip: 'Adds yesterday net win/loss from each daily push while the event is open; pauses when closed; resumes when reopened without reset.',
create_time: 'Created at',
update_time: 'Updated at',
'quick Search Fields': 'ID, PlayX user ID, username, phone',
}

View File

@@ -107,13 +107,19 @@ export default {
mall_order_approve: 'Approve',
mall_dailyPush: 'Daily push',
mall_claimLog: 'Claim log',
mall_pointsLog: 'Points management',
mall_pointsLog_index: 'Browse',
mall_pointsLog_add: 'Add',
mall_item: 'Products',
mall_playxOrder: 'PlayX orders',
mall_playxCenter: 'PlayX center',
mall_playxClaimLog: 'PlayX claim log',
mall_playxDailyPush: 'PlayX daily push',
mall_playxUserAsset: 'PlayX user assets',
mall_playxOrder: 'playX orders',
mall_playxCenter: 'playX center',
mall_playxClaimLog: 'playX claim log',
mall_playxDailyPush: 'playX daily push',
mall_playxUserAsset: 'playX user assets',
mall_pintsOrder: 'Points orders',
mall_redemptionOrder: 'Redemption orders',
mall_playxConfig: 'Mall parameters',
mall_playxConfig_index: 'View',
mall_playxConfig_save: 'Save',
},
}

View File

@@ -1,163 +0,0 @@
export default {
'stateTitle init': 'Module installer initialization...',
'stateTitle download': 'Downloading module...',
'stateTitle install': 'Installing module...',
'stateTitle getInstallableVersion': 'Get installable version...',
'env require': 'Composer',
'env require-dev': 'Composer-dev',
'env dependencies': 'NPM',
'env devDependencies': 'NPM-dev',
'env nuxtDependencies': 'Nuxt NPM',
'env nuxtDevDependencies': 'Nuxt NPM Dev',
// buy
'Module installation warning':
'Free download and update within one year after purchase. Virtual products do not support 7-day refund without reason',
'Order title': 'Order title',
'Order No': 'Order No.:',
'Purchase user': 'Purchase user',
'Order price': 'Order price',
'Purchased, can be installed directly': 'Purchased, can be installed directly',
'Understand and agree': 'Understand and agree',
'Module purchase and use agreement': 'Module purchase and use agreement',
'Point payment': 'Point payment',
'Balance payment': 'Balance payment',
'Wechat payment': 'Wechat payment',
'Alipay payment': 'Alipay payment',
'Install now': 'Install now',
payment: 'payment',
'Confirm order info': 'Confirm order info',
// commonDone
'Congratulations, module installation is complete': 'Congratulations, module installation is complete.',
'Module is disabled': 'Module is disabled.',
'Congratulations, the code of the module is ready': 'Congratulations, the code of the module is ready.',
'Unknown state': 'Unknown state.',
'Do not refresh the page!': 'Do not refresh the page!',
'New adjustment of dependency detected': 'New adjustment of dependency detected',
'This module adds new dependencies': 'This module adds new dependencies',
'The built-in terminal of the system is automatically installing these dependencies, please wait~':
'The built-in terminal of the system is automatically installing these dependencies, please wait~',
'View progress': 'View progress',
'Dependency installation completed~': 'Dependency installation completed~',
'This module does not add new dependencies': 'This module does not add new dependencies.',
'There is no adjustment for system dependency': 'There is no adjustment for system dependency.',
please: 'please',
'After installation 1': 'After installation',
'Manually clean up the system and browser cache': 'Manually clean up the system and browser cache.',
'After installation 2': 'After installation',
'Automatically execute reissue command?': 'Automatically execute reissue command?',
'End of installation': 'End of installation',
'Dependency installation fail 1': 'The dependency installation failed. Please click the retry button in the ',
'Dependency installation fail 2': 'terminal',
'Dependency installation fail 3': 'You can also view the ',
'Dependency installation fail 4': 'unfinished matters manually',
'Dependency installation fail 5': 'Until you are',
'Dependency installation fail 6': 'sure that the dependency is ready',
'Dependency installation fail 7': ', the module will not work!',
'Is the command that failed on the WEB terminal executed manually or in other ways successfully?':
'Is the command that failed on the WEB terminal executed manually or in other ways successfully?',
yes: 'yes',
no: 'no',
// confirmFileConflict
'Update warning':
'The following module files have been detected to be updated. When disabled, they will be automatically overwritten. Please pay attention to backup.',
'File conflict': 'File conflict',
'Conflict file': 'Conflict file',
'Dependency conflict': 'Dependency conflict',
'Confirm to disable the module': 'Confirm to disable the module',
'The module declares the added dependencies': 'The module declares the added dependencies',
Dependencies: 'Dependencies',
retain: 'Retain',
// goodsInfo
'detailed information': 'detailed information',
Price: 'Price',
'Last updated': 'Last updated',
'Published on': 'Published on:',
'amount of downloads': 'amount of downloads',
'Module classification': 'Module classification',
'Module documentation': 'Module documentation',
'Developer Homepage': 'Developer Homepage',
'Click to access': 'Click to access',
'Module status': 'Module status',
'View demo': 'View demo',
'Code scanning Preview': 'Code scanning Preview',
'Buy now': 'Buy now',
'continue installation': 'continue installation',
installed: 'installed',
'to update': 'to update',
uninstall: 'uninstall',
'Contact developer': 'Contact developer',
'Other works of developers': 'Other works of developers',
'There are no more works': 'There are no more works',
'You need to disable this module before updating Do you want to disable it now?':
'You need to disable this module before updating. Do you want to disable it now?',
'Disable and update': 'Disable and update',
'No module purchase order was found': 'No module purchase order was found. Do you want to purchase the current module now?',
// installConflict
'new file': 'new file',
'Existing files': 'Existing files',
'Treatment scheme': 'Treatment scheme',
'Backup and overwrite existing files': 'Backup and overwrite existing files',
'Discard new file': 'Discard new file',
environment: 'environment',
'New dependency': 'New dependency',
'Existing dependencies': 'Existing dependencies',
'Overwrite existing dependencies': 'Overwrite existing dependencies',
'Do not use new dependencies': 'Do not use new dependencies',
// tableHeader
'Upload zip package for installation': 'Upload zip package for installation',
'Upload installation': 'Upload installation',
'Uploaded / installed modules': 'Uploaded / installed modules',
'Local module': 'Local module',
'Publishing module': 'Publishing module',
'Get points': 'Get points',
'Search is actually very simple': 'Search is actually very simple',
// tabs
Loading: 'Loading...',
'No more': 'No more.',
// uploadInstall
'Local upload warning':
'Please make sure that the module package file comes from the official channel or the officially certified module author, otherwise the system may be damaged because:',
'The module can modify and add system files': 'The module can modify and add system files',
'The module can execute sql commands and codes': 'The module can execute sql commands and codes',
'The module can install new front and rear dependencies': 'The module can install new front and rear dependencies',
'Drag the module package file here': 'Drag the module package file here, Or',
'Click me to upload': 'Click me to upload',
'Uploaded, installation is about to start, please wait': 'Uploaded, installation is about to start, please wait',
'Update Log': 'Update Log',
'No detailed update log': 'No detailed update log',
'Use WeChat to scan QR code for payment': 'Use WeChat to scan QR code for payment',
'Use Alipay to scan QR code for payment': 'Use Alipay to scan QR code for payment',
'dependency-installation-fail-tips':
'If the command is successfully executed manually, click `Make sure dependency is ready` above to change the module to the installed state',
'New version': 'New version',
Install: 'Install',
'Installation cancelled because module already exists!': 'Installation cancelled because module already exists!',
'Installation cancelled because the directory required by the module is occupied!':
'Installation cancelled because the directory required by the module is occupied!',
'Installation complete': 'Installation complete',
'A conflict is found Please handle it manually': 'A conflict is found. Please handle it manually',
'Select Version': 'Select install version',
'Wait for dependent installation': 'Wait for dependent installation',
'The operation succeeds Please clear the system cache and refresh the browser ~':
'The operation succeeds. Please clear the system cache and refresh the browser ~',
'Deal with conflict': 'Deal with conflict',
'Wait for installation': 'Wait for installation',
'Conflict pending': 'Conflict pending',
'Dependency to be installed': 'Dependency to be installed',
'Restart Vite hot server': 'Restart Vite hot server',
'Restart Vite hot server tips':
'Before successfully restarting the service, you can find the button to manually restart the service from the button group on the right side of the top bar.',
'Manual restart': 'Manual restart',
'Restart Now': 'Restart Now',
// 选择安装版本
'Available system version': 'Available system version',
Description: 'Description',
Version: 'Version',
'Current installed version': 'Current installed version',
'Insufficient system version': 'Insufficient system version',
'Click to install': 'Click to install',
'Versions released beyond the authorization period': 'Versions released beyond the authorization period',
Renewal: 'Renewal',
'Order expiration time':
'The expiration time of the current order authorization is {expiration_time}, and the release time of this version is {create_time}',
}

View File

@@ -11,4 +11,7 @@ export default {
'Please leave blank if not modified': 'Please leave blank if you do not modify',
'Save changes': 'Save changes',
'Operation log': 'Operation log',
'Google Authenticator': 'Google Authenticator',
Bound: 'Bound',
'Not bound': 'Not bound',
}

View File

@@ -7,8 +7,13 @@ export default {
mobile: '手机号',
'Last login': '最后登录',
Password: '密码',
agent_id: '代理',
'Please leave blank if not modified': '不修改请留空',
'Personal signature': '个性签名',
'Administrator login': '管理员登录名',
'Google Authenticator': '谷歌验证器',
Bound: '已绑定',
'Not bound': '未绑定',
'Reset authenticator': '重置验证器',
'Reset authenticator confirm': '确定要重置该管理员的谷歌验证器吗?重置后该管理员下次登录需重新绑定。',
'Pending bind tip': '该管理员尚未绑定,需在其下次登录时自行完成绑定',
}

View File

@@ -3,4 +3,15 @@ export default {
'Please input a password': '请输入密码',
'Hold session': '保持会话',
'Sign in': '登录',
'Google Authenticator code': '谷歌验证器验证码',
'Please enter the 6-digit code': '请输入6位验证码',
'Verify and sign in': '验证并登录',
'Bind Google Authenticator': '绑定谷歌验证器',
'Scan QR code with Google Authenticator': '请使用 Google Authenticator 扫描下方二维码',
'Or enter secret manually': '或手动输入密钥',
'Confirm bind': '确认绑定',
'Back to login': '返回登录',
'Binding...': '绑定中...',
'Verifying...': '验证中...',
'Login failed, please try again': '登录未完成,请重试或联系管理员',
}

View File

@@ -4,7 +4,6 @@ export default {
playxuserasset__username: '用户名',
receiver_name: '收货人',
phone: '电话',
region: '地区',
detail_address: '详细地址',
default_setting: '默认地址',
'default_setting 0': '--',

View File

@@ -1,7 +1,7 @@
export default {
id: 'ID',
claim_request_id: '领取订单号',
user_id: '用户ID',
user_id: 'playX-ID',
claimed_amount: '领取积分',
create_time: '创建时间',
'quick Search Fields': 'ID',

View File

@@ -1,13 +1,29 @@
export default {
id: 'ID',
user_id: '用户ID',
user_id: 'playX-ID',
date: '业务日期',
username: '用户名',
yesterday_win_loss_net: '昨日净输赢',
converted_points: '转换积分',
yesterday_total_deposit: '昨日总充值',
lifetime_total_deposit: '历史总充值',
lifetime_total_withdraw: '历史总提现',
lifetime_total_deposit: '终身总充值',
lifetime_total_withdraw: '终身总提现',
lifetime_win_loss_net: '终身总输赢',
create_time: '创建时间',
'quick Search Fields': 'ID',
export_excel: 'Excel导出',
export_title: '导出每日推送数据',
export_tip: '将按当前列表筛选条件与排序导出为 Excel.xlsx服务端分批写入支持导出全部匹配数据。',
export_fields: '导出字段',
export_select_all: '全选',
export_clear_all: '清空',
export_limit: '导出条数0 表示全部匹配)',
export_all_matched: '全部匹配',
export_matched_count: '当前筛选条件下共 {count} 条',
export_actual_count: '实际将导出 {count} 条',
export_large_warning: '导出条数较多,可能需要等待较长时间,请勿关闭页面。',
export_confirm: '开始导出',
export_success: '已成功导出 {count} 条数据',
export_failed: '导出失败,请稍后重试',
}

View File

@@ -2,14 +2,23 @@ export default {
id: 'ID',
title: '标题',
description: '描述',
title_en: '标题(英文)',
description_en: '描述(英文)',
title_ms: '标题(马来语)',
description_ms: '描述(马来语)',
remark: '备注',
score: '兑换积分',
score_tip: '用户兑换本商品时扣除的可用积分。',
type: '类型',
'type 1': '红利(BONUS)',
'type 2': '实物(PHYSICAL)',
'type 3': '提现(WITHDRAW)',
amount: '现金面值',
multiplier: '流水倍数',
amount_tip: '兑换成功后写入订单并推送给 PlayX 的现金金额。可参考下方建议值(兑换积分 × 商城积分兑现金参考比例),也可按业务自行填写。',
amount_suggested: '建议现金面值:{amount}(兑换积分 {score} × 比例 {ratio}',
amount_apply_suggested: '填入建议金额',
amount_ratio_missing: '暂未读取到商城「积分兑现金参考比例」,建议金额无法计算;现金面值请手工填写。',
points_to_cash_ratio_label: '当前参考比例',
category: '红利业务类别',
category_title: '类别展示名',
admin_id: '创建管理员',

View File

@@ -3,24 +3,22 @@ export default {
manual_retry: '手动重试',
retry_confirm: '确认将该订单加入重试队列?',
id: 'ID',
user_id: '用户ID',
user_id: '用户',
type: '类型',
'type BONUS': '红利(BONUS)',
'type PHYSICAL': '实物(PHYSICAL)',
'type WITHDRAW': '提现(WITHDRAW)',
status: '状态',
'status PENDING': '处理中(PENDING)',
'status COMPLETED': '已完成(COMPLETED)',
'status SHIPPED': '已发货(SHIPPED)',
'status REJECTED': '已驳回(REJECTED)',
'status PENDING': '处理中',
'status COMPLETED': '已完成',
'status SHIPPED': '已发货',
'status REJECTED': '已驳回',
mall_item_id: '商品ID',
mallitem__title: '商品标题',
points_cost: '消耗积分',
amount: '现金面值',
multiplier: '流水倍数',
external_transaction_id: '订单号',
playx_transaction_id: 'PlayX流水号',
grant_status: '推送playx状态',
grant_status: '推送playX状态',
'grant_status NOT_SENT': '未发送',
'grant_status SENT_PENDING': '已发送排队',
'grant_status ACCEPTED': '已接收',

View File

@@ -0,0 +1,62 @@
export default {
userAsset: {
title: '页面说明',
desc: '维护积分商城用户资产主数据,含 PlayX 用户绑定、积分字段等,用于前台领取与兑换鉴权。',
},
address: {
title: '页面说明',
desc: '查看与管理用户收货地址,供实物类订单发货使用。',
},
order: {
title: '页面说明',
desc: '集中查看商城订单(含审核、发货、积分变动等),支持按条件检索与后台操作。',
},
dailyPush: {
title: '页面说明',
desc: '展示合作方 T+1 每日推送的经营数据快照;入库时按商城参数折算「转换积分」并同步用户资产。',
},
claimLog: {
title: '页面说明',
desc: '用户从待领取积分划转至可用积分的操作流水,可按时间与用户追溯。',
},
pointsLog: {
title: '页面说明',
desc: '记录管理员对用户可用积分的人工增加与扣除;流水仅允许新增和查看。',
},
item: {
title: '页面说明',
desc: '维护积分商城上架商品、库存与多语言展示信息。',
},
pintsOrder: {
title: '页面说明',
desc: '以积分支付的订单列表,用于核对兑换与发货状态。',
},
redemptionOrder: {
title: '页面说明',
desc: '兑换类业务订单(如礼金等)列表,用于核对发放与对账。',
},
playxCenter: {
title: '页面说明',
desc: 'PlayX 相关能力的聚合入口:订单、每日推送、领取记录与用户资产分栏查看,便于联调与排障。',
},
playxOrder: {
title: '页面说明',
desc: '从 PlayX 视角查看与商城关联的订单数据,用于对接核对。',
},
playxDailyPush: {
title: '页面说明',
desc: 'PlayX 每日推送记录在后台的列表视图(与独立「每日推送」菜单数据源一致时可对照查看)。',
},
playxClaimLog: {
title: '页面说明',
desc: 'PlayX 用户在商城侧的领取流水,用于核对领取额度与频次。',
},
playxUserAsset: {
title: '页面说明',
desc: 'PlayX 用户资产在后台的列表视图,可与「用户资产」主菜单交叉核对。',
},
playxConfig: {
title: '页面说明',
desc: '「常规配置」:今日积分领取上限比例、积分兑现金参考比例(资产展示与商品现金面值建议);「活动配置」含活动起止日与积分来源。',
},
}

View File

@@ -1,6 +1,6 @@
export default {
title: 'PlayX 对接中心',
desc: '集中管理积分商城与 PlayX 的订单、推送、领取与资产数据。建议优先处理“推送playx状态=失败可重试”的订单。',
title: 'playX 对接中心',
desc: '集中管理积分商城与 playX 的订单、推送、领取与资产数据。建议优先处理“推送playX状态=失败可重试”的订单。',
orders: '统一订单',
dailyPush: '每日推送',
claimLog: '领取记录',

View File

@@ -1,7 +1,7 @@
export default {
id: 'ID',
claim_request_id: '领取订单号',
user_id: '用户ID',
user_id: 'playX-ID',
claimed_amount: '领取积分',
create_time: '创建时间',
'quick Search Fields': 'ID',

View File

@@ -0,0 +1,31 @@
export default {
tab_regular: '常规配置',
tab_event: '活动配置',
section_event_period: '活动时间',
section_daily_source: '积分来源一:昨日净输赢',
daily_points_enabled: '启用昨日净输赢转积分',
daily_points_enabled_tip: '根据每日推送中的「昨日净输赢」折算待领取积分;仅统计会员输的钱(净输赢为负),赢的不计。',
return_ratio: '昨日净输赢兑换比例',
return_ratio_tip: '每日推送入库时:转换积分 = floor(|昨日净输赢|) × 本比例(仅负值,结果为非负整数)。需满足活动时间规则且本来源已启用。',
section_lifetime_source: '积分来源二:终身总输赢',
lifetime_points_enabled: '启用终身总输赢转积分',
lifetime_points_enabled_tip: '使用每日推送中每位会员最新一条记录的「终身总输赢」字段;仅统计输的钱。第三方未传该字段时跳过。',
lifetime_points_ratio: '终身总输赢兑换比例',
lifetime_points_ratio_tip: '一键计算时:待领取增量按 |终身总输赢| × 本比例折算。重复执行会先按已记入的终身折算额扣回,再按新比例增加。',
calculate_lifetime_btn: '一键计算终身总输赢',
calculate_lifetime_tip: '按各会员最新每日推送中的终身总输赢与上方比例,批量更新待领取积分与领取上限。无终身总输赢数据的会员将跳过。',
calculate_lifetime_confirm: '将按当前终身总输赢兑换比例重新折算全部会员积分,已折算部分会先扣回再按新比例增加。是否继续?',
calculate_lifetime_success: '终身总输赢积分折算完成:共处理 {processed} 条,更新 {updated} 条,跳过 {skipped} 条',
section_mall_open: '商城开放日',
mall_open_date: '活动开始日',
mall_open_date_tip: '设置后隔日生效。从生效日起对业务日期不早于开始日的每日推送执行「昨日净输赢」转积分T+1。',
active_mall_open_date: '当前生效的开始日',
mall_event_end_date: '活动结束日',
mall_event_end_date_tip: '设置后隔日生效,可留空表示不限制结束。结束日次日不再对结束日当天业务数据的昨日净输赢折算积分与解锁上限;结束日之后的业务日期也不再折算。',
active_event_end_date: '当前生效的结束日',
event_end_before_start: '活动结束日不能早于开始日',
unlock_ratio: '今日积分领取上限比例',
unlock_ratio_tip: '每日推送:今日可领取上限 = 昨日总充值 × 本比例(受活动时间规则约束)。',
points_to_cash_ratio: '积分兑现金参考比例',
points_to_cash_ratio_tip: '资产接口 withdrawable_cash = 可用积分 × 本比例;商品管理添加红利/提现商品时,「现金面值」建议金额 = 兑换积分 × 本比例(可手工覆盖)。',
}

View File

@@ -1,12 +1,14 @@
export default {
id: 'ID',
user_id: '用户ID',
user_id: 'playX-ID',
date: '业务日期',
username: '用户名',
yesterday_win_loss_net: '昨日净输赢',
converted_points: '转换积分',
yesterday_total_deposit: '昨日总充值',
lifetime_total_deposit: '历史总充值',
lifetime_total_withdraw: '历史总提现',
lifetime_total_deposit: '终身总充值',
lifetime_total_withdraw: '终身总提现',
lifetime_win_loss_net: '终身总输赢',
create_time: '创建时间',
'quick Search Fields': 'ID',
}

View File

@@ -1,6 +1,6 @@
export default {
id: 'ID',
user_id: '用户ID',
user_id: 'playX-ID',
type: '类型',
'type BONUS': '红利(BONUS)',
'type PHYSICAL': '实物(PHYSICAL)',
@@ -14,10 +14,8 @@ export default {
mallitem__title: '商品标题',
points_cost: '消耗积分',
amount: '现金面值',
multiplier: '流水倍数',
external_transaction_id: '订单号',
playx_transaction_id: 'PlayX流水号',
grant_status: '推送playx状态',
grant_status: '推送playX状态',
'grant_status NOT_SENT': '未发送',
'grant_status SENT_PENDING': '已发送排队',
'grant_status ACCEPTED': '已接收(accepted)',
@@ -34,5 +32,12 @@ export default {
create_time: '创建时间',
update_time: '修改时间',
'quick Search Fields': 'ID',
manual_retry: '手动重试',
retry_confirm: '确认将该订单加入重试队列?',
ship: '发货',
ship_shipping_no_prompt: '请输入物流单号',
ship_shipping_company_prompt: '请输入物流公司',
reject: '驳回',
reject_reason_prompt: '请输入驳回原因',
}

View File

@@ -2,7 +2,7 @@ export default {
id: 'ID',
username: '用户名',
phone: '手机号',
playx_user_id: 'PlayX用户ID',
playx_user_id: 'playX-ID',
locked_points: '待领取积分',
available_points: '可用积分',
today_limit: '今日可领取上限',
@@ -10,5 +10,5 @@ export default {
today_limit_date: '今日上限日期',
create_time: '创建时间',
update_time: '修改时间',
'quick Search Fields': 'ID、PlayX用户ID、用户名、手机号',
'quick Search Fields': 'ID、playX用户ID、用户名、手机号',
}

View File

@@ -0,0 +1,20 @@
export default {
id: 'ID',
user_asset: '用户',
user_asset_id: '用户资产ID',
playx_user_id: 'playX-ID',
username: '用户名',
available_points: '可用积分',
current_points: '当前积分',
score_value: '变更积分',
before: '调整前积分',
after: '调整后积分',
memo: '备注',
admin: '操作管理员',
create_time: '创建时间',
quick_search_fields: 'playX用户ID、用户名、手机号',
non_zero_integer_required: '请输入非零整数',
insufficient_available_points: '可用积分不足',
admin_add_memo: '管理员增加积分 {value}',
admin_deduct_memo: '管理员扣除积分 {value}',
}

View File

@@ -2,14 +2,19 @@ export default {
id: 'ID',
username: '用户名',
phone: '手机号',
playx_user_id: 'PlayX用户ID',
playx_user_id: 'playX-ID',
locked_points: '待领取积分',
available_points: '可用积分',
adjust_available_points: '调整积分',
today_limit: '今日可领取上限',
today_limit_tip: '一般由每日推送自动写入:昨日总充值 × 商城参数「今日积分领取上限比例」;活动关闭或不允许结算时为 0。对应业务日见「今日上限日期」。后台可手工改下次成功推送可能覆盖。',
today_claimed: '今日已领取',
today_limit_date: '今日上限日期',
points_claim_cap: '领取积分上限',
total_points_claimed: '累计已领取积分',
event_period_win_loss_net: '活动期间累积净输赢',
event_period_win_loss_net_tip: '活动开放时按每日推送的「昨日净输赢」累加;关闭期间不累加,再次开启后接着累加,不重置。',
create_time: '创建时间',
update_time: '修改时间',
'quick Search Fields': 'ID、PlayX用户ID、用户名、手机号',
'quick Search Fields': 'ID、playX用户ID、用户名、手机号',
}

View File

@@ -108,13 +108,19 @@ export default {
mall_order_approve: '审核通过',
mall_dailyPush: '每日推送',
mall_claimLog: '领取记录',
mall_pointsLog: '积分管理',
mall_pointsLog_index: '查看',
mall_pointsLog_add: '新增',
mall_item: '商品管理',
mall_playxOrder: 'PlayX订单',
mall_playxCenter: 'PlayX中心',
mall_playxClaimLog: 'PlayX领取记录',
mall_playxDailyPush: 'PlayX每日推送',
mall_playxUserAsset: 'PlayX用户资产',
mall_playxOrder: 'playX订单',
mall_playxCenter: 'playX中心',
mall_playxClaimLog: 'playX领取记录',
mall_playxDailyPush: 'playX每日推送',
mall_playxUserAsset: 'playX用户资产',
mall_pintsOrder: '积分订单',
mall_redemptionOrder: '兑换订单',
mall_playxConfig: '商城参数配置',
mall_playxConfig_index: '查看',
mall_playxConfig_save: '保存',
},
}

View File

@@ -1,153 +0,0 @@
export default {
'stateTitle init': '模块安装器初始化...',
'stateTitle download': '正在下载模块...',
'stateTitle install': '正在安装模块...',
'stateTitle getInstallableVersion': '正在获取模块版本列表...',
'env require': '后端依赖composer',
'env require-dev': '后端开发环境依赖composer',
'env dependencies': '前端依赖NPM',
'env devDependencies': '前端开发环境依赖NPM',
'env nuxtDependencies': '前端依赖Nuxt-NPM',
'env nuxtDevDependencies': '前端开发环境依赖Nuxt-NPM',
// buy
'Module installation warning': '购买后一年内可免费下载和更新虚拟产品不支持7天无理由退款',
'Order title': '订单标题',
'Order No': '订单编号',
'Purchase user': '购买用户',
'Order price': '订单价格',
'Purchased, can be installed directly': '已购买,可直接安装',
'Understand and agree': '理解并同意',
'Module purchase and use agreement': '模块购买和使用协议',
'Point payment': '积分支付',
'Balance payment': '余额支付',
'Wechat payment': '微信支付',
'Alipay payment': '支付宝支付',
'Install now': '立即安装',
payment: '支付',
'Confirm order info': '确认订单信息',
// commonDone
'Congratulations, module installation is complete': '恭喜,模块安装已完成。',
'Module is disabled': '模块已禁用。',
'Congratulations, the code of the module is ready': '恭喜,模块的代码已经准备好了。',
'Unknown state': '未知状态。',
'Do not refresh the page!': '请勿刷新页面!',
'New adjustment of dependency detected': '检测到依赖项有新的调整',
'This module adds new dependencies': '本模块添加了新的依赖项',
'The built-in terminal of the system is automatically installing these dependencies, please wait~': '系统内置终端正在自动安装这些依赖,请稍等~',
'View progress': '查看进度',
'Dependency installation completed~': '依赖已安装完成~',
'This module does not add new dependencies': '本模块没有添加新的依赖项。',
'There is no adjustment for system dependency': '系统依赖无调整。',
please: '请',
'After installation 1': '在安装结束后',
'Manually clean up the system and browser cache': '手动的清理系统和浏览器缓存。',
'After installation 2': '安装结束后',
'Automatically execute reissue command?': '自动执行重新发布命令?',
'End of installation': '安装结束',
'Dependency installation fail 1': '依赖安装失败,请点击',
'Dependency installation fail 2': '终端',
'Dependency installation fail 3': '中的重试按钮,您也可以查看',
'Dependency installation fail 4': '手动完成未尽事宜',
'Dependency installation fail 5': '在您',
'Dependency installation fail 6': '确定依赖已准备好',
'Dependency installation fail 7': '之前,模块还不能正常使用!',
'Is the command that failed on the WEB terminal executed manually or in other ways successfully?':
'WEB终端失败的命令已经手动或以其他方式执行成功',
yes: '是',
no: '否',
// confirmFileConflict
'Update warning': '检测到以下的模块文件有更新,禁用时将自动覆盖,请注意备份。',
'File conflict': '文件冲突',
'Conflict file': '冲突文件',
'Dependency conflict': '依赖冲突',
'Confirm to disable the module': '确认禁用模块',
'The module declares the added dependencies': '模块声明添加的依赖',
Dependencies: '依赖项',
retain: '保留',
// goodsInfo
'detailed information': '详细信息',
Price: '价格',
'Last updated': '最后更新',
'Published on': '发布时间',
'amount of downloads': '下载次数',
'Module classification': '模块分类',
'Module documentation': '模块文档',
'Developer Homepage': '开发者主页',
'Click to access': '点击访问',
'Module status': '模块状态',
'View demo': '查看演示',
'Code scanning Preview': '扫码预览',
'Buy now': '立即购买',
'continue installation': '继续安装',
installed: '已安装',
'to update': '更新',
uninstall: '卸载',
'Contact developer': '联系开发者',
'Other works of developers': 'TA的其他作品',
'There are no more works': '没有更多作品了',
'You need to disable this module before updating Do you want to disable it now?': '更新前需要先禁用该模块,立即禁用?',
'Disable and update': '禁用并更新',
'No module purchase order was found': '没有找到有效的模块购买订单,是否立即购买当前模块?',
// installConflict
'new file': '新文件',
'Existing files': '已有文件',
'Treatment scheme': '处理方案',
'Backup and overwrite existing files': '备份并覆盖已有文件',
'Discard new file': '丢弃新文件',
environment: '环境',
'New dependency': '新依赖',
'Existing dependencies': '已有依赖',
'Overwrite existing dependencies': '覆盖已有依赖',
'Do not use new dependencies': '不使用新依赖',
// tableHeader
'Upload zip package for installation': '上传ZIP包安装',
'Upload installation': '上传安装',
'Uploaded / installed modules': '已上传/安装的模块',
'Local module': '本地模块',
'Publishing module': '发布模块',
'Get points': '获得积分',
'Search is actually very simple': '搜索其实很简单',
// tabs
Loading: '加载中...',
'No more': '没有更多了...',
// uploadInstall
'Local upload warning': '请您务必确认模块包文件来自官方渠道或经由官方认证的模块作者,否则系统可能被破坏,因为:',
'The module can modify and add system files': '模块可以修改和新增系统文件',
'The module can execute sql commands and codes': '模块可以执行sql命令和代码',
'The module can install new front and rear dependencies': '模块可以安装新的前后端依赖',
'Drag the module package file here': '拖拽模块包文件到此处或',
'Click me to upload': '点击我上传',
'Uploaded, installation is about to start, please wait': '已上传,即将开始安装,请稍等',
'Update Log': '更新日志',
'No detailed update log': '无详细更新日志',
'Use WeChat to scan QR code for payment': '使用微信扫描二维码支付',
'Use Alipay to scan QR code for payment': '使用支付宝扫描二维码支付',
'dependency-installation-fail-tips': '若手动执行命令成功,可点击以上的 `确定依赖已准备好` 将模块修改为已安装状态。',
'New version': '有新版本',
Install: '安装',
'Installation cancelled because module already exists!': '安装取消,因为模块已经存在!',
'Installation cancelled because the directory required by the module is occupied!': '安装取消,因为模块所需目录被占用!',
'Installation complete': '安装完成',
'A conflict is found Please handle it manually': '发现冲突,请手动处理',
'Select Version': '选择安装版本',
'Wait for dependent installation': '等待依赖安装',
'The operation succeeds Please clear the system cache and refresh the browser ~': '操作成功,请清理系统缓存并刷新浏览器~',
'Deal with conflict': '处理冲突',
'Wait for installation': '等待安装',
'Conflict pending': '冲突待处理',
'Dependency to be installed': '依赖待安装',
'Restart Vite hot server': '重启热更新服务',
'Restart Vite hot server tips': '在完成服务重启之前,您还可以随时从顶栏右侧的按钮组中找到手动重启服务的按钮。',
'Manual restart': '手动重启',
'Restart Now': '立即重启',
// 选择安装版本
'Available system version': '可用系统版本',
Description: '描述',
Version: '版本',
'Current installed version': '当前安装版本',
'Insufficient system version': '系统版本不足',
'Click to install': '点击安装',
'Versions released beyond the authorization period': '授权期限以外发布的版本',
Renewal: '续费',
'Order expiration time': '当前订单授权过期时间为 {expiration_time},此版本发布时间为 {create_time}',
}

View File

@@ -11,4 +11,7 @@ export default {
'Please leave blank if not modified': '不修改请留空',
'Save changes': '保存修改',
'Operation log': '操作日志',
'Google Authenticator': '谷歌验证器',
Bound: '已绑定',
'Not bound': '未绑定',
}

View File

@@ -2,7 +2,7 @@ export default {
'User name': '用户名',
'User nickname': '用户昵称',
balance: '余额',
'User ID': '用户ID',
'User ID': 'playX-ID',
'Change balance': '变更余额',
'Before change': '变更前',
'After change': '变更后',

View File

@@ -53,8 +53,11 @@ export async function loadLang(app: App) {
*/
if (locale == 'zh-cn') {
assignLocale[locale].push(getLangFileMessage(import.meta.glob('./common/zh-cn/**/*.ts', { eager: true }), locale))
// 积分商城后台页表格列在 setup 阶段即 t(),若仅依赖路由懒加载会出现列头显示为 key
assignLocale[locale].push(getLangFileMessage(import.meta.glob('./backend/zh-cn/mall/**/*.ts', { eager: true }), locale))
} else if (locale == 'en') {
assignLocale[locale].push(getLangFileMessage(import.meta.glob('./common/en/**/*.ts', { eager: true }), locale))
assignLocale[locale].push(getLangFileMessage(import.meta.glob('./backend/en/mall/**/*.ts', { eager: true }), locale))
}
const messages = {

View File

@@ -71,13 +71,6 @@
<Icon :color="configStore.getColorVal('headerBarTabColor')" class="nav-menu-icon" v-else name="el-icon-FullScreen" size="18" />
</div>
<!-- 终端 - 仅超管 -->
<div v-if="adminInfo.super" @click="terminal.toggle()" class="nav-menu-item pt2">
<el-badge :is-dot="terminal.state.showDot">
<Icon :color="configStore.getColorVal('headerBarTabColor')" class="nav-menu-icon" name="local-terminal" size="26" />
</el-badge>
</div>
<!-- 清理缓存 - 仅超管 -->
<el-dropdown
v-if="adminInfo.super"
@@ -140,7 +133,6 @@
</div>
<Config />
<Terminal />
</div>
</template>
@@ -152,14 +144,12 @@ import { useI18n } from 'vue-i18n'
import Config from './config.vue'
import { logout } from '/@/api/backend/index'
import { postClearCache } from '/@/api/common'
import Terminal from '/@/components/terminal/index.vue'
import { editDefaultLang } from '/@/lang'
import router from '/@/router'
import { useAdminInfo } from '/@/stores/adminInfo'
import { useConfig } from '/@/stores/config'
import { ADMIN_INFO, BA_ACCOUNT } from '/@/stores/constant/cacheKey'
import { useSiteConfig } from '/@/stores/siteConfig'
import { useTerminal } from '/@/stores/terminal'
import { fullUrl, timeFormat } from '/@/utils/common'
import { routePush } from '/@/utils/router'
import { Local, Session } from '/@/utils/storage'
@@ -169,7 +159,6 @@ const { t } = useI18n()
const adminInfo = useAdminInfo()
const configStore = useConfig()
const terminal = useTerminal()
const siteConfig = useSiteConfig()
const reloadHotServerPopover = useTemplateRef('reloadHotServerPopover')

View File

@@ -6,7 +6,6 @@
import { reactive } from 'vue'
import { useConfig } from '/@/stores/config'
import { useNavTabs } from '/@/stores/navTabs'
import { useTerminal } from '/@/stores/terminal'
import { useSiteConfig } from '/@/stores/siteConfig'
import { useAdminInfo } from '/@/stores/adminInfo'
import { useRoute } from 'vue-router'
@@ -29,7 +28,6 @@ defineOptions({
components: { Default, Classic, Streamline, Double },
})
const terminal = useTerminal()
const navTabs = useNavTabs()
const config = useConfig()
const route = useRoute()
@@ -59,8 +57,6 @@ const init = () => {
index()
.then((res) => {
siteConfig.dataFill(res.data.siteConfig)
terminal.changePackageManager(res.data.terminal.npmPackageManager)
terminal.changePHPDevelopmentServer(res.data.terminal.phpDevelopmentServer)
siteConfig.setInitialize(true)
if (!isEmpty(res.data.adminInfo)) {

View File

@@ -9,8 +9,12 @@ import { mergeMessage } from '/@/lang/index'
import { useConfig } from '/@/stores/config'
import { useAdminInfo } from '/@/stores/adminInfo'
import { isAdminApp } from '/@/utils/common'
import { ensureStandardHashUrl, redirectRootToAdminLoginIfNoHash } from '/@/utils/hashRouteUrl'
import { uniq } from 'lodash-es'
redirectRootToAdminLoginIfNoHash()
ensureStandardHashUrl()
const router = createRouter({
history: createWebHashHistory(),
routes: staticRoutes,
@@ -84,8 +88,11 @@ router.beforeEach((to, from, next) => {
// 路由加载后
router.afterEach(() => {
ensureStandardHashUrl()
if (window.existLoading) {
loading.hide()
} else {
window.existLoading = false
}
NProgress.done()
})

View File

@@ -9,8 +9,6 @@ export const ADMIN_INFO = 'adminInfo'
export const STORE_CONFIG = 'storeConfig_v2'
// 后台标签页
export const STORE_TAB_VIEW_CONFIG = 'storeTabViewConfig'
// 终端
export const STORE_TERMINAL = 'storeTerminal'
// 工作时间
export const WORKING_TIME = 'workingTime'

View File

@@ -1,8 +0,0 @@
export const enum taskStatus {
Waiting,
Connecting,
Executing,
Success,
Failed,
Unknown,
}

View File

@@ -134,48 +134,6 @@ export interface UserInfo {
refresh_token: string
}
export interface TaskItem {
// 任务唯一标识
uuid: string
// 创建时间
createTime: string
// 状态
status: number
// 命令
command: string
// 命令执行日志
message: string[]
// 显示命令执行日志
showMessage: boolean
// 失败阻断后续命令执行
blockOnFailure: boolean
// 扩展信息,自动发送到后台
extend: string
// 执行结果回调
callback: Function
}
export interface Terminal {
// 显示终端窗口
show: boolean
// 在后台终端按钮上显示一个红点
showDot: boolean
// 任务列表
taskList: TaskItem[]
// 包管理器
packageManager: string
// 显示终端设置窗口
showConfig: boolean
// 开始任务时自动清理已完成任务
automaticCleanupTask: string
// PHP 开发服务环境
phpDevelopmentServer: boolean
// NPM 源
npmRegistry: string
// composer 源
composerRegistry: string
}
export interface SiteConfig {
// 站点名称
siteName: string
@@ -196,6 +154,8 @@ export interface SiteConfig {
recordNumber?: string
// 内容分发网络URL的参数格式如 imageMogr2/format/heif
cdnUrlParams: string
// 是否开启管理员谷歌验证器
totpEnable?: boolean
// 初始化状态
initialize: boolean

View File

@@ -15,6 +15,7 @@ export const useSiteConfig = defineStore('siteConfig', {
headNav: [],
recordNumber: '',
cdnUrlParams: '',
totpEnable: true,
initialize: false,
userInitialize: false,
}

View File

@@ -1,292 +0,0 @@
import { ElNotification } from 'element-plus'
import { defineStore } from 'pinia'
import { nextTick, reactive } from 'vue'
import { buildTerminalUrl } from '/@/api/common'
import { i18n } from '/@/lang/index'
import { STORE_TERMINAL } from '/@/stores/constant/cacheKey'
import { SYSTEM_ZINDEX } from '/@/stores/constant/common'
import { taskStatus } from '/@/stores/constant/terminalTaskStatus'
import type { Terminal } from '/@/stores/interface/index'
import { timeFormat } from '/@/utils/common'
import { uuid } from '/@/utils/random'
import { closeHotUpdate, openHotUpdate } from '/@/utils/vite'
export const useTerminal = defineStore(
'terminal',
() => {
const state: Terminal = reactive({
show: false,
showDot: false,
taskList: [],
packageManager: 'pnpm',
showConfig: false,
automaticCleanupTask: '1',
phpDevelopmentServer: false,
npmRegistry: 'unknown',
composerRegistry: 'unknown',
})
function init() {
for (const key in state.taskList) {
if (state.taskList[key].status == taskStatus.Connecting || state.taskList[key].status == taskStatus.Executing) {
state.taskList[key].status = taskStatus.Unknown
}
}
}
function toggle(val = !state.show) {
state.show = val
if (val) {
toggleDot(false)
}
}
function toggleDot(val = !state.showDot) {
state.showDot = val
}
function toggleConfigDialog(val = !state.showConfig) {
toggle(!val)
state.showConfig = val
}
function changeRegistry(val: string, type: 'npm' | 'composer') {
state[type == 'npm' ? 'npmRegistry' : 'composerRegistry'] = val
}
function changePackageManager(val: string) {
state.packageManager = val
}
function changePHPDevelopmentServer(val: boolean) {
state.phpDevelopmentServer = val
}
function changeAutomaticCleanupTask(val: '0' | '1') {
state.automaticCleanupTask = val
}
function setTaskStatus(idx: number, status: number) {
state.taskList[idx].status = status
if ((status == taskStatus.Failed || status == taskStatus.Unknown) && state.taskList[idx].blockOnFailure) {
setTaskShowMessage(idx, true)
}
}
function taskCompleted(idx: number) {
// 命令执行完毕,重新打开热更新
openHotUpdate('terminal')
if (typeof state.taskList[idx].callback != 'function') return
const status = state.taskList[idx].status
if (status == taskStatus.Failed || status == taskStatus.Unknown) {
state.taskList[idx].callback(taskStatus.Failed)
} else if (status == taskStatus.Success) {
state.taskList[idx].callback(taskStatus.Success)
}
}
function setTaskShowMessage(idx: number, val = !state.taskList[idx].showMessage) {
state.taskList[idx].showMessage = val
}
function addTaskMessage(idx: number, message: string) {
if (!state.show) toggleDot(true)
state.taskList[idx].message = state.taskList[idx].message.concat(message)
nextTick(() => {
execMessageScrollbarKeep(state.taskList[idx].uuid)
})
}
function addTask(command: string, blockOnFailure = true, extend = '', callback: Function = () => {}) {
if (!state.show) toggleDot(true)
state.taskList = state.taskList.concat({
uuid: uuid(),
createTime: timeFormat(),
status: taskStatus.Waiting,
command: command,
message: [],
showMessage: false,
blockOnFailure: blockOnFailure,
extend: extend,
callback: callback,
})
// 清理任务列表
if (parseInt(state.automaticCleanupTask) === 1) {
clearSuccessTask()
}
// 检查是否有已经失败的任务
if (state.show === false) {
for (const key in state.taskList) {
if (state.taskList[key].status == taskStatus.Failed || state.taskList[key].status == taskStatus.Unknown) {
ElNotification({
type: 'error',
message: i18n.global.t('terminal.Newly added tasks will never start because they are blocked by failed tasks'),
zIndex: SYSTEM_ZINDEX,
})
break
}
}
}
startTask()
}
function addTaskPM(command: string, blockOnFailure = true, extend = '', callback: Function = () => {}) {
addTask(command + '.' + state.packageManager, blockOnFailure, extend, callback)
}
function delTask(idx: number) {
if (state.taskList[idx].status != taskStatus.Connecting && state.taskList[idx].status != taskStatus.Executing) {
state.taskList.splice(idx, 1)
}
startTask()
}
function startTask() {
let taskKey = null
// 寻找可以开始执行的命令
for (const key in state.taskList) {
if (state.taskList[key].status == taskStatus.Waiting) {
taskKey = parseInt(key)
break
}
if (state.taskList[key].status == taskStatus.Connecting || state.taskList[key].status == taskStatus.Executing) {
break
}
if (state.taskList[key].status == taskStatus.Success) {
continue
}
if (state.taskList[key].status == taskStatus.Failed || state.taskList[key].status == taskStatus.Unknown) {
if (state.taskList[key].blockOnFailure) {
break
} else {
continue
}
}
}
if (taskKey !== null) {
setTaskStatus(taskKey, taskStatus.Connecting)
startEventSource(taskKey)
}
}
function startEventSource(taskKey: number) {
// 命令执行期间禁用热更新
closeHotUpdate('terminal')
window.eventSource = new EventSource(
buildTerminalUrl(state.taskList[taskKey].command, state.taskList[taskKey].uuid, state.taskList[taskKey].extend)
)
window.eventSource.onmessage = function (e) {
const data = JSON.parse(e.data)
if (!data || !data.data) {
return
}
const taskIdx = findTaskIdxFromUuid(data.uuid)
if (taskIdx === false) {
return
}
if (data.data == 'command-exec-error') {
setTaskStatus(taskIdx, taskStatus.Failed)
window.eventSource.close()
taskCompleted(taskIdx)
startTask()
} else if (data.data == 'command-exec-completed') {
window.eventSource.close()
if (state.taskList[taskIdx].status != taskStatus.Success) {
setTaskStatus(taskIdx, taskStatus.Failed)
}
taskCompleted(taskIdx)
startTask()
} else if (data.data == 'command-link-success') {
setTaskStatus(taskIdx, taskStatus.Executing)
} else if (data.data == 'command-exec-success') {
setTaskStatus(taskIdx, taskStatus.Success)
} else {
addTaskMessage(taskIdx, data.data)
}
}
window.eventSource.onerror = function () {
window.eventSource.close()
const taskIdx = findTaskIdxFromGuess(taskKey)
if (taskIdx === false) return
setTaskStatus(taskIdx, taskStatus.Failed)
taskCompleted(taskIdx)
}
}
function retryTask(idx: number) {
state.taskList[idx].message = []
setTaskStatus(idx, taskStatus.Waiting)
startTask()
}
function clearSuccessTask() {
state.taskList = state.taskList.filter((item) => item.status != taskStatus.Success)
}
function findTaskIdxFromUuid(uuid: string) {
for (const key in state.taskList) {
if (state.taskList[key].uuid == uuid) {
return parseInt(key)
}
}
return false
}
function findTaskIdxFromGuess(idx: number) {
if (!state.taskList[idx]) {
let taskKey = -1
for (const key in state.taskList) {
if (state.taskList[key].status == taskStatus.Connecting || state.taskList[key].status == taskStatus.Executing) {
taskKey = parseInt(key)
}
}
return taskKey === -1 ? false : taskKey
} else {
return idx
}
}
function execMessageScrollbarKeep(uuid: string) {
const execMessageEl = document.querySelector('.exec-message-' + uuid) as Element
if (execMessageEl && execMessageEl.scrollHeight) {
execMessageEl.scrollTop = execMessageEl.scrollHeight
}
}
return {
state,
init,
toggle,
toggleDot,
setTaskStatus,
setTaskShowMessage,
addTaskMessage,
addTask,
addTaskPM,
delTask,
startTask,
retryTask,
clearSuccessTask,
toggleConfigDialog,
changeRegistry,
changePackageManager,
changePHPDevelopmentServer,
changeAutomaticCleanupTask,
}
},
{
persist: {
key: STORE_TERMINAL,
pick: ['state.showDot', 'state.taskList', 'state.automaticCleanupTask', 'state.npmRegistry', 'state.composerRegistry'],
},
}
)

View File

@@ -12,8 +12,29 @@ import { SYSTEM_ZINDEX } from '/@/stores/constant/common'
import { useUserInfo } from '/@/stores/userInfo'
import { isAdminApp } from '/@/utils/common'
/** 与后台 LoadLangPack 一致:优先与当前 i18n 语言对齐,再回落到 config */
function resolveAdminThinkLang(): string {
const cfg = useConfig()
try {
const loc = i18n?.global?.locale?.value
if (loc !== undefined && loc !== null && loc !== '') {
const v = String(loc).toLowerCase().replace('_', '-')
if (v === 'zh-cn' || v === 'zh') {
return 'zh-cn'
}
if (v === 'en') {
return 'en'
}
}
} catch {
// i18n 未就绪时忽略
}
return cfg.lang.defaultLang
}
window.requests = []
window.tokenRefreshing = false
window.authRedirecting = false
const pendingMap = new Map()
const loadingInstance: LoadingInstance = {
target: null,
@@ -50,7 +71,7 @@ function createAxios<Data = any, T = ApiPromise<Data>>(axiosConfig: AxiosRequest
baseURL: getUrl(),
timeout: 1000 * 10,
headers: {
'think-lang': config.lang.defaultLang,
'think-lang': resolveAdminThinkLang(),
},
responseType: 'json',
})
@@ -93,7 +114,7 @@ function createAxios<Data = any, T = ApiPromise<Data>>(axiosConfig: AxiosRequest
if (token) (config.headers as anyObj).batoken = token
const userToken = options.anotherToken || userInfo.getToken()
if (userToken) (config.headers as anyObj)['ba-user-token'] = userToken
;(config.headers as anyObj)['think-lang'] = useConfig().lang.defaultLang
;(config.headers as anyObj)['think-lang'] = resolveAdminThinkLang()
}
return config
@@ -183,10 +204,12 @@ function createAxios<Data = any, T = ApiPromise<Data>>(axiosConfig: AxiosRequest
}
if (response.data.code == 303) {
const isAdminAppFlag = isAdminApp()
const loginRouteName = isAdminAppFlag ? 'adminLogin' : 'userLogin'
const dataType = response.data.data?.type
let routerPath = isAdminAppFlag ? adminBaseRoute.path : memberCenterBaseRoutePath
// 需要登录,清理 token转到登录页
if (response.data.data.type == 'need login') {
if (dataType == 'need login') {
if (isAdminAppFlag) {
adminInfo.removeToken()
} else {
@@ -194,8 +217,13 @@ function createAxios<Data = any, T = ApiPromise<Data>>(axiosConfig: AxiosRequest
}
routerPath += '/login'
}
const alreadyOnLogin = router.currentRoute.value.name === loginRouteName
const targetIsLogin = routerPath.endsWith('/login')
if (!alreadyOnLogin || (!targetIsLogin && dataType === 'logged in')) {
router.push({ path: routerPath })
}
}
// code不等于1, 页面then内的具体逻辑就不执行了
return Promise.reject(response.data)
} else if (options.showSuccessMessage && response.data && response.data.code == 1) {
@@ -225,20 +253,120 @@ export default createAxios
* 处理异常
* @param {*} error
*/
/** 解析 axios 错误响应体(可能已是对象或 JSON 字符串) */
function normalizeAxiosErrorBody(data: unknown): anyObj | null {
if (data === undefined || data === null) {
return null
}
if (typeof data === 'string') {
try {
return JSON.parse(data) as anyObj
} catch {
return null
}
}
if (typeof data === 'object') {
return data as anyObj
}
return null
}
/**
* 后端未登录时通过 HTTP 303 + body.data.type === 'need login' 提示登录(见 Auth::LOGIN_RESPONSE_CODE
* Axios 将 303 视为失败,不会进入成功拦截器里对 code==303 的分支,需在此与之一致:清 token 并跳登录页。
*/
function redirectToLoginIfNeedLoginFromHttp303(error: any): boolean {
if (!error?.response || error.response.status !== 303) {
return false
}
const body = normalizeAxiosErrorBody(error.response.data)
if (!body || typeof body.data !== 'object' || body.data === null) {
return false
}
const needType = (body.data as anyObj).type
if (needType !== 'need login') {
return false
}
if (window.authRedirecting) {
return true
}
const isAdminAppFlag = isAdminApp()
const loginRouteName = isAdminAppFlag ? 'adminLogin' : 'userLogin'
const adminInfo = useAdminInfo()
const userInfo = useUserInfo()
if (router.currentRoute.value.name === loginRouteName) {
// 已在登录页:仅吞掉 303勿清 token避免 login GET 与 login POST 并发时清掉刚登录成功的 token
return true
}
window.authRedirecting = true
if (isAdminAppFlag) {
adminInfo.removeToken()
router.replace({ name: loginRouteName }).finally(() => {
window.authRedirecting = false
})
} else {
userInfo.removeToken()
const to = router.currentRoute.value.fullPath
router.replace({ name: loginRouteName, query: to ? { to } : {} }).finally(() => {
window.authRedirecting = false
})
}
return true
}
function httpErrorStatusHandle(error: any) {
// 处理被取消的请求
if (axios.isCancel(error)) return console.error(i18n.global.t('axios.Automatic cancellation due to duplicate request:') + error.message)
if (redirectToLoginIfNeedLoginFromHttp303(error)) {
return
}
let message = ''
if (error && error.response) {
switch (error.response.status) {
case 302:
message = i18n.global.t('axios.Interface redirected!')
break
case 303: {
const body303 = normalizeAxiosErrorBody(error.response.data)
const serverMsg303 = body303 && typeof body303.msg === 'string' ? body303.msg : ''
message = serverMsg303 !== '' ? serverMsg303 : i18n.global.t('axios.Interface redirected!')
break
}
case 400:
message = i18n.global.t('axios.Incorrect parameter!')
break
case 401:
message = i18n.global.t('axios.You do not have permission to operate!')
{
const data = error.response.data
const serverMsg = data && typeof data.msg === 'string' ? data.msg : ''
if (serverMsg) {
message = serverMsg
}
const isAdminAppFlag = isAdminApp()
const loginRouteName = isAdminAppFlag ? 'adminLogin' : 'userLogin'
if (!window.authRedirecting && router.currentRoute.value.name !== loginRouteName) {
window.authRedirecting = true
const adminInfo = useAdminInfo()
const userInfo = useUserInfo()
if (isAdminAppFlag) {
adminInfo.removeToken()
router.replace({ name: loginRouteName }).finally(() => {
window.authRedirecting = false
})
} else {
userInfo.removeToken()
const to = router.currentRoute.value.fullPath
router
.replace({ name: loginRouteName, query: to ? { to } : {} })
.finally(() => {
window.authRedirecting = false
})
}
}
}
break
case 403:
message = i18n.global.t('axios.You do not have permission to operate!')
@@ -251,6 +379,49 @@ function httpErrorStatusHandle(error: any) {
break
case 409:
message = i18n.global.t('axios.The same data already exists in the system!')
{
const data = error.response.data
const serverMsg = data && typeof data.msg === 'string' ? data.msg : ''
const authExpiredHints = [
'Token expiration',
'token expiration',
'登录态过期',
'请重新登录',
'Session expired',
'session expired',
'please login again',
'sila log masuk semula',
]
const looksLikeAuthExpired =
serverMsg !== '' && authExpiredHints.some((hint) => serverMsg.toLowerCase().includes(hint.toLowerCase()))
if (looksLikeAuthExpired) {
message = serverMsg
const isAdminAppFlag = isAdminApp()
const loginRouteName = isAdminAppFlag ? 'adminLogin' : 'userLogin'
if (!window.authRedirecting && router.currentRoute.value.name !== loginRouteName) {
window.authRedirecting = true
const adminInfo = useAdminInfo()
const userInfo = useUserInfo()
if (isAdminAppFlag) {
adminInfo.removeToken()
router.replace({ name: loginRouteName }).finally(() => {
window.authRedirecting = false
})
} else {
userInfo.removeToken()
const to = router.currentRoute.value.fullPath
router
.replace({ name: loginRouteName, query: to ? { to } : {} })
.finally(() => {
window.authRedirecting = false
})
}
}
}
}
break
case 500:
message = i18n.global.t('axios.Server internal error!')

View File

@@ -287,7 +287,25 @@ export const checkFileMimetype = (fileName: string, fileType: string) => {
? siteConfig.upload.allowedMimeTypes
: siteConfig.upload.allowedMimeTypes.toLowerCase().split(',')
const forbiddenSuffixes = siteConfig.upload.forbiddenSuffixes
? isArray(siteConfig.upload.forbiddenSuffixes)
? siteConfig.upload.forbiddenSuffixes
: siteConfig.upload.forbiddenSuffixes.toLowerCase().split(',')
: []
const nameParts = fileName.toLowerCase().split('.')
if (nameParts.length > 1) {
for (let i = 0; i < nameParts.length - 1; i++) {
if (forbiddenSuffixes.includes(nameParts[i])) {
return false
}
}
}
const fileSuffix = fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase()
if (forbiddenSuffixes.includes(fileSuffix)) {
return false
}
if (allowedSuffixes.includes(fileSuffix) || allowedSuffixes.includes('.' + fileSuffix)) {
return true
}

View File

@@ -0,0 +1,39 @@
import { adminBaseRoutePath } from '/@/router/static/adminBase'
function shouldStripQuestionBeforeHash(): boolean {
const { search, hash } = window.location
if (!hash.startsWith('#/')) {
return false
}
if (search === '?') {
return true
}
return window.location.href.includes('/?#/')
}
/** 将 `/?#/path` 规范为 `/#/path` */
export function ensureStandardHashUrl(): void {
if (typeof window === 'undefined') {
return
}
const { pathname } = window.location
const onAppRoot = pathname === '/' || pathname === ''
if (!onAppRoot || !shouldStripQuestionBeforeHash()) {
return
}
const hash = window.location.hash
window.history.replaceState(window.history.state, '', pathname + hash)
}
/** 根路径无 hash 时跳转到后台登录(`/#/admin/login` */
export function redirectRootToAdminLoginIfNoHash(): void {
if (typeof window === 'undefined') {
return
}
const { pathname, hash } = window.location
const onAppRoot = pathname === '/' || pathname === ''
if (!onAppRoot || hash) {
return
}
window.location.replace(pathname + '#' + adminBaseRoutePath + '/login')
}

View File

@@ -26,9 +26,11 @@ export const loading = {
hide: () => {
nextTick(() => {
setTimeout(() => {
const el = document.querySelector('.block-loading')
el && el.parentNode?.removeChild(el)
}, 1000)
document.querySelectorAll('.block-loading').forEach((el) => {
el.parentNode?.removeChild(el)
})
window.existLoading = false
}, 100)
})
},
}

View File

@@ -9,7 +9,6 @@
/>
<!-- 表格 -->
<!-- 要使用`el-table`组件原有的属性直接加在Table标签上即可 -->
<Table />
<!-- 表单 -->
@@ -25,7 +24,9 @@ import Table from '/@/components/table/index.vue'
import TableHeader from '/@/components/table/header/index.vue'
import { defaultOptButtons } from '/@/components/table'
import { baTableApi } from '/@/api/common'
import { resetTotp } from '/@/api/backend/auth/admin'
import { useAdminInfo } from '/@/stores/adminInfo'
import { useSiteConfig } from '/@/stores/siteConfig'
import { useI18n } from 'vue-i18n'
defineOptions({
@@ -34,11 +35,68 @@ defineOptions({
const { t } = useI18n()
const adminInfo = useAdminInfo()
const siteConfig = useSiteConfig()
const optButtons = defaultOptButtons(['edit', 'delete'])
optButtons[1].display = (row) => {
return row.id != adminInfo.id
/** 超管或拥有 resetTotp 权限,且不能操作自己 */
const canManageTotp = (row: TableRow) => {
if (!siteConfig.totpEnable) {
return false
}
if (row.id == adminInfo.id) {
return false
}
return adminInfo.super
}
const isTotpBound = (row: TableRow) => {
return row.totp_bound === true || row.totp_bound === 1 || row.totp_bound === '1'
}
const optButtons: OptButton[] = [
{
render: 'confirmButton',
name: 'resetTotp',
title: 'auth.admin.Reset authenticator',
text: '',
type: 'warning',
icon: 'fa fa-refresh',
class: 'table-row-reset-totp',
popconfirm: {
confirmButtonText: t('Confirm'),
cancelButtonText: t('Cancel'),
confirmButtonType: 'warning',
title: t('auth.admin.Reset authenticator confirm'),
},
disabledTip: false,
display: (row: TableRow) => canManageTotp(row) && isTotpBound(row),
click: (row: TableRow) => {
resetTotp(row.id).then(() => {
baTable.onTableHeaderAction('refresh', {})
})
},
},
{
render: 'tipButton',
name: 'bindTip',
title: 'auth.admin.Pending bind tip',
text: '',
type: 'info',
icon: 'fa fa-shield',
class: 'table-row-bind-tip',
disabledTip: false,
display: (row: TableRow) => canManageTotp(row) && !isTotpBound(row),
disabled: () => true,
},
...defaultOptButtons(['edit', 'delete']).map((btn) => {
if (btn.name === 'delete') {
return {
...btn,
display: (row: TableRow) => row.id != adminInfo.id,
}
}
return btn
}),
]
const baTable = new baTableClass(
new baTableApi('/admin/auth.Admin/'),
@@ -52,6 +110,17 @@ const baTable = new baTableClass(
{ label: t('auth.admin.avatar'), prop: 'avatar', align: 'center', render: 'image', operator: false },
{ label: t('auth.admin.email'), prop: 'email', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{ label: t('auth.admin.mobile'), prop: 'mobile', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{
label: t('auth.admin.Google Authenticator'),
prop: 'totp_bound',
align: 'center',
render: 'tag',
custom: { true: 'success', false: 'info' },
replaceValue: { true: t('auth.admin.Bound'), false: t('auth.admin.Not bound') },
operator: false,
width: 120,
show: siteConfig.totpEnable,
},
{
label: t('auth.admin.Last login'),
prop: 'last_login_time',
@@ -61,13 +130,6 @@ const baTable = new baTableClass(
operator: 'RANGE',
width: 160,
},
{
label: t('auth.admin.agent_id'),
prop: 'agent_id',
align: 'center',
width: '160',
showOverflowTooltip: true,
},
{ label: t('Create time'), prop: 'create_time', align: 'center', render: 'datetime', sortable: 'custom', operator: 'RANGE', width: 160 },
{
label: t('State'),
@@ -80,7 +142,7 @@ const baTable = new baTableClass(
{
label: t('Operate'),
align: 'center',
width: '100',
width: '170',
render: 'buttons',
buttons: optButtons,
operator: false,

View File

@@ -691,7 +691,6 @@ import { getDatabaseConnectionListUrl, getTableFieldList, getTableListUrl } from
import BaInput from '/@/components/baInput/index.vue'
import FormItem from '/@/components/formItem/index.vue'
import { useConfig } from '/@/stores/config'
import { useTerminal } from '/@/stores/terminal'
import { getArrayKey } from '/@/utils/common'
import { uuid } from '/@/utils/random'
import { buildValidatorData, regularVarName } from '/@/utils/validate'
@@ -702,7 +701,6 @@ import { changeStep, state as crudState, designTypes, fieldItem, getTableAttr, t
let nameRepeatCount = 1
const { t } = useI18n()
const config = useConfig()
const terminal = useTerminal()
const formRef = useTemplateRef('formRef')
const tabsRefs = useTemplateRefsList<HTMLElement>()
const designWindowRef = useTemplateRef('designWindowRef')
@@ -1161,13 +1159,7 @@ const startGenerate = () => {
})
.then((res) => {
const callback = () => {
const webViewsDir = state.table.webViewsDir.replace(/^web/, '.')
terminal.toggle(true)
terminal.addTask('npx.prettier', false, webViewsDir, () => {
terminal.toggle(false)
terminal.toggleDot(true)
nextTick(() => {
// 要求 Vite 服务端重启
if (import.meta.hot) {
reloadServer('crud')
} else {
@@ -1177,7 +1169,6 @@ const startGenerate = () => {
})
}
})
})
}
if ((state.sync > 0 && config.crud.syncedUpdate === 'yes') || (state.sync == 0 && config.crud.syncType == 'automatic')) {

View File

@@ -23,7 +23,15 @@
<div class="form">
<img class="profile-avatar" :src="fullUrl('/static/images/avatar.png')" alt="" />
<div class="content">
<el-form @keyup.enter="onSubmitPre()" ref="formRef" :rules="rules" size="large" :model="form">
<!-- 账号密码登录 -->
<el-form
v-if="state.step === 'login'"
@keyup.enter="onSubmitPre()"
ref="formRef"
:rules="rules"
size="large"
:model="form"
>
<el-form-item prop="username">
<el-input
ref="usernameRef"
@@ -64,6 +72,84 @@
</el-button>
</el-form-item>
</el-form>
<!-- 谷歌验证器验证码 -->
<el-form
v-else-if="state.step === 'totp'"
@keyup.enter="onTotpVerify()"
ref="totpFormRef"
:rules="totpRules"
size="large"
:model="totpForm"
>
<div class="step-title">{{ t('login.Google Authenticator code') }}</div>
<div class="step-desc">{{ state.username }}</div>
<el-form-item prop="code">
<el-input
v-model="totpForm.code"
maxlength="6"
:placeholder="t('login.Please enter the 6-digit code')"
clearable
>
<template #prefix>
<Icon name="fa fa-shield" class="form-item-icon" size="16" color="var(--el-input-icon-color)" />
</template>
</el-input>
</el-form-item>
<el-form-item>
<el-button
:loading="state.submitLoading"
class="submit-button"
round
type="primary"
size="large"
@click="onTotpVerify()"
>
{{ state.submitLoading ? t('login.Verifying...') : t('login.Verify and sign in') }}
</el-button>
</el-form-item>
<el-button class="back-button" link type="primary" @click="resetToLogin">{{ t('login.Back to login') }}</el-button>
</el-form>
<!-- 绑定谷歌验证器 -->
<div v-else-if="state.step === 'bind'" class="bind-panel">
<div class="step-title">{{ t('login.Bind Google Authenticator') }}</div>
<div class="step-desc">{{ t('login.Scan QR code with Google Authenticator') }}</div>
<div v-loading="state.bindLoading" class="qr-wrap">
<img v-if="state.qrCode" :src="state.qrCode" alt="QR Code" class="qr-image" />
</div>
<div v-if="state.secret" class="secret-box">
<span class="secret-label">{{ t('login.Or enter secret manually') }}:</span>
<span class="secret-value">{{ state.secret }}</span>
</div>
<el-form @keyup.enter="onBindConfirm()" ref="bindFormRef" :rules="totpRules" size="large" :model="totpForm">
<el-form-item prop="code">
<el-input
v-model="totpForm.code"
maxlength="6"
:placeholder="t('login.Please enter the 6-digit code')"
clearable
>
<template #prefix>
<Icon name="fa fa-shield" class="form-item-icon" size="16" color="var(--el-input-icon-color)" />
</template>
</el-input>
</el-form-item>
<el-form-item>
<el-button
:loading="state.submitLoading"
class="submit-button"
round
type="primary"
size="large"
@click="onBindConfirm()"
>
{{ state.submitLoading ? t('login.Binding...') : t('login.Confirm bind') }}
</el-button>
</el-form-item>
</el-form>
<el-button class="back-button" link type="primary" @click="resetToLogin">{{ t('login.Back to login') }}</el-button>
</div>
</div>
</div>
</div>
@@ -78,7 +164,7 @@ import { useI18n } from 'vue-i18n'
import { editDefaultLang } from '/@/lang/index'
import { useConfig } from '/@/stores/config'
import { useAdminInfo } from '/@/stores/adminInfo'
import { login } from '/@/api/backend'
import { login, totpVerify, totpBindInit, totpBindConfirm } from '/@/api/backend'
import { uuid } from '/@/utils/random'
import { buildValidatorData } from '/@/utils/validate'
import router from '/@/router'
@@ -86,20 +172,111 @@ import clickCaptcha from '/@/components/clickCaptcha'
import toggleDark from '/@/utils/useDark'
import { fullUrl } from '/@/utils/common'
import { adminBaseRoutePath } from '/@/router/static/adminBase'
import type { FormItemRule } from 'element-plus'
import { ElMessage } from 'element-plus'
let timer: number
let loginPrefetchController: AbortController | null = null
const abortLoginPrefetch = () => {
if (loginPrefetchController) {
loginPrefetchController.abort()
loginPrefetchController = null
}
}
const unwrapLoginApiData = (res: anyObj | undefined): anyObj => {
if (!res || typeof res !== 'object') {
return {}
}
if (res.code !== undefined && res.data !== undefined && typeof res.data === 'object') {
return res.data as anyObj
}
return res as anyObj
}
/** 从 login / totpVerify / totpBindConfirm 响应中解析 userInfo */
const extractLoginUserInfo = (res: anyObj | undefined): anyObj | undefined => {
const data = unwrapLoginApiData(res)
if (data.userInfo && typeof data.userInfo === 'object' && data.userInfo.token) {
return data.userInfo as anyObj
}
if (data.token) {
return data as anyObj
}
return undefined
}
const finishLoginWithApiResponse = (res: anyObj | undefined) => {
const userInfo = extractLoginUserInfo(res)
if (userInfo) {
navigateAfterLogin(userInfo)
return true
}
handleLoginApiResult(res)
return false
}
const handleLoginApiResult = (res: anyObj | undefined) => {
if (!res || typeof res !== 'object') {
return
}
const code = res.code
const data = unwrapLoginApiData(res)
const msg = typeof res.msg === 'string' ? res.msg : ''
if (code === 303 && data.type === 'logged in') {
router.replace({ path: adminBaseRoutePath })
return
}
if (data.userInfo?.token) {
navigateAfterLogin(data.userInfo)
return
}
const tempToken = data.tempToken || data.temp_token
if (data.type === 'need_totp' && tempToken) {
state.step = 'totp'
state.tempToken = tempToken
state.username = data.username || form.username
totpForm.code = ''
if (msg) {
ElMessage.info(msg)
}
return
}
if (data.type === 'need_bind_totp' && tempToken) {
state.step = 'bind'
state.tempToken = tempToken
state.username = data.username || form.username
totpForm.code = ''
if (msg) {
ElMessage.info(msg)
}
loadBindInfo()
}
}
const config = useConfig()
const adminInfo = useAdminInfo()
toggleDark(config.layout.isDark)
const formRef = useTemplateRef('formRef')
const totpFormRef = useTemplateRef('totpFormRef')
const bindFormRef = useTemplateRef('bindFormRef')
const usernameRef = useTemplateRef('usernameRef')
const passwordRef = useTemplateRef('passwordRef')
const state = reactive({
showCaptcha: false,
submitLoading: false,
bindLoading: false,
step: 'login' as 'login' | 'totp' | 'bind',
tempToken: '',
username: '',
secret: '',
qrCode: '',
})
const form = reactive({
username: '',
password: '',
@@ -108,14 +285,33 @@ const form = reactive({
captchaInfo: '',
})
const totpForm = reactive({
code: '',
})
const { t } = useI18n()
// 表单验证规则
const rules = reactive({
username: [buildValidatorData({ name: 'required', message: t('login.Please enter an account') }), buildValidatorData({ name: 'account' })],
password: [buildValidatorData({ name: 'required', message: t('login.Please input a password') }), buildValidatorData({ name: 'password' })],
})
const totpRules: Partial<Record<string, FormItemRule[]>> = reactive({
code: [
buildValidatorData({ name: 'required', message: t('login.Please enter the 6-digit code') }),
{
validator: (_rule, val, callback) => {
if (!/^\d{6}$/.test(val)) {
callback(new Error(t('login.Please enter the 6-digit code')))
} else {
callback()
}
},
trigger: 'blur',
},
],
})
const focusInput = () => {
if (form.username === '') {
usernameRef.value?.focus()
@@ -124,22 +320,53 @@ const focusInput = () => {
}
}
const resetToLogin = () => {
state.step = 'login'
state.tempToken = ''
state.secret = ''
state.qrCode = ''
state.username = ''
totpForm.code = ''
nextTick(() => focusInput())
}
const navigateAfterLogin = (userInfo: anyObj) => {
if (!userInfo?.token) {
ElMessage.error(t('login.Login failed, please try again'))
return
}
abortLoginPrefetch()
adminInfo.setToken(userInfo.token, 'auth')
if (userInfo.refresh_token) {
adminInfo.setToken(userInfo.refresh_token, 'refresh')
}
adminInfo.dataFill(userInfo, ['token', 'refresh_token'])
router.replace({ path: adminBaseRoutePath })
}
onMounted(() => {
timer = window.setTimeout(() => {
pageBubble.init()
}, 1000)
login('get')
abortLoginPrefetch()
loginPrefetchController = new AbortController()
login('get', {}, { signal: loginPrefetchController.signal })
.then((res) => {
state.showCaptcha = res.data.captcha
const data = unwrapLoginApiData(res)
state.showCaptcha = Boolean(data.captcha)
nextTick(() => focusInput())
})
.catch((err) => {
if (err?.code === 'ERR_CANCELED' || err?.message === 'canceled') {
return
}
console.log(err)
})
})
onBeforeUnmount(() => {
abortLoginPrefetch()
clearTimeout(timer)
pageBubble.removeListeners()
})
@@ -157,26 +384,96 @@ const onSubmitPre = () => {
}
const onSubmit = (captchaInfo = '') => {
abortLoginPrefetch()
state.submitLoading = true
form.captchaInfo = captchaInfo
login('post', form)
.then((res) => {
const userInfo = res?.data?.userInfo
if (!userInfo?.token) {
finishLoginWithApiResponse(res)
})
.catch((err) => {
if (err?.code === 'ERR_CANCELED' || err?.message === 'canceled') {
return
}
adminInfo.dataFill(userInfo, false)
nextTick(() => {
router.push({ path: adminBaseRoutePath })
})
})
.catch(() => {
// 接口错误由 axios 拦截器处理
if (err && typeof err === 'object' && 'code' in err) {
handleLoginApiResult(err as anyObj)
return
}
console.log(err)
})
.finally(() => {
state.submitLoading = false
})
}
const loadBindInfo = () => {
state.bindLoading = true
totpBindInit({ tempToken: state.tempToken })
.then((res) => {
state.secret = res.data.secret
state.qrCode = res.data.qrCode
})
.finally(() => {
state.bindLoading = false
})
}
const onTotpVerify = () => {
totpFormRef.value?.validate((valid) => {
if (!valid) return
state.submitLoading = true
totpVerify({
tempToken: state.tempToken,
code: totpForm.code,
keep: form.keep,
})
.then((res) => {
if (!finishLoginWithApiResponse(res)) {
const code = res?.code
if (code === 1) {
ElMessage.error(t('login.Login failed, please try again'))
}
}
})
.catch((err) => {
if (err && typeof err === 'object' && 'msg' in err && err.msg) {
ElMessage.error(String(err.msg))
}
})
.finally(() => {
state.submitLoading = false
})
})
}
const onBindConfirm = () => {
bindFormRef.value?.validate((valid) => {
if (!valid) return
state.submitLoading = true
totpBindConfirm({
tempToken: state.tempToken,
secret: state.secret,
code: totpForm.code,
keep: form.keep,
})
.then((res) => {
if (!finishLoginWithApiResponse(res)) {
const code = res?.code
if (code === 1) {
ElMessage.error(t('login.Login failed, please try again'))
}
}
})
.catch((err) => {
if (err && typeof err === 'object' && 'msg' in err && err.msg) {
ElMessage.error(String(err.msg))
}
})
.finally(() => {
state.submitLoading = false
})
})
}
</script>
<style scoped lang="scss">
@@ -193,6 +490,57 @@ const onSubmit = (captchaInfo = '') => {
.form-item-icon {
height: auto;
}
.step-title {
font-size: 18px;
font-weight: 500;
text-align: center;
margin-bottom: 8px;
color: var(--el-text-color-primary);
}
.step-desc {
font-size: 13px;
text-align: center;
margin-bottom: 20px;
color: var(--el-text-color-secondary);
word-break: break-all;
}
.bind-panel {
.qr-wrap {
display: flex;
justify-content: center;
margin-bottom: 16px;
min-height: 200px;
}
.qr-image {
width: 200px;
height: 200px;
border: 1px solid var(--el-border-color);
border-radius: 8px;
background: #fff;
}
.secret-box {
font-size: 12px;
text-align: center;
margin-bottom: 16px;
padding: 8px;
background: var(--el-fill-color-light);
border-radius: 6px;
word-break: break-all;
.secret-label {
display: block;
color: var(--el-text-color-secondary);
margin-bottom: 4px;
}
.secret-value {
font-family: monospace;
color: var(--el-text-color-primary);
}
}
}
.back-button {
display: block;
margin: 0 auto;
}
.login {
position: absolute;
top: 0;
@@ -263,7 +611,6 @@ const onSubmit = (captchaInfo = '') => {
align-items: center;
}
// 暗黑样式
@at-root .dark {
.bubble {
background: url(/@/assets/bg-dark.jpg) repeat;

View File

@@ -1,5 +1,6 @@
<template>
<div class="default-main ba-table-box">
<MallPageIntro page-key="address" />
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
@@ -25,6 +26,7 @@ import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table'
import MallPageIntro from '/@/components/mall/MallPageIntro.vue'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
@@ -37,17 +39,6 @@ const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
const hasChinese = (s: string) => /[\u4e00-\u9fa5]/.test(s)
const formatRegion = (raw: string) => {
const s = raw.toString().trim()
if (!s) return ''
if (hasChinese(s)) {
return s.replace(/[,\s]+/g, '')
}
return s.replace(/[,\s]+/g, ',')
}
/**
* baTable 内包含了表格的所有数据且数据具备响应性,然后通过 provide 注入给了后代组件
*/
@@ -85,24 +76,15 @@ const baTable = new baTableClass(
sortable: false,
operator: 'LIKE',
},
{
label: t('mall.address.region'),
prop: 'region_text',
align: 'center',
operator: false,
showOverflowTooltip: true,
formatter: (row: TableRow, _column: TableColumn, cellValue: string) => {
const raw = (cellValue || row.region || '').toString()
return formatRegion(raw)
},
},
{
label: t('mall.address.detail_address'),
prop: 'detail_address',
align: 'center',
minWidth: 200,
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
showOverflowTooltip: true,
},
{
label: t('mall.address.default_setting'),

View File

@@ -51,16 +51,9 @@
prop="phone"
:placeholder="t('Please input field', { field: t('mall.address.phone') })"
/>
<FormItem
:label="t('mall.address.region')"
type="city"
v-model="baTable.form.items!.region"
prop="region"
:placeholder="t('Please select field', { field: t('mall.address.region') })"
/>
<FormItem
:label="t('mall.address.detail_address')"
type="string"
type="textarea"
v-model="baTable.form.items!.detail_address"
prop="detail_address"
:placeholder="t('Please input field', { field: t('mall.address.detail_address') })"
@@ -103,6 +96,8 @@ const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
phone: [buildValidatorData({ name: 'required', title: t('mall.address.phone') })],
receiver_name: [buildValidatorData({ name: 'required', title: t('mall.address.receiver_name') })],
detail_address: [buildValidatorData({ name: 'required', title: t('mall.address.detail_address') })],
create_time: [buildValidatorData({ name: 'date', title: t('mall.address.create_time') })],
update_time: [buildValidatorData({ name: 'date', title: t('mall.address.update_time') })],
})

View File

@@ -1,5 +1,6 @@
<template>
<div class="default-main ba-table-box">
<MallPageIntro page-key="claimLog" />
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<TableHeader
@@ -15,6 +16,7 @@
import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { baTableApi } from '/@/api/common'
import MallPageIntro from '/@/components/mall/MallPageIntro.vue'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'

View File

@@ -0,0 +1,268 @@
<template>
<el-dialog
v-model="visible"
class="ba-operate-dialog"
:close-on-click-modal="false"
width="560px"
@open="onOpen"
>
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ t('mall.dailyPush.export_title') }}
</div>
</template>
<el-alert type="info" :closable="false" class="export-tip">
{{ t('mall.dailyPush.export_tip') }}
</el-alert>
<div class="export-section">
<div class="section-title">{{ t('mall.dailyPush.export_fields') }}</div>
<div class="field-actions">
<el-button link type="primary" @click="selectAllFields">{{ t('mall.dailyPush.export_select_all') }}</el-button>
<el-button link type="primary" @click="clearFields">{{ t('mall.dailyPush.export_clear_all') }}</el-button>
</div>
<el-checkbox-group v-model="form.fields" class="field-group">
<el-checkbox v-for="item in fieldOptions" :key="item.value" :label="item.value" :value="item.value">
{{ item.label }}
</el-checkbox>
</el-checkbox-group>
</div>
<div class="export-section">
<div class="section-title">{{ t('mall.dailyPush.export_limit') }}</div>
<div class="limit-row">
<el-input-number v-model="form.exportLimit" :min="0" :max="matchedCount || undefined" :step="1000" controls-position="right" />
<div class="limit-presets">
<el-button
v-for="preset in limitPresets"
:key="preset"
size="small"
:type="form.exportLimit === preset ? 'primary' : 'default'"
@click="form.exportLimit = preset"
>
{{ preset }}
</el-button>
<el-button size="small" :type="form.exportLimit === 0 ? 'primary' : 'default'" @click="useMatchedCount">
{{ t('mall.dailyPush.export_all_matched') }}
</el-button>
</div>
</div>
<div class="count-info" v-loading="countLoading">
{{ t('mall.dailyPush.export_matched_count', { count: matchedCount }) }}
<span v-if="actualExportCount > 0">
{{ t('mall.dailyPush.export_actual_count', { count: actualExportCount }) }}
</span>
</div>
<el-alert v-if="actualExportCount > 10000" type="warning" :closable="false" class="large-export-warning">
{{ t('mall.dailyPush.export_large_warning') }}
</el-alert>
</div>
<template #footer>
<el-button @click="visible = false">{{ t('Cancel') }}</el-button>
<el-button type="primary" :loading="exporting" :disabled="form.fields.length === 0" @click="submitExport">
{{ t('mall.dailyPush.export_confirm') }}
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, inject, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElNotification } from 'element-plus'
import createAxios from '/@/utils/axios'
import type baTableClass from '/@/utils/baTable'
import { SYSTEM_ZINDEX } from '/@/stores/constant/common'
const { t } = useI18n()
const baTable = inject('baTable') as baTableClass
const visible = defineModel<boolean>({ default: false })
const exporting = ref(false)
const countLoading = ref(false)
const matchedCount = ref(0)
const limitPresets = [1000, 5000, 10000, 50000]
const fieldOptions = computed(() => [
{ value: 'id', label: t('mall.dailyPush.id') },
{ value: 'user_id', label: t('mall.dailyPush.user_id') },
{ value: 'date', label: t('mall.dailyPush.date') },
{ value: 'username', label: t('mall.dailyPush.username') },
{ value: 'yesterday_win_loss_net', label: t('mall.dailyPush.yesterday_win_loss_net') },
{ value: 'converted_points', label: t('mall.dailyPush.converted_points') },
{ value: 'yesterday_total_deposit', label: t('mall.dailyPush.yesterday_total_deposit') },
{ value: 'lifetime_total_deposit', label: t('mall.dailyPush.lifetime_total_deposit') },
{ value: 'lifetime_total_withdraw', label: t('mall.dailyPush.lifetime_total_withdraw') },
{ value: 'lifetime_win_loss_net', label: t('mall.dailyPush.lifetime_win_loss_net') },
{ value: 'create_time', label: t('mall.dailyPush.create_time') },
])
const form = reactive({
fields: fieldOptions.value.map((item) => item.value),
exportLimit: 0,
})
const actualExportCount = computed(() => {
if (form.exportLimit <= 0) {
return matchedCount.value
}
return Math.min(form.exportLimit, matchedCount.value)
})
const selectAllFields = () => {
form.fields = fieldOptions.value.map((item) => item.value)
}
const clearFields = () => {
form.fields = []
}
const useMatchedCount = () => {
form.exportLimit = 0
}
const buildExportParams = () => {
const filter = baTable.table.filter || {}
return {
...filter,
fields: form.fields,
export_limit: form.exportLimit <= 0 ? 0 : form.exportLimit,
}
}
const loadMatchedCount = async () => {
countLoading.value = true
try {
const res = await createAxios<{ count: number; max_limit: number }>({
url: '/admin/mall.DailyPush/exportCount',
method: 'get',
params: baTable.table.filter || {},
})
matchedCount.value = res.data.count
} catch {
matchedCount.value = baTable.table.total || 0
} finally {
countLoading.value = false
}
}
const parseBlobError = async (blob: Blob): Promise<string> => {
try {
const text = await blob.text()
const json = JSON.parse(text)
if (json && typeof json.msg === 'string' && json.msg !== '') {
return json.msg
}
} catch {
// ignore
}
return t('mall.dailyPush.export_failed')
}
const downloadBlob = (blob: Blob, filename: string) => {
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
}
const submitExport = async () => {
if (form.fields.length === 0) {
return
}
exporting.value = true
try {
const response = await createAxios<Blob>(
{
url: '/admin/mall.DailyPush/export',
method: 'get',
params: buildExportParams(),
responseType: 'blob',
timeout: 0,
},
{
reductDataFormat: false,
showErrorMessage: false,
cancelDuplicateRequest: false,
}
)
const blob = response.data
const contentType = String(response.headers['content-type'] || '')
if (contentType.includes('application/json')) {
throw new Error(await parseBlobError(blob))
}
const disposition = String(response.headers['content-disposition'] || '')
const filenameMatch = disposition.match(/filename="?([^";]+)"?/i)
const filename = filenameMatch?.[1] || `daily_push_${Date.now()}.xlsx`
downloadBlob(blob, filename)
ElNotification({
type: 'success',
message: t('mall.dailyPush.export_success', { count: actualExportCount.value }),
zIndex: SYSTEM_ZINDEX,
})
visible.value = false
} catch (error) {
const message = error instanceof Error ? error.message : t('mall.dailyPush.export_failed')
ElNotification({
type: 'error',
message,
zIndex: SYSTEM_ZINDEX,
})
} finally {
exporting.value = false
}
}
const onOpen = () => {
loadMatchedCount()
}
</script>
<style scoped lang="scss">
.export-tip {
margin-bottom: 16px;
}
.export-section {
margin-bottom: 18px;
}
.section-title {
font-weight: 600;
margin-bottom: 8px;
}
.field-actions {
margin-bottom: 8px;
}
.field-group {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 12px;
}
.limit-row {
display: flex;
flex-direction: column;
gap: 10px;
}
.limit-presets {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.count-info {
margin-top: 10px;
color: var(--el-text-color-secondary);
font-size: 13px;
}
.large-export-warning {
margin-top: 12px;
}
</style>

View File

@@ -1,23 +1,36 @@
<template>
<div class="default-main ba-table-box">
<MallPageIntro page-key="dailyPush" />
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<TableHeader
:buttons="['refresh', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('mall.dailyPush.quick Search Fields') })"
></TableHeader>
>
<template #refreshAppend>
<el-tooltip v-if="baTable.auth('export')" :content="t('mall.dailyPush.export_excel')" placement="top">
<el-button v-blur class="table-header-operate btns-ml-12" type="success" @click="exportVisible = true">
<Icon name="fa fa-file-excel-o" />
<span class="table-header-operate-text">{{ t('mall.dailyPush.export_excel') }}</span>
</el-button>
</el-tooltip>
</template>
</TableHeader>
<Table ref="tableRef"></Table>
<ExportDialog v-model="exportVisible" />
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, useTemplateRef } from 'vue'
import { onMounted, provide, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { baTableApi } from '/@/api/common'
import MallPageIntro from '/@/components/mall/MallPageIntro.vue'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
import ExportDialog from './exportDialog.vue'
defineOptions({
name: 'mall/dailyPush',
@@ -25,6 +38,7 @@ defineOptions({
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const exportVisible = ref(false)
const baTable = new baTableClass(
new baTableApi('/admin/mall.DailyPush/'),
@@ -56,7 +70,9 @@ const baTable = new baTableClass(
label: t('mall.dailyPush.username'),
prop: 'username',
align: 'center',
minWidth: 95,
operatorPlaceholder: t('Fuzzy query'),
showOverflowTooltip: true,
sortable: false,
operator: 'LIKE',
},
@@ -64,30 +80,49 @@ const baTable = new baTableClass(
label: t('mall.dailyPush.yesterday_win_loss_net'),
prop: 'yesterday_win_loss_net',
align: 'center',
minWidth: 90,
operator: 'RANGE',
sortable: false,
sortable: 'custom',
},
{
label: t('mall.dailyPush.converted_points'),
prop: 'converted_points',
align: 'center',
minWidth: 80,
operator: 'RANGE',
sortable: 'custom',
},
{
label: t('mall.dailyPush.yesterday_total_deposit'),
prop: 'yesterday_total_deposit',
align: 'center',
minWidth: 90,
operator: 'RANGE',
sortable: false,
sortable: 'custom',
},
{
label: t('mall.dailyPush.lifetime_total_deposit'),
prop: 'lifetime_total_deposit',
align: 'center',
minWidth: 90,
operator: 'RANGE',
sortable: false,
sortable: 'custom',
},
{
label: t('mall.dailyPush.lifetime_total_withdraw'),
prop: 'lifetime_total_withdraw',
align: 'center',
minWidth: 95,
minWidth: 90,
operator: 'RANGE',
sortable: false,
sortable: 'custom',
},
{
label: t('mall.dailyPush.lifetime_win_loss_net'),
prop: 'lifetime_win_loss_net',
align: 'center',
minWidth: 90,
operator: 'RANGE',
sortable: 'custom',
},
{
label: t('mall.dailyPush.create_time'),

View File

@@ -1,5 +1,6 @@
<template>
<div class="default-main ba-table-box">
<MallPageIntro page-key="item" />
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
@@ -25,6 +26,7 @@ import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table'
import MallPageIntro from '/@/components/mall/MallPageIntro.vue'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
@@ -58,6 +60,46 @@ const baTable = new baTableClass(
sortable: false,
operator: 'LIKE',
},
{
label: t('mall.item.title_en'),
prop: 'title_en',
align: 'center',
minWidth: 90,
show: false,
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{
label: t('mall.item.description_en'),
prop: 'description_en',
align: 'center',
minWidth: 90,
show: false,
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{
label: t('mall.item.title_ms'),
prop: 'title_ms',
align: 'center',
minWidth: 90,
show: false,
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{
label: t('mall.item.description_ms'),
prop: 'description_ms',
align: 'center',
minWidth: 90,
show: false,
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{
label: t('mall.item.score'),
prop: 'score',
@@ -85,13 +127,6 @@ const baTable = new baTableClass(
sortable: false,
operator: 'RANGE',
},
{
label: t('mall.item.multiplier'),
prop: 'multiplier',
align: 'center',
sortable: false,
operator: 'eq',
},
{
label: t('mall.item.category'),
prop: 'category',
@@ -189,6 +224,10 @@ const baTable = new baTableClass(
defaultItems: {
stock: 0,
sort: 100,
title_en: '',
description_en: '',
title_ms: '',
description_ms: '',
},
}
)

View File

@@ -57,34 +57,72 @@
@keyup.ctrl.enter="baTable.onSubmit(formRef)"
:placeholder="t('Please input field', { field: t('mall.item.description') })"
/>
<FormItem
:label="t('mall.item.title_en')"
type="string"
v-model="baTable.form.items!.title_en"
prop="title_en"
:placeholder="t('Please input field', { field: t('mall.item.title_en') })"
/>
<FormItem
:label="t('mall.item.description_en')"
type="textarea"
v-model="baTable.form.items!.description_en"
prop="description_en"
:input-attr="{ rows: 3 }"
@keyup.enter.stop=""
@keyup.ctrl.enter="baTable.onSubmit(formRef)"
:placeholder="t('Please input field', { field: t('mall.item.description_en') })"
/>
<FormItem
:label="t('mall.item.title_ms')"
type="string"
v-model="baTable.form.items!.title_ms"
prop="title_ms"
:placeholder="t('Please input field', { field: t('mall.item.title_ms') })"
/>
<FormItem
:label="t('mall.item.description_ms')"
type="textarea"
v-model="baTable.form.items!.description_ms"
prop="description_ms"
:input-attr="{ rows: 3 }"
@keyup.enter.stop=""
@keyup.ctrl.enter="baTable.onSubmit(formRef)"
:placeholder="t('Please input field', { field: t('mall.item.description_ms') })"
/>
<FormItem
:label="t('mall.item.score')"
type="number"
v-model="baTable.form.items!.score"
prop="score"
:block-help="t('mall.item.score_tip')"
:input-attr="{ step: 1 }"
:placeholder="t('Please input field', { field: t('mall.item.score') })"
/>
<!-- BONUS / WITHDRAW现金面值流水倍数 -->
<FormItem
v-if="isBonusOrWithdraw"
:label="t('mall.item.amount')"
type="number"
<!-- BONUS / WITHDRAW现金面值 -->
<el-form-item v-if="isBonusOrWithdraw" :label="t('mall.item.amount')" prop="amount">
<el-input-number
v-model="baTable.form.items!.amount"
prop="amount"
:input-attr="{ step: 0.01 }"
:min="0"
:step="0.01"
:precision="2"
controls-position="right"
class="w100"
:placeholder="t('Please input field', { field: t('mall.item.amount') })"
/>
<FormItem
v-if="isBonusOrWithdraw"
:label="t('mall.item.multiplier')"
type="number"
v-model="baTable.form.items!.multiplier"
prop="multiplier"
:input-attr="{ step: 1 }"
:placeholder="t('Please input field', { field: t('mall.item.multiplier') })"
/>
<div v-if="suggestedAmount !== null" class="form-tip amount-suggest-row">
<span>{{ suggestedAmountHint }}</span>
<el-button type="primary" link @click="applySuggestedAmount">
{{ t('mall.item.amount_apply_suggested') }}
</el-button>
</div>
<div v-else-if="isBonusOrWithdraw && pointsRatioLoaded" class="form-tip">
{{ t('mall.item.amount_ratio_missing') }}
</div>
<div class="form-tip">{{ amountBlockHelp }}</div>
</el-form-item>
<!-- BONUSBonus Grant 需要 -->
<FormItem
v-if="isBonus"
@@ -156,8 +194,9 @@
<script setup lang="ts">
import type { FormItemRule } from 'element-plus'
import { computed, inject, reactive, useTemplateRef, watch } from 'vue'
import { computed, inject, reactive, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { index as fetchPlayxConfig } from '/@/api/backend/mall/playxConfig'
import FormItem from '/@/components/formItem/index.vue'
import { useConfig } from '/@/stores/config'
import type baTableClass from '/@/utils/baTable'
@@ -169,6 +208,79 @@ const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const pointsToCashRatio = ref<number | null>(null)
const pointsRatioLoaded = ref(false)
const loadPointsToCashRatio = () => {
pointsRatioLoaded.value = false
fetchPlayxConfig()
.then((res) => {
const row = res.data.row ?? {}
const raw = row.points_to_cash_ratio
if (raw !== undefined && raw !== null && raw !== '') {
const parsed = parseFloat(String(raw))
pointsToCashRatio.value = Number.isFinite(parsed) ? parsed : null
} else {
pointsToCashRatio.value = null
}
})
.catch(() => {
pointsToCashRatio.value = null
})
.finally(() => {
pointsRatioLoaded.value = true
})
}
watch(
() => baTable.form.operate,
(op) => {
if (op === 'Add' || op === 'Edit') {
loadPointsToCashRatio()
}
},
{ immediate: true }
)
const suggestedAmount = computed(() => {
const ratio = pointsToCashRatio.value
if (ratio === null || ratio < 0) {
return null
}
const score = Number(baTable.form.items?.score)
if (!Number.isFinite(score) || score < 0) {
return null
}
return Math.round(score * ratio * 100) / 100
})
const suggestedAmountHint = computed(() => {
if (suggestedAmount.value === null || pointsToCashRatio.value === null) {
return ''
}
const score = Number(baTable.form.items?.score)
return t('mall.item.amount_suggested', {
amount: suggestedAmount.value,
score: Number.isFinite(score) ? score : 0,
ratio: pointsToCashRatio.value,
})
})
const amountBlockHelp = computed(() => {
const base = t('mall.item.amount_tip')
if (pointsToCashRatio.value === null) {
return base
}
return `${base} ${t('mall.item.points_to_cash_ratio_label')}${pointsToCashRatio.value}`
})
const applySuggestedAmount = () => {
if (suggestedAmount.value === null || !baTable.form.items) {
return
}
baTable.form.items.amount = suggestedAmount.value
}
const itemType = computed(() => baTable.form.items!.type)
const isBonus = computed(() => itemType.value === 1 || itemType.value === '1')
const isPhysical = computed(() => itemType.value === 2 || itemType.value === '2')
@@ -192,9 +304,8 @@ watch(
// BONUS实物库存不适用
baTable.form.items.stock = 0
} else if (typeNum === 2) {
// PHYSICAL现金/倍数/类别不适用
// PHYSICAL现金/类别不适用
baTable.form.items.amount = 0
baTable.form.items.multiplier = 0
baTable.form.items.category = ''
baTable.form.items.category_title = ''
} else if (typeNum === 3) {
@@ -257,25 +368,6 @@ const rules: Partial<Record<string, FormItemRule[]>> = reactive({
trigger: 'blur',
},
],
multiplier: [
{
validator: (rule: any, val: any, callback: Function) => {
if (!isBonusOrWithdraw.value) return callback()
if (val === '' || val === null || val === undefined) {
return callback(new Error(t('Please input field', { field: t('mall.item.multiplier') })))
}
const num = Number(val)
if (!Number.isFinite(num) || !Number.isInteger(num)) {
return callback(new Error(t('Please enter the correct field', { field: t('mall.item.multiplier') })))
}
if (num < 0) {
return callback(new Error(t('Please enter the correct field', { field: t('mall.item.multiplier') })))
}
return callback()
},
trigger: 'blur',
},
],
category: [
{
validator: (rule: any, val: any, callback: Function) => {
@@ -325,4 +417,20 @@ const rules: Partial<Record<string, FormItemRule[]>> = reactive({
})
</script>
<style scoped lang="scss"></style>
<style scoped lang="scss">
.w100 {
width: 100%;
}
.form-tip {
margin-top: 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.5;
}
.amount-suggest-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 8px;
}
</style>

View File

@@ -1,5 +1,6 @@
<template>
<div class="default-main ba-table-box">
<MallPageIntro page-key="order" />
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<TableHeader
@@ -18,6 +19,7 @@ import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { baTableApi } from '/@/api/common'
import createAxios from '/@/utils/axios'
import MallPageIntro from '/@/components/mall/MallPageIntro.vue'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
@@ -30,6 +32,7 @@ defineOptions({
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete']).map((btn) =>
btn.name === 'edit'
? {
@@ -38,6 +41,7 @@ const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete']).map((btn)
type: 'primary',
class: 'table-row-edit',
icon: 'fa fa-check',
display: (row: TableRow) => ['PHYSICAL', 'WITHDRAW'].includes(String(row.type)) && row.status === 'PENDING',
}
: btn
)
@@ -56,6 +60,17 @@ const baTable = new baTableClass(
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
formatter: (row: TableRow, _column: TableColumn, cellValue: string) => {
const r = row as Record<string, unknown>
const rel = r.mallUserAsset ?? r.mall_user_asset
if (rel && typeof rel === 'object' && 'username' in rel) {
const u = (rel as { username?: unknown }).username
if (typeof u === 'string' && u.trim() !== '') {
return u
}
}
return (cellValue ?? row.user_id ?? '').toString()
},
},
{
label: t('mall.order.type'),
@@ -78,8 +93,8 @@ const baTable = new baTableClass(
prop: 'status',
align: 'center',
effect: 'dark',
custom: { PENDING: 'success', COMPLETED: 'primary', SHIPPED: 'info', REJECTED: 'loading' },
minWidth: 160,
custom: { PENDING: 'success', COMPLETED: 'primary', SHIPPED: 'info', REJECTED: 'danger' },
minWidth: 100,
operator: 'eq',
sortable: false,
render: 'tag',
@@ -90,7 +105,14 @@ const baTable = new baTableClass(
REJECTED: t('mall.order.status REJECTED'),
},
},
{ label: t('mall.order.mall_item_id'), prop: 'mall_item_id', align: 'center', operator: 'RANGE', sortable: false },
{
label: t('mall.order.mall_item_id'),
prop: 'mall_item_id',
align: 'center',
show: false,
operator: 'RANGE',
sortable: false,
},
{
label: t('mall.order.mallitem__title'),
prop: 'mallItem.title',
@@ -102,7 +124,6 @@ const baTable = new baTableClass(
},
{ label: t('mall.order.points_cost'), prop: 'points_cost', align: 'center', minWidth: 90, operator: 'RANGE', sortable: false },
{ label: t('mall.order.amount'), prop: 'amount', align: 'center', minWidth: 90, operator: 'RANGE', sortable: false },
{ label: t('mall.order.multiplier'), prop: 'multiplier', align: 'center', minWidth: 90, operator: 'eq', sortable: false },
{
label: t('mall.order.external_transaction_id'),
prop: 'external_transaction_id',
@@ -113,14 +134,6 @@ const baTable = new baTableClass(
sortable: false,
operator: 'LIKE',
},
{
label: t('mall.order.playx_transaction_id'),
prop: 'playx_transaction_id',
align: 'center',
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{
label: t('mall.order.grant_status'),
prop: 'grant_status',
@@ -150,7 +163,8 @@ const baTable = new baTableClass(
label: t('mall.order.fail_reason'),
prop: 'fail_reason',
align: 'center',
showOverflowTooltip: true,
minWidth: 140,
render: 'failReason',
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
@@ -159,7 +173,8 @@ const baTable = new baTableClass(
label: t('mall.order.reject_reason'),
prop: 'reject_reason',
align: 'center',
showOverflowTooltip: true,
minWidth: 140,
render: 'failReason',
sortable: false,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
@@ -314,5 +329,3 @@ onMounted(() => {
})
})
</script>
<style scoped lang="scss"></style>

View File

@@ -85,7 +85,7 @@
:placeholder="t('Please input field', { field: t('mall.order.reject_reason') })"
/>
<el-alert v-else type="info" :closable="false" show-icon>
确认后将驳回该订单并退回积分红利/提现订单无需填写驳回原因
确认后将驳回该订单并退回积分提现订单无需填写驳回原因
</el-alert>
</template>
</template>
@@ -182,9 +182,10 @@ const { t } = useI18n()
const isEdit = computed(() => baTable.form.operate === 'Edit')
const isPending = computed(() => isEdit.value && baTable.form.items?.status === 'PENDING')
const needsReviewType = computed(() => ['PHYSICAL', 'WITHDRAW'].includes(String(baTable.form.items?.type || '')))
const isPhysical = computed(() => baTable.form.items?.type === 'PHYSICAL')
const canApprove = computed(() => isPending.value)
const usePagedActions = computed(() => isPending.value)
const canApprove = computed(() => isPending.value && needsReviewType.value)
const usePagedActions = computed(() => isPending.value && needsReviewType.value)
const page = ref<1 | 2>(1)
const action = ref<'approveShip' | 'reject' | null>(null)

View File

@@ -1,5 +1,6 @@
<template>
<div class="default-main ba-table-box">
<MallPageIntro page-key="pintsOrder" />
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
@@ -25,6 +26,7 @@ import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table'
import MallPageIntro from '/@/components/mall/MallPageIntro.vue'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'

View File

@@ -2,12 +2,7 @@
<div class="default-main">
<!-- 语言包注入是异步的 t() 调用和子组件渲染都延后到注入完成后避免 intlify Not found -->
<template v-if="langReady">
<el-card shadow="never" class="mb-4">
<template #header>
<div class="card-header">{{ t('mall.playxCenter.title') }}</div>
</template>
<div class="desc">{{ t('mall.playxCenter.desc') }}</div>
</el-card>
<MallPageIntro page-key="playxCenter" class="mb-4" />
<el-tabs v-model="activeName" type="border-card">
<el-tab-pane :label="t('mall.playxCenter.orders')" name="orders">
@@ -34,6 +29,7 @@ import PlayxOrderIndex from '/@/views/backend/mall/playxOrder/index.vue'
import PlayxDailyPushIndex from '/@/views/backend/mall/playxDailyPush/index.vue'
import PlayxClaimLogIndex from '/@/views/backend/mall/playxClaimLog/index.vue'
import PlayxUserAssetIndex from '/@/views/backend/mall/playxUserAsset/index.vue'
import MallPageIntro from '/@/components/mall/MallPageIntro.vue'
import { useConfig } from '/@/stores/config'
import { mergeMessage } from '/@/lang/index'
@@ -92,11 +88,5 @@ onMounted(async () => {
.mb-4 {
margin-bottom: 16px;
}
.card-header {
font-weight: 600;
}
.desc {
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -1,5 +1,6 @@
<template>
<div class="default-main ba-table-box">
<MallPageIntro page-key="playxClaimLog" />
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<TableHeader
@@ -15,6 +16,7 @@
import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { baTableApi } from '/@/api/common'
import MallPageIntro from '/@/components/mall/MallPageIntro.vue'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'

View File

@@ -0,0 +1,284 @@
<template>
<div class="default-main ba-table-box">
<MallPageIntro page-key="playxConfig" />
<el-alert class="ba-table-alert" v-if="remark" :title="remark" type="info" show-icon />
<el-card shadow="never" v-loading="loading">
<el-form
v-if="!loading"
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
@submit.prevent=""
@keyup.enter="onSubmit()"
>
<el-tabs v-model="activeTab" class="playx-config-tabs">
<el-tab-pane :label="t('mall.playxConfig.tab_regular')" name="regular">
<el-form-item :label="t('mall.playxConfig.unlock_ratio')" prop="unlock_ratio">
<el-input-number
v-model="form.unlock_ratio"
:min="0"
:max="100"
:step="0.001"
:precision="3"
controls-position="right"
class="w100"
/>
<div class="form-tip">{{ t('mall.playxConfig.unlock_ratio_tip') }}</div>
</el-form-item>
<el-form-item :label="t('mall.playxConfig.points_to_cash_ratio')" prop="points_to_cash_ratio">
<el-input-number
v-model="form.points_to_cash_ratio"
:min="0"
:max="100"
:step="0.01"
:precision="4"
controls-position="right"
class="w100"
/>
<div class="form-tip">{{ t('mall.playxConfig.points_to_cash_ratio_tip') }}</div>
</el-form-item>
</el-tab-pane>
<el-tab-pane :label="t('mall.playxConfig.tab_event')" name="event">
<el-divider content-position="left">{{ t('mall.playxConfig.section_event_period') }}</el-divider>
<el-form-item :label="t('mall.playxConfig.mall_open_date')" prop="mall_open_date">
<el-date-picker
v-model="form.mall_open_date"
type="date"
value-format="YYYY-MM-DD"
class="w100"
:placeholder="t('Please select field', { field: t('mall.playxConfig.mall_open_date') })"
/>
<div class="form-tip">{{ t('mall.playxConfig.mall_open_date_tip') }}</div>
<div v-if="activeMallOpenDate" class="form-tip">
{{ t('mall.playxConfig.active_mall_open_date') }}{{ activeMallOpenDate }}
</div>
</el-form-item>
<el-form-item :label="t('mall.playxConfig.mall_event_end_date')" prop="mall_event_end_date">
<el-date-picker
v-model="form.mall_event_end_date"
type="date"
value-format="YYYY-MM-DD"
class="w100"
clearable
:placeholder="t('Please select field', { field: t('mall.playxConfig.mall_event_end_date') })"
/>
<div class="form-tip">{{ t('mall.playxConfig.mall_event_end_date_tip') }}</div>
<div v-if="activeEventEndDate" class="form-tip">
{{ t('mall.playxConfig.active_event_end_date') }}{{ activeEventEndDate }}
</div>
</el-form-item>
<el-divider content-position="left">{{ t('mall.playxConfig.section_daily_source') }}</el-divider>
<el-form-item :label="t('mall.playxConfig.daily_points_enabled')" prop="daily_points_enabled">
<el-switch v-model="form.daily_points_enabled" />
<div class="form-tip">{{ t('mall.playxConfig.daily_points_enabled_tip') }}</div>
</el-form-item>
<el-form-item :label="t('mall.playxConfig.return_ratio')" prop="return_ratio">
<el-input-number
v-model="form.return_ratio"
:min="0"
:max="100"
:step="0.001"
:precision="3"
controls-position="right"
class="w100"
:disabled="!form.daily_points_enabled"
/>
<div class="form-tip">{{ t('mall.playxConfig.return_ratio_tip') }}</div>
</el-form-item>
<el-divider content-position="left">{{ t('mall.playxConfig.section_lifetime_source') }}</el-divider>
<el-form-item :label="t('mall.playxConfig.lifetime_points_enabled')" prop="lifetime_points_enabled">
<el-switch v-model="form.lifetime_points_enabled" />
<div class="form-tip">{{ t('mall.playxConfig.lifetime_points_enabled_tip') }}</div>
</el-form-item>
<el-form-item :label="t('mall.playxConfig.lifetime_points_ratio')" prop="lifetime_points_ratio">
<el-input-number
v-model="form.lifetime_points_ratio"
:min="0"
:max="100"
:step="0.001"
:precision="3"
controls-position="right"
class="w100"
:disabled="!form.lifetime_points_enabled"
/>
<div class="form-tip">{{ t('mall.playxConfig.lifetime_points_ratio_tip') }}</div>
</el-form-item>
<el-form-item>
<el-button
type="warning"
:loading="calculating"
:disabled="!form.lifetime_points_enabled"
@click="onCalculateLifetime()"
>
{{ t('mall.playxConfig.calculate_lifetime_btn') }}
</el-button>
<div class="form-tip">{{ t('mall.playxConfig.calculate_lifetime_tip') }}</div>
</el-form-item>
</el-tab-pane>
</el-tabs>
<el-form-item class="playx-config-submit">
<el-button type="primary" :loading="submitting" @click="onSubmit()">{{ t('Save') }}</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import type { FormInstance, FormRules } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { onMounted, reactive, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { calculateLifetimePoints, index, save } from '/@/api/backend/mall/playxConfig'
import MallPageIntro from '/@/components/mall/MallPageIntro.vue'
defineOptions({
name: 'mall/playxConfig',
})
const { t } = useI18n()
const formRef = useTemplateRef<FormInstance>('formRef')
const loading = ref(true)
const submitting = ref(false)
const calculating = ref(false)
const remark = ref('')
const activeTab = ref('regular')
const activeMallOpenDate = ref('')
const activeEventEndDate = ref('')
const form = reactive({
return_ratio: 0.1,
unlock_ratio: 0.1,
points_to_cash_ratio: 0.01,
daily_points_enabled: true,
lifetime_points_enabled: false,
lifetime_points_ratio: 0.1,
mall_open_date: '' as string | null,
mall_event_end_date: '' as string | null,
})
const validateEventDates = (_rule: unknown, _value: unknown, callback: (err?: Error) => void) => {
const start = form.mall_open_date
const end = form.mall_event_end_date
if (start && end && end < start) {
callback(new Error(t('mall.playxConfig.event_end_before_start')))
return
}
callback()
}
const rules: FormRules = {
return_ratio: [{ required: true, message: t('Please input field', { field: t('mall.playxConfig.return_ratio') }) }],
unlock_ratio: [{ required: true, message: t('Please input field', { field: t('mall.playxConfig.unlock_ratio') }) }],
points_to_cash_ratio: [{ required: true, message: t('Please input field', { field: t('mall.playxConfig.points_to_cash_ratio') }) }],
lifetime_points_ratio: [{ required: true, message: t('Please input field', { field: t('mall.playxConfig.lifetime_points_ratio') }) }],
mall_event_end_date: [{ validator: validateEventDates, trigger: 'change' }],
mall_open_date: [{ validator: validateEventDates, trigger: 'change' }],
}
const loadData = () => {
loading.value = true
index()
.then((res) => {
remark.value = res.data.remark ?? ''
const row = res.data.row ?? {}
activeMallOpenDate.value = row.active_mall_open_date ?? ''
activeEventEndDate.value = row.active_event_end_date ?? ''
if (row.return_ratio !== undefined && row.return_ratio !== null) {
form.return_ratio = parseFloat(String(row.return_ratio))
}
if (row.unlock_ratio !== undefined && row.unlock_ratio !== null) {
form.unlock_ratio = parseFloat(String(row.unlock_ratio))
}
if (row.points_to_cash_ratio !== undefined && row.points_to_cash_ratio !== null) {
form.points_to_cash_ratio = parseFloat(String(row.points_to_cash_ratio))
}
form.daily_points_enabled = Number(row.daily_points_enabled ?? 1) === 1
form.lifetime_points_enabled = Number(row.lifetime_points_enabled ?? 0) === 1
if (row.lifetime_points_ratio !== undefined && row.lifetime_points_ratio !== null) {
form.lifetime_points_ratio = parseFloat(String(row.lifetime_points_ratio))
}
form.mall_open_date = row.mall_open_date ? String(row.mall_open_date).slice(0, 10) : null
form.mall_event_end_date = row.mall_event_end_date ? String(row.mall_event_end_date).slice(0, 10) : null
})
.finally(() => {
loading.value = false
})
}
const onSubmit = () => {
formRef.value?.validate((valid) => {
if (!valid) return
submitting.value = true
save({
return_ratio: form.return_ratio,
unlock_ratio: form.unlock_ratio,
points_to_cash_ratio: form.points_to_cash_ratio,
daily_points_enabled: form.daily_points_enabled ? 1 : 0,
lifetime_points_enabled: form.lifetime_points_enabled ? 1 : 0,
lifetime_points_ratio: form.lifetime_points_ratio,
mall_open_date: form.mall_open_date || null,
mall_event_end_date: form.mall_event_end_date || null,
})
.then(() => {
loadData()
})
.finally(() => {
submitting.value = false
})
})
}
const onCalculateLifetime = () => {
ElMessageBox.confirm(t('mall.playxConfig.calculate_lifetime_confirm'), t('Reminder'), {
type: 'warning',
}).then(() => {
calculating.value = true
calculateLifetimePoints()
.then((res) => {
const stats = res.data ?? {}
ElMessage.success(
t('mall.playxConfig.calculate_lifetime_success', {
processed: stats.processed ?? 0,
updated: stats.updated ?? 0,
skipped: stats.skipped ?? 0,
})
)
loadData()
})
.finally(() => {
calculating.value = false
})
})
}
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
.w100 {
width: 100%;
}
.form-tip {
margin-top: 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.5;
}
.playx-config-tabs {
margin-bottom: 8px;
}
.playx-config-submit {
margin-top: 16px;
margin-bottom: 0;
}
</style>

View File

@@ -1,5 +1,6 @@
<template>
<div class="default-main ba-table-box">
<MallPageIntro page-key="playxDailyPush" />
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<TableHeader
@@ -15,6 +16,7 @@
import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { baTableApi } from '/@/api/common'
import MallPageIntro from '/@/components/mall/MallPageIntro.vue'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
@@ -33,20 +35,94 @@ const baTable = new baTableClass(
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('mall.playxDailyPush.id'), prop: 'id', align: 'center', width: 70, operator: 'RANGE', sortable: 'custom' },
{ label: t('mall.playxDailyPush.user_id'), prop: 'user_id', align: 'center', operatorPlaceholder: t('Fuzzy query'), sortable: false, operator: 'LIKE' },
{ label: t('mall.playxDailyPush.date'), prop: 'date', align: 'center', render: 'date', operator: 'RANGE', comSearchRender: 'date', sortable: 'custom', width: 120, operatorPlaceholder: t('Fuzzy query') },
{ label: t('mall.playxDailyPush.username'), prop: 'username', align: 'center', operatorPlaceholder: t('Fuzzy query'), sortable: false, operator: 'LIKE' },
{ label: t('mall.playxDailyPush.yesterday_win_loss_net'), prop: 'yesterday_win_loss_net', align: 'center', operator: 'RANGE', sortable: false },
{ label: t('mall.playxDailyPush.yesterday_total_deposit'), prop: 'yesterday_total_deposit', align: 'center', operator: 'RANGE', sortable: false },
{ label: t('mall.playxDailyPush.lifetime_total_deposit'), prop: 'lifetime_total_deposit', align: 'center', operator: 'RANGE', sortable: false },
{ label: t('mall.playxDailyPush.lifetime_total_withdraw'), prop: 'lifetime_total_withdraw', align: 'center', operator: 'RANGE', sortable: false },
{ label: t('mall.playxDailyPush.create_time'), prop: 'create_time', align: 'center', render: 'datetime', operator: 'RANGE', comSearchRender: 'datetime', sortable: 'custom', width: 160, timeFormat: 'yyyy-mm-dd hh:MM:ss' },
{
label: t('mall.playxDailyPush.user_id'),
prop: 'user_id',
align: 'center',
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{
label: t('mall.playxDailyPush.date'),
prop: 'date',
align: 'center',
render: 'date',
operator: 'RANGE',
comSearchRender: 'date',
sortable: 'custom',
width: 120,
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('mall.playxDailyPush.username'),
prop: 'username',
align: 'center',
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{
label: t('mall.playxDailyPush.yesterday_win_loss_net'),
prop: 'yesterday_win_loss_net',
align: 'center',
operator: 'RANGE',
sortable: false,
},
{
label: t('mall.playxDailyPush.converted_points'),
prop: 'converted_points',
align: 'center',
minWidth: 160,
operator: 'RANGE',
sortable: false,
width: 100,
},
{
label: t('mall.playxDailyPush.yesterday_total_deposit'),
prop: 'yesterday_total_deposit',
align: 'center',
operator: 'RANGE',
sortable: false,
},
{
label: t('mall.playxDailyPush.lifetime_total_deposit'),
prop: 'lifetime_total_deposit',
align: 'center',
operator: 'RANGE',
sortable: false,
},
{
label: t('mall.playxDailyPush.lifetime_total_withdraw'),
prop: 'lifetime_total_withdraw',
align: 'center',
operator: 'RANGE',
sortable: false,
},
{
label: t('mall.playxDailyPush.lifetime_win_loss_net'),
prop: 'lifetime_win_loss_net',
align: 'center',
operator: 'RANGE',
sortable: false,
},
{
label: t('mall.playxDailyPush.create_time'),
prop: 'create_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 160,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
],
dblClickNotEditColumn: [undefined],
},
{
defaultItems: {},
},
}
)
provide('baTable', baTable)
@@ -62,4 +138,3 @@ onMounted(() => {
</script>
<style scoped lang="scss"></style>

View File

@@ -1,5 +1,6 @@
<template>
<div class="default-main ba-table-box">
<MallPageIntro page-key="playxOrder" />
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<TableHeader
@@ -15,6 +16,7 @@
import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { baTableApi } from '/@/api/common'
import MallPageIntro from '/@/components/mall/MallPageIntro.vue'
import createAxios from '/@/utils/axios'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
@@ -67,9 +69,7 @@ const baTable = new baTableClass(
{ label: t('mall.playxOrder.mallitem__title'), prop: 'mallItem.title', align: 'center', operatorPlaceholder: t('Fuzzy query'), sortable: false, operator: 'LIKE' },
{ label: t('mall.playxOrder.points_cost'), prop: 'points_cost', align: 'center', operator: 'RANGE', sortable: false },
{ label: t('mall.playxOrder.amount'), prop: 'amount', align: 'center', operator: 'RANGE', sortable: false },
{ label: t('mall.playxOrder.multiplier'), prop: 'multiplier', align: 'center', operator: 'eq', sortable: false },
{ label: t('mall.playxOrder.external_transaction_id'), prop: 'external_transaction_id', align: 'center', operatorPlaceholder: t('Fuzzy query'), sortable: false, operator: 'LIKE' },
{ label: t('mall.playxOrder.playx_transaction_id'), prop: 'playx_transaction_id', align: 'center', operatorPlaceholder: t('Fuzzy query'), sortable: false, operator: 'LIKE' },
{
label: t('mall.playxOrder.grant_status'),
prop: 'grant_status',
@@ -106,24 +106,24 @@ const baTable = new baTableClass(
{
label: t('Operate'),
align: 'center',
width: 220,
render: 'buttons',
buttons: [
{
render: 'confirmButton',
name: 'retry',
title: 'Retry',
text: '手动重试',
title: 'mall.playxOrder.manual_retry',
text: '',
type: 'warning',
icon: '',
icon: 'fa fa-refresh',
class: 'table-row-edit',
display: (row: TableRow) =>
row.type === 'BONUS' &&
row.status === 'PENDING' &&
['NOT_SENT', 'SENT_PENDING', 'FAILED_RETRYABLE', 'FAILED_FINAL'].includes(String(row.grant_status)),
popconfirm: {
title: '确认将该订单加入重试队列?',
confirmButtonText: '确认',
cancelButtonText: '取消',
title: t('mall.playxOrder.retry_confirm'),
confirmButtonText: t('Confirm'),
cancelButtonText: t('Cancel'),
confirmButtonType: 'warning',
},
click: async (row: TableRow) => {
@@ -143,23 +143,28 @@ const baTable = new baTableClass(
},
},
{
render: 'basicButton',
render: 'tipButton',
name: 'ship',
title: 'Ship',
text: '发货',
title: 'mall.playxOrder.ship',
text: '',
type: 'success',
icon: '',
icon: 'fa fa-truck',
class: 'table-row-ship',
display: (row: TableRow) => row.type === 'PHYSICAL' && row.status === 'PENDING',
click: async (row: TableRow) => {
try {
const shippingNoRes = await ElMessageBox.prompt('请输入物流单号', '发货', {
confirmButtonText: '确认',
cancelButtonText: '取消',
})
const shippingCompanyRes = await ElMessageBox.prompt('请输入物流公司', '发货', {
confirmButtonText: '确认',
cancelButtonText: '取消',
const shippingNoRes = await ElMessageBox.prompt(t('mall.playxOrder.ship_shipping_no_prompt'), t('mall.playxOrder.ship'), {
confirmButtonText: t('Confirm'),
cancelButtonText: t('Cancel'),
})
const shippingCompanyRes = await ElMessageBox.prompt(
t('mall.playxOrder.ship_shipping_company_prompt'),
t('mall.playxOrder.ship'),
{
confirmButtonText: t('Confirm'),
cancelButtonText: t('Cancel'),
}
)
const shippingNo = shippingNoRes.value
const shippingCompany = shippingCompanyRes.value
@@ -185,18 +190,19 @@ const baTable = new baTableClass(
},
},
{
render: 'basicButton',
render: 'tipButton',
name: 'reject',
title: 'Reject',
text: '驳回',
title: 'mall.playxOrder.reject',
text: '',
type: 'danger',
icon: '',
icon: 'fa fa-times',
class: 'table-row-reject',
display: (row: TableRow) => row.type === 'PHYSICAL' && row.status === 'PENDING',
click: async (row: TableRow) => {
try {
const res = await ElMessageBox.prompt('请输入驳回原因', '驳回', {
confirmButtonText: '确认',
cancelButtonText: '取消',
const res = await ElMessageBox.prompt(t('mall.playxOrder.reject_reason_prompt'), t('mall.playxOrder.reject'), {
confirmButtonText: t('Confirm'),
cancelButtonText: t('Cancel'),
})
await createAxios(
{
@@ -218,6 +224,7 @@ const baTable = new baTableClass(
},
},
],
operator: false,
},
],
dblClickNotEditColumn: [undefined],

View File

@@ -1,5 +1,6 @@
<template>
<div class="default-main ba-table-box">
<MallPageIntro page-key="playxUserAsset" />
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<TableHeader
@@ -15,6 +16,7 @@
import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { baTableApi } from '/@/api/common'
import MallPageIntro from '/@/components/mall/MallPageIntro.vue'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'

View File

@@ -0,0 +1,150 @@
<template>
<div class="default-main ba-table-box">
<MallPageIntro page-key="pointsLog" />
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<TableHeader
:buttons="['refresh', 'add', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('mall.pointsLog.quick_search_fields') })"
>
<el-button v-if="state.userAsset.id" v-blur class="table-header-operate">
<span class="table-header-operate-text">
{{
state.userAsset.username +
' (ID:' +
state.userAsset.id +
') ' +
t('mall.pointsLog.available_points') +
': ' +
state.userAsset.available_points
}}
</span>
</el-button>
</TableHeader>
<Table />
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { debounce } from 'lodash-es'
import { provide, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { getUserAsset, url } from '/@/api/backend/mall/pointsLog'
import { baTableApi } from '/@/api/common'
import MallPageIntro from '/@/components/mall/MallPageIntro.vue'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
import PopupForm from './popupForm.vue'
defineOptions({
name: 'mall/pointsLog',
})
interface UserAssetInfo {
id?: number
username?: string
phone?: string
playx_user_id?: string
available_points?: number
}
const { t } = useI18n()
const route = useRoute()
const routeUserAssetId = route.query.user_asset_id
const defaultUserAssetId = typeof routeUserAssetId === 'string' ? routeUserAssetId : ''
const state = reactive<{ userAsset: UserAssetInfo }>({
userAsset: {},
})
const baTable = new baTableClass(
new baTableApi(url),
{
column: [
{ label: t('mall.pointsLog.id'), prop: 'id', align: 'center', operator: '=', width: 70 },
{ label: t('mall.pointsLog.user_asset_id'), prop: 'user_asset_id', align: 'center', operator: '=', width: 100 },
{
label: t('mall.pointsLog.playx_user_id'),
prop: 'userAsset.playx_user_id',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('mall.pointsLog.username'),
prop: 'userAsset.username',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{ label: t('mall.pointsLog.score_value'), prop: 'score_value', align: 'center', operator: 'RANGE', sortable: 'custom' },
{ label: t('mall.pointsLog.before'), prop: 'before', align: 'center', operator: 'RANGE', sortable: 'custom' },
{ label: t('mall.pointsLog.after'), prop: 'after', align: 'center', operator: 'RANGE', sortable: 'custom' },
{
label: t('mall.pointsLog.memo'),
prop: 'memo',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
showOverflowTooltip: true,
},
{ label: t('mall.pointsLog.admin'), prop: 'admin.username', align: 'center', operator: 'LIKE' },
{
label: t('mall.pointsLog.create_time'),
prop: 'create_time',
align: 'center',
render: 'datetime',
sortable: 'custom',
operator: 'RANGE',
width: 160,
},
],
dblClickNotEditColumn: ['all'],
},
{
defaultItems: {
user_asset_id: defaultUserAssetId,
score_value: 0,
},
}
)
const getUserAssetInfo = debounce((userAssetId: unknown) => {
if ((typeof userAssetId !== 'string' && typeof userAssetId !== 'number') || userAssetId === '') {
state.userAsset = {}
return
}
getUserAsset(userAssetId).then((response) => {
state.userAsset = response.data.userAsset
})
}, 300)
baTable.after.onSubmit = () => {
getUserAssetInfo(baTable.comSearch.form.user_asset_id)
}
baTable.after.onTableHeaderAction = ({ event }) => {
if (event == 'refresh') {
getUserAssetInfo(baTable.comSearch.form.user_asset_id)
}
}
baTable.mount()
baTable.getData()
provide('baTable', baTable)
getUserAssetInfo(baTable.comSearch.form.user_asset_id)
watch(
() => baTable.comSearch.form.user_asset_id,
(newValue) => {
baTable.form.defaultItems!.user_asset_id = newValue
getUserAssetInfo(newValue)
}
)
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,189 @@
<template>
<el-dialog class="ba-operate-dialog" :close-on-click-modal="false" :model-value="baTable.form.operate == 'Add'" @close="baTable.toggleForm">
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ baTable.form.operate ? t(baTable.form.operate) : '' }}
</div>
</template>
<el-scrollbar class="ba-table-form-scrollbar">
<div
class="ba-operate-form"
:class="'ba-' + baTable.form.operate + '-form'"
:style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'"
>
<el-form
v-if="!baTable.form.loading"
ref="formRef"
:model="baTable.form.items"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="baTable.form.labelWidth + 'px'"
:rules="rules"
@keyup.enter="baTable.onSubmit(formRef)"
>
<FormItem
type="remoteSelect"
prop="user_asset_id"
:label="t('mall.pointsLog.user_asset')"
v-model="baTable.form.items!.user_asset_id"
:placeholder="t('Click select')"
:input-attr="{
pk: 'id',
field: 'username',
remoteUrl: '/admin/mall.UserAsset/select',
onChange: getUserAssetInfo,
}"
/>
<el-form-item :label="t('mall.pointsLog.playx_user_id')">
<el-input v-model="state.userAsset.playx_user_id" disabled />
</el-form-item>
<el-form-item :label="t('mall.pointsLog.username')">
<el-input v-model="state.userAsset.username" disabled />
</el-form-item>
<el-form-item :label="t('mall.pointsLog.current_points')">
<el-input v-model="state.userAsset.available_points" disabled />
</el-form-item>
<el-form-item prop="score_value" :label="t('mall.pointsLog.score_value')">
<el-input-number
v-model="baTable.form.items!.score_value"
:precision="0"
:step="1"
controls-position="right"
style="width: 100%"
@change="changeScore"
/>
</el-form-item>
<el-form-item :label="t('mall.pointsLog.after')">
<el-input v-model="state.after" disabled />
</el-form-item>
<el-form-item :label="t('mall.pointsLog.memo')">
<el-input v-model="state.memo" type="textarea" disabled />
</el-form-item>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm('')">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" type="primary" @click="baTable.onSubmit(formRef)">
{{ t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import type { FormItemRule } from 'element-plus'
import { inject, reactive, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { getUserAsset } from '/@/api/backend/mall/pointsLog'
import FormItem from '/@/components/formItem/index.vue'
import { useConfig } from '/@/stores/config'
import type baTableClass from '/@/utils/baTable'
import { buildValidatorData } from '/@/utils/validate'
interface UserAssetInfo {
id?: number
username?: string
playx_user_id?: string
available_points?: number
}
const config = useConfig()
const { t, locale } = useI18n()
const injectedBaTable = inject<baTableClass>('baTable')
if (!injectedBaTable) {
throw new Error('baTable is not provided')
}
const baTable = injectedBaTable
const formRef = useTemplateRef('formRef')
const state = reactive<{
userAsset: UserAssetInfo
after: number
memo: string
}>({
userAsset: {},
after: 0,
memo: '',
})
const validateScoreValue: FormItemRule['validator'] = (_rule, value, callback) => {
if (typeof value !== 'number' || !Number.isInteger(value) || value === 0) {
callback(new Error(t('mall.pointsLog.non_zero_integer_required')))
return
}
if ((state.userAsset.available_points ?? 0) + value < 0) {
callback(new Error(t('mall.pointsLog.insufficient_available_points')))
return
}
callback()
}
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
user_asset_id: [buildValidatorData({ name: 'required', title: t('mall.pointsLog.user_asset') })],
score_value: [
{
validator: validateScoreValue,
trigger: 'blur',
},
],
})
const syncMemoToForm = (scoreValue: number) => {
let memoText = ''
if (scoreValue > 0) {
memoText = t('mall.pointsLog.admin_add_memo', { value: Math.abs(scoreValue) })
} else if (scoreValue < 0) {
memoText = t('mall.pointsLog.admin_deduct_memo', { value: Math.abs(scoreValue) })
}
state.memo = memoText
if (baTable.form.items) {
baTable.form.items.memo = memoText
}
}
const changeScore = (value: number | undefined) => {
const scoreValue = value ?? 0
const availablePoints = state.userAsset.available_points ?? 0
state.after = availablePoints + scoreValue
syncMemoToForm(scoreValue)
}
const getUserAssetInfo = () => {
const userAssetId = baTable.form.items!.user_asset_id
if ((typeof userAssetId !== 'string' && typeof userAssetId !== 'number') || userAssetId === '') {
state.userAsset = {}
state.after = 0
state.memo = ''
if (baTable.form.items) {
baTable.form.items.memo = ''
}
return
}
getUserAsset(userAssetId).then((response) => {
state.userAsset = response.data.userAsset
changeScore(baTable.form.items!.score_value)
})
}
watch(
() => baTable.form.operate,
(operate) => {
if (operate == 'Add') {
getUserAssetInfo()
}
}
)
watch(locale, () => {
if (baTable.form.operate === 'Add') {
changeScore(baTable.form.items?.score_value as number | undefined)
}
})
</script>
<style scoped lang="scss"></style>

View File

@@ -1,5 +1,6 @@
<template>
<div class="default-main ba-table-box">
<MallPageIntro page-key="redemptionOrder" />
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
@@ -25,6 +26,7 @@ import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table'
import MallPageIntro from '/@/components/mall/MallPageIntro.vue'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'

View File

@@ -1,5 +1,6 @@
<template>
<div class="default-main ba-table-box">
<MallPageIntro page-key="userAsset" />
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<TableHeader
@@ -19,6 +20,7 @@ import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table'
import MallPageIntro from '/@/components/mall/MallPageIntro.vue'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
@@ -43,6 +45,7 @@ const baTable = new baTableClass(
prop: 'username',
align: 'center',
operatorPlaceholder: t('Fuzzy query'),
showOverflowTooltip: true,
sortable: false,
operator: 'LIKE',
},
@@ -50,7 +53,9 @@ const baTable = new baTableClass(
label: t('mall.userAsset.phone'),
prop: 'phone',
align: 'center',
minWidth: 100,
operatorPlaceholder: t('Fuzzy query'),
showOverflowTooltip: true,
sortable: false,
operator: 'LIKE',
},
@@ -58,14 +63,68 @@ const baTable = new baTableClass(
label: t('mall.userAsset.playx_user_id'),
prop: 'playx_user_id',
align: 'center',
minWidth: 120,
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{ label: t('mall.userAsset.locked_points'), prop: 'locked_points', align: 'center', operator: 'RANGE', sortable: false },
{ label: t('mall.userAsset.available_points'), prop: 'available_points', align: 'center', operator: 'RANGE', sortable: false },
{ label: t('mall.userAsset.today_limit'), prop: 'today_limit', align: 'center', operator: 'RANGE', sortable: false },
{ label: t('mall.userAsset.today_claimed'), prop: 'today_claimed', align: 'center', operator: 'RANGE', sortable: false },
{
label: t('mall.userAsset.locked_points'),
prop: 'locked_points',
align: 'center',
minWidth: 120,
operator: 'RANGE',
sortable: true,
},
{
label: t('mall.userAsset.available_points'),
prop: 'available_points',
align: 'center',
minWidth: 120,
operator: 'RANGE',
sortable: true,
},
{
label: t('mall.userAsset.today_limit'),
prop: 'today_limit',
align: 'center',
minWidth: 160,
operator: 'RANGE',
sortable: true,
},
{
label: t('mall.userAsset.today_claimed'),
prop: 'today_claimed',
align: 'center',
minWidth: 120,
operator: 'RANGE',
sortable: true,
},
{
label: t('mall.userAsset.points_claim_cap'),
prop: 'points_claim_cap',
align: 'center',
minWidth: 160,
operator: 'RANGE',
sortable: true,
},
{
label: t('mall.userAsset.total_points_claimed'),
prop: 'total_points_claimed',
align: 'center',
minWidth: 160,
operator: 'RANGE',
sortable: true,
},
{
label: t('mall.userAsset.event_period_win_loss_net'),
prop: 'event_period_win_loss_net',
align: 'center',
minWidth: 140,
showOverflowTooltip: true,
operator: 'RANGE',
sortable: true,
},
{
label: t('mall.userAsset.today_limit_date'),
prop: 'today_limit_date',

View File

@@ -27,7 +27,13 @@
:label-width="baTable.form.labelWidth + 'px'"
:rules="rules"
>
<FormItem :label="t('mall.userAsset.id')" type="number" v-model="baTable.form.items!.id" prop="id" :input-attr="{ disabled: true }" />
<FormItem
:label="t('mall.userAsset.id')"
type="number"
v-model="baTable.form.items!.id"
prop="id"
:input-attr="{ disabled: true }"
/>
<FormItem
:label="t('mall.userAsset.playx_user_id')"
type="string"
@@ -54,32 +60,38 @@
type="number"
v-model="baTable.form.items!.locked_points"
prop="locked_points"
:input-attr="{ step: 1, min: 0 }"
:placeholder="t('Please input field', { field: t('mall.userAsset.locked_points') })"
/>
<FormItem
:label="t('mall.userAsset.available_points')"
type="number"
v-model="baTable.form.items!.available_points"
prop="available_points"
:input-attr="{ step: 1, min: 0 }"
:placeholder="t('Please input field', { field: t('mall.userAsset.available_points') })"
:input-attr="{ disabled: true }"
/>
<el-form-item :label="t('mall.userAsset.available_points')">
<el-input v-model="baTable.form.items!.available_points" readonly>
<template #append>
<el-button @click="changeAvailablePoints">{{ t('mall.userAsset.adjust_available_points') }}</el-button>
</template>
</el-input>
</el-form-item>
<FormItem
:label="t('mall.userAsset.today_limit')"
type="number"
v-model="baTable.form.items!.today_limit"
prop="today_limit"
:block-help="t('mall.userAsset.today_limit_tip')"
:input-attr="{ step: 1, min: 0 }"
:placeholder="t('Please input field', { field: t('mall.userAsset.today_limit') })"
/>
<FormItem
:label="t('mall.userAsset.event_period_win_loss_net')"
type="number"
v-model="baTable.form.items!.event_period_win_loss_net"
prop="event_period_win_loss_net"
:block-help="t('mall.userAsset.event_period_win_loss_net_tip')"
:input-attr="{ disabled: true, step: 0.01 }"
/>
<FormItem
:label="t('mall.userAsset.today_claimed')"
type="number"
v-model="baTable.form.items!.today_claimed"
prop="today_claimed"
:input-attr="{ step: 1, min: 0 }"
:placeholder="t('Please input field', { field: t('mall.userAsset.today_claimed') })"
:input-attr="{ disabled: true }"
/>
</el-form>
</div>
@@ -104,21 +116,32 @@ import baTableClass from '/@/utils/baTable'
import FormItem from '/@/components/formItem/index.vue'
import type { FormItemRule } from 'element-plus'
import { buildValidatorData } from '/@/utils/validate'
import { useRouter } from 'vue-router'
const config = useConfig()
const router = useRouter()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
username: [buildValidatorData({ name: 'required', title: t('mall.userAsset.username') })],
phone: [buildValidatorData({ name: 'required', title: t('mall.userAsset.phone') })],
locked_points: [buildValidatorData({ name: 'required', title: t('mall.userAsset.locked_points') })],
available_points: [buildValidatorData({ name: 'required', title: t('mall.userAsset.available_points') })],
today_limit: [buildValidatorData({ name: 'required', title: t('mall.userAsset.today_limit') })],
today_claimed: [buildValidatorData({ name: 'required', title: t('mall.userAsset.today_claimed') })],
})
const changeAvailablePoints = () => {
const userAssetId = baTable.form.items!.id
baTable.toggleForm()
router.push({
name: 'mall/pointsLog',
query: {
user_asset_id: userAssetId,
},
})
}
</script>
<style scoped lang="scss"></style>

View File

@@ -1,126 +0,0 @@
<template>
<div>
<el-dialog v-model="state.dialog.buy" class="buy-dialog" :title="t('module.Confirm order info')" top="20vh" width="28%">
<div v-loading="state.loading.buy">
<el-alert :title="t('module.Module installation warning')" type="error" :center="true" :closable="false" />
<div v-if="!isEmpty(state.buy.info)" class="order-info">
<div class="order-info-item">{{ t('module.Order title') }}{{ state.buy.info.title }}</div>
<div class="order-info-item">{{ t('module.Order No') }}{{ state.buy.info.sn }}</div>
<div class="order-info-item">{{ t('module.Purchase user') }}{{ specificUserName(baAccount) }}</div>
<div class="order-info-item">
{{ t('module.Order price') }}
<span v-if="!state.buy.info.purchased" class="order-price">
{{ currency(state.buy.info.amount, state.buy.info.pay.money ? 1 : 0) }}
</span>
<span v-else class="order-price">{{ t('module.Purchased, can be installed directly') }}</span>
</div>
<div class="order-footer">
<div class="order-agreement">
<el-checkbox v-model="state.buy.agreement" size="small" label="" />
<span>
{{ t('module.Understand and agree') }}
<a
href="https://doc.buildadmin.com/guide/other/appendix/templateAgreement.html"
target="_blank"
rel="noopener noreferrer"
>
{{ t('module.Module purchase and use agreement') }}
</a>
</span>
</div>
<div class="order-info-buttons">
<template v-if="!state.buy.info.purchased">
<el-button
v-if="state.buy.info.pay.score"
:loading="state.loading.common"
@click="onPay('score')"
v-blur
type="warning"
>
{{ t('module.Point payment') }}
</el-button>
<template v-if="state.buy.info.pay.money">
<el-button :loading="state.loading.common" @click="onPay('balance')" v-blur type="warning">
{{ t('module.Balance payment') }}
</el-button>
<el-button :loading="state.loading.common" @click="onPay('wx')" v-blur type="success">
{{ t('module.Wechat payment') }}
</el-button>
<el-button :loading="state.loading.common" @click="onPay('zfb')" v-blur type="primary">
{{ t('module.Alipay payment') }}
</el-button>
</template>
</template>
<el-button
v-else
:loading="state.loading.common"
@click="onPreInstallModule(state.buy.info.uid, state.buy.info.id, true)"
v-blur
type="warning"
>
{{ t('module.Install now') }}
</el-button>
</div>
</div>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { isEmpty } from 'lodash-es'
import { useI18n } from 'vue-i18n'
import { currency, onPay, onPreInstallModule, specificUserName } from '../index'
import { state } from '../store'
import { useBaAccount } from '/@/stores/baAccount'
const { t } = useI18n()
const baAccount = useBaAccount()
</script>
<style scoped lang="scss">
.order-info {
padding: 10px 0;
.order-info-item {
padding-top: 6px;
}
.order-footer {
padding-top: 20px;
.order-agreement {
display: flex;
align-items: center;
font-size: 12px;
span {
padding-left: 4px;
}
a {
text-decoration: none;
color: var(--el-color-primary);
}
}
.order-info-buttons {
padding-top: 15px;
display: flex;
align-items: center;
justify-content: center;
}
}
}
@media screen and (max-width: 1440px) {
:deep(.buy-dialog) {
--el-dialog-width: 26% !important;
}
}
@media screen and (max-width: 1280px) {
:deep(.buy-dialog) {
--el-dialog-width: 32% !important;
}
}
@media screen and (max-width: 1024px) {
:deep(.buy-dialog) {
--el-dialog-width: 70% !important;
}
}
</style>

View File

@@ -1,73 +0,0 @@
<template>
<div>
<el-dialog
:close-on-press-escape="state.common.quickClose"
:title="state.common.dialogTitle"
:close-on-click-modal="state.common.quickClose"
v-model="state.dialog.common"
class="common-dialog"
>
<el-scrollbar :height="500">
<!-- 公共dialog形式的loading -->
<div
v-if="state.common.type == 'loading'"
v-loading="true"
:element-loading-text="state.common.loadingTitle ? $t('module.stateTitle ' + state.common.loadingTitle) : ''"
:key="state.common.loadingComponentKey"
class="common-loading"
></div>
<!-- 选择安装版本 -->
<SelectVersion v-if="state.common.type == 'selectVersion'" />
<!-- 安装冲突 -->
<InstallConflict v-if="state.common.type == 'installConflict'" />
<!-- 禁用冲突 -->
<ConfirmFileConflict v-if="state.common.type == 'disableConfirmConflict'" />
<!-- 安装/禁用结束 -->
<CommonDone v-if="state.common.type == 'done'" />
<!-- 上传安装 -->
<UploadInstall v-if="state.common.type == 'uploadInstall'" />
</el-scrollbar>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { state } from '../store'
import CommonDone from './commonDone.vue'
import SelectVersion from './commonSelectVersion.vue'
import ConfirmFileConflict from './confirmFileConflict.vue'
import InstallConflict from './installConflict.vue'
import UploadInstall from './uploadInstall.vue'
</script>
<style scoped lang="scss">
:deep(.common-dialog) .el-dialog__body {
padding: 10px 20px;
}
.common-dialog {
height: 500px;
}
.common-loading {
height: 400px;
}
@media screen and (max-width: 1440px) {
:deep(.common-dialog) {
--el-dialog-width: 60% !important;
}
}
@media screen and (max-width: 1280px) {
:deep(.common-dialog) {
--el-dialog-width: 80% !important;
}
}
@media screen and (max-width: 1024px) {
:deep(.common-dialog) {
--el-dialog-width: 92% !important;
}
}
</style>

View File

@@ -1,288 +0,0 @@
<template>
<div class="install-done">
<div class="install-done-title">
<span v-if="state.common.moduleState == moduleInstallState.INSTALLED">
{{ t('module.Congratulations, module installation is complete') }}
</span>
<span v-else-if="state.common.moduleState == moduleInstallState.DISABLE">{{ t('module.Module is disabled') }}</span>
<span v-else-if="state.common.moduleState == moduleInstallState.DEPENDENT_WAIT_INSTALL">
{{ t('module.Congratulations, the code of the module is ready') }}
</span>
<span v-else>{{ t('module.Unknown state') }}</span>
</div>
<div class="install-tis-box">
<div v-if="state.common.dependInstallState != 'none'" class="depend-box">
<div class="depend-loading" v-if="state.common.dependInstallState == 'executing'" v-loading="true"></div>
<div class="depend-tis">
<div v-if="state.common.dependInstallState == 'executing'">
<span class="color-red">{{ t('module.Do not refresh the page!') }}</span>
<span v-if="state.common.moduleState == moduleInstallState.DISABLE">
{{ t('module.New adjustment of dependency detected') }}
</span>
<span v-else-if="state.common.moduleState == moduleInstallState.DEPENDENT_WAIT_INSTALL">
{{ t('module.This module adds new dependencies') }}
</span>
<span></span>
<span>
{{ t('module.The built-in terminal of the system is automatically installing these dependencies, please wait~') }}
</span>
<span class="span-a" @click="showTerminal">{{ t('module.View progress') }}</span>
</div>
<div v-if="state.common.dependInstallState == 'success'" class="color-green">
{{ t('module.Dependency installation completed~') }}
</div>
<div v-if="state.common.dependInstallState == 'fail'" class="exec-fail color-red">
{{ t('module.Dependency installation fail 1') }}
<span class="span-a" @click="showTerminal">{{ t('module.Dependency installation fail 2') }}</span>
{{ t('module.Dependency installation fail 3') }}
<el-link target="_blank" type="primary" href="https://doc.buildadmin.com/guide/install/manualOperation.html">
{{ t('module.Dependency installation fail 4') }}
</el-link>
</div>
</div>
</div>
<div v-else-if="state.common.moduleState == moduleInstallState.INSTALLED" class="depend-tis">
{{ t('module.This module does not add new dependencies') }}
</div>
<div v-else>{{ t('module.There is no adjustment for system dependency') }}</div>
</div>
<div v-if="state.common.dependInstallState == 'fail'" class="install-tis-box text-align-center">
<div class="install-tis">
{{ t('module.Dependency installation fail 5') }}
<span class="span-a" @click="onConfirmDepend">
{{ t('module.Dependency installation fail 6') }}
</span>
{{ t('module.Dependency installation fail 7') }}
<span class="dependency-installation-fail-tips">
{{ t('module.dependency-installation-fail-tips') }}
</span>
</div>
</div>
<div class="install-tis-box">
<div class="install-tis">
{{ t('module.please') }}
{{ state.common.moduleState == moduleInstallState.DISABLE ? '' : t('module.After installation 1') }}
{{ t('module.Manually clean up the system and browser cache') }}
</div>
</div>
<div class="install-tis-box">
<div class="install-form">
<FormItem
:label="
(state.common.moduleState == moduleInstallState.DISABLE ? '' : t('module.After installation 2')) +
t('module.Automatically execute reissue command?')
"
v-model="form.rebuild"
type="radio"
:input-attr="{
border: true,
content: { 0: t('module.no'), 1: t('module.yes') },
}"
/>
</div>
</div>
<div class="install-tis-box" v-if="hotUpdateState.dirtyFile && state.common.moduleState != moduleInstallState.DISABLE">
<div class="install-form">
<el-form-item :label="t('module.After installation 2') + t('module.Restart Vite hot server')">
<BaInput
v-model="form.reloadHotServer"
type="radio"
:attr="{
class: 'hot-server-input',
border: true,
content: {
0: t('vite.Later') + t('module.Manual restart'),
1: t('module.Restart Now'),
},
}"
/>
<el-popover :width="360" placement="top">
<div>
<div class="el-popover__title">{{ t('vite.Reload hot server title') }}</div>
<div class="reload-hot-server-content">
<p>
<span>{{ t('vite.Reload hot server tips 1') }}</span>
<span>{{ t(`vite.Close type ${hotUpdateState.closeType}`) }}</span>
<span>{{ t('vite.Reload hot server tips 2') }}</span>
</p>
<p>{{ t('vite.Reload hot server tips 3') }}</p>
<p>{{ t('module.Restart Vite hot server tips') }}</p>
</div>
</div>
<template #reference>
<div class="block-help hot-server-tips">{{ t('module.detailed information') }}</div>
</template>
</el-popover>
</el-form-item>
</div>
</div>
<div class="install-done-button-box">
<el-button
v-blur
:disabled="state.common.dependInstallState != 'executing' || state.common.moduleState == moduleInstallState.INSTALLED ? false : true"
size="large"
class="install-done-button"
type="primary"
:loading="state.loading.common"
@click="onSubmitInstallDone"
>
{{ state.common.moduleState == moduleInstallState.DISABLE ? t('Complete') : t('module.End of installation') }}
</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ElMessageBox } from 'element-plus'
import { reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { onRefreshTableData } from '../index'
import { state } from '../store'
import { moduleInstallState } from '../types'
import { dependentInstallComplete } from '/@/api/backend/module'
import BaInput from '/@/components/baInput/index.vue'
import FormItem from '/@/components/formItem/index.vue'
import { taskStatus } from '/@/stores/constant/terminalTaskStatus'
import { useTerminal } from '/@/stores/terminal'
import { hotUpdateState, reloadServer } from '/@/utils/vite'
const { t } = useI18n()
const terminal = useTerminal()
const form = reactive({
rebuild: 0,
reloadHotServer: 0,
})
const showTerminal = () => {
terminal.toggle(true)
}
const onSubmitInstallDone = () => {
state.dialog.common = false
if (form.rebuild == 1) {
terminal.toggle(true)
terminal.addTaskPM('web-build', false, '', (res: number) => {
if (res == taskStatus.Success) {
terminal.toggle(false)
if (form.reloadHotServer == 1 && state.common.moduleState != moduleInstallState.DISABLE) {
reloadServer('modules')
}
}
})
} else if (form.reloadHotServer == 1 && state.common.moduleState != moduleInstallState.DISABLE) {
reloadServer('modules')
}
}
const onConfirmDepend = () => {
ElMessageBox.confirm(t('module.Is the command that failed on the WEB terminal executed manually or in other ways successfully?'), t('Reminder'), {
confirmButtonText: t('module.yes'),
cancelButtonText: t('Cancel'),
type: 'warning',
}).then(() => {
state.loading.common = true
dependentInstallComplete(state.common.uid).then(() => {
onRefreshTableData()
state.loading.common = false
state.common.dependInstallState = 'success'
})
})
}
</script>
<style scoped lang="scss">
.install-done-title {
font-size: var(--el-font-size-extra-large);
color: var(--el-color-success);
text-align: center;
}
.text-align-center {
text-align: center;
}
.install-tis-box {
padding: 20px;
margin: 20px auto;
width: 70%;
border: 1px solid var(--el-border-color-lighter);
border-radius: var(--el-border-radius-base);
display: flex;
align-items: center;
justify-content: center;
.dependency-installation-fail-tips {
display: block;
font-size: var(--el-font-size-extra-small);
text-align: center;
padding-top: 5px;
color: var(--el-text-color-regular);
}
}
.depend-box {
display: flex;
align-items: center;
justify-content: center;
}
.install-tis {
color: var(--el-color-warning);
}
.depend-loading {
width: 30px;
height: 30px;
margin-right: 36px;
}
.span-a {
color: var(--el-color-primary);
cursor: pointer;
&:hover {
color: var(--el-color-primary-light-5);
}
}
.install-form :deep(.ba-input-item-radio) {
margin-bottom: 0;
}
.exec-fail {
display: flex;
}
.color-red {
color: var(--el-color-danger);
}
.color-green {
color: var(--el-color-success);
}
.install-done-button-box {
display: flex;
align-items: center;
justify-content: center;
.install-done-button {
width: 120px;
}
}
.reload-hot-server-content {
font-size: var(--el-font-size-small);
p {
margin-bottom: 6px;
}
}
.hot-server-input {
width: 100%;
}
.hot-server-tips {
width: auto;
cursor: pointer;
}
@media screen and (max-width: 1600px) {
:deep(.install-tis-box) {
width: 76%;
}
}
@media screen and (max-width: 1280px) {
:deep(.install-tis-box) {
width: 80%;
}
}
@media screen and (max-width: 900px) {
:deep(.install-tis-box) {
width: 96%;
flex-wrap: wrap;
}
}
</style>

View File

@@ -1,134 +0,0 @@
<template>
<div>
<el-table :data="state.common.versions" class="w100" stripe>
<el-table-column property="version" align="center" :label="t('module.Version')" />
<el-table-column property="short_describe" :show-overflow-tooltip="true" align="center" :label="t('module.Description')" />
<el-table-column property="available_system_version_text" align="center" :label="t('module.Available system version')">
<template #default="scope">
<div v-if="scope.row.available_system_version && state.sysVersion">
<div class="available-system-version">
<Icon
v-if="compareVersion(scope.row.available_system_version)"
name="el-icon-CircleCheckFilled"
color="var(--el-color-success)"
size="14"
/>
<Icon v-else name="el-icon-CircleCloseFilled" size="14" color="var(--el-color-danger)" />
<div class="available-system-version-text">{{ scope.row.available_system_version_text }}</div>
</div>
</div>
<div v-else>-</div>
</template>
</el-table-column>
<el-table-column property="createtime_text" align="center" :label="t('Create time')" />
<el-table-column :label="t('module.Install')" align="center" :min-width="140">
<template #default="scope">
<div v-if="scope.row.downloadable">
<div v-if="isLocalModuleVersion(scope.row.version)" class="renewal-text">{{ t('module.Current installed version') }}</div>
<div v-else-if="!compareVersion(scope.row.available_system_version)">{{ t('module.Insufficient system version') }}</div>
<div v-else>
<el-button type="primary" @click="onInstall(scope.row.uid, scope.row.order_id, scope.row.version)">
{{ t('module.Click to install') }}
</el-button>
</div>
</div>
<el-tooltip
v-else
effect="dark"
:content="
t('module.Order expiration time', {
expiration_time: timeFormat(scope.row.order_expiration_time),
create_time: timeFormat(scope.row.createtime),
})
"
placement="top"
>
<div class="renewal">
<div class="renewal-text">{{ t('module.Versions released beyond the authorization period') }}</div>
<el-button @click="onBuy(true)" type="danger">{{ t('module.Renewal') }}</el-button>
</div>
</el-tooltip>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup lang="ts">
import { memoize } from 'lodash-es'
import { useI18n } from 'vue-i18n'
import { execInstall, onBuy, showCommonLoading } from '../index'
import { state } from '../store'
import { timeFormat } from '/@/utils/common'
const { t } = useI18n()
const formatSysVersion = memoize((sysVersion: string) => {
// 去掉 sysVersion 开头的 v
sysVersion = sysVersion.replace(/^v/, '')
// 以 . 分割,不足两位的补 0
sysVersion = sysVersion
.split('.')
.map((item) => {
return item.padStart(2, '0')
})
.join('')
return parseInt(sysVersion)
})
const isLocalModuleVersion = (version: string) => {
const localModule = state.installedModule.find((item) => {
return item.uid == state.common.uid
})
if (localModule) {
version = version.replace(/^v/, '')
localModule.version = localModule.version.replace(/^v/, '')
if (version == localModule.version) {
return true
}
}
return false
}
const compareVersion = memoize((version: string): boolean => {
const sysVersion = formatSysVersion(state.sysVersion)
return sysVersion > parseInt(version)
})
const onInstall = (uid: string, id: number, version: string) => {
state.dialog.common = true
state.common.dialogTitle = t('module.Install')
showCommonLoading('download')
// 关闭其他弹窗
state.dialog.baAccount = false
state.dialog.buy = false
state.dialog.goodsInfo = false
execInstall(uid, id, version, state.common.update)
}
</script>
<style scoped lang="scss">
.renewal {
display: flex;
align-items: center;
justify-content: center;
.renewal-text {
font-size: 12px;
margin-right: 6px;
}
}
.available-system-version {
display: flex;
align-items: center;
justify-content: center;
.available-system-version-text {
margin-left: 4px;
}
}
</style>

View File

@@ -1,90 +0,0 @@
<template>
<div>
<div class="confirm-file-conflict">
<template v-if="state.common.disableConflictFile.length">
<div class="conflict-title">{{ $t('module.File conflict') }}</div>
<el-alert :closable="false" :center="true" :title="$t('module.Update warning')" class="alert-warning" type="warning"></el-alert>
<el-table :data="state.common.disableConflictFile" stripe border :style="{ width: '100%', marginBottom: '20px' }">
<el-table-column prop="file" :label="$t('module.Conflict file')" />
</el-table>
</template>
<template v-if="state.common.disableDependConflict.length > 0">
<div class="conflict-title">{{ $t('module.The module declares the added dependencies') }}</div>
<el-table :data="state.common.disableDependConflict" stripe border style="width: 100%">
<el-table-column prop="env" :label="$t('module.environment')">
<template #default="scope">
<span v-if="scope.row.env">{{ $t('module.env ' + scope.row.env) }}</span>
</template>
</el-table-column>
<el-table-column prop="dependTitle" :label="$t('module.Dependencies')" />
<el-table-column prop="solution" width="200" :label="$t('module.Treatment scheme')" align="center">
<template #default="scope">
<el-select v-model="scope.row.solution">
<el-option :label="$t('Delete')" value="delete"></el-option>
<el-option :label="$t('module.retain')" value="retain"></el-option>
</el-select>
</template>
</el-table-column>
</el-table>
</template>
</div>
<div class="center-buttons">
<el-button
v-blur
class="center-button"
:loading="state.loading.common"
:disabled="state.loading.common"
size="large"
type="primary"
@click="onDisable(true)"
>
{{ $t('module.Confirm to disable the module') }}
</el-button>
<el-button v-blur class="center-button" size="large" @click="cancelDisable()"> {{ $t('Cancel') }} </el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { state } from '../store'
import { onDisable } from '../index'
const cancelDisable = () => {
state.dialog.common = false
state.goodsInfo.enable = true
}
</script>
<style scoped lang="scss">
.confirm-file-conflict {
min-height: 400px;
}
.conflict-alert {
width: 500px;
margin: 0 auto;
}
.alert-warning {
margin: 20px auto;
width: 500px;
}
.depend-conflict-tips {
text-align: center;
}
.text-bold {
font-weight: bold;
}
.conflict-title {
font-size: var(--el-font-size-large);
text-align: center;
margin-bottom: 20px;
}
.center-buttons {
display: flex;
justify-content: center;
margin: 20px auto;
}
.center-button {
width: 120px;
}
</style>

View File

@@ -1,610 +0,0 @@
<template>
<div>
<el-dialog v-model="state.dialog.goodsInfo" class="goods-info-dialog" :title="t('module.detailed information')" width="60%">
<el-scrollbar v-loading="state.loading.goodsInfo" :key="state.goodsInfo.uid" :height="500">
<div class="goods-info">
<div class="goods-images">
<el-carousel height="300" v-if="state.goodsInfo.images" indicator-position="outside">
<el-carousel-item class="goods-image-item" v-for="(image, idx) in state.goodsInfo.images" :key="idx">
<el-image fit="contain" :preview-src-list="state.goodsInfo.images" :preview-teleported="true" :src="image"></el-image>
</el-carousel-item>
</el-carousel>
</div>
<div class="goods-basic">
<h4 class="goods-basic-title">{{ state.goodsInfo.title }}</h4>
<div class="goods-tag">
<el-tag v-for="(tag, idx) in state.goodsInfo.tags" :key="idx" :type="tag.type ? tag.type : 'primary'">
{{ tag.name }}
</el-tag>
</div>
<div class="basic-item">
<div class="basic-item-title">{{ t('module.Price') }}</div>
<div class="basic-item-price">
{{
typeof state.goodsInfo.currency_select != 'undefined'
? currency(state.goodsInfo.present_price, state.goodsInfo.currency_select)
: '-'
}}
</div>
</div>
<div class="basic-item">
<div class="basic-item-title">{{ t('module.Last updated') }}</div>
<div class="basic-item-content">{{ state.goodsInfo.updatetime ? timeFormat(state.goodsInfo.updatetime) : '-' }}</div>
</div>
<div class="basic-item">
<div class="basic-item-title">{{ t('module.Published on') }}</div>
<div class="basic-item-content">{{ state.goodsInfo.createtime ? timeFormat(state.goodsInfo.createtime) : '-' }}</div>
</div>
<div v-if="!installButtonState.stateSwitch.includes(state.goodsInfo.state)" class="basic-item">
<div class="basic-item-title">{{ t('module.amount of downloads') }}</div>
<div class="basic-item-content">{{ state.goodsInfo.downloads ? state.goodsInfo.downloads : '-' }}</div>
</div>
<div class="basic-item">
<div class="basic-item-title">{{ t('module.Module classification') }}</div>
<div class="basic-item-content">{{ state.goodsInfo.category ? state.goodsInfo.category.name : '-' }}</div>
</div>
<div class="basic-item">
<div class="basic-item-title">{{ t('module.Module documentation') }}</div>
<div class="basic-item-content">
<el-link
type="primary"
class="basic-item-link"
v-if="state.goodsInfo.docs"
target="_blank"
:href="`https://doc.buildadmin.com/md/${state.goodsInfo.docs.name ? state.goodsInfo.docs.name : state.goodsInfo.docs.id}`"
rel="noopener noreferrer"
>
{{ t('module.Click to access') }}
</el-link>
<span v-else>-</span>
</div>
</div>
<div class="basic-item">
<div class="basic-item-title">{{ t('module.Developer Homepage') }}</div>
<div class="basic-item-content">
<el-link
type="primary"
class="basic-item-link"
v-if="state.goodsInfo.author_url"
target="_blank"
:href="state.goodsInfo.author_url"
rel="noopener noreferrer"
>
{{ t('module.Click to access') }}
</el-link>
<span v-else>-</span>
</div>
</div>
<div v-if="installButtonState.stateSwitch.includes(state.goodsInfo.state)" class="basic-item">
<div class="basic-item-title">{{ t('module.Module status') }}</div>
<div class="basic-item-content">
<el-switch
@change="onChangeState"
:loading="state.loading.common"
:disabled="state.loading.common"
v-model="state.goodsInfo.enable"
/>
</div>
</div>
<div class="basic-buttons">
<el-dropdown
v-if="
(!state.goodsInfo.purchased || installButtonState.InstallNow.includes(state.goodsInfo.state)) &&
state.goodsInfo.demo &&
state.goodsInfo.demo.length > 0
"
>
<el-button class="basic-button-demo" type="primary">
<span class="basic-button-dropdown-span">{{ t('module.View demo') }}</span>
<Icon color="#ffffff" size="16" name="el-icon-ArrowDown" />
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="(demo, idx) in state.goodsInfo.demo"
:key="idx"
@click="openDemo(demo.link, demo.image ? false : true)"
class="basic-button-dropdown-item"
>
<el-popover
placement="right"
:title="t('module.Code scanning Preview')"
trigger="hover"
:disabled="demo.image ? false : true"
:width="174"
>
<template #reference>
<div class="demo-item-title">
<Icon :name="demo.icon" size="14" color="var(--el-color-primary)" />{{ demo.title }}
</div>
</template>
<div class="demo-image">
<img :src="demo.image" alt="" />
</div>
</el-popover>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button
v-if="
!state.goodsInfo.purchased &&
installButtonState.buy.includes(state.goodsInfo.state) &&
state.goodsInfo.type == 'online'
"
@click="onBuy(false)"
v-blur
class="basic-button-item"
type="danger"
>
{{ t('module.Buy now') }}
</el-button>
<el-button
v-if="
(state.goodsInfo.state == moduleInstallState.UNINSTALLED && state.goodsInfo.purchased) ||
state.goodsInfo.state == moduleInstallState.WAIT_INSTALL
"
@click="
onPreInstallModule(
state.goodsInfo.uid,
state.goodsInfo.purchased,
state.goodsInfo.state == moduleInstallState.WAIT_INSTALL ? false : true
)
"
:loading="state.loading.common"
v-blur
class="basic-button-item"
type="success"
>
{{ t('module.Install now') }}
</el-button>
<el-button
v-if="installButtonState.continueInstallation.includes(state.goodsInfo.state)"
@click="onPreInstallModule(state.goodsInfo.uid, state.goodsInfo.purchased, false)"
:loading="state.loading.common"
v-blur
class="basic-button-item"
type="success"
>
{{ t('module.continue installation') }}
</el-button>
<el-button
v-if="installButtonState.alreadyInstalled.includes(state.goodsInfo.state)"
v-blur
:disabled="true"
class="basic-button-item"
>
{{ t('module.installed') }} v{{ state.goodsInfo.version }}
</el-button>
<el-button
v-if="state.goodsInfo.type == 'local' && !installButtonState.alreadyInstalled.includes(state.goodsInfo.state)"
v-blur
:disabled="true"
class="basic-button-item"
>
{{ t('module.Local module') }} v{{ state.goodsInfo.version }}
</el-button>
<el-button
v-if="state.goodsInfo.new_version && installButtonState.updateButton.includes(state.goodsInfo.state)"
@click="onUpdate(state.goodsInfo.uid, state.goodsInfo.purchased)"
v-loading="state.loading.common"
v-blur
class="basic-button-item"
type="success"
>
{{ t('module.to update') }}
</el-button>
<el-button
v-if="installButtonState.stateSwitch.includes(state.goodsInfo.state)"
v-loading="state.loading.common"
@click="unInstall(state.goodsInfo.uid)"
v-blur
class="basic-button-item"
type="danger"
>
{{ t('module.uninstall') }}
</el-button>
</div>
</div>
<div v-if="!isEmpty(state.goodsInfo.developer)" class="goods-developer">
<div class="developer-header">
<el-avatar :size="60" :src="state.goodsInfo.developer.avatar" />
<div class="developer-name">
<h3 class="developer-nickname">{{ state.goodsInfo.developer.nickname }}</h3>
<div class="developer-group">
{{ state.goodsInfo.developer.group ? state.goodsInfo.developer.group : '-' }}
</div>
</div>
</div>
<div v-if="state.goodsInfo.qq" class="developer-contact">
<h4 class="developer-info-title">{{ t('module.Contact developer') }}</h4>
<div class="contact-item">
<a
rel="noopener noreferrer"
target="_blank"
:href="'http://wpa.qq.com/msgrd?v=3&uin=' + state.goodsInfo.qq + '&site=qq&menu=yes'"
>
<span>QQ{{ state.goodsInfo.qq }}</span>
</a>
</div>
</div>
<div class="developer-recommend">
<h4 class="developer-info-title">{{ t('module.Other works of developers') }}</h4>
<div v-if="state.goodsInfo.developer.goods.length > 0" class="recommend-goods">
<div
v-for="(goods_item, idx) in state.goodsInfo.developer.goods"
:key="idx"
@click="showInfo(goods_item.uid)"
class="recommend-goods-item"
>
<el-image fit="contain" class="recommend-goods-logo" :src="goods_item.logo"> </el-image>
<div class="recommend-goods-title">{{ goods_item.title }}</div>
</div>
</div>
<div v-else class="data-empty">{{ t('module.There are no more works') }}</div>
</div>
</div>
</div>
<div class="goods-detail ba-markdown" v-html="state.goodsInfo.detail_editor"></div>
<div class="goods-version">
<h1>{{ t('module.Update Log') }}</h1>
<div class="version-timeline" v-if="state.goodsInfo.version_log">
<el-timeline>
<el-timeline-item
v-for="(version, idx) in state.goodsInfo.version_log"
:key="idx"
:timestamp="timeFormat(version.createtime)"
placement="top"
:color="idx == 0 ? 'var(--el-color-success)' : ''"
>
<el-card class="version-card" shadow="hover">
<template #header>
<div class="version-card-header">
<h2>{{ version.title }}</h2>
<span class="version-short-describe">{{ version.short_describe }}</span>
</div>
</template>
<div
class="version-detail ba-markdown"
v-html="version.describe ? version.describe : t('module.No detailed update log')"
></div>
</el-card>
</el-timeline-item>
</el-timeline>
</div>
<div v-else class="empty-update-log">{{ $t('module.No detailed update log') }}</div>
</div>
</el-scrollbar>
</el-dialog>
<Buy />
<Pay />
</div>
</template>
<script setup lang="ts">
import { ElMessageBox } from 'element-plus'
import { isEmpty } from 'lodash-es'
import { useI18n } from 'vue-i18n'
import { currency, onBuy, onDisable, onEnable, onPreInstallModule, onRefreshTableData, showInfo } from '../index'
import { state } from '../store'
import { moduleInstallState } from '../types'
import Buy from './buy.vue'
import Pay from './pay.vue'
import { getInstallState, postUninstall } from '/@/api/backend/module'
import { useBaAccount } from '/@/stores/baAccount'
import { timeFormat } from '/@/utils/common'
const installButtonState = {
InstallNow: [moduleInstallState.UNINSTALLED, moduleInstallState.WAIT_INSTALL],
continueInstallation: [moduleInstallState.CONFLICT_PENDING, moduleInstallState.DEPENDENT_WAIT_INSTALL],
alreadyInstalled: [moduleInstallState.INSTALLED],
stateSwitch: [
moduleInstallState.INSTALLED,
moduleInstallState.CONFLICT_PENDING,
moduleInstallState.DEPENDENT_WAIT_INSTALL,
moduleInstallState.DISABLE,
],
updateButton: [moduleInstallState.WAIT_INSTALL, moduleInstallState.INSTALLED, moduleInstallState.DISABLE],
buy: [moduleInstallState.UNINSTALLED],
}
const { t } = useI18n()
const openDemo = (url: string, open: boolean) => {
if (!open || !url) return
window.open(url)
}
const onChangeState = () => {
if (state.goodsInfo.enable) {
onEnable(state.goodsInfo.uid)
} else {
state.common.disableParams = {
uid: state.goodsInfo.uid,
state: 0,
}
onDisable()
}
}
const unInstall = (uid: string) => {
state.loading.common = true
postUninstall(uid)
.then(() => {
onRefreshTableData()
state.dialog.goodsInfo = false
})
.finally(() => {
state.loading.common = false
})
}
const onUpdate = (uid: string, order: number) => {
// 无有效订单
if (!order) {
ElMessageBox.confirm(t('module.No module purchase order was found'), t('Reminder'), {
confirmButtonText: t('Confirm'),
cancelButtonText: t('Cancel'),
type: 'warning',
})
.then(() => {
onBuy(true)
})
.catch(() => {})
return
}
// 未登录
const baAccount = useBaAccount()
if (!baAccount.token) {
state.dialog.baAccount = true
return
}
state.loading.common = true
getInstallState(uid)
.then((res) => {
if (res.data.state == moduleInstallState.DISABLE) {
onPreInstallModule(uid, order, true, true)
} else {
ElMessageBox.confirm(t('module.You need to disable this module before updating Do you want to disable it now?'), t('Reminder'), {
confirmButtonText: t('module.Disable and update'),
cancelButtonText: t('Cancel'),
type: 'warning',
})
.then(() => {
state.common.disableParams = {
uid: uid,
state: 0,
update: 1,
}
onDisable()
})
.catch(() => {})
}
})
.finally(() => {
state.loading.common = false
})
}
</script>
<style scoped lang="scss">
:deep(.goods-info-dialog) .el-dialog__body {
padding: 0px 20px;
}
.demo-image,
.demo-image img {
width: 150px;
height: 150px;
}
.demo-item-title {
display: flex;
align-items: center;
.icon {
margin-right: 6px;
}
}
.goods-info {
display: flex;
position: relative;
.goods-images {
max-width: 41%;
width: 300px;
.goods-image-item {
display: flex;
align-items: center;
justify-content: center;
}
:deep(.el-carousel__indicators) {
line-height: 10px;
.el-carousel__indicator {
padding: 0 var(--el-carousel-indicator-padding-horizontal);
}
}
}
.goods-basic {
position: relative;
.goods-basic-title {
padding-bottom: 20px;
}
flex: 1;
padding: 0 10px;
.basic-item {
display: flex;
align-items: center;
padding: 4px 0;
.basic-item-title {
font-size: var(--el-font-size-base);
color: var(--el-text-color-secondary);
width: 80px;
}
.basic-item-price {
font-size: 16px;
color: var(--el-color-danger);
}
.basic-item-content {
font-size: var(--el-font-size-base);
color: var(--el-text-color-regular);
}
}
.basic-button-dropdown-span {
padding-right: 6px;
}
.basic-buttons {
padding-top: 6px;
}
.basic-button-demo {
margin-right: 10px;
}
}
.goods-developer {
width: 20%;
border-left: 1px solid var(--ba-bg-color);
padding: 10px;
position: absolute;
right: 0;
.developer-header {
display: flex;
align-items: center;
justify-content: center;
.developer-name {
padding-left: 10px;
flex: 1;
.developer-group {
padding-top: 5px;
font-size: var(--el-font-size-extra-small);
color: var(--el-text-color-secondary);
}
}
}
.developer-info-title {
color: var(--el-text-color-secondary);
padding-top: 15px;
line-height: 20px;
text-align: center;
}
.contact-item {
cursor: pointer;
padding-left: 10px;
line-height: 30px;
text-align: center;
a {
color: var(--el-color-primary);
text-decoration: none;
}
}
.recommend-goods-item {
display: flex;
align-items: center;
margin: 4px 0;
cursor: pointer;
padding: 6px;
&:hover {
background-color: var(--ba-bg-color);
}
.recommend-goods-logo {
width: 42px;
border-radius: var(--el-border-radius-base);
}
.recommend-goods-title {
flex: 1;
margin-left: 6px;
font-size: var(--el-font-size-small);
display: block;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
line-height: 15px;
height: 28px;
}
}
.developer-recommend {
.data-empty {
font-size: var(--el-font-size-extra-small);
color: var(--el-text-color-secondary);
text-align: center;
padding: 6px;
}
}
}
.el-carousel__item:nth-child(2n) {
background-color: #99a9bf;
}
.basic-item-link {
font-size: var(--el-font-size-small);
}
}
.basic-button-item {
--el-loading-spinner-size: 22px;
}
.goods-detail {
width: 80%;
}
.goods-version {
width: 80%;
h1 {
margin: 1.4em 0 0.8em;
font-weight: 700;
font-size: var(--el-font-size-large);
text-transform: uppercase;
color: var(--el-color-primary);
}
.version-timeline {
padding-left: 2px;
:deep(.el-card__body) {
padding: 10px 20px 20px 20px;
}
}
.version-card {
border: 1px solid var(--el-color-info-light-9);
}
.version-card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
}
.empty-update-log {
display: flex;
justify-content: center;
color: var(--el-color-info);
}
/* 商品详情弹窗-s */
@media screen and (max-width: 1440px) {
:deep(.goods-info-dialog) {
--el-dialog-width: 65% !important;
}
}
@media screen and (max-width: 1280px) {
:deep(.goods-info-dialog) {
--el-dialog-width: 80% !important;
}
}
@media screen and (max-width: 1024px) {
:deep(.goods-info-dialog) {
--el-dialog-width: 92% !important;
}
}
/* 商品详情弹窗-e */
@media screen and (max-width: 865px) {
.goods-info .goods-developer {
display: none;
}
}
@media screen and (max-width: 540px) {
.goods-info {
flex-wrap: wrap;
.goods-images {
max-width: 100%;
width: 100%;
}
}
.goods-detail {
padding-top: 15px;
}
}
</style>

View File

@@ -1,93 +0,0 @@
<template>
<div>
<div class="install-conflict">
<template v-if="state.common.fileConflict.length > 0">
<div class="install-title">{{ $t('module.File conflict') }}</div>
<el-table :data="state.common.fileConflict" stripe border :style="{ width: '100%' }">
<el-table-column prop="newFile" :label="$t('module.new file')" />
<el-table-column prop="oldFile" :label="$t('module.Existing files')" />
<el-table-column prop="solution" width="200" :label="$t('module.Treatment scheme')" align="center">
<template #default="scope">
<el-select v-model="scope.row.solution">
<el-option :label="$t('module.Backup and overwrite existing files')" value="cover"></el-option>
<el-option :label="$t('module.Discard new file')" value="discard"></el-option>
</el-select>
</template>
</el-table-column>
</el-table>
</template>
<template v-if="state.common.dependConflict.length > 0">
<div class="install-title">{{ $t('module.Dependency conflict') }}</div>
<el-table :data="state.common.dependConflict" stripe border style="width: 100%">
<el-table-column prop="env" :label="$t('module.environment')">
<template #default="scope">
<span v-if="scope.row.env">{{ $t('module.env ' + scope.row.env) }}</span>
</template>
</el-table-column>
<el-table-column prop="newDepend" :label="$t('module.New dependency')" />
<el-table-column prop="oldDepend" :label="$t('module.Existing dependencies')" />
<el-table-column prop="solution" width="200" :label="$t('module.Treatment scheme')" align="center">
<template #default="scope">
<el-select v-model="scope.row.solution">
<el-option :label="$t('module.Overwrite existing dependencies')" value="cover"></el-option>
<el-option :label="$t('module.Do not use new dependencies')" value="discard"></el-option>
</el-select>
</template>
</el-table-column>
</el-table>
</template>
</div>
<el-button
v-blur
class="install-done-button"
:loading="state.loading.common"
:disabled="state.loading.common"
size="large"
type="primary"
@click="onSubmitConflictHandle"
>
{{ $t('Confirm') }}
</el-button>
</div>
</template>
<script setup lang="ts">
import { state } from '../store'
import { execInstall } from '../index'
const onSubmitConflictHandle = () => {
state.loading.common = true
let fileConflict: anyObj = {},
dependConflict: anyObj = {}
for (const key in state.common.fileConflict) {
fileConflict[state.common.fileConflict[key].oldFile] = state.common.fileConflict[key]['solution']
}
for (const key in state.common.dependConflict) {
if (typeof dependConflict[state.common.dependConflict[key].env] == 'undefined') {
dependConflict[state.common.dependConflict[key].env] = {}
}
dependConflict[state.common.dependConflict[key].env][state.common.dependConflict[key].depend] = state.common.dependConflict[key]['solution']
}
execInstall(state.common.uid, 0, '', false, {
dependConflict: dependConflict,
fileConflict: fileConflict,
conflictHandle: true,
})
}
</script>
<style scoped lang="scss">
.install-conflict {
min-height: 400px;
}
.install-title {
font-size: var(--el-font-size-large);
text-align: center;
padding: 20px;
}
.install-done-button {
display: block;
margin: 20px auto;
width: 120px;
}
</style>

View File

@@ -1,150 +0,0 @@
<template>
<div>
<el-dialog
v-model="state.dialog.pay"
:close-on-press-escape="false"
:close-on-click-modal="false"
:destroy-on-close="true"
class="pay-dialog"
top="20vh"
width="680px"
>
<div>
<div class="header-box">
<img
class="pay-logo"
:src="'https://buildadmin.com/static/images/' + (state.common.payType == 'wx' ? 'wechat-pay.png' : 'alipay.png')"
alt=""
/>
</div>
<div class="pay-box">
<div class="left">
<div class="order-info">
<div class="order-info-items">{{ t('module.Order title') }}{{ state.payInfo.info.title }}</div>
<div class="order-info-items">{{ t('module.Order No') }}{{ state.payInfo.info.sn }}</div>
<div class="order-info-items">{{ t('module.Purchase user') }}{{ specificUserName(baAccount) }}</div>
<div class="order-info-items">
<span>{{ t('module.Order price') }}</span>
<span class="rmb-symbol">
<span class="amount">{{ state.payInfo.info.amount }}</span>
</span>
</div>
</div>
<div class="pay_qr">
<QrcodeVue v-if="state.common.payType == 'wx'" :value="state.payInfo.pay.code_url" :size="220" :margin="0" level="H" />
<iframe
v-if="state.common.payType == 'zfb'"
:srcdoc="state.payInfo.pay.code_url"
frameborder="no"
border="0"
marginwidth="0"
marginheight="0"
scrolling="no"
width="220"
height="220"
style="overflow: hidden"
>
</iframe>
<div v-if="state.payInfo.pay.status == 'success'" class="pay-success">
<Icon name="fa fa-check" color="var(--el-color-success)" size="30" />
</div>
</div>
<el-alert class="qr-tips" :closable="false" type="success" center>
<div class="qr-tips-content">
<Icon color="var(--el-color-success)" :name="state.common.payType == 'wx' ? 'fa fa-wechat' : 'fa fa-buysellads'" />
<span v-if="state.common.payType == 'wx'">{{ t('module.Use WeChat to scan QR code for payment') }}</span>
<span v-if="state.common.payType == 'zfb'">{{ t('module.Use Alipay to scan QR code for payment') }}</span>
</div>
</el-alert>
</div>
<div class="right">
<img
class="pay-logo"
:src="'https://buildadmin.com/static/images/screenshot-' + (state.common.payType == 'wx' ? 'wechat.png' : 'alipay.png')"
alt=""
/>
</div>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import QrcodeVue from 'qrcode.vue'
import { useI18n } from 'vue-i18n'
import { specificUserName } from '../index'
import { state } from '../store'
import { useBaAccount } from '/@/stores/baAccount'
const { t } = useI18n()
const baAccount = useBaAccount()
</script>
<style scoped lang="scss">
:deep(.pay-dialog) .el-dialog__body {
padding: var(--el-dialog-padding-primary);
padding-top: 0;
}
.header-box {
.pay-logo {
height: 30px;
user-select: none;
}
padding-bottom: 10px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.pay-box {
display: flex;
.right {
margin-left: auto;
}
}
.order-info {
padding: 15px 0;
.order-info-items {
line-height: 24px;
.rmb-symbol {
color: var(--el-color-danger);
font-size: 13px;
}
.amount {
color: var(--el-color-danger);
font-size: 16px;
}
}
}
.pay_qr {
display: flex;
margin-bottom: 25px;
justify-content: center;
position: relative;
.pay-success {
border-radius: 50%;
border: 3px solid rgba($color: #67c23a, $alpha: 0.8);
padding: 5px;
position: absolute;
left: calc(50% - 15px);
top: calc(50% - 15px);
}
}
.qr-tips {
margin-top: 15px;
.qr-tips-content {
.icon {
margin-right: 5px;
}
display: flex;
align-items: center;
justify-content: center;
}
}
@media screen and (max-width: 700px) {
:deep(.pay-dialog) {
--el-dialog-width: 96% !important;
}
.pay-box .right {
display: none;
}
}
</style>

View File

@@ -1,125 +0,0 @@
<template>
<div>
<el-alert class="ba-table-alert" v-if="state.table.remark" :title="state.table.remark" type="info" show-icon />
<div class="modules-header">
<div class="table-header-buttons">
<el-button :title="$t('Refresh')" @click="onRefreshTableData" v-blur color="#40485b" type="info">
<Icon name="fa fa-refresh" color="#ffffff" size="14" />
</el-button>
<el-button-group class="ml10">
<el-button @click="uploadInstall" :title="t('module.Upload zip package for installation')" v-blur type="primary">
<Icon name="fa fa-upload" color="#ffffff" size="14" />
<span class="table-header-operate-text">{{ t('module.Upload installation') }}</span>
</el-button>
<el-button
@click="localModules"
:class="state.table.onlyLocal ? 'local-active' : ''"
:title="t('module.Uploaded / installed modules')"
v-blur
type="primary"
>
<Icon name="fa fa-desktop" color="#ffffff" size="14" />
<span class="table-header-operate-text">{{ t('module.Local module') }}</span>
</el-button>
</el-button-group>
<el-button-group class="ml10 publish-module-button-group">
<el-button @click="navigateTo('https://doc.buildadmin.com/senior/module/start.html')" v-blur type="success">
<Icon name="fa fa-cloud-upload" color="#ffffff" size="14" />
<span class="table-header-operate-text">{{ t('module.Publishing module') }}</span>
</el-button>
<el-button @click="navigateTo('https://doc.buildadmin.com/guide/other/appendix/getPoints.html')" v-blur type="success">
<Icon name="fa fa-rocket" color="#ffffff" size="14" />
<span class="table-header-operate-text">{{ t('module.Get points') }}</span>
</el-button>
</el-button-group>
<el-button v-blur class="ml10 ba-account-button" @click="onShowBaAccount" type="success">
<Icon name="fa fa-user-o" color="#ffffff" size="14" />
<span class="table-header-operate-text">{{ t('layouts.Member information') }}</span>
</el-button>
</div>
<div class="table-search">
<el-input
v-model="state.table.params.quickSearch"
class="xs-hidden"
@input="onSearchInput"
:placeholder="t('module.Search is actually very simple')"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { debounce } from 'lodash-es'
import { useI18n } from 'vue-i18n'
import { loadData, onRefreshTableData } from '../index'
import { state } from '../store'
const { t } = useI18n()
const localModules = () => {
state.table.onlyLocal = !state.table.onlyLocal
loadData()
}
const onShowBaAccount = () => {
state.dialog.baAccount = true
}
const onSearchInput = debounce(() => {
state.table.modulesEbak[state.table.params.activeTab] = undefined
loadData()
}, 500)
const navigateTo = (url: string) => {
window.open(url, '_blank')
}
const uploadInstall = () => {
state.dialog.common = true
state.common.quickClose = true
state.common.dialogTitle = t('module.Upload installation')
state.common.type = 'uploadInstall'
}
</script>
<style scoped lang="scss">
.ml10 {
margin-left: 10px;
}
.ba-table-alert {
border: none;
}
.modules-header {
display: flex;
align-items: center;
padding: 10px;
margin-bottom: 10px;
background-color: var(--ba-bg-color-overlay);
border-radius: var(--el-border-radius-base);
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.table-header-operate-text {
padding-left: 6px;
}
.table-search {
margin-left: auto;
}
.local-active {
border-color: var(--el-button-active-border-color);
background-color: var(--el-button-active-bg-color);
}
@media screen and (max-width: 1300px) {
.ba-account-button {
display: block;
margin: 10px 0 0 0;
}
}
@media screen and (max-width: 1100px) {
.publish-module-button-group {
display: block;
margin: 10px 0 0 0;
}
}
</style>

View File

@@ -1,168 +0,0 @@
<template>
<div>
<el-tabs
v-loading="state.loading.table"
:element-loading-text="$t('module.Loading')"
v-model="state.table.params.activeTab"
type="border-card"
class="store-tabs"
@tab-change="onTabChange"
>
<el-tab-pane v-for="cat in state.table.category" :name="cat.id.toString()" :key="cat.id" :label="cat.name" class="store-tab-pane">
<template v-if="state.table.modules[state.table.params.activeTab] && state.table.modules[state.table.params.activeTab].length > 0">
<el-row :gutter="15" class="goods">
<el-col
:xs="12"
:sm="8"
:md="8"
:lg="6"
:xl="4"
v-for="item in state.table.modules[state.table.params.activeTab]"
:key="item.uid"
class="goods-col"
>
<div @click="showInfo(item.uid)" class="goods-item suspension">
<el-image
loading="lazy"
fit="cover"
class="goods-img"
:src="item.logo ? item.logo : fullUrl('/static/images/local-module-logo.png')"
/>
<div class="goods-footer">
<div class="goods-tag" v-if="item.tags && item.tags.length > 0">
<el-tag v-for="(tag, idx) in item.tags" :type="tag.type ? tag.type : 'primary'" :key="idx">
{{ tag.name }}
</el-tag>
</div>
<div class="goods-title">
{{ item.title }}
</div>
<div class="goods-data">
<span class="download-count">
<Icon name="fa fa-download" color="#c0c4cc" size="13" /> {{ item.downloads ? item.downloads : '-' }}
</span>
<span v-if="item.state === moduleInstallState.UNINSTALLED" class="goods-price">
<span class="original-price">{{ currency(item.original_price, item.currency_select) }}</span>
<span class="current-price">{{ currency(item.present_price, item.currency_select) }}</span>
</span>
<div v-else class="goods-price">
<el-tag effect="dark" :type="item.stateTag.type ? item.stateTag.type : 'primary'">
{{ item.stateTag.text }}
</el-tag>
</div>
</div>
</div>
</div>
</el-col>
</el-row>
</template>
<el-empty v-else class="modules-empty" :description="$t('module.No more')" />
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { currency, loadData, showInfo } from '../index'
import { state } from '../store'
import { moduleInstallState } from '../types'
import { fullUrl } from '/@/utils/common'
const onTabChange = () => {
loadData()
}
</script>
<style scoped lang="scss">
.suspension:hover {
z-index: 1;
}
.goods-item {
display: block;
margin-bottom: 15px;
padding-bottom: 40px;
position: relative;
border-radius: var(--el-border-radius-base);
background-color: var(--el-fill-color-extra-light);
box-shadow: var(--el-box-shadow-light);
cursor: pointer;
}
.goods-img {
display: block;
border-radius: var(--el-border-radius-base);
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.modules-empty {
width: 100%;
}
.goods-footer {
display: block;
overflow: hidden;
padding: 10px 10px 0 10px;
.goods-tag {
min-height: 60px;
}
.goods-title {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding-top: 6px;
font-size: 14px;
line-height: 18px;
}
.goods-data {
display: flex;
width: calc(100% - 20px);
position: absolute;
bottom: 0;
align-items: baseline;
padding: 10px 0;
.download-count {
color: var(--el-text-color-placeholder);
}
.goods-price {
margin-left: auto;
}
.original-price {
font-size: 13px;
color: var(--el-text-color-placeholder);
text-decoration: line-through;
}
.current-price {
font-size: 16px;
color: var(--el-color-danger);
padding-left: 6px;
}
}
}
.el-tabs--border-card {
border: none;
box-shadow: var(--el-box-shadow-light);
border-radius: var(--el-border-radius-base);
}
.el-tabs--border-card :deep(.el-tabs__header) {
background-color: var(--ba-bg-color);
border-bottom: none;
border-radius: var(--el-border-radius-base);
}
.el-tabs--border-card :deep(.el-tabs__item.is-active) {
border: 1px solid transparent;
}
.el-tabs--border-card :deep(.el-tabs__nav-wrap) {
border-radius: var(--el-border-radius-base);
}
:deep(.store-tabs) .el-tabs__content {
padding: 15px 15px 0 15px;
min-height: 350px;
}
@media screen and (max-width: 520px) {
.goods {
.goods-col {
max-width: 100%;
flex-basis: 100%;
}
}
}
</style>

View File

@@ -1,71 +0,0 @@
<template>
<div class="upload-install">
<div class="tips">
<div class="title">{{ $t('module.Local upload warning') }}</div>
<div class="tip-item">1. {{ $t('module.The module can modify and add system files') }}</div>
<div class="tip-item">2. {{ $t('module.The module can execute sql commands and codes') }}</div>
<div class="tip-item">3. {{ $t('module.The module can install new front and rear dependencies') }}</div>
</div>
<el-upload class="upload-module" :show-file-list="false" accept=".zip" drag :auto-upload="false" @change="uploadModule">
<template v-if="state.uploadState == 'wait-file'">
<Icon size="50px" color="#909399" name="el-icon-UploadFilled" />
<div class="el-upload__text">
{{ $t('module.Drag the module package file here') }} <em>{{ $t('module.Click me to upload') }}</em>
</div>
</template>
<el-result v-else icon="success" :sub-title="$t('module.Uploaded, installation is about to start, please wait')"></el-result>
</el-upload>
</div>
</template>
<script setup lang="ts">
import type { UploadFile } from 'element-plus'
import { reactive } from 'vue'
import { onPreInstallModule } from '../index'
import { upload } from '/@/api/backend/module'
import { fileUpload } from '/@/api/common'
const state = reactive({
uploadState: 'wait-file',
})
const uploadModule = (file: UploadFile) => {
if (!file || !file.raw) return
let fd = new FormData()
fd.append('file', file.raw!)
fileUpload(fd, {}, true).then((res) => {
if (res.code == 1) {
upload(res.data.file.url)
.then((res) => {
state.uploadState = 'success'
onPreInstallModule(res.data.info.uid, 0, false, res.data.info.update ? true : false)
})
.catch(() => {
state.uploadState = 'wait-file'
})
}
})
}
</script>
<style scoped lang="scss">
.tips {
padding: 20px;
background-color: var(--el-bg-color-page);
border-radius: var(--el-border-radius-base);
max-width: 400px;
margin: 0 auto;
color: var(--el-color-danger);
.title {
font-size: var(--el-font-size-medium);
padding-bottom: 6px;
}
.tip-item {
font-size: var(--el-font-size-base);
}
}
.upload-module {
max-width: 460px;
margin: 40px auto;
}
</style>

View File

@@ -1,604 +0,0 @@
import { ElNotification } from 'element-plus'
import { isArray } from 'lodash-es'
import { state } from './store'
import { moduleInstallState, type moduleState } from './types'
import {
changeState,
createOrder,
getInstallState,
index,
info,
modules,
payCheck,
payOrder,
postInstallModule,
preDownload,
} from '/@/api/backend/module'
import { i18n } from '/@/lang/index'
import router from '/@/router/index'
import { useBaAccount } from '/@/stores/baAccount'
import { SYSTEM_ZINDEX } from '/@/stores/constant/common'
import { taskStatus } from '/@/stores/constant/terminalTaskStatus'
import type { UserInfo } from '/@/stores/interface'
import { useTerminal } from '/@/stores/terminal'
import { fullUrl } from '/@/utils/common'
import { uuid } from '/@/utils/random'
import { changeListenDirtyFileSwitch, closeHotUpdate } from '/@/utils/vite'
export const loadData = () => {
state.loading.table = true
if (!state.table.indexLoaded) {
loadIndex().then(() => {
getModules()
})
} else {
getModules()
}
}
export const onRefreshTableData = () => {
state.table.indexLoaded = false
for (const key in state.table.modulesEbak) {
state.table.modulesEbak[key] = undefined
}
loadData()
}
const loadIndex = () => {
return index().then((res) => {
state.table.indexLoaded = true
state.sysVersion = res.data.sysVersion
state.nuxtVersion = res.data.nuxtVersion
state.installedModule = res.data.installed
const installedModuleUids: string[] = []
const installedModuleVersions: { uid: string; version: string }[] = []
if (res.data.installed) {
state.installedModule.forEach((item) => {
installedModuleUids.push(item.uid)
installedModuleVersions.push({
uid: item.uid,
version: item.version,
})
})
state.installedModuleUids = installedModuleUids
state.installedModuleVersions = installedModuleVersions
}
})
}
const getModules = () => {
if (typeof state.table.modulesEbak[state.table.params.activeTab] != 'undefined') {
state.table.modules[state.table.params.activeTab] = modulesOnlyLocalHandle(state.table.modulesEbak[state.table.params.activeTab])
state.loading.table = false
return
}
const params: anyObj = {}
for (const key in state.table.params) {
if (state.table.params[key] != '') {
params[key] = state.table.params[key]
}
}
const moduleUids: string[] = []
params['installed'] = state.installedModuleVersions
params['sysVersion'] = state.sysVersion
modules(params)
.then((res) => {
if (params.activeTab == 'all') {
res.data.rows.forEach((item: anyObj) => {
moduleUids.push(item.uid)
})
state.installedModule.forEach((item) => {
if (moduleUids.indexOf(item.uid) === -1) {
if (state.table.params.quickSearch) {
if (item.title.includes(state.table.params.quickSearch)) res.data.rows.push(item)
} else {
res.data.rows.push(item)
}
}
})
}
state.table.remark = res.data.remark
state.table.modulesEbak[params.activeTab] = res.data.rows.map((item: anyObj) => {
const idx = state.installedModuleUids.indexOf(item.uid)
if (idx !== -1) {
item.state = state.installedModule[idx].state
item.title = state.installedModule[idx].title
item.version = state.installedModule[idx].version
item.website = state.installedModule[idx].website
item.stateTag = moduleStatus(item.state)
if (!isArray(item.tags)) item.tags = []
item.tags.push({
name: `${i18n.global.t('module.installed')} v${state.installedModule[idx].version}`,
type: 'primary',
})
} else {
item.state = 0
}
if (item.new_version && item.tags) {
item.tags.push({
name: i18n.global.t('module.New version'),
type: 'danger',
})
}
return item
})
state.table.modules[params.activeTab] = modulesOnlyLocalHandle(state.table.modulesEbak[params.activeTab])
state.table.category = res.data.category
})
.finally(() => {
state.loading.table = false
})
}
export const showInfo = (uid: string) => {
state.dialog.goodsInfo = true
state.loading.goodsInfo = true
const localItem = state.installedModule.find((item) => {
return item.uid == uid
})
info({
uid: uid,
localVersion: localItem?.version,
sysVersion: state.sysVersion,
})
.then((res) => {
if (localItem) {
if (res.data.info.type == 'local') {
res.data.info = localItem
res.data.info.images = [fullUrl('/static/images/local-module-logo.png')]
res.data.info.type = 'local' // 纯本地模块
} else {
res.data.info.type = 'online'
res.data.info.state = localItem.state
res.data.info.version = localItem.version
}
res.data.info.enable = localItem.state === moduleInstallState.DISABLE ? false : true
} else {
res.data.info.state = 0
res.data.info.type = 'online'
}
state.goodsInfo = res.data.info
})
.catch((err) => {
if (loginExpired(err)) {
state.dialog.goodsInfo = false
}
})
.finally(() => {
state.loading.goodsInfo = false
})
}
/**
* 支付订单
* @param renew 是否是续费订单
*/
export const onBuy = (renew = false) => {
state.dialog.buy = true
state.loading.buy = true
createOrder({
goods_id: state.goodsInfo.id,
})
.then((res) => {
state.loading.buy = false
state.buy.renew = renew
state.buy.info = res.data.info
})
.catch((err) => {
state.dialog.buy = false
state.loading.buy = false
loginExpired(err)
})
}
export const onPay = (payType: 'score' | 'wx' | 'balance' | 'zfb') => {
state.common.payType = payType
state.loading.common = true
payOrder(state.buy.info.id, payType)
.then((res) => {
// 关闭其他弹窗
state.dialog.buy = false
state.dialog.goodsInfo = false
if (payType == 'wx' || payType == 'zfb') {
// 显示支付二维码
state.dialog.pay = true
state.payInfo = res.data
// 轮询获取支付状态
const timer = setInterval(() => {
payCheck(state.payInfo.info.sn)
.then(() => {
state.payInfo.pay.status = 'success'
clearInterval(timer)
if (state.buy.renew) {
showInfo(res.data.info.uid)
} else {
onPreInstallModule(res.data.info.uid, res.data.info.id, true)
}
state.dialog.pay = false
})
.catch(() => {})
}, 3000)
} else {
if (state.buy.renew) {
showInfo(res.data.info.uid)
} else {
onPreInstallModule(res.data.info.uid, res.data.info.id, true)
}
}
})
.catch((err) => {
loginExpired(err)
})
.finally(() => {
state.loading.common = false
})
}
export const showCommonLoading = (loadingTitle: moduleState['common']['loadingTitle']) => {
state.common.type = 'loading'
state.common.loadingTitle = loadingTitle
state.common.loadingComponentKey = uuid()
}
/**
* 模块预安装
*/
export const onPreInstallModule = (uid: string, id: number, needGetInstallableVersion: boolean, update: boolean = false) => {
state.dialog.common = true
showCommonLoading('init')
state.common.dialogTitle = i18n.global.t('module.Install')
const nextStep = (moduleState: number) => {
if (needGetInstallableVersion) {
// 获取模块版本列表
showCommonLoading('getInstallableVersion')
preDownload({
uid,
orderId: id,
sysVersion: state.sysVersion,
nuxtVersion: state.nuxtVersion,
installed: state.installedModuleUids,
})
.then((res) => {
state.common.uid = uid
state.common.update = update
state.common.type = 'selectVersion'
state.common.dialogTitle = i18n.global.t('module.Select Version')
state.common.versions = res.data.versions
// 关闭其他弹窗
state.dialog.baAccount = false
state.dialog.buy = false
state.dialog.goodsInfo = false
})
.catch((res) => {
if (loginExpired(res)) return
state.dialog.common = false
})
} else {
// 立即安装(上传安装、继续安装)
showCommonLoading(moduleState === moduleInstallState.UNINSTALLED ? 'download' : 'install')
execInstall(uid, id, '', update)
// 关闭其他弹窗
state.dialog.baAccount = false
state.dialog.buy = false
state.dialog.goodsInfo = false
}
}
if (update) {
nextStep(moduleInstallState.DISABLE)
} else {
// 获取安装状态
getInstallState(uid).then((res) => {
if (
res.data.state === moduleInstallState.INSTALLED ||
res.data.state === moduleInstallState.DISABLE ||
res.data.state === moduleInstallState.DIRECTORY_OCCUPIED
) {
ElNotification({
type: 'error',
message:
res.data.state === moduleInstallState.INSTALLED || res.data.state === moduleInstallState.DISABLE
? i18n.global.t('module.Installation cancelled because module already exists!')
: i18n.global.t('module.Installation cancelled because the directory required by the module is occupied!'),
})
state.dialog.common = false
return
}
nextStep(res.data.state)
})
}
}
/**
* 执行安装请求,还包含启用、安装时的冲突处理
*/
export const execInstall = (uid: string, id: number, version: string = '', update: boolean = false, extend: anyObj = {}) => {
postInstallModule(uid, id, version, update, extend)
.then(() => {
state.common.dialogTitle = i18n.global.t('module.Installation complete')
state.common.moduleState = moduleInstallState.INSTALLED
state.common.type = 'done'
onRefreshTableData()
})
.catch((res) => {
if (loginExpired(res)) return
if (res.code == -1) {
state.common.uid = res.data.uid
state.common.type = 'installConflict'
state.common.dialogTitle = i18n.global.t('module.A conflict is found Please handle it manually')
state.common.fileConflict = res.data.fileConflict
state.common.dependConflict = res.data.dependConflict
} else if (res.code == -2) {
state.common.type = 'done'
state.common.uid = res.data.uid
state.common.dialogTitle = i18n.global.t('module.Wait for dependent installation')
state.common.moduleState = moduleInstallState.DEPENDENT_WAIT_INSTALL
state.common.waitInstallDepend = res.data.wait_install
state.common.dependInstallState = 'executing'
const terminal = useTerminal()
if (res.data.wait_install.includes('npm_dependent_wait_install')) {
terminal.addTaskPM('web-install', true, 'module-install:' + res.data.uid, (res: number) => {
terminalTaskExecComplete(res, 'npm_dependent_wait_install')
})
}
if (res.data.wait_install.includes('nuxt_npm_dependent_wait_install')) {
terminal.addTaskPM('nuxt-install', true, 'module-install:' + res.data.uid, (res: number) => {
terminalTaskExecComplete(res, 'nuxt_npm_dependent_wait_install')
})
}
if (res.data.wait_install.includes('composer_dependent_wait_install')) {
terminal.addTask('composer.update', true, 'module-install:' + res.data.uid, (res: number) => {
terminalTaskExecComplete(res, 'composer_dependent_wait_install')
})
}
} else if (res.code == 0) {
ElNotification({
type: 'error',
message: res.msg,
zIndex: SYSTEM_ZINDEX,
})
state.dialog.common = false
onRefreshTableData()
}
})
.finally(() => {
state.loading.common = false
})
}
const terminalTaskExecComplete = (res: number, type: string) => {
if (res == taskStatus.Success) {
state.common.waitInstallDepend = state.common.waitInstallDepend.filter((depend: string) => {
return depend != type
})
if (state.common.waitInstallDepend.length == 0) {
state.common.dependInstallState = 'success'
// 仅在命令全部执行完毕才刷新数据
if (router.currentRoute.value.name === 'moduleStore/moduleStore') {
onRefreshTableData()
}
}
} else {
const terminal = useTerminal()
terminal.toggle(true)
state.common.dependInstallState = 'fail'
// 有命令执行失败了,刷新一次数据
if (router.currentRoute.value.name === 'moduleStore/moduleStore') {
onRefreshTableData()
}
}
// 连续安装模块的情况中,首个模块的命令执行完毕时,自动启动了热更新
if (router.currentRoute.value.name === 'moduleStore/moduleStore') {
closeHotUpdate('modules')
}
}
export const onDisable = (confirmConflict = false) => {
state.loading.common = true
// 拼装依赖处理方案
if (confirmConflict) {
const dependConflict: anyObj = {}
for (const key in state.common.disableDependConflict) {
if (state.common.disableDependConflict[key]['solution'] != 'delete') {
continue
}
if (typeof dependConflict[state.common.disableDependConflict[key].env] == 'undefined') {
dependConflict[state.common.disableDependConflict[key].env] = []
}
dependConflict[state.common.disableDependConflict[key].env].push(state.common.disableDependConflict[key].depend)
}
state.common.disableParams['confirmConflict'] = 1
state.common.disableParams['dependConflictSolution'] = dependConflict
}
changeState(state.common.disableParams)
.then(() => {
ElNotification({
type: 'success',
message: i18n.global.t('module.The operation succeeds Please clear the system cache and refresh the browser ~'),
zIndex: SYSTEM_ZINDEX,
})
state.dialog.common = false
onRefreshTableData()
})
.catch((res) => {
if (res.code == -1) {
state.dialog.common = true
state.common.dialogTitle = i18n.global.t('module.Deal with conflict')
state.common.type = 'disableConfirmConflict'
state.common.disableDependConflict = res.data.dependConflict
if (res.data.conflictFile && res.data.conflictFile.length) {
const conflictFile = []
for (const key in res.data.conflictFile) {
conflictFile.push({
file: res.data.conflictFile[key],
})
}
state.common.disableConflictFile = conflictFile
}
} else if (res.code == -2) {
state.dialog.common = true
const commandsData = {
type: 'disable',
commands: res.data.wait_install,
}
state.common.uid = state.goodsInfo.uid
execCommand(commandsData)
} else if (res.code == -3) {
// 更新
onPreInstallModule(state.goodsInfo.uid, state.goodsInfo.purchased, true, true)
} else {
ElNotification({
type: 'error',
message: res.msg,
zIndex: SYSTEM_ZINDEX,
})
if (state.common.disableParams && state.common.disableParams.uid) {
showInfo(state.common.disableParams.uid)
} else {
onRefreshTableData()
}
}
})
.finally(() => {
state.loading.common = false
})
}
export const onEnable = (uid: string) => {
state.loading.common = true
changeState({
uid: uid,
state: 1,
})
.then(() => {
state.dialog.common = true
showCommonLoading('init')
state.common.dialogTitle = i18n.global.t('Enable')
execInstall(uid, 0)
state.dialog.goodsInfo = false
})
.catch((res) => {
ElNotification({
type: 'error',
message: res.msg,
zIndex: SYSTEM_ZINDEX,
})
state.loading.common = false
})
}
export const loginExpired = (res: ApiResponse) => {
const baAccount = useBaAccount()
if (res.code == 301 || res.code == 408) {
baAccount.removeToken()
state.dialog.baAccount = true
return true
}
return false
}
const modulesOnlyLocalHandle = (modules: anyObj) => {
if (!state.table.onlyLocal) return modules
return modules.filter((item: anyObj) => {
return item.installed
})
}
export const execCommand = (data: anyObj) => {
if (data.type == 'disable') {
state.dialog.common = true
state.common.type = 'done'
state.common.dialogTitle = i18n.global.t('module.Wait for dependent installation')
state.common.moduleState = moduleInstallState.DISABLE
state.common.dependInstallState = 'executing'
const terminal = useTerminal()
data.commands.forEach((item: anyObj) => {
state.common.waitInstallDepend.push(item.type)
if (item.pm) {
if (item.command == 'web-install') {
changeListenDirtyFileSwitch(false)
}
terminal.addTaskPM(item.command, true, '', (res: number) => {
terminalTaskExecComplete(res, item.type)
if (item.command == 'web-install') {
changeListenDirtyFileSwitch(true)
}
})
} else {
terminal.addTask(item.command, true, '', (res: number) => {
terminalTaskExecComplete(res, item.type)
})
}
})
}
}
export const specificUserName = (userInfo: Partial<UserInfo>) => {
return userInfo.nickname + '' + (userInfo.email || userInfo.mobile || 'ID:' + userInfo.id) + ''
}
export const currency = (price: number, val: number) => {
if (typeof price == 'undefined' || typeof val == 'undefined') {
return '-'
}
if (val == 0) {
return parseInt(price.toString()) + i18n.global.t('Integral')
} else {
return '¥' + price
}
}
export const moduleStatus = (state: number) => {
switch (state) {
case moduleInstallState.INSTALLED:
return {
type: '',
text: i18n.global.t('module.installed'),
}
case moduleInstallState.WAIT_INSTALL:
return {
type: 'success',
text: i18n.global.t('module.Wait for installation'),
}
case moduleInstallState.CONFLICT_PENDING:
return {
type: 'danger',
text: i18n.global.t('module.Conflict pending'),
}
case moduleInstallState.DEPENDENT_WAIT_INSTALL:
return {
type: 'warning',
text: i18n.global.t('module.Dependency to be installed'),
}
case moduleInstallState.DISABLE:
return {
type: 'warning',
text: i18n.global.t('Disable'),
}
default:
return {
type: 'info',
text: i18n.global.t('Unknown'),
}
}
}

View File

@@ -1,45 +0,0 @@
<template>
<div class="default-main ba-table-box">
<TableHeader />
<Tabs />
<GoodsInfo />
<CommonDialog />
<BaAccountDialog v-model="state.dialog.baAccount" :login-callback="() => (state.dialog.baAccount = false)" />
</div>
</template>
<script setup lang="ts">
import { onActivated, onDeactivated, onMounted, onUnmounted } from 'vue'
import CommonDialog from './components/commonDialog.vue'
import GoodsInfo from './components/goodsInfo.vue'
import TableHeader from './components/tableHeader.vue'
import Tabs from './components/tabs.vue'
import { loadData } from './index'
import { state } from './store'
import BaAccountDialog from '/@/layouts/backend/components/baAccount.vue'
import { closeHotUpdate, openHotUpdate } from '/@/utils/vite'
defineOptions({
name: 'moduleStore/moduleStore',
})
onMounted(() => {
loadData()
closeHotUpdate('modules')
})
onActivated(() => {
closeHotUpdate('modules')
})
onDeactivated(() => {
openHotUpdate('modules')
})
onUnmounted(() => {
openHotUpdate('modules')
})
</script>
<style scoped lang="scss">
:deep(.goods-tag) .el-tag {
margin: 0 6px 6px 0;
}
</style>

View File

@@ -1,63 +0,0 @@
import { reactive } from 'vue'
import { uuid } from '/@/utils/random'
import type { moduleState } from './types'
export const state = reactive<moduleState>({
loading: {
buy: false,
table: true,
common: false,
install: false,
goodsInfo: false,
},
dialog: {
buy: false,
pay: false,
common: false,
goodsInfo: false,
baAccount: false,
},
table: {
remark: '',
modules: [],
modulesEbak: [],
category: [],
onlyLocal: false,
indexLoaded: false,
params: {
quickSearch: '',
activeTab: 'all',
},
},
payInfo: {},
goodsInfo: {},
buy: {
info: {},
renew: false,
agreement: true,
},
common: {
uid: '',
moduleState: 0,
quickClose: false,
type: 'loading',
dialogTitle: '',
fileConflict: [],
dependConflict: [],
loadingTitle: 'init',
loadingComponentKey: uuid(),
waitInstallDepend: [],
dependInstallState: 'none',
disableConflictFile: [],
disableDependConflict: [],
disableParams: {},
payType: 'wx',
update: false,
versions: [],
},
sysVersion: '',
nuxtVersion: '',
installedModule: [],
installedModuleUids: [],
installedModuleVersions: [],
})

View File

@@ -1,78 +0,0 @@
export enum moduleInstallState {
UNINSTALLED,
INSTALLED,
WAIT_INSTALL,
CONFLICT_PENDING,
DEPENDENT_WAIT_INSTALL,
DIRECTORY_OCCUPIED,
DISABLE,
}
export interface moduleInfo {
uid: string
title: string
version: string
state: number
website: string
stateTag: {
type: string
text: string
}
}
export interface moduleState {
loading: {
buy: boolean
table: boolean
common: boolean
install: boolean
goodsInfo: boolean
}
dialog: {
buy: boolean
pay: boolean
common: boolean
goodsInfo: boolean
baAccount: boolean
}
table: {
remark: string
modules: anyObj
modulesEbak: anyObj
category: anyObj
onlyLocal: boolean
indexLoaded: boolean
params: anyObj
}
payInfo: anyObj
goodsInfo: anyObj
buy: {
info: anyObj
renew: boolean
agreement: boolean
}
common: {
uid: string
moduleState: number
quickClose: boolean
type: 'loading' | 'installConflict' | 'done' | 'disableConfirmConflict' | 'uploadInstall' | 'selectVersion'
dialogTitle: string
fileConflict: anyObj[]
dependConflict: anyObj[]
loadingTitle: 'init' | 'download' | 'install' | 'getInstallableVersion'
loadingComponentKey: string
waitInstallDepend: string[]
dependInstallState: 'none' | 'executing' | 'success' | 'fail'
disableConflictFile: { file: string }[]
disableDependConflict: anyObj[]
disableParams: anyObj
payType: 'score' | 'wx' | 'balance' | 'zfb'
update: boolean
versions: anyObj[]
}
sysVersion: string
nuxtVersion: string
installedModule: moduleInfo[]
installedModuleUids: string[]
installedModuleVersions: { uid: string; version: string }[]
}

View File

@@ -38,6 +38,11 @@
<el-form-item :label="t('routine.adminInfo.user name')">
<el-input disabled v-model="state.adminInfo.username"></el-input>
</el-form-item>
<el-form-item :label="t('routine.adminInfo.Google Authenticator')" v-if="siteConfig.totpEnable">
<el-tag :type="state.adminInfo.totp_bound ? 'success' : 'info'">
{{ state.adminInfo.totp_bound ? t('routine.adminInfo.Bound') : t('routine.adminInfo.Not bound') }}
</el-tag>
</el-form-item>
<el-form-item :label="t('routine.adminInfo.User nickname')" prop="nickname">
<el-input :placeholder="t('routine.adminInfo.Please enter a nickname')" v-model="state.adminInfo.nickname"></el-input>
</el-form-item>
@@ -112,6 +117,7 @@ import { uuid } from '../../../utils/random'
import { buildValidatorData } from '/@/utils/validate'
import { fileUpload } from '/@/api/common'
import { useAdminInfo } from '/@/stores/adminInfo'
import { useSiteConfig } from '/@/stores/siteConfig'
import { isEmpty } from 'lodash-es'
defineOptions({
@@ -122,6 +128,7 @@ const { t } = useI18n()
const formRef = useTemplateRef('formRef')
const adminInfoStore = useAdminInfo()
const siteConfig = useSiteConfig()
const state: {
adminInfo: anyObj

View File

@@ -3,6 +3,7 @@ interface Window {
lazy: number
unique: number
tokenRefreshing: boolean
authRedirecting: boolean
requests: Function[]
eventSource: EventSource
loadLangHandle: Record<string, any>

View File

@@ -6,6 +6,7 @@ type TableRenderer =
| 'customTemplate'
| 'date'
| 'datetime'
| 'failReason'
| 'icon'
| 'image'
| 'images'

View File

@@ -27,11 +27,10 @@ const viteConfig = ({ mode }: ConfigEnv): UserConfig => {
server: {
port: parseInt(VITE_PORT),
open: VITE_OPEN != 'false',
// 开发时把 /api、/admin、/install 代理到 webman避免跨域
// 开发时把 /api、/admin 代理到 webman避免跨域
proxy: {
'/api': { target: VITE_PROXY_TARGET || 'http://localhost:6969', changeOrigin: true },
'/admin': { target: VITE_PROXY_TARGET || 'http://localhost:6969', changeOrigin: true },
'/install': { target: VITE_PROXY_TARGET || 'http://localhost:6969', changeOrigin: true },
},
},
build: {