feat(enterprise-site): add PbootCMS enterprise site publisher
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:
@@ -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
@@ -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')
|
||||||
|
|||||||
+1343
-142
File diff suppressed because it is too large
Load Diff
@@ -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 完成前,前端可以先展示结构化空态和接入流程,但路由/术语要按最终模型命名,避免后续返工。
|
||||||
@@ -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×tamp=TIMESTAMP&signature=SIGNATURE"
|
||||||
|
```
|
||||||
|
|
||||||
|
如果站点 rewrite 已启用,也支持:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://www.example.com/api/GeoPublisher/ping?appid=APPID×tamp=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×tamp=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×tamp=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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
Reference in New Issue
Block a user