@@ -141,7 +141,7 @@ func (h *aiAdminHandler) updateKeyContentLog(c *gin.Context) {
|
||||
//
|
||||
// @Summary 分页查询内容日志
|
||||
// @Tags AI 管理
|
||||
// @Success 200 {object} pagedResponse[model.AiCallLog]
|
||||
// @Success 200 {object} pagedResponse[model.AiContentLog]
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/ai-content-logs [get]
|
||||
func (h *aiAdminHandler) listContentLogs(c *gin.Context) {
|
||||
@@ -400,7 +400,7 @@ func (h *aiAdminHandler) removeBlacklist(c *gin.Context) {
|
||||
|
||||
// @Summary AI 调用日志列表
|
||||
// @Tags AI 管理
|
||||
// @Success 200 {object} pagedResponse[model.AiContentLog]
|
||||
// @Success 200 {object} pagedResponse[model.AiCallLog]
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/ai-logs [get]
|
||||
func (h *aiAdminHandler) listLogs(c *gin.Context) {
|
||||
|
||||
+15
-9
@@ -65,7 +65,8 @@ func (h *authxHandler) totpSetup(c *gin.Context) {
|
||||
// @Summary 激活两步验证
|
||||
// @Tags 认证
|
||||
// @Param body body object true "{code: 6 位验证码}"
|
||||
// @Success 204 "已启用"
|
||||
// @Success 200 {object} tokenResponse "已启用,返回换发的新 token"
|
||||
// @Success 204 "已启用但新 token 签发失败,需重新登录"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/auth/totp/activate [post]
|
||||
func (h *authxHandler) totpActivate(c *gin.Context) {
|
||||
@@ -93,7 +94,8 @@ func (h *authxHandler) totpActivate(c *gin.Context) {
|
||||
// @Summary 停用两步验证
|
||||
// @Tags 认证
|
||||
// @Param body body object true "{password 或 code 任一确认}"
|
||||
// @Success 204 "已停用"
|
||||
// @Success 200 {object} tokenResponse "已停用,返回换发的新 token"
|
||||
// @Success 204 "已停用但新 token 签发失败,需重新登录"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/auth/totp/disable [post]
|
||||
func (h *authxHandler) totpDisable(c *gin.Context) {
|
||||
@@ -170,7 +172,8 @@ func (h *authxHandler) getCredentials(c *gin.Context) {
|
||||
// @Summary 修改登录凭据
|
||||
// @Tags 认证
|
||||
// @Param body body service.UpdateCredentialsInput true "新凭据(当前密码必验)"
|
||||
// @Success 204 "已更新,请重新登录"
|
||||
// @Success 200 {object} tokenResponse "已更新,返回换发的新 token"
|
||||
// @Success 204 "已更新但新 token 签发失败,需重新登录"
|
||||
// @Failure 401 {object} errorResponse "当前密码错误"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/auth/credentials [put]
|
||||
@@ -202,7 +205,8 @@ func (h *authxHandler) updateCredentials(c *gin.Context) {
|
||||
// @Summary 密码登录开关
|
||||
// @Tags 认证
|
||||
// @Param body body object true "{disabled: bool}"
|
||||
// @Success 204 "已保存"
|
||||
// @Success 200 {object} tokenResponse "已保存,返回换发的新 token"
|
||||
// @Success 204 "已保存但新 token 签发失败,需重新登录"
|
||||
// @Failure 409 {object} errorResponse "未绑定外部身份"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/auth/password-login [put]
|
||||
@@ -375,7 +379,8 @@ func (h *authxHandler) identities(c *gin.Context) {
|
||||
// @Summary 解绑外部身份
|
||||
// @Tags 认证
|
||||
// @Param id path int true "身份 ID"
|
||||
// @Success 204 "已解绑"
|
||||
// @Success 200 {object} tokenResponse "已解绑,返回换发的新 token"
|
||||
// @Success 204 "已解绑但新 token 签发失败,需重新登录"
|
||||
// @Failure 409 {object} errorResponse "最后一个身份不可解绑"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/auth/identities/{id} [delete]
|
||||
@@ -414,14 +419,15 @@ func (h *authxHandler) getOAuthSettings(c *gin.Context, settings *service.Settin
|
||||
c.JSON(http.StatusOK, view)
|
||||
}
|
||||
|
||||
// updateOAuthSettings 保存 provider 配置;secret 缺省沿用、空串清除。
|
||||
// updateOAuthSettings 部分更新 provider 配置;缺省字段沿用现值,
|
||||
// secret 传空串清除、缺省沿用。
|
||||
//
|
||||
// @Summary 保存 OAuth provider 配置
|
||||
// @Summary 部分更新 OAuth provider 配置
|
||||
// @Tags 设置
|
||||
// @Param body body service.UpdateOAuthInput true "provider 配置"
|
||||
// @Param body body service.UpdateOAuthInput true "出现的字段才会被更新"
|
||||
// @Success 200 {object} service.OAuthProvidersView
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/settings/oauth [put]
|
||||
// @Router /api/v1/settings/oauth [patch]
|
||||
func (h *authxHandler) updateOAuthSettings(c *gin.Context, settings *service.SettingService) {
|
||||
var req service.UpdateOAuthInput
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
|
||||
+138
-3
@@ -1,13 +1,121 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"oci-portal/internal/oci"
|
||||
"oci-portal/internal/service"
|
||||
)
|
||||
|
||||
// idpIconResponse 是图标上传响应;fileName 为域存储内标识,留作后续清理。
|
||||
type idpIconResponse struct {
|
||||
URL string `json:"url"`
|
||||
FileName string `json:"fileName"`
|
||||
}
|
||||
|
||||
type idpSetupWarning struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
ResourceCreated bool `json:"resourceCreated"`
|
||||
RequestID string `json:"requestId"`
|
||||
}
|
||||
|
||||
type createIdpResponse struct {
|
||||
oci.IdentityProviderInfo
|
||||
SetupWarning *idpSetupWarning `json:"setupWarning,omitempty"`
|
||||
}
|
||||
|
||||
// @Summary 上传身份提供商图标
|
||||
// @Description 上传到身份域公共图片存储(/storage/v1/Images),返回可填入 iconUrl 的公网地址
|
||||
// @Tags 租户 IAM
|
||||
// @Accept multipart/form-data
|
||||
// @Param id path int true "配置 ID"
|
||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||
// @Param file formData file true "图标文件(png/jpg/jpeg/gif/svg/webp/ico,≤1MB)"
|
||||
// @Success 200 {object} idpIconResponse
|
||||
// @Failure 400 {object} map[string]string "缺文件字段、文件为空或文件名非法"
|
||||
// @Failure 413 {object} map[string]string "文件超过 1MB"
|
||||
// @Failure 415 {object} map[string]string "扩展名不支持或与文件内容不符"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/oci-configs/{id}/idp-icons [post]
|
||||
func (h *ociConfigHandler) uploadIdpIcon(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
status, msg := iconFormStatus(err)
|
||||
c.JSON(status, gin.H{"error": msg})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
data, err := io.ReadAll(file) // 请求体总量由路由级 bodyLimit 兜底
|
||||
if err != nil {
|
||||
status, msg := iconFormStatus(err)
|
||||
c.JSON(status, gin.H{"error": msg})
|
||||
return
|
||||
}
|
||||
url, fileName, err := h.svc.UploadIdpIcon(c.Request.Context(), id, c.Query("domainId"), header.Filename, data)
|
||||
if err != nil {
|
||||
respondIdpIconErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, idpIconResponse{URL: url, FileName: fileName})
|
||||
}
|
||||
|
||||
// @Summary 删除身份提供商图标
|
||||
// @Description 使用上传响应中的 fileName 删除身份域公开图片;404 按已删除处理
|
||||
// @Tags 租户 IAM
|
||||
// @Param id path int true "配置 ID"
|
||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||
// @Param fileName query string true "上传响应返回的 IdP 图标存储标识(images/idp-icon-<32hex>.<ext>)"
|
||||
// @Success 204 "无内容"
|
||||
// @Failure 400 {object} map[string]string "fileName 缺失或不是本服务生成的 IdP 图标标识"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/oci-configs/{id}/idp-icons [delete]
|
||||
func (h *ociConfigHandler) deleteIdpIcon(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.svc.DeleteIdpIcon(c.Request.Context(), id, c.Query("domainId"), c.Query("fileName")); err != nil {
|
||||
respondIdpIconErr(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// iconFormStatus 区分请求体超限(413)与表单缺失/损坏(400)。
|
||||
func iconFormStatus(err error) (int, string) {
|
||||
var mbe *http.MaxBytesError
|
||||
if errors.As(err, &mbe) {
|
||||
return http.StatusRequestEntityTooLarge, "请求体超出上限(图标最大 1MB)"
|
||||
}
|
||||
return http.StatusBadRequest, "缺少文件字段 file"
|
||||
}
|
||||
|
||||
// respondIdpIconErr 把图标校验哨兵错误映射为语义状态码,其余走统一错误边界。
|
||||
func respondIdpIconErr(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrIdpIconTooLarge):
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "图标不能超过 1MB"})
|
||||
case errors.Is(err, service.ErrIdpIconBadType):
|
||||
c.JSON(http.StatusUnsupportedMediaType, gin.H{"error": "扩展名不支持或与文件内容不符"})
|
||||
case errors.Is(err, service.ErrIdpIconEmpty):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "文件为空"})
|
||||
case errors.Is(err, service.ErrIdpIconBadName):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "图标文件名非法"})
|
||||
default:
|
||||
respondError(c, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Federation:SAML IdP 与 sign-on 免 MFA ----
|
||||
|
||||
// createIdpRequest 的映射 / JIT 字段全部可缺省:布尔用指针区分「未传」,
|
||||
@@ -58,11 +166,12 @@ func (h *ociConfigHandler) listIdentityProviders(c *gin.Context) {
|
||||
}
|
||||
|
||||
// @Summary 创建身份提供商(SAML)
|
||||
// @Description 创建禁用态 IdP;若 IdP 已创建但 JIT 后置配置失败且回滚无法确认,仍返回 201,并在 setupWarning 中标明部分成功
|
||||
// @Tags 租户 IAM
|
||||
// @Param id path int true "配置 ID"
|
||||
// @Param domainId query string false "身份域 OCID(缺省 Default 域)"
|
||||
// @Param body body createIdpRequest true "请求体"
|
||||
// @Success 201 {object} oci.IdentityProviderInfo
|
||||
// @Success 201 {object} createIdpResponse
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/oci-configs/{id}/identity-providers [post]
|
||||
func (h *ociConfigHandler) createIdentityProvider(c *gin.Context) {
|
||||
@@ -89,11 +198,37 @@ func (h *ociConfigHandler) createIdentityProvider(c *gin.Context) {
|
||||
JitAssignAdminGroup: boolOr(req.JitAssignAdminGroup, true),
|
||||
JitMapEmail: req.JitMapEmail,
|
||||
})
|
||||
if err != nil {
|
||||
respondCreateIdpResult(c, idp, err)
|
||||
}
|
||||
|
||||
func respondCreateIdpResult(c *gin.Context, idp oci.IdentityProviderInfo, err error) {
|
||||
if err == nil {
|
||||
c.JSON(http.StatusCreated, createIdpResponse{IdentityProviderInfo: idp})
|
||||
return
|
||||
}
|
||||
var partial *oci.PartialIdentityProviderCreateError
|
||||
if !errors.As(err, &partial) {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, idp)
|
||||
requestID := logPartialIdpCreate(c, partial)
|
||||
c.JSON(http.StatusCreated, createIdpResponse{
|
||||
IdentityProviderInfo: partial.IdentityProvider,
|
||||
SetupWarning: &idpSetupWarning{
|
||||
Code: oci.IdpSetupWarningCode, Message: "JIT 配置未完成,自动回滚状态无法确认,请刷新列表检查",
|
||||
ResourceCreated: true, RequestID: requestID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func logPartialIdpCreate(c *gin.Context, partial *oci.PartialIdentityProviderCreateError) string {
|
||||
requestID := newRequestID()
|
||||
detail := errors.Unwrap(partial)
|
||||
if detail == nil {
|
||||
detail = partial
|
||||
}
|
||||
log.Printf("[WARN %s] %s %s: partial IdP create: %v", requestID, c.Request.Method, requestPath(c), detail)
|
||||
return requestID
|
||||
}
|
||||
|
||||
// @Summary 激活身份提供商
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"oci-portal/internal/crypto"
|
||||
"oci-portal/internal/model"
|
||||
"oci-portal/internal/oci"
|
||||
"oci-portal/internal/service"
|
||||
)
|
||||
|
||||
func newIconTestRouter(t *testing.T) (*gin.Engine, string, uint, *service.SystemLogService) {
|
||||
t.Helper()
|
||||
router, auth, logs, db := newTestRouterDB(t)
|
||||
id := seedIconConfig(t, db)
|
||||
token, _, err := auth.Login(context.Background(), "admin", "pass123", "127.0.0.1", "")
|
||||
if err != nil {
|
||||
t.Fatalf("login: %v", err)
|
||||
}
|
||||
return router, token, id, logs
|
||||
}
|
||||
|
||||
func seedIconConfig(t *testing.T, db *gorm.DB) uint {
|
||||
t.Helper()
|
||||
cipher, err := crypto.NewCipher("test-key")
|
||||
if err != nil {
|
||||
t.Fatalf("new cipher: %v", err)
|
||||
}
|
||||
key, err := cipher.EncryptString("-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----")
|
||||
if err != nil {
|
||||
t.Fatalf("encrypt key: %v", err)
|
||||
}
|
||||
cfg := model.OciConfig{Alias: "icons", TenancyOCID: "ocid1.tenancy.oc1..t", UserOCID: "ocid1.user.oc1..u",
|
||||
Fingerprint: "aa:bb", Region: "us-ashburn-1", HomeRegionKey: "IAD", PrivateKeyEnc: key}
|
||||
if err := db.Create(&cfg).Error; err != nil {
|
||||
t.Fatalf("create config: %v", err)
|
||||
}
|
||||
return cfg.ID
|
||||
}
|
||||
|
||||
func iconSVG(t *testing.T, size int) []byte {
|
||||
t.Helper()
|
||||
data := []byte(`<svg xmlns="http://www.w3.org/2000/svg"/>`)
|
||||
if len(data) > size {
|
||||
t.Fatalf("svg size %d exceeds target %d", len(data), size)
|
||||
}
|
||||
return append(data, bytes.Repeat([]byte(" "), size-len(data))...)
|
||||
}
|
||||
|
||||
func sendIconUpload(t *testing.T, router *gin.Engine, token, path, fileName string, data []byte) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
part, err := writer.CreateFormFile("file", fileName)
|
||||
if err != nil {
|
||||
t.Fatalf("create form file: %v", err)
|
||||
}
|
||||
_, _ = part.Write(data)
|
||||
_ = writer.Close()
|
||||
req := httptest.NewRequest(http.MethodPost, path, &body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestIdpIconMultipartSizeBoundary(t *testing.T) {
|
||||
router, token, id, logs := newIconTestRouter(t)
|
||||
t.Cleanup(logs.Wait)
|
||||
path := "/api/v1/oci-configs/" + strconv.FormatUint(uint64(id), 10) + "/idp-icons"
|
||||
cases := []struct {
|
||||
name, fileName string
|
||||
data []byte
|
||||
want int
|
||||
}{
|
||||
{"exactly 1MiB", "icon.svg", iconSVG(t, 1<<20), http.StatusOK},
|
||||
{"one byte over", "icon.svg", iconSVG(t, 1<<20+1), http.StatusRequestEntityTooLarge},
|
||||
{"empty", "icon.png", nil, http.StatusBadRequest},
|
||||
{"spoofed png", "icon.png", []byte("not an image"), http.StatusUnsupportedMediaType},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
rec := sendIconUpload(t, router, token, path, tc.fileName, tc.data)
|
||||
if rec.Code != tc.want {
|
||||
t.Errorf("%s status = %d, want %d, body %s", tc.name, rec.Code, tc.want, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteIdpIconRoute(t *testing.T) {
|
||||
router, token, id, logs := newIconTestRouter(t)
|
||||
t.Cleanup(logs.Wait)
|
||||
base := "/api/v1/oci-configs/" + strconv.FormatUint(uint64(id), 10) + "/idp-icons"
|
||||
fileName := "images/idp-icon-" + strings.Repeat("ab", 16) + ".png"
|
||||
query := url.Values{"fileName": {fileName}}
|
||||
rec := doRequest(t, router, http.MethodDelete, base+"?"+query.Encode(), token, "")
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Errorf("delete status = %d, want 204, body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec = doRequest(t, router, http.MethodDelete, base+"?fileName=images/company-brand.png", token, "")
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("invalid delete status = %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func assertResponderStatus(t *testing.T, name string, err error, want int, responder func(*gin.Context, error)) {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(rec)
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
responder(ctx, err)
|
||||
if rec.Code != want {
|
||||
t.Errorf("%s status = %d, want %d", name, rec.Code, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdpIconErrorResponder(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
want int
|
||||
}{
|
||||
{"empty icon", service.ErrIdpIconEmpty, 400},
|
||||
{"bad icon name", service.ErrIdpIconBadName, 400},
|
||||
{"large icon", service.ErrIdpIconTooLarge, 413},
|
||||
{"bad icon type", service.ErrIdpIconBadType, 415},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
assertResponderStatus(t, tc.name, tc.err, tc.want, respondIdpIconErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateIdpOrdinaryErrorHasNoPartialSemantics(t *testing.T) {
|
||||
rec, body := callCreateIdpResponder(t, oci.IdentityProviderInfo{}, errors.New("create failed"))
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500; body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, ok := body["setupWarning"]; ok || bytes.Contains(rec.Body.Bytes(), []byte("resourceCreated")) {
|
||||
t.Errorf("ordinary error unexpectedly carries partial semantics: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateIdpPartialErrorReturnsStableWarning(t *testing.T) {
|
||||
partial := &oci.PartialIdentityProviderCreateError{
|
||||
IdentityProvider: oci.IdentityProviderInfo{ID: "idp-1", Name: "test-idp"},
|
||||
}
|
||||
rec, body := callCreateIdpResponder(t, oci.IdentityProviderInfo{}, errors.Join(errors.New("raw secret"), partial))
|
||||
warning, ok := body["setupWarning"].(map[string]interface{})
|
||||
if rec.Code != http.StatusCreated || !ok {
|
||||
t.Fatalf("status = %d, warning = %#v; body %s", rec.Code, body["setupWarning"], rec.Body.String())
|
||||
}
|
||||
requestID, _ := warning["requestId"].(string)
|
||||
if warning["code"] != oci.IdpSetupWarningCode || warning["resourceCreated"] != true || requestID == "" {
|
||||
t.Errorf("warning = %#v, want stable partial-create contract", warning)
|
||||
}
|
||||
if bytes.Contains(rec.Body.Bytes(), []byte("secret")) {
|
||||
t.Errorf("partial response leaks internal cause: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func callCreateIdpResponder(t *testing.T, idp oci.IdentityProviderInfo, err error) (*httptest.ResponseRecorder, map[string]interface{}) {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(rec)
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, "/identity-providers", nil)
|
||||
respondCreateIdpResult(ctx, idp, err)
|
||||
body := map[string]interface{}{}
|
||||
if decodeErr := json.Unmarshal(rec.Body.Bytes(), &body); decodeErr != nil {
|
||||
t.Fatalf("decode response: %v", decodeErr)
|
||||
}
|
||||
return rec, body
|
||||
}
|
||||
|
||||
func TestPARErrorResponder(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
}{
|
||||
{"bad PAR type", service.ErrPARInvalidAccessType},
|
||||
{"bad PAR expiry", service.ErrPARInvalidExpiration},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
assertResponderStatus(t, tc.name, tc.err, http.StatusBadRequest, respondPARError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePARValidationReturns400(t *testing.T) {
|
||||
router, token, id, logs := newIconTestRouter(t)
|
||||
t.Cleanup(logs.Wait)
|
||||
path := "/api/v1/oci-configs/" + strconv.FormatUint(uint64(id), 10) + "/buckets/b/pars"
|
||||
cases := []struct {
|
||||
name, body string
|
||||
}{
|
||||
{"bad access type", `{"accessType":"BucketRead","expiresHours":24}`},
|
||||
{"zero expiration", `{"accessType":"ObjectRead","expiresHours":0}`},
|
||||
{"excessive expiration", `{"accessType":"ObjectRead","expiresHours":876001}`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
rec := doRequest(t, router, http.MethodPost, path, token, tc.body)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("%s status = %d, want 400, body %s", tc.name, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"oci-portal/internal/oci"
|
||||
"oci-portal/internal/service"
|
||||
)
|
||||
|
||||
// ---- 对象存储 ----
|
||||
@@ -363,6 +364,7 @@ type createPARRequest struct {
|
||||
// @Param bucket path string true "桶名"
|
||||
// @Param body body createPARRequest true "请求体"
|
||||
// @Success 201 {object} oci.PAR "fullUrl 仅创建响应返回,请立即保存"
|
||||
// @Failure 400 {object} map[string]string "accessType 或 expiresHours 非法"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/oci-configs/{id}/buckets/{bucket}/pars [post]
|
||||
func (h *ociConfigHandler) createPAR(c *gin.Context) {
|
||||
@@ -379,12 +381,23 @@ func (h *ociConfigHandler) createPAR(c *gin.Context) {
|
||||
Name: req.Name, ObjectName: req.ObjectName, AccessType: req.AccessType, ExpiresHours: req.ExpiresHours,
|
||||
})
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
respondPARError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, par)
|
||||
}
|
||||
|
||||
func respondPARError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrPARInvalidAccessType):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "accessType 不受支持"})
|
||||
case errors.Is(err, service.ErrPARInvalidExpiration):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "expiresHours 必须在 1-876000 范围内"})
|
||||
default:
|
||||
respondError(c, err)
|
||||
}
|
||||
}
|
||||
|
||||
// parPage 是 PAR 游标分页响应;nextPage 空串表示已到末页。
|
||||
type parPage struct {
|
||||
Items []oci.PAR `json:"items"`
|
||||
@@ -424,7 +437,8 @@ func (h *ociConfigHandler) listPARs(c *gin.Context) {
|
||||
// @Param parId query string false "PAR ID(可含 / 等字符,故经 query 传递)"
|
||||
// @Param all query string false "为 1 时删除全部"
|
||||
// @Param region query string false "区域"
|
||||
// @Success 204 "删除成功,链接立即失效"
|
||||
// @Success 200 {object} map[string]int "all=1 时返回 {deleted: n}"
|
||||
// @Success 204 "按 parId 删单条成功,链接立即失效"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/oci-configs/{id}/buckets/{bucket}/pars [delete]
|
||||
func (h *ociConfigHandler) deletePAR(c *gin.Context) {
|
||||
|
||||
@@ -34,6 +34,13 @@ func NewRouter(auth *service.AuthService, oauth *service.OAuthService, ociConfig
|
||||
registerSettings(secured, settings, notifier, systemLogs, proxies)
|
||||
registerTasksAndLogs(secured, tasks, logEvents)
|
||||
registerOci(secured, ociConfigs)
|
||||
// 上传类接口独立成组:文件本体上限 1MB,multipart 边界与字段头另占空间,
|
||||
// 留在统一 1MB 的 v1 组里恰好 1MB 的文件会被截断;超限由 handler 映射 413
|
||||
uploads := r.Group("/api/v1", bodyLimit(1<<20+64<<10), RequireAuth(auth), systemLogMiddleware(systemLogs))
|
||||
registerOciUploads(uploads, ociConfigs)
|
||||
// 对象内容中转 PUT:5MB 文件上限 + 封装余量,精确上限由 handler/service 执行
|
||||
contentUploads := r.Group("/api/v1", bodyLimit(5<<20+64<<10), RequireAuth(auth), systemLogMiddleware(systemLogs))
|
||||
registerOciContentUploads(contentUploads, ociConfigs)
|
||||
registerAiAdmin(secured, aiGateway)
|
||||
registerSwagger(r)
|
||||
|
||||
|
||||
@@ -56,6 +56,14 @@ func (nullClient) GetSubscription(context.Context, oci.Credentials, string) (oci
|
||||
return oci.SubscriptionDetail{}, nil
|
||||
}
|
||||
|
||||
func (nullClient) UploadDomainImage(_ context.Context, _ oci.Credentials, _, _, fileName string, _ []byte) (string, string, error) {
|
||||
return "https://images.example/" + fileName, "images/generated/" + fileName, nil
|
||||
}
|
||||
|
||||
func (nullClient) DeleteDomainImage(context.Context, oci.Credentials, string, string, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func newTestRouter(t *testing.T) (*gin.Engine, *service.AuthService, *service.SystemLogService) {
|
||||
t.Helper()
|
||||
r, auth, systemLogs, _ := newTestRouterDB(t)
|
||||
|
||||
@@ -35,5 +35,5 @@ func registerAuthSecured(secured *gin.RouterGroup, auth *service.AuthService, oa
|
||||
secured.DELETE("/auth/identities/:id", ax.unbindIdentity)
|
||||
secured.POST("/auth/revoke-sessions", ax.revokeSessions)
|
||||
secured.GET("/settings/oauth", func(c *gin.Context) { ax.getOAuthSettings(c, settings) })
|
||||
secured.PUT("/settings/oauth", func(c *gin.Context) { ax.updateOAuthSettings(c, settings) })
|
||||
secured.PATCH("/settings/oauth", func(c *gin.Context) { ax.updateOAuthSettings(c, settings) })
|
||||
}
|
||||
|
||||
@@ -127,13 +127,21 @@ func registerOciObjectStorage(g *gin.RouterGroup, h *ociConfigHandler) {
|
||||
g.GET("/oci-configs/:id/buckets/:bucket/objects/detail", h.objectDetail)
|
||||
// 小文件中转:预览/编辑直读直写,不签发 PAR
|
||||
g.GET("/oci-configs/:id/buckets/:bucket/objects/content", h.getObjectContent)
|
||||
g.PUT("/oci-configs/:id/buckets/:bucket/objects/content", h.putObjectContent)
|
||||
g.GET("/oci-configs/:id/buckets/:bucket/pars", h.listPARs)
|
||||
g.POST("/oci-configs/:id/buckets/:bucket/pars", h.createPAR)
|
||||
// parId 经 query 传递:OCI PAR id 含 "/" 等字符,路径参数无法匹配
|
||||
g.DELETE("/oci-configs/:id/buckets/:bucket/pars", h.deletePAR)
|
||||
}
|
||||
|
||||
// registerOciUploads 挂需放宽请求体上限的上传接口;router 侧以独立组绕开
|
||||
// v1 统一 1MB bodyLimit(multipart 除文件本体外还有边界与字段头开销)。
|
||||
// idp-icons 用独立路径,避免与 /identity-providers/:idpId 同段静态/参数混排。
|
||||
func registerOciUploads(g *gin.RouterGroup, ociConfigs *service.OciConfigService) {
|
||||
h := &ociConfigHandler{svc: ociConfigs}
|
||||
g.POST("/oci-configs/:id/idp-icons", h.uploadIdpIcon)
|
||||
g.DELETE("/oci-configs/:id/idp-icons", h.deleteIdpIcon)
|
||||
}
|
||||
|
||||
// registerOciCost 成本快照。
|
||||
func registerOciCost(g *gin.RouterGroup, h *ociConfigHandler) {
|
||||
g.GET("/oci-configs/:id/costs", h.costs)
|
||||
@@ -167,3 +175,11 @@ func registerOciTenantIAM(g *gin.RouterGroup, h *ociConfigHandler) {
|
||||
g.POST("/oci-configs/:id/sign-on-exemptions", h.createMfaExemption)
|
||||
g.DELETE("/oci-configs/:id/sign-on-exemptions/:ruleId", h.deleteMfaExemption)
|
||||
}
|
||||
|
||||
// registerOciContentUploads 挂对象内容保存接口(编辑器直写,5MB 文件上限);
|
||||
// 必须留在独立的大 body limit 组:统一 1MB 组会先截断请求,handler 的 5MB
|
||||
// 上限与 swagger 声明就成了空话(2026-07 全量审查 #11)。
|
||||
func registerOciContentUploads(g *gin.RouterGroup, ociConfigs *service.OciConfigService) {
|
||||
h := &ociConfigHandler{svc: ociConfigs}
|
||||
g.PUT("/oci-configs/:id/buckets/:bucket/objects/content", h.putObjectContent)
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ func registerSettings(secured *gin.RouterGroup, settings *service.SettingService
|
||||
secured.GET("/settings/task", st.getTaskSettings)
|
||||
secured.PUT("/settings/task", st.updateTaskSettings)
|
||||
secured.GET("/settings/security", st.getSecurity)
|
||||
secured.PUT("/settings/security", st.updateSecurity)
|
||||
secured.PATCH("/settings/security", st.updateSecurity)
|
||||
|
||||
px := &proxyHandler{svc: proxies}
|
||||
secured.GET("/proxies", px.list)
|
||||
|
||||
@@ -316,16 +316,17 @@ func (h *settingsHandler) getSecurity(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, view)
|
||||
}
|
||||
|
||||
// updateSecurity 保存安全设置并返回最新值;越界或非法返回 400,保存后立即生效。
|
||||
// updateSecurity 部分更新安全设置并返回最新值;只落库请求中出现的字段,
|
||||
// 越界或非法返回 400,保存后立即生效。
|
||||
//
|
||||
// @Summary 保存安全设置并返回最新值
|
||||
// @Summary 部分更新安全设置并返回最新值
|
||||
// @Tags 设置
|
||||
// @Param body body service.SecuritySettings true "请求体"
|
||||
// @Param body body service.SecurityPatch true "出现的字段才会被更新"
|
||||
// @Success 200 {object} service.SecuritySettings
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/settings/security [put]
|
||||
// @Router /api/v1/settings/security [patch]
|
||||
func (h *settingsHandler) updateSecurity(c *gin.Context) {
|
||||
var req service.SecuritySettings
|
||||
var req service.SecurityPatch
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
|
||||
+16
-2
@@ -92,8 +92,10 @@ func (h *taskHandler) get(c *gin.Context) {
|
||||
// @Summary 更新任务
|
||||
// @Tags 任务与日志回传
|
||||
// @Param id path int true "任务 ID"
|
||||
// @Param body body updateTaskRequest true "可更新字段"
|
||||
// @Param body body updateTaskRequest true "可更新字段;抢机 payload 的 count 语义为目标台数"
|
||||
// @Success 200 {object} model.Task
|
||||
// @Failure 400 {object} errorResponse "参数非法或抢机目标不大于已完成数量"
|
||||
// @Failure 409 {object} errorResponse "任务被并发修改,须刷新重试"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/tasks/{id} [put]
|
||||
func (h *taskHandler) update(c *gin.Context) {
|
||||
@@ -113,12 +115,24 @@ func (h *taskHandler) update(c *gin.Context) {
|
||||
Status: req.Status,
|
||||
})
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
respondUpdateTaskErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, task)
|
||||
}
|
||||
|
||||
// respondUpdateTaskErr 把任务编辑的哨兵错误映射为语义状态码,其余走统一错误边界。
|
||||
func respondUpdateTaskErr(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrTaskConflict):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, service.ErrSnatchTargetTooLow):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
default:
|
||||
respondError(c, err)
|
||||
}
|
||||
}
|
||||
|
||||
// @Summary 删除任务
|
||||
// @Tags 任务与日志回传
|
||||
// @Param id path int true "任务 ID"
|
||||
|
||||
Reference in New Issue
Block a user