feat: 同步本地全量开发到 GitHub(前端/后端/超管/开发文档规则)

含 AI 助手、抖音 OAuth、流量池推送、支付获客、SuperAdmin 设备与场景管理等本地迭代;以本地为准单向推送,未拉取远程。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Manus AI
2026-05-26 22:12:40 +08:00
parent a3bda0ccac
commit 03ae8c07b6
336 changed files with 31345 additions and 7830 deletions

View File

@@ -0,0 +1,227 @@
<?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 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,urls,coverImage,senderNickname,senderAvatar,sendTime,createTime')
->order('id DESC')
->page($page, $limit)
->select();
$list = array_map(function ($r) {
$r['resUrls'] = json_decode($r['resUrls'] ?? '[]', true) ?: [];
$r['urls'] = json_decode($r['urls'] ?? '[]', true) ?: [];
return $r;
}, $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');
}
}