328 lines
9.8 KiB
Go
328 lines
9.8 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/redis/go-redis/v9"
|
|
"go.uber.org/zap"
|
|
"google.golang.org/grpc/codes"
|
|
|
|
appErrors "github.com/topfans/backend/pkg/errors"
|
|
"github.com/topfans/backend/pkg/logger"
|
|
"github.com/topfans/backend/pkg/models"
|
|
pb "github.com/topfans/backend/pkg/proto/asset"
|
|
pbCommon "github.com/topfans/backend/pkg/proto/common"
|
|
"github.com/topfans/backend/services/assetService/model"
|
|
"github.com/topfans/backend/services/assetService/repository"
|
|
"github.com/topfans/backend/services/assetService/util/qrcode"
|
|
)
|
|
|
|
// validSystemTypes spec § 3 枚举表
|
|
var validSystemTypes = map[string]struct{}{
|
|
"android": {},
|
|
"ios": {},
|
|
"h5": {},
|
|
"mp-weixin": {},
|
|
"mp-alipay": {},
|
|
"mp-baidu": {},
|
|
"mp-toutiao": {},
|
|
"mp-lark": {},
|
|
"mp-qq": {},
|
|
"mp-kuaishou": {},
|
|
"mp-xhs": {},
|
|
"app-plus": {},
|
|
"other": {},
|
|
}
|
|
|
|
// validResults spec § 3.5 结果枚举
|
|
var validResults = map[string]struct{}{
|
|
"success": {},
|
|
"cancel": {},
|
|
"fail_app_missing": {},
|
|
"fail_network": {},
|
|
"fail_canvas": {},
|
|
"fail_other": {},
|
|
"fail_permission": {},
|
|
"fail_already_saved": {},
|
|
}
|
|
|
|
// validShareTargets spec § 3 分享目标枚举
|
|
//
|
|
// 包含前端"复制链接"专用值 `copy_link`:
|
|
// - GetAssetQrcode: 前端调用方一般不传(QR 接口主要服务图形分享),
|
|
// 但保留兼容,允许空字符串或 copy_link 命中校验。
|
|
// - TrackShare: 前端 copyLink() 走 trackShare 埋点时上报 copy_link,
|
|
// 这样"复制链接"也能进入分享统计,与图形分享并列归因。
|
|
//
|
|
// 同步点:与 frontend/utils/constants.js SHARE_TARGETS 保持一致。
|
|
var validShareTargets = map[string]struct{}{
|
|
"weixin_friend": {},
|
|
"weixin_moment": {},
|
|
"qq": {},
|
|
"qq_zone": {},
|
|
"sinaweibo": {},
|
|
"save_image": {},
|
|
"copy_link": {},
|
|
}
|
|
|
|
const (
|
|
qrcodeCacheTTL = 7 * 24 * time.Hour
|
|
qrcodeCacheKey = "share:qrcode:%d:%d:%s"
|
|
)
|
|
|
|
// QRUploader 上传 PNG 到 OSS/CDN 的接口(便于测试 mock)
|
|
//
|
|
// 真实实现见 util/ossutil 包。
|
|
type QRUploader interface {
|
|
UploadBytes(ctx context.Context, key string, data []byte, contentType string) (string, error)
|
|
}
|
|
|
|
// ShareRepository 分享数据访问抽象(便于测试 mock)
|
|
//
|
|
// 生产实现 *repository.ShareRepo 满足该接口;测试可注入内存实现。
|
|
type ShareRepository interface {
|
|
Create(ctx context.Context, e *model.ShareEvent) (int64, error)
|
|
AssetExists(ctx context.Context, assetID int64) (bool, error)
|
|
UserExists(ctx context.Context, userID int64) (bool, error)
|
|
}
|
|
|
|
// ShareService 分享业务逻辑(spec § 3 + § 3.5)
|
|
//
|
|
// 负责:
|
|
// - GetAssetQrcode: 生成/缓存 资产分享二维码 PNG,返回 CDN URL
|
|
// - TrackShare: 落库一次分享动作(用于归因/防刷分析)
|
|
type ShareService struct {
|
|
repo ShareRepository
|
|
redis *redis.Client
|
|
uploader QRUploader
|
|
landingBase string
|
|
}
|
|
|
|
// NewShareService 创建 ShareService 实例
|
|
//
|
|
// repo 入参类型保持 *repository.ShareRepo 以兼容调用方;
|
|
// 内部以 ShareRepository 接口存储,便于单测注入 mock 实现。
|
|
func NewShareService(repo *repository.ShareRepo, redisClient *redis.Client, uploader QRUploader, landingBase string) *ShareService {
|
|
return &ShareService{
|
|
repo: repo,
|
|
redis: redisClient,
|
|
uploader: uploader,
|
|
landingBase: landingBase,
|
|
}
|
|
}
|
|
|
|
// newShareServiceWithDeps 测试/扩展入口,直接注入 ShareRepository 抽象
|
|
func newShareServiceWithDeps(repo ShareRepository, redisClient *redis.Client, uploader QRUploader, landingBase string) *ShareService {
|
|
return &ShareService{
|
|
repo: repo,
|
|
redis: redisClient,
|
|
uploader: uploader,
|
|
landingBase: landingBase,
|
|
}
|
|
}
|
|
|
|
// GetAssetQrcode 生成或获取缓存的二维码 CDN URL(spec § 3)
|
|
//
|
|
// 流程:
|
|
// 1. 校验入参(sharer_user_id / system_type / share_target)
|
|
// 2. 校验 asset 存在、user 存在
|
|
// 3. 组装落地页 URL(landingBase + asset_id + from=sharer + s=system_type)
|
|
// 4. 命中 Redis 缓存直接返回
|
|
// 5. 生成 PNG -> 上传 OSS -> 写回 Redis(TTL 7d)
|
|
func (s *ShareService) GetAssetQrcode(ctx context.Context, req *pb.GetAssetQrcodeRequest) (*pb.GetAssetQrcodeResponse, error) {
|
|
if req == nil {
|
|
return nil, fmt.Errorf("request is nil")
|
|
}
|
|
if req.SharerUserId == 0 {
|
|
logger.Logger.Warn("GetAssetQrcode missing sharer_user_id",
|
|
zap.Int64("asset_id", req.AssetId),
|
|
)
|
|
return nil, fmt.Errorf("%w: sharer_user_id is required", appErrors.ErrInvalidUserID)
|
|
}
|
|
if req.SystemType == "" {
|
|
return nil, appErrors.ErrInvalidSystemType
|
|
}
|
|
if _, ok := validSystemTypes[req.SystemType]; !ok {
|
|
return nil, fmt.Errorf("%w: unsupported system_type=%q", appErrors.ErrInvalidSystemType, req.SystemType)
|
|
}
|
|
if req.ShareTarget != "" {
|
|
if _, ok := validShareTargets[req.ShareTarget]; !ok {
|
|
return nil, fmt.Errorf("%w: unsupported share_target=%q", appErrors.ErrInvalidShareTarget, req.ShareTarget)
|
|
}
|
|
}
|
|
if req.AssetId <= 0 {
|
|
return nil, fmt.Errorf("%w: invalid asset_id=%d", appErrors.ErrAssetNotFound, req.AssetId)
|
|
}
|
|
|
|
exists, err := s.repo.AssetExists(ctx, req.AssetId)
|
|
if err != nil {
|
|
logger.Logger.Error("GetAssetQrcode check asset failed",
|
|
zap.Int64("asset_id", req.AssetId),
|
|
zap.Error(err),
|
|
)
|
|
return nil, fmt.Errorf("check asset: %w", err)
|
|
}
|
|
if !exists {
|
|
return nil, appErrors.ErrAssetNotFound
|
|
}
|
|
|
|
userOK, err := s.repo.UserExists(ctx, req.SharerUserId)
|
|
if err != nil {
|
|
logger.Logger.Error("GetAssetQrcode check user failed",
|
|
zap.Int64("sharer_user_id", req.SharerUserId),
|
|
zap.Error(err),
|
|
)
|
|
return nil, fmt.Errorf("check user: %w", err)
|
|
}
|
|
if !userOK {
|
|
return nil, appErrors.ErrUserNotFound
|
|
}
|
|
|
|
landingURL := fmt.Sprintf("%s/asset/%d?from=%d&s=%s",
|
|
s.landingBase, req.AssetId, req.SharerUserId, req.SystemType)
|
|
|
|
cacheKey := fmt.Sprintf(qrcodeCacheKey, req.AssetId, req.SharerUserId, req.SystemType)
|
|
if s.redis != nil {
|
|
if cached, cerr := s.redis.Get(ctx, cacheKey).Result(); cerr == nil && cached != "" {
|
|
logger.Logger.Debug("GetAssetQrcode cache hit",
|
|
zap.String("cache_key", cacheKey),
|
|
zap.String("qrcode_url", cached),
|
|
)
|
|
return &pb.GetAssetQrcodeResponse{
|
|
Base: successResp(),
|
|
QrcodeUrl: cached,
|
|
ExpiresAt: time.Now().Add(qrcodeCacheTTL).Unix(),
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
pngBytes, err := qrcode.Generate(landingURL, 512)
|
|
if err != nil {
|
|
logger.Logger.Error("GetAssetQrcode generate qrcode failed",
|
|
zap.String("landing_url", landingURL),
|
|
zap.Error(err),
|
|
)
|
|
return nil, fmt.Errorf("generate qrcode: %w", err)
|
|
}
|
|
|
|
ossKey := fmt.Sprintf("share/qrcode/%d_%d_%s.png", req.AssetId, req.SharerUserId, req.SystemType)
|
|
cdnURL, err := s.uploader.UploadBytes(ctx, ossKey, pngBytes, "image/png")
|
|
if err != nil {
|
|
logger.Logger.Error("GetAssetQrcode upload qrcode failed",
|
|
zap.String("oss_key", ossKey),
|
|
zap.Error(err),
|
|
)
|
|
return nil, fmt.Errorf("upload qrcode: %w", err)
|
|
}
|
|
|
|
if s.redis != nil {
|
|
if setErr := s.redis.Set(ctx, cacheKey, cdnURL, qrcodeCacheTTL).Err(); setErr != nil {
|
|
// 缓存写失败不阻塞主流程,记 warn
|
|
logger.Logger.Warn("GetAssetQrcode cache set failed",
|
|
zap.String("cache_key", cacheKey),
|
|
zap.Error(setErr),
|
|
)
|
|
}
|
|
}
|
|
|
|
logger.Logger.Info("GetAssetQrcode successful",
|
|
zap.Int64("asset_id", req.AssetId),
|
|
zap.Int64("sharer_user_id", req.SharerUserId),
|
|
zap.String("system_type", req.SystemType),
|
|
zap.String("qrcode_url", cdnURL),
|
|
)
|
|
|
|
return &pb.GetAssetQrcodeResponse{
|
|
Base: successResp(),
|
|
QrcodeUrl: cdnURL,
|
|
ExpiresAt: time.Now().Add(qrcodeCacheTTL).Unix(),
|
|
}, nil
|
|
}
|
|
|
|
// TrackShare 记录一次分享动作(spec § 3.5)
|
|
func (s *ShareService) TrackShare(ctx context.Context, req *pb.TrackShareRequest) (*pb.TrackShareResponse, error) {
|
|
if req == nil {
|
|
return nil, fmt.Errorf("request is nil")
|
|
}
|
|
if req.SharerUserId == 0 {
|
|
return nil, fmt.Errorf("%w: sharer_user_id is required", appErrors.ErrInvalidUserID)
|
|
}
|
|
if _, ok := validSystemTypes[req.SystemType]; !ok {
|
|
return nil, appErrors.ErrInvalidSystemType
|
|
}
|
|
if _, ok := validShareTargets[req.ShareTarget]; !ok {
|
|
return nil, appErrors.ErrInvalidShareTarget
|
|
}
|
|
if _, ok := validResults[req.Result]; !ok {
|
|
return nil, appErrors.ErrInvalidShareResult
|
|
}
|
|
if req.AssetId <= 0 {
|
|
return nil, fmt.Errorf("%w: invalid asset_id=%d", appErrors.ErrAssetNotFound, req.AssetId)
|
|
}
|
|
|
|
userOK, err := s.repo.UserExists(ctx, req.SharerUserId)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("check user: %w", err)
|
|
}
|
|
if !userOK {
|
|
return nil, appErrors.ErrUserNotFound
|
|
}
|
|
|
|
e := &model.ShareEvent{
|
|
AssetID: req.AssetId,
|
|
SharerUserID: req.SharerUserId,
|
|
SystemType: req.SystemType,
|
|
ShareTarget: req.ShareTarget,
|
|
Result: req.Result,
|
|
ClientTs: req.ClientTs,
|
|
ServerTs: time.Now().UnixMilli(),
|
|
}
|
|
if req.Extra != nil {
|
|
if raw, mErr := json.Marshal(req.Extra.AsMap()); mErr == nil {
|
|
e.Extra = models.JSONB(raw)
|
|
} else {
|
|
logger.Logger.Warn("TrackShare marshal extra failed, store empty",
|
|
zap.Error(mErr),
|
|
)
|
|
e.Extra = models.JSONB("{}")
|
|
}
|
|
}
|
|
|
|
id, err := s.repo.Create(ctx, e)
|
|
if err != nil {
|
|
logger.Logger.Error("TrackShare create event failed",
|
|
zap.Int64("asset_id", req.AssetId),
|
|
zap.Int64("sharer_user_id", req.SharerUserId),
|
|
zap.String("result", req.Result),
|
|
zap.Error(err),
|
|
)
|
|
return nil, fmt.Errorf("create share event: %w", err)
|
|
}
|
|
|
|
logger.Logger.Info("TrackShare successful",
|
|
zap.Int64("share_event_id", id),
|
|
zap.Int64("asset_id", req.AssetId),
|
|
zap.Int64("sharer_user_id", req.SharerUserId),
|
|
zap.String("system_type", req.SystemType),
|
|
zap.String("share_target", req.ShareTarget),
|
|
zap.String("result", req.Result),
|
|
)
|
|
|
|
return &pb.TrackShareResponse{
|
|
Base: successResp(),
|
|
ShareEventId: id,
|
|
}, nil
|
|
}
|
|
|
|
// successResp 返回成功的 BaseResponse 助手
|
|
func successResp() *pbCommon.BaseResponse {
|
|
return &pbCommon.BaseResponse{
|
|
Code: uint32(codes.OK),
|
|
Message: "ok",
|
|
Timestamp: time.Now().UnixMilli(),
|
|
}
|
|
}
|