修复全量审查问题;设置接口PATCH化;回传指纹加固
CI / test (push) Successful in 32s

This commit is contained in:
2026-07-22 16:51:23 +08:00
parent 0614ef22af
commit f51fb6c722
66 changed files with 3997 additions and 687 deletions
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
}
// ---- FederationSAML 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 激活身份提供商
+217
View File
@@ -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())
}
}
}
+16 -2
View File
@@ -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) {
+7
View File
@@ -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)
+8
View File
@@ -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)
+1 -1
View File
@@ -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) })
}
+17 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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)
+6 -5
View File
@@ -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
View File
@@ -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"