feat: add point activities and exchange

This commit is contained in:
Codex
2026-06-30 03:28:11 +08:00
parent e035f5be9a
commit 2fea68fe4d
22 changed files with 2121 additions and 43 deletions

View File

@@ -0,0 +1,178 @@
create table if not exists public.point_activity_tasks (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
legacy_id text,
code citext not null,
title text not null,
description text,
task_type text not null default 'manual'
check (task_type in (
'daily_check_in',
'feedback_submit',
'feedback_resolved',
'practice_complete',
'vocabulary_review',
'mock_exam_submit',
'manual'
)),
reward_points integer not null default 0 check (reward_points > 0 and reward_points <= 100000),
claim_limit_per_user integer not null default 1 check (claim_limit_per_user > 0 and claim_limit_per_user <= 100000),
period_type text not null default 'once'
check (period_type in ('once', 'daily', 'weekly', 'monthly', 'unlimited')),
valid_from timestamptz,
valid_to timestamptz,
status text not null default 'active'
check (status in ('active', 'disabled', 'archived')),
sort_order integer not null default 0,
metadata jsonb not null default '{}'::jsonb,
created_by uuid references public.platform_users(id) on delete set null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, code),
unique (tenant_id, legacy_id)
);
comment on table public.point_activity_tasks is
'Tenant-scoped score activity tasks configured by marketing/operations teams.';
create table if not exists public.user_point_activity_claims (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
user_id uuid not null references public.platform_users(id) on delete cascade,
task_id uuid not null references public.point_activity_tasks(id) on delete cascade,
period_key text not null,
score_event_id uuid references public.user_score_events(id) on delete set null,
source_type text,
source_id uuid,
status text not null default 'claimed'
check (status in ('claimed', 'cancelled')),
metadata jsonb not null default '{}'::jsonb,
claimed_at timestamptz not null default now(),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, user_id, task_id, period_key)
);
comment on table public.user_point_activity_claims is
'Idempotent student claim records for activity rewards.';
create table if not exists public.point_exchange_items (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
legacy_id text,
code citext not null,
title text not null,
description text,
cost_points integer not null check (cost_points > 0 and cost_points <= 10000000),
item_type text not null default 'manual'
check (item_type in ('coupon', 'manual', 'asset', 'custom')),
coupon_id uuid references public.coupons(id) on delete set null,
asset_id uuid references public.content_assets(id) on delete set null,
stock_total integer check (stock_total is null or stock_total >= 0),
stock_used integer not null default 0 check (stock_used >= 0),
per_user_limit integer not null default 1 check (per_user_limit > 0 and per_user_limit <= 100000),
valid_from timestamptz,
valid_to timestamptz,
status text not null default 'active'
check (status in ('active', 'disabled', 'archived')),
sort_order integer not null default 0,
metadata jsonb not null default '{}'::jsonb,
created_by uuid references public.platform_users(id) on delete set null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, code),
unique (tenant_id, legacy_id),
check (stock_total is null or stock_used <= stock_total)
);
comment on table public.point_exchange_items is
'Tenant-scoped score exchange catalog. Coupon fulfillment is supported, activation-code fulfillment is intentionally kept out of the first contract.';
create table if not exists public.user_point_exchange_orders (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
user_id uuid not null references public.platform_users(id) on delete cascade,
item_id uuid not null references public.point_exchange_items(id) on delete restrict,
status text not null default 'completed'
check (status in ('completed', 'pending_fulfillment', 'cancelled')),
cost_points integer not null check (cost_points > 0),
score_event_id uuid references public.user_score_events(id) on delete set null,
coupon_redemption_id uuid references public.coupon_redemptions(id) on delete set null,
asset_id uuid references public.content_assets(id) on delete set null,
idempotency_key text,
metadata jsonb not null default '{}'::jsonb,
exchanged_at timestamptz not null default now(),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, user_id, idempotency_key)
);
comment on table public.user_point_exchange_orders is
'Student score exchange orders. Each completed order has a negative redeem_cost ledger event.';
create index if not exists idx_point_activity_tasks_tenant_status
on public.point_activity_tasks(tenant_id, status, sort_order, created_at desc);
create index if not exists idx_point_activity_claims_user
on public.user_point_activity_claims(tenant_id, user_id, claimed_at desc);
create index if not exists idx_point_activity_claims_task
on public.user_point_activity_claims(tenant_id, task_id, claimed_at desc);
create index if not exists idx_point_exchange_items_tenant_status
on public.point_exchange_items(tenant_id, status, sort_order, created_at desc);
create index if not exists idx_point_exchange_orders_user
on public.user_point_exchange_orders(tenant_id, user_id, exchanged_at desc);
create index if not exists idx_point_exchange_orders_item
on public.user_point_exchange_orders(tenant_id, item_id, exchanged_at desc);
alter table public.point_activity_tasks enable row level security;
alter table public.user_point_activity_claims enable row level security;
alter table public.point_exchange_items enable row level security;
alter table public.user_point_exchange_orders enable row level security;
drop policy if exists tenant_isolation on public.point_activity_tasks;
create policy tenant_isolation on public.point_activity_tasks
for all
using (tenant_id = app.current_tenant_id() or app.is_platform_admin())
with check (tenant_id = app.current_tenant_id() or app.is_platform_admin());
drop policy if exists tenant_isolation on public.user_point_activity_claims;
create policy tenant_isolation on public.user_point_activity_claims
for all
using (tenant_id = app.current_tenant_id() or app.is_platform_admin())
with check (tenant_id = app.current_tenant_id() or app.is_platform_admin());
drop policy if exists tenant_isolation on public.point_exchange_items;
create policy tenant_isolation on public.point_exchange_items
for all
using (tenant_id = app.current_tenant_id() or app.is_platform_admin())
with check (tenant_id = app.current_tenant_id() or app.is_platform_admin());
drop policy if exists tenant_isolation on public.user_point_exchange_orders;
create policy tenant_isolation on public.user_point_exchange_orders
for all
using (tenant_id = app.current_tenant_id() or app.is_platform_admin())
with check (tenant_id = app.current_tenant_id() or app.is_platform_admin());
drop trigger if exists set_updated_at on public.point_activity_tasks;
create trigger set_updated_at
before update on public.point_activity_tasks
for each row execute function app.touch_updated_at();
drop trigger if exists set_updated_at on public.user_point_activity_claims;
create trigger set_updated_at
before update on public.user_point_activity_claims
for each row execute function app.touch_updated_at();
drop trigger if exists set_updated_at on public.point_exchange_items;
create trigger set_updated_at
before update on public.point_exchange_items
for each row execute function app.touch_updated_at();
drop trigger if exists set_updated_at on public.user_point_exchange_orders;
create trigger set_updated_at
before update on public.user_point_exchange_orders
for each row execute function app.touch_updated_at();

View File

@@ -0,0 +1,28 @@
do $$
begin
if exists (
select 1
from pg_constraint
where conname = 'badges_unlock_type_check'
) then
alter table public.badges drop constraint badges_unlock_type_check;
end if;
alter table public.badges
add constraint badges_unlock_type_check
check (
unlock_type is null
or unlock_type in (
'manual',
'auto',
'score',
'check_in',
'practice_count',
'vocabulary_mastered',
'mock_exam_score',
'feedback_resolved',
'activity_reward',
'custom'
)
);
end $$;