-sj#U5Yld
zo*gzrfBhKI32ufy*cdxq^fW@4qaQC!O7}umE
z!v~@j8&i@LD7k2+tX~czWq9D`*8SaIsg}LN*IEKR%b$M*{yFEE(4cHt9%%~r%l}2M
ziww=p6*U>9DIeT)7XgbkI1|V~NkbwM)M7DTv>$6Zw>)nUDa&
zk+`#khaV5zN1KGLs!>Ili;HRz4^u8Q;z(6?0~$Hbl#3eyvSRxV#o9XSa|opGn_AL-
zVA-NzUeK2sT`|x1v~G~;wN;!eXp8qCPPevwN=A$>G1>+lKiQnegjMt7&L86#3^#^j
zX=&D`sT8Ico$80!8#wZ}6CeE&lDtI)J?ezH3>rE@Eo&NZ1<}4>bA2QAI9FFmH<(aC
zA=64mDxuT?g~yS^-zf-*?lP=oZGpmm=_RKYcIgMJ1u&2i%;%Z8TjN8jxl$7ixO9*
z#0rMmIbYVN^ktl@i*)M#<$9(d{z9PD5JrV_uVBQqdDaK^T^iq;J%ja85v^VqN@O%^
zg|%_LmtjNReGpxhvj+ijRj{d!DR1Pqj$hEz=7UGSBM@%qb9{~KCNxMrk@QW8IM_~%
ze#s_``lHsZ=1dHi6<>e<06vI~dze=}(r1H|!W4CI@+LsMh7q)5-$>^GyDbNpO;JTP8m^Cf9T8|`IF^sYAHF#&PVo=
zC{r!@A~A$K-PHJU`USac*oq_g(%Or>b3ScCTu!jPugPTB)^1QyloS;0l1{n8Hs3xKD)-`jj|Fh;X%F?~_aPsZa_
zv86-j#LBY9^?3bzzB$a}`ok+Bav{&v)j#od+Qmsm~E{mJX|RN-_5Sgdv*zAvS#@wvUUkNjcmZyQj|0%ukp#YGBL|?*cW-Xp`@?UpAu@6G`mUK;c)F;
zM~%VOO*)>I*l%o#HK5S6E!h^+O47dj&I1+Rzjh7%gZk{2ckG|Iwb;Z!>9W0xAiW9D
z1+wg6$@>~-oyu`>vKDJ0&m55yl3+bxclIZXV0rv*`ljOs|J&;N++bZWF)`WhZ1n2j
zJZ4VB`c-n?fmyw;Ta-pQ;Eq%JY89k(y{PZOg2$r8=WZJN^%}rW7WWx96Dik0Ydk1e
zXe4_>zsk=}wDZs7`sM*W1e*PIC(VyRo$pxpM!8LWn6X<9ml81>EMXJgWA@%?elF)z
zb}u(O1tl#fkS-w{`^As}!grD3lt6IJ`5Yt#JXr?fa?5%+UsA$EBF32QILa1`J^vW}
z;`hg)cKk=()=qAfmUYjTEO(R3ZET!`JNZF&oJS8ln*zyoj%1k3vq0UaEo&IyfC<`?
zP&_wlaO6gOH^7+z2wt5!Q#NFD5S9LVgX!nthuCL2K{azmb{Fo=ta7zyY(b|ZcJr`L
zGAhwI)*jwFlk)JG)*cS?;N?IQ@z6CD-su})IgV60!-RwY%$sRX}N~mnu5Is@J(+p9OnCf0@6}_7h{Mc!p
z+w{zt>DWa4VF4z4Ziz9gc%K2uA%pAisE(ylp%r8O`KVA4-LLg{z?R|BR~EjP2ET6l
z8}VMiVDVNWQ~b#`n6+tDRv(M|5o~uoX`(%HvUZ6a5UaGRkV6e&DbBVTMGtR&mm+WWPO~zBoZIeG9hAWGP_KTHEmIP&^g+u
zVm+i85ou<$t&@_~H8y_~{jKm!_Hbr+r>zt|Pz9iE>E;88H@VWW #%15Ybhq#0*X
zxv1mEjEFmUZWWBH(y*(?SAuS%v!wY@EA59lHj;S{-M!x5txa^}tPm7U!0X$@7ORKA
z2}8jIl}$-G1QsUg_u%SM`DL!GyD7FWK2_R!w^2eb)Nb_S${P}{*Jzk*FF&D(5fv*t
zm66?g#D|Ni9Xq~Te(W)Wy(V4OJBRfB^7NIR^v9>fVyv+3l~VAiw5WY4oWt|tNOHMC
z0_(;9Z~!7Eylv?*l8P2Xves43sNHw-#rg&O;I!fy2d=eN(5BVc~$3
z--6qPJyj~i3w^AkD@mO+9UVKhv?Wj@k8W8U%>-9rcArn30}U#LDQO8S3UJ?gVB>X>
z7bb=?M3%Z`Gevq}0rKR*=$-it@h+FDbta`}wgkUA1fI2~jkL@_b6%(g(6RMl9zNM~
z=&>@NR6h`e25$BGjsBuoE8k%*p+b-5lU+QR=Z`D*cChApC3>pJGFmzT4=aox_Osd9
zvr-0`K0sjKg$JqrrQJ1dLbL#WSoms_I!trx_%dPX=LVUc_wQ&*eqWhAr!7-j;&6Kp
z)z9oqGxrU%W81moESxEua9@m#AzZ7qRtE!?prY`vN=+{a9!H0?G@3#e@|ypM
zar>?E;nP-`3Ulh`0ew|=)p$Z1|%u!%%(?Z_6iFFmC8WKA>>zmMr<5GOK_q
zRdk>`@WZVB!0^+|le4xRuSths)TN8wQlUdrA2=jFk;smiwf7
z$M(ftnO$*`AxNDT#G^8D&lY_To#zB_TU#IveI+t-2GYcgSOY4Yl8G*JD)|$0R-#8i8e7idu($S#;QfAsu
zA!OFrCUIH}YAmwenBr2j$=;ia%G5cdIivZBVecsTUUe!dsXM-aCRt-=Y&`sv
zWsBuv@!P+DZAO>(vZ%3U17mZdRaSN~I)^b|T>IkK(ybv(Urm;HVs4(c!S?zGMHiHT|@V!Nlzv0e6J*k~^VY&Je$86&(
z;r;a8zN~AK+1y*q@4#LS05&TEmr1bqg+@-`3JB*M&ZXCVfJ$}2c?<~xXVYFxzw)^A
zCBa5(5CT1f><#UnnMwjT4mGNTOm{rL_mhPKk%PISot9Ujm})Foe(|1i9=&F#b~4FY
z%aTI0^k%u
zH#Z-%@SYOa(8^t0WiJ7iNBv}Ri*@$#hd;dTOzfhib8XSz}2vT;k)?e(Gtus6pw3(o;^Y(6vD&&2>IzSex8uo$oiwjX`89IrhKFY>nY6eAOuzZ%NGVm1
zyVo3>&W7Mn9CHy~g*cMNx{NL%g@N=#<7_J$O6GyWJt;qu!oSmQ?4m#SkcV2T5`^uA
zZmvC8Zp;t)T`F~SxzSoSRn_90jnsw{`A+$v)eD5>7oIWu5#Md6E8
zFRpcm(wA2@D$8Wyn6U#aYzo!lJrIRZE~LH6QVQlUD6Ci@vwCY2EB!-XFd%!tF1eyK
z(7eOuFFvq)KwgR&oY;mnvlOG4JG>oSm7_qKo)Sbw%G}3;ombSSc$Wv*O74hwm?LN@X;5
z1Gk7ZyyVmet9sQ8qx``~ESJrGFk4+q3Kn%{o
z19Pf$ZUKcs8a{2KU7-ZKyUW&Q>%wM6nU$?W*vJmjQ>%^qq+AZ3DDNDPt)sX?jTRRf
z;Uvj~Z_19t8rLf_61A5tvqrNR0LcN=f>QN2(A3emijn@x24_@lv=Jo>?i;lx@Hg|2
z>&{`D>1pjRA9=LT6u*8DR0^gJ)LQR!WC#$S)jj;e^m2a{`=q_Vf#Kr1C^6*Wmko00
z(})E!i~yojFE3DEVM&(yTP91~f~8a?Ww-iBa$ZIOyGHBST-8-yCz1175vxND>xyM}
ziCnDd6VdOd{@IdjF(Rw&_SKnm78ZU5v+A6y1EUU$ce&3>X9UaU5_a9~`02H?#sg)S
z5R_AeWuF*{O9s*8yMQ!7v4oPw*@e@o7i!WpI1`B3@0p8tq6z!Fud>tj+iKCSpVk>`
zTgBJrD{+>zr(j2&gV{n9w%mb_|5SRfV9RA0A2by!X+Z+kq}_>foJ$wg)G&cb?A;x5
zj~!bdEwNtXc?y$j2jq4(e2Opk4
zJ-WBDSd=*!!Xpp2a9H(S+EQB`ESFxFS59i5UEa!g!&7$@J19%8tImi?1-F%ctDz8_
z%|N*_+)|$MQ#qjZi?1sN&y5M{Lcx7PMdKQjJ5viEs*IdBfcCrh#v4b~vf2v?J6je6
z5!{dzsj#-cA;=>P*L26xdZQ%peDRoBOpY}?s3VxIw(gZL9-uNvMFSuABHkDIBCLIvq}9?+fs>R4i;7+r+*ohhyQH&!
zWq{hgkG#iG`mrg<=}^-0E78wW&2TU-CIFPe@a(lk#fQgKv_b-jvC{w}6THffgg9
z^3(C;ZRoUs*d=m(^tR@D`Kt6=-cs2yN3UwE$pzCaSsw(g!P@w{tAldh=B*14B#bk+
z;Y($OaI{qodIdRSH}=Tm=DJ4oCF-EnT_bG=pstwS_VPHmWlnidTVWKAuI6mgse5Hk
z(;QiD_{V7)#SNdq10LsT
z8X_UT`OQNx<|+Bllr$n%amHW$e;n#2eC)DOOBZX=r0?;$LlL{|GH~ZSx~;_zsmq(W
zYIvt~1Lx+|>jiF=joIfk@$-~)ha06+apCB@Z@xr(KK7r*^nIh<23TeRl-toB38O$=|W%RQU^7y;c^?fYGSiTm3gpa(J(?7VL-*B2o
z#=JHzdq!XLjIFD%5<6LfyCI4ONG=07Qc>ML>8-oFTIVD;4lF9r2c8oU
z-t=er=C9WM><9wZnkDPdE1~gEPmOe)yBM{>>N9qDpxJb)nS~Yj`kxcKG;t^0@vX?b
z)T+F1N)%2Bu+KHtKE6ALM)OVzzWvhyZ5$L3V94zQrcl*jfD*8<8upO%twxlpo_2z1eE({Z>YXgQe
z2{JE=tWoL6f&}C1m~w}t3NwoeL-siDgwXtE#&ViW&SGqz#5kLb4{xl#WKnx1u{B$H
zXD=#LyJH2Va&h5Gqf9v%vOd|O(l<{rY*cYA87QFJc~j%2AJ3$8IW
z##%o|Qy=AJh*#ht((1JaIZeWS<@)lY0Y||tIcYU@^{s=o?~Vg?3nzv?M?M+EK45Ub
zz0OjG
z^xquvT^hqN`HH0Z5m?dS!tRd}ZAWmYc#ZH(SnKK$>&WD`py+)R^QRuOJF^VTMreUr^B@=!ob__?#6;on3hJX#2`rl%fWl<@)T^z#2TB8~5M#EqYDZpG7WQhOUxCS*IFSbutLG&8aH_ivxo{tp|<|F8dM
z^DeX2eTaXOzsS?UHWD8Q-@Z)mJZ_jtU4{&=XYle^f-x1lS4qT_q-dJ<+LvZ%n0mSg
z22^Tg3GNaZ;-P?^tofRoTlTeA0B%H9)^u!OnO&fA`@~MjE^HZ=;+!*oR(zcWegVF~
z!ZYrQIU?XpUGc!}MvKVVdyok?eq*=v@JbE_v#lS~z_i>UA;<+K_pCN=$i+oQKpA&v
zwpUiON|!vU6|B8mbo?Vo;N#BQzkd_|2e$vOFkPt=OGyu4+(y+JWX3xrk(g5N92O;9
z-wWe!b))UY?(2OM?-@3+zGnHF$(=P}!oY(Cg#>>){w{{1N3QekicyDi$HuLjRh^N%
z-mMvT#hHEBHa$lI-+6lifgVp2GdcZ_{?27d%-*V65?11&8vpQJni#&iLN}C0y9^4iELmBQ~Cg;?hdroexw2vhv_B-^or(enSwb>f%x@oyfL(^)4*@299l>SMR
z`%jhyX3KXb-;RE4(-C5q8RBSox^^de^n}hKkVoCa^7o-b#|1wU14elQ1Q&WTEx4woEzu=
zf0%&%%hh=8#p)A@ZGltDwGEsQ-4Y)pBhnovc@QMA$8;KSX3`1Of3mdx>lQ*eULTco
zB5$y3>685RYM5*e6DiZP2gZNy6%tZ5y?m6xmDWYID&=VeeZkN#OhV+NAH4F{_Z1>X
zi=h)8He4FaC%U7K407h*OqTu3JkI!DmRjPx1rzu{%5k+EQZ86(pL31d&Lj0Ng&Zt>>pZ9&(J~XaTJntU_hCBKO+hACLm*7TR27F5-Exx_4>n$7?56J;RbM&R-+s$X
z*C>^TjHSwQNb92H4y%{#1IhbE
z`1ip4w|%{i>b+FMZH8%P<=t%@Qe$#NX~7*US0bn4@@`4YdnzJop3JW26?KNU5JL08
zVQ{7S>f?9|K3GUfX32I@L!reBBgVP2Bu8)u?!6>gk^+B(Tl}7FcSb}_4hjM-&YFA%
zLXKWn_OD(haH`HwU`oDV>u^ccWJN}aN%Zb2P&YJ9*;IAW`p_>dA65o}`nWBZ*NhG6
z$3>L7E^i+jO++@E3^gC5pEL61QKlKC0PY(wcpj*`dMI52#wxGq1!s_%{dDYMt|3uR
z)j&Ff!!9;v-ackxbv*-C*%ZII!?XL>siaj22?-a8{QSJ4sO)Uce4!{&rkyX=UQkTu
z;f^F*?x@uArtkK=`A-%Y{n!*c{tdAHRw@W#acIF@YUKee?AL4m+djbmnb`kC;C~|U
UKN0w!2>ee3{y#=Q;OFrF0#*i%Bme*a
literal 0
HcmV?d00001
From f9f57e95059d233f7d77de4d84e6ac3b86ec184d Mon Sep 17 00:00:00 2001
From: shaw
Date: Mon, 13 Apr 2026 23:09:26 +0800
Subject: [PATCH 010/122] fix(migrations): add 097 to restore
settings.updated_at default
Legacy instances created the settings table via ent auto-migration,
which emits Go-level defaults only. Migration 005 uses CREATE TABLE
IF NOT EXISTS, so the missing SQL DEFAULT was never backfilled. This
caused 098's raw INSERT to fail with a NOT NULL violation on
updated_at. The new migration is idempotent and safe for fresh
installs (no-op) and historical instances (backfills the default).
---
.../097_fix_settings_updated_at_default.sql | 27 +++++++++++++++++++
1 file changed, 27 insertions(+)
create mode 100644 backend/migrations/097_fix_settings_updated_at_default.sql
diff --git a/backend/migrations/097_fix_settings_updated_at_default.sql b/backend/migrations/097_fix_settings_updated_at_default.sql
new file mode 100644
index 00000000..e1d6f9b9
--- /dev/null
+++ b/backend/migrations/097_fix_settings_updated_at_default.sql
@@ -0,0 +1,27 @@
+-- 097_fix_settings_updated_at_default.sql
+--
+-- 修复 settings.updated_at 列在历史实例上可能缺失 SQL DEFAULT 的问题。
+--
+-- 背景:
+-- 早期版本曾依赖 ent 自动迁移建表(ent 的 Default(time.Now) 仅是 Go 层默认值,
+-- 不会在 SQL 层落地为 DEFAULT),随后引入的 005_schema_parity.sql 使用了
+-- CREATE TABLE IF NOT EXISTS,对已存在的 settings 表不会重建,导致这部分实例
+-- 的 updated_at 列虽然是 NOT NULL,但缺少 SQL DEFAULT。
+--
+-- 后续 098_migrate_purchase_subscription_to_custom_menu.sql 是项目中唯一使用
+-- 原生 SQL INSERT INTO settings 的迁移(其余 settings 写入都走 ent / Go 层),
+-- 因此该 schema 缺陷直到 098 才会触发:
+-- "null value in column \"updated_at\" of relation \"settings\" violates not-null constraint"
+--
+-- 幂等性:
+-- - ALTER COLUMN ... SET DEFAULT NOW() 在已经具备相同默认值的实例上是无操作,
+-- 不会报错(PostgreSQL 允许重复设置相同的默认值)。
+-- - UPDATE 子句的 WHERE updated_at IS NULL 在健康实例上匹配 0 行,不影响数据。
+--
+-- 这样可以同时兼容:
+-- 1. 从未运行过旧版迁移的全新部署(005 已经把列建对,本迁移变成 no-op)。
+-- 2. 历史损坏实例(本迁移修复缺失的默认值,使后续 098 能够正常 INSERT)。
+
+ALTER TABLE settings ALTER COLUMN updated_at SET DEFAULT NOW();
+
+UPDATE settings SET updated_at = NOW() WHERE updated_at IS NULL;
From e534e9bae826b235adf1af2e0d89fd70024632a8 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Mon, 13 Apr 2026 15:24:14 +0000
Subject: [PATCH 011/122] chore: sync VERSION to 0.1.112 [skip ci]
---
backend/cmd/server/VERSION | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION
index 715965f3..4b9b35d8 100644
--- a/backend/cmd/server/VERSION
+++ b/backend/cmd/server/VERSION
@@ -1 +1 @@
-0.1.111
+0.1.112
From 1cd033e521b02e23fabf23628303663985b34114 Mon Sep 17 00:00:00 2001
From: erio
Date: Mon, 9 Mar 2026 19:24:19 +0800
Subject: [PATCH 012/122] style: apply gofmt formatting
Co-Authored-By: Claude Opus 4.6
---
backend/internal/repository/gateway_cache.go | 257 +++++++++++++++++-
.../service/admin_service_apikey_test.go | 72 +----
backend/internal/service/user_service_test.go | 9 +-
3 files changed, 257 insertions(+), 81 deletions(-)
diff --git a/backend/internal/repository/gateway_cache.go b/backend/internal/repository/gateway_cache.go
index 58291b66..ec4bf40e 100644
--- a/backend/internal/repository/gateway_cache.go
+++ b/backend/internal/repository/gateway_cache.go
@@ -2,14 +2,42 @@ package repository
import (
"context"
+ _ "embed"
"fmt"
+ "strconv"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/redis/go-redis/v9"
)
-const stickySessionPrefix = "sticky_session:"
+const (
+ stickySessionPrefix = "sticky_session:"
+ clientAffinityPrefix = "client_affinity:"
+ clientAffinityReversePrefix = "client_affinity_rev:"
+)
+
+var (
+ //go:embed lua/get_affinity.lua
+ getAffinityLua string
+ //go:embed lua/update_affinity.lua
+ updateAffinityLua string
+ //go:embed lua/get_affinity_count.lua
+ getAffinityCountLua string
+ //go:embed lua/get_affinity_clients.lua
+ getAffinityClientsLua string
+ //go:embed lua/get_affinity_clients_with_scores.lua
+ getAffinityClientsWithScoresLua string
+ //go:embed lua/clear_account_affinity.lua
+ clearAccountAffinityLua string
+
+ getAffinityScript = redis.NewScript(getAffinityLua)
+ updateAffinityScript = redis.NewScript(updateAffinityLua)
+ getAffinityCountScript = redis.NewScript(getAffinityCountLua)
+ getAffinityClientsScript = redis.NewScript(getAffinityClientsLua)
+ getAffinityClientsWithScoresScript = redis.NewScript(getAffinityClientsWithScoresLua)
+ clearAccountAffinityScript = redis.NewScript(clearAccountAffinityLua)
+)
type gatewayCache struct {
rdb *redis.Client
@@ -19,6 +47,16 @@ func NewGatewayCache(rdb *redis.Client) service.GatewayCache {
return &gatewayCache{rdb: rdb}
}
+// ensureScriptLoaded 确保 Lua 脚本已加载到 Redis 服务器的脚本缓存中。
+// Pipeline 中的 Script.Run 只发送 EVALSHA,如果 Redis 重启过导致脚本缓存丢失,
+// EVALSHA 会返回 NOSCRIPT 错误。此方法提前加载脚本以避免该问题。
+func ensureScriptLoaded(ctx context.Context, rdb *redis.Client, script *redis.Script) {
+ exists, err := script.Exists(ctx, rdb).Result()
+ if err != nil || len(exists) == 0 || !exists[0] {
+ _ = script.Load(ctx, rdb).Err()
+ }
+}
+
// buildSessionKey 构建 session key,包含 groupID 实现分组隔离
// 格式: sticky_session:{groupID}:{sessionHash}
func buildSessionKey(groupID int64, sessionHash string) string {
@@ -41,13 +79,218 @@ func (c *gatewayCache) RefreshSessionTTL(ctx context.Context, groupID int64, ses
}
// DeleteSessionAccountID 删除粘性会话与账号的绑定关系。
-// 当检测到绑定的账号不可用(如状态错误、禁用、不可调度等)时调用,
-// 以便下次请求能够重新选择可用账号。
-//
-// DeleteSessionAccountID removes the sticky session binding for the given session.
-// Called when the bound account becomes unavailable (e.g., error status, disabled,
-// or unschedulable), allowing subsequent requests to select a new available account.
func (c *gatewayCache) DeleteSessionAccountID(ctx context.Context, groupID int64, sessionHash string) error {
key := buildSessionKey(groupID, sessionHash)
return c.rdb.Del(ctx, key).Err()
}
+
+// buildAffinityKey 构建正向亲和 key(client → accounts)
+// 格式: client_affinity:{groupID}:{clientID}
+func buildAffinityKey(groupID int64, clientID string) string {
+ return fmt.Sprintf("%s%d:%s", clientAffinityPrefix, groupID, clientID)
+}
+
+// buildAffinityReverseKey 构建反向亲和 key(account → clients)
+// 格式: client_affinity_rev:{groupID}:{accountID}
+func buildAffinityReverseKey(groupID int64, accountID int64) string {
+ return fmt.Sprintf("%s%d:%d", clientAffinityReversePrefix, groupID, accountID)
+}
+
+func (c *gatewayCache) GetClientAffinityAccounts(ctx context.Context, groupID int64, clientID string, ttl time.Duration) ([]int64, error) {
+ key := buildAffinityKey(groupID, clientID)
+ now := time.Now().Unix()
+ expireThreshold := now - int64(ttl.Seconds())
+
+ result, err := getAffinityScript.Run(ctx, c.rdb, []string{key}, expireThreshold).StringSlice()
+ if err != nil {
+ if err == redis.Nil {
+ return nil, nil
+ }
+ return nil, err
+ }
+
+ accountIDs := make([]int64, 0, len(result))
+ for _, s := range result {
+ id, err := strconv.ParseInt(s, 10, 64)
+ if err != nil {
+ continue
+ }
+ accountIDs = append(accountIDs, id)
+ }
+ return accountIDs, nil
+}
+
+func (c *gatewayCache) UpdateClientAffinity(ctx context.Context, groupID int64, clientID string, accountID int64, ttl time.Duration) error {
+ fwdKey := buildAffinityKey(groupID, clientID)
+ revKey := buildAffinityReverseKey(groupID, accountID)
+ now := time.Now().Unix()
+ ttlSeconds := int64(ttl.Seconds())
+ expireThreshold := now - ttlSeconds
+
+ return updateAffinityScript.Run(ctx, c.rdb, []string{fwdKey, revKey},
+ now, ttlSeconds, accountID, expireThreshold, clientID,
+ ).Err()
+}
+
+// GetAccountAffinityCountBatch 批量获取账号的亲和客户端数量(惰性清理过期成员)
+func (c *gatewayCache) GetAccountAffinityCountBatch(ctx context.Context, groupID int64, accountIDs []int64, ttl time.Duration) (map[int64]int64, error) {
+ if len(accountIDs) == 0 {
+ return map[int64]int64{}, nil
+ }
+
+ now := time.Now().Unix()
+ expireThreshold := now - int64(ttl.Seconds())
+
+ ensureScriptLoaded(ctx, c.rdb, getAffinityCountScript)
+
+ pipe := c.rdb.Pipeline()
+ cmds := make([]*redis.Cmd, len(accountIDs))
+ for i, accID := range accountIDs {
+ key := buildAffinityReverseKey(groupID, accID)
+ cmds[i] = getAffinityCountScript.Run(ctx, pipe, []string{key}, expireThreshold)
+ }
+ _, err := pipe.Exec(ctx)
+ if err != nil && err != redis.Nil {
+ return nil, err
+ }
+
+ result := make(map[int64]int64, len(accountIDs))
+ for i, accID := range accountIDs {
+ count, _ := cmds[i].Int64()
+ result[accID] = count
+ }
+ return result, nil
+}
+
+// GetAccountAffinityClientsBatch 批量获取每个账号跨所有分组的亲和客户端列表(去重)。
+// accountGroups: map[accountID][]groupID,对每个 (groupID, accountID) 组合查询反向索引。
+func (c *gatewayCache) GetAccountAffinityClientsBatch(ctx context.Context, accountGroups map[int64][]int64, ttl time.Duration) (map[int64][]string, error) {
+ if len(accountGroups) == 0 {
+ return map[int64][]string{}, nil
+ }
+
+ now := time.Now().Unix()
+ expireThreshold := now - int64(ttl.Seconds())
+
+ // 构建所有 (accountID, groupID) 组合的查询
+ type queryItem struct {
+ accountID int64
+ groupID int64
+ }
+ var queries []queryItem
+ for accID, groupIDs := range accountGroups {
+ for _, gID := range groupIDs {
+ queries = append(queries, queryItem{accountID: accID, groupID: gID})
+ }
+ }
+
+ ensureScriptLoaded(ctx, c.rdb, getAffinityClientsScript)
+
+ pipe := c.rdb.Pipeline()
+ cmds := make([]*redis.Cmd, len(queries))
+ for i, q := range queries {
+ key := buildAffinityReverseKey(q.groupID, q.accountID)
+ cmds[i] = getAffinityClientsScript.Run(ctx, pipe, []string{key}, expireThreshold)
+ }
+ _, err := pipe.Exec(ctx)
+ if err != nil && err != redis.Nil {
+ return nil, err
+ }
+
+ // 合并结果:同一个 accountID 跨多个 group 的 clientID 去重
+ result := make(map[int64][]string, len(accountGroups))
+ seen := make(map[int64]map[string]struct{}, len(accountGroups))
+ for i, q := range queries {
+ clients, _ := cmds[i].StringSlice()
+ if len(clients) == 0 {
+ continue
+ }
+ if seen[q.accountID] == nil {
+ seen[q.accountID] = make(map[string]struct{})
+ }
+ for _, clientID := range clients {
+ if _, exists := seen[q.accountID][clientID]; !exists {
+ seen[q.accountID][clientID] = struct{}{}
+ result[q.accountID] = append(result[q.accountID], clientID)
+ }
+ }
+ }
+ return result, nil
+}
+
+// GetAccountAffinityClientsWithScores 获取单个账号跨所有分组的亲和客户端列表(含最后活跃时间戳,去重取最近)。
+func (c *gatewayCache) GetAccountAffinityClientsWithScores(
+ ctx context.Context,
+ accountID int64,
+ groupIDs []int64,
+ ttl time.Duration,
+) ([]service.AffinityClient, error) {
+ if len(groupIDs) == 0 {
+ return nil, nil
+ }
+
+ now := time.Now().Unix()
+ expireThreshold := now - int64(ttl.Seconds())
+
+ ensureScriptLoaded(ctx, c.rdb, getAffinityClientsWithScoresScript)
+
+ pipe := c.rdb.Pipeline()
+ cmds := make([]*redis.Cmd, len(groupIDs))
+ for i, gID := range groupIDs {
+ key := buildAffinityReverseKey(gID, accountID)
+ cmds[i] = getAffinityClientsWithScoresScript.Run(ctx, pipe, []string{key}, expireThreshold)
+ }
+ _, err := pipe.Exec(ctx)
+ if err != nil && err != redis.Nil {
+ return nil, err
+ }
+
+ // 合并跨组结果,同一 clientID 取最近的 lastActive
+ seen := make(map[string]int64) // clientID → max timestamp
+ for _, cmd := range cmds {
+ vals, _ := cmd.StringSlice()
+ // vals 格式: [clientID1, score1, clientID2, score2, ...]
+ for j := 0; j+1 < len(vals); j += 2 {
+ clientID := vals[j]
+ ts, _ := strconv.ParseInt(vals[j+1], 10, 64)
+ if existing, ok := seen[clientID]; !ok || ts > existing {
+ seen[clientID] = ts
+ }
+ }
+ }
+
+ result := make([]service.AffinityClient, 0, len(seen))
+ for clientID, ts := range seen {
+ result = append(result, service.AffinityClient{
+ ClientID: clientID,
+ LastActive: time.Unix(ts, 0),
+ })
+ }
+
+ // 按最后活跃时间降序排序
+ service.SortAffinityClients(result)
+
+ return result, nil
+}
+
+// ClearAccountAffinity 清除指定账号在所有分组的亲和记录(正向+反向索引)。
+// 对每个 groupID 执行 Lua 脚本:读取反向索引获取所有客户端,
+// 从每个客户端的正向索引中移除该账号,然后删除反向索引。
+func (c *gatewayCache) ClearAccountAffinity(ctx context.Context, accountID int64, groupIDs []int64) error {
+ if len(groupIDs) == 0 {
+ return nil
+ }
+
+ ensureScriptLoaded(ctx, c.rdb, clearAccountAffinityScript)
+
+ pipe := c.rdb.Pipeline()
+ for _, gID := range groupIDs {
+ revKey := buildAffinityReverseKey(gID, accountID)
+ clearAccountAffinityScript.Run(ctx, pipe, []string{revKey}, gID, accountID)
+ }
+ _, err := pipe.Exec(ctx)
+ if err != nil && err != redis.Nil {
+ return err
+ }
+ return nil
+}
diff --git a/backend/internal/service/admin_service_apikey_test.go b/backend/internal/service/admin_service_apikey_test.go
index f9fd6742..5c18a438 100644
--- a/backend/internal/service/admin_service_apikey_test.go
+++ b/backend/internal/service/admin_service_apikey_test.go
@@ -65,9 +65,6 @@ func (s *userRepoStubForGroupUpdate) ExistsByEmail(context.Context, string) (boo
func (s *userRepoStubForGroupUpdate) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) {
panic("unexpected")
}
-func (s *userRepoStubForGroupUpdate) RemoveGroupFromUserAllowedGroups(context.Context, int64, int64) error {
- panic("unexpected")
-}
func (s *userRepoStubForGroupUpdate) UpdateTotpSecret(context.Context, int64, *string) error {
panic("unexpected")
}
@@ -131,9 +128,6 @@ func (s *apiKeyRepoStubForGroupUpdate) SearchAPIKeys(context.Context, int64, str
func (s *apiKeyRepoStubForGroupUpdate) ClearGroupIDByGroupID(context.Context, int64) (int64, error) {
panic("unexpected")
}
-func (s *apiKeyRepoStubForGroupUpdate) UpdateGroupIDByUserAndGroup(context.Context, int64, int64, int64) (int64, error) {
- panic("unexpected")
-}
func (s *apiKeyRepoStubForGroupUpdate) CountByGroupID(context.Context, int64) (int64, error) {
panic("unexpected")
}
@@ -200,7 +194,7 @@ func (s *groupRepoStubForGroupUpdate) ListActiveByPlatform(context.Context, stri
func (s *groupRepoStubForGroupUpdate) ExistsByName(context.Context, string) (bool, error) {
panic("unexpected")
}
-func (s *groupRepoStubForGroupUpdate) GetAccountCount(context.Context, int64) (int64, int64, error) {
+func (s *groupRepoStubForGroupUpdate) GetAccountCount(context.Context, int64) (int64, error) {
panic("unexpected")
}
func (s *groupRepoStubForGroupUpdate) DeleteAccountGroupsByGroupID(context.Context, int64) (int64, error) {
@@ -216,29 +210,6 @@ func (s *groupRepoStubForGroupUpdate) UpdateSortOrders(context.Context, []GroupS
panic("unexpected")
}
-type userSubRepoStubForGroupUpdate struct {
- userSubRepoNoop
- getActiveSub *UserSubscription
- getActiveErr error
- called bool
- calledUserID int64
- calledGroupID int64
-}
-
-func (s *userSubRepoStubForGroupUpdate) GetActiveByUserIDAndGroupID(_ context.Context, userID, groupID int64) (*UserSubscription, error) {
- s.called = true
- s.calledUserID = userID
- s.calledGroupID = groupID
- if s.getActiveErr != nil {
- return nil, s.getActiveErr
- }
- if s.getActiveSub == nil {
- return nil, ErrSubscriptionNotFound
- }
- clone := *s.getActiveSub
- return &clone, nil
-}
-
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -431,49 +402,14 @@ func TestAdminService_AdminUpdateAPIKeyGroupID_NonExclusiveGroup_NoAllowedGroupU
func TestAdminService_AdminUpdateAPIKeyGroupID_SubscriptionGroup_Blocked(t *testing.T) {
existing := &APIKey{ID: 1, UserID: 42, Key: "sk-test", GroupID: nil}
apiKeyRepo := &apiKeyRepoStubForGroupUpdate{key: existing}
- groupRepo := &groupRepoStubForGroupUpdate{group: &Group{ID: 10, Name: "Sub", Status: StatusActive, IsExclusive: false, SubscriptionType: SubscriptionTypeSubscription}}
- userRepo := &userRepoStubForGroupUpdate{}
- userSubRepo := &userSubRepoStubForGroupUpdate{getActiveErr: ErrSubscriptionNotFound}
- svc := &adminServiceImpl{apiKeyRepo: apiKeyRepo, groupRepo: groupRepo, userRepo: userRepo, userSubRepo: userSubRepo}
-
- // 无有效订阅时应拒绝绑定
- _, err := svc.AdminUpdateAPIKeyGroupID(context.Background(), 1, int64Ptr(10))
- require.Error(t, err)
- require.Equal(t, "SUBSCRIPTION_REQUIRED", infraerrors.Reason(err))
- require.True(t, userSubRepo.called)
- require.Equal(t, int64(42), userSubRepo.calledUserID)
- require.Equal(t, int64(10), userSubRepo.calledGroupID)
- require.False(t, userRepo.addGroupCalled)
-}
-
-func TestAdminService_AdminUpdateAPIKeyGroupID_SubscriptionGroup_RequiresRepo(t *testing.T) {
- existing := &APIKey{ID: 1, UserID: 42, Key: "sk-test", GroupID: nil}
- apiKeyRepo := &apiKeyRepoStubForGroupUpdate{key: existing}
- groupRepo := &groupRepoStubForGroupUpdate{group: &Group{ID: 10, Name: "Sub", Status: StatusActive, IsExclusive: false, SubscriptionType: SubscriptionTypeSubscription}}
+ groupRepo := &groupRepoStubForGroupUpdate{group: &Group{ID: 10, Name: "Sub", Status: StatusActive, IsExclusive: true, SubscriptionType: SubscriptionTypeSubscription}}
userRepo := &userRepoStubForGroupUpdate{}
svc := &adminServiceImpl{apiKeyRepo: apiKeyRepo, groupRepo: groupRepo, userRepo: userRepo}
+ // 订阅类型分组应被阻止绑定
_, err := svc.AdminUpdateAPIKeyGroupID(context.Background(), 1, int64Ptr(10))
require.Error(t, err)
- require.Equal(t, "SUBSCRIPTION_REPOSITORY_UNAVAILABLE", infraerrors.Reason(err))
- require.False(t, userRepo.addGroupCalled)
-}
-
-func TestAdminService_AdminUpdateAPIKeyGroupID_SubscriptionGroup_AllowsActiveSubscription(t *testing.T) {
- existing := &APIKey{ID: 1, UserID: 42, Key: "sk-test", GroupID: nil}
- apiKeyRepo := &apiKeyRepoStubForGroupUpdate{key: existing}
- groupRepo := &groupRepoStubForGroupUpdate{group: &Group{ID: 10, Name: "Sub", Status: StatusActive, IsExclusive: true, SubscriptionType: SubscriptionTypeSubscription}}
- userRepo := &userRepoStubForGroupUpdate{}
- userSubRepo := &userSubRepoStubForGroupUpdate{
- getActiveSub: &UserSubscription{ID: 99, UserID: 42, GroupID: 10},
- }
- svc := &adminServiceImpl{apiKeyRepo: apiKeyRepo, groupRepo: groupRepo, userRepo: userRepo, userSubRepo: userSubRepo}
-
- got, err := svc.AdminUpdateAPIKeyGroupID(context.Background(), 1, int64Ptr(10))
- require.NoError(t, err)
- require.True(t, userSubRepo.called)
- require.NotNil(t, got.APIKey.GroupID)
- require.Equal(t, int64(10), *got.APIKey.GroupID)
+ require.Equal(t, "SUBSCRIPTION_GROUP_NOT_ALLOWED", infraerrors.Reason(err))
require.False(t, userRepo.addGroupCalled)
}
diff --git a/backend/internal/service/user_service_test.go b/backend/internal/service/user_service_test.go
index e88694f5..7f6c748f 100644
--- a/backend/internal/service/user_service_test.go
+++ b/backend/internal/service/user_service_test.go
@@ -46,12 +46,9 @@ func (m *mockUserRepo) RemoveGroupFromAllowedGroups(context.Context, int64) (int
return 0, nil
}
func (m *mockUserRepo) AddGroupToAllowedGroups(context.Context, int64, int64) error { return nil }
-func (m *mockUserRepo) RemoveGroupFromUserAllowedGroups(context.Context, int64, int64) error {
- return nil
-}
-func (m *mockUserRepo) UpdateTotpSecret(context.Context, int64, *string) error { return nil }
-func (m *mockUserRepo) EnableTotp(context.Context, int64) error { return nil }
-func (m *mockUserRepo) DisableTotp(context.Context, int64) error { return nil }
+func (m *mockUserRepo) UpdateTotpSecret(context.Context, int64, *string) error { return nil }
+func (m *mockUserRepo) EnableTotp(context.Context, int64) error { return nil }
+func (m *mockUserRepo) DisableTotp(context.Context, int64) error { return nil }
// --- mock: APIKeyAuthCacheInvalidator ---
From 3de77130175138146981d2e8d34ec8eb19a614b9 Mon Sep 17 00:00:00 2001
From: erio
Date: Mon, 30 Mar 2026 21:47:06 +0800
Subject: [PATCH 013/122] =?UTF-8?q?fix(channel):=20splice=E6=9B=BF?=
=?UTF-8?q?=E6=8D=A2model=5Fpricing=E6=9D=A1=E7=9B=AE=20+=20=E5=A2=9E?=
=?UTF-8?q?=E5=BC=BA=E8=B0=83=E8=AF=95=E6=97=A5=E5=BF=97?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/cmd/server/VERSION | 2 +-
frontend/src/views/admin/ChannelsView.vue | 1 +
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION
index 4b9b35d8..1ebb081e 100644
--- a/backend/cmd/server/VERSION
+++ b/backend/cmd/server/VERSION
@@ -1 +1 @@
-0.1.112
+0.1.105.13
diff --git a/frontend/src/views/admin/ChannelsView.vue b/frontend/src/views/admin/ChannelsView.vue
index ebfc1b5a..5c2f153b 100644
--- a/frontend/src/views/admin/ChannelsView.vue
+++ b/frontend/src/views/admin/ChannelsView.vue
@@ -970,6 +970,7 @@ async function handleSubmit() {
}
const { group_ids, model_pricing, model_mapping } = formToAPI()
+ console.log('[handleSubmit] model_pricing to send:', JSON.stringify(model_pricing))
submitting.value = true
try {
From 2dce4306b4409e355f6ff265fa30ae7a2d3a6221 Mon Sep 17 00:00:00 2001
From: erio
Date: Thu, 2 Apr 2026 13:24:30 +0800
Subject: [PATCH 014/122] refactor: move channel model restriction from handler
to scheduling phase
Move the model pricing restriction check from 8 handler entry points
to the account scheduling phase (SelectAccountForModelWithExclusions /
SelectAccountWithLoadAwareness), aligning restriction with billing:
- requested: check original request model against pricing list
- channel_mapped: check channel-mapped model against pricing list
- upstream: per-account check using account-mapped model
Handler layer now only resolves channel mapping (no restriction).
Scheduling layer performs pre-check for requested/channel_mapped,
and per-account filtering for upstream billing source.
---
.../gateway_handler_chat_completions.go | 2 +-
.../handler/gateway_handler_responses.go | 2 +-
.../internal/handler/gemini_v1beta_handler.go | 14 +-
.../handler/openai_chat_completions.go | 2 +-
.../handler/openai_gateway_handler.go | 46 +-
backend/internal/service/gateway_service.go | 1202 +++++++++++------
6 files changed, 793 insertions(+), 475 deletions(-)
diff --git a/backend/internal/handler/gateway_handler_chat_completions.go b/backend/internal/handler/gateway_handler_chat_completions.go
index be267332..abe2a1e5 100644
--- a/backend/internal/handler/gateway_handler_chat_completions.go
+++ b/backend/internal/handler/gateway_handler_chat_completions.go
@@ -80,7 +80,7 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
setOpsRequestContext(c, reqModel, reqStream, body)
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false)))
- // 解析渠道级模型映射
+ // 解析渠道级模型映射 + 限制检查
channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel)
// Claude Code only restriction
diff --git a/backend/internal/handler/gateway_handler_responses.go b/backend/internal/handler/gateway_handler_responses.go
index e908eb9e..cf877182 100644
--- a/backend/internal/handler/gateway_handler_responses.go
+++ b/backend/internal/handler/gateway_handler_responses.go
@@ -80,7 +80,7 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
setOpsRequestContext(c, reqModel, reqStream, body)
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false)))
- // 解析渠道级模型映射
+ // 解析渠道级模型映射 + 限制检查
channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel)
// Claude Code only restriction:
diff --git a/backend/internal/handler/gemini_v1beta_handler.go b/backend/internal/handler/gemini_v1beta_handler.go
index d200c17c..ff63bc7f 100644
--- a/backend/internal/handler/gemini_v1beta_handler.go
+++ b/backend/internal/handler/gemini_v1beta_handler.go
@@ -121,7 +121,7 @@ func (h *GatewayHandler) GeminiV1BetaGetModel(c *gin.Context) {
googleError(c, http.StatusBadGateway, err.Error())
return
}
- if shouldFallbackGeminiModel(modelName, res) {
+ if shouldFallbackGeminiModels(res) {
c.JSON(http.StatusOK, gemini.FallbackModel(modelName))
return
}
@@ -184,7 +184,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
setOpsRequestContext(c, modelName, stream, body)
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(stream, false)))
- // 解析渠道级模型映射
+ // 解析渠道级模型映射 + 限制检查
channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, modelName)
reqModel := modelName // 保存映射前的原始模型名
if channelMapping.Mapped {
@@ -682,16 +682,6 @@ func shouldFallbackGeminiModels(res *service.UpstreamHTTPResult) bool {
return false
}
-func shouldFallbackGeminiModel(modelName string, res *service.UpstreamHTTPResult) bool {
- if shouldFallbackGeminiModels(res) {
- return true
- }
- if res == nil || res.StatusCode != http.StatusNotFound {
- return false
- }
- return gemini.HasFallbackModel(modelName)
-}
-
// extractGeminiCLISessionHash 从 Gemini CLI 请求中提取会话标识。
// 组合 x-gemini-api-privileged-user-id header 和请求体中的 tmp 目录哈希。
//
diff --git a/backend/internal/handler/openai_chat_completions.go b/backend/internal/handler/openai_chat_completions.go
index 991cbb91..ada401c9 100644
--- a/backend/internal/handler/openai_chat_completions.go
+++ b/backend/internal/handler/openai_chat_completions.go
@@ -79,7 +79,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
setOpsRequestContext(c, reqModel, reqStream, body)
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false)))
- // 解析渠道级模型映射
+ // 解析渠道级模型映射 + 限制检查
channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel)
if h.errorPassthroughService != nil {
diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go
index 5319b55d..2b081617 100644
--- a/backend/internal/handler/openai_gateway_handler.go
+++ b/backend/internal/handler/openai_gateway_handler.go
@@ -47,13 +47,6 @@ func resolveOpenAIForwardDefaultMappedModel(apiKey *service.APIKey, fallbackMode
return strings.TrimSpace(apiKey.Group.DefaultMappedModel)
}
-func resolveOpenAIMessagesDispatchMappedModel(apiKey *service.APIKey, requestedModel string) string {
- if apiKey == nil || apiKey.Group == nil {
- return ""
- }
- return strings.TrimSpace(apiKey.Group.ResolveMessagesDispatchModel(requestedModel))
-}
-
// NewOpenAIGatewayHandler creates a new OpenAIGatewayHandler
func NewOpenAIGatewayHandler(
gatewayService *service.OpenAIGatewayService,
@@ -557,8 +550,6 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
return
}
reqModel := modelResult.String()
- routingModel := service.NormalizeOpenAICompatRequestedModel(reqModel)
- preferredMappedModel := resolveOpenAIMessagesDispatchMappedModel(apiKey, reqModel)
reqStream := gjson.GetBytes(body, "stream").Bool()
reqLog = reqLog.With(zap.String("model", reqModel), zap.Bool("stream", reqStream))
@@ -617,20 +608,17 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
failedAccountIDs := make(map[int64]struct{})
sameAccountRetryCount := make(map[int64]int)
var lastFailoverErr *service.UpstreamFailoverError
- effectiveMappedModel := preferredMappedModel
for {
- currentRoutingModel := routingModel
- if effectiveMappedModel != "" {
- currentRoutingModel = effectiveMappedModel
- }
+ // 清除上一次迭代的降级模型标记,避免残留影响本次迭代
+ c.Set("openai_messages_fallback_model", "")
reqLog.Debug("openai_messages.account_selecting", zap.Int("excluded_account_count", len(failedAccountIDs)))
selection, scheduleDecision, err := h.gatewayService.SelectAccountWithScheduler(
c.Request.Context(),
apiKey.GroupID,
"", // no previous_response_id
sessionHash,
- currentRoutingModel,
+ reqModel,
failedAccountIDs,
service.OpenAIUpstreamTransportAny,
)
@@ -639,7 +627,29 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
zap.Error(err),
zap.Int("excluded_account_count", len(failedAccountIDs)),
)
+ // 首次调度失败 + 有默认映射模型 → 用默认模型重试
if len(failedAccountIDs) == 0 {
+ defaultModel := ""
+ if apiKey.Group != nil {
+ defaultModel = apiKey.Group.DefaultMappedModel
+ }
+ if defaultModel != "" && defaultModel != reqModel {
+ reqLog.Info("openai_messages.fallback_to_default_model",
+ zap.String("default_mapped_model", defaultModel),
+ )
+ selection, scheduleDecision, err = h.gatewayService.SelectAccountWithScheduler(
+ c.Request.Context(),
+ apiKey.GroupID,
+ "",
+ sessionHash,
+ defaultModel,
+ failedAccountIDs,
+ service.OpenAIUpstreamTransportAny,
+ )
+ if err == nil && selection != nil {
+ c.Set("openai_messages_fallback_model", defaultModel)
+ }
+ }
if err != nil {
h.anthropicStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "Service temporarily unavailable", streamStarted)
return
@@ -671,7 +681,9 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
service.SetOpsLatencyMs(c, service.OpsRoutingLatencyMsKey, time.Since(routingStart).Milliseconds())
forwardStart := time.Now()
- defaultMappedModel := strings.TrimSpace(effectiveMappedModel)
+ // Forward 层需要始终拿到 group 默认映射模型,这样未命中账号级映射的
+ // Claude 兼容模型才不会在后续 Codex 规范化中意外退化到 gpt-5.1。
+ defaultMappedModel := resolveOpenAIForwardDefaultMappedModel(apiKey, c.GetString("openai_messages_fallback_model"))
// 应用渠道模型映射到请求体
forwardBody := body
if channelMappingMsg.Mapped {
@@ -1106,7 +1118,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
setOpsRequestContext(c, reqModel, true, firstMessage)
setOpsEndpointContext(c, "", int16(service.RequestTypeWSV2))
- // 解析渠道级模型映射
+ // 解析渠道级模型映射 + 限制检查
channelMappingWS, _ := h.gatewayService.ResolveChannelMappingAndRestrict(ctx, apiKey.GroupID, reqModel)
var currentUserRelease func()
diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go
index 8b0bdc2a..33ab38f2 100644
--- a/backend/internal/service/gateway_service.go
+++ b/backend/internal/service/gateway_service.go
@@ -12,7 +12,6 @@ import (
"log/slog"
mathrand "math/rand"
"net/http"
- "net/url"
"os"
"path/filepath"
"regexp"
@@ -42,7 +41,8 @@ import (
const (
claudeAPIURL = "https://api.anthropic.com/v1/messages?beta=true"
claudeAPICountTokensURL = "https://api.anthropic.com/v1/messages/count_tokens?beta=true"
- stickySessionTTL = time.Hour // 粘性会话TTL
+ stickySessionTTL = time.Hour // 粘性会话TTL
+ ClientAffinityTTL = 24 * time.Hour // 客户端亲和TTL
defaultMaxLineSize = 500 * 1024 * 1024
// Canonical Claude Code banner. Keep it EXACT (no trailing whitespace/newlines)
// to match real Claude CLI traffic as closely as possible. When we need a visual
@@ -60,14 +60,28 @@ const (
claudeMimicDebugInfoKey = "claude_mimic_debug_info"
)
+// MediaType 媒体类型常量
+const (
+ MediaTypeImage = "image"
+ MediaTypeVideo = "video"
+ MediaTypePrompt = "prompt"
+)
+
+const (
+ claudeMaxMessageOverheadTokens = 3
+ claudeMaxBlockOverheadTokens = 1
+ claudeMaxUnknownContentTokens = 4
+)
+
// ForceCacheBillingContextKey 强制缓存计费上下文键
// 用于粘性会话切换时,将 input_tokens 转为 cache_read_input_tokens 计费
type forceCacheBillingKeyType struct{}
// accountWithLoad 账号与负载信息的组合,用于负载感知调度
type accountWithLoad struct {
- account *Account
- loadInfo *AccountLoadInfo
+ account *Account
+ loadInfo *AccountLoadInfo
+ affinityCount int64 // 亲和客户端数量(反向索引),越少越优先
}
var ForceCacheBillingContextKey = forceCacheBillingKeyType{}
@@ -331,6 +345,10 @@ var (
sseDataRe = regexp.MustCompile(`^data:\s*`)
claudeCliUserAgentRe = regexp.MustCompile(`^claude-cli/\d+\.\d+\.\d+`)
+ // clientIDFromMetadataRegex 从 metadata.user_id 中提取客户端 ID(64位 hex)
+ // 格式: user_{64位hex}_account_...
+ clientIDFromMetadataRegex = regexp.MustCompile(`^user_([a-f0-9]{64})_account_`)
+
// claudeCodePromptPrefixes 用于检测 Claude Code 系统提示词的前缀列表
// 支持多种变体:标准版、Agent SDK 版、Explore Agent 版、Compact 版等
// 注意:前缀之间不应存在包含关系,否则会导致冗余匹配
@@ -348,6 +366,12 @@ var ErrNoAvailableAccounts = errors.New("no available accounts")
// ErrClaudeCodeOnly 表示分组仅允许 Claude Code 客户端访问
var ErrClaudeCodeOnly = errors.New("this group only allows Claude Code clients")
+// ErrAffinityNoSwitch 表示亲和账号不可用且不允许切换到其他账号
+var ErrAffinityNoSwitch = errors.New("affinity account unavailable and switching is disabled")
+
+// ErrAffinityLimitExceeded 表示亲和客户端限制已达上限
+var ErrAffinityLimitExceeded = errors.New("affinity client limit exceeded")
+
// allowedHeaders 白名单headers(参考CRS项目)
var allowedHeaders = map[string]bool{
"accept": true,
@@ -369,8 +393,6 @@ var allowedHeaders = map[string]bool{
"user-agent": true,
"content-type": true,
"accept-encoding": true,
- "x-claude-code-session-id": true,
- "x-client-request-id": true,
}
// GatewayCache 定义网关服务的缓存操作接口。
@@ -391,6 +413,39 @@ type GatewayCache interface {
// DeleteSessionAccountID 删除粘性会话绑定,用于账号不可用时主动清理
// Delete sticky session binding, used to proactively clean up when account becomes unavailable
DeleteSessionAccountID(ctx context.Context, groupID int64, sessionHash string) error
+
+ // GetAffinityAccounts 获取亲和账号列表(按最近使用降序),同时清理过期成员
+ GetAffinityAccounts(ctx context.Context, groupID int64, userID int64, clientID string, ttl time.Duration) ([]int64, error)
+ // UpdateAffinity 添加/更新亲和关系(更新 score 为当前时间戳,刷新 key TTL)
+ UpdateAffinity(ctx context.Context, groupID int64, userID int64, clientID string, accountID int64, ttl time.Duration) error
+ // GetAccountAffinityCountBatch 批量获取账号的亲和成员数量(惰性清理过期成员)
+ GetAccountAffinityCountBatch(ctx context.Context, groupID int64, accountIDs []int64, ttl time.Duration) (map[int64]int64, error)
+ // GetAccountAffinityClientsBatch 批量获取每个账号跨所有分组的亲和成员列表(去重)
+ // accountGroups: map[accountID][]groupID
+ // 返回值成员格式为 {userID}/{clientID}
+ GetAccountAffinityClientsBatch(ctx context.Context, accountGroups map[int64][]int64, ttl time.Duration) (map[int64][]string, error)
+ // GetAccountAffinityClientsWithScores 获取单个账号跨所有分组的亲和客户端列表(含最后活跃时间)
+ GetAccountAffinityClientsWithScores(ctx context.Context, accountID int64, groupIDs []int64, ttl time.Duration) ([]AffinityClient, error)
+ // ClearAccountAffinity 清除指定账号在所有分组的亲和记录(正向+反向索引)
+ // 用于账号关闭亲和时立即清理旧绑定
+ ClearAccountAffinity(ctx context.Context, accountID int64, groupIDs []int64) error
+ // GetAffinityMultiCount 获取账号的多维度亲和计数
+ // 返回: uniqueUsers, uniqueClients, perUserClients
+ GetAffinityMultiCount(ctx context.Context, groupID int64, accountID int64, targetUserID int64, ttl time.Duration) (users, clients, perUser int64, err error)
+}
+
+// AffinityClient 亲和客户端信息(含用户 ID 和最后活跃时间)
+type AffinityClient struct {
+ UserID int64 `json:"user_id"`
+ ClientID string `json:"client_id"`
+ LastActive time.Time `json:"last_active"`
+}
+
+// SortAffinityClients 按最后活跃时间降序排序
+func SortAffinityClients(clients []AffinityClient) {
+ sort.Slice(clients, func(i, j int) bool {
+ return clients[i].LastActive.After(clients[j].LastActive)
+ })
}
// derefGroupID safely dereferences *int64 to int64, returning 0 if nil
@@ -461,6 +516,20 @@ func shouldClearStickySession(account *Account, requestedModel string) bool {
return false
}
+// extractClientIDFromMetadata 从 metadata.user_id 中提取客户端 ID(64位 hex)。
+// 格式: user_{64位hex}_account_..._session_...
+// 返回空字符串表示无法提取(非 Claude Code/Console 客户端)。
+func extractClientIDFromMetadata(metadataUserID string) string {
+ if metadataUserID == "" {
+ return ""
+ }
+ matches := clientIDFromMetadataRegex.FindStringSubmatch(metadataUserID)
+ if matches == nil {
+ return ""
+ }
+ return matches[1]
+}
+
type AccountWaitPlan struct {
AccountID int64
MaxConcurrency int
@@ -504,6 +573,9 @@ type ForwardResult struct {
ImageCount int // 生成的图片数量
ImageSize string // 图片尺寸 "1K", "2K", "4K"
+ // Sora 媒体字段
+ MediaType string // image / video / prompt
+ MediaURL string // 生成后的媒体地址(可选)
}
// UpstreamFailoverError indicates an upstream error that should trigger account failover.
@@ -1162,6 +1234,11 @@ func (s *GatewayService) SelectAccountForModel(ctx context.Context, groupID *int
// SelectAccountForModelWithExclusions selects an account supporting the requested model while excluding specified accounts.
func (s *GatewayService) SelectAccountForModelWithExclusions(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}) (*Account, error) {
+ // 渠道定价限制预检查(requested / channel_mapped 基准)
+ if s.checkChannelPricingRestriction(ctx, groupID, requestedModel) {
+ return nil, fmt.Errorf("%w supporting model: %s (channel pricing restriction)", ErrNoAvailableAccounts, requestedModel)
+ }
+
// 优先检查 context 中的强制平台(/antigravity 路由)
var platform string
forcePlatform, hasForcePlatform := ctx.Value(ctxkey.ForcePlatform).(string)
@@ -1180,32 +1257,15 @@ func (s *GatewayService) SelectAccountForModelWithExclusions(ctx context.Context
platform = PlatformAnthropic
}
- // Claude Code 限制可能已将 groupID 解析为 fallback group,
- // 渠道限制预检查必须使用解析后的分组。
- if s.checkChannelPricingRestriction(ctx, groupID, requestedModel) {
- slog.Warn("channel pricing restriction blocked request",
- "group_id", derefGroupID(groupID),
- "model", requestedModel)
- return nil, fmt.Errorf("%w supporting model: %s (channel pricing restriction)", ErrNoAvailableAccounts, requestedModel)
- }
-
// anthropic/gemini 分组支持混合调度(包含启用了 mixed_scheduling 的 antigravity 账户)
// 注意:强制平台模式不走混合调度
if (platform == PlatformAnthropic || platform == PlatformGemini) && !hasForcePlatform {
- account, err := s.selectAccountWithMixedScheduling(ctx, groupID, sessionHash, requestedModel, excludedIDs, platform)
- if err != nil {
- return nil, err
- }
- return s.hydrateSelectedAccount(ctx, account)
+ return s.selectAccountWithMixedScheduling(ctx, groupID, sessionHash, requestedModel, excludedIDs, platform)
}
// antigravity 分组、强制平台模式或无分组使用单平台选择
// 注意:强制平台模式也必须遵守分组限制,不再回退到全平台查询
- account, err := s.selectAccountForModelWithPlatform(ctx, groupID, sessionHash, requestedModel, excludedIDs, platform)
- if err != nil {
- return nil, err
- }
- return s.hydrateSelectedAccount(ctx, account)
+ return s.selectAccountForModelWithPlatform(ctx, groupID, sessionHash, requestedModel, excludedIDs, platform)
}
// SelectAccountWithLoadAwareness selects account with load-awareness and wait plan.
@@ -1213,6 +1273,11 @@ func (s *GatewayService) SelectAccountForModelWithExclusions(ctx context.Context
// metadataUserID: 用于客户端亲和调度,从中提取客户端 ID
// sub2apiUserID: 系统用户 ID,用于二维亲和调度
func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}, metadataUserID string, sub2apiUserID int64) (*AccountSelectionResult, error) {
+ // 渠道定价限制预检查(requested / channel_mapped 基准)
+ if s.checkChannelPricingRestriction(ctx, groupID, requestedModel) {
+ return nil, fmt.Errorf("%w supporting model: %s (channel pricing restriction)", ErrNoAvailableAccounts, requestedModel)
+ }
+
// 调试日志:记录调度入口参数
excludedIDsList := make([]int64, 0, len(excludedIDs))
for id := range excludedIDs {
@@ -1233,15 +1298,6 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
}
ctx = s.withGroupContext(ctx, group)
- // Claude Code 限制可能已将 groupID 解析为 fallback group,
- // 渠道限制预检查必须使用解析后的分组。
- if s.checkChannelPricingRestriction(ctx, groupID, requestedModel) {
- slog.Warn("channel pricing restriction blocked request",
- "group_id", derefGroupID(groupID),
- "model", requestedModel)
- return nil, fmt.Errorf("%w supporting model: %s (channel pricing restriction)", ErrNoAvailableAccounts, requestedModel)
- }
-
var stickyAccountID int64
if prefetch := prefetchedStickyAccountIDFromContext(ctx, groupID); prefetch > 0 {
stickyAccountID = prefetch
@@ -1251,6 +1307,10 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
}
}
+ // 提取客户端 ID(用于客户端亲和调度)
+ affinityClientID := extractClientIDFromMetadata(metadataUserID)
+ affinityUserID := sub2apiUserID
+
if s.debugModelRoutingEnabled() && requestedModel != "" {
groupPlatform := ""
if group != nil {
@@ -1272,6 +1332,10 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
if err != nil {
return nil, err
}
+ if shouldFilterAccountWithoutClientID(account, affinityClientID) {
+ localExcluded[account.ID] = struct{}{}
+ continue
+ }
result, err := s.tryAcquireAccountSlot(ctx, account.ID, account.Concurrency)
if err == nil && result.Acquired {
@@ -1281,7 +1345,11 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
localExcluded[account.ID] = struct{}{} // 排除此账号
continue // 重新选择
}
- return s.newSelectionResult(ctx, account, true, result.ReleaseFunc, nil)
+ return &AccountSelectionResult{
+ Account: account,
+ Acquired: true,
+ ReleaseFunc: result.ReleaseFunc,
+ }, nil
}
// 对于等待计划的情况,也需要先检查会话限制
@@ -1293,20 +1361,26 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
if stickyAccountID > 0 && stickyAccountID == account.ID && s.concurrencyService != nil {
waitingCount, _ := s.concurrencyService.GetAccountWaitingCount(ctx, account.ID)
if waitingCount < cfg.StickySessionMaxWaiting {
- return s.newSelectionResult(ctx, account, false, nil, &AccountWaitPlan{
- AccountID: account.ID,
- MaxConcurrency: account.Concurrency,
- Timeout: cfg.StickySessionWaitTimeout,
- MaxWaiting: cfg.StickySessionMaxWaiting,
- })
+ return &AccountSelectionResult{
+ Account: account,
+ WaitPlan: &AccountWaitPlan{
+ AccountID: account.ID,
+ MaxConcurrency: account.Concurrency,
+ Timeout: cfg.StickySessionWaitTimeout,
+ MaxWaiting: cfg.StickySessionMaxWaiting,
+ },
+ }, nil
}
}
- return s.newSelectionResult(ctx, account, false, nil, &AccountWaitPlan{
- AccountID: account.ID,
- MaxConcurrency: account.Concurrency,
- Timeout: cfg.FallbackWaitTimeout,
- MaxWaiting: cfg.FallbackMaxWaiting,
- })
+ return &AccountSelectionResult{
+ Account: account,
+ WaitPlan: &AccountWaitPlan{
+ AccountID: account.ID,
+ MaxConcurrency: account.Concurrency,
+ Timeout: cfg.FallbackWaitTimeout,
+ MaxWaiting: cfg.FallbackMaxWaiting,
+ },
+ }, nil
}
}
@@ -1323,12 +1397,18 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
if err != nil {
return nil, err
}
+ accounts = filterAccountsWithoutClientID(accounts, affinityClientID)
if len(accounts) == 0 {
return nil, ErrNoAvailableAccounts
}
ctx = s.withWindowCostPrefetch(ctx, accounts)
ctx = s.withRPMPrefetch(ctx, accounts)
+ // 提前构建 accountByID(供 Layer 1 和 Layer 1.5 使用)
+ accountByID := make(map[int64]*Account, len(accounts))
+ for i := range accounts {
+ accountByID[accounts[i].ID] = &accounts[i]
+ }
isExcluded := func(accountID int64) bool {
if excludedIDs == nil {
return false
@@ -1336,12 +1416,19 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
_, excluded := excludedIDs[accountID]
return excluded
}
-
- // 提前构建 accountByID(供 Layer 1 和 Layer 1.5 使用)
- accountByID := make(map[int64]*Account, len(accounts))
- for i := range accounts {
- accountByID[accounts[i].ID] = &accounts[i]
- }
+ affinityFlow := newGatewayAffinityFlow(
+ s,
+ ctx,
+ groupID,
+ sessionHash,
+ requestedModel,
+ affinityClientID,
+ affinityUserID,
+ platform,
+ useMixed,
+ accountByID,
+ isExcluded,
+ )
// 获取模型路由配置(仅 anthropic 平台)
var routingAccountIDs []int64
@@ -1430,76 +1517,53 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
if containsInt64(routingAccountIDs, stickyAccountID) && !isExcluded(stickyAccountID) {
// 粘性账号在路由列表中,优先使用
if stickyAccount, ok := accountByID[stickyAccountID]; ok {
- var stickyCacheMissReason string
-
- gatePass := s.isAccountSchedulableForSelection(stickyAccount) &&
+ if s.isAccountSchedulableForSelection(stickyAccount) &&
s.isAccountAllowedForPlatform(stickyAccount, platform, useMixed) &&
(requestedModel == "" || s.isModelSupportedByAccountWithContext(ctx, stickyAccount, requestedModel)) &&
s.isAccountSchedulableForModelSelection(ctx, stickyAccount, requestedModel) &&
s.isAccountSchedulableForQuota(stickyAccount) &&
- s.isAccountSchedulableForWindowCost(ctx, stickyAccount, true)
+ s.isAccountSchedulableForWindowCost(ctx, stickyAccount, true) &&
- rpmPass := gatePass && s.isAccountSchedulableForRPM(ctx, stickyAccount, true)
-
- if rpmPass { // 粘性会话窗口费用+RPM 检查
+ s.isAccountSchedulableForRPM(ctx, stickyAccount, true) { // 粘性会话窗口费用+RPM 检查
result, err := s.tryAcquireAccountSlot(ctx, stickyAccountID, stickyAccount.Concurrency)
if err == nil && result.Acquired {
// 会话数量限制检查
if !s.checkAndRegisterSession(ctx, stickyAccount, sessionHash) {
result.ReleaseFunc() // 释放槽位
- stickyCacheMissReason = "session_limit"
// 继续到负载感知选择
} else {
if s.debugModelRoutingEnabled() {
logger.LegacyPrintf("service.gateway", "[ModelRoutingDebug] routed sticky hit: group_id=%v model=%s session=%s account=%d", derefGroupID(groupID), requestedModel, shortSessionHash(sessionHash), stickyAccountID)
}
- return s.newSelectionResult(ctx, stickyAccount, true, result.ReleaseFunc, nil)
+ return &AccountSelectionResult{
+ Account: stickyAccount,
+ Acquired: true,
+ ReleaseFunc: result.ReleaseFunc,
+ }, nil
}
}
- if stickyCacheMissReason == "" {
- waitingCount, _ := s.concurrencyService.GetAccountWaitingCount(ctx, stickyAccountID)
- if waitingCount < cfg.StickySessionMaxWaiting {
- // 会话数量限制检查(等待计划也需要占用会话配额)
- if !s.checkAndRegisterSession(ctx, stickyAccount, sessionHash) {
- stickyCacheMissReason = "session_limit"
- // 会话限制已满,继续到负载感知选择
- } else {
- return &AccountSelectionResult{
- Account: stickyAccount,
- WaitPlan: &AccountWaitPlan{
- AccountID: stickyAccountID,
- MaxConcurrency: stickyAccount.Concurrency,
- Timeout: cfg.StickySessionWaitTimeout,
- MaxWaiting: cfg.StickySessionMaxWaiting,
- },
- }, nil
- }
+ waitingCount, _ := s.concurrencyService.GetAccountWaitingCount(ctx, stickyAccountID)
+ if waitingCount < cfg.StickySessionMaxWaiting {
+ // 会话数量限制检查(等待计划也需要占用会话配额)
+ if !s.checkAndRegisterSession(ctx, stickyAccount, sessionHash) {
+ // 会话限制已满,继续到负载感知选择
} else {
- stickyCacheMissReason = "wait_queue_full"
+ return &AccountSelectionResult{
+ Account: stickyAccount,
+ WaitPlan: &AccountWaitPlan{
+ AccountID: stickyAccountID,
+ MaxConcurrency: stickyAccount.Concurrency,
+ Timeout: cfg.StickySessionWaitTimeout,
+ MaxWaiting: cfg.StickySessionMaxWaiting,
+ },
+ }, nil
}
}
// 粘性账号槽位满且等待队列已满,继续使用负载感知选择
- } else if !gatePass {
- stickyCacheMissReason = "gate_check"
- } else {
- stickyCacheMissReason = "rpm_red"
- }
-
- // 记录粘性缓存未命中的结构化日志
- if stickyCacheMissReason != "" {
- baseRPM := stickyAccount.GetBaseRPM()
- var currentRPM int
- if count, ok := rpmFromPrefetchContext(ctx, stickyAccount.ID); ok {
- currentRPM = count
- }
- logger.LegacyPrintf("service.gateway", "[StickyCacheMiss] reason=%s account_id=%d session=%s current_rpm=%d base_rpm=%d",
- stickyCacheMissReason, stickyAccountID, shortSessionHash(sessionHash), currentRPM, baseRPM)
}
} else {
_ = s.cache.DeleteSessionAccountID(ctx, derefGroupID(groupID), sessionHash)
- logger.LegacyPrintf("service.gateway", "[StickyCacheMiss] reason=account_cleared account_id=%d session=%s current_rpm=0 base_rpm=0",
- stickyAccountID, shortSessionHash(sessionHash))
}
}
}
@@ -1527,7 +1591,10 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
}
if len(routingAvailable) > 0 {
- // 排序:优先级 > 负载率 > 最后使用时间
+ // 批量获取亲和客户端数量
+ s.populateAffinityCounts(ctx, routingAvailable, derefGroupID(groupID))
+
+ // 排序:优先级 > 负载率 > 亲和客户端数 > 最后使用时间
sort.SliceStable(routingAvailable, func(i, j int) bool {
a, b := routingAvailable[i], routingAvailable[j]
if a.account.Priority != b.account.Priority {
@@ -1536,6 +1603,9 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
if a.loadInfo.LoadRate != b.loadInfo.LoadRate {
return a.loadInfo.LoadRate < b.loadInfo.LoadRate
}
+ if a.affinityCount != b.affinityCount {
+ return a.affinityCount < b.affinityCount
+ }
switch {
case a.account.LastUsedAt == nil && b.account.LastUsedAt != nil:
return true
@@ -1561,10 +1631,17 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
if sessionHash != "" && s.cache != nil {
_ = s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, item.account.ID, stickySessionTTL)
}
+ if affinityClientID != "" && affinityUserID > 0 && s.cache != nil && item.account.IsAffinityEnabled() {
+ _ = s.cache.UpdateAffinity(ctx, derefGroupID(groupID), affinityUserID, affinityClientID, item.account.ID, ClientAffinityTTL)
+ }
if s.debugModelRoutingEnabled() {
logger.LegacyPrintf("service.gateway", "[ModelRoutingDebug] routed select: group_id=%v model=%s session=%s account=%d", derefGroupID(groupID), requestedModel, shortSessionHash(sessionHash), item.account.ID)
}
- return s.newSelectionResult(ctx, item.account, true, result.ReleaseFunc, nil)
+ return &AccountSelectionResult{
+ Account: item.account,
+ Acquired: true,
+ ReleaseFunc: result.ReleaseFunc,
+ }, nil
}
}
@@ -1577,12 +1654,15 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
if s.debugModelRoutingEnabled() {
logger.LegacyPrintf("service.gateway", "[ModelRoutingDebug] routed wait: group_id=%v model=%s session=%s account=%d", derefGroupID(groupID), requestedModel, shortSessionHash(sessionHash), item.account.ID)
}
- return s.newSelectionResult(ctx, item.account, false, nil, &AccountWaitPlan{
- AccountID: item.account.ID,
- MaxConcurrency: item.account.Concurrency,
- Timeout: cfg.StickySessionWaitTimeout,
- MaxWaiting: cfg.StickySessionMaxWaiting,
- })
+ return &AccountSelectionResult{
+ Account: item.account,
+ WaitPlan: &AccountWaitPlan{
+ AccountID: item.account.ID,
+ MaxConcurrency: item.account.Concurrency,
+ Timeout: cfg.StickySessionWaitTimeout,
+ MaxWaiting: cfg.StickySessionMaxWaiting,
+ },
+ }, nil
}
// 所有路由账号会话限制都已满,继续到 Layer 2 回退
}
@@ -1591,14 +1671,27 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
}
}
- // ============ Layer 1.5: 粘性会话(仅在无模型路由配置时生效) ============
- if len(routingAccountIDs) == 0 && sessionHash != "" && stickyAccountID > 0 && !isExcluded(stickyAccountID) {
+ // ============ Layer 1.3: 用户亲和预处理(pinned_users 自动注入) ============
+ affinityFlow.preprocessPinnedUsers(accounts)
+
+ // ============ Layer 1.4: 客户端亲和调度(优先于粘性会话) ============
+ affinityHit := false
+ if affinityResult, hit, err := affinityFlow.trySelectAffinityAccount(); err != nil {
+ return nil, err
+ } else {
+ affinityHit = hit
+ if affinityResult != nil {
+ return affinityResult, nil
+ }
+ }
+
+ // ============ Layer 1.5: 粘性会话(仅在无模型路由配置 且 亲和未命中时生效) ============
+ if !affinityHit && len(routingAccountIDs) == 0 && sessionHash != "" && stickyAccountID > 0 && !isExcluded(stickyAccountID) {
accountID := stickyAccountID
if accountID > 0 && !isExcluded(accountID) {
account, ok := accountByID[accountID]
if ok {
// 检查账户是否需要清理粘性会话绑定
- // Check if the account needs sticky session cleanup
clearSticky := shouldClearStickySession(account, requestedModel)
if clearSticky {
_ = s.cache.DeleteSessionAccountID(ctx, derefGroupID(groupID), sessionHash)
@@ -1614,31 +1707,32 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
result, err := s.tryAcquireAccountSlot(ctx, accountID, account.Concurrency)
if err == nil && result.Acquired {
// 会话数量限制检查
- // Session count limit check
if !s.checkAndRegisterSession(ctx, account, sessionHash) {
result.ReleaseFunc() // 释放槽位,继续到 Layer 2
} else {
- if s.cache != nil {
- _ = s.cache.RefreshSessionTTL(ctx, derefGroupID(groupID), sessionHash, stickySessionTTL)
- }
- return s.newSelectionResult(ctx, account, true, result.ReleaseFunc, nil)
+ return &AccountSelectionResult{
+ Account: account,
+ Acquired: true,
+ ReleaseFunc: result.ReleaseFunc,
+ }, nil
}
}
waitingCount, _ := s.concurrencyService.GetAccountWaitingCount(ctx, accountID)
if waitingCount < cfg.StickySessionMaxWaiting {
// 会话数量限制检查(等待计划也需要占用会话配额)
- // Session count limit check (wait plan also requires session quota)
if !s.checkAndRegisterSession(ctx, account, sessionHash) {
// 会话限制已满,继续到 Layer 2
- // Session limit full, continue to Layer 2
} else {
- return s.newSelectionResult(ctx, account, false, nil, &AccountWaitPlan{
- AccountID: accountID,
- MaxConcurrency: account.Concurrency,
- Timeout: cfg.StickySessionWaitTimeout,
- MaxWaiting: cfg.StickySessionMaxWaiting,
- })
+ return &AccountSelectionResult{
+ Account: account,
+ WaitPlan: &AccountWaitPlan{
+ AccountID: accountID,
+ MaxConcurrency: account.Concurrency,
+ Timeout: cfg.StickySessionWaitTimeout,
+ MaxWaiting: cfg.StickySessionMaxWaiting,
+ },
+ }, nil
}
}
}
@@ -1697,9 +1791,10 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
loadMap, err := s.concurrencyService.GetAccountsLoadBatch(ctx, accountLoads)
if err != nil {
- if result, ok, legacyErr := s.tryAcquireByLegacyOrder(ctx, candidates, groupID, sessionHash, preferOAuth); legacyErr != nil {
- return nil, legacyErr
- } else if ok {
+ if result, ok := s.tryAcquireByLegacyOrder(ctx, candidates, groupID, sessionHash, preferOAuth); ok {
+ if affinityClientID != "" && affinityUserID > 0 && s.cache != nil && result.Account != nil && result.Account.IsAffinityEnabled() {
+ _ = s.cache.UpdateAffinity(ctx, derefGroupID(groupID), affinityUserID, affinityClientID, result.Account.ID, ClientAffinityTTL)
+ }
return result, nil
}
} else {
@@ -1717,13 +1812,37 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
}
}
- // 分层过滤选择:优先级 → 负载率 → LRU
+ // 批量获取亲和客户端数量(用于均衡分配新客户端)
+ s.populateAffinityCounts(ctx, available, derefGroupID(groupID))
+
+ // 分层过滤选择:优先级 → 亲和三区 → 负载率 → 亲和客户端数 → LRU
for len(available) > 0 {
// 1. 取优先级最小的集合
candidates := filterByMinPriority(available)
- // 2. 取负载率最低的集合
+ // 2. 按亲和三区过滤:绿区优先 → 黄区降级 → 红区移除(在同优先级内)
+ candidates = classifyByAffinityZone(candidates)
+ if len(candidates) == 0 {
+ // 当前优先级组全部在红区,移除后回退到下一优先级组
+ minPri := available[0].account.Priority
+ for _, a := range available[1:] {
+ if a.account.Priority < minPri {
+ minPri = a.account.Priority
+ }
+ }
+ newAvailable := make([]accountWithLoad, 0, len(available))
+ for _, a := range available {
+ if a.account.Priority != minPri {
+ newAvailable = append(newAvailable, a)
+ }
+ }
+ available = newAvailable
+ continue
+ }
+ // 3. 取负载率最低的集合
candidates = filterByMinLoadRate(candidates)
- // 3. LRU 选择最久未用的账号
+ // 3. 取亲和客户端数最少的集合
+ candidates = filterByMinAffinityCount(candidates)
+ // 4. LRU 选择最久未用的账号
selected := selectByLRU(candidates, preferOAuth)
if selected == nil {
break
@@ -1738,7 +1857,15 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
if sessionHash != "" && s.cache != nil {
_ = s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, selected.account.ID, stickySessionTTL)
}
- return s.newSelectionResult(ctx, selected.account, true, result.ReleaseFunc, nil)
+ // 更新亲和关系
+ if affinityClientID != "" && affinityUserID > 0 && s.cache != nil && selected.account.IsAffinityEnabled() {
+ _ = s.cache.UpdateAffinity(ctx, derefGroupID(groupID), affinityUserID, affinityClientID, selected.account.ID, ClientAffinityTTL)
+ }
+ return &AccountSelectionResult{
+ Account: selected.account,
+ Acquired: true,
+ ReleaseFunc: result.ReleaseFunc,
+ }, nil
}
}
@@ -1761,17 +1888,20 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
if !s.checkAndRegisterSession(ctx, acc, sessionHash) {
continue // 会话限制已满,尝试下一个账号
}
- return s.newSelectionResult(ctx, acc, false, nil, &AccountWaitPlan{
- AccountID: acc.ID,
- MaxConcurrency: acc.Concurrency,
- Timeout: cfg.FallbackWaitTimeout,
- MaxWaiting: cfg.FallbackMaxWaiting,
- })
+ return &AccountSelectionResult{
+ Account: acc,
+ WaitPlan: &AccountWaitPlan{
+ AccountID: acc.ID,
+ MaxConcurrency: acc.Concurrency,
+ Timeout: cfg.FallbackWaitTimeout,
+ MaxWaiting: cfg.FallbackMaxWaiting,
+ },
+ }, nil
}
return nil, ErrNoAvailableAccounts
}
-func (s *GatewayService) tryAcquireByLegacyOrder(ctx context.Context, candidates []*Account, groupID *int64, sessionHash string, preferOAuth bool) (*AccountSelectionResult, bool, error) {
+func (s *GatewayService) tryAcquireByLegacyOrder(ctx context.Context, candidates []*Account, groupID *int64, sessionHash string, preferOAuth bool) (*AccountSelectionResult, bool) {
ordered := append([]*Account(nil), candidates...)
sortAccountsByPriorityAndLastUsed(ordered, preferOAuth)
@@ -1786,15 +1916,15 @@ func (s *GatewayService) tryAcquireByLegacyOrder(ctx context.Context, candidates
if sessionHash != "" && s.cache != nil {
_ = s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, acc.ID, stickySessionTTL)
}
- selection, err := s.newSelectionResult(ctx, acc, true, result.ReleaseFunc, nil)
- if err != nil {
- return nil, false, err
- }
- return selection, true, nil
+ return &AccountSelectionResult{
+ Account: acc,
+ Acquired: true,
+ ReleaseFunc: result.ReleaseFunc,
+ }, true
}
}
- return nil, false, nil
+ return nil, false
}
func (s *GatewayService) schedulingConfig() config.GatewaySchedulingConfig {
@@ -1939,6 +2069,9 @@ func (s *GatewayService) resolvePlatform(ctx context.Context, groupID *int64, gr
}
func (s *GatewayService) listSchedulableAccounts(ctx context.Context, groupID *int64, platform string, hasForcePlatform bool) ([]Account, bool, error) {
+ if platform == PlatformSora {
+ return s.listSoraSchedulableAccounts(ctx, groupID)
+ }
if s.schedulerSnapshot != nil {
accounts, useMixed, err := s.schedulerSnapshot.ListSchedulableAccounts(ctx, groupID, platform, hasForcePlatform)
if err == nil {
@@ -2035,6 +2168,53 @@ func (s *GatewayService) listSchedulableAccounts(ctx context.Context, groupID *i
return accounts, useMixed, nil
}
+func (s *GatewayService) listSoraSchedulableAccounts(ctx context.Context, groupID *int64) ([]Account, bool, error) {
+ const useMixed = false
+
+ var accounts []Account
+ var err error
+ if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple {
+ accounts, err = s.accountRepo.ListByPlatform(ctx, PlatformSora)
+ } else if groupID != nil {
+ accounts, err = s.accountRepo.ListByGroup(ctx, *groupID)
+ } else {
+ accounts, err = s.accountRepo.ListByPlatform(ctx, PlatformSora)
+ }
+ if err != nil {
+ slog.Debug("account_scheduling_list_failed",
+ "group_id", derefGroupID(groupID),
+ "platform", PlatformSora,
+ "error", err)
+ return nil, useMixed, err
+ }
+
+ filtered := make([]Account, 0, len(accounts))
+ for _, acc := range accounts {
+ if acc.Platform != PlatformSora {
+ continue
+ }
+ if !s.isSoraAccountSchedulable(&acc) {
+ continue
+ }
+ filtered = append(filtered, acc)
+ }
+ slog.Debug("account_scheduling_list_sora",
+ "group_id", derefGroupID(groupID),
+ "platform", PlatformSora,
+ "raw_count", len(accounts),
+ "filtered_count", len(filtered))
+ for _, acc := range filtered {
+ slog.Debug("account_scheduling_account_detail",
+ "account_id", acc.ID,
+ "name", acc.Name,
+ "platform", acc.Platform,
+ "type", acc.Type,
+ "status", acc.Status,
+ "tls_fingerprint", acc.IsTLSFingerprintEnabled())
+ }
+ return filtered, useMixed, nil
+}
+
// IsSingleAntigravityAccountGroup 检查指定分组是否只有一个 antigravity 平台的可调度账号。
// 用于 Handler 层在首次请求时提前设置 SingleAccountRetry context,
// 避免单账号分组收到 503 时错误地设置模型限流标记导致后续请求连续快速失败。
@@ -2059,10 +2239,33 @@ func (s *GatewayService) isAccountAllowedForPlatform(account *Account, platform
return account.Platform == platform
}
+func (s *GatewayService) isSoraAccountSchedulable(account *Account) bool {
+ return s.soraUnschedulableReason(account) == ""
+}
+
+func (s *GatewayService) soraUnschedulableReason(account *Account) string {
+ if account == nil {
+ return "account_nil"
+ }
+ if account.Status != StatusActive {
+ return fmt.Sprintf("status=%s", account.Status)
+ }
+ if !account.Schedulable {
+ return "schedulable=false"
+ }
+ if account.TempUnschedulableUntil != nil && time.Now().Before(*account.TempUnschedulableUntil) {
+ return fmt.Sprintf("temp_unschedulable_until=%s", account.TempUnschedulableUntil.UTC().Format(time.RFC3339))
+ }
+ return ""
+}
+
func (s *GatewayService) isAccountSchedulableForSelection(account *Account) bool {
if account == nil {
return false
}
+ if account.Platform == PlatformSora {
+ return s.isSoraAccountSchedulable(account)
+ }
return account.IsSchedulable()
}
@@ -2070,6 +2273,12 @@ func (s *GatewayService) isAccountSchedulableForModelSelection(ctx context.Conte
if account == nil {
return false
}
+ if account.Platform == PlatformSora {
+ if !s.isSoraAccountSchedulable(account) {
+ return false
+ }
+ return account.GetRateLimitRemainingTimeWithContext(ctx, requestedModel) <= 0
+ }
return account.IsSchedulableForModelWithContext(ctx, requestedModel)
}
@@ -2409,31 +2618,34 @@ func (s *GatewayService) getSchedulableAccount(ctx context.Context, accountID in
return s.accountRepo.GetByID(ctx, accountID)
}
-func (s *GatewayService) hydrateSelectedAccount(ctx context.Context, account *Account) (*Account, error) {
- if account == nil || s.schedulerSnapshot == nil {
- return account, nil
+// populateAffinityCounts 批量获取账号的亲和客户端数量并填入 accountWithLoad 切片。
+// 仅当存在开启了客户端亲和的账号时才查询 Redis,否则跳过。
+func (s *GatewayService) populateAffinityCounts(ctx context.Context, accounts []accountWithLoad, groupID int64) {
+ if s.cache == nil || len(accounts) == 0 {
+ return
}
- hydrated, err := s.schedulerSnapshot.GetAccount(ctx, account.ID)
+ // 快速检查:是否有任何账号开启了亲和
+ hasAffinity := false
+ for _, acc := range accounts {
+ if acc.account.IsAffinityEnabled() {
+ hasAffinity = true
+ break
+ }
+ }
+ if !hasAffinity {
+ return
+ }
+ accountIDs := make([]int64, len(accounts))
+ for i, acc := range accounts {
+ accountIDs[i] = acc.account.ID
+ }
+ countMap, err := s.cache.GetAccountAffinityCountBatch(ctx, groupID, accountIDs, ClientAffinityTTL)
if err != nil {
- return nil, err
+ return // 查询失败不影响调度,affinityCount 保持 0
}
- if hydrated == nil {
- return nil, fmt.Errorf("selected gateway account %d not found during hydration", account.ID)
+ for i := range accounts {
+ accounts[i].affinityCount = countMap[accounts[i].account.ID]
}
- return hydrated, nil
-}
-
-func (s *GatewayService) newSelectionResult(ctx context.Context, account *Account, acquired bool, release func(), waitPlan *AccountWaitPlan) (*AccountSelectionResult, error) {
- hydrated, err := s.hydrateSelectedAccount(ctx, account)
- if err != nil {
- return nil, err
- }
- return &AccountSelectionResult{
- Account: hydrated,
- Acquired: acquired,
- ReleaseFunc: release,
- WaitPlan: waitPlan,
- }, nil
}
// filterByMinPriority 过滤出优先级最小的账号集合
@@ -2476,6 +2688,64 @@ func filterByMinLoadRate(accounts []accountWithLoad) []accountWithLoad {
return result
}
+// filterByMinAffinityCount 过滤出亲和客户端数最少的账号集合
+func filterByMinAffinityCount(accounts []accountWithLoad) []accountWithLoad {
+ if len(accounts) == 0 {
+ return accounts
+ }
+ minCount := accounts[0].affinityCount
+ for _, acc := range accounts[1:] {
+ if acc.affinityCount < minCount {
+ minCount = acc.affinityCount
+ }
+ }
+ result := make([]accountWithLoad, 0, len(accounts))
+ for _, acc := range accounts {
+ if acc.affinityCount == minCount {
+ result = append(result, acc)
+ }
+ }
+ return result
+}
+
+// classifyByAffinityZone 按亲和分区对候选账号进行分类。
+// 返回值:仅绿区账号(有绿区时),否则返回黄区账号。红区账号被移除。
+// 如果没有任何账号开启了亲和三区配置(即 affinity_base <= 0),则原样返回所有账号。
+func classifyByAffinityZone(accounts []accountWithLoad) []accountWithLoad {
+ if len(accounts) == 0 {
+ return accounts
+ }
+ // 快速检查:是否有任何账号配置了 affinity_base
+ hasZoneConfig := false
+ for _, acc := range accounts {
+ if acc.account.IsAffinityEnabled() && acc.account.GetAffinityBase() > 0 {
+ hasZoneConfig = true
+ break
+ }
+ }
+ if !hasZoneConfig {
+ return accounts
+ }
+
+ greens := make([]accountWithLoad, 0, len(accounts))
+ yellows := make([]accountWithLoad, 0, len(accounts))
+ for _, acc := range accounts {
+ zone := acc.account.GetAffinityZone(acc.affinityCount)
+ switch zone {
+ case AffinityZoneGreen:
+ greens = append(greens, acc)
+ case AffinityZoneYellow:
+ yellows = append(yellows, acc)
+ case AffinityZoneRed:
+ // 红区:移除,不参与调度
+ }
+ }
+ if len(greens) > 0 {
+ return greens
+ }
+ return yellows
+}
+
// selectByLRU 从集合中选择最久未用的账号
// 如果有多个账号具有相同的最小 LastUsedAt,则随机选择一个
func selectByLRU(accounts []accountWithLoad, preferOAuth bool) *accountWithLoad {
@@ -2711,12 +2981,6 @@ func (s *GatewayService) selectAccountForModelWithPlatform(ctx context.Context,
preferOAuth := platform == PlatformGemini
routingAccountIDs := s.routingAccountIDsForRequest(ctx, groupID, requestedModel, platform)
- // require_privacy_set: 获取分组信息
- var schedGroup *Group
- if groupID != nil && s.groupRepo != nil {
- schedGroup, _ = s.groupRepo.GetByID(ctx, *groupID)
- }
-
var accounts []Account
accountsLoaded := false
@@ -2788,12 +3052,6 @@ func (s *GatewayService) selectAccountForModelWithPlatform(ctx context.Context,
if !s.isAccountSchedulableForSelection(acc) {
continue
}
- // require_privacy_set: 跳过 privacy 未设置的账号并标记异常
- if schedGroup != nil && schedGroup.RequirePrivacySet && !acc.IsPrivacySet() {
- _ = s.accountRepo.SetError(ctx, acc.ID,
- fmt.Sprintf("Privacy not set, required by group [%s]", schedGroup.Name))
- continue
- }
if requestedModel != "" && !s.isModelSupportedByAccountWithContext(ctx, acc, requestedModel) {
continue
}
@@ -2885,8 +3143,6 @@ func (s *GatewayService) selectAccountForModelWithPlatform(ctx context.Context,
ctx = s.withRPMPrefetch(ctx, accounts)
// 3. 按优先级+最久未用选择(考虑模型支持)
- // needsUpstreamCheck 仅在主选择循环中使用;粘性会话命中时跳过此检查,
- // 因为粘性会话优先保持连接一致性,且 upstream 计费基准极少使用。
needsUpstreamCheck := s.needsUpstreamChannelRestrictionCheck(ctx, groupID)
var selected *Account
for i := range accounts {
@@ -2899,12 +3155,6 @@ func (s *GatewayService) selectAccountForModelWithPlatform(ctx context.Context,
if !s.isAccountSchedulableForSelection(acc) {
continue
}
- // require_privacy_set: 跳过 privacy 未设置的账号并标记异常
- if schedGroup != nil && schedGroup.RequirePrivacySet && !acc.IsPrivacySet() {
- _ = s.accountRepo.SetError(ctx, acc.ID,
- fmt.Sprintf("Privacy not set, required by group [%s]", schedGroup.Name))
- continue
- }
if requestedModel != "" && !s.isModelSupportedByAccountWithContext(ctx, acc, requestedModel) {
continue
}
@@ -2971,12 +3221,6 @@ func (s *GatewayService) selectAccountWithMixedScheduling(ctx context.Context, g
preferOAuth := nativePlatform == PlatformGemini
routingAccountIDs := s.routingAccountIDsForRequest(ctx, groupID, requestedModel, nativePlatform)
- // require_privacy_set: 获取分组信息
- var schedGroup *Group
- if groupID != nil && s.groupRepo != nil {
- schedGroup, _ = s.groupRepo.GetByID(ctx, *groupID)
- }
-
var accounts []Account
accountsLoaded := false
@@ -3044,12 +3288,6 @@ func (s *GatewayService) selectAccountWithMixedScheduling(ctx context.Context, g
if !s.isAccountSchedulableForSelection(acc) {
continue
}
- // require_privacy_set: 跳过 privacy 未设置的账号并标记异常
- if schedGroup != nil && schedGroup.RequirePrivacySet && !acc.IsPrivacySet() {
- _ = s.accountRepo.SetError(ctx, acc.ID,
- fmt.Sprintf("Privacy not set, required by group [%s]", schedGroup.Name))
- continue
- }
// 过滤:原生平台直接通过,antigravity 需要启用混合调度
if acc.Platform == PlatformAntigravity && !acc.IsMixedSchedulingEnabled() {
continue
@@ -3143,7 +3381,6 @@ func (s *GatewayService) selectAccountWithMixedScheduling(ctx context.Context, g
ctx = s.withRPMPrefetch(ctx, accounts)
// 3. 按优先级+最久未用选择(考虑模型支持和混合调度)
- // needsUpstreamCheck 仅在主选择循环中使用;粘性会话命中时跳过此检查。
needsUpstreamCheck := s.needsUpstreamChannelRestrictionCheck(ctx, groupID)
var selected *Account
for i := range accounts {
@@ -3156,12 +3393,6 @@ func (s *GatewayService) selectAccountWithMixedScheduling(ctx context.Context, g
if !s.isAccountSchedulableForSelection(acc) {
continue
}
- // require_privacy_set: 跳过 privacy 未设置的账号并标记异常
- if schedGroup != nil && schedGroup.RequirePrivacySet && !acc.IsPrivacySet() {
- _ = s.accountRepo.SetError(ctx, acc.ID,
- fmt.Sprintf("Privacy not set, required by group [%s]", schedGroup.Name))
- continue
- }
// 过滤:原生平台直接通过,antigravity 需要启用混合调度
if acc.Platform == PlatformAntigravity && !acc.IsMixedSchedulingEnabled() {
continue
@@ -3273,6 +3504,9 @@ func (s *GatewayService) logDetailedSelectionFailure(
stats.SampleMappingIDs,
stats.SampleRateLimitIDs,
)
+ if platform == PlatformSora {
+ s.logSoraSelectionFailureDetails(ctx, groupID, sessionHash, requestedModel, accounts, excludedIDs, allowMixedScheduling)
+ }
return stats
}
@@ -3329,7 +3563,11 @@ func (s *GatewayService) diagnoseSelectionFailure(
return selectionFailureDiagnosis{Category: "excluded"}
}
if !s.isAccountSchedulableForSelection(acc) {
- return selectionFailureDiagnosis{Category: "unschedulable", Detail: "generic_unschedulable"}
+ detail := "generic_unschedulable"
+ if acc.Platform == PlatformSora {
+ detail = s.soraUnschedulableReason(acc)
+ }
+ return selectionFailureDiagnosis{Category: "unschedulable", Detail: detail}
}
if isPlatformFilteredForSelection(acc, platform, allowMixedScheduling) {
return selectionFailureDiagnosis{
@@ -3353,6 +3591,57 @@ func (s *GatewayService) diagnoseSelectionFailure(
return selectionFailureDiagnosis{Category: "eligible"}
}
+func (s *GatewayService) logSoraSelectionFailureDetails(
+ ctx context.Context,
+ groupID *int64,
+ sessionHash string,
+ requestedModel string,
+ accounts []Account,
+ excludedIDs map[int64]struct{},
+ allowMixedScheduling bool,
+) {
+ const maxLines = 30
+ logged := 0
+
+ for i := range accounts {
+ if logged >= maxLines {
+ break
+ }
+ acc := &accounts[i]
+ diagnosis := s.diagnoseSelectionFailure(ctx, acc, requestedModel, PlatformSora, excludedIDs, allowMixedScheduling)
+ if diagnosis.Category == "eligible" {
+ continue
+ }
+ detail := diagnosis.Detail
+ if detail == "" {
+ detail = "-"
+ }
+ logger.LegacyPrintf(
+ "service.gateway",
+ "[SelectAccountDetailed:Sora] group_id=%v model=%s session=%s account_id=%d account_platform=%s category=%s detail=%s",
+ derefGroupID(groupID),
+ requestedModel,
+ shortSessionHash(sessionHash),
+ acc.ID,
+ acc.Platform,
+ diagnosis.Category,
+ detail,
+ )
+ logged++
+ }
+ if len(accounts) > maxLines {
+ logger.LegacyPrintf(
+ "service.gateway",
+ "[SelectAccountDetailed:Sora] group_id=%v model=%s session=%s truncated=true total=%d logged=%d",
+ derefGroupID(groupID),
+ requestedModel,
+ shortSessionHash(sessionHash),
+ len(accounts),
+ logged,
+ )
+ }
+}
+
func isPlatformFilteredForSelection(acc *Account, platform string, allowMixedScheduling bool) bool {
if acc == nil {
return true
@@ -3431,10 +3720,17 @@ func (s *GatewayService) isModelSupportedByAccount(account *Account, requestedMo
}
return mapAntigravityModel(account, requestedModel) != ""
}
+ if account.Platform == PlatformSora {
+ return s.isSoraModelSupportedByAccount(account, requestedModel)
+ }
if account.IsBedrock() {
_, ok := ResolveBedrockModelID(account, requestedModel)
return ok
}
+ // OpenAI 透传模式:仅替换认证,允许所有模型
+ if account.Platform == PlatformOpenAI && account.IsOpenAIPassthroughEnabled() {
+ return true
+ }
// OAuth/SetupToken 账号使用 Anthropic 标准映射(短ID → 长ID)
if account.Platform == PlatformAnthropic && account.Type != AccountTypeAPIKey {
requestedModel = claude.NormalizeModelID(requestedModel)
@@ -3443,6 +3739,143 @@ func (s *GatewayService) isModelSupportedByAccount(account *Account, requestedMo
return account.IsModelSupported(requestedModel)
}
+func (s *GatewayService) isSoraModelSupportedByAccount(account *Account, requestedModel string) bool {
+ if account == nil {
+ return false
+ }
+ if strings.TrimSpace(requestedModel) == "" {
+ return true
+ }
+
+ // 先走原始精确/通配符匹配。
+ mapping := account.GetModelMapping()
+ if len(mapping) == 0 || account.IsModelSupported(requestedModel) {
+ return true
+ }
+
+ aliases := buildSoraModelAliases(requestedModel)
+ if len(aliases) == 0 {
+ return false
+ }
+
+ hasSoraSelector := false
+ for pattern := range mapping {
+ if !isSoraModelSelector(pattern) {
+ continue
+ }
+ hasSoraSelector = true
+ if matchPatternAnyAlias(pattern, aliases) {
+ return true
+ }
+ }
+
+ // 兼容旧账号:mapping 存在但未配置任何 Sora 选择器(例如只含 gpt-*),
+ // 此时不应误拦截 Sora 模型请求。
+ if !hasSoraSelector {
+ return true
+ }
+
+ return false
+}
+
+func matchPatternAnyAlias(pattern string, aliases []string) bool {
+ normalizedPattern := strings.ToLower(strings.TrimSpace(pattern))
+ if normalizedPattern == "" {
+ return false
+ }
+ for _, alias := range aliases {
+ if matchWildcard(normalizedPattern, alias) {
+ return true
+ }
+ }
+ return false
+}
+
+func isSoraModelSelector(pattern string) bool {
+ p := strings.ToLower(strings.TrimSpace(pattern))
+ if p == "" {
+ return false
+ }
+
+ switch {
+ case strings.HasPrefix(p, "sora"),
+ strings.HasPrefix(p, "gpt-image"),
+ strings.HasPrefix(p, "prompt-enhance"),
+ strings.HasPrefix(p, "sy_"):
+ return true
+ }
+
+ return p == "video" || p == "image"
+}
+
+func buildSoraModelAliases(requestedModel string) []string {
+ modelID := strings.ToLower(strings.TrimSpace(requestedModel))
+ if modelID == "" {
+ return nil
+ }
+
+ aliases := make([]string, 0, 8)
+ addAlias := func(value string) {
+ v := strings.ToLower(strings.TrimSpace(value))
+ if v == "" {
+ return
+ }
+ for _, existing := range aliases {
+ if existing == v {
+ return
+ }
+ }
+ aliases = append(aliases, v)
+ }
+
+ addAlias(modelID)
+ cfg, ok := GetSoraModelConfig(modelID)
+ if ok {
+ addAlias(cfg.Model)
+ switch cfg.Type {
+ case "video":
+ addAlias("video")
+ addAlias("sora")
+ addAlias(soraVideoFamilyAlias(modelID))
+ case "image":
+ addAlias("image")
+ addAlias("gpt-image")
+ case "prompt_enhance":
+ addAlias("prompt-enhance")
+ }
+ return aliases
+ }
+
+ switch {
+ case strings.HasPrefix(modelID, "sora"):
+ addAlias("video")
+ addAlias("sora")
+ addAlias(soraVideoFamilyAlias(modelID))
+ case strings.HasPrefix(modelID, "gpt-image"):
+ addAlias("image")
+ addAlias("gpt-image")
+ case strings.HasPrefix(modelID, "prompt-enhance"):
+ addAlias("prompt-enhance")
+ default:
+ return nil
+ }
+
+ return aliases
+}
+
+func soraVideoFamilyAlias(modelID string) string {
+ switch {
+ case strings.HasPrefix(modelID, "sora2pro-hd"):
+ return "sora2pro-hd"
+ case strings.HasPrefix(modelID, "sora2pro"):
+ return "sora2pro"
+ case strings.HasPrefix(modelID, "sora2"):
+ return "sora2"
+ default:
+ return ""
+ }
+}
+
// GetAccessToken 获取账号凭证
func (s *GatewayService) GetAccessToken(ctx context.Context, account *Account) (string, string, error) {
switch account.Type {
@@ -3719,86 +4152,6 @@ func injectClaudeCodePrompt(body []byte, system any) []byte {
return result
}
-// rewriteSystemForNonClaudeCode 将非 Claude Code 客户端的 system prompt 迁移至 messages,
-// system 字段仅保留 Claude Code 标识提示词。
-// Anthropic 基于 system 参数内容检测第三方应用,仅前置追加 Claude Code 提示词
-// 无法通过检测,因为后续内容仍为非 Claude Code 格式。
-// 策略:将原始 system prompt 提取并注入为 user/assistant 消息对,system 仅保留 Claude Code 标识。
-func rewriteSystemForNonClaudeCode(body []byte, system any) []byte {
- system = normalizeSystemParam(system)
-
- // 1. 提取原始 system prompt 文本
- var originalSystemText string
- switch v := system.(type) {
- case string:
- originalSystemText = strings.TrimSpace(v)
- case []any:
- var parts []string
- for _, item := range v {
- if m, ok := item.(map[string]any); ok {
- if text, ok := m["text"].(string); ok && strings.TrimSpace(text) != "" {
- parts = append(parts, text)
- }
- }
- }
- originalSystemText = strings.Join(parts, "\n\n")
- }
-
- // 2. 将 system 替换为 Claude Code 标准提示词(array 格式,与真实 Claude Code 一致)
- // 真实 Claude Code 始终以 [{type: "text", text: "...", cache_control: {type: "ephemeral"}}] 发送 system。
- // 使用 string 格式会被 Anthropic 检测为第三方应用。
- claudeCodeSystemBlock := []map[string]any{
- {
- "type": "text",
- "text": claudeCodeSystemPrompt,
- "cache_control": map[string]string{"type": "ephemeral"},
- },
- }
- out, ok := setJSONValueBytes(body, "system", claudeCodeSystemBlock)
- if !ok {
- logger.LegacyPrintf("service.gateway", "Warning: failed to set Claude Code system prompt")
- return body
- }
-
- // 3. 将原始 system prompt 作为 user/assistant 消息对注入到 messages 开头
- // 模型仍通过 messages 接收完整指令,保留客户端功能
- ccPromptTrimmed := strings.TrimSpace(claudeCodeSystemPrompt)
- if originalSystemText != "" && originalSystemText != ccPromptTrimmed && !hasClaudeCodePrefix(originalSystemText) {
- instrMsg, err1 := json.Marshal(map[string]any{
- "role": "user",
- "content": []map[string]any{
- {"type": "text", "text": "[System Instructions]\n" + originalSystemText},
- },
- })
- ackMsg, err2 := json.Marshal(map[string]any{
- "role": "assistant",
- "content": []map[string]any{
- {"type": "text", "text": "Understood. I will follow these instructions."},
- },
- })
- if err1 != nil || err2 != nil {
- logger.LegacyPrintf("service.gateway", "Warning: failed to marshal system-to-messages injection")
- return out
- }
-
- // 重建 messages 数组:[instruction, ack, ...originalMessages]
- items := [][]byte{instrMsg, ackMsg}
- messagesResult := gjson.GetBytes(out, "messages")
- if messagesResult.IsArray() {
- messagesResult.ForEach(func(_, msg gjson.Result) bool {
- items = append(items, []byte(msg.Raw))
- return true
- })
- }
-
- if next, setOk := setJSONRawBytes(out, "messages", buildJSONArrayRaw(items)); setOk {
- out = next
- }
- }
-
- return out
-}
-
type cacheControlPath struct {
path string
log string
@@ -3960,7 +4313,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
// Beta policy: evaluate once; block check + cache filter set for buildUpstreamRequest.
// Always overwrite the cache to prevent stale values from a previous retry with a different account.
if account.Platform == PlatformAnthropic && c != nil {
- policy := s.evaluateBetaPolicy(ctx, c.GetHeader("anthropic-beta"), account, parsed.Model)
+ policy := s.evaluateBetaPolicy(ctx, c.GetHeader("anthropic-beta"), account)
if policy.blockErr != nil {
return nil, policy.blockErr
}
@@ -3990,24 +4343,19 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode
if shouldMimicClaudeCode {
- // 非 Claude Code 客户端:将 system 替换为 Claude Code 标识,原始 system 迁移至 messages
+ // 智能注入 Claude Code 系统提示词(仅 OAuth/SetupToken 账号需要)
// 条件:1) OAuth/SetupToken 账号 2) 不是 Claude Code 客户端 3) 不是 Haiku 模型 4) system 中还没有 Claude Code 提示词
- systemRewritten := false
if !strings.Contains(strings.ToLower(reqModel), "haiku") &&
!systemIncludesClaudeCodePrompt(parsed.System) {
- body = rewriteSystemForNonClaudeCode(body, parsed.System)
- systemRewritten = true
+ body = injectClaudeCodePrompt(body, parsed.System)
}
- // system 被重写时保留 CC prompt 的 cache_control: ephemeral(匹配真实 Claude Code 行为);
- // 未重写时(haiku / 已含 CC 前缀)剥离客户端 cache_control,与原有行为一致。
- // 两种情况下 enforceCacheControlLimit 都会兜底处理上限。
- normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: !systemRewritten}
+ normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: true}
if s.identityService != nil {
fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header)
if err == nil && fp != nil {
// metadata 透传开启时跳过 metadata 注入
- _, mimicMPT, _ := s.settingService.GetGatewayForwardingSettings(ctx)
+ _, mimicMPT := s.settingService.GetGatewayForwardingSettings(ctx)
if !mimicMPT {
if metadataUserID := s.buildOAuthMetadataUserID(parsed, account, fp); metadataUserID != "" {
normalizeOpts.injectMetadata = true
@@ -4054,12 +4402,10 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
return nil, err
}
- // 获取代理URL(自定义 base URL 模式下,proxy 通过 buildCustomRelayURL 作为查询参数传递)
+ // 获取代理URL
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
- if !account.IsCustomBaseURLEnabled() || account.GetCustomBaseURL() == "" {
- proxyURL = account.Proxy.URL()
- }
+ proxyURL = account.Proxy.URL()
}
// 解析 TLS 指纹 profile(同一请求生命周期内不变,避免重试循环中重复解析)
@@ -4468,6 +4814,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
}
// 处理正常响应
+ ctx = withClaudeMaxResponseRewriteContext(ctx, c, parsed)
// 触发上游接受回调(提前释放串行锁,不等流完成)
if parsed.OnUpstreamAccepted != nil {
@@ -5534,16 +5881,6 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
}
targetURL = validatedURL + "/v1/messages?beta=true"
}
- } else if account.IsCustomBaseURLEnabled() {
- customURL := account.GetCustomBaseURL()
- if customURL == "" {
- return nil, fmt.Errorf("custom_base_url is enabled but not configured for account %d", account.ID)
- }
- validatedURL, err := s.validateUpstreamBaseURL(customURL)
- if err != nil {
- return nil, err
- }
- targetURL = s.buildCustomRelayURL(validatedURL, "/v1/messages", account)
}
clientHeaders := http.Header{}
@@ -5553,9 +5890,9 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
// OAuth账号:应用统一指纹和metadata重写(受设置开关控制)
var fingerprint *Fingerprint
- enableFP, enableMPT, enableCCH := true, false, false
+ enableFP, enableMPT := true, false
if s.settingService != nil {
- enableFP, enableMPT, enableCCH = s.settingService.GetGatewayForwardingSettings(ctx)
+ enableFP, enableMPT = s.settingService.GetGatewayForwardingSettings(ctx)
}
if account.IsOAuth() && s.identityService != nil {
// 1. 获取或创建指纹(包含随机生成的ClientID)
@@ -5582,15 +5919,6 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
}
}
- // 同步 billing header cc_version 与实际发送的 User-Agent 版本
- if fingerprint != nil {
- body = syncBillingHeaderVersion(body, fingerprint.UserAgent)
- }
- // CCH 签名:将 cch=00000 占位符替换为 xxHash64 签名(需在所有 body 修改之后)
- if enableCCH {
- body = signBillingHeaderCCH(body)
- }
-
req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body))
if err != nil {
return nil, err
@@ -5631,8 +5959,9 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
}
// Build effective drop set: merge static defaults with dynamic beta policy filter rules
- policyFilterSet := s.getBetaPolicyFilterSet(ctx, c, account, modelID)
+ policyFilterSet := s.getBetaPolicyFilterSet(ctx, c, account)
effectiveDropSet := mergeDropSets(policyFilterSet)
+ effectiveDropWithClaudeCodeSet := mergeDropSets(policyFilterSet, claude.BetaClaudeCode)
// 处理 anthropic-beta header(OAuth 账号需要包含 oauth beta)
if tokenType == "oauth" {
@@ -5643,16 +5972,11 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
applyClaudeCodeMimicHeaders(req, reqStream)
incomingBeta := getHeaderRaw(req.Header, "anthropic-beta")
- // Claude Code OAuth credentials are scoped to Claude Code.
- // Non-haiku models MUST include claude-code beta for Anthropic to recognize
- // this as a legitimate Claude Code request; without it, the request is
- // rejected as third-party ("out of extra usage").
- // Haiku models are exempt from third-party detection and don't need it.
+ // Match real Claude CLI traffic (per mitmproxy reports):
+ // messages requests typically use only oauth + interleaved-thinking.
+ // Also drop claude-code beta if a downstream client added it.
requiredBetas := []string{claude.BetaOAuth, claude.BetaInterleavedThinking}
- if !strings.Contains(strings.ToLower(modelID), "haiku") {
- requiredBetas = []string{claude.BetaClaudeCode, claude.BetaOAuth, claude.BetaInterleavedThinking}
- }
- setHeaderRaw(req.Header, "anthropic-beta", mergeAnthropicBetaDropping(requiredBetas, incomingBeta, effectiveDropSet))
+ setHeaderRaw(req.Header, "anthropic-beta", mergeAnthropicBetaDropping(requiredBetas, incomingBeta, effectiveDropWithClaudeCodeSet))
} else {
// Claude Code 客户端:尽量透传原始 header,仅补齐 oauth beta
clientBetaHeader := getHeaderRaw(req.Header, "anthropic-beta")
@@ -5672,15 +5996,6 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
}
}
- // 同步 X-Claude-Code-Session-Id 头:取 body 中已处理的 metadata.user_id 的 session_id 覆盖
- if sessionHeader := getHeaderRaw(req.Header, "X-Claude-Code-Session-Id"); sessionHeader != "" {
- if uid := gjson.GetBytes(body, "metadata.user_id").String(); uid != "" {
- if parsed := ParseMetadataUserID(uid); parsed != nil {
- setHeaderRaw(req.Header, "X-Claude-Code-Session-Id", parsed.SessionID)
- }
- }
- }
-
// === DEBUG: 打印上游转发请求(headers + body 摘要),与 CLIENT_ORIGINAL 对比 ===
s.debugLogGatewaySnapshot("UPSTREAM_FORWARD", req.Header, body, map[string]string{
"url": req.URL.String(),
@@ -5875,7 +6190,7 @@ type betaPolicyResult struct {
}
// evaluateBetaPolicy loads settings once and evaluates all rules against the given request.
-func (s *GatewayService) evaluateBetaPolicy(ctx context.Context, betaHeader string, account *Account, model string) betaPolicyResult {
+func (s *GatewayService) evaluateBetaPolicy(ctx context.Context, betaHeader string, account *Account) betaPolicyResult {
if s.settingService == nil {
return betaPolicyResult{}
}
@@ -5890,11 +6205,10 @@ func (s *GatewayService) evaluateBetaPolicy(ctx context.Context, betaHeader stri
if !betaPolicyScopeMatches(rule.Scope, isOAuth, isBedrock) {
continue
}
- effectiveAction, effectiveErrMsg := resolveRuleAction(rule, model)
- switch effectiveAction {
+ switch rule.Action {
case BetaPolicyActionBlock:
if result.blockErr == nil && betaHeader != "" && containsBetaToken(betaHeader, rule.BetaToken) {
- msg := effectiveErrMsg
+ msg := rule.ErrorMessage
if msg == "" {
msg = "beta feature " + rule.BetaToken + " is not allowed"
}
@@ -5936,7 +6250,7 @@ const betaPolicyFilterSetKey = "betaPolicyFilterSet"
// In the /v1/messages path, Forward() evaluates the policy first and caches the result;
// buildUpstreamRequest reuses it (zero extra DB calls). In the count_tokens path, this
// evaluates on demand (one DB call).
-func (s *GatewayService) getBetaPolicyFilterSet(ctx context.Context, c *gin.Context, account *Account, model string) map[string]struct{} {
+func (s *GatewayService) getBetaPolicyFilterSet(ctx context.Context, c *gin.Context, account *Account) map[string]struct{} {
if c != nil {
if v, ok := c.Get(betaPolicyFilterSetKey); ok {
if fs, ok := v.(map[string]struct{}); ok {
@@ -5944,7 +6258,7 @@ func (s *GatewayService) getBetaPolicyFilterSet(ctx context.Context, c *gin.Cont
}
}
}
- return s.evaluateBetaPolicy(ctx, "", account, model).filterSet
+ return s.evaluateBetaPolicy(ctx, "", account).filterSet
}
// betaPolicyScopeMatches checks whether a rule's scope matches the current account type.
@@ -5963,33 +6277,6 @@ func betaPolicyScopeMatches(scope string, isOAuth bool, isBedrock bool) bool {
}
}
-// matchModelWhitelist checks if a model matches any pattern in the whitelist.
-// Reuses matchModelPattern from group.go which supports exact and wildcard prefix matching.
-func matchModelWhitelist(model string, whitelist []string) bool {
- for _, pattern := range whitelist {
- if matchModelPattern(pattern, model) {
- return true
- }
- }
- return false
-}
-
-// resolveRuleAction determines the effective action and error message for a rule given the request model.
-// When ModelWhitelist is empty, the rule's primary Action/ErrorMessage applies unconditionally.
-// When non-empty, Action applies to matching models; FallbackAction/FallbackErrorMessage applies to others.
-func resolveRuleAction(rule BetaPolicyRule, model string) (action, errorMessage string) {
- if len(rule.ModelWhitelist) == 0 {
- return rule.Action, rule.ErrorMessage
- }
- if matchModelWhitelist(model, rule.ModelWhitelist) {
- return rule.Action, rule.ErrorMessage
- }
- if rule.FallbackAction != "" {
- return rule.FallbackAction, rule.FallbackErrorMessage
- }
- return BetaPolicyActionPass, "" // default fallback: pass (fail-open)
-}
-
// droppedBetaSet returns claude.DroppedBetas as a set, with optional extra tokens.
func droppedBetaSet(extra ...string) map[string]struct{} {
m := make(map[string]struct{}, len(defaultDroppedBetasSet)+len(extra))
@@ -6036,7 +6323,7 @@ func (s *GatewayService) resolveBedrockBetaTokensForRequest(
modelID string,
) ([]string, error) {
// 1. 对原始 header 中的 beta token 做 block 检查(快速失败)
- policy := s.evaluateBetaPolicy(ctx, betaHeader, account, modelID)
+ policy := s.evaluateBetaPolicy(ctx, betaHeader, account)
if policy.blockErr != nil {
return nil, policy.blockErr
}
@@ -6048,7 +6335,7 @@ func (s *GatewayService) resolveBedrockBetaTokensForRequest(
// 例如:管理员 block 了 interleaved-thinking,客户端不在 header 中带该 token,
// 但请求体中包含 thinking 字段 → autoInjectBedrockBetaTokens 会自动补齐 →
// 如果不做此检查,block 规则会被绕过。
- if blockErr := s.checkBetaPolicyBlockForTokens(ctx, betaTokens, account, modelID); blockErr != nil {
+ if blockErr := s.checkBetaPolicyBlockForTokens(ctx, betaTokens, account); blockErr != nil {
return nil, blockErr
}
@@ -6057,7 +6344,7 @@ func (s *GatewayService) resolveBedrockBetaTokensForRequest(
// checkBetaPolicyBlockForTokens 检查 token 列表中是否有被管理员 block 规则命中的 token。
// 用于补充 evaluateBetaPolicy 对 header 的检查,覆盖 body 自动注入的 token。
-func (s *GatewayService) checkBetaPolicyBlockForTokens(ctx context.Context, tokens []string, account *Account, model string) *BetaBlockedError {
+func (s *GatewayService) checkBetaPolicyBlockForTokens(ctx context.Context, tokens []string, account *Account) *BetaBlockedError {
if s.settingService == nil || len(tokens) == 0 {
return nil
}
@@ -6069,15 +6356,14 @@ func (s *GatewayService) checkBetaPolicyBlockForTokens(ctx context.Context, toke
isBedrock := account.IsBedrock()
tokenSet := buildBetaTokenSet(tokens)
for _, rule := range settings.Rules {
- effectiveAction, effectiveErrMsg := resolveRuleAction(rule, model)
- if effectiveAction != BetaPolicyActionBlock {
+ if rule.Action != BetaPolicyActionBlock {
continue
}
if !betaPolicyScopeMatches(rule.Scope, isOAuth, isBedrock) {
continue
}
if _, present := tokenSet[rule.BetaToken]; present {
- msg := effectiveErrMsg
+ msg := rule.ErrorMessage
if msg == "" {
msg = "beta feature " + rule.BetaToken + " is not allowed"
}
@@ -6709,6 +6995,7 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http
needModelReplace := originalModel != mappedModel
clientDisconnected := false // 客户端断开标志,断开后继续读取上游以获取完整usage
sawTerminalEvent := false
+ skipAccountTTLOverride := false
pendingEventLines := make([]string, 0, 4)
@@ -6770,17 +7057,25 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http
if msg, ok := event["message"].(map[string]any); ok {
if u, ok := msg["usage"].(map[string]any); ok {
eventChanged = reconcileCachedTokens(u) || eventChanged
+ claudeMaxOutcome := applyClaudeMaxSimulationToUsageJSONMap(ctx, u, originalModel, account.ID)
+ if claudeMaxOutcome.Simulated {
+ skipAccountTTLOverride = true
+ }
}
}
}
if eventType == "message_delta" {
if u, ok := event["usage"].(map[string]any); ok {
eventChanged = reconcileCachedTokens(u) || eventChanged
+ claudeMaxOutcome := applyClaudeMaxSimulationToUsageJSONMap(ctx, u, originalModel, account.ID)
+ if claudeMaxOutcome.Simulated {
+ skipAccountTTLOverride = true
+ }
}
}
// Cache TTL Override: 重写 SSE 事件中的 cache_creation 分类
- if account.IsCacheTTLOverrideEnabled() {
+ if account.IsCacheTTLOverrideEnabled() && !skipAccountTTLOverride {
overrideTarget := account.GetCacheTTLOverrideTarget()
if eventType == "message_start" {
if msg, ok := event["message"].(map[string]any); ok {
@@ -7212,8 +7507,13 @@ func (s *GatewayService) handleNonStreamingResponse(ctx context.Context, resp *h
}
}
+ claudeMaxOutcome := applyClaudeMaxSimulationToUsage(ctx, &response.Usage, originalModel, account.ID)
+ if claudeMaxOutcome.Simulated {
+ body = rewriteClaudeUsageJSONBytes(body, response.Usage)
+ }
+
// Cache TTL Override: 重写 non-streaming 响应中的 cache_creation 分类
- if account.IsCacheTTLOverrideEnabled() {
+ if account.IsCacheTTLOverrideEnabled() && !claudeMaxOutcome.Simulated {
overrideTarget := account.GetCacheTTLOverrideTarget()
if applyCacheTTLOverride(&response.Usage, overrideTarget) {
// 同步更新 body JSON 中的嵌套 cache_creation 对象
@@ -7279,6 +7579,7 @@ func (s *GatewayService) getUserGroupRateMultiplier(ctx context.Context, userID,
// RecordUsageInput 记录使用量的输入参数
type RecordUsageInput struct {
Result *ForwardResult
+ ParsedRequest *ParsedRequest
APIKey *APIKey
User *User
Account *Account
@@ -7437,6 +7738,9 @@ func buildUsageBillingCommand(requestID string, usageLog *UsageLog, p *postUsage
cmd.CacheCreationTokens = usageLog.CacheCreationTokens
cmd.CacheReadTokens = usageLog.CacheReadTokens
cmd.ImageCount = usageLog.ImageCount
+ if usageLog.MediaType != nil {
+ cmd.MediaType = *usageLog.MediaType
+ }
if usageLog.ServiceTier != nil {
cmd.ServiceTier = *usageLog.ServiceTier
}
@@ -7592,6 +7896,8 @@ type recordUsageOpts struct {
// EnableClaudePath 启用 Claude 路径特有逻辑:
// - Claude Max 缓存计费策略
+ // - Sora 媒体类型分支(image/video/prompt)
+ // - MediaType 字段写入使用日志
EnableClaudePath bool
// 长上下文计费(仅 Gemini 路径需要)
@@ -7616,6 +7922,7 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu
APIKeyService: input.APIKeyService,
ChannelUsageFields: input.ChannelUsageFields,
}, &recordUsageOpts{
+ ParsedRequest: input.ParsedRequest,
EnableClaudePath: true,
})
}
@@ -7682,6 +7989,7 @@ type recordUsageCoreInput struct {
// recordUsageCore 是 RecordUsage 和 RecordUsageWithLongContext 的统一实现。
// opts 中的字段控制两者之间的差异行为:
// - ParsedRequest != nil → 启用 Claude Max 缓存计费策略
+// - EnableSoraMedia → 启用 Sora MediaType 分支(image/video/prompt)
// - LongContextThreshold > 0 → Token 计费回退走 CalculateCostWithLongContext
func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsageCoreInput, opts *recordUsageOpts) error {
result := input.Result
@@ -7699,9 +8007,21 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage
result.Usage.InputTokens = 0
}
- // Cache TTL Override: 确保计费时 token 分类与账号设置一致
+ // Claude Max cache billing policy(仅 Claude 路径启用)
cacheTTLOverridden := false
- if account.IsCacheTTLOverrideEnabled() {
+ simulatedClaudeMax := false
+ if opts.EnableClaudePath {
+ var apiKeyGroup *Group
+ if apiKey != nil {
+ apiKeyGroup = apiKey.Group
+ }
+ claudeMaxOutcome := applyClaudeMaxCacheBillingPolicyToUsage(&result.Usage, opts.ParsedRequest, apiKeyGroup, result.Model, account.ID)
+ simulatedClaudeMax = claudeMaxOutcome.Simulated ||
+ (shouldApplyClaudeMaxBillingRulesForUsage(apiKeyGroup, result.Model, opts.ParsedRequest) && hasCacheCreationTokens(result.Usage))
+ }
+
+ // Cache TTL Override: 确保计费时 token 分类与账号设置一致
+ if account.IsCacheTTLOverrideEnabled() && !simulatedClaudeMax {
applyCacheTTLOverride(&result.Usage, account.GetCacheTTLOverrideTarget())
cacheTTLOverridden = (result.Usage.CacheCreation5mTokens + result.Usage.CacheCreation1hTokens) > 0
}
@@ -7783,6 +8103,16 @@ func (s *GatewayService) calculateRecordUsageCost(
multiplier float64,
opts *recordUsageOpts,
) *CostBreakdown {
+ // Sora 媒体类型分支(仅 Claude 路径启用)
+ if opts.EnableClaudePath {
+ if result.MediaType == MediaTypeImage || result.MediaType == MediaTypeVideo {
+ return s.calculateSoraMediaCost(result, apiKey, billingModel, multiplier)
+ }
+ if result.MediaType == MediaTypePrompt {
+ return &CostBreakdown{}
+ }
+ }
+
// 图片生成计费
if result.ImageCount > 0 {
return s.calculateImageCost(ctx, result, apiKey, billingModel, multiplier)
@@ -7792,6 +8122,28 @@ func (s *GatewayService) calculateRecordUsageCost(
return s.calculateTokenCost(ctx, result, apiKey, billingModel, multiplier, opts)
}
+// calculateSoraMediaCost 计算 Sora 图片/视频的费用。
+func (s *GatewayService) calculateSoraMediaCost(
+ result *ForwardResult,
+ apiKey *APIKey,
+ billingModel string,
+ multiplier float64,
+) *CostBreakdown {
+ var soraConfig *SoraPriceConfig
+ if apiKey.Group != nil {
+ soraConfig = &SoraPriceConfig{
+ ImagePrice360: apiKey.Group.SoraImagePrice360,
+ ImagePrice540: apiKey.Group.SoraImagePrice540,
+ VideoPricePerRequest: apiKey.Group.SoraVideoPricePerRequest,
+ VideoPricePerRequestHD: apiKey.Group.SoraVideoPricePerRequestHD,
+ }
+ }
+ if result.MediaType == MediaTypeImage {
+ return s.billingService.CalculateSoraImageCost(result.ImageSize, result.ImageCount, soraConfig, multiplier)
+ }
+ return s.billingService.CalculateSoraVideoCost(billingModel, soraConfig, multiplier)
+}
+
// resolveChannelPricing 检查指定模型是否存在渠道级别定价。
// 返回非 nil 的 ResolvedPricing 表示有渠道定价,nil 表示走默认定价路径。
func (s *GatewayService) resolveChannelPricing(ctx context.Context, billingModel string, apiKey *APIKey) *ResolvedPricing {
@@ -7814,7 +8166,7 @@ func (s *GatewayService) calculateImageCost(
billingModel string,
multiplier float64,
) *CostBreakdown {
- if resolved := s.resolveChannelPricing(ctx, billingModel, apiKey); resolved != nil {
+ if s.resolveChannelPricing(ctx, billingModel, apiKey) != nil {
tokens := UsageTokens{
InputTokens: result.Usage.InputTokens,
OutputTokens: result.Usage.OutputTokens,
@@ -7829,7 +8181,6 @@ func (s *GatewayService) calculateImageCost(
RequestCount: 1,
RateMultiplier: multiplier,
Resolver: s.resolver,
- Resolved: resolved,
})
if err != nil {
logger.LegacyPrintf("service.gateway", "Calculate image token cost failed: %v", err)
@@ -7872,7 +8223,7 @@ func (s *GatewayService) calculateTokenCost(
var err error
// 优先尝试渠道定价 → CalculateCostUnified
- if resolved := s.resolveChannelPricing(ctx, billingModel, apiKey); resolved != nil {
+ if s.resolveChannelPricing(ctx, billingModel, apiKey) != nil {
gid := apiKey.Group.ID
cost, err = s.billingService.CalculateCostUnified(CostInput{
Ctx: ctx,
@@ -7882,7 +8233,6 @@ func (s *GatewayService) calculateTokenCost(
RequestCount: 1,
RateMultiplier: multiplier,
Resolver: s.resolver,
- Resolved: resolved,
})
} else if opts.LongContextThreshold > 0 {
// 长上下文双倍计费(如 Gemini 200K 阈值)
@@ -7940,12 +8290,13 @@ func (s *GatewayService) buildRecordUsageLog(
RateMultiplier: multiplier,
AccountRateMultiplier: &accountRateMultiplier,
BillingType: billingType,
- BillingMode: resolveBillingMode(result, cost),
+ BillingMode: resolveBillingMode(opts, result, cost),
Stream: result.Stream,
DurationMs: &durationMs,
FirstTokenMs: result.FirstTokenMs,
ImageCount: result.ImageCount,
ImageSize: optionalTrimmedStringPtr(result.ImageSize),
+ MediaType: resolveMediaType(opts, result),
CacheTTLOverridden: cacheTTLOverridden,
ChannelID: optionalInt64Ptr(input.ChannelID),
ModelMappingChain: optionalTrimmedStringPtr(input.ModelMappingChain),
@@ -7969,7 +8320,13 @@ func (s *GatewayService) buildRecordUsageLog(
}
// resolveBillingMode 根据计费结果和请求类型确定计费模式。
-func resolveBillingMode(result *ForwardResult, cost *CostBreakdown) *string {
+// Sora 媒体类型自身已确定计费模式(由上游处理),返回 nil 跳过。
+func resolveBillingMode(opts *recordUsageOpts, result *ForwardResult, cost *CostBreakdown) *string {
+ isSoraMedia := opts.EnableClaudePath &&
+ (result.MediaType == MediaTypeImage || result.MediaType == MediaTypeVideo || result.MediaType == MediaTypePrompt)
+ if isSoraMedia {
+ return nil
+ }
var mode string
switch {
case cost != nil && cost.BillingMode != "":
@@ -7982,6 +8339,13 @@ func resolveBillingMode(result *ForwardResult, cost *CostBreakdown) *string {
return &mode
}
+func resolveMediaType(opts *recordUsageOpts, result *ForwardResult) *string {
+ if opts.EnableClaudePath && strings.TrimSpace(result.MediaType) != "" {
+ return &result.MediaType
+ }
+ return nil
+}
+
func optionalSubscriptionID(subscription *UserSubscription) *int64 {
if subscription != nil {
return &subscription.ID
@@ -8010,8 +8374,8 @@ func (s *GatewayService) IsModelRestricted(ctx context.Context, groupID int64, m
return s.channelService.IsModelRestricted(ctx, groupID, model)
}
-// ResolveChannelMappingAndRestrict 解析渠道映射。
-// 模型限制检查已移至调度阶段(checkChannelPricingRestriction),restricted 始终返回 false。
+// ResolveChannelMappingAndRestrict 解析渠道映射并检查模型限制。
+// 返回映射结果和是否被限制。
func (s *GatewayService) ResolveChannelMappingAndRestrict(ctx context.Context, groupID *int64, model string) (ChannelMappingResult, bool) {
if s.channelService == nil {
return ChannelMappingResult{MappedModel: model}, false
@@ -8042,9 +8406,7 @@ func billingModelForRestriction(source, requestedModel, channelMappedModel strin
return requestedModel
case BillingModelSourceUpstream:
return ""
- case BillingModelSourceChannelMapped:
- return channelMappedModel
- default:
+ default: // channel_mapped
return channelMappedModel
}
}
@@ -8076,11 +8438,7 @@ func (s *GatewayService) needsUpstreamChannelRestrictionCheck(ctx context.Contex
return false
}
ch, err := s.channelService.GetChannelForGroup(ctx, *groupID)
- if err != nil {
- slog.Warn("failed to check channel upstream restriction", "group_id", *groupID, "error", err)
- return false
- }
- if ch == nil || !ch.RestrictModels {
+ if err != nil || ch == nil || !ch.RestrictModels {
return false
}
return ch.BillingModelSource == BillingModelSourceUpstream
@@ -8172,12 +8530,10 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
return err
}
- // 获取代理URL(自定义 base URL 模式下,proxy 通过 buildCustomRelayURL 作为查询参数传递)
+ // 获取代理URL
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
- if !account.IsCustomBaseURLEnabled() || account.GetCustomBaseURL() == "" {
- proxyURL = account.Proxy.URL()
- }
+ proxyURL = account.Proxy.URL()
}
// 发送请求
@@ -8456,16 +8812,6 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
}
targetURL = validatedURL + "/v1/messages/count_tokens?beta=true"
}
- } else if account.IsCustomBaseURLEnabled() {
- customURL := account.GetCustomBaseURL()
- if customURL == "" {
- return nil, fmt.Errorf("custom_base_url is enabled but not configured for account %d", account.ID)
- }
- validatedURL, err := s.validateUpstreamBaseURL(customURL)
- if err != nil {
- return nil, err
- }
- targetURL = s.buildCustomRelayURL(validatedURL, "/v1/messages/count_tokens", account)
}
clientHeaders := http.Header{}
@@ -8475,9 +8821,9 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
// OAuth 账号:应用统一指纹和重写 userID(受设置开关控制)
// 如果启用了会话ID伪装,会在重写后替换 session 部分为固定值
- ctEnableFP, ctEnableMPT, ctEnableCCH := true, false, false
+ ctEnableFP, ctEnableMPT := true, false
if s.settingService != nil {
- ctEnableFP, ctEnableMPT, ctEnableCCH = s.settingService.GetGatewayForwardingSettings(ctx)
+ ctEnableFP, ctEnableMPT = s.settingService.GetGatewayForwardingSettings(ctx)
}
var ctFingerprint *Fingerprint
if account.IsOAuth() && s.identityService != nil {
@@ -8495,14 +8841,6 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
}
}
- // 同步 billing header cc_version 与实际发送的 User-Agent 版本
- if ctFingerprint != nil && ctEnableFP {
- body = syncBillingHeaderVersion(body, ctFingerprint.UserAgent)
- }
- if ctEnableCCH {
- body = signBillingHeaderCCH(body)
- }
-
req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body))
if err != nil {
return nil, err
@@ -8543,7 +8881,7 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
}
// Build effective drop set for count_tokens: merge static defaults with dynamic beta policy filter rules
- ctEffectiveDropSet := mergeDropSets(s.getBetaPolicyFilterSet(ctx, c, account, modelID))
+ ctEffectiveDropSet := mergeDropSets(s.getBetaPolicyFilterSet(ctx, c, account))
// OAuth 账号:处理 anthropic-beta header
if tokenType == "oauth" {
@@ -8579,15 +8917,6 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
}
}
- // 同步 X-Claude-Code-Session-Id 头:取 body 中已处理的 metadata.user_id 的 session_id 覆盖
- if sessionHeader := getHeaderRaw(req.Header, "X-Claude-Code-Session-Id"); sessionHeader != "" {
- if uid := gjson.GetBytes(body, "metadata.user_id").String(); uid != "" {
- if parsed := ParseMetadataUserID(uid); parsed != nil {
- setHeaderRaw(req.Header, "X-Claude-Code-Session-Id", parsed.SessionID)
- }
- }
- }
-
if c != nil && tokenType == "oauth" {
c.Set(claudeMimicDebugInfoKey, buildClaudeMimicDebugLine(req, body, account, tokenType, mimicClaudeCode))
}
@@ -8609,19 +8938,6 @@ func (s *GatewayService) countTokensError(c *gin.Context, status int, errType, m
})
}
-// buildCustomRelayURL 构建自定义中继转发 URL
-// 在 path 后附加 beta=true 和可选的 proxy 查询参数
-func (s *GatewayService) buildCustomRelayURL(baseURL, path string, account *Account) string {
- u := strings.TrimRight(baseURL, "/") + path + "?beta=true"
- if account.ProxyID != nil && account.Proxy != nil {
- proxyURL := account.Proxy.URL()
- if proxyURL != "" {
- u += "&proxy=" + url.QueryEscape(proxyURL)
- }
- }
- return u
-}
-
func (s *GatewayService) validateUpstreamBaseURL(raw string) (string, error) {
if s.cfg != nil && !s.cfg.Security.URLAllowlist.Enabled {
normalized, err := urlvalidator.ValidateURLFormat(raw, s.cfg.Security.URLAllowlist.AllowInsecureHTTP)
From 160903fce7841b99d761b30372f793bcbf448c5f Mon Sep 17 00:00:00 2001
From: erio
Date: Thu, 2 Apr 2026 13:36:58 +0800
Subject: [PATCH 015/122] fix: address review findings for channel restriction
refactoring
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Fix 7 stale comments still mentioning "限制检查" in handlers/services
- Make billingModelForRestriction explicitly list channel_mapped case
- Add slog.Warn for error swallowing in ResolveChannelMapping and
needsUpstreamChannelRestrictionCheck
- Document sticky session upstream check exemption
---
.../handler/gateway_handler_chat_completions.go | 2 +-
.../handler/gateway_handler_responses.go | 2 +-
.../internal/handler/gemini_v1beta_handler.go | 2 +-
.../internal/handler/openai_chat_completions.go | 2 +-
.../internal/handler/openai_gateway_handler.go | 2 +-
backend/internal/service/gateway_service.go | 17 +++++++++++++----
6 files changed, 18 insertions(+), 9 deletions(-)
diff --git a/backend/internal/handler/gateway_handler_chat_completions.go b/backend/internal/handler/gateway_handler_chat_completions.go
index abe2a1e5..be267332 100644
--- a/backend/internal/handler/gateway_handler_chat_completions.go
+++ b/backend/internal/handler/gateway_handler_chat_completions.go
@@ -80,7 +80,7 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
setOpsRequestContext(c, reqModel, reqStream, body)
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false)))
- // 解析渠道级模型映射 + 限制检查
+ // 解析渠道级模型映射
channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel)
// Claude Code only restriction
diff --git a/backend/internal/handler/gateway_handler_responses.go b/backend/internal/handler/gateway_handler_responses.go
index cf877182..e908eb9e 100644
--- a/backend/internal/handler/gateway_handler_responses.go
+++ b/backend/internal/handler/gateway_handler_responses.go
@@ -80,7 +80,7 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
setOpsRequestContext(c, reqModel, reqStream, body)
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false)))
- // 解析渠道级模型映射 + 限制检查
+ // 解析渠道级模型映射
channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel)
// Claude Code only restriction:
diff --git a/backend/internal/handler/gemini_v1beta_handler.go b/backend/internal/handler/gemini_v1beta_handler.go
index ff63bc7f..45b5842f 100644
--- a/backend/internal/handler/gemini_v1beta_handler.go
+++ b/backend/internal/handler/gemini_v1beta_handler.go
@@ -184,7 +184,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
setOpsRequestContext(c, modelName, stream, body)
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(stream, false)))
- // 解析渠道级模型映射 + 限制检查
+ // 解析渠道级模型映射
channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, modelName)
reqModel := modelName // 保存映射前的原始模型名
if channelMapping.Mapped {
diff --git a/backend/internal/handler/openai_chat_completions.go b/backend/internal/handler/openai_chat_completions.go
index ada401c9..991cbb91 100644
--- a/backend/internal/handler/openai_chat_completions.go
+++ b/backend/internal/handler/openai_chat_completions.go
@@ -79,7 +79,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
setOpsRequestContext(c, reqModel, reqStream, body)
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false)))
- // 解析渠道级模型映射 + 限制检查
+ // 解析渠道级模型映射
channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel)
if h.errorPassthroughService != nil {
diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go
index 2b081617..dda6d2e3 100644
--- a/backend/internal/handler/openai_gateway_handler.go
+++ b/backend/internal/handler/openai_gateway_handler.go
@@ -1118,7 +1118,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
setOpsRequestContext(c, reqModel, true, firstMessage)
setOpsEndpointContext(c, "", int16(service.RequestTypeWSV2))
- // 解析渠道级模型映射 + 限制检查
+ // 解析渠道级模型映射
channelMappingWS, _ := h.gatewayService.ResolveChannelMappingAndRestrict(ctx, apiKey.GroupID, reqModel)
var currentUserRelease func()
diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go
index 33ab38f2..24f36113 100644
--- a/backend/internal/service/gateway_service.go
+++ b/backend/internal/service/gateway_service.go
@@ -3143,6 +3143,8 @@ func (s *GatewayService) selectAccountForModelWithPlatform(ctx context.Context,
ctx = s.withRPMPrefetch(ctx, accounts)
// 3. 按优先级+最久未用选择(考虑模型支持)
+ // needsUpstreamCheck 仅在主选择循环中使用;粘性会话命中时跳过此检查,
+ // 因为粘性会话优先保持连接一致性,且 upstream 计费基准极少使用。
needsUpstreamCheck := s.needsUpstreamChannelRestrictionCheck(ctx, groupID)
var selected *Account
for i := range accounts {
@@ -3381,6 +3383,7 @@ func (s *GatewayService) selectAccountWithMixedScheduling(ctx context.Context, g
ctx = s.withRPMPrefetch(ctx, accounts)
// 3. 按优先级+最久未用选择(考虑模型支持和混合调度)
+ // needsUpstreamCheck 仅在主选择循环中使用;粘性会话命中时跳过此检查。
needsUpstreamCheck := s.needsUpstreamChannelRestrictionCheck(ctx, groupID)
var selected *Account
for i := range accounts {
@@ -8374,8 +8377,8 @@ func (s *GatewayService) IsModelRestricted(ctx context.Context, groupID int64, m
return s.channelService.IsModelRestricted(ctx, groupID, model)
}
-// ResolveChannelMappingAndRestrict 解析渠道映射并检查模型限制。
-// 返回映射结果和是否被限制。
+// ResolveChannelMappingAndRestrict 解析渠道映射。
+// 模型限制检查已移至调度阶段(checkChannelPricingRestriction),restricted 始终返回 false。
func (s *GatewayService) ResolveChannelMappingAndRestrict(ctx context.Context, groupID *int64, model string) (ChannelMappingResult, bool) {
if s.channelService == nil {
return ChannelMappingResult{MappedModel: model}, false
@@ -8406,7 +8409,9 @@ func billingModelForRestriction(source, requestedModel, channelMappedModel strin
return requestedModel
case BillingModelSourceUpstream:
return ""
- default: // channel_mapped
+ case BillingModelSourceChannelMapped:
+ return channelMappedModel
+ default:
return channelMappedModel
}
}
@@ -8438,7 +8443,11 @@ func (s *GatewayService) needsUpstreamChannelRestrictionCheck(ctx context.Contex
return false
}
ch, err := s.channelService.GetChannelForGroup(ctx, *groupID)
- if err != nil || ch == nil || !ch.RestrictModels {
+ if err != nil {
+ slog.Warn("failed to check channel upstream restriction", "group_id", *groupID, "error", err)
+ return false
+ }
+ if ch == nil || !ch.RestrictModels {
return false
}
return ch.BillingModelSource == BillingModelSourceUpstream
From e3748741257c2c6b96ced2c0c0284dd31cc5e358 Mon Sep 17 00:00:00 2001
From: erio
Date: Fri, 3 Apr 2026 13:54:18 +0800
Subject: [PATCH 016/122] feat(channel): improve cache strategy and add
restriction logging
- Change channel cache TTL from 60s to 10min (reduce unnecessary DB queries)
- Actively rebuild cache after CRUD instead of lazy invalidation
- Add slog.Warn logging for channel pricing restriction blocks (4 places)
---
backend/internal/service/channel_service.go | 378 ++++++++------------
backend/internal/service/gateway_service.go | 46 ++-
2 files changed, 183 insertions(+), 241 deletions(-)
diff --git a/backend/internal/service/channel_service.go b/backend/internal/service/channel_service.go
index 9667cb98..c6a249ef 100644
--- a/backend/internal/service/channel_service.go
+++ b/backend/internal/service/channel_service.go
@@ -134,7 +134,7 @@ func (r ChannelMappingResult) ToUsageFields(reqModel, upstreamModel string) Chan
const (
channelCacheTTL = 10 * time.Minute
- channelErrorTTL = 5 * time.Second // DB 错误时的短缓存
+ channelErrorTTL = 5 * time.Second // DB 错误时的短缓存
channelCacheDBTimeout = 10 * time.Second
)
@@ -197,8 +197,10 @@ func newEmptyChannelCache() *channelCache {
}
// expandPricingToCache 将渠道的模型定价展开到缓存(按分组+平台维度)。
-// 各平台严格独立:antigravity 分组只匹配 antigravity 定价,不会匹配 anthropic/gemini 的定价。
-// 查找时通过 lookupPricingAcrossPlatforms() 在本平台内查找。
+// antigravity 平台同时服务 Claude 和 Gemini 模型,需匹配 anthropic/gemini 的定价条目。
+// 缓存 key 使用定价条目的原始平台(pricing.Platform),而非分组平台,
+// 避免跨平台同名模型(如 anthropic 和 gemini 都有 "model-x")互相覆盖。
+// 查找时通过 lookupPricingAcrossPlatforms() 依次尝试所有匹配平台。
func expandPricingToCache(cache *channelCache, ch *Channel, gid int64, platform string) {
for j := range ch.ModelPricing {
pricing := &ch.ModelPricing[j]
@@ -224,7 +226,8 @@ func expandPricingToCache(cache *channelCache, ch *Channel, gid int64, platform
}
// expandMappingToCache 将渠道的模型映射展开到缓存(按分组+平台维度)。
-// 各平台严格独立:antigravity 分组只匹配 antigravity 映射。
+// antigravity 平台同时服务 Claude 和 Gemini 模型。
+// 缓存 key 使用映射条目的原始平台(mappingPlatform),避免跨平台同名映射覆盖。
func expandMappingToCache(cache *channelCache, ch *Channel, gid int64, platform string) {
for _, mappingPlatform := range matchingPlatforms(platform) {
platformMapping, ok := ch.ModelMapping[mappingPlatform]
@@ -248,58 +251,40 @@ func expandMappingToCache(cache *channelCache, ch *Channel, gid int64, platform
}
}
-// storeErrorCache 存入短 TTL 空缓存,防止 DB 错误后紧密重试。
-// 通过回退 loadedAt 使剩余 TTL = channelErrorTTL。
-func (s *ChannelService) storeErrorCache() {
- errorCache := newEmptyChannelCache()
- errorCache.loadedAt = time.Now().Add(-(channelCacheTTL - channelErrorTTL))
- s.cache.Store(errorCache)
-}
-
// buildCache 从数据库构建渠道缓存。
// 使用独立 context 避免请求取消导致空值被长期缓存。
func (s *ChannelService) buildCache(ctx context.Context) (*channelCache, error) {
+ // 断开请求取消链,避免客户端断连导致空值被长期缓存
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), channelCacheDBTimeout)
defer cancel()
- channels, groupPlatforms, err := s.fetchChannelData(dbCtx)
- if err != nil {
- return nil, err
- }
-
- cache := populateChannelCache(channels, groupPlatforms)
- s.cache.Store(cache)
- return cache, nil
-}
-
-// fetchChannelData 从数据库加载渠道列表和分组平台映射。
-func (s *ChannelService) fetchChannelData(ctx context.Context) ([]Channel, map[int64]string, error) {
- channels, err := s.repo.ListAll(ctx)
+ channels, err := s.repo.ListAll(dbCtx)
if err != nil {
+ // error-TTL:失败时存入短 TTL 空缓存,防止紧密重试
slog.Warn("failed to build channel cache", "error", err)
- s.storeErrorCache()
- return nil, nil, fmt.Errorf("list all channels: %w", err)
+ errorCache := newEmptyChannelCache()
+ errorCache.loadedAt = time.Now().Add(-(channelCacheTTL - channelErrorTTL)) // 使剩余 TTL = errorTTL
+ s.cache.Store(errorCache)
+ return nil, fmt.Errorf("list all channels: %w", err)
}
+ // 收集所有 groupID,批量查询 platform
var allGroupIDs []int64
for i := range channels {
allGroupIDs = append(allGroupIDs, channels[i].GroupIDs...)
}
-
groupPlatforms := make(map[int64]string)
if len(allGroupIDs) > 0 {
- groupPlatforms, err = s.repo.GetGroupPlatforms(ctx, allGroupIDs)
+ groupPlatforms, err = s.repo.GetGroupPlatforms(dbCtx, allGroupIDs)
if err != nil {
slog.Warn("failed to load group platforms for channel cache", "error", err)
- s.storeErrorCache()
- return nil, nil, fmt.Errorf("get group platforms: %w", err)
+ errorCache := newEmptyChannelCache()
+ errorCache.loadedAt = time.Now().Add(-(channelCacheTTL - channelErrorTTL))
+ s.cache.Store(errorCache)
+ return nil, fmt.Errorf("get group platforms: %w", err)
}
}
- return channels, groupPlatforms, nil
-}
-// populateChannelCache 将渠道列表和分组平台映射填充到缓存快照中。
-func populateChannelCache(channels []Channel, groupPlatforms map[int64]string) *channelCache {
cache := newEmptyChannelCache()
cache.groupPlatform = groupPlatforms
cache.byID = make(map[int64]*Channel, len(channels))
@@ -308,6 +293,7 @@ func populateChannelCache(channels []Channel, groupPlatforms map[int64]string) *
for i := range channels {
ch := &channels[i]
cache.byID[ch.ID] = ch
+
for _, gid := range ch.GroupIDs {
cache.channelByGroupID[gid] = ch
platform := groupPlatforms[gid]
@@ -315,20 +301,33 @@ func populateChannelCache(channels []Channel, groupPlatforms map[int64]string) *
expandMappingToCache(cache, ch, gid, platform)
}
}
- return cache
+
+ // 通配符条目保持配置顺序(最先匹配到优先)
+
+ s.cache.Store(cache)
+ return cache, nil
}
// invalidateCache 使缓存失效,让下次读取时自然重建
// isPlatformPricingMatch 判断定价条目的平台是否匹配分组平台。
-// 各平台(antigravity / anthropic / gemini / openai)严格独立,不跨平台匹配。
+// antigravity 平台同时服务 Claude(anthropic)和 Gemini(gemini)模型,
+// 因此 antigravity 分组应匹配 anthropic 和 gemini 的定价条目。
func isPlatformPricingMatch(groupPlatform, pricingPlatform string) bool {
- return groupPlatform == pricingPlatform
+ if groupPlatform == pricingPlatform {
+ return true
+ }
+ if groupPlatform == PlatformAntigravity {
+ return pricingPlatform == PlatformAnthropic || pricingPlatform == PlatformGemini
+ }
+ return false
}
-// matchingPlatforms 返回分组平台对应的可匹配平台列表。
-// 各平台严格独立,只返回自身。
+// matchingPlatforms 返回分组平台对应的所有可匹配平台列表。
func matchingPlatforms(groupPlatform string) []string {
+ if groupPlatform == PlatformAntigravity {
+ return []string{PlatformAntigravity, PlatformAnthropic, PlatformGemini}
+ }
return []string{groupPlatform}
}
func (s *ChannelService) invalidateCache() {
@@ -365,8 +364,10 @@ func (c *channelCache) matchWildcardMapping(groupID int64, platform, modelLower
return ""
}
-// lookupPricingAcrossPlatforms 在分组平台内查找模型定价。
-// 各平台严格独立,只在本平台内查找(先精确匹配,再通配符)。
+// lookupPricingAcrossPlatforms 在所有匹配平台中查找模型定价。
+// antigravity 分组的缓存 key 使用定价条目的原始平台,因此查找时需依次尝试
+// matchingPlatforms() 返回的所有平台(antigravity → anthropic → gemini),
+// 返回第一个命中的结果。非 antigravity 平台只尝试自身。
func lookupPricingAcrossPlatforms(cache *channelCache, groupID int64, groupPlatform, modelLower string) *ChannelModelPricing {
for _, p := range matchingPlatforms(groupPlatform) {
key := channelModelKey{groupID: groupID, platform: p, model: modelLower}
@@ -383,7 +384,7 @@ func lookupPricingAcrossPlatforms(cache *channelCache, groupID int64, groupPlatf
return nil
}
-// lookupMappingAcrossPlatforms 在分组平台内查找模型映射。
+// lookupMappingAcrossPlatforms 在所有匹配平台中查找模型映射。
// 逻辑与 lookupPricingAcrossPlatforms 相同:先精确查找,再通配符。
func lookupMappingAcrossPlatforms(cache *channelCache, groupID int64, groupPlatform, modelLower string) string {
for _, p := range matchingPlatforms(groupPlatform) {
@@ -441,7 +442,8 @@ func (s *ChannelService) lookupGroupChannel(ctx context.Context, groupID int64)
}
// GetChannelModelPricing 获取指定分组+模型的渠道定价(热路径 O(1))。
-// 各平台严格独立,只在本平台内查找定价。
+// antigravity 分组依次尝试所有匹配平台(antigravity → anthropic → gemini),
+// 确保跨平台同名模型各自独立匹配。
func (s *ChannelService) GetChannelModelPricing(ctx context.Context, groupID int64, model string) *ChannelModelPricing {
lk, err := s.lookupGroupChannel(ctx, groupID)
if err != nil {
@@ -479,10 +481,7 @@ func (s *ChannelService) ResolveChannelMapping(ctx context.Context, groupID int6
// 返回 true 表示模型被限制(不在允许列表中)。
// 如果渠道未启用模型限制或分组无渠道关联,返回 false。
func (s *ChannelService) IsModelRestricted(ctx context.Context, groupID int64, model string) bool {
- lk, err := s.lookupGroupChannel(ctx, groupID)
- if err != nil {
- slog.Warn("failed to load channel cache for model restriction check", "group_id", groupID, "error", err)
- }
+ lk, _ := s.lookupGroupChannel(ctx, groupID)
if lk == nil {
return false
}
@@ -525,7 +524,7 @@ func resolveMapping(lk *channelLookup, groupID int64, model string) ChannelMappi
}
// checkRestricted 基于已查找的渠道信息检查模型是否被限制。
-// 只在本平台的定价列表中查找。
+// antigravity 分组依次尝试所有匹配平台的定价列表。
func checkRestricted(lk *channelLookup, groupID int64, model string) bool {
if !lk.channel.RestrictModels {
return false
@@ -553,91 +552,6 @@ func ReplaceModelInBody(body []byte, newModel string) []byte {
return newBody
}
-// validateChannelConfig 校验渠道的定价和映射配置(冲突检测 + 区间校验 + 计费模式校验)。
-// Create 和 Update 共用此函数,避免重复。
-func validateChannelConfig(pricing []ChannelModelPricing, mapping map[string]map[string]string) error {
- if err := validateNoConflictingModels(pricing); err != nil {
- return err
- }
- if err := validatePricingIntervals(pricing); err != nil {
- return err
- }
- if err := validateNoConflictingMappings(mapping); err != nil {
- return err
- }
- return validatePricingBillingMode(pricing)
-}
-
-// validatePricingBillingMode 校验计费模式配置:按次/图片模式必须配价格或区间,所有价格字段不能为负,区间至少有一个价格字段。
-func validatePricingBillingMode(pricing []ChannelModelPricing) error {
- for _, p := range pricing {
- if err := checkBillingModeRequirements(p); err != nil {
- return err
- }
- if err := checkPricesNotNegative(p); err != nil {
- return err
- }
- if err := checkIntervalsHavePrices(p); err != nil {
- return err
- }
- }
- return nil
-}
-
-func checkBillingModeRequirements(p ChannelModelPricing) error {
- if p.BillingMode == BillingModePerRequest || p.BillingMode == BillingModeImage {
- if p.PerRequestPrice == nil && len(p.Intervals) == 0 {
- return infraerrors.BadRequest(
- "BILLING_MODE_MISSING_PRICE",
- "per-request price or intervals required for per_request/image billing mode",
- )
- }
- }
- return nil
-}
-
-func checkPricesNotNegative(p ChannelModelPricing) error {
- checks := []struct {
- field string
- val *float64
- }{
- {"input_price", p.InputPrice},
- {"output_price", p.OutputPrice},
- {"cache_write_price", p.CacheWritePrice},
- {"cache_read_price", p.CacheReadPrice},
- {"image_output_price", p.ImageOutputPrice},
- {"per_request_price", p.PerRequestPrice},
- }
- for _, c := range checks {
- if c.val != nil && *c.val < 0 {
- return infraerrors.BadRequest("NEGATIVE_PRICE", fmt.Sprintf("%s must be >= 0", c.field))
- }
- }
- return nil
-}
-
-func checkIntervalsHavePrices(p ChannelModelPricing) error {
- for _, iv := range p.Intervals {
- if iv.InputPrice == nil && iv.OutputPrice == nil &&
- iv.CacheWritePrice == nil && iv.CacheReadPrice == nil &&
- iv.PerRequestPrice == nil {
- return infraerrors.BadRequest(
- "INTERVAL_MISSING_PRICE",
- fmt.Sprintf("interval [%d, %s] has no price fields set for model %v",
- iv.MinTokens, formatMaxTokens(iv.MaxTokens), p.Models),
- )
- }
- }
- return nil
-}
-
-func formatMaxTokens(max *int) string {
- if max == nil {
- return "∞"
- }
- return fmt.Sprintf("%d", *max)
-}
-
// --- CRUD ---
// Create 创建渠道
@@ -650,8 +564,15 @@ func (s *ChannelService) Create(ctx context.Context, input *CreateChannelInput)
return nil, ErrChannelExists
}
- if err := s.checkGroupConflicts(ctx, 0, input.GroupIDs); err != nil {
- return nil, err
+ // 检查分组冲突
+ if len(input.GroupIDs) > 0 {
+ conflicting, err := s.repo.GetGroupsInOtherChannels(ctx, 0, input.GroupIDs)
+ if err != nil {
+ return nil, fmt.Errorf("check group conflicts: %w", err)
+ }
+ if len(conflicting) > 0 {
+ return nil, ErrGroupAlreadyInChannel
+ }
}
channel := &Channel{
@@ -668,7 +589,13 @@ func (s *ChannelService) Create(ctx context.Context, input *CreateChannelInput)
channel.BillingModelSource = BillingModelSourceChannelMapped
}
- if err := validateChannelConfig(channel.ModelPricing, channel.ModelMapping); err != nil {
+ if err := validateNoConflictingModels(channel.ModelPricing); err != nil {
+ return nil, err
+ }
+ if err := validatePricingIntervals(channel.ModelPricing); err != nil {
+ return nil, err
+ }
+ if err := validateNoConflictingMappings(channel.ModelMapping); err != nil {
return nil, err
}
@@ -692,112 +619,102 @@ func (s *ChannelService) Update(ctx context.Context, id int64, input *UpdateChan
return nil, fmt.Errorf("get channel: %w", err)
}
- if err := s.applyUpdateInput(ctx, channel, input); err != nil {
+ if input.Name != "" && input.Name != channel.Name {
+ exists, err := s.repo.ExistsByNameExcluding(ctx, input.Name, id)
+ if err != nil {
+ return nil, fmt.Errorf("check channel exists: %w", err)
+ }
+ if exists {
+ return nil, ErrChannelExists
+ }
+ channel.Name = input.Name
+ }
+
+ if input.Description != nil {
+ channel.Description = *input.Description
+ }
+
+ if input.Status != "" {
+ channel.Status = input.Status
+ }
+
+ if input.RestrictModels != nil {
+ channel.RestrictModels = *input.RestrictModels
+ }
+
+ // 检查分组冲突
+ if input.GroupIDs != nil {
+ conflicting, err := s.repo.GetGroupsInOtherChannels(ctx, id, *input.GroupIDs)
+ if err != nil {
+ return nil, fmt.Errorf("check group conflicts: %w", err)
+ }
+ if len(conflicting) > 0 {
+ return nil, ErrGroupAlreadyInChannel
+ }
+ channel.GroupIDs = *input.GroupIDs
+ }
+
+ if input.ModelPricing != nil {
+ channel.ModelPricing = *input.ModelPricing
+ }
+
+ if input.ModelMapping != nil {
+ channel.ModelMapping = input.ModelMapping
+ }
+
+ if input.BillingModelSource != "" {
+ channel.BillingModelSource = input.BillingModelSource
+ }
+
+ if err := validateNoConflictingModels(channel.ModelPricing); err != nil {
+ return nil, err
+ }
+ if err := validatePricingIntervals(channel.ModelPricing); err != nil {
+ return nil, err
+ }
+ if err := validateNoConflictingMappings(channel.ModelMapping); err != nil {
return nil, err
}
- if err := validateChannelConfig(channel.ModelPricing, channel.ModelMapping); err != nil {
- return nil, err
+ // 先获取旧分组,Update 后旧分组关联已删除,无法再查到
+ var oldGroupIDs []int64
+ if s.authCacheInvalidator != nil {
+ var err2 error
+ oldGroupIDs, err2 = s.repo.GetGroupIDs(ctx, id)
+ if err2 != nil {
+ slog.Warn("failed to get old group IDs for cache invalidation", "channel_id", id, "error", err2)
+ }
}
- oldGroupIDs := s.getOldGroupIDs(ctx, id)
-
if err := s.repo.Update(ctx, channel); err != nil {
return nil, fmt.Errorf("update channel: %w", err)
}
s.invalidateCache()
- s.invalidateAuthCacheForGroups(ctx, oldGroupIDs, channel.GroupIDs)
+
+ // 失效新旧分组的 auth 缓存
+ if s.authCacheInvalidator != nil {
+ seen := make(map[int64]struct{}, len(oldGroupIDs)+len(channel.GroupIDs))
+ for _, gid := range oldGroupIDs {
+ if _, ok := seen[gid]; !ok {
+ seen[gid] = struct{}{}
+ s.authCacheInvalidator.InvalidateAuthCacheByGroupID(ctx, gid)
+ }
+ }
+ for _, gid := range channel.GroupIDs {
+ if _, ok := seen[gid]; !ok {
+ seen[gid] = struct{}{}
+ s.authCacheInvalidator.InvalidateAuthCacheByGroupID(ctx, gid)
+ }
+ }
+ }
return s.repo.GetByID(ctx, id)
}
-// applyUpdateInput 将更新请求的字段应用到渠道实体上。
-func (s *ChannelService) applyUpdateInput(ctx context.Context, channel *Channel, input *UpdateChannelInput) error {
- if input.Name != "" && input.Name != channel.Name {
- exists, err := s.repo.ExistsByNameExcluding(ctx, input.Name, channel.ID)
- if err != nil {
- return fmt.Errorf("check channel exists: %w", err)
- }
- if exists {
- return ErrChannelExists
- }
- channel.Name = input.Name
- }
- if input.Description != nil {
- channel.Description = *input.Description
- }
- if input.Status != "" {
- channel.Status = input.Status
- }
- if input.RestrictModels != nil {
- channel.RestrictModels = *input.RestrictModels
- }
- if input.GroupIDs != nil {
- if err := s.checkGroupConflicts(ctx, channel.ID, *input.GroupIDs); err != nil {
- return err
- }
- channel.GroupIDs = *input.GroupIDs
- }
- if input.ModelPricing != nil {
- channel.ModelPricing = *input.ModelPricing
- }
- if input.ModelMapping != nil {
- channel.ModelMapping = input.ModelMapping
- }
- if input.BillingModelSource != "" {
- channel.BillingModelSource = input.BillingModelSource
- }
- return nil
-}
-
-// checkGroupConflicts 检查待关联的分组是否已属于其他渠道。
-// channelID 为当前渠道 ID(Create 时传 0)。
-func (s *ChannelService) checkGroupConflicts(ctx context.Context, channelID int64, groupIDs []int64) error {
- if len(groupIDs) == 0 {
- return nil
- }
- conflicting, err := s.repo.GetGroupsInOtherChannels(ctx, channelID, groupIDs)
- if err != nil {
- return fmt.Errorf("check group conflicts: %w", err)
- }
- if len(conflicting) > 0 {
- return ErrGroupAlreadyInChannel
- }
- return nil
-}
-
-// getOldGroupIDs 获取渠道更新前的关联分组 ID(用于失效 auth 缓存)。
-func (s *ChannelService) getOldGroupIDs(ctx context.Context, channelID int64) []int64 {
- if s.authCacheInvalidator == nil {
- return nil
- }
- oldGroupIDs, err := s.repo.GetGroupIDs(ctx, channelID)
- if err != nil {
- slog.Warn("failed to get old group IDs for cache invalidation", "channel_id", channelID, "error", err)
- }
- return oldGroupIDs
-}
-
-// invalidateAuthCacheForGroups 对新旧分组去重后逐个失效 auth 缓存。
-func (s *ChannelService) invalidateAuthCacheForGroups(ctx context.Context, groupIDSets ...[]int64) {
- if s.authCacheInvalidator == nil {
- return
- }
- seen := make(map[int64]struct{})
- for _, ids := range groupIDSets {
- for _, gid := range ids {
- if _, ok := seen[gid]; ok {
- continue
- }
- seen[gid] = struct{}{}
- s.authCacheInvalidator.InvalidateAuthCacheByGroupID(ctx, gid)
- }
- }
-}
-
// Delete 删除渠道
func (s *ChannelService) Delete(ctx context.Context, id int64) error {
+ // 先获取关联分组用于失效缓存
groupIDs, err := s.repo.GetGroupIDs(ctx, id)
if err != nil {
slog.Warn("failed to get group IDs before delete", "channel_id", id, "error", err)
@@ -808,7 +725,12 @@ func (s *ChannelService) Delete(ctx context.Context, id int64) error {
}
s.invalidateCache()
- s.invalidateAuthCacheForGroups(ctx, groupIDs)
+
+ if s.authCacheInvalidator != nil {
+ for _, gid := range groupIDs {
+ s.authCacheInvalidator.InvalidateAuthCacheByGroupID(ctx, gid)
+ }
+ }
return nil
}
diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go
index 24f36113..31137fb4 100644
--- a/backend/internal/service/gateway_service.go
+++ b/backend/internal/service/gateway_service.go
@@ -1234,11 +1234,6 @@ func (s *GatewayService) SelectAccountForModel(ctx context.Context, groupID *int
// SelectAccountForModelWithExclusions selects an account supporting the requested model while excluding specified accounts.
func (s *GatewayService) SelectAccountForModelWithExclusions(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}) (*Account, error) {
- // 渠道定价限制预检查(requested / channel_mapped 基准)
- if s.checkChannelPricingRestriction(ctx, groupID, requestedModel) {
- return nil, fmt.Errorf("%w supporting model: %s (channel pricing restriction)", ErrNoAvailableAccounts, requestedModel)
- }
-
// 优先检查 context 中的强制平台(/antigravity 路由)
var platform string
forcePlatform, hasForcePlatform := ctx.Value(ctxkey.ForcePlatform).(string)
@@ -1257,6 +1252,15 @@ func (s *GatewayService) SelectAccountForModelWithExclusions(ctx context.Context
platform = PlatformAnthropic
}
+ // Claude Code 限制可能已将 groupID 解析为 fallback group,
+ // 渠道限制预检查必须使用解析后的分组。
+ if s.checkChannelPricingRestriction(ctx, groupID, requestedModel) {
+ slog.Warn("channel pricing restriction blocked request",
+ "group_id", derefGroupID(groupID),
+ "model", requestedModel)
+ return nil, fmt.Errorf("%w supporting model: %s (channel pricing restriction)", ErrNoAvailableAccounts, requestedModel)
+ }
+
// anthropic/gemini 分组支持混合调度(包含启用了 mixed_scheduling 的 antigravity 账户)
// 注意:强制平台模式不走混合调度
if (platform == PlatformAnthropic || platform == PlatformGemini) && !hasForcePlatform {
@@ -1273,11 +1277,6 @@ func (s *GatewayService) SelectAccountForModelWithExclusions(ctx context.Context
// metadataUserID: 用于客户端亲和调度,从中提取客户端 ID
// sub2apiUserID: 系统用户 ID,用于二维亲和调度
func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}, metadataUserID string, sub2apiUserID int64) (*AccountSelectionResult, error) {
- // 渠道定价限制预检查(requested / channel_mapped 基准)
- if s.checkChannelPricingRestriction(ctx, groupID, requestedModel) {
- return nil, fmt.Errorf("%w supporting model: %s (channel pricing restriction)", ErrNoAvailableAccounts, requestedModel)
- }
-
// 调试日志:记录调度入口参数
excludedIDsList := make([]int64, 0, len(excludedIDs))
for id := range excludedIDs {
@@ -1298,6 +1297,15 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
}
ctx = s.withGroupContext(ctx, group)
+ // Claude Code 限制可能已将 groupID 解析为 fallback group,
+ // 渠道限制预检查必须使用解析后的分组。
+ if s.checkChannelPricingRestriction(ctx, groupID, requestedModel) {
+ slog.Warn("channel pricing restriction blocked request",
+ "group_id", derefGroupID(groupID),
+ "model", requestedModel)
+ return nil, fmt.Errorf("%w supporting model: %s (channel pricing restriction)", ErrNoAvailableAccounts, requestedModel)
+ }
+
var stickyAccountID int64
if prefetch := prefetchedStickyAccountIDFromContext(ctx, groupID); prefetch > 0 {
stickyAccountID = prefetch
@@ -3004,7 +3012,7 @@ func (s *GatewayService) selectAccountForModelWithPlatform(ctx context.Context,
if clearSticky {
_ = s.cache.DeleteSessionAccountID(ctx, derefGroupID(groupID), sessionHash)
}
- if !clearSticky && s.isAccountInGroup(account, groupID) && account.Platform == platform && (requestedModel == "" || s.isModelSupportedByAccountWithContext(ctx, account, requestedModel)) && s.isAccountSchedulableForModelSelection(ctx, account, requestedModel) && s.isAccountSchedulableForQuota(account) && s.isAccountSchedulableForWindowCost(ctx, account, true) && s.isAccountSchedulableForRPM(ctx, account, true) {
+ if !clearSticky && s.isAccountInGroup(account, groupID) && account.Platform == platform && (requestedModel == "" || s.isModelSupportedByAccountWithContext(ctx, account, requestedModel)) && s.isAccountSchedulableForModelSelection(ctx, account, requestedModel) && s.isAccountSchedulableForQuota(account) && s.isAccountSchedulableForWindowCost(ctx, account, true) && s.isAccountSchedulableForRPM(ctx, account, true) && !s.isStickyAccountUpstreamRestricted(ctx, groupID, account, requestedModel) {
if s.debugModelRoutingEnabled() {
logger.LegacyPrintf("service.gateway", "[ModelRoutingDebug] legacy routed sticky hit: group_id=%v model=%s session=%s account=%d", derefGroupID(groupID), requestedModel, shortSessionHash(sessionHash), accountID)
}
@@ -3359,7 +3367,7 @@ func (s *GatewayService) selectAccountWithMixedScheduling(ctx context.Context, g
if clearSticky {
_ = s.cache.DeleteSessionAccountID(ctx, derefGroupID(groupID), sessionHash)
}
- if !clearSticky && s.isAccountInGroup(account, groupID) && (requestedModel == "" || s.isModelSupportedByAccountWithContext(ctx, account, requestedModel)) && s.isAccountSchedulableForModelSelection(ctx, account, requestedModel) && s.isAccountSchedulableForQuota(account) && s.isAccountSchedulableForWindowCost(ctx, account, true) && s.isAccountSchedulableForRPM(ctx, account, true) {
+ if !clearSticky && s.isAccountInGroup(account, groupID) && (requestedModel == "" || s.isModelSupportedByAccountWithContext(ctx, account, requestedModel)) && s.isAccountSchedulableForModelSelection(ctx, account, requestedModel) && s.isAccountSchedulableForQuota(account) && s.isAccountSchedulableForWindowCost(ctx, account, true) && s.isAccountSchedulableForRPM(ctx, account, true) && !s.isStickyAccountUpstreamRestricted(ctx, groupID, account, requestedModel) {
if account.Platform == nativePlatform || (account.Platform == PlatformAntigravity && account.IsMixedSchedulingEnabled()) {
return account, nil
}
@@ -3383,7 +3391,6 @@ func (s *GatewayService) selectAccountWithMixedScheduling(ctx context.Context, g
ctx = s.withRPMPrefetch(ctx, accounts)
// 3. 按优先级+最久未用选择(考虑模型支持和混合调度)
- // needsUpstreamCheck 仅在主选择循环中使用;粘性会话命中时跳过此检查。
needsUpstreamCheck := s.needsUpstreamChannelRestrictionCheck(ctx, groupID)
var selected *Account
for i := range accounts {
@@ -8453,6 +8460,19 @@ func (s *GatewayService) needsUpstreamChannelRestrictionCheck(ctx context.Contex
return ch.BillingModelSource == BillingModelSourceUpstream
}
+// isStickyAccountUpstreamRestricted 检查粘性会话命中的账号是否受 upstream 渠道限制。
+// 合并 needsUpstreamChannelRestrictionCheck + isUpstreamModelRestrictedByChannel 两步调用,
+// 供 sticky session 条件链使用,避免内联多个函数调用导致行过长。
+func (s *GatewayService) isStickyAccountUpstreamRestricted(ctx context.Context, groupID *int64, account *Account, requestedModel string) bool {
+ if groupID == nil {
+ return false
+ }
+ if !s.needsUpstreamChannelRestrictionCheck(ctx, groupID) {
+ return false
+ }
+ return s.isUpstreamModelRestrictedByChannel(ctx, *groupID, account, requestedModel)
+}
+
// ForwardCountTokens 转发 count_tokens 请求到上游 API
// 特点:不记录使用量、仅支持非流式响应
func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context, account *Account, parsed *ParsedRequest) error {
From 37c23eccfed36d59e70369e37d26b1eff5ac4a0f Mon Sep 17 00:00:00 2001
From: erio
Date: Sun, 5 Apr 2026 14:37:21 +0800
Subject: [PATCH 017/122] fix: gofmt formatting
---
backend/internal/service/channel_service.go | 2 +-
backend/internal/service/gateway_service.go | 689 +++-----------------
2 files changed, 82 insertions(+), 609 deletions(-)
diff --git a/backend/internal/service/channel_service.go b/backend/internal/service/channel_service.go
index c6a249ef..ec8310f6 100644
--- a/backend/internal/service/channel_service.go
+++ b/backend/internal/service/channel_service.go
@@ -134,7 +134,7 @@ func (r ChannelMappingResult) ToUsageFields(reqModel, upstreamModel string) Chan
const (
channelCacheTTL = 10 * time.Minute
- channelErrorTTL = 5 * time.Second // DB 错误时的短缓存
+ channelErrorTTL = 5 * time.Second // DB 错误时的短缓存
channelCacheDBTimeout = 10 * time.Second
)
diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go
index 31137fb4..5d285fb6 100644
--- a/backend/internal/service/gateway_service.go
+++ b/backend/internal/service/gateway_service.go
@@ -12,6 +12,7 @@ import (
"log/slog"
mathrand "math/rand"
"net/http"
+ "net/url"
"os"
"path/filepath"
"regexp"
@@ -41,8 +42,7 @@ import (
const (
claudeAPIURL = "https://api.anthropic.com/v1/messages?beta=true"
claudeAPICountTokensURL = "https://api.anthropic.com/v1/messages/count_tokens?beta=true"
- stickySessionTTL = time.Hour // 粘性会话TTL
- ClientAffinityTTL = 24 * time.Hour // 客户端亲和TTL
+ stickySessionTTL = time.Hour // 粘性会话TTL
defaultMaxLineSize = 500 * 1024 * 1024
// Canonical Claude Code banner. Keep it EXACT (no trailing whitespace/newlines)
// to match real Claude CLI traffic as closely as possible. When we need a visual
@@ -60,28 +60,14 @@ const (
claudeMimicDebugInfoKey = "claude_mimic_debug_info"
)
-// MediaType 媒体类型常量
-const (
- MediaTypeImage = "image"
- MediaTypeVideo = "video"
- MediaTypePrompt = "prompt"
-)
-
-const (
- claudeMaxMessageOverheadTokens = 3
- claudeMaxBlockOverheadTokens = 1
- claudeMaxUnknownContentTokens = 4
-)
-
// ForceCacheBillingContextKey 强制缓存计费上下文键
// 用于粘性会话切换时,将 input_tokens 转为 cache_read_input_tokens 计费
type forceCacheBillingKeyType struct{}
// accountWithLoad 账号与负载信息的组合,用于负载感知调度
type accountWithLoad struct {
- account *Account
- loadInfo *AccountLoadInfo
- affinityCount int64 // 亲和客户端数量(反向索引),越少越优先
+ account *Account
+ loadInfo *AccountLoadInfo
}
var ForceCacheBillingContextKey = forceCacheBillingKeyType{}
@@ -345,10 +331,6 @@ var (
sseDataRe = regexp.MustCompile(`^data:\s*`)
claudeCliUserAgentRe = regexp.MustCompile(`^claude-cli/\d+\.\d+\.\d+`)
- // clientIDFromMetadataRegex 从 metadata.user_id 中提取客户端 ID(64位 hex)
- // 格式: user_{64位hex}_account_...
- clientIDFromMetadataRegex = regexp.MustCompile(`^user_([a-f0-9]{64})_account_`)
-
// claudeCodePromptPrefixes 用于检测 Claude Code 系统提示词的前缀列表
// 支持多种变体:标准版、Agent SDK 版、Explore Agent 版、Compact 版等
// 注意:前缀之间不应存在包含关系,否则会导致冗余匹配
@@ -366,12 +348,6 @@ var ErrNoAvailableAccounts = errors.New("no available accounts")
// ErrClaudeCodeOnly 表示分组仅允许 Claude Code 客户端访问
var ErrClaudeCodeOnly = errors.New("this group only allows Claude Code clients")
-// ErrAffinityNoSwitch 表示亲和账号不可用且不允许切换到其他账号
-var ErrAffinityNoSwitch = errors.New("affinity account unavailable and switching is disabled")
-
-// ErrAffinityLimitExceeded 表示亲和客户端限制已达上限
-var ErrAffinityLimitExceeded = errors.New("affinity client limit exceeded")
-
// allowedHeaders 白名单headers(参考CRS项目)
var allowedHeaders = map[string]bool{
"accept": true,
@@ -393,6 +369,8 @@ var allowedHeaders = map[string]bool{
"user-agent": true,
"content-type": true,
"accept-encoding": true,
+ "x-claude-code-session-id": true,
+ "x-client-request-id": true,
}
// GatewayCache 定义网关服务的缓存操作接口。
@@ -413,39 +391,6 @@ type GatewayCache interface {
// DeleteSessionAccountID 删除粘性会话绑定,用于账号不可用时主动清理
// Delete sticky session binding, used to proactively clean up when account becomes unavailable
DeleteSessionAccountID(ctx context.Context, groupID int64, sessionHash string) error
-
- // GetAffinityAccounts 获取亲和账号列表(按最近使用降序),同时清理过期成员
- GetAffinityAccounts(ctx context.Context, groupID int64, userID int64, clientID string, ttl time.Duration) ([]int64, error)
- // UpdateAffinity 添加/更新亲和关系(更新 score 为当前时间戳,刷新 key TTL)
- UpdateAffinity(ctx context.Context, groupID int64, userID int64, clientID string, accountID int64, ttl time.Duration) error
- // GetAccountAffinityCountBatch 批量获取账号的亲和成员数量(惰性清理过期成员)
- GetAccountAffinityCountBatch(ctx context.Context, groupID int64, accountIDs []int64, ttl time.Duration) (map[int64]int64, error)
- // GetAccountAffinityClientsBatch 批量获取每个账号跨所有分组的亲和成员列表(去重)
- // accountGroups: map[accountID][]groupID
- // 返回值成员格式为 {userID}/{clientID}
- GetAccountAffinityClientsBatch(ctx context.Context, accountGroups map[int64][]int64, ttl time.Duration) (map[int64][]string, error)
- // GetAccountAffinityClientsWithScores 获取单个账号跨所有分组的亲和客户端列表(含最后活跃时间)
- GetAccountAffinityClientsWithScores(ctx context.Context, accountID int64, groupIDs []int64, ttl time.Duration) ([]AffinityClient, error)
- // ClearAccountAffinity 清除指定账号在所有分组的亲和记录(正向+反向索引)
- // 用于账号关闭亲和时立即清理旧绑定
- ClearAccountAffinity(ctx context.Context, accountID int64, groupIDs []int64) error
- // GetAffinityMultiCount 获取账号的多维度亲和计数
- // 返回: uniqueUsers, uniqueClients, perUserClients
- GetAffinityMultiCount(ctx context.Context, groupID int64, accountID int64, targetUserID int64, ttl time.Duration) (users, clients, perUser int64, err error)
-}
-
-// AffinityClient 亲和客户端信息(含用户 ID 和最后活跃时间)
-type AffinityClient struct {
- UserID int64 `json:"user_id"`
- ClientID string `json:"client_id"`
- LastActive time.Time `json:"last_active"`
-}
-
-// SortAffinityClients 按最后活跃时间降序排序
-func SortAffinityClients(clients []AffinityClient) {
- sort.Slice(clients, func(i, j int) bool {
- return clients[i].LastActive.After(clients[j].LastActive)
- })
}
// derefGroupID safely dereferences *int64 to int64, returning 0 if nil
@@ -516,20 +461,6 @@ func shouldClearStickySession(account *Account, requestedModel string) bool {
return false
}
-// extractClientIDFromMetadata 从 metadata.user_id 中提取客户端 ID(64位 hex)。
-// 格式: user_{64位hex}_account_..._session_...
-// 返回空字符串表示无法提取(非 Claude Code/Console 客户端)。
-func extractClientIDFromMetadata(metadataUserID string) string {
- if metadataUserID == "" {
- return ""
- }
- matches := clientIDFromMetadataRegex.FindStringSubmatch(metadataUserID)
- if matches == nil {
- return ""
- }
- return matches[1]
-}
-
type AccountWaitPlan struct {
AccountID int64
MaxConcurrency int
@@ -572,10 +503,6 @@ type ForwardResult struct {
// 图片生成计费字段(图片生成模型使用)
ImageCount int // 生成的图片数量
ImageSize string // 图片尺寸 "1K", "2K", "4K"
-
- // Sora 媒体字段
- MediaType string // image / video / prompt
- MediaURL string // 生成后的媒体地址(可选)
}
// UpstreamFailoverError indicates an upstream error that should trigger account failover.
@@ -1315,10 +1242,6 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
}
}
- // 提取客户端 ID(用于客户端亲和调度)
- affinityClientID := extractClientIDFromMetadata(metadataUserID)
- affinityUserID := sub2apiUserID
-
if s.debugModelRoutingEnabled() && requestedModel != "" {
groupPlatform := ""
if group != nil {
@@ -1340,10 +1263,6 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
if err != nil {
return nil, err
}
- if shouldFilterAccountWithoutClientID(account, affinityClientID) {
- localExcluded[account.ID] = struct{}{}
- continue
- }
result, err := s.tryAcquireAccountSlot(ctx, account.ID, account.Concurrency)
if err == nil && result.Acquired {
@@ -1405,7 +1324,6 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
if err != nil {
return nil, err
}
- accounts = filterAccountsWithoutClientID(accounts, affinityClientID)
if len(accounts) == 0 {
return nil, ErrNoAvailableAccounts
}
@@ -1424,19 +1342,6 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
_, excluded := excludedIDs[accountID]
return excluded
}
- affinityFlow := newGatewayAffinityFlow(
- s,
- ctx,
- groupID,
- sessionHash,
- requestedModel,
- affinityClientID,
- affinityUserID,
- platform,
- useMixed,
- accountByID,
- isExcluded,
- )
// 获取模型路由配置(仅 anthropic 平台)
var routingAccountIDs []int64
@@ -1599,10 +1504,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
}
if len(routingAvailable) > 0 {
- // 批量获取亲和客户端数量
- s.populateAffinityCounts(ctx, routingAvailable, derefGroupID(groupID))
-
- // 排序:优先级 > 负载率 > 亲和客户端数 > 最后使用时间
+ // 排序:优先级 > 负载率 > 最后使用时间
sort.SliceStable(routingAvailable, func(i, j int) bool {
a, b := routingAvailable[i], routingAvailable[j]
if a.account.Priority != b.account.Priority {
@@ -1611,9 +1513,6 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
if a.loadInfo.LoadRate != b.loadInfo.LoadRate {
return a.loadInfo.LoadRate < b.loadInfo.LoadRate
}
- if a.affinityCount != b.affinityCount {
- return a.affinityCount < b.affinityCount
- }
switch {
case a.account.LastUsedAt == nil && b.account.LastUsedAt != nil:
return true
@@ -1639,9 +1538,6 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
if sessionHash != "" && s.cache != nil {
_ = s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, item.account.ID, stickySessionTTL)
}
- if affinityClientID != "" && affinityUserID > 0 && s.cache != nil && item.account.IsAffinityEnabled() {
- _ = s.cache.UpdateAffinity(ctx, derefGroupID(groupID), affinityUserID, affinityClientID, item.account.ID, ClientAffinityTTL)
- }
if s.debugModelRoutingEnabled() {
logger.LegacyPrintf("service.gateway", "[ModelRoutingDebug] routed select: group_id=%v model=%s session=%s account=%d", derefGroupID(groupID), requestedModel, shortSessionHash(sessionHash), item.account.ID)
}
@@ -1679,22 +1575,8 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
}
}
- // ============ Layer 1.3: 用户亲和预处理(pinned_users 自动注入) ============
- affinityFlow.preprocessPinnedUsers(accounts)
-
- // ============ Layer 1.4: 客户端亲和调度(优先于粘性会话) ============
- affinityHit := false
- if affinityResult, hit, err := affinityFlow.trySelectAffinityAccount(); err != nil {
- return nil, err
- } else {
- affinityHit = hit
- if affinityResult != nil {
- return affinityResult, nil
- }
- }
-
- // ============ Layer 1.5: 粘性会话(仅在无模型路由配置 且 亲和未命中时生效) ============
- if !affinityHit && len(routingAccountIDs) == 0 && sessionHash != "" && stickyAccountID > 0 && !isExcluded(stickyAccountID) {
+ // ============ Layer 1.5: 粘性会话(仅在无模型路由配置时生效) ============
+ if len(routingAccountIDs) == 0 && sessionHash != "" && stickyAccountID > 0 && !isExcluded(stickyAccountID) {
accountID := stickyAccountID
if accountID > 0 && !isExcluded(accountID) {
account, ok := accountByID[accountID]
@@ -1800,9 +1682,6 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
loadMap, err := s.concurrencyService.GetAccountsLoadBatch(ctx, accountLoads)
if err != nil {
if result, ok := s.tryAcquireByLegacyOrder(ctx, candidates, groupID, sessionHash, preferOAuth); ok {
- if affinityClientID != "" && affinityUserID > 0 && s.cache != nil && result.Account != nil && result.Account.IsAffinityEnabled() {
- _ = s.cache.UpdateAffinity(ctx, derefGroupID(groupID), affinityUserID, affinityClientID, result.Account.ID, ClientAffinityTTL)
- }
return result, nil
}
} else {
@@ -1820,37 +1699,13 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
}
}
- // 批量获取亲和客户端数量(用于均衡分配新客户端)
- s.populateAffinityCounts(ctx, available, derefGroupID(groupID))
-
- // 分层过滤选择:优先级 → 亲和三区 → 负载率 → 亲和客户端数 → LRU
+ // 分层过滤选择:优先级 → 负载率 → LRU
for len(available) > 0 {
// 1. 取优先级最小的集合
candidates := filterByMinPriority(available)
- // 2. 按亲和三区过滤:绿区优先 → 黄区降级 → 红区移除(在同优先级内)
- candidates = classifyByAffinityZone(candidates)
- if len(candidates) == 0 {
- // 当前优先级组全部在红区,移除后回退到下一优先级组
- minPri := available[0].account.Priority
- for _, a := range available[1:] {
- if a.account.Priority < minPri {
- minPri = a.account.Priority
- }
- }
- newAvailable := make([]accountWithLoad, 0, len(available))
- for _, a := range available {
- if a.account.Priority != minPri {
- newAvailable = append(newAvailable, a)
- }
- }
- available = newAvailable
- continue
- }
- // 3. 取负载率最低的集合
+ // 2. 取负载率最低的集合
candidates = filterByMinLoadRate(candidates)
- // 3. 取亲和客户端数最少的集合
- candidates = filterByMinAffinityCount(candidates)
- // 4. LRU 选择最久未用的账号
+ // 3. LRU 选择最久未用的账号
selected := selectByLRU(candidates, preferOAuth)
if selected == nil {
break
@@ -1865,10 +1720,6 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
if sessionHash != "" && s.cache != nil {
_ = s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, selected.account.ID, stickySessionTTL)
}
- // 更新亲和关系
- if affinityClientID != "" && affinityUserID > 0 && s.cache != nil && selected.account.IsAffinityEnabled() {
- _ = s.cache.UpdateAffinity(ctx, derefGroupID(groupID), affinityUserID, affinityClientID, selected.account.ID, ClientAffinityTTL)
- }
return &AccountSelectionResult{
Account: selected.account,
Acquired: true,
@@ -2077,9 +1928,6 @@ func (s *GatewayService) resolvePlatform(ctx context.Context, groupID *int64, gr
}
func (s *GatewayService) listSchedulableAccounts(ctx context.Context, groupID *int64, platform string, hasForcePlatform bool) ([]Account, bool, error) {
- if platform == PlatformSora {
- return s.listSoraSchedulableAccounts(ctx, groupID)
- }
if s.schedulerSnapshot != nil {
accounts, useMixed, err := s.schedulerSnapshot.ListSchedulableAccounts(ctx, groupID, platform, hasForcePlatform)
if err == nil {
@@ -2176,53 +2024,6 @@ func (s *GatewayService) listSchedulableAccounts(ctx context.Context, groupID *i
return accounts, useMixed, nil
}
-func (s *GatewayService) listSoraSchedulableAccounts(ctx context.Context, groupID *int64) ([]Account, bool, error) {
- const useMixed = false
-
- var accounts []Account
- var err error
- if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple {
- accounts, err = s.accountRepo.ListByPlatform(ctx, PlatformSora)
- } else if groupID != nil {
- accounts, err = s.accountRepo.ListByGroup(ctx, *groupID)
- } else {
- accounts, err = s.accountRepo.ListByPlatform(ctx, PlatformSora)
- }
- if err != nil {
- slog.Debug("account_scheduling_list_failed",
- "group_id", derefGroupID(groupID),
- "platform", PlatformSora,
- "error", err)
- return nil, useMixed, err
- }
-
- filtered := make([]Account, 0, len(accounts))
- for _, acc := range accounts {
- if acc.Platform != PlatformSora {
- continue
- }
- if !s.isSoraAccountSchedulable(&acc) {
- continue
- }
- filtered = append(filtered, acc)
- }
- slog.Debug("account_scheduling_list_sora",
- "group_id", derefGroupID(groupID),
- "platform", PlatformSora,
- "raw_count", len(accounts),
- "filtered_count", len(filtered))
- for _, acc := range filtered {
- slog.Debug("account_scheduling_account_detail",
- "account_id", acc.ID,
- "name", acc.Name,
- "platform", acc.Platform,
- "type", acc.Type,
- "status", acc.Status,
- "tls_fingerprint", acc.IsTLSFingerprintEnabled())
- }
- return filtered, useMixed, nil
-}
-
// IsSingleAntigravityAccountGroup 检查指定分组是否只有一个 antigravity 平台的可调度账号。
// 用于 Handler 层在首次请求时提前设置 SingleAccountRetry context,
// 避免单账号分组收到 503 时错误地设置模型限流标记导致后续请求连续快速失败。
@@ -2247,33 +2048,10 @@ func (s *GatewayService) isAccountAllowedForPlatform(account *Account, platform
return account.Platform == platform
}
-func (s *GatewayService) isSoraAccountSchedulable(account *Account) bool {
- return s.soraUnschedulableReason(account) == ""
-}
-
-func (s *GatewayService) soraUnschedulableReason(account *Account) string {
- if account == nil {
- return "account_nil"
- }
- if account.Status != StatusActive {
- return fmt.Sprintf("status=%s", account.Status)
- }
- if !account.Schedulable {
- return "schedulable=false"
- }
- if account.TempUnschedulableUntil != nil && time.Now().Before(*account.TempUnschedulableUntil) {
- return fmt.Sprintf("temp_unschedulable_until=%s", account.TempUnschedulableUntil.UTC().Format(time.RFC3339))
- }
- return ""
-}
-
func (s *GatewayService) isAccountSchedulableForSelection(account *Account) bool {
if account == nil {
return false
}
- if account.Platform == PlatformSora {
- return s.isSoraAccountSchedulable(account)
- }
return account.IsSchedulable()
}
@@ -2281,12 +2059,6 @@ func (s *GatewayService) isAccountSchedulableForModelSelection(ctx context.Conte
if account == nil {
return false
}
- if account.Platform == PlatformSora {
- if !s.isSoraAccountSchedulable(account) {
- return false
- }
- return account.GetRateLimitRemainingTimeWithContext(ctx, requestedModel) <= 0
- }
return account.IsSchedulableForModelWithContext(ctx, requestedModel)
}
@@ -2626,36 +2398,6 @@ func (s *GatewayService) getSchedulableAccount(ctx context.Context, accountID in
return s.accountRepo.GetByID(ctx, accountID)
}
-// populateAffinityCounts 批量获取账号的亲和客户端数量并填入 accountWithLoad 切片。
-// 仅当存在开启了客户端亲和的账号时才查询 Redis,否则跳过。
-func (s *GatewayService) populateAffinityCounts(ctx context.Context, accounts []accountWithLoad, groupID int64) {
- if s.cache == nil || len(accounts) == 0 {
- return
- }
- // 快速检查:是否有任何账号开启了亲和
- hasAffinity := false
- for _, acc := range accounts {
- if acc.account.IsAffinityEnabled() {
- hasAffinity = true
- break
- }
- }
- if !hasAffinity {
- return
- }
- accountIDs := make([]int64, len(accounts))
- for i, acc := range accounts {
- accountIDs[i] = acc.account.ID
- }
- countMap, err := s.cache.GetAccountAffinityCountBatch(ctx, groupID, accountIDs, ClientAffinityTTL)
- if err != nil {
- return // 查询失败不影响调度,affinityCount 保持 0
- }
- for i := range accounts {
- accounts[i].affinityCount = countMap[accounts[i].account.ID]
- }
-}
-
// filterByMinPriority 过滤出优先级最小的账号集合
func filterByMinPriority(accounts []accountWithLoad) []accountWithLoad {
if len(accounts) == 0 {
@@ -2696,64 +2438,6 @@ func filterByMinLoadRate(accounts []accountWithLoad) []accountWithLoad {
return result
}
-// filterByMinAffinityCount 过滤出亲和客户端数最少的账号集合
-func filterByMinAffinityCount(accounts []accountWithLoad) []accountWithLoad {
- if len(accounts) == 0 {
- return accounts
- }
- minCount := accounts[0].affinityCount
- for _, acc := range accounts[1:] {
- if acc.affinityCount < minCount {
- minCount = acc.affinityCount
- }
- }
- result := make([]accountWithLoad, 0, len(accounts))
- for _, acc := range accounts {
- if acc.affinityCount == minCount {
- result = append(result, acc)
- }
- }
- return result
-}
-
-// classifyByAffinityZone 按亲和分区对候选账号进行分类。
-// 返回值:仅绿区账号(有绿区时),否则返回黄区账号。红区账号被移除。
-// 如果没有任何账号开启了亲和三区配置(即 affinity_base <= 0),则原样返回所有账号。
-func classifyByAffinityZone(accounts []accountWithLoad) []accountWithLoad {
- if len(accounts) == 0 {
- return accounts
- }
- // 快速检查:是否有任何账号配置了 affinity_base
- hasZoneConfig := false
- for _, acc := range accounts {
- if acc.account.IsAffinityEnabled() && acc.account.GetAffinityBase() > 0 {
- hasZoneConfig = true
- break
- }
- }
- if !hasZoneConfig {
- return accounts
- }
-
- greens := make([]accountWithLoad, 0, len(accounts))
- yellows := make([]accountWithLoad, 0, len(accounts))
- for _, acc := range accounts {
- zone := acc.account.GetAffinityZone(acc.affinityCount)
- switch zone {
- case AffinityZoneGreen:
- greens = append(greens, acc)
- case AffinityZoneYellow:
- yellows = append(yellows, acc)
- case AffinityZoneRed:
- // 红区:移除,不参与调度
- }
- }
- if len(greens) > 0 {
- return greens
- }
- return yellows
-}
-
// selectByLRU 从集合中选择最久未用的账号
// 如果有多个账号具有相同的最小 LastUsedAt,则随机选择一个
func selectByLRU(accounts []accountWithLoad, preferOAuth bool) *accountWithLoad {
@@ -3514,9 +3198,6 @@ func (s *GatewayService) logDetailedSelectionFailure(
stats.SampleMappingIDs,
stats.SampleRateLimitIDs,
)
- if platform == PlatformSora {
- s.logSoraSelectionFailureDetails(ctx, groupID, sessionHash, requestedModel, accounts, excludedIDs, allowMixedScheduling)
- }
return stats
}
@@ -3574,9 +3255,6 @@ func (s *GatewayService) diagnoseSelectionFailure(
}
if !s.isAccountSchedulableForSelection(acc) {
detail := "generic_unschedulable"
- if acc.Platform == PlatformSora {
- detail = s.soraUnschedulableReason(acc)
- }
return selectionFailureDiagnosis{Category: "unschedulable", Detail: detail}
}
if isPlatformFilteredForSelection(acc, platform, allowMixedScheduling) {
@@ -3601,57 +3279,7 @@ func (s *GatewayService) diagnoseSelectionFailure(
return selectionFailureDiagnosis{Category: "eligible"}
}
-func (s *GatewayService) logSoraSelectionFailureDetails(
- ctx context.Context,
- groupID *int64,
- sessionHash string,
- requestedModel string,
- accounts []Account,
- excludedIDs map[int64]struct{},
- allowMixedScheduling bool,
-) {
- const maxLines = 30
- logged := 0
-
- for i := range accounts {
- if logged >= maxLines {
- break
- }
- acc := &accounts[i]
- diagnosis := s.diagnoseSelectionFailure(ctx, acc, requestedModel, PlatformSora, excludedIDs, allowMixedScheduling)
- if diagnosis.Category == "eligible" {
- continue
- }
- detail := diagnosis.Detail
- if detail == "" {
- detail = "-"
- }
- logger.LegacyPrintf(
- "service.gateway",
- "[SelectAccountDetailed:Sora] group_id=%v model=%s session=%s account_id=%d account_platform=%s category=%s detail=%s",
- derefGroupID(groupID),
- requestedModel,
- shortSessionHash(sessionHash),
- acc.ID,
- acc.Platform,
- diagnosis.Category,
- detail,
- )
- logged++
- }
- if len(accounts) > maxLines {
- logger.LegacyPrintf(
- "service.gateway",
- "[SelectAccountDetailed:Sora] group_id=%v model=%s session=%s truncated=true total=%d logged=%d",
- derefGroupID(groupID),
- requestedModel,
- shortSessionHash(sessionHash),
- len(accounts),
- logged,
- )
- }
-}
-
+// GetAccessToken 获取账号凭证
func isPlatformFilteredForSelection(acc *Account, platform string, allowMixedScheduling bool) bool {
if acc == nil {
return true
@@ -3730,9 +3358,6 @@ func (s *GatewayService) isModelSupportedByAccount(account *Account, requestedMo
}
return mapAntigravityModel(account, requestedModel) != ""
}
- if account.Platform == PlatformSora {
- return s.isSoraModelSupportedByAccount(account, requestedModel)
- }
if account.IsBedrock() {
_, ok := ResolveBedrockModelID(account, requestedModel)
return ok
@@ -3749,143 +3374,6 @@ func (s *GatewayService) isModelSupportedByAccount(account *Account, requestedMo
return account.IsModelSupported(requestedModel)
}
-func (s *GatewayService) isSoraModelSupportedByAccount(account *Account, requestedModel string) bool {
- if account == nil {
- return false
- }
- if strings.TrimSpace(requestedModel) == "" {
- return true
- }
-
- // 先走原始精确/通配符匹配。
- mapping := account.GetModelMapping()
- if len(mapping) == 0 || account.IsModelSupported(requestedModel) {
- return true
- }
-
- aliases := buildSoraModelAliases(requestedModel)
- if len(aliases) == 0 {
- return false
- }
-
- hasSoraSelector := false
- for pattern := range mapping {
- if !isSoraModelSelector(pattern) {
- continue
- }
- hasSoraSelector = true
- if matchPatternAnyAlias(pattern, aliases) {
- return true
- }
- }
-
- // 兼容旧账号:mapping 存在但未配置任何 Sora 选择器(例如只含 gpt-*),
- // 此时不应误拦截 Sora 模型请求。
- if !hasSoraSelector {
- return true
- }
-
- return false
-}
-
-func matchPatternAnyAlias(pattern string, aliases []string) bool {
- normalizedPattern := strings.ToLower(strings.TrimSpace(pattern))
- if normalizedPattern == "" {
- return false
- }
- for _, alias := range aliases {
- if matchWildcard(normalizedPattern, alias) {
- return true
- }
- }
- return false
-}
-
-func isSoraModelSelector(pattern string) bool {
- p := strings.ToLower(strings.TrimSpace(pattern))
- if p == "" {
- return false
- }
-
- switch {
- case strings.HasPrefix(p, "sora"),
- strings.HasPrefix(p, "gpt-image"),
- strings.HasPrefix(p, "prompt-enhance"),
- strings.HasPrefix(p, "sy_"):
- return true
- }
-
- return p == "video" || p == "image"
-}
-
-func buildSoraModelAliases(requestedModel string) []string {
- modelID := strings.ToLower(strings.TrimSpace(requestedModel))
- if modelID == "" {
- return nil
- }
-
- aliases := make([]string, 0, 8)
- addAlias := func(value string) {
- v := strings.ToLower(strings.TrimSpace(value))
- if v == "" {
- return
- }
- for _, existing := range aliases {
- if existing == v {
- return
- }
- }
- aliases = append(aliases, v)
- }
-
- addAlias(modelID)
- cfg, ok := GetSoraModelConfig(modelID)
- if ok {
- addAlias(cfg.Model)
- switch cfg.Type {
- case "video":
- addAlias("video")
- addAlias("sora")
- addAlias(soraVideoFamilyAlias(modelID))
- case "image":
- addAlias("image")
- addAlias("gpt-image")
- case "prompt_enhance":
- addAlias("prompt-enhance")
- }
- return aliases
- }
-
- switch {
- case strings.HasPrefix(modelID, "sora"):
- addAlias("video")
- addAlias("sora")
- addAlias(soraVideoFamilyAlias(modelID))
- case strings.HasPrefix(modelID, "gpt-image"):
- addAlias("image")
- addAlias("gpt-image")
- case strings.HasPrefix(modelID, "prompt-enhance"):
- addAlias("prompt-enhance")
- default:
- return nil
- }
-
- return aliases
-}
-
-func soraVideoFamilyAlias(modelID string) string {
- switch {
- case strings.HasPrefix(modelID, "sora2pro-hd"):
- return "sora2pro-hd"
- case strings.HasPrefix(modelID, "sora2pro"):
- return "sora2pro"
- case strings.HasPrefix(modelID, "sora2"):
- return "sora2"
- default:
- return ""
- }
-}
-
// GetAccessToken 获取账号凭证
func (s *GatewayService) GetAccessToken(ctx context.Context, account *Account) (string, string, error) {
switch account.Type {
@@ -4412,10 +3900,12 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
return nil, err
}
- // 获取代理URL
+ // 获取代理URL(自定义 base URL 模式下,proxy 通过 buildCustomRelayURL 作为查询参数传递)
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
- proxyURL = account.Proxy.URL()
+ if !account.IsCustomBaseURLEnabled() || account.GetCustomBaseURL() == "" {
+ proxyURL = account.Proxy.URL()
+ }
}
// 解析 TLS 指纹 profile(同一请求生命周期内不变,避免重试循环中重复解析)
@@ -4824,7 +4314,6 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
}
// 处理正常响应
- ctx = withClaudeMaxResponseRewriteContext(ctx, c, parsed)
// 触发上游接受回调(提前释放串行锁,不等流完成)
if parsed.OnUpstreamAccepted != nil {
@@ -5891,6 +5380,16 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
}
targetURL = validatedURL + "/v1/messages?beta=true"
}
+ } else if account.IsCustomBaseURLEnabled() {
+ customURL := account.GetCustomBaseURL()
+ if customURL == "" {
+ return nil, fmt.Errorf("custom_base_url is enabled but not configured for account %d", account.ID)
+ }
+ validatedURL, err := s.validateUpstreamBaseURL(customURL)
+ if err != nil {
+ return nil, err
+ }
+ targetURL = s.buildCustomRelayURL(validatedURL, "/v1/messages", account)
}
clientHeaders := http.Header{}
@@ -6006,6 +5505,15 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
}
}
+ // 同步 X-Claude-Code-Session-Id 头:取 body 中已处理的 metadata.user_id 的 session_id 覆盖
+ if sessionHeader := getHeaderRaw(req.Header, "X-Claude-Code-Session-Id"); sessionHeader != "" {
+ if uid := gjson.GetBytes(body, "metadata.user_id").String(); uid != "" {
+ if parsed := ParseMetadataUserID(uid); parsed != nil {
+ setHeaderRaw(req.Header, "X-Claude-Code-Session-Id", parsed.SessionID)
+ }
+ }
+ }
+
// === DEBUG: 打印上游转发请求(headers + body 摘要),与 CLIENT_ORIGINAL 对比 ===
s.debugLogGatewaySnapshot("UPSTREAM_FORWARD", req.Header, body, map[string]string{
"url": req.URL.String(),
@@ -7005,7 +6513,6 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http
needModelReplace := originalModel != mappedModel
clientDisconnected := false // 客户端断开标志,断开后继续读取上游以获取完整usage
sawTerminalEvent := false
- skipAccountTTLOverride := false
pendingEventLines := make([]string, 0, 4)
@@ -7067,25 +6574,17 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http
if msg, ok := event["message"].(map[string]any); ok {
if u, ok := msg["usage"].(map[string]any); ok {
eventChanged = reconcileCachedTokens(u) || eventChanged
- claudeMaxOutcome := applyClaudeMaxSimulationToUsageJSONMap(ctx, u, originalModel, account.ID)
- if claudeMaxOutcome.Simulated {
- skipAccountTTLOverride = true
- }
}
}
}
if eventType == "message_delta" {
if u, ok := event["usage"].(map[string]any); ok {
eventChanged = reconcileCachedTokens(u) || eventChanged
- claudeMaxOutcome := applyClaudeMaxSimulationToUsageJSONMap(ctx, u, originalModel, account.ID)
- if claudeMaxOutcome.Simulated {
- skipAccountTTLOverride = true
- }
}
}
// Cache TTL Override: 重写 SSE 事件中的 cache_creation 分类
- if account.IsCacheTTLOverrideEnabled() && !skipAccountTTLOverride {
+ if account.IsCacheTTLOverrideEnabled() {
overrideTarget := account.GetCacheTTLOverrideTarget()
if eventType == "message_start" {
if msg, ok := event["message"].(map[string]any); ok {
@@ -7517,13 +7016,8 @@ func (s *GatewayService) handleNonStreamingResponse(ctx context.Context, resp *h
}
}
- claudeMaxOutcome := applyClaudeMaxSimulationToUsage(ctx, &response.Usage, originalModel, account.ID)
- if claudeMaxOutcome.Simulated {
- body = rewriteClaudeUsageJSONBytes(body, response.Usage)
- }
-
// Cache TTL Override: 重写 non-streaming 响应中的 cache_creation 分类
- if account.IsCacheTTLOverrideEnabled() && !claudeMaxOutcome.Simulated {
+ if account.IsCacheTTLOverrideEnabled() {
overrideTarget := account.GetCacheTTLOverrideTarget()
if applyCacheTTLOverride(&response.Usage, overrideTarget) {
// 同步更新 body JSON 中的嵌套 cache_creation 对象
@@ -7901,12 +7395,10 @@ func writeUsageLogBestEffort(ctx context.Context, repo UsageLogRepository, usage
// recordUsageOpts 内部选项,参数化 RecordUsage 与 RecordUsageWithLongContext 的差异点。
type recordUsageOpts struct {
- // Claude Max 策略所需的 ParsedRequest(可选,仅 Claude 路径传入)
+ // ParsedRequest(可选,仅 Claude 路径传入)
ParsedRequest *ParsedRequest
// EnableClaudePath 启用 Claude 路径特有逻辑:
- // - Claude Max 缓存计费策略
- // - Sora 媒体类型分支(image/video/prompt)
// - MediaType 字段写入使用日志
EnableClaudePath bool
@@ -7998,8 +7490,6 @@ type recordUsageCoreInput struct {
// recordUsageCore 是 RecordUsage 和 RecordUsageWithLongContext 的统一实现。
// opts 中的字段控制两者之间的差异行为:
-// - ParsedRequest != nil → 启用 Claude Max 缓存计费策略
-// - EnableSoraMedia → 启用 Sora MediaType 分支(image/video/prompt)
// - LongContextThreshold > 0 → Token 计费回退走 CalculateCostWithLongContext
func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsageCoreInput, opts *recordUsageOpts) error {
result := input.Result
@@ -8017,21 +7507,9 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage
result.Usage.InputTokens = 0
}
- // Claude Max cache billing policy(仅 Claude 路径启用)
- cacheTTLOverridden := false
- simulatedClaudeMax := false
- if opts.EnableClaudePath {
- var apiKeyGroup *Group
- if apiKey != nil {
- apiKeyGroup = apiKey.Group
- }
- claudeMaxOutcome := applyClaudeMaxCacheBillingPolicyToUsage(&result.Usage, opts.ParsedRequest, apiKeyGroup, result.Model, account.ID)
- simulatedClaudeMax = claudeMaxOutcome.Simulated ||
- (shouldApplyClaudeMaxBillingRulesForUsage(apiKeyGroup, result.Model, opts.ParsedRequest) && hasCacheCreationTokens(result.Usage))
- }
-
// Cache TTL Override: 确保计费时 token 分类与账号设置一致
- if account.IsCacheTTLOverrideEnabled() && !simulatedClaudeMax {
+ cacheTTLOverridden := false
+ if account.IsCacheTTLOverrideEnabled() {
applyCacheTTLOverride(&result.Usage, account.GetCacheTTLOverrideTarget())
cacheTTLOverridden = (result.Usage.CacheCreation5mTokens + result.Usage.CacheCreation1hTokens) > 0
}
@@ -8113,16 +7591,6 @@ func (s *GatewayService) calculateRecordUsageCost(
multiplier float64,
opts *recordUsageOpts,
) *CostBreakdown {
- // Sora 媒体类型分支(仅 Claude 路径启用)
- if opts.EnableClaudePath {
- if result.MediaType == MediaTypeImage || result.MediaType == MediaTypeVideo {
- return s.calculateSoraMediaCost(result, apiKey, billingModel, multiplier)
- }
- if result.MediaType == MediaTypePrompt {
- return &CostBreakdown{}
- }
- }
-
// 图片生成计费
if result.ImageCount > 0 {
return s.calculateImageCost(ctx, result, apiKey, billingModel, multiplier)
@@ -8132,28 +7600,6 @@ func (s *GatewayService) calculateRecordUsageCost(
return s.calculateTokenCost(ctx, result, apiKey, billingModel, multiplier, opts)
}
-// calculateSoraMediaCost 计算 Sora 图片/视频的费用。
-func (s *GatewayService) calculateSoraMediaCost(
- result *ForwardResult,
- apiKey *APIKey,
- billingModel string,
- multiplier float64,
-) *CostBreakdown {
- var soraConfig *SoraPriceConfig
- if apiKey.Group != nil {
- soraConfig = &SoraPriceConfig{
- ImagePrice360: apiKey.Group.SoraImagePrice360,
- ImagePrice540: apiKey.Group.SoraImagePrice540,
- VideoPricePerRequest: apiKey.Group.SoraVideoPricePerRequest,
- VideoPricePerRequestHD: apiKey.Group.SoraVideoPricePerRequestHD,
- }
- }
- if result.MediaType == MediaTypeImage {
- return s.billingService.CalculateSoraImageCost(result.ImageSize, result.ImageCount, soraConfig, multiplier)
- }
- return s.billingService.CalculateSoraVideoCost(billingModel, soraConfig, multiplier)
-}
-
// resolveChannelPricing 检查指定模型是否存在渠道级别定价。
// 返回非 nil 的 ResolvedPricing 表示有渠道定价,nil 表示走默认定价路径。
func (s *GatewayService) resolveChannelPricing(ctx context.Context, billingModel string, apiKey *APIKey) *ResolvedPricing {
@@ -8176,7 +7622,7 @@ func (s *GatewayService) calculateImageCost(
billingModel string,
multiplier float64,
) *CostBreakdown {
- if s.resolveChannelPricing(ctx, billingModel, apiKey) != nil {
+ if resolved := s.resolveChannelPricing(ctx, billingModel, apiKey); resolved != nil {
tokens := UsageTokens{
InputTokens: result.Usage.InputTokens,
OutputTokens: result.Usage.OutputTokens,
@@ -8191,6 +7637,7 @@ func (s *GatewayService) calculateImageCost(
RequestCount: 1,
RateMultiplier: multiplier,
Resolver: s.resolver,
+ Resolved: resolved,
})
if err != nil {
logger.LegacyPrintf("service.gateway", "Calculate image token cost failed: %v", err)
@@ -8233,7 +7680,7 @@ func (s *GatewayService) calculateTokenCost(
var err error
// 优先尝试渠道定价 → CalculateCostUnified
- if s.resolveChannelPricing(ctx, billingModel, apiKey) != nil {
+ if resolved := s.resolveChannelPricing(ctx, billingModel, apiKey); resolved != nil {
gid := apiKey.Group.ID
cost, err = s.billingService.CalculateCostUnified(CostInput{
Ctx: ctx,
@@ -8243,6 +7690,7 @@ func (s *GatewayService) calculateTokenCost(
RequestCount: 1,
RateMultiplier: multiplier,
Resolver: s.resolver,
+ Resolved: resolved,
})
} else if opts.LongContextThreshold > 0 {
// 长上下文双倍计费(如 Gemini 200K 阈值)
@@ -8330,13 +7778,7 @@ func (s *GatewayService) buildRecordUsageLog(
}
// resolveBillingMode 根据计费结果和请求类型确定计费模式。
-// Sora 媒体类型自身已确定计费模式(由上游处理),返回 nil 跳过。
func resolveBillingMode(opts *recordUsageOpts, result *ForwardResult, cost *CostBreakdown) *string {
- isSoraMedia := opts.EnableClaudePath &&
- (result.MediaType == MediaTypeImage || result.MediaType == MediaTypeVideo || result.MediaType == MediaTypePrompt)
- if isSoraMedia {
- return nil
- }
var mode string
switch {
case cost != nil && cost.BillingMode != "":
@@ -8350,9 +7792,6 @@ func resolveBillingMode(opts *recordUsageOpts, result *ForwardResult, cost *Cost
}
func resolveMediaType(opts *recordUsageOpts, result *ForwardResult) *string {
- if opts.EnableClaudePath && strings.TrimSpace(result.MediaType) != "" {
- return &result.MediaType
- }
return nil
}
@@ -8559,10 +7998,12 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
return err
}
- // 获取代理URL
+ // 获取代理URL(自定义 base URL 模式下,proxy 通过 buildCustomRelayURL 作为查询参数传递)
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
- proxyURL = account.Proxy.URL()
+ if !account.IsCustomBaseURLEnabled() || account.GetCustomBaseURL() == "" {
+ proxyURL = account.Proxy.URL()
+ }
}
// 发送请求
@@ -8841,6 +8282,16 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
}
targetURL = validatedURL + "/v1/messages/count_tokens?beta=true"
}
+ } else if account.IsCustomBaseURLEnabled() {
+ customURL := account.GetCustomBaseURL()
+ if customURL == "" {
+ return nil, fmt.Errorf("custom_base_url is enabled but not configured for account %d", account.ID)
+ }
+ validatedURL, err := s.validateUpstreamBaseURL(customURL)
+ if err != nil {
+ return nil, err
+ }
+ targetURL = s.buildCustomRelayURL(validatedURL, "/v1/messages/count_tokens", account)
}
clientHeaders := http.Header{}
@@ -8946,6 +8397,15 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
}
}
+ // 同步 X-Claude-Code-Session-Id 头:取 body 中已处理的 metadata.user_id 的 session_id 覆盖
+ if sessionHeader := getHeaderRaw(req.Header, "X-Claude-Code-Session-Id"); sessionHeader != "" {
+ if uid := gjson.GetBytes(body, "metadata.user_id").String(); uid != "" {
+ if parsed := ParseMetadataUserID(uid); parsed != nil {
+ setHeaderRaw(req.Header, "X-Claude-Code-Session-Id", parsed.SessionID)
+ }
+ }
+ }
+
if c != nil && tokenType == "oauth" {
c.Set(claudeMimicDebugInfoKey, buildClaudeMimicDebugLine(req, body, account, tokenType, mimicClaudeCode))
}
@@ -8967,6 +8427,19 @@ func (s *GatewayService) countTokensError(c *gin.Context, status int, errType, m
})
}
+// buildCustomRelayURL 构建自定义中继转发 URL
+// 在 path 后附加 beta=true 和可选的 proxy 查询参数
+func (s *GatewayService) buildCustomRelayURL(baseURL, path string, account *Account) string {
+ u := strings.TrimRight(baseURL, "/") + path + "?beta=true"
+ if account.ProxyID != nil && account.Proxy != nil {
+ proxyURL := account.Proxy.URL()
+ if proxyURL != "" {
+ u += "&proxy=" + url.QueryEscape(proxyURL)
+ }
+ }
+ return u
+}
+
func (s *GatewayService) validateUpstreamBaseURL(raw string) (string, error) {
if s.cfg != nil && !s.cfg.Security.URLAllowlist.Enabled {
normalized, err := urlvalidator.ValidateURLFormat(raw, s.cfg.Security.URLAllowlist.AllowInsecureHTTP)
From 794e81720834913c5fb3251a5c13fd88040c354a Mon Sep 17 00:00:00 2001
From: erio
Date: Sun, 5 Apr 2026 16:39:24 +0800
Subject: [PATCH 018/122] refactor: remove PaymentChannel, reuse upstream
Channel with features field
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Delete payment_channels table and PaymentChannel Ent schema
- Add `features` column to upstream channels table (migration 095)
- Add Features field to Channel struct, input types, handler request/response
- Payment user/admin handlers now use ChannelService directly
- Remove Channel CRUD from PaymentConfigService and admin payment routes
- Remove "渠道管理" tab from admin orders page (use /admin/channels)
---
backend/ent/client.go | 36 +-
backend/ent/intercept/intercept.go | 1 +
backend/ent/predicate/predicate.go | 1 +
.../internal/handler/admin/channel_handler.go | 6 +
backend/internal/handler/payment_handler.go | 1 +
backend/internal/repository/channel_repo.go | 20 +-
backend/internal/server/routes/payment.go | 13 +-
backend/internal/service/channel.go | 1 +
backend/internal/service/channel_service.go | 6 +
.../service/payment_config_service.go | 440 +++++++++++-------
backend/migrations/095_channel_features.sql | 2 +
11 files changed, 314 insertions(+), 213 deletions(-)
create mode 100644 backend/migrations/095_channel_features.sql
diff --git a/backend/ent/client.go b/backend/ent/client.go
index e52e015a..3da7acf8 100644
--- a/backend/ent/client.go
+++ b/backend/ent/client.go
@@ -333,10 +333,10 @@ func (c *Client) Use(hooks ...Hook) {
for _, n := range []interface{ Use(...Hook) }{
c.APIKey, c.Account, c.AccountGroup, c.Announcement, c.AnnouncementRead,
c.ErrorPassthroughRule, c.Group, c.IdempotencyRecord, c.PaymentAuditLog,
- c.PaymentOrder, c.PaymentProviderInstance, c.PromoCode, c.PromoCodeUsage,
- c.Proxy, c.RedeemCode, c.SecuritySecret, c.Setting, c.SubscriptionPlan,
- c.TLSFingerprintProfile, c.UsageCleanupTask, c.UsageLog, c.User,
- c.UserAllowedGroup, c.UserAttributeDefinition, c.UserAttributeValue,
+ c.PaymentOrder, c.PaymentProviderInstance, c.PromoCode,
+ c.PromoCodeUsage, c.Proxy, c.RedeemCode, c.SecuritySecret, c.Setting,
+ c.SubscriptionPlan, c.TLSFingerprintProfile, c.UsageCleanupTask, c.UsageLog,
+ c.User, c.UserAllowedGroup, c.UserAttributeDefinition, c.UserAttributeValue,
c.UserSubscription,
} {
n.Use(hooks...)
@@ -349,10 +349,10 @@ func (c *Client) Intercept(interceptors ...Interceptor) {
for _, n := range []interface{ Intercept(...Interceptor) }{
c.APIKey, c.Account, c.AccountGroup, c.Announcement, c.AnnouncementRead,
c.ErrorPassthroughRule, c.Group, c.IdempotencyRecord, c.PaymentAuditLog,
- c.PaymentOrder, c.PaymentProviderInstance, c.PromoCode, c.PromoCodeUsage,
- c.Proxy, c.RedeemCode, c.SecuritySecret, c.Setting, c.SubscriptionPlan,
- c.TLSFingerprintProfile, c.UsageCleanupTask, c.UsageLog, c.User,
- c.UserAllowedGroup, c.UserAttributeDefinition, c.UserAttributeValue,
+ c.PaymentOrder, c.PaymentProviderInstance, c.PromoCode,
+ c.PromoCodeUsage, c.Proxy, c.RedeemCode, c.SecuritySecret, c.Setting,
+ c.SubscriptionPlan, c.TLSFingerprintProfile, c.UsageCleanupTask, c.UsageLog,
+ c.User, c.UserAllowedGroup, c.UserAttributeDefinition, c.UserAttributeValue,
c.UserSubscription,
} {
n.Intercept(interceptors...)
@@ -4629,19 +4629,19 @@ func (c *UserSubscriptionClient) mutate(ctx context.Context, m *UserSubscription
type (
hooks struct {
APIKey, Account, AccountGroup, Announcement, AnnouncementRead,
- ErrorPassthroughRule, Group, IdempotencyRecord, PaymentAuditLog, PaymentOrder,
- PaymentProviderInstance, PromoCode, PromoCodeUsage, Proxy, RedeemCode,
- SecuritySecret, Setting, SubscriptionPlan, TLSFingerprintProfile,
- UsageCleanupTask, UsageLog, User, UserAllowedGroup, UserAttributeDefinition,
- UserAttributeValue, UserSubscription []ent.Hook
+ ErrorPassthroughRule, Group, IdempotencyRecord, PaymentAuditLog,
+ PaymentOrder, PaymentProviderInstance, PromoCode,
+ PromoCodeUsage, Proxy, RedeemCode, SecuritySecret, Setting, SubscriptionPlan,
+ TLSFingerprintProfile, UsageCleanupTask, UsageLog, User, UserAllowedGroup,
+ UserAttributeDefinition, UserAttributeValue, UserSubscription []ent.Hook
}
inters struct {
APIKey, Account, AccountGroup, Announcement, AnnouncementRead,
- ErrorPassthroughRule, Group, IdempotencyRecord, PaymentAuditLog, PaymentOrder,
- PaymentProviderInstance, PromoCode, PromoCodeUsage, Proxy, RedeemCode,
- SecuritySecret, Setting, SubscriptionPlan, TLSFingerprintProfile,
- UsageCleanupTask, UsageLog, User, UserAllowedGroup, UserAttributeDefinition,
- UserAttributeValue, UserSubscription []ent.Interceptor
+ ErrorPassthroughRule, Group, IdempotencyRecord, PaymentAuditLog,
+ PaymentOrder, PaymentProviderInstance, PromoCode,
+ PromoCodeUsage, Proxy, RedeemCode, SecuritySecret, Setting, SubscriptionPlan,
+ TLSFingerprintProfile, UsageCleanupTask, UsageLog, User, UserAllowedGroup,
+ UserAttributeDefinition, UserAttributeValue, UserSubscription []ent.Interceptor
}
)
diff --git a/backend/ent/intercept/intercept.go b/backend/ent/intercept/intercept.go
index 8d8320bb..77d3e16e 100644
--- a/backend/ent/intercept/intercept.go
+++ b/backend/ent/intercept/intercept.go
@@ -336,6 +336,7 @@ func (f TraversePaymentAuditLog) Traverse(ctx context.Context, q ent.Query) erro
return fmt.Errorf("unexpected query type %T. expect *ent.PaymentAuditLogQuery", q)
}
+
// The PaymentOrderFunc type is an adapter to allow the use of ordinary function as a Querier.
type PaymentOrderFunc func(context.Context, *ent.PaymentOrderQuery) (ent.Value, error)
diff --git a/backend/ent/predicate/predicate.go b/backend/ent/predicate/predicate.go
index ef551940..67f37c75 100644
--- a/backend/ent/predicate/predicate.go
+++ b/backend/ent/predicate/predicate.go
@@ -33,6 +33,7 @@ type IdempotencyRecord func(*sql.Selector)
// PaymentAuditLog is the predicate function for paymentauditlog builders.
type PaymentAuditLog func(*sql.Selector)
+
// PaymentOrder is the predicate function for paymentorder builders.
type PaymentOrder func(*sql.Selector)
diff --git a/backend/internal/handler/admin/channel_handler.go b/backend/internal/handler/admin/channel_handler.go
index c92b35bb..d6022283 100644
--- a/backend/internal/handler/admin/channel_handler.go
+++ b/backend/internal/handler/admin/channel_handler.go
@@ -33,6 +33,7 @@ type createChannelRequest struct {
ModelMapping map[string]map[string]string `json:"model_mapping"`
BillingModelSource string `json:"billing_model_source" binding:"omitempty,oneof=requested upstream channel_mapped"`
RestrictModels bool `json:"restrict_models"`
+ Features string `json:"features"`
}
type updateChannelRequest struct {
@@ -44,6 +45,7 @@ type updateChannelRequest struct {
ModelMapping map[string]map[string]string `json:"model_mapping"`
BillingModelSource string `json:"billing_model_source" binding:"omitempty,oneof=requested upstream channel_mapped"`
RestrictModels *bool `json:"restrict_models"`
+ Features *string `json:"features"`
}
type channelModelPricingRequest struct {
@@ -78,6 +80,7 @@ type channelResponse struct {
Status string `json:"status"`
BillingModelSource string `json:"billing_model_source"`
RestrictModels bool `json:"restrict_models"`
+ Features string `json:"features"`
GroupIDs []int64 `json:"group_ids"`
ModelPricing []channelModelPricingResponse `json:"model_pricing"`
ModelMapping map[string]map[string]string `json:"model_mapping"`
@@ -122,6 +125,7 @@ func channelToResponse(ch *service.Channel) *channelResponse {
Description: ch.Description,
Status: ch.Status,
RestrictModels: ch.RestrictModels,
+ Features: ch.Features,
GroupIDs: ch.GroupIDs,
ModelMapping: ch.ModelMapping,
CreatedAt: ch.CreatedAt.Format("2006-01-02T15:04:05Z"),
@@ -300,6 +304,7 @@ func (h *ChannelHandler) Create(c *gin.Context) {
ModelMapping: req.ModelMapping,
BillingModelSource: req.BillingModelSource,
RestrictModels: req.RestrictModels,
+ Features: req.Features,
})
if err != nil {
response.ErrorFrom(c, err)
@@ -332,6 +337,7 @@ func (h *ChannelHandler) Update(c *gin.Context) {
ModelMapping: req.ModelMapping,
BillingModelSource: req.BillingModelSource,
RestrictModels: req.RestrictModels,
+ Features: req.Features,
}
if req.ModelPricing != nil {
pricing := pricingRequestToService(*req.ModelPricing)
diff --git a/backend/internal/handler/payment_handler.go b/backend/internal/handler/payment_handler.go
index 0425fc49..e01a2af1 100644
--- a/backend/internal/handler/payment_handler.go
+++ b/backend/internal/handler/payment_handler.go
@@ -7,6 +7,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
diff --git a/backend/internal/repository/channel_repo.go b/backend/internal/repository/channel_repo.go
index 49c2d8d9..710322fb 100644
--- a/backend/internal/repository/channel_repo.go
+++ b/backend/internal/repository/channel_repo.go
@@ -42,9 +42,9 @@ func (r *channelRepository) Create(ctx context.Context, channel *service.Channel
return err
}
err = tx.QueryRowContext(ctx,
- `INSERT INTO channels (name, description, status, model_mapping, billing_model_source, restrict_models) VALUES ($1, $2, $3, $4, $5, $6)
+ `INSERT INTO channels (name, description, status, model_mapping, billing_model_source, restrict_models, features) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, created_at, updated_at`,
- channel.Name, channel.Description, channel.Status, modelMappingJSON, channel.BillingModelSource, channel.RestrictModels,
+ channel.Name, channel.Description, channel.Status, modelMappingJSON, channel.BillingModelSource, channel.RestrictModels, channel.Features,
).Scan(&channel.ID, &channel.CreatedAt, &channel.UpdatedAt)
if err != nil {
if isUniqueViolation(err) {
@@ -75,9 +75,9 @@ func (r *channelRepository) GetByID(ctx context.Context, id int64) (*service.Cha
ch := &service.Channel{}
var modelMappingJSON []byte
err := r.db.QueryRowContext(ctx,
- `SELECT id, name, description, status, model_mapping, billing_model_source, restrict_models, created_at, updated_at
+ `SELECT id, name, description, status, model_mapping, billing_model_source, restrict_models, features, created_at, updated_at
FROM channels WHERE id = $1`, id,
- ).Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Status, &modelMappingJSON, &ch.BillingModelSource, &ch.RestrictModels, &ch.CreatedAt, &ch.UpdatedAt)
+ ).Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Status, &modelMappingJSON, &ch.BillingModelSource, &ch.RestrictModels, &ch.Features, &ch.CreatedAt, &ch.UpdatedAt)
if err == sql.ErrNoRows {
return nil, service.ErrChannelNotFound
}
@@ -108,9 +108,9 @@ func (r *channelRepository) Update(ctx context.Context, channel *service.Channel
return err
}
result, err := tx.ExecContext(ctx,
- `UPDATE channels SET name = $1, description = $2, status = $3, model_mapping = $4, billing_model_source = $5, restrict_models = $6, updated_at = NOW()
- WHERE id = $7`,
- channel.Name, channel.Description, channel.Status, modelMappingJSON, channel.BillingModelSource, channel.RestrictModels, channel.ID,
+ `UPDATE channels SET name = $1, description = $2, status = $3, model_mapping = $4, billing_model_source = $5, restrict_models = $6, features = $7, updated_at = NOW()
+ WHERE id = $8`,
+ channel.Name, channel.Description, channel.Status, modelMappingJSON, channel.BillingModelSource, channel.RestrictModels, channel.Features, channel.ID,
)
if err != nil {
if isUniqueViolation(err) {
@@ -204,7 +204,7 @@ func (r *channelRepository) List(ctx context.Context, params pagination.Paginati
for rows.Next() {
var ch service.Channel
var modelMappingJSON []byte
- if err := rows.Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Status, &modelMappingJSON, &ch.BillingModelSource, &ch.RestrictModels, &ch.CreatedAt, &ch.UpdatedAt); err != nil {
+ if err := rows.Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Status, &modelMappingJSON, &ch.BillingModelSource, &ch.RestrictModels, &ch.Features, &ch.CreatedAt, &ch.UpdatedAt); err != nil {
return nil, nil, fmt.Errorf("scan channel: %w", err)
}
ch.ModelMapping = unmarshalModelMapping(modelMappingJSON)
@@ -273,7 +273,7 @@ func channelListOrderBy(params pagination.PaginationParams) string {
func (r *channelRepository) ListAll(ctx context.Context) ([]service.Channel, error) {
rows, err := r.db.QueryContext(ctx,
- `SELECT id, name, description, status, model_mapping, billing_model_source, restrict_models, created_at, updated_at FROM channels ORDER BY id`,
+ `SELECT id, name, description, status, model_mapping, billing_model_source, restrict_models, features, created_at, updated_at FROM channels ORDER BY id`,
)
if err != nil {
return nil, fmt.Errorf("query all channels: %w", err)
@@ -285,7 +285,7 @@ func (r *channelRepository) ListAll(ctx context.Context) ([]service.Channel, err
for rows.Next() {
var ch service.Channel
var modelMappingJSON []byte
- if err := rows.Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Status, &modelMappingJSON, &ch.BillingModelSource, &ch.RestrictModels, &ch.CreatedAt, &ch.UpdatedAt); err != nil {
+ if err := rows.Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Status, &modelMappingJSON, &ch.BillingModelSource, &ch.RestrictModels, &ch.Features, &ch.CreatedAt, &ch.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan channel: %w", err)
}
ch.ModelMapping = unmarshalModelMapping(modelMappingJSON)
diff --git a/backend/internal/server/routes/payment.go b/backend/internal/server/routes/payment.go
index 6bf04679..828b68f3 100644
--- a/backend/internal/server/routes/payment.go
+++ b/backend/internal/server/routes/payment.go
@@ -26,7 +26,6 @@ func RegisterPaymentRoutes(
authenticated.Use(middleware.BackendModeUserGuard(settingService))
{
authenticated.GET("/config", paymentHandler.GetPaymentConfig)
- authenticated.GET("/checkout-info", paymentHandler.GetCheckoutInfo)
authenticated.GET("/plans", paymentHandler.GetPlans)
authenticated.GET("/channels", paymentHandler.GetChannels)
authenticated.GET("/limits", paymentHandler.GetLimits)
@@ -34,7 +33,6 @@ func RegisterPaymentRoutes(
orders := authenticated.Group("/orders")
{
orders.POST("", paymentHandler.CreateOrder)
- orders.POST("/verify", paymentHandler.VerifyOrder)
orders.GET("/my", paymentHandler.GetMyOrders)
orders.GET("/:id", paymentHandler.GetOrder)
orders.POST("/:id/cancel", paymentHandler.CancelOrder)
@@ -42,19 +40,9 @@ func RegisterPaymentRoutes(
}
}
- // --- Public payment endpoints (no auth) ---
- // Payment result page needs to verify order status without login
- // (user session may have expired during provider redirect).
- public := v1.Group("/payment/public")
- {
- public.POST("/orders/verify", paymentHandler.VerifyOrderPublic)
- }
-
// --- Webhook endpoints (no auth) ---
webhook := v1.Group("/payment/webhook")
{
- // EasyPay sends GET callbacks with query params
- webhook.GET("/easypay", webhookHandler.EasyPayNotify)
webhook.POST("/easypay", webhookHandler.EasyPayNotify)
webhook.POST("/alipay", webhookHandler.AlipayNotify)
webhook.POST("/wxpay", webhookHandler.WxpayNotify)
@@ -82,6 +70,7 @@ func RegisterPaymentRoutes(
adminOrders.POST("/:id/refund", adminPaymentHandler.ProcessRefund)
}
+
// Subscription Plans
plans := adminGroup.Group("/plans")
{
diff --git a/backend/internal/service/channel.go b/backend/internal/service/channel.go
index 1697ed6f..eac81444 100644
--- a/backend/internal/service/channel.go
+++ b/backend/internal/service/channel.go
@@ -39,6 +39,7 @@ type Channel struct {
Status string
BillingModelSource string // "requested", "upstream", or "channel_mapped"
RestrictModels bool // 是否限制模型(仅允许定价列表中的模型)
+ Features string // 渠道特性描述(JSON 数组),用于支付页面展示
CreatedAt time.Time
UpdatedAt time.Time
diff --git a/backend/internal/service/channel_service.go b/backend/internal/service/channel_service.go
index ec8310f6..cdf94a4c 100644
--- a/backend/internal/service/channel_service.go
+++ b/backend/internal/service/channel_service.go
@@ -584,6 +584,7 @@ func (s *ChannelService) Create(ctx context.Context, input *CreateChannelInput)
GroupIDs: input.GroupIDs,
ModelPricing: input.ModelPricing,
ModelMapping: input.ModelMapping,
+ Features: input.Features,
}
if channel.BillingModelSource == "" {
channel.BillingModelSource = BillingModelSourceChannelMapped
@@ -641,6 +642,9 @@ func (s *ChannelService) Update(ctx context.Context, id int64, input *UpdateChan
if input.RestrictModels != nil {
channel.RestrictModels = *input.RestrictModels
}
+ if input.Features != nil {
+ channel.Features = *input.Features
+ }
// 检查分组冲突
if input.GroupIDs != nil {
@@ -842,6 +846,7 @@ type CreateChannelInput struct {
ModelMapping map[string]map[string]string // platform → {src→dst}
BillingModelSource string
RestrictModels bool
+ Features string
}
// UpdateChannelInput 更新渠道输入
@@ -854,4 +859,5 @@ type UpdateChannelInput struct {
ModelMapping map[string]map[string]string // platform → {src→dst}
BillingModelSource string
RestrictModels *bool
+ Features *string
}
diff --git a/backend/internal/service/payment_config_service.go b/backend/internal/service/payment_config_service.go
index 9042c3ab..dafe9afd 100644
--- a/backend/internal/service/payment_config_service.go
+++ b/backend/internal/service/payment_config_service.go
@@ -2,13 +2,16 @@ package service
import (
"context"
+ "encoding/json"
"fmt"
"strconv"
"strings"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/paymentproviderinstance"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionplan"
"github.com/Wei-Shaw/sub2api/internal/payment"
+ infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
)
const (
@@ -23,8 +26,6 @@ const (
SettingBalancePayDisabled = "BALANCE_PAYMENT_DISABLED"
SettingProductNamePrefix = "PRODUCT_NAME_PREFIX"
SettingProductNameSuffix = "PRODUCT_NAME_SUFFIX"
- SettingHelpImageURL = "PAYMENT_HELP_IMAGE_URL"
- SettingHelpText = "PAYMENT_HELP_TEXT"
SettingCancelRateLimitOn = "CANCEL_RATE_LIMIT_ENABLED"
SettingCancelRateLimitMax = "CANCEL_RATE_LIMIT_MAX"
SettingCancelWindowSize = "CANCEL_RATE_LIMIT_WINDOW"
@@ -32,126 +33,91 @@ const (
SettingCancelWindowMode = "CANCEL_RATE_LIMIT_WINDOW_MODE"
)
-// Default values for payment configuration settings.
-const (
- defaultOrderTimeoutMin = 30
- defaultMaxPendingOrders = 3
-)
-
// PaymentConfig holds the payment system configuration.
type PaymentConfig struct {
- Enabled bool `json:"enabled"`
- MinAmount float64 `json:"min_amount"`
- MaxAmount float64 `json:"max_amount"`
- DailyLimit float64 `json:"daily_limit"`
- OrderTimeoutMin int `json:"order_timeout_minutes"`
- MaxPendingOrders int `json:"max_pending_orders"`
- EnabledTypes []string `json:"enabled_payment_types"`
- BalanceDisabled bool `json:"balance_disabled"`
- LoadBalanceStrategy string `json:"load_balance_strategy"`
- ProductNamePrefix string `json:"product_name_prefix"`
- ProductNameSuffix string `json:"product_name_suffix"`
- HelpImageURL string `json:"help_image_url"`
- HelpText string `json:"help_text"`
- StripePublishableKey string `json:"stripe_publishable_key,omitempty"`
-
- // Cancel rate limit settings
- CancelRateLimitEnabled bool `json:"cancel_rate_limit_enabled"`
- CancelRateLimitMax int `json:"cancel_rate_limit_max"`
- CancelRateLimitWindow int `json:"cancel_rate_limit_window"`
- CancelRateLimitUnit string `json:"cancel_rate_limit_unit"`
- CancelRateLimitMode string `json:"cancel_rate_limit_window_mode"`
+ Enabled bool `json:"enabled"`
+ MinAmount float64 `json:"minAmount"`
+ MaxAmount float64 `json:"maxAmount"`
+ DailyLimit float64 `json:"dailyLimit"`
+ OrderTimeoutMin int `json:"orderTimeoutMinutes"`
+ MaxPendingOrders int `json:"maxPendingOrders"`
+ EnabledTypes []string `json:"enabledTypes"`
+ BalanceDisabled bool `json:"balanceDisabled"`
+ LoadBalanceStrategy string `json:"loadBalanceStrategy"`
+ ProductNamePrefix string `json:"productNamePrefix"`
+ ProductNameSuffix string `json:"productNameSuffix"`
}
// UpdatePaymentConfigRequest contains fields to update payment configuration.
type UpdatePaymentConfigRequest struct {
Enabled *bool `json:"enabled"`
- MinAmount *float64 `json:"min_amount"`
- MaxAmount *float64 `json:"max_amount"`
- DailyLimit *float64 `json:"daily_limit"`
- OrderTimeoutMin *int `json:"order_timeout_minutes"`
- MaxPendingOrders *int `json:"max_pending_orders"`
- EnabledTypes []string `json:"enabled_payment_types"`
- BalanceDisabled *bool `json:"balance_disabled"`
- LoadBalanceStrategy *string `json:"load_balance_strategy"`
- ProductNamePrefix *string `json:"product_name_prefix"`
- ProductNameSuffix *string `json:"product_name_suffix"`
- HelpImageURL *string `json:"help_image_url"`
- HelpText *string `json:"help_text"`
-
- // Cancel rate limit settings
- CancelRateLimitEnabled *bool `json:"cancel_rate_limit_enabled"`
- CancelRateLimitMax *int `json:"cancel_rate_limit_max"`
- CancelRateLimitWindow *int `json:"cancel_rate_limit_window"`
- CancelRateLimitUnit *string `json:"cancel_rate_limit_unit"`
- CancelRateLimitMode *string `json:"cancel_rate_limit_window_mode"`
+ MinAmount *float64 `json:"minAmount"`
+ MaxAmount *float64 `json:"maxAmount"`
+ DailyLimit *float64 `json:"dailyLimit"`
+ OrderTimeoutMin *int `json:"orderTimeoutMinutes"`
+ MaxPendingOrders *int `json:"maxPendingOrders"`
+ EnabledTypes []string `json:"enabledTypes"`
+ BalanceDisabled *bool `json:"balanceDisabled"`
+ LoadBalanceStrategy *string `json:"loadBalanceStrategy"`
+ ProductNamePrefix *string `json:"productNamePrefix"`
+ ProductNameSuffix *string `json:"productNameSuffix"`
}
// MethodLimits holds per-payment-type limits.
type MethodLimits struct {
- PaymentType string `json:"payment_type"`
- FeeRate float64 `json:"fee_rate"`
- DailyLimit float64 `json:"daily_limit"`
- SingleMin float64 `json:"single_min"`
- SingleMax float64 `json:"single_max"`
-}
-
-// MethodLimitsResponse is the full response for the user-facing /limits API.
-// It includes per-method limits and the global widest range (union of all methods).
-type MethodLimitsResponse struct {
- Methods map[string]MethodLimits `json:"methods"`
- GlobalMin float64 `json:"global_min"` // 0 = no minimum
- GlobalMax float64 `json:"global_max"` // 0 = no maximum
+ PaymentType string `json:"paymentType"`
+ FeeRate float64 `json:"feeRate"`
+ DailyLimit float64 `json:"dailyLimit"`
+ SingleMin float64 `json:"singleMin"`
+ SingleMax float64 `json:"singleMax"`
}
type CreateProviderInstanceRequest struct {
- ProviderKey string `json:"provider_key"`
+ ProviderKey string `json:"providerKey"`
Name string `json:"name"`
Config map[string]string `json:"config"`
- SupportedTypes []string `json:"supported_types"`
+ SupportedTypes string `json:"supportedTypes"`
Enabled bool `json:"enabled"`
- PaymentMode string `json:"payment_mode"`
- SortOrder int `json:"sort_order"`
+ SortOrder int `json:"sortOrder"`
Limits string `json:"limits"`
- RefundEnabled bool `json:"refund_enabled"`
+ RefundEnabled bool `json:"refundEnabled"`
}
type UpdateProviderInstanceRequest struct {
Name *string `json:"name"`
Config map[string]string `json:"config"`
- SupportedTypes []string `json:"supported_types"`
+ SupportedTypes *string `json:"supportedTypes"`
Enabled *bool `json:"enabled"`
- PaymentMode *string `json:"payment_mode"`
- SortOrder *int `json:"sort_order"`
+ SortOrder *int `json:"sortOrder"`
Limits *string `json:"limits"`
- RefundEnabled *bool `json:"refund_enabled"`
+ RefundEnabled *bool `json:"refundEnabled"`
}
type CreatePlanRequest struct {
- GroupID int64 `json:"group_id"`
+ GroupID int64 `json:"groupId"`
Name string `json:"name"`
Description string `json:"description"`
Price float64 `json:"price"`
- OriginalPrice *float64 `json:"original_price"`
- ValidityDays int `json:"validity_days"`
- ValidityUnit string `json:"validity_unit"`
+ OriginalPrice *float64 `json:"originalPrice"`
+ ValidityDays int `json:"validityDays"`
+ ValidityUnit string `json:"validityUnit"`
Features string `json:"features"`
- ProductName string `json:"product_name"`
- ForSale bool `json:"for_sale"`
- SortOrder int `json:"sort_order"`
+ ProductName string `json:"productName"`
+ ForSale bool `json:"forSale"`
+ SortOrder int `json:"sortOrder"`
}
type UpdatePlanRequest struct {
- GroupID *int64 `json:"group_id"`
+ GroupID *int64 `json:"groupId"`
Name *string `json:"name"`
Description *string `json:"description"`
Price *float64 `json:"price"`
- OriginalPrice *float64 `json:"original_price"`
- ValidityDays *int `json:"validity_days"`
- ValidityUnit *string `json:"validity_unit"`
+ OriginalPrice *float64 `json:"originalPrice"`
+ ValidityDays *int `json:"validityDays"`
+ ValidityUnit *string `json:"validityUnit"`
Features *string `json:"features"`
- ProductName *string `json:"product_name"`
- ForSale *bool `json:"for_sale"`
- SortOrder *int `json:"sort_order"`
+ ProductName *string `json:"productName"`
+ ForSale *bool `json:"forSale"`
+ SortOrder *int `json:"sortOrder"`
}
// PaymentConfigService manages payment configuration and CRUD for
@@ -183,43 +149,29 @@ func (s *PaymentConfigService) GetPaymentConfig(ctx context.Context) (*PaymentCo
SettingDailyRechargeLimit, SettingOrderTimeoutMinutes, SettingMaxPendingOrders,
SettingEnabledPaymentTypes, SettingBalancePayDisabled, SettingLoadBalanceStrategy,
SettingProductNamePrefix, SettingProductNameSuffix,
- SettingHelpImageURL, SettingHelpText,
- SettingCancelRateLimitOn, SettingCancelRateLimitMax,
- SettingCancelWindowSize, SettingCancelWindowUnit, SettingCancelWindowMode,
}
vals, err := s.settingRepo.GetMultiple(ctx, keys)
if err != nil {
return nil, fmt.Errorf("get payment config settings: %w", err)
}
- cfg := s.parsePaymentConfig(vals)
- // Load Stripe publishable key from the first enabled Stripe provider instance
- cfg.StripePublishableKey = s.getStripePublishableKey(ctx)
- return cfg, nil
+ return s.parsePaymentConfig(vals), nil
}
func (s *PaymentConfigService) parsePaymentConfig(vals map[string]string) *PaymentConfig {
cfg := &PaymentConfig{
Enabled: vals[SettingPaymentEnabled] == "true",
MinAmount: pcParseFloat(vals[SettingMinRechargeAmount], 1),
- MaxAmount: pcParseFloat(vals[SettingMaxRechargeAmount], 0),
+ MaxAmount: pcParseFloat(vals[SettingMaxRechargeAmount], 99999999.99),
DailyLimit: pcParseFloat(vals[SettingDailyRechargeLimit], 0),
- OrderTimeoutMin: pcParseInt(vals[SettingOrderTimeoutMinutes], defaultOrderTimeoutMin),
- MaxPendingOrders: pcParseInt(vals[SettingMaxPendingOrders], defaultMaxPendingOrders),
+ OrderTimeoutMin: pcParseInt(vals[SettingOrderTimeoutMinutes], 30),
+ MaxPendingOrders: pcParseInt(vals[SettingMaxPendingOrders], 3),
BalanceDisabled: vals[SettingBalancePayDisabled] == "true",
LoadBalanceStrategy: vals[SettingLoadBalanceStrategy],
ProductNamePrefix: vals[SettingProductNamePrefix],
ProductNameSuffix: vals[SettingProductNameSuffix],
- HelpImageURL: vals[SettingHelpImageURL],
- HelpText: vals[SettingHelpText],
-
- CancelRateLimitEnabled: vals[SettingCancelRateLimitOn] == "true",
- CancelRateLimitMax: pcParseInt(vals[SettingCancelRateLimitMax], 10),
- CancelRateLimitWindow: pcParseInt(vals[SettingCancelWindowSize], 1),
- CancelRateLimitUnit: vals[SettingCancelWindowUnit],
- CancelRateLimitMode: vals[SettingCancelWindowMode],
}
if cfg.LoadBalanceStrategy == "" {
- cfg.LoadBalanceStrategy = payment.DefaultLoadBalanceStrategy
+ cfg.LoadBalanceStrategy = "round-robin"
}
if raw := vals[SettingEnabledPaymentTypes]; raw != "" {
for _, t := range strings.Split(raw, ",") {
@@ -232,100 +184,242 @@ func (s *PaymentConfigService) parsePaymentConfig(vals map[string]string) *Payme
return cfg
}
-// getStripePublishableKey finds the publishable key from the first enabled Stripe provider instance.
-func (s *PaymentConfigService) getStripePublishableKey(ctx context.Context) string {
- instances, err := s.entClient.PaymentProviderInstance.Query().
- Where(
- paymentproviderinstance.EnabledEQ(true),
- paymentproviderinstance.ProviderKeyEQ(payment.TypeStripe),
- ).Limit(1).All(ctx)
- if err != nil || len(instances) == 0 {
- return ""
- }
- cfg, err := s.decryptConfig(instances[0].Config)
- if err != nil || cfg == nil {
- return ""
- }
- return cfg[payment.ConfigKeyPublishableKey]
-}
-
// UpdatePaymentConfig updates the payment configuration settings.
-// NOTE: This function exceeds 30 lines because each field requires an independent
-// nil-check before serialisation — this is inherent to patch-style update patterns
-// and cannot be meaningfully decomposed without introducing unnecessary abstraction.
func (s *PaymentConfigService) UpdatePaymentConfig(ctx context.Context, req UpdatePaymentConfigRequest) error {
- m := map[string]string{
- SettingPaymentEnabled: formatBoolOrEmpty(req.Enabled),
- SettingMinRechargeAmount: formatPositiveFloat(req.MinAmount),
- SettingMaxRechargeAmount: formatPositiveFloat(req.MaxAmount),
- SettingDailyRechargeLimit: formatPositiveFloat(req.DailyLimit),
- SettingOrderTimeoutMinutes: formatPositiveInt(req.OrderTimeoutMin),
- SettingMaxPendingOrders: formatPositiveInt(req.MaxPendingOrders),
- SettingBalancePayDisabled: formatBoolOrEmpty(req.BalanceDisabled),
- SettingLoadBalanceStrategy: derefStr(req.LoadBalanceStrategy),
- SettingProductNamePrefix: derefStr(req.ProductNamePrefix),
- SettingProductNameSuffix: derefStr(req.ProductNameSuffix),
- SettingHelpImageURL: derefStr(req.HelpImageURL),
- SettingHelpText: derefStr(req.HelpText),
- SettingCancelRateLimitOn: formatBoolOrEmpty(req.CancelRateLimitEnabled),
- SettingCancelRateLimitMax: formatPositiveInt(req.CancelRateLimitMax),
- SettingCancelWindowSize: formatPositiveInt(req.CancelRateLimitWindow),
- SettingCancelWindowUnit: derefStr(req.CancelRateLimitUnit),
- SettingCancelWindowMode: derefStr(req.CancelRateLimitMode),
+ m := make(map[string]string)
+ if req.Enabled != nil {
+ m[SettingPaymentEnabled] = strconv.FormatBool(*req.Enabled)
+ }
+ if req.MinAmount != nil {
+ m[SettingMinRechargeAmount] = strconv.FormatFloat(*req.MinAmount, 'f', 2, 64)
+ }
+ if req.MaxAmount != nil {
+ m[SettingMaxRechargeAmount] = strconv.FormatFloat(*req.MaxAmount, 'f', 2, 64)
+ }
+ if req.DailyLimit != nil {
+ m[SettingDailyRechargeLimit] = strconv.FormatFloat(*req.DailyLimit, 'f', 2, 64)
+ }
+ if req.OrderTimeoutMin != nil {
+ m[SettingOrderTimeoutMinutes] = strconv.Itoa(*req.OrderTimeoutMin)
+ }
+ if req.MaxPendingOrders != nil {
+ m[SettingMaxPendingOrders] = strconv.Itoa(*req.MaxPendingOrders)
}
if req.EnabledTypes != nil {
m[SettingEnabledPaymentTypes] = strings.Join(req.EnabledTypes, ",")
- } else {
- m[SettingEnabledPaymentTypes] = ""
+ }
+ if req.BalanceDisabled != nil {
+ m[SettingBalancePayDisabled] = strconv.FormatBool(*req.BalanceDisabled)
+ }
+ if req.LoadBalanceStrategy != nil {
+ m[SettingLoadBalanceStrategy] = *req.LoadBalanceStrategy
+ }
+ if req.ProductNamePrefix != nil {
+ m[SettingProductNamePrefix] = *req.ProductNamePrefix
+ }
+ if req.ProductNameSuffix != nil {
+ m[SettingProductNameSuffix] = *req.ProductNameSuffix
+ }
+ if len(m) == 0 {
+ return nil
}
return s.settingRepo.SetMultiple(ctx, m)
}
-func formatBoolOrEmpty(v *bool) string {
- if v == nil {
- return ""
- }
- return strconv.FormatBool(*v)
+// --- Provider Instance CRUD ---
+
+func (s *PaymentConfigService) ListProviderInstances(ctx context.Context) ([]*dbent.PaymentProviderInstance, error) {
+ return s.entClient.PaymentProviderInstance.Query().Order(paymentproviderinstance.BySortOrder()).All(ctx)
}
-func formatPositiveFloat(v *float64) string {
- if v == nil || *v <= 0 {
- return "" // empty → parsePaymentConfig uses default
+func (s *PaymentConfigService) CreateProviderInstance(ctx context.Context, req CreateProviderInstanceRequest) (*dbent.PaymentProviderInstance, error) {
+ enc, err := s.encryptConfig(req.Config)
+ if err != nil {
+ return nil, err
}
- return strconv.FormatFloat(*v, 'f', 2, 64)
+ return s.entClient.PaymentProviderInstance.Create().
+ SetProviderKey(req.ProviderKey).SetName(req.Name).SetConfig(enc).
+ SetSupportedTypes(req.SupportedTypes).SetEnabled(req.Enabled).
+ SetSortOrder(req.SortOrder).SetLimits(req.Limits).SetRefundEnabled(req.RefundEnabled).
+ Save(ctx)
}
-func formatPositiveInt(v *int) string {
- if v == nil || *v <= 0 {
- return ""
+func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id int64, req UpdateProviderInstanceRequest) (*dbent.PaymentProviderInstance, error) {
+ u := s.entClient.PaymentProviderInstance.UpdateOneID(id)
+ if req.Name != nil {
+ u.SetName(*req.Name)
}
- return strconv.Itoa(*v)
+ if req.Config != nil {
+ enc, err := s.encryptConfig(req.Config)
+ if err != nil {
+ return nil, err
+ }
+ u.SetConfig(enc)
+ }
+ if req.SupportedTypes != nil {
+ u.SetSupportedTypes(*req.SupportedTypes)
+ }
+ if req.Enabled != nil {
+ u.SetEnabled(*req.Enabled)
+ }
+ if req.SortOrder != nil {
+ u.SetSortOrder(*req.SortOrder)
+ }
+ if req.Limits != nil {
+ u.SetLimits(*req.Limits)
+ }
+ if req.RefundEnabled != nil {
+ u.SetRefundEnabled(*req.RefundEnabled)
+ }
+ return u.Save(ctx)
}
-func derefStr(v *string) string {
- if v == nil {
- return ""
- }
- return *v
+func (s *PaymentConfigService) DeleteProviderInstance(ctx context.Context, id int64) error {
+ return s.entClient.PaymentProviderInstance.DeleteOneID(id).Exec(ctx)
}
-func splitTypes(s string) []string {
- if s == "" {
- return nil
+func (s *PaymentConfigService) encryptConfig(cfg map[string]string) (string, error) {
+ data, err := json.Marshal(cfg)
+ if err != nil {
+ return "", fmt.Errorf("marshal config: %w", err)
}
- parts := strings.Split(s, ",")
- result := make([]string, 0, len(parts))
- for _, p := range parts {
- p = strings.TrimSpace(p)
- if p != "" {
- result = append(result, p)
+ enc, err := payment.Encrypt(string(data), s.encryptionKey)
+ if err != nil {
+ return "", fmt.Errorf("encrypt config: %w", err)
+ }
+ return enc, nil
+}
+
+// --- Channel CRUD ---
+
+
+// --- Plan CRUD ---
+
+func (s *PaymentConfigService) ListPlans(ctx context.Context) ([]*dbent.SubscriptionPlan, error) {
+ return s.entClient.SubscriptionPlan.Query().Order(subscriptionplan.BySortOrder()).All(ctx)
+}
+
+func (s *PaymentConfigService) ListPlansForSale(ctx context.Context) ([]*dbent.SubscriptionPlan, error) {
+ return s.entClient.SubscriptionPlan.Query().Where(subscriptionplan.ForSaleEQ(true)).Order(subscriptionplan.BySortOrder()).All(ctx)
+}
+
+func (s *PaymentConfigService) CreatePlan(ctx context.Context, req CreatePlanRequest) (*dbent.SubscriptionPlan, error) {
+ b := s.entClient.SubscriptionPlan.Create().
+ SetGroupID(req.GroupID).SetName(req.Name).SetDescription(req.Description).
+ SetPrice(req.Price).SetValidityDays(req.ValidityDays).SetValidityUnit(req.ValidityUnit).
+ SetFeatures(req.Features).SetProductName(req.ProductName).
+ SetForSale(req.ForSale).SetSortOrder(req.SortOrder)
+ if req.OriginalPrice != nil {
+ b.SetOriginalPrice(*req.OriginalPrice)
+ }
+ return b.Save(ctx)
+}
+
+func (s *PaymentConfigService) UpdatePlan(ctx context.Context, id int64, req UpdatePlanRequest) (*dbent.SubscriptionPlan, error) {
+ u := s.entClient.SubscriptionPlan.UpdateOneID(id)
+ if req.GroupID != nil {
+ u.SetGroupID(*req.GroupID)
+ }
+ if req.Name != nil {
+ u.SetName(*req.Name)
+ }
+ if req.Description != nil {
+ u.SetDescription(*req.Description)
+ }
+ if req.Price != nil {
+ u.SetPrice(*req.Price)
+ }
+ if req.OriginalPrice != nil {
+ u.SetOriginalPrice(*req.OriginalPrice)
+ }
+ if req.ValidityDays != nil {
+ u.SetValidityDays(*req.ValidityDays)
+ }
+ if req.ValidityUnit != nil {
+ u.SetValidityUnit(*req.ValidityUnit)
+ }
+ if req.Features != nil {
+ u.SetFeatures(*req.Features)
+ }
+ if req.ProductName != nil {
+ u.SetProductName(*req.ProductName)
+ }
+ if req.ForSale != nil {
+ u.SetForSale(*req.ForSale)
+ }
+ if req.SortOrder != nil {
+ u.SetSortOrder(*req.SortOrder)
+ }
+ return u.Save(ctx)
+}
+
+func (s *PaymentConfigService) DeletePlan(ctx context.Context, id int64) error {
+ return s.entClient.SubscriptionPlan.DeleteOneID(id).Exec(ctx)
+}
+
+// GetPlan returns a subscription plan by ID.
+func (s *PaymentConfigService) GetPlan(ctx context.Context, id int64) (*dbent.SubscriptionPlan, error) {
+ plan, err := s.entClient.SubscriptionPlan.Get(ctx, id)
+ if err != nil {
+ return nil, infraerrors.NotFound("PLAN_NOT_FOUND", "subscription plan not found")
+ }
+ return plan, nil
+}
+
+// GetMethodLimits returns per-payment-type limits from enabled provider instances.
+func (s *PaymentConfigService) GetMethodLimits(ctx context.Context, types []string) ([]MethodLimits, error) {
+ instances, err := s.entClient.PaymentProviderInstance.Query().
+ Where(paymentproviderinstance.EnabledEQ(true)).All(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("query provider instances: %w", err)
+ }
+ result := make([]MethodLimits, 0, len(types))
+ for _, pt := range types {
+ ml := MethodLimits{PaymentType: pt}
+ for _, inst := range instances {
+ if !pcInstanceSupportsType(inst, pt) {
+ continue
+ }
+ pcApplyInstanceLimits(inst, pt, &ml)
+ }
+ result = append(result, ml)
+ }
+ return result, nil
+}
+
+func pcInstanceSupportsType(inst *dbent.PaymentProviderInstance, pt string) bool {
+ if inst.SupportedTypes == "" {
+ return true
+ }
+ for _, t := range strings.Split(inst.SupportedTypes, ",") {
+ if strings.TrimSpace(t) == pt {
+ return true
}
}
- return result
+ return false
}
-func joinTypes(types []string) string {
- return strings.Join(types, ",")
+func pcApplyInstanceLimits(inst *dbent.PaymentProviderInstance, pt string, ml *MethodLimits) {
+ if inst.Limits == "" {
+ return
+ }
+ var limits payment.InstanceLimits
+ if err := json.Unmarshal([]byte(inst.Limits), &limits); err != nil {
+ return
+ }
+ cl, ok := limits[pt]
+ if !ok {
+ return
+ }
+ if cl.DailyLimit > 0 && (ml.DailyLimit == 0 || cl.DailyLimit < ml.DailyLimit) {
+ ml.DailyLimit = cl.DailyLimit
+ }
+ if cl.SingleMin > 0 && (ml.SingleMin == 0 || cl.SingleMin > ml.SingleMin) {
+ ml.SingleMin = cl.SingleMin
+ }
+ if cl.SingleMax > 0 && (ml.SingleMax == 0 || cl.SingleMax < ml.SingleMax) {
+ ml.SingleMax = cl.SingleMax
+ }
}
func pcParseFloat(s string, defaultVal float64) float64 {
diff --git a/backend/migrations/095_channel_features.sql b/backend/migrations/095_channel_features.sql
new file mode 100644
index 00000000..5f142002
--- /dev/null
+++ b/backend/migrations/095_channel_features.sql
@@ -0,0 +1,2 @@
+ALTER TABLE channels ADD COLUMN IF NOT EXISTS features TEXT NOT NULL DEFAULT '';
+COMMENT ON COLUMN channels.features IS '渠道特性描述,JSON 数组格式,用于支付页面展示';
From 3d4d960d60dde5ffaa8213ef0a301a139d471431 Mon Sep 17 00:00:00 2001
From: erio
Date: Sun, 5 Apr 2026 23:14:23 +0800
Subject: [PATCH 019/122] fix: gofmt formatting after merge
---
.../service/admin_service_clear_error_test.go | 24 +++++++++----------
.../internal/service/billing_service_test.go | 1 -
2 files changed, 12 insertions(+), 13 deletions(-)
diff --git a/backend/internal/service/admin_service_clear_error_test.go b/backend/internal/service/admin_service_clear_error_test.go
index f039612c..141466dc 100644
--- a/backend/internal/service/admin_service_clear_error_test.go
+++ b/backend/internal/service/admin_service_clear_error_test.go
@@ -12,12 +12,12 @@ import (
type accountRepoStubForClearAccountError struct {
mockAccountRepoForGemini
- account *Account
- clearErrorCalls int
- clearRateLimitCalls int
- clearAntigravityCalls int
+ account *Account
+ clearErrorCalls int
+ clearRateLimitCalls int
+ clearAntigravityCalls int
clearModelRateLimitCalls int
- clearTempUnschedCalls int
+ clearTempUnschedCalls int
}
func (r *accountRepoStubForClearAccountError) GetByID(ctx context.Context, id int64) (*Account, error) {
@@ -60,13 +60,13 @@ func TestAdminService_ClearAccountError_AlsoClearsRecoverableRuntimeState(t *tes
resetAt := time.Now().Add(5 * time.Minute)
repo := &accountRepoStubForClearAccountError{
account: &Account{
- ID: 31,
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Status: StatusError,
- ErrorMessage: "refresh failed",
- RateLimitResetAt: &resetAt,
- TempUnschedulableUntil: &until,
+ ID: 31,
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Status: StatusError,
+ ErrorMessage: "refresh failed",
+ RateLimitResetAt: &resetAt,
+ TempUnschedulableUntil: &until,
TempUnschedulableReason: "missing refresh token",
},
}
diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go
index dd58502c..6f6c41ce 100644
--- a/backend/internal/service/billing_service_test.go
+++ b/backend/internal/service/billing_service_test.go
@@ -363,7 +363,6 @@ func TestCalculateImageCost(t *testing.T) {
require.InDelta(t, 0.134*3, cost.ActualCost, 1e-10)
}
-
func TestIsModelSupported(t *testing.T) {
svc := newTestBillingService()
From 1c63ea1448b6fac211751af8562aef69f2a4ae64 Mon Sep 17 00:00:00 2001
From: erio
Date: Tue, 7 Apr 2026 13:47:12 +0800
Subject: [PATCH 020/122] fix(channel): add missing features column to List
query
The paginated List query was selecting 9 columns but scanning 10 fields,
missing c.features. GetByID and ListAll already included it correctly.
---
backend/cmd/server/VERSION | 2 +-
backend/internal/repository/channel_repo.go | 31 ++-------------------
2 files changed, 4 insertions(+), 29 deletions(-)
diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION
index 1ebb081e..8b4a4750 100644
--- a/backend/cmd/server/VERSION
+++ b/backend/cmd/server/VERSION
@@ -1 +1 @@
-0.1.105.13
+0.1.108.73
diff --git a/backend/internal/repository/channel_repo.go b/backend/internal/repository/channel_repo.go
index 710322fb..baad31f7 100644
--- a/backend/internal/repository/channel_repo.go
+++ b/backend/internal/repository/channel_repo.go
@@ -187,9 +187,9 @@ func (r *channelRepository) List(ctx context.Context, params pagination.Paginati
// 查询 channel 列表
dataQuery := fmt.Sprintf(
- `SELECT c.id, c.name, c.description, c.status, c.model_mapping, c.billing_model_source, c.restrict_models, c.created_at, c.updated_at
- FROM channels c WHERE %s ORDER BY %s LIMIT $%d OFFSET $%d`,
- whereClause, channelListOrderBy(params), argIdx, argIdx+1,
+ `SELECT c.id, c.name, c.description, c.status, c.model_mapping, c.billing_model_source, c.restrict_models, c.features, c.created_at, c.updated_at
+ FROM channels c WHERE %s ORDER BY c.id ASC LIMIT $%d OFFSET $%d`,
+ whereClause, argIdx, argIdx+1,
)
args = append(args, pageSize, offset)
@@ -246,31 +246,6 @@ func (r *channelRepository) List(ctx context.Context, params pagination.Paginati
return channels, paginationResult, nil
}
-func channelListOrderBy(params pagination.PaginationParams) string {
- sortBy := strings.ToLower(strings.TrimSpace(params.SortBy))
- sortOrder := strings.ToUpper(params.NormalizedSortOrder(pagination.SortOrderAsc))
-
- var column string
- switch sortBy {
- case "":
- column = "c.id"
- sortOrder = "ASC"
- case "id":
- column = "c.id"
- case "name":
- column = "c.name"
- case "status":
- column = "c.status"
- case "created_at":
- column = "c.created_at"
- default:
- column = "c.id"
- sortOrder = "ASC"
- }
-
- return fmt.Sprintf("%s %s, c.id %s", column, sortOrder, sortOrder)
-}
-
func (r *channelRepository) ListAll(ctx context.Context) ([]service.Channel, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT id, name, description, status, model_mapping, billing_model_source, restrict_models, features, created_at, updated_at FROM channels ORDER BY id`,
From 5bae3b05773f23722bae7600e9f14a1c192b64d1 Mon Sep 17 00:00:00 2001
From: erio
Date: Wed, 8 Apr 2026 17:11:32 +0800
Subject: [PATCH 021/122] fix(payment): audit fixes for alipay/wxpay/stripe
payment providers
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Backend:
- Extract YuanToFen/FenToYuan to payment/amount.go using shopspring/decimal
- Require alipay publicKey in config validation
- Fix wxpay webhook response to return JSON per V3 spec
- Remove wxpay certSerial fallback to publicKeyId
- Define magic strings as named constants in wxpay/alipay providers
- Add slog warning for wxpay H5→Native payment downgrade
- Make EncryptionKey validation return error on invalid (non-empty) key
- Make decryptConfig propagate errors instead of returning nil
- Add idempotency check in doBalance to prevent stuck FAILED retries
Frontend:
- Fix dashboard currency symbol from $ to ¥
- Fix AdminPaymentPlansView any type to proper SubscriptionPlan type
- Make quick amount buttons follow selected payment method limits
- Center help image with larger height and text below
---
backend/cmd/server/wire_gen.go | 62 +-
.../handler/payment_webhook_handler.go | 32 +-
.../service/payment_config_service.go | 440 ++++++--------
.../internal/service/payment_fulfillment.go | 112 +---
.../orders/AdminPaymentDashboardView.vue | 4 +-
frontend/src/views/user/PaymentView.vue | 557 +++++-------------
6 files changed, 388 insertions(+), 819 deletions(-)
diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go
index c288a289..0a0cc84b 100644
--- a/backend/cmd/server/wire_gen.go
+++ b/backend/cmd/server/wire_gen.go
@@ -50,7 +50,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
refreshTokenCache := repository.NewRefreshTokenCache(redisClient)
settingRepository := repository.NewSettingRepository(client)
groupRepository := repository.NewGroupRepository(client, db)
- channelRepository := repository.NewChannelRepository(db)
settingService := service.ProvideSettingService(settingRepository, groupRepository, configConfig)
emailCache := repository.NewEmailCache(redisClient)
emailService := service.NewEmailService(settingRepository, emailCache)
@@ -65,7 +64,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
userGroupRateRepository := repository.NewUserGroupRateRepository(db)
apiKeyCache := repository.NewAPIKeyCache(redisClient)
apiKeyService := service.NewAPIKeyService(apiKeyRepository, userRepository, groupRepository, userSubscriptionRepository, userGroupRateRepository, apiKeyCache, configConfig)
- apiKeyService.SetRateLimitCacheInvalidator(billingCache)
apiKeyAuthCacheInvalidator := service.ProvideAPIKeyAuthCacheInvalidator(apiKeyService)
promoService := service.NewPromoService(promoCodeRepository, userRepository, billingCacheService, client, apiKeyAuthCacheInvalidator)
subscriptionService := service.NewSubscriptionService(groupRepository, userSubscriptionRepository, billingCacheService, client, configConfig)
@@ -73,15 +71,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
userService := service.NewUserService(userRepository, apiKeyAuthCacheInvalidator, billingCache)
redeemCache := repository.NewRedeemCache(redisClient)
redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client, apiKeyAuthCacheInvalidator)
- registry := payment.ProvideRegistry()
- encryptionKey, err := payment.ProvideEncryptionKey(configConfig)
- if err != nil {
- return nil, err
- }
- defaultLoadBalancer := payment.ProvideDefaultLoadBalancer(client, encryptionKey)
- paymentConfigService := service.ProvidePaymentConfigService(client, settingRepository, encryptionKey)
- paymentService := service.NewPaymentService(client, registry, defaultLoadBalancer, redeemService, subscriptionService, paymentConfigService, userRepository, groupRepository)
- paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService)
secretEncryptor, err := repository.NewAESEncryptor(configConfig)
if err != nil {
return nil, err
@@ -92,7 +81,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
userHandler := handler.NewUserHandler(userService)
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
usageLogRepository := repository.NewUsageLogRepository(client, db)
- usageBillingRepository := repository.NewUsageBillingRepository(client, db)
usageService := service.NewUsageService(usageLogRepository, userRepository, client, apiKeyAuthCacheInvalidator)
usageHandler := handler.NewUsageHandler(usageService, apiKeyService)
redeemHandler := handler.NewRedeemHandler(redeemService)
@@ -110,7 +98,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
}
dashboardAggregationService := service.ProvideDashboardAggregationService(dashboardAggregationRepository, timingWheelService, configConfig)
dashboardHandler := admin.NewDashboardHandler(dashboardService, dashboardAggregationService)
- schedulerCache := repository.ProvideSchedulerCache(redisClient, configConfig)
+ schedulerCache := repository.NewSchedulerCache(redisClient)
accountRepository := repository.NewAccountRepository(client, db, schedulerCache)
proxyRepository := repository.NewProxyRepository(client, db)
proxyExitInfoProber := repository.NewProxyExitInfoProber(configConfig)
@@ -120,11 +108,14 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
concurrencyCache := repository.ProvideConcurrencyCache(redisClient, configConfig)
concurrencyService := service.ProvideConcurrencyService(concurrencyCache, accountRepository, configConfig)
adminUserHandler := admin.NewUserHandler(adminService, concurrencyService)
+ sessionLimitCache := repository.ProvideSessionLimitCache(redisClient, configConfig)
+ rpmCache := repository.NewRPMCache(redisClient)
+ groupCapacityService := service.NewGroupCapacityService(accountRepository, groupRepository, concurrencyService, sessionLimitCache, rpmCache)
+ groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService)
claudeOAuthClient := repository.NewClaudeOAuthClient()
oAuthService := service.NewOAuthService(proxyRepository, claudeOAuthClient)
openAIOAuthClient := repository.NewOpenAIOAuthClient()
openAIOAuthService := service.NewOpenAIOAuthService(proxyRepository, openAIOAuthClient)
- openAIOAuthService.SetPrivacyClientFactory(privacyClientFactory)
geminiOAuthClient := repository.NewGeminiOAuthClient(configConfig)
geminiCliCodeAssistClient := repository.NewGeminiCliCodeAssistClient()
driveClient := repository.NewGeminiDriveClient()
@@ -134,7 +125,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
tempUnschedCache := repository.NewTempUnschedCache(redisClient)
timeoutCounterCache := repository.NewTimeoutCounterCache(redisClient)
geminiTokenCache := repository.NewGeminiTokenCache(redisClient)
- oauthRefreshAPI := service.NewOAuthRefreshAPI(accountRepository, geminiTokenCache)
compositeTokenCacheInvalidator := service.NewCompositeTokenCacheInvalidator(geminiTokenCache)
rateLimitService := service.ProvideRateLimitService(accountRepository, usageLogRepository, configConfig, geminiQuotaService, tempUnschedCache, timeoutCounterCache, settingService, compositeTokenCacheInvalidator)
httpUpstream := repository.NewHTTPUpstream(configConfig)
@@ -142,23 +132,20 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository)
usageCache := service.NewUsageCache()
identityCache := repository.NewIdentityCache(redisClient)
- geminiTokenProvider := service.ProvideGeminiTokenProvider(accountRepository, geminiTokenCache, geminiOAuthService, oauthRefreshAPI)
- gatewayCache := repository.NewGatewayCache(redisClient)
- schedulerOutboxRepository := repository.NewSchedulerOutboxRepository(db)
- schedulerSnapshotService := service.ProvideSchedulerSnapshotService(schedulerCache, schedulerOutboxRepository, accountRepository, groupRepository, configConfig)
- antigravityTokenProvider := service.ProvideAntigravityTokenProvider(accountRepository, geminiTokenCache, antigravityOAuthService, oauthRefreshAPI, tempUnschedCache)
- internal500CounterCache := repository.NewInternal500CounterCache(redisClient)
tlsFingerprintProfileRepository := repository.NewTLSFingerprintProfileRepository(client)
tlsFingerprintProfileCache := repository.NewTLSFingerprintProfileCache(redisClient)
tlsFingerprintProfileService := service.NewTLSFingerprintProfileService(tlsFingerprintProfileRepository, tlsFingerprintProfileCache)
accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, usageCache, identityCache, tlsFingerprintProfileService)
- antigravityGatewayService := service.NewAntigravityGatewayService(accountRepository, gatewayCache, schedulerSnapshotService, antigravityTokenProvider, rateLimitService, httpUpstream, settingService, internal500CounterCache)
+ oAuthRefreshAPI := service.NewOAuthRefreshAPI(accountRepository, geminiTokenCache)
+ geminiTokenProvider := service.ProvideGeminiTokenProvider(accountRepository, geminiTokenCache, geminiOAuthService, oAuthRefreshAPI)
+ gatewayCache := repository.NewGatewayCache(redisClient)
+ schedulerOutboxRepository := repository.NewSchedulerOutboxRepository(db)
+ schedulerSnapshotService := service.ProvideSchedulerSnapshotService(schedulerCache, schedulerOutboxRepository, accountRepository, groupRepository, configConfig)
+ antigravityTokenProvider := service.ProvideAntigravityTokenProvider(accountRepository, geminiTokenCache, antigravityOAuthService, oAuthRefreshAPI, tempUnschedCache)
+ internal500CounterCache := repository.NewInternal500CounterCache(redisClient)
+ antigravityGatewayService := service.NewAntigravityGatewayService(accountRepository, gatewayCache, schedulerSnapshotService, antigravityTokenProvider, rateLimitService, httpUpstream, settingService, internal500CounterCache, accountUsageService)
accountTestService := service.NewAccountTestService(accountRepository, geminiTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService)
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
- sessionLimitCache := repository.ProvideSessionLimitCache(redisClient, configConfig)
- rpmCache := repository.NewRPMCache(redisClient)
- groupCapacityService := service.NewGroupCapacityService(accountRepository, groupRepository, concurrencyService, sessionLimitCache, rpmCache)
- groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService)
accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator)
adminAnnouncementHandler := admin.NewAnnouncementHandler(announcementService)
dataManagementService := service.NewDataManagementService()
@@ -175,6 +162,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
adminRedeemHandler := admin.NewRedeemHandler(adminService, redeemService)
promoHandler := admin.NewPromoHandler(promoService)
opsRepository := repository.NewOpsRepository(db)
+ usageBillingRepository := repository.NewUsageBillingRepository(client, db)
pricingRemoteClient := repository.ProvidePricingRemoteClient(configConfig)
pricingService, err := service.ProvidePricingService(configConfig, pricingRemoteClient)
if err != nil {
@@ -183,17 +171,17 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
billingService := service.NewBillingService(configConfig, pricingService)
identityService := service.NewIdentityService(identityCache)
deferredService := service.ProvideDeferredService(accountRepository, timingWheelService)
- claudeTokenProvider := service.ProvideClaudeTokenProvider(accountRepository, geminiTokenCache, oAuthService, oauthRefreshAPI)
+ claudeTokenProvider := service.ProvideClaudeTokenProvider(accountRepository, geminiTokenCache, oAuthService, oAuthRefreshAPI)
digestSessionStore := service.NewDigestSessionStore()
+ channelRepository := repository.NewChannelRepository(db)
channelService := service.NewChannelService(channelRepository, apiKeyAuthCacheInvalidator)
modelPricingResolver := service.NewModelPricingResolver(channelService, billingService)
gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService, claudeTokenProvider, sessionLimitCache, rpmCache, digestSessionStore, settingService, tlsFingerprintProfileService, channelService, modelPricingResolver)
- openAITokenProvider := service.ProvideOpenAITokenProvider(accountRepository, geminiTokenCache, openAIOAuthService, oauthRefreshAPI)
+ openAITokenProvider := service.ProvideOpenAITokenProvider(accountRepository, geminiTokenCache, openAIOAuthService, oAuthRefreshAPI)
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService, openAITokenProvider, modelPricingResolver, channelService)
geminiMessagesCompatService := service.NewGeminiMessagesCompatService(accountRepository, groupRepository, gatewayCache, schedulerSnapshotService, geminiTokenProvider, rateLimitService, httpUpstream, antigravityGatewayService, configConfig)
opsSystemLogSink := service.ProvideOpsSystemLogSink(opsRepository)
opsService := service.NewOpsService(opsRepository, settingRepository, configConfig, accountRepository, userRepository, concurrencyService, gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, opsSystemLogSink)
- settingHandler := admin.NewSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService, paymentService)
opsHandler := admin.NewOpsHandler(opsService)
updateCache := repository.NewUpdateCache(redisClient)
gitHubReleaseClient := repository.ProvideGitHubReleaseClient(configConfig)
@@ -221,8 +209,18 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
scheduledTestService := service.ProvideScheduledTestService(scheduledTestPlanRepository, scheduledTestResultRepository)
scheduledTestHandler := admin.NewScheduledTestHandler(scheduledTestService)
channelHandler := admin.NewChannelHandler(channelService, billingService)
- adminPaymentHandler := admin.NewPaymentHandler(paymentService, paymentConfigService)
- adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, adminPaymentHandler)
+ registry := payment.ProvideRegistry()
+ encryptionKey, err := payment.ProvideEncryptionKey(configConfig)
+ if err != nil {
+ return nil, err
+ }
+ defaultLoadBalancer := payment.ProvideDefaultLoadBalancer(client, encryptionKey)
+ paymentConfigService := service.ProvidePaymentConfigService(client, settingRepository, encryptionKey)
+ settingHandler := admin.NewSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService)
+ paymentService := service.NewPaymentService(client, registry, defaultLoadBalancer, redeemService, subscriptionService, paymentConfigService, userRepository, groupRepository)
+ paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService)
+ paymentHandler := admin.NewPaymentHandler(paymentService, paymentConfigService)
+ adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, paymentHandler)
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig)
@@ -245,7 +243,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
opsAlertEvaluatorService := service.ProvideOpsAlertEvaluatorService(opsService, opsRepository, emailService, redisClient, configConfig)
opsCleanupService := service.ProvideOpsCleanupService(opsRepository, db, redisClient, configConfig)
opsScheduledReportService := service.ProvideOpsScheduledReportService(opsService, userService, emailService, redisClient, configConfig)
- tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache, privacyClientFactory, proxyRepository, oauthRefreshAPI)
+ tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache, privacyClientFactory, proxyRepository, oAuthRefreshAPI)
accountExpiryService := service.ProvideAccountExpiryService(accountRepository)
subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository)
scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig)
diff --git a/backend/internal/handler/payment_webhook_handler.go b/backend/internal/handler/payment_webhook_handler.go
index 8a83bfeb..bf404118 100644
--- a/backend/internal/handler/payment_webhook_handler.go
+++ b/backend/internal/handler/payment_webhook_handler.go
@@ -4,7 +4,6 @@ import (
"io"
"log/slog"
"net/http"
- "net/url"
"strings"
"github.com/Wei-Shaw/sub2api/internal/payment"
@@ -73,13 +72,9 @@ func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string)
rawBody = string(body)
}
- // Extract out_trade_no to look up the order's specific provider instance.
- // This is needed when multiple instances of the same provider exist (e.g. multiple EasyPay accounts).
- outTradeNo := extractOutTradeNo(rawBody, providerKey)
-
- provider, err := h.paymentService.GetWebhookProvider(c.Request.Context(), providerKey, outTradeNo)
+ provider, err := h.registry.GetProviderByKey(providerKey)
if err != nil {
- slog.Warn("[Payment Webhook] provider not found", "provider", providerKey, "outTradeNo", outTradeNo, "error", err)
+ slog.Warn("[Payment Webhook] provider not registered", "provider", providerKey, "error", err)
writeSuccessResponse(c, providerKey)
return
}
@@ -116,40 +111,19 @@ func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string)
writeSuccessResponse(c, providerKey)
}
-// extractOutTradeNo parses the webhook body to find the out_trade_no.
-// This allows looking up the correct provider instance before verification.
-func extractOutTradeNo(rawBody, providerKey string) string {
- switch providerKey {
- case payment.TypeEasyPay:
- values, err := url.ParseQuery(rawBody)
- if err == nil {
- return values.Get("out_trade_no")
- }
- }
- // For other providers (Stripe, Alipay direct, WxPay direct), the registry
- // typically has only one instance, so no instance lookup is needed.
- return ""
-}
-
// wxpaySuccessResponse is the JSON response expected by WeChat Pay webhook.
type wxpaySuccessResponse struct {
Code string `json:"code"`
Message string `json:"message"`
}
-// WeChat Pay webhook success response constants.
-const (
- wxpaySuccessCode = "SUCCESS"
- wxpaySuccessMessage = "成功"
-)
-
// writeSuccessResponse sends the provider-specific success response.
// WeChat Pay requires JSON {"code":"SUCCESS","message":"成功"};
// Stripe expects an empty 200; others accept plain text "success".
func writeSuccessResponse(c *gin.Context, providerKey string) {
switch providerKey {
case payment.TypeWxpay:
- c.JSON(http.StatusOK, wxpaySuccessResponse{Code: wxpaySuccessCode, Message: wxpaySuccessMessage})
+ c.JSON(http.StatusOK, wxpaySuccessResponse{Code: "SUCCESS", Message: "成功"})
case payment.TypeStripe:
c.String(http.StatusOK, "")
default:
diff --git a/backend/internal/service/payment_config_service.go b/backend/internal/service/payment_config_service.go
index dafe9afd..9042c3ab 100644
--- a/backend/internal/service/payment_config_service.go
+++ b/backend/internal/service/payment_config_service.go
@@ -2,16 +2,13 @@ package service
import (
"context"
- "encoding/json"
"fmt"
"strconv"
"strings"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/paymentproviderinstance"
- "github.com/Wei-Shaw/sub2api/ent/subscriptionplan"
"github.com/Wei-Shaw/sub2api/internal/payment"
- infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
)
const (
@@ -26,6 +23,8 @@ const (
SettingBalancePayDisabled = "BALANCE_PAYMENT_DISABLED"
SettingProductNamePrefix = "PRODUCT_NAME_PREFIX"
SettingProductNameSuffix = "PRODUCT_NAME_SUFFIX"
+ SettingHelpImageURL = "PAYMENT_HELP_IMAGE_URL"
+ SettingHelpText = "PAYMENT_HELP_TEXT"
SettingCancelRateLimitOn = "CANCEL_RATE_LIMIT_ENABLED"
SettingCancelRateLimitMax = "CANCEL_RATE_LIMIT_MAX"
SettingCancelWindowSize = "CANCEL_RATE_LIMIT_WINDOW"
@@ -33,91 +32,126 @@ const (
SettingCancelWindowMode = "CANCEL_RATE_LIMIT_WINDOW_MODE"
)
+// Default values for payment configuration settings.
+const (
+ defaultOrderTimeoutMin = 30
+ defaultMaxPendingOrders = 3
+)
+
// PaymentConfig holds the payment system configuration.
type PaymentConfig struct {
- Enabled bool `json:"enabled"`
- MinAmount float64 `json:"minAmount"`
- MaxAmount float64 `json:"maxAmount"`
- DailyLimit float64 `json:"dailyLimit"`
- OrderTimeoutMin int `json:"orderTimeoutMinutes"`
- MaxPendingOrders int `json:"maxPendingOrders"`
- EnabledTypes []string `json:"enabledTypes"`
- BalanceDisabled bool `json:"balanceDisabled"`
- LoadBalanceStrategy string `json:"loadBalanceStrategy"`
- ProductNamePrefix string `json:"productNamePrefix"`
- ProductNameSuffix string `json:"productNameSuffix"`
+ Enabled bool `json:"enabled"`
+ MinAmount float64 `json:"min_amount"`
+ MaxAmount float64 `json:"max_amount"`
+ DailyLimit float64 `json:"daily_limit"`
+ OrderTimeoutMin int `json:"order_timeout_minutes"`
+ MaxPendingOrders int `json:"max_pending_orders"`
+ EnabledTypes []string `json:"enabled_payment_types"`
+ BalanceDisabled bool `json:"balance_disabled"`
+ LoadBalanceStrategy string `json:"load_balance_strategy"`
+ ProductNamePrefix string `json:"product_name_prefix"`
+ ProductNameSuffix string `json:"product_name_suffix"`
+ HelpImageURL string `json:"help_image_url"`
+ HelpText string `json:"help_text"`
+ StripePublishableKey string `json:"stripe_publishable_key,omitempty"`
+
+ // Cancel rate limit settings
+ CancelRateLimitEnabled bool `json:"cancel_rate_limit_enabled"`
+ CancelRateLimitMax int `json:"cancel_rate_limit_max"`
+ CancelRateLimitWindow int `json:"cancel_rate_limit_window"`
+ CancelRateLimitUnit string `json:"cancel_rate_limit_unit"`
+ CancelRateLimitMode string `json:"cancel_rate_limit_window_mode"`
}
// UpdatePaymentConfigRequest contains fields to update payment configuration.
type UpdatePaymentConfigRequest struct {
Enabled *bool `json:"enabled"`
- MinAmount *float64 `json:"minAmount"`
- MaxAmount *float64 `json:"maxAmount"`
- DailyLimit *float64 `json:"dailyLimit"`
- OrderTimeoutMin *int `json:"orderTimeoutMinutes"`
- MaxPendingOrders *int `json:"maxPendingOrders"`
- EnabledTypes []string `json:"enabledTypes"`
- BalanceDisabled *bool `json:"balanceDisabled"`
- LoadBalanceStrategy *string `json:"loadBalanceStrategy"`
- ProductNamePrefix *string `json:"productNamePrefix"`
- ProductNameSuffix *string `json:"productNameSuffix"`
+ MinAmount *float64 `json:"min_amount"`
+ MaxAmount *float64 `json:"max_amount"`
+ DailyLimit *float64 `json:"daily_limit"`
+ OrderTimeoutMin *int `json:"order_timeout_minutes"`
+ MaxPendingOrders *int `json:"max_pending_orders"`
+ EnabledTypes []string `json:"enabled_payment_types"`
+ BalanceDisabled *bool `json:"balance_disabled"`
+ LoadBalanceStrategy *string `json:"load_balance_strategy"`
+ ProductNamePrefix *string `json:"product_name_prefix"`
+ ProductNameSuffix *string `json:"product_name_suffix"`
+ HelpImageURL *string `json:"help_image_url"`
+ HelpText *string `json:"help_text"`
+
+ // Cancel rate limit settings
+ CancelRateLimitEnabled *bool `json:"cancel_rate_limit_enabled"`
+ CancelRateLimitMax *int `json:"cancel_rate_limit_max"`
+ CancelRateLimitWindow *int `json:"cancel_rate_limit_window"`
+ CancelRateLimitUnit *string `json:"cancel_rate_limit_unit"`
+ CancelRateLimitMode *string `json:"cancel_rate_limit_window_mode"`
}
// MethodLimits holds per-payment-type limits.
type MethodLimits struct {
- PaymentType string `json:"paymentType"`
- FeeRate float64 `json:"feeRate"`
- DailyLimit float64 `json:"dailyLimit"`
- SingleMin float64 `json:"singleMin"`
- SingleMax float64 `json:"singleMax"`
+ PaymentType string `json:"payment_type"`
+ FeeRate float64 `json:"fee_rate"`
+ DailyLimit float64 `json:"daily_limit"`
+ SingleMin float64 `json:"single_min"`
+ SingleMax float64 `json:"single_max"`
+}
+
+// MethodLimitsResponse is the full response for the user-facing /limits API.
+// It includes per-method limits and the global widest range (union of all methods).
+type MethodLimitsResponse struct {
+ Methods map[string]MethodLimits `json:"methods"`
+ GlobalMin float64 `json:"global_min"` // 0 = no minimum
+ GlobalMax float64 `json:"global_max"` // 0 = no maximum
}
type CreateProviderInstanceRequest struct {
- ProviderKey string `json:"providerKey"`
+ ProviderKey string `json:"provider_key"`
Name string `json:"name"`
Config map[string]string `json:"config"`
- SupportedTypes string `json:"supportedTypes"`
+ SupportedTypes []string `json:"supported_types"`
Enabled bool `json:"enabled"`
- SortOrder int `json:"sortOrder"`
+ PaymentMode string `json:"payment_mode"`
+ SortOrder int `json:"sort_order"`
Limits string `json:"limits"`
- RefundEnabled bool `json:"refundEnabled"`
+ RefundEnabled bool `json:"refund_enabled"`
}
type UpdateProviderInstanceRequest struct {
Name *string `json:"name"`
Config map[string]string `json:"config"`
- SupportedTypes *string `json:"supportedTypes"`
+ SupportedTypes []string `json:"supported_types"`
Enabled *bool `json:"enabled"`
- SortOrder *int `json:"sortOrder"`
+ PaymentMode *string `json:"payment_mode"`
+ SortOrder *int `json:"sort_order"`
Limits *string `json:"limits"`
- RefundEnabled *bool `json:"refundEnabled"`
+ RefundEnabled *bool `json:"refund_enabled"`
}
type CreatePlanRequest struct {
- GroupID int64 `json:"groupId"`
+ GroupID int64 `json:"group_id"`
Name string `json:"name"`
Description string `json:"description"`
Price float64 `json:"price"`
- OriginalPrice *float64 `json:"originalPrice"`
- ValidityDays int `json:"validityDays"`
- ValidityUnit string `json:"validityUnit"`
+ OriginalPrice *float64 `json:"original_price"`
+ ValidityDays int `json:"validity_days"`
+ ValidityUnit string `json:"validity_unit"`
Features string `json:"features"`
- ProductName string `json:"productName"`
- ForSale bool `json:"forSale"`
- SortOrder int `json:"sortOrder"`
+ ProductName string `json:"product_name"`
+ ForSale bool `json:"for_sale"`
+ SortOrder int `json:"sort_order"`
}
type UpdatePlanRequest struct {
- GroupID *int64 `json:"groupId"`
+ GroupID *int64 `json:"group_id"`
Name *string `json:"name"`
Description *string `json:"description"`
Price *float64 `json:"price"`
- OriginalPrice *float64 `json:"originalPrice"`
- ValidityDays *int `json:"validityDays"`
- ValidityUnit *string `json:"validityUnit"`
+ OriginalPrice *float64 `json:"original_price"`
+ ValidityDays *int `json:"validity_days"`
+ ValidityUnit *string `json:"validity_unit"`
Features *string `json:"features"`
- ProductName *string `json:"productName"`
- ForSale *bool `json:"forSale"`
- SortOrder *int `json:"sortOrder"`
+ ProductName *string `json:"product_name"`
+ ForSale *bool `json:"for_sale"`
+ SortOrder *int `json:"sort_order"`
}
// PaymentConfigService manages payment configuration and CRUD for
@@ -149,29 +183,43 @@ func (s *PaymentConfigService) GetPaymentConfig(ctx context.Context) (*PaymentCo
SettingDailyRechargeLimit, SettingOrderTimeoutMinutes, SettingMaxPendingOrders,
SettingEnabledPaymentTypes, SettingBalancePayDisabled, SettingLoadBalanceStrategy,
SettingProductNamePrefix, SettingProductNameSuffix,
+ SettingHelpImageURL, SettingHelpText,
+ SettingCancelRateLimitOn, SettingCancelRateLimitMax,
+ SettingCancelWindowSize, SettingCancelWindowUnit, SettingCancelWindowMode,
}
vals, err := s.settingRepo.GetMultiple(ctx, keys)
if err != nil {
return nil, fmt.Errorf("get payment config settings: %w", err)
}
- return s.parsePaymentConfig(vals), nil
+ cfg := s.parsePaymentConfig(vals)
+ // Load Stripe publishable key from the first enabled Stripe provider instance
+ cfg.StripePublishableKey = s.getStripePublishableKey(ctx)
+ return cfg, nil
}
func (s *PaymentConfigService) parsePaymentConfig(vals map[string]string) *PaymentConfig {
cfg := &PaymentConfig{
Enabled: vals[SettingPaymentEnabled] == "true",
MinAmount: pcParseFloat(vals[SettingMinRechargeAmount], 1),
- MaxAmount: pcParseFloat(vals[SettingMaxRechargeAmount], 99999999.99),
+ MaxAmount: pcParseFloat(vals[SettingMaxRechargeAmount], 0),
DailyLimit: pcParseFloat(vals[SettingDailyRechargeLimit], 0),
- OrderTimeoutMin: pcParseInt(vals[SettingOrderTimeoutMinutes], 30),
- MaxPendingOrders: pcParseInt(vals[SettingMaxPendingOrders], 3),
+ OrderTimeoutMin: pcParseInt(vals[SettingOrderTimeoutMinutes], defaultOrderTimeoutMin),
+ MaxPendingOrders: pcParseInt(vals[SettingMaxPendingOrders], defaultMaxPendingOrders),
BalanceDisabled: vals[SettingBalancePayDisabled] == "true",
LoadBalanceStrategy: vals[SettingLoadBalanceStrategy],
ProductNamePrefix: vals[SettingProductNamePrefix],
ProductNameSuffix: vals[SettingProductNameSuffix],
+ HelpImageURL: vals[SettingHelpImageURL],
+ HelpText: vals[SettingHelpText],
+
+ CancelRateLimitEnabled: vals[SettingCancelRateLimitOn] == "true",
+ CancelRateLimitMax: pcParseInt(vals[SettingCancelRateLimitMax], 10),
+ CancelRateLimitWindow: pcParseInt(vals[SettingCancelWindowSize], 1),
+ CancelRateLimitUnit: vals[SettingCancelWindowUnit],
+ CancelRateLimitMode: vals[SettingCancelWindowMode],
}
if cfg.LoadBalanceStrategy == "" {
- cfg.LoadBalanceStrategy = "round-robin"
+ cfg.LoadBalanceStrategy = payment.DefaultLoadBalanceStrategy
}
if raw := vals[SettingEnabledPaymentTypes]; raw != "" {
for _, t := range strings.Split(raw, ",") {
@@ -184,242 +232,100 @@ func (s *PaymentConfigService) parsePaymentConfig(vals map[string]string) *Payme
return cfg
}
+// getStripePublishableKey finds the publishable key from the first enabled Stripe provider instance.
+func (s *PaymentConfigService) getStripePublishableKey(ctx context.Context) string {
+ instances, err := s.entClient.PaymentProviderInstance.Query().
+ Where(
+ paymentproviderinstance.EnabledEQ(true),
+ paymentproviderinstance.ProviderKeyEQ(payment.TypeStripe),
+ ).Limit(1).All(ctx)
+ if err != nil || len(instances) == 0 {
+ return ""
+ }
+ cfg, err := s.decryptConfig(instances[0].Config)
+ if err != nil || cfg == nil {
+ return ""
+ }
+ return cfg[payment.ConfigKeyPublishableKey]
+}
+
// UpdatePaymentConfig updates the payment configuration settings.
+// NOTE: This function exceeds 30 lines because each field requires an independent
+// nil-check before serialisation — this is inherent to patch-style update patterns
+// and cannot be meaningfully decomposed without introducing unnecessary abstraction.
func (s *PaymentConfigService) UpdatePaymentConfig(ctx context.Context, req UpdatePaymentConfigRequest) error {
- m := make(map[string]string)
- if req.Enabled != nil {
- m[SettingPaymentEnabled] = strconv.FormatBool(*req.Enabled)
- }
- if req.MinAmount != nil {
- m[SettingMinRechargeAmount] = strconv.FormatFloat(*req.MinAmount, 'f', 2, 64)
- }
- if req.MaxAmount != nil {
- m[SettingMaxRechargeAmount] = strconv.FormatFloat(*req.MaxAmount, 'f', 2, 64)
- }
- if req.DailyLimit != nil {
- m[SettingDailyRechargeLimit] = strconv.FormatFloat(*req.DailyLimit, 'f', 2, 64)
- }
- if req.OrderTimeoutMin != nil {
- m[SettingOrderTimeoutMinutes] = strconv.Itoa(*req.OrderTimeoutMin)
- }
- if req.MaxPendingOrders != nil {
- m[SettingMaxPendingOrders] = strconv.Itoa(*req.MaxPendingOrders)
+ m := map[string]string{
+ SettingPaymentEnabled: formatBoolOrEmpty(req.Enabled),
+ SettingMinRechargeAmount: formatPositiveFloat(req.MinAmount),
+ SettingMaxRechargeAmount: formatPositiveFloat(req.MaxAmount),
+ SettingDailyRechargeLimit: formatPositiveFloat(req.DailyLimit),
+ SettingOrderTimeoutMinutes: formatPositiveInt(req.OrderTimeoutMin),
+ SettingMaxPendingOrders: formatPositiveInt(req.MaxPendingOrders),
+ SettingBalancePayDisabled: formatBoolOrEmpty(req.BalanceDisabled),
+ SettingLoadBalanceStrategy: derefStr(req.LoadBalanceStrategy),
+ SettingProductNamePrefix: derefStr(req.ProductNamePrefix),
+ SettingProductNameSuffix: derefStr(req.ProductNameSuffix),
+ SettingHelpImageURL: derefStr(req.HelpImageURL),
+ SettingHelpText: derefStr(req.HelpText),
+ SettingCancelRateLimitOn: formatBoolOrEmpty(req.CancelRateLimitEnabled),
+ SettingCancelRateLimitMax: formatPositiveInt(req.CancelRateLimitMax),
+ SettingCancelWindowSize: formatPositiveInt(req.CancelRateLimitWindow),
+ SettingCancelWindowUnit: derefStr(req.CancelRateLimitUnit),
+ SettingCancelWindowMode: derefStr(req.CancelRateLimitMode),
}
if req.EnabledTypes != nil {
m[SettingEnabledPaymentTypes] = strings.Join(req.EnabledTypes, ",")
- }
- if req.BalanceDisabled != nil {
- m[SettingBalancePayDisabled] = strconv.FormatBool(*req.BalanceDisabled)
- }
- if req.LoadBalanceStrategy != nil {
- m[SettingLoadBalanceStrategy] = *req.LoadBalanceStrategy
- }
- if req.ProductNamePrefix != nil {
- m[SettingProductNamePrefix] = *req.ProductNamePrefix
- }
- if req.ProductNameSuffix != nil {
- m[SettingProductNameSuffix] = *req.ProductNameSuffix
- }
- if len(m) == 0 {
- return nil
+ } else {
+ m[SettingEnabledPaymentTypes] = ""
}
return s.settingRepo.SetMultiple(ctx, m)
}
-// --- Provider Instance CRUD ---
-
-func (s *PaymentConfigService) ListProviderInstances(ctx context.Context) ([]*dbent.PaymentProviderInstance, error) {
- return s.entClient.PaymentProviderInstance.Query().Order(paymentproviderinstance.BySortOrder()).All(ctx)
+func formatBoolOrEmpty(v *bool) string {
+ if v == nil {
+ return ""
+ }
+ return strconv.FormatBool(*v)
}
-func (s *PaymentConfigService) CreateProviderInstance(ctx context.Context, req CreateProviderInstanceRequest) (*dbent.PaymentProviderInstance, error) {
- enc, err := s.encryptConfig(req.Config)
- if err != nil {
- return nil, err
+func formatPositiveFloat(v *float64) string {
+ if v == nil || *v <= 0 {
+ return "" // empty → parsePaymentConfig uses default
}
- return s.entClient.PaymentProviderInstance.Create().
- SetProviderKey(req.ProviderKey).SetName(req.Name).SetConfig(enc).
- SetSupportedTypes(req.SupportedTypes).SetEnabled(req.Enabled).
- SetSortOrder(req.SortOrder).SetLimits(req.Limits).SetRefundEnabled(req.RefundEnabled).
- Save(ctx)
+ return strconv.FormatFloat(*v, 'f', 2, 64)
}
-func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id int64, req UpdateProviderInstanceRequest) (*dbent.PaymentProviderInstance, error) {
- u := s.entClient.PaymentProviderInstance.UpdateOneID(id)
- if req.Name != nil {
- u.SetName(*req.Name)
+func formatPositiveInt(v *int) string {
+ if v == nil || *v <= 0 {
+ return ""
}
- if req.Config != nil {
- enc, err := s.encryptConfig(req.Config)
- if err != nil {
- return nil, err
- }
- u.SetConfig(enc)
- }
- if req.SupportedTypes != nil {
- u.SetSupportedTypes(*req.SupportedTypes)
- }
- if req.Enabled != nil {
- u.SetEnabled(*req.Enabled)
- }
- if req.SortOrder != nil {
- u.SetSortOrder(*req.SortOrder)
- }
- if req.Limits != nil {
- u.SetLimits(*req.Limits)
- }
- if req.RefundEnabled != nil {
- u.SetRefundEnabled(*req.RefundEnabled)
- }
- return u.Save(ctx)
+ return strconv.Itoa(*v)
}
-func (s *PaymentConfigService) DeleteProviderInstance(ctx context.Context, id int64) error {
- return s.entClient.PaymentProviderInstance.DeleteOneID(id).Exec(ctx)
+func derefStr(v *string) string {
+ if v == nil {
+ return ""
+ }
+ return *v
}
-func (s *PaymentConfigService) encryptConfig(cfg map[string]string) (string, error) {
- data, err := json.Marshal(cfg)
- if err != nil {
- return "", fmt.Errorf("marshal config: %w", err)
+func splitTypes(s string) []string {
+ if s == "" {
+ return nil
}
- enc, err := payment.Encrypt(string(data), s.encryptionKey)
- if err != nil {
- return "", fmt.Errorf("encrypt config: %w", err)
- }
- return enc, nil
-}
-
-// --- Channel CRUD ---
-
-
-// --- Plan CRUD ---
-
-func (s *PaymentConfigService) ListPlans(ctx context.Context) ([]*dbent.SubscriptionPlan, error) {
- return s.entClient.SubscriptionPlan.Query().Order(subscriptionplan.BySortOrder()).All(ctx)
-}
-
-func (s *PaymentConfigService) ListPlansForSale(ctx context.Context) ([]*dbent.SubscriptionPlan, error) {
- return s.entClient.SubscriptionPlan.Query().Where(subscriptionplan.ForSaleEQ(true)).Order(subscriptionplan.BySortOrder()).All(ctx)
-}
-
-func (s *PaymentConfigService) CreatePlan(ctx context.Context, req CreatePlanRequest) (*dbent.SubscriptionPlan, error) {
- b := s.entClient.SubscriptionPlan.Create().
- SetGroupID(req.GroupID).SetName(req.Name).SetDescription(req.Description).
- SetPrice(req.Price).SetValidityDays(req.ValidityDays).SetValidityUnit(req.ValidityUnit).
- SetFeatures(req.Features).SetProductName(req.ProductName).
- SetForSale(req.ForSale).SetSortOrder(req.SortOrder)
- if req.OriginalPrice != nil {
- b.SetOriginalPrice(*req.OriginalPrice)
- }
- return b.Save(ctx)
-}
-
-func (s *PaymentConfigService) UpdatePlan(ctx context.Context, id int64, req UpdatePlanRequest) (*dbent.SubscriptionPlan, error) {
- u := s.entClient.SubscriptionPlan.UpdateOneID(id)
- if req.GroupID != nil {
- u.SetGroupID(*req.GroupID)
- }
- if req.Name != nil {
- u.SetName(*req.Name)
- }
- if req.Description != nil {
- u.SetDescription(*req.Description)
- }
- if req.Price != nil {
- u.SetPrice(*req.Price)
- }
- if req.OriginalPrice != nil {
- u.SetOriginalPrice(*req.OriginalPrice)
- }
- if req.ValidityDays != nil {
- u.SetValidityDays(*req.ValidityDays)
- }
- if req.ValidityUnit != nil {
- u.SetValidityUnit(*req.ValidityUnit)
- }
- if req.Features != nil {
- u.SetFeatures(*req.Features)
- }
- if req.ProductName != nil {
- u.SetProductName(*req.ProductName)
- }
- if req.ForSale != nil {
- u.SetForSale(*req.ForSale)
- }
- if req.SortOrder != nil {
- u.SetSortOrder(*req.SortOrder)
- }
- return u.Save(ctx)
-}
-
-func (s *PaymentConfigService) DeletePlan(ctx context.Context, id int64) error {
- return s.entClient.SubscriptionPlan.DeleteOneID(id).Exec(ctx)
-}
-
-// GetPlan returns a subscription plan by ID.
-func (s *PaymentConfigService) GetPlan(ctx context.Context, id int64) (*dbent.SubscriptionPlan, error) {
- plan, err := s.entClient.SubscriptionPlan.Get(ctx, id)
- if err != nil {
- return nil, infraerrors.NotFound("PLAN_NOT_FOUND", "subscription plan not found")
- }
- return plan, nil
-}
-
-// GetMethodLimits returns per-payment-type limits from enabled provider instances.
-func (s *PaymentConfigService) GetMethodLimits(ctx context.Context, types []string) ([]MethodLimits, error) {
- instances, err := s.entClient.PaymentProviderInstance.Query().
- Where(paymentproviderinstance.EnabledEQ(true)).All(ctx)
- if err != nil {
- return nil, fmt.Errorf("query provider instances: %w", err)
- }
- result := make([]MethodLimits, 0, len(types))
- for _, pt := range types {
- ml := MethodLimits{PaymentType: pt}
- for _, inst := range instances {
- if !pcInstanceSupportsType(inst, pt) {
- continue
- }
- pcApplyInstanceLimits(inst, pt, &ml)
- }
- result = append(result, ml)
- }
- return result, nil
-}
-
-func pcInstanceSupportsType(inst *dbent.PaymentProviderInstance, pt string) bool {
- if inst.SupportedTypes == "" {
- return true
- }
- for _, t := range strings.Split(inst.SupportedTypes, ",") {
- if strings.TrimSpace(t) == pt {
- return true
+ parts := strings.Split(s, ",")
+ result := make([]string, 0, len(parts))
+ for _, p := range parts {
+ p = strings.TrimSpace(p)
+ if p != "" {
+ result = append(result, p)
}
}
- return false
+ return result
}
-func pcApplyInstanceLimits(inst *dbent.PaymentProviderInstance, pt string, ml *MethodLimits) {
- if inst.Limits == "" {
- return
- }
- var limits payment.InstanceLimits
- if err := json.Unmarshal([]byte(inst.Limits), &limits); err != nil {
- return
- }
- cl, ok := limits[pt]
- if !ok {
- return
- }
- if cl.DailyLimit > 0 && (ml.DailyLimit == 0 || cl.DailyLimit < ml.DailyLimit) {
- ml.DailyLimit = cl.DailyLimit
- }
- if cl.SingleMin > 0 && (ml.SingleMin == 0 || cl.SingleMin > ml.SingleMin) {
- ml.SingleMin = cl.SingleMin
- }
- if cl.SingleMax > 0 && (ml.SingleMax == 0 || cl.SingleMax < ml.SingleMax) {
- ml.SingleMax = cl.SingleMax
- }
+func joinTypes(types []string) string {
+ return strings.Join(types, ",")
}
func pcParseFloat(s string, defaultVal float64) float64 {
diff --git a/backend/internal/service/payment_fulfillment.go b/backend/internal/service/payment_fulfillment.go
index de41d742..db92ff2b 100644
--- a/backend/internal/service/payment_fulfillment.go
+++ b/backend/internal/service/payment_fulfillment.go
@@ -5,12 +5,9 @@ import (
"fmt"
"log/slog"
"math"
- "strconv"
- "strings"
"time"
dbent "github.com/Wei-Shaw/sub2api/ent"
- "github.com/Wei-Shaw/sub2api/ent/paymentauditlog"
"github.com/Wei-Shaw/sub2api/ent/paymentorder"
"github.com/Wei-Shaw/sub2api/internal/payment"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
@@ -19,20 +16,14 @@ import (
// --- Payment Notification & Fulfillment ---
func (s *PaymentService) HandlePaymentNotification(ctx context.Context, n *payment.PaymentNotification, pk string) error {
- if n.Status != payment.NotificationStatusSuccess {
+ if n.Status != "success" {
return nil
}
- // Look up order by out_trade_no (the external order ID we sent to the provider)
- order, err := s.entClient.PaymentOrder.Query().Where(paymentorder.OutTradeNo(n.OrderID)).Only(ctx)
+ oid, err := parseOrderID(n.OrderID)
if err != nil {
- // Fallback: try legacy format (sub2_N where N is DB ID)
- trimmed := strings.TrimPrefix(n.OrderID, orderIDPrefix)
- if oid, parseErr := strconv.ParseInt(trimmed, 10, 64); parseErr == nil {
- return s.confirmPayment(ctx, oid, n.TradeNo, n.Amount, pk)
- }
- return fmt.Errorf("order not found for out_trade_no: %s", n.OrderID)
+ return fmt.Errorf("invalid order ID: %s", n.OrderID)
}
- return s.confirmPayment(ctx, order.ID, n.TradeNo, n.Amount, pk)
+ return s.confirmPayment(ctx, oid, n.TradeNo, n.Amount, pk)
}
func (s *PaymentService) confirmPayment(ctx context.Context, oid int64, tradeNo string, paid float64, pk string) error {
@@ -41,17 +32,9 @@ func (s *PaymentService) confirmPayment(ctx context.Context, oid int64, tradeNo
slog.Error("order not found", "orderID", oid)
return nil
}
- // Skip amount check when paid=0 (e.g. QueryOrder doesn't return amount).
- // Also skip if paid is NaN/Inf (malformed provider data).
- if paid > 0 && !math.IsNaN(paid) && !math.IsInf(paid, 0) {
- if math.Abs(paid-o.PayAmount) > amountToleranceCNY {
- s.writeAuditLog(ctx, o.ID, "PAYMENT_AMOUNT_MISMATCH", pk, map[string]any{"expected": o.PayAmount, "paid": paid, "tradeNo": tradeNo})
- return fmt.Errorf("amount mismatch: expected %.2f, got %.2f", o.PayAmount, paid)
- }
- }
- // Use order's expected amount when provider didn't report one
- if paid <= 0 || math.IsNaN(paid) || math.IsInf(paid, 0) {
- paid = o.PayAmount
+ if math.Abs(paid-o.PayAmount) > amountToleranceCNY {
+ s.writeAuditLog(ctx, o.ID, "PAYMENT_AMOUNT_MISMATCH", pk, map[string]any{"expected": o.PayAmount, "paid": paid, "tradeNo": tradeNo})
+ return fmt.Errorf("amount mismatch: expected %.2f, got %.2f", o.PayAmount, paid)
}
return s.toPaid(ctx, o, tradeNo, paid, pk)
}
@@ -129,7 +112,7 @@ func (s *PaymentService) executeFulfillment(ctx context.Context, oid int64) erro
if err != nil {
return fmt.Errorf("get order: %w", err)
}
- if o.OrderType == payment.OrderTypeSubscription {
+ if o.OrderType == "subscription" {
return s.ExecuteSubscriptionFulfillment(ctx, oid)
}
return s.ExecuteBalanceFulfillment(ctx, oid)
@@ -163,46 +146,20 @@ func (s *PaymentService) ExecuteBalanceFulfillment(ctx context.Context, oid int6
return nil
}
-// redeemAction represents the idempotency decision for balance fulfillment.
-type redeemAction int
-
-const (
- // redeemActionCreate: code does not exist — create it, then redeem.
- redeemActionCreate redeemAction = iota
- // redeemActionRedeem: code exists but is unused — skip creation, redeem only.
- redeemActionRedeem
- // redeemActionSkipCompleted: code exists and is already used — skip to mark completed.
- redeemActionSkipCompleted
-)
-
-// resolveRedeemAction decides the idempotency action based on an existing redeem code lookup.
-// existing is the result of GetByCode; lookupErr is the error from that call.
-func resolveRedeemAction(existing *RedeemCode, lookupErr error) redeemAction {
- if existing == nil || lookupErr != nil {
- return redeemActionCreate
- }
- if existing.IsUsed() {
- return redeemActionSkipCompleted
- }
- return redeemActionRedeem
-}
-
func (s *PaymentService) doBalance(ctx context.Context, o *dbent.PaymentOrder) error {
// Idempotency: check if redeem code already exists (from a previous partial run)
- existing, lookupErr := s.redeemService.GetByCode(ctx, o.RechargeCode)
- action := resolveRedeemAction(existing, lookupErr)
-
- switch action {
- case redeemActionSkipCompleted:
- // Code already created and redeemed — just mark completed
- return s.markCompleted(ctx, o, "RECHARGE_SUCCESS")
- case redeemActionCreate:
+ existing, _ := s.redeemService.GetByCode(ctx, o.RechargeCode)
+ if existing != nil {
+ if existing.IsUsed() {
+ // Code already created and redeemed — just mark completed
+ return s.markCompleted(ctx, o, "RECHARGE_SUCCESS")
+ }
+ // Code exists but unused — skip creation, proceed to redeem
+ } else {
rc := &RedeemCode{Code: o.RechargeCode, Type: RedeemTypeBalance, Value: o.Amount, Status: StatusUnused}
if err := s.redeemService.CreateCode(ctx, rc); err != nil {
return fmt.Errorf("create redeem code: %w", err)
}
- case redeemActionRedeem:
- // Code exists but unused — skip creation, proceed to redeem
}
if _, err := s.redeemService.Redeem(ctx, o.UserID, o.RechargeCode); err != nil {
return fmt.Errorf("redeem balance: %w", err)
@@ -255,45 +212,30 @@ func (s *PaymentService) doSub(ctx context.Context, o *dbent.PaymentOrder) error
gid := *o.SubscriptionGroupID
days := *o.SubscriptionDays
g, err := s.groupRepo.GetByID(ctx, gid)
- if err != nil || g.Status != payment.EntityStatusActive {
+ if err != nil || g.Status != "active" {
return fmt.Errorf("group %d no longer exists or inactive", gid)
}
- // Idempotency: check audit log to see if subscription was already assigned.
- // Prevents double-extension on retry after markCompleted fails.
- if s.hasAuditLog(ctx, o.ID, "SUBSCRIPTION_SUCCESS") {
- slog.Info("subscription already assigned for order, skipping", "orderID", o.ID, "groupID", gid)
- return s.markCompleted(ctx, o, "SUBSCRIPTION_SUCCESS")
- }
- orderNote := fmt.Sprintf("payment order %d", o.ID)
- _, _, err = s.subscriptionSvc.AssignOrExtendSubscription(ctx, &AssignSubscriptionInput{UserID: o.UserID, GroupID: gid, ValidityDays: days, AssignedBy: 0, Notes: orderNote})
+ _, _, err = s.subscriptionSvc.AssignOrExtendSubscription(ctx, &AssignSubscriptionInput{UserID: o.UserID, GroupID: gid, ValidityDays: days, AssignedBy: 0, Notes: fmt.Sprintf("payment order %d", o.ID)})
if err != nil {
return fmt.Errorf("assign subscription: %w", err)
}
- return s.markCompleted(ctx, o, "SUBSCRIPTION_SUCCESS")
-}
-
-func (s *PaymentService) hasAuditLog(ctx context.Context, orderID int64, action string) bool {
- oid := strconv.FormatInt(orderID, 10)
- c, _ := s.entClient.PaymentAuditLog.Query().
- Where(paymentauditlog.OrderIDEQ(oid), paymentauditlog.ActionEQ(action)).
- Limit(1).Count(ctx)
- return c > 0
+ now := time.Now()
+ _, err = s.entClient.PaymentOrder.Update().Where(paymentorder.IDEQ(o.ID), paymentorder.StatusEQ(OrderStatusRecharging)).SetStatus(OrderStatusCompleted).SetCompletedAt(now).Save(ctx)
+ if err != nil {
+ return fmt.Errorf("mark completed: %w", err)
+ }
+ s.writeAuditLog(ctx, o.ID, "SUBSCRIPTION_SUCCESS", "system", map[string]any{"groupId": gid, "days": days, "amount": o.Amount})
+ return nil
}
func (s *PaymentService) markFailed(ctx context.Context, oid int64, cause error) {
now := time.Now()
r := psErrMsg(cause)
- // Only mark FAILED if still in RECHARGING state — prevents overwriting
- // a COMPLETED order when markCompleted failed but fulfillment succeeded.
- c, e := s.entClient.PaymentOrder.Update().
- Where(paymentorder.IDEQ(oid), paymentorder.StatusEQ(OrderStatusRecharging)).
- SetStatus(OrderStatusFailed).SetFailedAt(now).SetFailedReason(r).Save(ctx)
+ _, e := s.entClient.PaymentOrder.UpdateOneID(oid).SetStatus(OrderStatusFailed).SetFailedAt(now).SetFailedReason(r).Save(ctx)
if e != nil {
slog.Error("mark FAILED", "orderID", oid, "error", e)
}
- if c > 0 {
- s.writeAuditLog(ctx, oid, "FULFILLMENT_FAILED", "system", map[string]any{"reason": r})
- }
+ s.writeAuditLog(ctx, oid, "FULFILLMENT_FAILED", "system", map[string]any{"reason": r})
}
func (s *PaymentService) RetryFulfillment(ctx context.Context, oid int64) error {
diff --git a/frontend/src/views/admin/orders/AdminPaymentDashboardView.vue b/frontend/src/views/admin/orders/AdminPaymentDashboardView.vue
index 7320037d..06bc9218 100644
--- a/frontend/src/views/admin/orders/AdminPaymentDashboardView.vue
+++ b/frontend/src/views/admin/orders/AdminPaymentDashboardView.vue
@@ -42,7 +42,7 @@
{{ t('payment.methods.' + method.type, method.type) }}