- 新增 lsky.php:兰空图床 API 客户端,token 缓存+提前60秒续签+401自动重签重试 - 新增 lsky_keys.php:图床凭据模板(仓库仅占位符,真值只在服务器,永不上传) - api/ticket.php:新增 upload_image 动作,限 png/jpg、单张≤2048K、内容与后缀必须一致、同会话每分钟限12次;web_tickets 增 images 列(JSON 数组,上限3张,URL 仅限自家图床) - core.php:权威建表补 images 列 + 老库幂等 ALTER 迁移 - player/admin 两端:上传即暂存、光标处插入 [图片N] 滚动占位符、上限3张;提交时随工单入库 - player/admin/service 三端详情:占位符还原为 markdown 图片,未引用图片补在末尾
166 lines
7.1 KiB
PHP
166 lines
7.1 KiB
PHP
<?php
|
||
/**
|
||
* 兰空图床(Lsky Pro)API 客户端 —— 工单图片上传专用
|
||
*
|
||
* 接口规格(图床官方 API 文档,站点 https://img.ypshidifu.cn/api/v1/):
|
||
* POST /tokens body {email,password} -> {status, data:{token, expires_at}}
|
||
* POST /upload multipart: file(+strategy_id)
|
||
* Header: Authorization: Bearer <token>
|
||
* -> {status, data:{key,links:{url,html,markdown,...},thumbnail_url,...}}
|
||
*
|
||
* 设计要点:
|
||
* - 凭据在 lsky_keys.php(仓库只有空模板,部署脚本永不上传);
|
||
* - token 有有效期 -> 文件缓存 + 提前 60 秒自动续签 + 上传遇 401 自动强刷重签重试 1 次;
|
||
* - 本文件不含任何敏感值,可入库、可批量部署。
|
||
*
|
||
* ★ 依赖 core.php 的 error()/success()(本文件只被 api/ticket.php 引入,core 必然已加载)。
|
||
*/
|
||
|
||
// 图床前缀白名单:写库前校验图片 URL 只能来自自家图床(防存任意 URL)。
|
||
if (!defined('LSKY_ALLOWED_HOST_PREFIX')) {
|
||
define('LSKY_ALLOWED_HOST_PREFIX', 'https://img.ypshidifu.cn/');
|
||
}
|
||
|
||
/** 加载 lsky_keys.php(只加载一次),并检查必填项 */
|
||
function lskyLoadKeys() {
|
||
static $done = false;
|
||
if ($done) return;
|
||
$file = __DIR__ . '/lsky_keys.php';
|
||
if (!file_exists($file)) {
|
||
error('图床配置缺失:服务器上没有 lsky_keys.php(请参照仓库模板放置)', 500);
|
||
}
|
||
require_once $file;
|
||
$done = true;
|
||
if (!defined('LSKY_API_URL') || !defined('LSKY_EMAIL') || !defined('LSKY_PASSWORD')) {
|
||
error('图床配置不完整:LSKY_API_URL / LSKY_EMAIL / LSKY_PASSWORD 必须定义', 500);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 底层 HTTP(curl)。$isMultipart=true 时 $payload 直接作为 CURLOPT_POSTFIELDS 数组
|
||
* (boundary 交给 curl 自动处理,切勿手写 Content-Type)。
|
||
* 返回 ['ok','status','msg','data']。
|
||
*/
|
||
function lskyCurlJson($method, $url, $payload = null, $token = null, $isMultipart = false) {
|
||
if (!function_exists('curl_init')) {
|
||
return ['ok' => false, 'status' => 0, 'msg' => '服务器缺少 PHP curl 扩展', 'data' => null];
|
||
}
|
||
$ch = curl_init();
|
||
curl_setopt($ch, CURLOPT_URL, $url);
|
||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
|
||
curl_setopt($ch, CURLOPT_TIMEOUT, 60);
|
||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
|
||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
|
||
|
||
$headers = ['Accept: application/json'];
|
||
if ($token !== null && $token !== '') $headers[] = 'Authorization: Bearer ' . $token;
|
||
|
||
if ($method === 'POST') {
|
||
curl_setopt($ch, CURLOPT_POST, true);
|
||
if ($isMultipart) {
|
||
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); // 数组 -> multipart
|
||
} else {
|
||
$json = json_encode($payload === null ? new stdClass() : $payload);
|
||
curl_setopt($ch, CURLOPT_POSTFIELDS, $json);
|
||
$headers[] = 'Content-Type: application/json';
|
||
}
|
||
} else {
|
||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
|
||
}
|
||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||
|
||
$body = curl_exec($ch);
|
||
$errno = curl_errno($ch);
|
||
$err = curl_error($ch);
|
||
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||
curl_close($ch);
|
||
|
||
if ($errno) {
|
||
return ['ok' => false, 'status' => 0, 'msg' => 'cURL错误: ' . $err, 'data' => null];
|
||
}
|
||
$j = json_decode((string)$body, true);
|
||
return [
|
||
'ok' => ($status >= 200 && $status < 300),
|
||
'status' => $status,
|
||
'msg' => (is_array($j) && isset($j['message']) && $j['message'] !== '')
|
||
? (string)$j['message']
|
||
: ('HTTP ' . $status . ' ' . substr((string)$body, 0, 160)),
|
||
'data' => (is_array($j) && isset($j['data']) && is_array($j['data'])) ? $j['data'] : null,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 获取 API token:缓存优先(提前 60 秒过期),失效则 POST /tokens 续签并落盘。
|
||
* 续签失败但缓存里还有旧 token 时返回旧 token(图床临时抖动不至于让上传全挂)。
|
||
*/
|
||
function lskyGetToken($force = false) {
|
||
lskyLoadKeys();
|
||
$cache = defined('LSKY_TOKEN_CACHE') ? LSKY_TOKEN_CACHE : (__DIR__ . '/db/lsky_token.json');
|
||
$cached = null;
|
||
if (!$force && is_readable($cache)) {
|
||
$cached = json_decode((string)@file_get_contents($cache), true);
|
||
if (is_array($cached) && !empty($cached['token'])
|
||
&& isset($cached['expire_at']) && time() < (int)$cached['expire_at'] - 60) {
|
||
return $cached['token'];
|
||
}
|
||
}
|
||
$r = lskyCurlJson('POST', rtrim(LSKY_API_URL, '/') . '/tokens',
|
||
['email' => LSKY_EMAIL, 'password' => LSKY_PASSWORD]);
|
||
if ($r['ok'] && !empty($r['data']['token'])) {
|
||
$token = (string)$r['data']['token'];
|
||
$expire = time() + 3600; // 响应没有 expires_at 时按 1 小时兜底
|
||
if (!empty($r['data']['expires_at'])) {
|
||
$ts = strtotime((string)$r['data']['expires_at']);
|
||
if ($ts !== false && $ts > time()) $expire = $ts;
|
||
}
|
||
$dir = dirname($cache);
|
||
if (!is_dir($dir)) @mkdir($dir, 0755, true);
|
||
@file_put_contents($cache,
|
||
json_encode(['token' => $token, 'expire_at' => $expire, 'cached_at' => time()]),
|
||
LOCK_EX);
|
||
return $token;
|
||
}
|
||
// 续签失败 -> 旧缓存顶着用
|
||
if (is_array($cached) && !empty($cached['token'])) return $cached['token'];
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 上传一张已通过校验的图片到图床。
|
||
* 401(token 失效)自动强刷 token 重试 1 次。
|
||
* 成功返回 ['ok'=>true,'url'=>..,'thumb'=>..,'key'=>..],失败 ['ok'=>false,'msg'=>..]
|
||
*/
|
||
function lskyUploadFile($tmpPath, $origName) {
|
||
lskyLoadKeys();
|
||
if (!is_uploaded_file($tmpPath)) {
|
||
return ['ok' => false, 'msg' => '非法上传(is_uploaded_file 校验失败)'];
|
||
}
|
||
$mime = 'application/octet-stream';
|
||
$info = @getimagesize($tmpPath);
|
||
if (!empty($info['mime'])) $mime = (string)$info['mime'];
|
||
|
||
for ($try = 0; $try < 2; $try++) {
|
||
$token = lskyGetToken($try > 0);
|
||
if (!$token) {
|
||
return ['ok' => false, 'msg' => '获取图床 token 失败(请检查 lsky_keys.php 的邮箱密码)'];
|
||
}
|
||
$fields = ['file' => new CURLFile($tmpPath, $mime, $origName)];
|
||
if (defined('LSKY_STRATEGY_ID') && (int)LSKY_STRATEGY_ID > 0) {
|
||
$fields['strategy_id'] = (int)LSKY_STRATEGY_ID;
|
||
}
|
||
$r = lskyCurlJson('POST', rtrim(LSKY_API_URL, '/') . '/upload', $fields, $token, true);
|
||
if ($r['ok'] && !empty($r['data']['links']['url'])) {
|
||
$url = (string)$r['data']['links']['url'];
|
||
$thumb = !empty($r['data']['thumbnail_url']) ? (string)$r['data']['thumbnail_url'] : $url;
|
||
return ['ok' => true, 'url' => $url, 'thumb' => $thumb,
|
||
'key' => isset($r['data']['key']) ? (string)$r['data']['key'] : ''];
|
||
}
|
||
if ($r['status'] !== 401) {
|
||
return ['ok' => false, 'msg' => $r['msg']];
|
||
}
|
||
// 401 -> 第 2 轮由 lskyGetToken(true) 强制续签后重试
|
||
}
|
||
return ['ok' => false, 'msg' => '上传失败(token 无效或已过期)'];
|
||
}
|