2026-06-11 13:33:44 +08:00

353 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}