Files
gochat/internal/channel/tiktok/service.go
T
2026-06-04 15:44:48 +08:00

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,
})
}