Skip to content

Commit 6d2a6ce

Browse files
committed
到期终止调错了 API:/terminate 是立即终止,不是到期终止
我把「到期注销」实现成 POST /{service}/terminate + confirmTermination。 用户照着做之后,OVH 当场把服务器暂停了,并邮件通知「5 天内不付款就彻底清除 硬盘数据,此操作无法撤销」——不是等到期日。一台在跑的机器因此停了。 ## 错在哪 OVH 的生命周期动作枚举 services.expanded.Lifecycle.ActionEnum 把两件事分得很清楚: terminate 立即终止 terminateAtExpirationDate 到期终止 /{service}/terminate 端点只对应前者,它没有「到期」这个选项可选。 到期终止只能通过 PUT /services/{serviceId} 的 terminationPolicy 字段设置: empty 不终止 / 撤销已提交的终止 terminateAtExpirationDate 到期日终止 terminateAtEngagementDate 合同期结束时终止 三个区都有这个端点和这三个取值。services.update.Service 的三个字段全部 canBeNull:true,所以只发 terminationPolicy 一个是合法的。 我之前查的是 OVH 通用计费文档那句「terminate 在当期订阅结束时生效」, 就当成确定结论告诉了用户「不会立即停机」。但同一批搜索结果里其实写着 OVH 的注销分「Cancel service now」和「Cancel service」两种,我没有去查 API 的 /terminate 对应哪一种就下了结论。 ## 改了什么 - 新增 PUT /api/{server,vps}-control/:name/termination-policy,走正确的端点 - 续费对话框的「到期终止」改用它;从「到期终止」切回自动/手动续费时会先发 policy=empty 撤销,否则续费模式改了、终止标记还挂着,到期照样销毁 - 前端不再有任何地方调用 /terminate - 独服 TerminateService 和 VPS 的 useTerminateVps 都加了警告注释说明它们是 立即终止;VPS 维护页那个独立入口本来就写明「确认后立即销毁」,是诚实的,保持原样 对话框现在写的是「到期日之前照常使用,到期后才销毁」,并告诉用户随时可以 切回自动/手动续费来撤销。
1 parent 44b2d04 commit 6d2a6ce

7 files changed

Lines changed: 200 additions & 121 deletions

File tree

server/internal/handlers/server_control_misc.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,14 @@ var terminationFutureUses = map[string]bool{
600600
"SUBSCRIBE_SIMILAR_SERVICE_WITH_COMPETITOR": true,
601601
}
602602

603+
// TerminateService ⚠️ 立即终止。
604+
//
605+
// 实测(2026-08-25):这条链路走完之后 OVH **当场把服务器暂停**,并邮件通知
606+
// 「5 天内不付款就彻底清除硬盘数据,此操作无法撤销」—— 不是等到期日。
607+
//
608+
// 想要「到期才终止」请用 UpdateTerminationPolicy(PUT /services/{serviceId} 的
609+
// terminationPolicy = terminateAtExpirationDate)。OVH 的生命周期动作枚举里
610+
// terminate 和 terminateAtExpirationDate 是两个不同的动作,这个端点只对应前者。
603611
func TerminateService(state *app.State) gin.HandlerFunc {
604612
return func(c *gin.Context) {
605613
svc := c.Param("service_name")
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package handlers
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
7+
"github.com/gin-gonic/gin"
8+
ovhsdk "github.com/ovh/go-ovh/ovh"
9+
10+
"github.com/ovh-buy/server/internal/app"
11+
)
12+
13+
// 到期终止策略。
14+
//
15+
// 血的教训:这件事**不能**用 POST /{service}/terminate。
16+
// OVH 的生命周期动作枚举 services.expanded.Lifecycle.ActionEnum 把两件事分得很清楚:
17+
//
18+
// terminate 立即终止 —— 提交后 OVH 当场把服务器暂停,
19+
// 并邮件通知"5 天内不付款就彻底清除硬盘数据"
20+
// terminateAtExpirationDate 到期终止 —— 用到当期结束,到期日才销毁
21+
//
22+
// /{service}/terminate 端点对应的是前者,它没有"到期"这个选项可选。
23+
// 到期终止只能通过 PUT /services/{serviceId} 的 terminationPolicy 字段设置。
24+
// 我一开始用错了端点,把用户一台在跑的服务器直接弄停了。
25+
//
26+
// terminationPolicy 的三个取值(三区一致):
27+
//
28+
// empty 不终止(也就是撤销已提交的到期终止)
29+
// terminateAtExpirationDate 到期日终止
30+
// terminateAtEngagementDate 合同期结束时终止(仅对有合同期的服务有意义)
31+
const (
32+
TerminationNone = "empty"
33+
TerminationAtExpiration = "terminateAtExpirationDate"
34+
TerminationAtEngagement = "terminateAtEngagementDate"
35+
)
36+
37+
var terminationPolicies = map[string]bool{
38+
TerminationNone: true,
39+
TerminationAtExpiration: true,
40+
TerminationAtEngagement: true,
41+
}
42+
43+
// setTerminationPolicy PUT /services/{serviceId}
44+
//
45+
// services.update.Service 的三个字段(displayName / renew / terminationPolicy)全部
46+
// canBeNull:true,所以只发要改的那一个是合法的 —— 不需要像 RenewType 那样补全。
47+
func setTerminationPolicy(client *ovhsdk.Client, serviceID int64, policy string) error {
48+
return client.Put(fmt.Sprintf("/services/%d", serviceID),
49+
map[string]interface{}{"terminationPolicy": policy}, nil)
50+
}
51+
52+
// UpdateTerminationPolicy PUT /api/server-control/:service_name/termination-policy
53+
func UpdateTerminationPolicy(state *app.State) gin.HandlerFunc {
54+
return terminationPolicyHandler(state, serviceIDForDedicated, "server_control", "服务器")
55+
}
56+
57+
// UpdateVpsTerminationPolicy PUT /api/vps-control/:service_name/termination-policy
58+
func UpdateVpsTerminationPolicy(state *app.State) gin.HandlerFunc {
59+
return terminationPolicyHandler(state, serviceIDForVps, "vps_control", "VPS")
60+
}
61+
62+
func terminationPolicyHandler(
63+
state *app.State,
64+
resolveID func(*ovhsdk.Client, string) (int64, error),
65+
logSource, label string,
66+
) gin.HandlerFunc {
67+
return func(c *gin.Context) {
68+
svc := c.Param("service_name")
69+
client, err := ovhClientFor(state, c)
70+
if err != nil {
71+
noOVHResp(c)
72+
return
73+
}
74+
var body struct {
75+
Policy string `json:"policy"`
76+
}
77+
_ = c.ShouldBindJSON(&body)
78+
if !terminationPolicies[body.Policy] {
79+
c.JSON(http.StatusBadRequest, gin.H{"success": false,
80+
"error": "policy 必须是 empty / terminateAtExpirationDate / terminateAtEngagementDate 之一"})
81+
return
82+
}
83+
84+
serviceID, err := resolveID(client, svc)
85+
if err != nil {
86+
c.JSON(http.StatusInternalServerError, gin.H{"success": false,
87+
"error": "取 serviceId 失败: " + err.Error()})
88+
return
89+
}
90+
if err := setTerminationPolicy(client, serviceID, body.Policy); err != nil {
91+
state.Logger.Error(label+" "+svc+" 设置终止策略失败: "+err.Error(), logSource)
92+
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
93+
return
94+
}
95+
96+
msg := "已设为到期终止:到期日之前照常使用,到期后销毁"
97+
if body.Policy == TerminationNone {
98+
msg = "已取消终止,服务恢复正常续费"
99+
} else if body.Policy == TerminationAtEngagement {
100+
msg = "已设为合同期结束时终止"
101+
}
102+
state.Logger.Warn(fmt.Sprintf("%s %s 终止策略改为 %s", label, svc, body.Policy), logSource)
103+
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg, "policy": body.Policy})
104+
}
105+
}

server/main.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,9 @@ func main() {
367367
sc.GET("/:service_name/ongoing", handlers.GetOngoingTasks(state))
368368
sc.GET("/:service_name/license/windows/compliant", handlers.GetCompliantWindowsVersions(state))
369369
sc.GET("/:service_name/license/windows-sql/compliant", handlers.GetCompliantWindowsSqlVersions(state))
370+
// 到期终止走 terminationPolicy,不是 /terminate ——
371+
// 后者是立即终止,提交即暂停服务器
372+
sc.PUT("/:service_name/termination-policy", handlers.UpdateTerminationPolicy(state))
370373
sc.POST("/:service_name/terminate", handlers.TerminateService(state))
371374
sc.POST("/:service_name/confirm-termination", handlers.ConfirmTermination(state))
372375
sc.GET("/:service_name/spla", handlers.GetSPLAList(state))
@@ -414,6 +417,7 @@ func main() {
414417

415418
// 杂项
416419
vc.POST("/:service_name/change-contact", handlers.ChangeVpsContact(state))
420+
vc.PUT("/:service_name/termination-policy", handlers.UpdateVpsTerminationPolicy(state))
417421
vc.POST("/:service_name/terminate", handlers.TerminateVps(state))
418422
vc.POST("/:service_name/confirm-termination", handlers.ConfirmVpsTermination(state))
419423
vc.GET("/:service_name/secondary-dns", handlers.GetVpsSecondaryDns(state))

web/src/components/server-control/RenewalDialog.tsx

Lines changed: 38 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,17 @@ import { Button } from "@/components/ui/button";
77
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
88
import {
99
useUpdateRenewal,
10-
useTerminateService,
11-
useConfirmTermination,
10+
useUpdateTerminationPolicy,
1211
type ServiceInfo,
1312
} from "@/hooks/use-server-control";
14-
import { Input } from "@/components/ui/input";
1513
import { toast } from "sonner";
1614

1715
type RenewMode = "auto" | "manual" | "delete";
1816

1917
const MODE_OPTIONS: Array<{ value: RenewMode; label: string; desc: string }> = [
2018
{ value: "auto", label: "自动续费", desc: "到期前 OVH 自动扣款续费" },
2119
{ value: "manual", label: "手动续费", desc: "到期前需手动付款,不付则服务终止" },
22-
{ value: "delete", label: "到期注销", desc: "取消续费,到期后销毁(需邮件确认)" },
20+
{ value: "delete", label: "到期终止", desc: "到期日之前照常使用,到期后才销毁" },
2321
];
2422

2523
/** 用 VPS / dedicated 各自的 update hook 都行,Dialog 只关心 mutation 接口形状 */
@@ -31,9 +29,8 @@ export type RenewalMutation = {
3129
/** 终止流程的两步。VPS 和独服的端点不同(/vps-control 与 /server-control),
3230
* 所以必须由调用方注入 —— 写死一边会让另一边打到错误的端点上。 */
3331
export type TerminationMutations = {
34-
terminate: { mutateAsync: () => Promise<any>; isPending: boolean };
35-
confirm: {
36-
mutateAsync: (vars: { token: string }) => Promise<any>;
32+
policy: {
33+
mutateAsync: (vars: { policy: string }) => Promise<any>;
3734
isPending: boolean;
3835
};
3936
};
@@ -67,25 +64,20 @@ export function RenewalDialog({
6764
const defaultUpdate = useUpdateRenewal(serviceName);
6865
const update = mutation ?? defaultUpdate;
6966

70-
// 到期注销走的是 OVH 的服务终止流程,不是 serviceInfos 里的 renew 标志位。
71-
// 那条路会被 OVH 回 400 "Arguments conflicting",而且它自己的 issue 里记着
72-
// 这组标志位行为不可预测。终止有专用端点,也是 OVH 控制台走的那条。
73-
// 代价是必须邮件确认 —— 这是 OVH 的规定,绕不开,所以对话框要有第二步。
74-
const defaultTerminate = useTerminateService(serviceName);
75-
const defaultConfirm = useConfirmTermination(serviceName);
76-
const terminate = termination?.terminate ?? defaultTerminate;
77-
const confirmTerm = termination?.confirm ?? defaultConfirm;
78-
// 已经发起终止、正在等用户填邮件里的 token
79-
const [awaitingToken, setAwaitingToken] = useState(false);
80-
const [token, setToken] = useState("");
67+
// 到期终止走 PUT /services/{serviceId} 的 terminationPolicy。
68+
//
69+
// **不要**用 POST /terminate —— 那是「立即终止」,提交后 OVH 当场把服务器暂停,
70+
// 并邮件通知「5 天内不付款就彻底清除硬盘数据」。这个坑真实踩过一次。
71+
// OVH 的生命周期动作枚举里 terminate 与 terminateAtExpirationDate 是两个不同的动作,
72+
// /terminate 端点只对应前者,没有「到期」这个选项可选。
73+
const defaultPolicy = useUpdateTerminationPolicy(serviceName);
74+
const policyMut = termination?.policy ?? defaultPolicy;
8175

8276
// 弹窗每次打开同步当前状态
8377
useEffect(() => {
8478
if (open) {
8579
setMode(currentMode);
8680
setPeriod(info.renewalPeriod || 1);
87-
setAwaitingToken(false);
88-
setToken("");
8981
}
9082
// eslint-disable-next-line react-hooks/exhaustive-deps
9183
}, [open]);
@@ -100,38 +92,29 @@ export function RenewalDialog({
10092
const handleSubmit = async () => {
10193
if (mode === "delete") {
10294
try {
103-
const res = await terminate.mutateAsync();
104-
setAwaitingToken(true);
105-
toast.info(res?.message || "终止请求已提交,确认码已发到管理员邮箱", { duration: 8000 });
95+
const res = await policyMut.mutateAsync({ policy: "terminateAtExpirationDate" });
96+
toast.success(res?.message || "已设为到期终止", { duration: 6000 });
97+
onOpenChange(false);
10698
} catch (e: any) {
107-
toast.error(errText(e, "发起终止失败"), { duration: 6000 });
99+
toast.error(errText(e, "设置失败"), { duration: 8000 });
108100
}
109101
return;
110102
}
103+
// 从「到期终止」切回自动/手动续费时,先把终止策略撤掉,
104+
// 否则续费模式改了、终止标记还挂着,到期照样销毁。
111105
try {
106+
if (currentMode === "delete") {
107+
await policyMut.mutateAsync({ policy: "empty" });
108+
}
112109
await update.mutateAsync({ mode, period });
113-
toast.success("续费策略已更新");
110+
toast.success(currentMode === "delete" ? "已取消终止并更新续费策略" : "续费策略已更新");
114111
onOpenChange(false);
115112
} catch (e: any) {
116113
toast.error(errText(e, "更新失败"), { duration: 6000 });
117114
}
118115
};
119116

120-
const handleConfirmTerm = async () => {
121-
if (!token.trim()) {
122-
toast.error("请填写邮件里的确认码");
123-
return;
124-
}
125-
try {
126-
await confirmTerm.mutateAsync({ token: token.trim() });
127-
toast.success("已确认终止。到期日之前服务器照常运行,到期后才销毁");
128-
onOpenChange(false);
129-
} catch (e: any) {
130-
toast.error(errText(e, "确认失败"), { duration: 8000 });
131-
}
132-
};
133-
134-
const busy = update.isPending || terminate.isPending || confirmTerm.isPending;
117+
const busy = update.isPending || policyMut.isPending;
135118

136119
return (
137120
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -213,45 +196,17 @@ export function RenewalDialog({
213196
</div>
214197
)}
215198

216-
{mode === "delete" && !awaitingToken && (
199+
{mode === "delete" && (
217200
<div className="border border-destructive/40 bg-destructive/5 rounded-xl p-2.5 flex gap-2">
218201
<AlertCircle className="w-3.5 h-3.5 text-destructive flex-shrink-0 mt-0.5" />
219202
<div className="text-[11px] text-muted-foreground space-y-1">
220203
<p>
221-
服务器将在到期日 (
204+
到期日 (
222205
{info.expiration ? new Date(info.expiration).toLocaleDateString("zh-CN") : "—"}
223-
) 销毁,数据无法恢复。
206+
) 之前<b>照常使用</b>,到期后才销毁,数据无法恢复。
224207
</p>
225-
<p>
226-
OVH 要求邮件确认:点下面的按钮后会往账户管理员邮箱发一封确认邮件,
227-
把里面的确认码填回来才真正生效。
228-
</p>
229-
<p>
230-
到期之前服务器<b>照常运行</b>,不会立即停机 —— OVH 的终止是
231-
「当期订阅结束时才生效」。立即释放资源是另一个接口,本控制台不调用。
232-
</p>
233-
<p>
234-
<b>反悔要去 OVH 控制台</b>(API 没有取消终止的端点):
235-
My offers and services → 该服务右侧 <code>...</code>
236-
Stop cancellation of service。撤销是立即生效的。
237-
</p>
238-
</div>
239-
</div>
240-
)}
241-
242-
{mode === "delete" && awaitingToken && (
243-
<div className="space-y-2 pt-1">
244-
<div className="border border-amber-500/40 bg-amber-500/10 rounded-xl p-2.5 text-[11px] text-muted-foreground">
245-
确认邮件已发到账户管理员邮箱。把邮件里的确认码填在下面 ——
246-
<b>没填之前终止不会生效</b>,服务器仍会照常续费。
208+
<p>改主意了随时回到这里选「自动续费」或「手动续费」即可撤销。</p>
247209
</div>
248-
<label className="text-[12px] font-semibold block">邮件里的确认码</label>
249-
<Input
250-
value={token}
251-
onChange={(e) => setToken(e.target.value)}
252-
placeholder="粘贴邮件中的 token"
253-
autoFocus
254-
/>
255210
</div>
256211
)}
257212
</div>
@@ -261,23 +216,17 @@ export function RenewalDialog({
261216
<Button variant="outline" onClick={() => onOpenChange(false)}>
262217
取消
263218
</Button>
264-
{!info.renewalForced &&
265-
(awaitingToken ? (
266-
<Button onClick={handleConfirmTerm} disabled={busy} variant="destructive">
267-
{confirmTerm.isPending ? "确认中…" : "确认终止"}
268-
</Button>
269-
) : (
270-
<Button
271-
onClick={handleSubmit}
272-
disabled={
273-
busy ||
274-
(mode !== "delete" && mode === currentMode && period === info.renewalPeriod)
275-
}
276-
variant={mode === "delete" ? "destructive" : "default"}
277-
>
278-
{busy ? "提交中…" : mode === "delete" ? "申请终止" : "保存"}
279-
</Button>
280-
))}
219+
{!info.renewalForced && (
220+
<Button
221+
onClick={handleSubmit}
222+
disabled={
223+
busy || (mode !== "delete" && mode === currentMode && period === info.renewalPeriod)
224+
}
225+
variant={mode === "delete" ? "destructive" : "default"}
226+
>
227+
{busy ? "提交中…" : "保存"}
228+
</Button>
229+
)}
281230
</DialogFooter>
282231
</DialogContent>
283232
</Dialog>

web/src/hooks/use-server-control.ts

Lines changed: 18 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -126,34 +126,32 @@ export function useUpdateRenewal(serviceName: string) {
126126
});
127127
}
128128

129-
/* ──────────── 服务终止(到期注销) ──────────── */
129+
/* ──────────── 到期终止策略 ──────────── */
130130

131131
/**
132-
* 请求终止服务。OVH 会把确认 token 发到账户管理员邮箱,拿到后再调 confirm
132+
* 设置终止策略
133133
*
134-
* 为什么「到期注销」不走 serviceInfos 的 renew 字段:那条路 OVH 会回
135-
* 400 "Arguments conflicting",而且 OVH 自己的 issue 里记录着这组标志位
136-
* 行为不可预测(同一份 payload 发两次会在自动/手动之间来回跳)。
137-
* 终止是有专用端点的(POST /terminate + POST /confirmTermination),
138-
* 也是 OVH 控制台「Terminate my service」走的那条 —— 效果就是
139-
* 「取消续费,服务保留到当期结束后销毁」,正是这里要的语义。
134+
* **不要**用 POST /terminate —— 那是「立即终止」,提交后 OVH 当场把服务器暂停,
135+
* 并邮件通知「5 天内不付款就彻底清除硬盘数据」。这个坑真实踩过一次。
136+
*
137+
* OVH 的生命周期动作枚举把两件事分得很清楚:
138+
* terminate 立即终止
139+
* terminateAtExpirationDate 到期终止(用到当期结束)
140+
* 到期终止只能通过 PUT /services/{serviceId} 的 terminationPolicy 设置。
141+
*
142+
* policy 取值:
143+
* empty 不终止 / 撤销已提交的到期终止
144+
* terminateAtExpirationDate 到期日终止
145+
* terminateAtEngagementDate 合同期结束时终止
140146
*/
141-
export function useTerminateService(serviceName: string) {
147+
export function useUpdateTerminationPolicy(serviceName: string) {
148+
const qc = useQueryClient();
142149
return useMutation({
143-
mutationFn: async () =>
144-
(await api.post(`/server-control/${serviceName}/terminate`)).data as {
150+
mutationFn: async (vars: { policy: string }) =>
151+
(await api.put(`/server-control/${serviceName}/termination-policy`, vars)).data as {
145152
success: boolean;
146153
message: string;
147154
},
148-
});
149-
}
150-
151-
/** 用邮件里的 token 确认终止 */
152-
export function useConfirmTermination(serviceName: string) {
153-
const qc = useQueryClient();
154-
return useMutation({
155-
mutationFn: async (vars: { token: string; reason?: string; commentary?: string }) =>
156-
(await api.post(`/server-control/${serviceName}/confirm-termination`, vars)).data,
157155
onSuccess: () => {
158156
qc.invalidateQueries({ queryKey: qk.serverControl.serviceInfo(serviceName) });
159157
},

0 commit comments

Comments
 (0)