Compare commits

7 Commits

Author SHA1 Message Date
e5f83846b3 将项目中所有total_draw_count字段重构为total_ticket_count字段
将项目中所有paid_draw_count字段重构为paid_ticket_count字段
将项目中所有free_draw_count字段重构为free_ticket_count字段
2026-03-05 14:15:32 +08:00
5ab16243bd 重新设计状态码规范 2026-03-05 13:44:56 +08:00
8d8cee696f 重新登录或注册后清除掉原有用户的user-token保证只有一个用户能够登录 2026-03-05 12:25:59 +08:00
74612f136e 相同的设备标识dice只保证一个auth-token生效,清除掉多余的同一个dice多余的auth-token 2026-03-05 12:22:41 +08:00
13d8adbfe0 添加authToken和userToken 2026-03-05 12:17:20 +08:00
a10afa5add 优化获取平台币接口coin返回null的问题 2026-03-04 20:17:40 +08:00
2cfbe0c80c 修复打包失败问题 2026-03-04 20:12:23 +08:00
30 changed files with 592 additions and 366 deletions

View File

@@ -169,9 +169,9 @@
{ prop: 't3_wight', label: 'T3池权重', width: 100, formatter: weightFormatter('t3_wight') }, { prop: 't3_wight', label: 'T3池权重', width: 100, formatter: weightFormatter('t3_wight') },
{ prop: 't4_wight', label: 'T4池权重', width: 100, formatter: weightFormatter('t4_wight') }, { prop: 't4_wight', label: 'T4池权重', width: 100, formatter: weightFormatter('t4_wight') },
{ prop: 't5_wight', label: 'T5池权重', width: 100, formatter: weightFormatter('t5_wight') }, { prop: 't5_wight', label: 'T5池权重', width: 100, formatter: weightFormatter('t5_wight') },
{ prop: 'total_draw_count', label: '总抽奖次数' }, { prop: 'total_ticket_count', label: '总抽奖次数' },
{ prop: 'paid_draw_count', label: '购买抽奖次数' }, { prop: 'paid_ticket_count', label: '购买抽奖次数' },
{ prop: 'free_draw_count', label: '赠送抽奖次数' }, { prop: 'free_ticket_count', label: '赠送抽奖次数' },
{ prop: 'created_at', label: '创建时间' }, { prop: 'created_at', label: '创建时间' },
{ prop: 'updated_at', label: '更新时间' }, { prop: 'updated_at', label: '更新时间' },
{ prop: 'operation', label: '操作', width: 120, fixed: 'right', useSlot: true } { prop: 'operation', label: '操作', width: 120, fixed: 'right', useSlot: true }
@@ -206,10 +206,15 @@
// 钱包操作弹窗(从平台币 tag 点击打开) // 钱包操作弹窗(从平台币 tag 点击打开)
const walletDialogVisible = ref(false) const walletDialogVisible = ref(false)
const walletOperatePlayer = ref<Record<string, any> | null>(null) type WalletPlayer = { id: number; username?: string; coin?: number }
const walletOperatePlayer = ref<WalletPlayer | null>(null)
function openWalletOperate(row: Record<string, any>) { function openWalletOperate(row: Record<string, any>) {
walletOperatePlayer.value = row walletOperatePlayer.value = {
id: Number(row.id),
username: row.username,
coin: row.coin != null ? Number(row.coin) : undefined
}
walletDialogVisible.value = true walletDialogVisible.value = true
} }
</script> </script>

View File

@@ -92,7 +92,7 @@
const walletBalance = computed(() => { const walletBalance = computed(() => {
const c = props.player?.coin const c = props.player?.coin
return c != null && c !== '' ? Number(c) : 0 return c != null ? Number(c) : 0
}) })
const rules = reactive<FormRules>({ const rules = reactive<FormRules>({

View File

@@ -83,12 +83,12 @@
username: undefined, username: undefined,
use_coins_min: undefined, use_coins_min: undefined,
use_coins_max: undefined, use_coins_max: undefined,
total_draw_count_min: undefined, total_ticket_count_min: undefined,
total_draw_count_max: undefined, total_ticket_count_max: undefined,
paid_draw_count_min: undefined, paid_ticket_count_min: undefined,
paid_draw_count_max: undefined, paid_ticket_count_max: undefined,
free_draw_count_min: undefined, free_ticket_count_min: undefined,
free_draw_count_max: undefined, free_ticket_count_max: undefined,
create_time_min: undefined, create_time_min: undefined,
create_time_max: undefined, create_time_max: undefined,
create_time: undefined as [string, string] | undefined create_time: undefined as [string, string] | undefined
@@ -131,9 +131,9 @@
{ prop: 'id', label: 'ID', width: 80 }, { prop: 'id', label: 'ID', width: 80 },
{ prop: 'player_id', label: '玩家用户名', formatter: (row: Record<string, any>) => usernameFormatter(row) }, { prop: 'player_id', label: '玩家用户名', formatter: (row: Record<string, any>) => usernameFormatter(row) },
{ prop: 'use_coins', label: '消耗硬币' }, { prop: 'use_coins', label: '消耗硬币' },
{ prop: 'total_draw_count', label: '总抽奖次数' }, { prop: 'total_ticket_count', label: '总抽奖次数' },
{ prop: 'paid_draw_count', label: '购买抽奖次数' }, { prop: 'paid_ticket_count', label: '购买抽奖次数' },
{ prop: 'free_draw_count', label: '赠送抽奖次数' }, { prop: 'free_ticket_count', label: '赠送抽奖次数' },
{ prop: 'remark', label: '备注', width: 100, showOverflowTooltip: true }, { prop: 'remark', label: '备注', width: 100, showOverflowTooltip: true },
{ prop: 'create_time', label: '创建时间', width: 170 }, { prop: 'create_time', label: '创建时间', width: 170 },
{ prop: 'operation', label: '操作', width: 100, fixed: 'right', useSlot: true } { prop: 'operation', label: '操作', width: 100, fixed: 'right', useSlot: true }

View File

@@ -28,25 +28,25 @@
<el-form-item label="消耗硬币" prop="use_coins"> <el-form-item label="消耗硬币" prop="use_coins">
<el-input-number v-model="formData.use_coins" placeholder="请输入消耗硬币" :min="0" /> <el-input-number v-model="formData.use_coins" placeholder="请输入消耗硬币" :min="0" />
</el-form-item> </el-form-item>
<el-form-item label="购买抽奖次数" prop="paid_draw_count"> <el-form-item label="购买抽奖次数" prop="paid_ticket_count">
<el-input-number <el-input-number
v-model="formData.paid_draw_count" v-model="formData.paid_ticket_count"
placeholder="请输入购买抽奖次数" placeholder="请输入购买抽奖次数"
:min="0" :min="0"
@change="onDrawCountChange" @change="onTicketCountChange"
/> />
</el-form-item> </el-form-item>
<el-form-item label="赠送抽奖次数" prop="free_draw_count"> <el-form-item label="赠送抽奖次数" prop="free_ticket_count">
<el-input-number <el-input-number
v-model="formData.free_draw_count" v-model="formData.free_ticket_count"
placeholder="请输入赠送抽奖次数" placeholder="请输入赠送抽奖次数"
:min="0" :min="0"
@change="onDrawCountChange" @change="onTicketCountChange"
/> />
</el-form-item> </el-form-item>
<el-form-item label="总抽奖次数" prop="total_draw_count"> <el-form-item label="总抽奖次数" prop="total_ticket_count">
<el-input-number <el-input-number
:model-value="totalDrawCountComputed" :model-value="totalTicketCountComputed"
placeholder="自动求和" placeholder="自动求和"
:min="0" :min="0"
disabled disabled
@@ -110,23 +110,23 @@
const rules = reactive<FormRules>({ const rules = reactive<FormRules>({
player_id: [{ required: true, message: '请选择玩家', trigger: 'change' }], player_id: [{ required: true, message: '请选择玩家', trigger: 'change' }],
use_coins: [{ required: true, message: '消耗硬币必需填写', trigger: 'blur' }], use_coins: [{ required: true, message: '消耗硬币必需填写', trigger: 'blur' }],
paid_draw_count: [{ required: true, message: '购买抽奖次数必需填写', trigger: 'blur' }], paid_ticket_count: [{ required: true, message: '购买抽奖次数必需填写', trigger: 'blur' }],
free_draw_count: [{ required: true, message: '赠送抽奖次数必需填写', trigger: 'blur' }], free_ticket_count: [{ required: true, message: '赠送抽奖次数必需填写', trigger: 'blur' }],
remark: [{ required: true, message: '备注必需填写', trigger: 'blur' }] remark: [{ required: true, message: '备注必需填写', trigger: 'blur' }]
}) })
/** 玩家下拉选项id、username */ /** 玩家下拉选项id、username */
const playerOptions = ref<Array<{ id: number; username: string }>>([]) const playerOptions = ref<Array<{ id: number; username: string }>>([])
/** 总抽奖次数 = 购买抽奖次数 + 赠送抽奖次数(只读展示) */ /** total_ticket_count = paid_ticket_count + free_ticket_count(只读展示) */
const totalDrawCountComputed = computed(() => { const totalTicketCountComputed = computed(() => {
const paid = Number(formData.paid_draw_count) || 0 const paid = Number(formData.paid_ticket_count) || 0
const free = Number(formData.free_draw_count) || 0 const free = Number(formData.free_ticket_count) || 0
return paid + free return paid + free
}) })
function onDrawCountChange() { function onTicketCountChange() {
formData.total_draw_count = totalDrawCountComputed.value formData.total_ticket_count = totalTicketCountComputed.value
} }
/** /**
@@ -136,9 +136,9 @@
id: null, id: null,
player_id: null, player_id: null,
use_coins: null as number | null, use_coins: null as number | null,
total_draw_count: null as number | null, total_ticket_count: null as number | null,
paid_draw_count: null as number | null, paid_ticket_count: null as number | null,
free_draw_count: null as number | null, free_ticket_count: null as number | null,
remark: '' remark: ''
} }
@@ -188,9 +188,9 @@
'id', 'id',
'player_id', 'player_id',
'use_coins', 'use_coins',
'total_draw_count', 'total_ticket_count',
'paid_draw_count', 'paid_ticket_count',
'free_draw_count', 'free_ticket_count',
'remark' 'remark'
] ]
keys.forEach((key) => { keys.forEach((key) => {
@@ -210,12 +210,12 @@
} }
/** /**
* 提交表单(总抽奖次数由购买+赠送自动求和,提交前写入) * 提交表单(total_ticket_count 由 paid_ticket_count + free_ticket_count 自动求和,提交前写入)
*/ */
const handleSubmit = async () => { const handleSubmit = async () => {
if (!formRef.value) return if (!formRef.value) return
try { try {
formData.total_draw_count = totalDrawCountComputed.value formData.total_ticket_count = totalTicketCountComputed.value
await formRef.value.validate() await formRef.value.validate()
if (props.dialogType === 'add') { if (props.dialogType === 'add') {
const rest = { ...formData } as Record<string, unknown> const rest = { ...formData } as Record<string, unknown>

View File

@@ -35,10 +35,10 @@
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col v-bind="setSpan(6)"> <el-col v-bind="setSpan(6)">
<el-form-item label="总抽奖次数" prop="total_draw_count_min"> <el-form-item label="总抽奖次数" prop="total_ticket_count_min">
<div class="range-wrap"> <div class="range-wrap">
<el-input-number <el-input-number
v-model="formData.total_draw_count_min" v-model="formData.total_ticket_count_min"
placeholder="最小" placeholder="最小"
:min="0" :min="0"
controls-position="right" controls-position="right"
@@ -46,7 +46,7 @@
/> />
<span class="range-sep"></span> <span class="range-sep"></span>
<el-input-number <el-input-number
v-model="formData.total_draw_count_max" v-model="formData.total_ticket_count_max"
placeholder="最大" placeholder="最大"
:min="0" :min="0"
controls-position="right" controls-position="right"
@@ -56,10 +56,10 @@
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col v-bind="setSpan(6)"> <el-col v-bind="setSpan(6)">
<el-form-item label="购买抽奖次数" prop="paid_draw_count_min"> <el-form-item label="购买抽奖次数" prop="paid_ticket_count_min">
<div class="range-wrap"> <div class="range-wrap">
<el-input-number <el-input-number
v-model="formData.paid_draw_count_min" v-model="formData.paid_ticket_count_min"
placeholder="最小" placeholder="最小"
:min="0" :min="0"
controls-position="right" controls-position="right"
@@ -67,7 +67,7 @@
/> />
<span class="range-sep"></span> <span class="range-sep"></span>
<el-input-number <el-input-number
v-model="formData.paid_draw_count_max" v-model="formData.paid_ticket_count_max"
placeholder="最大" placeholder="最大"
:min="0" :min="0"
controls-position="right" controls-position="right"
@@ -77,10 +77,10 @@
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col v-bind="setSpan(6)"> <el-col v-bind="setSpan(6)">
<el-form-item label="赠送抽奖次数" prop="free_draw_count_min"> <el-form-item label="赠送抽奖次数" prop="free_ticket_count_min">
<div class="range-wrap"> <div class="range-wrap">
<el-input-number <el-input-number
v-model="formData.free_draw_count_min" v-model="formData.free_ticket_count_min"
placeholder="最小" placeholder="最小"
:min="0" :min="0"
controls-position="right" controls-position="right"
@@ -88,7 +88,7 @@
/> />
<span class="range-sep"></span> <span class="range-sep"></span>
<el-input-number <el-input-number
v-model="formData.free_draw_count_max" v-model="formData.free_ticket_count_max"
placeholder="最大" placeholder="最大"
:min="0" :min="0"
controls-position="right" controls-position="right"

View File

@@ -196,9 +196,9 @@
align: 'center', align: 'center',
showOverflowTooltip: true showOverflowTooltip: true
}, },
{ prop: 'total_draw_count', label: '总抽奖次数', align: 'center' }, { prop: 'total_ticket_count', label: '总抽奖次数', align: 'center' },
{ prop: 'paid_draw_count', label: '购买抽奖次数', align: 'center' }, { prop: 'paid_ticket_count', label: '购买抽奖次数', align: 'center' },
{ prop: 'free_draw_count', label: '赠送抽奖次数', align: 'center' }, { prop: 'free_ticket_count', label: '赠送抽奖次数', align: 'center' },
{ prop: 'create_time', label: '创建时间', width: 170, align: 'center' }, { prop: 'create_time', label: '创建时间', width: 170, align: 'center' },
{ {
prop: 'operation', prop: 'operation',

View File

@@ -17,10 +17,14 @@ REDIS_PASSWORD = ''
REDIS_DB = 0 REDIS_DB = 0
# API 鉴权与用户(可选,不填则用默认值) # API 鉴权与用户(可选,不填则用默认值)
# API_AUTH_TOKEN_EXP = 86400 # authToken 签名密钥(必填,与客户端约定,用于 signature 校验)
API_AUTH_TOKEN_SECRET = xF75oK91TQj13s0UmNIr1NBWMWGfflNO
# authToken 时间戳允许误差秒数,防重放,默认 300
API_AUTH_TOKEN_TIME_TOLERANCE = 300
API_AUTH_TOKEN_EXP = 86400
# API_USER_TOKEN_EXP = 604800 # API_USER_TOKEN_EXP = 604800
# API_USER_CACHE_EXPIRE = 604800 API_USER_CACHE_EXPIRE = 86400
# API_USER_ENCRYPT_KEY = dafuweng_api_user_cache_key_32 API_USER_ENCRYPT_KEY = Wj818SK8dhKBKNOY3PUTmZfhQDMCXEZi
# 验证码配置,支持cache|session # 验证码配置,支持cache|session
CAPTCHA_MODE = cache CAPTCHA_MODE = cache

54
server/app/api/cache/AuthTokenCache.php vendored Normal file
View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace app\api\cache;
use support\think\Cache;
/**
* 按设备标识存储当前有效的 auth-token同一设备只保留最新一个旧 token 自动失效
*/
class AuthTokenCache
{
private static function prefix(): string
{
return config('api.auth_token_device_prefix', 'api:auth_token:');
}
/**
* 设置该设备当前有效的 auth-token会覆盖同设备之前的 token使旧 token 失效)
* @param string $device 设备标识,如 dice
* @param string $token 完整 auth-token 字符串
* @param int $ttl 过期时间(秒),应与 auth_token_exp 一致
*/
public static function setDeviceToken(string $device, string $token, int $ttl): bool
{
if ($device === '' || $ttl <= 0) {
return false;
}
$key = self::prefix() . $device;
return Cache::set($key, $token, $ttl);
}
/**
* 获取该设备当前有效的 auth-token不存在或已过期返回 null
*/
public static function getDeviceToken(string $device): ?string
{
if ($device === '') {
return null;
}
$key = self::prefix() . $device;
$value = Cache::get($key);
return $value !== null && $value !== '' ? (string) $value : null;
}
/**
* 校验请求中的 token 是否为该设备当前唯一有效 token
*/
public static function isCurrentToken(string $device, string $token): bool
{
$current = self::getDeviceToken($device);
return $current !== null && $current === $token;
}
}

View File

@@ -131,4 +131,51 @@ class UserCache
$val = Cache::get($key); $val = Cache::get($key);
return $val !== null && $val !== ''; return $val !== null && $val !== '';
} }
/** 当前有效 user-token 按用户存储的 key 前缀(重新登录/注册后覆盖,保证单用户单 token */
private static function currentTokenPrefix(): string
{
return config('api.user_token_current_prefix', 'api:user:current_token:');
}
private static function userTokenExpire(): int
{
return (int) config('api.user_token_exp', 604800);
}
/**
* 设置该用户当前唯一有效的 user-token登录/注册时调用,会覆盖该用户之前的 token
* @param int $userId 用户 ID
* @param string $token 完整 user-token 字符串
*/
public static function setCurrentUserToken(int $userId, string $token): bool
{
if ($userId <= 0 || $token === '') {
return false;
}
$key = self::currentTokenPrefix() . $userId;
return Cache::set($key, $token, self::userTokenExpire());
}
/**
* 获取该用户当前在服务端登记的有效 user-token不存在或已过期返回 null
*/
public static function getCurrentUserToken(int $userId): ?string
{
if ($userId <= 0) {
return null;
}
$key = self::currentTokenPrefix() . $userId;
$value = Cache::get($key);
return $value !== null && $value !== '' ? (string) $value : null;
}
/**
* 校验请求中的 token 是否为该用户当前唯一有效 token
*/
public static function isCurrentUserToken(int $userId, string $token): bool
{
$current = self::getCurrentUserToken($userId);
return $current !== null && $current === $token;
}
} }

View File

@@ -7,29 +7,84 @@ use support\Request;
use support\Response; use support\Response;
use Tinywan\Jwt\JwtToken; use Tinywan\Jwt\JwtToken;
use plugin\saiadmin\basic\OpenController; use plugin\saiadmin\basic\OpenController;
use app\api\util\ReturnCode;
use app\api\cache\AuthTokenCache;
/** /**
* API 鉴权 Token 接口 * API 鉴权 Token 接口
* 仅支持 GET必传参数signature、secret、device、time签名规则signature = md5(device . secret . time)
* 后续所有 /api 接口调用均需在请求头携带此接口返回的 auth-token * 后续所有 /api 接口调用均需在请求头携带此接口返回的 auth-token
*/ */
class AuthTokenController extends OpenController class AuthTokenController extends OpenController
{ {
/** /**
* 获取 auth-token * 获取 auth-token
* GET 或 POST /api/authToken * GET /api/authToken
* 参数signature签名、secret密钥、device设备标识、time时间戳四者均为必传且非空
*/ */
public function index(Request $request): Response public function index(Request $request): Response
{ {
$exp = config('api.auth_token_exp', 86400); if (strtoupper($request->method()) !== 'GET') {
return $this->fail('仅支持 GET 请求', ReturnCode::PARAMS_ERROR);
}
$param = $request->get();
$signature = trim((string) ($param['signature'] ?? ''));
$secret = trim((string) ($param['secret'] ?? ''));
$device = trim((string) ($param['device'] ?? ''));
$time = trim((string) ($param['time'] ?? ''));
if ($signature === '' || $secret === '' || $device === '' || $time === '') {
return $this->fail('signature、secret、device、time 均为必传且不能为空', ReturnCode::PARAMS_ERROR);
}
$serverSecret = trim((string) config('api.auth_token_secret', ''));
if ($serverSecret === '') {
return $this->fail('服务未配置 API_AUTH_TOKEN_SECRET', ReturnCode::PARAMS_ERROR);
}
if ($secret !== $serverSecret) {
return $this->fail('密钥错误', ReturnCode::FORBIDDEN);
}
$tolerance = (int) config('api.auth_token_time_tolerance', 300);
$now = time();
$ts = is_numeric($time) ? (int) $time : 0;
if ($ts <= 0 || abs($now - $ts) > $tolerance) {
return $this->fail('时间戳无效或已过期', ReturnCode::PARAMS_ERROR);
}
$sign = $this->getAuthToken($device, $serverSecret, $time);
if ($sign !== $signature) {
return $this->fail('签名验证失败', ReturnCode::FORBIDDEN);
}
$exp = (int) config('api.auth_token_exp', 86400);
$tokenResult = JwtToken::generateToken([ $tokenResult = JwtToken::generateToken([
'id' => 0, 'id' => 0,
'plat' => 'api', 'plat' => 'api',
'device' => $device,
'access_exp' => $exp, 'access_exp' => $exp,
]); ]);
// 同一设备只保留最新 token覆盖后旧 token 失效
AuthTokenCache::setDeviceToken($device, $tokenResult['access_token'], $exp);
return $this->success([ return $this->success([
'auth-token' => $tokenResult['access_token'], 'auth-token' => $tokenResult['access_token'],
'expires_in' => $tokenResult['expires_in'], 'expires_in' => $tokenResult['expires_in'],
]); ]);
} }
/**
* 生成签名signature = md5(device . secret . time)
*
* @param string $device 设备标识
* @param string $secret 密钥(来自配置)
* @param string $time 时间戳
* @return string
*/
private function getAuthToken(string $device, string $secret, string $time): string
{
return md5($device . $secret . $time);
}
} }

View File

@@ -5,9 +5,9 @@ namespace app\api\controller;
use support\Request; use support\Request;
use support\Response; use support\Response;
use app\api\logic\UserLogic;
use app\api\logic\GameLogic; use app\api\logic\GameLogic;
use app\api\logic\PlayStartLogic; use app\api\logic\PlayStartLogic;
use app\api\logic\UserLogic;
use app\api\util\ReturnCode; use app\api\util\ReturnCode;
use app\dice\model\play_record\DicePlayRecord; use app\dice\model\play_record\DicePlayRecord;
use app\dice\model\player\DicePlayer; use app\dice\model\player\DicePlayer;
@@ -23,30 +23,15 @@ class GameController extends OpenController
/** /**
* 购买抽奖券 * 购买抽奖券
* POST /api/game/buyLotteryTickets * POST /api/game/buyLotteryTickets
* header: auth-token, user-token * header: auth-token, user-token(由 CheckUserTokenMiddleware 注入 request->user_id
* body: count = 1 | 5 | 101次/100coin, 5次/500coin, 10次/1000coin * body: count = 1 | 5 | 101次/100coin, 5次/500coin, 10次/1000coin
* 记录钱包流水,并更新缓存中玩家的 total_draw_count、paid_draw_count、free_draw_count、coin
*/ */
public function buyLotteryTickets(Request $request): Response public function buyLotteryTickets(Request $request): Response
{ {
$token = $request->header('user-token'); $userId = UserLogic::getUserIdFromRequest($request) ?? 0;
if (empty($token)) {
$auth = $request->header('authorization');
if ($auth && stripos($auth, 'Bearer ') === 0) {
$token = trim(substr($auth, 7));
}
}
if (empty($token)) {
return $this->fail('请携带 user-token', ReturnCode::MISSING_TOKEN);
}
$userId = UserLogic::getUserIdFromToken($token);
if ($userId === null) {
return $this->fail('user-token 无效或已过期', ReturnCode::TOKEN_TIMEOUT);
}
$count = (int) $request->post('count', 0); $count = (int) $request->post('count', 0);
if (!in_array($count, [1, 5, 10], true)) { if (!in_array($count, [1, 5, 10], true)) {
return $this->fail('购买抽奖券错误', ReturnCode::EMPTY_PARAMS); return $this->fail('购买抽奖券错误', ReturnCode::PARAMS_ERROR);
} }
try { try {
@@ -60,7 +45,7 @@ class GameController extends OpenController
$coin = $player ? (float) $player->coin : 0; $coin = $player ? (float) $player->coin : 0;
return $this->success(['coin' => $coin], $msg); return $this->success(['coin' => $coin], $msg);
} }
return $this->fail($msg, ReturnCode::EMPTY_PARAMS); return $this->fail($msg, ReturnCode::BUSINESS_ERROR);
} }
} }
@@ -79,39 +64,24 @@ class GameController extends OpenController
/** /**
* 开始游戏(抽奖一局) * 开始游戏(抽奖一局)
* POST /api/game/playStart * POST /api/game/playStart
* header: auth-token, user-token * header: auth-token, user-token(由 CheckUserTokenMiddleware 注入 request->user_id
* body: rediction 必传0=无 1=中奖 * body: rediction 必传0=无 1=中奖
* 余额不足时返回 code=200、message=玩家当前余额不足无法开启对局;超时返回 code=200、message=服务超时,并记录 status=0
*/ */
public function playStart(Request $request): Response public function playStart(Request $request): Response
{ {
$token = $request->header('user-token'); $userId = UserLogic::getUserIdFromRequest($request) ?? 0;
if (empty($token)) {
$auth = $request->header('authorization');
if ($auth && stripos($auth, 'Bearer ') === 0) {
$token = trim(substr($auth, 7));
}
}
if (empty($token)) {
return $this->fail('请携带 user-token', ReturnCode::MISSING_TOKEN);
}
$userId = UserLogic::getUserIdFromToken($token);
if ($userId === null) {
return $this->fail('user-token 无效或已过期', ReturnCode::TOKEN_TIMEOUT);
}
$rediction = $request->post('rediction'); $rediction = $request->post('rediction');
if ($rediction === '' || $rediction === null) { if ($rediction === '' || $rediction === null) {
return $this->fail('请传递 rediction 参数', ReturnCode::EMPTY_PARAMS); return $this->fail('请传递 rediction 参数', ReturnCode::PARAMS_ERROR);
} }
$direction = (int) $rediction; $direction = (int) $rediction;
if (!in_array($direction, [0, 1], true)) { if (!in_array($direction, [0, 1], true)) {
return $this->fail('rediction 必须为 0 或 1', ReturnCode::EMPTY_PARAMS); return $this->fail('rediction 必须为 0 或 1', ReturnCode::PARAMS_ERROR);
} }
$player = DicePlayer::find($userId); $player = DicePlayer::find($userId);
if (!$player) { if (!$player) {
return $this->fail('用户不存在', ReturnCode::EMPTY_PARAMS); return $this->fail('用户不存在', ReturnCode::NOT_FOUND);
} }
$minEv = (float) DiceRewardConfig::min('real_ev'); $minEv = (float) DiceRewardConfig::min('real_ev');
$minCoin = abs($minEv + 100); $minCoin = abs($minEv + 100);
@@ -125,7 +95,7 @@ class GameController extends OpenController
$data = $logic->run($userId, $direction); $data = $logic->run($userId, $direction);
return $this->success($data); return $this->success($data);
} catch (ApiException $e) { } catch (ApiException $e) {
return $this->fail($e->getMessage(), ReturnCode::EMPTY_PARAMS); return $this->fail($e->getMessage(), ReturnCode::BUSINESS_ERROR);
} catch (\Throwable $e) { } catch (\Throwable $e) {
$timeoutRecord = null; $timeoutRecord = null;
try { try {

View File

@@ -28,7 +28,7 @@ class UserController extends OpenController
$phone = $request->post('phone', ''); $phone = $request->post('phone', '');
$password = $request->post('password', ''); $password = $request->post('password', '');
if ($phone === '' || $password === '') { if ($phone === '' || $password === '') {
return $this->fail('请填写手机号和密码', ReturnCode::EMPTY_PARAMS); return $this->fail('请填写手机号和密码', ReturnCode::PARAMS_ERROR);
} }
$logic = new UserLogic(); $logic = new UserLogic();
$data = $logic->login($phone, $password); $data = $logic->login($phone, $password);
@@ -50,7 +50,7 @@ class UserController extends OpenController
$password = $request->post('password', ''); $password = $request->post('password', '');
$nickname = $request->post('nickname'); $nickname = $request->post('nickname');
if ($phone === '' || $password === '') { if ($phone === '' || $password === '') {
return $this->fail('请填写手机号和密码', ReturnCode::EMPTY_PARAMS); return $this->fail('请填写手机号和密码', ReturnCode::PARAMS_ERROR);
} }
$logic = new UserLogic(); $logic = new UserLogic();
$data = $logic->register($phone, $password, $nickname ? (string) $nickname : null); $data = $logic->register($phone, $password, $nickname ? (string) $nickname : null);
@@ -64,54 +64,31 @@ class UserController extends OpenController
/** /**
* 退出登录 * 退出登录
* POST /api/user/logout * POST /api/user/logout
* header: user-token或 Authorization: Bearer <user-token> * header: user-token由 CheckUserTokenMiddleware 校验并注入 request->userToken
* 将当前 user-token 加入黑名单,之后该 token 无法再用于获取 user_id
*/ */
public function logout(Request $request): Response public function logout(Request $request): Response
{ {
$token = $request->header('user-token'); $token = $request->userToken ?? UserLogic::getTokenFromRequest($request);
if (empty($token)) { if ($token === '' || !UserLogic::logout($token)) {
$auth = $request->header('authorization'); return $this->fail('退出失败或 token 已失效', ReturnCode::TOKEN_INVALID);
if ($auth && stripos($auth, 'Bearer ') === 0) {
$token = trim(substr($auth, 7));
}
} }
if (empty($token)) { return $this->success('已退出登录');
return $this->fail('请携带 user-token', ReturnCode::MISSING_TOKEN);
}
if (UserLogic::logout($token)) {
return $this->success('已退出登录');
}
return $this->fail('退出失败或 token 已失效', ReturnCode::TOKEN_TIMEOUT);
} }
/** /**
* 获取当前用户信息 * 获取当前用户信息
* GET /api/user/info * GET /api/user/info
* header: user-token或 Authorization: Bearer <user-token> * header: user-token由 CheckUserTokenMiddleware 校验并注入 request->user_id
* 返回id, username, phone, uid, name, coin, total_draw_count * 返回id, username, phone, uid, name, coin, total_ticket_count
*/ */
public function info(Request $request): Response public function info(Request $request): Response
{ {
$token = $request->header('user-token'); $userId = UserLogic::getUserIdFromRequest($request) ?? 0;
if (empty($token)) {
$auth = $request->header('authorization');
if ($auth && stripos($auth, 'Bearer ') === 0) {
$token = trim(substr($auth, 7));
}
}
if (empty($token)) {
return $this->fail('请携带 user-token', ReturnCode::MISSING_TOKEN);
}
$userId = UserLogic::getUserIdFromToken($token);
if ($userId === null) {
return $this->fail('user-token 无效或已过期', ReturnCode::TOKEN_TIMEOUT);
}
$user = UserLogic::getCachedUser($userId); $user = UserLogic::getCachedUser($userId);
if (empty($user)) { if (empty($user)) {
return $this->fail('用户不存在', ReturnCode::EMPTY_PARAMS); return $this->fail('用户不存在', ReturnCode::NOT_FOUND);
} }
$fields = ['id', 'username', 'phone', 'uid', 'name', 'coin', 'total_draw_count']; $fields = ['id', 'username', 'phone', 'uid', 'name', 'coin', 'total_ticket_count'];
$info = []; $info = [];
foreach ($fields as $field) { foreach ($fields as $field) {
if (array_key_exists($field, $user)) { if (array_key_exists($field, $user)) {
@@ -122,32 +99,18 @@ class UserController extends OpenController
} }
/** /**
* 获取钱包余额(读缓存,不查库,低延迟 * 获取钱包余额(优先读缓存,缓存未命中时从库拉取并回写缓存
* GET /api/user/balance * GET /api/user/balance
* header: user-token或 Authorization: Bearer <user-token> * header: user-token由 CheckUserTokenMiddleware 校验并注入 request->user_id
* 返回coin, phone, username登录时已写入缓存本接口只从缓存读取
*/ */
public function balance(Request $request): Response public function balance(Request $request): Response
{ {
$token = $request->header('user-token'); $userId = UserLogic::getUserIdFromRequest($request) ?? 0;
if (empty($token)) { $user = UserLogic::getCachedUser($userId);
$auth = $request->header('authorization');
if ($auth && stripos($auth, 'Bearer ') === 0) {
$token = trim(substr($auth, 7));
}
}
if (empty($token)) {
return $this->fail('请携带 user-token', ReturnCode::MISSING_TOKEN);
}
$userId = UserLogic::getUserIdFromToken($token);
if ($userId === null) {
return $this->fail('user-token 无效或已过期', ReturnCode::TOKEN_TIMEOUT);
}
$user = UserCache::getUser($userId);
if (empty($user)) { if (empty($user)) {
return $this->fail('缓存已过期,请重新登录', ReturnCode::TOKEN_TIMEOUT); return $this->fail('用户不存在', ReturnCode::NOT_FOUND);
} }
$coin = $user['coin'] ?? null; $coin = $user['coin'] ?? 0;
if (is_string($coin) && is_numeric($coin)) { if (is_string($coin) && is_numeric($coin)) {
$coin = str_contains($coin, '.') ? (float) $coin : (int) $coin; $coin = str_contains($coin, '.') ? (float) $coin : (int) $coin;
} }
@@ -161,26 +124,12 @@ class UserController extends OpenController
/** /**
* 玩家钱包流水 * 玩家钱包流水
* GET /api/user/walletRecord * GET /api/user/walletRecord
* header: user-token或 Authorization: Bearer <user-token> * header: user-token由 CheckUserTokenMiddleware 校验并注入 request->user_id
* 参数: page 页码默认1, limit 每页条数默认10, create_time_min/create_time_max 创建时间范围(可选) * 参数: page 页码默认1, limit 每页条数默认10, create_time_min/create_time_max 创建时间范围(可选)
*/ */
public function walletRecord(Request $request): Response public function walletRecord(Request $request): Response
{ {
$token = $request->header('user-token'); $userId = UserLogic::getUserIdFromRequest($request) ?? 0;
if (empty($token)) {
$auth = $request->header('authorization');
if ($auth && stripos($auth, 'Bearer ') === 0) {
$token = trim(substr($auth, 7));
}
}
if (empty($token)) {
return $this->fail('请携带 user-token', ReturnCode::MISSING_TOKEN);
}
$userId = UserLogic::getUserIdFromToken($token);
if ($userId === null) {
return $this->fail('user-token 无效或已过期', ReturnCode::TOKEN_TIMEOUT);
}
$page = (int) $request->post('page', 1); $page = (int) $request->post('page', 1);
$limit = (int) $request->post('limit', 10); $limit = (int) $request->post('limit', 10);
if ($page < 1) { if ($page < 1) {
@@ -217,26 +166,12 @@ class UserController extends OpenController
/** /**
* 游玩记录 * 游玩记录
* GET /api/user/playGameRecord * GET /api/user/playGameRecord
* header: user-token或 Authorization: Bearer <user-token> * header: user-token由 CheckUserTokenMiddleware 校验并注入 request->user_id
* 参数: page 页码默认1, limit 每页条数默认10, create_time_min/create_time_max 创建时间范围(可选) * 参数: page 页码默认1, limit 每页条数默认10, create_time_min/create_time_max 创建时间范围(可选)
*/ */
public function playGameRecord(Request $request): Response public function playGameRecord(Request $request): Response
{ {
$token = $request->header('user-token'); $userId = UserLogic::getUserIdFromRequest($request) ?? 0;
if (empty($token)) {
$auth = $request->header('authorization');
if ($auth && stripos($auth, 'Bearer ') === 0) {
$token = trim(substr($auth, 7));
}
}
if (empty($token)) {
return $this->fail('请携带 user-token', ReturnCode::MISSING_TOKEN);
}
$userId = UserLogic::getUserIdFromToken($token);
if ($userId === null) {
return $this->fail('user-token 无效或已过期', ReturnCode::TOKEN_TIMEOUT);
}
$page = (int) $request->post('page', 1); $page = (int) $request->post('page', 1);
$limit = (int) $request->post('limit', 10); $limit = (int) $request->post('limit', 10);
if ($page < 1) { if ($page < 1) {

View File

@@ -29,7 +29,7 @@ class GameLogic
* 购买抽奖券 * 购买抽奖券
* @param int $playerId 玩家ID即 user_id * @param int $playerId 玩家ID即 user_id
* @param int $count 购买档位1 / 5 / 10 * @param int $count 购买档位1 / 5 / 10
* @return array 更新后的 coin, total_draw_count, paid_draw_count, free_draw_count * @return array 更新后的 coin, total_ticket_count, paid_ticket_count, free_ticket_count
*/ */
public function buyLotteryTickets(int $playerId, int $count): array public function buyLotteryTickets(int $playerId, int $count): array
{ {
@@ -52,9 +52,9 @@ class GameLogic
} }
$coinAfter = $coinBefore - $cost; $coinAfter = $coinBefore - $cost;
$totalBefore = (int) ($player->total_draw_count ?? 0); $totalBefore = (int) ($player->total_ticket_count ?? 0);
$paidBefore = (int) ($player->paid_draw_count ?? 0); $paidBefore = (int) ($player->paid_ticket_count ?? 0);
$freeBefore = (int) ($player->free_draw_count ?? 0); $freeBefore = (int) ($player->free_ticket_count ?? 0);
Db::transaction(function () use ( Db::transaction(function () use (
$player, $player,
@@ -70,9 +70,9 @@ class GameLogic
$freeBefore $freeBefore
) { ) {
$player->coin = $coinAfter; $player->coin = $coinAfter;
$player->total_draw_count = $totalBefore + $addTotal; $player->total_ticket_count = $totalBefore + $addTotal;
$player->paid_draw_count = $paidBefore + $addPaid; $player->paid_ticket_count = $paidBefore + $addPaid;
$player->free_draw_count = $freeBefore + $addFree; $player->free_ticket_count = $freeBefore + $addFree;
$player->save(); $player->save();
// 钱包流水记录 // 钱包流水记录
@@ -82,9 +82,9 @@ class GameLogic
'type' => self::WALLET_TYPE_BUY_DRAW, 'type' => self::WALLET_TYPE_BUY_DRAW,
'wallet_before' => $coinBefore, 'wallet_before' => $coinBefore,
'wallet_after' => $coinAfter, 'wallet_after' => $coinAfter,
'total_draw_count' => $addTotal, 'total_ticket_count' => $addTotal,
'paid_draw_count' => $addPaid, 'paid_ticket_count' => $addPaid,
'free_draw_count' => $addFree, 'free_ticket_count' => $addFree,
'remark' => "购买抽奖券{$addTotal}次(付费{$addPaid}次+赠送{$addFree}次)", 'remark' => "购买抽奖券{$addTotal}次(付费{$addPaid}次+赠送{$addFree}次)",
]); ]);
@@ -92,9 +92,9 @@ class GameLogic
DicePlayerTicketRecord::create([ DicePlayerTicketRecord::create([
'player_id' => $playerId, 'player_id' => $playerId,
'use_coins' => $cost, 'use_coins' => $cost,
'total_draw_count' => $addTotal, 'total_ticket_count' => $addTotal,
'paid_draw_count' => $addPaid, 'paid_ticket_count' => $addPaid,
'free_draw_count' => $addFree, 'free_ticket_count' => $addFree,
'remark' => "购买抽奖券{$addTotal}次(付费{$addPaid}次+赠送{$addFree}次)", 'remark' => "购买抽奖券{$addTotal}次(付费{$addPaid}次+赠送{$addFree}次)",
]); ]);
}); });
@@ -105,9 +105,9 @@ class GameLogic
return [ return [
'coin' => (float) $updated->coin, 'coin' => (float) $updated->coin,
'total_draw_count' => (int) $updated->total_draw_count, 'total_ticket_count' => (int) $updated->total_ticket_count,
'paid_draw_count' => (int) $updated->paid_draw_count, 'paid_ticket_count' => (int) $updated->paid_ticket_count,
'free_draw_count' => (int) $updated->free_draw_count, 'free_ticket_count' => (int) $updated->free_ticket_count,
]; ];
} }
} }

View File

@@ -54,8 +54,8 @@ class PlayStartLogic
throw new ApiException('当前玩家余额小于DiceRewardConfigMin.real_ev+100无法继续游戏'); throw new ApiException('当前玩家余额小于DiceRewardConfigMin.real_ev+100无法继续游戏');
} }
$paid = (int) ($player->paid_draw_count ?? 0); $paid = (int) ($player->paid_ticket_count ?? 0);
$free = (int) ($player->free_draw_count ?? 0); $free = (int) ($player->free_ticket_count ?? 0);
if ($paid + $free <= 0) { if ($paid + $free <= 0) {
throw new ApiException('抽奖券不足'); throw new ApiException('抽奖券不足');
} }
@@ -125,21 +125,21 @@ class PlayStartLogic
$coinBefore = (float) $p->coin; $coinBefore = (float) $p->coin;
$coinAfter = $coinBefore + $winCoin; $coinAfter = $coinBefore + $winCoin;
$p->coin = $coinAfter; $p->coin = $coinAfter;
$p->total_draw_count = max(0, (int) $p->total_draw_count - 1); $p->total_ticket_count = max(0, (int) $p->total_ticket_count - 1);
if ($ticketType === self::LOTTERY_TYPE_PAID) { if ($ticketType === self::LOTTERY_TYPE_PAID) {
$p->paid_draw_count = max(0, (int) $p->paid_draw_count - 1); $p->paid_ticket_count = max(0, (int) $p->paid_ticket_count - 1);
} else { } else {
$p->free_draw_count = max(0, (int) $p->free_draw_count - 1); $p->free_ticket_count = max(0, (int) $p->free_ticket_count - 1);
} }
// 若本局中奖档位为 T5则额外赠送 1 次免费抽奖次数(总次数也 +1并记录抽奖券获取记录 // 若本局中奖档位为 T5则额外赠送 1 次免费抽奖次数(总次数也 +1并记录抽奖券获取记录
if ($isTierT5) { if ($isTierT5) {
$p->free_draw_count = (int) $p->free_draw_count + 1; $p->free_ticket_count = (int) $p->free_ticket_count + 1;
$p->total_draw_count = (int) $p->total_draw_count + 1; $p->total_ticket_count = (int) $p->total_ticket_count + 1;
DicePlayerTicketRecord::create([ DicePlayerTicketRecord::create([
'player_id' => $playerId, 'player_id' => $playerId,
'free_draw_count' => 1, 'free_ticket_count' => 1,
'remark' => '中奖结果为T5', 'remark' => '中奖结果为T5',
]); ]);
} }

View File

@@ -56,6 +56,8 @@ class UserLogic
UserCache::setUser((int) $user->id, $userArr); UserCache::setUser((int) $user->id, $userArr);
$userToken = $this->generateUserToken((int) $user->id); $userToken = $this->generateUserToken((int) $user->id);
// 同一用户只保留最新一次登录的 token旧 token 自动失效
UserCache::setCurrentUserToken((int) $user->id, $userToken);
return [ return [
'user' => $userArr, 'user' => $userArr,
'user-token' => $userToken, 'user-token' => $userToken,
@@ -94,6 +96,8 @@ class UserLogic
UserCache::setUser((int) $user->id, $userArr); UserCache::setUser((int) $user->id, $userArr);
$userToken = $this->generateUserToken((int) $user->id); $userToken = $this->generateUserToken((int) $user->id);
// 同一用户只保留最新一次登录的 token旧 token 自动失效
UserCache::setCurrentUserToken((int) $user->id, $userToken);
return [ return [
'user' => $userArr, 'user' => $userArr,
'user-token' => $userToken, 'user-token' => $userToken,
@@ -123,6 +127,41 @@ class UserLogic
return $result['access_token']; return $result['access_token'];
} }
/**
* 从请求中解析 user-tokenheader: user-token 或 Authorization: Bearer
* @param object $request 需有 header(string $name) 方法
*/
public static function getTokenFromRequest(object $request): string
{
$token = $request->header('user-token') ?? '';
if ($token !== '') {
return trim((string) $token);
}
$auth = $request->header('authorization');
if ($auth && stripos($auth, 'Bearer ') === 0) {
return trim(substr($auth, 7));
}
return '';
}
/**
* 从请求获取当前用户 ID优先 request->user_id否则从 header 的 user-token 解析
* 中间件未正确注入时仍可兜底解析
* @param object $request 需有 user_id 属性及 header() 方法
*/
public static function getUserIdFromRequest(object $request): ?int
{
$id = $request->user_id ?? null;
if ($id !== null && (int) $id > 0) {
return (int) $id;
}
$token = self::getTokenFromRequest($request);
if ($token === '') {
return null;
}
return self::getUserIdFromToken($token);
}
/** /**
* 根据 user-token 获取 user_id不写缓存仅解析 JWT * 根据 user-token 获取 user_id不写缓存仅解析 JWT
* 若 token 已通过退出接口加入黑名单,返回 null * 若 token 已通过退出接口加入黑名单,返回 null
@@ -139,7 +178,15 @@ class UserLogic
return null; return null;
} }
$id = $extend['id'] ?? null; $id = $extend['id'] ?? null;
return $id !== null ? (int) $id : null; if ($id === null) {
return null;
}
$userId = (int) $id;
// 同一用户只允许当前登记的 token 生效,重新登录/注册后旧 token 失效
if (!UserCache::isCurrentUserToken($userId, $userToken)) {
return null;
}
return $userId;
} catch (\Throwable $e) { } catch (\Throwable $e) {
return null; return null;
} }

View File

@@ -1,75 +0,0 @@
<?php
declare(strict_types=1);
namespace app\api\middleware;
use support\Log;
use Webman\Http\Request;
use Webman\Http\Response;
use Webman\MiddlewareInterface;
use Tinywan\Jwt\JwtToken;
use Tinywan\Jwt\Exception\JwtTokenException;
use Tinywan\Jwt\Exception\JwtTokenExpiredException;
use app\api\util\ReturnCode;
use plugin\saiadmin\exception\ApiException;
/**
* API 鉴权中间件
* 校验请求头 auth-token或 Authorization: Bearer xxx白名单路径不校验
*/
class CheckApiAuthMiddleware implements MiddlewareInterface
{
/** 不需要 auth-token 的路径(仅获取 token 的接口) */
private const WHITELIST = [
'api/authToken',
];
public function process(Request $request, callable $handler): Response
{
$path = trim($request->path(), '/');
if ($this->isWhitelist($path)) {
return $handler($request);
}
$token = $request->header('auth-token');
if (empty($token)) {
$auth = $request->header('authorization');
if ($auth && stripos($auth, 'Bearer ') === 0) {
$token = trim(substr($auth, 7));
}
}
if (empty($token)) {
throw new ApiException('请携带 auth-token', ReturnCode::MISSING_TOKEN);
}
try {
// ACCESS_TOKEN = 1JwtToken 内部私有常量)
$decoded = JwtToken::verify(1, $token);
$extend = $decoded['extend'] ?? [];
if (($extend['plat'] ?? '') !== 'api') {
throw new ApiException('auth-token 无效', ReturnCode::TOKEN_TIMEOUT);
}
} catch (JwtTokenExpiredException $e) {
Log::error('auth-token 已过期, 报错信息'. $e);
throw new ApiException('auth-token 已过期', ReturnCode::TOKEN_TIMEOUT);
} catch (JwtTokenException $e) {
Log::error('auth-token 无效, 报错信息'. $e);
throw new ApiException($e->getMessage() ?: 'auth-token 无效', ReturnCode::TOKEN_TIMEOUT);
} catch (\Throwable $e) {
Log::error('auth-token 校验失败, 报错信息'. $e);
throw new ApiException('auth-token 校验失败', ReturnCode::TOKEN_TIMEOUT);
}
return $handler($request);
}
private function isWhitelist(string $path): bool
{
foreach (self::WHITELIST as $prefix) {
if ($path === $prefix || str_starts_with($path, $prefix . '/')) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace app\api\middleware;
use support\Log;
use Webman\Http\Request;
use Webman\Http\Response;
use Webman\MiddlewareInterface;
use Tinywan\Jwt\JwtToken;
use Tinywan\Jwt\Exception\JwtTokenException;
use Tinywan\Jwt\Exception\JwtTokenExpiredException;
use app\api\util\ReturnCode;
use app\api\cache\AuthTokenCache;
use plugin\saiadmin\exception\ApiException;
/**
* 仅校验 auth-token 请求头
* 白名单路径(如 /api/authToken不校验其它接口必须携带有效 auth-token 或 Authorization: Bearer <token>,且必须通过 JWT 签名与 plat=api 校验
*/
class CheckAuthTokenMiddleware implements MiddlewareInterface
{
/** 不需要 auth-token 的路径 */
private const WHITELIST = [
'api/authToken',
];
/** JWT 至少为 xxx.yyy.zzz 三段 */
private const JWT_PARTS_MIN = 3;
public function process(Request $request, callable $handler): Response
{
$path = trim((string) $request->path(), '/');
if ($this->isWhitelist($path)) {
return $handler($request);
}
$token = $this->getAuthTokenFromRequest($request);
if ($token === '') {
throw new ApiException('请携带 auth-token', ReturnCode::UNAUTHORIZED);
}
if (!$this->looksLikeJwt($token)) {
throw new ApiException('auth-token 格式无效', ReturnCode::TOKEN_INVALID);
}
$decoded = $this->verifyAuthToken($token);
$extend = $decoded['extend'] ?? [];
if (($extend['plat'] ?? '') !== 'api') {
throw new ApiException('auth-token 无效(非 API 凭证)', ReturnCode::TOKEN_INVALID);
}
// 同一设备只允许一个 auth-token 生效,非当前 token 视为已失效
$device = (string) ($extend['device'] ?? '');
if ($device !== '' && !AuthTokenCache::isCurrentToken($device, $token)) {
throw new ApiException('auth-token 已失效(该设备已签发新凭证,请使用新 auth-token', ReturnCode::TOKEN_INVALID);
}
return $handler($request);
}
private function getAuthTokenFromRequest(Request $request): string
{
$token = $request->header('auth-token');
if ($token !== null && $token !== '') {
return trim((string) $token);
}
$auth = $request->header('authorization');
if ($auth && stripos($auth, 'Bearer ') === 0) {
return trim(substr($auth, 7));
}
return '';
}
private function looksLikeJwt(string $token): bool
{
$parts = explode('.', $token);
return count($parts) >= self::JWT_PARTS_MIN;
}
/**
* 校验 auth-token 有效性签名、过期、iss 等),无效或过期必抛 ApiException
*/
private function verifyAuthToken(string $token): array
{
try {
return JwtToken::verify(1, $token);
} catch (JwtTokenExpiredException $e) {
Log::error('auth-token 已过期, 报错信息' . $e);
throw new ApiException('auth-token 已过期', ReturnCode::TOKEN_INVALID);
} catch (JwtTokenException $e) {
Log::error('auth-token 无效, 报错信息' . $e);
throw new ApiException($e->getMessage() ?: 'auth-token 无效', ReturnCode::TOKEN_INVALID);
} catch (\Throwable $e) {
Log::error('auth-token 校验失败, 报错信息' . $e);
throw new ApiException('auth-token 校验失败', ReturnCode::TOKEN_INVALID);
}
}
private function isWhitelist(string $path): bool
{
foreach (self::WHITELIST as $prefix) {
if ($path === $prefix || str_starts_with($path, $prefix . '/')) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace app\api\middleware;
use Webman\Http\Request;
use Webman\Http\Response;
use Webman\MiddlewareInterface;
use app\api\logic\UserLogic;
use app\api\util\ReturnCode;
use plugin\saiadmin\exception\ApiException;
/**
* 校验 user-token 请求头
* 从 header 读取 user-token 或 Authorization: Bearer <user-token>,校验通过后将 user_id、userToken 写入 request 供控制器使用
*/
class CheckUserTokenMiddleware implements MiddlewareInterface
{
public function process(Request $request, callable $handler): Response
{
$token = $request->header('user-token');
if (empty($token)) {
$auth = $request->header('authorization');
if ($auth && stripos($auth, 'Bearer ') === 0) {
$token = trim(substr($auth, 7));
}
}
if (empty($token)) {
throw new ApiException('请携带 user-token', ReturnCode::UNAUTHORIZED);
}
$userId = UserLogic::getUserIdFromToken($token);
if ($userId === null) {
throw new ApiException('user-token 无效或已过期', ReturnCode::TOKEN_INVALID);
}
$request->user_id = $userId;
$request->userToken = $token;
return $handler($request);
}
}

View File

@@ -109,7 +109,7 @@ class LotteryService
return $tiers[4]; return $tiers[4];
} }
/** 按 paid_draw_count 与 free_draw_count 权重随机抽取 0=付费 1=免费 */ /** 按 paid_ticket_count 与 free_ticket_count 权重随机抽取 0=付费 1=免费 */
public static function drawTicketType(int $paid, int $free): int public static function drawTicketType(int $paid, int $free): int
{ {
if ($paid <= 0 && $free <= 0) { if ($paid <= 0 && $free <= 0) {

View File

@@ -4,19 +4,32 @@ declare(strict_types=1);
namespace app\api\util; namespace app\api\util;
/** /**
* API 状态码统一管理 * API 统一状态码
* 与 HTTP 语义对齐,便于前端与网关处理
*/ */
class ReturnCode class ReturnCode
{ {
/** 200 成功 */ /** 200 成功 */
public const SUCCESS = 200; public const SUCCESS = 200;
/** 201 请携带 tokenauth-token / user-token */ /** 400 请求参数错误(缺少参数、参数无效、格式错误等 */
public const MISSING_TOKEN = 201; public const PARAMS_ERROR = 400;
/** 202 缺少参数 / 参数错误 / 业务校验不通过(如余额不足、购买抽奖券错误等 */ /** 401 未授权(未携带 auth-token 或 user-token */
public const EMPTY_PARAMS = 202; public const UNAUTHORIZED = 401;
/** 203 token 过期或无效auth-token / user-token 过期、缓存已过期等) */ /** 402 token 无效或已过期(格式无效、签名错误、过期、非当前有效 token 等) */
public const TOKEN_TIMEOUT = 203; public const TOKEN_INVALID = 402;
/** 403 鉴权失败(密钥错误、签名验证失败等) */
public const FORBIDDEN = 403;
/** 404 资源不存在(用户不存在等) */
public const NOT_FOUND = 404;
/** 422 业务逻辑错误(余额不足、购买失败、业务校验不通过等) */
public const BUSINESS_ERROR = 422;
/** 500 服务器内部错误 */
public const SERVER_ERROR = 500;
} }

View File

@@ -41,12 +41,12 @@ class DicePlayerTicketRecordController extends BaseController
['username', ''], ['username', ''],
['use_coins_min', ''], ['use_coins_min', ''],
['use_coins_max', ''], ['use_coins_max', ''],
['total_draw_count_min', ''], ['total_ticket_count_min', ''],
['total_draw_count_max', ''], ['total_ticket_count_max', ''],
['paid_draw_count_min', ''], ['paid_ticket_count_min', ''],
['paid_draw_count_max', ''], ['paid_ticket_count_max', ''],
['free_draw_count_min', ''], ['free_ticket_count_min', ''],
['free_draw_count_max', ''], ['free_ticket_count_max', ''],
['create_time_min', ''], ['create_time_min', ''],
['create_time_max', ''], ['create_time_max', ''],
]); ]);

View File

@@ -25,28 +25,28 @@ class DicePlayerTicketRecordLogic extends BaseLogic
} }
/** /**
* 添加前:总抽奖次数 = 购买抽奖次数 + 赠送抽奖次数 * 添加前:total_ticket_count = paid_ticket_count + free_ticket_count
*/ */
public function add(array $data): mixed public function add(array $data): mixed
{ {
$data = $this->fillTotalDrawCount($data); $data = $this->fillTotalTicketCount($data);
return parent::add($data); return parent::add($data);
} }
/** /**
* 修改前:总抽奖次数 = 购买抽奖次数 + 赠送抽奖次数 * 修改前:total_ticket_count = paid_ticket_count + free_ticket_count
*/ */
public function edit($id, array $data): mixed public function edit($id, array $data): mixed
{ {
$data = $this->fillTotalDrawCount($data); $data = $this->fillTotalTicketCount($data);
return parent::edit($id, $data); return parent::edit($id, $data);
} }
private function fillTotalDrawCount(array $data): array private function fillTotalTicketCount(array $data): array
{ {
$paid = isset($data['paid_draw_count']) ? (int) $data['paid_draw_count'] : 0; $paid = isset($data['paid_ticket_count']) ? (int) $data['paid_ticket_count'] : 0;
$free = isset($data['free_draw_count']) ? (int) $data['free_draw_count'] : 0; $free = isset($data['free_ticket_count']) ? (int) $data['free_ticket_count'] : 0;
$data['total_draw_count'] = $paid + $free; $data['total_ticket_count'] = $paid + $free;
return $data; return $data;
} }
} }

View File

@@ -29,9 +29,9 @@ class DicePlayerWalletRecordLogic extends BaseLogic
*/ */
public function add(array $data): mixed public function add(array $data): mixed
{ {
$data['total_draw_count'] = $data['total_draw_count'] ?? 0; $data['total_ticket_count'] = $data['total_ticket_count'] ?? 0;
$data['paid_draw_count'] = $data['paid_draw_count'] ?? 0; $data['paid_ticket_count'] = $data['paid_ticket_count'] ?? 0;
$data['free_draw_count'] = $data['free_draw_count'] ?? 0; $data['free_ticket_count'] = $data['free_ticket_count'] ?? 0;
return parent::add($data); return parent::add($data);
} }
@@ -81,9 +81,9 @@ class DicePlayerWalletRecordLogic extends BaseLogic
'wallet_after' => $walletAfter, 'wallet_after' => $walletAfter,
'remark' => $remark, 'remark' => $remark,
'user_id' => $adminId, 'user_id' => $adminId,
'total_draw_count' => 0, 'total_ticket_count' => 0,
'paid_draw_count' => 0, 'paid_ticket_count' => 0,
'free_draw_count' => 0, 'free_ticket_count' => 0,
]; ];
return $this->model->create($record); return $this->model->create($record);

View File

@@ -28,9 +28,9 @@ use app\dice\model\lottery_config\DiceLotteryConfig;
* @property $t3_wight T3池权重 * @property $t3_wight T3池权重
* @property $t4_wight T4池权重 * @property $t4_wight T4池权重
* @property $t5_wight T5池权重 * @property $t5_wight T5池权重
* @property $total_draw_count 总抽奖次数 * @property $total_ticket_count 总抽奖次数
* @property $paid_draw_count 购买抽奖次数 * @property $paid_ticket_count 购买抽奖次数
* @property $free_draw_count 赠送抽奖次数 * @property $free_ticket_count 赠送抽奖次数
* @property $created_at 创建时间 * @property $created_at 创建时间
* @property $updated_at 更新时间 * @property $updated_at 更新时间
* @property $deleted_at 删除时间 * @property $deleted_at 删除时间

View File

@@ -18,9 +18,9 @@ use think\model\relation\BelongsTo;
* @property $id ID * @property $id ID
* @property $player_id 玩家id * @property $player_id 玩家id
* @property $use_coins 消耗硬币 * @property $use_coins 消耗硬币
* @property $total_draw_count 总抽奖次数 * @property $total_ticket_count 总抽奖次数
* @property $paid_draw_count 购买抽奖次数 * @property $paid_ticket_count 购买抽奖次数
* @property $free_draw_count 赠送抽奖次数 * @property $free_ticket_count 赠送抽奖次数
* @property $remark 备注 * @property $remark 备注
* @property $create_time 创建时间 * @property $create_time 创建时间
* @property $update_time 修改时间 * @property $update_time 修改时间
@@ -79,51 +79,51 @@ class DicePlayerTicketRecord extends BaseModel
} }
} }
/** 总抽奖次数下限 */ /** 总抽奖次数(total_ticket_count)下限 */
public function searchTotalDrawCountMinAttr($query, $value) public function searchTotalTicketCountMinAttr($query, $value)
{ {
if ($value !== '' && $value !== null) { if ($value !== '' && $value !== null) {
$query->where('total_draw_count', '>=', $value); $query->where('total_ticket_count', '>=', $value);
} }
} }
/** 总抽奖次数上限 */ /** 总抽奖次数(total_ticket_count)上限 */
public function searchTotalDrawCountMaxAttr($query, $value) public function searchTotalTicketCountMaxAttr($query, $value)
{ {
if ($value !== '' && $value !== null) { if ($value !== '' && $value !== null) {
$query->where('total_draw_count', '<=', $value); $query->where('total_ticket_count', '<=', $value);
} }
} }
/** 购买抽奖次数下限 */ /** 购买抽奖次数(paid_ticket_count)下限 */
public function searchPaidDrawCountMinAttr($query, $value) public function searchPaidTicketCountMinAttr($query, $value)
{ {
if ($value !== '' && $value !== null) { if ($value !== '' && $value !== null) {
$query->where('paid_draw_count', '>=', $value); $query->where('paid_ticket_count', '>=', $value);
} }
} }
/** 购买抽奖次数上限 */ /** 购买抽奖次数(paid_ticket_count)上限 */
public function searchPaidDrawCountMaxAttr($query, $value) public function searchPaidTicketCountMaxAttr($query, $value)
{ {
if ($value !== '' && $value !== null) { if ($value !== '' && $value !== null) {
$query->where('paid_draw_count', '<=', $value); $query->where('paid_ticket_count', '<=', $value);
} }
} }
/** 赠送抽奖次数下限 */ /** 赠送抽奖次数(free_ticket_count)下限 */
public function searchFreeDrawCountMinAttr($query, $value) public function searchFreeTicketCountMinAttr($query, $value)
{ {
if ($value !== '' && $value !== null) { if ($value !== '' && $value !== null) {
$query->where('free_draw_count', '>=', $value); $query->where('free_ticket_count', '>=', $value);
} }
} }
/** 赠送抽奖次数上限 */ /** 赠送抽奖次数(free_ticket_count)上限 */
public function searchFreeDrawCountMaxAttr($query, $value) public function searchFreeTicketCountMaxAttr($query, $value)
{ {
if ($value !== '' && $value !== null) { if ($value !== '' && $value !== null) {
$query->where('free_draw_count', '<=', $value); $query->where('free_ticket_count', '<=', $value);
} }
} }

View File

@@ -22,9 +22,9 @@ use think\model\relation\BelongsTo;
* @property $type 类型:0=充值 1=提现 2=购买抽奖次数 * @property $type 类型:0=充值 1=提现 2=购买抽奖次数
* @property $wallet_before 钱包操作前 * @property $wallet_before 钱包操作前
* @property $wallet_after 钱包操作后 * @property $wallet_after 钱包操作后
* @property $total_draw_count 总抽奖次数 * @property $total_ticket_count 总抽奖次数
* @property $paid_draw_count 购买抽奖次数 * @property $paid_ticket_count 购买抽奖次数
* @property $free_draw_count 赠送抽奖次数 * @property $free_ticket_count 赠送抽奖次数
* @property $remark 备注 * @property $remark 备注
* @property $user_id 操作管理员idtype 3/4 时记录) * @property $user_id 操作管理员idtype 3/4 时记录)
* @property $create_time 创建时间 * @property $create_time 创建时间

View File

@@ -19,9 +19,9 @@ class DicePlayerTicketRecordValidate extends BaseValidate
protected $rule = [ protected $rule = [
'player_id' => 'require', 'player_id' => 'require',
'use_coins' => 'require', 'use_coins' => 'require',
'total_draw_count' => 'require', 'total_ticket_count' => 'require',
'paid_draw_count' => 'require', 'paid_ticket_count' => 'require',
'free_draw_count' => 'require', 'free_ticket_count' => 'require',
'remark' => 'require', 'remark' => 'require',
]; ];
@@ -31,9 +31,9 @@ class DicePlayerTicketRecordValidate extends BaseValidate
protected $message = [ protected $message = [
'player_id' => '玩家id必须填写', 'player_id' => '玩家id必须填写',
'use_coins' => '消耗硬币必须填写', 'use_coins' => '消耗硬币必须填写',
'total_draw_count' => '总抽奖次数必须填写', 'total_ticket_count' => '总抽奖次数必须填写',
'paid_draw_count' => '购买抽奖次数必须填写', 'paid_ticket_count' => '购买抽奖次数必须填写',
'free_draw_count' => '赠送抽奖次数必须填写', 'free_ticket_count' => '赠送抽奖次数必须填写',
'remark' => '备注必须填写', 'remark' => '备注必须填写',
]; ];
@@ -44,17 +44,17 @@ class DicePlayerTicketRecordValidate extends BaseValidate
'save' => [ 'save' => [
'player_id', 'player_id',
'use_coins', 'use_coins',
'total_draw_count', 'total_ticket_count',
'paid_draw_count', 'paid_ticket_count',
'free_draw_count', 'free_ticket_count',
'remark', 'remark',
], ],
'update' => [ 'update' => [
'player_id', 'player_id',
'use_coins', 'use_coins',
'total_draw_count', 'total_ticket_count',
'paid_draw_count', 'paid_ticket_count',
'free_draw_count', 'free_ticket_count',
'remark', 'remark',
], ],
]; ];

View File

@@ -3,10 +3,18 @@
* API 鉴权与用户相关配置 * API 鉴权与用户相关配置
*/ */
return [ return [
// auth-token 签名密钥(与客户端约定,用于 /api/authToken 的 signature 校验,必填)
'auth_token_secret' => env('API_AUTH_TOKEN_SECRET', ''),
// auth-token 时间戳允许误差(秒),防重放,默认 300 秒
'auth_token_time_tolerance' => (int) env('API_AUTH_TOKEN_TIME_TOLERANCE', 300),
// auth-token 有效期(秒),默认 24 小时 // auth-token 有效期(秒),默认 24 小时
'auth_token_exp' => (int) env('API_AUTH_TOKEN_EXP', 86400), 'auth_token_exp' => (int) env('API_AUTH_TOKEN_EXP', 86400),
// auth-token 按设备存储的 Redis key 前缀(同一设备只保留最新一个 auth-token
'auth_token_device_prefix' => env('API_AUTH_TOKEN_DEVICE_PREFIX', 'api:auth_token:'),
// user-token 有效期(秒),默认 7 天 // user-token 有效期(秒),默认 7 天
'user_token_exp' => (int) env('API_USER_TOKEN_EXP', 604800), 'user_token_exp' => (int) env('API_USER_TOKEN_EXP', 604800),
// 按用户存储当前有效 user-token 的 Redis key 前缀(同一用户仅保留最新一次登录的 token
'user_token_current_prefix' => env('API_USER_TOKEN_CURRENT_PREFIX', 'api:user:current_token:'),
// 用户信息 Redis 缓存过期时间(秒),默认 7 天 // 用户信息 Redis 缓存过期时间(秒),默认 7 天
'user_cache_expire' => (int) env('API_USER_CACHE_EXPIRE', 604800), 'user_cache_expire' => (int) env('API_USER_CACHE_EXPIRE', 604800),
// 用户缓存 Redis key 前缀 // 用户缓存 Redis key 前缀

View File

@@ -13,22 +13,29 @@
*/ */
use Webman\Route; use Webman\Route;
use app\api\middleware\CheckApiAuthMiddleware; use app\api\middleware\CheckAuthTokenMiddleware;
use app\api\middleware\CheckUserTokenMiddleware;
// API 路由:需先调用 /api/authToken 获取 auth-token请求时携带 header: auth-token 或 Authorization: Bearer <token> // 仅需 auth-token 的路由组(authToken 接口在中间件内白名单跳过)
Route::group('/api', function () { Route::group('/api', function () {
Route::any('/authToken', [app\api\controller\AuthTokenController::class, 'index']); Route::any('/authToken', [app\api\controller\AuthTokenController::class, 'index']);
Route::post('/user/login', [app\api\controller\UserController::class, 'login']); Route::any('/user/login', [app\api\controller\UserController::class, 'login']);
Route::post('/user/register', [app\api\controller\UserController::class, 'register']); Route::any('/user/register', [app\api\controller\UserController::class, 'register']);
Route::post('/user/logout', [app\api\controller\UserController::class, 'logout']); })->middleware([
Route::get('/user/info', [app\api\controller\UserController::class, 'info']); CheckAuthTokenMiddleware::class,
Route::get('/user/balance', [app\api\controller\UserController::class, 'balance']); ]);
Route::get('/user/walletRecord', [app\api\controller\UserController::class, 'walletRecord']);
Route::get('/user/playGameRecord', [app\api\controller\UserController::class, 'playGameRecord']);
Route::post('/game/buyLotteryTickets', [app\api\controller\GameController::class, 'buyLotteryTickets']);
Route::get('/game/lotteryPool', [app\api\controller\GameController::class, 'lotteryPool']);
Route::post('/game/playStart', [app\api\controller\GameController::class, 'playStart']);
})->middleware([CheckApiAuthMiddleware::class]);
// 需 auth-token + user-token 的路由组
Route::group('/api', function () {
Route::any('/user/logout', [app\api\controller\UserController::class, 'logout']);
Route::any('/user/info', [app\api\controller\UserController::class, 'info']);
Route::any('/user/balance', [app\api\controller\UserController::class, 'balance']);
Route::any('/user/walletRecord', [app\api\controller\UserController::class, 'walletRecord']);
Route::any('/user/playGameRecord', [app\api\controller\UserController::class, 'playGameRecord']);
Route::any('/game/buyLotteryTickets', [app\api\controller\GameController::class, 'buyLotteryTickets']);
Route::any('/game/lotteryPool', [app\api\controller\GameController::class, 'lotteryPool']);
Route::any('/game/playStart', [app\api\controller\GameController::class, 'playStart']);
})->middleware([
CheckAuthTokenMiddleware::class,
CheckUserTokenMiddleware::class,
]);

View File

@@ -20,6 +20,11 @@ namespace support;
*/ */
class Request extends \Webman\Http\Request class Request extends \Webman\Http\Request
{ {
/** 由 CheckUserTokenMiddleware 注入:当前用户 ID */
public ?int $user_id = null;
/** 由 CheckUserTokenMiddleware 注入:当前 user-token 原始字符串 */
public ?string $userToken = null;
/** /**
* 获取参数增强方法 * 获取参数增强方法