feat(publish): add baijiahao and jianshu desktop adapters

Wires up Baijiahao (百家号) and Jianshu (简书) as first-class desktop
publish targets, with risk-control prompts surfaced in the runtime
controller and a normalized error message in publish records. Adds
external-link buttons in the publish management table, an asset
format conversion endpoint for cover image compatibility, and
reorders publish-status display priority so failures take precedence
over partial successes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-26 19:47:35 +08:00
parent ae63f60def
commit 87e329207c
27 changed files with 2056 additions and 54 deletions
@@ -128,6 +128,7 @@ func (s *MediaService) listArticlePublishRecords(ctx context.Context, tenantID,
); err != nil {
return nil, response.ErrInternal(50043, "publish_record_scan_failed", err.Error())
}
normalizePublishRecordForResponse(&item)
items = append(items, item)
}
return items, nil
@@ -0,0 +1,76 @@
package app
import (
"fmt"
"net/url"
"strings"
)
func normalizePublishRecordForResponse(item *PublishRecordResponse) {
if item == nil {
return
}
item.ErrorMessage = normalizePublishRecordErrorMessage(item.PlatformID, item.ErrorMessage)
if strings.TrimSpace(item.PlatformID) != "baijiahao" {
return
}
articleID := ""
if item.ExternalArticleID != nil {
articleID = strings.TrimSpace(*item.ExternalArticleID)
}
if articleID == "" {
articleID = extractBaijiahaoArticleIDFromURL(item.ExternalArticleURL)
}
if articleID == "" {
articleID = extractBaijiahaoArticleIDFromURL(item.ExternalManageURL)
}
if articleID == "" {
return
}
publicURL := baijiahaoPublishedArticleURL(articleID)
item.ExternalArticleURL = &publicURL
}
func normalizePublishRecordErrorMessage(platform string, message *string) *string {
if strings.TrimSpace(platform) != "baijiahao" || message == nil {
return message
}
trimmed := strings.TrimSpace(*message)
if trimmed == "baijiahao_challenge_required" ||
trimmed == "desktop_account_risk_control" ||
trimmed == "desktop_account_challenge_required" ||
strings.HasPrefix(trimmed, "baijiahao_challenge_required:") {
normalized := baijiahaoRiskControlPrompt
return &normalized
}
return message
}
func baijiahaoPublishedArticleURL(articleID string) string {
return fmt.Sprintf("https://baijiahao.baidu.com/s?id=%s", url.QueryEscape(strings.TrimSpace(articleID)))
}
func extractBaijiahaoArticleIDFromURL(value *string) string {
if value == nil {
return ""
}
trimmed := strings.TrimSpace(*value)
if trimmed == "" {
return ""
}
parsed, err := url.Parse(trimmed)
if err != nil {
return ""
}
if id := strings.TrimSpace(parsed.Query().Get("id")); id != "" {
return id
}
if id := strings.TrimSpace(parsed.Query().Get("article_id")); id != "" {
return id
}
return ""
}
@@ -155,7 +155,7 @@ func syncDesktopPublishTaskState(
publishedAt,
nullableJSON(task.Payload),
nullableJSON(task.Result),
desktopTaskErrorMessage(errorPayload),
desktopTaskErrorMessage(errorPayload, task.Platform),
publishRecordID,
task.TenantID,
); err != nil {
@@ -411,7 +411,12 @@ func extractStringPointer(payload map[string]any, keys ...string) *string {
return nil
}
func desktopTaskErrorMessage(payload map[string]any) *string {
const baijiahaoRiskControlPrompt = "您触发平台风控,请手动发稿一篇并完成验证,方可继续使用"
func desktopTaskErrorMessage(payload map[string]any, platform string) *string {
if message := desktopTaskPlatformErrorMessage(payload, platform); message != nil {
return message
}
if message := extractStringPointer(payload, "message"); message != nil {
return message
}
@@ -423,3 +428,26 @@ func desktopTaskErrorMessage(payload map[string]any) *string {
}
return nil
}
func desktopTaskPlatformErrorMessage(payload map[string]any, platform string) *string {
if strings.TrimSpace(platform) != "baijiahao" {
return nil
}
for _, key := range []string{"code", "message", "detail"} {
value := extractStringPointer(payload, key)
if value == nil {
continue
}
trimmed := strings.TrimSpace(*value)
if trimmed == "baijiahao_challenge_required" ||
trimmed == "desktop_account_risk_control" ||
trimmed == "desktop_account_challenge_required" ||
strings.HasPrefix(trimmed, "baijiahao_challenge_required:") {
message := baijiahaoRiskControlPrompt
return &message
}
}
return nil
}
@@ -0,0 +1,65 @@
package app
import "testing"
func TestDesktopTaskErrorMessageMapsBaijiahaoRiskControl(t *testing.T) {
tests := []struct {
name string
payload map[string]any
}{
{
name: "adapter challenge code",
payload: map[string]any{
"code": "baijiahao_challenge_required",
"message": "baijiahao_challenge_required: 您所在网络环境异常,请完成验证",
},
},
{
name: "desktop account risk code",
payload: map[string]any{
"code": "desktop_account_risk_control",
"message": "desktop account risk control triggered",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := desktopTaskErrorMessage(tt.payload, "baijiahao")
if got == nil || *got != baijiahaoRiskControlPrompt {
t.Fatalf("desktopTaskErrorMessage() = %v, want %q", got, baijiahaoRiskControlPrompt)
}
})
}
}
func TestDesktopTaskErrorMessageKeepsGenericMessage(t *testing.T) {
got := desktopTaskErrorMessage(map[string]any{
"code": "baijiahao_upload_proxy_failed",
"message": "百家号素材上传接口拒绝了封面图。",
}, "baijiahao")
if got == nil || *got != "百家号素材上传接口拒绝了封面图。" {
t.Fatalf("desktopTaskErrorMessage() = %v", got)
}
}
func TestNormalizePublishRecordForResponseUsesBaijiahaoPublicURL(t *testing.T) {
articleID := "1863525130677225913"
editURL := "https://baijiahao.baidu.com/builder/rc/edit?type=news&article_id=1863525130677225913"
item := &PublishRecordResponse{
PlatformID: "baijiahao",
ExternalArticleID: &articleID,
ExternalArticleURL: &editURL,
ExternalManageURL: &editURL,
}
normalizePublishRecordForResponse(item)
const want = "https://baijiahao.baidu.com/s?id=1863525130677225913"
if item.ExternalArticleURL == nil || *item.ExternalArticleURL != want {
t.Fatalf("ExternalArticleURL = %v, want %q", item.ExternalArticleURL, want)
}
if item.ExternalManageURL == nil || *item.ExternalManageURL != editURL {
t.Fatalf("ExternalManageURL = %v, want original edit URL", item.ExternalManageURL)
}
}
@@ -1,14 +1,19 @@
package transport
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"fmt"
"image"
"image/jpeg"
"image/png"
"net/http"
"strings"
"github.com/gin-gonic/gin"
_ "golang.org/x/image/webp"
"github.com/geo-platform/tenant-api/internal/bootstrap"
)
@@ -43,6 +48,14 @@ func (h *AssetHandler) Serve(c *gin.Context) {
return
}
if converted, contentType, ok := convertPublicAssetFormat(content, c.Query("format")); ok {
c.Header("Cache-Control", "public, max-age=31536000, immutable")
c.Header("Content-Type", contentType)
c.Writer.WriteHeader(http.StatusOK)
_, _ = c.Writer.Write(converted)
return
}
contentType := http.DetectContentType(content)
c.Header("Cache-Control", "public, max-age=31536000, immutable")
c.Header("Content-Type", contentType)
@@ -50,6 +63,34 @@ func (h *AssetHandler) Serve(c *gin.Context) {
_, _ = c.Writer.Write(content)
}
func convertPublicAssetFormat(content []byte, format string) ([]byte, string, bool) {
normalized := strings.ToLower(strings.TrimSpace(format))
if normalized == "" {
return nil, "", false
}
if normalized != "png" && normalized != "jpg" && normalized != "jpeg" {
return nil, "", false
}
decoded, _, err := image.Decode(bytes.NewReader(content))
if err != nil {
return nil, "", false
}
var output bytes.Buffer
if normalized == "png" {
if err := png.Encode(&output, decoded); err != nil {
return nil, "", false
}
return output.Bytes(), "image/png", true
}
if err := jpeg.Encode(&output, decoded, &jpeg.Options{Quality: 92}); err != nil {
return nil, "", false
}
return output.Bytes(), "image/jpeg", true
}
func (h *AssetHandler) signObjectKey(objectKey string) string {
encodedKey := base64.RawURLEncoding.EncodeToString([]byte(objectKey))
mac := hmac.New(sha256.New, []byte(h.secret))