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:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user