Init project

master
落雨楓 1 year ago
commit 129ee5ac3d

@ -0,0 +1,2 @@
<?php
$specialPageAliases = [];

@ -0,0 +1,13 @@
<?php
use MediaWiki\MediaWikiServices;
use Isekai\UserPoints\Service\IsekaiUserPointsFactory;
use Isekai\UserPoints\Service\IsekaiUserDailySignFactory;
return [
'IsekaiUserPoints' => static function ( MediaWikiServices $services ) {
return new IsekaiUserPointsFactory( $services );
},
'IsekaiUserDailySign' => static function ( MediaWikiServices $services ) {
return new IsekaiUserDailySignFactory( $services );
},
];

@ -0,0 +1,12 @@
{
"name": "hyperzlib/isekai-user-points",
"type": "library",
"license": "GPL-2.0-or-later",
"authors": [
{
"name": "Lex Lim",
"email": "hyperzlib@outlook.com"
}
],
"require": {}
}

@ -0,0 +1,105 @@
{
"name": "Isekai User Points",
"namemsg": "isekai-userpoints",
"author": "Hyperzlib",
"version": "1.0.0",
"url": "https://git.isekai.cn/Isekai-Project/mediawiki-extension-IsekaiUserPoints",
"descriptionmsg": "isekai-userpoints-desc",
"license-name": "GPL-2.0-or-later",
"type": "api",
"requires": {
"MediaWiki": ">= 1.35.0"
},
"MessagesDirs": {
"IsekaiUserPoints": [
"i18n"
]
},
"ExtensionMessagesFiles": {
"IsekaiUserPointsAlias": "IsekaiUserPoints.alias.php"
},
"AutoloadNamespaces": {
"Isekai\\UserPoints\\": "includes"
},
"Hooks": {
"LoadExtensionSchemaUpdates": "Isekai\\UserPoints\\Hooks::onLoadExtensionSchemaUpdates",
"BeforePageDisplay": "Isekai\\UserPoints\\Hooks::onBeforePageDisplay",
"GetPreferences": "Isekai\\UserPoints\\Hooks::onGetPreferences"
},
"APIModules": {
"userdailysign": "Isekai\\UserPoints\\Api\\ApiUserDailySign"
},
"APIPropModules": {
"userpoints": "Isekai\\UserPoints\\Api\\ApiQueryUserPoints",
"pointinfo": "Isekai\\UserPoints\\Api\\ApiQueryPointInfo"
},
"APIListModules": {
"userspoints": "Isekai\\UserPoints\\Api\\ApiQueryUsersPoints"
},
"ServiceWiringFiles": [
"IsekaiUserPoints.services.php"
],
"GroupPermissions": {
"bureaucrat": {
"queryuserpoints": true,
"edituserpoints": true
},
"suppress": {
"queryuserpoints": true,
"edituserpoints": true
},
"sysop": {
"queryuserpoints": true,
"edituserpoints": true
}
},
"GrantPermissions": {
"userpointsmanager": {
"queryuserpoints": true,
"edituserpoints": true
}
},
"ResourceModules": {
"ext.isekai.userpoints.base": {
"styles": ["ext.isekai.userpoints.base.less"]
},
"ext.isekai.userpoints.dailysign": {
"scripts": ["ext.isekai.userpoints.dailysign.js"],
"messages": [
"comma-separator",
"isekai-userpoints-point-name-num",
"isekai-userpoints-dailysign-notify-title",
"isekai-userpoints-dailysign-notify-success"
]
}
},
"ResourceFileModulePaths": {
"localBasePath": "modules",
"remoteExtPath": "IsekaiUserPoints/modules"
},
"config": {
"IsekaiUserPointConfig": {
"value": {
"exp": {
"name": "Exp.",
"namemsg": "isekai-userpoints-point-name-exp",
"icon": {
"normal": {
"image": "https://static.isekai.dev/isekaiwiki/isekaiwiki-exp.png"
},
"invert": {
"image": "https://static.isekai.dev/isekaiwiki/isekaiwiki-exp-invert.png"
}
}
}
}
},
"IsekaiUserPointShowOnUserPerferences": {
"value": true
},
"IsekaiUserDailySignConfig": {
"value": null
}
},
"manifest_version": 2
}

@ -0,0 +1,28 @@
{
"isekai-userpoints": "异世界百科 用户积分",
"isekai-userpoints-desc": "存储用户积分,为其他应用提供积分功能",
"isekai-userpoints-point-name-num": "$2 $3 $1",
"isekai-userpoints-service-system": "积分系统",
"isekai-userpoints-action-system-point-expired": "积分过期",
"apihelp-query+userpoints-summary": "获取当前登录用户的积分",
"apihelp-query+userspoints-summary": "获取用户积分",
"apihelp-query+userspoints-param-users": "要获取信息的用户列表。",
"apihelp-query+userspoints-param-userids": "要获得信息的用户ID列表。",
"apierror-isekaiuserpoints-notfound": "未找到积分信息",
"isekai-userpoints-point-name-exp": "经验值",
"isekai-userpoints-point-name-kamakano": "星砂",
"isekai-userpoints-source-system": "积分系统",
"apihelp-userdailysign-summary": "用户签到",
"apiwarn-isekai-userpoints-already-signed-today": "今天已经签到过了",
"isekai-userpoints-dailysign-notify-title": "每日签到",
"isekai-userpoints-dailysign-notify-success": "每日登录网站,获得了$1"
}

@ -0,0 +1,75 @@
<?php
namespace Isekai\UserPoints\Api;
use ApiQueryBase;
use ApiQuery;
use Config;
use MediaWiki\MediaWikiServices;
use WANObjectCache;
use Isekai\UserPoints\Utils;
class ApiQueryPointInfo extends ApiQueryBase {
private const CACHE_VERSION = 2;
private const PREFIX = 'pi';
private $params;
/**
* @var Config
*/
private $config;
/**
* @var WANObjectCache
*/
private $cache;
/**
* @param ApiQuery $query API query module object
* @param string $moduleName Name of this query module
*/
public function __construct($query, $moduleName) {
parent::__construct($query, $moduleName, self::PREFIX);
$this->config = MediaWikiServices::getInstance()->getConfigFactory()->makeConfig( 'textextracts' );
$this->cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
}
/**
* @throws \ApiUsageException
*/
public function execute() {
$pointConfig = $this->config->get('IsekaiUserPointConfig');
$pointTypes = array_keys($pointConfig);
$pointInfos = [];
foreach ($pointTypes as $pointType) {
$pointInfos[$pointType] = [
'name' => Utils::getPointName($pointType),
'icon' => Utils::getPointIcon($pointType),
];
}
$result = $this->getResult();
$result->addValue(
[ 'query', $this->getModuleName() ],
'pointinfo',
$pointInfos
);
}
/**
* @param array $params Ignored parameters
* @return string
*/
public function getCacheMode($params) {
return 'public';
}
public function getAllowedParams() {
return [
];
}
}

@ -0,0 +1,99 @@
<?php
namespace Isekai\UserPoints\Api;
use ApiQueryBase;
use ApiQuery;
use Config;
use MediaWiki\MediaWikiServices;
use WANObjectCache;
use Isekai\UserPoints\Service\IsekaiUserPointsFactory;
use Isekai\UserPoints\Utils;
use stdClass;
class ApiQueryUserPoints extends ApiQueryBase {
private const CACHE_VERSION = 2;
private const PREFIX = 'up';
private $params;
/**
* @var Config
*/
private $config;
/**
* @var WANObjectCache
*/
private $cache;
/**
* @param ApiQuery $query API query module object
* @param string $moduleName Name of this query module
*/
public function __construct($query, $moduleName) {
parent::__construct($query, $moduleName, self::PREFIX);
$this->config = MediaWikiServices::getInstance()->getConfigFactory()->makeConfig( 'textextracts' );
$this->cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
}
/**
* @throws \ApiUsageException
*/
public function execute() {
$user = $this->getUser();
if (!$user->isRegistered()) {
$this->dieWithError('apierror-mustbeloggedin-generic', 'login-required');
return false;
}
$pointConfig = $this->config->get('IsekaiUserPointConfig');
$pointTypes = array_keys($pointConfig);
/** @var IsekaiUserPointsFactory */
$userPointsFactory = MediaWikiServices::getInstance()->getService('IsekaiUserPoints');
/** @var array<IsekaiUserPoints> */
$userPoints = [];
foreach ($pointTypes as $pointType) {
$userPoint = $userPointsFactory->newFromUser($user, $pointType);
if ($userPoint) {
$userPoints[$pointType] = $userPointsFactory->newFromUser($user, $pointType);
}
}
if (count($userPoints) === 0) {
$this->dieWithError('apierror-isekaiuserpoints-notfound', 'not-found');
return false;
}
$result = $this->getResult();
foreach ($userPoints as $pointType => $userPoint) {
$pointName = Utils::getPointName($pointType);
$pointIcon = Utils::getPointIcon($pointType);
$result->addValue(['query', $this->getModuleName()], $pointType, [
'points' => $userPoint->points,
'timed_points_data' => $userPoint->timedPointsData,
'locked_points' => $userPoint->lockedPoints,
'locked_points_data' => $userPoint->lockedPointsData,
'name' => $pointName,
'icon' => $pointIcon,
]);
}
if (empty($userPoints)) {
$result->addValue(['query'], $this->getModuleName(), new stdClass());
}
}
/**
* @param array $params Ignored parameters
* @return string
*/
public function getCacheMode($params) {
return 'private';
}
public function getAllowedParams() {
return [];
}
}

@ -0,0 +1,124 @@
<?php
namespace Isekai\UserPoints\Api;
use ApiQueryBase;
use ApiQuery;
use Config;
use MediaWiki\MediaWikiServices;
use WANObjectCache;
use Wikimedia\ParamValidator\ParamValidator;
use User;
use Isekai\UserPoints\Service\IsekaiUserPointsFactory;
use Isekai\UserPoints\Utils;
class ApiQueryUsersPoints extends ApiQueryBase {
private const CACHE_VERSION = 2;
private const PREFIX = 'up';
private $params;
/**
* @var Config
*/
private $config;
/**
* @var WANObjectCache
*/
private $cache;
/**
* @param ApiQuery $query API query module object
* @param string $moduleName Name of this query module
*/
public function __construct($query, $moduleName) {
parent::__construct($query, $moduleName, self::PREFIX);
$this->config = MediaWikiServices::getInstance()->getMainConfig();
$this->cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
}
/**
* @throws \ApiUsageException
*/
public function execute() {
$this->checkUserRightsAny('queryuserpoints');
$params = $this->extractRequestParams();
$this->requireMaxOneParameter( $params, 'userids', 'users' );
$users = (array)$params['users'];
$userids = (array)$params['userids'];
$services = MediaWikiServices::getInstance();
$pointConfig = $this->config->get('IsekaiUserPointConfig');
$pointTypes = array_keys($pointConfig);
$pointInfos = [];
foreach ($pointTypes as $pointType) {
$pointInfos[$pointType] = [
'name' => Utils::getPointName($pointType),
'icon' => Utils::getPointIcon($pointType),
];
}
/** @var IsekaiUserPointsFactory */
$userPointsFactory = $services->getService('IsekaiUserPoints');
$userFactory = $services->getUserFactory();
$userInstances = [];
foreach ( $users as $u ) {
$user = $userFactory->newFromName( $u );
$userInstances[] = $user;
}
foreach ( $userids as $u ) {
$user = $userFactory->newFromId( $u );
$userInstances[] = $user;
}
$userPointsInstances = $userPointsFactory->newFromUsers($userInstances, $pointTypes);
$result = $this->getResult();
foreach ($userPointsInstances as $userPointsTuple) {
list($user, $type, $userPoint) = $userPointsTuple;
$result->addValue(
[ 'query', $this->getModuleName(), "pointdata", $user->getId() ],
$type,
[
'point_type' => $type,
'points' => $userPoint->points,
'timed_points_data' => $userPoint->timedPointsData,
'locked_points' => $userPoint->lockedPoints,
'locked_points_data' => $userPoint->lockedPointsData,
]
);
}
$result->addValue(
[ 'query', $this->getModuleName() ],
'pointinfo',
$pointInfos
);
}
/**
* @param array $params Ignored parameters
* @return string
*/
public function getCacheMode($params) {
return 'public';
}
public function getAllowedParams() {
return [
'users' => [
ParamValidator::PARAM_ISMULTI => true
],
'userids' => [
ParamValidator::PARAM_ISMULTI => true,
ParamValidator::PARAM_TYPE => 'integer'
],
];
}
}

@ -0,0 +1,63 @@
<?php
namespace Isekai\UserPoints\Api;
use ApiBase;
use Isekai\UserPoints\Service\IsekaiUserPointsFactory;
use Isekai\UserPoints\Service\IsekaiUserDailySignFactory;
use Isekai\UserPoints\Utils;
use MediaWiki\MediaWikiServices;
class ApiUserDailySign extends ApiBase {
public function __construct( $main, $method ) {
parent::__construct( $main->getMain(), $method );
}
public function execute() {
$services = MediaWikiServices::getInstance();
$user = $this->getUser();
if (!$user->isRegistered()) {
$this->dieWithError('apierror-mustbeloggedin-generic', 'login-required');
}
/** @var IsekaiUserDailySignFactory */
$dailySignFactory = $services->getService('IsekaiUserDailySign');
$dailySignService = $dailySignFactory->newFromUser($user);
$result = $this->getResult();
if (date('Y-m-d') === date('Y-m-d', strtotime($dailySignService->lastSignDate))) {
$result->addValue(['userdailysign'], 'success', 0);
$result->addValue(['userdailysign'], 'point_delta', []);
$this->addWarning('isekai-userpoints-already-signed-today', 'already-signed-today');
return;
}
$pointDelta = $dailySignService->doSign();
if (!empty($pointDelta)) {
$result->addValue(['userdailysign'], 'success', 1);
} else {
$result->addValue(['userdailysign'], 'success', 0);
}
$resultPointDelta = [];
foreach ($pointDelta as $pointType => $delta) {
$pointName = Utils::getPointName($pointType);
$pointIcon = Utils::getPointIcon($pointType);
$resultPointDelta[] = [
'point_type' => $pointType,
'name' => $pointName,
'icon' => $pointIcon,
'points' => $delta,
];
}
$result->addValue(['userdailysign'], 'point_delta', $resultPointDelta);
}
public function needsToken() {
return 'csrf';
}
}

@ -0,0 +1,63 @@
<?php
namespace Isekai\UserPoints;
use DatabaseUpdater;
use MediaWiki\MediaWikiServices;
use OutputPage;
use Skin;
use User;
class Hooks {
/**
* Implements LoadExtensionSchemaUpdates hook.
*
* @param \DatabaseUpdater $updater
*/
public static function onLoadExtensionSchemaUpdates($updater) {
$dir = dirname(__DIR__) . '/sql/';
$type = $updater->getDB()->getType();
$updater->addExtensionTable('isekai_user_daily_sign', $dir . $type . '/isekai_user_daily_sign.sql');
$updater->addExtensionTable('isekai_user_daily_sign_log', $dir . $type . '/isekai_user_daily_sign_log.sql');
$updater->addExtensionTable('isekai_user_points', $dir . $type . '/isekai_user_points.sql');
$updater->addExtensionTable('isekai_user_points_log', $dir . $type . '/isekai_user_points_log.sql');
}
public static function onBeforePageDisplay(OutputPage $out, Skin $skin) {
$config = MediaWikiServices::getInstance()->getMainConfig();
$out->addModuleStyles(['ext.isekai.userpoints.base']);
$dailySignConfig = $config->get('IsekaiUserDailySignConfig');
if ($dailySignConfig) {
$out->addModules(['ext.isekai.userpoints.dailysign']);
}
}
public static function onGetPreferences(User $user, &$preferences) {
$config = MediaWikiServices::getInstance()->getMainConfig();
if ($config->get('IsekaiUserPointShowOnUserPerferences')) {
$userPointConfig = $config->get('IsekaiUserPointConfig');
/** @var \Isekai\UserPoints\Service\IsekaiUserPointsFactory */
$userPointsFactory = MediaWikiServices::getInstance()->getService('IsekaiUserPoints');
foreach ($userPointConfig as $pointType => $pointConfig) {
$userPoints = $userPointsFactory->newFromUser($user, $pointType);
$icon = Utils::getPointIcon($pointType);
$name = Utils::getPointName($pointType);
$language = MediaWikiServices::getInstance()->getContentLanguage();
$preferences['isekai-userpoints-' . $pointType] = [
'type' => 'text',
'label-raw' => $icon . ' ' . $name,
'default' => $language->formatNum($userPoints->points),
'section' => 'personal/info',
'raw' => true,
];
}
}
}
}

@ -0,0 +1,256 @@
<?php
namespace Isekai\UserPoints\Service;
use stdClass;
use User;
use MediaWiki\MediaWikiServices;
/**
* @property int $user_id
* @property string $last_sign_date
* @property string|array $sign_days_data
* @property int $total_sign_days
*/
class IsekaiUserDailySignData extends stdClass {}
/**
* @property int $userId
* @property string $lastSignDate
* @property array $signDaysData
* @property int $totalSignDays
*/
class IsekaiUserDailySign {
/** @var MediaWikiServices */
private $services;
/** @var User */
private $user;
/** @var array */
private $pointConfig;
/** @var array */
private $dailySignConfig;
private $dataKeysMap = [
'userId' => 'user_id',
'lastSignDate' => 'last_sign_date',
'signDaysData' => 'sign_days_data',
'totalSignDays' => 'total_sign_days'
];
/** @var IsekaiUserDailySignData|null */
private $mDailySignData = null;
private $recordExists = false;
public function __construct(User $user, array $pointConfig, array $dailySignConfig, MediaWikiServices $services) {
$this->user = $user;
$this->pointConfig = $pointConfig;
$this->dailySignConfig = $dailySignConfig;
$this->services = $services;
}
private function loadDailySignData() {
if (!$this->mDailySignData) {
$dbr = $this->services->getDBLoadBalancer()->getConnection(DB_REPLICA);
$this->mDailySignData = $dbr->selectRow(
'isekai_user_daily_sign',
'*',
[
'user_id' => $this->user->getId()
],
__METHOD__
);
if (!$this->mDailySignData) {
$this->initDefaultData();
} else {
$this->mDailySignData->sign_days_data = json_decode($this->mDailySignData->sign_days_data, true) ?? [];
$this->recordExists = true;
}
}
}
private function initDefaultData() {
$this->mDailySignData = new IsekaiUserDailySignData();
$this->mDailySignData->user_id = $this->user->getId();
$this->mDailySignData->last_sign_date = '1970-01-01';
$this->mDailySignData->sign_days_data = [];
$this->mDailySignData->total_sign_days = 0;
}
public function __get($name) {
$this->loadDailySignData();
if (isset($this->dataKeysMap[$name])) {
$key = $this->dataKeysMap[$name];
return $this->mDailySignData->$key;
} else {
throw new \Exception("Invalid property $name");
}
}
public function doSign() {
$this->loadDailySignData();
$pointDelta = [];
$lastSignTime = strtotime($this->mDailySignData->last_sign_date);
if (date('Y-m-d', time()) == date('Y-m-d', $lastSignTime)) { // already signed in today
return [];
}
/** @var IsekaiUserPointsFactory */
$userPointsFactory = $this->services->getService('IsekaiUserPoints');
foreach ($this->dailySignConfig as $pointType => $config) {
if (!isset($this->pointConfig[$pointType])) { // point type not exist, ignore
continue;
}
if (!isset($config['points'])) { // no points config, ignore
continue;
}
$signDays = $this->mDailySignData->sign_days_data[$pointType] ?? 0;
$continuous_reset = $config['continuous_reset'] ?? false;
$expireTime = $config['expire_time'] ?? -1;
$points = $config['points'];
if (!is_array($points)) {
$points = [ $points ];
}
switch ($continuous_reset) {
case 'day':
if (date('Y-m-d', strtotime('-1 day')) != date('Y-m-d', $lastSignTime)) {
$signDays = 0;
}
case 'week':
if (date('Y-W', time()) != date('Y-W', $lastSignTime)) {
$signDays = 0;
}
break;
case 'month':
if (date('Y-m', time()) != date('Y-m', $lastSignTime)) {
$signDays = 0;
}
break;
case 'year':
if (date('Y', time()) != date('Y', $lastSignTime)) {
$signDays = 0;
}
break;
}
$pointsIndex = min($signDays, count($points) - 1);
$pointsAdd = $points[$pointsIndex];
$pointsService = $userPointsFactory->newFromUser($this->user, $pointType);
$pointsService->addPoints($pointsAdd, $expireTime, 'daily_sign', 'sign_in');
$pointDelta[$pointType] = $pointsAdd;
$this->mDailySignData->sign_days_data[$pointType] = $signDays + 1;
}
$this->mDailySignData->last_sign_date = date('Y-m-d H:i:s', time());
$this->mDailySignData->total_sign_days ++;
$this->save();
$this->saveSignLog($pointDelta);
return $pointDelta;
}
private function save() {
$dbw = $this->services->getDBLoadBalancer()->getConnection(DB_PRIMARY);
if ($this->recordExists) {
$dbw->update(
'isekai_user_daily_sign',
[
'last_sign_date' => $this->mDailySignData->last_sign_date,
'sign_days_data' => json_encode($this->mDailySignData->sign_days_data),
'total_sign_days' => $this->mDailySignData->total_sign_days,
],
[
'user_id' => $this->user->getId()
],
__METHOD__
);
} else {
$dbw->insert(
'isekai_user_daily_sign',
[
'user_id' => $this->mDailySignData->user_id,
'last_sign_date' => $this->mDailySignData->last_sign_date,
'sign_days_data' => json_encode($this->mDailySignData->sign_days_data),
'total_sign_days' => $this->mDailySignData->total_sign_days,
],
__METHOD__
);
$this->recordExists = true;
}
}
private function saveSignLog($pointDelta) {
$dbr = $this->services->getDBLoadBalancer()->getConnection(DB_REPLICA);
$dbw = $this->services->getDBLoadBalancer()->getConnection(DB_PRIMARY);
$currentYear = intval(date('Y', time()));
$currentMonth = intval(date('m', time()));
$currentDate = intval(date('d', time()));
$logData = $dbr->selectRow(
'isekai_user_daily_sign_log',
'*',
[
'user_id' => $this->user->getId(),
'year' => $currentYear,
'month' => $currentMonth
],
__METHOD__
);
if ($logData) {
$signLog = json_decode($logData->sign_log, true);
if (!is_array($signLog)) {
$signLog = [];
}
$signLog[] = [
'date' => $currentDate,
'delta' => $pointDelta
];
$logData->sign_log = json_encode($signLog);
$dbw->update(
'isekai_user_daily_sign_log',
[
'sign_log' => $logData->sign_log
],
[
'user_id' => $this->user->getId(),
'year' => $currentYear,
'month' => $currentMonth
],
__METHOD__
);
} else {
$signLog = [[
'date' => $currentDate,
'delta' => $pointDelta
]];
$dbw->insert(
'isekai_user_daily_sign_log',
[
'user_id' => $this->user->getId(),
'year' => $currentYear,
'month' => $currentMonth,
'sign_log' => json_encode($signLog)
],
__METHOD__
);
}
}
}

@ -0,0 +1,62 @@
<?php
namespace Isekai\UserPoints\Service;
use Config;
use User;
use MediaWiki\MediaWikiServices;
class IsekaiUserDailySignFactory {
/** @var MediaWikiServices */
private $services;
/** @var \Config */
private $config;
/** @var array */
private $pointConfig;
/** @var array */
private $dailySignConfig;
private static $instances;
public function __construct(MediaWikiServices $services) {
$this->services = $services;
$this->config = $services->getMainConfig();
$this->pointConfig = $this->config->get('IsekaiUserPointConfig');
$this->dailySignConfig = $this->config->get('IsekaiUserDailySignConfig');
}
/**
* @param User $user
* @return IsekaiUserDailySign|null
*/
public function newFromUser(User $user) {
if (!$this->dailySignConfig) {
throw new \Exception('$wgIsekaiUserDailySignConfig is not configured');
}
if (!$user->isRegistered()) {
return null;
}
$userId = $user->getId();
$cacheKey = $userId;
if (!isset(self::$instances[$cacheKey])) {
self::$instances[$cacheKey] = new IsekaiUserDailySign($user, $this->pointConfig, $this->dailySignConfig, $this->services);
}
return self::$instances[$cacheKey];
}
/**
* @param int $userId
* @return IsekaiUserDailySign|null
*/
public function newFromUserId($userId) {
$user = $this->services->getUserFactory()->newFromId($userId);
if (!$user) {
return null;
}
return $this->newFromUser($user);
}
}

@ -0,0 +1,441 @@
<?php
namespace Isekai\UserPoints\Service;
use User;
use stdClass;
use MediaWiki\MediaWikiServices;
/**
* @property int $user_id
* @property string $type
* @property int $points
* @property string|array $timed_points_data
* @property int $locked_points
* @property string|array $locked_points_data
* @property int $next_refresh_time
*/
class IsekaiUserPointsData extends stdClass {}
/**
* @property int $points
* @property array $timedPointsData
* @property int $lockedPoints
* @property array $lockedPointsData
* @property int $nextRefreshTime
*/
class IsekaiUserPoints {
public const DEFAULT_TRANSACTION_TIMEOUT = 60 * 10;
public const MAX_TRANSACTION_TIMEOUT = 60 * 60;
/** @var MediaWikiServices */
private $services;
/** @var \Config */
private $config;
/** @var User */
public $user;
/** @var string */
public $type;
/** @var string */
public $pointName = '';
/** @var string */
private $rawName = '';
/** @var IsekaiUserPointsData|null */
private $mPointData = null;
/** @var bool */
private $pointRecordExists = false;
private $pointDataKeysMap = [
'points' => 'points',
'timedPointsData' => 'timed_points_data',
'lockedPoints' => 'locked_points',
'lockedPointsData' => 'locked_points_data',
'nextRefreshTime' => 'next_refresh_time'
];
public function __construct(User $user, string $type, array $pointConfig, MediaWikiServices $mediaWikiServices = null) {
if (!$mediaWikiServices) {
$this->services = MediaWikiServices::getInstance();
} else {
$this->services = $mediaWikiServices;
}
$this->config = $this->services->getMainConfig();
$this->user = $user;
$this->type = $type;
if (isset($pointConfig['namemsg'])) {
$this->pointName = wfMessage($pointConfig['namemsg'])->text();
$this->rawName = $pointConfig['name'] ?? $this->pointName;
} elseif (isset($pointConfig['name'])) {
$this->pointName = $this->rawName = $pointConfig['name'];
}
}
public function setData($pointData) {
$this->mPointData = $pointData;
}
private function loadPointData() {
if (!$this->mPointData) {
$dbr = $this->services->getDBLoadBalancer()->getConnection(DB_REPLICA);
$this->mPointData = $dbr->selectRow(
'isekai_user_points',
'*',
[
'user_id' => $this->user->getId(),
'type' => $this->type
],
__METHOD__
);
if (!$this->mPointData) {
$this->initDefaultData();
} else {
$this->mPointData->timed_points_data = json_decode($this->mPointData->timed_points_data, true) ?? [];
$this->mPointData->locked_points_data = json_decode($this->mPointData->locked_points_data, true) ?? [];
$this->pointRecordExists = true;
$this->removeExpiredPoints();
}
}
}
private function initDefaultData() {
$this->mPointData = new IsekaiUserPointsData();
$this->mPointData->user_id = $this->user->getId();
$this->mPointData->type = $this->type;
$this->mPointData->points = 0;
$this->mPointData->timed_points_data = [];
$this->mPointData->locked_points = 0;
$this->mPointData->locked_points_data = [];
$this->mPointData->next_refresh_time = null;
}
/**
* Remove expired points
*/
private function removeExpiredPoints() {
$currentTime = time();
if ($this->mPointData->next_refresh_time !== null &&
$currentTime > $this->mPointData->next_refresh_time &&
is_array($this->mPointData->timed_points_data)) {
$touched = false;
$originalPoints = $this->mPointData->points;
foreach ($this->mPointData->timed_points_data as $key => $data) {
list($expireTime, $points) = $data;
if ($currentTime > $expireTime) {
$this->mPointData->points -= $points;
unset($this->mPointData->timed_points_data[$key]);
$touched = true;
}
}
if ($touched) {
// Update next refresh time
if (count($this->mPointData->timed_points_data) > 0) {
// Sort by expire time
usort($this->mPointData->timed_points_data, function($a, $b) {
return $a[0] - $b[0];
});
$this->mPointData->next_refresh_time = $this->mPointData->timed_points_data[0][0];
} else {
$this->mPointData->next_refresh_time = null;
}
$this->save();
$deltaPoints = $originalPoints - $this->mPointData->points;
$hookContainer = $this->services->getHookContainer();
$hookContainer->run('IsekaiUserPoints::PointsExpired', [$this, $deltaPoints]);
$this->savePointLog(-$deltaPoints, 'system', 'point-expired');
}
}
}
public function __get($name) {
$this->loadPointData();
if (isset($this->pointDataKeysMap[$name])) {
$key = $this->pointDataKeysMap[$name];
return $this->mPointData->$key;
} else {
throw new \Exception("Invalid property $name");
}
}
/**
* Add user points
* @param int $points Points to add
* @param int $expireTime Expire time in seconds. -1 for never expire
* @param string $service Service name that add points. Message key is `isekai-userpoints-service-$service`.
* @param ?string $action Action name that add points. Message key is `isekai-userpoints-action-$service-$action`.
*/
public function addPoints($points, $expireTime = -1, $service = 'system', $action = null) {
$this->loadPointData();
$hookContainer = MediaWikiServices::getInstance()->getHookContainer();
$hookContainer->run('IsekaiUserPoints::BeforeAddPoints', [$this, &$points, &$expireTime, &$service, &$action]);
$this->mPointData->points += $points;
// Handle expire time
$expireTime = intval($expireTime);
$resolvedExipreTime = null;
if ($expireTime > 0) {
// Quantify expire time to 1 day
$expireTime = ceil($expireTime / 86400) * 86400;
$resolvedExipreTime = time() + $expireTime;
$recordExists = false;
foreach ($this->mPointData->timed_points_data as $key => $data) {
$oneExpireTime = $data[0];
if ($resolvedExipreTime === $oneExpireTime) {
$this->mPointData->timed_points_data[$key][1] += $points;
$recordExists = true;
break;
}
}
if (!$recordExists) {
$this->mPointData->timed_points_data[] = [$resolvedExipreTime, $points];
}
if ($this->mPointData->next_refresh_time === null) {
$this->mPointData->next_refresh_time = $resolvedExipreTime;
}
}
/** @todo Add point log */
$this->save();
$this->savePointLog($points, $service, $action, $resolvedExipreTime);
$hookContainer->run('IsekaiUserPoints::AfterAddPoints', [$this, $points, $expireTime, $service, $action]);
}
/**
* Detect if the user has enough points to consume
* @param int $points Points to consume
*/
public function hasEnoughPoints($points) {
$this->loadPointData();
return $this->mPointData->points >= $points;
}
/**
* Consume points
* @param int $points Points to consume
* @param bool $force Force consume points even if the user has not enough points
* @param bool $ignoreSave Ignore save points data after consume points
* @param string $service Service name that consume points. Message key is `isekai-userpoints-service-$service`.
* @param ?string $action Action name that consume points. Message key is `isekai-userpoints-action-$service-$action`.
* @return bool True if the user has enough points to consume
*/
public function consumePoints($points, $force = false, $ignoreSave = false, $service = 'system', $action = null) {
$this->loadPointData();
$hookContainer = MediaWikiServices::getInstance()->getHookContainer();
$hookContainer->run('IsekaiUserPoints::BeforeConsumePoints', [$this, &$points, &$service, &$action]);
if (!$this->hasEnoughPoints($points) && !$force) {
return false;
}
$this->mPointData->points = max(0, $this->mPointData->points - $points);
if ($this->mPointData->next_refresh_time !== null) { // Update timed points data
$consumedPoints = $points;
$touched = false;
foreach ($this->mPointData->timed_points_data as $key => $data) {
list($expireTime, $onePoints) = $data;
if ($onePoints <= $consumedPoints) {
$consumedPoints -= $onePoints;
unset($this->mPointData->timed_points_data[$key]);
$touched = true;
} else { // All points consumed
$this->mPointData->timed_points_data[$key][1] -= $consumedPoints;
$consumedPoints = 0;
$touched = true;
break;
}
}
if ($touched) {
// Update next refresh time
if (count($this->mPointData->timed_points_data) > 0) {
// Sort by expire time
usort($this->mPointData->timed_points_data, function($a, $b) {
return $a[0] - $b[0];
});
$this->mPointData->next_refresh_time = $this->mPointData->timed_points_data[0][0];
} else {
$this->mPointData->next_refresh_time = null;
}
}
}
if (!$ignoreSave) {
$this->save();
}
$this->savePointLog(-$points, $service, $action);
$hookContainer->run('IsekaiUserPoints::AfterConsumePoints', [$this, $points, $service, $action]);
return true;
}
/**
* Start a consume points transaction
* @param int $points Points to consume
* @param int|null $timeout Transaction timeout in seconds
* @param string $service Service name that consume points. Message key is `isekai-userpoints-service-$service`.
* @param ?string $action Action name that consume points. Message key is `isekai-userpoints-action-$service-$action`.
* @return bool True if the transaction started successfully
*/
public function startConsumePointsTransaction($points, $timeout = null, $service = 'system', $action = null) {
$this->loadPointData();
$hookContainer = MediaWikiServices::getInstance()->getHookContainer();
$hookContainer->run('IsekaiUserPoints::BeforeConsumePoints', [$this, &$points, &$service, &$action]);
if (!$this->hasEnoughPoints($points)) {
return false;
}
$timeout = min($timeout ?? self::DEFAULT_TRANSACTION_TIMEOUT, self::MAX_TRANSACTION_TIMEOUT);
$expireTime = time() + $timeout;
// Generate random string for transaction id
$transactionId = '';
do {
$transactionId = bin2hex(random_bytes(8));
} while (isset($this->mPointData->locked_points_data[$transactionId]));
$result = $this->consumePoints($points, false, true);
if (!$result) return false;
$this->mPointData->locked_points += $points;
$this->mPointData->locked_points_data[$transactionId] = [$expireTime, $points, $service, $action];
$this->save();
return $transactionId;
}
/**
* Commit a consume points transaction
* @param string $transactionId Transaction id
* @return bool True if the transaction is committed successfully
*/
public function commitConsumePointsTransaction($transactionId) {
if (!isset($this->mPointData->locked_points_data[$transactionId])) {
return false;
}
list($expireTime, $points, $service, $action) = $this->mPointData->locked_points_data[$transactionId];
$this->mPointData->locked_points = max(0, $this->mPointData->locked_points - $points);
unset($this->mPointData->locked_points_data[$transactionId]);
$this->save();
$hookContainer = MediaWikiServices::getInstance()->getHookContainer();
$this->savePointLog(-$points, $service, $action);
$hookContainer->run('IsekaiUserPoints::AfterConsumePoints', [$this, $points, $service, $action]);
return true;
}
/**
* Rollback a consume points transaction
* @param string $transactionId Transaction id
* @return bool True if the transaction is rolled back successfully
*/
public function rollbackConsumePointsTransaction($transactionId) {
if (!isset($this->mPointData->locked_points_data[$transactionId])) {
return false;
}
list($expireTime, $points) = $this->mPointData->locked_points_data[$transactionId];
$this->mPointData->locked_points = max(0, $this->mPointData->locked_points - $points);
$this->mPointData->points += $points;
unset($this->mPointData->locked_points_data[$transactionId]);
$this->save();
return true;
}
/**
* Save point data
*/
private function save() {
$dbw = $this->services->getDBLoadBalancer()->getConnection(DB_PRIMARY);
if ($this->pointRecordExists) {
$dbw->update(
'isekai_user_points',
[
'points' => $this->mPointData->points,
'timed_points_data' => json_encode($this->mPointData->timed_points_data),
'locked_points' => $this->mPointData->locked_points,
'locked_points_data' => json_encode($this->mPointData->locked_points_data),
'next_refresh_time' => $this->mPointData->next_refresh_time
],
[
'user_id' => $this->user->getId(),
'type' => $this->type,
],
__METHOD__
);
} else {
$dbw->insert(
'isekai_user_points',
[
'user_id' => $this->mPointData->user_id,
'type' => $this->mPointData->type,
'points' => $this->mPointData->points,
'timed_points_data' => json_encode($this->mPointData->timed_points_data),
'locked_points' => $this->mPointData->locked_points,
'locked_points_data' => json_encode($this->mPointData->locked_points_data),
'next_refresh_time' => $this->mPointData->next_refresh_time,
],
__METHOD__
);
$this->pointRecordExists = true;
}
}
private function savePointLog($points, $service, $action, $expireTime = null) {
$dbw = $this->services->getDBLoadBalancer()->getConnection(DB_PRIMARY);
if ($expireTime) {
$expireDate = date('Y-m-d', $expireTime);
} else {
$expireDate = null;
}
$dbw->insert(
'isekai_user_points_log',
[
'user_id' => $this->user->getId(),
'point_type' => $this->type,
'points' => $points,
'point_expire' => $expireDate,
'service' => $service,
'action' => $action,
'timestamp' => wfTimestampNow()
],
__METHOD__
);
}
}

@ -0,0 +1,114 @@
<?php
namespace Isekai\UserPoints\Service;
use MediaWiki\MediaWikiServices;
use User;
class IsekaiUserPointsFactory {
/** @var MediaWikiServices */
private $services;
/** @var \Config */
private $config;
/** @var array */
private $pointConfig;
private static $instances;
public function __construct(MediaWikiServices $services) {
$this->services = $services;
$this->config = $services->getMainConfig();
$this->pointConfig = $this->config->get('IsekaiUserPointConfig');
}
/**
* @param int $userId
* @param string $pointType
* @return IsekaiUserPoints|null
*/
public function newFromUserId(int $userId, string $pointType) {
$user = $this->services->getUserFactory()->newFromId($userId);
return $this->newFromUser($user, $pointType);
}
/**
* @param User $user
* @return IsekaiUserPoints|null
*/
public function newFromUser(User $user, string $pointType) {
if (!isset($this->pointConfig[$pointType])) {
return null;
}
if (!$user->isRegistered()) {
return null;
}
$userId = $user->getId();
$cacheKey = $userId . ':' . $pointType;
if (!isset(self::$instances[$cacheKey])) {
self::$instances[$cacheKey] = new IsekaiUserPoints($user, $pointType, $this->pointConfig, $this->services);
}
return self::$instances[$cacheKey];
}
/**
* @param User $user
* @param string $pointType
* @param stdClass $data
*/
public function newFromData(User $user, string $pointType, $data) {
$instance = $this->newFromUser($user, $pointType);
if ($instance) {
$instance->setData($data);
}
return $instance;
}
/**
* @return array{0: User, 1: string, 2: IsekaiUserPoints} (user, instance)
*/
public function newFromUsers(array $users, array $pointTypes) {
$userids = [];
$usermap = [];
foreach ($users as $user) {
$userId = $user->getId();
$userids[] = $userId;
$usermap[$userId] = $user;
}
$dbr = $this->services->getDBLoadBalancer()->getConnection(DB_REPLICA);
$pointDataRows = $dbr->select(
'isekai_user_points',
'*',
[ 'user_id' => $userids, 'point_type' => $pointTypes ],
__METHOD__
);
$result = [];
$initedUsers = [];
foreach ($pointDataRows as $pointData) {
$user = $usermap[$pointData->user_id];
$pointType = $pointData->type;
$instance = $this->newFromUser($user, $pointType);
if ($instance) {
$instance->setData($pointData);
}
$result[] = [$user, $instance];
$initedUsers[] = $user;
}
foreach ($userids as $userId) {
if (!in_array($userId, $initedUsers)) {
$user = $usermap[$userId];
$pointType = $pointData->type;
$instance = $this->newFromUser($user, $pointType);
$result[] = [$user, $pointType, $instance];
}
}
return $result;
}
}

@ -0,0 +1,71 @@
<?php
namespace Isekai\UserPoints;
use Html;
use MediaWiki\MediaWikiServices;
class Utils {
public static function getPointName($pointType) {
$pointConfig = MediaWikiServices::getInstance()->getMainConfig()->get('IsekaiUserPointConfig');
if (!isset($pointConfig[$pointType])) {
return '{' . $pointType . '}';
} else {
$currentConfig = $pointConfig[$pointType];
if (isset($currentConfig['namemsg'])) {
return wfMessage($currentConfig['namemsg'])->text();
} else {
return $currentConfig['name'];
}
}
}
public static function getPointIcon($pointType) {
$pointConfig = MediaWikiServices::getInstance()->getMainConfig()->get('IsekaiUserPointConfig');
if (!isset($pointConfig[$pointType])) {
return '';
}
$currentConfig = $pointConfig[$pointType];
if (!isset($currentConfig['icon']) || !isset($currentConfig['icon']['normal'])) {
return '';
}
$iconDom = [];
foreach(['normal', 'invert'] as $iconType) {
if (!isset($currentConfig['icon'][$iconType])) {
continue;
}
$iconConfig = $currentConfig['icon'][$iconType] ?? $currentConfig['icon']['normal'];
$className = $iconConfig['class'] ?? [];
if (is_string($className)) {
$className = explode(' ', $className);
}
$className[] = 'isekai-point-icon-' . $iconType;
if (isset($iconConfig['image'])) {
$iconDom[] = Html::element('img', [
'src' => $iconConfig['image'],
'class' => implode(' ', $className),
]);
} elseif (isset($iconConfig['html'])) {
$iconDom[] = Html::rawElement('span', [
'class' => implode(' ', $className),
], $iconConfig['html']);
} else {
$text = $iconConfig['text'] ?? '';
$iconDom[] = Html::element('span', [
'class' => implode(' ', $className),
], $text);
}
}
return Html::rawElement('span', [
'class' => "isekai-point-icon isekai-point-icon-type-$pointType"
], implode('', $iconDom));
}
}

@ -0,0 +1,10 @@
.isekai-point-icon {
> .isekai-point-icon-invert {
display: none;
}
> img {
width: 20px;
height: 20px;
}
}

@ -0,0 +1,43 @@
$(function() {
const storeKey = 'isekai-userpoints-dailysign-lastSignDate';
const lastSignDate = localStorage.getItem(storeKey);
const today = new Date().toLocaleDateString();
if (lastSignDate !== today) {
let mwApi = new mw.Api();
mwApi.postWithToken('csrf', {
action: 'userdailysign',
}).done(function(data) {
if (data.userdailysign && data.userdailysign.success) {
if (Array.isArray(data.userdailysign.point_delta)) {
const pointDelta = data.userdailysign.point_delta;
let pointMsgList = [];
pointDelta.forEach(function (pointDeltaInfo) {
let msg = mw.msg('isekai-userpoints-point-name-num', pointDeltaInfo.name, pointDeltaInfo.icon, pointDeltaInfo.points);
pointMsgList.push(msg);
});
let separator = mw.msg('comma-separator');
let pointMsg = pointMsgList.join(separator);
let notificationMsg = mw.msg('isekai-userpoints-dailysign-notify-success', pointMsg);
mw.notify('', {
title: mw.msg('isekai-userpoints-dailysign-notify-title'),
tag: 'isekai-userpoints-dailysign',
id: 'isekai-userpoints-dailysign-notify',
});
function changeNotifyContent() {
let notifyDom = document.querySelector('#isekai-userpoints-dailysign-notify');
if (notifyDom) {
notifyDom.querySelector('.mw-notification-content').innerHTML = notificationMsg;
} else {
requestAnimationFrame(changeNotifyContent);
}
}
changeNotifyContent();
}
localStorage.setItem(storeKey, today);
}
});
}
});

@ -0,0 +1,7 @@
CREATE TABLE /*_*/isekai_user_daily_sign (
`user_id` INT UNSIGNED NOT NULL,
`last_sign_date` DATE NOT NULL,
`sign_days_data` LONGTEXT NOT NULL,
`total_sign_days` INT UNSIGNED NOT NULL DEFAULT '0'
) /*$wgDBTableOptions*/;
ALTER TABLE /*_*/isekai_user_daily_sign ADD PRIMARY KEY (`user_id`);

@ -0,0 +1,10 @@
CREATE TABLE /*_*/isekai_user_daily_sign_log (
`user_id` INT UNSIGNED NOT NULL,
`year` INT UNSIGNED NOT NULL,
`month` INT UNSIGNED NOT NULL,
`sign_log` LONGTEXT NOT NULL
) /*$wgDBTableOptions*/;
ALTER TABLE /*_*/isekai_user_daily_sign_log ADD PRIMARY KEY (`user_id`);
ALTER TABLE /*_*/isekai_user_daily_sign_log ADD INDEX(`user_id`, `year`, `month`);
ALTER TABLE /*_*/isekai_user_daily_sign_log ADD INDEX(`year`);
ALTER TABLE /*_*/isekai_user_daily_sign_log ADD INDEX(`month`);

@ -0,0 +1,14 @@
CREATE TABLE /*_*/isekai_user_points (
`user_id` INT UNSIGNED NOT NULL,
`type` VARCHAR(20) NOT NULL,
`points` INT UNSIGNED NOT NULL DEFAULT '0',
`timed_points_data` LONGTEXT NOT NULL,
`locked_points` INT UNSIGNED NOT NULL DEFAULT '0',
`locked_points_data` LONGTEXT NOT NULL,
`next_refresh_time` BIGINT UNSIGNED NULL
) /*$wgDBTableOptions*/;
ALTER TABLE /*_*/isekai_user_points ADD PRIMARY KEY (`user_id`, `type`);
ALTER TABLE /*_*/isekai_user_points ADD INDEX(`user_id`);
ALTER TABLE /*_*/isekai_user_points ADD INDEX(`type`);
ALTER TABLE /*_*/isekai_user_points ADD INDEX(`points`);
ALTER TABLE /*_*/isekai_user_points ADD INDEX(`next_refresh_time`);

@ -0,0 +1,15 @@
CREATE TABLE /*_*/isekai_user_points_log (
`id` INT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT,
`user_id` INT UNSIGNED NOT NULL,
`point_type` VARCHAR(20) NOT NULL,
`points` INT NOT NULL DEFAULT '0',
`point_expire` DATE NULL,
`service` VARCHAR(255) NOT NULL,
`action` VARCHAR(255) NOT NULL,
`timestamp` BIGINT UNSIGNED NOT NULL
) /*$wgDBTableOptions*/;
ALTER TABLE /*_*/isekai_user_points_log ADD INDEX(`user_id`, `point_type`);
ALTER TABLE /*_*/isekai_user_points_log ADD INDEX(`user_id`);
ALTER TABLE /*_*/isekai_user_points_log ADD INDEX(`service`);
ALTER TABLE /*_*/isekai_user_points_log ADD INDEX(`action`);
ALTER TABLE /*_*/isekai_user_points_log ADD INDEX(`timestamp`);

@ -0,0 +1,7 @@
CREATE TABLE isekai_user_daily_sign (
user_id INT NOT NULL,
last_sign_date DATE NOT NULL,
sign_days_data JSON NOT NULL,
total_sign_days INT NOT NULL DEFAULT 0
);
ALTER TABLE isekai_user_daily_sign ADD PRIMARY KEY (user_id);

@ -0,0 +1,10 @@
CREATE TABLE isekai_user_daily_sign_log (
user_id INT NOT NULL,
year INT NOT NULL,
month INT NOT NULL,
sign_log JSON NOT NULL
);
ALTER TABLE isekai_user_daily_sign_log ADD PRIMARY KEY (user_id);
ALTER TABLE isekai_user_daily_sign_log ADD INDEX(user_id, year, month);
ALTER TABLE isekai_user_daily_sign_log ADD INDEX(year);
ALTER TABLE isekai_user_daily_sign_log ADD INDEX(month);

@ -0,0 +1,17 @@
-- Create table for postgres
CREATE TABLE isekai_user_points (
user_id INTEGER NOT NULL,
type VARCHAR(20) NOT NULL,
points INTEGER NOT NULL DEFAULT 0,
timed_points_data TEXT NOT NULL,
locked_points INTEGER NOT NULL DEFAULT 0,
locked_points_data TEXT NOT NULL,
next_refresh_time BIGINT NULL
);
ALTER TABLE isekai_user_points ADD PRIMARY KEY (user_id, type);
ALTER TABLE isekai_user_points ADD INDEX (user_id);
ALTER TABLE isekai_user_points ADD INDEX (type);
ALTER TABLE isekai_user_points ADD INDEX (points);
ALTER TABLE isekai_user_points ADD INDEX (timed_points_data);
ALTER TABLE isekai_user_points ADD INDEX (next_refresh_time);

@ -0,0 +1,15 @@
CREATE TABLE isekai_user_points_log (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL,
point_type VARCHAR(20) NOT NULL,
points INT NOT NULL DEFAULT 0,
point_expire DATE NULL,
service VARCHAR(255) NOT NULL,
action VARCHAR(255) NOT NULL,
timestamp BIGINT NOT NULL
);
ALTER TABLE isekai_user_points_log ADD INDEX(user_id, point_type);
ALTER TABLE isekai_user_points_log ADD INDEX(user_id);
ALTER TABLE isekai_user_points_log ADD INDEX(service);
ALTER TABLE isekai_user_points_log ADD INDEX(action);
ALTER TABLE isekai_user_points_log ADD INDEX(timestamp);
Loading…
Cancel
Save