168 lines
5.0 KiB
Go
168 lines
5.0 KiB
Go
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
|
||
}
|