package main import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "os" "strings" "time" activityv1 "hyapp.local/api/proto/activity/v1" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" ) const ( adminBaseURL = "http://127.0.0.1:13100" gatewayBaseURL = "http://127.0.0.1:13000" activityGRPCURL = "127.0.0.1:13006" appCode = "lalu" ) func main() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() // 真实链路从后台 HTTP 登录开始,拿到后台 token 后只通过 daily-task 接口创建配置,不直接写 task_definitions。 adminToken := must(adminLogin(ctx)) suffix := fmt.Sprintf("%d", time.Now().UnixNano()) task := must(createTask(ctx, adminToken, suffix)) // App 用户必须从 gateway 创建,确保 user-service 生成真实用户、session、access token 和腾讯 IM 账号导入链路。 user := must(quickCreateUser(ctx, suffix)) // IM 测试走 gateway 的真实 UserSig 接口;这里校验 SDKAppID、IM user_id 和 user_sig 都按当前 access token 生成。 im := must(getIMUserSig(ctx, user.AccessToken)) // 任务进度不允许 App 自报;smoke 用 activity-service 真实 gRPC 消费入口模拟 owner outbox worker 投递的服务端事实。 event := must(consumeTaskEvent(ctx, task.TaskID, user.UserID, suffix)) // App 查询和领取只走 gateway HTTP;领取后 activity-service 会调用真实 wallet-service CreditTaskReward。 beforeClaim := must(getTaskItem(ctx, user.AccessToken, task.TaskID)) claim := must(claimTask(ctx, user.AccessToken, beforeClaim, suffix)) afterClaim := must(getTaskItem(ctx, user.AccessToken, task.TaskID)) // 输出固定字段,方便人工和脚本快速确认本地真实接口链路是否完整通过。 result := map[string]any{ "admin_task_id": task.TaskID, "admin_task_icon_key": task.IconKey, "admin_task_icon_url": task.IconURL, "admin_task_action_type": task.ActionType, "app_user_id": user.UserID, "app_display_user_id": user.DisplayUserID, "im_sdk_app_id": im.SDKAppID, "im_user_id": im.UserID, "im_user_sig_non_empty": im.UserSig != "", "event_status": event.Status, "event_matched_task_count": event.MatchedTaskCount, "task_status_before_claim": beforeClaim.Status, "task_progress_before_claim": beforeClaim.ProgressValue, "task_claimable_before_claim": beforeClaim.Claimable, "claim_status": claim.Status, "claim_wallet_transaction_id": claim.WalletTransactionID, "claim_reward_coin_amount": claim.RewardCoinAmount, "task_status_after_claim": afterClaim.Status, "task_claimable_after_claim": afterClaim.Claimable, "app_task_icon_key": beforeClaim.IconKey, "app_task_icon_url": beforeClaim.IconURL, "app_task_action_type": beforeClaim.ActionType, "app_task_action_param": beforeClaim.ActionParam, "app_task_action_payload_entry": beforeClaim.ActionPayload["entry"], } encoder := json.NewEncoder(os.Stdout) encoder.SetIndent("", " ") if err := encoder.Encode(result); err != nil { panic(err) } } func adminLogin(ctx context.Context) (string, error) { var envelope struct { Data struct { AccessToken string `json:"accessToken"` } `json:"data"` } if err := postJSON(ctx, adminBaseURL+"/api/v1/auth/login", "", map[string]any{ "username": "admin", "password": "admin123", }, &envelope); err != nil { return "", err } if envelope.Data.AccessToken == "" { return "", errors.New("admin login returned empty accessToken") } return envelope.Data.AccessToken, nil } type taskConfig struct { TaskID string `json:"task_id"` IconKey string `json:"icon_key"` IconURL string `json:"icon_url"` ActionType string `json:"action_type"` } func createTask(ctx context.Context, adminToken string, suffix string) (taskConfig, error) { var envelope struct { Data taskConfig `json:"data"` } err := postJSON(ctx, adminBaseURL+"/api/v1/admin/activity/daily-tasks", adminToken, map[string]any{ "task_type": "daily", "category": "smoke", "metric_type": "recharge_coin", "title": "Codex真实链路Smoke-" + suffix, "description": "本地真实接口任务链路验证", "audience_type": "all", "icon_key": "coin", "icon_url": "https://media.haiyihy.com/admin/task-icon-coin.png", "action_type": "wallet", "action_param": "wallet", "action_payload_json": `{"entry":"wallet"}`, "dimension_filter_json": `{}`, "target_value": 100, "target_unit": "coin", "reward_coin_amount": 20, "status": "active", "sort_order": 1, "effective_from_ms": 0, "effective_to_ms": 0, }, &envelope) if err != nil { return taskConfig{}, err } if envelope.Data.TaskID == "" || envelope.Data.IconKey != "coin" || envelope.Data.ActionType != "wallet" { return taskConfig{}, fmt.Errorf("admin task response missing metadata: %+v", envelope.Data) } return envelope.Data, nil } type appUser struct { UserID string DisplayUserID string AccessToken string } func quickCreateUser(ctx context.Context, suffix string) (appUser, error) { var envelope struct { Data struct { Token struct { UserID string `json:"user_id"` DisplayUserID string `json:"display_user_id"` AccessToken string `json:"access_token"` } `json:"token"` } `json:"data"` } if err := postJSON(ctx, gatewayBaseURL+"/api/v1/auth/account/quick-create", "", map[string]any{ "password": "TaskFlow@123456", "device_id": "codex-task-smoke-" + suffix, "platform": "ios", "language": "zh-CN", "timezone": "Asia/Shanghai", }, &envelope); err != nil { return appUser{}, err } user := appUser{ UserID: envelope.Data.Token.UserID, DisplayUserID: envelope.Data.Token.DisplayUserID, AccessToken: envelope.Data.Token.AccessToken, } if user.UserID == "" || user.AccessToken == "" { return appUser{}, fmt.Errorf("quick create returned incomplete token: %+v", user) } return user, nil } type imUserSig struct { SDKAppID int64 `json:"sdk_app_id"` UserID string `json:"user_id"` UserSig string `json:"user_sig"` } func getIMUserSig(ctx context.Context, accessToken string) (imUserSig, error) { var envelope struct { Data imUserSig `json:"data"` } if err := getJSON(ctx, gatewayBaseURL+"/api/v1/im/usersig", accessToken, &envelope); err != nil { return imUserSig{}, err } if envelope.Data.SDKAppID == 0 || envelope.Data.UserID == "" || envelope.Data.UserSig == "" { return imUserSig{}, fmt.Errorf("im usersig response incomplete: %+v", envelope.Data) } return envelope.Data, nil } type taskEventResult struct { Status string MatchedTaskCount int32 } func consumeTaskEvent(ctx context.Context, taskID string, userID string, suffix string) (taskEventResult, error) { conn, err := grpc.NewClient(activityGRPCURL, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { return taskEventResult{}, err } defer conn.Close() resp, err := activityv1.NewTaskServiceClient(conn).ConsumeTaskEvent(ctx, &activityv1.ConsumeTaskEventRequest{ Meta: &activityv1.RequestMeta{ RequestId: "task-smoke-event-" + suffix, Caller: "task-system-smoke", AppCode: appCode, SentAtMs: time.Now().UTC().UnixMilli(), }, EventId: "task-smoke-event-" + taskID + "-" + suffix, EventType: "WalletRechargeRecorded", SourceService: "wallet-service", UserId: mustParseUserID(userID), MetricType: "recharge_coin", Value: 100, OccurredAtMs: time.Now().UTC().UnixMilli(), DimensionsJson: `{"source":"task-system-smoke"}`, }) if err != nil { return taskEventResult{}, err } return taskEventResult{Status: resp.GetStatus(), MatchedTaskCount: resp.GetMatchedTaskCount()}, nil } type taskItem struct { TaskID string `json:"task_id"` TaskType string `json:"task_type"` TaskDay string `json:"task_day"` Status string `json:"status"` ProgressValue int64 `json:"progress_value"` Claimable bool `json:"claimable"` IconKey string `json:"icon_key"` IconURL string `json:"icon_url"` ActionType string `json:"action_type"` ActionParam string `json:"action_param"` ActionPayload map[string]any `json:"action_payload"` } func getTaskItem(ctx context.Context, accessToken string, taskID string) (taskItem, error) { var envelope struct { Data struct { Sections []struct { Items []taskItem `json:"items"` } `json:"sections"` } `json:"data"` } if err := getJSON(ctx, gatewayBaseURL+"/api/v1/tasks/tabs", accessToken, &envelope); err != nil { return taskItem{}, err } for _, section := range envelope.Data.Sections { for _, item := range section.Items { if item.TaskID == taskID { if item.ActionPayload == nil { item.ActionPayload = map[string]any{} } return item, nil } } } return taskItem{}, fmt.Errorf("task %s not found in tabs response", taskID) } type claimResult struct { Status string `json:"status"` WalletTransactionID string `json:"wallet_transaction_id"` RewardCoinAmount int64 `json:"reward_coin_amount"` } func claimTask(ctx context.Context, accessToken string, item taskItem, suffix string) (claimResult, error) { var envelope struct { Data claimResult `json:"data"` } if err := postJSON(ctx, gatewayBaseURL+"/api/v1/tasks/claim", accessToken, map[string]any{ "task_id": item.TaskID, "task_type": item.TaskType, "task_day": item.TaskDay, "command_id": "cmd-task-smoke-claim-" + suffix, }, &envelope); err != nil { return claimResult{}, err } if envelope.Data.WalletTransactionID == "" { return claimResult{}, fmt.Errorf("claim returned empty wallet transaction: %+v", envelope.Data) } return envelope.Data, nil } func getJSON(ctx context.Context, url string, bearer string, out any) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return err } return doJSON(req, bearer, out) } func postJSON(ctx context.Context, url string, bearer string, payload any, out any) error { body, err := json.Marshal(payload) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") return doJSON(req, bearer, out) } func doJSON(req *http.Request, bearer string, out any) error { req.Header.Set("X-App-Code", appCode) req.Header.Set("X-Request-ID", "task-smoke-"+fmt.Sprintf("%d", time.Now().UnixNano())) if bearer != "" { req.Header.Set("Authorization", "Bearer "+bearer) } resp, err := http.DefaultClient.Do(req) if err != nil { return err } defer resp.Body.Close() data, err := io.ReadAll(resp.Body) if err != nil { return err } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("%s %s failed: status=%d body=%s", req.Method, req.URL.Path, resp.StatusCode, string(data)) } if err := json.Unmarshal(data, out); err != nil { return fmt.Errorf("decode %s %s failed: %w body=%s", req.Method, req.URL.Path, err, string(data)) } return nil } func mustParseUserID(value string) int64 { var userID int64 if _, err := fmt.Sscanf(strings.TrimSpace(value), "%d", &userID); err != nil || userID <= 0 { panic(fmt.Errorf("invalid user_id %q", value)) } return userID } func must[T any](value T, err error) T { if err != nil { panic(err) } return value }