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