// 接入方只需要填自己的业务数据,其余交给 SDK: // // - data_date 留空自动填当天 UTC 日期(回补历史数据时显式指定) // - company 留空自动填「瑞升」 // - role_id 留空自动补 [](不是 null) // - 发送前做必填校验和日期格式校验,问题一次全列出来 // - JSON 编码、带 API Key 的 POST、超时控制 // // 最小用法: // // client := datacenter.New(os.Getenv("STUDIO_API_KEY")) // // resp, err := client.Send(ctx, datacenter.Record{ // CompanyPlatform: "黄果短剧", // 必填:所属产品/项目名 // PublishedData: "01/09/2026", // 必填:视频上线时间 // ScriptName: "剧本A", // 必填:剧本名称 // DramaName: "短剧A", // 必填:短剧名称 // DramaNo: "第1集", // 必填:集数 // VideoURL: "https://cdn.x/a.mp4", // 必填:成片下载链接 // PlatformURL: "https://x.com/a", // 必填:产品内播放页链接 // // VideoID: "12345", // 选填但建议填 // Views: 10000, // Clicks: 8200, // Likes: 300, // }) // // 一次可以传多条:client.Send(ctx, r1, r2, r3)。 package datacenter import ( "bytes" "context" "encoding/json" "fmt" "io" "log" "net/http" "strings" "sync" "time" ) const ( // DefaultEndpoint 是数据回流接口地址,Client.Endpoint 留空时用它。 DefaultEndpoint = "https://ldschool.up.railway.app/api/v1/video-performance/daily" // DefaultCompany 是所属公司的默认值,接入方不用填。 DefaultCompany = "瑞升" // DateLayout 是接口要求的日期格式:DD/MM/YYYY,对应 Go 的 "02/01/2006"。 // 注意和 "2006-01-02" 的区别,写成后者会被本地校验拦下。 DateLayout = "02/01/2006" ) // Record 是一条视频在某一天的数据。一次请求可以带多条。 // // 字段分三类: // // 1. SDK 自动填:DataDate、Company、RoleID,接入方留空即可。 // 2. 必填:CompanyPlatform、PublishedData、ScriptName、DramaName、DramaNo、 // VideoURL、PlatformURL。留空会在本地就报错,不会白跑一次请求。 // 3. 选填:其余字段。带 omitempty 的字段留空就整个不出现在请求体里, // 不会用空字符串污染服务端数据。 type Record struct { // ---- 由 SDK 自动填,接入方留空即可 ---- // DataDate 数据所属日期,格式 DD/MM/YYYY。 // 留空 = 当天 UTC 日期;要回补历史数据就显式写,如 "21/09/2026"。 DataDate string `json:"data_date"` // Company 所属公司,留空 = 瑞升。 Company string `json:"company"` // RoleID 角色 ID 数组,留空会发送 [](而不是 null)。 RoleID []string `json:"role_id"` // ---- 必填 ---- // CompanyPlatform 所属项目/产品名称,如「黄果短剧」。 CompanyPlatform string `json:"company_platform"` // PublishedData 视频首次上线时间,格式 DD/MM/YYYY。 PublishedData string `json:"published_data"` // ScriptName 剧本名称,需与剧本 ID 对应一致。 ScriptName string `json:"script_name"` // DramaName 短剧名称,需与集数保持一致。 DramaName string `json:"drama_name"` // DramaNo 集数,如「第1集」。 DramaNo string `json:"drama_no"` // VideoURL 可访问的成片下载/存储链接。 VideoURL string `json:"video_url"` // PlatformURL 视频在产品内的播放页面链接。 PlatformURL string `json:"platform_url"` // ---- 选填 ---- // VideoID 视频唯一标识。选填,但建议填,便于中台对齐同一条视频的历史数据。 VideoID string `json:"video_id,omitempty"` // ScriptID 剧本唯一编号。 ScriptID string `json:"script_id,omitempty"` // PlatformVideoID 视频在产品平台内的唯一 ID。 PlatformVideoID string `json:"platform_video_id,omitempty"` // Views 浏览量:统计周期内的视频曝光/浏览次数。 Views int64 `json:"views"` // Clicks 播放量:统计周期内的有效播放次数。 Clicks int64 `json:"clicks"` // Likes 点赞量:统计周期内的累计点赞次数。 Likes int64 `json:"likes"` // LikeRate 点赞率,字符串带百分号,如 "3.66%"。 // 计算公式:点赞量 ÷ 播放量 × 100%,保留两位小数。 LikeRate string `json:"like_rate,omitempty"` // CompletionRate 完播率,如 "43.00%"。 // 没有单条视频数据时,可以填剧本整体的完播率。 CompletionRate string `json:"completion_rate,omitempty"` // PaidRate 付费率,如 "0.00%"。 // 计算公式:付费用户 ÷ 播放用户 × 100%;免费短剧统一填 "0.00%"。 PaidRate string `json:"paid_rate,omitempty"` // Comments 评论量。用指针是为了区分「没填」和「填了 0」: // 填 datacenter.Int(0) 会真的发出 0,留 nil 则整个字段省略。 Comments *int64 `json:"comments,omitempty"` // Shares 分享量,和 Comments 同理。 Shares *int64 `json:"shares,omitempty"` // CompletionRateScope 完播率口径,取值 video / script / unknown。 CompletionRateScope string `json:"completion_rate_scope,omitempty"` } // Int 返回指向 v 的指针,用来给 Comments / Shares 填 0。 // // Comments: datacenter.Int(0) // 发出 "comments": 0 // Comments: nil // 不发送 comments 字段 func Int(v int64) *int64 { return &v } // request 是接口的请求体,数据都包在 records 数组里。 type request struct { Records []Record `json:"records"` } // Response 是接口返回,方便直接打日志。 type Response struct { StatusCode int // HTTP 状态码 Body []byte // 响应体原文,最多读 1MB } // OK 表示接口返回 200。 func (r *Response) OK() bool { return r != nil && r.StatusCode == http.StatusOK } // ValidationError 表示本地校验没过。Problems 里逐条说明是第几条、缺什么, // 一次列全,不用改一个跑一次。 type ValidationError struct { Problems []string // 例:`records[0]: 缺少必填字段: script_name, video_url` } func (e *ValidationError) Error() string { return "datacenter: 数据校验失败:\n " + strings.Join(e.Problems, "\n ") } // Client 是回流客户端。创建一次就能一直复用,多个 goroutine 同时用也没问题。 type Client struct { APIKey string // 领取时展示的完整 Key,以 ld_ 开头;建议从环境变量读,别写进代码 Endpoint string // 接口地址,留空用 DefaultEndpoint HTTPClient *http.Client // 留空用 30 秒超时的默认 client // OnError 是异步上报失败时的回调,可选。不设置就打到 stderr。 // 校验失败、网络失败、队列满被丢弃,都会走这里。 // 回调在后台 goroutine 里执行,里面别做耗时操作。 OnError func(error) // QueueSize 是异步队列能缓存的批次数,留空用 DefaultQueueSize。 // 队列满了会丢弃新数据(不阻塞主流程)并回调 OnError。 QueueSize int // 以下为异步内部状态,接入方不用管。 mu sync.Mutex queue chan []Record done chan struct{} started bool closed bool } // DefaultQueueSize 是异步队列的默认长度(批次数)。 const DefaultQueueSize = 1000 // asyncTimeout 是后台单次请求的超时,比 HTTPClient 的 30 秒留一点余量。 const asyncTimeout = 60 * time.Second // New 用 API Key 创建客户端,地址和 HTTP client 都用默认值。 func New(apiKey string) *Client { return &Client{ APIKey: strings.TrimSpace(apiKey), Endpoint: DefaultEndpoint, HTTPClient: &http.Client{Timeout: 30 * time.Second}, QueueSize: DefaultQueueSize, } } // Send 上报一批数据:先补齐默认值(日期/公司/角色数组),再校验,最后整体 POST。 // // 校验不通过返回 *ValidationError(不发请求);服务端返回非 200 时同时返回 // Response 和 error,Response 里带着状态码和响应体,方便排查。 // // 只打印不发请求的话用 JSON()。 func (c *Client) Send(ctx context.Context, records ...Record) (*Response, error) { if c.APIKey == "" { return nil, fmt.Errorf("datacenter: APIKey 为空") } if !strings.HasPrefix(c.APIKey, "ld_") { return nil, fmt.Errorf("datacenter: APIKey 需要是领取时展示的完整 Key,以 ld_ 开头") } if len(records) == 0 { return nil, fmt.Errorf("datacenter: 没有要发送的数据") } // 复制一份再动,不改调用方传进来的切片。 payload := make([]Record, len(records)) copy(payload, records) // 校验全部走一遍再返回,把问题攒一起报。 var problems []string for i := range payload { payload[i].fillDefaults() if err := payload[i].validate(); err != nil { problems = append(problems, fmt.Sprintf("records[%d]: %v", i, err)) } } if len(problems) > 0 { return nil, &ValidationError{Problems: problems} } body, err := json.Marshal(request{Records: payload}) if err != nil { return nil, fmt.Errorf("datacenter: 编码请求失败: %w", err) } endpoint := c.Endpoint if endpoint == "" { endpoint = DefaultEndpoint } req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("datacenter: 构造请求失败: %w", err) } req.Header.Set("Authorization", "Bearer "+c.APIKey) req.Header.Set("Content-Type", "application/json") client := c.HTTPClient if client == nil { client = &http.Client{Timeout: 30 * time.Second} } resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("datacenter: 请求失败: %w", err) } defer resp.Body.Close() // 只读 1MB,防止对面返回异常大的内容把内存吃掉。 respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return nil, fmt.Errorf("datacenter: 读取响应失败: %w", err) } out := &Response{StatusCode: resp.StatusCode, Body: respBody} if !out.OK() { return out, fmt.Errorf("datacenter: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody))) } return out, nil } // SendAsync 异步上报一批数据,立即返回,不会阻塞调用方。 // // 数据先进内存队列,由后台 goroutine 依次发送;同一个 client 的发送顺序 // 和调用顺序一致。适合放在主流程里(定时任务、接口 handler)用。 // // 三点必须知道: // // 1. 失败不会返回给调用方,通过 c.OnError 回调(不设置就打到 stderr)。 // 2. 队列满了会丢弃新数据并回调,宁丢不堵——主流程优先。 // 3. 进程退出前必须调 c.Close() 等数据发完,否则队列里的会丢。 // // 需要拿到结果、或者只是短脚本发一次就退出,直接用同步的 Send。 func (c *Client) SendAsync(records ...Record) { if len(records) == 0 { return } var err error c.mu.Lock() switch { case c.closed: err = fmt.Errorf("datacenter: 客户端已 Close,丢弃 %d 条数据", len(records)) default: if !c.started { size := c.QueueSize if size <= 0 { size = DefaultQueueSize } c.queue = make(chan []Record, size) c.done = make(chan struct{}) c.started = true go c.worker() } select { case c.queue <- records: default: err = fmt.Errorf("datacenter: 异步队列已满(%d 批),丢弃 %d 条数据", cap(c.queue), len(records)) } } c.mu.Unlock() // 回调放在锁外,这样 OnError 里再调 SendAsync 也不会死锁。 if err != nil { c.reportErr(err) } } // worker 是后台发送协程,按队列顺序逐批发,发完一批再取下一批。 func (c *Client) worker() { defer close(c.done) for batch := range c.queue { ctx, cancel := context.WithTimeout(context.Background(), asyncTimeout) _, err := c.Send(ctx, batch...) cancel() if err != nil { c.reportErr(err) } } } // Close 停止接收新的异步数据,并等队列里剩下的发完。 // // 进程/任务退出前一定要调(defer 或任务末尾)。没调过 SendAsync 的话是空操作, // 重复调也安全。Close 之后还调 SendAsync 不会 panic,数据会被丢弃并回调 OnError。 func (c *Client) Close() { c.mu.Lock() c.closed = true if c.started { close(c.queue) } done := c.done c.mu.Unlock() if done != nil { <-done // 等 worker 把队列清空 } } // reportErr 把异步过程中的错误交给 OnError,没设置就打到 stderr。 func (c *Client) reportErr(err error) { if c.OnError != nil { c.OnError(err) return } log.Println(err) } // JSON 返回这批数据最终会发出去的 JSON(同样会补默认值),用来本地核对, // 不发任何请求。配合 Send 使用可以做到「先看一眼,再真发」。 func JSON(records ...Record) ([]byte, error) { payload := make([]Record, len(records)) copy(payload, records) for i := range payload { payload[i].fillDefaults() } return json.MarshalIndent(request{Records: payload}, "", " ") } // fillDefaults 补齐 SDK 负责的三个字段。显式填过的值不会被覆盖。 func (r *Record) fillDefaults() { if strings.TrimSpace(r.DataDate) == "" { r.DataDate = time.Now().UTC().Format(DateLayout) } if strings.TrimSpace(r.Company) == "" { r.Company = DefaultCompany } if r.RoleID == nil { // 用空数组而不是 nil,避免 JSON 里出现 "role_id": null。 r.RoleID = []string{} } } // validate 检查必填字段和日期格式,返回一条汇总的错误信息。 func (r *Record) validate() error { // 必填字段,顺序和《参数说明》表格一致。 required := []struct { name string // 报错时显示的字段名,用 JSON 里的名字方便对照 value string }{ {"company_platform", r.CompanyPlatform}, {"published_data", r.PublishedData}, {"script_name", r.ScriptName}, {"drama_name", r.DramaName}, {"drama_no", r.DramaNo}, {"video_url", r.VideoURL}, {"platform_url", r.PlatformURL}, } var missing []string for _, f := range required { if strings.TrimSpace(f.value) == "" { missing = append(missing, f.name) } } // 从示例代码抄下来忘了改的占位符,也拦一下。 if strings.HasPrefix(r.VideoID, "REPLACE_") { missing = append(missing, "video_id") } // 日期必须是 DD/MM/YYYY。写成 2026-09-21 这种会在这里被拦下。 for _, d := range []struct{ name, value string }{ {"data_date", r.DataDate}, {"published_data", r.PublishedData}, } { if _, err := time.Parse(DateLayout, d.value); err != nil { missing = append(missing, fmt.Sprintf("%s 日期格式应为 DD/MM/YYYY,当前为 %q", d.name, d.value)) } } if len(missing) == 0 { return nil } return fmt.Errorf("缺少必填字段: %s", strings.Join(missing, ", ")) }