From 84631789039fdbf557296167d4cc522dde79187a Mon Sep 17 00:00:00 2001 From: goder-zhang Date: Tue, 10 Mar 2026 14:36:36 +0000 Subject: [PATCH] add devtodev sdk,add work flow context --- .gitignore | 2 + pkg/devtodev/README.md | 141 ++++++++++++++ pkg/devtodev/cmd/send/main.go | 72 ++++++++ pkg/devtodev/devtodev.go | 338 ++++++++++++++++++++++++++++++++++ pkg/devtodev/devtodev_test.go | 249 +++++++++++++++++++++++++ pkg/devtodev/event/common.go | 20 ++ pkg/devtodev/event/game.go | 91 +++++++++ pkg/devtodev/event/payment.go | 46 +++++ pkg/devtodev/event/session.go | 60 ++++++ pkg/devtodev/event/user.go | 73 ++++++++ pkg/devtodev/events.go | 218 ++++++++++++++++++++++ pkg/devtodev/go.mod | 5 + pkg/devtodev/go.sum | 4 + pkg/devtodev/note.md | 35 ++++ pkg/devtodev/report_event.md | 16 ++ pkg/devtodev/types.go | 36 ++++ pkg/devtodev/validation.go | 53 ++++++ work_flow/map_event.md | 31 ++++ 18 files changed, 1490 insertions(+) create mode 100644 pkg/devtodev/README.md create mode 100644 pkg/devtodev/cmd/send/main.go create mode 100644 pkg/devtodev/devtodev.go create mode 100644 pkg/devtodev/devtodev_test.go create mode 100644 pkg/devtodev/event/common.go create mode 100644 pkg/devtodev/event/game.go create mode 100644 pkg/devtodev/event/payment.go create mode 100644 pkg/devtodev/event/session.go create mode 100644 pkg/devtodev/event/user.go create mode 100644 pkg/devtodev/events.go create mode 100644 pkg/devtodev/go.mod create mode 100644 pkg/devtodev/go.sum create mode 100644 pkg/devtodev/note.md create mode 100644 pkg/devtodev/report_event.md create mode 100644 pkg/devtodev/types.go create mode 100644 pkg/devtodev/validation.go create mode 100644 work_flow/map_event.md diff --git a/.gitignore b/.gitignore index b3fc13a..2d5a8d1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ # 忽略日志目录和文件 log/ *.log.* +.idea/ +.DS_Store diff --git a/pkg/devtodev/README.md b/pkg/devtodev/README.md new file mode 100644 index 0000000..a7a6883 --- /dev/null +++ b/pkg/devtodev/README.md @@ -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 +``` diff --git a/pkg/devtodev/cmd/send/main.go b/pkg/devtodev/cmd/send/main.go new file mode 100644 index 0000000..09848e3 --- /dev/null +++ b/pkg/devtodev/cmd/send/main.go @@ -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) +} diff --git a/pkg/devtodev/devtodev.go b/pkg/devtodev/devtodev.go new file mode 100644 index 0000000..079460e --- /dev/null +++ b/pkg/devtodev/devtodev.go @@ -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") +} diff --git a/pkg/devtodev/devtodev_test.go b/pkg/devtodev/devtodev_test.go new file mode 100644 index 0000000..7ad8b33 --- /dev/null +++ b/pkg/devtodev/devtodev_test.go @@ -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, + }, + }, + }, + }, + }, + }, + }, + } +} diff --git a/pkg/devtodev/event/common.go b/pkg/devtodev/event/common.go new file mode 100644 index 0000000..0417023 --- /dev/null +++ b/pkg/devtodev/event/common.go @@ -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 +} diff --git a/pkg/devtodev/event/game.go b/pkg/devtodev/event/game.go new file mode 100644 index 0000000..c11df82 --- /dev/null +++ b/pkg/devtodev/event/game.go @@ -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 +} diff --git a/pkg/devtodev/event/payment.go b/pkg/devtodev/event/payment.go new file mode 100644 index 0000000..5fc491f --- /dev/null +++ b/pkg/devtodev/event/payment.go @@ -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 +} diff --git a/pkg/devtodev/event/session.go b/pkg/devtodev/event/session.go new file mode 100644 index 0000000..1c921bf --- /dev/null +++ b/pkg/devtodev/event/session.go @@ -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 +} diff --git a/pkg/devtodev/event/user.go b/pkg/devtodev/event/user.go new file mode 100644 index 0000000..d9c4b52 --- /dev/null +++ b/pkg/devtodev/event/user.go @@ -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 +} diff --git a/pkg/devtodev/events.go b/pkg/devtodev/events.go new file mode 100644 index 0000000..d079d2e --- /dev/null +++ b/pkg/devtodev/events.go @@ -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)) +} diff --git a/pkg/devtodev/go.mod b/pkg/devtodev/go.mod new file mode 100644 index 0000000..010f02c --- /dev/null +++ b/pkg/devtodev/go.mod @@ -0,0 +1,5 @@ +module devtodev-sdk + +go 1.23 + +require github.com/klauspost/compress v1.18.4 diff --git a/pkg/devtodev/go.sum b/pkg/devtodev/go.sum new file mode 100644 index 0000000..af89e63 --- /dev/null +++ b/pkg/devtodev/go.sum @@ -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= diff --git a/pkg/devtodev/note.md b/pkg/devtodev/note.md new file mode 100644 index 0000000..ece48ef --- /dev/null +++ b/pkg/devtodev/note.md @@ -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 客服反馈(自定义事件) diff --git a/pkg/devtodev/report_event.md b/pkg/devtodev/report_event.md new file mode 100644 index 0000000..392b700 --- /dev/null +++ b/pkg/devtodev/report_event.md @@ -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 | 用户提交客服反馈时 | 自定义事件 | diff --git a/pkg/devtodev/types.go b/pkg/devtodev/types.go new file mode 100644 index 0000000..40e5133 --- /dev/null +++ b/pkg/devtodev/types.go @@ -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{} diff --git a/pkg/devtodev/validation.go b/pkg/devtodev/validation.go new file mode 100644 index 0000000..8625830 --- /dev/null +++ b/pkg/devtodev/validation.go @@ -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 + } +} diff --git a/work_flow/map_event.md b/work_flow/map_event.md new file mode 100644 index 0000000..bedac61 --- /dev/null +++ b/work_flow/map_event.md @@ -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 上报