` * (也允许 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: * 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: */ 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'); } }