Compare commits

...
74 Commits
Author SHA1 Message Date
Wesley Liddick 26d894ef4f Merge pull request #5137 from Wei-Shaw/fix/upstream-url-path-segment-validation
fix(gateway): 收紧上游 URL 路径片段校验
2026-07-31 16:46:00 +08:00
shaw 017f6bbd5e fix(gateway): 收紧上游 URL 路径片段校验
网关有若干位置会把客户端可控的字符串拼进上游请求的 URL path(Responses
子路径、Gemini 模型名)。此前这些字符串未经校验直接参与拼接,可能改变上游
请求的路径结构,使实际发出的请求与客户端意图不一致。

- 新增 internal/service/upstream_path_guard.go:路径片段闭集允许清单
  (\w + `-` + `.`),拒绝空片段、纯点片段、超长片段与过深后缀
- /responses/*subpath 三条路由入口新增守卫,不可转发的子路径直接 404;
  service 层同时保证不产出不合规后缀,拼接函数再兜底一层
- Gemini AI Studio 原先 5 处重复的 URL 拼接收敛为唯一构造点
  buildGeminiAIStudioModelActionURL(校验模型片段 + action 白名单)
- Gemini native / GetModel handler 增加入口校验;ForwardAIStudioGET 逐片段校验
- Grok video 端点的 request_id 增加片段合规校验

合法子路径(/compact、/compact/detail、/{id}/cancel 形态)与既有模型名行为
不变,通配路由保留。
2026-07-31 16:11:45 +08:00
Wesley Liddick f9d2791693 Merge pull request #5032 from Ricardo-binZzz/fix/release-pricing-fallback-resource
fix(release): include pricing fallback resources
2026-07-31 14:00:07 +08:00
Ricardo-binZzz 105e5c5da3 fix(release): include pricing fallback resources 2026-07-31 13:51:22 +08:00
Wesley Liddick bf0fc03ab7 Merge pull request #5018 from hongheshan-svg/fix/glm-5.2-fallback-pricing
fix(billing): 补上 glm-5.2 兜底价,避免被 glm-5 子串匹配抢走
2026-07-31 12:00:45 +08:00
Wesley Liddick 2be08f3f39 Merge pull request #4913 from jeshica/fix/anthropic-count-tokens-max-tokens
fix: strip max_tokens from Anthropic count tokens
2026-07-31 11:58:28 +08:00
hongheshan-svgandClaude Opus 5 493955f7bd fix(billing): add glm-5.2 fallback pricing to stop glm-5 substring match
glm-5.2 has no entry in the fallback table, so getFallbackPricing falls
through to `strings.Contains(modelLower, "glm-5")` and prices it at
GLM-5 rates ($1.00 in / $3.20 out per MTok) instead of the official
z.ai rates ($1.40 / $4.40) — roughly 27% under.

LiteLLM carries no bare `glm-5.2` key either (only provider-prefixed
`cloudflare/@cf/zai-org/glm-5.2` and `fireworks_ai/.../glm-5p2`, which
the lookup candidates never match), so the request always lands on the
fallback path and the discrepancy shows up directly in usage logs.

Add the glm-5.2 entry (same price as glm-5.1 per docs.z.ai) and match it
before the bare `glm-5` branch, with a note that dotted variants must
precede it. The existing regression test asserting the old glm-5 price
is updated accordingly.

Source: https://docs.z.ai/guides/overview/pricing

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 11:49:30 +08:00
zhiyu 53aa5cd247 test: align ModelMappingPreservesOtherFields with max_tokens strip
The count_tokens path now strips max_tokens; this sibling test also
exercises ForwardCountTokens but still asserted max_tokens preservation.
Flip its assertion to expect the field is filtered, matching
CountTokensFiltersGenerationFields.
2026-07-31 11:47:21 +08:00
Wesley Liddick 0a45be17d8 Merge pull request #5033 from Ricardo-binZzz/fix/sub2api-no-new-privileges
fix(deploy): prevent application privilege gains
2026-07-31 11:44:28 +08:00
Wesley Liddick 60f6dc91cf Merge pull request #5115 from zvensmoluya/codex/update-gpt56-luna-terra-pricing
[codex] update GPT-5.6 Luna and Terra pricing
2026-07-31 11:44:21 +08:00
Wesley Liddick 1702ee1362 Merge pull request #5078 from wucm667/fix/issue-5072-subscription-expiry-label
fix(frontend): correct subscription expiry labels
2026-07-31 11:44:07 +08:00
Wesley Liddick c4b461c68c Merge pull request #5063 from lucas-ward/codex/issue-4211
fix(payment): keep subscription plan titles readable
2026-07-31 11:44:00 +08:00
Wesley Liddick 3c387a164d Merge pull request #5060 from alexj11324/codex/passkey-deployment-guidance
fix: clarify passkey deployment guidance
2026-07-31 11:43:53 +08:00
Wesley Liddick e854132a57 Merge pull request #4953 from spongehah/brn-qwen3guard-auxiliary-fields
feat(security-audit): allow Qwen3Guard auxiliary fields
2026-07-31 11:43:47 +08:00
Wesley Liddick a8cd33eead Merge pull request #5048 from wucm667/fix/issue-5041-claude-auto-classifier
fix(anthropic): recognize auto mode classifier
2026-07-31 11:43:39 +08:00
Wesley Liddick 276d9cbd9b Merge pull request #4993 from 17Yuns/fix/smtp-message-format
fix(email): generate standards-compliant SMTP messages
2026-07-31 11:43:32 +08:00
Wesley Liddick 1f8b3a9c60 Merge pull request #5037 from heathermhuang/codex/fix-composite-available-models
fix(channels): show Composite models by platform
2026-07-31 11:43:25 +08:00
Wesley Liddick c9156c1e7f Merge pull request #5053 from wucm667/fix/issue-5015-skip-unschedulable-refresh
fix(account): skip unschedulable token refresh candidates
2026-07-31 11:43:18 +08:00
Wesley Liddick 91850d36e6 Merge pull request #5030 from Ricardo-binZzz/fix/ops-cleanup-success-log-level
fix(logging): record cleanup success at info level
2026-07-31 11:43:11 +08:00
Wesley Liddick 6fa784fdd0 Merge pull request #5118 from Wei-Shaw/fix/openai-proxy-stream-circuit-fail-open
fix(openai): 代理断流熔断改为 fail-open 偏好,修复共用代理部署下的调度不可用
2026-07-31 10:42:24 +08:00
shaw da49ce3f29 fix(openai): fail open proxy stream circuit and collapse burst disconnects
The proxy stream circuit introduced in v0.1.164 (#4749) removes every
account behind a quarantined proxy from scheduling. When all schedulable
accounts share one proxy (a common deployment), two mid-stream
disconnects within a minute zeroed out capacity for 10 minutes and every
request failed with 502. One HTTP/2 connection loss also killed all
multiplexed streams at once, tripping the threshold from a single event.

- Quarantine now degrades to a preference: when the only reason no
  account is available is proxy quarantine, selection retries once with
  the quarantine bypassed, so capacity can never reach zero.
- Disconnects within 3s per proxy collapse into one failure event.
- Add gateway.openai_proxy_stream_circuit.disabled escape hatch.
- A completed stream still clears the quarantine immediately; TTL,
  thresholds and recording guards are unchanged.
2026-07-31 10:30:30 +08:00
Zven 313121f3f7 test(pricing): update requested Terra cost 2026-07-31 10:02:04 +08:00
Zven 488d3b09ec test(pricing): update Terra billing ratios 2026-07-31 09:55:16 +08:00
Zven b2d895fb8d fix(pricing): update GPT-5.6 Luna and Terra rates 2026-07-31 09:42:46 +08:00
wucm667 fb40211305 fix(frontend): correct subscription expiry labels 2026-07-30 12:47:05 +08:00
wucm667 352b21f4e2 fix(claude): relax marker to tolerate category element in classifier output
Real claude-cli/2.1.220 samples insert <category>...</category> between
</block> and <reason>, breaking the previous literal marker.
Also fix an unchecked type assertion in the test suite.
2026-07-29 23:25:02 +08:00
BayinForge 0ee9ea5765 fix(payment): keep subscription plan titles readable 2026-07-29 21:23:16 +08:00
Zhixuan Jiang 711056f5b7 fix: clarify passkey deployment guidance 2026-07-29 08:58:33 -04:00
wucm667 ac90355a87 fix(account): skip unschedulable token refresh candidates 2026-07-29 18:35:56 +08:00
wucm667 54b1f8f6b3 fix(anthropic): recognize auto mode classifier 2026-07-29 16:55:42 +08:00
Ricardo-binZzz 0010894f99 fix(deploy): prevent application privilege gains 2026-07-29 13:25:05 +08:00
Ricardo-binZzz 0353cdadfe fix(logging): record cleanup success at info level 2026-07-29 13:25:03 +08:00
Heatherm Huang 92dc61d401 fix(channels): show composite models by platform
Expand visible Composite groups into each configured concrete model platform while preserving ordinary group isolation and empty-state behavior.\n\nFixes #4985
2026-07-29 12:32:44 +08:00
github-actions[bot] 5a6143097d chore: sync VERSION to 0.1.168 [skip ci] 2026-07-29 03:51:01 +00:00
github-actions[bot] b9c7cb8e24 chore: sync VERSION to 0.1.167 [skip ci] 2026-07-29 03:36:59 +00:00
Wesley Liddick 99c8e4bf75 Merge pull request #4973 from yiancode/fix/openai-live-store-resilience
fix(openai-live): Live 会话 finalize 与 observer 对 store 故障的容错,防止用量记录静默丢失
2026-07-29 09:42:56 +08:00
Wesley Liddick f2d824836f Merge pull request #5008 from hansnow/fix/claude-sonnet-5-status-alias
fix(frontend): 补充 Claude Sonnet 5 模型状态别名
2026-07-29 09:41:54 +08:00
Wesley Liddick 6e1cbed423 Merge pull request #5024 from Wei-Shaw/fix/passkey-disabled-toast
fix(profile): 修复未配置 Passkey 时 /profile 每次访问都弹「加载 Passkey 失败」
2026-07-29 09:41:06 +08:00
shaw acad7f1a09 fix(profile): stop passkey load error toast when feature is disabled
The PASSKEY_DISABLED silence guard compared the string error code
against error.code, but the api client puts the numeric envelope code
there and the string code in error.reason, so the guard never matched
and every /profile visit on deployments without WebAuthn configured
showed a spurious "failed to load passkeys" toast.

Read error.reason instead, and skip the credentials request entirely
when the feature is disabled so the card no longer issues a request
that is guaranteed to fail with 403.
2026-07-28 22:51:58 +08:00
Wesley Liddick 8fd01c2814 Merge pull request #5003 from feeeei/main
feat(模型广场): add model plaza with group-scoped pricing showcase
2026-07-28 20:02:52 +08:00
Wesley Liddick 39903f006e Merge pull request #5005 from Wei-Shaw/refactor/scoped-column-updates
refactor(repository): scope user/api-key updates to explicitly declared columns
2026-07-28 19:43:50 +08:00
hansnow 32618e71e4 fix(frontend): 补充 Claude Sonnet 5 状态别名 2026-07-28 18:17:38 +08:00
shaw 86fb4781f4 refactor(repository): scope user/api-key updates to declared columns
UserRepository.Update and APIKeyRepository.Update rewrote the whole row on
every call, regardless of which fields the caller meant to change. Several
columns on those tables are maintained by dedicated atomic paths (balance
deduction, quota and rate-limit counters, limit adjustments, activity
timestamps), so a caller holding a slightly older snapshot could silently
roll them back - a lost update.

Both methods now take an explicit column mask and persist only the columns
the caller declares; everything else keeps its current database value.

- All user and API-key call sites declare exactly what they mutate, which
  turns admin edits and profile saves into genuine partial updates.
- Email uniqueness locking/lookup and allowed_groups sync only run when
  those fields are part of the update.
- UserUpdateFields deliberately has no balance/total_recharged members, so
  Update cannot touch them. New AdjustBalance/SetBalance apply the change in
  a single statement and return before/after values; admin balance
  adjustment uses them instead of read-modify-write.
- promo_codes.used_count is no longer written by Update; it is only ever
  incremented by the redemption path.
- The billing hot path that marks an API key quota-exhausted writes only
  status.
- Dropped a no-op row write in RevokeAllUserTokens: users has no
  token_version column, so it persisted nothing while still overwriting
  concurrently-updated columns.

Adds integration coverage that a stale snapshot cannot revert concurrent
atomic writes, and unit coverage pinning the column set each entry point
declares.
2026-07-28 17:21:32 +08:00
feeeei 720c405e35 feat: add model plaza with group-scoped pricing showcase
- public /model-plaza page (standalone + admin-embedded) listing groups
  with discounted effective prices alongside LiteLLM official reference
- faceted platform/group/rate filters: cross-dimension options gray out
  instead of disappearing, platform-tinted chips via accent color-mix
- paid-price columns highlighted with per-platform tint band
- OptionalJWT middleware so anonymous and signed-in users share one route
- admin settings: enable switch, require-auth switch, markdown description
2026-07-28 16:19:41 +08:00
Wesley Liddick 2e432173f7 Merge pull request #4920 from alexj11324/feat/passkey-auth
feat: add passkey authentication
2026-07-28 14:58:37 +08:00
shaw 38ef8dc069 feat: require account password for passkey enrollment and revocation
A hijacked session must not be able to silently add a passkey as a
persistent backdoor or remove the victim's credentials. Registration
(begin) and deletion now verify the account password server-side,
reusing the existing PASSWORD_REQUIRED / PASSWORD_INCORRECT errors.

The password is used instead of TOTP step-up so the guard also protects
deployments that never configured a TOTP encryption key. The password
key in both request bodies is covered by the audit middleware's
key-substring redaction, so no credential material reaches audit_logs.

Frontend: the add-passkey form gains a current-password field, and the
delete confirmation is now a dialog with a password input (replacing
window.confirm), mirroring the TOTP disable dialog. Backend error
messages (e.g. wrong password) are surfaced instead of the generic
failure toast. Rename remains password-free as it is cosmetic.
2026-07-28 14:12:46 +08:00
17Yuns edac19e87a fix(email): generate standards-compliant SMTP messages 2026-07-28 12:28:46 +08:00
shaw 97f44b21bb fix: keep passkey switch coupled to WebAuthn config and fix CI issues
- parseSettings now reports passkey_enabled=false whenever the WebAuthn
  deployment config is absent: a stale "true" row left behind after the
  config is removed previously made the admin update gate reject every
  settings save while the UI toggle was disabled, leaving no recovery
  path from the admin panel. Added a regression test.
- update the admin settings API contract goldens with the new
  passkey_enabled/passkey_configured/passkey_rp_id/passkey_rp_origins
  fields.
- errcheck: check rows.Close in passkey repository (repo convention).
- staticcheck QF1001: apply De Morgan's law in WebAuthn origin scheme
  validation.
2026-07-28 11:45:18 +08:00
Wesley Liddick 0ef2228ce9 Merge pull request #4906 from alfadb/feature/kimi-k3-support
feat: 支持 Kimi K3 与 Kimi Code 模型 ID
2026-07-28 11:23:53 +08:00
Wesley Liddick 1cf6972e73 Merge pull request #4950 from bestony/worktree/rapid-cloud-510e
feat(setup): add explicit setup bypass
2026-07-28 11:08:34 +08:00
Wesley Liddick 20893dc4fd Merge pull request #4945 from Jopqior/fix/gpt56-anthropic-max-effort
fix(openai): preserve GPT-5.6 max effort in messages bridges
2026-07-28 11:08:20 +08:00
Wesley Liddick a12d88ed4b Merge pull request #4957 from wucm667/fix/issue-4948-codex-web-search-manifest
fix(openai): preserve web search for API-key Codex clients
2026-07-28 11:07:16 +08:00
Wesley Liddick 6c04e41952 Merge pull request #4968 from jeshica/fix/claude-oauth-system-cache-breakpoint
fix: 保留 Claude OAuth system 迁移后的缓存断点
2026-07-28 11:07:03 +08:00
Wesley Liddick f71332ff85 Merge pull request #4980 from yan9651688/feat/model-id-copy
feat(accounts): add one-click model ID copy
2026-07-28 11:06:48 +08:00
Wesley Liddick c342a885b5 Merge pull request #4942 from HuntercodeT/fix/openai-passthrough-model-mapping-4936
fix(openai): honor passthrough over non-empty model_mapping in account selection (#4936)
2026-07-28 11:06:33 +08:00
alfadb 1b966dbea1 fix: treat Kimi 1M suffix as client syntax 2026-07-28 10:08:39 +08:00
alfadb 7e65eafbe4 feat: add Kimi K3 support 2026-07-28 10:08:39 +08:00
Wesley Liddick 1aeacf4d41 Merge pull request #4983 from Wei-Shaw/fix/issue-4887-prompt-audit-recovery
fix(security-audit): 解密失败不再吞掉整份配置,修复升级后配置消失且无法保存的死锁
2026-07-28 09:49:40 +08:00
Wesley Liddick d95f6b98ce Merge pull request #4975 from Vibeone/fix/msg-id-format-authentic
fix(gateway): 修复模拟响应 message ID 格式,改为正宗 Anthropic msg_01 格式
2026-07-28 09:49:31 +08:00
shaw bfbe113f5e fix(security-audit): 解密失败不再吞掉整份配置,修复升级后配置消失且无法保存的死锁 (#4887)
根因:prompt audit 是共享 TOTP_ENCRYPTION_KEY 加密器的功能中唯一不校验
EncryptionKeyConfigured 的落点。未配置固定密钥的部署每次重启自动生成新
密钥,v162 保存的节点 Token 密文在升级重启后永久无法解密,Reload 中
ActiveFromStorage 整体失败导致快照永远装不上:管理端 GET 回退默认 v1
(v166 起为 503),而保存路径直读数据库做 CAS 版本对比,必然冲突——
配置既看不见也改不掉。PR #4893 仅改变了报错形态,未修复根因。

修复:
- ActiveFromStorage 对单节点解密失败降级容忍:该节点运行时禁用并标记
  TokenInvalid,配置整体照常激活;管理端恢复显示真实版本号,重新输入
  Token 即可自愈(密文保留,密钥恢复后自动复原)
- blocking 意图下零可用节点时 evaluator 仍返回 unavailable,请求照旧
  被拒,fail-closed 语义不回归;async 意图下 enqueue 直接 drop 并告警
- Save 在未配置固定加密密钥时拒绝保存新 Token(与 TOTP/Ollama/备份
  一致的门控),错误码 prompt_audit_encryption_key_required
- token_status 新增 invalid 状态,前端凭据列与编辑框提示重新输入
- 新增 config_token_invalid 告警日志(集合变化时记录一次,不随 5s
  刷新刷屏)
2026-07-28 09:31:36 +08:00
yan9651688 d8ae153ae9 feat(accounts): make model IDs easy to copy
Administrators often need exact model identifiers while editing account whitelists. Add a dedicated copy action without changing model selection or upstream sync behavior.

Constraint: Keep the contribution frontend-only and avoid model routing or persistence changes
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep copy and selection as separate actions
Tested: focused Vitest, account component regression tests, frontend typecheck, lint, and production build
Not-tested: Authenticated browser screenshot
Related: Wei-Shaw/sub2api#2151
2026-07-28 09:23:52 +08:00
eyre 248236ce6d fix(gateway): 修复模拟响应使用 Bedrock msg_bdrk_ 格式,改为正宗 Anthropic msg_01 格式
问题:
探针拦截(suggestion mode / warmup / max_tokens=1 haiku)的模拟响应以及
Gemini/Antigravity 兼容层生成的 message ID 不符合 Anthropic 官方 API 格式,
容易被客户端识别为非正宗响应。

修复:
1. generateRealisticMsgID():msg_bdrk_ + 24字符 → msg_01 + 22位 Base62
   (与官方 API 返回的 msg_011CdS6b8gAhoKWdW9jE87Zs 格式一致)
2. 去掉固定的 msg_mock_suggestion / msg_mock_warmup,统一使用随机 ID
3. 流式响应格式对齐官方:
   - message_start 增加 stop_details/cache token 字段
   - content_block_start 字段顺序修正
   - message_delta.usage 只含 output_tokens
4. 非流式响应:增加 stop_details:null,移除非标准 total_tokens
5. Gemini Messages/ChatCompletions 兼容层:msg_ + hex → msg_01 + Base62
6. Antigravity response/stream transformer:msg_ + 12位 → msg_01 + 22位 Base62

验证方式:对照 Anthropic 官方 API 实际响应格式确认。
2026-07-27 15:20:03 +00:00
yian 1c26dc7ad8 fix(openai-live): Live 会话 finalize 与 observer 对 store 故障的容错
- finalizeLiveCall 写 usage log 改走 writeUsageLogBestEffort(日志 + 同步
  兜底重试)。MarkLiveCallClosed 已在 Redis 标记 first,这是该会话唯一一次
  落库机会,失败不能再被 `_, _ =` 静默丢弃
- observeLiveCall / waitForLiveObserverRetry 把 store 报错与「控制权被他人
  接管」拆开:store 抖动时有限次重试(liveObserverStoreRetryLimit),仍失败
  则按 record.ExpiresAt 兜底 finalize,保证 usage log 与租约释放不因 Redis
  故障而丢失;记录确实不存在(ErrLiveCallNotFound)才停止重试
- Live 会话 TotalCost/ActualCost 恒 0 的零计费行为保持不变,加 TODO 注明
  需产品决策(免费 or 按时长接入计费管道)
2026-07-27 22:22:46 +08:00
jy.liu 1631b19f84 fix: preserve system cache breakpoint in OAuth mimic 2026-07-27 19:28:45 +08:00
wucm667 3c62b5ca85 fix(openai): preserve web search for API-key Codex clients 2026-07-27 18:42:34 +08:00
spongehah fc495e087d feat(security-audit): allow Qwen3Guard auxiliary fields 2026-07-27 17:40:11 +08:00
Bestony@Homelab a6fc2d10b3 feat(setup): add explicit setup bypass 2026-07-27 17:08:41 +08:00
github-actions[bot] 59ce11c780 chore: sync VERSION to 0.1.166 [skip ci] 2026-07-27 08:57:42 +00:00
JopqiorandClaude 46dba19397 fix(openai): preserve GPT-5.6 max effort in messages bridges
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 14:54:01 +08:00
HuntercodeT 83b368553d fix(openai): honor passthrough over model_mapping in IsModelSupported (#4936)
An OpenAI account with auto-passthrough enabled (extra.openai_passthrough=true,
"replace auth only, allow all models") was still filtered out during account
selection when it had a non-empty credentials.model_mapping that did not list the
requested model. isOpenAICompatibleAccountEligibleForRequest calls
Account.IsModelSupported directly, and IsModelSupported only bypassed the mapping
whitelist for passthrough accounts when the mapping was empty. A leftover mapping
(common after switching an account from whitelist mode to passthrough) therefore
excluded the account -> zero candidates -> ErrNoAvailableAccounts, and the client
saw 404 "Model ... is not supported by any configured account in this group" even
though a direct account test with the same model succeeded (that path already
honored passthrough via isModelSupportedByAccount).

Fix: short-circuit passthrough at the top of Account.IsModelSupported, before the
model_mapping check, so it is consistent with isModelSupportedByAccount. Add a
regression test covering passthrough + non-empty leftover mapping.
2026-07-27 14:20:12 +08:00
Zhixuan Jiang 357c5b917b feat: add passkey sign-in settings control 2026-07-26 11:07:12 -04:00
Zhixuan Jiang 4158e73b3f fix: harden passkey deployment readiness 2026-07-26 10:14:55 -04:00
Zhixuan Jiang cc62979aa7 feat: add passkey authentication 2026-07-26 09:50:28 -04:00
jy.liu efa5a2240d fix: strip max_tokens from Anthropic count tokens 2026-07-26 21:20:37 +08:00
228 changed files with 9653 additions and 548 deletions
+2
View File
@@ -16,6 +16,8 @@ jobs:
run: |
/bin/bash -n deploy/apple-container.sh
/bin/bash deploy/tests/apple-container-test.sh
/bin/sh deploy/tests/docker-compose-security-test.sh
/bin/sh deploy/tests/docker-runtime-resources-test.sh
/bin/sh deploy/test-caddyfile-cache.sh
test:
+1
View File
@@ -49,6 +49,7 @@ dockers:
use: buildx
extra_files:
- deploy/docker-entrypoint.sh
- backend/resources
build_flag_templates:
- "--platform=linux/amd64"
- "--label=org.opencontainers.image.version={{ .Version }}"
+4
View File
@@ -65,6 +65,7 @@ dockers:
use: buildx
extra_files:
- deploy/docker-entrypoint.sh
- backend/resources
build_flag_templates:
- "--platform=linux/amd64"
- "--label=org.opencontainers.image.version={{ .Version }}"
@@ -80,6 +81,7 @@ dockers:
use: buildx
extra_files:
- deploy/docker-entrypoint.sh
- backend/resources
build_flag_templates:
- "--platform=linux/arm64"
- "--label=org.opencontainers.image.version={{ .Version }}"
@@ -95,6 +97,7 @@ dockers:
use: buildx
extra_files:
- deploy/docker-entrypoint.sh
- backend/resources
build_flag_templates:
- "--platform=linux/amd64"
- "--label=org.opencontainers.image.version={{ .Version }}"
@@ -110,6 +113,7 @@ dockers:
use: buildx
extra_files:
- deploy/docker-entrypoint.sh
- backend/resources
build_flag_templates:
- "--platform=linux/arm64"
- "--label=org.opencontainers.image.version={{ .Version }}"
+2 -1
View File
@@ -42,8 +42,9 @@ RUN addgroup -g 1000 sub2api && \
WORKDIR /app
# Copy pre-built binary from GoReleaser
# Copy the pre-built binary and runtime fallback resources from GoReleaser
COPY sub2api /app/sub2api
COPY --chown=sub2api:sub2api backend/resources /app/resources
# Create data directory
RUN mkdir -p /app/data && chown -R sub2api:sub2api /app
+1 -1
View File
@@ -1 +1 @@
0.1.165
0.1.168
+12 -3
View File
@@ -46,8 +46,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
return nil, err
}
userRepository := repository.NewUserRepository(client, db)
passkeyRepository := repository.NewPasskeyRepository(db)
redeemCodeRepository := repository.NewRedeemCodeRepository(client)
redisClient := repository.ProvideRedis(configConfig)
passkeySessionStore := repository.NewPasskeySessionStore(redisClient)
refreshTokenCache := repository.NewRefreshTokenCache(redisClient)
settingRepository := repository.NewSettingRepository(client)
groupRepository := repository.NewGroupRepository(client, db)
@@ -79,6 +81,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
affiliateRepository := repository.NewAffiliateRepository(client, db)
affiliateService := service.NewAffiliateService(affiliateRepository, settingService, apiKeyAuthCacheInvalidator, billingCacheService)
authService := service.NewAuthService(client, userRepository, redeemCodeRepository, refreshTokenCache, configConfig, settingService, emailService, turnstileService, emailQueueService, promoService, subscriptionService, affiliateService, serviceUserPlatformQuotaRepository)
passkeyService, err := service.NewPasskeyService(configConfig, passkeyRepository, passkeySessionStore, userRepository)
if err != nil {
return nil, err
}
userService := service.NewUserService(userRepository, settingRepository, apiKeyAuthCacheInvalidator, billingCache)
redeemCache := repository.NewRedeemCache(redisClient)
redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client, apiKeyAuthCacheInvalidator, affiliateService)
@@ -92,6 +98,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
userAttributeValueRepository := repository.NewUserAttributeValueRepository(client)
userAttributeService := service.NewUserAttributeService(userAttributeDefinitionRepository, userAttributeValueRepository)
authHandler := handler.NewAuthHandler(configConfig, authService, userService, settingService, promoService, redeemService, totpService, userAttributeService)
passkeyHandler := handler.NewPasskeyHandler(passkeyService, authService, settingService)
userHandler := handler.NewUserHandler(userService, authService, emailService, emailCache, affiliateService, serviceUserPlatformQuotaRepository)
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
usageLogRepository := repository.NewUsageLogRepository(client, db)
@@ -255,7 +262,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
contentModerationHashCache := repository.NewContentModerationHashCache(redisClient)
contentModerationService := service.NewContentModerationService(settingRepository, contentModerationRepository, contentModerationHashCache, groupRepository, userRepository, apiKeyAuthCacheInvalidator, emailService)
contentModerationHandler := admin.NewContentModerationHandler(contentModerationService)
configManager := securityaudit.NewConfigManager(db, settingRepository, redisClient, secretEncryptor)
configManager := securityaudit.NewConfigManager(db, settingRepository, redisClient, secretEncryptor, configConfig)
postgreSQLRepository := securityaudit.NewPostgreSQLRepository(db)
redisPayloadStore := securityaudit.NewRedisPayloadStore(redisClient)
openAICompatibleScanner := securityaudit.NewOpenAICompatibleScanner()
@@ -283,6 +290,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
handlerPaymentHandler := handler.NewPaymentHandler(paymentService, paymentConfigService)
paymentWebhookHandler := handler.NewPaymentWebhookHandler(paymentService, registry)
availableChannelHandler := handler.NewAvailableChannelHandler(channelService, apiKeyService, settingService)
modelPlazaHandler := handler.NewModelPlazaHandler(channelService, apiKeyService, settingService)
imageTaskStore := repository.NewImageTaskStore(redisClient)
imageTaskService := service.ProvideImageTaskService(imageTaskStore, imageStorageSettingService)
asyncImageHandler := handler.NewAsyncImageHandler(imageTaskService, openAIGatewayHandler)
@@ -296,13 +304,14 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
batchImageHandler := handler.ProvideBatchImageHandler(batchImagePublicService, batchImageDownloadService, batchImageCleanupService, openAIGatewayHandler)
idempotencyCoordinator := service.ProvideIdempotencyCoordinator(idempotencyRepository, configConfig)
idempotencyCleanupService := service.ProvideIdempotencyCleanupService(idempotencyRepository, configConfig)
handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, asyncImageHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService)
handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, passkeyHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, modelPlazaHandler, asyncImageHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService)
jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService, settingService, auditLogService)
optionalJWTAuthMiddleware := middleware.NewOptionalJWTAuthMiddleware(authService, userService, settingService, auditLogService)
adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService, auditLogService)
apiKeyAuthMiddleware := middleware.NewAPIKeyAuthMiddleware(apiKeyService, subscriptionService, configConfig)
auditLogMiddleware := middleware.NewAuditLogMiddleware(auditLogService)
stepUpAuthMiddleware := middleware.NewStepUpAuthMiddleware(totpService, userService, settingService)
engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, auditLogMiddleware, stepUpAuthMiddleware, apiKeyService, subscriptionService, opsService, settingService, compositeRouteResolver, redisClient)
engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, optionalJWTAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, auditLogMiddleware, stepUpAuthMiddleware, apiKeyService, subscriptionService, opsService, settingService, compositeRouteResolver, redisClient)
httpServer := server.ProvideHTTPServer(configConfig, engine)
opsMetricsCollector := service.ProvideOpsMetricsCollector(opsRepository, settingRepository, accountRepository, concurrencyService, db, redisClient, configConfig)
opsAggregationService := service.ProvideOpsAggregationService(opsRepository, settingRepository, db, redisClient, configConfig)
+9 -1
View File
@@ -16,7 +16,8 @@ require (
github.com/coder/websocket v1.8.14
github.com/dgraph-io/ristretto v0.2.0
github.com/gin-gonic/gin v1.9.1
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/go-webauthn/webauthn v0.17.4
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/google/wire v0.7.0
github.com/gorilla/websocket v1.5.3
@@ -97,6 +98,7 @@ require (
github.com/fatih/color v1.18.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
@@ -106,9 +108,12 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.2.6 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hashicorp/hcl/v2 v2.18.1 // indirect
@@ -137,6 +142,7 @@ require (
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
@@ -158,10 +164,12 @@ require (
github.com/testcontainers/testcontainers-go v0.40.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
github.com/zclconf/go-cty v1.14.4 // indirect
+18
View File
@@ -128,6 +128,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
@@ -153,16 +155,26 @@ github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68=
github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk=
github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8=
github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
@@ -261,6 +273,8 @@ github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaR
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -356,6 +370,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/tiktoken-go/tokenizer v0.8.0 h1:drHWno2Zx3eAm/hk/LmvBKXPpSImB7BRyh/ru4+3Q7Y=
github.com/tiktoken-go/tokenizer v0.8.0/go.mod h1:pTmPz4r14MV3JkUGAmAcdLdYhSxN68MCjrP+EoxBdx0=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
@@ -366,6 +382,8 @@ github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4d
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/wechatpay-apiv3/wechatpay-go v0.2.21 h1:uIyMpzvcaHA33W/QPtHstccw+X52HO1gFdvVL9O6Lfs=
github.com/wechatpay-apiv3/wechatpay-go v0.2.21/go.mod h1:A254AUBVB6R+EqQFo3yTgeh7HtyqRRtN2w9hQSOrd4Q=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
+60
View File
@@ -73,6 +73,7 @@ type Config struct {
Ops OpsConfig `mapstructure:"ops"`
JWT JWTConfig `mapstructure:"jwt"`
Totp TotpConfig `mapstructure:"totp"`
WebAuthn WebAuthnConfig `mapstructure:"webauthn"`
LinuxDo LinuxDoConnectConfig `mapstructure:"linuxdo_connect"`
WeChat WeChatConnectConfig `mapstructure:"wechat_connect"`
OIDC OIDCConnectConfig `mapstructure:"oidc_connect"`
@@ -686,6 +687,16 @@ type CORSConfig struct {
AllowCredentials bool `mapstructure:"allow_credentials"`
}
// WebAuthnConfig configures this deployment as a WebAuthn relying party.
// RPID and RPOrigins are security boundaries and must never be inferred from
// untrusted request Host or Origin headers.
type WebAuthnConfig struct {
Enabled bool `mapstructure:"enabled"`
RPDisplayName string `mapstructure:"rp_display_name"`
RPID string `mapstructure:"rp_id"`
RPOrigins []string `mapstructure:"rp_origins"`
}
const MaxForwardedClientIPHeaders = 16
type ForwardedClientIPSettings struct {
@@ -1024,6 +1035,8 @@ type GatewayOpenAIHTTP2Config struct {
// GatewayOpenAIProxyStreamCircuitConfig controls the bounded, in-process
// proxy-ID circuit used for incomplete OpenAI Responses SSE streams.
type GatewayOpenAIProxyStreamCircuitConfig struct {
// Disabled: 完全关闭代理断流熔断(默认开启)。
Disabled bool `mapstructure:"disabled"`
// FailureThreshold: 统计窗口内多少次断流后隔离代理。
FailureThreshold int `mapstructure:"failure_threshold"`
// WindowSeconds: 断流统计窗口(秒)。
@@ -1875,12 +1888,21 @@ func setDefaults() {
viper.SetDefault("cors.allowed_origins", []string{})
viper.SetDefault("cors.allow_credentials", true)
// WebAuthn / Passkeys are opt-in because every deployment must explicitly
// declare its relying-party domain and trusted browser origins.
viper.SetDefault("webauthn.enabled", false)
viper.SetDefault("webauthn.rp_display_name", "Sub2API")
viper.SetDefault("webauthn.rp_id", "")
viper.SetDefault("webauthn.rp_origins", []string{})
// Security
viper.SetDefault("security.url_allowlist.enabled", false)
viper.SetDefault("security.url_allowlist.upstream_hosts", []string{
"api.openai.com",
"api.anthropic.com",
"api.kimi.com",
"api.moonshot.ai",
"api.moonshot.cn",
"open.bigmodel.cn",
"api.minimaxi.com",
"generativelanguage.googleapis.com",
@@ -2262,6 +2284,7 @@ func setDefaults() {
viper.SetDefault("gateway.openai_http2.fallback_error_threshold", 2)
viper.SetDefault("gateway.openai_http2.fallback_window_seconds", 60)
viper.SetDefault("gateway.openai_http2.fallback_ttl_seconds", 600)
viper.SetDefault("gateway.openai_proxy_stream_circuit.disabled", false)
viper.SetDefault("gateway.openai_proxy_stream_circuit.failure_threshold", 2)
viper.SetDefault("gateway.openai_proxy_stream_circuit.window_seconds", 60)
viper.SetDefault("gateway.openai_proxy_stream_circuit.ttl_seconds", 600)
@@ -2591,6 +2614,43 @@ func (c *Config) Validate() error {
}
warnIfInsecureURL("server.frontend_url", c.Server.FrontendURL)
}
if c.WebAuthn.Enabled {
c.WebAuthn.RPDisplayName = strings.TrimSpace(c.WebAuthn.RPDisplayName)
c.WebAuthn.RPID = strings.ToLower(strings.TrimSpace(c.WebAuthn.RPID))
c.WebAuthn.RPOrigins = normalizeStringSlice(c.WebAuthn.RPOrigins)
if c.WebAuthn.RPDisplayName == "" {
return fmt.Errorf("webauthn.rp_display_name is required when passkeys are enabled")
}
if c.WebAuthn.RPID == "" {
return fmt.Errorf("webauthn.rp_id is required when passkeys are enabled")
}
if strings.Contains(c.WebAuthn.RPID, "://") || strings.ContainsAny(c.WebAuthn.RPID, "/:") {
return fmt.Errorf("webauthn.rp_id must be a domain without scheme, port, or path")
}
if len(c.WebAuthn.RPOrigins) == 0 {
return fmt.Errorf("webauthn.rp_origins must contain at least one origin when passkeys are enabled")
}
for i, origin := range c.WebAuthn.RPOrigins {
u, err := url.Parse(origin)
if err != nil || u.Scheme == "" || u.Host == "" {
return fmt.Errorf("webauthn.rp_origins contains invalid origin %q", origin)
}
if u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Path != "" {
return fmt.Errorf("webauthn.rp_origins entry %q must not include userinfo, path, query, or fragment", origin)
}
u.Scheme = strings.ToLower(u.Scheme)
u.Host = strings.ToLower(u.Host)
host := strings.ToLower(u.Hostname())
localDevelopment := host == "localhost" || host == "127.0.0.1" || host == "::1"
if u.Scheme != "https" && (u.Scheme != "http" || !localDevelopment) {
return fmt.Errorf("webauthn.rp_origins entry %q must use HTTPS (HTTP is allowed only for localhost)", origin)
}
if host != c.WebAuthn.RPID && !strings.HasSuffix(host, "."+c.WebAuthn.RPID) {
return fmt.Errorf("webauthn.rp_origins entry %q is not within relying party ID %q", origin, c.WebAuthn.RPID)
}
c.WebAuthn.RPOrigins[i] = u.Scheme + "://" + u.Host
}
}
if c.JWT.ExpireHour <= 0 {
return fmt.Errorf("jwt.expire_hour must be positive")
}
+18
View File
@@ -545,6 +545,7 @@ func TestLoadDefaultOpenAIHTTP2Enabled(t *testing.T) {
require.NoError(t, err)
require.True(t, cfg.Gateway.OpenAIHTTP2.Enabled)
require.True(t, cfg.Gateway.OpenAIHTTP2.AllowProxyFallbackToHTTP1)
require.False(t, cfg.Gateway.OpenAIProxyStreamCircuit.Disabled)
require.Equal(t, 2, cfg.Gateway.OpenAIProxyStreamCircuit.FailureThreshold)
require.Equal(t, 60, cfg.Gateway.OpenAIProxyStreamCircuit.WindowSeconds)
require.Equal(t, 600, cfg.Gateway.OpenAIProxyStreamCircuit.TTLSeconds)
@@ -552,12 +553,14 @@ func TestLoadDefaultOpenAIHTTP2Enabled(t *testing.T) {
func TestLoadOpenAIProxyStreamCircuitFromEnv(t *testing.T) {
resetViperWithJWTSecret(t)
t.Setenv("GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_DISABLED", "true")
t.Setenv("GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_FAILURE_THRESHOLD", "3")
t.Setenv("GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_WINDOW_SECONDS", "90")
t.Setenv("GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_TTL_SECONDS", "420")
cfg, err := Load()
require.NoError(t, err)
require.True(t, cfg.Gateway.OpenAIProxyStreamCircuit.Disabled)
require.Equal(t, 3, cfg.Gateway.OpenAIProxyStreamCircuit.FailureThreshold)
require.Equal(t, 90, cfg.Gateway.OpenAIProxyStreamCircuit.WindowSeconds)
require.Equal(t, 420, cfg.Gateway.OpenAIProxyStreamCircuit.TTLSeconds)
@@ -755,6 +758,21 @@ func TestLoadDefaultSecurityToggles(t *testing.T) {
if !cfg.Security.ResponseHeaders.Enabled {
t.Fatalf("ResponseHeaders.Enabled = false, want true")
}
wantHosts := []string{
"api.kimi.com",
"api.moonshot.ai",
"api.moonshot.cn",
}
hostSet := make(map[string]struct{}, len(cfg.Security.URLAllowlist.UpstreamHosts))
for _, h := range cfg.Security.URLAllowlist.UpstreamHosts {
hostSet[h] = struct{}{}
}
for _, want := range wantHosts {
if _, ok := hostSet[want]; !ok {
t.Fatalf("URLAllowlist.UpstreamHosts missing %q; got %v", want, cfg.Security.URLAllowlist.UpstreamHosts)
}
}
}
func TestLoadDefaultServerMode(t *testing.T) {
+104
View File
@@ -0,0 +1,104 @@
package config
import (
"strings"
"testing"
"github.com/spf13/viper"
"github.com/stretchr/testify/require"
)
func TestValidateWebAuthnConfig(t *testing.T) {
tests := []struct {
name string
configure func(*Config)
wantError string
}{
{
name: "valid production origin",
configure: func(cfg *Config) {
cfg.WebAuthn = WebAuthnConfig{
Enabled: true,
RPDisplayName: "Sub2API",
RPID: "sub2api.example.com",
RPOrigins: []string{"https://sub2api.example.com"},
}
},
},
{
name: "valid localhost development origin",
configure: func(cfg *Config) {
cfg.WebAuthn = WebAuthnConfig{
Enabled: true,
RPDisplayName: "Sub2API Dev",
RPID: "localhost",
RPOrigins: []string{"http://localhost:5173"},
}
},
},
{
name: "missing relying party id",
configure: func(cfg *Config) {
cfg.WebAuthn = WebAuthnConfig{
Enabled: true,
RPDisplayName: "Sub2API",
RPOrigins: []string{"https://sub2api.example.com"},
}
},
wantError: "webauthn.rp_id",
},
{
name: "relying party id contains scheme",
configure: func(cfg *Config) {
cfg.WebAuthn = WebAuthnConfig{
Enabled: true,
RPDisplayName: "Sub2API",
RPID: "https://sub2api.example.com",
RPOrigins: []string{"https://sub2api.example.com"},
}
},
wantError: "domain without scheme",
},
{
name: "non-local insecure origin",
configure: func(cfg *Config) {
cfg.WebAuthn = WebAuthnConfig{
Enabled: true,
RPDisplayName: "Sub2API",
RPID: "sub2api.example.com",
RPOrigins: []string{"http://sub2api.example.com"},
}
},
wantError: "must use HTTPS",
},
{
name: "origin outside relying party id",
configure: func(cfg *Config) {
cfg.WebAuthn = WebAuthnConfig{
Enabled: true,
RPDisplayName: "Sub2API",
RPID: "example.com",
RPOrigins: []string{"https://example.net"},
}
},
wantError: "not within relying party ID",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
viper.Reset()
t.Setenv("JWT_SECRET", strings.Repeat("x", 32))
cfg, err := Load()
require.NoError(t, err)
tt.configure(cfg)
err = cfg.Validate()
if tt.wantError == "" {
require.NoError(t, err)
} else {
require.ErrorContains(t, err, tt.wantError)
}
})
}
}
@@ -123,6 +123,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
if paymentCfg == nil {
paymentCfg = &service.PaymentConfig{}
}
passkeyConfigured, passkeyRPID, passkeyRPOrigins := h.settingService.PasskeyConfiguration()
payload := dto.SystemSettings{
RegistrationEnabled: settings.RegistrationEnabled,
@@ -134,6 +135,10 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
InvitationCodeEnabled: settings.InvitationCodeEnabled,
TotpEnabled: settings.TotpEnabled,
TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(),
PasskeyEnabled: settings.PasskeyEnabled,
PasskeyConfigured: passkeyConfigured,
PasskeyRPID: passkeyRPID,
PasskeyRPOrigins: passkeyRPOrigins,
SessionBindingEnabled: settings.SessionBindingEnabled,
StepUpEnabled: settings.StepUpEnabled,
AuditLogRetentionDays: settings.AuditLogRetentionDays,
@@ -346,6 +351,10 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
ModelPlazaEnabled: settings.ModelPlazaEnabled,
ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth,
ModelPlazaDescription: settings.ModelPlazaDescription,
AffiliateEnabled: settings.AffiliateEnabled,
AllowUserViewErrorRequests: settings.AllowUserViewErrorRequests,
@@ -56,6 +56,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.TotpEnabled != after.TotpEnabled {
changed = append(changed, "totp_enabled")
}
if before.PasskeyEnabled != after.PasskeyEnabled {
changed = append(changed, "passkey_enabled")
}
if before.SessionBindingEnabled != after.SessionBindingEnabled {
changed = append(changed, "session_binding_enabled")
}
@@ -525,6 +528,15 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.AvailableChannelsEnabled != after.AvailableChannelsEnabled {
changed = append(changed, "available_channels_enabled")
}
if before.ModelPlazaEnabled != after.ModelPlazaEnabled {
changed = append(changed, "model_plaza_enabled")
}
if before.ModelPlazaRequireAuth != after.ModelPlazaRequireAuth {
changed = append(changed, "model_plaza_require_auth")
}
if before.ModelPlazaDescription != after.ModelPlazaDescription {
changed = append(changed, "model_plaza_description")
}
if before.AffiliateEnabled != after.AffiliateEnabled {
changed = append(changed, "affiliate_enabled")
}
@@ -30,6 +30,7 @@ type UpdateSettingsRequest struct {
FrontendURL string `json:"frontend_url"`
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
PasskeyEnabled *bool `json:"passkey_enabled"` // Passkey 登录(省略=保持现值)
SessionBindingEnabled *bool `json:"session_binding_enabled"` // 会话 IP/UA 绑定(省略=保持现值)
StepUpEnabled *bool `json:"step_up_enabled"` // 敏感操作 step-up 2FA(省略=保持现值)
AuditLogRetentionDays int `json:"audit_log_retention_days"` // 审计日志保留天数
@@ -312,6 +313,11 @@ type UpdateSettingsRequest struct {
// Available Channels feature switch (user-facing)
AvailableChannelsEnabled *bool `json:"available_channels_enabled"`
// Model Plaza feature switches + description
ModelPlazaEnabled *bool `json:"model_plaza_enabled"`
ModelPlazaRequireAuth *bool `json:"model_plaza_require_auth"`
ModelPlazaDescription *string `json:"model_plaza_description"`
// Affiliate (邀请返利) feature switch
AffiliateEnabled *bool `json:"affiliate_enabled"`
@@ -463,6 +469,17 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
if req.StepUpEnabled != nil {
stepUpEnabled = *req.StepUpEnabled
}
passkeyEnabled := previousSettings.PasskeyEnabled
if req.PasskeyEnabled != nil {
passkeyEnabled = *req.PasskeyEnabled
}
if passkeyEnabled {
configured, _, _ := h.settingService.PasskeyConfiguration()
if !configured {
response.BadRequest(c, "Passkey sign-in requires a valid WebAuthn RP ID and allowed HTTPS origins in the deployment configuration")
return
}
}
forwardedClientIPHeaders := append([]string(nil), previousSettings.ForwardedClientIPHeaders...)
if req.ForwardedClientIPHeaders != nil {
forwardedClientIPHeaders = append([]string(nil), (*req.ForwardedClientIPHeaders)...)
@@ -1313,6 +1330,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
FrontendURL: req.FrontendURL,
InvitationCodeEnabled: req.InvitationCodeEnabled,
TotpEnabled: req.TotpEnabled,
PasskeyEnabled: passkeyEnabled,
SessionBindingEnabled: sessionBindingEnabled,
StepUpEnabled: stepUpEnabled,
AuditLogRetentionDays: req.AuditLogRetentionDays,
@@ -1665,6 +1683,24 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
return previousSettings.AvailableChannelsEnabled
}(),
ModelPlazaEnabled: func() bool {
if req.ModelPlazaEnabled != nil {
return *req.ModelPlazaEnabled
}
return previousSettings.ModelPlazaEnabled
}(),
ModelPlazaRequireAuth: func() bool {
if req.ModelPlazaRequireAuth != nil {
return *req.ModelPlazaRequireAuth
}
return previousSettings.ModelPlazaRequireAuth
}(),
ModelPlazaDescription: func() string {
if req.ModelPlazaDescription != nil {
return *req.ModelPlazaDescription
}
return previousSettings.ModelPlazaDescription
}(),
AffiliateEnabled: func() bool {
if req.AffiliateEnabled != nil {
return *req.AffiliateEnabled
@@ -1836,6 +1872,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
if updatedPaymentCfg == nil {
updatedPaymentCfg = &service.PaymentConfig{}
}
passkeyConfigured, passkeyRPID, passkeyRPOrigins := h.settingService.PasskeyConfiguration()
payload := dto.SystemSettings{
RegistrationEnabled: updatedSettings.RegistrationEnabled,
@@ -1847,6 +1884,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
InvitationCodeEnabled: updatedSettings.InvitationCodeEnabled,
TotpEnabled: updatedSettings.TotpEnabled,
TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(),
PasskeyEnabled: updatedSettings.PasskeyEnabled,
PasskeyConfigured: passkeyConfigured,
PasskeyRPID: passkeyRPID,
PasskeyRPOrigins: passkeyRPOrigins,
SessionBindingEnabled: updatedSettings.SessionBindingEnabled,
StepUpEnabled: updatedSettings.StepUpEnabled,
AuditLogRetentionDays: updatedSettings.AuditLogRetentionDays,
@@ -2055,6 +2096,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
AvailableChannelsEnabled: updatedSettings.AvailableChannelsEnabled,
ModelPlazaEnabled: updatedSettings.ModelPlazaEnabled,
ModelPlazaRequireAuth: updatedSettings.ModelPlazaRequireAuth,
ModelPlazaDescription: updatedSettings.ModelPlazaDescription,
AffiliateEnabled: updatedSettings.AffiliateEnabled,
RiskControlEnabled: updatedSettings.RiskControlEnabled,
+6 -2
View File
@@ -98,16 +98,20 @@ func ensureLoginUserActive(user *service.User) error {
// respondWithTokenPair 生成 Token 对并返回认证响应
// 如果 Token 对生成失败,回退到只返回 Access Token(向后兼容)
func (h *AuthHandler) respondWithTokenPair(c *gin.Context, user *service.User) {
respondWithTokenPair(c, h.authService, user)
}
func respondWithTokenPair(c *gin.Context, authService *service.AuthService, user *service.User) {
if err := ensureLoginUserActive(user); err != nil {
response.ErrorFrom(c, err)
return
}
tokenPair, err := h.authService.GenerateTokenPair(c.Request.Context(), user, "")
tokenPair, err := authService.GenerateTokenPair(c.Request.Context(), user, "")
if err != nil {
slog.Error("failed to generate token pair", "error", err, "user_id", user.ID)
// 回退到只返回Access Token
token, tokenErr := h.authService.GenerateToken(c.Request.Context(), user)
token, tokenErr := authService.GenerateToken(c.Request.Context(), user)
if tokenErr != nil {
response.InternalError(c, "Failed to generate token")
return
@@ -3015,7 +3015,7 @@ func (r *oauthPendingFlowUserRepo) GetFirstAdmin(context.Context) (*service.User
panic("unexpected GetFirstAdmin call")
}
func (r *oauthPendingFlowUserRepo) Update(ctx context.Context, user *service.User) error {
func (r *oauthPendingFlowUserRepo) Update(ctx context.Context, user *service.User, fields service.UserUpdateFields) error {
entity, err := r.client.User.UpdateOneID(user.ID).
SetEmail(user.Email).
SetUsername(user.Username).
@@ -3168,6 +3168,14 @@ func (r *oauthPendingFlowUserRepo) DeductBalance(context.Context, int64, float64
panic("unexpected DeductBalance call")
}
func (r *oauthPendingFlowUserRepo) AdjustBalance(ctx context.Context, id int64, delta float64) (service.BalanceChange, error) {
panic("unexpected AdjustBalance call")
}
func (r *oauthPendingFlowUserRepo) SetBalance(ctx context.Context, id int64, value float64) (service.BalanceChange, error) {
panic("unexpected SetBalance call")
}
func (r *oauthPendingFlowUserRepo) UpdateConcurrency(context.Context, int64, int) error {
panic("unexpected UpdateConcurrency call")
}
@@ -47,7 +47,11 @@ func TestAuthHandlerRevokeAllSessionsInvalidatesAccessTokens(t *testing.T) {
require.Equal(t, http.StatusOK, recorder.Code)
require.Equal(t, []int64{29}, refreshTokenCache.revokedUserIDs)
require.Equal(t, int64(8), repo.user.TokenVersion)
// users 表没有 token_version 列(见 resolvedTokenVersion:JWT 里的值由
// email+password_hash 指纹推导),所以自增 TokenVersion 只停留在内存里。
// 此前紧跟其后的整行 Update 不写任何有效数据,却会用旧快照覆盖并发写入的列,
// 已移除。会话撤销由上面的 refresh session 清理承担。
require.Equal(t, int64(7), repo.user.TokenVersion)
var resp struct {
Code int `json:"code"`
@@ -12,12 +12,11 @@ import (
// AvailableChannelHandler 处理用户侧「可用渠道」查询。
//
// 用户侧接口委托 ChannelService.ListAvailable,并在返回前做三层过滤:
// 用户侧接口委托 ChannelService.ListAvailable,并在返回前做四层过滤:
// 1. 行过滤:只保留状态为 Active 且与当前用户可访问分组有交集的渠道;
// 2. 分组过滤:渠道的 Groups 只保留用户可访问的那些;
// 3. 平台过滤:渠道的 SupportedModels 只保留平台在用户可见 Groups 中出现过的模型,
// 防止"渠道同时挂在 antigravity / anthropic 两个平台的分组上,用户只访问
// antigravity,却看到 anthropic 模型"这类跨平台信息泄漏;
// 3. 平台过滤:普通分组只保留自身平台模型;Composite 分组按渠道已配置的具体模型平台
// 展开。这样既防止普通分组跨平台泄漏,也让 Composite 正确展示其多平台能力;
// 4. 字段白名单:仅返回用户需要的字段(省略 BillingModelSource / RestrictModels
// / 内部 ID / Status 等管理字段)。
type AvailableChannelHandler struct {
@@ -172,19 +171,47 @@ func (h *AvailableChannelHandler) List(c *gin.Context) {
}
// buildPlatformSections 把一个渠道按 visibleGroups 的平台集合拆成有序的 section 列表:
// 每个 section 对应一个平台,只包含该平台的 groups 和 supported_models。
// 每个 section 对应一个具体平台,只包含该平台的 groups 和 supported_models。
//
// Composite 分组可访问渠道中所有已配置的具体平台,因此会被展开到每个有支持模型的
// 平台 section。普通分组仍严格留在自身平台,避免跨平台模型信息泄漏。Composite 渠道
// 尚未配置任何模型时保留 composite section,以便前端继续展示该分组和“未配置模型”状态。
// 输出按 platform 字母序稳定排序,便于前端等效比较与回归测试。
func buildPlatformSections(
ch service.AvailableChannel,
visibleGroups []userAvailableGroup,
) []userChannelPlatformSection {
groupsByPlatform := make(map[string][]userAvailableGroup, 4)
compositeGroups := make([]userAvailableGroup, 0, 1)
for _, g := range visibleGroups {
if g.Platform == "" {
continue
}
if g.Platform == service.PlatformComposite {
compositeGroups = append(compositeGroups, g)
continue
}
groupsByPlatform[g.Platform] = append(groupsByPlatform[g.Platform], g)
}
if len(compositeGroups) > 0 {
modelPlatforms := make(map[string]struct{}, len(ch.SupportedModels))
for i := range ch.SupportedModels {
if platform := ch.SupportedModels[i].Platform; platform != "" {
modelPlatforms[platform] = struct{}{}
}
}
if len(modelPlatforms) == 0 {
groupsByPlatform[service.PlatformComposite] = append(
groupsByPlatform[service.PlatformComposite],
compositeGroups...,
)
} else {
for platform := range modelPlatforms {
groupsByPlatform[platform] = append(groupsByPlatform[platform], compositeGroups...)
}
}
}
if len(groupsByPlatform) == 0 {
return nil
}
@@ -155,3 +155,101 @@ func TestBuildPlatformSections_GroupsByPlatform(t *testing.T) {
require.Len(t, sections[0].SupportedModels, 1)
require.Equal(t, "claude-sonnet-4-6", sections[0].SupportedModels[0].Name)
}
func TestBuildPlatformSections_CompositeGroupExpandsAcrossConfiguredModelPlatforms(t *testing.T) {
anthropicPrice := 3e-6
openAIPrice := 2.5e-6
ch := service.AvailableChannel{
Name: "composite-channel",
SupportedModels: []service.SupportedModel{
{
Name: "claude-sonnet-4-6",
Platform: service.PlatformAnthropic,
Pricing: &service.ChannelModelPricing{InputPrice: &anthropicPrice},
},
{
Name: "gpt-5",
Platform: service.PlatformOpenAI,
Pricing: &service.ChannelModelPricing{InputPrice: &openAIPrice},
},
},
}
visible := []userAvailableGroup{
{ID: 9, Name: "composite", Platform: service.PlatformComposite},
}
sections := buildPlatformSections(ch, visible)
require.Len(t, sections, 2)
require.Equal(t, service.PlatformAnthropic, sections[0].Platform)
require.Equal(t, service.PlatformOpenAI, sections[1].Platform)
for _, section := range sections {
require.Len(t, section.Groups, 1)
require.Equal(t, int64(9), section.Groups[0].ID)
require.Equal(t, service.PlatformComposite, section.Groups[0].Platform)
require.Len(t, section.SupportedModels, 1)
require.Equal(t, section.Platform, section.SupportedModels[0].Platform)
require.NotNil(t, section.SupportedModels[0].Pricing)
}
require.Equal(t, "claude-sonnet-4-6", sections[0].SupportedModels[0].Name)
require.Equal(t, "gpt-5", sections[1].SupportedModels[0].Name)
}
func TestBuildPlatformSections_OrdinaryGroupRemainsPlatformIsolated(t *testing.T) {
ch := service.AvailableChannel{
SupportedModels: []service.SupportedModel{
{Name: "claude-sonnet-4-6", Platform: service.PlatformAnthropic},
{Name: "gpt-5", Platform: service.PlatformOpenAI},
},
}
visible := []userAvailableGroup{
{ID: 1, Name: "anthropic-only", Platform: service.PlatformAnthropic},
}
sections := buildPlatformSections(ch, visible)
require.Len(t, sections, 1)
require.Equal(t, service.PlatformAnthropic, sections[0].Platform)
require.Len(t, sections[0].SupportedModels, 1)
require.Equal(t, "claude-sonnet-4-6", sections[0].SupportedModels[0].Name)
}
func TestBuildPlatformSections_CompositeAndOrdinaryGroupsShareConcreteSection(t *testing.T) {
ch := service.AvailableChannel{
SupportedModels: []service.SupportedModel{
{Name: "claude-sonnet-4-6", Platform: service.PlatformAnthropic},
{Name: "gpt-5", Platform: service.PlatformOpenAI},
},
}
visible := []userAvailableGroup{
{ID: 1, Name: "anthropic-only", Platform: service.PlatformAnthropic},
{ID: 9, Name: "composite", Platform: service.PlatformComposite},
}
sections := buildPlatformSections(ch, visible)
require.Len(t, sections, 2)
require.Equal(t, service.PlatformAnthropic, sections[0].Platform)
require.Equal(t, []int64{1, 9}, []int64{
sections[0].Groups[0].ID,
sections[0].Groups[1].ID,
})
require.Equal(t, service.PlatformOpenAI, sections[1].Platform)
require.Len(t, sections[1].Groups, 1)
require.Equal(t, int64(9), sections[1].Groups[0].ID)
}
func TestBuildPlatformSections_CompositeWithoutModelsKeepsEmptyCompositeSection(t *testing.T) {
visible := []userAvailableGroup{
{ID: 9, Name: "composite", Platform: service.PlatformComposite},
}
sections := buildPlatformSections(service.AvailableChannel{
SupportedModels: []service.SupportedModel{{Name: "invalid-without-platform"}},
}, visible)
require.Len(t, sections, 1)
require.Equal(t, service.PlatformComposite, sections[0].Platform)
require.Len(t, sections[0].Groups, 1)
require.Empty(t, sections[0].SupportedModels)
}
+16 -3
View File
@@ -36,9 +36,13 @@ type SystemSettings struct {
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
TotpEncryptionKeyConfigured bool `json:"totp_encryption_key_configured"` // TOTP 加密密钥是否已配置
SessionBindingEnabled bool `json:"session_binding_enabled"` // 会话 IP/UA 绑定
StepUpEnabled bool `json:"step_up_enabled"` // 敏感操作 step-up 2FA
AuditLogRetentionDays int `json:"audit_log_retention_days"` // 审计日志保留天数
PasskeyEnabled bool `json:"passkey_enabled"`
PasskeyConfigured bool `json:"passkey_configured"`
PasskeyRPID string `json:"passkey_rp_id"`
PasskeyRPOrigins []string `json:"passkey_rp_origins"`
SessionBindingEnabled bool `json:"session_binding_enabled"` // 会话 IP/UA 绑定
StepUpEnabled bool `json:"step_up_enabled"` // 敏感操作 step-up 2FA
AuditLogRetentionDays int `json:"audit_log_retention_days"` // 审计日志保留天数
LoginAgreementEnabled bool `json:"login_agreement_enabled"`
LoginAgreementMode string `json:"login_agreement_mode"`
LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"`
@@ -286,6 +290,11 @@ type SystemSettings struct {
// Available Channels feature switch (user-facing aggregate view)
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
// Model Plaza feature (public group/model pricing showcase)
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
ModelPlazaDescription string `json:"model_plaza_description"`
// 风控中心功能开关
RiskControlEnabled bool `json:"risk_control_enabled"`
@@ -320,6 +329,7 @@ type PublicSettings struct {
PasswordResetEnabled bool `json:"password_reset_enabled"`
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
PasskeyEnabled bool `json:"passkey_enabled"`
LoginAgreementEnabled bool `json:"login_agreement_enabled"`
LoginAgreementMode string `json:"login_agreement_mode"`
LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"`
@@ -369,6 +379,9 @@ type PublicSettings struct {
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
AffiliateEnabled bool `json:"affiliate_enabled"`
RiskControlEnabled bool `json:"risk_control_enabled"`
+15 -15
View File
@@ -2126,22 +2126,22 @@ func sendMockInterceptStream(c *gin.Context, model string, interceptType Interce
switch interceptType {
case InterceptTypeSuggestionMode:
msgID = "msg_mock_suggestion"
msgID = generateRealisticMsgID()
outputTokens = 1
textDeltas = []string{""} // 空内容
default: // InterceptTypeWarmup
msgID = "msg_mock_warmup"
msgID = generateRealisticMsgID()
outputTokens = 2
textDeltas = []string{"New", " Conversation"}
}
// Build message_start event with fixed schema.
messageStartJSON := `{"type":"message_start","message":{"id":` + strconv.Quote(msgID) + `,"type":"message","role":"assistant","model":` + strconv.Quote(model) + `,"content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":0}}}`
// Build message_start event — field order matches real Anthropic API response.
messageStartJSON := `{"type":"message_start","message":{"model":` + strconv.Quote(model) + `,"id":` + strconv.Quote(msgID) + `,"type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":0}}}`
// Build events
events := []string{
`event: message_start` + "\n" + `data: ` + string(messageStartJSON),
`event: content_block_start` + "\n" + `data: {"content_block":{"text":"","type":"text"},"index":0,"type":"content_block_start"}`,
`event: content_block_start` + "\n" + `data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`,
}
// Add text deltas
@@ -2151,7 +2151,7 @@ func sendMockInterceptStream(c *gin.Context, model string, interceptType Interce
}
// Add final events
messageDeltaJSON := `{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":10,"output_tokens":` + strconv.Itoa(outputTokens) + `}}`
messageDeltaJSON := `{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null,"stop_details":null},"usage":{"output_tokens":` + strconv.Itoa(outputTokens) + `}}`
events = append(events,
`event: content_block_stop`+"\n"+`data: {"index":0,"type":"content_block_stop"}`,
@@ -2166,20 +2166,20 @@ func sendMockInterceptStream(c *gin.Context, model string, interceptType Interce
}
}
// generateRealisticMsgID 生成仿真的消息 ID(msg_bdrk_XXXXXXX 格式)
// 格式与 Claude API 真实响应一致,24 位随机字母数字
// generateRealisticMsgID 生成仿真的消息 ID(msg_01XXXXXXX 格式)
// 格式与 Anthropic API 官方响应一致:msg_01 + 22 位 Base62 随机字符
func generateRealisticMsgID() string {
const charset = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
const idLen = 24
const idLen = 22
randomBytes := make([]byte, idLen)
if _, err := rand.Read(randomBytes); err != nil {
return fmt.Sprintf("msg_bdrk_%d", time.Now().UnixNano())
return fmt.Sprintf("msg_01%d", time.Now().UnixNano())
}
b := make([]byte, idLen)
for i := range b {
b[i] = charset[int(randomBytes[i])%len(charset)]
}
return "msg_bdrk_" + string(b)
return "msg_01" + string(b)
}
// sendMockInterceptResponse 发送非流式 mock 响应(用于请求拦截)
@@ -2189,7 +2189,7 @@ func sendMockInterceptResponse(c *gin.Context, model string, interceptType Inter
switch interceptType {
case InterceptTypeSuggestionMode:
msgID = "msg_mock_suggestion"
msgID = generateRealisticMsgID()
text = ""
outputTokens = 1
stopReason = "end_turn"
@@ -2199,13 +2199,13 @@ func sendMockInterceptResponse(c *gin.Context, model string, interceptType Inter
outputTokens = 1
stopReason = "max_tokens" // max_tokens=1 探测请求的 stop_reason 应为 max_tokens
default: // InterceptTypeWarmup
msgID = "msg_mock_warmup"
msgID = generateRealisticMsgID()
text = "New Conversation"
outputTokens = 2
stopReason = "end_turn"
}
// 构建完整的响应格式(与 Claude API 响应格式一致)
// 构建完整的响应格式(与 Anthropic API 官方响应格式一致)
response := gin.H{
"model": model,
"id": msgID,
@@ -2214,6 +2214,7 @@ func sendMockInterceptResponse(c *gin.Context, model string, interceptType Inter
"content": []gin.H{{"type": "text", "text": text}},
"stop_reason": stopReason,
"stop_sequence": nil,
"stop_details": nil,
"usage": gin.H{
"input_tokens": 10,
"cache_creation_input_tokens": 0,
@@ -2223,7 +2224,6 @@ func sendMockInterceptResponse(c *gin.Context, model string, interceptType Inter
"ephemeral_1h_input_tokens": 0,
},
"output_tokens": outputTokens,
"total_tokens": 10 + outputTokens,
},
}
@@ -49,7 +49,7 @@ func TestSendMockInterceptResponse_MaxTokensOneHaiku(t *testing.T) {
id, ok := response["id"].(string)
require.True(t, ok)
require.True(t, strings.HasPrefix(id, "msg_bdrk_"))
require.True(t, strings.HasPrefix(id, "msg_01"))
content, ok := response["content"].([]any)
require.True(t, ok)
@@ -7,6 +7,7 @@ import (
"context"
"encoding/json"
"net/http/httptest"
"strings"
"testing"
"time"
@@ -289,7 +290,7 @@ func TestGatewayHandlerMessages_InterceptWarmup_AntigravityAccount_MixedScheduli
var resp map[string]any
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.Equal(t, "msg_mock_warmup", resp["id"])
require.True(t, strings.HasPrefix(resp["id"].(string), "msg_01"))
require.Equal(t, "claude-sonnet-4-5", resp["model"])
content, ok := resp["content"].([]any)
@@ -378,6 +379,6 @@ func TestGatewayHandlerMessages_InterceptWarmup_AntigravityAccount_ForcePlatform
var resp map[string]any
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.Equal(t, "msg_mock_warmup", resp["id"])
require.True(t, strings.HasPrefix(resp["id"].(string), "msg_01"))
require.Equal(t, "claude-sonnet-4-5", resp["model"])
}
@@ -98,6 +98,12 @@ func (h *GatewayHandler) GeminiV1BetaGetModel(c *gin.Context) {
googleError(c, http.StatusBadRequest, "Missing model in URL")
return
}
// 模型名会被拼进上游 URL 的 path,先在入口校验片段合规性,
// 见 service/upstream_path_guard.go。
if !service.IsSafeGeminiModelPathSegment(modelName) {
googleError(c, http.StatusBadRequest, "Invalid model in URL")
return
}
if resolvedModel, ok := service.ResolvedUpstreamModelFromContext(c.Request.Context()); ok && strings.TrimSpace(resolvedModel) != "" {
modelName = strings.TrimSpace(resolvedModel)
}
@@ -169,6 +175,12 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
googleError(c, http.StatusNotFound, err.Error())
return
}
// URL 里的模型名最终会被拼进上游 /v1beta/models/{model}:{action},
// 先在入口校验片段合规性,见 service/upstream_path_guard.go。
if !service.IsSafeGeminiModelPathSegment(modelName) {
googleError(c, http.StatusBadRequest, "Invalid model in URL")
return
}
if resolvedModel, ok := service.ResolvedUpstreamModelFromContext(c.Request.Context()); ok && strings.TrimSpace(resolvedModel) != "" {
modelName = strings.TrimSpace(resolvedModel)
}
+2
View File
@@ -58,9 +58,11 @@ type Handlers struct {
OpenAIGateway *OpenAIGatewayHandler
Setting *SettingHandler
Totp *TotpHandler
Passkey *PasskeyHandler
Payment *PaymentHandler
PaymentWebhook *PaymentWebhookHandler
AvailableChannel *AvailableChannelHandler
ModelPlaza *ModelPlazaHandler
AsyncImage *AsyncImageHandler
BatchImage *BatchImageHandler
}
@@ -0,0 +1,198 @@
package handler
import (
"log/slog"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
// ModelPlazaHandler 处理「模型广场」查询。
//
// 广场路由挂 OptionalJWT 中间件:匿名可访问(除非 require_auth 开启),带 token 则
// 识别用户。可见性规则(橱窗语义,与「可用渠道」的可绑定语义不同):
// - 匿名:仅非专属分组(订阅型照常展示);
// - 登录:非专属分组 + user_allowed_groups 授权的专属分组(不检查订阅有效性)。
type ModelPlazaHandler struct {
channelService *service.ChannelService
apiKeyService *service.APIKeyService
settingService *service.SettingService
}
// NewModelPlazaHandler 创建模型广场 handler。
func NewModelPlazaHandler(
channelService *service.ChannelService,
apiKeyService *service.APIKeyService,
settingService *service.SettingService,
) *ModelPlazaHandler {
return &ModelPlazaHandler{
channelService: channelService,
apiKeyService: apiKeyService,
settingService: settingService,
}
}
// modelPlazaOfficialPricing LiteLLM 官方参考价(USD per token)。
type modelPlazaOfficialPricing struct {
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
CacheWritePrice *float64 `json:"cache_write_price"`
CacheWrite1hPrice *float64 `json:"cache_write_1h_price,omitempty"`
CacheReadPrice *float64 `json:"cache_read_price"`
}
// modelPlazaModel 广场模型条目:渠道定价(白名单形态)+ 官方参考价。
type modelPlazaModel struct {
Name string `json:"name"`
Platform string `json:"platform"`
Pricing *userSupportedModelPricing `json:"pricing"`
OfficialPricing *modelPlazaOfficialPricing `json:"official_pricing"`
}
// modelPlazaGroup 广场分组条目(白名单字段)。
type modelPlazaGroup struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Platform string `json:"platform"`
SubscriptionType string `json:"subscription_type"`
RateMultiplier float64 `json:"rate_multiplier"`
UserRateMultiplier *float64 `json:"user_rate_multiplier,omitempty"`
PeakRateEnabled bool `json:"peak_rate_enabled"`
PeakStart string `json:"peak_start"`
PeakEnd string `json:"peak_end"`
PeakRateMultiplier float64 `json:"peak_rate_multiplier"`
IsExclusive bool `json:"is_exclusive"`
Models []modelPlazaModel `json:"models"`
}
// modelPlazaResponse 广场页响应。
type modelPlazaResponse struct {
Description string `json:"description"`
Groups []modelPlazaGroup `json:"groups"`
}
// Get 返回模型广场数据。
// GET /api/v1/model-plaza
func (h *ModelPlazaHandler) Get(c *gin.Context) {
if h.settingService == nil {
response.NotFound(c, "Model plaza is not enabled")
return
}
rt := h.settingService.GetModelPlazaRuntime(c.Request.Context())
if !rt.Enabled {
response.NotFound(c, "Model plaza is not enabled")
return
}
subject, authed := middleware.GetAuthSubjectFromContext(c)
if rt.RequireAuth && !authed {
response.Unauthorized(c, "Authentication required")
return
}
groups, err := h.channelService.ListPlazaGroups(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
return
}
// allowedExclusive == nil 表示匿名;登录用户恒为非 nil(可能为空集合)。
var allowedExclusive map[int64]struct{}
var userRates map[int64]float64
if authed {
allowedExclusive, err = h.apiKeyService.GetUserAllowedGroupIDSet(c.Request.Context(), subject.UserID)
if err != nil {
// 可见性数据拿不到时不能静默降级成匿名视图(会错漏专属分组),直接报错。
response.ErrorFrom(c, err)
return
}
userRates, err = h.apiKeyService.GetUserGroupRates(c.Request.Context(), subject.UserID)
if err != nil {
// 专属倍率仅是展示增强,失败降级为分组默认倍率。
slog.Warn("model_plaza_user_rates_failed", "error", err, "user_id", subject.UserID)
userRates = nil
}
}
visible := filterPlazaVisibleGroups(groups, allowedExclusive)
out := make([]modelPlazaGroup, 0, len(visible))
for i := range visible {
out = append(out, toModelPlazaGroupDTO(&visible[i], userRates))
}
response.Success(c, modelPlazaResponse{
Description: rt.Description,
Groups: out,
})
}
// filterPlazaVisibleGroups 按登录态裁剪分组可见性。
// allowedExclusive == nil 表示匿名(仅非专属);非 nil 表示登录(非专属 + 授权专属)。
func filterPlazaVisibleGroups(
groups []service.PlazaGroup,
allowedExclusive map[int64]struct{},
) []service.PlazaGroup {
visible := make([]service.PlazaGroup, 0, len(groups))
for _, g := range groups {
if g.IsExclusive {
if allowedExclusive == nil {
continue
}
if _, ok := allowedExclusive[g.ID]; !ok {
continue
}
}
visible = append(visible, g)
}
return visible
}
// toModelPlazaGroupDTO 将 service 层广场分组映射为白名单 DTO,并合并用户专属倍率。
func toModelPlazaGroupDTO(g *service.PlazaGroup, userRates map[int64]float64) modelPlazaGroup {
models := make([]modelPlazaModel, 0, len(g.Models))
for i := range g.Models {
m := &g.Models[i]
models = append(models, modelPlazaModel{
Name: m.Name,
Platform: m.Platform,
Pricing: toUserPricing(m.Pricing),
OfficialPricing: toModelPlazaOfficialPricing(m.OfficialPricing),
})
}
dto := modelPlazaGroup{
ID: g.ID,
Name: g.Name,
Description: g.Description,
Platform: g.Platform,
SubscriptionType: g.SubscriptionType,
RateMultiplier: g.RateMultiplier,
PeakRateEnabled: g.PeakRateEnabled,
PeakStart: g.PeakStart,
PeakEnd: g.PeakEnd,
PeakRateMultiplier: g.PeakRateMultiplier,
IsExclusive: g.IsExclusive,
Models: models,
}
if rate, ok := userRates[g.ID]; ok {
dto.UserRateMultiplier = &rate
}
return dto
}
// toModelPlazaOfficialPricing 转换官方参考价;nil 透传(前端显示 "-")。
func toModelPlazaOfficialPricing(p *service.PlazaOfficialPricing) *modelPlazaOfficialPricing {
if p == nil {
return nil
}
return &modelPlazaOfficialPricing{
InputPrice: p.InputPrice,
OutputPrice: p.OutputPrice,
CacheWritePrice: p.CacheWritePrice,
CacheWrite1hPrice: p.CacheWrite1hPrice,
CacheReadPrice: p.CacheReadPrice,
}
}
@@ -0,0 +1,126 @@
//go:build unit
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func plazaGroups() []service.PlazaGroup {
return []service.PlazaGroup{
{ID: 1, Name: "public-standard", Platform: "anthropic", SubscriptionType: "standard", RateMultiplier: 1},
{ID: 2, Name: "exclusive-a", Platform: "anthropic", IsExclusive: true, RateMultiplier: 0.5},
{ID: 3, Name: "public-subscription", Platform: "openai", SubscriptionType: "subscription", RateMultiplier: 1},
{ID: 4, Name: "exclusive-b", Platform: "openai", IsExclusive: true, RateMultiplier: 0.8},
}
}
func TestFilterPlazaVisibleGroups_AnonymousSeesOnlyNonExclusive(t *testing.T) {
// 匿名(allowedExclusive == nil):仅非专属分组;订阅型公开分组照常可见(橱窗语义)。
visible := filterPlazaVisibleGroups(plazaGroups(), nil)
require.Len(t, visible, 2)
ids := []int64{visible[0].ID, visible[1].ID}
require.ElementsMatch(t, []int64{1, 3}, ids)
}
func TestFilterPlazaVisibleGroups_AuthedSeesGrantedExclusive(t *testing.T) {
// 登录:非专属 + 授权的专属;未授权的专属仍不可见。
allowed := map[int64]struct{}{2: {}}
visible := filterPlazaVisibleGroups(plazaGroups(), allowed)
require.Len(t, visible, 3)
ids := make([]int64, 0, len(visible))
for _, g := range visible {
ids = append(ids, g.ID)
}
require.ElementsMatch(t, []int64{1, 2, 3}, ids)
}
func TestFilterPlazaVisibleGroups_AuthedEmptySetSeesNoExclusive(t *testing.T) {
// 登录但无任何专属授权(空集合,非 nil):与匿名同样只见非专属,
// 但语义区分要保持——空集合不能被当作 nil 匿名分支。
visible := filterPlazaVisibleGroups(plazaGroups(), map[int64]struct{}{})
require.Len(t, visible, 2)
}
func TestModelPlazaHandler_NilSettingServiceFailsClosed404(t *testing.T) {
gin.SetMode(gin.TestMode)
h := &ModelPlazaHandler{} // settingService == nil → fail-closed
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/model-plaza", nil)
h.Get(c)
require.Equal(t, http.StatusNotFound, w.Code)
}
func TestToModelPlazaGroupDTO_UserRateAndFieldWhitelist(t *testing.T) {
g := service.PlazaGroup{
ID: 2, Name: "vip", Description: "d", Platform: "anthropic",
SubscriptionType: "standard", RateMultiplier: 1, IsExclusive: true,
Models: []service.PlazaModel{{
Name: "claude-sonnet",
Platform: "anthropic",
Pricing: &service.ChannelModelPricing{
BillingMode: service.BillingModeToken,
InputPrice: testPtr(3e-6),
},
OfficialPricing: &service.PlazaOfficialPricing{
InputPrice: testPtr(3e-6),
CacheReadPrice: testPtr(3e-7),
},
}},
}
// 有专属倍率:user_rate_multiplier 序列化输出
dto := toModelPlazaGroupDTO(&g, map[int64]float64{2: 0.5})
raw, err := json.Marshal(dto)
require.NoError(t, err)
var decoded map[string]any
require.NoError(t, json.Unmarshal(raw, &decoded))
for _, key := range []string{
"id", "name", "description", "platform", "subscription_type",
"rate_multiplier", "user_rate_multiplier", "is_exclusive", "models",
"peak_rate_enabled", "peak_start", "peak_end", "peak_rate_multiplier",
} {
_, exists := decoded[key]
require.Truef(t, exists, "plaza group DTO must expose %q", key)
}
require.InDelta(t, 0.5, decoded["user_rate_multiplier"].(float64), 1e-9)
// 模型条目:pricing + official_pricing 并存;official 缺失字段输出 null 而非省略
models := decoded["models"].([]any)
require.Len(t, models, 1)
model := models[0].(map[string]any)
require.Contains(t, model, "pricing")
require.Contains(t, model, "official_pricing")
official := model["official_pricing"].(map[string]any)
require.Contains(t, official, "input_price")
require.Contains(t, official, "cache_read_price")
_, has1h := official["cache_write_1h_price"]
require.False(t, has1h, "1h 缓存写价为 nil 时应 omitempty")
// 无专属倍率:user_rate_multiplier 整个字段省略
dtoNoRate := toModelPlazaGroupDTO(&g, nil)
rawNoRate, err := json.Marshal(dtoNoRate)
require.NoError(t, err)
var decodedNoRate map[string]any
require.NoError(t, json.Unmarshal(rawNoRate, &decodedNoRate))
_, hasRate := decodedNoRate["user_rate_multiplier"]
require.False(t, hasRate, "无专属倍率时 user_rate_multiplier 应 omitempty")
}
func TestToModelPlazaOfficialPricing_NilPassthrough(t *testing.T) {
require.Nil(t, toModelPlazaOfficialPricing(nil))
}
func testPtr(v float64) *float64 { return &v }
@@ -15,9 +15,9 @@ import (
// Codex CLI and the Codex desktop app refresh their model picker from
// GET {base_url}/models?client_version=... (custom provider mode) or
// GET /backend-api/codex/models (chatgpt_base_url mode). Both routes land
// here. The manifest is proxied verbatim from the selected account's ChatGPT
// backend or custom API key upstream. API key manifests use a short-lived,
// asynchronously revalidated cache to tolerate canceled client requests.
// here. ChatGPT manifests are proxied verbatim; custom API key manifests receive
// provider-compatibility normalization and use a short-lived, asynchronously
// revalidated cache to tolerate canceled client requests.
func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
if c.Request.Context().Err() != nil {
return
@@ -1407,7 +1407,7 @@ func TestOpenAIResponsesWebSocket_PassthroughTracksModelPerTurn(t *testing.T) {
require.Equal(t, "gpt-5.6-terra", *got.logs[1].UpstreamModel)
require.NotNil(t, got.logs[1].ModelMappingChain)
require.Equal(t, "terra→terra-channel→gpt-5.6-terra", *got.logs[1].ModelMappingChain)
require.InDelta(t, got.logs[1].TotalCost*2, got.logs[0].TotalCost, 1e-12,
require.InDelta(t, got.logs[1].TotalCost*2.5, got.logs[0].TotalCost, 1e-12,
"each turn must be billed with its own channel-mapped model")
}
@@ -1477,7 +1477,7 @@ func TestOpenAIResponsesWebSocket_PassthroughKeepsTurnMappingSnapshot(t *testing
require.Equal(t, "gpt-5.6-terra", *got.logs[1].UpstreamModel)
require.NotNil(t, got.logs[1].ModelMappingChain)
require.Equal(t, "sol→gpt-5.6-terra", *got.logs[1].ModelMappingChain)
require.InDelta(t, got.logs[1].TotalCost*2, got.logs[0].TotalCost, 1e-12,
require.InDelta(t, got.logs[1].TotalCost*2.5, got.logs[0].TotalCost, 1e-12,
"the next turn must use the updated channel mapping")
}
@@ -1506,7 +1506,7 @@ func TestOpenAIResponsesWebSocket_CtxPoolAppliesPerTurnMappingAndPreservesReques
require.Equal(t, "gpt-5.6-terra", got.logs[1].RequestedModel)
require.NotNil(t, got.logs[1].ModelMappingChain)
require.Equal(t, "gpt-5.6-terra→gpt-5.6-sol", *got.logs[1].ModelMappingChain)
require.InDelta(t, 20e-6, got.logs[1].TotalCost, 1e-12,
require.InDelta(t, 16e-6, got.logs[1].TotalCost, 1e-12,
"BillingModelSourceRequested must use the client model before channel mapping")
}
+254
View File
@@ -0,0 +1,254 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
type PasskeyHandler struct {
passkeys *service.PasskeyService
authService *service.AuthService
settingSvc *service.SettingService
}
func NewPasskeyHandler(
passkeys *service.PasskeyService,
authService *service.AuthService,
settingService *service.SettingService,
) *PasskeyHandler {
return &PasskeyHandler{
passkeys: passkeys,
authService: authService,
settingSvc: settingService,
}
}
type passkeyOptionsResponse struct {
SessionToken string `json:"session_token"`
Options any `json:"options"`
}
type passkeyFinishRequest struct {
SessionToken string `json:"session_token" binding:"required"`
Name string `json:"name,omitempty"`
Credential json.RawMessage `json:"credential" binding:"required"`
}
type passkeyRenameRequest struct {
Name string `json:"name" binding:"required"`
}
// passkeyPasswordRequest carries the account password that gates passkey
// enrollment and revocation. Binding errors are tolerated: a missing or
// malformed body yields an empty password, which the service rejects with a
// precise PASSWORD_REQUIRED error.
type passkeyPasswordRequest struct {
Password string `json:"password"`
}
func bindPasskeyPassword(c *gin.Context) string {
var req passkeyPasswordRequest
_ = c.ShouldBindJSON(&req)
return req.Password
}
const passkeyFinishBodyMaxBytes = 64 * 1024
// BeginLogin starts a usernameless, discoverable-credential login ceremony.
func (h *PasskeyHandler) BeginLogin(c *gin.Context) {
if !h.requirePasskeysEnabled(c) {
return
}
assertion, token, err := h.passkeys.BeginLogin(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, passkeyOptionsResponse{SessionToken: token, Options: assertion})
}
// FinishLogin validates a passkey assertion and creates a normal Sub2API token
// session. User verification is mandatory, so a successful passkey assertion
// already supplies phishing-resistant multi-factor authentication and does not
// enter the separate TOTP challenge flow.
func (h *PasskeyHandler) FinishLogin(c *gin.Context) {
if !h.requirePasskeysEnabled(c) {
return
}
req, ok := bindPasskeyFinishRequest(c)
if !ok {
return
}
credentialRequest := cloneRequestWithJSON(c.Request, req.Credential)
user, err := h.passkeys.FinishLogin(c.Request.Context(), req.SessionToken, credentialRequest)
if err != nil {
response.ErrorFrom(c, err)
return
}
if err = h.ensureBackendModeAllowsUser(c.Request.Context(), user); err != nil {
response.ErrorFrom(c, err)
return
}
middleware2.SetAuditActor(c, user.ID, user.Email)
c.Set("auth_method", service.AuditAuthMethodPasskey)
h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID)
respondWithTokenPair(c, h.authService, user)
}
func (h *PasskeyHandler) BeginRegistration(c *gin.Context) {
if !h.requirePasskeysEnabled(c) {
return
}
subject, ok := middleware2.GetAuthSubjectFromContext(c)
if !ok {
response.Unauthorized(c, "User not authenticated")
return
}
creation, token, err := h.passkeys.BeginRegistration(c.Request.Context(), subject.UserID, bindPasskeyPassword(c))
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, passkeyOptionsResponse{SessionToken: token, Options: creation})
}
func (h *PasskeyHandler) FinishRegistration(c *gin.Context) {
if !h.requirePasskeysEnabled(c) {
return
}
subject, ok := middleware2.GetAuthSubjectFromContext(c)
if !ok {
response.Unauthorized(c, "User not authenticated")
return
}
req, valid := bindPasskeyFinishRequest(c)
if !valid {
return
}
credentialRequest := cloneRequestWithJSON(c.Request, req.Credential)
credential, err := h.passkeys.FinishRegistration(
c.Request.Context(),
subject.UserID,
req.SessionToken,
req.Name,
credentialRequest,
)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, credential)
}
func (h *PasskeyHandler) List(c *gin.Context) {
subject, ok := middleware2.GetAuthSubjectFromContext(c)
if !ok {
response.Unauthorized(c, "User not authenticated")
return
}
credentials, err := h.passkeys.List(c.Request.Context(), subject.UserID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, credentials)
}
func (h *PasskeyHandler) Rename(c *gin.Context) {
subject, credentialID, ok := passkeyMutationTarget(c)
if !ok {
return
}
var req passkeyRenameRequest
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Name) == "" {
response.BadRequest(c, "Passkey name is required")
return
}
if err := h.passkeys.Rename(c.Request.Context(), subject.UserID, credentialID, req.Name); err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, gin.H{"success": true})
}
func (h *PasskeyHandler) Delete(c *gin.Context) {
subject, credentialID, ok := passkeyMutationTarget(c)
if !ok {
return
}
if err := h.passkeys.Delete(c.Request.Context(), subject.UserID, credentialID, bindPasskeyPassword(c)); err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, gin.H{"success": true})
}
func (h *PasskeyHandler) requirePasskeysEnabled(c *gin.Context) bool {
if h.settingSvc == nil {
response.ErrorFrom(c, service.ErrPasskeysDisabled)
return false
}
enabled, err := h.settingSvc.PasskeyEnabled(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
return false
}
if !enabled {
response.ErrorFrom(c, service.ErrPasskeysDisabled)
}
return enabled
}
func (h *PasskeyHandler) ensureBackendModeAllowsUser(ctx context.Context, user *service.User) error {
if err := ensureLoginUserActive(user); err != nil {
return err
}
if h.settingSvc == nil || !h.settingSvc.IsBackendModeEnabled(ctx) || user.IsAdmin() {
return nil
}
return infraerrors.Forbidden("BACKEND_MODE_ADMIN_ONLY", "Backend mode is active. Only admin login is allowed.")
}
func bindPasskeyFinishRequest(c *gin.Context) (*passkeyFinishRequest, bool) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, passkeyFinishBodyMaxBytes)
var req passkeyFinishRequest
if err := c.ShouldBindJSON(&req); err != nil || len(req.Credential) == 0 {
response.BadRequest(c, "Invalid passkey response")
return nil, false
}
return &req, true
}
func cloneRequestWithJSON(original *http.Request, payload []byte) *http.Request {
request := original.Clone(original.Context())
request.Body = io.NopCloser(bytes.NewReader(payload))
request.ContentLength = int64(len(payload))
request.Header = original.Header.Clone()
request.Header.Set("Content-Type", "application/json")
return request
}
func passkeyMutationTarget(c *gin.Context) (middleware2.AuthSubject, int64, bool) {
subject, ok := middleware2.GetAuthSubjectFromContext(c)
if !ok {
response.Unauthorized(c, "User not authenticated")
return middleware2.AuthSubject{}, 0, false
}
credentialID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || credentialID <= 0 {
response.BadRequest(c, "Invalid passkey ID")
return middleware2.AuthSubject{}, 0, false
}
return subject, credentialID, true
}
@@ -0,0 +1,101 @@
package handler
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type passkeySwitchSettingRepo struct {
value string
err error
}
func (r *passkeySwitchSettingRepo) Get(context.Context, string) (*service.Setting, error) {
return nil, service.ErrSettingNotFound
}
func (r *passkeySwitchSettingRepo) GetValue(context.Context, string) (string, error) {
return r.value, r.err
}
func (r *passkeySwitchSettingRepo) Set(context.Context, string, string) error { return nil }
func (r *passkeySwitchSettingRepo) GetMultiple(context.Context, []string) (map[string]string, error) {
return map[string]string{}, nil
}
func (r *passkeySwitchSettingRepo) SetMultiple(context.Context, map[string]string) error {
return nil
}
func (r *passkeySwitchSettingRepo) GetAll(context.Context) (map[string]string, error) {
return map[string]string{}, nil
}
func (r *passkeySwitchSettingRepo) Delete(context.Context, string) error { return nil }
func TestBindPasskeyFinishRequestRejectsOversizedBody(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(
http.MethodPost,
"/api/v1/auth/passkey/login/finish",
strings.NewReader(`{"credential":"`+strings.Repeat("x", passkeyFinishBodyMaxBytes)+`"}`),
)
context.Request.Header.Set("Content-Type", "application/json")
_, ok := bindPasskeyFinishRequest(context)
require.False(t, ok)
require.Equal(t, http.StatusBadRequest, recorder.Code)
}
func TestPasskeyBeginLoginRejectsDisabledAdminSwitch(t *testing.T) {
gin.SetMode(gin.TestMode)
repo := &passkeySwitchSettingRepo{value: "false"}
settings := service.NewSettingService(repo, &config.Config{
WebAuthn: config.WebAuthnConfig{Enabled: true},
})
handler := NewPasskeyHandler(nil, nil, settings)
recorder := httptest.NewRecorder()
ginContext, _ := gin.CreateTestContext(recorder)
ginContext.Request = httptest.NewRequest(http.MethodPost, "/api/v1/auth/passkey/login/begin", nil)
handler.BeginLogin(ginContext)
require.Equal(t, http.StatusForbidden, recorder.Code)
require.Contains(t, recorder.Body.String(), "PASSKEY_DISABLED")
}
func TestPasskeyBeginLoginReportsSettingStoreFailure(t *testing.T) {
gin.SetMode(gin.TestMode)
settings := service.NewSettingService(
&passkeySwitchSettingRepo{err: errors.New("database unavailable")},
&config.Config{WebAuthn: config.WebAuthnConfig{Enabled: true}},
)
handler := NewPasskeyHandler(nil, nil, settings)
recorder := httptest.NewRecorder()
ginContext, _ := gin.CreateTestContext(recorder)
ginContext.Request = httptest.NewRequest(http.MethodPost, "/api/v1/auth/passkey/login/begin", nil)
handler.BeginLogin(ginContext)
require.Equal(t, http.StatusInternalServerError, recorder.Code)
require.NotContains(t, recorder.Body.String(), "PASSKEY_DISABLED")
}
func TestPasskeyCredentialListRemainsAvailableWhenSignInDisabled(t *testing.T) {
gin.SetMode(gin.TestMode)
handler := NewPasskeyHandler(nil, nil, nil)
recorder := httptest.NewRecorder()
ginContext, _ := gin.CreateTestContext(recorder)
ginContext.Request = httptest.NewRequest(http.MethodGet, "/api/v1/user/passkeys", nil)
handler.List(ginContext)
require.Equal(t, http.StatusUnauthorized, recorder.Code)
require.NotContains(t, recorder.Body.String(), "PASSKEY_DISABLED")
}
@@ -52,6 +52,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
PasswordResetEnabled: settings.PasswordResetEnabled,
InvitationCodeEnabled: settings.InvitationCodeEnabled,
TotpEnabled: settings.TotpEnabled,
PasskeyEnabled: settings.PasskeyEnabled,
LoginAgreementEnabled: settings.LoginAgreementEnabled,
LoginAgreementMode: settings.LoginAgreementMode,
LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt,
@@ -98,6 +99,9 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
ModelPlazaEnabled: settings.ModelPlazaEnabled,
ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth,
AffiliateEnabled: settings.AffiliateEnabled,
RiskControlEnabled: settings.RiskControlEnabled,
+14 -2
View File
@@ -41,7 +41,7 @@ func (s *userHandlerRepoStub) GetFirstAdmin(context.Context) (*service.User, err
cloned := *s.user
return &cloned, nil
}
func (s *userHandlerRepoStub) Update(_ context.Context, user *service.User) error {
func (s *userHandlerRepoStub) Update(_ context.Context, user *service.User, _ service.UserUpdateFields) error {
cloned := *user
s.user = &cloned
return nil
@@ -93,6 +93,14 @@ func (s *userHandlerRepoStub) UpdateConcurrency(context.Context, int64, int) err
func (s *userHandlerRepoStub) BatchSetConcurrency(context.Context, []int64, int) (int, error) {
return 0, nil
}
func (s *userHandlerRepoStub) AdjustBalance(ctx context.Context, id int64, delta float64) (service.BalanceChange, error) {
panic("unexpected AdjustBalance call")
}
func (s *userHandlerRepoStub) SetBalance(ctx context.Context, id int64, value float64) (service.BalanceChange, error) {
panic("unexpected SetBalance call")
}
func (s *userHandlerRepoStub) BatchAddConcurrency(context.Context, []int64, int) (int, error) {
return 0, nil
}
@@ -656,7 +664,11 @@ func TestUserHandlerUnbindIdentityRevokesAllUserSessionsWhenAuthServiceConfigure
require.Equal(t, http.StatusOK, recorder.Code)
require.Equal(t, []int64{23}, refreshTokenCache.revokedUserIDs)
require.Equal(t, int64(5), repo.user.TokenVersion)
// 撤销依赖的是 refresh session 清理,而不是 token_version:users 表没有这一列
// (见 resolvedTokenVersion,实际值由 email+password_hash 指纹推导),
// 所以此前"自增 TokenVersion 再整行写回"不持久化任何东西,
// 却会用旧快照覆盖并发写入的列。这里断言用户行未被改写。
require.Equal(t, int64(4), repo.user.TokenVersion)
}
func TestUserHandlerUnbindIdentityDoesNotRevokeSessionsWhenNothingWasUnbound(t *testing.T) {
+6
View File
@@ -179,9 +179,11 @@ func ProvideHandlers(
openaiGatewayHandler *OpenAIGatewayHandler,
settingHandler *SettingHandler,
totpHandler *TotpHandler,
passkeyHandler *PasskeyHandler,
paymentHandler *PaymentHandler,
paymentWebhookHandler *PaymentWebhookHandler,
availableChannelHandler *AvailableChannelHandler,
modelPlazaHandler *ModelPlazaHandler,
asyncImageHandler *AsyncImageHandler,
batchImageHandler *BatchImageHandler,
_ *service.IdempotencyCoordinator,
@@ -201,9 +203,11 @@ func ProvideHandlers(
OpenAIGateway: openaiGatewayHandler,
Setting: settingHandler,
Totp: totpHandler,
Passkey: passkeyHandler,
Payment: paymentHandler,
PaymentWebhook: paymentWebhookHandler,
AvailableChannel: availableChannelHandler,
ModelPlaza: modelPlazaHandler,
AsyncImage: asyncImageHandler,
BatchImage: batchImageHandler,
}
@@ -223,10 +227,12 @@ var ProviderSet = wire.NewSet(
ProvideGatewayHandler,
ProvideOpenAIGatewayHandler,
NewTotpHandler,
NewPasskeyHandler,
ProvideSettingHandler,
NewPaymentHandler,
NewPaymentWebhookHandler,
NewAvailableChannelHandler,
NewModelPlazaHandler,
NewAsyncImageHandler,
ProvideBatchImageHandler,
@@ -293,7 +293,7 @@ func (p *NonStreamingProcessor) buildResponse(geminiResp *GeminiResponse, respon
respID = geminiResp.ResponseID
}
if respID == "" {
respID = "msg_" + generateRandomID()
respID = generateAnthropicMsgID()
}
return &ClaudeResponse{
@@ -372,3 +372,18 @@ func generateRandomID() string {
}
return string(id)
}
// generateAnthropicMsgID 生成 Anthropic 官方格式的 message ID:msg_01 + 22 位 Base62
func generateAnthropicMsgID() string {
const charset = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
const idLen = 22
randomBytes := make([]byte, idLen)
if _, err := rand.Read(randomBytes); err != nil {
return "msg_01" + generateRandomID() + generateRandomID()[:10]
}
b := make([]byte, idLen)
for i := range b {
b[i] = charset[int(randomBytes[i])%len(charset)]
}
return "msg_01" + string(b)
}
@@ -195,7 +195,7 @@ func (p *StreamingProcessor) emitMessageStart(v1Resp *V1InternalResponse) []byte
responseID = v1Resp.Response.ResponseID
}
if responseID == "" {
responseID = "msg_" + generateRandomID()
responseID = generateAnthropicMsgID()
}
var usageValue any = usage
+5
View File
@@ -611,6 +611,11 @@ func BuildVideoURLWithValidator(baseURL, requestID string, validator BaseURLVali
if requestID == "" {
return "", fmt.Errorf("request id is required")
}
// requestID 由客户端提供并拼进上游 URL 的 path。PathEscape 之外再要求它不是
// 纯点片段、不含控制字符,保证它只能是一个普通的路径片段。
if requestID == "." || requestID == ".." || strings.ContainsAny(requestID, "\x00\r\n") {
return "", fmt.Errorf("invalid request id")
}
return validatedBaseURL + "/videos/" + url.PathEscape(requestID), nil
}
@@ -1107,6 +1107,7 @@ func (r *accountRepository) ListOAuthRefreshCandidatePage(ctx context.Context, o
SELECT id
FROM accounts
WHERE deleted_at IS NULL
AND schedulable = TRUE
AND platform = ANY($1)
AND id > $2`
if options.ActiveOnly {
@@ -327,6 +327,14 @@ func (s *AccountRepoSuite) TestListOAuthRefreshCandidatePage_GrokCursorAndExclus
"expires_at": now.Add(30 * time.Minute).Format(time.RFC3339),
},
})
unschedulable := mustCreateAccount(s.T(), s.client, &service.Account{
Name: "grok-oauth-unschedulable-excluded",
Platform: service.PlatformGrok,
Type: service.AccountTypeOAuth,
Status: service.StatusActive,
Credentials: map[string]any{"refresh_token": "refresh-unschedulable"},
})
s.Require().NoError(s.client.Account.UpdateOneID(unschedulable.ID).SetSchedulable(false).Exec(s.ctx))
mustCreateAccount(s.T(), s.client, &service.Account{
Name: "grok-api-key-excluded",
Platform: service.PlatformGrok,
@@ -386,6 +394,7 @@ func (s *AccountRepoSuite) TestListOAuthRefreshCandidatePage_GrokCursorAndExclus
first := firstPage.Accounts
s.Require().Len(first, 2)
s.Require().Equal([]int64{valid1.ID, valid2.ID}, []int64{first[0].ID, first[1].ID})
s.Require().NotContains([]int64{first[0].ID, first[1].ID}, unschedulable.ID)
options.AfterID = first[len(first)-1].ID
secondPage, err := s.repo.ListOAuthRefreshCandidatePage(s.ctx, options)
@@ -276,6 +276,8 @@ func TestAccountRepository_ListOAuthRefreshCandidatePage_SQLFilter(t *testing.T)
normalized := normalizeSQLWhitespace(capturedSQL)
require.Contains(t, normalized, "deleted_at IS NULL")
require.Contains(t, normalized, "schedulable = TRUE",
"permanently unschedulable accounts must not remain OAuth refresh candidates")
require.Contains(t, normalized, "status = 'active'")
// setup-token 的 access_token 同为 8h 短期令牌,必须与 oauth 一起纳入后台刷新候选
require.Contains(t, normalized, "type IN ('oauth', 'setup-token')")
+70 -45
View File
@@ -222,7 +222,12 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se
return apiKeyEntityToService(m), nil
}
func (r *apiKeyRepository) Update(ctx context.Context, key *service.APIKey) error {
func (r *apiKeyRepository) Update(ctx context.Context, key *service.APIKey, fields service.APIKeyUpdateFields) error {
// 空掩码代表调用方不改任何列,直接返回,避免产生一次无意义的整行写。
if fields.IsEmpty() {
return nil
}
// 使用原子操作:将软删除检查与更新合并到同一语句,避免竞态条件。
// 之前的实现先检查 Exist 再 UpdateOneID,若在两步之间发生软删除,
// 则会更新已删除的记录。
@@ -232,57 +237,77 @@ func (r *apiKeyRepository) Update(ctx context.Context, key *service.APIKey) erro
now := time.Now()
builder := client.APIKey.Update().
Where(apikey.IDEQ(key.ID), apikey.DeletedAtIsNil()).
SetName(key.Name).
SetStatus(key.Status).
SetQuota(key.Quota).
SetQuotaUsed(key.QuotaUsed).
SetRateLimit5h(key.RateLimit5h).
SetRateLimit1d(key.RateLimit1d).
SetRateLimit7d(key.RateLimit7d).
SetUsage5h(key.Usage5h).
SetUsage1d(key.Usage1d).
SetUsage7d(key.Usage7d).
SetUpdatedAt(now)
if key.GroupID != nil {
builder.SetGroupID(*key.GroupID)
} else {
builder.ClearGroupID()
if fields.Name {
builder.SetName(key.Name)
}
if fields.Status {
builder.SetStatus(key.Status)
}
if fields.Quota {
builder.SetQuota(key.Quota)
}
if fields.QuotaUsed {
builder.SetQuotaUsed(key.QuotaUsed)
}
if fields.RateLimits {
builder.
SetRateLimit5h(key.RateLimit5h).
SetRateLimit1d(key.RateLimit1d).
SetRateLimit7d(key.RateLimit7d)
}
if fields.RateLimitUsage {
builder.
SetUsage5h(key.Usage5h).
SetUsage1d(key.Usage1d).
SetUsage7d(key.Usage7d)
// Rate limit window start times
if key.Window5hStart != nil {
builder.SetWindow5hStart(*key.Window5hStart)
} else {
builder.ClearWindow5hStart()
}
if key.Window1dStart != nil {
builder.SetWindow1dStart(*key.Window1dStart)
} else {
builder.ClearWindow1dStart()
}
if key.Window7dStart != nil {
builder.SetWindow7dStart(*key.Window7dStart)
} else {
builder.ClearWindow7dStart()
}
}
if fields.GroupID {
if key.GroupID != nil {
builder.SetGroupID(*key.GroupID)
} else {
builder.ClearGroupID()
}
}
// Expiration time
if key.ExpiresAt != nil {
builder.SetExpiresAt(*key.ExpiresAt)
} else {
builder.ClearExpiresAt()
}
// Rate limit window start times
if key.Window5hStart != nil {
builder.SetWindow5hStart(*key.Window5hStart)
} else {
builder.ClearWindow5hStart()
}
if key.Window1dStart != nil {
builder.SetWindow1dStart(*key.Window1dStart)
} else {
builder.ClearWindow1dStart()
}
if key.Window7dStart != nil {
builder.SetWindow7dStart(*key.Window7dStart)
} else {
builder.ClearWindow7dStart()
if fields.ExpiresAt {
if key.ExpiresAt != nil {
builder.SetExpiresAt(*key.ExpiresAt)
} else {
builder.ClearExpiresAt()
}
}
// IP 限制字段
if len(key.IPWhitelist) > 0 {
builder.SetIPWhitelist(key.IPWhitelist)
} else {
builder.ClearIPWhitelist()
}
if len(key.IPBlacklist) > 0 {
builder.SetIPBlacklist(key.IPBlacklist)
} else {
builder.ClearIPBlacklist()
if fields.IPRules {
if len(key.IPWhitelist) > 0 {
builder.SetIPWhitelist(key.IPWhitelist)
} else {
builder.ClearIPWhitelist()
}
if len(key.IPBlacklist) > 0 {
builder.SetIPBlacklist(key.IPBlacklist)
} else {
builder.ClearIPBlacklist()
}
}
affected, err := builder.Save(ctx)
@@ -139,7 +139,7 @@ func (s *APIKeyRepoSuite) TestUpdate() {
key.Name = "Renamed"
key.Status = service.StatusDisabled
err := s.repo.Update(s.ctx, key)
err := s.repo.Update(s.ctx, key, service.APIKeyUpdateFields{Name: true, Status: true})
s.Require().NoError(err, "Update")
got, err := s.repo.GetByID(s.ctx, key.ID)
@@ -163,7 +163,7 @@ func (s *APIKeyRepoSuite) TestUpdate_ClearGroupID() {
s.Require().NoError(s.repo.Create(s.ctx, key))
key.GroupID = nil
err := s.repo.Update(s.ctx, key)
err := s.repo.Update(s.ctx, key, service.APIKeyUpdateFields{GroupID: true})
s.Require().NoError(err, "Update")
got, err := s.repo.GetByID(s.ctx, key.ID)
@@ -368,7 +368,7 @@ func (s *APIKeyRepoSuite) TestCRUD_Search_ClearGroupID() {
key.Name = "Renamed"
key.Status = service.StatusDisabled
key.GroupID = nil
s.Require().NoError(s.repo.Update(s.ctx, key), "Update")
s.Require().NoError(s.repo.Update(s.ctx, key, service.APIKeyUpdateFields{Name: true, Status: true, GroupID: true}), "Update")
got2, err := s.repo.GetByID(s.ctx, key.ID)
s.Require().NoError(err, "GetByID")
@@ -486,7 +486,7 @@ func (s *APIKeyRepoSuite) TestIncrementQuotaUsedAndGetState() {
key := s.mustCreateApiKey(user.ID, "sk-quota-state", "QuotaState", nil)
key.Quota = 3
key.QuotaUsed = 1
s.Require().NoError(s.repo.Update(s.ctx, key), "Update quota")
s.Require().NoError(s.repo.Update(s.ctx, key, service.APIKeyUpdateFields{Quota: true, QuotaUsed: true}), "Update quota")
state, err := s.repo.IncrementQuotaUsedAndGetState(s.ctx, key.ID, 2.5)
s.Require().NoError(err, "IncrementQuotaUsedAndGetState")
@@ -0,0 +1,111 @@
//go:build integration
package repository
import (
"github.com/Wei-Shaw/sub2api/internal/service"
)
// api_keys 上的用量列由计费热路径原子递增(IncrementQuotaUsed /
// IncrementRateLimitUsage)。编辑 Key 时若整行回写,
// 并发累计的配额与限流计数就会被旧快照覆盖。
func (s *APIKeyRepoSuite) TestUpdate_DoesNotRevertConcurrentQuotaUsage() {
user := s.mustCreateUser("apikey-lost-update-quota@example.com")
key := &service.APIKey{
UserID: user.ID,
Key: "sk-lost-update-quota",
Name: "before",
Status: service.StatusActive,
Quota: 100,
}
s.Require().NoError(s.repo.Create(s.ctx, key), "Create")
stale, err := s.repo.GetByID(s.ctx, key.ID)
s.Require().NoError(err, "GetByID")
s.Require().Zero(stale.QuotaUsed)
newUsed, err := s.repo.IncrementQuotaUsed(s.ctx, key.ID, 30)
s.Require().NoError(err, "IncrementQuotaUsed")
s.Require().InDelta(30, newUsed, 1e-9)
stale.Name = "after"
s.Require().NoError(
s.repo.Update(s.ctx, stale, service.APIKeyUpdateFields{Name: true}),
"Update",
)
got, err := s.repo.GetByID(s.ctx, key.ID)
s.Require().NoError(err, "GetByID after update")
s.Require().Equal("after", got.Name, "declared column must still be written")
s.Require().InDelta(30, got.QuotaUsed, 1e-9, "quota_used must not be reverted by a stale key edit")
}
func (s *APIKeyRepoSuite) TestUpdate_DoesNotRevertConcurrentRateLimitUsage() {
user := s.mustCreateUser("apikey-lost-update-ratelimit@example.com")
key := &service.APIKey{
UserID: user.ID,
Key: "sk-lost-update-ratelimit",
Name: "before",
Status: service.StatusActive,
RateLimit5h: 100,
}
s.Require().NoError(s.repo.Create(s.ctx, key), "Create")
stale, err := s.repo.GetByID(s.ctx, key.ID)
s.Require().NoError(err, "GetByID")
s.Require().Zero(stale.Usage5h)
s.Require().NoError(s.repo.IncrementRateLimitUsage(s.ctx, key.ID, 42), "IncrementRateLimitUsage")
stale.Name = "after"
s.Require().NoError(
s.repo.Update(s.ctx, stale, service.APIKeyUpdateFields{Name: true}),
"Update",
)
got, err := s.repo.GetByID(s.ctx, key.ID)
s.Require().NoError(err, "GetByID after update")
s.Require().InDelta(42, got.Usage5h, 1e-9, "usage_5h must not be reverted by a stale key edit")
s.Require().InDelta(42, got.Usage1d, 1e-9, "usage_1d must not be reverted by a stale key edit")
s.Require().InDelta(42, got.Usage7d, 1e-9, "usage_7d must not be reverted by a stale key edit")
}
// 显式重置仍然必须生效,避免收窄写入列时把功能改坏。
func (s *APIKeyRepoSuite) TestUpdate_StillResetsUsageWhenDeclared() {
user := s.mustCreateUser("apikey-reset-usage@example.com")
key := &service.APIKey{
UserID: user.ID,
Key: "sk-reset-usage",
Name: "reset",
Status: service.StatusActive,
Quota: 100,
}
s.Require().NoError(s.repo.Create(s.ctx, key), "Create")
_, err := s.repo.IncrementQuotaUsed(s.ctx, key.ID, 30)
s.Require().NoError(err, "IncrementQuotaUsed")
s.Require().NoError(s.repo.IncrementRateLimitUsage(s.ctx, key.ID, 42), "IncrementRateLimitUsage")
current, err := s.repo.GetByID(s.ctx, key.ID)
s.Require().NoError(err, "GetByID")
current.QuotaUsed = 0
current.Usage5h = 0
current.Usage1d = 0
current.Usage7d = 0
current.Window5hStart = nil
current.Window1dStart = nil
current.Window7dStart = nil
s.Require().NoError(
s.repo.Update(s.ctx, current, service.APIKeyUpdateFields{QuotaUsed: true, RateLimitUsage: true}),
"Update",
)
got, err := s.repo.GetByID(s.ctx, key.ID)
s.Require().NoError(err, "GetByID after reset")
s.Require().Zero(got.QuotaUsed, "explicit quota reset must still apply")
s.Require().Zero(got.Usage5h, "explicit rate limit reset must still apply")
s.Require().Zero(got.Usage1d)
s.Require().Zero(got.Usage7d)
s.Require().Nil(got.Window5hStart)
}
@@ -77,8 +77,8 @@ func TestAuthCacheInvalidationTriggers_CoverSecurityMutationsOnly(t *testing.T)
userRepo := NewUserRepository(integrationEntClient, integrationDB)
loadedUser, err := userRepo.GetByID(ctx, user.ID)
require.NoError(t, err)
loadedUser.Balance += 10
require.NoError(t, userRepo.Update(ctx, loadedUser))
_, err = userRepo.AdjustBalance(ctx, loadedUser.ID, 10)
require.NoError(t, err)
require.Zero(t, count(), "balance update with unchanged allowed groups must not enqueue")
_, err = integrationDB.ExecContext(ctx, "UPDATE users SET status = 'disabled' WHERE id = $1", user.ID)
+239
View File
@@ -0,0 +1,239 @@
package repository
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/lib/pq"
)
type passkeyRepository struct {
db *sql.DB
}
func NewPasskeyRepository(db *sql.DB) service.PasskeyRepository {
return &passkeyRepository{db: db}
}
func (r *passkeyRepository) EnsureUserHandle(
ctx context.Context,
userID int64,
candidate []byte,
) ([]byte, error) {
if len(candidate) < 16 || len(candidate) > 64 {
return nil, fmt.Errorf("passkey user handle must contain 16-64 bytes")
}
if _, err := r.db.ExecContext(ctx, `
INSERT INTO passkey_user_handles (user_id, user_handle)
VALUES ($1, $2)
ON CONFLICT (user_id) DO NOTHING
`, userID, candidate); err != nil {
return nil, fmt.Errorf("ensure passkey user handle: %w", err)
}
return r.GetUserHandle(ctx, userID)
}
func (r *passkeyRepository) GetUserHandle(ctx context.Context, userID int64) ([]byte, error) {
var handle []byte
err := r.db.QueryRowContext(ctx, `
SELECT user_handle
FROM passkey_user_handles
WHERE user_id = $1
`, userID).Scan(&handle)
if errors.Is(err, sql.ErrNoRows) {
return nil, service.ErrPasskeyNotFound
}
if err != nil {
return nil, fmt.Errorf("get passkey user handle: %w", err)
}
return handle, nil
}
func (r *passkeyRepository) GetByCredentialID(
ctx context.Context,
credentialID []byte,
) (*service.PasskeyCredentialRecord, error) {
row := r.db.QueryRowContext(ctx, `
SELECT c.id, c.user_id, h.user_handle, c.name, c.credential_data,
c.last_used_at, c.created_at, c.updated_at
FROM passkey_credentials c
JOIN passkey_user_handles h ON h.user_id = c.user_id
WHERE c.credential_id = $1
`, credentialID)
record, err := scanPasskeyCredential(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, service.ErrPasskeyNotFound
}
if err != nil {
return nil, fmt.Errorf("get passkey credential: %w", err)
}
return record, nil
}
func (r *passkeyRepository) ListByUserID(
ctx context.Context,
userID int64,
) ([]service.PasskeyCredentialRecord, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT c.id, c.user_id, h.user_handle, c.name, c.credential_data,
c.last_used_at, c.created_at, c.updated_at
FROM passkey_credentials c
JOIN passkey_user_handles h ON h.user_id = c.user_id
WHERE c.user_id = $1
ORDER BY c.created_at DESC, c.id DESC
`, userID)
if err != nil {
return nil, fmt.Errorf("list passkey credentials: %w", err)
}
defer func() { _ = rows.Close() }()
records := make([]service.PasskeyCredentialRecord, 0)
for rows.Next() {
record, scanErr := scanPasskeyCredential(rows)
if scanErr != nil {
return nil, fmt.Errorf("scan passkey credential: %w", scanErr)
}
records = append(records, *record)
}
if err = rows.Err(); err != nil {
return nil, fmt.Errorf("list passkey credentials: %w", err)
}
return records, nil
}
func (r *passkeyRepository) Create(
ctx context.Context,
record *service.PasskeyCredentialRecord,
) (*service.PasskeyCredentialRecord, error) {
if record == nil || len(record.Credential.ID) == 0 {
return nil, fmt.Errorf("passkey credential is required")
}
credentialJSON, err := json.Marshal(record.Credential)
if err != nil {
return nil, fmt.Errorf("encode passkey credential: %w", err)
}
name := strings.TrimSpace(record.Name)
if name == "" {
name = "Passkey"
}
created := &service.PasskeyCredentialRecord{
UserID: record.UserID,
UserHandle: append([]byte(nil), record.UserHandle...),
Name: name,
Credential: record.Credential,
}
err = r.db.QueryRowContext(ctx, `
INSERT INTO passkey_credentials
(user_id, credential_id, name, credential_data)
VALUES ($1, $2, $3, $4::jsonb)
RETURNING id, name, created_at, updated_at
`, record.UserID, record.Credential.ID, name, string(credentialJSON)).
Scan(&created.ID, &created.Name, &created.CreatedAt, &created.UpdatedAt)
if err != nil {
var pqErr *pq.Error
if errors.As(err, &pqErr) && pqErr.Code == "23505" {
return nil, service.ErrPasskeyExists
}
return nil, fmt.Errorf("create passkey credential: %w", err)
}
return created, nil
}
func (r *passkeyRepository) UpdateCredential(
ctx context.Context,
userID int64,
credential *webauthn.Credential,
usedAt time.Time,
) error {
if credential == nil || len(credential.ID) == 0 {
return fmt.Errorf("passkey credential is required")
}
credentialJSON, err := json.Marshal(credential)
if err != nil {
return fmt.Errorf("encode passkey credential: %w", err)
}
result, err := r.db.ExecContext(ctx, `
UPDATE passkey_credentials
SET credential_data = $3::jsonb, last_used_at = $4, updated_at = NOW()
WHERE user_id = $1 AND credential_id = $2
`, userID, credential.ID, string(credentialJSON), usedAt.UTC())
if err != nil {
return fmt.Errorf("update passkey credential: %w", err)
}
return requirePasskeyAffected(result)
}
func (r *passkeyRepository) Rename(
ctx context.Context,
userID, credentialID int64,
name string,
) error {
result, err := r.db.ExecContext(ctx, `
UPDATE passkey_credentials
SET name = $3, updated_at = NOW()
WHERE user_id = $1 AND id = $2
`, userID, credentialID, name)
if err != nil {
return fmt.Errorf("rename passkey credential: %w", err)
}
return requirePasskeyAffected(result)
}
func (r *passkeyRepository) Delete(
ctx context.Context,
userID, credentialID int64,
) error {
result, err := r.db.ExecContext(ctx, `
DELETE FROM passkey_credentials
WHERE user_id = $1 AND id = $2
`, userID, credentialID)
if err != nil {
return fmt.Errorf("delete passkey credential: %w", err)
}
return requirePasskeyAffected(result)
}
type passkeyScanner interface {
Scan(dest ...any) error
}
func scanPasskeyCredential(scanner passkeyScanner) (*service.PasskeyCredentialRecord, error) {
var (
record service.PasskeyCredentialRecord
credentialJSON []byte
)
if err := scanner.Scan(
&record.ID,
&record.UserID,
&record.UserHandle,
&record.Name,
&credentialJSON,
&record.LastUsedAt,
&record.CreatedAt,
&record.UpdatedAt,
); err != nil {
return nil, err
}
if err := json.Unmarshal(credentialJSON, &record.Credential); err != nil {
return nil, fmt.Errorf("decode passkey credential: %w", err)
}
return &record, nil
}
func requirePasskeyAffected(result sql.Result) error {
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected == 0 {
return service.ErrPasskeyNotFound
}
return nil
}
@@ -0,0 +1,69 @@
package repository
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/redis/go-redis/v9"
)
const passkeySessionPrefix = "passkey:session:"
type passkeySessionStore struct {
redis *redis.Client
}
func NewPasskeySessionStore(redisClient *redis.Client) service.PasskeySessionStore {
return &passkeySessionStore{redis: redisClient}
}
func (s *passkeySessionStore) Store(
ctx context.Context,
session *service.PasskeySession,
ttl time.Duration,
) (string, error) {
if session == nil || ttl <= 0 {
return "", fmt.Errorf("invalid passkey session")
}
random := make([]byte, 32)
if _, err := rand.Read(random); err != nil {
return "", fmt.Errorf("generate passkey session token: %w", err)
}
token := base64.RawURLEncoding.EncodeToString(random)
payload, err := json.Marshal(session)
if err != nil {
return "", fmt.Errorf("encode passkey session: %w", err)
}
if err = s.redis.Set(ctx, passkeySessionPrefix+token, payload, ttl).Err(); err != nil {
return "", fmt.Errorf("store passkey session: %w", err)
}
return token, nil
}
func (s *passkeySessionStore) Consume(
ctx context.Context,
token string,
) (*service.PasskeySession, error) {
token = strings.TrimSpace(token)
if token == "" || len(token) > 128 {
return nil, service.ErrPasskeySession
}
payload, err := s.redis.GetDel(ctx, passkeySessionPrefix+token).Bytes()
if err == redis.Nil {
return nil, service.ErrPasskeySession
}
if err != nil {
return nil, fmt.Errorf("consume passkey session: %w", err)
}
var session service.PasskeySession
if err = json.Unmarshal(payload, &session); err != nil {
return nil, service.ErrPasskeySession
}
return &session, nil
}
@@ -87,13 +87,18 @@ func (r *promoCodeRepository) GetByCodeForUpdate(ctx context.Context, code strin
return promoCodeEntityToService(m), nil
}
// Update 写入管理员可编辑的字段。
//
// 这里刻意不写 used_count:它由兑换路径的 IncrementUsedCount 原子递增,
// 而 used_count >= max_uses 正是"优惠码用完了"的判定依据。若管理员编辑
// (改有效期、改额度……)时按快照把 used_count 回写,并发的兑换计数就会被抹掉,
// 兑换次数统计随之失真。PromoService.Update 也从不修改该字段。
func (r *promoCodeRepository) Update(ctx context.Context, code *service.PromoCode) error {
client := clientFromContext(ctx, r.client)
builder := client.PromoCode.UpdateOneID(code.ID).
SetCode(code.Code).
SetBonusAmount(code.BonusAmount).
SetMaxUses(code.MaxUses).
SetUsedCount(code.UsedCount).
SetStatus(code.Status).
SetNotes(code.Notes)
@@ -482,7 +482,8 @@ func (s *UserProfileIdentityRepoSuite) TestWithUserProfileIdentityTx_AllowsAvata
if err != nil {
return err
}
return s.repo.Update(txCtx, model)
// 只改头像时用户行没有任何列需要写,掩码为空——与 UserService.updateProfile 一致。
return s.repo.Update(txCtx, model, service.UserUpdateFields{})
})
s.Require().NoError(err)
+167 -36
View File
@@ -205,10 +205,14 @@ func (r *userRepository) GetByEmail(ctx context.Context, email string) (*service
return out, nil
}
func (r *userRepository) Update(ctx context.Context, userIn *service.User) error {
func (r *userRepository) Update(ctx context.Context, userIn *service.User, fields service.UserUpdateFields) error {
if userIn == nil {
return nil
}
// 空掩码代表调用方不改任何列,直接返回,避免产生一次无意义的整行写。
if fields.IsEmpty() {
return nil
}
// 使用 ent 事务包裹用户更新与 allowed_groups 同步,避免跨层事务不一致。
tx, err := r.client.Tx(ctx)
@@ -231,19 +235,23 @@ func (r *userRepository) Update(ctx context.Context, userIn *service.User) error
}
}
releaseEmailLock, err := lockRepositoryScopedKeys(
txCtx,
txClient,
txAwareSQLExecutor(txCtx, r.sql, r.client),
normalizedEmailUniquenessLockKey(userIn.Email),
)
if err != nil {
return err
}
defer releaseEmailLock()
// 邮箱唯一性锁与查重只在本次确实要改邮箱时才做:不改邮箱的更新既不需要
// 串行化,也不该因为快照里的旧邮箱已被他人占用而报 ErrEmailExists。
if fields.Email {
releaseEmailLock, err := lockRepositoryScopedKeys(
txCtx,
txClient,
txAwareSQLExecutor(txCtx, r.sql, r.client),
normalizedEmailUniquenessLockKey(userIn.Email),
)
if err != nil {
return err
}
defer releaseEmailLock()
if err := ensureNormalizedEmailAvailableWithClient(txCtx, txClient, userIn.ID, userIn.Email); err != nil {
return err
if err := ensureNormalizedEmailAvailableWithClient(txCtx, txClient, userIn.ID, userIn.Email); err != nil {
return err
}
}
existing, err := clientFromContext(txCtx, txClient).User.Get(txCtx, userIn.ID)
@@ -252,41 +260,64 @@ func (r *userRepository) Update(ctx context.Context, userIn *service.User) error
}
oldEmail := existing.Email
updateOp := txClient.User.UpdateOneID(userIn.ID).
SetEmail(userIn.Email).
SetUsername(userIn.Username).
SetNotes(userIn.Notes).
SetPasswordHash(userIn.PasswordHash).
SetRole(userIn.Role).
SetBalance(userIn.Balance).
SetConcurrency(userIn.Concurrency).
SetStatus(userIn.Status).
SetBalanceNotifyEnabled(userIn.BalanceNotifyEnabled).
SetBalanceNotifyThresholdType(userIn.BalanceNotifyThresholdType).
SetNillableBalanceNotifyThreshold(userIn.BalanceNotifyThreshold).
SetBalanceNotifyExtraEmails(marshalExtraEmails(userIn.BalanceNotifyExtraEmails)).
SetTotalRecharged(userIn.TotalRecharged).
SetRpmLimit(userIn.RPMLimit)
if userIn.SignupSource != "" {
updateOp := txClient.User.UpdateOneID(userIn.ID)
if fields.Email {
updateOp = updateOp.SetEmail(userIn.Email)
}
if fields.Username {
updateOp = updateOp.SetUsername(userIn.Username)
}
if fields.Notes {
updateOp = updateOp.SetNotes(userIn.Notes)
}
if fields.PasswordHash {
updateOp = updateOp.SetPasswordHash(userIn.PasswordHash)
}
if fields.Role {
updateOp = updateOp.SetRole(userIn.Role)
}
if fields.Concurrency {
updateOp = updateOp.SetConcurrency(userIn.Concurrency)
}
if fields.RPMLimit {
updateOp = updateOp.SetRpmLimit(userIn.RPMLimit)
}
if fields.Status {
updateOp = updateOp.SetStatus(userIn.Status)
}
if fields.BalanceNotifySettings {
updateOp = updateOp.
SetBalanceNotifyEnabled(userIn.BalanceNotifyEnabled).
SetBalanceNotifyThresholdType(userIn.BalanceNotifyThresholdType).
SetNillableBalanceNotifyThreshold(userIn.BalanceNotifyThreshold)
if userIn.BalanceNotifyThreshold == nil {
updateOp = updateOp.ClearBalanceNotifyThreshold()
}
}
if fields.BalanceNotifyExtraEmails {
updateOp = updateOp.SetBalanceNotifyExtraEmails(marshalExtraEmails(userIn.BalanceNotifyExtraEmails))
}
if fields.SignupSource && userIn.SignupSource != "" {
updateOp = updateOp.SetSignupSource(userIn.SignupSource)
}
if userIn.LastLoginAt != nil {
if fields.LastLoginAt && userIn.LastLoginAt != nil {
updateOp = updateOp.SetLastLoginAt(*userIn.LastLoginAt)
}
if userIn.LastActiveAt != nil {
if fields.LastActiveAt && userIn.LastActiveAt != nil {
updateOp = updateOp.SetLastActiveAt(*userIn.LastActiveAt)
}
if userIn.BalanceNotifyThreshold == nil {
updateOp = updateOp.ClearBalanceNotifyThreshold()
}
updated, err := updateOp.Save(txCtx)
if err != nil {
return translatePersistenceError(err, service.ErrUserNotFound, service.ErrEmailExists)
}
if err := r.syncUserAllowedGroupsWithClient(txCtx, txClient, updated.ID, userIn.AllowedGroups); err != nil {
return err
if fields.AllowedGroups {
if err := r.syncUserAllowedGroupsWithClient(txCtx, txClient, updated.ID, userIn.AllowedGroups); err != nil {
return err
}
}
// 始终以库中的邮箱为准补齐 email 身份:未改邮箱时 updated.Email == oldEmail,
// 这里退化为幂等的身份补写,与改邮箱前的行为一致。
if err := replaceEmailAuthIdentityWithClient(txCtx, txClient, updated.ID, oldEmail, updated.Email, "user_repo_update"); err != nil {
return err
}
@@ -828,6 +859,106 @@ func (r *userRepository) DeductBalance(ctx context.Context, id int64, amount flo
return nil
}
// AdjustBalance 原子地把 delta 累加到余额上,结果为负时整条语句不生效。
// 相比"读余额 → 算新值 → 整行写回",这里把读与写压进同一条 UPDATE,
// 并发的计费扣款不会被旧快照覆盖。
func (r *userRepository) AdjustBalance(ctx context.Context, id int64, delta float64) (service.BalanceChange, error) {
const updateSQL = `
UPDATE users
SET balance = balance + $1, updated_at = NOW()
WHERE id = $2 AND deleted_at IS NULL AND balance + $1 >= 0
RETURNING balance - $1, balance
`
change, ok, err := scanBalanceChange(ctx, clientFromContext(ctx, r.client), updateSQL, delta, id)
if err != nil {
return service.BalanceChange{}, err
}
if ok {
return change, nil
}
// 0 行既可能是用户不存在,也可能是余额不足以承受这次扣减,需要区分。
current, err := r.currentBalance(ctx, id)
if err != nil {
return service.BalanceChange{}, err
}
return service.BalanceChange{Old: current, New: current + delta}, service.ErrBalanceNegative
}
// SetBalance 原子地把余额置为 value,并返回变更前后的值。
func (r *userRepository) SetBalance(ctx context.Context, id int64, value float64) (service.BalanceChange, error) {
if value < 0 {
// 连同当前余额一起返回,便于上层给出可读的错误信息。
current, err := r.currentBalance(ctx, id)
if err != nil {
return service.BalanceChange{}, err
}
return service.BalanceChange{Old: current, New: value}, service.ErrBalanceNegative
}
const updateSQL = `
UPDATE users AS u
SET balance = $1, updated_at = NOW()
FROM (SELECT id, balance FROM users WHERE id = $2 AND deleted_at IS NULL) AS prev
WHERE u.id = prev.id AND u.deleted_at IS NULL
RETURNING prev.balance, u.balance
`
change, ok, err := scanBalanceChange(ctx, clientFromContext(ctx, r.client), updateSQL, value, id)
if err != nil {
return service.BalanceChange{}, err
}
if !ok {
return service.BalanceChange{}, service.ErrUserNotFound
}
return change, nil
}
// currentBalance 读取用户当前余额,用户不存在时返回 ErrUserNotFound。
func (r *userRepository) currentBalance(ctx context.Context, id int64) (balance float64, err error) {
rows, err := clientFromContext(ctx, r.client).QueryContext(ctx,
`SELECT balance FROM users WHERE id = $1 AND deleted_at IS NULL`, id)
if err != nil {
return 0, err
}
defer func() {
if closeErr := rows.Close(); closeErr != nil && err == nil {
err = closeErr
}
}()
if !rows.Next() {
if rowsErr := rows.Err(); rowsErr != nil {
return 0, rowsErr
}
return 0, service.ErrUserNotFound
}
if err := rows.Scan(&balance); err != nil {
return 0, err
}
return balance, rows.Err()
}
// scanBalanceChange 执行一条 RETURNING 旧余额、新余额的语句。ok 为 false 表示语句未命中任何行。
func scanBalanceChange(ctx context.Context, client *dbent.Client, query string, args ...any) (change service.BalanceChange, ok bool, err error) {
rows, err := client.QueryContext(ctx, query, args...)
if err != nil {
return service.BalanceChange{}, false, err
}
defer func() {
if closeErr := rows.Close(); closeErr != nil && err == nil {
err = closeErr
}
}()
if !rows.Next() {
if rowsErr := rows.Err(); rowsErr != nil {
return service.BalanceChange{}, false, rowsErr
}
return service.BalanceChange{}, false, nil
}
if err := rows.Scan(&change.Old, &change.New); err != nil {
return service.BalanceChange{}, false, err
}
return change, true, rows.Err()
}
func (r *userRepository) UpdateConcurrency(ctx context.Context, id int64, amount int) error {
client := clientFromContext(ctx, r.client)
n, err := client.User.Update().Where(dbuser.IDEQ(id)).AddConcurrency(amount).Save(ctx)
@@ -60,7 +60,7 @@ func (s *UserRepoSuite) TestUpdate_ReplacesEmailAuthIdentityWhenEmailChanges() {
})
user.Email = "after-update@example.com"
s.Require().NoError(s.repo.Update(s.ctx, user))
s.Require().NoError(s.repo.Update(s.ctx, user, service.UserUpdateFields{Email: true}))
newIdentity, err := s.client.AuthIdentity.Query().
Where(
@@ -118,7 +118,7 @@ func TestUserRepositoryUpdateRejectsNormalizedEmailDuplicate(t *testing.T) {
require.NoError(t, repo.Create(ctx, second))
second.Email = " existing@example.com "
err := repo.Update(ctx, second)
err := repo.Update(ctx, second, service.UserUpdateFields{Email: true})
require.ErrorIs(t, err, service.ErrEmailExists)
}
@@ -154,7 +154,7 @@ func (s *UserRepoSuite) TestUpdate() {
got, err := s.repo.GetByID(s.ctx, user.ID)
s.Require().NoError(err)
got.Username = "updated"
s.Require().NoError(s.repo.Update(s.ctx, got), "Update")
s.Require().NoError(s.repo.Update(s.ctx, got, service.UserUpdateFields{Username: true}), "Update")
updated, err := s.repo.GetByID(s.ctx, user.ID)
s.Require().NoError(err, "GetByID after update")
@@ -232,7 +232,7 @@ func (s *UserRepoSuite) TestUpdateIgnoresNoRowsFromConflictingEmailIdentityUpser
got, err := s.repo.GetByID(s.ctx, user.ID)
s.Require().NoError(err)
got.Username = "updated"
s.Require().NoError(s.repo.Update(s.ctx, got), "Update should tolerate ON CONFLICT DO NOTHING returning no rows")
s.Require().NoError(s.repo.Update(s.ctx, got, service.UserUpdateFields{Username: true}), "Update should tolerate ON CONFLICT DO NOTHING returning no rows")
updated, err := s.repo.GetByID(s.ctx, user.ID)
s.Require().NoError(err)
@@ -658,7 +658,7 @@ func (s *UserRepoSuite) TestCRUD_And_Filters_And_AtomicUpdates() {
s.Require().Equal(user2.ID, gotByEmail.ID, "GetByEmail ID mismatch")
got.Username = "Alice2"
s.Require().NoError(s.repo.Update(s.ctx, got), "Update")
s.Require().NoError(s.repo.Update(s.ctx, got, service.UserUpdateFields{Username: true}), "Update")
got2, err := s.repo.GetByID(s.ctx, user1.ID)
s.Require().NoError(err, "GetByID after update")
s.Require().Equal("Alice2", got2.Username, "Update did not persist")
@@ -0,0 +1,191 @@
//go:build integration
package repository
import (
"github.com/Wei-Shaw/sub2api/internal/service"
)
// 这一组用例覆盖用户行上的 lost update:调用方手里的快照可能早于并发发生的
// 原子写入(扣费、状态变更、限额调整、分组授予)。Update 只写显式声明的列,
// 未声明的列一律保持库中当前值,因此陈旧快照不会回滚这些并发结果。
func (s *UserRepoSuite) TestUpdate_DoesNotRevertConcurrentBalanceDeduction() {
user := s.mustCreateUser(&service.User{
Email: "lost-update-balance@example.com",
Username: "before",
Balance: 0.30,
})
// 调用方在扣费之前读到的旧快照(余额还是 0.30)。
stale, err := s.repo.GetByID(s.ctx, user.ID)
s.Require().NoError(err, "GetByID")
s.Require().InDelta(0.30, stale.Balance, 1e-9)
// 与之并发:计费按原子方式扣费。
s.Require().NoError(s.repo.DeductBalance(s.ctx, user.ID, 0.25), "DeductBalance")
// 基于旧快照的资料更新这时才落库。
stale.Username = "after"
s.Require().NoError(
s.repo.Update(s.ctx, stale, service.UserUpdateFields{Username: true}),
"Update",
)
got, err := s.repo.GetByID(s.ctx, user.ID)
s.Require().NoError(err, "GetByID after update")
s.Require().Equal("after", got.Username, "declared column must still be written")
s.Require().InDelta(0.05, got.Balance, 1e-9, "balance must not be reverted by a stale profile save")
}
// 同理,风控自动封禁把 status 置为 disabled 后,
// 基于旧快照的资料更新不得把 status 刷回 active。
func (s *UserRepoSuite) TestUpdate_DoesNotRevertConcurrentBan() {
user := s.mustCreateUser(&service.User{
Email: "lost-update-ban@example.com",
Username: "before",
Status: service.StatusActive,
})
stale, err := s.repo.GetByID(s.ctx, user.ID)
s.Require().NoError(err, "GetByID")
s.Require().Equal(service.StatusActive, stale.Status)
banned, err := s.repo.GetByID(s.ctx, user.ID)
s.Require().NoError(err, "GetByID for ban")
banned.Status = service.StatusDisabled
s.Require().NoError(
s.repo.Update(s.ctx, banned, service.UserUpdateFields{Status: true}),
"ban",
)
stale.Username = "after"
s.Require().NoError(
s.repo.Update(s.ctx, stale, service.UserUpdateFields{Username: true}),
"stale profile save",
)
got, err := s.repo.GetByID(s.ctx, user.ID)
s.Require().NoError(err, "GetByID after update")
s.Require().Equal("after", got.Username)
s.Require().Equal(service.StatusDisabled, got.Status, "ban must survive a stale profile save")
}
// 未声明的列不写,也意味着并发的限额调整不会被资料保存回滚。
func (s *UserRepoSuite) TestUpdate_DoesNotRevertConcurrentLimitChanges() {
user := s.mustCreateUser(&service.User{
Email: "lost-update-limits@example.com",
Username: "before",
Concurrency: 3,
RPMLimit: 30,
})
stale, err := s.repo.GetByID(s.ctx, user.ID)
s.Require().NoError(err, "GetByID")
concurrency, rpmLimit := 9, 90
affected, err := s.repo.BatchUpdateLimits(s.ctx, []int64{user.ID}, &concurrency, &rpmLimit)
s.Require().NoError(err, "BatchUpdateLimits")
s.Require().Equal(1, affected)
stale.Username = "after"
s.Require().NoError(
s.repo.Update(s.ctx, stale, service.UserUpdateFields{Username: true}),
"stale profile save",
)
got, err := s.repo.GetByID(s.ctx, user.ID)
s.Require().NoError(err, "GetByID after update")
s.Require().Equal(9, got.Concurrency, "concurrency must not be reverted")
s.Require().Equal(90, got.RPMLimit, "rpm limit must not be reverted")
}
// AllowedGroups 只在显式声明时才同步,否则并发授予的分组权限会被旧快照删掉。
func (s *UserRepoSuite) TestUpdate_DoesNotRevertConcurrentAllowedGroupGrant() {
group := s.mustCreateGroup("lost-update-group")
user := s.mustCreateUser(&service.User{
Email: "lost-update-groups@example.com",
Username: "before",
})
stale, err := s.repo.GetByID(s.ctx, user.ID)
s.Require().NoError(err, "GetByID")
s.Require().Empty(stale.AllowedGroups)
s.Require().NoError(s.repo.AddGroupToAllowedGroups(s.ctx, user.ID, group.ID), "AddGroupToAllowedGroups")
stale.Username = "after"
s.Require().NoError(
s.repo.Update(s.ctx, stale, service.UserUpdateFields{Username: true}),
"stale profile save",
)
got, err := s.repo.GetByID(s.ctx, user.ID)
s.Require().NoError(err, "GetByID after update")
s.Require().Equal([]int64{group.ID}, got.AllowedGroups, "granted group must not be reverted")
}
func (s *UserRepoSuite) TestAdjustBalance_AppliesDeltaAndReportsChange() {
user := s.mustCreateUser(&service.User{Email: "adjust-balance@example.com", Balance: 10})
change, err := s.repo.AdjustBalance(s.ctx, user.ID, 5)
s.Require().NoError(err, "AdjustBalance add")
s.Require().InDelta(10, change.Old, 1e-9)
s.Require().InDelta(15, change.New, 1e-9)
change, err = s.repo.AdjustBalance(s.ctx, user.ID, -5)
s.Require().NoError(err, "AdjustBalance subtract")
s.Require().InDelta(15, change.Old, 1e-9)
s.Require().InDelta(10, change.New, 1e-9)
got, err := s.repo.GetByID(s.ctx, user.ID)
s.Require().NoError(err, "GetByID")
s.Require().InDelta(10, got.Balance, 1e-9)
}
func (s *UserRepoSuite) TestAdjustBalance_RefusesNegativeResult() {
user := s.mustCreateUser(&service.User{Email: "adjust-balance-negative@example.com", Balance: 3})
change, err := s.repo.AdjustBalance(s.ctx, user.ID, -4)
s.Require().ErrorIs(err, service.ErrBalanceNegative)
s.Require().InDelta(3, change.Old, 1e-9, "error must report the real current balance")
s.Require().InDelta(-1, change.New, 1e-9)
got, err := s.repo.GetByID(s.ctx, user.ID)
s.Require().NoError(err, "GetByID")
s.Require().InDelta(3, got.Balance, 1e-9, "refused adjustment must not write")
}
func (s *UserRepoSuite) TestAdjustBalance_UserNotFound() {
_, err := s.repo.AdjustBalance(s.ctx, 99999999, 1)
s.Require().ErrorIs(err, service.ErrUserNotFound)
}
func (s *UserRepoSuite) TestSetBalance_ReplacesValueAndReportsPrevious() {
user := s.mustCreateUser(&service.User{Email: "set-balance@example.com", Balance: 7})
change, err := s.repo.SetBalance(s.ctx, user.ID, 2)
s.Require().NoError(err, "SetBalance")
s.Require().InDelta(7, change.Old, 1e-9)
s.Require().InDelta(2, change.New, 1e-9)
got, err := s.repo.GetByID(s.ctx, user.ID)
s.Require().NoError(err, "GetByID")
s.Require().InDelta(2, got.Balance, 1e-9)
}
func (s *UserRepoSuite) TestSetBalance_RejectsNegativeValue() {
user := s.mustCreateUser(&service.User{Email: "set-balance-negative@example.com", Balance: 7})
_, err := s.repo.SetBalance(s.ctx, user.ID, -1)
s.Require().ErrorIs(err, service.ErrBalanceNegative)
got, err := s.repo.GetByID(s.ctx, user.ID)
s.Require().NoError(err, "GetByID")
s.Require().InDelta(7, got.Balance, 1e-9)
}
func (s *UserRepoSuite) TestSetBalance_UserNotFound() {
_, err := s.repo.SetBalance(s.ctx, 99999999, 1)
s.Require().ErrorIs(err, service.ErrUserNotFound)
}
@@ -84,7 +84,7 @@ func (s *UserRepoSuite) TestUpdate_PersistsSignupSourceAndActivityTimestamps() {
created.LastLoginAt = &lastLoginAt
created.LastActiveAt = &lastActiveAt
s.Require().NoError(s.repo.Update(s.ctx, created))
s.Require().NoError(s.repo.Update(s.ctx, created, service.UserUpdateFields{SignupSource: true, LastLoginAt: true, LastActiveAt: true}))
got, err := s.repo.GetByID(s.ctx, created.ID)
s.Require().NoError(err)
+2
View File
@@ -88,6 +88,8 @@ var ProviderSet = wire.NewSet(
NewSettingRepository,
NewOpsRepository,
NewAuditLogRepository,
NewPasskeyRepository,
NewPasskeySessionStore,
NewUserSubscriptionRepository,
NewUserAttributeDefinitionRepository,
NewUserAttributeValueRepository,
@@ -90,6 +90,11 @@ type ActiveEndpoint struct {
TimeoutMS int
InputLimit int
Enabled bool
// TokenInvalid marks an endpoint whose persisted token ciphertext cannot be
// decrypted with the current encryption key (key changed or auto-generated
// on restart). The endpoint is kept visible for admins but excluded from
// runtime use until the token is re-entered or cleared (issue #4887).
TokenInvalid bool
}
type ActiveConfig struct {
@@ -365,7 +370,23 @@ func (cfg ActiveConfig) EnabledEndpoints() []ActiveEndpoint {
return result
}
func PublicFromStorage(cfg storageConfig, riskControlEnabled bool) PublicConfig {
// InvalidTokenEndpointIDs lists endpoints whose stored token could not be
// decrypted with the current encryption key.
func (cfg ActiveConfig) InvalidTokenEndpointIDs() []string {
ids := make([]string, 0)
for _, ep := range cfg.Endpoints {
if ep.TokenInvalid {
ids = append(ids, ep.ID)
}
}
return ids
}
func PublicFromStorage(cfg storageConfig, riskControlEnabled bool, invalidTokenEndpointIDs []string) PublicConfig {
invalid := make(map[string]struct{}, len(invalidTokenEndpointIDs))
for _, id := range invalidTokenEndpointIDs {
invalid[id] = struct{}{}
}
scanners := append([]string{}, cfg.Scanners...)
groupIDs := append([]int64{}, cfg.GroupIDs...)
endpoints := make([]PublicEndpoint, 0, len(cfg.Endpoints))
@@ -374,6 +395,9 @@ func PublicFromStorage(cfg storageConfig, riskControlEnabled bool) PublicConfig
status := "missing"
if hasToken {
status = "configured"
if _, ok := invalid[ep.ID]; ok {
status = "invalid"
}
}
endpoints = append(endpoints, PublicEndpoint{
ID: ep.ID, Name: ep.Name, Protocol: ep.Protocol, BaseURL: ep.BaseURL,
@@ -402,19 +426,27 @@ func ActiveFromStorage(cfg storageConfig, riskControlEnabled bool, encryptor Sec
}
for _, ep := range cfg.Endpoints {
token := ""
tokenInvalid := false
if ep.TokenCiphertext != "" {
if encryptor == nil {
return ActiveConfig{}, fmt.Errorf("prompt audit secret encryptor unavailable")
}
plain, err := encryptor.Decrypt(ep.TokenCiphertext)
if err != nil {
return ActiveConfig{}, fmt.Errorf("decrypt prompt audit endpoint token %q: %w", ep.ID, err)
// An undecryptable token (encryption key changed or regenerated)
// must not take the whole config down: admins would otherwise be
// locked out of the real config version and unable to recover
// (issue #4887). Keep the ciphertext persisted, but exclude the
// endpoint from runtime use until the token is re-entered.
tokenInvalid = true
} else {
token = plain
}
token = plain
}
active.Endpoints = append(active.Endpoints, ActiveEndpoint{
ID: ep.ID, Name: ep.Name, Protocol: ep.Protocol, BaseURL: ep.BaseURL, Model: ep.Model,
Token: token, TimeoutMS: ep.TimeoutMS, InputLimit: ep.InputLimit, Enabled: ep.Enabled,
Token: token, TimeoutMS: ep.TimeoutMS, InputLimit: ep.InputLimit,
Enabled: ep.Enabled && !tokenInvalid, TokenInvalid: tokenInvalid,
})
}
return active, nil
@@ -148,8 +148,8 @@ func TestPromptAuditConfigCASSecretRoundTripInvalidationAndTTL(t *testing.T) {
t.Cleanup(func() { require.NoError(t, redisClient.Close()) })
require.NoError(t, redisClient.Ping(context.Background()).Err())
managerOne := NewConfigManager(db, settingRepo, redisClient, encryptor)
managerTwo := NewConfigManager(db, settingRepo, redisClient, encryptor)
managerOne := NewConfigManager(db, settingRepo, redisClient, encryptor, testTotpKeyConfig())
managerTwo := NewConfigManager(db, settingRepo, redisClient, encryptor, testTotpKeyConfig())
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
require.NoError(t, managerOne.Start(ctx))
@@ -220,7 +220,7 @@ func TestPromptAuditConfigCASSecretRoundTripInvalidationAndTTL(t *testing.T) {
// A manager without Redis subscriptions must still converge through the
// bounded five-second refresh loop.
ttlManager := NewConfigManager(db, settingRepo, nil, encryptor)
ttlManager := NewConfigManager(db, settingRepo, nil, encryptor, testTotpKeyConfig())
require.NoError(t, ttlManager.Start(ctx))
t.Cleanup(func() { require.NoError(t, ttlManager.Shutdown(context.Background())) })
waitForConfigVersion(t, ttlManager, 3, time.Second)
@@ -233,7 +233,7 @@ func TestPromptAuditConfigCASSecretRoundTripInvalidationAndTTL(t *testing.T) {
// successfully committed PostgreSQL config.
deadRedis := redis.NewClient(&redis.Options{Addr: "127.0.0.1:1", MaxRetries: 0, DialTimeout: 30 * time.Millisecond, ReadTimeout: 30 * time.Millisecond, WriteTimeout: 30 * time.Millisecond})
t.Cleanup(func() { _ = deadRedis.Close() })
degraded := NewConfigManager(db, settingRepo, deadRedis, encryptor)
degraded := NewConfigManager(db, settingRepo, deadRedis, encryptor, testTotpKeyConfig())
require.NoError(t, degraded.Reload(context.Background()))
degradedSaved, err := degraded.Save(context.Background(), promptAuditUpdateRequest(4, 6, ""), 401)
require.NoError(t, err)
@@ -12,6 +12,7 @@ import (
"sync/atomic"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/redis/go-redis/v9"
@@ -29,6 +30,10 @@ type ConfigManager struct {
redis *redis.Client
encryptor SecretEncryptor
clock Clock
// encryptionKeyConfigured mirrors cfg.Totp.EncryptionKeyConfigured. With an
// auto-generated (per-boot) key, newly saved endpoint tokens would become
// undecryptable after the next restart, so Save rejects them (issue #4887).
encryptionKeyConfigured bool
snapshot atomic.Pointer[activeConfigSnapshot]
expected atomic.Int64
@@ -53,8 +58,11 @@ type ConfigManager struct {
wg sync.WaitGroup
}
func NewConfigManager(db *sql.DB, settings service.SettingRepository, redisClient *redis.Client, encryptor service.SecretEncryptor) *ConfigManager {
return &ConfigManager{db: db, settings: settings, redis: redisClient, encryptor: encryptor, clock: realClock{}}
func NewConfigManager(db *sql.DB, settings service.SettingRepository, redisClient *redis.Client, encryptor service.SecretEncryptor, cfg *config.Config) *ConfigManager {
return &ConfigManager{
db: db, settings: settings, redis: redisClient, encryptor: encryptor, clock: realClock{},
encryptionKeyConfigured: cfg != nil && cfg.Totp.EncryptionKeyConfigured,
}
}
func (m *ConfigManager) Start(ctx context.Context) error {
@@ -125,15 +133,45 @@ func (m *ConfigManager) Reload(ctx context.Context) error {
return err
}
now := m.clock.Now()
previous := m.snapshot.Load()
m.snapshot.Store(&activeConfigSnapshot{storage: cloneStorageConfig(storage), active: cloneActiveConfig(active), loadedAt: now})
m.configUntrusted.Store(false)
m.clearLoadError()
m.logInvalidTokenEndpoints(previous, active)
LogInfo(EventConfigLoaded, map[string]any{
"config_version": storage.ConfigVersion, "status": "loaded",
})
return nil
}
// logInvalidTokenEndpoints warns once per change (not on every 5s refresh)
// when stored endpoint tokens cannot be decrypted with the current key.
func (m *ConfigManager) logInvalidTokenEndpoints(previous *activeConfigSnapshot, active ActiveConfig) {
invalid := active.InvalidTokenEndpointIDs()
if len(invalid) == 0 {
return
}
if previous != nil {
prior := previous.active.InvalidTokenEndpointIDs()
if len(prior) == len(invalid) {
same := true
for i := range invalid {
if prior[i] != invalid[i] {
same = false
break
}
}
if same && previous.active.ConfigVersion == active.ConfigVersion {
return
}
}
}
LogWarn(EventConfigTokenInvalid, map[string]any{
"config_version": active.ConfigVersion, "status": "degraded",
"error_code": "endpoint_token_undecryptable", "guard_endpoint_id": strings.Join(invalid, ","),
})
}
func (m *ConfigManager) Active() (ActiveConfig, bool) {
if m == nil {
return ActiveConfig{}, false
@@ -202,7 +240,7 @@ func (m *ConfigManager) Public() (PublicConfig, error) {
if snapshot == nil {
return PublicConfig{}, infraerrors.ServiceUnavailable(ErrorCodeConfigUnavailable, "提示词审计配置暂不可用")
}
return PublicFromStorage(cloneStorageConfig(snapshot.storage), snapshot.active.RiskControlEnabled), nil
return PublicFromStorage(cloneStorageConfig(snapshot.storage), snapshot.active.RiskControlEnabled, snapshot.active.InvalidTokenEndpointIDs()), nil
}
func (m *ConfigManager) Save(ctx context.Context, req UpdateConfigRequest, actorID int64) (PublicConfig, error) {
@@ -268,11 +306,13 @@ func (m *ConfigManager) Save(ctx context.Context, req UpdateConfigRequest, actor
}
m.expected.Store(next.ConfigVersion)
m.expectedBlocking.Store(active.RiskControlEnabled && next.Enabled && next.BlockingEnabled)
previous := m.snapshot.Load()
m.snapshot.Store(&activeConfigSnapshot{storage: cloneStorageConfig(next), active: cloneActiveConfig(active), loadedAt: m.clock.Now()})
// A successful admin save installs a trustworthy snapshot; clear any prior
// fail-closed degradation so disabling audit actually takes effect.
m.configUntrusted.Store(false)
m.clearLoadError()
m.logInvalidTokenEndpoints(previous, active)
LogInfo(EventConfigUpdated, map[string]any{
"config_version": next.ConfigVersion, "status": "updated",
})
@@ -283,7 +323,7 @@ func (m *ConfigManager) Save(ctx context.Context, req UpdateConfigRequest, actor
})
}
}
return PublicFromStorage(next, active.RiskControlEnabled), nil
return PublicFromStorage(next, active.RiskControlEnabled, active.InvalidTokenEndpointIDs()), nil
}
func (m *ConfigManager) buildNextStorage(current storageConfig, req UpdateConfigRequest, actorID int64) (storageConfig, error) {
@@ -317,6 +357,10 @@ func (m *ConfigManager) buildNextStorage(current storageConfig, req UpdateConfig
case endpoint.ClearToken:
stored.TokenCiphertext = ""
case strings.TrimSpace(endpoint.Token) != "":
if !m.encryptionKeyConfigured {
return storageConfig{}, infraerrors.BadRequest(ErrorCodeEncryptionKeyRequired,
"未配置固定加密密钥,审计节点 Token 将在服务重启后失效。请先设置 TOTP_ENCRYPTION_KEY 环境变量(64 位十六进制)并重启服务")
}
ciphertext, err := m.encryptor.Encrypt(strings.TrimSpace(endpoint.Token))
if err != nil {
return storageConfig{}, fmt.Errorf("encrypt prompt audit endpoint token: %w", err)
@@ -4,8 +4,10 @@ import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/stretchr/testify/require"
)
@@ -13,7 +15,18 @@ import (
type prefixEncryptor struct{}
func (prefixEncryptor) Encrypt(value string) (string, error) { return "enc:" + value, nil }
func (prefixEncryptor) Decrypt(value string) (string, error) { return value[4:], nil }
func (prefixEncryptor) Decrypt(value string) (string, error) {
if !strings.HasPrefix(value, "enc:") {
return "", errors.New("cipher: message authentication failed")
}
return value[4:], nil
}
// testTotpKeyConfig mirrors a deployment with a fixed TOTP_ENCRYPTION_KEY so
// unit tests may persist endpoint tokens.
func testTotpKeyConfig() *config.Config {
return &config.Config{Totp: config.TotpConfig{EncryptionKeyConfigured: true}}
}
func TestDefaultConfigIsOff(t *testing.T) {
storage, err := ParseStorageConfig("")
@@ -23,7 +36,7 @@ func TestDefaultConfigIsOff(t *testing.T) {
require.NoError(t, err)
require.Equal(t, ModeOff, active.EffectiveMode())
require.Equal(t, AllScannerIDs, storage.Scanners)
publicJSON, err := json.Marshal(PublicFromStorage(storage, true))
publicJSON, err := json.Marshal(PublicFromStorage(storage, true, nil))
require.NoError(t, err)
require.Contains(t, string(publicJSON), `"group_ids":[]`)
require.Contains(t, string(publicJSON), `"endpoints":[]`)
@@ -38,7 +51,7 @@ func TestConfigRejectsBlockingWithoutAudit(t *testing.T) {
func TestPublicConfigNeverMarshalsToken(t *testing.T) {
storage := DefaultStorageConfig()
storage.Endpoints = []StorageEndpoint{{ID: "one", Name: "One", Protocol: "openai_compatible", BaseURL: "http://127.0.0.1:8080", Model: DefaultGuardModel, TokenCiphertext: "GUARD_TOKEN_CANARY_SECRET", TimeoutMS: 1000, InputLimit: 1000, Enabled: true}}
public := PublicFromStorage(storage, true)
public := PublicFromStorage(storage, true, nil)
raw, err := json.Marshal(public)
require.NoError(t, err)
require.NotContains(t, string(raw), "GUARD_TOKEN_CANARY_SECRET")
@@ -61,7 +74,7 @@ func TestConfigManagerPublicRequiresSuccessfullyLoadedSnapshot(t *testing.T) {
manager := NewConfigManager(nil, staticSettingRepository{values: map[string]string{
SettingKeyPromptAuditConfig: "",
SettingKeyRiskControl: "false",
}}, nil, prefixEncryptor{})
}}, nil, prefixEncryptor{}, testTotpKeyConfig())
require.NoError(t, manager.Reload(context.Background()))
public, err := manager.Public()
@@ -70,12 +83,14 @@ func TestConfigManagerPublicRequiresSuccessfullyLoadedSnapshot(t *testing.T) {
require.False(t, public.Enabled)
})
t.Run("persisted config activation failure is unavailable", func(t *testing.T) {
t.Run("unparseable persisted config is unavailable", func(t *testing.T) {
const canary = "persisted-token-canary"
manager := NewConfigManager(nil, staticSettingRepository{values: map[string]string{
// Endpoint without id/name fails validation, so no trustworthy
// snapshot can be installed from this raw value.
SettingKeyPromptAuditConfig: `{"enabled":true,"config_version":9,"endpoints":[{"token_ciphertext":"` + canary + `"}]}`,
SettingKeyRiskControl: "true",
}}, nil, prefixEncryptor{})
}}, nil, prefixEncryptor{}, testTotpKeyConfig())
require.Error(t, manager.Reload(context.Background()))
public, err := manager.Public()
@@ -95,7 +110,7 @@ func TestConfigManagerPublicRequiresSuccessfullyLoadedSnapshot(t *testing.T) {
SettingKeyPromptAuditConfig: string(raw),
SettingKeyRiskControl: "false",
}}}
manager := NewConfigManager(nil, repository, nil, prefixEncryptor{})
manager := NewConfigManager(nil, repository, nil, prefixEncryptor{}, testTotpKeyConfig())
require.NoError(t, manager.Reload(context.Background()))
repository.loadErr = errors.New("settings unavailable")
require.Error(t, manager.Reload(context.Background()))
@@ -107,8 +122,66 @@ func TestConfigManagerPublicRequiresSuccessfullyLoadedSnapshot(t *testing.T) {
})
}
// Regression coverage for issue #4887: a persisted config whose endpoint token
// can no longer be decrypted (encryption key changed or auto-generated per
// boot) must stay visible and editable for admins instead of falling back to a
// default v1 config that makes every save fail the CAS version check.
func TestConfigManagerUndecryptableTokenKeepsConfigVisibleAndRecoverable(t *testing.T) {
const canary = "persisted-token-canary"
persisted := `{"enabled":true,"blocking_enabled":false,"config_version":9,"endpoints":[{"id":"g1","name":"Guard","protocol":"openai_compatible","base_url":"http://127.0.0.1:8080","model":"m","token_ciphertext":"` + canary + `","timeout_ms":1000,"input_limit":1000,"enabled":true}]}`
manager := NewConfigManager(nil, staticSettingRepository{values: map[string]string{
SettingKeyPromptAuditConfig: persisted,
SettingKeyRiskControl: "true",
}}, nil, prefixEncryptor{}, testTotpKeyConfig())
require.NoError(t, manager.Reload(context.Background()), "an undecryptable token must not fail the whole config load")
public, err := manager.Public()
require.NoError(t, err)
require.Equal(t, int64(9), public.ConfigVersion, "admins must see the real persisted version so CAS saves can succeed")
require.Len(t, public.Endpoints, 1)
require.True(t, public.Endpoints[0].HasToken)
require.Equal(t, "invalid", public.Endpoints[0].TokenStatus)
raw, err := json.Marshal(public)
require.NoError(t, err)
require.NotContains(t, string(raw), canary)
active, ok := manager.Active()
require.True(t, ok)
require.Len(t, active.Endpoints, 1)
require.False(t, active.Endpoints[0].Enabled, "an endpoint with an undecryptable token must not be used at runtime")
require.True(t, active.Endpoints[0].TokenInvalid)
require.Empty(t, active.Endpoints[0].Token)
require.Empty(t, active.EnabledEndpoints())
require.Equal(t, []string{"g1"}, active.InvalidTokenEndpointIDs())
expected, activeVersion, _, _ := manager.RuntimeState()
require.Equal(t, int64(9), expected)
require.Equal(t, int64(9), activeVersion)
}
func TestConfigManagerUndecryptableTokenStillFailsClosedForBlockingIntent(t *testing.T) {
persisted := `{"enabled":true,"blocking_enabled":true,"config_version":9,"endpoints":[{"id":"g1","name":"Guard","protocol":"openai_compatible","base_url":"http://127.0.0.1:8080","model":"m","token_ciphertext":"undecryptable","timeout_ms":1000,"input_limit":1000,"enabled":true}]}`
manager := NewConfigManager(nil, staticSettingRepository{values: map[string]string{
SettingKeyPromptAuditConfig: persisted,
SettingKeyRiskControl: "true",
}}, nil, prefixEncryptor{}, testTotpKeyConfig())
require.NoError(t, manager.Reload(context.Background()))
require.Equal(t, ModeBlocking, manager.EffectiveMode())
service := &PromptService{config: manager, evaluator: NewGuardEvaluator(NewOpenAICompatibleScanner(), nil, nil)}
decision, err := service.Evaluate(context.Background(), Request{
Protocol: "openai_chat_completions",
Body: []byte(`{"messages":[{"role":"user","content":"hi"}]}`),
})
require.Error(t, err, "blocking intent with no usable endpoint must not let requests pass unaudited")
require.Nil(t, decision)
var guardErr *GuardError
require.ErrorAs(t, err, &guardErr)
require.Equal(t, ErrorCodeUnavailable, guardErr.Code)
}
func TestBuildNextStoragePreserveReplaceAndClearToken(t *testing.T) {
manager := &ConfigManager{encryptor: prefixEncryptor{}}
manager := &ConfigManager{encryptor: prefixEncryptor{}, encryptionKeyConfigured: true}
current := DefaultStorageConfig()
current.Endpoints = []StorageEndpoint{{ID: "one", Name: "One", Protocol: "openai_compatible", BaseURL: "http://127.0.0.1:8080", Model: DefaultGuardModel, TokenCiphertext: "enc:old", TimeoutMS: 1000, InputLimit: 1000}}
base := UpdateConfigRequest{ExpectedConfigVersion: 1, Strategy: "priority", WorkerCount: 1, QueueCapacity: 10, Scanners: []string{"PII"}, AllGroups: true,
@@ -130,6 +203,37 @@ func TestBuildNextStoragePreserveReplaceAndClearToken(t *testing.T) {
require.Empty(t, cleared.Endpoints[0].TokenCiphertext)
}
// Without a fixed encryption key the per-boot auto-generated key would make a
// freshly saved token undecryptable after the next restart (issue #4887), so
// saving a new token must be rejected with an actionable error. Preserving or
// clearing an existing ciphertext stays allowed so admins can still edit or
// disable the feature.
func TestBuildNextStorageRejectsNewTokenWithoutConfiguredEncryptionKey(t *testing.T) {
manager := &ConfigManager{encryptor: prefixEncryptor{}, encryptionKeyConfigured: false}
current := DefaultStorageConfig()
current.Endpoints = []StorageEndpoint{{ID: "one", Name: "One", Protocol: "openai_compatible", BaseURL: "http://127.0.0.1:8080", Model: DefaultGuardModel, TokenCiphertext: "enc:old", TimeoutMS: 1000, InputLimit: 1000}}
base := UpdateConfigRequest{ExpectedConfigVersion: 1, Strategy: "priority", WorkerCount: 1, QueueCapacity: 10, Scanners: []string{"PII"}, AllGroups: true,
Endpoints: []UpdateEndpoint{{ID: "one", Name: "One", Protocol: "openai_compatible", BaseURL: "http://127.0.0.1:8080", TimeoutMS: 1000, InputLimit: 1000}}}
newTokenReq := base
newTokenReq.Endpoints = append([]UpdateEndpoint(nil), base.Endpoints...)
newTokenReq.Endpoints[0].Token = "fresh-token"
_, err := manager.buildNextStorage(current, newTokenReq, 9)
require.Error(t, err)
require.Equal(t, ErrorCodeEncryptionKeyRequired, infraerrors.Reason(err))
preserved, err := manager.buildNextStorage(current, base, 9)
require.NoError(t, err)
require.Equal(t, "enc:old", preserved.Endpoints[0].TokenCiphertext)
clearedReq := base
clearedReq.Endpoints = append([]UpdateEndpoint(nil), base.Endpoints...)
clearedReq.Endpoints[0].ClearToken = true
cleared, err := manager.buildNextStorage(current, clearedReq, 9)
require.NoError(t, err)
require.Empty(t, cleared.Endpoints[0].TokenCiphertext)
}
func TestEffectiveModeTruthTable(t *testing.T) {
tests := []struct {
risk, enabled, blocking bool
@@ -203,7 +307,7 @@ func (r *switchableSettingRepository) GetMultiple(ctx context.Context, keys []st
func TestConfigManagerStartupLoadFailureDoesNotBlockWhenBlockingNotIntended(t *testing.T) {
// Settings unavailable and no prior blocking intent: stay ModeOff so the
// gateway remains usable and admins can still disable/configure Prompt Audit.
manager := NewConfigManager(nil, errorSettingRepository{}, nil, prefixEncryptor{})
manager := NewConfigManager(nil, errorSettingRepository{}, nil, prefixEncryptor{}, testTotpKeyConfig())
err := manager.Start(context.Background())
require.Error(t, err)
require.True(t, manager.configUntrusted.Load())
@@ -222,7 +326,7 @@ func TestConfigManagerStartupLoadFailureDoesNotBlockWhenBlockingNotIntended(t *t
}
func TestConfigManagerStartupLoadFailureFailsClosedWhenBlockingIntended(t *testing.T) {
manager := NewConfigManager(nil, errorSettingRepository{}, nil, prefixEncryptor{})
manager := NewConfigManager(nil, errorSettingRepository{}, nil, prefixEncryptor{}, testTotpKeyConfig())
// Simulate intent observed before a later load failure (e.g. decrypt error).
manager.observeExpectedState(`{"enabled":true,"blocking_enabled":true,"config_version":3}`, true)
manager.markConfigUntrusted()
@@ -161,7 +161,7 @@ func TestPromptAdminConfigRequiresVersionMapsConflictAndNeverEchoesToken(t *test
func TestPromptAdminGetConfigReturnsSecretFreeUnavailableError(t *testing.T) {
const canary = "persisted-config-secret-canary"
repository := &switchableSettingRepository{loadErr: errors.New("failed to load token " + canary)}
manager := NewConfigManager(nil, repository, nil, prefixEncryptor{})
manager := NewConfigManager(nil, repository, nil, prefixEncryptor{}, testTotpKeyConfig())
require.Error(t, manager.Reload(context.Background()))
service := &PromptService{config: manager}
@@ -10,6 +10,7 @@ const (
EventConfigUpdated = "prompt_audit.config_updated"
EventConfigLoaded = "prompt_guard.config_loaded"
EventConfigReloadDegraded = "prompt_guard.config_reload_degraded"
EventConfigTokenInvalid = "prompt_guard.config_token_invalid"
EventProbeStarted = "prompt_audit.endpoint_probe_started"
EventProbeFinished = "prompt_audit.endpoint_probe_finished"
EventProbeFailed = "prompt_audit.endpoint_probe_failed"
@@ -37,7 +38,7 @@ const (
)
var knownLogEvents = map[string]struct{}{
EventConfigUpdated: {}, EventConfigLoaded: {}, EventConfigReloadDegraded: {},
EventConfigUpdated: {}, EventConfigLoaded: {}, EventConfigReloadDegraded: {}, EventConfigTokenInvalid: {},
EventProbeStarted: {}, EventProbeFinished: {}, EventProbeFailed: {},
EventJobEnqueued: {}, EventEnqueueSkipped: {}, EventEnqueueDropped: {},
EventAuditStarted: {}, EventProcessingReclaimed: {}, EventProcessed: {}, EventProcessFailed: {}, EventFindingRecorded: {},
@@ -33,7 +33,7 @@ func TestPromptAuditLogAllowlistAndErrorsDoNotLeakCanarySecrets(t *testing.T) {
beforeUnknown := output.Len()
LogWarn("prompt_audit.typo_event", map[string]any{"status": "failed"})
require.Equal(t, beforeUnknown, output.Len(), "events outside the stable dictionary must not be emitted")
require.Len(t, knownLogEvents, 27)
require.Len(t, knownLogEvents, 28)
_, err := NormalizeBaseURL("https://guard.example.test/path?token=" + canary)
require.Error(t, err)
@@ -86,19 +86,13 @@ func NormalizeCategory(value string) string {
}
func ParseQwen3Guard(content string, enabledScanners []string) (*NormalizedResult, error) {
lines := make([]string, 0, 2)
for _, line := range strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") {
line = strings.TrimSpace(line)
if line != "" {
lines = append(lines, line)
}
}
if len(lines) != 2 {
return nil, &GuardError{Code: ErrorCodeInvalidResponse, Retryable: false}
}
var safety string
var categoryLine string
for _, line := range lines {
for _, line := range strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
lower := strings.ToLower(line)
switch {
case strings.HasPrefix(lower, "safety:"):
@@ -112,7 +106,7 @@ func ParseQwen3Guard(content string, enabledScanners []string) (*NormalizedResul
}
categoryLine = strings.TrimSpace(line[len("categories:"):])
default:
return nil, &GuardError{Code: ErrorCodeInvalidResponse}
// Auxiliary Guard fields, such as Refusal, do not affect audit decisions.
}
}
switch strings.ToLower(safety) {
@@ -22,7 +22,7 @@ func TestParseQwen3GuardStrictAndPolicy(t *testing.T) {
{"unsafe", "Safety: Unsafe\nCategories: Jailbreak", AllScannerIDs, EventCritical, ActionBlock, false},
{"unknown unsafe", "Safety: Unsafe\nCategories: Future Risk", AllScannerIDs, EventCritical, ActionBlock, false},
{"disabled unsafe warns", "Safety: Unsafe\nCategories: Violent", []string{"PII"}, EventFlag, ActionWarn, false},
{"extra explanation", "Safety: Safe\nCategories: None\nThis is safe", AllScannerIDs, "", "", true},
{"extra explanation", "Safety: Safe\nCategories: None\nThis is safe", AllScannerIDs, EventPass, ActionAllow, false},
{"duplicate", "Safety: Safe\nSafety: Safe", AllScannerIDs, "", "", true},
{"duplicate categories", "Safety: Safe\nCategories: None\nCategories: PII", AllScannerIDs, "", "", true},
{"missing categories", "Safety: Safe\n", AllScannerIDs, "", "", true},
@@ -42,6 +42,18 @@ func TestParseQwen3GuardStrictAndPolicy(t *testing.T) {
}
}
func TestParseQwen3GuardIgnoresAuxiliaryResponseFields(t *testing.T) {
result, err := ParseQwen3Guard("Safety: Unsafe\nCategories: Jailbreak\nRefusal: No", AllScannerIDs)
require.NoError(t, err)
require.Equal(t, "Unsafe", result.Safety)
require.Equal(t, []string{"jailbreak"}, result.Categories)
serialized, err := json.Marshal(result)
require.NoError(t, err)
require.NotContains(t, string(serialized), "Refusal")
require.NotContains(t, string(serialized), "No")
}
func TestQwen3GuardOfficialCategoriesAliasesAndUnknownAreStable(t *testing.T) {
official := "Violent, Non-violent Illegal Acts, Sexual Content or Sexual Acts, PII, Suicide & Self-Harm, Unethical Acts, Politically Sensitive Topics, Copyright Violation, Jailbreak"
result, err := ParseQwen3Guard("Safety: Unsafe\nCategories: "+official, AllScannerIDs)
@@ -39,7 +39,7 @@ func TestPromptServiceHasExplicitIdempotentLifecycle(t *testing.T) {
config := NewConfigManager(nil, staticSettingRepository{values: map[string]string{
SettingKeyPromptAuditConfig: "",
SettingKeyRiskControl: "false",
}}, nil, prefixEncryptor{})
}}, nil, prefixEncryptor{}, testTotpKeyConfig())
service := NewPromptService(
config,
NewPostgreSQLRepository(nil),
@@ -12,12 +12,13 @@ const (
ConfigInvalidationChannel = "sub2api:prompt_guard:config:invalidate"
PayloadKeyPrefix = "sub2api:prompt_audit:payload:"
ErrorCodeBlocked = "prompt_guard_blocked"
ErrorCodeUnavailable = "prompt_guard_unavailable"
ErrorCodeInvalidResponse = "prompt_guard_invalid_response"
ErrorCodeConfigConflict = "prompt_audit_config_conflict"
ErrorCodeConfigUnavailable = "prompt_audit_config_unavailable"
ErrorCodeRequiresEnabled = "prompt_guard_requires_audit_enabled"
ErrorCodeBlocked = "prompt_guard_blocked"
ErrorCodeUnavailable = "prompt_guard_unavailable"
ErrorCodeInvalidResponse = "prompt_guard_invalid_response"
ErrorCodeConfigConflict = "prompt_audit_config_conflict"
ErrorCodeConfigUnavailable = "prompt_audit_config_unavailable"
ErrorCodeEncryptionKeyRequired = "prompt_audit_encryption_key_required"
ErrorCodeRequiresEnabled = "prompt_guard_requires_audit_enabled"
DefaultGuardModel = "sileader/qwen3guard:0.6b"
)
+24 -2
View File
@@ -711,6 +711,10 @@ func TestAPIContracts(t *testing.T) {
"frontend_url": "",
"totp_enabled": false,
"totp_encryption_key_configured": false,
"passkey_enabled": false,
"passkey_configured": false,
"passkey_rp_id": "",
"passkey_rp_origins": [],
"session_binding_enabled": false,
"step_up_enabled": false,
"audit_log_retention_days": 180,
@@ -954,6 +958,9 @@ func TestAPIContracts(t *testing.T) {
"channel_monitor_enabled": true,
"channel_monitor_default_interval_seconds": 60,
"available_channels_enabled": false,
"model_plaza_enabled": false,
"model_plaza_require_auth": false,
"model_plaza_description": "",
"risk_control_enabled": false,
"cyber_session_block_enabled": false,
"cyber_session_block_ttl_seconds": 3600,
@@ -1028,6 +1035,10 @@ func TestAPIContracts(t *testing.T) {
"invitation_code_enabled": false,
"totp_enabled": false,
"totp_encryption_key_configured": false,
"passkey_enabled": false,
"passkey_configured": false,
"passkey_rp_id": "",
"passkey_rp_origins": [],
"session_binding_enabled": false,
"step_up_enabled": false,
"audit_log_retention_days": 180,
@@ -1234,6 +1245,9 @@ func TestAPIContracts(t *testing.T) {
"channel_monitor_enabled": true,
"channel_monitor_default_interval_seconds": 60,
"available_channels_enabled": false,
"model_plaza_enabled": false,
"model_plaza_require_auth": false,
"model_plaza_description": "",
"risk_control_enabled": false,
"cyber_session_block_enabled": false,
"cyber_session_block_ttl_seconds": 3600,
@@ -1528,7 +1542,7 @@ func (r *stubUserRepo) GetFirstAdmin(ctx context.Context) (*service.User, error)
return nil, service.ErrUserNotFound
}
func (r *stubUserRepo) Update(ctx context.Context, user *service.User) error {
func (r *stubUserRepo) Update(ctx context.Context, user *service.User, fields service.UserUpdateFields) error {
return errors.New("not implemented")
}
@@ -1564,6 +1578,14 @@ func (r *stubUserRepo) DeductBalance(ctx context.Context, id int64, amount float
return errors.New("not implemented")
}
func (r *stubUserRepo) AdjustBalance(ctx context.Context, id int64, delta float64) (service.BalanceChange, error) {
return service.BalanceChange{}, errors.New("not implemented")
}
func (r *stubUserRepo) SetBalance(ctx context.Context, id int64, value float64) (service.BalanceChange, error) {
return service.BalanceChange{}, errors.New("not implemented")
}
func (r *stubUserRepo) UpdateConcurrency(ctx context.Context, id int64, amount int) error {
return errors.New("not implemented")
}
@@ -2274,7 +2296,7 @@ func (r *stubApiKeyRepo) GetByKeyForAuth(ctx context.Context, key string) (*serv
return r.GetByKey(ctx, key)
}
func (r *stubApiKeyRepo) Update(ctx context.Context, key *service.APIKey) error {
func (r *stubApiKeyRepo) Update(ctx context.Context, key *service.APIKey, _ service.APIKeyUpdateFields) error {
if key == nil {
return errors.New("nil key")
}
+2 -1
View File
@@ -31,6 +31,7 @@ func ProvideRouter(
cfg *config.Config,
handlers *handler.Handlers,
jwtAuth middleware2.JWTAuthMiddleware,
optionalJWTAuth middleware2.OptionalJWTAuthMiddleware,
adminAuth middleware2.AdminAuthMiddleware,
apiKeyAuth middleware2.APIKeyAuthMiddleware,
auditLog middleware2.AuditLogMiddleware,
@@ -86,7 +87,7 @@ func ProvideRouter(
service.SetWebSearchManager(websearch.NewManager(configs, redisClient))
})
return SetupRouter(r, handlers, jwtAuth, adminAuth, apiKeyAuth, auditLog, stepUpAuth, apiKeyService, subscriptionService, opsService, settingService, compositeResolver, cfg, redisClient)
return SetupRouter(r, handlers, jwtAuth, optionalJWTAuth, adminAuth, apiKeyAuth, auditLog, stepUpAuth, apiKeyService, subscriptionService, opsService, settingService, compositeResolver, cfg, redisClient)
}
func configureTrustedProxies(r *gin.Engine, cfg config.ServerConfig) {
@@ -150,7 +150,7 @@ func (s *stubUserRepo) GetFirstAdmin(ctx context.Context) (*service.User, error)
panic("unexpected GetFirstAdmin call")
}
func (s *stubUserRepo) Update(ctx context.Context, user *service.User) error {
func (s *stubUserRepo) Update(ctx context.Context, user *service.User, fields service.UserUpdateFields) error {
panic("unexpected Update call")
}
@@ -198,6 +198,14 @@ func (s *stubUserRepo) DeductBalance(ctx context.Context, id int64, amount float
panic("unexpected DeductBalance call")
}
func (s *stubUserRepo) AdjustBalance(ctx context.Context, id int64, delta float64) (service.BalanceChange, error) {
panic("unexpected AdjustBalance call")
}
func (s *stubUserRepo) SetBalance(ctx context.Context, id int64, value float64) (service.BalanceChange, error) {
panic("unexpected SetBalance call")
}
func (s *stubUserRepo) UpdateConcurrency(ctx context.Context, id int64, amount int) error {
panic("unexpected UpdateConcurrency call")
}
@@ -106,7 +106,7 @@ func (f fakeAPIKeyRepo) GetByKey(ctx context.Context, key string) (*service.APIK
func (f fakeAPIKeyRepo) GetByKeyForAuth(ctx context.Context, key string) (*service.APIKey, error) {
return f.GetByKey(ctx, key)
}
func (f fakeAPIKeyRepo) Update(ctx context.Context, key *service.APIKey) error {
func (f fakeAPIKeyRepo) Update(ctx context.Context, key *service.APIKey, _ service.APIKeyUpdateFields) error {
return errors.New("not implemented")
}
func (f fakeAPIKeyRepo) Delete(ctx context.Context, id int64) error {
@@ -1550,7 +1550,7 @@ func (r *stubApiKeyRepo) GetByKeyForAuth(ctx context.Context, key string) (*serv
return r.GetByKey(ctx, key)
}
func (r *stubApiKeyRepo) Update(ctx context.Context, key *service.APIKey) error {
func (r *stubApiKeyRepo) Update(ctx context.Context, key *service.APIKey, _ service.APIKeyUpdateFields) error {
return errors.New("not implemented")
}
@@ -124,6 +124,7 @@ var auditSensitiveReads = map[string]string{
var auditActionOverrides = map[string]string{
"POST /api/v1/auth/login": service.AuditActionLogin,
"POST /api/v1/auth/login/2fa": service.AuditActionLogin2FA,
"POST /api/v1/auth/passkey/login/finish": service.AuditActionLogin,
"POST /api/v1/auth/register": service.AuditActionRegister,
"POST /api/v1/auth/refresh": service.AuditActionTokenRefresh,
"POST /api/v1/user/totp/step-up": service.AuditActionStepUpVerify,
@@ -146,6 +147,8 @@ var auditActionOverrides = map[string]string{
// auditBodyOmittedRoutes 请求体几乎整体由凭证构成的路由(如整块粘贴 auth JSON 的导入接口)。
// 这类 body 的凭证内嵌在普通字符串值里,键级脱敏无法覆盖,整体不入库。
var auditBodyOmittedRoutes = map[string]struct{}{
"POST /api/v1/auth/passkey/login/finish": {},
"POST /api/v1/user/passkeys/register/finish": {},
"POST /api/v1/admin/accounts/import/codex-session": {},
"PUT /api/v1/admin/accounts/:id/ollama-cloud-usage/session": {},
"PUT /api/v1/admin/prompt-audit/config": {},
@@ -146,6 +146,12 @@ func TestPromptAuditMutationAuditRoutesHaveStableActionsAndOmitBodies(t *testing
}
}
func TestPasskeyLoginAuditUsesCanonicalLoginActionAndOmitsCredentialBody(t *testing.T) {
route := "POST /api/v1/auth/passkey/login/finish"
require.Equal(t, service.AuditActionLogin, auditActionOverrides[route])
require.Contains(t, auditBodyOmittedRoutes, route)
}
// Ollama 会话保存的请求体整体就是浏览器 Cookie 明文,键级脱敏清单曾漏掉裸键
// "session",必须走整体不入库路径,防止会话凭证长期留存在 audit_logs。
func TestOllamaCloudUsageSessionRouteOmitsAuditBody(t *testing.T) {
@@ -29,7 +29,14 @@ func BackendModeUserGuard(settingService *service.SettingService) gin.HandlerFun
func backendModeAllowsAuthPath(path string) bool {
path = strings.ToLower(strings.TrimSpace(path))
for _, suffix := range []string{"/auth/login", "/auth/login/2fa", "/auth/logout", "/auth/refresh"} {
for _, suffix := range []string{
"/auth/login",
"/auth/login/2fa",
"/auth/passkey/login/begin",
"/auth/passkey/login/finish",
"/auth/logout",
"/auth/refresh",
} {
if strings.HasSuffix(path, suffix) {
return true
}
@@ -0,0 +1,30 @@
package middleware
import (
"strings"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
// NewOptionalJWTAuthMiddleware 创建可选 JWT 认证中间件。
//
// 无 Authorization header 时直接放行(匿名,context 中不设置 AuthSubject);
// 带 header 则委托严格 JWT 校验(token 版本 / 用户状态 / 会话绑定),失败返回 401——
// 前端 API client 对 401 会自动走 refresh-token 重试,因此不做静默降级。
func NewOptionalJWTAuthMiddleware(
authService *service.AuthService,
userService *service.UserService,
settingService *service.SettingService,
auditService *service.AuditLogService,
) OptionalJWTAuthMiddleware {
strict := jwtAuth(authService, userService, userService, settingService, auditService)
return OptionalJWTAuthMiddleware(func(c *gin.Context) {
if strings.TrimSpace(c.GetHeader("Authorization")) == "" {
c.Next()
return
}
strict(c)
})
}
@@ -0,0 +1,99 @@
//go:build unit
package middleware
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// newOptionalJWTTestEnv 创建 OptionalJWT 中间件测试环境。
// handler 回写「是否携带 AuthSubject」,便于断言匿名 vs 登录两种路径。
func newOptionalJWTTestEnv(users map[int64]*service.User) (*gin.Engine, *service.AuthService) {
gin.SetMode(gin.TestMode)
cfg := &config.Config{}
cfg.JWT.Secret = "test-jwt-secret-32bytes-long!!!"
cfg.JWT.AccessTokenExpireMinutes = 60
userRepo := &stubJWTUserRepo{users: users}
authSvc := service.NewAuthService(nil, userRepo, nil, nil, cfg, nil, nil, nil, nil, nil, nil, nil, nil)
userSvc := service.NewUserService(userRepo, nil, nil, nil)
mw := NewOptionalJWTAuthMiddleware(authSvc, userSvc, nil, nil)
r := gin.New()
r.Use(gin.HandlerFunc(mw))
r.GET("/plaza", func(c *gin.Context) {
subject, authed := GetAuthSubjectFromContext(c)
c.JSON(http.StatusOK, gin.H{"authed": authed, "user_id": subject.UserID})
})
return r, authSvc
}
func TestOptionalJWTAuth_NoHeaderPassesAnonymously(t *testing.T) {
router, _ := newOptionalJWTTestEnv(nil)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/plaza", nil)
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
require.Contains(t, w.Body.String(), `"authed":false`)
}
func TestOptionalJWTAuth_ValidTokenSetsSubject(t *testing.T) {
user := &service.User{
ID: 7,
Email: "plaza@example.com",
Role: "user",
Status: service.StatusActive,
Concurrency: 5,
TokenVersion: 1,
}
router, authSvc := newOptionalJWTTestEnv(map[int64]*service.User{7: user})
token, err := authSvc.GenerateToken(context.Background(), user)
require.NoError(t, err)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/plaza", nil)
req.Header.Set("Authorization", "Bearer "+token)
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
require.Contains(t, w.Body.String(), `"authed":true`)
require.Contains(t, w.Body.String(), `"user_id":7`)
}
func TestOptionalJWTAuth_InvalidTokenRejected401(t *testing.T) {
// 带了 header 就必须通过严格校验:坏 token 返回 401 而非静默降级为匿名,
// 前端 401 拦截器会走 refresh-token 重试。
router, _ := newOptionalJWTTestEnv(nil)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/plaza", nil)
req.Header.Set("Authorization", "Bearer not-a-real-token")
router.ServeHTTP(w, req)
require.Equal(t, http.StatusUnauthorized, w.Code)
}
func TestOptionalJWTAuth_BlankHeaderTreatedAsAnonymous(t *testing.T) {
// 空白 header(如 "Authorization: ")按匿名处理,不进入严格校验。
router, _ := newOptionalJWTTestEnv(nil)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/plaza", nil)
req.Header.Set("Authorization", " ")
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
require.Contains(t, w.Body.String(), `"authed":false`)
}
@@ -8,6 +8,9 @@ import (
// JWTAuthMiddleware JWT 认证中间件类型
type JWTAuthMiddleware gin.HandlerFunc
// OptionalJWTAuthMiddleware 可选 JWT 认证中间件类型:匿名放行,带 token 严格校验
type OptionalJWTAuthMiddleware gin.HandlerFunc
// AdminAuthMiddleware 管理员认证中间件类型
type AdminAuthMiddleware gin.HandlerFunc
@@ -17,6 +20,7 @@ type APIKeyAuthMiddleware gin.HandlerFunc
// ProviderSet 中间件层的依赖注入
var ProviderSet = wire.NewSet(
NewJWTAuthMiddleware,
NewOptionalJWTAuthMiddleware,
NewAdminAuthMiddleware,
NewAPIKeyAuthMiddleware,
NewAuditLogMiddleware,
+6 -3
View File
@@ -24,6 +24,7 @@ func SetupRouter(
r *gin.Engine,
handlers *handler.Handlers,
jwtAuth middleware2.JWTAuthMiddleware,
optionalJWTAuth middleware2.OptionalJWTAuthMiddleware,
adminAuth middleware2.AdminAuthMiddleware,
apiKeyAuth middleware2.APIKeyAuthMiddleware,
auditLog middleware2.AuditLogMiddleware,
@@ -71,8 +72,8 @@ func SetupRouter(
// Serve embedded frontend with settings injection if available
if web.HasEmbeddedFrontend() {
frontendServer, err := web.NewFrontendServer(settingService)
if err != nil {
frontendServer, err := web.NewFrontendServer(settingService) //nolint:staticcheck // SA4023: the !embed stub always errors; embed builds can return nil
if err != nil { //nolint:staticcheck // SA4023: see above
log.Printf("Warning: Failed to create frontend server with settings injection: %v, using legacy mode", err)
r.Use(web.ServeEmbeddedFrontend())
settingService.SetOnUpdateCallback(refreshFrameOrigins)
@@ -89,7 +90,7 @@ func SetupRouter(
}
// 注册路由
registerRoutes(r, handlers, jwtAuth, adminAuth, apiKeyAuth, auditLog, stepUpAuth, apiKeyService, subscriptionService, opsService, settingService, compositeResolver, cfg, redisClient)
registerRoutes(r, handlers, jwtAuth, optionalJWTAuth, adminAuth, apiKeyAuth, auditLog, stepUpAuth, apiKeyService, subscriptionService, opsService, settingService, compositeResolver, cfg, redisClient)
return r
}
@@ -99,6 +100,7 @@ func registerRoutes(
r *gin.Engine,
h *handler.Handlers,
jwtAuth middleware2.JWTAuthMiddleware,
optionalJWTAuth middleware2.OptionalJWTAuthMiddleware,
adminAuth middleware2.AdminAuthMiddleware,
apiKeyAuth middleware2.APIKeyAuthMiddleware,
auditLog middleware2.AuditLogMiddleware,
@@ -124,6 +126,7 @@ func registerRoutes(
// 注册各模块路由
routes.RegisterAuthRoutes(v1, h, jwtAuth, auditLog, redisClient, settingService, panelRateLimiter)
routes.RegisterUserRoutes(v1, h, jwtAuth, auditLog, settingService, panelRateLimiter)
routes.RegisterModelPlazaRoutes(v1, h, optionalJWTAuth, settingService, panelRateLimiter)
routes.RegisterAdminRoutes(v1, h, adminAuth, auditLog, stepUpAuth, settingService, panelRateLimiter)
routes.RegisterGatewayRoutes(r, h, apiKeyAuth, apiKeyService, subscriptionService, opsService, settingService, compositeResolver, cfg)
routes.RegisterPaymentRoutes(v1, h.Payment, h.PaymentWebhook, h.Admin.Payment, jwtAuth, adminAuth, auditLog, settingService, panelRateLimiter)
+6
View File
@@ -41,6 +41,12 @@ func RegisterAuthRoutes(
auth.POST("/login/2fa", rateLimiter.LimitWithOptions("auth-login-2fa", 20, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,
}), h.Auth.Login2FA)
auth.POST("/passkey/login/begin", rateLimiter.LimitWithOptions("passkey-login-begin", 20, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,
}), h.Passkey.BeginLogin)
auth.POST("/passkey/login/finish", rateLimiter.LimitWithOptions("passkey-login-finish", 20, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,
}), h.Passkey.FinishLogin)
auth.POST("/send-verify-code", rateLimiter.LimitWithOptions("auth-send-verify-code", 5, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,
}), h.Auth.SendVerifyCode)
+23 -4
View File
@@ -153,6 +153,25 @@ func RegisterGatewayRoutes(
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate)
c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"type": "not_found_error", "message": "Videos API is not supported for this platform"}})
}
// /responses/*subpath 的子路径会被转发到上游同名端点之后,因此在入口就拒掉
// 不可转发的子路径,不让它进入调度与转发流程。可转发的判定见
// service.IsForwardableOpenAIResponsesRequestPath 及 upstream_path_guard.go。
guardResponsesSubpath := func(next gin.HandlerFunc) gin.HandlerFunc {
return func(c *gin.Context) {
if !service.IsForwardableOpenAIResponsesRequestPath(c) {
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalPolicyDenied)
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{
"error": gin.H{
"type": "not_found_error",
"message": "Unsupported responses subpath",
},
})
return
}
next(c)
}
}
// API网关(Claude API兼容)
gateway := r.Group("/v1")
gateway.Use(bodyLimit)
@@ -190,13 +209,13 @@ func RegisterGatewayRoutes(
}
h.Gateway.Responses(c)
})
gateway.POST("/responses/*subpath", func(c *gin.Context) {
gateway.POST("/responses/*subpath", guardResponsesSubpath(func(c *gin.Context) {
if isOpenAIResponsesCompatibleGatewayPlatform(c) {
h.OpenAIGateway.Responses(c)
return
}
h.Gateway.Responses(c)
})
}))
gateway.POST("/alpha/search", textBodyLimit, h.OpenAIGateway.AlphaSearch)
gateway.GET("/responses", func(c *gin.Context) {
h.OpenAIGateway.ResponsesWebSocket(c)
@@ -269,7 +288,7 @@ func RegisterGatewayRoutes(
h.Gateway.Responses(c)
}
r.POST("/responses", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, responsesHandler)
r.POST("/responses/*subpath", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, responsesHandler)
r.POST("/responses/*subpath", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, guardResponsesSubpath(responsesHandler))
r.POST("/alpha/search", textBodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, h.OpenAIGateway.AlphaSearch)
r.GET("/responses", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, func(c *gin.Context) {
h.OpenAIGateway.ResponsesWebSocket(c)
@@ -282,7 +301,7 @@ func RegisterGatewayRoutes(
codexDirect.POST("/realtime/calls", h.OpenAIGateway.Live)
codexDirect.GET("/:call_id", h.OpenAIGateway.LiveSideband)
codexDirect.POST("/responses", responsesHandler)
codexDirect.POST("/responses/*subpath", responsesHandler)
codexDirect.POST("/responses/*subpath", guardResponsesSubpath(responsesHandler))
codexDirect.POST("/alpha/search", textBodyLimit, h.OpenAIGateway.AlphaSearch)
codexDirect.GET("/responses", func(c *gin.Context) {
h.OpenAIGateway.ResponsesWebSocket(c)
@@ -333,6 +333,33 @@ func TestGatewayRoutesGrokAllowsCLICompatibilityEntrypoints(t *testing.T) {
}
}
// TestGatewayRoutesResponsesSubpathRejectsNonConformingSubpaths 端到端锁定不变式:
// /responses/*subpath 的子路径会被转发到上游同名端点之后,因此不合规的子路径必须
// 在入口就被拒绝,不得进入调度与转发流程。
func TestGatewayRoutesResponsesSubpathRejectsNonConformingSubpaths(t *testing.T) {
router := newGatewayRoutesTestRouter()
for _, path := range []string{
"/v1/responses/../../x/y",
"/v1/responses/..%2f..%2fx/y",
"/v1/responses/%2e%2e/%2e%2e/x",
"/responses/%2e%2e%2fx",
"/backend-api/codex/responses/..%2f..%2fx",
`/v1/responses/..\..\x`,
"/v1/responses/%3fa=b",
"/v1/responses/x%23frag",
"/v1/responses/compact%2f..",
} {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"model":"gpt-5"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusNotFound, w.Code, "path=%s must be rejected at the edge", path)
require.Contains(t, w.Body.String(), "Unsupported responses subpath", "path=%s", path)
}
}
func TestGatewayRoutesOpenAICountTokensPathIsRegistered(t *testing.T) {
router := newGatewayRoutesTestRouter(service.PlatformOpenAI)
@@ -0,0 +1,30 @@
package routes
import (
"github.com/Wei-Shaw/sub2api/internal/handler"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
// RegisterModelPlazaRoutes 注册模型广场路由。
//
// 挂 OptionalJWT:匿名可访问(开关与 require_auth 由 handler fail-closed 判定),
// 带 token 则识别用户以展示专属分组与个人倍率。
// BackendModeUserGuard 保证 backend 模式下广场不对非管理员开放(匿名无 role → 403)。
func RegisterModelPlazaRoutes(
v1 *gin.RouterGroup,
h *handler.Handlers,
optionalJWT middleware.OptionalJWTAuthMiddleware,
settingService *service.SettingService,
panelRateLimiter *middleware.PanelRateLimiter,
) {
plaza := v1.Group("/model-plaza")
plaza.Use(panelRateLimiter.PublicIP())
plaza.Use(gin.HandlerFunc(optionalJWT))
plaza.Use(middleware.BackendModeUserGuard(settingService))
{
plaza.GET("", h.ModelPlaza.Get)
}
}
+9
View File
@@ -61,6 +61,15 @@ func RegisterUserRoutes(
// 敏感操作二次验证:授予当前会话一段时间的 step-up 权限
totp.POST("/step-up", h.Totp.StepUp)
}
passkeys := user.Group("/passkeys")
{
passkeys.GET("", h.Passkey.List)
passkeys.POST("/register/begin", h.Passkey.BeginRegistration)
passkeys.POST("/register/finish", h.Passkey.FinishRegistration)
passkeys.PATCH("/:id", h.Passkey.Rename)
passkeys.DELETE("/:id", h.Passkey.Delete)
}
}
// API Key管理
+20 -1
View File
@@ -136,6 +136,18 @@ func (a *Account) IsActive() bool {
return a.Status == StatusActive
}
// IsSyntheticUITest reports whether the account belongs to an isolated UI load-test
// dataset. Production accounts never receive this marker. It lets the dedicated
// test instance exercise interactive quota and connection-test controls without
// sending fake credentials to an upstream provider.
func (a *Account) IsSyntheticUITest() bool {
if a == nil || a.Extra == nil {
return false
}
enabled, ok := a.Extra["synthetic_ui_test"].(bool)
return ok && enabled
}
// BillingRateMultiplier 返回账号计费倍率。
// - nil 表示未配置/旧缓存缺字段,按 1.0 处理
// - 允许 0,表示该账号计费为 0
@@ -779,9 +791,16 @@ func resolveRequestedModelInMapping(mapping map[string]string, requestedModel st
// 请求卡死在该账号上、无法 failover 到真正支持该模型的 API Key 账号(#3662)。
// 未知/自定义别名仍保持允许(兼容渠道级映射),见 isOpenAIOAuthServableModel。
func (a *Account) IsModelSupported(requestedModel string) bool {
// 透传模式仅替换认证、模型语义完全交由上游决定,因此放行所有模型。
// 该短路必须在 model_mapping 判定之前:账号从"白名单模式"切换到透传后,
// credentials 里常残留旧的非空 model_mapping,若不在此放行,透传账号会被
// model_mapping 白名单错误排除出候选集,导致 no available accounts / 404(issue #4936)。
if a.IsOpenAIPassthroughEnabled() {
return true
}
mapping := a.GetModelMapping()
if len(mapping) == 0 {
if a.IsOpenAIOAuth() && !a.IsOpenAIPassthroughEnabled() {
if a.IsOpenAIOAuth() {
return isOpenAIOAuthServableModel(requestedModel)
}
return true // 无映射 = 允许所有
@@ -186,6 +186,20 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int
return s.sendErrorAndEnd(c, "Account not found")
}
// Synthetic UI load-test accounts exercise the real SSE parsing and modal
// interactions, but intentionally do not send their placeholder credentials
// to an upstream provider.
if account.IsSyntheticUITest() {
testModelID := modelID
if testModelID == "" {
testModelID = claude.DefaultTestModel
}
s.sendEvent(c, TestEvent{Type: "test_start", Model: testModelID})
s.sendEvent(c, TestEvent{Type: "content", Text: "Synthetic Anthropic OAuth account is healthy and interactive."})
s.sendEvent(c, TestEvent{Type: "test_complete", Success: true})
return nil
}
// Route to platform-specific test method
if account.IsOpenAI() {
return s.testOpenAIAccountConnection(c, account, modelID, prompt, normalizeAccountTestMode(mode))
@@ -1195,8 +1209,10 @@ func (s *AccountTestService) buildGeminiAPIKeyRequest(ctx context.Context, accou
}
// Use streamGenerateContent for real-time feedback
fullURL := fmt.Sprintf("%s/v1beta/models/%s:streamGenerateContent?alt=sse",
strings.TrimRight(normalizedBaseURL, "/"), modelID)
fullURL, err := buildGeminiAIStudioModelActionURL(normalizedBaseURL, modelID, "streamGenerateContent", true)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "POST", fullURL, bytes.NewReader(payload))
if err != nil {
@@ -1232,7 +1248,10 @@ func (s *AccountTestService) buildGeminiOAuthRequest(ctx context.Context, accoun
if err != nil {
return nil, err
}
fullURL := fmt.Sprintf("%s/v1beta/models/%s:streamGenerateContent?alt=sse", strings.TrimRight(normalizedBaseURL, "/"), modelID)
fullURL, err := buildGeminiAIStudioModelActionURL(normalizedBaseURL, modelID, "streamGenerateContent", true)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bytes.NewReader(payload))
if err != nil {
@@ -345,6 +345,13 @@ func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64, for
return nil, fmt.Errorf("get account failed: %w", err)
}
// Dedicated UI load-test accounts must remain fully interactive without ever
// contacting Anthropic with synthetic credentials. Reuse the same persisted
// passive snapshot that the account table loads on mount.
if account.IsSyntheticUITest() && account.IsAnthropicOAuthOrSetupToken() {
return s.GetPassiveUsage(ctx, accountID)
}
if account.Platform == PlatformOpenAI && account.Type == AccountTypeOAuth {
usage, err := s.getOpenAIUsage(ctx, account, forceProbe)
if err == nil {
@@ -508,10 +515,30 @@ func (s *AccountUsageService) GetPassiveUsage(ctx context.Context, accountID int
// 添加窗口统计
s.addWindowStats(ctx, account, info)
if account.IsSyntheticUITest() {
applySyntheticWindowStats(info, account.Extra)
}
return info, nil
}
func applySyntheticWindowStats(info *UsageInfo, extra map[string]any) {
if info == nil || info.FiveHour == nil || len(extra) == 0 {
return
}
raw, ok := extra["synthetic_window_stats"].(map[string]any)
if !ok {
return
}
info.FiveHour.WindowStats = &WindowStats{
Requests: int64(parseExtraInt(raw["requests"])),
Tokens: int64(parseExtraInt(raw["tokens"])),
Cost: parseExtraFloat64(raw["cost"]),
StandardCost: parseExtraFloat64(raw["standard_cost"]),
UserCost: parseExtraFloat64(raw["user_cost"]),
}
}
// buildPassiveUsageWindow 从 Extra 中的被动采样数据(utilization 为 0-1 小数、reset 为 Unix 秒)
// 构建用量窗口,无数据时返回 nil。
func buildPassiveUsageWindow(extra map[string]any, utilKey, resetKey string) *UsageProgress {
+3 -3
View File
@@ -1078,7 +1078,7 @@ func (s *adminServiceImpl) AdminUpdateAPIKeyGroupID(ctx context.Context, keyID i
if addErr := s.userRepo.AddGroupToAllowedGroups(opCtx, apiKey.UserID, gid); addErr != nil {
return nil, fmt.Errorf("add group to user allowed groups: %w", addErr)
}
if err := s.apiKeyRepo.Update(opCtx, apiKey); err != nil {
if err := s.apiKeyRepo.Update(opCtx, apiKey, APIKeyUpdateFields{GroupID: true}); err != nil {
return nil, fmt.Errorf("update api key: %w", err)
}
if tx != nil {
@@ -1102,7 +1102,7 @@ func (s *adminServiceImpl) AdminUpdateAPIKeyGroupID(ctx context.Context, keyID i
}
// 非专属分组 / 解绑:无需事务,单步更新即可
if err := s.apiKeyRepo.Update(ctx, apiKey); err != nil {
if err := s.apiKeyRepo.Update(ctx, apiKey, APIKeyUpdateFields{GroupID: true}); err != nil {
return nil, fmt.Errorf("update api key: %w", err)
}
@@ -1127,7 +1127,7 @@ func (s *adminServiceImpl) AdminResetAPIKeyRateLimitUsage(ctx context.Context, k
apiKey.Window5hStart = nil
apiKey.Window1dStart = nil
apiKey.Window7dStart = nil
if err := s.apiKeyRepo.Update(ctx, apiKey); err != nil {
if err := s.apiKeyRepo.Update(ctx, apiKey, APIKeyUpdateFields{RateLimitUsage: true}); err != nil {
return nil, fmt.Errorf("reset api key rate limit usage: %w", err)
}
if s.authCacheInvalidator != nil {
@@ -45,7 +45,9 @@ func (s *userRepoStubForGroupUpdate) GetByEmail(context.Context, string) (*User,
func (s *userRepoStubForGroupUpdate) GetFirstAdmin(context.Context) (*User, error) {
panic("unexpected")
}
func (s *userRepoStubForGroupUpdate) Update(context.Context, *User) error { panic("unexpected") }
func (s *userRepoStubForGroupUpdate) Update(context.Context, *User, UserUpdateFields) error {
panic("unexpected")
}
func (s *userRepoStubForGroupUpdate) Delete(context.Context, int64) error { panic("unexpected") }
func (s *userRepoStubForGroupUpdate) GetUserAvatar(context.Context, int64) (*UserAvatar, error) {
panic("unexpected")
@@ -68,6 +70,14 @@ func (s *userRepoStubForGroupUpdate) UpdateBalance(context.Context, int64, float
func (s *userRepoStubForGroupUpdate) DeductBalance(context.Context, int64, float64) error {
panic("unexpected")
}
func (s *userRepoStubForGroupUpdate) AdjustBalance(ctx context.Context, id int64, delta float64) (BalanceChange, error) {
panic("unexpected AdjustBalance call")
}
func (s *userRepoStubForGroupUpdate) SetBalance(ctx context.Context, id int64, value float64) (BalanceChange, error) {
panic("unexpected SetBalance call")
}
func (s *userRepoStubForGroupUpdate) UpdateConcurrency(context.Context, int64, int) error {
panic("unexpected")
}
@@ -134,7 +144,7 @@ func (s *apiKeyRepoStubForGroupUpdate) GetByID(_ context.Context, _ int64) (*API
clone := *s.key
return &clone, nil
}
func (s *apiKeyRepoStubForGroupUpdate) Update(_ context.Context, key *APIKey) error {
func (s *apiKeyRepoStubForGroupUpdate) Update(_ context.Context, key *APIKey, _ APIKeyUpdateFields) error {
if s.updateErr != nil {
return s.updateErr
}
@@ -86,7 +86,7 @@ func (s *userRepoStub) GetFirstAdmin(ctx context.Context) (*User, error) {
panic("unexpected GetFirstAdmin call")
}
func (s *userRepoStub) Update(ctx context.Context, user *User) error {
func (s *userRepoStub) Update(ctx context.Context, user *User, fields UserUpdateFields) error {
s.updated = append(s.updated, user)
if s.usersByEmail == nil {
s.usersByEmail = make(map[string]*User)
@@ -141,6 +141,14 @@ func (s *userRepoStub) DeductBalance(ctx context.Context, id int64, amount float
panic("unexpected DeductBalance call")
}
func (s *userRepoStub) AdjustBalance(ctx context.Context, id int64, delta float64) (BalanceChange, error) {
panic("unexpected AdjustBalance call")
}
func (s *userRepoStub) SetBalance(ctx context.Context, id int64, value float64) (BalanceChange, error) {
panic("unexpected SetBalance call")
}
func (s *userRepoStub) UpdateConcurrency(ctx context.Context, id int64, amount int) error {
panic("unexpected UpdateConcurrency call")
}
@@ -64,7 +64,7 @@ func (s *emailSyncRepoStub) GetFirstAdmin(context.Context) (*User, error) {
return nil, fmt.Errorf("unexpected GetFirstAdmin call")
}
func (s *emailSyncRepoStub) Update(_ context.Context, user *User) error {
func (s *emailSyncRepoStub) Update(_ context.Context, user *User, _ UserUpdateFields) error {
s.updateCalls++
s.updated = append(s.updated, user)
s.user = user
@@ -117,6 +117,14 @@ func (s *emailSyncRepoStub) ExistsByEmailAlias(context.Context, string) (bool, e
return false, nil
}
func (s *emailSyncRepoStub) AdjustBalance(ctx context.Context, id int64, delta float64) (BalanceChange, error) {
panic("unexpected AdjustBalance call")
}
func (s *emailSyncRepoStub) SetBalance(ctx context.Context, id int64, value float64) (BalanceChange, error) {
panic("unexpected SetBalance call")
}
func (s *emailSyncRepoStub) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) {
return 0, nil
}
@@ -12,23 +12,34 @@ import (
type balanceUserRepoStub struct {
*userRepoStub
updateErr error
updated []*User
adjustErr error
// changes 记录每次原子余额变更,顺序与调用顺序一致。
changes []BalanceChange
}
func (s *balanceUserRepoStub) Update(ctx context.Context, user *User) error {
if s.updateErr != nil {
return s.updateErr
func (s *balanceUserRepoStub) AdjustBalance(ctx context.Context, id int64, delta float64) (BalanceChange, error) {
return s.apply(func(current float64) float64 { return current + delta })
}
func (s *balanceUserRepoStub) SetBalance(ctx context.Context, id int64, value float64) (BalanceChange, error) {
return s.apply(func(float64) float64 { return value })
}
func (s *balanceUserRepoStub) apply(next func(current float64) float64) (BalanceChange, error) {
if s.adjustErr != nil {
return BalanceChange{}, s.adjustErr
}
if user == nil {
return nil
if s.userRepoStub == nil || s.userRepoStub.user == nil {
return BalanceChange{}, ErrUserNotFound
}
clone := *user
s.updated = append(s.updated, &clone)
if s.userRepoStub != nil {
s.userRepoStub.user = &clone
change := BalanceChange{Old: s.userRepoStub.user.Balance}
change.New = next(change.Old)
if change.New < 0 {
return change, ErrBalanceNegative
}
return nil
s.userRepoStub.user.Balance = change.New
s.changes = append(s.changes, change)
return change, nil
}
type balanceRedeemRepoStub struct {
@@ -87,6 +98,63 @@ func (s *authCacheInvalidatorStub) InvalidateAuthCacheByGroupID(ctx context.Cont
s.groupIDs = append(s.groupIDs, groupID)
}
// 管理员调账必须走原子的 AdjustBalance/SetBalance,而不是"读余额→算新值→整行写回",
// 后者会把并发的计费扣款覆盖掉。userRepoStub.Update 对未预期的调用会 panic,
// 因此这里同时证明它没被走到。
func TestAdminService_UpdateUserBalance_UsesAtomicPrimitives(t *testing.T) {
tests := []struct {
name string
operation string
amount float64
want BalanceChange
}{
{name: "add", operation: "add", amount: 5, want: BalanceChange{Old: 10, New: 15}},
{name: "subtract", operation: "subtract", amount: 4, want: BalanceChange{Old: 10, New: 6}},
{name: "set", operation: "set", amount: 2, want: BalanceChange{Old: 10, New: 2}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repo := &balanceUserRepoStub{userRepoStub: &userRepoStub{user: &User{ID: 7, Balance: 10}}}
svc := &adminServiceImpl{
userRepo: repo,
redeemCodeRepo: &balanceRedeemRepoStub{redeemRepoStub: &redeemRepoStub{}},
}
user, err := svc.UpdateUserBalance(context.Background(), 7, tt.amount, tt.operation, "")
require.NoError(t, err)
require.Equal(t, []BalanceChange{tt.want}, repo.changes)
require.Equal(t, tt.want.New, user.Balance)
})
}
}
func TestAdminService_UpdateUserBalance_RejectsNegativeResult(t *testing.T) {
repo := &balanceUserRepoStub{userRepoStub: &userRepoStub{user: &User{ID: 7, Balance: 3}}}
svc := &adminServiceImpl{
userRepo: repo,
redeemCodeRepo: &balanceRedeemRepoStub{redeemRepoStub: &redeemRepoStub{}},
}
_, err := svc.UpdateUserBalance(context.Background(), 7, 4, "subtract", "")
require.Error(t, err)
require.Contains(t, err.Error(), "balance cannot be negative")
require.Empty(t, repo.changes, "refused adjustment must not be applied")
require.Equal(t, 3.0, repo.userRepoStub.user.Balance)
}
func TestAdminService_UpdateUserBalance_RejectsUnknownOperation(t *testing.T) {
repo := &balanceUserRepoStub{userRepoStub: &userRepoStub{user: &User{ID: 7, Balance: 10}}}
svc := &adminServiceImpl{
userRepo: repo,
redeemCodeRepo: &balanceRedeemRepoStub{redeemRepoStub: &redeemRepoStub{}},
}
_, err := svc.UpdateUserBalance(context.Background(), 7, 1, "multiply", "")
require.Error(t, err)
require.Empty(t, repo.changes)
}
func TestAdminService_UpdateUserBalance_InvalidatesAuthCache(t *testing.T) {
baseRepo := &userRepoStub{user: &User{ID: 7, Balance: 10}}
repo := &balanceUserRepoStub{userRepoStub: baseRepo}
@@ -16,7 +16,7 @@ type rpmUserRepoStub struct {
lastUpdated *User
}
func (s *rpmUserRepoStub) Update(_ context.Context, user *User) error {
func (s *rpmUserRepoStub) Update(_ context.Context, user *User, _ UserUpdateFields) error {
if user == nil {
return nil
}
+37 -20
View File
@@ -218,24 +218,33 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda
oldRPMLimit := user.RPMLimit
oldAllowedGroups := append([]int64(nil), user.AllowedGroups...)
// fields 与下面的 input.X 判空条件一一对应:管理员没提交的列不写回,
// 避免这份快照回滚并发的扣费、状态变更或批量限额调整。
var fields UserUpdateFields
if input.Email != "" {
user.Email = input.Email
fields.Email = true
}
if input.Password != "" {
if err := user.SetPassword(input.Password); err != nil {
return nil, err
}
fields.PasswordHash = true
}
if input.Username != nil {
user.Username = *input.Username
fields.Username = true
}
if input.Notes != nil {
user.Notes = *input.Notes
fields.Notes = true
}
if input.Status != "" {
user.Status = input.Status
fields.Status = true
}
// 角色变更(admin/user);空字符串表示不修改。
@@ -252,21 +261,25 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda
}
}
user.Role = role
fields.Role = true
}
if input.Concurrency != nil {
user.Concurrency = *input.Concurrency
fields.Concurrency = true
}
if input.RPMLimit != nil {
user.RPMLimit = *input.RPMLimit
fields.RPMLimit = true
}
if input.AllowedGroups != nil {
user.AllowedGroups = *input.AllowedGroups
fields.AllowedGroups = true
}
if err := s.userRepo.Update(ctx, user); err != nil {
if err := s.userRepo.Update(ctx, user, fields); err != nil {
return nil, err
}
@@ -493,30 +506,34 @@ func (s *adminServiceImpl) BatchUpdateLimits(ctx context.Context, userIDs []int6
}
func (s *adminServiceImpl) UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string, notes string) (*User, error) {
// 余额调整必须走原子接口:先读后整行写回会把并发的计费扣款覆盖掉。
var (
change BalanceChange
err error
)
switch operation {
case "set":
change, err = s.userRepo.SetBalance(ctx, userID, balance)
case "add":
change, err = s.userRepo.AdjustBalance(ctx, userID, balance)
case "subtract":
change, err = s.userRepo.AdjustBalance(ctx, userID, -balance)
default:
return nil, fmt.Errorf("unsupported balance operation: %q", operation)
}
if errors.Is(err, ErrBalanceNegative) {
return nil, fmt.Errorf("balance cannot be negative, current balance: %.2f, requested operation would result in: %.2f", change.Old, change.New)
}
if err != nil {
return nil, err
}
user, err := s.userRepo.GetByID(ctx, userID)
if err != nil {
return nil, err
}
oldBalance := user.Balance
switch operation {
case "set":
user.Balance = balance
case "add":
user.Balance += balance
case "subtract":
user.Balance -= balance
}
if user.Balance < 0 {
return nil, fmt.Errorf("balance cannot be negative, current balance: %.2f, requested operation would result in: %.2f", oldBalance, user.Balance)
}
if err := s.userRepo.Update(ctx, user); err != nil {
return nil, err
}
balanceDiff := user.Balance - oldBalance
balanceDiff := change.New - change.Old
if s.authCacheInvalidator != nil && balanceDiff != 0 {
s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
}
+75 -3
View File
@@ -53,6 +53,34 @@ const (
apiKeyLastUsedFailBackoff = 5 * time.Second
)
// APIKeyUpdateFields 声明 APIKeyRepository.Update 允许写回的列。
//
// 与 UserUpdateFields 同理:api_keys 的用量列由计费热路径原子递增
// (IncrementQuotaUsed / IncrementRateLimitUsage 的 quota_used、usage_5h/1d/7d),
// 若编辑 Key 时无条件整行回写,并发累计的配额与限流计数就会被旧快照覆盖。
// 因此调用方必须显式声明要改的列。
type APIKeyUpdateFields struct {
Name bool
Status bool
Quota bool
GroupID bool
ExpiresAt bool
// QuotaUsed 仅供"重置配额用量"路径声明;常规计费走 IncrementQuotaUsed。
QuotaUsed bool
// RateLimits 覆盖 rate_limit_5h / _1d / _7d 三个阈值。
RateLimits bool
// RateLimitUsage 覆盖 usage_5h/_1d/_7d 与三个窗口起点,
// 仅供"重置限流用量"路径声明;常规计费走 IncrementRateLimitUsage。
RateLimitUsage bool
// IPRules 覆盖 ip_whitelist 与 ip_blacklist。
IPRules bool
}
// IsEmpty 报告该次 Update 是否不写任何列。
func (f APIKeyUpdateFields) IsEmpty() bool {
return f == APIKeyUpdateFields{}
}
type APIKeyRepository interface {
Create(ctx context.Context, key *APIKey) error
GetByID(ctx context.Context, id int64) (*APIKey, error)
@@ -61,7 +89,8 @@ type APIKeyRepository interface {
GetByKey(ctx context.Context, key string) (*APIKey, error)
// GetByKeyForAuth 认证专用查询,返回最小字段集
GetByKeyForAuth(ctx context.Context, key string) (*APIKey, error)
Update(ctx context.Context, key *APIKey) error
// Update 只写 fields 中显式声明的列,其余列保持库中当前值。
Update(ctx context.Context, key *APIKey, fields APIKeyUpdateFields) error
Delete(ctx context.Context, id int64) error
// DeleteWithAudit keeps the legacy interface name for rolling-upgrade compatibility.
// Implementations must tombstone the key and soft-delete it atomically without
@@ -718,9 +747,17 @@ func (s *APIKeyService) Update(ctx context.Context, id int64, userID int64, req
}
}
// fields 只登记本次请求真正要改的列。quota_used 与 usage_5h/1d/7d 由计费热路径
// 原子递增,除非用户显式点了"重置",否则这里不用快照把它们写回去。
var fields APIKeyUpdateFields
// 下面若干分支会顺带把 Status 改回 active(配额扩容、清除过期等),
// 所以用原始值比对来决定是否写 status,而不是只看 req.Status。
originalStatus := apiKey.Status
// 更新字段
if req.Name != nil {
apiKey.Name = html.EscapeString(*req.Name)
fields.Name = true
}
if req.GroupID != nil {
@@ -740,10 +777,12 @@ func (s *APIKeyService) Update(ctx context.Context, id int64, userID int64, req
}
apiKey.GroupID = req.GroupID
fields.GroupID = true
}
if req.Status != nil {
apiKey.Status = *req.Status
fields.Status = true
// 如果状态改变,清除Redis缓存
if s.cache != nil {
_ = s.cache.DeleteCreateAttemptCount(ctx, apiKey.UserID)
@@ -753,6 +792,7 @@ func (s *APIKeyService) Update(ctx context.Context, id int64, userID int64, req
// Update quota fields
if req.Quota != nil {
apiKey.Quota = *req.Quota
fields.Quota = true
// If quota now has room, or is changed to unlimited, reactivate exhausted keys.
if apiKey.Status == StatusAPIKeyQuotaExhausted && (*req.Quota <= 0 || *req.Quota > apiKey.QuotaUsed) {
apiKey.Status = StatusActive
@@ -760,6 +800,7 @@ func (s *APIKeyService) Update(ctx context.Context, id int64, userID int64, req
}
if req.ResetQuota != nil && *req.ResetQuota {
apiKey.QuotaUsed = 0
fields.QuotaUsed = true
// If resetting quota and status was quota_exhausted, reactivate
if apiKey.Status == StatusAPIKeyQuotaExhausted {
apiKey.Status = StatusActive
@@ -767,12 +808,14 @@ func (s *APIKeyService) Update(ctx context.Context, id int64, userID int64, req
}
if req.ClearExpiration {
apiKey.ExpiresAt = nil
fields.ExpiresAt = true
// If clearing expiry and status was expired, reactivate
if apiKey.Status == StatusAPIKeyExpired {
apiKey.Status = StatusActive
}
} else if req.ExpiresAt != nil {
apiKey.ExpiresAt = req.ExpiresAt
fields.ExpiresAt = true
// If extending expiry and status was expired, reactivate
if apiKey.Status == StatusAPIKeyExpired && time.Now().Before(*req.ExpiresAt) {
apiKey.Status = StatusActive
@@ -782,20 +825,25 @@ func (s *APIKeyService) Update(ctx context.Context, id int64, userID int64, req
// 更新 IP 限制(nil 不修改,空数组清空设置)
if req.IPWhitelist != nil {
apiKey.IPWhitelist = *req.IPWhitelist
fields.IPRules = true
}
if req.IPBlacklist != nil {
apiKey.IPBlacklist = *req.IPBlacklist
fields.IPRules = true
}
// Update rate limit configuration
if req.RateLimit5h != nil {
apiKey.RateLimit5h = *req.RateLimit5h
fields.RateLimits = true
}
if req.RateLimit1d != nil {
apiKey.RateLimit1d = *req.RateLimit1d
fields.RateLimits = true
}
if req.RateLimit7d != nil {
apiKey.RateLimit7d = *req.RateLimit7d
fields.RateLimits = true
}
resetRateLimit := req.ResetRateLimitUsage != nil && *req.ResetRateLimitUsage
if resetRateLimit {
@@ -805,9 +853,15 @@ func (s *APIKeyService) Update(ctx context.Context, id int64, userID int64, req
apiKey.Window5hStart = nil
apiKey.Window1dStart = nil
apiKey.Window7dStart = nil
fields.RateLimitUsage = true
}
if err := s.apiKeyRepo.Update(ctx, apiKey); err != nil {
// 上面的自动复活分支可能改了 status,这里统一登记。
if apiKey.Status != originalStatus {
fields.Status = true
}
if err := s.apiKeyRepo.Update(ctx, apiKey, fields); err != nil {
return nil, fmt.Errorf("update api key: %w", err)
}
@@ -980,6 +1034,22 @@ func (s *APIKeyService) SearchAPIKeys(ctx context.Context, userID int64, keyword
return keys, nil
}
// GetUserAllowedGroupIDSet 返回 user_allowed_groups 授权给该用户的专属分组 ID 集合。
//
// 与 GetAvailableGroups 的区别:这里是「橱窗」语义(模型广场用),不检查订阅有效性,
// 也不关心分组是否活跃——仅回答"哪些专属分组对该用户可见"。返回值恒非 nil。
func (s *APIKeyService) GetUserAllowedGroupIDSet(ctx context.Context, userID int64) (map[int64]struct{}, error) {
user, err := s.userRepo.GetByID(ctx, userID)
if err != nil {
return nil, fmt.Errorf("get user: %w", err)
}
allowed := make(map[int64]struct{}, len(user.AllowedGroups))
for _, id := range user.AllowedGroups {
allowed[id] = struct{}{}
}
return allowed, nil
}
// GetUserGroupRates 获取用户的专属分组倍率配置
// 返回 map[groupID]rateMultiplier
func (s *APIKeyService) GetUserGroupRates(ctx context.Context, userID int64) (map[int64]float64, error) {
@@ -1046,7 +1116,9 @@ func (s *APIKeyService) UpdateQuotaUsed(ctx context.Context, apiKeyID int64, cos
// If quota is set and now exhausted, update status
if apiKey.Quota > 0 && newQuotaUsed >= apiKey.Quota {
apiKey.Status = StatusAPIKeyQuotaExhausted
if err := s.apiKeyRepo.Update(ctx, apiKey); err != nil {
// 只写 status:这条位于计费热路径,若整行回写会把刚刚原子递增的
// quota_used 与限流用量按快照覆盖掉。
if err := s.apiKeyRepo.Update(ctx, apiKey, APIKeyUpdateFields{Status: true}); err != nil {
return nil // Don't fail the request
}
// Invalidate cache so next request sees the new status
@@ -46,7 +46,7 @@ func (s *authRepoStub) GetByKeyForAuth(ctx context.Context, key string) (*APIKey
return s.getByKeyForAuth(ctx, key)
}
func (s *authRepoStub) Update(ctx context.Context, key *APIKey) error {
func (s *authRepoStub) Update(ctx context.Context, key *APIKey, _ APIKeyUpdateFields) error {
panic("unexpected Update call")
}
@@ -82,7 +82,7 @@ func (s *apiKeyRepoStub) GetByKeyForAuth(ctx context.Context, key string) (*APIK
panic("unexpected GetByKeyForAuth call")
}
func (s *apiKeyRepoStub) Update(ctx context.Context, key *APIKey) error {
func (s *apiKeyRepoStub) Update(ctx context.Context, key *APIKey, _ APIKeyUpdateFields) error {
if key != nil {
s.updatedKeys = append(s.updatedKeys, *key)
}
@@ -95,7 +95,7 @@ func (s *quotaBaseAPIKeyRepoStub) GetByKey(context.Context, string) (*APIKey, er
func (s *quotaBaseAPIKeyRepoStub) GetByKeyForAuth(context.Context, string) (*APIKey, error) {
panic("unexpected GetByKeyForAuth call")
}
func (s *quotaBaseAPIKeyRepoStub) Update(context.Context, *APIKey) error {
func (s *quotaBaseAPIKeyRepoStub) Update(context.Context, *APIKey, APIKeyUpdateFields) error {
panic("unexpected Update call")
}
func (s *quotaBaseAPIKeyRepoStub) Delete(context.Context, int64) error {
@@ -0,0 +1,133 @@
//go:build unit
package service
import (
"context"
"testing"
"github.com/stretchr/testify/require"
)
// api_keys 的 quota_used / usage_5h|1d|7d 由计费热路径原子递增。
// 编辑 Key(改名、换分组……)若整行回写,并发累计的用量就会被旧快照覆盖。
// 这些用例锁死"只声明请求真正要改的列"。
type updateFieldsAPIKeyRepoStub struct {
quotaBaseAPIKeyRepoStub
key *APIKey
updateFields []APIKeyUpdateFields
}
// IncrementQuotaUsed 模拟计费热路径上的原子递增:只动 quota_used。
func (s *updateFieldsAPIKeyRepoStub) IncrementQuotaUsed(_ context.Context, _ int64, amount float64) (float64, error) {
s.key.QuotaUsed += amount
return s.key.QuotaUsed, nil
}
func (s *updateFieldsAPIKeyRepoStub) GetByID(context.Context, int64) (*APIKey, error) {
clone := *s.key
return &clone, nil
}
func (s *updateFieldsAPIKeyRepoStub) Update(_ context.Context, _ *APIKey, fields APIKeyUpdateFields) error {
s.updateFields = append(s.updateFields, fields)
return nil
}
func newUpdateFieldsAPIKeyService(key *APIKey) (*APIKeyService, *updateFieldsAPIKeyRepoStub) {
repo := &updateFieldsAPIKeyRepoStub{key: key}
return &APIKeyService{apiKeyRepo: repo}, repo
}
func TestAPIKeyUpdate_OnlyDeclaresRequestedColumns(t *testing.T) {
name := "renamed"
quota := 500.0
rateLimit := 42.0
whitelist := []string{"10.0.0.1"}
tests := []struct {
name string
req UpdateAPIKeyRequest
want APIKeyUpdateFields
}{
{
name: "name only",
req: UpdateAPIKeyRequest{Name: &name},
want: APIKeyUpdateFields{Name: true},
},
{
name: "quota only",
req: UpdateAPIKeyRequest{Quota: &quota},
want: APIKeyUpdateFields{Quota: true},
},
{
name: "rate limit threshold only",
req: UpdateAPIKeyRequest{RateLimit5h: &rateLimit},
want: APIKeyUpdateFields{RateLimits: true},
},
{
name: "ip whitelist only",
req: UpdateAPIKeyRequest{IPWhitelist: &whitelist},
want: APIKeyUpdateFields{IPRules: true},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc, repo := newUpdateFieldsAPIKeyService(&APIKey{
ID: 1,
UserID: 7,
Key: "sk-test",
Name: "before",
Status: StatusActive,
Quota: 100,
QuotaUsed: 30,
Usage5h: 12,
})
_, err := svc.Update(context.Background(), 1, 7, tt.req)
require.NoError(t, err)
require.Equal(t, []APIKeyUpdateFields{tt.want}, repo.updateFields)
})
}
}
// 显式重置仍需声明对应的列,避免收窄写入列时把功能改坏。
func TestAPIKeyUpdate_DeclaresUsageColumnsOnExplicitReset(t *testing.T) {
reset := true
svc, repo := newUpdateFieldsAPIKeyService(&APIKey{
ID: 1, UserID: 7, Key: "sk-test", Status: StatusActive, Quota: 100, QuotaUsed: 30, Usage5h: 12,
})
_, err := svc.Update(context.Background(), 1, 7, UpdateAPIKeyRequest{
ResetQuota: &reset,
ResetRateLimitUsage: &reset,
})
require.NoError(t, err)
require.Equal(t, []APIKeyUpdateFields{{QuotaUsed: true, RateLimitUsage: true}}, repo.updateFields)
}
// 配额扩容会顺带把 quota_exhausted 复活为 active,此时必须声明 status。
func TestAPIKeyUpdate_DeclaresStatusWhenReactivated(t *testing.T) {
quota := 500.0
svc, repo := newUpdateFieldsAPIKeyService(&APIKey{
ID: 1, UserID: 7, Key: "sk-test", Status: StatusAPIKeyQuotaExhausted, Quota: 100, QuotaUsed: 100,
})
_, err := svc.Update(context.Background(), 1, 7, UpdateAPIKeyRequest{Quota: &quota})
require.NoError(t, err)
require.Equal(t, []APIKeyUpdateFields{{Quota: true, Status: true}}, repo.updateFields)
}
// 计费热路径把 Key 标记为配额耗尽时只写 status,
// 否则会把刚原子递增的 quota_used 按快照覆盖掉。
func TestUpdateQuotaUsed_ExhaustedMarkOnlyDeclaresStatus(t *testing.T) {
repo := &updateFieldsAPIKeyRepoStub{key: &APIKey{
ID: 1, UserID: 7, Key: "sk-test", Status: StatusActive, Quota: 10, QuotaUsed: 10,
}}
svc := &APIKeyService{apiKeyRepo: repo}
require.NoError(t, svc.UpdateQuotaUsed(context.Background(), 1, 5))
require.Equal(t, []APIKeyUpdateFields{{Status: true}}, repo.updateFields)
}
+1
View File
@@ -19,6 +19,7 @@ const (
// AuditAuthMethodJWT / AuditAuthMethodAdminAPIKey 与 auth 中间件写入的 auth_method 对齐。
AuditAuthMethodJWT = "jwt"
AuditAuthMethodAdminAPIKey = "admin_api_key"
AuditAuthMethodPasskey = "passkey"
// auditRequestBodyMaxBytes 请求体脱敏后入库的最大长度(字节),超出截断。
auditRequestBodyMaxBytes = 16 * 1024
@@ -79,7 +79,7 @@ func (s *AuthService) BindEmailIdentity(
currentUser.Email = normalizedEmail
currentUser.PasswordHash = hashedPassword
if err := s.userRepo.Update(ctx, currentUser); err != nil {
if err := s.userRepo.Update(ctx, currentUser, UserUpdateFields{Email: true, PasswordHash: true}); err != nil {
if errors.Is(err, ErrEmailExists) {
return nil, ErrEmailExists
}

Some files were not shown because too many files have changed in this diff Show More