diff --git a/controller/subscription.go b/controller/subscription.go index c6095312b776..f30cb08c06f9 100644 --- a/controller/subscription.go +++ b/controller/subscription.go @@ -302,6 +302,28 @@ func AdminBindSubscription(c *gin.Context) { // ---- Admin: user subscription management ---- +type AdminBatchActiveSubscriptionsRequest struct { + UserIds []int `json:"user_ids"` +} + +func AdminBatchActiveSubscriptions(c *gin.Context) { + var req AdminBatchActiveSubscriptionsRequest + if err := c.ShouldBindJSON(&req); err != nil || len(req.UserIds) == 0 { + common.ApiErrorMsg(c, "参数错误") + return + } + if len(req.UserIds) > 100 { + common.ApiErrorMsg(c, "单次查询最多100个用户") + return + } + subsMap, err := model.GetActiveSubscriptionsByUserIds(req.UserIds) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, subsMap) +} + func AdminListUserSubscriptions(c *gin.Context) { userId, _ := strconv.Atoi(c.Param("id")) if userId <= 0 { diff --git a/controller/user.go b/controller/user.go index 8229d0d2c2bc..2a9458a7edc6 100644 --- a/controller/user.go +++ b/controller/user.go @@ -244,8 +244,24 @@ func GetAllUsers(c *gin.Context) { func SearchUsers(c *gin.Context) { keyword := c.Query("keyword") group := c.Query("group") + planIdStr := c.Query("plan_id") pageInfo := common.GetPageQuery(c) - users, total, err := model.SearchUsers(keyword, group, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + + var users []*model.User + var total int64 + var err error + + if planIdStr != "" { + planId, parseErr := strconv.Atoi(planIdStr) + if parseErr != nil || planId <= 0 { + common.ApiErrorMsg(c, "无效的订阅套餐ID") + return + } + users, total, err = model.SearchUsers(keyword, group, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), planId) + } else { + users, total, err = model.SearchUsers(keyword, group, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + } + if err != nil { common.ApiError(c, err) return diff --git a/model/subscription.go b/model/subscription.go index 2d23a8b5bf2c..43cf18064d05 100644 --- a/model/subscription.go +++ b/model/subscription.go @@ -666,6 +666,27 @@ func GetAllActiveUserSubscriptions(userId int) ([]SubscriptionSummary, error) { return buildSubscriptionSummaries(subs), nil } +// GetActiveSubscriptionsByUserIds returns all active subscriptions per user for a batch of user IDs. +// Returns a map of userId -> []UserSubscription. +func GetActiveSubscriptionsByUserIds(userIds []int) (map[int][]UserSubscription, error) { + if len(userIds) == 0 { + return map[int][]UserSubscription{}, nil + } + now := common.GetTimestamp() + var subs []UserSubscription + err := DB.Where("user_id IN ? AND status = ? AND end_time > ?", userIds, "active", now). + Order("end_time desc, id desc"). + Find(&subs).Error + if err != nil { + return nil, err + } + result := make(map[int][]UserSubscription, len(userIds)) + for i := range subs { + result[subs[i].UserId] = append(result[subs[i].UserId], subs[i]) + } + return result, nil +} + // HasActiveUserSubscription returns whether the user has any active subscription. // This is a lightweight existence check to avoid heavy pre-consume transactions. func HasActiveUserSubscription(userId int) (bool, error) { diff --git a/model/user.go b/model/user.go index 1210b5435d04..e5b5b26aadce 100644 --- a/model/user.go +++ b/model/user.go @@ -222,10 +222,11 @@ func GetAllUsers(pageInfo *common.PageInfo) (users []*User, total int64, err err return users, total, nil } -func SearchUsers(keyword string, group string, startIdx int, num int) ([]*User, int64, error) { +func SearchUsers(keyword string, group string, startIdx int, num int, planId ...int) ([]*User, int64, error) { var users []*User var total int64 var err error + now := common.GetTimestamp() // 开始事务 tx := DB.Begin() @@ -241,6 +242,12 @@ func SearchUsers(keyword string, group string, startIdx int, num int) ([]*User, // 构建基础查询 query := tx.Unscoped().Model(&User{}) + // 如果指定了 planId,先筛选有该订阅计划的用户 + if len(planId) > 0 && planId[0] > 0 { + query = query.Where("id IN (?)", + tx.Model(&UserSubscription{}).Select("user_id").Where("plan_id = ? AND status = ? AND end_time > ?", planId[0], "active", now)) + } + // 构建搜索条件 likeCondition := "username LIKE ? OR email LIKE ? OR display_name LIKE ?" diff --git a/router/api-router.go b/router/api-router.go index bff158a819ce..cad4f3f83f0a 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -154,6 +154,7 @@ func SetApiRouter(router *gin.Engine) { subscriptionAdminRoute.POST("/bind", controller.AdminBindSubscription) // User subscription management (admin) + subscriptionAdminRoute.POST("/users/batch_active_subscriptions", controller.AdminBatchActiveSubscriptions) subscriptionAdminRoute.GET("/users/:id/subscriptions", controller.AdminListUserSubscriptions) subscriptionAdminRoute.POST("/users/:id/subscriptions", controller.AdminCreateUserSubscription) subscriptionAdminRoute.POST("/user_subscriptions/:id/invalidate", controller.AdminInvalidateUserSubscription) diff --git a/web/bun.lock b/web/bun.lock index e3b293cb12a6..9a8419226a6e 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "react-template", @@ -10,7 +11,7 @@ "@visactor/react-vchart": "~1.8.8", "@visactor/vchart": "~1.8.8", "@visactor/vchart-semi-theme": "~1.8.8", - "axios": "1.12.0", + "axios": "1.13.5", "clsx": "^2.1.1", "dayjs": "^1.11.11", "history": "^5.3.0", @@ -776,7 +777,7 @@ "autoprefixer": ["autoprefixer@10.4.21", "", { "dependencies": { "browserslist": "^4.24.4", "caniuse-lite": "^1.0.30001702", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ=="], - "axios": ["axios@1.12.0", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg=="], + "axios": ["axios@1.13.5", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q=="], "babel-plugin-macros": ["babel-plugin-macros@3.1.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", "resolve": "^1.19.0" } }, "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg=="], @@ -1104,13 +1105,13 @@ "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], - "follow-redirects": ["follow-redirects@1.15.9", "", {}, "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ=="], + "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], "for-in": ["for-in@1.0.2", "", {}, "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ=="], "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], - "form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="], + "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], "fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="], diff --git a/web/src/components/table/users/UsersColumnDefs.jsx b/web/src/components/table/users/UsersColumnDefs.jsx index dc3e6f3413ec..47411a118c0b 100644 --- a/web/src/components/table/users/UsersColumnDefs.jsx +++ b/web/src/components/table/users/UsersColumnDefs.jsx @@ -170,6 +170,74 @@ const renderQuotaUsage = (text, record, t) => { ); }; +const PLAN_TAG_COLORS = [ + 'green', 'blue', 'cyan', 'violet', 'teal', 'lime', 'amber', 'indigo', +]; + +const getPlanColor = (planId) => { + if (!planId) return 'green'; + return PLAN_TAG_COLORS[planId % PLAN_TAG_COLORS.length]; +}; + +/** + * Render subscription information (supports multiple active subscriptions) + */ +const renderSubscriptionInfo = (record, userSubscriptions, planOptions, t) => { + if (userSubscriptions === null) { + return -; + } + const subs = userSubscriptions[record.id]; + if (!subs || !Array.isArray(subs) || subs.length === 0) { + return ( + + {t('无订阅')} + + ); + } + + const now = Date.now() / 1000; + + return ( + + {subs.map((sub) => { + const isExpired = sub.end_time > 0 && sub.end_time < now; + const isActive = sub.status === 'active' && !isExpired; + const planName = planOptions?.find( + (p) => p.value === sub.plan_id, + )?.label; + const endDate = sub.end_time + ? new Date(sub.end_time * 1000).toLocaleString() + : '-'; + + const tooltipContent = ( +
+
+ {t('到期')}: {endDate} +
+ {sub.amount_total > 0 && ( +
+ {t('额度')}: {sub.amount_used}/{sub.amount_total} +
+ )} +
+ ); + + return ( + + + {planName || (sub.plan_id ? `#${sub.plan_id}` : t('订阅中'))} + + + ); + })} +
+ ); +}; + /** * Render invite information */ @@ -309,6 +377,8 @@ export const getUsersColumns = ({ showResetPasskeyModal, showResetTwoFAModal, showUserSubscriptionsModal, + userSubscriptions, + planOptions, }) => { return [ { @@ -345,6 +415,12 @@ export const getUsersColumns = ({ return
{renderRole(text, t)}
; }, }, + { + title: t('订阅信息'), + dataIndex: 'subscription', + render: (text, record) => + renderSubscriptionInfo(record, userSubscriptions, planOptions, t), + }, { title: t('邀请信息'), dataIndex: 'invite', diff --git a/web/src/components/table/users/UsersFilters.jsx b/web/src/components/table/users/UsersFilters.jsx index 7c4c7486b2eb..66864d34e761 100644 --- a/web/src/components/table/users/UsersFilters.jsx +++ b/web/src/components/table/users/UsersFilters.jsx @@ -29,6 +29,7 @@ const UsersFilters = ({ activePage, pageSize, groupOptions, + planOptions, loading, searching, t, @@ -88,6 +89,24 @@ const UsersFilters = ({ size='small' /> + {planOptions && planOptions.length > 0 && ( +
+ { + setTimeout(() => { + searchUsers(1, pageSize); + }, 100); + }} + className='w-full' + showClear + pure + size='small' + /> +
+ )}