Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions controller/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ var auditContentTemplates = map[string]string{

"subscription.plan_reset": "Reset active subscriptions for plan ${plan_id}",
"subscription.user_plan_reset": "Reset active plan ${plan_id} subscriptions for user ${target_user_id}",
"subscription.admin_grant": "Granted subscription plan ${plan_title} (ID: ${plan_id}) to user ${target_user_id}",
"subscription.granted": "Admin granted you subscription plan ${plan_title} (ID: ${plan_id})",
}

// auditContentEN 按 action 模板渲染英文兜底文本;未登记的 action 退回 action 本身。
Expand Down
21 changes: 21 additions & 0 deletions controller/subscription.go
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,7 @@ func AdminBindSubscription(c *gin.Context) {
common.ApiError(c, err)
return
}
recordSubscriptionGrantLogs(c, req.UserId, req.PlanId)
if msg != "" {
common.ApiSuccess(c, gin.H{"message": msg})
return
Expand Down Expand Up @@ -417,6 +418,25 @@ func recordSubscriptionResetUserLogs(result *model.SubscriptionResetResult, admi
}
}

// recordSubscriptionGrantLogs records both the target user's manage log and the
// operator audit entry after an admin grants a subscription without payment.
func recordSubscriptionGrantLogs(c *gin.Context, userId int, planId int) {
planTitle := ""
if plan, err := model.GetSubscriptionPlanById(planId); err == nil && plan != nil {
planTitle = plan.Title
}
// The target user's manage log stores a language-neutral op descriptor so
// the frontend can localize it per viewer, like the other audit logs.
userParams := map[string]interface{}{"plan_title": planTitle, "plan_id": planId}
model.RecordOperationAuditLog(userId, auditContentEN("subscription.granted", userParams), "",
"subscription.granted", userParams, auditOperatorInfo(c), nil)
recordManageAuditFor(c, userId, "subscription.admin_grant", map[string]interface{}{
"target_user_id": userId,
"plan_id": planId,
"plan_title": planTitle,
})
}

// AdminCreateUserSubscription creates a new user subscription from a plan (no payment).
func AdminCreateUserSubscription(c *gin.Context) {
if !requirePaymentCompliance(c) {
Expand All @@ -438,6 +458,7 @@ func AdminCreateUserSubscription(c *gin.Context) {
common.ApiError(c, err)
return
}
recordSubscriptionGrantLogs(c, userId, req.PlanId)
if msg != "" {
common.ApiSuccess(c, gin.H{"message": msg})
return
Expand Down
7 changes: 7 additions & 0 deletions model/subscription.go
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,13 @@ func CreateUserSubscriptionFromPlanTx(tx *gorm.DB, userId int, plan *Subscriptio
if userId <= 0 {
return nil, errors.New("invalid user id")
}
// Lock the user row so concurrent grants for the same user are serialized.
// Without it the MaxPurchasePerUser count below races: two transactions can
// both read count == max-1 and both insert, exceeding the limit.
var lockedUser User
if err := lockForUpdate(tx).Where("id = ?", userId).First(&lockedUser).Error; err != nil {
return nil, err
}
if plan.MaxPurchasePerUser > 0 {
var count int64
if err := tx.Model(&UserSubscription{}).
Expand Down
97 changes: 97 additions & 0 deletions model/subscription_admin_grant_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package model

import (
"testing"

"github.com/QuantumNous/new-api/common"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"gorm.io/gorm/utils/tests"
)

func seedGrantUser(t *testing.T, id int, group string) {
t.Helper()
require.NoError(t, DB.Create(&User{Id: id, Username: "grant-user", Group: group}).Error)
}

func seedGrantPlan(t *testing.T, plan *SubscriptionPlan) {
t.Helper()
require.NoError(t, DB.Create(plan).Error)
}

// The purchase-limit count in CreateUserSubscriptionFromPlanTx runs inside the
// transaction that locks the user row, so the row lock must be part of the
// statement that guards it.
func TestCreateUserSubscriptionLocksUserRow(t *testing.T) {
dummyDB, err := gorm.Open(tests.DummyDialector{}, &gorm.Config{DryRun: true})
require.NoError(t, err)
buildSQL := func() string {
var user User
return lockForUpdate(dummyDB).Where("id = ?", 1).Find(&user).Statement.SQL.String()
}

t.Cleanup(func() {
common.SetDatabaseTypes(common.DatabaseTypeSQLite, common.DatabaseTypeSQLite)
})

common.SetDatabaseTypes(common.DatabaseTypeMySQL, common.DatabaseTypeSQLite)
sql := buildSQL()
assert.Contains(t, sql, "users")
assert.Contains(t, sql, "FOR UPDATE")
}

func TestCreateUserSubscriptionStillEnforcesPurchaseLimit(t *testing.T) {
truncateTables(t)

seedGrantUser(t, 101, "default")
plan := &SubscriptionPlan{
Id: 9301,
Title: "Pro",
PriceAmount: 10,
DurationUnit: SubscriptionDurationMonth,
DurationValue: 1,
TotalAmount: 1000,
QuotaResetPeriod: SubscriptionResetNever,
MaxPurchasePerUser: 1,
}
seedGrantPlan(t, plan)

// DB is passed directly instead of opening a transaction: the test harness
// caps the pool at one connection, and GetDBTimestamp inside the callee
// would deadlock waiting for a second one.
_, err := CreateUserSubscriptionFromPlanTx(DB, 101, plan, "admin")
require.NoError(t, err)

_, err = CreateUserSubscriptionFromPlanTx(DB, 101, plan, "admin")
require.Error(t, err)
assert.Contains(t, err.Error(), "已达到该套餐购买上限")

var count int64
require.NoError(t, DB.Model(&UserSubscription{}).Where("user_id = ? AND plan_id = ?", 101, plan.Id).Count(&count).Error)
assert.Equal(t, int64(1), count)
}

// Locking the user row also means a grant for a non-existent user now fails
// up front instead of creating an orphan subscription.
func TestCreateUserSubscriptionRejectsUnknownUser(t *testing.T) {
truncateTables(t)

plan := &SubscriptionPlan{
Id: 9302,
Title: "Pro",
PriceAmount: 10,
DurationUnit: SubscriptionDurationMonth,
DurationValue: 1,
TotalAmount: 1000,
QuotaResetPeriod: SubscriptionResetNever,
}
seedGrantPlan(t, plan)

_, err := CreateUserSubscriptionFromPlanTx(DB, 999, plan, "admin")
require.Error(t, err)

var count int64
require.NoError(t, DB.Model(&UserSubscription{}).Count(&count).Error)
assert.Equal(t, int64(0), count)
}
4 changes: 4 additions & 0 deletions web/default/src/features/usage-logs/lib/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,10 @@ const AUDIT_TEMPLATES: Record<string, string> = {
'subscription.plan_create': 'Created a subscription plan',
'subscription.plan_update': 'Updated a subscription plan',
'subscription.bind': 'Bound a subscription',
'subscription.admin_grant':
'Granted subscription plan {{plan_title}} (ID: {{plan_id}}) to user {{target_user_id}}',
'subscription.granted':
'Admin granted you subscription plan {{plan_title}} (ID: {{plan_id}})',
// Logs
'log.clear': 'Cleared historical logs',
// Generic middleware fallback
Expand Down
2 changes: 2 additions & 0 deletions web/default/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@
"Admin access required": "Admin access required",
"Admin area": "Admin area",
"Admin Channel Permissions": "Admin Channel Permissions",
"Admin granted you subscription plan {{plan_title}} (ID: {{plan_id}})": "Admin granted you subscription plan {{plan_title}} (ID: {{plan_id}})",
"Admin notes (only visible to admins)": "Admin notes (only visible to admins)",
"Admin Only": "Admin Only",
"Admin Permissions": "Admin Permissions",
Expand Down Expand Up @@ -2121,6 +2122,7 @@
"gpt-4, claude-3-opus, etc.": "gpt-4, claude-3-opus, etc.",
"GPU count": "GPU count",
"Granted at": "Granted at",
"Granted subscription plan {{plan_title}} (ID: {{plan_id}}) to user {{target_user_id}}": "Granted subscription plan {{plan_title}} (ID: {{plan_id}}) to user {{target_user_id}}",
"Greater than": "Greater than",
"Greater Than": "Greater Than",
"Greater than or equal": "Greater than or equal",
Expand Down
2 changes: 2 additions & 0 deletions web/default/src/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@
"Admin access required": "Accès administrateur requis",
"Admin area": "Espace administrateur",
"Admin Channel Permissions": "Autorisations des canaux administrateur",
"Admin granted you subscription plan {{plan_title}} (ID: {{plan_id}})": "L'administrateur vous a attribué le forfait d'abonnement {{plan_title}} (ID : {{plan_id}})",
"Admin notes (only visible to admins)": "Notes d'administration (visibles uniquement par les administrateurs)",
"Admin Only": "Administrateur uniquement",
"Admin Permissions": "Autorisations administrateur",
Expand Down Expand Up @@ -2121,6 +2122,7 @@
"gpt-4, claude-3-opus, etc.": "gpt-4, claude-3-opus, etc.",
"GPU count": "Nombre de GPU",
"Granted at": "Accordé le",
"Granted subscription plan {{plan_title}} (ID: {{plan_id}}) to user {{target_user_id}}": "Forfait d'abonnement {{plan_title}} (ID : {{plan_id}}) attribué à l'utilisateur {{target_user_id}}",
"Greater than": "Supérieur à",
"Greater Than": "Supérieur à",
"Greater than or equal": "Supérieur ou égal",
Expand Down
2 changes: 2 additions & 0 deletions web/default/src/i18n/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@
"Admin access required": "管理者アクセスが必要です",
"Admin area": "管理者エリア",
"Admin Channel Permissions": "管理者のチャネル権限",
"Admin granted you subscription plan {{plan_title}} (ID: {{plan_id}})": "管理者がサブスクリプションプラン {{plan_title}}(ID: {{plan_id}})を付与しました",
"Admin notes (only visible to admins)": "管理者メモ (管理者のみに表示)",
"Admin Only": "管理者のみ",
"Admin Permissions": "管理者権限",
Expand Down Expand Up @@ -2121,6 +2122,7 @@
"gpt-4, claude-3-opus, etc.": "gpt-4、claude-3-opus など",
"GPU count": "GPU 数",
"Granted at": "付与日時",
"Granted subscription plan {{plan_title}} (ID: {{plan_id}}) to user {{target_user_id}}": "ユーザー {{target_user_id}} にサブスクリプションプラン {{plan_title}}(ID: {{plan_id}})を付与しました",
"Greater than": "より大きい",
"Greater Than": "より大きい",
"Greater than or equal": "以上",
Expand Down
2 changes: 2 additions & 0 deletions web/default/src/i18n/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@
"Admin access required": "Требуется доступ администратора",
"Admin area": "Область администратора",
"Admin Channel Permissions": "Права администратора для каналов",
"Admin granted you subscription plan {{plan_title}} (ID: {{plan_id}})": "Администратор выдал вам план подписки {{plan_title}} (ID: {{plan_id}})",
"Admin notes (only visible to admins)": "Заметки администратора (видны только администраторам)",
"Admin Only": "Только для администраторов",
"Admin Permissions": "Права администратора",
Expand Down Expand Up @@ -2121,6 +2122,7 @@
"gpt-4, claude-3-opus, etc.": "gpt-4, claude-3-opus и т. д.",
"GPU count": "Количество GPU",
"Granted at": "Выдано",
"Granted subscription plan {{plan_title}} (ID: {{plan_id}}) to user {{target_user_id}}": "План подписки {{plan_title}} (ID: {{plan_id}}) выдан пользователю {{target_user_id}}",
"Greater than": "Больше",
"Greater Than": "Больше",
"Greater than or equal": "Больше или равно",
Expand Down
2 changes: 2 additions & 0 deletions web/default/src/i18n/locales/vi.json
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@
"Admin access required": "Yêu cầu quyền truy cập Admin",
"Admin area": "Khu vực quản trị",
"Admin Channel Permissions": "Quyền kênh của quản trị viên",
"Admin granted you subscription plan {{plan_title}} (ID: {{plan_id}})": "Quản trị viên đã cấp cho bạn gói đăng ký {{plan_title}} (ID: {{plan_id}})",
"Admin notes (only visible to admins)": "Ghi chú của quản trị viên (chỉ hiển thị với quản trị viên)",
"Admin Only": "Chỉ dành cho quản trị viên",
"Admin Permissions": "Quyền quản trị viên",
Expand Down Expand Up @@ -2121,6 +2122,7 @@
"gpt-4, claude-3-opus, etc.": "gpt-4, claude-3-opus, v.v.",
"GPU count": "Số lượng GPU",
"Granted at": "Được cấp lúc",
"Granted subscription plan {{plan_title}} (ID: {{plan_id}}) to user {{target_user_id}}": "Đã cấp gói đăng ký {{plan_title}} (ID: {{plan_id}}) cho người dùng {{target_user_id}}",
"Greater than": "Lớn hơn",
"Greater Than": "Lớn hơn",
"Greater than or equal": "Lớn hơn hoặc bằng",
Expand Down
2 changes: 2 additions & 0 deletions web/default/src/i18n/locales/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@
"Admin access required": "需要管理員權限",
"Admin area": "管理員區域",
"Admin Channel Permissions": "管理員渠道權限",
"Admin granted you subscription plan {{plan_title}} (ID: {{plan_id}})": "管理員為你開通訂閱方案 {{plan_title}}(ID: {{plan_id}})",
"Admin notes (only visible to admins)": "管理員備註(僅管理員可見)",
"Admin Only": "僅限管理員",
"Admin Permissions": "管理員權限",
Expand Down Expand Up @@ -2121,6 +2122,7 @@
"gpt-4, claude-3-opus, etc.": "gpt-4, claude-3-opus, 等",
"GPU count": "GPU 數量",
"Granted at": "發放時間",
"Granted subscription plan {{plan_title}} (ID: {{plan_id}}) to user {{target_user_id}}": "為用戶 {{target_user_id}} 開通訂閱方案 {{plan_title}}(ID: {{plan_id}})",
"Greater than": "大於",
"Greater Than": "大於",
"Greater than or equal": "大於等於",
Expand Down
2 changes: 2 additions & 0 deletions web/default/src/i18n/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@
"Admin access required": "需要管理员权限",
"Admin area": "管理员区域",
"Admin Channel Permissions": "管理员渠道权限",
"Admin granted you subscription plan {{plan_title}} (ID: {{plan_id}})": "管理员为你开通订阅套餐 {{plan_title}}(ID: {{plan_id}})",
"Admin notes (only visible to admins)": "管理员备注(仅管理员可见)",
"Admin Only": "仅限管理员",
"Admin Permissions": "管理员权限",
Expand Down Expand Up @@ -2121,6 +2122,7 @@
"gpt-4, claude-3-opus, etc.": "gpt-4, claude-3-opus, 等",
"GPU count": "GPU 数量",
"Granted at": "发放时间",
"Granted subscription plan {{plan_title}} (ID: {{plan_id}}) to user {{target_user_id}}": "为用户 {{target_user_id}} 开通订阅套餐 {{plan_title}}(ID: {{plan_id}})",
"Greater than": "大于",
"Greater Than": "大于",
"Greater than or equal": "大于等于",
Expand Down
Loading