Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fa08aa930 | ||
|
|
27cb485d55 | ||
|
|
22ec77b570 | ||
|
|
b197ba61ce | ||
|
|
a5a2fea045 | ||
|
|
0dc6e56aae | ||
|
|
bf76168ba5 | ||
|
|
2854ab2ace | ||
|
|
e95c909ee5 | ||
|
|
ba1bb0a3d3 | ||
|
|
11fd61458c | ||
|
|
d56e94b875 | ||
|
|
d089c5789c | ||
|
|
9fac6f15ce | ||
|
|
0fd2e9216d | ||
|
|
759332aa92 | ||
|
|
b408edf97b | ||
|
|
f0f1f59a44 | ||
|
|
ac68d73f0c | ||
|
|
2e70ec0a15 | ||
|
|
de15f29d1f | ||
|
|
809e1cf8ee | ||
|
|
c9dda3c01f | ||
|
|
6752cdc826 | ||
|
|
ee90246aa5 | ||
|
|
2d2b6f0325 | ||
|
|
f26ca5661e | ||
|
|
aee9a7ba98 | ||
|
|
41cdd438d7 | ||
|
|
b23475ac0c | ||
|
|
e76e0499d7 | ||
|
|
498f010ec3 | ||
|
|
20008264fe | ||
|
|
b650bdd68d | ||
|
|
41def4ba03 | ||
|
|
707b87a96f | ||
|
|
cbe0c4665f | ||
|
|
dec709fb3a | ||
|
|
4dd3aee5c2 | ||
|
|
6bd248fd1f | ||
|
|
be297b90ce | ||
|
|
dfdb838ce4 | ||
|
|
be3aee1a4b | ||
|
|
588e02b29e | ||
|
|
659086861d | ||
|
|
0c53d4f334 | ||
|
|
8e07ec55db | ||
|
|
129d05258a | ||
|
|
46ef146225 | ||
|
|
35df02aeac | ||
|
|
b3f796972e | ||
|
|
e5dc1f5972 | ||
|
|
df2cedeead | ||
|
|
ebbdc70311 | ||
|
|
f385cdceb7 | ||
|
|
2649573b99 | ||
|
|
a1b2b32e08 | ||
|
|
f3a3a0869d | ||
|
|
b2687b2258 | ||
|
|
28c2d20d9f | ||
|
|
c620ad6a36 | ||
|
|
d0a1443a41 | ||
|
|
2b462b07de | ||
|
|
87dfc66132 | ||
|
|
5d7f213cbf | ||
|
|
36d5f4e4ca | ||
|
|
a5638a4e54 | ||
|
|
72ccd1b111 | ||
|
|
3f2ef60468 | ||
|
|
372436323a | ||
|
|
0b8e5eec32 | ||
|
|
9caa3c9c5e | ||
|
|
3020652fa1 | ||
|
|
537bd50469 | ||
|
|
6bc6998b0e | ||
|
|
4e23c77ece | ||
|
|
821399ade1 | ||
|
|
441b8ec2e2 | ||
|
|
fafdd8342f | ||
|
|
50dff6762c | ||
|
|
e63492090a | ||
|
|
51b5142af7 | ||
|
|
634b0eee48 | ||
|
|
fd66ae1ecb | ||
|
|
a32196eedd | ||
|
|
3302146759 | ||
|
|
41af8e8e11 | ||
|
|
11a3da65c0 | ||
|
|
99a8d8ad28 | ||
|
|
5436d0bf10 | ||
|
|
a632cb0050 | ||
|
|
df59b8b966 | ||
|
|
d0b8760eb9 | ||
|
|
5fe4ee1da1 | ||
|
|
dfb36e45f1 | ||
|
|
c4128580f2 | ||
|
|
c797159bf5 | ||
|
|
7869b7fe38 | ||
|
|
a5781fe31d | ||
|
|
b26dcc3da2 | ||
|
|
0ff93aca7a | ||
|
|
9934bd2572 | ||
|
|
0435417f43 | ||
|
|
7dc7cfce1d | ||
|
|
98be187d56 | ||
|
|
e236bff1ed | ||
|
|
45be32b254 | ||
|
|
60da9ba178 | ||
|
|
be26e6ff2c | ||
|
|
f5b2961270 | ||
|
|
a0eaf12a01 | ||
|
|
5eb9da9c93 | ||
|
|
5e99561d6e | ||
|
|
1034f576d7 | ||
|
|
71c16b7230 | ||
|
|
aac3261c69 | ||
|
|
f77cf6b477 | ||
|
|
8c2d9b9a12 | ||
|
|
c9fb221a31 | ||
|
|
0a9146c3d1 | ||
|
|
42e471f59a | ||
|
|
a34d4967e6 | ||
|
|
d679ee3c29 | ||
|
|
ebd83dc0f9 | ||
|
|
fd004bdd88 | ||
|
|
e998f8044b | ||
|
|
de64b02612 | ||
|
|
3812e627a8 | ||
|
|
8b46994dc2 | ||
|
|
9817d5d5bf | ||
|
|
5e9529bb88 | ||
|
|
96c9166b0e | ||
|
|
bdf7ead157 | ||
|
|
e28b11ad75 | ||
|
|
c3e860607d | ||
|
|
2fe756e4be | ||
|
|
3b5d812f7a | ||
|
|
5f62625ae6 | ||
|
|
605b6f6632 | ||
|
|
db0414233c | ||
|
|
e2d21c35af | ||
|
|
59e9356c51 | ||
|
|
c1335bae12 | ||
|
|
b28a223337 | ||
|
|
03727ac363 | ||
|
|
1158cf31bc | ||
|
|
41bb15d302 | ||
|
|
b4f38b092f | ||
|
|
73de2ea7f0 | ||
|
|
6e54776509 | ||
|
|
dc1bc15457 | ||
|
|
7cb98e5bdc | ||
|
|
930326116e | ||
|
|
cafc95c3e2 | ||
|
|
89b2d63ef2 | ||
|
|
915c60b150 | ||
|
|
345d5c6b56 | ||
|
|
6c46c2cb7d | ||
|
|
76e0d90736 | ||
|
|
56ca027a0f | ||
|
|
bf4f006736 | ||
|
|
185f9c9920 | ||
|
|
dcd8689d78 | ||
|
|
efb8b45876 | ||
|
|
ac6d4ccf09 | ||
|
|
ac6f2e9693 | ||
|
|
906be3f742 | ||
|
|
5654321380 | ||
|
|
0476b5c97a | ||
|
|
e70e36e4d2 | ||
|
|
901958ba1b | ||
|
|
93a3bf3077 | ||
|
|
7316d83027 | ||
|
|
55902d8548 | ||
|
|
260fda19b3 | ||
|
|
a0a3d0c33e | ||
|
|
438510d298 | ||
|
|
10e623f674 | ||
|
|
4a7148e203 | ||
|
|
d3acd8e96e | ||
|
|
7cbf82ed65 | ||
|
|
709cf61853 | ||
|
|
d86e83259e | ||
|
|
c99112a9e9 | ||
|
|
a22b12677f | ||
|
|
b0dbc22fe5 | ||
|
|
7c857bd080 | ||
|
|
bebafe7697 | ||
|
|
fc1e5a94a9 | ||
|
|
61f735a667 | ||
|
|
b105cc0fd5 | ||
|
|
38577c6ca0 | ||
|
|
47598462f5 | ||
|
|
753c5e255c | ||
|
|
3b18d1fa3d | ||
|
|
a822972d80 | ||
|
|
82553c4dca | ||
|
|
da810c3b43 | ||
|
|
b1403e8b29 | ||
|
|
b244f850e7 | ||
|
|
bad87ff533 | ||
|
|
88ca0c1d13 | ||
|
|
e5f7836bf3 | ||
|
|
7a38c66214 | ||
|
|
c275422251 | ||
|
|
9a0fbcc87d | ||
|
|
df99b9417c | ||
|
|
819fda34d9 | ||
|
|
f93a6c50ce | ||
|
|
a8cfafa02c | ||
|
|
d57106a56f | ||
|
|
2fc4fef847 | ||
|
|
fcd3bc1272 | ||
|
|
98feeccbe1 | ||
|
|
44f502bab8 | ||
|
|
ac6e36f96b | ||
|
|
40c8252734 | ||
|
|
5f022663ac | ||
|
|
8a7269f539 | ||
|
|
683a8d8096 | ||
|
|
54ef446c1d | ||
|
|
815bc6c9b5 | ||
|
|
720db8983f | ||
|
|
2a80495880 | ||
|
|
8a87a658ad | ||
|
|
939905b8c7 | ||
|
|
0d28642181 | ||
|
|
1b9645ca32 | ||
|
|
e7a4f3f465 | ||
|
|
f29ccc7dfb | ||
|
|
b2e2c7e69c | ||
|
|
b3a07aeae7 | ||
|
|
39be1ec97f | ||
|
|
fd9c945fab | ||
|
|
94c059c99e | ||
|
|
1721b2b2d1 | ||
|
|
32ddd5c60b | ||
|
|
d53dd112ce | ||
|
|
36a352f5eb | ||
|
|
9983f5b80a | ||
|
|
0bff6f9ac5 | ||
|
|
6db9e42352 | ||
|
|
fd0da2570d | ||
|
|
50c3e52799 | ||
|
|
db1813f7a3 | ||
|
|
6d50934de6 | ||
|
|
27278f62ed | ||
|
|
d65b4c90ad | ||
|
|
e89f4b43c0 | ||
|
|
38355ed204 | ||
|
|
03f6a28096 | ||
|
|
c9f42e1f77 | ||
|
|
2a58a57a7c | ||
|
|
29122e3051 | ||
|
|
65ad7df4f4 | ||
|
|
5bd9368ab4 | ||
|
|
cc7612bdbd | ||
|
|
ce6af41357 | ||
|
|
0112782049 | ||
|
|
650c50e34b | ||
|
|
55242ffac1 | ||
|
|
2b49d662cc | ||
|
|
0a97a5f461 | ||
|
|
00d68ff6df | ||
|
|
dbdbfb1122 | ||
|
|
4567f6582b | ||
|
|
063454ae96 | ||
|
|
a1560ae77c | ||
|
|
30adee43bc | ||
|
|
a2cf297d90 | ||
|
|
65fa728921 | ||
|
|
82576e0a38 | ||
|
|
9707dedca2 | ||
|
|
ae5e980dd1 | ||
|
|
28e7adef09 | ||
|
|
9491de0a39 | ||
|
|
147c1879d9 | ||
|
|
32df33a1c3 | ||
|
|
85a3b12254 | ||
|
|
c6f375d3ab | ||
|
|
9f5b57fc96 | ||
|
|
e5f38a6f60 | ||
|
|
d430343f51 | ||
|
|
6239e395b8 | ||
|
|
7c2fee6c99 | ||
|
|
f4b51b0f2b | ||
|
|
df51edfb06 | ||
|
|
6bf6722bae | ||
|
|
0da1fe28ef |
@@ -119,6 +119,8 @@ tests
|
||||
CLAUDE.md
|
||||
.claude
|
||||
scripts
|
||||
!backend/scripts/
|
||||
!backend/scripts/resolve-version.sh
|
||||
.code-review-state
|
||||
#openspec/
|
||||
code-reviews/
|
||||
|
||||
@@ -67,9 +67,9 @@ COPY backend/ ./
|
||||
COPY --from=frontend-builder /app/backend/internal/web/dist ./internal/web/dist
|
||||
|
||||
# Build the binary (BuildType=release for CI builds, embed frontend)
|
||||
# Version precedence: build arg VERSION > cmd/server/VERSION
|
||||
# Version precedence: build arg VERSION > exact git tag > cmd/server/VERSION
|
||||
RUN VERSION_VALUE="${VERSION}" && \
|
||||
if [ -z "${VERSION_VALUE}" ]; then VERSION_VALUE="$(tr -d '\r\n' < ./cmd/server/VERSION)"; fi && \
|
||||
if [ -z "${VERSION_VALUE}" ]; then VERSION_VALUE="$(./scripts/resolve-version.sh)"; fi && \
|
||||
DATE_VALUE="${DATE:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}" && \
|
||||
CGO_ENABLED=0 GOOS=linux go build \
|
||||
-tags embed \
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
**AI API Gateway Platform for Subscription Quota Distribution**
|
||||
|
||||
English | [日本語](README_JA.md)
|
||||
English | [中文](README_CN.md) | [日本語](README_JA.md)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -23,26 +23,11 @@ Please read the following carefully before using this project:
|
||||
- **🚨 Terms of Service Risk**: Using this project may violate the terms of service of Anthropic and other upstream providers. Please review the relevant providers' user agreements before use; all risks arising from such use are borne solely by the user.
|
||||
- **⚖️ Compliant Use**: Use this project only in compliance with the laws and regulations of your country or region. Any unlawful use is strictly prohibited.
|
||||
- **📖 Disclaimer**: This project is provided for technical learning and research purposes only. The authors assume no liability for account bans, service interruptions, data loss, or any other direct or indirect damages resulting from the use of this project.
|
||||
|
||||
## Overview
|
||||
|
||||
Sub2API is an AI API gateway platform designed to distribute and manage API quotas from AI product subscriptions. Users can access upstream AI services through platform-generated API Keys, while the platform handles authentication, billing, load balancing, and request forwarding.
|
||||
|
||||
## Features
|
||||
|
||||
- **Multi-Account Management** - Support multiple upstream account types (OAuth, API Key)
|
||||
- **API Key Distribution** - Generate and manage API Keys for users
|
||||
- **Precise Billing** - Token-level usage tracking and cost calculation
|
||||
- **Smart Scheduling** - Intelligent account selection with sticky sessions
|
||||
- **Concurrency Control** - Per-user and per-account concurrency limits
|
||||
- **Rate Limiting** - Configurable request and token rate limits
|
||||
- **Built-in Payment System** - Supports EasyPay, Alipay, WeChat Pay, and Stripe for user self-service top-up, no separate payment service needed ([Configuration Guide](docs/PAYMENT.md))
|
||||
- **Admin Dashboard** - Web interface for monitoring and management
|
||||
- **External System Integration** - Embed external systems (e.g. ticketing) via iframe to extend the admin dashboard
|
||||
- **🚫 No Commercial Authorization**: The developers of this project have never authorized any individual or organization to conduct any form of commercial operation based on this project. Any commercial activity conducted in the name of or based on this project is unrelated to this project and its developers, and all resulting disputes, losses, and legal liabilities shall be borne solely by the party conducting such activity.
|
||||
|
||||
## ❤️ Sponsors
|
||||
|
||||
> [Want to appear here?](mailto:support@pincc.ai)
|
||||
> [Want to appear here?](mailto:support@sub2api.org)
|
||||
|
||||
<table>
|
||||
|
||||
@@ -61,16 +46,6 @@ Sub2API is an AI API gateway platform designed to distribute and manage API quot
|
||||
<td>Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses. AIGoCode has prepared a special benefit for sub2api users: if you register via <a href="https://aigocode.com/invite/SUB2API">this link</a>, you'll receive an extra 10% bonus credit on your first top-up!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://code.silkapi.com/register?aff=SUB2API"><img src="assets/partners/logos/silkapi.png" alt="silkapi" width="150"></a></td>
|
||||
<td>Thanks to SilkAPI for sponsoring this project! <a href="https://code.silkapi.com/register?aff=SUB2API">SilkAPI</a> is a relay service built on Sub2API, specializing in providing high-speed and stable Codex API relay.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://ylscode.com/"><img src="assets/partners/logos/ylscode.png" alt="ylscode" width="150"></a></td>
|
||||
<td>Thanks to YLS Code for sponsoring this project! <a href="https://ylscode.com/">YLS Code</a> is dedicated to building secure enterprise-grade Coding Agent productivity services, offering stable and fast Codex / Claude / Gemini subscription services along with pay-as-you-go API options for flexible choices. Register now for a limited-time 3-day Codex trial bonus!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=KMVZQM"><img src="assets/partners/logos/AICodeMirror.jpg" alt="AICodeMirror" width="150"></a></td>
|
||||
<td>Thanks to AICodeMirror for sponsoring this project! AICodeMirror provides official high-stability relay services for Claude Code / Codex / Gemini CLI, with enterprise-grade concurrency, fast invoicing, and 24/7 dedicated technical support. Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original price, with extra discounts on top-ups! AICodeMirror offers special benefits for sub2api users: register via <a href="https://www.aicodemirror.com/register?invitecode=KMVZQM">this link</a> to enjoy 20% off your first top-up, and enterprise customers can get up to 25% off!</td>
|
||||
@@ -97,12 +72,6 @@ Sub2API is an AI API gateway platform designed to distribute and manage API quot
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://runapi.co/register?aff=fu2E"><img src="assets/partners/logos/runapi.png" alt="RunAPI" width="150"></a></td>
|
||||
<td>Thanks to RunAPI for sponsoring this project! <a href="https://runapi.co/register?aff=fu2E">RunAPI</a> is an efficient and stable API platform and OpenRouter alternative. With one API Key, you can access 150+ popular models including OpenAI, Claude, Gemini, DeepSeek, and Grok, with pricing as low as 10% of the original rate. It is highly stable and seamlessly compatible with tools such as Claude Code and OpenClaw.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://unity2.ai/register?source=sub2api"><img src="assets/partners/logos/unity2.png" alt="unity2" width="150"></a></td>
|
||||
<td>Thanks to Unity2 for sponsoring this project! <a href="https://unity2.ai/register?source=sub2api">Unity2</a> is a high-performance AI model API relay for individuals, teams, and enterprises, handling 30B+ tokens/day with 5000 RPM concurrency. One API Key works across Claude Code, Codex, OpenAI models, IDE plugins, and Agent workflows, with balance billing, bundled subscriptions, enterprise invoicing, and 1-on-1 support. <a href="https://unity2.ai/register?source=sub2api">Register</a> to claim $2 in balance, plus $10 more by joining the official group — up to $12 in free credit.
|
||||
@@ -121,8 +90,79 @@ Sub2API is an AI API gateway platform designed to distribute and manage API quot
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://apikl.ai"><img src="assets/partners/logos/apikl.png" alt="apikl" width="150"></a></td>
|
||||
<td>Thanks to Apikl for sponsoring this project! Built on Sub2API, the platform provides developers with relay services for Codex / Claude series models, focusing on long-term stability, high-speed direct connections, and excellent cost-effectiveness. It offers pay-as-you-go balance billing, enterprise-grade official invoices, and one-on-one dedicated support. <a href="https://apikl.ai">Register now</a> for a 1:1 top-up bonus — double your balance!
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://tokeneum.ai"><img src="assets/partners/logos/tokeneum.png" alt="tokeneum" width="150"></a></td>
|
||||
<td>Thanks to TokenEum for sponsoring this project! <a href="https://tokeneum.ai">TokenEum</a> is a comprehensive AI model aggregation platform and intelligent agent development company. It brings together top-tier international models — including Claude, Gemini, and OpenAI — alongside leading open-source models such as GLM, Qwen, and Kimi, offering a wide range of options across different quality and price tiers to suit every need. TokenEum also provides access to cutting-edge video generation models like Seedance2.0 and Happy Horse. Committed to transparency and honest business practices, TokenEum ensures all model information is accurate and reliable. Visit <a href="https://tokeneum.ai">tokeneum.ai</a> to get started.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://666api.work/sub2api"><img src="assets/partners/logos/666api.jpg" alt="666api" width="150"></a></td>
|
||||
<td>Thanks to 666api for sponsoring this project! <a href="https://666api.work/sub2api">666api</a> is an all-in-one platform offering:<br>
|
||||
⚡ API Relay — Pay-as-you-go access to global models sourced 100% from official providers, up to 75% off official pricing<br>
|
||||
Exclusive: Zhipu GLM 50% off · DeepSeek V4-pro 50% off · Seedance 2.0 8% off (whitelisted) · HappyHorse Overseas 30% off (whitelisted)<br>
|
||||
🔑 GPT Subscription Accounts (same-origin IP included) · Global Residential IP <br>
|
||||
💰 Invoices supported
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://dis.chatdesks.cn/chatdesk/hsyqsub2api.html"><img src="assets/partners/logos/byteplus.png" alt="BytePlus" width="150"></a></td>
|
||||
<td>Thanks to Dola seed for sponsoring this project! Dola Seed 2.0 is a full‑modal general large model independently developed by ByteDance for the global market. Built on a unified multimodal architecture, it supports joint understanding and generation of text, images, audio, and video. It natively enables agent collaboration, with strong reasoning, long‑task execution, tool integration, and coding capabilities. It is widely applicable to smart cockpits, personal assistants, education, customer support, marketing, retail, and other scenarios. It excels in multimodal perception, end‑to‑end complex task delivery, stable interaction, and data security, and is readily accessible and deployable via the ModelArk platform.Register via <a href="https://dis.chatdesks.cn/chatdesk/hsyqsub2api.html">this link</a> to get 500,000 tokens of free inference quota per model.<a href="https://dis.chatdesks.cn/chatdesk/hsyqsub2api.html"> >>中国大陆地区的开发者请点击这里</a></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://sui-xiang.com/"><img src="assets/partners/logos/sui-xiang.jpg" alt="sui-xiang" width="150"></a></td>
|
||||
<td>Thanks to Suixiang AI Gateway for sponsoring this project! <a href="https://sui-xiang.com/">Suixiang AI Gateway</a> is a reliable and efficient API relay service provider offering relay services for Claude, Codex, Gemini, and more. A privacy-focused relay — no data reselling, no model dilution; privacy, transparency, and lightning-fast after-sales support. New accounts get ¥0.5 in trial credit daily by signing in; top-ups are 1:1, no subscription required, pay-as-you-go. Multi-line redundancy, cross-region disaster recovery, automatic failover, and uninterrupted long-link SSE. 99.9% availability — critical calls never fall behind.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.miyaip.com/?invitecode=sub2api"><img src="assets/partners/logos/miyaip.png" alt="miyaip" width="150"></a></td>
|
||||
<td>Thanks to MiyaIP for sponsoring this project! <a href="https://www.miyaip.com/?invitecode=sub2api">MiyaIP</a> is a platform dedicated to global residential proxy network services, committed to providing high-quality, pure overseas residential IP resources for enterprise developers, cross-border business teams, and AI application users. It delivers stable, independent overseas network environments for AI platforms, overseas SaaS, and other online services, supporting multi-region access testing and project environment isolation. Ideal for development and testing scenarios that require access to overseas AI services, such as: AI model platform access, AI development testing, AI SaaS service usage, AI API debugging, and multi-region network environment validation.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://anpin.ai"><img src="assets/partners/logos/anpin.jpg" alt="anpin" width="150"></a></td>
|
||||
<td>Thanks to <a href="https://anpin.ai">anpin.ai</a> for sponsoring this project! anpin.ai is a premium AI relay service platform dedicated to advancing AI accessibility. With an advanced technical architecture and globally distributed deployment, it provides users with a direct high-speed channel to the world's top-tier large language models.<br>
|
||||
Self-built primary account pool: 1-3s ultra-fast response, supports channel-partner distribution<br>
|
||||
Extreme stability: multi-line intelligent routing + redundant backup system, ensuring year-round high-availability operation;<br>
|
||||
Model authenticity: no content intervention or secondary filtering — experience the purest, most powerful native model capabilities.<br>
|
||||
1:1 top-up, enterprise-grade service with invoicing available. Anpin AI is not just a relay — it's your secure, reliable, and efficient bridge to the frontier world of intelligence.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.proxy4free.com/?keyword=4yjqecpc"><img src="assets/partners/logos/proxy4free.png" alt="proxy4free" width="150"></a></td>
|
||||
<td>Thanks to Proxy4Free for sponsoring this project! Proxy4Free is a data proxy service provider for developers and AI applications, offering residential proxies, static residential proxies, ISP proxies, and datacenter proxies for scenarios such as Web Scraping, Browser Automation, and AI Agents. With global IP resources, stable connections, and flexible switching, it helps developers improve data collection success rates and reduce the risk of IP bans. Register via <a href="https://www.proxy4free.com/?keyword=4yjqecpc">this link</a> to get started and easily build more stable and efficient automation workflows.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
## Overview
|
||||
|
||||
Sub2API is an AI API gateway platform designed to distribute and manage API quotas from AI product subscriptions. Users can access upstream AI services through platform-generated API Keys, while the platform handles authentication, billing, load balancing, and request forwarding.
|
||||
|
||||
## Features
|
||||
|
||||
- **Multi-Account Management** - Support multiple upstream account types (OAuth, API Key)
|
||||
- **API Key Distribution** - Generate and manage API Keys for users
|
||||
- **Precise Billing** - Token-level usage tracking and cost calculation
|
||||
- **Smart Scheduling** - Intelligent account selection with sticky sessions
|
||||
- **Concurrency Control** - Per-user and per-account concurrency limits
|
||||
- **Rate Limiting** - Configurable request and token rate limits
|
||||
- **Built-in Payment System** - Supports EasyPay, Alipay, WeChat Pay, and Stripe for user self-service top-up, no separate payment service needed ([Configuration Guide](docs/PAYMENT.md))
|
||||
- **Admin Dashboard** - Web interface for monitoring and management
|
||||
- **External System Integration** - Embed external systems (e.g. ticketing) via iframe to extend the admin dashboard
|
||||
|
||||
## Ecosystem
|
||||
|
||||
Community projects that extend or integrate with Sub2API:
|
||||
@@ -419,7 +459,8 @@ pnpm run build
|
||||
|
||||
# 4. Build backend with embedded frontend
|
||||
cd ../backend
|
||||
go build -tags embed -o sub2api ./cmd/server
|
||||
VERSION="$(./scripts/resolve-version.sh)"
|
||||
go build -tags embed -ldflags="-X main.Version=${VERSION}" -o sub2api ./cmd/server
|
||||
|
||||
# 5. Create configuration file
|
||||
cp ../deploy/config.example.yaml ./config.yaml
|
||||
@@ -482,20 +523,20 @@ Additional security-related options are available in `config.yaml`:
|
||||
|
||||
**⚠️ Security Warning: HTTP URL Configuration**
|
||||
|
||||
When `security.url_allowlist.enabled=false`, the system performs minimal URL validation by default, **rejecting HTTP URLs** and only allowing HTTPS. To allow HTTP URLs (e.g., for development or internal testing), you must explicitly set:
|
||||
When `security.url_allowlist.enabled=false`, the system performs minimal URL validation and **allows HTTP URLs by default** (dev-friendly mode; Docker Compose deployments use the same default). For production, explicitly tighten this to HTTPS-only:
|
||||
|
||||
```yaml
|
||||
security:
|
||||
url_allowlist:
|
||||
enabled: false # Disable allowlist checks
|
||||
allow_insecure_http: true # Allow HTTP URLs (⚠️ INSECURE)
|
||||
allow_insecure_http: false # HTTPS only (recommended for production)
|
||||
```
|
||||
|
||||
**Or via environment variable:**
|
||||
|
||||
```bash
|
||||
SECURITY_URL_ALLOWLIST_ENABLED=false
|
||||
SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=true
|
||||
SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=false
|
||||
```
|
||||
|
||||
**Risks of allowing HTTP:**
|
||||
@@ -509,7 +550,7 @@ SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=true
|
||||
- ✅ Testing account connectivity before obtaining HTTPS
|
||||
- ❌ Production environments (use HTTPS only)
|
||||
|
||||
**Example error without this setting:**
|
||||
**Example error for HTTP URLs when `allow_insecure_http: false` is set:**
|
||||
```
|
||||
Invalid base URL: invalid url scheme: http
|
||||
```
|
||||
@@ -520,6 +561,25 @@ If you disable URL validation or response header filtering, harden your network
|
||||
- Enforce TLS-only outbound traffic
|
||||
- Strip sensitive upstream response headers at the proxy
|
||||
|
||||
#### ⚠️ Important: Creating the Admin Account
|
||||
|
||||
The initial admin account is **only created via the setup wizard** (served at `http://<host>:8080` on first run). The `default.admin_email` / `default.admin_password` fields in `config.yaml` are **not used** to create it — they exist in the template for historical reasons.
|
||||
|
||||
Because step 5 above pre-creates `config.yaml`, the setup wizard will be **skipped on first run**: the server detects an existing config and boots straight into normal mode with an empty `users` table, so the first login attempt fails with `invalid email or password`.
|
||||
|
||||
**Two ways to create the admin account:**
|
||||
|
||||
1. **Recommended — let the wizard generate `config.yaml`:** Skip step 5 (do not run the `cp`). Start `./sub2api` directly; the setup wizard at `http://localhost:8080` walks you through database, Redis, and admin account setup, then writes `config.yaml` for you.
|
||||
|
||||
2. **If you already created `config.yaml`:** Temporarily move it aside so the wizard can trigger on first run, then restore it afterwards:
|
||||
```bash
|
||||
mv config.yaml config.yaml.bak
|
||||
./sub2api # wizard runs at http://localhost:8080 and writes a fresh config.yaml
|
||||
# stop the server (Ctrl+C) once the wizard completes, then restore your config:
|
||||
mv config.yaml.bak config.yaml
|
||||
./sub2api # restart in normal mode and log in with the admin you just created
|
||||
```
|
||||
|
||||
```bash
|
||||
# 6. Run the application
|
||||
./sub2api
|
||||
@@ -559,6 +619,55 @@ Simple Mode is designed for individual developers or internal teams who want qui
|
||||
|
||||
---
|
||||
|
||||
## Grok / xAI OAuth Support
|
||||
|
||||
Sub2API supports Grok subscription accounts through xAI OAuth and forwards OpenAI-compatible Responses traffic to xAI.
|
||||
|
||||
### Supported Scope
|
||||
|
||||
- Platform name: `grok`
|
||||
- Account type: OAuth subscription accounts
|
||||
- Public Responses targets: `/v1/responses`, `/responses`, and `/backend-api/codex/responses`, forwarded to `${XAI_BASE_URL:-https://api.x.ai/v1}/responses`
|
||||
- Public Claude-compatible target: `/v1/messages`, converted to xAI Responses and returned as Anthropic Messages output for Claude CLI style clients
|
||||
- Public Chat Completions targets: `/v1/chat/completions` and `/chat/completions`, forwarded to `${XAI_BASE_URL:-https://api.x.ai/v1}/chat/completions`
|
||||
- Codex CLI style Responses WebSocket ingress is accepted on the Responses targets and bridged to xAI HTTP/SSE Responses upstream
|
||||
- Initial text models: `grok-4.3`, `grok-build-0.1`, `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning`, and `grok-4.20-multi-agent-0309`
|
||||
- Media targets for Grok groups: `/v1/images/generations`, `/images/generations`, `/v1/images/edits`, `/images/edits`, `/v1/videos/generations`, `/videos/generations`, `/v1/videos/{request_id}`, and `/videos/{request_id}`. Generation requests require the group image-generation permission.
|
||||
- Media models: `grok-imagine`, `grok-imagine-image-quality`, `grok-imagine-image`, `grok-imagine-edit`, `grok-imagine-video`, and `grok-imagine-video-1.5`
|
||||
- Out of scope for this provider: TTS, transcription, browser automation, cookies, and Grok web scraping
|
||||
|
||||
### OAuth Configuration
|
||||
|
||||
The Grok OAuth flow uses PKCE and does not require committing private secrets. The default client details follow the public xAI OAuth flow used by compatible clients, and every value can be overridden by environment variable:
|
||||
|
||||
| Variable | Default |
|
||||
|----------|---------|
|
||||
| `XAI_OAUTH_CLIENT_ID` | Public xAI OAuth client ID |
|
||||
| `XAI_OAUTH_SCOPE` | `openid profile email offline_access grok-cli:access api:access` |
|
||||
| `XAI_OAUTH_REDIRECT_URI` | `http://127.0.0.1:56121/callback` |
|
||||
| `XAI_OAUTH_AUTHORIZE_URL` | `https://auth.x.ai/oauth2/authorize` |
|
||||
| `XAI_OAUTH_TOKEN_URL` | `https://auth.x.ai/oauth2/token` |
|
||||
| `XAI_BASE_URL` | `https://api.x.ai/v1` |
|
||||
|
||||
Administrators can create or reauthorize Grok accounts from the dashboard, or use the admin API:
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|----------|---------|
|
||||
| `POST /api/v1/admin/grok/oauth/auth-url` | Generate an xAI OAuth authorization URL |
|
||||
| `POST /api/v1/admin/grok/oauth/exchange-code` | Exchange a callback URL, query string, or code for OAuth credentials |
|
||||
| `POST /api/v1/admin/grok/oauth/refresh-token` | Validate or refresh a Grok refresh token |
|
||||
| `POST /api/v1/admin/grok/accounts/:id/refresh` | Refresh an existing Grok account |
|
||||
|
||||
Credential storage reuses the existing account JSON fields: `access_token`, `refresh_token`, `token_type`, `expires_at`, optional `email`, optional `subscription_tier`, and `entitlement_status`.
|
||||
|
||||
### Usage And Quota Display
|
||||
|
||||
xAI quota is passive. Sub2API does not invent subscription quota values; it records whitelisted xAI rate-limit headers from successful or rate-limited upstream responses when xAI sends them. Before the first usable upstream response, the dashboard shows quota as unknown and still displays local Sub2API usage stats.
|
||||
|
||||
`401` responses mark the account as needing reauthorization. `403` responses are treated as entitlement or subscription-tier failures instead of token-refresh loops. `429` responses use `Retry-After` or a short cooldown to temporarily remove the account from scheduling.
|
||||
|
||||
---
|
||||
|
||||
## Antigravity Support
|
||||
|
||||
Sub2API supports [Antigravity](https://antigravity.so/) accounts. After authorization, dedicated endpoints are available for Claude and Gemini models.
|
||||
@@ -583,12 +692,6 @@ Antigravity accounts support optional **hybrid scheduling**. When enabled, the g
|
||||
|
||||
> **⚠️ Warning**: Anthropic Claude and Antigravity Claude **cannot be mixed within the same conversation context**. Use groups to isolate them properly.
|
||||
|
||||
### Known Issues
|
||||
|
||||
In Claude Code, Plan Mode cannot exit automatically. (Normally when using the native Claude API, after planning is complete, Claude Code will pop up options for users to approve or reject the plan.)
|
||||
|
||||
**Workaround**: Press `Shift + Tab` to manually exit Plan Mode, then type your response to approve or reject the plan.
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
@@ -619,16 +722,6 @@ sub2api/
|
||||
└── install.sh # One-click installation script
|
||||
```
|
||||
|
||||
## Disclaimer
|
||||
|
||||
> **Please read carefully before using this project:**
|
||||
>
|
||||
> :rotating_light: **Terms of Service Risk**: Using this project may violate Anthropic's Terms of Service. Please read Anthropic's user agreement carefully before use. All risks arising from the use of this project are borne solely by the user.
|
||||
>
|
||||
> :book: **Disclaimer**: This project is for technical learning and research purposes only. The author assumes no responsibility for account suspension, service interruption, or any other losses caused by the use of this project.
|
||||
|
||||
---
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://star-history.com/#Wei-Shaw/sub2api&Date">
|
||||
|
||||
@@ -0,0 +1,768 @@
|
||||
# Sub2API
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://golang.org/)
|
||||
[](https://vuejs.org/)
|
||||
[](https://www.postgresql.org/)
|
||||
[](https://redis.io/)
|
||||
[](https://www.docker.com/)
|
||||
|
||||
<a href="https://trendshift.io/repositories/21823" target="_blank"><img src="https://trendshift.io/api/badge/repositories/21823" alt="Wei-Shaw%2Fsub2api | Trendshift" width="250" height="55"/></a>
|
||||
|
||||
**AI API 网关平台 - 订阅配额分发管理**
|
||||
|
||||
[English](README.md) | 中文 | [日本語](README_JA.md)
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
## ⚠️ 重要提醒
|
||||
|
||||
使用本项目前,请务必仔细阅读以下内容:
|
||||
|
||||
- **🚨 服务条款风险**:使用本项目可能违反 Anthropic 等上游服务商的服务条款。请在使用前仔细阅读相关服务商的用户协议,由此产生的一切风险由用户自行承担。
|
||||
- **⚖️ 合规使用**:请在符合您所在国家或地区法律法规的前提下使用本项目,严禁将其用于任何违法违规用途。
|
||||
- **📖 免责声明**:本项目仅供技术学习与研究使用,作者不对因使用本项目导致的账户封禁、服务中断、数据丢失或其他任何直接或间接损失承担责任。
|
||||
- **🚫 无商业授权**:本项目从未授权任何个人或组织基于本项目开展任何形式的商业化运营。任何以本项目名义或基于本项目从事的商业行为均与本项目及其开发者无关,由此产生的一切纠纷、损失和法律责任由行为主体自行承担。
|
||||
|
||||
## ❤️ 赞助商
|
||||
|
||||
> [想出现在这里?](mailto:support@sub2api.org)
|
||||
|
||||
<table>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.openmodel.ai?ref=sub2api"><img src="assets/partners/logos/openmodel.jpg" alt="openmodel" width="150"></a></td>
|
||||
<td>一个API,顶级模型随便用!<a href="https://www.openmodel.ai?ref=sub2api">OpenModel</a> 专注于生产级、高可用的 AI API 网关,让你的应用真正做到高速稳定:自动故障转移、智能选最优渠道、生产级 SLA 保障。远超单一供应商的 SLA,让稳定性成为您的核心竞争力。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://etok.ai"><img src="assets/partners/logos/etok.png" alt="ETok" width="150"></a></td>
|
||||
<td>感谢 ETok.ai 赞助了本项目!ETok.ai 致力于打造一站式 AI 编程工具服务平台。我们提供 Claude Code 专业套餐及技术社群服务,同时支持 Google Gemini 和 OpenAI Codex。通过精心设计的套餐方案和专业的技术社群,为开发者提供稳定的服务保障和持续的技术支持,让 AI 辅助编程真正成为开发者的生产力工具。点击<a href="https://etok.ai">这里</a>注册!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://aigocode.com/invite/SUB2API"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
|
||||
<td>感谢 AIGoCode 赞助了本项目!AIGoCode 是一站式集成 Claude Code、Codex 以及最新 Gemini 模型的综合平台,为您提供稳定、高效、高性价比的 AI 编程服务。平台提供灵活的订阅方案,零封号风险,免 VPN 直连,响应极速。AIGoCode 为 sub2api 用户准备了专属福利:通过<a href="https://aigocode.com/invite/SUB2API">此链接</a>注册,首次充值可额外获得 10% 赠送额度!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=KMVZQM"><img src="assets/partners/logos/AICodeMirror.jpg" alt="AICodeMirror" width="150"></a></td>
|
||||
<td>感谢 AICodeMirror 赞助了本项目!AICodeMirror 提供 Claude Code / Codex / Gemini CLI 官方高稳定性中转服务,企业级并发、快速开票、7×24 小时专属技术支持。Claude Code / Codex / Gemini 官方通道低至原价 38% / 2% / 9%,充值更享额外折扣!AICodeMirror 为 sub2api 用户提供专属福利:通过<a href="https://www.aicodemirror.com/register?invitecode=KMVZQM">此链接</a>注册,首次充值立享 8 折优惠,企业客户最高可享 75 折!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://shop.bmoplus.com/?utm_source=github"><img src="assets/partners/logos/bmoplus.jpg" alt="bmoplus" width="150"></a></td>
|
||||
<td>感谢 BmoPlus 赞助了本项目!BmoPlus 是一家专为AI订阅重度用户打造的可靠 AI 账号代充服务商,提供稳定的 ChatGPT Plus / ChatGPT Pro(全程质保) / Claude Pro / Super Grok / Gemini Pro 的官方代充&成品账号。 通过<a href="https://shop.bmoplus.com/?utm_source=github">BmoPlus AI成品号专卖/代充</a>注册下单的用户,可享GPT 官网订阅一折 的震撼价格!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://bestproxy.com/?keyword=a2e8iuol"><img src="assets/partners/logos/bestproxy.png" alt="bestproxy" width="150"></a></td>
|
||||
<td>感谢 Bestproxy 赞助了本项目!<a href="https://bestproxy.com/?keyword=a2e8iuol">Bestproxy</a> 是一家提供高纯度住宅IP,支持一号一IP独享,结合真实家庭网络与指纹隔离,可实现链路环境隔离,降低关联风控概率。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://pateway.ai/?ch=1tsfr51"><img src="assets/partners/logos/pateway.png" alt="pateway" width="150"></a></td>
|
||||
<td>感谢 PatewayAI 赞助了本项目!PatewayAI 是一家面向重度 AI 开发者、专注官方直连的高品质模型 API 中转服务商。提供 Claude 全系列与 Codex 系列模型,100% 官方源直供,不掺假不注水,欢迎检验。计费透明,Token 级账单可逐笔核验。
|
||||
同时支持企业级高并发,并为企业客户提供了专业的管理平台,企业客户可签订正式合同并开具发票,更多详情进入官网获取联系方式。
|
||||
现在通过 <a href="https://pateway.ai/?ch=1tsfr51">此链接</a> 注册即送 $3 试用额度,用户充值低至 6 折,邀请好友双向赠送,邀请奖励可达 $150。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://api.pptoken.org/register?promo=SUB2API"><img src="assets/partners/logos/pptoken.png" alt="pptoken" width="150"></a></td>
|
||||
<td>感谢 PPToken.org 赞助本项目! <a href="https://api.pptoken.org/register?promo=SUB2API">PPToken.org</a> 主打 GPT 系列模型 API 中转服务,支持 Codex、Claude Code、OpenAI 兼容客户端及 Gemini CLI 等工具接入。充值 1:1,1 元=1 美元额度;GPT 模型最低 0.16 倍倍率,综合成本约为官方价格的 0.22 折,最快首字 Token 约 1 秒,适合开发者低成本、高响应速度接入 GPT 模型能力。技术支持: 7×24 小时真人响应(不是机器人),群内@技术,10 分钟内有回复 。赞助商福利:前 200 名用户通过 <a href="https://api.pptoken.org/register?promo=SUB2API">[专属注册链接]</a> 注册,输入优惠码 `SUB2API`,可领取 Codex / Claude Code 免费试用额度,无门槛、不绑卡。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://unity2.ai/register?source=sub2api"><img src="assets/partners/logos/unity2.png" alt="unity2" width="150"></a></td>
|
||||
<td>感谢 Unity2 赞助本项目! <a href="https://unity2.ai/register?source=sub2api">Unity2</a> 是面向个人开发者、团队、企业的高性能 AI 模型 API 中转平台,长期服务国内头部企业,日均承载超 300 亿 token 调用,支持 5000 RPM 级高并发。一个 API Key 即可适配 Claude Code、Codex、OpenAI 模型、IDE 插件和 Agent 工作流等场景。具备企业级稳定供应能力,在高并发、持续调用和团队集中采购场景下依然保持低延迟、高可用。同时支持余额计费、组合订阅、首充优惠、企业开票、专属 1v1 对接,适合个人高频使用和企业长期接入。现在注册 Unity2.ai 可领取 $2 余额,加入官方群再送 $10 余额,合计最高可领 $12 免费额度,适合先体验后长期使用。<a href="https://unity2.ai/register?source=sub2api">注册链接</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://veilx.io/#/hello/SJRBRVDV"><img src="assets/partners/logos/veilx.png" alt="veilx" width="150"></a></td>
|
||||
<td>感谢 Veilx 赞助本项目! <a href="https://veilx.io/#/hello/SJRBRVDV">Veilx</a> CDN 专为超大规模 API 请求场景打造,针对 AI 中转站业务与 AI API 调用链路进行了深度优化,轻松应对高并发、高频请求与大流量传输,为开发者与企业提供更快、更稳、更低延迟的加速体验。无论是 OpenAI、Claude、Gemini 等 AI 接口中转,还是聊天、绘图、Embedding、流式输出等复杂场景,Veilx 都能显著提升响应速度与连接稳定性,有效降低网络波动带来的超时与失败问题。同时,Veilx 提供中国三网优化回国极速线路,大幅提升中国大陆地区访问海外 AI 服务的速度与稳定性,特别适合全球 AI 中转平台、海外 AI SaaS、跨境业务与高并发 API 系统部署。专为 AI API 而生,让你的 AI 中转服务更快、更稳、更省心。<a href="https://veilx.io/#/hello/SJRBRVDV">购买地址</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://roxybrowser.com/invite/bgGKG7"><img src="assets/partners/logos/RoxyBrowser.png" alt="RoxyBrowser" width="150"></a></td>
|
||||
<td>感谢 RoxyBrowser 赞助本项目!<a href="https://roxybrowser.com/invite/bgGKG7">RoxyBrowser</a> 是 Sub2API 的理想搭档:内置原生 Roxy AI Agent 与高质量原生住宅 IP,支持通过简单命令实现批量自动化,显著提升多账号管理的安全性与效率!点击<a href="https://roxybrowser.com/invite/bgGKG7">此链接</a>注册,可领取免费住宅 IP 套餐与终身 9 折优惠。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://apikl.ai"><img src="assets/partners/logos/apikl.png" alt="apikl" width="150"></a></td>
|
||||
<td>感谢 Apikl 赞助本项目!平台基于 Sub2API 搭建,为开发者提供 Codex / Claude 系列模型的中转服务,专注于长期稳定、高速直连与高性价比。支持按量计费的余额结算、企业级正规发票及一对一专属对接。<a href="https://apikl.ai">立即注册</a>即享充值 1:1 赠送 — 余额翻倍!
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://tokeneum.ai"><img src="assets/partners/logos/tokeneum.png" alt="tokeneum" width="150"></a></td>
|
||||
<td>感谢 TokenEum 赞助本项目!<a href="https://tokeneum.ai">TokenEum</a> 是一家综合性 AI 模型聚合平台与智能体开发公司,汇聚 Claude、Gemini、OpenAI 等国际顶级模型,以及 GLM、Qwen、Kimi 等主流开源模型,提供不同质量与价格梯度的丰富选择,满足多样化需求。平台还接入了 Seedance2.0、Happy Horse 等前沿视频生成模型。秉持透明诚信的经营理念,TokenEum 确保所有模型信息真实可靠。访问 <a href="https://tokeneum.ai">tokeneum.ai</a> 开始使用。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://666api.work/sub2api"><img src="assets/partners/logos/666api.jpg" alt="666api" width="150"></a></td>
|
||||
<td>感谢 666api 赞助本项目!<a href="https://666api.work/sub2api">666api</a> 是一站式综合服务平台,提供:<br>
|
||||
⚡ API 中转 — 全球模型按量计费接入,100% 官方源直供,最高 75 折优惠<br>
|
||||
独家特惠:智谱 GLM 5 折 · DeepSeek V4-pro 5 折 · Seedance 2.0 0.8 折(白名单)· HappyHorse 海外版 3 折(白名单)<br>
|
||||
🔑 GPT 订阅账号(含同源 IP)· 全球住宅 IP<br>
|
||||
💰 支持开票
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://dis.chatdesks.cn/chatdesk/hsyqsub2api.html"><img src="assets/partners/logos/huoshan.png" alt="HuoShan" width="150"></a></td>
|
||||
<td>感谢火山方舟 Agent Plan 模型赞助了本项目!方舟 Agent Plan 模型订阅套餐集成了包含 Doubao-Seed、Doubao-Seedance、Doubao-Seedream 等在内的字节跳动自研 SOTA 级模型,覆盖文本、代码、图像、视频等多模态任务。最新支持 MiniMax-M3、DeepSeek-V4 系列、GLM-5.1、Doubao-Seed-2.0 系列、Kimi-K2.6 等模型,工具不限。超全模态模型与 Harness 升级一步到位,深度支持 Agent 框架与 AI 编程工具。一次订阅,可以为不同任务切换合适的 AI 引擎。方舟 Coding Plan 为 Sub2Api 的用户提供了专属福利:通过<a href="https://dis.chatdesks.cn/chatdesk/hsyqsub2api.html">此链接</a>订阅方舟 Coding Plan,新客户首两个月享 2.5 折优惠 <a href="https://dis.chatdesks.cn/chatdesk/hsyqsub2api.html">>>For developers outside Mainland China, please click here</a></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://sui-xiang.com/"><img src="assets/partners/logos/sui-xiang.jpg" alt="sui-xiang" width="150"></a></td>
|
||||
<td>感谢 随想AI网关 赞助本项目!<a href="https://sui-xiang.com/">随想AI网关</a> 是一家可靠高效的 API 中继服务提供商,提供 Claude、Codex、Gemini 等的中继服务。注重隐私的中转站·无数据倒卖·无模型掺水,隐私,透明,极速售后。新账户注册每日签到就送 0.5 元测试额度,充值额度 1:1,无需订阅,按量付费。多线路冗余、跨区域容灾、自动故障切换,长链路 SSE 不中断。99.9% 可用性,关键调用从不掉队。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.miyaip.com/?invitecode=sub2api"><img src="assets/partners/logos/miyaip.png" alt="miyaip" width="150"></a></td>
|
||||
<td>感谢 MiyaIP 赞助本项目!<a href="https://www.miyaip.com/?invitecode=sub2api">MiyaIP</a> 是一家专注于全球住宅代理网络服务的平台,致力于为企业开发者、跨境业务团队及AI 应用用户提供高质量、纯净的海外住宅 IP 资源。为 AI 平台、海外 SaaS 及其他在线服务提供稳定、独立的海外网络环境,支持多地区访问测试和项目环境隔离。适用于需要访问海外 AI 服务的开发和测试场景,例如:AI 模型平台访问、AI 开发测试、AI SaaS 服务使用、AI API 调试、多地区网络环境验证
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://anpin.ai"><img src="assets/partners/logos/anpin.jpg" alt="anpin" width="150"></a></td>
|
||||
<td>感谢 <a href="https://anpin.ai">anpin.ai</a> 赞助本项目!anpin.ai 是一家致力于推动 AI 普惠的高端 AI 中转服务平台。我们以先进的技术架构和全球分布式部署,为用户提供直达国际顶尖大模型的高速通道。<br>
|
||||
自建一手号池:1-3S超快响应 支持同行分发<br>
|
||||
极致稳定:多线智能路由 + 冗余备份系统,确保服务全年无休、高可用运行;<br>
|
||||
模型真实性:不做任何内容干预与二次过滤,让您体验到最纯粹、最强大的原生模型能力。<br>
|
||||
充值1:1 企业级服务可开票,安品Ai不只是中转站,更是您连接前沿智能世界的安全、可靠、高效桥梁
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.proxy4free.com/?keyword=4yjqecpc"><img src="assets/partners/logos/proxy4free.png" alt="proxy4free" width="150"></a></td>
|
||||
<td>感谢 Proxy4Free 赞助本项目!Proxy4Free 是面向开发者和 AI 应用的数据代理服务商,提供住宅代理、静态住宅代理、ISP 代理及数据中心代理等多种代理解决方案,适用于 Web Scraping、Browser Automation、AI Agent 等场景。支持全球 IP 资源、稳定连接与灵活切换,帮助开发者提升数据采集成功率,降低 IP 封禁风险。通过<a href="https://www.proxy4free.com/?keyword=4yjqecpc">此链接注册</a>即可开始体验,轻松构建更稳定、高效的自动化工作流。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
</table>
|
||||
|
||||
## 项目概述
|
||||
|
||||
Sub2API 是一个 AI API 网关平台,用于分发和管理 AI 产品订阅的 API 配额。用户通过平台生成的 API Key 调用上游 AI 服务,平台负责鉴权、计费、负载均衡和请求转发。
|
||||
|
||||
## 核心功能
|
||||
|
||||
- **多账号管理** - 支持多种上游账号类型(OAuth、API Key)
|
||||
- **API Key 分发** - 为用户生成和管理 API Key
|
||||
- **精确计费** - Token 级别的用量追踪和成本计算
|
||||
- **智能调度** - 智能账号选择,支持粘性会话
|
||||
- **并发控制** - 用户级和账号级并发限制
|
||||
- **速率限制** - 可配置的请求和 Token 速率限制
|
||||
- **内置支付系统** - 支持 EasyPay 易支付、支付宝官方、微信官方、Stripe,用户自助充值,无需独立部署支付服务([配置指南](docs/PAYMENT_CN.md))
|
||||
- **管理后台** - Web 界面进行监控和管理
|
||||
- **外部系统集成** - 支持通过 iframe 嵌入外部系统(如工单等),扩展管理后台功能
|
||||
|
||||
## 生态项目
|
||||
|
||||
围绕 Sub2API 的社区扩展与集成项目:
|
||||
|
||||
| 项目 | 说明 | 功能 |
|
||||
|------|------|------|
|
||||
| ~~[Sub2ApiPay](https://github.com/touwaeriol/sub2apipay)~~ | ~~自助支付系统~~ | **已内置** — 支付功能已集成到 Sub2API 中,无需独立部署。详见 [支付配置指南](docs/PAYMENT_CN.md) |
|
||||
| [sub2api-mobile](https://github.com/ckken/sub2api-mobile) | 移动端管理控制台 | 跨平台应用(iOS/Android/Web),支持用户管理、账号管理、监控看板、多后端切换;基于 Expo + React Native 构建 |
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 组件 | 技术 |
|
||||
|------|------|
|
||||
| 后端 | Go 1.25.7, Gin, Ent |
|
||||
| 前端 | Vue 3.4+, Vite 5+, TailwindCSS |
|
||||
| 数据库 | PostgreSQL 15+ |
|
||||
| 缓存/队列 | Redis 7+ |
|
||||
|
||||
---
|
||||
|
||||
## Nginx 反向代理注意事项
|
||||
|
||||
通过 Nginx 反向代理 Sub2API(或 CRS 服务)并搭配 Codex CLI 使用时,需要在 Nginx 配置的 `http` 块中添加:
|
||||
|
||||
```nginx
|
||||
underscores_in_headers on;
|
||||
```
|
||||
|
||||
Nginx 默认会丢弃名称中含下划线的请求头(如 `session_id`),这会导致多账号环境下的粘性会话功能失效。
|
||||
|
||||
---
|
||||
|
||||
## 部署方式
|
||||
|
||||
### 方式一:脚本安装(推荐)
|
||||
|
||||
一键安装脚本,自动从 GitHub Releases 下载预编译的二进制文件。
|
||||
|
||||
#### 前置条件
|
||||
|
||||
- Linux 服务器(amd64 或 arm64)
|
||||
- PostgreSQL 15+(已安装并运行)
|
||||
- Redis 7+(已安装并运行)
|
||||
- Root 权限
|
||||
|
||||
#### 安装步骤
|
||||
|
||||
```bash
|
||||
curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/install.sh | sudo bash
|
||||
```
|
||||
|
||||
脚本会自动:
|
||||
1. 检测系统架构
|
||||
2. 下载最新版本
|
||||
3. 安装二进制文件到 `/opt/sub2api`
|
||||
4. 创建 systemd 服务
|
||||
5. 配置系统用户和权限
|
||||
|
||||
#### 安装后配置
|
||||
|
||||
```bash
|
||||
# 1. 启动服务
|
||||
sudo systemctl start sub2api
|
||||
|
||||
# 2. 设置开机自启
|
||||
sudo systemctl enable sub2api
|
||||
|
||||
# 3. 在浏览器中打开设置向导
|
||||
# http://你的服务器IP:8080
|
||||
```
|
||||
|
||||
设置向导将引导你完成:
|
||||
- 数据库配置
|
||||
- Redis 配置
|
||||
- 管理员账号创建
|
||||
|
||||
#### 升级
|
||||
|
||||
可以直接在 **管理后台** 左上角点击 **检测更新** 按钮进行在线升级。
|
||||
|
||||
网页升级功能支持:
|
||||
- 自动检测新版本
|
||||
- 一键下载并应用更新
|
||||
- 支持回滚
|
||||
|
||||
#### 常用命令
|
||||
|
||||
```bash
|
||||
# 查看状态
|
||||
sudo systemctl status sub2api
|
||||
|
||||
# 查看日志
|
||||
sudo journalctl -u sub2api -f
|
||||
|
||||
# 重启服务
|
||||
sudo systemctl restart sub2api
|
||||
|
||||
# 卸载
|
||||
curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/install.sh | sudo bash -s -- uninstall -y
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 方式二:Docker Compose(推荐)
|
||||
|
||||
使用 Docker Compose 部署,包含 PostgreSQL 和 Redis 容器。
|
||||
|
||||
#### 前置条件
|
||||
|
||||
- Docker 20.10+
|
||||
- Docker Compose v2+
|
||||
|
||||
#### 快速开始(一键部署)
|
||||
|
||||
使用自动化部署脚本快速搭建:
|
||||
|
||||
```bash
|
||||
# 创建部署目录
|
||||
mkdir -p sub2api-deploy && cd sub2api-deploy
|
||||
|
||||
# 下载并运行部署准备脚本
|
||||
curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/docker-deploy.sh | bash
|
||||
|
||||
# 启动服务
|
||||
docker compose up -d
|
||||
|
||||
# 查看日志
|
||||
docker compose logs -f sub2api
|
||||
```
|
||||
|
||||
**脚本功能:**
|
||||
- 下载 `docker-compose.local.yml`(本地保存为 `docker-compose.yml`)和 `.env.example`
|
||||
- 自动生成安全凭证(JWT_SECRET、TOTP_ENCRYPTION_KEY、POSTGRES_PASSWORD)
|
||||
- 创建 `.env` 文件并填充自动生成的密钥
|
||||
- 创建数据目录(使用本地目录,便于备份和迁移)
|
||||
- 显示生成的凭证供你记录
|
||||
|
||||
#### 手动部署
|
||||
|
||||
如果你希望手动配置:
|
||||
|
||||
```bash
|
||||
# 1. 克隆仓库
|
||||
git clone https://github.com/Wei-Shaw/sub2api.git
|
||||
cd sub2api/deploy
|
||||
|
||||
# 2. 复制环境配置文件
|
||||
cp .env.example .env
|
||||
|
||||
# 3. 编辑配置(生成安全密码)
|
||||
nano .env
|
||||
```
|
||||
|
||||
**`.env` 必须配置项:**
|
||||
|
||||
```bash
|
||||
# PostgreSQL 密码(必需)
|
||||
POSTGRES_PASSWORD=your_secure_password_here
|
||||
|
||||
# JWT 密钥(推荐 - 重启后保持用户登录状态)
|
||||
JWT_SECRET=your_jwt_secret_here
|
||||
|
||||
# TOTP 加密密钥(推荐 - 重启后保留双因素认证)
|
||||
TOTP_ENCRYPTION_KEY=your_totp_key_here
|
||||
|
||||
# 可选:管理员账号
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=your_admin_password
|
||||
|
||||
# 可选:自定义端口
|
||||
SERVER_PORT=8080
|
||||
```
|
||||
|
||||
**生成安全密钥:**
|
||||
```bash
|
||||
# 生成 JWT_SECRET
|
||||
openssl rand -hex 32
|
||||
|
||||
# 生成 TOTP_ENCRYPTION_KEY
|
||||
openssl rand -hex 32
|
||||
|
||||
# 生成 POSTGRES_PASSWORD
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
```bash
|
||||
# 4. 创建数据目录(本地版)
|
||||
mkdir -p data postgres_data redis_data
|
||||
|
||||
# 5. 启动所有服务
|
||||
# 选项 A:本地目录版(推荐 - 易于迁移)
|
||||
docker compose -f docker-compose.local.yml up -d
|
||||
|
||||
# 选项 B:命名卷版(简单设置)
|
||||
docker compose up -d
|
||||
|
||||
# 6. 查看状态
|
||||
docker compose -f docker-compose.local.yml ps
|
||||
|
||||
# 7. 查看日志
|
||||
docker compose -f docker-compose.local.yml logs -f sub2api
|
||||
```
|
||||
|
||||
#### 部署版本对比
|
||||
|
||||
| 版本 | 数据存储 | 迁移便利性 | 适用场景 |
|
||||
|------|---------|-----------|---------|
|
||||
| **docker-compose.local.yml** | 本地目录 | ✅ 简单(打包整个目录) | 生产环境、频繁备份 |
|
||||
| **docker-compose.yml** | 命名卷 | ⚠️ 需要 docker 命令 | 简单设置 |
|
||||
|
||||
**推荐:** 使用 `docker-compose.local.yml`(脚本部署)以便更轻松地管理数据。
|
||||
|
||||
#### 启用“数据管理”功能(datamanagementd)
|
||||
|
||||
如需启用管理后台“数据管理”,需要额外部署宿主机数据管理进程 `datamanagementd`。
|
||||
|
||||
关键点:
|
||||
|
||||
- 主进程固定探测:`/tmp/sub2api-datamanagement.sock`
|
||||
- 只有该 Socket 可连通时,数据管理功能才会开启
|
||||
- Docker 场景需将宿主机 Socket 挂载到容器同路径
|
||||
|
||||
详细部署步骤见:`deploy/DATAMANAGEMENTD_CN.md`
|
||||
|
||||
#### 访问
|
||||
|
||||
在浏览器中打开 `http://你的服务器IP:8080`
|
||||
|
||||
如果管理员密码是自动生成的,在日志中查找:
|
||||
```bash
|
||||
docker compose -f docker-compose.local.yml logs sub2api | grep "admin password"
|
||||
```
|
||||
|
||||
#### 升级
|
||||
|
||||
```bash
|
||||
# 拉取最新镜像并重建容器
|
||||
docker compose -f docker-compose.local.yml pull
|
||||
docker compose -f docker-compose.local.yml up -d
|
||||
```
|
||||
|
||||
#### 轻松迁移(本地目录版)
|
||||
|
||||
使用 `docker-compose.local.yml` 时,可以轻松迁移到新服务器:
|
||||
|
||||
```bash
|
||||
# 源服务器
|
||||
docker compose -f docker-compose.local.yml down
|
||||
cd ..
|
||||
tar czf sub2api-complete.tar.gz sub2api-deploy/
|
||||
|
||||
# 传输到新服务器
|
||||
scp sub2api-complete.tar.gz user@new-server:/path/
|
||||
|
||||
# 新服务器
|
||||
tar xzf sub2api-complete.tar.gz
|
||||
cd sub2api-deploy/
|
||||
docker compose -f docker-compose.local.yml up -d
|
||||
```
|
||||
|
||||
#### 常用命令
|
||||
|
||||
```bash
|
||||
# 停止所有服务
|
||||
docker compose -f docker-compose.local.yml down
|
||||
|
||||
# 重启
|
||||
docker compose -f docker-compose.local.yml restart
|
||||
|
||||
# 查看所有日志
|
||||
docker compose -f docker-compose.local.yml logs -f
|
||||
|
||||
# 删除所有数据(谨慎!)
|
||||
docker compose -f docker-compose.local.yml down
|
||||
rm -rf data/ postgres_data/ redis_data/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 方式三:源码编译
|
||||
|
||||
从源码编译安装,适合开发或定制需求。
|
||||
|
||||
#### 前置条件
|
||||
|
||||
- Go 1.21+
|
||||
- Node.js 18+
|
||||
- PostgreSQL 15+
|
||||
- Redis 7+
|
||||
|
||||
#### 编译步骤
|
||||
|
||||
```bash
|
||||
# 1. 克隆仓库
|
||||
git clone https://github.com/Wei-Shaw/sub2api.git
|
||||
cd sub2api
|
||||
|
||||
# 2. 安装 pnpm(如果还没有安装)
|
||||
npm install -g pnpm
|
||||
|
||||
# 3. 编译前端
|
||||
cd frontend
|
||||
pnpm install
|
||||
pnpm run build
|
||||
# 构建产物输出到 ../backend/internal/web/dist/
|
||||
|
||||
# 4. 编译后端(嵌入前端)
|
||||
cd ../backend
|
||||
VERSION="$(./scripts/resolve-version.sh)"
|
||||
go build -tags embed -ldflags="-X main.Version=${VERSION}" -o sub2api ./cmd/server
|
||||
|
||||
# 5. 创建配置文件
|
||||
cp ../deploy/config.example.yaml ./config.yaml
|
||||
|
||||
# 6. 编辑配置
|
||||
nano config.yaml
|
||||
```
|
||||
|
||||
> **注意:** `-tags embed` 参数会将前端嵌入到二进制文件中。不使用此参数编译的程序将不包含前端界面。
|
||||
|
||||
**`config.yaml` 关键配置:**
|
||||
|
||||
```yaml
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
mode: "release"
|
||||
|
||||
database:
|
||||
host: "localhost"
|
||||
port: 5432
|
||||
user: "postgres"
|
||||
password: "your_password"
|
||||
dbname: "sub2api"
|
||||
|
||||
redis:
|
||||
host: "localhost"
|
||||
port: 6379
|
||||
password: ""
|
||||
|
||||
jwt:
|
||||
secret: "change-this-to-a-secure-random-string"
|
||||
expire_hour: 24
|
||||
|
||||
default:
|
||||
user_concurrency: 5
|
||||
user_balance: 0
|
||||
api_key_prefix: "sk-"
|
||||
rate_multiplier: 1.0
|
||||
```
|
||||
|
||||
### Sora 功能状态(暂不可用)
|
||||
|
||||
> ⚠️ 当前 Sora 相关功能因上游接入与媒体链路存在技术问题,暂时不可用。
|
||||
> 现阶段请勿在生产环境依赖 Sora 能力。
|
||||
> 文档中的 `gateway.sora_*` 配置仅作预留,待技术问题修复后再恢复可用。
|
||||
|
||||
### Sora 媒体签名 URL(功能恢复后可选)
|
||||
|
||||
当配置 `gateway.sora_media_signing_key` 且 `gateway.sora_media_signed_url_ttl_seconds > 0` 时,网关会将 Sora 输出的媒体地址改写为临时签名 URL(`/sora/media-signed/...`)。这样无需 API Key 即可在浏览器中直接访问,且具备过期控制与防篡改能力(签名包含 path + query)。
|
||||
|
||||
```yaml
|
||||
gateway:
|
||||
# /sora/media 是否强制要求 API Key(默认 false)
|
||||
sora_media_require_api_key: false
|
||||
# 媒体临时签名密钥(为空则禁用签名)
|
||||
sora_media_signing_key: "your-signing-key"
|
||||
# 临时签名 URL 有效期(秒)
|
||||
sora_media_signed_url_ttl_seconds: 900
|
||||
```
|
||||
|
||||
> 若未配置签名密钥,`/sora/media-signed` 将返回 503。
|
||||
> 如需更严格的访问控制,可将 `sora_media_require_api_key` 设为 true,仅允许携带 API Key 的 `/sora/media` 访问。
|
||||
|
||||
访问策略说明:
|
||||
- `/sora/media`:内部调用或客户端携带 API Key 才能下载
|
||||
- `/sora/media-signed`:外部可访问,但有签名 + 过期控制
|
||||
|
||||
`config.yaml` 还支持以下安全相关配置:
|
||||
|
||||
- `cors.allowed_origins` 配置 CORS 白名单
|
||||
- `security.url_allowlist` 配置上游/价格数据/CRS 主机白名单
|
||||
- `security.url_allowlist.enabled` 可关闭 URL 校验(慎用)
|
||||
- `security.url_allowlist.allow_insecure_http` 关闭校验时允许 HTTP URL
|
||||
- `security.url_allowlist.allow_private_hosts` 允许私有/本地 IP 地址
|
||||
- `security.response_headers.enabled` 可启用可配置响应头过滤(关闭时使用默认白名单)
|
||||
- `security.csp` 配置 Content-Security-Policy
|
||||
- `billing.circuit_breaker` 计费异常时 fail-closed
|
||||
- `server.trusted_proxies` 启用可信代理解析 X-Forwarded-For
|
||||
- `turnstile.required` 在 release 模式强制启用 Turnstile
|
||||
|
||||
**网关防御纵深建议(重点)**
|
||||
|
||||
- `gateway.upstream_response_read_max_bytes`:限制非流式上游响应读取大小(默认 `8MB`),用于防止异常响应导致内存放大。
|
||||
- `gateway.proxy_probe_response_read_max_bytes`:限制代理探测响应读取大小(默认 `1MB`)。
|
||||
- `gateway.gemini_debug_response_headers`:默认 `false`,仅在排障时短时开启,避免高频请求日志开销。
|
||||
- `/auth/register`、`/auth/login`、`/auth/login/2fa`、`/auth/send-verify-code` 已提供服务端兜底限流(Redis 故障时 fail-close)。
|
||||
- 推荐将 WAF/CDN 作为第一层防护,服务端限流与响应读取上限作为第二层兜底;两层同时保留,避免旁路流量与误配置风险。
|
||||
|
||||
**⚠️ 安全警告:HTTP URL 配置**
|
||||
|
||||
当 `security.url_allowlist.enabled=false` 时,系统仅执行最小 URL 校验,且**默认允许 HTTP URL**(开发友好模式,Docker Compose 部署的默认值一致)。生产环境建议显式收紧为仅允许 HTTPS:
|
||||
|
||||
```yaml
|
||||
security:
|
||||
url_allowlist:
|
||||
enabled: false # 禁用白名单检查
|
||||
allow_insecure_http: false # 仅允许 HTTPS(生产环境推荐)
|
||||
```
|
||||
|
||||
**或通过环境变量:**
|
||||
|
||||
```bash
|
||||
SECURITY_URL_ALLOWLIST_ENABLED=false
|
||||
SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=false
|
||||
```
|
||||
|
||||
**允许 HTTP 的风险:**
|
||||
- API 密钥和数据以**明文传输**(可被截获)
|
||||
- 易受**中间人攻击 (MITM)**
|
||||
- **不适合生产环境**
|
||||
|
||||
**适用场景:**
|
||||
- ✅ 开发/测试环境的本地服务器(http://localhost)
|
||||
- ✅ 内网可信端点
|
||||
- ✅ 获取 HTTPS 前测试账号连通性
|
||||
- ❌ 生产环境(仅使用 HTTPS)
|
||||
|
||||
**设置 `allow_insecure_http: false` 后,HTTP URL 会返回如下错误:**
|
||||
```
|
||||
Invalid base URL: invalid url scheme: http
|
||||
```
|
||||
|
||||
如关闭 URL 校验或响应头过滤,请加强网络层防护:
|
||||
- 出站访问白名单限制上游域名/IP
|
||||
- 阻断私网/回环/链路本地地址
|
||||
- 强制仅允许 TLS 出站
|
||||
- 在反向代理层移除敏感响应头
|
||||
|
||||
#### ⚠️ 重要:创建管理员账号
|
||||
|
||||
初始管理员账号**只能通过 setup 向导创建**(首次启动时访问 `http://<host>:8080`)。`config.yaml` 中的 `default.admin_email` / `default.admin_password` 字段**不会被用来创建管理员**——它们只是出于历史原因保留在模板里。
|
||||
|
||||
由于上面第 5 步预先创建了 `config.yaml`,**setup 向导在首次启动时会被跳过**:服务检测到 config 已存在,会直接进入正常模式,此时 `users` 表为空,首次登录会返回 `invalid email or password`。
|
||||
|
||||
**创建管理员的两种方式:**
|
||||
|
||||
1. **推荐——让向导自动生成 `config.yaml`:** 跳过上面的第 5 步(不要执行 `cp`)。直接运行 `./sub2api`,访问 `http://localhost:8080`,向导会引导你完成数据库、Redis 和管理员账号配置,并自动写出 `config.yaml`。
|
||||
|
||||
2. **如果你已经创建了 `config.yaml`:** 首次启动前先把它临时移走以触发向导,完成后再恢复:
|
||||
```bash
|
||||
mv config.yaml config.yaml.bak
|
||||
./sub2api # 向导在 http://localhost:8080 启动,并生成新的 config.yaml
|
||||
# 向导完成后 Ctrl+C 停服,再恢复你的配置:
|
||||
mv config.yaml.bak config.yaml
|
||||
./sub2api # 重启进入正常模式,用刚创建的管理员登录
|
||||
```
|
||||
|
||||
```bash
|
||||
# 6. 运行应用
|
||||
./sub2api
|
||||
```
|
||||
|
||||
#### HTTP/2 (h2c) 与 HTTP/1.1 回退
|
||||
|
||||
后端明文端口默认支持 h2c,并保留 HTTP/1.1 回退用于 WebSocket 与旧客户端。浏览器通常不支持 h2c,性能收益主要在反向代理或内网链路。
|
||||
|
||||
**反向代理示例(Caddy):**
|
||||
|
||||
```caddyfile
|
||||
transport http {
|
||||
versions h2c h1
|
||||
}
|
||||
```
|
||||
|
||||
**验证:**
|
||||
|
||||
```bash
|
||||
# h2c prior knowledge
|
||||
curl --http2-prior-knowledge -I http://localhost:8080/health
|
||||
# HTTP/1.1 回退
|
||||
curl --http1.1 -I http://localhost:8080/health
|
||||
# WebSocket 回退验证(需管理员 token)
|
||||
websocat -H="Sec-WebSocket-Protocol: sub2api-admin, jwt.<ADMIN_TOKEN>" ws://localhost:8080/api/v1/admin/ops/ws/qps
|
||||
```
|
||||
|
||||
#### 开发模式
|
||||
|
||||
```bash
|
||||
# 后端(支持热重载)
|
||||
cd backend
|
||||
go run ./cmd/server
|
||||
|
||||
# 前端(支持热重载)
|
||||
cd frontend
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
#### 代码生成
|
||||
|
||||
修改 `backend/ent/schema` 后,需要重新生成 Ent + Wire:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
go generate ./ent
|
||||
go generate ./cmd/server
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 简易模式
|
||||
|
||||
简易模式适合个人开发者或内部团队快速使用,不依赖完整 SaaS 功能。
|
||||
|
||||
- 启用方式:设置环境变量 `RUN_MODE=simple`
|
||||
- 功能差异:隐藏 SaaS 相关功能,跳过计费流程
|
||||
- 安全注意事项:生产环境需同时设置 `SIMPLE_MODE_CONFIRM=true` 才允许启动
|
||||
|
||||
---
|
||||
|
||||
## Antigravity 使用说明
|
||||
|
||||
Sub2API 支持 [Antigravity](https://antigravity.so/) 账户,授权后可通过专用端点访问 Claude 和 Gemini 模型。
|
||||
|
||||
### 专用端点
|
||||
|
||||
| 端点 | 模型 |
|
||||
|------|------|
|
||||
| `/antigravity/v1/messages` | Claude 模型 |
|
||||
| `/antigravity/v1beta/` | Gemini 模型 |
|
||||
|
||||
### Claude Code 配置示例
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_BASE_URL="http://localhost:8080/antigravity"
|
||||
export ANTHROPIC_AUTH_TOKEN="sk-xxx"
|
||||
```
|
||||
|
||||
### 混合调度模式
|
||||
|
||||
Antigravity 账户支持可选的**混合调度**功能。开启后,通用端点 `/v1/messages` 和 `/v1beta/` 也会调度该账户。
|
||||
|
||||
> **⚠️ 注意**:Anthropic Claude 和 Antigravity Claude **不能在同一上下文中混合使用**,请通过分组功能做好隔离。
|
||||
|
||||
---
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
sub2api/
|
||||
├── backend/ # Go 后端服务
|
||||
│ ├── cmd/server/ # 应用入口
|
||||
│ ├── internal/ # 内部模块
|
||||
│ │ ├── config/ # 配置管理
|
||||
│ │ ├── model/ # 数据模型
|
||||
│ │ ├── service/ # 业务逻辑
|
||||
│ │ ├── handler/ # HTTP 处理器
|
||||
│ │ └── gateway/ # API 网关核心
|
||||
│ └── resources/ # 静态资源
|
||||
│
|
||||
├── frontend/ # Vue 3 前端
|
||||
│ └── src/
|
||||
│ ├── api/ # API 调用
|
||||
│ ├── stores/ # 状态管理
|
||||
│ ├── views/ # 页面组件
|
||||
│ └── components/ # 通用组件
|
||||
│
|
||||
└── deploy/ # 部署文件
|
||||
├── docker-compose.yml # Docker Compose 配置
|
||||
├── .env.example # Docker Compose 环境变量
|
||||
├── config.example.yaml # 二进制部署完整配置文件
|
||||
└── install.sh # 一键安装脚本
|
||||
```
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://star-history.com/#Wei-Shaw/sub2api&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=Wei-Shaw/sub2api&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=Wei-Shaw/sub2api&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=Wei-Shaw/sub2api&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目基于 [GNU 宽通用公共许可证 v3.0](LICENSE)(或更高版本)授权。
|
||||
|
||||
Copyright (c) 2026 Wesley Liddick
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
**如果觉得有用,请给个 Star 支持一下!**
|
||||
|
||||
</div>
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
**サブスクリプションクォータ配分のための AI API ゲートウェイプラットフォーム**
|
||||
|
||||
[English](README.md) | 日本語
|
||||
[English](README.md) | [中文](README_CN.md) | 日本語
|
||||
|
||||
</div>
|
||||
|
||||
@@ -23,26 +23,11 @@
|
||||
- **🚨 利用規約のリスク**:本プロジェクトの使用は、Anthropic をはじめとする上流プロバイダーの利用規約に違反する可能性があります。ご利用前に各プロバイダーのユーザー規約を必ずご確認ください。使用により生じるすべてのリスクはユーザーご自身が負うものとします。
|
||||
- **⚖️ 法令遵守**:お住まいの国または地域の法令を遵守した上で本プロジェクトをご利用ください。いかなる違法な目的での使用も固く禁じます。
|
||||
- **📖 免責事項**:本プロジェクトは技術的な学習および研究の目的でのみ提供されます。本プロジェクトの使用により生じたアカウントの停止、サービスの中断、データの損失、その他一切の直接的または間接的な損害について、作者は一切の責任を負いません。
|
||||
|
||||
## 概要
|
||||
|
||||
Sub2API は、AI 製品のサブスクリプションから API クォータを配分・管理するために設計された AI API ゲートウェイプラットフォームです。ユーザーはプラットフォームが生成した API キーを通じて上流の AI サービスにアクセスでき、プラットフォームは認証、課金、負荷分散、リクエスト転送を処理します。
|
||||
|
||||
## 機能
|
||||
|
||||
- **マルチアカウント管理** - 複数の上流アカウントタイプ(OAuth、APIキー)をサポート
|
||||
- **APIキー配布** - ユーザー向けの APIキーの生成と管理
|
||||
- **精密な課金** - トークンレベルの使用量追跡とコスト計算
|
||||
- **スマートスケジューリング** - スティッキーセッション付きのインテリジェントなアカウント選択
|
||||
- **同時実行制御** - ユーザーごと・アカウントごとの同時実行数制限
|
||||
- **レート制限** - 設定可能なリクエスト数およびトークンレート制限
|
||||
- **内蔵決済システム** - EasyPay、Alipay、WeChat Pay、Stripe に対応。ユーザーのセルフサービスチャージが可能で、別途決済サービスのデプロイは不要([設定ガイド](docs/PAYMENT.md))
|
||||
- **管理ダッシュボード** - 監視・管理のための Web インターフェース
|
||||
- **外部システム連携** - 外部システム(チケット管理など)を iframe 経由で管理ダッシュボードに埋め込み可能
|
||||
- **🚫 商用利用の非許諾**:本プロジェクトの開発者は、いかなる個人または組織に対しても、本プロジェクトを利用したいかなる形態の商業運営も一切許諾していません。本プロジェクトの名義で、または本プロジェクトに基づいて行われる商業行為はすべて本プロジェクトおよびその開発者とは無関係であり、それにより生じる一切の紛争、損失、法的責任は行為者自身が負うものとします。
|
||||
|
||||
## ❤️ スポンサー
|
||||
|
||||
> [こちらに掲載しませんか?](mailto:support@pincc.ai)
|
||||
> [こちらに掲載しませんか?](mailto:support@sub2api.org)
|
||||
|
||||
<table>
|
||||
|
||||
@@ -61,16 +46,6 @@ Sub2API は、AI 製品のサブスクリプションから API クォータを
|
||||
<td>AIGoCode のご支援に感謝します!AIGoCode は Claude Code、Codex、最新の Gemini モデルを統合したオールインワンプラットフォームで、安定的かつ効率的でコストパフォーマンスに優れた AI コーディングサービスを提供します。柔軟なサブスクリプションプラン、アカウント停止リスクゼロ、VPN 不要の直接アクセス、超高速レスポンスが特長です。AIGoCode は sub2api ユーザー向けに特別特典を用意しています:<a href="https://aigocode.com/invite/SUB2API">こちらのリンク</a>から登録すると、初回チャージ時に 10% のボーナスクレジットを追加プレゼント!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://code.silkapi.com/register?aff=SUB2API"><img src="assets/partners/logos/silkapi.png" alt="silkapi" width="150"></a></td>
|
||||
<td>SilkAPI のご支援に感謝します!<a href="https://code.silkapi.com/register?aff=SUB2API">SilkAPI</a> は Sub2API をベースに構築された中継サービスで、高速かつ安定した Codex API 中継の提供に特化しています。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://ylscode.com/"><img src="assets/partners/logos/ylscode.png" alt="ylscode" width="150"></a></td>
|
||||
<td>YLS Code のご支援に感謝します!<a href="https://ylscode.com/">YLS Code</a> は安全なエンタープライズグレードの Coding Agent 生産性サービスの構築に取り組んでおり、安定かつ高速な Codex / Claude / Gemini サブスクリプションサービスと従量課金 API の柔軟なプランを提供しています。期間限定で新規登録者に 3 日間の Codex 試用特典をプレゼント中!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=KMVZQM"><img src="assets/partners/logos/AICodeMirror.jpg" alt="AICodeMirror" width="150"></a></td>
|
||||
<td>AICodeMirror のご支援に感謝します!AICodeMirror は Claude Code / Codex / Gemini CLI の公式高安定性リレーサービスを提供しており、エンタープライズグレードの同時実行、迅速な請求書発行、24時間年中無休の専属テクニカルサポートを備えています。Claude Code / Codex / Gemini の公式チャネルを定価の 38% / 2% / 9% で利用可能、チャージ時にはさらに追加割引!AICodeMirror は sub2api ユーザー向けに特別特典を提供中:<a href="https://www.aicodemirror.com/register?invitecode=KMVZQM">こちらのリンク</a>から登録すると、初回チャージが 20% オフ、法人のお客様は最大 25% オフ!</td>
|
||||
@@ -99,12 +74,6 @@ Sub2API は、AI 製品のサブスクリプションから API クォータを
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://runapi.co/register?aff=fu2E"><img src="assets/partners/logos/runapi.png" alt="RunAPI" width="150"></a></td>
|
||||
<td>RunAPI のご支援に感謝します!<a href="https://runapi.co/register?aff=fu2E">RunAPI</a> は効率的で安定した API プラットフォームで、OpenRouter の代替として利用できます。1つの API キーで OpenAI、Claude、Gemini、DeepSeek、Grok など 150以上の主要モデルにアクセスでき、価格は最低 10% から。非常に安定しており、Claude Code や OpenClaw などのツールとシームレスに互換します。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://unity2.ai/register?source=sub2api"><img src="assets/partners/logos/unity2.png" alt="unity2" width="150"></a></td>
|
||||
<td>Unity2 のご支援に感謝します!<a href="https://unity2.ai/register?source=sub2api">Unity2</a> は個人開発者、チーム、企業向けの高性能 AI モデル API 中継プラットフォームです。中国の大手企業に長期にわたりサービスを提供しており、1日あたり 300 億以上のトークン呼び出しを処理し、5000 RPM 級の高並列性をサポートします。1つの API キーで Claude Code、Codex、OpenAI モデル、IDE プラグイン、Agent ワークフローなど様々なシナリオに対応できます。エンタープライズグレードの安定供給能力を備え、高並列・継続的な呼び出し・チームの集中購入シーンでも低レイテンシと高可用性を維持します。残高課金、組み合わせサブスクリプション、初回チャージ特典、企業向け請求書発行、専属 1v1 サポートにも対応しており、個人の頻繁な利用にも企業の長期導入にも適しています。今 Unity2.ai に登録すると $2 の残高、公式グループに参加するとさらに $10 の残高がもらえ、合計最大 $12 の無料クレジットを獲得できます — 試用後に長期利用したい方に最適です。<a href="https://unity2.ai/register?source=sub2api">登録リンク</a>
|
||||
@@ -123,8 +92,75 @@ Sub2API は、AI 製品のサブスクリプションから API クォータを
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://apikl.ai"><img src="assets/partners/logos/apikl.png" alt="apikl" width="150"></a></td>
|
||||
<td>Apikl のご支援に感謝します!Sub2API をベースに構築された本プラットフォームは、開発者向けに Codex / Claude シリーズモデルの中継サービスを提供しています。長期安定性、高速直結、高いコストパフォーマンスを重視し、従量課金の残高ベース課金、エンタープライズグレードの正規請求書、1対1の専属サポートを提供します。<a href="https://apikl.ai">今すぐ登録</a>でチャージ 1:1 ボーナス — 残高が倍に!
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://tokeneum.ai"><img src="assets/partners/logos/tokeneum.png" alt="tokeneum" width="150"></a></td>
|
||||
<td>TokenEum のご支援に感謝します!<a href="https://tokeneum.ai">TokenEum</a> は総合的な AI モデル集約プラットフォームおよびインテリジェントエージェント開発企業です。Claude、Gemini、OpenAI などの世界トップクラスのモデルに加え、GLM、Qwen、Kimi などの主要なオープンソースモデルも集約しており、品質と価格の異なる豊富な選択肢を提供してあらゆるニーズに対応します。また、Seedance2.0 や Happy Horse などの最先端の動画生成モデルも利用可能です。TokenEum は透明性と誠実なビジネスを重視し、すべてのモデル情報の正確性と信頼性を保証します。<a href="https://tokeneum.ai">tokeneum.ai</a> でぜひお試しください。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://666api.work/sub2api"><img src="assets/partners/logos/666api.jpg" alt="666api" width="150"></a></td>
|
||||
<td>666api のご支援に感謝します!<a href="https://666api.work/sub2api">666api</a> はオールインワンプラットフォームで、以下を提供しています:⚡ API 中継 — グローバルモデルへの従量課金アクセス、100% 公式ソースから直接供給、公式価格の最大 75% オフ。独占特典:Zhipu GLM 50% オフ・DeepSeek V4-pro 50% オフ・Seedance2.0 8% オフ(ホワイトリスト)・HappyHorse Overseas 30% オフ(ホワイトリスト)🔑 GPT サブスクリプションアカウント — 同源 IP 込み・グローバル住宅 IP 💰 請求書発行対応
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://dis.chatdesks.cn/chatdesk/hsyqsub2api.html"><img src="assets/partners/logos/byteplus.png" alt="BytePlus" width="150"></a></td>
|
||||
<td>Dola seed のご支援に感謝します!Dola Seed 2.0 は、ByteDance がグローバル市場向けに独自開発した全モーダル汎用大規模モデルです。統一されたマルチモーダルアーキテクチャに基づき、テキスト・画像・音声・動画の共同理解と生成をサポートします。エージェント協調をネイティブに有効化し、強力な推論・長期タスク実行・ツール統合・コーディング能力を備えています。スマートコックピット、パーソナルアシスタント、教育、カスタマーサポート、マーケティング、リテールなど多様なシナリオに広く適用可能です。マルチモーダル知覚、エンドツーエンドの複雑タスク配信、安定したインタラクション、データセキュリティに優れ、ModelArk プラットフォームを通じて容易にアクセス・デプロイできます。<a href="https://dis.chatdesks.cn/chatdesk/hsyqsub2api.html">こちらのリンク</a>から登録すると、各モデルにつき 50 万トークンの無料推論クォータを獲得できます。<a href="https://dis.chatdesks.cn/chatdesk/hsyqsub2api.html"> >>中国大陸地域の開発者はこちらをクリック</a></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://sui-xiang.com/"><img src="assets/partners/logos/sui-xiang.jpg" alt="sui-xiang" width="150"></a></td>
|
||||
<td>随想AI ゲートウェイのご支援に感謝します!<a href="https://sui-xiang.com/">随想AI ゲートウェイ</a> は信頼性と効率に優れた API 中継サービスプロバイダーで、Claude、Codex、Gemini などの中継サービスを提供しています。プライバシー重視の中継ステーション・データ転売なし・モデル水増しなし、プライバシー・透明性・超高速アフターサービス。新規アカウント登録後、毎日サインインで 0.5 元のテストクレジットがもらえ、チャージは 1:1、サブスクリプション不要、従量課金。マルチライン冗長、クロスリージョン災害復旧、自動フェイルオーバー、長時間 SSE 接続が途切れません。99.9% の可用性、重要な呼び出しは決して遅れません。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.miyaip.com/?invitecode=sub2api"><img src="assets/partners/logos/miyaip.png" alt="miyaip" width="150"></a></td>
|
||||
<td>MiyaIP のご支援に感謝します!<a href="https://www.miyaip.com/?invitecode=sub2api">MiyaIP</a> はグローバル住宅プロキシネットワークサービスに特化したプラットフォームで、企業開発者、越境ビジネスチーム、AI アプリケーションユーザーに高品質で純粋な海外住宅 IP リソースを提供することに注力しています。AI プラットフォーム、海外 SaaS、その他のオンラインサービスに安定した独立した海外ネットワーク環境を提供し、複数地域でのアクセステストやプロジェクト環境の分離をサポートします。海外 AI サービスへのアクセスが必要な開発・テストシナリオに最適です。例:AI モデルプラットフォームアクセス、AI 開発テスト、AI SaaS サービス利用、AI API デバッグ、複数地域のネットワーク環境検証など。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://anpin.ai"><img src="assets/partners/logos/anpin.jpg" alt="anpin" width="150"></a></td>
|
||||
<td><a href="https://anpin.ai">anpin.ai</a> のご支援に感謝します!anpin.ai は AI の普及推進に取り組むハイエンド AI 中継サービスプラットフォームです。先進的な技術アーキテクチャとグローバル分散デプロイメントにより、国際トップクラスの大規模モデルへの高速直通ルートを提供します。<br>
|
||||
自社構築の一次アカウントプール:1-3秒の超高速応答、同業者への配布に対応<br>
|
||||
極めて高い安定性:マルチライン・インテリジェントルーティング + 冗長バックアップシステムにより、年中無休の高可用性運用を確保;<br>
|
||||
モデルの真正性:一切のコンテンツ介入や二次フィルタリングを行わず、最も純粋で強力なネイティブモデル能力を体験できます。<br>
|
||||
チャージ 1:1、エンタープライズグレードサービスは請求書発行可能。安品 AI は単なる中継所ではなく、最先端のインテリジェント世界へと繋がる安全・信頼性・高効率の橋渡し役です。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.proxy4free.com/?keyword=4yjqecpc"><img src="assets/partners/logos/proxy4free.png" alt="proxy4free" width="150"></a></td>
|
||||
<td>Proxy4Free のご支援に感謝します!Proxy4Free は開発者と AI アプリケーション向けのデータプロキシサービスプロバイダーで、住宅プロキシ、静的住宅プロキシ、ISP プロキシ、データセンタープロキシなど多様なプロキシソリューションを提供しており、Web Scraping、Browser Automation、AI Agent などのシナリオに適しています。グローバル IP リソース、安定した接続、柔軟な切り替えをサポートし、開発者のデータ収集成功率の向上と IP ブロックリスクの低減を支援します。<a href="https://www.proxy4free.com/?keyword=4yjqecpc">こちらのリンクから登録</a>して、より安定した効率的な自動化ワークフローを簡単に構築しましょう。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
## 概要
|
||||
|
||||
Sub2API は、AI 製品のサブスクリプションから API クォータを配分・管理するために設計された AI API ゲートウェイプラットフォームです。ユーザーはプラットフォームが生成した API キーを通じて上流の AI サービスにアクセスでき、プラットフォームは認証、課金、負荷分散、リクエスト転送を処理します。
|
||||
|
||||
## 機能
|
||||
|
||||
- **マルチアカウント管理** - 複数の上流アカウントタイプ(OAuth、APIキー)をサポート
|
||||
- **APIキー配布** - ユーザー向けの APIキーの生成と管理
|
||||
- **精密な課金** - トークンレベルの使用量追跡とコスト計算
|
||||
- **スマートスケジューリング** - スティッキーセッション付きのインテリジェントなアカウント選択
|
||||
- **同時実行制御** - ユーザーごと・アカウントごとの同時実行数制限
|
||||
- **レート制限** - 設定可能なリクエスト数およびトークンレート制限
|
||||
- **内蔵決済システム** - EasyPay、Alipay、WeChat Pay、Stripe に対応。ユーザーのセルフサービスチャージが可能で、別途決済サービスのデプロイは不要([設定ガイド](docs/PAYMENT.md))
|
||||
- **管理ダッシュボード** - 監視・管理のための Web インターフェース
|
||||
- **外部システム連携** - 外部システム(チケット管理など)を iframe 経由で管理ダッシュボードに埋め込み可能
|
||||
|
||||
## エコシステム
|
||||
|
||||
Sub2API を拡張・統合するコミュニティプロジェクト:
|
||||
@@ -421,7 +457,8 @@ pnpm run build
|
||||
|
||||
# 4. フロントエンドを組み込んだバックエンドをビルド
|
||||
cd ../backend
|
||||
go build -tags embed -o sub2api ./cmd/server
|
||||
VERSION="$(./scripts/resolve-version.sh)"
|
||||
go build -tags embed -ldflags="-X main.Version=${VERSION}" -o sub2api ./cmd/server
|
||||
|
||||
# 5. 設定ファイルを作成
|
||||
cp ../deploy/config.example.yaml ./config.yaml
|
||||
@@ -484,20 +521,20 @@ default:
|
||||
|
||||
**⚠️ セキュリティ警告: HTTP URL 設定**
|
||||
|
||||
`security.url_allowlist.enabled=false` の場合、システムはデフォルトで最小限の URL バリデーションを行い、**HTTP URL を拒否**して HTTPS のみを許可します。HTTP URL を許可するには(開発環境や内部テスト用など)、以下を明示的に設定する必要があります:
|
||||
`security.url_allowlist.enabled=false` の場合、システムは最小限の URL バリデーションのみを行い、**デフォルトで HTTP URL を許可**します(開発フレンドリーモード。Docker Compose デプロイのデフォルトも同じです)。本番環境では、以下のように明示的に HTTPS のみに制限することを推奨します:
|
||||
|
||||
```yaml
|
||||
security:
|
||||
url_allowlist:
|
||||
enabled: false # 許可リストチェックを無効化
|
||||
allow_insecure_http: true # HTTP URL を許可(⚠️ セキュリティリスクあり)
|
||||
allow_insecure_http: false # HTTPS のみ許可(本番環境推奨)
|
||||
```
|
||||
|
||||
**または環境変数で設定:**
|
||||
|
||||
```bash
|
||||
SECURITY_URL_ALLOWLIST_ENABLED=false
|
||||
SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=true
|
||||
SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=false
|
||||
```
|
||||
|
||||
**HTTP を許可するリスク:**
|
||||
@@ -511,7 +548,7 @@ SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=true
|
||||
- ✅ HTTPS 取得前のアカウント接続テスト
|
||||
- ❌ 本番環境(HTTPS のみを使用)
|
||||
|
||||
**この設定なしで表示されるエラー例:**
|
||||
**`allow_insecure_http: false` 設定時に HTTP URL で表示されるエラー例:**
|
||||
```
|
||||
Invalid base URL: invalid url scheme: http
|
||||
```
|
||||
@@ -522,6 +559,25 @@ URL バリデーションまたはレスポンスヘッダーフィルタリン
|
||||
- TLS のみのアウトバウンドトラフィックを強制
|
||||
- プロキシで機密性の高い上流レスポンスヘッダーを除去
|
||||
|
||||
#### ⚠️ 重要:管理者アカウントの作成
|
||||
|
||||
初期管理者アカウントは**セットアップウィザード経由でのみ作成**されます(初回起動時に `http://<host>:8080` にアクセス)。`config.yaml` の `default.admin_email` / `default.admin_password` フィールドは**管理者作成には使われません**。テンプレートに残っているのは歴史的経緯によるものです。
|
||||
|
||||
上記ステップ 5 で事前に `config.yaml` を作成しているため、**初回起動時にセットアップウィザードはスキップされます**。サーバーは既存の config を検出して通常モードで直接起動し、この時点では `users` テーブルが空のため、初回ログインは `invalid email or password` を返します。
|
||||
|
||||
**管理者アカウントを作成する 2 つの方法:**
|
||||
|
||||
1. **推奨 — ウィザードに `config.yaml` を自動生成させる:** 上記ステップ 5 をスキップします(`cp` を実行しない)。`./sub2api` を直接起動し、`http://localhost:8080` にアクセスすると、セットアップウィザードがデータベース・Redis・管理者アカウントの設定を案内し、`config.yaml` を自動生成します。
|
||||
|
||||
2. **すでに `config.yaml` を作成してしまった場合:** 初回起動前に一時的に退避してウィザードを発生させ、完了後に戻します:
|
||||
```bash
|
||||
mv config.yaml config.yaml.bak
|
||||
./sub2api # ウィザードが http://localhost:8080 で起動し、新しい config.yaml を生成します
|
||||
# ウィザード完了後、Ctrl+C でサーバーを停止し、設定を復元します:
|
||||
mv config.yaml.bak config.yaml
|
||||
./sub2api # 通常モードで再起動し、作成した管理者でログインします
|
||||
```
|
||||
|
||||
```bash
|
||||
# 6. アプリケーションを実行
|
||||
./sub2api
|
||||
@@ -585,12 +641,6 @@ Antigravity アカウントはオプションの**ハイブリッドスケジュ
|
||||
|
||||
> **⚠️ 警告**: Anthropic Claude と Antigravity Claude は**同じ会話コンテキスト内で混在させることはできません**。グループを使用して適切に分離してください。
|
||||
|
||||
### 既知の問題
|
||||
|
||||
Claude Code では、Plan Mode を自動的に終了できません。(通常、ネイティブの Claude API を使用する場合、計画が完了すると Claude Code はユーザーに計画を承認または拒否するオプションをポップアップ表示します。)
|
||||
|
||||
**回避策**: `Shift + Tab` を押して手動で Plan Mode を終了し、計画を承認または拒否するためのレスポンスを入力してください。
|
||||
|
||||
---
|
||||
|
||||
## プロジェクト構成
|
||||
@@ -621,16 +671,6 @@ sub2api/
|
||||
└── install.sh # ワンクリックインストールスクリプト
|
||||
```
|
||||
|
||||
## 免責事項
|
||||
|
||||
> **本プロジェクトをご利用の前に、以下をよくお読みください:**
|
||||
>
|
||||
> :rotating_light: **利用規約違反のリスク**: 本プロジェクトの使用は Anthropic の利用規約に違反する可能性があります。使用前に Anthropic のユーザー契約をよくお読みください。本プロジェクトの使用に起因するすべてのリスクは、ユーザー自身が負うものとします。
|
||||
>
|
||||
> :book: **免責事項**: 本プロジェクトは技術的な学習および研究目的のみで提供されています。作者は、本プロジェクトの使用によるアカウント停止、サービス中断、その他の損失について一切の責任を負いません。
|
||||
|
||||
---
|
||||
|
||||
## スター履歴
|
||||
|
||||
<a href="https://star-history.com/#Wei-Shaw/sub2api&Date">
|
||||
|
||||
|
After Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 26 KiB |
@@ -15,7 +15,8 @@ RUN go mod download
|
||||
COPY . .
|
||||
|
||||
# 构建应用
|
||||
RUN go build -o main ./cmd/server/
|
||||
RUN VERSION_VALUE="$(./scripts/resolve-version.sh)" && \
|
||||
go build -ldflags="-s -w -X main.Version=${VERSION_VALUE}" -o main ./cmd/server/
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 8080
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.PHONY: build generate test test-unit test-integration test-e2e
|
||||
|
||||
VERSION ?= $(shell tr -d '\r\n' < ./cmd/server/VERSION)
|
||||
VERSION ?= $(shell ./scripts/resolve-version.sh)
|
||||
LDFLAGS ?= -s -w -X main.Version=$(VERSION)
|
||||
|
||||
build:
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.1.137
|
||||
0.1.144
|
||||
|
||||
@@ -94,6 +94,7 @@ func provideCleanup(
|
||||
openaiOAuth *service.OpenAIOAuthService,
|
||||
geminiOAuth *service.GeminiOAuthService,
|
||||
antigravityOAuth *service.AntigravityOAuthService,
|
||||
grokOAuth *service.GrokOAuthService,
|
||||
openAIGateway *service.OpenAIGatewayService,
|
||||
scheduledTestRunner *service.ScheduledTestRunnerService,
|
||||
backupSvc *service.BackupService,
|
||||
@@ -222,6 +223,12 @@ func provideCleanup(
|
||||
antigravityOAuth.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"GrokOAuthService", func() error {
|
||||
if grokOAuth != nil {
|
||||
grokOAuth.Stop()
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{"OpenAIWSPool", func() error {
|
||||
if openAIGateway != nil {
|
||||
openAIGateway.CloseOpenAIWSPool()
|
||||
|
||||
@@ -141,7 +141,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
privacyClientFactory := providePrivacyClientFactory()
|
||||
openAIOAuthService := service.ProvideOpenAIOAuthService(proxyRepository, openAIOAuthClient, privacyClientFactory)
|
||||
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, balanceNotifyService, settingService, serviceUserPlatformQuotaRepository)
|
||||
grokOAuthClient := repository.NewGrokOAuthClient()
|
||||
grokOAuthService := service.NewGrokOAuthService(proxyRepository, grokOAuthClient)
|
||||
grokTokenProvider := service.ProvideGrokTokenProvider(accountRepository, geminiTokenCache, grokOAuthService, oAuthRefreshAPI, tempUnschedCache)
|
||||
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService, openAITokenProvider, grokTokenProvider, modelPricingResolver, channelService, balanceNotifyService, settingService, serviceUserPlatformQuotaRepository)
|
||||
geminiOAuthClient := repository.NewGeminiOAuthClient(configConfig)
|
||||
geminiCliCodeAssistClient := repository.NewGeminiCliCodeAssistClient()
|
||||
driveClient := repository.NewGeminiDriveClient()
|
||||
@@ -178,9 +181,11 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService)
|
||||
claudeUsageFetcher := repository.NewClaudeUsageFetcher(httpUpstream)
|
||||
antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository)
|
||||
grokQuotaFetcher := service.NewGrokQuotaFetcher()
|
||||
openAIQuotaService := service.ProvideOpenAIQuotaService(accountRepository, proxyRepository, openAITokenProvider, privacyClientFactory)
|
||||
usageCache := service.NewUsageCache()
|
||||
accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, usageCache, identityCache, tlsFingerprintProfileService)
|
||||
accountTestService := service.NewAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService)
|
||||
accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, grokQuotaFetcher, openAIQuotaService, usageCache, identityCache, tlsFingerprintProfileService)
|
||||
accountTestService := service.NewAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, grokTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService)
|
||||
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
|
||||
accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator)
|
||||
adminAnnouncementHandler := admin.NewAnnouncementHandler(announcementService)
|
||||
@@ -191,10 +196,11 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
backupService := service.ProvideBackupService(settingRepository, configConfig, secretEncryptor, backupObjectStoreFactory, dbDumper)
|
||||
backupHandler := admin.NewBackupHandler(backupService, userService)
|
||||
oAuthHandler := admin.NewOAuthHandler(oAuthService)
|
||||
openAIQuotaService := service.ProvideOpenAIQuotaService(accountRepository, proxyRepository, openAITokenProvider, privacyClientFactory)
|
||||
openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService, openAIQuotaService)
|
||||
geminiOAuthHandler := admin.NewGeminiOAuthHandler(geminiOAuthService)
|
||||
antigravityOAuthHandler := admin.NewAntigravityOAuthHandler(antigravityOAuthService)
|
||||
grokQuotaService := service.ProvideGrokQuotaService(accountRepository, proxyRepository, grokTokenProvider, httpUpstream)
|
||||
grokOAuthHandler := admin.NewGrokOAuthHandler(grokOAuthService, adminService, grokQuotaService)
|
||||
proxyHandler := admin.NewProxyHandler(adminService)
|
||||
adminRedeemHandler := admin.NewRedeemHandler(adminService, redeemService)
|
||||
promoHandler := admin.NewPromoHandler(promoService)
|
||||
@@ -242,7 +248,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
paymentHandler := admin.NewPaymentHandler(paymentService, paymentConfigService)
|
||||
affiliateHandler := admin.NewAffiliateHandler(affiliateService, adminService)
|
||||
complianceHandler := admin.NewComplianceHandler(settingService)
|
||||
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, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, paymentHandler, affiliateHandler, complianceHandler)
|
||||
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, paymentHandler, affiliateHandler, complianceHandler)
|
||||
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
|
||||
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
|
||||
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig)
|
||||
@@ -266,7 +272,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
opsAlertEvaluatorService := service.ProvideOpsAlertEvaluatorService(opsService, opsRepository, emailService, redisClient, configConfig, proxyRepository)
|
||||
opsCleanupService := service.ProvideOpsCleanupService(opsRepository, db, redisClient, configConfig, channelMonitorService, settingRepository, opsService)
|
||||
opsScheduledReportService := service.ProvideOpsScheduledReportService(opsService, userService, emailService, redisClient, configConfig)
|
||||
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache, privacyClientFactory, proxyRepository, oAuthRefreshAPI, openAIGatewayService)
|
||||
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache, privacyClientFactory, proxyRepository, oAuthRefreshAPI, openAIGatewayService)
|
||||
accountExpiryService := service.ProvideAccountExpiryService(accountRepository)
|
||||
proxyExpiryService := service.ProvideProxyExpiryService(proxyRepository)
|
||||
subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository, settingRepository, notificationEmailService, leaderLockCache, db)
|
||||
@@ -274,7 +280,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService, leaderLockCache, db)
|
||||
channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService)
|
||||
userPlatformQuotaUsageFlusher := service.ProvideUserPlatformQuotaUsageFlusher(configConfig, billingCache, serviceUserPlatformQuotaRepository, timingWheelService)
|
||||
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, schedulerSnapshotService, tokenRefreshService, accountExpiryService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, userPlatformQuotaUsageFlusher)
|
||||
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, schedulerSnapshotService, tokenRefreshService, accountExpiryService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, userPlatformQuotaUsageFlusher)
|
||||
application := &Application{
|
||||
Server: httpServer,
|
||||
Cleanup: v,
|
||||
@@ -325,6 +331,7 @@ func provideCleanup(
|
||||
openaiOAuth *service.OpenAIOAuthService,
|
||||
geminiOAuth *service.GeminiOAuthService,
|
||||
antigravityOAuth *service.AntigravityOAuthService,
|
||||
grokOAuth *service.GrokOAuthService,
|
||||
openAIGateway *service.OpenAIGatewayService,
|
||||
scheduledTestRunner *service.ScheduledTestRunnerService,
|
||||
backupSvc *service.BackupService,
|
||||
@@ -452,6 +459,12 @@ func provideCleanup(
|
||||
antigravityOAuth.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"GrokOAuthService", func() error {
|
||||
if grokOAuth != nil {
|
||||
grokOAuth.Stop()
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{"OpenAIWSPool", func() error {
|
||||
if openAIGateway != nil {
|
||||
openAIGateway.CloseOpenAIWSPool()
|
||||
|
||||
@@ -74,6 +74,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
|
||||
openAIOAuthSvc,
|
||||
geminiOAuthSvc,
|
||||
antigravityOAuthSvc,
|
||||
nil, // grokOAuth
|
||||
nil, // openAIGateway
|
||||
nil, // scheduledTestRunner
|
||||
nil, // backupSvc
|
||||
|
||||
@@ -77,6 +77,10 @@ type Account struct {
|
||||
SessionWindowEnd *time.Time `json:"session_window_end,omitempty"`
|
||||
// SessionWindowStatus holds the value of the "session_window_status" field.
|
||||
SessionWindowStatus *string `json:"session_window_status,omitempty"`
|
||||
// Parent account id for a linked spark shadow (NULL = normal).
|
||||
ParentAccountID *int64 `json:"parent_account_id,omitempty"`
|
||||
// 'global' (default) or 'spark' (shadow reads codex_bengalfox).
|
||||
QuotaDimension account.QuotaDimension `json:"quota_dimension,omitempty"`
|
||||
// Edges holds the relations/edges for other nodes in the graph.
|
||||
// The values are being populated by the AccountQuery when eager-loading is set.
|
||||
Edges AccountEdges `json:"edges"`
|
||||
@@ -89,13 +93,17 @@ type AccountEdges struct {
|
||||
Groups []*Group `json:"groups,omitempty"`
|
||||
// Proxy holds the value of the proxy edge.
|
||||
Proxy *Proxy `json:"proxy,omitempty"`
|
||||
// Parent holds the value of the parent edge.
|
||||
Parent *Account `json:"parent,omitempty"`
|
||||
// Children holds the value of the children edge.
|
||||
Children []*Account `json:"children,omitempty"`
|
||||
// UsageLogs holds the value of the usage_logs edge.
|
||||
UsageLogs []*UsageLog `json:"usage_logs,omitempty"`
|
||||
// AccountGroups holds the value of the account_groups edge.
|
||||
AccountGroups []*AccountGroup `json:"account_groups,omitempty"`
|
||||
// loadedTypes holds the information for reporting if a
|
||||
// type was loaded (or requested) in eager-loading or not.
|
||||
loadedTypes [4]bool
|
||||
loadedTypes [6]bool
|
||||
}
|
||||
|
||||
// GroupsOrErr returns the Groups value or an error if the edge
|
||||
@@ -118,10 +126,30 @@ func (e AccountEdges) ProxyOrErr() (*Proxy, error) {
|
||||
return nil, &NotLoadedError{edge: "proxy"}
|
||||
}
|
||||
|
||||
// ParentOrErr returns the Parent value or an error if the edge
|
||||
// was not loaded in eager-loading, or loaded but was not found.
|
||||
func (e AccountEdges) ParentOrErr() (*Account, error) {
|
||||
if e.Parent != nil {
|
||||
return e.Parent, nil
|
||||
} else if e.loadedTypes[2] {
|
||||
return nil, &NotFoundError{label: account.Label}
|
||||
}
|
||||
return nil, &NotLoadedError{edge: "parent"}
|
||||
}
|
||||
|
||||
// ChildrenOrErr returns the Children value or an error if the edge
|
||||
// was not loaded in eager-loading.
|
||||
func (e AccountEdges) ChildrenOrErr() ([]*Account, error) {
|
||||
if e.loadedTypes[3] {
|
||||
return e.Children, nil
|
||||
}
|
||||
return nil, &NotLoadedError{edge: "children"}
|
||||
}
|
||||
|
||||
// UsageLogsOrErr returns the UsageLogs value or an error if the edge
|
||||
// was not loaded in eager-loading.
|
||||
func (e AccountEdges) UsageLogsOrErr() ([]*UsageLog, error) {
|
||||
if e.loadedTypes[2] {
|
||||
if e.loadedTypes[4] {
|
||||
return e.UsageLogs, nil
|
||||
}
|
||||
return nil, &NotLoadedError{edge: "usage_logs"}
|
||||
@@ -130,7 +158,7 @@ func (e AccountEdges) UsageLogsOrErr() ([]*UsageLog, error) {
|
||||
// AccountGroupsOrErr returns the AccountGroups value or an error if the edge
|
||||
// was not loaded in eager-loading.
|
||||
func (e AccountEdges) AccountGroupsOrErr() ([]*AccountGroup, error) {
|
||||
if e.loadedTypes[3] {
|
||||
if e.loadedTypes[5] {
|
||||
return e.AccountGroups, nil
|
||||
}
|
||||
return nil, &NotLoadedError{edge: "account_groups"}
|
||||
@@ -147,9 +175,9 @@ func (*Account) scanValues(columns []string) ([]any, error) {
|
||||
values[i] = new(sql.NullBool)
|
||||
case account.FieldRateMultiplier:
|
||||
values[i] = new(sql.NullFloat64)
|
||||
case account.FieldID, account.FieldProxyID, account.FieldProxyFallbackOriginID, account.FieldConcurrency, account.FieldLoadFactor, account.FieldPriority:
|
||||
case account.FieldID, account.FieldProxyID, account.FieldProxyFallbackOriginID, account.FieldConcurrency, account.FieldLoadFactor, account.FieldPriority, account.FieldParentAccountID:
|
||||
values[i] = new(sql.NullInt64)
|
||||
case account.FieldName, account.FieldNotes, account.FieldPlatform, account.FieldType, account.FieldStatus, account.FieldErrorMessage, account.FieldTempUnschedulableReason, account.FieldSessionWindowStatus:
|
||||
case account.FieldName, account.FieldNotes, account.FieldPlatform, account.FieldType, account.FieldStatus, account.FieldErrorMessage, account.FieldTempUnschedulableReason, account.FieldSessionWindowStatus, account.FieldQuotaDimension:
|
||||
values[i] = new(sql.NullString)
|
||||
case account.FieldCreatedAt, account.FieldUpdatedAt, account.FieldDeletedAt, account.FieldLastUsedAt, account.FieldExpiresAt, account.FieldRateLimitedAt, account.FieldRateLimitResetAt, account.FieldOverloadUntil, account.FieldTempUnschedulableUntil, account.FieldSessionWindowStart, account.FieldSessionWindowEnd:
|
||||
values[i] = new(sql.NullTime)
|
||||
@@ -368,6 +396,19 @@ func (_m *Account) assignValues(columns []string, values []any) error {
|
||||
_m.SessionWindowStatus = new(string)
|
||||
*_m.SessionWindowStatus = value.String
|
||||
}
|
||||
case account.FieldParentAccountID:
|
||||
if value, ok := values[i].(*sql.NullInt64); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field parent_account_id", values[i])
|
||||
} else if value.Valid {
|
||||
_m.ParentAccountID = new(int64)
|
||||
*_m.ParentAccountID = value.Int64
|
||||
}
|
||||
case account.FieldQuotaDimension:
|
||||
if value, ok := values[i].(*sql.NullString); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field quota_dimension", values[i])
|
||||
} else if value.Valid {
|
||||
_m.QuotaDimension = account.QuotaDimension(value.String)
|
||||
}
|
||||
default:
|
||||
_m.selectValues.Set(columns[i], values[i])
|
||||
}
|
||||
@@ -391,6 +432,16 @@ func (_m *Account) QueryProxy() *ProxyQuery {
|
||||
return NewAccountClient(_m.config).QueryProxy(_m)
|
||||
}
|
||||
|
||||
// QueryParent queries the "parent" edge of the Account entity.
|
||||
func (_m *Account) QueryParent() *AccountQuery {
|
||||
return NewAccountClient(_m.config).QueryParent(_m)
|
||||
}
|
||||
|
||||
// QueryChildren queries the "children" edge of the Account entity.
|
||||
func (_m *Account) QueryChildren() *AccountQuery {
|
||||
return NewAccountClient(_m.config).QueryChildren(_m)
|
||||
}
|
||||
|
||||
// QueryUsageLogs queries the "usage_logs" edge of the Account entity.
|
||||
func (_m *Account) QueryUsageLogs() *UsageLogQuery {
|
||||
return NewAccountClient(_m.config).QueryUsageLogs(_m)
|
||||
@@ -542,6 +593,14 @@ func (_m *Account) String() string {
|
||||
builder.WriteString("session_window_status=")
|
||||
builder.WriteString(*v)
|
||||
}
|
||||
builder.WriteString(", ")
|
||||
if v := _m.ParentAccountID; v != nil {
|
||||
builder.WriteString("parent_account_id=")
|
||||
builder.WriteString(fmt.Sprintf("%v", *v))
|
||||
}
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("quota_dimension=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.QuotaDimension))
|
||||
builder.WriteByte(')')
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"entgo.io/ent"
|
||||
@@ -73,10 +74,18 @@ const (
|
||||
FieldSessionWindowEnd = "session_window_end"
|
||||
// FieldSessionWindowStatus holds the string denoting the session_window_status field in the database.
|
||||
FieldSessionWindowStatus = "session_window_status"
|
||||
// FieldParentAccountID holds the string denoting the parent_account_id field in the database.
|
||||
FieldParentAccountID = "parent_account_id"
|
||||
// FieldQuotaDimension holds the string denoting the quota_dimension field in the database.
|
||||
FieldQuotaDimension = "quota_dimension"
|
||||
// EdgeGroups holds the string denoting the groups edge name in mutations.
|
||||
EdgeGroups = "groups"
|
||||
// EdgeProxy holds the string denoting the proxy edge name in mutations.
|
||||
EdgeProxy = "proxy"
|
||||
// EdgeParent holds the string denoting the parent edge name in mutations.
|
||||
EdgeParent = "parent"
|
||||
// EdgeChildren holds the string denoting the children edge name in mutations.
|
||||
EdgeChildren = "children"
|
||||
// EdgeUsageLogs holds the string denoting the usage_logs edge name in mutations.
|
||||
EdgeUsageLogs = "usage_logs"
|
||||
// EdgeAccountGroups holds the string denoting the account_groups edge name in mutations.
|
||||
@@ -95,6 +104,14 @@ const (
|
||||
ProxyInverseTable = "proxies"
|
||||
// ProxyColumn is the table column denoting the proxy relation/edge.
|
||||
ProxyColumn = "proxy_id"
|
||||
// ParentTable is the table that holds the parent relation/edge.
|
||||
ParentTable = "accounts"
|
||||
// ParentColumn is the table column denoting the parent relation/edge.
|
||||
ParentColumn = "parent_account_id"
|
||||
// ChildrenTable is the table that holds the children relation/edge.
|
||||
ChildrenTable = "accounts"
|
||||
// ChildrenColumn is the table column denoting the children relation/edge.
|
||||
ChildrenColumn = "parent_account_id"
|
||||
// UsageLogsTable is the table that holds the usage_logs relation/edge.
|
||||
UsageLogsTable = "usage_logs"
|
||||
// UsageLogsInverseTable is the table name for the UsageLog entity.
|
||||
@@ -143,6 +160,8 @@ var Columns = []string{
|
||||
FieldSessionWindowStart,
|
||||
FieldSessionWindowEnd,
|
||||
FieldSessionWindowStatus,
|
||||
FieldParentAccountID,
|
||||
FieldQuotaDimension,
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -203,6 +222,32 @@ var (
|
||||
SessionWindowStatusValidator func(string) error
|
||||
)
|
||||
|
||||
// QuotaDimension defines the type for the "quota_dimension" enum field.
|
||||
type QuotaDimension string
|
||||
|
||||
// QuotaDimensionGlobal is the default value of the QuotaDimension enum.
|
||||
const DefaultQuotaDimension = QuotaDimensionGlobal
|
||||
|
||||
// QuotaDimension values.
|
||||
const (
|
||||
QuotaDimensionGlobal QuotaDimension = "global"
|
||||
QuotaDimensionSpark QuotaDimension = "spark"
|
||||
)
|
||||
|
||||
func (qd QuotaDimension) String() string {
|
||||
return string(qd)
|
||||
}
|
||||
|
||||
// QuotaDimensionValidator is a validator for the "quota_dimension" field enum values. It is called by the builders before save.
|
||||
func QuotaDimensionValidator(qd QuotaDimension) error {
|
||||
switch qd {
|
||||
case QuotaDimensionGlobal, QuotaDimensionSpark:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("account: invalid enum value for quota_dimension field: %q", qd)
|
||||
}
|
||||
}
|
||||
|
||||
// OrderOption defines the ordering options for the Account queries.
|
||||
type OrderOption func(*sql.Selector)
|
||||
|
||||
@@ -346,6 +391,16 @@ func BySessionWindowStatus(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldSessionWindowStatus, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByParentAccountID orders the results by the parent_account_id field.
|
||||
func ByParentAccountID(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldParentAccountID, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByQuotaDimension orders the results by the quota_dimension field.
|
||||
func ByQuotaDimension(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldQuotaDimension, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByGroupsCount orders the results by groups count.
|
||||
func ByGroupsCount(opts ...sql.OrderTermOption) OrderOption {
|
||||
return func(s *sql.Selector) {
|
||||
@@ -367,6 +422,27 @@ func ByProxyField(field string, opts ...sql.OrderTermOption) OrderOption {
|
||||
}
|
||||
}
|
||||
|
||||
// ByParentField orders the results by parent field.
|
||||
func ByParentField(field string, opts ...sql.OrderTermOption) OrderOption {
|
||||
return func(s *sql.Selector) {
|
||||
sqlgraph.OrderByNeighborTerms(s, newParentStep(), sql.OrderByField(field, opts...))
|
||||
}
|
||||
}
|
||||
|
||||
// ByChildrenCount orders the results by children count.
|
||||
func ByChildrenCount(opts ...sql.OrderTermOption) OrderOption {
|
||||
return func(s *sql.Selector) {
|
||||
sqlgraph.OrderByNeighborsCount(s, newChildrenStep(), opts...)
|
||||
}
|
||||
}
|
||||
|
||||
// ByChildren orders the results by children terms.
|
||||
func ByChildren(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption {
|
||||
return func(s *sql.Selector) {
|
||||
sqlgraph.OrderByNeighborTerms(s, newChildrenStep(), append([]sql.OrderTerm{term}, terms...)...)
|
||||
}
|
||||
}
|
||||
|
||||
// ByUsageLogsCount orders the results by usage_logs count.
|
||||
func ByUsageLogsCount(opts ...sql.OrderTermOption) OrderOption {
|
||||
return func(s *sql.Selector) {
|
||||
@@ -408,6 +484,20 @@ func newProxyStep() *sqlgraph.Step {
|
||||
sqlgraph.Edge(sqlgraph.M2O, false, ProxyTable, ProxyColumn),
|
||||
)
|
||||
}
|
||||
func newParentStep() *sqlgraph.Step {
|
||||
return sqlgraph.NewStep(
|
||||
sqlgraph.From(Table, FieldID),
|
||||
sqlgraph.To(Table, FieldID),
|
||||
sqlgraph.Edge(sqlgraph.M2O, true, ParentTable, ParentColumn),
|
||||
)
|
||||
}
|
||||
func newChildrenStep() *sqlgraph.Step {
|
||||
return sqlgraph.NewStep(
|
||||
sqlgraph.From(Table, FieldID),
|
||||
sqlgraph.To(Table, FieldID),
|
||||
sqlgraph.Edge(sqlgraph.O2M, false, ChildrenTable, ChildrenColumn),
|
||||
)
|
||||
}
|
||||
func newUsageLogsStep() *sqlgraph.Step {
|
||||
return sqlgraph.NewStep(
|
||||
sqlgraph.From(Table, FieldID),
|
||||
|
||||
@@ -190,6 +190,11 @@ func SessionWindowStatus(v string) predicate.Account {
|
||||
return predicate.Account(sql.FieldEQ(FieldSessionWindowStatus, v))
|
||||
}
|
||||
|
||||
// ParentAccountID applies equality check predicate on the "parent_account_id" field. It's identical to ParentAccountIDEQ.
|
||||
func ParentAccountID(v int64) predicate.Account {
|
||||
return predicate.Account(sql.FieldEQ(FieldParentAccountID, v))
|
||||
}
|
||||
|
||||
// CreatedAtEQ applies the EQ predicate on the "created_at" field.
|
||||
func CreatedAtEQ(v time.Time) predicate.Account {
|
||||
return predicate.Account(sql.FieldEQ(FieldCreatedAt, v))
|
||||
@@ -1550,6 +1555,56 @@ func SessionWindowStatusContainsFold(v string) predicate.Account {
|
||||
return predicate.Account(sql.FieldContainsFold(FieldSessionWindowStatus, v))
|
||||
}
|
||||
|
||||
// ParentAccountIDEQ applies the EQ predicate on the "parent_account_id" field.
|
||||
func ParentAccountIDEQ(v int64) predicate.Account {
|
||||
return predicate.Account(sql.FieldEQ(FieldParentAccountID, v))
|
||||
}
|
||||
|
||||
// ParentAccountIDNEQ applies the NEQ predicate on the "parent_account_id" field.
|
||||
func ParentAccountIDNEQ(v int64) predicate.Account {
|
||||
return predicate.Account(sql.FieldNEQ(FieldParentAccountID, v))
|
||||
}
|
||||
|
||||
// ParentAccountIDIn applies the In predicate on the "parent_account_id" field.
|
||||
func ParentAccountIDIn(vs ...int64) predicate.Account {
|
||||
return predicate.Account(sql.FieldIn(FieldParentAccountID, vs...))
|
||||
}
|
||||
|
||||
// ParentAccountIDNotIn applies the NotIn predicate on the "parent_account_id" field.
|
||||
func ParentAccountIDNotIn(vs ...int64) predicate.Account {
|
||||
return predicate.Account(sql.FieldNotIn(FieldParentAccountID, vs...))
|
||||
}
|
||||
|
||||
// ParentAccountIDIsNil applies the IsNil predicate on the "parent_account_id" field.
|
||||
func ParentAccountIDIsNil() predicate.Account {
|
||||
return predicate.Account(sql.FieldIsNull(FieldParentAccountID))
|
||||
}
|
||||
|
||||
// ParentAccountIDNotNil applies the NotNil predicate on the "parent_account_id" field.
|
||||
func ParentAccountIDNotNil() predicate.Account {
|
||||
return predicate.Account(sql.FieldNotNull(FieldParentAccountID))
|
||||
}
|
||||
|
||||
// QuotaDimensionEQ applies the EQ predicate on the "quota_dimension" field.
|
||||
func QuotaDimensionEQ(v QuotaDimension) predicate.Account {
|
||||
return predicate.Account(sql.FieldEQ(FieldQuotaDimension, v))
|
||||
}
|
||||
|
||||
// QuotaDimensionNEQ applies the NEQ predicate on the "quota_dimension" field.
|
||||
func QuotaDimensionNEQ(v QuotaDimension) predicate.Account {
|
||||
return predicate.Account(sql.FieldNEQ(FieldQuotaDimension, v))
|
||||
}
|
||||
|
||||
// QuotaDimensionIn applies the In predicate on the "quota_dimension" field.
|
||||
func QuotaDimensionIn(vs ...QuotaDimension) predicate.Account {
|
||||
return predicate.Account(sql.FieldIn(FieldQuotaDimension, vs...))
|
||||
}
|
||||
|
||||
// QuotaDimensionNotIn applies the NotIn predicate on the "quota_dimension" field.
|
||||
func QuotaDimensionNotIn(vs ...QuotaDimension) predicate.Account {
|
||||
return predicate.Account(sql.FieldNotIn(FieldQuotaDimension, vs...))
|
||||
}
|
||||
|
||||
// HasGroups applies the HasEdge predicate on the "groups" edge.
|
||||
func HasGroups() predicate.Account {
|
||||
return predicate.Account(func(s *sql.Selector) {
|
||||
@@ -1596,6 +1651,52 @@ func HasProxyWith(preds ...predicate.Proxy) predicate.Account {
|
||||
})
|
||||
}
|
||||
|
||||
// HasParent applies the HasEdge predicate on the "parent" edge.
|
||||
func HasParent() predicate.Account {
|
||||
return predicate.Account(func(s *sql.Selector) {
|
||||
step := sqlgraph.NewStep(
|
||||
sqlgraph.From(Table, FieldID),
|
||||
sqlgraph.Edge(sqlgraph.M2O, true, ParentTable, ParentColumn),
|
||||
)
|
||||
sqlgraph.HasNeighbors(s, step)
|
||||
})
|
||||
}
|
||||
|
||||
// HasParentWith applies the HasEdge predicate on the "parent" edge with a given conditions (other predicates).
|
||||
func HasParentWith(preds ...predicate.Account) predicate.Account {
|
||||
return predicate.Account(func(s *sql.Selector) {
|
||||
step := newParentStep()
|
||||
sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) {
|
||||
for _, p := range preds {
|
||||
p(s)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// HasChildren applies the HasEdge predicate on the "children" edge.
|
||||
func HasChildren() predicate.Account {
|
||||
return predicate.Account(func(s *sql.Selector) {
|
||||
step := sqlgraph.NewStep(
|
||||
sqlgraph.From(Table, FieldID),
|
||||
sqlgraph.Edge(sqlgraph.O2M, false, ChildrenTable, ChildrenColumn),
|
||||
)
|
||||
sqlgraph.HasNeighbors(s, step)
|
||||
})
|
||||
}
|
||||
|
||||
// HasChildrenWith applies the HasEdge predicate on the "children" edge with a given conditions (other predicates).
|
||||
func HasChildrenWith(preds ...predicate.Account) predicate.Account {
|
||||
return predicate.Account(func(s *sql.Selector) {
|
||||
step := newChildrenStep()
|
||||
sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) {
|
||||
for _, p := range preds {
|
||||
p(s)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// HasUsageLogs applies the HasEdge predicate on the "usage_logs" edge.
|
||||
func HasUsageLogs() predicate.Account {
|
||||
return predicate.Account(func(s *sql.Selector) {
|
||||
|
||||
@@ -391,6 +391,34 @@ func (_c *AccountCreate) SetNillableSessionWindowStatus(v *string) *AccountCreat
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetParentAccountID sets the "parent_account_id" field.
|
||||
func (_c *AccountCreate) SetParentAccountID(v int64) *AccountCreate {
|
||||
_c.mutation.SetParentAccountID(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableParentAccountID sets the "parent_account_id" field if the given value is not nil.
|
||||
func (_c *AccountCreate) SetNillableParentAccountID(v *int64) *AccountCreate {
|
||||
if v != nil {
|
||||
_c.SetParentAccountID(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetQuotaDimension sets the "quota_dimension" field.
|
||||
func (_c *AccountCreate) SetQuotaDimension(v account.QuotaDimension) *AccountCreate {
|
||||
_c.mutation.SetQuotaDimension(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableQuotaDimension sets the "quota_dimension" field if the given value is not nil.
|
||||
func (_c *AccountCreate) SetNillableQuotaDimension(v *account.QuotaDimension) *AccountCreate {
|
||||
if v != nil {
|
||||
_c.SetQuotaDimension(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// AddGroupIDs adds the "groups" edge to the Group entity by IDs.
|
||||
func (_c *AccountCreate) AddGroupIDs(ids ...int64) *AccountCreate {
|
||||
_c.mutation.AddGroupIDs(ids...)
|
||||
@@ -411,6 +439,40 @@ func (_c *AccountCreate) SetProxy(v *Proxy) *AccountCreate {
|
||||
return _c.SetProxyID(v.ID)
|
||||
}
|
||||
|
||||
// SetParentID sets the "parent" edge to the Account entity by ID.
|
||||
func (_c *AccountCreate) SetParentID(id int64) *AccountCreate {
|
||||
_c.mutation.SetParentID(id)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableParentID sets the "parent" edge to the Account entity by ID if the given value is not nil.
|
||||
func (_c *AccountCreate) SetNillableParentID(id *int64) *AccountCreate {
|
||||
if id != nil {
|
||||
_c = _c.SetParentID(*id)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetParent sets the "parent" edge to the Account entity.
|
||||
func (_c *AccountCreate) SetParent(v *Account) *AccountCreate {
|
||||
return _c.SetParentID(v.ID)
|
||||
}
|
||||
|
||||
// AddChildIDs adds the "children" edge to the Account entity by IDs.
|
||||
func (_c *AccountCreate) AddChildIDs(ids ...int64) *AccountCreate {
|
||||
_c.mutation.AddChildIDs(ids...)
|
||||
return _c
|
||||
}
|
||||
|
||||
// AddChildren adds the "children" edges to the Account entity.
|
||||
func (_c *AccountCreate) AddChildren(v ...*Account) *AccountCreate {
|
||||
ids := make([]int64, len(v))
|
||||
for i := range v {
|
||||
ids[i] = v[i].ID
|
||||
}
|
||||
return _c.AddChildIDs(ids...)
|
||||
}
|
||||
|
||||
// AddUsageLogIDs adds the "usage_logs" edge to the UsageLog entity by IDs.
|
||||
func (_c *AccountCreate) AddUsageLogIDs(ids ...int64) *AccountCreate {
|
||||
_c.mutation.AddUsageLogIDs(ids...)
|
||||
@@ -515,6 +577,10 @@ func (_c *AccountCreate) defaults() error {
|
||||
v := account.DefaultSchedulable
|
||||
_c.mutation.SetSchedulable(v)
|
||||
}
|
||||
if _, ok := _c.mutation.QuotaDimension(); !ok {
|
||||
v := account.DefaultQuotaDimension
|
||||
_c.mutation.SetQuotaDimension(v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -584,6 +650,14 @@ func (_c *AccountCreate) check() error {
|
||||
return &ValidationError{Name: "session_window_status", err: fmt.Errorf(`ent: validator failed for field "Account.session_window_status": %w`, err)}
|
||||
}
|
||||
}
|
||||
if _, ok := _c.mutation.QuotaDimension(); !ok {
|
||||
return &ValidationError{Name: "quota_dimension", err: errors.New(`ent: missing required field "Account.quota_dimension"`)}
|
||||
}
|
||||
if v, ok := _c.mutation.QuotaDimension(); ok {
|
||||
if err := account.QuotaDimensionValidator(v); err != nil {
|
||||
return &ValidationError{Name: "quota_dimension", err: fmt.Errorf(`ent: validator failed for field "Account.quota_dimension": %w`, err)}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -723,6 +797,10 @@ func (_c *AccountCreate) createSpec() (*Account, *sqlgraph.CreateSpec) {
|
||||
_spec.SetField(account.FieldSessionWindowStatus, field.TypeString, value)
|
||||
_node.SessionWindowStatus = &value
|
||||
}
|
||||
if value, ok := _c.mutation.QuotaDimension(); ok {
|
||||
_spec.SetField(account.FieldQuotaDimension, field.TypeEnum, value)
|
||||
_node.QuotaDimension = value
|
||||
}
|
||||
if nodes := _c.mutation.GroupsIDs(); len(nodes) > 0 {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.M2M,
|
||||
@@ -760,6 +838,39 @@ func (_c *AccountCreate) createSpec() (*Account, *sqlgraph.CreateSpec) {
|
||||
_node.ProxyID = &nodes[0]
|
||||
_spec.Edges = append(_spec.Edges, edge)
|
||||
}
|
||||
if nodes := _c.mutation.ParentIDs(); len(nodes) > 0 {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.M2O,
|
||||
Inverse: true,
|
||||
Table: account.ParentTable,
|
||||
Columns: []string{account.ParentColumn},
|
||||
Bidi: false,
|
||||
Target: &sqlgraph.EdgeTarget{
|
||||
IDSpec: sqlgraph.NewFieldSpec(account.FieldID, field.TypeInt64),
|
||||
},
|
||||
}
|
||||
for _, k := range nodes {
|
||||
edge.Target.Nodes = append(edge.Target.Nodes, k)
|
||||
}
|
||||
_node.ParentAccountID = &nodes[0]
|
||||
_spec.Edges = append(_spec.Edges, edge)
|
||||
}
|
||||
if nodes := _c.mutation.ChildrenIDs(); len(nodes) > 0 {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.O2M,
|
||||
Inverse: false,
|
||||
Table: account.ChildrenTable,
|
||||
Columns: []string{account.ChildrenColumn},
|
||||
Bidi: false,
|
||||
Target: &sqlgraph.EdgeTarget{
|
||||
IDSpec: sqlgraph.NewFieldSpec(account.FieldID, field.TypeInt64),
|
||||
},
|
||||
}
|
||||
for _, k := range nodes {
|
||||
edge.Target.Nodes = append(edge.Target.Nodes, k)
|
||||
}
|
||||
_spec.Edges = append(_spec.Edges, edge)
|
||||
}
|
||||
if nodes := _c.mutation.UsageLogsIDs(); len(nodes) > 0 {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.O2M,
|
||||
@@ -1290,6 +1401,36 @@ func (u *AccountUpsert) ClearSessionWindowStatus() *AccountUpsert {
|
||||
return u
|
||||
}
|
||||
|
||||
// SetParentAccountID sets the "parent_account_id" field.
|
||||
func (u *AccountUpsert) SetParentAccountID(v int64) *AccountUpsert {
|
||||
u.Set(account.FieldParentAccountID, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateParentAccountID sets the "parent_account_id" field to the value that was provided on create.
|
||||
func (u *AccountUpsert) UpdateParentAccountID() *AccountUpsert {
|
||||
u.SetExcluded(account.FieldParentAccountID)
|
||||
return u
|
||||
}
|
||||
|
||||
// ClearParentAccountID clears the value of the "parent_account_id" field.
|
||||
func (u *AccountUpsert) ClearParentAccountID() *AccountUpsert {
|
||||
u.SetNull(account.FieldParentAccountID)
|
||||
return u
|
||||
}
|
||||
|
||||
// SetQuotaDimension sets the "quota_dimension" field.
|
||||
func (u *AccountUpsert) SetQuotaDimension(v account.QuotaDimension) *AccountUpsert {
|
||||
u.Set(account.FieldQuotaDimension, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateQuotaDimension sets the "quota_dimension" field to the value that was provided on create.
|
||||
func (u *AccountUpsert) UpdateQuotaDimension() *AccountUpsert {
|
||||
u.SetExcluded(account.FieldQuotaDimension)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateNewValues updates the mutable fields using the new values that were set on create.
|
||||
// Using this option is equivalent to using:
|
||||
//
|
||||
@@ -1874,6 +2015,41 @@ func (u *AccountUpsertOne) ClearSessionWindowStatus() *AccountUpsertOne {
|
||||
})
|
||||
}
|
||||
|
||||
// SetParentAccountID sets the "parent_account_id" field.
|
||||
func (u *AccountUpsertOne) SetParentAccountID(v int64) *AccountUpsertOne {
|
||||
return u.Update(func(s *AccountUpsert) {
|
||||
s.SetParentAccountID(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateParentAccountID sets the "parent_account_id" field to the value that was provided on create.
|
||||
func (u *AccountUpsertOne) UpdateParentAccountID() *AccountUpsertOne {
|
||||
return u.Update(func(s *AccountUpsert) {
|
||||
s.UpdateParentAccountID()
|
||||
})
|
||||
}
|
||||
|
||||
// ClearParentAccountID clears the value of the "parent_account_id" field.
|
||||
func (u *AccountUpsertOne) ClearParentAccountID() *AccountUpsertOne {
|
||||
return u.Update(func(s *AccountUpsert) {
|
||||
s.ClearParentAccountID()
|
||||
})
|
||||
}
|
||||
|
||||
// SetQuotaDimension sets the "quota_dimension" field.
|
||||
func (u *AccountUpsertOne) SetQuotaDimension(v account.QuotaDimension) *AccountUpsertOne {
|
||||
return u.Update(func(s *AccountUpsert) {
|
||||
s.SetQuotaDimension(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateQuotaDimension sets the "quota_dimension" field to the value that was provided on create.
|
||||
func (u *AccountUpsertOne) UpdateQuotaDimension() *AccountUpsertOne {
|
||||
return u.Update(func(s *AccountUpsert) {
|
||||
s.UpdateQuotaDimension()
|
||||
})
|
||||
}
|
||||
|
||||
// Exec executes the query.
|
||||
func (u *AccountUpsertOne) Exec(ctx context.Context) error {
|
||||
if len(u.create.conflict) == 0 {
|
||||
@@ -2624,6 +2800,41 @@ func (u *AccountUpsertBulk) ClearSessionWindowStatus() *AccountUpsertBulk {
|
||||
})
|
||||
}
|
||||
|
||||
// SetParentAccountID sets the "parent_account_id" field.
|
||||
func (u *AccountUpsertBulk) SetParentAccountID(v int64) *AccountUpsertBulk {
|
||||
return u.Update(func(s *AccountUpsert) {
|
||||
s.SetParentAccountID(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateParentAccountID sets the "parent_account_id" field to the value that was provided on create.
|
||||
func (u *AccountUpsertBulk) UpdateParentAccountID() *AccountUpsertBulk {
|
||||
return u.Update(func(s *AccountUpsert) {
|
||||
s.UpdateParentAccountID()
|
||||
})
|
||||
}
|
||||
|
||||
// ClearParentAccountID clears the value of the "parent_account_id" field.
|
||||
func (u *AccountUpsertBulk) ClearParentAccountID() *AccountUpsertBulk {
|
||||
return u.Update(func(s *AccountUpsert) {
|
||||
s.ClearParentAccountID()
|
||||
})
|
||||
}
|
||||
|
||||
// SetQuotaDimension sets the "quota_dimension" field.
|
||||
func (u *AccountUpsertBulk) SetQuotaDimension(v account.QuotaDimension) *AccountUpsertBulk {
|
||||
return u.Update(func(s *AccountUpsert) {
|
||||
s.SetQuotaDimension(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateQuotaDimension sets the "quota_dimension" field to the value that was provided on create.
|
||||
func (u *AccountUpsertBulk) UpdateQuotaDimension() *AccountUpsertBulk {
|
||||
return u.Update(func(s *AccountUpsert) {
|
||||
s.UpdateQuotaDimension()
|
||||
})
|
||||
}
|
||||
|
||||
// Exec executes the query.
|
||||
func (u *AccountUpsertBulk) Exec(ctx context.Context) error {
|
||||
if u.create.err != nil {
|
||||
|
||||
@@ -30,6 +30,8 @@ type AccountQuery struct {
|
||||
predicates []predicate.Account
|
||||
withGroups *GroupQuery
|
||||
withProxy *ProxyQuery
|
||||
withParent *AccountQuery
|
||||
withChildren *AccountQuery
|
||||
withUsageLogs *UsageLogQuery
|
||||
withAccountGroups *AccountGroupQuery
|
||||
modifiers []func(*sql.Selector)
|
||||
@@ -113,6 +115,50 @@ func (_q *AccountQuery) QueryProxy() *ProxyQuery {
|
||||
return query
|
||||
}
|
||||
|
||||
// QueryParent chains the current query on the "parent" edge.
|
||||
func (_q *AccountQuery) QueryParent() *AccountQuery {
|
||||
query := (&AccountClient{config: _q.config}).Query()
|
||||
query.path = func(ctx context.Context) (fromU *sql.Selector, err error) {
|
||||
if err := _q.prepareQuery(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
selector := _q.sqlQuery(ctx)
|
||||
if err := selector.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
step := sqlgraph.NewStep(
|
||||
sqlgraph.From(account.Table, account.FieldID, selector),
|
||||
sqlgraph.To(account.Table, account.FieldID),
|
||||
sqlgraph.Edge(sqlgraph.M2O, true, account.ParentTable, account.ParentColumn),
|
||||
)
|
||||
fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step)
|
||||
return fromU, nil
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
// QueryChildren chains the current query on the "children" edge.
|
||||
func (_q *AccountQuery) QueryChildren() *AccountQuery {
|
||||
query := (&AccountClient{config: _q.config}).Query()
|
||||
query.path = func(ctx context.Context) (fromU *sql.Selector, err error) {
|
||||
if err := _q.prepareQuery(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
selector := _q.sqlQuery(ctx)
|
||||
if err := selector.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
step := sqlgraph.NewStep(
|
||||
sqlgraph.From(account.Table, account.FieldID, selector),
|
||||
sqlgraph.To(account.Table, account.FieldID),
|
||||
sqlgraph.Edge(sqlgraph.O2M, false, account.ChildrenTable, account.ChildrenColumn),
|
||||
)
|
||||
fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step)
|
||||
return fromU, nil
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
// QueryUsageLogs chains the current query on the "usage_logs" edge.
|
||||
func (_q *AccountQuery) QueryUsageLogs() *UsageLogQuery {
|
||||
query := (&UsageLogClient{config: _q.config}).Query()
|
||||
@@ -351,6 +397,8 @@ func (_q *AccountQuery) Clone() *AccountQuery {
|
||||
predicates: append([]predicate.Account{}, _q.predicates...),
|
||||
withGroups: _q.withGroups.Clone(),
|
||||
withProxy: _q.withProxy.Clone(),
|
||||
withParent: _q.withParent.Clone(),
|
||||
withChildren: _q.withChildren.Clone(),
|
||||
withUsageLogs: _q.withUsageLogs.Clone(),
|
||||
withAccountGroups: _q.withAccountGroups.Clone(),
|
||||
// clone intermediate query.
|
||||
@@ -381,6 +429,28 @@ func (_q *AccountQuery) WithProxy(opts ...func(*ProxyQuery)) *AccountQuery {
|
||||
return _q
|
||||
}
|
||||
|
||||
// WithParent tells the query-builder to eager-load the nodes that are connected to
|
||||
// the "parent" edge. The optional arguments are used to configure the query builder of the edge.
|
||||
func (_q *AccountQuery) WithParent(opts ...func(*AccountQuery)) *AccountQuery {
|
||||
query := (&AccountClient{config: _q.config}).Query()
|
||||
for _, opt := range opts {
|
||||
opt(query)
|
||||
}
|
||||
_q.withParent = query
|
||||
return _q
|
||||
}
|
||||
|
||||
// WithChildren tells the query-builder to eager-load the nodes that are connected to
|
||||
// the "children" edge. The optional arguments are used to configure the query builder of the edge.
|
||||
func (_q *AccountQuery) WithChildren(opts ...func(*AccountQuery)) *AccountQuery {
|
||||
query := (&AccountClient{config: _q.config}).Query()
|
||||
for _, opt := range opts {
|
||||
opt(query)
|
||||
}
|
||||
_q.withChildren = query
|
||||
return _q
|
||||
}
|
||||
|
||||
// WithUsageLogs tells the query-builder to eager-load the nodes that are connected to
|
||||
// the "usage_logs" edge. The optional arguments are used to configure the query builder of the edge.
|
||||
func (_q *AccountQuery) WithUsageLogs(opts ...func(*UsageLogQuery)) *AccountQuery {
|
||||
@@ -481,9 +551,11 @@ func (_q *AccountQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*Acco
|
||||
var (
|
||||
nodes = []*Account{}
|
||||
_spec = _q.querySpec()
|
||||
loadedTypes = [4]bool{
|
||||
loadedTypes = [6]bool{
|
||||
_q.withGroups != nil,
|
||||
_q.withProxy != nil,
|
||||
_q.withParent != nil,
|
||||
_q.withChildren != nil,
|
||||
_q.withUsageLogs != nil,
|
||||
_q.withAccountGroups != nil,
|
||||
}
|
||||
@@ -522,6 +594,19 @@ func (_q *AccountQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*Acco
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if query := _q.withParent; query != nil {
|
||||
if err := _q.loadParent(ctx, query, nodes, nil,
|
||||
func(n *Account, e *Account) { n.Edges.Parent = e }); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if query := _q.withChildren; query != nil {
|
||||
if err := _q.loadChildren(ctx, query, nodes,
|
||||
func(n *Account) { n.Edges.Children = []*Account{} },
|
||||
func(n *Account, e *Account) { n.Edges.Children = append(n.Edges.Children, e) }); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if query := _q.withUsageLogs; query != nil {
|
||||
if err := _q.loadUsageLogs(ctx, query, nodes,
|
||||
func(n *Account) { n.Edges.UsageLogs = []*UsageLog{} },
|
||||
@@ -632,6 +717,71 @@ func (_q *AccountQuery) loadProxy(ctx context.Context, query *ProxyQuery, nodes
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (_q *AccountQuery) loadParent(ctx context.Context, query *AccountQuery, nodes []*Account, init func(*Account), assign func(*Account, *Account)) error {
|
||||
ids := make([]int64, 0, len(nodes))
|
||||
nodeids := make(map[int64][]*Account)
|
||||
for i := range nodes {
|
||||
if nodes[i].ParentAccountID == nil {
|
||||
continue
|
||||
}
|
||||
fk := *nodes[i].ParentAccountID
|
||||
if _, ok := nodeids[fk]; !ok {
|
||||
ids = append(ids, fk)
|
||||
}
|
||||
nodeids[fk] = append(nodeids[fk], nodes[i])
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
query.Where(account.IDIn(ids...))
|
||||
neighbors, err := query.All(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, n := range neighbors {
|
||||
nodes, ok := nodeids[n.ID]
|
||||
if !ok {
|
||||
return fmt.Errorf(`unexpected foreign-key "parent_account_id" returned %v`, n.ID)
|
||||
}
|
||||
for i := range nodes {
|
||||
assign(nodes[i], n)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (_q *AccountQuery) loadChildren(ctx context.Context, query *AccountQuery, nodes []*Account, init func(*Account), assign func(*Account, *Account)) error {
|
||||
fks := make([]driver.Value, 0, len(nodes))
|
||||
nodeids := make(map[int64]*Account)
|
||||
for i := range nodes {
|
||||
fks = append(fks, nodes[i].ID)
|
||||
nodeids[nodes[i].ID] = nodes[i]
|
||||
if init != nil {
|
||||
init(nodes[i])
|
||||
}
|
||||
}
|
||||
if len(query.ctx.Fields) > 0 {
|
||||
query.ctx.AppendFieldOnce(account.FieldParentAccountID)
|
||||
}
|
||||
query.Where(predicate.Account(func(s *sql.Selector) {
|
||||
s.Where(sql.InValues(s.C(account.ChildrenColumn), fks...))
|
||||
}))
|
||||
neighbors, err := query.All(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, n := range neighbors {
|
||||
fk := n.ParentAccountID
|
||||
if fk == nil {
|
||||
return fmt.Errorf(`foreign-key "parent_account_id" is nil for node %v`, n.ID)
|
||||
}
|
||||
node, ok := nodeids[*fk]
|
||||
if !ok {
|
||||
return fmt.Errorf(`unexpected referenced foreign-key "parent_account_id" returned %v for node %v`, *fk, n.ID)
|
||||
}
|
||||
assign(node, n)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (_q *AccountQuery) loadUsageLogs(ctx context.Context, query *UsageLogQuery, nodes []*Account, init func(*Account), assign func(*Account, *UsageLog)) error {
|
||||
fks := make([]driver.Value, 0, len(nodes))
|
||||
nodeids := make(map[int64]*Account)
|
||||
@@ -724,6 +874,9 @@ func (_q *AccountQuery) querySpec() *sqlgraph.QuerySpec {
|
||||
if _q.withProxy != nil {
|
||||
_spec.Node.AddColumnOnce(account.FieldProxyID)
|
||||
}
|
||||
if _q.withParent != nil {
|
||||
_spec.Node.AddColumnOnce(account.FieldParentAccountID)
|
||||
}
|
||||
}
|
||||
if ps := _q.predicates; len(ps) > 0 {
|
||||
_spec.Predicate = func(selector *sql.Selector) {
|
||||
|
||||
@@ -530,6 +530,40 @@ func (_u *AccountUpdate) ClearSessionWindowStatus() *AccountUpdate {
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetParentAccountID sets the "parent_account_id" field.
|
||||
func (_u *AccountUpdate) SetParentAccountID(v int64) *AccountUpdate {
|
||||
_u.mutation.SetParentAccountID(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableParentAccountID sets the "parent_account_id" field if the given value is not nil.
|
||||
func (_u *AccountUpdate) SetNillableParentAccountID(v *int64) *AccountUpdate {
|
||||
if v != nil {
|
||||
_u.SetParentAccountID(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearParentAccountID clears the value of the "parent_account_id" field.
|
||||
func (_u *AccountUpdate) ClearParentAccountID() *AccountUpdate {
|
||||
_u.mutation.ClearParentAccountID()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetQuotaDimension sets the "quota_dimension" field.
|
||||
func (_u *AccountUpdate) SetQuotaDimension(v account.QuotaDimension) *AccountUpdate {
|
||||
_u.mutation.SetQuotaDimension(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableQuotaDimension sets the "quota_dimension" field if the given value is not nil.
|
||||
func (_u *AccountUpdate) SetNillableQuotaDimension(v *account.QuotaDimension) *AccountUpdate {
|
||||
if v != nil {
|
||||
_u.SetQuotaDimension(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// AddGroupIDs adds the "groups" edge to the Group entity by IDs.
|
||||
func (_u *AccountUpdate) AddGroupIDs(ids ...int64) *AccountUpdate {
|
||||
_u.mutation.AddGroupIDs(ids...)
|
||||
@@ -550,6 +584,40 @@ func (_u *AccountUpdate) SetProxy(v *Proxy) *AccountUpdate {
|
||||
return _u.SetProxyID(v.ID)
|
||||
}
|
||||
|
||||
// SetParentID sets the "parent" edge to the Account entity by ID.
|
||||
func (_u *AccountUpdate) SetParentID(id int64) *AccountUpdate {
|
||||
_u.mutation.SetParentID(id)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableParentID sets the "parent" edge to the Account entity by ID if the given value is not nil.
|
||||
func (_u *AccountUpdate) SetNillableParentID(id *int64) *AccountUpdate {
|
||||
if id != nil {
|
||||
_u = _u.SetParentID(*id)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetParent sets the "parent" edge to the Account entity.
|
||||
func (_u *AccountUpdate) SetParent(v *Account) *AccountUpdate {
|
||||
return _u.SetParentID(v.ID)
|
||||
}
|
||||
|
||||
// AddChildIDs adds the "children" edge to the Account entity by IDs.
|
||||
func (_u *AccountUpdate) AddChildIDs(ids ...int64) *AccountUpdate {
|
||||
_u.mutation.AddChildIDs(ids...)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AddChildren adds the "children" edges to the Account entity.
|
||||
func (_u *AccountUpdate) AddChildren(v ...*Account) *AccountUpdate {
|
||||
ids := make([]int64, len(v))
|
||||
for i := range v {
|
||||
ids[i] = v[i].ID
|
||||
}
|
||||
return _u.AddChildIDs(ids...)
|
||||
}
|
||||
|
||||
// AddUsageLogIDs adds the "usage_logs" edge to the UsageLog entity by IDs.
|
||||
func (_u *AccountUpdate) AddUsageLogIDs(ids ...int64) *AccountUpdate {
|
||||
_u.mutation.AddUsageLogIDs(ids...)
|
||||
@@ -597,6 +665,33 @@ func (_u *AccountUpdate) ClearProxy() *AccountUpdate {
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearParent clears the "parent" edge to the Account entity.
|
||||
func (_u *AccountUpdate) ClearParent() *AccountUpdate {
|
||||
_u.mutation.ClearParent()
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearChildren clears all "children" edges to the Account entity.
|
||||
func (_u *AccountUpdate) ClearChildren() *AccountUpdate {
|
||||
_u.mutation.ClearChildren()
|
||||
return _u
|
||||
}
|
||||
|
||||
// RemoveChildIDs removes the "children" edge to Account entities by IDs.
|
||||
func (_u *AccountUpdate) RemoveChildIDs(ids ...int64) *AccountUpdate {
|
||||
_u.mutation.RemoveChildIDs(ids...)
|
||||
return _u
|
||||
}
|
||||
|
||||
// RemoveChildren removes "children" edges to Account entities.
|
||||
func (_u *AccountUpdate) RemoveChildren(v ...*Account) *AccountUpdate {
|
||||
ids := make([]int64, len(v))
|
||||
for i := range v {
|
||||
ids[i] = v[i].ID
|
||||
}
|
||||
return _u.RemoveChildIDs(ids...)
|
||||
}
|
||||
|
||||
// ClearUsageLogs clears all "usage_logs" edges to the UsageLog entity.
|
||||
func (_u *AccountUpdate) ClearUsageLogs() *AccountUpdate {
|
||||
_u.mutation.ClearUsageLogs()
|
||||
@@ -687,6 +782,11 @@ func (_u *AccountUpdate) check() error {
|
||||
return &ValidationError{Name: "session_window_status", err: fmt.Errorf(`ent: validator failed for field "Account.session_window_status": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.QuotaDimension(); ok {
|
||||
if err := account.QuotaDimensionValidator(v); err != nil {
|
||||
return &ValidationError{Name: "quota_dimension", err: fmt.Errorf(`ent: validator failed for field "Account.quota_dimension": %w`, err)}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -843,6 +943,9 @@ func (_u *AccountUpdate) sqlSave(ctx context.Context) (_node int, err error) {
|
||||
if _u.mutation.SessionWindowStatusCleared() {
|
||||
_spec.ClearField(account.FieldSessionWindowStatus, field.TypeString)
|
||||
}
|
||||
if value, ok := _u.mutation.QuotaDimension(); ok {
|
||||
_spec.SetField(account.FieldQuotaDimension, field.TypeEnum, value)
|
||||
}
|
||||
if _u.mutation.GroupsCleared() {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.M2M,
|
||||
@@ -929,6 +1032,80 @@ func (_u *AccountUpdate) sqlSave(ctx context.Context) (_node int, err error) {
|
||||
}
|
||||
_spec.Edges.Add = append(_spec.Edges.Add, edge)
|
||||
}
|
||||
if _u.mutation.ParentCleared() {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.M2O,
|
||||
Inverse: true,
|
||||
Table: account.ParentTable,
|
||||
Columns: []string{account.ParentColumn},
|
||||
Bidi: false,
|
||||
Target: &sqlgraph.EdgeTarget{
|
||||
IDSpec: sqlgraph.NewFieldSpec(account.FieldID, field.TypeInt64),
|
||||
},
|
||||
}
|
||||
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
|
||||
}
|
||||
if nodes := _u.mutation.ParentIDs(); len(nodes) > 0 {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.M2O,
|
||||
Inverse: true,
|
||||
Table: account.ParentTable,
|
||||
Columns: []string{account.ParentColumn},
|
||||
Bidi: false,
|
||||
Target: &sqlgraph.EdgeTarget{
|
||||
IDSpec: sqlgraph.NewFieldSpec(account.FieldID, field.TypeInt64),
|
||||
},
|
||||
}
|
||||
for _, k := range nodes {
|
||||
edge.Target.Nodes = append(edge.Target.Nodes, k)
|
||||
}
|
||||
_spec.Edges.Add = append(_spec.Edges.Add, edge)
|
||||
}
|
||||
if _u.mutation.ChildrenCleared() {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.O2M,
|
||||
Inverse: false,
|
||||
Table: account.ChildrenTable,
|
||||
Columns: []string{account.ChildrenColumn},
|
||||
Bidi: false,
|
||||
Target: &sqlgraph.EdgeTarget{
|
||||
IDSpec: sqlgraph.NewFieldSpec(account.FieldID, field.TypeInt64),
|
||||
},
|
||||
}
|
||||
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
|
||||
}
|
||||
if nodes := _u.mutation.RemovedChildrenIDs(); len(nodes) > 0 && !_u.mutation.ChildrenCleared() {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.O2M,
|
||||
Inverse: false,
|
||||
Table: account.ChildrenTable,
|
||||
Columns: []string{account.ChildrenColumn},
|
||||
Bidi: false,
|
||||
Target: &sqlgraph.EdgeTarget{
|
||||
IDSpec: sqlgraph.NewFieldSpec(account.FieldID, field.TypeInt64),
|
||||
},
|
||||
}
|
||||
for _, k := range nodes {
|
||||
edge.Target.Nodes = append(edge.Target.Nodes, k)
|
||||
}
|
||||
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
|
||||
}
|
||||
if nodes := _u.mutation.ChildrenIDs(); len(nodes) > 0 {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.O2M,
|
||||
Inverse: false,
|
||||
Table: account.ChildrenTable,
|
||||
Columns: []string{account.ChildrenColumn},
|
||||
Bidi: false,
|
||||
Target: &sqlgraph.EdgeTarget{
|
||||
IDSpec: sqlgraph.NewFieldSpec(account.FieldID, field.TypeInt64),
|
||||
},
|
||||
}
|
||||
for _, k := range nodes {
|
||||
edge.Target.Nodes = append(edge.Target.Nodes, k)
|
||||
}
|
||||
_spec.Edges.Add = append(_spec.Edges.Add, edge)
|
||||
}
|
||||
if _u.mutation.UsageLogsCleared() {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.O2M,
|
||||
@@ -1493,6 +1670,40 @@ func (_u *AccountUpdateOne) ClearSessionWindowStatus() *AccountUpdateOne {
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetParentAccountID sets the "parent_account_id" field.
|
||||
func (_u *AccountUpdateOne) SetParentAccountID(v int64) *AccountUpdateOne {
|
||||
_u.mutation.SetParentAccountID(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableParentAccountID sets the "parent_account_id" field if the given value is not nil.
|
||||
func (_u *AccountUpdateOne) SetNillableParentAccountID(v *int64) *AccountUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetParentAccountID(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearParentAccountID clears the value of the "parent_account_id" field.
|
||||
func (_u *AccountUpdateOne) ClearParentAccountID() *AccountUpdateOne {
|
||||
_u.mutation.ClearParentAccountID()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetQuotaDimension sets the "quota_dimension" field.
|
||||
func (_u *AccountUpdateOne) SetQuotaDimension(v account.QuotaDimension) *AccountUpdateOne {
|
||||
_u.mutation.SetQuotaDimension(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableQuotaDimension sets the "quota_dimension" field if the given value is not nil.
|
||||
func (_u *AccountUpdateOne) SetNillableQuotaDimension(v *account.QuotaDimension) *AccountUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetQuotaDimension(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// AddGroupIDs adds the "groups" edge to the Group entity by IDs.
|
||||
func (_u *AccountUpdateOne) AddGroupIDs(ids ...int64) *AccountUpdateOne {
|
||||
_u.mutation.AddGroupIDs(ids...)
|
||||
@@ -1513,6 +1724,40 @@ func (_u *AccountUpdateOne) SetProxy(v *Proxy) *AccountUpdateOne {
|
||||
return _u.SetProxyID(v.ID)
|
||||
}
|
||||
|
||||
// SetParentID sets the "parent" edge to the Account entity by ID.
|
||||
func (_u *AccountUpdateOne) SetParentID(id int64) *AccountUpdateOne {
|
||||
_u.mutation.SetParentID(id)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableParentID sets the "parent" edge to the Account entity by ID if the given value is not nil.
|
||||
func (_u *AccountUpdateOne) SetNillableParentID(id *int64) *AccountUpdateOne {
|
||||
if id != nil {
|
||||
_u = _u.SetParentID(*id)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetParent sets the "parent" edge to the Account entity.
|
||||
func (_u *AccountUpdateOne) SetParent(v *Account) *AccountUpdateOne {
|
||||
return _u.SetParentID(v.ID)
|
||||
}
|
||||
|
||||
// AddChildIDs adds the "children" edge to the Account entity by IDs.
|
||||
func (_u *AccountUpdateOne) AddChildIDs(ids ...int64) *AccountUpdateOne {
|
||||
_u.mutation.AddChildIDs(ids...)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AddChildren adds the "children" edges to the Account entity.
|
||||
func (_u *AccountUpdateOne) AddChildren(v ...*Account) *AccountUpdateOne {
|
||||
ids := make([]int64, len(v))
|
||||
for i := range v {
|
||||
ids[i] = v[i].ID
|
||||
}
|
||||
return _u.AddChildIDs(ids...)
|
||||
}
|
||||
|
||||
// AddUsageLogIDs adds the "usage_logs" edge to the UsageLog entity by IDs.
|
||||
func (_u *AccountUpdateOne) AddUsageLogIDs(ids ...int64) *AccountUpdateOne {
|
||||
_u.mutation.AddUsageLogIDs(ids...)
|
||||
@@ -1560,6 +1805,33 @@ func (_u *AccountUpdateOne) ClearProxy() *AccountUpdateOne {
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearParent clears the "parent" edge to the Account entity.
|
||||
func (_u *AccountUpdateOne) ClearParent() *AccountUpdateOne {
|
||||
_u.mutation.ClearParent()
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearChildren clears all "children" edges to the Account entity.
|
||||
func (_u *AccountUpdateOne) ClearChildren() *AccountUpdateOne {
|
||||
_u.mutation.ClearChildren()
|
||||
return _u
|
||||
}
|
||||
|
||||
// RemoveChildIDs removes the "children" edge to Account entities by IDs.
|
||||
func (_u *AccountUpdateOne) RemoveChildIDs(ids ...int64) *AccountUpdateOne {
|
||||
_u.mutation.RemoveChildIDs(ids...)
|
||||
return _u
|
||||
}
|
||||
|
||||
// RemoveChildren removes "children" edges to Account entities.
|
||||
func (_u *AccountUpdateOne) RemoveChildren(v ...*Account) *AccountUpdateOne {
|
||||
ids := make([]int64, len(v))
|
||||
for i := range v {
|
||||
ids[i] = v[i].ID
|
||||
}
|
||||
return _u.RemoveChildIDs(ids...)
|
||||
}
|
||||
|
||||
// ClearUsageLogs clears all "usage_logs" edges to the UsageLog entity.
|
||||
func (_u *AccountUpdateOne) ClearUsageLogs() *AccountUpdateOne {
|
||||
_u.mutation.ClearUsageLogs()
|
||||
@@ -1663,6 +1935,11 @@ func (_u *AccountUpdateOne) check() error {
|
||||
return &ValidationError{Name: "session_window_status", err: fmt.Errorf(`ent: validator failed for field "Account.session_window_status": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.QuotaDimension(); ok {
|
||||
if err := account.QuotaDimensionValidator(v); err != nil {
|
||||
return &ValidationError{Name: "quota_dimension", err: fmt.Errorf(`ent: validator failed for field "Account.quota_dimension": %w`, err)}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1836,6 +2113,9 @@ func (_u *AccountUpdateOne) sqlSave(ctx context.Context) (_node *Account, err er
|
||||
if _u.mutation.SessionWindowStatusCleared() {
|
||||
_spec.ClearField(account.FieldSessionWindowStatus, field.TypeString)
|
||||
}
|
||||
if value, ok := _u.mutation.QuotaDimension(); ok {
|
||||
_spec.SetField(account.FieldQuotaDimension, field.TypeEnum, value)
|
||||
}
|
||||
if _u.mutation.GroupsCleared() {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.M2M,
|
||||
@@ -1922,6 +2202,80 @@ func (_u *AccountUpdateOne) sqlSave(ctx context.Context) (_node *Account, err er
|
||||
}
|
||||
_spec.Edges.Add = append(_spec.Edges.Add, edge)
|
||||
}
|
||||
if _u.mutation.ParentCleared() {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.M2O,
|
||||
Inverse: true,
|
||||
Table: account.ParentTable,
|
||||
Columns: []string{account.ParentColumn},
|
||||
Bidi: false,
|
||||
Target: &sqlgraph.EdgeTarget{
|
||||
IDSpec: sqlgraph.NewFieldSpec(account.FieldID, field.TypeInt64),
|
||||
},
|
||||
}
|
||||
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
|
||||
}
|
||||
if nodes := _u.mutation.ParentIDs(); len(nodes) > 0 {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.M2O,
|
||||
Inverse: true,
|
||||
Table: account.ParentTable,
|
||||
Columns: []string{account.ParentColumn},
|
||||
Bidi: false,
|
||||
Target: &sqlgraph.EdgeTarget{
|
||||
IDSpec: sqlgraph.NewFieldSpec(account.FieldID, field.TypeInt64),
|
||||
},
|
||||
}
|
||||
for _, k := range nodes {
|
||||
edge.Target.Nodes = append(edge.Target.Nodes, k)
|
||||
}
|
||||
_spec.Edges.Add = append(_spec.Edges.Add, edge)
|
||||
}
|
||||
if _u.mutation.ChildrenCleared() {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.O2M,
|
||||
Inverse: false,
|
||||
Table: account.ChildrenTable,
|
||||
Columns: []string{account.ChildrenColumn},
|
||||
Bidi: false,
|
||||
Target: &sqlgraph.EdgeTarget{
|
||||
IDSpec: sqlgraph.NewFieldSpec(account.FieldID, field.TypeInt64),
|
||||
},
|
||||
}
|
||||
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
|
||||
}
|
||||
if nodes := _u.mutation.RemovedChildrenIDs(); len(nodes) > 0 && !_u.mutation.ChildrenCleared() {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.O2M,
|
||||
Inverse: false,
|
||||
Table: account.ChildrenTable,
|
||||
Columns: []string{account.ChildrenColumn},
|
||||
Bidi: false,
|
||||
Target: &sqlgraph.EdgeTarget{
|
||||
IDSpec: sqlgraph.NewFieldSpec(account.FieldID, field.TypeInt64),
|
||||
},
|
||||
}
|
||||
for _, k := range nodes {
|
||||
edge.Target.Nodes = append(edge.Target.Nodes, k)
|
||||
}
|
||||
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
|
||||
}
|
||||
if nodes := _u.mutation.ChildrenIDs(); len(nodes) > 0 {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.O2M,
|
||||
Inverse: false,
|
||||
Table: account.ChildrenTable,
|
||||
Columns: []string{account.ChildrenColumn},
|
||||
Bidi: false,
|
||||
Target: &sqlgraph.EdgeTarget{
|
||||
IDSpec: sqlgraph.NewFieldSpec(account.FieldID, field.TypeInt64),
|
||||
},
|
||||
}
|
||||
for _, k := range nodes {
|
||||
edge.Target.Nodes = append(edge.Target.Nodes, k)
|
||||
}
|
||||
_spec.Edges.Add = append(_spec.Edges.Add, edge)
|
||||
}
|
||||
if _u.mutation.UsageLogsCleared() {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.O2M,
|
||||
|
||||
@@ -820,6 +820,38 @@ func (c *AccountClient) QueryProxy(_m *Account) *ProxyQuery {
|
||||
return query
|
||||
}
|
||||
|
||||
// QueryParent queries the parent edge of a Account.
|
||||
func (c *AccountClient) QueryParent(_m *Account) *AccountQuery {
|
||||
query := (&AccountClient{config: c.config}).Query()
|
||||
query.path = func(context.Context) (fromV *sql.Selector, _ error) {
|
||||
id := _m.ID
|
||||
step := sqlgraph.NewStep(
|
||||
sqlgraph.From(account.Table, account.FieldID, id),
|
||||
sqlgraph.To(account.Table, account.FieldID),
|
||||
sqlgraph.Edge(sqlgraph.M2O, true, account.ParentTable, account.ParentColumn),
|
||||
)
|
||||
fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step)
|
||||
return fromV, nil
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
// QueryChildren queries the children edge of a Account.
|
||||
func (c *AccountClient) QueryChildren(_m *Account) *AccountQuery {
|
||||
query := (&AccountClient{config: c.config}).Query()
|
||||
query.path = func(context.Context) (fromV *sql.Selector, _ error) {
|
||||
id := _m.ID
|
||||
step := sqlgraph.NewStep(
|
||||
sqlgraph.From(account.Table, account.FieldID, id),
|
||||
sqlgraph.To(account.Table, account.FieldID),
|
||||
sqlgraph.Edge(sqlgraph.O2M, false, account.ChildrenTable, account.ChildrenColumn),
|
||||
)
|
||||
fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step)
|
||||
return fromV, nil
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
// QueryUsageLogs queries the usage_logs edge of a Account.
|
||||
func (c *AccountClient) QueryUsageLogs(_m *Account) *UsageLogQuery {
|
||||
query := (&UsageLogClient{config: c.config}).Query()
|
||||
|
||||
@@ -31,6 +31,14 @@ type Group struct {
|
||||
Description *string `json:"description,omitempty"`
|
||||
// RateMultiplier holds the value of the "rate_multiplier" field.
|
||||
RateMultiplier float64 `json:"rate_multiplier,omitempty"`
|
||||
// 是否启用高峰时段倍率
|
||||
PeakRateEnabled bool `json:"peak_rate_enabled,omitempty"`
|
||||
// 高峰开始时间 HH:MM(含),如 14:00;空表示未配置
|
||||
PeakStart string `json:"peak_start,omitempty"`
|
||||
// 高峰结束时间 HH:MM(不含),如 18:00
|
||||
PeakEnd string `json:"peak_end,omitempty"`
|
||||
// 高峰时段叠加倍率,仅在 peak_rate_enabled 且处于 [peak_start, peak_end) 时乘入文本倍率
|
||||
PeakRateMultiplier float64 `json:"peak_rate_multiplier,omitempty"`
|
||||
// IsExclusive holds the value of the "is_exclusive" field.
|
||||
IsExclusive bool `json:"is_exclusive,omitempty"`
|
||||
// Status holds the value of the "status" field.
|
||||
@@ -197,13 +205,13 @@ func (*Group) scanValues(columns []string) ([]any, error) {
|
||||
switch columns[i] {
|
||||
case group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig:
|
||||
values[i] = new([]byte)
|
||||
case group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldImageRateIndependent, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet:
|
||||
case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldImageRateIndependent, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet:
|
||||
values[i] = new(sql.NullBool)
|
||||
case group.FieldRateMultiplier, group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd, group.FieldImageRateMultiplier, group.FieldImagePrice1k, group.FieldImagePrice2k, group.FieldImagePrice4k:
|
||||
case group.FieldRateMultiplier, group.FieldPeakRateMultiplier, group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd, group.FieldImageRateMultiplier, group.FieldImagePrice1k, group.FieldImagePrice2k, group.FieldImagePrice4k:
|
||||
values[i] = new(sql.NullFloat64)
|
||||
case group.FieldID, group.FieldDefaultValidityDays, group.FieldFallbackGroupID, group.FieldFallbackGroupIDOnInvalidRequest, group.FieldSortOrder, group.FieldRpmLimit:
|
||||
values[i] = new(sql.NullInt64)
|
||||
case group.FieldName, group.FieldDescription, group.FieldStatus, group.FieldPlatform, group.FieldSubscriptionType, group.FieldDefaultMappedModel:
|
||||
case group.FieldName, group.FieldDescription, group.FieldPeakStart, group.FieldPeakEnd, group.FieldStatus, group.FieldPlatform, group.FieldSubscriptionType, group.FieldDefaultMappedModel:
|
||||
values[i] = new(sql.NullString)
|
||||
case group.FieldCreatedAt, group.FieldUpdatedAt, group.FieldDeletedAt:
|
||||
values[i] = new(sql.NullTime)
|
||||
@@ -266,6 +274,30 @@ func (_m *Group) assignValues(columns []string, values []any) error {
|
||||
} else if value.Valid {
|
||||
_m.RateMultiplier = value.Float64
|
||||
}
|
||||
case group.FieldPeakRateEnabled:
|
||||
if value, ok := values[i].(*sql.NullBool); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field peak_rate_enabled", values[i])
|
||||
} else if value.Valid {
|
||||
_m.PeakRateEnabled = value.Bool
|
||||
}
|
||||
case group.FieldPeakStart:
|
||||
if value, ok := values[i].(*sql.NullString); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field peak_start", values[i])
|
||||
} else if value.Valid {
|
||||
_m.PeakStart = value.String
|
||||
}
|
||||
case group.FieldPeakEnd:
|
||||
if value, ok := values[i].(*sql.NullString); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field peak_end", values[i])
|
||||
} else if value.Valid {
|
||||
_m.PeakEnd = value.String
|
||||
}
|
||||
case group.FieldPeakRateMultiplier:
|
||||
if value, ok := values[i].(*sql.NullFloat64); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field peak_rate_multiplier", values[i])
|
||||
} else if value.Valid {
|
||||
_m.PeakRateMultiplier = value.Float64
|
||||
}
|
||||
case group.FieldIsExclusive:
|
||||
if value, ok := values[i].(*sql.NullBool); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field is_exclusive", values[i])
|
||||
@@ -554,6 +586,18 @@ func (_m *Group) String() string {
|
||||
builder.WriteString("rate_multiplier=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.RateMultiplier))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("peak_rate_enabled=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.PeakRateEnabled))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("peak_start=")
|
||||
builder.WriteString(_m.PeakStart)
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("peak_end=")
|
||||
builder.WriteString(_m.PeakEnd)
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("peak_rate_multiplier=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.PeakRateMultiplier))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("is_exclusive=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.IsExclusive))
|
||||
builder.WriteString(", ")
|
||||
|
||||
@@ -28,6 +28,14 @@ const (
|
||||
FieldDescription = "description"
|
||||
// FieldRateMultiplier holds the string denoting the rate_multiplier field in the database.
|
||||
FieldRateMultiplier = "rate_multiplier"
|
||||
// FieldPeakRateEnabled holds the string denoting the peak_rate_enabled field in the database.
|
||||
FieldPeakRateEnabled = "peak_rate_enabled"
|
||||
// FieldPeakStart holds the string denoting the peak_start field in the database.
|
||||
FieldPeakStart = "peak_start"
|
||||
// FieldPeakEnd holds the string denoting the peak_end field in the database.
|
||||
FieldPeakEnd = "peak_end"
|
||||
// FieldPeakRateMultiplier holds the string denoting the peak_rate_multiplier field in the database.
|
||||
FieldPeakRateMultiplier = "peak_rate_multiplier"
|
||||
// FieldIsExclusive holds the string denoting the is_exclusive field in the database.
|
||||
FieldIsExclusive = "is_exclusive"
|
||||
// FieldStatus holds the string denoting the status field in the database.
|
||||
@@ -167,6 +175,10 @@ var Columns = []string{
|
||||
FieldName,
|
||||
FieldDescription,
|
||||
FieldRateMultiplier,
|
||||
FieldPeakRateEnabled,
|
||||
FieldPeakStart,
|
||||
FieldPeakEnd,
|
||||
FieldPeakRateMultiplier,
|
||||
FieldIsExclusive,
|
||||
FieldStatus,
|
||||
FieldPlatform,
|
||||
@@ -235,6 +247,18 @@ var (
|
||||
NameValidator func(string) error
|
||||
// DefaultRateMultiplier holds the default value on creation for the "rate_multiplier" field.
|
||||
DefaultRateMultiplier float64
|
||||
// DefaultPeakRateEnabled holds the default value on creation for the "peak_rate_enabled" field.
|
||||
DefaultPeakRateEnabled bool
|
||||
// DefaultPeakStart holds the default value on creation for the "peak_start" field.
|
||||
DefaultPeakStart string
|
||||
// PeakStartValidator is a validator for the "peak_start" field. It is called by the builders before save.
|
||||
PeakStartValidator func(string) error
|
||||
// DefaultPeakEnd holds the default value on creation for the "peak_end" field.
|
||||
DefaultPeakEnd string
|
||||
// PeakEndValidator is a validator for the "peak_end" field. It is called by the builders before save.
|
||||
PeakEndValidator func(string) error
|
||||
// DefaultPeakRateMultiplier holds the default value on creation for the "peak_rate_multiplier" field.
|
||||
DefaultPeakRateMultiplier float64
|
||||
// DefaultIsExclusive holds the default value on creation for the "is_exclusive" field.
|
||||
DefaultIsExclusive bool
|
||||
// DefaultStatus holds the default value on creation for the "status" field.
|
||||
@@ -323,6 +347,26 @@ func ByRateMultiplier(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldRateMultiplier, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByPeakRateEnabled orders the results by the peak_rate_enabled field.
|
||||
func ByPeakRateEnabled(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldPeakRateEnabled, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByPeakStart orders the results by the peak_start field.
|
||||
func ByPeakStart(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldPeakStart, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByPeakEnd orders the results by the peak_end field.
|
||||
func ByPeakEnd(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldPeakEnd, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByPeakRateMultiplier orders the results by the peak_rate_multiplier field.
|
||||
func ByPeakRateMultiplier(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldPeakRateMultiplier, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByIsExclusive orders the results by the is_exclusive field.
|
||||
func ByIsExclusive(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldIsExclusive, opts...).ToFunc()
|
||||
|
||||
@@ -85,6 +85,26 @@ func RateMultiplier(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldRateMultiplier, v))
|
||||
}
|
||||
|
||||
// PeakRateEnabled applies equality check predicate on the "peak_rate_enabled" field. It's identical to PeakRateEnabledEQ.
|
||||
func PeakRateEnabled(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldPeakRateEnabled, v))
|
||||
}
|
||||
|
||||
// PeakStart applies equality check predicate on the "peak_start" field. It's identical to PeakStartEQ.
|
||||
func PeakStart(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldPeakStart, v))
|
||||
}
|
||||
|
||||
// PeakEnd applies equality check predicate on the "peak_end" field. It's identical to PeakEndEQ.
|
||||
func PeakEnd(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldPeakEnd, v))
|
||||
}
|
||||
|
||||
// PeakRateMultiplier applies equality check predicate on the "peak_rate_multiplier" field. It's identical to PeakRateMultiplierEQ.
|
||||
func PeakRateMultiplier(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldPeakRateMultiplier, v))
|
||||
}
|
||||
|
||||
// IsExclusive applies equality check predicate on the "is_exclusive" field. It's identical to IsExclusiveEQ.
|
||||
func IsExclusive(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldIsExclusive, v))
|
||||
@@ -520,6 +540,186 @@ func RateMultiplierLTE(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldLTE(FieldRateMultiplier, v))
|
||||
}
|
||||
|
||||
// PeakRateEnabledEQ applies the EQ predicate on the "peak_rate_enabled" field.
|
||||
func PeakRateEnabledEQ(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldPeakRateEnabled, v))
|
||||
}
|
||||
|
||||
// PeakRateEnabledNEQ applies the NEQ predicate on the "peak_rate_enabled" field.
|
||||
func PeakRateEnabledNEQ(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldNEQ(FieldPeakRateEnabled, v))
|
||||
}
|
||||
|
||||
// PeakStartEQ applies the EQ predicate on the "peak_start" field.
|
||||
func PeakStartEQ(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldPeakStart, v))
|
||||
}
|
||||
|
||||
// PeakStartNEQ applies the NEQ predicate on the "peak_start" field.
|
||||
func PeakStartNEQ(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldNEQ(FieldPeakStart, v))
|
||||
}
|
||||
|
||||
// PeakStartIn applies the In predicate on the "peak_start" field.
|
||||
func PeakStartIn(vs ...string) predicate.Group {
|
||||
return predicate.Group(sql.FieldIn(FieldPeakStart, vs...))
|
||||
}
|
||||
|
||||
// PeakStartNotIn applies the NotIn predicate on the "peak_start" field.
|
||||
func PeakStartNotIn(vs ...string) predicate.Group {
|
||||
return predicate.Group(sql.FieldNotIn(FieldPeakStart, vs...))
|
||||
}
|
||||
|
||||
// PeakStartGT applies the GT predicate on the "peak_start" field.
|
||||
func PeakStartGT(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldGT(FieldPeakStart, v))
|
||||
}
|
||||
|
||||
// PeakStartGTE applies the GTE predicate on the "peak_start" field.
|
||||
func PeakStartGTE(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldGTE(FieldPeakStart, v))
|
||||
}
|
||||
|
||||
// PeakStartLT applies the LT predicate on the "peak_start" field.
|
||||
func PeakStartLT(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldLT(FieldPeakStart, v))
|
||||
}
|
||||
|
||||
// PeakStartLTE applies the LTE predicate on the "peak_start" field.
|
||||
func PeakStartLTE(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldLTE(FieldPeakStart, v))
|
||||
}
|
||||
|
||||
// PeakStartContains applies the Contains predicate on the "peak_start" field.
|
||||
func PeakStartContains(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldContains(FieldPeakStart, v))
|
||||
}
|
||||
|
||||
// PeakStartHasPrefix applies the HasPrefix predicate on the "peak_start" field.
|
||||
func PeakStartHasPrefix(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldHasPrefix(FieldPeakStart, v))
|
||||
}
|
||||
|
||||
// PeakStartHasSuffix applies the HasSuffix predicate on the "peak_start" field.
|
||||
func PeakStartHasSuffix(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldHasSuffix(FieldPeakStart, v))
|
||||
}
|
||||
|
||||
// PeakStartEqualFold applies the EqualFold predicate on the "peak_start" field.
|
||||
func PeakStartEqualFold(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldEqualFold(FieldPeakStart, v))
|
||||
}
|
||||
|
||||
// PeakStartContainsFold applies the ContainsFold predicate on the "peak_start" field.
|
||||
func PeakStartContainsFold(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldContainsFold(FieldPeakStart, v))
|
||||
}
|
||||
|
||||
// PeakEndEQ applies the EQ predicate on the "peak_end" field.
|
||||
func PeakEndEQ(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldPeakEnd, v))
|
||||
}
|
||||
|
||||
// PeakEndNEQ applies the NEQ predicate on the "peak_end" field.
|
||||
func PeakEndNEQ(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldNEQ(FieldPeakEnd, v))
|
||||
}
|
||||
|
||||
// PeakEndIn applies the In predicate on the "peak_end" field.
|
||||
func PeakEndIn(vs ...string) predicate.Group {
|
||||
return predicate.Group(sql.FieldIn(FieldPeakEnd, vs...))
|
||||
}
|
||||
|
||||
// PeakEndNotIn applies the NotIn predicate on the "peak_end" field.
|
||||
func PeakEndNotIn(vs ...string) predicate.Group {
|
||||
return predicate.Group(sql.FieldNotIn(FieldPeakEnd, vs...))
|
||||
}
|
||||
|
||||
// PeakEndGT applies the GT predicate on the "peak_end" field.
|
||||
func PeakEndGT(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldGT(FieldPeakEnd, v))
|
||||
}
|
||||
|
||||
// PeakEndGTE applies the GTE predicate on the "peak_end" field.
|
||||
func PeakEndGTE(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldGTE(FieldPeakEnd, v))
|
||||
}
|
||||
|
||||
// PeakEndLT applies the LT predicate on the "peak_end" field.
|
||||
func PeakEndLT(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldLT(FieldPeakEnd, v))
|
||||
}
|
||||
|
||||
// PeakEndLTE applies the LTE predicate on the "peak_end" field.
|
||||
func PeakEndLTE(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldLTE(FieldPeakEnd, v))
|
||||
}
|
||||
|
||||
// PeakEndContains applies the Contains predicate on the "peak_end" field.
|
||||
func PeakEndContains(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldContains(FieldPeakEnd, v))
|
||||
}
|
||||
|
||||
// PeakEndHasPrefix applies the HasPrefix predicate on the "peak_end" field.
|
||||
func PeakEndHasPrefix(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldHasPrefix(FieldPeakEnd, v))
|
||||
}
|
||||
|
||||
// PeakEndHasSuffix applies the HasSuffix predicate on the "peak_end" field.
|
||||
func PeakEndHasSuffix(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldHasSuffix(FieldPeakEnd, v))
|
||||
}
|
||||
|
||||
// PeakEndEqualFold applies the EqualFold predicate on the "peak_end" field.
|
||||
func PeakEndEqualFold(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldEqualFold(FieldPeakEnd, v))
|
||||
}
|
||||
|
||||
// PeakEndContainsFold applies the ContainsFold predicate on the "peak_end" field.
|
||||
func PeakEndContainsFold(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldContainsFold(FieldPeakEnd, v))
|
||||
}
|
||||
|
||||
// PeakRateMultiplierEQ applies the EQ predicate on the "peak_rate_multiplier" field.
|
||||
func PeakRateMultiplierEQ(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldPeakRateMultiplier, v))
|
||||
}
|
||||
|
||||
// PeakRateMultiplierNEQ applies the NEQ predicate on the "peak_rate_multiplier" field.
|
||||
func PeakRateMultiplierNEQ(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldNEQ(FieldPeakRateMultiplier, v))
|
||||
}
|
||||
|
||||
// PeakRateMultiplierIn applies the In predicate on the "peak_rate_multiplier" field.
|
||||
func PeakRateMultiplierIn(vs ...float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldIn(FieldPeakRateMultiplier, vs...))
|
||||
}
|
||||
|
||||
// PeakRateMultiplierNotIn applies the NotIn predicate on the "peak_rate_multiplier" field.
|
||||
func PeakRateMultiplierNotIn(vs ...float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldNotIn(FieldPeakRateMultiplier, vs...))
|
||||
}
|
||||
|
||||
// PeakRateMultiplierGT applies the GT predicate on the "peak_rate_multiplier" field.
|
||||
func PeakRateMultiplierGT(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldGT(FieldPeakRateMultiplier, v))
|
||||
}
|
||||
|
||||
// PeakRateMultiplierGTE applies the GTE predicate on the "peak_rate_multiplier" field.
|
||||
func PeakRateMultiplierGTE(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldGTE(FieldPeakRateMultiplier, v))
|
||||
}
|
||||
|
||||
// PeakRateMultiplierLT applies the LT predicate on the "peak_rate_multiplier" field.
|
||||
func PeakRateMultiplierLT(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldLT(FieldPeakRateMultiplier, v))
|
||||
}
|
||||
|
||||
// PeakRateMultiplierLTE applies the LTE predicate on the "peak_rate_multiplier" field.
|
||||
func PeakRateMultiplierLTE(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldLTE(FieldPeakRateMultiplier, v))
|
||||
}
|
||||
|
||||
// IsExclusiveEQ applies the EQ predicate on the "is_exclusive" field.
|
||||
func IsExclusiveEQ(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldIsExclusive, v))
|
||||
|
||||
@@ -105,6 +105,62 @@ func (_c *GroupCreate) SetNillableRateMultiplier(v *float64) *GroupCreate {
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetPeakRateEnabled sets the "peak_rate_enabled" field.
|
||||
func (_c *GroupCreate) SetPeakRateEnabled(v bool) *GroupCreate {
|
||||
_c.mutation.SetPeakRateEnabled(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillablePeakRateEnabled sets the "peak_rate_enabled" field if the given value is not nil.
|
||||
func (_c *GroupCreate) SetNillablePeakRateEnabled(v *bool) *GroupCreate {
|
||||
if v != nil {
|
||||
_c.SetPeakRateEnabled(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetPeakStart sets the "peak_start" field.
|
||||
func (_c *GroupCreate) SetPeakStart(v string) *GroupCreate {
|
||||
_c.mutation.SetPeakStart(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillablePeakStart sets the "peak_start" field if the given value is not nil.
|
||||
func (_c *GroupCreate) SetNillablePeakStart(v *string) *GroupCreate {
|
||||
if v != nil {
|
||||
_c.SetPeakStart(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetPeakEnd sets the "peak_end" field.
|
||||
func (_c *GroupCreate) SetPeakEnd(v string) *GroupCreate {
|
||||
_c.mutation.SetPeakEnd(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillablePeakEnd sets the "peak_end" field if the given value is not nil.
|
||||
func (_c *GroupCreate) SetNillablePeakEnd(v *string) *GroupCreate {
|
||||
if v != nil {
|
||||
_c.SetPeakEnd(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetPeakRateMultiplier sets the "peak_rate_multiplier" field.
|
||||
func (_c *GroupCreate) SetPeakRateMultiplier(v float64) *GroupCreate {
|
||||
_c.mutation.SetPeakRateMultiplier(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillablePeakRateMultiplier sets the "peak_rate_multiplier" field if the given value is not nil.
|
||||
func (_c *GroupCreate) SetNillablePeakRateMultiplier(v *float64) *GroupCreate {
|
||||
if v != nil {
|
||||
_c.SetPeakRateMultiplier(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetIsExclusive sets the "is_exclusive" field.
|
||||
func (_c *GroupCreate) SetIsExclusive(v bool) *GroupCreate {
|
||||
_c.mutation.SetIsExclusive(v)
|
||||
@@ -640,6 +696,22 @@ func (_c *GroupCreate) defaults() error {
|
||||
v := group.DefaultRateMultiplier
|
||||
_c.mutation.SetRateMultiplier(v)
|
||||
}
|
||||
if _, ok := _c.mutation.PeakRateEnabled(); !ok {
|
||||
v := group.DefaultPeakRateEnabled
|
||||
_c.mutation.SetPeakRateEnabled(v)
|
||||
}
|
||||
if _, ok := _c.mutation.PeakStart(); !ok {
|
||||
v := group.DefaultPeakStart
|
||||
_c.mutation.SetPeakStart(v)
|
||||
}
|
||||
if _, ok := _c.mutation.PeakEnd(); !ok {
|
||||
v := group.DefaultPeakEnd
|
||||
_c.mutation.SetPeakEnd(v)
|
||||
}
|
||||
if _, ok := _c.mutation.PeakRateMultiplier(); !ok {
|
||||
v := group.DefaultPeakRateMultiplier
|
||||
_c.mutation.SetPeakRateMultiplier(v)
|
||||
}
|
||||
if _, ok := _c.mutation.IsExclusive(); !ok {
|
||||
v := group.DefaultIsExclusive
|
||||
_c.mutation.SetIsExclusive(v)
|
||||
@@ -742,6 +814,28 @@ func (_c *GroupCreate) check() error {
|
||||
if _, ok := _c.mutation.RateMultiplier(); !ok {
|
||||
return &ValidationError{Name: "rate_multiplier", err: errors.New(`ent: missing required field "Group.rate_multiplier"`)}
|
||||
}
|
||||
if _, ok := _c.mutation.PeakRateEnabled(); !ok {
|
||||
return &ValidationError{Name: "peak_rate_enabled", err: errors.New(`ent: missing required field "Group.peak_rate_enabled"`)}
|
||||
}
|
||||
if _, ok := _c.mutation.PeakStart(); !ok {
|
||||
return &ValidationError{Name: "peak_start", err: errors.New(`ent: missing required field "Group.peak_start"`)}
|
||||
}
|
||||
if v, ok := _c.mutation.PeakStart(); ok {
|
||||
if err := group.PeakStartValidator(v); err != nil {
|
||||
return &ValidationError{Name: "peak_start", err: fmt.Errorf(`ent: validator failed for field "Group.peak_start": %w`, err)}
|
||||
}
|
||||
}
|
||||
if _, ok := _c.mutation.PeakEnd(); !ok {
|
||||
return &ValidationError{Name: "peak_end", err: errors.New(`ent: missing required field "Group.peak_end"`)}
|
||||
}
|
||||
if v, ok := _c.mutation.PeakEnd(); ok {
|
||||
if err := group.PeakEndValidator(v); err != nil {
|
||||
return &ValidationError{Name: "peak_end", err: fmt.Errorf(`ent: validator failed for field "Group.peak_end": %w`, err)}
|
||||
}
|
||||
}
|
||||
if _, ok := _c.mutation.PeakRateMultiplier(); !ok {
|
||||
return &ValidationError{Name: "peak_rate_multiplier", err: errors.New(`ent: missing required field "Group.peak_rate_multiplier"`)}
|
||||
}
|
||||
if _, ok := _c.mutation.IsExclusive(); !ok {
|
||||
return &ValidationError{Name: "is_exclusive", err: errors.New(`ent: missing required field "Group.is_exclusive"`)}
|
||||
}
|
||||
@@ -873,6 +967,22 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) {
|
||||
_spec.SetField(group.FieldRateMultiplier, field.TypeFloat64, value)
|
||||
_node.RateMultiplier = value
|
||||
}
|
||||
if value, ok := _c.mutation.PeakRateEnabled(); ok {
|
||||
_spec.SetField(group.FieldPeakRateEnabled, field.TypeBool, value)
|
||||
_node.PeakRateEnabled = value
|
||||
}
|
||||
if value, ok := _c.mutation.PeakStart(); ok {
|
||||
_spec.SetField(group.FieldPeakStart, field.TypeString, value)
|
||||
_node.PeakStart = value
|
||||
}
|
||||
if value, ok := _c.mutation.PeakEnd(); ok {
|
||||
_spec.SetField(group.FieldPeakEnd, field.TypeString, value)
|
||||
_node.PeakEnd = value
|
||||
}
|
||||
if value, ok := _c.mutation.PeakRateMultiplier(); ok {
|
||||
_spec.SetField(group.FieldPeakRateMultiplier, field.TypeFloat64, value)
|
||||
_node.PeakRateMultiplier = value
|
||||
}
|
||||
if value, ok := _c.mutation.IsExclusive(); ok {
|
||||
_spec.SetField(group.FieldIsExclusive, field.TypeBool, value)
|
||||
_node.IsExclusive = value
|
||||
@@ -1223,6 +1333,60 @@ func (u *GroupUpsert) AddRateMultiplier(v float64) *GroupUpsert {
|
||||
return u
|
||||
}
|
||||
|
||||
// SetPeakRateEnabled sets the "peak_rate_enabled" field.
|
||||
func (u *GroupUpsert) SetPeakRateEnabled(v bool) *GroupUpsert {
|
||||
u.Set(group.FieldPeakRateEnabled, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdatePeakRateEnabled sets the "peak_rate_enabled" field to the value that was provided on create.
|
||||
func (u *GroupUpsert) UpdatePeakRateEnabled() *GroupUpsert {
|
||||
u.SetExcluded(group.FieldPeakRateEnabled)
|
||||
return u
|
||||
}
|
||||
|
||||
// SetPeakStart sets the "peak_start" field.
|
||||
func (u *GroupUpsert) SetPeakStart(v string) *GroupUpsert {
|
||||
u.Set(group.FieldPeakStart, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdatePeakStart sets the "peak_start" field to the value that was provided on create.
|
||||
func (u *GroupUpsert) UpdatePeakStart() *GroupUpsert {
|
||||
u.SetExcluded(group.FieldPeakStart)
|
||||
return u
|
||||
}
|
||||
|
||||
// SetPeakEnd sets the "peak_end" field.
|
||||
func (u *GroupUpsert) SetPeakEnd(v string) *GroupUpsert {
|
||||
u.Set(group.FieldPeakEnd, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdatePeakEnd sets the "peak_end" field to the value that was provided on create.
|
||||
func (u *GroupUpsert) UpdatePeakEnd() *GroupUpsert {
|
||||
u.SetExcluded(group.FieldPeakEnd)
|
||||
return u
|
||||
}
|
||||
|
||||
// SetPeakRateMultiplier sets the "peak_rate_multiplier" field.
|
||||
func (u *GroupUpsert) SetPeakRateMultiplier(v float64) *GroupUpsert {
|
||||
u.Set(group.FieldPeakRateMultiplier, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdatePeakRateMultiplier sets the "peak_rate_multiplier" field to the value that was provided on create.
|
||||
func (u *GroupUpsert) UpdatePeakRateMultiplier() *GroupUpsert {
|
||||
u.SetExcluded(group.FieldPeakRateMultiplier)
|
||||
return u
|
||||
}
|
||||
|
||||
// AddPeakRateMultiplier adds v to the "peak_rate_multiplier" field.
|
||||
func (u *GroupUpsert) AddPeakRateMultiplier(v float64) *GroupUpsert {
|
||||
u.Add(group.FieldPeakRateMultiplier, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// SetIsExclusive sets the "is_exclusive" field.
|
||||
func (u *GroupUpsert) SetIsExclusive(v bool) *GroupUpsert {
|
||||
u.Set(group.FieldIsExclusive, v)
|
||||
@@ -1833,6 +1997,69 @@ func (u *GroupUpsertOne) UpdateRateMultiplier() *GroupUpsertOne {
|
||||
})
|
||||
}
|
||||
|
||||
// SetPeakRateEnabled sets the "peak_rate_enabled" field.
|
||||
func (u *GroupUpsertOne) SetPeakRateEnabled(v bool) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetPeakRateEnabled(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdatePeakRateEnabled sets the "peak_rate_enabled" field to the value that was provided on create.
|
||||
func (u *GroupUpsertOne) UpdatePeakRateEnabled() *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdatePeakRateEnabled()
|
||||
})
|
||||
}
|
||||
|
||||
// SetPeakStart sets the "peak_start" field.
|
||||
func (u *GroupUpsertOne) SetPeakStart(v string) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetPeakStart(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdatePeakStart sets the "peak_start" field to the value that was provided on create.
|
||||
func (u *GroupUpsertOne) UpdatePeakStart() *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdatePeakStart()
|
||||
})
|
||||
}
|
||||
|
||||
// SetPeakEnd sets the "peak_end" field.
|
||||
func (u *GroupUpsertOne) SetPeakEnd(v string) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetPeakEnd(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdatePeakEnd sets the "peak_end" field to the value that was provided on create.
|
||||
func (u *GroupUpsertOne) UpdatePeakEnd() *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdatePeakEnd()
|
||||
})
|
||||
}
|
||||
|
||||
// SetPeakRateMultiplier sets the "peak_rate_multiplier" field.
|
||||
func (u *GroupUpsertOne) SetPeakRateMultiplier(v float64) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetPeakRateMultiplier(v)
|
||||
})
|
||||
}
|
||||
|
||||
// AddPeakRateMultiplier adds v to the "peak_rate_multiplier" field.
|
||||
func (u *GroupUpsertOne) AddPeakRateMultiplier(v float64) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.AddPeakRateMultiplier(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdatePeakRateMultiplier sets the "peak_rate_multiplier" field to the value that was provided on create.
|
||||
func (u *GroupUpsertOne) UpdatePeakRateMultiplier() *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdatePeakRateMultiplier()
|
||||
})
|
||||
}
|
||||
|
||||
// SetIsExclusive sets the "is_exclusive" field.
|
||||
func (u *GroupUpsertOne) SetIsExclusive(v bool) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
@@ -2688,6 +2915,69 @@ func (u *GroupUpsertBulk) UpdateRateMultiplier() *GroupUpsertBulk {
|
||||
})
|
||||
}
|
||||
|
||||
// SetPeakRateEnabled sets the "peak_rate_enabled" field.
|
||||
func (u *GroupUpsertBulk) SetPeakRateEnabled(v bool) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetPeakRateEnabled(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdatePeakRateEnabled sets the "peak_rate_enabled" field to the value that was provided on create.
|
||||
func (u *GroupUpsertBulk) UpdatePeakRateEnabled() *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdatePeakRateEnabled()
|
||||
})
|
||||
}
|
||||
|
||||
// SetPeakStart sets the "peak_start" field.
|
||||
func (u *GroupUpsertBulk) SetPeakStart(v string) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetPeakStart(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdatePeakStart sets the "peak_start" field to the value that was provided on create.
|
||||
func (u *GroupUpsertBulk) UpdatePeakStart() *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdatePeakStart()
|
||||
})
|
||||
}
|
||||
|
||||
// SetPeakEnd sets the "peak_end" field.
|
||||
func (u *GroupUpsertBulk) SetPeakEnd(v string) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetPeakEnd(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdatePeakEnd sets the "peak_end" field to the value that was provided on create.
|
||||
func (u *GroupUpsertBulk) UpdatePeakEnd() *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdatePeakEnd()
|
||||
})
|
||||
}
|
||||
|
||||
// SetPeakRateMultiplier sets the "peak_rate_multiplier" field.
|
||||
func (u *GroupUpsertBulk) SetPeakRateMultiplier(v float64) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetPeakRateMultiplier(v)
|
||||
})
|
||||
}
|
||||
|
||||
// AddPeakRateMultiplier adds v to the "peak_rate_multiplier" field.
|
||||
func (u *GroupUpsertBulk) AddPeakRateMultiplier(v float64) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.AddPeakRateMultiplier(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdatePeakRateMultiplier sets the "peak_rate_multiplier" field to the value that was provided on create.
|
||||
func (u *GroupUpsertBulk) UpdatePeakRateMultiplier() *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdatePeakRateMultiplier()
|
||||
})
|
||||
}
|
||||
|
||||
// SetIsExclusive sets the "is_exclusive" field.
|
||||
func (u *GroupUpsertBulk) SetIsExclusive(v bool) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
|
||||
@@ -117,6 +117,69 @@ func (_u *GroupUpdate) AddRateMultiplier(v float64) *GroupUpdate {
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetPeakRateEnabled sets the "peak_rate_enabled" field.
|
||||
func (_u *GroupUpdate) SetPeakRateEnabled(v bool) *GroupUpdate {
|
||||
_u.mutation.SetPeakRateEnabled(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillablePeakRateEnabled sets the "peak_rate_enabled" field if the given value is not nil.
|
||||
func (_u *GroupUpdate) SetNillablePeakRateEnabled(v *bool) *GroupUpdate {
|
||||
if v != nil {
|
||||
_u.SetPeakRateEnabled(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetPeakStart sets the "peak_start" field.
|
||||
func (_u *GroupUpdate) SetPeakStart(v string) *GroupUpdate {
|
||||
_u.mutation.SetPeakStart(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillablePeakStart sets the "peak_start" field if the given value is not nil.
|
||||
func (_u *GroupUpdate) SetNillablePeakStart(v *string) *GroupUpdate {
|
||||
if v != nil {
|
||||
_u.SetPeakStart(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetPeakEnd sets the "peak_end" field.
|
||||
func (_u *GroupUpdate) SetPeakEnd(v string) *GroupUpdate {
|
||||
_u.mutation.SetPeakEnd(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillablePeakEnd sets the "peak_end" field if the given value is not nil.
|
||||
func (_u *GroupUpdate) SetNillablePeakEnd(v *string) *GroupUpdate {
|
||||
if v != nil {
|
||||
_u.SetPeakEnd(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetPeakRateMultiplier sets the "peak_rate_multiplier" field.
|
||||
func (_u *GroupUpdate) SetPeakRateMultiplier(v float64) *GroupUpdate {
|
||||
_u.mutation.ResetPeakRateMultiplier()
|
||||
_u.mutation.SetPeakRateMultiplier(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillablePeakRateMultiplier sets the "peak_rate_multiplier" field if the given value is not nil.
|
||||
func (_u *GroupUpdate) SetNillablePeakRateMultiplier(v *float64) *GroupUpdate {
|
||||
if v != nil {
|
||||
_u.SetPeakRateMultiplier(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// AddPeakRateMultiplier adds value to the "peak_rate_multiplier" field.
|
||||
func (_u *GroupUpdate) AddPeakRateMultiplier(v float64) *GroupUpdate {
|
||||
_u.mutation.AddPeakRateMultiplier(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetIsExclusive sets the "is_exclusive" field.
|
||||
func (_u *GroupUpdate) SetIsExclusive(v bool) *GroupUpdate {
|
||||
_u.mutation.SetIsExclusive(v)
|
||||
@@ -921,6 +984,16 @@ func (_u *GroupUpdate) check() error {
|
||||
return &ValidationError{Name: "name", err: fmt.Errorf(`ent: validator failed for field "Group.name": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.PeakStart(); ok {
|
||||
if err := group.PeakStartValidator(v); err != nil {
|
||||
return &ValidationError{Name: "peak_start", err: fmt.Errorf(`ent: validator failed for field "Group.peak_start": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.PeakEnd(); ok {
|
||||
if err := group.PeakEndValidator(v); err != nil {
|
||||
return &ValidationError{Name: "peak_end", err: fmt.Errorf(`ent: validator failed for field "Group.peak_end": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.Status(); ok {
|
||||
if err := group.StatusValidator(v); err != nil {
|
||||
return &ValidationError{Name: "status", err: fmt.Errorf(`ent: validator failed for field "Group.status": %w`, err)}
|
||||
@@ -980,6 +1053,21 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
|
||||
if value, ok := _u.mutation.AddedRateMultiplier(); ok {
|
||||
_spec.AddField(group.FieldRateMultiplier, field.TypeFloat64, value)
|
||||
}
|
||||
if value, ok := _u.mutation.PeakRateEnabled(); ok {
|
||||
_spec.SetField(group.FieldPeakRateEnabled, field.TypeBool, value)
|
||||
}
|
||||
if value, ok := _u.mutation.PeakStart(); ok {
|
||||
_spec.SetField(group.FieldPeakStart, field.TypeString, value)
|
||||
}
|
||||
if value, ok := _u.mutation.PeakEnd(); ok {
|
||||
_spec.SetField(group.FieldPeakEnd, field.TypeString, value)
|
||||
}
|
||||
if value, ok := _u.mutation.PeakRateMultiplier(); ok {
|
||||
_spec.SetField(group.FieldPeakRateMultiplier, field.TypeFloat64, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AddedPeakRateMultiplier(); ok {
|
||||
_spec.AddField(group.FieldPeakRateMultiplier, field.TypeFloat64, value)
|
||||
}
|
||||
if value, ok := _u.mutation.IsExclusive(); ok {
|
||||
_spec.SetField(group.FieldIsExclusive, field.TypeBool, value)
|
||||
}
|
||||
@@ -1530,6 +1618,69 @@ func (_u *GroupUpdateOne) AddRateMultiplier(v float64) *GroupUpdateOne {
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetPeakRateEnabled sets the "peak_rate_enabled" field.
|
||||
func (_u *GroupUpdateOne) SetPeakRateEnabled(v bool) *GroupUpdateOne {
|
||||
_u.mutation.SetPeakRateEnabled(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillablePeakRateEnabled sets the "peak_rate_enabled" field if the given value is not nil.
|
||||
func (_u *GroupUpdateOne) SetNillablePeakRateEnabled(v *bool) *GroupUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetPeakRateEnabled(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetPeakStart sets the "peak_start" field.
|
||||
func (_u *GroupUpdateOne) SetPeakStart(v string) *GroupUpdateOne {
|
||||
_u.mutation.SetPeakStart(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillablePeakStart sets the "peak_start" field if the given value is not nil.
|
||||
func (_u *GroupUpdateOne) SetNillablePeakStart(v *string) *GroupUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetPeakStart(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetPeakEnd sets the "peak_end" field.
|
||||
func (_u *GroupUpdateOne) SetPeakEnd(v string) *GroupUpdateOne {
|
||||
_u.mutation.SetPeakEnd(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillablePeakEnd sets the "peak_end" field if the given value is not nil.
|
||||
func (_u *GroupUpdateOne) SetNillablePeakEnd(v *string) *GroupUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetPeakEnd(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetPeakRateMultiplier sets the "peak_rate_multiplier" field.
|
||||
func (_u *GroupUpdateOne) SetPeakRateMultiplier(v float64) *GroupUpdateOne {
|
||||
_u.mutation.ResetPeakRateMultiplier()
|
||||
_u.mutation.SetPeakRateMultiplier(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillablePeakRateMultiplier sets the "peak_rate_multiplier" field if the given value is not nil.
|
||||
func (_u *GroupUpdateOne) SetNillablePeakRateMultiplier(v *float64) *GroupUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetPeakRateMultiplier(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// AddPeakRateMultiplier adds value to the "peak_rate_multiplier" field.
|
||||
func (_u *GroupUpdateOne) AddPeakRateMultiplier(v float64) *GroupUpdateOne {
|
||||
_u.mutation.AddPeakRateMultiplier(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetIsExclusive sets the "is_exclusive" field.
|
||||
func (_u *GroupUpdateOne) SetIsExclusive(v bool) *GroupUpdateOne {
|
||||
_u.mutation.SetIsExclusive(v)
|
||||
@@ -2347,6 +2498,16 @@ func (_u *GroupUpdateOne) check() error {
|
||||
return &ValidationError{Name: "name", err: fmt.Errorf(`ent: validator failed for field "Group.name": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.PeakStart(); ok {
|
||||
if err := group.PeakStartValidator(v); err != nil {
|
||||
return &ValidationError{Name: "peak_start", err: fmt.Errorf(`ent: validator failed for field "Group.peak_start": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.PeakEnd(); ok {
|
||||
if err := group.PeakEndValidator(v); err != nil {
|
||||
return &ValidationError{Name: "peak_end", err: fmt.Errorf(`ent: validator failed for field "Group.peak_end": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.Status(); ok {
|
||||
if err := group.StatusValidator(v); err != nil {
|
||||
return &ValidationError{Name: "status", err: fmt.Errorf(`ent: validator failed for field "Group.status": %w`, err)}
|
||||
@@ -2423,6 +2584,21 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
|
||||
if value, ok := _u.mutation.AddedRateMultiplier(); ok {
|
||||
_spec.AddField(group.FieldRateMultiplier, field.TypeFloat64, value)
|
||||
}
|
||||
if value, ok := _u.mutation.PeakRateEnabled(); ok {
|
||||
_spec.SetField(group.FieldPeakRateEnabled, field.TypeBool, value)
|
||||
}
|
||||
if value, ok := _u.mutation.PeakStart(); ok {
|
||||
_spec.SetField(group.FieldPeakStart, field.TypeString, value)
|
||||
}
|
||||
if value, ok := _u.mutation.PeakEnd(); ok {
|
||||
_spec.SetField(group.FieldPeakEnd, field.TypeString, value)
|
||||
}
|
||||
if value, ok := _u.mutation.PeakRateMultiplier(); ok {
|
||||
_spec.SetField(group.FieldPeakRateMultiplier, field.TypeFloat64, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AddedPeakRateMultiplier(); ok {
|
||||
_spec.AddField(group.FieldPeakRateMultiplier, field.TypeFloat64, value)
|
||||
}
|
||||
if value, ok := _u.mutation.IsExclusive(); ok {
|
||||
_spec.SetField(group.FieldIsExclusive, field.TypeBool, value)
|
||||
}
|
||||
|
||||
@@ -46,6 +46,16 @@ func TestPaymentOrdersOutTradeNoPartialUniqueIndex(t *testing.T) {
|
||||
require.Equal(t, (&entsql.IndexAnnotation{Where: "out_trade_no <> ''"}).Where, idx.Annotation.Where)
|
||||
}
|
||||
|
||||
func TestAccountsParentAccountForeignKey(t *testing.T) {
|
||||
fk := findForeignKeyByColumn(t, AccountsTable, "parent_account_id")
|
||||
require.Len(t, fk.Columns, 1)
|
||||
require.Equal(t, "parent_account_id", fk.Columns[0].Name)
|
||||
require.False(t, fk.Columns[0].Unique, "active-shadow uniqueness is enforced by the partial uq_accounts_spark_shadow_per_parent index")
|
||||
require.Len(t, fk.RefColumns, 1)
|
||||
require.Equal(t, "id", fk.RefColumns[0].Name)
|
||||
require.Equal(t, entschema.Restrict, fk.OnDelete)
|
||||
}
|
||||
|
||||
func findForeignKeyBySymbol(t *testing.T, table *entschema.Table, symbol string) *entschema.ForeignKey {
|
||||
t.Helper()
|
||||
|
||||
@@ -71,3 +81,18 @@ func findIndexByName(t *testing.T, table *entschema.Table, name string) *entsche
|
||||
require.Failf(t, "missing index", "table %s should include index %s", table.Name, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func findForeignKeyByColumn(t *testing.T, table *entschema.Table, column string) *entschema.ForeignKey {
|
||||
t.Helper()
|
||||
|
||||
for _, fk := range table.ForeignKeys {
|
||||
for _, col := range fk.Columns {
|
||||
if col.Name == column {
|
||||
return fk
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
require.Failf(t, "missing foreign key", "table %s should include foreign key for column %s", table.Name, column)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -124,7 +124,9 @@ var (
|
||||
{Name: "session_window_start", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}},
|
||||
{Name: "session_window_end", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}},
|
||||
{Name: "session_window_status", Type: field.TypeString, Nullable: true, Size: 20},
|
||||
{Name: "quota_dimension", Type: field.TypeEnum, Enums: []string{"global", "spark"}, Default: "global"},
|
||||
{Name: "proxy_id", Type: field.TypeInt64, Nullable: true},
|
||||
{Name: "parent_account_id", Type: field.TypeInt64, Nullable: true},
|
||||
}
|
||||
// AccountsTable holds the schema information for the "accounts" table.
|
||||
AccountsTable = &schema.Table{
|
||||
@@ -134,10 +136,16 @@ var (
|
||||
ForeignKeys: []*schema.ForeignKey{
|
||||
{
|
||||
Symbol: "accounts_proxies_proxy",
|
||||
Columns: []*schema.Column{AccountsColumns[29]},
|
||||
Columns: []*schema.Column{AccountsColumns[30]},
|
||||
RefColumns: []*schema.Column{ProxiesColumns[0]},
|
||||
OnDelete: schema.SetNull,
|
||||
},
|
||||
{
|
||||
Symbol: "accounts_accounts_children",
|
||||
Columns: []*schema.Column{AccountsColumns[31]},
|
||||
RefColumns: []*schema.Column{AccountsColumns[0]},
|
||||
OnDelete: schema.Restrict,
|
||||
},
|
||||
},
|
||||
Indexes: []*schema.Index{
|
||||
{
|
||||
@@ -158,7 +166,7 @@ var (
|
||||
{
|
||||
Name: "account_proxy_id",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{AccountsColumns[29]},
|
||||
Columns: []*schema.Column{AccountsColumns[30]},
|
||||
},
|
||||
{
|
||||
Name: "account_priority",
|
||||
@@ -205,6 +213,11 @@ var (
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{AccountsColumns[3]},
|
||||
},
|
||||
{
|
||||
Name: "account_parent_account_id",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{AccountsColumns[31]},
|
||||
},
|
||||
},
|
||||
}
|
||||
// AccountGroupsColumns holds the columns for the "account_groups" table.
|
||||
@@ -644,6 +657,10 @@ var (
|
||||
{Name: "name", Type: field.TypeString, Size: 100},
|
||||
{Name: "description", Type: field.TypeString, Nullable: true, SchemaType: map[string]string{"postgres": "text"}},
|
||||
{Name: "rate_multiplier", Type: field.TypeFloat64, Default: 1, SchemaType: map[string]string{"postgres": "decimal(10,4)"}},
|
||||
{Name: "peak_rate_enabled", Type: field.TypeBool, Default: false},
|
||||
{Name: "peak_start", Type: field.TypeString, Size: 5, Default: ""},
|
||||
{Name: "peak_end", Type: field.TypeString, Size: 5, Default: ""},
|
||||
{Name: "peak_rate_multiplier", Type: field.TypeFloat64, Default: 1, SchemaType: map[string]string{"postgres": "decimal(10,4)"}},
|
||||
{Name: "is_exclusive", Type: field.TypeBool, Default: false},
|
||||
{Name: "status", Type: field.TypeString, Size: 20, Default: "active"},
|
||||
{Name: "platform", Type: field.TypeString, Size: 50, Default: "anthropic"},
|
||||
@@ -683,22 +700,22 @@ var (
|
||||
{
|
||||
Name: "group_status",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{GroupsColumns[8]},
|
||||
Columns: []*schema.Column{GroupsColumns[12]},
|
||||
},
|
||||
{
|
||||
Name: "group_platform",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{GroupsColumns[9]},
|
||||
Columns: []*schema.Column{GroupsColumns[13]},
|
||||
},
|
||||
{
|
||||
Name: "group_subscription_type",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{GroupsColumns[10]},
|
||||
Columns: []*schema.Column{GroupsColumns[14]},
|
||||
},
|
||||
{
|
||||
Name: "group_is_exclusive",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{GroupsColumns[7]},
|
||||
Columns: []*schema.Column{GroupsColumns[11]},
|
||||
},
|
||||
{
|
||||
Name: "group_deleted_at",
|
||||
@@ -708,7 +725,7 @@ var (
|
||||
{
|
||||
Name: "group_sort_order",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{GroupsColumns[28]},
|
||||
Columns: []*schema.Column{GroupsColumns[32]},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1820,6 +1837,7 @@ func init() {
|
||||
Table: "api_keys",
|
||||
}
|
||||
AccountsTable.ForeignKeys[0].RefTable = ProxiesTable
|
||||
AccountsTable.ForeignKeys[1].RefTable = AccountsTable
|
||||
AccountsTable.Annotation = &entsql.Annotation{
|
||||
Table: "accounts",
|
||||
}
|
||||
|
||||
@@ -2310,12 +2310,18 @@ type AccountMutation struct {
|
||||
session_window_start *time.Time
|
||||
session_window_end *time.Time
|
||||
session_window_status *string
|
||||
quota_dimension *account.QuotaDimension
|
||||
clearedFields map[string]struct{}
|
||||
groups map[int64]struct{}
|
||||
removedgroups map[int64]struct{}
|
||||
clearedgroups bool
|
||||
proxy *int64
|
||||
clearedproxy bool
|
||||
parent *int64
|
||||
clearedparent bool
|
||||
children map[int64]struct{}
|
||||
removedchildren map[int64]struct{}
|
||||
clearedchildren bool
|
||||
usage_logs map[int64]struct{}
|
||||
removedusage_logs map[int64]struct{}
|
||||
clearedusage_logs bool
|
||||
@@ -3776,6 +3782,91 @@ func (m *AccountMutation) ResetSessionWindowStatus() {
|
||||
delete(m.clearedFields, account.FieldSessionWindowStatus)
|
||||
}
|
||||
|
||||
// SetParentAccountID sets the "parent_account_id" field.
|
||||
func (m *AccountMutation) SetParentAccountID(i int64) {
|
||||
m.parent = &i
|
||||
}
|
||||
|
||||
// ParentAccountID returns the value of the "parent_account_id" field in the mutation.
|
||||
func (m *AccountMutation) ParentAccountID() (r int64, exists bool) {
|
||||
v := m.parent
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldParentAccountID returns the old "parent_account_id" field's value of the Account entity.
|
||||
// If the Account object wasn't provided to the builder, the object is fetched from the database.
|
||||
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
|
||||
func (m *AccountMutation) OldParentAccountID(ctx context.Context) (v *int64, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldParentAccountID is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldParentAccountID requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldParentAccountID: %w", err)
|
||||
}
|
||||
return oldValue.ParentAccountID, nil
|
||||
}
|
||||
|
||||
// ClearParentAccountID clears the value of the "parent_account_id" field.
|
||||
func (m *AccountMutation) ClearParentAccountID() {
|
||||
m.parent = nil
|
||||
m.clearedFields[account.FieldParentAccountID] = struct{}{}
|
||||
}
|
||||
|
||||
// ParentAccountIDCleared returns if the "parent_account_id" field was cleared in this mutation.
|
||||
func (m *AccountMutation) ParentAccountIDCleared() bool {
|
||||
_, ok := m.clearedFields[account.FieldParentAccountID]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ResetParentAccountID resets all changes to the "parent_account_id" field.
|
||||
func (m *AccountMutation) ResetParentAccountID() {
|
||||
m.parent = nil
|
||||
delete(m.clearedFields, account.FieldParentAccountID)
|
||||
}
|
||||
|
||||
// SetQuotaDimension sets the "quota_dimension" field.
|
||||
func (m *AccountMutation) SetQuotaDimension(ad account.QuotaDimension) {
|
||||
m.quota_dimension = &ad
|
||||
}
|
||||
|
||||
// QuotaDimension returns the value of the "quota_dimension" field in the mutation.
|
||||
func (m *AccountMutation) QuotaDimension() (r account.QuotaDimension, exists bool) {
|
||||
v := m.quota_dimension
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldQuotaDimension returns the old "quota_dimension" field's value of the Account entity.
|
||||
// If the Account object wasn't provided to the builder, the object is fetched from the database.
|
||||
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
|
||||
func (m *AccountMutation) OldQuotaDimension(ctx context.Context) (v account.QuotaDimension, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldQuotaDimension is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldQuotaDimension requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldQuotaDimension: %w", err)
|
||||
}
|
||||
return oldValue.QuotaDimension, nil
|
||||
}
|
||||
|
||||
// ResetQuotaDimension resets all changes to the "quota_dimension" field.
|
||||
func (m *AccountMutation) ResetQuotaDimension() {
|
||||
m.quota_dimension = nil
|
||||
}
|
||||
|
||||
// AddGroupIDs adds the "groups" edge to the Group entity by ids.
|
||||
func (m *AccountMutation) AddGroupIDs(ids ...int64) {
|
||||
if m.groups == nil {
|
||||
@@ -3857,6 +3948,100 @@ func (m *AccountMutation) ResetProxy() {
|
||||
m.clearedproxy = false
|
||||
}
|
||||
|
||||
// SetParentID sets the "parent" edge to the Account entity by id.
|
||||
func (m *AccountMutation) SetParentID(id int64) {
|
||||
m.parent = &id
|
||||
}
|
||||
|
||||
// ClearParent clears the "parent" edge to the Account entity.
|
||||
func (m *AccountMutation) ClearParent() {
|
||||
m.clearedparent = true
|
||||
m.clearedFields[account.FieldParentAccountID] = struct{}{}
|
||||
}
|
||||
|
||||
// ParentCleared reports if the "parent" edge to the Account entity was cleared.
|
||||
func (m *AccountMutation) ParentCleared() bool {
|
||||
return m.ParentAccountIDCleared() || m.clearedparent
|
||||
}
|
||||
|
||||
// ParentID returns the "parent" edge ID in the mutation.
|
||||
func (m *AccountMutation) ParentID() (id int64, exists bool) {
|
||||
if m.parent != nil {
|
||||
return *m.parent, true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ParentIDs returns the "parent" edge IDs in the mutation.
|
||||
// Note that IDs always returns len(IDs) <= 1 for unique edges, and you should use
|
||||
// ParentID instead. It exists only for internal usage by the builders.
|
||||
func (m *AccountMutation) ParentIDs() (ids []int64) {
|
||||
if id := m.parent; id != nil {
|
||||
ids = append(ids, *id)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ResetParent resets all changes to the "parent" edge.
|
||||
func (m *AccountMutation) ResetParent() {
|
||||
m.parent = nil
|
||||
m.clearedparent = false
|
||||
}
|
||||
|
||||
// AddChildIDs adds the "children" edge to the Account entity by ids.
|
||||
func (m *AccountMutation) AddChildIDs(ids ...int64) {
|
||||
if m.children == nil {
|
||||
m.children = make(map[int64]struct{})
|
||||
}
|
||||
for i := range ids {
|
||||
m.children[ids[i]] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// ClearChildren clears the "children" edge to the Account entity.
|
||||
func (m *AccountMutation) ClearChildren() {
|
||||
m.clearedchildren = true
|
||||
}
|
||||
|
||||
// ChildrenCleared reports if the "children" edge to the Account entity was cleared.
|
||||
func (m *AccountMutation) ChildrenCleared() bool {
|
||||
return m.clearedchildren
|
||||
}
|
||||
|
||||
// RemoveChildIDs removes the "children" edge to the Account entity by IDs.
|
||||
func (m *AccountMutation) RemoveChildIDs(ids ...int64) {
|
||||
if m.removedchildren == nil {
|
||||
m.removedchildren = make(map[int64]struct{})
|
||||
}
|
||||
for i := range ids {
|
||||
delete(m.children, ids[i])
|
||||
m.removedchildren[ids[i]] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// RemovedChildren returns the removed IDs of the "children" edge to the Account entity.
|
||||
func (m *AccountMutation) RemovedChildrenIDs() (ids []int64) {
|
||||
for id := range m.removedchildren {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ChildrenIDs returns the "children" edge IDs in the mutation.
|
||||
func (m *AccountMutation) ChildrenIDs() (ids []int64) {
|
||||
for id := range m.children {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ResetChildren resets all changes to the "children" edge.
|
||||
func (m *AccountMutation) ResetChildren() {
|
||||
m.children = nil
|
||||
m.clearedchildren = false
|
||||
m.removedchildren = nil
|
||||
}
|
||||
|
||||
// AddUsageLogIDs adds the "usage_logs" edge to the UsageLog entity by ids.
|
||||
func (m *AccountMutation) AddUsageLogIDs(ids ...int64) {
|
||||
if m.usage_logs == nil {
|
||||
@@ -3945,7 +4130,7 @@ func (m *AccountMutation) Type() string {
|
||||
// order to get all numeric fields that were incremented/decremented, call
|
||||
// AddedFields().
|
||||
func (m *AccountMutation) Fields() []string {
|
||||
fields := make([]string, 0, 29)
|
||||
fields := make([]string, 0, 31)
|
||||
if m.created_at != nil {
|
||||
fields = append(fields, account.FieldCreatedAt)
|
||||
}
|
||||
@@ -4033,6 +4218,12 @@ func (m *AccountMutation) Fields() []string {
|
||||
if m.session_window_status != nil {
|
||||
fields = append(fields, account.FieldSessionWindowStatus)
|
||||
}
|
||||
if m.parent != nil {
|
||||
fields = append(fields, account.FieldParentAccountID)
|
||||
}
|
||||
if m.quota_dimension != nil {
|
||||
fields = append(fields, account.FieldQuotaDimension)
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
@@ -4099,6 +4290,10 @@ func (m *AccountMutation) Field(name string) (ent.Value, bool) {
|
||||
return m.SessionWindowEnd()
|
||||
case account.FieldSessionWindowStatus:
|
||||
return m.SessionWindowStatus()
|
||||
case account.FieldParentAccountID:
|
||||
return m.ParentAccountID()
|
||||
case account.FieldQuotaDimension:
|
||||
return m.QuotaDimension()
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
@@ -4166,6 +4361,10 @@ func (m *AccountMutation) OldField(ctx context.Context, name string) (ent.Value,
|
||||
return m.OldSessionWindowEnd(ctx)
|
||||
case account.FieldSessionWindowStatus:
|
||||
return m.OldSessionWindowStatus(ctx)
|
||||
case account.FieldParentAccountID:
|
||||
return m.OldParentAccountID(ctx)
|
||||
case account.FieldQuotaDimension:
|
||||
return m.OldQuotaDimension(ctx)
|
||||
}
|
||||
return nil, fmt.Errorf("unknown Account field %s", name)
|
||||
}
|
||||
@@ -4378,6 +4577,20 @@ func (m *AccountMutation) SetField(name string, value ent.Value) error {
|
||||
}
|
||||
m.SetSessionWindowStatus(v)
|
||||
return nil
|
||||
case account.FieldParentAccountID:
|
||||
v, ok := value.(int64)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetParentAccountID(v)
|
||||
return nil
|
||||
case account.FieldQuotaDimension:
|
||||
v, ok := value.(account.QuotaDimension)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetQuotaDimension(v)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("unknown Account field %s", name)
|
||||
}
|
||||
@@ -4519,6 +4732,9 @@ func (m *AccountMutation) ClearedFields() []string {
|
||||
if m.FieldCleared(account.FieldSessionWindowStatus) {
|
||||
fields = append(fields, account.FieldSessionWindowStatus)
|
||||
}
|
||||
if m.FieldCleared(account.FieldParentAccountID) {
|
||||
fields = append(fields, account.FieldParentAccountID)
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
@@ -4581,6 +4797,9 @@ func (m *AccountMutation) ClearField(name string) error {
|
||||
case account.FieldSessionWindowStatus:
|
||||
m.ClearSessionWindowStatus()
|
||||
return nil
|
||||
case account.FieldParentAccountID:
|
||||
m.ClearParentAccountID()
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("unknown Account nullable field %s", name)
|
||||
}
|
||||
@@ -4676,19 +4895,31 @@ func (m *AccountMutation) ResetField(name string) error {
|
||||
case account.FieldSessionWindowStatus:
|
||||
m.ResetSessionWindowStatus()
|
||||
return nil
|
||||
case account.FieldParentAccountID:
|
||||
m.ResetParentAccountID()
|
||||
return nil
|
||||
case account.FieldQuotaDimension:
|
||||
m.ResetQuotaDimension()
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("unknown Account field %s", name)
|
||||
}
|
||||
|
||||
// AddedEdges returns all edge names that were set/added in this mutation.
|
||||
func (m *AccountMutation) AddedEdges() []string {
|
||||
edges := make([]string, 0, 3)
|
||||
edges := make([]string, 0, 5)
|
||||
if m.groups != nil {
|
||||
edges = append(edges, account.EdgeGroups)
|
||||
}
|
||||
if m.proxy != nil {
|
||||
edges = append(edges, account.EdgeProxy)
|
||||
}
|
||||
if m.parent != nil {
|
||||
edges = append(edges, account.EdgeParent)
|
||||
}
|
||||
if m.children != nil {
|
||||
edges = append(edges, account.EdgeChildren)
|
||||
}
|
||||
if m.usage_logs != nil {
|
||||
edges = append(edges, account.EdgeUsageLogs)
|
||||
}
|
||||
@@ -4709,6 +4940,16 @@ func (m *AccountMutation) AddedIDs(name string) []ent.Value {
|
||||
if id := m.proxy; id != nil {
|
||||
return []ent.Value{*id}
|
||||
}
|
||||
case account.EdgeParent:
|
||||
if id := m.parent; id != nil {
|
||||
return []ent.Value{*id}
|
||||
}
|
||||
case account.EdgeChildren:
|
||||
ids := make([]ent.Value, 0, len(m.children))
|
||||
for id := range m.children {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids
|
||||
case account.EdgeUsageLogs:
|
||||
ids := make([]ent.Value, 0, len(m.usage_logs))
|
||||
for id := range m.usage_logs {
|
||||
@@ -4721,10 +4962,13 @@ func (m *AccountMutation) AddedIDs(name string) []ent.Value {
|
||||
|
||||
// RemovedEdges returns all edge names that were removed in this mutation.
|
||||
func (m *AccountMutation) RemovedEdges() []string {
|
||||
edges := make([]string, 0, 3)
|
||||
edges := make([]string, 0, 5)
|
||||
if m.removedgroups != nil {
|
||||
edges = append(edges, account.EdgeGroups)
|
||||
}
|
||||
if m.removedchildren != nil {
|
||||
edges = append(edges, account.EdgeChildren)
|
||||
}
|
||||
if m.removedusage_logs != nil {
|
||||
edges = append(edges, account.EdgeUsageLogs)
|
||||
}
|
||||
@@ -4741,6 +4985,12 @@ func (m *AccountMutation) RemovedIDs(name string) []ent.Value {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids
|
||||
case account.EdgeChildren:
|
||||
ids := make([]ent.Value, 0, len(m.removedchildren))
|
||||
for id := range m.removedchildren {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids
|
||||
case account.EdgeUsageLogs:
|
||||
ids := make([]ent.Value, 0, len(m.removedusage_logs))
|
||||
for id := range m.removedusage_logs {
|
||||
@@ -4753,13 +5003,19 @@ func (m *AccountMutation) RemovedIDs(name string) []ent.Value {
|
||||
|
||||
// ClearedEdges returns all edge names that were cleared in this mutation.
|
||||
func (m *AccountMutation) ClearedEdges() []string {
|
||||
edges := make([]string, 0, 3)
|
||||
edges := make([]string, 0, 5)
|
||||
if m.clearedgroups {
|
||||
edges = append(edges, account.EdgeGroups)
|
||||
}
|
||||
if m.clearedproxy {
|
||||
edges = append(edges, account.EdgeProxy)
|
||||
}
|
||||
if m.clearedparent {
|
||||
edges = append(edges, account.EdgeParent)
|
||||
}
|
||||
if m.clearedchildren {
|
||||
edges = append(edges, account.EdgeChildren)
|
||||
}
|
||||
if m.clearedusage_logs {
|
||||
edges = append(edges, account.EdgeUsageLogs)
|
||||
}
|
||||
@@ -4774,6 +5030,10 @@ func (m *AccountMutation) EdgeCleared(name string) bool {
|
||||
return m.clearedgroups
|
||||
case account.EdgeProxy:
|
||||
return m.clearedproxy
|
||||
case account.EdgeParent:
|
||||
return m.clearedparent
|
||||
case account.EdgeChildren:
|
||||
return m.clearedchildren
|
||||
case account.EdgeUsageLogs:
|
||||
return m.clearedusage_logs
|
||||
}
|
||||
@@ -4787,6 +5047,9 @@ func (m *AccountMutation) ClearEdge(name string) error {
|
||||
case account.EdgeProxy:
|
||||
m.ClearProxy()
|
||||
return nil
|
||||
case account.EdgeParent:
|
||||
m.ClearParent()
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("unknown Account unique edge %s", name)
|
||||
}
|
||||
@@ -4801,6 +5064,12 @@ func (m *AccountMutation) ResetEdge(name string) error {
|
||||
case account.EdgeProxy:
|
||||
m.ResetProxy()
|
||||
return nil
|
||||
case account.EdgeParent:
|
||||
m.ResetParent()
|
||||
return nil
|
||||
case account.EdgeChildren:
|
||||
m.ResetChildren()
|
||||
return nil
|
||||
case account.EdgeUsageLogs:
|
||||
m.ResetUsageLogs()
|
||||
return nil
|
||||
@@ -15056,6 +15325,11 @@ type GroupMutation struct {
|
||||
description *string
|
||||
rate_multiplier *float64
|
||||
addrate_multiplier *float64
|
||||
peak_rate_enabled *bool
|
||||
peak_start *string
|
||||
peak_end *string
|
||||
peak_rate_multiplier *float64
|
||||
addpeak_rate_multiplier *float64
|
||||
is_exclusive *bool
|
||||
status *string
|
||||
platform *string
|
||||
@@ -15482,6 +15756,170 @@ func (m *GroupMutation) ResetRateMultiplier() {
|
||||
m.addrate_multiplier = nil
|
||||
}
|
||||
|
||||
// SetPeakRateEnabled sets the "peak_rate_enabled" field.
|
||||
func (m *GroupMutation) SetPeakRateEnabled(b bool) {
|
||||
m.peak_rate_enabled = &b
|
||||
}
|
||||
|
||||
// PeakRateEnabled returns the value of the "peak_rate_enabled" field in the mutation.
|
||||
func (m *GroupMutation) PeakRateEnabled() (r bool, exists bool) {
|
||||
v := m.peak_rate_enabled
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldPeakRateEnabled returns the old "peak_rate_enabled" field's value of the Group entity.
|
||||
// If the Group object wasn't provided to the builder, the object is fetched from the database.
|
||||
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
|
||||
func (m *GroupMutation) OldPeakRateEnabled(ctx context.Context) (v bool, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldPeakRateEnabled is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldPeakRateEnabled requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldPeakRateEnabled: %w", err)
|
||||
}
|
||||
return oldValue.PeakRateEnabled, nil
|
||||
}
|
||||
|
||||
// ResetPeakRateEnabled resets all changes to the "peak_rate_enabled" field.
|
||||
func (m *GroupMutation) ResetPeakRateEnabled() {
|
||||
m.peak_rate_enabled = nil
|
||||
}
|
||||
|
||||
// SetPeakStart sets the "peak_start" field.
|
||||
func (m *GroupMutation) SetPeakStart(s string) {
|
||||
m.peak_start = &s
|
||||
}
|
||||
|
||||
// PeakStart returns the value of the "peak_start" field in the mutation.
|
||||
func (m *GroupMutation) PeakStart() (r string, exists bool) {
|
||||
v := m.peak_start
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldPeakStart returns the old "peak_start" field's value of the Group entity.
|
||||
// If the Group object wasn't provided to the builder, the object is fetched from the database.
|
||||
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
|
||||
func (m *GroupMutation) OldPeakStart(ctx context.Context) (v string, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldPeakStart is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldPeakStart requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldPeakStart: %w", err)
|
||||
}
|
||||
return oldValue.PeakStart, nil
|
||||
}
|
||||
|
||||
// ResetPeakStart resets all changes to the "peak_start" field.
|
||||
func (m *GroupMutation) ResetPeakStart() {
|
||||
m.peak_start = nil
|
||||
}
|
||||
|
||||
// SetPeakEnd sets the "peak_end" field.
|
||||
func (m *GroupMutation) SetPeakEnd(s string) {
|
||||
m.peak_end = &s
|
||||
}
|
||||
|
||||
// PeakEnd returns the value of the "peak_end" field in the mutation.
|
||||
func (m *GroupMutation) PeakEnd() (r string, exists bool) {
|
||||
v := m.peak_end
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldPeakEnd returns the old "peak_end" field's value of the Group entity.
|
||||
// If the Group object wasn't provided to the builder, the object is fetched from the database.
|
||||
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
|
||||
func (m *GroupMutation) OldPeakEnd(ctx context.Context) (v string, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldPeakEnd is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldPeakEnd requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldPeakEnd: %w", err)
|
||||
}
|
||||
return oldValue.PeakEnd, nil
|
||||
}
|
||||
|
||||
// ResetPeakEnd resets all changes to the "peak_end" field.
|
||||
func (m *GroupMutation) ResetPeakEnd() {
|
||||
m.peak_end = nil
|
||||
}
|
||||
|
||||
// SetPeakRateMultiplier sets the "peak_rate_multiplier" field.
|
||||
func (m *GroupMutation) SetPeakRateMultiplier(f float64) {
|
||||
m.peak_rate_multiplier = &f
|
||||
m.addpeak_rate_multiplier = nil
|
||||
}
|
||||
|
||||
// PeakRateMultiplier returns the value of the "peak_rate_multiplier" field in the mutation.
|
||||
func (m *GroupMutation) PeakRateMultiplier() (r float64, exists bool) {
|
||||
v := m.peak_rate_multiplier
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldPeakRateMultiplier returns the old "peak_rate_multiplier" field's value of the Group entity.
|
||||
// If the Group object wasn't provided to the builder, the object is fetched from the database.
|
||||
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
|
||||
func (m *GroupMutation) OldPeakRateMultiplier(ctx context.Context) (v float64, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldPeakRateMultiplier is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldPeakRateMultiplier requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldPeakRateMultiplier: %w", err)
|
||||
}
|
||||
return oldValue.PeakRateMultiplier, nil
|
||||
}
|
||||
|
||||
// AddPeakRateMultiplier adds f to the "peak_rate_multiplier" field.
|
||||
func (m *GroupMutation) AddPeakRateMultiplier(f float64) {
|
||||
if m.addpeak_rate_multiplier != nil {
|
||||
*m.addpeak_rate_multiplier += f
|
||||
} else {
|
||||
m.addpeak_rate_multiplier = &f
|
||||
}
|
||||
}
|
||||
|
||||
// AddedPeakRateMultiplier returns the value that was added to the "peak_rate_multiplier" field in this mutation.
|
||||
func (m *GroupMutation) AddedPeakRateMultiplier() (r float64, exists bool) {
|
||||
v := m.addpeak_rate_multiplier
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// ResetPeakRateMultiplier resets all changes to the "peak_rate_multiplier" field.
|
||||
func (m *GroupMutation) ResetPeakRateMultiplier() {
|
||||
m.peak_rate_multiplier = nil
|
||||
m.addpeak_rate_multiplier = nil
|
||||
}
|
||||
|
||||
// SetIsExclusive sets the "is_exclusive" field.
|
||||
func (m *GroupMutation) SetIsExclusive(b bool) {
|
||||
m.is_exclusive = &b
|
||||
@@ -17264,7 +17702,7 @@ func (m *GroupMutation) Type() string {
|
||||
// order to get all numeric fields that were incremented/decremented, call
|
||||
// AddedFields().
|
||||
func (m *GroupMutation) Fields() []string {
|
||||
fields := make([]string, 0, 35)
|
||||
fields := make([]string, 0, 39)
|
||||
if m.created_at != nil {
|
||||
fields = append(fields, group.FieldCreatedAt)
|
||||
}
|
||||
@@ -17283,6 +17721,18 @@ func (m *GroupMutation) Fields() []string {
|
||||
if m.rate_multiplier != nil {
|
||||
fields = append(fields, group.FieldRateMultiplier)
|
||||
}
|
||||
if m.peak_rate_enabled != nil {
|
||||
fields = append(fields, group.FieldPeakRateEnabled)
|
||||
}
|
||||
if m.peak_start != nil {
|
||||
fields = append(fields, group.FieldPeakStart)
|
||||
}
|
||||
if m.peak_end != nil {
|
||||
fields = append(fields, group.FieldPeakEnd)
|
||||
}
|
||||
if m.peak_rate_multiplier != nil {
|
||||
fields = append(fields, group.FieldPeakRateMultiplier)
|
||||
}
|
||||
if m.is_exclusive != nil {
|
||||
fields = append(fields, group.FieldIsExclusive)
|
||||
}
|
||||
@@ -17390,6 +17840,14 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) {
|
||||
return m.Description()
|
||||
case group.FieldRateMultiplier:
|
||||
return m.RateMultiplier()
|
||||
case group.FieldPeakRateEnabled:
|
||||
return m.PeakRateEnabled()
|
||||
case group.FieldPeakStart:
|
||||
return m.PeakStart()
|
||||
case group.FieldPeakEnd:
|
||||
return m.PeakEnd()
|
||||
case group.FieldPeakRateMultiplier:
|
||||
return m.PeakRateMultiplier()
|
||||
case group.FieldIsExclusive:
|
||||
return m.IsExclusive()
|
||||
case group.FieldStatus:
|
||||
@@ -17469,6 +17927,14 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e
|
||||
return m.OldDescription(ctx)
|
||||
case group.FieldRateMultiplier:
|
||||
return m.OldRateMultiplier(ctx)
|
||||
case group.FieldPeakRateEnabled:
|
||||
return m.OldPeakRateEnabled(ctx)
|
||||
case group.FieldPeakStart:
|
||||
return m.OldPeakStart(ctx)
|
||||
case group.FieldPeakEnd:
|
||||
return m.OldPeakEnd(ctx)
|
||||
case group.FieldPeakRateMultiplier:
|
||||
return m.OldPeakRateMultiplier(ctx)
|
||||
case group.FieldIsExclusive:
|
||||
return m.OldIsExclusive(ctx)
|
||||
case group.FieldStatus:
|
||||
@@ -17578,6 +18044,34 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error {
|
||||
}
|
||||
m.SetRateMultiplier(v)
|
||||
return nil
|
||||
case group.FieldPeakRateEnabled:
|
||||
v, ok := value.(bool)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetPeakRateEnabled(v)
|
||||
return nil
|
||||
case group.FieldPeakStart:
|
||||
v, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetPeakStart(v)
|
||||
return nil
|
||||
case group.FieldPeakEnd:
|
||||
v, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetPeakEnd(v)
|
||||
return nil
|
||||
case group.FieldPeakRateMultiplier:
|
||||
v, ok := value.(float64)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetPeakRateMultiplier(v)
|
||||
return nil
|
||||
case group.FieldIsExclusive:
|
||||
v, ok := value.(bool)
|
||||
if !ok {
|
||||
@@ -17792,6 +18286,9 @@ func (m *GroupMutation) AddedFields() []string {
|
||||
if m.addrate_multiplier != nil {
|
||||
fields = append(fields, group.FieldRateMultiplier)
|
||||
}
|
||||
if m.addpeak_rate_multiplier != nil {
|
||||
fields = append(fields, group.FieldPeakRateMultiplier)
|
||||
}
|
||||
if m.adddaily_limit_usd != nil {
|
||||
fields = append(fields, group.FieldDailyLimitUsd)
|
||||
}
|
||||
@@ -17838,6 +18335,8 @@ func (m *GroupMutation) AddedField(name string) (ent.Value, bool) {
|
||||
switch name {
|
||||
case group.FieldRateMultiplier:
|
||||
return m.AddedRateMultiplier()
|
||||
case group.FieldPeakRateMultiplier:
|
||||
return m.AddedPeakRateMultiplier()
|
||||
case group.FieldDailyLimitUsd:
|
||||
return m.AddedDailyLimitUsd()
|
||||
case group.FieldWeeklyLimitUsd:
|
||||
@@ -17878,6 +18377,13 @@ func (m *GroupMutation) AddField(name string, value ent.Value) error {
|
||||
}
|
||||
m.AddRateMultiplier(v)
|
||||
return nil
|
||||
case group.FieldPeakRateMultiplier:
|
||||
v, ok := value.(float64)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.AddPeakRateMultiplier(v)
|
||||
return nil
|
||||
case group.FieldDailyLimitUsd:
|
||||
v, ok := value.(float64)
|
||||
if !ok {
|
||||
@@ -18076,6 +18582,18 @@ func (m *GroupMutation) ResetField(name string) error {
|
||||
case group.FieldRateMultiplier:
|
||||
m.ResetRateMultiplier()
|
||||
return nil
|
||||
case group.FieldPeakRateEnabled:
|
||||
m.ResetPeakRateEnabled()
|
||||
return nil
|
||||
case group.FieldPeakStart:
|
||||
m.ResetPeakStart()
|
||||
return nil
|
||||
case group.FieldPeakEnd:
|
||||
m.ResetPeakEnd()
|
||||
return nil
|
||||
case group.FieldPeakRateMultiplier:
|
||||
m.ResetPeakRateMultiplier()
|
||||
return nil
|
||||
case group.FieldIsExclusive:
|
||||
m.ResetIsExclusive()
|
||||
return nil
|
||||
|
||||
@@ -796,92 +796,112 @@ func init() {
|
||||
groupDescRateMultiplier := groupFields[2].Descriptor()
|
||||
// group.DefaultRateMultiplier holds the default value on creation for the rate_multiplier field.
|
||||
group.DefaultRateMultiplier = groupDescRateMultiplier.Default.(float64)
|
||||
// groupDescPeakRateEnabled is the schema descriptor for peak_rate_enabled field.
|
||||
groupDescPeakRateEnabled := groupFields[3].Descriptor()
|
||||
// group.DefaultPeakRateEnabled holds the default value on creation for the peak_rate_enabled field.
|
||||
group.DefaultPeakRateEnabled = groupDescPeakRateEnabled.Default.(bool)
|
||||
// groupDescPeakStart is the schema descriptor for peak_start field.
|
||||
groupDescPeakStart := groupFields[4].Descriptor()
|
||||
// group.DefaultPeakStart holds the default value on creation for the peak_start field.
|
||||
group.DefaultPeakStart = groupDescPeakStart.Default.(string)
|
||||
// group.PeakStartValidator is a validator for the "peak_start" field. It is called by the builders before save.
|
||||
group.PeakStartValidator = groupDescPeakStart.Validators[0].(func(string) error)
|
||||
// groupDescPeakEnd is the schema descriptor for peak_end field.
|
||||
groupDescPeakEnd := groupFields[5].Descriptor()
|
||||
// group.DefaultPeakEnd holds the default value on creation for the peak_end field.
|
||||
group.DefaultPeakEnd = groupDescPeakEnd.Default.(string)
|
||||
// group.PeakEndValidator is a validator for the "peak_end" field. It is called by the builders before save.
|
||||
group.PeakEndValidator = groupDescPeakEnd.Validators[0].(func(string) error)
|
||||
// groupDescPeakRateMultiplier is the schema descriptor for peak_rate_multiplier field.
|
||||
groupDescPeakRateMultiplier := groupFields[6].Descriptor()
|
||||
// group.DefaultPeakRateMultiplier holds the default value on creation for the peak_rate_multiplier field.
|
||||
group.DefaultPeakRateMultiplier = groupDescPeakRateMultiplier.Default.(float64)
|
||||
// groupDescIsExclusive is the schema descriptor for is_exclusive field.
|
||||
groupDescIsExclusive := groupFields[3].Descriptor()
|
||||
groupDescIsExclusive := groupFields[7].Descriptor()
|
||||
// group.DefaultIsExclusive holds the default value on creation for the is_exclusive field.
|
||||
group.DefaultIsExclusive = groupDescIsExclusive.Default.(bool)
|
||||
// groupDescStatus is the schema descriptor for status field.
|
||||
groupDescStatus := groupFields[4].Descriptor()
|
||||
groupDescStatus := groupFields[8].Descriptor()
|
||||
// group.DefaultStatus holds the default value on creation for the status field.
|
||||
group.DefaultStatus = groupDescStatus.Default.(string)
|
||||
// group.StatusValidator is a validator for the "status" field. It is called by the builders before save.
|
||||
group.StatusValidator = groupDescStatus.Validators[0].(func(string) error)
|
||||
// groupDescPlatform is the schema descriptor for platform field.
|
||||
groupDescPlatform := groupFields[5].Descriptor()
|
||||
groupDescPlatform := groupFields[9].Descriptor()
|
||||
// group.DefaultPlatform holds the default value on creation for the platform field.
|
||||
group.DefaultPlatform = groupDescPlatform.Default.(string)
|
||||
// group.PlatformValidator is a validator for the "platform" field. It is called by the builders before save.
|
||||
group.PlatformValidator = groupDescPlatform.Validators[0].(func(string) error)
|
||||
// groupDescSubscriptionType is the schema descriptor for subscription_type field.
|
||||
groupDescSubscriptionType := groupFields[6].Descriptor()
|
||||
groupDescSubscriptionType := groupFields[10].Descriptor()
|
||||
// group.DefaultSubscriptionType holds the default value on creation for the subscription_type field.
|
||||
group.DefaultSubscriptionType = groupDescSubscriptionType.Default.(string)
|
||||
// group.SubscriptionTypeValidator is a validator for the "subscription_type" field. It is called by the builders before save.
|
||||
group.SubscriptionTypeValidator = groupDescSubscriptionType.Validators[0].(func(string) error)
|
||||
// groupDescDefaultValidityDays is the schema descriptor for default_validity_days field.
|
||||
groupDescDefaultValidityDays := groupFields[10].Descriptor()
|
||||
groupDescDefaultValidityDays := groupFields[14].Descriptor()
|
||||
// group.DefaultDefaultValidityDays holds the default value on creation for the default_validity_days field.
|
||||
group.DefaultDefaultValidityDays = groupDescDefaultValidityDays.Default.(int)
|
||||
// groupDescAllowImageGeneration is the schema descriptor for allow_image_generation field.
|
||||
groupDescAllowImageGeneration := groupFields[11].Descriptor()
|
||||
groupDescAllowImageGeneration := groupFields[15].Descriptor()
|
||||
// group.DefaultAllowImageGeneration holds the default value on creation for the allow_image_generation field.
|
||||
group.DefaultAllowImageGeneration = groupDescAllowImageGeneration.Default.(bool)
|
||||
// groupDescImageRateIndependent is the schema descriptor for image_rate_independent field.
|
||||
groupDescImageRateIndependent := groupFields[12].Descriptor()
|
||||
groupDescImageRateIndependent := groupFields[16].Descriptor()
|
||||
// group.DefaultImageRateIndependent holds the default value on creation for the image_rate_independent field.
|
||||
group.DefaultImageRateIndependent = groupDescImageRateIndependent.Default.(bool)
|
||||
// groupDescImageRateMultiplier is the schema descriptor for image_rate_multiplier field.
|
||||
groupDescImageRateMultiplier := groupFields[13].Descriptor()
|
||||
groupDescImageRateMultiplier := groupFields[17].Descriptor()
|
||||
// group.DefaultImageRateMultiplier holds the default value on creation for the image_rate_multiplier field.
|
||||
group.DefaultImageRateMultiplier = groupDescImageRateMultiplier.Default.(float64)
|
||||
// groupDescClaudeCodeOnly is the schema descriptor for claude_code_only field.
|
||||
groupDescClaudeCodeOnly := groupFields[17].Descriptor()
|
||||
groupDescClaudeCodeOnly := groupFields[21].Descriptor()
|
||||
// group.DefaultClaudeCodeOnly holds the default value on creation for the claude_code_only field.
|
||||
group.DefaultClaudeCodeOnly = groupDescClaudeCodeOnly.Default.(bool)
|
||||
// groupDescModelRoutingEnabled is the schema descriptor for model_routing_enabled field.
|
||||
groupDescModelRoutingEnabled := groupFields[21].Descriptor()
|
||||
groupDescModelRoutingEnabled := groupFields[25].Descriptor()
|
||||
// group.DefaultModelRoutingEnabled holds the default value on creation for the model_routing_enabled field.
|
||||
group.DefaultModelRoutingEnabled = groupDescModelRoutingEnabled.Default.(bool)
|
||||
// groupDescMcpXMLInject is the schema descriptor for mcp_xml_inject field.
|
||||
groupDescMcpXMLInject := groupFields[22].Descriptor()
|
||||
groupDescMcpXMLInject := groupFields[26].Descriptor()
|
||||
// group.DefaultMcpXMLInject holds the default value on creation for the mcp_xml_inject field.
|
||||
group.DefaultMcpXMLInject = groupDescMcpXMLInject.Default.(bool)
|
||||
// groupDescSupportedModelScopes is the schema descriptor for supported_model_scopes field.
|
||||
groupDescSupportedModelScopes := groupFields[23].Descriptor()
|
||||
groupDescSupportedModelScopes := groupFields[27].Descriptor()
|
||||
// group.DefaultSupportedModelScopes holds the default value on creation for the supported_model_scopes field.
|
||||
group.DefaultSupportedModelScopes = groupDescSupportedModelScopes.Default.([]string)
|
||||
// groupDescSortOrder is the schema descriptor for sort_order field.
|
||||
groupDescSortOrder := groupFields[24].Descriptor()
|
||||
groupDescSortOrder := groupFields[28].Descriptor()
|
||||
// group.DefaultSortOrder holds the default value on creation for the sort_order field.
|
||||
group.DefaultSortOrder = groupDescSortOrder.Default.(int)
|
||||
// groupDescAllowMessagesDispatch is the schema descriptor for allow_messages_dispatch field.
|
||||
groupDescAllowMessagesDispatch := groupFields[25].Descriptor()
|
||||
groupDescAllowMessagesDispatch := groupFields[29].Descriptor()
|
||||
// group.DefaultAllowMessagesDispatch holds the default value on creation for the allow_messages_dispatch field.
|
||||
group.DefaultAllowMessagesDispatch = groupDescAllowMessagesDispatch.Default.(bool)
|
||||
// groupDescRequireOauthOnly is the schema descriptor for require_oauth_only field.
|
||||
groupDescRequireOauthOnly := groupFields[26].Descriptor()
|
||||
groupDescRequireOauthOnly := groupFields[30].Descriptor()
|
||||
// group.DefaultRequireOauthOnly holds the default value on creation for the require_oauth_only field.
|
||||
group.DefaultRequireOauthOnly = groupDescRequireOauthOnly.Default.(bool)
|
||||
// groupDescRequirePrivacySet is the schema descriptor for require_privacy_set field.
|
||||
groupDescRequirePrivacySet := groupFields[27].Descriptor()
|
||||
groupDescRequirePrivacySet := groupFields[31].Descriptor()
|
||||
// group.DefaultRequirePrivacySet holds the default value on creation for the require_privacy_set field.
|
||||
group.DefaultRequirePrivacySet = groupDescRequirePrivacySet.Default.(bool)
|
||||
// groupDescDefaultMappedModel is the schema descriptor for default_mapped_model field.
|
||||
groupDescDefaultMappedModel := groupFields[28].Descriptor()
|
||||
groupDescDefaultMappedModel := groupFields[32].Descriptor()
|
||||
// group.DefaultDefaultMappedModel holds the default value on creation for the default_mapped_model field.
|
||||
group.DefaultDefaultMappedModel = groupDescDefaultMappedModel.Default.(string)
|
||||
// group.DefaultMappedModelValidator is a validator for the "default_mapped_model" field. It is called by the builders before save.
|
||||
group.DefaultMappedModelValidator = groupDescDefaultMappedModel.Validators[0].(func(string) error)
|
||||
// groupDescMessagesDispatchModelConfig is the schema descriptor for messages_dispatch_model_config field.
|
||||
groupDescMessagesDispatchModelConfig := groupFields[29].Descriptor()
|
||||
groupDescMessagesDispatchModelConfig := groupFields[33].Descriptor()
|
||||
// group.DefaultMessagesDispatchModelConfig holds the default value on creation for the messages_dispatch_model_config field.
|
||||
group.DefaultMessagesDispatchModelConfig = groupDescMessagesDispatchModelConfig.Default.(domain.OpenAIMessagesDispatchModelConfig)
|
||||
// groupDescModelsListConfig is the schema descriptor for models_list_config field.
|
||||
groupDescModelsListConfig := groupFields[30].Descriptor()
|
||||
groupDescModelsListConfig := groupFields[34].Descriptor()
|
||||
// group.DefaultModelsListConfig holds the default value on creation for the models_list_config field.
|
||||
group.DefaultModelsListConfig = groupDescModelsListConfig.Default.(domain.GroupModelsListConfig)
|
||||
// groupDescRpmLimit is the schema descriptor for rpm_limit field.
|
||||
groupDescRpmLimit := groupFields[31].Descriptor()
|
||||
groupDescRpmLimit := groupFields[35].Descriptor()
|
||||
// group.DefaultRpmLimit holds the default value on creation for the rpm_limit field.
|
||||
group.DefaultRpmLimit = groupDescRpmLimit.Default.(int)
|
||||
idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin()
|
||||
|
||||
@@ -196,6 +196,11 @@ func (Account) Fields() []ent.Field {
|
||||
Optional().
|
||||
Nillable().
|
||||
MaxLen(20),
|
||||
|
||||
field.Int64("parent_account_id").Optional().Nillable().
|
||||
Comment("Parent account id for a linked spark shadow (NULL = normal)."),
|
||||
field.Enum("quota_dimension").Values("global", "spark").Default("global").
|
||||
Comment("'global' (default) or 'spark' (shadow reads codex_bengalfox)."),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +217,14 @@ func (Account) Edges() []ent.Edge {
|
||||
edge.To("proxy", Proxy.Type).
|
||||
Field("proxy_id").
|
||||
Unique(),
|
||||
// children/parent: linked spark shadow relationship.
|
||||
// parent_account_id is nullable, and the active one-shadow-per-parent rule
|
||||
// is enforced by the partial unique index in migration 154a.
|
||||
edge.To("children", Account.Type).
|
||||
Annotations(entsql.OnDelete(entsql.Restrict)).
|
||||
From("parent").
|
||||
Field("parent_account_id").
|
||||
Unique(),
|
||||
// usage_logs: 该账户的使用日志
|
||||
edge.To("usage_logs", UsageLog.Type),
|
||||
}
|
||||
@@ -235,5 +248,6 @@ func (Account) Indexes() []ent.Index {
|
||||
index.Fields("platform", "priority"),
|
||||
index.Fields("priority", "status"),
|
||||
index.Fields("deleted_at"), // 软删除查询优化
|
||||
index.Fields("parent_account_id"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,22 @@ func (Group) Fields() []ent.Field {
|
||||
field.Float("rate_multiplier").
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(10,4)"}).
|
||||
Default(1.0),
|
||||
// 高峰时段倍率(added by migration 158)
|
||||
field.Bool("peak_rate_enabled").
|
||||
Default(false).
|
||||
Comment("是否启用高峰时段倍率"),
|
||||
field.String("peak_start").
|
||||
MaxLen(5).
|
||||
Default("").
|
||||
Comment("高峰开始时间 HH:MM(含),如 14:00;空表示未配置;不支持跨天"),
|
||||
field.String("peak_end").
|
||||
MaxLen(5).
|
||||
Default("").
|
||||
Comment("高峰结束时间 HH:MM(不含),必须大于 peak_start;不支持跨天,如 22:00-02:00"),
|
||||
field.Float("peak_rate_multiplier").
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(10,4)"}).
|
||||
Default(1.0).
|
||||
Comment("高峰时段叠加倍率,仅在 peak_rate_enabled 且处于 [peak_start, peak_end) 时乘入文本倍率"),
|
||||
field.Bool("is_exclusive").
|
||||
Default(false),
|
||||
field.String("status").
|
||||
|
||||
@@ -41,7 +41,7 @@ func (UserPlatformQuota) Fields() []ent.Field {
|
||||
// 注意:平台列表的单一权威源为 service.AllowedQuotaPlatforms;
|
||||
// 此处为 ent 构建期约束,需与 service.AllowedQuotaPlatforms 保持同步。
|
||||
switch s {
|
||||
case "anthropic", "openai", "gemini", "antigravity":
|
||||
case "anthropic", "openai", "gemini", "antigravity", "grok":
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("platform %q is not allowed", s)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package ent
|
||||
|
||||
import "context"
|
||||
|
||||
// WithoutTx 返回一个剥离了所附 *Tx 的 ctx 副本,使调用方可以在基础 client
|
||||
// (autocommit)上执行 best-effort、非关键的副作用,而不加入——也就不会毒化——
|
||||
// 外层事务。
|
||||
//
|
||||
// Postgres 语义:事务内任一语句失败即把整个事务标记为 aborted,后续语句全部被拒,
|
||||
// 直到 ROLLBACK。因此对 fail-open 的副作用(如注册时的默认平台配额快照)必须做事务
|
||||
// 隔离,否则一条无关紧要的写入失败会连累调用方的关键事务。
|
||||
func WithoutTx(ctx context.Context) context.Context {
|
||||
if TxFromContext(ctx) == nil {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, txCtxKey{}, (*Tx)(nil))
|
||||
}
|
||||
@@ -86,6 +86,7 @@ require (
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/dlclark/regexp2/v2 v2.1.0 // indirect
|
||||
github.com/docker/docker v28.5.1+incompatible // indirect
|
||||
github.com/docker/go-connections v0.6.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
@@ -155,6 +156,7 @@ require (
|
||||
github.com/testcontainers/testcontainers-go v0.40.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
github.com/tiktoken-go/tokenizer v0.8.0 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
|
||||
@@ -108,6 +108,8 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/dlclark/regexp2/v2 v2.1.0 h1:jHXRmHRZGbuQzDZjMlCAXOvQb75iv3HyLDzXGj5H1AY=
|
||||
github.com/dlclark/regexp2/v2 v2.1.0/go.mod h1:Bz5TMy5d8fPK0ximH0Yi9KvsRHNnvXqUx9XG6a4wB+I=
|
||||
github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM=
|
||||
github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
||||
@@ -164,6 +166,8 @@ github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
|
||||
@@ -358,6 +362,8 @@ github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/tiktoken-go/tokenizer v0.8.0 h1:drHWno2Zx3eAm/hk/LmvBKXPpSImB7BRyh/ru4+3Q7Y=
|
||||
github.com/tiktoken-go/tokenizer v0.8.0/go.mod h1:pTmPz4r14MV3JkUGAmAcdLdYhSxN68MCjrP+EoxBdx0=
|
||||
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||
|
||||
@@ -643,6 +643,10 @@ type ProxyProbeConfig struct {
|
||||
|
||||
type BillingConfig struct {
|
||||
CircuitBreaker CircuitBreakerConfig `mapstructure:"circuit_breaker"`
|
||||
// MinimumBalanceReserve is the conservative preflight floor for balance billing.
|
||||
// Requests in balance mode are rejected when the cached balance is below this
|
||||
// amount, even if it is still positive. Set to 0 to keep the legacy balance > 0 gate.
|
||||
MinimumBalanceReserve float64 `mapstructure:"minimum_balance_reserve"`
|
||||
// UserPlatformQuotaCacheTTLSeconds 用户 × 平台 quota 缓存 TTL(秒),默认 86400=1天,覆盖典型 daily 窗口。
|
||||
// 消费点:
|
||||
// - billing_cache_service.cacheWriteWorker 异步累加
|
||||
@@ -717,6 +721,9 @@ type GatewayConfig struct {
|
||||
// OpenAIPassthroughAllowTimeoutHeaders: OpenAI 透传模式是否放行客户端超时头
|
||||
// 关闭(默认)可避免 x-stainless-timeout 等头导致上游提前断流。
|
||||
OpenAIPassthroughAllowTimeoutHeaders bool `mapstructure:"openai_passthrough_allow_timeout_headers"`
|
||||
// OpenAICompactModel: /responses/compact 上游使用的模型。
|
||||
// compact 端点支持模型滞后于普通 /responses 时,可用该配置降级规避上游错误。
|
||||
OpenAICompactModel string `mapstructure:"openai_compact_model"`
|
||||
// OpenAIWS: OpenAI Responses WebSocket 配置(默认开启,可按需回滚到 HTTP)
|
||||
OpenAIWS GatewayOpenAIWSConfig `mapstructure:"openai_ws"`
|
||||
// OpenAIScheduler: OpenAI 高级调度器粘性逃逸配置
|
||||
@@ -863,7 +870,7 @@ func (c *UserMessageQueueConfig) GetEffectiveMode() string {
|
||||
type GatewayOpenAIWSConfig struct {
|
||||
// ModeRouterV2Enabled: 新版 WS mode 路由开关(默认 false;关闭时保持 legacy 行为)
|
||||
ModeRouterV2Enabled bool `mapstructure:"mode_router_v2_enabled"`
|
||||
// IngressModeDefault: ingress 默认模式(off/ctx_pool/passthrough)
|
||||
// IngressModeDefault: ingress 默认模式(off/ctx_pool/passthrough/http_bridge)
|
||||
IngressModeDefault string `mapstructure:"ingress_mode_default"`
|
||||
// Enabled: 全局总开关(默认 true)
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
@@ -960,6 +967,11 @@ type GatewayOpenAIWSSchedulerScoreWeights struct {
|
||||
// Reset 倾向「会话窗口最早重置」的账号(use-it-or-lose-it)。
|
||||
// >0 时,剩余重置时间越短的账号得分越高,从而被优先用尽。默认 0(关闭,不改变原有行为)。
|
||||
Reset float64 `mapstructure:"reset"`
|
||||
// QuotaHeadroom 倾向 7d 剩余额度更健康的账号;默认 0(关闭,不改变原有行为)。
|
||||
QuotaHeadroom float64 `mapstructure:"quota_headroom"`
|
||||
// PreviousResponse/SessionSticky 仅在开启 OpenAI 高级调度的粘性加权时生效。
|
||||
PreviousResponse float64 `mapstructure:"previous_response"`
|
||||
SessionSticky float64 `mapstructure:"session_sticky"`
|
||||
}
|
||||
|
||||
// GatewayOpenAISchedulerConfig OpenAI 高级调度器配置。
|
||||
@@ -1615,6 +1627,7 @@ func setDefaults() {
|
||||
viper.SetDefault("billing.circuit_breaker.failure_threshold", 5)
|
||||
viper.SetDefault("billing.circuit_breaker.reset_timeout_seconds", 30)
|
||||
viper.SetDefault("billing.circuit_breaker.half_open_requests", 3)
|
||||
viper.SetDefault("billing.minimum_balance_reserve", 0.000001)
|
||||
viper.SetDefault("billing.user_platform_quota_cache_ttl_seconds", 86400)
|
||||
viper.SetDefault("billing.user_platform_quota_sentinel_ttl_seconds", 3600)
|
||||
|
||||
@@ -1829,6 +1842,7 @@ func setDefaults() {
|
||||
viper.SetDefault("gateway.force_codex_cli", false)
|
||||
viper.SetDefault("gateway.codex_image_generation_bridge_enabled", false)
|
||||
viper.SetDefault("gateway.openai_passthrough_allow_timeout_headers", false)
|
||||
viper.SetDefault("gateway.openai_compact_model", "gpt-5.4")
|
||||
// OpenAI Responses WebSocket(默认开启;可通过 force_http 紧急回滚)
|
||||
viper.SetDefault("gateway.openai_ws.enabled", true)
|
||||
viper.SetDefault("gateway.openai_ws.mode_router_v2_enabled", false)
|
||||
@@ -1879,6 +1893,9 @@ func setDefaults() {
|
||||
viper.SetDefault("gateway.openai_ws.scheduler_score_weights.error_rate", 0.8)
|
||||
viper.SetDefault("gateway.openai_ws.scheduler_score_weights.ttft", 0.5)
|
||||
viper.SetDefault("gateway.openai_ws.scheduler_score_weights.reset", 0.0)
|
||||
viper.SetDefault("gateway.openai_ws.scheduler_score_weights.quota_headroom", 0.0)
|
||||
viper.SetDefault("gateway.openai_ws.scheduler_score_weights.previous_response", 5.0)
|
||||
viper.SetDefault("gateway.openai_ws.scheduler_score_weights.session_sticky", 3.0)
|
||||
// OpenAI HTTP upstream protocol strategy
|
||||
viper.SetDefault("gateway.openai_http2.enabled", true)
|
||||
viper.SetDefault("gateway.openai_http2.allow_proxy_fallback_to_http1", true)
|
||||
@@ -1933,7 +1950,10 @@ func setDefaults() {
|
||||
viper.SetDefault("gateway.usage_record.worker_count", 128)
|
||||
viper.SetDefault("gateway.usage_record.queue_size", 16384)
|
||||
viper.SetDefault("gateway.usage_record.task_timeout_seconds", 5)
|
||||
viper.SetDefault("gateway.usage_record.overflow_policy", UsageRecordOverflowPolicySample)
|
||||
// 默认 sync:队列满时由提交方内联执行(提交点在响应写出之后,不阻塞客户端)。
|
||||
// sample/drop 会在溢出时静默丢弃计费任务,造成扣费与 usage_logs 对账缺口(issue #3656),
|
||||
// 仅供显式配置的运维场景使用。
|
||||
viper.SetDefault("gateway.usage_record.overflow_policy", UsageRecordOverflowPolicySync)
|
||||
viper.SetDefault("gateway.usage_record.overflow_sample_percent", 10)
|
||||
viper.SetDefault("gateway.usage_record.auto_scale_enabled", true)
|
||||
viper.SetDefault("gateway.usage_record.auto_scale_min_workers", 128)
|
||||
@@ -2277,6 +2297,9 @@ func (c *Config) Validate() error {
|
||||
return fmt.Errorf("billing.circuit_breaker.half_open_requests must be positive")
|
||||
}
|
||||
}
|
||||
if c.Billing.MinimumBalanceReserve < 0 {
|
||||
return fmt.Errorf("billing.minimum_balance_reserve must be non-negative")
|
||||
}
|
||||
if c.Database.MaxOpenConns <= 0 {
|
||||
return fmt.Errorf("database.max_open_conns must be positive")
|
||||
}
|
||||
@@ -2613,11 +2636,11 @@ func (c *Config) Validate() error {
|
||||
}
|
||||
if mode := strings.ToLower(strings.TrimSpace(c.Gateway.OpenAIWS.IngressModeDefault)); mode != "" {
|
||||
switch mode {
|
||||
case "off", "ctx_pool", "passthrough":
|
||||
case "off", "ctx_pool", "passthrough", "http_bridge":
|
||||
case "shared", "dedicated":
|
||||
slog.Warn("gateway.openai_ws.ingress_mode_default is deprecated, treating as ctx_pool; please update to off|ctx_pool|passthrough", "value", mode)
|
||||
slog.Warn("gateway.openai_ws.ingress_mode_default is deprecated, treating as ctx_pool; please update to off|ctx_pool|passthrough|http_bridge", "value", mode)
|
||||
default:
|
||||
return fmt.Errorf("gateway.openai_ws.ingress_mode_default must be one of off|ctx_pool|passthrough")
|
||||
return fmt.Errorf("gateway.openai_ws.ingress_mode_default must be one of off|ctx_pool|passthrough|http_bridge")
|
||||
}
|
||||
}
|
||||
if mode := strings.ToLower(strings.TrimSpace(c.Gateway.OpenAIWS.StoreDisabledConnMode)); mode != "" {
|
||||
@@ -2655,14 +2678,18 @@ func (c *Config) Validate() error {
|
||||
c.Gateway.OpenAIWS.SchedulerScoreWeights.Load < 0 ||
|
||||
c.Gateway.OpenAIWS.SchedulerScoreWeights.Queue < 0 ||
|
||||
c.Gateway.OpenAIWS.SchedulerScoreWeights.ErrorRate < 0 ||
|
||||
c.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT < 0 {
|
||||
c.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT < 0 ||
|
||||
c.Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom < 0 ||
|
||||
c.Gateway.OpenAIWS.SchedulerScoreWeights.PreviousResponse < 0 ||
|
||||
c.Gateway.OpenAIWS.SchedulerScoreWeights.SessionSticky < 0 {
|
||||
return fmt.Errorf("gateway.openai_ws.scheduler_score_weights.* must be non-negative")
|
||||
}
|
||||
weightSum := c.Gateway.OpenAIWS.SchedulerScoreWeights.Priority +
|
||||
c.Gateway.OpenAIWS.SchedulerScoreWeights.Load +
|
||||
c.Gateway.OpenAIWS.SchedulerScoreWeights.Queue +
|
||||
c.Gateway.OpenAIWS.SchedulerScoreWeights.ErrorRate +
|
||||
c.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT
|
||||
c.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT +
|
||||
c.Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom
|
||||
if weightSum <= 0 {
|
||||
return fmt.Errorf("gateway.openai_ws.scheduler_score_weights must not all be zero")
|
||||
}
|
||||
|
||||
@@ -167,6 +167,9 @@ func TestLoadDefaultOpenAIWSConfig(t *testing.T) {
|
||||
if cfg.Gateway.OpenAIWS.PayloadLogSampleRate != 0.2 {
|
||||
t.Fatalf("Gateway.OpenAIWS.PayloadLogSampleRate = %v, want 0.2", cfg.Gateway.OpenAIWS.PayloadLogSampleRate)
|
||||
}
|
||||
if cfg.Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom != 0 {
|
||||
t.Fatalf("Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom = %v, want 0", cfg.Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom)
|
||||
}
|
||||
if !cfg.Gateway.OpenAIWS.StoreDisabledForceNewConn {
|
||||
t.Fatalf("Gateway.OpenAIWS.StoreDisabledForceNewConn = false, want true")
|
||||
}
|
||||
@@ -181,6 +184,23 @@ func TestLoadDefaultOpenAIWSConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaultOpenAICompactModel(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "gpt-5.4", cfg.Gateway.OpenAICompactModel)
|
||||
}
|
||||
|
||||
func TestLoadOpenAICompactModelFromEnv(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
t.Setenv("GATEWAY_OPENAI_COMPACT_MODEL", "gpt-5.3-codex")
|
||||
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "gpt-5.3-codex", cfg.Gateway.OpenAICompactModel)
|
||||
}
|
||||
|
||||
func TestLoadDefaultOpenAIHTTP2Enabled(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
|
||||
@@ -1157,6 +1177,11 @@ func TestValidateConfigErrors(t *testing.T) {
|
||||
mutate: func(c *Config) { c.Billing.CircuitBreaker.HalfOpenRequests = 0 },
|
||||
wantErr: "billing.circuit_breaker.half_open_requests",
|
||||
},
|
||||
{
|
||||
name: "billing minimum balance reserve",
|
||||
mutate: func(c *Config) { c.Billing.MinimumBalanceReserve = -0.01 },
|
||||
wantErr: "billing.minimum_balance_reserve",
|
||||
},
|
||||
{
|
||||
name: "database max open conns",
|
||||
mutate: func(c *Config) { c.Database.MaxOpenConns = 0 },
|
||||
@@ -1670,7 +1695,7 @@ func TestValidateConfig_OpenAIWSRules(t *testing.T) {
|
||||
wantErr: "gateway.openai_ws.store_disabled_conn_mode",
|
||||
},
|
||||
{
|
||||
name: "ingress_mode_default 必须为 off|ctx_pool|passthrough",
|
||||
name: "ingress_mode_default 必须为 off|ctx_pool|passthrough|http_bridge",
|
||||
mutate: func(c *Config) { c.Gateway.OpenAIWS.IngressModeDefault = "invalid" },
|
||||
wantErr: "gateway.openai_ws.ingress_mode_default",
|
||||
},
|
||||
@@ -1712,6 +1737,11 @@ func TestValidateConfig_OpenAIWSRules(t *testing.T) {
|
||||
mutate: func(c *Config) { c.Gateway.OpenAIWS.SchedulerScoreWeights.Queue = -0.1 },
|
||||
wantErr: "gateway.openai_ws.scheduler_score_weights.* must be non-negative",
|
||||
},
|
||||
{
|
||||
name: "scheduler_score_weights quota_headroom 不能为负数",
|
||||
mutate: func(c *Config) { c.Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom = -0.1 },
|
||||
wantErr: "gateway.openai_ws.scheduler_score_weights.* must be non-negative",
|
||||
},
|
||||
{
|
||||
name: "scheduler_score_weights 不能全为 0",
|
||||
mutate: func(c *Config) {
|
||||
@@ -1751,6 +1781,18 @@ func TestValidateConfig_OpenAIWSRules(t *testing.T) {
|
||||
require.Contains(t, err.Error(), tc.wantErr)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("quota_headroom 可作为唯一有效调度权重", func(t *testing.T) {
|
||||
cfg := buildValid(t)
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Priority = 0
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Load = 0
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Queue = 0
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.ErrorRate = 0
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT = 0
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom = 0.1
|
||||
|
||||
require.NoError(t, cfg.Validate())
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateConfig_AutoScaleDisabledIgnoreAutoScaleFields(t *testing.T) {
|
||||
@@ -1861,8 +1903,8 @@ func TestLoad_DefaultGatewayUsageRecordConfig(t *testing.T) {
|
||||
if cfg.Gateway.UsageRecord.TaskTimeoutSeconds != 5 {
|
||||
t.Fatalf("task_timeout_seconds = %d, want 5", cfg.Gateway.UsageRecord.TaskTimeoutSeconds)
|
||||
}
|
||||
if cfg.Gateway.UsageRecord.OverflowPolicy != UsageRecordOverflowPolicySample {
|
||||
t.Fatalf("overflow_policy = %s, want %s", cfg.Gateway.UsageRecord.OverflowPolicy, UsageRecordOverflowPolicySample)
|
||||
if cfg.Gateway.UsageRecord.OverflowPolicy != UsageRecordOverflowPolicySync {
|
||||
t.Fatalf("overflow_policy = %s, want %s", cfg.Gateway.UsageRecord.OverflowPolicy, UsageRecordOverflowPolicySync)
|
||||
}
|
||||
if cfg.Gateway.UsageRecord.OverflowSamplePercent != 10 {
|
||||
t.Fatalf("overflow_sample_percent = %d, want 10", cfg.Gateway.UsageRecord.OverflowSamplePercent)
|
||||
|
||||
@@ -22,6 +22,7 @@ const (
|
||||
PlatformOpenAI = "openai"
|
||||
PlatformGemini = "gemini"
|
||||
PlatformAntigravity = "antigravity"
|
||||
PlatformGrok = "grok"
|
||||
)
|
||||
|
||||
// Account type constants
|
||||
@@ -67,6 +68,9 @@ const (
|
||||
SubscriptionStatusSuspended = "suspended"
|
||||
)
|
||||
|
||||
// AntigravityGemini31ProAgentModel is the upstream route for Gemini 3.1 Pro High.
|
||||
const AntigravityGemini31ProAgentModel = "gemini-pro-agent"
|
||||
|
||||
// DefaultAntigravityModelMapping 是 Antigravity 平台的默认模型映射
|
||||
// 当账号未配置 model_mapping 时使用此默认值
|
||||
// 与前端 useModelWhitelist.ts 中的 antigravityDefaultMappings 保持一致
|
||||
@@ -102,10 +106,12 @@ var DefaultAntigravityModelMapping = map[string]string{
|
||||
"gemini-3-flash-preview": "gemini-3-flash",
|
||||
"gemini-3-pro-preview": "gemini-3-pro-high",
|
||||
// Gemini 3.1 白名单
|
||||
"gemini-3.1-pro-high": "gemini-3.1-pro-high",
|
||||
"gemini-3.1-pro-low": "gemini-3.1-pro-low",
|
||||
AntigravityGemini31ProAgentModel: AntigravityGemini31ProAgentModel,
|
||||
"gemini-3.1-pro": AntigravityGemini31ProAgentModel,
|
||||
"gemini-3.1-pro-high": AntigravityGemini31ProAgentModel,
|
||||
"gemini-3.1-pro-low": "gemini-3.1-pro-low",
|
||||
// Gemini 3.1 preview 映射
|
||||
"gemini-3.1-pro-preview": "gemini-3.1-pro-high",
|
||||
"gemini-3.1-pro-preview": AntigravityGemini31ProAgentModel,
|
||||
// Gemini 3.1 image 白名单
|
||||
"gemini-3.1-flash-image": "gemini-3.1-flash-image",
|
||||
// Gemini 3.1 image preview 映射
|
||||
@@ -135,6 +141,7 @@ var DefaultBedrockModelMapping = map[string]string{
|
||||
"claude-opus-4-1": "us.anthropic.claude-opus-4-1-20250805-v1:0",
|
||||
"claude-opus-4-20250514": "us.anthropic.claude-opus-4-20250514-v1:0",
|
||||
// Claude Sonnet
|
||||
"claude-sonnet-5": "us.anthropic.claude-sonnet-5-v1",
|
||||
"claude-sonnet-4-6-thinking": "us.anthropic.claude-sonnet-4-6",
|
||||
"claude-sonnet-4-6": "us.anthropic.claude-sonnet-4-6",
|
||||
"claude-sonnet-4-5": "us.anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
|
||||
@@ -43,6 +43,28 @@ func TestDefaultAntigravityModelMapping_ContainsNewClaudeModels(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultAntigravityModelMapping_Gemini31ProAliases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[string]string{
|
||||
AntigravityGemini31ProAgentModel: AntigravityGemini31ProAgentModel,
|
||||
"gemini-3.1-pro": AntigravityGemini31ProAgentModel,
|
||||
"gemini-3.1-pro-high": AntigravityGemini31ProAgentModel,
|
||||
"gemini-3.1-pro-preview": AntigravityGemini31ProAgentModel,
|
||||
"gemini-3.1-pro-low": "gemini-3.1-pro-low",
|
||||
}
|
||||
|
||||
for from, want := range cases {
|
||||
got, ok := DefaultAntigravityModelMapping[from]
|
||||
if !ok {
|
||||
t.Fatalf("expected mapping for %q to exist", from)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("unexpected mapping for %q: got %q want %q", from, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultBedrockModelMapping_ContainsNewClaudeModels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ type codexJWTOpenAIClaims struct {
|
||||
}
|
||||
|
||||
type codexAccountIndex struct {
|
||||
accountsByKey map[string]service.Account
|
||||
accountsByKey map[string][]service.Account
|
||||
}
|
||||
|
||||
func (h *AccountHandler) ImportCodexSession(c *gin.Context) {
|
||||
@@ -178,7 +178,7 @@ func (h *AccountHandler) importCodexSessions(ctx context.Context, req CodexSessi
|
||||
}
|
||||
skipMixedChannelCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk
|
||||
|
||||
seenIdentity := map[string]int{}
|
||||
seenIdentity := map[string]codexSeenIdentity{}
|
||||
for _, entry := range entries {
|
||||
item, err := normalizeCodexImportEntry(entry)
|
||||
if err != nil {
|
||||
@@ -225,7 +225,7 @@ func (h *AccountHandler) importCodexSessions(ctx context.Context, req CodexSessi
|
||||
})
|
||||
}
|
||||
|
||||
if duplicateIndex, ok := firstSeenCodexIdentity(seenIdentity, item.IdentityKeys); ok {
|
||||
if duplicateIndex, ok := firstSeenCodexIdentity(seenIdentity, item.IdentityKeys, item.UserID); ok {
|
||||
message := fmt.Sprintf("与第 %d 条导入项重复,已跳过", duplicateIndex)
|
||||
result.Skipped++
|
||||
result.Items = append(result.Items, CodexSessionImportItem{
|
||||
@@ -241,9 +241,29 @@ func (h *AccountHandler) importCodexSessions(ctx context.Context, req CodexSessi
|
||||
})
|
||||
continue
|
||||
}
|
||||
markCodexIdentitySeen(seenIdentity, item.IdentityKeys, entry.Index)
|
||||
markCodexIdentitySeen(seenIdentity, item.IdentityKeys, entry.Index, item.UserID)
|
||||
|
||||
if existing := index.Find(item.IdentityKeys); existing != nil && updateExisting {
|
||||
existing, matchedKey := index.Find(item.IdentityKeys, item.UserID)
|
||||
if existing != nil && updateExisting {
|
||||
if strings.HasPrefix(matchedKey, "account:") && item.UserID != "" &&
|
||||
codexCredentialString(existing.Credentials, "chatgpt_user_id") == "" {
|
||||
result.Warnings = append(result.Warnings, CodexSessionImportMessage{
|
||||
Index: entry.Index,
|
||||
Name: accountName,
|
||||
Message: "已有账号未记录 chatgpt_user_id,已按共享的 chatgpt_account_id 匹配并回填,请确认两者属于同一用户",
|
||||
})
|
||||
}
|
||||
preserveExistingRefresh := item.RefreshToken == "" &&
|
||||
codexCredentialString(existing.Credentials, "refresh_token") != ""
|
||||
if preserveExistingRefresh {
|
||||
result.Warnings = append(result.Warnings, CodexSessionImportMessage{
|
||||
Index: entry.Index,
|
||||
Name: accountName,
|
||||
Message: "已有账号包含 refresh_token,本次 accessToken-only 导入已保留自动续期凭据",
|
||||
})
|
||||
effectiveExpiresAt = nil
|
||||
autoPauseOnExpired = nil
|
||||
}
|
||||
mergedCredentials := mergeCodexImportCredentials(existing.Credentials, credentials, item)
|
||||
mergedExtra := mergeCodexImportMap(existing.Extra, extra)
|
||||
updateInput := &service.UpdateAccountInput{
|
||||
@@ -583,7 +603,7 @@ func normalizeCodexImportEntry(entry codexImportEntry) (*codexImportAccount, err
|
||||
|
||||
fingerprint := codexTokenFingerprint(item.AccessToken)
|
||||
item.Extra["access_token_sha256"] = fingerprint
|
||||
item.IdentityKeys = buildCodexIdentityKeys(item.AccountID, item.UserID, item.Email, item.AccessToken)
|
||||
item.IdentityKeys = buildCodexImportIdentityKeys(item.AccountID, item.UserID, item.Email, item.AccessToken, item.RefreshToken)
|
||||
item.Name = buildCodexImportAccountName(item, entry.Index)
|
||||
|
||||
return item, nil
|
||||
@@ -774,16 +794,20 @@ func sanitizeCodexImportCredentialExtras(input map[string]any) map[string]any {
|
||||
return nil
|
||||
}
|
||||
protected := map[string]struct{}{
|
||||
"access_token": {},
|
||||
"refresh_token": {},
|
||||
"id_token": {},
|
||||
"expires_at": {},
|
||||
"email": {},
|
||||
"chatgpt_account_id": {},
|
||||
"chatgpt_user_id": {},
|
||||
"organization_id": {},
|
||||
"plan_type": {},
|
||||
"client_id": {},
|
||||
"access_token": {},
|
||||
"refresh_token": {},
|
||||
"id_token": {},
|
||||
"expires_at": {},
|
||||
"email": {},
|
||||
"chatgpt_account_id": {},
|
||||
"chatgpt_user_id": {},
|
||||
"organization_id": {},
|
||||
"plan_type": {},
|
||||
"client_id": {},
|
||||
"auth_mode": {},
|
||||
"openai_auth_mode": {},
|
||||
"token_type": {},
|
||||
"chatgpt_account_is_fedramp": {},
|
||||
}
|
||||
out := make(map[string]any, len(input))
|
||||
for key, value := range input {
|
||||
@@ -802,13 +826,25 @@ func sanitizeCodexImportCredentialExtras(input map[string]any) map[string]any {
|
||||
return out
|
||||
}
|
||||
|
||||
func buildCodexIdentityKeys(accountID, userID, email, accessToken string) []string {
|
||||
keys := make([]string, 0, 4)
|
||||
// buildCodexImportIdentityKeys 生成导入条目的匹配键。refresh_token 缺失时
|
||||
// Codex session 只能作为 accessToken-only 凭据使用,此时以 access token
|
||||
// 指纹作为唯一稳定身份,避免同 workspace 下共享的 account/user 标识误合并。
|
||||
func buildCodexImportIdentityKeys(accountID, userID, email, accessToken, refreshToken string) []string {
|
||||
accessToken = strings.TrimSpace(accessToken)
|
||||
refreshToken = strings.TrimSpace(refreshToken)
|
||||
if refreshToken == "" && accessToken != "" {
|
||||
return []string{"access:" + codexTokenFingerprint(accessToken)}
|
||||
}
|
||||
return buildCodexStoredIdentityKeys(accountID, userID, email, accessToken)
|
||||
}
|
||||
|
||||
// buildCodexStoredIdentityKeys 生成存量账号索引键,保留 user/account 维度,
|
||||
// 让 accessToken-only 账号后续升级为完整 OAuth 时仍能命中并更新原账号。
|
||||
func buildCodexStoredIdentityKeys(accountID, userID, email, accessToken string) []string {
|
||||
keys := make([]string, 0, 3)
|
||||
accountID = strings.TrimSpace(accountID)
|
||||
userID = strings.TrimSpace(userID)
|
||||
if accountID != "" {
|
||||
keys = append(keys, "account:"+accountID)
|
||||
}
|
||||
accessToken = strings.TrimSpace(accessToken)
|
||||
if userID != "" {
|
||||
keys = append(keys, "user:"+userID)
|
||||
}
|
||||
@@ -817,14 +853,17 @@ func buildCodexIdentityKeys(accountID, userID, email, accessToken string) []stri
|
||||
keys = append(keys, "email:"+email)
|
||||
}
|
||||
}
|
||||
if accessToken = strings.TrimSpace(accessToken); accessToken != "" {
|
||||
if accessToken != "" {
|
||||
keys = append(keys, "access:"+codexTokenFingerprint(accessToken))
|
||||
}
|
||||
if accountID != "" {
|
||||
keys = append(keys, "account:"+accountID)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func buildCodexAccountIndex(accounts []service.Account) *codexAccountIndex {
|
||||
index := &codexAccountIndex{accountsByKey: map[string]service.Account{}}
|
||||
index := &codexAccountIndex{accountsByKey: map[string][]service.Account{}}
|
||||
for _, account := range accounts {
|
||||
index.Add(account)
|
||||
}
|
||||
@@ -836,43 +875,99 @@ func (i *codexAccountIndex) Add(account service.Account) {
|
||||
return
|
||||
}
|
||||
if i.accountsByKey == nil {
|
||||
i.accountsByKey = map[string]service.Account{}
|
||||
i.accountsByKey = map[string][]service.Account{}
|
||||
}
|
||||
keys := buildCodexIdentityKeys(
|
||||
i.remove(account.ID)
|
||||
keys := buildCodexStoredIdentityKeys(
|
||||
codexCredentialString(account.Credentials, "chatgpt_account_id"),
|
||||
codexCredentialString(account.Credentials, "chatgpt_user_id"),
|
||||
codexCredentialString(account.Credentials, "email"),
|
||||
codexCredentialString(account.Credentials, "access_token"),
|
||||
)
|
||||
for _, key := range keys {
|
||||
i.accountsByKey[key] = account
|
||||
i.accountsByKey[key] = upsertCodexAccount(i.accountsByKey[key], account)
|
||||
}
|
||||
}
|
||||
|
||||
func (i *codexAccountIndex) Find(keys []string) *service.Account {
|
||||
func (i *codexAccountIndex) remove(accountID int64) {
|
||||
for key, accounts := range i.accountsByKey {
|
||||
kept := accounts[:0]
|
||||
for _, account := range accounts {
|
||||
if account.ID != accountID {
|
||||
kept = append(kept, account)
|
||||
}
|
||||
}
|
||||
if len(kept) == 0 {
|
||||
delete(i.accountsByKey, key)
|
||||
continue
|
||||
}
|
||||
i.accountsByKey[key] = kept
|
||||
}
|
||||
}
|
||||
|
||||
// upsertCodexAccount 保留同一键下的全部候选账号(共享的 account: 键可对应
|
||||
// 团队内多个账号),同一账号重复 Add 时原位替换为最新状态。
|
||||
func upsertCodexAccount(accounts []service.Account, account service.Account) []service.Account {
|
||||
for idx := range accounts {
|
||||
if accounts[idx].ID == account.ID {
|
||||
accounts[idx] = account
|
||||
return accounts
|
||||
}
|
||||
}
|
||||
return append(accounts, account)
|
||||
}
|
||||
|
||||
// Find 返回第一个通过跨用户校验的候选账号及其命中的匹配键。
|
||||
func (i *codexAccountIndex) Find(keys []string, userID string) (*service.Account, string) {
|
||||
if i == nil {
|
||||
return nil
|
||||
return nil, ""
|
||||
}
|
||||
for _, key := range keys {
|
||||
if account, ok := i.accountsByKey[key]; ok {
|
||||
return &account
|
||||
for _, account := range i.accountsByKey[key] {
|
||||
if codexIdentityConflicts(key, userID, codexCredentialString(account.Credentials, "chatgpt_user_id")) {
|
||||
continue
|
||||
}
|
||||
return &account, key
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
func firstSeenCodexIdentity(seen map[string]int, keys []string) (int, bool) {
|
||||
// codexIdentityConflicts 判断 account: 键的命中是否把同一 ChatGPT 团队的两个
|
||||
// 不同成员误连到一起:双方都携带 user id 且不相等时视为冲突。存量索引侧
|
||||
// 仍保留 account 键,任一侧缺少 user id 时允许匹配,使含 refresh_token
|
||||
// 的常规导入和 accessToken-only 账号升级为完整 OAuth 时仍能更新原账号。
|
||||
func codexIdentityConflicts(key, userID, storedUserID string) bool {
|
||||
if !strings.HasPrefix(key, "account:") {
|
||||
return false
|
||||
}
|
||||
userID = strings.TrimSpace(userID)
|
||||
storedUserID = strings.TrimSpace(storedUserID)
|
||||
return userID != "" && storedUserID != "" && userID != storedUserID
|
||||
}
|
||||
|
||||
type codexSeenIdentity struct {
|
||||
index int
|
||||
userID string
|
||||
}
|
||||
|
||||
func firstSeenCodexIdentity(seen map[string]codexSeenIdentity, keys []string, userID string) (int, bool) {
|
||||
for _, key := range keys {
|
||||
if index, ok := seen[key]; ok {
|
||||
return index, true
|
||||
entry, ok := seen[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if codexIdentityConflicts(key, userID, entry.userID) {
|
||||
continue
|
||||
}
|
||||
return entry.index, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func markCodexIdentitySeen(seen map[string]int, keys []string, index int) {
|
||||
func markCodexIdentitySeen(seen map[string]codexSeenIdentity, keys []string, index int, userID string) {
|
||||
for _, key := range keys {
|
||||
seen[key] = index
|
||||
seen[key] = codexSeenIdentity{index: index, userID: userID}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -893,8 +988,15 @@ func mergeCodexImportCredentials(existing, incoming map[string]any, item *codexI
|
||||
return out
|
||||
}
|
||||
if strings.TrimSpace(item.RefreshToken) == "" {
|
||||
delete(out, "refresh_token")
|
||||
delete(out, "client_id")
|
||||
if codexCredentialString(existing, "refresh_token") == "" {
|
||||
delete(out, "refresh_token")
|
||||
delete(out, "client_id")
|
||||
} else {
|
||||
out["refresh_token"] = existing["refresh_token"]
|
||||
if clientID, ok := existing["client_id"]; ok {
|
||||
out["client_id"] = clientID
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(item.IDToken) == "" {
|
||||
delete(out, "id_token")
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
func TestParseCodexSessionImportEntriesSupportsRawTokenJSONAndArray(t *testing.T) {
|
||||
@@ -142,7 +145,7 @@ func TestNormalizeCodexSessionJSONExtractsCredentialsAndIgnoresSessionToken(t *t
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeCodexImportCredentialsClearsStaleRefreshFieldsWhenIncomingHasNoRefreshToken(t *testing.T) {
|
||||
func TestMergeCodexImportCredentialsPreservesExistingRefreshFieldsWhenIncomingHasNoRefreshToken(t *testing.T) {
|
||||
existing := map[string]any{
|
||||
"access_token": "old-access-token",
|
||||
"refresh_token": "old-refresh-token",
|
||||
@@ -169,11 +172,11 @@ func TestMergeCodexImportCredentialsClearsStaleRefreshFieldsWhenIncomingHasNoRef
|
||||
if merged["chatgpt_account_id"] != "acct-new" {
|
||||
t.Fatalf("chatgpt_account_id = %v, want acct-new", merged["chatgpt_account_id"])
|
||||
}
|
||||
if _, ok := merged["refresh_token"]; ok {
|
||||
t.Fatalf("refresh_token should be cleared")
|
||||
if merged["refresh_token"] != "old-refresh-token" {
|
||||
t.Fatalf("refresh_token = %v, want old-refresh-token", merged["refresh_token"])
|
||||
}
|
||||
if _, ok := merged["client_id"]; ok {
|
||||
t.Fatalf("client_id should be cleared")
|
||||
if merged["client_id"] != "old-client-id" {
|
||||
t.Fatalf("client_id = %v, want old-client-id", merged["client_id"])
|
||||
}
|
||||
if _, ok := merged["id_token"]; ok {
|
||||
t.Fatalf("id_token should be cleared")
|
||||
@@ -299,14 +302,20 @@ func TestResolveCodexImportExpiryForNoRefreshTokenUsesEarlierRequestExpiry(t *te
|
||||
}
|
||||
|
||||
func TestCodexIdentityKeysPreferStrongIdentifiers(t *testing.T) {
|
||||
keys := buildCodexIdentityKeys("acct-1", "user-1", "same@example.com", "token")
|
||||
keys := buildCodexImportIdentityKeys("acct-1", "user-1", "same@example.com", "token", "refresh")
|
||||
if len(keys) == 0 || keys[0] != "user:user-1" {
|
||||
t.Fatalf("user key should have highest priority when refresh token exists: %v", keys)
|
||||
}
|
||||
if keys[len(keys)-1] != "account:acct-1" {
|
||||
t.Fatalf("shared account key should be the last fallback: %v", keys)
|
||||
}
|
||||
for _, key := range keys {
|
||||
if strings.HasPrefix(key, "email:") {
|
||||
t.Fatalf("strong identity should not include email fallback: %v", keys)
|
||||
}
|
||||
}
|
||||
|
||||
keys = buildCodexIdentityKeys("", "", "same@example.com", "token")
|
||||
keys = buildCodexImportIdentityKeys("", "", "same@example.com", "token", "refresh")
|
||||
hasEmail := false
|
||||
for _, key := range keys {
|
||||
if key == "email:same@example.com" {
|
||||
@@ -316,6 +325,605 @@ func TestCodexIdentityKeysPreferStrongIdentifiers(t *testing.T) {
|
||||
if !hasEmail {
|
||||
t.Fatalf("weak identity should include email fallback: %v", keys)
|
||||
}
|
||||
|
||||
keys = buildCodexImportIdentityKeys("acct-1", "user-1", "same@example.com", "token", "")
|
||||
if len(keys) != 1 || !strings.HasPrefix(keys[0], "access:") {
|
||||
t.Fatalf("accessToken-only identity should use only access fingerprint: %v", keys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexAccountIndexDoesNotMatchDifferentUsersInSameChatGPTAccount(t *testing.T) {
|
||||
existing := service.Account{
|
||||
ID: 10,
|
||||
Credentials: map[string]any{
|
||||
"chatgpt_account_id": "team-1",
|
||||
"chatgpt_user_id": "user-1",
|
||||
"access_token": "token-1",
|
||||
"refresh_token": "refresh-1",
|
||||
},
|
||||
}
|
||||
index := buildCodexAccountIndex([]service.Account{existing})
|
||||
|
||||
keys := buildCodexImportIdentityKeys("team-1", "user-2", "", "token-2", "refresh-2")
|
||||
if got, _ := index.Find(keys, "user-2"); got != nil {
|
||||
t.Fatalf("Find matched account ID %d for a different chatgpt_user_id in the same team", got.ID)
|
||||
}
|
||||
|
||||
keys = buildCodexImportIdentityKeys("team-1", "user-1", "", "token-2", "refresh-2")
|
||||
got, _ := index.Find(keys, "user-1")
|
||||
if got == nil || got.ID != existing.ID {
|
||||
t.Fatalf("Find by same chatgpt_user_id = %v, want account ID %d", got, existing.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexAccountIndexFallsBackToAccountKeyWhenRefreshTokenExistsAndUserIDMissing(t *testing.T) {
|
||||
// 含 refresh_token 的常规导入沿用 a5638a4e 的兼容逻辑:存量账号缺少
|
||||
// chatgpt_user_id 时,携带 user id 的重新导入仍可命中并回填。
|
||||
legacy := service.Account{
|
||||
ID: 20,
|
||||
Credentials: map[string]any{
|
||||
"chatgpt_account_id": "team-1",
|
||||
"access_token": "token-old",
|
||||
"refresh_token": "refresh-old",
|
||||
},
|
||||
}
|
||||
index := buildCodexAccountIndex([]service.Account{legacy})
|
||||
|
||||
keys := buildCodexImportIdentityKeys("team-1", "user-1", "", "token-new", "refresh-new")
|
||||
got, matchedKey := index.Find(keys, "user-1")
|
||||
if got == nil || got.ID != legacy.ID {
|
||||
t.Fatalf("Find legacy account without stored user id = %v, want account ID %d", got, legacy.ID)
|
||||
}
|
||||
if matchedKey != "account:team-1" {
|
||||
t.Fatalf("matched key = %q, want account:team-1", matchedKey)
|
||||
}
|
||||
|
||||
// 反向:含 refresh_token 的导入条目无法解析出 user id 时,仍应通过
|
||||
// account 键命中已有账号,保持常规导入去重行为。
|
||||
full := service.Account{
|
||||
ID: 21,
|
||||
Credentials: map[string]any{
|
||||
"chatgpt_account_id": "team-2",
|
||||
"chatgpt_user_id": "user-9",
|
||||
"access_token": "token-old",
|
||||
"refresh_token": "refresh-old",
|
||||
},
|
||||
}
|
||||
index = buildCodexAccountIndex([]service.Account{full})
|
||||
|
||||
keys = buildCodexImportIdentityKeys("team-2", "", "", "token-opaque", "refresh-new")
|
||||
got, _ = index.Find(keys, "")
|
||||
if got == nil || got.ID != full.ID {
|
||||
t.Fatalf("Find by account key without entry user id = %v, want account ID %d", got, full.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexAccountIndexAccessTokenOnlyUsesTokenFingerprint(t *testing.T) {
|
||||
existing := service.Account{
|
||||
ID: 22,
|
||||
Credentials: map[string]any{
|
||||
"chatgpt_account_id": "team-1",
|
||||
"chatgpt_user_id": "user-1",
|
||||
"access_token": "token-old",
|
||||
},
|
||||
}
|
||||
index := buildCodexAccountIndex([]service.Account{existing})
|
||||
|
||||
keys := buildCodexImportIdentityKeys("team-1", "user-1", "", "token-new", "")
|
||||
if got, matchedKey := index.Find(keys, "user-1"); got != nil {
|
||||
t.Fatalf("accessToken-only import matched by %q despite different token: account ID %d", matchedKey, got.ID)
|
||||
}
|
||||
|
||||
keys = buildCodexImportIdentityKeys("team-1", "user-1", "", "token-old", "")
|
||||
got, matchedKey := index.Find(keys, "user-1")
|
||||
if got == nil || got.ID != existing.ID {
|
||||
t.Fatalf("Find accessToken-only duplicate by fingerprint = %v, want account ID %d", got, existing.ID)
|
||||
}
|
||||
if !strings.HasPrefix(matchedKey, "access:") {
|
||||
t.Fatalf("matched key = %q, want access fingerprint", matchedKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexAccountIndexKeepsAllCandidatesForSharedAccountKey(t *testing.T) {
|
||||
legacy := service.Account{
|
||||
ID: 30,
|
||||
Credentials: map[string]any{
|
||||
"chatgpt_account_id": "team-1",
|
||||
"access_token": "token-legacy",
|
||||
"refresh_token": "refresh-legacy",
|
||||
},
|
||||
}
|
||||
member := service.Account{
|
||||
ID: 31,
|
||||
Credentials: map[string]any{
|
||||
"chatgpt_account_id": "team-1",
|
||||
"chatgpt_user_id": "user-2",
|
||||
"access_token": "token-member",
|
||||
"refresh_token": "refresh-member",
|
||||
},
|
||||
}
|
||||
|
||||
// 无论索引构建顺序如何,携带新 user id 的条目都应跳过 user-2 的账号,
|
||||
// 命中缺少 user id 的存量账号,而不是因单一候选被遮蔽而落空。
|
||||
for _, accounts := range [][]service.Account{
|
||||
{member, legacy},
|
||||
{legacy, member},
|
||||
} {
|
||||
index := buildCodexAccountIndex(accounts)
|
||||
|
||||
keys := buildCodexImportIdentityKeys("team-1", "user-1", "", "token-new", "refresh-new")
|
||||
got, matchedKey := index.Find(keys, "user-1")
|
||||
if got == nil || got.ID != legacy.ID {
|
||||
t.Fatalf("Find with shared account key = %v, want legacy account ID %d", got, legacy.ID)
|
||||
}
|
||||
if matchedKey != "account:team-1" {
|
||||
t.Fatalf("matched key = %q, want account:team-1", matchedKey)
|
||||
}
|
||||
|
||||
keys = buildCodexImportIdentityKeys("team-1", "user-2", "", "token-new", "refresh-new")
|
||||
got, matchedKey = index.Find(keys, "user-2")
|
||||
if got == nil || got.ID != member.ID {
|
||||
t.Fatalf("Find by user key = %v, want member account ID %d", got, member.ID)
|
||||
}
|
||||
if matchedKey != "user:user-2" {
|
||||
t.Fatalf("matched key = %q, want user:user-2", matchedKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexAccountIndexUpsertReplacesSameAccount(t *testing.T) {
|
||||
legacy := service.Account{
|
||||
ID: 40,
|
||||
Credentials: map[string]any{
|
||||
"chatgpt_account_id": "team-1",
|
||||
"access_token": "token-old",
|
||||
},
|
||||
}
|
||||
index := buildCodexAccountIndex([]service.Account{legacy})
|
||||
|
||||
backfilled := service.Account{
|
||||
ID: 40,
|
||||
Credentials: map[string]any{
|
||||
"chatgpt_account_id": "team-1",
|
||||
"chatgpt_user_id": "user-1",
|
||||
"access_token": "token-new",
|
||||
"refresh_token": "refresh-new",
|
||||
},
|
||||
}
|
||||
index.Add(backfilled)
|
||||
|
||||
// 回填后同一账号在 account 键下应被原位替换而非残留旧副本:
|
||||
// 其他成员的条目不应再通过旧副本(无 user id)命中该账号。
|
||||
keys := buildCodexImportIdentityKeys("team-1", "user-2", "", "token-other", "refresh-other")
|
||||
if got, matchedKey := index.Find(keys, "user-2"); got != nil {
|
||||
t.Fatalf("stale candidate matched after upsert by %q: account ID %d", matchedKey, got.ID)
|
||||
}
|
||||
|
||||
keys = buildCodexImportIdentityKeys("team-1", "user-1", "", "token-other", "refresh-other")
|
||||
got, _ := index.Find(keys, "user-1")
|
||||
if got == nil || got.ID != backfilled.ID {
|
||||
t.Fatalf("Find after upsert = %v, want account ID %d", got, backfilled.ID)
|
||||
}
|
||||
if uid := codexCredentialString(got.Credentials, "chatgpt_user_id"); uid != "user-1" {
|
||||
t.Fatalf("upsert did not replace credentials, chatgpt_user_id = %q", uid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexIdentitySeenDistinguishesTeamMembers(t *testing.T) {
|
||||
seen := map[string]codexSeenIdentity{}
|
||||
member1 := buildCodexImportIdentityKeys("team-1", "user-1", "", "token-1", "refresh-1")
|
||||
markCodexIdentitySeen(seen, member1, 1, "user-1")
|
||||
|
||||
member2 := buildCodexImportIdentityKeys("team-1", "user-2", "", "token-2", "refresh-2")
|
||||
if index, ok := firstSeenCodexIdentity(seen, member2, "user-2"); ok {
|
||||
t.Fatalf("different team member treated as duplicate of entry %d", index)
|
||||
}
|
||||
|
||||
again := buildCodexImportIdentityKeys("team-1", "user-1", "", "token-3", "refresh-3")
|
||||
index, ok := firstSeenCodexIdentity(seen, again, "user-1")
|
||||
if !ok || index != 1 {
|
||||
t.Fatalf("same user re-entry dedup = (%d, %v), want (1, true)", index, ok)
|
||||
}
|
||||
|
||||
// 无 user id 的条目不应因共享 account id 与已见团队成员互相去重;
|
||||
// 只有相同 access token 指纹才视为重复。
|
||||
opaque := buildCodexImportIdentityKeys("team-1", "", "", "token-4", "")
|
||||
index, ok = firstSeenCodexIdentity(seen, opaque, "")
|
||||
if ok {
|
||||
t.Fatalf("entry without user id dedup = (%d, %v), want no match", index, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCodexImportUsesJWTSubForAccessTokenOnlyIdentity(t *testing.T) {
|
||||
accessToken := buildCodexImportTestJWT(t, time.Now().Add(time.Hour), map[string]any{
|
||||
"sub": "user-from-access-token",
|
||||
"https://api.openai.com/auth": map[string]any{
|
||||
"chatgpt_account_id": "workspace-1",
|
||||
},
|
||||
})
|
||||
|
||||
item, err := normalizeCodexImportEntry(codexImportEntry{Index: 1, Value: accessToken})
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeCodexImportEntry error = %v", err)
|
||||
}
|
||||
if item.UserID != "user-from-access-token" {
|
||||
t.Fatalf("UserID = %q, want JWT sub", item.UserID)
|
||||
}
|
||||
if len(item.IdentityKeys) != 1 || !strings.HasPrefix(item.IdentityKeys[0], "access:") {
|
||||
t.Fatalf("IdentityKeys = %v, want access fingerprint only for accessToken-only import", item.IdentityKeys)
|
||||
}
|
||||
if got := item.Credentials["chatgpt_user_id"]; got != "user-from-access-token" {
|
||||
t.Fatalf("credential chatgpt_user_id = %v, want JWT sub", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportCodexSessionsAccessTokenOnlySameWorkspaceDifferentUsersCreatesTwoAccounts(t *testing.T) {
|
||||
svc := newCodexImportMemoryAdminService(nil)
|
||||
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)}
|
||||
entries := []codexImportEntry{
|
||||
{Index: 1, Value: buildCodexAccessOnlyImportValue(t, "workspace-1", "user-1")},
|
||||
{Index: 2, Value: buildCodexAccessOnlyImportValue(t, "workspace-1", "user-2")},
|
||||
}
|
||||
|
||||
result, err := handler.importCodexSessions(context.Background(), req, entries)
|
||||
if err != nil {
|
||||
t.Fatalf("importCodexSessions error = %v", err)
|
||||
}
|
||||
if result.Created != 2 || result.Updated != 0 || result.Skipped != 0 || result.Failed != 0 {
|
||||
t.Fatalf("result = %+v, want two created accounts", result)
|
||||
}
|
||||
if len(svc.createdAccounts) != 2 {
|
||||
t.Fatalf("created accounts = %d, want 2", len(svc.createdAccounts))
|
||||
}
|
||||
if svc.createdAccounts[0].Credentials["chatgpt_user_id"] == svc.createdAccounts[1].Credentials["chatgpt_user_id"] {
|
||||
t.Fatalf("created accounts share user id: %v", svc.createdAccounts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportCodexSessionsAccessTokenOnlySameWorkspaceAndUserDifferentTokensCreatesTwoAccounts(t *testing.T) {
|
||||
svc := newCodexImportMemoryAdminService(nil)
|
||||
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)}
|
||||
entries := []codexImportEntry{
|
||||
{Index: 1, Value: map[string]any{
|
||||
"access_token": buildCodexImportTestJWT(t, time.Now().Add(time.Hour), map[string]any{
|
||||
"sub": "shared-user",
|
||||
"jti": "token-1",
|
||||
"https://api.openai.com/auth": map[string]any{
|
||||
"chatgpt_account_id": "workspace-1",
|
||||
},
|
||||
}),
|
||||
}},
|
||||
{Index: 2, Value: map[string]any{
|
||||
"access_token": buildCodexImportTestJWT(t, time.Now().Add(time.Hour), map[string]any{
|
||||
"sub": "shared-user",
|
||||
"jti": "token-2",
|
||||
"https://api.openai.com/auth": map[string]any{
|
||||
"chatgpt_account_id": "workspace-1",
|
||||
},
|
||||
}),
|
||||
}},
|
||||
}
|
||||
|
||||
result, err := handler.importCodexSessions(context.Background(), req, entries)
|
||||
if err != nil {
|
||||
t.Fatalf("importCodexSessions error = %v", err)
|
||||
}
|
||||
if result.Created != 2 || result.Updated != 0 || result.Skipped != 0 || result.Failed != 0 {
|
||||
t.Fatalf("result = %+v, want two created accounts", result)
|
||||
}
|
||||
if len(svc.createdAccounts) != 2 {
|
||||
t.Fatalf("created accounts = %d, want 2", len(svc.createdAccounts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportCodexSessionsAccessTokenOnlySameUserUpdatesExisting(t *testing.T) {
|
||||
existingToken := buildCodexAccessToken(t, "workspace-1", "user-1", time.Now().Add(time.Hour))
|
||||
svc := newCodexImportMemoryAdminService([]service.Account{{
|
||||
ID: 10,
|
||||
Name: "existing",
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"chatgpt_account_id": "workspace-1",
|
||||
"chatgpt_user_id": "user-1",
|
||||
"access_token": existingToken,
|
||||
},
|
||||
}})
|
||||
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)}
|
||||
entries := []codexImportEntry{
|
||||
{Index: 1, Value: map[string]any{"access_token": existingToken}},
|
||||
}
|
||||
|
||||
result, err := handler.importCodexSessions(context.Background(), req, entries)
|
||||
if err != nil {
|
||||
t.Fatalf("importCodexSessions error = %v", err)
|
||||
}
|
||||
if result.Created != 0 || result.Updated != 1 || result.Failed != 0 {
|
||||
t.Fatalf("result = %+v, want one updated account", result)
|
||||
}
|
||||
if len(svc.createdAccounts) != 0 {
|
||||
t.Fatalf("created accounts = %d, want 0", len(svc.createdAccounts))
|
||||
}
|
||||
if len(svc.updatedAccounts) != 1 || svc.updatedAccounts[0].id != 10 {
|
||||
t.Fatalf("updated accounts = %+v, want account 10", svc.updatedAccounts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportCodexSessionsUpgradesAccessTokenOnlyAccountWithRefreshToken(t *testing.T) {
|
||||
oldToken := buildCodexAccessTokenWithJTI(t, "workspace-1", "user-1", "old-token", time.Now().Add(time.Hour))
|
||||
newToken := buildCodexAccessTokenWithJTI(t, "workspace-1", "user-1", "new-token", time.Now().Add(time.Hour))
|
||||
svc := newCodexImportMemoryAdminService([]service.Account{{
|
||||
ID: 12,
|
||||
Name: "existing",
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"chatgpt_account_id": "workspace-1",
|
||||
"chatgpt_user_id": "user-1",
|
||||
"access_token": oldToken,
|
||||
},
|
||||
}})
|
||||
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)}
|
||||
entries := []codexImportEntry{
|
||||
{Index: 1, Value: map[string]any{
|
||||
"access_token": newToken,
|
||||
"refresh_token": "refresh-new",
|
||||
}},
|
||||
}
|
||||
|
||||
result, err := handler.importCodexSessions(context.Background(), req, entries)
|
||||
if err != nil {
|
||||
t.Fatalf("importCodexSessions error = %v", err)
|
||||
}
|
||||
if result.Created != 0 || result.Updated != 1 || result.Failed != 0 {
|
||||
t.Fatalf("result = %+v, want one updated account", result)
|
||||
}
|
||||
if len(svc.updatedAccounts) != 1 || svc.updatedAccounts[0].id != 12 {
|
||||
t.Fatalf("updated accounts = %+v, want account 12", svc.updatedAccounts)
|
||||
}
|
||||
if got := svc.updatedAccounts[0].input.Credentials["refresh_token"]; got != "refresh-new" {
|
||||
t.Fatalf("updated refresh_token = %v, want refresh-new", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportCodexSessionsAccessTokenOnlyPreservesExistingRefreshToken(t *testing.T) {
|
||||
existingToken := buildCodexAccessToken(t, "workspace-1", "user-1", time.Now().Add(time.Hour))
|
||||
svc := newCodexImportMemoryAdminService([]service.Account{{
|
||||
ID: 13,
|
||||
Name: "existing",
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"chatgpt_account_id": "workspace-1",
|
||||
"chatgpt_user_id": "user-1",
|
||||
"access_token": existingToken,
|
||||
"refresh_token": "refresh-old",
|
||||
"client_id": "client-old",
|
||||
},
|
||||
}})
|
||||
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)}
|
||||
entries := []codexImportEntry{
|
||||
{Index: 1, Value: map[string]any{"access_token": existingToken}},
|
||||
}
|
||||
|
||||
result, err := handler.importCodexSessions(context.Background(), req, entries)
|
||||
if err != nil {
|
||||
t.Fatalf("importCodexSessions error = %v", err)
|
||||
}
|
||||
if result.Created != 0 || result.Updated != 1 || result.Failed != 0 {
|
||||
t.Fatalf("result = %+v, want one updated account", result)
|
||||
}
|
||||
update := svc.updatedAccounts[0].input
|
||||
if got := update.Credentials["refresh_token"]; got != "refresh-old" {
|
||||
t.Fatalf("refresh_token = %v, want refresh-old", got)
|
||||
}
|
||||
if got := update.Credentials["client_id"]; got != "client-old" {
|
||||
t.Fatalf("client_id = %v, want client-old", got)
|
||||
}
|
||||
if update.ExpiresAt != nil {
|
||||
t.Fatalf("ExpiresAt = %v, want nil to preserve OAuth account expiry", *update.ExpiresAt)
|
||||
}
|
||||
if update.AutoPauseOnExpired != nil {
|
||||
t.Fatalf("AutoPauseOnExpired = %v, want nil to preserve OAuth account scheduling", *update.AutoPauseOnExpired)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportCodexSessionsBatchOldAccessTokenDoesNotRollbackRefreshToken(t *testing.T) {
|
||||
oldToken := buildCodexAccessTokenWithJTI(t, "workspace-1", "user-1", "old-token", time.Now().Add(time.Hour))
|
||||
newToken := buildCodexAccessTokenWithJTI(t, "workspace-1", "user-1", "new-token", time.Now().Add(time.Hour))
|
||||
svc := newCodexImportMemoryAdminService([]service.Account{{
|
||||
ID: 14,
|
||||
Name: "existing",
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"chatgpt_account_id": "workspace-1",
|
||||
"chatgpt_user_id": "user-1",
|
||||
"access_token": oldToken,
|
||||
"refresh_token": "refresh-old",
|
||||
},
|
||||
}})
|
||||
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)}
|
||||
entries := []codexImportEntry{
|
||||
{Index: 1, Value: map[string]any{
|
||||
"access_token": newToken,
|
||||
"refresh_token": "refresh-new",
|
||||
}},
|
||||
{Index: 2, Value: map[string]any{"access_token": oldToken}},
|
||||
}
|
||||
|
||||
result, err := handler.importCodexSessions(context.Background(), req, entries)
|
||||
if err != nil {
|
||||
t.Fatalf("importCodexSessions error = %v", err)
|
||||
}
|
||||
if result.Updated != 1 || result.Created != 1 || result.Failed != 0 {
|
||||
t.Fatalf("result = %+v, want first item updated and stale access token created separately", result)
|
||||
}
|
||||
if len(svc.updatedAccounts) != 1 || svc.updatedAccounts[0].id != 14 {
|
||||
t.Fatalf("updated accounts = %+v, want account 14 updated once", svc.updatedAccounts)
|
||||
}
|
||||
stored, err := svc.GetAccount(context.Background(), 14)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAccount error = %v", err)
|
||||
}
|
||||
if got := stored.Credentials["access_token"]; got != newToken {
|
||||
t.Fatalf("stored access_token rolled back = %v, want new token", got)
|
||||
}
|
||||
if got := stored.Credentials["refresh_token"]; got != "refresh-new" {
|
||||
t.Fatalf("stored refresh_token = %v, want refresh-new", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportCodexSessionsWithRefreshTokenKeepsExistingDedup(t *testing.T) {
|
||||
existingToken := buildCodexAccessToken(t, "workspace-1", "user-1", time.Now().Add(time.Hour))
|
||||
svc := newCodexImportMemoryAdminService([]service.Account{{
|
||||
ID: 11,
|
||||
Name: "existing",
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"chatgpt_account_id": "workspace-1",
|
||||
"chatgpt_user_id": "user-1",
|
||||
"access_token": existingToken,
|
||||
"refresh_token": "refresh-old",
|
||||
},
|
||||
}})
|
||||
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)}
|
||||
entries := []codexImportEntry{
|
||||
{Index: 1, Value: buildCodexRefreshImportValue(t, "workspace-1", "user-1", "refresh-new")},
|
||||
}
|
||||
|
||||
result, err := handler.importCodexSessions(context.Background(), req, entries)
|
||||
if err != nil {
|
||||
t.Fatalf("importCodexSessions error = %v", err)
|
||||
}
|
||||
if result.Created != 0 || result.Updated != 1 || result.Failed != 0 {
|
||||
t.Fatalf("result = %+v, want one updated account", result)
|
||||
}
|
||||
if got := svc.updatedAccounts[0].input.Credentials["refresh_token"]; got != "refresh-new" {
|
||||
t.Fatalf("updated refresh_token = %v, want refresh-new", got)
|
||||
}
|
||||
}
|
||||
|
||||
type codexImportMemoryAdminService struct {
|
||||
*stubAdminService
|
||||
nextID int64
|
||||
updatedAccounts []struct {
|
||||
id int64
|
||||
input *service.UpdateAccountInput
|
||||
}
|
||||
}
|
||||
|
||||
func newCodexImportMemoryAdminService(accounts []service.Account) *codexImportMemoryAdminService {
|
||||
stub := newStubAdminService()
|
||||
stub.accounts = append([]service.Account(nil), accounts...)
|
||||
return &codexImportMemoryAdminService{
|
||||
stubAdminService: stub,
|
||||
nextID: 100,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *codexImportMemoryAdminService) CreateAccount(ctx context.Context, input *service.CreateAccountInput) (*service.Account, error) {
|
||||
s.createdAccounts = append(s.createdAccounts, input)
|
||||
if s.createAccountErr != nil {
|
||||
return nil, s.createAccountErr
|
||||
}
|
||||
account := service.Account{
|
||||
ID: s.nextID,
|
||||
Name: input.Name,
|
||||
Platform: input.Platform,
|
||||
Type: input.Type,
|
||||
Status: service.StatusActive,
|
||||
Credentials: cloneCodexImportTestMap(input.Credentials),
|
||||
Extra: cloneCodexImportTestMap(input.Extra),
|
||||
}
|
||||
s.nextID++
|
||||
s.accounts = append(s.accounts, account)
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
func (s *codexImportMemoryAdminService) UpdateAccount(ctx context.Context, id int64, input *service.UpdateAccountInput) (*service.Account, error) {
|
||||
s.updatedAccounts = append(s.updatedAccounts, struct {
|
||||
id int64
|
||||
input *service.UpdateAccountInput
|
||||
}{id: id, input: input})
|
||||
if s.updateAccountErr != nil {
|
||||
return nil, s.updateAccountErr
|
||||
}
|
||||
for idx := range s.accounts {
|
||||
if s.accounts[idx].ID == id {
|
||||
s.accounts[idx].Credentials = cloneCodexImportTestMap(input.Credentials)
|
||||
s.accounts[idx].Extra = cloneCodexImportTestMap(input.Extra)
|
||||
return &s.accounts[idx], nil
|
||||
}
|
||||
}
|
||||
account := service.Account{ID: id, Status: service.StatusActive, Credentials: cloneCodexImportTestMap(input.Credentials)}
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
func (s *codexImportMemoryAdminService) GetAccount(ctx context.Context, id int64) (*service.Account, error) {
|
||||
for idx := range s.accounts {
|
||||
if s.accounts[idx].ID == id {
|
||||
return &s.accounts[idx], nil
|
||||
}
|
||||
}
|
||||
return s.stubAdminService.GetAccount(ctx, id)
|
||||
}
|
||||
|
||||
func buildCodexAccessOnlyImportValue(t *testing.T, accountID, userID string) map[string]any {
|
||||
t.Helper()
|
||||
return map[string]any{
|
||||
"access_token": buildCodexAccessToken(t, accountID, userID, time.Now().Add(time.Hour)),
|
||||
}
|
||||
}
|
||||
|
||||
func buildCodexRefreshImportValue(t *testing.T, accountID, userID, refreshToken string) map[string]any {
|
||||
t.Helper()
|
||||
return map[string]any{
|
||||
"access_token": buildCodexAccessToken(t, accountID, userID, time.Now().Add(time.Hour)),
|
||||
"refresh_token": refreshToken,
|
||||
}
|
||||
}
|
||||
|
||||
func buildCodexAccessToken(t *testing.T, accountID, userID string, exp time.Time) string {
|
||||
t.Helper()
|
||||
return buildCodexAccessTokenWithJTI(t, accountID, userID, "", exp)
|
||||
}
|
||||
|
||||
func buildCodexAccessTokenWithJTI(t *testing.T, accountID, userID, jti string, exp time.Time) string {
|
||||
t.Helper()
|
||||
claims := map[string]any{
|
||||
"sub": userID,
|
||||
"https://api.openai.com/auth": map[string]any{
|
||||
"chatgpt_account_id": accountID,
|
||||
},
|
||||
}
|
||||
if jti != "" {
|
||||
claims["jti"] = jti
|
||||
}
|
||||
return buildCodexImportTestJWT(t, exp, claims)
|
||||
}
|
||||
|
||||
func cloneCodexImportTestMap(input map[string]any) map[string]any {
|
||||
if input == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(input))
|
||||
for key, value := range input {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func boolPtr(v bool) *bool {
|
||||
return &v
|
||||
}
|
||||
|
||||
func buildCodexImportTestJWT(t *testing.T, exp time.Time, extraClaims map[string]any) string {
|
||||
|
||||
@@ -30,6 +30,9 @@ type DataPayload struct {
|
||||
ExportedAt string `json:"exported_at"`
|
||||
Proxies []DataProxy `json:"proxies"`
|
||||
Accounts []DataAccount `json:"accounts"`
|
||||
// SkippedShadows 记录导出时被排除的 spark 影子账号数量(见 ExportData)。仅作可见性提示,
|
||||
// 导入侧忽略该字段;omitempty 保持向后兼容。
|
||||
SkippedShadows int `json:"skipped_shadows,omitempty"`
|
||||
}
|
||||
|
||||
type DataProxy struct {
|
||||
@@ -50,6 +53,10 @@ type DataProxy struct {
|
||||
// DataAccount 是管理员显式备份导出使用的账号结构,故意不走 dto.Account 的脱敏路径,
|
||||
// Credentials 原文返回。这是"管理员备份"这一显式行为的一部分;如未来需要导出脱敏版本,
|
||||
// 应新增独立结构而非修改这里。
|
||||
// 注意:本结构不含 parent_account_id/quota_dimension——spark 影子账号在 ExportData 处被显式
|
||||
// 排除(影子不持凭据、通用凭据型导入强制 credentials 非空无法重建父子链接),不在此表达。
|
||||
// 影子的独立调度配置(priority/并发/分组/status 管理员可单独调)亦不在本备份范围,属已知局限
|
||||
// (外审第6轮裁决:保持排除 + 前端警告,而非升级格式做完整往返)。
|
||||
type DataAccount struct {
|
||||
Name string `json:"name"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
@@ -105,6 +112,24 @@ func (h *AccountHandler) ExportData(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 排除 spark 影子账号:影子不持凭据,通用凭据型导出无法表达父子链接、导入侧又强制 credentials
|
||||
// 非空——若混入会产出无法还原的坏备份(导入即失败)。影子的独立调度配置(priority/并发/分组/
|
||||
// status,管理员可单独调)随之不进备份,还原后需在重建的影子上重新调优;前端按 skipped_shadows
|
||||
// 提示用户(外审第5轮发现、第6轮裁决:保持排除 + 警告,不做完整往返)。
|
||||
skippedShadows := 0
|
||||
exportable := make([]service.Account, 0, len(accounts))
|
||||
for i := range accounts {
|
||||
if accounts[i].IsCredentialShadow() {
|
||||
skippedShadows++
|
||||
continue
|
||||
}
|
||||
exportable = append(exportable, accounts[i])
|
||||
}
|
||||
accounts = exportable
|
||||
if skippedShadows > 0 {
|
||||
slog.Info("export_skipped_spark_shadows", "count", skippedShadows)
|
||||
}
|
||||
|
||||
includeProxies, err := parseIncludeProxies(c)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
@@ -191,9 +216,10 @@ func (h *AccountHandler) ExportData(c *gin.Context) {
|
||||
}
|
||||
|
||||
payload := DataPayload{
|
||||
ExportedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Proxies: dataProxies,
|
||||
Accounts: dataAccounts,
|
||||
ExportedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Proxies: dataProxies,
|
||||
Accounts: dataAccounts,
|
||||
SkippedShadows: skippedShadows,
|
||||
}
|
||||
|
||||
response.Success(c, payload)
|
||||
|
||||
@@ -18,10 +18,11 @@ type dataResponse struct {
|
||||
}
|
||||
|
||||
type dataPayload struct {
|
||||
Type string `json:"type"`
|
||||
Version int `json:"version"`
|
||||
Proxies []dataProxy `json:"proxies"`
|
||||
Accounts []dataAccount `json:"accounts"`
|
||||
Type string `json:"type"`
|
||||
Version int `json:"version"`
|
||||
Proxies []dataProxy `json:"proxies"`
|
||||
Accounts []dataAccount `json:"accounts"`
|
||||
SkippedShadows int `json:"skipped_shadows"`
|
||||
}
|
||||
|
||||
type dataProxy struct {
|
||||
@@ -172,6 +173,46 @@ func TestExportDataWithoutProxies(t *testing.T) {
|
||||
require.Nil(t, resp.Data.Accounts[0].ProxyKey)
|
||||
}
|
||||
|
||||
// TestExportDataExcludesSparkShadow 验证外审第5轮 P1/P2:导出时排除 spark 影子账号
|
||||
// (影子无凭据、导入侧强制 credentials 非空,混入会产出无法还原的坏备份),并透出跳过计数。
|
||||
func TestExportDataExcludesSparkShadow(t *testing.T) {
|
||||
router, adminSvc := setupAccountDataRouter()
|
||||
|
||||
parentID := int64(21)
|
||||
adminSvc.accounts = []service.Account{
|
||||
{
|
||||
ID: parentID,
|
||||
Name: "mother",
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeOAuth,
|
||||
Credentials: map[string]any{"token": "secret"},
|
||||
Status: service.StatusActive,
|
||||
},
|
||||
{
|
||||
ID: 22,
|
||||
Name: "mother (Spark)",
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeOAuth,
|
||||
Credentials: map[string]any{}, // 影子恒空凭据
|
||||
ParentAccountID: &parentID, // 影子标记
|
||||
QuotaDimension: service.QuotaDimensionSpark,
|
||||
Status: service.StatusActive,
|
||||
},
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/data?include_proxies=false", nil)
|
||||
router.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var resp dataResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
require.Equal(t, 0, resp.Code)
|
||||
require.Len(t, resp.Data.Accounts, 1, "影子应被排除,仅导出母账号")
|
||||
require.Equal(t, "mother", resp.Data.Accounts[0].Name)
|
||||
require.Equal(t, 1, resp.Data.SkippedShadows, "跳过的影子数量应透出")
|
||||
}
|
||||
|
||||
func TestExportDataPassesAccountFiltersAndSort(t *testing.T) {
|
||||
router, adminSvc := setupAccountDataRouter()
|
||||
adminSvc.accounts = []service.Account{
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -25,6 +26,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -169,13 +171,29 @@ type CheckMixedChannelRequest struct {
|
||||
// AccountWithConcurrency extends Account with real-time concurrency info
|
||||
type AccountWithConcurrency struct {
|
||||
*dto.Account
|
||||
CurrentConcurrency int `json:"current_concurrency"`
|
||||
CurrentConcurrency int `json:"current_concurrency"`
|
||||
SchedulerScore *AccountSchedulerScore `json:"scheduler_score,omitempty"`
|
||||
SchedulerScores []AccountSchedulerGroupScore `json:"scheduler_scores,omitempty"`
|
||||
// 以下字段仅对 Anthropic OAuth/SetupToken 账号有效,且仅在启用相应功能时返回
|
||||
CurrentWindowCost *float64 `json:"current_window_cost,omitempty"` // 当前窗口费用
|
||||
ActiveSessions *int `json:"active_sessions,omitempty"` // 当前活跃会话数
|
||||
CurrentRPM *int `json:"current_rpm,omitempty"` // 当前分钟 RPM 计数
|
||||
}
|
||||
|
||||
type AccountSchedulerScore struct {
|
||||
BaseScore float64 `json:"base_score"`
|
||||
StickyScore float64 `json:"sticky_score"`
|
||||
StickyScoreInfinity bool `json:"sticky_score_infinity"`
|
||||
StickyWeightedEnabled bool `json:"sticky_weighted_enabled"`
|
||||
}
|
||||
|
||||
type AccountSchedulerGroupScore struct {
|
||||
GroupID *int64 `json:"group_id"`
|
||||
GroupName string `json:"group_name,omitempty"`
|
||||
GroupPriority *int `json:"group_priority,omitempty"`
|
||||
AccountSchedulerScore
|
||||
}
|
||||
|
||||
const accountListGroupUngroupedQueryValue = "ungrouped"
|
||||
|
||||
func (h *AccountHandler) buildAccountResponseWithRuntime(ctx context.Context, account *service.Account) AccountWithConcurrency {
|
||||
@@ -219,9 +237,237 @@ func (h *AccountHandler) buildAccountResponseWithRuntime(ctx context.Context, ac
|
||||
}
|
||||
}
|
||||
|
||||
h.enrichShadowParents(ctx, []AccountWithConcurrency{item})
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
// scoreOpenAIAccountSchedulerPool 对池内 OpenAI 账号计算调度分数快照。
|
||||
// loadMap 为共享的账号负载数据(含池内全部账号即可,多余条目无害);传 nil 时自行批查。
|
||||
func (h *AccountHandler) scoreOpenAIAccountSchedulerPool(ctx context.Context, accounts []service.Account, loadMap map[int64]*service.AccountLoadInfo) map[int64]AccountSchedulerScore {
|
||||
if len(accounts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
openAIAccounts := make([]*service.Account, 0, len(accounts))
|
||||
for i := range accounts {
|
||||
account := &accounts[i]
|
||||
if account.Platform != service.PlatformOpenAI {
|
||||
continue
|
||||
}
|
||||
openAIAccounts = append(openAIAccounts, account)
|
||||
}
|
||||
if len(openAIAccounts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if loadMap == nil {
|
||||
loadMap = h.fetchOpenAIAccountLoadMap(ctx, openAIAccounts)
|
||||
}
|
||||
|
||||
var scores map[int64]service.OpenAIAccountSchedulerScoreSnapshot
|
||||
if h.rateLimitService != nil {
|
||||
scores = h.rateLimitService.BuildOpenAIAccountSchedulerScoreSnapshot(ctx, openAIAccounts, loadMap)
|
||||
} else {
|
||||
scores = service.BuildOpenAIAccountSchedulerScoreSnapshot(openAIAccounts, loadMap)
|
||||
}
|
||||
result := make(map[int64]AccountSchedulerScore, len(scores))
|
||||
for accountID, score := range scores {
|
||||
result[accountID] = AccountSchedulerScore{
|
||||
BaseScore: score.BaseScore,
|
||||
StickyScore: score.StickyScore,
|
||||
StickyScoreInfinity: score.StickyScoreInfinity,
|
||||
StickyWeightedEnabled: score.StickyWeightedEnabled,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// fetchOpenAIAccountLoadMap 一次性批查给定 OpenAI 账号的负载数据;
|
||||
// 失败时记录日志并返回空表(分数按零负载计算,属可接受降级)。
|
||||
func (h *AccountHandler) fetchOpenAIAccountLoadMap(ctx context.Context, openAIAccounts []*service.Account) map[int64]*service.AccountLoadInfo {
|
||||
loadMap := map[int64]*service.AccountLoadInfo{}
|
||||
if h.concurrencyService == nil || len(openAIAccounts) == 0 {
|
||||
return loadMap
|
||||
}
|
||||
seen := make(map[int64]struct{}, len(openAIAccounts))
|
||||
loadReq := make([]service.AccountWithConcurrency, 0, len(openAIAccounts))
|
||||
for _, account := range openAIAccounts {
|
||||
if account == nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[account.ID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[account.ID] = struct{}{}
|
||||
loadReq = append(loadReq, service.AccountWithConcurrency{
|
||||
ID: account.ID,
|
||||
MaxConcurrency: account.EffectiveLoadFactor(),
|
||||
})
|
||||
}
|
||||
if batchLoad, err := h.concurrencyService.GetAccountsLoadBatch(ctx, loadReq); err != nil {
|
||||
slog.Warn("openai_scheduler_score_load_batch_failed", "error", err)
|
||||
} else if batchLoad != nil {
|
||||
loadMap = batchLoad
|
||||
}
|
||||
return loadMap
|
||||
}
|
||||
|
||||
func (h *AccountHandler) buildOpenAIAccountSchedulerScores(
|
||||
ctx context.Context,
|
||||
accounts []service.Account,
|
||||
filterPool []service.Account,
|
||||
) (map[int64]*AccountSchedulerScore, map[int64][]AccountSchedulerGroupScore) {
|
||||
if len(accounts) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if len(filterPool) == 0 {
|
||||
filterPool = accounts
|
||||
}
|
||||
|
||||
pageOpenAIAccountIDs := make(map[int64]struct{})
|
||||
groupIDs := make(map[int64]struct{})
|
||||
for i := range accounts {
|
||||
account := &accounts[i]
|
||||
if account.Platform != service.PlatformOpenAI {
|
||||
continue
|
||||
}
|
||||
pageOpenAIAccountIDs[account.ID] = struct{}{}
|
||||
if len(account.AccountGroups) == 0 && len(account.GroupIDs) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, accountGroup := range account.AccountGroups {
|
||||
if accountGroup.GroupID > 0 {
|
||||
groupIDs[accountGroup.GroupID] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, groupID := range account.GroupIDs {
|
||||
if groupID > 0 {
|
||||
groupIDs[groupID] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(pageOpenAIAccountIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// 先取各分组池,再对"过滤池 ∪ 分组池"的账号并集做一次负载批查,
|
||||
// 避免每个池各查一次 Redis 的 N+1。
|
||||
groupIDList := make([]int64, 0, len(groupIDs))
|
||||
for groupID := range groupIDs {
|
||||
groupIDList = append(groupIDList, groupID)
|
||||
}
|
||||
sort.Slice(groupIDList, func(i, j int) bool { return groupIDList[i] < groupIDList[j] })
|
||||
|
||||
groupPools := make(map[int64][]service.Account, len(groupIDList))
|
||||
if h.adminService != nil {
|
||||
for _, groupID := range groupIDList {
|
||||
gid := groupID
|
||||
pool, err := h.adminService.ListOpenAISchedulableAccountsForSchedulerScore(ctx, &gid)
|
||||
if err != nil {
|
||||
slog.Warn("openai_scheduler_group_score_pool_failed", "group_id", gid, "error", err)
|
||||
continue
|
||||
}
|
||||
groupPools[gid] = pool
|
||||
}
|
||||
}
|
||||
|
||||
loadUnion := make([]*service.Account, 0, len(filterPool))
|
||||
collectOpenAIAccounts := func(pool []service.Account) {
|
||||
for i := range pool {
|
||||
if pool[i].Platform == service.PlatformOpenAI {
|
||||
loadUnion = append(loadUnion, &pool[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
collectOpenAIAccounts(filterPool)
|
||||
for _, pool := range groupPools {
|
||||
collectOpenAIAccounts(pool)
|
||||
}
|
||||
loadMap := h.fetchOpenAIAccountLoadMap(ctx, loadUnion)
|
||||
|
||||
baseScores := make(map[int64]*AccountSchedulerScore)
|
||||
for accountID, score := range h.scoreOpenAIAccountSchedulerPool(ctx, filterPool, loadMap) {
|
||||
copiedScore := score
|
||||
baseScores[accountID] = &copiedScore
|
||||
}
|
||||
|
||||
groupScoresByAccount := make(map[int64][]AccountSchedulerGroupScore)
|
||||
scoreGroupPool := func(groupID *int64, groupNameByID map[int64]string, groupPriorityByAccount map[int64]int, pool []service.Account) {
|
||||
if len(pool) == 0 {
|
||||
return
|
||||
}
|
||||
scores := h.scoreOpenAIAccountSchedulerPool(ctx, pool, loadMap)
|
||||
for accountID, schedulerScore := range scores {
|
||||
if _, ok := pageOpenAIAccountIDs[accountID]; !ok {
|
||||
continue
|
||||
}
|
||||
groupScore := AccountSchedulerGroupScore{
|
||||
GroupID: groupID,
|
||||
AccountSchedulerScore: schedulerScore,
|
||||
}
|
||||
if groupID != nil {
|
||||
groupScore.GroupName = groupNameByID[*groupID]
|
||||
if priority, ok := groupPriorityByAccount[accountID]; ok {
|
||||
groupScore.GroupPriority = &priority
|
||||
}
|
||||
}
|
||||
groupScoresByAccount[accountID] = append(groupScoresByAccount[accountID], groupScore)
|
||||
}
|
||||
}
|
||||
|
||||
for _, groupID := range groupIDList {
|
||||
gid := groupID
|
||||
pool, ok := groupPools[gid]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
groupNameByID := make(map[int64]string)
|
||||
groupPriorityByAccount := make(map[int64]int)
|
||||
for i := range pool {
|
||||
account := &pool[i]
|
||||
for _, accountGroup := range account.AccountGroups {
|
||||
if accountGroup.GroupID != gid {
|
||||
continue
|
||||
}
|
||||
groupPriorityByAccount[account.ID] = accountGroup.Priority
|
||||
if accountGroup.Group != nil {
|
||||
groupNameByID[gid] = accountGroup.Group.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
scoreGroupPool(&gid, groupNameByID, groupPriorityByAccount, pool)
|
||||
}
|
||||
|
||||
for accountID := range groupScoresByAccount {
|
||||
sort.SliceStable(groupScoresByAccount[accountID], func(i, j int) bool {
|
||||
left := groupScoresByAccount[accountID][i]
|
||||
right := groupScoresByAccount[accountID][j]
|
||||
return *left.GroupID < *right.GroupID
|
||||
})
|
||||
}
|
||||
return baseScores, groupScoresByAccount
|
||||
}
|
||||
|
||||
func (h *AccountHandler) listAccountSchedulerScoreFilterPool(
|
||||
ctx context.Context,
|
||||
platform, accountType, status, search string,
|
||||
groupID int64,
|
||||
privacyMode string,
|
||||
) []service.Account {
|
||||
if h.adminService == nil || (platform != "" && platform != service.PlatformOpenAI) {
|
||||
return nil
|
||||
}
|
||||
// 池只用于 OpenAI 分数计算(非 OpenAI 账号会在打分时被丢弃),
|
||||
// 无论列表页平台过滤为何,查询一律限定 openai,避免无过滤时全表扫描。
|
||||
accounts, err := h.adminService.ListAccountsForSchedulerScoreFilter(ctx, service.PlatformOpenAI, accountType, status, search, groupID, privacyMode)
|
||||
if err != nil {
|
||||
slog.Warn("openai_scheduler_filter_score_pool_failed", "error", err)
|
||||
return nil
|
||||
}
|
||||
return accounts
|
||||
}
|
||||
|
||||
// List handles listing all accounts with pagination
|
||||
// GET /api/v1/admin/accounts
|
||||
func (h *AccountHandler) List(c *gin.Context) {
|
||||
@@ -274,6 +520,20 @@ func (h *AccountHandler) List(c *gin.Context) {
|
||||
var windowCosts map[int64]float64
|
||||
var activeSessions map[int64]int
|
||||
var rpmCounts map[int64]int
|
||||
// 仅当前页存在 OpenAI 账号时才计算调度分数,避免为空结果付出池查询开销。
|
||||
var schedulerScores map[int64]*AccountSchedulerScore
|
||||
var schedulerGroupScores map[int64][]AccountSchedulerGroupScore
|
||||
pageHasOpenAIAccounts := false
|
||||
for i := range accounts {
|
||||
if accounts[i].Platform == service.PlatformOpenAI {
|
||||
pageHasOpenAIAccounts = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if pageHasOpenAIAccounts {
|
||||
schedulerFilterPool := h.listAccountSchedulerScoreFilterPool(c.Request.Context(), platform, accountType, status, search, groupID, privacyMode)
|
||||
schedulerScores, schedulerGroupScores = h.buildOpenAIAccountSchedulerScores(c.Request.Context(), accounts, schedulerFilterPool)
|
||||
}
|
||||
|
||||
// 始终获取并发数(Redis ZCARD,极低开销)
|
||||
if h.concurrencyService != nil {
|
||||
@@ -354,6 +614,8 @@ func (h *AccountHandler) List(c *gin.Context) {
|
||||
item := AccountWithConcurrency{
|
||||
Account: dto.AccountFromService(acc),
|
||||
CurrentConcurrency: concurrencyCounts[acc.ID],
|
||||
SchedulerScore: schedulerScores[acc.ID],
|
||||
SchedulerScores: schedulerGroupScores[acc.ID],
|
||||
}
|
||||
|
||||
// 添加窗口费用(仅当启用时)
|
||||
@@ -380,6 +642,8 @@ func (h *AccountHandler) List(c *gin.Context) {
|
||||
result[i] = item
|
||||
}
|
||||
|
||||
h.enrichShadowParents(c.Request.Context(), result)
|
||||
|
||||
etag := buildAccountsListETag(result, total, page, pageSize, platform, accountType, status, search, lite)
|
||||
if etag != "" {
|
||||
c.Header("ETag", etag)
|
||||
@@ -832,6 +1096,12 @@ func (h *AccountHandler) refreshSingleAccount(ctx context.Context, account *serv
|
||||
if !account.IsOAuth() {
|
||||
return nil, "", infraerrors.BadRequest("NOT_OAUTH", "cannot refresh non-OAuth account")
|
||||
}
|
||||
// spark 影子凭据由母账号管理、自身恒空,刷新无意义且会先打上游;在调用上游前早拒
|
||||
// (覆盖单账号与批量两入口;批量侧将其计为 failed 并附说明)(外审第6轮)。
|
||||
if account.IsCredentialShadow() {
|
||||
return nil, "", infraerrors.BadRequest("SPARK_SHADOW_NO_REFRESH",
|
||||
"cannot refresh spark shadow account; its credentials are managed by the parent account")
|
||||
}
|
||||
|
||||
var newCredentials map[string]any
|
||||
|
||||
@@ -849,6 +1119,7 @@ func (h *AccountHandler) refreshSingleAccount(ctx context.Context, account *serv
|
||||
newCredentials[k] = v
|
||||
}
|
||||
}
|
||||
newCredentials = service.NormalizeOpenAIPersonalAccessTokenCredentials(account, tokenInfo, newCredentials)
|
||||
} else if account.Platform == service.PlatformGemini {
|
||||
tokenInfo, err := h.geminiOAuthService.RefreshAccountToken(ctx, account)
|
||||
if err != nil {
|
||||
@@ -1811,7 +2082,7 @@ func (h *AccountHandler) ResetQuota(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := h.adminService.ResetAccountQuota(c.Request.Context(), accountID); err != nil {
|
||||
response.InternalError(c, "Failed to reset account quota: "+err.Error())
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2063,6 +2334,56 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Handle Grok accounts
|
||||
if account.Platform == service.PlatformGrok {
|
||||
defaultModels := xai.DefaultModels()
|
||||
|
||||
hasExplicitMapping := false
|
||||
switch rawMapping := account.Credentials["model_mapping"].(type) {
|
||||
case map[string]any:
|
||||
hasExplicitMapping = len(rawMapping) > 0
|
||||
case map[string]string:
|
||||
hasExplicitMapping = len(rawMapping) > 0
|
||||
}
|
||||
if !hasExplicitMapping {
|
||||
response.Success(c, defaultModels)
|
||||
return
|
||||
}
|
||||
|
||||
mapping := account.GetModelMapping()
|
||||
if len(mapping) == 0 {
|
||||
response.Success(c, defaultModels)
|
||||
return
|
||||
}
|
||||
|
||||
defaultByID := make(map[string]xai.Model, len(defaultModels))
|
||||
for _, model := range defaultModels {
|
||||
defaultByID[model.ID] = model
|
||||
}
|
||||
|
||||
requestedModels := make([]string, 0, len(mapping))
|
||||
for requestedModel := range mapping {
|
||||
requestedModels = append(requestedModels, requestedModel)
|
||||
}
|
||||
sort.Strings(requestedModels)
|
||||
|
||||
var models []xai.Model
|
||||
for _, requestedModel := range requestedModels {
|
||||
if defaultModel, found := defaultByID[requestedModel]; found {
|
||||
models = append(models, defaultModel)
|
||||
continue
|
||||
}
|
||||
models = append(models, xai.Model{
|
||||
ID: requestedModel,
|
||||
Object: "model",
|
||||
OwnedBy: "xai",
|
||||
DisplayName: requestedModel,
|
||||
})
|
||||
}
|
||||
response.Success(c, models)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle Claude/Anthropic accounts
|
||||
// For OAuth and Setup-Token accounts: return default models
|
||||
if account.IsOAuth() {
|
||||
|
||||
@@ -61,6 +61,7 @@ func setupSyncUpstreamModelsRouter(adminSvc service.AdminService, upstream servi
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
upstream,
|
||||
&config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
|
||||
nil,
|
||||
@@ -70,6 +71,77 @@ func setupSyncUpstreamModelsRouter(adminSvc service.AdminService, upstream servi
|
||||
return router
|
||||
}
|
||||
|
||||
func TestAccountHandlerGetAvailableModels_GrokUsesXAIModels(t *testing.T) {
|
||||
svc := &availableModelsAdminService{
|
||||
stubAdminService: newStubAdminService(),
|
||||
account: service.Account{
|
||||
ID: 44,
|
||||
Name: "grok-oauth",
|
||||
Platform: service.PlatformGrok,
|
||||
Type: service.AccountTypeOAuth,
|
||||
Status: service.StatusActive,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{
|
||||
"grok-4.3": "grok-4.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
router := setupAvailableModelsRouter(svc)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/44/models", nil)
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var resp struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
require.Len(t, resp.Data, 1)
|
||||
require.Equal(t, "grok-4.3", resp.Data[0].ID)
|
||||
}
|
||||
|
||||
func TestAccountHandlerGetAvailableModels_GrokDefaultsToXAIModelsWithoutMapping(t *testing.T) {
|
||||
svc := &availableModelsAdminService{
|
||||
stubAdminService: newStubAdminService(),
|
||||
account: service.Account{
|
||||
ID: 45,
|
||||
Name: "grok-oauth-defaults",
|
||||
Platform: service.PlatformGrok,
|
||||
Type: service.AccountTypeOAuth,
|
||||
Status: service.StatusActive,
|
||||
},
|
||||
}
|
||||
router := setupAvailableModelsRouter(svc)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/45/models", nil)
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var resp struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
require.NotEmpty(t, resp.Data)
|
||||
|
||||
var ids []string
|
||||
for _, model := range resp.Data {
|
||||
id := model.ID
|
||||
ids = append(ids, id)
|
||||
require.NotContains(t, strings.ToLower(id), "claude")
|
||||
}
|
||||
require.Contains(t, ids, "grok-4.3")
|
||||
require.Contains(t, ids, "grok-build-0.1")
|
||||
}
|
||||
|
||||
func TestAccountHandlerGetAvailableModels_OpenAIOAuthUsesExplicitModelMapping(t *testing.T) {
|
||||
svc := &availableModelsAdminService{
|
||||
stubAdminService: newStubAdminService(),
|
||||
@@ -141,6 +213,48 @@ func TestAccountHandlerGetAvailableModels_OpenAIOAuthPassthroughFallsBackToDefau
|
||||
require.NotEqual(t, "gpt-5", resp.Data[0].ID)
|
||||
}
|
||||
|
||||
func TestAccountHandlerGetAvailableModels_OpenAISparkShadowReturnsMappingModels(t *testing.T) {
|
||||
parentID := int64(100)
|
||||
svc := &availableModelsAdminService{
|
||||
stubAdminService: newStubAdminService(),
|
||||
account: service.Account{
|
||||
ID: 44,
|
||||
Name: "openai-spark-shadow",
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeOAuth,
|
||||
Status: service.StatusActive,
|
||||
ParentAccountID: &parentID,
|
||||
QuotaDimension: service.QuotaDimensionSpark,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{
|
||||
"gpt-5.3-codex-spark": "gpt-5.3-codex-spark",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
router := setupAvailableModelsRouter(svc)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/44/models", nil)
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var resp struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
ids := make([]string, 0, len(resp.Data))
|
||||
for _, m := range resp.Data {
|
||||
ids = append(ids, m.ID)
|
||||
}
|
||||
require.ElementsMatch(t, []string{
|
||||
"gpt-5.3-codex-spark",
|
||||
}, ids, "影子可用模型由 model_mapping 派生(非写死)")
|
||||
}
|
||||
|
||||
func TestAccountHandlerSyncUpstreamModels_ConfigErrorReturnsBadRequest(t *testing.T) {
|
||||
svc := &availableModelsAdminService{
|
||||
stubAdminService: newStubAdminService(),
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -50,3 +51,222 @@ func TestAccountHandlerListIncludesCreatedAt(t *testing.T) {
|
||||
_, offset := parsed.Zone()
|
||||
require.Equal(t, 0, offset)
|
||||
}
|
||||
|
||||
func TestAccountHandlerListReturnsSchedulerScoresPerGroup(t *testing.T) {
|
||||
router, adminSvc := setupAccountListRouter()
|
||||
now := time.Now().UTC()
|
||||
groupID := int64(41)
|
||||
adminSvc.accounts = []service.Account{
|
||||
{
|
||||
ID: 101,
|
||||
Name: "account-high-priority",
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 10,
|
||||
Priority: 1,
|
||||
AccountGroups: []service.AccountGroup{
|
||||
{AccountID: 101, GroupID: groupID, Priority: 100, Group: &service.Group{ID: groupID, Name: "openai"}},
|
||||
},
|
||||
GroupIDs: []int64{groupID},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
{
|
||||
ID: 102,
|
||||
Name: "account-low-priority",
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 10,
|
||||
Priority: 100000,
|
||||
AccountGroups: []service.AccountGroup{
|
||||
{AccountID: 102, GroupID: groupID, Priority: 1, Group: &service.Group{ID: groupID, Name: "openai"}},
|
||||
},
|
||||
GroupIDs: []int64{groupID},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20&platform=openai", nil)
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var payload struct {
|
||||
Data struct {
|
||||
Items []struct {
|
||||
ID int64 `json:"id"`
|
||||
SchedulerScore struct {
|
||||
BaseScore float64 `json:"base_score"`
|
||||
} `json:"scheduler_score"`
|
||||
SchedulerScores []struct {
|
||||
GroupID *int64 `json:"group_id"`
|
||||
GroupName string `json:"group_name"`
|
||||
GroupPriority *int `json:"group_priority"`
|
||||
BaseScore float64 `json:"base_score"`
|
||||
} `json:"scheduler_scores"`
|
||||
} `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
|
||||
require.Len(t, payload.Data.Items, 2)
|
||||
|
||||
var high, low *struct {
|
||||
ID int64 `json:"id"`
|
||||
SchedulerScore struct {
|
||||
BaseScore float64 `json:"base_score"`
|
||||
} `json:"scheduler_score"`
|
||||
SchedulerScores []struct {
|
||||
GroupID *int64 `json:"group_id"`
|
||||
GroupName string `json:"group_name"`
|
||||
GroupPriority *int `json:"group_priority"`
|
||||
BaseScore float64 `json:"base_score"`
|
||||
} `json:"scheduler_scores"`
|
||||
}
|
||||
for i := range payload.Data.Items {
|
||||
item := &payload.Data.Items[i]
|
||||
switch item.ID {
|
||||
case 101:
|
||||
high = item
|
||||
case 102:
|
||||
low = item
|
||||
}
|
||||
}
|
||||
require.NotNil(t, high)
|
||||
require.NotNil(t, low)
|
||||
require.Len(t, high.SchedulerScores, 1)
|
||||
require.Len(t, low.SchedulerScores, 1)
|
||||
require.Equal(t, groupID, *high.SchedulerScores[0].GroupID)
|
||||
require.Equal(t, "openai", high.SchedulerScores[0].GroupName)
|
||||
require.Equal(t, 100, *high.SchedulerScores[0].GroupPriority)
|
||||
require.Equal(t, 1, *low.SchedulerScores[0].GroupPriority)
|
||||
require.Greater(t, high.SchedulerScores[0].BaseScore, low.SchedulerScores[0].BaseScore)
|
||||
}
|
||||
|
||||
func TestAccountHandlerListKeepsSchedulerScoreScopedToFilter(t *testing.T) {
|
||||
router, adminSvc := setupAccountListRouter()
|
||||
now := time.Now().UTC()
|
||||
groupID := int64(42)
|
||||
visibleAccount := service.Account{
|
||||
ID: 201,
|
||||
Name: "visible-low-priority",
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 10,
|
||||
Priority: 100000,
|
||||
AccountGroups: []service.AccountGroup{
|
||||
{AccountID: 201, GroupID: groupID, Priority: 1, Group: &service.Group{ID: groupID, Name: "openai"}},
|
||||
},
|
||||
GroupIDs: []int64{groupID},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
hiddenGroupPeer := service.Account{
|
||||
ID: 202,
|
||||
Name: "hidden-high-priority",
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 10,
|
||||
Priority: 1,
|
||||
AccountGroups: []service.AccountGroup{
|
||||
{AccountID: 202, GroupID: groupID, Priority: 2, Group: &service.Group{ID: groupID, Name: "openai"}},
|
||||
},
|
||||
GroupIDs: []int64{groupID},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
adminSvc.accounts = []service.Account{visibleAccount}
|
||||
adminSvc.accountSchedulerScoreFilterAccounts = []service.Account{visibleAccount, hiddenGroupPeer}
|
||||
adminSvc.openAISchedulerScorePoolAccounts = []service.Account{visibleAccount, hiddenGroupPeer}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=1&platform=openai", nil)
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var payload struct {
|
||||
Data struct {
|
||||
Items []struct {
|
||||
ID int64 `json:"id"`
|
||||
SchedulerScore struct {
|
||||
BaseScore float64 `json:"base_score"`
|
||||
} `json:"scheduler_score"`
|
||||
SchedulerScores []struct {
|
||||
GroupID *int64 `json:"group_id"`
|
||||
BaseScore float64 `json:"base_score"`
|
||||
} `json:"scheduler_scores"`
|
||||
} `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
|
||||
require.Len(t, payload.Data.Items, 1)
|
||||
item := payload.Data.Items[0]
|
||||
require.Equal(t, int64(201), item.ID)
|
||||
require.Len(t, item.SchedulerScores, 1)
|
||||
require.Equal(t, groupID, *item.SchedulerScores[0].GroupID)
|
||||
require.Equal(t, item.SchedulerScores[0].BaseScore, item.SchedulerScore.BaseScore)
|
||||
}
|
||||
|
||||
func TestAccountHandlerListSchedulerScoreIgnoresPagination(t *testing.T) {
|
||||
router, adminSvc := setupAccountListRouter()
|
||||
now := time.Now().UTC()
|
||||
visibleAccount := service.Account{
|
||||
ID: 301,
|
||||
Name: "visible-low-priority",
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 10,
|
||||
Priority: 100000,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
hiddenFilterPeer := service.Account{
|
||||
ID: 302,
|
||||
Name: "hidden-high-priority",
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 10,
|
||||
Priority: 1,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
adminSvc.accounts = []service.Account{visibleAccount}
|
||||
adminSvc.accountSchedulerScoreFilterAccounts = []service.Account{visibleAccount, hiddenFilterPeer}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=1&platform=openai", nil)
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var payload struct {
|
||||
Data struct {
|
||||
Items []struct {
|
||||
ID int64 `json:"id"`
|
||||
SchedulerScore struct {
|
||||
BaseScore float64 `json:"base_score"`
|
||||
} `json:"scheduler_score"`
|
||||
SchedulerScores []struct {
|
||||
GroupID *int64 `json:"group_id"`
|
||||
BaseScore float64 `json:"base_score"`
|
||||
} `json:"scheduler_scores"`
|
||||
} `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
|
||||
require.Len(t, payload.Data.Items, 1)
|
||||
require.Equal(t, int64(301), payload.Data.Items[0].ID)
|
||||
require.Less(t, payload.Data.Items[0].SchedulerScore.BaseScore, 3.75)
|
||||
require.Empty(t, payload.Data.Items[0].SchedulerScores)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestRefreshSingleAccount_RejectsShadow 验证外审第6轮:手动刷新对 spark 影子在调用上游前早拒
|
||||
// (影子凭据由母账号管理、自身恒空,刷新无意义)。该守卫同时覆盖单账号与批量刷新两入口。
|
||||
func TestRefreshSingleAccount_RejectsShadow(t *testing.T) {
|
||||
h := &AccountHandler{} // 影子在使用任何依赖前即返回,无需注入
|
||||
parentID := int64(5)
|
||||
shadow := &service.Account{
|
||||
ID: 9,
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeOAuth, // IsOAuth()=true,确保不是先撞 NOT_OAUTH
|
||||
ParentAccountID: &parentID,
|
||||
QuotaDimension: service.QuotaDimensionSpark,
|
||||
}
|
||||
|
||||
_, _, err := h.refreshSingleAccount(context.Background(), shadow)
|
||||
require.Error(t, err, "影子刷新应被早拒")
|
||||
require.Equal(t, http.StatusBadRequest, infraerrors.Code(err))
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
// enrichShadowParentInfo 把母账号的展示信息回填到影子行的 parent_* 字段。
|
||||
// 纯函数:仅依赖传入的母账号 map,便于单测;非影子或母账号缺失时优雅留空。
|
||||
func enrichShadowParentInfo(items []AccountWithConcurrency, parents map[int64]*service.Account) {
|
||||
for i := range items {
|
||||
a := items[i].Account
|
||||
if a == nil || a.ParentAccountID == nil {
|
||||
continue
|
||||
}
|
||||
p := parents[*a.ParentAccountID]
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
a.ParentEmail = p.GetCredential("email")
|
||||
a.ParentPlanType = p.GetCredential("plan_type")
|
||||
a.ParentSubscriptionExpiresAt = p.GetCredential("subscription_expires_at")
|
||||
a.ParentChatGPTAccountID = p.GetCredential("chatgpt_account_id")
|
||||
a.ParentPrivacyMode = p.GetExtraString("privacy_mode")
|
||||
}
|
||||
}
|
||||
|
||||
// enrichShadowParents 收集本批影子行的母账号 ID、一次批量解析(避免 N+1),再回填。
|
||||
// 解析失败时不报错(parent_* 留空,降级)。
|
||||
func (h *AccountHandler) enrichShadowParents(ctx context.Context, items []AccountWithConcurrency) {
|
||||
seen := make(map[int64]struct{})
|
||||
for i := range items {
|
||||
a := items[i].Account
|
||||
if a == nil || a.ParentAccountID == nil {
|
||||
continue
|
||||
}
|
||||
seen[*a.ParentAccountID] = struct{}{}
|
||||
}
|
||||
if len(seen) == 0 {
|
||||
return
|
||||
}
|
||||
parentIDs := make([]int64, 0, len(seen))
|
||||
for pid := range seen {
|
||||
parentIDs = append(parentIDs, pid)
|
||||
}
|
||||
parents, err := h.adminService.GetAccountsByIDs(ctx, parentIDs)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
pmap := make(map[int64]*service.Account, len(parents))
|
||||
for _, p := range parents {
|
||||
pmap[p.ID] = p
|
||||
}
|
||||
enrichShadowParentInfo(items, pmap)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEnrichShadowParentInfo(t *testing.T) {
|
||||
pid := int64(100)
|
||||
parent := &service.Account{
|
||||
ID: 100,
|
||||
Credentials: map[string]any{
|
||||
"email": "owner@example.com",
|
||||
"plan_type": "pro",
|
||||
"subscription_expires_at": "2026-12-31T00:00:00Z",
|
||||
"chatgpt_account_id": "acct_123",
|
||||
},
|
||||
Extra: map[string]any{"privacy_mode": "training_off"},
|
||||
}
|
||||
parents := map[int64]*service.Account{100: parent}
|
||||
|
||||
shadow := AccountWithConcurrency{Account: &dto.Account{ID: 200, ParentAccountID: &pid}}
|
||||
normal := AccountWithConcurrency{Account: &dto.Account{ID: 1}}
|
||||
orphan := AccountWithConcurrency{Account: &dto.Account{ID: 201, ParentAccountID: ptrInt64(999)}}
|
||||
items := []AccountWithConcurrency{shadow, normal, orphan}
|
||||
|
||||
enrichShadowParentInfo(items, parents)
|
||||
|
||||
require.Equal(t, "owner@example.com", items[0].ParentEmail, "影子回填母账号邮箱")
|
||||
require.Equal(t, "pro", items[0].ParentPlanType)
|
||||
require.Equal(t, "training_off", items[0].ParentPrivacyMode)
|
||||
require.Equal(t, "2026-12-31T00:00:00Z", items[0].ParentSubscriptionExpiresAt)
|
||||
require.Equal(t, "acct_123", items[0].ParentChatGPTAccountID)
|
||||
|
||||
require.Empty(t, items[1].ParentEmail, "非影子不回填")
|
||||
require.Empty(t, items[2].ParentEmail, "母账号缺失时优雅留空")
|
||||
}
|
||||
|
||||
func ptrInt64(v int64) *int64 { return &v }
|
||||
@@ -10,26 +10,29 @@ import (
|
||||
)
|
||||
|
||||
type stubAdminService struct {
|
||||
users []service.User
|
||||
apiKeys []service.APIKey
|
||||
groups []service.Group
|
||||
accounts []service.Account
|
||||
proxies []service.Proxy
|
||||
proxyCounts []service.ProxyWithAccountCount
|
||||
redeems []service.RedeemCode
|
||||
boundAuthIdentity *service.AdminBindAuthIdentityInput
|
||||
boundAuthIdentityFor int64
|
||||
createdAccounts []*service.CreateAccountInput
|
||||
createdProxies []*service.CreateProxyInput
|
||||
updatedProxyIDs []int64
|
||||
updatedProxies []*service.UpdateProxyInput
|
||||
testedProxyIDs []int64
|
||||
getUserErr error
|
||||
createAccountErr error
|
||||
updateAccountErr error
|
||||
bulkUpdateAccountErr error
|
||||
checkMixedErr error
|
||||
lastMixedCheck struct {
|
||||
users []service.User
|
||||
apiKeys []service.APIKey
|
||||
groups []service.Group
|
||||
accounts []service.Account
|
||||
accountSchedulerScoreFilterAccounts []service.Account
|
||||
openAISchedulerScorePoolAccounts []service.Account
|
||||
proxies []service.Proxy
|
||||
proxyCounts []service.ProxyWithAccountCount
|
||||
redeems []service.RedeemCode
|
||||
boundAuthIdentity *service.AdminBindAuthIdentityInput
|
||||
boundAuthIdentityFor int64
|
||||
createdAccounts []*service.CreateAccountInput
|
||||
createdProxies []*service.CreateProxyInput
|
||||
updatedProxyIDs []int64
|
||||
updatedProxies []*service.UpdateProxyInput
|
||||
testedProxyIDs []int64
|
||||
getUserErr error
|
||||
createAccountErr error
|
||||
createSparkShadowErr error
|
||||
updateAccountErr error
|
||||
bulkUpdateAccountErr error
|
||||
checkMixedErr error
|
||||
lastMixedCheck struct {
|
||||
accountID int64
|
||||
platform string
|
||||
groupIDs []int64
|
||||
@@ -328,7 +331,56 @@ func (s *stubAdminService) ListAccounts(ctx context.Context, page, pageSize int,
|
||||
s.lastListAccounts.sortBy = sortBy
|
||||
s.lastListAccounts.sortOrder = sortOrder
|
||||
s.lastListAccounts.calls++
|
||||
return s.accounts, int64(len(s.accounts)), nil
|
||||
accounts := s.accounts
|
||||
total := len(accounts)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = total
|
||||
}
|
||||
start := (page - 1) * pageSize
|
||||
if start >= total {
|
||||
return []service.Account{}, int64(total), nil
|
||||
}
|
||||
end := start + pageSize
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
return accounts[start:end], int64(total), nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) ListAccountsForSchedulerScoreFilter(_ context.Context, platform, accountType, status, search string, groupID int64, privacyMode string) ([]service.Account, error) {
|
||||
if s.accountSchedulerScoreFilterAccounts != nil {
|
||||
return s.accountSchedulerScoreFilterAccounts, nil
|
||||
}
|
||||
return s.accounts, nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) ListOpenAISchedulableAccountsForSchedulerScore(_ context.Context, groupID *int64) ([]service.Account, error) {
|
||||
accounts := s.openAISchedulerScorePoolAccounts
|
||||
if accounts == nil {
|
||||
accounts = s.accounts
|
||||
}
|
||||
out := make([]service.Account, 0, len(accounts))
|
||||
for _, account := range accounts {
|
||||
if account.Platform != service.PlatformOpenAI || !account.IsSchedulable() {
|
||||
continue
|
||||
}
|
||||
if groupID == nil {
|
||||
if len(account.AccountGroups) == 0 && len(account.GroupIDs) == 0 {
|
||||
out = append(out, account)
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, accountGroup := range account.AccountGroups {
|
||||
if accountGroup.GroupID == *groupID {
|
||||
out = append(out, account)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) GetAccount(ctx context.Context, id int64) (*service.Account, error) {
|
||||
@@ -636,5 +688,25 @@ func (s *stubAdminService) RevertAccountProxyFallback(ctx context.Context, id in
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) CreateShadow(ctx context.Context, parentID int64, opts service.ShadowOptions) (*service.Account, error) {
|
||||
if s.createSparkShadowErr != nil {
|
||||
return nil, s.createSparkShadowErr
|
||||
}
|
||||
pid := parentID
|
||||
return &service.Account{
|
||||
ID: 9001,
|
||||
Name: opts.Name,
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeOAuth,
|
||||
Priority: opts.Priority,
|
||||
Concurrency: opts.Concurrency,
|
||||
GroupIDs: opts.GroupIDs,
|
||||
ParentAccountID: &pid,
|
||||
QuotaDimension: service.QuotaDimensionSpark,
|
||||
Credentials: map[string]any{},
|
||||
Extra: map[string]any{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Ensure stub implements interface.
|
||||
var _ service.AdminService = (*stubAdminService)(nil)
|
||||
|
||||
@@ -509,6 +509,7 @@ var platformToLiteLLMProvider = map[string]string{
|
||||
service.PlatformOpenAI: "openai",
|
||||
service.PlatformGemini: "google",
|
||||
service.PlatformAntigravity: "anthropic",
|
||||
service.PlatformGrok: "xai",
|
||||
}
|
||||
|
||||
// SyncPricingModels 返回 LiteLLM 定价目录中指定平台的最新模型列表
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type GrokOAuthHandler struct {
|
||||
grokOAuthService *service.GrokOAuthService
|
||||
adminService service.AdminService
|
||||
quotaService *service.GrokQuotaService
|
||||
}
|
||||
|
||||
func NewGrokOAuthHandler(
|
||||
grokOAuthService *service.GrokOAuthService,
|
||||
adminService service.AdminService,
|
||||
quotaService *service.GrokQuotaService,
|
||||
) *GrokOAuthHandler {
|
||||
return &GrokOAuthHandler{
|
||||
grokOAuthService: grokOAuthService,
|
||||
adminService: adminService,
|
||||
quotaService: quotaService,
|
||||
}
|
||||
}
|
||||
|
||||
type GrokGenerateAuthURLRequest struct {
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
}
|
||||
|
||||
func (h *GrokOAuthHandler) GenerateAuthURL(c *gin.Context) {
|
||||
var req GrokGenerateAuthURLRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
req = GrokGenerateAuthURLRequest{}
|
||||
}
|
||||
result, err := h.grokOAuthService.GenerateAuthURL(c.Request.Context(), req.ProxyID, req.RedirectURI)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
type GrokExchangeCodeRequest struct {
|
||||
SessionID string `json:"session_id" binding:"required"`
|
||||
Code string `json:"code" binding:"required"`
|
||||
State string `json:"state"`
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
}
|
||||
|
||||
func (h *GrokOAuthHandler) ExchangeCode(c *gin.Context) {
|
||||
var req GrokExchangeCodeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
tokenInfo, err := h.grokOAuthService.ExchangeCode(c.Request.Context(), &service.GrokExchangeCodeInput{
|
||||
SessionID: req.SessionID,
|
||||
Code: req.Code,
|
||||
State: req.State,
|
||||
RedirectURI: req.RedirectURI,
|
||||
ProxyID: req.ProxyID,
|
||||
})
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, tokenInfo)
|
||||
}
|
||||
|
||||
type GrokRefreshTokenRequest struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
RT string `json:"rt"`
|
||||
ClientID string `json:"client_id"`
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
}
|
||||
|
||||
func (h *GrokOAuthHandler) RefreshToken(c *gin.Context) {
|
||||
var req GrokRefreshTokenRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
refreshToken := strings.TrimSpace(req.RefreshToken)
|
||||
if refreshToken == "" {
|
||||
refreshToken = strings.TrimSpace(req.RT)
|
||||
}
|
||||
if refreshToken == "" {
|
||||
response.BadRequest(c, "refresh_token is required")
|
||||
return
|
||||
}
|
||||
|
||||
var proxyURL string
|
||||
if req.ProxyID != nil {
|
||||
proxy, err := h.adminService.GetProxy(c.Request.Context(), *req.ProxyID)
|
||||
if err == nil && proxy != nil {
|
||||
proxyURL = proxy.URL()
|
||||
}
|
||||
}
|
||||
tokenInfo, err := h.grokOAuthService.RefreshToken(c.Request.Context(), refreshToken, proxyURL, req.ClientID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, tokenInfo)
|
||||
}
|
||||
|
||||
func (h *GrokOAuthHandler) RefreshAccountToken(c *gin.Context) {
|
||||
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid account ID")
|
||||
return
|
||||
}
|
||||
account, err := h.adminService.GetAccount(c.Request.Context(), accountID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
if account.Platform != service.PlatformGrok {
|
||||
response.BadRequest(c, "Account platform does not match Grok OAuth endpoint")
|
||||
return
|
||||
}
|
||||
if !account.IsOAuth() {
|
||||
response.BadRequest(c, "Cannot refresh non-OAuth account credentials")
|
||||
return
|
||||
}
|
||||
tokenInfo, err := h.grokOAuthService.RefreshAccountToken(c.Request.Context(), account)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
newCredentials := h.grokOAuthService.BuildAccountCredentials(tokenInfo)
|
||||
newCredentials = service.MergeCredentials(account.Credentials, newCredentials)
|
||||
if baseURL := strings.TrimSpace(account.GetCredential("base_url")); baseURL != "" {
|
||||
newCredentials["base_url"] = baseURL
|
||||
}
|
||||
updatedAccount, err := h.adminService.UpdateAccount(c.Request.Context(), accountID, &service.UpdateAccountInput{
|
||||
Credentials: newCredentials,
|
||||
})
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, dto.AccountFromService(updatedAccount))
|
||||
}
|
||||
|
||||
func (h *GrokOAuthHandler) CreateAccountFromOAuth(c *gin.Context) {
|
||||
var req struct {
|
||||
SessionID string `json:"session_id" binding:"required"`
|
||||
Code string `json:"code" binding:"required"`
|
||||
State string `json:"state"`
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
Name string `json:"name"`
|
||||
Concurrency int `json:"concurrency"`
|
||||
Priority int `json:"priority"`
|
||||
GroupIDs []int64 `json:"group_ids"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
tokenInfo, err := h.grokOAuthService.ExchangeCode(c.Request.Context(), &service.GrokExchangeCodeInput{
|
||||
SessionID: req.SessionID,
|
||||
Code: req.Code,
|
||||
State: req.State,
|
||||
RedirectURI: req.RedirectURI,
|
||||
ProxyID: req.ProxyID,
|
||||
})
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
credentials := h.grokOAuthService.BuildAccountCredentials(tokenInfo)
|
||||
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" && tokenInfo.Email != "" {
|
||||
name = tokenInfo.Email
|
||||
}
|
||||
if name == "" {
|
||||
name = "Grok OAuth Account"
|
||||
}
|
||||
|
||||
account, err := h.adminService.CreateAccount(c.Request.Context(), &service.CreateAccountInput{
|
||||
Name: name,
|
||||
Platform: service.PlatformGrok,
|
||||
Type: service.AccountTypeOAuth,
|
||||
Credentials: credentials,
|
||||
ProxyID: req.ProxyID,
|
||||
Concurrency: req.Concurrency,
|
||||
Priority: req.Priority,
|
||||
GroupIDs: req.GroupIDs,
|
||||
})
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, dto.AccountFromService(account))
|
||||
}
|
||||
|
||||
func (h *GrokOAuthHandler) QueryQuota(c *gin.Context) {
|
||||
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid account ID")
|
||||
return
|
||||
}
|
||||
if h.quotaService == nil {
|
||||
response.BadRequest(c, "grok quota service is not enabled")
|
||||
return
|
||||
}
|
||||
result, err := h.quotaService.ProbeUsage(c.Request.Context(), accountID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
func (h *GrokOAuthHandler) ResetQuota(c *gin.Context) {
|
||||
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid account ID")
|
||||
return
|
||||
}
|
||||
if h.quotaService == nil {
|
||||
response.BadRequest(c, "grok quota service is not enabled")
|
||||
return
|
||||
}
|
||||
result, err := h.quotaService.ResetQuota(c.Request.Context(), accountID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
func (h *GrokOAuthHandler) RuntimeSanity(c *gin.Context) {
|
||||
response.Success(c, xai.RuntimeSanity())
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//go:build unit
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
type grokQuotaHandlerAccountRepo struct {
|
||||
service.AccountRepository
|
||||
account *service.Account
|
||||
updates map[int64]map[string]any
|
||||
}
|
||||
|
||||
func (r *grokQuotaHandlerAccountRepo) GetByID(_ context.Context, id int64) (*service.Account, error) {
|
||||
if r.account != nil && r.account.ID == id {
|
||||
return r.account, nil
|
||||
}
|
||||
return nil, service.ErrAccountNotFound
|
||||
}
|
||||
|
||||
func (r *grokQuotaHandlerAccountRepo) UpdateExtra(_ context.Context, id int64, updates map[string]any) error {
|
||||
if r.updates == nil {
|
||||
r.updates = make(map[int64]map[string]any)
|
||||
}
|
||||
r.updates[id] = updates
|
||||
return nil
|
||||
}
|
||||
|
||||
type grokQuotaHandlerUpstream struct {
|
||||
resp *http.Response
|
||||
lastReq *http.Request
|
||||
lastBody []byte
|
||||
}
|
||||
|
||||
func (u *grokQuotaHandlerUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
|
||||
u.lastReq = req
|
||||
if req.Body != nil {
|
||||
u.lastBody, _ = io.ReadAll(req.Body)
|
||||
}
|
||||
return u.resp, nil
|
||||
}
|
||||
|
||||
func (u *grokQuotaHandlerUpstream) DoWithTLS(
|
||||
req *http.Request,
|
||||
proxyURL string,
|
||||
accountID int64,
|
||||
accountConcurrency int,
|
||||
_ *tlsfingerprint.Profile,
|
||||
) (*http.Response, error) {
|
||||
return u.Do(req, proxyURL, accountID, accountConcurrency)
|
||||
}
|
||||
|
||||
func TestGrokOAuthHandlerQueryQuotaProbesUpstream(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
repo := &grokQuotaHandlerAccountRepo{account: &service.Account{
|
||||
ID: 42,
|
||||
Platform: service.PlatformGrok,
|
||||
Type: service.AccountTypeOAuth,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "access-token",
|
||||
"expires_at": time.Now().Add(time.Hour).UTC().Format(time.RFC3339),
|
||||
},
|
||||
}}
|
||||
upstream := &grokQuotaHandlerUpstream{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{
|
||||
"X-Ratelimit-Limit-Requests": []string{"10"},
|
||||
"X-Ratelimit-Remaining-Requests": []string{"8"},
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader(`{"id":"resp_probe"}`)),
|
||||
}}
|
||||
quotaService := service.NewGrokQuotaService(repo, nil, service.NewGrokTokenProvider(repo, nil), upstream)
|
||||
handler := NewGrokOAuthHandler(nil, nil, quotaService)
|
||||
|
||||
router := gin.New()
|
||||
router.GET("/api/v1/admin/grok/accounts/:id/quota", handler.QueryQuota)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/grok/accounts/42/quota", nil)
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), `"source":"active_probe"`)
|
||||
require.Contains(t, rec.Body.String(), `"headers_observed":true`)
|
||||
require.NotContains(t, rec.Body.String(), "access-token")
|
||||
require.Equal(t, xai.DefaultBaseURL+"/responses", upstream.lastReq.URL.String())
|
||||
require.Equal(t, "Bearer access-token", upstream.lastReq.Header.Get("Authorization"))
|
||||
require.Contains(t, string(upstream.lastBody), `"store":false`)
|
||||
require.NotNil(t, repo.updates[42])
|
||||
}
|
||||
|
||||
func TestGrokOAuthHandlerResetQuotaReturnsUnsupported(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
repo := &grokQuotaHandlerAccountRepo{account: &service.Account{
|
||||
ID: 43,
|
||||
Platform: service.PlatformGrok,
|
||||
Type: service.AccountTypeOAuth,
|
||||
}}
|
||||
quotaService := service.NewGrokQuotaService(repo, nil, nil, nil)
|
||||
handler := NewGrokOAuthHandler(nil, nil, quotaService)
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/v1/admin/grok/accounts/:id/reset-quota", handler.ResetQuota)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/grok/accounts/43/reset-quota", nil)
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusNotImplemented, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), `"reason":"GROK_QUOTA_RESET_UNSUPPORTED"`)
|
||||
require.NotContains(t, rec.Body.String(), "access-token")
|
||||
}
|
||||
|
||||
func TestGrokOAuthHandlerRuntimeSanityDoesNotExposeSecrets(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv(xai.EnvBaseURL, "http://127.0.0.1:8080/v1?access_token=secret")
|
||||
t.Setenv(xai.EnvClientID, "client-secret-like-value")
|
||||
|
||||
handler := NewGrokOAuthHandler(nil, nil, nil)
|
||||
router := gin.New()
|
||||
router.GET("/api/v1/admin/grok/runtime-sanity", handler.RuntimeSanity)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/grok/runtime-sanity", nil)
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), `"public_gateway_scope":"responses_only"`)
|
||||
require.Contains(t, rec.Body.String(), `"valid":false`)
|
||||
require.NotContains(t, rec.Body.String(), "access_token")
|
||||
require.NotContains(t, rec.Body.String(), "secret")
|
||||
require.NotContains(t, rec.Body.String(), "client-secret-like-value")
|
||||
}
|
||||
@@ -84,7 +84,7 @@ func NewGroupHandler(adminService service.AdminService, dashboardService *servic
|
||||
type CreateGroupRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity"`
|
||||
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok"`
|
||||
RateMultiplier float64 `json:"rate_multiplier"`
|
||||
IsExclusive bool `json:"is_exclusive"`
|
||||
SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
|
||||
@@ -95,6 +95,10 @@ type CreateGroupRequest struct {
|
||||
AllowImageGeneration bool `json:"allow_image_generation"`
|
||||
ImageRateIndependent bool `json:"image_rate_independent"`
|
||||
ImageRateMultiplier *float64 `json:"image_rate_multiplier"`
|
||||
PeakRateEnabled bool `json:"peak_rate_enabled"`
|
||||
PeakStart string `json:"peak_start"`
|
||||
PeakEnd string `json:"peak_end"`
|
||||
PeakRateMultiplier *float64 `json:"peak_rate_multiplier"`
|
||||
ImagePrice1K *float64 `json:"image_price_1k"`
|
||||
ImagePrice2K *float64 `json:"image_price_2k"`
|
||||
ImagePrice4K *float64 `json:"image_price_4k"`
|
||||
@@ -124,7 +128,7 @@ type CreateGroupRequest struct {
|
||||
type UpdateGroupRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity"`
|
||||
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok"`
|
||||
RateMultiplier *float64 `json:"rate_multiplier"`
|
||||
IsExclusive *bool `json:"is_exclusive"`
|
||||
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
|
||||
@@ -136,6 +140,10 @@ type UpdateGroupRequest struct {
|
||||
AllowImageGeneration *bool `json:"allow_image_generation"`
|
||||
ImageRateIndependent *bool `json:"image_rate_independent"`
|
||||
ImageRateMultiplier *float64 `json:"image_rate_multiplier"`
|
||||
PeakRateEnabled *bool `json:"peak_rate_enabled"`
|
||||
PeakStart *string `json:"peak_start"`
|
||||
PeakEnd *string `json:"peak_end"`
|
||||
PeakRateMultiplier *float64 `json:"peak_rate_multiplier"`
|
||||
ImagePrice1K *float64 `json:"image_price_1k"`
|
||||
ImagePrice2K *float64 `json:"image_price_2k"`
|
||||
ImagePrice4K *float64 `json:"image_price_4k"`
|
||||
@@ -277,6 +285,11 @@ func (h *GroupHandler) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := service.ValidatePeakRateConfig(req.SubscriptionType, req.PeakRateEnabled, req.PeakStart, req.PeakEnd, float64ValueOrDefault(req.PeakRateMultiplier, 1.0)); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
group, err := h.adminService.CreateGroup(c.Request.Context(), &service.CreateGroupInput{
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
@@ -290,6 +303,10 @@ func (h *GroupHandler) Create(c *gin.Context) {
|
||||
AllowImageGeneration: req.AllowImageGeneration,
|
||||
ImageRateIndependent: req.ImageRateIndependent,
|
||||
ImageRateMultiplier: req.ImageRateMultiplier,
|
||||
PeakRateEnabled: req.PeakRateEnabled,
|
||||
PeakStart: req.PeakStart,
|
||||
PeakEnd: req.PeakEnd,
|
||||
PeakRateMultiplier: req.PeakRateMultiplier,
|
||||
ImagePrice1K: req.ImagePrice1K,
|
||||
ImagePrice2K: req.ImagePrice2K,
|
||||
ImagePrice4K: req.ImagePrice4K,
|
||||
@@ -346,6 +363,10 @@ func (h *GroupHandler) Update(c *gin.Context) {
|
||||
AllowImageGeneration: req.AllowImageGeneration,
|
||||
ImageRateIndependent: req.ImageRateIndependent,
|
||||
ImageRateMultiplier: req.ImageRateMultiplier,
|
||||
PeakRateEnabled: req.PeakRateEnabled,
|
||||
PeakStart: req.PeakStart,
|
||||
PeakEnd: req.PeakEnd,
|
||||
PeakRateMultiplier: req.PeakRateMultiplier,
|
||||
ImagePrice1K: req.ImagePrice1K,
|
||||
ImagePrice2K: req.ImagePrice2K,
|
||||
ImagePrice4K: req.ImagePrice4K,
|
||||
|
||||
@@ -3,6 +3,7 @@ package admin
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||
@@ -106,6 +107,24 @@ type OpenAIRefreshTokenRequest struct {
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
}
|
||||
|
||||
type OpenAICodexPATCreateRequest struct {
|
||||
AccessToken string `json:"access_token" binding:"required"`
|
||||
Name string `json:"name"`
|
||||
Notes *string `json:"notes"`
|
||||
GroupIDs []int64 `json:"group_ids"`
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
Concurrency *int `json:"concurrency"`
|
||||
Priority *int `json:"priority"`
|
||||
RateMultiplier *float64 `json:"rate_multiplier"`
|
||||
LoadFactor *int `json:"load_factor"`
|
||||
ExpiresAt *int64 `json:"expires_at"`
|
||||
AutoPauseOnExpired *bool `json:"auto_pause_on_expired"`
|
||||
CredentialExtras map[string]any `json:"credential_extras"`
|
||||
Extra map[string]any `json:"extra"`
|
||||
SkipDefaultGroupBind *bool `json:"skip_default_group_bind"`
|
||||
ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"`
|
||||
}
|
||||
|
||||
// RefreshToken refreshes an OpenAI OAuth token
|
||||
// POST /api/v1/admin/openai/refresh-token
|
||||
func (h *OpenAIOAuthHandler) RefreshToken(c *gin.Context) {
|
||||
@@ -175,6 +194,13 @@ func (h *OpenAIOAuthHandler) RefreshAccountToken(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// spark 影子账号凭据透传母账号、自身恒空,刷新无意义;在调用上游前早拒,避免先打上游
|
||||
// 再被凭据写守卫拦下的无谓副作用(外审第6轮)。
|
||||
if account.IsCredentialShadow() {
|
||||
response.BadRequest(c, "Cannot refresh spark shadow account; its credentials are managed by the parent account")
|
||||
return
|
||||
}
|
||||
|
||||
// Use OpenAI OAuth service to refresh token
|
||||
tokenInfo, err := h.openaiOAuthService.RefreshAccountToken(c.Request.Context(), account)
|
||||
if err != nil {
|
||||
@@ -191,6 +217,7 @@ func (h *OpenAIOAuthHandler) RefreshAccountToken(c *gin.Context) {
|
||||
newCredentials[k] = v
|
||||
}
|
||||
}
|
||||
newCredentials = service.NormalizeOpenAIPersonalAccessTokenCredentials(account, tokenInfo, newCredentials)
|
||||
|
||||
updatedAccount, err := h.adminService.UpdateAccount(c.Request.Context(), accountID, &service.UpdateAccountInput{
|
||||
Credentials: newCredentials,
|
||||
@@ -269,6 +296,114 @@ func (h *OpenAIOAuthHandler) CreateAccountFromOAuth(c *gin.Context) {
|
||||
response.Success(c, dto.AccountFromService(account))
|
||||
}
|
||||
|
||||
// CreateAccountFromCodexPAT creates an OpenAI OAuth account from a Codex at-* personal access token.
|
||||
// POST /api/v1/admin/openai/create-from-codex-pat
|
||||
func (h *OpenAIOAuthHandler) CreateAccountFromCodexPAT(c *gin.Context) {
|
||||
var req OpenAICodexPATCreateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.Concurrency != nil && *req.Concurrency < 0 {
|
||||
response.BadRequest(c, "concurrency must be >= 0")
|
||||
return
|
||||
}
|
||||
if req.Priority != nil && *req.Priority < 0 {
|
||||
response.BadRequest(c, "priority must be >= 0")
|
||||
return
|
||||
}
|
||||
if req.RateMultiplier != nil && *req.RateMultiplier < 0 {
|
||||
response.BadRequest(c, "rate_multiplier must be >= 0")
|
||||
return
|
||||
}
|
||||
if req.LoadFactor != nil && *req.LoadFactor > 10000 {
|
||||
response.BadRequest(c, "load_factor must be <= 10000")
|
||||
return
|
||||
}
|
||||
|
||||
var proxyURL string
|
||||
if req.ProxyID != nil {
|
||||
proxy, err := h.adminService.GetProxy(c.Request.Context(), *req.ProxyID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
if proxy != nil {
|
||||
proxyURL = proxy.URL()
|
||||
}
|
||||
}
|
||||
|
||||
tokenInfo, err := h.openaiOAuthService.ValidateCodexPersonalAccessToken(c.Request.Context(), req.AccessToken, proxyURL)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
credentials := mergeCodexImportMap(
|
||||
h.openaiOAuthService.BuildAccountCredentials(tokenInfo),
|
||||
sanitizeCodexImportCredentialExtras(req.CredentialExtras),
|
||||
)
|
||||
extra := mergeCodexImportMap(req.Extra, map[string]any{
|
||||
"import_source": "codex_personal_access_token",
|
||||
"auth_provider": "codex_personal_access_token",
|
||||
"imported_at": time.Now().UTC().Format(time.RFC3339),
|
||||
"access_token_sha256": codexTokenFingerprint(req.AccessToken),
|
||||
})
|
||||
|
||||
concurrency := 3
|
||||
if req.Concurrency != nil {
|
||||
concurrency = *req.Concurrency
|
||||
}
|
||||
priority := 50
|
||||
if req.Priority != nil {
|
||||
priority = *req.Priority
|
||||
}
|
||||
skipDefaultGroupBind := false
|
||||
if req.SkipDefaultGroupBind != nil {
|
||||
skipDefaultGroupBind = *req.SkipDefaultGroupBind
|
||||
}
|
||||
|
||||
account, err := h.adminService.CreateAccount(c.Request.Context(), &service.CreateAccountInput{
|
||||
Name: buildOpenAICodexPATAccountName(req.Name, tokenInfo),
|
||||
Notes: req.Notes,
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeOAuth,
|
||||
Credentials: credentials,
|
||||
Extra: extra,
|
||||
ProxyID: req.ProxyID,
|
||||
Concurrency: concurrency,
|
||||
Priority: priority,
|
||||
RateMultiplier: req.RateMultiplier,
|
||||
LoadFactor: req.LoadFactor,
|
||||
GroupIDs: req.GroupIDs,
|
||||
ExpiresAt: req.ExpiresAt,
|
||||
AutoPauseOnExpired: req.AutoPauseOnExpired,
|
||||
SkipDefaultGroupBind: skipDefaultGroupBind,
|
||||
SkipMixedChannelCheck: req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk,
|
||||
})
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, dto.AccountFromService(account))
|
||||
}
|
||||
|
||||
func buildOpenAICodexPATAccountName(name string, tokenInfo *service.OpenAITokenInfo) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name != "" {
|
||||
return name
|
||||
}
|
||||
if tokenInfo != nil {
|
||||
for _, candidate := range []string{tokenInfo.Email, tokenInfo.ChatGPTAccountID, tokenInfo.ChatGPTUserID} {
|
||||
if candidate = strings.TrimSpace(candidate); candidate != "" {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
return "Codex PAT Account"
|
||||
}
|
||||
|
||||
// QueryQuota queries the rate-limit / quota usage for an OpenAI account.
|
||||
// GET /api/v1/admin/openai/accounts/:id/quota
|
||||
func (h *OpenAIOAuthHandler) QueryQuota(c *gin.Context) {
|
||||
@@ -289,6 +424,43 @@ func (h *OpenAIOAuthHandler) QueryQuota(c *gin.Context) {
|
||||
response.Success(c, usage)
|
||||
}
|
||||
|
||||
// CreateShadowRequest is the request body for CreateShadow.
|
||||
type CreateShadowRequest struct {
|
||||
Name string `json:"name"`
|
||||
Priority int `json:"priority"`
|
||||
Concurrency int `json:"concurrency"`
|
||||
GroupIDs []int64 `json:"group_ids"`
|
||||
}
|
||||
|
||||
// CreateShadow creates a spark-dimension shadow account for a parent OpenAI OAuth account.
|
||||
// POST /api/v1/admin/accounts/:id/shadow
|
||||
func (h *OpenAIOAuthHandler) CreateShadow(c *gin.Context) {
|
||||
parentID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid account ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req CreateShadowRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
shadow, err := h.adminService.CreateShadow(c.Request.Context(), parentID, service.ShadowOptions{
|
||||
Name: req.Name,
|
||||
Priority: req.Priority,
|
||||
Concurrency: req.Concurrency,
|
||||
GroupIDs: req.GroupIDs,
|
||||
})
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, dto.AccountFromServiceShallow(shadow))
|
||||
}
|
||||
|
||||
// ResetQuota consumes one rate-limit reset credit for an OpenAI account.
|
||||
// POST /api/v1/admin/openai/accounts/:id/reset-quota
|
||||
func (h *OpenAIOAuthHandler) ResetQuota(c *gin.Context) {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
//go:build unit
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
func TestCreateShadow_ReturnsCreatedShadow(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
stub := &stubAdminService{}
|
||||
h := NewOpenAIOAuthHandler(nil, stub, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/v1/admin/accounts/:id/shadow", h.CreateShadow)
|
||||
|
||||
body := `{"name":"p-spark","priority":50,"concurrency":2,"group_ids":[10,20]}`
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/42/shadow", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var resp map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
|
||||
data, ok := resp["data"].(map[string]any)
|
||||
require.True(t, ok, "response should have data field")
|
||||
|
||||
// parent_account_id must be present and equal to the path param
|
||||
pid, ok := data["parent_account_id"].(float64)
|
||||
require.True(t, ok, "parent_account_id should be present")
|
||||
require.Equal(t, float64(42), pid)
|
||||
|
||||
// quota_dimension must be "spark"
|
||||
require.Equal(t, service.QuotaDimensionSpark, data["quota_dimension"])
|
||||
|
||||
// name round-trips
|
||||
require.Equal(t, "p-spark", data["name"])
|
||||
}
|
||||
|
||||
func TestCreateShadow_InvalidID(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
h := NewOpenAIOAuthHandler(nil, &stubAdminService{}, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/v1/admin/accounts/:id/shadow", h.CreateShadow)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/not-a-number/shadow",
|
||||
strings.NewReader(`{"name":"x"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
}
|
||||
|
||||
func TestCreateShadow_ServiceError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
stub := &stubAdminService{createSparkShadowErr: errors.New("database unavailable")}
|
||||
h := NewOpenAIOAuthHandler(nil, stub, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/v1/admin/accounts/:id/shadow", h.CreateShadow)
|
||||
|
||||
body := `{"name":"p-spark","priority":50}`
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/42/shadow", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
// A generic (non-ApplicationError) service error maps to 500 via response.ErrorFrom.
|
||||
require.GreaterOrEqual(t, rec.Code, http.StatusBadRequest)
|
||||
require.Equal(t, http.StatusInternalServerError, rec.Code)
|
||||
}
|
||||
|
||||
func TestCreateShadow_BadBody(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
h := NewOpenAIOAuthHandler(nil, &stubAdminService{}, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/v1/admin/accounts/:id/shadow", h.CreateShadow)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/42/shadow",
|
||||
strings.NewReader(`{not valid json`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
}
|
||||
@@ -73,6 +73,13 @@ func NewOpsHandler(opsService *service.OpsService) *OpsHandler {
|
||||
}
|
||||
|
||||
// GetErrorLogs lists ops error logs.
|
||||
// applyOpsErrorSortParams reads sort_by/sort_order query params into the filter.
|
||||
// Column whitelist and order normalization live in the repository; unknown
|
||||
// values degrade to the default (created_at DESC), mirroring the usage list.
|
||||
func applyOpsErrorSortParams(c *gin.Context, filter *service.OpsErrorLogFilter) {
|
||||
filter.SetSort(c.Query("sort_by"), c.Query("sort_order"))
|
||||
}
|
||||
|
||||
// GET /api/v1/admin/ops/errors
|
||||
func (h *OpsHandler) GetErrorLogs(c *gin.Context) {
|
||||
if h.opsService == nil {
|
||||
@@ -114,10 +121,17 @@ func (h *OpsHandler) GetErrorLogs(c *gin.Context) {
|
||||
// buildOpsErrorLogsWhere 以 COALESCE(requested_model, model) 比对。
|
||||
filter.Model = strings.TrimSpace(c.Query("model"))
|
||||
|
||||
// Force request errors: client-visible status >= 400.
|
||||
// buildOpsErrorLogsWhere already applies this for non-upstream phase.
|
||||
if strings.EqualFold(strings.TrimSpace(filter.Phase), "upstream") {
|
||||
filter.Phase = ""
|
||||
// 请求错误语义:client-visible status>=400 守卫恒生效(未设
|
||||
// IncludeRecoveredUpstream 时 phase=upstream 不再绕过守卫),故
|
||||
// phase=upstream 作为普通过滤条件保留——此前这里清空该值,导致
|
||||
// 错误类型下拉选「上游」等于不过滤。
|
||||
|
||||
// 分类(用户侧粗分类码)→ phase/type ANY 条件,与用户端 /usage/errors 同一映射;
|
||||
// 未知分类返回空切片 = 不过滤。与 phase 参数可同时设置(AND 语义)。
|
||||
if cat := strings.TrimSpace(c.Query("category")); cat != "" {
|
||||
phases, types := service.CategoryToFilter(cat)
|
||||
filter.ErrorPhasesAny = phases
|
||||
filter.ErrorTypesAny = types
|
||||
}
|
||||
|
||||
if platform := strings.TrimSpace(c.Query("platform")); platform != "" {
|
||||
@@ -187,6 +201,8 @@ func (h *OpsHandler) GetErrorLogs(c *gin.Context) {
|
||||
filter.StatusCodes = out
|
||||
}
|
||||
|
||||
applyOpsErrorSortParams(c, filter)
|
||||
|
||||
result, err := h.opsService.GetErrorLogs(c.Request.Context(), filter)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
@@ -234,10 +250,17 @@ func (h *OpsHandler) ListRequestErrors(c *gin.Context) {
|
||||
// buildOpsErrorLogsWhere 以 COALESCE(requested_model, model) 比对。
|
||||
filter.Model = strings.TrimSpace(c.Query("model"))
|
||||
|
||||
// Force request errors: client-visible status >= 400.
|
||||
// buildOpsErrorLogsWhere already applies this for non-upstream phase.
|
||||
if strings.EqualFold(strings.TrimSpace(filter.Phase), "upstream") {
|
||||
filter.Phase = ""
|
||||
// 请求错误语义:client-visible status>=400 守卫恒生效(未设
|
||||
// IncludeRecoveredUpstream 时 phase=upstream 不再绕过守卫),故
|
||||
// phase=upstream 作为普通过滤条件保留——此前这里清空该值,导致
|
||||
// 错误类型下拉选「上游」等于不过滤。
|
||||
|
||||
// 分类(用户侧粗分类码)→ phase/type ANY 条件,与用户端 /usage/errors 同一映射;
|
||||
// 未知分类返回空切片 = 不过滤。与 phase 参数可同时设置(AND 语义)。
|
||||
if cat := strings.TrimSpace(c.Query("category")); cat != "" {
|
||||
phases, types := service.CategoryToFilter(cat)
|
||||
filter.ErrorPhasesAny = phases
|
||||
filter.ErrorTypesAny = types
|
||||
}
|
||||
|
||||
if platform := strings.TrimSpace(c.Query("platform")); platform != "" {
|
||||
@@ -291,6 +314,8 @@ func (h *OpsHandler) ListRequestErrors(c *gin.Context) {
|
||||
filter.StatusCodes = out
|
||||
}
|
||||
|
||||
applyOpsErrorSortParams(c, filter)
|
||||
|
||||
result, err := h.opsService.GetErrorLogs(c.Request.Context(), filter)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
@@ -362,6 +387,8 @@ func (h *OpsHandler) ListRequestErrorUpstreamErrors(c *gin.Context) {
|
||||
}
|
||||
filter.View = "all"
|
||||
filter.Phase = "upstream"
|
||||
// 上游错误列表需含 status<400 的 recovered 行,显式豁免客户端可见守卫。
|
||||
filter.IncludeRecoveredUpstream = true
|
||||
filter.Owner = "provider"
|
||||
filter.Source = strings.TrimSpace(c.Query("error_source"))
|
||||
filter.Query = strings.TrimSpace(c.Query("q"))
|
||||
@@ -377,6 +404,8 @@ func (h *OpsHandler) ListRequestErrorUpstreamErrors(c *gin.Context) {
|
||||
filter.ClientRequestID = clientRequestID
|
||||
}
|
||||
|
||||
applyOpsErrorSortParams(c, filter)
|
||||
|
||||
result, err := h.opsService.GetErrorLogs(c.Request.Context(), filter)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
@@ -442,6 +471,8 @@ func (h *OpsHandler) ListUpstreamErrors(c *gin.Context) {
|
||||
|
||||
filter.View = parseOpsViewParam(c)
|
||||
filter.Phase = "upstream"
|
||||
// 上游错误列表需含 status<400 的 recovered 行,显式豁免客户端可见守卫。
|
||||
filter.IncludeRecoveredUpstream = true
|
||||
filter.Owner = "provider"
|
||||
filter.Source = strings.TrimSpace(c.Query("error_source"))
|
||||
filter.Query = strings.TrimSpace(c.Query("q"))
|
||||
@@ -497,6 +528,8 @@ func (h *OpsHandler) ListUpstreamErrors(c *gin.Context) {
|
||||
filter.StatusCodes = out
|
||||
}
|
||||
|
||||
applyOpsErrorSortParams(c, filter)
|
||||
|
||||
result, err := h.opsService.GetErrorLogs(c.Request.Context(), filter)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -47,6 +49,9 @@ func (h *OpsHandler) GetConcurrencyStats(c *gin.Context) {
|
||||
|
||||
platform, group, account, collectedAt, err := h.opsService.GetConcurrencyStats(c.Request.Context(), platformFilter, groupID)
|
||||
if err != nil {
|
||||
if isOpsRealtimeRequestCanceled(c, err) {
|
||||
return
|
||||
}
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
@@ -86,6 +91,9 @@ func (h *OpsHandler) GetUserConcurrencyStats(c *gin.Context) {
|
||||
|
||||
users, collectedAt, err := h.opsService.GetUserConcurrencyStats(c.Request.Context())
|
||||
if err != nil {
|
||||
if isOpsRealtimeRequestCanceled(c, err) {
|
||||
return
|
||||
}
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
@@ -140,6 +148,9 @@ func (h *OpsHandler) GetAccountAvailability(c *gin.Context) {
|
||||
|
||||
platformStats, groupStats, accountStats, collectedAt, err := h.opsService.GetAccountAvailabilityStats(c.Request.Context(), platform, groupID)
|
||||
if err != nil {
|
||||
if isOpsRealtimeRequestCanceled(c, err) {
|
||||
return
|
||||
}
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
@@ -156,6 +167,19 @@ func (h *OpsHandler) GetAccountAvailability(c *gin.Context) {
|
||||
response.Success(c, payload)
|
||||
}
|
||||
|
||||
func isOpsRealtimeRequestCanceled(c *gin.Context, err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return true
|
||||
}
|
||||
if c != nil && c.Request != nil && errors.Is(c.Request.Context().Err(), context.Canceled) {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(err.Error(), "canceling statement due to user request")
|
||||
}
|
||||
|
||||
func parseOpsRealtimeWindow(v string) (time.Duration, string, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||
case "", "1min", "1m":
|
||||
@@ -236,6 +260,9 @@ func (h *OpsHandler) GetRealtimeTrafficSummary(c *gin.Context) {
|
||||
|
||||
summary, err := h.opsService.GetRealtimeTrafficSummary(c.Request.Context(), filter)
|
||||
if err != nil {
|
||||
if isOpsRealtimeRequestCanceled(c, err) {
|
||||
return
|
||||
}
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ type opsSystemLogCleanupRequest struct {
|
||||
RequestID string `json:"request_id"`
|
||||
ClientRequestID string `json:"client_request_id"`
|
||||
UserID *int64 `json:"user_id"`
|
||||
APIKeyID *int64 `json:"api_key_id"`
|
||||
AccountID *int64 `json:"account_id"`
|
||||
Platform string `json:"platform"`
|
||||
Model string `json:"model"`
|
||||
@@ -71,6 +72,14 @@ func (h *OpsHandler) ListSystemLogs(c *gin.Context) {
|
||||
}
|
||||
filter.UserID = &id
|
||||
}
|
||||
if v := strings.TrimSpace(c.Query("api_key_id")); v != "" {
|
||||
id, parseErr := strconv.ParseInt(v, 10, 64)
|
||||
if parseErr != nil || id <= 0 {
|
||||
response.BadRequest(c, "Invalid api_key_id")
|
||||
return
|
||||
}
|
||||
filter.APIKeyID = &id
|
||||
}
|
||||
if v := strings.TrimSpace(c.Query("account_id")); v != "" {
|
||||
id, parseErr := strconv.ParseInt(v, 10, 64)
|
||||
if parseErr != nil || id <= 0 {
|
||||
@@ -136,6 +145,10 @@ func (h *OpsHandler) CleanupSystemLogs(c *gin.Context) {
|
||||
response.BadRequest(c, "Invalid end_time")
|
||||
return
|
||||
}
|
||||
if req.APIKeyID != nil && *req.APIKeyID <= 0 {
|
||||
response.BadRequest(c, "Invalid api_key_id")
|
||||
return
|
||||
}
|
||||
|
||||
filter := &service.OpsSystemLogCleanupFilter{
|
||||
StartTime: start,
|
||||
@@ -145,6 +158,7 @@ func (h *OpsHandler) CleanupSystemLogs(c *gin.Context) {
|
||||
RequestID: strings.TrimSpace(req.RequestID),
|
||||
ClientRequestID: strings.TrimSpace(req.ClientRequestID),
|
||||
UserID: req.UserID,
|
||||
APIKeyID: req.APIKeyID,
|
||||
AccountID: req.AccountID,
|
||||
Platform: strings.TrimSpace(req.Platform),
|
||||
Model: strings.TrimSpace(req.Model),
|
||||
|
||||
@@ -72,6 +72,19 @@ func TestOpsSystemLogHandler_ListInvalidAccountID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpsSystemLogHandler_ListInvalidAPIKeyID(t *testing.T) {
|
||||
svc := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
h := NewOpsHandler(svc)
|
||||
r := newOpsSystemLogTestRouter(h, false)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/logs?api_key_id=abc", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d, want 400", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpsSystemLogHandler_ListMonitoringDisabled(t *testing.T) {
|
||||
svc := service.NewOpsService(nil, nil, &config.Config{
|
||||
Ops: config.OpsConfig{Enabled: false},
|
||||
@@ -178,6 +191,34 @@ func TestOpsSystemLogHandler_CleanupServiceUnavailable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpsSystemLogHandler_CleanupAcceptsAPIKeyID(t *testing.T) {
|
||||
svc := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
h := NewOpsHandler(svc)
|
||||
r := newOpsSystemLogTestRouter(h, true)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/logs/cleanup", bytes.NewBufferString(`{"api_key_id":123}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status=%d, want 503", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpsSystemLogHandler_CleanupInvalidAPIKeyID(t *testing.T) {
|
||||
svc := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
h := NewOpsHandler(svc)
|
||||
r := newOpsSystemLogTestRouter(h, true)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/logs/cleanup", bytes.NewBufferString(`{"api_key_id":0}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d, want 400", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpsSystemLogHandler_CleanupMonitoringDisabled(t *testing.T) {
|
||||
svc := service.NewOpsService(nil, nil, &config.Config{
|
||||
Ops: config.OpsConfig{Enabled: false},
|
||||
|
||||
@@ -2,6 +2,7 @@ package admin
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
@@ -115,24 +116,105 @@ func (h *PaymentHandler) RetryFulfillment(c *gin.Context) {
|
||||
response.Success(c, gin.H{"message": "fulfillment retried"})
|
||||
}
|
||||
|
||||
func sanitizeAdminPaymentOrdersForResponse(orders []*dbent.PaymentOrder) []*dbent.PaymentOrder {
|
||||
if len(orders) == 0 {
|
||||
return orders
|
||||
}
|
||||
out := make([]*dbent.PaymentOrder, 0, len(orders))
|
||||
type AdminPaymentOrderResult struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
UserEmail string `json:"user_email,omitempty"`
|
||||
UserName string `json:"user_name,omitempty"`
|
||||
UserNotes *string `json:"user_notes,omitempty"`
|
||||
Amount float64 `json:"amount"`
|
||||
PayAmount float64 `json:"pay_amount"`
|
||||
FeeRate float64 `json:"fee_rate"`
|
||||
Currency string `json:"currency"`
|
||||
RechargeCode string `json:"recharge_code,omitempty"`
|
||||
OutTradeNo string `json:"out_trade_no"`
|
||||
PaymentType string `json:"payment_type"`
|
||||
PaymentTradeNo string `json:"payment_trade_no,omitempty"`
|
||||
PayURL *string `json:"pay_url,omitempty"`
|
||||
QRCode *string `json:"qr_code,omitempty"`
|
||||
QRCodeImg *string `json:"qr_code_img,omitempty"`
|
||||
OrderType string `json:"order_type"`
|
||||
PlanID *int64 `json:"plan_id,omitempty"`
|
||||
SubscriptionGroupID *int64 `json:"subscription_group_id,omitempty"`
|
||||
SubscriptionDays *int `json:"subscription_days,omitempty"`
|
||||
ProviderInstanceID *string `json:"provider_instance_id,omitempty"`
|
||||
ProviderKey *string `json:"provider_key,omitempty"`
|
||||
Status string `json:"status"`
|
||||
RefundAmount float64 `json:"refund_amount"`
|
||||
RefundReason *string `json:"refund_reason,omitempty"`
|
||||
RefundAt *time.Time `json:"refund_at,omitempty"`
|
||||
ForceRefund bool `json:"force_refund,omitempty"`
|
||||
RefundRequestedAt *time.Time `json:"refund_requested_at,omitempty"`
|
||||
RefundRequestReason *string `json:"refund_request_reason,omitempty"`
|
||||
RefundRequestedBy *string `json:"refund_requested_by,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
PaidAt *time.Time `json:"paid_at,omitempty"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
FailedAt *time.Time `json:"failed_at,omitempty"`
|
||||
FailedReason *string `json:"failed_reason,omitempty"`
|
||||
ClientIP string `json:"client_ip,omitempty"`
|
||||
SrcHost string `json:"src_host,omitempty"`
|
||||
SrcURL *string `json:"src_url,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func sanitizeAdminPaymentOrdersForResponse(orders []*dbent.PaymentOrder) []*AdminPaymentOrderResult {
|
||||
out := make([]*AdminPaymentOrderResult, 0, len(orders))
|
||||
for _, order := range orders {
|
||||
out = append(out, sanitizeAdminPaymentOrderForResponse(order))
|
||||
if item := sanitizeAdminPaymentOrderForResponse(order); item != nil {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sanitizeAdminPaymentOrderForResponse(order *dbent.PaymentOrder) *dbent.PaymentOrder {
|
||||
func sanitizeAdminPaymentOrderForResponse(order *dbent.PaymentOrder) *AdminPaymentOrderResult {
|
||||
if order == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *order
|
||||
cloned.ProviderSnapshot = nil
|
||||
return &cloned
|
||||
return &AdminPaymentOrderResult{
|
||||
ID: order.ID,
|
||||
UserID: order.UserID,
|
||||
UserEmail: order.UserEmail,
|
||||
UserName: order.UserName,
|
||||
UserNotes: order.UserNotes,
|
||||
Amount: order.Amount,
|
||||
PayAmount: order.PayAmount,
|
||||
FeeRate: order.FeeRate,
|
||||
Currency: service.PaymentOrderCurrency(order),
|
||||
RechargeCode: order.RechargeCode,
|
||||
OutTradeNo: order.OutTradeNo,
|
||||
PaymentType: order.PaymentType,
|
||||
PaymentTradeNo: order.PaymentTradeNo,
|
||||
PayURL: order.PayURL,
|
||||
QRCode: order.QrCode,
|
||||
QRCodeImg: order.QrCodeImg,
|
||||
OrderType: order.OrderType,
|
||||
PlanID: order.PlanID,
|
||||
SubscriptionGroupID: order.SubscriptionGroupID,
|
||||
SubscriptionDays: order.SubscriptionDays,
|
||||
ProviderInstanceID: order.ProviderInstanceID,
|
||||
ProviderKey: order.ProviderKey,
|
||||
Status: order.Status,
|
||||
RefundAmount: order.RefundAmount,
|
||||
RefundReason: order.RefundReason,
|
||||
RefundAt: order.RefundAt,
|
||||
ForceRefund: order.ForceRefund,
|
||||
RefundRequestedAt: order.RefundRequestedAt,
|
||||
RefundRequestReason: order.RefundRequestReason,
|
||||
RefundRequestedBy: order.RefundRequestedBy,
|
||||
ExpiresAt: order.ExpiresAt,
|
||||
PaidAt: order.PaidAt,
|
||||
CompletedAt: order.CompletedAt,
|
||||
FailedAt: order.FailedAt,
|
||||
FailedReason: order.FailedReason,
|
||||
ClientIP: order.ClientIP,
|
||||
SrcHost: order.SrcHost,
|
||||
SrcURL: order.SrcURL,
|
||||
CreatedAt: order.CreatedAt,
|
||||
UpdatedAt: order.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// AdminProcessRefundRequest is the request body for admin refund processing.
|
||||
@@ -175,6 +257,22 @@ func (h *PaymentHandler) ProcessRefund(c *gin.Context) {
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
// QueryAndFinalizeRefund queries the provider refund status and finalizes a pending refund.
|
||||
// POST /api/v1/admin/payment/orders/:id/refund/query
|
||||
func (h *PaymentHandler) QueryAndFinalizeRefund(c *gin.Context) {
|
||||
orderID, ok := parseIDParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.paymentService.QueryAndFinalizeRefund(c.Request.Context(), orderID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
// --- Subscription Plans ---
|
||||
|
||||
// ListPlans returns all subscription plans.
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||
)
|
||||
|
||||
func TestSanitizeAdminPaymentOrderForResponseAddsCurrency(t *testing.T) {
|
||||
now := time.Now()
|
||||
order := &dbent.PaymentOrder{
|
||||
ID: 1,
|
||||
UserID: 2,
|
||||
Amount: 100,
|
||||
PayAmount: 108,
|
||||
FeeRate: 8,
|
||||
OutTradeNo: "sub2_202606250001",
|
||||
PaymentType: "stripe",
|
||||
OrderType: "subscription",
|
||||
Status: "COMPLETED",
|
||||
ExpiresAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
ProviderSnapshot: map[string]any{
|
||||
"schema_version": 2,
|
||||
"currency": "USD",
|
||||
},
|
||||
}
|
||||
|
||||
got := sanitizeAdminPaymentOrderForResponse(order)
|
||||
if got == nil {
|
||||
t.Fatal("expected sanitized order")
|
||||
}
|
||||
if got.Currency != "USD" {
|
||||
t.Fatalf("expected currency USD, got %q", got.Currency)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(got)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal sanitized order: %v", err)
|
||||
}
|
||||
if strings.Contains(string(body), "provider_snapshot") {
|
||||
t.Fatalf("expected provider_snapshot to be omitted, got %s", string(body))
|
||||
}
|
||||
}
|
||||
@@ -217,12 +217,13 @@ func TestSettingHandler_UpdateSettings_PersistsPaymentVisibleMethodsAndAdvancedS
|
||||
handler := NewSettingHandler(svc, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
body := map[string]any{
|
||||
"promo_code_enabled": true,
|
||||
"payment_visible_method_alipay_source": "easypay",
|
||||
"payment_visible_method_wxpay_source": "wxpay",
|
||||
"payment_visible_method_alipay_enabled": true,
|
||||
"payment_visible_method_wxpay_enabled": false,
|
||||
"openai_advanced_scheduler_enabled": true,
|
||||
"promo_code_enabled": true,
|
||||
"payment_visible_method_alipay_source": "easypay",
|
||||
"payment_visible_method_wxpay_source": "wxpay",
|
||||
"payment_visible_method_alipay_enabled": true,
|
||||
"payment_visible_method_wxpay_enabled": false,
|
||||
"openai_advanced_scheduler_enabled": true,
|
||||
"openai_advanced_scheduler_subscription_priority_enabled": true,
|
||||
}
|
||||
rawBody, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
@@ -240,6 +241,7 @@ func TestSettingHandler_UpdateSettings_PersistsPaymentVisibleMethodsAndAdvancedS
|
||||
require.Equal(t, "true", repo.values[service.SettingPaymentVisibleMethodAlipayEnabled])
|
||||
require.Equal(t, "false", repo.values[service.SettingPaymentVisibleMethodWxpayEnabled])
|
||||
require.Equal(t, "true", repo.values["openai_advanced_scheduler_enabled"])
|
||||
require.Equal(t, "true", repo.values[service.SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled])
|
||||
|
||||
var resp response.Response
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
@@ -250,6 +252,7 @@ func TestSettingHandler_UpdateSettings_PersistsPaymentVisibleMethodsAndAdvancedS
|
||||
require.Equal(t, true, data["payment_visible_method_alipay_enabled"])
|
||||
require.Equal(t, false, data["payment_visible_method_wxpay_enabled"])
|
||||
require.Equal(t, true, data["openai_advanced_scheduler_enabled"])
|
||||
require.Equal(t, true, data["openai_advanced_scheduler_subscription_priority_enabled"])
|
||||
}
|
||||
|
||||
func TestSettingHandler_UpdateSettings_PreservesLegacyBlankPaymentVisibleMethodSource(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
//go:build unit
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// updateSettingsCodexStatus PUT /settings 仅带给定字段,返回 HTTP 状态码(轻量 stub repo,无 DB)。
|
||||
func updateSettingsCodexStatus(t *testing.T, body map[string]any) int {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
repo := &settingHandlerRepoStub{values: map[string]string{service.SettingKeyPromoCodeEnabled: "true"}}
|
||||
svc := service.NewSettingService(repo, &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}})
|
||||
handler := NewSettingHandler(svc, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
raw, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(raw))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
handler.UpdateSettings(c)
|
||||
return rec.Code
|
||||
}
|
||||
|
||||
// 白名单是双因子 AND:originator-only 条目在运行时永不命中(静默失效)。
|
||||
// handler 应路由到 ValidateCodexWhitelistEntriesJSON,在写入时即拒(400)。
|
||||
func TestUpdateSettings_CodexWhitelistRejectsUnmatchable(t *testing.T) {
|
||||
code := updateSettingsCodexStatus(t, map[string]any{
|
||||
"codex_cli_only_whitelist": `[{"originator":"opencode"}]`,
|
||||
})
|
||||
require.Equal(t, http.StatusBadRequest, code, "白名单 originator-only 应被拒(静默失效防护)")
|
||||
}
|
||||
|
||||
func TestUpdateSettings_CodexWhitelistAcceptsMatchable(t *testing.T) {
|
||||
code := updateSettingsCodexStatus(t, map[string]any{
|
||||
"codex_cli_only_whitelist": `[{"originator":"opencode","ua_contains":["opencode/"]}]`,
|
||||
})
|
||||
require.Equal(t, http.StatusOK, code, "可命中白名单条目应通过")
|
||||
}
|
||||
|
||||
// 黑名单是 OR 宽 deny:允许 originator-only。非对称——不受白名单收紧影响。
|
||||
func TestUpdateSettings_CodexBlacklistAllowsOriginatorOnly(t *testing.T) {
|
||||
code := updateSettingsCodexStatus(t, map[string]any{
|
||||
"codex_cli_only_blacklist": `[{"originator":"evil"}]`,
|
||||
})
|
||||
require.Equal(t, http.StatusOK, code, "黑名单 originator-only 应允许(非对称)")
|
||||
}
|
||||
@@ -249,8 +249,9 @@ func (h *SubscriptionHandler) ResetQuota(c *gin.Context) {
|
||||
response.Success(c, dto.UserSubscriptionFromServiceAdmin(sub))
|
||||
}
|
||||
|
||||
// Revoke handles revoking a subscription
|
||||
// DELETE /api/v1/admin/subscriptions/:id
|
||||
// Revoke handles revoking a subscription.
|
||||
// POST /api/v1/admin/subscriptions/:id/revoke
|
||||
// DELETE /api/v1/admin/subscriptions/:id is kept for backward compatibility.
|
||||
func (h *SubscriptionHandler) Revoke(c *gin.Context) {
|
||||
subscriptionID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
@@ -267,6 +268,24 @@ func (h *SubscriptionHandler) Revoke(c *gin.Context) {
|
||||
response.Success(c, gin.H{"message": "Subscription revoked successfully"})
|
||||
}
|
||||
|
||||
// Restore handles restoring a revoked subscription.
|
||||
// POST /api/v1/admin/subscriptions/:id/restore
|
||||
func (h *SubscriptionHandler) Restore(c *gin.Context) {
|
||||
subscriptionID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid subscription ID")
|
||||
return
|
||||
}
|
||||
|
||||
subscription, err := h.subscriptionService.RestoreSubscription(c.Request.Context(), subscriptionID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, dto.UserSubscriptionFromServiceAdmin(subscription))
|
||||
}
|
||||
|
||||
// ListByGroup handles listing subscriptions for a specific group
|
||||
// GET /api/v1/admin/groups/:id/subscriptions
|
||||
func (h *SubscriptionHandler) ListByGroup(c *gin.Context) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package admin
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"strconv"
|
||||
@@ -629,8 +630,8 @@ func (h *UserHandler) UpdateUserPlatformQuotas(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Quotas) > 4 {
|
||||
response.BadRequest(c, "quotas length must be <= 4")
|
||||
if len(req.Quotas) > len(service.AllowedQuotaPlatforms) {
|
||||
response.BadRequest(c, fmt.Sprintf("quotas length must be <= %d", len(service.AllowedQuotaPlatforms)))
|
||||
return
|
||||
}
|
||||
seen := make(map[string]struct{}, len(req.Quotas))
|
||||
@@ -749,7 +750,7 @@ func (h *UserHandler) UpdateUserPlatformQuotas(c *gin.Context) {
|
||||
|
||||
// 失效 cache:对全部允许的 platform 统一 invalidate。
|
||||
// Trade-off:精确失效(仅 req 涉及平台 + 被软删平台)需 upsert 前额外 ListByUser,
|
||||
// 增加一次 DB 查询和逻辑复杂度。由于 AllowedQuotaPlatforms 只有 4 个元素,
|
||||
// 增加一次 DB 查询和逻辑复杂度。由于 AllowedQuotaPlatforms 数量很少,
|
||||
// 全量 invalidate 的额外开销可接受,且能可靠覆盖软删除场景。
|
||||
if h.billingCache != nil {
|
||||
for _, p := range service.AllowedQuotaPlatforms {
|
||||
|
||||
@@ -98,7 +98,10 @@ func TestUpdateUserPlatformQuotas_Success(t *testing.T) {
|
||||
|
||||
body := `{"quotas":[
|
||||
{"platform":"anthropic","daily_limit_usd":10.0,"weekly_limit_usd":null,"monthly_limit_usd":100.0},
|
||||
{"platform":"openai","daily_limit_usd":null,"weekly_limit_usd":null,"monthly_limit_usd":null}
|
||||
{"platform":"openai","daily_limit_usd":80.0,"weekly_limit_usd":300.0,"monthly_limit_usd":null},
|
||||
{"platform":"gemini","daily_limit_usd":null,"weekly_limit_usd":null,"monthly_limit_usd":null},
|
||||
{"platform":"antigravity","daily_limit_usd":null,"weekly_limit_usd":null,"monthly_limit_usd":null},
|
||||
{"platform":"grok","daily_limit_usd":null,"weekly_limit_usd":null,"monthly_limit_usd":null}
|
||||
]}`
|
||||
c, w := putReq(t, body)
|
||||
h.UpdateUserPlatformQuotas(c)
|
||||
@@ -109,12 +112,12 @@ func TestUpdateUserPlatformQuotas_Success(t *testing.T) {
|
||||
if len(repo.upsertCalls) != 1 {
|
||||
t.Fatalf("UpsertForUser should be called once, got %d", len(repo.upsertCalls))
|
||||
}
|
||||
if repo.upsertCalls[0].userID != 42 || len(repo.upsertCalls[0].records) != 2 {
|
||||
if repo.upsertCalls[0].userID != 42 || len(repo.upsertCalls[0].records) != len(service.AllowedQuotaPlatforms) {
|
||||
t.Errorf("unexpected upsert call: %+v", repo.upsertCalls[0])
|
||||
}
|
||||
// 缓存失效:请求中 2 个 platform + 软删除的 2 个 platform(gemini, antigravity)= 4 次
|
||||
if len(cache.deleteCalls) != 4 {
|
||||
t.Errorf("expected 4 cache delete calls, got %d: %+v", len(cache.deleteCalls), cache.deleteCalls)
|
||||
// 缓存失效:按全部允许平台统一失效。
|
||||
if len(cache.deleteCalls) != 5 {
|
||||
t.Errorf("expected 5 cache delete calls, got %d: %+v", len(cache.deleteCalls), cache.deleteCalls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,7 +157,7 @@ func TestUpdateUserPlatformQuotas_RejectsNegativeLimit(t *testing.T) {
|
||||
func TestUpdateUserPlatformQuotas_RejectsTooManyEntries(t *testing.T) {
|
||||
h := buildTestHandler(&upsertCapturingQuotaRepo{}, &billingCacheStub{})
|
||||
body := `{"quotas":[
|
||||
{"platform":"anthropic"},{"platform":"openai"},{"platform":"gemini"},{"platform":"antigravity"},{"platform":"anthropic"}
|
||||
{"platform":"anthropic"},{"platform":"openai"},{"platform":"gemini"},{"platform":"antigravity"},{"platform":"grok"},{"platform":"anthropic"}
|
||||
]}`
|
||||
c, w := putReq(t, body)
|
||||
h.UpdateUserPlatformQuotas(c)
|
||||
|
||||
@@ -383,6 +383,7 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
|
||||
upstreamClaims,
|
||||
compatEmail,
|
||||
compatEmailUser,
|
||||
emailVerificationRequired,
|
||||
forceEmailOnSignup,
|
||||
); err != nil {
|
||||
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
|
||||
@@ -433,6 +434,7 @@ func (h *AuthHandler) createLinuxDoOAuthChoicePendingSession(
|
||||
upstreamClaims map[string]any,
|
||||
compatEmail string,
|
||||
compatEmailUser *dbent.User,
|
||||
emailVerificationRequired bool,
|
||||
forceEmailOnSignup bool,
|
||||
) error {
|
||||
suggestionEmail := strings.TrimSpace(suggestedEmail)
|
||||
@@ -467,6 +469,17 @@ func (h *AuthHandler) createLinuxDoOAuthChoicePendingSession(
|
||||
if forceEmailOnSignup && compatEmailUser == nil {
|
||||
completionResponse["choice_reason"] = "force_email_on_signup"
|
||||
}
|
||||
if (emailVerificationRequired || forceEmailOnSignup) && compatEmailUser == nil {
|
||||
completionResponse["step"] = "create_account_required"
|
||||
completionResponse["email_binding_required"] = true
|
||||
completionResponse["force_email_on_signup"] = true
|
||||
if emailVerificationRequired {
|
||||
completionResponse["choice_reason"] = "email_verification_required"
|
||||
}
|
||||
delete(completionResponse, "email")
|
||||
delete(completionResponse, "resolved_email")
|
||||
resolvedChoiceEmail = ""
|
||||
}
|
||||
|
||||
var targetUserID *int64
|
||||
if compatEmailUser != nil && compatEmailUser.ID > 0 {
|
||||
|
||||
@@ -631,6 +631,108 @@ func TestLinuxDoOAuthCallbackCreatesChoicePendingSessionWhenSignupRequiresInvite
|
||||
require.Equal(t, "third_party_signup", completion["choice_reason"])
|
||||
}
|
||||
|
||||
func TestLinuxDoOAuthCallbackEmailVerificationCompletesWithBoundEmail(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/token":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"access_token":"linuxdo-access","token_type":"Bearer","expires_in":3600}`))
|
||||
case "/userinfo":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"id":"email-verify-123","username":"linuxdo_email","name":"Email Verify","avatar_url":"https://cdn.example/email.png"}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
handler, client := newLinuxDoOAuthHandlerAndClientWithEmailVerification(t, false, "fresh@example.com", "246810", config.LinuxDoConnectConfig{
|
||||
Enabled: true,
|
||||
ClientID: "linuxdo-client",
|
||||
ClientSecret: "linuxdo-secret",
|
||||
AuthorizeURL: upstream.URL + "/authorize",
|
||||
TokenURL: upstream.URL + "/token",
|
||||
UserInfoURL: upstream.URL + "/userinfo",
|
||||
Scopes: "read",
|
||||
RedirectURL: "https://api.example.com/api/v1/auth/oauth/linuxdo/callback",
|
||||
FrontendRedirectURL: "/auth/linuxdo/callback",
|
||||
TokenAuthMethod: "client_secret_post",
|
||||
UsePKCE: true,
|
||||
})
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/linuxdo/callback?code=code-email&state=state-email", nil)
|
||||
req.AddCookie(encodedCookie(linuxDoOAuthStateCookieName, "state-email"))
|
||||
req.AddCookie(encodedCookie(linuxDoOAuthRedirectCookie, "/dashboard"))
|
||||
req.AddCookie(encodedCookie(linuxDoOAuthVerifierCookie, "verifier-email"))
|
||||
req.AddCookie(encodedCookie(linuxDoOAuthIntentCookieName, oauthIntentLogin))
|
||||
req.AddCookie(encodedCookie(oauthPendingBrowserCookieName, "browser-email"))
|
||||
c.Request = req
|
||||
|
||||
handler.LinuxDoOAuthCallback(c)
|
||||
|
||||
require.Equal(t, http.StatusFound, recorder.Code)
|
||||
require.Equal(t, "/auth/linuxdo/callback", recorder.Header().Get("Location"))
|
||||
sessionCookie := findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName)
|
||||
require.NotNil(t, sessionCookie)
|
||||
|
||||
ctx := context.Background()
|
||||
session, err := client.PendingAuthSession.Query().
|
||||
Where(pendingauthsession.SessionTokenEQ(decodeCookieValueForTest(t, sessionCookie.Value))).
|
||||
Only(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, oauthIntentLogin, session.Intent)
|
||||
require.Nil(t, session.TargetUserID)
|
||||
require.Empty(t, session.ResolvedEmail)
|
||||
require.Equal(t, "linuxdo-email-verify-123@linuxdo-connect.invalid", session.UpstreamIdentityClaims["email"])
|
||||
|
||||
completion, ok := session.LocalFlowState[oauthCompletionResponseKey].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "create_account_required", completion["step"])
|
||||
require.Equal(t, true, completion["email_binding_required"])
|
||||
require.Equal(t, true, completion["force_email_on_signup"])
|
||||
require.Equal(t, "email_verification_required", completion["choice_reason"])
|
||||
require.NotContains(t, completion, "email")
|
||||
require.NotContains(t, completion, "resolved_email")
|
||||
|
||||
createRecorder := httptest.NewRecorder()
|
||||
createCtx, _ := gin.CreateTestContext(createRecorder)
|
||||
body := bytes.NewBufferString(`{"email":"fresh@example.com","verify_code":"246810","password":"secret-123","adopt_display_name":false,"adopt_avatar":false}`)
|
||||
createReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/pending/create-account", body)
|
||||
createReq.Header.Set("Content-Type", "application/json")
|
||||
createReq.AddCookie(sessionCookie)
|
||||
createReq.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("browser-email")})
|
||||
createCtx.Request = createReq
|
||||
|
||||
handler.CreatePendingOAuthAccount(createCtx)
|
||||
|
||||
require.Equal(t, http.StatusOK, createRecorder.Code)
|
||||
responseData := decodeJSONBody(t, createRecorder)
|
||||
require.NotEmpty(t, responseData["access_token"])
|
||||
|
||||
userEntity, err := client.User.Query().
|
||||
Where(dbuser.EmailEQ("fresh@example.com")).
|
||||
Only(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "linuxdo", userEntity.SignupSource)
|
||||
|
||||
identity, err := client.AuthIdentity.Query().
|
||||
Where(
|
||||
authidentity.ProviderTypeEQ("linuxdo"),
|
||||
authidentity.ProviderKeyEQ("linuxdo"),
|
||||
authidentity.ProviderSubjectEQ("email-verify-123"),
|
||||
).
|
||||
Only(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, userEntity.ID, identity.UserID)
|
||||
|
||||
storedSession, err := client.PendingAuthSession.Get(ctx, session.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, storedSession.ConsumedAt)
|
||||
}
|
||||
|
||||
func TestLinuxDoOAuthCallbackDirectlyLogsInNewUserWhenEmailVerificationDisabled(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
@@ -1103,6 +1205,24 @@ func newLinuxDoOAuthTestHandler(t *testing.T, invitationEnabled bool, oauthCfg c
|
||||
func newLinuxDoOAuthHandlerAndClient(t *testing.T, invitationEnabled bool, oauthCfg config.LinuxDoConnectConfig) (*AuthHandler, *dbent.Client) {
|
||||
t.Helper()
|
||||
handler, client := newOAuthPendingFlowTestHandler(t, invitationEnabled)
|
||||
configureLinuxDoOAuthTestHandler(handler, oauthCfg)
|
||||
return handler, client
|
||||
}
|
||||
|
||||
func newLinuxDoOAuthHandlerAndClientWithEmailVerification(
|
||||
t *testing.T,
|
||||
invitationEnabled bool,
|
||||
email string,
|
||||
code string,
|
||||
oauthCfg config.LinuxDoConnectConfig,
|
||||
) (*AuthHandler, *dbent.Client) {
|
||||
t.Helper()
|
||||
handler, client := newOAuthPendingFlowTestHandlerWithEmailVerification(t, invitationEnabled, email, code)
|
||||
configureLinuxDoOAuthTestHandler(handler, oauthCfg)
|
||||
return handler, client
|
||||
}
|
||||
|
||||
func configureLinuxDoOAuthTestHandler(handler *AuthHandler, oauthCfg config.LinuxDoConnectConfig) {
|
||||
handler.settingSvc = nil
|
||||
handler.cfg = &config.Config{
|
||||
JWT: config.JWTConfig{
|
||||
@@ -1113,5 +1233,4 @@ func newLinuxDoOAuthHandlerAndClient(t *testing.T, invitationEnabled bool, oauth
|
||||
},
|
||||
LinuxDo: oauthCfg,
|
||||
}
|
||||
return handler, client
|
||||
}
|
||||
|
||||
@@ -50,15 +50,19 @@ func (h *AvailableChannelHandler) featureEnabled(c *gin.Context) bool {
|
||||
// userAvailableGroup 用户可见的分组概要(白名单字段)。
|
||||
//
|
||||
// 前端据此区分专属 vs 公开分组(IsExclusive)、订阅 vs 标准分组(SubscriptionType,
|
||||
// 订阅视觉加深),并用 RateMultiplier 作为默认倍率;用户专属倍率前端走
|
||||
// 订阅视觉加深),并展示默认倍率与高峰倍率规则;用户专属倍率前端走
|
||||
// /groups/rates,和 API 密钥页面保持一致。
|
||||
type userAvailableGroup struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Platform string `json:"platform"`
|
||||
SubscriptionType string `json:"subscription_type"`
|
||||
RateMultiplier float64 `json:"rate_multiplier"`
|
||||
IsExclusive bool `json:"is_exclusive"`
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Platform string `json:"platform"`
|
||||
SubscriptionType string `json:"subscription_type"`
|
||||
RateMultiplier float64 `json:"rate_multiplier"`
|
||||
PeakRateEnabled bool `json:"peak_rate_enabled"`
|
||||
PeakStart string `json:"peak_start"`
|
||||
PeakEnd string `json:"peak_end"`
|
||||
PeakRateMultiplier float64 `json:"peak_rate_multiplier"`
|
||||
IsExclusive bool `json:"is_exclusive"`
|
||||
}
|
||||
|
||||
// userSupportedModelPricing 用户可见的定价字段白名单。
|
||||
@@ -213,12 +217,16 @@ func filterUserVisibleGroups(
|
||||
continue
|
||||
}
|
||||
visible = append(visible, userAvailableGroup{
|
||||
ID: g.ID,
|
||||
Name: g.Name,
|
||||
Platform: g.Platform,
|
||||
SubscriptionType: g.SubscriptionType,
|
||||
RateMultiplier: g.RateMultiplier,
|
||||
IsExclusive: g.IsExclusive,
|
||||
ID: g.ID,
|
||||
Name: g.Name,
|
||||
Platform: g.Platform,
|
||||
SubscriptionType: g.SubscriptionType,
|
||||
RateMultiplier: g.RateMultiplier,
|
||||
PeakRateEnabled: g.PeakRateEnabled,
|
||||
PeakStart: g.PeakStart,
|
||||
PeakEnd: g.PeakEnd,
|
||||
PeakRateMultiplier: g.PeakRateMultiplier,
|
||||
IsExclusive: g.IsExclusive,
|
||||
})
|
||||
}
|
||||
return visible
|
||||
|
||||
@@ -101,13 +101,13 @@ func TestUserAvailableChannel_FieldWhitelist(t *testing.T) {
|
||||
require.Truef(t, exists, "platform section must expose %q", key)
|
||||
}
|
||||
|
||||
// Group DTO 暴露区分专属/公开、订阅类型、默认倍率所需的字段,
|
||||
// Group DTO 暴露区分专属/公开、订阅类型、默认倍率和高峰倍率规则所需的字段,
|
||||
// 前端据此渲染 GroupBadge 并与 API 密钥页保持一致的视觉。
|
||||
rawGroup, err := json.Marshal(row.Platforms[0].Groups[0])
|
||||
require.NoError(t, err)
|
||||
var groupDecoded map[string]any
|
||||
require.NoError(t, json.Unmarshal(rawGroup, &groupDecoded))
|
||||
for _, key := range []string{"id", "name", "platform", "subscription_type", "rate_multiplier", "is_exclusive"} {
|
||||
for _, key := range []string{"id", "name", "platform", "subscription_type", "rate_multiplier", "peak_rate_enabled", "peak_start", "peak_end", "peak_rate_multiplier", "is_exclusive"} {
|
||||
_, exists := groupDecoded[key]
|
||||
require.Truef(t, exists, "group DTO must expose %q", key)
|
||||
}
|
||||
|
||||
@@ -181,6 +181,10 @@ func groupFromServiceBase(g *service.Group) Group {
|
||||
AllowImageGeneration: g.AllowImageGeneration,
|
||||
ImageRateIndependent: g.ImageRateIndependent,
|
||||
ImageRateMultiplier: g.ImageRateMultiplier,
|
||||
PeakRateEnabled: g.PeakRateEnabled,
|
||||
PeakStart: g.PeakStart,
|
||||
PeakEnd: g.PeakEnd,
|
||||
PeakRateMultiplier: g.PeakRateMultiplier,
|
||||
ImagePrice1K: g.ImagePrice1K,
|
||||
ImagePrice2K: g.ImagePrice2K,
|
||||
ImagePrice4K: g.ImagePrice4K,
|
||||
@@ -234,6 +238,8 @@ func AccountFromServiceShallow(a *service.Account) *Account {
|
||||
SessionWindowEnd: a.SessionWindowEnd,
|
||||
SessionWindowStatus: a.SessionWindowStatus,
|
||||
GroupIDs: a.GroupIDs,
|
||||
ParentAccountID: a.ParentAccountID,
|
||||
QuotaDimension: a.QuotaDimension,
|
||||
}
|
||||
|
||||
// 提取 5h 窗口费用控制和会话数量控制配置(仅 Anthropic OAuth/SetupToken 账号有效)
|
||||
@@ -573,7 +579,7 @@ func AccountSummaryFromService(a *service.Account) *AccountSummary {
|
||||
}
|
||||
|
||||
func usageLogFromServiceUser(l *service.UsageLog) UsageLog {
|
||||
// 普通用户 DTO:严禁包含管理员字段(例如 account_rate_multiplier、ip_address、account)。
|
||||
// 普通用户 DTO:严禁包含管理员字段(例如 account_rate_multiplier、account、upstream_model)。
|
||||
requestType := l.EffectiveRequestType()
|
||||
stream, openAIWSMode := service.ApplyLegacyRequestFields(requestType, l.Stream, l.OpenAIWSMode)
|
||||
requestedModel := l.RequestedModel
|
||||
@@ -590,7 +596,6 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog {
|
||||
ServiceTier: l.ServiceTier,
|
||||
ReasoningEffort: l.ReasoningEffort,
|
||||
InboundEndpoint: l.InboundEndpoint,
|
||||
UpstreamEndpoint: l.UpstreamEndpoint,
|
||||
GroupID: l.GroupID,
|
||||
SubscriptionID: l.SubscriptionID,
|
||||
InputTokens: l.InputTokens,
|
||||
@@ -622,6 +627,7 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog {
|
||||
ImageSizeBreakdown: l.ImageSizeBreakdown,
|
||||
MediaType: l.MediaType,
|
||||
UserAgent: l.UserAgent,
|
||||
IPAddress: l.IPAddress,
|
||||
CacheTTLOverridden: l.CacheTTLOverridden,
|
||||
BillingMode: l.BillingMode,
|
||||
CreatedAt: l.CreatedAt,
|
||||
@@ -633,7 +639,7 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog {
|
||||
}
|
||||
|
||||
// UsageLogFromService converts a service UsageLog to DTO for regular users.
|
||||
// It excludes Account details and IP address - users should not see these.
|
||||
// It excludes admin-only account/upstream internals while keeping user billing and request metadata.
|
||||
func UsageLogFromService(l *service.UsageLog) *UsageLog {
|
||||
if l == nil {
|
||||
return nil
|
||||
@@ -648,8 +654,10 @@ func UsageLogFromServiceAdmin(l *service.UsageLog) *AdminUsageLog {
|
||||
if l == nil {
|
||||
return nil
|
||||
}
|
||||
usageLog := usageLogFromServiceUser(l)
|
||||
usageLog.UpstreamEndpoint = l.UpstreamEndpoint
|
||||
return &AdminUsageLog{
|
||||
UsageLog: usageLogFromServiceUser(l),
|
||||
UsageLog: usageLog,
|
||||
UpstreamModel: l.UpstreamModel,
|
||||
ChannelID: l.ChannelID,
|
||||
ModelMappingChain: l.ModelMappingChain,
|
||||
@@ -751,6 +759,7 @@ func userSubscriptionFromServiceBase(sub *service.UserSubscription) UserSubscrip
|
||||
MonthlyUsageUSD: sub.MonthlyUsageUSD,
|
||||
CreatedAt: sub.CreatedAt,
|
||||
UpdatedAt: sub.UpdatedAt,
|
||||
RevokedAt: sub.DeletedAt,
|
||||
User: UserFromServiceShallow(sub.User),
|
||||
Group: GroupFromServiceShallow(sub.Group),
|
||||
}
|
||||
|
||||
@@ -95,8 +95,7 @@ func TestUsageLogFromService_IncludesServiceTierForUserAndAdmin(t *testing.T) {
|
||||
require.Equal(t, serviceTier, *userDTO.ServiceTier)
|
||||
require.NotNil(t, userDTO.InboundEndpoint)
|
||||
require.Equal(t, inboundEndpoint, *userDTO.InboundEndpoint)
|
||||
require.NotNil(t, userDTO.UpstreamEndpoint)
|
||||
require.Equal(t, upstreamEndpoint, *userDTO.UpstreamEndpoint)
|
||||
require.Nil(t, userDTO.UpstreamEndpoint)
|
||||
require.NotNil(t, adminDTO.ServiceTier)
|
||||
require.Equal(t, serviceTier, *adminDTO.ServiceTier)
|
||||
require.NotNil(t, adminDTO.InboundEndpoint)
|
||||
@@ -133,6 +132,45 @@ func TestUsageLogFromService_UsesRequestedModelAndKeepsUpstreamAdminOnly(t *test
|
||||
require.Contains(t, string(adminJSON), `"upstream_model":"claude-sonnet-4-20250514"`)
|
||||
}
|
||||
|
||||
func TestUsageLogFromService_KeepsUserBillingAndIPWithoutAdminCostFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ipAddress := "203.0.113.10"
|
||||
accountRateMultiplier := 1.5
|
||||
accountStatsCost := 0.21
|
||||
log := &service.UsageLog{
|
||||
RequestID: "req_user_visible_billing",
|
||||
Model: "gpt-5.4",
|
||||
InputCost: 0.01,
|
||||
OutputCost: 0.02,
|
||||
CacheCreationCost: 0.03,
|
||||
CacheReadCost: 0.04,
|
||||
TotalCost: 0.10,
|
||||
ActualCost: 0.08,
|
||||
RateMultiplier: 0.8,
|
||||
IPAddress: &ipAddress,
|
||||
AccountRateMultiplier: &accountRateMultiplier,
|
||||
AccountStatsCost: &accountStatsCost,
|
||||
}
|
||||
|
||||
userDTO := UsageLogFromService(log)
|
||||
require.Equal(t, 0.01, userDTO.InputCost)
|
||||
require.Equal(t, 0.02, userDTO.OutputCost)
|
||||
require.Equal(t, 0.03, userDTO.CacheCreationCost)
|
||||
require.Equal(t, 0.04, userDTO.CacheReadCost)
|
||||
require.Equal(t, 0.10, userDTO.TotalCost)
|
||||
require.Equal(t, 0.08, userDTO.ActualCost)
|
||||
require.Equal(t, 0.8, userDTO.RateMultiplier)
|
||||
require.NotNil(t, userDTO.IPAddress)
|
||||
require.Equal(t, ipAddress, *userDTO.IPAddress)
|
||||
|
||||
userJSON, err := json.Marshal(userDTO)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, string(userJSON), "account_rate_multiplier")
|
||||
require.NotContains(t, string(userJSON), "account_stats_cost")
|
||||
require.NotContains(t, string(userJSON), "account_cost")
|
||||
}
|
||||
|
||||
func TestUsageLogFromService_FallsBackToLegacyModelWhenRequestedModelMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -186,9 +186,17 @@ type SystemSettings struct {
|
||||
ClaudeOAuthSystemPromptBlocks string `json:"claude_oauth_system_prompt_blocks"`
|
||||
EnableAnthropicCacheTTL1hInjection bool `json:"enable_anthropic_cache_ttl_1h_injection"`
|
||||
RewriteMessageCacheControl bool `json:"rewrite_message_cache_control"`
|
||||
EnableClientDatelineNormalization bool `json:"enable_client_dateline_normalization"`
|
||||
AntigravityUserAgentVersion string `json:"antigravity_user_agent_version"`
|
||||
OpenAICodexUserAgent string `json:"openai_codex_user_agent"`
|
||||
OpenAIAllowClaudeCodeCodexPlugin bool `json:"openai_allow_claude_code_codex_plugin"`
|
||||
|
||||
// codex_cli_only 加固
|
||||
MinCodexVersion string `json:"min_codex_version"`
|
||||
MaxCodexVersion string `json:"max_codex_version"`
|
||||
CodexCLIOnlyBlacklist string `json:"codex_cli_only_blacklist"`
|
||||
CodexCLIOnlyWhitelist string `json:"codex_cli_only_whitelist"`
|
||||
CodexCLIOnlyAllowAppServerClients bool `json:"codex_cli_only_allow_app_server_clients"`
|
||||
CodexCLIOnlyEngineFingerprintSignals string `json:"codex_cli_only_engine_fingerprint_signals"`
|
||||
|
||||
// Web Search Emulation
|
||||
WebSearchEmulationEnabled bool `json:"web_search_emulation_enabled"`
|
||||
@@ -200,7 +208,29 @@ type SystemSettings struct {
|
||||
PaymentVisibleMethodWxpayEnabled bool `json:"payment_visible_method_wxpay_enabled"`
|
||||
|
||||
// OpenAI account scheduling
|
||||
OpenAIAdvancedSchedulerEnabled bool `json:"openai_advanced_scheduler_enabled"`
|
||||
OpenAIAdvancedSchedulerEnabled bool `json:"openai_advanced_scheduler_enabled"`
|
||||
OpenAIAdvancedSchedulerStickyWeightedEnabled bool `json:"openai_advanced_scheduler_sticky_weighted_enabled"`
|
||||
OpenAIAdvancedSchedulerSubscriptionPriorityEnabled bool `json:"openai_advanced_scheduler_subscription_priority_enabled"`
|
||||
OpenAIAdvancedSchedulerLBTopK string `json:"openai_advanced_scheduler_lb_top_k"`
|
||||
OpenAIAdvancedSchedulerWeightPriority string `json:"openai_advanced_scheduler_weight_priority"`
|
||||
OpenAIAdvancedSchedulerWeightLoad string `json:"openai_advanced_scheduler_weight_load"`
|
||||
OpenAIAdvancedSchedulerWeightQueue string `json:"openai_advanced_scheduler_weight_queue"`
|
||||
OpenAIAdvancedSchedulerWeightErrorRate string `json:"openai_advanced_scheduler_weight_error_rate"`
|
||||
OpenAIAdvancedSchedulerWeightTTFT string `json:"openai_advanced_scheduler_weight_ttft"`
|
||||
OpenAIAdvancedSchedulerWeightReset string `json:"openai_advanced_scheduler_weight_reset"`
|
||||
OpenAIAdvancedSchedulerWeightQuotaHeadroom string `json:"openai_advanced_scheduler_weight_quota_headroom"`
|
||||
OpenAIAdvancedSchedulerWeightPreviousResponse string `json:"openai_advanced_scheduler_weight_previous_response"`
|
||||
OpenAIAdvancedSchedulerWeightSessionSticky string `json:"openai_advanced_scheduler_weight_session_sticky"`
|
||||
OpenAIAdvancedSchedulerEffectiveLBTopK string `json:"openai_advanced_scheduler_effective_lb_top_k"`
|
||||
OpenAIAdvancedSchedulerEffectiveWeightPriority string `json:"openai_advanced_scheduler_effective_weight_priority"`
|
||||
OpenAIAdvancedSchedulerEffectiveWeightLoad string `json:"openai_advanced_scheduler_effective_weight_load"`
|
||||
OpenAIAdvancedSchedulerEffectiveWeightQueue string `json:"openai_advanced_scheduler_effective_weight_queue"`
|
||||
OpenAIAdvancedSchedulerEffectiveWeightErrorRate string `json:"openai_advanced_scheduler_effective_weight_error_rate"`
|
||||
OpenAIAdvancedSchedulerEffectiveWeightTTFT string `json:"openai_advanced_scheduler_effective_weight_ttft"`
|
||||
OpenAIAdvancedSchedulerEffectiveWeightReset string `json:"openai_advanced_scheduler_effective_weight_reset"`
|
||||
OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom string `json:"openai_advanced_scheduler_effective_weight_quota_headroom"`
|
||||
OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse string `json:"openai_advanced_scheduler_effective_weight_previous_response"`
|
||||
OpenAIAdvancedSchedulerEffectiveWeightSessionSticky string `json:"openai_advanced_scheduler_effective_weight_session_sticky"`
|
||||
|
||||
// Payment configuration
|
||||
PaymentEnabled bool `json:"payment_enabled"`
|
||||
@@ -212,6 +242,7 @@ type SystemSettings struct {
|
||||
PaymentEnabledTypes []string `json:"payment_enabled_types"`
|
||||
PaymentBalanceDisabled bool `json:"payment_balance_disabled"`
|
||||
PaymentBalanceRechargeMultiplier float64 `json:"payment_balance_recharge_multiplier"`
|
||||
PaymentSubscriptionUSDToCNYRate float64 `json:"payment_subscription_usd_to_cny_rate"`
|
||||
PaymentRechargeFeeRate float64 `json:"payment_recharge_fee_rate"`
|
||||
PaymentLoadBalanceStrat string `json:"payment_load_balance_strategy"`
|
||||
PaymentProductNamePrefix string `json:"payment_product_name_prefix"`
|
||||
@@ -313,10 +344,14 @@ type PublicSettings struct {
|
||||
BackendModeEnabled bool `json:"backend_mode_enabled"`
|
||||
PaymentEnabled bool `json:"payment_enabled"`
|
||||
Version string `json:"version"`
|
||||
BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"`
|
||||
AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"`
|
||||
BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"`
|
||||
BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"`
|
||||
// 服务器全局时区(IANA 名称与当前 UTC 偏移,如 "Asia/Shanghai" / "+08:00")。
|
||||
// 高峰时段等按服务器本地时间判定的窗口,前端展示时据此标注,避免用户按浏览器本地时间误读。
|
||||
ServerTimezone string `json:"server_timezone"`
|
||||
ServerUTCOffset string `json:"server_utc_offset"`
|
||||
BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"`
|
||||
AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"`
|
||||
BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"`
|
||||
BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"`
|
||||
|
||||
ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
|
||||
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
|
||||
|
||||
@@ -97,12 +97,17 @@ type Group struct {
|
||||
MonthlyLimitUSD *float64 `json:"monthly_limit_usd"`
|
||||
|
||||
// 图片生成计费配置(仅 antigravity 平台使用)
|
||||
AllowImageGeneration bool `json:"allow_image_generation"`
|
||||
ImageRateIndependent bool `json:"image_rate_independent"`
|
||||
ImageRateMultiplier float64 `json:"image_rate_multiplier"`
|
||||
ImagePrice1K *float64 `json:"image_price_1k"`
|
||||
ImagePrice2K *float64 `json:"image_price_2k"`
|
||||
ImagePrice4K *float64 `json:"image_price_4k"`
|
||||
AllowImageGeneration bool `json:"allow_image_generation"`
|
||||
ImageRateIndependent bool `json:"image_rate_independent"`
|
||||
ImageRateMultiplier float64 `json:"image_rate_multiplier"`
|
||||
// 高峰时段倍率配置
|
||||
PeakRateEnabled bool `json:"peak_rate_enabled"`
|
||||
PeakStart string `json:"peak_start"`
|
||||
PeakEnd string `json:"peak_end"`
|
||||
PeakRateMultiplier float64 `json:"peak_rate_multiplier"`
|
||||
ImagePrice1K *float64 `json:"image_price_1k"`
|
||||
ImagePrice2K *float64 `json:"image_price_2k"`
|
||||
ImagePrice4K *float64 `json:"image_price_4k"`
|
||||
|
||||
// Claude Code 客户端限制
|
||||
ClaudeCodeOnly bool `json:"claude_code_only"`
|
||||
@@ -253,6 +258,17 @@ type Account struct {
|
||||
QuotaNotifyTotalEnabled *bool `json:"quota_notify_total_enabled,omitempty"`
|
||||
QuotaNotifyTotalThreshold *float64 `json:"quota_notify_total_threshold,omitempty"`
|
||||
|
||||
// 影子账号关系(spark 维度影子)
|
||||
ParentAccountID *int64 `json:"parent_account_id,omitempty"`
|
||||
QuotaDimension string `json:"quota_dimension,omitempty"`
|
||||
|
||||
// 影子账号回填的母账号信息(仅影子非空,源自母账号 Credentials/Extra)
|
||||
ParentEmail string `json:"parent_email,omitempty"`
|
||||
ParentPlanType string `json:"parent_plan_type,omitempty"`
|
||||
ParentPrivacyMode string `json:"parent_privacy_mode,omitempty"`
|
||||
ParentSubscriptionExpiresAt string `json:"parent_subscription_expires_at,omitempty"`
|
||||
ParentChatGPTAccountID string `json:"parent_chatgpt_account_id,omitempty"`
|
||||
|
||||
Proxy *Proxy `json:"proxy,omitempty"`
|
||||
AccountGroups []AccountGroup `json:"account_groups,omitempty"`
|
||||
|
||||
@@ -480,6 +496,8 @@ type UsageLog struct {
|
||||
|
||||
// User-Agent
|
||||
UserAgent *string `json:"user_agent"`
|
||||
// IPAddress is visible to the owner of the usage record.
|
||||
IPAddress *string `json:"ip_address,omitempty"`
|
||||
|
||||
// Cache TTL Override 标记
|
||||
CacheTTLOverridden bool `json:"cache_ttl_overridden"`
|
||||
@@ -515,7 +533,7 @@ type AdminUsageLog struct {
|
||||
// AccountStatsCost 自定义定价规则计算的账号统计费用(nil 表示使用默认公式)
|
||||
AccountStatsCost *float64 `json:"account_stats_cost,omitempty"`
|
||||
|
||||
// IPAddress 用户请求 IP(仅管理员可见)
|
||||
// IPAddress 用户请求 IP
|
||||
IPAddress *string `json:"ip_address,omitempty"`
|
||||
|
||||
// Account 最小账号信息(避免泄露敏感字段)
|
||||
@@ -581,8 +599,9 @@ type UserSubscription struct {
|
||||
WeeklyUsageUSD float64 `json:"weekly_usage_usd"`
|
||||
MonthlyUsageUSD float64 `json:"monthly_usage_usd"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
RevokedAt *time.Time `json:"revoked_at,omitempty"`
|
||||
|
||||
User *User `json:"user,omitempty"`
|
||||
Group *Group `json:"group,omitempty"`
|
||||
|
||||
@@ -21,6 +21,8 @@ const (
|
||||
EndpointResponses = "/v1/responses"
|
||||
EndpointImagesGenerations = "/v1/images/generations"
|
||||
EndpointImagesEdits = "/v1/images/edits"
|
||||
EndpointVideosGenerations = "/v1/videos/generations"
|
||||
EndpointVideos = "/v1/videos"
|
||||
EndpointGeminiModels = "/v1beta/models"
|
||||
)
|
||||
|
||||
@@ -53,6 +55,10 @@ func NormalizeInboundEndpoint(path string) string {
|
||||
return EndpointImagesGenerations
|
||||
case strings.Contains(path, EndpointImagesEdits) || strings.Contains(path, "/images/edits"):
|
||||
return EndpointImagesEdits
|
||||
case strings.Contains(path, EndpointVideosGenerations) || strings.Contains(path, "/videos/generations"):
|
||||
return EndpointVideosGenerations
|
||||
case strings.Contains(path, EndpointVideos) || strings.Contains(path, "/videos/"):
|
||||
return EndpointVideos
|
||||
case strings.Contains(path, EndpointResponses):
|
||||
return EndpointResponses
|
||||
case strings.Contains(path, EndpointGeminiModels):
|
||||
@@ -77,8 +83,8 @@ func DeriveUpstreamEndpoint(inbound, rawRequestPath, platform string) string {
|
||||
inbound = strings.TrimSpace(inbound)
|
||||
|
||||
switch platform {
|
||||
case service.PlatformOpenAI:
|
||||
if inbound == EndpointEmbeddings || inbound == EndpointImagesGenerations || inbound == EndpointImagesEdits {
|
||||
case service.PlatformOpenAI, service.PlatformGrok:
|
||||
if inbound == EndpointEmbeddings || inbound == EndpointImagesGenerations || inbound == EndpointImagesEdits || inbound == EndpointVideosGenerations || inbound == EndpointVideos {
|
||||
return inbound
|
||||
}
|
||||
// OpenAI forwards everything to the Responses API.
|
||||
|
||||
@@ -28,6 +28,8 @@ func TestNormalizeInboundEndpoint(t *testing.T) {
|
||||
{"/v1/responses", EndpointResponses},
|
||||
{"/v1/images/generations", EndpointImagesGenerations},
|
||||
{"/v1/images/edits", EndpointImagesEdits},
|
||||
{"/v1/videos/generations", EndpointVideosGenerations},
|
||||
{"/v1/videos/req_123", EndpointVideos},
|
||||
{"/v1beta/models", EndpointGeminiModels},
|
||||
|
||||
// Prefixed paths (antigravity, openai).
|
||||
@@ -81,6 +83,8 @@ func TestDeriveUpstreamEndpoint(t *testing.T) {
|
||||
{"openai embeddings", EndpointEmbeddings, "/v1/embeddings", service.PlatformOpenAI, EndpointEmbeddings},
|
||||
{"openai image generations", EndpointImagesGenerations, "/v1/images/generations", service.PlatformOpenAI, EndpointImagesGenerations},
|
||||
{"openai image edits", EndpointImagesEdits, "/openai/v1/images/edits", service.PlatformOpenAI, EndpointImagesEdits},
|
||||
{"grok video generations", EndpointVideosGenerations, "/v1/videos/generations", service.PlatformGrok, EndpointVideosGenerations},
|
||||
{"grok video status", EndpointVideos, "/videos/req_123", service.PlatformGrok, EndpointVideos},
|
||||
|
||||
// Antigravity — uses inbound to pick Claude vs Gemini upstream.
|
||||
{"antigravity claude", EndpointMessages, "/antigravity/v1/messages", service.PlatformAntigravity, EndpointMessages},
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
|
||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
@@ -301,14 +302,22 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, sessionKey, reqModel, fs.FailedAccountIDs, "", int64(0)) // Gemini 不使用会话限制
|
||||
if err != nil {
|
||||
if len(fs.FailedAccountIDs) == 0 {
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, service.PlatformGemini)
|
||||
if !cls.ModelNotFound {
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
}
|
||||
reqLog.Warn("gateway.select_account_no_available",
|
||||
zap.String("model", reqModel),
|
||||
zap.Int64p("group_id", apiKey.GroupID),
|
||||
zap.String("platform", platform),
|
||||
zap.Bool("model_not_found", cls.ModelNotFound),
|
||||
zap.Error(err),
|
||||
)
|
||||
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error(), streamStarted)
|
||||
message := cls.Message
|
||||
if !cls.ModelNotFound {
|
||||
message = "No available accounts: " + err.Error()
|
||||
}
|
||||
h.handleStreamingAwareError(c, cls.Status, cls.ErrType, message, streamStarted)
|
||||
return
|
||||
}
|
||||
action := fs.HandleSelectionExhausted(c.Request.Context())
|
||||
@@ -578,15 +587,23 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), currentAPIKey.GroupID, sessionKey, reqModel, fs.FailedAccountIDs, parsedReq.MetadataUserID, subject.UserID)
|
||||
if err != nil {
|
||||
if len(fs.FailedAccountIDs) == 0 {
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, currentAPIKey, reqModel, reqModel, platform)
|
||||
if !cls.ModelNotFound {
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
}
|
||||
reqLog.Warn("gateway.select_account_no_available",
|
||||
zap.String("model", reqModel),
|
||||
zap.Int64p("group_id", currentAPIKey.GroupID),
|
||||
zap.String("platform", platform),
|
||||
zap.Bool("fallback_used", fallbackUsed),
|
||||
zap.Bool("model_not_found", cls.ModelNotFound),
|
||||
zap.Error(err),
|
||||
)
|
||||
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error(), streamStarted)
|
||||
message := cls.Message
|
||||
if !cls.ModelNotFound {
|
||||
message = "No available accounts: " + err.Error()
|
||||
}
|
||||
h.handleStreamingAwareError(c, cls.Status, cls.ErrType, message, streamStarted)
|
||||
return
|
||||
}
|
||||
action := fs.HandleSelectionExhausted(c.Request.Context())
|
||||
@@ -989,7 +1006,8 @@ func (h *GatewayHandler) Models(c *gin.Context) {
|
||||
// Get available models from account configurations for the selected group platform.
|
||||
availableModels := h.gatewayService.GetAvailableModels(c.Request.Context(), groupID, platform)
|
||||
if apiKey != nil && apiKey.Group != nil && apiKey.Group.CustomModelsListEnabled() {
|
||||
availableModels = filterModelsByCustomList(availableModels, defaultModelIDsForPlatform(platform), apiKey.Group.ModelsListConfig.Models)
|
||||
fallbackModels := defaultModelIDsForPlatform(platform)
|
||||
availableModels = filterModelsByCustomList(customModelsListSource(platform, availableModels, fallbackModels), fallbackModels, apiKey.Group.ModelsListConfig.Models)
|
||||
writeCustomModelsList(c, platform, availableModels)
|
||||
return
|
||||
}
|
||||
@@ -1073,6 +1091,13 @@ func writeOpenAIModelsList(c *gin.Context, modelIDs []string) {
|
||||
})
|
||||
}
|
||||
|
||||
func customModelsListSource(platform string, availableModels, fallbackModels []string) []string {
|
||||
if platform == service.PlatformAnthropic && len(availableModels) > 0 {
|
||||
return mergeModelIDs(availableModels, fallbackModels)
|
||||
}
|
||||
return availableModels
|
||||
}
|
||||
|
||||
func filterModelsByCustomList(availableModels, fallbackModels, selectedModels []string) []string {
|
||||
if len(selectedModels) == 0 {
|
||||
return availableModels
|
||||
@@ -1141,6 +1166,17 @@ func defaultModelIDsForPlatform(platform string) []string {
|
||||
ids = append(ids, model.ID)
|
||||
}
|
||||
return ids
|
||||
case service.PlatformAnthropic:
|
||||
ids := make([]string, 0, len(claude.DefaultModels)+len(antigravity.DefaultModels()))
|
||||
for _, model := range claude.DefaultModels {
|
||||
ids = append(ids, model.ID)
|
||||
}
|
||||
for _, model := range antigravity.DefaultModels() {
|
||||
ids = append(ids, model.ID)
|
||||
}
|
||||
return mergeModelIDs(ids, nil)
|
||||
case service.PlatformGrok:
|
||||
return xai.DefaultModelIDs()
|
||||
default:
|
||||
ids := make([]string, 0, len(claude.DefaultModels))
|
||||
for _, model := range claude.DefaultModels {
|
||||
@@ -1150,6 +1186,25 @@ func defaultModelIDsForPlatform(platform string) []string {
|
||||
}
|
||||
}
|
||||
|
||||
func mergeModelIDs(primary, secondary []string) []string {
|
||||
seen := make(map[string]struct{}, len(primary)+len(secondary))
|
||||
merged := make([]string, 0, len(primary)+len(secondary))
|
||||
for _, models := range [][]string{primary, secondary} {
|
||||
for _, model := range models {
|
||||
model = strings.TrimSpace(model)
|
||||
if model == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[model]; ok {
|
||||
continue
|
||||
}
|
||||
seen[model] = struct{}{}
|
||||
merged = append(merged, model)
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// AntigravityModels 返回 Antigravity 支持的全部模型
|
||||
// GET /antigravity/models
|
||||
func (h *GatewayHandler) AntigravityModels(c *gin.Context) {
|
||||
@@ -1785,8 +1840,11 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) {
|
||||
account, err := h.gatewayService.SelectAccountForModel(c.Request.Context(), apiKey.GroupID, sessionHash, parsedReq.Model)
|
||||
if err != nil {
|
||||
reqLog.Warn("gateway.count_tokens_select_account_failed", zap.Error(err))
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "Service temporarily unavailable")
|
||||
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, parsedReq.Model, parsedReq.Model, service.PlatformAnthropic)
|
||||
if !cls.ModelNotFound {
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
}
|
||||
h.errorResponse(c, cls.Status, cls.ErrType, cls.Message)
|
||||
return
|
||||
}
|
||||
setOpsSelectedAccount(c, account.ID, account.Platform)
|
||||
|
||||
@@ -162,8 +162,15 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, selectionSessionHash, reqModel, fs.FailedAccountIDs, "", int64(0))
|
||||
if err != nil {
|
||||
if len(fs.FailedAccountIDs) == 0 {
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
h.chatCompletionsErrorResponse(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error())
|
||||
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, groupPlatform)
|
||||
if !cls.ModelNotFound {
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
}
|
||||
message := cls.Message
|
||||
if !cls.ModelNotFound {
|
||||
message = "No available accounts: " + err.Error()
|
||||
}
|
||||
h.chatCompletionsErrorResponse(c, cls.Status, cls.ErrType, message)
|
||||
return
|
||||
}
|
||||
action := fs.HandleSelectionExhausted(c.Request.Context())
|
||||
|
||||
@@ -160,8 +160,15 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
|
||||
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(requestCtx, apiKey.GroupID, sessionHash, reqModel, fs.FailedAccountIDs, "", int64(0))
|
||||
if err != nil {
|
||||
if len(fs.FailedAccountIDs) == 0 {
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
h.responsesErrorResponse(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error())
|
||||
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, service.PlatformAnthropic)
|
||||
if !cls.ModelNotFound {
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
}
|
||||
message := cls.Message
|
||||
if !cls.ModelNotFound {
|
||||
message = "No available accounts: " + err.Error()
|
||||
}
|
||||
h.responsesErrorResponse(c, cls.Status, cls.ErrType, message)
|
||||
return
|
||||
}
|
||||
action := fs.HandleSelectionExhausted(requestCtx)
|
||||
|
||||
@@ -137,6 +137,7 @@ func (f *fakeConcurrencyCache) GetAccountConcurrencyBatch(_ context.Context, acc
|
||||
return result, nil
|
||||
}
|
||||
func (f *fakeConcurrencyCache) CleanupExpiredAccountSlots(context.Context, int64) error { return nil }
|
||||
func (f *fakeConcurrencyCache) CleanupExpiredAccountSlotKeys(context.Context) error { return nil }
|
||||
func (f *fakeConcurrencyCache) CleanupStaleProcessSlots(context.Context, string) error { return nil }
|
||||
|
||||
func newTestGatewayHandler(t *testing.T, group *service.Group, accounts []*service.Account) (*GatewayHandler, func()) {
|
||||
|
||||
@@ -89,6 +89,10 @@ func (m *concurrencyCacheMock) CleanupExpiredAccountSlots(ctx context.Context, a
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *concurrencyCacheMock) CleanupExpiredAccountSlotKeys(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *concurrencyCacheMock) CleanupStaleProcessSlots(ctx context.Context, activeRequestPrefix string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -140,6 +140,10 @@ func (s *helperConcurrencyCacheStub) CleanupExpiredAccountSlots(ctx context.Cont
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *helperConcurrencyCacheStub) CleanupExpiredAccountSlotKeys(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *helperConcurrencyCacheStub) CleanupStaleProcessSlots(ctx context.Context, activeRequestPrefix string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -269,6 +269,149 @@ func TestGatewayModels_CustomModelsListKeepsConcreteModelAllowedByWildcardMappin
|
||||
require.Equal(t, []string{"claude-sonnet-4-6"}, modelIDsForTest(got.Data))
|
||||
}
|
||||
|
||||
func TestGatewayModels_AnthropicCustomModelsListIncludesOAuthClaudeAndMappedDeepSeek(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
groupID := int64(28)
|
||||
h := newGatewayModelsHandlerForTest(
|
||||
&gatewayModelsAccountRepoStub{
|
||||
byGroup: map[int64][]service.Account{
|
||||
groupID: {
|
||||
{
|
||||
ID: 1,
|
||||
Platform: service.PlatformAnthropic,
|
||||
Type: service.AccountTypeOAuth,
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Platform: service.PlatformAnthropic,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{
|
||||
"deepseek-v4-pro": "deepseek-v4-pro",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
||||
Group: &service.Group{
|
||||
ID: groupID,
|
||||
Platform: service.PlatformAnthropic,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
Enabled: true,
|
||||
Models: []string{"claude-fable-5", "claude-opus-4-8", "deepseek-v4-pro"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
h.Models(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var got gatewayModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
require.Equal(t, []string{"claude-fable-5", "claude-opus-4-8", "deepseek-v4-pro"}, modelIDsForTest(got.Data))
|
||||
}
|
||||
|
||||
func TestGatewayModels_AnthropicCustomModelsListDisabledKeepsMappedModelList(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
groupID := int64(29)
|
||||
h := newGatewayModelsHandlerForTest(
|
||||
&gatewayModelsAccountRepoStub{
|
||||
byGroup: map[int64][]service.Account{
|
||||
groupID: {
|
||||
{
|
||||
ID: 1,
|
||||
Platform: service.PlatformAnthropic,
|
||||
Type: service.AccountTypeOAuth,
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Platform: service.PlatformAnthropic,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{
|
||||
"deepseek-v4-pro": "deepseek-v4-pro",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
||||
Group: &service.Group{
|
||||
ID: groupID,
|
||||
Platform: service.PlatformAnthropic,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
Enabled: false,
|
||||
Models: []string{"claude-fable-5", "deepseek-v4-pro"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
h.Models(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var got gatewayModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
require.Equal(t, []string{"deepseek-v4-pro"}, modelIDsForTest(got.Data))
|
||||
}
|
||||
|
||||
func TestGatewayModels_AnthropicCustomModelsListIncludesOAuthClaudeWithoutMappings(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
groupID := int64(30)
|
||||
h := newGatewayModelsHandlerForTest(
|
||||
&gatewayModelsAccountRepoStub{
|
||||
byGroup: map[int64][]service.Account{
|
||||
groupID: {
|
||||
{
|
||||
ID: 1,
|
||||
Platform: service.PlatformAnthropic,
|
||||
Type: service.AccountTypeOAuth,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
||||
Group: &service.Group{
|
||||
ID: groupID,
|
||||
Platform: service.PlatformAnthropic,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
Enabled: true,
|
||||
Models: []string{"claude-opus-4-6-thinking", "claude-sonnet-4-5"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
h.Models(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var got gatewayModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
require.Equal(t, []string{"claude-opus-4-6-thinking", "claude-sonnet-4-5"}, modelIDsForTest(got.Data))
|
||||
}
|
||||
|
||||
func TestGatewayModels_CustomModelsListCanReturnEmptyWhenSelectionsUnavailable(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
|
||||
@@ -350,8 +350,15 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
|
||||
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, sessionKey, modelName, fs.FailedAccountIDs, "", int64(0)) // Gemini 不使用会话限制
|
||||
if err != nil {
|
||||
if len(fs.FailedAccountIDs) == 0 {
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
googleError(c, http.StatusServiceUnavailable, "No available Gemini accounts: "+err.Error())
|
||||
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, modelName, modelName, service.PlatformGemini)
|
||||
if !cls.ModelNotFound {
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
}
|
||||
message := cls.Message
|
||||
if !cls.ModelNotFound {
|
||||
message = "No available Gemini accounts: " + err.Error()
|
||||
}
|
||||
googleError(c, cls.Status, message)
|
||||
return
|
||||
}
|
||||
action := fs.HandleSelectionExhausted(c.Request.Context())
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// GrokImages handles xAI image generation/editing through Grok groups.
|
||||
func (h *OpenAIGatewayHandler) GrokImages(c *gin.Context) {
|
||||
endpoint := service.GrokMediaEndpointImagesGenerations
|
||||
if strings.Contains(c.Request.URL.Path, "/images/edits") {
|
||||
endpoint = service.GrokMediaEndpointImagesEdits
|
||||
}
|
||||
h.handleGrokMedia(c, endpoint, "")
|
||||
}
|
||||
|
||||
// GrokVideoGeneration handles xAI video generation through Grok groups.
|
||||
func (h *OpenAIGatewayHandler) GrokVideoGeneration(c *gin.Context) {
|
||||
h.handleGrokMedia(c, service.GrokMediaEndpointVideosGenerations, "")
|
||||
}
|
||||
|
||||
// GrokVideoStatus handles xAI video status retrieval through Grok groups.
|
||||
func (h *OpenAIGatewayHandler) GrokVideoStatus(c *gin.Context) {
|
||||
h.handleGrokMedia(c, service.GrokMediaEndpointVideoStatus, c.Param("request_id"))
|
||||
}
|
||||
|
||||
func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.GrokMediaEndpoint, requestID string) {
|
||||
streamStarted := false
|
||||
defer h.recoverResponsesPanic(c, &streamStarted)
|
||||
|
||||
requestStart := time.Now()
|
||||
apiKey, ok := middleware2.GetAPIKeyFromContext(c)
|
||||
if !ok {
|
||||
h.errorResponse(c, http.StatusUnauthorized, "authentication_error", "Invalid API key")
|
||||
return
|
||||
}
|
||||
subject, ok := middleware2.GetAuthSubjectFromContext(c)
|
||||
if !ok {
|
||||
h.errorResponse(c, http.StatusInternalServerError, "api_error", "User context not found")
|
||||
return
|
||||
}
|
||||
|
||||
reqLog := requestLogger(
|
||||
c,
|
||||
"handler.openai_gateway.grok_media",
|
||||
zap.Int64("user_id", subject.UserID),
|
||||
zap.Int64("api_key_id", apiKey.ID),
|
||||
zap.Any("group_id", apiKey.GroupID),
|
||||
zap.String("endpoint", string(endpoint)),
|
||||
)
|
||||
if !h.ensureResponsesDependencies(c, reqLog) {
|
||||
return
|
||||
}
|
||||
|
||||
var body []byte
|
||||
var err error
|
||||
if endpoint.RequiresRequestBody() {
|
||||
body, err = pkghttputil.ReadRequestBodyWithPrealloc(c.Request)
|
||||
if err != nil {
|
||||
if maxErr, ok := extractMaxBytesError(err); ok {
|
||||
h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit))
|
||||
return
|
||||
}
|
||||
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body")
|
||||
return
|
||||
}
|
||||
if len(body) == 0 {
|
||||
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Request body is empty")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
contentType := c.GetHeader("Content-Type")
|
||||
requestInfo := service.ParseGrokMediaRequest(contentType, body)
|
||||
requestModel := requestInfo.Model
|
||||
if endpoint.IsGenerationRequest() && strings.TrimSpace(requestModel) == "" {
|
||||
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "model is required")
|
||||
return
|
||||
}
|
||||
if endpoint == service.GrokMediaEndpointVideoStatus && strings.TrimSpace(requestID) == "" {
|
||||
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "request_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
reqLog = reqLog.With(zap.String("model", requestModel))
|
||||
setOpsRequestContext(c, requestModel, false)
|
||||
setOpsEndpointContext(c, "", int16(service.RequestTypeSync))
|
||||
|
||||
if endpoint.IsGenerationRequest() {
|
||||
if !service.GroupAllowsImageGeneration(apiKey.Group) {
|
||||
h.errorResponse(c, http.StatusForbidden, "permission_error", service.ImageGenerationPermissionMessage())
|
||||
return
|
||||
}
|
||||
if moderationBody := requestInfo.ModerationBody(); len(moderationBody) > 0 {
|
||||
decision := h.checkContentModeration(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIImages, requestModel, moderationBody)
|
||||
if decision != nil && decision.Blocked {
|
||||
h.errorResponse(c, contentModerationStatus(decision), contentModerationErrorCode(decision), decision.Message)
|
||||
return
|
||||
}
|
||||
}
|
||||
imageReleaseFunc, acquired := h.acquireImageGenerationSlot(c, streamStarted)
|
||||
if !acquired {
|
||||
return
|
||||
}
|
||||
if imageReleaseFunc != nil {
|
||||
defer imageReleaseFunc()
|
||||
}
|
||||
}
|
||||
|
||||
if h.errorPassthroughService != nil {
|
||||
service.BindErrorPassthroughService(c, h.errorPassthroughService)
|
||||
}
|
||||
|
||||
subscription, _ := middleware2.GetSubscriptionFromContext(c)
|
||||
service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds())
|
||||
|
||||
userReleaseFunc, acquired := h.acquireResponsesUserSlot(c, subject.UserID, subject.Concurrency, false, &streamStarted, reqLog)
|
||||
if !acquired {
|
||||
return
|
||||
}
|
||||
if userReleaseFunc != nil {
|
||||
defer userReleaseFunc()
|
||||
}
|
||||
|
||||
if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription, service.QuotaPlatform(c.Request.Context(), apiKey)); err != nil {
|
||||
reqLog.Info("grok_media.billing_eligibility_check_failed", zap.Error(err))
|
||||
status, code, message, retryAfter := billingErrorDetails(err)
|
||||
if retryAfter > 0 {
|
||||
c.Header("Retry-After", strconv.Itoa(retryAfter))
|
||||
}
|
||||
h.errorResponse(c, status, code, message)
|
||||
return
|
||||
}
|
||||
|
||||
sessionSeed := body
|
||||
if len(sessionSeed) == 0 && strings.TrimSpace(requestID) != "" {
|
||||
sessionSeed = []byte(requestID)
|
||||
}
|
||||
sessionHash := h.gatewayService.GenerateExplicitSessionHash(c, sessionSeed)
|
||||
if endpoint == service.GrokMediaEndpointVideoStatus {
|
||||
sessionHash = service.GrokMediaVideoRequestSessionHash(requestID)
|
||||
}
|
||||
requestCtx := c.Request.Context()
|
||||
failedAccountIDs := make(map[int64]struct{})
|
||||
sameAccountRetryCount := make(map[int64]int)
|
||||
var lastFailoverErr *service.UpstreamFailoverError
|
||||
switchCount := 0
|
||||
maxAccountSwitches := h.maxAccountSwitches
|
||||
if maxAccountSwitches <= 0 {
|
||||
maxAccountSwitches = 3
|
||||
}
|
||||
routingStart := time.Now()
|
||||
|
||||
for {
|
||||
selection, scheduleDecision, err := h.gatewayService.SelectAccountWithSchedulerForCapability(
|
||||
requestCtx,
|
||||
apiKey.GroupID,
|
||||
"",
|
||||
sessionHash,
|
||||
requestModel,
|
||||
failedAccountIDs,
|
||||
service.OpenAIUpstreamTransportHTTPSSE,
|
||||
"",
|
||||
false,
|
||||
false,
|
||||
service.PlatformGrok,
|
||||
)
|
||||
if err != nil {
|
||||
reqLog.Warn("grok_media.account_select_failed",
|
||||
zap.Error(err),
|
||||
zap.Int("excluded_account_count", len(failedAccountIDs)),
|
||||
)
|
||||
if len(failedAccountIDs) == 0 {
|
||||
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, requestModel, requestModel, service.PlatformGrok)
|
||||
if !cls.ModelNotFound {
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
}
|
||||
h.errorResponse(c, cls.Status, cls.ErrType, cls.Message)
|
||||
return
|
||||
}
|
||||
if lastFailoverErr != nil {
|
||||
h.handleFailoverExhausted(c, lastFailoverErr, false)
|
||||
} else {
|
||||
h.errorResponse(c, http.StatusBadGateway, "api_error", "Upstream request failed")
|
||||
}
|
||||
return
|
||||
}
|
||||
if selection == nil || selection.Account == nil {
|
||||
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, requestModel, requestModel, service.PlatformGrok)
|
||||
if !cls.ModelNotFound {
|
||||
markOpsRoutingCapacityLimited(c)
|
||||
}
|
||||
h.errorResponse(c, cls.Status, cls.ErrType, cls.Message)
|
||||
return
|
||||
}
|
||||
|
||||
reqLog.Debug("grok_media.account_schedule_decision",
|
||||
zap.String("layer", scheduleDecision.Layer),
|
||||
zap.Bool("sticky_session_hit", scheduleDecision.StickySessionHit),
|
||||
zap.Int("candidate_count", scheduleDecision.CandidateCount),
|
||||
zap.Int("top_k", scheduleDecision.TopK),
|
||||
zap.Int64("latency_ms", scheduleDecision.LatencyMs),
|
||||
zap.Float64("load_skew", scheduleDecision.LoadSkew),
|
||||
)
|
||||
|
||||
account := selection.Account
|
||||
sessionHash = ensureOpenAIPoolModeSessionHash(sessionHash, account)
|
||||
setOpsSelectedAccount(c, account.ID, account.Platform)
|
||||
|
||||
accountReleaseFunc, accountAcquired := h.acquireResponsesAccountSlot(c, apiKey.GroupID, sessionHash, selection, false, &streamStarted, reqLog)
|
||||
if !accountAcquired {
|
||||
return
|
||||
}
|
||||
|
||||
service.SetOpsLatencyMs(c, service.OpsRoutingLatencyMsKey, time.Since(routingStart).Milliseconds())
|
||||
forwardStart := time.Now()
|
||||
writerSizeBeforeForward := c.Writer.Size()
|
||||
result, err := func() (*service.OpenAIForwardResult, error) {
|
||||
defer func() {
|
||||
if accountReleaseFunc != nil {
|
||||
accountReleaseFunc()
|
||||
}
|
||||
}()
|
||||
return h.gatewayService.ForwardGrokMedia(requestCtx, c, account, endpoint, requestID, body, contentType)
|
||||
}()
|
||||
|
||||
forwardDurationMs := time.Since(forwardStart).Milliseconds()
|
||||
upstreamLatencyMs, _ := getContextInt64(c, service.OpsUpstreamLatencyMsKey)
|
||||
responseLatencyMs := forwardDurationMs
|
||||
if upstreamLatencyMs > 0 && forwardDurationMs > upstreamLatencyMs {
|
||||
responseLatencyMs = forwardDurationMs - upstreamLatencyMs
|
||||
}
|
||||
service.SetOpsLatencyMs(c, service.OpsResponseLatencyMsKey, responseLatencyMs)
|
||||
|
||||
if err != nil {
|
||||
var failoverErr *service.UpstreamFailoverError
|
||||
if errors.As(err, &failoverErr) {
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
|
||||
if c.Writer.Size() != writerSizeBeforeForward {
|
||||
h.handleFailoverExhausted(c, failoverErr, true)
|
||||
return
|
||||
}
|
||||
if failoverErr.RetryableOnSameAccount {
|
||||
retryLimit := account.GetPoolModeRetryCount()
|
||||
if sameAccountRetryCount[account.ID] < retryLimit {
|
||||
sameAccountRetryCount[account.ID]++
|
||||
reqLog.Warn("grok_media.pool_mode_same_account_retry",
|
||||
zap.Int64("account_id", account.ID),
|
||||
zap.Int("upstream_status", failoverErr.StatusCode),
|
||||
zap.Int("retry_limit", retryLimit),
|
||||
zap.Int("retry_count", sameAccountRetryCount[account.ID]),
|
||||
)
|
||||
select {
|
||||
case <-requestCtx.Done():
|
||||
return
|
||||
case <-time.After(sameAccountRetryDelay):
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
h.gatewayService.RecordOpenAIAccountSwitch()
|
||||
failedAccountIDs[account.ID] = struct{}{}
|
||||
lastFailoverErr = failoverErr
|
||||
if switchCount >= maxAccountSwitches {
|
||||
h.handleFailoverExhausted(c, failoverErr, false)
|
||||
return
|
||||
}
|
||||
switchCount++
|
||||
reqLog.Warn("grok_media.upstream_failover_switching",
|
||||
zap.Int64("account_id", account.ID),
|
||||
zap.Int("upstream_status", failoverErr.StatusCode),
|
||||
zap.Int("switch_count", switchCount),
|
||||
zap.Int("max_switches", maxAccountSwitches),
|
||||
)
|
||||
continue
|
||||
}
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
|
||||
if c.Writer.Size() == writerSizeBeforeForward {
|
||||
h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Upstream request failed")
|
||||
}
|
||||
reqLog.Warn("grok_media.forward_failed",
|
||||
zap.Int64("account_id", account.ID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, nil)
|
||||
if endpoint == service.GrokMediaEndpointVideosGenerations && strings.TrimSpace(result.ResponseID) != "" {
|
||||
if err := h.gatewayService.BindGrokMediaVideoRequestAccount(requestCtx, apiKey.GroupID, result.ResponseID, account.ID); err != nil {
|
||||
reqLog.Warn("grok_media.bind_video_request_account_failed",
|
||||
zap.Int64("account_id", account.ID),
|
||||
zap.String("request_id", result.ResponseID),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
if shouldRecordGrokMediaUsage(endpoint, requestModel) {
|
||||
recordGrokMediaUsage(c, h, reqLog, apiKey, subject, subscription, account, result, requestModel, body, requestID)
|
||||
}
|
||||
reqLog.Debug("grok_media.request_completed",
|
||||
zap.Int64("account_id", account.ID),
|
||||
zap.Int("switch_count", switchCount),
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func shouldRecordGrokMediaUsage(endpoint service.GrokMediaEndpoint, requestModel string) bool {
|
||||
return endpoint.IsGenerationRequest() && strings.TrimSpace(requestModel) != ""
|
||||
}
|
||||
|
||||
func recordGrokMediaUsage(
|
||||
c *gin.Context,
|
||||
h *OpenAIGatewayHandler,
|
||||
reqLog *zap.Logger,
|
||||
apiKey *service.APIKey,
|
||||
subject middleware2.AuthSubject,
|
||||
subscription *service.UserSubscription,
|
||||
account *service.Account,
|
||||
result *service.OpenAIForwardResult,
|
||||
requestModel string,
|
||||
body []byte,
|
||||
requestID string,
|
||||
) {
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
clientIP := ip.GetClientIP(c)
|
||||
payloadForHash := body
|
||||
if len(payloadForHash) == 0 && strings.TrimSpace(requestID) != "" {
|
||||
payloadForHash = []byte(requestID)
|
||||
}
|
||||
inboundEndpoint := GetInboundEndpoint(c)
|
||||
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
|
||||
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
|
||||
channelUsageFields := service.ChannelUsageFields{
|
||||
OriginalModel: requestModel,
|
||||
ChannelMappedModel: requestModel,
|
||||
}
|
||||
h.submitOpenAIUsageRecordTask(c.Request.Context(), result, func(ctx context.Context) {
|
||||
if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{
|
||||
Result: result,
|
||||
APIKey: apiKey,
|
||||
User: apiKey.User,
|
||||
Account: account,
|
||||
Subscription: subscription,
|
||||
InboundEndpoint: inboundEndpoint,
|
||||
UpstreamEndpoint: upstreamEndpoint,
|
||||
UserAgent: userAgent,
|
||||
IPAddress: clientIP,
|
||||
RequestPayloadHash: service.HashUsageRequestPayload(payloadForHash),
|
||||
APIKeyService: h.apiKeyService,
|
||||
QuotaPlatform: quotaPlatform,
|
||||
ChannelUsageFields: channelUsageFields,
|
||||
}); err != nil {
|
||||
logger.L().With(
|
||||
zap.String("component", "handler.openai_gateway.grok_media"),
|
||||
zap.Int64("user_id", subject.UserID),
|
||||
zap.Int64("api_key_id", apiKey.ID),
|
||||
zap.Any("group_id", apiKey.GroupID),
|
||||
zap.String("model", requestModel),
|
||||
zap.Int64("account_id", account.ID),
|
||||
).Error("grok_media.record_usage_failed", zap.Error(err))
|
||||
reqLog.Debug("grok_media.record_usage_failed", zap.Error(err))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestShouldRecordGrokMediaUsage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
endpoint service.GrokMediaEndpoint
|
||||
model string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "image generation records usage",
|
||||
endpoint: service.GrokMediaEndpointImagesGenerations,
|
||||
model: "grok-imagine",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "image edit records usage",
|
||||
endpoint: service.GrokMediaEndpointImagesEdits,
|
||||
model: "grok-imagine-edit",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "video generation records usage",
|
||||
endpoint: service.GrokMediaEndpointVideosGenerations,
|
||||
model: "grok-imagine-video-1.5",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "video status skips empty model usage",
|
||||
endpoint: service.GrokMediaEndpointVideoStatus,
|
||||
model: "",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "generation skips usage without model",
|
||||
endpoint: service.GrokMediaEndpointImagesGenerations,
|
||||
model: " ",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.want, shouldRecordGrokMediaUsage(tt.endpoint, tt.model))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ type AdminHandlers struct {
|
||||
OpenAIOAuth *admin.OpenAIOAuthHandler
|
||||
GeminiOAuth *admin.GeminiOAuthHandler
|
||||
AntigravityOAuth *admin.AntigravityOAuthHandler
|
||||
GrokOAuth *admin.GrokOAuthHandler
|
||||
Proxy *admin.ProxyHandler
|
||||
Redeem *admin.RedeemHandler
|
||||
Promo *admin.PromoHandler
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
// noAccountErrorClassification describes the HTTP response to emit when
|
||||
// account selection failed with ErrNoAvailableAccounts. Handlers obtain it
|
||||
// via classifyNoAccountError and choose between:
|
||||
//
|
||||
// - 404 model_not_found — the group has accounts, but none of them are
|
||||
// configured to serve the requested model (config / typo / unsupported
|
||||
// model). Returning 503 here misleads operators and trips reverse-proxy
|
||||
// health checks; 404 lets the client surface the real problem.
|
||||
//
|
||||
// - 503 api_error — accounts that could serve the model exist but are
|
||||
// temporarily exhausted (rate limit, quota auto-pause, runtime block) OR
|
||||
// the group has no accounts at all. Both stay on 503 because retrying
|
||||
// after a backoff can plausibly succeed (or, in the empty-pool case, the
|
||||
// operator may be in the middle of adding accounts).
|
||||
type noAccountErrorClassification struct {
|
||||
Status int
|
||||
ErrType string
|
||||
Message string
|
||||
ModelNotFound bool // true when this is a 404 model_not_found classification
|
||||
}
|
||||
|
||||
// classifyNoAccountError decides between 404 model_not_found and 503
|
||||
// api_error for "no available accounts" failures.
|
||||
//
|
||||
// The classifier intentionally does not consume the original error: the
|
||||
// selection layer never tells us *why* the pool came up empty (rate-limited
|
||||
// vs. unsupported model are both wrapped as ErrNoAvailableAccounts). Instead
|
||||
// we re-check pool composition through DiagnoseModelAvailabilityForPlatform,
|
||||
// which only inspects model_mapping configuration and ignores transient
|
||||
// state. That guarantees a 404 is only returned when no operator action
|
||||
// short of editing the account's model_mapping could make this request
|
||||
// succeed.
|
||||
//
|
||||
// routingModel is the model name that account selection actually compared
|
||||
// against (i.e. after group-level dispatch mapping). displayModel is the
|
||||
// raw model the caller asked for; it is used only in the user-facing error
|
||||
// message so that internal mapping details don't leak. Most callers pass
|
||||
// the same value for both.
|
||||
//
|
||||
// platform is the platform the request was routed to (use
|
||||
// service.PlatformOpenAI / PlatformAnthropic / PlatformGemini). It is
|
||||
// required because Anthropic/Gemini routes additionally surface
|
||||
// mixed-scheduled Antigravity accounts; passing the wrong platform would
|
||||
// flip a legitimate 503 to a misleading 404 (or vice versa).
|
||||
func classifyNoAccountError(
|
||||
ctx context.Context,
|
||||
diag service.ModelAvailabilityDiagnoser,
|
||||
apiKey *service.APIKey,
|
||||
routingModel string,
|
||||
displayModel string,
|
||||
platform string,
|
||||
) noAccountErrorClassification {
|
||||
fallback := noAccountErrorClassification{
|
||||
Status: http.StatusServiceUnavailable,
|
||||
ErrType: "api_error",
|
||||
Message: "Service temporarily unavailable",
|
||||
}
|
||||
|
||||
routingModel = strings.TrimSpace(routingModel)
|
||||
displayModel = strings.TrimSpace(displayModel)
|
||||
if displayModel == "" {
|
||||
displayModel = routingModel
|
||||
}
|
||||
if diag == nil || apiKey == nil || apiKey.GroupID == nil || routingModel == "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
result := diag.DiagnoseModelAvailabilityForPlatform(ctx, apiKey.GroupID, routingModel, platform)
|
||||
if result.HasAccountsInPool && !result.HasModelSupport {
|
||||
return noAccountErrorClassification{
|
||||
Status: http.StatusNotFound,
|
||||
ErrType: "model_not_found",
|
||||
Message: fmt.Sprintf("Model %q is not supported by any configured account in this group", displayModel),
|
||||
ModelNotFound: true,
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// classifyNoAccountErrorFromGin is a thin wrapper that forwards the gin
|
||||
// context's underlying request context. Most call sites already have a
|
||||
// *gin.Context handy, so this keeps the call sites uncluttered.
|
||||
func classifyNoAccountErrorFromGin(
|
||||
c *gin.Context,
|
||||
diag service.ModelAvailabilityDiagnoser,
|
||||
apiKey *service.APIKey,
|
||||
routingModel string,
|
||||
displayModel string,
|
||||
platform string,
|
||||
) noAccountErrorClassification {
|
||||
ctx := context.Background()
|
||||
if c != nil && c.Request != nil {
|
||||
ctx = c.Request.Context()
|
||||
}
|
||||
return classifyNoAccountError(ctx, diag, apiKey, routingModel, displayModel, platform)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//go:build unit
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
type fakeDiagnoser struct {
|
||||
calls []fakeDiagnoseCall
|
||||
resp service.ModelAvailabilityDiagnosis
|
||||
}
|
||||
|
||||
type fakeDiagnoseCall struct {
|
||||
GroupID *int64
|
||||
Model string
|
||||
Platform string
|
||||
}
|
||||
|
||||
func (f *fakeDiagnoser) DiagnoseModelAvailabilityForPlatform(
|
||||
_ context.Context,
|
||||
groupID *int64,
|
||||
model, platform string,
|
||||
) service.ModelAvailabilityDiagnosis {
|
||||
f.calls = append(f.calls, fakeDiagnoseCall{
|
||||
GroupID: groupID,
|
||||
Model: model,
|
||||
Platform: platform,
|
||||
})
|
||||
return f.resp
|
||||
}
|
||||
|
||||
func ptrInt64(v int64) *int64 { return &v }
|
||||
|
||||
// newTestGinContextWithRequest wraps the bare newTestGinContext helper
|
||||
// (defined in openai_gateway_cyber_test.go) by additionally attaching a stub
|
||||
// *http.Request so the classifier can extract c.Request.Context().
|
||||
func newTestGinContextWithRequest() *gin.Context {
|
||||
c := newTestGinContext()
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/test", nil)
|
||||
return c
|
||||
}
|
||||
|
||||
func TestClassifyNoAccountError_NilDiagnoser_Falls503(t *testing.T) {
|
||||
c := newTestGinContextWithRequest()
|
||||
apiKey := &service.APIKey{GroupID: ptrInt64(7)}
|
||||
|
||||
cls := classifyNoAccountErrorFromGin(c, nil, apiKey, "gpt-5", "gpt-5", service.PlatformOpenAI)
|
||||
|
||||
require.Equal(t, http.StatusServiceUnavailable, cls.Status)
|
||||
require.Equal(t, "api_error", cls.ErrType)
|
||||
require.False(t, cls.ModelNotFound)
|
||||
}
|
||||
|
||||
func TestClassifyNoAccountError_NilAPIKey_Falls503(t *testing.T) {
|
||||
c := newTestGinContextWithRequest()
|
||||
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
|
||||
|
||||
cls := classifyNoAccountErrorFromGin(c, fd, nil, "gpt-5", "gpt-5", service.PlatformOpenAI)
|
||||
|
||||
require.Equal(t, http.StatusServiceUnavailable, cls.Status)
|
||||
require.False(t, cls.ModelNotFound)
|
||||
require.Empty(t, fd.calls, "diagnoser must not be consulted when apiKey missing")
|
||||
}
|
||||
|
||||
func TestClassifyNoAccountError_NilGroupID_Falls503(t *testing.T) {
|
||||
c := newTestGinContextWithRequest()
|
||||
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
|
||||
apiKey := &service.APIKey{GroupID: nil}
|
||||
|
||||
cls := classifyNoAccountErrorFromGin(c, fd, apiKey, "gpt-5", "gpt-5", service.PlatformOpenAI)
|
||||
|
||||
require.Equal(t, http.StatusServiceUnavailable, cls.Status)
|
||||
require.False(t, cls.ModelNotFound)
|
||||
require.Empty(t, fd.calls, "diagnoser must not be consulted when group not bound")
|
||||
}
|
||||
|
||||
func TestClassifyNoAccountError_EmptyModel_Falls503(t *testing.T) {
|
||||
c := newTestGinContextWithRequest()
|
||||
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
|
||||
apiKey := &service.APIKey{GroupID: ptrInt64(7)}
|
||||
|
||||
cls := classifyNoAccountErrorFromGin(c, fd, apiKey, " ", "", service.PlatformOpenAI)
|
||||
|
||||
require.Equal(t, http.StatusServiceUnavailable, cls.Status)
|
||||
require.False(t, cls.ModelNotFound)
|
||||
require.Empty(t, fd.calls)
|
||||
}
|
||||
|
||||
func TestClassifyNoAccountError_ModelNotSupported_Returns404(t *testing.T) {
|
||||
c := newTestGinContextWithRequest()
|
||||
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
|
||||
apiKey := &service.APIKey{GroupID: ptrInt64(42)}
|
||||
|
||||
cls := classifyNoAccountErrorFromGin(c, fd, apiKey, "gpt-5.1-codex-mini", "gpt-5.1-codex-mini", service.PlatformOpenAI)
|
||||
|
||||
require.Equal(t, http.StatusNotFound, cls.Status)
|
||||
require.Equal(t, "model_not_found", cls.ErrType)
|
||||
require.True(t, cls.ModelNotFound)
|
||||
require.Contains(t, cls.Message, "gpt-5.1-codex-mini", "message must surface the requested model")
|
||||
|
||||
require.Len(t, fd.calls, 1)
|
||||
require.Equal(t, "gpt-5.1-codex-mini", fd.calls[0].Model)
|
||||
require.Equal(t, service.PlatformOpenAI, fd.calls[0].Platform)
|
||||
require.NotNil(t, fd.calls[0].GroupID)
|
||||
require.Equal(t, int64(42), *fd.calls[0].GroupID)
|
||||
}
|
||||
|
||||
func TestClassifyNoAccountError_HasModelSupport_KeepsRoutingMessageGenerationToCaller(t *testing.T) {
|
||||
c := newTestGinContextWithRequest()
|
||||
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: true}}
|
||||
apiKey := &service.APIKey{GroupID: ptrInt64(7)}
|
||||
|
||||
cls := classifyNoAccountErrorFromGin(c, fd, apiKey, "gpt-5", "gpt-5", service.PlatformOpenAI)
|
||||
|
||||
require.Equal(t, http.StatusServiceUnavailable, cls.Status, "model exists somewhere — caller stays on 503")
|
||||
require.Equal(t, "api_error", cls.ErrType)
|
||||
require.False(t, cls.ModelNotFound)
|
||||
}
|
||||
|
||||
func TestClassifyNoAccountError_NoAccountsInPool_Stays503(t *testing.T) {
|
||||
c := newTestGinContextWithRequest()
|
||||
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: false, HasModelSupport: false}}
|
||||
apiKey := &service.APIKey{GroupID: ptrInt64(7)}
|
||||
|
||||
cls := classifyNoAccountErrorFromGin(c, fd, apiKey, "gpt-5", "gpt-5", service.PlatformOpenAI)
|
||||
|
||||
require.Equal(t, http.StatusServiceUnavailable, cls.Status, "empty pool is a service-availability issue, not a model issue")
|
||||
require.False(t, cls.ModelNotFound)
|
||||
}
|
||||
|
||||
func TestClassifyNoAccountError_DisplayModelOverridesRoutingForMessage(t *testing.T) {
|
||||
c := newTestGinContextWithRequest()
|
||||
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
|
||||
apiKey := &service.APIKey{GroupID: ptrInt64(7)}
|
||||
|
||||
cls := classifyNoAccountErrorFromGin(c, fd, apiKey, "gpt-5", "claude-3-fancy", service.PlatformOpenAI)
|
||||
|
||||
require.True(t, cls.ModelNotFound)
|
||||
require.Contains(t, cls.Message, "claude-3-fancy", "user-facing message must reference the model the user asked for, not the post-mapping routing model")
|
||||
require.Len(t, fd.calls, 1)
|
||||
require.Equal(t, "gpt-5", fd.calls[0].Model, "diagnosis must run against the routing model (post group dispatch mapping)")
|
||||
}
|
||||
|
||||
func TestClassifyNoAccountError_FromGin_NilContextStillSafe(t *testing.T) {
|
||||
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
|
||||
apiKey := &service.APIKey{GroupID: ptrInt64(7)}
|
||||
|
||||
cls := classifyNoAccountErrorFromGin(nil, fd, apiKey, "gpt-5", "gpt-5", service.PlatformOpenAI)
|
||||
|
||||
require.Equal(t, http.StatusNotFound, cls.Status, "even with a nil gin context the classifier must still run and yield a coherent response")
|
||||
require.True(t, cls.ModelNotFound)
|
||||
}
|
||||
@@ -101,6 +101,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
}
|
||||
|
||||
subscription, _ := middleware2.GetSubscriptionFromContext(c)
|
||||
requestPlatform := openAICompatibleRequestPlatform(apiKey)
|
||||
|
||||
service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds())
|
||||
routingStart := time.Now()
|
||||
@@ -144,6 +145,8 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
service.OpenAIUpstreamTransportAny,
|
||||
service.OpenAIEndpointCapabilityChatCompletions,
|
||||
false,
|
||||
false,
|
||||
requestPlatform,
|
||||
)
|
||||
if err != nil {
|
||||
reqLog.Warn("openai_chat_completions.account_select_failed",
|
||||
@@ -151,8 +154,11 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
zap.Int("excluded_account_count", len(failedAccountIDs)),
|
||||
)
|
||||
if len(failedAccountIDs) == 0 {
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "Service temporarily unavailable", streamStarted)
|
||||
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, service.PlatformOpenAI)
|
||||
if !cls.ModelNotFound {
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
}
|
||||
h.handleStreamingAwareError(c, cls.Status, cls.ErrType, cls.Message, streamStarted)
|
||||
return
|
||||
} else {
|
||||
if lastFailoverErr != nil {
|
||||
@@ -164,8 +170,11 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
if selection == nil || selection.Account == nil {
|
||||
markOpsRoutingCapacityLimited(c)
|
||||
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts", streamStarted)
|
||||
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, service.PlatformOpenAI)
|
||||
if !cls.ModelNotFound {
|
||||
markOpsRoutingCapacityLimited(c)
|
||||
}
|
||||
h.handleStreamingAwareError(c, cls.Status, cls.ErrType, cls.Message, streamStarted)
|
||||
return
|
||||
}
|
||||
account := selection.Account
|
||||
@@ -290,6 +299,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
clientIP := ip.GetClientIP(c)
|
||||
inboundEndpoint := GetInboundEndpoint(c)
|
||||
upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account)
|
||||
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
|
||||
|
||||
cyberBlocked := service.GetOpsCyberPolicy(c) != nil
|
||||
h.submitOpenAIUsageRecordTask(c.Request.Context(), result, func(ctx context.Context) {
|
||||
@@ -304,6 +314,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
UserAgent: userAgent,
|
||||
IPAddress: clientIP,
|
||||
APIKeyService: h.apiKeyService,
|
||||
QuotaPlatform: quotaPlatform,
|
||||
ChannelUsageFields: channelMapping.ToUsageFields(reqModel, result.UpstreamModel),
|
||||
CyberBlocked: cyberBlocked,
|
||||
}); err != nil {
|
||||
|
||||