gengxin
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use app\api\logic\tcm\TangDetectiveLogic;
|
||||
use app\common\service\game\TangDetectiveProgress;
|
||||
use app\common\service\game\TangDetectiveProgressException;
|
||||
use think\facade\Log;
|
||||
|
||||
/** All actions use the existing LoginMiddleware token identity. */
|
||||
class TangDetectiveController extends BaseApiController
|
||||
{
|
||||
public array $notNeedLogin = [];
|
||||
|
||||
public function catalog()
|
||||
{
|
||||
return $this->respond('GET', static fn (): array => (new TangDetectiveProgress())->catalog());
|
||||
}
|
||||
|
||||
public function progress()
|
||||
{
|
||||
return $this->respond('GET', fn (): array => (new TangDetectiveLogic())->read((int) $this->userId));
|
||||
}
|
||||
|
||||
public function saveProgress()
|
||||
{
|
||||
return $this->respond('POST', function (): array {
|
||||
if (strtolower($this->request->contentType()) !== 'application/json') {
|
||||
throw new TangDetectiveProgressException('UNSUPPORTED_MEDIA_TYPE', '请使用 JSON 提交存档');
|
||||
}
|
||||
$policy = new TangDetectiveProgress();
|
||||
$request = $policy->decodeRequest((string) $this->request->getContent());
|
||||
return (new TangDetectiveLogic($policy))->save((int) $this->userId, $request);
|
||||
});
|
||||
}
|
||||
|
||||
private function respond(string $method, callable $action)
|
||||
{
|
||||
try {
|
||||
// Guard methods too: ThinkPHP's conventional controller routes remain enabled.
|
||||
if (($method === 'GET' && !$this->request->isGet())
|
||||
|| ($method === 'POST' && !$this->request->isPost())) {
|
||||
throw new TangDetectiveProgressException('METHOD_NOT_ALLOWED', '请求方式不正确');
|
||||
}
|
||||
if ($this->userId <= 0) {
|
||||
throw new TangDetectiveProgressException('AUTH_REQUIRED', '请先登录');
|
||||
}
|
||||
return $this->data($action())->header(['Cache-Control' => 'no-store']);
|
||||
} catch (TangDetectiveProgressException $exception) {
|
||||
return $this->fail($exception->getMessage(), ['error_code' => $exception->errorCode()], 0, 0)
|
||||
->header(['Cache-Control' => 'no-store']);
|
||||
} catch (\Throwable $exception) {
|
||||
try {
|
||||
Log::warning('tang_detective_request_failure', [
|
||||
'user_id' => $this->userId,
|
||||
'error_code' => 'STORAGE_UNAVAILABLE',
|
||||
'exception_class' => get_class($exception),
|
||||
]);
|
||||
} catch (\Throwable $loggingException) {
|
||||
// No raw exception, request body, token or SQL is exposed on failure.
|
||||
}
|
||||
return $this->fail('云端存档暂时不可用,本机进度仍可保留', ['error_code' => 'STORAGE_UNAVAILABLE'], 0, 0)
|
||||
->header(['Cache-Control' => 'no-store']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\api\logic\tcm;
|
||||
|
||||
use app\common\service\game\TangDetectiveProgress;
|
||||
use app\common\service\game\TangDetectiveProgressException;
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
|
||||
/** Own-user persistence only. This class never touches the match-three tables. */
|
||||
final class TangDetectiveLogic
|
||||
{
|
||||
private const TABLE = 'tcm_tang_detective_progress';
|
||||
private TangDetectiveProgress $policy;
|
||||
|
||||
public function __construct(?TangDetectiveProgress $policy = null)
|
||||
{
|
||||
$this->policy = $policy ?? new TangDetectiveProgress();
|
||||
}
|
||||
|
||||
public function read(int $userId): array
|
||||
{
|
||||
$this->policy->defaultState($userId);
|
||||
try {
|
||||
$row = Db::name(self::TABLE)->where('user_id', $userId)->find();
|
||||
return $row ? $this->hydrate($userId, $row) : $this->policy->defaultState($userId);
|
||||
} catch (\Throwable $exception) {
|
||||
$this->storageFailure('read', $userId, $exception);
|
||||
}
|
||||
}
|
||||
|
||||
/** $request is exclusively the output of TangDetectiveProgress::decodeRequest. */
|
||||
public function save(int $userId, array $request): array
|
||||
{
|
||||
$this->policy->defaultState($userId);
|
||||
$transactionOpen = false;
|
||||
try {
|
||||
Db::startTrans();
|
||||
$transactionOpen = true;
|
||||
$row = Db::name(self::TABLE)->where('user_id', $userId)->lock(true)->find();
|
||||
$current = $row ? $this->hydrate($userId, $row) : $this->policy->defaultState($userId);
|
||||
$change = $this->policy->transition(
|
||||
$current,
|
||||
$request,
|
||||
(string) ($row['last_request_id'] ?? ''),
|
||||
(string) ($row['last_request_hash'] ?? '')
|
||||
);
|
||||
if (!$change['idempotent']) {
|
||||
$state = $change['state'];
|
||||
$values = [
|
||||
'schema_version' => $state['schema_version'],
|
||||
'content_version' => $state['content_version'],
|
||||
'revision' => $state['revision'],
|
||||
'story_generation' => $state['story_generation'],
|
||||
'progress_json' => $this->policy->encode($state['progress']),
|
||||
'last_request_id' => $request['request_id'],
|
||||
'last_request_hash' => $change['request_hash'],
|
||||
'update_time' => time(),
|
||||
];
|
||||
if ($row) {
|
||||
$updated = Db::name(self::TABLE)
|
||||
->where('user_id', $userId)
|
||||
->where('revision', $current['revision'])
|
||||
->where('story_generation', $current['story_generation'])
|
||||
->update($values);
|
||||
if ($updated !== 1) {
|
||||
throw new TangDetectiveProgressException('PROGRESS_CONFLICT', '存档已变化,请重新读取');
|
||||
}
|
||||
} else {
|
||||
// UNIQUE(user_id) arbitrates concurrent first saves. Never upsert/overwrite.
|
||||
$inserted = Db::name(self::TABLE)->insert($values + [
|
||||
'user_id' => $userId,
|
||||
'create_time' => time(),
|
||||
]);
|
||||
if ((int) $inserted !== 1) {
|
||||
throw new \UnexpectedValueException('Tang progress insert was not confirmed');
|
||||
}
|
||||
}
|
||||
}
|
||||
Db::commit();
|
||||
$transactionOpen = false;
|
||||
return $change['state'] + ['idempotent' => $change['idempotent']];
|
||||
} catch (\Throwable $exception) {
|
||||
if ($transactionOpen) {
|
||||
try {
|
||||
Db::rollback();
|
||||
} catch (\Throwable $rollbackException) {
|
||||
$this->storageFailure('rollback', $userId, $rollbackException);
|
||||
}
|
||||
}
|
||||
if ($exception instanceof TangDetectiveProgressException) {
|
||||
throw $exception;
|
||||
}
|
||||
if ($this->isConcurrentWriteFailure($exception)) {
|
||||
throw new TangDetectiveProgressException('PROGRESS_CONFLICT', '另一处正在保存,请重新读取存档');
|
||||
}
|
||||
$this->storageFailure('save', $userId, $exception);
|
||||
}
|
||||
}
|
||||
|
||||
private function hydrate(int $userId, array $row): array
|
||||
{
|
||||
if ((int) $row['user_id'] !== $userId || (int) $row['schema_version'] !== 1
|
||||
|| $row['content_version'] !== 'season-01'
|
||||
|| (int) $row['revision'] < 1 || (int) $row['story_generation'] < 0) {
|
||||
throw new \UnexpectedValueException('Stored Tang progress metadata is invalid');
|
||||
}
|
||||
try {
|
||||
$progress = $this->policy->normalizeProgress(
|
||||
json_decode((string) $row['progress_json'], false, 12, JSON_THROW_ON_ERROR)
|
||||
);
|
||||
} catch (\Throwable $exception) {
|
||||
// Corrupt persisted data is not a client validation error. Do not echo its content.
|
||||
throw new \UnexpectedValueException('Stored Tang progress is invalid');
|
||||
}
|
||||
$state = $this->policy->defaultState($userId);
|
||||
$state['revision'] = (int) $row['revision'];
|
||||
$state['story_generation'] = (int) $row['story_generation'];
|
||||
$state['progress'] = $progress;
|
||||
return $state;
|
||||
}
|
||||
|
||||
private function isConcurrentWriteFailure(\Throwable $exception): bool
|
||||
{
|
||||
if ($exception instanceof \PDOException) {
|
||||
return in_array((int) ($exception->errorInfo[1] ?? 0), [1062, 1205, 1213], true)
|
||||
|| (string) $exception->getCode() === '40001';
|
||||
}
|
||||
if ($exception instanceof \think\db\exception\PDOException) {
|
||||
$details = $exception->getData()['PDO Error Info'] ?? [];
|
||||
return in_array((int) ($details['Driver Error Code'] ?? 0), [1062, 1205, 1213], true)
|
||||
|| ($details['SQLSTATE'] ?? '') === '40001';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private function storageFailure(string $operation, int $userId, \Throwable $exception): void
|
||||
{
|
||||
try {
|
||||
// Deliberately omit exception message/trace: ORM errors may contain SQL or payloads.
|
||||
Log::warning('tang_detective_storage_failure', [
|
||||
'operation' => $operation,
|
||||
'user_id' => $userId,
|
||||
'error_code' => 'STORAGE_UNAVAILABLE',
|
||||
'exception_class' => get_class($exception),
|
||||
]);
|
||||
} catch (\Throwable $loggingException) {
|
||||
// An unavailable log sink must not reveal the original storage exception.
|
||||
}
|
||||
throw new TangDetectiveProgressException('STORAGE_UNAVAILABLE', '云端存档暂时不可用,本机进度仍可保留');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\game;
|
||||
|
||||
/** ID-only progress policy. No framework, database, filesystem writes or network. */
|
||||
final class TangDetectiveProgress
|
||||
{
|
||||
public const MAX_BODY_BYTES = 32768;
|
||||
private array $catalog;
|
||||
private array $chapters = [];
|
||||
private array $cardIds = [];
|
||||
|
||||
public function __construct(?array $catalog = null)
|
||||
{
|
||||
$this->catalog = $catalog ?? require dirname(__DIR__, 4) . '/config/tang_detective.php';
|
||||
foreach ($this->catalog['chapters'] as $chapter) {
|
||||
$this->chapters[$chapter['chapter_id']] = $chapter;
|
||||
$this->cardIds[] = $chapter['memory_card_id'];
|
||||
}
|
||||
}
|
||||
|
||||
public function catalog(): array
|
||||
{
|
||||
return $this->catalog;
|
||||
}
|
||||
|
||||
public function defaultProgress(): array
|
||||
{
|
||||
return [
|
||||
'completedHotspots' => (object) [],
|
||||
'completedChapters' => [],
|
||||
'lastChapter' => 1,
|
||||
'collectedMemoryCards' => [],
|
||||
'comicReaderByChapter' => (object) [],
|
||||
'lastPageId' => '',
|
||||
];
|
||||
}
|
||||
|
||||
public function defaultState(int $userId): array
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
throw new TangDetectiveProgressException('AUTH_REQUIRED', '请先登录');
|
||||
}
|
||||
return [
|
||||
'user_id' => $userId,
|
||||
'schema_version' => 1,
|
||||
'content_version' => 'season-01',
|
||||
'revision' => 0,
|
||||
'story_generation' => 0,
|
||||
'progress' => $this->defaultProgress(),
|
||||
];
|
||||
}
|
||||
|
||||
/** Decode raw JSON so objects, lists, integers and booleans stay distinct. */
|
||||
public function decodeRequest(string $raw): array
|
||||
{
|
||||
if (strlen($raw) > self::MAX_BODY_BYTES) {
|
||||
throw new TangDetectiveProgressException('PAYLOAD_TOO_LARGE', '存档请求超过大小限制');
|
||||
}
|
||||
try {
|
||||
$decoded = json_decode($raw, false, 12, JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException $exception) {
|
||||
$this->invalid('存档请求不是有效的 JSON 对象');
|
||||
}
|
||||
$source = $this->record($decoded, [
|
||||
'schema_version', 'content_version', 'base_revision', 'story_generation',
|
||||
'request_id', 'operation', 'progress',
|
||||
], true);
|
||||
if ($source['schema_version'] !== 1 || $source['content_version'] !== 'season-01') {
|
||||
throw new TangDetectiveProgressException('UNSUPPORTED_CONTENT_VERSION', '存档版本不匹配,请更新后再试');
|
||||
}
|
||||
$this->boundedInteger($source['base_revision'], 0, 2147483646);
|
||||
$this->boundedInteger($source['story_generation'], 0, 2147483646);
|
||||
if (!is_string($source['request_id']) || !preg_match('/\A[A-Za-z0-9_-]{16,64}\z/D', $source['request_id'])) {
|
||||
$this->invalid('存档请求标识无效');
|
||||
}
|
||||
if (!in_array($source['operation'], ['replace', 'reset_story'], true)) {
|
||||
$this->invalid('存档操作无效');
|
||||
}
|
||||
$progress = $this->normalizeProgress($source['progress']);
|
||||
if ($source['operation'] === 'reset_story') {
|
||||
$empty = $this->defaultProgress();
|
||||
$empty['collectedMemoryCards'] = $progress['collectedMemoryCards'];
|
||||
if ($this->encode($empty) !== $this->encode($progress)) {
|
||||
$this->invalid('重新开始请求必须清空故事进度');
|
||||
}
|
||||
}
|
||||
return [
|
||||
'schema_version' => 1,
|
||||
'content_version' => 'season-01',
|
||||
'base_revision' => $source['base_revision'],
|
||||
'story_generation' => $source['story_generation'],
|
||||
'request_id' => $source['request_id'],
|
||||
'operation' => $source['operation'],
|
||||
'progress' => $progress,
|
||||
];
|
||||
}
|
||||
|
||||
/** Reject unknown fields; canonicalize only equivalent ordering of sets/maps. */
|
||||
public function normalizeProgress($value): array
|
||||
{
|
||||
$source = $this->record($value, [
|
||||
'completedHotspots', 'completedChapters', 'lastChapter',
|
||||
'collectedMemoryCards', 'comicReaderByChapter', 'lastPageId',
|
||||
], true);
|
||||
$this->boundedInteger($source['lastChapter'], 1, count($this->chapters));
|
||||
$hotspots = $this->record($source['completedHotspots'], array_keys($this->chapters));
|
||||
$readers = $this->record($source['comicReaderByChapter'], array_keys($this->chapters));
|
||||
$finished = $this->idSet($source['completedChapters'], array_keys($this->chapters));
|
||||
$cards = $this->idSet($source['collectedMemoryCards'], $this->cardIds);
|
||||
$cleanHotspots = [];
|
||||
$cleanReaders = [];
|
||||
foreach ($this->chapters as $id => $chapter) {
|
||||
$events = $this->eventPrefix($hotspots[$id] ?? [], $chapter['hotspot_ids']);
|
||||
if (array_key_exists($id, $hotspots)) {
|
||||
$cleanHotspots[$id] = $events;
|
||||
}
|
||||
$isFinished = in_array($id, $finished, true);
|
||||
if (!array_key_exists($id, $readers)) {
|
||||
if ($events !== [] || $isFinished) {
|
||||
$this->invalid('章节存档缺少一致的阅读状态');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
$reader = $this->record($readers[$id], ['currentPageId', 'completedEventIds', 'chapterFinished'], true);
|
||||
$readerEvents = $this->eventPrefix($reader['completedEventIds'], $chapter['hotspot_ids']);
|
||||
if (!is_bool($reader['chapterFinished']) || $readerEvents !== $events
|
||||
|| $reader['chapterFinished'] !== $isFinished
|
||||
|| ($isFinished && count($events) !== 4)) {
|
||||
$this->invalid('章节完成状态与事件进度不一致');
|
||||
}
|
||||
$pageIndex = is_string($reader['currentPageId'])
|
||||
? array_search($reader['currentPageId'], $chapter['page_ids'], true)
|
||||
: false;
|
||||
$lastUnlocked = $isFinished ? 7 : min(6, 2 + count($events));
|
||||
if ($pageIndex === false || $pageIndex > $lastUnlocked) {
|
||||
$this->invalid('阅读页码无效或尚未解锁');
|
||||
}
|
||||
$cleanReaders[$id] = [
|
||||
'currentPageId' => $reader['currentPageId'],
|
||||
'completedEventIds' => $readerEvents,
|
||||
'chapterFinished' => $reader['chapterFinished'],
|
||||
];
|
||||
}
|
||||
if (!is_string($source['lastPageId'])) {
|
||||
$this->invalid('最后阅读页码无效');
|
||||
}
|
||||
$lastId = sprintf('S01-C%02d', $source['lastChapter']);
|
||||
if ($source['lastPageId'] !== '' && (
|
||||
!isset($cleanReaders[$lastId])
|
||||
|| $cleanReaders[$lastId]['currentPageId'] !== $source['lastPageId']
|
||||
)) {
|
||||
$this->invalid('最后阅读页码与章节书签不一致');
|
||||
}
|
||||
return [
|
||||
'completedHotspots' => (object) $cleanHotspots,
|
||||
'completedChapters' => $finished,
|
||||
'lastChapter' => $source['lastChapter'],
|
||||
'collectedMemoryCards' => $cards,
|
||||
'comicReaderByChapter' => (object) $cleanReaders,
|
||||
'lastPageId' => $source['lastPageId'],
|
||||
];
|
||||
}
|
||||
|
||||
/** Called under the user's database row lock; request must be decodeRequest's result. */
|
||||
public function transition(array $current, array $request, string $lastRequestId = '', string $lastHash = ''): array
|
||||
{
|
||||
$hash = hash('sha256', $this->encode($request));
|
||||
if ($lastRequestId !== '' && hash_equals($lastRequestId, $request['request_id'])) {
|
||||
if (!hash_equals($lastHash, $hash)) {
|
||||
throw new TangDetectiveProgressException('IDEMPOTENCY_CONFLICT', '同一请求标识不能提交不同存档');
|
||||
}
|
||||
return ['state' => $current, 'request_hash' => $hash, 'idempotent' => true];
|
||||
}
|
||||
if ($request['base_revision'] !== $current['revision']
|
||||
|| $request['story_generation'] !== $current['story_generation']) {
|
||||
throw new TangDetectiveProgressException('PROGRESS_CONFLICT', '存档已变化,请选择云端或本机进度');
|
||||
}
|
||||
if ($current['revision'] >= 2147483646 || $current['story_generation'] >= 2147483646) {
|
||||
throw new TangDetectiveProgressException('PROGRESS_CONFLICT', '存档版本超出范围,请联系管理员');
|
||||
}
|
||||
$next = $current;
|
||||
$next['progress'] = $request['progress'];
|
||||
if ($request['operation'] === 'reset_story') {
|
||||
$next['progress'] = $this->defaultProgress();
|
||||
$cards = array_unique(array_merge(
|
||||
$current['progress']['collectedMemoryCards'],
|
||||
$request['progress']['collectedMemoryCards']
|
||||
));
|
||||
$next['progress']['collectedMemoryCards'] = array_values(array_intersect($this->cardIds, $cards));
|
||||
++$next['story_generation'];
|
||||
}
|
||||
++$next['revision'];
|
||||
return ['state' => $next, 'request_hash' => $hash, 'idempotent' => false];
|
||||
}
|
||||
|
||||
public function encode(array $value): string
|
||||
{
|
||||
return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
private function record($value, array $allowed, bool $requireAll = false): array
|
||||
{
|
||||
if (!$value instanceof \stdClass) {
|
||||
$this->invalid('存档字段必须是对象');
|
||||
}
|
||||
$fields = get_object_vars($value);
|
||||
if (array_diff(array_keys($fields), $allowed) !== []
|
||||
|| ($requireAll && array_diff($allowed, array_keys($fields)) !== [])) {
|
||||
$this->invalid('存档包含未知字段或缺少必要字段');
|
||||
}
|
||||
return $fields;
|
||||
}
|
||||
|
||||
private function idSet($value, array $allowed): array
|
||||
{
|
||||
if (!is_array($value) || $value !== array_values($value) || count($value) > count($allowed)) {
|
||||
$this->invalid('存档标识列表无效');
|
||||
}
|
||||
$seen = [];
|
||||
foreach ($value as $id) {
|
||||
if (!is_string($id) || !in_array($id, $allowed, true) || isset($seen[$id])) {
|
||||
$this->invalid('存档包含非法或重复标识');
|
||||
}
|
||||
$seen[$id] = true;
|
||||
}
|
||||
return array_values(array_filter($allowed, static fn (string $id): bool => isset($seen[$id])));
|
||||
}
|
||||
|
||||
private function eventPrefix($value, array $allowed): array
|
||||
{
|
||||
if (!is_array($value) || $value !== array_slice($allowed, 0, count($value))) {
|
||||
$this->invalid('事件进度必须是本章连续前缀');
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function boundedInteger($value, int $minimum, int $maximum): void
|
||||
{
|
||||
if (!is_int($value) || $value < $minimum || $value > $maximum) {
|
||||
$this->invalid('存档整数参数无效');
|
||||
}
|
||||
}
|
||||
|
||||
private function invalid(string $message): void
|
||||
{
|
||||
throw new TangDetectiveProgressException('INVALID_REQUEST', $message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\game;
|
||||
|
||||
final class TangDetectiveProgressException extends \DomainException
|
||||
{
|
||||
private string $errorCode;
|
||||
|
||||
public function __construct(string $errorCode, string $message)
|
||||
{
|
||||
parent::__construct($message);
|
||||
$this->errorCode = $errorCode;
|
||||
}
|
||||
|
||||
public function errorCode(): string
|
||||
{
|
||||
return $this->errorCode;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user