[积分商城]优化对接API

This commit is contained in:
2026-03-30 11:47:32 +08:00
parent b30ef21780
commit 4a42899bfe
55 changed files with 835 additions and 1241 deletions

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

@@ -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',
];
$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

@@ -45,8 +45,8 @@ class MallAddress extends Model
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\MallPlayxUserAsset::class, 'playx_user_asset_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\MallPlayxUserAsset::class, 'playx_user_asset_id', 'id');
}
}

View File

@@ -4,10 +4,11 @@ declare(strict_types=1);
namespace app\common\model;
use ba\Random;
use support\think\Model;
/**
* PlayX 用户资产
* PlayX 用户资产(积分商城用户主表,含登录账号字段)
*/
class MallPlayxUserAsset extends Model
{
@@ -16,11 +17,72 @@ class MallPlayxUserAsset extends Model
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',
'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 = self::allocateUniquePhone();
if ($phone === null) {
throw new \RuntimeException('Failed to allocate unique phone');
}
$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_playx_user_asset');
}
$id = intval($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;
}
private static function allocateUniquePhone(): ?string
{
for ($i = 0; $i < 8; $i++) {
$candidate = '13' . str_pad(strval(mt_rand(0, 999999999)), 9, '0', STR_PAD_LEFT);
if (!self::where('phone', $candidate)->find()) {
return $candidate;
}
}
return null;
}
}

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\MallPlayxUserAsset::class, 'playx_user_asset_id', 'id');
}
public function mallItem(): \think\model\relation\BelongsTo

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

@@ -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'],
];
}