Files
Jiahui ea8e5cf0a9 feat(account): Add workspace subscription plan (#6103)
* initialize the data structure

* Initialize the lifecycle management of workspace subscriptions

* fix index

* add workspace subscription debt

* optimize parse period & init workspace subscription & fix subscription create bug

* remove quota controller

* add user notify framework:
content_generator:content generation
service:event notification service
providers:vms/sms/email ... provider implementation
types: core structure type definition

* optimize struct & adjust the database to obtain data

* Implement the initialization of subscriptions and the implementation of subscription traffic packages

* service implement the database layer

* auto add column payment.stripe

* add stripe pay interface

* add workspace subscription service

* add the usercr tag for subscription based spaces, Add delete workspace subscription operator & Add api interfaces related to obtaining workspace costs

* optimize interface

* overall adjustment and repair

* optimize traffic early warning notifications

* optimize traffic early warning notifications

* optimize

* optimize & add deletion account api

* optimize log

* add get payment status api && support workspace subscription AI quota package

* optimize

* Fixed the issue where traffic was not distributed after renewing the subscription package

* fix

* fix workspace subscription transaction process add ai quota package

* fix ci lint

* fix controller/account ci lint

* fix controller/pkg ci lint

* fix controller/account ci lint

* fix controller/pkg ci lint

* add admin add workspace subscription

* fix upgrade workspace subscription

* add get workspace subscription plans api

* add get workspace subscription plans api

* fix ci lint

* fix ci lint

* fix ci lint
2025-10-26 16:15:52 +08:00

370 lines
14 KiB
Go

// Copyright © 2024 sealos.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package usernotify
import (
"encoding/json"
"fmt"
"strings"
"github.com/labring/sealos/controllers/pkg/types"
)
// DefaultContentGenerator 默认内容生成器
type DefaultContentGenerator struct {
config map[NotificationMethod]ProviderConfig
}
// NewDefaultContentGenerator 创建默认内容生成器
func NewDefaultContentGenerator(
config map[NotificationMethod]ProviderConfig,
) *DefaultContentGenerator {
return &DefaultContentGenerator{config: config}
}
// GenerateContent 生成通知内容
func (g *DefaultContentGenerator) GenerateContent(
event *NotificationEvent,
method NotificationMethod,
) (title, content, templateID string, err error) {
switch event.EventType {
case EventTypeDebtStatusChange:
return g.generateDebtContent(method, event.EventData, event.Recipient)
case EventTypeSubscriptionStatusChange,
EventTypeSubscriptionOperationDone,
EventTypeSubscriptionPaymentDone,
EventTypeWorkspaceSubscriptionCreatedSuccess,
EventTypeWorkspaceSubscriptionCreatedFailed,
EventTypeWorkspaceSubscriptionUpgradedSuccess,
EventTypeWorkspaceSubscriptionUpgradedFailed,
EventTypeWorkspaceSubscriptionRenewedSuccess,
EventTypeWorkspaceSubscriptionRenewedBalanceFallback,
EventTypeWorkspaceSubscriptionRenewedFailed,
EventTypeWorkspaceSubscriptionDebt,
EventTypeWorkspaceSubscriptionDebtPreDeletion,
EventTypeTrafficUsageAlert:
return g.generateWorkspaceSubscriptionContent(method, event)
case EventTypeTrafficStatusChange /*EventTypeTrafficUsageAlert*/ :
return g.generateTrafficContent(method, event.EventData, event.Recipient)
case EventTypeCustom:
return g.generateCustomContent(method, event.EventData, event.Recipient)
default:
return "", "", "", fmt.Errorf("unsupported event type: %s", event.EventType)
}
}
// generateDebtContent 生成债务相关通知内容
func (g *DefaultContentGenerator) generateDebtContent(
method NotificationMethod,
eventData map[string]any,
recipient types.NotificationRecipient,
) (title, content, templateID string, err error) {
var debtData DebtEventData
dataBytes, err := json.Marshal(eventData)
if err != nil {
return "", "", "", fmt.Errorf("failed to marshal debt event data: %w", err)
}
if err := json.Unmarshal(dataBytes, &debtData); err != nil {
return "", "", "", fmt.Errorf("failed to parse debt event data: %w", err)
}
// 获取模板ID
if config, exists := g.config[method]; exists {
templateID = config.GetVMSTemplateID(EventTypeDebtStatusChange)
switch method {
case NotificationMethodSMS:
templateID = config.GetSMSTemplateCode(EventTypeDebtStatusChange)
case NotificationMethodEmail:
templateID = config.GetEmailTemplate(EventTypeDebtStatusChange)
}
}
switch debtData.CurrentStatus {
case types.DebtPeriod:
if debtData.LastStatus != types.DebtPeriod {
// 首次进入欠费状态
title = "账户欠费通知"
content = g.formatContent(
method,
"尊敬的{{.UserName}},您的账户余额不足,请及时充值以避免服务中断。",
map[string]any{
"UserName": recipient.UserName,
},
)
}
case types.DebtDeletionPeriod:
title = "账户欠费警告"
content = g.formatContent(
method,
"尊敬的{{.UserName}},您的账户已欠费{{.DebtDays}}天,请尽快充值以避免服务中断。",
map[string]any{
"UserName": recipient.UserName,
"DebtDays": debtData.DebtDays,
},
)
case types.FinalDeletionPeriod:
title = "资源清理预警"
content = g.formatContent(
method,
"尊敬的{{.UserName}},您的账户已欠费{{.DebtDays}}天,系统将在24小时内进行资源清理,请立即充值!",
map[string]any{
"UserName": recipient.UserName,
"DebtDays": debtData.DebtDays,
},
)
case types.NormalPeriod:
if debtData.LastStatus != types.NormalPeriod {
// 恢复正常状态
title = "账户恢复通知"
content = g.formatContent(
method,
"尊敬的{{.UserName}},您的账户已恢复正常,感谢您的及时充值。",
map[string]any{
"UserName": recipient.UserName,
},
)
}
default:
return "", "", "", fmt.Errorf("unsupported debt status: %s", debtData.CurrentStatus)
}
return title, content, templateID, nil
}
func (g *DefaultContentGenerator) generateWorkspaceSubscriptionContent(
method NotificationMethod,
event *NotificationEvent,
) (title, content, templateID string, err error) {
var subData WorkspaceSubscriptionEventData
dataBytes, err := json.Marshal(event.EventData)
if err != nil {
return "", "", "", fmt.Errorf("failed to marshal subscription event data: %w", err)
}
if err := json.Unmarshal(dataBytes, &subData); err != nil {
return "", "", "", fmt.Errorf("failed to parse subscription event data: %w", err)
}
// 获取模板ID
if config, exists := g.config[method]; exists {
templateID = config.GetVMSTemplateID(EventTypeSubscriptionOperationDone)
switch method {
case NotificationMethodSMS:
templateID = config.GetSMSTemplateCode(EventTypeSubscriptionOperationDone)
case NotificationMethodEmail:
templateID, err = config.GenerateEmailContent(event)
if err != nil {
return "", "", "", fmt.Errorf("failed to generate email content: %w", err)
}
}
}
var contentTmpl string
switch event.EventType {
case EventTypeWorkspaceSubscriptionCreatedSuccess:
title = "Workspace Subscription Created Successfully"
// contentTmpl = "尊敬的{{.UserName}},您的工作空间 {{.PlanName}} 订阅已成功创建。"
contentTmpl = "Dear {{.UserName}}, your workspace subscription for {{.PlanName}} has been successfully created."
case EventTypeWorkspaceSubscriptionCreatedFailed:
title = "Workspace Subscription Creation Failed"
// contentTmpl = "尊敬的{{.UserName}},您的{{.PlanName}}订阅创建失败,原因:{{.ErrorReason}}。"
contentTmpl = "Dear {{.UserName}}, your subscription for {{.PlanName}} failed to be created. Reason: {{.ErrorReason}}."
case EventTypeWorkspaceSubscriptionUpgradedSuccess:
title = "Workspace Subscription Upgraded Successfully"
// contentTmpl = "尊敬的{{.UserName}},您的{{.PlanName}}订阅已成功升级。"
contentTmpl = "Dear {{.UserName}}, your subscription for {{.PlanName}} has been successfully upgraded."
case EventTypeWorkspaceSubscriptionUpgradedFailed:
title = "Workspace Subscription Upgrade Failed"
// contentTmpl = "尊敬的{{.UserName}},您的{{.PlanName}}订阅升级失败,原因:{{.ErrorReason}}。"
contentTmpl = "Dear {{.UserName}}, your subscription upgrade for {{.PlanName}} failed. Reason: {{.ErrorReason}}."
case EventTypeWorkspaceSubscriptionRenewedSuccess:
title = "Workspace Subscription Renewed Successfully"
// contentTmpl = "尊敬的{{.UserName}},您的{{.PlanName}}订阅已成功续订。"
contentTmpl = "Dear {{.UserName}}, your subscription for {{.PlanName}} has been successfully renewed."
case EventTypeWorkspaceSubscriptionRenewedBalanceFallback:
title = "Workspace Subscription Renewed with Balance Fallback"
// contentTmpl = "尊敬的{{.UserName}},您的{{.PlanName}}订阅续订因余额不足已切换至备用支付方式。"
contentTmpl = "Dear {{.UserName}}, your subscription for {{.PlanName}} has been renewed using your balance due to insufficient funds."
case EventTypeWorkspaceSubscriptionRenewedFailed:
title = "Workspace Subscription Renewal Failed"
// contentTmpl = "尊敬的{{.UserName}},您的{{.PlanName}}订阅续订失败,原因:{{.ErrorReason}}。"
contentTmpl = "Dear {{.UserName}}, your subscription renewal for {{.PlanName}} failed. Reason: {{.ErrorReason}}."
case EventTypeWorkspaceSubscriptionDebt, EventTypeWorkspaceSubscriptionDebtPreDeletion:
title = "Workspace Subscription Debt Notice"
// contentTmpl = "尊敬的{{.UserName}},您的工作空间{{.Workspace}}订阅已进入欠费状态,请及时充值以避免服务中断。"
contentTmpl = "Dear {{.UserName}}, your workspace subscription for {{.Workspace}} is in debt status. Please recharge promptly to avoid service interruption."
case EventTypeTrafficUsageAlert:
title = "Workspace Traffic Usage Alert"
// contentTmpl = "尊敬的{{.UserName}},您的工作空间{{.Workspace}}流量使用已达到{{.UsagePercent}}%,请注意控制使用。"
contentTmpl = "Dear {{.UserName}}, your workspace {{.Workspace}} has used {{.UsagePercent}}% of its traffic. Please monitor your usage."
}
content = g.formatContent(method, contentTmpl, map[string]any{
"UserName": event.Recipient.UserName,
"PlanName": subData.NewPlanName,
"ErrorReason": subData.ErrorReason,
"Workspace": subData.WorkspaceName,
"Amount": subData.Amount,
"Domain": subData.Domain,
})
return title, content, templateID, nil
}
// generateTrafficContent 生成流量相关通知内容
func (g *DefaultContentGenerator) generateTrafficContent(
method NotificationMethod,
eventData map[string]any,
recipient types.NotificationRecipient,
) (title, content, templateID string, err error) {
var trafficData WorkspaceSubscriptionTrafficEventData
dataBytes, err := json.Marshal(eventData)
if err != nil {
return "", "", "", fmt.Errorf("failed to marshal traffic event data: %w", err)
}
if err := json.Unmarshal(dataBytes, &trafficData); err != nil {
return "", "", "", fmt.Errorf("failed to parse traffic event data: %w", err)
}
// 获取模板ID
if config, exists := g.config[method]; exists {
templateID = config.GetVMSTemplateID(EventTypeTrafficStatusChange)
switch method {
case NotificationMethodSMS:
templateID = config.GetSMSTemplateCode(EventTypeTrafficStatusChange)
case NotificationMethodEmail:
templateID = config.GetEmailTemplate(EventTypeTrafficStatusChange)
}
}
switch trafficData.Status {
case /*types.WorkspaceTrafficStatusExhausted,*/ types.WorkspaceTrafficStatusUsedUp:
title = "流量已用尽"
content = g.formatContent(
method,
"尊敬的{{.UserName}},您的工作空间{{.Workspace}}流量已用尽,请及时购买流量包。",
map[string]any{
"UserName": recipient.UserName,
"Workspace": trafficData.Workspace,
},
)
default:
if trafficData.UsagePercent >= 80 {
title = "流量预警"
content = g.formatContent(
method,
"尊敬的{{.UserName}},您的工作空间{{.Workspace}}流量使用已达到{{.UsagePercent}}%,请注意控制使用。",
map[string]any{
"UserName": recipient.UserName,
"Workspace": trafficData.Workspace,
"UsagePercent": trafficData.UsagePercent,
},
)
}
}
return title, content, templateID, nil
}
// generateCustomContent 生成自定义通知内容
func (g *DefaultContentGenerator) generateCustomContent(
method NotificationMethod,
eventData map[string]any,
recipient types.NotificationRecipient,
) (title, content, templateID string, err error) {
var customData CustomEventData
dataBytes, err := json.Marshal(eventData)
if err != nil {
return "", "", "", fmt.Errorf("failed to marshal custom event data: %w", err)
}
if err := json.Unmarshal(dataBytes, &customData); err != nil {
return "", "", "", fmt.Errorf("failed to parse custom event data: %w", err)
}
// 获取模板ID
if config, exists := g.config[method]; exists {
templateID = config.GetVMSTemplateID(EventTypeCustom)
switch method {
case NotificationMethodSMS:
templateID = config.GetSMSTemplateCode(EventTypeCustom)
case NotificationMethodEmail:
templateID = config.GetEmailTemplate(EventTypeCustom)
}
}
title = customData.Title
content = g.formatContent(method, customData.Content, map[string]any{
"UserName": recipient.UserName,
})
// 合并额外数据
if customData.ExtraData != nil {
for key, value := range customData.ExtraData {
placeholder := fmt.Sprintf("{{.%s}}", key)
content = strings.ReplaceAll(content, placeholder, fmt.Sprintf("%v", value))
}
}
return title, content, templateID, nil
}
// formatContent 格式化内容,根据不同的通知方式进行适配
func (g *DefaultContentGenerator) formatContent(
method NotificationMethod,
template string,
data map[string]any,
) string {
content := template
// 基本参数替换
for key, value := range data {
placeholder := fmt.Sprintf("{{.%s}}", key)
content = strings.ReplaceAll(content, placeholder, fmt.Sprintf("%v", value))
}
// 根据通知方式进行内容适配
switch method {
case NotificationMethodSMS:
// SMS内容简化,确保在短信字数限制内
content = g.simplifySMSContent(content)
case NotificationMethodEmail:
// Email可以包含更丰富的内容和格式
content = g.enrichEmailContent(content)
case NotificationMethodVMS:
// VMS需要语音友好的内容
content = g.optimizeVMSContent(content)
}
return content
}
// simplifySMSContent 简化SMS内容
func (g *DefaultContentGenerator) simplifySMSContent(content string) string {
// 移除敬语,简化表达
content = strings.ReplaceAll(content, "尊敬的", "")
content = strings.ReplaceAll(content, "请及时", "请")
content = strings.ReplaceAll(content, "以避免服务中断", "")
return content
}
// enrichEmailContent 丰富Email内容
func (g *DefaultContentGenerator) enrichEmailContent(content string) string {
// Email可以添加更多详细信息和格式
return content + "\n\n如有疑问,请联系技术支持。\n\n此邮件由Sealos系统自动发送,请勿回复。"
}
// optimizeVMSContent 优化VMS语音内容
func (g *DefaultContentGenerator) optimizeVMSContent(content string) string {
content = strings.ReplaceAll(content, "%", "百分之")
return content
}