499 lines
16 KiB
Go
499 lines
16 KiB
Go
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"`
|
||
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 := http.Header{}
|
||
if authorization != "" {
|
||
headers.Set("Authorization", authorization)
|
||
}
|
||
var resp InviteCode
|
||
if err := c.getJSON(ctx, endpoint, headers, &resp); err != nil {
|
||
return InviteCode{}, err
|
||
}
|
||
if strings.TrimSpace(resp.InviteCode) == "" {
|
||
return InviteCode{}, fmt.Errorf("invite code response is empty")
|
||
}
|
||
return resp, nil
|
||
}
|
||
|
||
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) 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)
|
||
}
|
||
}
|
||
}
|