feat(enterprise-site): add PbootCMS enterprise site publisher
Frontend CI / Frontend (push) Successful in 3m57s
Backend CI / Backend (push) Failing after 6m43s

Introduce a new enterprise-site publishing channel that lets tenants push
articles to self-hosted PbootCMS sites alongside the existing media supply
flow.

Backend (server/internal/tenant):
- enterprise_site_service: CRUD, ping, category sync, and article publish
- enterprise_site_pbootcms: PbootCMS API client integration
- enterprise_site_crypto: encrypt/decrypt stored site credentials
- enterprise_site_handler + routes under /enterprise-sites
- migrations for the enterprise site publisher tables
- config: add SERVER_PUBLIC_BASE_URL (Server.PublicBaseURL) for callbacks
- article/media services adjusted to support the publish flow

Frontend (apps/admin-web):
- PublishArticleModal & ArticlePublishStatus: enterprise-site publish UI
- MediaView: manage enterprise sites and categories
- api + shared-types: enterprise site endpoints and types
- http-client: add PATCH method support

Integrations:
- pbootcms GeoPublisher controller plugin + install guide
- docs/enterprise-site-publisher-v1.md design doc
This commit is contained in:
2026-06-06 13:06:14 +08:00
parent b40434018a
commit 88c37e50b2
20 changed files with 7708 additions and 528 deletions
@@ -18,6 +18,7 @@ interface PublishPlatformEntry {
accountName: string accountName: string
avatarUrl: string avatarUrl: string
status: PublishRecordStatus status: PublishRecordStatus
targetType: string
} }
type PublishRecordStatus = 'success' | 'failed' | 'publishing' type PublishRecordStatus = 'success' | 'failed' | 'publishing'
@@ -83,7 +84,11 @@ const platformEntries = computed<PublishPlatformEntry[]>(() => {
const entries: PublishPlatformEntry[] = [] const entries: PublishPlatformEntry[] = []
for (const record of publishRecordsQuery.data.value ?? []) { for (const record of publishRecordsQuery.data.value ?? []) {
const key = `${record.platform_id}:${record.platform_account_id}` const targetType = String(record.target_type ?? 'platform_account')
const key =
targetType === 'enterprise_site'
? `${targetType}:${record.target_connection_id ?? record.id}`
: `${record.platform_id}:${record.platform_account_id}`
if (seen.has(key)) { if (seen.has(key)) {
continue continue
} }
@@ -91,7 +96,7 @@ const platformEntries = computed<PublishPlatformEntry[]>(() => {
const normalizedId = normalizePublishPlatformId(record.platform_id) const normalizedId = normalizePublishPlatformId(record.platform_id)
const platform = platformMap.value.get(normalizedId) const platform = platformMap.value.get(normalizedId)
const fallback = getPublishPlatformMeta(normalizedId) const fallback = enterprisePlatformFallback(normalizedId)
entries.push({ entries.push({
key, key,
@@ -101,6 +106,7 @@ const platformEntries = computed<PublishPlatformEntry[]>(() => {
accountName: record.platform_nickname || '--', accountName: record.platform_nickname || '--',
avatarUrl: '', avatarUrl: '',
status: normalizePublishRecordStatus(record.status), status: normalizePublishRecordStatus(record.status),
targetType,
}) })
} }
@@ -236,6 +242,17 @@ function getAccountInitial(accountName: string): string {
return trimmed.slice(0, 1).toUpperCase() return trimmed.slice(0, 1).toUpperCase()
} }
function enterprisePlatformFallback(platformId: string) {
if (platformId === 'pbootcms') {
return {
name: 'PBootCMS',
shortName: 'CMS',
accent: '#0f766e',
}
}
return getPublishPlatformMeta(platformId)
}
</script> </script>
<template> <template>
File diff suppressed because it is too large Load Diff
+50
View File
@@ -28,6 +28,7 @@ import type {
CreateKolPromptRequest, CreateKolPromptRequest,
CreateMediaSupplyOrderRequest, CreateMediaSupplyOrderRequest,
CreateMediaSupplyOrderResponse, CreateMediaSupplyOrderResponse,
CreateEnterpriseSiteConnectionRequest,
CreatePluginSessionRequest, CreatePluginSessionRequest,
CreatePluginSessionResponse, CreatePluginSessionResponse,
CreatePublishBatchRequest, CreatePublishBatchRequest,
@@ -39,6 +40,10 @@ import type {
DesktopClientDownloadableReleaseListResponse, DesktopClientDownloadableReleaseListResponse,
DesktopClientReleaseCheckResponse, DesktopClientReleaseCheckResponse,
DesktopClientReleaseDownloadURLResponse, DesktopClientReleaseDownloadURLResponse,
EnterpriseSiteCapability,
EnterpriseSiteCategory,
EnterpriseSiteConnection,
EnterpriseSitePublishResponse,
FolderDeletePreview, FolderDeletePreview,
GenerateFromRuleRequest, GenerateFromRuleRequest,
GenerateFromRuleResponse, GenerateFromRuleResponse,
@@ -109,6 +114,7 @@ import type {
PromptRuleSimple, PromptRuleSimple,
PromptRuleStatusRequest, PromptRuleStatusRequest,
PublishKolPromptRequest, PublishKolPromptRequest,
PublishEnterpriseSiteArticleRequest,
PublishRecord, PublishRecord,
Question, Question,
QuestionCandidateResult, QuestionCandidateResult,
@@ -144,6 +150,7 @@ import type {
TemplateTitleTaskResultResponse, TemplateTitleTaskResultResponse,
TenantPublishTaskListResponse, TenantPublishTaskListResponse,
UpdateArticleRequest, UpdateArticleRequest,
UpdateEnterpriseSiteConnectionRequest,
UpdateKolPackageRequest, UpdateKolPackageRequest,
UpdateKolPromptRequest, UpdateKolPromptRequest,
UpdateQuestionRequest, UpdateQuestionRequest,
@@ -339,6 +346,7 @@ const currentBrandScopedPathPatterns = [
/^\/api\/tenant\/instant-tasks(?:\/|$)/, /^\/api\/tenant\/instant-tasks(?:\/|$)/,
/^\/api\/tenant\/publish-jobs(?:\/|$)/, /^\/api\/tenant\/publish-jobs(?:\/|$)/,
/^\/api\/tenant\/publish-tasks\/[^/]+\/retry(?:\/|$)/, /^\/api\/tenant\/publish-tasks\/[^/]+\/retry(?:\/|$)/,
/^\/api\/tenant\/enterprise-sites\/[^/]+\/publish(?:\/|$)/,
/^\/api\/tenant\/media-supply\/orders(?:\/|$)/, /^\/api\/tenant\/media-supply\/orders(?:\/|$)/,
/^\/api\/tenant\/prompt-rules(?:\/|$)/, /^\/api\/tenant\/prompt-rules(?:\/|$)/,
] ]
@@ -1378,6 +1386,48 @@ export const publishTasksApi = {
}, },
} }
export const enterpriseSitesApi = {
list() {
return apiClient.get<EnterpriseSiteConnection[]>('/api/tenant/enterprise-sites')
},
create(payload: CreateEnterpriseSiteConnectionRequest) {
return apiClient.post<EnterpriseSiteConnection, CreateEnterpriseSiteConnectionRequest>(
'/api/tenant/enterprise-sites',
payload,
)
},
update(id: number, payload: UpdateEnterpriseSiteConnectionRequest) {
return apiClient.patch<EnterpriseSiteConnection, UpdateEnterpriseSiteConnectionRequest>(
`/api/tenant/enterprise-sites/${id}`,
payload,
)
},
remove(id: number) {
return apiClient.remove<null>(`/api/tenant/enterprise-sites/${id}`)
},
ping(id: number) {
return apiClient.post<EnterpriseSiteCapability>(
`/api/tenant/enterprise-sites/${id}/ping`,
)
},
categories(id: number) {
return apiClient.get<EnterpriseSiteCategory[]>(
`/api/tenant/enterprise-sites/${id}/categories`,
)
},
syncCategories(id: number) {
return apiClient.post<EnterpriseSiteCategory[]>(
`/api/tenant/enterprise-sites/${id}/categories/sync`,
)
},
publish(id: number, payload: PublishEnterpriseSiteArticleRequest) {
return apiClient.post<EnterpriseSitePublishResponse, PublishEnterpriseSiteArticleRequest>(
`/api/tenant/enterprise-sites/${id}/publish`,
payload,
)
},
}
export const complianceApi = { export const complianceApi = {
runtimeStatus() { runtimeStatus() {
return apiClient.get<ComplianceRuntimeStatus>('/api/tenant/compliance/runtime-status') return apiClient.get<ComplianceRuntimeStatus>('/api/tenant/compliance/runtime-status')
File diff suppressed because it is too large Load Diff
+237
View File
@@ -0,0 +1,237 @@
# 企业自建站点自动发文系统 v1
## 定位
企业自建站点不是 UGC 媒体账号,也不应该走桌面客户端登录态。它是客户自有 CMS 的站点连接,由 Geo SaaS 服务端通过 CMS 适配器直接调用发布接口。
媒体管理建议分三类:
```text
UGC媒体平台 走桌面客户端账号授权和发布队列
第三方新闻源 走资源/订单/投稿线索
企业自建站点 走 CMS 连接、栏目同步、服务端发布
```
## 核心模型
建议新增站点连接表:
```text
enterprise_site_connections
```
关键字段:
```text
id
tenant_id
name
site_url
cms_type pbootcms / wordpress / dedecms / phpcms
auth_type native_api / application_password / plugin_token
credential_ref 凭证密文或凭证引用
status draft / connected / error / disabled
last_ping_at
last_sync_at
last_error
created_at
updated_at
```
栏目缓存表:
```text
enterprise_site_categories
```
关键字段:
```text
id
tenant_id
connection_id
remote_id
parent_remote_id
name
slug
raw_payload
synced_at
```
发布记录可复用现有发布记录体系,也可以增加 `target_type=enterprise_site``target_connection_id`。不要把企业站点伪装成 `platform_account`,否则后续 WordPress、PBootCMS 插件、DedeCMS 插件会和桌面端账号模型缠在一起。
## 统一适配器协议
所有 CMS 适配器都实现:
```text
ping(connection) -> SiteCapability
categories(connection, options) -> Category[]
publish(connection, article, options) -> PublishResult
update(connection, remoteId, article, options) -> PublishResult
status(connection, remoteId) -> RemoteContentStatus
```
返回结构:
```text
SiteCapability:
cms_type
plugin_version
site_url
site_name
api_auth
Category:
id
parent_id
title
slug
raw
PublishResult:
remote_id
url
status
raw
RemoteContentStatus:
remote_id
title
status
url
updated_at
```
## PBootCMS v1
集成方式:站点放置单文件插件。
插件路径:
```text
integrations/pbootcms/apps/api/controller/GeoPublisherController.php
```
客户站点安装到:
```text
apps/api/controller/GeoPublisherController.php
```
接口:
```text
GET /api/GeoPublisher/ping
GET /api/GeoPublisher/categories
POST /api/GeoPublisher/publish
POST /api/GeoPublisher/update
GET /api/GeoPublisher/status
```
鉴权使用 PBootCMS 原生 API 参数:
```text
appid
timestamp
signature = md5(md5(appid + api_secret + timestamp))
```
适配器保存:
```text
site_url
appid
api_secret
default_acode
default_mcode
```
## WordPress v1
优先使用 WordPress REST API 和 Application Password
```text
GET /wp-json/
GET /wp-json/wp/v2/categories
POST /wp-json/wp/v2/posts
POST /wp-json/wp/v2/posts/{id}
GET /wp-json/wp/v2/posts/{id}
```
鉴权:
```text
Basic base64(username:application_password)
```
WordPress 不需要客户安装插件即可完成基础发文;如果后续要支持自定义字段、SEO 插件字段、图片本地化、内链策略,再提供 WordPress 插件增强。
## DedeCMS / PHPCMS v1
建议走与 PBootCMS 相同的单文件插件协议,不直接侵入后台登录。
原因:
```text
老版本安全差异大
后台路径常被改名
字段模型定制多
原生 API 能力不统一
```
插件统一暴露:
```text
ping / categories / publish / update / status
```
SaaS 侧只新增 `cms_type` 适配器,不改发布业务协议。
## 发布链路
```text
文章生成完成
选择发布目标:企业自建站点
读取 connection + category mapping
服务端调用 CmsPublisherAdapter.publish
保存 remote_id/url/raw response
同步状态和错误
```
失败重试策略:
```text
网络超时:可重试
401/403:标记连接异常,要求重新绑定
栏目不存在:停止重试,要求重新同步栏目
CMS 返回字段错误:停止重试,记录原始响应
```
## 安全要求
```text
凭证不得明文回显给前端
日志不得打印 api_secret/application_password/plugin_token
发布接口只允许 HTTPS 站点进入生产发布
支持手动禁用连接
记录每次 ping/sync/publish 的错误摘要和时间
```
## 后台页面 v1
媒体管理页新增 `企业自建站点` 标签。
首版页面应提供:
```text
站点连接列表
CMS 类型
连通状态
栏目同步时间
最近发布结果
安装插件入口
新增连接入口
```
在后端 API 完成前,前端可以先展示结构化空态和接入流程,但路由/术语要按最终模型命名,避免后续返工。
+175
View File
@@ -0,0 +1,175 @@
# PBootCMS GeoPublisher 安装说明
## 1. 放置文件
把本目录中的整个 `apps` 目录复制到客户 PBootCMS 网站根目录:
```text
{PBootCMS_ROOT}/apps
```
最终应存在:
```text
{PBootCMS_ROOT}/apps/api/controller/GeoPublisherController.php
{PBootCMS_ROOT}/apps/api/controller/AiseoController.php
```
这个文件只依赖 PBootCMS 原生 `core\basic\Controller``core\basic\Model``ay_content``ay_content_sort``ay_model`,不需要改后台代码,也不需要安装额外数据库表。
## 2. 开启 PBootCMS API
登录 PBootCMS 后台:
```text
全局配置 -> 配置参数 -> API
```
开启:
```text
api_open = 1
api_auth = 1
api_appid = 自定义 AppID
api_secret = 自定义密钥
```
Geo SaaS 绑定站点时填写:
```text
CMS 类型:PBootCMS
网站域名:https://www.example.com
AppID:后台 api_appid
Secret:后台 api_secret
```
## 3. 签名规则
每次请求都带:
```text
appid={api_appid}
timestamp={当前 Unix 秒级时间戳}
signature=md5(md5(appid + api_secret + timestamp))
```
示例:
```php
$signature = md5(md5($appid . $secret . $timestamp));
```
插件默认允许 300 秒时间差,便于 SaaS 服务器和客户服务器存在轻微时钟偏差。
## 4. 连通性测试
推荐入口:
```bash
curl "https://www.example.com/api.php?p=/GeoPublisher/ping&appid=APPID&timestamp=TIMESTAMP&signature=SIGNATURE"
```
如果站点 rewrite 已启用,也支持:
```bash
curl "https://www.example.com/api/GeoPublisher/ping?appid=APPID&timestamp=TIMESTAMP&signature=SIGNATURE"
```
成功返回:
```json
{
"code": 1,
"data": {
"cms_type": "pbootcms",
"plugin_version": "1.0.0",
"site_url": "https://www.example.com"
},
"msg": ""
}
```
## 5. 栏目同步
默认同步 PBootCMS 文章模型 `mcode=2` 的可用栏目:
```bash
curl "https://www.example.com/api.php?p=/GeoPublisher/categories&appid=APPID&timestamp=TIMESTAMP&signature=SIGNATURE&mcode=2"
```
兼容旧插件参数:
```text
model_hash=2
```
## 6. 发布文章
推荐使用 `POST JSON`
```bash
curl -X POST "https://www.example.com/api/GeoPublisher/publish?appid=APPID&timestamp=TIMESTAMP&signature=SIGNATURE" \
-H "Content-Type: application/json" \
-d '{
"scode": "5",
"title": "企业新闻标题",
"content": "<p>正文内容</p>",
"keywords": "关键词1,关键词2",
"description": "摘要",
"author": "Geo SaaS",
"source": "企业官网",
"status": 1
}'
```
核心字段:
```text
scode/category_id PBootCMS 栏目编码,必填
title 标题,必填
content HTML 正文,必填
status 1 发布,0 草稿
ico 缩略图,支持远程 URL 自动下载
pics 多图,支持逗号字符串或数组
date 发布时间,支持 Unix 时间戳或日期字符串
filename 自定义 URL 名称,可选
tags/keywords/description 可选
```
文章正文中的远程图片、封面 `ico`、多图 `pics` 会先下载到 PBootCMS 本地:
```text
/static/upload/image/YYYYMMDD/geo_*.jpg
```
如果图片下载或保存失败,接口会返回失败,不会把 SaaS 平台图片链接写进 PBootCMS 正文。
## 7. 更新与状态
```text
POST /api.php?p=/GeoPublisher/update
GET /api.php?p=/GeoPublisher/status&id={content_id}
```
更新接口字段和发布接口一致,额外必填:
```text
id = PBootCMS 内容 ID
```
## 8. 旧插件兼容接口
插件保留:
```text
/api.php?p=/GeoPublisher/categoryLists
/api.php?p=/GeoPublisher/articleAdd
/api.php?p=/Aiseo/categoryLists
/api.php?p=/Aiseo/articleAdd
```
用于兼容历史 `AiseoController.php` 风格调用。新 SaaS 侧建议统一使用:
```text
ping / categories / publish / update / status
```
@@ -0,0 +1,748 @@
<?php
/**
* Geo SaaS universal publisher endpoint for PBootCMS.
*
* Install path in a PBootCMS site:
* apps/api/controller/GeoPublisherController.php
*
* The controller only depends on PBootCMS core Controller/Model and the
* standard content/sort tables, so users can drop the apps directory into
* the PBootCMS root and use it without extra database migrations.
*/
namespace app\api\controller;
use core\basic\Controller;
use core\basic\Model;
class GeoPublisherController extends Controller
{
const CMS_TYPE = 'pbootcms';
const PLUGIN_VERSION = '1.2.0';
private $model;
private $payload = array();
private $contentFields = null;
public function __construct()
{
if (function_exists('cache_config')) {
cache_config();
}
$this->model = new Model();
$this->payload = $this->readPayload();
$this->assertApiAccess();
}
public function ping()
{
$this->success(array(
'cms_type' => self::CMS_TYPE,
'plugin_version' => self::PLUGIN_VERSION,
'site_url' => $this->siteUrl(),
'site_name' => $this->config('cmsname') ?: 'PBootCMS',
'api_auth' => (bool) $this->config('api_auth'),
'time' => date('Y-m-d H:i:s')
));
}
public function categories()
{
$acode = $this->param('acode', $this->defaultAcode());
$mcode = $this->param('mcode', $this->param('model_hash', '2'));
if (! $this->isSafeCode($acode) || ! $this->isSafeCode($mcode)) {
$this->fail(1400, 'invalid acode or mcode');
}
$rows = $this->model->table('ay_content_sort')
->where("acode='" . $this->sqlValue($acode) . "'")
->where("mcode='" . $this->sqlValue($mcode) . "'")
->where('status=1')
->field('scode,pcode,mcode,name,filename,sorting')
->order('sorting ASC,id ASC')
->select();
$items = array();
if ($rows) {
foreach ($rows as $row) {
$items[] = array(
'id' => (string) $row->scode,
'scode' => (string) $row->scode,
'parent_id' => (string) $row->pcode,
'mcode' => (string) $row->mcode,
'title' => (string) $row->name,
'name' => (string) $row->name,
'filename' => (string) $row->filename,
'sorting' => (int) $row->sorting
);
}
}
$this->success($items);
}
public function publish()
{
$data = $this->buildContentData(false);
$id = $this->model->table('ay_content')->autoTime()->insertGetId($this->filterContentFields($data));
if (! $id) {
$this->fail(1403, 'insert ay_content error', '文章发布失败');
}
$this->clearRuntimeCache();
$this->success(array(
'id' => (int) $id,
'cms_type' => self::CMS_TYPE,
'status' => (int) $data['status'],
'url' => $this->contentUrl(
$id,
$this->param('scode', $this->param('category_id', '')),
$this->param('filename', '')
)
), 'published');
}
public function update()
{
$id = (int) $this->param('id', 0);
if ($id <= 0) {
$this->fail(1404, 'id is empty', '文章 ID 不能为空');
}
$current = $this->model->table('ay_content')->where('id=' . $id)->find();
if (! $current) {
$this->fail(1404, 'content not found', '文章不存在');
}
$data = $this->buildContentData(true, $current);
$ok = $this->model->table('ay_content')->where('id=' . $id)->autoTime()->update($this->filterContentFields($data));
if ($ok === false) {
$this->fail(1403, 'update ay_content error', '文章更新失败');
}
$this->clearRuntimeCache();
$this->success(array(
'id' => $id,
'cms_type' => self::CMS_TYPE,
'status' => (int) $data['status'],
'url' => $this->contentUrl(
$id,
$this->param('scode', $this->param('category_id', $current->scode)),
$this->param('filename', $current->filename)
)
), 'updated');
}
public function status()
{
$id = (int) $this->param('id', 0);
if ($id <= 0) {
$this->fail(1404, 'id is empty', '文章 ID 不能为空');
}
$row = $this->model->table('ay_content')->where('id=' . $id)->find();
if (! $row) {
$this->fail(1404, 'content not found', '文章不存在');
}
$this->success(array(
'id' => (int) $row->id,
'title' => (string) $row->title,
'scode' => (string) $row->scode,
'status' => (int) $row->status,
'date' => (string) $row->date,
'url' => $this->contentUrl($row->id, $row->scode, $row->filename)
));
}
// Backward compatible names used by older Aiseo-style plugins.
public function categoryLists()
{
$this->categories();
}
public function articleAdd()
{
$this->publish();
}
private function buildContentData($isUpdate = false, $current = null)
{
$title = $this->param('title', $isUpdate && $current ? $current->title : '');
if (! $title) {
$this->fail(1404, 'title is empty', '标题不能为空');
}
$content = $this->param('content', $isUpdate && $current ? $current->content : '');
if (! $content) {
$this->fail(1404, 'content is empty', '内容不能为空');
}
$scode = $this->param('scode', $this->param('category_id', $isUpdate && $current ? $current->scode : ''));
if (! $scode) {
$this->fail(1404, 'scode is empty', '内容栏目编码不能为空');
}
if (! $this->isSafeCode($scode)) {
$this->fail(1400, 'invalid scode', '栏目编码不合法');
}
$sort = $this->getSort($scode);
if (! $sort) {
$this->fail(1404, 'sort not found', '栏目不存在或不可用');
}
$content = $this->localizeContentImages($content);
$ico = $this->param('ico', $isUpdate && $current ? $current->ico : '');
if ($ico && ($this->isRemoteUrl($ico) || $this->isDataImageUrl($ico))) {
$ico = $this->localizeImage($ico);
}
$status = $this->param('status', $isUpdate && $current ? $current->status : 1);
if ($status === '' || $status === null) {
$status = 1;
}
return array(
'acode' => $this->sqlValue($this->param('acode', $isUpdate && $current ? $current->acode : $this->defaultAcode())),
'scode' => $this->sqlValue($scode),
'subscode' => $this->sqlValue($this->param('subscode', $isUpdate && $current ? $current->subscode : '')),
'title' => $this->sqlValue($title),
'titlecolor' => $this->sqlValue($this->param('titlecolor', $isUpdate && $current ? $current->titlecolor : '#333333')),
'subtitle' => $this->sqlValue($this->param('subtitle', $isUpdate && $current ? $current->subtitle : '')),
'filename' => $this->sqlValue($this->param('filename', $isUpdate && $current ? $current->filename : '')),
'author' => $this->sqlValue($this->param('author', $isUpdate && $current ? $current->author : 'Geo SaaS')),
'source' => $this->sqlValue($this->param('source', $isUpdate && $current ? $current->source : 'Geo SaaS')),
'outlink' => $this->sqlValue($this->param('outlink', $isUpdate && $current ? $current->outlink : '')),
'date' => $this->sqlValue($this->normalizeDate($this->param('date', $isUpdate && $current ? $current->date : ''))),
'ico' => $this->sqlValue($ico),
'pics' => $this->sqlValue($this->normalizePics($this->param('pics', $isUpdate && $current ? $current->pics : ''))),
'content' => $this->sqlValue($content),
'tags' => $this->sqlValue($this->param('tags', $isUpdate && $current ? $current->tags : '')),
'enclosure' => $this->sqlValue($this->param('enclosure', $isUpdate && $current ? $current->enclosure : '')),
'keywords' => $this->sqlValue($this->param('keywords', $isUpdate && $current ? $current->keywords : '')),
'description' => $this->sqlValue($this->param('description', $isUpdate && $current ? $current->description : $this->summary($content))),
'sorting' => (int) $this->param('sorting', $isUpdate && $current ? $current->sorting : 255),
'status' => (int) $status,
'istop' => (int) $this->param('istop', $isUpdate && $current ? $current->istop : 0),
'isrecommend' => (int) $this->param('isrecommend', $isUpdate && $current ? $current->isrecommend : 0),
'isheadline' => (int) $this->param('isheadline', $isUpdate && $current ? $current->isheadline : 0),
'visits' => $isUpdate && $current ? (int) $current->visits : 0,
'likes' => $isUpdate && $current ? (int) $current->likes : 0,
'oppose' => $isUpdate && $current ? (int) $current->oppose : 0,
'create_user' => $this->sqlValue($isUpdate && $current ? $current->create_user : 'geo_saas'),
'update_user' => $this->sqlValue('geo_saas'),
'gid' => (int) $this->param('gid', $isUpdate && $current ? $current->gid : 0),
'gtype' => (int) $this->param('gtype', $isUpdate && $current ? $current->gtype : 4),
'gnote' => $this->sqlValue($this->param('gnote', $isUpdate && $current ? $current->gnote : '')),
'picstitle' => $this->sqlValue($this->param('picstitle', $isUpdate && $current ? $current->picstitle : ''))
);
}
private function getSort($scode)
{
return $this->model->table('ay_content_sort a')
->field('a.*,m.urlname,m.type')
->join(array(
array('ay_model m', 'a.mcode=m.mcode', 'LEFT')
))
->where("a.scode='" . $this->sqlValue($scode) . "'")
->where('a.status=1')
->find();
}
private function contentUrl($id, $scode, $filename = '')
{
$sort = $this->getSort($scode);
if (! $sort) {
return $this->siteUrl();
}
try {
if (class_exists('\\app\\home\\controller\\ParserController')) {
$parserClass = '\\app\\home\\controller\\ParserController';
$parser = new $parserClass();
$link = $parser->parserLink(
$sort->type,
$sort->urlname,
'content',
$scode,
$sort->filename,
$id,
$filename
);
return $this->absoluteUrl($link);
}
} catch (\Exception $e) {
}
$urlName = $sort->urlname ?: $sort->filename;
if (! $urlName) {
$urlName = $scode;
}
return $this->absoluteUrl('/?' . $urlName . '/' . $id . '.html');
}
private function filterContentFields($data)
{
$fields = $this->contentFields();
if (! $fields) {
return $data;
}
$filtered = array();
foreach ($data as $key => $value) {
if (isset($fields[$key])) {
$filtered[$key] = $value;
}
}
return $filtered;
}
private function contentFields()
{
if ($this->contentFields !== null) {
return $this->contentFields;
}
$this->contentFields = array();
try {
$fields = $this->model->tableFields('ay_content');
if (is_array($fields)) {
foreach ($fields as $field) {
$field = trim((string) $field);
if ($field !== '') {
$this->contentFields[$field] = true;
}
}
}
} catch (\Exception $e) {
$this->contentFields = array();
}
return $this->contentFields;
}
private function readPayload()
{
$payload = array();
$raw = file_get_contents('php://input');
if ($raw) {
$json = json_decode($raw, true);
if (is_array($json)) {
$payload = $json;
}
}
foreach ($_REQUEST as $key => $value) {
$payload[$key] = $value;
}
return $payload;
}
private function param($name, $default = '')
{
if (array_key_exists($name, $this->payload)) {
return $this->payload[$name];
}
return $default;
}
private function assertApiAccess()
{
$config = $this->config();
if (! isset($config['api_open']) || ! $config['api_open']) {
$this->fail(1401, 'api is closed', '系统尚未开启 API 功能');
}
if (! empty($config['api_auth'])) {
if (empty($config['api_appid']) || empty($config['api_secret'])) {
$this->fail(1401, 'api credential is not configured', '后台 API 认证配置不完整');
}
$appid = $this->param('appid', '');
$timestamp = $this->param('timestamp', '');
$signature = $this->param('signature', '');
if (! $appid || ! $timestamp || ! $signature) {
$this->fail(1401, 'missing appid timestamp or signature', '缺少 API 认证参数');
}
if ((string) $appid !== (string) $config['api_appid']) {
$this->fail(1401, 'invalid appid', 'API 用户不正确');
}
if (! is_numeric($timestamp) || abs(time() - (int) $timestamp) > 300) {
$this->fail(1401, 'timestamp expired', 'API 时间戳已过期');
}
$expected = md5(md5($config['api_appid'] . $config['api_secret'] . $timestamp));
if (! $this->hashEquals($expected, $signature)) {
$this->fail(1401, 'invalid signature', 'API 签名不正确');
}
}
}
private function hashEquals($known, $user)
{
if (function_exists('hash_equals')) {
return hash_equals((string) $known, (string) $user);
}
return (string) $known === (string) $user;
}
private function localizeContentImages($content)
{
return preg_replace_callback('/<img\\b[^>]*>/i', function ($imgMatch) {
$tag = $imgMatch[0];
preg_match_all('/\\b(src|data-src|_src)=[\"\']([^\"\']+)[\"\']/i', $tag, $attrMatches, PREG_SET_ORDER);
if (! $attrMatches) {
return $tag;
}
$replacements = array();
foreach ($attrMatches as $attrMatch) {
$url = trim($attrMatch[2]);
if (! $this->isRemoteUrl($url) && ! $this->isDataImageUrl($url)) {
continue;
}
if (! isset($replacements[$url])) {
$replacements[$url] = $this->localizeImage($url);
}
}
foreach ($replacements as $remote => $local) {
$tag = str_replace($remote, $local, $tag);
}
return $tag;
}, $content);
}
private function normalizePics($pics)
{
if (! $pics) {
return '';
}
if (is_array($pics)) {
$items = $pics;
} else {
$items = explode(',', $pics);
}
$result = array();
foreach ($items as $item) {
$item = trim((string) $item);
if (! $item) {
continue;
}
if ($this->isRemoteUrl($item)) {
$item = $this->localizeImage($item);
} elseif ($this->isDataImageUrl($item)) {
$item = $this->localizeImage($item);
}
if ($item) {
$result[] = $item;
}
}
return implode(',', $result);
}
private function localizeImage($value)
{
if ($this->isDataImageUrl($value)) {
$decoded = $this->decodeDataImage($value);
return $this->saveImageData($decoded['data'], $decoded['ext']);
}
return $this->downloadImage($value);
}
private function downloadImage($url)
{
if (! $this->isRemoteUrl($url)) {
return $url;
}
$data = $this->httpGet($url);
if (! $data) {
$this->fail(1405, 'download image failed: ' . $url, '图片下载失败');
}
$path = parse_url($url, PHP_URL_PATH);
if (! $path) {
$path = '';
}
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
if (! in_array($ext, array('jpg', 'jpeg', 'png', 'gif', 'webp'))) {
$ext = $this->imageExtensionFromData($data);
}
if (! $ext) {
$ext = 'jpg';
}
return $this->saveImageData($data, $ext);
}
private function saveImageData($data, $ext = '')
{
if (! $data || ! $this->isImageData($data, '')) {
$this->fail(1405, 'invalid image data', '图片数据无效');
}
if (! $ext || ! in_array(strtolower($ext), array('jpg', 'jpeg', 'png', 'gif', 'webp'))) {
$ext = $this->imageExtensionFromData($data);
}
if (! $ext) {
$ext = 'jpg';
}
$relativeDir = '/static/upload/image/' . date('Ymd');
$absoluteDir = $this->rootPath() . $relativeDir;
if (! is_dir($absoluteDir) && ! mkdir($absoluteDir, 0777, true)) {
$this->fail(1405, 'create upload directory failed', '图片保存目录创建失败');
}
$fileName = 'geo_' . date('His') . '_' . mt_rand(1000, 9999) . '.' . $ext;
$absoluteFile = $absoluteDir . '/' . $fileName;
$relativeFile = $relativeDir . '/' . $fileName;
if (file_put_contents($absoluteFile, $data) === false) {
$this->fail(1405, 'save image failed', '图片保存失败');
}
return $relativeFile;
}
private function decodeDataImage($value)
{
if (! preg_match('/^data:image\\/(jpeg|jpg|png|gif|webp);base64,([A-Za-z0-9+\\/\\r\\n=]+)$/i', trim((string) $value), $matches)) {
$this->fail(1405, 'invalid data image', '图片数据无效');
}
$data = base64_decode(preg_replace('/\\s+/', '', $matches[2]), true);
if (! $data) {
$this->fail(1405, 'decode data image failed', '图片数据无效');
}
$ext = strtolower($matches[1]);
if ($ext === 'jpeg') {
$ext = 'jpg';
}
return array('data' => $data, 'ext' => $ext);
}
private function httpGet($url)
{
if (function_exists('curl_init')) {
$curl = curl_init();
curl_setopt($curl, CURLOPT_URL, $url);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 8);
curl_setopt($curl, CURLOPT_TIMEOUT, 20);
curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($curl, CURLOPT_USERAGENT, 'GeoPublisher/' . self::PLUGIN_VERSION);
$data = curl_exec($curl);
$code = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
$contentType = (string) curl_getinfo($curl, CURLINFO_CONTENT_TYPE);
curl_close($curl);
if ($code >= 200 && $code < 300 && $data && $this->isImageData($data, $contentType)) {
return $data;
}
return false;
}
$context = stream_context_create(array(
'http' => array(
'method' => 'GET',
'timeout' => 20,
'header' => "User-Agent: GeoPublisher/" . self::PLUGIN_VERSION . "\r\n"
)
));
$data = @file_get_contents($url, false, $context);
return $data && $this->isImageData($data, '') ? $data : false;
}
private function isImageData($data, $contentType = '')
{
if ($contentType && stripos($contentType, 'image/') !== false) {
return true;
}
if (function_exists('getimagesizefromstring')) {
if (@getimagesizefromstring($data) !== false) {
return true;
}
}
return $this->isWebPData($data);
}
private function isWebPData($data)
{
return strlen($data) >= 12 && substr($data, 0, 4) === 'RIFF' && substr($data, 8, 4) === 'WEBP';
}
private function imageExtensionFromData($data)
{
if ($this->isWebPData($data)) {
return 'webp';
}
if (! function_exists('getimagesizefromstring')) {
return 'jpg';
}
$info = @getimagesizefromstring($data);
if (! $info || empty($info['mime'])) {
return 'jpg';
}
switch (strtolower($info['mime'])) {
case 'image/jpeg':
return 'jpg';
case 'image/png':
return 'png';
case 'image/gif':
return 'gif';
case 'image/webp':
return 'webp';
default:
return 'jpg';
}
}
private function rootPath()
{
if (defined('ROOT_PATH') && ROOT_PATH) {
return rtrim(ROOT_PATH, '/\\');
}
return rtrim(dirname(dirname(dirname(dirname(__FILE__)))), '/\\');
}
private function normalizeDate($value)
{
if (! $value) {
return date('Y-m-d H:i:s');
}
if (is_numeric($value)) {
return date('Y-m-d H:i:s', (int) $value);
}
$time = strtotime($value);
return $time ? date('Y-m-d H:i:s', $time) : date('Y-m-d H:i:s');
}
private function summary($content)
{
$text = strip_tags($content);
if (function_exists('clear_html_blank')) {
$text = clear_html_blank($text);
}
if (function_exists('substr_both')) {
return substr_both($text, 0, 150);
}
if (function_exists('mb_substr')) {
return mb_substr($text, 0, 150, 'UTF-8');
}
return substr($text, 0, 150);
}
private function defaultAcode()
{
if (function_exists('get_default_lg')) {
return get_default_lg();
}
return 'cn';
}
private function isSafeCode($value)
{
return (bool) preg_match('/^[A-Za-z0-9_\\-]+$/', (string) $value);
}
private function isRemoteUrl($url)
{
return preg_match('/^https?:\\/\\//i', (string) $url);
}
private function isDataImageUrl($url)
{
return preg_match('/^data:image\\/(jpeg|jpg|png|gif|webp);base64,/i', trim((string) $url));
}
private function sqlValue($value)
{
if (is_array($value) || is_object($value)) {
$value = json_encode($value);
}
$value = (string) $value;
if (function_exists('get_magic_quotes_gpc') && get_magic_quotes_gpc()) {
$value = stripslashes($value);
}
return addslashes($value);
}
private function absoluteUrl($link)
{
if (preg_match('/^https?:\\/\\//i', $link)) {
return $link;
}
$base = $this->siteUrl();
if (! $link) {
return $base;
}
return rtrim($base, '/') . '/' . ltrim($link, '/');
}
private function siteUrl()
{
if (function_exists('get_http_url')) {
return get_http_url();
}
$https = isset($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) === 'on';
$host = isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : '';
return ($https ? 'https://' : 'http://') . $host;
}
private function clearRuntimeCache()
{
if (defined('RUN_PATH') && function_exists('path_delete')) {
@path_delete(RUN_PATH . '/cache');
@path_delete(RUN_PATH . '/complile');
@path_delete(RUN_PATH . '/config');
@path_delete(RUN_PATH . '/upgrade');
}
if (function_exists('cache_config')) {
@cache_config(true);
}
if (function_exists('opcache_reset') && extension_loaded('Zend OPcache')) {
@opcache_reset();
}
}
private function success($data = array(), $msg = '')
{
$this->respond(1, $data, $msg);
}
private function fail($code, $data = '', $msg = '')
{
$this->respond($code, $data, $msg);
}
private function respond($code, $data = '', $msg = '')
{
if (! headers_sent()) {
header('Content-Type: application/json; charset=utf-8');
}
echo json_encode(array(
'code' => $code,
'data' => $data,
'msg' => $msg
));
exit();
}
}
+4
View File
@@ -51,6 +51,7 @@ export interface ApiClient {
get: <T>(url: string, config?: AxiosRequestConfig) => Promise<T> get: <T>(url: string, config?: AxiosRequestConfig) => Promise<T>
post: <T, B = unknown>(url: string, body?: B, config?: AxiosRequestConfig) => Promise<T> post: <T, B = unknown>(url: string, body?: B, config?: AxiosRequestConfig) => Promise<T>
put: <T, B = unknown>(url: string, body?: B, config?: AxiosRequestConfig) => Promise<T> put: <T, B = unknown>(url: string, body?: B, config?: AxiosRequestConfig) => Promise<T>
patch: <T, B = unknown>(url: string, body?: B, config?: AxiosRequestConfig) => Promise<T>
remove: <T>(url: string, config?: AxiosRequestConfig) => Promise<T> remove: <T>(url: string, config?: AxiosRequestConfig) => Promise<T>
} }
@@ -172,6 +173,9 @@ export function createApiClient(options: CreateApiClientOptions = {}): ApiClient
async put<T, B = unknown>(url: string, body?: B, config?: AxiosRequestConfig) { async put<T, B = unknown>(url: string, body?: B, config?: AxiosRequestConfig) {
return unwrapEnvelope(await client.put<ApiEnvelope<T>>(url, body, config)) return unwrapEnvelope(await client.put<ApiEnvelope<T>>(url, body, config))
}, },
async patch<T, B = unknown>(url: string, body?: B, config?: AxiosRequestConfig) {
return unwrapEnvelope(await client.patch<ApiEnvelope<T>>(url, body, config))
},
async remove<T>(url: string, config?: AxiosRequestConfig) { async remove<T>(url: string, config?: AxiosRequestConfig) {
return unwrapEnvelope(await client.delete<ApiEnvelope<T>>(url, config)) return unwrapEnvelope(await client.delete<ApiEnvelope<T>>(url, config))
}, },
+106 -1
View File
@@ -1258,8 +1258,10 @@ export interface PublishRecord {
id: number id: number
publish_batch_id: number publish_batch_id: number
article_id: number article_id: number
platform_account_id: number platform_account_id?: number | null
desktop_account_id: string | null desktop_account_id: string | null
target_type?: 'platform_account' | 'enterprise_site' | string
target_connection_id?: number | null
platform_id: string platform_id: string
platform_name: string platform_name: string
platform_nickname: string platform_nickname: string
@@ -1273,6 +1275,109 @@ export interface PublishRecord {
updated_at: string updated_at: string
} }
export type EnterpriseSiteCmsType = 'pbootcms' | 'wordpress' | 'dedecms' | 'phpcms'
export type EnterpriseSiteStatus = 'draft' | 'connected' | 'error' | 'disabled'
export interface EnterpriseSiteLatestPublishRecord {
id: number
article_id: number
article_title?: string | null
status: string
external_article_id?: string | null
external_article_url?: string | null
error_message?: string | null
published_at?: string | null
created_at: string
updated_at: string
}
export interface EnterpriseSiteConnection {
id: number
name: string
site_url: string
cms_type: EnterpriseSiteCmsType | string
auth_type: string
appid: string
default_acode?: string | null
default_mcode?: string | null
default_scode?: string | null
status: EnterpriseSiteStatus | string
plugin_version?: string | null
site_name?: string | null
last_ping_at?: string | null
last_sync_at?: string | null
last_publish_at?: string | null
last_error?: string | null
category_count: number
latest_record?: EnterpriseSiteLatestPublishRecord | null
created_at: string
updated_at: string
}
export interface EnterpriseSiteCategory {
id: number
remote_id: string
parent_remote_id?: string | null
name: string
slug?: string | null
raw?: Record<string, JsonValue>
synced_at: string
}
export interface CreateEnterpriseSiteConnectionRequest {
name?: string
site_url: string
cms_type: EnterpriseSiteCmsType
appid: string
secret: string
default_acode?: string | null
default_mcode?: string | null
default_scode?: string | null
}
export interface UpdateEnterpriseSiteConnectionRequest {
name?: string
site_url?: string
appid?: string
secret?: string
default_acode?: string | null
default_mcode?: string | null
default_scode?: string | null
status?: EnterpriseSiteStatus
}
export interface EnterpriseSiteCapability {
cms_type: string
plugin_version?: string | null
site_url: string
site_name?: string | null
api_auth: boolean
}
export interface PublishEnterpriseSiteArticleRequest {
article_id: number
category_id?: string
publish_type?: 'publish' | 'draft'
author?: string | null
source?: string | null
article_version_id?: number | null
ack_record_id?: number | null
cover_asset_url?: string | null
cover_image_asset_id?: number | null
}
export interface EnterpriseSitePublishResponse {
publish_batch_id: number
publish_record_id: number
connection_id: number
article_id: number
status: string
external_article_id?: string | null
external_article_url?: string | null
error_message?: string | null
published_at?: string | null
}
export interface PublisherLocalPlatformState { export interface PublisherLocalPlatformState {
platform_id: string platform_id: string
connected: boolean connected: boolean
+4
View File
@@ -47,6 +47,7 @@ type Config struct {
type ServerConfig struct { type ServerConfig struct {
Port int `mapstructure:"port"` Port int `mapstructure:"port"`
Mode string `mapstructure:"mode"` Mode string `mapstructure:"mode"`
PublicBaseURL string `mapstructure:"public_base_url"`
TrustedProxies []string `mapstructure:"trusted_proxies"` TrustedProxies []string `mapstructure:"trusted_proxies"`
AllowedOrigins []string `mapstructure:"allowed_origins"` AllowedOrigins []string `mapstructure:"allowed_origins"`
SecurityHeaders SecurityHeaders `mapstructure:"security_headers"` SecurityHeaders SecurityHeaders `mapstructure:"security_headers"`
@@ -1193,6 +1194,9 @@ func applyEnvOverrides(cfg *Config) {
if origins, ok := lookupNonEmptyEnv("SERVER_ALLOWED_ORIGINS"); ok { if origins, ok := lookupNonEmptyEnv("SERVER_ALLOWED_ORIGINS"); ok {
cfg.Server.AllowedOrigins = strings.Split(origins, ",") cfg.Server.AllowedOrigins = strings.Split(origins, ",")
} }
if publicBaseURL, ok := lookupNonEmptyEnv("SERVER_PUBLIC_BASE_URL"); ok {
cfg.Server.PublicBaseURL = publicBaseURL
}
if enabled, ok := lookupBoolEnv("SERVER_SECURITY_HEADERS_ENABLED"); ok { if enabled, ok := lookupBoolEnv("SERVER_SECURITY_HEADERS_ENABLED"); ok {
cfg.Server.SecurityHeaders.Enabled = enabled cfg.Server.SecurityHeaders.Enabled = enabled
} }
@@ -110,7 +110,10 @@ const articlePublishStatusAggregateJoin = `
BOOL_OR(latest.status_bucket = 'success') AS has_success, BOOL_OR(latest.status_bucket = 'success') AS has_success,
BOOL_OR(latest.status_bucket = 'failed') AS has_failed BOOL_OR(latest.status_bucket = 'failed') AS has_failed
FROM ( FROM (
SELECT DISTINCT ON (pr.platform_account_id) SELECT DISTINCT ON (
COALESCE(pr.target_type, 'platform_account'),
COALESCE(pr.platform_account_id, pr.target_connection_id)
)
CASE CASE
WHEN pr.status IN ('success', 'published', 'publish_success') THEN 'success' WHEN pr.status IN ('success', 'published', 'publish_success') THEN 'success'
WHEN pr.status IN ('publishing', 'pending', 'queued', 'running') THEN 'publishing' WHEN pr.status IN ('publishing', 'pending', 'queued', 'running') THEN 'publishing'
@@ -119,7 +122,11 @@ const articlePublishStatusAggregateJoin = `
FROM publish_records pr FROM publish_records pr
WHERE pr.tenant_id = a.tenant_id WHERE pr.tenant_id = a.tenant_id
AND pr.article_id = a.id AND pr.article_id = a.id
ORDER BY pr.platform_account_id, pr.created_at DESC, pr.id DESC ORDER BY
COALESCE(pr.target_type, 'platform_account'),
COALESCE(pr.platform_account_id, pr.target_connection_id),
pr.created_at DESC,
pr.id DESC
) latest ) latest
) publish_status_summary ON true` ) publish_status_summary ON true`
@@ -0,0 +1,78 @@
package app
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
"io"
"strings"
)
const enterpriseSiteCredentialPrefix = "v1:"
func encryptEnterpriseSiteCredential(secret string, keyMaterial string) (string, error) {
secret = strings.TrimSpace(secret)
if secret == "" {
return "", fmt.Errorf("enterprise site credential is empty")
}
key := enterpriseSiteCredentialKey(keyMaterial)
block, err := aes.NewCipher(key[:])
if err != nil {
return "", fmt.Errorf("create enterprise site credential cipher: %w", err)
}
aead, err := cipher.NewGCM(block)
if err != nil {
return "", fmt.Errorf("create enterprise site credential gcm: %w", err)
}
nonce := make([]byte, aead.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", fmt.Errorf("generate enterprise site credential nonce: %w", err)
}
ciphertext := aead.Seal(nil, nonce, []byte(secret), nil)
packed := append(nonce, ciphertext...)
return enterpriseSiteCredentialPrefix + base64.StdEncoding.EncodeToString(packed), nil
}
func decryptEnterpriseSiteCredential(ciphertext string, keyMaterial string) (string, error) {
ciphertext = strings.TrimSpace(ciphertext)
if ciphertext == "" {
return "", fmt.Errorf("enterprise site credential ciphertext is empty")
}
if !strings.HasPrefix(ciphertext, enterpriseSiteCredentialPrefix) {
return "", fmt.Errorf("unsupported enterprise site credential format")
}
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(ciphertext, enterpriseSiteCredentialPrefix))
if err != nil {
return "", fmt.Errorf("decode enterprise site credential: %w", err)
}
key := enterpriseSiteCredentialKey(keyMaterial)
block, err := aes.NewCipher(key[:])
if err != nil {
return "", fmt.Errorf("create enterprise site credential cipher: %w", err)
}
aead, err := cipher.NewGCM(block)
if err != nil {
return "", fmt.Errorf("create enterprise site credential gcm: %w", err)
}
if len(raw) <= aead.NonceSize() {
return "", fmt.Errorf("enterprise site credential payload is invalid")
}
nonce := raw[:aead.NonceSize()]
encrypted := raw[aead.NonceSize():]
plaintext, err := aead.Open(nil, nonce, encrypted, nil)
if err != nil {
return "", fmt.Errorf("decrypt enterprise site credential: %w", err)
}
return string(plaintext), nil
}
func enterpriseSiteCredentialKey(keyMaterial string) [32]byte {
trimmed := strings.TrimSpace(keyMaterial)
if trimmed == "" {
trimmed = "geo-rankly-enterprise-site-development-key"
}
return sha256.Sum256([]byte("enterprise-site-credential:" + trimmed))
}
@@ -0,0 +1,402 @@
package app
import (
"bytes"
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const pbootCMSPlatformID = "pbootcms"
type cmsPublisher interface {
Ping(ctx context.Context, credential enterpriseSiteCredential) (*EnterpriseSiteCapability, map[string]any, error)
Categories(ctx context.Context, credential enterpriseSiteCredential) ([]EnterpriseSiteCategoryItem, []map[string]any, error)
Publish(ctx context.Context, credential enterpriseSiteCredential, req cmsPublishRequest) (*cmsPublishResult, map[string]any, error)
}
type pbootCMSPublisher struct {
client *http.Client
}
type enterpriseSiteCredential struct {
SiteURL string
AppID string
Secret string
DefaultAcode string
DefaultMcode string
DefaultScode string
}
type cmsPublishRequest struct {
ArticleID int64
Title string
ContentHTML string
CategoryID string
Keywords string
Description string
Author string
Source string
CoverURL string
PublishType string
}
type cmsPublishResult struct {
RemoteID string
URL string
Status string
}
type pbootCMSResponse struct {
Code int `json:"code"`
Data json.RawMessage `json:"data"`
Msg string `json:"msg"`
}
type pbootCMSRequestError struct {
message string
routeUnavailable bool
}
func (err *pbootCMSRequestError) Error() string {
return err.message
}
func newPBootCMSPublisher(client *http.Client) *pbootCMSPublisher {
if client == nil {
client = &http.Client{Timeout: 20 * time.Second}
}
return &pbootCMSPublisher{client: client}
}
func (p *pbootCMSPublisher) Ping(ctx context.Context, credential enterpriseSiteCredential) (*EnterpriseSiteCapability, map[string]any, error) {
var payload map[string]any
if err := p.request(ctx, http.MethodGet, credential, "ping", nil, nil, &payload); err != nil {
return nil, nil, err
}
capability := &EnterpriseSiteCapability{
CMSType: strings.TrimSpace(stringFromMap(payload, "cms_type")),
PluginVersion: stringPointerFromMap(payload, "plugin_version"),
SiteURL: strings.TrimSpace(stringFromMap(payload, "site_url")),
SiteName: stringPointerFromMap(payload, "site_name"),
APIAuth: boolFromMap(payload, "api_auth"),
}
if capability.CMSType == "" {
capability.CMSType = pbootCMSPlatformID
}
if capability.SiteURL == "" {
capability.SiteURL = credential.SiteURL
}
return capability, payload, nil
}
func (p *pbootCMSPublisher) Categories(ctx context.Context, credential enterpriseSiteCredential) ([]EnterpriseSiteCategoryItem, []map[string]any, error) {
query := url.Values{}
if strings.TrimSpace(credential.DefaultAcode) != "" {
query.Set("acode", strings.TrimSpace(credential.DefaultAcode))
}
if strings.TrimSpace(credential.DefaultMcode) != "" {
query.Set("mcode", strings.TrimSpace(credential.DefaultMcode))
}
var rawItems []map[string]any
if err := p.request(ctx, http.MethodGet, credential, "categories", query, nil, &rawItems); err != nil {
return nil, nil, err
}
items := make([]EnterpriseSiteCategoryItem, 0, len(rawItems))
for _, raw := range rawItems {
id := firstStringFromMap(raw, "id", "scode", "remote_id")
name := firstStringFromMap(raw, "title", "name")
if strings.TrimSpace(id) == "" || strings.TrimSpace(name) == "" {
continue
}
items = append(items, EnterpriseSiteCategoryItem{
RemoteID: strings.TrimSpace(id),
ParentRemoteID: pbootCMSOptionalString(firstStringFromMap(raw, "parent_id", "pcode")),
Name: strings.TrimSpace(name),
Slug: pbootCMSOptionalString(firstStringFromMap(raw, "slug", "filename")),
Raw: raw,
})
}
return items, rawItems, nil
}
func (p *pbootCMSPublisher) Publish(ctx context.Context, credential enterpriseSiteCredential, req cmsPublishRequest) (*cmsPublishResult, map[string]any, error) {
categoryID := strings.TrimSpace(req.CategoryID)
if categoryID == "" {
categoryID = strings.TrimSpace(credential.DefaultScode)
}
body := map[string]any{
"scode": categoryID,
"title": strings.TrimSpace(req.Title),
"content": strings.TrimSpace(req.ContentHTML),
"keywords": strings.TrimSpace(req.Keywords),
"description": strings.TrimSpace(req.Description),
"author": strings.TrimSpace(req.Author),
"source": strings.TrimSpace(req.Source),
"status": pbootCMSStatusFromPublishType(req.PublishType),
}
if strings.TrimSpace(req.CoverURL) != "" {
body["ico"] = strings.TrimSpace(req.CoverURL)
}
if strings.TrimSpace(credential.DefaultAcode) != "" {
body["acode"] = strings.TrimSpace(credential.DefaultAcode)
}
var payload map[string]any
if err := p.request(ctx, http.MethodPost, credential, "publish", nil, body, &payload); err != nil {
return nil, nil, err
}
remoteID := firstStringFromMap(payload, "id", "remote_id")
result := &cmsPublishResult{
RemoteID: strings.TrimSpace(remoteID),
URL: strings.TrimSpace(firstStringFromMap(payload, "url", "external_article_url")),
Status: strings.TrimSpace(firstStringFromMap(payload, "status")),
}
if result.Status == "" {
result.Status = "published"
}
return result, payload, nil
}
func (p *pbootCMSPublisher) request(ctx context.Context, method string, credential enterpriseSiteCredential, action string, query url.Values, body any, out any) error {
endpoints, err := pbootCMSEndpoints(credential.SiteURL, action)
if err != nil {
return err
}
var lastErr error
for _, endpoint := range endpoints {
if err := p.requestEndpoint(ctx, method, endpoint, credential, action, query, body, out); err != nil {
lastErr = err
if !pbootCMSRouteUnavailable(err) {
break
}
continue
}
return nil
}
if lastErr != nil {
return lastErr
}
return fmt.Errorf("pbootcms %s endpoint is unavailable", action)
}
func (p *pbootCMSPublisher) requestEndpoint(ctx context.Context, method string, endpoint *url.URL, credential enterpriseSiteCredential, action string, query url.Values, body any, out any) error {
requestEndpoint := *endpoint
requestQuery := requestEndpoint.Query()
for key, values := range query {
requestQuery.Del(key)
for _, value := range values {
requestQuery.Add(key, value)
}
}
p.signQuery(credential, requestQuery)
requestEndpoint.RawQuery = requestQuery.Encode()
var reader io.Reader
if body != nil {
raw, marshalErr := json.Marshal(body)
if marshalErr != nil {
return fmt.Errorf("marshal pbootcms request: %w", marshalErr)
}
reader = bytes.NewReader(raw)
}
httpReq, err := http.NewRequestWithContext(ctx, method, requestEndpoint.String(), reader)
if err != nil {
return fmt.Errorf("build pbootcms request: %w", err)
}
httpReq.Header.Set("User-Agent", "GeoRankly-PBootCMS/1.0")
if body != nil {
httpReq.Header.Set("Content-Type", "application/json")
}
resp, err := p.client.Do(httpReq)
if err != nil {
return fmt.Errorf("request pbootcms %s: %w", action, err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
if err != nil {
return fmt.Errorf("read pbootcms response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return &pbootCMSRequestError{
message: fmt.Sprintf("pbootcms %s returned http %d", action, resp.StatusCode),
routeUnavailable: resp.StatusCode == http.StatusNotFound,
}
}
var envelope pbootCMSResponse
if err := json.Unmarshal(raw, &envelope); err != nil {
rawText := strings.TrimSpace(string(raw))
return &pbootCMSRequestError{
message: fmt.Sprintf("parse pbootcms response: %v", err),
routeUnavailable: pbootCMSLooksLikeMissingRoute(rawText),
}
}
if envelope.Code != 1 {
msg := strings.TrimSpace(envelope.Msg)
if msg == "" {
msg = strings.TrimSpace(string(envelope.Data))
}
if msg == "" {
msg = fmt.Sprintf("pbootcms %s failed with code %d", action, envelope.Code)
}
return &pbootCMSRequestError{
message: msg,
routeUnavailable: strings.Contains(msg, "页面类文件不存在") || strings.Contains(strings.ToLower(msg), "not found"),
}
}
if out == nil {
return nil
}
if len(envelope.Data) == 0 || string(envelope.Data) == "null" {
return nil
}
if err := json.Unmarshal(envelope.Data, out); err != nil {
return fmt.Errorf("parse pbootcms data: %w", err)
}
return nil
}
func (p *pbootCMSPublisher) signQuery(credential enterpriseSiteCredential, query url.Values) {
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
appid := strings.TrimSpace(credential.AppID)
secret := strings.TrimSpace(credential.Secret)
query.Set("appid", appid)
query.Set("timestamp", timestamp)
query.Set("signature", pbootCMSSignature(appid, secret, timestamp))
}
func pbootCMSEndpoints(siteURL string, action string) ([]*url.URL, error) {
parsed, err := url.Parse(strings.TrimSpace(siteURL))
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return nil, fmt.Errorf("invalid pbootcms site url")
}
basePath := strings.TrimRight(parsed.Path, "/")
cleanAction := strings.TrimLeft(action, "/")
pretty := *parsed
pretty.Path = basePath + "/api/GeoPublisher/" + cleanAction
pretty.RawQuery = ""
pretty.Fragment = ""
queryEntry := *parsed
queryEntry.Path = basePath + "/api.php"
query := url.Values{}
query.Set("p", "/GeoPublisher/"+cleanAction)
queryEntry.RawQuery = query.Encode()
queryEntry.Fragment = ""
return []*url.URL{&pretty, &queryEntry}, nil
}
func pbootCMSSignature(appid, secret, timestamp string) string {
first := md5.Sum([]byte(appid + secret + timestamp))
firstHex := hex.EncodeToString(first[:])
second := md5.Sum([]byte(firstHex))
return hex.EncodeToString(second[:])
}
func pbootCMSStatusFromPublishType(publishType string) int {
if strings.TrimSpace(publishType) == "draft" {
return 0
}
return 1
}
func pbootCMSRouteUnavailable(err error) bool {
requestErr, ok := err.(*pbootCMSRequestError)
return ok && requestErr.routeUnavailable
}
func pbootCMSLooksLikeMissingRoute(text string) bool {
text = strings.ToLower(strings.TrimSpace(text))
return strings.Contains(text, "页面类文件不存在") ||
strings.Contains(text, "页面不存在") ||
strings.Contains(text, "404") ||
strings.Contains(text, "not found")
}
func firstStringFromMap(payload map[string]any, keys ...string) string {
for _, key := range keys {
if value := stringFromMap(payload, key); strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func stringFromMap(payload map[string]any, key string) string {
if payload == nil {
return ""
}
value, ok := payload[key]
if !ok || value == nil {
return ""
}
switch typed := value.(type) {
case string:
return typed
case float64:
if typed == float64(int64(typed)) {
return strconv.FormatInt(int64(typed), 10)
}
return strconv.FormatFloat(typed, 'f', -1, 64)
case int:
return strconv.Itoa(typed)
case int64:
return strconv.FormatInt(typed, 10)
case bool:
if typed {
return "true"
}
return "false"
default:
return fmt.Sprintf("%v", typed)
}
}
func stringPointerFromMap(payload map[string]any, key string) *string {
if value := strings.TrimSpace(stringFromMap(payload, key)); value != "" {
return &value
}
return nil
}
func boolFromMap(payload map[string]any, key string) bool {
if payload == nil {
return false
}
switch value := payload[key].(type) {
case bool:
return value
case string:
return strings.EqualFold(strings.TrimSpace(value), "true") || strings.TrimSpace(value) == "1"
case float64:
return value != 0
default:
return false
}
}
func pbootCMSOptionalString(value string) *string {
value = strings.TrimSpace(value)
if value == "" || value == "0" {
return nil
}
return &value
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,436 @@
package app
import (
"context"
"strings"
"testing"
"github.com/geo-platform/tenant-api/internal/shared/config"
"github.com/geo-platform/tenant-api/internal/shared/publicasset"
"github.com/sergi/go-diff/diffmatchpatch"
)
func TestEnterpriseSiteDescriptionUsesRenderedHTMLText(t *testing.T) {
got := enterpriseSiteDescription(
"<h2>2026年合肥全屋定制行业发展现状</h2><p>消费者需求升级。</p>",
"## 旧版标题\n旧版正文不应该优先进入摘要",
)
if strings.Contains(got, "##") {
t.Fatalf("description contains markdown heading marker: %q", got)
}
if strings.Contains(got, "旧版正文") {
t.Fatalf("description used markdown while html text was available: %q", got)
}
if !strings.Contains(got, "2026年合肥全屋定制行业发展现状") {
t.Fatalf("description = %q, want rendered html text", got)
}
}
func TestEnterpriseSiteStripLeadingTitleHeadingHTMLRemovesDuplicateTitle(t *testing.T) {
got := enterpriseSiteStripLeadingTitleHeadingHTML(
"<h1>合肥全屋定制口碑怎么判断 看这里</h1><p>正文第一段。</p>",
"合肥全屋定制口碑怎么判断 看这里",
)
if strings.Contains(got, "<h1>") || strings.Contains(got, "合肥全屋定制口碑怎么判断 看这里") {
t.Fatalf("duplicate title heading was not removed: %q", got)
}
if !strings.Contains(got, "正文第一段") {
t.Fatalf("body content was lost: %q", got)
}
}
func TestEnterpriseSiteStripLeadingTitleHeadingHTMLKeepsDifferentHeading(t *testing.T) {
input := "<h2>行业现状</h2><p>正文第一段。</p>"
got := enterpriseSiteStripLeadingTitleHeadingHTML(input, "合肥全屋定制口碑怎么判断 看这里")
if got != input {
t.Fatalf("different leading heading changed: %q", got)
}
}
func TestEnterpriseSiteStripLeadingTitleHeadingMarkdownRemovesDuplicateTitle(t *testing.T) {
got := enterpriseSiteStripLeadingTitleHeadingMarkdown(
"# 合肥全屋定制口碑怎么判断 看这里\n\n正文第一段。",
"合肥全屋定制口碑怎么判断 看这里",
)
if strings.Contains(got, "合肥全屋定制口碑怎么判断 看这里") {
t.Fatalf("duplicate markdown title was not removed: %q", got)
}
if got != "正文第一段。" {
t.Fatalf("markdown body = %q, want body only", got)
}
}
func TestEnterpriseSiteStripLeadingTitleHeadingMarkdownRemovesPlainTitle(t *testing.T) {
got := enterpriseSiteStripLeadingTitleHeadingMarkdown(
"装修预算不够?2026安徽6款高性价比家居整理\n\n近年安徽本地全屋定制需求持续上涨。",
"装修预算不够?2026安徽6款高性价比家居整理",
)
if strings.Contains(got, "装修预算不够?2026安徽6款高性价比家居整理") {
t.Fatalf("plain duplicate title was not removed: %q", got)
}
if !strings.HasPrefix(got, "近年安徽本地全屋定制需求持续上涨") {
t.Fatalf("markdown body = %q, want body only", got)
}
}
func TestEnterpriseSiteDescriptionAfterTitleStripSkipsDuplicateTitle(t *testing.T) {
title := "合肥全屋定制口碑怎么判断 看这里"
html := enterpriseSiteStripLeadingTitleHeadingHTML("<h1>"+title+"</h1><p>正文第一段。</p>", title)
markdown := enterpriseSiteStripLeadingTitleHeadingMarkdown("# "+title+"\n\n正文第一段。", title)
got := enterpriseSiteDescription(html, markdown)
if strings.Contains(got, title) {
t.Fatalf("description still contains duplicate title: %q", got)
}
if !strings.Contains(got, "正文第一段") {
t.Fatalf("description = %q, want body text", got)
}
}
func TestEnterpriseSiteDescriptionFallsBackToCleanMarkdown(t *testing.T) {
got := enterpriseSiteDescription("", "## 2026年合肥全屋定制行业发展现状\n- 消费者需求升级。")
if strings.Contains(got, "##") || strings.Contains(got, "- ") {
t.Fatalf("description was not cleaned: %q", got)
}
if !strings.Contains(got, "消费者需求升级") {
t.Fatalf("description = %q, want markdown text", got)
}
}
func TestEnterpriseSiteNormalizeStoredContentRestoresDiffPatch(t *testing.T) {
source := "# 2026安徽全屋定制行业\n\n## 引言\n正文第一段。"
dmp := diffmatchpatch.New()
patch := dmp.PatchToText(dmp.PatchMake("", source))
got := enterpriseSiteNormalizeStoredContent(patch)
if got != source {
t.Fatalf("restored content = %q, want %q", got, source)
}
if strings.Contains(got, "@@ -") {
t.Fatalf("diff patch leaked into normalized content: %q", got)
}
}
func TestEnterpriseSiteNormalizeStoredContentRepairsMalformedEditorImage(t *testing.T) {
got := enterpriseSiteNormalizeStoredContent(
`<p class="article-editor-image article-editor-image--center" align="center" <img src="/api/public/assets/token" alt="" /></p>`,
)
if !strings.Contains(got, `align="center"><img`) {
t.Fatalf("malformed editor image was not repaired: %q", got)
}
}
func TestEnterpriseSiteHTMLContentPatchFallsBackToMarkdownRendering(t *testing.T) {
source := "# 2026安徽全屋定制行业\n\n## 引言\n正文第一段。"
dmp := diffmatchpatch.New()
htmlContent := enterpriseSiteNormalizeStoredContent(dmp.PatchToText(dmp.PatchMake("", source)))
markdownContent := ""
if htmlContent != "" && !enterpriseSiteContentLooksLikeHTML(htmlContent) {
markdownContent = htmlContent
htmlContent = ""
}
got, err := markdownToEnterpriseSiteHTML(markdownContent)
if err != nil {
t.Fatalf("render markdown: %v", err)
}
if strings.Contains(got, "@@ -") || strings.Contains(got, "%E5") {
t.Fatalf("rendered HTML still contains patch/url-encoded content: %q", got)
}
if !strings.Contains(got, "<h1>2026安徽全屋定制行业</h1>") || !strings.Contains(got, "<p>正文第一段。</p>") {
t.Fatalf("rendered HTML = %q, want UEditor-ready HTML", got)
}
}
func TestEnterpriseSiteAbsoluteImageURLsRewritesRelativeAssetPaths(t *testing.T) {
got := enterpriseSiteAbsoluteImageURLs(
`<p><img src="/api/public/assets/token" data-src="/api/public/assets/token" /></p>`,
"http://127.0.0.1:8080",
)
if strings.Count(got, "http://127.0.0.1:8080/api/public/assets/token") != 2 {
t.Fatalf("image urls were not absolutized: %s", got)
}
}
func TestPrepareEnterpriseSiteImageURLsEmbedsPublicAssetImages(t *testing.T) {
objectKey := "tenants/7/images/body.png"
token := publicasset.SignObjectKey(objectKey, "test-secret")
storage := &enterpriseSiteImageObjectStorage{content: testEnterpriseSitePNG()}
svc := NewEnterpriseSiteService(nil, config.NewStaticProvider(&config.Config{
JWT: config.JWTConfig{Secret: "test-secret"},
})).WithObjectStorage(storage)
got := svc.prepareEnterpriseSiteImageURLs(
context.Background(),
7,
`<p><img src="/api/public/assets/`+token+`" data-src="/api/public/assets/`+token+`" /></p>`,
"http://127.0.0.1:8080",
)
if storage.gotKey != objectKey {
t.Fatalf("object key = %q, want %q", storage.gotKey, objectKey)
}
if strings.Count(got, `data:image/png;base64,`) != 2 {
t.Fatalf("image was not embedded in both image attrs: %s", got)
}
if strings.Contains(got, "/api/public/assets/") {
t.Fatalf("public asset url leaked into enterprise site payload: %s", got)
}
}
func TestPrepareEnterpriseSiteImageURLsEmbedsPublicAssetImagesWithStaleSignature(t *testing.T) {
objectKey := "tenants/7/images/body.png"
token := publicasset.SignObjectKey(objectKey, "old-secret")
storage := &enterpriseSiteImageObjectStorage{content: testEnterpriseSitePNG()}
svc := NewEnterpriseSiteService(nil, config.NewStaticProvider(&config.Config{
JWT: config.JWTConfig{Secret: "new-secret"},
})).WithObjectStorage(storage)
got := svc.prepareEnterpriseSiteImageURLs(
context.Background(),
7,
`<p><img src="http://localhost:5178/api/public/assets/`+token+`" /></p>`,
"http://127.0.0.1:8080",
)
if storage.gotKey != objectKey {
t.Fatalf("object key = %q, want %q", storage.gotKey, objectKey)
}
if !strings.Contains(got, `src="data:image/png;base64,`) {
t.Fatalf("stale signed image was not embedded: %s", got)
}
if strings.Contains(got, "localhost:5178") || strings.Contains(got, "/api/public/assets/") {
t.Fatalf("saas asset url leaked into enterprise site payload: %s", got)
}
}
func TestPrepareEnterpriseSiteImageURLsStripsSaaSImageAttrs(t *testing.T) {
objectKey := "tenants/7/images/body.png"
token := publicasset.SignObjectKey(objectKey, "test-secret")
storage := &enterpriseSiteImageObjectStorage{content: testEnterpriseSitePNG()}
svc := NewEnterpriseSiteService(nil, config.NewStaticProvider(&config.Config{
JWT: config.JWTConfig{Secret: "test-secret"},
})).WithObjectStorage(storage)
got := svc.prepareEnterpriseSiteImageURLs(
context.Background(),
7,
`<p><img src="/api/public/assets/`+token+`" alt="" data-asset-id="35" asset-id="35" /></p>`,
"http://127.0.0.1:8080",
)
if !strings.Contains(got, `src="data:image/png;base64,`) {
t.Fatalf("image was not embedded: %s", got)
}
if strings.Contains(got, "data-asset-id") || strings.Contains(got, "asset-id") {
t.Fatalf("saas image attrs leaked into enterprise site payload: %s", got)
}
}
func TestPrepareEnterpriseSiteImageURLsDropsUnavailableSaaSAssetImage(t *testing.T) {
objectKey := "tenants/7/images/deleted.webp"
token := publicasset.SignObjectKey(objectKey, "old-secret")
storage := &enterpriseSiteImageObjectStorage{}
svc := NewEnterpriseSiteService(nil, config.NewStaticProvider(&config.Config{
JWT: config.JWTConfig{Secret: "new-secret"},
})).WithObjectStorage(storage)
got := svc.prepareEnterpriseSiteImageURLs(
context.Background(),
7,
`<p>前文</p><p class="article-editor-image article-editor-image--center" align="center"><img src="http://localhost:5178/api/public/assets/`+token+`" alt="" /></p><p>后文</p>`,
"http://127.0.0.1:8080",
)
if strings.Contains(got, "localhost:5178") || strings.Contains(got, "/api/public/assets/") {
t.Fatalf("unavailable saas asset url leaked into enterprise site payload: %s", got)
}
if strings.Contains(got, "<img") || strings.Contains(got, "article-editor-image") {
t.Fatalf("unavailable image wrapper was not removed: %s", got)
}
if !strings.Contains(got, "前文") || !strings.Contains(got, "后文") {
t.Fatalf("surrounding content was lost: %s", got)
}
}
func TestPrepareEnterpriseSiteImageURLsEmbedsIncompletePublicAssetPaths(t *testing.T) {
objectKey := "tenants/7/articles/42/images/body.webp"
token := publicasset.SignObjectKey(objectKey, "test-secret")
storage := &enterpriseSiteImageObjectStorage{content: testEnterpriseSitePNG()}
svc := NewEnterpriseSiteService(nil, config.NewStaticProvider(&config.Config{
JWT: config.JWTConfig{Secret: "test-secret"},
})).WithObjectStorage(storage)
got := svc.prepareEnterpriseSiteImageURLs(
context.Background(),
7,
`<p><img src="api/public/assets/`+token+`?format=png" /></p>`,
"http://127.0.0.1:8080",
)
if storage.gotKey != objectKey {
t.Fatalf("object key = %q, want %q", storage.gotKey, objectKey)
}
if !strings.Contains(got, `src="data:image/png;base64,`) {
t.Fatalf("image was not embedded for incomplete asset path: %s", got)
}
if strings.Contains(got, "127.0.0.1") || strings.Contains(got, "api/public/assets") {
t.Fatalf("incomplete asset path leaked into enterprise site payload: %s", got)
}
}
func TestMarkdownRenderedEnterpriseSiteImageEmbedsIncompletePublicAssetPath(t *testing.T) {
objectKey := "tenants/7/articles/42/images/body.webp"
token := publicasset.SignObjectKey(objectKey, "test-secret")
storage := &enterpriseSiteImageObjectStorage{content: testEnterpriseSitePNG()}
svc := NewEnterpriseSiteService(nil, config.NewStaticProvider(&config.Config{
JWT: config.JWTConfig{Secret: "test-secret"},
})).WithObjectStorage(storage)
html, err := markdownToEnterpriseSiteHTML("正文\n\n![body](api/public/assets/" + token + "?format=png)")
if err != nil {
t.Fatalf("render markdown: %v", err)
}
got := svc.prepareEnterpriseSiteImageURLs(context.Background(), 7, html, "http://127.0.0.1:8080")
if storage.gotKey != objectKey {
t.Fatalf("object key = %q, want %q", storage.gotKey, objectKey)
}
if !strings.Contains(got, `src="data:image/png;base64,`) {
t.Fatalf("markdown image was not embedded for incomplete asset path: %s", got)
}
if strings.Contains(got, "127.0.0.1") || strings.Contains(got, "api/public/assets") {
t.Fatalf("markdown image asset path leaked into enterprise site payload: %s", got)
}
}
func TestPrepareEnterpriseSitePublishAssetsEmbedsIncompleteCoverAssetPath(t *testing.T) {
objectKey := "tenants/7/articles/42/images/cover.webp"
token := publicasset.SignObjectKey(objectKey, "test-secret")
storage := &enterpriseSiteImageObjectStorage{content: testEnterpriseSitePNG()}
svc := NewEnterpriseSiteService(nil, config.NewStaticProvider(&config.Config{
JWT: config.JWTConfig{Secret: "test-secret"},
Server: config.ServerConfig{PublicBaseURL: "http://127.0.0.1:8080"},
})).WithObjectStorage(storage)
got := svc.prepareEnterpriseSiteCoverURL(
context.Background(),
7,
"api/public/assets/"+token+"?format=png",
nil,
"http://127.0.0.1:8080",
)
if storage.gotKey != objectKey {
t.Fatalf("object key = %q, want %q", storage.gotKey, objectKey)
}
if !strings.HasPrefix(got, "data:image/png;base64,") {
t.Fatalf("cover was not embedded as data URI: %q", got)
}
}
func TestPrepareEnterpriseSiteCoverURLDropsUnavailableSaaSAssetURL(t *testing.T) {
objectKey := "tenants/7/images/deleted.webp"
token := publicasset.SignObjectKey(objectKey, "old-secret")
storage := &enterpriseSiteImageObjectStorage{}
svc := NewEnterpriseSiteService(nil, config.NewStaticProvider(&config.Config{
JWT: config.JWTConfig{Secret: "new-secret"},
Server: config.ServerConfig{PublicBaseURL: "http://127.0.0.1:8080"},
})).WithObjectStorage(storage)
got := svc.prepareEnterpriseSiteCoverURL(
context.Background(),
7,
"http://localhost:5178/api/public/assets/"+token,
nil,
"http://127.0.0.1:8080",
)
if got != "" {
t.Fatalf("unavailable saas cover should not be sent to pbootcms, got %q", got)
}
}
func TestEnterpriseSiteNormalizeLegacyPublishErrorState(t *testing.T) {
errorMessage := "图片下载失败"
item := EnterpriseSiteConnectionResponse{
Status: "error",
LastError: &errorMessage,
LatestRecord: &EnterpriseSiteLatestPublishRecord{
Status: "failed",
ErrorMessage: &errorMessage,
},
}
enterpriseSiteNormalizeLegacyPublishErrorState(&item)
if item.Status != "connected" {
t.Fatalf("status = %q, want connected", item.Status)
}
if item.LastError != nil {
t.Fatalf("last error = %q, want nil", *item.LastError)
}
}
func TestEnterpriseSiteNormalizeLegacyPublishErrorStateKeepsPingError(t *testing.T) {
lastError := "CMS request failed"
recordError := "图片下载失败"
item := EnterpriseSiteConnectionResponse{
Status: "error",
LastError: &lastError,
LatestRecord: &EnterpriseSiteLatestPublishRecord{
Status: "failed",
ErrorMessage: &recordError,
},
}
enterpriseSiteNormalizeLegacyPublishErrorState(&item)
if item.Status != "error" {
t.Fatalf("status = %q, want error", item.Status)
}
if item.LastError == nil || *item.LastError != lastError {
t.Fatalf("last error changed: %#v", item.LastError)
}
}
type enterpriseSiteImageObjectStorage struct {
content []byte
gotKey string
}
func (s *enterpriseSiteImageObjectStorage) Validate() error { return nil }
func (s *enterpriseSiteImageObjectStorage) PutBytes(context.Context, string, []byte, string) error {
return nil
}
func (s *enterpriseSiteImageObjectStorage) GetBytes(_ context.Context, objectKey string) ([]byte, error) {
s.gotKey = objectKey
return s.content, nil
}
func (s *enterpriseSiteImageObjectStorage) Exists(context.Context, string) (bool, error) {
return true, nil
}
func (s *enterpriseSiteImageObjectStorage) Delete(context.Context, string) error { return nil }
func (s *enterpriseSiteImageObjectStorage) PublicURL(string) (string, error) { return "", nil }
func (s *enterpriseSiteImageObjectStorage) DownloadURL(string) (string, error) { return "", nil }
func testEnterpriseSitePNG() []byte {
return []byte{
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41,
0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00,
0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
0x42, 0x60, 0x82,
}
}
+22 -8
View File
@@ -38,8 +38,10 @@ type PublishRecordResponse struct {
ID int64 `json:"id"` ID int64 `json:"id"`
PublishBatchID int64 `json:"publish_batch_id"` PublishBatchID int64 `json:"publish_batch_id"`
ArticleID int64 `json:"article_id"` ArticleID int64 `json:"article_id"`
PlatformAccountID int64 `json:"platform_account_id"` PlatformAccountID *int64 `json:"platform_account_id"`
DesktopAccountID *string `json:"desktop_account_id"` DesktopAccountID *string `json:"desktop_account_id"`
TargetType string `json:"target_type"`
TargetConnectionID *int64 `json:"target_connection_id"`
PlatformID string `json:"platform_id"` PlatformID string `json:"platform_id"`
PlatformName string `json:"platform_name"` PlatformName string `json:"platform_name"`
PlatformNickname string `json:"platform_nickname"` PlatformNickname string `json:"platform_nickname"`
@@ -98,12 +100,22 @@ func (s *MediaService) ListArticlePublishRecords(ctx context.Context, articleID
func (s *MediaService) listArticlePublishRecords(ctx context.Context, tenantID, brandID, articleID int64) ([]PublishRecordResponse, error) { func (s *MediaService) listArticlePublishRecords(ctx context.Context, tenantID, brandID, articleID int64) ([]PublishRecordResponse, error) {
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT pr.id, pr.publish_batch_id, pr.article_id, pr.platform_account_id, pa.desktop_id::text, pr.platform_id, SELECT pr.id, pr.publish_batch_id, pr.article_id, pr.platform_account_id, pa.desktop_id::text,
mp.name, pa.nickname, pr.status, pr.external_article_id, pr.external_article_url, COALESCE(pr.target_type, 'platform_account'), pr.target_connection_id, pr.platform_id,
COALESCE(mp.name, esc.site_name, esc.name, pr.platform_id) AS platform_name,
CASE
WHEN COALESCE(pr.target_type, 'platform_account') = 'enterprise_site'
THEN COALESCE(esc.site_name, esc.name, '企业站点')
ELSE COALESCE(pa.nickname, '')
END AS platform_nickname,
pr.status, pr.external_article_id, pr.external_article_url,
pr.external_manage_url, pr.published_at, pr.error_message, pr.created_at, pr.updated_at pr.external_manage_url, pr.published_at, pr.error_message, pr.created_at, pr.updated_at
FROM publish_records pr FROM publish_records pr
JOIN media_platforms mp ON mp.platform_id = pr.platform_id LEFT JOIN media_platforms mp ON mp.platform_id = pr.platform_id
JOIN platform_accounts pa ON pa.id = pr.platform_account_id LEFT JOIN platform_accounts pa ON pa.id = pr.platform_account_id
LEFT JOIN enterprise_site_connections esc
ON esc.id = pr.target_connection_id
AND esc.tenant_id = pr.tenant_id
JOIN articles a ON a.id = pr.article_id AND a.tenant_id = pr.tenant_id JOIN articles a ON a.id = pr.article_id AND a.tenant_id = pr.tenant_id
WHERE pr.tenant_id = $1 AND pr.article_id = $2 AND a.brand_id = $3 AND a.deleted_at IS NULL WHERE pr.tenant_id = $1 AND pr.article_id = $2 AND a.brand_id = $3 AND a.deleted_at IS NULL
ORDER BY pr.created_at DESC, pr.id DESC ORDER BY pr.created_at DESC, pr.id DESC
@@ -116,13 +128,15 @@ func (s *MediaService) listArticlePublishRecords(ctx context.Context, tenantID,
items := make([]PublishRecordResponse, 0) items := make([]PublishRecordResponse, 0)
for rows.Next() { for rows.Next() {
var item PublishRecordResponse var item PublishRecordResponse
var desktopAccountID string var desktopAccountID *string
if err := rows.Scan( if err := rows.Scan(
&item.ID, &item.ID,
&item.PublishBatchID, &item.PublishBatchID,
&item.ArticleID, &item.ArticleID,
&item.PlatformAccountID, &item.PlatformAccountID,
&desktopAccountID, &desktopAccountID,
&item.TargetType,
&item.TargetConnectionID,
&item.PlatformID, &item.PlatformID,
&item.PlatformName, &item.PlatformName,
&item.PlatformNickname, &item.PlatformNickname,
@@ -137,8 +151,8 @@ func (s *MediaService) listArticlePublishRecords(ctx context.Context, tenantID,
); err != nil { ); err != nil {
return nil, response.ErrInternal(50043, "publish_record_scan_failed", err.Error()) return nil, response.ErrInternal(50043, "publish_record_scan_failed", err.Error())
} }
if strings.TrimSpace(desktopAccountID) != "" { if desktopAccountID != nil && strings.TrimSpace(*desktopAccountID) != "" {
item.DesktopAccountID = &desktopAccountID item.DesktopAccountID = desktopAccountID
} }
normalizePublishRecordForResponse(&item) normalizePublishRecordForResponse(&item)
items = append(items, item) items = append(items, item)
@@ -0,0 +1,142 @@
package transport
import (
"strconv"
"github.com/gin-gonic/gin"
"github.com/geo-platform/tenant-api/internal/bootstrap"
"github.com/geo-platform/tenant-api/internal/shared/response"
"github.com/geo-platform/tenant-api/internal/tenant/app"
)
type EnterpriseSiteHandler struct {
svc *app.EnterpriseSiteService
}
func NewEnterpriseSiteHandler(a *bootstrap.App) *EnterpriseSiteHandler {
return &EnterpriseSiteHandler{
svc: app.NewEnterpriseSiteService(a.DB, a.ConfigStore).
WithCache(a.Cache).
WithObjectStorage(a.ObjectStorage),
}
}
func (h *EnterpriseSiteHandler) List(c *gin.Context) {
data, err := h.svc.List(c.Request.Context())
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *EnterpriseSiteHandler) Create(c *gin.Context) {
var req app.CreateEnterpriseSiteConnectionRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
return
}
data, err := h.svc.Create(c.Request.Context(), req)
if err != nil {
response.Error(c, err)
return
}
response.SuccessWithStatus(c, 201, data)
}
func (h *EnterpriseSiteHandler) Update(c *gin.Context) {
id, ok := enterpriseSiteIDParam(c)
if !ok {
return
}
var req app.UpdateEnterpriseSiteConnectionRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
return
}
data, err := h.svc.Update(c.Request.Context(), id, req)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *EnterpriseSiteHandler) Delete(c *gin.Context) {
id, ok := enterpriseSiteIDParam(c)
if !ok {
return
}
if err := h.svc.Delete(c.Request.Context(), id); err != nil {
response.Error(c, err)
return
}
response.Success(c, nil)
}
func (h *EnterpriseSiteHandler) Ping(c *gin.Context) {
id, ok := enterpriseSiteIDParam(c)
if !ok {
return
}
data, err := h.svc.Ping(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *EnterpriseSiteHandler) Categories(c *gin.Context) {
id, ok := enterpriseSiteIDParam(c)
if !ok {
return
}
data, err := h.svc.Categories(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *EnterpriseSiteHandler) SyncCategories(c *gin.Context) {
id, ok := enterpriseSiteIDParam(c)
if !ok {
return
}
data, err := h.svc.SyncCategories(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *EnterpriseSiteHandler) PublishArticle(c *gin.Context) {
id, ok := enterpriseSiteIDParam(c)
if !ok {
return
}
var req app.PublishEnterpriseSiteArticleRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
return
}
data, err := h.svc.PublishArticle(c.Request.Context(), id, req)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func enterpriseSiteIDParam(c *gin.Context) (int64, bool) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || id <= 0 {
response.Error(c, response.ErrBadRequest(40001, "invalid_id", "enterprise site id must be a positive number"))
return 0, false
}
return id, true
}
@@ -104,6 +104,17 @@ func RegisterRoutes(app *bootstrap.App) {
media := tenantProtected.Group("/media") media := tenantProtected.Group("/media")
media.GET("/platforms", mediaHandler.ListPlatforms) media.GET("/platforms", mediaHandler.ListPlatforms)
enterpriseSiteHandler := NewEnterpriseSiteHandler(app)
enterpriseSites := tenantProtected.Group("/enterprise-sites")
enterpriseSites.GET("", enterpriseSiteHandler.List)
enterpriseSites.POST("", enterpriseSiteHandler.Create)
enterpriseSites.PATCH("/:id", enterpriseSiteHandler.Update)
enterpriseSites.DELETE("/:id", enterpriseSiteHandler.Delete)
enterpriseSites.POST("/:id/ping", enterpriseSiteHandler.Ping)
enterpriseSites.GET("/:id/categories", enterpriseSiteHandler.Categories)
enterpriseSites.POST("/:id/categories/sync", enterpriseSiteHandler.SyncCategories)
enterpriseSites.POST("/:id/publish", middleware.RequireCurrentBrand(), enterpriseSiteHandler.PublishArticle)
desktopClient := tenantProtected.Group("/desktop-client") desktopClient := tenantProtected.Group("/desktop-client")
desktopClient.GET("/releases/downloadable", desktopReleaseHandler.ListDownloadable) desktopClient.GET("/releases/downloadable", desktopReleaseHandler.ListDownloadable)
@@ -0,0 +1,20 @@
DROP INDEX IF EXISTS idx_publish_records_enterprise_site_active;
DROP INDEX IF EXISTS uk_publish_records_batch_enterprise_site;
DELETE FROM publish_records
WHERE target_type = 'enterprise_site';
ALTER TABLE publish_records
DROP CONSTRAINT IF EXISTS ck_publish_records_target_ref,
DROP CONSTRAINT IF EXISTS ck_publish_records_target_type,
DROP COLUMN IF EXISTS target_connection_id,
DROP COLUMN IF EXISTS target_type;
ALTER TABLE publish_records
ALTER COLUMN platform_account_id SET NOT NULL;
DELETE FROM media_platforms
WHERE platform_id = 'pbootcms';
DROP TABLE IF EXISTS enterprise_site_categories;
DROP TABLE IF EXISTS enterprise_site_connections;
@@ -0,0 +1,96 @@
CREATE TABLE enterprise_site_connections (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
workspace_id BIGINT NOT NULL REFERENCES workspaces(id),
created_by_user_id BIGINT NOT NULL REFERENCES users(id),
name VARCHAR(160) NOT NULL,
site_url TEXT NOT NULL,
cms_type VARCHAR(32) NOT NULL,
auth_type VARCHAR(32) NOT NULL DEFAULT 'native_api',
appid VARCHAR(160) NOT NULL,
credential_ciphertext TEXT NOT NULL,
default_acode VARCHAR(32),
default_mcode VARCHAR(32),
default_scode VARCHAR(64),
status VARCHAR(20) NOT NULL DEFAULT 'draft',
plugin_version VARCHAR(40),
site_name VARCHAR(160),
last_ping_at TIMESTAMPTZ,
last_sync_at TIMESTAMPTZ,
last_publish_at TIMESTAMPTZ,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
CONSTRAINT ck_enterprise_site_connections_cms_type
CHECK (cms_type IN ('pbootcms', 'wordpress', 'dedecms', 'phpcms')),
CONSTRAINT ck_enterprise_site_connections_status
CHECK (status IN ('draft', 'connected', 'error', 'disabled'))
);
CREATE UNIQUE INDEX uk_enterprise_site_connections_active_site
ON enterprise_site_connections (tenant_id, workspace_id, lower(site_url), cms_type)
WHERE deleted_at IS NULL;
CREATE INDEX idx_enterprise_site_connections_tenant_workspace
ON enterprise_site_connections (tenant_id, workspace_id, status, created_at DESC)
WHERE deleted_at IS NULL;
CREATE TABLE enterprise_site_categories (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
workspace_id BIGINT NOT NULL REFERENCES workspaces(id),
connection_id BIGINT NOT NULL REFERENCES enterprise_site_connections(id) ON DELETE CASCADE,
remote_id VARCHAR(128) NOT NULL,
parent_remote_id VARCHAR(128),
name VARCHAR(160) NOT NULL,
slug VARCHAR(160),
raw_payload_json JSONB,
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uk_enterprise_site_categories_remote
UNIQUE (connection_id, remote_id)
);
CREATE INDEX idx_enterprise_site_categories_connection
ON enterprise_site_categories (connection_id, remote_id);
ALTER TABLE publish_records
ALTER COLUMN platform_account_id DROP NOT NULL;
ALTER TABLE publish_records
ADD COLUMN target_type VARCHAR(32) NOT NULL DEFAULT 'platform_account',
ADD COLUMN target_connection_id BIGINT REFERENCES enterprise_site_connections(id);
ALTER TABLE publish_records
ADD CONSTRAINT ck_publish_records_target_type
CHECK (target_type IN ('platform_account', 'enterprise_site'));
ALTER TABLE publish_records
ADD CONSTRAINT ck_publish_records_target_ref
CHECK (
(target_type = 'platform_account' AND platform_account_id IS NOT NULL AND target_connection_id IS NULL)
OR
(target_type = 'enterprise_site' AND platform_account_id IS NULL AND target_connection_id IS NOT NULL)
);
CREATE UNIQUE INDEX uk_publish_records_batch_enterprise_site
ON publish_records (publish_batch_id, target_connection_id)
WHERE target_type = 'enterprise_site';
CREATE INDEX idx_publish_records_enterprise_site_active
ON publish_records (tenant_id, article_id, target_connection_id, updated_at DESC, id DESC)
WHERE target_type = 'enterprise_site';
INSERT INTO media_platforms (platform_id, name, category, short_name, accent_color, login_url, logo_url, status, sort_order)
VALUES ('pbootcms', 'PBootCMS', 'enterprise', 'P', '#1d4ed8', NULL, NULL, 'active', 900)
ON CONFLICT (platform_id)
DO UPDATE SET
name = EXCLUDED.name,
category = EXCLUDED.category,
short_name = EXCLUDED.short_name,
accent_color = EXCLUDED.accent_color,
status = EXCLUDED.status,
sort_order = EXCLUDED.sort_order,
updated_at = NOW();