diff --git a/controller/subscription.go b/controller/subscription.go index 85c26b7fd..fd88ca1f6 100644 --- a/controller/subscription.go +++ b/controller/subscription.go @@ -295,6 +295,73 @@ func AdminBindSubscription(c *gin.Context) { common.ApiSuccess(c, nil) } +// ---- Admin: user subscription management ---- + +func AdminListUserSubscriptions(c *gin.Context) { + userId, _ := strconv.Atoi(c.Param("id")) + if userId <= 0 { + common.ApiErrorMsg(c, "无效的用户ID") + return + } + subs, err := model.AdminListUserSubscriptions(userId) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, subs) +} + +type AdminCreateUserSubscriptionRequest struct { + PlanId int `json:"plan_id"` +} + +// AdminCreateUserSubscription creates a new user subscription from a plan (no payment). +func AdminCreateUserSubscription(c *gin.Context) { + userId, _ := strconv.Atoi(c.Param("id")) + if userId <= 0 { + common.ApiErrorMsg(c, "无效的用户ID") + return + } + var req AdminCreateUserSubscriptionRequest + if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 { + common.ApiErrorMsg(c, "参数错误") + return + } + if err := model.AdminBindSubscription(userId, req.PlanId, ""); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, nil) +} + +// AdminInvalidateUserSubscription cancels a user subscription immediately. +func AdminInvalidateUserSubscription(c *gin.Context) { + subId, _ := strconv.Atoi(c.Param("id")) + if subId <= 0 { + common.ApiErrorMsg(c, "无效的订阅ID") + return + } + if err := model.AdminInvalidateUserSubscription(subId); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, nil) +} + +// AdminDeleteUserSubscription hard-deletes a user subscription. +func AdminDeleteUserSubscription(c *gin.Context) { + subId, _ := strconv.Atoi(c.Param("id")) + if subId <= 0 { + common.ApiErrorMsg(c, "无效的订阅ID") + return + } + if err := model.AdminDeleteUserSubscription(subId); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, nil) +} + // ---- Helper: serialize provider payload safely ---- func jsonString(v any) string { diff --git a/model/subscription.go b/model/subscription.go index 75aebc1ed..a79c5e3ff 100644 --- a/model/subscription.go +++ b/model/subscription.go @@ -392,6 +392,44 @@ func GetAllUserSubscriptions(userId int) ([]SubscriptionSummary, error) { return result, nil } +// ---- Admin helpers for managing user subscriptions ---- + +// AdminListUserSubscriptions lists all subscriptions (including expired) for a user. +func AdminListUserSubscriptions(userId int) ([]SubscriptionSummary, error) { + return GetAllUserSubscriptions(userId) +} + +// AdminInvalidateUserSubscription marks a user subscription as cancelled and ends it immediately. +func AdminInvalidateUserSubscription(userSubscriptionId int) error { + if userSubscriptionId <= 0 { + return errors.New("invalid userSubscriptionId") + } + now := common.GetTimestamp() + return DB.Model(&UserSubscription{}). + Where("id = ?", userSubscriptionId). + Updates(map[string]interface{}{ + "status": "cancelled", + "end_time": now, + "updated_at": now, + }).Error +} + +// AdminDeleteUserSubscription hard-deletes a user subscription and its items. +func AdminDeleteUserSubscription(userSubscriptionId int) error { + if userSubscriptionId <= 0 { + return errors.New("invalid userSubscriptionId") + } + return DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("user_subscription_id = ?", userSubscriptionId).Delete(&UserSubscriptionItem{}).Error; err != nil { + return err + } + if err := tx.Where("id = ?", userSubscriptionId).Delete(&UserSubscription{}).Error; err != nil { + return err + } + return nil + }) +} + type SubscriptionPreConsumeResult struct { UserSubscriptionId int ItemId int diff --git a/router/api-router.go b/router/api-router.go index 9b2f20b55..29e9f2e25 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -139,6 +139,12 @@ func SetApiRouter(router *gin.Engine) { subscriptionAdminRoute.PUT("/plans/:id", controller.AdminUpdateSubscriptionPlan) subscriptionAdminRoute.DELETE("/plans/:id", controller.AdminDeleteSubscriptionPlan) subscriptionAdminRoute.POST("/bind", controller.AdminBindSubscription) + + // User subscription management (admin) + subscriptionAdminRoute.GET("/users/:id/subscriptions", controller.AdminListUserSubscriptions) + subscriptionAdminRoute.POST("/users/:id/subscriptions", controller.AdminCreateUserSubscription) + subscriptionAdminRoute.POST("/user_subscriptions/:id/invalidate", controller.AdminInvalidateUserSubscription) + subscriptionAdminRoute.DELETE("/user_subscriptions/:id", controller.AdminDeleteUserSubscription) } // Subscription payment callbacks (no auth) diff --git a/web/src/components/table/users/UsersColumnDefs.jsx b/web/src/components/table/users/UsersColumnDefs.jsx index 17d0a6324..dc3e6f341 100644 --- a/web/src/components/table/users/UsersColumnDefs.jsx +++ b/web/src/components/table/users/UsersColumnDefs.jsx @@ -208,7 +208,7 @@ const renderOperations = ( showDeleteModal, showResetPasskeyModal, showResetTwoFAModal, - showBindSubscriptionModal, + showUserSubscriptionsModal, t, }, ) => { @@ -219,8 +219,8 @@ const renderOperations = ( const moreMenu = [ { node: 'item', - name: t('绑定订阅套餐'), - onClick: () => showBindSubscriptionModal(record), + name: t('订阅管理'), + onClick: () => showUserSubscriptionsModal(record), }, { node: 'divider', @@ -308,7 +308,7 @@ export const getUsersColumns = ({ showDeleteModal, showResetPasskeyModal, showResetTwoFAModal, - showBindSubscriptionModal, + showUserSubscriptionsModal, }) => { return [ { @@ -365,7 +365,7 @@ export const getUsersColumns = ({ showDeleteModal, showResetPasskeyModal, showResetTwoFAModal, - showBindSubscriptionModal, + showUserSubscriptionsModal, t, }), }, diff --git a/web/src/components/table/users/UsersTable.jsx b/web/src/components/table/users/UsersTable.jsx index 525b11682..e0f8a9cec 100644 --- a/web/src/components/table/users/UsersTable.jsx +++ b/web/src/components/table/users/UsersTable.jsx @@ -31,7 +31,7 @@ import EnableDisableUserModal from './modals/EnableDisableUserModal'; import DeleteUserModal from './modals/DeleteUserModal'; import ResetPasskeyModal from './modals/ResetPasskeyModal'; import ResetTwoFAModal from './modals/ResetTwoFAModal'; -import BindSubscriptionModal from './modals/BindSubscriptionModal'; +import UserSubscriptionsModal from './modals/UserSubscriptionsModal'; const UsersTable = (usersData) => { const { @@ -62,7 +62,7 @@ const UsersTable = (usersData) => { const [enableDisableAction, setEnableDisableAction] = useState(''); const [showResetPasskeyModal, setShowResetPasskeyModal] = useState(false); const [showResetTwoFAModal, setShowResetTwoFAModal] = useState(false); - const [showBindSubscriptionModal, setShowBindSubscriptionModal] = + const [showUserSubscriptionsModal, setShowUserSubscriptionsModal] = useState(false); // Modal handlers @@ -97,9 +97,9 @@ const UsersTable = (usersData) => { setShowResetTwoFAModal(true); }; - const showBindSubscriptionUserModal = (user) => { + const showUserSubscriptionsUserModal = (user) => { setModalUser(user); - setShowBindSubscriptionModal(true); + setShowUserSubscriptionsModal(true); }; // Modal confirm handlers @@ -140,7 +140,7 @@ const UsersTable = (usersData) => { showDeleteModal: showDeleteUserModal, showResetPasskeyModal: showResetPasskeyUserModal, showResetTwoFAModal: showResetTwoFAUserModal, - showBindSubscriptionModal: showBindSubscriptionUserModal, + showUserSubscriptionsModal: showUserSubscriptionsUserModal, }); }, [ t, @@ -152,7 +152,7 @@ const UsersTable = (usersData) => { showDeleteUserModal, showResetPasskeyUserModal, showResetTwoFAUserModal, - showBindSubscriptionUserModal, + showUserSubscriptionsUserModal, ]); // Handle compact mode by removing fixed positioning @@ -253,9 +253,9 @@ const UsersTable = (usersData) => { t={t} /> - setShowBindSubscriptionModal(false)} + setShowUserSubscriptionsModal(false)} user={modalUser} t={t} onSuccess={() => refresh?.()} diff --git a/web/src/components/table/users/modals/UserSubscriptionsModal.jsx b/web/src/components/table/users/modals/UserSubscriptionsModal.jsx new file mode 100644 index 000000000..0b47c8b44 --- /dev/null +++ b/web/src/components/table/users/modals/UserSubscriptionsModal.jsx @@ -0,0 +1,431 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useEffect, useMemo, useState } from 'react'; +import { + Button, + Empty, + Modal, + Popover, + Select, + SideSheet, + Space, + Tag, + Typography, +} from '@douyinfe/semi-ui'; +import { IconPlusCircle } from '@douyinfe/semi-icons'; +import { + IllustrationNoResult, + IllustrationNoResultDark, +} from '@douyinfe/semi-illustrations'; +import { API, showError, showSuccess } from '../../../../helpers'; +import { useIsMobile } from '../../../../hooks/common/useIsMobile'; +import CardTable from '../../../common/ui/CardTable'; + +const { Text } = Typography; + +function formatTs(ts) { + if (!ts) return '-'; + return new Date(ts * 1000).toLocaleString(); +} + +function renderStatusTag(sub, t) { + const now = Date.now() / 1000; + const end = sub?.end_time || 0; + const status = sub?.status || ''; + + const isExpiredByTime = end > 0 && end < now; + const isActive = status === 'active' && !isExpiredByTime; + if (isActive) { + return ( + + {t('生效')} + + ); + } + if (status === 'cancelled') { + return ( + + {t('已作废')} + + ); + } + return ( + + {t('已过期')} + + ); +} + +const UserSubscriptionsModal = ({ visible, onCancel, user, t, onSuccess }) => { + const isMobile = useIsMobile(); + const [loading, setLoading] = useState(false); + const [creating, setCreating] = useState(false); + const [plansLoading, setPlansLoading] = useState(false); + + const [plans, setPlans] = useState([]); + const [selectedPlanId, setSelectedPlanId] = useState(null); + + const [subs, setSubs] = useState([]); + const [currentPage, setCurrentPage] = useState(1); + const pageSize = 10; + + const planTitleMap = useMemo(() => { + const map = new Map(); + (plans || []).forEach((p) => { + const id = p?.plan?.id; + const title = p?.plan?.title; + if (id) map.set(id, title || `#${id}`); + }); + return map; + }, [plans]); + + const pagedSubs = useMemo(() => { + const start = Math.max(0, (Number(currentPage || 1) - 1) * pageSize); + const end = start + pageSize; + return (subs || []).slice(start, end); + }, [subs, currentPage]); + + const planOptions = useMemo(() => { + return (plans || []).map((p) => ({ + label: `${p?.plan?.title || ''} (${p?.plan?.currency || 'USD'} ${Number(p?.plan?.price_amount || 0)})`, + value: p?.plan?.id, + })); + }, [plans]); + + const loadPlans = async () => { + setPlansLoading(true); + try { + const res = await API.get('/api/subscription/admin/plans'); + if (res.data?.success) { + setPlans(res.data.data || []); + } else { + showError(res.data?.message || t('加载失败')); + } + } catch (e) { + showError(t('请求失败')); + } finally { + setPlansLoading(false); + } + }; + + const loadUserSubscriptions = async () => { + if (!user?.id) return; + setLoading(true); + try { + const res = await API.get(`/api/subscription/admin/users/${user.id}/subscriptions`); + if (res.data?.success) { + const next = res.data.data || []; + setSubs(next); + setCurrentPage(1); + } else { + showError(res.data?.message || t('加载失败')); + } + } catch (e) { + showError(t('请求失败')); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (!visible) return; + setSelectedPlanId(null); + setCurrentPage(1); + loadPlans(); + loadUserSubscriptions(); + }, [visible]); + + const handlePageChange = (page) => { + setCurrentPage(page); + }; + + const createSubscription = async () => { + if (!user?.id) { + showError(t('用户信息缺失')); + return; + } + if (!selectedPlanId) { + showError(t('请选择订阅套餐')); + return; + } + setCreating(true); + try { + const res = await API.post(`/api/subscription/admin/users/${user.id}/subscriptions`, { + plan_id: selectedPlanId, + }); + if (res.data?.success) { + showSuccess(t('新增成功')); + setSelectedPlanId(null); + await loadUserSubscriptions(); + onSuccess?.(); + } else { + showError(res.data?.message || t('新增失败')); + } + } catch (e) { + showError(t('请求失败')); + } finally { + setCreating(false); + } + }; + + const invalidateSubscription = (subId) => { + Modal.confirm({ + title: t('确认作废'), + content: t('作废后该订阅将立即失效,历史记录不受影响。是否继续?'), + centered: true, + onOk: async () => { + try { + const res = await API.post( + `/api/subscription/admin/user_subscriptions/${subId}/invalidate`, + ); + if (res.data?.success) { + showSuccess(t('已作废')); + await loadUserSubscriptions(); + onSuccess?.(); + } else { + showError(res.data?.message || t('操作失败')); + } + } catch (e) { + showError(t('请求失败')); + } + }, + }); + }; + + const deleteSubscription = (subId) => { + Modal.confirm({ + title: t('确认删除'), + content: t('删除会彻底移除该订阅记录(含权益明细)。是否继续?'), + centered: true, + okType: 'danger', + onOk: async () => { + try { + const res = await API.delete(`/api/subscription/admin/user_subscriptions/${subId}`); + if (res.data?.success) { + showSuccess(t('已删除')); + await loadUserSubscriptions(); + onSuccess?.(); + } else { + showError(res.data?.message || t('删除失败')); + } + } catch (e) { + showError(t('请求失败')); + } + }, + }); + }; + + const columns = useMemo(() => { + return [ + { + title: 'ID', + dataIndex: ['subscription', 'id'], + key: 'id', + width: 70, + }, + { + title: t('套餐'), + key: 'plan', + width: 180, + render: (_, record) => { + const sub = record?.subscription; + const planId = sub?.plan_id; + const title = planTitleMap.get(planId) || (planId ? `#${planId}` : '-'); + return ( +
+
{title}
+
+ {t('来源')}: {sub?.source || '-'} +
+
+ ); + }, + }, + { + title: t('状态'), + key: 'status', + width: 90, + render: (_, record) => renderStatusTag(record?.subscription, t), + }, + { + title: t('有效期'), + key: 'validity', + width: 200, + render: (_, record) => { + const sub = record?.subscription; + return ( +
+
+ {t('开始')}: {formatTs(sub?.start_time)} +
+
+ {t('结束')}: {formatTs(sub?.end_time)} +
+
+ ); + }, + }, + { + title: t('权益'), + key: 'items', + width: 80, + render: (_, record) => { + const items = record?.items || []; + if (items.length === 0) return -; + const content = ( +
+ {items.map((it) => ( +
+ {it.model_name} + + {it.amount_used}/{it.amount_total} + {it.quota_type === 1 ? t('次') : ''} + +
+ ))} +
+ ); + return ( + + + {items.length} {t('项')} + + + ); + }, + }, + { + title: '', + key: 'operate', + width: 140, + fixed: 'right', + render: (_, record) => { + const sub = record?.subscription; + const now = Date.now() / 1000; + const isExpired = (sub?.end_time || 0) > 0 && (sub?.end_time || 0) < now; + const isActive = sub?.status === 'active' && !isExpired; + const isCancelled = sub?.status === 'cancelled'; + return ( + + + + + ); + }, + }, + ]; + }, [t, planTitleMap]); + + return ( + + + {t('管理')} + + + {t('用户订阅管理')} + + + {user?.username || '-'} (ID: {user?.id || '-'}) + + + } + > +
+ {/* 顶部操作栏:新增订阅 */} +
+
+