diff --git a/backend/internal/modules/invitation/handler.go b/backend/internal/modules/invitation/handler.go index 5b2d911..6caf3dc 100644 --- a/backend/internal/modules/invitation/handler.go +++ b/backend/internal/modules/invitation/handler.go @@ -1,6 +1,7 @@ package invitation import ( + "context" "errors" "net/http" "strconv" @@ -13,14 +14,23 @@ import ( "github.com/gin-gonic/gin" ) +type SiteScoper interface { + ScopeSiteID(ctx context.Context, adminID, requested uint64) (uint64, error) +} + type Handler struct { - service *Service + service *Service + siteScoper SiteScoper } func NewHandler(service *Service) *Handler { return &Handler{service: service} } +func (h *Handler) SetSiteScoper(scoper SiteScoper) { + h.siteScoper = scoper +} + // GetInviteInfo 公开接口:根据邀请码查询邀请人简要信息(用于落地注册页) func (h *Handler) GetInviteInfo(c *gin.Context) { code := strings.TrimSpace(c.Param("code")) @@ -225,3 +235,131 @@ func writeInvitationError(c *gin.Context, err error) { response.Error(c, http.StatusInternalServerError, "invitation_error", err.Error()) } } + +// DistributionGetConfig 分销端获取本站邀请返利比例配置 +func (h *Handler) DistributionGetConfig(c *gin.Context) { + siteID, err := h.resolveDistributionSiteID(c) + if err != nil { + response.Forbidden(c, "无权访问该分销站点") + return + } + cfg, err := h.service.GetRebateConfig(c.Request.Context(), siteID) + if err != nil { + writeInvitationError(c, err) + return + } + response.OK(c, cfg) +} + +// DistributionUpdateConfig 分销端更新本站邀请返利比例配置 +func (h *Handler) DistributionUpdateConfig(c *gin.Context) { + siteID, err := h.resolveDistributionSiteID(c) + if err != nil { + response.Forbidden(c, "无权操作该分销站点") + return + } + if siteID == 0 { + response.BadRequest(c, "未指定有效分销站点") + return + } + var req UpdateConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "比例参数格式不正确,必须在0到1之间") + return + } + cfg, err := h.service.UpdateRebateConfig(c.Request.Context(), siteID, req) + if err != nil { + writeInvitationError(c, err) + return + } + response.OK(c, cfg) +} + +// DistributionListInvitations 分销端查看本站邀请关系 +func (h *Handler) DistributionListInvitations(c *gin.Context) { + siteID, err := h.resolveDistributionSiteID(c) + if err != nil { + response.Forbidden(c, "无权访问该分销站点") + return + } + var query AdminInvitationQuery + if err := c.ShouldBindQuery(&query); err != nil { + response.BadRequest(c, "查询参数不正确") + return + } + query.DistributionSiteID = &siteID + items, total, err := h.service.ListAdminInvitations(c.Request.Context(), query) + if err != nil { + writeInvitationError(c, err) + return + } + response.OK(c, gin.H{ + "items": items, + "total": total, + "page": query.Page, + "page_size": query.PageSize, + }) +} + +// DistributionListRebates 分销端查看本站返利支出流水 +func (h *Handler) DistributionListRebates(c *gin.Context) { + siteID, err := h.resolveDistributionSiteID(c) + if err != nil { + response.Forbidden(c, "无权访问该分销站点") + return + } + var query AdminRebateLedgerQuery + if err := c.ShouldBindQuery(&query); err != nil { + response.BadRequest(c, "查询参数不正确") + return + } + query.DistributionSiteID = &siteID + items, total, err := h.service.ListAdminRebateLedgers(c.Request.Context(), query) + if err != nil { + writeInvitationError(c, err) + return + } + response.OK(c, gin.H{ + "items": items, + "total": total, + "page": query.Page, + "page_size": query.PageSize, + }) +} + +func (h *Handler) resolveDistributionSiteID(c *gin.Context) (uint64, error) { + adminID, _ := currentAdminID(c) + var requested uint64 + if raw := c.Param("id"); raw != "" { + if id, err := strconv.ParseUint(raw, 10, 64); err == nil && id > 0 { + requested = id + } + } + if requested == 0 { + if raw := c.Query("site_id"); raw != "" { + if id, err := strconv.ParseUint(raw, 10, 64); err == nil && id > 0 { + requested = id + } + } + } + if requested == 0 { + if raw := c.Query("distribution_site_id"); raw != "" { + if id, err := strconv.ParseUint(raw, 10, 64); err == nil && id > 0 { + requested = id + } + } + } + if h.siteScoper != nil { + return h.siteScoper.ScopeSiteID(c.Request.Context(), adminID, requested) + } + return requested, nil +} + +func currentAdminID(c *gin.Context) (uint64, bool) { + value, ok := c.Get(middleware.ContextAdminID) + if !ok { + return 0, false + } + adminID, ok := value.(uint64) + return adminID, ok +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 881fb02..6dc2a8c 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -352,6 +352,9 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { } } distributionHandler := distribution.NewHandler(distributionService) + if invitationHandler != nil && distributionService != nil { + invitationHandler.SetSiteScoper(distributionService) + } backupMonitorHandler := backupmonitor.NewHandler(cfg.BackupStatusFile) var adminRoleRepo *adminrole.Repository if deps.DB != nil { @@ -684,6 +687,16 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { distributionProtectedRoutes.PUT("/accounts/:id/markup", distributionHandler.UpdateAccountMarkup) distributionProtectedRoutes.POST("/accounts/batch-markup", distributionHandler.BatchUpdateAccountMarkup) distributionProtectedRoutes.POST("/accounts/batch-display", distributionHandler.BatchUpdateAccountDisplay) + if invitationHandler != nil { + distributionProtectedRoutes.GET("/sites/:id/invitation-config", invitationHandler.DistributionGetConfig) + distributionProtectedRoutes.PUT("/sites/:id/invitation-config", invitationHandler.DistributionUpdateConfig) + distributionProtectedRoutes.GET("/sites/:id/invitations", invitationHandler.DistributionListInvitations) + distributionProtectedRoutes.GET("/sites/:id/invitation-rebates", invitationHandler.DistributionListRebates) + distributionProtectedRoutes.GET("/invitation-config", invitationHandler.DistributionGetConfig) + distributionProtectedRoutes.PUT("/invitation-config", invitationHandler.DistributionUpdateConfig) + distributionProtectedRoutes.GET("/invitations", invitationHandler.DistributionListInvitations) + distributionProtectedRoutes.GET("/invitation-rebates", invitationHandler.DistributionListRebates) + } } adminRoutes := api.Group("/admin", requireAdmin, middleware.RequireAdminPasswordChanged()) diff --git a/backend/pkg/response/response.go b/backend/pkg/response/response.go index 62ed6d1..1e0a968 100644 --- a/backend/pkg/response/response.go +++ b/backend/pkg/response/response.go @@ -71,6 +71,10 @@ func BadRequest(c *gin.Context, message string) { Error(c, http.StatusBadRequest, "bad_request", message) } +func Forbidden(c *gin.Context, message string) { + Error(c, http.StatusForbidden, "forbidden", message) +} + func Unauthorized(c *gin.Context, message string) { Error(c, http.StatusUnauthorized, "unauthorized", message) } diff --git a/distribution-admin/src/AppShell.tsx b/distribution-admin/src/AppShell.tsx index 9c09b30..9993e44 100644 --- a/distribution-admin/src/AppShell.tsx +++ b/distribution-admin/src/AppShell.tsx @@ -16,6 +16,7 @@ import { SkinOutlined, UserOutlined, WalletOutlined, + GiftOutlined, } from '@ant-design/icons' import { getMe, logout, type CurrentMember, getStoredToken } from './api' import { MemberContext } from './MemberContext' @@ -29,6 +30,7 @@ import Orders from './pages/Orders' import Wallet from './pages/Wallet' import Announcements from './pages/Announcements' import SiteSettings from './pages/SiteSettings' +import Invitations from './pages/Invitations' import Login from './pages/Login' import Storefront from './pages/Storefront' import './style.css' @@ -62,6 +64,11 @@ function MainLayout({ member, onLogout }: { member: CurrentMember; onLogout: () icon: , label: {member.is_superadmin ? '比例与税费规则' : '合作政策与权益'}, }, + { + key: '/invitations', + icon: , + label: 邀请返利设置, + }, ] const selectedKey = menuItems.some((item) => item && 'key' in item && item.key === location.pathname) @@ -162,6 +169,7 @@ function MainLayout({ member, onLogout }: { member: CurrentMember; onLogout: () } /> } /> } /> + } /> } /> diff --git a/distribution-admin/src/api.ts b/distribution-admin/src/api.ts index bc9286d..44b6b7d 100644 --- a/distribution-admin/src/api.ts +++ b/distribution-admin/src/api.ts @@ -880,3 +880,85 @@ export async function testSiteSMSConfig(siteID: number, payload: TestSiteSMSConf const resp = await api.post>(`/admin/distribution/sites/${siteID}/sms-config/test`, payload) return extract(resp) } + +export interface InvitationRebateConfig { + distribution_site_id: number + first_month_rate: number + second_month_rate: number + subsequent_rate: number + enabled: boolean + updated_at?: string +} + +export interface InvitedUserItem { + id: number + inviter_id: number + inviter_phone?: string + inviter_nickname?: string + invitee_id: number + invitee_phone?: string + invitee_nickname?: string + invite_code: string + status: string + verified_at?: string + created_at: string +} + +export interface RebateLedgerItem { + id: number + order_id: number + order_no: string + inviter_id: number + inviter_phone?: string + inviter_nickname?: string + invitee_id: number + invitee_phone?: string + invitee_nickname?: string + trade_role: 'buyer' | 'seller' + order_profit_basis_cent: number + single_side_basis_cent: number + rate_applied: number + days_since_verified: number + cycle_stage: string + rebate_amount_cent: number + status: string + settled_at: string +} + +export async function fetchSiteInvitationConfig(siteID?: number): Promise { + const url = siteID ? `/distribution/sites/${siteID}/invitation-config` : '/distribution/invitation-config' + const resp = await api.get>(url) + return extract(resp) +} + +export async function updateSiteInvitationConfig( + siteID: number, + payload: { + first_month_rate: number + second_month_rate: number + subsequent_rate: number + enabled: boolean + } +): Promise { + const url = siteID ? `/distribution/sites/${siteID}/invitation-config` : '/distribution/invitation-config' + const resp = await api.put>(url, payload) + return extract(resp) +} + +export async function fetchSiteInvitations( + siteID: number, + params: { page: number; page_size: number; phone?: string; invite_code?: string } +): Promise<{ items: InvitedUserItem[]; total: number; page: number; page_size: number }> { + const url = siteID ? `/distribution/sites/${siteID}/invitations` : '/distribution/invitations' + const resp = await api.get>(url, { params }) + return extract(resp) +} + +export async function fetchSiteRebates( + siteID: number, + params: { page: number; page_size: number; order_no?: string; inviter_phone?: string; invitee_phone?: string } +): Promise<{ items: RebateLedgerItem[]; total: number; page: number; page_size: number }> { + const url = siteID ? `/distribution/sites/${siteID}/invitation-rebates` : '/distribution/invitation-rebates' + const resp = await api.get>(url, { params }) + return extract(resp) +} diff --git a/distribution-admin/src/pages/Invitations.tsx b/distribution-admin/src/pages/Invitations.tsx new file mode 100644 index 0000000..6257908 --- /dev/null +++ b/distribution-admin/src/pages/Invitations.tsx @@ -0,0 +1,529 @@ +import { useEffect, useState, useCallback } from 'react' +import { + Card, + Typography, + Space, + Select, + message, + Button, + Form, + InputNumber, + Switch, + Alert, + Tabs, + Table, + Tag, + Input, + Row, + Col, +} from 'antd' +import { + SettingOutlined, + UserOutlined, + DollarCircleOutlined, + SaveOutlined, + ReloadOutlined, + SearchOutlined, + InfoCircleOutlined, +} from '@ant-design/icons' +import { + fetchSiteInvitationConfig, + updateSiteInvitationConfig, + fetchSiteInvitations, + fetchSiteRebates, + listSites, + formatMoney, + type Site, + type InvitedUserItem, + type RebateLedgerItem, +} from '../api' +import { useMember } from '../MemberContext' + +export default function Invitations() { + const member = useMember() + const isSuperadmin = Boolean(member?.is_superadmin) + + const [sites, setSites] = useState([]) + const [selectedSiteID, setSelectedSiteID] = useState(member?.site_id || 0) + const [activeTab, setActiveTab] = useState('config') + + // 配置表单状态 + const [loadingConfig, setLoadingConfig] = useState(false) + const [savingConfig, setSavingConfig] = useState(false) + const [form] = Form.useForm() + + // 邀请关系状态 + const [loadingInvitations, setLoadingInvitations] = useState(false) + const [invitations, setInvitations] = useState([]) + const [invitationsTotal, setInvitationsTotal] = useState(0) + const [invitationPage, setInvitationPage] = useState(1) + const [invitationPhone, setInvitationPhone] = useState('') + const [invitationCode, setInvitationCode] = useState('') + + // 返利流水状态 + const [loadingRebates, setLoadingRebates] = useState(false) + const [rebates, setRebates] = useState([]) + const [rebatesTotal, setRebatesTotal] = useState(0) + const [rebatePage, setRebatePage] = useState(1) + const [rebateOrderNo, setRebateOrderNo] = useState('') + const [rebateInviterPhone, setRebateInviterPhone] = useState('') + + // 加载站点列表(仅超管可选站点) + useEffect(() => { + if (isSuperadmin) { + listSites() + .then((data) => { + setSites(data) + if (!selectedSiteID && data.length > 0) { + setSelectedSiteID(data[0].id) + } + }) + .catch(() => { + // ignore + }) + } else if (member?.site_id) { + setSelectedSiteID(member.site_id) + } + }, [isSuperadmin, member?.site_id, selectedSiteID]) + + // 加载配置 + const loadConfig = useCallback(async (siteID: number) => { + if (!siteID) return + setLoadingConfig(true) + try { + const cfg = await fetchSiteInvitationConfig(siteID) + form.setFieldsValue({ + enabled: cfg.enabled !== false, + firstMonthRate: Math.round((cfg.first_month_rate || 0.6) * 100), + secondMonthRate: Math.round((cfg.second_month_rate || 0.3) * 100), + subsequentRate: Math.round((cfg.subsequent_rate || 0.15) * 100), + }) + } catch { + message.error('获取本站邀请返利配置失败') + } finally { + setLoadingConfig(false) + } + }, [form]) + + // 加载邀请关系 + const loadInvitations = useCallback(async (siteID: number, page = 1) => { + if (!siteID) return + setLoadingInvitations(true) + try { + const res = await fetchSiteInvitations(siteID, { + page, + page_size: 15, + phone: invitationPhone.trim() || undefined, + invite_code: invitationCode.trim() || undefined, + }) + setInvitations(res.items || []) + setInvitationsTotal(res.total || 0) + setInvitationPage(page) + } catch { + message.error('获取邀请关系列表失败') + } finally { + setLoadingInvitations(false) + } + }, [invitationPhone, invitationCode]) + + // 加载返利流水 + const loadRebates = useCallback(async (siteID: number, page = 1) => { + if (!siteID) return + setLoadingRebates(true) + try { + const res = await fetchSiteRebates(siteID, { + page, + page_size: 15, + order_no: rebateOrderNo.trim() || undefined, + inviter_phone: rebateInviterPhone.trim() || undefined, + }) + setRebates(res.items || []) + setRebatesTotal(res.total || 0) + setRebatePage(page) + } catch { + message.error('获取返利流水列表失败') + } finally { + setLoadingRebates(false) + } + }, [rebateOrderNo, rebateInviterPhone]) + + useEffect(() => { + if (selectedSiteID) { + if (activeTab === 'config') { + void loadConfig(selectedSiteID) + } else if (activeTab === 'invitations') { + void loadInvitations(selectedSiteID, 1) + } else if (activeTab === 'rebates') { + void loadRebates(selectedSiteID, 1) + } + } + }, [selectedSiteID, activeTab, loadConfig, loadInvitations, loadRebates]) + + // 保存配置 + const handleSaveConfig = async () => { + try { + const values = await form.validateFields() + setSavingConfig(true) + await updateSiteInvitationConfig(selectedSiteID, { + enabled: Boolean(values.enabled), + first_month_rate: Number((values.firstMonthRate / 100).toFixed(4)), + second_month_rate: Number((values.secondMonthRate / 100).toFixed(4)), + subsequent_rate: Number((values.subsequentRate / 100).toFixed(4)), + }) + message.success('分站邀请返利比例配置已保存') + } catch { + message.error('保存返利比例配置失败,请检查输入') + } finally { + setSavingConfig(false) + } + } + + const tabItems = [ + { + key: 'config', + label: ( + + 本站返利比例自主设置 + + ), + children: ( + + } + style={{ marginBottom: 24 }} + /> + +
+ + + + + + + + + + + + + + + + + + + +
+
+ ), + }, + { + key: 'invitations', + label: ( + + 本站邀请关系 ({invitationsTotal}) + + ), + children: ( + + + setInvitationPhone(e.target.value)} + style={{ width: 180 }} + allowClear + /> + setInvitationCode(e.target.value)} + style={{ width: 160 }} + allowClear + /> + + + + + + loading={loadingInvitations} + dataSource={invitations} + rowKey="id" + pagination={{ + current: invitationPage, + pageSize: 15, + total: invitationsTotal, + onChange: (p) => loadInvitations(selectedSiteID, p), + showTotal: (total) => `共 ${total} 条邀请关系`, + }} + columns={[ + { + title: '邀请人', + key: 'inviter', + render: (_, r) => ( +
+
{r.inviter_nickname || '用户' + r.inviter_id}
+ + {r.inviter_phone || '--'} + +
+ ), + }, + { + title: '邀请码', + dataIndex: 'invite_code', + render: (code) => {code}, + }, + { + title: '被邀请人', + key: 'invitee', + render: (_, r) => ( +
+
{r.invitee_nickname || '用户' + r.invitee_id}
+ + {r.invitee_phone || '--'} + +
+ ), + }, + { + title: '实名状态', + dataIndex: 'status', + render: (status) => + status === 'active' ? ( + 已实名生效 + ) : ( + 注册待实名 + ), + }, + { + title: '注册时间', + dataIndex: 'created_at', + }, + { + title: '实名通过时间', + dataIndex: 'verified_at', + render: (val) => val || '--', + }, + ]} + /> +
+ ), + }, + { + key: 'rebates', + label: ( + + 本站返利流水 ({rebatesTotal}) + + ), + children: ( + + + setRebateOrderNo(e.target.value)} + style={{ width: 180 }} + allowClear + /> + setRebateInviterPhone(e.target.value)} + style={{ width: 160 }} + allowClear + /> + + + + + + loading={loadingRebates} + dataSource={rebates} + rowKey="id" + pagination={{ + current: rebatePage, + pageSize: 15, + total: rebatesTotal, + onChange: (p) => loadRebates(selectedSiteID, p), + showTotal: (total) => `共 ${total} 条返利流水`, + }} + columns={[ + { + title: '订单号', + dataIndex: 'order_no', + render: (no) => {no}, + }, + { + title: '分成端', + dataIndex: 'trade_role', + render: (role) => + role === 'buyer' ? ( + 买家端 (本站售出) + ) : ( + 卖家端 (本站收号) + ), + }, + { + title: '收益基准', + dataIndex: 'single_side_basis_cent', + render: (cent) => formatMoney(cent), + }, + { + title: '分成比例', + dataIndex: 'rate_applied', + render: (r, row) => ( +
+ {(r * 100).toFixed(1)}% + + {row.cycle_stage === 'first_month' + ? '首月' + : row.cycle_stage === 'second_month' + ? '次月' + : '长期'} + +
+ ), + }, + { + title: '返利发放金额', + dataIndex: 'rebate_amount_cent', + render: (cent) => ( + + +{formatMoney(cent)} + + ), + }, + { + title: '获益邀请人', + key: 'inviter', + render: (_, r) => ( +
+
{r.inviter_nickname || '用户' + r.inviter_id}
+ + {r.inviter_phone || '--'} + +
+ ), + }, + { + title: '结算时间', + dataIndex: 'settled_at', + }, + ]} + /> +
+ ), + }, + ] + + return ( + + + + + 本站邀请返利自主设置 + + + 分销站点可根据自身经营策略,自由调整本站邀请返利比例并监控本站返利支出与用户绑定流水。 + + + + {isSuperadmin && sites.length > 0 ? ( + + 切换分销网站: +