Compare commits

..

5 Commits

Author SHA1 Message Date
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
39 changed files with 376 additions and 821 deletions

View File

@@ -24,8 +24,12 @@ 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=5590a339502b133f4d0c545c3cdad159a4827dfccb3f51bb110c56f9b96568ca
# Agent authtoken/api/v1/authTokenJWT 签名密钥;留空则使用下方 buildadmin.token.key
AGENT_AUTH_JWT_SECRET=
# token 会话缓存过期时间(秒)
PLAYX_SESSION_EXPIRE_SECONDS=3600
# PlayX API商城调用 PlayX 时使用)

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

@@ -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

@@ -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

@@ -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

@@ -10,7 +10,6 @@ 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\MallPlayxUserAsset;
use app\admin\model\Admin;
use Webman\Http\Request;
@@ -72,17 +71,12 @@ class Auth extends Api
return $this->error(__('Agent not found'));
}
$channelId = intval($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'));
}
@@ -94,7 +88,6 @@ class Auth extends Api
$expire = intval(config('buildadmin.agent_auth.token_expire', 86400));
$payload = [
'agent_id' => $agentId,
'channel_id' => $channel->id,
'admin_id' => $admin->id,
];
$authtoken = AgentJwt::encode($payload, $expire);

View File

@@ -14,6 +14,7 @@ use app\common\model\MallPlayxDailyPush;
use app\common\model\MallPlayxSession;
use app\common\model\MallPlayxOrder;
use app\common\model\MallPlayxUserAsset;
use app\common\library\PlayxInboundJwt;
use support\think\Db;
use Webman\Http\Request;
use support\Response;
@@ -156,6 +157,14 @@ class Playx extends Api
return $response;
}
$partnerJwtSecret = strval(config('playx.partner_jwt_secret', ''));
if ($partnerJwtSecret !== '') {
$authHeader = strval($request->header('authorization', ''));
if (!PlayxInboundJwt::verifyBearer($authHeader, $partnerJwtSecret)) {
return $this->error(__('Invalid or missing JWT'), null, 0, ['statusCode' => 401]);
}
}
$body = $request->post();
if (empty($body)) {
$raw = $request->rawBody();
@@ -164,16 +173,6 @@ class Playx extends Api
}
}
$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'));
}
$secret = config('playx.daily_push_secret', '');
if ($secret !== '') {
$sig = $request->header('X-Signature', '');
@@ -189,6 +188,130 @@ class Playx extends Api
}
}
// ===== 新版批量上报格式 =====
// 兼容你们截图:{ 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 = MallPlayxDailyPush::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 {
MallPlayxDailyPush::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 map playx user to mall user'));
}
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 = MallPlayxDailyPush::where('user_id', $playxUserId)->where('date', $date)->find();
if ($exists) {
return $this->success('', [
@@ -212,10 +335,9 @@ 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));
}
@@ -225,14 +347,16 @@ class Playx extends Api
if (!$asset) {
throw new \RuntimeException(__('Failed to map playx user to mall user'));
}
$todayLimitDate = $date;
if ($asset->today_limit_date !== $todayLimitDate) {
if ($asset->today_limit_date !== $date) {
$asset->today_claimed = 0;
$asset->today_limit_date = $todayLimitDate;
$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;

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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -87,8 +87,8 @@ 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 小时

View File

@@ -12,6 +12,11 @@ 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。非空时daily-push 等回调需带 Authorization: Bearer
* 仅写入部署环境变量,勿提交仓库。
*/
'partner_jwt_secret' => strval(env('PLAYX_PARTNER_JWT_SECRET', '')),
// token 会话缓存过期时间(秒)
'session_expire_seconds' => intval(env('PLAYX_SESSION_EXPIRE_SECONDS', '3600')),
/**

View File

@@ -38,6 +38,36 @@
| `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`
@@ -97,6 +127,49 @@ curl -X POST 'http://localhost:1818/api/v1/playx/daily-push' \
}
```
#### 示例(新版批量上报)
请求:
```bash
curl -X POST 'http://localhost:1818/api/v1/playx/daily-push' \
-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

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)) {

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

@@ -6,9 +6,9 @@ export default {
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': 'NO',
'default_setting 1': 'YES',
create_time: 'create_time',
update_time: 'update_time',
'quick Search Fields': 'id',

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

@@ -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

@@ -7,8 +7,8 @@ export default {
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

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

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

@@ -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

@@ -78,9 +78,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') },
},
{

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',