Compare commits

...

30 Commits

Author SHA1 Message Date
0fdc1e2e88 优化每日推送接口 2026-04-08 13:57:38 +08:00
186af5a55f 更新推送订单接口新增start_time和end_time 2026-04-08 10:28:40 +08:00
6bec4e7758 修复签名生成bug 2026-04-07 10:46:32 +08:00
f9e4e61d93 修复签名生成bug 2026-04-07 10:45:07 +08:00
8b6727dac1 修复签名生成bug 2026-04-07 10:42:50 +08:00
b97d33a24f 优化统一订单页面翻译 2026-04-03 11:07:38 +08:00
6f12afcd10 优化页面翻译,优化统一订单页面审核操作 2026-04-03 10:59:56 +08:00
5ab85d1d53 优化同意订单页面和推送订单到playx的功能 2026-04-03 10:39:40 +08:00
941f0f4a8c 优化推送订单功能 2026-04-03 10:15:43 +08:00
520e950dc5 优化接口以及后台页面样式 2026-03-31 15:37:32 +08:00
2868899253 优化接口/api/v1/temLogin报错 2026-03-31 09:39:02 +08:00
8305dbfcfd 对接文档-PlayX 调用积分商城接口说明 2026-03-30 18:35:07 +08:00
1cd5c3142d 优化页面和模型 2026-03-30 18:33:24 +08:00
2686c54781 优化首页和收货地址管理 2026-03-30 17:37:28 +08:00
e38fd660e2 优化访问接口口跳转地址 2026-03-30 16:32:43 +08:00
6c402d4221 优化每日推送接口/api/v1/playx/daily-push 2026-03-30 16:19:14 +08:00
ae7d7940d9 API-优化每日推送接口 2026-03-30 15:41:24 +08:00
c2ce8085c2 API接口-优化/创建保存jwt 2026-03-30 15:19:22 +08:00
2d561f81b5 优化项目修复管理员登录提示权限不足报错 2026-03-30 15:17:47 +08:00
7db09b119e [积分商城]收获地址管理-优化 2026-03-30 14:45:14 +08:00
d9dc31e388 移除渠道管理 2026-03-30 14:45:09 +08:00
179d67cb0e 修复跨域报错 2026-03-30 14:00:38 +08:00
43a6c9ee47 修复跨域报错 2026-03-30 13:53:56 +08:00
e08239dacf 修复跨域报错 2026-03-30 13:51:01 +08:00
a0d114fbc4 修复跨域报错 2026-03-30 12:45:48 +08:00
e4e5a5cae2 修复跨域报错 2026-03-30 12:17:54 +08:00
8669a20dc8 修复跨域报错 2026-03-30 12:13:20 +08:00
09ef8edd15 修复跨域报错 2026-03-30 11:59:08 +08:00
4a42899bfe [积分商城]优化对接API 2026-03-30 11:47:32 +08:00
b30ef21780 [积分商城]优化页面样式 2026-03-27 11:28:57 +08:00
146 changed files with 4978 additions and 3322 deletions

View File

@@ -24,10 +24,27 @@ PLAYX_POINTS_TO_CASH_RATIO=0.1
PLAYX_RETURN_RATIO=0.1
# 解锁比例:今日可领取上限 = yesterday_total_deposit * unlock_ratio
PLAYX_UNLOCK_RATIO=0.1
# Daily Push 签名校验密钥(建议从部署系统注入,避免写入代码/仓库)
# Daily Push 签名校验密钥(HMAC建议从部署系统注入,避免写入代码/仓库)
PLAYX_DAILY_PUSH_SECRET=
# 合作方回调 JWT 验签密钥HS256与对端私发密钥一致与上一项可同时配置则两种均需通过
PLAYX_PARTNER_JWT_SECRET=
# Agent authtoken/api/v1/authTokenJWT 签名密钥;留空则使用下方 buildadmin.token.key
AGENT_AUTH_JWT_SECRET=
# token 会话缓存过期时间(秒)
PLAYX_SESSION_EXPIRE_SECONDS=3600
# PlayX API商城调用 PlayX 时使用)
PLAYX_API_BASE_URL=
PLAYX_API_SECRET_KEY=
# 推送订单url
PLAYX_ANGPOW_IMPORT_BASE_URL=https://ss2-staging2.ttwd8.com
# 推送订单接口
PLAYX_ANGPOW_IMPORT_PATH=/cashmarket/v3/merchant-api/angpow-imports
# 商户编码merchant_code
PLAYX_ANGPOW_MERCHANT_CODE=plx
# HMAC 密钥(与对端一致)
PLAYX_ANGPOW_IMPORT_AUTH_KEY=
# HTTPSCA 证书包绝对路径(推荐下载 https://curl.se/ca/cacert.pem 后填写,解决 cURL error 60
PLAYX_ANGPOW_IMPORT_CACERT=
# 是否校验 SSL1=校验0=不校验,仅本地调试,生产勿用)
PLAYX_ANGPOW_IMPORT_VERIFY_SSL=1

View File

@@ -191,7 +191,7 @@ php webman migrate
## 六、路由说明
- **后台 API**`/admin/{module}.{Controller}/{action}`
- 示例:`/admin/mall.Player/index` → `app\admin\controller\mall\Player::index`
- 示例:`/admin/mall.User/index` → `app\admin\controller\mall\User::index`
- **前台 API**`/api/...`
- **安装**`/api/Install/...`
@@ -205,7 +205,7 @@ php webman migrate
| 语言 key 命名错误 | `quick Search Fields` 改为 `quickSearchFields` |
| 编辑时密码必填 | 编辑时密码可选,仅新增时必填;后端支持密码加密与重置 |
| 多余表单校验 | 移除 `create_time`、`update_time` 的表单校验 |
| mall_player 表缺失 | 新增迁移文件 `20250318120000_mall_player.php` |
| mall_user 与 PlayX 资产口径混用 | 新增重构迁移并改为 `mall_user` 一对一扩展资产表 |
---

View File

@@ -5,6 +5,10 @@ declare(strict_types=1);
namespace app\admin\controller;
use app\common\controller\Backend;
use app\common\model\MallClaimLog;
use app\common\model\MallOrder;
use app\common\model\MallUserAsset;
use support\think\Db;
use Webman\Http\Request;
use support\Response;
@@ -15,8 +19,78 @@ class Dashboard extends Backend
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$now = time();
$todayStart = strtotime(date('Y-m-d', $now) . ' 00:00:00');
$yesterdayStart = $todayStart - 86400;
$newPlayersToday = MallUserAsset::where('create_time', '>=', $todayStart)
->where('create_time', '<=', $now)
->count();
$yesterdayPointsClaimed = MallClaimLog::where('create_time', '>=', $yesterdayStart)
->where('create_time', '<', $todayStart)
->sum('claimed_amount');
$yesterdayRedeemQuery = MallOrder::where('create_time', '>=', $yesterdayStart)
->where('create_time', '<', $todayStart);
$yesterdayRedeemCount = (clone $yesterdayRedeemQuery)->count();
$yesterdayRedeemPointsCostSum = (clone $yesterdayRedeemQuery)->sum('points_cost');
$yesterdayRedeemAmountSum = (clone $yesterdayRedeemQuery)->sum('amount');
$yesterdayRedeemCompletedCount = (clone $yesterdayRedeemQuery)
->where('status', MallOrder::STATUS_COMPLETED)
->count();
$yesterdayRedeemRejectedCount = (clone $yesterdayRedeemQuery)
->where('status', MallOrder::STATUS_REJECTED)
->count();
$yesterdayRedeemByItem = Db::name('mall_order')
->alias('o')
->leftJoin('mall_item i', 'i.id = o.mall_item_id')
->where('o.create_time', '>=', $yesterdayStart)
->where('o.create_time', '<', $todayStart)
->group('o.mall_item_id, i.title')
->field([
'o.mall_item_id',
'i.title',
Db::raw('COUNT(*) as order_count'),
Db::raw('SUM(o.points_cost) as points_cost_sum'),
Db::raw('SUM(o.amount) as amount_sum'),
Db::raw('SUM(CASE WHEN o.status = "COMPLETED" THEN 1 ELSE 0 END) as completed_count'),
Db::raw('SUM(CASE WHEN o.status = "REJECTED" THEN 1 ELSE 0 END) as rejected_count'),
])
->orderRaw('order_count DESC')
->select()
->toArray();
$pendingPhysicalToShip = MallOrder::where('type', MallOrder::TYPE_PHYSICAL)
->where('status', MallOrder::STATUS_PENDING)
->count();
$grantFailedRetryableCount = MallOrder::where('type', MallOrder::TYPE_BONUS)
->where('grant_status', MallOrder::GRANT_FAILED_RETRYABLE)
->count();
return $this->success('', [
'remark' => get_route_remark()
'remark' => get_route_remark(),
'playx' => [
'time_range' => [
'today_start' => $todayStart,
'yesterday_start' => $yesterdayStart,
'now' => $now,
],
'new_players_today' => $newPlayersToday,
'yesterday_points_claimed' => $yesterdayPointsClaimed,
'yesterday_redeem' => [
'order_count' => $yesterdayRedeemCount,
'points_cost_sum' => $yesterdayRedeemPointsCostSum,
'amount_sum' => $yesterdayRedeemAmountSum,
'completed_count' => $yesterdayRedeemCompletedCount,
'rejected_count' => $yesterdayRedeemRejectedCount,
'by_item' => $yesterdayRedeemByItem,
],
'pending_physical_to_ship' => $pendingPhysicalToShip,
'grant_failed_retryable' => $grantFailedRetryableCount,
],
]);
}
}

View File

@@ -17,7 +17,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'];
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 $quickSearchField = ['username', 'nickname'];
@@ -25,8 +25,6 @@ class Admin extends Backend
protected string $dataLimitField = 'id';
protected array $withJoinTable = ['channel'];
protected function initController(Request $request): ?Response
{
$this->model = new AdminModel();
@@ -46,8 +44,7 @@ class Admin extends Backend
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->withoutField('login_failure,password,salt')
->withJoin($this->withJoinTable, $this->withJoinType ?? 'LEFT')
->visible(['channel' => ['name']])
->withJoin($this->withJoinTable ?? [], $this->withJoinType ?? 'LEFT')
->alias($alias)
->where($where)
->order($order)
@@ -81,13 +78,9 @@ class Admin extends Backend
'mobile' => 'regex:/^1[3-9]\d{9}$/|unique:admin,mobile',
'group_arr' => 'required|array',
];
if ($this->auth->isSuperAdmin()) {
$rules['channel_id'] = 'required|integer|min:1';
}
$messages = [
'username.regex' => __('Please input correct username'),
'password.regex' => __('Please input correct password'),
'channel_id.required' => __('Please select channel'),
];
Validator::make($data, $rules, $messages)->validate();
} catch (ValidationException $e) {
@@ -95,14 +88,6 @@ class Admin extends Backend
}
}
if (!$this->auth->isSuperAdmin()) {
$currentChannelId = (int) ($this->auth->model->channel_id ?? 0);
if ($currentChannelId <= 0) {
return $this->error(__('Current admin has no channel bound'));
}
$data['channel_id'] = $currentChannelId;
}
$passwd = $data['password'] ?? '';
$data = $this->excludeFields($data);
$result = false;
@@ -115,7 +100,12 @@ class Admin extends Backend
$result = $this->model->save($data);
if ($result !== false) {
$agentId = strtolower(md5($this->model->username . $this->model->id));
$this->model->where('id', $this->model->id)->update(['agent_id' => $agentId]);
$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 = [];
@@ -306,6 +296,39 @@ class Admin extends Backend
]);
}
/**
* 去掉已废弃的渠道字段,避免组合搜索生成对不存在列的条件
*/
protected function filterSearchArray(array $search): array
{
return array_values(array_filter($search, static function ($item) {
if (!is_array($item) || !isset($item['field'])) {
return true;
}
$f = (string) $item['field'];
if ($f === 'channel_id' || str_ends_with($f, '.channel_id')) {
return false;
}
if ($f === 'channel.name' || str_starts_with($f, 'channel.')) {
return false;
}
return true;
}));
}
public function queryOrderBuilder(): array
{
$order = parent::queryOrderBuilder();
foreach (array_keys($order) as $key) {
if ($key === 'channel_id' || (is_string($key) && str_contains($key, 'channel.'))) {
unset($order[$key]);
}
}
return $order;
}
private function checkGroupAuth(array $groups): ?Response
{
if ($this->auth->isSuperAdmin()) {

View File

@@ -1,110 +0,0 @@
<?php
namespace app\admin\controller\channel;
use Throwable;
use app\common\controller\Backend;
/**
* 渠道管理
*/
class Manage extends Backend
{
/**
* ChannelManage模型对象
* @var object|null
* @phpstan-var \app\common\model\ChannelManage|null
*/
protected ?object $model = null;
protected array|string $preExcludeFields = ['id', 'create_time', 'update_time', 'secret'];
protected array $withJoinTable = ['admin'];
protected string|array $quickSearchField = ['id'];
protected bool $autoFillAdminId = true;
public function initialize(): void
{
parent::initialize();
$this->model = new \app\common\model\ChannelManage();
}
/**
* 查看
* @throws Throwable
*/
public function index(\Webman\Http\Request $request): \support\Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if ($request->get('select') || $request->post('select')) {
return $this->select($request);
}
/**
* 1. withJoin 不可使用 alias 方法设置表别名,别名将自动使用关联模型名称(小写下划线命名规则)
* 2. 以下的别名设置了主表别名,同时便于拼接查询参数等
* 3. paginate 数据集可使用链式操作 each(function($item, $key) {}) 遍历处理
*/
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->withJoin($this->withJoinTable, $this->withJoinType)
->visible(['admin' => ['username']])
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
return $this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
/**
* 白名单(页面按钮规则,用于菜单规则中配置按钮权限)
* 实际编辑通过 edit 接口提交 ip_white 字段
*/
public function whitelist(\Webman\Http\Request $request): \support\Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
return $this->success();
}
/**
* 渠道下拉选择(供 remoteSelect 使用)
*/
public function select(\Webman\Http\Request $request): \support\Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->field('id,name,title')
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
return $this->success('', [
'list' => $res->items(),
'total' => $res->total(),
]);
}
/**
* add、edit、del、sortable 已由父类 Backend 实现,无需重写即可直接使用
* 若需重写,请确保调用 initializeBackend($request) 并传入 Request 参数
* 若模型有 admin_id 字段需自动填充,可设置 protected bool $autoFillAdminId = true
*/
}

View File

@@ -19,7 +19,7 @@ class Address extends Backend
protected array|string $preExcludeFields = ['id', 'create_time', 'update_time'];
protected array $withJoinTable = ['mallUser'];
protected array $withJoinTable = ['playxUserAsset'];
protected string|array $quickSearchField = ['id'];
@@ -52,10 +52,10 @@ class Address extends Backend
*/
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->with(['mallUser' => function ($query) {
->with(['playxUserAsset' => function ($query) {
$query->field('id,username');
}])
->visible(['mallUser' => ['username']])
->visible(['playxUserAsset' => ['username']])
->alias($alias)
->where($where)
->order($order)

View File

@@ -8,13 +8,13 @@ use support\Response;
use Webman\Http\Request;
/**
* PlayX 领取记录(后台列表)
* 领取记录(后台列表)
*/
class PlayxClaimLog extends Backend
class ClaimLog extends Backend
{
/**
* @var object|null
* @phpstan-var \app\common\model\MallPlayxClaimLog|null
* @phpstan-var \app\common\model\MallClaimLog|null
*/
protected ?object $model = null;
@@ -33,7 +33,7 @@ class PlayxClaimLog extends Backend
public function initialize(): void
{
parent::initialize();
$this->model = new \app\common\model\MallPlayxClaimLog();
$this->model = new \app\common\model\MallClaimLog();
}
/**

View File

@@ -8,13 +8,13 @@ use support\Response;
use Webman\Http\Request;
/**
* PlayX 每日推送数据(后台列表)
* 每日推送数据(后台列表)
*/
class PlayxDailyPush extends Backend
class DailyPush extends Backend
{
/**
* @var object|null
* @phpstan-var \app\common\model\MallPlayxDailyPush|null
* @phpstan-var \app\common\model\MallDailyPush|null
*/
protected ?object $model = null;
@@ -37,7 +37,7 @@ class PlayxDailyPush extends Backend
public function initialize(): void
{
parent::initialize();
$this->model = new \app\common\model\MallPlayxDailyPush();
$this->model = new \app\common\model\MallDailyPush();
}
/**

View File

@@ -0,0 +1,318 @@
<?php
namespace app\admin\controller\mall;
use Throwable;
use app\common\controller\Backend;
use app\common\library\MallBonusGrantPush;
use app\common\model\MallOrder;
use app\common\model\MallUserAsset;
use support\think\Db;
use support\Response;
use Webman\Http\Request;
/**
* 统一订单(后台列表)
*/
class Order extends Backend
{
/**
* @var object|null
* @phpstan-var \app\common\model\MallOrder|null
*/
protected ?object $model = null;
protected array|string $preExcludeFields = ['id', 'create_time', 'update_time'];
protected array $withJoinTable = ['mallItem'];
protected string|array $quickSearchField = ['user_id', 'external_transaction_id', 'playx_transaction_id'];
protected string|array $indexField = [
'id',
'user_id',
'type',
'status',
'mall_item_id',
'points_cost',
'amount',
'multiplier',
'external_transaction_id',
'playx_transaction_id',
'grant_status',
'fail_reason',
'reject_reason',
'shipping_company',
'shipping_no',
'receiver_name',
'receiver_phone',
'receiver_address',
'mall_address_id',
'create_time',
'update_time',
];
public function initialize(): void
{
parent::initialize();
$this->model = new \app\common\model\MallOrder();
}
/**
* 查看
* @throws Throwable
*/
public function index(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if ($request->get('select') || $request->post('select')) {
return $this->select($request);
}
[$where, $alias, $limit, $order] = $this->queryBuilder();
$res = $this->model
->with(['mallItem' => function ($query) {
$query->field('id,title');
}])
->visible(['mallItem' => ['title']])
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
return $this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
/**
* PHYSICAL 发货:更新 shipping_company/shipping_no并将状态置为 SHIPPED
*/
public function ship(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if ($request->method() !== 'POST') {
return $this->error(__('Parameter error'));
}
$data = $request->post();
$id = $data['id'] ?? 0;
$shippingCompany = $data['shipping_company'] ?? '';
$shippingNo = $data['shipping_no'] ?? '';
if (!$id || $shippingCompany === '' || $shippingNo === '') {
return $this->error(__('Missing required fields'));
}
$order = MallOrder::where('id', $id)->find();
if (!$order) {
return $this->error(__('Record not found'));
}
if ($order->type !== MallOrder::TYPE_PHYSICAL) {
return $this->error(__('Order type not PHYSICAL'));
}
if ($order->status !== MallOrder::STATUS_PENDING) {
return $this->error(__('Order status must be PENDING'));
}
Db::startTrans();
try {
$order->shipping_company = $shippingCompany;
$order->shipping_no = $shippingNo;
$order->status = MallOrder::STATUS_SHIPPED;
$order->save();
Db::commit();
} catch (Throwable $e) {
Db::rollback();
return $this->error($e->getMessage());
}
return $this->success(__('Shipped successfully'));
}
/**
* 审核通过(非 PHYSICAL更新状态为 COMPLETED
*/
public function approve(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if ($request->method() !== 'POST') {
return $this->error(__('Parameter error'));
}
$id = $request->post('id', 0);
if (!$id) {
return $this->error(__('Missing required fields'));
}
$order = MallOrder::where('id', $id)->find();
if (!$order) {
return $this->error(__('Record not found'));
}
if ($order->status !== MallOrder::STATUS_PENDING) {
return $this->error(__('Order status must be PENDING'));
}
if ($order->type === MallOrder::TYPE_PHYSICAL) {
return $this->error(__('Order type not supported'));
}
Db::startTrans();
try {
$order->status = MallOrder::STATUS_COMPLETED;
$order->update_time = time();
$order->save();
Db::commit();
} catch (Throwable $e) {
Db::rollback();
return $this->error($e->getMessage());
}
return $this->success(__('Approved successfully'));
}
/**
* 审核驳回:更新状态为 REJECTED并退回积分到 available_points所有类型通用
*/
public function reject(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if ($request->method() !== 'POST') {
return $this->error(__('Parameter error'));
}
$data = $request->post();
$id = $data['id'] ?? 0;
$rejectReason = $data['reject_reason'] ?? '';
if (!$id) {
return $this->error(__('Missing required fields'));
}
$order = MallOrder::where('id', $id)->find();
if (!$order) {
return $this->error(__('Record not found'));
}
if ($order->status !== MallOrder::STATUS_PENDING) {
return $this->error(__('Order status must be PENDING'));
}
if ($order->type === MallOrder::TYPE_PHYSICAL && $rejectReason === '') {
return $this->error(__('Missing required fields'));
}
Db::startTrans();
try {
$asset = MallUserAsset::where('playx_user_id', $order->user_id ?? '')->find();
if (!$asset) {
throw new \RuntimeException('User asset not found');
}
$refund = $order->points_cost ?? 0;
if ($refund > 0) {
$asset->available_points += $refund;
$asset->save();
}
$order->status = MallOrder::STATUS_REJECTED;
$order->reject_reason = $rejectReason;
if ($order->type === MallOrder::TYPE_BONUS) {
$order->grant_status = MallOrder::GRANT_FAILED_FINAL;
} else {
$order->grant_status = MallOrder::GRANT_NOT_APPLICABLE;
}
$order->update_time = time();
$order->save();
Db::commit();
} catch (Throwable $e) {
Db::rollback();
return $this->error($e->getMessage());
}
return $this->success(__('Rejected successfully'));
}
/**
* 手动推送红利(同步调用 PlayX不限制自动重试次数成功则 ACCEPTED失败写入 fail_reason
*/
public function retry(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if ($request->method() !== 'POST') {
return $this->error(__('Parameter error'));
}
$id = $request->post('id', 0);
if (!$id) {
return $this->error(__('Missing required fields'));
}
$order = MallOrder::where('id', $id)->find();
if (!$order) {
return $this->error(__('Record not found'));
}
if ($order->type !== MallOrder::TYPE_BONUS) {
return $this->error(__('Only BONUS can retry'));
}
if ($order->status !== MallOrder::STATUS_PENDING) {
return $this->error(__('Order status must be PENDING'));
}
$allowedStatuses = [
MallOrder::GRANT_NOT_SENT,
MallOrder::GRANT_SENT_PENDING,
MallOrder::GRANT_FAILED_RETRYABLE,
MallOrder::GRANT_FAILED_FINAL,
];
if (!in_array($order->grant_status, $allowedStatuses, true)) {
return $this->error(__('Current grant status cannot be manually pushed'));
}
if (strval(config('playx.api.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->fail_reason = null;
$order->update_time = time();
$order->save();
return $this->success(__('Push succeeded'));
}
$failReason = __('Manual push failed') . ': ' . $result['message'];
$order->fail_reason = $failReason;
$order->grant_status = MallOrder::GRANT_FAILED_FINAL;
$order->update_time = time();
$order->save();
return $this->error($failReason);
}
}

View File

@@ -19,7 +19,7 @@ class PintsOrder extends Backend
protected array|string $preExcludeFields = ['id', 'create_time', 'update_time'];
protected array $withJoinTable = ['mallUser'];
protected array $withJoinTable = ['playxUserAsset'];
protected string|array $quickSearchField = ['id'];
@@ -47,10 +47,10 @@ class PintsOrder extends Backend
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->with(['mallUser' => function ($query) {
->with(['playxUserAsset' => function ($query) {
$query->field('id,username');
}])
->visible(['mallUser' => ['username']])
->visible(['playxUserAsset' => ['username']])
->alias($alias)
->where($where)
->order($order)

View File

@@ -1,149 +0,0 @@
<?php
namespace app\admin\controller\mall;
use app\common\controller\Backend;
use support\Response;
use Throwable;
use Webman\Http\Request;
/**
* 积分商城用户
*/
class Player extends Backend
{
/**
* Player模型对象
* @var object|null
* @phpstan-var \app\admin\model\mall\Player|null
*/
protected ?object $model = null;
protected array|string $preExcludeFields = ['id', 'create_time', 'update_time', 'password'];
protected string|array $quickSearchField = ['id'];
/** 列表不返回密码字段 */
protected string|array $indexField = ['id', 'username', 'create_time', 'update_time', 'score'];
public function initialize(): void
{
parent::initialize();
$this->model = new \app\admin\model\mall\Player();
}
/**
* 添加(重写以支持密码加密)
*/
public function add(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response instanceof Response) {
return $response;
}
if ($request->method() !== 'POST') {
$this->error(__('Parameter error'));
}
$data = $request->post();
if (!$data) {
$this->error(__('Parameter %s can not be empty', ['']));
}
$passwd = $data['password'] ?? '';
if (empty($passwd)) {
$this->error(__('Parameter %s can not be empty', [__('Password')]));
}
$data = $this->applyInputFilter($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('add');
}
$validate->check($data);
}
}
$result = $this->model->save($data);
if ($result !== false && $passwd) {
$this->model->resetPassword((int) $this->model->id, $passwd);
}
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->error($e->getMessage());
}
$result !== false ? $this->success(__('Added successfully')) : $this->error(__('No rows were added'));
}
/**
* 编辑(重写以支持编辑时密码可选)
*/
public function edit(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response instanceof Response) {
return $response;
}
$pk = $this->model->getPk();
$id = $request->post($pk) ?? $request->get($pk);
$row = $this->model->find($id);
if (!$row) {
$this->error(__('Record not found'));
}
if ($request->method() === 'POST') {
$data = $request->post();
if (!$data) {
$this->error(__('Parameter %s can not be empty', ['']));
}
if (!empty($data['password'])) {
$this->model->resetPassword((int) $row->id, $data['password']);
}
$data = $this->applyInputFilter($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');
}
$validate->check(array_merge($data, [$pk => $row[$pk]]));
}
}
$result = $row->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->error($e->getMessage());
}
return $result !== false ? $this->success(__('Update successful')) : $this->error(__('No rows updated'));
}
unset($row['password']);
$row['password'] = '';
$this->success('', ['row' => $row]);
}
/**
* 若需重写查看、删除等方法,请复制 @see \app\admin\library\traits\Backend 中对应的方法至此进行重写
*/
}

View File

@@ -1,250 +0,0 @@
<?php
namespace app\admin\controller\mall;
use Throwable;
use app\common\controller\Backend;
use app\common\model\MallPlayxOrder;
use app\common\model\MallPlayxUserAsset;
use support\think\Db;
use support\Response;
use Webman\Http\Request;
/**
* PlayX 统一订单(后台列表)
*/
class PlayxOrder extends Backend
{
/**
* @var object|null
* @phpstan-var \app\common\model\MallPlayxOrder|null
*/
protected ?object $model = null;
protected array|string $preExcludeFields = ['id', 'create_time', 'update_time'];
protected array $withJoinTable = ['mallItem'];
protected string|array $quickSearchField = ['user_id', 'external_transaction_id', 'playx_transaction_id'];
protected string|array $indexField = [
'id',
'user_id',
'type',
'status',
'mall_item_id',
'points_cost',
'amount',
'multiplier',
'external_transaction_id',
'playx_transaction_id',
'grant_status',
'fail_reason',
'reject_reason',
'shipping_company',
'shipping_no',
'receiver_name',
'receiver_phone',
'receiver_address',
'create_time',
'update_time',
];
public function initialize(): void
{
parent::initialize();
$this->model = new \app\common\model\MallPlayxOrder();
}
/**
* 查看
* @throws Throwable
*/
public function index(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if ($request->get('select') || $request->post('select')) {
return $this->select($request);
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->with(['mallItem' => function ($query) {
$query->field('id,title');
}])
->visible(['mallItem' => ['title']])
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
return $this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
/**
* PHYSICAL 发货:更新 shipping_company/shipping_no并将状态置为 SHIPPED
*/
public function ship(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if ($request->method() !== 'POST') {
return $this->error(__('Parameter error'));
}
$data = $request->post();
$id = intval($data['id'] ?? 0);
$shippingCompany = strval($data['shipping_company'] ?? '');
$shippingNo = strval($data['shipping_no'] ?? '');
if ($id <= 0 || $shippingCompany === '' || $shippingNo === '') {
return $this->error(__('Missing required fields'));
}
$order = MallPlayxOrder::where('id', $id)->find();
if (!$order) {
return $this->error(__('Record not found'));
}
if ($order->type !== MallPlayxOrder::TYPE_PHYSICAL) {
return $this->error(__('Order type not PHYSICAL'));
}
if ($order->status !== MallPlayxOrder::STATUS_PENDING) {
return $this->error(__('Order status must be PENDING'));
}
Db::startTrans();
try {
$order->shipping_company = $shippingCompany;
$order->shipping_no = $shippingNo;
$order->status = MallPlayxOrder::STATUS_SHIPPED;
$order->save();
Db::commit();
} catch (Throwable $e) {
Db::rollback();
return $this->error($e->getMessage());
}
return $this->success(__('Shipped successfully'));
}
/**
* PHYSICAL 驳回:更新状态为 REJECTED并退回积分到 available_points
*/
public function reject(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if ($request->method() !== 'POST') {
return $this->error(__('Parameter error'));
}
$data = $request->post();
$id = intval($data['id'] ?? 0);
$rejectReason = strval($data['reject_reason'] ?? '');
if ($id <= 0 || $rejectReason === '') {
return $this->error(__('Missing required fields'));
}
$order = MallPlayxOrder::where('id', $id)->find();
if (!$order) {
return $this->error(__('Record not found'));
}
if ($order->type !== MallPlayxOrder::TYPE_PHYSICAL) {
return $this->error(__('Order type not PHYSICAL'));
}
if ($order->status !== MallPlayxOrder::STATUS_PENDING) {
return $this->error(__('Order status must be PENDING'));
}
Db::startTrans();
try {
$asset = MallPlayxUserAsset::where('user_id', strval($order->user_id ?? ''))->find();
if (!$asset) {
$asset = MallPlayxUserAsset::create([
'user_id' => strval($order->user_id ?? ''),
'username' => strval($order->user_id ?? ''),
'locked_points' => 0,
'available_points' => 0,
'today_limit' => 0,
'today_claimed' => 0,
'today_limit_date' => null,
'create_time' => time(),
'update_time' => time(),
]);
}
$refund = intval($order->points_cost ?? 0);
if ($refund > 0) {
$asset->available_points += $refund;
$asset->save();
}
$order->status = MallPlayxOrder::STATUS_REJECTED;
$order->reject_reason = $rejectReason;
$order->grant_status = MallPlayxOrder::GRANT_FAILED_FINAL;
$order->save();
Db::commit();
} catch (Throwable $e) {
Db::rollback();
return $this->error($e->getMessage());
}
return $this->success(__('Rejected successfully'));
}
/**
* 手动重试(仅红利/提现,且必须 FAILED_RETRYABLE
*/
public function retry(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if ($request->method() !== 'POST') {
return $this->error(__('Parameter error'));
}
$id = intval($request->post('id', 0));
if ($id <= 0) {
return $this->error(__('Missing required fields'));
}
$order = MallPlayxOrder::where('id', $id)->find();
if (!$order) {
return $this->error(__('Record not found'));
}
if (!in_array($order->type, [MallPlayxOrder::TYPE_BONUS, MallPlayxOrder::TYPE_WITHDRAW], true)) {
return $this->error(__('Only BONUS/WITHDRAW can retry'));
}
if ($order->grant_status !== MallPlayxOrder::GRANT_FAILED_RETRYABLE) {
return $this->error(__('Only FAILED_RETRYABLE can retry'));
}
if (intval($order->retry_count) >= 3) {
return $this->error(__('Retry count exceeded'));
}
$order->grant_status = MallPlayxOrder::GRANT_NOT_SENT;
$order->save();
return $this->success(__('Retry queued'));
}
}

View File

@@ -1,58 +0,0 @@
<?php
namespace app\admin\controller\mall;
use Throwable;
use app\common\controller\Backend;
use support\Response;
use Webman\Http\Request;
/**
* PlayX 用户资产(后台列表)
*/
class PlayxUserAsset extends Backend
{
/**
* @var object|null
* @phpstan-var \app\common\model\MallPlayxUserAsset|null
*/
protected ?object $model = null;
protected array|string $preExcludeFields = ['id', 'create_time', 'update_time'];
protected string|array $quickSearchField = ['user_id', 'username'];
protected string|array $indexField = [
'id',
'user_id',
'username',
'locked_points',
'available_points',
'today_limit',
'today_claimed',
'today_limit_date',
'create_time',
'update_time',
];
public function initialize(): void
{
parent::initialize();
$this->model = new \app\common\model\MallPlayxUserAsset();
}
/**
* 查看
* @throws Throwable
*/
public function index(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
return $this->_index();
}
}

View File

@@ -19,7 +19,7 @@ class RedemptionOrder extends Backend
protected array|string $preExcludeFields = ['id', 'create_time', 'update_time'];
protected array $withJoinTable = ['mallUser', 'mallItem'];
protected array $withJoinTable = ['playxUserAsset', 'mallItem'];
protected string|array $quickSearchField = ['id'];
@@ -52,10 +52,10 @@ class RedemptionOrder extends Backend
*/
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->with(['mallUser' => function ($query) {
->with(['playxUserAsset' => function ($query) {
$query->field('id,username');
}])
->visible(['mallUser' => ['username'], 'mallItem' => ['title']])
->visible(['playxUserAsset' => ['username'], 'mallItem' => ['title']])
->alias($alias)
->where($where)
->order($order)

View File

@@ -1,224 +0,0 @@
<?php
declare(strict_types=1);
namespace app\admin\controller\mall;
use Throwable;
use app\common\controller\Backend;
use support\Response;
use Webman\Http\Request;
/**
* 商城用户
*/
class User extends Backend
{
/**
* @var \app\common\model\MallUser|null
*/
protected ?object $model = null;
protected array|string $preExcludeFields = ['id', 'create_time', 'update_time', 'password'];
protected array $withJoinTable = ['admin'];
protected string|array $quickSearchField = ['id', 'username', 'phone'];
/** 列表不返回密码 */
protected string|array $indexField = ['id', 'username', 'phone', 'score', 'daily_claim', 'daily_claim_use', 'available_for_withdrawal', 'admin_id', 'create_time', 'update_time'];
public function initialize(): void
{
parent::initialize();
$this->model = new \app\common\model\MallUser();
}
/**
* 查看
*/
public function index(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if ($request->get('select') || $request->post('select')) {
return $this->select($request);
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->withoutField('password')
->withJoin($this->withJoinTable, $this->withJoinType)
->visible(['admin' => ['username']])
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
return $this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
/**
* 添加(密码加密)
*/
public function add(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if ($request->method() !== 'POST') {
return $this->error(__('Parameter error'));
}
$data = $request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$passwd = $data['password'] ?? '';
if (empty($passwd)) {
return $this->error(__('Parameter %s can not be empty', [__('Password')]));
}
$data = $this->applyInputFilter($data);
$data = $this->excludeFields($data);
//保存管理员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);
if ($result !== false && $passwd) {
$this->model->resetPassword((int) $this->model->id, $passwd);
}
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
return $result !== false ? $this->success(__('Added successfully')) : $this->error(__('No rows were added'));
}
/**
* 编辑(密码可选更新)
*/
public function edit(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
$pk = $this->model->getPk();
$id = $request->post($pk) ?? $request->get($pk);
$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 ($request->method() === 'POST') {
$data = $request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
if (!empty($data['password'])) {
$this->model->resetPassword((int) $row->id, $data['password']);
}
$data = $this->applyInputFilter($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');
}
$validate->check(array_merge($data, [$pk => $row[$pk]]));
}
}
$result = $row->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
return $result !== false ? $this->success(__('Update successful')) : $this->error(__('No rows updated'));
}
unset($row['password']);
$row['password'] = '';
return $this->success('', ['row' => $row]);
}
/**
* 远程下拉数据(供 remoteSelect 使用)
*/
public function select(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->withoutField('password')
->withJoin($this->withJoinTable, $this->withJoinType)
->visible(['admin' => ['username']])
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
return $this->success('', [
'list' => $res->items(),
'total' => $res->total(),
]);
}
/**
* 删除
*/
public function del(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
return $this->_del();
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace app\admin\controller\mall;
use app\common\controller\Backend;
use support\Response;
use Webman\Http\Request;
/**
* 用户资产(后台列表)
*/
class UserAsset extends Backend
{
/**
* @var object|null
* @phpstan-var \app\common\model\MallUserAsset|null
*/
protected ?object $model = null;
protected array|string $preExcludeFields = ['id', 'create_time', 'update_time'];
protected string|array $quickSearchField = [
'playx_user_id',
'username',
'phone',
];
protected string|array $indexField = [
'id',
'playx_user_id',
'username',
'phone',
'locked_points',
'available_points',
'today_limit',
'today_claimed',
'today_limit_date',
'create_time',
'update_time',
];
public function initialize(): void
{
parent::initialize();
$this->model = new \app\common\model\MallUserAsset();
}
/**
* 远程下拉:资产主键 id + 用户名(用于地址/订单等关联)
*/
public function select(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
[$where, $alias, $limit, $order] = $this->queryBuilder();
$res = $this->model
->field('id,username')
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
$list = [];
foreach ($res->items() as $row) {
$arr = $row->toArray();
$list[] = [
'id' => $arr['id'] ?? 0,
'username' => $arr['username'] ?? '',
];
}
return $this->success('', [
'list' => $list,
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
}

View File

@@ -13,7 +13,7 @@ class AdminInfo extends Backend
{
protected ?object $model = null;
protected array|string $preExcludeFields = ['username', 'last_login_time', 'password', 'salt', 'status'];
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 function initController(Request $request): ?Response

View File

@@ -14,7 +14,7 @@ class Attachment extends Backend
protected ?object $model = null;
protected array|string $quickSearchField = 'name';
protected array $withJoinTable = ['admin', 'user'];
protected array $withJoinTable = ['admin'];
protected array|string $defaultSortField = ['last_upload_time' => 'desc'];
protected function initController(Request $request): ?Response

View File

@@ -95,4 +95,9 @@ return [
'%d records and files have been deleted' => '%d records and files have been deleted',
'Please input correct username' => 'Please enter the correct username',
'Group Name Arr' => 'Group Name Arr',
'Push succeeded' => 'Push succeeded',
'Manual push failed' => 'Manual push failed',
'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',
];

View File

@@ -2,6 +2,4 @@
return [
'Group Name Arr' => 'Administrator Grouping ',
'Please use another administrator account to disable the current account!' => 'Disable the current account, please use another administrator account!',
'Please select channel' => 'Please select channel',
'Current admin has no channel bound' => 'Current admin has no channel bound',
];

View File

@@ -114,4 +114,9 @@ return [
'%d records and files have been deleted' => '已删除%d条记录和文件',
'Please input correct username' => '请输入正确的用户名',
'Group Name Arr' => '分组名称数组',
'Push succeeded' => '推送成功',
'Manual push failed' => '手动推送失败',
'PlayX API not configured' => 'PlayX 接口未配置',
'Current grant status cannot be manually pushed' => '当前发放状态不可手动推送',
'Order status must be PENDING' => '订单状态须为处理中',
];

View File

@@ -3,6 +3,4 @@ return [
'Group Name Arr' => '管理员分组',
'Please use another administrator account to disable the current account!' => '请使用另外的管理员账户禁用当前账户!',
'You have no permission to add an administrator to this group!' => '您没有权限向此分组添加管理员!',
'Please select channel' => '请选择渠道',
'Current admin has no channel bound' => '当前管理员未绑定渠道',
];

View File

@@ -33,7 +33,7 @@ class Auth extends \ba\Auth
protected string $refreshToken = '';
protected int $keepTime = 86400;
protected int $refreshTokenKeepTime = 2592000;
protected array $allowFields = ['id', 'username', 'nickname', 'avatar', 'last_login_time', 'channel_id'];
protected array $allowFields = ['id', 'username', 'nickname', 'avatar', 'last_login_time'];
public function __construct(array $config = [])
{

View File

@@ -21,13 +21,23 @@ use support\think\Db;
* @property string $password 密码密文
* @property string $salt 密码盐
* @property string $status 状态:enable=启用,disable=禁用
* @property string $agent_id 代理ID关联渠道
* @property int $channel_id 渠道ID
* @property string $agent_id 代理 IDAPI 鉴权
* @property string $agent_api_secret Agent API 密钥
*/
class Admin extends Model
{
use TimestampInteger;
/**
* 已移除的 channel_id 等若仍被旧请求/缓存传入,禁止参与读写
*/
protected function getOptions(): array
{
return [
'disuse' => ['channel_id'],
];
}
protected string $table = 'admin';
protected string $pk = 'id';
protected bool $autoWriteTimestamp = true;
@@ -66,12 +76,4 @@ class Admin extends Model
{
return $this->where(['id' => $uid])->update(['password' => hash_password($newPassword), 'salt' => '']);
}
/**
* 关联渠道
*/
public function channel(): \think\model\relation\BelongsTo
{
return $this->belongsTo(\app\common\model\ChannelManage::class, 'channel_id', 'id');
}
}

View File

@@ -1,28 +0,0 @@
<?php
namespace app\admin\model\mall;
use app\common\model\traits\TimestampInteger;
use support\think\Model;
/**
* Player
*/
class Player extends Model
{
use TimestampInteger;
// 表名
protected $name = 'mall_player';
// 自动写入时间戳字段
protected $autoWriteTimestamp = true;
/**
* 重置密码
*/
public function resetPassword(int $id, string $newPassword): bool
{
return $this->where(['id' => $id])->update(['password' => hash_password($newPassword)]) !== false;
}
}

View File

@@ -75,6 +75,9 @@ class Common extends Api
if ($refreshToken['type'] == UserAuth::TOKEN_TYPE . '-refresh') {
Token::set($newToken, UserAuth::TOKEN_TYPE, $refreshToken['user_id'], (int)config('buildadmin.user_token_keep_time', 259200));
}
if ($refreshToken['type'] == UserAuth::TOKEN_TYPE_MALL_USER . '-refresh') {
Token::set($newToken, UserAuth::TOKEN_TYPE_MALL_USER, $refreshToken['user_id'], (int)config('buildadmin.user_token_keep_time', 259200));
}
return $this->success('', [
'type' => $refreshToken['type'],

View File

@@ -4,9 +4,13 @@ declare(strict_types=1);
namespace app\api\controller\v1;
use ba\Random;
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\ChannelManage;
use app\common\model\MallUserAsset;
use app\admin\model\Admin;
use Webman\Http\Request;
use support\Response;
@@ -26,6 +30,11 @@ class Auth extends Api
*/
protected int $timeTolerance = 300;
/**
* 临时登录 token 有效期(秒)
*/
protected int $tempTokenExpire = 86400;
/**
* 获取鉴权 TokenGET 请求)
* 参数仅从 Query 读取signature、secret、agent_id、time
@@ -47,7 +56,7 @@ class Auth extends Api
return $this->error(__('Parameter signature/secret/agent_id/time can not be empty'));
}
$timestamp = (int) $time;
$timestamp = intval($time);
if ($timestamp <= 0) {
return $this->error(__('Invalid timestamp'));
}
@@ -62,17 +71,12 @@ class Auth extends Api
return $this->error(__('Agent not found'));
}
$channelId = (int) ($admin->channel_id ?? 0);
if ($channelId <= 0) {
$apiSecret = strval($admin->agent_api_secret ?? '');
if ($apiSecret === '') {
return $this->error(__('Agent not found'));
}
$channel = ChannelManage::where('id', $channelId)->find();
if (!$channel || $channel->secret === '') {
return $this->error(__('Agent not found'));
}
if ($channel->secret !== $secret) {
if ($apiSecret !== $secret) {
return $this->error(__('Invalid agent or secret'));
}
@@ -81,11 +85,10 @@ class Auth extends Api
return $this->error(__('Invalid signature'));
}
$expire = (int) config('buildadmin.agent_auth.token_expire', 86400);
$expire = intval(config('buildadmin.agent_auth.token_expire', 86400));
$payload = [
'agent_id' => $agentId,
'channel_id' => $channel->id,
'admin_id' => $admin->id,
'agent_id' => $agentId,
'admin_id' => $admin->id,
];
$authtoken = AgentJwt::encode($payload, $expire);
@@ -93,4 +96,70 @@ class Auth extends Api
'authtoken' => $authtoken,
]);
}
/**
* H5 临时登录GET/POST
* 参数username
* 写入或复用 mall_user_asset签发 muser 类型 tokenuser_id 为资产表主键)
*/
public function temLogin(Request $request): Response
{
$response = $this->initializeApi($request);
if ($response !== null) {
return $response;
}
$enabled = config('buildadmin.agent_auth.temp_login_enable', false);
if (!$enabled) {
return $this->error(__('Temp login is disabled'));
}
$username = trim(strval($request->get('username', $request->post('username', ''))));
// 兼容querystring 中未编码的 '+' 会被解析为空格application/x-www-form-urlencoded 规则)
// 例如:/api/v1/temLogin?username=+607... 期望保留 '+',则从原始 querystring 提取并还原
if ($username !== '' && str_contains($username, ' ')) {
$qs = $request->queryString();
if (is_string($qs) && $qs !== '') {
foreach (explode('&', $qs) as $pair) {
if ($pair === '' || !str_contains($pair, '=')) {
continue;
}
[$k, $v] = explode('=', $pair, 2);
if (rawurldecode($k) === 'username') {
// 先把 %xx 解码;注意这里不把 '+' 当空格处理,从而保留 '+'
$username = trim(rawurldecode($v));
break;
}
}
}
}
if ($username === '') {
return $this->error(__('Parameter username can not be empty'));
}
try {
$asset = MallUserAsset::ensureForUsername($username);
} catch (Throwable $e) {
return $this->error($e->getMessage());
}
$token = Random::uuid();
$refreshToken = Random::uuid();
$expire = config('buildadmin.agent_auth.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);
return $this->success('', [
'userInfo' => [
'id' => $assetId,
'username' => strval($asset->username ?? ''),
'nickname' => strval($asset->username ?? ''),
'playx_user_id' => strval($asset->playx_user_id ?? ''),
'token' => $token,
'refresh_token' => $refreshToken,
'expires_in' => $expire,
],
]);
}
}

View File

@@ -4,13 +4,17 @@ declare(strict_types=1);
namespace app\api\controller\v1;
use ba\Random;
use app\common\controller\Api;
use app\common\facade\Token;
use app\common\library\Auth as UserAuth;
use app\common\model\MallItem;
use app\common\model\MallPlayxClaimLog;
use app\common\model\MallPlayxDailyPush;
use app\common\model\MallPlayxSession;
use app\common\model\MallPlayxOrder;
use app\common\model\MallPlayxUserAsset;
use app\common\model\MallClaimLog;
use app\common\model\MallDailyPush;
use app\common\model\MallSession;
use app\common\model\MallOrder;
use app\common\model\MallUserAsset;
use app\common\model\MallAddress;
use support\think\Db;
use Webman\Http\Request;
use support\Response;
@@ -21,33 +25,130 @@ use support\Response;
class Playx extends Api
{
/**
* 从请求解析 PlayX 会话用户ID优先 session_id其次 user_id
* 从请求解析 mall_user_asset.idmuser token、session、user_id 均指向资产表主键或 playx_user_id
*/
private function resolveUserIdFromRequest(Request $request): ?string
private function resolvePlayxAssetIdFromRequest(Request $request): ?int
{
$sessionId = strval($request->post('session_id', $request->get('session_id', '')));
if ($sessionId !== '') {
$session = MallPlayxSession::where('session_id', $sessionId)->find();
if (!$session) {
return null;
$session = MallSession::where('session_id', $sessionId)->find();
if ($session) {
$expireTime = intval($session->expire_time ?? 0);
if ($expireTime > time()) {
$asset = MallUserAsset::where('playx_user_id', strval($session->user_id ?? ''))->find();
if ($asset) {
return intval($asset->getKey());
}
}
}
$expireTime = intval($session->expire_time ?? 0);
if ($expireTime <= time()) {
return null;
$assetId = $this->resolveAssetIdByToken($sessionId);
if ($assetId !== null) {
return $assetId;
}
return strval($session->user_id ?? '');
}
$token = strval($request->post('token', $request->get('token', '')));
if ($token === '') {
$token = get_auth_token(['ba', 'token'], $request);
}
if ($token !== '') {
return $this->resolveAssetIdByToken($token);
}
$userId = strval($request->post('user_id', $request->get('user_id', '')));
if ($userId === '') {
return null;
}
return $userId;
if (ctype_digit($userId)) {
return intval($userId);
}
$asset = MallUserAsset::where('playx_user_id', $userId)->find();
if ($asset) {
return intval($asset->getKey());
}
return null;
}
private function resolveAssetIdByToken(string $token): ?int
{
$tokenData = Token::get($token);
$tokenType = strval($tokenData['type'] ?? '');
$isMemberOrMall = $tokenType === UserAuth::TOKEN_TYPE || $tokenType === UserAuth::TOKEN_TYPE_MALL_USER;
if (!empty($tokenData)
&& $isMemberOrMall
&& intval($tokenData['expire_time'] ?? 0) > time()
&& intval($tokenData['user_id'] ?? 0) > 0
) {
return intval($tokenData['user_id']);
}
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
{
$asset = MallUserAsset::where('playx_user_id', $playxUserId)->find();
if ($asset) {
return $asset;
}
$effectiveUsername = trim($username);
if ($effectiveUsername === '') {
$effectiveUsername = 'playx_' . $playxUserId;
}
$byName = MallUserAsset::where('username', $effectiveUsername)->find();
if ($byName) {
$byName->playx_user_id = $playxUserId;
$byName->save();
return $byName;
}
$phone = $this->buildTempPhone();
if ($phone === null) {
return null;
}
$pwd = hash_password(Random::build('alnum', 16));
$now = time();
return MallUserAsset::create([
'playx_user_id' => $playxUserId,
'username' => $effectiveUsername,
'phone' => $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,
]);
}
private function getAssetById(int $assetId): ?MallUserAsset
{
return MallUserAsset::where('id', $assetId)->find();
}
/**
* Daily Push API - PlayX 调用商城接收 T+1 数据
* POST /api/v1/playx/daily-push
* POST /api/v1/mall/dailyPush
*/
public function dailyPush(Request $request): Response
{
@@ -64,45 +165,159 @@ class Playx extends Api
}
}
$requestId = $body['request_id'] ?? '';
$date = $body['date'] ?? '';
$userId = $body['user_id'] ?? '';
$yesterdayWinLossNet = $body['yesterday_win_loss_net'] ?? 0;
$yesterdayTotalDeposit = $body['yesterday_total_deposit'] ?? 0;
if ($requestId === '' || $date === '' || $userId === '') {
return $this->error(__('Missing required fields: request_id, date, user_id'));
}
$secret = config('playx.daily_push_secret', '');
if ($secret !== '') {
$sig = $request->header('X-Signature', '');
$ts = $request->header('X-Timestamp', '');
$rid = $request->header('X-Request-Id', '');
if ($sig === '' || $ts === '' || $rid === '') {
return $this->error('INVALID_SIGNATURE', null, 0, ['statusCode' => 401]);
return $this->error(__('Daily push signature missing or incomplete'), null, 0, ['statusCode' => 401]);
}
$canonical = $ts . "\n" . $rid . "\nPOST\n/api/v1/playx/daily-push\n" . hash('sha256', json_encode($body));
$canonical = $ts . "\n" . $rid . "\nPOST\n/api/v1/mall/dailyPush\n" . hash('sha256', json_encode($body));
$expected = hash_hmac('sha256', $canonical, $secret);
if (!hash_equals($expected, $sig)) {
return $this->error('INVALID_SIGNATURE', null, 0, ['statusCode' => 401]);
return $this->error(__('Daily push signature verification failed'), null, 0, ['statusCode' => 401]);
}
}
$exists = MallPlayxDailyPush::where('user_id', $userId)->where('date', $date)->find();
// ===== 新版批量上报格式 =====
// 兼容你们截图:{ report_date, member:[{member_id, login, lty_deposit, lty_withdrawal, yesterday_total_w, yesterday_total_deposit}, ...] }
if (isset($body['report_date']) && isset($body['member']) && is_array($body['member'])) {
$reportDate = $body['report_date'];
$date = '';
if (is_numeric($reportDate)) {
$date = date('Y-m-d', intval($reportDate));
} else {
$date = strval($reportDate);
}
$members = $body['member'];
if ($date === '' || empty($members)) {
return $this->error(__('Missing required fields: report_date, member'));
}
$requestId = strval($body['request_id'] ?? '');
if ($requestId === '') {
$requestId = 'report_' . $date;
}
$returnRatio = config('playx.return_ratio', 0.1);
$unlockRatio = config('playx.unlock_ratio', 0.1);
$results = [];
$allDeduped = true;
foreach ($members as $m) {
$playxUserId = strval($m['member_id'] ?? '');
if ($playxUserId === '') {
return $this->error(__('Missing required fields: member_id'));
}
$username = strval($m['login'] ?? '');
$yesterdayWinLossNet = $m['yesterday_total_w'] ?? 0;
$yesterdayTotalDeposit = $m['yesterday_total_deposit'] ?? 0;
$lifetimeTotalDeposit = $m['lty_deposit'] ?? 0;
$lifetimeTotalWithdraw = $m['lty_withdrawal'] ?? 0;
$exists = MallDailyPush::where('user_id', $playxUserId)->where('date', $date)->find();
if ($exists) {
$results[] = [
'user_id' => $playxUserId,
'deduped' => true,
'accepted' => true,
'message' => __('Duplicate input'),
];
continue;
}
Db::startTrans();
try {
MallDailyPush::create([
'user_id' => $playxUserId,
'date' => $date,
'username' => $username,
'yesterday_win_loss_net' => $yesterdayWinLossNet,
'yesterday_total_deposit' => $yesterdayTotalDeposit,
'lifetime_total_deposit' => $lifetimeTotalDeposit,
'lifetime_total_withdraw' => $lifetimeTotalWithdraw,
'create_time' => time(),
]);
$newLocked = 0;
if ($yesterdayWinLossNet < 0) {
$newLocked = intval(round(abs(floatval($yesterdayWinLossNet)) * $returnRatio));
}
$todayLimit = intval(round(floatval($yesterdayTotalDeposit) * $unlockRatio));
$asset = $this->ensureAssetForPlayx($playxUserId, $username);
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[] = [
'user_id' => $playxUserId,
'deduped' => false,
'accepted' => true,
'message' => __('Ok'),
];
$allDeduped = false;
} catch (\Throwable $e) {
Db::rollback();
return $this->error($e->getMessage());
}
}
return $this->success('', [
'request_id' => $requestId,
'accepted' => true,
'deduped' => $allDeduped,
'message' => $allDeduped ? __('Duplicate input') : __('Ok'),
'results' => $results,
]);
}
// ===== 旧版单条上报格式(兼容)=====
$requestId = $body['request_id'] ?? '';
$date = $body['date'] ?? '';
$playxUserId = strval($body['user_id'] ?? '');
$yesterdayWinLossNet = $body['yesterday_win_loss_net'] ?? 0;
$yesterdayTotalDeposit = $body['yesterday_total_deposit'] ?? 0;
if ($requestId === '' || $date === '' || $playxUserId === '') {
return $this->error(__('Missing required fields: request_id, date, user_id'));
}
$exists = MallDailyPush::where('user_id', $playxUserId)->where('date', $date)->find();
if ($exists) {
return $this->success('', [
'request_id' => $requestId,
'accepted' => true,
'deduped' => true,
'message' => 'duplicate input',
'message' => __('Duplicate input'),
]);
}
Db::startTrans();
try {
MallPlayxDailyPush::create([
'user_id' => $userId,
MallDailyPush::create([
'user_id' => $playxUserId,
'date' => $date,
'username' => $body['username'] ?? '',
'yesterday_win_loss_net' => $yesterdayWinLossNet,
@@ -112,40 +327,34 @@ class Playx extends Api
'create_time' => time(),
]);
$newLocked = 0;
$returnRatio = config('playx.return_ratio', 0.1);
$unlockRatio = config('playx.unlock_ratio', 0.1);
$newLocked = 0;
if ($yesterdayWinLossNet < 0) {
$newLocked = intval(round(abs(floatval($yesterdayWinLossNet)) * $returnRatio));
}
$todayLimit = intval(round(floatval($yesterdayTotalDeposit) * $unlockRatio));
$asset = MallPlayxUserAsset::where('user_id', $userId)->find();
$todayLimitDate = $date;
if ($asset) {
if ($asset->today_limit_date !== $todayLimitDate) {
$asset->today_claimed = 0;
$asset->today_limit_date = $todayLimitDate;
}
$asset->locked_points += $newLocked;
$asset->today_limit = $todayLimit;
$asset->username = $body['username'] ?? $asset->username;
$asset->save();
} else {
MallPlayxUserAsset::create([
'user_id' => $userId,
'username' => $body['username'] ?? '',
'locked_points' => $newLocked,
'available_points' => 0,
'today_limit' => $todayLimit,
'today_claimed' => 0,
'today_limit_date' => $todayLimitDate,
'create_time' => time(),
'update_time' => time(),
]);
$asset = $this->ensureAssetForPlayx($playxUserId, strval($body['username'] ?? ''));
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();
@@ -156,13 +365,13 @@ class Playx extends Api
'request_id' => $requestId,
'accepted' => true,
'deduped' => false,
'message' => 'ok',
'message' => __('Ok'),
]);
}
/**
* Token 验证 - 接收前端 token调用 PlayX 验证(占位,待 PlayX 提供 API
* POST /api/v1/playx/verify-token
* Token 验证 - POST /api/v1/playx/verify-token
* 配置 playx.verify_token_local_only=true 时仅本地校验 token不请求 PlayX
*/
public function verifyToken(Request $request): Response
{
@@ -171,15 +380,19 @@ class Playx extends Api
return $response;
}
$token = $request->post('token', $request->post('session', ''));
$token = strval($request->post('token', $request->post('session', $request->get('token', ''))));
if ($token === '') {
return $this->error('INVALID_TOKEN', null, 0, ['statusCode' => 401]);
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
}
if (config('playx.verify_token_local_only', false)) {
return $this->verifyTokenLocal($token);
}
$baseUrl = config('playx.api.base_url', '');
$verifyUrl = config('playx.api.token_verify_url', '/api/v1/auth/verify-token');
if ($baseUrl === '') {
return $this->error('PlayX API not configured');
return $this->error(__('PlayX API not configured'));
}
try {
@@ -196,7 +409,10 @@ class Playx extends Api
$code = $res->getStatusCode();
$data = json_decode(strval($res->getBody()), true);
if ($code !== 200 || empty($data['user_id'])) {
return $this->error($data['message'] ?? 'INVALID_TOKEN', null, 0, ['statusCode' => 401]);
$remoteMsg = $data['message'] ?? '';
$msg = is_string($remoteMsg) && $remoteMsg !== '' ? $remoteMsg : __('Token expiration');
return $this->error($msg, null, 0, ['statusCode' => 401]);
}
$userId = strval($data['user_id']);
@@ -211,7 +427,7 @@ class Playx extends Api
}
$sessionId = bin2hex(random_bytes(16));
MallPlayxSession::create([
MallSession::create([
'session_id' => $sessionId,
'user_id' => $userId,
'username' => $username,
@@ -231,6 +447,53 @@ class Playx extends Api
}
}
/**
* 本地校验 temLogin 等写入的商城 token类型 muser写入 mall_session
*/
private function verifyTokenLocal(string $token): 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 ?? '');
if ($playxUserId === '') {
$playxUserId = strval($assetId);
}
$expireAt = time() + intval(config('playx.session_expire_seconds', 3600));
$sessionId = bin2hex(random_bytes(16));
MallSession::create([
'session_id' => $sessionId,
'user_id' => $playxUserId,
'username' => strval($asset->username ?? ''),
'expire_time' => $expireAt,
'create_time' => time(),
'update_time' => time(),
]);
return $this->success('', [
'session_id' => $sessionId,
'user_id' => $playxUserId,
'username' => strval($asset->username ?? ''),
'token_expire_at' => date('c', $expireAt),
]);
}
/**
* 用户资产
* GET /api/v1/playx/assets?user_id=xxx
@@ -242,12 +505,12 @@ class Playx extends Api
return $response;
}
$userId = $this->resolveUserIdFromRequest($request);
if ($userId === null) {
return $this->error('INVALID_TOKEN', null, 0, ['statusCode' => 401]);
$assetId = $this->resolvePlayxAssetIdFromRequest($request);
if ($assetId === null) {
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
}
$asset = MallPlayxUserAsset::where('user_id', $userId)->find();
$asset = $this->getAssetById($assetId);
if (!$asset) {
return $this->success('', [
'locked_points' => 0,
@@ -282,21 +545,24 @@ class Playx extends Api
}
$claimRequestId = strval($request->post('claim_request_id', ''));
$userId = $this->resolveUserIdFromRequest($request);
if ($claimRequestId === '' || $userId === null) {
$assetId = $this->resolvePlayxAssetIdFromRequest($request);
if ($assetId === null) {
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
}
if ($claimRequestId === '') {
return $this->error(__('claim_request_id and user_id/session_id required'));
}
$exists = MallPlayxClaimLog::where('claim_request_id', $claimRequestId)->find();
if ($exists) {
$asset = MallPlayxUserAsset::where('user_id', $userId)->find();
return $this->success('', $this->formatAsset($asset));
}
$asset = MallPlayxUserAsset::where('user_id', $userId)->find();
if (!$asset) {
$asset = $this->getAssetById($assetId);
if (!$asset || strval($asset->playx_user_id ?? '') === '') {
return $this->error(__('User asset not found'));
}
$playxUserId = strval($asset->playx_user_id);
$exists = MallClaimLog::where('claim_request_id', $claimRequestId)->find();
if ($exists) {
return $this->success('', $this->formatAsset($asset));
}
$todayLimitDate = date('Y-m-d');
if ($asset->today_limit_date !== $todayLimitDate) {
@@ -313,9 +579,9 @@ class Playx extends Api
Db::startTrans();
try {
MallPlayxClaimLog::create([
MallClaimLog::create([
'claim_request_id' => $claimRequestId,
'user_id' => $userId,
'user_id' => $playxUserId,
'claimed_amount' => $canClaim,
'create_time' => time(),
]);
@@ -395,12 +661,16 @@ class Playx extends Api
return $response;
}
$userId = $this->resolveUserIdFromRequest($request);
if ($userId === null) {
return $this->error('INVALID_TOKEN', null, 0, ['statusCode' => 401]);
$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->success('', ['list' => []]);
}
$list = MallPlayxOrder::where('user_id', $userId)
$list = MallOrder::where('user_id', strval($asset->playx_user_id))
->with(['mallItem'])
->order('id', 'desc')
->limit(100)
@@ -409,7 +679,198 @@ class Playx extends Api
return $this->success('', ['list' => $list->toArray()]);
}
private function formatAsset(?MallPlayxUserAsset $asset): array
/**
* 收货地址列表
* GET /api/v1/playx/address/list?session_id=xxx
*/
public function addressList(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]);
}
$list = MallAddress::where('playx_user_asset_id', $assetId)
->order('default_setting', 'desc')
->order('id', 'desc')
->select();
return $this->success('', ['list' => $list->toArray()]);
}
/**
* 添加收货地址(可设置默认)
* POST /api/v1/playx/address/add
*/
public function addressAdd(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]);
}
$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) {
return $this->error(__('Missing required fields'));
}
Db::startTrans();
try {
if ($defaultSetting === 1) {
MallAddress::where('playx_user_asset_id', $assetId)->update(['default_setting' => 0]);
}
$created = MallAddress::create([
'playx_user_asset_id' => $assetId,
'receiver_name' => $receiverName,
'phone' => $phone,
'region' => $region,
'detail_address' => $detailAddress,
'default_setting' => $defaultSetting,
'create_time' => time(),
'update_time' => time(),
]);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
return $this->error($e->getMessage());
}
return $this->success('', [
'id' => $created ? $created->id : null,
]);
}
/**
* 修改收货地址(包含设置默认地址)
* POST /api/v1/playx/address/edit
*/
public function addressEdit(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]);
}
$id = intval($request->post('id', 0));
if ($id <= 0) {
return $this->error(__('Missing required fields'));
}
$row = MallAddress::where('id', $id)->where('playx_user_asset_id', $assetId)->find();
if (!$row) {
return $this->error(__('Record not found'));
}
$updates = [];
if ($request->post('phone', null) !== null) {
$updates['phone'] = trim(strval($request->post('phone', '')));
}
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', '')));
}
if ($request->post('default_setting', null) !== null) {
$updates['default_setting'] = strval($request->post('default_setting', '0')) === '1' ? 1 : 0;
}
if (empty($updates)) {
return $this->success('', ['updated' => false]);
}
Db::startTrans();
try {
if (isset($updates['default_setting']) && $updates['default_setting'] === 1) {
MallAddress::where('playx_user_asset_id', $assetId)->update(['default_setting' => 0]);
}
$updates['update_time'] = time();
MallAddress::where('id', $id)->where('playx_user_asset_id', $assetId)->update($updates);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
return $this->error($e->getMessage());
}
return $this->success('', ['updated' => true]);
}
/**
* 删除收货地址
* POST /api/v1/playx/address/delete
*/
public function addressDelete(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]);
}
$id = intval($request->post('id', 0));
if ($id <= 0) {
return $this->error(__('Missing required fields'));
}
$row = MallAddress::where('id', $id)->where('playx_user_asset_id', $assetId)->find();
if (!$row) {
return $this->error(__('Record not found'));
}
$wasDefault = intval($row->default_setting ?? 0) === 1;
Db::startTrans();
try {
MallAddress::where('id', $id)->where('playx_user_asset_id', $assetId)->delete();
if ($wasDefault) {
$fallback = MallAddress::where('playx_user_asset_id', $assetId)->order('id', 'desc')->find();
if ($fallback) {
$fallback->default_setting = 1;
$fallback->update_time = time();
$fallback->save();
}
}
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
return $this->error($e->getMessage());
}
return $this->success('', ['deleted' => true]);
}
private function formatAsset(?MallUserAsset $asset): array
{
if (!$asset) {
return [
@@ -438,8 +899,11 @@ class Playx extends Api
}
$itemId = intval($request->post('item_id', 0));
$userId = $this->resolveUserIdFromRequest($request);
if ($itemId <= 0 || $userId === null) {
$assetId = $this->resolvePlayxAssetIdFromRequest($request);
if ($assetId === null) {
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
}
if ($itemId <= 0) {
return $this->error(__('item_id and user_id/session_id required'));
}
@@ -448,10 +912,11 @@ class Playx extends Api
return $this->error(__('Item not found or not available'));
}
$asset = MallPlayxUserAsset::where('user_id', $userId)->find();
if (!$asset || $asset->available_points < $item->score) {
$asset = $this->getAssetById($assetId);
if (!$asset || strval($asset->playx_user_id ?? '') === '' || $asset->available_points < $item->score) {
return $this->error(__('Insufficient points'));
}
$playxUserId = strval($asset->playx_user_id);
$multiplier = intval($item->multiplier ?? 0);
if ($multiplier <= 0) {
@@ -465,18 +930,20 @@ class Playx extends Api
$asset->save();
$orderNo = 'BONUS_ORD' . date('YmdHis') . mt_rand(1000, 9999);
$order = MallPlayxOrder::create([
'user_id' => $userId,
'type' => MallPlayxOrder::TYPE_BONUS,
'status' => MallPlayxOrder::STATUS_PENDING,
$order = MallOrder::create([
'user_id' => $playxUserId,
'type' => MallOrder::TYPE_BONUS,
'status' => MallOrder::STATUS_PENDING,
'mall_item_id' => $item->id,
'points_cost' => $item->score,
'amount' => $amount,
'multiplier' => $multiplier,
'external_transaction_id' => $orderNo,
'grant_status' => MallPlayxOrder::GRANT_NOT_SENT,
'grant_status' => MallOrder::GRANT_NOT_SENT,
'create_time' => time(),
'update_time' => time(),
'start_time' => date('Y-m-d H:i:s', time()),
'end_time' => date('Y-m-d H:i:s', time()+86400*3),
]);
Db::commit();
@@ -487,7 +954,7 @@ class Playx extends Api
$baseUrl = config('playx.api.base_url', '');
if ($baseUrl !== '') {
$this->callPlayxBonusGrant($order, $item, $userId);
$this->callPlayxBonusGrant($order, $item, $playxUserId);
}
return $this->success(__('Redeem submitted, please wait about 10 minutes'), [
@@ -504,11 +971,21 @@ class Playx extends Api
}
$itemId = intval($request->post('item_id', 0));
$userId = $this->resolveUserIdFromRequest($request);
$receiverName = $request->post('receiver_name', '');
$receiverPhone = $request->post('receiver_phone', '');
$receiverAddress = $request->post('receiver_address', '');
if ($itemId <= 0 || $userId === null || $receiverName === '' || $receiverPhone === '' || $receiverAddress === '') {
$addressId = intval($request->post('address_id', 0));
$assetId = $this->resolvePlayxAssetIdFromRequest($request);
if ($itemId <= 0 || $addressId <= 0) {
return $this->error(__('Missing required fields'));
}
if ($assetId === null) {
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
}
$addrRow = MallAddress::where('id', $addressId)->where('playx_user_asset_id', $assetId)->find();
if (!$addrRow) {
return $this->error(__('Shipping address not found'));
}
$snapshot = MallAddress::snapshotForPhysicalOrder($addrRow);
if ($snapshot['receiver_phone'] === '' || $snapshot['receiver_address'] === '' || $snapshot['receiver_name'] === '') {
return $this->error(__('Missing required fields'));
}
@@ -520,25 +997,28 @@ class Playx extends Api
return $this->error(__('Out of stock'));
}
$asset = MallPlayxUserAsset::where('user_id', $userId)->find();
if (!$asset || $asset->available_points < $item->score) {
$asset = $this->getAssetById($assetId);
if (!$asset || strval($asset->playx_user_id ?? '') === '' || $asset->available_points < $item->score) {
return $this->error(__('Insufficient points'));
}
$playxUserId = strval($asset->playx_user_id);
Db::startTrans();
try {
$asset->available_points -= $item->score;
$asset->save();
MallPlayxOrder::create([
'user_id' => $userId,
'type' => MallPlayxOrder::TYPE_PHYSICAL,
'status' => MallPlayxOrder::STATUS_PENDING,
MallOrder::create([
'user_id' => $playxUserId,
'type' => MallOrder::TYPE_PHYSICAL,
'status' => MallOrder::STATUS_PENDING,
'mall_item_id' => $item->id,
'points_cost' => $item->score,
'receiver_name' => $receiverName,
'receiver_phone' => $receiverPhone,
'receiver_address' => $receiverAddress,
'mall_address_id' => $addressId,
'receiver_name' => $snapshot['receiver_name'],
'receiver_phone' => $snapshot['receiver_phone'],
'receiver_address' => $snapshot['receiver_address'],
'grant_status' => MallOrder::GRANT_NOT_APPLICABLE,
'create_time' => time(),
'update_time' => time(),
]);
@@ -565,8 +1045,11 @@ class Playx extends Api
}
$itemId = intval($request->post('item_id', 0));
$userId = $this->resolveUserIdFromRequest($request);
if ($itemId <= 0 || $userId === null) {
$assetId = $this->resolvePlayxAssetIdFromRequest($request);
if ($assetId === null) {
return $this->error(__('Token expiration'), null, 0, ['statusCode' => 401]);
}
if ($itemId <= 0) {
return $this->error(__('item_id and user_id/session_id required'));
}
@@ -575,10 +1058,11 @@ class Playx extends Api
return $this->error(__('Item not found or not available'));
}
$asset = MallPlayxUserAsset::where('user_id', $userId)->find();
if (!$asset || $asset->available_points < $item->score) {
$asset = $this->getAssetById($assetId);
if (!$asset || strval($asset->playx_user_id ?? '') === '' || $asset->available_points < $item->score) {
return $this->error(__('Insufficient points'));
}
$playxUserId = strval($asset->playx_user_id);
$multiplier = intval($item->multiplier ?? 0);
if ($multiplier <= 0) {
@@ -592,16 +1076,16 @@ class Playx extends Api
$asset->save();
$orderNo = 'WITHDRAW_ORD' . date('YmdHis') . mt_rand(1000, 9999);
$order = MallPlayxOrder::create([
'user_id' => $userId,
'type' => MallPlayxOrder::TYPE_WITHDRAW,
'status' => MallPlayxOrder::STATUS_PENDING,
$order = MallOrder::create([
'user_id' => $playxUserId,
'type' => MallOrder::TYPE_WITHDRAW,
'status' => MallOrder::STATUS_PENDING,
'mall_item_id' => $item->id,
'points_cost' => $item->score,
'amount' => $amount,
'multiplier' => $multiplier,
'external_transaction_id' => $orderNo,
'grant_status' => MallPlayxOrder::GRANT_NOT_SENT,
'grant_status' => MallOrder::GRANT_NOT_APPLICABLE,
'create_time' => time(),
'update_time' => time(),
]);
@@ -612,18 +1096,13 @@ class Playx extends Api
return $this->error($e->getMessage());
}
$baseUrl = config('playx.api.base_url', '');
if ($baseUrl !== '') {
$this->callPlayxBalanceCredit($order, $userId);
}
return $this->success(__('Withdraw submitted, please wait about 10 minutes'), [
'order_id' => $order->id,
'status' => 'PENDING',
]);
}
private function callPlayxBonusGrant(MallPlayxOrder $order, MallItem $item, string $userId): void
private function callPlayxBonusGrant(MallOrder $order, MallItem $item, string $userId): void
{
$baseUrl = rtrim(config('playx.api.base_url', ''), '/');
$url = config('playx.api.bonus_grant_url', '/api/v1/bonus/grant');
@@ -648,53 +1127,18 @@ class Playx extends Api
$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 = MallPlayxOrder::GRANT_ACCEPTED;
$order->grant_status = MallOrder::GRANT_ACCEPTED;
$order->save();
} else {
$order->grant_status = MallPlayxOrder::GRANT_FAILED_RETRYABLE;
$order->grant_status = MallOrder::GRANT_FAILED_RETRYABLE;
$order->fail_reason = $data['message'] ?? 'unknown';
$order->save();
}
} catch (\Throwable $e) {
$order->grant_status = MallPlayxOrder::GRANT_FAILED_RETRYABLE;
$order->grant_status = MallOrder::GRANT_FAILED_RETRYABLE;
$order->fail_reason = $e->getMessage();
$order->save();
}
}
private function callPlayxBalanceCredit(MallPlayxOrder $order, string $userId): void
{
$baseUrl = rtrim(config('playx.api.base_url', ''), '/');
$url = config('playx.api.balance_credit_url', '/api/v1/balance/credit');
if ($baseUrl === '') {
return;
}
try {
$client = new \GuzzleHttp\Client(['timeout' => 15]);
$res = $client->post($baseUrl . $url, [
'json' => [
'request_id' => 'mall_withdraw_' . uniqid(),
'externalTransactionId' => $order->external_transaction_id,
'user_id' => $userId,
'amount' => $order->amount,
'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 = MallPlayxOrder::GRANT_ACCEPTED;
$order->save();
} else {
$order->grant_status = MallPlayxOrder::GRANT_FAILED_RETRYABLE;
$order->fail_reason = $data['message'] ?? 'unknown';
$order->save();
}
} catch (\Throwable $e) {
$order->grant_status = MallPlayxOrder::GRANT_FAILED_RETRYABLE;
$order->fail_reason = $e->getMessage();
$order->save();
}
}
}

View File

@@ -19,10 +19,39 @@ return [
'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',
'Parameter username can not be empty' => 'Parameter username can not be empty',
'Token expiration' => 'Session expired, please login again.',
// Member center account
'Data updated successfully~' => 'Data updated successfully~',
'Password has been changed~' => 'Password has been changed~',
'Password has been changed, please login again~' => 'Password has been 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/*
'Invalid token' => 'Invalid or expired token',
'PlayX API not configured' => 'PlayX API is not configured',
'Duplicate input' => 'Duplicate submission',
'Ok' => 'OK',
'Failed to map playx user to mall user' => 'Failed to map PlayX user to mall user',
'Missing required fields: request_id, date, user_id' => 'Missing required fields: request_id, date, user_id',
'Missing required fields: report_date, member' => 'Missing required fields: report_date, member',
'Missing required fields: member_id' => 'Missing required fields: member_id',
'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',
'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 not found or not available' => 'Item not found or not available',
'Insufficient points' => 'Insufficient 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',
'Redeem success' => 'Redeem successful',
'Withdraw submitted, please wait about 10 minutes' => 'Withdrawal submitted, please wait about 10 minutes',
];

View File

@@ -49,6 +49,9 @@ return [
'Invalid agent or secret' => '代理或密钥无效',
'Invalid signature' => '签名无效',
'Agent not found' => '代理不存在',
'Temp login is disabled' => '临时登录已关闭',
'Failed to create temp account' => '无法生成唯一手机号,请稍后重试',
'Parameter username can not be empty' => '参数 username 不能为空',
'Token expiration' => '登录态过期,请重新登录!',
'Captcha error' => '验证码错误!',
// 会员中心 account
@@ -57,4 +60,30 @@ return [
'Password has been changed, please login again~' => '密码已修改,请重新登录~',
'already exists' => '已存在',
'nicknameChsDash' => '用户名只能是汉字、字母、数字和下划线_及破折号-',
// PlayX API v1 /api/v1/*
'Invalid token' => '令牌无效或已过期',
'PlayX API not configured' => '未配置 PlayX 接口地址',
'Duplicate input' => '重复提交',
'Ok' => '成功',
'Failed to map playx user to mall user' => '无法将 PlayX 用户关联到商城用户',
'Missing required fields: request_id, date, user_id' => '缺少必填字段request_id、date、user_id',
'Missing required fields: report_date, member' => '缺少必填字段report_date、member',
'Missing required fields: member_id' => '缺少必填字段member_id',
'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',
'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 not found or not available' => '商品不存在或已下架',
'Insufficient points' => '积分不足',
'Redeem submitted, please wait about 10 minutes' => '兑换已提交,请等待约 10 分钟',
'Missing required fields' => '缺少必填字段',
'Out of stock' => '库存不足',
'Record not found' => '记录不存在',
'Shipping address not found' => '收货地址不存在',
'Redeem success' => '兑换成功',
'Withdraw submitted, please wait about 10 minutes' => '提现申请已提交,请等待约 10 分钟',
];

View File

@@ -238,6 +238,7 @@ class Backend extends Api
$limit = is_numeric($limit) ? intval($limit) : 10;
$search = $this->request->get('search', []);
$search = is_array($search) ? $search : [];
$search = $this->filterSearchArray($search);
$initKey = $this->request->get('initKey', $pk);
$initValue = $this->request->get('initValue', '');
$initOperator = $this->request->get('initOperator', 'in');
@@ -352,6 +353,14 @@ class Backend extends Api
return [$where, $alias, $limit, $this->queryOrderBuilder()];
}
/**
* 组合搜索条件过滤(子类可覆盖,例如去掉已删除的数据库字段)
*/
protected function filterSearchArray(array $search): array
{
return $search;
}
/**
* 查询的排序参数构建器
*/

View File

@@ -18,7 +18,7 @@ class AgentJwt
/**
* 生成 JWT authtoken
* @param array $payload agent_id, channel_id, admin_id 等
* @param array $payload agent_idadmin_id 等
* @param int $expire 有效期(秒)
*/
public static function encode(array $payload, int $expire = 86400): string

View File

@@ -19,6 +19,11 @@ class Auth extends \ba\Auth
public const LOGGED_IN = 'logged in';
public const TOKEN_TYPE = 'user';
/**
* 积分商城用户mall_playx_user_asset 主键Token 类型,与会员 user 表区分
*/
public const TOKEN_TYPE_MALL_USER = 'muser';
protected bool $loginEd = false;
protected string $error = '';
protected ?User $model = null;

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace app\common\library;
use app\common\model\MallItem;
use app\common\model\MallOrder;
use GuzzleHttp\Client;
/**
* 红利订单调用 PlayX bonus/grant与定时任务、后台手动推送共用
*/
final class MallBonusGrantPush
{
/**
* @return array{ok: bool, message: string, playx_transaction_id: string}
*/
public static function push(MallOrder $order): array
{
$baseUrl = rtrim(strval(config('playx.api.base_url', '')), '/');
if ($baseUrl === '') {
return [
'ok' => false,
'message' => 'PlayX base_url not configured',
'playx_transaction_id' => '',
];
}
$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;
}
$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,
],
]);
$data = json_decode(strval($res->getBody()), true) ?? [];
if ($res->getStatusCode() === 200 && ($data['status'] ?? '') === 'accepted') {
return [
'ok' => true,
'message' => '',
'playx_transaction_id' => strval($data['playx_transaction_id'] ?? ''),
];
}
return [
'ok' => false,
'message' => strval($data['message'] ?? 'PlayX bonus grant not accepted'),
'playx_transaction_id' => '',
];
} catch (\Throwable $e) {
return [
'ok' => false,
'message' => $e->getMessage(),
'playx_transaction_id' => '',
];
}
}
}

View File

@@ -0,0 +1,48 @@
<?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;
/**
* PlayX / 合作方回调:校验 Authorization: Bearer JWTHS256
*/
class PlayxInboundJwt
{
public const ALG = 'HS256';
/**
* 从 Authorization 头解析 Bearer token 并校验签名与有效期
*/
public static function verifyBearer(string $authorizationHeader, string $secret): bool
{
if ($secret === '') {
return false;
}
$token = self::extractBearer($authorizationHeader);
if ($token === '') {
return false;
}
try {
JWT::decode($token, new Key($secret, self::ALG));
return true;
} catch (ExpiredException|SignatureInvalidException|\Throwable) {
return false;
}
}
public static function extractBearer(string $authorizationHeader): string
{
if (preg_match('/Bearer\s+(\S+)/i', $authorizationHeader, $m)) {
return $m[1];
}
return '';
}
}

View File

@@ -11,12 +11,15 @@ use Exception;
*/
class TokenExpirationException extends Exception
{
protected array $data = [];
public function __construct(
protected string $message = '',
protected int $code = 409,
protected array $data = [],
string $message = '',
int $code = 409,
array $data = [],
?\Throwable $previous = null
) {
$this->data = $data;
parent::__construct($message, $code, $previous);
}

View File

@@ -30,7 +30,7 @@ class AllowCrossDomain implements MiddlewareInterface
'Access-Control-Allow-Credentials' => 'true',
'Access-Control-Max-Age' => '1800',
'Access-Control-Allow-Methods' => 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
'Access-Control-Allow-Headers' => 'Content-Type, Authorization, batoken, ba-user-token, think-lang',
'Access-Control-Allow-Headers' => 'Content-Type, Authorization, batoken, ba-user-token, think-lang, lang, server',
];
$origin = $request->header('origin');
if (is_array($origin)) {

View File

@@ -11,6 +11,9 @@ use Webman\Http\Response;
/**
* 加载控制器语言包中间件Webman 迁移版,等价 ThinkPHP LoadLangPack
* 根据当前路由加载对应控制器的语言包到 Translator
*
* 对外 api/:优先请求头 langzh / zh-cn → 中文包 zh-cnen → 英文包),未传则 think-lang再默认 zh-cn不根据浏览器 Accept-Language
* admin/think-lang → Accept-Language → 配置默认
*/
class LoadLangPack implements MiddlewareInterface
{
@@ -25,22 +28,61 @@ class LoadLangPack implements MiddlewareInterface
protected function loadLang(Request $request): void
{
// 优先从请求头 think-lang 获取前端选择的语言(与前端 axios 发送的 header 对应)
// 安装页等未发送 think-lang 时,回退到 Accept-Language 或配置默认值
$headerLang = $request->header('think-lang');
$path = trim($request->path(), '/');
$isApi = str_starts_with($path, 'api/');
$isAdmin = str_starts_with($path, 'admin/');
$allowLangList = config('lang.allow_lang_list', ['zh-cn', 'en']);
if ($headerLang && in_array(str_replace('_', '-', strtolower($headerLang)), $allowLangList)) {
$langSet = str_replace('_', '-', strtolower($headerLang));
} else {
$acceptLang = $request->header('accept-language', '');
if (preg_match('/^zh[-_]?cn|^zh/i', $acceptLang)) {
$langSet = null;
// 对外 APIPlayX、H5 等):优先 lang 请求头,默认中文 zh-cn不跟随浏览器 Accept-Language
if ($isApi) {
$langHeader = $request->header('lang');
if (is_array($langHeader)) {
$langHeader = $langHeader[0] ?? '';
}
$langHeader = is_string($langHeader) ? trim($langHeader) : '';
if ($langHeader !== '') {
$langSet = $this->normalizeLangHeader($langHeader, $allowLangList);
}
}
// 与后台 Vue 一致的 think-lang对外 API 在 lang 未设置时仍可生效)
if ($langSet === null) {
$headerLang = $request->header('think-lang');
if (is_array($headerLang)) {
$headerLang = $headerLang[0] ?? '';
}
$headerLang = is_string($headerLang) ? trim($headerLang) : '';
if ($headerLang !== '') {
$normalized = str_replace('_', '-', strtolower($headerLang));
if (in_array($normalized, $allowLangList, true)) {
$langSet = $normalized;
}
}
}
if ($langSet === null) {
if ($isApi) {
$langSet = 'zh-cn';
} elseif (preg_match('/^en/i', $acceptLang)) {
$langSet = 'en';
} elseif ($isAdmin) {
$acceptLang = $request->header('accept-language', '');
if (is_array($acceptLang)) {
$acceptLang = $acceptLang[0] ?? '';
}
$acceptLang = is_string($acceptLang) ? $acceptLang : '';
if (preg_match('/^zh[-_]?cn|^zh/i', $acceptLang)) {
$langSet = 'zh-cn';
} elseif (preg_match('/^en/i', $acceptLang)) {
$langSet = 'en';
} else {
$langSet = config('lang.default_lang', config('translation.locale', 'zh-cn'));
}
$langSet = str_replace('_', '-', strtolower((string) $langSet));
} else {
$langSet = config('lang.default_lang', config('translation.locale', 'zh-cn'));
$langSet = str_replace('_', '-', strtolower((string) $langSet));
}
$langSet = str_replace('_', '-', strtolower($langSet));
}
// 设置当前请求的翻译语言,使 __() 和 trans() 使用正确的语言
@@ -48,7 +90,6 @@ class LoadLangPack implements MiddlewareInterface
locale($langSet);
}
$path = trim($request->path(), '/');
$parts = explode('/', $path);
$app = $parts[0] ?? 'api';
@@ -81,4 +122,26 @@ class LoadLangPack implements MiddlewareInterface
}
}
}
/**
* 将 lang 请求头取值映射为语言包标识zh / zh-cn → zh-cnen → en
*/
private function normalizeLangHeader(string $raw, array $allowLangList): ?string
{
$s = str_replace('_', '-', strtolower(trim($raw)));
if ($s === '') {
return null;
}
if (in_array($s, $allowLangList, true)) {
return $s;
}
if (str_starts_with($s, 'en')) {
return in_array('en', $allowLangList, true) ? 'en' : null;
}
if ($s === 'zh' || str_starts_with($s, 'zh-')) {
return in_array('zh-cn', $allowLangList, true) ? 'zh-cn' : null;
}
return null;
}
}

View File

@@ -37,9 +37,4 @@ class Attachment extends Model
{
return $this->belongsTo(\app\admin\model\Admin::class, 'admin_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}

View File

@@ -1,84 +0,0 @@
<?php
namespace app\common\model;
use support\think\Model;
/**
* ChannelManage
*/
class ChannelManage extends Model
{
// 表名
protected $name = 'channel_manage';
// 自动写入时间戳字段
protected $autoWriteTimestamp = true;
// 字段类型转换
protected $type = [
'create_time' => 'integer',
'update_time' => 'integer',
'ip_white' => 'json',
];
/**
* 获取 IP 白名单,统一返回字符串数组格式
* 兼容:["127.0.0.1"]、[{"value":"127.0.0.1"}]、[{"127.0.0.1":""}]
*/
public function getipWhiteAttr($value): array
{
$arr = is_array($value) ? $value : (!$value ? [] : json_decode($value, true));
if (!is_array($arr)) {
return [];
}
$result = [];
foreach ($arr as $item) {
if (is_string($item)) {
$result[] = $item;
} elseif (is_array($item)) {
if (isset($item['value'])) {
$result[] = $item['value'];
} else {
$key = array_key_first($item);
if ($key !== null && $key !== '') {
$result[] = $key;
}
}
}
}
return array_values(array_filter($result));
}
/**
* 写入 IP 白名单,存储格式 ["127.0.0.1","192.168.1.1"]
*/
public function setipWhiteAttr($value): array
{
$arr = is_array($value) ? $value : [];
$result = [];
foreach ($arr as $ip) {
$ip = is_string($ip) ? trim($ip) : '';
if ($ip !== '') {
$result[] = $ip;
}
}
return array_values($result);
}
/**
* 创建时自动生成密钥strtoupper(md5(name+id))
*/
protected static function onAfterInsert($model): void
{
$pk = $model->getPk();
$secret = strtoupper(md5($model->name . $model->$pk));
$model->where($pk, $model->$pk)->update(['secret' => $secret]);
}
public function admin(): \think\model\relation\BelongsTo
{
return $this->belongsTo(\app\admin\model\Admin::class, 'admin_id', 'id');
}
}

View File

@@ -41,12 +41,46 @@ class MallAddress extends Model
public function getregionTextAttr($value, $row): string
{
if ($row['region'] === '' || $row['region'] === null) return '';
$cityNames = \support\think\Db::name('area')->whereIn('id', $row['region'])->column('name');
$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 mallUser(): \think\model\relation\BelongsTo
public function playxUserAsset(): \think\model\relation\BelongsTo
{
return $this->belongsTo(\app\common\model\MallUser::class, 'mall_user_id', 'id');
return $this->belongsTo(\app\common\model\MallUserAsset::class, 'playx_user_asset_id', 'id');
}
/**
* 实物订单收货快照(写入 mall_order.receiver_*,与 mall_address 当前内容一致)
*
* @return array{receiver_name: string, receiver_phone: string, receiver_address: string}
*/
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,
];
}
}

View File

@@ -7,14 +7,15 @@ namespace app\common\model;
use support\think\Model;
/**
* PlayX 领取记录(幂等)
* 领取记录(幂等)
*/
class MallPlayxClaimLog extends Model
class MallClaimLog extends Model
{
protected string $name = 'mall_playx_claim_log';
protected string $name = 'mall_claim_log';
protected array $type = [
'claimed_amount' => 'integer',
'create_time' => 'integer',
];
}

View File

@@ -7,11 +7,11 @@ namespace app\common\model;
use support\think\Model;
/**
* PlayX 每日推送数据
* 每日推送数据
*/
class MallPlayxDailyPush extends Model
class MallDailyPush extends Model
{
protected string $name = 'mall_playx_daily_push';
protected string $name = 'mall_daily_push';
protected array $type = [
'yesterday_win_loss_net' => 'float',
@@ -21,3 +21,4 @@ class MallPlayxDailyPush extends Model
'create_time' => 'integer',
];
}

View File

@@ -7,7 +7,7 @@ namespace app\common\model;
use support\think\Model;
/**
* PlayX 统一订单
* 统一订单
*
* @property int $id
* @property string $user_id
@@ -28,10 +28,11 @@ use support\think\Model;
* @property string $receiver_name
* @property string $receiver_phone
* @property string|null $receiver_address
* @property int|null $mall_address_id
*/
class MallPlayxOrder extends Model
class MallOrder extends Model
{
protected string $name = 'mall_playx_order';
protected string $name = 'mall_order';
protected bool $autoWriteTimestamp = true;
@@ -50,17 +51,27 @@ class MallPlayxOrder extends Model
public const GRANT_FAILED_RETRYABLE = 'FAILED_RETRYABLE';
public const GRANT_FAILED_FINAL = 'FAILED_FINAL';
/** 非红利订单不参与 PlayX/Angpow 推送,固定为该占位值 */
public const GRANT_NOT_APPLICABLE = '---';
protected array $type = [
'create_time' => 'integer',
'update_time' => 'integer',
'points_cost' => 'integer',
'amount' => 'float',
'multiplier' => 'integer',
'retry_count' => 'integer',
'create_time' => 'integer',
'update_time' => 'integer',
'points_cost' => 'integer',
'amount' => 'float',
'multiplier' => 'integer',
'retry_count' => 'integer',
'mall_address_id' => 'integer',
];
public function mallItem(): \think\model\relation\BelongsTo
{
return $this->belongsTo(MallItem::class, 'mall_item_id', 'id');
}
public function mallAddress(): \think\model\relation\BelongsTo
{
return $this->belongsTo(MallAddress::class, 'mall_address_id', 'id');
}
}

View File

@@ -19,8 +19,8 @@ class MallPintsOrder extends Model
protected $autoWriteTimestamp = true;
public function mallUser(): \think\model\relation\BelongsTo
public function playxUserAsset(): \think\model\relation\BelongsTo
{
return $this->belongsTo(\app\common\model\MallUser::class, 'mall_user_id', 'id');
return $this->belongsTo(\app\common\model\MallUserAsset::class, 'playx_user_asset_id', 'id');
}
}

View File

@@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace app\common\model;
use support\think\Model;
/**
* PlayX 会话缓存
*/
class MallPlayxSession extends Model
{
protected string $name = 'mall_playx_session';
protected bool $autoWriteTimestamp = true;
protected array $type = [
// 这里需要显式声明 create_time / update_time 为 integer
// 否则 ThinkORM 可能把 bigint 时间戳当成字符串,导致写入时出现 now 字符串问题。
'create_time' => 'integer',
'update_time' => 'integer',
'expire_time' => 'integer',
];
}

View File

@@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace app\common\model;
use support\think\Model;
/**
* PlayX 用户资产
*/
class MallPlayxUserAsset extends Model
{
protected string $name = 'mall_playx_user_asset';
protected bool $autoWriteTimestamp = true;
protected array $type = [
'create_time' => 'integer',
'update_time' => 'integer',
'locked_points' => 'integer',
'available_points' => 'integer',
'today_limit' => 'integer',
'today_claimed' => 'integer',
];
}

View File

@@ -19,9 +19,9 @@ class MallRedemptionOrder extends Model
protected $autoWriteTimestamp = true;
public function mallUser(): \think\model\relation\BelongsTo
public function playxUserAsset(): \think\model\relation\BelongsTo
{
return $this->belongsTo(\app\common\model\MallUser::class, 'mall_user_id', 'id');
return $this->belongsTo(\app\common\model\MallUserAsset::class, 'playx_user_asset_id', 'id');
}
public function mallItem(): \think\model\relation\BelongsTo

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace app\common\model;
use support\think\Model;
/**
* 会话缓存
*/
class MallSession extends Model
{
protected string $name = 'mall_session';
protected bool $autoWriteTimestamp = true;
protected array $type = [
'create_time' => 'integer',
'update_time' => 'integer',
'expire_time' => 'integer',
];
}

View File

@@ -1,31 +0,0 @@
<?php
namespace app\common\model;
use app\common\model\traits\TimestampInteger;
use support\think\Model;
/**
* 商城用户模型
*/
class MallUser extends Model
{
use TimestampInteger;
protected string $name = 'mall_user';
protected bool $autoWriteTimestamp = true;
public function admin(): \think\model\relation\BelongsTo
{
return $this->belongsTo(\app\admin\model\Admin::class, 'admin_id', 'id');
}
/**
* 重置密码(加密存储)
*/
public function resetPassword(int $id, string $newPassword): bool
{
return $this->where(['id' => $id])->update(['password' => hash_password($newPassword)]) !== false;
}
}

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace app\common\model;
use ba\Random;
use support\think\Model;
/**
* 用户资产(积分商城用户主表,含登录账号字段)
*/
class MallUserAsset extends Model
{
protected string $name = 'mall_user_asset';
protected bool $autoWriteTimestamp = true;
protected array $type = [
'create_time' => 'integer',
'update_time' => 'integer',
'locked_points' => 'integer',
'available_points' => 'integer',
'today_limit' => 'integer',
'today_claimed' => 'integer',
'admin_id' => 'integer',
];
/**
* H5 临时登录按用户名查找或创建资产行playx_user_id 使用 mall_{id}
*/
public static function ensureForUsername(string $username): self
{
$username = trim($username);
$existing = self::where('username', $username)->find();
if ($existing) {
return $existing;
}
// 创建用户时phone 与 username 同值H5 临时账号)
$phone = $username;
$phoneExisting = self::where('phone', $phone)->find();
if ($phoneExisting) {
// 若历史数据存在“手机号=用户名”的行,直接复用;若不一致则拒绝创建,避免污染
if (trim((string) ($phoneExisting->username ?? '')) === $username) {
return $phoneExisting;
}
throw new \RuntimeException('Username is already used by another account');
}
$pwd = hash_password(Random::build('alnum', 16));
$now = time();
$temporaryPlayxId = 'tmp_' . bin2hex(random_bytes(16));
$created = self::create([
'playx_user_id' => $temporaryPlayxId,
'username' => $username,
'phone' => $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');
}
$id = $created->getKey();
$finalPlayxId = 'mall_' . $id;
if (self::where('playx_user_id', $finalPlayxId)->where('id', '<>', $id)->find()) {
$finalPlayxId = 'mall_' . $id . '_' . bin2hex(random_bytes(4));
}
$created->playx_user_id = $finalPlayxId;
$created->save();
return $created;
}
// allocateUniquePhone 已废弃:临时登录场景下 phone=用户名
}

View File

@@ -1,31 +0,0 @@
<?php
namespace app\common\validate;
use think\Validate;
class ChannelManage extends Validate
{
protected $failException = true;
/**
* 验证规则
*/
protected $rule = [
];
/**
* 提示消息
*/
protected $message = [
];
/**
* 验证场景
*/
protected $scene = [
'add' => [],
'edit' => [],
];
}

View File

@@ -1,25 +0,0 @@
<?php
namespace app\common\validate;
use think\Validate;
class MallUser extends Validate
{
protected $failException = true;
protected $rule = [
'username' => 'require',
'phone' => 'require',
];
protected $message = [
'username.require' => '用户名不能为空',
'phone.require' => '手机号不能为空',
];
protected $scene = [
'add' => ['username', 'phone'],
'edit' => ['username', 'phone'],
];
}

View File

@@ -167,7 +167,7 @@ if (!function_exists('get_auth_token')) {
if (!function_exists('get_agent_jwt_payload')) {
/**
* 解析 Agent JWT authtoken返回 payloadagent_id、channel_id、admin_id 等)
* 解析 Agent JWT authtoken返回 payloadagent_id、admin_id 等)
* @param string $token authtoken
* @return array 成功返回 payload失败返回空数组
*/
@@ -200,7 +200,8 @@ if (!function_exists('get_controller_path')) {
$parts = explode('\\', $relative);
$path = [];
foreach ($parts as $p) {
$path[] = strtolower(preg_replace('/([a-z])([A-Z])/', '$1_$2', $p));
// 与 admin_rule.name / 前端 path 一致:驼峰首字母小写(如 AdminInfo -> adminInfo不用 snake_case
$path[] = lcfirst($p);
}
return implode('/', $path);
}
@@ -216,7 +217,19 @@ if (!function_exists('get_controller_path')) {
if (count($parts) < 2) {
return $parts[0] ?? null;
}
return implode('/', array_slice($parts, 1, -1)) ?: $parts[1];
// admin/routine.Attachment/index -> routine/attachmentThinkPHP 风格 URL 中带点的控制器段)
$middle = array_slice($parts, 1, -1);
$segments = [];
foreach ($middle as $m) {
if (str_contains($m, '.')) {
foreach (explode('.', $m) as $dotPart) {
$segments[] = lcfirst($dotPart);
}
} else {
$segments[] = lcfirst($m);
}
}
return $segments !== [] ? implode('/', $segments) : ($parts[1] ?? null);
}
}

View File

@@ -0,0 +1,328 @@
<?php
namespace app\process;
use app\common\model\MallItem;
use app\common\model\MallOrder;
use app\common\model\MallUserAsset;
use GuzzleHttp\Client;
use Workerman\Timer;
use Workerman\Worker;
/**
* Angpow 导入推送任务
* - 数据源mall_ordertype=BONUS
* - 推送频率:每 30 秒
* - 批量:每次最多 100 条(对方文档限制)
* - 幂等merchant_code + report_date 级别签名;每条订单通过 external_transaction_id 在本地控制只推送一次
*/
class AngpowImportJobs
{
private const TIMER_SECONDS = 30;
private const BATCH_LIMIT = 100;
private const MAX_RETRY = 3;
protected Client $http;
public function __construct()
{
// 确保定时任务只在一个 worker 上运行
if (!Worker::getAllWorkers()) {
return;
}
$this->http = new Client($this->buildGuzzleOptions());
Timer::add(self::TIMER_SECONDS, [$this, 'pushPendingOrders']);
}
/**
* Guzzle 默认校验 HTTPSWindows 未配置 CA 时会出现 cURL error 60。
* 优先使用 PLAYX_ANGPOW_IMPORT_CACERT 指向 cacert.pem否则可按环境关闭校验仅开发
*/
private function buildGuzzleOptions(): array
{
$options = [
'timeout' => 20,
'http_errors' => false,
];
$conf = config('playx.angpow_import');
if (!is_array($conf)) {
return $options;
}
$caFile = $conf['ca_file'] ?? '';
if (is_string($caFile) && $caFile !== '' && is_file($caFile)) {
$options['verify'] = $caFile;
return $options;
}
$verifySsl = $conf['verify_ssl'] ?? true;
if ($verifySsl === false) {
$options['verify'] = false;
}
return $options;
}
public function pushPendingOrders(): void
{
$conf = config('playx.angpow_import');
if (!is_array($conf)) {
return;
}
$baseUrl = $conf['base_url'] ?? '';
$path = $conf['path'] ?? '';
$merchantCode = $conf['merchant_code'] ?? '';
$authKey = $conf['auth_key'] ?? '';
if (!is_string($baseUrl) || $baseUrl === '' || !is_string($path) || $path === '' || !is_string($merchantCode) || $merchantCode === '') {
return;
}
if (!is_string($authKey) || $authKey === '') {
return;
}
$url = rtrim($baseUrl, '/') . $path;
$orders = MallOrder::where('type', MallOrder::TYPE_BONUS)
->whereIn('grant_status', [MallOrder::GRANT_NOT_SENT, MallOrder::GRANT_FAILED_RETRYABLE])
->where('status', MallOrder::STATUS_PENDING)
->where('retry_count', '<', self::MAX_RETRY)
->order('id', 'asc')
->limit(self::BATCH_LIMIT)
->select();
if ($orders->isEmpty()) {
return;
}
$reportDate = strval(time());
$signatureInput = 'merchant_code=' . $merchantCode . '&report_date=' . $reportDate;
$signature = $this->buildSignature($signatureInput, $authKey);
if ($signature === null) {
return;
}
$payload = [
'merchant_code' => $merchantCode,
'report_date' => $reportDate,
'angpow' => [],
'currency_visual' => [
[
'currency' => strval($conf['currency'] ?? 'MYR'),
'visual_name' => strval($conf['visual_name'] ?? 'Angpow'),
],
],
];
$orderIds = [];
foreach ($orders as $order) {
if (!$order instanceof MallOrder) {
continue;
}
$row = $this->buildAngpowRow($order);
if ($row === null) {
// 构造失败:直接标为可重试失败
$this->markFailedAttempt($order, 'Build payload failed');
continue;
}
$payload['angpow'][] = $row;
$orderIds[] = $order->id;
}
if (empty($payload['angpow'])) {
return;
}
// 先标记“已尝试发送”,避免并发重复推送;同时只在这里累加 retry_count一次推送=一次尝试)
$now = time();
foreach ($orders as $order) {
if (!$order instanceof MallOrder) {
continue;
}
if (!in_array($order->id, $orderIds, true)) {
continue;
}
$retry = $order->retry_count ?? 0;
if (!is_int($retry)) {
$retry = is_numeric($retry) ? intval($retry) : 0;
}
$order->retry_count = $retry + 1;
$order->grant_status = MallOrder::GRANT_SENT_PENDING;
$order->update_time = $now;
$order->save();
}
$res = null;
$body = '';
try {
$res = $this->http->post($url, [
'headers' => [
'Content-Type' => 'application/json',
'X-Request-Signature' => $signature,
],
'json' => $payload,
]);
$body = strval($res->getBody());
} catch (\Throwable $e) {
// 网络/异常:对这一批订单记一次失败尝试
foreach ($orders as $order) {
if (!$order instanceof MallOrder) {
continue;
}
$this->markFailedAttempt($order, $e->getMessage());
}
return;
}
$data = json_decode($body, true);
if (!is_array($data)) {
foreach ($orders as $order) {
if (!$order instanceof MallOrder) {
continue;
}
$this->markFailedAttempt($order, 'Invalid response');
}
return;
}
$code = $data['code'] ?? null;
$message = $data['message'] ?? '';
$msg = is_string($message) ? $message : 'Request failed';
// 成功code=0
if ($code === '0' || $code === 0) {
MallOrder::whereIn('id', $orderIds)->update([
'grant_status' => MallOrder::GRANT_ACCEPTED,
'fail_reason' => null,
'update_time' => time(),
]);
return;
}
// 失败:整批视为失败(对方未提供逐条返回)
foreach ($orders as $order) {
if (!$order instanceof MallOrder) {
continue;
}
$this->markFailedAttempt($order, $msg);
}
}
private function buildAngpowRow(MallOrder $order): ?array
{
$asset = MallUserAsset::where('playx_user_id', $order->user_id)->find();
if (!$asset) {
if (is_string($order->user_id) && ctype_digit($order->user_id)) {
$byId = MallUserAsset::where('id', $order->user_id)->find();
if ($byId) {
$asset = $byId;
}
}
}
if (!$asset || !is_string($asset->playx_user_id ?? null) || strval($asset->playx_user_id) === '') {
return null;
}
$item = null;
if ($order->mallItem) {
$item = $order->mallItem;
} else {
$item = MallItem::where('id', $order->mall_item_id)->find();
}
if (!$item) {
return null;
}
// $createTime = $order->create_time ?? null;
// if (!is_int($createTime)) {
// if (is_numeric($createTime)) {
// $createTime = intval($createTime);
// } else {
// $createTime = time();
// }
// }
$start = gmdate('Y-m-d H:i:s', strtotime($order->start_time));
$end = gmdate('Y-m-d H:i:s', strtotime($order->end_time));
return [
'member_login' => strval($asset->playx_user_id),
'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 ?? ''),
'one_time_turnover' => 'yes',
'multiplier' => $order->multiplier,
];
}
private function markFailedAttempt(MallOrder $order, string $reason): void
{
// retry_count 在“准备发送”阶段已 +1此处用当前 retry_count 作为 attempt 编号
$retryCount = $order->retry_count ?? 0;
$attempt = is_int($retryCount) ? $retryCount : (is_numeric($retryCount) ? intval($retryCount) : 0);
if ($attempt <= 0) {
$attempt = 1;
$order->retry_count = 1;
}
$prev = $order->fail_reason;
$prefix = 'attempt ' . $attempt . ': ';
$line = $prefix . $reason;
$newReason = $line;
if (is_string($prev) && $prev !== '') {
$newReason = $prev . "\n" . $line;
}
$final = $attempt >= self::MAX_RETRY;
$order->grant_status = $final ? MallOrder::GRANT_FAILED_FINAL : MallOrder::GRANT_FAILED_RETRYABLE;
$order->fail_reason = $newReason;
$order->save();
}
/**
* 生成对方要求的 Base64(HMAC-SHA1)
* - 文档中示例 python 会 base64_decode(key) 后参与 hmac
* - 生产 key 由 BA 提供,可能是 base64 或 hex这里做兼容处理
*/
private 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

@@ -3,12 +3,12 @@
namespace app\process;
use Webman\App;
use Webman\Http\Response;
class Http extends App
{
/**
* 在父类处理前拦截 OPTIONS 预检,直接返回 CORS 头(避免预检未命中路由时无 CORS
* 必须与 AllowCrossDomain::optionsResponse 一致,否则会覆盖中间件里对 Allow-Headers如 server的配置
*/
public function onMessage($connection, $request): void
{
@@ -18,19 +18,8 @@ class Http extends App
$path = is_string($path) ? trim($path, '/') : '';
$isApiOrAdmin = $path !== '' && (str_starts_with($path, 'api') || str_starts_with($path, 'admin'));
if ($isApiOrAdmin) {
$origin = $request->header('origin');
$origin = is_array($origin) ? ($origin[0] ?? '') : (is_string($origin) ? trim($origin) : '');
if ($origin === '') {
$origin = '*';
}
$headers = [
'Access-Control-Allow-Origin' => $origin,
'Access-Control-Allow-Credentials' => 'true',
'Access-Control-Max-Age' => '1800',
'Access-Control-Allow-Methods' => 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
'Access-Control-Allow-Headers' => 'Content-Type, Authorization, batoken, ba-user-token, think-lang',
];
$connection->send(new Response(204, $headers, ''));
$response = \app\common\middleware\AllowCrossDomain::optionsResponse($request);
$connection->send($response);
return;
}
}

View File

@@ -2,9 +2,9 @@
namespace app\process;
use app\common\model\MallItem;
use app\common\model\MallPlayxOrder;
use app\common\model\MallPlayxUserAsset;
use app\common\library\MallBonusGrantPush;
use app\common\model\MallOrder;
use app\common\model\MallUserAsset;
use GuzzleHttp\Client;
use Workerman\Timer;
use Workerman\Worker;
@@ -47,14 +47,15 @@ class PlayxJobs
$path = strval(config('playx.api.transaction_status_url', '/api/v1/transaction/status'));
$url = rtrim($baseUrl, '/') . $path;
$list = MallPlayxOrder::where('grant_status', MallPlayxOrder::GRANT_ACCEPTED)
->where('status', MallPlayxOrder::STATUS_PENDING)
$list = MallOrder::where('type', MallOrder::TYPE_BONUS)
->where('grant_status', MallOrder::GRANT_ACCEPTED)
->where('status', MallOrder::STATUS_PENDING)
->order('id', 'desc')
->limit(50)
->select();
foreach ($list as $order) {
/** @var MallPlayxOrder $order */
/** @var MallOrder $order */
try {
$res = $this->http->get($url, [
'query' => [
@@ -65,16 +66,16 @@ class PlayxJobs
$data = json_decode(strval($res->getBody()), true) ?? [];
$pxStatus = $data['status'] ?? '';
if ($pxStatus === MallPlayxOrder::STATUS_COMPLETED) {
$order->status = MallPlayxOrder::STATUS_COMPLETED;
if ($pxStatus === MallOrder::STATUS_COMPLETED) {
$order->status = MallOrder::STATUS_COMPLETED;
$order->save();
continue;
}
if ($pxStatus === 'FAILED' || $pxStatus === MallPlayxOrder::STATUS_REJECTED) {
if ($pxStatus === 'FAILED' || $pxStatus === MallOrder::STATUS_REJECTED) {
// 仅在从 PENDING 转 REJECTED 时退分,避免重复入账
$order->status = MallPlayxOrder::STATUS_REJECTED;
$order->grant_status = MallPlayxOrder::GRANT_FAILED_FINAL;
$order->status = MallOrder::STATUS_REJECTED;
$order->grant_status = MallOrder::GRANT_FAILED_FINAL;
$order->fail_reason = strval($data['message'] ?? 'PlayX transaction failed');
$order->save();
$this->refundPoints($order);
@@ -98,24 +99,20 @@ class PlayxJobs
return;
}
$bonusPath = strval(config('playx.api.bonus_grant_url', '/api/v1/bonus/grant'));
$withdrawPath = strval(config('playx.api.balance_credit_url', '/api/v1/balance/credit'));
$bonusUrl = rtrim($baseUrl, '/') . $bonusPath;
$withdrawUrl = rtrim($baseUrl, '/') . $withdrawPath;
$maxRetry = 3;
$list = MallPlayxOrder::whereIn('grant_status', [
MallPlayxOrder::GRANT_NOT_SENT,
MallPlayxOrder::GRANT_FAILED_RETRYABLE,
$list = MallOrder::where('type', MallOrder::TYPE_BONUS)
->whereIn('grant_status', [
MallOrder::GRANT_NOT_SENT,
MallOrder::GRANT_FAILED_RETRYABLE,
])
->where('status', MallPlayxOrder::STATUS_PENDING)
->where('status', MallOrder::STATUS_PENDING)
->where('retry_count', '<', $maxRetry)
->order('id', 'desc')
->limit(50)
->select();
foreach ($list as $order) {
/** @var MallPlayxOrder $order */
/** @var MallOrder $order */
$allow = $this->allowRetryByInterval($order);
if (!$allow) {
continue;
@@ -124,25 +121,23 @@ class PlayxJobs
$order->retry_count = intval($order->retry_count ?? 0) + 1;
try {
$this->sendGrantByOrder($order, $bonusUrl, $withdrawUrl, $maxRetry);
$this->sendGrantByOrder($order, $maxRetry);
} catch (\Throwable $e) {
$order->fail_reason = $e->getMessage();
if (intval($order->retry_count) >= $maxRetry) {
$order->grant_status = MallPlayxOrder::GRANT_FAILED_FINAL;
$order->status = MallPlayxOrder::STATUS_REJECTED;
$order->grant_status = MallOrder::GRANT_FAILED_FINAL;
$order->save();
$this->refundPoints($order);
} else {
$order->grant_status = MallPlayxOrder::GRANT_FAILED_RETRYABLE;
$order->grant_status = MallOrder::GRANT_FAILED_RETRYABLE;
$order->save();
}
}
}
}
private function allowRetryByInterval(MallPlayxOrder $order): bool
private function allowRetryByInterval(MallOrder $order): bool
{
if ($order->grant_status === MallPlayxOrder::GRANT_NOT_SENT) {
if ($order->grant_status === MallOrder::GRANT_NOT_SENT) {
return true;
}
@@ -167,107 +162,39 @@ class PlayxJobs
return false;
}
private function sendGrantByOrder(MallPlayxOrder $order, string $bonusUrl, string $withdrawUrl, int $maxRetry): void
private function sendGrantByOrder(MallOrder $order, int $maxRetry): void
{
$item = null;
if ($order->mallItem) {
$item = $order->mallItem;
} else {
$item = MallItem::where('id', $order->mall_item_id)->find();
}
if ($order->type === MallPlayxOrder::TYPE_BONUS) {
$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;
}
$requestId = 'mall_retry_bonus_' . uniqid();
$res = $this->http->post($bonusUrl, [
'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,
],
]);
$data = json_decode(strval($res->getBody()), true) ?? [];
if ($res->getStatusCode() === 200 && ($data['status'] ?? '') === 'accepted') {
$order->grant_status = MallPlayxOrder::GRANT_ACCEPTED;
$order->playx_transaction_id = strval($data['playx_transaction_id'] ?? '');
$order->save();
return;
}
$order->fail_reason = strval($data['message'] ?? 'PlayX bonus grant not accepted');
if (intval($order->retry_count) >= $maxRetry) {
$order->grant_status = MallPlayxOrder::GRANT_FAILED_FINAL;
$order->status = MallPlayxOrder::STATUS_REJECTED;
$order->save();
$this->refundPoints($order);
return;
}
$order->grant_status = MallPlayxOrder::GRANT_FAILED_RETRYABLE;
$order->save();
if ($order->type !== MallOrder::TYPE_BONUS) {
return;
}
if ($order->type === MallPlayxOrder::TYPE_WITHDRAW) {
$multiplier = intval($order->multiplier ?? 0);
if ($multiplier <= 0) {
$multiplier = 1;
}
$requestId = 'mall_retry_withdraw_' . uniqid();
$res = $this->http->post($withdrawUrl, [
'json' => [
'request_id' => $requestId,
'externalTransactionId' => $order->external_transaction_id,
'user_id' => $order->user_id,
'amount' => $order->amount,
'multiplier' => $multiplier,
],
]);
$data = json_decode(strval($res->getBody()), true) ?? [];
if ($res->getStatusCode() === 200 && ($data['status'] ?? '') === 'accepted') {
$order->grant_status = MallPlayxOrder::GRANT_ACCEPTED;
$order->playx_transaction_id = strval($data['playx_transaction_id'] ?? '');
$order->save();
return;
}
$order->fail_reason = strval($data['message'] ?? 'PlayX balance credit not accepted');
if (intval($order->retry_count) >= $maxRetry) {
$order->grant_status = MallPlayxOrder::GRANT_FAILED_FINAL;
$order->status = MallPlayxOrder::STATUS_REJECTED;
$order->save();
$this->refundPoints($order);
return;
}
$order->grant_status = MallPlayxOrder::GRANT_FAILED_RETRYABLE;
$result = MallBonusGrantPush::push($order);
if ($result['ok']) {
$order->grant_status = MallOrder::GRANT_ACCEPTED;
$order->playx_transaction_id = $result['playx_transaction_id'];
$order->save();
return;
}
// PHYSICAL 目前由后台手工发货/驳回,不参与 PlayX 发放重试
$order->fail_reason = $result['message'];
if (intval($order->retry_count) >= $maxRetry) {
$order->grant_status = MallOrder::GRANT_FAILED_FINAL;
$order->save();
return;
}
$order->grant_status = MallOrder::GRANT_FAILED_RETRYABLE;
$order->save();
}
private function refundPoints(MallPlayxOrder $order): void
private function refundPoints(MallOrder $order): void
{
if ($order->points_cost <= 0) {
return;
}
$asset = MallPlayxUserAsset::where('user_id', $order->user_id)->find();
$asset = MallUserAsset::where('playx_user_id', $order->user_id)->find();
if (!$asset) {
return;
}

View File

@@ -40,7 +40,8 @@
"nelexa/zip": "^4.0.0",
"voku/anti-xss": "^4.1",
"topthink/think-validate": "^3.0",
"firebase/php-jwt": "^7.0"
"firebase/php-jwt": "^7.0",
"guzzlehttp/guzzle": "^7.10"
},
"suggest": {
"ext-event": "For better performance. "

View File

@@ -5,11 +5,11 @@
return [
// 允许跨域访问的域名(* 表示任意;开发可用 *,生产建议填具体域名)
'cors_request_domain' => '*',
'cors_request_domain' => '*,playx-admin.cjdhr.top',
// 是否开启会员登录验证码
'user_login_captcha' => true,
'user_login_captcha' => false,
// 是否开启管理员登录验证码
'admin_login_captcha' => true,
'admin_login_captcha' => false,
// 会员登录失败可重试次数,false则无限
'user_login_retry' => 10,
// 管理员登录失败可重试次数,false则无限
@@ -87,10 +87,14 @@ return [
'agents' => [
// 'agent_001' => 'your_secret_key',
],
// JWT 签名密钥(留空则使用 token.key
'jwt_secret' => '',
// 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,
// 临时登录 token 有效期(秒),默认 1 天
'temp_login_expire' => 86400,
],
// 版本号
'version' => 'v2.3.6',

View File

@@ -12,8 +12,18 @@ return [
'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', '')),
/**
* 合作方 JWT 验签密钥HS256。非空时dailyPush 等回调需带 Authorization: Bearer
* 仅写入部署环境变量,勿提交仓库。
*/
'partner_jwt_secret' => strval(env('PLAYX_PARTNER_JWT_SECRET', '')),
// 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。
*/
'verify_token_local_only' => filter_var(env('PLAYX_VERIFY_TOKEN_LOCAL_ONLY', '1'), FILTER_VALIDATE_BOOLEAN),
// PlayX API 配置(商城调用 PlayX 时使用)
'api' => [
'base_url' => strval(env('PLAYX_API_BASE_URL', '')),
@@ -23,4 +33,22 @@ return [
'balance_credit_url' => '/api/v1/balance/credit',
'transaction_status_url' => '/api/v1/transaction/status',
],
// Angpow Import API商城调用对方 cashmarket/merchant-api 时使用)
'angpow_import' => [
// 对方 base_url例如 https://ss2-staging2.ttwd8.com
'base_url' => strval(env('PLAYX_ANGPOW_IMPORT_BASE_URL', '')),
// 路径:文档示例为 /api/v3/merchant/angpow-imports对方 curl 示例为 /cashmarket/v3/merchant-api/angpow-imports
'path' => strval(env('PLAYX_ANGPOW_IMPORT_PATH', '/api/v3/merchant/angpow-imports')),
// merchant_code固定 plx 或按环境配置)
'merchant_code' => strval(env('PLAYX_ANGPOW_MERCHANT_CODE', 'plx')),
// HMAC-SHA1 的 auth key生产环境由 BA 提供)
'auth_key' => strval(env('PLAYX_ANGPOW_IMPORT_AUTH_KEY', '')),
// HTTPS指定 CA 证书包路径(推荐,下载 https://curl.se/ca/cacert.pem 后填绝对路径,可避免 cURL error 60
'ca_file' => strval(env('PLAYX_ANGPOW_IMPORT_CACERT', '')),
// 是否校验 SSL生产必须为 true本地无 CA 时可临时 false勿用于生产
'verify_ssl' => filter_var(env('PLAYX_ANGPOW_IMPORT_VERIFY_SSL', '1'), FILTER_VALIDATE_BOOLEAN),
// 固定货币展示映射
'currency' => 'MYR',
'visual_name' => 'Angpow',
],
];

View File

@@ -15,6 +15,7 @@
use support\Log;
use support\Request;
use app\process\Http;
use app\process\AngpowImportJobs;
global $argv;
@@ -65,4 +66,9 @@ return [
'handler' => app\process\PlayxJobs::class,
'reloadable' => false,
],
// Angpow 导入推送任务:订单兑换后推送到对方平台
'angpow_import_jobs' => [
'handler' => AngpowImportJobs::class,
'reloadable' => false,
],
];

View File

@@ -110,17 +110,22 @@ 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 积分商城
Route::post('/api/v1/playx/daily-push', [\app\api\controller\v1\Playx::class, 'dailyPush']);
Route::post('/api/v1/playx/verify-token', [\app\api\controller\v1\Playx::class, 'verifyToken']);
Route::get('/api/v1/playx/assets', [\app\api\controller\v1\Playx::class, 'assets']);
Route::post('/api/v1/playx/claim', [\app\api\controller\v1\Playx::class, 'claim']);
Route::get('/api/v1/playx/items', [\app\api\controller\v1\Playx::class, 'items']);
Route::post('/api/v1/playx/bonus/redeem', [\app\api\controller\v1\Playx::class, 'bonusRedeem']);
Route::post('/api/v1/playx/physical/redeem', [\app\api\controller\v1\Playx::class, 'physicalRedeem']);
Route::post('/api/v1/playx/withdraw/apply', [\app\api\controller\v1\Playx::class, 'withdrawApply']);
Route::get('/api/v1/playx/orders', [\app\api\controller\v1\Playx::class, 'orders']);
Route::post('/api/v1/mall/dailyPush', [\app\api\controller\v1\Playx::class, 'dailyPush']);
Route::post('/api/v1/mall/verifyToken', [\app\api\controller\v1\Playx::class, 'verifyToken']);
Route::get('/api/v1/mall/assets', [\app\api\controller\v1\Playx::class, 'assets']);
Route::post('/api/v1/mall/claim', [\app\api\controller\v1\Playx::class, 'claim']);
Route::get('/api/v1/mall/items', [\app\api\controller\v1\Playx::class, 'items']);
Route::post('/api/v1/mall/bonusRedeem', [\app\api\controller\v1\Playx::class, 'bonusRedeem']);
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/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']);
Route::post('/api/v1/mall/addressDelete', [\app\api\controller\v1\Playx::class, 'addressDelete']);
// ==================== Admin 路由 ====================
// Admin 多为 JSON API前端可能用 GET 传参查列表、POST 提交表单,使用 any 确保兼容

View File

@@ -1,30 +0,0 @@
<?php
use Phinx\Migration\AbstractMigration;
/**
* 积分商城用户表 mall_player
*/
class MallPlayer extends AbstractMigration
{
public function change(): void
{
if (!$this->hasTable('mall_player')) {
$table = $this->table('mall_player', [
'id' => false,
'comment' => '积分商城用户表',
'row_format' => 'DYNAMIC',
'primary_key' => 'id',
'collation' => 'utf8mb4_unicode_ci',
]);
$table->addColumn('id', 'integer', ['comment' => 'ID', 'signed' => false, 'identity' => true, 'null' => false])
->addColumn('username', 'string', ['limit' => 50, 'default' => '', 'comment' => '用户名', 'null' => false])
->addColumn('password', 'string', ['limit' => 255, 'default' => '', 'comment' => '密码', 'null' => false])
->addColumn('score', 'integer', ['signed' => false, 'default' => 0, 'comment' => '积分', 'null' => false])
->addColumn('create_time', 'biginteger', ['signed' => false, 'null' => true, 'default' => null, 'comment' => '创建时间'])
->addColumn('update_time', 'biginteger', ['signed' => false, 'null' => true, 'default' => null, 'comment' => '修改时间'])
->addIndex(['username'])
->create();
}
}
}

View File

@@ -0,0 +1,205 @@
# H5 积分商城接口文档(含流程说明)
> 面向H5活动页/积分商城前台)调用
> 基础路径:`/api/v1`
> 返回结构BuildAdmin 通用 `code/msg/time/data`(成功 `code=1`
---
## 1. 总体流程说明
### 1.1 流程 AH5 临时登录(推荐)
适用场景H5 只需要“用户名级别”的轻量登录,不依赖 PlayX 的 token。
1. H5 调用 `GET/POST /api/v1/temLogin?username=xxx` 获取 **商城 token**(类型 `muser`
2. H5 后续请求统一携带该 token推荐放在 Header`token: <muser_token>`,也可用参数 `token`
3. H5 调用:
- `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` 查询订单
- `GET/POST /api/v1/mall/address*` 管理地址addressList/addressAdd/addressEdit/addressDelete
### 1.2 流程 BPlayX token 换取 session兼容
适用场景H5 已经拿到了 PlayX 下发的 token希望换取商城侧 `session_id`
1. H5 调用 `POST /api/v1/mall/verifyToken`(传 `token``session`
2. 服务端返回 `data.session_id`
3. H5 后续请求携带 `session_id`(优先级高于 token
---
## 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` 查找
推荐做法:
- H5 统一只用 **Header `token`**(值为 `muser` token避免 URL 泄露与参数歧义。
---
## 3. 接口列表H5 常用)
### 3.1 H5 临时登录
**GET/POST** ` /api/v1/temLogin `
参数:
- `username`:必填,用户名(字符串)
成功返回 `data.userInfo`
- `id`:资产主键(`mall_user_asset.id`
- `username`:用户名
- `playx_user_id`:映射的 PlayX 用户标识(字符串)
- `token`**muser token**(后续请求使用)
- `refresh_token`:刷新 token当前前端未强依赖可不接
- `expires_in`:秒
示例:
```bash
curl "https://{域名}/api/v1/temLogin?username=test001"
```
---
### 3.2 资产查询
**GET** ` /api/v1/mall/assets `
鉴权:携带 `token``session_id``user_id`
成功返回 `data`
- `locked_points`:待领取积分
- `available_points`:可用积分
- `today_limit`:今日可领取上限
- `today_claimed`:今日已领取
- `withdrawable_cash`:可提现现金(积分按配置比例换算)
---
### 3.3 领取积分(幂等)
**POST** ` /api/v1/mall/claim `
参数:
- `claim_request_id`:必填,幂等键(建议:`{业务前缀}_{assetId}_{毫秒时间戳}`
- 身份参数:`token` / `session_id` / `user_id` 三选一(推荐 `token`
说明:
- 同一个 `claim_request_id` 重复提交会直接返回成功(不会重复入账)
- 会受 `today_limit/today_claimed/locked_points` 限制
---
### 3.4 商品列表
**GET** ` /api/v1/mall/items `
参数:
- `type`:可选,`BONUS | PHYSICAL | WITHDRAW`
---
### 3.5 红利兑换(提交订单)
**POST** ` /api/v1/mall/bonusRedeem `
参数:
- `item_id`:必填
- 身份参数:`token` / `session_id` / `user_id`
返回:
- `data.order_id`
- `data.status`(通常 `PENDING`
---
### 3.6 实物兑换(提交订单)
**POST** ` /api/v1/mall/physicalRedeem `
参数:
- `item_id`:必填
- `address_id`:必填,收货地址主键(`mall_address.id`,须为当前用户资产下地址)
- 身份参数:`token` / `session_id` / `user_id`
说明:服务端会将该地址在下单时刻的 **收货人 / 电话 / 完整地址** 写入订单字段(快照),并写入 `mall_order.mall_address_id` 关联所选地址。
---
### 3.7 提现申请(提交订单)
**POST** ` /api/v1/mall/withdrawApply `
参数:
- `item_id`:必填
- 身份参数:`token` / `session_id` / `user_id`
---
### 3.8 订单列表
**GET** ` /api/v1/mall/orders `
鉴权:`token` / `session_id` / `user_id`
说明:
- 返回最多 100 条
- 订单里包含 `mallItem`(商品信息)
---
## 4. 地址管理H5
> 地址与资产主体通过 `playx_user_asset_id` 关联(即 `mall_user_asset.id`)。
### 4.1 地址列表
**GET** ` /api/v1/mall/addressList `
### 4.2 新增地址
**POST** ` /api/v1/mall/addressAdd `
Body 含 `receiver_name`(收货人,建议填写;实物兑换下单快照需要非空的收货人、电话与拼接后的完整地址)。
### 4.3 编辑地址
**POST** ` /api/v1/mall/addressEdit `
### 4.4 删除地址
**POST** ` /api/v1/mall/addressDelete `
---
## 5. session 换取(可选)
### 5.1 token 换 session
**POST** ` /api/v1/mall/verifyToken `
参数(二选一):
- `token`
- `session`
成功返回:
- `data.session_id`
- `data.user_id`
- `data.username`
- `data.token_expire_at`
---
## 6. 常见错误与排查
- **401 登录态过期**token/session 过期或不匹配;请重新 `temLogin` 或重新 `verifyToken`
- **提示缺少必填字段**:按各接口参数补齐(如 `claim_request_id``item_id``address_id`(实物)、地址中收货人/电话/完整地址等)
- **积分不足/无可领取积分**`locked_points<=0` 或已达 `today_limit`

View File

@@ -1,7 +1,7 @@
## 0. 交付说明(给 PlayX
- **交付物**:本文件(接口清单 + 业务流程 + 联调验收清单)。
- **建议联调顺序**Token 验证 → 每日推送 → 领取 → 红利发放 → 提现入账 → 实物后台处理。
- **建议联调顺序**Token 验证(远程 PlayX 或本地 `verify_token_local_only`→ 每日推送 → 领取 → 红利发放 → 提现入账 → 实物后台处理。
- **约定**:接口 URL、字段最终表、签名细节以 PlayX 提供的最终口径为准;本文档负责把流程、幂等、重试与最小字段集合先对齐。
## 1. 文档目的与范围
@@ -29,6 +29,8 @@ flowchart LR
MallBackend -->|"BonusGrantAPI/BalanceCreditAPI"| PlayXBackend
```
> 当 **`playx.verify_token_local_only=true`** 时「Token 验证」一步在商城内完成,**不经过** `PlayXBackend` 的 Token Verification API详见 **§4.1**。
## 3. 关键业务对象与状态机
### 3.1 资产口径(最小集合)
@@ -55,17 +57,43 @@ flowchart LR
### 4.1 登录鉴权Iframe + token
> **接口与字段细节**以代码为准完整说明见同目录《PlayX-接口文档.md》§3 H5、§3.2 `temLogin`、§3.3 `verify-token`)。
#### 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)。
#### 4.1.2 模式 A联调 PlayX生产/预发,远程校验 token
1. 用户在 PlayX 内打开积分商城入口iframe
2. PlayX 前端通过 postMessage 发送 `token/session` 给商城前端
3. 商城后端调用 PlayX 的 **Token Verification API** 校验 token
4. PlayX 返回 `user_id``username`(以及会话有效期等)
5. 商城建立会话,返回会员资产与商品列表数据
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`**(及可选会话过期时间等)
6. 商城写入 **`mall_playx_session`**`session_id` + 上述 `user_id`/`username` + 过期时间),后续 H5 可用 **`session_id`** 或 **`token`(商城临时 token见模式 B** 调用资产/领取等接口。
幂等安全与会话续期
幂等安全:
- 前端不信任 `user_id` 直传;只接收 token/session。
- Token 验证接口需要签名/鉴权(见第 7 节)。
- **会话续期**:由于玩家访问积分商城可能停留时间较长,当商城调用任意 API 遇到 Token 校验过期(如 HTTP 401商城前端会通过 postMessage 向 PlayX 父级页面请求派发新的 Token 以实现静默续期,请 PlayX 配合予以支持。
- H5 **不要**把 PlayX 的 `user_id` 当作唯一可信凭据直传下单;**以 tokensession** 或由商城签发 token 的流程为准
- PlayX 侧 Token Verification API 的鉴权/签名若有按双方约定可参考《PlayX-接口文档》§2.1)。
#### 4.1.3 模式 B本地 / 无 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` 签发)。
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.1.4 会话续期与前端约定
- **会话续期**:玩家停留时间较长时,若商城 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 → 商城)

View File

@@ -1,6 +1,6 @@
# PlayX 接口文档(按调用方向拆分)
说明:本文档严格依据当前代码 `app/api/controller/v1/Playx.php` 与定时任务 `app/process/PlayxJobs.php` 整理。
说明:本文档严格依据当前代码 `app/api/controller/v1/Playx.php``app/api/controller/v1/Auth.php`(临时登录)、`config/playx.php` 与定时任务 `app/process/PlayxJobs.php` 整理。
三类接口分别为:
- `积分商城 -> PlayX`PlayX 调用商城)
@@ -13,31 +13,67 @@
### 1.1 Daily Push API
* 方法:`POST`
* 路径:`/api/v1/playx/daily-push`
* 路径:`/api/v1/mall/dailyPush`
#### Header签名校验:可选)
`playx.daily_push_secret` 配置非空时,需要携带:
#### Header多语言,可选)
- `lang`: `zh`/`zh-cn` 返回中文(默认);`en` 返回英文
#### Header签名校验HMAC 必填)
`playx.daily_push_secret` 配置非空时需要携带HMAC
- `X-Request-Id`:请求 ID
- `X-Timestamp`:时间戳
- `X-Signature`签名HMAC_SHA256
服务端签名计算:
- `canonical = X-Timestamp + "\n" + X-Request-Id + "\nPOST\n/api/v1/playx/daily-push\n" + sha256(json_body)`
- `canonical = X-Timestamp + "\n" + X-Request-Id + "\nPOST\n/api/v1/mall/dailyPush\n" + sha256(json_body)`
- `expected = hash_hmac('sha256', canonical, daily_push_secret)`
- 校验:`hash_equals(expected, X-Signature)`
说明:
- 本项目对接方案为 **仅启用 HMAC**,不使用 `Authorization` 头做校验。
#### Body
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `request_id` | string | 是 | 外部推送请求号(原样返回) |
| `date` | string(YYYY-MM-DD) | 是 | 业务日期(入库到 `mall_playx_daily_push.date` |
| `user_id` | string | 是 | PlayX 用户 ID用于幂等 |
| `username` | string | 否 | 展示冗余 |
| `user_id` | string | 是 | PlayX 用户 ID用于幂等;入库 `mall_playx_daily_push.user_id` 等;服务端会映射/创建 `mall_user``mall_playx_user_asset` |
| `username` | string | 否 | 展示冗余(同步到商城用户侧逻辑时使用) |
| `yesterday_win_loss_net` | number | 否 | 昨日净输赢(仅当 `< 0` 时计算新增保障金) |
| `yesterday_total_deposit` | number | 否 | 昨日总充值(用于计算今日可领取上限) |
| `lifetime_total_deposit` | number | 否 | 历史总充值 |
| `lifetime_total_withdraw` | number | 否 | 历史总提现 |
##### 格式 B新版批量上报兼容你截图
新版 body 形如:
```json
{
"report_date": "1700000000",
"member": [
{
"member_id": "123456",
"login": "john",
"lty_deposit": 15230.75,
"lty_withdrawal": 12400.50,
"yesterday_total_w": -320.25,
"yesterday_total_deposit": 500.00
}
]
}
```
字段映射(服务端内部会转换成旧字段再计算):
- `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_deposit` -> `yesterday_total_deposit`
- `member[].lty_deposit` -> `lifetime_total_deposit`
- `member[].lty_withdrawal` -> `lifetime_total_withdraw`
返回补充:
- 批量模式会在 `data` 里增加 `results[]`,每个成员一条结果(是否 `deduped`)。
#### 幂等规则
* 幂等键:`user_id + date`
* 重复推送:不会重复入账,返回 `data.deduped=true`
@@ -68,7 +104,7 @@
#### 示例(未开启签名校验)
请求:
```bash
curl -X POST 'http://localhost:1818/api/v1/playx/daily-push' \
curl -X POST 'http://localhost:1818/api/v1/mall/dailyPush' \
-H 'Content-Type: application/json' \
-d '{
"request_id":"req_1001",
@@ -97,13 +133,57 @@ curl -X POST 'http://localhost:1818/api/v1/playx/daily-push' \
}
```
#### 示例(新版批量上报)
请求:
```bash
curl -X POST 'http://localhost:1818/api/v1/mall/dailyPush' \
-H 'Content-Type: application/json' \
-d '{
"report_date": "1700000000",
"member": [
{
"member_id": "123456",
"login": "john",
"lty_deposit": 15230.75,
"lty_withdrawal": 12400.50,
"yesterday_total_w": -320.25,
"yesterday_total_deposit": 500.00
}
]
}'
```
返回(首次写入至少一个成员时的示例):
```json
{
"code": 1,
"msg": "",
"time": 0,
"data": {
"request_id": "report_2023-11-14",
"accepted": true,
"deduped": false,
"message": "Ok",
"results": [
{
"user_id": "123456",
"accepted": true,
"deduped": false,
"message": "Ok"
}
]
}
}
```
---
## 2. PlayX -> 积分商城(商城调用 PlayX
> 下面这些接口由 PlayX 提供。商城侧仅按“请求参数 + 期望返回判定条件”发起调用与处理结果。
> **说明**H5 调商城的 **`/api/v1/mall/verifyToken`** 在配置 **`playx.verify_token_local_only=true`**(默认)时**不会请求**本节接口,而是在商城内校验 `muser` token远程对接 PlayX 时见 **3.3** 与下文 **2.1**。
### 2.1 Token Verification API
### 2.1 Token Verification APIPlayX 侧实现,远程验证时使用)
* 方法:`POST`
* URL`${playx.api.base_url}${playx.api.token_verify_url}`
* 默认:`/api/v1/auth/verify-token`
@@ -257,48 +337,171 @@ curl -X POST 'http://localhost:1818/api/v1/playx/daily-push' \
## 3. 积分商城 -> H5服务端提供给 H5 的接口)
说明:鉴权与用户解析规则由 `resolveUserIdFromRequest()` 决定。
* 优先使用 `session_id`(在 `mall_playx_session` 查到且未过期)
* 其次使用 `user_id`
### 3.0 数据模型说明(与代码一致)
公共鉴权字段:
* `session_id`:字符串
* `user_id`字符串
* **商城用户**:表 `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`
### 3.1 鉴权解析规则(`resolveMallUserIdFromRequest`
以下接口在服务端最终都会解析出 **商城用户 `mall_user.id`**,再按该用户查询 `mall_playx_user_asset` 等。
优先级(由高到低):
1. **`session_id`**`post` 优先,`get` 兼容)
*`mall_playx_session` 中存在且未过期:用会话里的 `user_id`(即 `playx_user_id`)在 `mall_playx_user_asset` 反查 `mall_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`
> 注意:请求参数的取值方式是 `post()` 优先,`get()` 兼容(即同字段既可传 post 也可传 get
---
### 3.1 Token 验证
* 方法:`POST`
* 路径`/api/v1/playx/verify-token`
### 3.2 临时登录(获取商城 token
* 方法`GET`(推荐)或 `POST`
* 路径:`/api/v1/temLogin`
* 开关:`config/buildadmin.php``agent_auth.temp_login_enable``true`;有效期 `agent_auth.temp_login_expire`(秒)。
#### 请求参数
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `username` | string | 是 | 商城用户名(唯一);不存在则自动创建 `mall_user` |
#### 行为说明
*`mall_user` 不存在:创建用户(随机占位手机号、随机密码等,与后台「商城用户」一致)。
* **无论是否新用户**:保证存在 **`mall_playx_user_asset`** 一条记录(`MallPlayxUserAsset::ensureForMallUser``playx_user_id` 默认 **`mall_{mall_user.id}`**(与 PlayX 真实 ID 冲突概率低)。
* 签发 **商城 token**(类型 **`muser`**,非会员表 `user`),并签发 `muser-refresh` 刷新令牌。
#### 返回(成功 data.userInfo
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | int | `mall_user.id` |
| `username` | string | 用户名 |
| `nickname` | string | 同 `username` |
| `playx_user_id` | string | 资产表中的 `playx_user_id`(如 `mall_12` |
| `token` | string | 访问 H5 接口时携带 |
| `refresh_token` | string | 调用 `/api/common/refreshToken` 时使用(类型 `muser-refresh` |
| `expires_in` | int | token 有效秒数 |
#### 示例
```bash
curl -G 'http://localhost:1818/api/v1/temLogin' --data-urlencode 'username=demo_h5'
```
用户名含 `+` 等号时需 URL 编码(如 `%2B60123456789`)。
---
### 3.3 Token 验证(换 session
* 方法:`POST`(推荐 `GET``token` 亦可)
* 路径:`/api/v1/mall/verifyToken`
#### 配置:本地验证 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`
#### 请求参数
#### 请求 Body
必填其一:
* `token`(优先读取)
* `session`兼容字段,当 `token` 为空时会被当作 token
* `token`Body 优先;`session` 兼容字段Query 也可传 `token`
#### 返回(成功 data
| 字段 | 类型 | 说明 |
|------|------|------|
| `session_id` | string | 写入 `mall_playx_session` |
| `user_id` | string | PlayX 用户 ID |
| `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`
#### 示例
请求:
* token 为空HTTP 401msg=`INVALID_TOKEN`
* 远程模式且 PlayX 未配置:`msg=PlayX API not configured`
#### 示例(本地验证)
```bash
curl -X POST 'http://localhost:1818/api/v1/playx/verify-token' \
curl -X POST 'http://localhost:1818/api/v1/mall/verifyToken' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'token=PLAYX_TOKEN_XXX'
--data-urlencode 'token=上一步TemLogin返回的token'
```
---
### 3.x 收货地址(`mall_address`
> 下面接口用于 H5 维护收货地址。鉴权同本章其他接口:携带 `session_id` 或 `token` 或 `user_id`。
#### 3.x.1 地址列表
* 方法:`GET`
* 路径:`/api/v1/mall/addressList`
返回:`data.list` 为地址数组。
#### 3.x.2 添加地址
* 方法:`POST`
* 路径:`/api/v1/mall/addressAdd`
Body
| 字段 | 必填 | 说明 |
|------|------|------|
| `receiver_name` | 是 | 收货人 |
| `phone` | 是 | 电话 |
| `region` | 是 | 地区(数组或逗号分隔字符串) |
| `detail_address` | 是 | 详细地址 |
| `default_setting` | 否 | `1` 设为默认地址 |
#### 3.x.3 修改地址(含设为默认)
* 方法:`POST`
* 路径:`/api/v1/mall/addressEdit`
Body`id` 必填,其余字段按需传入更新。
#### 3.x.4 删除地址
* 方法:`POST`
* 路径:`/api/v1/mall/addressDelete`
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,
@@ -314,13 +517,17 @@ curl -X POST 'http://localhost:1818/api/v1/playx/verify-token' \
---
### 3.2 用户资产
### 3.4 用户资产Assets
* 方法:`GET`
* 路径:`/api/v1/playx/assets`
* 路径:`/api/v1/mall/assets`
#### 请求参数(鉴权)
* `session_id`(优先)
* `user_id`(兼容)
以下任选其一即可(与 **3.1 鉴权解析规则** 一致):
* `session_id`
* `token`(或请求头 `ba-token` / `token`
* `user_id`(纯数字为 `mall_user.id`,否则为 `playx_user_id`
#### 返回(成功 data
若未找到资产:返回 0。
@@ -334,7 +541,7 @@ curl -X POST 'http://localhost:1818/api/v1/playx/verify-token' \
#### 示例
```bash
curl -G 'http://localhost:1818/api/v1/playx/assets' --data-urlencode 'session_id=7b1c....'
curl -G 'http://localhost:1818/api/v1/mall/assets' --data-urlencode 'token=上一步temLogin返回的token'
```
响应(示例):
@@ -355,15 +562,16 @@ curl -G 'http://localhost:1818/api/v1/playx/assets' --data-urlencode 'session_id
---
### 3.3 领取Claim
### 3.5 领取Claim
* 方法:`POST`
* 路径:`/api/v1/playx/claim`
* 路径:`/api/v1/mall/claim`
#### 请求 Body
必填:
* `claim_request_id`幂等键string唯一
鉴权:
* `session_id` `user_id`
鉴权:同 **3.1**`session_id` / `token` / `user_id`
#### 返回(成功 data
`用户资产` 返回字段一致(资产快照)。
@@ -373,10 +581,10 @@ curl -G 'http://localhost:1818/api/v1/playx/assets' --data-urlencode 'session_id
#### 示例
```bash
curl -X POST 'http://localhost:1818/api/v1/playx/claim' \
curl -X POST 'http://localhost:1818/api/v1/mall/claim' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'claim_request_id=claim_001' \
--data-urlencode 'session_id=7b1c....'
--data-urlencode 'token=上一步temLogin返回的token'
```
响应(首次领取,示例):
@@ -413,9 +621,9 @@ curl -X POST 'http://localhost:1818/api/v1/playx/claim' \
---
### 3.4 商品列表
### 3.6 商品列表
* 方法:`GET`
* 路径:`/api/v1/playx/items`
* 路径:`/api/v1/mall/items`
#### 请求参数
* `type`(可选):`BONUS` | `PHYSICAL` | `WITHDRAW`
@@ -428,7 +636,7 @@ curl -X POST 'http://localhost:1818/api/v1/playx/claim' \
#### 示例
请求:
```bash
curl -G 'http://localhost:1818/api/v1/playx/items' --data-urlencode 'type=WITHDRAW'
curl -G 'http://localhost:1818/api/v1/mall/items' --data-urlencode 'type=WITHDRAW'
```
响应(示例):
@@ -456,15 +664,14 @@ curl -G 'http://localhost:1818/api/v1/playx/items' --data-urlencode 'type=WITHDR
---
### 3.5 红利兑换Bonus Redeem
### 3.7 红利兑换Bonus Redeem
* 方法:`POST`
* 路径:`/api/v1/playx/bonus/redeem`
* 路径:`/api/v1/mall/bonusRedeem`
#### 请求 Body
必填:
* `item_id`:商品 ID要求 `mall_item.type=BONUS``status=1`
鉴权:
* `session_id``user_id`
鉴权:**3.1**`session_id` / `token` / `user_id`
#### 返回(成功)
* `msg``Redeem submitted, please wait about 10 minutes`
@@ -473,7 +680,7 @@ curl -G 'http://localhost:1818/api/v1/playx/items' --data-urlencode 'type=WITHDR
#### 示例
```bash
curl -X POST 'http://localhost:1818/api/v1/playx/bonus/redeem' \
curl -X POST 'http://localhost:1818/api/v1/mall/bonusRedeem' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'item_id=123' \
--data-urlencode 'session_id=7b1c....'
@@ -494,18 +701,15 @@ curl -X POST 'http://localhost:1818/api/v1/playx/bonus/redeem' \
---
### 3.6 实物兑换Physical Redeem
### 3.8 实物兑换Physical Redeem
* 方法:`POST`
* 路径:`/api/v1/playx/physical/redeem`
* 路径:`/api/v1/mall/physicalRedeem`
#### 请求 Body
必填:
* `item_id`:商品 ID要求 `mall_item.type=PHYSICAL``status=1`
* `receiver_name`:收货人
* `receiver_phone`:收货电话
* `receiver_address`:收货地址
鉴权:
* `session_id``user_id`
* `address_id`:收货地址 ID`mall_address.id`,须属于当前用户资产;下单时写入 `mall_order.mall_address_id`,并将该地址快照写入 `receiver_name` / `receiver_phone` / `receiver_address`
鉴权:同 **3.1**`session_id` / `token` / `user_id`
#### 返回(成功)
* `msg``Redeem success`
@@ -513,12 +717,10 @@ curl -X POST 'http://localhost:1818/api/v1/playx/bonus/redeem' \
#### 示例
```bash
curl -X POST 'http://localhost:1818/api/v1/playx/physical/redeem' \
curl -X POST 'http://localhost:1818/api/v1/mall/physicalRedeem' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'item_id=200' \
--data-urlencode 'receiver_name=张三' \
--data-urlencode 'receiver_phone=18800001111' \
--data-urlencode 'receiver_address=北京市朝阳区XX路XX号' \
--data-urlencode 'address_id=10' \
--data-urlencode 'session_id=7b1c....'
```
@@ -534,15 +736,14 @@ curl -X POST 'http://localhost:1818/api/v1/playx/physical/redeem' \
---
### 3.7 提现申请Withdraw Apply
### 3.9 提现申请Withdraw Apply
* 方法:`POST`
* 路径:`/api/v1/playx/withdraw/apply`
* 路径:`/api/v1/mall/withdrawApply`
#### 请求 Body
必填:
* `item_id`:商品 ID要求 `mall_item.type=WITHDRAW``status=1`
鉴权:
* `session_id``user_id`
鉴权:**3.1**`session_id` / `token` / `user_id`
#### 返回(成功)
* `msg``Withdraw submitted, please wait about 10 minutes`
@@ -551,7 +752,7 @@ curl -X POST 'http://localhost:1818/api/v1/playx/physical/redeem' \
#### 示例
```bash
curl -X POST 'http://localhost:1818/api/v1/playx/withdraw/apply' \
curl -X POST 'http://localhost:1818/api/v1/mall/withdrawApply' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'item_id=321' \
--data-urlencode 'session_id=7b1c....'
@@ -572,20 +773,23 @@ curl -X POST 'http://localhost:1818/api/v1/playx/withdraw/apply' \
---
### 3.8 订单列表
### 3.10 订单列表
* 方法:`GET`
* 路径:`/api/v1/playx/orders`
* 路径:`/api/v1/mall/orders`
#### 请求参数
* `session_id``user_id`
#### 请求参数(鉴权)
**3.1**`session_id` / `token` / `user_id`)。
#### 返回(成功 data
* `list`:订单列表(最多 100 条),并包含关联的 `mallItem`(关系对象)
* 列表项中的 `user_id`**PlayX 侧 `playx_user_id`**(字符串),与 `mall_playx_order.user_id` 一致
#### 示例
请求:
```bash
curl -G 'http://localhost:1818/api/v1/playx/orders' --data-urlencode 'session_id=7b1c....'
curl -G 'http://localhost:1818/api/v1/mall/orders' --data-urlencode 'token=上一步temLogin返回的token'
```
响应(示例,简化):
@@ -619,6 +823,6 @@ curl -G 'http://localhost:1818/api/v1/playx/orders' --data-urlencode 'session_id
---
### 3.9 同步额度(可选)
当前代码未实现并未注册路由:`/api/v1/playx/sync-limit`
### 3.11 同步额度(可选)
当前代码未实现并未注册路由:`/api/v1/mall/syncLimit`

View File

@@ -0,0 +1,447 @@
# PlayX 调用积分商城接口说明
本文档描述 **PlayX 平台(或 PlayX 侧脚本/服务)如何调用积分商城已开放的 HTTP 接口**:基础约定、推荐流程、鉴权方式、请求参数与返回结构。
实现依据:`config/route.php``app/api/controller/v1/Playx.php``config/playx.php`
---
## 1. 基础约定
### 1.1 Base URL
将下列路径拼在积分商城对外域名之后,例如:
`https://{商城域名}/api/v1/mall/dailyPush`
(联调时请向商城方索取正式环境与测试环境地址。)
### 1.2 通用响应结构JSON
所有接口成功或失败,响应体均为:
| 字段 | 类型 | 说明 |
|------|------|------|
| `code` | int | `1` 表示业务成功;`0` 表示业务失败 |
| `msg` | string | 提示信息(失败时为错误原因) |
| `time` | int | Unix 时间戳(秒) |
| `data` | object/array/null | 业务数据;失败时可能为 `null` |
部分错误场景会通过 HTTP 状态码区分(如 **401**),此时 `code` 仍为 `0`,请同时判断 HTTP 状态与 `code`
**成功示例:**
```json
{
"code": 1,
"msg": "",
"time": 1730000000,
"data": { }
}
```
**失败示例:**
```json
{
"code": 0,
"msg": "错误原因",
"time": 1730000000,
"data": null
}
```
### 1.3 Content-Type
- 本文档中 **POST** 且带 JSON Body 的接口,请使用:`Content-Type: application/json`
### 1.4 多语言(响应文案)
可通过请求头 `lang` 控制返回文案语言:
| Header | 值 | 说明 |
|--------|----|------|
| `lang` | `zh` / `zh-cn` | 返回中文(默认) |
| `lang` | `en` | 返回英文 |
---
## 2. 使用流程(推荐)
### 2.1 PlayX 服务端 → 商城:每日数据推送(主流程)
适用于 T+1 等业务数据由 **PlayX 服务端**主动推送到积分商城。
```mermaid
sequenceDiagram
participant PX as PlayX 服务端
participant M as 积分商城
Note over PX,M: 按约定配置 HMAC 密钥
PX->>M: POST /api/v1/mall/dailyPushJSON Body
M-->>PX: code=1, data.accepted / deduped
```
1. 与商城方约定 **商城 Base URL****HMAC**`X-Signature` 等)密钥。
2.**§3** 构造请求并推送。
3. 根据返回 `data.deduped` 判断是否为幂等重复推送。
### 2.2 用户侧H5 / 内嵌页)→ 商城:会话与业务接口
以下接口多由 **用户在浏览器内**打开积分商城 H5 后调用,通过 **`session_id`**(先调 `verifyToken` 获取)或 **`token`**(商城 `muser` 类 token标识用户**不一定由 PlayX 后端直接调用**
- `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 平台对接」主流程中展开;需要时由运营向商城索取单独说明。
---
## 3. PlayX 服务端推送Daily Push
### 3.1 概要
| 项目 | 值 |
|------|-----|
| 方法 | `POST` |
| 路径 | `/api/v1/mall/dailyPush` |
### 3.2 鉴权(按商城部署配置,可组合)
#### 推荐方案:仅启用 HMAC当前对接采用
商城侧配置:设置环境变量 **`PLAYX_DAILY_PUSH_SECRET`** 为非空(启用 HMAC 校验)。
#### HMAC 签名(必填)
当商城配置 **`PLAYX_DAILY_PUSH_SECRET`** 非空时,需同时携带:
| Header | 说明 |
|--------|------|
| `X-Request-Id` | 请求 ID建议与 Body 内可追溯字段一致) |
| `X-Timestamp` | Unix 时间戳(秒,字符串) |
| `X-Signature` | 签名(十六进制小写或大写需与实现一致,以下为十六进制字符串) |
签名原文与计算:
```
canonical = X-Timestamp + "\n" + X-Request-Id + "\nPOST\n/api/v1/mall/dailyPush\n" + sha256(json_body)
expected = HMAC_SHA256( canonical , PLAYX_DAILY_PUSH_SECRET )
```
其中 `json_body`**实际发送的 JSON 原始字符串** 计算出的 SHA256十六进制与 PHP `hash('sha256', $rawBody)` 一致。
校验:`hash_equals(expected, X-Signature)`
#### Header 填写清单HMAC 模式)
- 必填:`X-Request-Id``X-Timestamp``X-Signature`
#### 重要注意:`json_body` 必须与实际发送一致
为了保证签名可验通过:用于计算 sha256 的 `json_body` 必须是**实际发送到 HTTP body 的原始 JSON 字符串**(字节级一致)。\
建议:在发送端先序列化 JSON 得到字符串 `rawBody`,用该 `rawBody` 做 sha256 与 HMAC再把同一个 `rawBody` 作为请求 body 发送。
### 3.3 Body 参数JSON
`/api/v1/mall/dailyPush` 支持 **两种入参格式**(按字段自动识别):
#### 格式 A旧版单条上报兼容
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `request_id` | string | 是 | 本次推送请求号;响应中原样返回 |
| `date` | string | 是 | 业务日期,格式 `YYYY-MM-DD` |
| `user_id` | string | 是 | PlayX 用户 ID幂等键之一 |
| `username` | string | 否 | 展示名;用于同步/创建商城侧用户资产展示信息 |
| `yesterday_win_loss_net` | number | 否 | 昨日净输赢;**小于 0** 时按配置比例计入待领取保障金(`locked_points` |
| `yesterday_total_deposit` | number | 否 | 昨日总充值;用于计算当日可领取上限等 |
| `lifetime_total_deposit` | number | 否 | 历史总充值(冗余入库) |
| `lifetime_total_withdraw` | number | 否 | 历史总提现(冗余入库) |
#### 格式 B新版批量上报你图中格式
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `report_date` | string/number | 是 | 报表日期;可以为 Unix 秒时间戳(如 `1700000000`)或 `YYYY-MM-DD` |
| `member` | array | 是 | 成员列表,每个成员包含一名 PlayX 用户数据 |
成员元素字段:
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `member_id` | string | 是 | PlayX 用户 ID幂等键之一 |
| `login` | string | 否 | 用户展示名 |
| `yesterday_total_w` | number | 否 | 昨日净输赢;小于 0 才会累加到 `locked_points` |
| `yesterday_total_deposit` | number | 否 | 昨日总充值;用于计算 `today_limit` |
| `lty_deposit` | number | 否 | 历史总充值(冗余入库) |
| `lty_withdrawal` | number | 否 | 历史总提现(冗余入库) |
#### Body 填写要求(批量模式)
- **必须有**`report_date``member`(数组且至少 1 个元素)、`member[].member_id`
- **允许缺省**:成员的 `login/yesterday_total_w/yesterday_total_deposit/lty_deposit/lty_withdrawal`;缺省时按 `0` 或空字符串处理。
- **日期**`report_date` 传 Unix 秒会自动转换成 `YYYY-MM-DD`;如果直接传 `YYYY-MM-DD` 也支持。
### 3.4 幂等
- 幂等键:**`user_id` + `date`**
- 重复推送:不重复入账,返回 `data.deduped = true`
### 3.5 返回 `data` 字段
| 字段 | 类型 | 说明 |
|------|------|------|
| `request_id` | string | 与请求一致 |
| `accepted` | bool | 是否受理成功 |
| `deduped` | bool | 是否为重复推送(幂等命中) |
| `message` | string | 说明文案 |
#### 格式 B批量上报的返回补充
批量模式会在 `data` 中增加:`results`
`data.results` 为数组,元素字段如下:
| 字段 | 类型 | 说明 |
|------|------|------|
| `user_id` | string | 对应成员的 `member_id` |
| `accepted` | bool | 是否受理成功 |
| `deduped` | bool | 该成员是否为重复推送 |
| `message` | string | `ok``duplicate input` |
**HTTP 401**HMAC 不通过(签名缺失/不完整/校验失败)。
### 3.6 请求示例
```bash
curl -X POST 'https://{商城域名}/api/v1/mall/dailyPush' \
-H 'Content-Type: application/json' \
-H 'X-Request-Id: req_1700000000_123456' \
-H 'X-Timestamp: 1700000000' \
-H 'X-Signature: <按本文档 canonical 计算出的 HMAC_SHA256>' \
-d '{
"report_date": "1700000000",
"member": [
{
"member_id": "123456",
"login": "john",
"lty_deposit": 15230.75,
"lty_withdrawal": 12400.50,
"yesterday_total_w": -320.25,
"yesterday_total_deposit": 500.00
}
]
}'
```
响应示例(首次写入,至少有一个成员非重复):
```json
{
"code": 1,
"msg": "",
"time": 0,
"data": {
"request_id": "report_2023-11-14",
"accepted": true,
"deduped": false,
"message": "Ok",
"results": [
{
"user_id": "123456",
"accepted": true,
"deduped": false,
"message": "Ok"
}
]
}
}
```
---
## 4. 身份说明(`session_id` / `token` / `user_id`
以下接口通过 **`resolvePlayxAssetIdFromRequest`** 解析当前用户,优先级如下:
1. **`session_id`**POST/GET对应商城表 `mall_playx_session`,未过期则映射到 `mall_playx_user_asset`
2.`session_id` 实际是 **`muser` 类型 token**(历史兼容),也会按 token 解析。
3. **`token`**POST/GET 或标准鉴权头):商城 token 表内类型为会员或 **`muser`** 且未过期时,`user_id`**`mall_playx_user_asset.id`**(资产表主键)。
4. **`user_id`**POST/GET
- 若**纯数字**:视为 **`mall_playx_user_asset.id`**
- 否则:按 **`playx_user_id`** 查找资产行。
无法解析身份时,通常返回 **401** 或参数错误提示。
---
## 5. 其他接口一览(摘要)
> 下列均为 **BuildAdmin 通用 `code/msg/time/data` 结构**;成功时 `code=1`。
### 5.1 `POST /api/v1/mall/verifyToken`
用于将 **PlayX token**(或本地联调 token**商城 `session_id`**
| 参数位置 | 名称 | 说明 |
|----------|------|------|
| POST/GET | `token``session` | PlayX 或商城 token |
**说明:**`playx.verify_token_local_only=true`(默认),商城**仅本地校验** token不请求 PlayX 远程接口;远程模式需配置 `PLAYX_API_BASE_URL` 等。
**成功 `data` 示例:**
| 字段 | 说明 |
|------|------|
| `session_id` | 后续接口可带此字段 |
| `user_id` | PlayX 用户 ID 或映射后的标识 |
| `username` | 用户名 |
| `token_expire_at` | ISO8601 过期时间 |
---
### 5.2 `GET /api/v1/mall/assets`
查询积分资产;需 **§4** 身份。
**成功 `data`**
| 字段 | 说明 |
|------|------|
| `locked_points` | 待领取积分 |
| `available_points` | 可用积分 |
| `today_limit` | 今日可领取上限 |
| `today_claimed` | 今日已领取 |
| `withdrawable_cash` | 可提现现金(由积分×配置比例换算,保留小数) |
---
### 5.3 `POST /api/v1/mall/claim`
领取积分;需 **§4** 身份。
| 参数 | 必填 | 说明 |
|------|------|------|
| `claim_request_id` | 是 | 幂等请求号 |
**成功 `data`** 与资产结构一致(含 `locked_points``available_points``today_limit``today_claimed``withdrawable_cash` 等)。
---
### 5.4 `GET /api/v1/mall/items`
商品列表。
| 参数 | 必填 | 说明 |
|------|------|------|
| `type` | 否 | `BONUS` / `PHYSICAL` / `WITHDRAW`,筛选类型 |
**成功 `data`** `{ "list": [ ... ] }`
---
### 5.5 `POST /api/v1/mall/bonusRedeem`
红利兑换;需 **§4** 身份。
| 参数 | 必填 | 说明 |
|------|------|------|
| `item_id` | 是 | 商品 ID |
**成功 `data`**`order_id``status`(如 `PENDING`)等。
---
### 5.6 `POST /api/v1/mall/physicalRedeem`
实物兑换;需 **§4** 身份。
| 参数 | 必填 | 说明 |
|------|------|------|
| `item_id` | 是 | 实物商品 ID |
| `address_id` | 是 | `mall_address.id`(当前用户下地址);订单保存 `mall_address_id` 与地址快照 |
---
### 5.7 `POST /api/v1/mall/withdrawApply`
提现类兑换申请;需 **§4** 身份。
| 参数 | 必填 | 说明 |
|------|------|------|
| `item_id` | 是 | 提现档位商品 ID |
---
### 5.8 `GET /api/v1/mall/orders`
订单列表;需 **§4** 身份。
**成功 `data`** `{ "list": [ ... ] }`(含关联商品等,以实际返回为准)。
---
### 5.9 收货地址(`mall_address`
> 下列接口均需携带 **§4 身份参数**`session_id` / `token` / `user_id` 之一)。
#### 5.9.1 获取收货地址列表
- **方法**`GET`
- **路径**`/api/v1/mall/addressList`
返回 `data.list`:地址数组(按 `default_setting` 优先,其次 id 倒序)。
#### 5.9.2 添加收货地址
- **方法**`POST`
- **路径**`/api/v1/mall/addressAdd`
Body表单或 JSON 均可,建议 JSON
| 字段 | 必填 | 说明 |
|------|------|------|
| `receiver_name` | 是 | 收货人 |
| `phone` | 是 | 联系电话 |
| `region` | 是 | 地区(可传数组或逗号分隔字符串) |
| `detail_address` | 是 | 详细地址(短文本) |
| `default_setting` | 否 | `1` 设为默认地址;`0` 或不传为非默认 |
成功返回:`data.id` 为新地址 id。
#### 5.9.3 修改收货地址(含设置默认)
- **方法**`POST`
- **路径**`/api/v1/mall/addressEdit`
Body
| 字段 | 必填 | 说明 |
|------|------|------|
| `id` | 是 | 地址 id |
| `receiver_name/phone/region/detail_address/default_setting` | 否 | 需要修改的字段(只更新传入项) |
`default_setting=1`:会自动把该用户其他地址的 `default_setting` 置为 0。
#### 5.9.4 删除收货地址
- **方法**`POST`
- **路径**`/api/v1/mall/addressDelete`
Body
| 字段 | 必填 | 说明 |
|------|------|------|
| `id` | 是 | 地址 id |
若删除的是默认地址:服务端会将剩余地址里 id 最大的一条自动设为默认(若存在)。
---
## 6. 配置项(供运维/对方技术对照)
| 环境变量 / 配置 | 作用 |
|-----------------|------|
| `PLAYX_DAILY_PUSH_SECRET` | 非空则 Daily Push 必须带合法 HMAC 头 |
| `PLAYX_VERIFY_TOKEN_LOCAL_ONLY` | 为 true 时 verifyToken 不请求 PlayX 远程 |
| `PLAYX_API_BASE_URL` | 商城调用 PlayX 接口时使用与「PlayX 调商城」方向相反) |
---
## 7. 版本与变更
- 文档与仓库代码同步维护;接口路径以 `config/route.php` 为准。
- 若后续升级鉴权策略(例如叠加 JWT以部署环境变量与最新文档为准。

View File

@@ -11,7 +11,7 @@
接收 PlayX 每日 T+1 数据推送。
* 方法:`POST`
* 路径:`/api/v1/playx/daily-push`
* 路径:`/api/v1/mall/dailyPush`
##### 请求Header
当配置了 `playx.daily_push_secret`Daily Push 签名校验)时,需要携带:
@@ -20,7 +20,7 @@
* `X-Signature`签名HMAC_SHA256
签名计算逻辑(服务端):
* canonical`{X-Timestamp}\n{X-Request-Id}\nPOST\n/api/v1/playx/daily-push\n{sha256(json_body)}`
* canonical`{X-Timestamp}\n{X-Request-Id}\nPOST\n/api/v1/mall/dailyPush\n{sha256(json_body)}`
* expected`hash_hmac('sha256', canonical, daily_push_secret)`
##### 请求Body
@@ -51,7 +51,7 @@
##### 示例
无签名校验(`PLAYX_DAILY_PUSH_SECRET` 为空):
```bash
curl -X POST 'http://localhost:1818/api/v1/playx/daily-push' \
curl -X POST 'http://localhost:1818/api/v1/mall/dailyPush' \
-H 'Content-Type: application/json' \
-d '{
"request_id":"req_1001",
@@ -246,7 +246,7 @@ curl -G '${playx.api.base_url}/api/v1/transaction/status' \
### 1.3 商城内部 API供 H5 前端调用)
#### Token 验证
* 方法:`POST`
* 路径:`/api/v1/playx/verify-token`
* 路径:`/api/v1/mall/verifyToken`
请求Body
* `token`(必填,优先读取)
@@ -260,14 +260,14 @@ curl -G '${playx.api.base_url}/api/v1/transaction/status' \
示例:
```bash
curl -X POST 'http://localhost:1818/api/v1/playx/verify-token' \
curl -X POST 'http://localhost:1818/api/v1/mall/verifyToken' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'token=PLAYX_TOKEN_XXX'
```
#### 用户资产
* 方法:`GET`
* 路径:`/api/v1/playx/assets`
* 路径:`/api/v1/mall/assets`
请求参数(二选一):
* `session_id`(优先):从 `mall_playx_session` 查 user_id并校验过期
@@ -282,7 +282,7 @@ curl -X POST 'http://localhost:1818/api/v1/playx/verify-token' \
##### 示例
```bash
curl -G 'http://localhost:1818/api/v1/playx/assets' --data-urlencode 'session_id=7b1c....'
curl -G 'http://localhost:1818/api/v1/mall/assets' --data-urlencode 'session_id=7b1c....'
```
```json
@@ -301,7 +301,7 @@ curl -G 'http://localhost:1818/api/v1/playx/assets' --data-urlencode 'session_id
#### 领取Claim
* 方法:`POST`
* 路径:`/api/v1/playx/claim`
* 路径:`/api/v1/mall/claim`
请求:
* `claim_request_id`幂等键string必填且唯一
@@ -312,7 +312,7 @@ curl -G 'http://localhost:1818/api/v1/playx/assets' --data-urlencode 'session_id
##### 示例
(首次领取成功,可能返回 `msg=Claim success`;若幂等重复,`msg` 可能为空)
```bash
curl -X POST 'http://localhost:1818/api/v1/playx/claim' \
curl -X POST 'http://localhost:1818/api/v1/mall/claim' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'claim_request_id=claim_001' \
--data-urlencode 'session_id=7b1c....'
@@ -334,7 +334,7 @@ curl -X POST 'http://localhost:1818/api/v1/playx/claim' \
#### 商品列表
* 方法:`GET`
* 路径:`/api/v1/playx/items`
* 路径:`/api/v1/mall/items`
请求(可选):
* `type=BONUS|PHYSICAL|WITHDRAW`
@@ -344,7 +344,7 @@ curl -X POST 'http://localhost:1818/api/v1/playx/claim' \
##### 示例
```bash
curl -G 'http://localhost:1818/api/v1/playx/items' --data-urlencode 'type=BONUS'
curl -G 'http://localhost:1818/api/v1/mall/items' --data-urlencode 'type=BONUS'
```
```json
@@ -370,7 +370,7 @@ curl -G 'http://localhost:1818/api/v1/playx/items' --data-urlencode 'type=BONUS'
#### 红利兑换Bonus Redeem
* 方法:`POST`
* 路径:`/api/v1/playx/bonus/redeem`
* 路径:`/api/v1/mall/bonusRedeem`
请求:
* `item_id`:商品 IDBONUS
@@ -382,7 +382,7 @@ curl -G 'http://localhost:1818/api/v1/playx/items' --data-urlencode 'type=BONUS'
##### 示例
```bash
curl -X POST 'http://localhost:1818/api/v1/playx/bonus/redeem' \
curl -X POST 'http://localhost:1818/api/v1/mall/bonusRedeem' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'item_id=123' \
--data-urlencode 'session_id=7b1c....'
@@ -401,13 +401,11 @@ curl -X POST 'http://localhost:1818/api/v1/playx/bonus/redeem' \
#### 实物兑换Physical Redeem
* 方法:`POST`
* 路径:`/api/v1/playx/physical/redeem`
* 路径:`/api/v1/mall/physicalRedeem`
请求:
* `item_id`:商品 IDPHYSICAL
* `receiver_name`
* `receiver_phone`
* `receiver_address`
* `address_id``mall_address.id`(当前用户资产下地址;订单写入 `mall_address_id` 与收货快照)
* 鉴权:`session_id``user_id`
成功返回:
@@ -416,12 +414,10 @@ curl -X POST 'http://localhost:1818/api/v1/playx/bonus/redeem' \
##### 示例
```bash
curl -X POST 'http://localhost:1818/api/v1/playx/physical/redeem' \
curl -X POST 'http://localhost:1818/api/v1/mall/physicalRedeem' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'item_id=200' \
--data-urlencode 'receiver_name=张三' \
--data-urlencode 'receiver_phone=18800001111' \
--data-urlencode 'receiver_address=北京市朝阳区XX路XX号' \
--data-urlencode 'address_id=10' \
--data-urlencode 'session_id=7b1c....'
```
@@ -435,7 +431,7 @@ curl -X POST 'http://localhost:1818/api/v1/playx/physical/redeem' \
#### 提现申请Withdraw Apply
* 方法:`POST`
* 路径:`/api/v1/playx/withdraw/apply`
* 路径:`/api/v1/mall/withdrawApply`
请求:
* `item_id`:商品 IDWITHDRAW
@@ -447,7 +443,7 @@ curl -X POST 'http://localhost:1818/api/v1/playx/physical/redeem' \
##### 示例
```bash
curl -X POST 'http://localhost:1818/api/v1/playx/withdraw/apply' \
curl -X POST 'http://localhost:1818/api/v1/mall/withdrawApply' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'item_id=321' \
--data-urlencode 'session_id=7b1c....'
@@ -466,7 +462,7 @@ curl -X POST 'http://localhost:1818/api/v1/playx/withdraw/apply' \
#### 订单列表
* 方法:`GET`
* 路径:`/api/v1/playx/orders`
* 路径:`/api/v1/mall/orders`
请求:
* `session_id``user_id`
@@ -476,7 +472,7 @@ curl -X POST 'http://localhost:1818/api/v1/playx/withdraw/apply' \
##### 示例
```bash
curl -G 'http://localhost:1818/api/v1/playx/orders' --data-urlencode 'session_id=7b1c....'
curl -G 'http://localhost:1818/api/v1/mall/orders' --data-urlencode 'session_id=7b1c....'
```
```json
@@ -502,7 +498,7 @@ curl -G 'http://localhost:1818/api/v1/playx/orders' --data-urlencode 'session_id
```
#### 同步额度
当前代码未实现并未注册路由:`/api/v1/playx/sync-limit`
当前代码未实现并未注册路由:`/api/v1/mall/syncLimit`
如需补齐,请在接口设计阶段新增对应实现与 PlayX API 对接。
---
@@ -530,7 +526,7 @@ curl -G 'http://localhost:1818/api/v1/playx/orders' --data-urlencode 'session_id
- 发货:录入物流公司、单号 → `SHIPPED`
- 驳回:录入驳回原因 → `REJECTED`,自动退回积分
- **红利/提现订单**
- 展示 `external_transaction_id``playx_transaction_id`发放子状态
- 展示 `external_transaction_id``playx_transaction_id`推送playx
- 手动重试:仅对 `FAILED_RETRYABLE` 状态,记录 `retry_request_id`、操作者、原因
### 2.3 用户资产与人工调账
@@ -607,29 +603,30 @@ curl -G 'http://localhost:1818/api/v1/playx/orders' --data-urlencode 'session_id
**表名**`mall_playx_order`(或统一改造 mall_pints_order / mall_redemption_order
| 字段 | 类型 | 说明 |
|------|------|------|
| id | int | 主键 |
| user_id | varchar(64) | 用户 ID |
| type | enum | BONUS / PHYSICAL / WITHDRAW |
| status | enum | PENDING / COMPLETED / SHIPPED / REJECTED |
| mall_item_id | int | 商品 ID |
| points_cost | int | 消耗积分 |
| amount | decimal(15,2) | 现金面值(红利/提现) |
| multiplier | int | 流水倍数 |
| external_transaction_id | varchar(64) | 外部交易幂等键 |
| playx_transaction_id | varchar(64) | PlayX 流水号 |
| 字段 | 类型 | 说明 |
|------|------|----------------------------------------------------------------------|
| id | int | 主键 |
| user_id | varchar(64) | 用户 ID |
| type | enum | BONUS / PHYSICAL / WITHDRAW |
| status | enum | PENDING / COMPLETED / SHIPPED / REJECTED |
| mall_item_id | int | 商品 ID |
| points_cost | int | 消耗积分 |
| amount | decimal(15,2) | 现金面值(红利/提现) |
| multiplier | int | 流水倍数 |
| external_transaction_id | varchar(64) | 订单号 |
| playx_transaction_id | varchar(64) | PlayX 流水号 |
| grant_status | enum | NOT_SENT / SENT_PENDING / ACCEPTED / FAILED_RETRYABLE / FAILED_FINAL |
| fail_reason | text | 失败原因 |
| retry_count | int | 重试次数 |
| reject_reason | varchar(255) | 驳回原因(实物) |
| shipping_company | varchar(50) | 物流公司 |
| shipping_no | varchar(64) | 物流单号 |
| receiver_name | varchar(50) | 收货人 |
| receiver_phone | varchar(20) | 收货电话 |
| receiver_address | text | 收货地址 |
| create_time | bigint | 创建时间 |
| update_time | bigint | 更新时间 |
| fail_reason | text | 失败原因 |
| retry_count | int | 重试次数 |
| reject_reason | varchar(255) | 驳回原因(实物) |
| shipping_company | varchar(50) | 物流公司 |
| shipping_no | varchar(64) | 物流单号 |
| mall_address_id | int unsigned, NULL | 实物兑换所选 `mall_address.id`(快照仍见 `receiver_*` |
| receiver_name | varchar(50) | 收货 |
| receiver_phone | varchar(20) | 收货电话 |
| receiver_address | text | 收货地址 |
| create_time | bigint | 创建时间 |
| update_time | bigint | 更新时间 |
**索引**`user_id``external_transaction_id``type``status`

View File

@@ -85,6 +85,15 @@ class Auth
}
} elseif (in_array($rule, $name)) {
$list[] = $rule;
} else {
// 仅勾选父级菜单(如 auth/admin允许访问子路径auth/admin/index、add 等)
$baseRule = preg_replace('/\?.*$/U', '', $rule);
foreach ((array) $name as $n) {
if ($baseRule !== '' && str_starts_with((string) $n, $baseRule . '/')) {
$list[] = $rule;
break;
}
}
}
}
if ($relation === 'or' && !empty($list)) {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

View File

@@ -1,102 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="200px" height="200px" viewBox="0 0 200 200" enable-background="new 0 0 200 200" xml:space="preserve"> <image id="image0" width="200" height="200" x="0" y="0"
href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAMAAACahl6sAAAABGdBTUEAALGPC/xhBQAAACBjSFJN
AAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAC+lBMVEX///9No/1Dnv1Dnv1D
nv1Dnv1Dnv1Dnv1Dnv1Dnv1Dnv1Dnv1Dnv1Dnv1Dnv1Fn/1En/1GoP1Dnv1Dnv1Dnv1Dnv1Dnv1D
nv1Dnv1Dnv1Dnv1Dnv1Dnv1Jof1Dnv1Dnv1Zqf1Dnv1cq/1OpP1Qpf1Tp/1Mo/1PpP1En/2antB2
w/9+vfeAvvVxvv6FltWas+F41f+Dyv+FzP9zwP5PpP1Vp/1Vp/1erP1erP1ss/1Fn/1Vp/3TlW9i
tP6/mLFfsv5Lov1psv1Mo/2Gzf+ck8Nruv5Jof1Spv28nrpJov1jr/1ttP1Tpv3Clpywj7Vbqv1M
o/1nmud7xf99x/9Rpf1Iof22ka9br/68k69Kov1OpP1VqP3FpoZ2ldhUp/2VkM5rs/3EmZZouP5K
ov1Qpf11leJMnfe3psVOpP1nsf1Vp/3AkaSIzv9bq/xRpf1Up/1bmu+Cyv+qkbpNpP3Gn4+TksRW
qP1HoP1GoP1dsf5Tpv1/yP+7kqtNpP3EqYJUp/2lk7xMo/1Spv1WqP1Uqv1WqP1jvP7FjqXifIWn
msOQmM3neH/LiZ6BpuLWgZFwqe7ag5DThpatl72UotS8hKJSpv05mf0/nP114f+Fzv+I0P+B1v+K
z/89m/1Anf1Dnv2Z5XaF0PyLz/+Uxe1+2f9Cnv07mv3BwVCd52d43v8+m/2v4zqq12aHzfnBsnqp
7jSurtKMzf+G1P86mv2h6Vm12jymtty2w2212kCGzvSj412s2l+s2GK9uXSh61Zz5P9Cnf2m3l6r
3lqu11+otNiwzmeo41at2F+Zv+p83f+k6FWz2kSKz+OCzf+v2FGLz96Pyvaz2kmw2U6N0Naos9qQ
xvLFrnWy2U+Q0c2fu+SS0cGw2VWV0bmV0ras2GGX0q+w2ViY0qr+YVz/XVb5Z2b/V03Nk6iawOH8
ZGL0bm37Z2WG6bT1bGzXiZmd8lv/XWL4amm/u2r/XWn/WlHFtWL/V277Y27rc3jDnLar3F/LrGSZ
+VTya23////Mjm1rAAAAlnRSTlMABAsRHCczOkFGST43LyQXDwhTX2ZvdWlkW08sVQJiIBJYaty+
zuHWd7zDwsW0l+Xl5uy6i7axf28x/cD4meOV8kKE8amp9NjwfGEjmdy7lO+FzdOQ98SP2X/npvaL
yaE24qPq4ox4/odNotP5jd7Gf+K26uyeqnr7k9TZzoH3nbHtlLqGrqPe+smh/t+r753z6c68y0O0
Xjp1AAAAAWJLR0QAiAUdSAAAAAd0SU1FB+YEBBcDHr5guP0AAA+vSURBVHja7V15fFTVFQ7ZzL6R
vABJALUI2koVWUOriIo7SgVqQFErm4hgRQOlYBBLpdpFu8wgcTIVzVhSsNKgUkRJQYjWarW2tNTW
2oRYhqaaGitd+KPzZubet913z/dme6G/fD9//hTecr+593z3nHvOuzctrR/96Ec/+hHGgPSMzKzs
U3Jy8/LycvMLCouKS0rdbpNTlBQV5pcNLK9QjKgcNHhIQVXmyUEnPaugulyRomJgXnZmjdsNlaG0
KH9ghYKhsjo70+32ilFSWIaSYCjPLeprHZNeWO2QBOuY3CK3265D0RCnfWHol/xitwmEUVowNA4W
EVRnuc0iLSMnns7QMDTbVWvJyE0IizAqCwa4RaMkgTTCVLJdoTEgPzGDyjDAXLCVqsqE01BRnWIF
K45x2gCQk0qrPyVpNEIoH5ay7hicTB4h5KamUwqSTCOEQSnwJ0vLks8jhIJk88hMjlhZUZbc6bEw
RTRCKE+mEOekjkcISZsda1JjHhqSZCglA1PMI6TDyeAxPPGuFY2yxPModoOHopyacB6u0Ahh8P8J
jwQzyXCPR0JHV7o79sGQOIuPf5kkPuQniEfygigUhU6ae9rpnxJjxOkMZ4wcxZ995lmfFuAzZ6Nt
G/3Zc84VYcx57Iqx48ZzTJgoxmkCIpNqPV4Knsm8IZ/b9OhmET4PEjm/cfNjQlzArpgCtGe8qEsu
9DX5CXibpnIiP3z80S2CdlyE8bj4iSe3CHlcwq4Y52km4A9ME/v6kz0UEb/nUj40nvrRo0Im2OC6
bKuYxxh2weW1LVRjmgNXiK3kyiYvdW+T7yr2pqt/vG27kMnlAI/p2xuJgTWC/lUDI+3s/Rrg5mvZ
m2Y8/RMxky/QPGY8s3WHkMd17IqZXnKcN3kn2REZMMtHD67x7F2zt9n0yRdJItc3/lTcIXXsijnA
MJ9rL8FXtDZTt/tq+dC5YeezQibnUjxufM7GQrh9zQ2QDWnxD5dMJqcH6B9iHnvbTc/v3LXtZwIm
lxBEzt8qll5N8ZChMUE2K97cAgzNsex1t+x+YdceAZOLRkl5TLeT3i+xKwD9DNwqn9/HA2NzPnvf
ghdf2iPsk/NkPBYu2iu2dD4kF9Py2ea7TU5kuJ9+RmAJe+PtP98nZLL0DgmR2Y/bWDoXiWXAr7k8
jcAEoFensaFz59Mv7Rcy+bLE0l+2kV4u21chLsaVFJGaabSdBeayd9514KCYib3Ldf6TYktfyqV3
BaA4SygeqgSTj/FO4RJ898729j0Cix9jx+OeV14lnKx6eg5oqR1OE0kbCYzQEeyto5//xf72Pa+1
//J1U/PsXK7LtooNhDNfuQqQ3okAj7RJgATrXK7d+0J98tobvzIzEbtcd71pI71fYVesBoz0DIQH
JMGt3OVS7T3E5K1fm5lcJ+KxcNEmMY+vsivGemnPNXAhRgSQYIPLtS/C5G0TE5HLtcZOeteyK+YD
lj4Z4wFJsM7lulftkhCTt0x9InC5bvzNJrH0nuXA0gHp5bgV+Fm4vasSHGbyhsnirRJ8vU18u5Rd
MAqx9GtgHmkTA23kQNVcLlWCI0yMfTLG7HLd84rNXMidrOXAWFhVghMpn+/E5QpLcLvA4s0ul530
cq8XcLL8rfU5MI9MRDvaAg3s/SGXK9onJouvM/BY91sb6eVjEHCyWu9TKmAiZYoyD3G5uAQfCtt7
2OLfft1Ogu9ctJWQ3oYAMIOtVxS06K4UnV/vZy2Yvu1glMnvXvu9vk/0ErymkZLeKYDGrA5dVw0S
Cadux9Fd0uJfyZpwQ8TeLUx0EkzHt8BE7G0LL6yBlcOnwj8Pl+CbovYeZSLQI1uvl3OFBkFkqRNb
Cy6NPHeJIwlm9m5mspRJ8D2HbbxevpIFmKVvVeRxWMqEFQUgEjyHW/LTUXs3M2Fztp30OnGymltZ
IASNLZZNB0S9WQuxuL2rTN76g8ZkrVx6v6bgv1sr/92qAB4DKhz0dWAWu3ght3eVyS6NSfgnt11w
4E5WA72S1eSdya4eAhAZxs1zZa0TCdbsPcTknV1/5EzUSGP2drH0LuW6Nw2U3giQOVFXb7IB0MNm
PgvcsvsFrU/eefZPjMkYidfL49v7gVf5dY4CUNClL9S4D/iZeKJhwYvc3tvb39WYbP668kAjIb11
zUAIdL+uZXSZSqnuamU9vTAT9hkiWMMl2MBk8znrXraRXj7xI/HtFH3L6Mm9SH+5cinkxTEJPqTr
EpXJn6N98opYebWVrJn0MkGbr0HfMNpIjAWkUyG/ml297sBBPZP3oky2NG4OdYhgcPEgE1nJmm9o
mEKWpZly0cBysm8Vl567NQmOMPkLs/gdT1rnEe5kIfGtd7GxYeRMYq5ZRByg5ezi0ToJ1vfJYzs2
PbfIbCfO4tvlpnZR0VW66XplLu1yeZt5rvdqvb1rfbJj7xMdndtNfeIkieCbZQ6bqVqbIjMRZT69
hKpJ8Ay9BHMmIR5HOrve32sgwte5p7Y5cYUYKgki1jJSZMVMk+DZ7x60Mtn716OdwWMdTxgCEp55
AJTRs8LSLCVdTiTfegeQKNYkeOG9L7W3m5j8TeXRHez6+yadcnHpReYq73prs4gKekEBDeRybWBX
X3zA2CX73/3gqQ8/DHZ3d/d0HtYcFR6nKNc6crJQ2aoRVZICLpdBgt/T83jhg3989JHKozvYu/FV
7jqe7eDhLf46QavkTkq68OtUxOXiud4FL+okOMTj439+EuYRQtczzJl3lkQYJ2qUXH8zhLVyQC5M
GPWaeAQ7jzdGKzf4SpajZScD5N5WpvAeSFi4C6FFvXoe6r+DXdH4Sotvgfytlns1QD6RZImJTKW9
bN37WNRr6o/QPx3BzeEucRLfepaJ2zRUSiRbfBOUwecjIBr1GnlEyHT9S+0SHt8CKzUWJ4uhXErE
7uOpUUhNBbfJmw6E7H3/vy08uruPHnvz1S1LudeLxLfzbJokn9ptv6uoR1SSN/CW3ftCPP5j4RHq
kvf3ak4WsppZuzImIvbfeiLzFl94XHBop5BHd/eRjsPnsKuAIjkb6VUhD63ybIkg5QiaBE/f9l8h
j1CXbOSWjhTJTVESTQSKevkCmnLvx0Ie3d2933Dw07RpafDEEQGi3mbN5Xrwk6MiHsHOh7iThfgL
y5QYici+h3YW9X7zRI+ASE/nt9gFSBKhaXGsRPIlRKCyNi6WM44fEXRI77fZ30OWvlzSGrlqST/C
BQoNdS7XmhNWJkc6v8P+GrB0lkSIhYj8O8M5TlZtFj5kIRLsup797Vi6HFa3ziTCICmRItmtSDGu
Lte77vgxE4+OHq1Sw0nYKcRAKRHiEyRnK5sP95jsvetM9ldIOsy3XtoWufdbIv8Utw6xUL7WPNrY
JcHOjfxBs2KMb3WQp0hKB8nvHkevDekCU6ME9/Ry6QWU3CuMb3UgPvOhvrlH/FXucs3Q23uw9xHe
sUgSYTLREmLLjiHE7YAE61yu2bouOdbBV7Lill4VxCfJ5PYBSPW9tnC+8Ygmvd9lfwiIHyG9Koic
VRV1P5JF1qLei5m9BztOcK93DrAIu4JqRwXx4X4m9QDI+Z7Gr36ESXDX99gfAUkEXf7WDvL5UJ+c
tgPkJOkk+EhUetmYh6Lm1VQr6K8sB5GPAAJUnXbeHrb3nl7uZAHS20JJbwinUETyyEc4W629U5Xg
YO8D7P+RIjlD/tYG5D4K2fQz0PqwCO46cSx49Ci3dKBIThLfaiihiNDWjlXsabLzcGhgOYpvAw10
E8opHmlpwJfSi2kJ9nvq2dUPHu89wQNHZFjOp1ug5NFEkE+MoapWPjU/0uEsvh0LtAAoPUO2BoKS
AVqu9/vsv+qQnNE8oAFKBk0E2hWh3gNUfk+13AY5WSuB9wMmYs20C4G4XJeab0JK2VrnIq+Hapih
LfKAmLupxRziIR+BzUHejlQ56SvPZACiXnPQ3QDEty2kk6UCrMWGtqqo89OfaZt88Vjzt1YA4qsC
8FIUyOUyGi5QJIc4WSrAXeqwsQVVOOuWCoEulCQRjCML3aMO2walwUfnzXSLt46L5OwBb/IE7gfm
6CMTIL5tRpwsFfBeaCXY86DcMmubs1hfiqEoD1bmTwJwudhoqQdWX2RJBD2QMmxn5g6Vvo2Dr1yO
vRU2dRXl2DM3AN/1hhXVWZJIDkc7CaHbGIK5XqBITveJJgFn++eDG1EhEuxdnEjpRWd1hmzwsYgf
OALJq/hA6aVKAGPtEsQz99cCA2sZ9j6nHYJbCVBwBSwAwdLruEPA+AqLemmg0otFVEYUgY+upyWY
BJ1EiIJauhYC3Wf5vviZ0EmEKBzt4caA7mm4nv5mguJBJhGioJbgbYBuiguU20hB5W81xLoFMGjv
a4GUoAzW5RYbxLzZLLIOrAIIYiUg87cM+OffFmDRu6IAu9tIOgRIIoQRz9bl4OACcr220L7JTNbA
UoHuKrsiZgluDtRjr4BWSe0B7nYPfDthA1h6gWVrKcCNTAFHXQggfxuBg/hWjBrMTOqA3W1E0Oo9
5EjANt/gBB+bBKPSm5BtmEHvEahcEnQIJr2ViTmfIBt6GVBLZgEa38Zr6AzYoULAwqMJUP5Wgdes
AUBnEQDlNuaBhS0txuS72wBaegQyhEZLx+LbxJ6wgERZlwM5W0OHQPlbsugkCUzGO3JUsKXFRPPA
RheQ+9EAOVnJOLmDKndU1IVH3OWC8rfJOTQtn34xHvVCTlayDn3MJt+8to1OFEY7hM7fVg5PEo+0
tGHkQiqQPwgDyN+emsxjazPI422ACudwh5D526Qca6MDFcYvgaJe2smKO/4gQa1uA1lP2skamIrT
BDPkc+NMpLyOcLKSPawY5KcI0i4X4WSl7hzBtGLZNE9XOMuTCLkpPQQ1WyLEVLmN1MkanOoTqdMl
0RbhcrVusL2zwo2DXIttwy15hVnA/iOwHJeObh9mZyqyqLfJ9vvb3ESF5jGgSExFFvXa5W/dpKEi
U+jd20uwWHor80vcpaEiI9+6GLnSbxf1iqR3cKFrx04bUZNlsXs7CbZKb2VuqgVXivTCauPMYlNu
Y8rfVuYVuXqcuZhL1RDdGBN/X2FIIgzN6YMsIqjJLKhmZETlNlr+dlBeocsqRZMprsoZXC4ut1Hj
24qhZQVZfZ2EhvRhVdatVLz+H2RlujR5x4EBllyv/HygvgvzSSbU+UB9F8aTTNoCt8X/SHdwoeE7
P89qt9sTO/SJRa/3ZrebEzuKdRLsqXe7NfHgDt4l2PlAfRbaYVInq/QysPP8Tl7pZRjZGnGyTlrp
ZZgUrsKmj2br+1D3+HRyPlCfhXqYlJPzgfouJnh8q04+l1eEWz1XxP+QvoCJ4NFsfR5Xnhb/M/rR
j370I4L/AYWr1zfavhjTAAAAJXRFWHRkYXRlOmNyZWF0ZQAyMDIyLTA0LTA0VDE1OjAzOjMwKzA4
OjAwP7kofQAAACV0RVh0ZGF0ZTptb2RpZnkAMjAyMi0wNC0wNFQxNTowMzozMCswODowME7kkMEA
AAAgdEVYdHNvZnR3YXJlAGh0dHBzOi8vaW1hZ2VtYWdpY2sub3JnvM8dnQAAABh0RVh0VGh1bWI6
OkRvY3VtZW50OjpQYWdlcwAxp/+7LwAAABh0RVh0VGh1bWI6OkltYWdlOjpIZWlnaHQAMjAwfdcV
aQAAABd0RVh0VGh1bWI6OkltYWdlOjpXaWR0aAAyMDDuJkU0AAAAGXRFWHRUaHVtYjo6TWltZXR5
cGUAaW1hZ2UvcG5nP7JWTgAAABd0RVh0VGh1bWI6Ok1UaW1lADE2NDkwNTU4MTD76yvxAAAAEnRF
WHRUaHVtYjo6U2l6ZQA0NTEzQkLz/Q6yAAAARnRFWHRUaHVtYjo6VVJJAGZpbGU6Ly8vYXBwL3Rt
cC9pbWFnZWxjL2ltZ3ZpZXcyXzlfMTY0Nzg0ODMyNDI4NTI2NzhfODZfWzBdhSsH1AAAAABJRU5E
rkJggg==" ></image>
</svg>

Before

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -1,54 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/install/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BuildAdmin-安装</title>
<script>
(function(){
var urls = { adminUrl: '', frontUrl: '' };
fetch('/api/install/accessUrls').then(function(r){return r.json();}).then(function(res){
if (res && res.data) { urls.adminUrl = res.data.adminUrl || ''; urls.frontUrl = res.data.frontUrl || ''; }
}).catch(function(){});
function applyUrls() {
if (!urls.adminUrl && !urls.frontUrl) return;
document.querySelectorAll('input[type="text"], input:not([type])').forEach(function(inp){
var v = (inp.value || '').trim();
if (v && (v.indexOf('#/admin') >= 0 || v.indexOf('index.html') >= 0) && v.indexOf('#/') >= 0) {
inp.value = urls.adminUrl;
inp.dispatchEvent(new Event('input', { bubbles: true }));
}
});
document.querySelectorAll('a[href*="#/admin"]').forEach(function(a){ if (urls.adminUrl) a.href = urls.adminUrl; });
document.querySelectorAll('a[href*="#/"]').forEach(function(a){
if (urls.frontUrl && a.href.indexOf('#/admin') < 0) a.href = urls.frontUrl;
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function(){ setInterval(applyUrls, 800); });
else setInterval(applyUrls, 800);
})();
(function(){
function closeMigrateModal() {
if (!document.body) return;
var txt = document.body.innerText || document.body.textContent || '';
if (txt.indexOf('数据表迁移失败') < 0 && txt.indexOf('数据表自动迁移失败') < 0) return;
var btns = document.body.querySelectorAll('button, [role="button"], .el-button');
for (var i = 0; i < btns.length; i++) {
var b = btns[i];
if (b.textContent && b.textContent.indexOf('继续安装') >= 0) { b.click(); return; }
}
}
var obs = new MutationObserver(closeMigrateModal);
function start() { if (document.body) { obs.observe(document.body, { childList: true, subtree: true }); closeMigrateModal(); } }
if (document.body) start(); else document.addEventListener('DOMContentLoaded', start);
setInterval(closeMigrateModal, 150);
})();
</script>
<script type="module" crossorigin src="/install/assets/index.js"></script>
<link rel="stylesheet" crossorigin href="/install/assets/index.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

27
tmp_sig.php Normal file
View File

@@ -0,0 +1,27 @@
<?php
//脚本执行指令 php tmp_sig.php
$secret = '5590a339502b133f4d0c545c3cdad159a4827dfccb3f51bb110c56f9b96568ca';
$ts = '1775525663';
$rid = 'req_1775525663_123';
$body = [
'report_date' => '1775525663',
'member' => [
[
'member_id' => '123',
'login' => 'zhenhui',
'ltv_deposit' => 1500,
'ltv_withdrawal' => 1800,
'yesterday_total_wl' => -300,
'yesterday_total_deposit' => 600,
],
],
];
$json = json_encode($body);
$canonical = $ts . "\n" . $rid . "\nPOST\n/api/v1/mall/dailyPush\n" . hash('sha256', $json);
echo "json={$json}\n";
echo "sha256=" . hash('sha256', $json) . "\n";
echo "X-Signature=" . hash_hmac('sha256', $canonical, $secret) . "\n";

View File

@@ -8,4 +8,4 @@ VITE_BASE_PATH = '/'
VITE_OUT_DIR = 'dist'
# 线上环境接口地址 - 'getCurrentDomain:表示获取当前域名'
VITE_AXIOS_BASE_URL = 'getCurrentDomain'
VITE_AXIOS_BASE_URL = 'https://playx-api.cjdhr.top'

View File

@@ -1,7 +1,10 @@
/**
* backend common language package
*/
import menu from './en/menu'
export default {
menu,
Balance: 'Balance',
Integral: 'Integral',
Connection: 'connection',

View File

@@ -1,9 +1,6 @@
export default {
username: 'Username',
nickname: 'Nickname',
channel_id: 'Channel',
channel_name: 'Channel name',
'Please select channel': 'Please select channel',
group: 'Group',
avatar: 'Avatar',
email: 'Email',

View File

@@ -1,15 +0,0 @@
export default {
id: 'id',
name: 'name',
ip_white: 'ip_white',
ip_placeholder: 'Please enter IP address',
whitelist: 'Whitelist',
title: 'title',
remark: 'remark',
admin_id: 'admin_id',
admin__username: 'username',
secret: 'secret',
create_time: 'create_time',
update_time: 'update_time',
'quick Search Fields': 'id',
}

View File

@@ -36,4 +36,23 @@ export default {
second: 'Second',
day: 'Day',
'Number of attachments Uploaded': 'Number of attachments upload',
Today: 'Today',
Yesterday: 'Yesterday',
Orders: 'Orders',
Pending: 'Pending',
'Daily new players': 'Daily new players',
'Yesterday points': 'Yesterday points (claimed)',
'Yesterday redeem': 'Yesterday redeem',
'Pending physical to ship': 'Pending physical to ship',
'Yesterday item redeem stat': 'Yesterday item redeem stat',
'Yesterday redeem points sum': 'Yesterday redeem points sum',
'Yesterday redeem amount sum': 'Yesterday redeem amount sum',
'Grant failed retryable': 'Grant failed retryable',
'Item ID': 'Item ID',
'Item title': 'Item title',
'Order count': 'Order count',
Completed: 'Completed',
Rejected: 'Rejected',
'Points sum': 'Points sum',
'Amount sum': 'Amount sum',
}

View File

@@ -1,14 +1,14 @@
export default {
id: 'id',
mall_user_id: 'mall_user_id',
malluser__username: 'username',
playx_user_asset_id: 'PlayX user asset',
playxuserasset__username: 'username',
receiver_name: 'receiver name',
phone: 'phone',
region: 'region',
detail_address: 'detail_address',
address: 'address',
default_setting: 'default_setting',
'default_setting 0': 'default_setting 0',
'default_setting 1': 'default_setting 1',
default_setting: 'Default address',
'default_setting 0': '--',
'default_setting 1': 'YES',
create_time: 'create_time',
update_time: 'update_time',
'quick Search Fields': 'id',

View File

@@ -0,0 +1,9 @@
export default {
id: 'id',
claim_request_id: 'claim_request_id',
user_id: 'user_id',
claimed_amount: 'claimed_amount',
create_time: 'create_time',
'quick Search Fields': 'id',
}

View File

@@ -0,0 +1,13 @@
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',
}

View File

@@ -0,0 +1,43 @@
export default {
approve: 'Review',
manual_retry: 'Retry grant',
retry_confirm: 'Queue this order for grant retry?',
id: 'Order ID',
user_id: 'User ID',
type: 'Type',
'type BONUS': 'Bonus',
'type PHYSICAL': 'Physical',
'type WITHDRAW': 'Withdraw',
status: 'Status',
'status PENDING': 'Pending',
'status COMPLETED': 'Completed',
'status SHIPPED': 'Shipped',
'status REJECTED': 'Rejected',
mall_item_id: 'Product ID',
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)',
'grant_status ACCEPTED': 'Accepted',
'grant_status FAILED_RETRYABLE': 'Failed (retryable)',
'grant_status FAILED_FINAL': 'Failed (final)',
'grant_status ---': '—',
fail_reason: 'Failure reason',
reject_reason: 'Rejection reason',
shipping_company: 'Carrier',
shipping_no: 'Tracking number',
receiver_name: 'Recipient name',
receiver_phone: 'Recipient phone',
receiver_address: 'Shipping address',
mall_address_id: 'Address ID',
start_time: 'Redemption time',
end_time: 'Collection end time',
create_time: 'Created at',
update_time: 'Updated at',
'quick Search Fields': 'Order ID',
}

View File

@@ -1,8 +1,8 @@
export default {
id: 'id',
order: 'order',
mall_user_id: 'mall_user_id',
malluser__username: 'username',
playx_user_asset_id: 'PlayX user asset',
playxuserasset__username: 'username',
type: 'type',
'type 1': 'type 1',
'type 2': 'type 2',

View File

@@ -1,9 +0,0 @@
export default {
id: 'id',
username: 'username',
password: 'password',
create_time: 'create_time',
update_time: 'update_time',
score: 'score',
quickSearchFields: 'id',
}

View File

@@ -23,6 +23,7 @@ export default {
'grant_status ACCEPTED': 'ACCEPTED',
'grant_status FAILED_RETRYABLE': 'FAILED_RETRYABLE',
'grant_status FAILED_FINAL': 'FAILED_FINAL',
'grant_status ---': '---',
fail_reason: 'fail_reason',
reject_reason: 'reject_reason',
shipping_company: 'shipping_company',

View File

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

View File

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

View File

@@ -1,15 +0,0 @@
export default {
id: 'id',
username: 'username',
phone: 'phone',
password: 'password',
score: 'score',
daily_claim: 'daily_claim',
daily_claim_use: 'daily_claim_use',
available_for_withdrawal: 'available_for_withdrawal',
admin_id: 'admin_id',
admin__username: 'username',
create_time: 'create_time',
update_time: 'update_time',
'quick Search Fields': 'id',
}

View File

@@ -0,0 +1,15 @@
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',
}

View File

@@ -0,0 +1,119 @@
/**
* Admin menu titles (admin_rule.name → menu.names.{name with / as _})
*/
export default {
names: {
dashboard: 'Dashboard',
dashboard_index: 'Browse',
dashboard_dashboard: 'Dashboard',
auth: 'Access control',
auth_group: 'Admin groups',
auth_group_index: 'Browse',
auth_group_add: 'Add',
auth_group_edit: 'Edit',
auth_group_del: 'Delete',
auth_admin: 'Administrators',
auth_admin_index: 'Browse',
auth_admin_add: 'Add',
auth_admin_edit: 'Edit',
auth_admin_del: 'Delete',
auth_rule: 'Menu rules',
auth_rule_index: 'Browse',
auth_rule_add: 'Add',
auth_rule_edit: 'Edit',
auth_rule_del: 'Delete',
auth_rule_sortable: 'Sort',
auth_adminLog: 'Admin logs',
auth_adminLog_index: 'Browse',
user: 'Members',
user_user: 'Members',
user_user_index: 'Browse',
user_user_add: 'Add',
user_user_edit: 'Edit',
user_user_del: 'Delete',
user_group: 'Member groups',
user_group_index: 'Browse',
user_group_add: 'Add',
user_group_edit: 'Edit',
user_group_del: 'Delete',
user_rule: 'Member rules',
user_rule_index: 'Browse',
user_rule_add: 'Add',
user_rule_edit: 'Edit',
user_rule_del: 'Delete',
user_rule_sortable: 'Sort',
user_moneyLog: 'Balance logs',
user_moneyLog_index: 'Browse',
user_moneyLog_add: 'Add',
user_scoreLog: 'Points logs',
user_scoreLog_index: 'Browse',
user_scoreLog_add: 'Add',
routine: 'General',
routine_config: 'System config',
routine_config_index: 'Browse',
routine_config_edit: 'Edit',
routine_config_add: 'Add',
routine_config_del: 'Delete',
routine_attachment: 'Attachments',
routine_attachment_index: 'Browse',
routine_attachment_edit: 'Edit',
routine_attachment_del: 'Delete',
routine_adminInfo: 'Profile',
routine_adminInfo_index: 'Browse',
routine_adminInfo_edit: 'Edit',
security: 'Data security',
security_dataRecycleLog: 'Recycle bin',
security_dataRecycleLog_index: 'Browse',
security_dataRecycleLog_del: 'Delete',
security_dataRecycleLog_restore: 'Restore',
security_dataRecycleLog_info: 'Details',
security_sensitiveDataLog: 'Sensitive data logs',
security_sensitiveDataLog_index: 'Browse',
security_sensitiveDataLog_del: 'Delete',
security_sensitiveDataLog_rollback: 'Rollback',
security_sensitiveDataLog_info: 'Details',
security_dataRecycle: 'Recycle rules',
security_dataRecycle_index: 'Browse',
security_dataRecycle_add: 'Add',
security_dataRecycle_edit: 'Edit',
security_dataRecycle_del: 'Delete',
security_sensitiveData: 'Sensitive field rules',
security_sensitiveData_index: 'Browse',
security_sensitiveData_add: 'Add',
security_sensitiveData_edit: 'Edit',
security_sensitiveData_del: 'Delete',
buildadmin: 'BuildAdmin',
buildadmin_buildadmin: 'BuildAdmin',
moduleStore_moduleStore: 'Module store',
moduleStore_moduleStore_index: 'Browse',
moduleStore_moduleStore_install: 'Install',
moduleStore_moduleStore_changeState: 'Change state',
moduleStore_moduleStore_uninstall: 'Uninstall',
moduleStore_moduleStore_update: 'Update',
crud_crud: 'CRUD generator',
crud_crud_index: 'Browse',
crud_crud_generate: 'Generate',
crud_crud_delete: 'Delete',
mall: 'Points mall',
mall_userAsset: 'User assets',
mall_userAsset_index: 'Browse',
mall_userAsset_edit: 'Edit',
mall_userAsset_del: 'Delete',
mall_address: 'Shipping addresses',
mall_order: 'Orders',
mall_order_add: 'Add',
mall_order_edit: 'Edit',
mall_order_del: 'Delete',
mall_order_approve: 'Approve',
mall_dailyPush: 'Daily push',
mall_claimLog: 'Claim log',
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_pintsOrder: 'Points orders',
mall_redemptionOrder: 'Redemption orders',
},
}

View File

@@ -1,6 +1,5 @@
export default {
'Upload administrator': 'Upload administrator',
'Upload user': 'Upload member',
'Storage mode': 'Storage mode',
'Physical path': 'Physical path',
'image width': 'Picture width',

View File

@@ -2,7 +2,10 @@
* 后台公共语言包
* 覆盖风险:请避免使用页面语言包的目录名、文件名作为翻译 key
*/
import menu from './zh-cn/menu'
export default {
menu,
Balance: '余额',
Integral: '积分',
Connection: '连接标识',

View File

@@ -1,9 +1,6 @@
export default {
username: '用户名',
nickname: '昵称',
channel_id: '渠道',
channel_name: '渠道名称',
'Please select channel': '请选择渠道',
group: '角色组',
avatar: '头像',
email: '电子邮箱',

View File

@@ -1,15 +0,0 @@
export default {
id: 'ID',
name: '渠道名',
ip_white: 'IP白名单',
ip_placeholder: '请输入IP地址',
whitelist: '白名单',
title: '标题',
remark: '备注',
admin_id: '管理员',
admin__username: '用户名',
secret: '密钥',
create_time: '创建时间',
update_time: '修改时间',
'quick Search Fields': 'ID',
}

View File

@@ -36,4 +36,23 @@ export default {
second: '秒',
day: '天',
'Number of attachments Uploaded': '附件上传量',
Today: '今日',
Yesterday: '昨日',
Orders: '订单',
Pending: '待处理',
'Daily new players': '每日新增玩家',
'Yesterday points': '昨日积分(领取)',
'Yesterday redeem': '昨日兑换',
'Pending physical to ship': '待发货实物单',
'Yesterday item redeem stat': '昨日商品兑换统计',
'Yesterday redeem points sum': '昨日兑换消耗积分合计',
'Yesterday redeem amount sum': '昨日兑换现金面值合计',
'Grant failed retryable': '发放失败待重试',
'Item ID': '商品ID',
'Item title': '商品名称',
'Order count': '兑换次数',
Completed: '已完成',
Rejected: '已驳回',
'Points sum': '消耗积分合计',
'Amount sum': '现金面值合计',
}

View File

@@ -1,14 +1,14 @@
export default {
id: 'ID',
mall_user_id: '用户',
malluser__username: '用户名',
playx_user_asset_id: '用户资产',
playxuserasset__username: '用户名',
receiver_name: '收货人',
phone: '电话',
region: '地区',
detail_address: '详细地址',
address: '地址',
default_setting: '默认地址',
'default_setting 0': '',
'default_setting 1': '',
'default_setting 0': '--',
'default_setting 1': '',
create_time: '创建时间',
update_time: '修改时间',
'quick Search Fields': 'ID',

View File

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

View File

@@ -0,0 +1,13 @@
export default {
id: 'ID',
user_id: '用户ID',
date: '业务日期',
username: '用户名',
yesterday_win_loss_net: '昨日净输赢',
yesterday_total_deposit: '昨日总充值',
lifetime_total_deposit: '历史总充值',
lifetime_total_withdraw: '历史总提现',
create_time: '创建时间',
'quick Search Fields': 'ID',
}

View File

@@ -0,0 +1,43 @@
export default {
approve: '审核',
manual_retry: '手动重试',
retry_confirm: '确认将该订单加入重试队列?',
id: 'ID',
user_id: '用户ID',
type: '类型',
'type BONUS': '红利(BONUS)',
'type PHYSICAL': '实物(PHYSICAL)',
'type WITHDRAW': '提现(WITHDRAW)',
status: '状态',
'status PENDING': '处理中(PENDING)',
'status COMPLETED': '已完成(COMPLETED)',
'status SHIPPED': '已发货(SHIPPED)',
'status REJECTED': '已驳回(REJECTED)',
mall_item_id: '商品ID',
mallitem__title: '商品标题',
points_cost: '消耗积分',
amount: '现金面值',
multiplier: '流水倍数',
external_transaction_id: '订单号',
playx_transaction_id: 'PlayX流水号',
grant_status: '推送playx状态',
'grant_status NOT_SENT': '未发送',
'grant_status SENT_PENDING': '已发送排队',
'grant_status ACCEPTED': '已接收',
'grant_status FAILED_RETRYABLE': '失败可重试',
'grant_status FAILED_FINAL': '失败最终',
'grant_status ---': '---',
fail_reason: '失败原因',
reject_reason: '驳回原因',
shipping_company: '物流公司',
shipping_no: '物流单号',
receiver_name: '收货人',
receiver_phone: '收货电话',
receiver_address: '收货地址',
mall_address_id: '地址ID',
start_time: '兑换时间',
end_time: '领取结束时间',
create_time: '创建时间',
update_time: '修改时间',
'quick Search Fields': 'ID',
}

View File

@@ -1,8 +1,8 @@
export default {
id: 'ID',
order: '订单编号',
mall_user_id: '用户',
malluser__username: '用户名',
playx_user_asset_id: '用户资产',
playxuserasset__username: '用户名',
type: '类型',
'type 1': '奖励',
'type 2': '充值',

View File

@@ -1,9 +0,0 @@
export default {
id: 'ID',
username: '用户名',
password: '密码',
create_time: '创建时间',
update_time: '修改时间',
score: '积分',
quickSearchFields: 'ID',
}

View File

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

View File

@@ -1,6 +1,6 @@
export default {
id: 'ID',
claim_request_id: '领取幂等键',
claim_request_id: '领取订单号',
user_id: '用户ID',
claimed_amount: '领取积分',
create_time: '创建时间',

View File

@@ -15,14 +15,15 @@ export default {
points_cost: '消耗积分',
amount: '现金面值',
multiplier: '流水倍数',
external_transaction_id: '外部交易幂等键',
external_transaction_id: '订单号',
playx_transaction_id: 'PlayX流水号',
grant_status: '发放子状态',
grant_status: '推送playx状态',
'grant_status NOT_SENT': '未发送',
'grant_status SENT_PENDING': '已发送排队',
'grant_status ACCEPTED': '已接收(accepted)',
'grant_status FAILED_RETRYABLE': '失败可重试',
'grant_status FAILED_FINAL': '失败最终',
'grant_status ---': '---',
fail_reason: '失败原因',
reject_reason: '驳回原因',
shipping_company: '物流公司',

View File

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

View File

@@ -1,8 +1,8 @@
export default {
id: 'ID',
order: '订单号',
mall_user_id: '用户',
malluser__username: '用户名',
playx_user_asset_id: '用户资产',
playxuserasset__username: '用户名',
status: '状态',
'status 0': '待发放',
'status 1': '已发放',

View File

@@ -1,15 +0,0 @@
export default {
id: 'ID',
username: '用户名',
phone: '手机号',
password: '密码',
score: '积分',
daily_claim: '每日限额',
daily_claim_use: '每日限额(已使用)',
available_for_withdrawal: '可提现金额',
admin_id: '归属管理员id',
admin__username: '归属管理员',
create_time: '创建时间',
update_time: '修改时间',
'quick Search Fields': 'ID',
}

View File

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

View File

@@ -0,0 +1,120 @@
/**
* 后台菜单标题(与 admin_rule.name 对应menu.names.{name 中 / 改为 _}
*/
export default {
names: {
/** version202 后菜单 name 为 dashboard不再使用 dashboard/dashboard */
dashboard: '控制台',
dashboard_index: '查看',
dashboard_dashboard: '控制台',
auth: '权限管理',
auth_group: '角色组管理',
auth_group_index: '查看',
auth_group_add: '添加',
auth_group_edit: '编辑',
auth_group_del: '删除',
auth_admin: '管理员管理',
auth_admin_index: '查看',
auth_admin_add: '添加',
auth_admin_edit: '编辑',
auth_admin_del: '删除',
auth_rule: '菜单规则管理',
auth_rule_index: '查看',
auth_rule_add: '添加',
auth_rule_edit: '编辑',
auth_rule_del: '删除',
auth_rule_sortable: '快速排序',
auth_adminLog: '管理员日志管理',
auth_adminLog_index: '查看',
user: '会员管理',
user_user: '会员管理',
user_user_index: '查看',
user_user_add: '添加',
user_user_edit: '编辑',
user_user_del: '删除',
user_group: '会员分组管理',
user_group_index: '查看',
user_group_add: '添加',
user_group_edit: '编辑',
user_group_del: '删除',
user_rule: '会员规则管理',
user_rule_index: '查看',
user_rule_add: '添加',
user_rule_edit: '编辑',
user_rule_del: '删除',
user_rule_sortable: '快速排序',
user_moneyLog: '会员余额管理',
user_moneyLog_index: '查看',
user_moneyLog_add: '添加',
user_scoreLog: '会员积分管理',
user_scoreLog_index: '查看',
user_scoreLog_add: '添加',
routine: '常规管理',
routine_config: '系统配置',
routine_config_index: '查看',
routine_config_edit: '编辑',
routine_config_add: '添加',
routine_config_del: '删除',
routine_attachment: '附件管理',
routine_attachment_index: '查看',
routine_attachment_edit: '编辑',
routine_attachment_del: '删除',
routine_adminInfo: '个人资料',
routine_adminInfo_index: '查看',
routine_adminInfo_edit: '编辑',
security: '数据安全管理',
security_dataRecycleLog: '数据回收站',
security_dataRecycleLog_index: '查看',
security_dataRecycleLog_del: '删除',
security_dataRecycleLog_restore: '还原',
security_dataRecycleLog_info: '查看详情',
security_sensitiveDataLog: '敏感数据修改记录',
security_sensitiveDataLog_index: '查看',
security_sensitiveDataLog_del: '删除',
security_sensitiveDataLog_rollback: '回滚',
security_sensitiveDataLog_info: '查看详情',
security_dataRecycle: '数据回收规则管理',
security_dataRecycle_index: '查看',
security_dataRecycle_add: '添加',
security_dataRecycle_edit: '编辑',
security_dataRecycle_del: '删除',
security_sensitiveData: '敏感字段规则管理',
security_sensitiveData_index: '查看',
security_sensitiveData_add: '添加',
security_sensitiveData_edit: '编辑',
security_sensitiveData_del: '删除',
buildadmin: 'BuildAdmin',
buildadmin_buildadmin: 'BuildAdmin',
moduleStore_moduleStore: '模块市场',
moduleStore_moduleStore_index: '查看',
moduleStore_moduleStore_install: '安装',
moduleStore_moduleStore_changeState: '调整状态',
moduleStore_moduleStore_uninstall: '卸载',
moduleStore_moduleStore_update: '更新',
crud_crud: 'CRUD代码生成',
crud_crud_index: '查看',
crud_crud_generate: '生成',
crud_crud_delete: '删除',
mall: '积分商城',
mall_userAsset: '用户资产',
mall_userAsset_index: '查看',
mall_userAsset_edit: '编辑',
mall_userAsset_del: '删除',
mall_address: '收货地址管理',
mall_order: '统一订单',
mall_order_add: '新增',
mall_order_edit: '编辑',
mall_order_del: '删除',
mall_order_approve: '审核通过',
mall_dailyPush: '每日推送',
mall_claimLog: '领取记录',
mall_item: '商品管理',
mall_playxOrder: 'PlayX订单',
mall_playxCenter: 'PlayX中心',
mall_playxClaimLog: 'PlayX领取记录',
mall_playxDailyPush: 'PlayX每日推送',
mall_playxUserAsset: 'PlayX用户资产',
mall_pintsOrder: '积分订单',
mall_redemptionOrder: '兑换订单',
},
}

View File

@@ -1,6 +1,5 @@
export default {
'Upload administrator': '上传管理员',
'Upload user': '上传会员',
'Storage mode': '存储方式',
'Physical path': '物理路径',
'image width': '图片宽度',

View File

@@ -4,7 +4,7 @@
<el-sub-menu @click="onClickSubMenu(menu)" :index="getMenuKey(menu)" :key="getMenuKey(menu)">
<template #title>
<Icon :color="config.getColorVal('menuColor')" :name="menu.meta?.icon ? menu.meta?.icon : config.layout.menuDefaultIcon" />
<span>{{ menu.meta?.title ? menu.meta?.title : $t('noTitle') }}</span>
<span>{{ menuTitleFromRoute(menu) }}</span>
</template>
<MenuTree :extends="{ ...props.extends, level: props.extends.level + 1 }" :menus="menu.children" />
</el-sub-menu>
@@ -12,7 +12,7 @@
<template v-else>
<el-menu-item @click="onClickMenu(menu)" :index="getMenuKey(menu)" :key="getMenuKey(menu)">
<Icon :color="config.getColorVal('menuColor')" :name="menu.meta?.icon ? menu.meta?.icon : config.layout.menuDefaultIcon" />
<span>{{ menu.meta?.title ? menu.meta?.title : $t('noTitle') }}</span>
<span>{{ menuTitleFromRoute(menu) }}</span>
</el-menu-item>
</template>
</template>
@@ -23,6 +23,7 @@ import { ElNotification } from 'element-plus'
import { useI18n } from 'vue-i18n'
import type { RouteRecordRaw } from 'vue-router'
import { useConfig } from '/@/stores/config'
import { menuTitleFromRoute } from '/@/utils/menuI18n'
import { getFirstRoute, getMenuKey, onClickMenu } from '/@/utils/router'
const { t } = useI18n()

View File

@@ -9,7 +9,7 @@
:ref="tabsRefs.set"
:key="idx"
>
{{ item.meta.title }}
{{ menuTitleFromRoute(item) }}
<transition @after-leave="selectNavTab(tabsRefs[navTabs.state.activeIndex])" name="el-fade-in">
<Icon v-show="navTabs.state.tabsView.length > 1" class="close-icon" @click.stop="closeTab(item)" size="15" name="el-icon-Close" />
</transition>
@@ -29,6 +29,7 @@ import type { ContextMenuItem, ContextMenuItemClickEmitArg } from '/@/components
import useCurrentInstance from '/@/utils/useCurrentInstance'
import Contextmenu from '/@/components/contextmenu/index.vue'
import horizontalScroll from '/@/utils/horizontalScroll'
import { menuTitleFromRoute } from '/@/utils/menuI18n'
import { getFirstRoute, routePush } from '/@/utils/router'
import { adminBaseRoutePath } from '/@/router/static/adminBase'

View File

@@ -13,7 +13,6 @@ export const useAdminInfo = defineStore('adminInfo', {
token: '',
refresh_token: '',
super: false,
channel_id: 0,
}
},
actions: {

View File

@@ -113,8 +113,6 @@ export interface AdminInfo {
refresh_token: string
// 是否是 superAdmin用于判定是否显示终端按钮等不做任何权限判断
super: boolean
// 渠道ID创建子管理员时默认绑定
channel_id?: number
}
export interface UserInfo {

View File

@@ -51,7 +51,6 @@ function createAxios<Data = any, T = ApiPromise<Data>>(axiosConfig: AxiosRequest
timeout: 1000 * 10,
headers: {
'think-lang': config.lang.defaultLang,
server: true,
},
responseType: 'json',
})

33
web/src/utils/menuI18n.ts Normal file
View File

@@ -0,0 +1,33 @@
import type { RouteLocationNormalized, RouteRecordRaw } from 'vue-router'
import { i18n } from '/@/lang/index'
/**
* 后台菜单/标签标题:优先按路由 name对应 admin_rule.name匹配 menu.names.*
*/
export function menuI18nKeyFromName(name: string | symbol | null | undefined): string {
if (name == null || name === '') {
return ''
}
const n = String(name).trim()
if (!n) {
return ''
}
return `menu.names.${n.replace(/\//g, '_')}`
}
export function menuTitleFromName(name: string | symbol | null | undefined, fallback?: string): string {
const key = menuI18nKeyFromName(name)
if (key && i18n.global.te(key)) {
return String(i18n.global.t(key))
}
if (fallback && i18n.global.te(fallback)) {
return String(i18n.global.t(fallback))
}
return fallback || ''
}
export function menuTitleFromRoute(route: RouteRecordRaw | RouteLocationNormalized): string {
const name = route.name
const metaTitle = route.meta && typeof route.meta.title === 'string' ? route.meta.title : ''
return menuTitleFromName(name, metaTitle) || metaTitle || String(i18n.global.t('noTitle'))
}

View File

@@ -61,13 +61,6 @@ const baTable = new baTableClass(
operator: 'RANGE',
width: 160,
},
{
label: t('auth.admin.channel_name'),
prop: 'channel.name',
align: 'center',
minWidth: 120,
operator: false,
},
{
label: t('auth.admin.agent_id'),
prop: 'agent_id',

View File

@@ -41,19 +41,6 @@
prop="nickname"
:placeholder="t('Please input field', { field: t('auth.admin.nickname') })"
/>
<FormItem
v-if="baTable.form.operate === 'Add' && adminInfo.super"
:label="t('auth.admin.channel_id')"
v-model="baTable.form.items!.channel_id"
prop="channel_id"
type="remoteSelect"
:input-attr="{
pk: 'id',
field: 'name',
remoteUrl: '/admin/channel.Manage/index',
placeholder: t('auth.admin.Please select channel'),
}"
/>
<FormItem
:label="t('auth.admin.group')"
v-model="baTable.form.items!.group_arr"
@@ -147,17 +134,6 @@ const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
username: [buildValidatorData({ name: 'required', title: t('auth.admin.username') }), buildValidatorData({ name: 'account' })],
nickname: [buildValidatorData({ name: 'required', title: t('auth.admin.nickname') })],
channel_id: [
{
validator: (_rule: any, val: any, callback: Function) => {
if (baTable.form.operate === 'Add' && adminInfo.super && !val) {
return callback(new Error(t('auth.admin.Please select channel')))
}
return callback()
},
trigger: 'change',
},
],
group_arr: [buildValidatorData({ name: 'required', message: t('Please select field', { field: t('auth.admin.group') }) })],
email: [buildValidatorData({ name: 'email', message: t('Please enter the correct field', { field: t('auth.admin.email') }) })],
mobile: [buildValidatorData({ name: 'mobile', message: t('Please enter the correct field', { field: t('auth.admin.mobile') }) })],

View File

@@ -1,182 +0,0 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<!-- 自定义按钮请使用插槽甚至公共搜索也可以使用具名插槽渲染参见文档 -->
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('channel.manage.quick Search Fields') })"
></TableHeader>
<!-- 表格 -->
<!-- 表格列有多种自定义渲染方式比如自定义组件具名插槽等参见文档 -->
<!-- 要使用 el-table 组件原有的属性直接加在 Table 标签上即可 -->
<Table ref="tableRef"></Table>
<!-- 表单 -->
<PopupForm />
<!-- 白名单弹窗 -->
<WhitelistPopup v-model:row="whitelistRow" @saved="baTable.getData()" />
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import WhitelistPopup from './whitelistPopup.vue'
import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
defineOptions({
name: 'channel/manage',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const whitelistRow = ref<TableRow | null>(null)
function openWhitelistDialog(row: TableRow) {
whitelistRow.value = row
}
const optButtons: OptButton[] = [
...defaultOptButtons(['edit', 'delete']),
{
render: 'tipButton',
name: 'whitelist',
title: 'channel.manage.whitelist',
text: '',
type: 'success',
icon: 'fa fa-list',
class: 'table-row-whitelist',
disabledTip: false,
display: (_row, _field) => baTable.auth('whitelist'),
click: (row: TableRow) => openWhitelistDialog(row),
},
]
/**
* baTable 内包含了表格的所有数据且数据具备响应性,然后通过 provide 注入给了后代组件
*/
const baTable = new baTableClass(
new baTableApi('/admin/channel.Manage/'),
{
pk: 'id',
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('channel.manage.id'), prop: 'id', align: 'center', width: 70, operator: 'RANGE', sortable: 'custom' },
{
label: t('channel.manage.name'),
prop: 'name',
align: 'center',
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{
label: t('channel.manage.title'),
prop: 'title',
align: 'center',
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{
label: t('channel.manage.admin__username'),
prop: 'admin.username',
align: 'center',
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
operator: 'LIKE',
comSearchRender: 'string',
},
{
label: t('channel.manage.secret'),
prop: 'secret',
align: 'center',
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{
label: t('channel.manage.remark'),
prop: 'remark',
align: 'center',
minWidth: '100',
showOverflowTooltip: true,
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{
label: t('channel.manage.ip_white'),
prop: 'ip_white',
align: 'center',
minWidth: '120',
render: 'tags',
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{
label: t('channel.manage.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('channel.manage.update_time'),
prop: 'update_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 160,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{ label: t('Operate'), align: 'center', width: 130, render: 'buttons', buttons: optButtons, operator: false },
],
dblClickNotEditColumn: [undefined],
},
{
defaultItems: { ip_white: [] },
before: {
onSubmit: () => {
const items = baTable.form.items
if (!items) return
// 从 popupForm 的 ipWhiteList 同步到 form.items避免 watch 循环)
const ipWhiteRef = baTable.form.extend?.ipWhiteListRef
if (ipWhiteRef?.value) {
items.ip_white = ipWhiteRef.value.filter((ip: string) => ip && String(ip).trim() !== '')
} else if (Array.isArray(items.ip_white)) {
items.ip_white = items.ip_white.filter((ip: string) => ip && String(ip).trim() !== '')
}
},
},
}
)
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
baTable.initSort()
baTable.dragSort()
})
})
</script>
<style scoped lang="scss"></style>

View File

@@ -1,154 +0,0 @@
<template>
<!-- 对话框表单 -->
<!-- 建议使用 Prettier 格式化代码 -->
<!-- el-form 内可以混用 el-form-itemFormItemba-input 等输入组件 -->
<el-dialog
class="ba-operate-dialog"
:close-on-click-modal="false"
:model-value="['Add', 'Edit'].includes(baTable.form.operate!)"
@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 v-loading="baTable.form.loading" 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"
@submit.prevent=""
@keyup.enter="baTable.onSubmit(formRef)"
:model="baTable.form.items"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="baTable.form.labelWidth + 'px'"
:rules="rules"
>
<FormItem
:label="t('channel.manage.name')"
type="string"
v-model="baTable.form.items!.name"
prop="name"
:placeholder="t('Please input field', { field: t('channel.manage.name') })"
/>
<el-form-item :label="t('channel.manage.ip_white')" prop="ip_white">
<div class="ba-ip-white-list">
<div class="ba-ip-white-item" v-for="(ip, idx) in ipWhiteList" :key="idx">
<el-input v-model="ipWhiteList[idx]" :placeholder="t('channel.manage.ip_placeholder')" clearable />
<el-button @click="onDelIpWhite(idx)" size="small" icon="el-icon-Delete" circle />
</div>
<el-button v-blur class="ba-add-ip-white" @click="onAddIpWhite" icon="el-icon-Plus">{{ t('Add') }}</el-button>
</div>
</el-form-item>
<FormItem
:label="t('channel.manage.title')"
type="string"
v-model="baTable.form.items!.title"
prop="title"
:placeholder="t('Please input field', { field: t('channel.manage.title') })"
/>
<FormItem
:label="t('channel.manage.remark')"
type="textarea"
v-model="baTable.form.items!.remark"
prop="remark"
:input-attr="{ rows: 3 }"
@keyup.enter.stop=""
@keyup.ctrl.enter="baTable.onSubmit(formRef)"
:placeholder="t('Please input field', { field: t('channel.manage.remark') })"
/>
<FormItem
:label="t('channel.manage.admin_id')"
type="remoteSelect"
v-model="baTable.form.items!.admin_id"
prop="admin_id"
:input-attr="{ pk: 'admin.id', field: 'username', remoteUrl: '/admin/auth.Admin/index' }"
:placeholder="t('Please select field', { field: t('channel.manage.admin_id') })"
/>
</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" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import type { FormItemRule } from 'element-plus'
import { inject, reactive, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import FormItem from '/@/components/formItem/index.vue'
import { useConfig } from '/@/stores/config'
import type baTableClass from '/@/utils/baTable'
import { buildValidatorData } from '/@/utils/validate'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
/** 将后端数据转为 IP 字符串数组(兼容旧格式 [{key,value}] 和新格式 [ip] */
function normalizeIpWhite(val: unknown): string[] {
if (!val || !Array.isArray(val)) return []
return val.map((item) => (typeof item === 'string' ? item : (item?.value ?? '')))
}
const ipWhiteList = ref<string[]>([])
// 仅当表单加载时从 form.items 同步到 ipWhiteList避免双向 watch 导致循环更新
watch(
() => baTable.form.items?.ip_white,
(val) => {
ipWhiteList.value = normalizeIpWhite(val)
},
{ immediate: true }
)
const onAddIpWhite = () => {
ipWhiteList.value.push('')
}
const onDelIpWhite = (idx: number) => {
ipWhiteList.value.splice(idx, 1)
}
// 将 ipWhiteList 暴露给 baTable供提交时同步
if (!baTable.form.extend) baTable.form.extend = {}
baTable.form.extend.ipWhiteListRef = ipWhiteList
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
name: [buildValidatorData({ name: 'required', title: t('channel.manage.name') })],
title: [buildValidatorData({ name: 'required', title: t('channel.manage.title') })],
admin_id: [buildValidatorData({ name: 'required', title: t('channel.manage.admin_id') })],
create_time: [buildValidatorData({ name: 'date', title: t('channel.manage.create_time') })],
update_time: [buildValidatorData({ name: 'date', title: t('channel.manage.update_time') })],
})
</script>
<style scoped lang="scss">
.ba-ip-white-list {
.ba-ip-white-item {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
.el-input {
flex: 1;
}
}
.ba-add-ip-white {
margin-top: 4px;
}
}
</style>

View File

@@ -1,101 +0,0 @@
<template>
<el-dialog class="ba-operate-dialog" :close-on-click-modal="false" :model-value="!!row" :title="t('channel.manage.whitelist')" @close="close">
<el-scrollbar v-loading="loading" class="ba-table-form-scrollbar">
<div class="ba-operate-form">
<div class="ba-ip-white-list">
<div class="ba-ip-white-item" v-for="(ip, idx) in ipWhiteList" :key="idx">
<el-input v-model="ipWhiteList[idx]" :placeholder="t('channel.manage.ip_placeholder')" clearable />
<el-button @click="onDelIpWhite(idx)" size="small" icon="el-icon-Delete" circle />
</div>
<el-button v-blur class="ba-add-ip-white" @click="onAddIpWhite" icon="el-icon-Plus">{{ t('Add') }}</el-button>
</div>
</div>
</el-scrollbar>
<template #footer>
<el-button @click="close">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="submitLoading" @click="onSave" type="primary">{{ t('Save') }}</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { baTableApi } from '/@/api/common'
const { t } = useI18n()
const props = defineProps<{
row: TableRow | null
}>()
const emit = defineEmits<{
(e: 'update:row', val: TableRow | null): void
(e: 'saved'): void
}>()
const api = new baTableApi('/admin/channel.Manage/')
const loading = ref(false)
const submitLoading = ref(false)
/** 将后端数据转为 IP 字符串数组(兼容旧格式 [{key,value}] 和新格式 [ip] */
function normalizeIpWhite(val: unknown): string[] {
if (!val || !Array.isArray(val)) return []
return val.map((item) => (typeof item === 'string' ? item : (item?.value ?? '')))
}
const ipWhiteList = ref<string[]>([])
watch(
() => props.row,
(val) => {
ipWhiteList.value = val ? normalizeIpWhite(val.ip_white) : []
},
{ immediate: true }
)
const onAddIpWhite = () => {
ipWhiteList.value.push('')
}
const onDelIpWhite = (idx: number) => {
ipWhiteList.value.splice(idx, 1)
}
const close = () => {
emit('update:row', null)
}
const onSave = async () => {
if (!props.row) return
const ips = ipWhiteList.value.filter((ip) => ip.trim() !== '')
submitLoading.value = true
try {
await api.postData('edit', {
id: props.row.id,
ip_white: ips,
})
emit('saved')
close()
} finally {
submitLoading.value = false
}
}
</script>
<style scoped lang="scss">
.ba-ip-white-list {
.ba-ip-white-item {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
.el-input {
flex: 1;
}
}
.ba-add-ip-white {
margin-top: 4px;
}
}
</style>

View File

@@ -28,119 +28,88 @@
<el-row :gutter="20">
<el-col :sm="12" :lg="6">
<div class="small-panel user-reg suspension">
<div class="small-panel-title">{{ t('dashboard.Member registration') }}</div>
<div class="small-panel-title">{{ t('dashboard.Daily new players') }}</div>
<div class="small-panel-content">
<div class="content-left">
<Icon color="#8595F4" size="20" name="fa fa-line-chart" />
<el-statistic :value="userRegNumberOutput" :value-style="statisticValueStyle" />
</div>
<div class="content-right">+14%</div>
<div class="content-right color-info">{{ t('dashboard.Today') }}</div>
</div>
</div>
</el-col>
<el-col :sm="12" :lg="6">
<div class="small-panel file suspension">
<div class="small-panel-title">{{ t('dashboard.Number of attachments Uploaded') }}</div>
<div class="small-panel-title">{{ t('dashboard.Yesterday points') }}</div>
<div class="small-panel-content">
<div class="content-left">
<Icon color="#AD85F4" size="20" name="fa fa-file-text" />
<el-statistic :value="fileNumberOutput" :value-style="statisticValueStyle" />
</div>
<div class="content-right">+50%</div>
<div class="content-right color-info">{{ t('dashboard.Yesterday') }}</div>
</div>
</div>
</el-col>
<el-col :sm="12" :lg="6">
<div class="small-panel users suspension">
<div class="small-panel-title">{{ t('dashboard.Total number of members') }}</div>
<div class="small-panel-title">{{ t('dashboard.Yesterday redeem') }}</div>
<div class="small-panel-content">
<div class="content-left">
<Icon color="#74A8B5" size="20" name="fa fa-users" />
<el-statistic :value="usersNumberOutput" :value-style="statisticValueStyle" />
</div>
<div class="content-right">+28%</div>
<div class="content-right color-info">{{ t('dashboard.Orders') }}</div>
</div>
</div>
</el-col>
<el-col :sm="12" :lg="6">
<div class="small-panel addons suspension">
<div class="small-panel-title">{{ t('dashboard.Number of installed plug-ins') }}</div>
<div class="small-panel-title">{{ t('dashboard.Pending physical to ship') }}</div>
<div class="small-panel-content">
<div class="content-left">
<Icon color="#F48595" size="20" name="fa fa-object-group" />
<el-statistic :value="addonsNumberOutput" :value-style="statisticValueStyle" />
</div>
<div class="content-right">+88%</div>
<div class="content-right color-info">{{ t('dashboard.Pending') }}</div>
</div>
</div>
</el-col>
</el-row>
</div>
<div class="growth-chart">
<el-row :gutter="20">
<el-col class="lg-mb-20" :xs="24" :sm="24" :md="12" :lg="9">
<el-card shadow="hover" :header="t('dashboard.Membership growth')">
<div class="user-growth-chart" :ref="chartRefs.set"></div>
</el-card>
</el-col>
<el-col class="lg-mb-20" :xs="24" :sm="24" :md="12" :lg="9">
<el-card shadow="hover" :header="t('dashboard.Annex growth')">
<div class="file-growth-chart" :ref="chartRefs.set"></div>
</el-card>
</el-col>
<el-col :xs="24" :sm="24" :md="24" :lg="6">
<el-card class="new-user-card" shadow="hover" :header="t('dashboard.New member')">
<div class="new-user-growth">
<el-scrollbar>
<div class="new-user-item">
<img class="new-user-avatar" src="~assets/login-header.png" alt="" />
<div class="new-user-base">
<div class="new-user-name">妙码生花</div>
<div class="new-user-time">12分钟前{{ t('dashboard.Joined us') }}</div>
</div>
<Icon class="new-user-arrow" color="#8595F4" name="fa fa-angle-right" />
</div>
<div class="new-user-item">
<img class="new-user-avatar" src="~assets/login-header.png" alt="" />
<div class="new-user-base">
<div class="new-user-name">码上生花</div>
<div class="new-user-time">12分钟前{{ t('dashboard.Joined us') }}</div>
</div>
<Icon class="new-user-arrow" color="#8595F4" name="fa fa-angle-right" />
</div>
<div class="new-user-item">
<img class="new-user-avatar" src="~assets/login-header.png" alt="" />
<div class="new-user-base">
<div class="new-user-name">Admin</div>
<div class="new-user-time">12分钟前{{ t('dashboard.Joined us') }}</div>
</div>
<Icon class="new-user-arrow" color="#8595F4" name="fa fa-angle-right" />
</div>
<div class="new-user-item">
<img class="new-user-avatar" :src="fullUrl('/static/images/avatar.png')" alt="" />
<div class="new-user-base">
<div class="new-user-name">纯属虚构</div>
<div class="new-user-time">12分钟前{{ t('dashboard.Joined us') }}</div>
</div>
<Icon class="new-user-arrow" color="#8595F4" name="fa fa-angle-right" />
</div>
</el-scrollbar>
</div>
</el-card>
</el-col>
</el-row>
</div>
<div class="growth-chart">
<el-row :gutter="20">
<el-col class="lg-mb-20" :xs="24" :sm="24" :md="24" :lg="12">
<el-card shadow="hover" :header="t('dashboard.Member source')">
<div class="user-source-chart" :ref="chartRefs.set"></div>
</el-card>
</el-col>
<el-col class="lg-mb-20" :xs="24" :sm="24" :md="24" :lg="12">
<el-card shadow="hover" :header="t('dashboard.Member last name')">
<div class="user-surname-chart" :ref="chartRefs.set"></div>
<el-col :xs="24" :sm="24" :md="24" :lg="24">
<el-card shadow="hover" :header="t('dashboard.Yesterday item redeem stat')">
<div class="playx-kpis">
<div class="playx-kpi">
<div class="playx-kpi-title">{{ t('dashboard.Yesterday redeem points sum') }}</div>
<div class="playx-kpi-value">{{ state.playx?.yesterday_redeem?.points_cost_sum ?? 0 }}</div>
</div>
<div class="playx-kpi">
<div class="playx-kpi-title">{{ t('dashboard.Yesterday redeem amount sum') }}</div>
<div class="playx-kpi-value">{{ state.playx?.yesterday_redeem?.amount_sum ?? 0 }}</div>
</div>
<div class="playx-kpi">
<div class="playx-kpi-title">{{ t('dashboard.Grant failed retryable') }}</div>
<div class="playx-kpi-value">{{ state.playx?.grant_failed_retryable ?? 0 }}</div>
</div>
</div>
<el-table
v-loading="state.playxLoading"
:data="state.playx?.yesterday_redeem?.by_item ?? []"
size="small"
style="width: 100%; margin-top: 12px"
>
<el-table-column prop="mall_item_id" :label="t('dashboard.Item ID')" width="100" />
<el-table-column prop="title" :label="t('dashboard.Item title')" min-width="220" />
<el-table-column prop="order_count" :label="t('dashboard.Order count')" width="120" />
<el-table-column prop="completed_count" :label="t('dashboard.Completed')" width="120" />
<el-table-column prop="rejected_count" :label="t('dashboard.Rejected')" width="120" />
<el-table-column prop="points_cost_sum" :label="t('dashboard.Points sum')" width="140" />
<el-table-column prop="amount_sum" :label="t('dashboard.Amount sum')" width="140" />
</el-table>
</el-card>
</el-col>
</el-row>
@@ -149,17 +118,15 @@
</template>
<script setup lang="ts">
import { useEventListener, useTemplateRefsList, useTransition } from '@vueuse/core'
import * as echarts from 'echarts'
import { CSSProperties, nextTick, onActivated, onBeforeMount, onMounted, onUnmounted, reactive, toRefs, watch } from 'vue'
import { useTransition } from '@vueuse/core'
import { CSSProperties, onMounted, onUnmounted, reactive, toRefs } from 'vue'
import { useI18n } from 'vue-i18n'
import { index } from '/@/api/backend/dashboard'
import coffeeSvg from '/@/assets/dashboard/coffee.svg'
import headerSvg from '/@/assets/dashboard/header-1.svg'
import { useAdminInfo } from '/@/stores/adminInfo'
import { WORKING_TIME } from '/@/stores/constant/cacheKey'
import { useNavTabs } from '/@/stores/navTabs'
import { fullUrl, getGreet } from '/@/utils/common'
import { getGreet } from '/@/utils/common'
import { Local } from '/@/utils/storage'
let workTimer: number
@@ -169,20 +136,20 @@ defineOptions({
const d = new Date()
const { t } = useI18n()
const navTabs = useNavTabs()
const adminInfo = useAdminInfo()
const chartRefs = useTemplateRefsList<HTMLDivElement>()
const state: {
charts: any[]
remark: string
workingTimeFormat: string
pauseWork: boolean
playx: any | null
playxLoading: boolean
} = reactive({
charts: [],
remark: 'dashboard.Loading',
workingTimeFormat: '',
pauseWork: false,
playx: null,
playxLoading: true,
})
/**
@@ -206,301 +173,20 @@ const statisticValueStyle: CSSProperties = {
index().then((res) => {
state.remark = res.data.remark
state.playx = res.data.playx ?? null
state.playxLoading = false
initCountUp()
}).catch(() => {
state.playxLoading = false
})
const initCountUp = () => {
// 虚拟数据
countUpRefs.userRegNumber.value = 5456
countUpRefs.fileNumber.value = 1234
countUpRefs.usersNumber.value = 9486
countUpRefs.addonsNumber.value = 875
}
const initUserGrowthChart = () => {
const userGrowthChart = echarts.init(chartRefs.value[0] as HTMLElement)
const option = {
grid: {
top: 40,
right: 0,
bottom: 20,
left: 40,
},
xAxis: {
data: [
t('dashboard.Monday'),
t('dashboard.Tuesday'),
t('dashboard.Wednesday'),
t('dashboard.Thursday'),
t('dashboard.Friday'),
t('dashboard.Saturday'),
t('dashboard.Sunday'),
],
},
yAxis: {},
legend: {
data: [t('dashboard.Visits'), t('dashboard.Registration volume')],
textStyle: {
color: '#73767a',
},
top: 0,
},
series: [
{
name: t('dashboard.Visits'),
data: [100, 160, 280, 230, 190, 200, 480],
type: 'line',
smooth: true,
areaStyle: {
color: '#8595F4',
},
},
{
name: t('dashboard.Registration volume'),
data: [45, 180, 146, 99, 210, 127, 288],
type: 'line',
smooth: true,
areaStyle: {
color: '#F48595',
opacity: 0.5,
},
},
],
}
userGrowthChart.setOption(option)
state.charts.push(userGrowthChart)
}
const initFileGrowthChart = () => {
const fileGrowthChart = echarts.init(chartRefs.value[1] as HTMLElement)
const option = {
grid: {
top: 30,
right: 0,
bottom: 20,
left: 0,
},
tooltip: {
trigger: 'item',
},
legend: {
type: 'scroll',
bottom: 0,
data: (function () {
var list = []
for (var i = 1; i <= 28; i++) {
list.push(i + 2000 + '')
}
return list
})(),
textStyle: {
color: '#73767a',
},
},
visualMap: {
top: 'middle',
right: 10,
color: ['red', 'yellow'],
calculable: true,
},
radar: {
indicator: [
{ name: t('dashboard.picture') },
{ name: t('dashboard.file') },
{ name: t('dashboard.table') },
{ name: t('dashboard.Compressed package') },
{ name: t('dashboard.other') },
],
},
series: (function () {
var series = []
for (var i = 1; i <= 28; i++) {
series.push({
type: 'radar',
symbol: 'none',
lineStyle: {
width: 1,
},
emphasis: {
areaStyle: {
color: 'rgba(0,250,0,0.3)',
},
},
data: [
{
value: [(40 - i) * 10, (38 - i) * 4 + 60, i * 5 + 10, i * 9, (i * i) / 2],
name: i + 2000 + '',
},
],
})
}
return series
})(),
}
fileGrowthChart.setOption(option)
state.charts.push(fileGrowthChart)
}
const initUserSourceChart = () => {
const UserSourceChart = echarts.init(chartRefs.value[2] as HTMLElement)
const pathSymbols = {
reindeer:
'path://M-22.788,24.521c2.08-0.986,3.611-3.905,4.984-5.892 c-2.686,2.782-5.047,5.884-9.102,7.312c-0.992,0.005-0.25-2.016,0.34-2.362l1.852-0.41c0.564-0.218,0.785-0.842,0.902-1.347 c2.133-0.727,4.91-4.129,6.031-6.194c1.748-0.7,4.443-0.679,5.734-2.293c1.176-1.468,0.393-3.992,1.215-6.557 c0.24-0.754,0.574-1.581,1.008-2.293c-0.611,0.011-1.348-0.061-1.959-0.608c-1.391-1.245-0.785-2.086-1.297-3.313 c1.684,0.744,2.5,2.584,4.426,2.586C-8.46,3.012-8.255,2.901-8.04,2.824c6.031-1.952,15.182-0.165,19.498-3.937 c1.15-3.933-1.24-9.846-1.229-9.938c0.008-0.062-1.314-0.004-1.803-0.258c-1.119-0.771-6.531-3.75-0.17-3.33 c0.314-0.045,0.943,0.259,1.439,0.435c-0.289-1.694-0.92-0.144-3.311-1.946c0,0-1.1-0.855-1.764-1.98 c-0.836-1.09-2.01-2.825-2.992-4.031c-1.523-2.476,1.367,0.709,1.816,1.108c1.768,1.704,1.844,3.281,3.232,3.983 c0.195,0.203,1.453,0.164,0.926-0.468c-0.525-0.632-1.367-1.278-1.775-2.341c-0.293-0.703-1.311-2.326-1.566-2.711 c-0.256-0.384-0.959-1.718-1.67-2.351c-1.047-1.187-0.268-0.902,0.521-0.07c0.789,0.834,1.537,1.821,1.672,2.023 c0.135,0.203,1.584,2.521,1.725,2.387c0.102-0.259-0.035-0.428-0.158-0.852c-0.125-0.423-0.912-2.032-0.961-2.083 c-0.357-0.852-0.566-1.908-0.598-3.333c0.4-2.375,0.648-2.486,0.549-0.705c0.014,1.143,0.031,2.215,0.602,3.247 c0.807,1.496,1.764,4.064,1.836,4.474c0.561,3.176,2.904,1.749,2.281-0.126c-0.068-0.446-0.109-2.014-0.287-2.862 c-0.18-0.849-0.219-1.688-0.113-3.056c0.066-1.389,0.232-2.055,0.277-2.299c0.285-1.023,0.4-1.088,0.408,0.135 c-0.059,0.399-0.131,1.687-0.125,2.655c0.064,0.642-0.043,1.768,0.172,2.486c0.654,1.928-0.027,3.496,1,3.514 c1.805-0.424,2.428-1.218,2.428-2.346c-0.086-0.704-0.121-0.843-0.031-1.193c0.221-0.568,0.359-0.67,0.312-0.076 c-0.055,0.287,0.031,0.533,0.082,0.794c0.264,1.197,0.912,0.114,1.283-0.782c0.15-0.238,0.539-2.154,0.545-2.522 c-0.023-0.617,0.285-0.645,0.309,0.01c0.064,0.422-0.248,2.646-0.205,2.334c-0.338,1.24-1.105,3.402-3.379,4.712 c-0.389,0.12-1.186,1.286-3.328,2.178c0,0,1.729,0.321,3.156,0.246c1.102-0.19,3.707-0.027,4.654,0.269 c1.752,0.494,1.531-0.053,4.084,0.164c2.26-0.4,2.154,2.391-1.496,3.68c-2.549,1.405-3.107,1.475-2.293,2.984 c3.484,7.906,2.865,13.183,2.193,16.466c2.41,0.271,5.732-0.62,7.301,0.725c0.506,0.333,0.648,1.866-0.457,2.86 c-4.105,2.745-9.283,7.022-13.904,7.662c-0.977-0.194,0.156-2.025,0.803-2.247l1.898-0.03c0.596-0.101,0.936-0.669,1.152-1.139 c3.16-0.404,5.045-3.775,8.246-4.818c-4.035-0.718-9.588,3.981-12.162,1.051c-5.043,1.423-11.449,1.84-15.895,1.111 c-3.105,2.687-7.934,4.021-12.115,5.866c-3.271,3.511-5.188,8.086-9.967,10.414c-0.986,0.119-0.48-1.974,0.066-2.385l1.795-0.618 C-22.995,25.682-22.849,25.035-22.788,24.521z',
plane: 'path://M1.112,32.559l2.998,1.205l-2.882,2.268l-2.215-0.012L1.112,32.559z M37.803,23.96 c0.158-0.838,0.5-1.509,0.961-1.904c-0.096-0.037-0.205-0.071-0.344-0.071c-0.777-0.005-2.068-0.009-3.047-0.009 c-0.633,0-1.217,0.066-1.754,0.18l2.199,1.804H37.803z M39.738,23.036c-0.111,0-0.377,0.325-0.537,0.924h1.076 C40.115,23.361,39.854,23.036,39.738,23.036z M39.934,39.867c-0.166,0-0.674,0.705-0.674,1.986s0.506,1.986,0.674,1.986 s0.672-0.705,0.672-1.986S40.102,39.867,39.934,39.867z M38.963,38.889c-0.098-0.038-0.209-0.07-0.348-0.073 c-0.082,0-0.174,0-0.268-0.001l-7.127,4.671c0.879,0.821,2.42,1.417,4.348,1.417c0.979,0,2.27-0.006,3.047-0.01 c0.139,0,0.25-0.034,0.348-0.072c-0.646-0.555-1.07-1.643-1.07-2.967C37.891,40.529,38.316,39.441,38.963,38.889z M32.713,23.96 l-12.37-10.116l-4.693-0.004c0,0,4,8.222,4.827,10.121H32.713z M59.311,32.374c-0.248,2.104-5.305,3.172-8.018,3.172H39.629 l-25.325,16.61L9.607,52.16c0,0,6.687-8.479,7.95-10.207c1.17-1.6,3.019-3.699,3.027-6.407h-2.138 c-5.839,0-13.816-3.789-18.472-5.583c-2.818-1.085-2.396-4.04-0.031-4.04h0.039l-3.299-11.371h3.617c0,0,4.352,5.696,5.846,7.5 c2,2.416,4.503,3.678,8.228,3.87h30.727c2.17,0,4.311,0.417,6.252,1.046c3.49,1.175,5.863,2.7,7.199,4.027 C59.145,31.584,59.352,32.025,59.311,32.374z M22.069,30.408c0-0.815-0.661-1.475-1.469-1.475c-0.812,0-1.471,0.66-1.471,1.475 s0.658,1.475,1.471,1.475C21.408,31.883,22.069,31.224,22.069,30.408z M27.06,30.408c0-0.815-0.656-1.478-1.466-1.478 c-0.812,0-1.471,0.662-1.471,1.478s0.658,1.477,1.471,1.477C26.404,31.885,27.06,31.224,27.06,30.408z M32.055,30.408 c0-0.815-0.66-1.475-1.469-1.475c-0.808,0-1.466,0.66-1.466,1.475s0.658,1.475,1.466,1.475 C31.398,31.883,32.055,31.224,32.055,30.408z M37.049,30.408c0-0.815-0.658-1.478-1.467-1.478c-0.812,0-1.469,0.662-1.469,1.478 s0.656,1.477,1.469,1.477C36.389,31.885,37.049,31.224,37.049,30.408z M42.039,30.408c0-0.815-0.656-1.478-1.465-1.478 c-0.811,0-1.469,0.662-1.469,1.478s0.658,1.477,1.469,1.477C41.383,31.885,42.039,31.224,42.039,30.408z M55.479,30.565 c-0.701-0.436-1.568-0.896-2.627-1.347c-0.613,0.289-1.551,0.476-2.73,0.476c-1.527,0-1.639,2.263,0.164,2.316 C52.389,32.074,54.627,31.373,55.479,30.565z',
rocket: 'path://M-244.396,44.399c0,0,0.47-2.931-2.427-6.512c2.819-8.221,3.21-15.709,3.21-15.709s5.795,1.383,5.795,7.325C-237.818,39.679-244.396,44.399-244.396,44.399z M-260.371,40.827c0,0-3.881-12.946-3.881-18.319c0-2.416,0.262-4.566,0.669-6.517h17.684c0.411,1.952,0.675,4.104,0.675,6.519c0,5.291-3.87,18.317-3.87,18.317H-260.371z M-254.745,18.951c-1.99,0-3.603,1.676-3.603,3.744c0,2.068,1.612,3.744,3.603,3.744c1.988,0,3.602-1.676,3.602-3.744S-252.757,18.951-254.745,18.951z M-255.521,2.228v-5.098h1.402v4.969c1.603,1.213,5.941,5.069,7.901,12.5h-17.05C-261.373,7.373-257.245,3.558-255.521,2.228zM-265.07,44.399c0,0-6.577-4.721-6.577-14.896c0-5.942,5.794-7.325,5.794-7.325s0.393,7.488,3.211,15.708C-265.539,41.469-265.07,44.399-265.07,44.399z M-252.36,45.15l-1.176-1.22L-254.789,48l-1.487-4.069l-1.019,2.116l-1.488-3.826h8.067L-252.36,45.15z',
train: 'path://M67.335,33.596L67.335,33.596c-0.002-1.39-1.153-3.183-3.328-4.218h-9.096v-2.07h5.371 c-4.939-2.07-11.199-4.141-14.89-4.141H19.72v12.421v5.176h38.373c4.033,0,8.457-1.035,9.142-5.176h-0.027 c0.076-0.367,0.129-0.751,0.129-1.165L67.335,33.596L67.335,33.596z M27.999,30.413h-3.105v-4.141h3.105V30.413z M35.245,30.413 h-3.104v-4.141h3.104V30.413z M42.491,30.413h-3.104v-4.141h3.104V30.413z M49.736,30.413h-3.104v-4.141h3.104V30.413z M14.544,40.764c1.143,0,2.07-0.927,2.07-2.07V35.59V25.237c0-1.145-0.928-2.07-2.07-2.07H-9.265c-1.143,0-2.068,0.926-2.068,2.07 v10.351v3.105c0,1.144,0.926,2.07,2.068,2.07H14.544L14.544,40.764z M8.333,26.272h3.105v4.141H8.333V26.272z M1.087,26.272h3.105 v4.141H1.087V26.272z M-6.159,26.272h3.105v4.141h-3.105V26.272z M-9.265,41.798h69.352v1.035H-9.265V41.798z',
}
const option = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'none',
},
formatter: function (params: any) {
return params[0].name + ': ' + params[0].value
},
},
xAxis: {
data: [t('dashboard.Baidu'), t('dashboard.Direct access'), t('dashboard.take a plane'), t('dashboard.Take the high-speed railway')],
axisTick: { show: false },
axisLine: { show: false },
axisLabel: {
color: '#e54035',
},
},
yAxis: {
splitLine: { show: false },
axisTick: { show: false },
axisLine: { show: false },
axisLabel: { show: false },
},
color: ['#e54035'],
series: [
{
name: 'hill',
type: 'pictorialBar',
barCategoryGap: '-130%',
symbol: 'path://M0,10 L10,10 C5.5,10 5.5,5 5,0 C4.5,5 4.5,10 0,10 z',
itemStyle: {
opacity: 0.5,
},
emphasis: {
itemStyle: {
opacity: 1,
},
},
data: [123, 60, 25, 80],
z: 10,
},
{
name: 'glyph',
type: 'pictorialBar',
barGap: '-100%',
symbolPosition: 'end',
symbolSize: 50,
symbolOffset: [0, '-120%'],
data: [
{
value: 123,
symbol: pathSymbols.reindeer,
symbolSize: [60, 60],
},
{
value: 60,
symbol: pathSymbols.rocket,
symbolSize: [50, 60],
},
{
value: 25,
symbol: pathSymbols.plane,
symbolSize: [65, 35],
},
{
value: 80,
symbol: pathSymbols.train,
symbolSize: [50, 30],
},
],
},
],
}
UserSourceChart.setOption(option)
state.charts.push(UserSourceChart)
}
const initUserSurnameChart = () => {
const userSurnameChart = echarts.init(chartRefs.value[3] as HTMLElement)
const data = genData(20)
const option = {
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b} : {c} ({d}%)',
},
legend: {
type: 'scroll',
orient: 'vertical',
right: 10,
top: 20,
bottom: 20,
data: data.legendData,
textStyle: {
color: '#73767a',
},
},
series: [
{
name: t('dashboard.full name'),
type: 'pie',
radius: '55%',
center: ['40%', '50%'],
data: data.seriesData,
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)',
},
},
},
],
}
function genData(count: any) {
// prettier-ignore
const nameList = [
'赵', '钱', '孙', '李', '周', '吴', '郑', '王', '冯', '陈', '褚', '卫', '蒋', '沈', '韩', '杨', '朱', '秦', '尤', '许', '何', '吕', '施', '张', '孔', '曹', '严', '华', '金', '魏', '陶', '姜', '戚', '谢', '邹', '喻', '柏', '水', '窦', '章', '云', '苏', '潘', '葛', '奚', '范', '彭', '郎', '鲁', '韦', '昌', '马', '苗', '凤', '花', '方', '俞', '任', '袁', '柳', '酆', '鲍', '史', '唐', '费', '廉', '岑', '薛', '雷', '贺', '倪', '汤', '滕', '殷', '罗', '毕', '郝', '邬', '安', '常', '乐', '于', '时', '傅', '皮', '卞', '齐', '康', '伍', '余', '元', '卜', '顾', '孟', '平', '黄', '和', '穆', '萧', '尹', '姚', '邵', '湛', '汪', '祁', '毛', '禹', '狄', '米', '贝', '明', '臧', '计', '伏', '成', '戴', '谈', '宋', '茅', '庞', '熊', '纪', '舒', '屈', '项', '祝', '董', '梁', '杜', '阮', '蓝', '闵', '席', '季', '麻', '强', '贾', '路', '娄', '危'
];
const legendData = []
const seriesData = []
for (var i = 0; i < count; i++) {
var name = Math.random() > 0.85 ? makeWord(2, 1) + '·' + makeWord(2, 0) : makeWord(2, 1)
legendData.push(name)
seriesData.push({
name: name,
value: Math.round(Math.random() * 100000),
})
}
return {
legendData: legendData,
seriesData: seriesData,
}
function makeWord(max: any, min: any) {
const nameLen = Math.ceil(Math.random() * max + min)
const name = []
for (var i = 0; i < nameLen; i++) {
name.push(nameList[Math.round(Math.random() * nameList.length - 1)])
}
return name.join('')
}
}
userSurnameChart.setOption(option)
state.charts.push(userSurnameChart)
}
const echartsResize = () => {
nextTick(() => {
for (const key in state.charts) {
state.charts[key].resize()
}
})
const playx = state.playx ?? {}
const yesterdayRedeem = playx.yesterday_redeem ?? {}
countUpRefs.userRegNumber.value = playx.new_players_today ?? 0
countUpRefs.fileNumber.value = playx.yesterday_points_claimed ?? 0
countUpRefs.usersNumber.value = yesterdayRedeem.order_count ?? 0
countUpRefs.addonsNumber.value = playx.pending_physical_to_ship ?? 0
}
const onChangeWorkState = () => {
@@ -594,36 +280,13 @@ const formatSeconds = (seconds: number) => {
return result
}
onActivated(() => {
echartsResize()
})
onMounted(() => {
startWork()
initCountUp()
initUserGrowthChart()
initFileGrowthChart()
initUserSourceChart()
initUserSurnameChart()
useEventListener(window, 'resize', echartsResize)
})
onBeforeMount(() => {
for (const key in state.charts) {
state.charts[key].dispose()
}
})
onUnmounted(() => {
clearInterval(workTimer)
})
watch(
() => navTabs.state.tabFullScreen,
() => {
echartsResize()
}
)
</script>
<style scoped lang="scss">
@@ -751,48 +414,27 @@ watch(
.growth-chart {
margin-bottom: 20px;
}
.user-growth-chart,
.file-growth-chart {
height: 260px;
.playx-kpis {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.new-user-growth {
height: 300px;
}
.user-source-chart,
.user-surname-chart {
height: 400px;
}
.new-user-item {
display: flex;
align-items: center;
padding: 20px;
margin: 10px 15px;
box-shadow: 0 0 30px 0 rgba(82, 63, 105, 0.05);
.playx-kpi {
background-color: var(--ba-bg-color-overlay);
.new-user-avatar {
height: 48px;
width: 48px;
border-radius: 50%;
}
.new-user-base {
margin-left: 10px;
color: #2c3f5d;
.new-user-name {
font-size: 15px;
}
.new-user-time {
font-size: 13px;
}
}
.new-user-arrow {
margin-left: auto;
}
border: 1px solid var(--ba-border-color);
border-radius: var(--el-border-radius-base);
padding: 12px;
}
.new-user-card :deep(.el-card__body) {
padding: 0;
.playx-kpi-title {
font-size: 13px;
color: var(--el-text-color-secondary);
}
.playx-kpi-value {
margin-top: 6px;
font-size: 20px;
font-weight: 600;
color: var(--el-text-color-primary);
}
@media screen and (max-width: 425px) {
.welcome-img {
display: none;

View File

@@ -37,6 +37,17 @@ 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 注入给了后代组件
*/
@@ -48,15 +59,24 @@ const baTable = new baTableClass(
{ type: 'selection', align: 'center', operator: false },
{ label: t('mall.address.id'), prop: 'id', align: 'center', width: 70, operator: 'RANGE', sortable: 'custom' },
{
label: t('mall.address.malluser__username'),
prop: 'mallUser.username',
label: t('mall.address.playxuserasset__username'),
prop: 'playxUserAsset.username',
align: 'center',
minWidth: 120,
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
showOverflowTooltip: true,
operator: 'LIKE',
comSearchRender: 'string',
},
{
label: t('mall.address.receiver_name'),
prop: 'receiver_name',
align: 'center',
minWidth: 100,
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{
label: t('mall.address.phone'),
prop: 'phone',
@@ -65,7 +85,17 @@ const baTable = new baTableClass(
sortable: false,
operator: 'LIKE',
},
{ label: t('mall.address.region'), prop: 'region_text', align: 'center', operator: false },
{
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',
@@ -78,9 +108,11 @@ const baTable = new baTableClass(
label: t('mall.address.default_setting'),
prop: 'default_setting',
align: 'center',
effect: 'dark',
custom: { 0: 'error', 1: 'primary' },
operator: 'eq',
sortable: false,
render: 'switch',
render: 'tag',
replaceValue: { '0': t('mall.address.default_setting 0'), '1': t('mall.address.default_setting 1') },
},
{
@@ -105,7 +137,7 @@ const baTable = new baTableClass(
width: 160,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{ label: t('Operate'), align: 'center', width: 100, render: 'buttons', buttons: optButtons, operator: false },
{ label: t('Operate'), align: 'center', width: 80, render: 'buttons', buttons: optButtons, operator: false, fixed: 'right' },
],
dblClickNotEditColumn: [undefined, 'default_setting'],
},

View File

@@ -30,12 +30,19 @@
:rules="rules"
>
<FormItem
:label="t('mall.address.mall_user_id')"
:label="t('mall.address.playx_user_asset_id')"
type="remoteSelect"
v-model="baTable.form.items!.mall_user_id"
prop="mall_user_id"
:input-attr="{ pk: 'mall_user.id', field: 'username', remoteUrl: '/admin/mall.User/index' }"
:placeholder="t('Please select field', { field: t('mall.address.mall_user_id') })"
v-model="baTable.form.items!.playx_user_asset_id"
prop="playx_user_asset_id"
:input-attr="{ pk: 'mall_user_asset.id', field: 'username', remoteUrl: '/admin/mall.UserAsset/select' }"
:placeholder="t('Please select field', { field: t('mall.address.playx_user_asset_id') })"
/>
<FormItem
:label="t('mall.address.receiver_name')"
type="string"
v-model="baTable.form.items!.receiver_name"
prop="receiver_name"
:placeholder="t('Please input field', { field: t('mall.address.receiver_name') })"
/>
<FormItem
:label="t('mall.address.phone')"
@@ -58,16 +65,6 @@
prop="detail_address"
:placeholder="t('Please input field', { field: t('mall.address.detail_address') })"
/>
<FormItem
:label="t('mall.address.address')"
type="textarea"
v-model="baTable.form.items!.address"
prop="address"
:input-attr="{ rows: 3 }"
@keyup.enter.stop=""
@keyup.ctrl.enter="baTable.onSubmit(formRef)"
:placeholder="t('Please input field', { field: t('mall.address.address') })"
/>
<FormItem
:label="t('mall.address.default_setting')"
type="switch"

View File

@@ -0,0 +1,61 @@
<template>
<div class="default-main ba-table-box">
<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.claimLog.quick Search Fields') })"
></TableHeader>
<Table ref="tableRef"></Table>
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { baTableApi } from '/@/api/common'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
defineOptions({
name: 'mall/claimLog',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const baTable = new baTableClass(
new baTableApi('/admin/mall.ClaimLog/'),
{
pk: 'id',
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('mall.claimLog.id'), prop: 'id', align: 'center', width: 70, operator: 'RANGE', sortable: 'custom' },
{ label: t('mall.claimLog.claim_request_id'), prop: 'claim_request_id', align: 'center', operatorPlaceholder: t('Fuzzy query'), sortable: false, operator: 'LIKE' },
{ label: t('mall.claimLog.user_id'), prop: 'user_id', align: 'center', operatorPlaceholder: t('Fuzzy query'), sortable: false, operator: 'LIKE' },
{ label: t('mall.claimLog.claimed_amount'), prop: 'claimed_amount', align: 'center', operator: 'RANGE', sortable: false },
{ label: t('mall.claimLog.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)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
baTable.initSort()
baTable.dragSort()
})
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,123 @@
<template>
<div class="default-main ba-table-box">
<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>
<Table ref="tableRef"></Table>
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { baTableApi } from '/@/api/common'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
defineOptions({
name: 'mall/dailyPush',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const baTable = new baTableClass(
new baTableApi('/admin/mall.DailyPush/'),
{
pk: 'id',
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('mall.dailyPush.id'), prop: 'id', align: 'center', width: 70, operator: 'RANGE', sortable: 'custom' },
{
label: t('mall.dailyPush.user_id'),
prop: 'user_id',
align: 'center',
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{
label: t('mall.dailyPush.date'),
prop: 'date',
align: 'center',
render: 'date',
operator: 'RANGE',
comSearchRender: 'date',
sortable: 'custom',
width: 120,
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('mall.dailyPush.username'),
prop: 'username',
align: 'center',
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{
label: t('mall.dailyPush.yesterday_win_loss_net'),
prop: 'yesterday_win_loss_net',
align: 'center',
operator: 'RANGE',
sortable: false,
},
{
label: t('mall.dailyPush.yesterday_total_deposit'),
prop: 'yesterday_total_deposit',
align: 'center',
operator: 'RANGE',
sortable: false,
},
{
label: t('mall.dailyPush.lifetime_total_deposit'),
prop: 'lifetime_total_deposit',
align: 'center',
operator: 'RANGE',
sortable: false,
},
{
label: t('mall.dailyPush.lifetime_total_withdraw'),
prop: 'lifetime_total_withdraw',
align: 'center',
minWidth: 95,
operator: 'RANGE',
sortable: false,
},
{
label: t('mall.dailyPush.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)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
baTable.initSort()
baTable.dragSort()
})
})
</script>
<style scoped lang="scss"></style>

View File

@@ -52,6 +52,8 @@ const baTable = new baTableClass(
label: t('mall.item.description'),
prop: 'description',
align: 'center',
minWidth: 80,
// showOverflowTooltip: true,
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
@@ -60,6 +62,7 @@ const baTable = new baTableClass(
label: t('mall.item.score'),
prop: 'score',
align: 'center',
minWidth: 90,
sortable: false,
operator: 'RANGE',
},
@@ -173,7 +176,7 @@ const baTable = new baTableClass(
{
label: t('Operate'),
align: 'center',
width: 100,
width: 80,
render: 'buttons',
buttons: optButtons,
operator: false,

View File

@@ -29,6 +29,7 @@
:label-width="baTable.form.labelWidth + 'px'"
:rules="rules"
>
<!-- 先选类型未选类型时只显示标题与类型避免看到无关字段 -->
<FormItem
:label="t('mall.item.title')"
type="string"
@@ -36,34 +37,6 @@
prop="title"
:placeholder="t('Please input field', { field: t('mall.item.title') })"
/>
<FormItem
:label="t('mall.item.description')"
type="textarea"
v-model="baTable.form.items!.description"
prop="description"
:input-attr="{ rows: 3 }"
@keyup.enter.stop=""
@keyup.ctrl.enter="baTable.onSubmit(formRef)"
:placeholder="t('Please input field', { field: t('mall.item.description') })"
/>
<FormItem
:label="t('mall.item.remark')"
type="textarea"
v-model="baTable.form.items!.remark"
prop="remark"
:input-attr="{ rows: 3 }"
@keyup.enter.stop=""
@keyup.ctrl.enter="baTable.onSubmit(formRef)"
:placeholder="t('Please input field', { field: t('mall.item.remark') })"
/>
<FormItem
:label="t('mall.item.score')"
type="number"
v-model="baTable.form.items!.score"
prop="score"
:input-attr="{ step: 1 }"
:placeholder="t('Please input field', { field: t('mall.item.score') })"
/>
<FormItem
:label="t('mall.item.type')"
type="select"
@@ -72,65 +45,101 @@
:input-attr="{ content: { '1': t('mall.item.type 1'), '2': t('mall.item.type 2'), '3': t('mall.item.type 3') } }"
:placeholder="t('Please select field', { field: t('mall.item.type') })"
/>
<FormItem
:label="t('mall.item.amount')"
type="number"
v-model="baTable.form.items!.amount"
prop="amount"
:input-attr="{ step: 0.01 }"
:placeholder="t('Please input field', { field: t('mall.item.amount') })"
v-if="isBonusOrWithdraw"
/>
<FormItem
: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') })"
v-if="isBonusOrWithdraw"
/>
<FormItem
:label="t('mall.item.category')"
type="string"
v-model="baTable.form.items!.category"
prop="category"
:placeholder="t('Please input field', { field: t('mall.item.category') })"
v-if="isBonusOrWithdraw"
/>
<FormItem
:label="t('mall.item.category_title')"
type="string"
v-model="baTable.form.items!.category_title"
prop="category_title"
:placeholder="t('Please input field', { field: t('mall.item.category_title') })"
v-if="isBonusOrWithdraw"
/>
<FormItem :label="t('mall.item.image')" type="image" v-model="baTable.form.items!.image" />
<FormItem
:label="t('mall.item.stock')"
type="number"
v-model="baTable.form.items!.stock"
prop="stock"
:input-attr="{ step: 1 }"
:placeholder="t('Please input field', { field: t('mall.item.stock') })"
v-if="isPhysical"
/>
<FormItem
:label="t('mall.item.sort')"
type="number"
v-model="baTable.form.items!.sort"
prop="sort"
:input-attr="{ step: 1 }"
:placeholder="t('Please input field', { field: t('mall.item.sort') })"
/>
<FormItem
:label="t('mall.item.status')"
type="switch"
v-model="baTable.form.items!.status"
prop="status"
:input-attr="{ content: { '0': t('mall.item.status 0'), '1': t('mall.item.status 1') } }"
/>
<template v-if="hasItemType">
<FormItem
:label="t('mall.item.description')"
type="textarea"
v-model="baTable.form.items!.description"
prop="description"
:input-attr="{ rows: 3 }"
@keyup.enter.stop=""
@keyup.ctrl.enter="baTable.onSubmit(formRef)"
:placeholder="t('Please input field', { field: t('mall.item.description') })"
/>
<FormItem
:label="t('mall.item.score')"
type="number"
v-model="baTable.form.items!.score"
prop="score"
: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"
v-model="baTable.form.items!.amount"
prop="amount"
:input-attr="{ step: 0.01 }"
: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') })"
/>
<!-- BONUSBonus Grant 需要 -->
<FormItem
v-if="isBonus"
:label="t('mall.item.category')"
type="string"
v-model="baTable.form.items!.category"
prop="category"
:placeholder="t('Please input field', { field: t('mall.item.category') })"
/>
<FormItem
v-if="isBonus"
:label="t('mall.item.category_title')"
type="string"
v-model="baTable.form.items!.category_title"
prop="category_title"
:placeholder="t('Please input field', { field: t('mall.item.category_title') })"
/>
<!-- PHYSICAL库存 -->
<FormItem
v-if="isPhysical"
:label="t('mall.item.stock')"
type="number"
v-model="baTable.form.items!.stock"
prop="stock"
:input-attr="{ step: 1 }"
:placeholder="t('Please input field', { field: t('mall.item.stock') })"
/>
<FormItem
:label="t('mall.item.remark')"
type="textarea"
v-model="baTable.form.items!.remark"
prop="remark"
:input-attr="{ rows: 3 }"
@keyup.enter.stop=""
@keyup.ctrl.enter="baTable.onSubmit(formRef)"
:placeholder="t('Please input field', { field: t('mall.item.remark') })"
/>
<FormItem :label="t('mall.item.image')" type="image" v-model="baTable.form.items!.image" />
<FormItem
:label="t('mall.item.sort')"
type="number"
v-model="baTable.form.items!.sort"
prop="sort"
:input-attr="{ step: 1 }"
:placeholder="t('Please input field', { field: t('mall.item.sort') })"
/>
<FormItem
:label="t('mall.item.status')"
type="switch"
v-model="baTable.form.items!.status"
prop="status"
:input-attr="{ content: { '0': t('mall.item.status 0'), '1': t('mall.item.status 1') } }"
/>
</template>
</el-form>
</div>
</el-scrollbar>
@@ -166,7 +175,10 @@ const isPhysical = computed(() => itemType.value === 2 || itemType.value === '2'
const isWithdraw = computed(() => itemType.value === 3 || itemType.value === '3')
const isBonusOrWithdraw = computed(() => isBonus.value || isWithdraw.value)
// 切换类型后,清理不适用的字段,避免“隐藏字段仍保留上一次的值”导致提交脏数据
/** 已选择商品类型1/2/3才展示其余表单项 */
const hasItemType = computed(() => isBonus.value || isPhysical.value || isWithdraw.value)
// 切换类型后清理不适用的字段严格WITHDRAW 不保留红利类别字段)
watch(
itemType,
(n, o) => {
@@ -176,14 +188,20 @@ watch(
const typeNum = Number(n)
if (!Number.isFinite(typeNum)) return
if (typeNum === 2) {
if (typeNum === 1) {
// BONUS实物库存不适用
baTable.form.items.stock = 0
} else if (typeNum === 2) {
// PHYSICAL现金/倍数/类别不适用
baTable.form.items.amount = 0
baTable.form.items.multiplier = 0
baTable.form.items.category = ''
baTable.form.items.category_title = ''
} else {
// BONUS / WITHDRAW
} else if (typeNum === 3) {
// WITHDRAW:提现接口不使用 category清空避免误存
baTable.form.items.stock = 0
baTable.form.items.category = ''
baTable.form.items.category_title = ''
}
},
{ flush: 'post' },
@@ -191,10 +209,33 @@ watch(
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
title: [buildValidatorData({ name: 'required', title: t('mall.item.title') })],
description: [buildValidatorData({ name: 'required', title: t('mall.item.description') })],
description: [
{
validator: (rule: any, val: any, callback: Function) => {
if (!hasItemType.value) return callback()
if (val === '' || val === null || val === undefined) {
return callback(new Error(t('Please input field', { field: t('mall.item.description') })))
}
return callback()
},
trigger: 'blur',
},
],
score: [
buildValidatorData({ name: 'number', title: t('mall.item.score') }),
buildValidatorData({ name: 'required', title: t('mall.item.score') }),
{
validator: (rule: any, val: any, callback: Function) => {
if (!hasItemType.value) return callback()
if (val === '' || val === null || val === undefined) {
return callback(new Error(t('Please input field', { field: t('mall.item.score') })))
}
const num = Number(val)
if (!Number.isFinite(num)) {
return callback(new Error(t('Please enter the correct field', { field: t('mall.item.score') })))
}
return callback()
},
trigger: 'blur',
},
],
type: [buildValidatorData({ name: 'required', title: t('mall.item.type') })],
amount: [
@@ -238,7 +279,7 @@ const rules: Partial<Record<string, FormItemRule[]>> = reactive({
category: [
{
validator: (rule: any, val: any, callback: Function) => {
if (!isBonusOrWithdraw.value) return callback()
if (!isBonus.value) return callback()
if (!val) {
return callback(new Error(t('Please input field', { field: t('mall.item.category') })))
}
@@ -250,7 +291,7 @@ const rules: Partial<Record<string, FormItemRule[]>> = reactive({
category_title: [
{
validator: (rule: any, val: any, callback: Function) => {
if (!isBonusOrWithdraw.value) return callback()
if (!isBonus.value) return callback()
if (!val) {
return callback(new Error(t('Please input field', { field: t('mall.item.category_title') })))
}

View File

@@ -0,0 +1,318 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('mall.order.quick Search Fields') })"
></TableHeader>
<Table ref="tableRef"></Table>
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { baTableApi } from '/@/api/common'
import createAxios from '/@/utils/axios'
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'
import { defaultOptButtons } from '/@/components/table'
defineOptions({
name: 'mall/order',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete']).map((btn) =>
btn.name === 'edit'
? {
...btn,
title: t('mall.order.approve'),
type: 'primary',
class: 'table-row-edit',
icon: 'fa fa-check',
}
: btn
)
const baTable = new baTableClass(
new baTableApi('/admin/mall.Order/'),
{
pk: 'id',
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('mall.order.id'), prop: 'id', align: 'center', width: 70, operator: 'RANGE', sortable: 'custom' },
{
label: t('mall.order.user_id'),
prop: 'user_id',
align: 'center',
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{
label: t('mall.order.type'),
prop: 'type',
align: 'center',
effect: 'light',
custom: { BONUS: 'success', PHYSICAL: 'primary', WITHDRAW: 'info' },
minWidth: 140,
operator: 'eq',
sortable: false,
render: 'tag',
replaceValue: {
BONUS: t('mall.order.type BONUS'),
PHYSICAL: t('mall.order.type PHYSICAL'),
WITHDRAW: t('mall.order.type WITHDRAW'),
},
},
{
label: t('mall.order.status'),
prop: 'status',
align: 'center',
effect: 'dark',
custom: { PENDING: 'success', COMPLETED: 'primary', SHIPPED: 'info', REJECTED: 'loading' },
minWidth: 160,
operator: 'eq',
sortable: false,
render: 'tag',
replaceValue: {
PENDING: t('mall.order.status PENDING'),
COMPLETED: t('mall.order.status COMPLETED'),
SHIPPED: t('mall.order.status SHIPPED'),
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.mallitem__title'),
prop: 'mallItem.title',
align: 'center',
minWidth: 90,
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{ 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',
align: 'center',
minWidth: 80,
showOverflowTooltip: true,
operatorPlaceholder: t('Fuzzy query'),
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',
align: 'center',
minWidth: 100,
custom: {
NOT_SENT: 'info',
SENT_PENDING: 'primary',
ACCEPTED: 'primary',
FAILED_RETRYABLE: 'error',
FAILED_FINAL: 'error',
'---': 'info',
},
operator: 'eq',
sortable: false,
render: 'tag',
replaceValue: {
NOT_SENT: t('mall.order.grant_status NOT_SENT'),
SENT_PENDING: t('mall.order.grant_status SENT_PENDING'),
ACCEPTED: t('mall.order.grant_status ACCEPTED'),
FAILED_RETRYABLE: t('mall.order.grant_status FAILED_RETRYABLE'),
FAILED_FINAL: t('mall.order.grant_status FAILED_FINAL'),
'---': t('mall.order.grant_status ---'),
},
},
{
label: t('mall.order.fail_reason'),
prop: 'fail_reason',
align: 'center',
showOverflowTooltip: true,
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{
label: t('mall.order.reject_reason'),
prop: 'reject_reason',
align: 'center',
showOverflowTooltip: true,
sortable: false,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('mall.order.shipping_company'),
prop: 'shipping_company',
align: 'center',
showOverflowTooltip: true,
sortable: false,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('mall.order.shipping_no'),
prop: 'shipping_no',
align: 'center',
showOverflowTooltip: true,
sortable: false,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('mall.order.receiver_name'),
prop: 'receiver_name',
align: 'center',
showOverflowTooltip: true,
sortable: false,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('mall.order.receiver_phone'),
prop: 'receiver_phone',
align: 'center',
showOverflowTooltip: true,
sortable: false,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('mall.order.receiver_address'),
prop: 'receiver_address',
align: 'center',
showOverflowTooltip: true,
sortable: false,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{ label: t('mall.order.mall_address_id'), prop: 'mall_address_id', align: 'center', width: 100, operator: 'eq', sortable: false },
{
label: t('mall.order.start_time'),
prop: 'start_time',
align: 'center',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 160,
},
{
label: t('mall.order.end_time'),
prop: 'end_time',
align: 'center',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 160,
},
{
label: t('mall.order.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.order.update_time'),
prop: 'update_time',
align: 'center',
show: false,
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 160,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
label: t('Operate'),
align: 'center',
width: 120,
fixed: 'right',
render: 'buttons',
buttons: [
...optButtons,
{
render: 'confirmButton',
name: 'retry',
title: t('mall.order.manual_retry'),
text: '',
type: 'primary',
class: 'table-row-edit',
icon: 'fa fa-refresh',
display: (row: TableRow) =>
row.type === 'BONUS' &&
row.status === 'PENDING' &&
['NOT_SENT', 'SENT_PENDING', 'FAILED_RETRYABLE', 'FAILED_FINAL'].includes(String(row.grant_status)),
popconfirm: {
title: t('mall.order.retry_confirm'),
confirmButtonText: t('Confirm'),
cancelButtonText: t('Cancel'),
confirmButtonType: 'primary',
},
click: async (row: TableRow) => {
await createAxios(
{
url: '/admin/mall.Order/retry',
method: 'post',
data: {
id: row.id,
},
},
{
showSuccessMessage: true,
}
)
await baTable.getData()
},
},
],
},
],
dblClickNotEditColumn: [undefined],
},
{
defaultItems: {},
}
)
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
baTable.initSort()
baTable.dragSort()
})
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,304 @@
<template>
<el-dialog
class="ba-operate-dialog"
:close-on-click-modal="false"
:model-value="['Add', 'Edit'].includes(baTable.form.operate!)"
@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 v-loading="baTable.form.loading" 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"
@submit.prevent=""
@keyup.enter="baTable.onSubmit(formRef)"
:model="baTable.form.items"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="baTable.form.labelWidth + 'px'"
:rules="rules"
>
<!-- PENDING两页审核流程PHYSICAL 显示收货信息其它类型只显示基本信息 -->
<template v-if="usePagedActions && page === 1">
<FormItem :label="t('mall.order.type')" type="string" v-model="baTable.form.items!.type" prop="type" :input-attr="{ disabled: true }" />
<FormItem :label="t('mall.order.status')" type="string" v-model="baTable.form.items!.status" prop="status" :input-attr="{ disabled: true }" />
<template v-if="isPhysical">
<FormItem
:label="t('mall.order.receiver_name')"
type="string"
v-model="baTable.form.items!.receiver_name"
prop="receiver_name"
:input-attr="{ disabled: true }"
/>
<FormItem
:label="t('mall.order.receiver_phone')"
type="string"
v-model="baTable.form.items!.receiver_phone"
prop="receiver_phone"
:input-attr="{ disabled: true }"
/>
<FormItem
:label="t('mall.order.receiver_address')"
type="string"
v-model="baTable.form.items!.receiver_address"
prop="receiver_address"
:input-attr="{ disabled: true }"
/>
</template>
</template>
<template v-else-if="usePagedActions">
<template v-if="action === 'approveShip' && isPhysical">
<FormItem
:label="t('mall.order.shipping_company')"
type="string"
v-model="baTable.form.items!.shipping_company"
prop="shipping_company"
:placeholder="t('Please input field', { field: t('mall.order.shipping_company') })"
/>
<FormItem
:label="t('mall.order.shipping_no')"
type="string"
v-model="baTable.form.items!.shipping_no"
prop="shipping_no"
:placeholder="t('Please input field', { field: t('mall.order.shipping_no') })"
/>
</template>
<template v-else-if="action === 'reject'">
<FormItem
v-if="isPhysical"
:label="t('mall.order.reject_reason')"
type="textarea"
v-model="baTable.form.items!.reject_reason"
prop="reject_reason"
:input-attr="{ rows: 3 }"
@keyup.enter.stop=""
: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>
<!-- 其它订单保留常规可编辑表单 + 保存 -->
<template v-else>
<FormItem :label="t('mall.order.type')" type="string" v-model="baTable.form.items!.type" prop="type" :input-attr="{ disabled: true }" />
<FormItem
:label="t('mall.order.status')"
type="select"
v-model="baTable.form.items!.status"
prop="status"
:input-attr="{ content: { PENDING: t('mall.order.status PENDING'), COMPLETED: t('mall.order.status COMPLETED'), SHIPPED: t('mall.order.status SHIPPED'), REJECTED: t('mall.order.status REJECTED') } }"
:placeholder="t('Please select field', { field: t('mall.order.status') })"
/>
<template v-if="isPhysical">
<FormItem
:label="t('mall.order.shipping_company')"
type="string"
v-model="baTable.form.items!.shipping_company"
prop="shipping_company"
:placeholder="t('Please input field', { field: t('mall.order.shipping_company') })"
/>
<FormItem
:label="t('mall.order.shipping_no')"
type="string"
v-model="baTable.form.items!.shipping_no"
prop="shipping_no"
:placeholder="t('Please input field', { field: t('mall.order.shipping_no') })"
/>
<FormItem
:label="t('mall.order.reject_reason')"
type="textarea"
v-model="baTable.form.items!.reject_reason"
prop="reject_reason"
:input-attr="{ rows: 3 }"
@keyup.enter.stop=""
:placeholder="t('Please input field', { field: t('mall.order.reject_reason') })"
/>
</template>
</template>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="onCancel">{{ t('Cancel') }}</el-button>
<template v-if="usePagedActions && page === 1">
<el-button v-if="canApprove" @click="onApprove" type="success">审核通过</el-button>
<el-button @click="goReject" type="danger">驳回</el-button>
</template>
<template v-else-if="usePagedActions">
<el-button @click="backToFirst">返回</el-button>
<el-button
v-if="action === 'approveShip'"
v-blur
:loading="submitting"
@click="submitShip"
type="success"
>
提交发货
</el-button>
<el-button v-if="action === 'reject'" v-blur :loading="submitting" @click="submitReject" type="danger">提交驳回</el-button>
</template>
<template v-else>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</template>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, inject, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useConfig } from '/@/stores/config'
import baTableClass from '/@/utils/baTable'
import FormItem from '/@/components/formItem/index.vue'
import type { FormItemRule } from 'element-plus'
import { ElMessage } from 'element-plus'
import { buildValidatorData } from '/@/utils/validate'
import createAxios from '/@/utils/axios'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const isEdit = computed(() => baTable.form.operate === 'Edit')
const isPending = computed(() => isEdit.value && baTable.form.items?.status === 'PENDING')
const isPhysical = computed(() => baTable.form.items?.type === 'PHYSICAL')
const canApprove = computed(() => isPending.value)
const usePagedActions = computed(() => isPending.value)
const page = ref<1 | 2>(1)
const action = ref<'approveShip' | 'reject' | null>(null)
const submitting = ref(false)
const resetPager = () => {
page.value = 1
action.value = null
submitting.value = false
}
watch(
() => baTable.form.operate,
() => {
resetPager()
}
)
const onCancel = () => {
resetPager()
baTable.toggleForm()
}
const backToFirst = () => {
page.value = 1
action.value = null
}
const onApprove = async () => {
if (!isPending.value) {
return
}
if (isPhysical.value) {
page.value = 2
action.value = 'approveShip'
return
}
const id = baTable.form.items?.id
if (!id) {
return
}
submitting.value = true
try {
await createAxios({ url: '/admin/mall.Order/approve', method: 'post', data: { id } }, { showSuccessMessage: true })
resetPager()
baTable.toggleForm()
await baTable.getData()
} finally {
submitting.value = false
}
}
const goReject = () => {
page.value = 2
action.value = 'reject'
}
const submitShip = async () => {
const id = baTable.form.items?.id
const shippingCompany = (baTable.form.items?.shipping_company || '').toString().trim()
const shippingNo = (baTable.form.items?.shipping_no || '').toString().trim()
if (!id || shippingCompany === '' || shippingNo === '') {
ElMessage.error('请填写物流公司与物流单号')
return
}
submitting.value = true
try {
await createAxios(
{ url: '/admin/mall.Order/ship', method: 'post', data: { id, shipping_company: shippingCompany, shipping_no: shippingNo } },
{ showSuccessMessage: true }
)
resetPager()
baTable.toggleForm()
await baTable.getData()
} finally {
submitting.value = false
}
}
const submitReject = async () => {
const id = baTable.form.items?.id
const rejectReason = (baTable.form.items?.reject_reason || '').toString().trim()
if (!id) {
return
}
if (isPhysical.value && rejectReason === '') {
ElMessage.error('请填写驳回原因')
return
}
submitting.value = true
try {
await createAxios(
{ url: '/admin/mall.Order/reject', method: 'post', data: { id, reject_reason: rejectReason } },
{ showSuccessMessage: true }
)
resetPager()
baTable.toggleForm()
await baTable.getData()
} finally {
submitting.value = false
}
}
const rules = computed<Partial<Record<string, FormItemRule[]>>>(() => {
if (!isPhysical.value) {
return {}
}
return {
shipping_company: [buildValidatorData({ name: 'required', title: t('mall.order.shipping_company') })],
shipping_no: [buildValidatorData({ name: 'required', title: t('mall.order.shipping_no') })],
reject_reason: [buildValidatorData({ name: 'required', title: t('mall.order.reject_reason') })],
}
})
</script>
<style scoped lang="scss"></style>

View File

@@ -56,8 +56,8 @@ const baTable = new baTableClass(
operator: 'LIKE',
},
{
label: t('mall.pintsOrder.malluser__username'),
prop: 'mallUser.username',
label: t('mall.pintsOrder.playxuserasset__username'),
prop: 'playxUserAsset.username',
align: 'center',
minWidth: 120,
operatorPlaceholder: t('Fuzzy query'),

View File

@@ -37,12 +37,12 @@
:placeholder="t('Please input field', { field: t('mall.pintsOrder.order') })"
/>
<FormItem
:label="t('mall.pintsOrder.mall_user_id')"
:label="t('mall.pintsOrder.playx_user_asset_id')"
type="remoteSelect"
v-model="baTable.form.items!.mall_user_id"
prop="mall_user_id"
:input-attr="{ pk: 'mall_user.id', field: 'username', remoteUrl: '/admin/mall.User/index' }"
:placeholder="t('Please select field', { field: t('mall.pintsOrder.mall_user_id') })"
v-model="baTable.form.items!.playx_user_asset_id"
prop="playx_user_asset_id"
:input-attr="{ pk: 'mall_user_asset.id', field: 'username', remoteUrl: '/admin/mall.UserAsset/select' }"
:placeholder="t('Please select field', { field: t('mall.pintsOrder.playx_user_asset_id') })"
/>
<FormItem
:label="t('mall.pintsOrder.type')"
@@ -93,7 +93,7 @@ const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
order: [buildValidatorData({ name: 'required', title: t('mall.pintsOrder.order') })],
mall_user_id: [buildValidatorData({ name: 'required', title: t('mall.pintsOrder.mall_user_id') })],
playx_user_asset_id: [buildValidatorData({ name: 'required', title: t('mall.pintsOrder.playx_user_asset_id') })],
type: [buildValidatorData({ name: 'required', title: t('mall.pintsOrder.type') })],
score: [buildValidatorData({ name: 'number', title: t('mall.pintsOrder.score') })],
create_time: [buildValidatorData({ name: 'date', title: t('mall.pintsOrder.create_time') })],

View File

@@ -1,102 +0,0 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<!-- 自定义按钮请使用插槽甚至公共搜索也可以使用具名插槽渲染参见文档 -->
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('mall.player.quickSearchFields') })"
></TableHeader>
<!-- 表格 -->
<!-- 表格列有多种自定义渲染方式比如自定义组件具名插槽等参见文档 -->
<!-- 要使用 el-table 组件原有的属性直接加在 Table 标签上即可 -->
<Table ref="tableRef"></Table>
<!-- 表单 -->
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
defineOptions({
name: 'mall/player',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
/**
* baTable 内包含了表格的所有数据且数据具备响应性,然后通过 provide 注入给了后代组件
*/
const baTable = new baTableClass(
new baTableApi('/admin/mall.Player/'),
{
pk: 'id',
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('mall.player.id'), prop: 'id', align: 'center', width: 70, operator: 'RANGE', sortable: 'custom' },
{
label: t('mall.player.username'),
prop: 'username',
align: 'center',
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{
label: t('mall.player.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.player.update_time'),
prop: 'update_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 160,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{ label: t('mall.player.score'), prop: 'score', align: 'center', sortable: false, operator: 'RANGE' },
{ label: t('Operate'), align: 'center', width: 100, render: 'buttons', buttons: optButtons, operator: false },
],
dblClickNotEditColumn: [undefined],
},
{
defaultItems: {},
}
)
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
baTable.initSort()
baTable.dragSort()
})
})
</script>
<style scoped lang="scss"></style>

View File

@@ -1,109 +0,0 @@
<template>
<!-- 对话框表单 -->
<!-- 建议使用 Prettier 格式化代码 -->
<!-- el-form 内可以混用 el-form-itemFormItemba-input 等输入组件 -->
<el-dialog
class="ba-operate-dialog"
:close-on-click-modal="false"
:model-value="['Add', 'Edit'].includes(baTable.form.operate!)"
@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 v-loading="baTable.form.loading" 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"
@submit.prevent=""
@keyup.enter="baTable.onSubmit(formRef)"
:model="baTable.form.items"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="baTable.form.labelWidth + 'px'"
:rules="rules"
>
<FormItem
:label="t('mall.player.username')"
type="string"
v-model="baTable.form.items!.username"
prop="username"
:placeholder="t('Please input field', { field: t('mall.player.username') })"
/>
<FormItem
:label="t('mall.player.password')"
type="password"
v-model="baTable.form.items!.password"
prop="password"
:placeholder="t('Please input field', { field: t('mall.player.password') })"
/>
<FormItem
:label="t('mall.player.score')"
type="number"
v-model="baTable.form.items!.score"
prop="score"
:input-attr="{ step: 1 }"
:placeholder="t('Please input field', { field: t('mall.player.score') })"
/>
</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" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import type { FormItemRule } from 'element-plus'
import { inject, reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import FormItem from '/@/components/formItem/index.vue'
import { useConfig } from '/@/stores/config'
import type baTableClass from '/@/utils/baTable'
import { buildValidatorData, regularPassword } from '/@/utils/validate'
const config = useConfig()
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.player.username') })],
password: [
{
validator: (_rule: unknown, val: string, callback: (error?: Error) => void) => {
if (baTable.form.operate === 'Add') {
if (!val) {
return callback(new Error(t('Please input field', { field: t('mall.player.password') })))
}
} else {
if (!val) {
return callback()
}
}
if (!regularPassword(val)) {
return callback(new Error(t('validate.Please enter the correct password')))
}
return callback()
},
trigger: 'blur',
},
],
score: [buildValidatorData({ name: 'number', title: t('mall.player.score') })],
})
</script>
<style scoped lang="scss"></style>

View File

@@ -83,6 +83,7 @@ const baTable = new baTableClass(
ACCEPTED: t('mall.playxOrder.grant_status ACCEPTED'),
FAILED_RETRYABLE: t('mall.playxOrder.grant_status FAILED_RETRYABLE'),
FAILED_FINAL: t('mall.playxOrder.grant_status FAILED_FINAL'),
'---': t('mall.playxOrder.grant_status ---'),
},
},
{
@@ -116,7 +117,9 @@ const baTable = new baTableClass(
type: 'warning',
icon: '',
display: (row: TableRow) =>
(row.type === 'BONUS' || row.type === 'WITHDRAW') && row.grant_status === 'FAILED_RETRYABLE' && row.status === 'PENDING',
row.type === 'BONUS' &&
row.status === 'PENDING' &&
['NOT_SENT', 'SENT_PENDING', 'FAILED_RETRYABLE', 'FAILED_FINAL'].includes(String(row.grant_status)),
popconfirm: {
title: '确认将该订单加入重试队列?',
confirmButtonText: '确认',
@@ -126,7 +129,7 @@ const baTable = new baTableClass(
click: async (row: TableRow) => {
await createAxios(
{
url: '/admin/mall.PlayxOrder/retry',
url: '/admin/mall.Order/retry',
method: 'post',
data: {
id: row.id,

View File

@@ -33,21 +33,73 @@ const baTable = new baTableClass(
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('mall.playxUserAsset.id'), prop: 'id', align: 'center', width: 70, operator: 'RANGE', sortable: 'custom' },
{ label: t('mall.playxUserAsset.user_id'), prop: 'user_id', align: 'center', operatorPlaceholder: t('Fuzzy query'), sortable: false, operator: 'LIKE' },
{ label: t('mall.playxUserAsset.username'), prop: 'username', align: 'center', operatorPlaceholder: t('Fuzzy query'), sortable: false, operator: 'LIKE' },
{
label: t('mall.playxUserAsset.username'),
prop: 'username',
align: 'center',
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{
label: t('mall.playxUserAsset.phone'),
prop: 'phone',
align: 'center',
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{
label: t('mall.playxUserAsset.playx_user_id'),
prop: 'playx_user_id',
align: 'center',
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{ label: t('mall.playxUserAsset.locked_points'), prop: 'locked_points', align: 'center', operator: 'RANGE', sortable: false },
{ label: t('mall.playxUserAsset.available_points'), prop: 'available_points', align: 'center', operator: 'RANGE', sortable: false },
{ label: t('mall.playxUserAsset.today_limit'), prop: 'today_limit', align: 'center', operator: 'RANGE', sortable: false },
{ label: t('mall.playxUserAsset.today_claimed'), prop: 'today_claimed', align: 'center', operator: 'RANGE', sortable: false },
{ label: t('mall.playxUserAsset.today_limit_date'), prop: 'today_limit_date', align: 'center', render: 'date', operator: 'RANGE', comSearchRender: 'date', sortable: 'custom', width: 120, operatorPlaceholder: t('Fuzzy query') },
{ label: t('mall.playxUserAsset.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.playxUserAsset.update_time'), prop: 'update_time', align: 'center', render: 'datetime', operator: 'RANGE', comSearchRender: 'datetime', sortable: 'custom', width: 160, timeFormat: 'yyyy-mm-dd hh:MM:ss' },
{
label: t('mall.playxUserAsset.today_limit_date'),
prop: 'today_limit_date',
align: 'center',
render: 'date',
operator: 'RANGE',
comSearchRender: 'date',
sortable: 'custom',
width: 120,
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('mall.playxUserAsset.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.playxUserAsset.update_time'),
prop: 'update_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)
@@ -63,4 +115,3 @@ onMounted(() => {
</script>
<style scoped lang="scss"></style>

View File

@@ -56,8 +56,8 @@ const baTable = new baTableClass(
operator: 'LIKE',
},
{
label: t('mall.redemptionOrder.malluser__username'),
prop: 'mallUser.username',
label: t('mall.redemptionOrder.playxuserasset__username'),
prop: 'playxUserAsset.username',
align: 'center',
minWidth: 120,
operatorPlaceholder: t('Fuzzy query'),

View File

@@ -37,12 +37,12 @@
:placeholder="t('Please input field', { field: t('mall.redemptionOrder.order') })"
/>
<FormItem
:label="t('mall.redemptionOrder.mall_user_id')"
:label="t('mall.redemptionOrder.playx_user_asset_id')"
type="remoteSelect"
v-model="baTable.form.items!.mall_user_id"
prop="mall_user_id"
:input-attr="{ pk: 'mall_user.id', field: 'username', remoteUrl: '/admin/mall.User/index' }"
:placeholder="t('Please select field', { field: t('mall.redemptionOrder.mall_user_id') })"
v-model="baTable.form.items!.playx_user_asset_id"
prop="playx_user_asset_id"
:input-attr="{ pk: 'mall_user_asset.id', field: 'username', remoteUrl: '/admin/mall.UserAsset/select' }"
:placeholder="t('Please select field', { field: t('mall.redemptionOrder.playx_user_asset_id') })"
/>
<FormItem
:label="t('mall.redemptionOrder.status')"

View File

@@ -2,19 +2,13 @@
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<!-- 自定义按钮请使用插槽甚至公共搜索也可以使用具名插槽渲染参见文档 -->
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('mall.user.quick Search Fields') })"
:buttons="['refresh', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('mall.userAsset.quick Search Fields') })"
></TableHeader>
<!-- 表格 -->
<!-- 表格列有多种自定义渲染方式比如自定义组件具名插槽等参见文档 -->
<!-- 要使用 el-table 组件原有的属性直接加在 Table 标签上即可 -->
<Table ref="tableRef"></Table>
<!-- 表单 -->
<PopupForm />
</div>
</template>
@@ -30,47 +24,61 @@ import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
defineOptions({
name: 'mall/user',
name: 'mall/userAsset',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
/**
* baTable 内包含了表格的所有数据且数据具备响应性然后通过 provide 注入给了后代组件
*/
const baTable = new baTableClass(
new baTableApi('/admin/mall.User/'),
new baTableApi('/admin/mall.UserAsset/'),
{
pk: 'id',
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('mall.user.id'), prop: 'id', align: 'center', width: 70, operator: 'RANGE', sortable: 'custom' },
{ label: t('mall.userAsset.id'), prop: 'id', align: 'center', width: 70, operator: 'RANGE', sortable: 'custom' },
{
label: t('mall.user.username'),
label: t('mall.userAsset.username'),
prop: 'username',
align: 'center',
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{ label: t('mall.user.phone'), prop: 'phone', align: 'center', operatorPlaceholder: t('Fuzzy query'), sortable: false, operator: 'LIKE' },
{ label: t('mall.user.score'), prop: 'score', align: 'center', sortable: false, operator: 'RANGE' },
{ label: t('mall.user.daily_claim'), prop: 'daily_claim', align: 'center', sortable: false, operator: 'RANGE' },
{ label: t('mall.user.daily_claim_use'), prop: 'daily_claim_use', align: 'center', sortable: false, operator: 'RANGE' },
{ label: t('mall.user.available_for_withdrawal'), prop: 'available_for_withdrawal', align: 'center', sortable: false, operator: 'RANGE' },
{
label: t('mall.user.admin__username'),
prop: 'admin.username',
label: t('mall.userAsset.phone'),
prop: 'phone',
align: 'center',
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
sortable: false,
operator: 'LIKE',
comSearchRender: 'string',
},
{
label: t('mall.user.create_time'),
label: t('mall.userAsset.playx_user_id'),
prop: 'playx_user_id',
align: 'center',
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.today_limit_date'),
prop: 'today_limit_date',
align: 'center',
render: 'date',
operator: 'RANGE',
comSearchRender: 'date',
sortable: 'custom',
width: 120,
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('mall.userAsset.create_time'),
prop: 'create_time',
align: 'center',
render: 'datetime',
@@ -81,7 +89,7 @@ const baTable = new baTableClass(
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
label: t('mall.user.update_time'),
label: t('mall.userAsset.update_time'),
prop: 'update_time',
align: 'center',
render: 'datetime',
@@ -91,17 +99,12 @@ const baTable = new baTableClass(
width: 160,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{ label: t('Operate'), align: 'center', width: 100, render: 'buttons', buttons: optButtons, operator: false },
{ label: t('Operate'), align: 'center', fixed: 'right', width: 80, render: 'buttons', buttons: optButtons, operator: false },
],
dblClickNotEditColumn: [undefined],
},
{
defaultItems: {
score: 0,
daily_claim: 100,
daily_claim_use: 0,
available_for_withdrawal: 0,
},
defaultItems: {},
}
)

View File

@@ -1,11 +1,8 @@
<template>
<!-- 对话框表单 -->
<!-- 建议使用 Prettier 格式化代码 -->
<!-- el-form 内可以混用 el-form-itemFormItemba-input 等输入组件 -->
<el-dialog
class="ba-operate-dialog"
:close-on-click-modal="false"
:model-value="['Add', 'Edit'].includes(baTable.form.operate!)"
:model-value="['Edit'].includes(baTable.form.operate!)"
@close="baTable.toggleForm"
>
<template #header>
@@ -13,6 +10,7 @@
{{ baTable.form.operate ? t(baTable.form.operate) : '' }}
</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div
class="ba-operate-form"
@@ -29,67 +27,69 @@
: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.user.username')"
:label="t('mall.userAsset.playx_user_id')"
type="string"
v-model="baTable.form.items!.playx_user_id"
prop="playx_user_id"
:input-attr="{ disabled: true }"
/>
<FormItem
:label="t('mall.userAsset.username')"
type="string"
v-model="baTable.form.items!.username"
prop="username"
:placeholder="t('Please input field', { field: t('mall.user.username') })"
:placeholder="t('Please input field', { field: t('mall.userAsset.username') })"
/>
<FormItem
:label="t('mall.user.phone')"
:label="t('mall.userAsset.phone')"
type="string"
v-model="baTable.form.items!.phone"
prop="phone"
:placeholder="t('Please input field', { field: t('mall.user.phone') })"
:placeholder="t('Please input field', { field: t('mall.userAsset.phone') })"
/>
<FormItem
:label="t('mall.user.password')"
type="password"
v-model="baTable.form.items!.password"
prop="password"
:placeholder="t('Please input field', { field: t('mall.user.password') })"
/>
<FormItem
:label="t('mall.user.score')"
:label="t('mall.userAsset.locked_points')"
type="number"
v-model="baTable.form.items!.score"
prop="score"
:input-attr="{ step: 1 }"
:placeholder="t('Please input field', { field: t('mall.user.score') })"
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.user.daily_claim')"
:label="t('mall.userAsset.available_points')"
type="number"
v-model="baTable.form.items!.daily_claim"
prop="daily_claim"
:input-attr="{ step: 1 }"
:placeholder="t('Please input field', { field: t('mall.user.daily_claim') })"
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') })"
/>
<FormItem
:label="t('mall.user.daily_claim_use')"
:label="t('mall.userAsset.today_limit')"
type="number"
v-model="baTable.form.items!.daily_claim_use"
prop="daily_claim_use"
:input-attr="{ step: 1 }"
:placeholder="t('Please input field', { field: t('mall.user.daily_claim_use') })"
v-model="baTable.form.items!.today_limit"
prop="today_limit"
:input-attr="{ step: 1, min: 0 }"
:placeholder="t('Please input field', { field: t('mall.userAsset.today_limit') })"
/>
<FormItem
:label="t('mall.user.available_for_withdrawal')"
:label="t('mall.userAsset.today_claimed')"
type="number"
v-model="baTable.form.items!.available_for_withdrawal"
prop="available_for_withdrawal"
:input-attr="{ step: 1 }"
:placeholder="t('Please input field', { field: t('mall.user.available_for_withdrawal') })"
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') })"
/>
</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" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}
{{ t('Save') }}
</el-button>
</div>
</template>
@@ -97,31 +97,28 @@
</template>
<script setup lang="ts">
import type { FormItemRule } from 'element-plus'
import { inject, reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import FormItem from '/@/components/formItem/index.vue'
import { useConfig } from '/@/stores/config'
import type baTableClass from '/@/utils/baTable'
import baTableClass from '/@/utils/baTable'
import FormItem from '/@/components/formItem/index.vue'
import type { FormItemRule } from 'element-plus'
import { buildValidatorData } from '/@/utils/validate'
const config = useConfig()
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.user.username') })],
phone: [buildValidatorData({ name: 'required', title: t('mall.user.phone') })],
password: [buildValidatorData({ name: 'password', title: t('mall.user.password') })],
score: [buildValidatorData({ name: 'number', title: t('mall.user.score') })],
daily_claim: [buildValidatorData({ name: 'number', title: t('mall.user.daily_claim') })],
daily_claim_use: [buildValidatorData({ name: 'number', title: t('mall.user.daily_claim_use') })],
available_for_withdrawal: [buildValidatorData({ name: 'number', title: t('mall.user.available_for_withdrawal') })],
create_time: [buildValidatorData({ name: 'date', title: t('mall.user.create_time') })],
update_time: [buildValidatorData({ name: 'date', title: t('mall.user.update_time') })],
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') })],
})
</script>
<style scoped lang="scss"></style>

View File

@@ -82,13 +82,6 @@ const baTable = new baTableClass(new baTableApi('/admin/routine.Attachment/'), {
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('routine.attachment.Upload user'),
prop: 'user.nickname',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('utils.size'),
prop: 'size',

View File

@@ -4,6 +4,7 @@ type TableRenderer =
| 'color'
| 'customRender'
| 'customTemplate'
| 'date'
| 'datetime'
| 'icon'
| 'image'