fix: 落实 #3867-#3870 合并审计的全部跟进项

透传规则(跟进 #3868/#3870,refs #3857):
- CC/Messages 4 条协议转换路径改用共享 helper,走语义状态推断 +
  body 归一化,使按错误码配置的透传规则也能命中(原先传 0 恒不命中)
- helper 增加 platform 参数:本服务同时承载 openai/grok 平台账号,
  规则须按 account.Platform 匹配,消除硬编码平台错配
- /v1/responses 两条路径命中透传规则时补记 ops 上游错误事件,
  对齐 CC/Messages 与 antigravity 先例,消除监控盲区

用户角色管理(跟进 #3869):
- 补齐 EN 语言包缺失的 admin.users.form.roleLabel
- 新增"最后一个管理员不可降级"守卫,覆盖跨管理员互降致零 admin 锁死
- 角色变更/创建管理员落审计日志(含操作者 actor_admin_id)
This commit is contained in:
shaw
2026-07-09 20:06:09 +08:00
parent 10102807e2
commit 7918b1a9c5
11 changed files with 221 additions and 28 deletions
@@ -276,6 +276,7 @@ func (h *UserHandler) Create(c *gin.Context) {
Concurrency: req.Concurrency,
RPMLimit: req.RPMLimit,
AllowedGroups: req.AllowedGroups,
ActorAdminID: getAdminIDFromContext(c),
})
if err != nil {
response.ErrorFrom(c, err)
@@ -320,6 +321,7 @@ func (h *UserHandler) Update(c *gin.Context) {
Status: req.Status,
AllowedGroups: req.AllowedGroups,
GroupRates: req.GroupRates,
ActorAdminID: getAdminIDFromContext(c),
})
if err != nil {
response.ErrorFrom(c, err)
@@ -130,6 +130,8 @@ type CreateUserInput struct {
Concurrency int
RPMLimit int
AllowedGroups []int64
// ActorAdminID 执行本次操作的管理员ID(来自JWT),仅用于权限敏感操作的审计日志。
ActorAdminID int64
}
type UpdateUserInput struct {
@@ -146,6 +148,8 @@ type UpdateUserInput struct {
// GroupRates 用户专属分组倍率配置
// map[groupID]*ratenil 表示删除该分组的专属倍率
GroupRates map[int64]*float64
// ActorAdminID 执行本次操作的管理员ID(来自JWT),仅用于权限敏感操作的审计日志。
ActorAdminID int64
}
type AdminBindAuthIdentityInput struct {
@@ -6,6 +6,7 @@ import (
"context"
"testing"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/stretchr/testify/require"
)
@@ -83,3 +84,60 @@ func TestAdminService_UpdateUser_InvalidRoleRejected(t *testing.T) {
require.Error(t, err)
require.Nil(t, repo.lastUpdated, "非法角色不应触发持久化")
}
// roleGuardUserRepoStub 在 rpmUserRepoStub 之上提供可控的管理员计数,
// 用于测试"最后一个管理员不可降级"守卫。
type roleGuardUserRepoStub struct {
*rpmUserRepoStub
adminTotal int64
listCalls int
}
func (s *roleGuardUserRepoStub) ListWithFilters(_ context.Context, _ pagination.PaginationParams, _ UserListFilters) ([]User, *pagination.PaginationResult, error) {
s.listCalls++
return nil, &pagination.PaginationResult{Total: s.adminTotal}, nil
}
func TestAdminService_UpdateUser_DemoteLastAdminRejected(t *testing.T) {
base := &userRepoStub{user: &User{ID: 42, Email: "a@example.com", Role: RoleAdmin}}
repo := &roleGuardUserRepoStub{rpmUserRepoStub: &rpmUserRepoStub{userRepoStub: base}, adminTotal: 1}
svc := &adminServiceImpl{userRepo: repo, redeemCodeRepo: &redeemRepoStub{}}
_, err := svc.UpdateUser(context.Background(), 42, &UpdateUserInput{Role: RoleUser})
require.Error(t, err)
require.Contains(t, err.Error(), "last admin")
require.Nil(t, repo.lastUpdated, "最后一个管理员不应被降级持久化")
require.Equal(t, 1, repo.listCalls, "降级路径应触发管理员计数")
}
func TestAdminService_UpdateUser_DemoteAdminAllowedWhenOthersExist(t *testing.T) {
base := &userRepoStub{user: &User{ID: 42, Email: "a@example.com", Role: RoleAdmin}}
repo := &roleGuardUserRepoStub{rpmUserRepoStub: &rpmUserRepoStub{userRepoStub: base}, adminTotal: 2}
invalidator := &authCacheInvalidatorStub{}
svc := &adminServiceImpl{
userRepo: repo,
redeemCodeRepo: &redeemRepoStub{},
authCacheInvalidator: invalidator,
}
updated, err := svc.UpdateUser(context.Background(), 42, &UpdateUserInput{Role: RoleUser})
require.NoError(t, err)
require.Equal(t, RoleUser, updated.Role)
require.NotNil(t, repo.lastUpdated)
require.Equal(t, RoleUser, repo.lastUpdated.Role, "存在其他管理员时允许降级")
}
func TestAdminService_UpdateUser_PromoteDoesNotCountAdmins(t *testing.T) {
base := &userRepoStub{user: &User{ID: 42, Email: "u@example.com", Role: RoleUser}}
repo := &roleGuardUserRepoStub{rpmUserRepoStub: &rpmUserRepoStub{userRepoStub: base}, adminTotal: 1}
svc := &adminServiceImpl{
userRepo: repo,
redeemCodeRepo: &redeemRepoStub{},
authCacheInvalidator: &authCacheInvalidatorStub{},
}
updated, err := svc.UpdateUser(context.Background(), 42, &UpdateUserInput{Role: RoleAdmin})
require.NoError(t, err)
require.Equal(t, RoleAdmin, updated.Role)
require.Equal(t, 0, repo.listCalls, "升级路径不应触发管理员计数")
}
+36
View File
@@ -148,10 +148,33 @@ func (s *adminServiceImpl) CreateUser(ctx context.Context, input *CreateUserInpu
if err := s.userRepo.Create(ctx, user); err != nil {
return nil, err
}
// 创建管理员属权限敏感操作,落审计日志(含操作者),便于事后追溯。
if user.Role == RoleAdmin {
logger.LegacyPrintf("service.admin", "audit: admin user created actor_admin_id=%d target_user_id=%d",
input.ActorAdminID, user.ID)
}
s.assignDefaultSubscriptions(ctx, user.ID)
return user, nil
}
// ensureNotLastAdmin 降级管理员前确认系统中仍存在其他管理员,防止零 admin 锁死。
// 注:读取与写入之间存在竞态窗口,极端并发下仍可能双双降级;作为后台低频操作
// 的兜底保护足够,彻底防护需依赖数据库层约束。
func (s *adminServiceImpl) ensureNotLastAdmin(ctx context.Context) error {
noSubs := false
_, result, err := s.userRepo.ListWithFilters(ctx,
pagination.PaginationParams{Page: 1, PageSize: 1},
UserListFilters{Role: RoleAdmin, IncludeSubscriptions: &noSubs},
)
if err != nil {
return fmt.Errorf("count admin users: %w", err)
}
if result == nil || result.Total <= 1 {
return errors.New("cannot demote the last admin user")
}
return nil
}
func (s *adminServiceImpl) assignDefaultSubscriptions(ctx context.Context, userID int64) {
if s.settingService == nil || s.defaultSubAssigner == nil || userID <= 0 {
return
@@ -221,6 +244,13 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda
if err != nil {
return nil, err
}
// 防锁死保护:不允许降级系统中最后一个管理员(自我降级已在 handler 层拦截,
// 此处兜底覆盖跨管理员互降导致零 admin 的场景)。
if user.Role == RoleAdmin && role == RoleUser {
if err := s.ensureNotLastAdmin(ctx); err != nil {
return nil, err
}
}
user.Role = role
}
@@ -240,6 +270,12 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda
return nil, err
}
// 角色变更属权限敏感操作,落审计日志(含操作者),便于事后追溯。
if user.Role != oldRole {
logger.LegacyPrintf("service.admin", "audit: user role changed actor_admin_id=%d target_user_id=%d old_role=%s new_role=%s",
input.ActorAdminID, user.ID, oldRole, user.Role)
}
// 同步用户专属分组倍率
if input.GroupRates != nil && s.userGroupRateRepo != nil {
if err := s.userGroupRateRepo.SyncUserGroupRates(ctx, user.ID, input.GroupRates); err != nil {
@@ -425,14 +425,11 @@ func (s *OpenAIGatewayService) handleChatBufferedStreamingResponse(
return nil, s.newOpenAIStreamFailoverError(c, account, false, requestID, payload, message)
}
message = s.recordOpenAIStreamUpstreamError(c, account, false, requestID, "http_error", payload, message)
// response.failed 到达在 HTTP 200 SSE 流上,无真实 HTTP 错误码,传 0。
if status, errType, errMsg, matched := applyErrorPassthroughRule(
c, account.Platform, 0, payload,
http.StatusBadGateway, "upstream_error", message,
// response.failed 到达在 HTTP 200 SSE 流上,无真实 HTTP 错误码;统一走语义
// 状态推断 + body 归一化(与 /v1/responses 路径一致),使按错误码配置的规则可命中。
if status, errType, errMsg, matched := applyOpenAIStreamFailedErrorPassthroughRule(
c, account.Platform, payload, message,
); matched {
if status == 0 {
status = http.StatusBadGateway
}
if errMsg == "" {
errMsg = message
}
@@ -597,13 +594,11 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse(
}
message = s.recordOpenAIStreamUpstreamError(c, account, false, requestID, "http_error", payloadBytes, message)
defaultStatus, defaultErrType, defaultMsg := http.StatusBadGateway, "upstream_error", message
if status, errType, errMsg, matched := applyErrorPassthroughRule(
c, account.Platform, 0, payloadBytes,
defaultStatus, defaultErrType, defaultMsg,
// 统一走语义状态推断 + body 归一化(与 /v1/responses 路径一致),
// 使按错误码配置的透传规则可命中。
if status, errType, errMsg, matched := applyOpenAIStreamFailedErrorPassthroughRule(
c, account.Platform, payloadBytes, message,
); matched {
if status == 0 {
status = defaultStatus
}
if errMsg == "" {
errMsg = defaultMsg
}
@@ -465,13 +465,11 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse(
return nil, s.newOpenAIStreamFailoverError(c, account, false, requestID, payload, message)
}
message = s.recordOpenAIStreamUpstreamError(c, account, false, requestID, "http_error", payload, message)
if status, errType, errMsg, matched := applyErrorPassthroughRule(
c, account.Platform, 0, payload,
http.StatusBadGateway, "api_error", message,
// 统一走语义状态推断 + body 归一化(与 /v1/responses 路径一致),
// 使按错误码配置的透传规则可命中。
if status, errType, errMsg, matched := applyOpenAIStreamFailedErrorPassthroughRule(
c, account.Platform, payload, message,
); matched {
if status == 0 {
status = http.StatusBadGateway
}
if errMsg == "" {
errMsg = message
}
@@ -819,13 +817,11 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse(
}
message = s.recordOpenAIStreamUpstreamError(c, account, false, requestID, "http_error", payloadBytes, message)
errStatus, errType, errMsg := http.StatusBadGateway, "api_error", message
if status, et, em, matched := applyErrorPassthroughRule(
c, account.Platform, 0, payloadBytes,
errStatus, errType, errMsg,
// 统一走语义状态推断 + body 归一化(与 /v1/responses 路径一致),
// 使按错误码配置的透传规则可命中。
if status, et, em, matched := applyOpenAIStreamFailedErrorPassthroughRule(
c, account.Platform, payloadBytes, message,
); matched {
if status == 0 {
status = errStatus
}
if em == "" {
em = errMsg
}
@@ -694,8 +694,12 @@ func openAIStreamFailedEventPassthroughBody(payload []byte, failedMessage string
return body
}
// applyOpenAIStreamFailedErrorPassthroughRule 对 response.failed 事件应用错误透传规则:
// 归一化 body 供关键词匹配/消息提取,并推断语义状态码使按错误码配置的规则可以命中。
// platform 必须传 account.Platform——本服务同时承载 openai 与 grok 平台账号,规则按平台匹配。
func applyOpenAIStreamFailedErrorPassthroughRule(
c *gin.Context,
platform string,
payload []byte,
failedMessage string,
) (status int, errType string, errMsg string, matched bool) {
@@ -703,7 +707,7 @@ func applyOpenAIStreamFailedErrorPassthroughRule(
upstreamStatus := openAIStreamFailedEventSemanticStatus(payload, failedMessage)
return applyErrorPassthroughRule(
c,
PlatformOpenAI,
platform,
upstreamStatus,
ruleBody,
http.StatusBadGateway,
@@ -922,7 +926,10 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
})
}
if !openAIStreamClientOutputStarted(c, clientOutputStarted) {
if status, errType, errMsg, matched := applyOpenAIStreamFailedErrorPassthroughRule(c, dataBytes, failedMessage); matched {
if status, errType, errMsg, matched := applyOpenAIStreamFailedErrorPassthroughRule(c, account.Platform, dataBytes, failedMessage); matched {
// 命中透传规则也要记录 ops 上游错误事件(对齐 CC/Messages 与
// antigravity 先例),否则透传命中的 failed 在监控中不可见。
s.recordOpenAIStreamUpstreamError(c, account, true, upstreamRequestID, "http_error", dataBytes, failedMessage)
MarkResponseCommitted(c)
c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8")
c.JSON(status, gin.H{
@@ -141,3 +141,84 @@ func TestForwardAsChatCompletions_ResponseFailed_NoRule_Still502(t *testing.T) {
require.Error(t, err)
require.Equal(t, http.StatusBadGateway, rec.Code, "without passthrough rule should still be 502")
}
// bindStatusCodePassthroughRule 绑定一条按错误码+关键词双条件(MatchModeAll)匹配的规则。
// 此类规则依赖语义状态码推断才能在协议转换路径命中(response.failed 无真实 HTTP 状态码)。
func bindStatusCodePassthroughRule(c *gin.Context, platform string, statusCode int, keyword string, responseCode int) {
rule := &model.ErrorPassthroughRule{
ID: 1,
Name: "status-code-rule",
Enabled: true,
Priority: 1,
Platforms: []string{platform},
ErrorCodes: []int{statusCode},
Keywords: []string{keyword},
MatchMode: model.MatchModeAll,
ResponseCode: &responseCode,
PassthroughBody: true,
}
svc := &ErrorPassthroughService{}
svc.setLocalCache([]*model.ErrorPassthroughRule{rule})
BindErrorPassthroughService(c, svc)
}
func TestForwardAsChatCompletions_ResponseFailed_ErrorCodeRuleMatchesViaSemanticStatus(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":false}`)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
bindStatusCodePassthroughRule(c, "openai", http.StatusBadRequest, "context_length_exceeded", http.StatusBadRequest)
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader(buildContextLengthFailedSSE())),
}}
svc := &OpenAIGatewayService{
cfg: rawChatCompletionsTestConfig(),
httpUpstream: upstream,
}
account := rawChatCompletionsTestAccount()
_, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "")
require.Error(t, err)
require.Equal(t, http.StatusBadRequest, rec.Code, "error-code-conditioned rule should match via semantic status inference")
respBody := rec.Body.String()
require.Equal(t, "upstream_error", gjson.Get(respBody, "error.type").String())
require.Contains(t, gjson.Get(respBody, "error.message").String(), "context window")
}
func TestForwardAsAnthropic_ResponseFailed_ErrorCodeRuleMatchesViaSemanticStatus(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-5.4","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":false}`)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
bindStatusCodePassthroughRule(c, "openai", http.StatusBadRequest, "context_length_exceeded", http.StatusBadRequest)
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader(buildContextLengthFailedSSE())),
}}
svc := &OpenAIGatewayService{
cfg: rawChatCompletionsTestConfig(),
httpUpstream: upstream,
}
account := rawChatCompletionsTestAccount()
_, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "")
require.Error(t, err)
require.Equal(t, http.StatusBadRequest, rec.Code, "error-code-conditioned rule should match via semantic status inference")
respBody := rec.Body.String()
require.NotEmpty(t, gjson.Get(respBody, "error.message").String())
}
@@ -256,8 +256,11 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp
})
}
if !openAIStreamClientOutputStarted(c, clientOutputStarted) {
if status, errType, errMsg, matched := applyOpenAIStreamFailedErrorPassthroughRule(c, dataBytes, failedMessage); matched {
if status, errType, errMsg, matched := applyOpenAIStreamFailedErrorPassthroughRule(c, account.Platform, dataBytes, failedMessage); matched {
sawFailedEvent = true
// 命中透传规则也要记录 ops 上游错误事件(对齐 CC/Messages 与
// antigravity 先例),否则透传命中的 failed 在监控中不可见。
s.recordOpenAIStreamUpstreamError(c, account, false, upstreamRequestID, "http_error", dataBytes, failedMessage)
MarkResponseCommitted(c)
c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8")
c.JSON(status, gin.H{
@@ -1504,6 +1504,11 @@ func TestOpenAIStreamingContextWindowResponseFailedBeforeOutputAppliesPassthroug
require.Equal(t, upstreamMessage, gjson.Get(body, "error.message").String())
require.NotContains(t, body, "response.failed")
require.NotContains(t, body, "Upstream request failed")
// 命中透传规则也应记录 ops 上游错误事件(对齐 CC/Messages 与 antigravity 先例)。
opsVal, opsRecorded := c.Get(OpsUpstreamErrorsKey)
require.True(t, opsRecorded, "passthrough hit should record an ops upstream error event")
opsEvents, _ := opsVal.([]*OpsUpstreamErrorEvent)
require.NotEmpty(t, opsEvents)
}
func TestOpenAIStreamingPreambleOnlyMissingTerminalReturnsFailover(t *testing.T) {
@@ -1890,6 +1895,11 @@ func TestOpenAIStreamingPassthroughContextWindowResponseFailedBeforeOutputApplie
require.Equal(t, upstreamMessage, gjson.Get(body, "error.message").String())
require.NotContains(t, body, "response.failed")
require.NotContains(t, body, "Upstream request failed")
// 命中透传规则也应记录 ops 上游错误事件(对齐 CC/Messages 与 antigravity 先例)。
opsVal, opsRecorded := c.Get(OpsUpstreamErrorsKey)
require.True(t, opsRecorded, "passthrough hit should record an ops upstream error event")
opsEvents, _ := opsVal.([]*OpsUpstreamErrorEvent)
require.NotEmpty(t, opsEvents)
}
func TestOpenAIStreamingPassthroughContextWindowResponseFailedBeforeOutputWithoutRulePassesThrough(t *testing.T) {
@@ -433,6 +433,7 @@ export default {
creating: 'Creating...',
updating: 'Updating...',
form: {
roleLabel: 'Role',
rpmLimit: 'Requests Per Minute (RPM)',
rpmLimitPlaceholder: '0 = unlimited',
rpmLimitHint: 'Max requests per minute for this user; 0 = unlimited. Acts as a fallback only when the group has no rpm_limit set.'