add devtodev sdk,add work flow context

This commit is contained in:
goder-zhang 2026-03-10 14:36:36 +00:00
parent 8f53751b68
commit 8463178903
18 changed files with 1490 additions and 0 deletions

2
.gitignore vendored
View File

@ -1,3 +1,5 @@
# 忽略日志目录和文件 # 忽略日志目录和文件
log/ log/
*.log.* *.log.*
.idea/
.DS_Store

141
pkg/devtodev/README.md Normal file
View File

@ -0,0 +1,141 @@
# devtodev SDK (Go)
Minimal Go SDK for devtodev Data API 2.0 event reporting.
## Install
Use as a local module or set your module path in `go.mod`.
## Usage
```go
package main
import (
"context"
"time"
devtodev "devtodev-sdk"
)
func main() {
client := devtodev.NewClient("YOUR_APP_ID")
payload := devtodev.Payload{
Reports: []devtodev.Report{
{
DeviceID: "device-123",
Packages: []devtodev.Package{
{
Language: "en",
Country: "US",
Events: []devtodev.Event{
{
"code": "ce",
"timestamp": time.Now().UnixMilli(),
"level": 5,
"name": "custom_event",
"parameters": map[string]interface{}{
"level": 5,
},
},
},
},
},
},
},
}
if err := client.Send(context.Background(), payload); err != nil {
panic(err)
}
}
```
## Event Helpers
This SDK includes a `Reporter` wrapper. Each event has its own function and sends a single report.
```go
client := devtodev.NewClient("YOUR_APP_ID")
reporter := devtodev.NewReporter(client, "device-123")
reporter.Package = devtodev.Package{
Language: "en",
Country: "US",
}
// Device Info (di)
_, _ = reporter.DeviceInfo(context.Background(), time.Now().UnixMilli(), map[string]interface{}{
"platform": "ios",
"device": "iPhone14,3",
})
// Custom Event (ce)
_, _ = reporter.CustomEvent(context.Background(), time.Now().UnixMilli(), 1, "custom_event", map[string]interface{}{
"score": 123,
}, nil)
```
## 中文事件文档
**Service Events**
- Device Info`di`):设备信息上报。必填字段:`code=di`、`timestamp`。新用户必须先发送该事件,否则不会注册用户。
- Session Start`ss`):会话开始。必填字段:`code=ss`、`timestamp`、`level`。
- User Engagement`ue`):用户活跃/心跳,记录活跃时长。必填字段:`code=ue`、`timestamp`、`level`、`length`(秒)。
- Setting User Tracking Status (GDPR)`ts`):用户追踪授权状态。必填字段:`code=ts`、`timestamp`、`trackingAllowed`bool
- Alive`al`):应用存活/心跳。必填字段:`code=al`、`timestamp`。
**User properties**
- People`pl`):用户属性更新。必填字段:`code=pl`、`timestamp`、`level`、`parameters`(用户属性键值对)。
**Basic Events**
- Custom Event`ce`):自定义事件。必填字段:`code=ce`、`timestamp`、`level`、`name``parameters` 可选。
- Real Payment`rp`真实支付IAP。必填字段`code=rp`、`timestamp`、`level`、`productId`、`orderId`、`price`、`currencyCode`。
- Onboarding / Tutorial`tr`):新手引导。必填字段:`code=tr`、`timestamp`、`level`、`step`。
- Virtual Currency Payment`vp`):虚拟货币消费。必填字段:`code=vp`、`timestamp`、`level`、`purchaseAmount`、`purchasePrice`、`purchaseType`、`purchaseId`。
- Currency Accrual`ca`):虚拟货币获得。必填字段:`code=ca`、`timestamp`、`level`,且 `bought``earned` 至少提供一个。
- Current Balance`cb`):当前余额。必填字段:`code=cb`、`timestamp`、`level`、`balance`。
- Level Up`lu`):升级。必填字段:`code=lu`、`timestamp`、`level``balance`/`spent`/`earned`/`bought` 可选。
- Progression Event`pe`):进度事件。必填字段:`code=pe`、`timestamp`、`level`、`name`、`parameters`。`parameters` 内必填:`success`、`duration`。
**Secondary Events**
- Referral`rf`):邀请推荐/安装来源。必填字段:`code=rf`、`timestamp`。
- Ad Impression`adrv`):广告展示。必填字段:`code=adrv`、`timestamp`、`ad_network`、`revenue`。
- Social Connect`sc`):社交绑定。必填字段:`code=sc`、`timestamp`、`level`、`socialNetwork`。
- Social Post`sp`):社交分享。必填字段:`code=sp`、`timestamp`、`level`、`socialNetwork`、`postReason`。
**事件相关性说明**
- Session Start`ss`)需要搭配 User Engagement`ue`)才能完整统计会话时长。
- Device Info`di`)必须作为新用户的首个事件上报;建议每次会话开始时也上报一次。
- Alive`al`)在用户超过 5 分钟没有任何事件时,需要上报以保持“在线”状态展示。
- Setting User Tracking Status`ts`)当用户拒绝追踪时必须上报;`trackingAllowed=false` 会触发删除该用户数据并禁止继续收集,若之后改为 `true` 则视为新用户。
- Session Start`ss`/ User Engagement`ue`/ Alive`al`)可通过 `sessionId` 关联为同一会话。
- Currency Accrual`ca`)不建议按“每笔交易”上报,应按 5-10 分钟聚合上报;当用户等级变化时应中断并上报一次聚合结果。
- Current Balance`cb`)不应对同一用户一天上报超过一次。
- Referral`rf`)每个用户仅需上报一次;若已接入 AppsFlyer 等广告追踪或 devtodev 自定义回调,可不再上报。
- Progression Event`pe`)的 `parameters.source` 用于串联上一个关卡/区域,便于形成进度链路。
## Notes
- Default endpoint: `https://api.devtodev.com/v2/analytics/report?appId=YOUR_APP_ID`
- Payload size limit is 2 MB uncompressed.
- Timestamps are in milliseconds since epoch.
- For new users, send the Device Info event (`code: "di"`) first so the user is registered.
- SDK validates required fields (`reports`, `deviceId`, `packages`, `events`, `code`, `timestamp`) before sending.
- Content-Type values follow the API: `application/json`, `application/zstd`, `application/gzip`.
- Use `SendWithResponse` if you need response details (`status`, `headers`, `body`, parsed JSON).
- Retries happen on common transient HTTP statuses and network errors.
## Customize
```go
client := devtodev.NewClient("YOUR_APP_ID")
client.Endpoint = "https://api.devtodev.com/v2/analytics/report"
client.Retry.MaxAttempts = 5
client.Retry.BaseDelay = 300 * time.Millisecond
client.Retry.MaxDelay = 3 * time.Second
// Compression options
client.Compression = devtodev.CompressionGzip // or CompressionZstd / CompressionNone
```

View File

@ -0,0 +1,72 @@
package main
import (
"context"
"fmt"
"math/rand"
"time"
devtodev "devtodev-sdk"
)
func main() {
rand.Seed(time.Now().UnixNano())
appID := "2d942fe9-c1d3-081c-bdb3-ec6080273fe6"
client := devtodev.NewClient(appID)
client.Compression = devtodev.CompressionNone
ctx := context.Background()
userCount := rand.Intn(2) + 2 // 2-3 users
for u := 0; u < userCount; u++ {
deviceID := fmt.Sprintf("device-%d", rand.Int63())
base := time.Now().Add(time.Duration(-rand.Intn(120)) * time.Second)
level := rand.Intn(20) + 1
reporter := devtodev.NewReporter(client, deviceID)
reporter.Package = devtodev.Package{
Language: "en",
Country: "US",
}
if _, err := reporter.DeviceInfo(ctx, base.UnixMilli(), map[string]interface{}{
"platform": "ios",
"device": "iPhone14,3",
}); err != nil {
panic(err)
}
if _, err := reporter.SessionStart(ctx, base.Add(2*time.Second).UnixMilli(), level, nil); err != nil {
panic(err)
}
// 2-4 user engagement heartbeats
ueCount := rand.Intn(3) + 2
for i := 0; i < ueCount; i++ {
if _, err := reporter.UserEngagement(ctx, base.Add(time.Duration(10*(i+1))*time.Second).UnixMilli(), level, 10, nil); err != nil {
panic(err)
}
}
// 1-3 real payments, each price 5-10
payments := rand.Intn(3) + 1
for i := 0; i < payments; i++ {
price := float64(rand.Intn(6) + 5)
if _, err := reporter.RealPayment(
ctx,
base.Add(time.Duration(60+(i*10))*time.Second).UnixMilli(),
level,
fmt.Sprintf("com.demo.product.%d", rand.Intn(5)+1),
fmt.Sprintf("order-%d-%d", time.Now().UnixNano(), i),
price,
"USD",
nil,
); err != nil {
panic(err)
}
}
}
fmt.Printf("sent events for %d users\n", userCount)
}

338
pkg/devtodev/devtodev.go Normal file
View File

@ -0,0 +1,338 @@
package devtodev
import (
"bytes"
"compress/gzip"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"math/rand"
"net"
"net/http"
"net/url"
"strings"
"time"
"github.com/klauspost/compress/zstd"
)
const (
defaultEndpoint = "https://api.devtodev.com/v2/analytics/report"
defaultUserAgent = "devtodev-go-sdk/0.1"
maxPayloadBytes = 2 * 1024 * 1024
)
// Compression controls request body encoding.
type Compression string
const (
CompressionNone Compression = "none"
CompressionGzip Compression = "gzip"
CompressionZstd Compression = "zstd"
)
// Client sends events to devtodev Data API 2.0.
type Client struct {
AppID string
Endpoint string
HTTP *http.Client
Retry RetryConfig
UserAgent string
Compression Compression
}
// Response captures HTTP response details for a send.
type Response struct {
StatusCode int
Headers http.Header
Body []byte
JSON map[string]interface{}
}
// APIError provides details for non-2xx responses.
type APIError struct {
StatusCode int
Message string
Body []byte
}
func (e *APIError) Error() string {
if e.Message != "" {
return fmt.Sprintf("request failed with status %d: %s", e.StatusCode, e.Message)
}
return fmt.Sprintf("request failed with status %d", e.StatusCode)
}
// RetryConfig configures retry behavior for transient failures.
type RetryConfig struct {
MaxAttempts int
BaseDelay time.Duration
MaxDelay time.Duration
Jitter bool
RetryStatuses map[int]struct{}
}
// NewClient creates a client with defaults.
func NewClient(appID string) *Client {
return &Client{
AppID: appID,
Endpoint: defaultEndpoint,
HTTP: &http.Client{Timeout: 10 * time.Second},
Retry: RetryConfig{
MaxAttempts: 3,
BaseDelay: 500 * time.Millisecond,
MaxDelay: 5 * time.Second,
Jitter: true,
RetryStatuses: map[int]struct{}{408: {}, 425: {}, 429: {}, 500: {}, 502: {}, 503: {}, 504: {}},
},
UserAgent: defaultUserAgent,
Compression: CompressionNone,
}
}
// Send sends a payload to devtodev.
func (c *Client) Send(ctx context.Context, payload Payload) error {
_, err := c.SendWithResponse(ctx, payload)
return err
}
// SendWithResponse sends a payload and returns the HTTP response details.
func (c *Client) SendWithResponse(ctx context.Context, payload Payload) (*Response, error) {
if c == nil {
return nil, errors.New("client is nil")
}
if c.AppID == "" {
return nil, errors.New("AppID is required")
}
if err := ValidatePayload(payload); err != nil {
return nil, err
}
endpoint := c.Endpoint
if endpoint == "" {
endpoint = defaultEndpoint
}
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal payload: %w", err)
}
if len(body) > maxPayloadBytes {
return nil, fmt.Errorf("payload too large: %d bytes exceeds %d bytes", len(body), maxPayloadBytes)
}
contentType := "application/json"
payloadBytes := body
switch c.Compression {
case "", CompressionNone:
// no-op
case CompressionGzip:
contentType = "application/gzip"
payloadBytes, err = gzipCompress(body)
if err != nil {
return nil, fmt.Errorf("gzip compress: %w", err)
}
case CompressionZstd:
contentType = "application/zstd"
payloadBytes, err = zstdCompress(body)
if err != nil {
return nil, fmt.Errorf("zstd compress: %w", err)
}
default:
return nil, fmt.Errorf("unsupported compression: %s", c.Compression)
}
reqURL, err := withAppID(endpoint, c.AppID)
if err != nil {
return nil, fmt.Errorf("build url: %w", err)
}
httpClient := c.HTTP
if httpClient == nil {
httpClient = &http.Client{Timeout: 10 * time.Second}
}
attempts := max(1, c.Retry.MaxAttempts)
for i := 1; i <= attempts; i++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(payloadBytes))
if err != nil {
return nil, fmt.Errorf("new request: %w", err)
}
req.Header.Set("Content-Type", contentType)
if c.UserAgent != "" {
req.Header.Set("User-Agent", c.UserAgent)
}
resp, err := httpClient.Do(req)
var parsed *Response
if err == nil && resp != nil {
parsed, err = parseResponse(resp)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
}
if err == nil && resp != nil && resp.StatusCode >= 200 && resp.StatusCode < 300 {
return parsed, nil
}
retry := shouldRetry(err, resp, c.Retry.RetryStatuses)
if !retry || i == attempts {
if err != nil {
return parsed, fmt.Errorf("request failed: %w", err)
}
return parsed, apiError(parsed)
}
wait := backoff(c.Retry, i)
if wait > 0 {
timer := time.NewTimer(wait)
select {
case <-ctx.Done():
timer.Stop()
return nil, ctx.Err()
case <-timer.C:
}
}
}
return nil, errors.New("unexpected retry loop exit")
}
func withAppID(endpoint, appID string) (string, error) {
u, err := url.Parse(endpoint)
if err != nil {
return "", err
}
q := u.Query()
if q.Get("appId") == "" {
q.Set("appId", appID)
}
u.RawQuery = q.Encode()
return u.String(), nil
}
func shouldRetry(err error, resp *http.Response, retryStatuses map[int]struct{}) bool {
if err != nil {
var netErr net.Error
if errors.As(err, &netErr) {
return true
}
if strings.Contains(err.Error(), "timeout") {
return true
}
return true
}
if resp == nil {
return true
}
if _, ok := retryStatuses[resp.StatusCode]; ok {
return true
}
if resp.StatusCode >= 500 {
return true
}
return false
}
func backoff(cfg RetryConfig, attempt int) time.Duration {
if cfg.BaseDelay <= 0 {
return 0
}
exp := math.Pow(2, float64(attempt-1))
delay := time.Duration(float64(cfg.BaseDelay) * exp)
if cfg.MaxDelay > 0 && delay > cfg.MaxDelay {
delay = cfg.MaxDelay
}
if cfg.Jitter {
maxJitter := int64(delay / 2)
if maxJitter > 0 {
delay = delay/2 + time.Duration(rand.Int63n(maxJitter))
}
}
return delay
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func gzipCompress(data []byte) ([]byte, error) {
var buf bytes.Buffer
zw := gzip.NewWriter(&buf)
if _, err := zw.Write(data); err != nil {
zw.Close()
return nil, err
}
if err := zw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func zstdCompress(data []byte) ([]byte, error) {
var buf bytes.Buffer
encoder, err := zstd.NewWriter(&buf)
if err != nil {
return nil, err
}
if _, err := encoder.Write(data); err != nil {
encoder.Close()
return nil, err
}
if err := encoder.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func parseResponse(resp *http.Response) (*Response, error) {
if resp == nil {
return nil, nil
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
out := &Response{
StatusCode: resp.StatusCode,
Headers: resp.Header.Clone(),
Body: body,
}
if isJSONResponse(resp.Header.Get("Content-Type")) && len(body) > 0 {
var m map[string]interface{}
if err := json.Unmarshal(body, &m); err == nil {
out.JSON = m
}
}
return out, nil
}
func apiError(resp *Response) error {
if resp == nil {
return &APIError{StatusCode: 0, Message: "no response"}
}
msg := ""
if resp.JSON != nil {
if v, ok := resp.JSON["message"].(string); ok {
msg = v
} else if v, ok := resp.JSON["error"].(string); ok {
msg = v
}
}
return &APIError{
StatusCode: resp.StatusCode,
Message: msg,
Body: resp.Body,
}
}
func isJSONResponse(contentType string) bool {
return strings.Contains(contentType, "application/json") || strings.Contains(contentType, "+json")
}

View File

@ -0,0 +1,249 @@
package devtodev
import (
"bytes"
"compress/gzip"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/klauspost/compress/zstd"
)
func TestSend_NoCompression(t *testing.T) {
payload := samplePayload()
expected, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Fatalf("method: %s", r.Method)
}
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
t.Fatalf("content-type: %s", ct)
}
if r.URL.Query().Get("appId") != "app-123" {
t.Fatalf("appId: %s", r.URL.Query().Get("appId"))
}
got, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
if !bytes.Equal(got, expected) {
t.Fatalf("body mismatch")
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
client := NewClient("app-123")
client.Endpoint = server.URL
if err := client.Send(context.Background(), payload); err != nil {
t.Fatalf("send: %v", err)
}
}
func TestSend_GzipCompression(t *testing.T) {
payload := samplePayload()
expected, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if ct := r.Header.Get("Content-Type"); ct != "application/gzip" {
t.Fatalf("content-type: %s", ct)
}
zr, err := gzip.NewReader(r.Body)
if err != nil {
t.Fatalf("gzip reader: %v", err)
}
got, err := io.ReadAll(zr)
if err != nil {
t.Fatalf("read body: %v", err)
}
zr.Close()
if !bytes.Equal(got, expected) {
t.Fatalf("body mismatch")
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
client := NewClient("app-123")
client.Endpoint = server.URL
client.Compression = CompressionGzip
if err := client.Send(context.Background(), payload); err != nil {
t.Fatalf("send: %v", err)
}
}
func TestSend_ZstdCompression(t *testing.T) {
payload := samplePayload()
expected, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if ct := r.Header.Get("Content-Type"); ct != "application/zstd" {
t.Fatalf("content-type: %s", ct)
}
decoder, err := zstd.NewReader(r.Body)
if err != nil {
t.Fatalf("zstd reader: %v", err)
}
got, err := io.ReadAll(decoder)
if err != nil {
t.Fatalf("read body: %v", err)
}
decoder.Close()
if !bytes.Equal(got, expected) {
t.Fatalf("body mismatch")
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
client := NewClient("app-123")
client.Endpoint = server.URL
client.Compression = CompressionZstd
if err := client.Send(context.Background(), payload); err != nil {
t.Fatalf("send: %v", err)
}
}
func TestValidatePayload(t *testing.T) {
cases := []struct {
name string
payload Payload
ok bool
}{
{
name: "empty",
payload: Payload{},
ok: false,
},
{
name: "missing device id",
payload: Payload{
Reports: []Report{{}},
},
ok: false,
},
{
name: "missing packages",
payload: Payload{
Reports: []Report{{DeviceID: "d1"}},
},
ok: false,
},
{
name: "missing events",
payload: Payload{
Reports: []Report{{DeviceID: "d1", Packages: []Package{{}}}},
},
ok: false,
},
{
name: "missing code",
payload: Payload{
Reports: []Report{{DeviceID: "d1", Packages: []Package{{Events: []Event{{"timestamp": int64(1)}}}}}},
},
ok: false,
},
{
name: "missing timestamp",
payload: Payload{
Reports: []Report{{DeviceID: "d1", Packages: []Package{{Events: []Event{{"code": "ce"}}}}}},
},
ok: false,
},
{
name: "ok",
payload: Payload{
Reports: []Report{{DeviceID: "d1", Packages: []Package{{Events: []Event{{"code": "ce", "timestamp": int64(1)}}}}}},
},
ok: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := ValidatePayload(tc.payload)
if tc.ok && err != nil {
t.Fatalf("expected ok, got %v", err)
}
if !tc.ok && err == nil {
t.Fatalf("expected error")
}
})
}
}
func TestSendWithResponse_ParsesJSONError(t *testing.T) {
payload := samplePayload()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"message":"bad request"}`))
}))
defer server.Close()
client := NewClient("app-123")
client.Endpoint = server.URL
client.Retry.MaxAttempts = 1
resp, err := client.SendWithResponse(context.Background(), payload)
if err == nil {
t.Fatalf("expected error")
}
if resp == nil || resp.StatusCode != http.StatusBadRequest {
t.Fatalf("unexpected response: %+v", resp)
}
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected APIError, got %T", err)
}
if apiErr.Message != "bad request" {
t.Fatalf("unexpected message: %q", apiErr.Message)
}
}
func samplePayload() Payload {
return Payload{
Reports: []Report{
{
DeviceID: "device-123",
Packages: []Package{
{
Language: "en",
Country: "US",
Events: []Event{
{
"code": "ce",
"timestamp": time.Now().UnixMilli(),
"level": 5,
"name": "custom_event",
"parameters": map[string]interface{}{
"level": 5,
},
},
},
},
},
},
},
}
}

View File

@ -0,0 +1,20 @@
package event
import "fmt"
func addFields(event map[string]interface{}, fields map[string]interface{}, protected ...string) error {
if len(fields) == 0 {
return nil
}
block := map[string]struct{}{}
for _, key := range protected {
block[key] = struct{}{}
}
for k, v := range fields {
if _, ok := block[k]; ok {
return fmt.Errorf("fields must not override %s", k)
}
event[k] = v
}
return nil
}

View File

@ -0,0 +1,91 @@
package event
import "fmt"
func Onboarding(timestamp int64, level int, step int, fields map[string]interface{}) (map[string]interface{}, error) {
event := map[string]interface{}{
"code": "tr",
"timestamp": timestamp,
"level": level,
"step": step,
}
if err := addFields(event, fields, "code", "timestamp", "level", "step"); err != nil {
return nil, err
}
return event, nil
}
func CurrencyAccrual(timestamp int64, level int, bought, earned map[string]map[string]float64, fields map[string]interface{}) (map[string]interface{}, error) {
if len(bought) == 0 && len(earned) == 0 {
return nil, fmt.Errorf("either bought or earned must be provided")
}
event := map[string]interface{}{
"code": "ca",
"timestamp": timestamp,
"level": level,
}
if len(bought) > 0 {
event["bought"] = bought
}
if len(earned) > 0 {
event["earned"] = earned
}
if err := addFields(event, fields, "code", "timestamp", "level", "bought", "earned"); err != nil {
return nil, err
}
return event, nil
}
func CurrentBalance(timestamp int64, level int, balance map[string]float64, fields map[string]interface{}) (map[string]interface{}, error) {
event := map[string]interface{}{
"code": "cb",
"timestamp": timestamp,
"level": level,
"balance": balance,
}
if err := addFields(event, fields, "code", "timestamp", "level", "balance"); err != nil {
return nil, err
}
return event, nil
}
func LevelUp(timestamp int64, level int, balance, spent, earned, bought map[string]float64, fields map[string]interface{}) (map[string]interface{}, error) {
event := map[string]interface{}{
"code": "lu",
"timestamp": timestamp,
"level": level,
}
if len(balance) > 0 {
event["balance"] = balance
}
if len(spent) > 0 {
event["spent"] = spent
}
if len(earned) > 0 {
event["earned"] = earned
}
if len(bought) > 0 {
event["bought"] = bought
}
if err := addFields(event, fields, "code", "timestamp", "level", "balance", "spent", "earned", "bought"); err != nil {
return nil, err
}
return event, nil
}
func ProgressionEvent(timestamp int64, level int, name string, parameters map[string]interface{}, fields map[string]interface{}) (map[string]interface{}, error) {
if len(parameters) == 0 {
return nil, fmt.Errorf("parameters is required")
}
event := map[string]interface{}{
"code": "pe",
"timestamp": timestamp,
"level": level,
"name": name,
"parameters": parameters,
}
if err := addFields(event, fields, "code", "timestamp", "level", "name", "parameters"); err != nil {
return nil, err
}
return event, nil
}

View File

@ -0,0 +1,46 @@
package event
func RealPayment(timestamp int64, level int, productID, orderID string, price float64, currencyCode string, fields map[string]interface{}) (map[string]interface{}, error) {
event := map[string]interface{}{
"code": "rp",
"timestamp": timestamp,
"level": level,
"productId": productID,
"orderId": orderID,
"price": price,
"currencyCode": currencyCode,
}
if err := addFields(event, fields, "code", "timestamp", "level", "productId", "orderId", "price", "currencyCode"); err != nil {
return nil, err
}
return event, nil
}
func VirtualCurrencyPayment(timestamp int64, level int, purchaseAmount int, purchasePrice map[string]float64, purchaseType, purchaseID string, fields map[string]interface{}) (map[string]interface{}, error) {
event := map[string]interface{}{
"code": "vp",
"timestamp": timestamp,
"level": level,
"purchaseAmount": purchaseAmount,
"purchasePrice": purchasePrice,
"purchaseType": purchaseType,
"purchaseId": purchaseID,
}
if err := addFields(event, fields, "code", "timestamp", "level", "purchaseAmount", "purchasePrice", "purchaseType", "purchaseId"); err != nil {
return nil, err
}
return event, nil
}
func AdImpression(timestamp int64, adNetwork string, revenue float64, fields map[string]interface{}) (map[string]interface{}, error) {
event := map[string]interface{}{
"code": "adrv",
"timestamp": timestamp,
"ad_network": adNetwork,
"revenue": revenue,
}
if err := addFields(event, fields, "code", "timestamp", "ad_network", "revenue"); err != nil {
return nil, err
}
return event, nil
}

View File

@ -0,0 +1,60 @@
package event
func DeviceInfo(timestamp int64, fields map[string]interface{}) (map[string]interface{}, error) {
event := map[string]interface{}{
"code": "di",
"timestamp": timestamp,
}
if err := addFields(event, fields, "code", "timestamp"); err != nil {
return nil, err
}
return event, nil
}
func SessionStart(timestamp int64, level int, fields map[string]interface{}) (map[string]interface{}, error) {
event := map[string]interface{}{
"code": "ss",
"timestamp": timestamp,
"level": level,
}
if err := addFields(event, fields, "code", "timestamp", "level"); err != nil {
return nil, err
}
return event, nil
}
func UserEngagement(timestamp int64, level int, length int, fields map[string]interface{}) (map[string]interface{}, error) {
event := map[string]interface{}{
"code": "ue",
"timestamp": timestamp,
"level": level,
"length": length,
}
if err := addFields(event, fields, "code", "timestamp", "level", "length"); err != nil {
return nil, err
}
return event, nil
}
func TrackingStatus(timestamp int64, trackingAllowed bool, fields map[string]interface{}) (map[string]interface{}, error) {
event := map[string]interface{}{
"code": "ts",
"timestamp": timestamp,
"trackingAllowed": trackingAllowed,
}
if err := addFields(event, fields, "code", "timestamp", "trackingAllowed"); err != nil {
return nil, err
}
return event, nil
}
func Alive(timestamp int64, fields map[string]interface{}) (map[string]interface{}, error) {
event := map[string]interface{}{
"code": "al",
"timestamp": timestamp,
}
if err := addFields(event, fields, "code", "timestamp"); err != nil {
return nil, err
}
return event, nil
}

View File

@ -0,0 +1,73 @@
package event
import "fmt"
func People(timestamp int64, level int, properties map[string]interface{}, fields map[string]interface{}) (map[string]interface{}, error) {
if len(properties) == 0 {
return nil, fmt.Errorf("parameters is required")
}
event := map[string]interface{}{
"code": "pl",
"timestamp": timestamp,
"level": level,
"parameters": properties,
}
if err := addFields(event, fields, "code", "timestamp", "level", "parameters"); err != nil {
return nil, err
}
return event, nil
}
func CustomEvent(timestamp int64, level int, name string, parameters map[string]interface{}, fields map[string]interface{}) (map[string]interface{}, error) {
event := map[string]interface{}{
"code": "ce",
"timestamp": timestamp,
"level": level,
"name": name,
}
if parameters != nil {
event["parameters"] = parameters
}
if err := addFields(event, fields, "code", "timestamp", "level", "name", "parameters"); err != nil {
return nil, err
}
return event, nil
}
func Referral(timestamp int64, fields map[string]interface{}) (map[string]interface{}, error) {
event := map[string]interface{}{
"code": "rf",
"timestamp": timestamp,
}
if err := addFields(event, fields, "code", "timestamp"); err != nil {
return nil, err
}
return event, nil
}
func SocialConnect(timestamp int64, level int, socialNetwork string, fields map[string]interface{}) (map[string]interface{}, error) {
event := map[string]interface{}{
"code": "sc",
"timestamp": timestamp,
"level": level,
"socialNetwork": socialNetwork,
}
if err := addFields(event, fields, "code", "timestamp", "level", "socialNetwork"); err != nil {
return nil, err
}
return event, nil
}
func SocialPost(timestamp int64, level int, socialNetwork, postReason string, fields map[string]interface{}) (map[string]interface{}, error) {
event := map[string]interface{}{
"code": "sp",
"timestamp": timestamp,
"level": level,
"socialNetwork": socialNetwork,
"postReason": postReason,
}
if err := addFields(event, fields, "code", "timestamp", "level", "socialNetwork", "postReason"); err != nil {
return nil, err
}
return event, nil
}

218
pkg/devtodev/events.go Normal file
View File

@ -0,0 +1,218 @@
package devtodev
import (
"context"
"fmt"
"devtodev-sdk/event"
)
// Reporter wraps Client with shared report/package fields and per-event helpers.
// Each event helper sends exactly one report with one package.
type Reporter struct {
Client *Client
DeviceID string
UserID string
PreviousDeviceID string
PreviousUserID string
DevtodevID string
Package Package
}
// NewReporter creates a Reporter with a client and device ID.
func NewReporter(client *Client, deviceID string) *Reporter {
return &Reporter{
Client: client,
DeviceID: deviceID,
}
}
// Report sends a single event using the current reporter context.
func (r *Reporter) Report(ctx context.Context, event Event) (*Response, error) {
if r == nil {
return nil, fmt.Errorf("reporter is nil")
}
if r.Client == nil {
return nil, fmt.Errorf("client is nil")
}
if r.DeviceID == "" {
return nil, fmt.Errorf("deviceId is required")
}
pkg := r.Package
pkg.Events = []Event{event}
payload := Payload{
Reports: []Report{
{
DeviceID: r.DeviceID,
UserID: r.UserID,
PreviousDeviceID: r.PreviousDeviceID,
PreviousUserID: r.PreviousUserID,
DevtodevID: r.DevtodevID,
Packages: []Package{pkg},
},
},
}
return r.Client.SendWithResponse(ctx, payload)
}
// DeviceInfo (code "di").
func (r *Reporter) DeviceInfo(ctx context.Context, timestamp int64, fields map[string]interface{}) (*Response, error) {
e, err := event.DeviceInfo(timestamp, fields)
if err != nil {
return nil, err
}
return r.Report(ctx, Event(e))
}
// SessionStart (code "ss").
func (r *Reporter) SessionStart(ctx context.Context, timestamp int64, level int, fields map[string]interface{}) (*Response, error) {
e, err := event.SessionStart(timestamp, level, fields)
if err != nil {
return nil, err
}
return r.Report(ctx, Event(e))
}
// UserEngagement (code "ue").
func (r *Reporter) UserEngagement(ctx context.Context, timestamp int64, level int, length int, fields map[string]interface{}) (*Response, error) {
e, err := event.UserEngagement(timestamp, level, length, fields)
if err != nil {
return nil, err
}
return r.Report(ctx, Event(e))
}
// TrackingStatus (GDPR) (code "ts").
func (r *Reporter) TrackingStatus(ctx context.Context, timestamp int64, trackingAllowed bool, fields map[string]interface{}) (*Response, error) {
e, err := event.TrackingStatus(timestamp, trackingAllowed, fields)
if err != nil {
return nil, err
}
return r.Report(ctx, Event(e))
}
// Alive (code "al").
func (r *Reporter) Alive(ctx context.Context, timestamp int64, fields map[string]interface{}) (*Response, error) {
e, err := event.Alive(timestamp, fields)
if err != nil {
return nil, err
}
return r.Report(ctx, Event(e))
}
// People (user properties) (code "pl").
func (r *Reporter) People(ctx context.Context, timestamp int64, level int, properties map[string]interface{}, fields map[string]interface{}) (*Response, error) {
e, err := event.People(timestamp, level, properties, fields)
if err != nil {
return nil, err
}
return r.Report(ctx, Event(e))
}
// CustomEvent (code "ce").
func (r *Reporter) CustomEvent(ctx context.Context, timestamp int64, level int, name string, parameters map[string]interface{}, fields map[string]interface{}) (*Response, error) {
e, err := event.CustomEvent(timestamp, level, name, parameters, fields)
if err != nil {
return nil, err
}
return r.Report(ctx, Event(e))
}
// RealPayment (code "rp").
func (r *Reporter) RealPayment(ctx context.Context, timestamp int64, level int, productID, orderID string, price float64, currencyCode string, fields map[string]interface{}) (*Response, error) {
e, err := event.RealPayment(timestamp, level, productID, orderID, price, currencyCode, fields)
if err != nil {
return nil, err
}
return r.Report(ctx, Event(e))
}
// Onboarding (tutorial) (code "tr").
func (r *Reporter) Onboarding(ctx context.Context, timestamp int64, level int, step int, fields map[string]interface{}) (*Response, error) {
e, err := event.Onboarding(timestamp, level, step, fields)
if err != nil {
return nil, err
}
return r.Report(ctx, Event(e))
}
// VirtualCurrencyPayment (code "vp").
func (r *Reporter) VirtualCurrencyPayment(ctx context.Context, timestamp int64, level int, purchaseAmount int, purchasePrice map[string]float64, purchaseType, purchaseID string, fields map[string]interface{}) (*Response, error) {
e, err := event.VirtualCurrencyPayment(timestamp, level, purchaseAmount, purchasePrice, purchaseType, purchaseID, fields)
if err != nil {
return nil, err
}
return r.Report(ctx, Event(e))
}
// CurrencyAccrual (code "ca"). At least one of bought or earned must be provided.
func (r *Reporter) CurrencyAccrual(ctx context.Context, timestamp int64, level int, bought, earned map[string]map[string]float64, fields map[string]interface{}) (*Response, error) {
e, err := event.CurrencyAccrual(timestamp, level, bought, earned, fields)
if err != nil {
return nil, err
}
return r.Report(ctx, Event(e))
}
// CurrentBalance (code "cb").
func (r *Reporter) CurrentBalance(ctx context.Context, timestamp int64, level int, balance map[string]float64, fields map[string]interface{}) (*Response, error) {
e, err := event.CurrentBalance(timestamp, level, balance, fields)
if err != nil {
return nil, err
}
return r.Report(ctx, Event(e))
}
// LevelUp (code "lu").
func (r *Reporter) LevelUp(ctx context.Context, timestamp int64, level int, balance, spent, earned, bought map[string]float64, fields map[string]interface{}) (*Response, error) {
e, err := event.LevelUp(timestamp, level, balance, spent, earned, bought, fields)
if err != nil {
return nil, err
}
return r.Report(ctx, Event(e))
}
// ProgressionEvent (code "pe"). "parameters" is required and should include success and duration.
func (r *Reporter) ProgressionEvent(ctx context.Context, timestamp int64, level int, name string, parameters map[string]interface{}, fields map[string]interface{}) (*Response, error) {
e, err := event.ProgressionEvent(timestamp, level, name, parameters, fields)
if err != nil {
return nil, err
}
return r.Report(ctx, Event(e))
}
// Referral (code "rf").
func (r *Reporter) Referral(ctx context.Context, timestamp int64, fields map[string]interface{}) (*Response, error) {
e, err := event.Referral(timestamp, fields)
if err != nil {
return nil, err
}
return r.Report(ctx, Event(e))
}
// AdImpression (code "adrv").
func (r *Reporter) AdImpression(ctx context.Context, timestamp int64, adNetwork string, revenue float64, fields map[string]interface{}) (*Response, error) {
e, err := event.AdImpression(timestamp, adNetwork, revenue, fields)
if err != nil {
return nil, err
}
return r.Report(ctx, Event(e))
}
// SocialConnect (code "sc").
func (r *Reporter) SocialConnect(ctx context.Context, timestamp int64, level int, socialNetwork string, fields map[string]interface{}) (*Response, error) {
e, err := event.SocialConnect(timestamp, level, socialNetwork, fields)
if err != nil {
return nil, err
}
return r.Report(ctx, Event(e))
}
// SocialPost (code "sp").
func (r *Reporter) SocialPost(ctx context.Context, timestamp int64, level int, socialNetwork, postReason string, fields map[string]interface{}) (*Response, error) {
e, err := event.SocialPost(timestamp, level, socialNetwork, postReason, fields)
if err != nil {
return nil, err
}
return r.Report(ctx, Event(e))
}

5
pkg/devtodev/go.mod Normal file
View File

@ -0,0 +1,5 @@
module devtodev-sdk
go 1.23
require github.com/klauspost/compress v1.18.4

4
pkg/devtodev/go.sum Normal file
View File

@ -0,0 +1,4 @@
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=

35
pkg/devtodev/note.md Normal file
View File

@ -0,0 +1,35 @@
1.登录2.注册3.token刷新4.充值5.进入游戏6.下注7.提现8.客服反馈
1.
device_info 设备信息
2.
Virtual Currency Payment # 表示用户用虚拟币购买某个“具体商品”
虚拟币 → 商品
3.
Current Balance # 实时资产余额
4.
session_start ✅ 会话开启,登陆
5.
register 注册事件 (自定义事件)
6.
token刷新 事件(自定义事件)
7.
Real Payment # 真实支付,收到支付回调时调用,充值
8.
enter game 进入游戏事件(自定义事件)
9.
currency_accrual 游戏收益,等等其他收益,下注
典型场景:
游戏胜利获得筹码
活动奖励发币
任务奖励
充值后发放筹
系统补偿
9.
withdraw 提现事件(自定义事件)
10.
custom support 客服反馈(自定义事件)

View File

@ -0,0 +1,16 @@
## 事件埋点列表
| 事件名称 | 上报场景 | 事件类型 |
|---|---|---|
| device_info | 首次安装 / 版本升级 / 设备环境变化时上报设备信息 | 预设事件 |
| session_start | 用户产生一次会话(进入平台 / 打开App | 预设事件 |
| real_currency_payment | 用户真实支付成功(充值成功回调时) | 预设事件 |
| virtual_currency_payment | 用户用虚拟币购买具体商品VIP、礼包、道具等 | 预设事件 |
| currency_accrual | 用户虚拟币增加(赢币、奖励、充值发筹码、系统补偿等) | 预设事件 |
| currency_spent | 用户虚拟币消费(下注) | 预设事件 |
| current_balance | 余额变动后 / 登录时 / 每日汇总时上报当前资产余额 | 预设事件 |
| register | 用户完成注册时 | 自定义事件 |
| token_refresh | 用户 token 刷新时 | 自定义事件 |
| enter_game | 用户进入某个具体游戏时 | 自定义事件 |
| withdraw | 用户提现成功时 | 自定义事件 |
| custom_support | 用户提交客服反馈时 | 自定义事件 |

36
pkg/devtodev/types.go Normal file
View File

@ -0,0 +1,36 @@
package devtodev
// Payload is the top-level request body for Data API 2.0.
type Payload struct {
Reports []Report `json:"reports"`
}
// Report groups events for a device (or a player).
type Report struct {
DeviceID string `json:"deviceId"`
UserID string `json:"userId,omitempty"`
PreviousDeviceID string `json:"previousDeviceId,omitempty"`
PreviousUserID string `json:"previousUserId,omitempty"`
DevtodevID string `json:"devtodevId,omitempty"`
Packages []Package `json:"packages"`
}
// Package groups events under a locale and metadata.
type Package struct {
Language string `json:"language,omitempty"`
Country string `json:"country,omitempty"`
Platform string `json:"platform,omitempty"`
IP string `json:"ip,omitempty"`
AppVersion string `json:"appVersion,omitempty"`
AppBuildVersion string `json:"appBuildVersion,omitempty"`
SDKVersion string `json:"sdkVersion,omitempty"`
SDKCodeVersion int `json:"sdkCodeVersion,omitempty"`
Bundle string `json:"bundle,omitempty"`
InstallationSource string `json:"installationSource,omitempty"`
Engine string `json:"engine,omitempty"`
Events []Event `json:"events"`
}
// Event is a raw devtodev event object.
// Use the Data API 2.0 event schema when building this map.
type Event map[string]interface{}

View File

@ -0,0 +1,53 @@
package devtodev
import "fmt"
// ValidatePayload checks for required fields and common schema errors.
func ValidatePayload(payload Payload) error {
if len(payload.Reports) == 0 {
return fmt.Errorf("reports is required")
}
for ri, report := range payload.Reports {
if report.DeviceID == "" {
return fmt.Errorf("reports[%d].deviceId is required", ri)
}
if len(report.Packages) == 0 {
return fmt.Errorf("reports[%d].packages is required", ri)
}
for pi, pkg := range report.Packages {
if len(pkg.Events) == 0 {
return fmt.Errorf("reports[%d].packages[%d].events is required", ri, pi)
}
for ei, event := range pkg.Events {
code, ok := event["code"]
if !ok {
return fmt.Errorf("reports[%d].packages[%d].events[%d].code is required", ri, pi, ei)
}
if codeStr, ok := code.(string); !ok || codeStr == "" {
return fmt.Errorf("reports[%d].packages[%d].events[%d].code must be a non-empty string", ri, pi, ei)
}
ts, ok := event["timestamp"]
if !ok {
return fmt.Errorf("reports[%d].packages[%d].events[%d].timestamp is required", ri, pi, ei)
}
if !isNumber(ts) {
return fmt.Errorf("reports[%d].packages[%d].events[%d].timestamp must be a number", ri, pi, ei)
}
}
}
}
return nil
}
func isNumber(v interface{}) bool {
switch v.(type) {
case int, int8, int16, int32, int64:
return true
case uint, uint8, uint16, uint32, uint64:
return true
case float32, float64:
return true
default:
return false
}
}

31
work_flow/map_event.md Normal file
View File

@ -0,0 +1,31 @@
# 200-205 到 devtodev 映射表
基于当前仓库信息整理,依据主要来自 `model/awssqs/sqs.go``pkg/devtodev/report_event.md`
## 总表
| SQS Action | 含义 | 推荐映射到 devtodev | 类型 | 是否满足需求 | 备注 |
|---|---|---|---|---|---|
| `200` `SqsActionUserBehaviorRegister` | 注册 | `register` | 自定义事件 | `是,部分满足` | 同时建议补发 `device_info`,因为新用户首事件应先有设备信息 |
| `201` `SqsActionUserBehaviorLogin` | 登录 | `session_start` | 预设事件 | `是,部分满足` | 只能覆盖“会话开始”;若要统计会话时长,还要配合 `user_engagement` |
| `202` `SqsActionUserBehaviorEditPassword` | 修改登录密码 | `不映射` | 无 | `否` | `report_event.md` 里没有这个需求,除非额外埋成自定义事件如 `edit_password` |
| `203` `SqsActionUserBehaviorEditPayPassword` | 修改支付密码 | `不映射` | 无 | `否` | 当前需求里没有 |
| `204` `SqsActionUserBehaviorUpdateWallet` | 更新钱包地址/绑定银行卡 | `不映射` | 无 | `否` | 当前需求里没有;如果要分析绑卡,可单独加自定义事件 |
| `205` `SqsActionWalletBalanceChange` | 余额变更 | `按来源拆分` | 混合 | `部分满足` | 不能直接统一映射成一个 devtodev 事件,必须按 `sourceType` 或业务场景拆 |
## `205` 拆分建议
| `205` 子场景 | 推荐映射到 devtodev | 类型 | 需要字段 |
|---|---|---|---|
| 充值成功,法币/真实支付入账 | `real_currency_payment` | 预设事件 | `orderId`、`price`、`currencyCode`、`productId` |
| 提现成功 | `withdraw` | 自定义事件 | 建议参数:`amount`、`sourceId`、`recordNo` |
| 游戏赢币、活动奖励、补偿、赠送 | `currency_accrual` | 预设事件 | 需要明确币种和金额 |
| 下注、扣减虚拟币 | `currency_spent` | 预设事件 | 需要明确币种和金额 |
| 每次账变后同步当前余额 | `current_balance` | 预设事件 | 需要当前余额;但文档建议不要对同一用户一天上报超过一次 |
| 用虚拟币购买 VIP/礼包/道具 | `virtual_currency_payment` | 预设事件 | `purchaseAmount`、`purchasePrice`、`purchaseType`、`purchaseId` |
## 结论
- `200-205` 里能直接对上需求的主要是 `200`、`201`,以及需要拆分后的 `205`
- `token_refresh`、`enter_game`、`custom_support` 不在 `200-205` 范围内,需要从其他业务入口补埋点
- 当前代码里这组事件在 SQS 消费后只是落库日志,还没有真正接到 devtodev 上报