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:
hailin 2026-03-08 00:18:17 -08:00
parent 6be84617d2
commit 4df699348f
24 changed files with 2205 additions and 243 deletions

View File

@ -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>
);
}

View File

@ -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;
}

View File

@ -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();
}

View File

@ -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

View File

@ -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;

View File

@ -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();
});

View File

@ -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();

View File

@ -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).

View File

@ -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}`);
}
}
}

View File

@ -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,
};
}
}

View File

@ -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),
};
}
}

View File

@ -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 };
}
}

View File

@ -0,0 +1,7 @@
export class UserPointBalance {
userId: string;
balance: number;
totalEarned: number;
totalSpent: number; // always ≤ 0
updatedAt: Date;
}

View File

@ -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;
}

View File

@ -0,0 +1,6 @@
export class UserReferralCode {
userId: string;
code: string;
clickCount: number;
createdAt: Date;
}

View File

@ -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;
}

View File

@ -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,
};
}
}

View File

@ -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,
};
}
}

View File

@ -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,
};
}
}

View File

@ -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');

View File

@ -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.

View File

@ -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');
}

View File

@ -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 {}

View File

@ -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()
);