155 lines
6.6 KiB
PHP
155 lines
6.6 KiB
PHP
<?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', '云端存档暂时不可用,本机进度仍可保留');
|
|
}
|
|
}
|