Skip to content

Commit 7a8e261

Browse files
committed
release: 发布 1.2.75 并修复订单与前端交互
修复支付订单取消与回调并发竞态,完善账号、授权、导入、日期范围、密钥、用量和订单页面交互,更新文档站变更日志与构建工具链版本。
1 parent c363a16 commit 7a8e261

20 files changed

Lines changed: 289 additions & 75 deletions

File tree

backend/cmd/server/VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.2.74
1+
1.2.75

backend/internal/service/payment_order_lifecycle.go

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ const (
2929
checkPaidResultCancelled = "cancelled"
3030
)
3131

32+
// ErrPaymentOrderAlreadyPaid indicates that upstream reconciliation found a
33+
// payment, so cancelling the local order would be unsafe.
34+
var ErrPaymentOrderAlreadyPaid = infraerrors.Conflict("ORDER_ALREADY_PAID", "order has already been paid and cannot be cancelled")
35+
3236
func (s *PaymentService) checkCancelRateLimit(ctx context.Context, userID int64, cfg *PaymentConfig) error {
3337
if !cfg.CancelRateLimitEnabled || cfg.CancelRateLimitMax <= 0 {
3438
return nil
@@ -102,7 +106,14 @@ func (s *PaymentService) CancelOrder(ctx context.Context, orderID, userID int64)
102106
if o.Status != OrderStatusPending {
103107
return "", infraerrors.BadRequest("INVALID_STATUS", "order cannot be cancelled in current status")
104108
}
105-
return s.cancelCore(ctx, o, OrderStatusCancelled, fmt.Sprintf("user:%d", userID), "user cancelled order")
109+
msg, err := s.cancelCore(ctx, o, OrderStatusCancelled, fmt.Sprintf("user:%d", userID), "user cancelled order")
110+
if err != nil {
111+
return "", err
112+
}
113+
if msg == checkPaidResultAlreadyPaid {
114+
return "", ErrPaymentOrderAlreadyPaid
115+
}
116+
return msg, nil
106117
}
107118

108119
func (s *PaymentService) AdminCancelOrder(ctx context.Context, orderID int64) (string, error) {
@@ -113,7 +124,14 @@ func (s *PaymentService) AdminCancelOrder(ctx context.Context, orderID int64) (s
113124
if o.Status != OrderStatusPending {
114125
return "", infraerrors.BadRequest("INVALID_STATUS", "order cannot be cancelled in current status")
115126
}
116-
return s.cancelCore(ctx, o, OrderStatusCancelled, "admin", "admin cancelled order")
127+
msg, err := s.cancelCore(ctx, o, OrderStatusCancelled, "admin", "admin cancelled order")
128+
if err != nil {
129+
return "", err
130+
}
131+
if msg == checkPaidResultAlreadyPaid {
132+
return "", ErrPaymentOrderAlreadyPaid
133+
}
134+
return msg, nil
117135
}
118136

119137
func (s *PaymentService) cancelCore(ctx context.Context, o *dbent.PaymentOrder, fs, op, ad string) (string, error) {
@@ -164,7 +182,19 @@ func (s *PaymentService) cancelCore(ctx context.Context, o *dbent.PaymentOrder,
164182
if err := tx.Commit(); err != nil {
165183
return "", fmt.Errorf("commit empty cancel transaction: %w", err)
166184
}
167-
return checkPaidResultCancelled, nil
185+
186+
// The conditional update can legitimately affect no rows when a webhook or
187+
// expiry worker changes the order between the initial read and this write.
188+
// Do not report a successful cancellation unless the persisted state proves
189+
// that this transition (or an equivalent one) won.
190+
latest, err := s.entClient.PaymentOrder.Get(ctx, o.ID)
191+
if err != nil {
192+
return "", fmt.Errorf("reload order after empty cancel: %w", err)
193+
}
194+
if latest.Status == fs {
195+
return checkPaidResultCancelled, nil
196+
}
197+
return "", infraerrors.Conflict("ORDER_STATE_CHANGED", "order status changed before cancellation")
168198
}
169199

170200
func (s *PaymentService) checkPaid(ctx context.Context, o *dbent.PaymentOrder) string {

backend/internal/service/payment_order_lifecycle_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import (
1010

1111
dbent "github.com/Wei-Shaw/sub2api/ent"
1212
"github.com/Wei-Shaw/sub2api/ent/enttest"
13+
"github.com/Wei-Shaw/sub2api/ent/paymentorder"
1314
"github.com/Wei-Shaw/sub2api/internal/payment"
15+
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
1416
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
1517
"github.com/stretchr/testify/require"
1618

@@ -566,6 +568,51 @@ func TestPaymentOrderQueryReferenceUsesOutTradeNoForOfficialProviders(t *testing
566568
}))
567569
}
568570

571+
func TestCancelCoreReportsConcurrentStateChange(t *testing.T) {
572+
ctx := context.Background()
573+
client := newPaymentOrderLifecycleTestClient(t)
574+
user, err := client.User.Create().
575+
SetEmail("cancel-race@example.com").
576+
SetPasswordHash("hash").
577+
SetUsername("cancel-race-user").
578+
Save(ctx)
579+
require.NoError(t, err)
580+
581+
order, err := client.PaymentOrder.Create().
582+
SetUserID(user.ID).
583+
SetUserEmail(user.Email).
584+
SetUserName(user.Username).
585+
SetAmount(10).
586+
SetPayAmount(10).
587+
SetFeeRate(0).
588+
SetRechargeCode("CANCEL-RACE").
589+
SetOutTradeNo("sub2_cancel_race").
590+
SetPaymentType("").
591+
SetPaymentTradeNo("").
592+
SetOrderType(payment.OrderTypeBalance).
593+
SetStatus(OrderStatusPending).
594+
SetExpiresAt(time.Now().Add(time.Hour)).
595+
SetClientIP("127.0.0.1").
596+
SetSrcHost("api.example.com").
597+
Save(ctx)
598+
require.NoError(t, err)
599+
600+
loaded, err := client.PaymentOrder.Get(ctx, order.ID)
601+
require.NoError(t, err)
602+
_, err = client.PaymentOrder.Update().Where(paymentorder.IDEQ(order.ID)).SetStatus(OrderStatusCompleted).Save(ctx)
603+
require.NoError(t, err)
604+
605+
svc := &PaymentService{entClient: client}
606+
_, err = svc.cancelCore(ctx, loaded, OrderStatusCancelled, "admin", "admin cancelled order")
607+
require.Error(t, err)
608+
require.True(t, infraerrors.IsConflict(err))
609+
require.Equal(t, "ORDER_STATE_CHANGED", infraerrors.Reason(err))
610+
611+
reloaded, err := client.PaymentOrder.Get(ctx, order.ID)
612+
require.NoError(t, err)
613+
require.Equal(t, OrderStatusCompleted, reloaded.Status)
614+
}
615+
569616
func newPaymentOrderLifecycleTestClient(t *testing.T) *dbent.Client {
570617
t.Helper()
571618

docs/site/content/docs/operations/changelog.mdx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@ title: 更新日志
33
description: 按版本记录 Pixel API 的主要功能更新。
44
---
55

6+
## v1.2.75
7+
8+
- 修复支付订单取消与支付回调并发时的状态竞态:订单状态已被支付流程改变时不再错误返回取消成功,并明确返回状态冲突。
9+
- 完善账号管理、平台授权、导入和模型选择交互,统一分组名称、平台配置校验与中英文提示。
10+
- 优化日期范围、密钥、用量和订单页面的筛选、展示与错误处理,补齐对应的前端回归测试。
11+
- 数据库无新增迁移。
12+
613
## v1.2.69
714

815
- 账号广场按窗口高度安排筛选栏、卡片和底部分页,减少两侧留白,并按可用空间调整每页卡片数量。

docs/site/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"name": "sub2api-docs",
33
"version": "0.1.0",
4+
"packageManager": "pnpm@10.33.4",
45
"private": true,
56
"type": "module",
67
"scripts": {

frontend/src/components/account/CNProviderSettings.vue

Lines changed: 92 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,61 +2,118 @@
22
<div v-if="isCN" class="space-y-3 rounded-lg border border-gray-200 p-3 dark:border-dark-600">
33
<div class="grid gap-3 sm:grid-cols-2">
44
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">账号模式
5-
<select v-model="local.mode" class="input mt-1"><option value="payg">Pay-as-you-go</option><option value="coding">Coding Plan</option></select>
5+
<select v-model="local.mode" class="input mt-1">
6+
<option value="payg">API key</option>
7+
<option value="coding">Coding Plan</option>
8+
</select>
69
</label>
710
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">API 协议
8-
<select v-model="local.protocol" class="input mt-1"><option value="chat_completions">Chat Completions</option><option value="anthropic">Anthropic Messages</option><option v-if="supportsResponses" value="responses">Responses</option><option value="adaptive">Adaptive</option></select>
11+
<select v-model="local.protocol" class="input mt-1">
12+
<option value="chat_completions">Chat Completions</option>
13+
<option value="anthropic">Anthropic Messages</option>
14+
<option v-if="supportsResponses" value="responses">Responses</option>
15+
<option value="adaptive">Adaptive</option>
16+
</select>
917
</label>
1018
</div>
19+
<p class="text-xs text-gray-500 dark:text-gray-400">
20+
接口地址会根据平台、账号模式和 API 协议自动选择,并锁定为官方地址。
21+
</p>
1122
<template v-if="local.protocol === 'adaptive'">
12-
<label v-for="item in adaptiveItems" :key="item.key" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ item.label }} base URL
13-
<input v-model="local.api_base_urls[item.key]" class="input mt-1" type="url" :placeholder="item.placeholder" />
14-
</label>
23+
<div v-for="item in adaptiveItems" :key="item.key" class="space-y-1">
24+
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ item.label }} 官方地址</span>
25+
<code v-if="isReadOnly" class="block rounded-md bg-gray-50 px-3 py-2 text-xs text-gray-600 break-all dark:bg-dark-700 dark:text-gray-300">{{ item.url }}</code>
26+
<input v-else v-model="local.api_base_urls[item.key]" class="input mt-1" type="url" :placeholder="item.url" />
27+
</div>
1528
</template>
16-
<label v-else class="block text-sm font-medium text-gray-700 dark:text-gray-300">Base URL
17-
<input v-model="local.base_url" class="input mt-1" type="url" :placeholder="defaultBaseUrl" />
18-
</label>
29+
<div v-else class="space-y-1">
30+
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">官方接口地址</span>
31+
<code v-if="isReadOnly" class="block rounded-md bg-gray-50 px-3 py-2 text-xs text-gray-600 break-all dark:bg-dark-700 dark:text-gray-300">{{ defaultBaseUrl }}</code>
32+
<input v-else v-model="local.base_url" class="input mt-1" type="url" :placeholder="defaultBaseUrl" />
33+
</div>
1934
</div>
2035
</template>
2136
<script setup lang="ts">
2237
import { computed, reactive, watch } from 'vue'
2338
import { defaultCNAdaptiveBaseUrls, defaultCNBaseUrl, cnSupportsNativeResponses, type CnAccountMode, type CnApiProtocol, type CnProviderPlatform } from './credentialsBuilder'
24-
const props = defineProps<{ platform: string; modelValue: { mode: CnAccountMode; protocol: CnApiProtocol; base_url: string; api_base_urls: Record<string, string> } }>()
39+
const props = withDefaults(defineProps<{ platform: string; modelValue: { mode: CnAccountMode; protocol: CnApiProtocol; base_url: string; api_base_urls: Record<string, string> }; allowCustomBaseUrl?: boolean }>(), {
40+
allowCustomBaseUrl: false
41+
})
2542
const emit = defineEmits<{ (e: 'update:modelValue', value: typeof props.modelValue): void }>()
2643
const isCN = computed(() => ['kimi', 'zhipu', 'deepseek', 'minimax', 'qwen'].includes(props.platform))
44+
const isReadOnly = computed(() => props.allowCustomBaseUrl === false)
45+
const allowCustomBaseUrl = computed(() => !isReadOnly.value)
2746
const supportsResponses = computed(() => cnSupportsNativeResponses(props.platform))
2847
const local = reactive({ mode: props.modelValue.mode, protocol: props.modelValue.protocol, base_url: props.modelValue.base_url, api_base_urls: { ...props.modelValue.api_base_urls } })
2948
const defaultBaseUrl = computed(() => isCN.value ? defaultCNBaseUrl(props.platform, local.mode, local.protocol) : '')
30-
const adaptiveItems = computed(() => Object.entries(defaultCNAdaptiveBaseUrls(props.platform as CnProviderPlatform, local.mode)).filter(([key]) => key !== 'responses' || supportsResponses.value).map(([key, placeholder]) => ({ key, label: key === 'chat_completions' ? 'Chat Completions' : key[0].toUpperCase() + key.slice(1), placeholder })))
31-
watch(() => [local.mode, local.protocol] as const, ([mode, protocol], [previousMode, previousProtocol]) => {
32-
const previousDefault = defaultCNBaseUrl(props.platform, previousMode, previousProtocol)
33-
if (!local.base_url.trim() || local.base_url === previousDefault) {
34-
local.base_url = defaultCNBaseUrl(props.platform, mode, protocol)
49+
const adaptiveItems = computed(() => Object.entries(defaultCNAdaptiveBaseUrls(props.platform as CnProviderPlatform, local.mode))
50+
.filter(([key]) => key !== 'responses' || supportsResponses.value)
51+
.map(([key, url]) => ({
52+
key,
53+
label: key === 'chat_completions' ? 'Chat Completions' : key === 'anthropic' ? 'Anthropic Messages' : 'Responses',
54+
url
55+
})))
56+
57+
const sameConfig = (left: typeof props.modelValue, right: typeof props.modelValue) =>
58+
left.mode === right.mode &&
59+
left.protocol === right.protocol &&
60+
left.base_url === right.base_url &&
61+
JSON.stringify(left.api_base_urls) === JSON.stringify(right.api_base_urls)
62+
63+
const emitConfig = () => {
64+
const defaults = defaultCNAdaptiveBaseUrls(props.platform as CnProviderPlatform, local.mode)
65+
const isAdaptive = local.protocol === 'adaptive'
66+
const officialBaseUrl = isAdaptive ? defaults.chat_completions : defaultCNBaseUrl(props.platform, local.mode, local.protocol)
67+
const baseUrl = allowCustomBaseUrl.value ? (local.base_url.trim() || officialBaseUrl) : officialBaseUrl
68+
const apiBaseUrls = isAdaptive
69+
? Object.fromEntries(Object.entries(defaults).map(([key, url]) => [key, allowCustomBaseUrl.value ? (local.api_base_urls[key]?.trim() || url) : url]))
70+
: {}
71+
const nextConfig = {
72+
mode: local.mode,
73+
protocol: local.protocol,
74+
base_url: baseUrl,
75+
api_base_urls: apiBaseUrls
3576
}
36-
const previousURLs = defaultCNAdaptiveBaseUrls(props.platform as CnProviderPlatform, previousMode)
37-
const nextURLs = defaultCNAdaptiveBaseUrls(props.platform as CnProviderPlatform, mode)
38-
for (const key of Object.keys(nextURLs) as Array<keyof typeof nextURLs>) {
39-
if (!local.api_base_urls[key] || local.api_base_urls[key] === previousURLs[key]) {
40-
local.api_base_urls[key] = nextURLs[key]
41-
}
77+
if (local.base_url !== nextConfig.base_url) local.base_url = nextConfig.base_url
78+
if (JSON.stringify(local.api_base_urls) !== JSON.stringify(nextConfig.api_base_urls)) {
79+
local.api_base_urls = nextConfig.api_base_urls
4280
}
43-
})
44-
watch(local, () => emit('update:modelValue', { mode: local.mode, protocol: local.protocol, base_url: local.base_url, api_base_urls: { ...local.api_base_urls } }), { deep: true })
45-
const sameApiBaseUrls = (left: Record<string, string>, right: Record<string, string>) => {
46-
const leftKeys = Object.keys(left)
47-
const rightKeys = Object.keys(right)
48-
if (leftKeys.length !== rightKeys.length) return false
49-
return leftKeys.every((key) => left[key] === right[key])
81+
if (!sameConfig(props.modelValue, nextConfig)) emit('update:modelValue', nextConfig)
5082
}
51-
watch(() => [props.platform, props.modelValue], () => {
52-
const protocol = !cnSupportsNativeResponses(props.platform) && props.modelValue.protocol === 'responses'
83+
84+
watch(() => [local.mode, local.protocol, props.platform] as const, ([mode, protocol], previous) => {
85+
if (!cnSupportsNativeResponses(props.platform) && local.protocol === 'responses') {
86+
local.protocol = 'chat_completions'
87+
return
88+
}
89+
if (allowCustomBaseUrl.value && previous) {
90+
const previousDefault = defaultCNBaseUrl(previous[2], previous[0], previous[1])
91+
if (!local.base_url.trim() || local.base_url === previousDefault) {
92+
local.base_url = defaultCNBaseUrl(props.platform, mode, protocol)
93+
}
94+
const previousURLs = defaultCNAdaptiveBaseUrls(previous[2] as CnProviderPlatform, previous[0])
95+
const nextURLs = defaultCNAdaptiveBaseUrls(props.platform as CnProviderPlatform, mode)
96+
for (const key of Object.keys(nextURLs) as Array<keyof typeof nextURLs>) {
97+
if (!local.api_base_urls[key] || local.api_base_urls[key] === previousURLs[key]) {
98+
local.api_base_urls[key] = nextURLs[key]
99+
}
100+
}
101+
}
102+
emitConfig()
103+
}, { immediate: true })
104+
105+
watch(local, () => emitConfig(), { deep: true })
106+
107+
watch(() => props.modelValue, (value) => {
108+
if (local.mode !== value.mode) local.mode = value.mode
109+
const protocol = !cnSupportsNativeResponses(props.platform) && value.protocol === 'responses'
53110
? 'chat_completions'
54-
: props.modelValue.protocol
55-
if (local.mode !== props.modelValue.mode) local.mode = props.modelValue.mode
111+
: value.protocol
56112
if (local.protocol !== protocol) local.protocol = protocol
57-
if (local.base_url !== props.modelValue.base_url) local.base_url = props.modelValue.base_url
58-
if (!sameApiBaseUrls(local.api_base_urls, props.modelValue.api_base_urls)) {
59-
local.api_base_urls = { ...props.modelValue.api_base_urls }
113+
if (allowCustomBaseUrl.value) {
114+
if (local.base_url !== value.base_url) local.base_url = value.base_url
115+
if (JSON.stringify(local.api_base_urls) !== JSON.stringify(value.api_base_urls)) local.api_base_urls = { ...value.api_base_urls }
60116
}
61-
}, { deep: true, immediate: true })
117+
if (local.protocol === protocol && local.mode === value.mode) emitConfig()
118+
}, { deep: true })
62119
</script>

frontend/src/components/account/CreateAccountModal.vue

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1291,6 +1291,7 @@
12911291
<CNProviderSettings
12921292
v-if="isCNPlatform(form.platform)"
12931293
:platform="form.platform"
1294+
:allow-custom-base-url="false"
12941295
v-model="cnProviderConfig"
12951296
/>
12961297

@@ -4447,6 +4448,15 @@ watch(
44474448
form.type = 'apikey'
44484449
return
44494450
}
4451+
// 国内平台仅支持 API Key/上游配置。用户范围也必须保持 apikey,
4452+
// 否则会被误判为 OAuth 并落入默认的 Anthropic 授权流程。
4453+
if (isCNPlatform(form.platform)) {
4454+
accountCategory.value = 'apikey'
4455+
addMethod.value = 'oauth'
4456+
antigravityAccountType.value = 'oauth'
4457+
form.type = 'apikey'
4458+
return
4459+
}
44504460
if (isUserScope.value) {
44514461
if (accountCategory.value !== 'oauth-based') {
44524462
accountCategory.value = 'oauth-based'
@@ -4485,7 +4495,7 @@ watch(
44854495
watch(
44864496
() => form.platform,
44874497
(newPlatform) => {
4488-
if (isUserScope.value) {
4498+
if (isUserScope.value && !isCNPlatform(newPlatform as AccountPlatform)) {
44894499
accountCategory.value = 'oauth-based'
44904500
addMethod.value = 'oauth'
44914501
antigravityAccountType.value = 'oauth'
@@ -5009,7 +5019,7 @@ const resetForm = () => {
50095019
form.name = ''
50105020
form.notes = ''
50115021
form.platform = props.initialPlatform
5012-
form.type = 'oauth'
5022+
form.type = isCNPlatform(form.platform) ? 'apikey' : 'oauth'
50135023
form.share_mode = 'private'
50145024
form.account_level = props.initialAccountLevel
50155025
form.credentials = {}
@@ -5020,7 +5030,7 @@ const resetForm = () => {
50205030
form.rate_multiplier = 1
50215031
form.group_ids = []
50225032
form.expires_at = null
5023-
accountCategory.value = 'oauth-based'
5033+
accountCategory.value = isCNPlatform(form.platform) ? 'apikey' : 'oauth-based'
50245034
addMethod.value = 'oauth'
50255035
apiKeyBaseUrl.value = 'https://api.anthropic.com'
50265036
apiKeyValue.value = ''

frontend/src/components/account/EditAccountModal.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@
142142

143143
<!-- API Key fields (only for apikey type) -->
144144
<div v-if="(!isUserScope || isCNPlatform(account.platform)) && account.type === 'apikey'" class="space-y-4">
145-
<CNProviderSettings v-if="isCNPlatform(account.platform)" v-model="editCNConfig" :platform="account.platform" />
145+
<CNProviderSettings v-if="isCNPlatform(account.platform)" v-model="editCNConfig" :platform="account.platform" :allow-custom-base-url="false" />
146146
<div v-if="account.platform !== 'opencode' && !isCNPlatform(account.platform)">
147147
<label class="input-label">{{ t('admin.accounts.baseUrl') }}</label>
148148
<input

0 commit comments

Comments
 (0)