package gamemanagement import ( "bytes" "context" "fmt" "io" "net/http" "net/url" "strings" "time" "hyapp-admin-server/internal/platform/idgen" ) const ( robotAvatarDownloadTimeout = 10 * time.Second robotAvatarMaxBytes = 5 << 20 robotAvatarSniffBytes = 512 ) // ObjectUploader 是游戏管理模块需要的对象存储最小能力,实际生产实现由后台 COS 平台层提供。 type ObjectUploader interface { PutObject(ctx context.Context, key string, reader io.Reader, sizeBytes int64, contentType string) (string, error) } type robotAvatarConfig struct { uploader ObjectUploader objectPrefix string httpClient *http.Client } func defaultRobotAvatarConfig() robotAvatarConfig { return robotAvatarConfig{ objectPrefix: "admin", httpClient: &http.Client{Timeout: robotAvatarDownloadTimeout}, } } // WithRobotAvatarUploader 注入后台已装配的 COS 上传器,机器人创建只拿这个薄接口,不直接依赖 COS SDK。 func WithRobotAvatarUploader(uploader ObjectUploader, objectPrefix string) Option { return func(h *Handler) { h.robotAvatar.uploader = uploader h.robotAvatar.objectPrefix = cleanRobotAvatarObjectPrefix(objectPrefix) } } // WithRobotAvatarHTTPClient 只给测试替换下载客户端;生产默认使用带超时的标准 HTTP client。 func WithRobotAvatarHTTPClient(client *http.Client) Option { return func(h *Handler) { if client != nil { h.robotAvatar.httpClient = client } } } func (h *Handler) uploadRobotAvatarFromSource(ctx context.Context, actorID uint, sourceURL string) (string, error) { sourceURL = strings.TrimSpace(sourceURL) if sourceURL == "" { return "", fmt.Errorf("robot avatar source url is empty") } if h.robotAvatar.uploader == nil { return "", fmt.Errorf("robot avatar cos uploader is not configured") } data, contentType, err := h.downloadRobotAvatar(ctx, sourceURL) if err != nil { return "", err } // 机器人头像先进入后台 COS 前缀,再写入用户资料;App 端只依赖自有 CDN URL,不直接依赖第三方头像站。 key := buildRobotAvatarObjectKey(h.robotAvatar.objectPrefix, actorID, contentType, time.Now().UTC()) return h.robotAvatar.uploader.PutObject(ctx, key, bytes.NewReader(data), int64(len(data)), contentType) } func (h *Handler) downloadRobotAvatar(ctx context.Context, sourceURL string) ([]byte, string, error) { if !validRobotAvatarSourceURL(sourceURL) { return nil, "", fmt.Errorf("robot avatar source url is invalid") } client := h.robotAvatar.httpClient if client == nil { client = &http.Client{Timeout: robotAvatarDownloadTimeout} } req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil) if err != nil { return nil, "", fmt.Errorf("build robot avatar request: %w", err) } req.Header.Set("User-Agent", "hyapp-admin-server/robot-avatar") resp, err := client.Do(req) if err != nil { return nil, "", fmt.Errorf("download robot avatar: %w", err) } defer resp.Body.Close() if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { return nil, "", fmt.Errorf("download robot avatar returned status %d", resp.StatusCode) } if resp.ContentLength > robotAvatarMaxBytes { return nil, "", fmt.Errorf("robot avatar is too large") } data, err := io.ReadAll(io.LimitReader(resp.Body, robotAvatarMaxBytes+1)) if err != nil { return nil, "", fmt.Errorf("read robot avatar: %w", err) } if len(data) == 0 || len(data) > robotAvatarMaxBytes { return nil, "", fmt.Errorf("robot avatar size is invalid") } sniffSize := len(data) if sniffSize > robotAvatarSniffBytes { sniffSize = robotAvatarSniffBytes } contentType, ok := normalizeRobotAvatarContentType(http.DetectContentType(data[:sniffSize]), resp.Header.Get("Content-Type")) if !ok { return nil, "", fmt.Errorf("robot avatar content type is unsupported") } return data, contentType, nil } func validRobotAvatarSourceURL(raw string) bool { u, err := url.ParseRequestURI(strings.TrimSpace(raw)) if err != nil { return false } return (u.Scheme == "https" || u.Scheme == "http") && strings.TrimSpace(u.Host) != "" } func normalizeRobotAvatarContentType(values ...string) (string, bool) { for _, value := range values { contentType := strings.ToLower(strings.TrimSpace(strings.Split(value, ";")[0])) switch contentType { case "image/jpeg", "image/jpg": return "image/jpeg", true case "image/png": return "image/png", true case "image/webp": return "image/webp", true } } return "", false } func buildRobotAvatarObjectKey(objectPrefix string, actorID uint, contentType string, now time.Time) string { extension := ".jpg" switch contentType { case "image/png": extension = ".png" case "image/webp": extension = ".webp" } return fmt.Sprintf( "%s/game-robots/avatars/%d/%s/%s%s", cleanRobotAvatarObjectPrefix(objectPrefix), actorID, now.UTC().Format("20060102"), idgen.New("avatar"), extension, ) } func cleanRobotAvatarObjectPrefix(value string) string { value = strings.Trim(strings.TrimSpace(value), "/") if value == "" { return "admin" } return value }