Files
2026-10-26 16:28:14 +08:00

438 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 接入方只需要填自己的业务数据,其余交给 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, ", "))
}