fb166fa9fe
Persist host/registrable_domain/site_key on monitoring article URL aliases, populate them on desktop-publish completion, and use them in the citation pipeline to count how often SaaS-published content appears as a citation source. Expose a /citation-summary endpoint with a 7/30-day window switch and surface the new source-share metrics in the tracking dashboard. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
535 lines
17 KiB
Go
535 lines
17 KiB
Go
package app
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestDecideHeartbeatPrimaryLease(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
state heartbeatPrimaryLeaseState
|
|
want heartbeatPrimaryLeaseAction
|
|
}{
|
|
{
|
|
name: "current primary fast path",
|
|
state: heartbeatPrimaryLeaseState{
|
|
Found: true,
|
|
CurrentIsPrimary: true,
|
|
LeaseLive: true,
|
|
PrimaryAvailable: true,
|
|
},
|
|
want: heartbeatPrimaryLeaseActionPrimaryHit,
|
|
},
|
|
{
|
|
name: "current primary renews when due",
|
|
state: heartbeatPrimaryLeaseState{
|
|
Found: true,
|
|
CurrentIsPrimary: true,
|
|
LeaseLive: true,
|
|
RenewDue: true,
|
|
PrimaryAvailable: true,
|
|
},
|
|
want: heartbeatPrimaryLeaseActionRenew,
|
|
},
|
|
{
|
|
name: "non primary does not steal live lease",
|
|
state: heartbeatPrimaryLeaseState{
|
|
Found: true,
|
|
CurrentIsPrimary: false,
|
|
LeaseLive: true,
|
|
PrimaryAvailable: true,
|
|
},
|
|
want: heartbeatPrimaryLeaseActionNonPrimaryLive,
|
|
},
|
|
{
|
|
name: "expired lease can be claimed",
|
|
state: heartbeatPrimaryLeaseState{
|
|
Found: true,
|
|
CurrentIsPrimary: false,
|
|
LeaseLive: false,
|
|
PrimaryAvailable: true,
|
|
},
|
|
want: heartbeatPrimaryLeaseActionClaimExpired,
|
|
},
|
|
{
|
|
name: "revoked primary can be replaced",
|
|
state: heartbeatPrimaryLeaseState{
|
|
Found: true,
|
|
CurrentIsPrimary: false,
|
|
LeaseLive: true,
|
|
PrimaryAvailable: false,
|
|
},
|
|
want: heartbeatPrimaryLeaseActionClaimUnavailable,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := decideHeartbeatPrimaryLease(tt.state)
|
|
if got.Action != tt.want {
|
|
t.Fatalf("decideHeartbeatPrimaryLease() = %v, want %v", got.Action, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMonitoringHeartbeatPrimaryLeaseMetrics(t *testing.T) {
|
|
resetMonitoringHeartbeatPrimaryLeaseMetricsForTest()
|
|
t.Cleanup(resetMonitoringHeartbeatPrimaryLeaseMetricsForTest)
|
|
|
|
recordHeartbeatPrimaryLeaseMetric("primary_hit", 20*time.Millisecond)
|
|
recordHeartbeatPrimaryLeaseMetric("contention", 10*time.Millisecond)
|
|
recordHeartbeatPrimaryLeaseWriteMetric("renew")
|
|
|
|
snapshot := MonitoringHeartbeatPrimaryLeaseMetricsSnapshotValue()
|
|
if snapshot.PrimaryHitTotal != 1 {
|
|
t.Fatalf("PrimaryHitTotal = %d, want 1", snapshot.PrimaryHitTotal)
|
|
}
|
|
if snapshot.ContentionTotal != 1 {
|
|
t.Fatalf("ContentionTotal = %d, want 1", snapshot.ContentionTotal)
|
|
}
|
|
if snapshot.DBRenewTotal != 1 {
|
|
t.Fatalf("DBRenewTotal = %d, want 1", snapshot.DBRenewTotal)
|
|
}
|
|
if snapshot.DurationCount != 2 {
|
|
t.Fatalf("DurationCount = %d, want 2", snapshot.DurationCount)
|
|
}
|
|
|
|
prometheus := MonitoringHeartbeatPrimaryLeaseMetricsPrometheus()
|
|
if !strings.Contains(prometheus, `monitoring_heartbeat_primary_lease_resolutions_total{outcome="primary_hit"} 1`) {
|
|
t.Fatalf("prometheus output missing primary_hit metric: %s", prometheus)
|
|
}
|
|
if !strings.Contains(prometheus, `monitoring_heartbeat_primary_lease_db_writes_total{operation="renew"} 1`) {
|
|
t.Fatalf("prometheus output missing renew write metric: %s", prometheus)
|
|
}
|
|
}
|
|
|
|
func TestDecideMonitoringHeartbeatSnapshot(t *testing.T) {
|
|
now := time.Date(2026, 4, 24, 3, 0, 0, 0, time.UTC)
|
|
base := monitoringPlatformAccessSnapshotState{
|
|
AccessStatus: "accessible",
|
|
Connected: true,
|
|
Accessible: true,
|
|
UpdatedAt: now.Add(-time.Minute),
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
previous *monitoringPlatformAccessSnapshotState
|
|
next monitoringPlatformAccessSnapshotState
|
|
primary bool
|
|
want monitoringHeartbeatSnapshotDecision
|
|
}{
|
|
{
|
|
name: "missing snapshot inserts",
|
|
next: base,
|
|
primary: true,
|
|
want: monitoringHeartbeatSnapshotDecision{
|
|
WriteSnapshot: true,
|
|
StateChanged: true,
|
|
SnapshotOperation: "insert",
|
|
},
|
|
},
|
|
{
|
|
name: "fresh unchanged accessible snapshot skips write",
|
|
previous: &base,
|
|
next: base,
|
|
primary: true,
|
|
want: monitoringHeartbeatSnapshotDecision{
|
|
SnapshotOperation: "skip",
|
|
},
|
|
},
|
|
{
|
|
name: "stale unchanged snapshot refreshes",
|
|
previous: &monitoringPlatformAccessSnapshotState{
|
|
AccessStatus: "accessible",
|
|
Connected: true,
|
|
Accessible: true,
|
|
UpdatedAt: now.Add(-monitoringHeartbeatAccessSnapshotRefreshAfter - time.Second),
|
|
},
|
|
next: base,
|
|
primary: true,
|
|
want: monitoringHeartbeatSnapshotDecision{
|
|
WriteSnapshot: true,
|
|
RefreshDue: true,
|
|
SnapshotOperation: "refresh",
|
|
},
|
|
},
|
|
{
|
|
name: "changed snapshot updates",
|
|
previous: &base,
|
|
next: monitoringPlatformAccessSnapshotState{
|
|
AccessStatus: "unavailable",
|
|
Connected: true,
|
|
Accessible: false,
|
|
},
|
|
primary: true,
|
|
want: monitoringHeartbeatSnapshotDecision{
|
|
WriteSnapshot: true,
|
|
StateChanged: true,
|
|
PendingReconcileCandidate: true,
|
|
SnapshotOperation: "update",
|
|
},
|
|
},
|
|
{
|
|
name: "fresh unchanged primary inaccessible snapshot checks pending tasks without writing snapshot",
|
|
previous: &monitoringPlatformAccessSnapshotState{
|
|
AccessStatus: "not_logged_in",
|
|
Connected: false,
|
|
Accessible: false,
|
|
UpdatedAt: now.Add(-time.Minute),
|
|
},
|
|
next: monitoringPlatformAccessSnapshotState{
|
|
AccessStatus: "not_logged_in",
|
|
Connected: false,
|
|
Accessible: false,
|
|
},
|
|
primary: true,
|
|
want: monitoringHeartbeatSnapshotDecision{
|
|
PendingReconcileCandidate: true,
|
|
SnapshotOperation: "skip",
|
|
},
|
|
},
|
|
{
|
|
name: "non-primary inaccessible snapshot does not reconcile",
|
|
previous: &monitoringPlatformAccessSnapshotState{
|
|
AccessStatus: "not_logged_in",
|
|
Connected: false,
|
|
Accessible: false,
|
|
UpdatedAt: now.Add(-time.Minute),
|
|
},
|
|
next: monitoringPlatformAccessSnapshotState{
|
|
AccessStatus: "not_logged_in",
|
|
Connected: false,
|
|
Accessible: false,
|
|
},
|
|
primary: false,
|
|
want: monitoringHeartbeatSnapshotDecision{
|
|
SnapshotOperation: "skip",
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := decideMonitoringHeartbeatSnapshot(tt.previous, tt.next, now, tt.primary)
|
|
if got.WriteSnapshot != tt.want.WriteSnapshot ||
|
|
got.StateChanged != tt.want.StateChanged ||
|
|
got.RefreshDue != tt.want.RefreshDue ||
|
|
got.PendingReconcileCandidate != tt.want.PendingReconcileCandidate ||
|
|
got.ReconcilePending != tt.want.ReconcilePending ||
|
|
got.SnapshotOperation != tt.want.SnapshotOperation {
|
|
t.Fatalf("decideMonitoringHeartbeatSnapshot() = %+v, want %+v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMonitoringHeartbeatSnapshotMetrics(t *testing.T) {
|
|
resetMonitoringHeartbeatSnapshotMetricsForTest()
|
|
t.Cleanup(resetMonitoringHeartbeatSnapshotMetricsForTest)
|
|
|
|
recordMonitoringHeartbeatSnapshotMetrics(monitoringHeartbeatSnapshotMetricsEvent{
|
|
PlatformCount: 3,
|
|
TransactionOpened: true,
|
|
SnapshotInsertedCount: 1,
|
|
SnapshotSkippedCount: 2,
|
|
ReconciledSkippedTaskCount: 5,
|
|
ProjectionRebuildCount: 2,
|
|
Duration: 20 * time.Millisecond,
|
|
})
|
|
recordMonitoringHeartbeatSnapshotMetrics(monitoringHeartbeatSnapshotMetricsEvent{
|
|
PlatformCount: 1,
|
|
Duration: 10 * time.Millisecond,
|
|
Err: errors.New("snapshot failed"),
|
|
})
|
|
|
|
snapshot := MonitoringHeartbeatSnapshotMetricsSnapshotValue()
|
|
if snapshot.PlatformReportedTotal != 4 {
|
|
t.Fatalf("PlatformReportedTotal = %d, want 4", snapshot.PlatformReportedTotal)
|
|
}
|
|
if snapshot.TransactionOpenedTotal != 1 {
|
|
t.Fatalf("TransactionOpenedTotal = %d, want 1", snapshot.TransactionOpenedTotal)
|
|
}
|
|
if snapshot.SnapshotInsertedTotal != 1 {
|
|
t.Fatalf("SnapshotInsertedTotal = %d, want 1", snapshot.SnapshotInsertedTotal)
|
|
}
|
|
if snapshot.SnapshotSkippedTotal != 2 {
|
|
t.Fatalf("SnapshotSkippedTotal = %d, want 2", snapshot.SnapshotSkippedTotal)
|
|
}
|
|
if snapshot.ReconciledSkippedTaskTotal != 5 {
|
|
t.Fatalf("ReconciledSkippedTaskTotal = %d, want 5", snapshot.ReconciledSkippedTaskTotal)
|
|
}
|
|
if snapshot.ProjectionRebuildTotal != 2 {
|
|
t.Fatalf("ProjectionRebuildTotal = %d, want 2", snapshot.ProjectionRebuildTotal)
|
|
}
|
|
if snapshot.ErrorTotal != 1 {
|
|
t.Fatalf("ErrorTotal = %d, want 1", snapshot.ErrorTotal)
|
|
}
|
|
if snapshot.DurationCount != 2 {
|
|
t.Fatalf("DurationCount = %d, want 2", snapshot.DurationCount)
|
|
}
|
|
|
|
prometheus := MonitoringHeartbeatSnapshotMetricsPrometheus()
|
|
if !strings.Contains(prometheus, `monitoring_heartbeat_snapshot_writes_total{operation="insert"} 1`) {
|
|
t.Fatalf("prometheus output missing insert write metric: %s", prometheus)
|
|
}
|
|
if !strings.Contains(prometheus, "monitoring_heartbeat_snapshot_transactions_total 1") {
|
|
t.Fatalf("prometheus output missing transaction metric: %s", prometheus)
|
|
}
|
|
}
|
|
|
|
func TestBuildMonitoringRawPayloadKimiIncludesSearchResultsInCitationInputs(t *testing.T) {
|
|
citationTitle := "真实引用文章"
|
|
searchTitle := "搜索网页结果"
|
|
|
|
req := MonitoringTaskResultRequest{
|
|
Status: "succeeded",
|
|
Citations: []MonitoringSourceItem{
|
|
{
|
|
URL: "https://example.com/citation",
|
|
Title: &citationTitle,
|
|
},
|
|
},
|
|
SearchResults: []MonitoringSourceItem{
|
|
{
|
|
URL: "https://example.com/search",
|
|
Title: &searchTitle,
|
|
},
|
|
},
|
|
}
|
|
|
|
rawPayload, sourceInputs := buildMonitoringRawPayload("kimi", req)
|
|
if len(sourceInputs) != 2 {
|
|
t.Fatalf("buildMonitoringRawPayload() sourceInputs len = %d, want 2", len(sourceInputs))
|
|
}
|
|
seen := make(map[string]bool)
|
|
for _, item := range sourceInputs {
|
|
seen[item.URL] = true
|
|
}
|
|
if !seen["https://example.com/citation"] {
|
|
t.Fatalf("buildMonitoringRawPayload() missing citation url in source inputs: %#v", sourceInputs)
|
|
}
|
|
if !seen["https://example.com/search"] {
|
|
t.Fatalf("buildMonitoringRawPayload() missing search url in source inputs: %#v", sourceInputs)
|
|
}
|
|
|
|
var payload map[string]interface{}
|
|
if err := json.Unmarshal(rawPayload, &payload); err != nil {
|
|
t.Fatalf("json.Unmarshal(rawPayload) error = %v", err)
|
|
}
|
|
if got := len(extractMonitoringSourceItems(payload["search_results"])); got != 1 {
|
|
t.Fatalf("stored search_results len = %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestValidateMonitoringTaskResultSubmissionRequiresAnswerOnSuccess(t *testing.T) {
|
|
if err := validateMonitoringTaskResultSubmission("kimi", "failed", MonitoringTaskResultRequest{Status: "failed"}); err != nil {
|
|
t.Fatalf("validateMonitoringTaskResultSubmission() failed status error = %v, want nil", err)
|
|
}
|
|
|
|
answer := "有效回答"
|
|
if err := validateMonitoringTaskResultSubmission("kimi", "succeeded", MonitoringTaskResultRequest{Status: "succeeded", Answer: &answer}); err != nil {
|
|
t.Fatalf("validateMonitoringTaskResultSubmission() succeeded status with answer error = %v, want nil", err)
|
|
}
|
|
|
|
if err := validateMonitoringTaskResultSubmission("kimi", "succeeded", MonitoringTaskResultRequest{Status: "succeeded"}); err == nil {
|
|
t.Fatal("validateMonitoringTaskResultSubmission() succeeded status without answer error = nil, want error")
|
|
}
|
|
}
|
|
|
|
func TestValidateMonitoringTaskResultSubmissionRequiresSourcesForCitationMarkers(t *testing.T) {
|
|
answer := "正文 [citation:1]"
|
|
if err := validateMonitoringTaskResultSubmission("yuanbao", "succeeded", MonitoringTaskResultRequest{
|
|
Status: "succeeded",
|
|
Answer: &answer,
|
|
}); err == nil {
|
|
t.Fatal("validateMonitoringTaskResultSubmission() citation marker without sources error = nil, want error")
|
|
}
|
|
|
|
title := "引用文章"
|
|
if err := validateMonitoringTaskResultSubmission("yuanbao", "succeeded", MonitoringTaskResultRequest{
|
|
Status: "succeeded",
|
|
Answer: &answer,
|
|
Citations: []MonitoringSourceItem{
|
|
{URL: "https://example.com/source", Title: &title},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("validateMonitoringTaskResultSubmission() citation marker with sources error = %v, want nil", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildMonitoringRawPayloadQwenIncludesSearchResultsInCitationInputs(t *testing.T) {
|
|
searchTitle := "搜索网页结果"
|
|
|
|
req := MonitoringTaskResultRequest{
|
|
Status: "succeeded",
|
|
SearchResults: []MonitoringSourceItem{
|
|
{
|
|
URL: "https://example.com/search",
|
|
Title: &searchTitle,
|
|
},
|
|
},
|
|
}
|
|
|
|
_, sourceInputs := buildMonitoringRawPayload("qwen", req)
|
|
if len(sourceInputs) != 1 {
|
|
t.Fatalf("buildMonitoringRawPayload() sourceInputs len = %d, want 1", len(sourceInputs))
|
|
}
|
|
for _, item := range sourceInputs {
|
|
if item.URL != "https://example.com/search" {
|
|
t.Fatalf("buildMonitoringRawPayload() source url = %q, want search url", item.URL)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildMonitoringCitationFactCanDisableContentResolution(t *testing.T) {
|
|
aliasArticleID := int64(42)
|
|
aliasPublishRecordID := int64(84)
|
|
normalizedURL := "https://example.com/article"
|
|
input := MonitoringSourceItem{
|
|
URL: "https://example.com/article?utm_source=test#section",
|
|
NormalizedURL: &normalizedURL,
|
|
}
|
|
aliasMap := map[string]monitoringAliasResolution{
|
|
normalizedURL: {
|
|
ArticleID: &aliasArticleID,
|
|
PublishRecordID: &aliasPublishRecordID,
|
|
},
|
|
}
|
|
|
|
resolvedFact, ok := buildMonitoringCitationFact(input, aliasMap, true)
|
|
if !ok {
|
|
t.Fatal("buildMonitoringCitationFact() with content resolution returned !ok")
|
|
}
|
|
if resolvedFact.ArticleID == nil || *resolvedFact.ArticleID != aliasArticleID {
|
|
t.Fatalf("resolved ArticleID = %v, want %d", resolvedFact.ArticleID, aliasArticleID)
|
|
}
|
|
if resolvedFact.PublishRecordID == nil || *resolvedFact.PublishRecordID != aliasPublishRecordID {
|
|
t.Fatalf("resolved PublishRecordID = %v, want %d", resolvedFact.PublishRecordID, aliasPublishRecordID)
|
|
}
|
|
|
|
sourceOnlyFact, ok := buildMonitoringCitationFact(input, aliasMap, false)
|
|
if !ok {
|
|
t.Fatal("buildMonitoringCitationFact() without content resolution returned !ok")
|
|
}
|
|
if sourceOnlyFact.ArticleID != nil {
|
|
t.Fatalf("source-only ArticleID = %v, want nil", sourceOnlyFact.ArticleID)
|
|
}
|
|
if sourceOnlyFact.PublishRecordID != nil {
|
|
t.Fatalf("source-only PublishRecordID = %v, want nil", sourceOnlyFact.PublishRecordID)
|
|
}
|
|
}
|
|
|
|
func TestScoreMonitoringAliasCandidateUsesDomainWithTitleEvidence(t *testing.T) {
|
|
lastPath := "article-42"
|
|
input := monitoringAliasLookupInput{
|
|
NormalizedURL: "https://m.example.com/article-42",
|
|
RegistrableDomain: "example.com",
|
|
LastPathSegment: &lastPath,
|
|
TitleKey: normalizeMonitoringAliasTitle("北京口腔医院全面测评"),
|
|
}
|
|
candidate := monitoringAliasCandidate{
|
|
NormalizedURL: "https://www.example.com/posts/article-42",
|
|
RegistrableDomain: "example.com",
|
|
LastPathSegment: "article-42",
|
|
TitleKey: normalizeMonitoringAliasTitle("北京口腔医院全面测评"),
|
|
}
|
|
|
|
if score := scoreMonitoringAliasCandidate(input, candidate); score < 90 {
|
|
t.Fatalf("scoreMonitoringAliasCandidate() = %d, want strong domain/title match", score)
|
|
}
|
|
}
|
|
|
|
func TestBuildMonitoringRawPayloadDeepSeekIncludesSearchResultsInCitationInputs(t *testing.T) {
|
|
searchTitle := "搜索网页结果"
|
|
|
|
req := MonitoringTaskResultRequest{
|
|
Status: "succeeded",
|
|
SearchResults: []MonitoringSourceItem{
|
|
{
|
|
URL: "https://example.com/search",
|
|
Title: &searchTitle,
|
|
},
|
|
},
|
|
}
|
|
|
|
_, sourceInputs := buildMonitoringRawPayload("deepseek", req)
|
|
if len(sourceInputs) != 1 {
|
|
t.Fatalf("buildMonitoringRawPayload() sourceInputs len = %d, want 1", len(sourceInputs))
|
|
}
|
|
for _, item := range sourceInputs {
|
|
if item.URL != "https://example.com/search" {
|
|
t.Fatalf("buildMonitoringRawPayload() source url = %q, want search url", item.URL)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExtractMonitoringInlineCitationsFromRawResponseKimi(t *testing.T) {
|
|
raw := []byte(`{
|
|
"inline_citation_candidates": [
|
|
{
|
|
"url": "https://example.com/a#ref",
|
|
"title": "文章 A",
|
|
"site_name": "示例站点"
|
|
},
|
|
{
|
|
"url": "https://example.com/a#other",
|
|
"title": "文章 A",
|
|
"site_name": "示例站点"
|
|
},
|
|
{
|
|
"url": "https://example.com/b",
|
|
"title": "文章 B",
|
|
"site_name": "另一个站点"
|
|
}
|
|
]
|
|
}`)
|
|
|
|
items := extractMonitoringInlineCitationsFromRawResponse(raw, "kimi")
|
|
if len(items) != 2 {
|
|
t.Fatalf("extractMonitoringInlineCitationsFromRawResponse() len = %d, want 2", len(items))
|
|
}
|
|
if items[0].URL != "https://example.com/a" {
|
|
t.Fatalf("extractMonitoringInlineCitationsFromRawResponse() first url = %q, want https://example.com/a", items[0].URL)
|
|
}
|
|
if items[1].URL != "https://example.com/b" {
|
|
t.Fatalf("extractMonitoringInlineCitationsFromRawResponse() second url = %q, want https://example.com/b", items[1].URL)
|
|
}
|
|
}
|
|
|
|
func TestExtractMonitoringInlineCitationsFromRawResponseDeepSeekFallsBackToInlineLinks(t *testing.T) {
|
|
raw := []byte(`{
|
|
"inline_links": [
|
|
{
|
|
"url": "https://example.com/a#cite-1",
|
|
"text": "-1",
|
|
"siteName": "示例站点"
|
|
},
|
|
{
|
|
"url": "https://example.com/b",
|
|
"title": "文章 B",
|
|
"siteName": "另一个站点"
|
|
}
|
|
]
|
|
}`)
|
|
|
|
items := extractMonitoringInlineCitationsFromRawResponse(raw, "deepseek")
|
|
if len(items) != 2 {
|
|
t.Fatalf("extractMonitoringInlineCitationsFromRawResponse() len = %d, want 2", len(items))
|
|
}
|
|
if items[0].URL != "https://example.com/a" {
|
|
t.Fatalf("extractMonitoringInlineCitationsFromRawResponse() first url = %q, want https://example.com/a", items[0].URL)
|
|
}
|
|
if items[0].SiteName == nil || *items[0].SiteName != "示例站点" {
|
|
t.Fatalf("extractMonitoringInlineCitationsFromRawResponse() first site = %v, want 示例站点", items[0].SiteName)
|
|
}
|
|
if items[1].Title == nil || *items[1].Title != "文章 B" {
|
|
t.Fatalf("extractMonitoringInlineCitationsFromRawResponse() second title = %v, want 文章 B", items[1].Title)
|
|
}
|
|
}
|