package integration import ( "bytes" "chatapp3-golang/internal/config" "context" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" ) type Client struct { cfg config.Config httpClient *http.Client } type UserCredential struct { UserID Int64Value `json:"userId"` SysOrigin string `json:"sysOrigin"` ExpireTime Int64Value `json:"expireTime"` ReleaseTime Int64Value `json:"releaseTime"` Version string `json:"version"` Sign string `json:"sign"` } type ConsoleAccount struct { ID Int64Value `json:"id"` LoginName string `json:"loginName"` Nickname string `json:"nickname"` Token string `json:"token"` } type UserProfile struct { ID Int64Value `json:"id"` Account string `json:"account"` UserAvatar string `json:"userAvatar"` UserNickname string `json:"userNickname"` } type GoldReceiptCommand struct { ReceiptType string `json:"receiptType"` UserID int64 `json:"userId"` SysOrigin string `json:"sysOrigin"` EventID string `json:"eventId"` Remark string `json:"remark,omitempty"` Amount PennyAmountPayload `json:"amount"` CloseDelayAsset bool `json:"closeDelayAsset"` OpUserType string `json:"opUserType"` CustomizeOrigin string `json:"customizeOrigin,omitempty"` CustomizeOriginDesc string `json:"customizeOriginDesc,omitempty"` } type PennyAmountPayload struct { PennyAmount int64 `json:"pennyAmount,string"` DollarAmount int64 `json:"dollarAmount"` } type TestGoldReceiptCommand struct { ReceiptType string `json:"receiptType"` UserID int64 `json:"userId"` EventType string `json:"eventType"` EventDesc string `json:"eventDesc"` EventID string `json:"eventId"` SysOrigin string `json:"sysOrigin"` Remark string `json:"remark,omitempty"` Amount int64 `json:"amount"` CloseDelayAsset bool `json:"closeDelayAsset"` } type InviteCode struct { UserID Int64Value `json:"userId"` InviteCode string `json:"inviteCode"` HasBound bool `json:"hasBound"` } type GrantGoldRequest struct { UserID int64 `json:"userId"` SysOrigin string `json:"sysOrigin"` EventID string `json:"eventId"` Origin string `json:"origin"` GoldAmount int64 `json:"goldAmount"` Remark string `json:"remark"` } type GrantPropsRequest struct { TrackID int64 `json:"trackId"` AcceptUserID int64 `json:"acceptUserId"` SysOrigin string `json:"sysOrigin"` Origin string `json:"origin"` SourceGroupID int64 `json:"sourceGroupId"` } type SendActivityRewardRequest struct { TrackID int64 `json:"trackId"` Origin string `json:"origin"` SysOrigin string `json:"sysOrigin"` SourceGroupID int64 `json:"sourceGroupId"` AcceptUserID int64 `json:"acceptUserId"` } // OfficialNoticeCustomizeRequest 表示发送自定义官方通知的请求体。 type OfficialNoticeCustomizeRequest struct { ToAccounts []int64 `json:"toAccounts"` LangCode string `json:"langCode,omitempty"` NoticeType string `json:"noticeType"` Expand any `json:"expand,omitempty"` Title string `json:"title"` Content string `json:"content"` } type RewardGroupItem struct { ID Int64Value `json:"id"` Type string `json:"type"` Name string `json:"name"` Content string `json:"content"` Quantity Int64Value `json:"quantity"` Cover string `json:"cover"` Remark string `json:"remark"` } type RewardGroupDetail struct { ID Int64Value `json:"id"` Name string `json:"name"` SysOrigin string `json:"sysOrigin"` RewardConfigList []RewardGroupItem `json:"rewardConfigList"` } type resultResponse[T any] struct { Code int `json:"code"` Message string `json:"message"` Body T `json:"body"` Success *bool `json:"success"` } type Int64Value int64 func (v *Int64Value) UnmarshalJSON(data []byte) error { raw := strings.TrimSpace(string(data)) if raw == "" || raw == "null" { *v = 0 return nil } if strings.HasPrefix(raw, "\"") && strings.HasSuffix(raw, "\"") { raw = strings.Trim(raw, "\"") } parsed, err := strconv.ParseInt(raw, 10, 64) if err != nil { return err } *v = Int64Value(parsed) return nil } func New(cfg config.Config) *Client { return &Client{ cfg: cfg, httpClient: &http.Client{ Timeout: cfg.HTTP.Timeout, }, } } func (c *Client) AuthenticateToken(ctx context.Context, token string) (UserCredential, error) { tokenCredential, err := parseUserCredentialToken(token) if err != nil { return UserCredential{}, err } endpoint := c.cfg.Java.AuthBaseURL + "/auth/client/getUserCredential?userId=" + url.QueryEscape(strconv.FormatInt(int64(tokenCredential.UserID), 10)) var resp resultResponse[UserCredential] if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil { return UserCredential{}, err } if int64(resp.Body.UserID) == 0 { return UserCredential{}, fmt.Errorf("empty credential response") } if strings.TrimSpace(resp.Body.Sign) == "" { return UserCredential{}, fmt.Errorf("empty credential sign") } if !strings.EqualFold(strings.TrimSpace(resp.Body.Sign), strings.TrimSpace(tokenCredential.Sign)) { return UserCredential{}, fmt.Errorf("credential sign mismatch") } if strings.TrimSpace(resp.Body.SysOrigin) != "" && !strings.EqualFold(resp.Body.SysOrigin, tokenCredential.SysOrigin) { return UserCredential{}, fmt.Errorf("credential sysOrigin mismatch") } return tokenCredential, nil } func (c *Client) CreateIfAbsentUserCredentialToken(ctx context.Context, userID int64) (string, error) { endpoint := c.cfg.Java.AuthBaseURL + "/auth/client/createIfAbsentUserCredentialToken?userId=" + url.QueryEscape(strconv.FormatInt(userID, 10)) var resp resultResponse[string] if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil { return "", err } if strings.TrimSpace(resp.Body) == "" { return "", fmt.Errorf("empty token response") } return strings.TrimSpace(resp.Body), nil } // AuthenticateConsoleToken 校验后台 console Bearer token,并返回管理员基础信息。 func (c *Client) AuthenticateConsoleToken(ctx context.Context, authorization string) (ConsoleAccount, error) { authorization = strings.TrimSpace(authorization) if authorization == "" { return ConsoleAccount{}, fmt.Errorf("authorization is blank") } endpoint := strings.TrimRight(c.cfg.Java.ConsoleBaseURL, "/") + "/account/info" headers := http.Header{} headers.Set("Authorization", authorization) var resp resultResponse[ConsoleAccount] if err := c.getJSON(ctx, endpoint, headers, &resp); err != nil { return ConsoleAccount{}, err } if int64(resp.Body.ID) == 0 { return ConsoleAccount{}, fmt.Errorf("empty console account response") } return resp.Body, nil } func parseUserCredentialToken(token string) (UserCredential, error) { token = strings.TrimSpace(token) if token == "" { return UserCredential{}, fmt.Errorf("token is blank") } parts := strings.SplitN(token, ".", 2) if len(parts) != 2 { return UserCredential{}, fmt.Errorf("invalid token format") } sign := strings.TrimSpace(parts[0]) if sign == "" { return UserCredential{}, fmt.Errorf("invalid token sign") } decoded, err := base64.StdEncoding.DecodeString(parts[1]) if err != nil { return UserCredential{}, fmt.Errorf("decode token payload: %w", err) } payload, err := url.QueryUnescape(string(decoded)) if err != nil { return UserCredential{}, fmt.Errorf("unescape token payload: %w", err) } items := strings.Split(payload, ":") if len(items) != 5 { return UserCredential{}, fmt.Errorf("invalid token payload") } userID, err := strconv.ParseInt(strings.TrimSpace(items[1]), 10, 64) if err != nil { return UserCredential{}, fmt.Errorf("invalid token userId: %w", err) } expireTime, err := strconv.ParseInt(strings.TrimSpace(items[3]), 10, 64) if err != nil { return UserCredential{}, fmt.Errorf("invalid token expireTime: %w", err) } releaseTime, err := strconv.ParseInt(strings.TrimSpace(items[4]), 10, 64) if err != nil { return UserCredential{}, fmt.Errorf("invalid token releaseTime: %w", err) } if expireTime <= time.Now().UnixMilli() { return UserCredential{}, fmt.Errorf("token expired") } return UserCredential{ UserID: Int64Value(userID), SysOrigin: strings.TrimSpace(items[2]), ExpireTime: Int64Value(expireTime), ReleaseTime: Int64Value(releaseTime), Version: strings.TrimSpace(items[0]), Sign: sign, }, nil } func (c *Client) GetDeviceFingerprint(ctx context.Context, userID int64) (string, error) { endpoint := c.cfg.Java.DeviceBaseURL + "/user/device/fingerprint?userId=" + url.QueryEscape(strconv.FormatInt(userID, 10)) var resp resultResponse[string] if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil { return "", err } return strings.TrimSpace(resp.Body), nil } func (c *Client) EnsureInviteCode(ctx context.Context, authorization string) (InviteCode, error) { endpoint := c.cfg.Java.AppBaseURL + "/activity/invite/user/my/invite/code" headers := javaAppHeadersFromAuthorization(authorization) if authorization != "" { headers.Set("Authorization", authorization) } var resp resultResponse[InviteCode] if err := c.getJSON(ctx, endpoint, headers, &resp); err != nil { return InviteCode{}, err } if strings.TrimSpace(resp.Body.InviteCode) == "" { return InviteCode{}, fmt.Errorf("invite code response is empty") } return resp.Body, nil } func javaAppHeadersFromAuthorization(authorization string) http.Header { headers := http.Header{} token := trimBearerToken(authorization) sysOrigin := "LIKEI" var userID int64 if credential, err := parseUserCredentialToken(token); err == nil { userID = int64(credential.UserID) if strings.TrimSpace(credential.SysOrigin) != "" { sysOrigin = strings.TrimSpace(credential.SysOrigin) } } headers.Set("Req-Sys-Origin", "origin="+sysOrigin+";originChild="+sysOrigin) headers.Set("Req-Client", "Android") headers.Set("Req-App-Intel", "version=1.0.0;build=100;channel=local;model=go-service;sysVersion=go") headers.Set("Req-Zone", "Asia/Shanghai") headers.Set("Req-Imei", "go-service") if userID > 0 { headers.Set("Req-Inner-Internal", fmt.Sprintf("time=%d;uid=%d;", time.Now().UnixMilli(), userID)) } else { headers.Set("Req-Inner-Internal", fmt.Sprintf("time=%d;", time.Now().UnixMilli())) } return headers } func trimBearerToken(authorization string) string { token := strings.TrimSpace(authorization) if len(token) >= 7 && strings.EqualFold(token[:7], "Bearer ") { return strings.TrimSpace(token[7:]) } return token } func (c *Client) GrantGold(ctx context.Context, req GrantGoldRequest) error { return c.postJSON(ctx, c.rewardBridgeBaseURL()+"/internal/resident-activity/invite/grant-gold", req, nil, nil) } func (c *Client) GrantProps(ctx context.Context, req GrantPropsRequest) error { return c.postJSON(ctx, c.rewardBridgeBaseURL()+"/internal/resident-activity/invite/grant-props", req, nil, nil) } func (c *Client) SendActivityReward(ctx context.Context, req SendActivityRewardRequest) error { return c.postJSON(ctx, c.cfg.Java.OtherBaseURL+"/props-activity-cnf/client/sendActivityReward", req, nil, nil) } // SendOfficialNoticeCustomize 调用 external 服务发送自定义官方通知。 func (c *Client) SendOfficialNoticeCustomize(ctx context.Context, req OfficialNoticeCustomizeRequest) error { return c.postJSON(ctx, c.cfg.Java.ExternalBaseURL+"/official-notice/client/send/customize/template", req, nil, nil) } func (c *Client) GetRewardGroupDetail(ctx context.Context, groupID int64) (RewardGroupDetail, error) { endpoint := c.cfg.Java.OtherBaseURL + "/props-activity/reward/group/client/getByGroupId?id=" + url.QueryEscape(strconv.FormatInt(groupID, 10)) var resp resultResponse[RewardGroupDetail] if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil { return RewardGroupDetail{}, err } if int64(resp.Body.ID) == 0 { return RewardGroupDetail{}, fmt.Errorf("empty reward group response") } return resp.Body, nil } func (c *Client) GetUserProfile(ctx context.Context, userID int64) (UserProfile, error) { endpoint := c.cfg.Java.OtherBaseURL + "/user-profile/client/getByUserId?userId=" + url.QueryEscape(strconv.FormatInt(userID, 10)) var resp resultResponse[UserProfile] if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil { return UserProfile{}, err } if int64(resp.Body.ID) == 0 { return UserProfile{}, fmt.Errorf("empty user profile response") } return resp.Body, nil } func (c *Client) GetUserProfileByAccount(ctx context.Context, sysOrigin, account string) (UserProfile, error) { endpoint := c.cfg.Java.OtherBaseURL + "/user-profile/client/getByAccount?sysOrigin=" + url.QueryEscape(strings.TrimSpace(sysOrigin)) + "&account=" + url.QueryEscape(strings.TrimSpace(account)) var resp resultResponse[UserProfile] if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil { return UserProfile{}, err } if int64(resp.Body.ID) == 0 { return UserProfile{}, fmt.Errorf("empty user profile response") } return resp.Body, nil } func (c *Client) GetUserProfileByAccountOne(ctx context.Context, account string) (UserProfile, error) { endpoint := c.cfg.Java.OtherBaseURL + "/user-profile/client/getByAccountOne?account=" + url.QueryEscape(strings.TrimSpace(account)) var resp resultResponse[UserProfile] if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil { return UserProfile{}, err } if int64(resp.Body.ID) == 0 { return UserProfile{}, fmt.Errorf("empty user profile response") } return resp.Body, nil } func (c *Client) rewardBridgeBaseURL() string { if baseURL := strings.TrimRight(strings.TrimSpace(c.cfg.Java.ConsoleBaseURL), "/"); baseURL != "" { return baseURL } return strings.TrimRight(strings.TrimSpace(c.cfg.Java.BridgeBaseURL), "/") } func (c *Client) GetUserLanguage(ctx context.Context, userID int64) (string, error) { endpoint := c.cfg.Java.OtherBaseURL + "/user-profile/client/getLanguage?userId=" + url.QueryEscape(strconv.FormatInt(userID, 10)) var resp resultResponse[string] if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil { return "", err } return strings.TrimSpace(resp.Body), nil } func (c *Client) MapGoldBalance(ctx context.Context, userIDs []int64) (map[int64]int64, error) { endpoint := c.cfg.Java.WalletBaseURL + "/wallet/gold/client/mapBalance" var resp resultResponse[map[string]int64] if err := c.postJSON(ctx, endpoint, userIDs, nil, &resp); err != nil { return nil, err } result := make(map[int64]int64, len(resp.Body)) for key, value := range resp.Body { userID, err := strconv.ParseInt(key, 10, 64) if err != nil { continue } result[userID] = value } return result, nil } func (c *Client) ExistsGoldEvent(ctx context.Context, eventID string) (bool, error) { endpoint := c.cfg.Java.WalletBaseURL + "/wallet/gold/client/existsEventId?eventId=" + url.QueryEscape(eventID) var resp resultResponse[bool] if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil { return false, err } return resp.Body, nil } func (c *Client) ChangeGoldBalance(ctx context.Context, cmd GoldReceiptCommand) error { endpoint := c.cfg.Java.WalletBaseURL + "/wallet/gold/client/balance/change" if err := c.postJSON(ctx, endpoint, cmd, nil, nil); err == nil { return nil } else if !shouldFallbackToGoldTestEndpoint(err) { return err } // Some local environments only expose the lighter test endpoint payload. testCmd := TestGoldReceiptCommand{ ReceiptType: cmd.ReceiptType, UserID: cmd.UserID, EventType: cmd.CustomizeOrigin, EventDesc: cmd.CustomizeOriginDesc, EventID: cmd.EventID, SysOrigin: cmd.SysOrigin, Remark: cmd.Remark, Amount: cmd.Amount.DollarAmount, CloseDelayAsset: cmd.CloseDelayAsset, } return c.postJSON(ctx, c.cfg.Java.WalletBaseURL+"/wallet/gold/client/balance/change/test", testCmd, nil, nil) } func NewPennyAmountPayloadFromDollar(amount int64) PennyAmountPayload { return PennyAmountPayload{ PennyAmount: amount * 100, DollarAmount: amount, } } func shouldFallbackToGoldTestEndpoint(err error) bool { if err == nil { return false } message := strings.ToLower(strings.TrimSpace(err.Error())) switch { case strings.Contains(message, "status=404"), strings.Contains(message, "status=405"), strings.Contains(message, "status=501"), strings.Contains(message, "connection refused"), strings.Contains(message, "no such host"), strings.Contains(message, "unsupported protocol"): return true default: return false } } func (c *Client) getJSON(ctx context.Context, endpoint string, headers http.Header, out any) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return err } applyHeaders(req.Header, headers) resp, err := c.httpClient.Do(req) if err != nil { return err } defer resp.Body.Close() return decodeResponse(resp, out) } func (c *Client) postJSON(ctx context.Context, endpoint string, payload any, headers http.Header, out any) error { data, err := json.Marshal(payload) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(data)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") applyHeaders(req.Header, headers) resp, err := c.httpClient.Do(req) if err != nil { return err } defer resp.Body.Close() return decodeResponse(resp, out) } func decodeResponse(resp *http.Response, out any) error { body, err := io.ReadAll(resp.Body) if err != nil { return err } if resp.StatusCode >= http.StatusBadRequest { return fmt.Errorf("java api failed: status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(body))) } if out == nil || len(body) == 0 { return nil } if err := json.Unmarshal(body, out); err != nil { return fmt.Errorf("decode response failed: %w body=%s", err, strings.TrimSpace(string(body))) } return nil } func applyHeaders(dst http.Header, src http.Header) { for key, values := range src { for _, value := range values { dst.Add(key, value) } } }