232 lines
8.9 KiB
PHP
232 lines
8.9 KiB
PHP
<?php
|
||
/**
|
||
* 开放接口 · 内容库
|
||
*
|
||
* 鉴权:HTTP Header `X-API-KEY: <library.externalApiKey>`
|
||
* (也允许 query/body 参数 `apiKey`,但仅推荐 header 方式)
|
||
*
|
||
* 提供两类外部能力:
|
||
* POST /v1/open/content-library/push-item — 第三方把素材写入指定内容库
|
||
* GET /v1/open/content-library/items — 第三方按 libraryId 拉取素材列表
|
||
*
|
||
* 真源:开发文档/5、接口/03-业务板块接口/06-内容库接口.md
|
||
*
|
||
* @package app\common\controller
|
||
*/
|
||
|
||
namespace app\common\controller;
|
||
|
||
use app\cunkebao\model\ContentLibrary;
|
||
use app\cunkebao\service\ContentItemMediaService;
|
||
use library\ResponseHelper;
|
||
use think\Controller;
|
||
use think\Db;
|
||
use think\facade\Request;
|
||
|
||
class OpenContentLibraryController extends Controller
|
||
{
|
||
/** 单页最大数量 */
|
||
const MAX_LIMIT = 200;
|
||
|
||
/**
|
||
* 解析 apiKey 并定位内容库;失败抛 401/403/404
|
||
* @return array 内容库行(含 companyId、id、externalApiKey 等)
|
||
*/
|
||
private function ensureExternalApiSchemaReady()
|
||
{
|
||
if (!ContentLibrary::externalApiColumnsReady()) {
|
||
ResponseHelper::error(
|
||
503,
|
||
'外部接口字段未就绪,请先执行迁移:Server/database/migrations/20260526_content_library_external_api.sql'
|
||
)->send();
|
||
exit;
|
||
}
|
||
}
|
||
|
||
private function authLibrary(): array
|
||
{
|
||
$this->ensureExternalApiSchemaReady();
|
||
|
||
$apiKey = (string) Request::header('X-API-KEY', '');
|
||
if ($apiKey === '') {
|
||
$apiKey = (string) Request::param('apiKey', '');
|
||
}
|
||
$apiKey = trim($apiKey);
|
||
if ($apiKey === '') {
|
||
ResponseHelper::unauthorized('缺少 apiKey(请在 Header X-API-KEY 中传入)')->send();
|
||
exit;
|
||
}
|
||
|
||
// 先按 libraryId+apiKey 精确匹配(更高效,并避免歧义)
|
||
$libraryId = intval(Request::param('libraryId', 0));
|
||
$where = [
|
||
['externalApiKey', '=', $apiKey],
|
||
['externalApiEnabled', '=', 1],
|
||
['isDel', '=', 0],
|
||
];
|
||
if ($libraryId > 0) {
|
||
$where[] = ['id', '=', $libraryId];
|
||
}
|
||
$library = ContentLibrary::where($where)->find();
|
||
if (!$library) {
|
||
ResponseHelper::unauthorized('apiKey 无效或对应的内容库未启用外部接口')->send();
|
||
exit;
|
||
}
|
||
return $library->toArray();
|
||
}
|
||
|
||
/**
|
||
* 写入审计日志(失败静默,不影响主流程)
|
||
*/
|
||
private function logCall(array $library, string $action, int $code, string $msg, $payloadDigest = null): void
|
||
{
|
||
try {
|
||
Db::name('content_library_external_log')->insert([
|
||
'libraryId' => (int)($library['id'] ?? 0),
|
||
'companyId' => (int)($library['companyId'] ?? 0),
|
||
'action' => $action,
|
||
'clientIp' => substr((string) Request::ip(), 0, 64),
|
||
'apiKey' => substr((string)($library['externalApiKey'] ?? ''), 0, 8),
|
||
'payload' => is_array($payloadDigest) ? json_encode($payloadDigest, JSON_UNESCAPED_UNICODE) : (is_string($payloadDigest) ? $payloadDigest : null),
|
||
'responseCode' => $code,
|
||
'responseMsg' => mb_substr((string) $msg, 0, 240),
|
||
'createTime' => time(),
|
||
]);
|
||
} catch (\Throwable $e) {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 健康检查(公开)
|
||
* GET /v1/open/content-library/health
|
||
*/
|
||
public function health()
|
||
{
|
||
return ResponseHelper::success([
|
||
'service' => 'cunkebao-open-content-library',
|
||
'version' => '1.0',
|
||
'time' => time(),
|
||
], 'ok');
|
||
}
|
||
|
||
/**
|
||
* 写入素材到指定内容库
|
||
* POST /v1/open/content-library/push-item
|
||
* Header: X-API-KEY: <apiKey>
|
||
* Body : libraryId(int) contentType(int) content(string) title(string?) resUrls(array?) urls(array?) coverImage(string?)
|
||
*
|
||
* contentType 取值:1 图片 / 2 链接 / 3 视频 / 4 文本 / 5 小程序 / 6 图文
|
||
*/
|
||
public function pushItem()
|
||
{
|
||
if (!$this->request->isPost()) {
|
||
return ResponseHelper::error(400, '请使用 POST 方法');
|
||
}
|
||
$library = $this->authLibrary();
|
||
if (empty($library['externalAllowPushIn'])) {
|
||
$this->logCall($library, 'push_in', 403, '该内容库未开放外部推入');
|
||
return ResponseHelper::error(403, '该内容库未开放外部推入');
|
||
}
|
||
|
||
$param = $this->request->post();
|
||
$contentType = isset($param['contentType']) ? intval($param['contentType']) : 0;
|
||
$allowedTypes = [1, 2, 3, 4, 5, 6];
|
||
if (!in_array($contentType, $allowedTypes, true)) {
|
||
$this->logCall($library, 'push_in', 400, 'contentType 不合法');
|
||
return ResponseHelper::error(400, 'contentType 不合法(合法值:1图片 2链接 3视频 4文本 5小程序 6图文)');
|
||
}
|
||
$content = trim((string)($param['content'] ?? ''));
|
||
if ($content === '') {
|
||
$this->logCall($library, 'push_in', 400, 'content 不能为空');
|
||
return ResponseHelper::error(400, 'content 不能为空');
|
||
}
|
||
|
||
$now = time();
|
||
$row = [
|
||
'libraryId' => (int) $library['id'],
|
||
'companyId' => (int) $library['companyId'],
|
||
'userId' => (int) ($library['userId'] ?? 0),
|
||
'contentType' => $contentType,
|
||
'title' => mb_substr((string)($param['title'] ?? ''), 0, 200),
|
||
'content' => $content,
|
||
'resUrls' => isset($param['resUrls']) ? json_encode($param['resUrls'], JSON_UNESCAPED_UNICODE) : json_encode([]),
|
||
'urls' => isset($param['urls']) ? json_encode($param['urls'], JSON_UNESCAPED_UNICODE) : json_encode([]),
|
||
'coverImage' => (string)($param['coverImage'] ?? ''),
|
||
'sendTime' => $now,
|
||
'createTime' => $now,
|
||
'updateTime' => $now,
|
||
'isDel' => 0,
|
||
'senderNickname' => mb_substr((string)($param['senderNickname'] ?? '外部接口'), 0, 50),
|
||
'senderAvatar' => (string)($param['senderAvatar'] ?? ''),
|
||
];
|
||
|
||
try {
|
||
$insertId = Db::name('content_item')->insertGetId($row);
|
||
$this->logCall($library, 'push_in', 200, 'ok', ['contentType' => $contentType, 'len' => mb_strlen($content)]);
|
||
return ResponseHelper::success([
|
||
'id' => (int) $insertId,
|
||
'libraryId' => (int) $library['id'],
|
||
], 'ok');
|
||
} catch (\Throwable $e) {
|
||
$this->logCall($library, 'push_in', 500, $e->getMessage());
|
||
return ResponseHelper::error(500, '写入失败:' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 拉取指定内容库下的素材列表(仅未删除)
|
||
* GET /v1/open/content-library/items?libraryId=&page=1&limit=20&contentType=
|
||
* Header: X-API-KEY: <apiKey>
|
||
*/
|
||
public function items()
|
||
{
|
||
$library = $this->authLibrary();
|
||
if (empty($library['externalAllowPullOut'])) {
|
||
$this->logCall($library, 'pull_out', 403, '该内容库未开放外部拉取');
|
||
return ResponseHelper::error(403, '该内容库未开放外部拉取');
|
||
}
|
||
|
||
$page = max(1, intval(Request::param('page', 1)));
|
||
$limit = max(1, intval(Request::param('limit', 20)));
|
||
if ($limit > self::MAX_LIMIT) {
|
||
$limit = self::MAX_LIMIT;
|
||
}
|
||
$contentType = Request::param('contentType', '');
|
||
|
||
$where = [
|
||
['libraryId', '=', (int) $library['id']],
|
||
['isDel', '=', 0],
|
||
];
|
||
if ($contentType !== '' && $contentType !== null) {
|
||
$where[] = ['contentType', '=', intval($contentType)];
|
||
}
|
||
|
||
$query = Db::name('content_item')->where($where);
|
||
$total = (clone $query)->count();
|
||
$rows = $query
|
||
->field('id,libraryId,contentType,title,content,resUrls,ossUrls,urls,coverImage,senderNickname,senderAvatar,sendTime,createTime,updateTime')
|
||
->order('id DESC')
|
||
->page($page, $limit)
|
||
->select();
|
||
|
||
$list = array_map(function ($r) {
|
||
$row = is_array($r) ? $r : $r->toArray();
|
||
$formatted = ContentItemMediaService::formatForOpenApi($row);
|
||
$formatted['senderNickname'] = (string)($row['senderNickname'] ?? '');
|
||
$formatted['senderAvatar'] = (string)($row['senderAvatar'] ?? '');
|
||
$formatted['sendTime'] = (int)($row['sendTime'] ?? 0);
|
||
return $formatted;
|
||
}, $rows ?: []);
|
||
|
||
$this->logCall($library, 'pull_out', 200, 'ok', ['page' => $page, 'limit' => $limit]);
|
||
return ResponseHelper::success([
|
||
'total' => $total,
|
||
'page' => $page,
|
||
'limit' => $limit,
|
||
'libraryId' => (int) $library['id'],
|
||
'list' => $list,
|
||
], 'ok');
|
||
}
|
||
}
|