Files
xianyan/docs/toolsapi/application/api/controller/FileTransfer.php
Developer 283950ea07 chore: 批量代码优化与功能迭代更新
本次提交包含大量代码优化、功能新增与服务端配置更新:
1. 修复分析报告统计数据,调整CMake策略设置
2. 优化APP权限配置、编辑器与聊天界面组件
3. 更新依赖库版本与pubspec配置
4. 新增文件传输服务端、信令服务器相关配置与脚本
5. 完善用户注销功能与数据库迁移脚本
6. 优化多处动画效果、代码风格与日志输出
7. 新增多种调试与部署脚本,修复已知BUG
2026-05-12 06:28:04 +08:00

754 lines
26 KiB
PHP

<?php
namespace app\api\controller;
use app\common\controller\Api;
use think\Config;
use think\Cache;
use think\Db;
/**
* 文件传输助手 — 服务端API
* 创建时间: 2026-05-10
* 更新时间: 2026-05-10
* 作用: TURN凭据发放 + 设备配对管理 + 信令服务状态 + 房间管理 + 速率限制
* 兼容: LocalSend v2.1 协议 + 闲言 xianyan-v1 协议
* 参考: github.com/MatrixSeven/file-transfer-go (房间模型)
* 上次更新: 升级数据库持久化+速率限制+房间管理API
*/
class FileTransfer extends Api
{
protected $noNeedLogin = ['*'];
protected $noNeedRight = ['*'];
private $turnSecret = 'xianyan-turn-secret-2026';
private $turnRealm = 'tools.wktyl.com';
private $turnHost = '123.207.67.197';
private $turnPort = 3478;
private $turnTtl = 86400;
private $signalingUrl = 'ws://127.0.0.1:9443';
private $rateLimitMax = 60;
private $rateLimitWindow = 60;
public function _initialize()
{
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Device-Id, X-Protocol');
if ($this->request->method() === 'OPTIONS') {
http_response_code(204);
exit;
}
parent::_initialize();
$this->checkRateLimit();
}
// ─── 速率限制 ─────────────────────────────────────────
private function checkRateLimit()
{
$ip = $this->request->ip();
$key = 'rate_limit_ft_' . md5($ip);
$now = time();
$requests = Cache::get($key, []);
$requests = array_filter($requests, function ($t) use ($now) {
return ($now - $t) < $this->rateLimitWindow;
});
if (count($requests) >= $this->rateLimitMax) {
$this->error('请求过于频繁,请稍后再试', null, 429);
}
$requests[] = $now;
Cache::set($key, $requests, $this->rateLimitWindow);
}
// ─── TURN凭据 ─────────────────────────────────────────
/**
* 获取TURN凭据
* POST /api/file_transfer/turn_credentials
*/
public function turn_credentials()
{
$deviceId = $this->request->header('X-Device-Id', '');
$fingerprint = $this->request->post('fingerprint', '');
if (empty($deviceId) && empty($fingerprint)) {
$this->error('deviceId or fingerprint required', null, 400);
}
$ident = $deviceId ?: $fingerprint;
$timestamp = time() + $this->turnTtl;
$username = $timestamp . ':' . substr($ident, 0, 16);
$password = base64_encode(hash_hmac('sha1', $username, $this->turnSecret, true));
$stunServers = [
'stun:stun.l.google.com:19302',
'stun:stun1.l.google.com:19302',
];
$turnUrls = [];
if (!empty($this->turnHost)) {
$turnUrls = [
"turn:{$this->turnHost}:{$this->turnPort}?transport=udp",
"turn:{$this->turnHost}:{$this->turnPort}?transport=tcp",
];
}
$iceServers = [];
foreach ($stunServers as $url) {
$iceServers[] = ['urls' => $url];
}
foreach ($turnUrls as $url) {
$iceServers[] = [
'urls' => $url,
'username' => $username,
'credential' => $password,
];
}
$this->success('ok', [
'username' => $username,
'password' => $password,
'urls' => $turnUrls,
'ttl' => $this->turnTtl,
'iceServers' => $iceServers,
]);
}
// ─── 信令服务状态 ──────────────────────────────────────
/**
* 获取信令服务信息
* GET /api/file_transfer/signaling_info
*/
public function signaling_info()
{
$protocol = $this->request->header('X-Protocol', 'xianyan-v1');
$host = $this->request->host(true);
$this->success('ok', [
'signalingUrl' => "wss://{$host}:9443",
'signalingHttp' => "https://{$host}/api/file_transfer/",
'protocols' => ['xianyan-v1', 'localsend-v2'],
'defaultProtocol'=> 'xianyan-v1',
'serverTime' => time() * 1000,
'features' => [
'webrtc' => true,
'localsend' => true,
'tcpSocket' => true,
'usbTether' => true,
'bluetooth' => true,
'nfc' => true,
'qrCode' => true,
'hotspot' => true,
'relay' => true,
'roomCode' => true,
],
]);
}
/**
* 信令服务健康检查
* GET /api/file_transfer/health
*/
public function health()
{
$signalingAlive = false;
$fp = @fsockopen('127.0.0.1', 9443, $errno, $errstr, 2);
if ($fp) {
$signalingAlive = true;
fclose($fp);
}
$dbAlive = false;
try {
Db::query('SELECT 1');
$dbAlive = true;
} catch (\Exception $e) {
$dbAlive = false;
}
$this->success('ok', [
'status' => ($signalingAlive && $dbAlive) ? 'healthy' : 'degraded',
'signalingAlive' => $signalingAlive,
'databaseAlive' => $dbAlive,
'serverTime' => time() * 1000,
'version' => '2.0.0',
]);
}
// ─── 配对管理 (数据库持久化) ────────────────────────────
/**
* 创建配对请求
* POST /api/file_transfer/pair_request
*/
public function pair_request()
{
$fromId = $this->request->post('fromId', '');
$toId = $this->request->post('toId', '');
$fingerprint = $this->request->post('fingerprint', '');
$alias = $this->request->post('alias', 'Unknown');
$deviceType = $this->request->post('deviceType', 'mobile');
if (empty($fromId) || empty($toId)) {
$this->error('fromId and toId required', null, 400);
}
$requestId = bin2hex(random_bytes(16));
$expiresAt = time() + 300;
try {
Db::name('file_transfer_pair_request')->insert([
'request_id' => $requestId,
'from_id' => $fromId,
'to_id' => $toId,
'fingerprint' => $fingerprint,
'alias' => $alias,
'device_type' => $deviceType,
'status' => 'pending',
'created_at' => time(),
'expires_at' => $expiresAt,
]);
} catch (\Exception $e) {
$this->fallbackPairRequest($requestId, $fromId, $toId, $fingerprint, $alias, $deviceType, $expiresAt);
}
$this->success('ok', [
'requestId' => $requestId,
'expiresAt' => $expiresAt * 1000,
]);
}
/**
* 接受配对请求
* POST /api/file_transfer/pair_accept
*/
public function pair_accept()
{
$requestId = $this->request->post('requestId', '');
$deviceId = $this->request->post('deviceId', '');
if (empty($requestId) || empty($deviceId)) {
$this->error('requestId and deviceId required', null, 400);
}
try {
$req = Db::name('file_transfer_pair_request')
->where('request_id', $requestId)
->where('status', 'pending')
->find();
if (!$req) {
$this->error('request not found or expired', null, 404);
}
if (time() > $req['expires_at']) {
Db::name('file_transfer_pair_request')
->where('request_id', $requestId)
->update(['status' => 'expired']);
$this->error('request expired', null, 410);
}
Db::name('file_transfer_pair_request')
->where('request_id', $requestId)
->update(['status' => 'accepted']);
$fromId = $req['from_id'];
$toId = $req['to_id'];
$now = time();
Db::name('file_transfer_pair_record')->insertAll([
[
'device_id' => $fromId,
'peer_device_id' => $toId,
'fingerprint' => $req['fingerprint'],
'alias' => $req['alias'],
'paired_at' => $now,
'last_verified_at'=> $now,
'is_trusted' => 1,
],
[
'device_id' => $toId,
'peer_device_id' => $fromId,
'fingerprint' => $req['fingerprint'],
'alias' => $req['alias'],
'paired_at' => $now,
'last_verified_at'=> $now,
'is_trusted' => 1,
],
]);
} catch (\Exception $e) {
$this->fallbackPairAccept($requestId, $deviceId);
return;
}
$this->success('ok', ['paired' => true]);
}
/**
* 拒绝配对请求
* POST /api/file_transfer/pair_reject
*/
public function pair_reject()
{
$requestId = $this->request->post('requestId', '');
try {
Db::name('file_transfer_pair_request')
->where('request_id', $requestId)
->update(['status' => 'rejected']);
} catch (\Exception $e) {
$this->fallbackPairReject($requestId);
}
$this->success('ok');
}
/**
* 获取已配对设备列表
* GET /api/file_transfer/paired_devices
*/
public function paired_devices()
{
$deviceId = $this->request->param('deviceId', '');
if (empty($deviceId)) {
$this->error('deviceId required', null, 400);
}
$paired = [];
try {
$paired = Db::name('file_transfer_pair_record')
->where('device_id', $deviceId)
->select();
} catch (\Exception $e) {
$paired = $this->fallbackPairedDevices($deviceId);
}
$this->success('ok', ['devices' => $paired]);
}
/**
* 删除配对
* POST /api/file_transfer/pair_delete
*/
public function pair_delete()
{
$deviceId = $this->request->post('deviceId', '');
$peerDeviceId = $this->request->post('peerDeviceId', '');
if (empty($deviceId) || empty($peerDeviceId)) {
$this->error('deviceId and peerDeviceId required', null, 400);
}
try {
Db::name('file_transfer_pair_record')
->where('device_id', $deviceId)
->where('peer_device_id', $peerDeviceId)
->delete();
Db::name('file_transfer_pair_record')
->where('device_id', $peerDeviceId)
->where('peer_device_id', $deviceId)
->delete();
} catch (\Exception $e) {
$this->fallbackPairDelete($deviceId, $peerDeviceId);
}
$this->success('ok');
}
// ─── 房间管理 (参考 file-transfer-go) ──────────────────
/**
* 创建房间
* POST /api/file_transfer/create_room
*/
public function create_room()
{
$deviceId = $this->request->post('deviceId', '');
$alias = $this->request->post('alias', 'Unknown');
$deviceType = $this->request->post('deviceType', 'mobile');
$code = $this->generateRoomCode();
$expiresAt = time() + 3600;
try {
Db::name('file_transfer_room')->insert([
'room_code' => $code,
'creator_id' => $deviceId,
'alias' => $alias,
'device_type' => $deviceType,
'status' => 'waiting',
'created_at' => time(),
'expires_at' => $expiresAt,
]);
} catch (\Exception $e) {
$file = RUNTIME_PATH . 'file_transfer' . DIRECTORY_SEPARATOR . 'rooms.json';
$rooms = $this->readJsonFile($file);
$rooms[$code] = [
'room_code' => $code,
'creator_id' => $deviceId,
'alias' => $alias,
'device_type' => $deviceType,
'status' => 'waiting',
'created_at' => time(),
'expires_at' => $expiresAt,
];
$this->writeJsonFile($file, $rooms);
}
$this->success('ok', [
'code' => $code,
'expiresAt' => $expiresAt * 1000,
'createdAt' => time() * 1000,
]);
}
/**
* 查询房间状态
* GET /api/file_transfer/room_status
*/
public function room_status()
{
$code = strtoupper($this->request->param('code', ''));
if (empty($code)) {
$this->error('code required', null, 400);
}
$room = null;
try {
$room = Db::name('file_transfer_room')
->where('room_code', $code)
->find();
} catch (\Exception $e) {
$file = RUNTIME_PATH . 'file_transfer' . DIRECTORY_SEPARATOR . 'rooms.json';
$rooms = $this->readJsonFile($file);
$room = $rooms[$code] ?? null;
}
if (!$room) {
$this->success('ok', [
'exists' => false,
'message' => '房间不存在或已过期',
]);
return;
}
if (time() > ($room['expires_at'] ?? 0)) {
$this->success('ok', [
'exists' => false,
'message' => '房间已过期',
]);
return;
}
$this->success('ok', [
'exists' => true,
'status' => $room['status'] ?? 'waiting',
'senderOnline' => in_array($room['status'], ['waiting', 'connected']),
'receiverOnline' => $room['status'] === 'connected',
'isFull' => $room['status'] === 'connected',
'createdAt' => ($room['created_at'] ?? 0) * 1000,
'expiresAt' => ($room['expires_at'] ?? 0) * 1000,
]);
}
/**
* 加入房间
* POST /api/file_transfer/join_room
*/
public function join_room()
{
$code = strtoupper($this->request->post('code', ''));
$role = $this->request->post('role', '');
$deviceId = $this->request->post('deviceId', '');
if (empty($code) || !in_array($role, ['sender', 'receiver'])) {
$this->error('code and role(sender/receiver) required', null, 400);
}
$room = null;
try {
$room = Db::name('file_transfer_room')
->where('room_code', $code)
->find();
} catch (\Exception $e) {
$file = RUNTIME_PATH . 'file_transfer' . DIRECTORY_SEPARATOR . 'rooms.json';
$rooms = $this->readJsonFile($file);
$room = $rooms[$code] ?? null;
}
if (!$room) {
$this->error('房间不存在或已过期', null, 404);
}
if (time() > ($room['expires_at'] ?? 0)) {
$this->error('房间已过期', null, 410);
}
if (($room['status'] ?? '') === 'connected') {
$this->error('房间已满,正在传输中无法加入', null, 403);
}
try {
if ($role === 'sender') {
Db::name('file_transfer_room')
->where('room_code', $code)
->update(['sender_id' => $deviceId, 'status' => 'waiting']);
} else {
Db::name('file_transfer_room')
->where('room_code', $code)
->update(['receiver_id' => $deviceId, 'status' => 'connected']);
}
} catch (\Exception $e) {
$file = RUNTIME_PATH . 'file_transfer' . DIRECTORY_SEPARATOR . 'rooms.json';
$rooms = $this->readJsonFile($file);
if (isset($rooms[$code])) {
if ($role === 'sender') {
$rooms[$code]['sender_id'] = $deviceId;
$rooms[$code]['status'] = 'waiting';
} else {
$rooms[$code]['receiver_id'] = $deviceId;
$rooms[$code]['status'] = 'connected';
}
$this->writeJsonFile($file, $rooms);
}
}
$this->success('ok', [
'joined' => true,
'code' => $code,
'role' => $role,
]);
}
// ─── LocalSend兼容端点 ─────────────────────────────────
/**
* LocalSend设备信息 (兼容)
* GET /api/file_transfer/localsend_info
*/
public function localsend_info()
{
$this->success('ok', [
'alias' => '闲言传输服务',
'version' => '2.1',
'deviceModel' => 'Server',
'deviceType' => 'server',
'fingerprint' => hash('sha256', 'xianyan-server-' . $this->turnSecret),
'download' => false,
'protocol' => 'https',
'port' => 53317,
]);
}
// ─── 数据库安装 ────────────────────────────────────────
/**
* 安装/升级数据库表
* POST /api/file_transfer/install
*/
public function install()
{
$prefix = Config::get('database.prefix');
$sqls = [];
$sqls[] = "CREATE TABLE IF NOT EXISTS `{$prefix}file_transfer_pair_request` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`request_id` varchar(64) NOT NULL DEFAULT '' COMMENT '请求ID',
`from_id` varchar(128) NOT NULL DEFAULT '' COMMENT '发起方设备ID',
`to_id` varchar(128) NOT NULL DEFAULT '' COMMENT '目标设备ID',
`fingerprint` varchar(256) NOT NULL DEFAULT '' COMMENT '设备指纹',
`alias` varchar(128) NOT NULL DEFAULT 'Unknown' COMMENT '设备别名',
`device_type` varchar(32) NOT NULL DEFAULT 'mobile' COMMENT '设备类型',
`status` enum('pending','accepted','rejected','expired') NOT NULL DEFAULT 'pending',
`created_at` int(11) unsigned NOT NULL DEFAULT 0,
`expires_at` int(11) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_request_id` (`request_id`),
KEY `idx_from_id` (`from_id`),
KEY `idx_to_id` (`to_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文件传输-配对请求'";
$sqls[] = "CREATE TABLE IF NOT EXISTS `{$prefix}file_transfer_pair_record` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`device_id` varchar(128) NOT NULL DEFAULT '' COMMENT '设备ID',
`peer_device_id` varchar(128) NOT NULL DEFAULT '' COMMENT '对端设备ID',
`fingerprint` varchar(256) NOT NULL DEFAULT '' COMMENT '对端指纹',
`alias` varchar(128) NOT NULL DEFAULT '' COMMENT '对端别名',
`paired_at` int(11) unsigned NOT NULL DEFAULT 0,
`last_verified_at` int(11) unsigned NOT NULL DEFAULT 0,
`is_trusted` tinyint(1) NOT NULL DEFAULT 1,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_pair` (`device_id`, `peer_device_id`),
KEY `idx_device_id` (`device_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文件传输-配对记录'";
$sqls[] = "CREATE TABLE IF NOT EXISTS `{$prefix}file_transfer_room` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`room_code` varchar(16) NOT NULL DEFAULT '' COMMENT '房间码',
`creator_id` varchar(128) NOT NULL DEFAULT '' COMMENT '创建者设备ID',
`sender_id` varchar(128) NOT NULL DEFAULT '' COMMENT '发送方设备ID',
`receiver_id` varchar(128) NOT NULL DEFAULT '' COMMENT '接收方设备ID',
`alias` varchar(128) NOT NULL DEFAULT '' COMMENT '创建者别名',
`device_type` varchar(32) NOT NULL DEFAULT 'mobile' COMMENT '设备类型',
`status` enum('waiting','connected','completed','expired') NOT NULL DEFAULT 'waiting',
`created_at` int(11) unsigned NOT NULL DEFAULT 0,
`expires_at` int(11) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_room_code` (`room_code`),
KEY `idx_creator` (`creator_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文件传输-房间'";
$results = [];
foreach ($sqls as $sql) {
try {
Db::execute($sql);
$results[] = 'ok';
} catch (\Exception $e) {
$results[] = $e->getMessage();
}
}
$this->success('ok', [
'tables' => count($sqls),
'results' => $results,
]);
}
// ─── 文件缓存降级方案 ──────────────────────────────────
private function fallbackPairRequest($requestId, $fromId, $toId, $fingerprint, $alias, $deviceType, $expiresAt)
{
$file = RUNTIME_PATH . 'file_transfer' . DIRECTORY_SEPARATOR . 'pair_requests.json';
$requests = $this->readJsonFile($file);
$requests[$requestId] = [
'id' => $requestId,
'fromId' => $fromId,
'toId' => $toId,
'fingerprint' => $fingerprint,
'alias' => $alias,
'deviceType' => $deviceType,
'createdAt' => time(),
'expiresAt' => $expiresAt,
];
$this->writeJsonFile($file, $requests);
}
private function fallbackPairAccept($requestId, $deviceId)
{
$reqFile = RUNTIME_PATH . 'file_transfer' . DIRECTORY_SEPARATOR . 'pair_requests.json';
$requests = $this->readJsonFile($reqFile);
if (!isset($requests[$requestId])) {
$this->error('request not found or expired', null, 404);
}
$req = $requests[$requestId];
if (time() > $req['expiresAt']) {
unset($requests[$requestId]);
$this->writeJsonFile($reqFile, $requests);
$this->error('request expired', null, 410);
}
unset($requests[$requestId]);
$this->writeJsonFile($reqFile, $requests);
$pairFile = RUNTIME_PATH . 'file_transfer' . DIRECTORY_SEPARATOR . 'pair_records.json';
$records = $this->readJsonFile($pairFile);
$fromId = $req['fromId'];
$toId = $req['toId'];
$now = time();
$records[$fromId . ':' . $toId] = [
'deviceId' => $fromId,
'peerDeviceId' => $toId,
'fingerprint' => $req['fingerprint'],
'pairedAt' => $now,
'lastVerifiedAt' => $now,
'isTrusted' => true,
];
$records[$toId . ':' . $fromId] = [
'deviceId' => $toId,
'peerDeviceId' => $fromId,
'fingerprint' => $req['fingerprint'],
'pairedAt' => $now,
'lastVerifiedAt' => $now,
'isTrusted' => true,
];
$this->writeJsonFile($pairFile, $records);
$this->success('ok', ['paired' => true]);
}
private function fallbackPairReject($requestId)
{
$reqFile = RUNTIME_PATH . 'file_transfer' . DIRECTORY_SEPARATOR . 'pair_requests.json';
$requests = $this->readJsonFile($reqFile);
if (isset($requests[$requestId])) {
unset($requests[$requestId]);
$this->writeJsonFile($reqFile, $requests);
}
}
private function fallbackPairedDevices($deviceId)
{
$pairFile = RUNTIME_PATH . 'file_transfer' . DIRECTORY_SEPARATOR . 'pair_records.json';
$records = $this->readJsonFile($pairFile);
$paired = [];
foreach ($records as $key => $rec) {
if ($rec['deviceId'] === $deviceId) {
$paired[] = $rec;
}
}
return $paired;
}
private function fallbackPairDelete($deviceId, $peerDeviceId)
{
$pairFile = RUNTIME_PATH . 'file_transfer' . DIRECTORY_SEPARATOR . 'pair_records.json';
$records = $this->readJsonFile($pairFile);
unset($records[$deviceId . ':' . $peerDeviceId]);
unset($records[$peerDeviceId . ':' . $deviceId]);
$this->writeJsonFile($pairFile, $records);
}
// ─── 工具方法 ──────────────────────────────────────────
private function generateRoomCode()
{
$chars = '123456789ABCDEFGHIJKLMNPQRSTUVWXYZ';
$code = '';
for ($i = 0; $i < 6; $i++) {
$code .= $chars[random_int(0, strlen($chars) - 1)];
}
return $code;
}
private function readJsonFile(string $path): array
{
if (!is_file($path)) {
return [];
}
$content = file_get_contents($path);
$data = json_decode($content, true);
return is_array($data) ? $data : [];
}
private function writeJsonFile(string $path, array $data): void
{
$dir = dirname($path);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
file_put_contents(
$path,
json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE),
LOCK_EX
);
}
}