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
22 changes: 22 additions & 0 deletions controller/subscription.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
18 changes: 17 additions & 1 deletion controller/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions model/subscription.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
9 changes: 8 additions & 1 deletion model/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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 ?"

Expand Down
1 change: 1 addition & 0 deletions router/api-router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 5 additions & 4 deletions web/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

76 changes: 76 additions & 0 deletions web/src/components/table/users/UsersColumnDefs.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Tag color='grey' shape='circle' size='small'>-</Tag>;
}
const subs = userSubscriptions[record.id];
if (!subs || !Array.isArray(subs) || subs.length === 0) {
return (
<Tag color='grey' shape='circle' size='small'>
{t('无订阅')}
</Tag>
);
}

const now = Date.now() / 1000;

return (
<Space spacing={4} wrap>
{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 = (
<div className='text-xs'>
<div>
{t('到期')}: {endDate}
</div>
{sub.amount_total > 0 && (
<div>
{t('额度')}: {sub.amount_used}/{sub.amount_total}
</div>
)}
</div>
);

return (
<Tooltip key={sub.id} content={tooltipContent} position='top'>
<Tag
color={isActive ? getPlanColor(sub.plan_id) : 'grey'}
shape='circle'
size='small'
>
{planName || (sub.plan_id ? `#${sub.plan_id}` : t('订阅中'))}
</Tag>
</Tooltip>
);
})}
</Space>
);
};

/**
* Render invite information
*/
Expand Down Expand Up @@ -309,6 +377,8 @@ export const getUsersColumns = ({
showResetPasskeyModal,
showResetTwoFAModal,
showUserSubscriptionsModal,
userSubscriptions,
planOptions,
}) => {
return [
{
Expand Down Expand Up @@ -345,6 +415,12 @@ export const getUsersColumns = ({
return <div>{renderRole(text, t)}</div>;
},
},
{
title: t('订阅信息'),
dataIndex: 'subscription',
render: (text, record) =>
renderSubscriptionInfo(record, userSubscriptions, planOptions, t),
},
{
title: t('邀请信息'),
dataIndex: 'invite',
Expand Down
19 changes: 19 additions & 0 deletions web/src/components/table/users/UsersFilters.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const UsersFilters = ({
activePage,
pageSize,
groupOptions,
planOptions,
loading,
searching,
t,
Expand Down Expand Up @@ -88,6 +89,24 @@ const UsersFilters = ({
size='small'
/>
</div>
{planOptions && planOptions.length > 0 && (
<div className='w-full md:w-48'>
<Form.Select
field='searchPlan'
placeholder={t('选择订阅')}
optionList={planOptions}
onChange={(value) => {
setTimeout(() => {
searchUsers(1, pageSize);
}, 100);
}}
className='w-full'
showClear
pure
size='small'
/>
</div>
)}
<div className='flex gap-2 w-full md:w-auto'>
<Button
type='tertiary'
Expand Down
6 changes: 6 additions & 0 deletions web/src/components/table/users/UsersTable.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ const UsersTable = (usersData) => {
refresh,
resetUserPasskey,
resetUserTwoFA,
userSubscriptions,
planOptions,
t,
} = usersData;

Expand Down Expand Up @@ -141,6 +143,8 @@ const UsersTable = (usersData) => {
showResetPasskeyModal: showResetPasskeyUserModal,
showResetTwoFAModal: showResetTwoFAUserModal,
showUserSubscriptionsModal: showUserSubscriptionsUserModal,
userSubscriptions,
planOptions,
});
}, [
t,
Expand All @@ -153,6 +157,8 @@ const UsersTable = (usersData) => {
showResetPasskeyUserModal,
showResetTwoFAUserModal,
showUserSubscriptionsUserModal,
userSubscriptions,
planOptions,
]);

// Handle compact mode by removing fixed positioning
Expand Down
2 changes: 2 additions & 0 deletions web/src/components/table/users/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const UsersPage = () => {
activePage,
pageSize,
groupOptions,
planOptions,
loading,
searching,

Expand Down Expand Up @@ -98,6 +99,7 @@ const UsersPage = () => {
activePage={activePage}
pageSize={pageSize}
groupOptions={groupOptions}
planOptions={planOptions}
loading={loading}
searching={searching}
t={t}
Expand Down
Loading