feat: download url
This commit is contained in:
parent
0c821ffa16
commit
bebf76e582
@ -202,8 +202,8 @@ Java / Swift 示例:`docs/clients/`,OpenAPI 里也可点开。
|
|||||||
|
|
||||||
| purpose | object_key | 生命周期 |
|
| purpose | object_key | 生命周期 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `avatar` | `perm/avatar/{yyyyMMdd}/{name}.{ext}` | 永久 |
|
| `avatar` | `perm/avatar/{yyyyMMdd}/{name}.{ext}` | 永久;匿名 GET |
|
||||||
| `group_avatar` | `perm/group/{群号}/{name}.{ext}` | 永久 |
|
| `group_avatar` | `perm/group/{群号}/{name}.{ext}` | 永久;匿名 GET |
|
||||||
| `chat_image` | `chat/image/{yyyyMMdd}/{name}.{ext}` | 桶生命周期 |
|
| `chat_image` | `chat/image/{yyyyMMdd}/{name}.{ext}` | 桶生命周期 |
|
||||||
| `chat_voice` | `chat/voice/{yyyyMMdd}/{name}.{ext}` | 桶生命周期 |
|
| `chat_voice` | `chat/voice/{yyyyMMdd}/{name}.{ext}` | 桶生命周期 |
|
||||||
| `chat_video` | `chat/video/{yyyyMMdd}/{name}.{ext}` | 桶生命周期 |
|
| `chat_video` | `chat/video/{yyyyMMdd}/{name}.{ext}` | 桶生命周期 |
|
||||||
@ -217,6 +217,8 @@ Java / Swift 示例:`docs/clients/`,OpenAPI 里也可点开。
|
|||||||
|
|
||||||
合法 key:以 `perm/` 或 `chat/` 开头;禁止 `..`、空白、`\`、前导 `/`;最长 512。
|
合法 key:以 `perm/` 或 `chat/` 开头;禁止 `..`、空白、`\`、前导 `/`;最长 512。
|
||||||
|
|
||||||
|
头像匿名读:`perm/avatar/`、`perm/group/` 可直接 GET `{public_endpoint}/spark/{key}`(Nginx 也可省略 `/spark`)。聊天、收藏、朋友圈仍须 `POST /v1/files/downloads`。
|
||||||
|
|
||||||
发图片:客户端只 PUT 原图,再 `POST /v1/files/variants` 让服务端出缩略图和预览。消息里只存 key。
|
发图片:客户端只 PUT 原图,再 `POST /v1/files/variants` 让服务端出缩略图和预览。消息里只存 key。
|
||||||
|
|
||||||
进程访问 RustFS 用内网 `object_storage.endpoint`(Docker 里是 `rustfs:9000`),签给客户端的 URL 用 `public_endpoint`(`https://sparkr.wyrs365.com`)。
|
进程访问 RustFS 用内网 `object_storage.endpoint`(Docker 里是 `rustfs:9000`),签给客户端的 URL 用 `public_endpoint`(`https://sparkr.wyrs365.com`)。
|
||||||
|
|||||||
@ -27,6 +27,7 @@ spark-server (Docker):
|
|||||||
- Image / config / logs: `/data/spark-server/{images,configs,logs}`
|
- Image / config / logs: `/data/spark-server/{images,configs,logs}`
|
||||||
- Public security group: 443 only
|
- Public security group: 443 only
|
||||||
- Clients upload/download at `https://sparkr.wyrs365.com` (path-style). spark-server talks to `rustfs:9000` on the Docker network and signs URLs with `object_storage.public_endpoint`.
|
- Clients upload/download at `https://sparkr.wyrs365.com` (path-style). spark-server talks to `rustfs:9000` on the Docker network and signs URLs with `object_storage.public_endpoint`.
|
||||||
|
- Unsigned GET is allowed only for `/perm/avatar/` and `/perm/group/` (rewritten to `/spark/...`). Chat and other prefixes stay SigV4.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash deploy/nginx/install-host.sh
|
bash deploy/nginx/install-host.sh
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
# Spark RustFS S3 — sparkr.wyrs365.com
|
# Spark RustFS S3 — sparkr.wyrs365.com
|
||||||
# Public :443 is stream ssl_preread (see stream.d/spark-sni.conf).
|
# Public :443 is stream ssl_preread (see stream.d/spark-sni.conf).
|
||||||
# TLS terminates on loopback :8443; proxy HTTP to Docker rustfs on :9000.
|
# TLS terminates on loopback :8443; proxy HTTP to Docker rustfs on :9000.
|
||||||
# Do not rewrite the URI: SigV4 signs host + path.
|
# Signed PUT/GET keep the path as-is (/spark/...). Unsigned avatar/group
|
||||||
|
# URLs omit the bucket; only those prefixes are rewritten and publicly readable.
|
||||||
|
|
||||||
upstream spark_rustfs {
|
upstream spark_rustfs {
|
||||||
server 127.0.0.1:9000;
|
server 127.0.0.1:9000;
|
||||||
@ -46,6 +47,30 @@ server {
|
|||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
chunked_transfer_encoding off;
|
chunked_transfer_encoding off;
|
||||||
|
|
||||||
|
# Path-style S3 is /{bucket}/{key}. Clients often GET {public}/{object_key}.
|
||||||
|
# Rewrite only public prefixes; chat/ and perm/fav/ stay 403 without SigV4.
|
||||||
|
location ^~ /perm/avatar/ {
|
||||||
|
rewrite ^ /spark$request_uri break;
|
||||||
|
proxy_pass http://spark_rustfs;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
proxy_cache_convert_head off;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /perm/group/ {
|
||||||
|
rewrite ^ /spark$request_uri break;
|
||||||
|
proxy_pass http://spark_rustfs;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
proxy_cache_convert_head off;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://spark_rustfs;
|
proxy_pass http://spark_rustfs;
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/aws/aws-sdk-go-v2/aws"
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||||
@ -23,6 +24,7 @@ func (s *Store) ensureBucket(ctx context.Context, region string, chatExpireDays
|
|||||||
}
|
}
|
||||||
s.applyLifecycle(ctx, chatExpireDays)
|
s.applyLifecycle(ctx, chatExpireDays)
|
||||||
s.applyCORS(ctx)
|
s.applyCORS(ctx)
|
||||||
|
s.applyPublicRead(ctx)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -83,3 +85,46 @@ func (s *Store) applyCORS(ctx context.Context) {
|
|||||||
s.log.Warn("objectstore cors", zap.Error(err))
|
s.log.Warn("objectstore cors", zap.Error(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// applyPublicRead lets browsers load avatars without a presigned GET.
|
||||||
|
// chat/ and perm/fav/ stay private.
|
||||||
|
func (s *Store) applyPublicRead(ctx context.Context) {
|
||||||
|
pabCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_, err := s.client.PutPublicAccessBlock(pabCtx, &s3.PutPublicAccessBlockInput{
|
||||||
|
Bucket: aws.String(s.bucket),
|
||||||
|
PublicAccessBlockConfiguration: &types.PublicAccessBlockConfiguration{
|
||||||
|
BlockPublicAcls: aws.Bool(false),
|
||||||
|
IgnorePublicAcls: aws.Bool(false),
|
||||||
|
BlockPublicPolicy: aws.Bool(false),
|
||||||
|
RestrictPublicBuckets: aws.Bool(false),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("objectstore public access block", zap.Error(err))
|
||||||
|
}
|
||||||
|
policy := publicReadPolicy(s.bucket)
|
||||||
|
_, err = s.client.PutBucketPolicy(ctx, &s3.PutBucketPolicyInput{
|
||||||
|
Bucket: aws.String(s.bucket),
|
||||||
|
Policy: aws.String(policy),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("objectstore public policy", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func publicReadPolicy(bucket string) string {
|
||||||
|
return fmt.Sprintf(`{
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [{
|
||||||
|
"Sid": "PublicReadAvatars",
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Principal": "*",
|
||||||
|
"Action": ["s3:GetObject"],
|
||||||
|
"Resource": [
|
||||||
|
"arn:aws:s3:::%[1]s/perm/avatar/*",
|
||||||
|
"arn:aws:s3:::%[1]s/perm/group/*"
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
}`, bucket)
|
||||||
|
}
|
||||||
|
|||||||
@ -104,6 +104,16 @@ func TestGetPutNotReady(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPublicReadPolicyCoversAvatarsOnly(t *testing.T) {
|
||||||
|
p := publicReadPolicy("spark")
|
||||||
|
if !strings.Contains(p, "spark/perm/avatar/*") || !strings.Contains(p, "spark/perm/group/*") {
|
||||||
|
t.Fatalf("%s", p)
|
||||||
|
}
|
||||||
|
if strings.Contains(p, "chat/") || strings.Contains(p, "perm/fav") || strings.Contains(p, "perm/moment") {
|
||||||
|
t.Fatalf("chat/fav/moment must stay private: %s", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMapObjectErr(t *testing.T) {
|
func TestMapObjectErr(t *testing.T) {
|
||||||
err := mapObjectErr(&types.NoSuchKey{Message: aws.String("gone")})
|
err := mapObjectErr(&types.NoSuchKey{Message: aws.String("gone")})
|
||||||
if !errors.Is(err, errs.ErrMediaExpired) {
|
if !errors.Is(err, errs.ErrMediaExpired) {
|
||||||
|
|||||||
@ -20,3 +20,8 @@ type Download struct {
|
|||||||
Method string `json:"method"`
|
Method string `json:"method"`
|
||||||
ExpiresIn int64 `json:"expires_in"`
|
ExpiresIn int64 `json:"expires_in"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DownloadSet is one HTTP round-trip for up to nine Presigned GETs.
|
||||||
|
type DownloadSet struct {
|
||||||
|
Items []Download `json:"items"`
|
||||||
|
}
|
||||||
|
|||||||
@ -101,6 +101,36 @@ func TestCreateDownloadValidatesKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateDownloadsBatch(t *testing.T) {
|
||||||
|
svc := testUpload(t)
|
||||||
|
ctx := reqctx.With(t.Context(), &reqctx.RequestContext{UserID: 1, Language: "zh-CN"})
|
||||||
|
keys := []string{
|
||||||
|
"perm/moment/image/20260918/a.jpg",
|
||||||
|
"perm/moment/image/20260918/b.jpg",
|
||||||
|
"chat/image/20260918/c.jpg",
|
||||||
|
}
|
||||||
|
got, err := svc.CreateDownloads(ctx, 1, &BatchDownloadInput{ObjectKeys: keys})
|
||||||
|
if err != nil || got == nil || len(got.Items) != 3 {
|
||||||
|
t.Fatalf("%+v %v", got, err)
|
||||||
|
}
|
||||||
|
if got.Items[0].ObjectKey != keys[0] || got.Items[1].Method != "GET" || !strings.Contains(got.Items[2].DownloadURL, keys[2]) {
|
||||||
|
t.Fatalf("%+v", got.Items)
|
||||||
|
}
|
||||||
|
if _, err := svc.CreateDownloads(ctx, 1, &BatchDownloadInput{}); err == nil {
|
||||||
|
t.Fatal("empty")
|
||||||
|
}
|
||||||
|
tooMany := make([]string, 10)
|
||||||
|
for i := range tooMany {
|
||||||
|
tooMany[i] = "chat/image/20260918/x.jpg"
|
||||||
|
}
|
||||||
|
if _, err := svc.CreateDownloads(ctx, 1, &BatchDownloadInput{ObjectKeys: tooMany}); err == nil {
|
||||||
|
t.Fatal("too many")
|
||||||
|
}
|
||||||
|
if _, err := svc.CreateDownloads(ctx, 1, &BatchDownloadInput{ObjectKeys: []string{"../secret"}}); err == nil {
|
||||||
|
t.Fatal("path escape")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCreateCredentialsBatch(t *testing.T) {
|
func TestCreateCredentialsBatch(t *testing.T) {
|
||||||
svc := testUpload(t)
|
svc := testUpload(t)
|
||||||
ctx := reqctx.With(t.Context(), &reqctx.RequestContext{UserID: 42, Language: "zh-CN"})
|
ctx := reqctx.With(t.Context(), &reqctx.RequestContext{UserID: 42, Language: "zh-CN"})
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import (
|
|||||||
|
|
||||||
"spark-server/internal/module/upload/entity"
|
"spark-server/internal/module/upload/entity"
|
||||||
"spark-server/internal/pkg/errs"
|
"spark-server/internal/pkg/errs"
|
||||||
"spark-server/internal/pkg/i18n"
|
|
||||||
"spark-server/internal/pkg/reqctx"
|
"spark-server/internal/pkg/reqctx"
|
||||||
"spark-server/internal/pkg/validate"
|
"spark-server/internal/pkg/validate"
|
||||||
)
|
)
|
||||||
@ -31,18 +30,5 @@ func (s *Service) CreateDownload(ctx context.Context, userID int64, in *Download
|
|||||||
if !s.allow(userID) {
|
if !s.allow(userID) {
|
||||||
return nil, errs.ErrRateLimited
|
return nil, errs.ErrRateLimited
|
||||||
}
|
}
|
||||||
key := in.ObjectKey
|
return s.issueGet(ctx, in.ObjectKey)
|
||||||
if !entity.ValidObjectKey(key) {
|
|
||||||
return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyUploadKeyInvalid, nil))
|
|
||||||
}
|
|
||||||
url, err := s.store.PresignGet(ctx, key, s.ttl)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &entity.Download{
|
|
||||||
ObjectKey: key,
|
|
||||||
DownloadURL: url,
|
|
||||||
Method: entity.MethodGET,
|
|
||||||
ExpiresIn: s.expiresIn(),
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
64
internal/module/upload/logic/download_batch.go
Normal file
64
internal/module/upload/logic/download_batch.go
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
package logic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"spark-server/internal/module/upload/entity"
|
||||||
|
"spark-server/internal/pkg/errs"
|
||||||
|
"spark-server/internal/pkg/i18n"
|
||||||
|
"spark-server/internal/pkg/reqctx"
|
||||||
|
"spark-server/internal/pkg/validate"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxDownloadBatch = 9
|
||||||
|
|
||||||
|
// BatchDownloadInput issues up to nine Presigned GETs (moment 九宫格) in one call.
|
||||||
|
type BatchDownloadInput struct {
|
||||||
|
ObjectKeys []string `json:"object_keys" validate:"required,min=1,max=9,dive,required,max=512" label:"field.object_key" errmsg:"required:validate.upload.key_required;max:validate.upload.download_items_invalid"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) CreateDownloads(ctx context.Context, userID int64, in *BatchDownloadInput) (*entity.DownloadSet, error) {
|
||||||
|
if !s.ready() {
|
||||||
|
return nil, errs.ErrAvailable
|
||||||
|
}
|
||||||
|
if userID <= 0 {
|
||||||
|
return nil, errs.ErrUnauthorized
|
||||||
|
}
|
||||||
|
if in == nil {
|
||||||
|
return nil, errs.ErrInvalidArgument
|
||||||
|
}
|
||||||
|
if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if n := len(in.ObjectKeys); n < 1 || n > maxDownloadBatch {
|
||||||
|
return nil, errs.ErrInvalidArgument
|
||||||
|
}
|
||||||
|
if !s.allow(userID) {
|
||||||
|
return nil, errs.ErrRateLimited
|
||||||
|
}
|
||||||
|
items := make([]entity.Download, 0, len(in.ObjectKeys))
|
||||||
|
for _, key := range in.ObjectKeys {
|
||||||
|
got, err := s.issueGet(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, *got)
|
||||||
|
}
|
||||||
|
return &entity.DownloadSet{Items: items}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) issueGet(ctx context.Context, key string) (*entity.Download, error) {
|
||||||
|
if !entity.ValidObjectKey(key) {
|
||||||
|
return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyUploadKeyInvalid, nil))
|
||||||
|
}
|
||||||
|
url, err := s.store.PresignGet(ctx, key, s.ttl)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &entity.Download{
|
||||||
|
ObjectKey: key,
|
||||||
|
DownloadURL: url,
|
||||||
|
Method: entity.MethodGET,
|
||||||
|
ExpiresIn: s.expiresIn(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@ -59,6 +59,9 @@ func init() {
|
|||||||
KeyUploadItemsInvalid: {
|
KeyUploadItemsInvalid: {
|
||||||
ZhCN: "一次最多签发 3 个上传凭证", En: "At most 3 upload credentials per request", ZhTW: "一次最多簽發 3 個上傳憑證",
|
ZhCN: "一次最多签发 3 个上传凭证", En: "At most 3 upload credentials per request", ZhTW: "一次最多簽發 3 個上傳憑證",
|
||||||
},
|
},
|
||||||
|
KeyUploadDownloadItemsInvalid: {
|
||||||
|
ZhCN: "一次最多签发 9 个下载凭证", En: "At most 9 download credentials per request", ZhTW: "一次最多簽發 9 個下載憑證",
|
||||||
|
},
|
||||||
KeyFavoriteMsgRequired: {
|
KeyFavoriteMsgRequired: {
|
||||||
ZhCN: "请选择消息", En: "Please choose a message", ZhTW: "請選擇訊息",
|
ZhCN: "请选择消息", En: "Please choose a message", ZhTW: "請選擇訊息",
|
||||||
},
|
},
|
||||||
|
|||||||
@ -81,22 +81,23 @@ const (
|
|||||||
KeyFriendNotFriend = "friend.not_friend"
|
KeyFriendNotFriend = "friend.not_friend"
|
||||||
KeyFriendRequestInvalid = "friend.request_invalid"
|
KeyFriendRequestInvalid = "friend.request_invalid"
|
||||||
|
|
||||||
KeyUploadPurposeRequired = "validate.upload.purpose_required"
|
KeyUploadPurposeRequired = "validate.upload.purpose_required"
|
||||||
KeyUploadPurposeInvalid = "validate.upload.purpose_invalid"
|
KeyUploadPurposeInvalid = "validate.upload.purpose_invalid"
|
||||||
KeyUploadMIMERequired = "validate.upload.mime_required"
|
KeyUploadMIMERequired = "validate.upload.mime_required"
|
||||||
KeyUploadMIMEInvalid = "validate.upload.mime_invalid"
|
KeyUploadMIMEInvalid = "validate.upload.mime_invalid"
|
||||||
KeyUploadSizeRequired = "validate.upload.size_required"
|
KeyUploadSizeRequired = "validate.upload.size_required"
|
||||||
KeyUploadSizeInvalid = "validate.upload.size_invalid"
|
KeyUploadSizeInvalid = "validate.upload.size_invalid"
|
||||||
KeyUploadSizeExceeded = "validate.upload.size_exceeded"
|
KeyUploadSizeExceeded = "validate.upload.size_exceeded"
|
||||||
KeyUploadKeyRequired = "validate.upload.key_required"
|
KeyUploadKeyRequired = "validate.upload.key_required"
|
||||||
KeyUploadKeyInvalid = "validate.upload.key_invalid"
|
KeyUploadKeyInvalid = "validate.upload.key_invalid"
|
||||||
KeyUploadGroupRequired = "validate.upload.group_required"
|
KeyUploadGroupRequired = "validate.upload.group_required"
|
||||||
KeyUploadItemsRequired = "validate.upload.items_required"
|
KeyUploadItemsRequired = "validate.upload.items_required"
|
||||||
KeyUploadItemsInvalid = "validate.upload.items_invalid"
|
KeyUploadItemsInvalid = "validate.upload.items_invalid"
|
||||||
KeyFavoriteMsgRequired = "validate.favorite.msg_required"
|
KeyUploadDownloadItemsInvalid = "validate.upload.download_items_invalid"
|
||||||
KeyFavoriteMsgInvalid = "validate.favorite.msg_invalid"
|
KeyFavoriteMsgRequired = "validate.favorite.msg_required"
|
||||||
KeyMessageClientMsgRequired = "validate.message.client_msg_required"
|
KeyFavoriteMsgInvalid = "validate.favorite.msg_invalid"
|
||||||
KeyMessageContentRequired = "validate.message.content_required"
|
KeyMessageClientMsgRequired = "validate.message.client_msg_required"
|
||||||
|
KeyMessageContentRequired = "validate.message.content_required"
|
||||||
|
|
||||||
KeyFieldGroupName = "field.group_name"
|
KeyFieldGroupName = "field.group_name"
|
||||||
KeyFieldMemberUIDs = "field.member_uids"
|
KeyFieldMemberUIDs = "field.member_uids"
|
||||||
|
|||||||
@ -638,6 +638,7 @@ paths:
|
|||||||
summary: 当前用户资料
|
summary: 当前用户资料
|
||||||
description: |
|
description: |
|
||||||
Header:`Authorization: Bearer <token>`(必填),可选 `X-Device-ID`、`X-Language`。无 Body。
|
Header:`Authorization: Bearer <token>`(必填),可选 `X-Device-ID`、`X-Language`。无 Body。
|
||||||
|
`avatar` 是 object_key,可匿名 GET `https://sparkr.wyrs365.com/spark/{avatar}`。
|
||||||
operationId: getMe
|
operationId: getMe
|
||||||
parameters:
|
parameters:
|
||||||
- $ref: "#/components/parameters/X-Device-ID"
|
- $ref: "#/components/parameters/X-Device-ID"
|
||||||
@ -1107,8 +1108,8 @@ paths:
|
|||||||
description: |
|
description: |
|
||||||
客户端只传 `purpose` / `mime` / `size`,服务端生成 object_key 并签发短时 Presigned PUT。
|
客户端只传 `purpose` / `mime` / `size`,服务端生成 object_key 并签发短时 Presigned PUT。
|
||||||
文件直传 RustFS,不经过 Gin。
|
文件直传 RustFS,不经过 Gin。
|
||||||
`avatar` 写入 `perm/avatar/{yyyyMMdd}/{name}.{ext}`(不过期,路径不含 uid);
|
`avatar` 写入 `perm/avatar/{yyyyMMdd}/{name}.{ext}`(不过期,路径不含 uid,匿名 GET);
|
||||||
`group_avatar` 写入 `perm/group/{群号}/{name}.{ext}`(需 `group_id`,群主/管理员);
|
`group_avatar` 写入 `perm/group/{群号}/{name}.{ext}`(需 `group_id`,群主/管理员,匿名 GET);
|
||||||
聊天按类型:`chat/image|voice|video|gif|file/{yyyyMMdd}/{name}.{ext}`(桶生命周期按前缀过期)。
|
聊天按类型:`chat/image|voice|video|gif|file/{yyyyMMdd}/{name}.{ext}`(桶生命周期按前缀过期)。
|
||||||
朋友圈:`moment_image` / `moment_video` / `moment_cover` 写入 `perm/moment/{image|video|cover}/{yyyyMMdd}/{name}.{ext}`(不过期,删帖/换封面时删对象)。
|
朋友圈:`moment_image` / `moment_video` / `moment_cover` 写入 `perm/moment/{image|video|cover}/{yyyyMMdd}/{name}.{ext}`(不过期,删帖/换封面时删对象)。
|
||||||
`{name}` 为服务端 16 位 base62 随机串,不占用 Snowflake。
|
`{name}` 为服务端 16 位 base62 随机串,不占用 Snowflake。
|
||||||
@ -1196,7 +1197,7 @@ paths:
|
|||||||
post:
|
post:
|
||||||
tags: [files]
|
tags: [files]
|
||||||
summary: 签发对象存储下载凭证
|
summary: 签发对象存储下载凭证
|
||||||
description: 已登录用户用 object_key 换取短时 Presigned GET。key 必须是 `perm/` 或 `chat/` 前缀。
|
description: 已登录用户用 object_key 换取短时 Presigned GET。key 必须是 `perm/` 或 `chat/` 前缀。九宫格请用 `POST /v1/files/downloads/batch`。
|
||||||
operationId: createDownloadCredential
|
operationId: createDownloadCredential
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
@ -1213,6 +1214,42 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/Envelope"
|
$ref: "#/components/schemas/Envelope"
|
||||||
|
/v1/files/downloads/batch:
|
||||||
|
post:
|
||||||
|
tags: [files]
|
||||||
|
summary: 批量签发下载凭证
|
||||||
|
description: |
|
||||||
|
一次返回 1~9 个 Presigned GET,顺序与 `object_keys` 相同。朋友圈九宫格一次拿齐。
|
||||||
|
限流按请求计入,不按条数。文件仍直传 RustFS GET。
|
||||||
|
operationId: createDownloadCredentials
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/CreateDownloadBatchRequest"
|
||||||
|
example:
|
||||||
|
object_keys:
|
||||||
|
- perm/moment/image/20260918/a.jpg
|
||||||
|
- perm/moment/image/20260918/b.jpg
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 统一 envelope
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Envelope"
|
||||||
|
example:
|
||||||
|
code: "0"
|
||||||
|
msg: ok
|
||||||
|
data:
|
||||||
|
items:
|
||||||
|
- object_key: perm/moment/image/20260918/a.jpg
|
||||||
|
download_url: https://sparkr.wyrs365.com/spark/perm/moment/image/20260918/a.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256
|
||||||
|
method: GET
|
||||||
|
expires_in: 900
|
||||||
|
- object_key: perm/moment/image/20260918/b.jpg
|
||||||
|
method: GET
|
||||||
/v1/favorites:
|
/v1/favorites:
|
||||||
get:
|
get:
|
||||||
tags: [favorites]
|
tags: [favorites]
|
||||||
@ -1296,6 +1333,7 @@ paths:
|
|||||||
summary: 发朋友圈
|
summary: 发朋友圈
|
||||||
description: |
|
description: |
|
||||||
纯文,或最多 9 张图,或 1 条视频(图/视频互斥)。媒体先 `POST /v1/files`(`moment_image` / `moment_video`)再填 key。
|
纯文,或最多 9 张图,或 1 条视频(图/视频互斥)。媒体先 `POST /v1/files`(`moment_image` / `moment_video`)再填 key。
|
||||||
|
浏览九宫格:`POST /v1/files/downloads/batch` 一次换 9 张 Presigned GET。
|
||||||
`visibility`:0 好友公开 1 私密 2 白名单 3 黑名单。2/3 需 `audience_uids`(好友,最多 200)。
|
`visibility`:0 好友公开 1 私密 2 白名单 3 黑名单。2/3 需 `audience_uids`(好友,最多 200)。
|
||||||
operationId: publishMoment
|
operationId: publishMoment
|
||||||
requestBody:
|
requestBody:
|
||||||
@ -2108,6 +2146,10 @@ components:
|
|||||||
avatar:
|
avatar:
|
||||||
type: string
|
type: string
|
||||||
maxLength: 512
|
maxLength: 512
|
||||||
|
description: |
|
||||||
|
对象存储 key。仅 `perm/avatar/`、`perm/group/` 可匿名 GET(桶本身不公开)。
|
||||||
|
URL:`{public_endpoint}/spark/{avatar}`,Nginx 也可省略桶名 `{public_endpoint}/{avatar}`。
|
||||||
|
`chat/`、`perm/fav/`、`perm/moment/` 必须 Presigned GET。
|
||||||
gender:
|
gender:
|
||||||
$ref: "#/components/schemas/Gender"
|
$ref: "#/components/schemas/Gender"
|
||||||
birthday:
|
birthday:
|
||||||
@ -2211,6 +2253,17 @@ components:
|
|||||||
object_key:
|
object_key:
|
||||||
type: string
|
type: string
|
||||||
maxLength: 512
|
maxLength: 512
|
||||||
|
CreateDownloadBatchRequest:
|
||||||
|
type: object
|
||||||
|
required: [object_keys]
|
||||||
|
properties:
|
||||||
|
object_keys:
|
||||||
|
type: array
|
||||||
|
minItems: 1
|
||||||
|
maxItems: 9
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
maxLength: 512
|
||||||
SendMessageRequest:
|
SendMessageRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [client_msg_id, content]
|
required: [client_msg_id, content]
|
||||||
|
|||||||
@ -90,6 +90,19 @@ func TestUploadCredentialHTTP(t *testing.T) {
|
|||||||
if err := json.Unmarshal(batch.Data, &set); err != nil || len(set.Items) != 2 || set.Items[0].ObjectKey == set.Items[1].ObjectKey {
|
if err := json.Unmarshal(batch.Data, &set); err != nil || len(set.Items) != 2 || set.Items[0].ObjectKey == set.Items[1].ObjectKey {
|
||||||
t.Fatalf("%s %v", batch.Data, err)
|
t.Fatalf("%s %v", batch.Data, err)
|
||||||
}
|
}
|
||||||
|
dl := authJSON(t, h, &tok, http.MethodPost, "/v1/files/downloads/batch", `{"object_keys":["chat/image/20260917/a.jpg","perm/moment/image/20260917/b.jpg"]}`)
|
||||||
|
if dl.Code != "0" {
|
||||||
|
t.Fatalf("downloads %+v", dl)
|
||||||
|
}
|
||||||
|
var dls struct {
|
||||||
|
Items []struct {
|
||||||
|
ObjectKey string `json:"object_key"`
|
||||||
|
DownloadURL string `json:"download_url"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(dl.Data, &dls); err != nil || len(dls.Items) != 2 || dls.Items[0].DownloadURL == "" {
|
||||||
|
t.Fatalf("%s %v", dl.Data, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFavoriteTextHTTP(t *testing.T) {
|
func TestFavoriteTextHTTP(t *testing.T) {
|
||||||
|
|||||||
@ -72,6 +72,24 @@ func (h *Handler) Download(c *gin.Context) {
|
|||||||
respond.JSON(c, out)
|
respond.JSON(c, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) DownloadBatch(c *gin.Context) {
|
||||||
|
uid, ok := currentUID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in uploadlogic.BatchDownloadInput
|
||||||
|
if err := respond.BindJSON(c, &in); err != nil {
|
||||||
|
respond.JSONError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := h.files.CreateDownloads(c.Request.Context(), uid, &in)
|
||||||
|
if err != nil {
|
||||||
|
respond.JSONError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond.JSON(c, out)
|
||||||
|
}
|
||||||
|
|
||||||
func currentUID(c *gin.Context) (int64, bool) {
|
func currentUID(c *gin.Context) (int64, bool) {
|
||||||
rc := reqctx.From(c.Request.Context())
|
rc := reqctx.From(c.Request.Context())
|
||||||
if rc == nil || rc.UserID <= 0 {
|
if rc == nil || rc.UserID <= 0 {
|
||||||
|
|||||||
@ -136,6 +136,7 @@ func registerFileRoutes(v1 *gin.RouterGroup, deps *EngineDeps) {
|
|||||||
g.POST("", h.Create)
|
g.POST("", h.Create)
|
||||||
g.POST("/batch", h.CreateBatch)
|
g.POST("/batch", h.CreateBatch)
|
||||||
g.POST("/downloads", h.Download)
|
g.POST("/downloads", h.Download)
|
||||||
|
g.POST("/downloads/batch", h.DownloadBatch)
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerFavoriteRoutes(v1 *gin.RouterGroup, deps *EngineDeps) {
|
func registerFavoriteRoutes(v1 *gin.RouterGroup, deps *EngineDeps) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user