Files
cunkebao_v3/Server/application/common/controller/OpenContentLibraryController.php
Manus AI 5517457929 sync: 以本地为准同步全量变更至 GitHub
含四端需求文档、前端/后端/超管/触客宝迭代及部署脚本更新;未拉取远程。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 04:24:56 +08:00

232 lines
8.9 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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');
}
}