feat(enterprise-site): add WordPress support and scheduled auto-publish to sites
Frontend CI / Frontend (push) Successful in 4m17s
Backend CI / Backend (push) Failing after 6m42s

Add WordPress as an enterprise-site CMS type alongside pbootcms, and let
schedule tasks auto-publish generated articles to enterprise sites.

- WordPress connection/publisher service and tests; register wordpress
  media platform and relax cms_type CHECK constraint
- New publish_enterprise_site_targets JSONB column on schedule_tasks with
  array type constraint; thread targets through scheduler dispatch,
  prompt/KOL generation, and worker
- Admin UI: enterprise-site targets in generate/schedule/publish flows,
  KOL package form, MediaView, and i18n strings

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 19:44:24 +08:00
parent 41f5623791
commit c7bad83496
30 changed files with 2946 additions and 696 deletions
@@ -6,7 +6,10 @@ import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/netip"
"net/url"
"regexp"
"strconv"
@@ -45,11 +48,9 @@ type EnterpriseSiteService struct {
func NewEnterpriseSiteService(pool *pgxpool.Pool, cfg config.Provider) *EnterpriseSiteService {
return &EnterpriseSiteService{
pool: pool,
cfg: cfg,
httpClient: &http.Client{
Timeout: 25 * time.Second,
},
pool: pool,
cfg: cfg,
httpClient: newEnterpriseSiteHTTPClient(cfg),
compliance: tenantcompliance.NewService(pool, cfg, nil),
}
}
@@ -344,6 +345,9 @@ func (s *EnterpriseSiteService) Create(ctx context.Context, req CreateEnterprise
if err != nil {
return nil, err
}
if err := validateEnterpriseSiteCMSURL(normalized.SiteURL, normalized.CMSType); err != nil {
return nil, err
}
ciphertext, err := encryptEnterpriseSiteCredential(normalized.Secret, s.credentialKeyMaterial())
if err != nil {
return nil, response.ErrInternal(50221, "enterprise_site_secret_encrypt_failed", "企业站点凭证保存失败")
@@ -465,6 +469,9 @@ func (s *EnterpriseSiteService) Update(ctx context.Context, id int64, req Update
if !validEnterpriseSiteStatus(next.Status) {
next.Status = "draft"
}
if err := validateEnterpriseSiteCMSURL(next.SiteURL, next.CMSType); err != nil {
return nil, err
}
if !strings.EqualFold(next.SiteURL, current.SiteURL) {
if err := s.ensureEnterpriseSiteURLAvailable(ctx, actor.TenantID, workspaceID, id, next.SiteURL, next.CMSType); err != nil {
@@ -1361,8 +1368,10 @@ func (s *EnterpriseSiteService) publisherFor(cmsType string) cmsPublisher {
switch strings.TrimSpace(cmsType) {
case pbootCMSPlatformID:
return newPBootCMSPublisher(s.httpClient)
case wordpressPlatformID:
return newWordPressPublisher(s.httpClient)
default:
return newPBootCMSPublisher(s.httpClient)
return unsupportedCMSPublisher{cmsType: cmsType}
}
}
@@ -1514,7 +1523,7 @@ func normalizeCreateEnterpriseSiteConnectionRequest(req CreateEnterpriseSiteConn
return normalizedEnterpriseSiteConnectionRequest{}, err
}
cmsType := normalizeEnterpriseCMSType(req.CMSType)
if cmsType != pbootCMSPlatformID {
if !validEnterpriseCMSType(cmsType) {
return normalizedEnterpriseSiteConnectionRequest{}, response.ErrBadRequest(40040, "enterprise_site_cms_not_supported", "当前版本暂不支持该 CMS 类型")
}
appid := strings.TrimSpace(req.AppID)
@@ -1562,6 +1571,13 @@ func normalizeEnterpriseSiteURL(value string) (string, error) {
if scheme != "https" && scheme != "http" {
return "", response.ErrBadRequest(40041, "invalid_enterprise_site_url", "站点域名必须使用 http 或 https")
}
host := strings.TrimSpace(parsed.Hostname())
if host == "" {
return "", response.ErrBadRequest(40041, "invalid_enterprise_site_url", "站点域名格式不正确")
}
if isEnterpriseSiteBlockedHost(host) {
return "", response.ErrBadRequest(40041, "invalid_enterprise_site_url", "站点域名不可使用内网或系统保留地址")
}
parsed.Scheme = scheme
parsed.Path = strings.TrimRight(parsed.Path, "/")
parsed.RawQuery = ""
@@ -1569,10 +1585,130 @@ func normalizeEnterpriseSiteURL(value string) (string, error) {
return strings.TrimRight(parsed.String(), "/"), nil
}
func validateEnterpriseSiteCMSURL(siteURL, cmsType string) error {
parsed, err := url.Parse(strings.TrimSpace(siteURL))
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return response.ErrBadRequest(40041, "invalid_enterprise_site_url", "站点域名格式不正确")
}
if strings.EqualFold(strings.TrimSpace(cmsType), wordpressPlatformID) &&
strings.EqualFold(parsed.Scheme, "http") &&
!isEnterpriseSiteLoopbackHost(parsed.Hostname()) {
return response.ErrBadRequest(40041, "wordpress_https_required", "WordPress 站点使用 Application Password 时必须使用 HTTPS")
}
return nil
}
func isEnterpriseSiteBlockedHost(host string) bool {
host = strings.Trim(strings.ToLower(strings.TrimSpace(host)), "[]")
if host == "" {
return true
}
if isEnterpriseSiteLoopbackHost(host) || host == "localhost" {
return false
}
if addr, err := netip.ParseAddr(host); err == nil {
return isEnterpriseSiteBlockedAddr(addr)
}
return false
}
func isEnterpriseSiteLoopbackHost(host string) bool {
host = strings.Trim(strings.ToLower(strings.TrimSpace(host)), "[]")
if host == "localhost" {
return true
}
addr, err := netip.ParseAddr(host)
return err == nil && addr.IsLoopback()
}
func isEnterpriseSiteBlockedAddr(addr netip.Addr) bool {
if !addr.IsValid() {
return true
}
if addr.Is4In6() {
addr = addr.Unmap()
}
if addr.IsLoopback() {
return false
}
if addr.IsPrivate() ||
addr.IsLinkLocalUnicast() ||
addr.IsLinkLocalMulticast() ||
addr.IsMulticast() ||
addr.IsUnspecified() ||
addr.IsInterfaceLocalMulticast() {
return true
}
return enterpriseSiteBlockedAddrPrefix(addr)
}
func enterpriseSiteBlockedAddrPrefix(addr netip.Addr) bool {
blockedPrefixes := []netip.Prefix{
netip.MustParsePrefix("169.254.0.0/16"),
netip.MustParsePrefix("100.64.0.0/10"),
netip.MustParsePrefix("192.0.0.0/24"),
netip.MustParsePrefix("192.0.2.0/24"),
netip.MustParsePrefix("198.18.0.0/15"),
netip.MustParsePrefix("198.51.100.0/24"),
netip.MustParsePrefix("203.0.113.0/24"),
netip.MustParsePrefix("::/128"),
netip.MustParsePrefix("::1/128"),
netip.MustParsePrefix("fc00::/7"),
netip.MustParsePrefix("fe80::/10"),
netip.MustParsePrefix("2002::/16"),
netip.MustParsePrefix("ff00::/8"),
}
for _, prefix := range blockedPrefixes {
if prefix.Contains(addr) {
return true
}
}
return false
}
func newEnterpriseSiteHTTPClient(config.Provider) *http.Client {
transport := http.DefaultTransport.(*http.Transport).Clone()
dialer := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
host, _, err := net.SplitHostPort(address)
if err != nil {
host = address
}
if isEnterpriseSiteBlockedHost(host) {
return nil, fmt.Errorf("enterprise site target host is blocked")
}
conn, err := dialer.DialContext(ctx, network, address)
if err != nil {
return nil, err
}
remoteAddr := conn.RemoteAddr()
if tcpAddr, ok := remoteAddr.(*net.TCPAddr); ok {
if addr, ok := netip.AddrFromSlice(tcpAddr.IP); ok && isEnterpriseSiteBlockedAddr(addr) {
_ = conn.Close()
return nil, fmt.Errorf("enterprise site target address is blocked")
}
}
return conn, nil
}
return &http.Client{
Timeout: 25 * time.Second,
Transport: transport,
}
}
func normalizeEnterpriseCMSType(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func validEnterpriseCMSType(cmsType string) bool {
switch strings.TrimSpace(cmsType) {
case pbootCMSPlatformID, wordpressPlatformID:
return true
default:
return false
}
}
func normalizeOptionalEnterpriseCodePtr(value *string) *string {
if value == nil {
return nil
@@ -2486,6 +2622,27 @@ func sanitizeEnterpriseSiteError(err error) string {
if strings.HasPrefix(lower, "pbootcms ") && strings.Contains(lower, " endpoint is unavailable") {
return "CMS 接口不可用,请确认接口安装路径"
}
if strings.HasPrefix(lower, "request wordpress ") {
return "WordPress 请求失败,请检查站点地址和网络连通性"
}
if strings.HasPrefix(lower, "parse wordpress response") {
return "WordPress 响应解析失败,请确认 REST API 返回 JSON 格式"
}
if strings.Contains(lower, "rest_not_logged_in") || strings.Contains(lower, "incorrect_password") || strings.Contains(lower, "invalid_username") {
return "WordPress 认证失败,请使用 WordPress 用户名和 Application Password"
}
if strings.HasPrefix(lower, "invalid wordpress site url") {
return "站点域名格式不正确"
}
if strings.Contains(lower, "invalid wordpress category id") {
return "WordPress 栏目 ID 不正确,请先同步栏目后再发布"
}
if strings.HasPrefix(lower, "wordpress ") && strings.Contains(lower, " returned http ") {
return "WordPress REST API 请求失败,请检查站点权限和接口状态"
}
if strings.Contains(lower, "unsupported cms type") {
return "当前版本暂不支持该 CMS 类型"
}
if len([]rune(message)) > 300 {
runes := []rune(message)
return string(runes[:300])