From e3edf3242930af4bff8524a6a1e556f4db1973a7 Mon Sep 17 00:00:00 2001 From: WardLu Date: Sun, 16 Aug 2026 11:29:11 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(growth-loop):=20W5=20=E5=86=85?= =?UTF-8?q?=E6=B5=8B=E6=89=B9=E6=AC=A1=E8=A1=A8=E3=80=81F0~F6=20=E8=81=9A?= =?UTF-8?q?=E5=90=88=E4=B8=8E=20180=20=E5=A4=A9=E6=B4=BB=E5=8A=A8=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - private.learning_beta_batches:内测批次(F0 分母),仅存家庭 ID/批次/时间/状态, 客户端无访问权,家庭删除级联清理 - private.learning_growth_days 视图 + learning_funnel_status/report/wmgh_weekly 函数:严格按计划 8.1 + 附件 06 定义 F0~F6 与 WMGH(有效成长日去重、补记排除、 72h 快速激活、7 天观察窗、自然周冻结) - private.learning_purge_activity_events + pg_cron 每日任务:activity_events 滚动 180 天保留自动清理,仅私有内部访问 - 配套 pgTAP 测试:批次表 9 项、漏斗/WMGH 43 项、清理边界 10 项(共 62 项全过, 全量 221 项无回归) --- ...0260816120000_growth_loop_beta_batches.sql | 30 ++ ...6121000_growth_loop_funnel_aggregation.sql | 323 +++++++++++++ ...816122000_growth_loop_activity_cleanup.sql | 40 ++ .../tests/growth_loop_beta_batches_test.sql | 104 +++++ supabase/tests/growth_loop_cleanup_test.sql | 114 +++++ supabase/tests/growth_loop_funnel_test.sql | 438 ++++++++++++++++++ 6 files changed, 1049 insertions(+) create mode 100644 supabase/migrations/20260816120000_growth_loop_beta_batches.sql create mode 100644 supabase/migrations/20260816121000_growth_loop_funnel_aggregation.sql create mode 100644 supabase/migrations/20260816122000_growth_loop_activity_cleanup.sql create mode 100644 supabase/tests/growth_loop_beta_batches_test.sql create mode 100644 supabase/tests/growth_loop_cleanup_test.sql create mode 100644 supabase/tests/growth_loop_funnel_test.sql diff --git a/supabase/migrations/20260816120000_growth_loop_beta_batches.sql b/supabase/migrations/20260816120000_growth_loop_beta_batches.sql new file mode 100644 index 0000000..e194b8e --- /dev/null +++ b/supabase/migrations/20260816120000_growth_loop_beta_batches.sql @@ -0,0 +1,30 @@ +-- W5 / 交付项 1:private 内测批次表(F0 分母)。 +-- +-- 仅存家庭 ID、批次、邀请/加入时间、状态。不存邮箱、家长/孩子姓名、学习内容。 +-- 与 private.learning_activity_events 保持同一隐私边界:客户端无 schema/表/函数读取权限。 +-- 家庭删除时级联清理(learning_households on delete cascade)。 + +create table if not exists private.learning_beta_batches ( + household_id uuid primary key + references public.learning_households(id) on delete cascade, + batch text not null check (char_length(batch) between 1 and 40), + status text not null default 'active' + check (status in ('invited', 'active', 'paused', 'exited')), + invited_at timestamptz not null default now(), + joined_at timestamptz, + updated_at timestamptz not null default now(), + constraint learning_beta_batches_join_after_invite + check (joined_at is null or joined_at >= invited_at) +); + +alter table private.learning_beta_batches enable row level security; + +revoke all on table private.learning_beta_batches from public; +revoke all on table private.learning_beta_batches from anon; +revoke all on table private.learning_beta_batches from authenticated; + +create index if not exists learning_beta_batches_batch_idx + on private.learning_beta_batches (batch, joined_at); + +comment on table private.learning_beta_batches is + 'Shadow Mate private 内测批次表(F0 分母):仅存家庭 ID、批次、邀请/加入时间、状态,客户端无访问权。'; diff --git a/supabase/migrations/20260816121000_growth_loop_funnel_aggregation.sql b/supabase/migrations/20260816121000_growth_loop_funnel_aggregation.sql new file mode 100644 index 0000000..1b93469 --- /dev/null +++ b/supabase/migrations/20260816121000_growth_loop_funnel_aggregation.sql @@ -0,0 +1,323 @@ +-- W5 / 交付项 2:F0~F6 聚合定义 + private 只读视图/函数(严格遵循计划 8.1 + 附件 06)。 +-- +-- 固定定义: +-- * 有效成长行为 = 正常积分记录(learning_point_ledger.entry_type='manual',加分扣分都算, +-- 排除人工修正/反向纠错/期初积分/兑换/退款)∪ 学习模块有效打卡(活动事件 +-- growth_activity_recorded,payload.source='checkin')。 +-- * 及时记录(非补记)= 服务端接收时间与行为日期相差 <= 7 天:接受离线延迟同步, +-- 排除超过 7 天的事后补记(补记不能倒推激活/留存/连续使用)。 +-- * 有效成长日 = 按家庭时区一天内至少一条有效记录,同一天只计一次。 +-- * F0 = 内测批次家庭(分母)+ 家庭空间 + >=1 孩子档案 + 家长同意。 +-- * F1 = 有 >=1 可用积分项目(is_active)。 +-- * F2 = 首次有效成长日;F2 日期 = 首个有效成长日;72h 快速激活单独报告。 +-- * F3 = 创建并启用 >=1 奖励(learning_rewards.is_active 且 learning_profile_rewards.enabled)。 +-- * F4 = 首次成功兑换 = 第一条 status='fulfilled' 的 learning_redemptions(F4 日期 = fulfilled_at)。 +-- * F5 = 首次兑现后 7 天内再次产生有效行为((f4_at, f4_at + 7 天])。 +-- * F6 = 核心激活后 7 天内 >=3 个不同有效成长日([f2_at, f2_at + 6])。 +-- * WMGH = 自然周内 >=3 个不同有效成长日的家庭;每周每家庭最多计 1 次;自然周按家庭本地 +-- 周一 00:00 开始;自然周结束 7 天后冻结该周报告。 +-- +-- 隐私边界:与 private.learning_activity_events 一致,客户端无 schema/表/函数读取权限; +-- 不含儿童姓名/学习内容/邮箱/原始错误文本。 + +-- 有效成长日:每家庭每天一行(去重)。 +create or replace view private.learning_growth_days +with (security_invoker = true) as +select + behaviors.household_id, + behaviors.growth_date, + min(behaviors.behavior_at) as first_behavior_at +from ( + -- 正常积分记录(业务事实,唯一权威):entry_type='manual',且为及时记录(非补记) + select + ledger.household_id, + ledger.occurred_on as growth_date, + ledger.created_at as behavior_at + from public.learning_point_ledger ledger + where ledger.entry_type = 'manual' + and (ledger.created_at::date - ledger.occurred_on) between 0 and 7 + union all + -- 学习模块有效打卡:仅活动事件来源(打卡不产生积分流水) + select + event.household_id, + ((event.occurred_at at time zone coalesce(nullif(household.timezone, ''), 'Asia/Shanghai'))::date) as growth_date, + event.occurred_at as behavior_at + from private.learning_activity_events event + join public.learning_households household on household.id = event.household_id + where event.event_type = 'growth_activity_recorded' + and event.payload ->> 'source' = 'checkin' +) behaviors +group by behaviors.household_id, behaviors.growth_date; + +revoke all on private.learning_growth_days from public; +revoke all on private.learning_growth_days from anon; +revoke all on private.learning_growth_days from authenticated; + +-- 供聚合查询使用的索引(避免全表扫描) +create index if not exists learning_point_ledger_household_entry_idx + on public.learning_point_ledger (household_id, entry_type, occurred_on); + +-- 每内测批次家庭的 F0~F6 阶段与关键日期 +create or replace function private.learning_funnel_status() +returns table ( + household_id uuid, + batch text, + cohort_start timestamptz, + cohort_status text, + f0_activated boolean, + f1_point_item boolean, + f2_activated boolean, + f2_at date, + f2_within_72h boolean, + f3_reward_enabled boolean, + f4_first_redemption boolean, + f4_at timestamptz, + f5_behavior_after_redemption boolean, + f6_three_growth_days boolean, + growth_days_total integer, + growth_days_after_activation integer +) +language sql stable security definer +set search_path = public, private +as $$ +with cohort as ( + select + batch.household_id, + batch.batch, + coalesce(batch.joined_at, batch.invited_at) as cohort_start, + batch.status as cohort_status, + exists ( + select 1 from public.learning_profiles profile + where profile.household_id = batch.household_id + ) as has_profile, + exists ( + select 1 from public.learning_guardian_consents consent + where consent.household_id = batch.household_id + ) as has_consent + from private.learning_beta_batches batch +), +funnel as ( + select + cohort.household_id, + cohort.batch, + cohort.cohort_start, + cohort.cohort_status, + cohort.has_profile, + cohort.has_consent, + first_growth.f2_at, + redemption.f4_at + from cohort + left join lateral ( + select min(growth.growth_date) as f2_at + from private.learning_growth_days growth + where growth.household_id = cohort.household_id + ) first_growth on true + left join lateral ( + select min(redemption.fulfilled_at) as f4_at + from public.learning_redemptions redemption + where redemption.household_id = cohort.household_id + and redemption.status = 'fulfilled' + ) redemption on true +) +select + funnel.household_id, + funnel.batch, + funnel.cohort_start, + funnel.cohort_status, + (funnel.has_profile and funnel.has_consent) as f0_activated, + exists ( + select 1 from public.learning_point_items item + where item.household_id = funnel.household_id + and item.is_active + ) as f1_point_item, + funnel.f2_at is not null as f2_activated, + funnel.f2_at, + exists ( + select 1 from private.learning_growth_days growth + where growth.household_id = funnel.household_id + and growth.first_behavior_at <= funnel.cohort_start + interval '72 hours' + ) as f2_within_72h, + exists ( + select 1 + from public.learning_rewards reward + join public.learning_profile_rewards profile_reward + on profile_reward.reward_id = reward.id + and profile_reward.household_id = reward.household_id + where reward.household_id = funnel.household_id + and reward.is_active + and profile_reward.enabled + ) as f3_reward_enabled, + funnel.f4_at is not null as f4_first_redemption, + funnel.f4_at, + exists ( + select 1 from private.learning_growth_days growth + where growth.household_id = funnel.household_id + and funnel.f4_at is not null + and growth.first_behavior_at > funnel.f4_at + and growth.first_behavior_at <= funnel.f4_at + interval '7 days' + ) as f5_behavior_after_redemption, + (funnel.f2_at is not null and ( + select count(distinct growth.growth_date) >= 3 + from private.learning_growth_days growth + where growth.household_id = funnel.household_id + and growth.growth_date between funnel.f2_at and funnel.f2_at + 6 + )) as f6_three_growth_days, + ( + select count(distinct growth.growth_date)::integer + from private.learning_growth_days growth + where growth.household_id = funnel.household_id + ) as growth_days_total, + ( + select count(distinct growth.growth_date)::integer + from private.learning_growth_days growth + where growth.household_id = funnel.household_id + and funnel.f2_at is not null + and growth.growth_date between funnel.f2_at and funnel.f2_at + 6 + ) as growth_days_after_activation +from funnel; +$$; + +revoke all on function private.learning_funnel_status() from public; +revoke all on function private.learning_funnel_status() from anon; +revoke all on function private.learning_funnel_status() from authenticated; + +-- F0~F6 各阶段家庭数与比率(F0 为漏斗统一分母; +-- F5 分母=首次兑现且观察满 7 天,F6 分母=已激活且观察满 7 天) +create or replace function private.learning_funnel_report(p_as_of timestamptz default now()) +returns table (stage text, numerator bigint, denominator bigint, rate numeric) +language sql stable security definer +set search_path = public, private +as $$ +with status as ( + select s.*, household.timezone + from private.learning_funnel_status() s + join public.learning_households household on household.id = s.household_id +), +counts as ( + select + count(*) as cohort_n, + count(*) filter (where s.f0_activated) as f0_n + from status s +) +select stage, numerator, denominator, + case when denominator > 0 then round(numerator::numeric * 100 / denominator, 2) else null end as rate +from ( + select 'cohort'::text as stage, + c.cohort_n as numerator, + c.cohort_n as denominator + from counts c + union all + select 'f0_technical_activation', + c.f0_n, + c.cohort_n + from counts c + union all + select 'f1_usable_point_item', + (select count(*) from status s where s.f0_activated and s.f1_point_item), + c.f0_n + from counts c + union all + select 'f2_core_activation', + (select count(*) from status s where s.f0_activated and s.f2_activated), + c.f0_n + from counts c + union all + select 'f2_core_activation_within_72h', + (select count(*) from status s where s.f0_activated and s.f2_within_72h), + c.f0_n + from counts c + union all + select 'f3_reward_enabled', + (select count(*) from status s where s.f0_activated and s.f3_reward_enabled), + c.f0_n + from counts c + union all + select 'f4_first_redemption', + (select count(*) from status s where s.f0_activated and s.f4_first_redemption), + c.f0_n + from counts c + union all + select 'f5_behavior_after_redemption', + (select count(*) from status s + where s.f0_activated and s.f4_first_redemption + and s.f4_at + interval '7 days' <= p_as_of + and s.f5_behavior_after_redemption), + (select count(*) from status s + where s.f0_activated and s.f4_first_redemption + and s.f4_at + interval '7 days' <= p_as_of) + from counts c + union all + select 'f6_three_growth_days', + (select count(*) from status s + where s.f0_activated and s.f2_activated and s.f6_three_growth_days + and (p_as_of at time zone coalesce(nullif(s.timezone, ''), 'Asia/Shanghai'))::date >= s.f2_at + 6), + (select count(*) from status s + where s.f0_activated and s.f2_activated + and (p_as_of at time zone coalesce(nullif(s.timezone, ''), 'Asia/Shanghai'))::date >= s.f2_at + 6) + from counts c +) stages; +$$; + +revoke all on function private.learning_funnel_report(timestamptz) from public; +revoke all on function private.learning_funnel_report(timestamptz) from anon; +revoke all on function private.learning_funnel_report(timestamptz) from authenticated; + +-- WMGH(北极星):自然周内 >=3 个不同有效成长日的家庭,每周每家庭最多计 1 次。 +-- 自然周以家庭本地周一 00:00 开始;自然周结束 7 天后冻结该周报告。 +create or replace function private.learning_wmgh_weekly(p_as_of timestamptz default now()) +returns table ( + week_start date, + week_end date, + wmgh_household_count bigint, + cohort_household_count bigint +) +language sql stable security definer +set search_path = public, private +as $$ +with weekly as ( + select + growth.household_id, + growth.growth_date, + household.timezone, + (growth.growth_date - ((extract(dow from growth.growth_date)::int + 6) % 7)) as week_start + from private.learning_growth_days growth + join public.learning_households household on household.id = growth.household_id +), +frozen as ( + select household_id, growth_date, week_start + from weekly + where week_start + 13 <= (p_as_of at time zone coalesce(nullif(timezone, ''), 'Asia/Shanghai'))::date +), +qualified as ( + select growth.household_id, growth.week_start + from frozen growth + join private.learning_beta_batches batch on batch.household_id = growth.household_id + group by growth.household_id, growth.week_start + having count(distinct growth.growth_date) >= 3 +), +cohort_weeks as ( + select + batch.household_id, + household.timezone, + ((coalesce(batch.joined_at, batch.invited_at) at time zone coalesce(nullif(household.timezone, ''), 'Asia/Shanghai'))::date + - ((extract(dow from (coalesce(batch.joined_at, batch.invited_at) at time zone coalesce(nullif(household.timezone, ''), 'Asia/Shanghai'))::date)::int + 6) % 7)) as cohort_week_start + from private.learning_beta_batches batch + join public.learning_households household on household.id = batch.household_id +) +select + qualified.week_start, + qualified.week_start + 6 as week_end, + count(*)::bigint as wmgh_household_count, + ( + select count(*)::bigint + from cohort_weeks cw + where cw.cohort_week_start <= qualified.week_start + ) as cohort_household_count +from qualified +group by qualified.week_start +order by qualified.week_start; +$$; + +revoke all on function private.learning_wmgh_weekly(timestamptz) from public; +revoke all on function private.learning_wmgh_weekly(timestamptz) from anon; +revoke all on function private.learning_wmgh_weekly(timestamptz) from authenticated; diff --git a/supabase/migrations/20260816122000_growth_loop_activity_cleanup.sql b/supabase/migrations/20260816122000_growth_loop_activity_cleanup.sql new file mode 100644 index 0000000..1bf39bb --- /dev/null +++ b/supabase/migrations/20260816122000_growth_loop_activity_cleanup.sql @@ -0,0 +1,40 @@ +-- W5 / 交付项 3:activity_events 180 天自动清理。 +-- +-- private.learning_activity_events 是诊断事件流(payload 已被 allowlist 限制, +-- 不含儿童姓名/邮箱/自由文本/语音文本/完整错误堆栈/页面 URL),保留 180 天滚动窗口, +-- 到期直接删除(不保留原始文本,避免不可逆聚合泄露敏感内容)。 +-- 每日 04:00 UTC 由 pg_cron 执行;使用扩展检查 + 动态 SQL,无 pg_cron 的环境跳过, +-- 幂等(先删旧 job 再重建),与 learning_save_audit_prune 一致。 + +create or replace function private.learning_purge_activity_events(p_older_than interval default interval '180 days') +returns integer +language plpgsql security definer +set search_path = private +as $$ +declare + purged integer; +begin + delete from private.learning_activity_events + where received_at < now() - p_older_than; + get diagnostics purged = row_count; + return purged; +end; +$$; + +revoke all on function private.learning_purge_activity_events(interval) from public; +revoke all on function private.learning_purge_activity_events(interval) from anon; +revoke all on function private.learning_purge_activity_events(interval) from authenticated; + +do $$ +begin + if exists (select 1 from pg_extension where extname = 'pg_cron') then + execute 'delete from cron.job where jobname = ''shadow-mate-cleanup-activity-events'''; + execute format( + 'select cron.schedule(%L, %L, %L)', + 'shadow-mate-cleanup-activity-events', + '0 4 * * *', + 'select private.learning_purge_activity_events()' + ); + end if; +end; +$$; diff --git a/supabase/tests/growth_loop_beta_batches_test.sql b/supabase/tests/growth_loop_beta_batches_test.sql new file mode 100644 index 0000000..49e2051 --- /dev/null +++ b/supabase/tests/growth_loop_beta_batches_test.sql @@ -0,0 +1,104 @@ +-- W5 / 交付项 1:private 内测批次表(F0 分母)pgTAP 测试。 +-- +-- 验证:表存在、字段仅限边界合同(无邮箱/姓名/学习内容)、RLS、客户端无访问权、 +-- 家庭删除级联清理、状态与时间约束、postgres 内部可读写。 + +begin; +select plan(9); + +select is( + to_regclass('private.learning_beta_batches')::text, + 'private.learning_beta_batches', + 'private beta batch table exists' +); + +select is( + (select count(*) + from information_schema.columns + where table_schema = 'private' + and table_name = 'learning_beta_batches' + and column_name in ( + 'household_id', 'batch', 'status', 'invited_at', 'joined_at', 'updated_at' + )), + 6::bigint, + 'beta batch stores only bounded cohort fields (no emails/names/content)' +); + +select ok( + (select relrowsecurity from pg_class where oid = to_regclass('private.learning_beta_batches')), + 'beta batch table has RLS enabled' +); + +select ok( + coalesce((select not has_table_privilege('authenticated', 'private.learning_beta_batches', 'select,insert,update,delete')), false), + 'authenticated users cannot read or write the private batch table' +); + +select ok( + coalesce((select not has_table_privilege('anon', 'private.learning_beta_batches', 'select,insert,update,delete')), false), + 'anonymous users cannot read or write the private batch table' +); + +set local role postgres; + +insert into auth.users (id, email, encrypted_password, raw_user_meta_data) +values ( + '00000000-0000-4000-8000-00000000000a', + 'beta-batch-owner@example.test', + '$2a$10$test-password-hash', + '{}'::jsonb +); + +insert into public.learning_households (id, name, owner_user_id) +values ( + '00000000-aaaa-4000-8000-00000000000a', + '批次测试家庭', + '00000000-0000-4000-8000-00000000000a' +); + +insert into private.learning_beta_batches (household_id, batch, status, invited_at, joined_at) +values ( + '00000000-aaaa-4000-8000-00000000000a', + 'b1', + 'active', + now() - interval '10 days', + now() - interval '9 days' +); + +select lives_ok( + $$delete from public.learning_households + where id = '00000000-aaaa-4000-8000-00000000000a'$$, + 'household can be removed' +); + +select is( + (select count(*) from private.learning_beta_batches + where household_id = '00000000-aaaa-4000-8000-00000000000a'), + 0::bigint, + 'batch row cascades away with its household' +); + +insert into public.learning_households (id, name, owner_user_id) +values ( + '00000000-bbbb-4000-8000-00000000000a', + '批次约束测试家庭', + '00000000-0000-4000-8000-00000000000a' +); + +select throws_ok( + $$insert into private.learning_beta_batches (household_id, batch, status) + values ('00000000-bbbb-4000-8000-00000000000a', 'b1', 'bogus')$$, + '23514', + null, + 'invalid batch status is rejected' +); + +select throws_ok( + $$insert into private.learning_beta_batches (household_id, batch, invited_at, joined_at) + values ('00000000-bbbb-4000-8000-00000000000a', 'b1', now(), now() - interval '1 day')$$, + '23514', + null, + 'join before invite is rejected' +); + +rollback; diff --git a/supabase/tests/growth_loop_cleanup_test.sql b/supabase/tests/growth_loop_cleanup_test.sql new file mode 100644 index 0000000..362a742 --- /dev/null +++ b/supabase/tests/growth_loop_cleanup_test.sql @@ -0,0 +1,114 @@ +-- W5 / 交付项 3:activity_events 180 天自动清理 pgTAP 测试。 +-- +-- 验证:清理函数存在、客户端无执行权、180 天保留边界(179 天/正好 180 天保留, +-- 超过 180 天删除)、自定义保留窗口、pg_cron 任务已登记(无 pg_cron 环境跳过)。 + +begin; +select plan(10); + +select ok( + exists ( + select 1 + from pg_proc function + join pg_namespace namespace on namespace.oid = function.pronamespace + where namespace.nspname = 'private' + and function.proname = 'learning_purge_activity_events' + ), + 'purge function exists' +); + +select ok( + not has_function_privilege('authenticated', 'private.learning_purge_activity_events(interval)', 'execute'), + 'authenticated users cannot execute the purge' +); + +set local role postgres; + +insert into auth.users (id, email, encrypted_password, raw_user_meta_data) +values ( + '81000000-0000-4000-8000-000000000001', + 'cleanup-owner@example.test', + '$2a$10$test-password-hash', + '{}'::jsonb +); + +insert into public.learning_households (id, name, owner_user_id) +values ('82000000-0000-4000-8000-000000000001', '清理测试家庭', '81000000-0000-4000-8000-000000000001'); + +insert into public.learning_profiles (id, household_id, display_name, grade_level) +values ('83000000-0000-4000-8000-000000000001', '82000000-0000-4000-8000-000000000001', '清理孩子', 4); + +insert into private.learning_activity_events + (event_id, product_id, event_type, household_id, profile_id, occurred_at, timezone, payload, payload_hash, received_at) +values + ('84000000-0000-4000-8000-000000000001', 'shadow-mate', 'sync_failed', '82000000-0000-4000-8000-000000000001', '83000000-0000-4000-8000-000000000001', now() - interval '179 days', 'Asia/Shanghai', '{"error_code":"net"}'::jsonb, 'cleanup-h1', now() - interval '179 days'), + ('84000000-0000-4000-8000-000000000002', 'shadow-mate', 'sync_failed', '82000000-0000-4000-8000-000000000001', '83000000-0000-4000-8000-000000000001', now() - interval '180 days', 'Asia/Shanghai', '{"error_code":"net"}'::jsonb, 'cleanup-h2', now() - interval '180 days'), + ('84000000-0000-4000-8000-000000000003', 'shadow-mate', 'sync_failed', '82000000-0000-4000-8000-000000000001', '83000000-0000-4000-8000-000000000001', now() - interval '180 days 1 hour', 'Asia/Shanghai', '{"error_code":"net"}'::jsonb, 'cleanup-h3', now() - interval '180 days 1 hour'), + ('84000000-0000-4000-8000-000000000004', 'shadow-mate', 'sync_failed', '82000000-0000-4000-8000-000000000001', '83000000-0000-4000-8000-000000000001', now() - interval '200 days', 'Asia/Shanghai', '{"error_code":"net"}'::jsonb, 'cleanup-h4', now() - interval '200 days'), + ('84000000-0000-4000-8000-000000000005', 'shadow-mate', 'sync_failed', '82000000-0000-4000-8000-000000000001', '83000000-0000-4000-8000-000000000001', now() - interval '40 days', 'Asia/Shanghai', '{"error_code":"net"}'::jsonb, 'cleanup-h5', now() - interval '40 days'), + ('84000000-0000-4000-8000-000000000006', 'shadow-mate', 'sync_failed', '82000000-0000-4000-8000-000000000001', '83000000-0000-4000-8000-000000000001', now() - interval '20 days', 'Asia/Shanghai', '{"error_code":"net"}'::jsonb, 'cleanup-h6', now() - interval '20 days'); + +select lives_ok( + $$select private.learning_purge_activity_events()$$, + 'default 180-day purge runs' +); + +select is( + (select count(*) from private.learning_activity_events + where event_id = '84000000-0000-4000-8000-000000000001'), + 1::bigint, + 'event 179 days old is kept' +); + +select is( + (select count(*) from private.learning_activity_events + where event_id = '84000000-0000-4000-8000-000000000002'), + 1::bigint, + 'event exactly 180 days old is kept' +); + +select is( + (select count(*) from private.learning_activity_events + where event_id = '84000000-0000-4000-8000-000000000003'), + 0::bigint, + 'event older than 180 days is purged' +); + +select is( + (select count(*) from private.learning_activity_events + where event_id = '84000000-0000-4000-8000-000000000004'), + 0::bigint, + 'event 200 days old is purged' +); + +select is( + (select private.learning_purge_activity_events(interval '30 days')), + 3, + 'custom 30-day window purges the 40/180/179-day-old rows' +); + +select is( + (select count(*) from private.learning_activity_events + where event_id = '84000000-0000-4000-8000-000000000006'), + 1::bigint, + 'recent event is kept after a custom purge' +); + +-- cron.job 在无 pg_cron 环境不存在,直接用 CASE 会在解析期报错; +-- 用 plpgsql DO 块在运行时按扩展可用性判断(有 pg_cron 时任务必须恰好 1 个)。 +select lives_ok( +$job$ +do $$ +begin + if exists (select 1 from pg_extension where extname = 'pg_cron') + and to_regclass('cron.job') is not null then + if (select count(*) from cron.job where jobname = 'shadow-mate-cleanup-activity-events') <> 1 then + raise exception 'expected exactly 1 cleanup cron job'; + end if; + end if; +end $$; +$job$, +'cleanup cron job is scheduled when pg_cron is available' +); + +rollback; diff --git a/supabase/tests/growth_loop_funnel_test.sql b/supabase/tests/growth_loop_funnel_test.sql new file mode 100644 index 0000000..dc27cbc --- /dev/null +++ b/supabase/tests/growth_loop_funnel_test.sql @@ -0,0 +1,438 @@ +-- W5 / 交付项 2:F0~F6 聚合视图/函数 + WMGH pgTAP 测试。 +-- +-- 场景(共享 user,6 个内测批次家庭,均含档案+同意 => F0=6): +-- H1 完整漏斗:积分项 + 4 个有效成长日(-6/-2/-1/0)+ 奖励 + 已兑现兑换 + 兑现后行为 => F0~F6 全真 +-- H2 只有积分项:无成长行为 => F1 真,F2~F6 假 +-- H3 只有打卡 + 补记/修正/期初被排除:1 个有效成长日(来自 checkin),F2 真,F1/F3/F4/F6 假 +-- H4 进入内测 72h 内完成 F2:F2 真且 f2_within_72h 真 +-- H6 2 周前自然周内 3 个有效成长日 => F6 真,WMGH 计 1 +-- H7 3 周前自然周内仅 2 个有效成长日 => F6 假,WMGH 不计 + +begin; +select plan(43); + +-- 视图与函数存在性、客户端无访问权 +select is( + to_regclass('private.learning_growth_days')::text, + 'private.learning_growth_days', + 'private growth days view exists' +); + +select ok( + coalesce((select not has_table_privilege('authenticated', 'private.learning_growth_days', 'select,insert,update,delete')), false), + 'authenticated users cannot access the growth days view' +); + +select ok( + exists ( + select 1 + from pg_proc function + join pg_namespace namespace on namespace.oid = function.pronamespace + where namespace.nspname = 'private' + and function.proname = 'learning_funnel_status' + and function.pronargs = 0 + ), + 'funnel status function exists' +); + +select ok( + exists ( + select 1 + from pg_proc function + join pg_namespace namespace on namespace.oid = function.pronamespace + where namespace.nspname = 'private' + and function.proname = 'learning_funnel_report' + ), + 'funnel report function exists' +); + +select ok( + exists ( + select 1 + from pg_proc function + join pg_namespace namespace on namespace.oid = function.pronamespace + where namespace.nspname = 'private' + and function.proname = 'learning_wmgh_weekly' + ), + 'wmgh weekly function exists' +); + +select ok( + not has_function_privilege('authenticated', 'private.learning_funnel_status()', 'execute'), + 'authenticated users cannot execute the funnel status function' +); + +select ok( + not has_function_privilege('authenticated', 'private.learning_wmgh_weekly(timestamptz)', 'execute'), + 'authenticated users cannot execute the wmgh weekly function' +); + +select ok( + not has_function_privilege('authenticated', 'private.learning_funnel_report(timestamptz)', 'execute'), + 'authenticated users cannot execute the funnel report function' +); + +set local role postgres; + +create temp table wmgh_weeks as +select + (current_date - 14) - ((extract(dow from (current_date - 14))::int + 6) % 7) as ws, + (current_date - 21) - ((extract(dow from (current_date - 21))::int + 6) % 7) as ws3, + ((now() - interval '5 days') at time zone 'Asia/Shanghai')::date as checkin_day; + +insert into auth.users (id, email, encrypted_password, raw_user_meta_data) +values ( + '10000000-0000-4000-8000-000000000001', + 'funnel-owner@example.test', + '$2a$10$test-password-hash', + '{}'::jsonb +); + +insert into public.learning_households (id, name, owner_user_id) values + ('20000000-0000-4000-8000-000000000001', 'H1', '10000000-0000-4000-8000-000000000001'), + ('20000000-0000-4000-8000-000000000002', 'H2', '10000000-0000-4000-8000-000000000001'), + ('20000000-0000-4000-8000-000000000003', 'H3', '10000000-0000-4000-8000-000000000001'), + ('20000000-0000-4000-8000-000000000004', 'H4', '10000000-0000-4000-8000-000000000001'), + ('20000000-0000-4000-8000-000000000006', 'H6', '10000000-0000-4000-8000-000000000001'), + ('20000000-0000-4000-8000-000000000007', 'H7', '10000000-0000-4000-8000-000000000001'); + +insert into public.learning_profiles (id, household_id, display_name, grade_level) values + ('30000000-0000-4000-8000-000000000001', '20000000-0000-4000-8000-000000000001', 'P1', 4), + ('30000000-0000-4000-8000-000000000002', '20000000-0000-4000-8000-000000000002', 'P2', 4), + ('30000000-0000-4000-8000-000000000003', '20000000-0000-4000-8000-000000000003', 'P3', 4), + ('30000000-0000-4000-8000-000000000004', '20000000-0000-4000-8000-000000000004', 'P4', 4), + ('30000000-0000-4000-8000-000000000006', '20000000-0000-4000-8000-000000000006', 'P6', 4), + ('30000000-0000-4000-8000-000000000007', '20000000-0000-4000-8000-000000000007', 'P7', 4); + +insert into public.learning_guardian_consents (household_id, user_id, consent_type, policy_version) +select id, '10000000-0000-4000-8000-000000000001', 'learner_data_processing', 'privacy-v1' +from public.learning_households; + +insert into private.learning_beta_batches (household_id, batch, invited_at, joined_at) values + ('20000000-0000-4000-8000-000000000001', 'b1', now() - interval '30 days', now() - interval '29 days'), + ('20000000-0000-4000-8000-000000000002', 'b1', now() - interval '30 days', now() - interval '29 days'), + ('20000000-0000-4000-8000-000000000003', 'b1', now() - interval '30 days', now() - interval '29 days'), + ('20000000-0000-4000-8000-000000000004', 'b1', now() - interval '11 days', now() - interval '10 days'), + ('20000000-0000-4000-8000-000000000006', 'b1', now() - interval '41 days', now() - interval '40 days'), + ('20000000-0000-4000-8000-000000000007', 'b1', now() - interval '41 days', now() - interval '40 days'); + +-- 积分项目:H1 / H2 / H6 +insert into public.learning_point_items (id, household_id, name, default_points, is_active) values + ('40000000-0000-4000-8000-000000000001', '20000000-0000-4000-8000-000000000001', '阅读', 5, true), + ('40000000-0000-4000-8000-000000000002', '20000000-0000-4000-8000-000000000002', '阅读', 5, true), + ('40000000-0000-4000-8000-000000000006', '20000000-0000-4000-8000-000000000006', '阅读', 5, true); + +-- H1 有效成长日:-6(同日两条,去重)、-2、-1、今天 => 4 个有效成长日 +insert into public.learning_point_ledger + (household_id, profile_id, point_item_id, delta, entry_type, item_name_snapshot, request_id, occurred_on, created_at) +values + ('20000000-0000-4000-8000-000000000001', '30000000-0000-4000-8000-000000000001', '40000000-0000-4000-8000-000000000001', 5, 'manual', '阅读', gen_random_uuid(), current_date - 6, now() - interval '6 days'), + ('20000000-0000-4000-8000-000000000001', '30000000-0000-4000-8000-000000000001', '40000000-0000-4000-8000-000000000001', -3, 'manual', '运动', gen_random_uuid(), current_date - 6, now() - interval '6 days'), + ('20000000-0000-4000-8000-000000000001', '30000000-0000-4000-8000-000000000001', '40000000-0000-4000-8000-000000000001', 5, 'manual', '阅读', gen_random_uuid(), current_date - 2, now() - interval '2 days'), + ('20000000-0000-4000-8000-000000000001', '30000000-0000-4000-8000-000000000001', '40000000-0000-4000-8000-000000000001', 5, 'manual', '阅读', gen_random_uuid(), current_date - 1, now() - interval '1 day'), + ('20000000-0000-4000-8000-000000000001', '30000000-0000-4000-8000-000000000001', '40000000-0000-4000-8000-000000000001', 5, 'manual', '阅读', gen_random_uuid(), current_date, now()); + +-- H3 被排除的记录:补记(今天记录 20 天前的行为,created_at=now)、修正、期初积分;另有 1 条打卡事件 +insert into public.learning_point_ledger + (household_id, profile_id, point_item_id, delta, entry_type, item_name_snapshot, request_id, occurred_on, created_at) +values + ('20000000-0000-4000-8000-000000000003', '30000000-0000-4000-8000-000000000003', null, 5, 'manual', '阅读', gen_random_uuid(), current_date - 20, now()), + ('20000000-0000-4000-8000-000000000003', '30000000-0000-4000-8000-000000000003', null, -5, 'adjustment', '修正', gen_random_uuid(), current_date - 4, now() - interval '4 days'), + ('20000000-0000-4000-8000-000000000003', '30000000-0000-4000-8000-000000000003', null, 10, 'initial_balance', '期初', gen_random_uuid(), current_date - 3, now() - interval '3 days'); + +insert into private.learning_activity_events + (event_id, product_id, event_type, household_id, profile_id, occurred_at, timezone, payload, payload_hash, received_at) +values ( + '70000000-0000-4000-8000-000000000001', + 'shadow-mate', + 'growth_activity_recorded', + '20000000-0000-4000-8000-000000000003', + '30000000-0000-4000-8000-000000000003', + now() - interval '5 days', + 'Asia/Shanghai', + '{"source":"checkin"}'::jsonb, + 'funnel-test-hash', + now() - interval '5 days' +); + +-- H4 进入内测 72h 内完成 F2 +insert into public.learning_point_ledger + (household_id, profile_id, point_item_id, delta, entry_type, item_name_snapshot, request_id, occurred_on, created_at) +values ( + '20000000-0000-4000-8000-000000000004', '30000000-0000-4000-8000-000000000004', null, 5, 'manual', '阅读', + gen_random_uuid(), current_date - 9, now() - interval '9 days' +); + +-- H6 2 周前自然周内 3 个有效成长日 +insert into public.learning_point_ledger + (household_id, profile_id, point_item_id, delta, entry_type, item_name_snapshot, request_id, occurred_on, created_at) +select + '20000000-0000-4000-8000-000000000006', '30000000-0000-4000-8000-000000000006', '40000000-0000-4000-8000-000000000006', + 5, 'manual', '阅读', gen_random_uuid(), day, (day + time '10:00') at time zone 'Asia/Shanghai' +from (select (select ws from wmgh_weeks) as day + union all + select (select ws from wmgh_weeks) + 1 + union all + select (select ws from wmgh_weeks) + 2) days; + +-- H7 3 周前自然周内仅 2 个有效成长日 +insert into public.learning_point_ledger + (household_id, profile_id, point_item_id, delta, entry_type, item_name_snapshot, request_id, occurred_on, created_at) +select + '20000000-0000-4000-8000-000000000007', '30000000-0000-4000-8000-000000000007', null, + 5, 'manual', '阅读', gen_random_uuid(), day, (day + time '10:00') at time zone 'Asia/Shanghai' +from (select (select ws3 from wmgh_weeks) as day + union all + select (select ws3 from wmgh_weeks) + 1) days; + +-- H1 奖励 + 已兑现兑换 + 兑换后 7 天内的行为(-2/-1/0 天) +insert into public.learning_rewards (id, household_id, name, cost_points, reward_kind, category, is_active) +values ('50000000-0000-4000-8000-000000000001', '20000000-0000-4000-8000-000000000001', '奖励', 10, 'custom', 'family', true); + +insert into public.learning_profile_rewards (household_id, profile_id, reward_id, enabled) +values ('20000000-0000-4000-8000-000000000001', '30000000-0000-4000-8000-000000000001', '50000000-0000-4000-8000-000000000001', true); + +insert into public.learning_redemptions + (id, household_id, profile_id, reward_id, reward_name_snapshot, cost_points_snapshot, status, request_id, fulfilled_at) +values ( + '60000000-0000-4000-8000-000000000001', + '20000000-0000-4000-8000-000000000001', + '30000000-0000-4000-8000-000000000001', + '50000000-0000-4000-8000-000000000001', + '奖励', 10, 'fulfilled', gen_random_uuid(), now() - interval '3 days' +); + +-- ===== H1 完整漏斗 ===== +select is( + (select f0_activated from private.learning_funnel_status() where household_id = '20000000-0000-4000-8000-000000000001'), + true, + 'H1 is F0 (profile + consent)' +); + +select is( + (select f1_point_item from private.learning_funnel_status() where household_id = '20000000-0000-4000-8000-000000000001'), + true, + 'H1 has a usable point item (F1)' +); + +select is( + (select f2_at from private.learning_funnel_status() where household_id = '20000000-0000-4000-8000-000000000001'), + current_date - 6, + 'H1 F2 date is its first valid growth day' +); + +select is( + (select f2_within_72h from private.learning_funnel_status() where household_id = '20000000-0000-4000-8000-000000000001'), + false, + 'H1 did not activate within 72h of entering the beta' +); + +select is( + (select f3_reward_enabled from private.learning_funnel_status() where household_id = '20000000-0000-4000-8000-000000000001'), + true, + 'H1 created and enabled a reward (F3)' +); + +select is( + (select f4_first_redemption from private.learning_funnel_status() where household_id = '20000000-0000-4000-8000-000000000001'), + true, + 'H1 completed its first redemption (F4)' +); + +select is( + (select f5_behavior_after_redemption from private.learning_funnel_status() where household_id = '20000000-0000-4000-8000-000000000001'), + true, + 'H1 had valid behavior within 7 days of fulfillment (F5)' +); + +select is( + (select f6_three_growth_days from private.learning_funnel_status() where household_id = '20000000-0000-4000-8000-000000000001'), + true, + 'H1 has 3+ distinct growth days after activation (F6)' +); + +select is( + (select growth_days_total from private.learning_funnel_status() where household_id = '20000000-0000-4000-8000-000000000001'), + 4, + 'H1 same-day records dedupe into 4 distinct growth days' +); + +select is( + (select growth_days_after_activation from private.learning_funnel_status() where household_id = '20000000-0000-4000-8000-000000000001'), + 4, + 'H1 activation window contains 4 distinct growth days' +); + +-- ===== H2 只有积分项 ===== +select is( + (select f0_activated || '|' || f1_point_item || '|' || f2_activated + from private.learning_funnel_status() where household_id = '20000000-0000-4000-8000-000000000002'), + 'true|true|false', + 'H2 is F0+F1 only (no growth behavior)' +); + +select is( + (select f3_reward_enabled || '|' || f4_first_redemption || '|' || f6_three_growth_days || '|' || growth_days_total + from private.learning_funnel_status() where household_id = '20000000-0000-4000-8000-000000000002'), + 'false|false|false|0', + 'H2 has no reward, redemption, or growth days' +); + +-- ===== H3 打卡 + 排除规则 ===== +select is( + (select f2_at from private.learning_funnel_status() where household_id = '20000000-0000-4000-8000-000000000003'), + (select checkin_day from wmgh_weeks), + 'H3 F2 date comes from its check-in, not the 20-day-old backfill' +); + +select is( + (select growth_days_total from private.learning_funnel_status() where household_id = '20000000-0000-4000-8000-000000000003'), + 1, + 'H3 backfill/adjustment/initial-balance records are excluded' +); + +select is( + (select f1_point_item from private.learning_funnel_status() where household_id = '20000000-0000-4000-8000-000000000003'), + false, + 'H3 has no point item (F1 false)' +); + +select is( + (select f2_within_72h from private.learning_funnel_status() where household_id = '20000000-0000-4000-8000-000000000003'), + false, + 'H3 did not activate within 72h of entering the beta' +); + +-- ===== H4 72h 快速激活 ===== +select is( + (select f2_within_72h from private.learning_funnel_status() where household_id = '20000000-0000-4000-8000-000000000004'), + true, + 'H4 activated within 72h of entering the beta' +); + +select is( + (select f2_at from private.learning_funnel_status() where household_id = '20000000-0000-4000-8000-000000000004'), + current_date - 9, + 'H4 F2 date is its timely growth day' +); + +-- ===== H6 / H7 周内成长日阈值 ===== +select is( + (select f6_three_growth_days from private.learning_funnel_status() where household_id = '20000000-0000-4000-8000-000000000006'), + true, + 'H6 has 3 growth days in one week (F6 true)' +); + +select is( + (select growth_days_total from private.learning_funnel_status() where household_id = '20000000-0000-4000-8000-000000000006'), + 3, + 'H6 has exactly 3 distinct growth days' +); + +select is( + (select f6_three_growth_days from private.learning_funnel_status() where household_id = '20000000-0000-4000-8000-000000000007'), + false, + 'H7 has only 2 growth days in its week (F6 false)' +); + +select is( + (select growth_days_total from private.learning_funnel_status() where household_id = '20000000-0000-4000-8000-000000000007'), + 2, + 'H7 has exactly 2 distinct growth days' +); + +-- ===== 汇总报告(as_of = now + 10 天,使 H1 的 F5 观察满 7 天)===== +select is( + (select numerator || '|' || denominator from private.learning_funnel_report(p_as_of => now() + interval '10 days') + where stage = 'cohort'), + '6|6', + 'cohort counts all beta families' +); + +select is( + (select numerator from private.learning_funnel_report(p_as_of => now() + interval '10 days') + where stage = 'f0_technical_activation'), + 6::bigint, + 'F0 counts all families with profile + consent' +); + +select is( + (select numerator from private.learning_funnel_report(p_as_of => now() + interval '10 days') + where stage = 'f1_usable_point_item'), + 3::bigint, + 'F1 counts families with an active point item' +); + +select is( + (select numerator from private.learning_funnel_report(p_as_of => now() + interval '10 days') + where stage = 'f2_core_activation'), + 5::bigint, + 'F2 counts activated families' +); + +select is( + (select numerator from private.learning_funnel_report(p_as_of => now() + interval '10 days') + where stage = 'f2_core_activation_within_72h'), + 1::bigint, + '72h fast activation counts only H4' +); + +select is( + (select numerator from private.learning_funnel_report(p_as_of => now() + interval '10 days') + where stage = 'f3_reward_enabled'), + 1::bigint, + 'F3 counts families with an enabled reward' +); + +select is( + (select numerator from private.learning_funnel_report(p_as_of => now() + interval '10 days') + where stage = 'f4_first_redemption'), + 1::bigint, + 'F4 counts families with a first fulfilled redemption' +); + +select is( + (select numerator || '|' || denominator from private.learning_funnel_report(p_as_of => now() + interval '10 days') + where stage = 'f5_behavior_after_redemption'), + '1|1', + 'F5 counts observed families with behavior after first fulfillment' +); + +select is( + (select numerator || '|' || denominator from private.learning_funnel_report(p_as_of => now() + interval '10 days') + where stage = 'f6_three_growth_days'), + '2|5', + 'F6 counts activated families with 3+ growth days in the activation week' +); + +-- ===== WMGH(自然周冻结 + >=3 阈值)===== +select is( + (select wmgh_household_count from private.learning_wmgh_weekly() + where week_start = (select ws from wmgh_weeks)), + 1::bigint, + 'WMGH counts H6 once for its frozen 3-growth-day week' +); + +select is( + (select count(*) from private.learning_wmgh_weekly() + where week_start = (select ws3 from wmgh_weeks)), + 0::bigint, + 'WMGH excludes H7 with only 2 growth days in a week' +); + +select is( + (select count(*) from private.learning_wmgh_weekly( + p_as_of => ((select ws from wmgh_weeks) + 12) at time zone 'Asia/Shanghai') + where week_start = (select ws from wmgh_weeks)), + 0::bigint, + 'WMGH week is not reported before the 7-day freeze' +); + +select is( + (select count(*) from private.learning_wmgh_weekly() + where week_start = current_date - ((extract(dow from current_date)::int + 6) % 7)), + 0::bigint, + 'WMGH does not report the unfrozen current week' +); + +rollback; From 77ba894ea18b6a5e44a127a856ee41b27acbdffc Mon Sep 17 00:00:00 2001 From: WardLu Date: Thu, 20 Aug 2026 03:30:21 +0800 Subject: [PATCH 2/2] fix(growth-loop): address SHA-8 review findings --- PRIVACY.md | 118 ++++-- README.md | 2 +- docs/architecture.md | 4 +- docs/privacy-policy-publishing.md | 11 +- docs/user-guide.md | 2 +- privacy-policy.html | 50 ++- scripts/check.mjs | 58 +++ src/cloud.js | 7 +- src/learning-analytics.js | 2 +- src/learning-export.js | 5 +- ...0260816120000_growth_loop_beta_batches.sql | 30 -- ...816122000_growth_loop_activity_cleanup.sql | 40 -- ...0260820120000_growth_loop_beta_batches.sql | 86 +++++ ...121000_growth_loop_funnel_aggregation.sql} | 69 +++- ...820122000_growth_loop_activity_cleanup.sql | 353 ++++++++++++++++++ .../growth_loop_activity_events_test.sql | 182 ++++++++- supabase/tests/growth_loop_cleanup_test.sql | 44 ++- supabase/tests/growth_loop_funnel_test.sql | 189 +++++++++- supabase/tests/learning_rls_test.sql | 58 ++- tests/e2e/cloud.spec.js | 4 + tests/unit/learning-analytics.test.js | 1 + tests/unit/learning-export.test.js | 18 +- 22 files changed, 1163 insertions(+), 170 deletions(-) delete mode 100644 supabase/migrations/20260816120000_growth_loop_beta_batches.sql delete mode 100644 supabase/migrations/20260816122000_growth_loop_activity_cleanup.sql create mode 100644 supabase/migrations/20260820120000_growth_loop_beta_batches.sql rename supabase/migrations/{20260816121000_growth_loop_funnel_aggregation.sql => 20260820121000_growth_loop_funnel_aggregation.sql} (81%) create mode 100644 supabase/migrations/20260820122000_growth_loop_activity_cleanup.sql diff --git a/PRIVACY.md b/PRIVACY.md index 37abd9d..9c86f32 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,51 +1,115 @@ -# 影伴隐私说明 +# 影伴隐私说明 / Shadow Mate Privacy Policy -线上展示版:[sm.shadow.wang/privacy](https://sm.shadow.wang/privacy),包含中文和 English 两个版本。仓库根目录的 `privacy-policy.html` 是发布源文件;它会随 Vercel 构建输出为 `dist/privacy.html`,由生产站点直接提供。 +当前版本:`privacy-v2` -影伴面向家庭和未成年学习者,默认遵循数据最小化原则。本文件描述当前开源版本的技术行为。 +生效日期:2026 年 8 月 20 日 / August 20, 2026 -## 收集和保存的数据 +线上展示版:[sm.shadow.wang/privacy](https://sm.shadow.wang/privacy) + +仓库根目录的 `privacy-policy.html` 是中英文线上发布源文件。本文件描述同一数据边界;两者必须同步更新。 + +## 中文 + +影伴面向家庭和未成年学习者,默认遵循数据最小化原则。本说明描述当前 Dogfooding 和小规模内测版本的技术行为。 + +### 收集和保存的数据 - 家长用于登录的邮箱,由 Supabase Auth 处理。 - 家庭空间名称。 - 学习者显示名称和年级。建议使用昵称,不填写真实姓名。 -- 打卡、积分、书架和阅读日志等学习状态。 +- 打卡、积分、书架、阅读日志、奖励和兑换等学习状态。 +- 私有后端活动事件:产品标识、随机事件 ID、内部家庭和学习者 ID、协议允许的事件类型、事件发生和服务端接收时间、家庭时区、客户端版本、操作用户 ID,以及少量有类型和长度限制的枚举、布尔值或计数诊断字段。事件类型仅用于家庭启用、学习者创建、核心激活、有效成长行为、留存达标、奖励兑现、同步失败和本地朗读失败。 + +后端活动事件只用于内测漏斗、留存和连续使用统计,以及同步和本地朗读故障诊断。它们不接受自由文本、完整错误堆栈、页面 URL、邮箱、儿童显示名称、学习内容或语音文本;业务事实仍以积分、奖励和兑换等业务表为准。 + +当前版本不要求儿童提供邮箱、手机号、生日、学校、地址、精确位置或照片,也不包含广告。 + +### Vercel Analytics 与后端活动事件 -当前版本不要求儿童提供邮箱、手机号、生日、学校、地址、精确位置或照片,也不包含广告。应用通过 `@vercel/analytics` 使用 Vercel Web Analytics 记录匿名、聚合的页面访问数据;当前没有自定义事件,也不把学习状态、邮箱或儿童显示名称作为 Analytics 自定义字段发送。Vercel 文档说明页面访问数据可能包含时间、页面 URL、来源、设备/浏览器/操作系统和粗略地理位置;其设计不使用第三方 Cookie,也不跨站识别访客。使用 Vercel Analytics 的数据会发送到 Vercel 服务器,详情见 [Vercel Web Analytics Privacy and Compliance](https://vercel.com/docs/analytics/privacy-policy)。正式对外运营前仍需重新审查 URL、Referrer-Policy、家长同意和数据处理条款。 +应用通过 `@vercel/analytics` 使用 Vercel Web Analytics 记录匿名、聚合的页面访问数据。当前不向 Vercel Analytics 发送自定义事件,也不把学习状态、邮箱或儿童显示名称作为 Analytics 自定义字段发送。Vercel 页面访问数据可能包含时间、页面 URL、来源、设备、浏览器、操作系统和粗略地理位置;详情见 [Vercel Web Analytics Privacy and Compliance](https://vercel.com/docs/analytics/privacy-policy)。 + +“不向 Vercel Analytics 发送自定义事件”不表示影伴后端完全不记录事件。上节所述后端活动事件保存在 Shadow Mate 的私有 Supabase schema 中,与 Vercel Analytics 分开处理和保留。 当前版本的本地 Piper 朗读不把文本发送到影伴服务器。影伴不采集麦克风录音。 -## 家长同意和学习者档案 +### 家长同意和学习者档案 -学习者不是独立登录账号。创建第一个学习者或添加学习者前,登录用户必须确认自己是家长或监护人,并阅读本隐私说明。系统会在 Supabase 数据库记录以下同意审计字段:家庭 ID、认证用户 ID、同意类型 `learner_data_processing`、隐私说明版本 `privacy-v1` 和数据库生成的同意时间。客户端不能修改同意时间戳,也不能在没有同意记录的情况下通过公开 API 创建新的学习者档案。 +学习者不是独立登录账号。创建第一个学习者或添加学习者前,登录用户必须确认自己是家长或监护人,并阅读本说明。系统会记录家庭 ID、认证用户 ID、同意类型 `learner_data_processing`、隐私说明版本和数据库生成的同意时间。客户端不能修改同意时间戳,也不能在没有有效同意记录时通过公开 API 创建新的学习者档案。 -当前实现是“认证账号 + 家长/监护人确认 + 服务端审计记录”。创建学习者前,登录用户需要确认自己是家长或监护人并阅读本隐私说明。 +`privacy-v2` 明确披露私有后端活动事件、180 天保留期和导出边界。新同意记录使用 `privacy-v2`。已有 `privacy-v1` 记录继续作为有效的历史同意,不改写原始同意时间,也不要求仅因本次说明更新而重新确认。 -## 数据存放位置 +### 数据存放、隔离和访问 -- 离线学习状态保存在当前设备的浏览器存储中。 +- 离线学习状态保存在当前设备的浏览器 `localStorage` 中;登录会话保存在 `sessionStorage` 中。 - 登录后,家庭和学习状态同步到项目配置的 Supabase 数据库。 -- 登录会话使用浏览器会话存储;关闭对应浏览器会话后需要重新登录。 +- 学习数据按家庭隔离。匿名访问没有学习表权限;登录用户仍必须通过 `project_id = 'shadow-mate'`、家庭成员关系和 RLS 才能读取或修改记录。 +- 后端活动事件和内测批次记录位于私有 schema。浏览器、普通登录用户和家庭成员不能直接读取这些表或执行聚合/清理函数;活动事件只能由 owner/guardian 通过受控 RPC 写入协议允许的字段,受信运维角色才能读取或清理。 + +本机数据通常会保留到用户清除网站数据、使用隐私/无痕窗口、浏览器或系统自动清理,或更换访问域名。`localStorage` 是离线缓存,不应作为唯一备份;登录并同步后,云端家庭记录才是跨设备恢复来源。 -### 本机存储细节 +### 删除、导出和保留 -- 学习记录保存在浏览器的 `localStorage` 中,键名为 `shadow_mate_workbench_v1`;当前选择的孩子使用 `shadow_mate_active_profile` 保存。 -- 这些数据通常会在关闭浏览器后继续保留,直到用户清除该网站的浏览器数据、使用隐私/无痕窗口、浏览器或系统自动清理,或更换访问域名(每个域名都是独立的存储空间)。 -- `localStorage` 是离线缓存,不应当作为唯一备份。登录并同步后,云端家庭记录才是跨设备恢复来源;浏览器清理本机数据不会删除云端记录。 -- 登录会话保存在当前浏览器会话的 `sessionStorage` 中,因此重新打开新的浏览器会话可能需要再次点击邮件链接登录。 +- “清除本机数据”只删除当前设备的离线学习记录并退出登录,不删除云端记录。 +- 家庭 JSON 导出是可移植的家庭业务数据副本,包含家庭 ID/名称、学习者档案和状态、同意记录,以及 Growth Loop 的积分项目、学习者绑定、奖励、积分流水和兑换记录。 +- 导出不包含 Supabase Auth 身份或邮箱、会话/设备数据、Vercel Analytics 数据、私有后端原始活动事件 `private.learning_activity_events`,也不包含私有内测批次记录 `private.learning_beta_batches`。这些 server-only 记录不属于可移植的家庭业务历史。 +- 原始后端活动事件从服务端 `received_at` 起保留 180 天;超过 180 天的记录由受信清理任务删除。家庭或学习者删除时,关联活动事件会通过数据库外键级联删除,不等待保留期结束。 +- 内测批次记录随家庭删除级联删除。家庭所有者使用“删除全部家庭数据”时,家庭业务数据、同意记录、关联活动事件和内测批次记录都由同一家庭删除路径覆盖。 +- 共享 Supabase 项目中的家庭数据删除不会删除 Supabase Auth 身份;用户仍可使用同一邮箱重新登录。身份删除只在专用、隔离且经过服务端授权的账号删除流程中启用。 +- 当前没有独立的“撤回同意但保留家庭”自助流程。 -## 隔离和访问 +### 安全问题 -学习数据按家庭隔离。匿名访问没有学习表权限;登录用户仍必须通过家庭成员关系和 RLS 才能读取或修改记录。Supabase publishable key 是公开客户端标识,不是数据访问授权。 +不要在公开 Issue 中提交个人数据或安全漏洞。请使用仓库的私密漏洞报告功能,流程见 [SECURITY.md](SECURITY.md)。 -## 删除和保留 +## English -- 账号面板的“清除本机数据”只删除当前设备的离线学习记录并退出登录,不删除云端记录。 -- 家庭所有者可以从账号面板导出完整家庭 JSON 数据,也可以使用“删除全部家庭数据”删除当前家庭的云端记录、清理本机数据并退出登录。 -- 共享 Supabase 项目中的家庭数据删除不会删除 Supabase Auth 身份;用户仍可使用同一邮箱重新登录。身份删除只在专用、隔离的 Supabase 项目配置中启用。 -- 删除学习者或家庭时,同意记录随家庭或学习者所属家庭级联删除;当前没有独立的“撤回同意但保留家庭”的自助流程。 -- 数据保留期限、删除/导出/更正和撤回请求会根据产品运营地区和适用法律持续更新。 +Shadow Mate is designed for families and learners who may be minors. We apply data minimization by default. This policy describes the current Dogfooding and small-scale beta implementation. -## 安全事件 +### Data We Collect and Store -不要在公开 Issue 中提交个人数据或安全漏洞。请使用仓库的私密漏洞报告功能,流程见 [SECURITY.md](SECURITY.md)。 +- A parent's sign-in email, handled by Supabase Auth. +- The household space name. +- A learner display name and grade. We recommend a nickname rather than a real name. +- Learning state such as check-ins, points, bookshelf and reading logs, rewards, and redemptions. +- Private backend activity events: product identifier, random event ID, internal household and learner identifiers, an allowlisted event type, occurrence and server receipt timestamps, household timezone, client version, actor user ID, and a small typed and length-bounded set of enum, boolean, or count diagnostic fields. Event types are limited to household activation, learner creation, core activation, effective growth activity, retention qualification, reward redemption, sync failure, and local text-to-speech failure. + +Backend activity events are used only for beta funnel, retention, and sustained-use metrics, and for sync and local text-to-speech diagnostics. They do not accept free text, full error stacks, page URLs, email addresses, learner display names, learning content, or speech text. Product facts remain in the point, reward, and redemption records. + +The current version does not require a child to provide an email address, phone number, birthday, school, address, precise location, or photo. It does not contain advertising. + +### Vercel Analytics and Backend Activity Events + +The app uses Vercel Web Analytics for anonymous, aggregated page-visit data. It currently sends no custom events to Vercel Analytics and does not send learning state, email addresses, or learner display names as custom Analytics fields. Vercel page-visit data may include time, page URL, referrer, device, browser, operating system, and approximate location. See [Vercel Web Analytics Privacy and Compliance](https://vercel.com/docs/analytics/privacy-policy). + +“No custom events sent to Vercel Analytics” does not mean the Shadow Mate backend records no events. The private backend activity events described above are stored in Shadow Mate's private Supabase schema and have separate access and retention rules. + +The local Piper text-to-speech feature does not send text to Shadow Mate servers. Shadow Mate does not record microphone audio. + +### Parental Consent and Learner Profiles + +A learner is not an independent login account. Before creating or adding a learner, the signed-in user must confirm that they are the child's parent or guardian and read this policy. The system stores the household ID, authenticated user ID, consent type `learner_data_processing`, policy version, and a database-generated consent timestamp. The client cannot change the timestamp, and the public API cannot create a learner profile without a valid consent record. + +`privacy-v2` expressly documents private backend activity events, their 180-day retention, and the export boundary. New consent records use `privacy-v2`. Existing `privacy-v1` records remain valid historical consent; their original timestamps are not rewritten, and this policy update alone does not require a new confirmation. + +### Storage, Isolation, and Access + +- Offline learning state is stored in the current browser's `localStorage`; the sign-in session uses `sessionStorage`. +- After sign-in, household and learning state sync to the configured Supabase database. +- Learning data is isolated by household. Anonymous users have no learning-table access. Signed-in users must still pass `project_id = 'shadow-mate'`, household membership, and RLS checks. +- Backend activity events and beta batch records are in a private schema. Browsers, ordinary authenticated users, and household members cannot directly read those tables or execute aggregation or cleanup functions. An owner or guardian may write only allowlisted event fields through a guarded RPC; trusted operations roles may read or purge them. + +Local data normally remains until site data is cleared, a private/incognito session is used, the browser or operating system cleans it up, or the domain changes. `localStorage` is an offline cache and should not be the only backup. After synchronization, the cloud household record is the source for cross-device recovery. + +### Deletion, Export, and Retention + +- “Clear local data” removes only offline records on the current device and signs the user out; it does not delete cloud records. +- The household JSON export is a portable copy of household business data. It includes household ID/name, learner profiles and state, consent records, and Growth Loop point items, learner bindings, rewards, point ledger, and redemption records. +- The export excludes the Supabase Auth identity or email, session/device data, Vercel Analytics data, raw private backend activity events in `private.learning_activity_events`, and private beta batch records in `private.learning_beta_batches`. Those server-only records are not portable household business history. +- Raw backend activity events are retained for 180 days from server `received_at`; trusted cleanup deletes records older than 180 days. Deleting a household or learner cascades to its activity events without waiting for the retention period. +- Beta batch records cascade when the household is deleted. “Delete all household data” covers household business data, consent records, related activity events, and beta batch records through the same household deletion path. +- Deleting household data in the shared Supabase project does not delete the Supabase Auth identity. Identity deletion is available only in a dedicated, isolated, server-authorized account deletion flow. +- There is currently no self-service flow to withdraw consent while keeping the household. + +### Security Issues + +Do not submit personal data or security vulnerabilities in public issues. Use the repository's private vulnerability reporting process described in [SECURITY.md](SECURITY.md). diff --git a/README.md b/README.md index 81cbb27..397aceb 100644 --- a/README.md +++ b/README.md @@ -183,7 +183,7 @@ tests/e2e/ 离线、云端和数据生命周期测试 ## 当前边界 -影伴当前仓库版本为 v1.3.9,生产地址为 [sm.shadow.wang](https://sm.shadow.wang/)。它是面向家庭的开源 PWA,不包含广告;当前通过 [Vercel Web Analytics](https://vercel.com/docs/analytics/privacy-policy) 记录匿名、聚合的页面访问数据,也没有儿童独立账号体系。数据范围和删除方式见 [隐私说明](https://sm.shadow.wang/privacy),安全问题请按 [安全政策](SECURITY.md) 私下报告。 +影伴当前仓库版本为 v1.3.9,生产地址为 [sm.shadow.wang](https://sm.shadow.wang/)。它是面向家庭的开源 PWA,不包含广告,也没有儿童独立账号体系。当前通过 [Vercel Web Analytics](https://vercel.com/docs/analytics/privacy-policy) 记录匿名、聚合的页面访问数据;另有与 Vercel 分离、不可由普通用户读取的私有后端活动事件,用于内测指标和有限故障诊断,原始记录保留 180 天。具体字段、导出边界和删除方式见 [隐私说明](https://sm.shadow.wang/privacy),安全问题请按 [安全政策](SECURITY.md) 私下报告。 ### 英语发音 diff --git a/docs/architecture.md b/docs/architecture.md index a277481..a8552e2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -124,7 +124,7 @@ erDiagram 限制与演进条件: -- 如果需要排行榜、日级报表或推荐模型,应新增 append-only `learning_activity_events`; +- Growth Loop 使用 private、append-only 的 `learning_activity_events` 记录有界后端事件,用于内测漏斗和故障诊断;它不保存自由文本或学习内容,普通用户不可读取,原始记录按 `received_at` 保留 180 天。排行榜、推荐模型等新用途仍需另行评审; - 如果内容编辑频繁或需要付费授权,应新增内容集合、内容项和 entitlement 表; - 如果状态接近 1 MB、冲突频率升高或查询需要跨用户聚合,应将相关字段拆表; - 不直接删除 JSONB 状态;先双写、回填、验证,再切换读取。 @@ -146,7 +146,7 @@ erDiagram - 创建家庭时 `owner_user_id` 必须等于 `auth.uid()`。 - 家庭 owner 只能为自己创建初始 owner membership。 - owner/guardian 可创建和修改 learner profile/state。 -- 创建 learner profile 还必须存在当前 owner/guardian 的 `privacy-v1` 确认记录;记录只允许服务端默认时间戳写入,客户端无更新/删除权限。 +- 创建 learner profile 还必须存在当前 owner/guardian 的有效 `privacy-v1` 或 `privacy-v2` 确认记录;新确认写入 `privacy-v2`,历史 `privacy-v1` 继续有效。记录只允许服务端默认时间戳写入,客户端无更新/删除权限。 - `learning_save_state` 是 `SECURITY INVOKER`,不会绕过 RLS。 - `learning_is_household_owner` 是唯一的 `SECURITY DEFINER` 授权辅助函数:固定空 `search_path`、仅返回当前用户是否为指定家庭 owner、仅授予 `authenticated` 执行权,用于打断 household 与 membership 策略之间的递归。 - 更新策略同时具有 `USING` 与 `WITH CHECK`。 diff --git a/docs/privacy-policy-publishing.md b/docs/privacy-policy-publishing.md index bcc2502..b911422 100644 --- a/docs/privacy-policy-publishing.md +++ b/docs/privacy-policy-publishing.md @@ -37,4 +37,13 @@ http://localhost:5173/privacy/ 响应头应包含 `content-type: text/html`,页面应显示中文标题、品牌首屏、中英文内容和移动端布局。 -如果只是修订视觉样式或文字,保持 `privacy-v1`;如果收集范围、同意机制或处理目的发生实质变化,必须升级版本并按隐私同意数据库迁移流程处理,不能只覆盖 HTML。 +如果只是修订视觉样式或不改变含义的文字,可以保持当前隐私版本;如果收集范围、同意机制、处理目的、保留期或导出边界发生实质变化,必须升级版本并按隐私同意数据库迁移流程处理,不能只覆盖 HTML。 + +## privacy-v2 迁移说明 + +`privacy-v2` 于 2026-08-20 增加私有后端活动事件、180 天保留期和 server-only 导出边界说明。数据库提案 `20260820120000_growth_loop_beta_batches.sql` 将允许版本扩展为 `privacy-v1` / `privacy-v2`,并同步更新创建同意和学习者的 RLS 条件: + +- 新客户端只写入 `privacy-v2`。 +- 已有 `privacy-v1` 记录保留原始版本和时间戳,并继续满足学习者创建前的有效同意检查。 +- 客户端读取两个受支持版本,不能把历史家庭误判为未同意,也不自动补写或改写同意记录。 +- 任何进入共享 Supabase 的 canonical migration 仍必须由 Shadow Portal 控制面审批和执行;产品仓库只提交 proposal。 diff --git a/docs/user-guide.md b/docs/user-guide.md index 32e1695..719d510 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -60,7 +60,7 @@ MacBook 与无 GMS Android 的听感可能不同:前者通常优先使用 macO - 学习记录按孩子分别同步,避免多个孩子的打卡、积分和绘本记录混在一起。 - 家庭空间显示所有孩子记录中的最近同步时间。 - 切换孩子后,页面会加载该孩子对应的学习记录。 -- 家庭所有者可以在账号面板导出完整家庭 JSON 数据。 +- 家庭所有者可以在账号面板导出家庭业务 JSON(家庭/学习者状态、同意、积分和奖励记录);私有后端活动事件和内测批次记录不在导出范围内,但会在删除家庭时一并级联删除。 - “删除全部家庭数据”会删除当前家庭的云端学习数据、清理本机数据并退出登录;共享 Supabase 项目中的 Auth 身份不会被删除,之后仍可用同一邮箱登录。 ## 共享密码 diff --git a/privacy-policy.html b/privacy-policy.html index 4c44366..cf914ed 100644 --- a/privacy-policy.html +++ b/privacy-policy.html @@ -49,7 +49,7 @@

把成长留在
值得信任的地方

影伴隐私说明

-

产品:影伴 Shadow Mate · 当前版本:privacy-v1 · 生效日期:2026 年 8 月 12 日

+

产品:影伴 Shadow Mate · 当前版本:privacy-v2 · 生效日期:2026 年 8 月 20 日

影伴面向家庭和未成年学习者,默认遵循数据最小化原则。本说明描述当前 Dogfooding 和小规模内测版本的技术行为。

@@ -58,34 +58,42 @@

我们收集和保存什么

  • 家长用于登录的邮箱,由 Supabase Auth 处理。
  • 家庭空间名称。
  • 学习者显示名称和年级。建议使用昵称,不填写真实姓名。
  • -
  • 打卡、积分、书架和阅读日志等学习状态。
  • +
  • 打卡、积分、书架、阅读日志、奖励和兑换等学习状态。
  • +
  • 私有后端活动事件:产品标识、随机事件 ID、内部家庭和学习者 ID、协议允许的事件类型、事件发生和服务端接收时间、家庭时区、客户端版本、操作用户 ID,以及少量有类型和长度限制的枚举、布尔值或计数诊断字段。
  • 当前版本不要求儿童提供邮箱、手机号、生日、学校、地址、精确位置或照片,也不包含广告。

    +

    后端活动事件只用于内测漏斗、留存和连续使用统计,以及同步和本地朗读故障诊断。事件类型仅限家庭启用、学习者创建、核心激活、有效成长行为、留存达标、奖励兑现、同步失败和本地朗读失败。它们不接受自由文本、完整错误堆栈、页面 URL、邮箱、儿童显示名称、学习内容或语音文本;业务事实仍以积分、奖励和兑换等业务表为准。

    -

    分析服务和朗读功能

    -

    应用通过 Vercel Web Analytics 记录匿名、聚合的页面访问数据;当前没有自定义事件,也不把学习状态、邮箱或儿童显示名称作为 Analytics 自定义字段发送。页面访问数据可能包含时间、页面 URL、来源、设备、浏览器、操作系统和粗略地理位置。详情见 Vercel Web Analytics Privacy and Compliance

    +

    Vercel Analytics、后端活动事件和朗读功能

    +

    应用通过 Vercel Web Analytics 记录匿名、聚合的页面访问数据;当前不向 Vercel Analytics 发送自定义事件,也不把学习状态、邮箱或儿童显示名称作为 Analytics 自定义字段发送。页面访问数据可能包含时间、页面 URL、来源、设备、浏览器、操作系统和粗略地理位置。详情见 Vercel Web Analytics Privacy and Compliance

    +

    “不向 Vercel Analytics 发送自定义事件”不表示影伴后端完全不记录事件。上述后端活动事件保存在 Shadow Mate 的私有 Supabase schema 中,与 Vercel Analytics 分开处理和保留。

    当前版本的本地 Piper 朗读不把文本发送到影伴服务器。影伴不采集麦克风录音。

    家长同意和学习者档案

    -

    学习者不是独立登录账号。创建第一个学习者或添加学习者前,登录用户必须确认自己是家长或监护人,并阅读本隐私说明。系统会在 Supabase 数据库记录家庭 ID、认证用户 ID、同意类型 learner_data_processing、隐私说明版本 privacy-v1 和数据库生成的同意时间。客户端不能修改同意时间戳,也不能在没有同意记录的情况下通过公开 API 创建新的学习者档案。

    +

    学习者不是独立登录账号。创建第一个学习者或添加学习者前,登录用户必须确认自己是家长或监护人,并阅读本隐私说明。系统会在 Supabase 数据库记录家庭 ID、认证用户 ID、同意类型 learner_data_processing、隐私说明版本和数据库生成的同意时间。客户端不能修改同意时间戳,也不能在没有有效同意记录的情况下通过公开 API 创建新的学习者档案。

    +

    privacy-v2 明确披露私有后端活动事件、180 天保留期和导出边界。新同意记录使用 privacy-v2;已有 privacy-v1 记录继续作为有效的历史同意,不改写原始同意时间,也不要求仅因本次说明更新而重新确认。

    数据存放和访问

    • 离线学习状态保存在当前设备的浏览器存储中。
    • 登录后,家庭和学习状态同步到项目配置的 Supabase 数据库。
    • 登录会话使用浏览器会话存储;关闭对应浏览器会话后需要重新登录。
    • -
    • 学习数据按家庭隔离,登录用户仍必须通过家庭成员关系和 RLS 才能读取或修改记录。
    • +
    • 学习数据按家庭隔离,登录用户仍必须通过 project_id = 'shadow-mate'、家庭成员关系和 RLS 才能读取或修改记录。
    • +
    • 后端活动事件和内测批次记录位于私有 schema。浏览器、普通登录用户和家庭成员不能直接读取这些表或执行聚合/清理函数;owner/guardian 只能通过受控 RPC 写入协议允许的事件字段。

    本机缓存通常会在关闭浏览器后继续保留,直到用户清除该网站的浏览器数据、使用隐私/无痕窗口、浏览器或系统自动清理,或更换访问域名。登录并同步后,云端家庭记录才是跨设备恢复来源。

    删除、导出和保留

    • “清除本机数据”只删除当前设备的离线学习记录并退出登录,不删除云端记录。
    • -
    • 家庭所有者可以从账号面板导出完整家庭 JSON 数据,也可以使用“删除全部家庭数据”删除当前家庭的云端记录、清理本机数据并退出登录。
    • +
    • 家庭 JSON 导出包含家庭 ID/名称、学习者档案和状态、同意记录,以及 Growth Loop 的积分项目、学习者绑定、奖励、积分流水和兑换记录。
    • +
    • 导出不包含 Supabase Auth 身份或邮箱、会话/设备数据、Vercel Analytics 数据、私有后端原始活动事件 private.learning_activity_events,也不包含私有内测批次记录 private.learning_beta_batches
    • +
    • 原始后端活动事件从服务端 received_at 起保留 180 天;超过 180 天的记录由受信清理任务删除。删除家庭或学习者时,关联活动事件通过外键级联删除,不等待保留期结束。
    • +
    • 内测批次记录随家庭删除级联删除。“删除全部家庭数据”覆盖家庭业务数据、同意记录、关联活动事件和内测批次记录,并清理本机数据和退出登录。
    • 共享 Supabase 项目中的家庭数据删除不会删除 Supabase Auth 身份;用户仍可使用同一邮箱重新登录。
    • 删除学习者或家庭时,同意记录随家庭或学习者所属家庭级联删除。
    -

    当前没有独立的“撤回同意但保留家庭”的自助流程。数据保留期限、删除、导出、更正和撤回请求会根据产品运营地区和适用法律持续更新。

    +

    当前没有独立的“撤回同意但保留家庭”的自助流程。身份删除只在专用、隔离且经过服务端授权的账号删除流程中启用。

    安全问题

    请不要在公开 Issue 中提交个人数据或安全漏洞。安全问题请通过仓库的私密漏洞报告功能提交。

    @@ -93,7 +101,7 @@

    安全问题

    Shadow Mate Privacy Policy

    -

    Product: Shadow Mate · Current version: privacy-v1 · Effective date: August 12, 2026

    +

    Product: Shadow Mate · Current version: privacy-v2 · Effective date: August 20, 2026

    Shadow Mate is designed for families and learners who may be minors. We follow data minimization by default. This policy describes the technical behavior of the current Dogfooding and small-scale beta version.

    @@ -102,40 +110,48 @@

    What We Collect and Store

  • The parent's email address used to sign in, handled by Supabase Auth.
  • The family space name.
  • The learner's display name and grade. We recommend using a nickname instead of a real name.
  • -
  • Learning activity such as check-ins, points, bookshelf items, and reading logs.
  • +
  • Learning state such as check-ins, points, bookshelf and reading logs, rewards, and redemptions.
  • +
  • Private backend activity events: product identifier, random event ID, internal household and learner identifiers, an allowlisted event type, occurrence and server receipt timestamps, household timezone, client version, actor user ID, and a small typed and length-bounded set of enum, boolean, or count diagnostic fields.
  • The current version does not require a child to provide an email address, phone number, birthday, school, address, precise location, or photo. It does not contain advertising.

    +

    Backend activity events are used only for beta funnel, retention, and sustained-use metrics, and for sync and local text-to-speech diagnostics. Event types are limited to household activation, learner creation, core activation, effective growth activity, retention qualification, reward redemption, sync failure, and local text-to-speech failure. They do not accept free text, full error stacks, page URLs, email addresses, learner display names, learning content, or speech text. Product facts remain in the point, reward, and redemption records.

    -

    Analytics and Reading Aloud

    -

    The app uses Vercel Web Analytics for anonymous, aggregated page-visit data. It currently sends no custom events and does not send learning status, email addresses, or learner display names as custom Analytics fields. Page-visit data may include the time, page URL, referrer, device, browser, operating system, and approximate location. See Vercel Web Analytics Privacy and Compliance for details.

    +

    Vercel Analytics, Backend Activity Events, and Reading Aloud

    +

    The app uses Vercel Web Analytics for anonymous, aggregated page-visit data. It currently sends no custom events to Vercel Analytics and does not send learning state, email addresses, or learner display names as custom Analytics fields. Page-visit data may include time, page URL, referrer, device, browser, operating system, and approximate location. See Vercel Web Analytics Privacy and Compliance for details.

    +

    “No custom events sent to Vercel Analytics” does not mean the Shadow Mate backend records no events. The private backend activity events described above are stored in Shadow Mate's private Supabase schema and have separate access and retention rules.

    The local Piper text-to-speech feature does not send text to Shadow Mate servers in the current version. Shadow Mate does not record microphone audio.

    Parental Consent and Learner Profiles

    -

    A learner is not an independent login account. Before creating or adding a learner, the signed-in user must confirm that they are the child's parent or guardian and read this policy. Supabase stores the household ID, authenticated user ID, consent type learner_data_processing, policy version privacy-v1, and a database-generated consent timestamp. The client cannot change the timestamp, and the public API cannot create a new learner profile without a consent record.

    +

    A learner is not an independent login account. Before creating or adding a learner, the signed-in user must confirm that they are the child's parent or guardian and read this policy. Supabase stores the household ID, authenticated user ID, consent type learner_data_processing, policy version, and a database-generated consent timestamp. The client cannot change the timestamp, and the public API cannot create a new learner profile without a valid consent record.

    +

    privacy-v2 expressly documents private backend activity events, their 180-day retention, and the export boundary. New consent records use privacy-v2. Existing privacy-v1 records remain valid historical consent; their original timestamps are not rewritten, and this policy update alone does not require a new confirmation.

    Where Data Is Stored and Who Can Access It

    • Offline learning state is stored in the browser storage on the current device.
    • After sign-in, household and learning state sync to the Supabase database configured for the project.
    • The sign-in session uses browser session storage; a new browser session may require signing in again.
    • -
    • Learning data is isolated by household. Signed-in users must still pass household membership checks and Row Level Security (RLS) to read or change records.
    • +
    • Learning data is isolated by household. Signed-in users must still pass project_id = 'shadow-mate', household membership, and Row Level Security (RLS) checks.
    • +
    • Backend activity events and beta batch records are in a private schema. Browsers, ordinary authenticated users, and household members cannot directly read those tables or execute aggregation or cleanup functions. An owner or guardian may write only allowlisted event fields through a guarded RPC.

    Local cache normally remains after the browser closes until the site's browser data is cleared, a private/incognito session is used, the browser or operating system cleans it up, or the site domain changes. After synchronization, the cloud household record is the source for cross-device recovery.

    Deletion, Export, and Retention

    • “Clear local data” removes only offline learning records from the current device and signs the user out; it does not delete cloud records.
    • -
    • The household owner can export the full household data as JSON or use “Delete all household data” to remove the current household's cloud records, clear local data, and sign out.
    • +
    • The household JSON export includes household ID/name, learner profiles and state, consent records, and Growth Loop point items, learner bindings, rewards, point ledger, and redemption records.
    • +
    • The export excludes the Supabase Auth identity or email, session/device data, Vercel Analytics data, raw private backend activity events in private.learning_activity_events, and private beta batch records in private.learning_beta_batches.
    • +
    • Raw backend activity events are retained for 180 days from server received_at; trusted cleanup deletes records older than 180 days. Deleting a household or learner cascades to its activity events without waiting for the retention period.
    • +
    • Beta batch records cascade when the household is deleted. “Delete all household data” covers household business data, consent records, related activity events, and beta batch records, then clears local data and signs the user out.
    • Deleting household data in the shared Supabase project does not delete the Supabase Auth identity; the user can sign in again with the same email.
    • When a learner or household is deleted, its consent record is deleted through the household-level cascade.
    -

    There is currently no self-service flow to withdraw consent while keeping the household. Data retention periods and requests for deletion, export, correction, or withdrawal will be updated as the operating region and applicable laws require.

    +

    There is currently no self-service flow to withdraw consent while keeping the household. Identity deletion is available only in a dedicated, isolated, server-authorized account deletion flow.

    Security Issues

    Do not submit personal data or security vulnerabilities in public issues. Please use the repository's private vulnerability reporting process for security issues.

    -
    影伴 Shadow Mate · privacy-v1 · 2026
    +
    影伴 Shadow Mate · privacy-v2 · 2026
    diff --git a/scripts/check.mjs b/scripts/check.mjs index efcf6ad..ebc3750 100644 --- a/scripts/check.mjs +++ b/scripts/check.mjs @@ -20,6 +20,9 @@ const requiredFiles = [ ".vercelignore", "supabase/tests/learning_rls_test.sql", "supabase/migrations/20260811202411_child_privacy_consent.sql", + "supabase/migrations/20260820120000_growth_loop_beta_batches.sql", + "supabase/migrations/20260820121000_growth_loop_funnel_aggregation.sql", + "supabase/migrations/20260820122000_growth_loop_activity_cleanup.sql", ]; for (const file of requiredFiles) { @@ -104,6 +107,51 @@ for (const marker of [ if (/https:\/\/esm\.sh/i.test(cloud)) { throw new Error("Runtime CDN imports are not allowed"); } +for (const marker of [ + 'export const PRIVACY_POLICY_VERSION = "privacy-v2"', + 'Object.freeze(["privacy-v1", PRIVACY_POLICY_VERSION])', + '.in("policy_version", ACCEPTED_PRIVACY_POLICY_VERSIONS)', +]) { + if (!cloud.includes(marker)) throw new Error(`cloud.js is missing privacy migration step: ${marker}`); +} + +const privacyMarkdown = await readFile("PRIVACY.md", "utf8"); +const privacyHtml = await readFile("privacy-policy.html", "utf8"); +for (const [source, content, markers] of [ + ["PRIVACY.md", privacyMarkdown, [ + "privacy-v2", + "2026 年 8 月 20 日", + "August 20, 2026", + "180 天", + "180 days", + "private.learning_activity_events", + "private.learning_beta_batches", + "不向 Vercel Analytics 发送自定义事件", + "No custom events sent to Vercel Analytics", + "已有 `privacy-v1` 记录继续作为有效的历史同意", + "Existing `privacy-v1` records remain valid historical consent", + ]], + ["privacy-policy.html", privacyHtml, [ + "当前版本:privacy-v2", + "Current version: privacy-v2", + "2026 年 8 月 20 日", + "August 20, 2026", + "180 天", + "180 days", + "private.learning_activity_events", + "private.learning_beta_batches", + "不向 Vercel Analytics 发送自定义事件", + "No custom events sent to Vercel Analytics", + "已有 privacy-v1 记录继续作为有效的历史同意", + "Existing privacy-v1 records remain valid historical consent", + ]], +]) { + for (const marker of markers) { + if (!content.includes(marker)) { + throw new Error(`${source} is missing privacy-v2 consistency marker: ${marker}`); + } + } +} const piper = await readFile("src/piper-tts.js", "utf8"); for (const marker of [ @@ -115,6 +163,16 @@ for (const marker of [ const migrationDir = "supabase/migrations"; const migrations = (await readdir(migrationDir)).filter((name) => name.endsWith(".sql")).sort(); +const migrationsByVersion = new Map(); +for (const migration of migrations) { + const version = migration.match(/^(\d{14})_/)?.[1]; + if (!version) throw new Error(`Supabase migration is missing a 14-digit version: ${migration}`); + const existing = migrationsByVersion.get(version); + if (existing) { + throw new Error(`Supabase migration version ${version} is duplicated by ${existing} and ${migration}`); + } + migrationsByVersion.set(version, migration); +} const registryMigrationName = migrations.find((name) => name.endsWith("_projects_registry_compat.sql")); const registryRestrictionName = migrations.find((name) => name.endsWith("_restrict_project_registry_access.sql") diff --git a/src/cloud.js b/src/cloud.js index 043314b..1299208 100644 --- a/src/cloud.js +++ b/src/cloud.js @@ -15,7 +15,8 @@ const AUTH_PRODUCT_NAME = "影伴 Shadow Mate"; const ACTIVE_PROFILE_KEY = `${PRODUCT_ID.replaceAll("-", "_")}_active_profile`; const PASSWORD_PROMPT_KEY = `${PRODUCT_ID.replaceAll("-", "_")}_password_prompt_skipped`; export const GUARDIAN_CONSENT_TYPE = "learner_data_processing"; -export const PRIVACY_POLICY_VERSION = "privacy-v1"; +export const PRIVACY_POLICY_VERSION = "privacy-v2"; +const ACCEPTED_PRIVACY_POLICY_VERSIONS = Object.freeze(["privacy-v1", PRIVACY_POLICY_VERSION]); const PRIVACY_POLICY_URL = "https://sm.shadow.wang/privacy"; const MAX_CONFLICT_RETRIES = 2; const CONFLICT_RETRY_DELAY_MS = 200; @@ -881,7 +882,7 @@ function renderAccount() {

    家庭空间统一管理,学习记录按孩子分别同步。切换孩子后会加载对应的学习记录。

    ${choices}
    - ${hasGuardianConsent ? '

    家长同意已记录(隐私说明版本 privacy-v1)。

    ' : `

    添加学习者前,需要由家长或监护人确认隐私说明。

    ${guardianConsentField()}`} + ${hasGuardianConsent ? '

    家长同意已记录(当前或兼容的历史隐私说明版本)。

    ' : `

    添加学习者前,需要由家长或监护人确认隐私说明。

    ${guardianConsentField()}`}