353 lines
12 KiB
Go
353 lines
12 KiB
Go
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
|
||
}
|