mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
feat(groups): batch adjust rate multipliers with global factor
- Add PUT /admin/groups/:id/rate-multipliers batch endpoint - Add batch adjust UI: multiply all rates by a factor (e.g., ×0.5 to halve, ×2 to restore) - Remove inline rate editing (blur-save), rates now display as read-only - Backend: SyncGroupRateMultipliers repo method (delete + batch upsert)
This commit is contained in:
@@ -1 +1 @@
|
||||
0.1.96.3
|
||||
0.1.96.4
|
||||
|
||||
@@ -183,6 +183,10 @@ func (s *stubAdminService) ClearGroupRateMultipliers(_ context.Context, _ int64)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) BatchSetGroupRateMultipliers(_ context.Context, _ int64, _ []service.GroupRateMultiplierInput) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string, groupID int64) ([]service.Account, int64, error) {
|
||||
return s.accounts, int64(len(s.accounts)), nil
|
||||
}
|
||||
|
||||
@@ -377,6 +377,34 @@ func (h *GroupHandler) ClearGroupRateMultipliers(c *gin.Context) {
|
||||
response.Success(c, gin.H{"message": "Rate multipliers cleared successfully"})
|
||||
}
|
||||
|
||||
// BatchSetGroupRateMultipliersRequest represents batch set rate multipliers request
|
||||
type BatchSetGroupRateMultipliersRequest struct {
|
||||
Entries []service.GroupRateMultiplierInput `json:"entries" binding:"required"`
|
||||
}
|
||||
|
||||
// BatchSetGroupRateMultipliers handles batch setting rate multipliers for a group
|
||||
// PUT /api/v1/admin/groups/:id/rate-multipliers
|
||||
func (h *GroupHandler) BatchSetGroupRateMultipliers(c *gin.Context) {
|
||||
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid group ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req BatchSetGroupRateMultipliersRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.adminService.BatchSetGroupRateMultipliers(c.Request.Context(), groupID, req.Entries); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{"message": "Rate multipliers updated successfully"})
|
||||
}
|
||||
|
||||
// UpdateSortOrderRequest represents the request to update group sort orders
|
||||
type UpdateSortOrderRequest struct {
|
||||
Updates []struct {
|
||||
|
||||
@@ -193,6 +193,31 @@ func (r *userGroupRateRepository) SyncUserGroupRates(ctx context.Context, userID
|
||||
return nil
|
||||
}
|
||||
|
||||
// SyncGroupRateMultipliers 批量同步分组的用户专属倍率(先删后插)
|
||||
func (r *userGroupRateRepository) SyncGroupRateMultipliers(ctx context.Context, groupID int64, entries []service.GroupRateMultiplierInput) error {
|
||||
if _, err := r.sql.ExecContext(ctx, `DELETE FROM user_group_rate_multipliers WHERE group_id = $1`, groupID); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
userIDs := make([]int64, len(entries))
|
||||
rates := make([]float64, len(entries))
|
||||
for i, e := range entries {
|
||||
userIDs[i] = e.UserID
|
||||
rates[i] = e.RateMultiplier
|
||||
}
|
||||
now := time.Now()
|
||||
_, err := r.sql.ExecContext(ctx, `
|
||||
INSERT INTO user_group_rate_multipliers (user_id, group_id, rate_multiplier, created_at, updated_at)
|
||||
SELECT data.user_id, $1::bigint, data.rate_multiplier, $2::timestamptz, $2::timestamptz
|
||||
FROM unnest($3::bigint[], $4::double precision[]) AS data(user_id, rate_multiplier)
|
||||
ON CONFLICT (user_id, group_id)
|
||||
DO UPDATE SET rate_multiplier = EXCLUDED.rate_multiplier, updated_at = EXCLUDED.updated_at
|
||||
`, groupID, now, pq.Array(userIDs), pq.Array(rates))
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteByGroupID 删除指定分组的所有用户专属倍率
|
||||
func (r *userGroupRateRepository) DeleteByGroupID(ctx context.Context, groupID int64) error {
|
||||
_, err := r.sql.ExecContext(ctx, `DELETE FROM user_group_rate_multipliers WHERE group_id = $1`, groupID)
|
||||
|
||||
@@ -229,6 +229,7 @@ func registerGroupRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
groups.DELETE("/:id", h.Admin.Group.Delete)
|
||||
groups.GET("/:id/stats", h.Admin.Group.GetStats)
|
||||
groups.GET("/:id/rate-multipliers", h.Admin.Group.GetGroupRateMultipliers)
|
||||
groups.PUT("/:id/rate-multipliers", h.Admin.Group.BatchSetGroupRateMultipliers)
|
||||
groups.DELETE("/:id/rate-multipliers", h.Admin.Group.ClearGroupRateMultipliers)
|
||||
groups.GET("/:id/api-keys", h.Admin.Group.GetGroupAPIKeys)
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ type AdminService interface {
|
||||
GetGroupAPIKeys(ctx context.Context, groupID int64, page, pageSize int) ([]APIKey, int64, error)
|
||||
GetGroupRateMultipliers(ctx context.Context, groupID int64) ([]UserGroupRateEntry, error)
|
||||
ClearGroupRateMultipliers(ctx context.Context, groupID int64) error
|
||||
BatchSetGroupRateMultipliers(ctx context.Context, groupID int64, entries []GroupRateMultiplierInput) error
|
||||
UpdateGroupSortOrders(ctx context.Context, updates []GroupSortOrderUpdate) error
|
||||
|
||||
// API Key management (admin)
|
||||
@@ -1279,6 +1280,13 @@ func (s *adminServiceImpl) ClearGroupRateMultipliers(ctx context.Context, groupI
|
||||
return s.userGroupRateRepo.DeleteByGroupID(ctx, groupID)
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) BatchSetGroupRateMultipliers(ctx context.Context, groupID int64, entries []GroupRateMultiplierInput) error {
|
||||
if s.userGroupRateRepo == nil {
|
||||
return nil
|
||||
}
|
||||
return s.userGroupRateRepo.SyncGroupRateMultipliers(ctx, groupID, entries)
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) UpdateGroupSortOrders(ctx context.Context, updates []GroupSortOrderUpdate) error {
|
||||
return s.groupRepo.UpdateSortOrders(ctx, updates)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ type userGroupRateRepoStubForGroupRate struct {
|
||||
|
||||
deletedGroupIDs []int64
|
||||
deleteByGroupErr error
|
||||
|
||||
syncedGroupID int64
|
||||
syncedEntries []GroupRateMultiplierInput
|
||||
syncGroupErr error
|
||||
}
|
||||
|
||||
func (s *userGroupRateRepoStubForGroupRate) GetByUserID(_ context.Context, _ int64) (map[int64]float64, error) {
|
||||
@@ -38,6 +42,12 @@ func (s *userGroupRateRepoStubForGroupRate) SyncUserGroupRates(_ context.Context
|
||||
panic("unexpected SyncUserGroupRates call")
|
||||
}
|
||||
|
||||
func (s *userGroupRateRepoStubForGroupRate) SyncGroupRateMultipliers(_ context.Context, groupID int64, entries []GroupRateMultiplierInput) error {
|
||||
s.syncedGroupID = groupID
|
||||
s.syncedEntries = entries
|
||||
return s.syncGroupErr
|
||||
}
|
||||
|
||||
func (s *userGroupRateRepoStubForGroupRate) DeleteByGroupID(_ context.Context, groupID int64) error {
|
||||
s.deletedGroupIDs = append(s.deletedGroupIDs, groupID)
|
||||
return s.deleteByGroupErr
|
||||
@@ -128,3 +138,39 @@ func TestAdminService_ClearGroupRateMultipliers(t *testing.T) {
|
||||
require.Contains(t, err.Error(), "delete failed")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAdminService_BatchSetGroupRateMultipliers(t *testing.T) {
|
||||
t.Run("syncs entries to repo", func(t *testing.T) {
|
||||
repo := &userGroupRateRepoStubForGroupRate{}
|
||||
svc := &adminServiceImpl{userGroupRateRepo: repo}
|
||||
|
||||
entries := []GroupRateMultiplierInput{
|
||||
{UserID: 1, RateMultiplier: 1.5},
|
||||
{UserID: 2, RateMultiplier: 0.8},
|
||||
}
|
||||
err := svc.BatchSetGroupRateMultipliers(context.Background(), 10, entries)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(10), repo.syncedGroupID)
|
||||
require.Equal(t, entries, repo.syncedEntries)
|
||||
})
|
||||
|
||||
t.Run("returns nil when repo is nil", func(t *testing.T) {
|
||||
svc := &adminServiceImpl{userGroupRateRepo: nil}
|
||||
|
||||
err := svc.BatchSetGroupRateMultipliers(context.Background(), 10, nil)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("propagates repo error", func(t *testing.T) {
|
||||
repo := &userGroupRateRepoStubForGroupRate{
|
||||
syncGroupErr: errors.New("sync failed"),
|
||||
}
|
||||
svc := &adminServiceImpl{userGroupRateRepo: repo}
|
||||
|
||||
err := svc.BatchSetGroupRateMultipliers(context.Background(), 10, []GroupRateMultiplierInput{
|
||||
{UserID: 1, RateMultiplier: 1.0},
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "sync failed")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -68,11 +68,15 @@ func (s *userGroupRateRepoStubForListUsers) SyncUserGroupRates(_ context.Context
|
||||
panic("unexpected SyncUserGroupRates call")
|
||||
}
|
||||
|
||||
func (s *userGroupRateRepoStubForListUsers) GetByGroupID(_ context.Context, groupID int64) ([]UserGroupRateEntry, error) {
|
||||
func (s *userGroupRateRepoStubForListUsers) GetByGroupID(_ context.Context, _ int64) ([]UserGroupRateEntry, error) {
|
||||
panic("unexpected GetByGroupID call")
|
||||
}
|
||||
|
||||
func (s *userGroupRateRepoStubForListUsers) DeleteByGroupID(_ context.Context, groupID int64) error {
|
||||
func (s *userGroupRateRepoStubForListUsers) SyncGroupRateMultipliers(_ context.Context, _ int64, _ []GroupRateMultiplierInput) error {
|
||||
panic("unexpected SyncGroupRateMultipliers call")
|
||||
}
|
||||
|
||||
func (s *userGroupRateRepoStubForListUsers) DeleteByGroupID(_ context.Context, _ int64) error {
|
||||
panic("unexpected DeleteByGroupID call")
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,12 @@ type UserGroupRateEntry struct {
|
||||
RateMultiplier float64 `json:"rate_multiplier"`
|
||||
}
|
||||
|
||||
// GroupRateMultiplierInput 批量设置分组倍率的输入条目
|
||||
type GroupRateMultiplierInput struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
RateMultiplier float64 `json:"rate_multiplier"`
|
||||
}
|
||||
|
||||
// UserGroupRateRepository 用户专属分组倍率仓储接口
|
||||
// 允许管理员为特定用户设置分组的专属计费倍率,覆盖分组默认倍率
|
||||
type UserGroupRateRepository interface {
|
||||
@@ -30,6 +36,9 @@ type UserGroupRateRepository interface {
|
||||
// rates: map[groupID]*rateMultiplier,nil 表示删除该分组的专属倍率
|
||||
SyncUserGroupRates(ctx context.Context, userID int64, rates map[int64]*float64) error
|
||||
|
||||
// SyncGroupRateMultipliers 批量同步分组的用户专属倍率(替换整组数据)
|
||||
SyncGroupRateMultipliers(ctx context.Context, groupID int64, entries []GroupRateMultiplierInput) error
|
||||
|
||||
// DeleteByGroupID 删除指定分组的所有用户专属倍率(分组删除时调用)
|
||||
DeleteByGroupID(ctx context.Context, groupID int64) error
|
||||
|
||||
|
||||
@@ -201,6 +201,23 @@ export async function clearGroupRateMultipliers(id: number): Promise<{ message:
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch set rate multipliers for users in a group
|
||||
* @param id - Group ID
|
||||
* @param entries - Array of { user_id, rate_multiplier }
|
||||
* @returns Success confirmation
|
||||
*/
|
||||
export async function batchSetGroupRateMultipliers(
|
||||
id: number,
|
||||
entries: Array<{ user_id: number; rate_multiplier: number }>
|
||||
): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.put<{ message: string }>(
|
||||
`/admin/groups/${id}/rate-multipliers`,
|
||||
{ entries }
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export const groupsAPI = {
|
||||
list,
|
||||
getAll,
|
||||
@@ -214,6 +231,7 @@ export const groupsAPI = {
|
||||
getGroupApiKeys,
|
||||
getGroupRateMultipliers,
|
||||
clearGroupRateMultipliers,
|
||||
batchSetGroupRateMultipliers,
|
||||
updateSortOrder
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 添加用户 -->
|
||||
<!-- 操作区:添加用户 + 批量调整 + 全部清空 -->
|
||||
<div class="rounded-lg border border-gray-200 p-3 dark:border-dark-600">
|
||||
<h4 class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.groups.addUserRate') }}
|
||||
@@ -65,16 +65,43 @@
|
||||
<Icon v-if="addingRate" name="refresh" size="sm" class="mr-1 animate-spin" />
|
||||
{{ t('common.add') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="entries.length > 0"
|
||||
type="button"
|
||||
class="btn shrink-0 rounded-lg border border-red-200 bg-red-50 px-3 py-1.5 text-sm font-medium text-red-600 transition-colors hover:bg-red-100 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400 dark:hover:bg-red-900/40"
|
||||
:disabled="clearing"
|
||||
@click="showClearConfirm = true"
|
||||
>
|
||||
<Icon v-if="clearing" name="refresh" size="sm" class="mr-1 inline animate-spin" />
|
||||
{{ t('admin.groups.clearAll') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 批量调整 + 全部清空 -->
|
||||
<div v-if="entries.length > 0" class="mt-3 flex items-center gap-3 border-t border-gray-100 pt-3 dark:border-dark-600">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.groups.batchAdjust') }}</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-xs text-gray-400">×</span>
|
||||
<input
|
||||
v-model.number="batchFactor"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
autocomplete="off"
|
||||
class="hide-spinner w-20 rounded border border-gray-200 bg-white px-2 py-1 text-center text-sm transition-colors focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500/20 dark:border-dark-500 dark:bg-dark-700 dark:focus:border-primary-500"
|
||||
placeholder="0.5"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm shrink-0 px-2.5 py-1 text-xs"
|
||||
:disabled="!batchFactor || batchFactor <= 0 || adjusting"
|
||||
@click="handleBatchAdjust"
|
||||
>
|
||||
<Icon v-if="adjusting" name="refresh" size="sm" class="mr-1 animate-spin" />
|
||||
{{ t('admin.groups.applyMultiplier') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="ml-auto">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-red-200 bg-red-50 px-3 py-1.5 text-sm font-medium text-red-600 transition-colors hover:bg-red-100 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400 dark:hover:bg-red-900/40"
|
||||
:disabled="clearing"
|
||||
@click="showClearConfirm = true"
|
||||
>
|
||||
<Icon v-if="clearing" name="refresh" size="sm" class="mr-1 inline animate-spin" />
|
||||
{{ t('admin.groups.clearAll') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -134,17 +161,8 @@
|
||||
{{ entry.user_status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-2">
|
||||
<input
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
autocomplete="off"
|
||||
:value="entry.rate_multiplier"
|
||||
class="hide-spinner w-20 rounded border border-gray-200 bg-white px-2 py-1 text-center text-sm font-medium transition-colors focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500/20 dark:border-dark-500 dark:bg-dark-700 dark:focus:border-primary-500"
|
||||
@blur="handleUpdateRate(entry, ($event.target as HTMLInputElement).value)"
|
||||
@keydown.enter="($event.target as HTMLInputElement).blur()"
|
||||
/>
|
||||
<td class="whitespace-nowrap px-3 py-2 font-medium text-gray-900 dark:text-white">
|
||||
{{ entry.rate_multiplier }}
|
||||
</td>
|
||||
<td class="px-2 py-2">
|
||||
<button
|
||||
@@ -225,6 +243,8 @@ const clearing = ref(false)
|
||||
const showClearConfirm = ref(false)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const batchFactor = ref<number | null>(null)
|
||||
const adjusting = ref(false)
|
||||
|
||||
let searchTimeout: ReturnType<typeof setTimeout>
|
||||
|
||||
@@ -258,6 +278,7 @@ watch(() => props.show, (val) => {
|
||||
searchResults.value = []
|
||||
selectedUser.value = null
|
||||
newRate.value = null
|
||||
batchFactor.value = null
|
||||
}
|
||||
})
|
||||
|
||||
@@ -313,20 +334,24 @@ const handleAddRate = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateRate = async (entry: GroupRateMultiplierEntry, value: string) => {
|
||||
if (!props.group) return
|
||||
const numValue = parseFloat(value)
|
||||
if (isNaN(numValue) || numValue === entry.rate_multiplier) return
|
||||
const handleBatchAdjust = async () => {
|
||||
if (!props.group || !batchFactor.value || entries.value.length === 0) return
|
||||
adjusting.value = true
|
||||
try {
|
||||
await adminAPI.users.update(entry.user_id, {
|
||||
group_rates: { [props.group.id]: numValue }
|
||||
})
|
||||
appStore.showSuccess(t('admin.groups.rateUpdated'))
|
||||
const newEntries = entries.value.map(e => ({
|
||||
user_id: e.user_id,
|
||||
rate_multiplier: parseFloat((e.rate_multiplier * batchFactor.value!).toFixed(6))
|
||||
}))
|
||||
await adminAPI.groups.batchSetGroupRateMultipliers(props.group.id, newEntries)
|
||||
appStore.showSuccess(t('admin.groups.rateAdjusted'))
|
||||
batchFactor.value = null
|
||||
await loadEntries()
|
||||
emit('success')
|
||||
} catch (error) {
|
||||
appStore.showError(t('admin.groups.failedToSave'))
|
||||
console.error('Error updating rate multiplier:', error)
|
||||
console.error('Error batch adjusting rate multipliers:', error)
|
||||
} finally {
|
||||
adjusting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1426,6 +1426,10 @@ export default {
|
||||
clearAll: 'Clear All',
|
||||
confirmClearAll: 'Are you sure you want to clear all rate multiplier settings for this group? This cannot be undone.',
|
||||
rateCleared: 'All rate multipliers cleared',
|
||||
batchAdjust: 'Batch Adjust Rates',
|
||||
multiplierFactor: 'Factor',
|
||||
applyMultiplier: 'Apply',
|
||||
rateAdjusted: 'Rates adjusted successfully',
|
||||
userInfo: 'User Info',
|
||||
platforms: {
|
||||
all: 'All Platforms',
|
||||
|
||||
@@ -1525,6 +1525,10 @@ export default {
|
||||
clearAll: '全部清空',
|
||||
confirmClearAll: '确定要清空该分组所有用户的专属倍率设置吗?此操作不可撤销。',
|
||||
rateCleared: '已清空所有专属倍率',
|
||||
batchAdjust: '批量调整倍率',
|
||||
multiplierFactor: '乘数',
|
||||
applyMultiplier: '应用',
|
||||
rateAdjusted: '倍率已批量调整',
|
||||
userInfo: '用户信息',
|
||||
subscription: {
|
||||
title: '订阅设置',
|
||||
|
||||
Reference in New Issue
Block a user