feat(referral): add user-level personal circle + points system
- Migration 011: 4 new tables (user_referral_codes, user_referral_relationships, user_point_transactions, user_point_balances) - Referral service: user-level repositories, use cases, and controller endpoints (GET /me/user, /me/circle, /me/points; POST /internal/user-register) - Admin endpoints: user-circles, user-points, user-balances listing - Auth service: fire-and-forget user referral registration on signup - Flutter: 2-tab UI (企业推荐 / 我的圈子) with personal code card, points balance, circle member list, and points history - Web admin: 2 new tabs (用户圈子 / 用户积分) with transaction ledger and balance leaderboard Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6be84617d2
commit
4df699348f
|
|
@ -6,10 +6,18 @@ import {
|
|||
getAdminReferralStats,
|
||||
listAdminRelationships,
|
||||
listAdminRewards,
|
||||
listAdminUserCircles,
|
||||
listAdminUserPoints,
|
||||
listAdminUserBalances,
|
||||
} from '@/infrastructure/repositories/api-referral.repository';
|
||||
import { ReferralStatus, RewardStatus } from '@/domain/entities/referral';
|
||||
import {
|
||||
ReferralStatus,
|
||||
RewardStatus,
|
||||
UserReferralStatus,
|
||||
PointTransactionType,
|
||||
} from '@/domain/entities/referral';
|
||||
|
||||
// ── Status badge helper ────────────────────────────────────────────────────────
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const colorMap: Record<string, string> = {
|
||||
|
|
@ -30,8 +38,18 @@ function StatusBadge({ status }: { status: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
function formatCents(cents: number) {
|
||||
return `$${(cents / 100).toFixed(2)}`;
|
||||
function PointTypeBadge({ type }: { type: PointTransactionType }) {
|
||||
const labelMap: Record<string, string> = {
|
||||
REFERRAL_FIRST_PAYMENT: '推荐首次订阅',
|
||||
REFERRAL_RECURRING: '推荐续订',
|
||||
REFERRAL_L2: '二级推荐',
|
||||
REFERRAL_WELCOME: '加入欢迎礼',
|
||||
REDEMPTION_QUOTA: '兑换配额',
|
||||
REDEMPTION_UNLOCK: '兑换解锁',
|
||||
ADMIN_GRANT: '平台赠送',
|
||||
EXPIRY: '积分过期',
|
||||
};
|
||||
return <span className="text-xs text-muted-foreground">{labelMap[type] ?? type}</span>;
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null) {
|
||||
|
|
@ -39,7 +57,29 @@ function formatDate(iso: string | null) {
|
|||
return new Date(iso).toLocaleDateString('zh-CN');
|
||||
}
|
||||
|
||||
// ── Stats Overview ─────────────────────────────────────────────────────────────
|
||||
function Pagination({
|
||||
page, total, limit, onPageChange,
|
||||
}: { page: number; total: number; limit: number; onPageChange: (p: number) => void }) {
|
||||
if (total <= limit) return null;
|
||||
const pages = Math.ceil(total / limit);
|
||||
return (
|
||||
<div className="flex justify-end gap-2 mt-3">
|
||||
<button
|
||||
className="px-3 py-1 text-sm border rounded-lg disabled:opacity-40"
|
||||
disabled={page === 0}
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
>上一页</button>
|
||||
<span className="px-3 py-1 text-sm">{page + 1} / {pages}</span>
|
||||
<button
|
||||
className="px-3 py-1 text-sm border rounded-lg disabled:opacity-40"
|
||||
disabled={(page + 1) * limit >= total}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
>下一页</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Stats Overview ────────────────────────────────────────────────────────────
|
||||
|
||||
function StatsOverview() {
|
||||
const { data, isLoading } = useQuery({
|
||||
|
|
@ -52,13 +92,15 @@ function StatsOverview() {
|
|||
if (!data) return null;
|
||||
|
||||
const cards = [
|
||||
{ label: '总推荐数', value: data.totalReferrals, color: 'text-indigo-600' },
|
||||
{ label: '企业推荐总数', value: data.totalReferrals, color: 'text-indigo-600' },
|
||||
{ label: '已激活推荐', value: data.activeReferrals, color: 'text-green-600' },
|
||||
{ label: '待领积分记录', value: data.pendingRewards, color: 'text-amber-600' },
|
||||
{ label: '待结算奖励', value: data.pendingRewards, color: 'text-amber-600' },
|
||||
{ label: '用户圈子关系', value: data.totalUserCircles ?? 0, color: 'text-purple-600' },
|
||||
{ label: '积分交易笔数', value: data.totalPointTransactions ?? 0, color: 'text-blue-600' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
{cards.map((c) => (
|
||||
<div key={c.label} className="rounded-xl border bg-card p-5">
|
||||
<p className="text-sm text-muted-foreground">{c.label}</p>
|
||||
|
|
@ -69,7 +111,7 @@ function StatsOverview() {
|
|||
);
|
||||
}
|
||||
|
||||
// ── Relationships Table ────────────────────────────────────────────────────────
|
||||
// ── Tenant Relationships Table ────────────────────────────────────────────────
|
||||
|
||||
function RelationshipsTable() {
|
||||
const [status, setStatus] = useState<ReferralStatus | ''>('');
|
||||
|
|
@ -78,22 +120,17 @@ function RelationshipsTable() {
|
|||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['referral-relationships', status, page],
|
||||
queryFn: () =>
|
||||
listAdminRelationships({
|
||||
status: (status as ReferralStatus) || undefined,
|
||||
limit,
|
||||
offset: page * limit,
|
||||
}),
|
||||
queryFn: () => listAdminRelationships({ status: (status as ReferralStatus) || undefined, limit, offset: page * limit }),
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="font-semibold">推荐关系</h2>
|
||||
<h2 className="font-semibold">企业推荐关系</h2>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => { setStatus(e.target.value as any); setPage(0); }}
|
||||
onChange={(e) => { setStatus(e.target.value as ReferralStatus); setPage(0); }}
|
||||
className="text-sm border rounded-lg px-3 py-1.5 bg-background"
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
|
|
@ -129,38 +166,18 @@ function RelationshipsTable() {
|
|||
</tr>
|
||||
))}
|
||||
{data?.items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-muted-foreground">暂无数据</td>
|
||||
</tr>
|
||||
<tr><td colSpan={7} className="px-4 py-8 text-center text-muted-foreground">暂无数据</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{data && data.total > limit && (
|
||||
<div className="flex justify-end gap-2 mt-3">
|
||||
<button
|
||||
className="px-3 py-1 text-sm border rounded-lg disabled:opacity-40"
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>上一页</button>
|
||||
<span className="px-3 py-1 text-sm">
|
||||
{page + 1} / {Math.ceil(data.total / limit)}
|
||||
</span>
|
||||
<button
|
||||
className="px-3 py-1 text-sm border rounded-lg disabled:opacity-40"
|
||||
disabled={(page + 1) * limit >= data.total}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>下一页</button>
|
||||
</div>
|
||||
)}
|
||||
<Pagination page={page} total={data?.total ?? 0} limit={limit} onPageChange={setPage} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Rewards Table ─────────────────────────────────────────────────────────────
|
||||
// ── Tenant Rewards Table ──────────────────────────────────────────────────────
|
||||
|
||||
function RewardsTable() {
|
||||
const [status, setStatus] = useState<RewardStatus | ''>('');
|
||||
|
|
@ -169,12 +186,7 @@ function RewardsTable() {
|
|||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['referral-rewards', status, page],
|
||||
queryFn: () =>
|
||||
listAdminRewards({
|
||||
status: (status as RewardStatus) || undefined,
|
||||
limit,
|
||||
offset: page * limit,
|
||||
}),
|
||||
queryFn: () => listAdminRewards({ status: (status as RewardStatus) || undefined, limit, offset: page * limit }),
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
|
|
@ -184,10 +196,10 @@ function RewardsTable() {
|
|||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="font-semibold">积分奖励记录</h2>
|
||||
<h2 className="font-semibold">企业积分奖励</h2>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => { setStatus(e.target.value as any); setPage(0); }}
|
||||
onChange={(e) => { setStatus(e.target.value as RewardStatus); setPage(0); }}
|
||||
className="text-sm border rounded-lg px-3 py-1.5 bg-background"
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
|
|
@ -213,7 +225,7 @@ function RewardsTable() {
|
|||
{(data?.items ?? []).map((r) => (
|
||||
<tr key={r.id} className="border-t hover:bg-muted/30 transition-colors">
|
||||
<td className="px-4 py-3 font-mono text-xs">{r.beneficiaryTenantId.slice(0, 12)}…</td>
|
||||
<td className="px-4 py-3 font-semibold text-green-600">{formatCents(r.amountCents)}</td>
|
||||
<td className="px-4 py-3 font-semibold text-green-600">${(r.amountCents / 100).toFixed(2)}</td>
|
||||
<td className="px-4 py-3">{triggerLabel(r.triggerType, r.recurringMonth)}</td>
|
||||
<td className="px-4 py-3"><StatusBadge status={r.status} /></td>
|
||||
<td className="px-4 py-3 font-mono text-xs">{r.sourceInvoiceId?.slice(0, 8) ?? '—'}…</td>
|
||||
|
|
@ -222,31 +234,193 @@ function RewardsTable() {
|
|||
</tr>
|
||||
))}
|
||||
{data?.items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-muted-foreground">暂无数据</td>
|
||||
</tr>
|
||||
<tr><td colSpan={7} className="px-4 py-8 text-center text-muted-foreground">暂无数据</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && data.total > limit && (
|
||||
<div className="flex justify-end gap-2 mt-3">
|
||||
<button
|
||||
className="px-3 py-1 text-sm border rounded-lg disabled:opacity-40"
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>上一页</button>
|
||||
<span className="px-3 py-1 text-sm">
|
||||
{page + 1} / {Math.ceil(data.total / limit)}
|
||||
</span>
|
||||
<button
|
||||
className="px-3 py-1 text-sm border rounded-lg disabled:opacity-40"
|
||||
disabled={(page + 1) * limit >= data.total}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>下一页</button>
|
||||
<Pagination page={page} total={data?.total ?? 0} limit={limit} onPageChange={setPage} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── User Circles Table ────────────────────────────────────────────────────────
|
||||
|
||||
function UserCirclesTable() {
|
||||
const [status, setStatus] = useState<UserReferralStatus | ''>('');
|
||||
const [page, setPage] = useState(0);
|
||||
const limit = 20;
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['referral-user-circles', status, page],
|
||||
queryFn: () => listAdminUserCircles({ status: (status as UserReferralStatus) || undefined, limit, offset: page * limit }),
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="font-semibold">用户个人圈子关系</h2>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => { setStatus(e.target.value as UserReferralStatus); setPage(0); }}
|
||||
className="text-sm border rounded-lg px-3 py-1.5 bg-background"
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="PENDING">待激活</option>
|
||||
<option value="ACTIVE">已激活</option>
|
||||
<option value="REWARDED">已奖励</option>
|
||||
<option value="EXPIRED">已过期</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground py-8 text-center">加载中…</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
{['邀请人', '被邀请人', '邀请码', '层级', '状态', '加入时间', '激活时间'].map((h) => (
|
||||
<th key={h} className="text-left px-4 py-3 text-muted-foreground font-medium">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(data?.items ?? []).map((r) => (
|
||||
<tr key={r.id} className="border-t hover:bg-muted/30 transition-colors">
|
||||
<td className="px-4 py-3 font-mono text-xs">{r.referrerUserId.slice(0, 12)}…</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">{r.referredUserId.slice(0, 12)}…</td>
|
||||
<td className="px-4 py-3 font-mono font-semibold text-purple-600">{r.referralCode}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${r.level === 1 ? 'bg-purple-100 text-purple-700' : 'bg-indigo-100 text-indigo-700'}`}>
|
||||
L{r.level}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3"><StatusBadge status={r.status} /></td>
|
||||
<td className="px-4 py-3">{formatDate(r.createdAt)}</td>
|
||||
<td className="px-4 py-3">{formatDate(r.activatedAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{data?.items.length === 0 && (
|
||||
<tr><td colSpan={7} className="px-4 py-8 text-center text-muted-foreground">暂无数据</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<Pagination page={page} total={data?.total ?? 0} limit={limit} onPageChange={setPage} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── User Points Table ─────────────────────────────────────────────────────────
|
||||
|
||||
function UserPointsTable() {
|
||||
const [view, setView] = useState<'transactions' | 'balances'>('transactions');
|
||||
const [page, setPage] = useState(0);
|
||||
const limit = 20;
|
||||
|
||||
const txQuery = useQuery({
|
||||
queryKey: ['referral-user-points', page],
|
||||
queryFn: () => listAdminUserPoints({ limit, offset: page * limit }),
|
||||
enabled: view === 'transactions',
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const balQuery = useQuery({
|
||||
queryKey: ['referral-user-balances', page],
|
||||
queryFn: () => listAdminUserBalances({ limit, offset: page * limit }),
|
||||
enabled: view === 'balances',
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const isLoading = view === 'transactions' ? txQuery.isLoading : balQuery.isLoading;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="font-semibold">用户积分管理</h2>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { setView('transactions'); setPage(0); }}
|
||||
className={`px-3 py-1.5 text-sm rounded-lg border ${view === 'transactions' ? 'bg-primary text-primary-foreground border-primary' : 'bg-background'}`}
|
||||
>积分流水</button>
|
||||
<button
|
||||
onClick={() => { setView('balances'); setPage(0); }}
|
||||
className={`px-3 py-1.5 text-sm rounded-lg border ${view === 'balances' ? 'bg-primary text-primary-foreground border-primary' : 'bg-background'}`}
|
||||
>余额排行</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground py-8 text-center">加载中…</div>
|
||||
) : view === 'transactions' ? (
|
||||
<>
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
{['用户ID', '积分变动', '类型', '备注', '时间'].map((h) => (
|
||||
<th key={h} className="text-left px-4 py-3 text-muted-foreground font-medium">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(txQuery.data?.items ?? []).map((r) => (
|
||||
<tr key={r.id} className="border-t hover:bg-muted/30 transition-colors">
|
||||
<td className="px-4 py-3 font-mono text-xs">{r.userId.slice(0, 12)}…</td>
|
||||
<td className={`px-4 py-3 font-semibold ${r.delta > 0 ? 'text-green-600' : 'text-red-500'}`}>
|
||||
{r.delta > 0 ? '+' : ''}{r.delta} pts
|
||||
</td>
|
||||
<td className="px-4 py-3"><PointTypeBadge type={r.type} /></td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{r.note ?? '—'}</td>
|
||||
<td className="px-4 py-3">{formatDate(r.createdAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{txQuery.data?.items.length === 0 && (
|
||||
<tr><td colSpan={5} className="px-4 py-8 text-center text-muted-foreground">暂无数据</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination page={page} total={txQuery.data?.total ?? 0} limit={limit} onPageChange={setPage} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
{['用户ID', '当前余额', '累计获得', '累计消耗', '更新时间'].map((h) => (
|
||||
<th key={h} className="text-left px-4 py-3 text-muted-foreground font-medium">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(balQuery.data?.items ?? []).map((r, i) => (
|
||||
<tr key={r.userId} className="border-t hover:bg-muted/30 transition-colors">
|
||||
<td className="px-4 py-3 font-mono text-xs">
|
||||
{i === 0 && <span className="mr-1">🥇</span>}
|
||||
{i === 1 && <span className="mr-1">🥈</span>}
|
||||
{i === 2 && <span className="mr-1">🥉</span>}
|
||||
{r.userId.slice(0, 12)}…
|
||||
</td>
|
||||
<td className="px-4 py-3 font-bold text-purple-600">{r.balance} pts</td>
|
||||
<td className="px-4 py-3 text-green-600">+{r.totalEarned} pts</td>
|
||||
<td className="px-4 py-3 text-red-500">{r.totalSpent} pts</td>
|
||||
<td className="px-4 py-3">{formatDate(r.updatedAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{balQuery.data?.items.length === 0 && (
|
||||
<tr><td colSpan={5} className="px-4 py-8 text-center text-muted-foreground">暂无数据</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination page={page} total={balQuery.data?.total ?? 0} limit={limit} onPageChange={setPage} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -254,22 +428,24 @@ function RewardsTable() {
|
|||
|
||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Tab = 'overview' | 'relationships' | 'rewards';
|
||||
type Tab = 'overview' | 'relationships' | 'rewards' | 'circles' | 'points';
|
||||
|
||||
export default function ReferralPage() {
|
||||
const [tab, setTab] = useState<Tab>('overview');
|
||||
|
||||
const tabs: { key: Tab; label: string }[] = [
|
||||
{ key: 'overview', label: '概览' },
|
||||
{ key: 'relationships', label: '推荐关系' },
|
||||
{ key: 'rewards', label: '积分奖励' },
|
||||
{ key: 'relationships', label: '企业推荐' },
|
||||
{ key: 'rewards', label: '企业奖励' },
|
||||
{ key: 'circles', label: '用户圈子' },
|
||||
{ key: 'points', label: '用户积分' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">推荐管理</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">管理用户推荐关系与积分奖励</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">管理企业推荐关系、用户个人圈子与积分体系</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
|
|
@ -293,6 +469,8 @@ export default function ReferralPage() {
|
|||
{tab === 'overview' && <StatsOverview />}
|
||||
{tab === 'relationships' && <RelationshipsTable />}
|
||||
{tab === 'rewards' && <RewardsTable />}
|
||||
{tab === 'circles' && <UserCirclesTable />}
|
||||
{tab === 'points' && <UserPointsTable />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,3 +39,52 @@ export interface PaginatedResult<T> {
|
|||
items: T[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ── User-level / personal circle (C2C) ────────────────────────────────────────
|
||||
|
||||
export type UserReferralStatus = 'PENDING' | 'ACTIVE' | 'REWARDED' | 'EXPIRED';
|
||||
|
||||
export interface UserCircleRelationship {
|
||||
id: string;
|
||||
referrerUserId: string;
|
||||
referredUserId: string;
|
||||
referralCode: string;
|
||||
level: number;
|
||||
status: UserReferralStatus;
|
||||
activatedAt: string | null;
|
||||
rewardedAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type PointTransactionType =
|
||||
| 'REFERRAL_FIRST_PAYMENT'
|
||||
| 'REFERRAL_RECURRING'
|
||||
| 'REFERRAL_L2'
|
||||
| 'REFERRAL_WELCOME'
|
||||
| 'REDEMPTION_QUOTA'
|
||||
| 'REDEMPTION_UNLOCK'
|
||||
| 'ADMIN_GRANT'
|
||||
| 'EXPIRY';
|
||||
|
||||
export interface UserPointTransaction {
|
||||
id: string;
|
||||
userId: string;
|
||||
delta: number;
|
||||
type: PointTransactionType;
|
||||
refId: string | null;
|
||||
note: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface UserPointBalance {
|
||||
userId: string;
|
||||
balance: number;
|
||||
totalEarned: number;
|
||||
totalSpent: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ReferralAdminStatsExtended extends ReferralAdminStats {
|
||||
totalUserCircles: number;
|
||||
totalPointTransactions: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,14 @@ import {
|
|||
ReferralRelationship,
|
||||
ReferralReward,
|
||||
ReferralAdminStats,
|
||||
ReferralAdminStatsExtended,
|
||||
PaginatedResult,
|
||||
ReferralStatus,
|
||||
RewardStatus,
|
||||
UserReferralStatus,
|
||||
UserCircleRelationship,
|
||||
UserPointTransaction,
|
||||
UserPointBalance,
|
||||
} from '@/domain/entities/referral';
|
||||
|
||||
function getAuthHeaders(): HeadersInit {
|
||||
|
|
@ -19,7 +24,7 @@ function getAuthHeaders(): HeadersInit {
|
|||
|
||||
const BASE = '/api/proxy';
|
||||
|
||||
export async function getAdminReferralStats(): Promise<ReferralAdminStats> {
|
||||
export async function getAdminReferralStats(): Promise<ReferralAdminStatsExtended> {
|
||||
const res = await fetch(`${BASE}/api/v1/referral/admin/stats`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
|
|
@ -58,3 +63,47 @@ export async function listAdminRewards(params: {
|
|||
if (!res.ok) throw new Error(`Failed to fetch rewards: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function listAdminUserCircles(params: {
|
||||
status?: UserReferralStatus;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<PaginatedResult<UserCircleRelationship>> {
|
||||
const q = new URLSearchParams();
|
||||
if (params.status) q.set('status', params.status);
|
||||
if (params.limit != null) q.set('limit', String(params.limit));
|
||||
if (params.offset != null) q.set('offset', String(params.offset));
|
||||
const res = await fetch(`${BASE}/api/v1/referral/admin/user-circles?${q}`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to fetch user circles: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function listAdminUserPoints(params: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<PaginatedResult<UserPointTransaction>> {
|
||||
const q = new URLSearchParams();
|
||||
if (params.limit != null) q.set('limit', String(params.limit));
|
||||
if (params.offset != null) q.set('offset', String(params.offset));
|
||||
const res = await fetch(`${BASE}/api/v1/referral/admin/user-points?${q}`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to fetch user points: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function listAdminUserBalances(params: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<PaginatedResult<UserPointBalance>> {
|
||||
const q = new URLSearchParams();
|
||||
if (params.limit != null) q.set('limit', String(params.limit));
|
||||
if (params.offset != null) q.set('offset', String(params.offset));
|
||||
const res = await fetch(`${BASE}/api/v1/referral/admin/user-balances?${q}`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to fetch user balances: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,49 @@ class ReferralRepository {
|
|||
.toList();
|
||||
return (items: items, total: data['total'] as int? ?? 0);
|
||||
}
|
||||
|
||||
// ── User-level / personal circle ─────────────────────────────────────────
|
||||
|
||||
Future<UserReferralInfo> getMyUserReferralInfo() async {
|
||||
final res = await _dio.get('/api/v1/referral/me/user');
|
||||
return UserReferralInfo.fromJson(res.data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<({List<CircleMember> items, int total})> getMyCircle({
|
||||
int limit = 20,
|
||||
int offset = 0,
|
||||
}) async {
|
||||
final res = await _dio.get(
|
||||
'/api/v1/referral/me/circle',
|
||||
queryParameters: {'limit': limit, 'offset': offset},
|
||||
);
|
||||
final data = res.data as Map<String, dynamic>;
|
||||
final items = (data['items'] as List)
|
||||
.map((e) => CircleMember.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
return (items: items, total: data['total'] as int? ?? 0);
|
||||
}
|
||||
|
||||
Future<({int balance, int totalEarned, int totalSpent, List<PointTransaction> transactions, int total})>
|
||||
getMyPoints({int limit = 20, int offset = 0}) async {
|
||||
final res = await _dio.get(
|
||||
'/api/v1/referral/me/points',
|
||||
queryParameters: {'limit': limit, 'offset': offset},
|
||||
);
|
||||
final data = res.data as Map<String, dynamic>;
|
||||
final bal = data['balance'] as Map<String, dynamic>? ?? {};
|
||||
final txData = data['transactions'] as Map<String, dynamic>? ?? {};
|
||||
final txList = (txData['items'] as List? ?? [])
|
||||
.map((e) => PointTransaction.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
return (
|
||||
balance: bal['balance'] as int? ?? 0,
|
||||
totalEarned: bal['totalEarned'] as int? ?? 0,
|
||||
totalSpent: (bal['totalSpent'] as int? ?? 0).abs(),
|
||||
transactions: txList,
|
||||
total: txData['total'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Riverpod provider ────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -68,6 +68,109 @@ class ReferralItem {
|
|||
bool get isActive => status == 'ACTIVE' || status == 'REWARDED';
|
||||
}
|
||||
|
||||
// ── User-level personal circle models ─────────────────────────────────────
|
||||
|
||||
class UserReferralInfo {
|
||||
final String code;
|
||||
final String shareUrl;
|
||||
final int circleSize;
|
||||
final int activeCount;
|
||||
final int pointsBalance;
|
||||
final int totalEarned;
|
||||
final int totalSpent;
|
||||
|
||||
const UserReferralInfo({
|
||||
required this.code,
|
||||
required this.shareUrl,
|
||||
required this.circleSize,
|
||||
required this.activeCount,
|
||||
required this.pointsBalance,
|
||||
required this.totalEarned,
|
||||
required this.totalSpent,
|
||||
});
|
||||
|
||||
factory UserReferralInfo.fromJson(Map<String, dynamic> json) => UserReferralInfo(
|
||||
code: json['code'] as String? ?? '',
|
||||
shareUrl: json['shareUrl'] as String? ?? '',
|
||||
circleSize: json['circleSize'] as int? ?? 0,
|
||||
activeCount: json['activeCount'] as int? ?? 0,
|
||||
pointsBalance: json['pointsBalance'] as int? ?? 0,
|
||||
totalEarned: json['totalEarned'] as int? ?? 0,
|
||||
totalSpent: json['totalSpent'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
class CircleMember {
|
||||
final String relationshipId;
|
||||
final String referredUserId;
|
||||
final int level;
|
||||
final String status;
|
||||
final DateTime joinedAt;
|
||||
final DateTime? activatedAt;
|
||||
|
||||
const CircleMember({
|
||||
required this.relationshipId,
|
||||
required this.referredUserId,
|
||||
required this.level,
|
||||
required this.status,
|
||||
required this.joinedAt,
|
||||
this.activatedAt,
|
||||
});
|
||||
|
||||
factory CircleMember.fromJson(Map<String, dynamic> json) => CircleMember(
|
||||
relationshipId: json['relationshipId'] as String,
|
||||
referredUserId: json['referredUserId'] as String,
|
||||
level: json['level'] as int? ?? 1,
|
||||
status: json['status'] as String? ?? 'PENDING',
|
||||
joinedAt: DateTime.parse(json['joinedAt'] as String),
|
||||
activatedAt: json['activatedAt'] != null
|
||||
? DateTime.parse(json['activatedAt'] as String)
|
||||
: null,
|
||||
);
|
||||
|
||||
bool get isActive => status == 'ACTIVE' || status == 'REWARDED';
|
||||
}
|
||||
|
||||
class PointTransaction {
|
||||
final String id;
|
||||
final int delta;
|
||||
final String type;
|
||||
final String? note;
|
||||
final DateTime createdAt;
|
||||
|
||||
const PointTransaction({
|
||||
required this.id,
|
||||
required this.delta,
|
||||
required this.type,
|
||||
this.note,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
factory PointTransaction.fromJson(Map<String, dynamic> json) => PointTransaction(
|
||||
id: json['id'] as String,
|
||||
delta: json['delta'] as int? ?? 0,
|
||||
type: json['type'] as String? ?? '',
|
||||
note: json['note'] as String?,
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
);
|
||||
|
||||
bool get isEarned => delta > 0;
|
||||
|
||||
String get typeLabel {
|
||||
switch (type) {
|
||||
case 'REFERRAL_FIRST_PAYMENT': return '推荐首次订阅奖励';
|
||||
case 'REFERRAL_RECURRING': return '推荐续订奖励';
|
||||
case 'REFERRAL_L2': return '二级推荐奖励';
|
||||
case 'REFERRAL_WELCOME': return '加入欢迎积分';
|
||||
case 'REDEMPTION_QUOTA': return '兑换使用配额';
|
||||
case 'REDEMPTION_UNLOCK': return '兑换智能体解锁';
|
||||
case 'ADMIN_GRANT': return '平台赠送';
|
||||
case 'EXPIRY': return '积分过期';
|
||||
default: return type;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RewardItem {
|
||||
final String id;
|
||||
final int amountCents;
|
||||
|
|
|
|||
|
|
@ -2,25 +2,50 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||
import '../../data/referral_repository.dart';
|
||||
import '../../domain/models/referral_info.dart';
|
||||
|
||||
/// My referral info + code
|
||||
/// My tenant referral info + code
|
||||
final referralInfoProvider = FutureProvider<ReferralInfo>((ref) async {
|
||||
return ref.watch(referralRepositoryProvider).getMyReferralInfo();
|
||||
});
|
||||
|
||||
/// My direct referrals (first page)
|
||||
/// My direct tenant referrals (first page)
|
||||
final referralListProvider =
|
||||
FutureProvider<({List<ReferralItem> items, int total})>((ref) async {
|
||||
return ref.watch(referralRepositoryProvider).getMyReferrals();
|
||||
});
|
||||
|
||||
/// Pending rewards
|
||||
/// Pending tenant rewards
|
||||
final pendingRewardsProvider =
|
||||
FutureProvider<({List<RewardItem> items, int total})>((ref) async {
|
||||
return ref.watch(referralRepositoryProvider).getMyRewards(status: 'PENDING');
|
||||
});
|
||||
|
||||
/// All rewards (for history tab)
|
||||
/// All tenant rewards (for history tab)
|
||||
final allRewardsProvider =
|
||||
FutureProvider<({List<RewardItem> items, int total})>((ref) async {
|
||||
return ref.watch(referralRepositoryProvider).getMyRewards();
|
||||
});
|
||||
|
||||
// ── User-level / personal circle ─────────────────────────────────────────────
|
||||
|
||||
/// Personal user referral info (code, shareUrl, circleSize, pointsBalance …)
|
||||
final userReferralInfoProvider = FutureProvider<UserReferralInfo>((ref) async {
|
||||
return ref.watch(referralRepositoryProvider).getMyUserReferralInfo();
|
||||
});
|
||||
|
||||
/// Personal circle members (level-1 invitees)
|
||||
final myCircleProvider =
|
||||
FutureProvider<({List<CircleMember> items, int total})>((ref) async {
|
||||
return ref.watch(referralRepositoryProvider).getMyCircle();
|
||||
});
|
||||
|
||||
/// Points balance + recent transactions
|
||||
final myPointsProvider = FutureProvider<
|
||||
({
|
||||
int balance,
|
||||
int totalEarned,
|
||||
int totalSpent,
|
||||
List<PointTransaction> transactions,
|
||||
int total
|
||||
})>((ref) async {
|
||||
return ref.watch(referralRepositoryProvider).getMyPoints();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,11 +12,12 @@ class ReferralScreen extends ConsumerWidget {
|
|||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final infoAsync = ref.watch(referralInfoProvider);
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final cardColor = isDark ? AppColors.surface : Colors.white;
|
||||
|
||||
return Scaffold(
|
||||
return DefaultTabController(
|
||||
length: 2,
|
||||
child: Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppColors.background,
|
||||
|
|
@ -28,96 +29,612 @@ class ReferralScreen extends ConsumerWidget {
|
|||
ref.invalidate(referralInfoProvider);
|
||||
ref.invalidate(referralListProvider);
|
||||
ref.invalidate(pendingRewardsProvider);
|
||||
ref.invalidate(userReferralInfoProvider);
|
||||
ref.invalidate(myCircleProvider);
|
||||
ref.invalidate(myPointsProvider);
|
||||
},
|
||||
),
|
||||
],
|
||||
bottom: TabBar(
|
||||
indicatorColor: AppColors.primary,
|
||||
labelColor: AppColors.primary,
|
||||
unselectedLabelColor: Colors.grey,
|
||||
tabs: const [
|
||||
Tab(text: '企业推荐'),
|
||||
Tab(text: '我的圈子'),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: TabBarView(
|
||||
children: [
|
||||
_TenantReferralTab(cardColor: cardColor),
|
||||
_PersonalCircleTab(cardColor: cardColor),
|
||||
],
|
||||
),
|
||||
body: infoAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('加载失败: $e')),
|
||||
data: (info) => _ReferralBody(info: info, cardColor: cardColor),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ReferralBody extends ConsumerWidget {
|
||||
final ReferralInfo info;
|
||||
final Color cardColor;
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// Tab 1 — Tenant / B2B referral (existing)
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const _ReferralBody({required this.info, required this.cardColor});
|
||||
class _TenantReferralTab extends ConsumerWidget {
|
||||
final Color cardColor;
|
||||
const _TenantReferralTab({required this.cardColor});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return ListView(
|
||||
final infoAsync = ref.watch(referralInfoProvider);
|
||||
return infoAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('加载失败: $e')),
|
||||
data: (info) => ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
children: [
|
||||
// ── Referral Code Card ─────────────────────────────────────
|
||||
_ReferralCodeCard(info: info, cardColor: cardColor),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ── Stats Row ─────────────────────────────────────────────
|
||||
_StatsRow(info: info, cardColor: cardColor),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── Reward Rules ──────────────────────────────────────────
|
||||
_RewardRulesCard(cardColor: cardColor),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── Referral List ─────────────────────────────────────────
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
l10n.referralRecordsSection,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => _showReferralList(context),
|
||||
child: Text(l10n.viewAllReferralsLink),
|
||||
),
|
||||
],
|
||||
_SectionHeader(
|
||||
title: '推荐记录',
|
||||
onTap: () => Navigator.push(context,
|
||||
MaterialPageRoute(builder: (_) => const _ReferralListPage())),
|
||||
),
|
||||
_ReferralPreviewList(cardColor: cardColor),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── Reward List ───────────────────────────────────────────
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
l10n.pendingRewardsSection,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => _showRewardList(context),
|
||||
child: Text(l10n.viewAllRewardsLink),
|
||||
),
|
||||
],
|
||||
_SectionHeader(
|
||||
title: '待结算奖励',
|
||||
onTap: () => Navigator.push(context,
|
||||
MaterialPageRoute(builder: (_) => const _RewardListPage())),
|
||||
),
|
||||
_RewardPreviewList(cardColor: cardColor),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _showReferralList(BuildContext context) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const _ReferralListPage()),
|
||||
);
|
||||
}
|
||||
|
||||
void _showRewardList(BuildContext context) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const _RewardListPage()),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Referral Code Card ────────────────────────────────────────────────────────
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// Tab 2 — Personal circle / C2C
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class _PersonalCircleTab extends ConsumerWidget {
|
||||
final Color cardColor;
|
||||
const _PersonalCircleTab({required this.cardColor});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final infoAsync = ref.watch(userReferralInfoProvider);
|
||||
return infoAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('加载失败: $e')),
|
||||
data: (info) => ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
children: [
|
||||
_UserCodeCard(info: info, cardColor: cardColor),
|
||||
const SizedBox(height: 16),
|
||||
_PointsBalanceCard(info: info, cardColor: cardColor),
|
||||
const SizedBox(height: 20),
|
||||
_CircleRulesCard(cardColor: cardColor),
|
||||
const SizedBox(height: 20),
|
||||
_SectionHeader(
|
||||
title: '我的圈子成员',
|
||||
onTap: () => Navigator.push(context,
|
||||
MaterialPageRoute(builder: (_) => const _CircleListPage())),
|
||||
),
|
||||
_CirclePreviewList(cardColor: cardColor),
|
||||
const SizedBox(height: 20),
|
||||
_SectionHeader(
|
||||
title: '积分记录',
|
||||
onTap: () => Navigator.push(context,
|
||||
MaterialPageRoute(builder: (_) => const _PointsHistoryPage())),
|
||||
),
|
||||
_PointsPreviewList(cardColor: cardColor),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── User Referral Code Card ───────────────────────────────────────────────────
|
||||
|
||||
class _UserCodeCard extends StatelessWidget {
|
||||
final UserReferralInfo info;
|
||||
final Color cardColor;
|
||||
|
||||
const _UserCodeCard({required this.info, required this.cardColor});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
color: cardColor,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'我的个人邀请码',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
info.code,
|
||||
style: const TextStyle(
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 2,
|
||||
color: Color(0xFF7C3AED),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy, color: Color(0xFF7C3AED)),
|
||||
tooltip: '复制邀请码',
|
||||
onPressed: () => _copy(context, info.code),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
icon: const Icon(Icons.link, size: 18),
|
||||
label: const Text('复制邀请链接'),
|
||||
onPressed: () => _copy(context, info.shareUrl),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: const Color(0xFF7C3AED),
|
||||
side: const BorderSide(color: Color(0xFF7C3AED)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10)),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
icon: const Icon(Icons.share, size: 18),
|
||||
label: const Text('分享'),
|
||||
onPressed: () => _share(context, info),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF7C3AED),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _copy(BuildContext context, String text) {
|
||||
Clipboard.setData(ClipboardData(text: text));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('已复制到剪贴板'),
|
||||
duration: Duration(seconds: 2)),
|
||||
);
|
||||
}
|
||||
|
||||
void _share(BuildContext context, UserReferralInfo info) {
|
||||
final text =
|
||||
'邀请你加入 IT0 智能体管理平台,用AI管理你的数字工作!加入即获 200 积分奖励!\n邀请码:${info.code}\n链接:${info.shareUrl}';
|
||||
_copy(context, text);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Points Balance Card ───────────────────────────────────────────────────────
|
||||
|
||||
class _PointsBalanceCard extends StatelessWidget {
|
||||
final UserReferralInfo info;
|
||||
final Color cardColor;
|
||||
|
||||
const _PointsBalanceCard({required this.info, required this.cardColor});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
color: cardColor,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Row(
|
||||
children: [
|
||||
Icon(Icons.stars_rounded, color: Color(0xFFF59E0B), size: 20),
|
||||
SizedBox(width: 6),
|
||||
Text('积分余额',
|
||||
style:
|
||||
TextStyle(fontWeight: FontWeight.w600, fontSize: 15)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
_PointsStatItem(
|
||||
label: '当前余额',
|
||||
value: '${info.pointsBalance}',
|
||||
unit: 'pts',
|
||||
color: const Color(0xFF7C3AED),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_PointsStatItem(
|
||||
label: '圈子成员',
|
||||
value: '${info.circleSize}',
|
||||
unit: '人',
|
||||
color: const Color(0xFF10B981),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_PointsStatItem(
|
||||
label: '累计获得',
|
||||
value: '${info.totalEarned}',
|
||||
unit: 'pts',
|
||||
color: const Color(0xFF6366F1),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PointsStatItem extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
final String unit;
|
||||
final Color color;
|
||||
|
||||
const _PointsStatItem({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.unit,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontSize: 12, color: Colors.grey)),
|
||||
const SizedBox(height: 4),
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: value,
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: ' $unit',
|
||||
style: TextStyle(fontSize: 12, color: color.withAlpha(180)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Circle Rules Card ─────────────────────────────────────────────────────────
|
||||
|
||||
class _CircleRulesCard extends StatelessWidget {
|
||||
final Color cardColor;
|
||||
const _CircleRulesCard({required this.cardColor});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
color: cardColor,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Row(
|
||||
children: [
|
||||
Icon(Icons.people_alt_rounded,
|
||||
color: Color(0xFF7C3AED), size: 20),
|
||||
SizedBox(width: 6),
|
||||
Text('圈子奖励规则',
|
||||
style:
|
||||
TextStyle(fontWeight: FontWeight.w600, fontSize: 15)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_RuleItem(
|
||||
icon: Icons.card_giftcard_rounded,
|
||||
color: const Color(0xFF7C3AED),
|
||||
text: '新成员加入你的圈子,你和对方各获 200 积分欢迎礼',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_RuleItem(
|
||||
icon: Icons.star_rounded,
|
||||
color: const Color(0xFF6366F1),
|
||||
text: '圈子成员订阅 Pro 时,你获 1500 pts,对方获 500 pts',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_RuleItem(
|
||||
icon: Icons.star_rounded,
|
||||
color: const Color(0xFF7C3AED),
|
||||
text: '圈子成员订阅 Enterprise 时,你获 5000 pts,对方获 2000 pts',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_RuleItem(
|
||||
icon: Icons.repeat_rounded,
|
||||
color: const Color(0xFF10B981),
|
||||
text: '每月续订时你持续获得付款额 10% 的积分,最长 12 个月',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_RuleItem(
|
||||
icon: Icons.account_tree_rounded,
|
||||
color: const Color(0xFFF59E0B),
|
||||
text: '二级圈子续订,你获 5% 积分,最长 6 个月',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_RuleItem(
|
||||
icon: Icons.redeem_rounded,
|
||||
color: const Color(0xFF10B981),
|
||||
text: '积分可兑换额外使用配额或解锁智能体',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Circle Member Preview ─────────────────────────────────────────────────────
|
||||
|
||||
class _CirclePreviewList extends ConsumerWidget {
|
||||
final Color cardColor;
|
||||
const _CirclePreviewList({required this.cardColor});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final async = ref.watch(myCircleProvider);
|
||||
return async.when(
|
||||
loading: () => const SizedBox(
|
||||
height: 60, child: Center(child: CircularProgressIndicator())),
|
||||
error: (_, __) => const SizedBox.shrink(),
|
||||
data: (result) {
|
||||
if (result.items.isEmpty) {
|
||||
return _EmptyCard(cardColor: cardColor, message: '暂无圈子成员,快去邀请好友吧!');
|
||||
}
|
||||
final preview = result.items.take(3).toList();
|
||||
return Card(
|
||||
color: cardColor,
|
||||
elevation: 0,
|
||||
shape:
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: preview.map((m) => _CircleMemberTile(member: m)).toList(),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CircleMemberTile extends StatelessWidget {
|
||||
final CircleMember member;
|
||||
const _CircleMemberTile({required this.member});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final statusColor = member.isActive
|
||||
? const Color(0xFF10B981)
|
||||
: member.status == 'EXPIRED'
|
||||
? Colors.grey
|
||||
: const Color(0xFFF59E0B);
|
||||
final statusLabel = switch (member.status) {
|
||||
'PENDING' => '待激活',
|
||||
'ACTIVE' => '已激活',
|
||||
'REWARDED' => '已奖励',
|
||||
'EXPIRED' => '已过期',
|
||||
_ => member.status,
|
||||
};
|
||||
final levelLabel = member.level == 1 ? 'L1' : 'L2';
|
||||
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: const Color(0xFF7C3AED).withAlpha(20),
|
||||
child: Text(
|
||||
levelLabel,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF7C3AED),
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
member.referredUserId.length > 8
|
||||
? '${member.referredUserId.substring(0, 8)}...'
|
||||
: member.referredUserId,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: Text(
|
||||
'加入于 ${_formatDate(member.joinedAt)}',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
trailing: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor.withAlpha(20),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
statusLabel,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: statusColor,
|
||||
fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(DateTime dt) =>
|
||||
'${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
// ── Points Preview List ───────────────────────────────────────────────────────
|
||||
|
||||
class _PointsPreviewList extends ConsumerWidget {
|
||||
final Color cardColor;
|
||||
const _PointsPreviewList({required this.cardColor});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final async = ref.watch(myPointsProvider);
|
||||
return async.when(
|
||||
loading: () => const SizedBox(
|
||||
height: 60, child: Center(child: CircularProgressIndicator())),
|
||||
error: (_, __) => const SizedBox.shrink(),
|
||||
data: (result) {
|
||||
if (result.transactions.isEmpty) {
|
||||
return _EmptyCard(cardColor: cardColor, message: '暂无积分记录');
|
||||
}
|
||||
final preview = result.transactions.take(3).toList();
|
||||
return Card(
|
||||
color: cardColor,
|
||||
elevation: 0,
|
||||
shape:
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children:
|
||||
preview.map((t) => _PointsTile(tx: t)).toList(),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PointsTile extends StatelessWidget {
|
||||
final PointTransaction tx;
|
||||
const _PointsTile({required this.tx});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color =
|
||||
tx.isEarned ? const Color(0xFF10B981) : const Color(0xFFEF4444);
|
||||
final sign = tx.isEarned ? '+' : '';
|
||||
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: color.withAlpha(20),
|
||||
child: Icon(
|
||||
tx.isEarned ? Icons.add_circle_outline : Icons.remove_circle_outline,
|
||||
color: color,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
title: Text(tx.typeLabel,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
|
||||
subtitle: Text(
|
||||
_formatDate(tx.createdAt),
|
||||
style: const TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
trailing: Text(
|
||||
'$sign${tx.delta} pts',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(DateTime dt) =>
|
||||
'${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
// ── Full list pages (circle & points) ────────────────────────────────────────
|
||||
|
||||
class _CircleListPage extends ConsumerWidget {
|
||||
const _CircleListPage();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final async = ref.watch(myCircleProvider);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('我的圈子')),
|
||||
body: async.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('加载失败: $e')),
|
||||
data: (result) => result.items.isEmpty
|
||||
? const Center(child: Text('暂无圈子成员'))
|
||||
: ListView.separated(
|
||||
itemCount: result.items.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
itemBuilder: (_, i) =>
|
||||
_CircleMemberTile(member: result.items[i]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PointsHistoryPage extends ConsumerWidget {
|
||||
const _PointsHistoryPage();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final async = ref.watch(myPointsProvider);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('积分记录')),
|
||||
body: async.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('加载失败: $e')),
|
||||
data: (result) => result.transactions.isEmpty
|
||||
? const Center(child: Text('暂无积分记录'))
|
||||
: ListView.separated(
|
||||
itemCount: result.transactions.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
itemBuilder: (_, i) =>
|
||||
_PointsTile(tx: result.transactions[i]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// Shared / Tenant tab widgets (unchanged logic, extracted)
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class _ReferralCodeCard extends StatelessWidget {
|
||||
final ReferralInfo info;
|
||||
|
|
@ -203,20 +720,19 @@ class _ReferralCodeCard extends StatelessWidget {
|
|||
final l10n = AppLocalizations.of(context);
|
||||
Clipboard.setData(ClipboardData(text: text));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l10n.copiedToClipboard), duration: const Duration(seconds: 2)),
|
||||
SnackBar(
|
||||
content: Text(l10n.copiedToClipboard),
|
||||
duration: const Duration(seconds: 2)),
|
||||
);
|
||||
}
|
||||
|
||||
void _share(BuildContext context, ReferralInfo info) {
|
||||
// For a full implementation, use share_plus package
|
||||
// For now, copy the share text
|
||||
final text = '邀请你使用 IT0 智能运维平台,注册即可获得积分奖励!\n推荐码:${info.referralCode}\n链接:${info.shareUrl}';
|
||||
final text =
|
||||
'邀请你使用 IT0 智能体管理平台,注册即可获得积分奖励!\n推荐码:${info.referralCode}\n链接:${info.shareUrl}';
|
||||
_copy(context, text);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stats Row ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class _StatsRow extends StatelessWidget {
|
||||
final ReferralInfo info;
|
||||
final Color cardColor;
|
||||
|
|
@ -300,8 +816,8 @@ class _StatCard extends StatelessWidget {
|
|||
if (unit.isNotEmpty)
|
||||
TextSpan(
|
||||
text: unit,
|
||||
style: TextStyle(
|
||||
fontSize: 13, color: color.withAlpha(180)),
|
||||
style:
|
||||
TextStyle(fontSize: 13, color: color.withAlpha(180)),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -314,11 +830,8 @@ class _StatCard extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Reward Rules Card ─────────────────────────────────────────────────────────
|
||||
|
||||
class _RewardRulesCard extends StatelessWidget {
|
||||
final Color cardColor;
|
||||
|
||||
const _RewardRulesCard({required this.cardColor});
|
||||
|
||||
@override
|
||||
|
|
@ -335,11 +848,13 @@ class _RewardRulesCard extends StatelessWidget {
|
|||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.card_giftcard, color: Color(0xFFF59E0B), size: 20),
|
||||
const Icon(Icons.card_giftcard,
|
||||
color: Color(0xFFF59E0B), size: 20),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
l10n.rewardRulesTitle,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 15),
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600, fontSize: 15),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -396,7 +911,49 @@ class _RuleItem extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Preview Lists ─────────────────────────────────────────────────────────────
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
final String title;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _SectionHeader({required this.title, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600, fontSize: 16)),
|
||||
TextButton(onPressed: onTap, child: const Text('查看全部')),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyCard extends StatelessWidget {
|
||||
final Color cardColor;
|
||||
final String message;
|
||||
|
||||
const _EmptyCard({required this.cardColor, required this.message});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
color: cardColor,
|
||||
elevation: 0,
|
||||
shape:
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Center(
|
||||
child: Text(message,
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 13)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ReferralPreviewList extends ConsumerWidget {
|
||||
final Color cardColor;
|
||||
|
|
@ -408,27 +965,12 @@ class _ReferralPreviewList extends ConsumerWidget {
|
|||
final async = ref.watch(referralListProvider);
|
||||
return async.when(
|
||||
loading: () => const SizedBox(
|
||||
height: 60,
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
height: 60, child: Center(child: CircularProgressIndicator())),
|
||||
error: (_, __) => const SizedBox.shrink(),
|
||||
data: (result) {
|
||||
if (result.items.isEmpty) {
|
||||
return Card(
|
||||
color: cardColor,
|
||||
elevation: 0,
|
||||
shape:
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Center(
|
||||
child: Text(
|
||||
l10n.noReferralsMessage,
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 13),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return _EmptyCard(
|
||||
cardColor: cardColor, message: l10n.noReferralsMessage);
|
||||
}
|
||||
final preview = result.items.take(3).toList();
|
||||
return Card(
|
||||
|
|
@ -494,7 +1036,9 @@ class _ReferralTile extends StatelessWidget {
|
|||
child: Text(
|
||||
statusLabel,
|
||||
style: TextStyle(
|
||||
fontSize: 12, color: statusColor, fontWeight: FontWeight.w500),
|
||||
fontSize: 12,
|
||||
color: statusColor,
|
||||
fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -518,21 +1062,8 @@ class _RewardPreviewList extends ConsumerWidget {
|
|||
error: (_, __) => const SizedBox.shrink(),
|
||||
data: (result) {
|
||||
if (result.items.isEmpty) {
|
||||
return Card(
|
||||
color: cardColor,
|
||||
elevation: 0,
|
||||
shape:
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Center(
|
||||
child: Text(
|
||||
l10n.noPendingRewardsMessage,
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 13),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return _EmptyCard(
|
||||
cardColor: cardColor, message: l10n.noPendingRewardsMessage);
|
||||
}
|
||||
final preview = result.items.take(3).toList();
|
||||
return Card(
|
||||
|
|
@ -542,9 +1073,7 @@ class _RewardPreviewList extends ConsumerWidget {
|
|||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: preview
|
||||
.map((item) => _RewardTile(item: item))
|
||||
.toList(),
|
||||
children: preview.map((item) => _RewardTile(item: item)).toList(),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
@ -592,8 +1121,6 @@ class _RewardTile extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Full list pages ───────────────────────────────────────────────────────────
|
||||
|
||||
class _ReferralListPage extends ConsumerWidget {
|
||||
const _ReferralListPage();
|
||||
|
||||
|
|
|
|||
|
|
@ -188,6 +188,8 @@ export class AuthService {
|
|||
|
||||
// Async: register with referral-service (fire-and-forget)
|
||||
this.registerReferral(user.tenantId, user.id).catch(() => {});
|
||||
// Also create user-level personal referral code (C2C circle)
|
||||
this.registerUserReferral(user.id).catch(() => {});
|
||||
|
||||
const tokens = this.generateTokens(user);
|
||||
return {
|
||||
|
|
@ -298,8 +300,9 @@ export class AuthService {
|
|||
user.isActive = true;
|
||||
|
||||
// Async: register tenant with referral-service (fire-and-forget)
|
||||
// referralCode is not supported at tenant-creation time yet (future: pass via register body)
|
||||
this.registerReferral(slug, userId).catch(() => {});
|
||||
// Also create user-level personal referral code (C2C circle)
|
||||
this.registerUserReferral(userId).catch(() => {});
|
||||
|
||||
const tokens = this.generateTokens(user);
|
||||
return {
|
||||
|
|
@ -315,6 +318,37 @@ export class AuthService {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget call to referral-service to create the user's personal referral code
|
||||
* and optionally link a personal-circle referrer (C2C layer).
|
||||
* userReferralCode must start with "USR-" to distinguish from tenant codes.
|
||||
*/
|
||||
private async registerUserReferral(userId: string, userReferralCode?: string): Promise<void> {
|
||||
const referralServiceUrl = this.configService.get<string>(
|
||||
'REFERRAL_SERVICE_URL',
|
||||
'http://referral-service:3012',
|
||||
);
|
||||
const internalKey = this.configService.get<string>('INTERNAL_API_KEY', 'changeme-internal-key');
|
||||
const url = `${referralServiceUrl}/api/v1/referral/internal/user-register`;
|
||||
const http = await import('http');
|
||||
const https = await import('https');
|
||||
const body = JSON.stringify({ userId, userReferralCode });
|
||||
return new Promise((resolve) => {
|
||||
const lib = url.startsWith('https') ? https : http;
|
||||
const req = lib.request(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
'X-Internal-Api-Key': internalKey,
|
||||
},
|
||||
}, (res) => { res.resume(); resolve(); });
|
||||
req.on('error', () => resolve());
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget call to referral-service to create the tenant's referral code.
|
||||
* Uses HTTP to the internal referral-service URL (not through Kong).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,188 @@
|
|||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import Redis from 'ioredis';
|
||||
import { UserReferralRelationshipRepository } from '../../infrastructure/repositories/user-referral-relationship.repository';
|
||||
import { UserPointRepository } from '../../infrastructure/repositories/user-point.repository';
|
||||
import { ProcessedEventRepository } from '../../infrastructure/repositories/processed-event.repository';
|
||||
|
||||
/**
|
||||
* Listens to events:payment.received (consumer group: referral-user-service).
|
||||
* When a user pays, finds their user-level referral relationship and awards points
|
||||
* to their referrer (and level-2 referrer).
|
||||
*
|
||||
* Point award rules:
|
||||
* First payment (Pro): referrer +1500 pts, referred +500 pts
|
||||
* First payment (Enterprise): referrer +5000 pts, referred +2000 pts
|
||||
* Recurring (≤12 months): referrer + 10% of payment_cents/10 pts
|
||||
* Level-2 (≤6 months): l2-referrer + 5% of payment_cents/10 pts
|
||||
*
|
||||
* (Convention: 1 USD = 100 pts, so payment_cents / 100 * pts_per_dollar)
|
||||
*/
|
||||
|
||||
interface PaymentPayload {
|
||||
tenantId: string;
|
||||
userId?: string; // present in new events; may be absent in legacy
|
||||
invoiceId: string;
|
||||
paymentId: string;
|
||||
amount: number; // USD cents
|
||||
currency: string;
|
||||
planName?: string;
|
||||
}
|
||||
|
||||
const FIRST_PAYMENT_POINTS: Record<string, { referrer: number; referred: number }> = {
|
||||
pro: { referrer: 1500, referred: 500 },
|
||||
enterprise: { referrer: 5000, referred: 2000 },
|
||||
free: { referrer: 0, referred: 0 },
|
||||
};
|
||||
const RECURRING_PCT = 0.10; // 10% of amount_cents → pts (1 USD = 100 pts)
|
||||
const RECURRING_MAX = 12;
|
||||
const L2_PCT = 0.05; // 5%
|
||||
const L2_MAX = 6;
|
||||
|
||||
const STREAM = 'events:payment.received';
|
||||
const GROUP = 'referral-user-service'; // separate group from tenant referral consumer
|
||||
const CONSUMER = `referral-user-${process.pid}`;
|
||||
|
||||
@Injectable()
|
||||
export class ConsumeUserPaymentUseCase implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(ConsumeUserPaymentUseCase.name);
|
||||
private client: Redis;
|
||||
private running = false;
|
||||
|
||||
constructor(
|
||||
private readonly relRepo: UserReferralRelationshipRepository,
|
||||
private readonly pointRepo: UserPointRepository,
|
||||
private readonly processedRepo: ProcessedEventRepository,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
const url = this.config.get<string>('REDIS_URL', 'redis://localhost:6379');
|
||||
this.client = new Redis(url);
|
||||
try {
|
||||
await this.client.xgroup('CREATE', STREAM, GROUP, '0', 'MKSTREAM');
|
||||
} catch { /* group exists */ }
|
||||
this.running = true;
|
||||
this.loop().catch((e) => this.logger.error(`User payment loop error: ${e.message}`));
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
this.running = false;
|
||||
this.client.disconnect();
|
||||
}
|
||||
|
||||
private async loop() {
|
||||
while (this.running) {
|
||||
try {
|
||||
const res = await this.client.xreadgroup(
|
||||
'GROUP', GROUP, CONSUMER,
|
||||
'COUNT', '10', 'BLOCK', '5000',
|
||||
'STREAMS', STREAM, '>',
|
||||
) as Array<[string, Array<[string, string[]]>]> | null;
|
||||
|
||||
if (!res) continue;
|
||||
|
||||
for (const [, messages] of res) {
|
||||
for (const [id, fields] of messages) {
|
||||
const record: Record<string, string> = {};
|
||||
for (let i = 0; i < fields.length; i += 2) record[fields[i]] = fields[i + 1];
|
||||
await this.process(id, record);
|
||||
await this.client.xack(STREAM, GROUP, id);
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (this.running) {
|
||||
this.logger.error(`Redis error: ${e.message}`);
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async process(msgId: string, record: Record<string, string>) {
|
||||
try {
|
||||
const payload: PaymentPayload = JSON.parse(record['data'] ?? '{}');
|
||||
const userId = payload.userId;
|
||||
if (!userId) return; // legacy event without userId — skip user-level processing
|
||||
|
||||
const eventId = `user-payment:${payload.paymentId ?? msgId}`;
|
||||
if (await this.processedRepo.hasProcessed(eventId)) return;
|
||||
|
||||
// Find direct (level-1) referral relationship for this user
|
||||
const level1 = await this.relRepo.findByReferredUserId(userId);
|
||||
if (!level1) {
|
||||
await this.processedRepo.markProcessed(eventId, 'payment.received');
|
||||
return;
|
||||
}
|
||||
|
||||
const isFirst = level1.status === 'PENDING';
|
||||
const plan = (payload.planName ?? 'pro').toLowerCase();
|
||||
|
||||
if (isFirst) {
|
||||
await this.relRepo.updateStatus(level1.id, 'ACTIVE', { activatedAt: new Date() });
|
||||
|
||||
const rules = FIRST_PAYMENT_POINTS[plan] ?? FIRST_PAYMENT_POINTS['pro'];
|
||||
|
||||
if (rules.referrer > 0) {
|
||||
await this.pointRepo.addTransaction(
|
||||
level1.referrerUserId,
|
||||
rules.referrer,
|
||||
'REFERRAL_FIRST_PAYMENT',
|
||||
level1.id,
|
||||
`First payment by referred user (${plan})`,
|
||||
);
|
||||
}
|
||||
if (rules.referred > 0) {
|
||||
await this.pointRepo.addTransaction(
|
||||
userId,
|
||||
rules.referred,
|
||||
'REFERRAL_FIRST_PAYMENT',
|
||||
level1.id,
|
||||
`Bonus for subscribing via referral (${plan})`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.relRepo.updateStatus(level1.id, 'REWARDED', { rewardedAt: new Date() });
|
||||
this.logger.log(`User referral first-payment: referrer=${level1.referrerUserId} +${rules.referrer} pts`);
|
||||
|
||||
} else if (level1.status === 'ACTIVE' || level1.status === 'REWARDED') {
|
||||
const recurringCount = await this.relRepo.countRecurringRewards(level1.id);
|
||||
if (recurringCount < RECURRING_MAX) {
|
||||
const pts = Math.floor(payload.amount * RECURRING_PCT / 100); // cents→pts (1USD=100pts)
|
||||
if (pts > 0) {
|
||||
await this.pointRepo.addTransaction(
|
||||
level1.referrerUserId,
|
||||
pts,
|
||||
'REFERRAL_RECURRING',
|
||||
level1.id,
|
||||
`Recurring reward month ${recurringCount + 1}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Level-2 indirect reward
|
||||
const level2Rows = await this.relRepo.findByReferrerUserId(level1.referrerUserId);
|
||||
const l2 = level2Rows.items.find((r) => r.referredUserId === userId && r.level === 2);
|
||||
if (l2) {
|
||||
const l2Count = await this.relRepo.countRecurringRewards(l2.id);
|
||||
if (l2Count < L2_MAX) {
|
||||
const l2Pts = Math.floor(payload.amount * L2_PCT / 100);
|
||||
if (l2Pts > 0) {
|
||||
await this.pointRepo.addTransaction(
|
||||
l2.referrerUserId,
|
||||
l2Pts,
|
||||
'REFERRAL_L2',
|
||||
l2.id,
|
||||
`Level-2 recurring reward`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.processedRepo.markProcessed(eventId, 'payment.received');
|
||||
} catch (e: any) {
|
||||
this.logger.error(`Failed to process user payment ${msgId}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { UserReferralRelationshipRepository } from '../../infrastructure/repositories/user-referral-relationship.repository';
|
||||
import { UserReferralRelationship } from '../../domain/entities/user-referral-relationship.entity';
|
||||
|
||||
export interface CircleMember {
|
||||
relationshipId: string;
|
||||
referredUserId: string;
|
||||
level: 1 | 2;
|
||||
status: string;
|
||||
joinedAt: Date;
|
||||
activatedAt: Date | null;
|
||||
}
|
||||
|
||||
export interface GetMyCircleResult {
|
||||
items: CircleMember[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GetMyCircleUseCase {
|
||||
constructor(private readonly relRepo: UserReferralRelationshipRepository) {}
|
||||
|
||||
async execute(userId: string, limit = 20, offset = 0): Promise<GetMyCircleResult> {
|
||||
const { items, total } = await this.relRepo.findByReferrerUserId(userId, limit, offset);
|
||||
return {
|
||||
items: items.map((r: UserReferralRelationship) => ({
|
||||
relationshipId: r.id,
|
||||
referredUserId: r.referredUserId,
|
||||
level: r.level,
|
||||
status: r.status,
|
||||
joinedAt: r.createdAt,
|
||||
activatedAt: r.activatedAt,
|
||||
})),
|
||||
total,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { UserReferralCodeRepository } from '../../infrastructure/repositories/user-referral-code.repository';
|
||||
import { UserReferralRelationshipRepository } from '../../infrastructure/repositories/user-referral-relationship.repository';
|
||||
import { UserPointRepository } from '../../infrastructure/repositories/user-point.repository';
|
||||
|
||||
export interface UserReferralInfo {
|
||||
code: string;
|
||||
shareUrl: string;
|
||||
circleSize: number; // total direct invitees (level-1)
|
||||
activeCount: number; // those who have paid
|
||||
pointsBalance: number;
|
||||
totalEarned: number;
|
||||
totalSpent: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GetMyUserReferralInfoUseCase {
|
||||
constructor(
|
||||
private readonly codeRepo: UserReferralCodeRepository,
|
||||
private readonly relRepo: UserReferralRelationshipRepository,
|
||||
private readonly pointRepo: UserPointRepository,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
async execute(userId: string): Promise<UserReferralInfo> {
|
||||
// Ensure code exists (create if first visit)
|
||||
const codeEntity = await this.codeRepo.findByUserId(userId)
|
||||
?? await this.codeRepo.createForUser(userId);
|
||||
|
||||
const appUrl = this.config.get<string>('APP_URL', 'https://it0.szaiai.com');
|
||||
const shareUrl = `${appUrl}/join?ref=${codeEntity.code}`;
|
||||
|
||||
const { items, total } = await this.relRepo.findByReferrerUserId(userId, 1000, 0);
|
||||
const activeCount = items.filter((r) => r.status !== 'PENDING' && r.status !== 'EXPIRED').length;
|
||||
|
||||
const balance = await this.pointRepo.getBalance(userId);
|
||||
|
||||
return {
|
||||
code: codeEntity.code,
|
||||
shareUrl,
|
||||
circleSize: total,
|
||||
activeCount,
|
||||
pointsBalance: balance.balance,
|
||||
totalEarned: balance.totalEarned,
|
||||
totalSpent: Math.abs(balance.totalSpent),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { UserReferralCodeRepository } from '../../infrastructure/repositories/user-referral-code.repository';
|
||||
import { UserReferralRelationshipRepository } from '../../infrastructure/repositories/user-referral-relationship.repository';
|
||||
import { UserPointRepository } from '../../infrastructure/repositories/user-point.repository';
|
||||
|
||||
export interface RegisterUserReferralInput {
|
||||
userId: string;
|
||||
/** Optional: personal referral code entered by the user at registration */
|
||||
userReferralCode?: string;
|
||||
}
|
||||
|
||||
export interface RegisterUserReferralResult {
|
||||
myCode: string;
|
||||
}
|
||||
|
||||
// Welcome bonus points awarded to a newly invited user
|
||||
const WELCOME_BONUS_POINTS = 200;
|
||||
|
||||
@Injectable()
|
||||
export class RegisterUserReferralUseCase {
|
||||
private readonly logger = new Logger(RegisterUserReferralUseCase.name);
|
||||
|
||||
constructor(
|
||||
private readonly codeRepo: UserReferralCodeRepository,
|
||||
private readonly relRepo: UserReferralRelationshipRepository,
|
||||
private readonly pointRepo: UserPointRepository,
|
||||
) {}
|
||||
|
||||
async execute(input: RegisterUserReferralInput): Promise<RegisterUserReferralResult> {
|
||||
const { userId, userReferralCode } = input;
|
||||
|
||||
// 1. Auto-create this user's own referral code
|
||||
const myCode = await this.codeRepo.createForUser(userId);
|
||||
|
||||
// 2. If a referral code was provided, establish the relationship
|
||||
if (userReferralCode) {
|
||||
const codeEntity = await this.codeRepo.findByCode(userReferralCode);
|
||||
if (!codeEntity) {
|
||||
this.logger.warn(`User referral code not found: ${userReferralCode} for user ${userId}`);
|
||||
return { myCode: myCode.code };
|
||||
}
|
||||
|
||||
// Prevent self-referral
|
||||
if (codeEntity.userId === userId) {
|
||||
this.logger.warn(`Self-referral attempt by user ${userId}`);
|
||||
return { myCode: myCode.code };
|
||||
}
|
||||
|
||||
// Prevent double-referral (idempotent)
|
||||
const existing = await this.relRepo.findByReferredUserId(userId);
|
||||
if (existing) {
|
||||
this.logger.warn(`User ${userId} already has a referral relationship`);
|
||||
return { myCode: myCode.code };
|
||||
}
|
||||
|
||||
// Create level-1 (direct) relationship
|
||||
const rel = await this.relRepo.create(codeEntity.userId, userId, userReferralCode, 1);
|
||||
|
||||
// Award welcome bonus to the newly referred user
|
||||
await this.pointRepo.addTransaction(
|
||||
userId,
|
||||
WELCOME_BONUS_POINTS,
|
||||
'REFERRAL_WELCOME',
|
||||
rel.id,
|
||||
`Welcome bonus — invited by user ${codeEntity.userId}`,
|
||||
);
|
||||
|
||||
// Check if the referrer was themselves referred (level-2 chain)
|
||||
const referrerRel = await this.relRepo.findByReferredUserId(codeEntity.userId);
|
||||
if (referrerRel) {
|
||||
await this.relRepo.create(
|
||||
referrerRel.referrerUserId,
|
||||
userId,
|
||||
userReferralCode,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`User referral created: ${codeEntity.userId} → ${userId} (code: ${userReferralCode})`,
|
||||
);
|
||||
}
|
||||
|
||||
return { myCode: myCode.code };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
export class UserPointBalance {
|
||||
userId: string;
|
||||
balance: number;
|
||||
totalEarned: number;
|
||||
totalSpent: number; // always ≤ 0
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
export type PointTransactionType =
|
||||
| 'REFERRAL_FIRST_PAYMENT' // first subscription payment by referred user
|
||||
| 'REFERRAL_RECURRING' // recurring monthly reward (≤12 months)
|
||||
| 'REFERRAL_L2' // level-2 indirect reward (≤6 months)
|
||||
| 'REFERRAL_WELCOME' // welcome bonus to newly referred user
|
||||
| 'REDEMPTION_QUOTA' // redeemed for extra usage quota
|
||||
| 'REDEMPTION_UNLOCK' // redeemed to unlock a premium agent
|
||||
| 'ADMIN_GRANT' // platform admin manual adjustment
|
||||
| 'EXPIRY'; // points expiry deduction
|
||||
|
||||
export class UserPointTransaction {
|
||||
id: string;
|
||||
userId: string;
|
||||
delta: number; // positive = earned, negative = spent
|
||||
type: PointTransactionType;
|
||||
refId: string | null; // user_referral_relationships.id or redemption id
|
||||
note: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export class UserReferralCode {
|
||||
userId: string;
|
||||
code: string;
|
||||
clickCount: number;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
export type UserReferralStatus = 'PENDING' | 'ACTIVE' | 'REWARDED' | 'EXPIRED';
|
||||
|
||||
export class UserReferralRelationship {
|
||||
id: string;
|
||||
referrerUserId: string;
|
||||
referredUserId: string;
|
||||
referralCode: string;
|
||||
level: 1 | 2;
|
||||
status: UserReferralStatus;
|
||||
activatedAt: Date | null;
|
||||
rewardedAt: Date | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { UserPointBalance } from '../../domain/entities/user-point-balance.entity';
|
||||
import { UserPointTransaction, PointTransactionType } from '../../domain/entities/user-point-transaction.entity';
|
||||
|
||||
@Injectable()
|
||||
export class UserPointRepository {
|
||||
constructor(@InjectDataSource() private readonly ds: DataSource) {}
|
||||
|
||||
/**
|
||||
* Add a point transaction AND atomically update the balance cache.
|
||||
* delta > 0 = earn, delta < 0 = spend.
|
||||
* Throws if resulting balance would go below 0.
|
||||
*/
|
||||
async addTransaction(
|
||||
userId: string,
|
||||
delta: number,
|
||||
type: PointTransactionType,
|
||||
refId?: string,
|
||||
note?: string,
|
||||
): Promise<UserPointTransaction> {
|
||||
return this.ds.transaction(async (em) => {
|
||||
// Upsert balance row (lock for update)
|
||||
await em.query(
|
||||
`INSERT INTO public.user_point_balances (user_id) VALUES ($1)
|
||||
ON CONFLICT (user_id) DO NOTHING`,
|
||||
[userId],
|
||||
);
|
||||
const bal = await em.query(
|
||||
`SELECT balance FROM public.user_point_balances WHERE user_id = $1 FOR UPDATE`,
|
||||
[userId],
|
||||
);
|
||||
const current: number = bal[0]?.balance ?? 0;
|
||||
if (delta < 0 && current + delta < 0) {
|
||||
throw new Error(`Insufficient points: balance=${current}, requested=${-delta}`);
|
||||
}
|
||||
|
||||
// Insert transaction
|
||||
const txRows = await em.query(
|
||||
`INSERT INTO public.user_point_transactions (user_id, delta, type, ref_id, note)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
||||
[userId, delta, type, refId ?? null, note ?? null],
|
||||
);
|
||||
|
||||
// Update balance
|
||||
const earnedDelta = delta > 0 ? delta : 0;
|
||||
const spentDelta = delta < 0 ? delta : 0;
|
||||
await em.query(
|
||||
`UPDATE public.user_point_balances
|
||||
SET balance = balance + $2,
|
||||
total_earned = total_earned + $3,
|
||||
total_spent = total_spent + $4,
|
||||
updated_at = NOW()
|
||||
WHERE user_id = $1`,
|
||||
[userId, delta, earnedDelta, spentDelta],
|
||||
);
|
||||
|
||||
return this.mapTx(txRows[0]);
|
||||
});
|
||||
}
|
||||
|
||||
async getBalance(userId: string): Promise<UserPointBalance> {
|
||||
const rows = await this.ds.query(
|
||||
`SELECT * FROM public.user_point_balances WHERE user_id = $1`,
|
||||
[userId],
|
||||
);
|
||||
if (rows[0]) return this.mapBal(rows[0]);
|
||||
// No row yet — return zeroes
|
||||
return { userId, balance: 0, totalEarned: 0, totalSpent: 0, updatedAt: new Date() };
|
||||
}
|
||||
|
||||
async getTransactions(
|
||||
userId: string,
|
||||
limit = 20,
|
||||
offset = 0,
|
||||
): Promise<{ items: UserPointTransaction[]; total: number }> {
|
||||
const [rows, count] = await Promise.all([
|
||||
this.ds.query(
|
||||
`SELECT * FROM public.user_point_transactions
|
||||
WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3`,
|
||||
[userId, limit, offset],
|
||||
),
|
||||
this.ds.query(
|
||||
`SELECT COUNT(*)::int AS total FROM public.user_point_transactions WHERE user_id = $1`,
|
||||
[userId],
|
||||
),
|
||||
]);
|
||||
return { items: rows.map((r: any) => this.mapTx(r)), total: count[0]?.total ?? 0 };
|
||||
}
|
||||
|
||||
/** Admin: list all point transactions across all users (paginated). */
|
||||
async getAllTransactions(
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
): Promise<{ items: UserPointTransaction[]; total: number }> {
|
||||
const [rows, count] = await Promise.all([
|
||||
this.ds.query(
|
||||
`SELECT * FROM public.user_point_transactions ORDER BY created_at DESC LIMIT $1 OFFSET $2`,
|
||||
[limit, offset],
|
||||
),
|
||||
this.ds.query(
|
||||
`SELECT COUNT(*)::int AS total FROM public.user_point_transactions`,
|
||||
),
|
||||
]);
|
||||
return { items: rows.map((r: any) => this.mapTx(r)), total: count[0]?.total ?? 0 };
|
||||
}
|
||||
|
||||
/** Admin: get all user balance summaries (paginated). */
|
||||
async getAllBalances(
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
): Promise<{ items: UserPointBalance[]; total: number }> {
|
||||
const [rows, count] = await Promise.all([
|
||||
this.ds.query(
|
||||
`SELECT * FROM public.user_point_balances ORDER BY balance DESC LIMIT $1 OFFSET $2`,
|
||||
[limit, offset],
|
||||
),
|
||||
this.ds.query(
|
||||
`SELECT COUNT(*)::int AS total FROM public.user_point_balances`,
|
||||
),
|
||||
]);
|
||||
return { items: rows.map((r: any) => this.mapBal(r)), total: count[0]?.total ?? 0 };
|
||||
}
|
||||
|
||||
private mapTx(r: any): UserPointTransaction {
|
||||
return {
|
||||
id: r.id,
|
||||
userId: r.user_id,
|
||||
delta: r.delta,
|
||||
type: r.type,
|
||||
refId: r.ref_id,
|
||||
note: r.note,
|
||||
createdAt: r.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
private mapBal(r: any): UserPointBalance {
|
||||
return {
|
||||
userId: r.user_id,
|
||||
balance: r.balance,
|
||||
totalEarned: r.total_earned,
|
||||
totalSpent: r.total_spent,
|
||||
updatedAt: r.updated_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { UserReferralCode } from '../../domain/entities/user-referral-code.entity';
|
||||
|
||||
/** Generates a random user referral code: USR-XXXX-XXXX */
|
||||
function generateUserCode(): string {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
const seg = (n: number) =>
|
||||
Array.from({ length: n }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
|
||||
return `USR-${seg(4)}-${seg(4)}`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class UserReferralCodeRepository {
|
||||
constructor(@InjectDataSource() private readonly ds: DataSource) {}
|
||||
|
||||
/** Create a referral code for a user (idempotent — skips if already exists). */
|
||||
async createForUser(userId: string): Promise<UserReferralCode> {
|
||||
// Try up to 5 times to find a unique code
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
const code = generateUserCode();
|
||||
try {
|
||||
const rows = await this.ds.query(
|
||||
`INSERT INTO public.user_referral_codes (user_id, code)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (user_id) DO UPDATE SET user_id = EXCLUDED.user_id
|
||||
RETURNING *`,
|
||||
[userId, code],
|
||||
);
|
||||
return this.map(rows[0]);
|
||||
} catch {
|
||||
// code collision — retry
|
||||
}
|
||||
}
|
||||
// Fallback: just return whatever exists
|
||||
return this.findByUserId(userId) as Promise<UserReferralCode>;
|
||||
}
|
||||
|
||||
async findByCode(code: string): Promise<UserReferralCode | null> {
|
||||
const rows = await this.ds.query(
|
||||
`SELECT * FROM public.user_referral_codes WHERE code = $1`,
|
||||
[code],
|
||||
);
|
||||
return rows[0] ? this.map(rows[0]) : null;
|
||||
}
|
||||
|
||||
async findByUserId(userId: string): Promise<UserReferralCode | null> {
|
||||
const rows = await this.ds.query(
|
||||
`SELECT * FROM public.user_referral_codes WHERE user_id = $1`,
|
||||
[userId],
|
||||
);
|
||||
return rows[0] ? this.map(rows[0]) : null;
|
||||
}
|
||||
|
||||
/** Increment click count asynchronously (fire-and-forget safe). */
|
||||
async incrementClickCount(code: string): Promise<void> {
|
||||
await this.ds.query(
|
||||
`UPDATE public.user_referral_codes SET click_count = click_count + 1 WHERE code = $1`,
|
||||
[code],
|
||||
);
|
||||
}
|
||||
|
||||
private map(r: any): UserReferralCode {
|
||||
return {
|
||||
userId: r.user_id,
|
||||
code: r.code,
|
||||
clickCount: r.click_count,
|
||||
createdAt: r.created_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { UserReferralRelationship, UserReferralStatus } from '../../domain/entities/user-referral-relationship.entity';
|
||||
|
||||
@Injectable()
|
||||
export class UserReferralRelationshipRepository {
|
||||
constructor(@InjectDataSource() private readonly ds: DataSource) {}
|
||||
|
||||
async create(
|
||||
referrerUserId: string,
|
||||
referredUserId: string,
|
||||
referralCode: string,
|
||||
level: 1 | 2,
|
||||
): Promise<UserReferralRelationship> {
|
||||
const rows = await this.ds.query(
|
||||
`INSERT INTO public.user_referral_relationships
|
||||
(referrer_user_id, referred_user_id, referral_code, level)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (referred_user_id) DO NOTHING
|
||||
RETURNING *`,
|
||||
[referrerUserId, referredUserId, referralCode, level],
|
||||
);
|
||||
return rows[0] ? this.map(rows[0]) : this.findByReferredUserId(referredUserId) as Promise<UserReferralRelationship>;
|
||||
}
|
||||
|
||||
async findByReferredUserId(userId: string): Promise<UserReferralRelationship | null> {
|
||||
const rows = await this.ds.query(
|
||||
`SELECT * FROM public.user_referral_relationships WHERE referred_user_id = $1`,
|
||||
[userId],
|
||||
);
|
||||
return rows[0] ? this.map(rows[0]) : null;
|
||||
}
|
||||
|
||||
/** Direct circle members (level-1 referrals made by this user). */
|
||||
async findByReferrerUserId(
|
||||
userId: string,
|
||||
limit = 20,
|
||||
offset = 0,
|
||||
): Promise<{ items: UserReferralRelationship[]; total: number }> {
|
||||
const [rows, count] = await Promise.all([
|
||||
this.ds.query(
|
||||
`SELECT * FROM public.user_referral_relationships
|
||||
WHERE referrer_user_id = $1 AND level = 1
|
||||
ORDER BY created_at DESC LIMIT $2 OFFSET $3`,
|
||||
[userId, limit, offset],
|
||||
),
|
||||
this.ds.query(
|
||||
`SELECT COUNT(*)::int AS total FROM public.user_referral_relationships
|
||||
WHERE referrer_user_id = $1 AND level = 1`,
|
||||
[userId],
|
||||
),
|
||||
]);
|
||||
return { items: rows.map((r: any) => this.map(r)), total: count[0]?.total ?? 0 };
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
status: UserReferralStatus,
|
||||
timestamps: { activatedAt?: Date; rewardedAt?: Date } = {},
|
||||
): Promise<void> {
|
||||
await this.ds.query(
|
||||
`UPDATE public.user_referral_relationships
|
||||
SET status = $2,
|
||||
activated_at = COALESCE($3, activated_at),
|
||||
rewarded_at = COALESCE($4, rewarded_at)
|
||||
WHERE id = $1`,
|
||||
[id, status, timestamps.activatedAt ?? null, timestamps.rewardedAt ?? null],
|
||||
);
|
||||
}
|
||||
|
||||
/** Admin: list all user-level circle relationships (paginated). */
|
||||
async findAll(
|
||||
status?: UserReferralStatus,
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
): Promise<{ items: UserReferralRelationship[]; total: number }> {
|
||||
const where = status ? `WHERE status = $3` : '';
|
||||
const params = status ? [limit, offset, status] : [limit, offset];
|
||||
const [rows, count] = await Promise.all([
|
||||
this.ds.query(
|
||||
`SELECT * FROM public.user_referral_relationships ${where} ORDER BY created_at DESC LIMIT $1 OFFSET $2`,
|
||||
params,
|
||||
),
|
||||
this.ds.query(
|
||||
`SELECT COUNT(*)::int AS total FROM public.user_referral_relationships ${where}`,
|
||||
status ? [status] : [],
|
||||
),
|
||||
]);
|
||||
return { items: rows.map((r: any) => this.map(r)), total: count[0]?.total ?? 0 };
|
||||
}
|
||||
|
||||
/** Count recurring-type point transactions already issued for a relationship. */
|
||||
async countRecurringRewards(relationshipId: string): Promise<number> {
|
||||
const rows = await this.ds.query(
|
||||
`SELECT COUNT(*)::int AS cnt FROM public.user_point_transactions
|
||||
WHERE ref_id = $1 AND type IN ('REFERRAL_RECURRING', 'REFERRAL_L2')`,
|
||||
[relationshipId],
|
||||
);
|
||||
return rows[0]?.cnt ?? 0;
|
||||
}
|
||||
|
||||
private map(r: any): UserReferralRelationship {
|
||||
return {
|
||||
id: r.id,
|
||||
referrerUserId: r.referrer_user_id,
|
||||
referredUserId: r.referred_user_id,
|
||||
referralCode: r.referral_code,
|
||||
level: r.level,
|
||||
status: r.status,
|
||||
activatedAt: r.activated_at,
|
||||
rewardedAt: r.rewarded_at,
|
||||
createdAt: r.created_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -10,8 +10,11 @@ import {
|
|||
import { GetReferralListUseCase } from '../../../application/use-cases/get-referral-list.use-case';
|
||||
import { ReferralRelationshipRepository } from '../../../infrastructure/repositories/referral-relationship.repository';
|
||||
import { ReferralRewardRepository } from '../../../infrastructure/repositories/referral-reward.repository';
|
||||
import { UserReferralRelationshipRepository } from '../../../infrastructure/repositories/user-referral-relationship.repository';
|
||||
import { UserPointRepository } from '../../../infrastructure/repositories/user-point.repository';
|
||||
import { ReferralStatus } from '../../../domain/entities/referral-relationship.entity';
|
||||
import { RewardStatus } from '../../../domain/entities/referral-reward.entity';
|
||||
import { UserReferralStatus } from '../../../domain/entities/user-referral-relationship.entity';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
|
||||
/**
|
||||
|
|
@ -24,9 +27,13 @@ export class ReferralAdminController {
|
|||
private readonly getReferralList: GetReferralListUseCase,
|
||||
private readonly relationshipRepo: ReferralRelationshipRepository,
|
||||
private readonly rewardRepo: ReferralRewardRepository,
|
||||
private readonly userRelRepo: UserReferralRelationshipRepository,
|
||||
private readonly userPointRepo: UserPointRepository,
|
||||
) {}
|
||||
|
||||
/** GET /api/v1/referral/admin/relationships — all referral relationships */
|
||||
// ── Tenant-level (B2B) ───────────────────────────────────────────────────
|
||||
|
||||
/** GET /api/v1/referral/admin/relationships — all tenant referral relationships */
|
||||
@Get('relationships')
|
||||
async listRelationships(
|
||||
@Headers('authorization') auth: string,
|
||||
|
|
@ -38,7 +45,7 @@ export class ReferralAdminController {
|
|||
return this.relationshipRepo.findAll(status, Math.min(limit, 200), offset);
|
||||
}
|
||||
|
||||
/** GET /api/v1/referral/admin/rewards — all reward records */
|
||||
/** GET /api/v1/referral/admin/rewards — all tenant reward records */
|
||||
@Get('rewards')
|
||||
async listRewards(
|
||||
@Headers('authorization') auth: string,
|
||||
|
|
@ -54,18 +61,60 @@ export class ReferralAdminController {
|
|||
@Get('stats')
|
||||
async getStats(@Headers('authorization') auth: string) {
|
||||
this.requireAdmin(auth);
|
||||
const [totalRel, activeRel, pendingRewards] = await Promise.all([
|
||||
const [totalRel, activeRel, pendingRewards, totalCircles, totalPointTxns] = await Promise.all([
|
||||
this.relationshipRepo.findAll(undefined, 1, 0).then((r) => r.total),
|
||||
this.relationshipRepo.findAll('ACTIVE', 1, 0).then((r) => r.total),
|
||||
this.rewardRepo.findAll('PENDING', 1, 0).then((r) => r.total),
|
||||
this.userRelRepo.findAll(undefined, 1, 0).then((r) => r.total),
|
||||
this.userPointRepo.getAllTransactions(1, 0).then((r) => r.total),
|
||||
]);
|
||||
return {
|
||||
totalReferrals: totalRel,
|
||||
activeReferrals: activeRel,
|
||||
pendingRewards,
|
||||
totalUserCircles: totalCircles,
|
||||
totalPointTransactions: totalPointTxns,
|
||||
};
|
||||
}
|
||||
|
||||
// ── User-level / personal circle (C2C) ──────────────────────────────────
|
||||
|
||||
/** GET /api/v1/referral/admin/user-circles — all user-level circle relationships */
|
||||
@Get('user-circles')
|
||||
async listUserCircles(
|
||||
@Headers('authorization') auth: string,
|
||||
@Query('status') status: UserReferralStatus | undefined,
|
||||
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit: number,
|
||||
@Query('offset', new DefaultValuePipe(0), ParseIntPipe) offset: number,
|
||||
) {
|
||||
this.requireAdmin(auth);
|
||||
return this.userRelRepo.findAll(status, Math.min(limit, 200), offset);
|
||||
}
|
||||
|
||||
/** GET /api/v1/referral/admin/user-points — all point transactions across users */
|
||||
@Get('user-points')
|
||||
async listUserPoints(
|
||||
@Headers('authorization') auth: string,
|
||||
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit: number,
|
||||
@Query('offset', new DefaultValuePipe(0), ParseIntPipe) offset: number,
|
||||
) {
|
||||
this.requireAdmin(auth);
|
||||
return this.userPointRepo.getAllTransactions(Math.min(limit, 200), offset);
|
||||
}
|
||||
|
||||
/** GET /api/v1/referral/admin/user-balances — all user point balance summaries */
|
||||
@Get('user-balances')
|
||||
async listUserBalances(
|
||||
@Headers('authorization') auth: string,
|
||||
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit: number,
|
||||
@Query('offset', new DefaultValuePipe(0), ParseIntPipe) offset: number,
|
||||
) {
|
||||
this.requireAdmin(auth);
|
||||
return this.userPointRepo.getAllBalances(Math.min(limit, 200), offset);
|
||||
}
|
||||
|
||||
// ── Helper ───────────────────────────────────────────────────────────────
|
||||
|
||||
private requireAdmin(auth: string) {
|
||||
if (!auth?.startsWith('Bearer ')) {
|
||||
throw new UnauthorizedException('Missing authorization header');
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
import { InternalApiGuard } from '../guards/internal-api.guard';
|
||||
import { RegisterWithCodeUseCase } from '../../../application/use-cases/register-with-code.use-case';
|
||||
import { GetPendingCreditsUseCase } from '../../../application/use-cases/get-pending-credits.use-case';
|
||||
import { RegisterUserReferralUseCase } from '../../../application/use-cases/register-user-referral.use-case';
|
||||
|
||||
/**
|
||||
* Internal service-to-service endpoints.
|
||||
|
|
@ -18,9 +19,12 @@ import { GetPendingCreditsUseCase } from '../../../application/use-cases/get-pen
|
|||
* NOT exposed through Kong to the outside world.
|
||||
*
|
||||
* Called by:
|
||||
* - auth-service: on tenant registration → POST /api/v1/referral/internal/register
|
||||
* - billing-service: before invoice → GET /api/v1/referral/internal/:tenantId/pending-credits
|
||||
* - billing-service: after invoice → POST /api/v1/referral/internal/:tenantId/apply-credits
|
||||
* - auth-service (on registration):
|
||||
* POST /internal/register → tenant-level code + B2B relationship
|
||||
* POST /internal/user-register → user-level code + personal circle relationship
|
||||
* - billing-service:
|
||||
* GET /internal/:tenantId/pending-credits → credits to apply to next invoice
|
||||
* POST /internal/:tenantId/apply-credits → mark credits applied
|
||||
*/
|
||||
@Controller('api/v1/referral/internal')
|
||||
@UseGuards(InternalApiGuard)
|
||||
|
|
@ -28,11 +32,12 @@ export class ReferralInternalController {
|
|||
constructor(
|
||||
private readonly registerWithCode: RegisterWithCodeUseCase,
|
||||
private readonly getPendingCredits: GetPendingCreditsUseCase,
|
||||
private readonly registerUserReferral: RegisterUserReferralUseCase,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Called by auth-service immediately after a new tenant is created.
|
||||
* Creates the tenant's own referral code and optionally links a referrer.
|
||||
* Called by auth-service after a new TENANT is created (B2B flow).
|
||||
* Creates the tenant's own referral code; optionally links a tenant-level referrer.
|
||||
*/
|
||||
@Post('register')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
|
@ -46,6 +51,22 @@ export class ReferralInternalController {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by auth-service after ANY user registration (B2B or personal).
|
||||
* Creates the user's personal referral code; optionally links a personal-circle referrer.
|
||||
* userReferralCode must start with "USR-" to distinguish from tenant codes.
|
||||
*/
|
||||
@Post('user-register')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async userRegister(
|
||||
@Body() body: { userId: string; userReferralCode?: string },
|
||||
) {
|
||||
return this.registerUserReferral.execute({
|
||||
userId: body.userId,
|
||||
userReferralCode: body.userReferralCode,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by billing-service before generating an invoice.
|
||||
* Returns total pending credit and individual reward IDs.
|
||||
|
|
|
|||
|
|
@ -11,11 +11,26 @@ import {
|
|||
import { GetMyReferralInfoUseCase } from '../../../application/use-cases/get-my-referral-info.use-case';
|
||||
import { ValidateReferralCodeUseCase } from '../../../application/use-cases/validate-referral-code.use-case';
|
||||
import { GetReferralListUseCase } from '../../../application/use-cases/get-referral-list.use-case';
|
||||
import { GetMyUserReferralInfoUseCase } from '../../../application/use-cases/get-my-user-referral-info.use-case';
|
||||
import { GetMyCircleUseCase } from '../../../application/use-cases/get-my-circle.use-case';
|
||||
import { UserPointRepository } from '../../../infrastructure/repositories/user-point.repository';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
|
||||
/**
|
||||
* User-facing referral endpoints.
|
||||
* Kong enforces JWT — we extract tenant/user from the Authorization header here.
|
||||
* User-facing referral endpoints (JWT required via Kong).
|
||||
*
|
||||
* Tenant-level (B2B):
|
||||
* GET /me → tenant referral code + stats
|
||||
* GET /me/referrals → tenant-level direct referral list
|
||||
* GET /me/rewards → tenant credit rewards
|
||||
*
|
||||
* User-level / personal circle (C2C):
|
||||
* GET /me/user → personal code + points balance
|
||||
* GET /me/circle → personal circle members (level-1 invitees)
|
||||
* GET /me/points → points balance + transaction history
|
||||
*
|
||||
* Public:
|
||||
* GET /validate?code=... → validate any referral code
|
||||
*/
|
||||
@Controller('api/v1/referral')
|
||||
export class ReferralController {
|
||||
|
|
@ -25,16 +40,19 @@ export class ReferralController {
|
|||
private readonly getMyReferralInfo: GetMyReferralInfoUseCase,
|
||||
private readonly validateCode: ValidateReferralCodeUseCase,
|
||||
private readonly getReferralList: GetReferralListUseCase,
|
||||
private readonly getMyUserReferralInfo: GetMyUserReferralInfoUseCase,
|
||||
private readonly getMyCircle: GetMyCircleUseCase,
|
||||
private readonly pointRepo: UserPointRepository,
|
||||
) {}
|
||||
|
||||
/** GET /api/v1/referral/me — get current tenant's referral info & code */
|
||||
// ── Tenant-level (B2B) ──────────────────────────────────────────────────
|
||||
|
||||
@Get('me')
|
||||
async getMe(@Headers('authorization') auth: string) {
|
||||
const { tenantId, userId } = this.extractJwt(auth);
|
||||
return this.getMyReferralInfo.execute(tenantId, userId);
|
||||
}
|
||||
|
||||
/** GET /api/v1/referral/me/referrals?limit=20&offset=0 — list direct referrals */
|
||||
@Get('me/referrals')
|
||||
async getMyReferrals(
|
||||
@Headers('authorization') auth: string,
|
||||
|
|
@ -45,7 +63,6 @@ export class ReferralController {
|
|||
return this.getReferralList.getReferrals(tenantId, Math.min(limit, 100), offset);
|
||||
}
|
||||
|
||||
/** GET /api/v1/referral/me/rewards?status=PENDING&limit=20&offset=0 */
|
||||
@Get('me/rewards')
|
||||
async getMyRewards(
|
||||
@Headers('authorization') auth: string,
|
||||
|
|
@ -57,24 +74,56 @@ export class ReferralController {
|
|||
return this.getReferralList.getRewards(tenantId, status, Math.min(limit, 100), offset);
|
||||
}
|
||||
|
||||
/** GET /api/v1/referral/validate/:code — public, validate a referral code */
|
||||
// ── User-level / personal circle (C2C) ─────────────────────────────────
|
||||
|
||||
/** GET /me/user — personal code, shareUrl, circle size, points balance */
|
||||
@Get('me/user')
|
||||
async getMyUserInfo(@Headers('authorization') auth: string) {
|
||||
const { userId } = this.extractJwt(auth);
|
||||
return this.getMyUserReferralInfo.execute(userId);
|
||||
}
|
||||
|
||||
/** GET /me/circle — personal circle members (level-1 invitees) */
|
||||
@Get('me/circle')
|
||||
async getMyCircleMembers(
|
||||
@Headers('authorization') auth: string,
|
||||
@Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number,
|
||||
@Query('offset', new DefaultValuePipe(0), ParseIntPipe) offset: number,
|
||||
) {
|
||||
const { userId } = this.extractJwt(auth);
|
||||
return this.getMyCircle.execute(userId, Math.min(limit, 100), offset);
|
||||
}
|
||||
|
||||
/** GET /me/points — points balance + recent transactions */
|
||||
@Get('me/points')
|
||||
async getMyPoints(
|
||||
@Headers('authorization') auth: string,
|
||||
@Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number,
|
||||
@Query('offset', new DefaultValuePipe(0), ParseIntPipe) offset: number,
|
||||
) {
|
||||
const { userId } = this.extractJwt(auth);
|
||||
const [balance, txns] = await Promise.all([
|
||||
this.pointRepo.getBalance(userId),
|
||||
this.pointRepo.getTransactions(userId, Math.min(limit, 100), offset),
|
||||
]);
|
||||
return { balance, transactions: txns };
|
||||
}
|
||||
|
||||
// ── Public ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Get('validate')
|
||||
async validate(@Query('code') code: string) {
|
||||
return this.validateCode.execute(code ?? '');
|
||||
}
|
||||
|
||||
// ── Helper ───────────────────────────────────────────────────────────────
|
||||
|
||||
private extractJwt(auth: string): { tenantId: string; userId: string } {
|
||||
if (!auth?.startsWith('Bearer ')) {
|
||||
throw new UnauthorizedException('Missing authorization header');
|
||||
}
|
||||
if (!auth?.startsWith('Bearer ')) throw new UnauthorizedException('Missing authorization header');
|
||||
const token = auth.slice(7);
|
||||
const secret = process.env.JWT_SECRET || 'dev-secret';
|
||||
try {
|
||||
const payload = jwt.verify(token, secret) as any;
|
||||
return {
|
||||
tenantId: payload.tenantId,
|
||||
userId: payload.sub,
|
||||
};
|
||||
const payload = jwt.verify(token, process.env.JWT_SECRET || 'dev-secret') as any;
|
||||
return { tenantId: payload.tenantId, userId: payload.sub };
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid JWT');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ import { ReferralRelationshipRepository } from './infrastructure/repositories/re
|
|||
import { ReferralRewardRepository } from './infrastructure/repositories/referral-reward.repository';
|
||||
import { ReferralStatRepository } from './infrastructure/repositories/referral-stat.repository';
|
||||
import { ProcessedEventRepository } from './infrastructure/repositories/processed-event.repository';
|
||||
// User-level repositories
|
||||
import { UserReferralCodeRepository } from './infrastructure/repositories/user-referral-code.repository';
|
||||
import { UserReferralRelationshipRepository } from './infrastructure/repositories/user-referral-relationship.repository';
|
||||
import { UserPointRepository } from './infrastructure/repositories/user-point.repository';
|
||||
|
||||
// Application Use Cases
|
||||
import { GetMyReferralInfoUseCase } from './application/use-cases/get-my-referral-info.use-case';
|
||||
|
|
@ -24,6 +28,11 @@ import { RegisterWithCodeUseCase } from './application/use-cases/register-with-c
|
|||
import { ConsumePaymentReceivedUseCase } from './application/use-cases/consume-payment-received.use-case';
|
||||
import { GetReferralListUseCase } from './application/use-cases/get-referral-list.use-case';
|
||||
import { GetPendingCreditsUseCase } from './application/use-cases/get-pending-credits.use-case';
|
||||
// User-level use cases
|
||||
import { RegisterUserReferralUseCase } from './application/use-cases/register-user-referral.use-case';
|
||||
import { ConsumeUserPaymentUseCase } from './application/use-cases/consume-user-payment.use-case';
|
||||
import { GetMyUserReferralInfoUseCase } from './application/use-cases/get-my-user-referral-info.use-case';
|
||||
import { GetMyCircleUseCase } from './application/use-cases/get-my-circle.use-case';
|
||||
|
||||
// Controllers
|
||||
import { ReferralController } from './interfaces/rest/controllers/referral.controller';
|
||||
|
|
@ -48,20 +57,29 @@ import { ReferralAdminController } from './interfaces/rest/controllers/referral-
|
|||
ReferralAdminController,
|
||||
],
|
||||
providers: [
|
||||
// Repositories
|
||||
// Tenant-level repositories (B2B)
|
||||
ReferralCodeRepository,
|
||||
ReferralRelationshipRepository,
|
||||
ReferralRewardRepository,
|
||||
ReferralStatRepository,
|
||||
ProcessedEventRepository,
|
||||
// User-level repositories (C2C personal circle + points)
|
||||
UserReferralCodeRepository,
|
||||
UserReferralRelationshipRepository,
|
||||
UserPointRepository,
|
||||
|
||||
// Use Cases
|
||||
// Tenant-level use cases
|
||||
GetMyReferralInfoUseCase,
|
||||
ValidateReferralCodeUseCase,
|
||||
RegisterWithCodeUseCase,
|
||||
ConsumePaymentReceivedUseCase,
|
||||
GetReferralListUseCase,
|
||||
GetPendingCreditsUseCase,
|
||||
// User-level use cases
|
||||
RegisterUserReferralUseCase,
|
||||
ConsumeUserPaymentUseCase,
|
||||
GetMyUserReferralInfoUseCase,
|
||||
GetMyCircleUseCase,
|
||||
],
|
||||
})
|
||||
export class ReferralModule {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
-- Migration 011: User-level referral codes + personal circle + points system
|
||||
-- Extends the existing tenant-level referral system (006) to support
|
||||
-- individual user-to-user referrals and a unified points economy.
|
||||
--
|
||||
-- Design:
|
||||
-- • Each user gets their own referral code (vs. per-tenant in 006)
|
||||
-- • Referral relationships track user→user (personal circle, max 2 levels)
|
||||
-- • Points replace raw USD credits as the reward currency (flexible redemption)
|
||||
-- • Points ledger is append-only; balance is a denormalized cache
|
||||
|
||||
-- ── 1. User referral codes ──────────────────────────────────────────────────
|
||||
-- One row per user. Created automatically on first registration.
|
||||
CREATE TABLE IF NOT EXISTS public.user_referral_codes (
|
||||
user_id UUID PRIMARY KEY,
|
||||
code VARCHAR(20) NOT NULL UNIQUE,
|
||||
click_count INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ── 2. User referral relationships (personal circle) ────────────────────────
|
||||
-- Tracks who invited whom at the user level.
|
||||
-- Each user can be referred at most once (UNIQUE referred_user_id).
|
||||
-- level: 1 = direct invite, 2 = indirect (friend of friend).
|
||||
-- status: PENDING → ACTIVE (first engagement) → REWARDED (first payment done)
|
||||
-- EXPIRED (never activated after 90 days)
|
||||
CREATE TABLE IF NOT EXISTS public.user_referral_relationships (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
referrer_user_id UUID NOT NULL,
|
||||
referred_user_id UUID NOT NULL UNIQUE,
|
||||
referral_code VARCHAR(20) NOT NULL,
|
||||
level SMALLINT NOT NULL DEFAULT 1 CHECK (level IN (1, 2)),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'PENDING'
|
||||
CHECK (status IN ('PENDING','ACTIVE','REWARDED','EXPIRED')),
|
||||
activated_at TIMESTAMPTZ,
|
||||
rewarded_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_urr_referrer ON public.user_referral_relationships(referrer_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_urr_status ON public.user_referral_relationships(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_urr_created ON public.user_referral_relationships(created_at);
|
||||
|
||||
-- ── 3. Points ledger (append-only) ──────────────────────────────────────────
|
||||
-- Every point event is a row. delta > 0 = earned, delta < 0 = spent.
|
||||
-- type values:
|
||||
-- REFERRAL_FIRST_PAYMENT – first subscription payment by referred user
|
||||
-- REFERRAL_RECURRING – recurring monthly payment reward (≤12 months)
|
||||
-- REFERRAL_L2 – level-2 indirect reward (≤6 months)
|
||||
-- REFERRAL_WELCOME – welcome bonus awarded to newly referred user
|
||||
-- REDEMPTION_QUOTA – redeemed for extra usage quota
|
||||
-- REDEMPTION_UNLOCK – redeemed to unlock a premium agent
|
||||
-- ADMIN_GRANT – platform admin manual grant/deduction
|
||||
-- EXPIRY – points expiry deduction
|
||||
CREATE TABLE IF NOT EXISTS public.user_point_transactions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL,
|
||||
delta INT NOT NULL,
|
||||
type VARCHAR(40) NOT NULL,
|
||||
ref_id UUID, -- FK to user_referral_relationships.id or redemption id
|
||||
note TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_upt_user_id ON public.user_point_transactions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_upt_created ON public.user_point_transactions(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_upt_ref_id ON public.user_point_transactions(ref_id);
|
||||
|
||||
-- ── 4. Points balance (denormalized cache) ───────────────────────────────────
|
||||
-- Maintained atomically alongside user_point_transactions.
|
||||
-- balance = total_earned + total_spent (total_spent is always ≤ 0, so balance = earned - |spent|)
|
||||
CREATE TABLE IF NOT EXISTS public.user_point_balances (
|
||||
user_id UUID PRIMARY KEY,
|
||||
balance INT NOT NULL DEFAULT 0,
|
||||
total_earned INT NOT NULL DEFAULT 0,
|
||||
total_spent INT NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
Loading…
Reference in New Issue