feat(enterprise-site): add WordPress support and scheduled auto-publish to sites
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:
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user