194 lines
6.4 KiB
Go
194 lines
6.4 KiB
Go
package tiktok
|
|
|
|
// TikTokService provides high-level operations for the TikTok channel.
|
|
// Reference: TikTok Business API — https://business-api.tiktok.com/portal/docs
|
|
//
|
|
// This service coordinates:
|
|
// - ChannelTikTok model CRUD (via Repository)
|
|
// - TikTok Business API calls (OAuth, messaging, user profile)
|
|
// - Incoming/outgoing message processing
|
|
//
|
|
// Design: Service pattern follows gochat's channel service architecture
|
|
// (same as TelegramService, WhatsAppService).
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
channelmodel "github.com/gochat/gochat/internal/model/channel"
|
|
|
|
"github.com/go-resty/resty/v2"
|
|
"gorm.io/gorm"
|
|
applogger "github.com/gochat/gochat/pkg/logger"
|
|
)
|
|
|
|
// TikTokService handles TikTok channel business logic.
|
|
type TikTokService struct {
|
|
client *resty.Client
|
|
db *gorm.DB
|
|
repo *Repository
|
|
}
|
|
|
|
// NewTikTokService creates a new TikTok service with GORM DB and repository.
|
|
func NewTikTokService(repo *Repository) *TikTokService {
|
|
client := resty.New()
|
|
client.SetTimeout(30 * time.Second)
|
|
client.SetRetryCount(3)
|
|
client.SetRetryWaitTime(1 * time.Second)
|
|
return &TikTokService{
|
|
client: client,
|
|
db: repo.db,
|
|
repo: repo,
|
|
}
|
|
}
|
|
|
|
// === CRUD Operations ===
|
|
|
|
// CreateChannel creates a new TikTok channel.
|
|
// Flow: validate access_token → create ChannelTikTok → setup webhook → create Inbox
|
|
func (s *TikTokService) CreateChannel(ctx context.Context, accountID uint, accessToken, refreshToken, businessID string, expiresAt time.Time) (*channelmodel.ChannelTikTok, error) {
|
|
// Validate access token by calling TikTok user info API
|
|
if err := s.ValidateAccessToken(ctx, accessToken); err != nil {
|
|
return nil, fmt.Errorf("invalid TikTok access token: %w", err)
|
|
}
|
|
|
|
channel := &channelmodel.ChannelTikTok{
|
|
AccountID: accountID,
|
|
TikTokBusinessID: businessID,
|
|
AccessToken: accessToken,
|
|
RefreshToken: refreshToken,
|
|
TokenExpiresAt: expiresAt,
|
|
WebhookVerifyToken: fmt.Sprintf("tiktok_verify_%d_%d", accountID, time.Now().UnixNano()),
|
|
}
|
|
|
|
if err := s.repo.Create(ctx, channel); err != nil {
|
|
return nil, fmt.Errorf("failed to create TikTok channel: %w", err)
|
|
}
|
|
|
|
applogger.L().Infof("TikTok channel created: id=%d, account=%d, business_id=%s",
|
|
channel.ID, accountID, businessID)
|
|
return channel, nil
|
|
}
|
|
|
|
// UpdateChannel updates an existing TikTok channel.
|
|
func (s *TikTokService) UpdateChannel(ctx context.Context, channelID uint, updates map[string]interface{}) error {
|
|
if err := s.repo.UpdateFields(ctx, channelID, updates); err != nil {
|
|
return fmt.Errorf("failed to update TikTok channel %d: %w", channelID, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteChannel removes a TikTok channel.
|
|
func (s *TikTokService) DeleteChannel(ctx context.Context, channelID uint) error {
|
|
if err := s.repo.Delete(ctx, channelID); err != nil {
|
|
return fmt.Errorf("failed to delete TikTok channel %d: %w", channelID, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// === API Operations ===
|
|
|
|
// SendMessage sends a message to a TikTok user via the Business API.
|
|
// Reference: TikTok Business API POST /api/v2/message/send
|
|
func (s *TikTokService) SendMessage(ctx context.Context, accessToken, toUserID, content string) (*TikTokSendMessageResponse, error) {
|
|
resp, err := s.client.R().
|
|
SetContext(ctx).
|
|
SetAuthToken(accessToken).
|
|
SetBody(TikTokSendMessageRequest{
|
|
ToUserID: toUserID,
|
|
ContentType: "text",
|
|
Content: content,
|
|
}).
|
|
SetResult(TikTokSendMessageResponse{}).
|
|
Post("https://business-api.tiktok.com/portal/api/v2/message/send")
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("TikTok send message API call failed: %w", err)
|
|
}
|
|
|
|
result := resp.Result().(*TikTokSendMessageResponse)
|
|
if result.Code != 0 {
|
|
return nil, fmt.Errorf("TikTok send message API error: code=%d, message=%s", result.Code, result.Message)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// GetUserProfile fetches a TikTok user's profile information.
|
|
func (s *TikTokService) GetUserProfile(ctx context.Context, accessToken, userID string) (*TikTokUserProfile, error) {
|
|
resp, err := s.client.R().
|
|
SetContext(ctx).
|
|
SetAuthToken(accessToken).
|
|
SetQueryParam("open_id", userID).
|
|
SetResult(TikTokUserProfile{}).
|
|
Get("https://business-api.tiktok.com/portal/api/v2/user/info")
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("TikTok get user profile API call failed: %w", err)
|
|
}
|
|
|
|
return resp.Result().(*TikTokUserProfile), nil
|
|
}
|
|
|
|
// ValidateAccessToken checks if an access token is valid by calling a lightweight API.
|
|
func (s *TikTokService) ValidateAccessToken(ctx context.Context, accessToken string) error {
|
|
resp, err := s.client.R().
|
|
SetContext(ctx).
|
|
SetAuthToken(accessToken).
|
|
Get("https://business-api.tiktok.com/portal/api/v2/business/info")
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("TikTok token validation API call failed: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode() >= 400 {
|
|
return fmt.Errorf("TikTok token validation failed: HTTP %d", resp.StatusCode())
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RefreshOAuthToken refreshes an expired TikTok access token.
|
|
// Reference: TikTok Business API OAuth2 refresh token endpoint
|
|
func (s *TikTokService) RefreshOAuthToken(ctx context.Context, refreshToken, clientKey, clientSecret string) (*TikTokOAuthTokenResponse, error) {
|
|
resp, err := s.client.R().
|
|
SetContext(ctx).
|
|
SetBody(map[string]string{
|
|
"client_key": clientKey,
|
|
"client_secret": clientSecret,
|
|
"grant_type": "refresh_token",
|
|
"refresh_token": refreshToken,
|
|
}).
|
|
SetResult(TikTokOAuthTokenResponse{}).
|
|
Post("https://business-api.tiktok.com/portal/auth/token/refresh")
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("TikTok OAuth token refresh failed: %w", err)
|
|
}
|
|
|
|
result := resp.Result().(*TikTokOAuthTokenResponse)
|
|
if result.AccessToken == "" {
|
|
return nil, fmt.Errorf("TikTok OAuth token refresh returned empty access_token")
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// === Helper Methods ===
|
|
|
|
// GetChannelByID retrieves a TikTok channel by its database ID.
|
|
func (s *TikTokService) GetChannelByID(ctx context.Context, channelID uint) (*channelmodel.ChannelTikTok, error) {
|
|
return s.repo.GetByID(ctx, channelID)
|
|
}
|
|
|
|
// GetChannelByInboxID retrieves a TikTok channel by the associated inbox ID.
|
|
func (s *TikTokService) GetChannelByInboxID(ctx context.Context, inboxID uint) (*channelmodel.ChannelTikTok, error) {
|
|
return s.repo.GetByInboxID(ctx, inboxID)
|
|
}
|
|
|
|
// MarkReauthorizationRequired flags a channel as needing re-auth.
|
|
func (s *TikTokService) MarkReauthorizationRequired(ctx context.Context, channelID uint) error {
|
|
return s.UpdateChannel(ctx, channelID, map[string]interface{}{
|
|
"reauthorization_required": true,
|
|
})
|
|
} |