2026-06-11 01:02:16 +08:00

168 lines
5.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}